Files
osint_db/app.md
T
egutierrez 3063d3c44f feat(scans): persistencia de escaneos de red + POST /api/scan
Tabla network_scans (migración 005, schema main, lleva note_path) que otras
herramientas pueblan vía HTTP con escaneos de reconocimiento (whois/rdap/dns/
nmap/traceroute/ping). Endpoint POST /api/scan: id determinista
<target_slug>:<scan_type>:<YYYYMMDD-HHMM> derivado de scan_ts, idempotente por
id (duckdb_upsert ON CONFLICT DO UPDATE) bajo el lock single-writer del service.
summary (dict) se serializa a JSON.

network_scans no se deriva de notas: ni ingest_vault ni ingest_dav la tocan, así
que un re-ingest del vault no la trunca (test lo verifica).

Tests: inserción + id derivado, idempotencia mismo-minuto, validación de campos
requeridos (422), y no-truncado por ingest del vault.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-14 13:13:36 +02:00

216 lines
12 KiB
Markdown

---
name: osint_db
lang: py
domain: osint
version: 0.1.0
description: "Service FastAPI local (solo 127.0.0.1, puerto 8771) dueño único de la base DuckDB data/osint.duckdb: fuente de verdad estructurada del ecosistema OSINT. Indexa el vault de Obsidian osint (notes + persons/organizations/domains/cases/places con note_path), importa las maestras DAV de Xandikos (contacts, events), computa derivadas sin referencias a notas y renderiza tableros Markdown con bloques sentinel idempotentes dentro del vault."
tags: [service, osint, duckdb, obsidian, dav, vault]
uses_functions:
- list_obsidian_notes_py_obsidian
- read_obsidian_note_py_obsidian
- update_obsidian_note_py_obsidian
- create_obsidian_note_py_obsidian
- slugify_obsidian_name_py_obsidian
- dav_get_collection_py_infra
- dav_list_calendars_py_infra
- dav_list_addressbooks_py_infra
- dav_collection_ctag_py_infra
- dav_list_resources_py_infra
- dav_get_resource_py_infra
- carddav_put_vcard_py_infra
- caldav_put_event_py_infra
- dav_delete_resource_py_infra
- dav_make_addressbook_py_infra
- dav_make_calendar_py_infra
- pass_get_secret_py_infra
- duckdb_query_readonly_py_infra
- duckdb_execute_py_infra
- duckdb_upsert_py_infra
- build_vcard_py_core
- render_markdown_table_py_core
- upsert_sentinel_block_py_core
uses_types: []
framework: "fastapi"
entry_point: "server/main.py"
dir_path: "projects/osint/apps/osint_db"
repo_url: "https://gitea-dgg044oo04woo4ggcsws4gk0.organic-machine.com/dataforge/osint_db"
service:
port: 8771
health_endpoint: /api/health
health_timeout_s: 3
systemd_unit: null
systemd_scope: null
restart_policy: none
runtime: manual
pc_targets:
- lucas-linux
is_local_only: true
e2e_checks:
- id: tests
cmd: ".venv/bin/python -m pytest tests -q"
timeout_s: 120
- id: vault_missing
cmd: ".venv/bin/python server/main.py --vault /no/existe --port 0"
expect_exit: 2
timeout_s: 30
---
## Qué es
Service del project `osint` que posee en exclusiva la base DuckDB
`data/osint.duckdb`, la fuente de verdad estructurada del ecosistema OSINT
(vault de Obsidian `~/Obsidian/osint` + servidor Xandikos CardDAV/CalDAV). Un
plugin de Obsidian se construye en paralelo contra el contrato de esta API, por
eso los endpoints de datos responden SIEMPRE HTTP 200 con `status: ok|error`
en el body (el plugin parsea el body, no el código HTTP).
## Arquitectura de datos (3 categorías)
1. **Maestras con referencia a notas** (schema `main`): `notes` (índice
completo del vault) + `persons`, `organizations`, `domains`, `cases`,
`places` (fichas de nivel-1 de cada carpeta de entidades, excluyendo las
notas con prefijo `_`). Cada una lleva `note_path`: el path relativo de la
nota dentro del vault. **`persons` es dueña de sus campos estructurados**
(multi-valor `telefonos`/`emails`/`direcciones` JSON + singulares de compat
`telefono`/`email`/`direccion`): la API los edita y los materializa a la
nota. El ingest del vault es **selectivo** para `persons` — una ficha que ya
existe en la DB solo refresca `note_path` + `extra_fm` (el frontmatter
no-owned), conservando los campos OWNED; una ficha nueva se inserta completa
(bootstrap desde el frontmatter). `addressbooks` (schema `main`) registra
las libretas CardDAV: el ingest DAV las recorre todas (no solo la fija).
2. **Maestras DAV** (schema `main`): `contacts` y `events` importados de
Xandikos — fuente de verdad del lado agenda/calendario. `contacts.note_path`
se enlaza contra `persons` matcheando por UID `osint-<slug>`, por el
`dav_uid` extraído del campo `fuente` de la ficha, por teléfono normalizado
o por email. `network_scans` (schema `main`) registra escaneos de red
(whois/rdap/dns/nmap/traceroute/ping) que otras herramientas guardan vía
`POST /api/scan`; lleva `note_path` (la nota `dominios/<slug>/recon/<...>.md`
donde se documenta cada escaneo) y NO se reconstruye en ningún ingest — la
pueblan los escaneos y nunca se trunca.
3. **Derivadas** (schema `derived`): SOLO datos computados. **Regla dura:
ninguna tabla de `derived` lleva columna que referencie notas** (`note_path`
prohibido ahí; hay un test que lo verifica vía `information_schema`). Se
reconstruyen completas (DROP + CREATE) en cada ingest:
`derived.person_stats` (agregados por contexto/país/tag),
`derived.event_monthly` (eventos por calendario y mes) y
`derived.contact_link_quality` (contactos enlazados vs no, solo números).
Single-writer: SOLO este service escribe la DuckDB. La conexión de escritura se
abre bajo demanda (migraciones, ingest, render) serializada con un lock de
proceso y se cierra al terminar; las lecturas de `/api/query` abren su propia
conexión `read_only` vía `duckdb_query_readonly`.
## Migraciones
`migrations/NNN_*.sql` numeradas, aditivas e idempotentes, aplicadas en orden
al arrancar. La tabla `_migrations` registra las aplicadas (regla
`db_migrations` adaptada a DuckDB).
## Arrancar
```bash
cd projects/osint/apps/osint_db
uv sync # primera vez: crea .venv
.venv/bin/python server/main.py # defaults: vault ~/Obsidian/osint, puerto 8771
.venv/bin/python server/main.py --vault ~/Obsidian/osint --db data/osint.duckdb --port 8771
```
Health check: `curl http://127.0.0.1:8771/api/health`.
## Endpoints
| Método | Ruta | Qué hace |
|---|---|---|
| GET | `/api/health` | `{"status":"ok","db_path":"...","tables":N}` |
| GET | `/api/tables` | inventario: schema, name, kind master/derived, row_count, columnas |
| POST | `/api/query` | `{sql, params, max_rows}` → respuesta exacta de `duckdb_query_readonly` (solo lectura) |
| GET | `/api/queries` | catálogo de queries con nombre (`server/named_queries.py`) |
| POST | `/api/query/named` | `{name, max_rows}` → misma shape que `/api/query` |
| POST | `/api/ingest/vault` | escanea el vault completo; notes y entidades de espejo puro se reemplazan, persons se ingesta SELECTIVO (existentes solo `note_path`+`extra_fm`, nuevas bootstrap completo) |
| POST | `/api/ingest/dav` | baja TODAS las libretas registradas en `addressbooks` + cada calendario CalDAV, reconstruye contacts/events, enlaza y reconstruye derivadas |
| POST | `/api/render/note` | `{note_path, block_id, sql\|query, title?}` → tabla Markdown upsertada como bloque sentinel `osintdb` en la nota (la crea si no existe) |
| POST/PUT/DELETE | `/api/person[/{slug}]` | CRUD de personas multi-valor (`telefonos`/`emails`/`direcciones` listas). Tras escribir la DB, materializa la ficha DB→nota (singulares = `lista[0]`) sin tocar la prosa |
| POST | `/api/person/{slug}/render` | re-materializa la ficha DB→nota (frontmatter OWNED + merge `extra_fm`, preserva el body) |
| POST/PUT/DELETE | `/api/contact[/{uid}]` | CRUD de contactos CardDAV (`tels`/`emails` listas). Tras la DB, push DB→Xandikos (`build_vcard`+`carddav_put_vcard`, o `dav_delete_resource` en delete) fuera de la transacción |
| POST/PUT/DELETE | `/api/event[/{uid}]` | CRUD de eventos CalDAV. Push `caldav_put_event`/`dav_delete_resource` |
| POST | `/api/addressbook` | `{slug, display_name?, description?, color?}``dav_make_addressbook` + INSERT en `addressbooks` |
| POST | `/api/calendar` | `{slug, display_name?, color?}``dav_make_calendar` (paridad) |
| POST | `/api/scan` | `{target, target_slug, scan_type, note_path, tool?, summary?, scan_ts?}` → registra un escaneo de red en `network_scans`. `id = <target_slug>:<scan_type>:<YYYYMMDD-HHMM>` derivado de `scan_ts` (default: ahora); idempotente por id (upsert). Responde `{"status":"ok","id":<id>}` |
| POST | `/api/push/dav` | reconcilia en bloque por HTTP: recorre `contacts` y `events` de la DB y los empuja a Xandikos (1 PUT + 1 PROPFIND + 1 commit git por recurso, sin borrar). Fallback cuando no hay SSH al host de Xandikos |
| POST | `/api/push/dav-bulk` | vía RÁPIDA del push de **contactos** por DISCO: genera todos los `.vcf` en un tmpdir local y hace **1 rsync + 1 commit + 1 PROPFIND** contra el working tree git que Xandikos sirve. Reconcilia ~1000 contactos en <1s en vez de ~6 min. Requiere SSH por clave |
### `/api/push/dav` (HTTP) vs `/api/push/dav-bulk` (disco)
Ambos vuelcan los contactos de la DB a Xandikos, pero por mecanismos distintos:
| | `/api/push/dav` (HTTP) | `/api/push/dav-bulk` (disco) |
|---|---|---|
| Mecanismo | N PUT WebDAV (uno por contacto) | 1 rsync de todos los `.vcf` a la vez |
| PROPFIND | 1 por contacto (lee el etag tras cada PUT) | 1 al final, lee todos los etags de golpe |
| Commits git en el remoto | N (Xandikos commitea cada PUT) | 1 (`git add -A && commit` único) |
| Coste para ~1000 contactos | ~6 min (≥1 PROPFIND completo/contacto) | <1s (dominado por 2-3 round-trips SSH) |
| Eventos CalDAV | sí (también empuja `events`) | no (solo `contacts`) |
| Borra huérfanos remotos | no | sí — `rsync --delete` deja la colección `.vcf` == DB |
| Requisitos | red HTTPS a Xandikos + `pass` | SSH por clave al host + `rsync` ambos lados |
**Usa `/api/push/dav-bulk`** para reconciliar en bloque (tras una migración o un
ingest masivo) cuando hay SSH al host de Xandikos: es el camino normal por
rapidez. **Usa `/api/push/dav`** como fallback cuando no hay SSH, o cuando
también necesitas empujar eventos CalDAV.
Cómo lo ve Xandikos: sirve cada colección desde el working tree de un repo git y
calcula el ctag desde el HEAD. El push por disco escribe los `.vcf` directamente
en ese working tree y hace un único commit; Xandikos ve el nuevo HEAD en el
siguiente request (nuevo ctag → DAVx5 detecta el cambio). El rsync sincroniza
SOLO los `*.vcf` (`--include='*.vcf' --exclude='*'`), preservando `.git/`,
`.xandikos` (tipo de colección) y `push-subscriptions.json` (suscripciones
WebDAV-Push). Config del host/working tree: `DAV_BULK_SSH_HOST` /
`DAV_BULK_REMOTE_DIR` en `server/config.py`.
Queries con nombre incluidas: `personas_por_contexto`, `personas_recientes`,
`eventos_proximos`, `contactos_sin_nota`, `stats_personas`,
`calidad_enlace_contactos`, `eventos_por_mes`.
## Configuración
`server/config.py`: vault (`~/Obsidian/osint`), db (`data/osint.duckdb`
relativa a la app), DAV base/colecciones de Xandikos (los mismos valores que
`projects/osint/tools/sync_dav_to_osint.py`) y puerto 8771. Overrides por CLI:
`--vault`, `--db`, `--port`. La credencial DAV se resuelve SIEMPRE con
`pass_get_secret("dav/xandikos-enmanuel")`, nunca hardcodeada.
## Seguridad
- El vault y la base contienen datos personales sensibles: el server escucha
solo en `127.0.0.1` y `runtime: manual` (no se despliega a VPS,
`is_local_only: true`).
- `/api/query` es estrictamente read-only (conexión `read_only` de DuckDB).
- `/api/render/note` valida que el path destino no escapa del vault (realpath
bajo el realpath del vault).
- Vault inexistente al arrancar → error claro en stderr + exit 2.
## Tests
```bash
cd projects/osint/apps/osint_db
.venv/bin/python -m pytest tests -q
```
Vault temporal + DuckDB temporal, sin red: migraciones idempotentes, ingest del
vault con fixture (conteos, exclusión de sub-notas y `_`), extracción de
`dav_uid` desde `fuente`, `/api/query` ok/error/solo-lectura (siempre HTTP
200), catálogo y queries con nombre, inventario de tablas, regla dura derivadas
sin `note_path`, enlace contacto→ficha por teléfono y render de nota con bloque
sentinel idempotente + validación de inputs y path traversal.
## Gotchas
- DuckDB bloquea el archivo a un escritor exclusivo: mientras un ingest está
escribiendo, una lectura `read_only` concurrente puede devolver
`{"status":"error"}` por conflicto de lock. El cliente reintenta.
- `/api/ingest/dav` requiere `pass` desbloqueado (gpg-agent); si gpg está
bloqueado devuelve `{"status":"error"}` con el detalle, sin crash.
- Las notas con prefijo `_` (`_indice.md`, `_plantilla.md`) y las sub-notas de
documento (`personas/<slug>/<doc>.md`) entran en `notes` pero NO cuentan como
entidades.