Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion ROADMAP.md
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
# Roadmap & Next Steps

Status: **v0.3.1 — alpha.** CI live at [`.github/workflows/ci.yml`](.github/workflows/ci.yml). Public VPS+gVisor path: [`docs/DEPLOY_GVISOR.md`](docs/DEPLOY_GVISOR.md).
Status: **v0.3.1 — alpha.** CI live at [`.github/workflows/ci.yml`](.github/workflows/ci.yml). Public MCP: [`docs/DEPLOY_WORKERS_MCP.md`](docs/DEPLOY_WORKERS_MCP.md). Internal FastAPI/gVisor: [`docs/DEPLOY_GVISOR.md`](docs/DEPLOY_GVISOR.md).

## 🟢 Ready to use right now

Expand Down
42 changes: 42 additions & 0 deletions deploy/assert_tailscale_bind.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,42 @@
#!/usr/bin/env python3
"""Refuse FastAPI publish addresses that are not Tailscale CGNAT.

Compose interpolates TAILSCALE_IP into a host bind. A typo like 0.0.0.0
would publish unauthenticated REST on the public interface.
"""

from __future__ import annotations

import ipaddress
import os
import sys

TAILSCALE_CGNAT = ipaddress.ip_network("100.64.0.0/10")
_IPV4 = 4


def allowed_tailscale_bind(raw: str) -> bool:
try:
address = ipaddress.ip_address(raw.strip())
except ValueError:
return False
if address.version != _IPV4:
return False
if address.is_unspecified or address.is_loopback or address.is_multicast:
return False
return address in TAILSCALE_CGNAT


def main() -> int:
raw = os.environ.get("TAILSCALE_IP", "100.90.45.18")
if allowed_tailscale_bind(raw):
return 0
sys.stderr.write(
f"TAILSCALE_IP={raw!r} is not a Tailscale CGNAT IPv4 (100.64.0.0/10). "
"Refusing to bind. Set TAILSCALE_IP to the VPS tailnet address, never 0.0.0.0.\n"
)
return 1


if __name__ == "__main__":
raise SystemExit(main())
58 changes: 58 additions & 0 deletions deploy/docker-compose.charlie-mcp.yml
Original file line number Diff line number Diff line change
@@ -0,0 +1,58 @@
# Private Charlie MCP — gVisor + code mode.
# NEVER expose this stack without Cloudflare Tunnel + Access (service token).
# Public surface is workers/mcp (no code mode). Internal REST is docker-compose.gvisor.yml.
#
# Usage:
# docker compose -f deploy/docker-compose.charlie-mcp.yml up -d --build
# # then cloudflared points at http://127.0.0.1:8001
services:
openfindata-charlie-mcp:
build:
context: ..
dockerfile: Dockerfile
image: openfindata:latest
container_name: openfindata-charlie-mcp
runtime: runsc
restart: unless-stopped
ports:
# Loopback only — Tunnel (and optional Tailscale SSH tunnel) reach this.
- "127.0.0.1:8001:8000"
dns:
- 1.1.1.1
- 8.8.8.8
volumes:
- ./resolv.gvisor.conf:/etc/resolv.conf:ro
read_only: true
tmpfs:
- /tmp:mode=1777,size=256m
cap_drop:
- ALL
security_opt:
- no-new-privileges:true
mem_limit: 1024m
cpus: 1.5
user: "65534:65534"
pids_limit: 512
healthcheck:
disable: true
environment:
FINDATA_RATE_LIMIT_ENABLED: "true"
# Higher than public Worker; Charlie is authenticated and low-concurrency.
FINDATA_RATE_LIMIT_DEFAULT: "120/minute;5000/day"
# Private Charlie only — never set this on the public Worker or Traefik path.
FINDATA_MCP_CODE_MODE: "1"
# Fail-closed: compose refuses to start if this is unset/empty.
# Inject the same value as header X-Openfindata-Origin-Token from Charlie.
FINDATA_MCP_ORIGIN_TOKEN: "${FINDATA_MCP_ORIGIN_TOKEN:?set FINDATA_MCP_ORIGIN_TOKEN for Charlie code-mode}"
logging:
driver: json-file
options:
max-size: "10m"
max-file: "5"
networks:
- openfindata_charlie_net

networks:
openfindata_charlie_net:
driver: bridge
# Isolated from hermes / wealthuman / public openfindata_net.
15 changes: 4 additions & 11 deletions deploy/docker-compose.gvisor.yml
Original file line number Diff line number Diff line change
Expand Up @@ -12,7 +12,10 @@ services:
runtime: runsc
restart: unless-stopped
ports:
# Loopback + Tailscale CGNAT only. Never 0.0.0.0.
# Validate first: python3 deploy/assert_tailscale_bind.py
- "127.0.0.1:8000:8000"
- "${TAILSCALE_IP:-100.90.45.18}:8000:8000"
# gVisor does not reliably use Docker's 127.0.0.11 stub DNS.
# Mount a resolv.conf that points straight at public resolvers.
dns:
Expand All @@ -31,9 +34,7 @@ services:
cpus: 1.0
user: "65534:65534"
pids_limit: 256
# Traefik v3 drops routers for Docker-unhealthy containers. The image
# HEALTHCHECK hits /health; disable it here so a rate-limit blip cannot
# take the public router offline. /health remains exempt in the app.
# Image HEALTHCHECK hits /health and can 429 under rate limit; keep off.
healthcheck:
disable: true
environment:
Expand All @@ -48,14 +49,6 @@ services:
max-file: "5"
networks:
- openfindata_net
labels:
- traefik.enable=true
# Traefik runs in host network mode on this VPS — reach the published loopback port.
# Production: export OPENFINDATA_HOST=api.seudominio.com before up.
- traefik.http.routers.openfindata.rule=Host(`${OPENFINDATA_HOST:-findata.localhost}`)
- traefik.http.routers.openfindata.entrypoints=websecure
- traefik.http.routers.openfindata.tls.certresolver=letsencrypt
- traefik.http.services.openfindata.loadbalancer.server.url=http://127.0.0.1:8000

networks:
openfindata_net:
Expand Down
164 changes: 164 additions & 0 deletions docs/DEPLOY_CHARLIE_MCP.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,164 @@
# Deploy privado: MCP Charlie (code mode)

MCP **privado** do Charlie (Wealthuman): FastAPI + gVisor +
`FINDATA_MCP_CODE_MODE=1`, publicado só via Cloudflare Tunnel + Access
(service token). Não é superfície pública.

| Superfície | Onde | Code mode |
|---|---|---|
| Público | Worker `workers/mcp` → `https://openfindata.com.br/mcp` | Não |
| Interno REST | gVisor Tailscale `100.90.45.18:8000` | Não |
| Charlie | `https://charlie-mcp.openfindata.com.br/mcp` | Sim |

## Arquitetura

```
Charlie (Wealthuman Worker / agent)
→ HTTPS charlie-mcp.openfindata.com.br
→ Cloudflare Access (service token headers)
→ Tunnel openfindata-charlie-mcp
→ origin checa X-Openfindata-Origin-Token
→ 127.0.0.1:8001
→ container openfindata-charlie-mcp (runsc, CODE_MODE=1)
```

O Worker público **não** alcança Tailscale e **não** deve proxyar este
host. Charlie chama o hostname Access-protegido com service token.

## Compose (VPS)

Arquivo: `deploy/docker-compose.charlie-mcp.yml`

- Porta: `127.0.0.1:8001:8000` (loopback only)
- Runtime: `runsc` (gVisor)
- Rede isolada: `openfindata_charlie_net`
- `FINDATA_MCP_CODE_MODE=1`
- `FINDATA_MCP_ORIGIN_TOKEN` obrigatório (compose e processo recusam subir sem ele)
- Healthcheck Docker desabilitado (mesmo motivo do stack gVisor público antigo)

```bash
cd /opt/openfindata-launch
# openssl rand -hex 32
export FINDATA_MCP_ORIGIN_TOKEN='...' # persistir em deploy/.env (gitignored)
docker compose -f deploy/docker-compose.charlie-mcp.yml up -d --build
curl -sS http://127.0.0.1:8001/health
# /mcp no loopback sem X-Openfindata-Origin-Token deve ser 401
```

## Tunnel + Access

Já provisionado (conta Robertoecf / Access org Wealthuman):

| Recurso | Valor |
|---|---|
| Hostname | `charlie-mcp.openfindata.com.br` |
| Tunnel | `openfindata-charlie-mcp` |
| Ingress | `http://127.0.0.1:8001` |
| Access app | Charlie openfindata MCP |
| Service token name | `wealthuman-charlie-openfindata-mcp` |
| cloudflared unit | `cloudflared-charlie` |
| Token file | `/etc/cloudflared/openfindata-charlie.env` (root-only) |

Política Access: **somente** service token (sem e-mail / browser login).

O origin **não** confia só no Access. Com `CODE_MODE=1` o processo exige
`FINDATA_MCP_ORIGIN_TOKEN` no boot e o header `X-Openfindata-Origin-Token`
em todo path que não seja `/health`. O Charlie envia esse header junto
com o service token; Access não o stripa. Loopback sem o header → **401**.

### Headers obrigatórios

```http
CF-Access-Client-Id: <client_id>.access
CF-Access-Client-Secret: <client_secret>
X-Openfindata-Origin-Token: <origin token>
```

Guardar os três segredos no **Doppler Wealthuman** (nunca no git):

```text
OPENFINDATA_CHARLIE_MCP_URL=https://charlie-mcp.openfindata.com.br/mcp
OPENFINDATA_CHARLIE_CF_ACCESS_CLIENT_ID=...
OPENFINDATA_CHARLIE_CF_ACCESS_CLIENT_SECRET=...
OPENFINDATA_CHARLIE_ORIGIN_TOKEN=...
```

## Consumo MCP (Streamable HTTP)

fastapi-mcp é sessionful: use o `mcp-session-id` do `initialize` nas
chamadas seguintes.

```bash
# 1) initialize → gravar mcp-session-id do response header
# 2) notifications/initialized
# 3) tools/list ou tools/call com o mesmo session id
```

Tools esperadas: ~26, incluindo `findata_run_code`, registry, ANBIMA,
CVM/B3 paths (Python), além das séries públicas.

Smoke mínimo (substituir secrets do Doppler):

```bash
# Sem Access → 403
curl -sS -o /dev/null -w "%{http_code}\n" \
https://charlie-mcp.openfindata.com.br/health

# Com Access → 200
curl -sS -o /dev/null -w "%{http_code}\n" \
-H "CF-Access-Client-Id: $OPENFINDATA_CHARLIE_CF_ACCESS_CLIENT_ID" \
-H "CF-Access-Client-Secret: $OPENFINDATA_CHARLIE_CF_ACCESS_CLIENT_SECRET" \
https://charlie-mcp.openfindata.com.br/health
```

`findata_run_code` com `print(1+1)` deve retornar `output: "2\n"`.

Preflight obrigatório (não basta `/health`):

```bash
# Loopback: MCP sem origin token
curl -sS -o /dev/null -w "%{http_code}\n" \
-H 'content-type: application/json' \
-X POST http://127.0.0.1:8001/mcp \
--data '{"jsonrpc":"2.0","id":1,"method":"initialize","params":{"protocolVersion":"2025-03-26","capabilities":{},"clientInfo":{"name":"preflight","version":"0"}}}'
# 401

# Loopback: REST autenticado no origin
curl -sS -o /dev/null -w "%{http_code}\n" \
-H "X-Openfindata-Origin-Token: $FINDATA_MCP_ORIGIN_TOKEN" \
http://127.0.0.1:8001/stats
# 200

# Público: tools/list no Worker NÃO contém findata_run_code
```

## Operação

```bash
# Status
ssh monvanti-vps 'docker ps --filter name=openfindata-charlie; systemctl is-active cloudflared-charlie'

# Logs
ssh monvanti-vps 'docker logs --tail 100 openfindata-charlie-mcp'
ssh monvanti-vps 'journalctl -u cloudflared-charlie -n 50 --no-pager'

# Restart compose
ssh monvanti-vps 'cd /opt/openfindata-launch && docker compose -f deploy/docker-compose.charlie-mcp.yml up -d'
```

## Segurança

- Nunca publicar `8001` em `0.0.0.0` ou Traefik público.
- Nunca ligar `FINDATA_MCP_CODE_MODE=1` no Worker ou no compose gVisor
Tailscale-only usado para REST interno.
- Sem `FINDATA_MCP_ORIGIN_TOKEN` o container Charlie **não sobe**.
- Rotacionar o service token **e** o origin token se vazou em chat/logs;
atualizar Doppler (Access + `OPENFINDATA_CHARLIE_ORIGIN_TOKEN`) e o env do compose.
- Rate limit Charlie: `120/minute;5000/day` (compose).

## Wiring Wealthuman

No Charlie: MCP client Streamable HTTP apontando para
`OPENFINDATA_CHARLIE_MCP_URL` com Access **e** `X-Openfindata-Origin-Token`
em toda request (incluindo `initialize` e `tools/call`). Manter sessão
(`mcp-session-id`) por conversa/agent run.
Loading
Loading