""" MSC Tracker API + UI — front end publico para gestionar containers y extraer data del worker. Auth: usuarios en SQLite con passwords bcrypt. El admin puede crear/borrar otros users desde el panel. Storage: SQLite (archivo users.db) para users y sessions; JSON para state de containers. Auth methods: - Login web (form) -> cookie de sesion para panel/docs/admin - Header 'X-Api-Key: msc_...' para API scripts en /track/{container} y /llm/context Magic login queda deshabilitado: no hay login sin password por endpoint HTTP. """ import asyncio import base64 import hashlib import ipaddress import json import logging import os import secrets import sqlite3 import subprocess import time from contextlib import asynccontextmanager from datetime import datetime, timezone from pathlib import Path from typing import Any, Dict, List, Optional import bcrypt import requests from fastapi import ( Cookie, Depends, FastAPI, HTTPException, Request, Response, status, ) from fastapi.responses import HTMLResponse, JSONResponse, FileResponse, RedirectResponse, Response from fastapi.staticfiles import StaticFiles from pydantic import BaseModel, Field # --- Paths --- API_DIR = Path(__file__).parent STATE_FILE = API_DIR / "state.json" CONFIG_FILE = API_DIR / "config.json" STATIC_DIR = API_DIR / "static" LOG_FILE = API_DIR / "api.log" DB_FILE = API_DIR / "users.db" # --- Config defaults --- DEFAULT_PORT = 9091 DEFAULT_WORKER_URL = "http://127.0.0.1:9090" SESSION_COOKIE = "msc_session" SESSION_MAX_AGE = 60 * 60 * 24 * 7 # 7 dias SCHEDULER_TICK_SECONDS = 30 POLL_TIMEOUT_SECONDS = 60 LOGIN_RATE_WINDOW_SECONDS = 15 * 60 LOGIN_RATE_BLOCK_SECONDS = 15 * 60 LOGIN_MAX_FAILED_ATTEMPTS = 8 API_TOKEN_PREFIX = "msc_" # --- Logging --- logging.basicConfig( level=logging.INFO, format="%(asctime)s %(levelname)s %(name)s: %(message)s", handlers=[logging.FileHandler(LOG_FILE), logging.StreamHandler()], ) log = logging.getLogger("api") # --- Config / State --- def load_config() -> Dict[str, Any]: if not CONFIG_FILE.exists(): return { "bootstrap_password": None, "port": DEFAULT_PORT, "worker_url": DEFAULT_WORKER_URL, } return json.loads(CONFIG_FILE.read_text()) def load_state() -> Dict[str, Any]: if not STATE_FILE.exists(): return {"containers": []} return json.loads(STATE_FILE.read_text()) def save_state(state: Dict[str, Any]) -> None: tmp = STATE_FILE.with_suffix(".tmp") tmp.write_text(json.dumps(state, indent=2, default=str)) tmp.replace(STATE_FILE) CONFIG = load_config() BOOTSTRAP_PASSWORD = CONFIG.get("bootstrap_password") or os.environ.get("MSC_BOOTSTRAP_PASSWORD") PORT = int(CONFIG.get("port", DEFAULT_PORT)) WORKER_URL = CONFIG.get("worker_url", DEFAULT_WORKER_URL) SALT_ROUNDS = 12 # bcrypt cost LOGIN_FAILURES: Dict[str, List[float]] = {} LOGIN_BLOCKED_UNTIL: Dict[str, float] = {} # --- DB --- def get_db() -> sqlite3.Connection: conn = sqlite3.connect(DB_FILE, isolation_level=None) # autocommit conn.row_factory = sqlite3.Row conn.execute("PRAGMA journal_mode=WAL") conn.execute("PRAGMA foreign_keys=ON") return conn def init_db() -> None: """Crea las tablas y el admin bootstrap si está vacía.""" conn = get_db() conn.executescript(""" CREATE TABLE IF NOT EXISTS users ( username TEXT PRIMARY KEY, password_hash TEXT NOT NULL, role TEXT NOT NULL DEFAULT 'user', created_at TEXT NOT NULL, last_login TEXT ); CREATE TABLE IF NOT EXISTS sessions ( token TEXT PRIMARY KEY, username TEXT NOT NULL, created_at REAL NOT NULL, expires_at REAL NOT NULL, FOREIGN KEY (username) REFERENCES users(username) ON DELETE CASCADE ); CREATE INDEX IF NOT EXISTS idx_sessions_expires ON sessions(expires_at); CREATE TABLE IF NOT EXISTS api_tokens ( id INTEGER PRIMARY KEY AUTOINCREMENT, token_hash TEXT NOT NULL UNIQUE, token_prefix TEXT NOT NULL, username TEXT NOT NULL, label TEXT NOT NULL, scope TEXT NOT NULL DEFAULT 'track', created_at TEXT NOT NULL, last_used_at TEXT, revoked_at TEXT, FOREIGN KEY (username) REFERENCES users(username) ON DELETE CASCADE ); CREATE INDEX IF NOT EXISTS idx_api_tokens_user ON api_tokens(username); CREATE INDEX IF NOT EXISTS idx_api_tokens_revoked ON api_tokens(revoked_at); """) # Bootstrap admin si la tabla está vacía. Nunca crear admin/admin por default. cur = conn.execute("SELECT COUNT(*) AS n FROM users") if cur.fetchone()["n"] == 0: if not BOOTSTRAP_PASSWORD: conn.close() raise RuntimeError("users.db vacío y MSC_BOOTSTRAP_PASSWORD/bootstrap_password no configurado") pw_hash = bcrypt.hashpw(BOOTSTRAP_PASSWORD.encode(), bcrypt.gensalt(SALT_ROUNDS)).decode() conn.execute( "INSERT INTO users (username, password_hash, role, created_at) VALUES (?, ?, 'admin', ?)", ("admin", pw_hash, datetime.now(timezone.utc).isoformat()), ) log.info("bootstrap admin user created") conn.close() def cleanup_sessions() -> None: conn = get_db() conn.execute("DELETE FROM sessions WHERE expires_at < ?", (time.time(),)) conn.close() # Init DB on import init_db() # --- Auth helpers --- def make_session_token() -> str: return secrets.token_urlsafe(32) def hash_password(password: str) -> str: return bcrypt.hashpw(password.encode(), bcrypt.gensalt(SALT_ROUNDS)).decode() def verify_password(username: str, password: str) -> Optional[str]: """Devuelve el role si el user+pass son válidos, None si no.""" conn = get_db() row = conn.execute( "SELECT password_hash, role FROM users WHERE username = ?", (username,) ).fetchone() if not row: conn.close() return None if not bcrypt.checkpw(password.encode(), row["password_hash"].encode()): conn.close() return None conn.execute( "UPDATE users SET last_login = ? WHERE username = ?", (datetime.now(timezone.utc).isoformat(), username), ) conn.close() return row["role"] def create_session(username: str) -> str: token = make_session_token() now = time.time() conn = get_db() conn.execute( "INSERT INTO sessions (token, username, created_at, expires_at) VALUES (?, ?, ?, ?)", (token, username, now, now + SESSION_MAX_AGE), ) conn.close() return token def get_session_user(token: str) -> Optional[Dict[str, str]]: if not token: return None conn = get_db() row = conn.execute( "SELECT u.username, u.role, s.expires_at FROM sessions s JOIN users u ON s.username = u.username WHERE s.token = ?", (token,), ).fetchone() conn.close() if not row: return None if row["expires_at"] < time.time(): return None return {"username": row["username"], "role": row["role"]} def delete_session(token: str) -> None: if not token: return conn = get_db() conn.execute("DELETE FROM sessions WHERE token = ?", (token,)) conn.close() def hash_api_token(token: str) -> str: return hashlib.sha256(token.encode()).hexdigest() def make_api_token() -> str: return API_TOKEN_PREFIX + secrets.token_urlsafe(32) def verify_api_token(token: str) -> Optional[Dict[str, str]]: if not token.startswith(API_TOKEN_PREFIX): return None conn = get_db() row = conn.execute( """ SELECT t.id, t.username, t.scope, u.role FROM api_tokens t JOIN users u ON u.username = t.username WHERE t.token_hash = ? AND t.revoked_at IS NULL """, (hash_api_token(token),), ).fetchone() if not row: conn.close() return None conn.execute( "UPDATE api_tokens SET last_used_at = ? WHERE id = ?", (datetime.now(timezone.utc).isoformat(), row["id"]), ) conn.close() return { "username": row["username"], "role": row["role"], "scope": row["scope"], "_api_token": "true", } def parse_scopes(scope: str) -> set[str]: return {s.strip() for s in (scope or "").split(",") if s.strip()} def token_has_scope(token_user: Optional[Dict[str, str]], required_scope: str) -> bool: if not token_user: return False return required_scope in parse_scopes(token_user.get("scope", "")) def require_api_token_scope(request: Request, required_scope: str) -> Dict[str, str]: api_key = request.headers.get("X-Api-Key", "") token_user = verify_api_token(api_key) if api_key else None if not token_has_scope(token_user, required_scope): raise HTTPException(status_code=401, detail=f"API token invalido o sin scope {required_scope}") token_user["_auth_method"] = "api_token" return token_user def client_ip(request: Request) -> str: # Uvicorn recibe el IP real desde Tailscale Funnel en request.client. # No confiamos en X-Forwarded-For porque un cliente público podría spoofearlo. return request.client.host if request.client else "unknown" def cookie_secure_for_request(request: Request) -> bool: """ El panel normal por Tailscale Serve usa HTTPS y debe mantener Secure=True. El acceso alternativo por IP Tailscale usa http://100.87.116.90/msc-api vía nginx dentro de la tailnet; si dejamos Secure=True, los navegadores ignoran la cookie. """ host = (request.headers.get("host") or "").split(":", 1)[0] if host in {"100.87.116.90", "127.0.0.1", "localhost"}: return False return True def login_rate_keys(request: Request, username: str) -> List[str]: ip = client_ip(request) user = (username or "").strip().lower() return [f"ip:{ip}", f"ip-user:{ip}:{user}"] def check_login_rate_limit(keys: List[str]) -> None: now = time.time() for key in keys: blocked_until = LOGIN_BLOCKED_UNTIL.get(key, 0) if blocked_until > now: wait = max(1, int(blocked_until - now)) raise HTTPException( status_code=429, detail="demasiados intentos de login; probá de nuevo en unos minutos", headers={"Retry-After": str(wait)}, ) if blocked_until: LOGIN_BLOCKED_UNTIL.pop(key, None) def record_login_failure(keys: List[str]) -> None: now = time.time() cutoff = now - LOGIN_RATE_WINDOW_SECONDS for key in keys: attempts = [ts for ts in LOGIN_FAILURES.get(key, []) if ts >= cutoff] attempts.append(now) LOGIN_FAILURES[key] = attempts if len(attempts) >= LOGIN_MAX_FAILED_ATTEMPTS: LOGIN_BLOCKED_UNTIL[key] = now + LOGIN_RATE_BLOCK_SECONDS log.warning("login rate-limit activado para %s", key) def record_login_success(keys: List[str]) -> None: for key in keys: LOGIN_FAILURES.pop(key, None) LOGIN_BLOCKED_UNTIL.pop(key, None) def require_auth( request: Request, x_api_key: Optional[str] = None, cookie_session: Optional[str] = Cookie(default=None, alias=SESSION_COOKIE), ): """Valida acceso por API token acotado o cookie de sesion.""" # 1) API token para scripts: X-Api-Key: msc_... api_key = request.headers.get("X-Api-Key") if api_key: token_user = verify_api_token(api_key) # Tokens acotados por scope. /track/{container} requiere scope track. if token_has_scope(token_user, "track") and request.url.path.startswith("/track/"): token_user["_auth_method"] = "api_token" return token_user # /llm/context requiere scope context y no expone endpoints admin. if token_has_scope(token_user, "context") and request.url.path == "/llm/context": token_user["_auth_method"] = "api_token" return token_user # 2) Cookie del panel/login web if cookie_session: sess = get_session_user(cookie_session) if sess: sess["_auth_method"] = "cookie" return sess raise HTTPException(status_code=401, detail="credenciales invalidas") def require_admin(user: dict = Depends(require_auth)): if user.get("role") != "admin": raise HTTPException(403, "se requiere admin") return user # --- Schemas --- class LoginRequest(BaseModel): username: str = Field(..., min_length=1, max_length=64) password: str = Field(..., min_length=1, max_length=200) class ContainerIn(BaseModel): container: str = Field(..., min_length=4, max_length=20, pattern=r"^[A-Z0-9]+$") interval_minutes: int = Field(360, ge=5, le=1440) class ContainerPatch(BaseModel): interval_minutes: Optional[int] = Field(None, ge=5, le=1440) class UserCreate(BaseModel): username: str = Field(..., min_length=1, max_length=64, pattern=r"^[a-zA-Z0-9_.-]+$") password: str = Field(..., min_length=6, max_length=200) role: str = Field("user", pattern=r"^(admin|user)$") class UserPasswordChange(BaseModel): old_password: str = Field(..., min_length=1, max_length=200) new_password: str = Field(..., min_length=6, max_length=200) class AdminPasswordReset(BaseModel): new_password: str = Field(..., min_length=6, max_length=200) class ApiTokenCreate(BaseModel): username: str = Field(..., min_length=1, max_length=64, pattern=r"^[a-zA-Z0-9_.-]+$") label: str = Field("cron", min_length=1, max_length=80) scope: str = Field("track", pattern=r"^(track|context|track,context|context,track)$") # --- Worker client --- def call_worker_track(container: str) -> Dict[str, Any]: r = requests.post( f"{WORKER_URL}/track", json={"container": container}, timeout=POLL_TIMEOUT_SECONDS, ) if r.status_code == 200: return r.json() return {"error": True, "status": r.status_code, "detail": r.text[:500]} def call_worker_track_full(container: str, fresh: bool = True) -> Dict[str, Any]: r = requests.post( f"{WORKER_URL}/track_full", json={"container": container, "fresh": fresh}, timeout=POLL_TIMEOUT_SECONDS * 2, ) if r.status_code == 200: return r.json() try: d = r.json() return {"error": True, "status": r.status_code, "detail": d.get("detail", d)} except Exception: return {"error": True, "status": r.status_code, "detail": r.text[:500]} def call_worker_health() -> Optional[Dict[str, Any]]: try: r = requests.get(f"{WORKER_URL}/health", timeout=5) return r.json() if r.status_code == 200 else None except Exception: return None # --- Scheduler --- def should_poll(container: Dict[str, Any], now_ts: float) -> bool: last = container.get("last_poll_ts") if last is None: return True interval_s = container.get("interval_minutes", 360) * 60 return (now_ts - last) >= interval_s async def scheduler_loop(): log.info("scheduler arrancado") while True: try: await asyncio.sleep(SCHEDULER_TICK_SECONDS) cleanup_sessions() state = load_state() changed = False now_ts = time.time() for c in state["containers"]: if should_poll(c, now_ts): log.info(f"poll scheduled: {c['container']}") try: data = await asyncio.to_thread(call_worker_track, c["container"]) c["last_poll_ts"] = now_ts c["last_poll_at"] = datetime.now(timezone.utc).isoformat() if "error" in data: c["last_error"] = data.get("detail", "error") c["last_status"] = data.get("status", "error") else: c["last_data"] = data c["last_error"] = None c["last_status"] = 200 changed = True except Exception as e: log.exception(f"error polling {c['container']}") c["last_error"] = str(e)[:300] c["last_status"] = "exception" changed = True if changed: save_state(state) except asyncio.CancelledError: log.info("scheduler cancelado") break except Exception: log.exception("error en el scheduler loop") # --- App --- @asynccontextmanager async def lifespan(app: FastAPI): log.info(f"arrancando API en puerto {PORT}") task = asyncio.create_task(scheduler_loop()) yield task.cancel() try: await task except asyncio.CancelledError: pass app = FastAPI(title="MSC Tracker API", version="2.0.0", lifespan=lifespan, docs_url=None, redoc_url=None, openapi_url=None) # --- Network access policy / static assets --- TAILSCALE_NET = ipaddress.ip_network("100.64.0.0/10") def is_tailnet_request(request: Request) -> bool: host = request.client.host if request.client else "" try: ip = ipaddress.ip_address(host.split("%")[0]) except ValueError: return False return ip.is_loopback or ip in TAILSCALE_NET def require_tailnet_request(request: Request) -> None: if not is_tailnet_request(request): raise HTTPException(403, "solo disponible via Tailscale") @app.get("/static/{asset_path:path}") def static_asset(asset_path: str): """Sirve solo assets seguros. No exponemos HTML/docs internos via /static.""" allowed_suffixes = {".css", ".js", ".png", ".jpg", ".jpeg", ".gif", ".webp", ".ico"} base = STATIC_DIR.resolve() p = (STATIC_DIR / asset_path).resolve() if base not in p.parents or p.suffix.lower() not in allowed_suffixes or not p.is_file(): raise HTTPException(404, "asset no encontrado") return FileResponse(p) # --- Pages (server-side rendered con nav sticky) --- import html as _html def h(s): if s is None: return "" return _html.escape(str(s)) def render_nav(current: str, is_admin: bool, username: str = "") -> str: # Paths absolutos al prefijo /msc-api/ para que anden desde cualquier nivel B = "/msc-api" def link(key, label, path, sub=False): cls = " class=\"active\"" if current == key else "" if sub: return f'{label}' return f'{label}' # Panel (con dropdown de secciones) panel = ( '
' + link("panel", "Panel", f"{B}/#top", sub=True) + '' + '
' ) # Docs (con dropdown) docs_dropdown_active = current.startswith("docs") or current == "docs" docs = ( '
' + link("docs", "Docs", f"{B}/docs", sub=True) + ('' ) # Usuarios (solo admin, dropdown con acciones) if is_admin: users = ( '
' + 'Usuarios' + '' + '
' ) else: users = "" # Right side (user info o login) if username: right = ( '' ) else: right = 'Login' return ( '' ) def render_page(title: str, current: str, body: str, username: str = "", is_admin: bool = False) -> str: return ( '' '' '' f'{h(title)} - MSC Tracker' '' '' + render_nav(current, is_admin, username) + '
' + body + '
' + '' + '' + '' + '' ) def get_user_from_cookie(request: Request) -> dict: token = request.cookies.get(SESSION_COOKIE) if not token: return {"username": None, "role": None} sess = get_session_user(token) if not sess: return {"username": None, "role": None} return sess @app.get("/login", response_class=HTMLResponse) def login_page(): return HTMLResponse((STATIC_DIR / "login.html").read_text()) @app.get("/", response_class=HTMLResponse) def index(request: Request): require_tailnet_request(request) user = get_user_from_cookie(request) if not user["username"]: return RedirectResponse(url=CONFIG.get("base_path", "/msc-api") + "/login", status_code=302) body = (STATIC_DIR / "index.html").read_text() import re m = re.search(r"]*>(.*?)", body, re.DOTALL) if m: body = m.group(1) return HTMLResponse(render_page("Panel", "panel", body, user["username"], user["role"] == "admin")) @app.get("/docs", response_class=HTMLResponse) def docs_index(request: Request): user = get_user_from_cookie(request) if not user["username"]: return RedirectResponse(url=CONFIG.get("base_path", "/msc-api") + "/login", status_code=302) body = ( '

Centro de documentacion

' '

Guia completa de uso del MSC Tracker. Todo lo que necesitas saber para usar el panel y la API.

' '' ) return HTMLResponse(render_page("Docs", "docs", body, user["username"] or "", user["role"] == "admin")) def _read_doc(name: str) -> str: p = STATIC_DIR / "doc" / name if not p.exists(): return f"

{h(name)}

En construccion. Volve mas tarde.

" return p.read_text() @app.get("/docs/{name}", response_class=HTMLResponse) def doc_page(name: str, request: Request): user = get_user_from_cookie(request) if not user["username"]: return RedirectResponse(url=CONFIG.get("base_path", "/msc-api") + "/login", status_code=302) allowed = {"start", "andres", "api", "errors", "ops"} if name not in allowed: return HTMLResponse( render_page("404", "docs", "

404

Esa pagina no existe. Volve a docs.

", user["username"] or ""), status_code=404, ) body = _read_doc(name + ".html") return HTMLResponse(render_page(name.capitalize(), "docs", body, user["username"] or "", user["role"] == "admin")) @app.get("/users", response_class=HTMLResponse) def users_page(request: Request): require_tailnet_request(request) user = get_user_from_cookie(request) if not user["username"]: return RedirectResponse(url=CONFIG.get("base_path", "/msc-api") + "/login", status_code=302) if user["role"] != "admin": return HTMLResponse( render_page("Sin permisos", "users", "

Acceso denegado

Solo admins pueden ver esta seccion.

", user["username"], False), status_code=403, ) body = (STATIC_DIR / "users.html").read_text() return HTMLResponse(render_page("Usuarios", "users", body, user["username"], True)) @app.get("/logout", response_class=HTMLResponse) def logout_page(response: Response, request: Request): token = request.cookies.get(SESSION_COOKIE) if token: delete_session(token) r = RedirectResponse(url=CONFIG.get("base_path", "/msc-api") + "/login", status_code=302) r.delete_cookie(SESSION_COOKIE) return r # --- Auth endpoints --- @app.post("/api/login") def login(req: LoginRequest, response: Response, request: Request): rate_keys = login_rate_keys(request, req.username) check_login_rate_limit(rate_keys) role = verify_password(req.username, req.password) if not role: # Mismo mensaje para user inexistente y pass mal, para no filtrar info. # Delay + rate-limit in-memory para frenar brute force básico. record_login_failure(rate_keys) time.sleep(1) raise HTTPException(status_code=401, detail="credenciales invalidas") record_login_success(rate_keys) token = create_session(req.username) response.set_cookie( key=SESSION_COOKIE, value=token, max_age=SESSION_MAX_AGE, httponly=True, samesite="strict", secure=cookie_secure_for_request(request), ) return {"ok": True, "username": req.username, "role": role, "expires_in": SESSION_MAX_AGE} @app.post("/api/logout") def logout( response: Response, msc_session: Optional[str] = Cookie(default=None, alias=SESSION_COOKIE), ): if msc_session: delete_session(msc_session) response.delete_cookie(SESSION_COOKIE) return {"ok": True} @app.get("/api/me") def me(user: dict = Depends(require_auth)): return {"username": user["username"], "role": user["role"]} # Magic login deshabilitado: no mantenemos endpoints HTTP que logueen sin password. @app.get("/api/magic-login") def magic_login(): raise HTTPException(404, "magic login deshabilitado") # --- User management (admin only) --- @app.get("/api/admin/users") def list_users(request: Request, _: dict = Depends(require_admin)): require_tailnet_request(request) conn = get_db() rows = conn.execute( "SELECT username, role, created_at, last_login FROM users ORDER BY created_at" ).fetchall() conn.close() return [dict(r) for r in rows] @app.post("/api/admin/users") def create_user(u: UserCreate, request: Request, creator: dict = Depends(require_admin)): require_tailnet_request(request) conn = get_db() if conn.execute("SELECT 1 FROM users WHERE username = ?", (u.username,)).fetchone(): conn.close() raise HTTPException(409, f"user {u.username} ya existe") pw_hash = hash_password(u.password) conn.execute( "INSERT INTO users (username, password_hash, role, created_at) VALUES (?, ?, ?, ?)", (u.username, pw_hash, u.role, datetime.now(timezone.utc).isoformat()), ) conn.close() log.info(f"admin {creator['username']} creó user {u.username} ({u.role})") return {"ok": True, "username": u.username, "role": u.role} @app.delete("/api/admin/users/{username}") def delete_user(username: str, request: Request, deleter: dict = Depends(require_admin)): require_tailnet_request(request) if username == "admin": raise HTTPException(400, "no se puede borrar el admin") if username == deleter["username"]: raise HTTPException(400, "no te podés borrar a vos mismo") conn = get_db() cur = conn.execute("DELETE FROM users WHERE username = ?", (username,)) if cur.rowcount == 0: conn.close() raise HTTPException(404, f"no existe {username}") conn.close() log.info(f"admin {deleter['username']} borró user {username}") return {"ok": True, "deleted": username} @app.post("/api/admin/users/{username}/reset-password") def admin_reset_password( username: str, body: AdminPasswordReset, request: Request, _: dict = Depends(require_admin) ): require_tailnet_request(request) conn = get_db() if not conn.execute("SELECT 1 FROM users WHERE username = ?", (username,)).fetchone(): conn.close() raise HTTPException(404, f"no existe {username}") pw_hash = hash_password(body.new_password) conn.execute("UPDATE users SET password_hash = ? WHERE username = ?", (pw_hash, username)) conn.close() return {"ok": True, "username": username} # --- API tokens (admin only, solo Tailscale) --- @app.get("/api/admin/tokens") def list_api_tokens(request: Request, _: dict = Depends(require_admin)): require_tailnet_request(request) conn = get_db() rows = conn.execute( """ SELECT id, token_prefix, username, label, scope, created_at, last_used_at, revoked_at FROM api_tokens ORDER BY created_at DESC """ ).fetchall() conn.close() return [dict(r) for r in rows] @app.post("/api/admin/tokens") def create_api_token(body: ApiTokenCreate, request: Request, admin: dict = Depends(require_admin)): require_tailnet_request(request) conn = get_db() if not conn.execute("SELECT 1 FROM users WHERE username = ?", (body.username,)).fetchone(): conn.close() raise HTTPException(404, f"no existe {body.username}") token = make_api_token() token_prefix = token[:12] now = datetime.now(timezone.utc).isoformat() scope = ",".join(sorted(parse_scopes(body.scope))) cur = conn.execute( """ INSERT INTO api_tokens (token_hash, token_prefix, username, label, scope, created_at) VALUES (?, ?, ?, ?, ?, ?) """, (hash_api_token(token), token_prefix, body.username, body.label, scope, now), ) conn.close() log.info("admin %s creó API token %s para %s (%s, scope=%s)", admin["username"], token_prefix, body.username, body.label, scope) return { "ok": True, "id": cur.lastrowid, "username": body.username, "label": body.label, "scope": scope, "token_prefix": token_prefix, "token": token, # se muestra una sola vez } @app.delete("/api/admin/tokens/{token_id}") def revoke_api_token(token_id: int, request: Request, admin: dict = Depends(require_admin)): require_tailnet_request(request) conn = get_db() now = datetime.now(timezone.utc).isoformat() cur = conn.execute( "UPDATE api_tokens SET revoked_at = ? WHERE id = ? AND revoked_at IS NULL", (now, token_id), ) conn.close() if cur.rowcount == 0: raise HTTPException(404, "token no existe o ya estaba revocado") log.info("admin %s revocó API token id=%s", admin["username"], token_id) return {"ok": True, "revoked": token_id} # --- Self-service: change own password --- @app.post("/api/me/password") def change_my_password( body: UserPasswordChange, user: dict = Depends(require_auth) ): if not verify_password(user["username"], body.old_password): time.sleep(1) raise HTTPException(401, "password actual incorrecto") conn = get_db() pw_hash = hash_password(body.new_password) conn.execute("UPDATE users SET password_hash = ? WHERE username = ?", (pw_hash, user["username"])) # Invalidar otras sesiones del mismo user conn.execute("DELETE FROM sessions WHERE username = ?", (user["username"],)) conn.close() return {"ok": True} # --- Containers API (igual que antes) --- @app.get("/api/containers") def list_containers(request: Request, _: dict = Depends(require_auth)): require_tailnet_request(request) state = load_state() return state["containers"] @app.post("/api/containers") def add_container(c: ContainerIn, request: Request, _: dict = Depends(require_auth)): require_tailnet_request(request) state = load_state() if any(x["container"] == c.container for x in state["containers"]): raise HTTPException(409, f"el container {c.container} ya esta en la lista") state["containers"].append({ "container": c.container, "interval_minutes": c.interval_minutes, "added_at": datetime.now(timezone.utc).isoformat(), "last_poll_ts": None, "last_poll_at": None, "last_data": None, "last_status": None, "last_error": None, }) save_state(state) return state["containers"][-1] @app.delete("/api/containers/{container}") def remove_container(container: str, request: Request, _: dict = Depends(require_auth)): require_tailnet_request(request) state = load_state() before = len(state["containers"]) state["containers"] = [x for x in state["containers"] if x["container"] != container] if len(state["containers"]) == before: raise HTTPException(404, f"no existe el container {container}") save_state(state) return {"ok": True, "remaining": len(state["containers"])} @app.patch("/api/containers/{container}") def patch_container(container: str, patch: ContainerPatch, request: Request, _: dict = Depends(require_auth)): require_tailnet_request(request) state = load_state() for c in state["containers"]: if c["container"] == container: if patch.interval_minutes is not None: c["interval_minutes"] = patch.interval_minutes save_state(state) return c raise HTTPException(404, f"no existe el container {container}") @app.post("/api/containers/{container}/track") def track_now(container: str, request: Request, _: dict = Depends(require_auth)): require_tailnet_request(request) state = load_state() target = None for c in state["containers"]: if c["container"] == container: target = c break if not target: raise HTTPException(404, f"no existe el container {container}") log.info(f"track manual: {container}") try: data = call_worker_track(container) now_ts = time.time() target["last_poll_ts"] = now_ts target["last_poll_at"] = datetime.now(timezone.utc).isoformat() if "error" in data: target["last_error"] = data.get("detail", "error") target["last_status"] = data.get("status", "error") else: target["last_data"] = data target["last_error"] = None target["last_status"] = 200 save_state(state) return target except Exception as e: log.exception(f"error en track manual {container}") raise HTTPException(502, f"error llamando al worker: {e}") # --- Worker status / logs --- @app.get("/api/worker/status") def worker_status(request: Request, _: dict = Depends(require_auth)): require_tailnet_request(request) h = call_worker_health() return {"worker_reachable": h is not None, "worker_health": h} @app.get("/api/worker/logs") def worker_logs(request: Request, n: int = 50, _: dict = Depends(require_auth)): require_tailnet_request(request) try: r = subprocess.run( ["journalctl", "-u", "worker.service", "-n", str(n), "--no-pager", "-o", "cat"], capture_output=True, text=True, timeout=10, ) if r.returncode != 0: raise HTTPException(500, f"journalctl fallo: {r.stderr}") return {"lines": r.stdout.splitlines()} except FileNotFoundError: raise HTTPException(500, "journalctl no disponible") except Exception as e: raise HTTPException(500, str(e)) # --- Track on demand (Andres endpoint) --- class TrackRequest(BaseModel): container: str = Field(..., min_length=4, max_length=20, pattern=r"^[A-Z0-9]+$") @app.post("/api/track") def track_once(req: TrackRequest, request: Request, _: dict = Depends(require_auth)): require_tailnet_request(request) try: return call_worker_track(req.container) except Exception as e: raise HTTPException(502, f"error llamando al worker: {e}") @app.get("/track/{container}") def track_path(container: str, _: dict = Depends(require_auth)): container = container.strip().upper() if not (4 <= len(container) <= 20) or not container.isalnum() or not container.isascii(): raise HTTPException(400, f"container invalido: {container!r}") log.info(f"GET /track/{container}") try: return call_worker_track_full(container) except Exception as e: raise HTTPException(502, f"error llamando al worker: {e}") # --- LLM agent context (API token scope: context) --- @app.get("/llm/context") def llm_context(request: Request): user = require_api_token_scope(request, "context") return { "service": "MSC Tracker", "audience": "LLM/agente autorizado para consultar tracking MSC", "base_url": "https://msc-public-proxy.vercel.app/msc-api", "auth": { "type": "api_key", "header": "X-Api-Key", "token_prefix": API_TOKEN_PREFIX, "required_scopes": ["context", "track"], "note": "Usar el mismo header en todas las llamadas. No usar usuario/password ni Basic Auth.", }, "available_actions": [ { "name": "track_container", "description": "Consulta tracking de un container MSC al momento y devuelve JSON parseado + screenshot PNG en base64.", "method": "GET", "path": "/track/{container}", "required_scope": "track", "path_params": { "container": "Codigo alfanumerico del container, por ejemplo MEDU9730548" }, "example_request": { "method": "GET", "url": "https://msc-public-proxy.vercel.app/msc-api/track/MEDU9730548", "headers": {"X-Api-Key": "msc_..."}, }, "response_shape": { "json": "Objeto con container/events/status parseado por el scraper", "screenshot": "PNG base64 opcional", "screenshot_format": "png" }, } ], "operational_rules": [ "No hacer muchas llamadas seguidas contra MSC.", "Si hay varios containers, agregar delay random entre llamadas.", "Si responde 502 o timeout, esperar y reintentar una sola vez.", "Si responde 401, el API key esta mal, revocado o no tiene el scope requerido.", "No guardar ni mostrar el API key en logs, chats publicos ni codigo compartido.", ], "errors": { "400": "Container invalido.", "401": "API key ausente, invalido, revocado o sin scope.", "502": "Error llamando al worker/MSC; reintentar una vez luego de esperar.", }, "owner": {"username": user.get("username"), "role": user.get("role")}, } # --- llms.txt --- @app.get("/llms.txt", response_class=Response) def llms_txt(): raise HTTPException(404, "llms.txt deshabilitado") @app.get("/llms-full.txt", response_class=Response) def llms_full_txt(): raise HTTPException(404, "llms-full.txt deshabilitado") @app.get("/handover.md", response_class=Response) def handover_md(): raise HTTPException(404, "handover deshabilitado") @app.get("/favicon.ico") def favicon(): return Response(status_code=204) # --- Main --- if __name__ == "__main__": import uvicorn log.info(f"arrancando en 0.0.0.0:{PORT}") uvicorn.run(app, host="0.0.0.0", port=PORT, log_level="info")