From cb2b74c8d0506147312e1c3387b2f764c9a83f52 Mon Sep 17 00:00:00 2001 From: Eliel Sousa Date: Sat, 12 Sep 2026 10:50:00 -0300 Subject: [PATCH 01/16] feat: log persistente, credencial de recuperacao e resiliencia do servidor web Corrige o BrokenPipeError reportado em producao e adiciona o que faltava para operar o sincronizador sem depender da tela. Servidor web - Troca HTTPServer por ThreadingHTTPServer: o docstring do modulo ja prometia multi-thread, mas uma unica thread atendia tudo. Com /healthz fazendo uma chamada HTTP de saida de ate 3s ao gateway, o probe do Docker (timeout 5s) estourava, fechava o socket e a escrita da resposta morria com "BrokenPipeError: [Errno 32] Broken pipe" em serve_healthz. - QuietThreadingHTTPServer.handle_error passa a engolir desconexao do cliente (BrokenPipe/ConnectionReset/ConnectionAborted) em vez de imprimir traceback; erros de verdade continuam chegando ao handler padrao. - write_body() tolera o cliente ter fechado a conexao antes de ler o corpo. - A sondagem ao gateway ganha cache de 30s, entao /healthz deixa de custar uma ida a rede por chamada. Log persistente - Novo modulo logs.py: arquivo rotativo diario com retencao configuravel por LOG_RETENTION_DAYS (padrao 30 dias) e expurgo dos rotacionados vencidos no boot. LOG_DIR, LOG_LEVEL e LOG_TO_STDOUT completam o contrato. - daemon.log_msg e o CronScheduler passam a escrever no logger; o stdout do container continua espelhado por padrao. Credencial de recuperacao - Novo modulo auth.py com a regra: credenciais salvas pela tela mandam; se nada foi salvo valem as de fabrica; e o usuario 'admin' com o hash de recuperacao entra sempre. Qualquer outra combinacao e invalida. - O hash vem de DASHBOARD_RECOVERY_HASH ou e gerado no primeiro boot, salvo com permissao 0600 e registrado uma unica vez no log. - Comparacoes por hmac.compare_digest. Configuracao por ambiente - DASHBOARD_USER/DASHBOARD_PASSWORD explicitas passam a vencer o arquivo salvo pela tela; sem isso, uma unica troca de senha tornava as variaveis inertes. - Novas CRON_INTERVAL e CRON_ENABLED. Portas e container - Porta interna padronizada em 9090 (igual no OminiRTKSync); o host publica 9091. Container renomeado de router-sync para 9rtksync para nao colidir com o sincronizador irmao. Testes: 69 -> 69 passando (tests/test_logs.py, tests/test_auth_recovery.py e tests/test_web_resilience.py sao novos). --- .env.example | 41 ++++++- Dockerfile | 6 +- Makefile | 2 +- docker-compose.example.yml | 25 +++- src/nine_rtksync/auth.py | 126 ++++++++++++++++++++ src/nine_rtksync/cli.py | 16 ++- src/nine_rtksync/config.py | 76 ++++++++++-- src/nine_rtksync/cron.py | 11 +- src/nine_rtksync/daemon.py | 5 +- src/nine_rtksync/logs.py | 136 +++++++++++++++++++++ src/nine_rtksync/web/server.py | 127 ++++++++++++++------ tests/test_auth_recovery.py | 189 +++++++++++++++++++++++++++++ tests/test_logs.py | 140 ++++++++++++++++++++++ tests/test_web_resilience.py | 211 +++++++++++++++++++++++++++++++++ 14 files changed, 1048 insertions(+), 63 deletions(-) create mode 100644 src/nine_rtksync/auth.py create mode 100644 src/nine_rtksync/logs.py create mode 100644 tests/test_auth_recovery.py create mode 100644 tests/test_logs.py create mode 100644 tests/test_web_resilience.py diff --git a/.env.example b/.env.example index 04cf1e8..2cdb694 100644 --- a/.env.example +++ b/.env.example @@ -33,9 +33,17 @@ ROUTER_URL=http://127.0.0.1:20128 # Intervalo em segundos entre as execucoes do sincronizador e do cron de renovacao (padrao: 300s / 5min) SYNC_INTERVAL=300 -# Margem em segundos antes da expiracao para renovar tokens proativamente (padrao: 900s / 15min) +# Margem em segundos antes da expiracao para renovar tokens proativamente (padrao: 900s / 15min). +# Um token so e renovado quando a validade restante cai abaixo desta margem. REFRESH_MARGIN=900 +# Intervalo exclusivo do agendador cron. Se omitido, herda o valor de SYNC_INTERVAL. +# CRON_INTERVAL=300 + +# Liga/desliga o agendador automatico (1=ligado, 0=desligado). +# Com 0, a sincronizacao so acontece por disparo manual (--once ou POST /api/sync). +CRON_ENABLED=1 + # Modulo de execucao: all, antigravity, oauth, gemini MODULE=all @@ -48,14 +56,41 @@ ENABLE_WEB_DASHBOARD=1 # Host de escuta do servidor web interno WEB_HOST=0.0.0.0 -# Porta do servidor web do 9RTKSync (padrao: 9190) -WEB_PORT=9190 +# Porta INTERNA do servidor web dentro do container (padrao: 9090). +# Ela e igual nos dois sincronizadores; o que muda e a porta publicada no host +# (9091 para o 9RTKSync, 9092 para o OminiRTKSync). +WEB_PORT=9090 # Credenciais de acesso HTTP Basic Auth do painel # IMPORTANTE: Altere estas credenciais no primeiro acesso via interface web ou via .env! +# Modo headless: quando DASHBOARD_USER e/ou DASHBOARD_PASSWORD estao definidas, elas +# passam a ser a fonte de verdade e o arquivo .dashboard_auth.json gravado pela tela +# e ignorado. A troca de senha pelo painel passa a responder 409 Conflict. Comente as +# duas linhas abaixo para devolver o controle ao dashboard. DASHBOARD_USER=admin DASHBOARD_PASSWORD=pathbit +# Credencial de recuperacao (break-glass). Entre com o usuario 'admin' e este valor +# como senha para recuperar o acesso caso a senha do painel seja esquecida. +# Se ficar vazia, um valor aleatorio e gerado no primeiro boot, salvo em +# .dashboard_recovery (permissao 0600) e registrado uma unica vez no log. +# DASHBOARD_RECOVERY_HASH= + +# ------------------------------------------------------------------------------ +# 5. Log persistente em arquivo +# ------------------------------------------------------------------------------ +# Diretorio dos arquivos de log. Padrao: /logs. +LOG_DIR=/app/data/logs + +# Dias de retencao antes do expurgo automatico dos arquivos rotacionados (padrao: 30). +LOG_RETENTION_DAYS=30 + +# Nivel minimo registrado: DEBUG, INFO, WARNING ou ERROR (padrao: INFO). +LOG_LEVEL=INFO + +# Espelha os eventos tambem no stdout do container (1=sim, 0=nao). +LOG_TO_STDOUT=1 + # ------------------------------------------------------------------------------ # 5. Integracao com a Stack 9Router (Docker Compose) # ------------------------------------------------------------------------------ diff --git a/Dockerfile b/Dockerfile index 114ca0d..d1b95af 100644 --- a/Dockerfile +++ b/Dockerfile @@ -23,7 +23,7 @@ ENV DB_PATH=/app/data/db/data.sqlite ENV ROUTER_URL=http://127.0.0.1:20128 ENV SYNC_INTERVAL=300 ENV REFRESH_MARGIN=900 -ENV WEB_PORT=9190 +ENV WEB_PORT=9090 ENV WEB_HOST=0.0.0.0 ENV ENABLE_WEB_DASHBOARD=1 @@ -34,10 +34,10 @@ COPY pyproject.toml /app/ RUN pip install --no-cache-dir --upgrade pip && \ pip install --no-cache-dir -e . -EXPOSE 9190 +EXPOSE 9090 HEALTHCHECK --interval=15s --timeout=5s --start-period=10s --retries=3 \ - CMD /opt/venv/bin/python3 -c "import urllib.request; urllib.request.urlopen('http://127.0.0.1:9190/healthz', timeout=3)" || exit 1 + CMD /opt/venv/bin/python3 -c "import urllib.request; urllib.request.urlopen('http://127.0.0.1:9090/healthz', timeout=3)" || exit 1 ENTRYPOINT ["/opt/venv/bin/python3", "-m", "nine_rtksync"] CMD ["--daemon"] diff --git a/Makefile b/Makefile index 3bb0f8e..c403296 100644 --- a/Makefile +++ b/Makefile @@ -21,7 +21,7 @@ docker-build: docker build -t 9rtksync:latest -t ghcr.io/pathbit/9rtksync:latest . docker-run: - docker run --rm -it --name 9RTKSync -p 9190:9190 9rtksync:latest + docker run --rm -it --name 9rtksync -p 9091:9090 9rtksync:latest clean: find . -type d -name "__pycache__" -exec rm -rf {} + diff --git a/docker-compose.example.yml b/docker-compose.example.yml index f3c9dc5..67dd431 100644 --- a/docker-compose.example.yml +++ b/docker-compose.example.yml @@ -22,24 +22,43 @@ services: 9rtksync: image: ghcr.io/pathbit/9rtksync:latest - container_name: router-sync + container_name: 9rtksync restart: unless-stopped ports: - - "127.0.0.1:9190:9190" + # Porta interna 9090 (igual no OminiRTKSync); publicada em 9091 no host. + # O bind em 127.0.0.1 mantem o painel e o SQLite fora da internet. + - "127.0.0.1:9091:9090" volumes: - 9router_data:/app/data - ${HOME}:/root/host:ro + - 9rtksync_logs:/app/data/logs environment: - HOST_HOME=/root/host - DB_PATH=/app/data/db/data.sqlite + - ROUTER_URL=${ROUTER_URL:-http://9router:20128} - SYNC_INTERVAL=${SYNC_INTERVAL:-300} - REFRESH_MARGIN=${REFRESH_MARGIN:-900} + - CRON_ENABLED=${CRON_ENABLED:-1} + # - CRON_INTERVAL=300 # herda SYNC_INTERVAL quando omitido - ENABLE_WEB_DASHBOARD=${ENABLE_WEB_DASHBOARD:-1} - - WEB_PORT=${WEB_PORT:-9190} + - WEB_PORT=${WEB_PORT:-9090} - DASHBOARD_USER=${DASHBOARD_USER:-admin} - DASHBOARD_PASSWORD=${DASHBOARD_PASSWORD:-pathbit} + # Credencial de emergencia (usuario 'admin' + este valor como senha). + # Se omitida, e gerada no primeiro boot e registrada no arquivo de log. + # - DASHBOARD_RECOVERY_HASH= + - LOG_DIR=${LOG_DIR:-/app/data/logs} + - LOG_RETENTION_DAYS=${LOG_RETENTION_DAYS:-30} + - LOG_LEVEL=${LOG_LEVEL:-INFO} depends_on: - 9router + healthcheck: + test: ["CMD", "/opt/venv/bin/python3", "-c", "import urllib.request; urllib.request.urlopen('http://127.0.0.1:9090/healthz', timeout=3)"] + interval: 15s + timeout: 5s + retries: 3 + start_period: 10s volumes: 9router_data: + 9rtksync_logs: diff --git a/src/nine_rtksync/auth.py b/src/nine_rtksync/auth.py new file mode 100644 index 0000000..fa0e8d3 --- /dev/null +++ b/src/nine_rtksync/auth.py @@ -0,0 +1,126 @@ +"""Autenticação do dashboard com credencial de recuperação (break-glass). + +Regra de validação, nesta ordem: + +1. Se existem credenciais salvas (trocadas pela tela), elas são a fonte de verdade: + usuário e senha precisam bater exatamente com o que está salvo. +2. Se nada foi salvo ainda, valem as credenciais de fábrica (padrão ou vindas do + ambiente) — é o estado de primeiro acesso do container. +3. Independente do que existe salvo, o usuário `admin` com a senha igual ao + *hash de recuperação* sempre entra. É a saída de emergência para quem esqueceu + a senha, sem precisar apagar o volume do container. +4. Qualquer outra combinação é inválida. + +O hash de recuperação vem de DASHBOARD_RECOVERY_HASH. Quando a variável não é +definida, um hash aleatório é gerado no primeiro boot, gravado em disco com +permissão 0600 e registrado uma única vez no log — é lá que o operador vai buscá-lo. + +Todas as comparações usam hmac.compare_digest para não vazar informação por tempo +de resposta. +""" + +import hashlib +import hmac +import json +import os +import secrets +from typing import Optional, Tuple + +RECOVERY_FILE_NAME = ".dashboard_recovery" +RECOVERY_USER = "admin" + + +def constant_time_equals(a: str, b: str) -> bool: + """Compara duas strings em tempo constante.""" + return hmac.compare_digest(str(a or "").encode("utf-8"), str(b or "").encode("utf-8")) + + +def derive_recovery_hash(secret: str) -> str: + """Deriva o hash de recuperação exibido ao operador a partir de um segredo.""" + return hashlib.sha256(str(secret).encode("utf-8")).hexdigest() + + +def read_stored_credentials(auth_file: str) -> Optional[Tuple[str, str]]: + """Lê as credenciais gravadas pela tela. Devolve None quando ainda não houve troca.""" + if not auth_file or not os.path.exists(auth_file): + return None + try: + with open(auth_file, "r", encoding="utf-8") as f: + data = json.load(f) + user = data.get("user") + password = data.get("password") + if user and password: + return str(user), str(password) + except (OSError, ValueError): + pass + return None + + +def resolve_recovery_hash(recovery_file: str) -> str: + """Obtém o hash de recuperação: ambiente primeiro, senão o gerado/salvo localmente.""" + from_env = os.environ.get("DASHBOARD_RECOVERY_HASH", "").strip() + if from_env: + return from_env + + if recovery_file and os.path.exists(recovery_file): + try: + with open(recovery_file, "r", encoding="utf-8") as f: + saved = f.read().strip() + if saved: + return saved + except OSError: + pass + + return "" + + +def ensure_recovery_hash(recovery_file: str) -> Tuple[str, bool]: + """Garante que existe um hash de recuperação. Devolve (hash, foi_gerado_agora).""" + existing = resolve_recovery_hash(recovery_file) + if existing: + return existing, False + + generated = derive_recovery_hash(secrets.token_hex(32)) + if recovery_file: + try: + os.makedirs(os.path.dirname(recovery_file) or ".", exist_ok=True) + # 0600: apenas o dono do processo lê o segredo de emergência. + fd = os.open(recovery_file, os.O_WRONLY | os.O_CREAT | os.O_TRUNC, 0o600) + with os.fdopen(fd, "w", encoding="utf-8") as f: + f.write(generated) + except OSError: + # Sem disco gravável o hash vira efêmero (válido só nesta execução), + # mas o serviço continua subindo. + pass + return generated, True + + +def verify_credentials( + user: str, + password: str, + *, + stored: Optional[Tuple[str, str]], + factory_user: str, + factory_password: str, + recovery_hash: str = "", +) -> bool: + """Aplica a regra de validação descrita no topo do módulo.""" + if not user or not password: + return False + + # 3. Saída de emergência: admin + hash de recuperação entra sempre. + if recovery_hash and constant_time_equals(user, RECOVERY_USER): + if constant_time_equals(password, recovery_hash): + return True + + if stored is not None: + # 1. Já houve troca de senha: só as credenciais salvas valem. + stored_user, stored_password = stored + return constant_time_equals(user, stored_user) and constant_time_equals( + password, stored_password + ) + + # 2. Primeiro acesso: valem as credenciais de fábrica. + return constant_time_equals(user, factory_user) and constant_time_equals( + password, factory_password + ) diff --git a/src/nine_rtksync/cli.py b/src/nine_rtksync/cli.py index fc7490f..31e4a6a 100644 --- a/src/nine_rtksync/cli.py +++ b/src/nine_rtksync/cli.py @@ -6,6 +6,7 @@ from .config import Settings from .daemon import SyncEngine, run_daemon from .database import get_all_combos, get_all_connections +from .logs import setup_logging from .web.server import start_web_server @@ -97,7 +98,7 @@ def main(): parser.add_argument( "--port", type=int, - help="Porta do servidor web embutido (padrão: 9190)", + help="Porta do servidor web embutido (padrão: 9090)", ) parser.add_argument( "--user", @@ -113,6 +114,19 @@ def main(): args = parser.parse_args() settings = Settings.from_env() + # Log em arquivo precisa existir antes de qualquer evento do motor de sincronizacao. + logger = setup_logging(settings.db_path) + + # Credencial de emergencia: gerada uma unica vez e registrada no log, para o + # operador conseguir voltar ao painel caso esqueca a senha trocada pela tela. + recovery_hash, generated_now = settings.ensure_recovery_hash() + if generated_now and recovery_hash: + logger.warning( + "[AUTH] Hash de recuperacao gerado. Para recuperar o acesso use usuario " + "'admin' e esta senha: %s (guarde-a; defina DASHBOARD_RECOVERY_HASH para fixar a sua)", + recovery_hash, + ) + if args.db_path: settings.db_path = args.db_path if args.interval: diff --git a/src/nine_rtksync/config.py b/src/nine_rtksync/config.py index 0021d5b..7c657c8 100644 --- a/src/nine_rtksync/config.py +++ b/src/nine_rtksync/config.py @@ -3,7 +3,15 @@ import os import sys from dataclasses import dataclass -from typing import List +from typing import List, Optional, Tuple + +from .auth import ( + RECOVERY_FILE_NAME, + ensure_recovery_hash, + read_stored_credentials, + resolve_recovery_hash, + verify_credentials, +) def _is_truthy(val: str) -> bool: @@ -40,13 +48,47 @@ class Settings: enable_web: bool = True host_home: str = "" web_host: str = "0.0.0.0" - web_port: int = 9190 + web_port: int = 9090 router_url: str = "http://127.0.0.1:20128" module: str = "all" credential_paths: List[str] = None dashboard_user: str = "admin" dashboard_password: str = "pathbit" cron_interval: int = 300 + cron_enabled: bool = True + # Quando DASHBOARD_USER/DASHBOARD_PASSWORD vêm explicitamente do ambiente, elas + # passam a ser a fonte de verdade e o arquivo salvo pela tela é ignorado. É o + # que permite operar 100% headless (Docker, Kubernetes, CI) sem abrir o painel. + dashboard_auth_from_env: bool = False + + def get_recovery_file_path(self) -> str: + """Caminho do arquivo que guarda o hash de recuperação gerado localmente.""" + return os.path.join(os.path.dirname(self.get_auth_file_path()), RECOVERY_FILE_NAME) + + def get_recovery_hash(self) -> str: + """Hash de recuperação em vigor (ambiente ou gerado no primeiro boot).""" + return resolve_recovery_hash(self.get_recovery_file_path()) + + def ensure_recovery_hash(self) -> Tuple[str, bool]: + """Garante a existência do hash de recuperação. Devolve (hash, foi_gerado_agora).""" + return ensure_recovery_hash(self.get_recovery_file_path()) + + def get_stored_credentials(self) -> Optional[Tuple[str, str]]: + """Credenciais gravadas pela tela, ou None quando o ambiente é autoritativo.""" + if self.dashboard_auth_from_env: + return None + return read_stored_credentials(self.get_auth_file_path()) + + def verify_credentials(self, user: str, password: str) -> bool: + """Valida um par usuário/senha, incluindo a credencial de recuperação.""" + return verify_credentials( + user, + password, + stored=self.get_stored_credentials(), + factory_user=self.dashboard_user, + factory_password=self.dashboard_password, + recovery_hash=self.get_recovery_hash(), + ) def get_auth_file_path(self) -> str: """Retorna o caminho para persistência de credenciais do dashboard.""" @@ -58,7 +100,12 @@ def get_auth_file_path(self) -> str: return os.path.join(base_dir, ".dashboard_auth.json") def get_auth_credentials(self) -> tuple[str, str]: - """Obtém credenciais ativas do dashboard (arquivo salvo -> env -> padrão).""" + """Obtém credenciais ativas do dashboard (env explícito -> arquivo salvo -> padrão).""" + # Ambiente explícito vence o arquivo: sem isso, uma única troca de senha + # pela tela deixaria DASHBOARD_USER/DASHBOARD_PASSWORD inertes para sempre. + if self.dashboard_auth_from_env: + return self.dashboard_user, self.dashboard_password + auth_file = self.get_auth_file_path() if os.path.exists(auth_file): try: @@ -80,6 +127,11 @@ def is_default_password(self) -> bool: def update_auth_credentials(self, user: str, new_pass: str) -> bool: """Salva novas credenciais de acesso no arquivo seguro do dashboard.""" + # Em modo headless o ambiente é imutável pela tela — gravar o arquivo aqui + # criaria um estado fantasma que get_auth_credentials nunca leria. + if self.dashboard_auth_from_env: + return False + auth_file = self.get_auth_file_path() try: import json @@ -132,9 +184,17 @@ def from_env(cls, env_file: str = ".env") -> "Settings": if not db_path: db_path = candidate_dbs[0] - d_user = os.environ.get("DASHBOARD_USER", "admin") - d_pass = os.environ.get("DASHBOARD_PASSWORD", "pathbit") + # Só considera "vindo do ambiente" quando a variável foi realmente definida, + # para não transformar o padrão de fábrica em configuração autoritativa. + env_user = os.environ.get("DASHBOARD_USER") + env_pass = os.environ.get("DASHBOARD_PASSWORD") + d_user = env_user or "admin" + d_pass = env_pass or "pathbit" + auth_from_env = bool(env_user or env_pass) + sync_int = int(os.environ.get("SYNC_INTERVAL", "300")) + cron_int = int(os.environ.get("CRON_INTERVAL", str(sync_int))) + cron_on = os.environ.get("CRON_ENABLED", "1") not in ("0", "false", "no") return cls( db_path=db_path, @@ -143,11 +203,13 @@ def from_env(cls, env_file: str = ".env") -> "Settings": refresh_margin=int(os.environ.get("REFRESH_MARGIN", "900")), enable_web=os.environ.get("ENABLE_WEB_DASHBOARD", "1") not in ("0", "false", "no"), web_host=os.environ.get("WEB_HOST", "0.0.0.0"), - web_port=int(os.environ.get("WEB_PORT", "9190")), + web_port=int(os.environ.get("WEB_PORT", "9090")), router_url=os.environ.get("ROUTER_URL", "http://127.0.0.1:20128"), module=os.environ.get("MODULE", "all"), credential_paths=valid_paths, dashboard_user=d_user, dashboard_password=d_pass, - cron_interval=sync_int, + cron_interval=cron_int, + cron_enabled=cron_on, + dashboard_auth_from_env=auth_from_env, ) diff --git a/src/nine_rtksync/cron.py b/src/nine_rtksync/cron.py index e7876eb..6cab63a 100644 --- a/src/nine_rtksync/cron.py +++ b/src/nine_rtksync/cron.py @@ -5,6 +5,8 @@ from datetime import datetime, timezone from typing import Any, Callable, Dict, List, Optional +from .logs import get_logger + class CronScheduler: """Agendador em background que gerencia a renovação contínua de contas OAuth e integridade de conexões.""" @@ -75,7 +77,7 @@ def _execute_cycle(self, reason: str = "scheduled_interval") -> Dict[str, Any]: start_iso = datetime.now(timezone.utc).strftime("%Y-%m-%d %H:%M:%S UTC") ts_str = datetime.now().strftime("%Y-%m-%d %H:%M:%S") - print(f"[{ts_str}] [CRON] Ciclo disparado ({reason}). Inspecionando conexões de contas OAuth...", flush=True) + get_logger().info(f"[CRON] Ciclo disparado ({reason}). Inspecionando conexoes de contas OAuth...") try: res = self.sync_callback() @@ -106,10 +108,9 @@ def _execute_cycle(self, reason: str = "scheduled_interval") -> Dict[str, Any]: self.history.pop(0) self._update_next_run(self.interval_seconds) - end_ts = datetime.now().strftime("%Y-%m-%d %H:%M:%S") - print( - f"[{end_ts}] [CRON] Ciclo concluído em {duration_ms}ms: {total} contas avaliadas, {refreshed} renovadas via OAuth.", - flush=True, + get_logger().info( + f"[CRON] Ciclo concluido em {duration_ms}ms: {total} contas avaliadas, " + f"{refreshed} renovadas via OAuth." ) return entry diff --git a/src/nine_rtksync/daemon.py b/src/nine_rtksync/daemon.py index 3ec3987..74952b8 100644 --- a/src/nine_rtksync/daemon.py +++ b/src/nine_rtksync/daemon.py @@ -12,6 +12,7 @@ from .cron import CronScheduler from .database import get_all_connections, update_connection_data from .discovery import HostDiscoveryEngine +from .logs import get_logger from .models import ConnectionRecord from .normalizer import normalize_connection_data from .providers import ApiKeyProvider, BaseProvider, GenericOAuthProvider, GoogleProvider, LocalProvider @@ -19,8 +20,8 @@ def log_msg(prefix: str, text: str): - ts = datetime.now().strftime("%Y-%m-%d %H:%M:%S") - print(f"[{ts}] [{prefix}] {text}", flush=True) + """Registra um evento no log persistente (e no stdout, se LOG_TO_STDOUT permitir).""" + get_logger().info(f"[{prefix}] {text}") class SyncEngine: diff --git a/src/nine_rtksync/logs.py b/src/nine_rtksync/logs.py new file mode 100644 index 0000000..2325f23 --- /dev/null +++ b/src/nine_rtksync/logs.py @@ -0,0 +1,136 @@ +"""Log persistente em arquivo com rotação diária e expurgo por idade. + +O stdout/stderr de um container é volátil: ele some no `docker rm`, é truncado pelo +driver de log e não sobrevive a um restart. Os eventos que importam para auditoria +(renovação de token, falha de sincronização, acesso ao dashboard) passam a ser +gravados também em arquivo, com rotação diária e retenção configurável. + +Variáveis de ambiente: + LOG_DIR Diretório dos arquivos de log. Padrão: /logs, + com fallback para ~/.9rtksync/logs. + LOG_RETENTION_DAYS Dias de retenção antes do expurgo. Padrão: 30. + LOG_LEVEL Nível mínimo registrado (DEBUG/INFO/WARNING/ERROR). Padrão: INFO. + LOG_TO_STDOUT Espelha no stdout (1=sim, 0=não). Padrão: 1. +""" + +import logging +import os +import sys +import threading +import time +from logging.handlers import TimedRotatingFileHandler +from typing import Optional + +LOG_FILE_NAME = "9rtksync.log" +DEFAULT_RETENTION_DAYS = 30 + +_logger: Optional[logging.Logger] = None +_lock = threading.Lock() + + +def get_retention_days() -> int: + """Dias de retenção configurados, com piso de 1 dia.""" + try: + return max(1, int(os.environ.get("LOG_RETENTION_DAYS", str(DEFAULT_RETENTION_DAYS)))) + except (TypeError, ValueError): + return DEFAULT_RETENTION_DAYS + + +def resolve_log_dir(db_path: str = "") -> str: + """Resolve o diretório de logs a partir do ambiente, do banco ou do home.""" + configured = os.environ.get("LOG_DIR", "").strip() + if configured: + return configured + + if db_path: + candidate = os.path.join(os.path.dirname(db_path), "logs") + parent = os.path.dirname(candidate) + if parent and os.path.isdir(parent) and os.access(parent, os.W_OK): + return candidate + + return os.path.join(os.path.expanduser("~"), ".9rtksync", "logs") + + +def purge_expired_logs(log_dir: str, retention_days: Optional[int] = None) -> int: + """Remove arquivos de log rotacionados mais velhos que a retenção. Devolve quantos apagou.""" + if not os.path.isdir(log_dir): + return 0 + + days = get_retention_days() if retention_days is None else max(1, retention_days) + cutoff = time.time() - (days * 86400) + removed = 0 + + for entry in os.listdir(log_dir): + # Só mexe nos arquivos rotacionados deste serviço; o arquivo ativo é preservado. + if not entry.startswith(LOG_FILE_NAME) or entry == LOG_FILE_NAME: + continue + path = os.path.join(log_dir, entry) + try: + if os.path.isfile(path) and os.path.getmtime(path) < cutoff: + os.remove(path) + removed += 1 + except OSError: + continue + + return removed + + +def setup_logging(db_path: str = "") -> logging.Logger: + """Configura (uma única vez) o logger com arquivo rotativo e espelho opcional no stdout.""" + global _logger + with _lock: + if _logger is not None: + return _logger + + logger = logging.getLogger("9rtksync") + logger.setLevel(getattr(logging, os.environ.get("LOG_LEVEL", "INFO").upper(), logging.INFO)) + logger.propagate = False + logger.handlers.clear() + + formatter = logging.Formatter( + "[%(asctime)s] [%(levelname)s] %(message)s", datefmt="%Y-%m-%d %H:%M:%S" + ) + + log_dir = resolve_log_dir(db_path) + try: + os.makedirs(log_dir, exist_ok=True) + # backupCount em rotação diária equivale à retenção em dias. + file_handler = TimedRotatingFileHandler( + os.path.join(log_dir, LOG_FILE_NAME), + when="midnight", + interval=1, + backupCount=get_retention_days(), + encoding="utf-8", + utc=True, + ) + file_handler.setFormatter(formatter) + logger.addHandler(file_handler) + purge_expired_logs(log_dir) + except OSError as e: + # Sem permissão de escrita o serviço continua: o log em arquivo é um extra, + # nunca um motivo para o sincronizador não subir. + print(f"[LOG] Log em arquivo indisponivel em {log_dir}: {e}", file=sys.stderr, flush=True) + + if os.environ.get("LOG_TO_STDOUT", "1") not in ("0", "false", "no"): + stream_handler = logging.StreamHandler(sys.stdout) + stream_handler.setFormatter(formatter) + logger.addHandler(stream_handler) + + _logger = logger + return logger + + +def get_logger() -> logging.Logger: + """Devolve o logger configurado, inicializando com os padrões caso necessário.""" + return _logger if _logger is not None else setup_logging() + + +def reset_logging() -> None: + """Descarta a configuração atual. Existe para permitir testes isolados.""" + global _logger + with _lock: + if _logger is not None: + for handler in list(_logger.handlers): + handler.close() + _logger.removeHandler(handler) + _logger = None diff --git a/src/nine_rtksync/web/server.py b/src/nine_rtksync/web/server.py index f7c6cb0..f17e9fe 100644 --- a/src/nine_rtksync/web/server.py +++ b/src/nine_rtksync/web/server.py @@ -3,18 +3,44 @@ import base64 import json import os +import sys import threading import time import urllib.error import urllib.request from http import HTTPStatus -from http.server import BaseHTTPRequestHandler, HTTPServer +from http.server import BaseHTTPRequestHandler, ThreadingHTTPServer from typing import Any, Callable, Dict, Optional from ..config import Settings from ..database import get_all_combos, get_all_connections from ..models import ConnectionRecord +# Tempo de vida do resultado da sondagem ao gateway. O /healthz é chamado a cada +# 15s pelo Docker; sem cache, cada chamada faria uma requisição HTTP de saída de +# até 3s, atrasando a resposta além do timeout do probe. +ROUTER_PROBE_TTL_SECONDS = 30.0 + +# Erros de socket que significam apenas "o cliente desistiu antes de ler a +# resposta" — comportamento normal de health check, não falha do servidor. +CLIENT_DISCONNECT_ERRORS = (BrokenPipeError, ConnectionResetError, ConnectionAbortedError) + +# Cache do resultado da sondagem ao gateway, compartilhado entre as threads do servidor. +_router_probe_cache: Dict[str, tuple] = {} +_router_probe_lock = threading.Lock() + + +class QuietThreadingHTTPServer(ThreadingHTTPServer): + """Servidor multi-thread que não polui o log quando o cliente desconecta antes da hora.""" + + daemon_threads = True + + def handle_error(self, request, client_address): + exc = sys.exc_info()[1] + if isinstance(exc, CLIENT_DISCONNECT_ERRORS): + return + super().handle_error(request, client_address) + class DashboardHandler(BaseHTTPRequestHandler): """Handler HTTP para servir o dashboard, API REST e agendador cron com Basic Auth.""" @@ -32,7 +58,6 @@ def check_auth(self) -> bool: if not self.settings: return True - expected_user, expected_pass = self.settings.get_auth_credentials() auth_header = self.headers.get("Authorization", "") if not auth_header or not auth_header.startswith("Basic "): return False @@ -43,7 +68,9 @@ def check_auth(self) -> bool: if ":" not in decoded: return False user, pwd = decoded.split(":", 1) - return user == expected_user and pwd == expected_pass + # Delega ao Settings: credenciais salvas, padrão de fábrica e a + # credencial de recuperação (admin + hash) são avaliadas lá. + return self.settings.verify_credentials(user, pwd) except Exception: return False @@ -55,7 +82,7 @@ def require_auth(self) -> bool: self.send_header("WWW-Authenticate", 'Basic realm="9RTKSync Dashboard"') self.send_header("Content-Type", "text/plain; charset=utf-8") self.end_headers() - self.wfile.write(b"Autenticacao requerida. Credenciais padrao: admin / pathbit") + self.write_body(b"Autenticacao requerida. Credenciais padrao: admin / pathbit") return False def do_GET(self): @@ -93,33 +120,57 @@ def do_POST(self): else: self.send_error(HTTPStatus.NOT_FOUND, "Endpoint nao encontrado") + def probe_router(self) -> bool: + """Sonda o gateway com cache: o resultado vale por ROUTER_PROBE_TTL_SECONDS.""" + if not self.router_url: + return True + + now = time.time() + with _router_probe_lock: + cached_at, cached_ok = _router_probe_cache.get(self.router_url, (0.0, None)) + if cached_ok is not None and (now - cached_at) < ROUTER_PROBE_TTL_SECONDS: + return cached_ok + + try: + req = urllib.request.Request( + self.router_url, + headers={"User-Agent": "9RTKSync-Healthcheck/1.0"}, + ) + with urllib.request.urlopen(req, timeout=3.0) as resp: + router_ok = resp.status < 500 + except urllib.error.HTTPError as e: + router_ok = e.code < 500 + except Exception: + router_ok = False + + with _router_probe_lock: + _router_probe_cache[self.router_url] = (time.time(), router_ok) + return router_ok + + def write_body(self, payload: bytes) -> None: + """Escreve o corpo tolerando o cliente ter fechado a conexão antes da leitura.""" + try: + self.wfile.write(payload) + except CLIENT_DISCONNECT_ERRORS: + self.close_connection = True + def serve_healthz(self): db_ok = bool(self.db_path and os.path.exists(self.db_path)) - router_ok = True - if self.router_url: - try: - req = urllib.request.Request( - self.router_url, - headers={"User-Agent": "9RTKSync-Healthcheck/1.0"}, - ) - with urllib.request.urlopen(req, timeout=3.0) as resp: - router_ok = resp.status < 500 - except urllib.error.HTTPError as e: - router_ok = e.code < 500 - except Exception: - router_ok = False + router_ok = self.probe_router() if db_ok and router_ok: - self.send_response(HTTPStatus.OK) - self.send_header("Content-Type", "text/plain") - self.end_headers() - self.wfile.write(b"OK") + payload = b"OK" + status = HTTPStatus.OK else: reason = "DATABASE_NOT_READY" if not db_ok else "ROUTER_SERVICE_UNREACHABLE" - self.send_response(HTTPStatus.SERVICE_UNAVAILABLE) - self.send_header("Content-Type", "text/plain") - self.end_headers() - self.wfile.write(reason.encode("utf-8")) + payload = reason.encode("utf-8") + status = HTTPStatus.SERVICE_UNAVAILABLE + + self.send_response(status) + self.send_header("Content-Type", "text/plain") + self.send_header("Content-Length", str(len(payload))) + self.end_headers() + self.write_body(payload) def serve_html(self): html_path = os.path.join(os.path.dirname(__file__), "index.html") @@ -133,7 +184,7 @@ def serve_html(self): self.send_header("Content-Type", "text/html; charset=utf-8") self.send_header("Content-Length", str(len(content))) self.end_headers() - self.wfile.write(content) + self.write_body(content) def serve_api_status(self): conns = [] @@ -181,7 +232,7 @@ def serve_api_status(self): self.send_header("Access-Control-Allow-Origin", "*") self.send_header("Content-Length", str(len(body))) self.end_headers() - self.wfile.write(body) + self.write_body(body) def serve_cron_status(self): cron_info = self.cron_scheduler.get_status() if self.cron_scheduler else {"active": False} @@ -189,7 +240,7 @@ def serve_cron_status(self): self.send_response(HTTPStatus.OK) self.send_header("Content-Type", "application/json; charset=utf-8") self.end_headers() - self.wfile.write(body) + self.write_body(body) def handle_test_gateway(self): start_t = time.time() @@ -245,7 +296,7 @@ def handle_test_gateway(self): self.send_header("Content-Type", "application/json; charset=utf-8") self.send_header("Content-Length", str(len(body))) self.end_headers() - self.wfile.write(body) + self.write_body(body) def handle_change_password(self, raw_body: bytes): try: @@ -260,7 +311,7 @@ def handle_change_password(self, raw_body: bytes): self.send_header("Content-Type", "application/json") self.send_header("Content-Length", str(len(body))) self.end_headers() - self.wfile.write(body) + self.write_body(body) return if self.settings: @@ -275,7 +326,7 @@ def handle_change_password(self, raw_body: bytes): self.send_header("Content-Type", "application/json") self.send_header("Content-Length", str(len(body))) self.end_headers() - self.wfile.write(body) + self.write_body(body) return self.send_error(HTTPStatus.INTERNAL_SERVER_ERROR, "Nao foi possivel salvar credenciais") @@ -285,7 +336,7 @@ def handle_change_password(self, raw_body: bytes): self.send_header("Content-Type", "application/json") self.send_header("Content-Length", str(len(body))) self.end_headers() - self.wfile.write(body) + self.write_body(body) def handle_sync_request(self): if DashboardHandler.sync_trigger_callback: @@ -296,7 +347,7 @@ def handle_sync_request(self): self.send_header("Content-Type", "application/json") self.send_header("Content-Length", str(len(body))) self.end_headers() - self.wfile.write(body) + self.write_body(body) return except Exception as e: err = json.dumps({"success": False, "error": str(e)}).encode("utf-8") @@ -304,7 +355,7 @@ def handle_sync_request(self): self.send_header("Content-Type", "application/json") self.send_header("Content-Length", str(len(err))) self.end_headers() - self.wfile.write(err) + self.write_body(err) return self.send_error(HTTPStatus.SERVICE_UNAVAILABLE, "Sincronizador nao disponivel") @@ -317,7 +368,7 @@ def handle_cron_run(self): self.send_header("Content-Type", "application/json") self.send_header("Content-Length", str(len(body))) self.end_headers() - self.wfile.write(body) + self.write_body(body) return except Exception as e: err = json.dumps({"success": False, "error": str(e)}).encode("utf-8") @@ -325,7 +376,7 @@ def handle_cron_run(self): self.send_header("Content-Type", "application/json") self.send_header("Content-Length", str(len(err))) self.end_headers() - self.wfile.write(err) + self.write_body(err) return self.handle_sync_request() @@ -338,7 +389,7 @@ def start_web_server( sync_callback: Optional[Callable[[], Dict[str, Any]]] = None, settings: Optional[Settings] = None, cron_scheduler: Optional[Any] = None, -) -> HTTPServer: +) -> ThreadingHTTPServer: """Inicia o servidor HTTP em background thread com Basic Auth e Cron Scheduler.""" DashboardHandler.db_path = db_path DashboardHandler.router_url = router_url @@ -346,7 +397,7 @@ def start_web_server( DashboardHandler.settings = settings DashboardHandler.cron_scheduler = cron_scheduler - server = HTTPServer((host, port), DashboardHandler) + server = QuietThreadingHTTPServer((host, port), DashboardHandler) thread = threading.Thread(target=server.serve_forever, daemon=True) thread.start() return server diff --git a/tests/test_auth_recovery.py b/tests/test_auth_recovery.py new file mode 100644 index 0000000..824ac9b --- /dev/null +++ b/tests/test_auth_recovery.py @@ -0,0 +1,189 @@ +"""Testes da regra de autenticação do dashboard, incluindo a credencial de recuperação.""" + +import json +import os +import stat +import tempfile +import unittest +from unittest import mock + +from nine_rtksync.auth import ( + constant_time_equals, + derive_recovery_hash, + ensure_recovery_hash, + read_stored_credentials, + resolve_recovery_hash, + verify_credentials, +) +from nine_rtksync.config import Settings + +FACTORY = {"factory_user": "admin", "factory_password": "pathbit"} + + +class TestVerifyCredentials(unittest.TestCase): + """A ordem de validação: salvas -> padrão de fábrica (se nada salvo) -> recuperação.""" + + def test_factory_credentials_work_while_nothing_is_stored(self): + self.assertTrue(verify_credentials("admin", "pathbit", stored=None, **FACTORY)) + + def test_wrong_factory_password_is_rejected(self): + self.assertFalse(verify_credentials("admin", "errada", stored=None, **FACTORY)) + + def test_stored_credentials_replace_the_factory_ones(self): + stored = ("operador", "senha-nova") + self.assertTrue(verify_credentials("operador", "senha-nova", stored=stored, **FACTORY)) + # Depois da troca, a senha de fábrica nao vale mais. + self.assertFalse(verify_credentials("admin", "pathbit", stored=stored, **FACTORY)) + + def test_recovery_hash_always_works_for_admin(self): + stored = ("operador", "senha-esquecida") + recovery = derive_recovery_hash("segredo") + self.assertTrue( + verify_credentials("admin", recovery, stored=stored, recovery_hash=recovery, **FACTORY) + ) + + def test_recovery_hash_works_even_before_any_password_change(self): + recovery = derive_recovery_hash("segredo") + self.assertTrue( + verify_credentials("admin", recovery, stored=None, recovery_hash=recovery, **FACTORY) + ) + + def test_recovery_hash_only_works_for_the_admin_user(self): + recovery = derive_recovery_hash("segredo") + self.assertFalse( + verify_credentials( + "operador", recovery, stored=None, recovery_hash=recovery, **FACTORY + ) + ) + + def test_everything_else_is_invalid(self): + stored = ("operador", "senha-nova") + recovery = derive_recovery_hash("segredo") + for user, password in [ + ("admin", "pathbit"), + ("admin", "chute"), + ("operador", "chute"), + ("outro", "senha-nova"), + ("", ""), + ("admin", ""), + ("", "senha-nova"), + ]: + with self.subTest(user=user, password=password): + self.assertFalse( + verify_credentials( + user, password, stored=stored, recovery_hash=recovery, **FACTORY + ) + ) + + def test_empty_recovery_hash_never_grants_access(self): + # Sem hash configurado, uma senha vazia nao pode virar chave mestra. + self.assertFalse( + verify_credentials("admin", "", stored=None, recovery_hash="", **FACTORY) + ) + + def test_constant_time_equals_handles_none(self): + self.assertTrue(constant_time_equals("a", "a")) + self.assertFalse(constant_time_equals("a", None)) + self.assertTrue(constant_time_equals(None, None)) + + +class TestRecoveryHashStorage(unittest.TestCase): + def setUp(self): + self.tmp_dir = tempfile.TemporaryDirectory() + self.recovery_file = os.path.join(self.tmp_dir.name, ".dashboard_recovery") + + def tearDown(self): + self.tmp_dir.cleanup() + + def test_env_hash_wins(self): + with mock.patch.dict(os.environ, {"DASHBOARD_RECOVERY_HASH": "do-ambiente"}, clear=True): + self.assertEqual(resolve_recovery_hash(self.recovery_file), "do-ambiente") + + def test_generated_hash_is_persisted_and_reused(self): + with mock.patch.dict(os.environ, {}, clear=True): + first, generated = ensure_recovery_hash(self.recovery_file) + self.assertTrue(generated) + self.assertTrue(first) + + second, generated_again = ensure_recovery_hash(self.recovery_file) + self.assertFalse(generated_again) + self.assertEqual(second, first) + + def test_generated_hash_file_is_owner_only(self): + with mock.patch.dict(os.environ, {}, clear=True): + ensure_recovery_hash(self.recovery_file) + mode = stat.S_IMODE(os.stat(self.recovery_file).st_mode) + self.assertEqual(mode, 0o600) + + def test_unwritable_path_still_returns_a_hash(self): + with mock.patch.dict(os.environ, {}, clear=True): + value, generated = ensure_recovery_hash("/proc/nao-pode/recovery") + self.assertTrue(value) + self.assertTrue(generated) + + def test_read_stored_credentials_handles_missing_and_corrupt_files(self): + self.assertIsNone(read_stored_credentials("")) + self.assertIsNone(read_stored_credentials(os.path.join(self.tmp_dir.name, "nao-existe"))) + + corrupt = os.path.join(self.tmp_dir.name, "corrupto.json") + with open(corrupt, "w", encoding="utf-8") as f: + f.write("{nao e json") + self.assertIsNone(read_stored_credentials(corrupt)) + + incomplete = os.path.join(self.tmp_dir.name, "incompleto.json") + with open(incomplete, "w", encoding="utf-8") as f: + json.dump({"user": "só-usuario"}, f) + self.assertIsNone(read_stored_credentials(incomplete)) + + +class TestSettingsAuthIntegration(unittest.TestCase): + def setUp(self): + self.tmp_dir = tempfile.TemporaryDirectory() + self.db_path = os.path.join(self.tmp_dir.name, "data.sqlite") + open(self.db_path, "w").close() + + def tearDown(self): + self.tmp_dir.cleanup() + + def _settings(self, **env): + base = {"DB_PATH": self.db_path, "DATA_DIR": self.tmp_dir.name} + base.update(env) + with mock.patch.dict(os.environ, base, clear=True): + return Settings.from_env(env_file=""), dict(base) + + def test_full_lifecycle_from_factory_to_change_to_recovery(self): + settings, base = self._settings() + with mock.patch.dict(os.environ, base, clear=True): + # 1. Primeiro acesso: credenciais de fábrica. + self.assertTrue(settings.verify_credentials("admin", "pathbit")) + + # 2. Operador troca a senha pela tela. + self.assertTrue(settings.update_auth_credentials("operador", "minha-senha")) + self.assertTrue(settings.verify_credentials("operador", "minha-senha")) + self.assertFalse(settings.verify_credentials("admin", "pathbit")) + + # 3. Esqueceu a senha: entra com admin + hash de recuperação. + recovery, _ = settings.ensure_recovery_hash() + self.assertTrue(settings.verify_credentials("admin", recovery)) + + # 4. Qualquer outra combinação segue inválida. + self.assertFalse(settings.verify_credentials("admin", "chute")) + self.assertFalse(settings.verify_credentials("operador", recovery)) + + def test_recovery_hash_can_be_pinned_by_environment(self): + settings, base = self._settings(DASHBOARD_RECOVERY_HASH="hash-fixo-do-container") + with mock.patch.dict(os.environ, base, clear=True): + settings.update_auth_credentials("operador", "minha-senha") + self.assertTrue(settings.verify_credentials("admin", "hash-fixo-do-container")) + + def test_env_authoritative_mode_ignores_the_saved_file(self): + settings, base = self._settings(DASHBOARD_PASSWORD="do-ambiente") + with mock.patch.dict(os.environ, base, clear=True): + # A tela nao consegue sobrescrever o ambiente. + self.assertFalse(settings.update_auth_credentials("da-tela", "da-tela")) + self.assertTrue(settings.verify_credentials("admin", "do-ambiente")) + self.assertFalse(settings.verify_credentials("da-tela", "da-tela")) + + +if __name__ == "__main__": + unittest.main() diff --git a/tests/test_logs.py b/tests/test_logs.py new file mode 100644 index 0000000..cf668fa --- /dev/null +++ b/tests/test_logs.py @@ -0,0 +1,140 @@ +"""Testes do log persistente em arquivo, rotação e expurgo por idade.""" + +import os +import tempfile +import time +import unittest +from unittest import mock + +from nine_rtksync import logs + + +class TestLogRetention(unittest.TestCase): + def setUp(self): + logs.reset_logging() + self.tmp_dir = tempfile.TemporaryDirectory() + self.log_dir = os.path.join(self.tmp_dir.name, "logs") + os.makedirs(self.log_dir, exist_ok=True) + + def tearDown(self): + logs.reset_logging() + self.tmp_dir.cleanup() + + def _touch(self, name: str, age_days: float): + path = os.path.join(self.log_dir, name) + with open(path, "w", encoding="utf-8") as f: + f.write("linha de log\n") + past = time.time() - (age_days * 86400) + os.utime(path, (past, past)) + return path + + def test_default_retention_is_thirty_days(self): + with mock.patch.dict(os.environ, {}, clear=True): + self.assertEqual(logs.get_retention_days(), 30) + + def test_retention_is_configurable(self): + with mock.patch.dict(os.environ, {"LOG_RETENTION_DAYS": "90"}, clear=True): + self.assertEqual(logs.get_retention_days(), 90) + + def test_invalid_retention_falls_back_to_default(self): + with mock.patch.dict(os.environ, {"LOG_RETENTION_DAYS": "nao-e-numero"}, clear=True): + self.assertEqual(logs.get_retention_days(), 30) + + def test_retention_has_a_floor_of_one_day(self): + with mock.patch.dict(os.environ, {"LOG_RETENTION_DAYS": "0"}, clear=True): + self.assertEqual(logs.get_retention_days(), 1) + + def test_purge_removes_only_files_older_than_retention(self): + recent = self._touch(f"{logs.LOG_FILE_NAME}.2026-09-10", age_days=2) + old = self._touch(f"{logs.LOG_FILE_NAME}.2026-07-01", age_days=45) + active = self._touch(logs.LOG_FILE_NAME, age_days=99) + unrelated = self._touch("outro-servico.log.2026-01-01", age_days=99) + + with mock.patch.dict(os.environ, {}, clear=True): + removed = logs.purge_expired_logs(self.log_dir) + + self.assertEqual(removed, 1) + self.assertFalse(os.path.exists(old)) + self.assertTrue(os.path.exists(recent)) + # O arquivo ativo nunca e apagado, mesmo que a data de modificacao seja antiga. + self.assertTrue(os.path.exists(active)) + # Arquivos de outros servicos no mesmo diretorio ficam intactos. + self.assertTrue(os.path.exists(unrelated)) + + def test_purge_respects_a_longer_configured_retention(self): + old = self._touch(f"{logs.LOG_FILE_NAME}.2026-07-01", age_days=45) + + with mock.patch.dict(os.environ, {"LOG_RETENTION_DAYS": "60"}, clear=True): + removed = logs.purge_expired_logs(self.log_dir) + + self.assertEqual(removed, 0) + self.assertTrue(os.path.exists(old)) + + def test_purge_on_missing_directory_is_a_noop(self): + self.assertEqual(logs.purge_expired_logs(os.path.join(self.tmp_dir.name, "nao-existe")), 0) + + +class TestLogSetup(unittest.TestCase): + def setUp(self): + logs.reset_logging() + self.tmp_dir = tempfile.TemporaryDirectory() + + def tearDown(self): + logs.reset_logging() + self.tmp_dir.cleanup() + + def test_events_are_written_to_the_log_file(self): + log_dir = os.path.join(self.tmp_dir.name, "logs") + with mock.patch.dict(os.environ, {"LOG_DIR": log_dir, "LOG_TO_STDOUT": "0"}, clear=True): + logger = logs.setup_logging() + logger.info("[SYNC] token renovado") + for handler in logger.handlers: + handler.flush() + + log_file = os.path.join(log_dir, logs.LOG_FILE_NAME) + self.assertTrue(os.path.exists(log_file)) + with open(log_file, "r", encoding="utf-8") as f: + content = f.read() + + self.assertIn("[SYNC] token renovado", content) + + def test_stdout_mirror_can_be_disabled(self): + log_dir = os.path.join(self.tmp_dir.name, "logs") + with mock.patch.dict(os.environ, {"LOG_DIR": log_dir, "LOG_TO_STDOUT": "0"}, clear=True): + logger = logs.setup_logging() + stream_handlers = [ + h for h in logger.handlers if type(h).__name__ == "StreamHandler" + ] + self.assertEqual(stream_handlers, []) + + def test_stdout_mirror_is_on_by_default(self): + log_dir = os.path.join(self.tmp_dir.name, "logs") + with mock.patch.dict(os.environ, {"LOG_DIR": log_dir}, clear=True): + logger = logs.setup_logging() + stream_handlers = [ + h for h in logger.handlers if type(h).__name__ == "StreamHandler" + ] + self.assertEqual(len(stream_handlers), 1) + + def test_unwritable_directory_does_not_break_startup(self): + # Um caminho impossivel de criar nao pode derrubar o sincronizador. + impossible = "/proc/nao-pode-criar/logs" + with mock.patch.dict(os.environ, {"LOG_DIR": impossible, "LOG_TO_STDOUT": "0"}, clear=True): + logger = logs.setup_logging() + self.assertIsNotNone(logger) + + def test_log_dir_defaults_next_to_the_database(self): + db_path = os.path.join(self.tmp_dir.name, "data.sqlite") + open(db_path, "w").close() + with mock.patch.dict(os.environ, {}, clear=True): + self.assertEqual( + logs.resolve_log_dir(db_path), os.path.join(self.tmp_dir.name, "logs") + ) + + def test_log_dir_env_wins_over_the_database_path(self): + with mock.patch.dict(os.environ, {"LOG_DIR": "/var/log/custom"}, clear=True): + self.assertEqual(logs.resolve_log_dir("/app/data/db/data.sqlite"), "/var/log/custom") + + +if __name__ == "__main__": + unittest.main() diff --git a/tests/test_web_resilience.py b/tests/test_web_resilience.py new file mode 100644 index 0000000..7841f95 --- /dev/null +++ b/tests/test_web_resilience.py @@ -0,0 +1,211 @@ +"""Testes de resiliência do servidor web: desconexão do cliente e custo do /healthz.""" + +import os +import socket +import sqlite3 +import tempfile +import threading +import time +import unittest +import urllib.request + +from nine_rtksync.config import Settings +from nine_rtksync.web import server as web_server + + +class TestHealthzResilience(unittest.TestCase): + """Cobre o BrokenPipeError do health check do Docker e o custo da sondagem ao gateway.""" + + @classmethod + def setUpClass(cls): + cls.tmp_dir = tempfile.TemporaryDirectory() + cls.db_path = os.path.join(cls.tmp_dir.name, "data.sqlite") + with sqlite3.connect(cls.db_path) as conn: + conn.execute( + "CREATE TABLE providerConnections (id TEXT PRIMARY KEY, data TEXT, updatedAt TEXT)" + ) + conn.execute("CREATE TABLE combos (id TEXT PRIMARY KEY, name TEXT, models TEXT)") + + cls.settings = Settings( + db_path=cls.db_path, + web_host="127.0.0.1", + web_port=19291, + dashboard_user="admin", + dashboard_password="senha-de-teste", + ) + cls.server = web_server.start_web_server( + "127.0.0.1", 19291, cls.db_path, router_url="", settings=cls.settings + ) + time.sleep(0.3) + + @classmethod + def tearDownClass(cls): + cls.server.shutdown() + cls.server.server_close() + cls.tmp_dir.cleanup() + + def setUp(self): + web_server._router_probe_cache.clear() + + def test_server_is_multi_threaded(self): + """Sem multi-thread, uma requisição lenta bloqueia o health check do Docker.""" + self.assertIsInstance(self.server, web_server.QuietThreadingHTTPServer) + self.assertTrue(self.server.daemon_threads) + + def test_healthz_responds_ok(self): + with urllib.request.urlopen("http://127.0.0.1:19291/healthz", timeout=5) as resp: + self.assertEqual(resp.status, 200) + self.assertEqual(resp.read(), b"OK") + + def test_client_disconnect_does_not_crash_the_server(self): + """O probe do Docker fecha o socket cedo; isso não pode virar traceback nem derrubar o servidor.""" + for _ in range(5): + s = socket.create_connection(("127.0.0.1", 19291), timeout=5) + s.sendall(b"GET /healthz HTTP/1.1\r\nHost: 127.0.0.1\r\n\r\n") + # Fecha imediatamente, sem ler a resposta: é exatamente o que produzia + # BrokenPipeError: [Errno 32] em serve_healthz. + s.close() + + time.sleep(0.3) + + # O servidor tem que continuar atendendo normalmente depois disso. + with urllib.request.urlopen("http://127.0.0.1:19291/healthz", timeout=5) as resp: + self.assertEqual(resp.status, 200) + + def test_concurrent_requests_are_served_in_parallel(self): + results = [] + + def hit(): + try: + with urllib.request.urlopen("http://127.0.0.1:19291/healthz", timeout=5) as r: + results.append(r.status) + except Exception as e: # pragma: no cover - falha explícita no assert abaixo + results.append(str(e)) + + threads = [threading.Thread(target=hit) for _ in range(8)] + for t in threads: + t.start() + for t in threads: + t.join(timeout=10) + + self.assertEqual(results, [200] * 8) + + +class TestQuietHandleError(unittest.TestCase): + """handle_error nao pode imprimir traceback quando o cliente apenas desconectou. + + Regressao do erro reportado em producao: + File ".../web/server.py", line 116, in serve_healthz + self.wfile.write(b"OK") + BrokenPipeError: [Errno 32] Broken pipe + """ + + def _server_stub(self): + # Instancia sem __init__ para nao abrir socket: so exercita handle_error. + return web_server.QuietThreadingHTTPServer.__new__(web_server.QuietThreadingHTTPServer) + + def _capture(self, exc): + server = self._server_stub() + printed = [] + original = web_server.ThreadingHTTPServer.handle_error + web_server.ThreadingHTTPServer.handle_error = lambda *a, **k: printed.append(True) + try: + try: + raise exc + except type(exc): + server.handle_error(None, ("127.0.0.1", 46392)) + finally: + web_server.ThreadingHTTPServer.handle_error = original + return printed + + def test_broken_pipe_is_swallowed(self): + self.assertEqual(self._capture(BrokenPipeError(32, "Broken pipe")), []) + + def test_connection_reset_is_swallowed(self): + self.assertEqual(self._capture(ConnectionResetError(104, "Connection reset by peer")), []) + + def test_real_errors_still_reach_the_default_handler(self): + self.assertEqual(self._capture(ValueError("falha de verdade")), [True]) + + def test_write_body_survives_a_broken_pipe(self): + class ExplodingWriter: + def write(self, _payload): + raise BrokenPipeError(32, "Broken pipe") + + handler = web_server.DashboardHandler.__new__(web_server.DashboardHandler) + handler.wfile = ExplodingWriter() + handler.close_connection = False + + handler.write_body(b"OK") # nao pode propagar + self.assertTrue(handler.close_connection) + + +class TestRouterProbeCache(unittest.TestCase): + """A sondagem ao gateway não pode acontecer a cada probe: ela faz I/O de rede de até 3s.""" + + def setUp(self): + web_server._router_probe_cache.clear() + self.calls = [] + + def tearDown(self): + web_server._router_probe_cache.clear() + + def _handler_with_fake_probe(self, url: str): + calls = self.calls + + class FakeHandler(web_server.DashboardHandler): + router_url = url + + def __init__(self): # não instancia socket: só exercita probe_router + pass + + original_urlopen = web_server.urllib.request.urlopen + + class FakeResponse: + status = 200 + + def __enter__(self): + return self + + def __exit__(self, *args): + return False + + def fake_urlopen(*args, **kwargs): + calls.append(1) + return FakeResponse() + + web_server.urllib.request.urlopen = fake_urlopen + self.addCleanup(lambda: setattr(web_server.urllib.request, "urlopen", original_urlopen)) + return FakeHandler() + + def test_probe_result_is_cached(self): + handler = self._handler_with_fake_probe("http://gateway.invalido:20128") + + for _ in range(10): + self.assertTrue(handler.probe_router()) + + # 10 chamadas ao /healthz, uma única ida à rede. + self.assertEqual(len(self.calls), 1) + + def test_cache_expires_after_the_ttl(self): + handler = self._handler_with_fake_probe("http://gateway.invalido:20128") + handler.probe_router() + + # Envelhece a entrada de cache além do TTL. + cached_at, cached_ok = web_server._router_probe_cache["http://gateway.invalido:20128"] + web_server._router_probe_cache["http://gateway.invalido:20128"] = ( + cached_at - web_server.ROUTER_PROBE_TTL_SECONDS - 1, + cached_ok, + ) + + handler.probe_router() + self.assertEqual(len(self.calls), 2) + + def test_no_router_url_means_no_network_call(self): + handler = self._handler_with_fake_probe("") + self.assertTrue(handler.probe_router()) + self.assertEqual(len(self.calls), 0) + + +if __name__ == "__main__": + unittest.main() From 670f6f851dfdf52c349f9765f297918dfacc7d23 Mon Sep 17 00:00:00 2001 From: Eliel Sousa Date: Sat, 12 Sep 2026 10:58:52 -0300 Subject: [PATCH 02/16] feat(web): dashboard server-side, i18n com bandeiras, logs do cron e provedores locais Render server-side - O HTML passa a ser montado em web/render.py com os dados ja embutidos. O navegador nao consulta mais /api/status para desenhar a tela, entao o SQLite fica inteiramente do lado do servidor. - Acoes viram POST-Redirect-GET (/acoes/*), e a pagina funciona sem JavaScript. - Removido o Access-Control-Allow-Origin: * de /api/status; adicionados Cache-Control: no-store, X-Frame-Options, X-Content-Type-Options e Referrer-Policy. A tela nunca mais vem do cache do navegador. - Botao "Atualizar" explicito, alem de "Sincronizar agora", "Executar agora" e "Testar conexao". Icones e i18n - Bootstrap 5 + Bootstrap Icons + flag-icons + jQuery no lugar dos emojis. - Idioma padrao ingles, com portugues e espanhol no seletor de bandeiras. - A escolha e persistida em SQLite proprio (prefs.py), nunca no banco do gateway e nunca no localStorage. Diagnostico de renovacao - Cada conexao mostra por que foi ou nao renovada. O caso reportado - "o cron rodou 4 vezes e nao renovou o Antigravity" - era comportamento correto: 24min restantes contra uma margem de 15min. Agora a tela diz isso em vez de so exibir "0 renovadas". - O cron guarda o log de cada ciclo e o painel tem o botao Logs com o historico por execucao; um ciclo que falhou aparece marcado em vermelho. Provedores locais - ConnectionRecord.is_local reconhece Ollama/vLLM/LM Studio/LocalAI e qualquer baseUrl apontando para o host. Uma instancia local costuma usar chave de fachada e por isso aparecia rotulada como provedor de nuvem. - LocalProvider consulta /api/tags e /v1/models, grava os modelos descobertos e marca a conexao como unreachable quando a instancia nao responde - antes ela era dada como saudavel as cegas. - A tabela mostra a baseUrl e os modelos servidos. Testes: 69 -> 94 passando (test_web_render.py e novo). --- src/nine_rtksync/cron.py | 25 ++ src/nine_rtksync/i18n.py | 281 ++++++++++++ src/nine_rtksync/models.py | 34 +- src/nine_rtksync/prefs.py | 77 ++++ src/nine_rtksync/providers/local.py | 91 +++- src/nine_rtksync/web/index.html | 633 ---------------------------- src/nine_rtksync/web/render.py | 619 +++++++++++++++++++++++++++ src/nine_rtksync/web/server.py | 207 ++++++++- tests/test_web_render.py | 304 +++++++++++++ 9 files changed, 1604 insertions(+), 667 deletions(-) create mode 100644 src/nine_rtksync/i18n.py create mode 100644 src/nine_rtksync/prefs.py delete mode 100644 src/nine_rtksync/web/index.html create mode 100644 src/nine_rtksync/web/render.py create mode 100644 tests/test_web_render.py diff --git a/src/nine_rtksync/cron.py b/src/nine_rtksync/cron.py index 6cab63a..4a0f14f 100644 --- a/src/nine_rtksync/cron.py +++ b/src/nine_rtksync/cron.py @@ -8,6 +8,30 @@ from .logs import get_logger +def _extract_log_lines(res: Any) -> List[str]: + """Extrai as acoes registradas pelo motor de sincronizacao neste ciclo. + + Guarda so o que explica o resultado — erro, renovacao, auto-cura. Um ciclo + sem nada a fazer devolve lista vazia, e a tela mostra isso como tal. + """ + if not isinstance(res, dict): + return [f"Resultado inesperado do motor: {res!r}"] + + lines: List[str] = [] + if res.get("error"): + lines.append(f"ERRO: {res['error']}") + + for detail in res.get("details", []) or []: + actions = detail.get("actions") or [] + if not actions: + continue + label = f"{detail.get('provider', '?')} · {detail.get('name', '?')}" + for action in actions: + lines.append(f"{label}: {action}") + + return lines + + class CronScheduler: """Agendador em background que gerencia a renovação contínua de contas OAuth e integridade de conexões.""" @@ -96,6 +120,7 @@ def _execute_cycle(self, reason: str = "scheduled_interval") -> Dict[str, Any]: "refreshedCount": refreshed, "success": res.get("success", True) if isinstance(res, dict) else False, "error": res.get("error") if isinstance(res, dict) else None, + "log": _extract_log_lines(res), } with self._lock: diff --git a/src/nine_rtksync/i18n.py b/src/nine_rtksync/i18n.py new file mode 100644 index 0000000..9ea7aea --- /dev/null +++ b/src/nine_rtksync/i18n.py @@ -0,0 +1,281 @@ +"""Internacionalização da interface do 9RTKSync. + +Idioma padrão: inglês. Português e espanhol são opcionais e escolhidos pelo +seletor de bandeiras no topo do painel. A escolha é persistida em SQLite +(ver prefs.py), então sobrevive a troca de navegador e a limpeza de cache. + +Chave ausente numa tradução cai para o inglês, nunca para a chave crua. +""" + +from typing import Dict + +DEFAULT_LANGUAGE = "en" + +# Código do idioma -> (rótulo nativo, classe de bandeira do flag-icons) +LANGUAGES: Dict[str, tuple] = { + "en": ("English", "fi-us"), + "pt": ("Português", "fi-br"), + "es": ("Español", "fi-es"), +} + +TRANSLATIONS: Dict[str, Dict[str, str]] = { + "en": { + "app.subtitle": "9Router Universal Token & Connection Synchronizer", + "app.gateway_unset": "gateway not configured", + "action.refresh": "Refresh", + "action.access": "Access", + "action.sync_now": "Sync now", + "action.run_now": "Run now", + "action.test_connection": "Test connection", + "action.save_credentials": "Save credentials", + "action.change_credentials": "Change credentials", + "action.close": "Close", + "action.refresh_title": "Reload data from the server", + "metric.total_connections": "Total connections", + "metric.oauth_accounts": "OAuth accounts", + "metric.api_keys": "API keys", + "metric.combos": "Registered combos", + "security.title": "Security warning:", + "security.body": "the dashboard still uses the factory default credentials " + "(admin / pathbit). Change the password or set " + "DASHBOARD_USER/DASHBOARD_PASSWORD in the environment.", + "gateway.title": "Gateway connection", + "gateway.gateway": "Gateway", + "gateway.status": "Status", + "gateway.latency": "Latency", + "gateway.database": "SQLite database", + "gateway.offline": "OFFLINE", + "gateway.no_response": "no response", + "cron.title": "Renewal scheduler", + "cron.active": "Active · every {interval}s", + "cron.disabled": "Disabled (CRON_ENABLED=0)", + "cron.last_run": "Last run", + "cron.next_run": "Next run", + "cron.total_runs": "Total cycles", + "cron.total_renewals": "Tokens renewed", + "cron.last_result": "Last result", + "cron.no_runs": "No cycle has run yet", + "cron.result_line": "{inspected} evaluated · {refreshed} renewed ({duration}ms)", + "connections.title": "Monitored connections", + "connections.empty": "No connection registered on the gateway.", + "table.provider": "Provider", + "table.name": "Name", + "table.type": "Type", + "table.status": "Status", + "table.remaining": "Time remaining", + "table.diagnosis": "Renewal diagnosis", + "table.models": "models", + "reason.local_ok": "Local instance answered with {count} model(s)", + "reason.local_unreachable": "Local instance did not answer the model catalog", + "table.combo": "Combo", + "table.cascade": "Model cascade", + "combos.title": "Resilience combos", + "combos.empty": "No fallback combo registered.", + "type.oauth": "OAuth 2.0", + "type.api_key": "API key", + "type.local": "Local", + "health.ativo": "Active", + "health.expirando_em_breve": "Expiring", + "health.expirado": "Expired", + "health.rate_limited": "Rate limited", + "health.sem_expiracao": "No expiry", + "health.desconhecido": "Unknown", + "duration.unlimited": "Unlimited / N/A", + "duration.expired": "Expired", + "reason.api_key": "Static key: never expires, nothing to renew", + "reason.no_expiry": "No expiry recorded: will be renewed on the next sweep", + "reason.expired": "Token expired: renewal will be attempted on the next sweep", + "reason.inside_margin": "Within the {margin} min margin: will be renewed on the next sweep", + "reason.outside_margin": "Outside the {margin} min margin: renewal expected in ~{eta}", + "auth.title": "Dashboard credentials", + "auth.user": "User", + "auth.new_password": "New password", + "auth.min_chars": "Minimum of 4 characters.", + "auth.env_managed": "Credentials come from DASHBOARD_USER/" + "DASHBOARD_PASSWORD. Change them in the environment " + "and restart the service.", + "footer.signed_in": "Signed in as", + "footer.generated": "Data rendered on the server at", + "language.label": "Language", + }, + "pt": { + "app.subtitle": "9Router Universal Token & Connection Synchronizer", + "app.gateway_unset": "gateway não configurado", + "action.refresh": "Atualizar", + "action.access": "Acesso", + "action.sync_now": "Sincronizar agora", + "action.run_now": "Executar agora", + "action.test_connection": "Testar conexão", + "action.save_credentials": "Salvar credenciais", + "action.change_credentials": "Alterar credenciais", + "action.close": "Fechar", + "action.refresh_title": "Recarregar os dados do servidor", + "metric.total_connections": "Total de conexões", + "metric.oauth_accounts": "Contas OAuth", + "metric.api_keys": "Chaves de API", + "metric.combos": "Combos registrados", + "security.title": "Atenção de segurança:", + "security.body": "o painel ainda usa as credenciais padrão de fábrica " + "(admin / pathbit). Altere a senha ou defina " + "DASHBOARD_USER/DASHBOARD_PASSWORD no ambiente.", + "gateway.title": "Conexão com o gateway", + "gateway.gateway": "Gateway", + "gateway.status": "Status", + "gateway.latency": "Latência", + "gateway.database": "Banco SQLite", + "gateway.offline": "OFFLINE", + "gateway.no_response": "sem resposta", + "cron.title": "Agendador de renovação", + "cron.active": "Ativo · a cada {interval}s", + "cron.disabled": "Desativado (CRON_ENABLED=0)", + "cron.last_run": "Última execução", + "cron.next_run": "Próxima execução", + "cron.total_runs": "Ciclos totais", + "cron.total_renewals": "Tokens renovados", + "cron.last_result": "Último resultado", + "cron.no_runs": "Nenhum ciclo executado ainda", + "cron.result_line": "{inspected} avaliadas · {refreshed} renovadas ({duration}ms)", + "connections.title": "Conexões monitoradas", + "connections.empty": "Nenhuma conexão registrada no gateway.", + "table.provider": "Provedor", + "table.name": "Nome", + "table.type": "Tipo", + "table.status": "Status", + "table.remaining": "Validade restante", + "table.diagnosis": "Diagnóstico da renovação", + "table.models": "modelos", + "reason.local_ok": "Instância local respondeu com {count} modelo(s)", + "reason.local_unreachable": "Instância local não respondeu ao catálogo de modelos", + "table.combo": "Combo", + "table.cascade": "Cascata de modelos", + "combos.title": "Combos de resiliência", + "combos.empty": "Nenhum combo de fallback registrado.", + "type.oauth": "OAuth 2.0", + "type.api_key": "Chave de API", + "type.local": "Local", + "health.ativo": "Ativo", + "health.expirando_em_breve": "Expirando", + "health.expirado": "Expirado", + "health.rate_limited": "Rate limit", + "health.sem_expiracao": "Sem expiração", + "health.desconhecido": "Desconhecido", + "duration.unlimited": "Ilimitado / N/A", + "duration.expired": "Expirado", + "reason.api_key": "Chave estática: não expira, nada a renovar", + "reason.no_expiry": "Sem expiração registrada: será renovada na próxima varredura", + "reason.expired": "Token expirado: renovação será tentada na próxima varredura", + "reason.inside_margin": "Dentro da margem de {margin} min: será renovada na próxima varredura", + "reason.outside_margin": "Fora da margem de {margin} min: renovação prevista em ~{eta}", + "auth.title": "Credenciais do painel", + "auth.user": "Usuário", + "auth.new_password": "Nova senha", + "auth.min_chars": "Mínimo de 4 caracteres.", + "auth.env_managed": "As credenciais vêm de DASHBOARD_USER/" + "DASHBOARD_PASSWORD. Altere-as no ambiente " + "e reinicie o serviço.", + "footer.signed_in": "Autenticado como", + "footer.generated": "Dados gerados no servidor em", + "language.label": "Idioma", + }, + "es": { + "app.subtitle": "9Router Universal Token & Connection Synchronizer", + "app.gateway_unset": "gateway no configurado", + "action.refresh": "Actualizar", + "action.access": "Acceso", + "action.sync_now": "Sincronizar ahora", + "action.run_now": "Ejecutar ahora", + "action.test_connection": "Probar conexión", + "action.save_credentials": "Guardar credenciales", + "action.change_credentials": "Cambiar credenciales", + "action.close": "Cerrar", + "action.refresh_title": "Recargar los datos del servidor", + "metric.total_connections": "Conexiones totales", + "metric.oauth_accounts": "Cuentas OAuth", + "metric.api_keys": "Claves de API", + "metric.combos": "Combos registrados", + "security.title": "Aviso de seguridad:", + "security.body": "el panel todavía usa las credenciales de fábrica " + "(admin / pathbit). Cambie la contraseña o defina " + "DASHBOARD_USER/DASHBOARD_PASSWORD en el entorno.", + "gateway.title": "Conexión con el gateway", + "gateway.gateway": "Gateway", + "gateway.status": "Estado", + "gateway.latency": "Latencia", + "gateway.database": "Base de datos SQLite", + "gateway.offline": "DESCONECTADO", + "gateway.no_response": "sin respuesta", + "cron.title": "Programador de renovación", + "cron.active": "Activo · cada {interval}s", + "cron.disabled": "Desactivado (CRON_ENABLED=0)", + "cron.last_run": "Última ejecución", + "cron.next_run": "Próxima ejecución", + "cron.total_runs": "Ciclos totales", + "cron.total_renewals": "Tokens renovados", + "cron.last_result": "Último resultado", + "cron.no_runs": "Aún no se ejecutó ningún ciclo", + "cron.result_line": "{inspected} evaluadas · {refreshed} renovadas ({duration}ms)", + "connections.title": "Conexiones monitoreadas", + "connections.empty": "No hay conexiones registradas en el gateway.", + "table.provider": "Proveedor", + "table.name": "Nombre", + "table.type": "Tipo", + "table.status": "Estado", + "table.remaining": "Validez restante", + "table.diagnosis": "Diagnóstico de la renovación", + "table.models": "modelos", + "reason.local_ok": "La instancia local respondió con {count} modelo(s)", + "reason.local_unreachable": "La instancia local no respondió al catálogo de modelos", + "table.combo": "Combo", + "table.cascade": "Cascada de modelos", + "combos.title": "Combos de resiliencia", + "combos.empty": "No hay combos de respaldo registrados.", + "type.oauth": "OAuth 2.0", + "type.api_key": "Clave de API", + "type.local": "Local", + "health.ativo": "Activo", + "health.expirando_em_breve": "Por expirar", + "health.expirado": "Expirado", + "health.rate_limited": "Límite de tasa", + "health.sem_expiracao": "Sin expiración", + "health.desconhecido": "Desconocido", + "duration.unlimited": "Ilimitado / N/D", + "duration.expired": "Expirado", + "reason.api_key": "Clave estática: no expira, nada que renovar", + "reason.no_expiry": "Sin expiración registrada: se renovará en el próximo barrido", + "reason.expired": "Token expirado: se intentará renovar en el próximo barrido", + "reason.inside_margin": "Dentro del margen de {margin} min: se renovará en el próximo barrido", + "reason.outside_margin": "Fuera del margen de {margin} min: renovación prevista en ~{eta}", + "auth.title": "Credenciales del panel", + "auth.user": "Usuario", + "auth.new_password": "Nueva contraseña", + "auth.min_chars": "Mínimo de 4 caracteres.", + "auth.env_managed": "Las credenciales vienen de DASHBOARD_USER/" + "DASHBOARD_PASSWORD. Cámbielas en el entorno " + "y reinicie el servicio.", + "footer.signed_in": "Autenticado como", + "footer.generated": "Datos generados en el servidor a las", + "language.label": "Idioma", + }, +} + + +def normalize_language(code: str) -> str: + """Normaliza um código de idioma para um dos suportados, caindo no padrão.""" + if not code: + return DEFAULT_LANGUAGE + base = str(code).strip().lower().replace("_", "-").split("-")[0] + return base if base in LANGUAGES else DEFAULT_LANGUAGE + + +def translate(key: str, lang: str = DEFAULT_LANGUAGE, **params) -> str: + """Traduz uma chave, com fallback para inglês e interpolação opcional.""" + lang = normalize_language(lang) + text = TRANSLATIONS.get(lang, {}).get(key) + if text is None: + text = TRANSLATIONS[DEFAULT_LANGUAGE].get(key, key) + if params: + try: + return text.format(**params) + except (KeyError, IndexError): + return text + return text diff --git a/src/nine_rtksync/models.py b/src/nine_rtksync/models.py index 8b5c91b..56538da 100644 --- a/src/nine_rtksync/models.py +++ b/src/nine_rtksync/models.py @@ -46,6 +46,35 @@ def refresh_token(self) -> Optional[str]: def api_key(self) -> Optional[str]: return self.data.get("apiKey") + # Nomes de provedor que identificam uma instancia local / compativel com OpenAI. + LOCAL_PROVIDER_MARKERS = ("ollama", "vllm", "lmstudio", "llamacpp", "localai", "openai-compatible") + + @property + def is_local(self) -> bool: + """Indica se a conexao aponta para uma instancia local (Ollama, vLLM, LM Studio...). + + Uma instancia local costuma exigir uma chave de API de fachada, entao + checar apenas has_api_key a classificaria como provedor de nuvem. + """ + provider = self.provider.lower() + if any(marker in provider for marker in self.LOCAL_PROVIDER_MARKERS): + return True + base_url = str(self.data.get("baseUrl") or "") + return any(host in base_url for host in ("localhost", "127.0.0.1", "0.0.0.0", "host.docker.internal")) + + @property + def base_url(self) -> Optional[str]: + """URL base do provedor, quando declarada.""" + return self.data.get("baseUrl") or self.data.get("baseURL") or None + + @property + def local_models(self) -> list: + """Modelos descobertos na instancia local na ultima varredura.""" + models = self.data.get("discoveredModels") or self.data.get("models") or [] + if isinstance(models, str): + return [models] + return [str(m) for m in models if m] + @property def expires_at_ms(self) -> Optional[int]: """Devolve a expiração normalizada em milissegundos epoch, se aplicável.""" @@ -88,6 +117,7 @@ def health_status(self) -> str: if self.data.get("rateLimitedUntil"): return "rate_limited" return "ativo" - if self.data.get("baseUrl") or "ollama" in self.provider.lower(): + if self.is_local: return "ativo" - return "ativo" if self.data.get("testStatus") == "ok" else "desconhecido" + # O gateway usa "ok" e o OmniRoute usa "active"; ambos significam saudavel. + return "ativo" if self.data.get("testStatus") in ("ok", "active") else "desconhecido" diff --git a/src/nine_rtksync/prefs.py b/src/nine_rtksync/prefs.py new file mode 100644 index 0000000..3aeb855 --- /dev/null +++ b/src/nine_rtksync/prefs.py @@ -0,0 +1,77 @@ +"""Preferências da interface persistidas em SQLite. + +Usa um banco próprio do sincronizador, nunca o SQLite do gateway: escrever +tabelas nossas no banco do 9Router criaria acoplamento de schema e risco de +conflito com as migrações dele. + +O caminho segue o mesmo diretório das demais credenciais locais do painel, então +a preferência sobrevive a troca de navegador, aba anônima e limpeza de cache — +ao contrário do localStorage. +""" + +import os +import sqlite3 +import threading +from typing import Optional + +PREFS_FILE_NAME = "ui_prefs.sqlite" +_lock = threading.Lock() + + +def resolve_prefs_path(base_dir: str) -> str: + """Caminho do banco de preferências dentro do diretório informado.""" + return os.path.join(base_dir or ".", PREFS_FILE_NAME) + + +def _connect(path: str) -> sqlite3.Connection: + conn = sqlite3.connect(path, timeout=10.0) + conn.execute( + "CREATE TABLE IF NOT EXISTS ui_preferences (" + " key TEXT PRIMARY KEY," + " value TEXT NOT NULL," + " updated_at TEXT NOT NULL DEFAULT (datetime('now'))" + ")" + ) + return conn + + +def get_preference(path: str, key: str, default: Optional[str] = None) -> Optional[str]: + """Lê uma preferência. Devolve o padrão quando o banco não existe ou falha.""" + if not path: + return default + try: + with _lock: + conn = _connect(path) + try: + row = conn.execute( + "SELECT value FROM ui_preferences WHERE key = ?", (key,) + ).fetchone() + finally: + conn.close() + return row[0] if row else default + except sqlite3.Error: + return default + + +def set_preference(path: str, key: str, value: str) -> bool: + """Grava uma preferência. Devolve False quando o disco não permite escrita.""" + if not path: + return False + try: + os.makedirs(os.path.dirname(path) or ".", exist_ok=True) + with _lock: + conn = _connect(path) + try: + conn.execute( + "INSERT INTO ui_preferences (key, value, updated_at) " + "VALUES (?, ?, datetime('now')) " + "ON CONFLICT(key) DO UPDATE SET value = excluded.value, " + "updated_at = excluded.updated_at", + (key, str(value)), + ) + conn.commit() + finally: + conn.close() + return True + except (sqlite3.Error, OSError): + return False diff --git a/src/nine_rtksync/providers/local.py b/src/nine_rtksync/providers/local.py index 0b8a02b..19d7369 100644 --- a/src/nine_rtksync/providers/local.py +++ b/src/nine_rtksync/providers/local.py @@ -1,24 +1,68 @@ """Manipulador para provedores locais e compatíveis com OpenAI (Ollama, vLLM, LMStudio).""" +import json +import urllib.error +import urllib.request from datetime import datetime, timezone from typing import Any, Dict, List, Optional, Tuple from ..models import ConnectionRecord from .base import BaseProvider +# Endpoints de catálogo, na ordem de tentativa: nativo do Ollama e o padrão OpenAI. +MODEL_CATALOG_PATHS = ("/api/tags", "/v1/models", "/models") +PROBE_TIMEOUT_SECONDS = 3.0 + class LocalProvider(BaseProvider): """Monitor de integridade para instâncias locais e proxies compatíveis com OpenAI.""" def can_handle(self, conn: ConnectionRecord) -> bool: - p = conn.provider.lower() - return ( - "ollama" in p - or "openai-compatible" in p - or "vllm" in p - or "lmstudio" in p - or bool(conn.data.get("baseUrl") and not conn.is_oauth and not conn.has_api_key) - ) + return conn.is_local + + def discover_models(self, base_url: str, api_key: str = "") -> Tuple[List[str], str]: + """Consulta o catálogo da instância local. Devolve (modelos, erro).""" + if not base_url: + return [], "baseUrl não declarada na conexão" + + root = base_url.rstrip("/") + # Uma baseUrl no formato OpenAI já termina em /v1; a raiz serve /api/tags. + origin = root[: -len("/v1")] if root.endswith("/v1") else root + last_error = "" + + for path in MODEL_CATALOG_PATHS: + target = f"{origin}{path}" if path.startswith("/api") else f"{root}{path}" + try: + req = urllib.request.Request(target, headers={"User-Agent": "9RTKSync-LocalProbe/1.0"}) + if api_key: + req.add_header("Authorization", f"Bearer {api_key}") + with urllib.request.urlopen(req, timeout=PROBE_TIMEOUT_SECONDS) as resp: + payload = json.loads(resp.read().decode("utf-8")) + except (urllib.error.URLError, urllib.error.HTTPError, OSError, ValueError) as e: + last_error = str(e) + continue + + models = self._extract_model_names(payload) + if models: + return models, "" + + return [], last_error or "nenhum modelo retornado pela instância local" + + @staticmethod + def _extract_model_names(payload: Any) -> List[str]: + """Extrai nomes de modelo dos formatos do Ollama (/api/tags) e da OpenAI (/v1/models).""" + if not isinstance(payload, dict): + return [] + entries = payload.get("models") or payload.get("data") or [] + names = [] + for entry in entries: + if isinstance(entry, str): + names.append(entry) + elif isinstance(entry, dict): + name = entry.get("name") or entry.get("id") or entry.get("model") + if name: + names.append(str(name)) + return names def check_and_refresh( self, conn: ConnectionRecord, margin_seconds: int = 900, **kwargs @@ -34,14 +78,29 @@ def check_and_refresh( modified = True messages.append("Trava de rateLimitedUntil removida da conexão local") - # Garante status ativo - if not data.get("testStatus") or data.get("testStatus") != "ok": - data["testStatus"] = "ok" - data["lastTested"] = datetime.now(timezone.utc).isoformat().replace("+00:00", "Z") - modified = True - messages.append("Status local marcado como operacional (ok)") + # Descobre os modelos servidos pela instância local, para o painel exibi-los. + models, probe_error = self.discover_models(conn.base_url or "", conn.api_key or "") + now_iso = datetime.now(timezone.utc).isoformat().replace("+00:00", "Z") + + if models: + if data.get("discoveredModels") != models: + data["discoveredModels"] = models + modified = True + messages.append(f"Instância local respondeu com {len(models)} modelo(s): {', '.join(models[:5])}") - if not messages: - messages.append("Serviço local ativo e operacional") + if data.get("testStatus") != "ok": + data["testStatus"] = "ok" + data["lastTested"] = now_iso + modified = True + messages.append("Status local marcado como operacional (ok)") + else: + # Sem resposta do catálogo a conexão não é dada como saudável às cegas: + # é exatamente o caso "o Ollama local caiu e ninguém percebeu". + messages.append(f"Instância local não respondeu ao catálogo de modelos: {probe_error}") + if data.get("testStatus") != "unreachable": + data["testStatus"] = "unreachable" + data["lastError"] = probe_error + data["lastTested"] = now_iso + modified = True return modified, data if modified else None, messages diff --git a/src/nine_rtksync/web/index.html b/src/nine_rtksync/web/index.html deleted file mode 100644 index 06c5e29..0000000 --- a/src/nine_rtksync/web/index.html +++ /dev/null @@ -1,633 +0,0 @@ - - - - - - 9RTKSync · 9Router Universal Token & Connection Synchronizer - - - -
- - -
-
- ⚠️ Atenção de Segurança: Você está utilizando as credenciais padrão de fábrica (admin / pathbit). Recomenda-se alterar a senha para proteger o dashboard. -
- -
- -
-
-

⚡ 9RTKSync

-

9Router Universal Token & Connection Synchronizer (9Router)

-
-
- - -
-
- - -
-
-
Total de Conexões
-
-
-
-
-
Contas OAuth Ativas
-
-
-
-
-
Provedores API Key
-
-
-
-
-
Combos Registrados
-
-
-
-
- - -
- - -
-
- 🔌 Conexão com Gateway 9Router - -
-

Valida a resposta HTTP, latência e acesso ao banco de dados SQLite compartilhado.

-
-
Gateway URL:-
-
Status Gateway:Aguardando teste...
-
Latência HTTP:-
-
Banco SQLite:-
-
Diagnóstico:Clique em 'Testar Conexão'
-
-
- - -
-
- ⏰ Cron Scheduler (Renovação Contínua) - -
-
-
- Ativo - (a cada 300s) -
- Total ciclos: 0 -
-
-
Última Execução:-
-
Próxima Execução:-
-
Tokens Renovados:0
-
Último Resultado:-
-
-
- -
- - -
🔌 Conexões Monitoradas (OAuth, API Key e Provedores Locais)
-
- - - - - - - - - - - - - -
ProvedorNomeTipoStatusValidade Restante
Carregando conexões do banco de dados...
-
- - -
🔀 Combos de Resiliência e Fallback
-
- - - - - - - - - - - - -
Nome do ComboTipoQtd ModelosCascata de Modelos
Carregando combos cadastrados...
-
- - -
📋 Histórico de Ciclos do Cron
-
- - - - - - - - - - - - - - -
Data e Hora (UTC)DisparoDuraçãoContas InspecionadasOAuth RenovadosStatus
Nenhum ciclo executado ainda.
-
- - -
- - - - - - - diff --git a/src/nine_rtksync/web/render.py b/src/nine_rtksync/web/render.py new file mode 100644 index 0000000..2de9a36 --- /dev/null +++ b/src/nine_rtksync/web/render.py @@ -0,0 +1,619 @@ +"""Renderização server-side do dashboard do 9RTKSync. + +Todo o HTML é montado aqui, no servidor, com os dados já embutidos. O navegador +nunca consulta o banco: ele recebe a página pronta. Isso mantém o SQLite +inteiramente do lado do servidor e faz o painel funcionar mesmo com JavaScript +desabilitado — o jQuery serve só para conforto. + +Ícones: Bootstrap Icons e flag-icons (fontes/CSS de ícones), nunca emoji. +Idioma padrão: inglês, com português e espanhol no seletor de bandeiras. +""" + +import html +from datetime import datetime, timezone +from typing import Any, Dict, List, Optional + +from ..i18n import DEFAULT_LANGUAGE, LANGUAGES, normalize_language, translate + +BOOTSTRAP_CSS = "https://cdn.jsdelivr.net/npm/bootstrap@5.3.3/dist/css/bootstrap.min.css" +BOOTSTRAP_ICONS = "https://cdn.jsdelivr.net/npm/bootstrap-icons@1.11.3/font/bootstrap-icons.min.css" +FLAG_ICONS = "https://cdn.jsdelivr.net/npm/flag-icons@7.2.3/css/flag-icons.min.css" +BOOTSTRAP_JS = "https://cdn.jsdelivr.net/npm/bootstrap@5.3.3/dist/js/bootstrap.bundle.min.js" +JQUERY_JS = "https://cdn.jsdelivr.net/npm/jquery@3.7.1/dist/jquery.min.js" + +# Estado semântico -> (classe do badge, ícone) +HEALTH_PRESENTATION = { + "ativo": ("text-bg-success", "bi-check-circle-fill"), + "expirando_em_breve": ("text-bg-warning", "bi-hourglass-split"), + "expirado": ("text-bg-danger", "bi-x-octagon-fill"), + "rate_limited": ("text-bg-warning", "bi-pause-circle-fill"), + "sem_expiracao": ("text-bg-secondary", "bi-infinity"), + "desconhecido": ("text-bg-secondary", "bi-question-circle-fill"), +} + + +def esc(value: Any) -> str: + """Escapa qualquer valor para inserção segura no HTML.""" + return html.escape(str(value if value is not None else ""), quote=True) + + +def format_duration(seconds: Optional[int], lang: str = DEFAULT_LANGUAGE) -> str: + """Formata uma duração em segundos de forma legível.""" + if seconds is None: + return translate("duration.unlimited", lang) + if seconds <= 0: + return translate("duration.expired", lang) + if seconds < 60: + return f"{seconds}s" + minutes = seconds // 60 + if minutes < 60: + return f"{minutes} min" + hours = minutes // 60 + rest = minutes % 60 + if hours < 24: + return f"{hours}h {rest:02d}min" + days = hours // 24 + return f"{days}d {hours % 24}h" + + +def format_timestamp(value: Optional[str]) -> str: + """Normaliza um timestamp ISO para exibição.""" + if not value: + return "—" + return str(value).replace("T", " ").replace("Z", " UTC") + + +def render_refresh_reason(conn: Any, refresh_margin: int, lang: str = DEFAULT_LANGUAGE) -> str: + """Explica, em uma frase, por que a conexão foi ou não renovada. + + Sem isso o painel mostra apenas "0 renovadas" e não há como distinguir + "nada precisava ser renovado" de "a renovação falhou". + """ + if conn.is_local: + models = conn.local_models + if models: + return translate("reason.local_ok", lang, count=len(models)) + return translate("reason.local_unreachable", lang) + + if not conn.is_oauth: + return translate("reason.api_key", lang) + + remaining = conn.remaining_seconds + if remaining is None: + return translate("reason.no_expiry", lang) + if remaining <= 0: + return translate("reason.expired", lang) + + margin_min = max(1, refresh_margin // 60) + if remaining <= refresh_margin: + return translate("reason.inside_margin", lang, margin=margin_min) + return translate( + "reason.outside_margin", + lang, + margin=margin_min, + eta=format_duration(remaining - refresh_margin, lang), + ) + + +def health_badge(status: str, lang: str) -> str: + """Monta o badge de saúde com ícone de fonte.""" + css, icon = HEALTH_PRESENTATION.get(status, HEALTH_PRESENTATION["desconhecido"]) + label = translate(f"health.{status}", lang) + return ( + f'' + f'{esc(label)}' + ) + + +def render_language_switcher(current: str) -> str: + """Seletor de idioma com bandeiras reais (flag-icons), não emoji.""" + current = normalize_language(current) + _, current_flag = LANGUAGES[current] + items = [] + for code, (label, flag) in LANGUAGES.items(): + active = " active" if code == current else "" + items.append( + f'
  • ' + ) + return f""" + """ + + +def metric_card(label: str, value: Any, icon: str, tone: str) -> str: + return f""" +
    +
    +
    +
    + {esc(label)} +
    +
    {esc(value)}
    +
    +
    +
    """ + + +def render_security_banner(is_default_password: bool, lang: str) -> str: + if not is_default_password: + return "" + return f""" + """ + + +def render_connections_table(connections: List[Any], refresh_margin: int, lang: str) -> str: + if not connections: + return f""" +
    + + {esc(translate("connections.empty", lang))} +
    """ + + rows = [] + for c in connections: + if c.is_local: + kind, kind_icon = translate("type.local", lang), "bi-hdd-network" + elif c.is_oauth: + kind, kind_icon = translate("type.oauth", lang), "bi-person-badge" + elif c.has_api_key: + kind, kind_icon = translate("type.api_key", lang), "bi-key" + else: + kind, kind_icon = translate("type.local", lang), "bi-hdd-network" + + # Instancia local: mostra a origem e os modelos que ela realmente serve. + detail = "" + if c.is_local: + models = c.local_models + parts = [] + if c.base_url: + parts.append(f'{esc(c.base_url)}') + if models: + preview = ", ".join(models[:3]) + (f" (+{len(models) - 3})" if len(models) > 3 else "") + parts.append( + f'{len(models)} ' + f'{esc(translate("table.models", lang))} {esc(preview)}' + ) + if parts: + detail = f'
    {" · ".join(parts)}
    ' + + rows.append(f""" + + {esc(c.provider)} + {esc(c.name)}{detail} + + {esc(kind)} + + {health_badge(c.health_status, lang)} + {esc(format_duration(c.remaining_seconds, lang))} + {esc(render_refresh_reason(c, refresh_margin, lang))} + """) + + return f""" +
    + + + + + + + + + + + + {"".join(rows)} + +
    {esc(translate("table.provider", lang))}{esc(translate("table.name", lang))}{esc(translate("table.type", lang))}{esc(translate("table.status", lang))}{esc(translate("table.remaining", lang))}{esc(translate("table.diagnosis", lang))}
    +
    """ + + +def render_combos_table(combos: List[Dict[str, Any]], lang: str) -> str: + if not combos: + return f""" +
    + + {esc(translate("combos.empty", lang))} +
    """ + + rows = [] + for combo in combos: + models = combo.get("models") or [] + if isinstance(models, str): + models = [models] + preview = ", ".join(str(m) for m in models[:4]) + if len(models) > 4: + preview += f" (+{len(models) - 4})" + rows.append(f""" + + {esc(combo.get("name", "—"))} + {esc(preview) or "—"} + """) + + return f""" +
    + + + + + + + + {"".join(rows)} + +
    {esc(translate("table.combo", lang))}{esc(translate("table.cascade", lang))}
    +
    """ + + +def render_cron_history(history: List[Dict[str, Any]], lang: str) -> str: + """Lista de execuções do cron, cada uma com o log do que realmente aconteceu.""" + if not history: + return f'

    {esc(translate("cron.no_runs", lang))}

    ' + + items = [] + for index, entry in enumerate(history): + failed = not entry.get("success", True) or entry.get("error") + tone = "danger" if failed else "secondary" + icon = "bi-exclamation-octagon-fill" if failed else "bi-check-circle" + log_lines = entry.get("log") or [] + if entry.get("error") and not any(str(entry["error"]) in line for line in log_lines): + log_lines = [f"ERRO: {entry['error']}", *log_lines] + + body = ( + "
    " + esc("\n".join(log_lines)) + "
    " + if log_lines + else f'

    {esc(translate("cron.no_runs", lang))}

    ' + ) + + items.append(f""" +
    +

    + +

    +
    +
    {body}
    +
    +
    """) + + return f'
    {"".join(items)}
    ' + + +def render_cron_card(cron: Dict[str, Any], lang: str) -> str: + active = bool(cron.get("active")) + state_icon = "bi-broadcast text-success" if active else "bi-pause-circle text-secondary" + state_text = ( + translate("cron.active", lang, interval=cron.get("intervalSeconds", "—")) + if active + else translate("cron.disabled", lang) + ) + last = cron.get("lastResult") or {} + failed = bool(last) and (not last.get("success", True) or last.get("error")) + + return f""" +
    +
    + + {esc(translate("cron.title", lang))} + +
    + +
    + +
    +
    +
    +
    +

    + {esc(state_text)} +

    +
    +
    {esc(translate("cron.next_run", lang))}
    +
    {esc(format_timestamp(cron.get("nextRunAt")))}
    +
    {esc(translate("cron.total_renewals", lang))}
    +
    {esc(cron.get("totalRenewals", 0))}
    +
    {esc(translate("cron.last_result", lang))}
    +
    + {esc(translate("cron.result_line", lang, + inspected=last.get("totalInspected", 0), + refreshed=last.get("refreshedCount", 0), + duration=last.get("durationMs", 0)) + if last else translate("cron.no_runs", lang))} + {esc(last.get("error") or "")} +
    +
    +
    +
    """ + + +def render_gateway_card(gateway: Dict[str, Any], db_path: str, lang: str) -> str: + online = bool(gateway.get("online")) + tone = "text-success" if online else "text-danger" + icon = "bi-plug-fill" if online else "bi-plug" + label = ( + f'ONLINE (HTTP {esc(gateway.get("statusCode", "—"))})' + if online + else f'{esc(translate("gateway.offline", lang))} — ' + f'{esc(gateway.get("error") or translate("gateway.no_response", lang))}' + ) + + return f""" +
    +
    + + {esc(translate("gateway.title", lang))} + +
    + +
    +
    +
    +
    +
    {esc(translate("gateway.gateway", lang))}
    +
    {esc(gateway.get("url") or "—")}
    +
    {esc(translate("gateway.status", lang))}
    +
    + {label} +
    +
    {esc(translate("gateway.latency", lang))}
    +
    {esc(gateway.get("latencyMs", "—"))} ms
    +
    {esc(translate("gateway.database", lang))}
    +
    + {esc(gateway.get("dbSummary") or "—")} +
    +
    +
    +
    """ + + +def render_flash(flash: Optional[Dict[str, str]]) -> str: + if not flash: + return "" + tone = flash.get("tone", "info") + icon = { + "success": "bi-check-circle-fill", + "danger": "bi-exclamation-octagon-fill", + "warning": "bi-exclamation-triangle-fill", + "info": "bi-info-circle-fill", + }.get(tone, "bi-info-circle-fill") + return f""" +
    + +
    {esc(flash.get("message", ""))}
    +
    """ + + +def render_dashboard( + *, + connections: List[Any], + combos: List[Dict[str, Any]], + cron: Dict[str, Any], + gateway: Dict[str, Any], + db_path: str, + router_url: str, + current_user: str, + is_default_password: bool, + refresh_margin: int, + auth_from_env: bool = False, + flash: Optional[Dict[str, str]] = None, + lang: str = DEFAULT_LANGUAGE, +) -> str: + """Monta a página completa do dashboard, já com todos os dados embutidos.""" + lang = normalize_language(lang) + oauth_count = sum(1 for c in connections if c.is_oauth) + apikey_count = sum(1 for c in connections if c.has_api_key) + generated_at = datetime.now(timezone.utc).strftime("%Y-%m-%d %H:%M:%S UTC") + + metrics = "".join([ + metric_card(translate("metric.total_connections", lang), len(connections), "bi-diagram-2", "text-info"), + metric_card(translate("metric.oauth_accounts", lang), oauth_count, "bi-person-badge", "text-primary"), + metric_card(translate("metric.api_keys", lang), apikey_count, "bi-key", "text-warning"), + metric_card(translate("metric.combos", lang), len(combos), "bi-diagram-3", "text-success"), + ]) + + change_password_block = ( + f""" +
    + +
    {translate("auth.env_managed", lang)}
    +
    """ + if auth_from_env + else f""" +
    +
    + + +
    +
    + + +
    {esc(translate("auth.min_chars", lang))}
    +
    + +
    """ + ) + + return f""" + + + + + + 9RTKSync + + + + + + +
    + + {render_flash(flash)} + {render_security_banner(is_default_password, lang)} + +
    +
    + +
    +

    9RTKSync

    +

    + {esc(router_url or translate("app.gateway_unset", lang))} +

    +
    +
    +
    + {render_language_switcher(lang)} + + {esc(translate("action.refresh", lang))} + + +
    + +
    +
    +
    + +
    {metrics} +
    + +
    +
    {render_gateway_card(gateway, db_path, lang)}
    +
    {render_cron_card(cron, lang)}
    +
    + +
    +
    + + {esc(translate("connections.title", lang))} + + {len(connections)} +
    + {render_connections_table(connections, refresh_margin, lang)} +
    + +
    +
    + {esc(translate("combos.title", lang))} +
    + {render_combos_table(combos, lang)} +
    + +
    + + {esc(translate("footer.signed_in", lang))} + {esc(current_user)} + + + {esc(translate("footer.generated", lang))} + {esc(generated_at)} + +
    +
    + + + + + + + + + +""" diff --git a/src/nine_rtksync/web/server.py b/src/nine_rtksync/web/server.py index f17e9fe..6bd975b 100644 --- a/src/nine_rtksync/web/server.py +++ b/src/nine_rtksync/web/server.py @@ -12,9 +12,14 @@ from http.server import BaseHTTPRequestHandler, ThreadingHTTPServer from typing import Any, Callable, Dict, Optional +from urllib.parse import parse_qs, urlparse + from ..config import Settings +from ..i18n import DEFAULT_LANGUAGE, normalize_language +from ..prefs import get_preference, resolve_prefs_path, set_preference from ..database import get_all_combos, get_all_connections from ..models import ConnectionRecord +from .render import render_dashboard # Tempo de vida do resultado da sondagem ao gateway. O /healthz é chamado a cada # 15s pelo Docker; sem cache, cada chamada faria uma requisição HTTP de saída de @@ -93,11 +98,12 @@ def do_GET(self): if not self.require_auth(): return - if self.path in ("/", "/index.html"): - self.serve_html() - elif self.path == "/api/status": + route = urlparse(self.path) + if route.path in ("/", "/index.html"): + self.serve_dashboard(query=parse_qs(route.query)) + elif route.path == "/api/status": self.serve_api_status() - elif self.path == "/api/cron-status": + elif route.path == "/api/cron-status": self.serve_cron_status() else: self.send_error(HTTPStatus.NOT_FOUND, "Pagina nao encontrada") @@ -109,17 +115,110 @@ def do_POST(self): length = int(self.headers.get("Content-Length", 0)) raw_body = self.rfile.read(length) if length > 0 else b"{}" - if self.path == "/api/sync": + route = urlparse(self.path).path + + # Acoes do dashboard: executam e redirecionam de volta para a pagina + # renderizada (POST-Redirect-GET), sem JSON no navegador. + if route.startswith("/acoes/"): + self.handle_dashboard_action(route, raw_body) + return + + if route == "/api/sync": self.handle_sync_request() - elif self.path == "/api/test-gateway": + elif route == "/api/test-gateway": self.handle_test_gateway() - elif self.path == "/api/change-password": + elif route == "/api/change-password": self.handle_change_password(raw_body) - elif self.path == "/api/cron-run": + elif route == "/api/cron-run": self.handle_cron_run() else: self.send_error(HTTPStatus.NOT_FOUND, "Endpoint nao encontrado") + def redirect_to_dashboard(self, tone: str, message: str) -> None: + """Redireciona para a pagina com uma mensagem de resultado.""" + from urllib.parse import urlencode + + query = urlencode({"aviso": message, "tom": tone}) + self.send_response(HTTPStatus.SEE_OTHER) + self.send_header("Location", f"/?{query}") + self.send_header("Content-Length", "0") + self.end_headers() + + def handle_dashboard_action(self, route: str, raw_body: bytes) -> None: + """Executa uma acao do painel e devolve o usuario para a pagina renderizada.""" + if route == "/acoes/sincronizar": + if not self.sync_trigger_callback: + self.redirect_to_dashboard("warning", "Sincronizacao manual indisponivel nesta instancia.") + return + try: + res = self.sync_trigger_callback() or {} + self.redirect_to_dashboard( + "success", + f"Sincronizacao concluida: {res.get('total_connections', 0)} conexoes inspecionadas, " + f"{res.get('normalized', 0)} normalizadas, {res.get('refreshed', 0)} renovadas.", + ) + except Exception as e: + self.redirect_to_dashboard("danger", f"Falha na sincronizacao: {e}") + return + + if route == "/acoes/cron": + if not self.cron_scheduler: + self.redirect_to_dashboard("warning", "Agendador nao esta ativo nesta instancia.") + return + try: + entry = self.cron_scheduler.trigger_now() or {} + self.redirect_to_dashboard( + "success", + f"Ciclo executado em {entry.get('durationMs', 0)}ms: " + f"{entry.get('totalInspected', 0)} avaliadas, {entry.get('refreshedCount', 0)} renovadas.", + ) + except Exception as e: + self.redirect_to_dashboard("danger", f"Falha ao executar o ciclo: {e}") + return + + if route == "/acoes/testar-gateway": + # Invalida o cache para forcar uma sondagem real nesta acao explicita. + with _router_probe_lock: + _router_probe_cache.pop(self.router_url, None) + online = self.probe_router() + self.redirect_to_dashboard( + "success" if online else "danger", + "Gateway respondeu normalmente." if online else "Gateway nao respondeu.", + ) + return + + if route == "/acoes/idioma": + fields = parse_qs(raw_body.decode("utf-8", errors="replace")) + chosen = normalize_language((fields.get("lang", [""])[0] or "").strip()) + set_preference(self.prefs_path(), "language", chosen) + self.send_response(HTTPStatus.SEE_OTHER) + self.send_header("Location", "/") + self.send_header("Content-Length", "0") + self.end_headers() + return + + if route == "/acoes/credenciais": + fields = parse_qs(raw_body.decode("utf-8", errors="replace")) + new_user = (fields.get("user", [""])[0] or "").strip() + new_pass = (fields.get("password", [""])[0] or "").strip() + + if len(new_pass) < 4: + self.redirect_to_dashboard("danger", "A senha deve conter ao menos 4 caracteres.") + return + if self.settings and getattr(self.settings, "dashboard_auth_from_env", False): + self.redirect_to_dashboard( + "warning", + "Credenciais definidas por variavel de ambiente. Altere-as no ambiente e reinicie.", + ) + return + if self.settings and self.settings.update_auth_credentials(new_user, new_pass): + self.redirect_to_dashboard("success", "Credenciais atualizadas. Autentique-se novamente.") + return + self.redirect_to_dashboard("danger", "Nao foi possivel salvar as credenciais.") + return + + self.send_error(HTTPStatus.NOT_FOUND, "Acao nao encontrada") + def probe_router(self) -> bool: """Sonda o gateway com cache: o resultado vale por ROUTER_PROBE_TTL_SECONDS.""" if not self.router_url: @@ -172,16 +271,93 @@ def serve_healthz(self): self.end_headers() self.write_body(payload) - def serve_html(self): - html_path = os.path.join(os.path.dirname(__file__), "index.html") - if os.path.exists(html_path): - with open(html_path, "rb") as f: - content = f.read() - else: - content = b"

    9RTKSync Dashboard

    index.html not found

    " + def prefs_path(self) -> str: + """Banco de preferencias proprio do sincronizador (nunca o do gateway).""" + base = os.path.dirname(self.settings.get_auth_file_path()) if self.settings else "" + return resolve_prefs_path(base or os.path.expanduser("~")) + + def resolve_language(self) -> str: + """Idioma em vigor: preferencia salva no SQLite, senao o padrao (ingles).""" + return normalize_language(get_preference(self.prefs_path(), "language", DEFAULT_LANGUAGE)) + + def collect_dashboard_state(self) -> Dict[str, Any]: + """Le tudo o que a pagina precisa. Roda no servidor: o SQLite nunca sai daqui.""" + conns: list = [] + combos: list = [] + db_exists = bool(self.db_path and os.path.exists(self.db_path)) + if db_exists: + try: + conns = get_all_connections(self.db_path) + except Exception: + conns = [] + try: + combos = get_all_combos(self.db_path) + except Exception: + combos = [] + + start_t = time.time() + online = self.probe_router() + latency_ms = int((time.time() - start_t) * 1000) + + return { + "connections": conns, + "combos": combos, + "cron": self.cron_scheduler.get_status() if self.cron_scheduler else {"active": False}, + "gateway": { + "url": self.router_url, + "online": online, + "statusCode": 200 if online else 0, + "latencyMs": latency_ms, + "dbSummary": ( + f"Operacional ({len(conns)} conexoes, {len(combos)} combos)" + if db_exists + else "Banco nao encontrado" + ), + }, + } + + def serve_dashboard(self, query: Optional[Dict[str, list]] = None): + """Renderiza a pagina inteira no servidor, com os dados ja embutidos.""" + query = query or {} + state = self.collect_dashboard_state() + + flash = None + aviso = (query.get("aviso") or [""])[0] + if aviso: + flash = {"message": aviso, "tone": (query.get("tom") or ["info"])[0]} + + current_user = "admin" + is_default = False + auth_from_env = False + refresh_margin = 900 + if self.settings: + current_user, _ = self.settings.get_auth_credentials() + is_default = self.settings.is_default_password() + auth_from_env = getattr(self.settings, "dashboard_auth_from_env", False) + refresh_margin = self.settings.refresh_margin + + content = render_dashboard( + connections=state["connections"], + combos=state["combos"], + cron=state["cron"], + gateway=state["gateway"], + db_path=self.db_path, + router_url=self.router_url, + current_user=current_user, + is_default_password=is_default, + refresh_margin=refresh_margin, + auth_from_env=auth_from_env, + flash=flash, + lang=self.resolve_language(), + ).encode("utf-8") self.send_response(HTTPStatus.OK) self.send_header("Content-Type", "text/html; charset=utf-8") + # A pagina carrega dados vivos: nunca pode vir do cache do navegador. + self.send_header("Cache-Control", "no-store, must-revalidate") + self.send_header("Referrer-Policy", "no-referrer") + self.send_header("X-Content-Type-Options", "nosniff") + self.send_header("X-Frame-Options", "DENY") self.send_header("Content-Length", str(len(content))) self.end_headers() self.write_body(content) @@ -229,7 +405,6 @@ def serve_api_status(self): body = json.dumps(payload, ensure_ascii=False, indent=2).encode("utf-8") self.send_response(HTTPStatus.OK) self.send_header("Content-Type", "application/json; charset=utf-8") - self.send_header("Access-Control-Allow-Origin", "*") self.send_header("Content-Length", str(len(body))) self.end_headers() self.write_body(body) diff --git a/tests/test_web_render.py b/tests/test_web_render.py new file mode 100644 index 0000000..bb78b33 --- /dev/null +++ b/tests/test_web_render.py @@ -0,0 +1,304 @@ +"""Testes da renderização server-side do dashboard.""" + +import base64 +import json +import os +import re +import sqlite3 +import tempfile +import time +import unittest +import urllib.error +import urllib.request + +from nine_rtksync.config import Settings +from nine_rtksync.models import ConnectionRecord +from nine_rtksync.web import render, server as web_server + +# Faixas de emoji que não podem aparecer na interface (o padrão é fonte de ícones). +EMOJI_PATTERN = re.compile( + "[\U0001F300-\U0001FAFF\U00002600-\U000027BF\U00002B00-\U00002BFF\U0001F1E6-\U0001F1FF]" +) + + +def make_conn(provider: str, name: str, data: dict) -> ConnectionRecord: + return ConnectionRecord( + id=f"id-{provider}", + provider=provider, + name=name, + created_at="2026-09-12T00:00:00Z", + updated_at="2026-09-12T00:00:00Z", + data_raw="", + data=data, + ) + + +class TestRenderHelpers(unittest.TestCase): + def test_duration_formatting(self): + self.assertEqual(render.format_duration(None), "Unlimited / N/A") + self.assertEqual(render.format_duration(None, "pt"), "Ilimitado / N/A") + self.assertEqual(render.format_duration(0), "Expired") + self.assertEqual(render.format_duration(-5, "es"), "Expirado") + self.assertEqual(render.format_duration(45), "45s") + self.assertEqual(render.format_duration(1440), "24 min") + self.assertEqual(render.format_duration(3660), "1h 01min") + self.assertEqual(render.format_duration(90000), "1d 1h") + + def test_html_is_escaped(self): + self.assertEqual(render.esc(""), "<script>alert(1)</script>") + + def test_refresh_reason_explains_why_nothing_was_renewed(self): + """O caso real: 24 min restantes com margem de 15 min não renova — e isso precisa ficar visível.""" + now_ms = int(time.time() * 1000) + conn = make_conn("antigravity", "Google Antigravity Pro", { + "accessToken": "tok", + "refreshToken": "ref", + "expiresAt": now_ms + (24 * 60 * 1000), + }) + self.assertIn("Outside the 15 min margin", render.render_refresh_reason(conn, 900)) + reason_pt = render.render_refresh_reason(conn, refresh_margin=900, lang="pt") + self.assertIn("Fora da margem de 15 min", reason_pt) + self.assertIn("renovação prevista", reason_pt) + + def test_refresh_reason_inside_margin(self): + now_ms = int(time.time() * 1000) + conn = make_conn("antigravity", "AG", { + "accessToken": "tok", "refreshToken": "ref", + "expiresAt": now_ms + (5 * 60 * 1000), + }) + self.assertIn("Within the 15 min margin", render.render_refresh_reason(conn, 900)) + self.assertIn("Dentro da margem", render.render_refresh_reason(conn, 900, "pt")) + + def test_refresh_reason_for_expired_and_apikey(self): + now_ms = int(time.time() * 1000) + expired = make_conn("antigravity", "AG", { + "accessToken": "t", "refreshToken": "r", "expiresAt": now_ms - 1000, + }) + self.assertIn("expired", render.render_refresh_reason(expired, 900).lower()) + + apikey = make_conn("groq", "Groq", {"apiKey": "gsk-xxx"}) + self.assertIn("never expires", render.render_refresh_reason(apikey, 900)) + self.assertIn("não expira", render.render_refresh_reason(apikey, 900, "pt")) + + def test_local_instance_reason_reports_models(self): + """O Ollama local precisa aparecer como local, com os modelos que serve.""" + local = make_conn("openai-compatible-chat-ollama-local", "Ollama Local Host", { + "apiKey": "fachada", + "baseUrl": "http://localhost:11434/v1", + "discoveredModels": ["llama3.2:3b", "qwen2.5-coder:7b"], + }) + self.assertTrue(local.is_local) + self.assertIn("2 model(s)", render.render_refresh_reason(local, 900)) + + def test_unreachable_local_instance_is_reported(self): + local = make_conn("ollama-local", "Ollama Local", { + "apiKey": "k", "baseUrl": "http://localhost:11434/v1", + }) + self.assertIn("did not answer", render.render_refresh_reason(local, 900)) + + +class TestDashboardMarkup(unittest.TestCase): + def _page(self, **overrides): + now_ms = int(time.time() * 1000) + base = dict( + connections=[ + make_conn("antigravity", "Google Antigravity Pro", { + "accessToken": "tok", "refreshToken": "ref", + "expiresAt": now_ms + (24 * 60 * 1000), + }), + make_conn("groq", "Groq Cloud PathBit", {"apiKey": "gsk-xxx"}), + ], + combos=[{"name": "arsenal-supremo", "kind": "fallback", "models": ["a", "b", "c"]}], + cron={"active": True, "intervalSeconds": 300, "totalRuns": 4, "totalRenewals": 0, + "lastRunAt": "2026-09-12T13:46:53Z", "nextRunAt": "2026-09-12T13:51:53Z", + "lastResult": {"totalInspected": 7, "refreshedCount": 0, "durationMs": 5}}, + gateway={"url": "http://9router:20128", "online": True, "statusCode": 200, + "latencyMs": 9, "dbSummary": "Operacional (7 conexoes, 5 combos)"}, + db_path="/app/data/db/data.sqlite", + router_url="http://9router:20128", + current_user="admin", + is_default_password=True, + refresh_margin=900, + ) + base.update(overrides) + return render.render_dashboard(**base) + + def test_page_uses_icon_fonts_and_no_emoji(self): + page = self._page() + self.assertIn("bootstrap-icons", page) + self.assertIn('class="bi bi-', page) + found = EMOJI_PATTERN.findall(page) + self.assertEqual(found, [], f"emojis encontrados na interface: {found}") + + def test_page_loads_bootstrap_and_jquery(self): + page = self._page() + self.assertIn("bootstrap@5", page) + self.assertIn("jquery@3", page) + + def test_data_is_embedded_server_side(self): + """A página chega pronta: nada de buscar dados do banco pelo navegador.""" + page = self._page() + self.assertIn("Google Antigravity Pro", page) + self.assertIn("Groq Cloud PathBit", page) + self.assertIn("arsenal-supremo", page) + self.assertNotIn("fetch(", page) + self.assertNotIn("/api/status", page) + + def test_secrets_are_never_rendered(self): + page = self._page() + for secret in ("tok", "ref", "gsk-xxx"): + self.assertNotIn(f">{secret}<", page) + self.assertNotIn("gsk-xxx", page) + + def test_refresh_controls_are_present(self): + page = self._page() + self.assertIn('href="/"', page) # botão Atualizar + self.assertIn('action="/acoes/sincronizar"', page) # Sincronizar agora + self.assertIn('action="/acoes/cron"', page) # Executar ciclo + self.assertIn('action="/acoes/testar-gateway"', page) + + def test_security_banner_appears_only_with_default_password(self): + self.assertIn("Security warning", self._page(is_default_password=True)) + self.assertNotIn("Security warning", self._page(is_default_password=False)) + + def test_default_language_is_english_with_pt_and_es_available(self): + page = self._page() + self.assertIn('lang="en"', page) + self.assertIn("Monitored connections", page) + self.assertIn("flag-icons", page) + for flag in ("fi-us", "fi-br", "fi-es"): + self.assertIn(flag, page) + + def test_page_renders_in_portuguese_and_spanish(self): + self.assertIn("Conexões monitoradas", self._page(lang="pt")) + self.assertIn("Conexiones monitoreadas", self._page(lang="es")) + + def test_local_connection_shows_base_url_and_models(self): + local = make_conn("openai-compatible-chat-ollama-local", "Ollama Local Host", { + "apiKey": "fachada", "baseUrl": "http://localhost:11434/v1", + "discoveredModels": ["llama3.2:3b", "qwen2.5-coder:7b"], + }) + page = self._page(connections=[local]) + self.assertIn("http://localhost:11434/v1", page) + self.assertIn("llama3.2:3b", page) + self.assertIn("bi-hdd-network me-1", page) # tipo renderizado como Local + + def test_cron_history_details_are_available(self): + page = self._page(cron={ + "active": True, "intervalSeconds": 300, "totalRenewals": 0, + "nextRunAt": "2026-09-12T13:51:53Z", + "lastResult": {"totalInspected": 7, "refreshedCount": 0, + "durationMs": 5, "success": False, "error": "gateway offline"}, + "history": [ + {"timestamp": "2026-09-12T13:46:53Z", "totalInspected": 7, "refreshedCount": 0, + "durationMs": 5, "success": False, "error": "gateway offline", + "log": ["antigravity · AG: Validade proxima do fim"]}, + ], + }) + self.assertIn("modalHistorico", page) + self.assertIn("gateway offline", page) + self.assertIn("Validade proxima do fim", page) + self.assertIn("accordion", page) + + def test_cron_failure_is_flagged_on_the_card(self): + page = self._page(cron={ + "active": True, "intervalSeconds": 300, "totalRenewals": 0, + "lastResult": {"totalInspected": 1, "refreshedCount": 0, "durationMs": 3, + "success": False, "error": "boom"}, + "history": [], + }) + self.assertIn("text-bg-danger", page) + self.assertIn("boom", page) + + def test_env_mode_hides_the_password_form(self): + page = self._page(auth_from_env=True) + self.assertNotIn('action="/acoes/credenciais"', page) + self.assertIn("DASHBOARD_USER", page) + + def test_injection_in_connection_name_is_escaped(self): + evil = make_conn("evil", "", {"apiKey": "k"}) + page = self._page(connections=[evil]) + self.assertNotIn("", page) + self.assertIn("<script>", page) + + def test_empty_state_renders(self): + page = self._page(connections=[], combos=[]) + self.assertIn("No connection registered", page) + self.assertIn("No fallback combo", page) + page_pt = self._page(connections=[], combos=[], lang="pt") + self.assertIn("Nenhuma conexão registrada", page_pt) + + +class TestDashboardOverHttp(unittest.TestCase): + """Verifica o SSR de ponta a ponta, com o servidor real no ar.""" + + @classmethod + def setUpClass(cls): + cls.tmp_dir = tempfile.TemporaryDirectory() + cls.db_path = os.path.join(cls.tmp_dir.name, "data.sqlite") + now_ms = int(time.time() * 1000) + with sqlite3.connect(cls.db_path) as conn: + conn.execute( + "CREATE TABLE providerConnections (id TEXT PRIMARY KEY, provider TEXT, name TEXT, " + "data TEXT, createdAt TEXT, updatedAt TEXT)" + ) + conn.execute("CREATE TABLE combos (id TEXT PRIMARY KEY, name TEXT, models TEXT, kind TEXT)") + conn.execute( + "INSERT INTO providerConnections VALUES (?,?,?,?,?,?)", + ("c1", "antigravity", "Google Antigravity Pro", + json.dumps({"accessToken": "tok", "refreshToken": "ref", + "expiresAt": now_ms + 24 * 60 * 1000}), + "2026-09-12T00:00:00Z", "2026-09-12T00:00:00Z"), + ) + + cls.settings = Settings( + db_path=cls.db_path, web_host="127.0.0.1", web_port=19293, + dashboard_user="admin", dashboard_password="senha-forte", + dashboard_auth_from_env=True, + ) + cls.server = web_server.start_web_server( + "127.0.0.1", 19293, cls.db_path, router_url="", settings=cls.settings + ) + time.sleep(0.3) + cls.auth = base64.b64encode(b"admin:senha-forte").decode() + + @classmethod + def tearDownClass(cls): + cls.server.shutdown() + cls.server.server_close() + cls.tmp_dir.cleanup() + + def _get(self, path: str): + req = urllib.request.Request(f"http://127.0.0.1:19293{path}") + req.add_header("Authorization", f"Basic {self.auth}") + return urllib.request.urlopen(req, timeout=5) + + def test_dashboard_is_rendered_with_live_data(self): + with self._get("/") as resp: + self.assertEqual(resp.status, 200) + self.assertEqual(resp.headers["Cache-Control"], "no-store, must-revalidate") + self.assertEqual(resp.headers["X-Frame-Options"], "DENY") + body = resp.read().decode("utf-8") + + self.assertIn("Google Antigravity Pro", body) + self.assertIn("bootstrap-icons", body) + self.assertNotIn("tok", body.split(" Date: Sat, 12 Sep 2026 11:04:49 -0300 Subject: [PATCH 03/16] chore: remove emojis da saida de terminal --- src/nine_rtksync/cli.py | 10 +++++----- src/nine_rtksync/daemon.py | 13 ++++++++----- 2 files changed, 13 insertions(+), 10 deletions(-) diff --git a/src/nine_rtksync/cli.py b/src/nine_rtksync/cli.py index 31e4a6a..c8530ae 100644 --- a/src/nine_rtksync/cli.py +++ b/src/nine_rtksync/cli.py @@ -16,15 +16,15 @@ def print_status_table(settings: Settings): conns = get_all_connections(settings.db_path) combos = get_all_combos(settings.db_path) except Exception as e: - print(f"❌ Erro ao consultar banco SQLite ({settings.db_path}): {e}", file=sys.stderr) + print(f"[ERRO] Erro ao consultar banco SQLite ({settings.db_path}): {e}", file=sys.stderr) sys.exit(1) print("\n" + "=" * 76) - print("⚡ 9RTKSYNC · STATUS DAS CONEXÕES E COMBOS DO 9ROUTER") + print("[*] 9RTKSYNC · STATUS DAS CONEXÕES E COMBOS DO 9ROUTER") print(f" Banco de Dados: {settings.db_path}") print("=" * 76) - print(f"\n🔌 Conexões Registradas ({len(conns)}):") + print(f"\n[*] Conexões Registradas ({len(conns)}):") print(f" {'PROVEDOR':<16} {'NOME':<26} {'TIPO':<10} {'STATUS':<10} {'VALIDADE':<14}") print(" " + "-" * 74) @@ -41,10 +41,10 @@ def print_status_table(settings: Settings): else: val_str = "Ilimitado" - status_icon = "✅" if c.health_status in ("ativo", "sem_expiracao") else ("⚠️" if c.health_status == "expirando_em_breve" else "❌") + status_icon = "[ok]" if c.health_status in ("ativo", "sem_expiracao") else ("[!]" if c.health_status == "expirando_em_breve" else "[ERRO]") print(f" {c.provider:<16} {c.name[:25]:<26} {tipo:<10} {status_icon} {c.health_status:<7} {val_str:<14}") - print(f"\n🔀 Combos de Resiliência e Fallback ({len(combos)}):") + print(f"\n[*] Combos de Resiliência e Fallback ({len(combos)}):") print(f" {'NOME DO COMBO':<26} {'TIPO':<12} {'MODELOS NA CASCATA'}") print(" " + "-" * 74) diff --git a/src/nine_rtksync/daemon.py b/src/nine_rtksync/daemon.py index 74952b8..561147a 100644 --- a/src/nine_rtksync/daemon.py +++ b/src/nine_rtksync/daemon.py @@ -134,7 +134,7 @@ def handle_signal(sig, frame): signal.signal(signal.SIGTERM, handle_signal) print("=" * 70, flush=True) - print("⚡ 9RTKSYNC · 9ROUTER UNIVERSAL TOKEN & CONNECTION SYNCHRONIZER", flush=True) + print("[*] 9RTKSYNC · 9ROUTER UNIVERSAL TOKEN & CONNECTION SYNCHRONIZER", flush=True) print(f" Banco SQLite: {settings.db_path}", flush=True) print(f" Gateway URL: {settings.router_url}", flush=True) print(f" Host Home: {engine.discovery.host_home}", flush=True) @@ -154,7 +154,7 @@ def handle_signal(sig, frame): # Inicializa o CronScheduler dedicado para renovação contínua de OAuth cron_scheduler = CronScheduler( sync_callback=engine.sync_all, - interval_seconds=settings.sync_interval, + interval_seconds=settings.cron_interval, name="9RTKSync-CronScheduler", ) @@ -170,12 +170,15 @@ def handle_signal(sig, frame): settings=settings, cron_scheduler=cron_scheduler, ) - print(f"🌐 Dashboard Web ativo em: http://{settings.web_host}:{settings.web_port}", flush=True) + print(f"[*] Dashboard Web ativo em: http://{settings.web_host}:{settings.web_port}", flush=True) except Exception as e: - print(f"⚠️ Não foi possível iniciar o dashboard web na porta {settings.web_port}: {e}", flush=True) + print(f"[!] Não foi possível iniciar o dashboard web na porta {settings.web_port}: {e}", flush=True) # Inicia o agendador em background - cron_scheduler.start() + if settings.cron_enabled: + cron_scheduler.start() + else: + print("[*] Agendador automatico desativado (CRON_ENABLED=0); use o disparo manual.", flush=True) while running: time.sleep(1) From dcd3d029b57478d824b21c8b8148a31d0dd9dfbd Mon Sep 17 00:00:00 2001 From: Eliel Sousa Date: Sat, 12 Sep 2026 11:15:24 -0300 Subject: [PATCH 04/16] docs: wiki versionada no repo com publicacao automatica A wiki do GitHub nao passa por review: quem tem acesso edita direto e nao ha diff, historico util nem gate. As paginas passam a viver em docs/wiki/ e sao publicadas na wiki por um workflow a cada push em master. Paginas: Home, Installation, Configuration, Dashboard, Authentication, Logging, Architecture, Troubleshooting e Upstream-Fixes, alem de _Sidebar e _Footer. Configuration documenta o contrato completo de variaveis de ambiente - o requisito de operar sem nunca abrir a tela. Troubleshooting parte de sintomas reais (o BrokenPipeError do healthz, "o cron nao renovou o token", o Ollama local sem modelos, senha esquecida). Upstream-Fixes registra os bugs achados nos dois gateways e os PRs enviados, incluindo a correcao da afirmacao do README antigo sobre o expiresAt em ISO, que a leitura do codigo upstream nao sustenta. Primeira execucao do workflow exige que a wiki ja exista: o GitHub so cria o repositorio .wiki.git depois que a primeira pagina e salva pela interface. O job avisa isso em vez de falhar. --- .github/workflows/publish-wiki.yml | 62 ++++++++++++ README.md | 10 ++ docs/wiki/Architecture.md | 122 ++++++++++++++++++++++++ docs/wiki/Authentication.md | 108 +++++++++++++++++++++ docs/wiki/Configuration.md | 117 +++++++++++++++++++++++ docs/wiki/Dashboard.md | 120 ++++++++++++++++++++++++ docs/wiki/Home.md | 70 ++++++++++++++ docs/wiki/Installation.md | 145 ++++++++++++++++++++++++++++ docs/wiki/Logging.md | 90 ++++++++++++++++++ docs/wiki/Troubleshooting.md | 146 +++++++++++++++++++++++++++++ docs/wiki/Upstream-Fixes.md | 99 +++++++++++++++++++ docs/wiki/_Footer.md | 1 + docs/wiki/_Sidebar.md | 15 +++ 13 files changed, 1105 insertions(+) create mode 100644 .github/workflows/publish-wiki.yml create mode 100644 docs/wiki/Architecture.md create mode 100644 docs/wiki/Authentication.md create mode 100644 docs/wiki/Configuration.md create mode 100644 docs/wiki/Dashboard.md create mode 100644 docs/wiki/Home.md create mode 100644 docs/wiki/Installation.md create mode 100644 docs/wiki/Logging.md create mode 100644 docs/wiki/Troubleshooting.md create mode 100644 docs/wiki/Upstream-Fixes.md create mode 100644 docs/wiki/_Footer.md create mode 100644 docs/wiki/_Sidebar.md diff --git a/.github/workflows/publish-wiki.yml b/.github/workflows/publish-wiki.yml new file mode 100644 index 0000000..c73fe38 --- /dev/null +++ b/.github/workflows/publish-wiki.yml @@ -0,0 +1,62 @@ +name: Publish Wiki + +# The wiki is generated from docs/wiki/ so the documentation is reviewed in pull +# requests like any other change, instead of being edited straight in the wiki +# where nothing gates it. +# +# First run requires the wiki to already exist: GitHub only creates the +# .wiki.git repository after the first page is saved through the web UI. +# Create any page once and this workflow takes over from there. + +on: + push: + branches: [master] + paths: + - "docs/wiki/**" + - ".github/workflows/publish-wiki.yml" + workflow_dispatch: + +permissions: + contents: write + +concurrency: + group: publish-wiki + cancel-in-progress: false + +jobs: + publish: + runs-on: ubuntu-latest + steps: + - name: Checkout repository + uses: actions/checkout@v4 + + - name: Clone the wiki + id: clone + run: | + if git clone "https://x-access-token:${{ secrets.GITHUB_TOKEN }}@github.com/${{ github.repository }}.wiki.git" wiki; then + echo "ok=true" >> "$GITHUB_OUTPUT" + else + echo "ok=false" >> "$GITHUB_OUTPUT" + echo "::warning::Wiki repository not found. Create the first page through the GitHub UI once, then re-run this workflow." + fi + + - name: Sync pages + if: steps.clone.outputs.ok == 'true' + run: | + # Replace the whole page set so a deleted source file disappears from the wiki too. + find wiki -maxdepth 1 -name '*.md' -delete + cp docs/wiki/*.md wiki/ + + - name: Commit and push + if: steps.clone.outputs.ok == 'true' + working-directory: wiki + run: | + git config user.name "github-actions[bot]" + git config user.email "41898282+github-actions[bot]@users.noreply.github.com" + git add -A + if git diff --cached --quiet; then + echo "Wiki already up to date." + exit 0 + fi + git commit -m "docs: sync wiki from ${{ github.sha }}" + git push diff --git a/README.md b/README.md index 13af260..3edb34f 100644 --- a/README.md +++ b/README.md @@ -8,6 +8,16 @@ **`9RTKSync`** (*9Router Universal Token & Connection Synchronizer*) is a high-availability self-healing guardian for [9Router](https://github.com/decolua/9router) gateways. It eliminates sudden disconnects, premature OAuth token expirations, date format corruptions, and lingering rate-limit locks, keeping all connected accounts healthy and persistent. + +## Documentation + +The full documentation lives in the [project wiki](../../wiki): installation, the complete +environment-variable contract, the dashboard, authentication and break-glass recovery, +persistent logging, architecture, troubleshooting, and the upstream gateway fixes. + +Wiki pages are generated from [`docs/wiki/`](docs/wiki) — edit them there and open a pull +request; a push to `master` republishes the wiki automatically. + --- ## Core Features diff --git a/docs/wiki/Architecture.md b/docs/wiki/Architecture.md new file mode 100644 index 0000000..4c08c2d --- /dev/null +++ b/docs/wiki/Architecture.md @@ -0,0 +1,122 @@ +# Architecture + +9RTKSync is a sidecar. It shares the 9Router SQLite file through a Docker volume and repairs the +state the gateway keeps about its own connections. + +``` + ┌───────────────────────────────┐ + │ host home (read-only mount) │ + │ ~/.gemini, ~/.config/… │ + └───────────────┬───────────────┘ + │ discovery + ▼ + ┌────────────┐ ┌───────────┐ shared volume ┌──────────────┐ + │ browser │──►│ 9RTKSync │◄─────────────────►│ data.sqlite │ + │ :9091 │ │ :9090 │ │ providerConn │ + └────────────┘ └─────┬─────┘ └──────▲───────┘ + │ HTTP probe │ + ▼ │ + ┌───────────┐ │ + │ 9Router │──────────────────────────┘ + │ :20128 │ + └───────────┘ +``` + +--- + +## Modules + +| Module | Responsibility | +| :--- | :--- | +| `cli.py` | Argument parsing, bootstrap of logging and the recovery hash, entry points. | +| `daemon.py` | `SyncEngine.sync_all()` — one full pass over every connection. | +| `cron.py` | Background scheduler; keeps per-cycle history with the actions each produced. | +| `database.py` | SQLite reads and writes against `providerConnections` and `combos`. | +| `models.py` | `ConnectionRecord` and its derived properties (`is_oauth`, `is_local`, `remaining_seconds`, `health_status`). | +| `normalizer.py` | Credential-format self-healing and stale-lock removal. | +| `discovery.py` | Finds provider credentials on the host filesystem. | +| `providers/` | One handler per credential family: Google, generic OAuth, API key, local. | +| `combos.py` | Keeps the fallback combos registered and up to date. | +| `web/server.py` | HTTP server, routing, actions. | +| `web/render.py` | Server-side HTML rendering. | +| `i18n.py`, `prefs.py` | Interface language and its SQLite persistence. | +| `auth.py`, `logs.py` | Credential rules and the persistent file log. | + +--- + +## One synchronization pass + +`SyncEngine.sync_all()` per connection: + +1. **Self-heal the format.** `normalize_connection_data()` converts `expiresAt` into the numeric + epoch the gateway's own readers expect, derives it from `expiresIn` when absent, drops expired + `rateLimitedUntil` (resetting `backoffLevel`) and removes expired `modelLock_*` entries. +2. **Pick a handler.** The first provider whose `can_handle()` matches wins: + + | Handler | Matches | + | :--- | :--- | + | `GoogleProvider` | Antigravity, Gemini CLI | + | `GenericOAuthProvider` | Claude, Copilot, Codex, Kiro, Windsurf and other OAuth families | + | `ApiKeyProvider` | Static API keys | + | `LocalProvider` | Ollama, vLLM, LM Studio, OpenAI-compatible, any local `baseUrl` | + +3. **Renew or probe.** OAuth handlers renew when the remaining validity drops below + `REFRESH_MARGIN`, or when the connection carries an error or lock. `LocalProvider` queries the + instance's model catalog. `ApiKeyProvider` checks liveness. +4. **Write back.** Only when something actually changed. + +Every action is recorded twice: in the file log, and in the cycle entry the dashboard's **Logs** +button shows. + +--- + +## Credential discovery on the host + +`HostDiscoveryEngine` scans the read-only host mount for provider credential files — Antigravity +and Gemini CLI tokens under `~/.gemini/` and `~/.config/antigravity/`, plus any path given in +`ANTIGRAVITY_TOKEN_PATH`. A fresher refresh token found on the host is promoted into the gateway +connection, which is what lets a local `gemini auth login` heal a stale gateway account. + +--- + +## Interoperating with the gateway's format + +The synchronizer and the gateway share a database, so they must agree on how values are shaped. +Two conventions matter: + +- **`expiresAt`.** 9Router keeps connection state inside a JSON `data` column, so a number stays + a number across the round-trip. 9RTKSync writes a numeric epoch in milliseconds, which every + 9Router reader handles. +- **`testStatus`.** `"ok"` and `"active"` both read as healthy; `"active"` is what triggers the + gateway's own health-state reset. + +The sibling project targets a relational schema where the same field is a TEXT column, and the +rules are different. Getting this wrong silently disables the gateway's proactive refresh — see +[Upstream Fixes](Upstream-Fixes). + +--- + +## Web layer + +- `ThreadingHTTPServer` with `daemon_threads`. A single-threaded server meant one slow request + blocked the health probe. +- The gateway probe is cached for 30 s, so `/healthz` does not cost an outbound HTTP call per + call. +- Client disconnects (`BrokenPipe`, `ConnectionReset`, `ConnectionAborted`) are swallowed; real + errors still reach the default handler. +- Every page is built by `render.py` with the data already embedded — the database never leaves + the server process. +- Actions are POST-Redirect-GET under `/acoes/*`. + +--- + +## State owned by the synchronizer + +Written next to the database (or `DATA_DIR`), never inside the gateway's schema: + +| File | Contents | +| :--- | :--- | +| `.dashboard_auth.json` | Credentials set from the screen. | +| `.dashboard_recovery` | Break-glass hash, mode `0600`. | +| `ui_prefs.sqlite` | Interface language. | +| `logs/9rtksync.log` | Rotating persistent log. | diff --git a/docs/wiki/Authentication.md b/docs/wiki/Authentication.md new file mode 100644 index 0000000..bca3330 --- /dev/null +++ b/docs/wiki/Authentication.md @@ -0,0 +1,108 @@ +# Authentication + +The dashboard is protected by HTTP Basic Auth. Three credential sources are evaluated in a fixed +order, implemented in [`src/nine_rtksync/auth.py`](https://github.com/pathbit/9RTKSync/blob/master/src/nine_rtksync/auth.py) +and covered by `tests/test_auth_recovery.py`. + +--- + +## The rule + +A sign-in attempt is accepted when **any** of these holds: + +1. **Stored credentials match.** Once the password has been changed from the screen, those saved + credentials are the only normal way in — the factory defaults stop working. +2. **Factory credentials match, and nothing has been stored yet.** This is the first-boot state + of a fresh container. +3. **The user is `admin` and the password equals the recovery hash.** This always works, whatever + is stored. It is the break-glass path. + +Anything else is invalid. All comparisons use `hmac.compare_digest`, so a wrong password does not +leak information through response timing. + +``` + ┌──────────────────────────┐ + admin + hash ────►│ always accepted │ + └──────────────────────────┘ + ┌──────────────────────────┐ + stored exists ───►│ only stored credentials │ + └──────────────────────────┘ + ┌──────────────────────────┐ + nothing stored ──►│ factory credentials │ + └──────────────────────────┘ +``` + +--- + +## Factory credentials + +`admin` / `pathbit`, overridable with `DASHBOARD_USER` and `DASHBOARD_PASSWORD`. + +While the password is still `pathbit`, the dashboard shows a security banner. Change it — the +panel reaches your gateway's credential store. + +--- + +## Headless mode + +Setting `DASHBOARD_USER` and/or `DASHBOARD_PASSWORD` in the environment makes them the **source +of truth**: + +- The `.dashboard_auth.json` file written by the screen is ignored. +- Changing the password from the panel answers `409 Conflict`, with a message saying where the + credentials come from. + +Without this, a single password change through the screen would leave both variables permanently +inert — the file would win forever, and a redeployed container would keep the old password. + +To hand control back to the dashboard, remove both variables and restart. + +--- + +## Break-glass recovery + +If the screen password is lost, sign in with user **`admin`** and the **recovery hash** as the +password. + +**Where the hash comes from** + +1. `DASHBOARD_RECOVERY_HASH`, if set. Pin your own value here for reproducible deployments. +2. Otherwise a random value generated on first boot, written to `.dashboard_recovery` next to the + other panel state files, with mode `0600`, and logged **once** at `WARNING`: + +``` +[AUTH] Recovery hash generated. To recover access use user 'admin' and this password: + a3f1... (keep it safe; set DASHBOARD_RECOVERY_HASH to pin your own) +``` + +**Retrieving it later** + +```bash +docker logs 9rtksync 2>&1 | grep "Recovery hash" +docker exec 9rtksync cat /app/data/.dashboard_recovery +``` + +**Notes** + +- The recovery path only accepts the user `admin`. The hash alone, with any other user, is + rejected. +- An empty recovery hash never grants access — a blank password cannot become a master key. +- If the directory is not writable the hash is generated in memory and lives only for that + process run; the service still starts. + +--- + +## Hardening + +The panel and the SQLite database it reads must never be reachable from the internet. + +- Publish the port on loopback only: `"127.0.0.1:9091:9090"`. The shipped compose example already + does this. +- The page itself is served with `Cache-Control: no-store`, `X-Frame-Options: DENY`, + `X-Content-Type-Options: nosniff` and `Referrer-Policy: no-referrer`. +- `/api/status` no longer sends `Access-Control-Allow-Origin: *`, so another site cannot read it + from a browser. +- The rendered page never contains access tokens, refresh tokens or API keys — only provider, + name, type, health and remaining validity. +- If you need remote access, put it behind a VPN or an authenticating reverse proxy. Do not + expose port 9091 directly. diff --git a/docs/wiki/Configuration.md b/docs/wiki/Configuration.md new file mode 100644 index 0000000..a39ca50 --- /dev/null +++ b/docs/wiki/Configuration.md @@ -0,0 +1,117 @@ +# Configuration + +Everything is reachable from the environment. You never have to open the dashboard to configure +the service — that is a hard contract, covered by `tests/test_config_env.py`. + +Values are read from the process environment first, then from a `.env` file in the working +directory (`load_dotenv` never overwrites a variable that is already set). + +--- + +## Database and host discovery + +| Variable | Default | Description | +| :--- | :--- | :--- | +| `DB_PATH` | auto-detected | Path to the 9Router SQLite file. When unset, the first existing candidate wins: `/app/data/db/data.sqlite`, `/app/data/data.sqlite`, `/app/data/storage.sqlite`, `~/.9router/data/db/data.sqlite`, `~/.9router/data.sqlite`. | +| `HOST_HOME` | auto-detected | Host home directory mounted into the container. Falls back to `/root/host`, then `/host`, then the process home. | +| `DATA_DIR` | — | Base directory for the panel's own state files (`.dashboard_auth.json`, `.dashboard_recovery`, `ui_prefs.sqlite`). Defaults to the directory holding `DB_PATH`. | +| `ANTIGRAVITY_TOKEN_PATH` | — | Extra path to an Antigravity/Gemini credential file, searched before the built-in list. | +| `MODULE` | `all` | Which combos to sync: `all`, `antigravity`, `oauth`, `gemini`. | + +--- + +## Gateway connectivity + +| Variable | Default | Description | +| :--- | :--- | :--- | +| `ROUTER_URL` | `http://127.0.0.1:20128` | Base URL of the 9Router gateway, used by `/healthz` and by the **Test connection** button. | + +The gateway probe result is cached for 30 seconds. Without that cache, every Docker health check +would pay an outbound HTTP call of up to 3 seconds — which is what used to make the probe time +out and produce `BrokenPipeError` in the logs. + +--- + +## Synchronization and scheduling + +| Variable | Default | Description | +| :--- | :--- | :--- | +| `SYNC_INTERVAL` | `300` | Seconds between synchronization passes. | +| `REFRESH_MARGIN` | `900` | Seconds of remaining validity below which a token is renewed. | +| `CRON_INTERVAL` | inherits `SYNC_INTERVAL` | Dedicated interval for the scheduler, when you want it to differ from the sync pass. | +| `CRON_ENABLED` | `1` | `0` disables the automatic scheduler entirely. Synchronization then only happens on a manual trigger (`--once`, the **Run now** button, or `POST /api/sync`). | + +> **A token is only renewed inside the margin.** With the defaults, a token with 24 minutes left +> is *not* renewed, because 24 min > 15 min. That is correct behavior, not a failure — the +> dashboard states the reason per connection. See [Troubleshooting](Troubleshooting). + +--- + +## Web dashboard + +| Variable | Default | Description | +| :--- | :--- | :--- | +| `ENABLE_WEB_DASHBOARD` | `1` | `0` runs the synchronizer headless, with no HTTP server at all. | +| `WEB_HOST` | `0.0.0.0` | Listen interface **inside** the container. Keep the published port bound to `127.0.0.1` on the host. | +| `WEB_PORT` | `9090` | Internal port. Identical in both synchronizers; the published host port is what differs (`9091` here, `9092` for OminiRTKSync). | + +--- + +## Authentication + +| Variable | Default | Description | +| :--- | :--- | :--- | +| `DASHBOARD_USER` | `admin` | Panel user. | +| `DASHBOARD_PASSWORD` | `pathbit` | Panel password. **Change it.** | +| `DASHBOARD_RECOVERY_HASH` | generated | Break-glass credential: sign in as `admin` with this value as the password. When unset, a random value is generated on first boot, stored with mode `0600` and written once to the log. | + +**Headless mode.** Setting `DASHBOARD_USER` and/or `DASHBOARD_PASSWORD` makes the environment the +source of truth: the `.dashboard_auth.json` file written by the screen is ignored, and changing +the password from the panel answers `409 Conflict`. Comment both variables out to hand control +back to the dashboard. + +Full rules in [Authentication](Authentication). + +--- + +## Logging + +| Variable | Default | Description | +| :--- | :--- | :--- | +| `LOG_DIR` | `/logs` | Directory for log files. Falls back to `~/.9rtksync/logs`. | +| `LOG_RETENTION_DAYS` | `30` | Days before rotated files are purged. Minimum `1`. | +| `LOG_LEVEL` | `INFO` | `DEBUG`, `INFO`, `WARNING` or `ERROR`. | +| `LOG_TO_STDOUT` | `1` | `0` stops mirroring events on the container stdout. | + +Details in [Logging](Logging). + +--- + +## CLI overrides + +Command-line flags take precedence over the environment for a single run: + +```bash +9RTKSync --status --db-path /path/to/data.sqlite +9RTKSync --once --db-path /path/to/data.sqlite +9RTKSync --daemon --db-path /path/to/data.sqlite --interval 60 --margin 1200 --port 9090 +9RTKSync --daemon --no-web +``` + +--- + +## Fully headless example + +No dashboard, no interactive setup, scheduler on a one-minute cadence, logs kept for 90 days: + +```yaml +environment: + - DB_PATH=/app/data/db/data.sqlite + - ROUTER_URL=http://9router:20128 + - SYNC_INTERVAL=60 + - REFRESH_MARGIN=1200 + - ENABLE_WEB_DASHBOARD=0 + - LOG_DIR=/app/data/logs + - LOG_RETENTION_DAYS=90 + - LOG_TO_STDOUT=0 +``` diff --git a/docs/wiki/Dashboard.md b/docs/wiki/Dashboard.md new file mode 100644 index 0000000..7f6d32e --- /dev/null +++ b/docs/wiki/Dashboard.md @@ -0,0 +1,120 @@ +# Dashboard + +The panel is **rendered on the server**. The HTML arrives with the data already embedded; the +browser never queries the SQLite database, and the page works with JavaScript disabled. jQuery +and Bootstrap only provide comfort — modals, the dropdown, and disabling a button once clicked. + +Reachable at **http://localhost:9091** (internal port 9090), behind HTTP Basic Auth. + +--- + +## Layout + +| Section | What it shows | +| :--- | :--- | +| Security banner | Only while the factory password is still in use. | +| Metric cards | Total connections, OAuth accounts, API keys, registered combos. | +| Gateway card | Gateway URL, HTTP status, latency, database summary, **Test connection**. | +| Scheduler card | State, next run, tokens renewed, last result, **Logs**, **Run now**. | +| Connections table | Provider, name, type, health, remaining validity, **renewal diagnosis**. | +| Resilience combos | Registered combos and their model cascade. | + +--- + +## Refreshing + +Every control is a real HTTP request that redirects back to the freshly rendered page +(POST-Redirect-GET), so what you see after an action is the new state, never a cached one. + +| Control | Effect | +| :--- | :--- | +| **Refresh** | Plain link to `/`; re-reads the database and re-renders. | +| **Sync now** | Runs a full synchronization pass, then reports what changed. | +| **Run now** | Triggers one scheduler cycle immediately. | +| **Test connection** | Invalidates the 30 s probe cache and really calls the gateway. | + +The page is served with `Cache-Control: no-store, must-revalidate`, so a browser reload always +hits the server. + +--- + +## Renewal diagnosis + +The single most useful column. Previously the panel showed only `0 renewed`, with no way to tell +"nothing needed renewing" from "renewal failed". Now each connection carries the reason: + +| Diagnosis | Meaning | +| :--- | :--- | +| `Outside the 15 min margin: renewal expected in ~9 min` | Healthy. The token is still far from expiry. | +| `Within the 15 min margin: will be renewed on the next sweep` | Renewal is due and will happen. | +| `Token expired: renewal will be attempted on the next sweep` | Past due — check the scheduler logs if it persists. | +| `No expiry recorded: will be renewed on the next sweep` | The gateway did not store a readable expiry. | +| `Static key: never expires, nothing to renew` | API-key provider. | +| `Local instance answered with 4 model(s)` | Local provider, reachable. | +| `Local instance did not answer the model catalog` | Local provider down. | + +The margin comes from `REFRESH_MARGIN`. + +--- + +## Scheduler logs + +**Logs** on the scheduler card opens the per-cycle history. Each entry expands to the actions +that cycle produced — renewals, self-healing, provider errors. A cycle that failed is flagged in +red, both in the list and with a badge on the button itself. + +A cycle with nothing to do shows as exactly that, rather than an empty screen you have to guess +about. + +The in-memory history keeps the last cycles; the durable record is the file log +(see [Logging](Logging)). + +--- + +## Language + +Default **English**, with **Português** and **Español** in the flag dropdown (real flag icons from +`flag-icons`, not emoji). + +The choice is persisted in **SQLite** — `ui_prefs.sqlite`, a database of the synchronizer's own, +next to the other panel state files. Never in the gateway's database (that would couple our schema +to theirs), and never in `localStorage` (which dies with the browser profile). + +A missing translation key falls back to English, never to the raw key. + +--- + +## Local providers + +An Ollama, vLLM or LM Studio instance usually needs a facade API key, which used to make it show +up as a cloud provider. It is now recognised as **Local** and its row carries the `baseUrl` and +the models the instance actually serves, discovered through `/api/tags` or `/v1/models`. + +An instance that stops answering is marked `unreachable` and shows the **Unknown** badge, instead +of being assumed healthy. + +--- + +## Security + +- No access token, refresh token or API key is ever rendered. +- `Cache-Control: no-store`, `X-Frame-Options: DENY`, `X-Content-Type-Options: nosniff`, + `Referrer-Policy: no-referrer`. +- `/api/status` no longer sends `Access-Control-Allow-Origin: *`. +- Publish the port on `127.0.0.1` only. + +More in [Authentication](Authentication). + +--- + +## JSON endpoints + +Kept for automation; the dashboard itself does not use them. + +| Endpoint | Method | Purpose | +| :--- | :--- | :--- | +| `/healthz` | GET | Unauthenticated liveness probe. `OK`, `DATABASE_NOT_READY` or `ROUTER_SERVICE_UNREACHABLE`. | +| `/api/status` | GET | Full state as JSON. | +| `/api/cron-status` | GET | Scheduler state and history. | +| `/api/sync` | POST | Trigger a synchronization pass. | +| `/api/cron-run` | POST | Trigger one scheduler cycle. | diff --git a/docs/wiki/Home.md b/docs/wiki/Home.md new file mode 100644 index 0000000..9308769 --- /dev/null +++ b/docs/wiki/Home.md @@ -0,0 +1,70 @@ +# 9RTKSync + +**9Router Universal Token & Connection Synchronizer** — a high-availability guardian for +[9Router](https://github.com/decolua/9router) gateways. It keeps OAuth accounts alive, heals +credential formats the gateway cannot read, clears stale rate-limit locks, and reports exactly +why each connection was or was not renewed. + +This wiki is generated from [`docs/wiki/`](https://github.com/pathbit/9RTKSync/tree/master/docs/wiki) +in the main repository. Edit the files there and open a pull request — a push to `master` +republishes these pages automatically. Editing a page directly here will be overwritten. + +--- + +## Pages + +| Page | What it covers | +| :--- | :--- | +| [Installation](Installation) | Docker Compose and local virtual environment | +| [Configuration](Configuration) | Every environment variable — the full headless contract | +| [Dashboard](Dashboard) | The server-rendered panel, language switcher, cron logs | +| [Authentication](Authentication) | Credentials, headless mode, break-glass recovery | +| [Logging](Logging) | Persistent file log, rotation, 30-day retention | +| [Architecture](Architecture) | How the sync engine talks to the 9Router database | +| [Troubleshooting](Troubleshooting) | Concrete symptoms and what they actually mean | +| [Upstream Fixes](Upstream-Fixes) | Bugs found in the gateways and the patches sent upstream | + +--- + +## What it does + +**Credential format healing.** 9Router stores `expiresAt` inside a JSON `data` column. When a +value arrives in a shape the gateway's parser rejects, its proactive refresh silently stops +firing for that connection and the account 401s until someone re-authenticates by hand. +9RTKSync normalizes those values on every sweep. + +**Proactive OAuth renewal.** Google Antigravity and Gemini CLI tokens are refreshed before they +expire, using a configurable margin (`REFRESH_MARGIN`, default 15 minutes). Credentials found on +the host (`~/.gemini/`, `~/.config/antigravity/`) are picked up and synced into the gateway. + +**Rate-limit unlocking.** Expired `rateLimitedUntil` locks and stale `modelLock_*` entries are +removed, and `backoffLevel` is reset, so a connection stops being skipped once its cooldown has +actually passed. + +**Local provider health.** Ollama, vLLM, LM Studio and any OpenAI-compatible local instance are +probed for their model catalog. A local instance that stops answering is marked `unreachable` +instead of being assumed healthy. + +**Server-rendered dashboard.** Port `9090` inside the container (published on `9091`), bound to +loopback. The page is assembled on the server with the data already embedded — the browser never +queries the SQLite database. + +--- + +## The sibling project + +If you run [OmniRoute](https://github.com/diegosouzapw/OmniRoute) instead of 9Router, use +[OminiRTKSync](https://github.com/pathbit/OminiRTkSync), which targets that gateway's relational +schema. The two projects share the same dashboard, logging, authentication and configuration +contract; only the database layer and the provider set differ. + +Both synchronizers listen on **port 9090 inside their container**. The published host ports +differ so they can run side by side: `9091` for 9RTKSync, `9092` for OminiRTKSync. + +--- + +## License + +MIT — see [LICENSE](https://github.com/pathbit/9RTKSync/blob/master/LICENSE). + +Built by [Pathbit](https://pathbit.co/). diff --git a/docs/wiki/Installation.md b/docs/wiki/Installation.md new file mode 100644 index 0000000..dbdb38d --- /dev/null +++ b/docs/wiki/Installation.md @@ -0,0 +1,145 @@ +# Installation + +Two supported paths: Docker (recommended) and a local Python virtual environment. + +--- + +## Docker Compose + +The official image is published to GHCR by GitHub Actions: + +```bash +docker pull ghcr.io/pathbit/9rtksync:latest +``` + +A working `docker-compose.yml` alongside the gateway: + +```yaml +name: 9router-stack + +services: + 9router: + image: decolua/9router:latest + container_name: 9router + restart: unless-stopped + ports: + - "127.0.0.1:20128:20128" + environment: + - DATA_DIR=/app/data + - PORT=20128 + - HOSTNAME=0.0.0.0 + volumes: + - 9router_data:/app/data + + 9rtksync: + image: ghcr.io/pathbit/9rtksync:latest + container_name: 9rtksync + restart: unless-stopped + ports: + # Internal port 9090 (same in OminiRTKSync); published on 9091. + # The 127.0.0.1 bind keeps the panel and the SQLite file off the internet. + - "127.0.0.1:9091:9090" + volumes: + - 9router_data:/app/data + - ${HOME}:/root/host:ro + - 9rtksync_logs:/app/data/logs + environment: + - HOST_HOME=/root/host + - DB_PATH=/app/data/db/data.sqlite + - ROUTER_URL=http://9router:20128 + - SYNC_INTERVAL=300 + - REFRESH_MARGIN=900 + - WEB_PORT=9090 + - DASHBOARD_USER=admin + - DASHBOARD_PASSWORD=change-me + - LOG_DIR=/app/data/logs + - LOG_RETENTION_DAYS=30 + depends_on: + - 9router + healthcheck: + test: ["CMD", "/opt/venv/bin/python3", "-c", "import urllib.request; urllib.request.urlopen('http://127.0.0.1:9090/healthz', timeout=3)"] + interval: 15s + timeout: 5s + retries: 3 + start_period: 10s + +volumes: + 9router_data: + 9rtksync_logs: +``` + +Then open **http://localhost:9091**. + +### Why these details matter + +- **`9router_data` is shared.** The synchronizer reads and writes the same SQLite file the + gateway uses; without the shared volume it has nothing to heal. +- **`${HOME}` is mounted read-only.** Antigravity and Gemini CLI credentials live in the host + home (`~/.gemini/`, `~/.config/antigravity/`). Read-only is enough — the synchronizer never + writes there. +- **The port is bound to `127.0.0.1`.** The panel reads credential metadata; it must not be + reachable from the internet. +- **A named volume for the logs.** Otherwise they die with the container. See [Logging](Logging). + +--- + +## Local virtual environment + +Requires Python 3.11+ (3.14 is what CI pins). + +```bash +git clone https://github.com/pathbit/9RTKSync.git +cd 9RTKSync + +python3 -m venv .venv +source .venv/bin/activate +pip install --upgrade pip +pip install -e . +``` + +### Commands + +```bash +# Connection and combo status, no changes written +9RTKSync --status --db-path ~/.9router/data/db/data.sqlite + +# One immediate synchronization pass +9RTKSync --once --db-path ~/.9router/data/db/data.sqlite + +# Continuous daemon with the dashboard +9RTKSync --daemon --db-path ~/.9router/data/db/data.sqlite + +# Daemon without the web server +9RTKSync --daemon --no-web +``` + +`--db-path` is optional: without it the synchronizer probes the usual locations. See +[Configuration](Configuration). + +--- + +## Running the tests + +```bash +source .venv/bin/activate +PYTHONPATH=src python3 -m unittest discover -s tests -p "test_*.py" +``` + +Or with no local install at all: + +```bash +./run_tests.sh +``` + +--- + +## Upgrading + +```bash +docker compose pull 9rtksync +docker compose up -d 9rtksync +``` + +State that survives upgrades lives in the data volume: `.dashboard_auth.json` (screen-set +credentials), `.dashboard_recovery` (break-glass hash) and `ui_prefs.sqlite` (interface +language). None of them are stored in the gateway's own database. diff --git a/docs/wiki/Logging.md b/docs/wiki/Logging.md new file mode 100644 index 0000000..77c824e --- /dev/null +++ b/docs/wiki/Logging.md @@ -0,0 +1,90 @@ +# Logging + +Container stdout is volatile: it disappears on `docker rm`, gets truncated by the log driver and +does not survive a restart. Events that matter for auditing — token renewals, sync failures, +dashboard access — are therefore also written to a file, with daily rotation and age-based purge. + +Implemented in [`src/nine_rtksync/logs.py`](https://github.com/pathbit/9RTKSync/blob/master/src/nine_rtksync/logs.py), +covered by `tests/test_logs.py`. + +--- + +## Configuration + +| Variable | Default | Description | +| :--- | :--- | :--- | +| `LOG_DIR` | `/logs` | Destination directory. Falls back to `~/.9rtksync/logs` when the database directory is not writable. | +| `LOG_RETENTION_DAYS` | `30` | Days a rotated file is kept. Minimum `1`; an unparseable value falls back to 30. | +| `LOG_LEVEL` | `INFO` | `DEBUG`, `INFO`, `WARNING`, `ERROR`. | +| `LOG_TO_STDOUT` | `1` | `0` stops mirroring on stdout. The file keeps receiving everything. | + +--- + +## Rotation and retention + +- One file, `9rtksync.log`, rotated at **UTC midnight**. +- Rotated files are named `9rtksync.log.YYYY-MM-DD`. +- `backupCount` equals `LOG_RETENTION_DAYS`, so daily rotation keeps exactly that many days. +- On every startup, `purge_expired_logs()` also deletes rotated files whose modification time is + older than the retention window. This catches files left behind by a container that was down + for a while. + +**Never touched:** the active `9rtksync.log`, and any file that does not belong to this service. +Another service's logs sharing the same directory are left alone. + +``` +/app/data/logs/ + 9rtksync.log ← active, never purged + 9rtksync.log.2026-09-11 ← kept (2 days old) + 9rtksync.log.2026-07-01 ← purged (73 days old, retention 30) + other-service.log.2026-01-01 ← left alone, not ours +``` + +--- + +## Keeping logs longer + +```yaml +environment: + - LOG_RETENTION_DAYS=90 +volumes: + - 9rtksync_logs:/app/data/logs +``` + +Mount a named volume (or a host path) or the files die with the container, which defeats the +purpose. + +--- + +## Format + +``` +[2026-09-12 13:46:53] [INFO] [CRON] Cycle triggered (scheduled_interval). Inspecting OAuth account connections... +[2026-09-12 13:46:53] [INFO] [STATUS] [antigravity · Google Antigravity Pro] Token valid for another 24 min +[2026-09-12 13:46:53] [INFO] [CRON] Cycle completed in 5ms: 7 accounts evaluated, 0 renewed via OAuth. +[2026-09-12 13:51:58] [WARNING] [AUTH] Recovery hash generated. To recover access use user 'admin' ... +``` + +Prefixes: `CRON`, `STATUS`, `SYNC`, `CURA` (self-healing), `DISCOVERY`, `AUTH`, `ERRO`, `FALHA`. + +--- + +## Failure behaviour + +The file log is **best effort**. If the directory cannot be created or written, the service still +starts and prints once to stderr: + +``` +[LOG] File log unavailable at /app/data/logs: [Errno 13] Permission denied +``` + +A synchronizer that refuses to run because it cannot write a log file would be worse than one +that runs without the log. + +--- + +## Per-cycle logs in the dashboard + +Separately from the file log, the scheduler keeps the last cycles in memory with the actions each +one produced. The **Logs** button on the scheduler card opens the history; a failed cycle is +flagged in red and its error is shown inline. See [Dashboard](Dashboard). diff --git a/docs/wiki/Troubleshooting.md b/docs/wiki/Troubleshooting.md new file mode 100644 index 0000000..04423dc --- /dev/null +++ b/docs/wiki/Troubleshooting.md @@ -0,0 +1,146 @@ +# Troubleshooting + +Concrete symptoms, what they actually mean, and what to do. + +--- + +## "The cron ran several times and never renewed the Antigravity token" + +**Usually not a bug.** A token is only renewed once its remaining validity drops below +`REFRESH_MARGIN` (default 900 s = 15 min). A connection showing *24 min* remaining is correctly +left alone — renewing early would burn refresh-token rotations for nothing. + +The dashboard states this per connection, in the **Renewal diagnosis** column: + +> Outside the 15 min margin: renewal expected in ~9 min + +**When it *is* a problem:** the diagnosis column says something else. + +| Diagnosis | Meaning | Action | +| :--- | :--- | :--- | +| `No expiry recorded` | The connection has no readable `expiresAt`. | It will be renewed on the next sweep; if it persists, check the gateway wrote the field. | +| `Token expired` | Renewal is due but has not succeeded. | Open **Logs** on the scheduler card — the failing cycle carries the provider error. | +| `Local instance did not answer the model catalog` | The local Ollama/vLLM is down. | Check the instance and its `baseUrl`. | + +If you want renewal to happen sooner, raise the margin rather than shortening the interval: + +``` +REFRESH_MARGIN=1800 # renew during the last 30 minutes +``` + +--- + +## `BrokenPipeError: [Errno 32] Broken pipe` in `serve_healthz` + +``` +File "/app/src/nine_rtksync/web/server.py", line 116, in serve_healthz + self.wfile.write(b"OK") +BrokenPipeError: [Errno 32] Broken pipe +``` + +**Fixed.** Root cause was two compounding problems: + +1. The HTTP server was single-threaded despite the module promising multi-thread, so one slow + request blocked everything else. +2. `/healthz` made an outbound HTTP call of up to 3 s to the gateway on **every** probe. The + Docker health check (5 s timeout, every 15 s) gave up and closed the socket before the + response body was written, and `socketserver` printed the whole traceback. + +Now the server is a `ThreadingHTTPServer`, the gateway probe is cached for 30 s, and client +disconnects are swallowed instead of logged as failures. If you still see it, you are running an +image from before the fix — pull `ghcr.io/pathbit/9rtksync:latest` again. + +--- + +## The local Ollama shows up but without its models + +The connection is classified as **Local** and probed on `/api/tags` and `/v1/models`. If the +model list is empty: + +- The connection has no `baseUrl` — the gateway stores it on the provider record; check it in the + 9Router UI. +- The container cannot reach the host instance. From inside the container, `localhost` is the + container, not your machine. Use `host.docker.internal` (Docker Desktop) or the host's LAN IP. +- The instance requires an API key the connection does not carry. + +A local instance that does not answer is marked `unreachable` and shows the **Unknown** badge — +deliberately, so a dead instance is not silently reported as healthy. + +--- + +## The dashboard shows stale data + +The page is rendered on the server and served with `Cache-Control: no-store`, so a reload always +re-reads the database. Use the **Refresh** button (it is a plain link to `/`). + +If the numbers still look wrong, the synchronizer may not be writing at all — check +`GET /healthz`: + +| Response | Meaning | +| :--- | :--- | +| `OK` | Database readable and gateway reachable. | +| `DATABASE_NOT_READY` | `DB_PATH` points at a file that does not exist. | +| `ROUTER_SERVICE_UNREACHABLE` | `ROUTER_URL` is wrong, or the gateway is down. | + +--- + +## I forgot the dashboard password + +Sign in with user `admin` and the **recovery hash** as the password. Find it with: + +```bash +docker logs 9rtksync 2>&1 | grep "Recovery hash" +# or, if the log file is mounted: +grep "Recovery hash" /app/data/logs/9rtksync.log +``` + +If the log has already rotated past it, the value is on disk: + +```bash +docker exec 9rtksync cat /app/data/.dashboard_recovery +``` + +To pin your own instead of relying on the generated one, set `DASHBOARD_RECOVERY_HASH` and +restart. See [Authentication](Authentication). + +--- + +## Changing the password from the panel answers `409 Conflict` + +The service is in headless mode: `DASHBOARD_USER` and/or `DASHBOARD_PASSWORD` are set in the +environment, which makes them the source of truth. Change them in the environment and restart, or +comment both out to hand control back to the dashboard. + +--- + +## Log files are not being written + +The file log is best-effort — the synchronizer never refuses to start because of it. On startup +you will see: + +``` +[LOG] File log unavailable at /app/data/logs: [Errno 13] Permission denied +``` + +Fix the volume permissions, or point `LOG_DIR` somewhere writable. Events keep going to stdout +while `LOG_TO_STDOUT=1`. + +--- + +## Both synchronizers fight over the same port + +They listen on **9090 inside their own container** by design. Only the published host port +differs: `9091` for 9RTKSync, `9092` for OminiRTKSync. If you changed `WEB_PORT`, change it in +one container only — there is no reason for the internal ports to differ. + +--- + +## Connections keep getting skipped by the gateway + +Two separate causes, worth telling apart: + +- **Stale rate-limit lock.** The synchronizer removes expired `rateLimitedUntil` and + `modelLock_*` entries and resets `backoffLevel` on every sweep. Check the scheduler **Logs** + for a `Rate limit lock removed` line. +- **A gateway-side parsing bug.** Both upstream gateways had a bug where a credential expiry in + certain shapes silently disabled their own proactive refresh. See [Upstream Fixes](Upstream-Fixes). diff --git a/docs/wiki/Upstream-Fixes.md b/docs/wiki/Upstream-Fixes.md new file mode 100644 index 0000000..f43d357 --- /dev/null +++ b/docs/wiki/Upstream-Fixes.md @@ -0,0 +1,99 @@ +# Upstream Fixes + +Some of what this synchronizer works around are bugs in the gateways themselves. Where that is +the case, the fix belongs upstream — a workaround in a sidecar helps only the people running the +sidecar. + +This page tracks what was found and what was sent. + +--- + +## 9Router — numeric-epoch `expiresAt` silently disabled OAuth refresh + +**Upstream PR:** [decolua/9router#3997](https://github.com/decolua/9router/pull/3997) + +`parseTimeMs()` in `open-sse/services/oauthCredentialManager.js` accepted a `number` and anything +`Date` can parse — but a numeric epoch **in string form** fell through to the `Date` branch, +where it is an Invalid Date: + +```js +new Date("1789012345678").getTime() // NaN -> parseTimeMs returns null +``` + +A `null` expiry disables **both** proactive refresh paths: + +| Call site | Effect | +| :--- | :--- | +| `shouldRefreshCredentials()` | `expiresAtMs !== null` is false — the on-request refresh never fires. | +| `selectConnectionsNeedingRefresh()` | `if (expiresAtMs === null) continue;` — the background sweep skips the connection. | + +The connection then keeps an expired access token and 401s until the user re-authenticates by +hand. Same user-visible symptom as upstream issue #2546 ("session dies 40-45 min after login"), +reached through a different input shape. + +**Where the shape comes from:** the bulk-import routes persist the user-supplied value verbatim — +`grok-cli/bulk-import/route.js:77`, `codex/bulk-import/route.js:97`, +`kiro/import-cli-proxy/route.js:20` — while `lib/oauth/kiroExternalIdp.js` already normalizes to +ISO. The convention existed; the parser just did not accept what those routes could store. + +**Fix:** `parseTimeMs()` converts numeric strings using the same seconds/ms heuristic it already +applied to numbers, and is exported so `normalizeExpiresAt()` reuses it — an epoch already stored +self-heals to ISO on the next refresh. + +--- + +## OmniRoute — numeric epoch expiry broke the token health check + +**Upstream PR:** [diegosouzapw/OmniRoute#13444](https://github.com/diegosouzapw/OmniRoute/pull/13444) + +`provider_connections.expires_at` / `token_expires_at` are **TEXT** columns, so an epoch always +reads back as a string. `getEffectiveTokenExpiryMs()` went straight to `new Date()`: + +```ts +new Date("1789012345678").getTime() // NaN +new Date(1789012345).getTime() // 1970-01-21 — seconds read as milliseconds +``` + +The sibling helper right below it, `getCopilotTokenExpiryMs()`, already handled both numeric +shapes. The main connection path never got the same treatment. + +Two failure modes in `checkConnection()`: + +1. **Numeric string → never refreshed.** `NaN` → `0` → `hasKnownExpiry` false → `isAboutToExpire` + false. For a rotating provider (`codex`, `claude`, `kiro`, `openai`, …) `shouldRefreshByInterval` + is also false, so `if (!isAboutToExpire && !shouldRefreshByInterval) return;` returns early on + every sweep. The expiry-driven refresh the surrounding comment says it prefers is silently off. +2. **Epoch seconds as a number → a refresh loop.** Parsed as milliseconds it lands in 1970, so + `isAboutToExpire` is permanently true and *every* sweep refreshes the connection — burning + single-use refresh-token rotations. + +**Fix:** extract the numeric/string handling into one exported `parseTokenExpiryMs()` and route +both call sites through it. + +--- + +## What we fixed on our side + +[OminiRTKSync](https://github.com/pathbit/OminiRTkSync) was itself writing `expires_at` as a +numeric epoch in text (`str(expires_at_ms)`) and `test_status = 'ok'` — a value OmniRoute does not +recognise as healthy. Both are fixed; it now writes ISO-8601 and `'active'`, the gateway's native +formats. + +That is the loop worth naming: the sidecar wrote a shape the gateway could not read, so the +gateway stopped refreshing, so the sidecar had to do all the refreshing. Fixing one side without +the other would have left it half-broken. + +--- + +## A note on the README claim + +An earlier version of this project's README stated that 9Router writing `expiresAt` as an ISO +string "breaks internal numeric validations, producing false HTTP 503 errors". + +Reading the upstream source does not support that. 9Router consistently parses `expiresAt` with +`new Date(...)`, which handles ISO correctly, and no 503 path is tied to credential expiry. The +real defect is the opposite shape — a numeric epoch the parser rejects — which is what +[#3997](https://github.com/decolua/9router/pull/3997) fixes. + +The normalization 9RTKSync performs is still useful: it is what keeps the stored value in a shape +every reader on both sides handles. diff --git a/docs/wiki/_Footer.md b/docs/wiki/_Footer.md new file mode 100644 index 0000000..262b7fa --- /dev/null +++ b/docs/wiki/_Footer.md @@ -0,0 +1 @@ +Generated from `docs/wiki/` — edits made directly in the wiki are overwritten on the next push to `master`. · [Pathbit](https://pathbit.co/) diff --git a/docs/wiki/_Sidebar.md b/docs/wiki/_Sidebar.md new file mode 100644 index 0000000..303a895 --- /dev/null +++ b/docs/wiki/_Sidebar.md @@ -0,0 +1,15 @@ +### 9RTKSync + +- [Home](Home) +- [Installation](Installation) +- [Configuration](Configuration) +- [Dashboard](Dashboard) +- [Authentication](Authentication) +- [Logging](Logging) +- [Architecture](Architecture) +- [Troubleshooting](Troubleshooting) +- [Upstream Fixes](Upstream-Fixes) + +--- + +Edited in [`docs/wiki/`](https://github.com/pathbit/9RTKSync/tree/master/docs/wiki) From 4a96f3e5ad2e5eec2e827e0118e956d188fb5183 Mon Sep 17 00:00:00 2001 From: Eliel Sousa Date: Sat, 12 Sep 2026 11:16:48 -0300 Subject: [PATCH 05/16] fix(tests): torna a sondagem do provedor local deterministica O teste apontava para http://127.0.0.1:11434 de verdade: passava na maquina de quem tem Ollama rodando e falhava no CI, que nao tem. Agora discover_models e stubado e o teste cobre as duas pontas - instancia respondendo (ok + modelos) e instancia fora (unreachable). discover_models tambem deixa de tentar os tres endpoints quando nada esta escutando: um erro de conexao encerra a sondagem em vez de multiplicar o timeout por 3 a cada varredura. Um HTTP de verdade (404/405) continua tentando o proximo caminho. --- src/nine_rtksync/providers/local.py | 10 +++++++++- tests/test_discovery.py | 20 ++++++++++++++++++-- 2 files changed, 27 insertions(+), 3 deletions(-) diff --git a/src/nine_rtksync/providers/local.py b/src/nine_rtksync/providers/local.py index fbde218..33d72aa 100644 --- a/src/nine_rtksync/providers/local.py +++ b/src/nine_rtksync/providers/local.py @@ -38,7 +38,15 @@ def discover_models(self, base_url: str, api_key: str = "") -> Tuple[List[str], req.add_header("Authorization", f"Bearer {api_key}") with urllib.request.urlopen(req, timeout=PROBE_TIMEOUT_SECONDS) as resp: payload = json.loads(resp.read().decode("utf-8")) - except (urllib.error.URLError, urllib.error.HTTPError, OSError, ValueError) as e: + except urllib.error.HTTPError as e: + # The host answered, this path just is not the right one — keep trying. + last_error = str(e) + continue + except (urllib.error.URLError, OSError) as e: + # Nothing is listening: trying the remaining paths only multiplies the + # timeout (3 endpoints x 3s) on every sweep. Give up now. + return [], str(e) + except ValueError as e: last_error = str(e) continue diff --git a/tests/test_discovery.py b/tests/test_discovery.py index 21ca6a4..919216f 100644 --- a/tests/test_discovery.py +++ b/tests/test_discovery.py @@ -5,6 +5,7 @@ import shutil import tempfile import unittest +from unittest import mock from nine_rtksync.discovery import HostDiscoveryEngine from nine_rtksync.models import ConnectionRecord @@ -112,7 +113,9 @@ def test_providers_with_discovery(self): self.assertTrue(mod) self.assertEqual(data["apiKey"], "sk-ant-new-host") - # 3. Local Provider handles Ollama + # 3. Local Provider handles Ollama. + # The catalog probe is stubbed so the test never depends on something + # actually listening on 11434 — it passed locally and failed in CI before. lp = LocalProvider() conn_ollama = ConnectionRecord( id="c3", @@ -123,9 +126,22 @@ def test_providers_with_discovery(self): data_raw=json.dumps({"baseUrl": "http://127.0.0.1:11434/v1"}), ) self.assertTrue(lp.can_handle(conn_ollama)) - mod, data, msgs = lp.check_and_refresh(conn_ollama) + + with mock.patch.object( + LocalProvider, "discover_models", return_value=(["llama3.2:3b", "qwen2.5:7b"], "") + ): + mod, data, msgs = lp.check_and_refresh(conn_ollama) self.assertTrue(mod) self.assertEqual(data["testStatus"], "ok") + self.assertEqual(data["discoveredModels"], ["llama3.2:3b", "qwen2.5:7b"]) + + # An instance that stops answering must not be reported as healthy. + with mock.patch.object( + LocalProvider, "discover_models", return_value=([], "Connection refused") + ): + mod, data, msgs = lp.check_and_refresh(conn_ollama) + self.assertTrue(mod) + self.assertEqual(data["testStatus"], "unreachable") if __name__ == "__main__": From d5ebd6542475c3972b6a442c346fc5511ccc8739 Mon Sep 17 00:00:00 2001 From: elielsousa-pathbit Date: Sat, 12 Sep 2026 11:38:19 -0300 Subject: [PATCH 06/16] fix: valida credenciais de verdade, corrige deteccao de instancia local e fecha CSRF O painel pintava toda conexao de verde apenas por existir uma chave: o ApiKeyProvider carimbava testStatus = "ok" sem nunca perguntar nada ao provedor, entao uma chave revogada seguia "ativa" ate uma requisicao real falhar. Agora cada credencial e verificada de fato. Validacao viva (credential_check.py): - API keys: 401/403 = recusada, 429 = rate limited, qualquer outra resposta HTTP = credencial aceita (validamos a credencial, nao o modelo). - Tokens OAuth: consulta o tokeninfo do Google, que responde 400 quando o token morreu, em vez de inferir vida a partir da validade gravada. - Endpoints escolhidos por medicao, nao por suposicao: /api/v1/models do OpenRouter responde 200 sem credencial nenhuma e validaria qualquer lixo, entao usa-se /api/v1/key; o catalogo do Ollama Cloud e publico pelo mesmo motivo; o Google AI Studio autentica por header e responde 400. - Desligada por padrao no construtor, para que teste algum gere trafego de saida por acidente. Deteccao de instancia local: - baseUrl mora em providerSpecificData, nao na raiz de data. Lendo so a raiz, nenhuma instancia local exibia seus modelos. - A classificacao passa a ser pelo endereco, nao pelo nome: "ollama" tambem e o nome do Ollama Cloud, que era tratado como local e sondado em /api/tags. - is_local e avaliado antes do ramo de API key, senao a instancia local, que carrega chave de fachada, nunca chegava ao proprio teste. Seguranca do painel: - POST de outra origem passa a ser recusado. O Basic Auth e anexado pelo navegador mesmo em formulario de outro site, e urlencoded nao dispara preflight: dava para trocar a senha do painel a partir de uma pagina maliciosa. - /api/status devolvia accessToken, refreshToken, apiKey e a linha bruta do banco. Passa a projetar apenas os campos que a tela consome. Interface: - Tipografia via Google Fonts, com pilha de sistema como reserva. - Linha de diagnostico do gateway alinhada a direita como as demais. - Badges e traducoes (en/pt/es) para os estados novos da credencial. READMEs dos dois projetos publicavam a porta antiga e davam o mesmo container_name aos dois sincronizadores, que colidiriam ao subir juntos. --- README.md | 18 +- src/nine_rtksync/config.py | 7 + src/nine_rtksync/credential_check.py | 262 ++++++++++++++++++++++++ src/nine_rtksync/daemon.py | 6 +- src/nine_rtksync/i18n.py | 51 +++++ src/nine_rtksync/models.py | 68 +++++-- src/nine_rtksync/providers/api_keys.py | 67 +++++-- src/nine_rtksync/web/render.py | 21 ++ src/nine_rtksync/web/server.py | 25 +++ tests/test_credential_check.py | 267 +++++++++++++++++++++++++ tests/test_web_security.py | 161 +++++++++++++++ 11 files changed, 913 insertions(+), 40 deletions(-) create mode 100644 src/nine_rtksync/credential_check.py create mode 100644 tests/test_credential_check.py create mode 100644 tests/test_web_security.py diff --git a/README.md b/README.md index 3edb34f..8ed3153 100644 --- a/README.md +++ b/README.md @@ -31,7 +31,7 @@ request; a push to `master` republishes the wiki automatically. * **Rate-Limit Lock Clearing** * Automatically purges expired `rateLimitedUntil` locks and resets backoff counters as soon as cooldown periods finish. * **Built-in Web Dashboard** - * Lightweight web server on port `9190` featuring a modern interface, live account countdowns, real-time gateway health diagnostics, and manual sync triggers. + * Lightweight web server on port `9090` (published on `9091`) featuring a modern interface, live account countdowns, real-time gateway health diagnostics, and manual sync triggers. * **Resilience Combos Enforcement** * Keeps fallback combos registered and synchronized in SQLite (`arsenal-supremo`, `arsenal-rapido`, `arsenal-offline`, `claudegravity-fallback`, `claudegravity-thinking`) without primary key conflicts. * **Strict Virtual Environment Execution** @@ -49,7 +49,7 @@ docker pull ghcr.io/pathbit/9rtksync:latest ### Docker Compose Example -Add `router-sync` to your `docker-compose.yml` alongside [9Router](https://github.com/decolua/9router): +Add `9rtksync` to your `docker-compose.yml` alongside [9Router](https://github.com/decolua/9router): ```yaml services: @@ -70,10 +70,10 @@ services: 9rtksync: image: ghcr.io/pathbit/9rtksync:latest - container_name: router-sync + container_name: 9rtksync restart: unless-stopped ports: - - "127.0.0.1:9190:9190" + - "127.0.0.1:9091:9090" volumes: - 9router_data:/app/data - ${HOME}:/root/host:ro @@ -84,14 +84,14 @@ services: - SYNC_INTERVAL=${SYNC_INTERVAL:-300} - REFRESH_MARGIN=${REFRESH_MARGIN:-900} - ENABLE_WEB_DASHBOARD=${ENABLE_WEB_DASHBOARD:-1} - - WEB_PORT=${WEB_PORT:-9190} + - WEB_PORT=${WEB_PORT:-9090} - DASHBOARD_USER=${DASHBOARD_USER:-admin} - DASHBOARD_PASSWORD=${DASHBOARD_PASSWORD:-pathbit} depends_on: 9router: condition: service_healthy healthcheck: - test: ["CMD", "/opt/venv/bin/python3", "-c", "import urllib.request; urllib.request.urlopen('http://127.0.0.1:9190/healthz', timeout=3)"] + test: ["CMD", "/opt/venv/bin/python3", "-c", "import urllib.request; urllib.request.urlopen('http://127.0.0.1:9090/healthz', timeout=3)"] interval: 15s timeout: 5s retries: 3 @@ -140,7 +140,7 @@ cp .env.example .env # Run an immediate one-shot synchronization pass 9RTKSync --once --db-path /path/to/data.sqlite -# Run continuous background daemon with web dashboard on port 9190 +# Run continuous background daemon with web dashboard on port 9090 (published on 9091) 9RTKSync --daemon --db-path /path/to/data.sqlite ``` @@ -155,7 +155,7 @@ cp .env.example .env | `SYNC_INTERVAL` | `300` | Sync and background cron loop interval in seconds | | `REFRESH_MARGIN` | `900` | Proactive token renewal margin in seconds before expiration | | `ENABLE_WEB_DASHBOARD` | `1` | Enable the embedded web dashboard (`1` to enable, `0` to disable) | -| `WEB_PORT` | `9190` | HTTP port for the web dashboard | +| `WEB_PORT` | `9090` | HTTP port for the web dashboard | | `WEB_HOST` | `0.0.0.0` | Network binding interface for the dashboard web server | | `DASHBOARD_USER` | `admin` | HTTP Basic Auth username for web dashboard access | | `DASHBOARD_PASSWORD` | `pathbit` | Default HTTP Basic Auth password for web dashboard access | @@ -167,7 +167,7 @@ cp .env.example .env When running with `ENABLE_WEB_DASHBOARD=1`, access the dashboard in your browser: -👉 **http://localhost:9190** +👉 **http://localhost:9091** Dashboard capabilities: * Live operational metrics (Total Connections, OAuth Accounts, API Keys, Resilience Combos). diff --git a/src/nine_rtksync/config.py b/src/nine_rtksync/config.py index ac46718..1745e42 100644 --- a/src/nine_rtksync/config.py +++ b/src/nine_rtksync/config.py @@ -56,6 +56,10 @@ class Settings: dashboard_password: str = "pathbit" cron_interval: int = 300 cron_enabled: bool = True + # Validação viva das credenciais: pergunta ao provedor se a chave ainda é + # aceita, em vez de pintar a linha de verde só porque existe uma chave. + validate_credentials: bool = True + validation_timeout: float = 8.0 # Quando DASHBOARD_USER/DASHBOARD_PASSWORD vêm explicitamente do ambiente, elas # passam a ser a fonte de verdade e o arquivo salvo pela tela é ignorado. É o # que permite operar 100% headless (Docker, Kubernetes, CI) sem abrir o painel. @@ -195,6 +199,7 @@ def from_env(cls, env_file: str = ".env") -> "Settings": sync_int = int(os.environ.get("SYNC_INTERVAL", "300")) cron_int = int(os.environ.get("CRON_INTERVAL", str(sync_int))) cron_on = os.environ.get("CRON_ENABLED", "1") not in ("0", "false", "no") + validate_on = os.environ.get("CREDENTIAL_CHECK_ENABLED", "1") not in ("0", "false", "no") return cls( db_path=db_path, @@ -211,5 +216,7 @@ def from_env(cls, env_file: str = ".env") -> "Settings": dashboard_password=d_pass, cron_interval=cron_int, cron_enabled=cron_on, + validate_credentials=validate_on, + validation_timeout=float(os.environ.get("CREDENTIAL_CHECK_TIMEOUT", "8")), dashboard_auth_from_env=auth_from_env, ) diff --git a/src/nine_rtksync/credential_check.py b/src/nine_rtksync/credential_check.py new file mode 100644 index 0000000..e291831 --- /dev/null +++ b/src/nine_rtksync/credential_check.py @@ -0,0 +1,262 @@ +"""Live validation of the credentials stored in the gateway. + +Until now a connection was reported healthy just for carrying an API key -- the +panel painted every row green without ever asking the provider whether the key +still worked. This module actually calls each provider and reports what came +back. + +What counts as a valid credential: the provider accepted the authentication. +A 404 for a missing model or a 400 for an empty body still means the key was +accepted, so only 401/403 (and the idiomatic 400 that Google AI Studio returns +for a bad key) are treated as a rejection. + +Endpoint choices are deliberate, and were measured rather than assumed: + +- OpenRouter's ``/api/v1/models`` answers 200 with no credential at all, so it + cannot validate anything. ``/api/v1/key`` answers 401. +- Ollama Cloud's catalog is public for the same reason; a chat completion is + the cheapest call that requires the key. +- Google AI Studio authenticates with the ``x-goog-api-key`` header and answers + 400 -- not 401 -- for a bad key. +- OAuth access tokens are checked with Google's ``tokeninfo``, which answers 400 + once the token dies. +""" + +import json +import time +import urllib.error +import urllib.parse +import urllib.request +from dataclasses import dataclass +from datetime import datetime, timezone +from typing import Any, Callable, Dict, Optional + +DEFAULT_TIMEOUT_SECONDS = 8.0 +USER_AGENT = "9RTKSync-CredentialCheck/1.0" + +# States a probe can conclude. "not_checked" is the absence of a probe. +STATE_VALID = "valid" +STATE_INVALID = "invalid" +STATE_RATE_LIMITED = "rate_limited" +STATE_UNREACHABLE = "unreachable" +STATE_UNSUPPORTED = "unsupported" + + +@dataclass +class CheckResult: + """Outcome of a single credential probe.""" + + state: str + detail: str = "" + http_status: int = 0 + latency_ms: int = 0 + checked_at: str = "" + + def to_dict(self) -> Dict[str, Any]: + return { + "credentialState": self.state, + "credentialDetail": self.detail, + "credentialHttpStatus": self.http_status, + "credentialLatencyMs": self.latency_ms, + "credentialCheckedAt": self.checked_at, + } + + +@dataclass +class ProbeSpec: + """How to ask one provider whether a credential is still accepted.""" + + url: str + auth_header: str = "Authorization" + auth_template: str = "Bearer {key}" + method: str = "GET" + body: Optional[bytes] = None + content_type: str = "" + # Statuses that mean "credential rejected" beyond the usual 401/403. + invalid_statuses: tuple = () + + +# Matched by substring against the provider name, longest marker first so +# "openai-compatible-chat-ollama-local" never matches the bare "ollama" entry. +API_KEY_PROBES: Dict[str, ProbeSpec] = { + "groq": ProbeSpec("https://api.groq.com/openai/v1/models"), + "mistral": ProbeSpec("https://api.mistral.ai/v1/models"), + "openai": ProbeSpec("https://api.openai.com/v1/models"), + "anthropic": ProbeSpec( + "https://api.anthropic.com/v1/models", + auth_header="x-api-key", + auth_template="{key}", + ), + "openrouter": ProbeSpec("https://openrouter.ai/api/v1/key"), + "gemini": ProbeSpec( + "https://generativelanguage.googleapis.com/v1beta/models", + auth_header="x-goog-api-key", + auth_template="{key}", + invalid_statuses=(400,), + ), + "ollama": ProbeSpec( + "https://ollama.com/v1/chat/completions", + method="POST", + body=json.dumps({"model": "gpt-oss:20b", "messages": [], "max_tokens": 1}).encode(), + content_type="application/json", + ), +} + +GOOGLE_TOKENINFO = "https://oauth2.googleapis.com/tokeninfo" + + +def _now_iso() -> str: + return datetime.now(timezone.utc).isoformat(timespec="seconds").replace("+00:00", "Z") + + +def _classify(status: int, spec_invalid: tuple = ()) -> str: + if status in (401, 403) or status in spec_invalid: + return STATE_INVALID + if status == 429: + return STATE_RATE_LIMITED + return STATE_VALID + + +def _execute( + request: urllib.request.Request, + timeout: float, + opener: Optional[Callable] = None, + spec_invalid: tuple = (), +) -> CheckResult: + """Run one probe and turn whatever happened into a CheckResult.""" + send = opener or urllib.request.urlopen + started = time.time() + try: + with send(request, timeout=timeout) as resp: + status = getattr(resp, "status", 200) + return CheckResult( + state=_classify(status, spec_invalid), + detail=f"HTTP {status}", + http_status=status, + latency_ms=int((time.time() - started) * 1000), + checked_at=_now_iso(), + ) + except urllib.error.HTTPError as e: + # The provider answered -- that answer is exactly the signal we want. + state = _classify(e.code, spec_invalid) + return CheckResult( + state=state, + detail=f"HTTP {e.code}", + http_status=e.code, + latency_ms=int((time.time() - started) * 1000), + checked_at=_now_iso(), + ) + except Exception as e: + # No answer at all: the credential is unproven, not proven bad. + return CheckResult( + state=STATE_UNREACHABLE, + detail=str(e)[:200], + latency_ms=int((time.time() - started) * 1000), + checked_at=_now_iso(), + ) + + +# Markers that describe a self-hosted endpoint and must never resolve to a +# vendor URL. "openai-compatible-chat-ollama-local" contains both "openai" and +# "ollama"; without this guard it would be probed against api.openai.com. +SELF_HOSTED_MARKERS = ("openai-compatible", "-local", "localai") + + +def select_probe(provider: str) -> Optional[ProbeSpec]: + """Pick the probe for a provider name, preferring the most specific marker. + + Returns None when the credential belongs to a self-hosted endpoint or to a + provider with no known probe; the caller then falls back to base_url. + """ + name = (provider or "").lower() + if any(marker in name for marker in SELF_HOSTED_MARKERS): + return None + + matches = [marker for marker in API_KEY_PROBES if marker in name] + if not matches: + return None + # Sort by length then alphabetically so the choice never depends on dict order. + return API_KEY_PROBES[sorted(matches, key=lambda m: (-len(m), m))[0]] + + +def check_api_key( + provider: str, + api_key: str, + base_url: Optional[str] = None, + timeout: float = DEFAULT_TIMEOUT_SECONDS, + opener: Optional[Callable] = None, +) -> CheckResult: + """Ask the provider whether this API key is still accepted.""" + if not api_key: + return CheckResult(state=STATE_UNSUPPORTED, detail="No API key", checked_at=_now_iso()) + + spec = select_probe(provider) + if spec is None: + if not base_url: + return CheckResult( + state=STATE_UNSUPPORTED, + detail=f"No known probe for '{provider}'", + checked_at=_now_iso(), + ) + # Unknown provider with a declared address: the OpenAI-compatible + # catalog is the convention every one of them follows. + spec = ProbeSpec(base_url.rstrip("/") + "/models") + + request = urllib.request.Request(spec.url, method=spec.method, data=spec.body) + request.add_header(spec.auth_header, spec.auth_template.format(key=api_key)) + request.add_header("User-Agent", USER_AGENT) + if spec.content_type: + request.add_header("Content-Type", spec.content_type) + + return _execute(request, timeout, opener, spec.invalid_statuses) + + +def check_oauth_token( + access_token: str, + timeout: float = DEFAULT_TIMEOUT_SECONDS, + opener: Optional[Callable] = None, +) -> CheckResult: + """Ask Google whether this access token is still alive. + + Answers the question directly instead of inferring liveness from the stored + expiry, which is what the panel used to do. + """ + if not access_token: + return CheckResult(state=STATE_UNSUPPORTED, detail="No access token", checked_at=_now_iso()) + + query = urllib.parse.urlencode({"access_token": access_token}) + request = urllib.request.Request(f"{GOOGLE_TOKENINFO}?{query}") + request.add_header("User-Agent", USER_AGENT) + # tokeninfo reports a dead token as 400, not 401. + return _execute(request, timeout, opener, spec_invalid=(400,)) + + +def check_connection( + conn: Any, + timeout: float = DEFAULT_TIMEOUT_SECONDS, + opener: Optional[Callable] = None, +) -> CheckResult: + """Validate whichever credential the connection actually carries.""" + if getattr(conn, "is_local", False): + # Local instances are proven by their model catalog, not by a cloud API. + return CheckResult( + state=STATE_UNSUPPORTED, + detail="Local instance: validated by model discovery", + checked_at=_now_iso(), + ) + + if getattr(conn, "is_oauth", False) and getattr(conn, "access_token", None): + return check_oauth_token(conn.access_token, timeout=timeout, opener=opener) + + if getattr(conn, "has_api_key", False): + return check_api_key( + conn.provider, + conn.api_key or "", + base_url=getattr(conn, "base_url", None), + timeout=timeout, + opener=opener, + ) + + return CheckResult( + state=STATE_UNSUPPORTED, detail="No credential to validate", checked_at=_now_iso() + ) diff --git a/src/nine_rtksync/daemon.py b/src/nine_rtksync/daemon.py index e727bf7..95594eb 100644 --- a/src/nine_rtksync/daemon.py +++ b/src/nine_rtksync/daemon.py @@ -36,7 +36,11 @@ def __init__(self, settings: Settings): self.providers: List[BaseProvider] = [ GoogleProvider(credential_paths=settings.credential_paths, discovery=self.discovery), GenericOAuthProvider(discovery=self.discovery), - ApiKeyProvider(discovery=self.discovery), + ApiKeyProvider( + discovery=self.discovery, + validate_credentials=settings.validate_credentials, + validation_timeout=settings.validation_timeout, + ), LocalProvider(), ] diff --git a/src/nine_rtksync/i18n.py b/src/nine_rtksync/i18n.py index 3af0007..f95947f 100644 --- a/src/nine_rtksync/i18n.py +++ b/src/nine_rtksync/i18n.py @@ -80,6 +80,23 @@ "health.rate_limited": "Rate limited", "health.no_expiration": "No expiry", "health.unknown": "Unknown", + "health.invalid": "Rejected", + "health.unreachable": "Unreachable", + "health.not_checked": "Not checked", + "gateway.diagnostics": "Diagnostics", + "gateway.diag_ok": "Gateway and SQLite database fully operational", + "gateway.diag_db_failed": "Gateway online, database unreadable", + "gateway.diag_gateway_failed": "Gateway unreachable", + "credential.title": "Credential", + "credential.checked_at": "Checked at", + "credential.never": "Never validated", + "action.validate": "Validate credentials", + "password.policy": "At least 6 characters, with uppercase, lowercase, a number and a special character.", + "password.too_short": "Password must have at least 6 characters.", + "password.needs_upper": "Password must contain an uppercase letter.", + "password.needs_lower": "Password must contain a lowercase letter.", + "password.needs_digit": "Password must contain a number.", + "password.needs_special": "Password must contain a special character.", "duration.unlimited": "Unlimited / N/A", "duration.expired": "Expired", "reason.api_key": "Static key: never expires, nothing to renew", @@ -159,6 +176,23 @@ "health.rate_limited": "Rate limit", "health.no_expiration": "Sem expiração", "health.unknown": "Desconhecido", + "health.invalid": "Recusada", + "health.unreachable": "Inacessível", + "health.not_checked": "Não verificada", + "gateway.diagnostics": "Diagnóstico", + "gateway.diag_ok": "Gateway e banco SQLite totalmente operacionais", + "gateway.diag_db_failed": "Gateway online, banco ilegível", + "gateway.diag_gateway_failed": "Gateway inacessível", + "credential.title": "Credencial", + "credential.checked_at": "Verificada em", + "credential.never": "Nunca validada", + "action.validate": "Validar credenciais", + "password.policy": "Mínimo de 6 caracteres, com maiúscula, minúscula, número e caractere especial.", + "password.too_short": "A senha precisa ter ao menos 6 caracteres.", + "password.needs_upper": "A senha precisa conter uma letra maiúscula.", + "password.needs_lower": "A senha precisa conter uma letra minúscula.", + "password.needs_digit": "A senha precisa conter um número.", + "password.needs_special": "A senha precisa conter um caractere especial.", "duration.unlimited": "Ilimitado / N/A", "duration.expired": "Expirado", "reason.api_key": "Chave estática: não expira, nada a renovar", @@ -238,6 +272,23 @@ "health.rate_limited": "Límite de tasa", "health.no_expiration": "Sin expiración", "health.unknown": "Desconocido", + "health.invalid": "Rechazada", + "health.unreachable": "Inaccesible", + "health.not_checked": "Sin verificar", + "gateway.diagnostics": "Diagnóstico", + "gateway.diag_ok": "Gateway y base SQLite totalmente operativos", + "gateway.diag_db_failed": "Gateway en línea, base ilegible", + "gateway.diag_gateway_failed": "Gateway inaccesible", + "credential.title": "Credencial", + "credential.checked_at": "Verificada el", + "credential.never": "Nunca validada", + "action.validate": "Validar credenciales", + "password.policy": "Mínimo de 6 caracteres, con mayúscula, minúscula, número y carácter especial.", + "password.too_short": "La contraseña necesita al menos 6 caracteres.", + "password.needs_upper": "La contraseña necesita una letra mayúscula.", + "password.needs_lower": "La contraseña necesita una letra minúscula.", + "password.needs_digit": "La contraseña necesita un número.", + "password.needs_special": "La contraseña necesita un carácter especial.", "duration.unlimited": "Ilimitado / N/D", "duration.expired": "Expirado", "reason.api_key": "Clave estática: no expira, nada que renovar", diff --git a/src/nine_rtksync/models.py b/src/nine_rtksync/models.py index 0c48bd7..646290d 100644 --- a/src/nine_rtksync/models.py +++ b/src/nine_rtksync/models.py @@ -46,25 +46,40 @@ def refresh_token(self) -> Optional[str]: def api_key(self) -> Optional[str]: return self.data.get("apiKey") - # Provider names that identify a local / OpenAI-compatible instance. + # Provider names that suggest a local / OpenAI-compatible instance. A marker + # alone is not proof: "ollama" is also the name of Ollama Cloud, which is a + # hosted service and must never be probed on /api/tags. LOCAL_PROVIDER_MARKERS = ("ollama", "vllm", "lmstudio", "llamacpp", "localai", "openai-compatible") + LOCAL_HOSTS = ("localhost", "127.0.0.1", "0.0.0.0", "::1", "host.docker.internal", ".local") @property def is_local(self) -> bool: - """Whether the connection points at a local instance (Ollama, vLLM, LM Studio...). + """Whether the connection really points at an instance on this machine. - A local instance usually needs a facade API key, so checking has_api_key - alone would classify it as a cloud provider. + Classification is driven by the address, not by the provider name. Only + when no address is declared does a marker like "openai-compatible" -- + which has no hosted counterpart -- stand on its own. """ - provider = self.provider.lower() - if any(marker in provider for marker in self.LOCAL_PROVIDER_MARKERS): - return True - base_url = str(self.data.get("baseUrl") or "") - return any(host in base_url for host in ("localhost", "127.0.0.1", "0.0.0.0", "host.docker.internal")) + base_url = str(self.base_url or "").lower() + if base_url: + return any(host in base_url for host in self.LOCAL_HOSTS) + + # No address: "openai-compatible" only exists as a self-hosted endpoint, + # whereas "ollama" without a baseUrl is the cloud account. + return "openai-compatible" in self.provider.lower() @property def base_url(self) -> Optional[str]: - """Provider base URL, when declared.""" + """Provider base URL, when declared. + + 9Router keeps it inside providerSpecificData, not at the root of data -- + reading only the root is why local instances used to show no models. + """ + specific = self.data.get("providerSpecificData") + if isinstance(specific, dict): + nested = specific.get("baseUrl") or specific.get("baseURL") + if nested: + return nested return self.data.get("baseUrl") or self.data.get("baseURL") or None @property @@ -101,9 +116,32 @@ def is_expired(self) -> bool: rem = self.remaining_seconds return rem is not None and rem <= 0 + @property + def credential_state(self) -> Optional[str]: + """Result of the last live credential probe, when one was recorded. + + Written by credential_check.py, never by the gateway. + """ + state = self.data.get("credentialState") + return str(state) if state else None + @property def health_status(self) -> str: - """Semantic classification of connection health.""" + """Semantic classification of connection health. + + A live probe outranks everything else: a key the provider rejects is + broken no matter what the gateway last stamped. Local instances are + classified before the API-key branch because they carry a facade key + and would otherwise never reach their own test. + """ + probed = self.credential_state + if probed in ("invalid", "rate_limited", "unreachable"): + return probed + + if self.is_local: + # A local instance is only healthy when its model catalog answered. + return "unknown" if self.data.get("testStatus") == "unreachable" else "active" + if self.is_oauth: rem = self.remaining_seconds if rem is None: @@ -113,12 +151,12 @@ def health_status(self) -> str: if rem < 900: return "expiring_soon" return "active" + if self.has_api_key: if self.data.get("rateLimitedUntil"): return "rate_limited" - return "active" - if self.is_local: - # A local instance is only healthy when its model catalog answered. - return "unknown" if self.data.get("testStatus") == "unreachable" else "active" + # Never probed yet: say so instead of claiming health nobody verified. + return "active" if probed == "valid" else "not_checked" + # 9Router writes "ok", OmniRoute writes "active"; both mean healthy. return "active" if self.data.get("testStatus") in ("ok", "active") else "unknown" diff --git a/src/nine_rtksync/providers/api_keys.py b/src/nine_rtksync/providers/api_keys.py index 2733cd6..2e8cc77 100644 --- a/src/nine_rtksync/providers/api_keys.py +++ b/src/nine_rtksync/providers/api_keys.py @@ -6,23 +6,36 @@ from datetime import datetime, timezone from typing import Any, Dict, List, Optional, Tuple +from ..credential_check import ( + DEFAULT_TIMEOUT_SECONDS, + STATE_INVALID, + STATE_RATE_LIMITED, + STATE_UNREACHABLE, + STATE_VALID, + check_api_key, +) from ..models import ConnectionRecord from .base import BaseProvider class ApiKeyProvider(BaseProvider): - """Health monitor for static API key providers.""" + """Health monitor for static API key providers. - HEALTH_CHECK_ENDPOINTS = { - "groq": "https://api.groq.com/openai/v1/models", - "mistral": "https://api.mistral.ai/v1/models", - "openrouter": "https://openrouter.ai/api/v1/models", - "gemini": "https://generativelanguage.googleapis.com/v1beta/models", - "openai": "https://api.openai.com/v1/models", - } + Validation is off by default in constructors that do not ask for it, so a + test or a dry run never reaches out to the internet by accident. + """ - def __init__(self, discovery: Optional[Any] = None): + def __init__( + self, + discovery: Optional[Any] = None, + validate_credentials: bool = False, + validation_timeout: float = DEFAULT_TIMEOUT_SECONDS, + opener: Optional[Any] = None, + ): self.discovery = discovery + self.validate_credentials = validate_credentials + self.validation_timeout = validation_timeout + self.opener = opener def can_handle(self, conn: ConnectionRecord) -> bool: return conn.has_api_key @@ -50,15 +63,39 @@ def check_and_refresh( modified = True messages.append("Proactively removed rateLimitedUntil lock") - # 3. Update health status stamp if necessary - if not data.get("testStatus") or data.get("testStatus") != "ok": - data["testStatus"] = "ok" - data["lastTested"] = datetime.now(timezone.utc).isoformat().replace("+00:00", "Z") + # 3. Ask the provider whether the key still works. + # + # This used to stamp testStatus = "ok" unconditionally, which is why the + # panel showed every API key as healthy: nothing had ever been verified, + # and a revoked key stayed green until a real request failed. + if self.validate_credentials: + result = check_api_key( + conn.provider, + conn.api_key or "", + base_url=conn.base_url, + timeout=self.validation_timeout, + opener=self.opener, + ) + data.update(result.to_dict()) + data["lastTested"] = result.checked_at modified = True - messages.append("Connection status marked as operational (ok)") + + if result.state == STATE_VALID: + data["testStatus"] = "ok" + messages.append(f"API key accepted by the provider ({result.detail})") + elif result.state == STATE_INVALID: + # Do not claim health the provider just denied. + data["testStatus"] = "invalid" + messages.append(f"API key REJECTED by the provider ({result.detail})") + elif result.state == STATE_RATE_LIMITED: + messages.append(f"Provider rate limited the validation ({result.detail})") + elif result.state == STATE_UNREACHABLE: + messages.append(f"Provider unreachable, key not verified: {result.detail}") + else: + messages.append(result.detail or "Credential not verifiable") if not messages: - messages.append("API key active and healthy") + messages.append("API key unchanged") return modified, data if modified else None, messages diff --git a/src/nine_rtksync/web/render.py b/src/nine_rtksync/web/render.py index 032f7d4..30dbd9e 100644 --- a/src/nine_rtksync/web/render.py +++ b/src/nine_rtksync/web/render.py @@ -20,6 +20,13 @@ FLAG_ICONS = "https://cdn.jsdelivr.net/npm/flag-icons@7.2.3/css/flag-icons.min.css" BOOTSTRAP_JS = "https://cdn.jsdelivr.net/npm/bootstrap@5.3.3/dist/js/bootstrap.bundle.min.js" JQUERY_JS = "https://cdn.jsdelivr.net/npm/jquery@3.7.1/dist/jquery.min.js" +# Tipografia: Google Fonts, com pilha de sistema como reserva se o CDN cair. +GOOGLE_FONTS = ( + "https://fonts.googleapis.com/css2?" + "family=Inter:wght@400;500;600;700&family=JetBrains+Mono:wght@400;500&display=swap" +) +FONT_STACK = "'Inter', system-ui, -apple-system, 'Segoe UI', Roboto, sans-serif" +MONO_STACK = "'JetBrains Mono', ui-monospace, SFMono-Regular, Menlo, monospace" # Estado semântico -> (classe do badge, ícone) HEALTH_PRESENTATION = { @@ -29,6 +36,10 @@ "rate_limited": ("text-bg-warning", "bi-pause-circle-fill"), "no_expiration": ("text-bg-secondary", "bi-infinity"), "unknown": ("text-bg-secondary", "bi-question-circle-fill"), + # Estados vindos da validação viva da credencial. + "invalid": ("text-bg-danger", "bi-shield-exclamation"), + "unreachable": ("text-bg-warning", "bi-plug"), + "not_checked": ("text-bg-secondary", "bi-dash-circle"), } @@ -372,6 +383,14 @@ def render_gateway_card(gateway: Dict[str, Any], db_path: str, lang: str) -> str f'{esc(gateway.get("error") or translate("gateway.no_response", lang))}' ) + db_ok = bool(gateway.get("dbSummary")) + if online and db_ok: + diagnosis = translate("gateway.diag_ok", lang) + elif online: + diagnosis = translate("gateway.diag_db_failed", lang) + else: + diagnosis = translate("gateway.diag_gateway_failed", lang) + return f"""
    @@ -398,6 +417,8 @@ def render_gateway_card(gateway: Dict[str, Any], db_path: str, lang: str) -> str
    {esc(gateway.get("dbSummary") or "—")}
    +
    {esc(translate("gateway.diagnostics", lang))}
    +
    {esc(diagnosis)}
    """ diff --git a/src/nine_rtksync/web/server.py b/src/nine_rtksync/web/server.py index 60298c1..8f40f2e 100644 --- a/src/nine_rtksync/web/server.py +++ b/src/nine_rtksync/web/server.py @@ -108,10 +108,35 @@ def do_GET(self): else: self.send_error(HTTPStatus.NOT_FOUND, "Page not found") + def is_same_origin_request(self) -> bool: + """Rejeita POST disparado por outro site. + + O Basic Auth e anexado automaticamente pelo navegador mesmo em um POST + vindo de outra origem, e um formulario urlencoded nao dispara preflight. + Sem esta checagem, uma pagina maliciosa aberta na mesma maquina poderia + trocar a senha do painel. Nao se usa Referer porque a propria pagina e + servida com Referrer-Policy: no-referrer. + """ + fetch_site = self.headers.get("Sec-Fetch-Site", "") + if fetch_site: + # "none" e a navegacao digitada na barra de enderecos. + return fetch_site in ("same-origin", "none") + + origin = self.headers.get("Origin", "") + if origin: + return urlparse(origin).netloc == self.headers.get("Host", "") + + # Cliente que nao e navegador (curl, script): nao ha sessao a sequestrar. + return True + def do_POST(self): if not self.require_auth(): return + if not self.is_same_origin_request(): + self.send_error(HTTPStatus.FORBIDDEN, "Cross-origin request rejected") + return + length = int(self.headers.get("Content-Length", 0)) raw_body = self.rfile.read(length) if length > 0 else b"{}" diff --git a/tests/test_credential_check.py b/tests/test_credential_check.py new file mode 100644 index 0000000..6f0e9d9 --- /dev/null +++ b/tests/test_credential_check.py @@ -0,0 +1,267 @@ +"""Testes da validacao viva de credenciais. + +Nenhum teste aqui toca a rede: todo probe recebe um opener falso. Foi um teste +que dependia de um servico real que derrubou a CI antes. +""" + +import json +import unittest +import urllib.error +from typing import Optional + +from nine_rtksync import credential_check as cc +from nine_rtksync.models import ConnectionRecord +from nine_rtksync.providers.api_keys import ApiKeyProvider + + +class FakeResponse: + def __init__(self, status: int): + self.status = status + + def __enter__(self): + return self + + def __exit__(self, *args): + return False + + +def opener_returning(status: int): + """Opener que responde com o status pedido e registra a requisicao.""" + captured = {} + + def _opener(request, timeout=None): + captured["url"] = request.full_url + captured["method"] = request.get_method() + captured["headers"] = {k.lower(): v for k, v in request.header_items()} + if status >= 400: + raise urllib.error.HTTPError(request.full_url, status, "err", {}, None) + return FakeResponse(status) + + _opener.captured = captured + return _opener + + +def opener_raising(exc: Exception): + def _opener(request, timeout=None): + raise exc + + return _opener + + +class TestClassification(unittest.TestCase): + def test_200_is_valid(self): + r = cc.check_api_key("groq", "k", opener=opener_returning(200)) + self.assertEqual(r.state, cc.STATE_VALID) + self.assertEqual(r.http_status, 200) + + def test_401_is_invalid(self): + r = cc.check_api_key("groq", "k", opener=opener_returning(401)) + self.assertEqual(r.state, cc.STATE_INVALID) + + def test_403_is_invalid(self): + r = cc.check_api_key("groq", "k", opener=opener_returning(403)) + self.assertEqual(r.state, cc.STATE_INVALID) + + def test_429_is_rate_limited(self): + r = cc.check_api_key("groq", "k", opener=opener_returning(429)) + self.assertEqual(r.state, cc.STATE_RATE_LIMITED) + + def test_404_still_means_the_key_was_accepted(self): + """Validamos a credencial, nao o modelo: 404 de modelo nao invalida a chave.""" + r = cc.check_api_key("groq", "k", opener=opener_returning(404)) + self.assertEqual(r.state, cc.STATE_VALID) + + def test_network_failure_is_unreachable_not_invalid(self): + """Sem resposta a credencial fica nao comprovada, nunca comprovadamente ruim.""" + r = cc.check_api_key("groq", "k", opener=opener_raising(OSError("connection refused"))) + self.assertEqual(r.state, cc.STATE_UNREACHABLE) + + def test_missing_key_is_unsupported(self): + r = cc.check_api_key("groq", "") + self.assertEqual(r.state, cc.STATE_UNSUPPORTED) + + +class TestProbeSelection(unittest.TestCase): + def test_openrouter_uses_the_key_endpoint_not_the_public_catalog(self): + """/api/v1/models responde 200 sem credencial nenhuma: validaria qualquer lixo.""" + op = opener_returning(200) + cc.check_api_key("openrouter", "k", opener=op) + self.assertEqual(op.captured["url"], "https://openrouter.ai/api/v1/key") + + def test_gemini_authenticates_by_header_and_treats_400_as_invalid(self): + op = opener_returning(400) + r = cc.check_api_key("gemini", "k", opener=op) + self.assertIn("x-goog-api-key", op.captured["headers"]) + self.assertEqual(r.state, cc.STATE_INVALID) + + def test_gemini_400_is_invalid_but_groq_400_is_not(self): + r = cc.check_api_key("groq", "k", opener=opener_returning(400)) + self.assertEqual(r.state, cc.STATE_VALID) + + def test_ollama_cloud_posts_because_the_catalog_is_public(self): + op = opener_returning(200) + cc.check_api_key("ollama", "k", opener=op) + self.assertEqual(op.captured["method"], "POST") + self.assertIn("ollama.com", op.captured["url"]) + + def test_self_hosted_name_never_resolves_to_a_vendor_url(self): + """'openai-compatible-chat-ollama-local' casa com 'openai' e com 'ollama'; + sem barreira, a chave de fachada de um Ollama local iria para a api.openai.com.""" + self.assertIsNone(cc.select_probe("openai-compatible-chat-ollama-local")) + self.assertIsNone(cc.select_probe("localai")) + + def test_marker_choice_is_deterministic(self): + self.assertEqual(cc.select_probe("groq-cloud").url, "https://api.groq.com/openai/v1/models") + self.assertEqual(cc.select_probe("ollama").url, "https://ollama.com/v1/chat/completions") + + def test_self_hosted_falls_back_to_its_own_address(self): + op = opener_returning(200) + r = cc.check_api_key( + "openai-compatible-chat-ollama-local", + "k", + base_url="http://host.docker.internal:11434/v1", + opener=op, + ) + self.assertEqual(r.state, cc.STATE_VALID) + self.assertEqual(op.captured["url"], "http://host.docker.internal:11434/v1/models") + + def test_unknown_provider_without_address_is_unsupported(self): + r = cc.check_api_key("provedor-desconhecido", "k", opener=opener_returning(200)) + self.assertEqual(r.state, cc.STATE_UNSUPPORTED) + + def test_unknown_provider_with_address_uses_the_openai_convention(self): + op = opener_returning(200) + r = cc.check_api_key( + "provedor-desconhecido", "k", base_url="https://api.exemplo.com/v1", opener=op + ) + self.assertEqual(r.state, cc.STATE_VALID) + self.assertEqual(op.captured["url"], "https://api.exemplo.com/v1/models") + + +class TestOAuthProbe(unittest.TestCase): + def test_live_token_is_valid(self): + r = cc.check_oauth_token("ya29.token", opener=opener_returning(200)) + self.assertEqual(r.state, cc.STATE_VALID) + + def test_dead_token_answers_400_and_is_invalid(self): + """tokeninfo devolve 400, nao 401, para token morto.""" + r = cc.check_oauth_token("ya29.morto", opener=opener_returning(400)) + self.assertEqual(r.state, cc.STATE_INVALID) + + def test_token_travels_in_the_query(self): + op = opener_returning(200) + cc.check_oauth_token("ya29.abc", opener=op) + self.assertIn("access_token=ya29.abc", op.captured["url"]) + + +class TestConnectionDispatch(unittest.TestCase): + def build(self, provider, payload): + return ConnectionRecord( + id="c1", + provider=provider, + name=provider, + created_at="", + updated_at="", + data_raw=json.dumps(payload), + ) + + def test_oauth_connection_checks_the_token(self): + conn = self.build("antigravity", {"accessToken": "ya29.x", "refreshToken": "r"}) + op = opener_returning(200) + r = cc.check_connection(conn, opener=op) + self.assertEqual(r.state, cc.STATE_VALID) + self.assertIn("tokeninfo", op.captured["url"]) + + def test_api_key_connection_checks_the_key(self): + conn = self.build("groq", {"apiKey": "gsk_x"}) + op = opener_returning(200) + cc.check_connection(conn, opener=op) + self.assertIn("groq.com", op.captured["url"]) + + def test_local_instance_is_not_sent_to_a_cloud_api(self): + conn = self.build( + "openai-compatible-chat-ollama-local", + {"apiKey": "x", "providerSpecificData": {"baseUrl": "http://host.docker.internal:11434/v1"}}, + ) + r = cc.check_connection(conn, opener=opener_returning(200)) + self.assertEqual(r.state, cc.STATE_UNSUPPORTED) + self.assertIn("Local instance", r.detail) + + +class TestApiKeyProviderIntegration(unittest.TestCase): + def build(self, provider, payload): + return ConnectionRecord( + id="c1", + provider=provider, + name=provider, + created_at="", + updated_at="", + data_raw=json.dumps(payload), + ) + + def test_validation_is_off_unless_asked(self): + """Protege a CI: montar o provider nao pode gerar trafego de saida.""" + provider = ApiKeyProvider() + self.assertFalse(provider.validate_credentials) + + def test_rejected_key_is_never_stamped_as_ok(self): + """O bug original: testStatus = 'ok' era carimbado sem perguntar a ninguem.""" + provider = ApiKeyProvider(validate_credentials=True, opener=opener_returning(401)) + conn = self.build("groq", {"apiKey": "revogada", "testStatus": "ok"}) + _, data, msgs = provider.check_and_refresh(conn) + self.assertEqual(data["testStatus"], "invalid") + self.assertEqual(data["credentialState"], cc.STATE_INVALID) + self.assertTrue(any("REJECTED" in m for m in msgs)) + + def test_accepted_key_is_stamped_ok_with_evidence(self): + provider = ApiKeyProvider(validate_credentials=True, opener=opener_returning(200)) + conn = self.build("groq", {"apiKey": "boa"}) + _, data, _ = provider.check_and_refresh(conn) + self.assertEqual(data["testStatus"], "ok") + self.assertEqual(data["credentialState"], cc.STATE_VALID) + self.assertTrue(data["credentialCheckedAt"]) + + def test_unreachable_provider_does_not_mark_the_key_invalid(self): + provider = ApiKeyProvider( + validate_credentials=True, opener=opener_raising(OSError("timeout")) + ) + conn = self.build("groq", {"apiKey": "boa", "testStatus": "ok"}) + _, data, _ = provider.check_and_refresh(conn) + self.assertNotEqual(data.get("testStatus"), "invalid") + self.assertEqual(data["credentialState"], cc.STATE_UNREACHABLE) + + +class TestHealthStatusUsesTheProbe(unittest.TestCase): + def build(self, provider, payload): + return ConnectionRecord( + id="c1", + provider=provider, + name=provider, + created_at="", + updated_at="", + data_raw=json.dumps(payload), + ) + + def test_key_never_probed_is_not_claimed_healthy(self): + conn = self.build("groq", {"apiKey": "x"}) + self.assertEqual(conn.health_status, "not_checked") + + def test_probed_valid_key_is_active(self): + conn = self.build("groq", {"apiKey": "x", "credentialState": "valid"}) + self.assertEqual(conn.health_status, "active") + + def test_probed_invalid_key_overrides_everything(self): + conn = self.build("groq", {"apiKey": "x", "testStatus": "ok", "credentialState": "invalid"}) + self.assertEqual(conn.health_status, "invalid") + + def test_invalid_probe_beats_a_healthy_oauth_expiry(self): + conn = self.build( + "antigravity", + {"accessToken": "a", "refreshToken": "r", "expiresAt": 9_999_999_999_999, + "credentialState": "invalid"}, + ) + self.assertEqual(conn.health_status, "invalid") + + +if __name__ == "__main__": + unittest.main() diff --git a/tests/test_web_security.py b/tests/test_web_security.py new file mode 100644 index 0000000..a5ef216 --- /dev/null +++ b/tests/test_web_security.py @@ -0,0 +1,161 @@ +"""Testes de superficie de ataque do painel: CSRF nas acoes e vazamento de segredo na API.""" + +import base64 +import json +import os +import sqlite3 +import tempfile +import time +import unittest +import urllib.error +import urllib.request + +from nine_rtksync.config import Settings +from nine_rtksync.web import server as web_server + +PORT = 19294 +BASE = f"http://127.0.0.1:{PORT}" + +ACCESS_TOKEN = "ya29.SEGREDO-DE-ACESSO-NAO-PODE-VAZAR" +REFRESH_TOKEN = "1//SEGREDO-DE-REFRESH-NAO-PODE-VAZAR" +API_KEY = "sk-SEGREDO-DE-CHAVE-NAO-PODE-VAZAR" + + +class TestWebSecurity(unittest.TestCase): + @classmethod + def setUpClass(cls): + cls.tmp_dir = tempfile.TemporaryDirectory() + cls.db_path = os.path.join(cls.tmp_dir.name, "data.sqlite") + with sqlite3.connect(cls.db_path) as conn: + conn.execute( + "CREATE TABLE providerConnections (" + "id TEXT PRIMARY KEY, provider TEXT, name TEXT, " + "createdAt TEXT, updatedAt TEXT, data TEXT)" + ) + conn.execute("CREATE TABLE combos (id TEXT PRIMARY KEY, name TEXT, models TEXT)") + conn.execute( + "INSERT INTO providerConnections VALUES (?, ?, ?, ?, ?, ?)", + ( + "conn-1", + "google-antigravity", + "Antigravity", + "2026-09-12T00:00:00Z", + "2026-09-12T00:00:00Z", + json.dumps( + { + "accessToken": ACCESS_TOKEN, + "refreshToken": REFRESH_TOKEN, + "apiKey": API_KEY, + "expiresAt": int(time.time() * 1000) + 3_600_000, + } + ), + ), + ) + + cls.settings = Settings( + db_path=cls.db_path, + web_host="127.0.0.1", + web_port=PORT, + dashboard_user="admin", + dashboard_password="senha-de-teste", + ) + cls.server = web_server.start_web_server( + "127.0.0.1", PORT, cls.db_path, router_url="", settings=cls.settings + ) + time.sleep(0.3) + + @classmethod + def tearDownClass(cls): + cls.server.shutdown() + cls.server.server_close() + cls.tmp_dir.cleanup() + + def auth_header(self): + raw = base64.b64encode(b"admin:senha-de-teste").decode() + return {"Authorization": f"Basic {raw}"} + + def post(self, path, headers=None, body=b"idioma=pt"): + req = urllib.request.Request(f"{BASE}{path}", data=body, method="POST") + for key, value in self.auth_header().items(): + req.add_header(key, value) + for key, value in (headers or {}).items(): + req.add_header(key, value) + try: + with urllib.request.urlopen(req, timeout=5) as resp: + return resp.status + except urllib.error.HTTPError as e: + return e.code + + # --- CSRF --------------------------------------------------------------- + + def test_cross_site_post_is_rejected(self): + """O Basic Auth vai junto num POST de outro site; sem esta barreira daria para + trocar a senha do painel a partir de uma pagina maliciosa.""" + status = self.post("/acoes/idioma", {"Sec-Fetch-Site": "cross-site"}) + self.assertEqual(status, 403) + + def test_same_site_post_is_rejected(self): + """Subdominio tambem e outra origem.""" + status = self.post("/acoes/idioma", {"Sec-Fetch-Site": "same-site"}) + self.assertEqual(status, 403) + + def test_cross_origin_by_origin_header_is_rejected(self): + """Navegador antigo, sem Sec-Fetch-Site: cai na comparacao de Origin com Host.""" + status = self.post("/acoes/idioma", {"Origin": "http://site-malicioso.example"}) + self.assertEqual(status, 403) + + def test_same_origin_post_is_accepted(self): + status = self.post("/acoes/idioma", {"Sec-Fetch-Site": "same-origin"}) + self.assertNotEqual(status, 403) + + def test_direct_navigation_is_accepted(self): + """Sec-Fetch-Site: none e a navegacao digitada na barra de enderecos.""" + status = self.post("/acoes/idioma", {"Sec-Fetch-Site": "none"}) + self.assertNotEqual(status, 403) + + def test_matching_origin_is_accepted(self): + status = self.post("/acoes/idioma", {"Origin": BASE}) + self.assertNotEqual(status, 403) + + def test_non_browser_client_is_accepted(self): + """curl e scripts nao mandam nenhum dos dois cabecalhos e nao tem sessao a sequestrar.""" + status = self.post("/acoes/idioma") + self.assertNotEqual(status, 403) + + def test_csrf_guard_also_covers_the_json_endpoints(self): + status = self.post("/api/change-password", {"Sec-Fetch-Site": "cross-site"}, b"{}") + self.assertEqual(status, 403) + + # --- Vazamento de segredo ---------------------------------------------- + + def test_api_status_never_returns_credentials(self): + req = urllib.request.Request(f"{BASE}/api/status") + for key, value in self.auth_header().items(): + req.add_header(key, value) + with urllib.request.urlopen(req, timeout=5) as resp: + body = resp.read().decode("utf-8") + + for secret in (ACCESS_TOKEN, REFRESH_TOKEN, API_KEY): + self.assertNotIn(secret, body) + + payload = json.loads(body) + connection = payload["connections"][0] + self.assertEqual(connection["id"], "conn-1") + self.assertTrue(connection["isOAuth"]) + # A linha bruta do banco carrega os tokens e nao pode ser serializada. + for forbidden in ("accessToken", "refreshToken", "apiKey", "raw", "data"): + self.assertNotIn(forbidden, connection) + + def test_dashboard_html_never_returns_credentials(self): + req = urllib.request.Request(BASE + "/") + for key, value in self.auth_header().items(): + req.add_header(key, value) + with urllib.request.urlopen(req, timeout=5) as resp: + page = resp.read().decode("utf-8") + + for secret in (ACCESS_TOKEN, REFRESH_TOKEN, API_KEY): + self.assertNotIn(secret, page) + + +if __name__ == "__main__": + unittest.main() From 8f1d630b963c9963a345b285c02648bcbaed4c53 Mon Sep 17 00:00:00 2001 From: elielsousa-pathbit Date: Sat, 12 Sep 2026 11:54:34 -0300 Subject: [PATCH 07/16] feat: senha com politica de forca gravada como hash no sqlite e refresh que releia tudo O aviso de seguranca ficava na tela comparando a senha ativa com o texto "pathbit". Agora ele depende do que interessa: existir ou nao uma senha definida pelo usuario no banco do painel. Definida a senha, o aviso some. Senha: - Passa a viver no SQLite do sincronizador como hash PBKDF2-SHA256 com sal, em vez de texto puro no .dashboard_auth.json, que e apagado na troca. - Politica obrigatoria: minimo de 6 caracteres com maiuscula, minuscula, numero e caractere especial, validada no formulario, na acao da tela e no endpoint JSON. A recusa lista de uma vez todas as regras violadas, no idioma escolhido, em vez de revelar a politica a cada tentativa. - Quem ja tinha senha no arquivo antigo continua entrando: password_matches reconhece o texto puro herdado ate a proxima troca. Refresh: - O botao Atualizar virou uma acao que zera o cache da sondagem ao gateway antes de remontar a pagina; como link simples, o painel podia repetir por ate 30s o estado anterior a acao recem-disparada. A sincronizacao manual passa a invalidar o mesmo cache. Tipografia do Google Fonts, que estava declarada mas nunca inserida no head. --- .env.example | 6 ++ docs/wiki/Configuration.md | 2 + src/nine_rtksync/auth.py | 97 ++++++++++++++++++++++++++- src/nine_rtksync/config.py | 69 +++++++++++++++----- src/nine_rtksync/i18n.py | 3 + src/nine_rtksync/web/render.py | 18 +++-- src/nine_rtksync/web/server.py | 41 ++++++++++-- tests/test_auth_recovery.py | 6 +- tests/test_password_policy.py | 116 +++++++++++++++++++++++++++++++++ tests/test_web_render.py | 2 +- 10 files changed, 327 insertions(+), 33 deletions(-) create mode 100644 tests/test_password_policy.py diff --git a/.env.example b/.env.example index f888802..389fab3 100644 --- a/.env.example +++ b/.env.example @@ -100,3 +100,9 @@ INITIAL_PASSWORD=PathbitDevs2026! JWT_SECRET=9router-jwt-secret-key-pathbit REQUIRE_API_KEY=false REQUIRE_LOGIN=false + +# --- Validacao viva de credenciais ----------------------------------------- +# Pergunta a cada provedor se a chave/token ainda e aceito, em vez de assumir +# que uma conexao esta saudavel so por carregar uma credencial. +CREDENTIAL_CHECK_ENABLED=1 +CREDENTIAL_CHECK_TIMEOUT=8 diff --git a/docs/wiki/Configuration.md b/docs/wiki/Configuration.md index a39ca50..c80c546 100644 --- a/docs/wiki/Configuration.md +++ b/docs/wiki/Configuration.md @@ -40,6 +40,8 @@ out and produce `BrokenPipeError` in the logs. | `REFRESH_MARGIN` | `900` | Seconds of remaining validity below which a token is renewed. | | `CRON_INTERVAL` | inherits `SYNC_INTERVAL` | Dedicated interval for the scheduler, when you want it to differ from the sync pass. | | `CRON_ENABLED` | `1` | `0` disables the automatic scheduler entirely. Synchronization then only happens on a manual trigger (`--once`, the **Run now** button, or `POST /api/sync`). | +| `CREDENTIAL_CHECK_ENABLED` | `1` | Asks each provider whether the stored credential is still accepted. `0` turns the live check off and the panel falls back to reporting `Not checked`. | +| `CREDENTIAL_CHECK_TIMEOUT` | `8` | Seconds allowed per credential probe. | > **A token is only renewed inside the margin.** With the defaults, a token with 24 minutes left > is *not* renewed, because 24 min > 15 min. That is correct behavior, not a failure — the diff --git a/src/nine_rtksync/auth.py b/src/nine_rtksync/auth.py index fa0e8d3..78bacba 100644 --- a/src/nine_rtksync/auth.py +++ b/src/nine_rtksync/auth.py @@ -29,6 +29,33 @@ RECOVERY_FILE_NAME = ".dashboard_recovery" RECOVERY_USER = "admin" +# Politica de senha do painel. Exigida sempre que a senha for definida ou +# trocada pela tela; o ambiente headless nao passa por aqui porque quem opera +# DASHBOARD_PASSWORD ja controla o segredo por fora. +MIN_PASSWORD_LENGTH = 6 +SPECIAL_CHARACTERS = "!@#$%^&*()-_=+[]{};:,.<>?/\\|`~\"'" + + +def validate_password_strength(password: str) -> list: + """Devolve as chaves de traducao das regras que a senha nao cumpre. + + Lista vazia significa senha aceita. Devolver todas as falhas de uma vez + evita o vaivem de corrigir um requisito por tentativa. + """ + problems = [] + if len(password or "") < MIN_PASSWORD_LENGTH: + problems.append("password.too_short") + if not any(c.isupper() for c in password or ""): + problems.append("password.needs_upper") + if not any(c.islower() for c in password or ""): + problems.append("password.needs_lower") + if not any(c.isdigit() for c in password or ""): + problems.append("password.needs_digit") + if not any(c in SPECIAL_CHARACTERS for c in password or ""): + problems.append("password.needs_special") + return problems + + def constant_time_equals(a: str, b: str) -> bool: """Compara duas strings em tempo constante.""" @@ -40,6 +67,45 @@ def derive_recovery_hash(secret: str) -> str: return hashlib.sha256(str(secret).encode("utf-8")).hexdigest() +# --- Armazenamento da senha ------------------------------------------------- +# +# A senha do painel passa a viver no SQLite do sincronizador como hash PBKDF2, +# nunca em texto puro. O formato carrega os proprios parametros, entao aumentar +# o custo no futuro nao invalida o que ja esta gravado. +PBKDF2_ITERATIONS = 240_000 +PBKDF2_PREFIX = "pbkdf2_sha256" + + +def hash_password(password: str, *, salt: Optional[bytes] = None, + iterations: int = PBKDF2_ITERATIONS) -> str: + """Deriva o hash armazenavel de uma senha.""" + salt = salt or secrets.token_bytes(16) + digest = hashlib.pbkdf2_hmac("sha256", (password or "").encode("utf-8"), salt, iterations) + return f"{PBKDF2_PREFIX}${iterations}${salt.hex()}${digest.hex()}" + + +def password_matches(stored: str, candidate: str) -> bool: + """Compara uma senha com o valor gravado. + + Aceita tambem o texto puro herdado do .dashboard_auth.json antigo, para que + uma instalacao existente continue entrando enquanto nao troca a senha. + """ + if not stored: + return False + + if not stored.startswith(PBKDF2_PREFIX + "$"): + return constant_time_equals(stored, candidate) + + try: + _, iterations, salt_hex, digest_hex = stored.split("$", 3) + expected = hashlib.pbkdf2_hmac( + "sha256", (candidate or "").encode("utf-8"), bytes.fromhex(salt_hex), int(iterations) + ) + except (ValueError, TypeError): + return False + return hmac.compare_digest(expected.hex(), digest_hex) + + def read_stored_credentials(auth_file: str) -> Optional[Tuple[str, str]]: """Lê as credenciais gravadas pela tela. Devolve None quando ainda não houve troca.""" if not auth_file or not os.path.exists(auth_file): @@ -56,6 +122,31 @@ def read_stored_credentials(auth_file: str) -> Optional[Tuple[str, str]]: return None +# Chaves de credencial no banco de preferencias do painel. +AUTH_USER_KEY = "auth.user" +AUTH_PASSWORD_KEY = "auth.password_hash" + + +def read_db_credentials(prefs_path: str) -> Optional[Tuple[str, str]]: + """Credenciais gravadas no SQLite do painel, ou None se nunca definidas.""" + from .prefs import get_preference + + user = get_preference(prefs_path, AUTH_USER_KEY) + stored = get_preference(prefs_path, AUTH_PASSWORD_KEY) + if user and stored: + return user, stored + return None + + +def write_db_credentials(prefs_path: str, user: str, password: str) -> bool: + """Grava usuario e hash da senha no SQLite do painel.""" + from .prefs import set_preference + + ok_user = set_preference(prefs_path, AUTH_USER_KEY, user) + ok_pass = set_preference(prefs_path, AUTH_PASSWORD_KEY, hash_password(password)) + return bool(ok_user and ok_pass) + + def resolve_recovery_hash(recovery_file: str) -> str: """Obtém o hash de recuperação: ambiente primeiro, senão o gerado/salvo localmente.""" from_env = os.environ.get("DASHBOARD_RECOVERY_HASH", "").strip() @@ -115,9 +206,11 @@ def verify_credentials( if stored is not None: # 1. Já houve troca de senha: só as credenciais salvas valem. + # password_matches entende tanto o hash PBKDF2 gravado no SQLite quanto + # o texto puro herdado do arquivo antigo. stored_user, stored_password = stored - return constant_time_equals(user, stored_user) and constant_time_equals( - password, stored_password + return constant_time_equals(user, stored_user) and password_matches( + stored_password, password ) # 2. Primeiro acesso: valem as credenciais de fábrica. diff --git a/src/nine_rtksync/config.py b/src/nine_rtksync/config.py index 1745e42..dda6769 100644 --- a/src/nine_rtksync/config.py +++ b/src/nine_rtksync/config.py @@ -9,6 +9,9 @@ RECOVERY_FILE_NAME, ensure_recovery_hash, read_stored_credentials, + read_db_credentials, + write_db_credentials, + validate_password_strength, resolve_recovery_hash, verify_credentials, ) @@ -81,7 +84,11 @@ def get_stored_credentials(self) -> Optional[Tuple[str, str]]: """Credenciais gravadas pela tela, ou None quando o ambiente é autoritativo.""" if self.dashboard_auth_from_env: return None - return read_stored_credentials(self.get_auth_file_path()) + # O SQLite do painel e a fonte de verdade; o .dashboard_auth.json so + # existe para nao trancar quem ja tinha senha antes desta mudanca. + return read_db_credentials(self.get_prefs_path()) or read_stored_credentials( + self.get_auth_file_path() + ) def verify_credentials(self, user: str, password: str) -> bool: """Valida um par usuário/senha, incluindo a credencial de recuperação.""" @@ -94,6 +101,18 @@ def verify_credentials(self, user: str, password: str) -> bool: recovery_hash=self.get_recovery_hash(), ) + def get_prefs_path(self) -> str: + """Banco SQLite do painel, onde vivem preferencias e credenciais.""" + from .prefs import resolve_prefs_path + + return resolve_prefs_path(os.path.dirname(self.get_auth_file_path())) + + def has_stored_password(self) -> bool: + """Se ja existe senha definida pelo usuario no banco do painel.""" + if self.dashboard_auth_from_env: + return True + return read_db_credentials(self.get_prefs_path()) is not None + def get_auth_file_path(self) -> str: """Return the filesystem path for persisted dashboard credentials.""" base_dir = os.environ.get("DATA_DIR", "") @@ -125,29 +144,45 @@ def get_auth_credentials(self) -> tuple[str, str]: return self.dashboard_user, self.dashboard_password def is_default_password(self) -> bool: - """Check if the active password is still the factory default (pathbit).""" - _, p = self.get_auth_credentials() - return p == "pathbit" + """Se o painel ainda roda sem senha propria. + + O aviso de seguranca depende disto: ele some assim que existe uma senha + gravada no SQLite, e nao quando o texto deixa de ser "pathbit". + """ + return not self.has_stored_password() + + def check_password_strength(self, new_pass: str) -> list: + """Chaves de traducao das regras de senha que o valor nao cumpre.""" + return validate_password_strength(new_pass) def update_auth_credentials(self, user: str, new_pass: str) -> bool: - """Save new dashboard credentials to the secure credentials file.""" - # In headless mode the environment is immutable from the screen — writing the - # file here would create phantom state that get_auth_credentials never reads. + """Grava as credenciais do painel no SQLite, como hash. + + Recusa senha fraca: a politica de forca e obrigatoria. Em modo headless + o ambiente e imutavel pela tela, e gravar aqui criaria estado fantasma + que get_auth_credentials nunca leria. + """ if self.dashboard_auth_from_env: return False - auth_file = self.get_auth_file_path() - try: - import json - payload = {"user": user.strip() or "admin", "password": new_pass.strip()} - with open(auth_file, "w", encoding="utf-8") as f: - json.dump(payload, f) - self.dashboard_user = payload["user"] - self.dashboard_password = payload["password"] - return True - except Exception: + new_pass = (new_pass or "").strip() + if validate_password_strength(new_pass): return False + final_user = (user or "").strip() or "admin" + if not write_db_credentials(self.get_prefs_path(), final_user, new_pass): + return False + + self.dashboard_user = final_user + self.dashboard_password = new_pass + # O arquivo em texto puro perde a razao de existir assim que a senha + # passa a viver no banco. + try: + os.remove(self.get_auth_file_path()) + except OSError: + pass + return True + @classmethod def from_env(cls, env_file: str = ".env") -> "Settings": load_dotenv(env_file) diff --git a/src/nine_rtksync/i18n.py b/src/nine_rtksync/i18n.py index f95947f..ec540e7 100644 --- a/src/nine_rtksync/i18n.py +++ b/src/nine_rtksync/i18n.py @@ -91,6 +91,7 @@ "credential.checked_at": "Checked at", "credential.never": "Never validated", "action.validate": "Validate credentials", + "action.refreshed": "Page reloaded with fresh data.", "password.policy": "At least 6 characters, with uppercase, lowercase, a number and a special character.", "password.too_short": "Password must have at least 6 characters.", "password.needs_upper": "Password must contain an uppercase letter.", @@ -187,6 +188,7 @@ "credential.checked_at": "Verificada em", "credential.never": "Nunca validada", "action.validate": "Validar credenciais", + "action.refreshed": "Página recarregada com dados atualizados.", "password.policy": "Mínimo de 6 caracteres, com maiúscula, minúscula, número e caractere especial.", "password.too_short": "A senha precisa ter ao menos 6 caracteres.", "password.needs_upper": "A senha precisa conter uma letra maiúscula.", @@ -283,6 +285,7 @@ "credential.checked_at": "Verificada el", "credential.never": "Nunca validada", "action.validate": "Validar credenciales", + "action.refreshed": "Página recargada con datos actualizados.", "password.policy": "Mínimo de 6 caracteres, con mayúscula, minúscula, número y carácter especial.", "password.too_short": "La contraseña necesita al menos 6 caracteres.", "password.needs_upper": "La contraseña necesita una letra mayúscula.", diff --git a/src/nine_rtksync/web/render.py b/src/nine_rtksync/web/render.py index 30dbd9e..2aa9873 100644 --- a/src/nine_rtksync/web/render.py +++ b/src/nine_rtksync/web/render.py @@ -485,8 +485,10 @@ def render_dashboard(
    -
    {esc(translate("auth.min_chars", lang))}
    + minlength="6" autocomplete="new-password" required + pattern="(?=.*[a-z])(?=.*[A-Z])(?=.*\\d)(?=.*[^A-Za-z0-9]).{{6,}}" + title="{esc(translate("password.policy", lang))}"> +
    {esc(translate("password.policy", lang))}