Compare commits
23 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 588d092858 | |||
| a90b7443e4 | |||
| e1e9bb7499 | |||
| 1430039688 | |||
| 935008ec3f | |||
| d89da1292d | |||
| 83f1d7c8d3 | |||
| 216cad4c12 | |||
| 167a7e5eb7 | |||
| b8ec97e477 | |||
| 40400c0b88 | |||
| 236a4740b0 | |||
| 1c4a4b9259 | |||
| 1c8a86594f | |||
| a76760edba | |||
| 4a0f0e9dc0 | |||
| 73f41a3474 | |||
| eb8dbf66a1 | |||
| 6bc97df5c0 | |||
| e769836b0d | |||
| 93756fbd0c | |||
| 0a6d1b8d17 | |||
| 82f1f1bd58 |
+1
-1
@@ -150,7 +150,7 @@ Cualquier `SELECT ... FROM functions/types/apps/proposals WHERE ...` plano se ha
|
|||||||
**functions** — columnas: `id, name, kind, lang, domain, version, purity, signature, description, tags, uses_functions, uses_types, returns, returns_optional, error_type, imports, example, tested, tests, test_file_path, file_path, created_at, updated_at, props, emits, has_state, framework, variant, notes, documentation, code, content_hash, source_repo, source_license, source_file, params_schema`
|
**functions** — columnas: `id, name, kind, lang, domain, version, purity, signature, description, tags, uses_functions, uses_types, returns, returns_optional, error_type, imports, example, tested, tests, test_file_path, file_path, created_at, updated_at, props, emits, has_state, framework, variant, notes, documentation, code, content_hash, source_repo, source_license, source_file, params_schema`
|
||||||
- `params_schema`: JSON con semántica de inputs/outputs. Formato: `{"params":[{"name":"x","desc":"..."}],"output":"..."}`. Buscable via FTS5.
|
- `params_schema`: JSON con semántica de inputs/outputs. Formato: `{"params":[{"name":"x","desc":"..."}],"output":"..."}`. Buscable via FTS5.
|
||||||
- Enums: `kind`(function|pipeline|component) `purity`(pure|impure) `lang`(go|py|bash|ps)
|
- Enums: `kind`(function|pipeline|component) `purity`(pure|impure) `lang`(go|py|bash|ps)
|
||||||
- Dominios: core, infra, finance, datascience, cybersecurity, shell, tui, pipelines, browser
|
- Dominios: core, infra, finance, datascience, cybersecurity, shell, tui, pipelines, browser, obsidian
|
||||||
|
|
||||||
**types** — columnas: `id, name, lang, domain, version, algebraic, definition, description, tags, uses_types, file_path, created_at, updated_at, examples, notes, documentation, code, content_hash, source_repo, source_license, source_file`
|
**types** — columnas: `id, name, lang, domain, version, algebraic, definition, description, tags, uses_types, file_path, created_at, updated_at, examples, notes, documentation, code, content_hash, source_repo, source_license, source_file`
|
||||||
- Enums: `algebraic`(product|sum)
|
- Enums: `algebraic`(product|sum)
|
||||||
|
|||||||
@@ -2,7 +2,8 @@
|
|||||||
"permissions": {
|
"permissions": {
|
||||||
"allow": [
|
"allow": [
|
||||||
"Bash(CGO_ENABLED=1 go test *)",
|
"Bash(CGO_ENABLED=1 go test *)",
|
||||||
"Bash(sqlite3 *)"
|
"Bash(sqlite3 *)",
|
||||||
|
"Read(//home/enmanuel/.claude/**)"
|
||||||
]
|
]
|
||||||
},
|
},
|
||||||
"enabledMcpjsonServers": [
|
"enabledMcpjsonServers": [
|
||||||
|
|||||||
@@ -0,0 +1,58 @@
|
|||||||
|
---
|
||||||
|
name: ensure_project_gitignore
|
||||||
|
kind: function
|
||||||
|
lang: bash
|
||||||
|
domain: infra
|
||||||
|
version: "1.0.0"
|
||||||
|
purity: impure
|
||||||
|
signature: "ensure_project_gitignore(project_dir: string) -> void"
|
||||||
|
description: "Garantiza de forma idempotente que el .gitignore de un directorio de project contiene las lineas canonicas que excluyen del repo del project el contenido de sus sub-repos hijos (apps y analyses son repos Gitea independientes) y sus vaults (datos fuera de git). Evita el doble-tracking al hacer push del project."
|
||||||
|
tags: [git, gitignore, projects, infra]
|
||||||
|
params:
|
||||||
|
- name: project_dir
|
||||||
|
desc: "Ruta al directorio del project (p. ej. projects/aurgi). Debe existir; si no, error a stderr y return 1. El .gitignore se escribe/actualiza en <project_dir>/.gitignore."
|
||||||
|
output: "Sin salida en stdout. A stderr informa de la accion realizada: 'created' si creo el .gitignore, 'updated: anadidas N lineas' si anadio lineas faltantes, u 'ok: ya completo' si nada cambiaba. Codigo de salida 0 en exito, 1 si project_dir falta o no existe."
|
||||||
|
uses_functions: []
|
||||||
|
uses_types: []
|
||||||
|
returns: []
|
||||||
|
returns_optional: false
|
||||||
|
error_type: "error_go_core"
|
||||||
|
imports: []
|
||||||
|
tested: false
|
||||||
|
tests: []
|
||||||
|
test_file_path: ""
|
||||||
|
file_path: "bash/functions/infra/ensure_project_gitignore.sh"
|
||||||
|
---
|
||||||
|
|
||||||
|
## Ejemplo
|
||||||
|
|
||||||
|
```bash
|
||||||
|
source bash/functions/infra/ensure_project_gitignore.sh
|
||||||
|
|
||||||
|
# Asegura que projects/aurgi/.gitignore excluye el contenido de sus hijos.
|
||||||
|
ensure_project_gitignore projects/aurgi
|
||||||
|
# stderr: ensure_project_gitignore: created projects/aurgi/.gitignore
|
||||||
|
# (o: updated: anadidas 2 lineas / ok: ya completo)
|
||||||
|
```
|
||||||
|
|
||||||
|
Las lineas canonicas que la funcion garantiza son:
|
||||||
|
|
||||||
|
```
|
||||||
|
apps/*/
|
||||||
|
analysis/*/
|
||||||
|
vaults/*
|
||||||
|
!vaults/.gitkeep
|
||||||
|
!vaults/vault.yaml
|
||||||
|
```
|
||||||
|
|
||||||
|
## Cuando usarla
|
||||||
|
|
||||||
|
Llamala justo despues de crear un project nuevo (`mkdir -p projects/<nombre>/{apps,analysis,vaults}`) y antes de inicializar su repo Gitea con `ensure_repo_synced`, para que el repo del project nunca trackee el contenido de sus sub-repos hijos. Tambien al adoptar un project existente que aun no tiene estas exclusiones, o como paso de saneamiento cuando `git status` del project muestra contenido de `apps/`/`analysis/` que deberia estar ignorado.
|
||||||
|
|
||||||
|
## Gotchas
|
||||||
|
|
||||||
|
- La funcion modifica el filesystem (escribe en `<project_dir>/.gitignore`): es impura. No commitea ni hace push — solo deja el `.gitignore` correcto.
|
||||||
|
- La comparacion para no duplicar es linea-exacta (`grep -Fxq`). Una linea equivalente pero con espacios extra, comentario adjunto o glob distinto (p. ej. `apps/*` sin la barra final) NO se considera presente y la canonica se anade igualmente; podrian quedar ambas formas. Mantener el `.gitignore` con las lineas canonicas tal cual evita ruido.
|
||||||
|
- Si el `.gitignore` existente no termina en salto de linea, la funcion anade uno antes de apendar para no pegar la primera linea nueva al final de la ultima existente.
|
||||||
|
- Solo gestiona las exclusiones de sub-repos hijos y vaults del nivel-project; no toca otras reglas que el `.gitignore` ya contenga ni las reordena.
|
||||||
|
- Si una linea canonica ya existia con su forma exacta, no se vuelve a anadir (idempotente): re-ejecutar es seguro.
|
||||||
@@ -0,0 +1,76 @@
|
|||||||
|
#!/usr/bin/env bash
|
||||||
|
# ensure_project_gitignore — Garantiza de forma idempotente que el .gitignore de
|
||||||
|
# un directorio de project (projects/<nombre>/) contiene las lineas canonicas que
|
||||||
|
# excluyen del repo del project el contenido de sus sub-repos hijos (apps y
|
||||||
|
# analyses son repos Gitea independientes) y sus vaults (datos fuera de git).
|
||||||
|
#
|
||||||
|
# Esto evita que al hacer push del project se trackee por error el contenido de
|
||||||
|
# los hijos (doble-tracking). Ver .claude/rules/apps_subrepo.md y
|
||||||
|
# .claude/rules/projects.md.
|
||||||
|
#
|
||||||
|
# Uso:
|
||||||
|
# ensure_project_gitignore <project_dir>
|
||||||
|
#
|
||||||
|
# Salida:
|
||||||
|
# stdout vacio. A stderr informa de la accion realizada (created / updated / ok).
|
||||||
|
|
||||||
|
ensure_project_gitignore() {
|
||||||
|
local project_dir="$1"
|
||||||
|
|
||||||
|
if [[ -z "$project_dir" ]]; then
|
||||||
|
echo "ensure_project_gitignore: se requiere project_dir" >&2
|
||||||
|
return 1
|
||||||
|
fi
|
||||||
|
if [[ ! -d "$project_dir" ]]; then
|
||||||
|
echo "ensure_project_gitignore: directorio '$project_dir' no existe" >&2
|
||||||
|
return 1
|
||||||
|
fi
|
||||||
|
|
||||||
|
local gitignore="$project_dir/.gitignore"
|
||||||
|
|
||||||
|
# Lineas canonicas que deben estar presentes (orden de referencia).
|
||||||
|
local -a canonical=(
|
||||||
|
"apps/*/"
|
||||||
|
"analysis/*/"
|
||||||
|
"vaults/*"
|
||||||
|
"!vaults/.gitkeep"
|
||||||
|
"!vaults/vault.yaml"
|
||||||
|
)
|
||||||
|
|
||||||
|
# Caso 1: el .gitignore no existe — crearlo con el contenido canonico.
|
||||||
|
if [[ ! -f "$gitignore" ]]; then
|
||||||
|
printf '%s\n' "${canonical[@]}" > "$gitignore"
|
||||||
|
echo "ensure_project_gitignore: created $gitignore" >&2
|
||||||
|
return 0
|
||||||
|
fi
|
||||||
|
|
||||||
|
# Caso 2: existe — anadir solo las lineas que falten (comparacion linea-exacta),
|
||||||
|
# preservando el contenido y el orden existentes.
|
||||||
|
# Si el archivo no termina en newline, anadir uno antes de apendar para no
|
||||||
|
# pegar la primera linea nueva al final de la ultima existente.
|
||||||
|
if [[ -s "$gitignore" && -n "$(tail -c 1 "$gitignore")" ]]; then
|
||||||
|
printf '\n' >> "$gitignore"
|
||||||
|
fi
|
||||||
|
|
||||||
|
local line added=0
|
||||||
|
for line in "${canonical[@]}"; do
|
||||||
|
# grep -F -x: match literal de linea completa, sin interpretar metacaracteres.
|
||||||
|
if ! grep -Fxq -- "$line" "$gitignore"; then
|
||||||
|
printf '%s\n' "$line" >> "$gitignore"
|
||||||
|
added=$((added + 1))
|
||||||
|
fi
|
||||||
|
done
|
||||||
|
|
||||||
|
if [[ $added -gt 0 ]]; then
|
||||||
|
echo "ensure_project_gitignore: updated: anadidas $added lineas a $gitignore" >&2
|
||||||
|
else
|
||||||
|
echo "ensure_project_gitignore: ok: ya completo $gitignore" >&2
|
||||||
|
fi
|
||||||
|
|
||||||
|
return 0
|
||||||
|
}
|
||||||
|
|
||||||
|
# Si se invoca como script (no source), ejecutar la funcion.
|
||||||
|
if [[ "${BASH_SOURCE[0]}" == "${0}" ]]; then
|
||||||
|
ensure_project_gitignore "$@"
|
||||||
|
fi
|
||||||
@@ -3,14 +3,15 @@ name: full_git_pull
|
|||||||
kind: pipeline
|
kind: pipeline
|
||||||
lang: bash
|
lang: bash
|
||||||
domain: pipelines
|
domain: pipelines
|
||||||
version: "1.0.0"
|
version: "1.1.0"
|
||||||
purity: impure
|
purity: impure
|
||||||
signature: "full_git_pull() -> stdout: tabla resumen"
|
signature: "full_git_pull() -> stdout: tabla resumen"
|
||||||
description: "Pull automatico de fn_registry + todos los sub-repos locales + submodules + fn sync. Descubre repos locales, stashea dirty trees antes de pullear, hace pull --ff-only, actualiza submodulos del repo principal, pulla ~/.password-store, regenera registry.db con fn index y ejecuta fn sync."
|
description: "Pull automatico de fn_registry + todos los sub-repos locales + submodules + fn sync. Descubre repos locales, stashea dirty trees antes de pullear, hace pull --ff-only, actualiza submodulos del repo principal, pulla ~/.password-store, regenera registry.db con fn index, ejecuta fn sync y reclona los sub-repos hijos faltantes de cada project (apps/analysis) via clone_project_subrepos."
|
||||||
tags: [git, pull, sync, registry, pipeline, pendiente-usar]
|
tags: [git, pull, sync, registry, pipeline, pendiente-usar]
|
||||||
uses_functions:
|
uses_functions:
|
||||||
- discover_git_repos_bash_infra
|
- discover_git_repos_bash_infra
|
||||||
- git_pull_with_stash_bash_infra
|
- git_pull_with_stash_bash_infra
|
||||||
|
- clone_project_subrepos_bash_pipelines
|
||||||
- pass_get_bash_infra
|
- pass_get_bash_infra
|
||||||
uses_types: []
|
uses_types: []
|
||||||
returns: []
|
returns: []
|
||||||
@@ -51,4 +52,10 @@ bash bash/functions/pipelines/full_git_pull.sh
|
|||||||
|
|
||||||
## Notas
|
## Notas
|
||||||
|
|
||||||
Solo hace pull fast-forward — nunca rebase ni merge automatico. Los repos con divergencia o conflicto de stash se listan al final del resumen para intervencion manual, pero el pipeline no aborta por ellos. No clona repos faltantes: cada PC tiene el subset que le interesa (clonar manualmente si se necesita uno nuevo). Modo completamente no-interactivo.
|
Solo hace pull fast-forward — nunca rebase ni merge automatico. Los repos con divergencia o conflicto de stash se listan al final del resumen para intervencion manual, pero el pipeline no aborta por ellos. Modo completamente no-interactivo.
|
||||||
|
|
||||||
|
Desde v1.1.0 SI reclona los sub-repos hijos faltantes de cada project: tras `fn sync` (que trae a `registry.db` las filas de apps/analysis de todos los PCs), itera los projects y llama `clone_project_subrepos` para traer al disco los hijos que falten, re-indexando si clono alguno. `registry.db` actua como manifest de sub-repos, asi que clonar el project paraguas + `/full-git-pull` reconstruye su arbol entero sin adivinar nombres. Los repos sueltos (sin project) siguen sin auto-clonarse: cada PC tiene el subset que le interesa.
|
||||||
|
|
||||||
|
## Capability growth log
|
||||||
|
|
||||||
|
- v1.1.0 (2026-06-10) — anade el paso 6: reclonado de sub-repos hijos de cada project via `clone_project_subrepos` tras `fn sync`, con re-index si clona alguno. Permite reconstruir el arbol completo de un project en un PC nuevo (issue 0171).
|
||||||
|
|||||||
@@ -149,6 +149,42 @@ full_git_pull() {
|
|||||||
fi
|
fi
|
||||||
fi
|
fi
|
||||||
|
|
||||||
|
# --- Paso 6: Reclonar sub-repos hijos de cada project (issue 0171) ---
|
||||||
|
# Tras fn sync, registry.db contiene las filas apps/analysis de TODOS los PCs.
|
||||||
|
# clone_project_subrepos clona en este disco los hijos que falten (skip si ya
|
||||||
|
# existen). Asi, clonar el project paraguas y correr /full-git-pull reconstruye
|
||||||
|
# su arbol entero sin adivinar nombres de sub-repos: registry.db ES el manifest.
|
||||||
|
echo "" >&2
|
||||||
|
echo "[6/6] Reclonando sub-repos de projects..." >&2
|
||||||
|
local reclone_summary=" [skip] sin projects o registry.db"
|
||||||
|
if [[ -f "$registry_root/registry.db" ]] && command -v sqlite3 >/dev/null 2>&1; then
|
||||||
|
export FN_REGISTRY_ROOT="$registry_root"
|
||||||
|
export GITEA_URL="${GITEA_URL:-$(pass_get agentes/gitea-url | head -n1 2>/dev/null || true)}"
|
||||||
|
local clone_script="$SCRIPT_DIR/clone_project_subrepos.sh"
|
||||||
|
local any_cloned=0
|
||||||
|
if [[ -f "$clone_script" ]]; then
|
||||||
|
while IFS= read -r proj_id; do
|
||||||
|
[[ -z "$proj_id" ]] && continue
|
||||||
|
local clone_out
|
||||||
|
clone_out=$(bash "$clone_script" "$proj_id" 2>&1 || true)
|
||||||
|
if echo "$clone_out" | grep -q '\[cloned\]'; then
|
||||||
|
any_cloned=1
|
||||||
|
echo " $proj_id: nuevos sub-repos clonados" >&2
|
||||||
|
fi
|
||||||
|
done < <(sqlite3 "$registry_root/registry.db" "SELECT id FROM projects;" 2>/dev/null)
|
||||||
|
if [[ "$any_cloned" -eq 1 ]]; then
|
||||||
|
echo " re-index tras clonado..." >&2
|
||||||
|
[[ -x "$fn_bin" ]] && CGO_ENABLED=1 "$fn_bin" index >/dev/null 2>&1 || true
|
||||||
|
reclone_summary=" OK: nuevos sub-repos clonados + re-index"
|
||||||
|
else
|
||||||
|
reclone_summary=" OK: nada que clonar (todo presente)"
|
||||||
|
fi
|
||||||
|
else
|
||||||
|
reclone_summary=" [skip] clone_project_subrepos.sh no encontrado"
|
||||||
|
fi
|
||||||
|
fi
|
||||||
|
echo " $reclone_summary" >&2
|
||||||
|
|
||||||
# --- Resumen ---
|
# --- Resumen ---
|
||||||
echo ""
|
echo ""
|
||||||
echo "===== RESUMEN full_git_pull ====="
|
echo "===== RESUMEN full_git_pull ====="
|
||||||
@@ -171,6 +207,9 @@ full_git_pull() {
|
|||||||
echo ""
|
echo ""
|
||||||
echo "fn sync:"
|
echo "fn sync:"
|
||||||
echo "$sync_summary"
|
echo "$sync_summary"
|
||||||
|
echo ""
|
||||||
|
echo "Reclonado sub-repos de projects:"
|
||||||
|
echo "$reclone_summary"
|
||||||
|
|
||||||
if [[ ${#diverged[@]} -gt 0 || ${#conflicts[@]} -gt 0 ]]; then
|
if [[ ${#diverged[@]} -gt 0 || ${#conflicts[@]} -gt 0 ]]; then
|
||||||
echo ""
|
echo ""
|
||||||
|
|||||||
@@ -3,10 +3,10 @@ name: full_git_push
|
|||||||
kind: pipeline
|
kind: pipeline
|
||||||
lang: bash
|
lang: bash
|
||||||
domain: pipelines
|
domain: pipelines
|
||||||
version: "1.0.0"
|
version: "1.1.0"
|
||||||
purity: impure
|
purity: impure
|
||||||
signature: "full_git_push(commit_message?: string) -> stdout: tabla resumen"
|
signature: "full_git_push(commit_message?: string) -> stdout: tabla resumen"
|
||||||
description: "Push automatico de fn_registry + todos los sub-repos + fn sync. Descubre repos, escanea secrets (aborta si detecta), auto-inicializa apps/analyses sin .git via ensure_repo_synced, auto-commitea dirty trees, pushea solo repos adelantados, pushea ~/.password-store sin commitear, y ejecuta fn sync."
|
description: "Push automatico de fn_registry + todos los sub-repos + fn sync. Descubre repos, escanea secrets (aborta si detecta), auto-inicializa apps/analyses Y projects paraguas sin .git via ensure_repo_synced (asegurando el .gitignore canonico del project antes), auto-commitea dirty trees, pushea solo repos adelantados, pushea ~/.password-store sin commitear, y ejecuta fn sync."
|
||||||
tags: [git, push, sync, registry, pipeline, pendiente-usar]
|
tags: [git, push, sync, registry, pipeline, pendiente-usar]
|
||||||
uses_functions:
|
uses_functions:
|
||||||
- discover_git_repos_bash_infra
|
- discover_git_repos_bash_infra
|
||||||
@@ -14,6 +14,7 @@ uses_functions:
|
|||||||
- git_auto_commit_dirty_bash_infra
|
- git_auto_commit_dirty_bash_infra
|
||||||
- git_push_if_ahead_bash_infra
|
- git_push_if_ahead_bash_infra
|
||||||
- ensure_repo_synced_bash_infra
|
- ensure_repo_synced_bash_infra
|
||||||
|
- ensure_project_gitignore_bash_infra
|
||||||
- pass_get_bash_infra
|
- pass_get_bash_infra
|
||||||
uses_types: []
|
uses_types: []
|
||||||
returns: []
|
returns: []
|
||||||
@@ -62,3 +63,7 @@ bash bash/functions/pipelines/full_git_push.sh "feat: nueva funcion"
|
|||||||
## Notas
|
## Notas
|
||||||
|
|
||||||
El unico motivo para abortar antes de commitear es la deteccion de secrets. Cualquier otro error (push rechazado por non-fast-forward, fn sync no disponible) se reporta en el resumen y el pipeline continua con el resto de repos. Modo completamente no-interactivo.
|
El unico motivo para abortar antes de commitear es la deteccion de secrets. Cualquier otro error (push rechazado por non-fast-forward, fn sync no disponible) se reporta en el resumen y el pipeline continua con el resto de repos. Modo completamente no-interactivo.
|
||||||
|
|
||||||
|
## Capability growth log
|
||||||
|
|
||||||
|
- v1.1.0 (2026-06-10) — auto-inicializa tambien los projects paraguas (`projects/<p>/`) sin repo Gitea, no solo apps/analyses. Antes de pushear cada project asegura su `.gitignore` canonico via `ensure_project_gitignore` para no trackear el contenido de los sub-repos hijos. Cierra el agujero por el que projects como aurgi/obsidian/osint vivian solo en disco y se perdian al borrar el PC (issue 0171).
|
||||||
|
|||||||
@@ -13,6 +13,7 @@ source "$INFRA_DIR/git_auto_commit_dirty.sh"
|
|||||||
source "$INFRA_DIR/git_push_if_ahead.sh"
|
source "$INFRA_DIR/git_push_if_ahead.sh"
|
||||||
source "$INFRA_DIR/pass_get.sh"
|
source "$INFRA_DIR/pass_get.sh"
|
||||||
source "$INFRA_DIR/ensure_repo_synced.sh"
|
source "$INFRA_DIR/ensure_repo_synced.sh"
|
||||||
|
source "$INFRA_DIR/ensure_project_gitignore.sh"
|
||||||
source "$CYBERSEC_DIR/scan_secrets_in_dirty.sh"
|
source "$CYBERSEC_DIR/scan_secrets_in_dirty.sh"
|
||||||
|
|
||||||
full_git_push() {
|
full_git_push() {
|
||||||
@@ -65,6 +66,32 @@ full_git_push() {
|
|||||||
ensure_repo_synced "$d" dataforge "$(basename "$d")" master "chore: initial sync" || \
|
ensure_repo_synced "$d" dataforge "$(basename "$d")" master "chore: initial sync" || \
|
||||||
echo " [warn] fallo inicializando $d" >&2
|
echo " [warn] fallo inicializando $d" >&2
|
||||||
done < <(sqlite3 "$registry_root/registry.db" "SELECT dir_path FROM apps WHERE dir_path != '' UNION SELECT dir_path FROM analysis WHERE dir_path != '';" 2>/dev/null)
|
done < <(sqlite3 "$registry_root/registry.db" "SELECT dir_path FROM apps WHERE dir_path != '' UNION SELECT dir_path FROM analysis WHERE dir_path != '';" 2>/dev/null)
|
||||||
|
|
||||||
|
# Paso 1c: Auto-inicializar los PROJECTS paraguas sin .git (issue 0171).
|
||||||
|
# El directorio projects/<p>/ versiona SOLO las docs de nivel-project
|
||||||
|
# (project.md, vault.yaml, CONVENTIONS.md, tools/...). Sus hijos apps/* y
|
||||||
|
# analysis/* son sub-repos Gitea independientes, excluidos por el .gitignore
|
||||||
|
# canonico que ensure_project_gitignore garantiza ANTES del push para no
|
||||||
|
# trackear su contenido (doble-tracking). Sin esto, un project sin repo
|
||||||
|
# (aurgi, obsidian, osint) vivia solo en disco y se perdia al borrar el PC.
|
||||||
|
if [[ -f "$registry_root/registry.db" ]] && command -v sqlite3 >/dev/null 2>&1; then
|
||||||
|
while IFS= read -r proj_dir; do
|
||||||
|
[[ -z "$proj_dir" ]] && continue
|
||||||
|
local pd="$registry_root/$proj_dir"
|
||||||
|
[[ -d "$pd" ]] || continue
|
||||||
|
# Garantizar el .gitignore canonico ANTES de cualquier git add -A.
|
||||||
|
ensure_project_gitignore "$pd" || \
|
||||||
|
echo " [warn] no se pudo asegurar .gitignore de $pd" >&2
|
||||||
|
if [[ -d "$pd/.git" ]]; then
|
||||||
|
git -C "$pd" remote get-url origin >/dev/null 2>&1 && continue
|
||||||
|
echo " fix-remote: $pd (.git sin origin)" >&2
|
||||||
|
else
|
||||||
|
echo " auto-init project: $pd" >&2
|
||||||
|
fi
|
||||||
|
ensure_repo_synced "$pd" dataforge "$(basename "$pd")" master "chore: initial sync project" || \
|
||||||
|
echo " [warn] fallo inicializando project $pd" >&2
|
||||||
|
done < <(sqlite3 "$registry_root/registry.db" "SELECT CASE WHEN dir_path != '' THEN dir_path ELSE 'projects/'||id END FROM projects;" 2>/dev/null)
|
||||||
|
fi
|
||||||
else
|
else
|
||||||
echo " [warn] registry.db o sqlite3 no disponibles — omitiendo auto-init BD-driven" >&2
|
echo " [warn] registry.db o sqlite3 no disponibles — omitiendo auto-init BD-driven" >&2
|
||||||
fi
|
fi
|
||||||
@@ -72,28 +99,13 @@ full_git_push() {
|
|||||||
echo " [skip] GITEA_URL/GITEA_TOKEN no disponibles — omitiendo auto-init" >&2
|
echo " [skip] GITEA_URL/GITEA_TOKEN no disponibles — omitiendo auto-init" >&2
|
||||||
fi
|
fi
|
||||||
|
|
||||||
# Redescubrir repos tras posibles inicializaciones
|
# Redescubrir repos tras posibles inicializaciones.
|
||||||
|
# El repo de config de Claude (dataforge/repo_Claude, al que apuntan los
|
||||||
|
# symlinks de ~/.claude/) vive en fn_registry/external/repo_Claude, asi que
|
||||||
|
# discover_git_repos ya lo encuentra y pasa por scan-secrets/commit/push
|
||||||
|
# como un repo mas. No necesita tratamiento especial.
|
||||||
repos=$(discover_git_repos "$registry_root")
|
repos=$(discover_git_repos "$registry_root")
|
||||||
|
|
||||||
# --- Paso 1c: Incluir el repo de configuracion de Claude ---
|
|
||||||
# Los archivos de ~/.claude/ (settings.json, commands, skills, CLAUDE.md...)
|
|
||||||
# son symlinks a un repo git externo (dataforge/repo_Claude). Lo resolvemos
|
|
||||||
# de forma portable siguiendo el symlink de settings.json — sin hardcodear
|
|
||||||
# el path, que difiere entre PCs. Si resuelve a un repo git, lo anadimos a
|
|
||||||
# la lista para que pase por scan-secrets + auto-commit + push como los demas.
|
|
||||||
local claude_repo=""
|
|
||||||
if [[ -L "$HOME/.claude/settings.json" ]]; then
|
|
||||||
local _claude_settings_real
|
|
||||||
_claude_settings_real=$(readlink -f "$HOME/.claude/settings.json" 2>/dev/null || true)
|
|
||||||
if [[ -n "$_claude_settings_real" ]]; then
|
|
||||||
claude_repo=$(git -C "$(dirname "$_claude_settings_real")" rev-parse --show-toplevel 2>/dev/null || true)
|
|
||||||
fi
|
|
||||||
fi
|
|
||||||
if [[ -n "$claude_repo" && -d "$claude_repo/.git" ]]; then
|
|
||||||
echo "[1c] Incluyendo repo de config Claude: $claude_repo" >&2
|
|
||||||
repos="$repos"$'\n'"$claude_repo"
|
|
||||||
fi
|
|
||||||
|
|
||||||
# --- Paso 2: Escanear secrets ---
|
# --- Paso 2: Escanear secrets ---
|
||||||
echo "" >&2
|
echo "" >&2
|
||||||
echo "[2/6] Escaneando secrets en dirty trees..." >&2
|
echo "[2/6] Escaneando secrets en dirty trees..." >&2
|
||||||
|
|||||||
@@ -0,0 +1,65 @@
|
|||||||
|
---
|
||||||
|
name: close_onlyoffice_instance
|
||||||
|
kind: function
|
||||||
|
lang: bash
|
||||||
|
domain: shell
|
||||||
|
version: "1.0.0"
|
||||||
|
purity: impure
|
||||||
|
signature: "close_onlyoffice_instance(instance: string = demo, [--purge]) -> json"
|
||||||
|
description: "Termina el/los proceso(s) DesktopEditors de una INSTANCIA AISLADA (slot) de ONLYOFFICE Desktop Editors, identificados por su HOME=/tmp/oo_<instance> leido de /proc/<pid>/environ — asi NUNCA mata la instancia personal del usuario, solo la aislada. Envia SIGTERM, espera ~3s por evento (read -t, sin sleep foreground) y SIGKILL a los que sigan vivos. Con el flag --purge borra ademas los directorios del slot (/tmp/oo_<instance>*). Imprime JSON con instance, killed_pids (array), purged y status (closed|not_running)."
|
||||||
|
tags: [onlyoffice, desktop, x11, shell]
|
||||||
|
uses_functions: []
|
||||||
|
uses_types: []
|
||||||
|
returns: []
|
||||||
|
returns_optional: false
|
||||||
|
error_type: "error_go_core"
|
||||||
|
imports: []
|
||||||
|
params:
|
||||||
|
- name: instance
|
||||||
|
desc: "nombre del slot aislado a cerrar (default: demo). Solo se matan procesos DesktopEditors cuyo HOME sea /tmp/oo_<instance>"
|
||||||
|
- name: --purge
|
||||||
|
desc: "flag opcional: si se pasa, borra los directorios del slot (/tmp/oo_<instance>*) tras matar los procesos. Sin el flag, solo termina procesos y deja el estado del slot en disco"
|
||||||
|
output: "una linea JSON a stdout: {\"instance\":\"<i>\",\"killed_pids\":[<pids>],\"purged\":true|false,\"status\":\"closed\"|\"not_running\"}. Exit 0 siempre que opere bien (closed si mato procesos, not_running si no habia ninguno del slot), exit 1 si falta dependencia, exit 2 si flag desconocido"
|
||||||
|
tested: false
|
||||||
|
tests: []
|
||||||
|
test_file_path: ""
|
||||||
|
file_path: "bash/functions/shell/close_onlyoffice_instance.sh"
|
||||||
|
---
|
||||||
|
|
||||||
|
## Ejemplo
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# Cerrar el slot demo (deja /tmp/oo_demo* en disco para reusar la config)
|
||||||
|
bash bash/functions/shell/close_onlyoffice_instance.sh demo
|
||||||
|
|
||||||
|
# Cerrar y limpiar todo el estado del slot
|
||||||
|
bash bash/functions/shell/close_onlyoffice_instance.sh demo --purge
|
||||||
|
|
||||||
|
# Slot por defecto (demo) sin argumentos
|
||||||
|
bash bash/functions/shell/close_onlyoffice_instance.sh
|
||||||
|
|
||||||
|
# Via fn run
|
||||||
|
./fn run close_onlyoffice_instance_bash_shell reporte --purge
|
||||||
|
|
||||||
|
# Sourceado
|
||||||
|
source bash/functions/shell/close_onlyoffice_instance.sh
|
||||||
|
out=$(close_onlyoffice_instance demo --purge)
|
||||||
|
echo "$out"
|
||||||
|
# {"instance":"demo","killed_pids":[12345,12350],"purged":true,"status":"closed"}
|
||||||
|
```
|
||||||
|
|
||||||
|
## Cuando usarla
|
||||||
|
|
||||||
|
- Cuando terminas un flujo automatizado con ONLYOFFICE Desktop y quieres **cerrar la instancia aislada por completo** (cerrar la ventana con `wmctrl` deja el proceso vivo; esta funcion mata el proceso real).
|
||||||
|
- Para **liberar recursos** de un slot que ya no usas, opcionalmente borrando su estado en /tmp con `--purge`.
|
||||||
|
- Como ultimo paso del ciclo open -> reload -> close, garantizando que no quedan procesos huerfanos de la instancia aislada.
|
||||||
|
|
||||||
|
## Gotchas
|
||||||
|
|
||||||
|
- **Solo mata la instancia aislada**: identifica procesos por `HOME=/tmp/oo_<instance>` en `/proc/<pid>/environ`. La instancia personal del usuario (HOME real) NUNCA se toca. Esto es por diseño y por seguridad.
|
||||||
|
- **Cerrar la ventana NO mata el proceso**: por eso esta funcion existe. Tras `reload`/`wmctrl -ic` el proceso de la instancia aislada sigue vivo (deseable para reusar). Usa esta funcion para terminarlo de verdad.
|
||||||
|
- **`--purge` borra /tmp/oo_<instance>***: pierdes la config del slot (perfil, recientes). El slot se recreara limpio en el siguiente `open`. Sin `--purge`, el estado persiste y el siguiente arranque reusa esa config.
|
||||||
|
- **El slot vive en /tmp**: aunque no purgues, `/tmp/oo_<instance>*` se pierde al reiniciar el PC. Estado desechable.
|
||||||
|
- **Requiere X11 + wmctrl + xdotool** instalados (coherencia con el grupo, aunque esta funcion solo usa /proc para matar). Comprueba `command -v` y falla claro si falta alguna; no funciona en Wayland puro sin XWayland para el resto del grupo.
|
||||||
|
- **Carrera de /proc**: si un pid muere entre listarlo y leer su environ, se ignora silenciosamente (guardas `2>/dev/null || true`); no rompe la funcion (`set -uo pipefail` sin `-e`).
|
||||||
|
- **SIGKILL como ultimo recurso**: tras ~3s de SIGTERM, los procesos vivos reciben SIGKILL. Cambios sin guardar en la app (si los hubiera) se pierden — pero el flujo previsto edita en disco, no en la app, asi que no deberia haber estado sin guardar.
|
||||||
@@ -0,0 +1,109 @@
|
|||||||
|
#!/usr/bin/env bash
|
||||||
|
# close_onlyoffice_instance — termina el/los proceso(s) DesktopEditors de una
|
||||||
|
# INSTANCIA AISLADA (slot) de ONLYOFFICE Desktop Editors, identificados por su
|
||||||
|
# HOME=/tmp/oo_<instance> en /proc/<pid>/environ. Opcionalmente limpia los
|
||||||
|
# directorios del slot con --purge.
|
||||||
|
#
|
||||||
|
# Funcion impura: lee /proc, envia señales a procesos y (con --purge) borra
|
||||||
|
# directorios bajo /tmp. NO toca la instancia personal del usuario: solo mata
|
||||||
|
# procesos cuyo HOME apunta al slot aislado.
|
||||||
|
#
|
||||||
|
# Slot aislado: cada instance usa HOME=/tmp/oo_<instance>,
|
||||||
|
# XDG_RUNTIME_DIR=/tmp/oo_<instance>_run, XDG_CONFIG_HOME=/tmp/oo_<instance>/.config.
|
||||||
|
|
||||||
|
# Sin -e: lecturas de /proc/<pid>/environ pueden fallar por carrera (el pid
|
||||||
|
# muere entre listar y leer); no deben abortar la funcion.
|
||||||
|
set -uo pipefail
|
||||||
|
|
||||||
|
close_onlyoffice_instance() {
|
||||||
|
local instance="demo"
|
||||||
|
local purge=false
|
||||||
|
|
||||||
|
# Parseo de args: [instance] y/o --purge en cualquier orden.
|
||||||
|
local a
|
||||||
|
for a in "$@"; do
|
||||||
|
case "$a" in
|
||||||
|
--purge) purge=true ;;
|
||||||
|
-*) echo "close_onlyoffice_instance: flag desconocido '$a'" >&2; return 2 ;;
|
||||||
|
*) instance="$a" ;;
|
||||||
|
esac
|
||||||
|
done
|
||||||
|
|
||||||
|
# 1. Dependencias del sistema (consistencia con el grupo, aunque aqui solo
|
||||||
|
# se usa /proc; onlyoffice/wmctrl/xdotool deben existir para operar el slot).
|
||||||
|
local dep
|
||||||
|
for dep in onlyoffice-desktopeditors wmctrl xdotool; do
|
||||||
|
if ! command -v "$dep" >/dev/null 2>&1; then
|
||||||
|
echo "close_onlyoffice_instance: falta dependencia '$dep'" >&2
|
||||||
|
return 1
|
||||||
|
fi
|
||||||
|
done
|
||||||
|
|
||||||
|
local oo_home="/tmp/oo_${instance}"
|
||||||
|
|
||||||
|
# 2. Encontrar pids de DesktopEditors con HOME=/tmp/oo_<instance>.
|
||||||
|
local pids=() pid environ
|
||||||
|
for pid in $(pgrep -f '/opt/onlyoffice/desktopeditors/DesktopEditors' 2>/dev/null || true); do
|
||||||
|
# Leer el entorno del proceso; saltar si no se puede (carrera/permisos).
|
||||||
|
environ=$(tr '\0' '\n' <"/proc/${pid}/environ" 2>/dev/null || true)
|
||||||
|
[[ -z "$environ" ]] && continue
|
||||||
|
if grep -qx "HOME=${oo_home}" <<<"$environ" 2>/dev/null; then
|
||||||
|
pids+=("$pid")
|
||||||
|
fi
|
||||||
|
done
|
||||||
|
|
||||||
|
# 3. Si no hay procesos del slot: not_running (purge opcional igualmente).
|
||||||
|
if [[ ${#pids[@]} -eq 0 ]]; then
|
||||||
|
local purged=false
|
||||||
|
if [[ "$purge" == true ]]; then
|
||||||
|
rm -rf -- /tmp/oo_"${instance}"* 2>/dev/null || true
|
||||||
|
purged=true
|
||||||
|
fi
|
||||||
|
printf '{"instance":"%s","killed_pids":[],"purged":%s,"status":"not_running"}\n' \
|
||||||
|
"$instance" "$purged"
|
||||||
|
return 0
|
||||||
|
fi
|
||||||
|
|
||||||
|
# 4. SIGTERM a todos los pids del slot.
|
||||||
|
kill -TERM "${pids[@]}" 2>/dev/null || true
|
||||||
|
|
||||||
|
# 5. Esperar ~3s a que mueran (NUNCA sleep foreground): read -t 0.3 x10.
|
||||||
|
local w=0 wmax=10
|
||||||
|
while [[ $w -lt $wmax ]]; do
|
||||||
|
local alive=false p
|
||||||
|
for p in "${pids[@]}"; do
|
||||||
|
if kill -0 "$p" 2>/dev/null; then alive=true; break; fi
|
||||||
|
done
|
||||||
|
[[ "$alive" == false ]] && break
|
||||||
|
read -t 0.3 _ </dev/null 2>/dev/null || true
|
||||||
|
w=$((w + 1))
|
||||||
|
done
|
||||||
|
|
||||||
|
# 6. SIGKILL a los que sigan vivos.
|
||||||
|
local p
|
||||||
|
for p in "${pids[@]}"; do
|
||||||
|
if kill -0 "$p" 2>/dev/null; then
|
||||||
|
kill -KILL "$p" 2>/dev/null || true
|
||||||
|
fi
|
||||||
|
done
|
||||||
|
|
||||||
|
# 7. Purge opcional de los dirs del slot.
|
||||||
|
local purged=false
|
||||||
|
if [[ "$purge" == true ]]; then
|
||||||
|
rm -rf -- /tmp/oo_"${instance}"* 2>/dev/null || true
|
||||||
|
purged=true
|
||||||
|
fi
|
||||||
|
|
||||||
|
# 8. JSON con el array de pids terminados.
|
||||||
|
local pids_json
|
||||||
|
pids_json=$(printf '%s,' "${pids[@]}")
|
||||||
|
pids_json="[${pids_json%,}]"
|
||||||
|
printf '{"instance":"%s","killed_pids":%s,"purged":%s,"status":"closed"}\n' \
|
||||||
|
"$instance" "$pids_json" "$purged"
|
||||||
|
return 0
|
||||||
|
}
|
||||||
|
|
||||||
|
# Ejecutable directo o sourceado.
|
||||||
|
if [[ "${BASH_SOURCE[0]}" == "${0}" ]]; then
|
||||||
|
close_onlyoffice_instance "$@"
|
||||||
|
fi
|
||||||
@@ -0,0 +1,78 @@
|
|||||||
|
---
|
||||||
|
name: monitor_listening_ports
|
||||||
|
kind: function
|
||||||
|
lang: bash
|
||||||
|
domain: shell
|
||||||
|
version: "0.3.0"
|
||||||
|
purity: impure
|
||||||
|
signature: "monitor_listening_ports([--interval N], [--once]) -> void"
|
||||||
|
description: "TUI ligera de terminal que refresca cada N segundos una tabla de los sockets TCP en escucha (LISTEN) del equipo local: IP | PUERTO | PROCESO | PID | TIEMPO ACTIVO | CMD (cmdline real, util para distinguir python3/node genericos), ordenada por tiempo de vida del proceso dueño (descendente). Una fila por pid. Lanzada como root rellena tambien los sockets de otros usuarios. Modo --once imprime un solo frame y sale."
|
||||||
|
tags: [recon, ports, monitor, tui]
|
||||||
|
uses_functions: []
|
||||||
|
uses_types: []
|
||||||
|
returns: []
|
||||||
|
returns_optional: false
|
||||||
|
error_type: "error_go_core"
|
||||||
|
imports: []
|
||||||
|
params:
|
||||||
|
- name: --interval N
|
||||||
|
desc: "segundos entre refrescos en modo bucle (default: 1, acepta decimales)"
|
||||||
|
- name: --once
|
||||||
|
desc: "imprime un único frame de la tabla y termina con exit 0 (no interactivo; úsalo en tests y en `fn run` para no colgar)"
|
||||||
|
output: "tabla a stdout con columnas IP, PUERTO, PROCESO, PID, TIEMPO ACTIVO ordenada por uptime del proceso descendente; sin --once refresca en bucle infinito hasta Ctrl-C"
|
||||||
|
tested: false
|
||||||
|
tests: []
|
||||||
|
test_file_path: ""
|
||||||
|
file_path: "bash/functions/shell/monitor_listening_ports.sh"
|
||||||
|
---
|
||||||
|
|
||||||
|
## Ejemplo
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# Un solo frame (no cuelga) — ideal para fn run o un pipe
|
||||||
|
./fn run monitor_listening_ports_bash_shell --once
|
||||||
|
|
||||||
|
# Como script directo
|
||||||
|
bash bash/functions/shell/monitor_listening_ports.sh --once
|
||||||
|
|
||||||
|
# Sourceada, en bucle interactivo refrescando cada segundo (Ctrl-C para salir)
|
||||||
|
source bash/functions/shell/monitor_listening_ports.sh
|
||||||
|
monitor_listening_ports --interval 1
|
||||||
|
|
||||||
|
# Refresco mas lento
|
||||||
|
monitor_listening_ports --interval 5
|
||||||
|
```
|
||||||
|
|
||||||
|
Salida (frame `--once`, recortado):
|
||||||
|
|
||||||
|
```
|
||||||
|
IP PUERTO PROCESO PID TIEMPO ACTIVO
|
||||||
|
* 8420 registry_api 1885 4d 23:40:46
|
||||||
|
:: 8889 mitmweb 1892 4d 23:40:46
|
||||||
|
127.0.0.1 8484 sqlite_api 1889 4d 23:40:42
|
||||||
|
127.0.0.1 8899 jupyter-lab 155100 4d 19:33:55
|
||||||
|
::1 631 - - ?
|
||||||
|
```
|
||||||
|
|
||||||
|
## Cuando usarla
|
||||||
|
|
||||||
|
- Cuando quieras vigilar **qué puertos abren tus dev-servers / procesos web locales y desde cuándo** llevan vivos, en una sola pantalla que se actualiza sola.
|
||||||
|
- Para detectar de un vistazo un proceso recién levantado (aparece al fondo, con poco TIEMPO ACTIVO) o uno que lleva días escuchando (arriba del todo).
|
||||||
|
- Como paso de reconocimiento local del grupo `recon`: inventario rápido de superficie de escucha TCP del propio equipo, con el dueño de cada socket.
|
||||||
|
- En tests o automatizaciones que solo necesitan un snapshot: añade `--once` para obtener un frame y salir.
|
||||||
|
|
||||||
|
## Gotchas
|
||||||
|
|
||||||
|
- **Impura**: depende de `ss` (paquete iproute2) y `ps` (procps). Si falta cualquiera, sale con exit 1 y un mensaje a stderr.
|
||||||
|
- **Sin sudo no ves PROCESO/PID/CMD de sockets de otros usuarios** (típicamente procesos de root, ej. systemd-resolved en `127.0.0.54:53`, kernels Jupyter de otra sesión, o servidores en contenedores). Esas filas muestran `-`/`?`. La función **no usa sudo** a propósito; para **rellenarlos, lánzala como root**: `pass show claude/sudo | sudo -S bash bash/functions/shell/monitor_listening_ports.sh --interval 1` (el password se pipea, no queda en la cmdline). Como root, `ss` resuelve el dueño de todos los sockets.
|
||||||
|
- **Columna CMD = cmdline real** (`ps -o args=`, recortada a 90 chars). Es lo que distingue un `python3`/`node` genérico (PROCESO) de lo que realmente ejecuta: `python3 -m ipykernel_launcher ...`, `registry_api -port 8420`, etc. Procesos en distinto namespace (docker) pueden seguir sin CMD aunque corras como root.
|
||||||
|
- **Una fila por pid**: un mismo puerto con varios workers (ej. nginx, gunicorn) genera varias filas, una por cada pid dueño del socket.
|
||||||
|
- **`--once` evita colgar**: sin `--once` corre en bucle infinito. No lo lances así en tests ni en `fn run` desatendido — usa `--once`.
|
||||||
|
- **El orden es por uptime del PROCESO, no por el tiempo de la conexión**. `ps -o etimes=` mide cuánto lleva vivo el proceso completo, no cuándo abrió ese socket concreto.
|
||||||
|
- **Carrera ps**: si un pid muere entre `ss` y `ps`, su TIEMPO ACTIVO sale como `?` y la fila se ordena al final (no rompe el bucle; el script usa `set -uo pipefail` sin `-e`).
|
||||||
|
- En modo bucle oculta el cursor (`tput civis`) y lo restaura + limpia en un `trap` EXIT/INT/TERM, de modo que Ctrl-C deja la terminal limpia.
|
||||||
|
|
||||||
|
## Capability growth log
|
||||||
|
|
||||||
|
- v0.3.0 (14/06/2026) — añade columna **CMD** con la cmdline real del proceso (mapa pid→args construido en la misma llamada `ps -eo pid=,etimes=,args=`), para distinguir un `python3`/`node` genérico de lo que realmente ejecuta. Documenta cómo rellenar los sockets de otros usuarios (`-`) lanzando la TUI como root. Anchos de columna reajustados para dar sitio a CMD.
|
||||||
|
- v0.2.0 (14/06/2026) — corrige parpadeo y cuelgue del modo bucle. (1) Doble-buffer ANSI: cada frame se computa completo en una variable y se pinta con cursor-home `\033[H` + clear-to-end `\033[J` en vez de `tput clear` antes de recolectar, eliminando el instante en blanco. (2) Rendimiento: una sola llamada a `ps -eo pid=,etimes=` (mapa pid→uptime en memoria, antes era un fork de `ps` por pid) y construcción de filas con `printf -v` (builtin, antes un `$( )` por fila); frame de ~130 ms con cientos de sockets. (3) Bugfix de cuelgue: el avance del parser multi-pid usaba `BASH_REMATCH[0]`, que queda sobrescrito por el `[[ =~ ]]` interno de `_mlp_fmt_etime` → no recortaba el string y entraba en bucle infinito. Ahora el needle se captura justo tras el match, con guard anti-cuelgue si el recorte no progresa.
|
||||||
@@ -0,0 +1,271 @@
|
|||||||
|
#!/usr/bin/env bash
|
||||||
|
# monitor_listening_ports — TUI ligera que refresca una tabla de sockets TCP en
|
||||||
|
# escucha (LISTEN) del equipo local, ordenada por tiempo de vida del proceso
|
||||||
|
# dueño (descendente). Columnas: IP | PUERTO | PROCESO | PID | TIEMPO ACTIVO.
|
||||||
|
#
|
||||||
|
# Funcion impura: lee estado del sistema (sockets via `ss`, uptime de procesos
|
||||||
|
# via `ps`). Sin --once corre en bucle infinito refrescando cada N segundos.
|
||||||
|
#
|
||||||
|
# Rendimiento: cada frame hace UNA sola llamada a `ss` y UNA sola a `ps`
|
||||||
|
# (mapa pid->etimes en memoria). El parseo de cada socket es bash puro y SIN
|
||||||
|
# command substitution por fila: las cadenas se construyen con `printf -v`
|
||||||
|
# (builtin, cero forks) y el formato de tiempo se devuelve en una variable
|
||||||
|
# global. El modo bucle usa doble-buffer ANSI (cursor home + clear-to-end) en
|
||||||
|
# lugar de limpiar la pantalla antes de computar, para que nunca se vea vacia
|
||||||
|
# entre refrescos.
|
||||||
|
|
||||||
|
# No usamos -e a proposito: una carrera donde un pid muere entre `ss` y `ps`
|
||||||
|
# no debe matar el bucle entero. -u y pipefail se mantienen para robustez.
|
||||||
|
set -uo pipefail
|
||||||
|
|
||||||
|
# Formatea segundos a texto humano legible y lo deja en la global _mlp_human.
|
||||||
|
# Se evita `$( )` (un fork por fila) usando una variable de retorno.
|
||||||
|
# <1h -> MM:SS ej. 12:45
|
||||||
|
# <1d -> HH:MM:SS ej. 03:12:45
|
||||||
|
# >=1d -> Nd HH:MM:SS ej. 1d 03:12:45
|
||||||
|
_mlp_human=""
|
||||||
|
_mlp_fmt_etime() {
|
||||||
|
local secs="$1"
|
||||||
|
# Si no es un numero entero valido, devolver tal cual (ej. "?").
|
||||||
|
if ! [[ "$secs" =~ ^[0-9]+$ ]]; then
|
||||||
|
_mlp_human="$secs"
|
||||||
|
return 0
|
||||||
|
fi
|
||||||
|
local days=$(( secs / 86400 ))
|
||||||
|
local rem=$(( secs % 86400 ))
|
||||||
|
local hours=$(( rem / 3600 ))
|
||||||
|
local mins=$(( (rem % 3600) / 60 ))
|
||||||
|
local s=$(( rem % 60 ))
|
||||||
|
if (( days > 0 )); then
|
||||||
|
printf -v _mlp_human '%dd %02d:%02d:%02d' "$days" "$hours" "$mins" "$s"
|
||||||
|
elif (( hours > 0 )); then
|
||||||
|
printf -v _mlp_human '%02d:%02d:%02d' "$hours" "$mins" "$s"
|
||||||
|
else
|
||||||
|
printf -v _mlp_human '%02d:%02d' "$mins" "$s"
|
||||||
|
fi
|
||||||
|
}
|
||||||
|
|
||||||
|
# Imprime un unico frame de la tabla a stdout.
|
||||||
|
# Estrategia de rendimiento (cero forks por fila):
|
||||||
|
# 1. Un solo `ps -eo pid=,etimes=` construye un mapa pid -> segundos vivo.
|
||||||
|
# 2. Un solo `ss -H -tlnp` lista los sockets en escucha.
|
||||||
|
# 3. Cada linea se parsea con bash puro: IP/puerto por parameter expansion,
|
||||||
|
# (nombre,pid) del campo users:(...) iterando con BASH_REMATCH, y cada
|
||||||
|
# fila se arma con `printf -v` (builtin). El uptime se resuelve por lookup
|
||||||
|
# O(1) en el mapa.
|
||||||
|
# 4. Se ordena por segundos vivo descendente con un unico `sort`.
|
||||||
|
_mlp_render_frame() {
|
||||||
|
# Mapas pid -> etimes (segundos vivo) y pid -> cmdline completa. Una sola
|
||||||
|
# invocacion de ps por frame. `args=` va al ultimo porque lleva espacios,
|
||||||
|
# asi `read` lo captura entero en la tercera variable.
|
||||||
|
local -A etmap=() argmap=()
|
||||||
|
local _pid _et _args
|
||||||
|
while read -r _pid _et _args; do
|
||||||
|
[[ -z "$_pid" ]] && continue
|
||||||
|
etmap["$_pid"]="$_et"
|
||||||
|
argmap["$_pid"]="$_args"
|
||||||
|
done < <(ps -eo pid=,etimes=,args= 2>/dev/null)
|
||||||
|
|
||||||
|
# Cada fila intermedia: "<etimes>\t<ip>\t<puerto>\t<proceso>\t<pid>\t<humano>"
|
||||||
|
local -a rows=()
|
||||||
|
local line row
|
||||||
|
while IFS= read -r line; do
|
||||||
|
[[ -z "$line" ]] && continue
|
||||||
|
|
||||||
|
# Campos de `ss -H -tlnp`: State Recv-Q Send-Q Local:Port Peer:Port users:(...)
|
||||||
|
# Local:Port es el 4o token. Lo extraemos sin fork con read en array.
|
||||||
|
local -a F=()
|
||||||
|
read -ra F <<<"$line"
|
||||||
|
local local_addr="${F[3]:-}"
|
||||||
|
[[ -z "$local_addr" ]] && continue
|
||||||
|
|
||||||
|
# Separar IP y PUERTO partiendo por el ULTIMO ':'.
|
||||||
|
local ip port
|
||||||
|
port="${local_addr##*:}"
|
||||||
|
ip="${local_addr%:*}"
|
||||||
|
# Quitar corchetes de IPv6: [::] -> :: , [::1] -> ::1
|
||||||
|
ip="${ip#[}"
|
||||||
|
ip="${ip%]}"
|
||||||
|
# Caso de bind sin direccion explicita (raro): dejar marcador.
|
||||||
|
[[ -z "$ip" ]] && ip="*"
|
||||||
|
|
||||||
|
# Extraer el bloque users:(...) del final de la linea (si existe).
|
||||||
|
local users=""
|
||||||
|
[[ "$line" == *"users:("* ]] && users="${line#*users:(}"
|
||||||
|
|
||||||
|
if [[ -z "$users" ]]; then
|
||||||
|
# Socket sin info de proceso (pertenece a otro usuario y no corremos
|
||||||
|
# como root). Para verlo, lanzar la TUI como root (ver Gotchas).
|
||||||
|
printf -v row '%s\t%s\t%s\t%s\t%s\t%s\t%s' "-1" "$ip" "$port" "-" "-" "?" "-"
|
||||||
|
rows+=("$row")
|
||||||
|
continue
|
||||||
|
fi
|
||||||
|
|
||||||
|
# Dentro de users puede haber varios ("nombre",pid=N,fd=M). Una fila por
|
||||||
|
# pid. Iteramos con BASH_REMATCH avanzando sobre el string (cero forks).
|
||||||
|
local s="$users" pname pid etimes needle prev_s cmd found_any=0
|
||||||
|
while [[ "$s" =~ \"([^\"]*)\",pid=([0-9]+) ]]; do
|
||||||
|
# IMPORTANTE: capturar nombre/pid/needle ANTES de cualquier otra
|
||||||
|
# comparacion `[[ =~ ]]` (p.ej. dentro de _mlp_fmt_etime), porque
|
||||||
|
# cada `=~` SOBREESCRIBE BASH_REMATCH. Si se usara BASH_REMATCH[0]
|
||||||
|
# despues, contendria el match del ultimo `=~` y el recorte de `s`
|
||||||
|
# no avanzaria -> bucle infinito.
|
||||||
|
pname="${BASH_REMATCH[1]}"
|
||||||
|
pid="${BASH_REMATCH[2]}"
|
||||||
|
needle="${BASH_REMATCH[0]}"
|
||||||
|
found_any=1
|
||||||
|
|
||||||
|
# Lookup O(1) en el mapa. Si el pid ya no esta (carrera), marcar "?".
|
||||||
|
etimes="${etmap[$pid]:-}"
|
||||||
|
if [[ -z "$etimes" || ! "$etimes" =~ ^[0-9]+$ ]]; then
|
||||||
|
etimes="-1"
|
||||||
|
_mlp_human="?"
|
||||||
|
else
|
||||||
|
_mlp_fmt_etime "$etimes"
|
||||||
|
fi
|
||||||
|
|
||||||
|
# Comando real (cmdline completa) del pid; dice QUE es realmente un
|
||||||
|
# "python3"/"node" generico. Se recorta para no romper la tabla.
|
||||||
|
cmd="${argmap[$pid]:-}"
|
||||||
|
[[ -z "$cmd" ]] && cmd="-"
|
||||||
|
cmd="${cmd:0:90}"
|
||||||
|
|
||||||
|
printf -v row '%s\t%s\t%s\t%s\t%s\t%s\t%s' "$etimes" "$ip" "$port" "$pname" "$pid" "$_mlp_human" "$cmd"
|
||||||
|
rows+=("$row")
|
||||||
|
|
||||||
|
# Avanzar mas alla del match actual para no repetir el primer pid.
|
||||||
|
# Guard: si el recorte no cambia `s`, cortar para no colgar nunca.
|
||||||
|
prev_s="$s"
|
||||||
|
s="${s#*"$needle"}"
|
||||||
|
[[ "$s" == "$prev_s" ]] && break
|
||||||
|
done
|
||||||
|
|
||||||
|
# Si el formato fue inesperado y no se parseo ningun par, fila placeholder.
|
||||||
|
if (( found_any == 0 )); then
|
||||||
|
printf -v row '%s\t%s\t%s\t%s\t%s\t%s\t%s' "-1" "$ip" "$port" "-" "-" "?" "-"
|
||||||
|
rows+=("$row")
|
||||||
|
fi
|
||||||
|
done < <(ss -H -tlnp 2>/dev/null)
|
||||||
|
|
||||||
|
# Estilo de cabecera (negrita) si la terminal lo soporta.
|
||||||
|
local bold="" reset=""
|
||||||
|
if [[ -t 1 ]] && command -v tput >/dev/null 2>&1; then
|
||||||
|
bold=$(tput bold 2>/dev/null || true)
|
||||||
|
reset=$(tput sgr0 2>/dev/null || true)
|
||||||
|
fi
|
||||||
|
|
||||||
|
# Anchos fijos para alineacion estable (no usamos column -t). La ultima
|
||||||
|
# columna (CMD) es libre: muestra la cmdline real del proceso.
|
||||||
|
local fmt='%-26s %-7s %-16s %-8s %-13s %s\n'
|
||||||
|
# shellcheck disable=SC2059
|
||||||
|
printf "${bold}${fmt}${reset}" "IP" "PUERTO" "PROCESO" "PID" "TIEMPO ACTIVO" "CMD"
|
||||||
|
|
||||||
|
if (( ${#rows[@]} == 0 )); then
|
||||||
|
printf '(sin sockets TCP en escucha)\n'
|
||||||
|
return 0
|
||||||
|
fi
|
||||||
|
|
||||||
|
# Ordenar por la primera columna (etimes) numerica descendente y emitir las
|
||||||
|
# 5 columnas visibles (descartando la columna de orden).
|
||||||
|
printf '%s\n' "${rows[@]}" \
|
||||||
|
| sort -t$'\t' -k1,1nr \
|
||||||
|
| while IFS=$'\t' read -r _etimes ip port pname pid human cmd; do
|
||||||
|
# shellcheck disable=SC2059
|
||||||
|
printf "$fmt" "$ip" "$port" "$pname" "$pid" "$human" "$cmd"
|
||||||
|
done
|
||||||
|
}
|
||||||
|
|
||||||
|
monitor_listening_ports() {
|
||||||
|
local interval=1
|
||||||
|
local once=0
|
||||||
|
|
||||||
|
# Parseo de flags.
|
||||||
|
while (( $# > 0 )); do
|
||||||
|
case "$1" in
|
||||||
|
--interval)
|
||||||
|
interval="${2:-1}"
|
||||||
|
shift 2
|
||||||
|
;;
|
||||||
|
--interval=*)
|
||||||
|
interval="${1#*=}"
|
||||||
|
shift
|
||||||
|
;;
|
||||||
|
--once)
|
||||||
|
once=1
|
||||||
|
shift
|
||||||
|
;;
|
||||||
|
-h|--help)
|
||||||
|
cat <<'USAGE'
|
||||||
|
monitor_listening_ports [--interval N] [--once]
|
||||||
|
|
||||||
|
--interval N Segundos entre refrescos (default: 1, acepta decimales).
|
||||||
|
--once Imprime un solo frame de la tabla y termina (exit 0).
|
||||||
|
|
||||||
|
Tabla de sockets TCP en escucha (LISTEN) ordenada por tiempo de vida del
|
||||||
|
proceso dueño (descendente). Columnas: IP | PUERTO | PROCESO | PID | TIEMPO ACTIVO.
|
||||||
|
USAGE
|
||||||
|
return 0
|
||||||
|
;;
|
||||||
|
*)
|
||||||
|
printf 'monitor_listening_ports: argumento desconocido: %s\n' "$1" >&2
|
||||||
|
return 1
|
||||||
|
;;
|
||||||
|
esac
|
||||||
|
done
|
||||||
|
|
||||||
|
# Dependencias minimas.
|
||||||
|
if ! command -v ss >/dev/null 2>&1; then
|
||||||
|
printf 'monitor_listening_ports: requiere `ss` (paquete iproute2)\n' >&2
|
||||||
|
return 1
|
||||||
|
fi
|
||||||
|
if ! command -v ps >/dev/null 2>&1; then
|
||||||
|
printf 'monitor_listening_ports: requiere `ps` (paquete procps)\n' >&2
|
||||||
|
return 1
|
||||||
|
fi
|
||||||
|
|
||||||
|
# Modo single-frame: util para tests y para `fn run` sin colgar.
|
||||||
|
if (( once == 1 )); then
|
||||||
|
_mlp_render_frame
|
||||||
|
return 0
|
||||||
|
fi
|
||||||
|
|
||||||
|
# Modo bucle interactivo: oculta cursor y lo restaura + limpia al salir.
|
||||||
|
local have_tput=0
|
||||||
|
command -v tput >/dev/null 2>&1 && have_tput=1
|
||||||
|
|
||||||
|
_mlp_cleanup() {
|
||||||
|
if (( have_tput == 1 )); then
|
||||||
|
tput cnorm 2>/dev/null || true # restaurar cursor
|
||||||
|
tput sgr0 2>/dev/null || true # resetear atributos
|
||||||
|
fi
|
||||||
|
printf '\n'
|
||||||
|
}
|
||||||
|
trap '_mlp_cleanup; trap - INT TERM EXIT; return 0 2>/dev/null || exit 0' INT TERM EXIT
|
||||||
|
|
||||||
|
(( have_tput == 1 )) && tput civis 2>/dev/null || true # ocultar cursor
|
||||||
|
|
||||||
|
# Limpiamos la pantalla UNA sola vez al entrar. A partir de aqui cada frame
|
||||||
|
# se computa COMPLETO en una variable y luego se pinta con doble-buffer:
|
||||||
|
# cursor a home (\033[H), volcado del frame, y clear-to-end (\033[J) para
|
||||||
|
# borrar restos de un frame anterior mas largo. Asi nunca hay un instante
|
||||||
|
# con la pantalla vacia mientras se recolectan los datos.
|
||||||
|
printf '\033[2J'
|
||||||
|
|
||||||
|
local frame
|
||||||
|
while true; do
|
||||||
|
frame=$(
|
||||||
|
printf 'monitor_listening_ports — %s — intervalo %ss — orden: TIEMPO ACTIVO desc (Ctrl-C para salir)\n\n' \
|
||||||
|
"$(date '+%d/%m/%Y %H:%M:%S')" "$interval"
|
||||||
|
_mlp_render_frame
|
||||||
|
)
|
||||||
|
printf '\033[H' # cursor al inicio (sin borrar todavia)
|
||||||
|
printf '%s\n' "$frame" # volcar el frame ya calculado de golpe
|
||||||
|
printf '\033[J' # borrar de aqui al final (restos del frame previo)
|
||||||
|
sleep "$interval" || break
|
||||||
|
done
|
||||||
|
}
|
||||||
|
|
||||||
|
# Auto-invocacion cuando se ejecuta como script (no al hacer source).
|
||||||
|
if [[ "${BASH_SOURCE[0]}" == "${0}" ]]; then
|
||||||
|
monitor_listening_ports "$@"
|
||||||
|
fi
|
||||||
@@ -0,0 +1,62 @@
|
|||||||
|
---
|
||||||
|
name: open_onlyoffice_file
|
||||||
|
kind: function
|
||||||
|
lang: bash
|
||||||
|
domain: shell
|
||||||
|
version: "1.0.0"
|
||||||
|
purity: impure
|
||||||
|
signature: "open_onlyoffice_file(file_path: string, instance: string = demo) -> json"
|
||||||
|
description: "Abre un archivo en una INSTANCIA AISLADA de ONLYOFFICE Desktop Editors (Linux/X11) sin perturbar la instancia personal del usuario. Cada 'instance' (slot, default demo) usa su propio HOME=/tmp/oo_<instance>, XDG_RUNTIME_DIR y XDG_CONFIG_HOME bajo /tmp, lo que rompe el single-instance lock de ONLYOFFICE y permite una ventana propia en vez de una pestaña en la instancia del usuario. Espera la ventana por evento (xdotool, basename del archivo, timeout ~25s) sin sleep en foreground. Idempotente: si ya hay ventana para ese basename, no relanza y devuelve el wid existente. NO crea archivos: si file_path no existe, falla. Imprime una linea JSON con instance, file (ruta absoluta), wid (hex), pid y status (open|timeout)."
|
||||||
|
tags: [onlyoffice, desktop, x11, shell]
|
||||||
|
uses_functions: []
|
||||||
|
uses_types: []
|
||||||
|
returns: []
|
||||||
|
returns_optional: false
|
||||||
|
error_type: "error_go_core"
|
||||||
|
imports: []
|
||||||
|
params:
|
||||||
|
- name: file_path
|
||||||
|
desc: "ruta (relativa o absoluta) al archivo a abrir; DEBE existir, esta funcion no crea archivos. Se normaliza con readlink -f y se busca la ventana por su basename"
|
||||||
|
- name: instance
|
||||||
|
desc: "nombre del slot aislado (default: demo). Determina el env: HOME=/tmp/oo_<instance>, XDG_RUNTIME_DIR=/tmp/oo_<instance>_run, XDG_CONFIG_HOME=/tmp/oo_<instance>/.config. Usa el MISMO instance en reload/close para operar la misma instancia"
|
||||||
|
output: "una linea JSON a stdout: {\"instance\":\"<i>\",\"file\":\"<abs>\",\"wid\":\"<hex>|null\",\"pid\":<n>|null,\"status\":\"open\"|\"timeout\"}. Exit 0 si abrio (status open), exit 1 si la ventana no aparecio en el timeout (status timeout) o falta dependencia/archivo, exit 2 si falta el argumento file_path"
|
||||||
|
tested: false
|
||||||
|
tests: []
|
||||||
|
test_file_path: ""
|
||||||
|
file_path: "bash/functions/shell/open_onlyoffice_file.sh"
|
||||||
|
---
|
||||||
|
|
||||||
|
## Ejemplo
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# Como script directo (slot 'demo' por defecto)
|
||||||
|
bash bash/functions/shell/open_onlyoffice_file.sh /tmp/demo_reload.xlsx
|
||||||
|
|
||||||
|
# Slot nombrado distinto (ventana propia, no perturba la instancia personal)
|
||||||
|
bash bash/functions/shell/open_onlyoffice_file.sh /tmp/informe.docx reporte
|
||||||
|
|
||||||
|
# Via fn run
|
||||||
|
./fn run open_onlyoffice_file_bash_shell /tmp/demo_reload.xlsx demo
|
||||||
|
|
||||||
|
# Sourceado, capturando el wid del JSON
|
||||||
|
source bash/functions/shell/open_onlyoffice_file.sh
|
||||||
|
out=$(open_onlyoffice_file /tmp/demo_reload.xlsx demo)
|
||||||
|
echo "$out"
|
||||||
|
# {"instance":"demo","file":"/tmp/demo_reload.xlsx","wid":"0x3c00007","pid":12345,"status":"open"}
|
||||||
|
```
|
||||||
|
|
||||||
|
## Cuando usarla
|
||||||
|
|
||||||
|
- Cuando necesites **abrir un archivo en ONLYOFFICE Desktop desde terminal en su propia ventana aislada**, sin que se agregue como pestaña a la instancia personal del usuario.
|
||||||
|
- Como primer paso de un flujo automatizado open -> (editas el archivo en disco) -> `reload_onlyoffice_file` -> `close_onlyoffice_instance`.
|
||||||
|
- Cuando quieras un slot reproducible por nombre (`instance`) que reuse la misma instancia aislada entre llamadas (reabrir rapido en vez de arrancar el motor de cero).
|
||||||
|
|
||||||
|
## Gotchas
|
||||||
|
|
||||||
|
- **ONLYOFFICE Desktop es single-instance por usuario**: sin el slot aislado (HOME/XDG_RUNTIME_DIR propios), un segundo lanzamiento se reenvia a la instancia viva y abre el archivo como PESTAÑA, no ventana nueva. El lock NO se rompe con XDG_CONFIG_HOME solo; SI con HOME + XDG_RUNTIME_DIR propios. Esta funcion ya aplica esa convencion.
|
||||||
|
- **NO hay reload nativo de cambios externos** (GitHub Issue #2313 abierto, no implementado). Esta funcion solo abre; para reflejar ediciones hechas en disco hay que cerrar+reabrir con `reload_onlyoffice_file`.
|
||||||
|
- **NO crea archivos**: si `file_path` no existe, falla con exit 1. Crea el archivo por tu cuenta antes de llamar.
|
||||||
|
- **El slot vive en /tmp**: los dirs `/tmp/oo_<instance>*` se pierden al reiniciar el PC (tmpfs en muchos sistemas). No guardes nada importante ahi; es estado desechable de la instancia aislada.
|
||||||
|
- **Requiere X11 + wmctrl + xdotool**: no funciona en Wayland puro sin XWayland (xdotool no encontrara la ventana). La funcion comprueba `command -v` de las 3 deps y falla claro si falta alguna.
|
||||||
|
- **El pid reportado es el del launcher** (`onlyoffice-desktopeditors`), que puede reexec/fork al proceso real `DesktopEditors`; sirve como referencia best-effort, no para `kill` fiable (usa `close_onlyoffice_instance`, que localiza el proceso real por su HOME).
|
||||||
|
- **Idempotencia por basename**: si ya existe una ventana cuyo titulo contiene el basename del archivo (lo abrio el usuario en su instancia personal, por ejemplo), la funcion la considera "ya abierta" y devuelve ese wid sin relanzar. Usa un basename unico para el slot de pruebas si quieres evitar colisiones.
|
||||||
@@ -0,0 +1,103 @@
|
|||||||
|
#!/usr/bin/env bash
|
||||||
|
# open_onlyoffice_file — abre un archivo en una INSTANCIA AISLADA de ONLYOFFICE
|
||||||
|
# Desktop Editors (Linux/X11), sin perturbar la instancia personal del usuario.
|
||||||
|
#
|
||||||
|
# Funcion impura: lanza un proceso GUI, lee estado de ventanas (xdotool) y
|
||||||
|
# escribe directorios en /tmp. Imprime una linea JSON con el resultado.
|
||||||
|
#
|
||||||
|
# Por que "instancia aislada": ONLYOFFICE Desktop es single-instance por
|
||||||
|
# usuario — un segundo `onlyoffice-desktopeditors <file>` se reenvia a la
|
||||||
|
# instancia viva y abre el archivo como PESTAÑA en su ventana. El lock
|
||||||
|
# single-instance NO se rompe con XDG_CONFIG_HOME, pero SI se rompe lanzando
|
||||||
|
# con HOME y XDG_RUNTIME_DIR propios. Por eso cada "slot" nombrado (instance)
|
||||||
|
# usa su propio HOME/XDG_RUNTIME_DIR/XDG_CONFIG_HOME bajo /tmp.
|
||||||
|
|
||||||
|
# Sin -e: las busquedas de ventana (xdotool search) pueden no matchear y
|
||||||
|
# devolver exit !=0; no deben abortar la funcion. -u y pipefail se mantienen.
|
||||||
|
set -uo pipefail
|
||||||
|
|
||||||
|
open_onlyoffice_file() {
|
||||||
|
local file_path="${1:-}"
|
||||||
|
local instance="${2:-demo}"
|
||||||
|
|
||||||
|
if [[ -z "$file_path" ]]; then
|
||||||
|
echo "open_onlyoffice_file: falta <file_path>" >&2
|
||||||
|
echo "uso: open_onlyoffice_file <file_path> [instance]" >&2
|
||||||
|
return 2
|
||||||
|
fi
|
||||||
|
|
||||||
|
# 1. Dependencias del sistema.
|
||||||
|
local dep
|
||||||
|
for dep in onlyoffice-desktopeditors wmctrl xdotool; do
|
||||||
|
if ! command -v "$dep" >/dev/null 2>&1; then
|
||||||
|
echo "open_onlyoffice_file: falta dependencia '$dep' (instala el paquete correspondiente)" >&2
|
||||||
|
return 1
|
||||||
|
fi
|
||||||
|
done
|
||||||
|
|
||||||
|
# 2. El archivo DEBE existir — esta funcion no crea archivos.
|
||||||
|
if [[ ! -f "$file_path" ]]; then
|
||||||
|
echo "open_onlyoffice_file: el archivo no existe: $file_path (esta funcion no crea archivos)" >&2
|
||||||
|
return 1
|
||||||
|
fi
|
||||||
|
|
||||||
|
# Ruta absoluta y basename para titular/buscar la ventana.
|
||||||
|
local abs_path base
|
||||||
|
abs_path=$(readlink -f -- "$file_path")
|
||||||
|
base=$(basename -- "$abs_path")
|
||||||
|
|
||||||
|
# 3. Slot aislado: HOME/XDG_RUNTIME_DIR/XDG_CONFIG_HOME propios bajo /tmp.
|
||||||
|
local oo_home="/tmp/oo_${instance}"
|
||||||
|
local oo_run="/tmp/oo_${instance}_run"
|
||||||
|
local oo_cfg="${oo_home}/.config"
|
||||||
|
mkdir -p "$oo_home" "$oo_cfg" "$oo_run"
|
||||||
|
chmod 700 "$oo_run" 2>/dev/null || true
|
||||||
|
|
||||||
|
# 4. Idempotencia: si ya hay ventana para ese basename, no relanzar.
|
||||||
|
local existing_wid
|
||||||
|
existing_wid=$(xdotool search --name -- "$base" 2>/dev/null | head -1 || true)
|
||||||
|
if [[ -n "$existing_wid" ]]; then
|
||||||
|
local wid_hex
|
||||||
|
wid_hex=$(printf '0x%x' "$existing_wid" 2>/dev/null || echo "$existing_wid")
|
||||||
|
printf '{"instance":"%s","file":"%s","wid":"%s","pid":null,"status":"open"}\n' \
|
||||||
|
"$instance" "$abs_path" "$wid_hex"
|
||||||
|
return 0
|
||||||
|
fi
|
||||||
|
|
||||||
|
# 5. Lanzar la instancia aislada con su env propio. setsid lo desacopla de
|
||||||
|
# la terminal; redirige todo a un log del slot.
|
||||||
|
env HOME="$oo_home" XDG_RUNTIME_DIR="$oo_run" XDG_CONFIG_HOME="$oo_cfg" \
|
||||||
|
setsid onlyoffice-desktopeditors "$abs_path" \
|
||||||
|
>"/tmp/oo_${instance}.log" 2>&1 </dev/null &
|
||||||
|
local launch_pid=$!
|
||||||
|
|
||||||
|
# 6. Esperar la ventana por evento (NUNCA sleep en foreground).
|
||||||
|
# ~25s con read -t 0.3 => ~83 iteraciones.
|
||||||
|
local wid="" i=0 max=83
|
||||||
|
while [[ $i -lt $max ]]; do
|
||||||
|
wid=$(xdotool search --name -- "$base" 2>/dev/null | head -1 || true)
|
||||||
|
[[ -n "$wid" ]] && break
|
||||||
|
read -t 0.3 _ </dev/null 2>/dev/null || true
|
||||||
|
i=$((i + 1))
|
||||||
|
done
|
||||||
|
|
||||||
|
if [[ -z "$wid" ]]; then
|
||||||
|
printf '{"instance":"%s","file":"%s","wid":null,"pid":%s,"status":"timeout"}\n' \
|
||||||
|
"$instance" "$abs_path" "$launch_pid"
|
||||||
|
return 1
|
||||||
|
fi
|
||||||
|
|
||||||
|
local wid_hex
|
||||||
|
wid_hex=$(printf '0x%x' "$wid" 2>/dev/null || echo "$wid")
|
||||||
|
# El pid del proceso real (DesktopEditors) puede diferir del launcher; el
|
||||||
|
# launcher reexec/fork. Reportamos el pid del launcher (best-effort).
|
||||||
|
printf '{"instance":"%s","file":"%s","wid":"%s","pid":%s,"status":"open"}\n' \
|
||||||
|
"$instance" "$abs_path" "$wid_hex" "$launch_pid"
|
||||||
|
return 0
|
||||||
|
}
|
||||||
|
|
||||||
|
# Ejecutable directo: `bash open_onlyoffice_file.sh <file> [instance]`.
|
||||||
|
# Sourceado: define la funcion sin ejecutarla.
|
||||||
|
if [[ "${BASH_SOURCE[0]}" == "${0}" ]]; then
|
||||||
|
open_onlyoffice_file "$@"
|
||||||
|
fi
|
||||||
@@ -0,0 +1,61 @@
|
|||||||
|
---
|
||||||
|
name: reload_onlyoffice_file
|
||||||
|
kind: function
|
||||||
|
lang: bash
|
||||||
|
domain: shell
|
||||||
|
version: "1.0.0"
|
||||||
|
purity: impure
|
||||||
|
signature: "reload_onlyoffice_file(file_path: string, instance: string = demo) -> json"
|
||||||
|
description: "Recarga en la ventana de ONLYOFFICE Desktop Editors los datos que el caller edito EN DISCO, cerrando y reabriendo el archivo en la INSTANCIA AISLADA (slot). Es la funcion estrella del grupo: ONLYOFFICE no recarga cambios externos del archivo (GitHub Issue #2313 abierto, no implementado), asi que la unica forma de mostrar datos editados fuera de la app es cerrar la ventana (wmctrl -ic) y reabrir (ONLYOFFICE lee fresco del disco al abrir). Localiza la ventana por basename, la cierra y espera a que desaparezca (timeout ~10s), relanza con el env del slot aislado y espera la ventana nueva (timeout ~25s), todo por evento sin sleep en foreground. Si no habia ventana previa, actua como open. NO edita el archivo: el caller lo edita antes de llamar. Imprime JSON con wid_old, wid_new, reopened, elapsed_s y status (reloaded|timeout)."
|
||||||
|
tags: [onlyoffice, desktop, x11, shell]
|
||||||
|
uses_functions: []
|
||||||
|
uses_types: []
|
||||||
|
returns: []
|
||||||
|
returns_optional: false
|
||||||
|
error_type: "error_go_core"
|
||||||
|
imports: []
|
||||||
|
params:
|
||||||
|
- name: file_path
|
||||||
|
desc: "ruta (relativa o absoluta) al archivo cuya ventana se recarga; DEBE existir. El caller ya lo edito en disco antes de llamar. Se busca la ventana por su basename"
|
||||||
|
- name: instance
|
||||||
|
desc: "nombre del slot aislado (default: demo); debe coincidir con el usado en open_onlyoffice_file para reusar la misma instancia. Determina HOME/XDG_RUNTIME_DIR/XDG_CONFIG_HOME bajo /tmp"
|
||||||
|
output: "una linea JSON a stdout: {\"instance\":\"<i>\",\"file\":\"<abs>\",\"wid_old\":\"<hex>|null\",\"wid_new\":\"<hex>|null\",\"reopened\":true|false,\"elapsed_s\":<n>,\"status\":\"reloaded\"|\"timeout\"}. Exit 0 si reabrio (status reloaded), exit 1 si la ventana nueva no aparecio en el timeout (status timeout) o falta dependencia/archivo, exit 2 si falta file_path"
|
||||||
|
tested: false
|
||||||
|
tests: []
|
||||||
|
test_file_path: ""
|
||||||
|
file_path: "bash/functions/shell/reload_onlyoffice_file.sh"
|
||||||
|
---
|
||||||
|
|
||||||
|
## Ejemplo
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# Flujo tipico: editas el .xlsx en disco con tu herramienta y refrescas la vista
|
||||||
|
# (este ejemplo asume que /tmp/demo_reload.xlsx ya esta abierto en el slot demo)
|
||||||
|
bash bash/functions/shell/reload_onlyoffice_file.sh /tmp/demo_reload.xlsx demo
|
||||||
|
|
||||||
|
# Via fn run
|
||||||
|
./fn run reload_onlyoffice_file_bash_shell /tmp/demo_reload.xlsx demo
|
||||||
|
|
||||||
|
# Sourceado, dentro de un bucle de "editar en disco -> ver en ONLYOFFICE"
|
||||||
|
source bash/functions/shell/reload_onlyoffice_file.sh
|
||||||
|
# ... el caller modifica /tmp/demo_reload.xlsx por su cuenta ...
|
||||||
|
out=$(reload_onlyoffice_file /tmp/demo_reload.xlsx demo)
|
||||||
|
echo "$out"
|
||||||
|
# {"instance":"demo","file":"/tmp/demo_reload.xlsx","wid_old":"0x3c00007","wid_new":"0x3c0000b","reopened":true,"elapsed_s":4,"status":"reloaded"}
|
||||||
|
```
|
||||||
|
|
||||||
|
## Cuando usarla
|
||||||
|
|
||||||
|
- Cuando **editaste un archivo en disco fuera de ONLYOFFICE** (script, otra herramienta, generador) y necesitas que la ventana de ONLYOFFICE muestre los datos nuevos: esta funcion cierra y reabre para forzar la lectura fresca del disco.
|
||||||
|
- En bucles de iteracion rapida "modificar el archivo -> ver el resultado en ONLYOFFICE" sin tocar la instancia personal del usuario.
|
||||||
|
- Como reemplazo del reload nativo inexistente (Issue #2313): es la unica via fiable de refrescar la vista desde disco.
|
||||||
|
|
||||||
|
## Gotchas
|
||||||
|
|
||||||
|
- **No edita el archivo**: solo recarga la ventana desde disco. El caller es responsable de modificar el archivo ANTES de llamar; si no lo modifico, reabrira los mismos datos.
|
||||||
|
- **ONLYOFFICE no tiene reload de cambios externos** (GitHub Issue #2313 abierto, no implementado): por eso esta funcion existe y hace cerrar+reabrir. No hay forma "in-place" de refrescar.
|
||||||
|
- **`wmctrl -ic` puede disparar el dialogo "Guardar cambios"** si el usuario edito EN la app (no en disco) y hay cambios sin guardar en esa ventana. El flujo previsto es editar SOLO en disco con la ventana sin tocar; si editaste en la app, guarda o descarta antes, o el cierre se quedara esperando interaccion (la funcion saldra por timeout).
|
||||||
|
- **Single-instance + slot aislado**: usa el mismo `instance` que en `open_onlyoffice_file`. Con HOME/XDG_RUNTIME_DIR propios el relaunch reenvia a la instancia aislada viva y reabre rapido; con env por defecto se reenviaria a la instancia personal del usuario (no deseado).
|
||||||
|
- **El slot vive en /tmp**: `/tmp/oo_<instance>*` se pierde al reiniciar el PC. Estado desechable.
|
||||||
|
- **Requiere X11 + wmctrl + xdotool**: no funciona en Wayland puro sin XWayland. Comprueba las 3 deps y falla claro si falta alguna.
|
||||||
|
- **Carrera de cierre**: si la ventana tarda mas de ~10s en cerrarse (dialogo modal, app ocupada), la funcion continua igualmente al relaunch; el resultado puede acabar en `timeout` si la ventana nueva no aparece a tiempo.
|
||||||
@@ -0,0 +1,117 @@
|
|||||||
|
#!/usr/bin/env bash
|
||||||
|
# reload_onlyoffice_file — cierra y reabre un archivo en la INSTANCIA AISLADA de
|
||||||
|
# ONLYOFFICE Desktop Editors para que la ventana muestre los datos editados
|
||||||
|
# EN DISCO por el caller (ONLYOFFICE no recarga cambios externos: GitHub Issue
|
||||||
|
# #2313 abierto, no implementado — la unica forma es cerrar+reabrir).
|
||||||
|
#
|
||||||
|
# Funcion impura: cierra una ventana GUI (wmctrl), relanza un proceso y espera
|
||||||
|
# la ventana nueva por evento. NO edita el archivo — solo recarga la ventana
|
||||||
|
# desde el disco. El caller edita el archivo antes de llamar a esta funcion.
|
||||||
|
#
|
||||||
|
# Instancia aislada (slot): mismo HOME/XDG_RUNTIME_DIR/XDG_CONFIG_HOME que usa
|
||||||
|
# open_onlyoffice_file, para que el relaunch reenvie a la instancia aislada
|
||||||
|
# viva y reabra rapido en vez de arrancar el motor de cero.
|
||||||
|
|
||||||
|
# Sin -e: busquedas de ventana (xdotool/wmctrl) pueden no matchear; no deben
|
||||||
|
# abortar la funcion. -u y pipefail se mantienen.
|
||||||
|
set -uo pipefail
|
||||||
|
|
||||||
|
reload_onlyoffice_file() {
|
||||||
|
local file_path="${1:-}"
|
||||||
|
local instance="${2:-demo}"
|
||||||
|
|
||||||
|
if [[ -z "$file_path" ]]; then
|
||||||
|
echo "reload_onlyoffice_file: falta <file_path>" >&2
|
||||||
|
echo "uso: reload_onlyoffice_file <file_path> [instance]" >&2
|
||||||
|
return 2
|
||||||
|
fi
|
||||||
|
|
||||||
|
# 1. Dependencias del sistema.
|
||||||
|
local dep
|
||||||
|
for dep in onlyoffice-desktopeditors wmctrl xdotool; do
|
||||||
|
if ! command -v "$dep" >/dev/null 2>&1; then
|
||||||
|
echo "reload_onlyoffice_file: falta dependencia '$dep' (instala el paquete correspondiente)" >&2
|
||||||
|
return 1
|
||||||
|
fi
|
||||||
|
done
|
||||||
|
|
||||||
|
# 2. El archivo DEBE existir — no editamos ni creamos archivos.
|
||||||
|
if [[ ! -f "$file_path" ]]; then
|
||||||
|
echo "reload_onlyoffice_file: el archivo no existe: $file_path" >&2
|
||||||
|
return 1
|
||||||
|
fi
|
||||||
|
|
||||||
|
local abs_path base
|
||||||
|
abs_path=$(readlink -f -- "$file_path")
|
||||||
|
base=$(basename -- "$abs_path")
|
||||||
|
|
||||||
|
# 3. Slot aislado (identico a open_onlyoffice_file).
|
||||||
|
local oo_home="/tmp/oo_${instance}"
|
||||||
|
local oo_run="/tmp/oo_${instance}_run"
|
||||||
|
local oo_cfg="${oo_home}/.config"
|
||||||
|
mkdir -p "$oo_home" "$oo_cfg" "$oo_run"
|
||||||
|
chmod 700 "$oo_run" 2>/dev/null || true
|
||||||
|
|
||||||
|
local start_ts
|
||||||
|
start_ts=$(date +%s)
|
||||||
|
|
||||||
|
# 4. Localizar la ventana actual del archivo por basename.
|
||||||
|
local wid_old=""
|
||||||
|
wid_old=$(xdotool search --name -- "$base" 2>/dev/null | head -1 || true)
|
||||||
|
|
||||||
|
local wid_old_hex="null"
|
||||||
|
if [[ -n "$wid_old" ]]; then
|
||||||
|
wid_old_hex=$(printf '0x%x' "$wid_old" 2>/dev/null || echo "$wid_old")
|
||||||
|
|
||||||
|
# 5. Cerrar la ventana (sin teclear en la app) y esperar a que
|
||||||
|
# desaparezca (~10s con read -t 0.3 => ~33 iteraciones).
|
||||||
|
wmctrl -ic "$wid_old" 2>/dev/null || true
|
||||||
|
local g=0 gmax=33
|
||||||
|
while [[ $g -lt $gmax ]]; do
|
||||||
|
if ! xdotool search --name -- "$base" 2>/dev/null | grep -q .; then
|
||||||
|
break
|
||||||
|
fi
|
||||||
|
read -t 0.3 _ </dev/null 2>/dev/null || true
|
||||||
|
g=$((g + 1))
|
||||||
|
done
|
||||||
|
fi
|
||||||
|
|
||||||
|
# 6. Relanzar con el env del slot aislado. (Si no habia ventana previa,
|
||||||
|
# esto actua simplemente como open.)
|
||||||
|
env HOME="$oo_home" XDG_RUNTIME_DIR="$oo_run" XDG_CONFIG_HOME="$oo_cfg" \
|
||||||
|
setsid onlyoffice-desktopeditors "$abs_path" \
|
||||||
|
>"/tmp/oo_${instance}.log" 2>&1 </dev/null &
|
||||||
|
|
||||||
|
# 7. Esperar la ventana nueva por evento (~25s => ~83 iteraciones).
|
||||||
|
local wid_new="" i=0 max=83
|
||||||
|
while [[ $i -lt $max ]]; do
|
||||||
|
wid_new=$(xdotool search --name -- "$base" 2>/dev/null | head -1 || true)
|
||||||
|
# Si hubo ventana previa, aceptar cualquier wid que aparezca (el old
|
||||||
|
# ya se cerro; el nuevo puede reutilizar id o no). Si no la hubo,
|
||||||
|
# cualquier wid sirve.
|
||||||
|
[[ -n "$wid_new" ]] && break
|
||||||
|
read -t 0.3 _ </dev/null 2>/dev/null || true
|
||||||
|
i=$((i + 1))
|
||||||
|
done
|
||||||
|
|
||||||
|
local now_ts elapsed
|
||||||
|
now_ts=$(date +%s)
|
||||||
|
elapsed=$((now_ts - start_ts))
|
||||||
|
|
||||||
|
if [[ -z "$wid_new" ]]; then
|
||||||
|
printf '{"instance":"%s","file":"%s","wid_old":"%s","wid_new":null,"reopened":false,"elapsed_s":%s,"status":"timeout"}\n' \
|
||||||
|
"$instance" "$abs_path" "$wid_old_hex" "$elapsed"
|
||||||
|
return 1
|
||||||
|
fi
|
||||||
|
|
||||||
|
local wid_new_hex
|
||||||
|
wid_new_hex=$(printf '0x%x' "$wid_new" 2>/dev/null || echo "$wid_new")
|
||||||
|
printf '{"instance":"%s","file":"%s","wid_old":"%s","wid_new":"%s","reopened":true,"elapsed_s":%s,"status":"reloaded"}\n' \
|
||||||
|
"$instance" "$abs_path" "$wid_old_hex" "$wid_new_hex" "$elapsed"
|
||||||
|
return 0
|
||||||
|
}
|
||||||
|
|
||||||
|
# Ejecutable directo o sourceado.
|
||||||
|
if [[ "${BASH_SOURCE[0]}" == "${0}" ]]; then
|
||||||
|
reload_onlyoffice_file "$@"
|
||||||
|
fi
|
||||||
@@ -0,0 +1,90 @@
|
|||||||
|
---
|
||||||
|
name: save_onlyoffice_file
|
||||||
|
kind: function
|
||||||
|
lang: bash
|
||||||
|
domain: shell
|
||||||
|
purity: impure
|
||||||
|
version: 1.1.0
|
||||||
|
description: "Fuerza el guardado (Ctrl+S) de un documento abierto en una instancia de OnlyOffice Desktop en Linux/X11 y confirma que llego a disco por cambio de mtime. Primer paso del flujo seguro guardar -> actualizar -> recargar; evita perder cambios no guardados cuando un build regenera el archivo leyendo del disco."
|
||||||
|
signature: "save_onlyoffice_file(file_path: string, [instance: string]) -> json"
|
||||||
|
error_type: error_go_core
|
||||||
|
tags: [onlyoffice, desktop, x11, gui, save, persist]
|
||||||
|
uses_functions: []
|
||||||
|
uses_types: []
|
||||||
|
file_path: bash/functions/shell/save_onlyoffice_file.sh
|
||||||
|
params:
|
||||||
|
- name: file_path
|
||||||
|
desc: "ruta al documento abierto en OnlyOffice cuyo guardado se quiere forzar. Debe existir. Se normaliza a ruta absoluta y se usa su basename para localizar la ventana."
|
||||||
|
- name: instance
|
||||||
|
desc: "nombre del slot/instancia para etiquetar la salida JSON (default: 'demo'). Usar el MISMO valor que en open/reload/close del mismo documento por coherencia."
|
||||||
|
output: "linea JSON a stdout: {\"instance\":\"<i>\",\"file\":\"<abs>\",\"wid\":\"<hex>|null\",\"status\":\"saved\"|\"no_change\"|\"no_window\",\"dialog_confirmed\":0|1[,\"mtime_before\":N,\"mtime_after\":N]}. dialog_confirmed=1 si se envio Return para cerrar el dialogo modal de formato. Exit 0 salvo error de dependencia o archivo inexistente (exit 1)."
|
||||||
|
---
|
||||||
|
|
||||||
|
Fuerza el guardado (Ctrl+S) de un documento abierto en una instancia de ONLYOFFICE
|
||||||
|
Desktop Editors en Linux/X11 y confirma que el guardado llegó a disco observando el
|
||||||
|
cambio de `mtime` del archivo.
|
||||||
|
|
||||||
|
Existe para cerrar una ventana de pérdida de datos: OnlyOffice mantiene los cambios
|
||||||
|
en memoria hasta que el usuario guarda. Cualquier proceso que regenere el archivo
|
||||||
|
leyendo del disco (un build que refresca hojas, un script de sincronización)
|
||||||
|
perdería el trabajo manual no guardado. Esta función vuelca ese trabajo a disco
|
||||||
|
ANTES de tocar el archivo, de modo que el paso de actualización pueda preservarlo.
|
||||||
|
|
||||||
|
Es el primer paso del flujo seguro de refresco:
|
||||||
|
|
||||||
|
```
|
||||||
|
save_onlyoffice_file -> (actualizar el archivo en disco) -> reload_onlyoffice_file
|
||||||
|
```
|
||||||
|
|
||||||
|
## Ejemplo
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# Forzar el guardado de un xlsx abierto en la instancia "afiliados"
|
||||||
|
bash bash/functions/shell/save_onlyoffice_file.sh \
|
||||||
|
/home/enmanuel/afiliados/programas_afiliados.xlsx afiliados
|
||||||
|
# {"instance":"afiliados","file":"/home/enmanuel/afiliados/programas_afiliados.xlsx","wid":"0x0a20002a","status":"saved","mtime_before":1718380000,"mtime_after":1718380042}
|
||||||
|
|
||||||
|
# Via fn run (tras fn index)
|
||||||
|
./fn run save_onlyoffice_file /home/enmanuel/afiliados/programas_afiliados.xlsx afiliados
|
||||||
|
|
||||||
|
# Encadenado con la actualización y la recarga (flujo seguro completo)
|
||||||
|
bash bash/functions/shell/save_onlyoffice_file.sh "$XLSX" afiliados
|
||||||
|
python build_xlsx.py # regenera solo las hojas gestionadas
|
||||||
|
./fn run reload_onlyoffice_file "$XLSX" afiliados
|
||||||
|
```
|
||||||
|
|
||||||
|
## Cuando usarla
|
||||||
|
|
||||||
|
Llámala SIEMPRE justo antes de regenerar o modificar en disco un archivo que el
|
||||||
|
usuario pueda tener abierto en OnlyOffice, para no pisar sus cambios sin guardar.
|
||||||
|
Es el primer eslabón del flujo guardar -> actualizar -> recargar. Si no hay ventana
|
||||||
|
abierta para ese archivo, es un no-op seguro (status `no_window`).
|
||||||
|
|
||||||
|
## Gotchas
|
||||||
|
|
||||||
|
- **Orden crítico**: guarda ANTES de actualizar el archivo. Si actualizas primero y
|
||||||
|
guardas OnlyOffice después, OnlyOffice sobrescribe tu actualización con su copia
|
||||||
|
en memoria (vieja). El flujo correcto es save -> update -> reload.
|
||||||
|
- **status `no_change`**: el `mtime` no cambió. Normalmente significa que no había
|
||||||
|
cambios pendientes (no es un error).
|
||||||
|
- **Auto-confirmación del diálogo de formato (v1.1.0)**: si tras Ctrl+S el guardado no
|
||||||
|
se completa en ~1.2s, la función asume que OnlyOffice mostró un diálogo modal
|
||||||
|
("mantener formato") y le envía Return, que acepta la opción por defecto (mantener el
|
||||||
|
formato actual). El campo `dialog_confirmed` indica si se envió. Si no había diálogo,
|
||||||
|
el Return va al editor y solo mueve de celda (no altera datos). Para suprimir el
|
||||||
|
diálogo de forma permanente, desmárcalo en OnlyOffice: Configuración avanzada →
|
||||||
|
desactivar el aviso de formato al guardar.
|
||||||
|
- **status `no_window`**: no hay ninguna ventana cuyo título contenga el basename del
|
||||||
|
archivo. No hay nada que guardar; el disco ya es la única fuente de verdad.
|
||||||
|
- **Detección por basename**: dos archivos con el mismo nombre en rutas distintas
|
||||||
|
colisionan al localizar la ventana (igual que open/reload).
|
||||||
|
- **X11 obligatorio**: depende de `xdotool` (y `stat` de coreutils). No funciona en
|
||||||
|
Wayland puro sin XWayland.
|
||||||
|
- **Foco**: la función activa la ventana (`windowactivate --sync`) para que Ctrl+S
|
||||||
|
llegue al editor. Roba el foco un instante; es esperable.
|
||||||
|
|
||||||
|
## Capability growth log
|
||||||
|
|
||||||
|
- v1.1.0 (2026-06-15) — auto-confirma el diálogo modal "mantener formato" enviando
|
||||||
|
Return a la ventana activa cuando el guardado no se completa en ~1.2s; añade el campo
|
||||||
|
`dialog_confirmed` a la salida JSON.
|
||||||
@@ -0,0 +1,107 @@
|
|||||||
|
#!/usr/bin/env bash
|
||||||
|
# save_onlyoffice_file — fuerza el guardado (Ctrl+S) de un documento abierto en una
|
||||||
|
# instancia de ONLYOFFICE Desktop Editors en Linux/X11 y confirma que el archivo se
|
||||||
|
# escribio a disco observando el cambio de mtime.
|
||||||
|
#
|
||||||
|
# Para que existe: OnlyOffice mantiene los cambios en memoria hasta que el usuario
|
||||||
|
# guarda. Cualquier proceso que regenere el .xlsx leyendo del disco (por ejemplo un
|
||||||
|
# build que refresca hojas) perderia el trabajo manual no guardado. Esta funcion
|
||||||
|
# vuelca ese trabajo a disco ANTES de tocar el archivo, de modo que el paso de
|
||||||
|
# actualizacion pueda preservarlo. Es el primer paso del flujo seguro:
|
||||||
|
# save_onlyoffice_file -> (actualizar el archivo) -> reload_onlyoffice_file
|
||||||
|
#
|
||||||
|
# La ventana se localiza por el basename del archivo (OnlyOffice titula la ventana
|
||||||
|
# "<basename> — ONLYOFFICE"), igual que open_onlyoffice_file. Si no hay ventana
|
||||||
|
# abierta para ese basename no hay nada que guardar: se devuelve status "no_window"
|
||||||
|
# con exit 0 (el disco ya es la unica fuente de verdad).
|
||||||
|
#
|
||||||
|
# Funcion impura: envia eventos de teclado a X11 (xdotool) y lee el estado del
|
||||||
|
# sistema de archivos. Imprime una linea JSON con el resultado a stdout.
|
||||||
|
#
|
||||||
|
# No usamos `set -e`: los pipelines de busqueda de ventanas (xdotool|head) pueden no
|
||||||
|
# matchear y no deben abortar el script. Mantenemos -u y pipefail con guardas.
|
||||||
|
set -uo pipefail
|
||||||
|
|
||||||
|
save_onlyoffice_file() {
|
||||||
|
local file_path="${1:-}"
|
||||||
|
local instance="${2:-demo}"
|
||||||
|
|
||||||
|
# --- 1. Validacion de dependencias del sistema ---
|
||||||
|
local dep
|
||||||
|
for dep in xdotool stat; do
|
||||||
|
if ! command -v "$dep" >/dev/null 2>&1; then
|
||||||
|
echo "error: dependencia ausente: '$dep' (instala xdotool, coreutils)" >&2
|
||||||
|
return 1
|
||||||
|
fi
|
||||||
|
done
|
||||||
|
|
||||||
|
# --- 2. Validacion de argumentos ---
|
||||||
|
if [ -z "$file_path" ]; then
|
||||||
|
echo "error: uso: save_onlyoffice_file <file_path> [instance]" >&2
|
||||||
|
return 1
|
||||||
|
fi
|
||||||
|
if [ ! -f "$file_path" ]; then
|
||||||
|
echo "error: el archivo no existe: '$file_path'" >&2
|
||||||
|
return 1
|
||||||
|
fi
|
||||||
|
local abs_path
|
||||||
|
abs_path="$(cd "$(dirname "$file_path")" && pwd)/$(basename "$file_path")"
|
||||||
|
local base
|
||||||
|
base="$(basename "$abs_path")"
|
||||||
|
|
||||||
|
# --- 3. Localizar la ventana de OnlyOffice por basename ---
|
||||||
|
local wid=""
|
||||||
|
wid="$(xdotool search --name "$base" 2>/dev/null | head -1 || true)"
|
||||||
|
if [ -z "$wid" ]; then
|
||||||
|
printf '{"instance":"%s","file":"%s","wid":null,"status":"no_window"}\n' \
|
||||||
|
"$instance" "$abs_path"
|
||||||
|
return 0
|
||||||
|
fi
|
||||||
|
local hex
|
||||||
|
hex="$(printf '0x%08x' "$wid" 2>/dev/null || echo "$wid")"
|
||||||
|
|
||||||
|
# --- 4. mtime antes de guardar ---
|
||||||
|
local mtime_before
|
||||||
|
mtime_before="$(stat -c %Y "$abs_path" 2>/dev/null || echo 0)"
|
||||||
|
|
||||||
|
# --- 5. Enfocar la ventana y enviar Ctrl+S ---
|
||||||
|
xdotool windowactivate --sync "$wid" >/dev/null 2>&1 || true
|
||||||
|
xdotool key --clearmodifiers --window "$wid" ctrl+s >/dev/null 2>&1 || true
|
||||||
|
|
||||||
|
# --- 6. Esperar el guardado; auto-confirmar el dialogo de formato si aparece ---
|
||||||
|
# OnlyOffice puede mostrar un dialogo modal ("mantener formato") al guardar. Si el
|
||||||
|
# mtime no cambia en ~1.2s asumimos que hay un modal esperando y le enviamos Return:
|
||||||
|
# acepta la opcion por defecto, que es mantener el formato actual del archivo. Si no
|
||||||
|
# habia dialogo, el Return va al editor y solo mueve de celda (inofensivo: no altera
|
||||||
|
# datos). El intento se repite mientras el guardado no se confirme.
|
||||||
|
local mtime_after="$mtime_before" i=0 confirmed=0
|
||||||
|
local max=27 # ~8s a 0.3s por iteracion
|
||||||
|
until [ "$mtime_after" -gt "$mtime_before" ] || [ "$i" -ge "$max" ]; do
|
||||||
|
read -r -t 0.3 _ </dev/null 2>/dev/null || true
|
||||||
|
mtime_after="$(stat -c %Y "$abs_path" 2>/dev/null || echo "$mtime_before")"
|
||||||
|
i=$((i + 1))
|
||||||
|
# A partir de ~1.2s sin guardar, confirmar el dialogo modal con Return.
|
||||||
|
if [ "$i" -ge 4 ] && [ "$mtime_after" -le "$mtime_before" ]; then
|
||||||
|
local dlg
|
||||||
|
dlg="$(xdotool getactivewindow 2>/dev/null || true)"
|
||||||
|
if [ -n "$dlg" ]; then
|
||||||
|
xdotool key --clearmodifiers --window "$dlg" Return >/dev/null 2>&1 || true
|
||||||
|
confirmed=1
|
||||||
|
fi
|
||||||
|
fi
|
||||||
|
done
|
||||||
|
|
||||||
|
local status="saved"
|
||||||
|
if [ "$mtime_after" -le "$mtime_before" ]; then
|
||||||
|
# Sin cambio de mtime: no habia nada pendiente que guardar.
|
||||||
|
status="no_change"
|
||||||
|
fi
|
||||||
|
printf '{"instance":"%s","file":"%s","wid":"%s","status":"%s","dialog_confirmed":%s,"mtime_before":%s,"mtime_after":%s}\n' \
|
||||||
|
"$instance" "$abs_path" "$hex" "$status" "$confirmed" "$mtime_before" "$mtime_after"
|
||||||
|
return 0
|
||||||
|
}
|
||||||
|
|
||||||
|
# Ejecutable directo: `bash save_onlyoffice_file.sh <file> [instance]`.
|
||||||
|
if [ "${BASH_SOURCE[0]}" = "${0}" ]; then
|
||||||
|
save_onlyoffice_file "$@"
|
||||||
|
fi
|
||||||
@@ -70,6 +70,8 @@ func cmdDoctor(args []string) {
|
|||||||
doctorDod(r, jsonOut)
|
doctorDod(r, jsonOut)
|
||||||
case "e2e-coverage":
|
case "e2e-coverage":
|
||||||
doctorE2ECoverage(r, jsonOut)
|
doctorE2ECoverage(r, jsonOut)
|
||||||
|
case "projects":
|
||||||
|
doctorProjects(r, jsonOut)
|
||||||
default:
|
default:
|
||||||
fmt.Fprintf(os.Stderr, "unknown doctor subcommand: %s\n", sub)
|
fmt.Fprintf(os.Stderr, "unknown doctor subcommand: %s\n", sub)
|
||||||
doctorUsage()
|
doctorUsage()
|
||||||
@@ -100,6 +102,7 @@ Subcommands:
|
|||||||
modules Drift entre uses_modules (app.md) y fn_module_<x> link calls (CMakeLists.txt) - issue 0097
|
modules Drift entre uses_modules (app.md) y fn_module_<x> link calls (CMakeLists.txt) - issue 0097
|
||||||
dod Audita bloque dod_evidence_schema en dev/issues/ y dev/flows/ (issue 0114)
|
dod Audita bloque dod_evidence_schema en dev/issues/ y dev/flows/ (issue 0114)
|
||||||
e2e-coverage Porcentaje de apps con e2e_checks declarado en su app.md (issue 0121b)
|
e2e-coverage Porcentaje de apps con e2e_checks declarado en su app.md (issue 0121b)
|
||||||
|
projects Cobertura de projects vs sub-repos Gitea (repo propio + hijos clonables) (issue 0171)
|
||||||
|
|
||||||
Flags:
|
Flags:
|
||||||
--json Salida JSON (para scripting/agentes)
|
--json Salida JSON (para scripting/agentes)
|
||||||
@@ -505,6 +508,29 @@ func doctorML(root string, jsonOut bool) {
|
|||||||
fmt.Printf("\nOverall ML environment: %s\n", overall)
|
fmt.Printf("\nOverall ML environment: %s\n", overall)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func doctorProjects(root string, jsonOut bool) {
|
||||||
|
rows, err := infra.AuditProjectsCoverage(root)
|
||||||
|
if err != nil {
|
||||||
|
fmt.Fprintf(os.Stderr, "error: %v\n", err)
|
||||||
|
os.Exit(1)
|
||||||
|
}
|
||||||
|
orphans, oerr := infra.FindOrphanProjectRefs(root)
|
||||||
|
if oerr != nil {
|
||||||
|
fmt.Fprintf(os.Stderr, "error: %v\n", oerr)
|
||||||
|
os.Exit(1)
|
||||||
|
}
|
||||||
|
if jsonOut {
|
||||||
|
emit(map[string]any{
|
||||||
|
"coverage": rows,
|
||||||
|
"orphan_project_ids": orphans,
|
||||||
|
})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
fmt.Print(infra.FormatProjectsCoverage(rows))
|
||||||
|
fmt.Println("\n--- Check inverso: project_id huérfanos (apps/analysis sin project declarado) ---")
|
||||||
|
fmt.Print(infra.FormatOrphanProjectRefs(orphans))
|
||||||
|
}
|
||||||
|
|
||||||
func emit(v any) {
|
func emit(v any) {
|
||||||
b, err := json.MarshalIndent(v, "", " ")
|
b, err := json.MarshalIndent(v, "", " ")
|
||||||
if err != nil {
|
if err != nil {
|
||||||
|
|||||||
Binary file not shown.
@@ -0,0 +1,199 @@
|
|||||||
|
---
|
||||||
|
id: "0171"
|
||||||
|
title: "Manifest de sub-repos por project + re-clonado y auditoría de cobertura en Gitea"
|
||||||
|
status: pendiente
|
||||||
|
type: enhancement
|
||||||
|
domain:
|
||||||
|
- registry-quality
|
||||||
|
- infra
|
||||||
|
scope: registry-only
|
||||||
|
priority: alta
|
||||||
|
depends: []
|
||||||
|
blocks: []
|
||||||
|
related: ["0166"]
|
||||||
|
created: 2026-06-10
|
||||||
|
updated: 2026-06-10
|
||||||
|
tags: [projects, subrepo, gitea, clone, backup, manifest, fn-doctor]
|
||||||
|
---
|
||||||
|
|
||||||
|
> **Actualización 10/06/2026 — implementado el núcleo (enfoque KISS).** El manifest
|
||||||
|
> `subrepos.yaml` propuesto abajo se **descartó**: `registry.db` (tablas `apps`/`analysis`
|
||||||
|
> con `project_id`, propagadas entre PCs por `fn sync`) **ya es** el manifest de sub-repos, y
|
||||||
|
> `clone_project_subrepos_bash_pipelines` ya lo consume. No hace falta un archivo nuevo. Lo que
|
||||||
|
> faltaba era integración + auditoría. Ver `## Estado de implementación` al final.
|
||||||
|
# 0171 — Manifest de sub-repos por project + re-clonado y auditoría de cobertura en Gitea
|
||||||
|
|
||||||
|
## APP Metadata
|
||||||
|
|
||||||
|
| Campo | Valor |
|
||||||
|
|-------|-------|
|
||||||
|
| **ID** | 0171 |
|
||||||
|
| **Estado** | pendiente |
|
||||||
|
| **Prioridad** | alta (riesgo de pérdida de datos) |
|
||||||
|
| **Tipo** | enhancement — metadata de projects + `/full-git-pull` + `fn doctor` |
|
||||||
|
|
||||||
|
## Contexto
|
||||||
|
|
||||||
|
El 10/06/2026, al preparar un dashboard sobre el project `aurgi`, se descubrió que el project
|
||||||
|
paraguas **no existía en Gitea** (`dataforge/aurgi` → 404). Sus 3 analyses sí estaban a salvo como
|
||||||
|
sub-repos independientes (`dataforge/venta_web`, `dataforge/sale_prices_comprobation`,
|
||||||
|
`dataforge/presupuestos_callcenter`), pero **el `project.md`, `vault.yaml` y `CONVENTIONS.md` de
|
||||||
|
nivel-project no estaban versionados en ningún sitio**. Reconstruir el project obligó a *adivinar*
|
||||||
|
los nombres de los sub-repos hijos uno a uno desde la lista completa de repos de Gitea.
|
||||||
|
|
||||||
|
Una auditoría de cobertura `projects ↔ Gitea` confirmó el agujero:
|
||||||
|
|
||||||
|
| Project | Repo Gitea | Riesgo |
|
||||||
|
|---|---|---|
|
||||||
|
| fleet_monitoring, fn_monitoring, message_bus, web_scraping | ✅ | ninguno |
|
||||||
|
| **obsidian**, **osint** | ❌ (solo en disco local) | alto — resuelto en esta sesión (subidos a `dataforge/obsidian`, `dataforge/osint`) |
|
||||||
|
| **aurgi** | ❌ (404, paraguas inexistente) | pendiente — analyses salvados, docs nivel-project no |
|
||||||
|
|
||||||
|
Dos problemas estructurales quedan abiertos:
|
||||||
|
|
||||||
|
1. **Projects sin repo Gitea**: su contenido de nivel-project vive solo en disco. Si se borra el
|
||||||
|
disco (o el project no se sincroniza a otro PC), se pierde. La regla `projects.md` dice que cada
|
||||||
|
project debe ser su propio repo Gitea, pero no hay nada que lo **verifique ni lo fuerce**.
|
||||||
|
|
||||||
|
2. **Sub-repos hijos no referenciados**: el `.gitignore` de cada project excluye `apps/*/` y
|
||||||
|
`analysis/*/` (son sub-repos independientes). Por tanto, **un clon fresco del project NO trae sus
|
||||||
|
hijos**, y no existe ningún manifest que diga *qué hijos clonar*. Hoy `/full-git-pull` solo
|
||||||
|
descubre repos vía `discover_git_repos_bash_infra` (busca `.git` ya presentes en disco): si el
|
||||||
|
hijo nunca se clonó, es invisible. Resultado: para reconstruir un project en una máquina nueva hay
|
||||||
|
que adivinar sus sub-repos (exactamente lo que pasó con aurgi).
|
||||||
|
|
||||||
|
## Objetivo
|
||||||
|
|
||||||
|
Que **todo project** (a) tenga su repo Gitea garantizado y (b) **referencie declarativamente sus
|
||||||
|
sub-repos hijos** (apps + analyses), de modo que clonar el project en cualquier PC permita
|
||||||
|
re-clonar automáticamente todo su árbol sin adivinar nada.
|
||||||
|
|
||||||
|
## Propuesta
|
||||||
|
|
||||||
|
### 1. Manifest de sub-repos por project
|
||||||
|
|
||||||
|
Añadir a cada project un manifest declarativo de sus hijos. Dos opciones de formato (decidir una):
|
||||||
|
|
||||||
|
- **Opción A (KISS, preferida): `subrepos.yaml`** en la raíz del project, análogo a `vault.yaml`:
|
||||||
|
|
||||||
|
```yaml
|
||||||
|
# projects/<p>/subrepos.yaml — sub-repos hijos de este project (apps + analyses)
|
||||||
|
subrepos:
|
||||||
|
- kind: analysis # app | analysis
|
||||||
|
name: venta_web
|
||||||
|
path: analysis/venta_web
|
||||||
|
repo: dataforge/venta_web
|
||||||
|
url: https://gitea-.../dataforge/venta_web
|
||||||
|
- kind: analysis
|
||||||
|
name: sale_prices_comprobation
|
||||||
|
path: analysis/sale_prices_comprobation
|
||||||
|
repo: dataforge/sale_prices_comprobation
|
||||||
|
url: https://gitea-.../dataforge/sale_prices_comprobation
|
||||||
|
```
|
||||||
|
|
||||||
|
- **Opción B: sección `## Sub-repos`** en `project.md` con una tabla `kind | name | path | url`.
|
||||||
|
|
||||||
|
`subrepos.yaml` (Opción A) es más fácil de parsear por las funciones de git y se versiona con el
|
||||||
|
project (no está en el `.gitignore`). El manifest se **autogenera/actualiza** escaneando los `.git`
|
||||||
|
hijos presentes en disco + su `remote get-url origin` (reusar `discover_git_repos_bash_infra`).
|
||||||
|
|
||||||
|
### 2. Generación y mantenimiento del manifest
|
||||||
|
|
||||||
|
Función/pipeline nueva (delegar a `fn-constructor`, grupo `infra`/git) que, dado un project:
|
||||||
|
- Escanea `apps/*/.git` y `analysis/*/.git`, lee su remote origin.
|
||||||
|
- Escribe/actualiza `subrepos.yaml`.
|
||||||
|
- Idempotente. Se invoca dentro de `/full-git-push` (o `fn index`) para mantener el manifest al día.
|
||||||
|
|
||||||
|
### 3. Re-clonado desde el manifest en `/full-git-pull`
|
||||||
|
|
||||||
|
Extender `/full-git-pull` para que, tras actualizar cada project, lea su `subrepos.yaml` y **clone
|
||||||
|
los hijos que falten** (`url` → `path`). Así, en un PC nuevo: clonar `dataforge/<project>` →
|
||||||
|
`/full-git-pull` → reconstruye apps + analyses automáticamente. Requiere una función
|
||||||
|
`clone_missing_subrepos_bash_infra(project_dir)` (delegar a `fn-constructor`).
|
||||||
|
|
||||||
|
### 4. Garantizar repo Gitea de cada project + auditoría en `fn doctor`
|
||||||
|
|
||||||
|
- Subcomando nuevo `fn doctor projects` (función `audit_projects_coverage_go_infra`): por cada
|
||||||
|
project en disco reporta `repo_gitea` (existe en Gitea sí/no), `repo_url` (declarado en project.md
|
||||||
|
sí/no), y `subrepos_manifest` (presente + cuántos hijos en disco sin entrada / en manifest sin
|
||||||
|
clonar). Salida `--json`. Cero hallazgos = sano.
|
||||||
|
- Acción derivada documentada: `repo_gitea=no` → `ensure_repo_synced_bash_infra projects/<p>
|
||||||
|
dataforge <p> master "init: project <p>"`.
|
||||||
|
|
||||||
|
### 5. Backfill inicial
|
||||||
|
|
||||||
|
- `aurgi`: traer su `project.md` / `vault.yaml` / `CONVENTIONS.md` de `aurgi-pc` (o `home-wsl`) y
|
||||||
|
crear `dataforge/aurgi` + `subrepos.yaml` con los 3 analyses ya conocidos. **No** reconstruir a
|
||||||
|
mano un `project.md` mínimo (divergiría del real).
|
||||||
|
- Resto de projects con hijos (`fleet_monitoring`, `fn_monitoring`, `message_bus`, `web_scraping`):
|
||||||
|
generar su `subrepos.yaml` con la función del punto 2.
|
||||||
|
|
||||||
|
## Definition of Done
|
||||||
|
|
||||||
|
| Escenario | Tipo | Comando / evidencia | Resultado esperado |
|
||||||
|
|---|---|---|---|
|
||||||
|
| Golden: clon fresco reconstruye árbol | e2e | clonar `dataforge/<p>` en dir limpio → `/full-git-pull` | apps + analyses del project re-clonados desde `subrepos.yaml` |
|
||||||
|
| Edge: project sin hijos (obsidian) | e2e | generar manifest | `subrepos.yaml` válido y vacío (o ausente), sin error |
|
||||||
|
| Edge: hijo en disco sin `.git` | unit | auditoría | `fn doctor projects` lo reporta como "hijo sin sub-repo" |
|
||||||
|
| Error: project sin repo Gitea | e2e | `fn doctor projects --json` | lo marca `repo_gitea=false`, sugiere `ensure_repo_synced` |
|
||||||
|
| Cobertura | audit | `fn doctor projects` | 0 projects sin repo, 0 hijos sin referenciar |
|
||||||
|
|
||||||
|
## Decisiones abiertas
|
||||||
|
|
||||||
|
1. **Formato del manifest**: `subrepos.yaml` (A) vs. sección en `project.md` (B). Recomendado A.
|
||||||
|
2. **¿Auto-generar el manifest en `fn index`** o solo en `/full-git-push`? (evitar I/O de red en
|
||||||
|
`fn index`; preferible en push).
|
||||||
|
3. **aurgi**: ¿traer de `aurgi-pc` por SSH ahora, o dejarlo para cuando el project se sincronice?
|
||||||
|
|
||||||
|
## Notas
|
||||||
|
|
||||||
|
En esta sesión ya se resolvió el riesgo inmediato: `obsidian` y `osint` se subieron a Gitea
|
||||||
|
(`dataforge/obsidian`, `dataforge/osint`) con `ensure_repo_synced_bash_infra` y se les añadió
|
||||||
|
`repo_url` en su `project.md`. Este issue cubre la solución **estructural y reutilizable** para que
|
||||||
|
el caso no vuelva a ocurrir con ningún project. Relacionado con #0166 (dependencias app→app para
|
||||||
|
build reproducible): ambos persiguen que clonar el ecosistema en un PC nuevo sea determinista.
|
||||||
|
|
||||||
|
## Estado de implementación (10/06/2026)
|
||||||
|
|
||||||
|
Implementado con enfoque KISS, **sin** `subrepos.yaml` (registry.db + `fn sync` ya cumplen esa
|
||||||
|
función). Cambios:
|
||||||
|
|
||||||
|
**Funciones nuevas:**
|
||||||
|
- `ensure_project_gitignore_bash_infra` — garantiza idempotente el `.gitignore` canónico de un
|
||||||
|
project (`apps/*/`, `analysis/*/`, `vaults/*` + excepciones) antes de cualquier `git add -A`,
|
||||||
|
para no trackear el contenido de los sub-repos hijos.
|
||||||
|
- `audit_projects_coverage_go_infra` (+ `FormatProjectsCoverage`) — motor de `fn doctor projects`.
|
||||||
|
Reporta por project: `git`/`remote`/`repo_url`/`children (cloned/inDB)` + issues
|
||||||
|
(`no_gitea_repo`, `children_missing`, `dir_not_found`). Solo git local + registry.db, sin red.
|
||||||
|
|
||||||
|
**Integraciones:**
|
||||||
|
- `full_git_push` v1.1.0 — paso 1c: auto-inicializa y pushea los **projects paraguas** sin repo
|
||||||
|
(antes solo apps/analyses), asegurando el `.gitignore` canónico primero. Cierra el agujero
|
||||||
|
aurgi/obsidian/osint.
|
||||||
|
- `full_git_pull` v1.1.0 — paso 6: tras `fn sync`, reclona los sub-repos hijos faltantes de cada
|
||||||
|
project con `clone_project_subrepos` + re-index. Clonar el paraguas + `/full-git-pull` reconstruye
|
||||||
|
el árbol entero.
|
||||||
|
- `fn doctor projects` — nuevo subcomando (`cmd/fn/doctor.go`). Hoy reporta **0 projects con
|
||||||
|
problemas**.
|
||||||
|
|
||||||
|
**Hecho aparte (riesgo inmediato):** `dataforge/obsidian` + `dataforge/osint` creados, `repo_url`
|
||||||
|
en sus `project.md`.
|
||||||
|
|
||||||
|
### Pendientes (no bloquean el núcleo)
|
||||||
|
|
||||||
|
1. **Check inverso — HECHO (10/06/2026).** `FindOrphanProjectRefs` + `FormatOrphanProjectRefs` en
|
||||||
|
`audit_projects_coverage_go_infra`, enchufado en `fn doctor projects`. Detecta apps/analysis con
|
||||||
|
`project_id` sin fila en `projects`. Hoy reporta 4 paraguas huérfanos (existen en otro PC, nunca
|
||||||
|
subidos a Gitea — mismo caso que aurgi):
|
||||||
|
- `element_agents` (6 apps: agents_and_robots, agents_dashboard, device_agent, element_matrix_chat,
|
||||||
|
matrix_admin_panel, matrix_client_pc)
|
||||||
|
- `imagegen` (image_to_3d_studio)
|
||||||
|
- `osint_graph` (graph_explorer)
|
||||||
|
- `aurgi` (sus analyses sí están en Gitea; el paraguas no)
|
||||||
|
2. **Fix de datos de los 4 paraguas huérfanos — pendiente, requiere el PC origen.** No están en disco
|
||||||
|
ni en Gitea en este PC (`lucas-linux`), así que no se pueden reconstruir aquí sin inventar. El fix
|
||||||
|
correcto: correr `/full-git-push` en el PC donde cada paraguas existe en disco (`aurgi-pc` /
|
||||||
|
`home-wsl`). Con `full_git_push` v1.1.0 (paso 1c) eso ya los crea en Gitea automáticamente. Tras
|
||||||
|
eso, `/full-git-pull` aquí (paso 6) los traerá. NO reconstruir un `project.md` mínimo a mano.
|
||||||
|
3. **DoD vida útil**: validar el reclonado en un PC nuevo real (clon limpio del paraguas →
|
||||||
|
`/full-git-pull` → árbol reconstruido) antes de declarar el issue cerrado.
|
||||||
@@ -0,0 +1,184 @@
|
|||||||
|
---
|
||||||
|
id: "0172"
|
||||||
|
title: "App web OSINT: grafo sigma.js + tablas por tipo + fichas con imágenes sobre el vault osint"
|
||||||
|
status: pendiente
|
||||||
|
type: app
|
||||||
|
domain:
|
||||||
|
- osint
|
||||||
|
- frontend
|
||||||
|
scope: app-scoped
|
||||||
|
priority: media
|
||||||
|
depends: []
|
||||||
|
blocks: []
|
||||||
|
related: ["0171"]
|
||||||
|
created: 2026-06-10
|
||||||
|
updated: 2026-06-10
|
||||||
|
tags: [osint, web, sigma, graph, mantine, obsidian, vault, dashboard]
|
||||||
|
---
|
||||||
|
# 0172 — App web OSINT: grafo sigma.js + tablas por tipo + fichas con imágenes
|
||||||
|
|
||||||
|
## APP Metadata
|
||||||
|
|
||||||
|
| Campo | Valor |
|
||||||
|
|-------|-------|
|
||||||
|
| **ID** | 0172 |
|
||||||
|
| **Estado** | pendiente (solo plan — se construye cuando el vault tenga más datos) |
|
||||||
|
| **Prioridad** | media |
|
||||||
|
| **Tipo** | app — nueva app web en `projects/osint/apps/osint_web` |
|
||||||
|
| **Project** | osint (`projects/osint/`) |
|
||||||
|
|
||||||
|
## Contexto
|
||||||
|
|
||||||
|
El project `osint` guarda sus investigaciones en el vault de Obsidian
|
||||||
|
`/home/enmanuel/Obsidian/osint` (sub-repo `dataforge/osint`). Hoy ese vault tiene:
|
||||||
|
|
||||||
|
- **~82 nodos** repartidos en carpetas tipadas: `personas/` (45), `organizaciones/` (25),
|
||||||
|
`lugares/` (10), `dominios/` (1), `casos/` (1).
|
||||||
|
- **Datos tabulares** en el frontmatter YAML de cada ficha: `tipo`, `nombre`, `sexo`,
|
||||||
|
`fecha_nacimiento`, `dni`, `direccion`, `pais`, `aliases`, `tags`, etc.
|
||||||
|
- **Aristas implícitas**: los wikilinks `[[...]]` en las secciones `Relaciones`, `Lugares` y
|
||||||
|
`Documentos` conectan unas fichas con otras (y con sus attachments).
|
||||||
|
- **~240 attachments**: fotos, DNIs, certificados y PDFs en `attachments/<tipo>/<slug>/`,
|
||||||
|
embebidos en las notas con `![[...]]`.
|
||||||
|
|
||||||
|
Obsidian es bueno para *escribir* la investigación, pero malo para *explorarla* de un vistazo:
|
||||||
|
no da un grafo navegable de todos los objetivos, ni una tabla filtrable, ni una ficha-resumen
|
||||||
|
con la galería de imágenes de cada persona. Metabase/Grafana no encajan: leen BD SQL (no `.md`),
|
||||||
|
y no muestran ni grafo de nodos ni imágenes inline.
|
||||||
|
|
||||||
|
Decisión del usuario (10/06/2026): construir una **app web propia** que lea el vault y ofrezca
|
||||||
|
tres vistas — **grafo explorable con sigma.js**, **tablas filtradas por tipo**, y **fichas con
|
||||||
|
imágenes**. Este issue es **solo el plan**: la recopilación de datos en Obsidian continúa primero;
|
||||||
|
la app se implementa cuando haya suficiente material que justifique la inversión.
|
||||||
|
|
||||||
|
## Objetivo
|
||||||
|
|
||||||
|
Una app web local que, leyendo directamente los `.md` del vault `osint` (sin BD intermedia
|
||||||
|
obligatoria en v1), permita:
|
||||||
|
|
||||||
|
1. **Explorar el grafo** de nodos (personas, organizaciones, lugares, dominios, casos) y sus
|
||||||
|
conexiones por wikilinks, con sigma.js: zoom, pan, click en nodo → ficha, colores por tipo,
|
||||||
|
filtro de tipos visibles, búsqueda de nodo.
|
||||||
|
2. **Ver tablas filtradas por tipo**: una tabla por categoría (personas, organizaciones, ...)
|
||||||
|
con las columnas del frontmatter, ordenable y filtrable (por dni, lugar, fecha, tag).
|
||||||
|
3. **Abrir la ficha** de cualquier nodo: frontmatter renderizado + cuerpo Markdown + galería de
|
||||||
|
sus attachments (fotos, DNIs, PDFs) servidos por el backend.
|
||||||
|
|
||||||
|
## Arquitectura propuesta
|
||||||
|
|
||||||
|
```
|
||||||
|
projects/osint/apps/osint_web/ (sub-repo Gitea dataforge/osint_web)
|
||||||
|
app.md frontmatter de registro (framework: react-vite-mantine)
|
||||||
|
server/ backend Python (lee el vault, sirve JSON + attachments)
|
||||||
|
main.py FastAPI o stdlib http
|
||||||
|
frontend/ React + Vite + Mantine + sigma.js
|
||||||
|
src/
|
||||||
|
views/GraphView.tsx sigma.js + graphology
|
||||||
|
views/TablesView.tsx Mantine DataTable filtrable por tipo
|
||||||
|
views/NodeCard.tsx ficha + galería de attachments
|
||||||
|
```
|
||||||
|
|
||||||
|
### Backend (Python — máximo reuso del grupo `obsidian`)
|
||||||
|
|
||||||
|
Python porque el grupo de capacidad `obsidian` (11 funciones, dominio `obsidian`) ya cubre casi
|
||||||
|
todo el parseo del vault. **Registry-first**: el backend orquesta estas funciones, no reimplementa
|
||||||
|
el parseo.
|
||||||
|
|
||||||
|
Funciones del registry a reutilizar:
|
||||||
|
|
||||||
|
| Función | Uso en la app |
|
||||||
|
|---|---|
|
||||||
|
| `list_obsidian_notes_py_obsidian` | enumerar nodos por carpeta/tipo |
|
||||||
|
| `read_obsidian_note_py_obsidian` | leer ficha: `{frontmatter, body, wikilinks, tags}` |
|
||||||
|
| `parse_obsidian_frontmatter_py_obsidian` | datos tabulares de cada nodo |
|
||||||
|
| `extract_obsidian_wikilinks_py_obsidian` | aristas del grafo |
|
||||||
|
| `extract_obsidian_embeds_py_obsidian` | attachments embebidos en cada nota |
|
||||||
|
| `resolve_obsidian_embed_py_obsidian` | resolver `![[foto.jpg]]` → path real en disco para servir la imagen |
|
||||||
|
| `slugify_obsidian_name_py_obsidian` | normalizar nombre de wikilink → id de nodo |
|
||||||
|
| `search_obsidian_notes_py_obsidian` | búsqueda global en el grafo |
|
||||||
|
|
||||||
|
Funciones **nuevas** a delegar a `fn-constructor` (no escribir inline en la app):
|
||||||
|
|
||||||
|
- `build_obsidian_graph_py_obsidian` (impure) — dado `vault_dir`, devuelve
|
||||||
|
`{"nodes": [{id, tipo, label, frontmatter}], "edges": [{source, target, kind}]}`.
|
||||||
|
Resuelve cada wikilink a un nodo existente (vía slug / nombre de archivo); los wikilinks que
|
||||||
|
no resuelven a un `.md` del vault se marcan como aristas "dangling" o se descartan según flag.
|
||||||
|
Tag de grupo: `obsidian`. Es la pieza que el grupo declara como frontera no cubierta
|
||||||
|
("No indexa el grafo agregado") — esta función la cierra.
|
||||||
|
|
||||||
|
Endpoints HTTP (JSON salvo el de attachments):
|
||||||
|
|
||||||
|
| Método | Ruta | Devuelve |
|
||||||
|
|---|---|---|
|
||||||
|
| GET | `/api/graph` | grafo completo `{nodes, edges}` para sigma.js |
|
||||||
|
| GET | `/api/nodes?tipo=persona` | filas de la tabla de ese tipo (frontmatter aplanado) |
|
||||||
|
| GET | `/api/node/{slug}` | ficha: frontmatter + body (HTML/markdown) + lista de attachments |
|
||||||
|
| GET | `/api/attachment?path=...` | sirve el binario del attachment (image/pdf), con allowlist al vault |
|
||||||
|
| GET | `/api/search?q=...` | nodos que matchean |
|
||||||
|
|
||||||
|
Seguridad: el backend solo sirve archivos **dentro** del vault osint (path traversal bloqueado).
|
||||||
|
El vault contiene datos personales sensibles (DNIs) → la app escucha **solo en `127.0.0.1`**, sin
|
||||||
|
exponer a red. No es un service desplegable a VPS.
|
||||||
|
|
||||||
|
### Frontend (React + Vite + Mantine + sigma.js)
|
||||||
|
|
||||||
|
- Sistema del registry: React + Vite + Mantine v9 + `@fn_library` (grupo `mantine`, 63 funciones).
|
||||||
|
Componentes propios de `@fn_library` antes que HTML nativo (regla `frontend_theming.md`).
|
||||||
|
- **Grafo**: `sigma.js` + `graphology`. Color por `tipo`, tamaño por grado, layout
|
||||||
|
force-directed (graphology-layout-forceatlas2). Click en nodo → abre `NodeCard`. Panel lateral
|
||||||
|
con toggles de tipos visibles y caja de búsqueda.
|
||||||
|
- **Tablas**: una pestaña por tipo, Mantine `Table`/DataTable con columnas del frontmatter,
|
||||||
|
orden y filtro por columna (dni, lugar, fecha_nacimiento, tags).
|
||||||
|
- **Fichas**: `NodeCard` con frontmatter en formato clave-valor (fechas en formato europeo
|
||||||
|
DD/MM/AAAA — memoria `formato-fecha-europeo`), cuerpo Markdown, y galería de attachments
|
||||||
|
(imágenes con lightbox; PDFs como enlace/embed).
|
||||||
|
|
||||||
|
`sigma.js` y `graphology` son dependencias nuevas del frontend (no en `@fn_library`). KISS:
|
||||||
|
añadir solo esas dos; el resto (tabla, layout, modales) sale de Mantine/`@fn_library`.
|
||||||
|
|
||||||
|
## Decisiones abiertas
|
||||||
|
|
||||||
|
1. **¿BD intermedia o lectura directa del vault?** v1 lee el vault en cada arranque (cachea el
|
||||||
|
grafo en memoria). Si el vault crece mucho o se quiere histórico/diff, evaluar un
|
||||||
|
`operations.db` con `entities`/`relations` (encaja con el bucle reactivo). Recomendado:
|
||||||
|
empezar sin BD (KISS), añadirla solo si el rendimiento o un caso de uso lo exige.
|
||||||
|
2. **Backend FastAPI vs stdlib http**: FastAPI da validación y OpenAPI gratis; stdlib evita una
|
||||||
|
dependencia. Como el backend es fino (orquesta funciones del registry), decidir al construir.
|
||||||
|
3. **Live-reload del vault**: ¿re-escanear bajo demanda (botón "refrescar") o watcher de
|
||||||
|
filesystem? v1: botón refrescar (simple). Watcher si molesta.
|
||||||
|
4. **Aristas dangling**: wikilinks a notas que aún no existen — ¿mostrarlos como nodos fantasma
|
||||||
|
(útil para ver "objetivos pendientes de fichar") o esconderlos? Propuesta: nodo fantasma con
|
||||||
|
estilo atenuado, toggle para ocultar.
|
||||||
|
|
||||||
|
## Definition of Done
|
||||||
|
|
||||||
|
| Escenario | Tipo | Comando / evidencia | Resultado esperado |
|
||||||
|
|---|---|---|---|
|
||||||
|
| Golden: grafo carga el vault | e2e | `GET /api/graph` con el vault osint real | `nodes` ≥ nº de `.md`, `edges` con los wikilinks resueltos; sigma.js los pinta |
|
||||||
|
| Golden: ficha con imágenes | e2e | `GET /api/node/<persona con fotos>` + abrir NodeCard | frontmatter + cuerpo + galería con las imágenes de `attachments/personas/<slug>/` |
|
||||||
|
| Edge: tabla filtrada por tipo | e2e | `GET /api/nodes?tipo=organizacion` | solo nodos de ese tipo, columnas del frontmatter |
|
||||||
|
| Edge: wikilink dangling | unit | nota con `[[Persona-Inexistente]]` | arista marcada dangling / nodo fantasma, sin crash |
|
||||||
|
| Edge: nombre con mayúsculas/acentos | unit | wikilink `[[María del Mar]]` → slug | resuelve a `maria-del-mar-...md` vía `slugify_obsidian_name` |
|
||||||
|
| Error: path traversal en attachment | e2e | `GET /api/attachment?path=../../etc/passwd` | 403/404, jamás sirve fuera del vault |
|
||||||
|
| Error: vault inexistente | e2e | arrancar con `--vault /no/existe` | error claro al arrancar, no 500 silencioso |
|
||||||
|
| Cobertura | audit | `uses_functions` del `app.md` | declara todas las funciones del grupo `obsidian` consumidas |
|
||||||
|
|
||||||
|
Vida útil (cuando se construya): usar la app de verdad sobre el vault osint durante ≥7 días en
|
||||||
|
investigaciones reales; medir que el grafo sigue cargando sin romperse al crecer el vault.
|
||||||
|
|
||||||
|
## Notas
|
||||||
|
|
||||||
|
**Estado actual: solo plan.** No construir todavía — la recopilación de datos en Obsidian
|
||||||
|
continúa; cuando el vault tenga masa crítica de objetivos/relaciones, se arranca con
|
||||||
|
`/new-cpp-app` no aplica (es web): se hace `git init` del sub-repo `dataforge/osint_web` dentro de
|
||||||
|
`projects/osint/apps/osint_web/` antes de limpiar cualquier worktree (regla `apps_subrepo.md`),
|
||||||
|
scaffolding de frontend con el stack Mantine del registry, y backend Python orquestando el grupo
|
||||||
|
`obsidian`.
|
||||||
|
|
||||||
|
Onboarding (para cuando exista): arrancar backend `python server/main.py --vault
|
||||||
|
/home/enmanuel/Obsidian/osint --port 8470` y `pnpm dev` en `frontend/`; abrir
|
||||||
|
`http://127.0.0.1:5173`. Pestañas: Grafo / Tablas / (ficha al click). Solo localhost por los
|
||||||
|
datos sensibles del vault.
|
||||||
|
|
||||||
|
Relación con #0171 (manifest de sub-repos): cuando esta app exista será un hijo del project
|
||||||
|
`osint` y debe entrar en su `subrepos.yaml` para re-clonarse en otros PCs.
|
||||||
@@ -25,6 +25,8 @@ Indice de grupos de capacidades del registry. Cada grupo agrupa >=3 funciones qu
|
|||||||
| [android](android.md) | 37 | Toolbelt Android desde WSL2: adb, emuladores AVD, APK build/install, Capacitor, logcat |
|
| [android](android.md) | 37 | Toolbelt Android desde WSL2: adb, emuladores AVD, APK build/install, Capacitor, logcat |
|
||||||
| [web-proxy](web-proxy.md) | 5 | Captura de trafico HTTP/HTTPS liviana (mitmproxy): proxy con rotacion, navegador proxeado, consulta de capturas, tee del SSE de claude. Alternativa ligera a ZAP/Burp |
|
| [web-proxy](web-proxy.md) | 5 | Captura de trafico HTTP/HTTPS liviana (mitmproxy): proxy con rotacion, navegador proxeado, consulta de capturas, tee del SSE de claude. Alternativa ligera a ZAP/Burp |
|
||||||
| [flow-replay](flow-replay.md) | 3 | Guardar un flujo web (login, reiniciar server, formulario) como funcion reproducible: destila un HAR a call specs y lo reproduce sin navegador (HTTP puro), con fallback a chromium headless/visible. Consume las capturas de web-proxy |
|
| [flow-replay](flow-replay.md) | 3 | Guardar un flujo web (login, reiniciar server, formulario) como funcion reproducible: destila un HAR a call specs y lo reproduce sin navegador (HTTP puro), con fallback a chromium headless/visible. Consume las capturas de web-proxy |
|
||||||
|
| [hoppscotch](hoppscotch.md) | 7 | Operar Hoppscotch SELF-HOSTED (docker en selfhost/) via API GraphQL: login (magic link headless via mailpit), CRUD de requests (create/update/delete/list), set_environment (idempotente, resuelve secretos pass:). El agente crea/edita y el humano lo ve en vivo en su GUI (subscriptions). build es helper interno de serializacion. Modo .json local ELIMINADO |
|
||||||
|
| [dav](dav.md) | 9 | Cliente CardDAV/CalDAV (Python, solo stdlib) para Xandikos: parte un .vcf/.ics export de Google en recursos individuales (split puro), extrae/sintetiza UID, sube por HTTP PUT con Basic auth, lista (PROPFIND) y descarga (GET) recursos. Dos pipelines de import (vcf->carddav, ics->caldav). Formaliza la migracion ad-hoc de contactos/calendario |
|
||||||
| [metabase](metabase.md) | 106 | Operar Metabase via API REST: auth, cards, dashboards, collections, snippets, permissions |
|
| [metabase](metabase.md) | 106 | Operar Metabase via API REST: auth, cards, dashboards, collections, snippets, permissions |
|
||||||
| [doctor](doctor.md) | 11 | Diagnostico read-only del registry: artefactos, servicios, drift, funciones huerfanas |
|
| [doctor](doctor.md) | 11 | Diagnostico read-only del registry: artefactos, servicios, drift, funciones huerfanas |
|
||||||
| [notebook](notebook.md) | 5 | Operar Jupyter Lab colaborativo (discover/read/exec/write/kernel) |
|
| [notebook](notebook.md) | 5 | Operar Jupyter Lab colaborativo (discover/read/exec/write/kernel) |
|
||||||
@@ -49,6 +51,13 @@ Indice de grupos de capacidades del registry. Cada grupo agrupa >=3 funciones qu
|
|||||||
| [mesh-3d](mesh-3d.md) | 3 | Carga y upload a GPU de meshes 3D (OBJ, GLB/glTF 2.0): loaders CPU + mesh_gpu_upload OpenGL |
|
| [mesh-3d](mesh-3d.md) | 3 | Carga y upload a GPU de meshes 3D (OBJ, GLB/glTF 2.0): loaders CPU + mesh_gpu_upload OpenGL |
|
||||||
| [terminal-capture](terminal-capture.md) | 6 | Automatizar y capturar el texto de una CLI/TUI interactiva via PTY headless: spawn+input scripteado (one-shot y streaming), render del layout 2D (emulador VT), strip ANSI, delta por prefijo, y parseo de la TUI de claude a datos |
|
| [terminal-capture](terminal-capture.md) | 6 | Automatizar y capturar el texto de una CLI/TUI interactiva via PTY headless: spawn+input scripteado (one-shot y streaming), render del layout 2D (emulador VT), strip ANSI, delta por prefijo, y parseo de la TUI de claude a datos |
|
||||||
| [claude-direct](claude-direct.md) | 3 | Hablar directamente con la API de Anthropic Messages usando el token OAuth de Claude Code (Claude Max): leer token, stream SSE, bucle agentico de tool-use |
|
| [claude-direct](claude-direct.md) | 3 | Hablar directamente con la API de Anthropic Messages usando el token OAuth de Claude Code (Claude Max): leer token, stream SSE, bucle agentico de tool-use |
|
||||||
|
| [obsidian](obsidian.md) | 16 | CRUD headless de vaults y notas Obsidian como Markdown plano (frontmatter YAML + wikilinks): parse/format, read/create/update/delete/list/search notas, list/create vaults, slugify/embeds/resolve, render tabla Markdown + bloques sentinel gestionados. Sin app GUI |
|
||||||
|
| [duckdb](duckdb.md) | 5 | Operar bases DuckDB: open (Go), query read-only segura (Python, tipos JSON-safe), CSV->Parquet, dedup por hash, carga OHLCV. Base del patron BD-fuente-de-verdad + Obsidian-vista (app osint_db) |
|
||||||
|
| [recon](recon.md) | 8 | Reconocimiento de red OSINT: whois, rdap, dns (dig), ping, traceroute, nmap por perfiles. Cada scan se archiva en OSINT (nota vault + tabla DuckDB network_scans) via el sink save_scan_to_osint o el pipeline one-shot recon_osint. Perfiles nmap pesados (full-tcp/vuln/udp-top) en segundo plano. No es framework de explotacion; solo hosts autorizados |
|
||||||
|
| [osint-passive](osint-passive.md) | 8 | Recoleccion OSINT pasiva (fuentes publicas, no intrusiva): EXIF/PDF metadata, whois RDAP, DNS, subdominios crt.sh, guess emails, username enumeration, search dorks |
|
||||||
|
| [osint-enrich](osint-enrich.md) | 3 | Orquestadores de enriquecimiento OSINT: componen osint-passive para aumentar datapoints de personas (emails/usernames/dorks), orgs (whois+dns+subdominios) y metadatos de attachments |
|
||||||
|
| [market-intel](market-intel.md) | 8 | Inteligencia de mercado para captacion de clientes: scrapers de tendencias de productos/nichos (Amazon, Google Trends, TikTok, AliExpress) + precios de competencia, aterrizados en Postgres (pg_insert_rows/pg_apply_sql) y analizados en Metabase. Dispatcher ingest_market_trends invocado por dag_engine. TikTok/AliExpress por HTTP caen (anti-bot); pendiente browser CDP |
|
||||||
|
| [onlyoffice](onlyoffice.md) | 3 | Operar ONLYOFFICE Desktop Editors (binario onlyoffice-desktopeditors) en Linux/X11 desde terminal via instancia aislada (slot HOME=/tmp/oo_<instance>): abrir un archivo en ventana propia, cerrar+reabrir para mostrar datos editados en disco (no hay reload nativo, Issue #2313), y matar el proceso del slot. Solo gestiona la ventana, NO edita ni crea archivos. Requiere X11 + wmctrl + xdotool. No confundir con el Document Server (web/Docker) |
|
||||||
|
|
||||||
## Como anadir grupo
|
## Como anadir grupo
|
||||||
|
|
||||||
|
|||||||
@@ -0,0 +1,106 @@
|
|||||||
|
# dav — Cliente CardDAV/CalDAV (Python, solo stdlib)
|
||||||
|
|
||||||
|
Grupo de capacidad para operar un servidor **CardDAV/CalDAV** (Xandikos, git-backed,
|
||||||
|
en el VPS `magnus`) desde Python sin dependencias externas. Cubre el flujo de
|
||||||
|
**migracion**: partir un export de Google (un `.vcf` con N contactos, un `.ics` con
|
||||||
|
N eventos) en recursos individuales y subirlos uno a uno por HTTP PUT con Basic auth.
|
||||||
|
Tambien listar y descargar recursos para verificar o hacer backup.
|
||||||
|
|
||||||
|
Formaliza el flujo ad-hoc (heredocs) que migro 820 contactos + 98 eventos a Xandikos
|
||||||
|
(regla `function_growth_and_self_docs`: una composicion repetida >2 veces se promueve
|
||||||
|
a funciones/pipelines del registry).
|
||||||
|
|
||||||
|
## Restriccion de diseno
|
||||||
|
|
||||||
|
**Solo stdlib** (`urllib.request`, `re`, `hashlib`, `base64`, `ssl`). Sin `requests`,
|
||||||
|
`caldav` ni `vobject`. El header `Authorization: Basic base64(user:pass)` se construye
|
||||||
|
a mano. `verify_tls=True` por defecto. Coherente con el grupo `osint-passive` (sin deps).
|
||||||
|
|
||||||
|
## Funciones
|
||||||
|
|
||||||
|
| ID | Firma corta | Que hace | Purity |
|
||||||
|
|---|---|---|---|
|
||||||
|
| `split_vcards_py_infra` | `split_vcards(vcf_text) -> list` | Parte un `.vcf` en VCARDs individuales | pure |
|
||||||
|
| `split_vevents_to_vcalendars_py_infra` | `split_vevents_to_vcalendars(ics_text, prodid?) -> list` | Parte un VCALENDAR con N VEVENT en N VCALENDARs autonomos (replica VTIMEZONE) | pure |
|
||||||
|
| `extract_or_make_uid_py_infra` | `extract_or_make_uid(text, prefix?) -> str` | Extrae el `UID:` o sintetiza `<prefix><md5[:16]>` determinista | pure |
|
||||||
|
| `carddav_put_vcard_py_infra` | `carddav_put_vcard(base_url, user, pw, coll, uid, vcard) -> dict` | PUT de un VCARD (`.vcf`, `text/vcard`) | impure |
|
||||||
|
| `caldav_put_event_py_infra` | `caldav_put_event(base_url, user, pw, coll, uid, vcal) -> dict` | PUT de un VCALENDAR (`.ics`, `text/calendar`) | impure |
|
||||||
|
| `dav_list_resources_py_infra` | `dav_list_resources(base_url, user, pw, coll) -> dict` | PROPFIND Depth:1 -> lista de `{href, etag}` | impure |
|
||||||
|
| `dav_get_resource_py_infra` | `dav_get_resource(base_url, user, pw, href) -> dict` | GET de un recurso -> texto VCARD/VCALENDAR | impure |
|
||||||
|
| `dav_make_calendar_py_infra` | `dav_make_calendar(base_url, user, pw, calendar_home, slug, name?, color?, desc?) -> dict` | MKCALENDAR + PROPPATCH: crea una coleccion de calendario (agenda) nueva | impure |
|
||||||
|
| `dav_make_addressbook_py_infra` | `dav_make_addressbook(base_url, user, pw, contacts_home, slug, name?, desc?) -> dict` | Extended MKCOL: crea una coleccion CardDAV (libreta/agenda de contactos) nueva | impure |
|
||||||
|
| `dav_list_addressbooks_py_infra` | `dav_list_addressbooks(base_url, user, pw, contacts_home) -> dict` | PROPFIND Depth:1: lista las libretas CardDAV del contacts-home con nombre y descripcion | impure |
|
||||||
|
| `build_vcard_py_core` | `build_vcard(contact: dict) -> str` | Serializa un contacto a VCARD 3.0 MULTI-VALOR (N TEL/EMAIL/ADR + X-OSINT-*); pura | pure |
|
||||||
|
| `expand_rrule_py_infra` | `expand_rrule(dtstart_ical, rrule, range_start, range_end, all_day?) -> list` | Expande una RRULE iCalendar a las fechas de cada ocurrencia dentro de un rango | pure |
|
||||||
|
| `import_vcf_to_carddav_py_pipelines` | `import_vcf_to_carddav(vcf_path, base_url, user, pw, coll) -> dict` | Pipeline: .vcf -> split -> uid -> PUT por tarjeta | impure |
|
||||||
|
| `import_ics_to_caldav_py_pipelines` | `import_ics_to_caldav(ics_path, base_url, user, pw, coll) -> dict` | Pipeline: .ics -> split -> uid -> PUT por evento | impure |
|
||||||
|
|
||||||
|
## Sistema real (para los ejemplos)
|
||||||
|
|
||||||
|
- Servidor: **Xandikos** en `https://dav-eedeb681c4ab89ab8e444ac9.organic-machine.com`, Basic auth, usuario `enmanuel`.
|
||||||
|
- Password: `pass dav/xandikos-enmanuel` (primera linea). Resolver con `pass_get_secret_py_infra`, NUNCA hardcodear.
|
||||||
|
- Principal: `/enmanuel/`. Colecciones:
|
||||||
|
- CardDAV: `/enmanuel/contacts/addressbook/`
|
||||||
|
- CalDAV: `/enmanuel/calendars/calendar/`
|
||||||
|
|
||||||
|
## Ejemplo canonico end-to-end
|
||||||
|
|
||||||
|
Importar un `.vcf` exportado de Google a Xandikos, leyendo la password de `pass`:
|
||||||
|
|
||||||
|
```python
|
||||||
|
import sys
|
||||||
|
sys.path.insert(0, "python/functions")
|
||||||
|
from infra.pass_get_secret import pass_get_secret
|
||||||
|
from pipelines.import_vcf_to_carddav import import_vcf_to_carddav
|
||||||
|
|
||||||
|
BASE = "https://dav-eedeb681c4ab89ab8e444ac9.organic-machine.com"
|
||||||
|
pw = pass_get_secret("dav/xandikos-enmanuel")["value"] # NO logear
|
||||||
|
|
||||||
|
summary = import_vcf_to_carddav(
|
||||||
|
vcf_path="/home/enmanuel/Descargas/contacts.vcf",
|
||||||
|
base_url=BASE,
|
||||||
|
username="enmanuel",
|
||||||
|
password=pw,
|
||||||
|
collection_path="/enmanuel/contacts/addressbook/",
|
||||||
|
)
|
||||||
|
print(summary["ok"], summary["fail"], summary["total"]) # 820 0 820
|
||||||
|
```
|
||||||
|
|
||||||
|
Verificar el resultado listando la coleccion:
|
||||||
|
|
||||||
|
```python
|
||||||
|
from infra.dav_list_resources import dav_list_resources
|
||||||
|
res = dav_list_resources(BASE, "enmanuel", pw, "/enmanuel/contacts/addressbook/")
|
||||||
|
print(res["status"], len(res["resources"])) # ok 820
|
||||||
|
```
|
||||||
|
|
||||||
|
El calendario es analogo con `import_ics_to_caldav` + `/enmanuel/calendars/calendar/`.
|
||||||
|
|
||||||
|
Desde la CLI del registry (resuelve la pass como variable, no la pongas en claro):
|
||||||
|
|
||||||
|
```bash
|
||||||
|
PW=$(pass show dav/xandikos-enmanuel | head -n1)
|
||||||
|
./fn run import_vcf_to_carddav /home/enmanuel/Descargas/contacts.vcf \
|
||||||
|
https://dav-eedeb681c4ab89ab8e444ac9.organic-machine.com \
|
||||||
|
enmanuel "$PW" /enmanuel/contacts/addressbook/
|
||||||
|
```
|
||||||
|
|
||||||
|
## Fronteras
|
||||||
|
|
||||||
|
- **No descubre el principal ni las colecciones**: hay que conocer los paths
|
||||||
|
(`/enmanuel/contacts/addressbook/`, etc.). No implementa `current-user-principal`
|
||||||
|
ni `addressbook-home-set` discovery.
|
||||||
|
- **No hace sync incremental** real: `dav_list_resources` devuelve etags pero no
|
||||||
|
hay logica de diff/merge. Re-importar es idempotente por UID (sobrescribe), no
|
||||||
|
incremental.
|
||||||
|
- **No parsea campos VCARD/VEVENT**: trata cada componente como texto opaco. Para
|
||||||
|
transformar contenido (renombrar, deduplicar por nombre) usa otra herramienta.
|
||||||
|
- **Solo VEVENT** en calendario: VTODO/VJOURNAL se ignoran al partir el `.ics`.
|
||||||
|
- **Escrituras irreversibles**: los PUT sobrescriben en el servidor. Idempotente
|
||||||
|
por UID pero no hay confirmacion previa; valida el `.vcf`/`.ics` antes de importar.
|
||||||
|
|
||||||
|
## Prerequisitos
|
||||||
|
|
||||||
|
- `pass` configurado con la entrada `dav/xandikos-enmanuel`.
|
||||||
|
- Conectividad TLS al endpoint publico (`verify_tls=True`).
|
||||||
|
- Python del registry: `python/.venv/bin/python3`.
|
||||||
@@ -0,0 +1,57 @@
|
|||||||
|
# Capability: duckdb
|
||||||
|
|
||||||
|
Operar bases de datos DuckDB desde el registry: abrir/crear bases, consultas read-only seguras, conversion CSV -> Parquet, deduplicacion por hash y carga de series temporales. DuckDB es el motor analitico embebido del ecosistema (OLAP local, archivos `.duckdb`, lectura directa de CSV/Parquet/JSON).
|
||||||
|
|
||||||
|
Pieza central del patron **BD como fuente de verdad + Obsidian como vista** (project `osint`): la app `osint_db` posee la DuckDB maestra y este grupo aporta las primitivas de acceso.
|
||||||
|
|
||||||
|
## Funciones
|
||||||
|
|
||||||
|
| ID | Firma | Que hace |
|
||||||
|
|---|---|---|
|
||||||
|
| `duckdb_open_go_infra` | `DuckDBOpen(path string) (*sql.DB, error)` | Abre (o crea) una base DuckDB desde Go. Path vacio o `:memory:` abre en memoria. |
|
||||||
|
| `duckdb_query_readonly_py_infra` | `duckdb_query_readonly(db_path, sql, params=None, max_rows=10000) -> dict` | Consulta read-only segura: conexion `read_only=True`, params posicionales `?`, filas como `list[dict]` con tipos normalizados a JSON (date/datetime -> isoformat, Decimal -> float, bytes -> base64). Devuelve `{status, columns, rows, row_count, truncated}` sin lanzar. |
|
||||||
|
| `duckdb_execute_py_infra` | `duckdb_execute(db_path, sql, params=None) -> dict` | Ejecuta UNA sentencia de escritura (INSERT/UPDATE/DELETE/DDL) en conexion read-write, commit, devuelve `{status, rowcount}` sin lanzar. Primitivo de escritura del grupo (complementa a `duckdb_query_readonly`). |
|
||||||
|
| `duckdb_upsert_py_infra` | `duckdb_upsert(db_path, table, rows, key_cols, update_cols=None) -> dict` | UPSERT idempotente `INSERT ... ON CONFLICT (key_cols) DO UPDATE SET ...` actualizando SOLO `update_cols`. Excluir columnas de `update_cols` permite que un re-upsert NO las pise (ownership selectivo: la DB es la verdad). Devuelve `{status, inserted, updated}`. |
|
||||||
|
| `csv_to_parquet_duckdb_py_core` | `csv_to_parquet_duckdb(csv_path, parquet_path, column_casts=None, overwrite=False) -> bool` | Convierte CSV -> Parquet con `read_csv_auto`. `column_casts` fuerza tipos por columna. No reescribe si el parquet existe y `overwrite=False`. |
|
||||||
|
| `dedup_duckdb_table_by_hash_py_pipelines` | `dedup_duckdb_table_by_hash(duckdb_path, table, exclude_cols=None) -> dict` | Pipeline: anade columna `row_hash` (md5 de columnas de datos) idempotentemente y borra filas duplicadas conservando la primera insercion. |
|
||||||
|
| `load_ohlcv_from_duckdb_go_finance` | `LoadOHLCVFromDuckDB(dbPath, query string) ([][]float64, error)` | Carga datos OHLCV ejecutando una query SQL sobre una base DuckDB (consumo desde apps Go de finanzas). |
|
||||||
|
|
||||||
|
## Ejemplo canonico
|
||||||
|
|
||||||
|
Consulta read-only desde cualquier sesion (la conexion se abre `read_only=True` y se cierra siempre):
|
||||||
|
|
||||||
|
```bash
|
||||||
|
cd /home/enmanuel/fn_registry
|
||||||
|
python/.venv/bin/python3 - <<'PYEOF'
|
||||||
|
import sys
|
||||||
|
sys.path.insert(0, "python/functions")
|
||||||
|
from infra import duckdb_query_readonly
|
||||||
|
|
||||||
|
res = duckdb_query_readonly(
|
||||||
|
"projects/osint/apps/osint_db/data/osint.duckdb",
|
||||||
|
"SELECT contexto, COUNT(*) AS n FROM persons GROUP BY contexto ORDER BY n DESC",
|
||||||
|
max_rows=50,
|
||||||
|
)
|
||||||
|
print(res["status"], res["row_count"])
|
||||||
|
for row in res["rows"]:
|
||||||
|
print(row)
|
||||||
|
PYEOF
|
||||||
|
```
|
||||||
|
|
||||||
|
Conversion CSV -> Parquet en una linea:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
./fn run csv_to_parquet_duckdb datos.csv datos.parquet
|
||||||
|
```
|
||||||
|
|
||||||
|
## Gotchas del grupo
|
||||||
|
|
||||||
|
- **Single-writer**: DuckDB permite UN solo proceso escritor por archivo. Si un service (ej. `osint_db`) posee la base, el resto de procesos deben leer con `read_only=True` (`duckdb_query_readonly` ya lo hace) o pasar por la API HTTP del service. Las funciones de escritura (`duckdb_execute`, `duckdb_upsert`) abren en read-write y SOLO debe usarlas el proceso dueño de la base (dentro de su write lock), nunca un cliente concurrente.
|
||||||
|
- **Version del motor**: el formato de archivo puede cambiar entre versiones mayores de DuckDB. El venv del registry lleva `duckdb` 1.5.x; no mezclar con CLIs/WASM antiguos sobre el mismo archivo.
|
||||||
|
- `read_only=True` exige que el archivo exista — no crea bases nuevas.
|
||||||
|
|
||||||
|
## Fronteras
|
||||||
|
|
||||||
|
- NO cubre SQLite (`sqlite_open_go_infra` y el grupo de operations.db van aparte).
|
||||||
|
- NO cubre el render de resultados a Markdown/notas — eso es `render_markdown_table_py_core` + `upsert_sentinel_block_py_core` (grupo `obsidian`).
|
||||||
|
- El analisis exploratorio pesado (notebooks) vive en `analysis/` con sus propios venvs.
|
||||||
@@ -0,0 +1,83 @@
|
|||||||
|
# Capability group: `hoppscotch`
|
||||||
|
|
||||||
|
Operar una instancia **self-hosted de Hoppscotch** (consola de APIs, alternativa open-source a
|
||||||
|
Postman) desde el registry, vía su **API GraphQL**. El agente crea/edita requests, colecciones y
|
||||||
|
environments por la API; el humano los ve **en vivo** en su GUI (subscriptions = hot-reload real).
|
||||||
|
Las requests viven en la base de datos del self-host (Postgres), compartida entre el agente y la GUI.
|
||||||
|
|
||||||
|
Este es el **flujo canónico**. El antiguo modo "archivo `.json` local" (funciones
|
||||||
|
`parse_*` / `run_*` / `add_hoppscotch_request`) **fue eliminado**: escribía un `.json` en disco que
|
||||||
|
NO subía al workspace, así que el humano no lo veía en la GUI. No lo reintroduzcas.
|
||||||
|
|
||||||
|
## Stack self-host
|
||||||
|
|
||||||
|
Vive en `projects/web_scraping/hoppscotch/selfhost/` (docker compose: AIO + Postgres + mailpit).
|
||||||
|
|
||||||
|
| Servicio | URL | Para qué |
|
||||||
|
|---|---|---|
|
||||||
|
| App (cliente) | `http://localhost:3009` | la GUI donde el humano usa las colecciones (instalable como PWA) |
|
||||||
|
| Admin dashboard | `http://localhost:3100` | gestión (usuarios, config) |
|
||||||
|
| Backend GraphQL | `http://localhost:3170/graphql` | la API que usan las funciones |
|
||||||
|
| Mailpit | `http://localhost:8025` | captura el magic link del login (SMTP de pruebas, sin correo real) |
|
||||||
|
|
||||||
|
Levantar: `cd selfhost && docker compose up -d`. Team de trabajo: **"registry"**. Cuenta: `admin@example.com`.
|
||||||
|
|
||||||
|
## Funciones
|
||||||
|
|
||||||
|
| ID | Firma corta | Qué hace |
|
||||||
|
|---|---|---|
|
||||||
|
| `hoppscotch_login_py_infra` | `(email, *, backend_url, mailpit_url) -> {access_token,...}` | login por magic link headless (lee el link de mailpit) → JWT |
|
||||||
|
| `hoppscotch_create_request_py_infra` | `(collection_id, method, url, *, title, headers, body, body_type, team_id, access_token) -> dict` | crea una request en una colección de la team |
|
||||||
|
| `hoppscotch_update_request_py_infra` | `(request_id, method, url, *, title, headers, body, body_type, access_token) -> dict` | actualiza una request |
|
||||||
|
| `hoppscotch_delete_request_py_infra` | `(request_id, *, access_token) -> dict` | borra una request |
|
||||||
|
| `hoppscotch_list_requests_py_infra` | `(collection_id, *, access_token) -> {requests:[...]}` | lista las requests de una colección |
|
||||||
|
| `hoppscotch_set_environment_py_infra` | `(team_id, name, variables, *, access_token) -> dict` | crea/actualiza (idempotente) el environment de la team; resuelve secretos `pass:` |
|
||||||
|
| `build_hoppscotch_collection_py_infra` | `(calls, *, name, request_names) -> dict` | **helper interno** de create/update: serializa call specs al formato HoppRESTRequest. NO para escribir `.json` a mano |
|
||||||
|
| `pass_get_secret_py_infra` | `(path, *, line) -> {value}` | lee un secreto de `pass` (lo consume `set_environment` para no hardcodear keys) |
|
||||||
|
|
||||||
|
`access_token` se pasa como **cookie**, no header `Authorization`. Caduca a 24h → re-login con `hoppscotch_login`.
|
||||||
|
|
||||||
|
## Ejemplo canónico (end-to-end)
|
||||||
|
|
||||||
|
```python
|
||||||
|
import sys, os
|
||||||
|
sys.path.insert(0, os.path.join(os.path.expanduser("~/fn_registry"), "python", "functions"))
|
||||||
|
from infra.hoppscotch_login import hoppscotch_login
|
||||||
|
from infra.hoppscotch_create_request import hoppscotch_create_request
|
||||||
|
from infra.hoppscotch_set_environment import hoppscotch_set_environment
|
||||||
|
|
||||||
|
TEAM = "cmq8kn0v500030xls1nvminjy" # team "registry"
|
||||||
|
COLL = "cmq8knppc00040xlskt4ist27" # colección registry_api (de hoppscotch_list/DB)
|
||||||
|
|
||||||
|
tok = hoppscotch_login("admin@example.com")["access_token"]
|
||||||
|
|
||||||
|
# 1. Variables del workspace (secreto resuelto desde pass, no hardcodeado)
|
||||||
|
hoppscotch_set_environment(TEAM, "registry", [
|
||||||
|
{"key": "baseURL", "value": "https://registry.organic-machine.com", "secret": False},
|
||||||
|
{"key": "api_key", "value": "pass:apis/registry", "secret": True}, # pass: -> pass_get_secret
|
||||||
|
], access_token=tok)
|
||||||
|
|
||||||
|
# 2. Crear una request → aparece EN VIVO en la GUI del humano (subscriptions)
|
||||||
|
hoppscotch_create_request(
|
||||||
|
COLL, "GET", "<<baseURL>>/api/status",
|
||||||
|
title="status", headers={"Accept": "application/json"},
|
||||||
|
team_id=TEAM, access_token=tok,
|
||||||
|
)
|
||||||
|
```
|
||||||
|
|
||||||
|
## Fronteras (qué NO cubre)
|
||||||
|
|
||||||
|
- **No es modo archivo**: no escribe colecciones `.json` locales como fuente. Las requests viven en el
|
||||||
|
Postgres del self-host. (Los `.json` en `collections/` son solo respaldo/semilla importable.)
|
||||||
|
- **No automatiza la GUI**: opera por la API; la GUI la mira el humano.
|
||||||
|
- **No gestiona usuarios/teams del dashboard**: eso es el admin dashboard (`:3100`).
|
||||||
|
- **No ejecuta los scripts pre/post-request JS** de Hoppscotch.
|
||||||
|
|
||||||
|
## Gotchas
|
||||||
|
|
||||||
|
- `access_token` como **cookie** (`cookies={"access_token": tok}`), no `Authorization`. 24h de vida.
|
||||||
|
- `createRequestInCollection` de esta instancia **exige `team_id`** en el input (no solo el collectionID).
|
||||||
|
- Variables `<<var>>` se resuelven con el environment de la team (subscriptions las propagan a la GUI).
|
||||||
|
- Secretos: usa `value="pass:<ruta>"` en `set_environment` → se resuelve de `pass`, nunca se hardcodea
|
||||||
|
ni se logea en crudo.
|
||||||
|
- El secreto viaja en claro al backend local por GraphQL — es local (`127.0.0.1`), aceptable.
|
||||||
@@ -0,0 +1,54 @@
|
|||||||
|
# market-intel
|
||||||
|
|
||||||
|
Inteligencia de mercado para captación de clientes: scrapers de señales de demanda y
|
||||||
|
tendencias de productos/nichos desde varias fuentes públicas, más vigilancia de precios de
|
||||||
|
la competencia, aterrizados en Postgres y analizados con Metabase. Scheduling con
|
||||||
|
`dag_engine`. Origen: proyecto `captacion_clientes`.
|
||||||
|
|
||||||
|
## Funciones
|
||||||
|
|
||||||
|
| ID | Firma corta | Qué hace |
|
||||||
|
|---|---|---|
|
||||||
|
| `scrape_amazon_bestsellers_py_datascience` | `(marketplace, categories, list_type, max_items)` | Amazon Best Sellers + Movers & Shakers (ranking real de demanda). HTTP, funciona. |
|
||||||
|
| `scrape_google_trends_py_datascience` | `(keywords, geo, timeframe, include_related)` | Interés de búsqueda (0-100) + rising/top via pytrends. Backoff ante 429. |
|
||||||
|
| `scrape_tiktok_creative_py_datascience` | `(country, kind, limit, period)` | TikTok Creative Center (hashtags/songs/creators). **Bloqueado por anti-bot vía HTTP**; pendiente browser CDP. |
|
||||||
|
| `scrape_aliexpress_trending_py_datascience` | `(query, category, limit, ship_to)` | Productos populares AliExpress (orders/rating). **Bloqueado por captcha vía HTTP**; pendiente browser CDP. |
|
||||||
|
| `scrape_competitor_prices_py_datascience` | `(targets) -> list[dict]` | Precio actual de una lista de URLs de competidores (cascada: selector → JSON-LD → meta → heurística). |
|
||||||
|
| `pg_insert_rows_py_infra` | `(dsn, table, rows, add_snapshot_date=True)` | Insert append-only por lote en Postgres (execute_values parametrizado, añade snapshot_date). |
|
||||||
|
| `pg_apply_sql_py_infra` | `(dsn, sql_path) -> int` | Aplica un `.sql` de migración a Postgres (idempotente con IF NOT EXISTS). |
|
||||||
|
| `ingest_market_trends_py_pipelines` | `(source)` | Dispatcher: scrapea una fuente y la aterriza en su tabla. Lo invoca `dag_engine`. |
|
||||||
|
|
||||||
|
## Ejemplo canónico (end-to-end)
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# 1. (una vez) Stack Metabase + Postgres en Docker
|
||||||
|
fn run init_metabase_go_infra --project captacion --metabase-port 3030 --pg-port 5433 \
|
||||||
|
--pg-user captacion --pg-password "$(pass show captacion/postgres | head -1)"
|
||||||
|
docker exec captacion-postgres psql -U captacion -d metabase -c "CREATE DATABASE trends OWNER captacion"
|
||||||
|
|
||||||
|
# 2. (una vez) Aplicar el schema
|
||||||
|
python3 -c "import sys; sys.path.insert(0,'python/functions'); from infra import pg_apply_sql; \
|
||||||
|
pg_apply_sql('postgresql://captacion:PW@localhost:5433/trends', 'projects/captacion_clientes/db/migrations/001_schema.sql')"
|
||||||
|
|
||||||
|
# 3. Ingesta una fuente (manual o vía dag_engine)
|
||||||
|
fn run ingest_market_trends_py_pipelines amazon
|
||||||
|
fn run ingest_market_trends_py_pipelines google_trends
|
||||||
|
|
||||||
|
# 4. dag_engine lo hace solo: dags market-intel-daily (06:30) y competitor-prices-hourly
|
||||||
|
```
|
||||||
|
|
||||||
|
## Fronteras
|
||||||
|
|
||||||
|
- NO hace explotación ni bypass agresivo de anti-bot: TikTok/AliExpress por HTTP-directo
|
||||||
|
caen desde datacenter; la vía robusta es el browser MCP/CDP (grupo `navegator`/`web-proxy`,
|
||||||
|
doctrina `flow_replay.md`), aún no implementada para estas dos fuentes.
|
||||||
|
- NO es un grupo de visualización: el análisis vive en Metabase (grupo `metabase`).
|
||||||
|
- NO gestiona el scheduling: eso es `dag_engine` (grupo `scheduler`).
|
||||||
|
- El DSN de Postgres y credenciales NO se hardcodean: van en `pass`/`.env` del proyecto.
|
||||||
|
|
||||||
|
## Notas
|
||||||
|
|
||||||
|
- Las tablas de `trends` son append-only particionadas por `snapshot_date` — pensadas para
|
||||||
|
series temporales en Metabase (qué tendencia sube/baja). No correr en bucle apretado.
|
||||||
|
- `competitor_prices` se nutre de la tabla `competitor_targets` (el usuario inserta los
|
||||||
|
objetivos a vigilar: competidor + product_key + URL).
|
||||||
@@ -0,0 +1,91 @@
|
|||||||
|
# Capability: obsidian
|
||||||
|
|
||||||
|
CRUD headless de vaults y notas de Obsidian, tratadas como Markdown plano con frontmatter YAML y wikilinks `[[...]]`. El nucleo del grupo manipula los archivos `.md` directamente en disco (no necesita la app GUI). Un sub-conjunto aparte gestiona la **lista de vaults que la app de escritorio Obsidian conoce** (su config `~/.config/obsidian/obsidian.json` + el URI scheme `obsidian://`): `register_*`, `list_registered_*`, `unregister_*`, `open_obsidian_vault`. Scriptable, rapido, con telemetria del registry.
|
||||||
|
|
||||||
|
Los vaults de Obsidian del usuario viven en `/home/enmanuel/Obsidian/` y estan enlazados como vaults del registry en el project `obsidian` (`projects/obsidian/vaults/`). Ver `projects/obsidian/project.md`.
|
||||||
|
|
||||||
|
## Funciones
|
||||||
|
|
||||||
|
| ID | Firma | Que hace |
|
||||||
|
|---|---|---|
|
||||||
|
| `parse_obsidian_frontmatter_py_obsidian` | `parse_obsidian_frontmatter(content: str) -> {"frontmatter": dict, "body": str}` | **Pure.** Separa el frontmatter YAML (bloque `---` inicial) del cuerpo. Si no hay frontmatter valido devuelve `{}` + el contenido completo. |
|
||||||
|
| `extract_obsidian_wikilinks_py_obsidian` | `extract_obsidian_wikilinks(body: str) -> list` | **Pure.** Extrae los targets de los wikilinks `[[...]]` y embeds `![[...]]`. Normaliza `[[nota\|alias]]`, `[[nota#heading]]`, `[[nota#^block]]` -> `nota`. Dedup preservando orden. |
|
||||||
|
| `format_obsidian_note_py_obsidian` | `format_obsidian_note(frontmatter: dict, body: str) -> str` | **Pure.** Inversa de parse: serializa frontmatter (YAML entre `---`) + body a una nota `.md` completa. |
|
||||||
|
| `read_obsidian_note_py_obsidian` | `read_obsidian_note(path: str) -> dict` | Lee una nota: `{path, frontmatter, body, wikilinks, tags}`. Compone parse + extract. |
|
||||||
|
| `create_obsidian_note_py_obsidian` | `create_obsidian_note(vault_dir, rel_path, body="", frontmatter=None, overwrite=False) -> str` | Crea nota nueva (crea dirs padre, añade `.md`). Error si existe y `overwrite=False`. |
|
||||||
|
| `update_obsidian_note_py_obsidian` | `update_obsidian_note(path, body=None, set_frontmatter=None, append=None) -> str` | Edita nota existente: merge de frontmatter, reemplazo de body, o append al final. |
|
||||||
|
| `delete_obsidian_note_py_obsidian` | `delete_obsidian_note(path: str) -> bool` | Borra una nota (solo archivo, nunca directorio). Error si no existe. |
|
||||||
|
| `list_obsidian_notes_py_obsidian` | `list_obsidian_notes(vault_dir, subfolder="", tag="") -> list` | Lista paths de notas `.md` (recursivo). Excluye `.obsidian/` y `.trash/`. Filtro opcional por tag de frontmatter. |
|
||||||
|
| `search_obsidian_notes_py_obsidian` | `search_obsidian_notes(vault_dir, query, in_body=True, in_frontmatter=True) -> list` | Busca substring (case-insensitive) en las notas. Devuelve `[{path, matches:[{line, text}]}]`. |
|
||||||
|
| `list_obsidian_vaults_py_obsidian` | `list_obsidian_vaults(base_dir: str) -> list` | Lista los vaults (subdirs con `.obsidian/`) bajo `base_dir`. `[{name, path}]`. |
|
||||||
|
| `create_obsidian_vault_py_obsidian` | `create_obsidian_vault(parent_dir, name) -> str` | Crea un vault nuevo: carpeta + `.obsidian/app.json` minimo. Error si ya existe. |
|
||||||
|
| `register_obsidian_vault_py_obsidian` | `register_obsidian_vault(vault_path, open=False, config_path="") -> dict` | Da de alta un vault en la **app** Obsidian (entrada en `~/.config/obsidian/obsidian.json`). Idempotente por path, backup `.bak`, preserva el resto del JSON. NO toca el filesystem del vault. |
|
||||||
|
| `list_registered_obsidian_vaults_py_obsidian` | `list_registered_obsidian_vaults(config_path="") -> list` | Lista los vaults que la **app** Obsidian conoce (de `obsidian.json`), ordenados por path. `[{id, path, open, ts}]`. Distinto de `list_obsidian_vaults` (que escanea el filesystem). |
|
||||||
|
| `unregister_obsidian_vault_py_obsidian` | `unregister_obsidian_vault(vault_ref, config_path="") -> dict` | Quita un vault de la lista de la **app** Obsidian (por id o por path). NO borra la carpeta del vault. Backup `.bak`, preserva el resto del JSON. |
|
||||||
|
| `open_obsidian_vault_py_obsidian` | `open_obsidian_vault(vault, register_if_missing=True, launch=True, config_path="") -> dict` | Abre un vault en la **app** Obsidian via `obsidian://open?vault=<name>` (lanza `xdg-open`). Registra el vault antes si falta. `launch=False` solo construye el URI. |
|
||||||
|
| `slugify_obsidian_name_py_obsidian` | `slugify_obsidian_name(name: str) -> str` | **Pure.** Nombre/titulo -> slug kebab-case estable (translitera acentos, ñ->n). Estabiliza ids de nodo y nombres de archivo. |
|
||||||
|
| `extract_obsidian_embeds_py_obsidian` | `extract_obsidian_embeds(body: str) -> list` | **Pure.** Solo los embeds `![[...]]` (attachments incrustados), ignorando wikilinks normales. Dedup preservando orden. |
|
||||||
|
| `resolve_obsidian_embed_py_obsidian` | `resolve_obsidian_embed(vault_dir, embed_name) -> str` | Resuelve un embed `![[foto.jpg]]` a su path absoluto real (busca por basename unico en el vault). Cadena vacia si no existe. |
|
||||||
|
| `build_obsidian_graph_py_obsidian` | `build_obsidian_graph(vault_dir, include_dangling=True) -> {"nodes":[...], "edges":[...]}` | **Grafo agregado** del vault: cada nota = nodo tipado (`id`=slug, `label`, `tipo`, `frontmatter`); cada wikilink `[[...]]` = arista con `kind` por seccion. Wikilinks rotos -> nodos fantasma `dangling`. |
|
||||||
|
| `render_markdown_table_py_core` | `render_markdown_table(rows: list[dict], columns=None, max_rows=0) -> str` | **Pure** (vive en `core`). Lista de dicts -> tabla Markdown GFM. Escapa pipes, saltos de linea -> `<br>`, truncado opcional con pie `... N de M filas`. Base del render BD -> nota. |
|
||||||
|
| `upsert_sentinel_block_py_core` | `upsert_sentinel_block(text, block_id, content, marker="osintdb") -> str` | **Pure** (vive en `core`). Inserta o reemplaza un bloque gestionado entre sentinels `<!-- marker:begin id=X -->` / `<!-- marker:end id=X -->` dentro del body de una nota. Idempotente; ValueError si el bloque esta corrupto. |
|
||||||
|
|
||||||
|
## Ejemplo canonico
|
||||||
|
|
||||||
|
Componer varias funciones del grupo se hace por heredoc importando del registry (las funciones se importan, no se reescriben):
|
||||||
|
|
||||||
|
```bash
|
||||||
|
cd /home/enmanuel/fn_registry
|
||||||
|
python/.venv/bin/python3 - <<'PYEOF'
|
||||||
|
import sys
|
||||||
|
sys.path.insert(0, "python/functions")
|
||||||
|
from obsidian import (
|
||||||
|
list_obsidian_vaults, list_obsidian_notes, search_obsidian_notes,
|
||||||
|
create_obsidian_note, read_obsidian_note, update_obsidian_note, delete_obsidian_note,
|
||||||
|
)
|
||||||
|
|
||||||
|
# 1. Descubrir vaults del usuario
|
||||||
|
vaults = list_obsidian_vaults("/home/enmanuel/Obsidian")
|
||||||
|
print("vaults:", [v["name"] for v in vaults])
|
||||||
|
|
||||||
|
# 2. Listar y buscar notas en un vault
|
||||||
|
finanzas = "/home/enmanuel/Obsidian/Finanzas"
|
||||||
|
print("notas:", len(list_obsidian_notes(finanzas)))
|
||||||
|
print("hits:", [h["path"] for h in search_obsidian_notes(finanzas, "presupuesto")][:5])
|
||||||
|
|
||||||
|
# 3. CRUD de una nota (crear -> leer -> editar -> borrar)
|
||||||
|
p = create_obsidian_note(finanzas, "inbox/idea_x", body="Primera linea",
|
||||||
|
frontmatter={"tags": ["inbox"], "created": "2026-06-09"})
|
||||||
|
note = read_obsidian_note(p)
|
||||||
|
print("creada:", note["path"], note["frontmatter"], note["wikilinks"])
|
||||||
|
update_obsidian_note(p, set_frontmatter={"status": "done"}, append="Ver [[Otra Nota]]")
|
||||||
|
delete_obsidian_note(p)
|
||||||
|
PYEOF
|
||||||
|
```
|
||||||
|
|
||||||
|
Para una sola operacion con un id conocido, `fn run` tambien sirve:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
./fn run list_obsidian_vaults /home/enmanuel/Obsidian
|
||||||
|
./fn run list_obsidian_notes /home/enmanuel/Obsidian/Finanzas
|
||||||
|
```
|
||||||
|
|
||||||
|
## Cuando usar el grupo
|
||||||
|
|
||||||
|
- Crear/editar/leer notas de cualquier vault de Obsidian desde un agente o script, sin abrir la app.
|
||||||
|
- Buscar o listar notas por contenido o tag (ingesta, migracion, reporting sobre el vault).
|
||||||
|
- Crear vaults nuevos o inventariar los existentes.
|
||||||
|
|
||||||
|
## Fronteras (que NO cubre)
|
||||||
|
|
||||||
|
- **El CRUD de notas no habla con la app GUI** (no abre notas en la interfaz ni dispara plugins). Si la app esta abierta, escribir en disco puede chocar con sus locks/cache — cerrar la app o refrescar manualmente. La unica interaccion con la app es la **gestion de su lista de vaults** (`register_*`/`unregister_*`/`list_registered_*` sobre `obsidian.json`) y `open_obsidian_vault` (lanza el URI `obsidian://`); estas no editan notas ni renderizan nada.
|
||||||
|
- **Single-instance gotcha**: Obsidian cachea su `obsidian.json` en memoria al arrancar. Registrar/desregistrar un vault con la app abierta no se reflejara hasta reiniciarla; `open_obsidian_vault` sobre un vault recien registrado puede dar "unable to find a vault" hasta el reinicio.
|
||||||
|
- **No resuelve wikilinks a paths** automaticamente (devuelve los targets crudos). Resolver `[[nota]]` -> archivo real es responsabilidad del caller (busqueda por nombre en el vault).
|
||||||
|
- **No renderiza Markdown** ni evalua Dataview/templating. Trata las notas como texto + frontmatter.
|
||||||
|
- **El grafo agregado** del vault ya lo cubre `build_obsidian_graph_py_obsidian` (nodos tipados + aristas con `kind` + nodos fantasma `dangling`). Es la base de la vista grafo (sigma.js) de la app `osint_web`. Lo que sigue fuera del grupo es el *layout* visual del grafo (force-directed) — eso vive en el frontend.
|
||||||
|
|
||||||
|
## Gotchas
|
||||||
|
|
||||||
|
- Vaults grandes son caros: `NotasDeObsidian` pesa ~554M. `list_obsidian_notes` / `search_obsidian_notes` recorren todo el arbol — filtra por `subfolder` cuando puedas.
|
||||||
|
- `delete_obsidian_note` borra de verdad (no manda a `.trash/`). Para acciones destructivas masivas, listar primero y confirmar.
|
||||||
|
- El frontmatter `tags` puede venir como lista o como CSV string; `read_obsidian_note` lo normaliza a lista.
|
||||||
@@ -0,0 +1,79 @@
|
|||||||
|
# Capability group: onlyoffice
|
||||||
|
|
||||||
|
Operar **ONLYOFFICE Desktop Editors** (binario `/usr/bin/onlyoffice-desktopeditors`) en Linux/X11 desde terminal, gestionando la **ventana** de los archivos sin perturbar la instancia personal del usuario.
|
||||||
|
|
||||||
|
Este grupo NO es el ONLYOFFICE **Document Server** (web/Docker) — para eso ver `start_documentserver_bash_infra`, `documentserver_health_go_infra`, `onlyoffice_command_service_go_infra` y compañia. Este grupo es el editor de **escritorio**.
|
||||||
|
|
||||||
|
## Convencion de instancia aislada (slot)
|
||||||
|
|
||||||
|
ONLYOFFICE Desktop es **single-instance por usuario**: un segundo `onlyoffice-desktopeditors <file>` se reenvia a la instancia viva y abre el archivo como PESTAÑA en su ventana, no como ventana nueva. El lock single-instance NO se rompe con `XDG_CONFIG_HOME`, pero SI se rompe lanzando con `HOME` y `XDG_RUNTIME_DIR` propios.
|
||||||
|
|
||||||
|
Por eso las 3 funciones comparten un "slot" nombrado por `instance` (string, default `demo`):
|
||||||
|
|
||||||
|
```
|
||||||
|
HOME=/tmp/oo_<instance>
|
||||||
|
XDG_RUNTIME_DIR=/tmp/oo_<instance>_run (mkdir -p + chmod 700)
|
||||||
|
XDG_CONFIG_HOME=/tmp/oo_<instance>/.config
|
||||||
|
```
|
||||||
|
|
||||||
|
Lanzamiento canonico (identico en open y reload):
|
||||||
|
|
||||||
|
```bash
|
||||||
|
env HOME=/tmp/oo_<instance> XDG_RUNTIME_DIR=/tmp/oo_<instance>_run \
|
||||||
|
XDG_CONFIG_HOME=/tmp/oo_<instance>/.config \
|
||||||
|
setsid onlyoffice-desktopeditors <file> >/tmp/oo_<instance>.log 2>&1 </dev/null &
|
||||||
|
```
|
||||||
|
|
||||||
|
Usar el MISMO `instance` en todas las operaciones del slot: asi el relaunch reenvia a la instancia aislada viva y reabre rapido en vez de arrancar el motor de cero.
|
||||||
|
|
||||||
|
## Funciones
|
||||||
|
|
||||||
|
| ID | Firma corta | Que hace |
|
||||||
|
|---|---|---|
|
||||||
|
| `open_onlyoffice_file_bash_shell` | `open_onlyoffice_file <file> [instance]` | Abre un archivo existente en el slot aislado; espera la ventana por basename (~25s); JSON con wid/status. Idempotente, NO crea archivos. |
|
||||||
|
| `reload_onlyoffice_file_bash_shell` | `reload_onlyoffice_file <file> [instance]` | **Funcion estrella**: cierra (wmctrl -ic) y reabre el archivo en el slot para mostrar datos editados EN DISCO (ONLYOFFICE no tiene reload nativo, Issue #2313). JSON con wid_old/wid_new/elapsed_s/status. NO edita el archivo. |
|
||||||
|
| `close_onlyoffice_instance_bash_shell` | `close_onlyoffice_instance [instance] [--purge]` | Mata los procesos DesktopEditors del slot (por HOME=/tmp/oo_<instance> en /proc), SIGTERM->SIGKILL; con --purge borra /tmp/oo_<instance>*. JSON con killed_pids/status. |
|
||||||
|
|
||||||
|
## Ejemplo canonico (end-to-end)
|
||||||
|
|
||||||
|
Flujo completo "abrir -> editar el archivo en disco -> recargar la vista -> cerrar", todo sobre un slot aislado `demo` que no toca la instancia personal del usuario:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
cd /home/enmanuel/fn_registry
|
||||||
|
|
||||||
|
# 0. El caller prepara el archivo (esta funcion NO crea archivos)
|
||||||
|
printf 'a,b\n1,2\n' > /tmp/demo_reload.csv
|
||||||
|
|
||||||
|
# 1. Abrir en el slot aislado 'demo' -> ventana propia
|
||||||
|
./fn run open_onlyoffice_file_bash_shell /tmp/demo_reload.csv demo
|
||||||
|
# {"instance":"demo","file":"/tmp/demo_reload.csv","wid":"0x3c00007","pid":12345,"status":"open"}
|
||||||
|
|
||||||
|
# 2. El caller edita el archivo EN DISCO (script, generador, otra herramienta)
|
||||||
|
printf 'a,b\n1,2\n3,4\n5,6\n' > /tmp/demo_reload.csv
|
||||||
|
|
||||||
|
# 3. Recargar la ventana para que muestre los datos nuevos (cierra+reabre)
|
||||||
|
./fn run reload_onlyoffice_file_bash_shell /tmp/demo_reload.csv demo
|
||||||
|
# {"instance":"demo","file":"/tmp/demo_reload.csv","wid_old":"0x3c00007","wid_new":"0x3c0000b","reopened":true,"elapsed_s":4,"status":"reloaded"}
|
||||||
|
|
||||||
|
# 4. Cerrar la instancia aislada y limpiar su estado
|
||||||
|
./fn run close_onlyoffice_instance_bash_shell demo --purge
|
||||||
|
# {"instance":"demo","killed_pids":[12345],"purged":true,"status":"closed"}
|
||||||
|
```
|
||||||
|
|
||||||
|
## Fronteras (que NO hace el grupo)
|
||||||
|
|
||||||
|
- **NO edita ni crea archivos**. Solo gestiona la VENTANA (abrir, cerrar+reabrir, matar proceso). El contenido lo prepara y modifica el caller en disco.
|
||||||
|
- **NO es el Document Server** (web/Docker/JWT/Command Service). Eso es otro conjunto de funciones (`*documentserver*`, `*onlyoffice_jwt*`, `onlyoffice_command_service_*`).
|
||||||
|
- **NO recarga in-place**: ONLYOFFICE Desktop no soporta reload de cambios externos (Issue #2313 abierto). `reload_onlyoffice_file` lo emula con cerrar+reabrir; no hay alternativa "sin parpadeo".
|
||||||
|
- **NO toca la instancia personal del usuario**: todo opera sobre el slot aislado (HOME=/tmp/oo_<instance>). `close` solo mata procesos cuyo HOME es del slot.
|
||||||
|
|
||||||
|
## Prerequisitos
|
||||||
|
|
||||||
|
- Linux con **X11** (o XWayland). En Wayland puro sin XWayland, `xdotool`/`wmctrl` no encuentran la ventana.
|
||||||
|
- Binarios en PATH: `onlyoffice-desktopeditors`, `wmctrl`, `xdotool`. Cada funcion comprueba `command -v` y falla con exit !=0 si falta alguno.
|
||||||
|
|
||||||
|
## Notas
|
||||||
|
|
||||||
|
- Las esperas son **por evento** (`xdotool search` + `read -t`), nunca `sleep` en foreground, para no colgar bajo `fn run` ni tests.
|
||||||
|
- El slot vive en `/tmp` y se pierde al reiniciar el PC (estado desechable). `--purge` lo borra explicitamente.
|
||||||
|
- `wmctrl -ic` puede disparar el dialogo "Guardar cambios" SOLO si se edito dentro de la app con cambios sin guardar; el flujo previsto edita en disco, asi que la ventana no tiene estado pendiente.
|
||||||
@@ -0,0 +1,51 @@
|
|||||||
|
# Capability: osint-enrich
|
||||||
|
|
||||||
|
Orquestadores de enriquecimiento OSINT: componen las funciones atómicas de
|
||||||
|
[osint-passive](osint-passive.md) para aumentar los datapoints de una entidad (persona u
|
||||||
|
organización) del vault `osint` a partir de fuentes públicas. No tocan al objetivo de forma
|
||||||
|
intrusiva. Mismo encuadre dual-use que `osint-passive`: solo investigación autorizada.
|
||||||
|
|
||||||
|
## Funciones
|
||||||
|
|
||||||
|
| ID | Firma | Qué hace |
|
||||||
|
|---|---|---|
|
||||||
|
| `scan_ficha_attachments_metadata_py_cybersecurity` | `scan_ficha_attachments_metadata(attachments_dir) -> dict` | Escanea los attachments de una ficha (imágenes + PDFs), extrae EXIF/PDF metadata y agrega GPS y fechas. |
|
||||||
|
| `enrich_person_passive_py_cybersecurity` | `enrich_person_passive(nombre, apellidos, dominios=None, usernames=None) -> dict` | Candidatos para una persona: emails (guess), username hits, dorks. No verifica ni ejecuta. |
|
||||||
|
| `enrich_org_passive_py_cybersecurity` | `enrich_org_passive(dominio) -> dict` | Perfil pasivo de una org: whois + dns + subdominios. Resiliente a fallo parcial (campo `errors`). |
|
||||||
|
|
||||||
|
## Ejemplo canónico
|
||||||
|
|
||||||
|
```bash
|
||||||
|
cd /home/enmanuel/fn_registry
|
||||||
|
python/.venv/bin/python3 - <<'PYEOF'
|
||||||
|
import sys; sys.path.insert(0, "python/functions")
|
||||||
|
from cybersecurity import (scan_ficha_attachments_metadata,
|
||||||
|
enrich_person_passive, enrich_org_passive)
|
||||||
|
|
||||||
|
# 1. Metadatos de los documentos ya guardados de una persona (datos propios)
|
||||||
|
m = scan_ficha_attachments_metadata(
|
||||||
|
"/home/enmanuel/Obsidian/osint/attachments/personas/enmanuel-gutierrez-perez")
|
||||||
|
print(m["summary"]) # {n_files, n_images, n_pdfs, n_gps_points, n_dates, errors}
|
||||||
|
|
||||||
|
# 2. Candidatos de enriquecimiento de una persona (no toca al objetivo)
|
||||||
|
p = enrich_person_passive("Enmanuel", "Gutierrez Perez",
|
||||||
|
dominios=["gmail.com"], usernames=["enmanuelgp"])
|
||||||
|
print(p["email_candidates"][:5], len(p["dorks"]))
|
||||||
|
|
||||||
|
# 3. Perfil pasivo de una organización por su dominio
|
||||||
|
o = enrich_org_passive("organic-machine.com")
|
||||||
|
print(o["whois"].get("registrar"), o["dns"].get("A"), len(o["subdomains"]), o["errors"])
|
||||||
|
PYEOF
|
||||||
|
```
|
||||||
|
|
||||||
|
## Fronteras
|
||||||
|
|
||||||
|
- Compone solo funciones `osint-passive`. Para activa (port scan, fingerprint) haría falta
|
||||||
|
`osint-active` (no construido).
|
||||||
|
- Devuelve candidatos/datos crudos; **decidir qué escribir en la ficha** (y verificar) es del
|
||||||
|
caller. Encaja con el reporte de `projects/osint/tools/person_datapoints.py`.
|
||||||
|
|
||||||
|
## Gotchas
|
||||||
|
|
||||||
|
- `enrich_org_passive` nunca peta por una fuente lenta (crt.sh): el fallo va a `errors`.
|
||||||
|
- `enrich_person_passive` puede tardar por `enumerate_username_sites` (un request por sitio).
|
||||||
@@ -0,0 +1,64 @@
|
|||||||
|
# Capability: osint-passive
|
||||||
|
|
||||||
|
Recolección OSINT **pasiva**: obtener información sin interactuar de forma intrusiva con el
|
||||||
|
objetivo, usando solo fuentes públicas (DNS público, RDAP, Certificate Transparency, metadatos
|
||||||
|
de documentos propios, servicios de perfil públicos). Pensado para investigación autorizada,
|
||||||
|
due diligence, pentest con permiso y enriquecimiento de las fichas del vault `osint`.
|
||||||
|
|
||||||
|
**Encuadre:** dual-use. Úsese solo contra objetivos propios o con autorización. Las funciones
|
||||||
|
que tocan servicios públicos (`enumerate_username_sites`, `enum_subdomains_crtsh`) dejan una
|
||||||
|
huella mínima (un request a cada servicio); respeta sus rate limits.
|
||||||
|
|
||||||
|
## Funciones
|
||||||
|
|
||||||
|
| ID | Firma | Qué hace |
|
||||||
|
|---|---|---|
|
||||||
|
| `extract_exif_metadata_py_cybersecurity` | `extract_exif_metadata(image_path) -> dict` | EXIF de una imagen (fecha, cámara, software, GPS decimal) vía Pillow. |
|
||||||
|
| `extract_pdf_metadata_py_cybersecurity` | `extract_pdf_metadata(pdf_path) -> dict` | Document Info de un PDF (autor, fechas, software, páginas) vía pypdf. |
|
||||||
|
| `guess_email_formats_py_cybersecurity` | `guess_email_formats(nombre, apellidos, dominio) -> list` | **Pure.** Candidatos de email comunes a partir de nombre + dominio. |
|
||||||
|
| `enumerate_username_sites_py_cybersecurity` | `enumerate_username_sites(username, ...) -> list` | ¿Existe un username en ~12 redes públicas? (sherlock ligero, por código HTTP). |
|
||||||
|
| `build_search_dorks_py_cybersecurity` | `build_search_dorks(target, tipo, ...) -> list` | **Pure.** Genera dorks de motor de búsqueda (persona/email/dominio/usuario). |
|
||||||
|
| `dns_records_py_cybersecurity` | `dns_records(dominio, types=None) -> dict` | Registros DNS (A/AAAA/MX/TXT/NS/CNAME) vía `dig`. |
|
||||||
|
| `whois_lookup_py_cybersecurity` | `whois_lookup(dominio, ...) -> dict` | Datos de registro vía RDAP (WHOIS moderno HTTP/JSON, sin CLI). |
|
||||||
|
| `enum_subdomains_crtsh_py_cybersecurity` | `enum_subdomains_crtsh(dominio, ...) -> list` | Subdominios desde Certificate Transparency (crt.sh). |
|
||||||
|
|
||||||
|
Orquestadores (grupo [osint-enrich](osint-enrich.md)): `scan_ficha_attachments_metadata`,
|
||||||
|
`enrich_person_passive`, `enrich_org_passive`.
|
||||||
|
|
||||||
|
## Ejemplo canónico
|
||||||
|
|
||||||
|
```bash
|
||||||
|
cd /home/enmanuel/fn_registry
|
||||||
|
python/.venv/bin/python3 - <<'PYEOF'
|
||||||
|
import sys; sys.path.insert(0, "python/functions")
|
||||||
|
from cybersecurity import (dns_records, whois_lookup, enum_subdomains_crtsh,
|
||||||
|
guess_email_formats, build_search_dorks, extract_exif_metadata)
|
||||||
|
|
||||||
|
# Dominio (org)
|
||||||
|
print(whois_lookup("organic-machine.com")["registrar"]) # OVH sas
|
||||||
|
print(dns_records("organic-machine.com")["A"]) # ['135.125.201.30']
|
||||||
|
print(enum_subdomains_crtsh("organic-machine.com")[:5])
|
||||||
|
|
||||||
|
# Persona
|
||||||
|
print(guess_email_formats("Enmanuel", "Gutierrez Perez", "gmail.com")[:5])
|
||||||
|
print(build_search_dorks("Enmanuel Gutierrez Perez", "persona")[:3])
|
||||||
|
|
||||||
|
# Metadatos de un documento propio
|
||||||
|
print(extract_exif_metadata("/home/enmanuel/Obsidian/osint/attachments/personas/enmanuel-gutierrez-perez/dni-1.jpg"))
|
||||||
|
PYEOF
|
||||||
|
```
|
||||||
|
|
||||||
|
## Fronteras (qué NO es)
|
||||||
|
|
||||||
|
- **No es recolección activa**: no hace port scan, dns brute, ni sondea la infra del objetivo.
|
||||||
|
Eso sería el grupo `osint-active` (no construido todavía).
|
||||||
|
- **No verifica** los candidatos: `guess_email_formats` propone, no confirma que el email exista.
|
||||||
|
- **No ejecuta** los dorks: `build_search_dorks` los genera; ejecutarlos es otro paso (browser).
|
||||||
|
- **No incluye breach/leak lookup** (HIBP requiere API key de pago) — pendiente.
|
||||||
|
|
||||||
|
## Gotchas
|
||||||
|
|
||||||
|
- `crt.sh` va lento / rate-limitado y a veces responde 404; los orquestadores lo capturan en
|
||||||
|
`errors` y siguen.
|
||||||
|
- `enumerate_username_sites` da falsos positivos/negativos por anti-bot de algunos sitios.
|
||||||
|
- El GPS de EXIF revela ubicación — dato sensible; trátese como PII.
|
||||||
@@ -0,0 +1,195 @@
|
|||||||
|
# Capability: recon
|
||||||
|
|
||||||
|
Reconocimiento de red para OSINT desde el registry: lookups de registro (WHOIS/RDAP), DNS, sondeo de disponibilidad y ruta (ping/traceroute), escaneo de puertos y servicios, y fingerprint de la tecnologia web de un sitio (estilo Wappalyzer). El escaneo de puertos tiene dos caminos: el wrapper pesado de `nmap` (perfiles, scripts NSE, versiones), y un **camino nativo en Python puro** (`scan_tcp_ports` + `grab_service_banner` + `identify_port_service`, solo stdlib, sin nmap ni sudo) para escaneo rapido y portable. El fingerprint web sigue el mismo patron pura/impura: `fetch_http_fingerprint` recoge las señales (headers, html, cookies) y `detect_web_tech` (pura) matchea firmas para identificar servidor, CMS, frameworks JS, analytics y CDN. La mayoria de funciones son Python impuras, wrappean CLIs del sistema (`whois`, `rdap`, `dig`, `ping`, `traceroute`, `nmap`) o usan sockets/urllib stdlib, y devuelven siempre un dict `{status: ok|error}` sin lanzar excepciones. El grupo cierra el bucle con un **sink comun** que archiva cada escaneo en el ecosistema OSINT (nota Obsidian + registro DuckDB) y pipelines one-shot que escanean y guardan en una sola llamada.
|
||||||
|
|
||||||
|
Comparte tag y dominio (`cybersecurity`) con el grupo `osint-passive` (recoleccion no intrusiva desde fuentes publicas), del que reutiliza primitivas. La regla de operacion es la misma del project `osint`: **todo escaneo se archiva en OSINT**.
|
||||||
|
|
||||||
|
## Funciones
|
||||||
|
|
||||||
|
| ID | Firma | Que hace |
|
||||||
|
|---|---|---|
|
||||||
|
| `whois_lookup_py_cybersecurity` | `whois_lookup(target, timeout_s=30) -> dict` | Lookup WHOIS via el CLI `whois`. Captura el `raw` completo y parsea best-effort registrar, registrant_country, creation_date, expiry_date, updated_date, name_servers. Acepta dominio o IP. |
|
||||||
|
| `rdap_lookup_py_cybersecurity` | `rdap_lookup(target, timeout_s=30) -> dict` | Lookup RDAP (reemplazo JSON moderno de WHOIS) via el CLI openrdap `rdap`. Devuelve `data` (dict JSON), `handle`, `ldhName` y el `raw`. Acepta dominio, IP o ASN (`AS15169`). |
|
||||||
|
| `dns_records_py_cybersecurity` | `dns_records(domain, record_types=None, timeout_s=20) -> dict` | Registros DNS via `dig +short` (default A, AAAA, MX, NS, SOA, TXT, CNAME). Devuelve `records` (dict por tipo) y `raw` legible por bloque para el vault. |
|
||||||
|
| `ping_host_py_cybersecurity` | `ping_host(host, count=4, timeout_s=30) -> dict` | Sondeo ICMP via `ping`. Devuelve `loss_pct`, `rtt_avg_ms` (y min/max), `packets_sent`/`recv`, `raw`. Host filtrado = `status:ok` con `loss_pct=100`, no error. |
|
||||||
|
| `traceroute_host_py_cybersecurity` | `traceroute_host(host, max_hops=30, timeout_s=60) -> dict` | Traza la ruta via `traceroute`. Devuelve `hops` (lista de `{hop, hosts:[{name, ip, rtt_ms}]}`) y `raw`. Hops filtrados (`* * *`) = `hosts: []`. |
|
||||||
|
| `nmap_scan_py_cybersecurity` | `nmap_scan(target, profile="quick", ports=None, extra_args=None, out_dir=None, timeout_s=1800) -> dict` | Escaneo de puertos/servicios via `nmap` por perfiles (salida XML parseada). Devuelve `open_ports`, `hosts_up`, `xml_path`, `raw`, `elapsed_s`. Funcion estrella del grupo. |
|
||||||
|
| `scan_tcp_ports_py_cybersecurity` | `scan_tcp_ports(host, ports="common", timeout_s=1.0, workers=100) -> dict` | **Connect-scan TCP nativo (stdlib, sin nmap ni sudo).** Escanea puertos en paralelo con threads y clasifica cada uno en open/closed/filtered. `ports` acepta lista, preset `"common"`, rango `"1-1024"` o CSV. Devuelve `open` (lista de ints), `ip`, `raw`. NO detecta version de servicio. |
|
||||||
|
| `grab_service_banner_py_cybersecurity` | `grab_service_banner(host, port, timeout_s=3.0, send_probe=True) -> dict` | **Banner grab nativo (stdlib, sin nmap -sV).** Abre socket TCP, lee el banner e identifica el servicio real (ssh, http, ftp, smtp, mysql, redis, pop3, imap, telnet...) extrayendo `product` y `version` best-effort. Dice QUE habla detras de un puerto abierto. TLS/HTTPS no da banner plano. |
|
||||||
|
| `identify_port_service_py_cybersecurity` | `identify_port_service(port, proto="tcp") -> dict` | **Pure.** Mapea un puerto a su servicio IANA well-known esperado por convencion (`{service, description, known}`) desde una tabla embebida (~120 puertos). No sondea en vivo: dice que se ESPERA, no que hay. |
|
||||||
|
| `save_scan_to_osint_py_cybersecurity` | `save_scan_to_osint(target, scan_type, raw, summary=None, vault_dir="~/Obsidian/osint", service_url="http://127.0.0.1:8771", tool=None) -> dict` | **Sink OSINT.** Archiva un scan: nota Markdown tipada en el vault (capa critica) + POST a `osint_db` para registro DuckDB (best-effort). Devuelve `note_path`, `registered`, `scan_id`. |
|
||||||
|
| `recon_osint_py_pipelines` | `recon_osint(target, scan_type="whois", save=True, profile="quick", ...) -> dict` | **Pipeline one-shot.** Ejecuta un scan del tipo pedido y lo archiva en OSINT en una sola llamada (compone la funcion de scan + `save_scan_to_osint`). El camino canonico para recon + archivado. |
|
||||||
|
| `scan_port_services_py_pipelines` | `scan_port_services(host, ports="common", timeout_s=1.0, workers=100, grab_banners=True, banner_timeout_s=3.0, save=True) -> dict` | **Pipeline one-shot nativo.** Escanea puertos y, por cada abierto, devuelve servicio esperado (IANA) + servicio/version real del banner. Compone `scan_tcp_ports` + `identify_port_service` + `grab_service_banner` (+ sink OSINT). Reemplaza el patron scan→identify→grab sin nmap. |
|
||||||
|
| `fetch_http_fingerprint_py_cybersecurity` | `fetch_http_fingerprint(url, timeout_s=15.0, verify_tls=True, max_html_bytes=500000, user_agent=None) -> dict` | **Fetch de señales web (stdlib).** GET con UA de navegador, sigue redirects, descomprime gzip. Devuelve `headers` (lowercase), `cookies` (solo NOMBRES, sin valores), `html`, `title`, `server`, `status_code`, `final_url`, `raw`. Capa impura del fingerprint web. |
|
||||||
|
| `detect_web_tech_py_cybersecurity` | `detect_web_tech(headers, html="", cookies=None, final_url="") -> dict` | **Pure. Detector de tecnologia web estilo Wappalyzer.** Matchea ~50 firmas embebidas (regex) contra headers/html/cookies → `technologies[{name, category, version, confidence, evidence}]`, `by_category`, `count`. Cubre server, lenguaje, CMS, frameworks JS, librerias, analytics, CDN, e-commerce, WAF. |
|
||||||
|
| `fetch_http_fingerprint_cdp_py_browser` | `fetch_http_fingerprint_cdp(url, *, port=9222, wait_render_s=2.0, timeout_s=30.0, close_tab=True) -> dict` | **Fetch del HTML RENDERIZADO (post-JS) via CDP.** Navega en un Chrome remoto (compone `cdp_open_url_and_wait` + `cdp_eval`), espera el render y devuelve el `html` con el DOM ya montado por JS → detecta SPAs (React/Vue/Angular/Next) que el fetch estatico no ve. Mismo shape que `fetch_http_fingerprint` (headers={}, status_code=None: la red la aporta el estatico). |
|
||||||
|
| `fingerprint_web_stack_py_pipelines` | `fingerprint_web_stack(url, timeout_s=15.0, verify_tls=True, max_html_bytes=500000, save=True, use_cdp=False, cdp_port=9222, wait_render_s=2.0) -> dict` | **Pipeline one-shot = Wappalyzer del registry.** url → tecnologias detectadas. Compone `fetch_http_fingerprint` + `detect_web_tech` (+ sink OSINT). Con `use_cdp=True` añade `fetch_http_fingerprint_cdp`: headers reales del estatico + HTML renderizado del CDP (detecta SPAs); degrada a estatico con warning si no hay Chrome. El camino canonico para fingerprint web. |
|
||||||
|
|
||||||
|
### OSINT pasivo relacionado
|
||||||
|
|
||||||
|
Estas funciones llevan tambien el tag `recon` (y `osint-passive`): recoleccion no intrusiva desde fuentes publicas, sin tocar al objetivo. Utiles antes o junto al escaneo de red. Pagina madre completa: `docs/capabilities/osint-passive.md`.
|
||||||
|
|
||||||
|
| ID | Firma | Que hace |
|
||||||
|
|---|---|---|
|
||||||
|
| `build_search_dorks_py_cybersecurity` | `build_search_dorks(target, tipo="persona", extra_domains=None) -> list` | **Pure.** Genera dorks de buscador (frase exacta, `site:`, `filetype:`, leaks/pastebin) segun el tipo de target. Sin red. |
|
||||||
|
| `enum_subdomains_crtsh_py_cybersecurity` | `enum_subdomains_crtsh(dominio, timeout_s=20.0) -> list` | Enumera subdominios desde Certificate Transparency (crt.sh). Dedup, ordenado, sin wildcards. |
|
||||||
|
| `enumerate_username_sites_py_cybersecurity` | `enumerate_username_sites(username, timeout_s=8.0, sites=None) -> list` | Comprueba si un username existe en ~12 sitios publicos (estilo sherlock ligero) por codigo HTTP. |
|
||||||
|
| `guess_email_formats_py_cybersecurity` | `guess_email_formats(nombre, apellidos, dominio) -> list` | **Pure.** Genera candidatos de email comunes (nombre.apellido, inicial+apellido, ...). Sin red. |
|
||||||
|
| `enrich_org_passive_py_cybersecurity` | `enrich_org_passive(dominio) -> dict` | Orquestador: perfil pasivo de una organizacion componiendo whois + dns + subdominios crt.sh. |
|
||||||
|
|
||||||
|
## Ejemplo canonico end-to-end
|
||||||
|
|
||||||
|
**1. One-shot (preferido): escanear y archivar en una llamada.** El pipeline corre el scan y lo guarda en OSINT (nota + registro DuckDB) por ti.
|
||||||
|
|
||||||
|
```bash
|
||||||
|
cd /home/enmanuel/fn_registry
|
||||||
|
./fn run recon_osint ejemplo.com whois
|
||||||
|
```
|
||||||
|
|
||||||
|
Equivalente desde Python (cuando necesitas el dict de resultado):
|
||||||
|
|
||||||
|
```bash
|
||||||
|
python/.venv/bin/python3 - <<'PYEOF'
|
||||||
|
import sys
|
||||||
|
sys.path.insert(0, "python/functions")
|
||||||
|
from pipelines.recon_osint import recon_osint
|
||||||
|
|
||||||
|
res = recon_osint("ejemplo.com", scan_type="whois", save=True)
|
||||||
|
print(res["status"], res.get("note_path"), res.get("registered"))
|
||||||
|
PYEOF
|
||||||
|
```
|
||||||
|
|
||||||
|
**2. Manual atomico + sink.** Cuando quieres controlar el scan (perfil, puertos, summary propio) y guardarlo aparte. La funcion de scan se importa, no se reescribe.
|
||||||
|
|
||||||
|
```bash
|
||||||
|
cd /home/enmanuel/fn_registry
|
||||||
|
python/.venv/bin/python3 - <<'PYEOF'
|
||||||
|
import sys
|
||||||
|
sys.path.insert(0, "python/functions")
|
||||||
|
from cybersecurity import dns_records
|
||||||
|
from cybersecurity.save_scan_to_osint import save_scan_to_osint
|
||||||
|
|
||||||
|
scan = dns_records("ejemplo.com") # 1. escanear
|
||||||
|
if scan["status"] == "ok":
|
||||||
|
saved = save_scan_to_osint( # 2. archivar en OSINT
|
||||||
|
"ejemplo.com",
|
||||||
|
"dns",
|
||||||
|
scan["raw"],
|
||||||
|
summary={"A": scan["records"].get("A"), "MX": scan["records"].get("MX")},
|
||||||
|
tool="dig",
|
||||||
|
)
|
||||||
|
print(saved["note_path"], "registered:", saved["registered"])
|
||||||
|
PYEOF
|
||||||
|
```
|
||||||
|
|
||||||
|
**3. nmap largo en segundo plano.** Los perfiles pesados tardan de minutos a horas: lanzalos en background con `out_dir` (conserva el XML) y `timeout_s` alto, y archiva al terminar.
|
||||||
|
|
||||||
|
```bash
|
||||||
|
cd /home/enmanuel/fn_registry
|
||||||
|
# El pipeline one-shot tambien sirve para nmap; lanzar en background por la duracion:
|
||||||
|
nohup ./fn run recon_osint scanme.nmap.org nmap --profile full-tcp --timeout-s 7200 \
|
||||||
|
> /tmp/recon-fulltcp.log 2>&1 &
|
||||||
|
```
|
||||||
|
|
||||||
|
> `scanme.nmap.org` es el host oficial de pruebas de nmap (legal escanear). Cualquier otro objetivo de terceros exige autorizacion.
|
||||||
|
|
||||||
|
**4. Escaneo nativo de servicios de puertos (sin nmap), one-shot.** Cuando no quieres depender de `nmap`/sudo o buscas un barrido rapido y portable: el pipeline `scan_port_services` escanea los puertos y, por cada abierto, dice el servicio esperado por convencion (IANA) y el servicio/version real leido del banner.
|
||||||
|
|
||||||
|
```bash
|
||||||
|
cd /home/enmanuel/fn_registry
|
||||||
|
python/.venv/bin/python3 - <<'PYEOF'
|
||||||
|
import sys
|
||||||
|
sys.path.insert(0, "python/functions")
|
||||||
|
from pipelines.scan_port_services import scan_port_services
|
||||||
|
|
||||||
|
res = scan_port_services("scanme.nmap.org", ports="common", save=True)
|
||||||
|
print(res["status"], "abiertos:", res.get("open_ports"))
|
||||||
|
for s in res.get("services", []):
|
||||||
|
print(f" {s['port']}: esperado={s['expected_service']} real={s.get('actual_service')} version={s.get('version')}")
|
||||||
|
PYEOF
|
||||||
|
```
|
||||||
|
|
||||||
|
Las primitivas tambien sirven sueltas: `scan_tcp_ports(host, ports)` para solo el estado de los puertos, `grab_service_banner(host, port)` para identificar un servicio concreto, e `identify_port_service(port)` (pura) para el servicio esperado por convencion.
|
||||||
|
|
||||||
|
**5. Fingerprint de tecnologia web (Wappalyzer del registry), one-shot.** Identifica el stack de un sitio — servidor, lenguaje, CMS, frameworks JS, analytics, CDN — desde el HTML + cabeceras + cookies, sin ejecutar JS. El pipeline `fingerprint_web_stack` hace fetch + matching de firmas en una llamada.
|
||||||
|
|
||||||
|
```bash
|
||||||
|
cd /home/enmanuel/fn_registry
|
||||||
|
python/.venv/bin/python3 - <<'PYEOF'
|
||||||
|
import sys
|
||||||
|
sys.path.insert(0, "python/functions")
|
||||||
|
from pipelines.fingerprint_web_stack import fingerprint_web_stack
|
||||||
|
|
||||||
|
res = fingerprint_web_stack("https://example.com", save=True)
|
||||||
|
print(res["status"], "->", res.get("count"), "tecnologias")
|
||||||
|
for t in res.get("technologies", []):
|
||||||
|
print(f" {t['name']} [{t['category']}] v={t['version']!r} ({t['confidence']})")
|
||||||
|
PYEOF
|
||||||
|
```
|
||||||
|
|
||||||
|
Las dos capas tambien sueltas: `fetch_http_fingerprint(url)` para inspeccionar cabeceras+html+cookies crudos de una URL, y `detect_web_tech(headers, html, cookies)` (pura) para matchear firmas sobre señales ya recogidas (testeable sin red).
|
||||||
|
|
||||||
|
**Modo CDP (SPAs): detectar mas eficientemente el HTML renderizado.** Un fetch estatico NO ejecuta JavaScript: una SPA (React/Vue/Angular/Next con HTML inicial casi vacio) monta su DOM en runtime y el estatico la pierde. Con `use_cdp=True` el pipeline usa `fetch_http_fingerprint_cdp` (Chrome remoto via CDP) para analizar el DOM ya renderizado, combinando los headers reales del estatico con el HTML post-JS.
|
||||||
|
|
||||||
|
```bash
|
||||||
|
cd /home/enmanuel/fn_registry
|
||||||
|
python/.venv/bin/python3 - <<'PYEOF'
|
||||||
|
import sys
|
||||||
|
sys.path.insert(0, "python/functions")
|
||||||
|
from pipelines.fingerprint_web_stack import fingerprint_web_stack
|
||||||
|
|
||||||
|
# cdp_port=9333 = Chrome aislado del browser_mcp (recomendado para terceros); 9222 = navegador diario.
|
||||||
|
res = fingerprint_web_stack("https://una-spa.com", use_cdp=True, cdp_port=9333, save=False)
|
||||||
|
print(res["html_source"], "->", [t["name"] for t in res["technologies"]])
|
||||||
|
PYEOF
|
||||||
|
```
|
||||||
|
|
||||||
|
Ganancia verificada en vivo: sobre una SPA cuyo marcador de framework solo aparece tras ejecutar JS, el estatico detecta solo `nginx`; con `use_cdp=True` detecta ademas `Next.js`, `React`, `Node.js`. Si no hay Chrome en `cdp_port`, degrada al fetch estatico con un `warning` (no falla).
|
||||||
|
|
||||||
|
## Integracion OSINT
|
||||||
|
|
||||||
|
Cada escaneo guardado acaba en **dos sitios**, y por eso `save_scan_to_osint` (y el pipeline `recon_osint`) son el cierre obligatorio del grupo:
|
||||||
|
|
||||||
|
1. **Nota Markdown en el vault** `~/Obsidian/osint` bajo
|
||||||
|
`dominios/<slug>/recon/<scan_type>-<YYYYMMDD-HHMM>.md`. Frontmatter tipado
|
||||||
|
(`tipo: scan-red`, `scan_tipo`, `target`, `slug`, `fecha`, `herramienta`,
|
||||||
|
`tags: [scan-red, <scan_type>, recon]`) y el `raw` del scan en un bloque de
|
||||||
|
codigo. Es la **capa critica**: si falla, el sink devuelve `status:error`.
|
||||||
|
2. **Fila en la tabla DuckDB `network_scans`** (schema `main`) del service
|
||||||
|
`osint_db`, via `POST http://127.0.0.1:8771/api/scan`. Columnas:
|
||||||
|
`id, target, target_slug, scan_type, tool, scan_ts, note_path, summary(JSON),
|
||||||
|
created_at`. Es la **capa best-effort**: si el service esta caido o no expone
|
||||||
|
el endpoint, el sink degrada a solo-nota con `registered=False` +
|
||||||
|
`register_warning`, sin romper. El re-ingest del vault NO borra esta tabla.
|
||||||
|
|
||||||
|
**REGLA: todo escaneo se guarda en OSINT.** No hay scans "sueltos". O usas el
|
||||||
|
pipeline `recon_osint` (scan + archivado en 1 call), o llamas la funcion de scan
|
||||||
|
atomica y a continuacion `save_scan_to_osint` con su `raw`. El slug del target se
|
||||||
|
deriva con `re.sub(r"[^a-z0-9._-]+", "-", target.lower())`.
|
||||||
|
|
||||||
|
## Escaneos nmap utiles para segundo plano
|
||||||
|
|
||||||
|
Los perfiles pesados de `nmap_scan` deben lanzarse en background (`&` / `nohup` / `run_in_background`) por su duracion. Pasa `out_dir` para conservar el XML y sube `timeout_s`.
|
||||||
|
|
||||||
|
| Perfil | Flags nmap | Cuando usarlo | Duracion |
|
||||||
|
|---|---|---|---|
|
||||||
|
| `full-tcp` | `-p- -T4` | Mapear los 65535 puertos TCP (no solo el top 1000). Cuando buscas servicios en puertos no estandar. | Minutos a horas → background |
|
||||||
|
| `vuln` | `-sV --script vuln -T4` | Correr los scripts NSE de vulnerabilidades sobre los servicios detectados. Fase posterior a un service scan. | Largo, ruidoso → background |
|
||||||
|
| `udp-top` | `-sU --top-ports 100 -T4` | Descubrir servicios UDP (DNS, SNMP, NTP...). UDP es lento y suele requerir sudo. | Largo → background |
|
||||||
|
| `service` | `-sV -sC -T4` | Deteccion de version + scripts default sobre puertos abiertos. A veces tolerable en primer plano. | Medio (puede ir a background) |
|
||||||
|
| `aggressive` | `-A -T4` | OS + version + scripts + traceroute de golpe. Muy detectable; el `-O` interno puede pedir sudo. | Largo, ruidoso → background |
|
||||||
|
|
||||||
|
Perfiles ligeros que SI corren bien en primer plano: `quick` (`-T4 -F`, top 100), `top1000` (`-T4`), `discovery` (`-sn`, ping sweep de una subred → puebla `hosts_up`), `os` (`-O`, requiere sudo).
|
||||||
|
|
||||||
|
## Prerequisitos
|
||||||
|
|
||||||
|
- **CLIs instaladas** en el PATH: `whois` (`apt install whois`), `rdap` (openrdap, normalmente en `~/go/bin/rdap` — `go install github.com/openrdap/rdap/cmd/rdap@latest`), `dig` (`dnsutils`/`bind-utils`), `ping` (`iputils-ping`), `traceroute`, `nmap`. Si falta el binario, la funcion devuelve `status:error` con la instruccion de instalacion, nunca lanza.
|
||||||
|
- **Privilegios**: los perfiles de nmap `os` (-O), `udp-top` (-sU) y parte de `aggressive` requieren sudo/root; sin privilegios nmap cae a connect-scan TCP y esos modos quedan incompletos (estas funciones no usan sudo).
|
||||||
|
- **Service `osint_db` vivo** en `http://127.0.0.1:8771` para el registro estructurado en `network_scans`. Si esta caido, los scans siguen guardandose como nota (solo se pierde la fila DuckDB hasta el siguiente re-registro). Ver memoria `osint-duckdb-stack`.
|
||||||
|
|
||||||
|
## Fronteras (que NO cubre)
|
||||||
|
|
||||||
|
- **No es un framework de explotacion.** Es reconocimiento: identifica superficie (puertos, servicios, versiones, registro, ruta). No explota vulnerabilidades, no hace fuerza bruta de credenciales, no entrega payloads. Para eso, herramientas dedicadas fuera del registry.
|
||||||
|
- **Solo hosts autorizados o propios.** Escanear infraestructura de terceros sin permiso explicito puede ser delito. `scanme.nmap.org` es el unico host de terceros legal por defecto (es el host oficial de pruebas de nmap).
|
||||||
|
- **No evade deteccion.** No implementa tecnicas de evasion de IDS/WAF, fragmentacion, decoys ni timing de sigilo; `-T4` es ruidoso a proposito. Un objetivo que defienda activamente puede detectar y filtrar el escaneo.
|
||||||
|
- **No cubre OSINT pasivo de personas** (dorks, usernames, emails) mas alla de listar las funciones afines: esas viven en el grupo `osint-passive`. El render BD→nota y el grafo del vault son de `obsidian`/`duckdb`.
|
||||||
@@ -19,6 +19,7 @@ Filtro MCP: `mcp__registry__fn_search query="" tag="sink"`.
|
|||||||
| [http_post_json_py_infra](../../python/functions/infra/http_post_json.md) | py | HTTP JSON POST |
|
| [http_post_json_py_infra](../../python/functions/infra/http_post_json.md) | py | HTTP JSON POST |
|
||||||
| [http_post_json_go_infra](../../functions/infra/http_post_json.md) | go | HTTP JSON POST |
|
| [http_post_json_go_infra](../../functions/infra/http_post_json.md) | go | HTTP JSON POST |
|
||||||
| [db_insert_row_go_infra](../../functions/infra/db_insert_row.md) | go | SQL row insert |
|
| [db_insert_row_go_infra](../../functions/infra/db_insert_row.md) | go | SQL row insert |
|
||||||
|
| [save_scan_to_osint_py_cybersecurity](../../python/functions/cybersecurity/save_scan_to_osint.md) | py | Vault Obsidian (nota) + osint_db (DuckDB via HTTP) — sink de scans de red |
|
||||||
|
|
||||||
## Ejemplo canonico
|
## Ejemplo canonico
|
||||||
|
|
||||||
|
|||||||
@@ -51,12 +51,12 @@ func CdpClickXYHuman(c *CDPConn, x, y float64, opts MouseHumanOpts) error {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// clickPauseMs devuelve la pausa (ms) entre press y release según el modo de
|
// clickPauseMs devuelve la pausa (ms) entre press y release según el modo de
|
||||||
// velocidad: human 30-90, fast 5-15, instant 0.
|
// velocidad: human 30-90, auto/fast 5-15, instant 0.
|
||||||
func clickPauseMs(mode string) int {
|
func clickPauseMs(mode string) int {
|
||||||
switch mode {
|
switch mode {
|
||||||
case "instant":
|
case "instant":
|
||||||
return 0
|
return 0
|
||||||
case "fast":
|
case "fast", "auto":
|
||||||
return 5 + rand.Intn(11) // 5..15
|
return 5 + rand.Intn(11) // 5..15
|
||||||
default: // "human" o ""
|
default: // "human" o ""
|
||||||
return 30 + rand.Intn(61) // 30..90
|
return 30 + rand.Intn(61) // 30..90
|
||||||
|
|||||||
@@ -14,8 +14,16 @@ import (
|
|||||||
"strings"
|
"strings"
|
||||||
"sync"
|
"sync"
|
||||||
"sync/atomic"
|
"sync/atomic"
|
||||||
|
"time"
|
||||||
)
|
)
|
||||||
|
|
||||||
|
// cdpCmdTimeout es el tope que sendCDP espera por la respuesta a un comando antes
|
||||||
|
// de rendirse. Sin el, una respuesta que Chrome nunca envia (tab cerrada a media
|
||||||
|
// peticion, proceso colgado) bloquearia la goroutine del tool para siempre — el
|
||||||
|
// agente lo percibe como "lentitud infinita". Con el timeout, el tool falla limpio
|
||||||
|
// y el retry de withConn puede reconectar.
|
||||||
|
const cdpCmdTimeout = 30 * time.Second
|
||||||
|
|
||||||
// EventHandler es invocado cuando llega un evento CDP del metodo subscrito.
|
// EventHandler es invocado cuando llega un evento CDP del metodo subscrito.
|
||||||
// El handler corre en la goroutine del readLoop — debe ser rapido o despachar
|
// El handler corre en la goroutine del readLoop — debe ser rapido o despachar
|
||||||
// a un canal/goroutine propio. params puede ser nil si Chrome no envia.
|
// a un canal/goroutine propio. params puede ser nil si Chrome no envia.
|
||||||
@@ -36,6 +44,15 @@ type CDPConn struct {
|
|||||||
handlers map[string][]EventHandler
|
handlers map[string][]EventHandler
|
||||||
hMu sync.Mutex
|
hMu sync.Mutex
|
||||||
|
|
||||||
|
// axEnabled/netEnabled/pageEnabled cachean si ya enviamos el enable de cada
|
||||||
|
// dominio CDP en esta conexion. enable/disable es idempotente pero cuesta un
|
||||||
|
// round-trip; en el hot path del agente (percibir->actuar repetido) re-enviar
|
||||||
|
// Accessibility.enable / Network.enable en cada llamada duplica los RTT.
|
||||||
|
// Habilitar una vez y cachear el flag elimina ese coste por percepcion/espera.
|
||||||
|
axEnabled atomic.Bool
|
||||||
|
netEnabled atomic.Bool
|
||||||
|
pageEnabled atomic.Bool
|
||||||
|
|
||||||
// frameCtx cachea el executionContextId del isolated world por frameID, para
|
// frameCtx cachea el executionContextId del isolated world por frameID, para
|
||||||
// que CdpEvalInFrame no cree un mundo aislado nuevo en cada llamada.
|
// que CdpEvalInFrame no cree un mundo aislado nuevo en cada llamada.
|
||||||
// frameCtxMu protege solo el lazy-init del puntero (el cache tiene su mutex).
|
// frameCtxMu protege solo el lazy-init del puntero (el cache tiene su mutex).
|
||||||
@@ -250,12 +267,60 @@ func (c *CDPConn) sendCDP(method string, params map[string]any) (map[string]any,
|
|||||||
return nil, fmt.Errorf("cdp send %s: %w", method, err)
|
return nil, fmt.Errorf("cdp send %s: %w", method, err)
|
||||||
}
|
}
|
||||||
|
|
||||||
// Esperar respuesta
|
// Esperar respuesta (con timeout para no colgar el tool indefinidamente).
|
||||||
resp := <-ch
|
select {
|
||||||
|
case resp := <-ch:
|
||||||
if resp.Error != nil {
|
if resp.Error != nil {
|
||||||
return nil, fmt.Errorf("cdp %s: error %d: %s", method, resp.Error.Code, resp.Error.Message)
|
return nil, fmt.Errorf("cdp %s: error %d: %s", method, resp.Error.Code, resp.Error.Message)
|
||||||
}
|
}
|
||||||
return resp.Result, nil
|
return resp.Result, nil
|
||||||
|
case <-time.After(cdpCmdTimeout):
|
||||||
|
c.pendMu.Lock()
|
||||||
|
delete(c.pending, id)
|
||||||
|
c.pendMu.Unlock()
|
||||||
|
return nil, fmt.Errorf("cdp %s: sin respuesta tras %s (conexion colgada?)", method, cdpCmdTimeout)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ensureAX habilita el dominio Accessibility una sola vez por conexion (necesario
|
||||||
|
// antes de Accessibility.getFullAXTree). Idempotente y cacheado: la segunda y
|
||||||
|
// sucesivas llamadas son no-op, evitando un round-trip por percepcion.
|
||||||
|
func (c *CDPConn) ensureAX() error {
|
||||||
|
if c.axEnabled.Load() {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
if _, err := c.sendCDP("Accessibility.enable", nil); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
c.axEnabled.Store(true)
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// ensureNetwork habilita el dominio Network una sola vez por conexion. Cacheado:
|
||||||
|
// no lo deshabilitamos al terminar una espera (eso borraria el estado y forzaria
|
||||||
|
// el enable de nuevo); los handlers de eventos se desregistran por su cancel().
|
||||||
|
func (c *CDPConn) ensureNetwork() error {
|
||||||
|
if c.netEnabled.Load() {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
if _, err := c.sendCDP("Network.enable", nil); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
c.netEnabled.Store(true)
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// ensurePage habilita el dominio Page una sola vez por conexion (necesario para
|
||||||
|
// recibir Page.loadEventFired y demas eventos de ciclo de vida de la pagina).
|
||||||
|
func (c *CDPConn) ensurePage() error {
|
||||||
|
if c.pageEnabled.Load() {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
if _, err := c.sendCDP("Page.enable", nil); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
c.pageEnabled.Store(true)
|
||||||
|
return nil
|
||||||
}
|
}
|
||||||
|
|
||||||
// readLoop lee mensajes del WebSocket y los enruta a los canales pendientes
|
// readLoop lee mensajes del WebSocket y los enruta a los canales pendientes
|
||||||
|
|||||||
@@ -72,8 +72,10 @@ func CdpGetAXOutline(c *CDPConn, frameID string, maxChars int) (string, error) {
|
|||||||
return "", fmt.Errorf("cdp get ax outline: conexion nula")
|
return "", fmt.Errorf("cdp get ax outline: conexion nula")
|
||||||
}
|
}
|
||||||
|
|
||||||
// Accessibility.enable es idempotente; necesario antes de getFullAXTree.
|
// Accessibility.enable (idempotente, cacheado por conexion): necesario antes de
|
||||||
if _, err := c.sendCDP("Accessibility.enable", nil); err != nil {
|
// getFullAXTree. Cachear el flag evita un round-trip extra en cada percepcion,
|
||||||
|
// que es la operacion mas frecuente del bucle percibir->actuar del agente.
|
||||||
|
if err := c.ensureAX(); err != nil {
|
||||||
return "", fmt.Errorf("cdp get ax outline: Accessibility.enable: %w", err)
|
return "", fmt.Errorf("cdp get ax outline: Accessibility.enable: %w", err)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -9,11 +9,12 @@ import (
|
|||||||
|
|
||||||
// MouseHumanOpts configura el movimiento humano del ratón.
|
// MouseHumanOpts configura el movimiento humano del ratón.
|
||||||
type MouseHumanOpts struct {
|
type MouseHumanOpts struct {
|
||||||
// Mode es la política de velocidad: "human" (default, ""), "fast" o "instant".
|
// Mode es la política de velocidad: "auto"/"fast" (rápido), "human" (sigiloso,
|
||||||
// Controla los defaults de Steps/DurationMs/JitterPx y la pausa press/release:
|
// también "") o "instant". Controla los defaults de Steps/DurationMs/JitterPx y
|
||||||
|
// la pausa press/release:
|
||||||
|
// - auto/fast: recta ~5 pts, 40-80ms, jitter mínimo (eventos de ratón reales,
|
||||||
|
// rápido — modo por defecto del MCP para automatización propia).
|
||||||
// - human: Bézier ~25 pts, 350-800ms, jitter 2px (sigilo anti-bot alto).
|
// - human: Bézier ~25 pts, 350-800ms, jitter 2px (sigilo anti-bot alto).
|
||||||
// - fast: recta ~5 pts, 40-80ms, jitter mínimo (eventos de ratón reales,
|
|
||||||
// para scraping masivo propio).
|
|
||||||
// - instant: sin movimiento de ratón (CdpMoveMouseHuman es no-op); el click
|
// - instant: sin movimiento de ratón (CdpMoveMouseHuman es no-op); el click
|
||||||
// por #ref usa element.click() JS. Para tests y fallback sin bbox.
|
// por #ref usa element.click() JS. Para tests y fallback sin bbox.
|
||||||
// Los valores explícitos (Steps/DurationMs/JitterPx != 0) ganan al preset del modo.
|
// Los valores explícitos (Steps/DurationMs/JitterPx != 0) ganan al preset del modo.
|
||||||
@@ -37,7 +38,7 @@ type MouseHumanOpts struct {
|
|||||||
// Un modo desconocido se trata como "human" (el más seguro).
|
// Un modo desconocido se trata como "human" (el más seguro).
|
||||||
func MouseProfileForMode(mode string) MouseHumanOpts {
|
func MouseProfileForMode(mode string) MouseHumanOpts {
|
||||||
switch mode {
|
switch mode {
|
||||||
case "fast", "instant", "human", "":
|
case "auto", "fast", "instant", "human", "":
|
||||||
return MouseHumanOpts{Mode: mode, FromX: -1, FromY: -1}
|
return MouseHumanOpts{Mode: mode, FromX: -1, FromY: -1}
|
||||||
default:
|
default:
|
||||||
return MouseHumanOpts{Mode: "human", FromX: -1, FromY: -1}
|
return MouseHumanOpts{Mode: "human", FromX: -1, FromY: -1}
|
||||||
@@ -56,14 +57,14 @@ func mouseHumanDefaults(opts MouseHumanOpts) MouseHumanOpts {
|
|||||||
opts.DurationMs = 1
|
opts.DurationMs = 1
|
||||||
}
|
}
|
||||||
// JitterPx se queda en 0.
|
// JitterPx se queda en 0.
|
||||||
case "fast":
|
case "fast", "auto":
|
||||||
if opts.Steps <= 0 {
|
if opts.Steps <= 0 {
|
||||||
opts.Steps = 5
|
opts.Steps = 5
|
||||||
}
|
}
|
||||||
if opts.DurationMs <= 0 {
|
if opts.DurationMs <= 0 {
|
||||||
opts.DurationMs = 40 + rand.Intn(41) // 40..80
|
opts.DurationMs = 40 + rand.Intn(41) // 40..80
|
||||||
}
|
}
|
||||||
// JitterPx se queda en lo recibido (0 por defecto, sin jitter en fast).
|
// JitterPx se queda en lo recibido (0 por defecto, sin jitter en fast/auto).
|
||||||
default: // "human" o ""
|
default: // "human" o ""
|
||||||
if opts.Steps <= 0 {
|
if opts.Steps <= 0 {
|
||||||
opts.Steps = 25
|
opts.Steps = 25
|
||||||
|
|||||||
@@ -14,3 +14,17 @@ func CdpTypeRef(c *CDPConn, backendNodeID int, text string) error {
|
|||||||
}
|
}
|
||||||
return CdpTypeText(c, text)
|
return CdpTypeText(c, text)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// CdpTypeRefFast enfoca el elemento del #ref e inserta el texto en UN solo
|
||||||
|
// round-trip (Input.insertText), sin teclear caracter por caracter. Es el camino
|
||||||
|
// rápido del modo automático: equivale a focus(ref) → CdpInsertText. Para sitios
|
||||||
|
// con detección por pulsación usa CdpTypeRef (modo human, char por char).
|
||||||
|
func CdpTypeRefFast(c *CDPConn, backendNodeID int, text string) error {
|
||||||
|
if c == nil {
|
||||||
|
return fmt.Errorf("cdp type ref fast: conexión nil")
|
||||||
|
}
|
||||||
|
if _, err := c.sendCDP("DOM.focus", map[string]any{"backendNodeId": backendNodeID}); err != nil {
|
||||||
|
return fmt.Errorf("cdp type ref fast: focus ref %d: %w", backendNodeID, err)
|
||||||
|
}
|
||||||
|
return CdpInsertText(c, text)
|
||||||
|
}
|
||||||
|
|||||||
@@ -3,10 +3,10 @@ name: cdp_type_ref
|
|||||||
kind: function
|
kind: function
|
||||||
lang: go
|
lang: go
|
||||||
domain: browser
|
domain: browser
|
||||||
version: "1.0.0"
|
version: "1.1.0"
|
||||||
purity: impure
|
purity: impure
|
||||||
signature: "func CdpTypeRef(c *CDPConn, backendNodeID int, text string) error"
|
signature: "func CdpTypeRef(c *CDPConn, backendNodeID int, text string) error"
|
||||||
description: "Enfoca el elemento identificado por su #ref del AX outline vía DOM.focus y escribe el texto dado usando CdpTypeText. El #ref es el backendDOMNodeId estable del nodo DOM. El elemento debe aceptar input de texto (input, textarea, contenteditable)."
|
description: "Enfoca el elemento identificado por su #ref del AX outline vía DOM.focus y escribe el texto dado usando CdpTypeText (carácter a carácter, camino human). El #ref es el backendDOMNodeId estable del nodo DOM. Para el camino rápido (un solo round-trip Input.insertText) hay CdpTypeRefFast. El elemento debe aceptar input de texto (input, textarea, contenteditable)."
|
||||||
tags: [cdp, browser, action, ref, humanized, navegator]
|
tags: [cdp, browser, action, ref, humanized, navegator]
|
||||||
uses_functions: [cdp_type_text_go_browser]
|
uses_functions: [cdp_type_text_go_browser]
|
||||||
uses_types: []
|
uses_types: []
|
||||||
@@ -49,3 +49,7 @@ Tras `page_perceive` / `render_ax_outline`, cuando el agente quiere escribir en
|
|||||||
- `DOM.focus` falla si el elemento no es focusable (no es `input`, `textarea`, `contenteditable`, o similar). El error indica el ref y la causa.
|
- `DOM.focus` falla si el elemento no es focusable (no es `input`, `textarea`, `contenteditable`, o similar). El error indica el ref y la causa.
|
||||||
- Si el elemento necesita un click previo para activarse (algunos inputs con JS custom), combinar con `CdpClickRef` antes de `CdpTypeRef`.
|
- Si el elemento necesita un click previo para activarse (algunos inputs con JS custom), combinar con `CdpClickRef` antes de `CdpTypeRef`.
|
||||||
- No hace scroll previo — si el elemento no está visible en el viewport el focus CDP puede fallar en algunos navegadores. Combinar con `CdpClickRef` (que sí hace scroll) si hay dudas.
|
- No hace scroll previo — si el elemento no está visible en el viewport el focus CDP puede fallar en algunos navegadores. Combinar con `CdpClickRef` (que sí hace scroll) si hay dudas.
|
||||||
|
|
||||||
|
## Capability growth log
|
||||||
|
|
||||||
|
- v1.1.0 (2026-06-13) — Nueva función hermana `CdpTypeRefFast`: enfoca el #ref e inserta el texto en un solo round-trip (`Input.insertText`) en vez de teclear carácter a carácter. Es el camino rápido del modo automático del MCP (`dom_type_ref` con `mode=auto`); `CdpTypeRef` queda como el camino human (carácter a carácter con pausas aleatorias) para sitios con detección por pulsación.
|
||||||
|
|||||||
@@ -2,27 +2,38 @@ package browser
|
|||||||
|
|
||||||
import (
|
import (
|
||||||
"fmt"
|
"fmt"
|
||||||
|
"math/rand"
|
||||||
"strings"
|
"strings"
|
||||||
"time"
|
"time"
|
||||||
)
|
)
|
||||||
|
|
||||||
// CdpTypeText escribe texto en el elemento activo de la pagina caracter por caracter.
|
// assertEditableFocus verifica que el activeElement de la pagina acepta texto
|
||||||
// Usa Input.dispatchKeyEvent para simular pulsaciones de teclado reales.
|
// (input/textarea/select/contentEditable). Sin foco, los caracteres se pierden
|
||||||
// Recomienda usar CdpClick primero para enfocar el elemento objetivo.
|
// silenciosamente (van a document.body); devolvemos un error claro en vez de
|
||||||
|
// "escribir a la nada". Compartido por CdpTypeText (camino human) y CdpInsertText
|
||||||
|
// (camino rapido).
|
||||||
|
func assertEditableFocus(c *CDPConn) error {
|
||||||
|
focus, ferr := CdpEvaluate(c, `(function(){var a=document.activeElement;if(!a)return 'none';var t=a.tagName.toLowerCase();return (t==='input'||t==='textarea'||t==='select'||a.isContentEditable)?'ok':t;})()`)
|
||||||
|
if ferr != nil {
|
||||||
|
return fmt.Errorf("verificar foco: %w", ferr)
|
||||||
|
}
|
||||||
|
if strings.TrimSpace(focus) != "ok" {
|
||||||
|
return fmt.Errorf("no hay campo de texto enfocado (activeElement: %s); enfoca el input primero", strings.TrimSpace(focus))
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// CdpTypeText escribe texto en el elemento activo de la pagina caracter por
|
||||||
|
// caracter, con una pausa ALEATORIA entre teclas. Es el camino "human": emite
|
||||||
|
// keyDown/keyUp reales por tecla (sitios que validan pulsacion a pulsacion
|
||||||
|
// reaccionan) y el ritmo irregular reduce la deteccion de automatizacion. Para el
|
||||||
|
// camino rapido (modo auto) usa CdpInsertText: un solo round-trip, sin teclear.
|
||||||
func CdpTypeText(c *CDPConn, text string) error {
|
func CdpTypeText(c *CDPConn, text string) error {
|
||||||
if c == nil {
|
if c == nil {
|
||||||
return fmt.Errorf("cdp type text: conexion nula")
|
return fmt.Errorf("cdp type text: conexion nula")
|
||||||
}
|
}
|
||||||
|
if err := assertEditableFocus(c); err != nil {
|
||||||
// Verificar que hay un campo editable enfocado. Sin foco, los caracteres se
|
return fmt.Errorf("cdp type text: %w", err)
|
||||||
// pierden silenciosamente (van a document.body). Devolvemos error claro en vez
|
|
||||||
// de "escribir a la nada".
|
|
||||||
focus, ferr := CdpEvaluate(c, `(function(){var a=document.activeElement;if(!a)return 'none';var t=a.tagName.toLowerCase();return (t==='input'||t==='textarea'||t==='select'||a.isContentEditable)?'ok':t;})()`)
|
|
||||||
if ferr != nil {
|
|
||||||
return fmt.Errorf("cdp type text: verificar foco: %w", ferr)
|
|
||||||
}
|
|
||||||
if strings.TrimSpace(focus) != "ok" {
|
|
||||||
return fmt.Errorf("cdp type text: no hay campo de texto enfocado (activeElement: %s); usa CdpClick sobre el input primero", strings.TrimSpace(focus))
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// keyDown (con `text`) ya inserta el caracter en el elemento focado en
|
// keyDown (con `text`) ya inserta el caracter en el elemento focado en
|
||||||
@@ -49,9 +60,28 @@ func CdpTypeText(c *CDPConn, text string) error {
|
|||||||
return fmt.Errorf("cdp type text: keyUp %q: %w", charStr, err)
|
return fmt.Errorf("cdp type text: keyUp %q: %w", charStr, err)
|
||||||
}
|
}
|
||||||
|
|
||||||
// Pequena pausa entre caracteres para simular escritura humana.
|
// Pausa ALEATORIA entre caracteres (15-65 ms) para imitar el ritmo
|
||||||
time.Sleep(10 * time.Millisecond)
|
// irregular de un humano escribiendo, en vez de un intervalo de maquina fijo.
|
||||||
|
time.Sleep(time.Duration(15+rand.Intn(51)) * time.Millisecond)
|
||||||
}
|
}
|
||||||
|
|
||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// CdpInsertText inserta todo el texto en el elemento enfocado en UN solo
|
||||||
|
// round-trip via Input.insertText. Es el camino rapido del modo automatico: no
|
||||||
|
// emite keyDown/keyUp por tecla, por lo que sitios que validan pulsacion a
|
||||||
|
// pulsacion (autocompletes muy estrictos) pueden no reaccionar — para esos casos
|
||||||
|
// usa CdpTypeText (modo human). Requiere un campo editable enfocado.
|
||||||
|
func CdpInsertText(c *CDPConn, text string) error {
|
||||||
|
if c == nil {
|
||||||
|
return fmt.Errorf("cdp insert text: conexion nula")
|
||||||
|
}
|
||||||
|
if err := assertEditableFocus(c); err != nil {
|
||||||
|
return fmt.Errorf("cdp insert text: %w", err)
|
||||||
|
}
|
||||||
|
if _, err := c.sendCDP("Input.insertText", map[string]any{"text": text}); err != nil {
|
||||||
|
return fmt.Errorf("cdp insert text: %w", err)
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|||||||
@@ -3,11 +3,11 @@ name: cdp_type_text
|
|||||||
kind: function
|
kind: function
|
||||||
lang: go
|
lang: go
|
||||||
domain: browser
|
domain: browser
|
||||||
version: "1.0.0"
|
version: "1.1.0"
|
||||||
purity: impure
|
purity: impure
|
||||||
signature: "func CdpTypeText(c *CDPConn, text string) error"
|
signature: "func CdpTypeText(c *CDPConn, text string) error"
|
||||||
description: "Escribe texto en el elemento activo de la pagina caracter por caracter via Input.dispatchKeyEvent. Envia eventos keyDown, char y keyUp por cada caracter con 10ms de pausa entre ellos. Usar CdpClick primero para enfocar el elemento."
|
description: "Escribe texto en el elemento activo de la pagina caracter por caracter via Input.dispatchKeyEvent (camino human). Envia keyDown+keyUp por cada caracter con una pausa ALEATORIA (15-65ms) que imita el ritmo irregular humano. Para el camino rapido (un solo round-trip, sin teclear) usa CdpInsertText. Usar CdpClick primero para enfocar el elemento."
|
||||||
tags: [chrome, cdp, browser, automation, keyboard, input, devtools]
|
tags: [chrome, cdp, browser, automation, keyboard, input, devtools, navegator]
|
||||||
uses_functions: [cdp_connect_go_browser]
|
uses_functions: [cdp_connect_go_browser]
|
||||||
uses_types: []
|
uses_types: []
|
||||||
returns: []
|
returns: []
|
||||||
@@ -39,4 +39,10 @@ CdpTypeText(conn, "golang websocket")
|
|||||||
|
|
||||||
## Notas
|
## Notas
|
||||||
|
|
||||||
Envia tres eventos por caracter: `keyDown`, `char` (dispara el evento `input` del DOM) y `keyUp`. La pausa de 10ms entre caracteres simula escritura humana y ayuda con inputs que tienen debounce. Para texto largo, considerar inyectar directamente via `CdpEvaluate` con `element.value = "..."` + evento `input`.
|
Envia dos eventos por caracter: `keyDown` (con `text`, que ya inserta el caracter en Chrome) y `keyUp`. No envia un evento `char` extra: lo duplicaba en sitios que reaccionan a eventos `input` (DuckDuckGo, Google). La pausa ALEATORIA de 15-65ms entre caracteres imita el ritmo irregular humano (reduce deteccion) y ayuda con inputs que tienen debounce.
|
||||||
|
|
||||||
|
Para el camino rapido del modo automatico hay `CdpInsertText` (todo el texto en un solo `Input.insertText`, sin keyDown/keyUp por tecla) — mucho mas rapido, pero sitios que validan pulsacion a pulsacion pueden no reaccionar. Para texto largo donde no importa el sigilo, `CdpInsertText` es preferible.
|
||||||
|
|
||||||
|
## Capability growth log
|
||||||
|
|
||||||
|
- v1.1.0 (2026-06-13) — La pausa entre caracteres pasa de 10ms fija a aleatoria 15-65ms (ritmo no-máquina). Nueva función hermana `CdpInsertText`: inserta todo el texto en un solo round-trip (`Input.insertText`) para el modo automático rápido. Se extrajo el chequeo de foco a `assertEditableFocus` (compartido).
|
||||||
|
|||||||
@@ -136,11 +136,14 @@ func CdpWaitIdle(c *CDPConn, opts CdpWaitIdleOpts) error {
|
|||||||
})
|
})
|
||||||
defer cancel3()
|
defer cancel3()
|
||||||
|
|
||||||
// Habilitar dominio Network (igual que cdp_har_record).
|
// Habilitar dominio Network (idempotente, cacheado por conexion). NO lo
|
||||||
if _, err := c.sendCDP("Network.enable", nil); err != nil {
|
// deshabilitamos al salir: Network.disable borraria el estado y el siguiente
|
||||||
|
// wait_idle pagaria el enable de nuevo (round-trip extra). Los handlers de
|
||||||
|
// eventos se desregistran por sus cancel() de defer, que es lo unico necesario
|
||||||
|
// para dejar de contar.
|
||||||
|
if err := c.ensureNetwork(); err != nil {
|
||||||
return fmt.Errorf("cdp wait idle: Network.enable: %w", err)
|
return fmt.Errorf("cdp wait idle: Network.enable: %w", err)
|
||||||
}
|
}
|
||||||
defer c.sendCDP("Network.disable", nil) //nolint:errcheck
|
|
||||||
|
|
||||||
deadline := time.Now().Add(opts.Timeout)
|
deadline := time.Now().Add(opts.Timeout)
|
||||||
pollInterval := time.Duration(opts.PollMs) * time.Millisecond
|
pollInterval := time.Duration(opts.PollMs) * time.Millisecond
|
||||||
|
|||||||
@@ -6,9 +6,11 @@ import (
|
|||||||
)
|
)
|
||||||
|
|
||||||
// CdpWaitLoad espera a que la página actual termine de cargar completamente.
|
// CdpWaitLoad espera a que la página actual termine de cargar completamente.
|
||||||
// Hace polling de document.readyState via Runtime.evaluate cada 200ms hasta
|
// Bloquea hasta recibir el evento CDP Page.loadEventFired (sin polling): suscribe
|
||||||
// que sea "complete", o hasta que se agote el timeout.
|
// el evento via OnEvent y espera en un canal con timeout. Antes de esperar hace un
|
||||||
// Retorna error si el timeout se agota o si CdpEvaluate falla (conexion rota).
|
// fast path comprobando document.readyState — si la página ya está "complete",
|
||||||
|
// retorna de inmediato sin armar el handler.
|
||||||
|
// Retorna error si el timeout se agota o si no logra habilitar el dominio Page.
|
||||||
func CdpWaitLoad(c *CDPConn, timeout time.Duration) error {
|
func CdpWaitLoad(c *CDPConn, timeout time.Duration) error {
|
||||||
if c == nil {
|
if c == nil {
|
||||||
return fmt.Errorf("cdp wait load: conexion nula")
|
return fmt.Errorf("cdp wait load: conexion nula")
|
||||||
@@ -17,19 +19,35 @@ func CdpWaitLoad(c *CDPConn, timeout time.Duration) error {
|
|||||||
timeout = 30 * time.Second
|
timeout = 30 * time.Second
|
||||||
}
|
}
|
||||||
|
|
||||||
deadline := time.Now().Add(timeout)
|
// Fast path: si el documento ya terminó de cargar, no esperamos eventos.
|
||||||
interval := 200 * time.Millisecond
|
if rs, err := CdpEvaluate(c, "document.readyState"); err == nil && rs == "complete" {
|
||||||
|
|
||||||
for time.Now().Before(deadline) {
|
|
||||||
result, err := CdpEvaluate(c, "document.readyState")
|
|
||||||
if err != nil {
|
|
||||||
return fmt.Errorf("cdp wait load: error evaluando readyState: %w", err)
|
|
||||||
}
|
|
||||||
if result == "complete" {
|
|
||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
time.Sleep(interval)
|
|
||||||
|
// Habilitar Page (idempotente, cacheado) y suscribir el evento de carga.
|
||||||
|
if err := c.ensurePage(); err != nil {
|
||||||
|
return fmt.Errorf("cdp wait load: Page.enable: %w", err)
|
||||||
|
}
|
||||||
|
loaded := make(chan struct{}, 1)
|
||||||
|
cancel := c.OnEvent("Page.loadEventFired", func(_ string, _ map[string]any) {
|
||||||
|
select {
|
||||||
|
case loaded <- struct{}{}:
|
||||||
|
default:
|
||||||
|
}
|
||||||
|
})
|
||||||
|
defer cancel()
|
||||||
|
|
||||||
|
// Re-chequear readyState tras suscribir: si la carga terminó entre el fast
|
||||||
|
// path y el registro del handler, ya no llegaría el evento (carrera) — lo
|
||||||
|
// captamos aquí en vez de colgarnos hasta el timeout.
|
||||||
|
if rs, err := CdpEvaluate(c, "document.readyState"); err == nil && rs == "complete" {
|
||||||
|
return nil
|
||||||
}
|
}
|
||||||
|
|
||||||
|
select {
|
||||||
|
case <-loaded:
|
||||||
|
return nil
|
||||||
|
case <-time.After(timeout):
|
||||||
return fmt.Errorf("cdp wait load: pagina no cargo despues de %s", timeout)
|
return fmt.Errorf("cdp wait load: pagina no cargo despues de %s", timeout)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -3,11 +3,11 @@ name: cdp_wait_load
|
|||||||
kind: function
|
kind: function
|
||||||
lang: go
|
lang: go
|
||||||
domain: browser
|
domain: browser
|
||||||
version: "1.0.0"
|
version: "1.1.0"
|
||||||
purity: impure
|
purity: impure
|
||||||
signature: "func CdpWaitLoad(c *CDPConn, timeout time.Duration) error"
|
signature: "func CdpWaitLoad(c *CDPConn, timeout time.Duration) error"
|
||||||
description: "Espera a que la pagina actual termine de cargar completamente. Hace polling de document.readyState via Runtime.evaluate cada 200ms hasta que sea \"complete\", o hasta que se agote el timeout. Retorna error inmediato si CdpEvaluate falla (la conexion puede estar rota)."
|
description: "Espera a que la pagina actual termine de cargar completamente. Bloquea hasta recibir el evento CDP Page.loadEventFired (sin polling), con un fast path inicial de document.readyState: si ya esta complete, retorna de inmediato. Retorna error si se agota el timeout o si no logra habilitar el dominio Page."
|
||||||
tags: [chrome, cdp, browser, automation, wait, polling, devtools, readystate, load]
|
tags: [chrome, cdp, browser, automation, wait, event, devtools, readystate, load, loadeventfired, navegator]
|
||||||
uses_functions: [cdp_evaluate_go_browser]
|
uses_functions: [cdp_evaluate_go_browser]
|
||||||
uses_types: []
|
uses_types: []
|
||||||
returns: []
|
returns: []
|
||||||
@@ -42,6 +42,10 @@ html, _ := CdpGetHTML(conn)
|
|||||||
|
|
||||||
## Notas
|
## Notas
|
||||||
|
|
||||||
A diferencia de `CdpWaitElement`, que ignora errores de `CdpEvaluate` durante el polling (la pagina puede aun no estar lista), `CdpWaitLoad` retorna el error inmediatamente porque un fallo en `document.readyState` indica una conexion rota, no una condicion transitoria.
|
Bloquea esperando el evento CDP `Page.loadEventFired` (sin polling). Antes de esperar hace un fast path con `document.readyState`: si la página ya está `complete`, retorna de inmediato sin armar el handler. Tras suscribir el evento re-chequea `readyState` una vez más para no perder la carga por una carrera entre el fast path y el registro del handler. Habilita el dominio `Page` vía `ensurePage` (cacheado por conexión, idempotente).
|
||||||
|
|
||||||
Si `timeout <= 0` usa 30s por defecto (mas largo que `CdpWaitElement` porque la carga completa de red puede tardar mas que la aparicion de un elemento DOM).
|
Si `timeout <= 0` usa 30s por defecto (mas largo que `CdpWaitElement` porque la carga completa de red puede tardar mas que la aparicion de un elemento DOM).
|
||||||
|
|
||||||
|
## Capability growth log
|
||||||
|
|
||||||
|
- v1.1.0 (2026-06-13) — De polling de `document.readyState` cada 200ms a esperar el evento `Page.loadEventFired` (vía `OnEvent` + canal con timeout), con fast path inicial de `readyState`. Elimina los round-trips de polling y la cuantización de ±200ms: si la página ya está cargada retorna en microsegundos.
|
||||||
|
|||||||
@@ -0,0 +1,347 @@
|
|||||||
|
package infra
|
||||||
|
|
||||||
|
import (
|
||||||
|
"database/sql"
|
||||||
|
"fmt"
|
||||||
|
"os/exec"
|
||||||
|
"path/filepath"
|
||||||
|
"sort"
|
||||||
|
"strings"
|
||||||
|
"text/tabwriter"
|
||||||
|
|
||||||
|
_ "github.com/mattn/go-sqlite3"
|
||||||
|
)
|
||||||
|
|
||||||
|
// ProjectCoverage reports how well a project registered in registry.db is
|
||||||
|
// backed by a Gitea sub-repo and how many of its children (apps + analyses)
|
||||||
|
// are actually cloned on disk. It is the engine of `fn doctor projects`.
|
||||||
|
//
|
||||||
|
// The audit only touches the local filesystem and registry.db: it never hits
|
||||||
|
// the network nor the Gitea API, so it runs fast and requires no token.
|
||||||
|
type ProjectCoverage struct {
|
||||||
|
ProjectID string `json:"project_id"`
|
||||||
|
DirPath string `json:"dir_path"`
|
||||||
|
HasGit bool `json:"has_git"` // <root>/<dir_path>/.git exists as a directory
|
||||||
|
HasRemote bool `json:"has_remote"` // git -C <dir> remote get-url origin returned a non-empty url
|
||||||
|
RepoURLDeclared bool `json:"repo_url_declared"` // projects.repo_url != ""
|
||||||
|
ChildrenInDB int `json:"children_in_db"` // apps + analyses with project_id = ProjectID
|
||||||
|
ChildrenCloned int `json:"children_cloned"` // of those, how many have a .git on disk
|
||||||
|
ChildrenMissing int `json:"children_missing"` // ChildrenInDB - ChildrenCloned (at risk when re-cloning)
|
||||||
|
Issues []string `json:"issues"` // e.g. "dir_not_found", "no_gitea_repo", "children_missing"
|
||||||
|
}
|
||||||
|
|
||||||
|
// AuditProjectsCoverage walks every row in the projects table and reports, per
|
||||||
|
// project, whether it has a local git repo, whether that repo declares a remote
|
||||||
|
// origin, whether its repo_url is filled in registry.db, and how many of its
|
||||||
|
// children (apps + analyses) are cloned versus only known to the database.
|
||||||
|
//
|
||||||
|
// registryRoot is the repository root (the directory that holds registry.db).
|
||||||
|
// All relative dir_path values are resolved against it.
|
||||||
|
//
|
||||||
|
// Returns an error only if registry.db cannot be opened or queried. Projects
|
||||||
|
// whose directory is missing on disk are still reported, flagged with the
|
||||||
|
// "dir_not_found" issue, so the caller can surface them rather than silently
|
||||||
|
// dropping them.
|
||||||
|
func AuditProjectsCoverage(registryRoot string) ([]ProjectCoverage, error) {
|
||||||
|
dbPath := filepath.Join(registryRoot, "registry.db")
|
||||||
|
dsn := fmt.Sprintf("file:%s?mode=ro&_foreign_keys=on", dbPath)
|
||||||
|
db, err := sql.Open("sqlite3", dsn)
|
||||||
|
if err != nil {
|
||||||
|
return nil, fmt.Errorf("audit_projects_coverage: open db: %w", err)
|
||||||
|
}
|
||||||
|
defer db.Close()
|
||||||
|
if err := db.Ping(); err != nil {
|
||||||
|
return nil, fmt.Errorf("audit_projects_coverage: ping db: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
rows, err := db.Query(`SELECT id, COALESCE(dir_path,''), COALESCE(repo_url,'') FROM projects ORDER BY id`)
|
||||||
|
if err != nil {
|
||||||
|
return nil, fmt.Errorf("audit_projects_coverage: query projects: %w", err)
|
||||||
|
}
|
||||||
|
defer rows.Close()
|
||||||
|
|
||||||
|
var out []ProjectCoverage
|
||||||
|
for rows.Next() {
|
||||||
|
var pc ProjectCoverage
|
||||||
|
var dirPath, repoURL string
|
||||||
|
if err := rows.Scan(&pc.ProjectID, &dirPath, &repoURL); err != nil {
|
||||||
|
return nil, fmt.Errorf("audit_projects_coverage: scan project: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
// DirPath: use projects.dir_path; if empty, derive projects/<id>.
|
||||||
|
if dirPath == "" {
|
||||||
|
dirPath = filepath.Join("projects", pc.ProjectID)
|
||||||
|
}
|
||||||
|
pc.DirPath = dirPath
|
||||||
|
pc.RepoURLDeclared = repoURL != ""
|
||||||
|
|
||||||
|
absDir := dirPath
|
||||||
|
if !filepath.IsAbs(absDir) {
|
||||||
|
absDir = filepath.Join(registryRoot, dirPath)
|
||||||
|
}
|
||||||
|
|
||||||
|
dirFound := dirExists(absDir)
|
||||||
|
if !dirFound {
|
||||||
|
pc.Issues = append(pc.Issues, "dir_not_found")
|
||||||
|
} else {
|
||||||
|
pc.HasGit = dirExists(filepath.Join(absDir, ".git"))
|
||||||
|
if pc.HasGit {
|
||||||
|
pc.HasRemote = gitHasRemoteOrigin(absDir)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Children: apps + analyses with this project_id.
|
||||||
|
children, err := projectChildren(db, pc.ProjectID)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
pc.ChildrenInDB = len(children)
|
||||||
|
for _, childDir := range children {
|
||||||
|
absChild := childDir
|
||||||
|
if !filepath.IsAbs(absChild) {
|
||||||
|
absChild = filepath.Join(registryRoot, childDir)
|
||||||
|
}
|
||||||
|
if dirExists(filepath.Join(absChild, ".git")) {
|
||||||
|
pc.ChildrenCloned++
|
||||||
|
}
|
||||||
|
}
|
||||||
|
pc.ChildrenMissing = pc.ChildrenInDB - pc.ChildrenCloned
|
||||||
|
|
||||||
|
// Issues derived from the gathered state.
|
||||||
|
if !pc.HasRemote && !pc.RepoURLDeclared {
|
||||||
|
pc.Issues = append(pc.Issues, "no_gitea_repo")
|
||||||
|
}
|
||||||
|
if pc.ChildrenMissing > 0 {
|
||||||
|
pc.Issues = append(pc.Issues, "children_missing")
|
||||||
|
}
|
||||||
|
|
||||||
|
out = append(out, pc)
|
||||||
|
}
|
||||||
|
return out, rows.Err()
|
||||||
|
}
|
||||||
|
|
||||||
|
// projectChildren returns the dir_path of every app and analysis whose
|
||||||
|
// project_id matches the given project id.
|
||||||
|
func projectChildren(db *sql.DB, projectID string) ([]string, error) {
|
||||||
|
var dirs []string
|
||||||
|
for _, table := range []string{"apps", "analysis"} {
|
||||||
|
q := fmt.Sprintf(`SELECT COALESCE(dir_path,'') FROM %s WHERE project_id = ?`, table)
|
||||||
|
rows, err := db.Query(q, projectID)
|
||||||
|
if err != nil {
|
||||||
|
return nil, fmt.Errorf("audit_projects_coverage: query %s children: %w", table, err)
|
||||||
|
}
|
||||||
|
for rows.Next() {
|
||||||
|
var dp string
|
||||||
|
if err := rows.Scan(&dp); err != nil {
|
||||||
|
rows.Close()
|
||||||
|
return nil, fmt.Errorf("audit_projects_coverage: scan %s child: %w", table, err)
|
||||||
|
}
|
||||||
|
if dp != "" {
|
||||||
|
dirs = append(dirs, dp)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if err := rows.Err(); err != nil {
|
||||||
|
rows.Close()
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
rows.Close()
|
||||||
|
}
|
||||||
|
return dirs, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// gitHasRemoteOrigin reports whether the git repo at dir declares an origin
|
||||||
|
// remote with a non-empty URL. It shells out to `git -C <dir> remote get-url
|
||||||
|
// origin` and treats exit 0 with non-empty output as success. Any error
|
||||||
|
// (no origin, not a repo, git missing) is reported as false.
|
||||||
|
func gitHasRemoteOrigin(dir string) bool {
|
||||||
|
cmd := exec.Command("git", "-C", dir, "remote", "get-url", "origin")
|
||||||
|
outBytes, err := cmd.Output()
|
||||||
|
if err != nil {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
return strings.TrimSpace(string(outBytes)) != ""
|
||||||
|
}
|
||||||
|
|
||||||
|
// FormatProjectsCoverage renders a tabwriter table, one row per project, with
|
||||||
|
// git / remote / repo_url presence, children cloned vs declared, and the
|
||||||
|
// issues list. When no project has coverage problems it makes that explicit.
|
||||||
|
func FormatProjectsCoverage(rows []ProjectCoverage) string {
|
||||||
|
var sb strings.Builder
|
||||||
|
w := tabwriter.NewWriter(&sb, 0, 0, 2, ' ', 0)
|
||||||
|
fmt.Fprintln(w, "PROJECT\tGIT\tREMOTE\tREPO_URL\tCHILDREN\tISSUES")
|
||||||
|
|
||||||
|
withIssues := 0
|
||||||
|
for _, r := range rows {
|
||||||
|
issues := "-"
|
||||||
|
if len(r.Issues) > 0 {
|
||||||
|
issues = strings.Join(r.Issues, "; ")
|
||||||
|
withIssues++
|
||||||
|
}
|
||||||
|
fmt.Fprintf(w, "%s\t%s\t%s\t%s\t%d/%d\t%s\n",
|
||||||
|
r.ProjectID,
|
||||||
|
checkMark(r.HasGit),
|
||||||
|
checkMark(r.HasRemote),
|
||||||
|
checkMark(r.RepoURLDeclared),
|
||||||
|
r.ChildrenCloned, r.ChildrenInDB,
|
||||||
|
issues,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
w.Flush()
|
||||||
|
|
||||||
|
if len(rows) == 0 {
|
||||||
|
sb.WriteString("\nNo projects registered.\n")
|
||||||
|
return sb.String()
|
||||||
|
}
|
||||||
|
if withIssues == 0 {
|
||||||
|
sb.WriteString("\n0 projects con problemas de cobertura.\n")
|
||||||
|
} else {
|
||||||
|
fmt.Fprintf(&sb, "\n%d/%d projects con problemas de cobertura.\n", withIssues, len(rows))
|
||||||
|
}
|
||||||
|
return sb.String()
|
||||||
|
}
|
||||||
|
|
||||||
|
// checkMark returns ✓ for true, ✗ for false.
|
||||||
|
func checkMark(b bool) string {
|
||||||
|
if b {
|
||||||
|
return "✓"
|
||||||
|
}
|
||||||
|
return "✗"
|
||||||
|
}
|
||||||
|
|
||||||
|
// OrphanProjectRef describes a project_id that one or more children (apps and
|
||||||
|
// analyses) reference in registry.db but for which no row exists in the projects
|
||||||
|
// table. This is the inverse drift of AuditProjectsCoverage: instead of a
|
||||||
|
// registered project whose children are not cloned, it surfaces an umbrella
|
||||||
|
// project that was never synced to this PC (or never created here at all), so
|
||||||
|
// the children are pointing at a project that this registry does not know about.
|
||||||
|
// That is a data-loss risk: the umbrella project may exist on another machine
|
||||||
|
// and the link would be silently broken on a clone/sync into this one.
|
||||||
|
type OrphanProjectRef struct {
|
||||||
|
ProjectID string `json:"project_id"` // referenced by children but missing from projects
|
||||||
|
Apps []string `json:"apps"` // ids of apps that reference it (sorted)
|
||||||
|
Analyses []string `json:"analyses"` // ids of analyses that reference it (sorted)
|
||||||
|
}
|
||||||
|
|
||||||
|
// FindOrphanProjectRefs scans every app and analysis that declares a non-empty
|
||||||
|
// project_id and reports those project_id values that have no matching row in
|
||||||
|
// the projects table. Each orphan groups the ids of the apps and analyses that
|
||||||
|
// reference it.
|
||||||
|
//
|
||||||
|
// registryRoot is the repository root (the directory that holds registry.db).
|
||||||
|
//
|
||||||
|
// The result is sorted by ProjectID, and within each entry the Apps and
|
||||||
|
// Analyses lists are sorted alphabetically. When every child references a known
|
||||||
|
// project, an empty (non-nil) slice is returned with a nil error.
|
||||||
|
//
|
||||||
|
// Like AuditProjectsCoverage it only reads registry.db (opened read-only) and
|
||||||
|
// never touches the network nor the Gitea API. Returns an error only when
|
||||||
|
// registry.db cannot be opened or queried.
|
||||||
|
func FindOrphanProjectRefs(registryRoot string) ([]OrphanProjectRef, error) {
|
||||||
|
dbPath := filepath.Join(registryRoot, "registry.db")
|
||||||
|
dsn := fmt.Sprintf("file:%s?mode=ro&_foreign_keys=on", dbPath)
|
||||||
|
db, err := sql.Open("sqlite3", dsn)
|
||||||
|
if err != nil {
|
||||||
|
return nil, fmt.Errorf("find_orphan_project_refs: open db: %w", err)
|
||||||
|
}
|
||||||
|
defer db.Close()
|
||||||
|
if err := db.Ping(); err != nil {
|
||||||
|
return nil, fmt.Errorf("find_orphan_project_refs: ping db: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Set of known project ids.
|
||||||
|
known := map[string]bool{}
|
||||||
|
prows, err := db.Query(`SELECT id FROM projects`)
|
||||||
|
if err != nil {
|
||||||
|
return nil, fmt.Errorf("find_orphan_project_refs: query projects: %w", err)
|
||||||
|
}
|
||||||
|
for prows.Next() {
|
||||||
|
var id string
|
||||||
|
if err := prows.Scan(&id); err != nil {
|
||||||
|
prows.Close()
|
||||||
|
return nil, fmt.Errorf("find_orphan_project_refs: scan project: %w", err)
|
||||||
|
}
|
||||||
|
known[id] = true
|
||||||
|
}
|
||||||
|
if err := prows.Err(); err != nil {
|
||||||
|
prows.Close()
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
prows.Close()
|
||||||
|
|
||||||
|
// Accumulate orphan references from apps and analyses.
|
||||||
|
orphans := map[string]*OrphanProjectRef{}
|
||||||
|
|
||||||
|
get := func(pid string) *OrphanProjectRef {
|
||||||
|
o, ok := orphans[pid]
|
||||||
|
if !ok {
|
||||||
|
o = &OrphanProjectRef{ProjectID: pid}
|
||||||
|
orphans[pid] = o
|
||||||
|
}
|
||||||
|
return o
|
||||||
|
}
|
||||||
|
|
||||||
|
for _, table := range []string{"apps", "analysis"} {
|
||||||
|
q := fmt.Sprintf(`SELECT id, project_id FROM %s WHERE project_id != ''`, table)
|
||||||
|
rows, err := db.Query(q)
|
||||||
|
if err != nil {
|
||||||
|
return nil, fmt.Errorf("find_orphan_project_refs: query %s: %w", table, err)
|
||||||
|
}
|
||||||
|
for rows.Next() {
|
||||||
|
var id, pid string
|
||||||
|
if err := rows.Scan(&id, &pid); err != nil {
|
||||||
|
rows.Close()
|
||||||
|
return nil, fmt.Errorf("find_orphan_project_refs: scan %s row: %w", table, err)
|
||||||
|
}
|
||||||
|
if known[pid] {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
o := get(pid)
|
||||||
|
if table == "apps" {
|
||||||
|
o.Apps = append(o.Apps, id)
|
||||||
|
} else {
|
||||||
|
o.Analyses = append(o.Analyses, id)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if err := rows.Err(); err != nil {
|
||||||
|
rows.Close()
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
rows.Close()
|
||||||
|
}
|
||||||
|
|
||||||
|
out := make([]OrphanProjectRef, 0, len(orphans))
|
||||||
|
for _, o := range orphans {
|
||||||
|
sort.Strings(o.Apps)
|
||||||
|
sort.Strings(o.Analyses)
|
||||||
|
out = append(out, *o)
|
||||||
|
}
|
||||||
|
sort.Slice(out, func(i, j int) bool { return out[i].ProjectID < out[j].ProjectID })
|
||||||
|
return out, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// FormatOrphanProjectRefs renders a human-readable report of orphan project_id
|
||||||
|
// references, one block per orphan, listing how many apps and analyses point at
|
||||||
|
// the missing project plus their ids. When there are no orphans it makes that
|
||||||
|
// explicit on a single line.
|
||||||
|
func FormatOrphanProjectRefs(rows []OrphanProjectRef) string {
|
||||||
|
if len(rows) == 0 {
|
||||||
|
return "0 project_id huérfanos (todos los hijos tienen project declarado)\n"
|
||||||
|
}
|
||||||
|
|
||||||
|
var sb strings.Builder
|
||||||
|
w := tabwriter.NewWriter(&sb, 0, 0, 2, ' ', 0)
|
||||||
|
fmt.Fprintln(w, "PROJECT_ID\tAPPS\tANALYSES\tREFERENCED_BY")
|
||||||
|
for _, r := range rows {
|
||||||
|
refs := append(append([]string{}, r.Apps...), r.Analyses...)
|
||||||
|
refStr := strings.Join(refs, ", ")
|
||||||
|
if refStr == "" {
|
||||||
|
refStr = "-"
|
||||||
|
}
|
||||||
|
fmt.Fprintf(w, "%s\t%d\t%d\t%s\n",
|
||||||
|
r.ProjectID, len(r.Apps), len(r.Analyses), refStr)
|
||||||
|
}
|
||||||
|
w.Flush()
|
||||||
|
|
||||||
|
fmt.Fprintf(&sb, "\n%d project_id huérfanos (referenciados por hijos pero sin fila en projects).\n", len(rows))
|
||||||
|
return sb.String()
|
||||||
|
}
|
||||||
@@ -0,0 +1,109 @@
|
|||||||
|
---
|
||||||
|
name: audit_projects_coverage
|
||||||
|
kind: function
|
||||||
|
lang: go
|
||||||
|
domain: infra
|
||||||
|
version: "1.0.0"
|
||||||
|
purity: impure
|
||||||
|
signature: "func AuditProjectsCoverage(registryRoot string) ([]ProjectCoverage, error)"
|
||||||
|
description: "Audita la cobertura de los projects del registry frente a sus sub-repos Gitea: comprueba si cada project tiene .git local, remote origin y repo_url declarado, y cuantos de sus hijos (apps + analyses) estan clonados en disco versus solo conocidos por la BD. Motor del subcomando fn doctor projects. Solo lee registry.db + filesystem + git local, nunca la red ni la API de Gitea. Incluye FindOrphanProjectRefs, el check inverso: detecta apps o analyses que declaran un project_id sin fila en la tabla projects (project paraguas huerfano, riesgo de perdida al sincronizar)."
|
||||||
|
tags: [projects, gitea, subrepo, audit, infra, fn-doctor, doctor]
|
||||||
|
uses_functions: []
|
||||||
|
uses_types: []
|
||||||
|
returns: []
|
||||||
|
returns_optional: false
|
||||||
|
error_type: "error_go_core"
|
||||||
|
imports: ["database/sql", "os/exec", "path/filepath", "strings", "text/tabwriter", "github.com/mattn/go-sqlite3"]
|
||||||
|
tested: true
|
||||||
|
tests: ["healthy project con un hijo sin clonar marca children_missing", "project sin repo_url ni remote marca no_gitea_repo", "project sin directorio en disco marca dir_not_found", "error si registry.db no existe", "repo con origin devuelve true", "repo sin origin devuelve false", "sin issues lo deja claro", "con issues cuenta los afectados", "app con project_id huerfano lo detecta y agrupa ordenado", "app con project_id valido no aparece", "sin huerfanos devuelve slice vacio sin error", "sin huerfanos lo deja claro", "con huerfanos lista ids y cuenta"]
|
||||||
|
test_file_path: "functions/infra/audit_projects_coverage_test.go"
|
||||||
|
file_path: "functions/infra/audit_projects_coverage.go"
|
||||||
|
params:
|
||||||
|
- name: registryRoot
|
||||||
|
desc: "Raiz del repositorio (el directorio que contiene registry.db). Los dir_path relativos de projects, apps y analysis se resuelven contra esta raiz."
|
||||||
|
output: "Slice de ProjectCoverage, una entrada por fila de la tabla projects, con flags de git/remote/repo_url, conteos de hijos clonados vs declarados, y la lista de issues detectados. La funcion de formato FormatProjectsCoverage produce una tabla de texto humano."
|
||||||
|
---
|
||||||
|
|
||||||
|
## Ejemplo
|
||||||
|
|
||||||
|
```go
|
||||||
|
package main
|
||||||
|
|
||||||
|
import (
|
||||||
|
"fmt"
|
||||||
|
|
||||||
|
"fn-registry/functions/infra"
|
||||||
|
)
|
||||||
|
|
||||||
|
func main() {
|
||||||
|
rows, err := infra.AuditProjectsCoverage("/home/enmanuel/fn_registry")
|
||||||
|
if err != nil {
|
||||||
|
panic(err)
|
||||||
|
}
|
||||||
|
fmt.Print(infra.FormatProjectsCoverage(rows))
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
Salida típica:
|
||||||
|
|
||||||
|
```
|
||||||
|
PROJECT GIT REMOTE REPO_URL CHILDREN ISSUES
|
||||||
|
fleet_monitoring ✓ ✓ ✓ 2/2 -
|
||||||
|
fn_monitoring ✓ ✓ ✓ 3/3 -
|
||||||
|
message_bus ✓ ✓ ✓ 3/4 children_missing
|
||||||
|
web_scraping ✗ ✗ ✗ 0/3 no_gitea_repo; children_missing
|
||||||
|
|
||||||
|
1/4 projects con problemas de cobertura.
|
||||||
|
```
|
||||||
|
|
||||||
|
## Check inverso: FindOrphanProjectRefs
|
||||||
|
|
||||||
|
Mientras `AuditProjectsCoverage` parte de la tabla `projects` y mira hacia abajo (¿están sus hijos clonados?), `FindOrphanProjectRefs` recorre el grafo en sentido contrario: parte de las apps y analyses y mira hacia arriba (¿existe el project paraguas que declaran?). Detecta el drift inverso, apps o analyses cuyo `project_id` no tiene ninguna fila en la tabla `projects`. Es un project huérfano: existe en otro PC y nunca se sincronizó a este, o nunca se creó aquí. Es un riesgo de pérdida silenciosa, porque el enlace del hijo apunta a un project que este registro no conoce.
|
||||||
|
|
||||||
|
```go
|
||||||
|
package main
|
||||||
|
|
||||||
|
import (
|
||||||
|
"fmt"
|
||||||
|
|
||||||
|
"fn-registry/functions/infra"
|
||||||
|
)
|
||||||
|
|
||||||
|
func main() {
|
||||||
|
orphans, err := infra.FindOrphanProjectRefs("/home/enmanuel/fn_registry")
|
||||||
|
if err != nil {
|
||||||
|
panic(err)
|
||||||
|
}
|
||||||
|
fmt.Print(infra.FormatOrphanProjectRefs(orphans))
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
La firma es `func FindOrphanProjectRefs(registryRoot string) ([]OrphanProjectRef, error)`. Cada `OrphanProjectRef` agrupa, por `ProjectID` huérfano, los ids de las apps (`Apps`) y analyses (`Analyses`) que lo referencian, ambas listas ordenadas alfabéticamente y el slice resultante ordenado por `ProjectID`. Cuando todos los hijos apuntan a un project conocido devuelve un slice vacío (no nil) sin error. `FormatOrphanProjectRefs` produce una tabla de texto humano con el `project_id`, cuántas apps y analyses lo referencian y sus ids; si no hay huérfanos imprime una sola línea dejándolo claro.
|
||||||
|
|
||||||
|
Caso real detectado en este registro: apps con `project_id` ∈ {`element_agents`, `imagegen`, `osint_graph`} sin fila correspondiente en `projects`.
|
||||||
|
|
||||||
|
Salida típica con huérfanos:
|
||||||
|
|
||||||
|
```
|
||||||
|
PROJECT_ID APPS ANALYSES REFERENCED_BY
|
||||||
|
element_agents 1 0 shell_agent
|
||||||
|
imagegen 1 0 imagegen_ui
|
||||||
|
osint_graph 2 1 graph_explorer, scraper, gliner_glirel_tuning
|
||||||
|
|
||||||
|
3 project_id huérfanos (referenciados por hijos pero sin fila en projects).
|
||||||
|
```
|
||||||
|
|
||||||
|
## Cuando usarla
|
||||||
|
|
||||||
|
Úsala antes de un `/full-git-pull` masivo o tras clonar el registry en un PC nuevo para saber qué projects están realmente respaldados por su sub-repo Gitea y cuántos de sus hijos (apps y analyses) quedarían sin clonar. También como motor del futuro subcomando `fn doctor projects`: el caller la enchufa desde `cmd/fn/doctor.go` igual que `AuditUsesFunctions` o `AuditServicesSpec`, formatea con `FormatProjectsCoverage` para texto humano y serializa el slice directamente para `--json`.
|
||||||
|
|
||||||
|
## Gotchas
|
||||||
|
|
||||||
|
- Es **impura**: lee `registry.db` (abierto en modo read-only `?mode=ro`), recorre el filesystem y ejecuta `git -C <dir> remote get-url origin`. No toca la red ni la API de Gitea, así que no necesita token y es rápida.
|
||||||
|
- `HasRemote` solo se evalúa cuando el project tiene `.git` local; si no hay `.git`, queda `false` sin intentar el comando git.
|
||||||
|
- `gitHasRemoteOrigin` devuelve `false` ante cualquier error (no hay remote `origin`, no es un repo, git no instalado). No distingue "sin origin" de "git ausente"; si necesitas esa distinción, comprueba `git` por separado.
|
||||||
|
- El issue `no_gitea_repo` se emite solo cuando faltan **ambos** indicadores (`!HasRemote && !RepoURLDeclared`). Un project con `repo_url` declarado pero sin clonar (`dir_not_found`) NO se marca `no_gitea_repo` — el repo existe en Gitea, simplemente no está en este disco.
|
||||||
|
- `ChildrenMissing` cuenta los hijos (apps + analyses con ese `project_id`) cuya carpeta no tiene `.git` en disco: son los que se perderían o habría que reclonar. Cero hijos en la BD produce `0/0` y no genera issue.
|
||||||
|
- Si `projects.dir_path` está vacío, se deriva `projects/<id>`. Los `dir_path` ya absolutos se respetan tal cual.
|
||||||
|
- Devuelve error únicamente si `registry.db` no puede abrirse o consultarse. Los projects cuyo directorio no existe SÍ aparecen en el resultado, marcados con `dir_not_found`, para que el caller los muestre en vez de descartarlos en silencio.
|
||||||
|
- `FindOrphanProjectRefs` también es **impura**: lee `registry.db` en modo read-only (`?mode=ro`), pero no toca el filesystem ni git, solo cruza las tablas `projects`, `apps` y `analysis`. Ignora los hijos con `project_id` vacío (no son huérfanos, simplemente no pertenecen a ningún project). Devuelve un slice vacío no-nil cuando no hay huérfanos, así que el caller puede distinguir "sin huérfanos" (slice vacío, error nil) de "fallo al leer la BD" (error no nil).
|
||||||
@@ -0,0 +1,417 @@
|
|||||||
|
package infra
|
||||||
|
|
||||||
|
import (
|
||||||
|
"database/sql"
|
||||||
|
"os"
|
||||||
|
"os/exec"
|
||||||
|
"path/filepath"
|
||||||
|
"testing"
|
||||||
|
|
||||||
|
_ "github.com/mattn/go-sqlite3"
|
||||||
|
)
|
||||||
|
|
||||||
|
// seedProjectsRegistry builds a temp registry.db with the columns
|
||||||
|
// AuditProjectsCoverage reads, plus a handful of on-disk directories that
|
||||||
|
// model the cloned/missing states.
|
||||||
|
func seedProjectsRegistry(t *testing.T) string {
|
||||||
|
t.Helper()
|
||||||
|
dir := t.TempDir()
|
||||||
|
dbPath := filepath.Join(dir, "registry.db")
|
||||||
|
|
||||||
|
db, err := sql.Open("sqlite3", dbPath)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("open temp db: %v", err)
|
||||||
|
}
|
||||||
|
defer db.Close()
|
||||||
|
|
||||||
|
_, err = db.Exec(`
|
||||||
|
CREATE TABLE projects (
|
||||||
|
id TEXT PRIMARY KEY,
|
||||||
|
dir_path TEXT NOT NULL DEFAULT '',
|
||||||
|
repo_url TEXT NOT NULL DEFAULT ''
|
||||||
|
);
|
||||||
|
CREATE TABLE apps (
|
||||||
|
id TEXT PRIMARY KEY,
|
||||||
|
dir_path TEXT NOT NULL DEFAULT '',
|
||||||
|
project_id TEXT NOT NULL DEFAULT ''
|
||||||
|
);
|
||||||
|
CREATE TABLE analysis (
|
||||||
|
id TEXT PRIMARY KEY,
|
||||||
|
dir_path TEXT NOT NULL DEFAULT '',
|
||||||
|
project_id TEXT NOT NULL DEFAULT ''
|
||||||
|
);
|
||||||
|
`)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("create schema: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
_, err = db.Exec(`
|
||||||
|
INSERT INTO projects VALUES
|
||||||
|
('healthy', 'projects/healthy', 'https://gitea.example/dataforge/healthy'),
|
||||||
|
('no_repo', 'projects/no_repo', ''),
|
||||||
|
('missing', 'projects/missing', 'https://gitea.example/dataforge/missing');
|
||||||
|
INSERT INTO apps VALUES
|
||||||
|
('app_cloned', 'projects/healthy/apps/app_cloned', 'healthy'),
|
||||||
|
('app_orphan', 'projects/healthy/apps/app_orphan', 'healthy');
|
||||||
|
INSERT INTO analysis VALUES
|
||||||
|
('an_cloned', 'projects/healthy/analysis/an_cloned', 'healthy');
|
||||||
|
`)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("seed data: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
// healthy: directory + .git present; one child cloned, one missing.
|
||||||
|
mkGitDir(t, dir, "projects/healthy")
|
||||||
|
mkGitDir(t, dir, "projects/healthy/apps/app_cloned")
|
||||||
|
mkGitDir(t, dir, "projects/healthy/analysis/an_cloned")
|
||||||
|
// app_orphan has no .git on disk → counts as missing.
|
||||||
|
|
||||||
|
// no_repo: directory exists, no .git.
|
||||||
|
if err := os.MkdirAll(filepath.Join(dir, "projects/no_repo"), 0o755); err != nil {
|
||||||
|
t.Fatalf("mkdir no_repo: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
// missing: no directory at all on disk.
|
||||||
|
|
||||||
|
return dir
|
||||||
|
}
|
||||||
|
|
||||||
|
// mkGitDir creates <root>/<rel>/.git so dirExists treats it as a git repo.
|
||||||
|
func mkGitDir(t *testing.T, root, rel string) {
|
||||||
|
t.Helper()
|
||||||
|
if err := os.MkdirAll(filepath.Join(root, rel, ".git"), 0o755); err != nil {
|
||||||
|
t.Fatalf("mkdir %s/.git: %v", rel, err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func findCoverage(rows []ProjectCoverage, id string) *ProjectCoverage {
|
||||||
|
for i := range rows {
|
||||||
|
if rows[i].ProjectID == id {
|
||||||
|
return &rows[i]
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func hasIssue(pc *ProjectCoverage, issue string) bool {
|
||||||
|
for _, i := range pc.Issues {
|
||||||
|
if i == issue {
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestAuditProjectsCoverage(t *testing.T) {
|
||||||
|
t.Run("healthy project con un hijo sin clonar marca children_missing", func(t *testing.T) {
|
||||||
|
dir := seedProjectsRegistry(t)
|
||||||
|
rows, err := AuditProjectsCoverage(dir)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("AuditProjectsCoverage error: %v", err)
|
||||||
|
}
|
||||||
|
pc := findCoverage(rows, "healthy")
|
||||||
|
if pc == nil {
|
||||||
|
t.Fatal("project 'healthy' missing from results")
|
||||||
|
}
|
||||||
|
if !pc.HasGit {
|
||||||
|
t.Error("expected HasGit=true for healthy")
|
||||||
|
}
|
||||||
|
if !pc.RepoURLDeclared {
|
||||||
|
t.Error("expected RepoURLDeclared=true for healthy")
|
||||||
|
}
|
||||||
|
if pc.ChildrenInDB != 3 {
|
||||||
|
t.Errorf("expected 3 children in db, got %d", pc.ChildrenInDB)
|
||||||
|
}
|
||||||
|
if pc.ChildrenCloned != 2 {
|
||||||
|
t.Errorf("expected 2 cloned children, got %d", pc.ChildrenCloned)
|
||||||
|
}
|
||||||
|
if pc.ChildrenMissing != 1 {
|
||||||
|
t.Errorf("expected 1 missing child, got %d", pc.ChildrenMissing)
|
||||||
|
}
|
||||||
|
if !hasIssue(pc, "children_missing") {
|
||||||
|
t.Errorf("expected children_missing issue, got %v", pc.Issues)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
t.Run("project sin repo_url ni remote marca no_gitea_repo", func(t *testing.T) {
|
||||||
|
dir := seedProjectsRegistry(t)
|
||||||
|
rows, err := AuditProjectsCoverage(dir)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("error: %v", err)
|
||||||
|
}
|
||||||
|
pc := findCoverage(rows, "no_repo")
|
||||||
|
if pc == nil {
|
||||||
|
t.Fatal("project 'no_repo' missing")
|
||||||
|
}
|
||||||
|
if pc.HasGit {
|
||||||
|
t.Error("expected HasGit=false for no_repo")
|
||||||
|
}
|
||||||
|
if pc.RepoURLDeclared {
|
||||||
|
t.Error("expected RepoURLDeclared=false for no_repo")
|
||||||
|
}
|
||||||
|
if !hasIssue(pc, "no_gitea_repo") {
|
||||||
|
t.Errorf("expected no_gitea_repo issue, got %v", pc.Issues)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
t.Run("project sin directorio en disco marca dir_not_found", func(t *testing.T) {
|
||||||
|
dir := seedProjectsRegistry(t)
|
||||||
|
rows, err := AuditProjectsCoverage(dir)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("error: %v", err)
|
||||||
|
}
|
||||||
|
pc := findCoverage(rows, "missing")
|
||||||
|
if pc == nil {
|
||||||
|
t.Fatal("project 'missing' missing")
|
||||||
|
}
|
||||||
|
if !hasIssue(pc, "dir_not_found") {
|
||||||
|
t.Errorf("expected dir_not_found issue, got %v", pc.Issues)
|
||||||
|
}
|
||||||
|
if pc.HasGit {
|
||||||
|
t.Error("expected HasGit=false when dir not found")
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
t.Run("error si registry.db no existe", func(t *testing.T) {
|
||||||
|
dir := t.TempDir()
|
||||||
|
if _, err := AuditProjectsCoverage(dir); err == nil {
|
||||||
|
t.Error("expected error for missing db, got nil")
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestGitHasRemoteOrigin(t *testing.T) {
|
||||||
|
if _, err := exec.LookPath("git"); err != nil {
|
||||||
|
t.Skip("git not available")
|
||||||
|
}
|
||||||
|
t.Run("repo con origin devuelve true", func(t *testing.T) {
|
||||||
|
dir := t.TempDir()
|
||||||
|
runGit(t, dir, "init", "-q")
|
||||||
|
runGit(t, dir, "remote", "add", "origin", "https://example.com/x.git")
|
||||||
|
if !gitHasRemoteOrigin(dir) {
|
||||||
|
t.Error("expected true for repo with origin")
|
||||||
|
}
|
||||||
|
})
|
||||||
|
t.Run("repo sin origin devuelve false", func(t *testing.T) {
|
||||||
|
dir := t.TempDir()
|
||||||
|
runGit(t, dir, "init", "-q")
|
||||||
|
if gitHasRemoteOrigin(dir) {
|
||||||
|
t.Error("expected false for repo without origin")
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
func runGit(t *testing.T, dir string, args ...string) {
|
||||||
|
t.Helper()
|
||||||
|
cmd := exec.Command("git", append([]string{"-C", dir}, args...)...)
|
||||||
|
if out, err := cmd.CombinedOutput(); err != nil {
|
||||||
|
t.Fatalf("git %v: %v\n%s", args, err, out)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// seedOrphanRefsRegistry builds a temp registry.db where some children declare
|
||||||
|
// a project_id with no matching row in the projects table (orphan refs) while
|
||||||
|
// others reference a valid project. It returns the registry root directory.
|
||||||
|
func seedOrphanRefsRegistry(t *testing.T, withOrphans bool) string {
|
||||||
|
t.Helper()
|
||||||
|
dir := t.TempDir()
|
||||||
|
dbPath := filepath.Join(dir, "registry.db")
|
||||||
|
|
||||||
|
db, err := sql.Open("sqlite3", dbPath)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("open temp db: %v", err)
|
||||||
|
}
|
||||||
|
defer db.Close()
|
||||||
|
|
||||||
|
_, err = db.Exec(`
|
||||||
|
CREATE TABLE projects (
|
||||||
|
id TEXT PRIMARY KEY,
|
||||||
|
dir_path TEXT NOT NULL DEFAULT '',
|
||||||
|
repo_url TEXT NOT NULL DEFAULT ''
|
||||||
|
);
|
||||||
|
CREATE TABLE apps (
|
||||||
|
id TEXT PRIMARY KEY,
|
||||||
|
dir_path TEXT NOT NULL DEFAULT '',
|
||||||
|
project_id TEXT NOT NULL DEFAULT ''
|
||||||
|
);
|
||||||
|
CREATE TABLE analysis (
|
||||||
|
id TEXT PRIMARY KEY,
|
||||||
|
dir_path TEXT NOT NULL DEFAULT '',
|
||||||
|
project_id TEXT NOT NULL DEFAULT ''
|
||||||
|
);
|
||||||
|
`)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("create schema: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
// One known project exists in the projects table.
|
||||||
|
if _, err := db.Exec(`INSERT INTO projects VALUES ('known', 'projects/known', '')`); err != nil {
|
||||||
|
t.Fatalf("seed projects: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
// app_valid + an_valid reference the known project: never orphans.
|
||||||
|
if _, err := db.Exec(`
|
||||||
|
INSERT INTO apps VALUES ('app_valid', 'projects/known/apps/app_valid', 'known');
|
||||||
|
INSERT INTO analysis VALUES ('an_valid', 'projects/known/analysis/an_valid', 'known');
|
||||||
|
`); err != nil {
|
||||||
|
t.Fatalf("seed valid children: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
// app_no_project has an empty project_id: must be ignored.
|
||||||
|
if _, err := db.Exec(`INSERT INTO apps VALUES ('app_no_project', 'apps/app_no_project', '')`); err != nil {
|
||||||
|
t.Fatalf("seed no-project app: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
if withOrphans {
|
||||||
|
// Two apps + one analysis pointing at 'ghost' (missing from projects),
|
||||||
|
// plus one app pointing at 'specter' (also missing). Insert the apps for
|
||||||
|
// 'ghost' out of alphabetical order to exercise sorting.
|
||||||
|
if _, err := db.Exec(`
|
||||||
|
INSERT INTO apps VALUES ('app_zeta', 'apps/app_zeta', 'ghost');
|
||||||
|
INSERT INTO apps VALUES ('app_alpha', 'apps/app_alpha', 'ghost');
|
||||||
|
INSERT INTO analysis VALUES ('an_ghost', 'analysis/an_ghost', 'ghost');
|
||||||
|
INSERT INTO apps VALUES ('app_specter', 'apps/app_specter', 'specter');
|
||||||
|
`); err != nil {
|
||||||
|
t.Fatalf("seed orphan children: %v", err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return dir
|
||||||
|
}
|
||||||
|
|
||||||
|
func findOrphan(rows []OrphanProjectRef, id string) *OrphanProjectRef {
|
||||||
|
for i := range rows {
|
||||||
|
if rows[i].ProjectID == id {
|
||||||
|
return &rows[i]
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestFindOrphanProjectRefs(t *testing.T) {
|
||||||
|
t.Run("app con project_id huerfano lo detecta y agrupa ordenado", func(t *testing.T) {
|
||||||
|
dir := seedOrphanRefsRegistry(t, true)
|
||||||
|
rows, err := FindOrphanProjectRefs(dir)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("FindOrphanProjectRefs error: %v", err)
|
||||||
|
}
|
||||||
|
if len(rows) != 2 {
|
||||||
|
t.Fatalf("expected 2 orphan refs, got %d: %+v", len(rows), rows)
|
||||||
|
}
|
||||||
|
// Sorted by ProjectID: ghost before specter.
|
||||||
|
if rows[0].ProjectID != "ghost" || rows[1].ProjectID != "specter" {
|
||||||
|
t.Fatalf("expected [ghost specter], got [%s %s]", rows[0].ProjectID, rows[1].ProjectID)
|
||||||
|
}
|
||||||
|
|
||||||
|
ghost := findOrphan(rows, "ghost")
|
||||||
|
if ghost == nil {
|
||||||
|
t.Fatal("orphan 'ghost' missing")
|
||||||
|
}
|
||||||
|
wantApps := []string{"app_alpha", "app_zeta"} // alphabetical
|
||||||
|
if len(ghost.Apps) != 2 || ghost.Apps[0] != wantApps[0] || ghost.Apps[1] != wantApps[1] {
|
||||||
|
t.Errorf("expected ghost.Apps=%v, got %v", wantApps, ghost.Apps)
|
||||||
|
}
|
||||||
|
if len(ghost.Analyses) != 1 || ghost.Analyses[0] != "an_ghost" {
|
||||||
|
t.Errorf("expected ghost.Analyses=[an_ghost], got %v", ghost.Analyses)
|
||||||
|
}
|
||||||
|
|
||||||
|
specter := findOrphan(rows, "specter")
|
||||||
|
if specter == nil {
|
||||||
|
t.Fatal("orphan 'specter' missing")
|
||||||
|
}
|
||||||
|
if len(specter.Apps) != 1 || specter.Apps[0] != "app_specter" {
|
||||||
|
t.Errorf("expected specter.Apps=[app_specter], got %v", specter.Apps)
|
||||||
|
}
|
||||||
|
if len(specter.Analyses) != 0 {
|
||||||
|
t.Errorf("expected specter.Analyses empty, got %v", specter.Analyses)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
t.Run("app con project_id valido no aparece", func(t *testing.T) {
|
||||||
|
dir := seedOrphanRefsRegistry(t, true)
|
||||||
|
rows, err := FindOrphanProjectRefs(dir)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("error: %v", err)
|
||||||
|
}
|
||||||
|
if findOrphan(rows, "known") != nil {
|
||||||
|
t.Error("valid project 'known' should not appear as orphan")
|
||||||
|
}
|
||||||
|
// Children of 'known' must never be listed in any orphan entry.
|
||||||
|
for _, r := range rows {
|
||||||
|
for _, a := range append(append([]string{}, r.Apps...), r.Analyses...) {
|
||||||
|
if a == "app_valid" || a == "an_valid" || a == "app_no_project" {
|
||||||
|
t.Errorf("child %q with valid/empty project leaked into orphan %q", a, r.ProjectID)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
t.Run("sin huerfanos devuelve slice vacio sin error", func(t *testing.T) {
|
||||||
|
dir := seedOrphanRefsRegistry(t, false)
|
||||||
|
rows, err := FindOrphanProjectRefs(dir)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("error: %v", err)
|
||||||
|
}
|
||||||
|
if rows == nil {
|
||||||
|
t.Fatal("expected non-nil empty slice, got nil")
|
||||||
|
}
|
||||||
|
if len(rows) != 0 {
|
||||||
|
t.Errorf("expected 0 orphans, got %d: %+v", len(rows), rows)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
t.Run("error si registry.db no existe", func(t *testing.T) {
|
||||||
|
dir := t.TempDir()
|
||||||
|
if _, err := FindOrphanProjectRefs(dir); err == nil {
|
||||||
|
t.Error("expected error for missing db, got nil")
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestFormatOrphanProjectRefs(t *testing.T) {
|
||||||
|
t.Run("sin huerfanos lo deja claro", func(t *testing.T) {
|
||||||
|
out := FormatOrphanProjectRefs(nil)
|
||||||
|
if !contains(out, "0 project_id huérfanos") {
|
||||||
|
t.Errorf("expected clean message, got:\n%s", out)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
t.Run("con huerfanos lista ids y cuenta", func(t *testing.T) {
|
||||||
|
rows := []OrphanProjectRef{
|
||||||
|
{ProjectID: "ghost", Apps: []string{"app_alpha", "app_zeta"}, Analyses: []string{"an_ghost"}},
|
||||||
|
}
|
||||||
|
out := FormatOrphanProjectRefs(rows)
|
||||||
|
if !contains(out, "ghost") {
|
||||||
|
t.Errorf("expected project_id in output, got:\n%s", out)
|
||||||
|
}
|
||||||
|
if !contains(out, "app_alpha") || !contains(out, "an_ghost") {
|
||||||
|
t.Errorf("expected referencing ids in output, got:\n%s", out)
|
||||||
|
}
|
||||||
|
if !contains(out, "1 project_id huérfanos") {
|
||||||
|
t.Errorf("expected count line, got:\n%s", out)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestFormatProjectsCoverage(t *testing.T) {
|
||||||
|
t.Run("sin issues lo deja claro", func(t *testing.T) {
|
||||||
|
rows := []ProjectCoverage{
|
||||||
|
{ProjectID: "ok", HasGit: true, HasRemote: true, RepoURLDeclared: true, ChildrenInDB: 2, ChildrenCloned: 2},
|
||||||
|
}
|
||||||
|
out := FormatProjectsCoverage(rows)
|
||||||
|
if !contains(out, "0 projects con problemas de cobertura") {
|
||||||
|
t.Errorf("expected clean message, got:\n%s", out)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
t.Run("con issues cuenta los afectados", func(t *testing.T) {
|
||||||
|
rows := []ProjectCoverage{
|
||||||
|
{ProjectID: "bad", Issues: []string{"no_gitea_repo"}},
|
||||||
|
{ProjectID: "ok", HasGit: true, HasRemote: true, RepoURLDeclared: true},
|
||||||
|
}
|
||||||
|
out := FormatProjectsCoverage(rows)
|
||||||
|
if !contains(out, "1/2 projects con problemas de cobertura") {
|
||||||
|
t.Errorf("expected 1/2 count, got:\n%s", out)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
@@ -0,0 +1,150 @@
|
|||||||
|
package infra
|
||||||
|
|
||||||
|
import (
|
||||||
|
"encoding/json"
|
||||||
|
"fmt"
|
||||||
|
"strings"
|
||||||
|
"time"
|
||||||
|
)
|
||||||
|
|
||||||
|
// natsVarz refleja los campos relevantes de la respuesta JSON del endpoint
|
||||||
|
// /varz del monitoring HTTP embebido de un nats-server (puerto 8222, loopback).
|
||||||
|
// Solo se mapean los campos que producen series; el resto se ignora.
|
||||||
|
type natsVarz struct {
|
||||||
|
InMsgs int64 `json:"in_msgs"`
|
||||||
|
OutMsgs int64 `json:"out_msgs"`
|
||||||
|
InBytes int64 `json:"in_bytes"`
|
||||||
|
OutBytes int64 `json:"out_bytes"`
|
||||||
|
Connections int `json:"connections"`
|
||||||
|
SlowConsumers int `json:"slow_consumers"`
|
||||||
|
Subscriptions int `json:"subscriptions"`
|
||||||
|
Mem int64 `json:"mem"`
|
||||||
|
Start string `json:"start"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// natsConnz refleja los campos relevantes de /connz.
|
||||||
|
type natsConnz struct {
|
||||||
|
NumConnections int `json:"num_connections"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// natsStreamDetail refleja un stream dentro de account_details[].stream_detail[].
|
||||||
|
type natsStreamDetail struct {
|
||||||
|
Name string `json:"name"`
|
||||||
|
Cluster struct {
|
||||||
|
Leader string `json:"leader"`
|
||||||
|
} `json:"cluster"`
|
||||||
|
State struct {
|
||||||
|
Messages int64 `json:"messages"`
|
||||||
|
Bytes int64 `json:"bytes"`
|
||||||
|
} `json:"state"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// natsJsz refleja los campos relevantes de /jsz?streams=1.
|
||||||
|
type natsJsz struct {
|
||||||
|
Streams int64 `json:"streams"`
|
||||||
|
Messages int64 `json:"messages"`
|
||||||
|
Bytes int64 `json:"bytes"`
|
||||||
|
Memory int64 `json:"memory"`
|
||||||
|
Storage int64 `json:"storage"`
|
||||||
|
AccountDetails []struct {
|
||||||
|
StreamDetail []natsStreamDetail `json:"stream_detail"`
|
||||||
|
} `json:"account_details"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// ParseNatsMonitor convierte las respuestas JSON del endpoint de monitoring HTTP
|
||||||
|
// embebido de un nats-server (puerto 8222, loopback) en una serie de PromSample
|
||||||
|
// lista para empujar a VictoriaMetrics. Es la hermana de ParseUnibusHealth para
|
||||||
|
// las métricas server-level de NATS/JetStream (msgs/s, conexiones, KV bucket
|
||||||
|
// msgs, RAFT leader por stream, memoria). La consume el unibus_exporter de
|
||||||
|
// fleet_monitoring en modo scraper local por nodo.
|
||||||
|
//
|
||||||
|
// node es el nombre lógico del nodo (p.ej. "magnus"); se adjunta a CADA serie
|
||||||
|
// como las labels "node" e "instance" para distinguir los nodos cuando un único
|
||||||
|
// exporter scrapea varios.
|
||||||
|
//
|
||||||
|
// varz, connz y jsz son los cuerpos crudos de GET /varz, GET /connz y
|
||||||
|
// GET /jsz?streams=1 respectivamente:
|
||||||
|
// - varz es el core: si NO parsea como JSON válido devuelve (nil, error).
|
||||||
|
// - connz y jsz son best-effort: si vienen vacíos o no parsean, sus series se
|
||||||
|
// omiten sin abortar (no error), para que el scraper resista que un endpoint
|
||||||
|
// falle. nats_connections cae a varz.connections cuando connz no parsea.
|
||||||
|
func ParseNatsMonitor(node string, varz, connz, jsz []byte) ([]PromSample, error) {
|
||||||
|
var v natsVarz
|
||||||
|
if err := json.Unmarshal(varz, &v); err != nil {
|
||||||
|
return nil, fmt.Errorf("parse nats varz for node %q: %w", node, err)
|
||||||
|
}
|
||||||
|
|
||||||
|
// mk construye un PromSample con las labels base {node, instance} más, de
|
||||||
|
// forma opcional, labels extra (clave/valor alternados). Las labels base no
|
||||||
|
// se pueden sobreescribir desde extra.
|
||||||
|
mk := func(name string, val float64, extra ...string) PromSample {
|
||||||
|
labels := map[string]string{"node": node, "instance": node}
|
||||||
|
for i := 0; i+1 < len(extra); i += 2 {
|
||||||
|
labels[extra[i]] = extra[i+1]
|
||||||
|
}
|
||||||
|
return PromSample{Name: name, Labels: labels, Value: val}
|
||||||
|
}
|
||||||
|
|
||||||
|
out := []PromSample{
|
||||||
|
mk("nats_msgs_in_total", float64(v.InMsgs)),
|
||||||
|
mk("nats_msgs_out_total", float64(v.OutMsgs)),
|
||||||
|
mk("nats_bytes_in_total", float64(v.InBytes)),
|
||||||
|
mk("nats_bytes_out_total", float64(v.OutBytes)),
|
||||||
|
}
|
||||||
|
|
||||||
|
// nats_connections: prefiere connz.num_connections; si connz no parsea, cae
|
||||||
|
// a varz.connections para no perder la serie.
|
||||||
|
connections := float64(v.Connections)
|
||||||
|
if len(connz) > 0 {
|
||||||
|
var c natsConnz
|
||||||
|
if err := json.Unmarshal(connz, &c); err == nil {
|
||||||
|
connections = float64(c.NumConnections)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
out = append(out,
|
||||||
|
mk("nats_connections", connections),
|
||||||
|
mk("nats_slow_consumers", float64(v.SlowConsumers)),
|
||||||
|
mk("nats_mem_bytes", float64(v.Mem)),
|
||||||
|
mk("nats_subscriptions", float64(v.Subscriptions)),
|
||||||
|
)
|
||||||
|
|
||||||
|
// nats_server_start_seconds: epoch (segundos Unix) del campo start (RFC3339).
|
||||||
|
// Proxy de reinicios del nats-server: un cambio de este valor = el server
|
||||||
|
// reinició. Si el parse de la fecha falla, se omite la serie (no se aborta).
|
||||||
|
if t, err := time.Parse(time.RFC3339, v.Start); err == nil {
|
||||||
|
out = append(out, mk("nats_server_start_seconds", float64(t.Unix())))
|
||||||
|
}
|
||||||
|
|
||||||
|
// jsz es best-effort: si vacío o inválido, se omiten todas sus series.
|
||||||
|
if len(jsz) > 0 {
|
||||||
|
var j natsJsz
|
||||||
|
if err := json.Unmarshal(jsz, &j); err == nil {
|
||||||
|
out = append(out,
|
||||||
|
mk("nats_jetstream_streams", float64(j.Streams)),
|
||||||
|
mk("nats_jetstream_messages", float64(j.Messages)),
|
||||||
|
mk("nats_jetstream_bytes", float64(j.Bytes)),
|
||||||
|
mk("nats_jetstream_memory_bytes", float64(j.Memory)),
|
||||||
|
mk("nats_jetstream_storage_bytes", float64(j.Storage)),
|
||||||
|
)
|
||||||
|
for _, acc := range j.AccountDetails {
|
||||||
|
for _, sd := range acc.StreamDetail {
|
||||||
|
out = append(out,
|
||||||
|
mk("nats_stream_messages", float64(sd.State.Messages), "stream", sd.Name),
|
||||||
|
mk("nats_stream_bytes", float64(sd.State.Bytes), "stream", sd.Name),
|
||||||
|
)
|
||||||
|
leader := 0.0
|
||||||
|
if sd.Cluster.Leader == node {
|
||||||
|
leader = 1
|
||||||
|
}
|
||||||
|
out = append(out, mk("nats_jetstream_raft_leader", leader, "stream", sd.Name))
|
||||||
|
|
||||||
|
if bucket, ok := strings.CutPrefix(sd.Name, "KV_"); ok {
|
||||||
|
out = append(out, mk("kv_bucket_msgs", float64(sd.State.Messages), "bucket", bucket))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return out, nil
|
||||||
|
}
|
||||||
@@ -0,0 +1,119 @@
|
|||||||
|
---
|
||||||
|
name: parse_nats_monitor
|
||||||
|
kind: function
|
||||||
|
lang: go
|
||||||
|
domain: infra
|
||||||
|
version: "1.0.0"
|
||||||
|
purity: impure
|
||||||
|
signature: "func ParseNatsMonitor(node string, varz, connz, jsz []byte) ([]PromSample, error)"
|
||||||
|
description: "Convierte las respuestas JSON del endpoint de monitoring HTTP embebido de un nats-server (puerto 8222, loopback) en una serie de PromSample lista para empujar a VictoriaMetrics. Hermana de ParseUnibusHealth pero para las métricas server-level de NATS/JetStream: msgs/s, bytes, conexiones, slow consumers, memoria RSS, start epoch (proxy de reinicios), streams/messages/bytes/memory/storage de JetStream, y por stream nats_stream_messages/bytes, nats_jetstream_raft_leader y kv_bucket_msgs para los buckets KV_. Adjunta labels node e instance a cada serie. varz es el core (error si no parsea); connz y jsz son best-effort (se omiten sin abortar). La consume el unibus_exporter de fleet_monitoring como scraper local por nodo."
|
||||||
|
tags: [prometheus, metrics, nats, jetstream, monitoring, varz, connz, jsz, kv, raft, fleet-metrics, infra]
|
||||||
|
uses_functions: []
|
||||||
|
uses_types: ["PromSample_go_infra"]
|
||||||
|
returns: []
|
||||||
|
returns_optional: true
|
||||||
|
error_type: "error_go_core"
|
||||||
|
imports: ["encoding/json", "fmt", "strings", "time"]
|
||||||
|
params:
|
||||||
|
- name: node
|
||||||
|
desc: "nombre lógico del nodo (p.ej. \"magnus\"); se adjunta como labels node e instance a CADA serie y se compara con cluster.leader de cada stream para nats_jetstream_raft_leader"
|
||||||
|
- name: varz
|
||||||
|
desc: "cuerpo JSON crudo de GET http://127.0.0.1:8222/varz; core de la función (in_msgs, out_msgs, in_bytes, out_bytes, connections, slow_consumers, subscriptions, mem, start). Si no parsea, la función devuelve error"
|
||||||
|
- name: connz
|
||||||
|
desc: "cuerpo JSON crudo de GET http://127.0.0.1:8222/connz; best-effort (num_connections). Si vacío o inválido, nats_connections cae a varz.connections sin abortar"
|
||||||
|
- name: jsz
|
||||||
|
desc: "cuerpo JSON crudo de GET http://127.0.0.1:8222/jsz?streams=1; best-effort (streams, messages, bytes, memory, storage y account_details[].stream_detail[]). Si vacío o inválido, se omiten sus series sin abortar. Necesita ?streams=1 para traer stream_detail"
|
||||||
|
output: "slice de PromSample con labels base {node,instance}: nats_msgs_in/out_total, nats_bytes_in/out_total, nats_connections, nats_slow_consumers, nats_mem_bytes, nats_subscriptions, nats_server_start_seconds (omitida si start no parsea), nats_jetstream_streams/messages/bytes/memory_bytes/storage_bytes; y por stream nats_stream_messages{stream}, nats_stream_bytes{stream}, nats_jetstream_raft_leader{stream} (1 si cluster.leader==node) y, para streams KV_, kv_bucket_msgs{bucket} con el prefijo KV_ recortado. Error solo si varz no es JSON válido."
|
||||||
|
tested: true
|
||||||
|
test_file_path: "functions/infra/parse_nats_monitor_test.go"
|
||||||
|
tests:
|
||||||
|
- "TestParseNatsMonitorGolden"
|
||||||
|
- "TestParseNatsMonitorEmptyJsz"
|
||||||
|
- "TestParseNatsMonitorInvalidConnz"
|
||||||
|
- "TestParseNatsMonitorInvalidVarz"
|
||||||
|
---
|
||||||
|
|
||||||
|
# parse_nats_monitor
|
||||||
|
|
||||||
|
Función de transformación (clasificada `impure` porque devuelve `error` al fallar el
|
||||||
|
unmarshal del core; no hace I/O ni red por sí misma) que traduce las métricas
|
||||||
|
server-level de un **nats-server** a series Prometheus. Es la hermana de
|
||||||
|
`parse_unibus_health_go_infra`: aquella lee el `/healthz` de `membershipd` (posture),
|
||||||
|
esta lee el monitoring embebido de NATS (puerto 8222) para las métricas profundas que
|
||||||
|
`/healthz` no expone: msgs/s, conexiones, RAFT leader por stream, memoria, KV buckets.
|
||||||
|
|
||||||
|
Pertenece al grupo de capacidad `fleet-metrics`: se compone con
|
||||||
|
`format_prom_exposition_go_infra` (serializar) y `push_prom_remote_go_infra` (empujar a
|
||||||
|
VictoriaMetrics). La consume el `unibus_exporter` de `fleet_monitoring` en modo scraper
|
||||||
|
local por nodo, que hace los tres GET y le pasa los cuerpos crudos.
|
||||||
|
|
||||||
|
## Ejemplo
|
||||||
|
|
||||||
|
```go
|
||||||
|
package main
|
||||||
|
|
||||||
|
import (
|
||||||
|
"fmt"
|
||||||
|
"io"
|
||||||
|
"net/http"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"fn-registry/functions/infra"
|
||||||
|
)
|
||||||
|
|
||||||
|
func get(url string) []byte {
|
||||||
|
resp, err := http.Get(url)
|
||||||
|
if err != nil {
|
||||||
|
return nil // best-effort: connz/jsz pueden faltar
|
||||||
|
}
|
||||||
|
defer resp.Body.Close()
|
||||||
|
b, _ := io.ReadAll(resp.Body)
|
||||||
|
return b
|
||||||
|
}
|
||||||
|
|
||||||
|
func main() {
|
||||||
|
base := "http://127.0.0.1:8222"
|
||||||
|
varz := get(base + "/varz")
|
||||||
|
connz := get(base + "/connz")
|
||||||
|
jsz := get(base + "/jsz?streams=1")
|
||||||
|
|
||||||
|
samples, err := infra.ParseNatsMonitor("magnus", varz, connz, jsz)
|
||||||
|
if err != nil {
|
||||||
|
panic(err) // varz es el core: sin él no hay métricas
|
||||||
|
}
|
||||||
|
fmt.Print(infra.FormatPromExposition(samples, time.Now().UnixMilli()))
|
||||||
|
// nats_msgs_in_total{instance="magnus",node="magnus"} 17 ...
|
||||||
|
// kv_bucket_msgs{bucket="UNIBUS_users",instance="magnus",node="magnus"} 2 ...
|
||||||
|
// nats_jetstream_raft_leader{instance="magnus",node="magnus",stream="KV_UNIBUS_users"} 1 ...
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
## Cuando usarla
|
||||||
|
|
||||||
|
Úsala dentro de un exporter que monitoriza un nats-server con el monitoring HTTP
|
||||||
|
embebido activado (`http: 127.0.0.1:8222` en la config de NATS): tras hacer
|
||||||
|
`GET /varz`, `GET /connz` y `GET /jsz?streams=1` contra loopback, pasa los tres cuerpos
|
||||||
|
crudos a esta función para obtener todas las series server-level del nodo. Llámala como
|
||||||
|
scraper local por nodo (cada nodo expone su 8222 solo en loopback), no centralizado.
|
||||||
|
|
||||||
|
## Gotchas
|
||||||
|
|
||||||
|
- **Impura por contrato**: solo devuelve `error` si `varz` no es JSON válido (es el core).
|
||||||
|
`connz` y `jsz` son **best-effort**: si vienen vacíos o no parsean, sus series se omiten
|
||||||
|
sin abortar. Esto hace al scraper resistente a que un endpoint falle de forma puntual.
|
||||||
|
- **Monitoring loopback-only sin auth**: el puerto 8222 de NATS no tiene autenticación; por
|
||||||
|
eso debe bindearse a `127.0.0.1` y scrapearse localmente en cada nodo, nunca exponerse a
|
||||||
|
la red. El push agregado a VictoriaMetrics lo hace el exporter, no esta función.
|
||||||
|
- **`/jsz` necesita `?streams=1`** para traer `account_details[].stream_detail[]`. Sin ese
|
||||||
|
parámetro el cuerpo trae los totales pero no el detalle por stream, y entonces no salen
|
||||||
|
`nats_stream_*`, `nats_jetstream_raft_leader` ni `kv_bucket_msgs`.
|
||||||
|
- **`nats_connections`**: prefiere `connz.num_connections`; si `connz` no parsea, cae a
|
||||||
|
`varz.connections` para no perder la serie.
|
||||||
|
- **RAFT leader en standalone**: en un nats-server sin clúster, el objeto `cluster` puede
|
||||||
|
faltar o `leader` venir vacío; en ese caso `nats_jetstream_raft_leader` sale 0 salvo que
|
||||||
|
`cluster.leader == node`. Es esperado: en standalone no hay quorum RAFT real.
|
||||||
|
- **`kv_bucket_msgs`** solo se emite para streams cuyo nombre empieza por `KV_`, recortando
|
||||||
|
el prefijo (stream `KV_UNIBUS_users` → bucket `UNIBUS_users`).
|
||||||
|
- **`nats_server_start_seconds`** es el epoch Unix del campo `start` (RFC3339): sirve como
|
||||||
|
proxy de reinicios (un cambio de valor = el server reinició). Si el campo no parsea como
|
||||||
|
fecha válida, la serie se omite en lugar de abortar.
|
||||||
@@ -0,0 +1,160 @@
|
|||||||
|
package infra
|
||||||
|
|
||||||
|
import (
|
||||||
|
"os"
|
||||||
|
"testing"
|
||||||
|
)
|
||||||
|
|
||||||
|
// findNatsSample devuelve el primer PromSample cuyo Name coincide y cuyos labels
|
||||||
|
// extra (clave/valor alternados) están todos presentes con el valor esperado.
|
||||||
|
// El segundo retorno indica si se encontró.
|
||||||
|
func findNatsSample(samples []PromSample, name string, labels ...string) (PromSample, bool) {
|
||||||
|
for _, s := range samples {
|
||||||
|
if s.Name != name {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
match := true
|
||||||
|
for i := 0; i+1 < len(labels); i += 2 {
|
||||||
|
if s.Labels[labels[i]] != labels[i+1] {
|
||||||
|
match = false
|
||||||
|
break
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if match {
|
||||||
|
return s, true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return PromSample{}, false
|
||||||
|
}
|
||||||
|
|
||||||
|
func mustRead(t *testing.T, path string) []byte {
|
||||||
|
t.Helper()
|
||||||
|
b, err := os.ReadFile(path)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("read fixture %s: %v", path, err)
|
||||||
|
}
|
||||||
|
return b
|
||||||
|
}
|
||||||
|
|
||||||
|
// golden: fixtures reales de un nats-server 2.11.15, node="probe" (== el leader
|
||||||
|
// de los streams), valores concretos verificados a mano.
|
||||||
|
func TestParseNatsMonitorGolden(t *testing.T) {
|
||||||
|
varz := mustRead(t, "testdata/nats_varz.json")
|
||||||
|
connz := mustRead(t, "testdata/nats_connz.json")
|
||||||
|
jsz := mustRead(t, "testdata/nats_jsz.json")
|
||||||
|
|
||||||
|
got, err := ParseNatsMonitor("probe", varz, connz, jsz)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("unexpected error: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
want := map[string]float64{
|
||||||
|
"nats_msgs_in_total": 17,
|
||||||
|
"nats_msgs_out_total": 17,
|
||||||
|
"nats_mem_bytes": 18288640,
|
||||||
|
"nats_jetstream_streams": 3,
|
||||||
|
"nats_connections": 1,
|
||||||
|
"nats_jetstream_messages": 6,
|
||||||
|
}
|
||||||
|
for name, w := range want {
|
||||||
|
s, ok := findNatsSample(got, name)
|
||||||
|
if !ok {
|
||||||
|
t.Errorf("missing sample %q", name)
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
if s.Value != w {
|
||||||
|
t.Errorf("%s = %v, want %v", name, s.Value, w)
|
||||||
|
}
|
||||||
|
if s.Labels["node"] != "probe" || s.Labels["instance"] != "probe" {
|
||||||
|
t.Errorf("%s labels = %v, want node=instance=probe", name, s.Labels)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// kv_bucket_msgs por cada KV bucket (prefijo KV_ recortado).
|
||||||
|
for bucket, w := range map[string]float64{
|
||||||
|
"UNIBUS_users": 2,
|
||||||
|
"UNIBUS_rooms": 2,
|
||||||
|
"UNIBUS_members": 2,
|
||||||
|
} {
|
||||||
|
s, ok := findNatsSample(got, "kv_bucket_msgs", "bucket", bucket)
|
||||||
|
if !ok {
|
||||||
|
t.Errorf("missing kv_bucket_msgs{bucket=%q}", bucket)
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
if s.Value != w {
|
||||||
|
t.Errorf("kv_bucket_msgs{bucket=%q} = %v, want %v", bucket, s.Value, w)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// raft leader: probe == node, así que el stream KV_UNIBUS_users tiene leader=1.
|
||||||
|
s, ok := findNatsSample(got, "nats_jetstream_raft_leader", "stream", "KV_UNIBUS_users")
|
||||||
|
if !ok {
|
||||||
|
t.Fatal("missing nats_jetstream_raft_leader{stream=KV_UNIBUS_users}")
|
||||||
|
}
|
||||||
|
if s.Value != 1 {
|
||||||
|
t.Errorf("nats_jetstream_raft_leader{stream=KV_UNIBUS_users} = %v, want 1", s.Value)
|
||||||
|
}
|
||||||
|
|
||||||
|
// stream_detail también emite nats_stream_messages con label stream completo.
|
||||||
|
if s, ok := findNatsSample(got, "nats_stream_messages", "stream", "KV_UNIBUS_users"); !ok || s.Value != 2 {
|
||||||
|
t.Errorf("nats_stream_messages{stream=KV_UNIBUS_users} = %v ok=%v, want 2", s.Value, ok)
|
||||||
|
}
|
||||||
|
|
||||||
|
// nats_server_start_seconds presente (start es RFC3339 válido).
|
||||||
|
if _, ok := findNatsSample(got, "nats_server_start_seconds"); !ok {
|
||||||
|
t.Error("missing nats_server_start_seconds (start is a valid RFC3339)")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// edge: jsz sin streams ni account_details. No produce series kv_bucket_msgs ni
|
||||||
|
// nats_stream_*, pero sí las de varz/connz y las jetstream top-level (en 0).
|
||||||
|
func TestParseNatsMonitorEmptyJsz(t *testing.T) {
|
||||||
|
varz := mustRead(t, "testdata/nats_varz.json")
|
||||||
|
connz := mustRead(t, "testdata/nats_connz.json")
|
||||||
|
jsz := []byte(`{"streams":0,"account_details":[]}`)
|
||||||
|
|
||||||
|
got, err := ParseNatsMonitor("probe", varz, connz, jsz)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("unexpected error: %v", err)
|
||||||
|
}
|
||||||
|
if _, ok := findNatsSample(got, "kv_bucket_msgs", "bucket", "UNIBUS_users"); ok {
|
||||||
|
t.Error("did not expect kv_bucket_msgs with empty account_details")
|
||||||
|
}
|
||||||
|
if _, ok := findNatsSample(got, "nats_stream_messages"); ok {
|
||||||
|
t.Error("did not expect nats_stream_messages with empty account_details")
|
||||||
|
}
|
||||||
|
// varz/connz siguen presentes.
|
||||||
|
if s, ok := findNatsSample(got, "nats_msgs_in_total"); !ok || s.Value != 17 {
|
||||||
|
t.Errorf("nats_msgs_in_total = %v ok=%v, want 17", s.Value, ok)
|
||||||
|
}
|
||||||
|
if s, ok := findNatsSample(got, "nats_connections"); !ok || s.Value != 1 {
|
||||||
|
t.Errorf("nats_connections = %v ok=%v, want 1", s.Value, ok)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// edge: connz inválido. No es error; nats_connections cae a varz.connections (1).
|
||||||
|
// varz/jsz siguen produciendo sus series.
|
||||||
|
func TestParseNatsMonitorInvalidConnz(t *testing.T) {
|
||||||
|
varz := mustRead(t, "testdata/nats_varz.json")
|
||||||
|
jsz := mustRead(t, "testdata/nats_jsz.json")
|
||||||
|
|
||||||
|
got, err := ParseNatsMonitor("probe", varz, []byte("not json"), jsz)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("unexpected error: %v", err)
|
||||||
|
}
|
||||||
|
// fallback a varz.connections (= 1).
|
||||||
|
if s, ok := findNatsSample(got, "nats_connections"); !ok || s.Value != 1 {
|
||||||
|
t.Errorf("nats_connections = %v ok=%v, want 1 (fallback varz.connections)", s.Value, ok)
|
||||||
|
}
|
||||||
|
// jsz sigue vivo.
|
||||||
|
if s, ok := findNatsSample(got, "nats_jetstream_streams"); !ok || s.Value != 3 {
|
||||||
|
t.Errorf("nats_jetstream_streams = %v ok=%v, want 3", s.Value, ok)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// error path: varz inválido devuelve error no-nil (es el core, sin él no hay nada).
|
||||||
|
func TestParseNatsMonitorInvalidVarz(t *testing.T) {
|
||||||
|
if _, err := ParseNatsMonitor("probe", []byte("{{{"), nil, nil); err == nil {
|
||||||
|
t.Fatal("expected error for invalid varz, got nil")
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,67 @@
|
|||||||
|
package infra
|
||||||
|
|
||||||
|
import (
|
||||||
|
"encoding/json"
|
||||||
|
"fmt"
|
||||||
|
)
|
||||||
|
|
||||||
|
// unibusHealth refleja la respuesta JSON del endpoint /healthz de un nodo del
|
||||||
|
// cluster de mensajería unibus (membershipd). Forma verificada en producción:
|
||||||
|
//
|
||||||
|
// {"posture":{"enforce":true,"acl":true,"tls":true,"cluster":true,"store":"kv"},"status":"ok"}
|
||||||
|
type unibusHealth struct {
|
||||||
|
Status string `json:"status"`
|
||||||
|
Posture struct {
|
||||||
|
Enforce bool `json:"enforce"`
|
||||||
|
ACL bool `json:"acl"`
|
||||||
|
TLS bool `json:"tls"`
|
||||||
|
Cluster bool `json:"cluster"`
|
||||||
|
Store string `json:"store"`
|
||||||
|
} `json:"posture"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// ParseUnibusHealth convierte la respuesta JSON del endpoint /healthz de un nodo
|
||||||
|
// del cluster de mensajería unibus en una serie de PromSample lista para empujar
|
||||||
|
// a VictoriaMetrics, sin instrumentar el bus (solo lee su endpoint de salud).
|
||||||
|
//
|
||||||
|
// node es el nombre lógico del nodo (p.ej. "magnus"); se adjunta a cada serie
|
||||||
|
// como las labels "node" e "instance" para distinguir los nodos cuando un único
|
||||||
|
// exporter scrapea varios. La función SOLO debe llamarse cuando el nodo
|
||||||
|
// respondió: el caso "no responde" (unibus_up=0) lo emite el llamador, no esta
|
||||||
|
// función, porque sin cuerpo no hay nada que parsear.
|
||||||
|
//
|
||||||
|
// Devuelve siete series por nodo:
|
||||||
|
// - unibus_up = 1 (si el body parseó, el nodo respondió)
|
||||||
|
// - unibus_status_ok = 1 si status=="ok", si no 0
|
||||||
|
// - unibus_posture_enforce / _acl / _tls / _cluster = 1/0 según el booleano
|
||||||
|
// - unibus_store_kv = 1 si posture.store=="kv", si no 0
|
||||||
|
//
|
||||||
|
// Si el body no es JSON válido con la forma esperada, devuelve (nil, error).
|
||||||
|
func ParseUnibusHealth(node string, body []byte) ([]PromSample, error) {
|
||||||
|
var h unibusHealth
|
||||||
|
if err := json.Unmarshal(body, &h); err != nil {
|
||||||
|
return nil, fmt.Errorf("parse unibus healthz for node %q: %w", node, err)
|
||||||
|
}
|
||||||
|
b2f := func(b bool) float64 {
|
||||||
|
if b {
|
||||||
|
return 1
|
||||||
|
}
|
||||||
|
return 0
|
||||||
|
}
|
||||||
|
mk := func(name string, v float64) PromSample {
|
||||||
|
return PromSample{
|
||||||
|
Name: name,
|
||||||
|
Labels: map[string]string{"node": node, "instance": node},
|
||||||
|
Value: v,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return []PromSample{
|
||||||
|
mk("unibus_up", 1),
|
||||||
|
mk("unibus_status_ok", b2f(h.Status == "ok")),
|
||||||
|
mk("unibus_posture_enforce", b2f(h.Posture.Enforce)),
|
||||||
|
mk("unibus_posture_acl", b2f(h.Posture.ACL)),
|
||||||
|
mk("unibus_posture_tls", b2f(h.Posture.TLS)),
|
||||||
|
mk("unibus_posture_cluster", b2f(h.Posture.Cluster)),
|
||||||
|
mk("unibus_store_kv", b2f(h.Posture.Store == "kv")),
|
||||||
|
}, nil
|
||||||
|
}
|
||||||
@@ -0,0 +1,89 @@
|
|||||||
|
---
|
||||||
|
name: parse_unibus_health
|
||||||
|
kind: function
|
||||||
|
lang: go
|
||||||
|
domain: infra
|
||||||
|
version: "1.0.0"
|
||||||
|
purity: impure
|
||||||
|
signature: "func ParseUnibusHealth(node string, body []byte) ([]PromSample, error)"
|
||||||
|
description: "Convierte la respuesta JSON del endpoint /healthz de un nodo del cluster de mensajería unibus (membershipd) en una serie de PromSample lista para empujar a VictoriaMetrics, sin instrumentar el bus: solo lee su endpoint de salud. Adjunta a cada serie las labels node e instance (= nombre lógico del nodo) para distinguir los nodos cuando un único exporter scrapea varios. Emite siete series por nodo: unibus_up, unibus_status_ok, unibus_posture_enforce/acl/tls/cluster y unibus_store_kv. Devuelve error si el body no es JSON válido con la forma esperada."
|
||||||
|
tags: [prometheus, metrics, unibus, nats, healthz, posture, fleet-metrics, infra, monitoring]
|
||||||
|
uses_functions: []
|
||||||
|
uses_types: ["PromSample_go_infra"]
|
||||||
|
returns: []
|
||||||
|
returns_optional: false
|
||||||
|
error_type: "error_go_core"
|
||||||
|
imports: ["encoding/json", "fmt"]
|
||||||
|
params:
|
||||||
|
- name: node
|
||||||
|
desc: "nombre lógico del nodo (p.ej. \"magnus\"); se adjunta como labels node e instance a cada serie"
|
||||||
|
- name: body
|
||||||
|
desc: "cuerpo JSON crudo devuelto por GET https://<nodo>:8470/healthz, forma {\"posture\":{enforce,acl,tls,cluster bool; store string},\"status\":string}"
|
||||||
|
output: "slice de 7 PromSample con labels {node,instance}: unibus_up=1, unibus_status_ok (1 si status==ok), unibus_posture_enforce/acl/tls/cluster (1/0), unibus_store_kv (1 si posture.store==kv). Error si el body no es JSON válido."
|
||||||
|
tested: true
|
||||||
|
test_file_path: "functions/infra/parse_unibus_health_test.go"
|
||||||
|
tests:
|
||||||
|
- "TestParseUnibusHealthGolden"
|
||||||
|
- "TestParseUnibusHealthDegraded"
|
||||||
|
- "TestParseUnibusHealthInvalid"
|
||||||
|
---
|
||||||
|
|
||||||
|
# parse_unibus_health
|
||||||
|
|
||||||
|
Función pura de transformación (clasificada `impure` solo porque devuelve `error` al
|
||||||
|
fallar el unmarshal; no hace I/O ni red) que traduce la salud de un nodo del bus de
|
||||||
|
mensajería **unibus** a métricas Prometheus. Pertenece al grupo de capacidad
|
||||||
|
`fleet-metrics`: se compone con `format_prom_exposition_go_infra` (serializar) y
|
||||||
|
`push_prom_remote_go_infra` (empujar a VictoriaMetrics).
|
||||||
|
|
||||||
|
El endpoint `/healthz` de cada nodo (`membershipd`) responde, verificado en producción:
|
||||||
|
|
||||||
|
```json
|
||||||
|
{"posture":{"enforce":true,"acl":true,"tls":true,"cluster":true,"store":"kv"},"status":"ok"}
|
||||||
|
```
|
||||||
|
|
||||||
|
## Ejemplo
|
||||||
|
|
||||||
|
```go
|
||||||
|
package main
|
||||||
|
|
||||||
|
import (
|
||||||
|
"fmt"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"fn-registry/functions/infra"
|
||||||
|
)
|
||||||
|
|
||||||
|
func main() {
|
||||||
|
body := []byte(`{"posture":{"enforce":true,"acl":true,"tls":true,"cluster":true,"store":"kv"},"status":"ok"}`)
|
||||||
|
samples, err := infra.ParseUnibusHealth("magnus", body)
|
||||||
|
if err != nil {
|
||||||
|
panic(err)
|
||||||
|
}
|
||||||
|
// Serializa y (en un exporter real) empuja a VictoriaMetrics.
|
||||||
|
fmt.Print(infra.FormatPromExposition(samples, time.Now().UnixMilli()))
|
||||||
|
// unibus_up{instance="magnus",node="magnus"} 1 ...
|
||||||
|
// unibus_posture_enforce{instance="magnus",node="magnus"} 1 ...
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
## Cuando usarla
|
||||||
|
|
||||||
|
Úsala dentro de un exporter que monitoriza el cluster unibus: tras hacer
|
||||||
|
`GET https://<nodo>:8470/healthz` con la CA del cluster, pasa el cuerpo a esta función
|
||||||
|
para obtener las series del nodo. Llámala **solo cuando el nodo respondió**; si el GET
|
||||||
|
falla (timeout, TLS, no-2xx), emite tú `unibus_up=0` para ese nodo, porque sin cuerpo
|
||||||
|
no hay nada que parsear.
|
||||||
|
|
||||||
|
## Gotchas
|
||||||
|
|
||||||
|
- No emite `unibus_up=0`: ese caso (nodo caído) es responsabilidad del llamador, que sabe
|
||||||
|
si el GET falló. Esta función siempre emite `unibus_up=1` porque solo se la llama con un
|
||||||
|
cuerpo recibido.
|
||||||
|
- Las labels `node` e `instance` toman el mismo valor (el nombre lógico del nodo). El
|
||||||
|
`push_prom_remote_go_infra` añadiría `instance` vía `extra_label` por igual a todas las
|
||||||
|
series del body; por eso aquí ya se fija `instance` por-serie, para que cada nodo unibus
|
||||||
|
conserve su identidad cuando un solo exporter empuja los de varios nodos en un único POST.
|
||||||
|
- Solo lee la posture y el status que hoy expone `/healthz`. Métricas profundas de
|
||||||
|
NATS/JetStream (msgs/s, conexiones, RAFT leader por stream) NO salen de aquí: requieren
|
||||||
|
el monitoring embebido de NATS (puerto 8222), que en producción está cerrado.
|
||||||
@@ -0,0 +1,67 @@
|
|||||||
|
package infra
|
||||||
|
|
||||||
|
import "testing"
|
||||||
|
|
||||||
|
// golden: nodo seguro con la posture homogénea esperada en producción.
|
||||||
|
func TestParseUnibusHealthGolden(t *testing.T) {
|
||||||
|
body := []byte(`{"posture":{"enforce":true,"acl":true,"tls":true,"cluster":true,"store":"kv"},"status":"ok"}`)
|
||||||
|
got, err := ParseUnibusHealth("magnus", body)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("unexpected error: %v", err)
|
||||||
|
}
|
||||||
|
want := map[string]float64{
|
||||||
|
"unibus_up": 1,
|
||||||
|
"unibus_status_ok": 1,
|
||||||
|
"unibus_posture_enforce": 1,
|
||||||
|
"unibus_posture_acl": 1,
|
||||||
|
"unibus_posture_tls": 1,
|
||||||
|
"unibus_posture_cluster": 1,
|
||||||
|
"unibus_store_kv": 1,
|
||||||
|
}
|
||||||
|
if len(got) != len(want) {
|
||||||
|
t.Fatalf("got %d samples, want %d", len(got), len(want))
|
||||||
|
}
|
||||||
|
for _, s := range got {
|
||||||
|
w, ok := want[s.Name]
|
||||||
|
if !ok {
|
||||||
|
t.Errorf("unexpected sample %q", s.Name)
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
if s.Value != w {
|
||||||
|
t.Errorf("%s = %v, want %v", s.Name, s.Value, w)
|
||||||
|
}
|
||||||
|
if s.Labels["node"] != "magnus" || s.Labels["instance"] != "magnus" {
|
||||||
|
t.Errorf("%s labels = %v, want node=instance=magnus", s.Name, s.Labels)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// edge: nodo degradado (posture todo false, store distinto de kv, status != ok).
|
||||||
|
func TestParseUnibusHealthDegraded(t *testing.T) {
|
||||||
|
body := []byte(`{"posture":{"enforce":false,"acl":false,"tls":false,"cluster":false,"store":"sqlite"},"status":"degraded"}`)
|
||||||
|
got, err := ParseUnibusHealth("homer", body)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("unexpected error: %v", err)
|
||||||
|
}
|
||||||
|
want := map[string]float64{
|
||||||
|
"unibus_up": 1,
|
||||||
|
"unibus_status_ok": 0,
|
||||||
|
"unibus_posture_enforce": 0,
|
||||||
|
"unibus_posture_acl": 0,
|
||||||
|
"unibus_posture_tls": 0,
|
||||||
|
"unibus_posture_cluster": 0,
|
||||||
|
"unibus_store_kv": 0,
|
||||||
|
}
|
||||||
|
for _, s := range got {
|
||||||
|
if s.Value != want[s.Name] {
|
||||||
|
t.Errorf("%s = %v, want %v", s.Name, s.Value, want[s.Name])
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// error path: body que no es JSON válido devuelve error, no panic.
|
||||||
|
func TestParseUnibusHealthInvalid(t *testing.T) {
|
||||||
|
if _, err := ParseUnibusHealth("datardos", []byte("not json at all")); err == nil {
|
||||||
|
t.Fatal("expected error for invalid body, got nil")
|
||||||
|
}
|
||||||
|
}
|
||||||
+30
@@ -0,0 +1,30 @@
|
|||||||
|
{
|
||||||
|
"server_id": "NC23B47RQSJYPX5AIUC5CA3ND5RLCYREKSAFLM65MLBY5PBRIXPAFL7O",
|
||||||
|
"now": "2026-06-07T19:02:25.326833943Z",
|
||||||
|
"num_connections": 1,
|
||||||
|
"total": 1,
|
||||||
|
"offset": 0,
|
||||||
|
"limit": 1024,
|
||||||
|
"connections": [
|
||||||
|
{
|
||||||
|
"cid": 5,
|
||||||
|
"kind": "Client",
|
||||||
|
"type": "nats",
|
||||||
|
"ip": "127.0.0.1",
|
||||||
|
"port": 52734,
|
||||||
|
"start": "2026-06-07T21:02:24.812382826+02:00",
|
||||||
|
"last_activity": "2026-06-07T21:02:24.821005187+02:00",
|
||||||
|
"rtt": "623µs",
|
||||||
|
"uptime": "0s",
|
||||||
|
"idle": "0s",
|
||||||
|
"pending_bytes": 0,
|
||||||
|
"in_msgs": 17,
|
||||||
|
"out_msgs": 17,
|
||||||
|
"in_bytes": 1304,
|
||||||
|
"out_bytes": 3905,
|
||||||
|
"subscriptions": 2,
|
||||||
|
"lang": "go",
|
||||||
|
"version": "1.49.0"
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
+97
@@ -0,0 +1,97 @@
|
|||||||
|
{
|
||||||
|
"memory": 0,
|
||||||
|
"storage": 310,
|
||||||
|
"reserved_memory": 0,
|
||||||
|
"reserved_storage": 0,
|
||||||
|
"accounts": 1,
|
||||||
|
"ha_assets": 0,
|
||||||
|
"api": {
|
||||||
|
"level": 1,
|
||||||
|
"total": 6,
|
||||||
|
"errors": 0
|
||||||
|
},
|
||||||
|
"server_id": "NC23B47RQSJYPX5AIUC5CA3ND5RLCYREKSAFLM65MLBY5PBRIXPAFL7O",
|
||||||
|
"now": "2026-06-07T19:02:25.327216549Z",
|
||||||
|
"config": {
|
||||||
|
"max_memory": 3221225472,
|
||||||
|
"max_storage": 546399169536,
|
||||||
|
"store_dir": "/tmp/natsprobe4019469486/jetstream",
|
||||||
|
"sync_interval": 120000000000
|
||||||
|
},
|
||||||
|
"limits": {},
|
||||||
|
"streams": 3,
|
||||||
|
"consumers": 0,
|
||||||
|
"messages": 6,
|
||||||
|
"bytes": 310,
|
||||||
|
"account_details": [
|
||||||
|
{
|
||||||
|
"name": "$G",
|
||||||
|
"id": "$G",
|
||||||
|
"memory": 0,
|
||||||
|
"storage": 310,
|
||||||
|
"reserved_memory": 18446744073709551615,
|
||||||
|
"reserved_storage": 18446744073709551615,
|
||||||
|
"accounts": 0,
|
||||||
|
"ha_assets": 0,
|
||||||
|
"api": {
|
||||||
|
"level": 0,
|
||||||
|
"total": 6,
|
||||||
|
"errors": 0
|
||||||
|
},
|
||||||
|
"stream_detail": [
|
||||||
|
{
|
||||||
|
"name": "KV_UNIBUS_rooms",
|
||||||
|
"created": "2026-06-07T19:02:24.8170934Z",
|
||||||
|
"cluster": {
|
||||||
|
"leader": "probe"
|
||||||
|
},
|
||||||
|
"state": {
|
||||||
|
"messages": 2,
|
||||||
|
"bytes": 102,
|
||||||
|
"first_seq": 1,
|
||||||
|
"first_ts": "2026-06-07T19:02:24.817910599Z",
|
||||||
|
"last_seq": 2,
|
||||||
|
"last_ts": "2026-06-07T19:02:24.818011867Z",
|
||||||
|
"num_subjects": 2,
|
||||||
|
"consumer_count": 0
|
||||||
|
}
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"name": "KV_UNIBUS_members",
|
||||||
|
"created": "2026-06-07T19:02:24.818494147Z",
|
||||||
|
"cluster": {
|
||||||
|
"leader": "probe"
|
||||||
|
},
|
||||||
|
"state": {
|
||||||
|
"messages": 2,
|
||||||
|
"bytes": 106,
|
||||||
|
"first_seq": 1,
|
||||||
|
"first_ts": "2026-06-07T19:02:24.81917932Z",
|
||||||
|
"last_seq": 2,
|
||||||
|
"last_ts": "2026-06-07T19:02:24.819283444Z",
|
||||||
|
"num_subjects": 2,
|
||||||
|
"consumer_count": 0
|
||||||
|
}
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"name": "KV_UNIBUS_users",
|
||||||
|
"created": "2026-06-07T19:02:24.814500069Z",
|
||||||
|
"cluster": {
|
||||||
|
"leader": "probe"
|
||||||
|
},
|
||||||
|
"state": {
|
||||||
|
"messages": 2,
|
||||||
|
"bytes": 102,
|
||||||
|
"first_seq": 1,
|
||||||
|
"first_ts": "2026-06-07T19:02:24.81638123Z",
|
||||||
|
"last_seq": 2,
|
||||||
|
"last_ts": "2026-06-07T19:02:24.816570377Z",
|
||||||
|
"num_subjects": 2,
|
||||||
|
"consumer_count": 0
|
||||||
|
}
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"total": 1
|
||||||
|
}
|
||||||
+80
@@ -0,0 +1,80 @@
|
|||||||
|
{
|
||||||
|
"server_id": "NC23B47RQSJYPX5AIUC5CA3ND5RLCYREKSAFLM65MLBY5PBRIXPAFL7O",
|
||||||
|
"server_name": "probe",
|
||||||
|
"version": "2.11.15",
|
||||||
|
"proto": 1,
|
||||||
|
"go": "go1.26.4",
|
||||||
|
"host": "127.0.0.1",
|
||||||
|
"port": 14260,
|
||||||
|
"max_connections": 65536,
|
||||||
|
"ping_interval": 120000000000,
|
||||||
|
"ping_max": 2,
|
||||||
|
"http_host": "127.0.0.1",
|
||||||
|
"http_port": 8222,
|
||||||
|
"http_base_path": "",
|
||||||
|
"https_port": 0,
|
||||||
|
"auth_timeout": 2,
|
||||||
|
"max_control_line": 4096,
|
||||||
|
"max_payload": 1048576,
|
||||||
|
"max_pending": 67108864,
|
||||||
|
"cluster": {},
|
||||||
|
"gateway": {},
|
||||||
|
"leaf": {},
|
||||||
|
"mqtt": {},
|
||||||
|
"websocket": {},
|
||||||
|
"jetstream": {
|
||||||
|
"config": {
|
||||||
|
"max_memory": 3221225472,
|
||||||
|
"max_storage": 546399169536,
|
||||||
|
"store_dir": "/tmp/natsprobe4019469486/jetstream",
|
||||||
|
"sync_interval": 120000000000
|
||||||
|
},
|
||||||
|
"stats": {
|
||||||
|
"memory": 0,
|
||||||
|
"storage": 310,
|
||||||
|
"reserved_memory": 0,
|
||||||
|
"reserved_storage": 0,
|
||||||
|
"accounts": 1,
|
||||||
|
"ha_assets": 0,
|
||||||
|
"api": {
|
||||||
|
"level": 1,
|
||||||
|
"total": 6,
|
||||||
|
"errors": 0
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"limits": {}
|
||||||
|
},
|
||||||
|
"tls_timeout": 2,
|
||||||
|
"write_deadline": 10000000000,
|
||||||
|
"start": "2026-06-07T19:02:24.785745698Z",
|
||||||
|
"now": "2026-06-07T19:02:25.325501038Z",
|
||||||
|
"uptime": "0s",
|
||||||
|
"mem": 18288640,
|
||||||
|
"cores": 24,
|
||||||
|
"gomaxprocs": 24,
|
||||||
|
"gomemlimit": 4294967296,
|
||||||
|
"cpu": 0,
|
||||||
|
"connections": 1,
|
||||||
|
"total_connections": 1,
|
||||||
|
"routes": 0,
|
||||||
|
"remotes": 0,
|
||||||
|
"leafnodes": 0,
|
||||||
|
"in_msgs": 17,
|
||||||
|
"out_msgs": 17,
|
||||||
|
"in_bytes": 1304,
|
||||||
|
"out_bytes": 3905,
|
||||||
|
"slow_consumers": 0,
|
||||||
|
"subscriptions": 75,
|
||||||
|
"http_req_stats": {
|
||||||
|
"/varz": 1
|
||||||
|
},
|
||||||
|
"config_load_time": "2026-06-07T19:02:24.785745698Z",
|
||||||
|
"config_digest": "",
|
||||||
|
"system_account": "$SYS",
|
||||||
|
"slow_consumer_stats": {
|
||||||
|
"clients": 0,
|
||||||
|
"routes": 0,
|
||||||
|
"gateways": 0,
|
||||||
|
"leafs": 0
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -1,281 +0,0 @@
|
|||||||
---
|
|
||||||
name: fn_monitoring
|
|
||||||
description: "Monitoreo y visualizacion del estado del fn_registry. API HTTP read-only sobre las bases de datos SQLite y dashboard ImGui que consume la API."
|
|
||||||
tags: [monitoring, api, dashboard, sqlite, visualization]
|
|
||||||
repo_url: "https://gitea-dgg044oo04woo4ggcsws4gk0.organic-machine.com/dataforge/fn_monitoring"
|
|
||||||
---
|
|
||||||
|
|
||||||
## Apps
|
|
||||||
|
|
||||||
| App | Lang | Descripcion |
|
|
||||||
|-----|------|-------------|
|
|
||||||
| [sqlite_api](apps/sqlite_api/app.md) | Go | API REST HTTP read-only sobre `registry.db` y todas las `operations.db`. Puerto `8484`. |
|
|
||||||
| [registry_dashboard](apps/registry_dashboard/app.md) | C++ / ImGui | Dashboard con KPIs, charts y tablas del registry. Consume `sqlite_api` (HTTP) con fallback a SQLite directo. |
|
|
||||||
|
|
||||||
Cada `app.md` es la referencia canonica del binario — endpoints completos, flags, dependencias. Este documento cubre **como operar el proyecto como un todo**: arranque, service, flujo de datos, troubleshooting.
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## Arquitectura
|
|
||||||
|
|
||||||
```
|
|
||||||
registry.db (raiz)
|
|
||||||
apps/*/operations.db
|
|
||||||
projects/*/apps/*/operations.db
|
|
||||||
│ (read-only, mode=ro)
|
|
||||||
▼
|
|
||||||
┌──────────────────────────────────────────────┐
|
|
||||||
│ sqlite_api (Go net/http, :8484) │
|
|
||||||
│ /health │
|
|
||||||
│ /api/databases │
|
|
||||||
│ /api/databases/:db/tables │
|
|
||||||
│ /api/databases/:db/schema │
|
|
||||||
│ /api/databases/:db/query (POST, SELECT) │
|
|
||||||
│ /api/databases/:db/fts │
|
|
||||||
└──────────────────────────────────────────────┘
|
|
||||||
▲
|
|
||||||
│ HTTP GET/POST
|
|
||||||
│ (cpp-httplib + nlohmann/json)
|
|
||||||
│
|
|
||||||
┌──────────────────────────────────────────────┐
|
|
||||||
│ registry_dashboard (C++ / ImGui + ImPlot) │
|
|
||||||
│ main.cpp → reload_data() │
|
|
||||||
│ data_http.cpp (primario, HTTP) │
|
|
||||||
│ data.cpp (fallback, SQLite C API) │
|
|
||||||
│ views.cpp → KPI row, charts, tables │
|
|
||||||
└──────────────────────────────────────────────┘
|
|
||||||
```
|
|
||||||
|
|
||||||
**Separacion de responsabilidades:**
|
|
||||||
|
|
||||||
- `sqlite_api` **no conoce el dashboard**. Es una API generica: expone cualquier DB SQLite de `fn_registry/` read-only con FTS5.
|
|
||||||
- `registry_dashboard` **no conoce la estructura de registry.db directamente**, solo a traves del JSON que devuelve la API. El modo SQLite directo es fallback para entornos sin red.
|
|
||||||
|
|
||||||
**Puerto `8484`** — elegido para no colisionar con Metabase (3000), Jupyter (8888) ni deploy_server (9090).
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## Servicio sqlite_api
|
|
||||||
|
|
||||||
### Modos de arranque
|
|
||||||
|
|
||||||
| Modo | Comando | Cuando usarlo |
|
|
||||||
|------|---------|---------------|
|
|
||||||
| Dev (foreground, `go run`) | `cd projects/fn_monitoring/apps/sqlite_api && go run -tags fts5 .` | Iteracion rapida, ver logs en la terminal |
|
|
||||||
| Dev (background) | `./start.sh` (dentro de `apps/sqlite_api/`) | Probar el dashboard rapido sin systemd. Escribe PID en `sqlite_api.pid` y log en `sqlite_api.log` |
|
|
||||||
| Production (systemd) | `sudo systemctl start sqlite_api` | Arranque en boot, restart on failure, logs en journal |
|
|
||||||
|
|
||||||
### Variables de entorno
|
|
||||||
|
|
||||||
| Var | Valor | Proposito |
|
|
||||||
|-----|-------|-----------|
|
|
||||||
| `FN_REGISTRY_ROOT` | ruta absoluta a la raiz del registry | Evita que el binario busque `registry.db` subiendo por el cwd. Obligatoria bajo systemd. |
|
|
||||||
|
|
||||||
### Instalar como servicio systemd (local)
|
|
||||||
|
|
||||||
Usar el pipeline del registry `install_systemd_service_bash_pipelines`:
|
|
||||||
|
|
||||||
```bash
|
|
||||||
cd /home/lucas/fn_registry
|
|
||||||
|
|
||||||
# 1. Build del binario
|
|
||||||
CGO_ENABLED=1 go build -tags fts5 \
|
|
||||||
-o projects/fn_monitoring/apps/sqlite_api/sqlite_api \
|
|
||||||
./projects/fn_monitoring/apps/sqlite_api/
|
|
||||||
|
|
||||||
# 2. Instalar unit + enable + start (requiere sudo sin password para systemctl)
|
|
||||||
source bash/functions/pipelines/install_systemd_service.sh
|
|
||||||
install_systemd_service \
|
|
||||||
--name sqlite_api \
|
|
||||||
--exec "$(pwd)/projects/fn_monitoring/apps/sqlite_api/sqlite_api" \
|
|
||||||
--workdir "$(pwd)" \
|
|
||||||
--env "FN_REGISTRY_ROOT=$(pwd)" \
|
|
||||||
--description "fn_registry SQLite HTTP API" \
|
|
||||||
--after network.target \
|
|
||||||
--restart on-failure
|
|
||||||
```
|
|
||||||
|
|
||||||
### Operacion
|
|
||||||
|
|
||||||
```bash
|
|
||||||
sudo systemctl status sqlite_api # estado + ultimas lineas del journal
|
|
||||||
sudo systemctl restart sqlite_api # tras rebuild del binario
|
|
||||||
sudo systemctl stop sqlite_api # parar
|
|
||||||
journalctl -u sqlite_api -f # logs en vivo
|
|
||||||
curl http://127.0.0.1:8484/health # health check
|
|
||||||
```
|
|
||||||
|
|
||||||
### Redeploy tras cambios en el codigo Go
|
|
||||||
|
|
||||||
```bash
|
|
||||||
cd /home/lucas/fn_registry
|
|
||||||
CGO_ENABLED=1 go build -tags fts5 \
|
|
||||||
-o projects/fn_monitoring/apps/sqlite_api/sqlite_api \
|
|
||||||
./projects/fn_monitoring/apps/sqlite_api/
|
|
||||||
sudo systemctl restart sqlite_api
|
|
||||||
```
|
|
||||||
|
|
||||||
No hace falta reinstalar el unit — solo recompilar y reiniciar.
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## Dashboard registry_dashboard
|
|
||||||
|
|
||||||
### Build
|
|
||||||
|
|
||||||
```bash
|
|
||||||
cd cpp
|
|
||||||
cmake -B build/linux -S .
|
|
||||||
cmake --build build/linux --target registry_dashboard -j$(nproc)
|
|
||||||
```
|
|
||||||
|
|
||||||
El binario queda en `cpp/build/linux/registry_dashboard` (o `projects/fn_monitoring/apps/registry_dashboard/registry_dashboard.exe` en Windows).
|
|
||||||
|
|
||||||
### Ejecucion
|
|
||||||
|
|
||||||
```bash
|
|
||||||
# Modo API (por defecto, intenta localhost:8484)
|
|
||||||
./registry_dashboard
|
|
||||||
|
|
||||||
# API remoto
|
|
||||||
./registry_dashboard --api http://192.168.1.10:8484
|
|
||||||
|
|
||||||
# API + fallback SQLite
|
|
||||||
./registry_dashboard --api http://127.0.0.1:8484 /home/lucas/fn_registry/registry.db
|
|
||||||
|
|
||||||
# Solo SQLite (sin API)
|
|
||||||
./registry_dashboard /home/lucas/fn_registry/registry.db
|
|
||||||
```
|
|
||||||
|
|
||||||
La UI muestra en la cabecera de donde vienen los datos (HTTP vs SQLite). `F5` recarga.
|
|
||||||
|
|
||||||
### Flujo de datos
|
|
||||||
|
|
||||||
1. `main.cpp::reload_data()` intenta HTTP primero via `load_registry_data_http()`.
|
|
||||||
2. Si la API responde `200` y el JSON parsea, los datos pueblan `RegistryData`.
|
|
||||||
3. Si falla la API (timeout, 5xx, JSON invalido) y hay `--db`, cae a `load_registry_data()` (SQLite directo).
|
|
||||||
4. Si ninguno funciona, la UI muestra un mensaje de error y no hay reintento automatico — hay que pulsar reload.
|
|
||||||
|
|
||||||
### Vistas
|
|
||||||
|
|
||||||
| Seccion | Datos | Query subyacente |
|
|
||||||
|---------|-------|------------------|
|
|
||||||
| KPI row (8 cards) | totales y porcentajes | `SELECT COUNT(*)` sobre functions, types, apps, analysis, unit_tests, proposals + agregados tested/pure |
|
|
||||||
| Charts (bar + pie) | funciones por lang/domain, reparto pure/impure, kind | `GROUP BY lang`, `GROUP BY domain`, `GROUP BY purity`, `GROUP BY kind` |
|
|
||||||
| Tablas | ultimas 20 functions, apps, analysis, types | `ORDER BY updated_at DESC LIMIT 20` |
|
|
||||||
|
|
||||||
Detalle de composicion de componentes viz en `apps/registry_dashboard/app.md`.
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## Troubleshooting
|
|
||||||
|
|
||||||
| Sintoma | Causa probable | Verificacion / Fix |
|
|
||||||
|---------|----------------|--------------------|
|
|
||||||
| Dashboard dice "HTTP API failed, falling back to SQLite" | `sqlite_api` no esta corriendo | `curl http://127.0.0.1:8484/health` — si falla, `systemctl status sqlite_api` o `./start.sh` |
|
|
||||||
| `sqlite_api` arranca y muere inmediatamente | No encuentra `registry.db` | Exportar `FN_REGISTRY_ROOT=/home/lucas/fn_registry` o correr desde la raiz del registry |
|
|
||||||
| `systemctl start sqlite_api` pide password | Falta sudoers para systemctl | Ver `.claude/rules/deploy.md` — el usuario necesita `NOPASSWD` para `systemctl`, `mv` a `/etc/systemd/system/` |
|
|
||||||
| Dashboard abre pero todas las cifras son 0 | API conecta pero devuelve DB vacia | `curl -X POST http://127.0.0.1:8484/api/databases/registry/query -d '{"sql":"SELECT COUNT(*) FROM functions"}'` |
|
|
||||||
| API responde lento / timeout | Query pesada sobre FTS5 | Timeout hardcoded a 5s en `handlers.go`. Revisar la query en journal. |
|
|
||||||
| Bind rechazado (`address already in use`) | Otro proceso en `8484` | `ss -tlnp | grep 8484` — matar el huerfano o cambiar `--bind` |
|
|
||||||
|
|
||||||
### Logs
|
|
||||||
|
|
||||||
```bash
|
|
||||||
# systemd
|
|
||||||
journalctl -u sqlite_api -n 100 --no-pager
|
|
||||||
journalctl -u sqlite_api -f
|
|
||||||
|
|
||||||
# start.sh
|
|
||||||
tail -f projects/fn_monitoring/apps/sqlite_api/sqlite_api.log
|
|
||||||
```
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## Como extender
|
|
||||||
|
|
||||||
### Anadir un endpoint a sqlite_api
|
|
||||||
|
|
||||||
1. Registrar la ruta en `Server.Routes()` (`handlers.go`).
|
|
||||||
2. Handler lee `r.URL.Path` / `r.Body`, delega en `DBPool` para resolver la DB, ejecuta SQL read-only.
|
|
||||||
3. Test en `handlers_test.go` (patron: tabla de casos HTTP).
|
|
||||||
4. Rebuild + `systemctl restart sqlite_api`.
|
|
||||||
5. Documentar en `apps/sqlite_api/app.md` (tabla de endpoints).
|
|
||||||
|
|
||||||
### Anadir una vista al dashboard
|
|
||||||
|
|
||||||
1. Nuevo campo en `RegistryData` (`data.h`) + su equivalente en la respuesta JSON.
|
|
||||||
2. Parseo en `data_http.cpp` y carga SQL en `data.cpp` (ambos paths, para mantener el fallback).
|
|
||||||
3. Renderizado en `views.cpp` usando componentes del dominio `viz` (`kpi_card`, `bar_chart`, etc.) — ver regla `frontend_theming` analoga para C++: usar primitivos del registry antes que ImGui crudo.
|
|
||||||
4. Rebuild con CMake.
|
|
||||||
|
|
||||||
### Anadir una DB nueva
|
|
||||||
|
|
||||||
La descubre automaticamente `DiscoverDatabases()` escaneando `apps/*/operations.db` y `projects/*/apps/*/operations.db`. No hay que registrar nada — al reiniciar `sqlite_api` aparecen con alias `ops:{app_name}`.
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## Deploy en otros PCs
|
|
||||||
|
|
||||||
Este proyecto se instala identico en cualquier maquina con el registry clonado:
|
|
||||||
|
|
||||||
1. `fn sync` para traer los metadatos del proyecto.
|
|
||||||
2. Build + systemd install (seccion "Instalar como servicio systemd" arriba).
|
|
||||||
3. Build del dashboard.
|
|
||||||
|
|
||||||
Los datos son los `.db` locales — cada PC ve su propio estado del registry y sus propias `operations.db`. No hay sincronizacion remota de datos en este servicio: para eso existe `fn sync` contra `registry_api` (proyecto diferente, ver memoria `project_registry_api`).
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## Estado actual
|
|
||||||
|
|
||||||
### Fase — projects view + mutaciones desde el dashboard `[done 2026-04-25]`
|
|
||||||
|
|
||||||
El dashboard pasa de read-only a manipular el registry via la API. Ampliacion en tres patas:
|
|
||||||
|
|
||||||
**Backend (`sqlite_api`)** — endpoints nuevos en `handlers_projects.go` y `handlers_mutations.go`:
|
|
||||||
|
|
||||||
| Metodo | Path | Que hace |
|
|
||||||
|---|---|---|
|
|
||||||
| `GET` | `/api/projects` | Lista con conteos `apps_count` / `analyses_count` / `vaults_count` por proyecto + bloque `orphans` (entidades con `project_id` vacio). |
|
|
||||||
| `GET` | `/api/projects/{id}` | Detalle: apps[], analyses[], vaults[]. Acepta `id="orphans"` para devolver las huerfanas. |
|
|
||||||
| `POST` | `/api/reindex` | Ejecuta `fn index` desde `registryRoot`, devuelve `{ok, output}`. |
|
|
||||||
| `POST` | `/api/add/app` | Body `{name, lang, domain, project, description}` → crea `apps/{name}/` o `projects/{p}/apps/{name}/` con `app.md` minimo + `fn index`. |
|
|
||||||
| `POST` | `/api/add/analysis` | Body `{name, project, packages[], description}` → invoca `fn run init_jupyter_analysis [--project p] name pkg1 pkg2 ...`. |
|
|
||||||
| `POST` | `/api/add/vault` | Body `{name, project, path, description}` → crea dir o symlink en `projects/{p}/vaults/` + entry append en `vault.yaml`. |
|
|
||||||
|
|
||||||
`Server.registryRoot` se inyecta en `NewServer(pool, root)` (rebajado de `findRegistryRoot()` en `main.go`). Helpers `runFN()` y `runShell()` ejecutan con `cmd.Dir = registryRoot` y `FN_REGISTRY_ROOT` en el env.
|
|
||||||
|
|
||||||
**Dashboard (`registry_dashboard`)** — actions bar + tab Projects + modal Add:
|
|
||||||
|
|
||||||
- Toolbar nueva en el header (`fn_ui::toolbar`): boton `Reindex` (Primary) → dispara `http_post_reindex` via `process_runner`; boton `+ Add` → abre `modal_dialog`; boton `Reload`; `toast_inbox_button` con badge.
|
|
||||||
- Modal Add con `select` para kind (App / Analysis / Vault), `select` de proyecto (obligatorio para Vault, opcional para resto), `text_input` Name + Description y campos especificos por kind (lang/domain para App, packages CSV para Analysis, abs path para Vault). Submit dispara el endpoint correspondiente via `process_runner`. Toast al completar + reload automatico.
|
|
||||||
- Tab Projects con dos columnas: `tree_view` izquierda (proyectos + entrada "(orphans)" cuando hay entidades huerfanas), detalle derecha con tabs internas Apps / Analysis / Vaults. Click en un proyecto dispara `load_project_detail_http`.
|
|
||||||
|
|
||||||
**Datos en `RegistryData`**: nuevos `projects[]`, `orphan_apps`, `orphan_analyses`, `orphan_vaults`. Tipos nuevos `ProjectRow`, `VaultRow`, `ProjectDetail`. `load_registry_data_http` llama a `load_projects_http` al final como best-effort (no fatal si falla).
|
|
||||||
|
|
||||||
### Bug fix — vibracion al redimensionar `[done 2026-04-25]`
|
|
||||||
|
|
||||||
Dos fuentes de "vibracion" durante drag-resize de la ventana:
|
|
||||||
|
|
||||||
- `fullscreen_window_cpp_core` v0.2: anadido `NoScrollbar | NoScrollWithMouse`. Sin esto, si el contenido excedia por 1-2px aparecia un scrollbar fugaz que reducia el ancho ~14px y reflowaba todo.
|
|
||||||
- `views.cpp::draw_dashboard`: altura de charts pasa de `GetContentRegionAvail().y * 0.35` a constante 260 px. La proporcion relativa propagaba el resize a todos los plots.
|
|
||||||
- `kpi_card_cpp_viz` v1.2: altura fija 78 px (antes 108) + scale 1.4x (antes 1.8) + padding sm + `NoScrollbar`. El `AutoResizeY` con 8 cards generaba lag perceptible al redimensionar.
|
|
||||||
|
|
||||||
### Bug fix — HTTP POST timeout en thread de background `[done 2026-04-25]`
|
|
||||||
|
|
||||||
`http_client.cpp::request()` pasaba `struct timeval` a `setsockopt(SO_RCVTIMEO)` en Windows, donde MSDN especifica `DWORD` ms. Resultado: timeout efectivo de **5 ms** en lugar de **5 s**. Se nota especialmente en POST desde threads (background runners) porque la latencia de scheduling puede pasar de 5 ms. Fix: rama `_WIN32` con `DWORD timeout_ms`. Tambien `wsa_init` envuelto en `std::call_once` para evitar race entre main thread + runners. Mensajes de error formateados con ASCII (em dash U+2014 falla render con la fuente default).
|
|
||||||
|
|
||||||
### Tooling sibling — primitives_gallery `[done 2026-04-25]`
|
|
||||||
|
|
||||||
Nueva app dev en `cpp/apps/primitives_gallery/` (no es app del registry, vive en el source tree). Catalogo visual interactivo de los 19 primitivos UI de `cpp/functions/{core,viz}` con sidebar + panel + snippet por demo. Doble rol: smoke test visual al modificar tokens/componentes y build gate (esta en el CMake principal — si un primitivo rompe API la gallery no compila).
|
|
||||||
|
|
||||||
Demo destacada: `graph_viewport` con sliders de Nodes (100-20 000), Clusters (2-16) y los tres parametros de `ForceLayoutConfig` (Repulsion / Attraction / Gravity) aplicados en vivo. Util tambien como benchmark de rendimiento del stack `graph_renderer` + `graph_force_layout` + `graph_spatial_hash`.
|
|
||||||
|
|
||||||
`README.md` propio en `cpp/apps/primitives_gallery/README.md`.
|
|
||||||
|
|
||||||
### Lo siguiente que pega
|
|
||||||
|
|
||||||
- Tests unitarios de logica pura (Phase A del plan de tests): vendoreado de `doctest`, ~6 tests para `label_stride`, `slice_at`, `process_runner` transitions, `toast` queue, `tokens` sanity, `parse_url`. Cierra el ciclo gallery (visual) + ctest (logica).
|
|
||||||
- Para que algunos tests sean posibles hace falta exponer funciones internas de `bar_chart.cpp` y `pie_chart.cpp` (actualmente en namespace anonimo).
|
|
||||||
- `loginctl enable-linger lucas` para que el `sqlite_api.service` (user-level systemd) sobreviva al logout. Requiere sudo una vez. Decision pendiente del usuario.
|
|
||||||
@@ -0,0 +1,84 @@
|
|||||||
|
---
|
||||||
|
name: fetch_http_fingerprint_cdp
|
||||||
|
kind: function
|
||||||
|
lang: py
|
||||||
|
domain: browser
|
||||||
|
version: "1.0.0"
|
||||||
|
purity: impure
|
||||||
|
signature: "def fetch_http_fingerprint_cdp(url: str, *, port: int = 9222, wait_render_s: float = 2.0, timeout_s: float = 30.0, close_tab: bool = True) -> dict"
|
||||||
|
description: "Fingerprint web con HTML RENDERIZADO tras ejecutar JavaScript via Chrome DevTools Protocol (CDP). Navega con un Chrome remoto, espera a que la SPA monte el DOM y recoge el HTML post-JS, titulo, URL final y nombres de cookie. Detecta frameworks que el fetch estatico NO ve: React, Vue, Angular, Next, Svelte montados en runtime. Wappalyzer dinamico: devuelve la MISMA estructura que fetch_http_fingerprint para que detect_web_tech la consuma sin cambios. Recon web de SPAs / single-page applications con HTML inicial vacio."
|
||||||
|
tags: [recon, web-recon, browser, cdp, fingerprint, spa, wappalyzer, javascript, react, vue, angular]
|
||||||
|
uses_functions: ["cdp_open_url_and_wait_py_pipelines", "cdp_eval_py_browser"]
|
||||||
|
uses_types: []
|
||||||
|
returns: []
|
||||||
|
returns_optional: false
|
||||||
|
error_type: "error_py_core"
|
||||||
|
imports: []
|
||||||
|
params:
|
||||||
|
- name: url
|
||||||
|
desc: "URL objetivo del fingerprint (sitio a inspeccionar)."
|
||||||
|
- name: port
|
||||||
|
desc: "Puerto de remote debugging del Chrome a usar. Default 9222 (navegador diario, activado global). Para aislamiento de recon de terceros, apuntar a 9333 (Chrome aislado del browser_mcp)."
|
||||||
|
- name: wait_render_s
|
||||||
|
desc: "Segundos extra de espera tras el load event para que el JS de la SPA pinte el DOM (el load NO garantiza render completo). Default 2.0."
|
||||||
|
- name: timeout_s
|
||||||
|
desc: "Timeout de la navegacion en segundos. Default 30.0."
|
||||||
|
- name: close_tab
|
||||||
|
desc: "Si True, cierra el tab al terminar (best-effort via window.close()) para no dejar pestanas abiertas. Default True."
|
||||||
|
output: "dict siempre (nunca lanza). En exito: {status:'ok', url, final_url, title, status_code:None, headers:{}, cookies:[solo nombres no-httponly], html:<RENDERIZADO post-JS>, html_len, rendered:True, raw}. En error: {status:'error', error:<mensaje claro>, url}. status_code/headers quedan vacios porque CDP no expone la capa de red; esta funcion aporta el HTML renderizado, que es lo que detect_web_tech necesita para una SPA."
|
||||||
|
tested: true
|
||||||
|
tests: ["test_sin_chrome_devuelve_error_sin_lanzar", "test_url_vacia_devuelve_error", "test_happy_path_monkeypatch", "test_happy_path_eval_falla_devuelve_error"]
|
||||||
|
test_file_path: "python/functions/browser/fetch_http_fingerprint_cdp_test.py"
|
||||||
|
file_path: "python/functions/browser/fetch_http_fingerprint_cdp.py"
|
||||||
|
---
|
||||||
|
|
||||||
|
## Ejemplo
|
||||||
|
|
||||||
|
```python
|
||||||
|
import sys, os, json
|
||||||
|
sys.path.insert(0, os.path.join("python", "functions"))
|
||||||
|
from browser.fetch_http_fingerprint_cdp import fetch_http_fingerprint_cdp
|
||||||
|
from cybersecurity.detect_web_tech import detect_web_tech
|
||||||
|
|
||||||
|
# Recoge el HTML RENDERIZADO (post-JS) de una SPA via el Chrome diario (9222).
|
||||||
|
res = fetch_http_fingerprint_cdp("https://react.dev/", port=9222)
|
||||||
|
if res["status"] == "ok":
|
||||||
|
# detect_web_tech (PURA) consume las mismas senales que fetch_http_fingerprint.
|
||||||
|
tech = detect_web_tech(
|
||||||
|
res["headers"], # {} con CDP — usa el fetch estatico para headers
|
||||||
|
html=res["html"], # el HTML RENDERIZADO post-JS: aqui esta la clave
|
||||||
|
cookies=res["cookies"], # solo nombres
|
||||||
|
final_url=res["final_url"],
|
||||||
|
)
|
||||||
|
print(json.dumps(tech, ensure_ascii=False, indent=2))
|
||||||
|
else:
|
||||||
|
print("error:", res["error"])
|
||||||
|
```
|
||||||
|
|
||||||
|
## Cuando usarla
|
||||||
|
|
||||||
|
Cuando el fetch estatico (`fetch_http_fingerprint`) NO detecta el framework porque
|
||||||
|
el sitio es una SPA que monta el DOM con JavaScript (HTML inicial casi vacio:
|
||||||
|
`<div id="root">` o `<div id="__next">` sin contenido). Esta funcion recoge el HTML
|
||||||
|
DESPUES de que el JS pinte, de modo que `detect_web_tech` ve React / Vue / Angular /
|
||||||
|
Next igual que un Wappalyzer dinamico. Requiere un Chrome con remote debugging.
|
||||||
|
Combina ambas capas para fingerprint completo: estatico para headers + status +
|
||||||
|
cookies httponly; CDP para el HTML renderizado.
|
||||||
|
|
||||||
|
## Gotchas
|
||||||
|
|
||||||
|
- **Requiere un Chrome con remote debugging** escuchando en `port`: 9222 (navegador
|
||||||
|
diario, ya activado global) o 9333 (Chrome aislado del browser_mcp). Sin Chrome
|
||||||
|
vivo devuelve `{status:"error", error:"no hay Chrome en el puerto N (¿remote debugging activo?)"}` — no lanza.
|
||||||
|
- **Abre un tab en ESE navegador.** Con `port=9222` mezcla la sesion de tu navegador
|
||||||
|
PERSONAL (cookies de tu sesion, historial). Para recon de TERCEROS prefiere
|
||||||
|
`port=9333` (aislado) para no contaminar ni filtrar tu sesion.
|
||||||
|
- **`document.cookie` NO ve cookies httponly** (las de sesion casi siempre lo son):
|
||||||
|
esas y los headers de respuesta vienen mejor del fetch estatico `fetch_http_fingerprint`.
|
||||||
|
- **`headers` y `status_code` quedan vacios/None**: CDP no expone la capa de red sin
|
||||||
|
el dominio Network. Esta funcion aporta el HTML renderizado, no la red. Si necesitas
|
||||||
|
el status real o headers, usa el fetch estatico en paralelo.
|
||||||
|
- **`wait_render_s` puede ser insuficiente** para SPAs lentas (mucho data-fetching tras
|
||||||
|
el load). Si el `html` sale incompleto, sube `wait_render_s` (ej. 4.0-6.0).
|
||||||
|
- **Respeta scope y autorizacion legal**: solo inspecciona sitios que tengas permiso
|
||||||
|
para analizar.
|
||||||
@@ -0,0 +1,206 @@
|
|||||||
|
"""Fingerprint web con HTML RENDERIZADO (post-JS) via Chrome DevTools Protocol.
|
||||||
|
|
||||||
|
Funcion IMPURA: usa un Chrome con remote debugging para navegar a una URL,
|
||||||
|
esperar a que el JavaScript de la pagina monte el DOM, y recoger el HTML ya
|
||||||
|
renderizado (mas titulo, URL final y nombres de cookie). Devuelve la MISMA
|
||||||
|
estructura que `fetch_http_fingerprint_py_cybersecurity` para que el matcher de
|
||||||
|
firmas `detect_web_tech_py_cybersecurity` la consuma SIN cambios.
|
||||||
|
|
||||||
|
Por que existe: el fetch estatico (`fetch_http_fingerprint`) hace un GET con
|
||||||
|
urllib y NO ejecuta JavaScript. Una SPA (React/Vue/Angular/Next con HTML inicial
|
||||||
|
casi vacio) monta su framework en runtime, asi que el estatico no ve el stack.
|
||||||
|
Esta funcion recoge el HTML DESPUES de que el JS pinte, de modo que el matcher
|
||||||
|
detecta el framework igual que un Wappalyzer dinamico.
|
||||||
|
|
||||||
|
Compone DOS funciones del registry (no reescribe transporte CDP):
|
||||||
|
1. `cdp_open_url_and_wait` (pipeline) — crea tab nuevo en Chrome remoto, navega
|
||||||
|
y espera `Page.loadEventFired`. Devuelve el tab_id.
|
||||||
|
2. `cdp_eval` (browser) — evalua JS en la pestana cuyo URL contiene un substring.
|
||||||
|
|
||||||
|
SEGURIDAD: en `cookies` solo se guardan los NOMBRES, jamas los valores (son
|
||||||
|
tokens de sesion sensibles). `document.cookie` ademas NO ve cookies httponly:
|
||||||
|
esas (y los headers de respuesta) vienen mejor del fetch estatico.
|
||||||
|
|
||||||
|
Devuelve SIEMPRE un dict (estilo del grupo recon): nunca lanza excepciones.
|
||||||
|
"""
|
||||||
|
|
||||||
|
import json
|
||||||
|
import os
|
||||||
|
import sys
|
||||||
|
import time
|
||||||
|
import urllib.parse
|
||||||
|
|
||||||
|
sys.path.insert(0, os.path.join(os.path.dirname(__file__), ".."))
|
||||||
|
|
||||||
|
from browser.cdp_eval import cdp_eval
|
||||||
|
from pipelines.cdp_open_url_and_wait import cdp_open_url_and_wait
|
||||||
|
|
||||||
|
|
||||||
|
def _cookie_names(cookie_str: str) -> list[str]:
|
||||||
|
"""Extrae SOLO los nombres de cookie de un `document.cookie` (nunca valores).
|
||||||
|
|
||||||
|
`document.cookie` viene como ``"a=1; b=2; c=3"``. Partimos por ';' y nos
|
||||||
|
quedamos con lo anterior al primer '=' de cada par. Deduplica en orden.
|
||||||
|
"""
|
||||||
|
out: list[str] = []
|
||||||
|
seen: set[str] = set()
|
||||||
|
for pair in (cookie_str or "").split(";"):
|
||||||
|
pair = pair.strip()
|
||||||
|
if not pair:
|
||||||
|
continue
|
||||||
|
name = pair.split("=", 1)[0].strip()
|
||||||
|
if name and name not in seen:
|
||||||
|
seen.add(name)
|
||||||
|
out.append(name)
|
||||||
|
return out
|
||||||
|
|
||||||
|
|
||||||
|
def fetch_http_fingerprint_cdp(
|
||||||
|
url: str,
|
||||||
|
*,
|
||||||
|
port: int = 9222,
|
||||||
|
wait_render_s: float = 2.0,
|
||||||
|
timeout_s: float = 30.0,
|
||||||
|
close_tab: bool = True,
|
||||||
|
) -> dict:
|
||||||
|
"""Recoge el HTML renderizado (post-JS) de una URL via CDP para fingerprinting.
|
||||||
|
|
||||||
|
Funcion IMPURA: necesita un Chrome con remote debugging escuchando en `port`.
|
||||||
|
Navega con `cdp_open_url_and_wait`, espera `wait_render_s` para que la SPA
|
||||||
|
pinte el DOM, y recoge senales con `cdp_eval`. Nunca lanza: cualquier fallo
|
||||||
|
(Chrome no responde, tab no abre, eval con error) devuelve
|
||||||
|
``{"status": "error", ...}``.
|
||||||
|
|
||||||
|
La estructura de salida es COMPATIBLE con `fetch_http_fingerprint` y
|
||||||
|
`detect_web_tech`: `status_code` y `headers` quedan a None/vacios (CDP no
|
||||||
|
expone la capa de red sin el dominio Network); esta funcion aporta el `html`
|
||||||
|
RENDERIZADO, que es justo lo que el matcher de firmas necesita para una SPA.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
url: URL objetivo del fingerprint.
|
||||||
|
port: Puerto de remote debugging del Chrome a usar. Default 9222
|
||||||
|
(navegador diario, ya activado global). Para AISLAMIENTO (recon de
|
||||||
|
terceros sin mezclar tu sesion personal) apunta a 9333 (el Chrome
|
||||||
|
aislado del browser_mcp).
|
||||||
|
wait_render_s: Segundos extra de espera tras el load para que el JS de la
|
||||||
|
SPA pinte el DOM (el load event NO garantiza render completo). Default 2.0.
|
||||||
|
timeout_s: Timeout de la navegacion en segundos. Default 30.0.
|
||||||
|
close_tab: Si True, cierra el tab al terminar (best-effort via
|
||||||
|
`window.close()`) para no dejar pestanas abiertas en el navegador.
|
||||||
|
Default True.
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
dict. En exito::
|
||||||
|
|
||||||
|
{
|
||||||
|
"status": "ok",
|
||||||
|
"url": <url solicitada>,
|
||||||
|
"final_url": <location.href tras redirects client-side>,
|
||||||
|
"title": <document.title o None>,
|
||||||
|
"status_code": None, # CDP no expone el status del documento principal
|
||||||
|
"headers": {}, # CDP no expone response headers sin Network domain
|
||||||
|
"cookies": [<nombres de cookie no-httponly>],
|
||||||
|
"html": <HTML renderizado (post-JS)>,
|
||||||
|
"html_len": <len del html>,
|
||||||
|
"rendered": True, # marca que el html es post-JS
|
||||||
|
"raw": <bloque legible de evidencia>,
|
||||||
|
}
|
||||||
|
|
||||||
|
En error::
|
||||||
|
|
||||||
|
{"status": "error", "error": <mensaje claro>, "url": <url>}
|
||||||
|
"""
|
||||||
|
if not url or not url.strip():
|
||||||
|
return {"status": "error", "error": "fetch_http_fingerprint_cdp: url vacia", "url": url}
|
||||||
|
|
||||||
|
# Substring para elegir el target correcto en cdp_eval. El hostname es el
|
||||||
|
# fragmento mas estable de la URL (sobrevive a query strings y fragments).
|
||||||
|
try:
|
||||||
|
substr = urllib.parse.urlparse(url).hostname or url
|
||||||
|
except Exception: # noqa: BLE001 — URL malformada, caer al url completo
|
||||||
|
substr = url
|
||||||
|
|
||||||
|
# 1. Navegar: crea tab nuevo en el Chrome remoto y espera el load event.
|
||||||
|
try:
|
||||||
|
cdp_open_url_and_wait(port, url, int(timeout_s))
|
||||||
|
except Exception as e: # noqa: BLE001 — RuntimeError de cdp_open_url_and_wait
|
||||||
|
msg = str(e)
|
||||||
|
# Mensaje claro para el caso mas comun: no hay Chrome escuchando.
|
||||||
|
if "no se pudo crear tab" in msg or "URLError" in msg or "Connection refused" in msg:
|
||||||
|
msg = f"no hay Chrome en el puerto {port} (¿remote debugging activo?): {e}"
|
||||||
|
return {"status": "error", "error": f"fetch_http_fingerprint_cdp: {msg}", "url": url}
|
||||||
|
|
||||||
|
# 2. Esperar el render del JS (el load event no garantiza DOM pintado en SPAs).
|
||||||
|
if wait_render_s > 0:
|
||||||
|
time.sleep(wait_render_s)
|
||||||
|
|
||||||
|
# 3. Recoger senales con un solo eval (un objeto JSON con todo).
|
||||||
|
expr = (
|
||||||
|
"JSON.stringify({"
|
||||||
|
"html: document.documentElement.outerHTML,"
|
||||||
|
"title: document.title,"
|
||||||
|
"href: location.href,"
|
||||||
|
"cookie: document.cookie"
|
||||||
|
"})"
|
||||||
|
)
|
||||||
|
r = cdp_eval(expr, port=port, target_url_substr=substr, timeout_s=max(10.0, timeout_s))
|
||||||
|
|
||||||
|
# 4. (best-effort) cerrar el tab para no dejar basura en el navegador.
|
||||||
|
if close_tab:
|
||||||
|
try:
|
||||||
|
cdp_eval("window.close()", port=port, target_url_substr=substr, timeout_s=5.0)
|
||||||
|
except Exception: # noqa: BLE001 — cierre best-effort, no afecta al resultado
|
||||||
|
pass
|
||||||
|
|
||||||
|
if not r.get("ok"):
|
||||||
|
err = r.get("error") or "eval CDP fallo sin mensaje"
|
||||||
|
return {
|
||||||
|
"status": "error",
|
||||||
|
"error": f"fetch_http_fingerprint_cdp: no se pudo evaluar JS ({err})",
|
||||||
|
"url": url,
|
||||||
|
}
|
||||||
|
|
||||||
|
raw_value = r.get("value")
|
||||||
|
try:
|
||||||
|
data = json.loads(raw_value) if isinstance(raw_value, str) else (raw_value or {})
|
||||||
|
except Exception: # noqa: BLE001 — JSON malformado del eval
|
||||||
|
return {
|
||||||
|
"status": "error",
|
||||||
|
"error": "fetch_http_fingerprint_cdp: respuesta del eval no es JSON valido",
|
||||||
|
"url": url,
|
||||||
|
}
|
||||||
|
|
||||||
|
html = data.get("html") or ""
|
||||||
|
title = data.get("title") or None
|
||||||
|
final_url = data.get("href") or r.get("target_url") or url
|
||||||
|
cookies = _cookie_names(data.get("cookie") or "")
|
||||||
|
|
||||||
|
raw = (
|
||||||
|
f"CDP fingerprint {url}\n"
|
||||||
|
f"final_url: {final_url}\n"
|
||||||
|
f"title: {title}\n"
|
||||||
|
f"html_len: {len(html)}"
|
||||||
|
)
|
||||||
|
|
||||||
|
return {
|
||||||
|
"status": "ok",
|
||||||
|
"url": url,
|
||||||
|
"final_url": final_url,
|
||||||
|
"title": title,
|
||||||
|
"status_code": None,
|
||||||
|
"headers": {},
|
||||||
|
"cookies": cookies,
|
||||||
|
"html": html,
|
||||||
|
"html_len": len(html),
|
||||||
|
"rendered": True,
|
||||||
|
"raw": raw,
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
target = sys.argv[1] if len(sys.argv) > 1 else "https://react.dev/"
|
||||||
|
dbg_port = int(sys.argv[2]) if len(sys.argv) > 2 else 9222
|
||||||
|
out = fetch_http_fingerprint_cdp(target, port=dbg_port)
|
||||||
|
# No volcar el html entero por stdout: solo el resumen.
|
||||||
|
summary = {k: v for k, v in out.items() if k != "html"}
|
||||||
|
print(json.dumps(summary, ensure_ascii=False, indent=2))
|
||||||
@@ -0,0 +1,88 @@
|
|||||||
|
"""Tests para fetch_http_fingerprint_cdp.
|
||||||
|
|
||||||
|
No hay Chrome en el entorno de test/CI. Se cubren:
|
||||||
|
- El error path REAL: sin Chrome escuchando -> {status:"error"} sin lanzar.
|
||||||
|
- El happy path por composicion: monkeypatch de cdp_open_url_and_wait + cdp_eval
|
||||||
|
para validar la orquestacion (estructura, html renderizado, cookies solo nombres)
|
||||||
|
sin Chrome real.
|
||||||
|
"""
|
||||||
|
|
||||||
|
import os
|
||||||
|
import sys
|
||||||
|
|
||||||
|
sys.path.insert(0, os.path.join(os.path.dirname(__file__), ".."))
|
||||||
|
|
||||||
|
import browser.fetch_http_fingerprint_cdp as mod
|
||||||
|
from browser.fetch_http_fingerprint_cdp import fetch_http_fingerprint_cdp
|
||||||
|
|
||||||
|
|
||||||
|
def test_sin_chrome_devuelve_error_sin_lanzar():
|
||||||
|
# Puerto donde no hay Chrome -> degradacion limpia, nunca excepcion.
|
||||||
|
res = fetch_http_fingerprint_cdp("http://127.0.0.1:1/", port=1, timeout_s=2)
|
||||||
|
assert res["status"] == "error"
|
||||||
|
assert "error" in res and res["error"]
|
||||||
|
assert res["url"] == "http://127.0.0.1:1/"
|
||||||
|
|
||||||
|
|
||||||
|
def test_url_vacia_devuelve_error():
|
||||||
|
res = fetch_http_fingerprint_cdp(" ")
|
||||||
|
assert res["status"] == "error"
|
||||||
|
assert "url vacia" in res["error"]
|
||||||
|
|
||||||
|
|
||||||
|
def test_happy_path_monkeypatch(monkeypatch):
|
||||||
|
# Fake del pipeline: devuelve un tab_id sin tocar red.
|
||||||
|
def fake_open(debug_port, url, timeout_s=30):
|
||||||
|
assert url == "https://some-spa.com/"
|
||||||
|
return "TAB123"
|
||||||
|
|
||||||
|
# Fake del eval: primera llamada (recoger senales) devuelve el JSON de la SPA;
|
||||||
|
# llamadas posteriores (window.close) devuelven ok vacio.
|
||||||
|
calls = {"n": 0}
|
||||||
|
spa_html = '<html><body><div id="__next">hi</div></body></html>'
|
||||||
|
|
||||||
|
def fake_eval(expression, *, port=9222, target_url_substr="", await_promise=False, timeout_s=10.0):
|
||||||
|
calls["n"] += 1
|
||||||
|
if "outerHTML" in expression:
|
||||||
|
import json as _json
|
||||||
|
payload = _json.dumps({
|
||||||
|
"html": spa_html,
|
||||||
|
"title": "Some SPA",
|
||||||
|
"href": "https://some-spa.com/home",
|
||||||
|
"cookie": "session=SECRETVALUE; theme=dark",
|
||||||
|
})
|
||||||
|
return {"ok": True, "value": payload, "error": "", "target_url": "https://some-spa.com/"}
|
||||||
|
return {"ok": True, "value": None, "error": "", "target_url": "https://some-spa.com/"}
|
||||||
|
|
||||||
|
monkeypatch.setattr(mod, "cdp_open_url_and_wait", fake_open)
|
||||||
|
monkeypatch.setattr(mod, "cdp_eval", fake_eval)
|
||||||
|
|
||||||
|
res = fetch_http_fingerprint_cdp("https://some-spa.com/", port=9222, wait_render_s=0)
|
||||||
|
|
||||||
|
assert res["status"] == "ok"
|
||||||
|
assert res["rendered"] is True
|
||||||
|
assert '<div id="__next">' in res["html"]
|
||||||
|
assert res["html_len"] == len(spa_html)
|
||||||
|
assert res["title"] == "Some SPA"
|
||||||
|
assert res["final_url"] == "https://some-spa.com/home"
|
||||||
|
# Cookies: SOLO nombres, jamas valores.
|
||||||
|
assert res["cookies"] == ["session", "theme"]
|
||||||
|
assert "SECRETVALUE" not in str(res["cookies"])
|
||||||
|
# Compatibilidad con detect_web_tech: status_code None, headers vacio.
|
||||||
|
assert res["status_code"] is None
|
||||||
|
assert res["headers"] == {}
|
||||||
|
|
||||||
|
|
||||||
|
def test_happy_path_eval_falla_devuelve_error(monkeypatch):
|
||||||
|
def fake_open(debug_port, url, timeout_s=30):
|
||||||
|
return "TAB123"
|
||||||
|
|
||||||
|
def fake_eval(expression, *, port=9222, target_url_substr="", await_promise=False, timeout_s=10.0):
|
||||||
|
return {"ok": False, "value": None, "error": "boom", "target_url": ""}
|
||||||
|
|
||||||
|
monkeypatch.setattr(mod, "cdp_open_url_and_wait", fake_open)
|
||||||
|
monkeypatch.setattr(mod, "cdp_eval", fake_eval)
|
||||||
|
|
||||||
|
res = fetch_http_fingerprint_cdp("https://x.com/", wait_render_s=0)
|
||||||
|
assert res["status"] == "error"
|
||||||
|
assert "boom" in res["error"]
|
||||||
@@ -0,0 +1,81 @@
|
|||||||
|
---
|
||||||
|
name: build_vcard
|
||||||
|
kind: function
|
||||||
|
lang: py
|
||||||
|
domain: core
|
||||||
|
version: "1.0.0"
|
||||||
|
purity: pure
|
||||||
|
signature: "def build_vcard(contact: dict) -> str"
|
||||||
|
description: "Serializa un contacto (dict) a un VCARD 3.0 con soporte multi-valor: varias lineas TEL, EMAIL y ADR. Pura, solo compone texto. Acepta claves en espanol e ingles. Generaliza el _build_vcard inline de osint_web."
|
||||||
|
tags: [dav, vcard, carddav, contact, serialize, osint]
|
||||||
|
params:
|
||||||
|
- name: contact
|
||||||
|
desc: "dict del contacto. Claves opcionales (acepta nombre ES o EN): uid/slug (identificador, uno obligatorio), fn/nombre (FN), aliases (list -> NICKNAME CSV), org (ORG), tels/telefonos (list -> N lineas TEL;TYPE=CELL), emails/correos (list -> N lineas EMAIL;TYPE=INTERNET), adrs/direcciones (list -> N lineas ADR;TYPE=HOME con la direccion en el componente street), osint (dict con dni/pais/contexto/sexo/fecha_nacimiento -> lineas X-OSINT-*), note/notas (NOTE). Una lista que venga como string suelto se envuelve en [valor]."
|
||||||
|
output: "Texto VCARD 3.0 con lineas separadas por CRLF, empezando en BEGIN:VCARD / VERSION:3.0 y terminando en END:VCARD\\r\\n. Valores escapados segun RFC 6350; el ADR es un valor estructurado de 7 componentes cuyos separadores ';' NO se escapan."
|
||||||
|
uses_functions: []
|
||||||
|
uses_types: []
|
||||||
|
returns: []
|
||||||
|
returns_optional: false
|
||||||
|
error_type: ""
|
||||||
|
imports: []
|
||||||
|
tested: true
|
||||||
|
tests: ["test_multivalor_tels_emails_adr", "test_escape_en_fn", "test_campos_osint", "test_claves_ingles_y_espanol_equivalentes", "test_falta_uid_y_slug_lanza_valueerror"]
|
||||||
|
test_file_path: "python/functions/core/build_vcard_test.py"
|
||||||
|
file_path: "python/functions/core/build_vcard.py"
|
||||||
|
---
|
||||||
|
|
||||||
|
## Ejemplo
|
||||||
|
|
||||||
|
```python
|
||||||
|
from core.build_vcard import build_vcard
|
||||||
|
|
||||||
|
vcard = build_vcard({
|
||||||
|
"uid": "ada-lovelace",
|
||||||
|
"fn": "Ada Lovelace",
|
||||||
|
"org": "Analytical Engine Co.",
|
||||||
|
"tels": ["+34600111222", "+34600333444"], # 2 telefonos -> 2 lineas TEL
|
||||||
|
"emails": ["ada@example.com"],
|
||||||
|
"adrs": ["Calle Mayor 1, Madrid"],
|
||||||
|
"osint": {"dni": "12345678Z", "pais": "ES"},
|
||||||
|
"note": "Contacto de prueba",
|
||||||
|
})
|
||||||
|
print(vcard)
|
||||||
|
# BEGIN:VCARD
|
||||||
|
# VERSION:3.0
|
||||||
|
# UID:ada-lovelace
|
||||||
|
# FN:Ada Lovelace
|
||||||
|
# ORG:Analytical Engine Co.
|
||||||
|
# TEL;TYPE=CELL:+34600111222
|
||||||
|
# TEL;TYPE=CELL:+34600333444
|
||||||
|
# EMAIL;TYPE=INTERNET:ada@example.com
|
||||||
|
# ADR;TYPE=HOME:;;Calle Mayor 1\, Madrid;;;;
|
||||||
|
# X-OSINT-DNI:12345678Z
|
||||||
|
# X-OSINT-PAIS:ES
|
||||||
|
# NOTE:Contacto de prueba
|
||||||
|
# END:VCARD
|
||||||
|
```
|
||||||
|
|
||||||
|
## Cuando usarla
|
||||||
|
|
||||||
|
Cuando hay que materializar un contacto multi-valor (varios telefonos, emails o
|
||||||
|
direcciones) a vCard para subirlo a CardDAV. Es el paso "componer el texto vCard"
|
||||||
|
previo a `carddav_put_vcard_py_infra`. La reusan el service `osint_db` (push
|
||||||
|
DB -> Xandikos) y `osint_web`. Usa el UID como identificador del recurso
|
||||||
|
`<uid>.vcf`, asi re-subir el mismo UID sobrescribe (idempotente).
|
||||||
|
|
||||||
|
## Gotchas
|
||||||
|
|
||||||
|
- **Pura salvo `ValueError`**: es determinista y sin efectos (no red ni disco).
|
||||||
|
La unica excepcion posible es `ValueError` cuando faltan a la vez `uid` y
|
||||||
|
`slug` (no hay identificador) — validacion de entrada aceptable en una pura.
|
||||||
|
- **ADR estructurado de 7 campos**: el ADR del vCard es un valor estructurado
|
||||||
|
`po-box;extended;street;locality;region;postal-code;country`. La direccion se
|
||||||
|
coloca en el 3er componente (street) y el resto van vacios:
|
||||||
|
`ADR;TYPE=HOME:;;<street>;;;;`. Los `;` que separan los 7 componentes NO se
|
||||||
|
escapan; solo se escapa el contenido de cada componente (RFC 6350).
|
||||||
|
- **Claves ES/EN**: para cada lista/campo acepta el nombre espanol o el ingles
|
||||||
|
(`tels`/`telefonos`, `emails`/`correos`, `adrs`/`direcciones`, `note`/`notas`,
|
||||||
|
`fn`/`nombre`). Si vienen ambos, gana el primero presente segun el orden
|
||||||
|
documentado.
|
||||||
|
- **Lista como string suelto**: si una clave de lista llega como string en vez
|
||||||
|
de lista, se envuelve en `[valor]` y produce una sola linea.
|
||||||
@@ -0,0 +1,139 @@
|
|||||||
|
"""Serializa un contacto (dict) a un VCARD 3.0 con soporte multi-valor.
|
||||||
|
|
||||||
|
Generaliza el ``_build_vcard`` inline de ``osint_web/server/main.py`` (que solo
|
||||||
|
emitia un TEL y un EMAIL): aqui acepta listas de telefonos, emails y direcciones
|
||||||
|
y emite una linea por elemento. Es una funcion pura — solo compone texto, sin red
|
||||||
|
ni disco. La unica excepcion posible es ``ValueError`` por validacion de entrada
|
||||||
|
(falta de identificador), lo cual es aceptable para una funcion pura.
|
||||||
|
"""
|
||||||
|
|
||||||
|
# Orden + nombre de propiedad X-OSINT para cada clave del bloque osint.
|
||||||
|
_OSINT_FIELDS = (
|
||||||
|
("dni", "X-OSINT-DNI"),
|
||||||
|
("pais", "X-OSINT-PAIS"),
|
||||||
|
("contexto", "X-OSINT-CONTEXTO"),
|
||||||
|
("sexo", "X-OSINT-SEXO"),
|
||||||
|
("fecha_nacimiento", "X-OSINT-FECHA-NACIMIENTO"),
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def _vcard_escape(value: str) -> str:
|
||||||
|
"""Escapa un valor de texto para una linea vCard (RFC 6350).
|
||||||
|
|
||||||
|
Reglas: ``\\`` -> ``\\\\``, salto de linea -> ``\\n``, ``,`` -> ``\\,``,
|
||||||
|
``;`` -> ``\\;``. Se aplica al contenido de cada propiedad, NO a los
|
||||||
|
separadores estructurales del ADR.
|
||||||
|
|
||||||
|
El retorno de carro ``\\r`` crudo se ELIMINA (no se escapa): un ``\\r`` solo,
|
||||||
|
sin ``\\n`` que lo siga, sobrevive al escape de ``\\n`` y queda como carácter de
|
||||||
|
control dentro del valor. Varios parsers de vCard (y el propio ``_unfold_lines``
|
||||||
|
de osint_web, que normaliza ``\\r`` a ``\\n``) lo tratan como un separador de
|
||||||
|
línea, lo que permitiría inyectar propiedades nuevas (p. ej. ``X-OSINT-DNI``)
|
||||||
|
en la tarjeta. Eliminarlo cierra ese vector, en paridad con el escape iCal.
|
||||||
|
"""
|
||||||
|
return (
|
||||||
|
value.replace("\\", "\\\\")
|
||||||
|
.replace("\r", "")
|
||||||
|
.replace("\n", "\\n")
|
||||||
|
.replace(",", "\\,")
|
||||||
|
.replace(";", "\\;")
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def _as_list(value) -> list:
|
||||||
|
"""Normaliza un valor a lista: ``None`` -> ``[]``, string suelto -> ``[s]``.
|
||||||
|
|
||||||
|
Tolera que una clave que deberia ser lista venga como string suelto.
|
||||||
|
"""
|
||||||
|
if value is None:
|
||||||
|
return []
|
||||||
|
if isinstance(value, str):
|
||||||
|
return [value]
|
||||||
|
if isinstance(value, (list, tuple)):
|
||||||
|
return list(value)
|
||||||
|
return [value]
|
||||||
|
|
||||||
|
|
||||||
|
def _pick(contact: dict, *keys):
|
||||||
|
"""Devuelve el primer valor no vacio entre ``keys`` (acepta ES/EN)."""
|
||||||
|
for key in keys:
|
||||||
|
val = contact.get(key)
|
||||||
|
if val:
|
||||||
|
return val
|
||||||
|
return None
|
||||||
|
|
||||||
|
|
||||||
|
def build_vcard(contact: dict) -> str:
|
||||||
|
"""Serializa un contacto (dict) a un VCARD 3.0 con soporte multi-valor.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
contact: dict con claves opcionales (acepta nombre ES o EN):
|
||||||
|
- ``uid`` / ``slug``: identificador del vCard. Uno obligatorio.
|
||||||
|
- ``fn`` / ``nombre``: nombre completo (FN).
|
||||||
|
- ``aliases``: lista -> NICKNAME (CSV escapado).
|
||||||
|
- ``org``: organizacion -> ORG.
|
||||||
|
- ``tels`` / ``telefonos``: lista -> una linea TEL;TYPE=CELL por item.
|
||||||
|
- ``emails`` / ``correos``: lista -> una linea EMAIL;TYPE=INTERNET por item.
|
||||||
|
- ``adrs`` / ``direcciones``: lista -> una linea ADR;TYPE=HOME por item
|
||||||
|
(la direccion va en el componente street del ADR estructurado).
|
||||||
|
- ``osint``: dict con ``dni, pais, contexto, sexo, fecha_nacimiento``
|
||||||
|
-> lineas X-OSINT-* (solo las presentes/no vacias).
|
||||||
|
- ``note`` / ``notas``: texto -> NOTE.
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
Texto VCARD 3.0 con lineas separadas por CRLF, terminando en
|
||||||
|
``END:VCARD\\r\\n``.
|
||||||
|
|
||||||
|
Raises:
|
||||||
|
ValueError: si faltan ``uid`` y ``slug`` (no hay identificador).
|
||||||
|
"""
|
||||||
|
uid = contact.get("uid") or contact.get("slug")
|
||||||
|
if not uid:
|
||||||
|
raise ValueError("build_vcard: falta identificador (uid o slug)")
|
||||||
|
uid = str(uid).strip()
|
||||||
|
|
||||||
|
nombre = _pick(contact, "fn", "nombre")
|
||||||
|
nombre = str(nombre).strip() if nombre else uid
|
||||||
|
|
||||||
|
lines = [
|
||||||
|
"BEGIN:VCARD",
|
||||||
|
"VERSION:3.0",
|
||||||
|
"UID:%s" % _vcard_escape(uid),
|
||||||
|
"FN:%s" % _vcard_escape(nombre),
|
||||||
|
]
|
||||||
|
|
||||||
|
aliases = _as_list(contact.get("aliases"))
|
||||||
|
if aliases:
|
||||||
|
joined = ",".join(_vcard_escape(str(a)) for a in aliases)
|
||||||
|
lines.append("NICKNAME:%s" % joined)
|
||||||
|
|
||||||
|
org = contact.get("org")
|
||||||
|
if org:
|
||||||
|
lines.append("ORG:%s" % _vcard_escape(str(org)))
|
||||||
|
|
||||||
|
for tel in _as_list(_pick(contact, "tels", "telefonos")):
|
||||||
|
lines.append("TEL;TYPE=CELL:%s" % _vcard_escape(str(tel)))
|
||||||
|
|
||||||
|
for email in _as_list(_pick(contact, "emails", "correos")):
|
||||||
|
lines.append("EMAIL;TYPE=INTERNET:%s" % _vcard_escape(str(email)))
|
||||||
|
|
||||||
|
for adr in _as_list(_pick(contact, "adrs", "direcciones")):
|
||||||
|
# ADR estructurado: 7 componentes separados por ';' SIN escapar los
|
||||||
|
# separadores. La direccion va en el 3er componente (street); el resto
|
||||||
|
# vacios: po-box;extended;street;locality;region;postal-code;country.
|
||||||
|
street = _vcard_escape(str(adr))
|
||||||
|
lines.append("ADR;TYPE=HOME:;;%s;;;;" % street)
|
||||||
|
|
||||||
|
osint = contact.get("osint")
|
||||||
|
if isinstance(osint, dict):
|
||||||
|
for key, x_name in _OSINT_FIELDS:
|
||||||
|
val = osint.get(key)
|
||||||
|
if val:
|
||||||
|
lines.append("%s:%s" % (x_name, _vcard_escape(str(val))))
|
||||||
|
|
||||||
|
note = _pick(contact, "note", "notas")
|
||||||
|
if note:
|
||||||
|
lines.append("NOTE:%s" % _vcard_escape(str(note)))
|
||||||
|
|
||||||
|
lines.append("END:VCARD")
|
||||||
|
return "\r\n".join(lines) + "\r\n"
|
||||||
@@ -0,0 +1,98 @@
|
|||||||
|
"""Tests para build_vcard."""
|
||||||
|
|
||||||
|
import pytest
|
||||||
|
|
||||||
|
from build_vcard import build_vcard
|
||||||
|
|
||||||
|
|
||||||
|
def _count_lines(vcard: str, prefix: str) -> int:
|
||||||
|
return sum(1 for ln in vcard.split("\r\n") if ln.startswith(prefix))
|
||||||
|
|
||||||
|
|
||||||
|
def test_multivalor_tels_emails_adr():
|
||||||
|
vcard = build_vcard(
|
||||||
|
{
|
||||||
|
"uid": "ada-lovelace",
|
||||||
|
"fn": "Ada Lovelace",
|
||||||
|
"tels": ["+34600111222", "+34600333444"],
|
||||||
|
"emails": ["ada@example.com", "lovelace@example.org"],
|
||||||
|
"adrs": ["Calle Mayor 1, Madrid"],
|
||||||
|
}
|
||||||
|
)
|
||||||
|
assert _count_lines(vcard, "TEL") == 2
|
||||||
|
assert _count_lines(vcard, "EMAIL") == 2
|
||||||
|
assert _count_lines(vcard, "ADR") == 1
|
||||||
|
assert vcard.startswith("BEGIN:VCARD\r\nVERSION:3.0\r\n")
|
||||||
|
assert vcard.endswith("END:VCARD\r\n")
|
||||||
|
|
||||||
|
|
||||||
|
def test_escape_en_fn():
|
||||||
|
vcard = build_vcard({"uid": "x", "fn": "Doe, John; Jr"})
|
||||||
|
# ',' -> '\,' y ';' -> '\;' en el valor del FN.
|
||||||
|
assert "FN:Doe\\, John\\; Jr" in vcard.split("\r\n")
|
||||||
|
|
||||||
|
|
||||||
|
def test_campos_osint():
|
||||||
|
vcard = build_vcard(
|
||||||
|
{
|
||||||
|
"uid": "target-1",
|
||||||
|
"fn": "Target One",
|
||||||
|
"osint": {
|
||||||
|
"dni": "12345678Z",
|
||||||
|
"pais": "ES",
|
||||||
|
"contexto": "investigacion",
|
||||||
|
"sexo": "M",
|
||||||
|
"fecha_nacimiento": "1990-01-01",
|
||||||
|
"vacio": "",
|
||||||
|
},
|
||||||
|
}
|
||||||
|
)
|
||||||
|
lines = vcard.split("\r\n")
|
||||||
|
assert "X-OSINT-DNI:12345678Z" in lines
|
||||||
|
assert "X-OSINT-PAIS:ES" in lines
|
||||||
|
assert "X-OSINT-CONTEXTO:investigacion" in lines
|
||||||
|
assert "X-OSINT-SEXO:M" in lines
|
||||||
|
assert "X-OSINT-FECHA-NACIMIENTO:1990-01-01" in lines
|
||||||
|
# Una clave vacia o desconocida no emite linea.
|
||||||
|
assert _count_lines(vcard, "X-OSINT-") == 5
|
||||||
|
|
||||||
|
|
||||||
|
def test_claves_ingles_y_espanol_equivalentes():
|
||||||
|
ingles = build_vcard(
|
||||||
|
{"uid": "a", "fn": "A", "tels": ["+1"], "emails": ["a@b.c"]}
|
||||||
|
)
|
||||||
|
espanol = build_vcard(
|
||||||
|
{"uid": "a", "fn": "A", "telefonos": ["+1"], "correos": ["a@b.c"]}
|
||||||
|
)
|
||||||
|
assert ingles == espanol
|
||||||
|
|
||||||
|
|
||||||
|
def test_falta_uid_y_slug_lanza_valueerror():
|
||||||
|
with pytest.raises(ValueError):
|
||||||
|
build_vcard({"fn": "Sin identificador"})
|
||||||
|
|
||||||
|
|
||||||
|
def test_cr_crudo_no_inyecta_propiedades():
|
||||||
|
"""Un '\\r' crudo en un valor no debe poder inyectar una propiedad nueva.
|
||||||
|
|
||||||
|
Sin neutralizar el '\\r', un parser que normalice '\\r' a salto de línea (como
|
||||||
|
el _unfold_lines de osint_web) leería 'X-OSINT-DNI' / 'X-EVIL' como propiedades
|
||||||
|
legítimas, burlando el control de "no exponer X-OSINT-* al móvil". El escape
|
||||||
|
debe eliminar el '\\r' para que el valor quede en una sola línea física.
|
||||||
|
"""
|
||||||
|
vcard = build_vcard(
|
||||||
|
{
|
||||||
|
"uid": "victima",
|
||||||
|
"fn": "Bob\rX-OSINT-DNI:11111111H\rX-EVIL:pwned",
|
||||||
|
"tels": ["911\rNOTE:leak"],
|
||||||
|
}
|
||||||
|
)
|
||||||
|
# Simula el unfold de osint_web: '\r\n' y '\r' sueltos pasan a salto de línea.
|
||||||
|
physical_lines = vcard.replace("\r\n", "\n").replace("\r", "\n").split("\n")
|
||||||
|
inyectadas = [
|
||||||
|
ln for ln in physical_lines if ln.startswith(("X-OSINT-DNI", "X-EVIL", "NOTE"))
|
||||||
|
]
|
||||||
|
assert inyectadas == [], f"propiedades inyectadas via CR: {inyectadas}"
|
||||||
|
# El '\r' no debe sobrevivir en el texto serializado salvo como CRLF de línea.
|
||||||
|
assert "\rX-OSINT" not in vcard
|
||||||
|
assert "\rNOTE" not in vcard
|
||||||
@@ -0,0 +1,76 @@
|
|||||||
|
---
|
||||||
|
name: contact_import_key
|
||||||
|
kind: function
|
||||||
|
lang: py
|
||||||
|
domain: core
|
||||||
|
version: "1.0.0"
|
||||||
|
purity: pure
|
||||||
|
signature: "def contact_import_key(name: str = \"\", phones: list = None, emails: list = None) -> str"
|
||||||
|
description: "Genera una clave de importacion determinista de contactos para imports idempotentes. La identidad prioriza el telefono normalizado (mas estable entre exports de Google), luego el email, y por ultimo el nombre normalizado sin acentos. Re-importar el mismo .vcf matchea la fila existente sin depender de UIDs opacos. Pura, sin I/O."
|
||||||
|
tags: [contactos, import, hash, dav, dedup, core]
|
||||||
|
params:
|
||||||
|
- name: name
|
||||||
|
desc: "Nombre del contacto (FN). Solo se usa como ultimo recurso cuando no hay telefono ni email. Se normaliza con NFKD (quita acentos), lowercase, espacios colapsados, y se filtra a [a-z0-9 ]."
|
||||||
|
- name: phones
|
||||||
|
desc: "Lista de telefonos en cualquier formato (con prefijos, espacios, guiones). Para cada uno se extraen solo los digitos (re.sub r'\\D') y se toman los ultimos 9 si len>=9 (numero nacional estable), o todos los digitos si son menos. Vacios descartados, dedup + sort ascendente. None equivale a lista vacia."
|
||||||
|
- name: emails
|
||||||
|
desc: "Lista de emails. Cada uno se pasa a strip+lowercase; se descartan los vacios y los que no contienen '@'. Dedup + sort. None equivale a lista vacia."
|
||||||
|
output: "Clave determinista con formato 'v1-' + los primeros 16 caracteres hex del SHA-1 de la identity string. Longitud total fija de 19 caracteres. La identity string es 'tel:<phones>' si hay telefonos, 'email:<emails>' si no hay telefonos pero si emails, o 'name:<name_norm>' en otro caso."
|
||||||
|
uses_functions: []
|
||||||
|
uses_types: []
|
||||||
|
returns: []
|
||||||
|
returns_optional: false
|
||||||
|
error_type: ""
|
||||||
|
imports: []
|
||||||
|
tested: true
|
||||||
|
tests: ["test_determinismo_misma_entrada_misma_clave", "test_estabilidad_ante_formato_y_nombre_distinto", "test_fallback_a_email_cuando_no_hay_telefono", "test_fallback_a_nombre_insensible_a_acentos_y_mayusculas", "test_prefijo_v1_y_longitud"]
|
||||||
|
test_file_path: "python/functions/core/contact_import_key_test.py"
|
||||||
|
file_path: "python/functions/core/contact_import_key.py"
|
||||||
|
---
|
||||||
|
|
||||||
|
## Ejemplo
|
||||||
|
|
||||||
|
```python
|
||||||
|
from core.contact_import_key import contact_import_key
|
||||||
|
|
||||||
|
# El telefono manda y es robusto al formato: estos dos producen la MISMA clave.
|
||||||
|
k1 = contact_import_key("Bob", ["+34 600 11 22 33"])
|
||||||
|
k2 = contact_import_key("BOB DISTINTO", ["600112233"])
|
||||||
|
print(k1, k1 == k2)
|
||||||
|
# v1-8d3f... True (mismo telefono normalizado -> misma clave)
|
||||||
|
|
||||||
|
# Sin telefono, cae a email; sin email, cae al nombre normalizado.
|
||||||
|
print(contact_import_key("Carol", [], ["CAROL@Example.com"])) # v1-... (identity = email:carol@example.com)
|
||||||
|
print(contact_import_key("José Pérez")) # == contact_import_key("jose perez")
|
||||||
|
```
|
||||||
|
|
||||||
|
## Cuando usarla
|
||||||
|
|
||||||
|
Usala en el nucleo de un import idempotente de contactos: antes de insertar una
|
||||||
|
fila de un `.vcf` de Google, calcula la clave y busca esa clave en la tabla
|
||||||
|
destino. Si existe, actualizas la fila; si no, la insertas. Asi re-importar el
|
||||||
|
mismo export no duplica contactos y no depende de UIDs opacos (que Google rota)
|
||||||
|
ni del nombre (que el pipeline de import transforma: quita sufijos de lugar,
|
||||||
|
reordena). Es la primitiva de matching del sistema de import idempotente del
|
||||||
|
grupo `dav`.
|
||||||
|
|
||||||
|
## Gotchas
|
||||||
|
|
||||||
|
- **Pura y determinista**: misma entrada -> misma salida, sin red ni disco. No
|
||||||
|
lanza excepciones (entradas `None`/vacias se tratan como lista/cadena vacia).
|
||||||
|
- **Prioridad telefono > email > nombre por estabilidad**: el telefono
|
||||||
|
normalizado es lo mas estable entre exports de Google, por eso es la identidad
|
||||||
|
primaria; el email es fallback; el nombre es el ultimo recurso porque el
|
||||||
|
pipeline de import lo transforma (quita sufijos de lugar, reordena) y no es
|
||||||
|
fiable como identidad.
|
||||||
|
- **Cambiar los telefonos cambia la clave**: si los telefonos de un contacto
|
||||||
|
varian entre dos importaciones, la clave cambia y se trata como contacto
|
||||||
|
distinto. Es una limitacion aceptada: la idempotencia es robusta solo para
|
||||||
|
contactos cuyos telefonos no varian. Lo mismo aplica al fallback de email y al
|
||||||
|
de nombre.
|
||||||
|
- **Ultimos 9 digitos**: solo se conservan los ultimos 9 digitos del telefono
|
||||||
|
para ignorar prefijos de pais inconsistentes (`+34`, `0034`, sin prefijo
|
||||||
|
producen la misma clave). Numeros con menos de 9 digitos se usan completos.
|
||||||
|
- **Prefijo `v1-`**: versiona el algoritmo. Si en el futuro cambia la
|
||||||
|
normalizacion o el esquema de identidad, se sube a `v2-` y las claves nuevas no
|
||||||
|
colisionan con las viejas; el consumidor puede migrar de forma controlada.
|
||||||
@@ -0,0 +1,77 @@
|
|||||||
|
"""Clave de importacion determinista de contactos para imports idempotentes.
|
||||||
|
|
||||||
|
Genera una clave estable a partir de los datos de un contacto (telefono, email,
|
||||||
|
nombre) para que re-importar el mismo .vcf de Google matchee la fila existente
|
||||||
|
sin depender de UIDs opacos ni de nombres que el pipeline de import transforma.
|
||||||
|
"""
|
||||||
|
|
||||||
|
import hashlib
|
||||||
|
import re
|
||||||
|
import unicodedata
|
||||||
|
|
||||||
|
|
||||||
|
def contact_import_key(
|
||||||
|
name: str = "",
|
||||||
|
phones: list = None,
|
||||||
|
emails: list = None,
|
||||||
|
) -> str:
|
||||||
|
"""Calcula una clave de importacion determinista para un contacto.
|
||||||
|
|
||||||
|
La identidad se construye priorizando lo mas estable entre exports de
|
||||||
|
Google: telefono normalizado > email normalizado > nombre normalizado.
|
||||||
|
La funcion es pura: dada la misma entrada devuelve siempre la misma clave,
|
||||||
|
sin I/O ni estado mutable.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
name: nombre del contacto (FN). Solo se usa como ultimo recurso.
|
||||||
|
phones: lista de telefonos en cualquier formato. Cada uno se reduce a
|
||||||
|
sus digitos y se queda con los ultimos 9 (numero nacional estable).
|
||||||
|
emails: lista de emails. Se pasan a minusculas y se filtran los que no
|
||||||
|
contienen "@".
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
Clave determinista con el formato "v1-" + 16 hex chars del SHA-1 de la
|
||||||
|
identity string. Longitud total fija de 19 caracteres.
|
||||||
|
"""
|
||||||
|
phones = phones or []
|
||||||
|
emails = emails or []
|
||||||
|
|
||||||
|
# 1. Normalizar phones: solo digitos, ultimos 9 si len>=9, dedup + sort.
|
||||||
|
phones_norm = set()
|
||||||
|
for p in phones:
|
||||||
|
digits = re.sub(r"\D", "", str(p))
|
||||||
|
if not digits:
|
||||||
|
continue
|
||||||
|
if len(digits) >= 9:
|
||||||
|
digits = digits[-9:]
|
||||||
|
phones_norm.add(digits)
|
||||||
|
phones_sorted = sorted(phones_norm)
|
||||||
|
|
||||||
|
# 2. Normalizar emails: lowercase + strip, descartar vacios y sin "@".
|
||||||
|
emails_norm = set()
|
||||||
|
for e in emails:
|
||||||
|
e = str(e).strip().lower()
|
||||||
|
if not e or "@" not in e:
|
||||||
|
continue
|
||||||
|
emails_norm.add(e)
|
||||||
|
emails_sorted = sorted(emails_norm)
|
||||||
|
|
||||||
|
# 3. Normalizar name: NFKD sin acentos, lowercase, colapsar espacios,
|
||||||
|
# quedarse solo con [a-z0-9 ].
|
||||||
|
decomposed = unicodedata.normalize("NFKD", name or "")
|
||||||
|
without_marks = "".join(c for c in decomposed if not unicodedata.combining(c))
|
||||||
|
lowered = without_marks.lower()
|
||||||
|
collapsed = re.sub(r"\s+", " ", lowered).strip()
|
||||||
|
name_norm = re.sub(r"[^a-z0-9 ]", "", collapsed)
|
||||||
|
|
||||||
|
# 4. Identity string priorizando lo mas estable.
|
||||||
|
if phones_sorted:
|
||||||
|
identity = "tel:" + ",".join(phones_sorted)
|
||||||
|
elif emails_sorted:
|
||||||
|
identity = "email:" + ",".join(emails_sorted)
|
||||||
|
else:
|
||||||
|
identity = "name:" + name_norm
|
||||||
|
|
||||||
|
# 5. Clave versionada.
|
||||||
|
digest = hashlib.sha1(identity.encode("utf-8")).hexdigest()[:16]
|
||||||
|
return "v1-" + digest
|
||||||
@@ -0,0 +1,38 @@
|
|||||||
|
"""Tests para contact_import_key."""
|
||||||
|
|
||||||
|
from contact_import_key import contact_import_key
|
||||||
|
|
||||||
|
|
||||||
|
def test_determinismo_misma_entrada_misma_clave():
|
||||||
|
a = contact_import_key("Ada Lovelace", ["+34600112233"], ["ada@example.com"])
|
||||||
|
b = contact_import_key("Ada Lovelace", ["+34600112233"], ["ada@example.com"])
|
||||||
|
assert a == b
|
||||||
|
|
||||||
|
|
||||||
|
def test_estabilidad_ante_formato_y_nombre_distinto():
|
||||||
|
# Mismo telefono normalizado (ultimos 9 digitos), distinto formato y nombre.
|
||||||
|
# El telefono manda -> misma clave.
|
||||||
|
con_formato = contact_import_key("Bob", ["+34 600 11 22 33"])
|
||||||
|
sin_formato = contact_import_key("BOB DISTINTO", ["600112233"])
|
||||||
|
assert con_formato == sin_formato
|
||||||
|
|
||||||
|
|
||||||
|
def test_fallback_a_email_cuando_no_hay_telefono():
|
||||||
|
solo_email = contact_import_key("Carol", [], ["CAROL@Example.com"])
|
||||||
|
mismo_email_otro_nombre = contact_import_key("nombre cambiado", [], ["carol@example.com"])
|
||||||
|
assert solo_email == mismo_email_otro_nombre
|
||||||
|
# Y distinto de la clave por nombre puro (prefijo de identity distinto).
|
||||||
|
por_nombre = contact_import_key("Carol")
|
||||||
|
assert solo_email != por_nombre
|
||||||
|
|
||||||
|
|
||||||
|
def test_fallback_a_nombre_insensible_a_acentos_y_mayusculas():
|
||||||
|
con_acentos = contact_import_key("José Pérez")
|
||||||
|
sin_acentos = contact_import_key("jose perez")
|
||||||
|
assert con_acentos == sin_acentos
|
||||||
|
|
||||||
|
|
||||||
|
def test_prefijo_v1_y_longitud():
|
||||||
|
k = contact_import_key("Dave", ["600999888"])
|
||||||
|
assert k.startswith("v1-")
|
||||||
|
assert len(k) == 19 # 3 ("v1-") + 16 hex chars
|
||||||
@@ -0,0 +1,52 @@
|
|||||||
|
---
|
||||||
|
name: render_markdown_table
|
||||||
|
kind: function
|
||||||
|
lang: py
|
||||||
|
domain: core
|
||||||
|
version: "1.0.0"
|
||||||
|
purity: pure
|
||||||
|
signature: "def render_markdown_table(rows: list, columns: list = None, max_rows: int = 0) -> str"
|
||||||
|
description: "Renderiza una lista de diccionarios como tabla Markdown GitHub-flavored. Resuelve columnas desde las claves del primer dict o desde un orden explicito, normaliza None/bool, escapa pipes, convierte saltos de linea a <br> y permite truncar filas."
|
||||||
|
tags: [markdown, table, render, obsidian]
|
||||||
|
uses_functions: []
|
||||||
|
uses_types: []
|
||||||
|
returns: []
|
||||||
|
returns_optional: false
|
||||||
|
error_type: ""
|
||||||
|
imports: []
|
||||||
|
params:
|
||||||
|
- name: rows
|
||||||
|
desc: "lista de diccionarios, uno por fila; cada dict mapea nombre de columna a valor de celda"
|
||||||
|
- name: columns
|
||||||
|
desc: "orden explicito de columnas; si None usa las claves del primer dict preservando orden de insercion; con rows vacio y None devuelve string vacio"
|
||||||
|
- name: max_rows
|
||||||
|
desc: "numero maximo de filas a renderizar; 0 = todas; si N>0 y hay mas de N filas trunca a N y anade una linea final '_... N de M filas_' fuera de la tabla"
|
||||||
|
output: "string con la tabla Markdown (header + separador |---| + filas); string vacio cuando no hay filas ni columnas explicitas"
|
||||||
|
tested: true
|
||||||
|
tests: ["caso normal", "rows vacio", "rows vacio con columns", "columns explicitas ordenan", "escape de pipes", "none bool y saltos de linea", "max rows truncado", "max rows sin truncar"]
|
||||||
|
test_file_path: "python/functions/core/render_markdown_table_test.py"
|
||||||
|
file_path: "python/functions/core/render_markdown_table.py"
|
||||||
|
---
|
||||||
|
|
||||||
|
## Ejemplo
|
||||||
|
|
||||||
|
```python
|
||||||
|
rows = [
|
||||||
|
{"nombre": "Alice", "edad": 30, "activo": True},
|
||||||
|
{"nombre": "Bob", "edad": 25, "activo": False},
|
||||||
|
]
|
||||||
|
md = render_markdown_table(rows)
|
||||||
|
# | nombre | edad | activo |
|
||||||
|
# |---|---|---|
|
||||||
|
# | Alice | 30 | true |
|
||||||
|
# | Bob | 25 | false |
|
||||||
|
|
||||||
|
# Con columnas explicitas y truncado:
|
||||||
|
muchas = [{"n": i} for i in range(100)]
|
||||||
|
md2 = render_markdown_table(muchas, columns=["n"], max_rows=10)
|
||||||
|
# Renderiza solo 10 filas y termina con: _... 10 de 100 filas_
|
||||||
|
```
|
||||||
|
|
||||||
|
## Cuando usarla
|
||||||
|
|
||||||
|
Cuando tengas una lista de registros (`list[dict]`) y quieras volcarla a una tabla Markdown lista para Obsidian, un README o un comentario de PR. Util para resumir consultas o datasets en notas: pasa `columns` para fijar el orden y `max_rows` para no saturar la nota con cientos de filas.
|
||||||
@@ -0,0 +1,67 @@
|
|||||||
|
"""Render a list of dict rows as a GitHub-flavored Markdown table."""
|
||||||
|
|
||||||
|
|
||||||
|
def render_markdown_table(rows: list, columns: list = None, max_rows: int = 0) -> str:
|
||||||
|
"""Render a list of dict rows as a GitHub-flavored Markdown table.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
rows: List of dictionaries, one per row. Each dictionary maps a column
|
||||||
|
name to its cell value.
|
||||||
|
columns: Explicit column order. When None, the keys of the first row are
|
||||||
|
used preserving insertion order. When rows is empty and columns is
|
||||||
|
None, the result is an empty string.
|
||||||
|
max_rows: Maximum number of rows to render. 0 means all rows. When N > 0
|
||||||
|
and the number of rows exceeds N, the table is truncated to N rows
|
||||||
|
and a trailing line `\n_... N de M filas_` is appended after the
|
||||||
|
table indicating how many of the total rows are shown.
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
str: The Markdown table as a string. Returns an empty string when there
|
||||||
|
are no rows and no explicit columns.
|
||||||
|
"""
|
||||||
|
|
||||||
|
def render_cell(value) -> str:
|
||||||
|
"""Convert a cell value to its Markdown-safe string representation."""
|
||||||
|
if value is None:
|
||||||
|
text = ""
|
||||||
|
elif isinstance(value, bool):
|
||||||
|
text = "true" if value else "false"
|
||||||
|
else:
|
||||||
|
text = str(value)
|
||||||
|
# Escape pipe characters so they do not break the table layout and turn
|
||||||
|
# newlines into <br> tags to keep each row on a single Markdown line.
|
||||||
|
text = text.replace("|", "\\|")
|
||||||
|
text = text.replace("\r\n", "\n").replace("\r", "\n").replace("\n", "<br>")
|
||||||
|
return text
|
||||||
|
|
||||||
|
# Resolve the column order.
|
||||||
|
if columns is None:
|
||||||
|
if not rows:
|
||||||
|
return ""
|
||||||
|
cols = list(rows[0].keys())
|
||||||
|
else:
|
||||||
|
cols = list(columns)
|
||||||
|
|
||||||
|
# Decide how many rows to render and whether truncation applies.
|
||||||
|
total = len(rows)
|
||||||
|
if max_rows > 0 and total > max_rows:
|
||||||
|
visible = rows[:max_rows]
|
||||||
|
truncated = True
|
||||||
|
else:
|
||||||
|
visible = rows
|
||||||
|
truncated = False
|
||||||
|
|
||||||
|
header = "| " + " | ".join(render_cell(col) for col in cols) + " |"
|
||||||
|
separator = "|" + "---|" * len(cols)
|
||||||
|
|
||||||
|
lines = [header, separator]
|
||||||
|
for row in visible:
|
||||||
|
cells = [render_cell(row.get(col)) for col in cols]
|
||||||
|
lines.append("| " + " | ".join(cells) + " |")
|
||||||
|
|
||||||
|
table = "\n".join(lines)
|
||||||
|
|
||||||
|
if truncated:
|
||||||
|
table += f"\n_... {max_rows} de {total} filas_"
|
||||||
|
|
||||||
|
return table
|
||||||
@@ -0,0 +1,79 @@
|
|||||||
|
"""Tests para render_markdown_table."""
|
||||||
|
|
||||||
|
import sys
|
||||||
|
import os
|
||||||
|
|
||||||
|
sys.path.insert(0, os.path.dirname(__file__))
|
||||||
|
from render_markdown_table import render_markdown_table
|
||||||
|
|
||||||
|
|
||||||
|
def test_caso_normal():
|
||||||
|
rows = [
|
||||||
|
{"nombre": "Alice", "edad": 30},
|
||||||
|
{"nombre": "Bob", "edad": 25},
|
||||||
|
]
|
||||||
|
result = render_markdown_table(rows)
|
||||||
|
lines = result.split("\n")
|
||||||
|
assert lines[0] == "| nombre | edad |"
|
||||||
|
assert lines[1] == "|---|---|"
|
||||||
|
assert lines[2] == "| Alice | 30 |"
|
||||||
|
assert lines[3] == "| Bob | 25 |"
|
||||||
|
assert len(lines) == 4
|
||||||
|
|
||||||
|
|
||||||
|
def test_rows_vacio():
|
||||||
|
assert render_markdown_table([]) == ""
|
||||||
|
|
||||||
|
|
||||||
|
def test_rows_vacio_con_columns():
|
||||||
|
result = render_markdown_table([], columns=["a", "b"])
|
||||||
|
assert result == "| a | b |\n|---|---|"
|
||||||
|
|
||||||
|
|
||||||
|
def test_columns_explicitas_ordenan():
|
||||||
|
rows = [{"b": 2, "a": 1}]
|
||||||
|
result = render_markdown_table(rows, columns=["a", "b"])
|
||||||
|
lines = result.split("\n")
|
||||||
|
assert lines[0] == "| a | b |"
|
||||||
|
assert lines[2] == "| 1 | 2 |"
|
||||||
|
|
||||||
|
|
||||||
|
def test_escape_de_pipes():
|
||||||
|
rows = [{"col": "x|y|z"}]
|
||||||
|
result = render_markdown_table(rows)
|
||||||
|
assert "x\\|y\\|z" in result
|
||||||
|
|
||||||
|
|
||||||
|
def test_none_bool_y_saltos_de_linea():
|
||||||
|
rows = [{"a": None, "b": True, "c": False, "d": "line1\nline2"}]
|
||||||
|
result = render_markdown_table(rows)
|
||||||
|
data_line = result.split("\n")[2]
|
||||||
|
assert data_line == "| | true | false | line1<br>line2 |"
|
||||||
|
|
||||||
|
|
||||||
|
def test_max_rows_truncado():
|
||||||
|
rows = [{"n": i} for i in range(5)]
|
||||||
|
result = render_markdown_table(rows, max_rows=2)
|
||||||
|
lines = result.split("\n")
|
||||||
|
# header + separator + 2 data rows + truncation note
|
||||||
|
assert len(lines) == 5
|
||||||
|
assert lines[-1] == "_... 2 de 5 filas_"
|
||||||
|
|
||||||
|
|
||||||
|
def test_max_rows_sin_truncar():
|
||||||
|
rows = [{"n": 1}, {"n": 2}]
|
||||||
|
result = render_markdown_table(rows, max_rows=5)
|
||||||
|
assert "filas_" not in result
|
||||||
|
assert len(result.split("\n")) == 4
|
||||||
|
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
test_caso_normal()
|
||||||
|
test_rows_vacio()
|
||||||
|
test_rows_vacio_con_columns()
|
||||||
|
test_columns_explicitas_ordenan()
|
||||||
|
test_escape_de_pipes()
|
||||||
|
test_none_bool_y_saltos_de_linea()
|
||||||
|
test_max_rows_truncado()
|
||||||
|
test_max_rows_sin_truncar()
|
||||||
|
print("All tests passed.")
|
||||||
@@ -0,0 +1,74 @@
|
|||||||
|
---
|
||||||
|
name: upsert_sentinel_block
|
||||||
|
kind: function
|
||||||
|
lang: py
|
||||||
|
domain: core
|
||||||
|
version: "1.0.0"
|
||||||
|
purity: pure
|
||||||
|
signature: "def upsert_sentinel_block(text: str, block_id: str, content: str, marker: str = \"osintdb\") -> str"
|
||||||
|
description: "Inserta o reemplaza de forma idempotente un bloque gestionado delimitado por sentinels HTML-comment (<!-- {marker}:begin id={block_id} --> ... <!-- {marker}:end id={block_id} -->) dentro de un texto. Util para mantener secciones auto-generadas en notas Markdown de Obsidian sin pisar el resto del cuerpo."
|
||||||
|
tags: [markdown, sentinel, managed-block, obsidian]
|
||||||
|
uses_functions: []
|
||||||
|
uses_types: []
|
||||||
|
returns: []
|
||||||
|
returns_optional: false
|
||||||
|
error_type: ""
|
||||||
|
imports: [re]
|
||||||
|
params:
|
||||||
|
- name: text
|
||||||
|
desc: "Texto sobre el que operar (tipicamente el body de una nota Markdown de Obsidian). Puede contener otros bloques gestionados con ids distintos, que no se tocan."
|
||||||
|
- name: block_id
|
||||||
|
desc: "Identificador unico del bloque dentro del texto. Se usa literal en la regex (se escapa con re.escape), asi que puede contener caracteres especiales."
|
||||||
|
- name: content
|
||||||
|
desc: "Contenido a colocar entre los sentinels. NO debe contener los propios sentinels o se corromperia el bloque."
|
||||||
|
- name: marker
|
||||||
|
desc: "Prefijo del namespace de los comentarios sentinel. Default 'osintdb'. Se usa literal en la regex (se escapa con re.escape)."
|
||||||
|
output: "El texto resultante con el bloque insertado al final (si no existia) o con su contenido reemplazado entre sentinels (si ya existia). Los sentinels siempre se conservan."
|
||||||
|
tested: true
|
||||||
|
tests:
|
||||||
|
- "insercion nueva anade bloque al final"
|
||||||
|
- "reemplazo existente sustituye solo el contenido"
|
||||||
|
- "idempotencia dos aplicaciones mismo resultado"
|
||||||
|
- "bloque corrupto solo inicio lanza valueerror"
|
||||||
|
- "bloque corrupto solo fin lanza valueerror"
|
||||||
|
- "multiples bloques ids distintos en mismo texto"
|
||||||
|
- "marker personalizado"
|
||||||
|
test_file_path: "python/functions/core/upsert_sentinel_block_test.py"
|
||||||
|
file_path: "python/functions/core/upsert_sentinel_block.py"
|
||||||
|
---
|
||||||
|
|
||||||
|
## Ejemplo
|
||||||
|
|
||||||
|
```python
|
||||||
|
from core.upsert_sentinel_block import upsert_sentinel_block
|
||||||
|
|
||||||
|
nota = "# Dossier OSINT\n\nNotas manuales del investigador."
|
||||||
|
|
||||||
|
# Primera vez: inserta el bloque al final.
|
||||||
|
nota = upsert_sentinel_block(nota, "persons", "- Alice\n- Bob")
|
||||||
|
|
||||||
|
# Segunda vez con nuevo contenido: reemplaza solo lo que hay entre sentinels.
|
||||||
|
nota = upsert_sentinel_block(nota, "persons", "- Alice\n- Bob\n- Carol")
|
||||||
|
|
||||||
|
print(nota)
|
||||||
|
# # Dossier OSINT
|
||||||
|
#
|
||||||
|
# Notas manuales del investigador.
|
||||||
|
#
|
||||||
|
# <!-- osintdb:begin id=persons -->
|
||||||
|
# - Alice
|
||||||
|
# - Bob
|
||||||
|
# - Carol
|
||||||
|
# <!-- osintdb:end id=persons -->
|
||||||
|
```
|
||||||
|
|
||||||
|
## Cuando usarla
|
||||||
|
|
||||||
|
Cuando necesites mantener una seccion auto-generada dentro de un documento de texto (tipicamente una nota Markdown de Obsidian) sin sobreescribir lo que el usuario haya escrito a mano. Llamala cada vez que regeneres los datos: el primer uso inserta el bloque y los siguientes solo refrescan su contenido, dejando intacto el resto de la nota y otros bloques gestionados con ids distintos.
|
||||||
|
|
||||||
|
## Gotchas
|
||||||
|
|
||||||
|
- Si en `text` aparece solo uno de los dos sentinels del `block_id` (bloque corrupto, por ejemplo si alguien borro el sentinel de fin a mano) la funcion lanza `ValueError` con un mensaje que indica cuantos sentinels de inicio y de fin se encontraron. Lo mismo si aparece mas de un par de sentinels para el mismo `block_id` (bloque duplicado).
|
||||||
|
- `content` NO debe contener los propios sentinels (`<!-- {marker}:begin id={block_id} -->` / `<!-- {marker}:end id={block_id} -->`). Si los contiene, el siguiente upsert vera sentinels de sobra y fallara o reemplazara de forma incorrecta.
|
||||||
|
- Es funcion pura: no escribe en disco. Lee/devuelve strings; la persistencia de la nota (lectura del fichero, escritura del resultado) corre por cuenta del llamante.
|
||||||
|
- `block_id` y `marker` se escapan con `re.escape`, por lo que es seguro usar valores con caracteres especiales de regex. Aun asi, conviene mantenerlos en formato simple (alfanumerico, guiones) para que los comentarios HTML sean legibles en la nota.
|
||||||
@@ -0,0 +1,74 @@
|
|||||||
|
"""Gestion idempotente de bloques delimitados por sentinels HTML-comment dentro de un texto."""
|
||||||
|
|
||||||
|
import re
|
||||||
|
|
||||||
|
|
||||||
|
def upsert_sentinel_block(
|
||||||
|
text: str, block_id: str, content: str, marker: str = "osintdb"
|
||||||
|
) -> str:
|
||||||
|
"""Inserta o reemplaza un bloque gestionado delimitado por sentinels HTML-comment.
|
||||||
|
|
||||||
|
Un bloque gestionado queda envuelto entre dos comentarios HTML que actuan como
|
||||||
|
sentinels:
|
||||||
|
|
||||||
|
<!-- {marker}:begin id={block_id} -->
|
||||||
|
...contenido gestionado...
|
||||||
|
<!-- {marker}:end id={block_id} -->
|
||||||
|
|
||||||
|
Si el bloque (ambos sentinels con ese block_id) ya existe en el texto, su contenido
|
||||||
|
se reemplaza por el nuevo manteniendo los sentinels. Si no existe, el bloque se anade
|
||||||
|
al final del texto. La operacion es idempotente: aplicarla dos veces con el mismo
|
||||||
|
content produce el mismo resultado.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
text: Texto sobre el que operar (tipicamente el body de una nota Markdown de
|
||||||
|
Obsidian). Puede contener otros bloques gestionados con ids distintos.
|
||||||
|
block_id: Identificador unico del bloque dentro del texto. Se usa literal en
|
||||||
|
la expresion regular, asi que se escapa con re.escape.
|
||||||
|
content: Contenido a colocar entre los sentinels. No debe contener los propios
|
||||||
|
sentinels.
|
||||||
|
marker: Prefijo del namespace de los comentarios sentinel. Default "osintdb".
|
||||||
|
Se usa literal en la expresion regular, asi que se escapa con re.escape.
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
El texto resultante con el bloque insertado o reemplazado.
|
||||||
|
|
||||||
|
Raises:
|
||||||
|
ValueError: Si aparece solo uno de los dos sentinels del block_id (bloque
|
||||||
|
corrupto) o si aparecen sentinels duplicados.
|
||||||
|
"""
|
||||||
|
begin = f"<!-- {marker}:begin id={block_id} -->"
|
||||||
|
end = f"<!-- {marker}:end id={block_id} -->"
|
||||||
|
|
||||||
|
begin_re = re.escape(begin)
|
||||||
|
end_re = re.escape(end)
|
||||||
|
|
||||||
|
n_begin = len(re.findall(begin_re, text))
|
||||||
|
n_end = len(re.findall(end_re, text))
|
||||||
|
|
||||||
|
if n_begin != n_end:
|
||||||
|
raise ValueError(
|
||||||
|
f"Bloque corrupto para block_id={block_id!r} con marker={marker!r}: "
|
||||||
|
f"se encontraron {n_begin} sentinel(s) de inicio y {n_end} de fin "
|
||||||
|
f"(deben coincidir)."
|
||||||
|
)
|
||||||
|
|
||||||
|
if n_begin > 1:
|
||||||
|
raise ValueError(
|
||||||
|
f"Bloque duplicado para block_id={block_id!r} con marker={marker!r}: "
|
||||||
|
f"se encontraron {n_begin} pares de sentinels (debe haber como maximo uno)."
|
||||||
|
)
|
||||||
|
|
||||||
|
if n_begin == 1:
|
||||||
|
# El bloque existe: reemplazar todo lo que hay entre los sentinels.
|
||||||
|
pattern = re.compile(begin_re + r".*?" + end_re, re.DOTALL)
|
||||||
|
replacement = f"{begin}\n{content}\n{end}"
|
||||||
|
return pattern.sub(lambda _: replacement, text, count=1)
|
||||||
|
|
||||||
|
# El bloque no existe: anadirlo al final.
|
||||||
|
block = f"{begin}\n{content}\n{end}\n"
|
||||||
|
if text == "":
|
||||||
|
return block
|
||||||
|
if text.endswith("\n"):
|
||||||
|
return f"{text}\n{block}"
|
||||||
|
return f"{text}\n\n{block}"
|
||||||
@@ -0,0 +1,113 @@
|
|||||||
|
"""Tests para upsert_sentinel_block."""
|
||||||
|
|
||||||
|
import sys
|
||||||
|
import os
|
||||||
|
|
||||||
|
sys.path.insert(0, os.path.dirname(__file__))
|
||||||
|
|
||||||
|
from upsert_sentinel_block import upsert_sentinel_block
|
||||||
|
|
||||||
|
|
||||||
|
def test_insercion_nueva_anade_bloque_al_final():
|
||||||
|
text = "# Nota\n\nTexto previo del usuario."
|
||||||
|
result = upsert_sentinel_block(text, "persons", "- Alice\n- Bob")
|
||||||
|
|
||||||
|
assert "<!-- osintdb:begin id=persons -->" in result
|
||||||
|
assert "<!-- osintdb:end id=persons -->" in result
|
||||||
|
assert "- Alice\n- Bob" in result
|
||||||
|
# El texto previo se conserva intacto al inicio.
|
||||||
|
assert result.startswith("# Nota\n\nTexto previo del usuario.")
|
||||||
|
# El bloque va al final.
|
||||||
|
assert result.index("Texto previo") < result.index("osintdb:begin")
|
||||||
|
|
||||||
|
|
||||||
|
def test_reemplazo_existente_sustituye_solo_el_contenido():
|
||||||
|
text = (
|
||||||
|
"Intro\n\n"
|
||||||
|
"<!-- osintdb:begin id=persons -->\n"
|
||||||
|
"contenido viejo\n"
|
||||||
|
"<!-- osintdb:end id=persons -->\n"
|
||||||
|
)
|
||||||
|
result = upsert_sentinel_block(text, "persons", "contenido nuevo")
|
||||||
|
|
||||||
|
assert "contenido viejo" not in result
|
||||||
|
assert "contenido nuevo" in result
|
||||||
|
# Los sentinels siguen presentes una sola vez.
|
||||||
|
assert result.count("<!-- osintdb:begin id=persons -->") == 1
|
||||||
|
assert result.count("<!-- osintdb:end id=persons -->") == 1
|
||||||
|
# La intro se conserva.
|
||||||
|
assert result.startswith("Intro\n\n")
|
||||||
|
|
||||||
|
|
||||||
|
def test_idempotencia_dos_aplicaciones_mismo_resultado():
|
||||||
|
text = "# Nota\n\nCuerpo."
|
||||||
|
once = upsert_sentinel_block(text, "persons", "- Alice\n- Bob")
|
||||||
|
twice = upsert_sentinel_block(once, "persons", "- Alice\n- Bob")
|
||||||
|
|
||||||
|
assert once == twice
|
||||||
|
assert twice.count("<!-- osintdb:begin id=persons -->") == 1
|
||||||
|
assert twice.count("<!-- osintdb:end id=persons -->") == 1
|
||||||
|
|
||||||
|
|
||||||
|
def test_bloque_corrupto_solo_inicio_lanza_valueerror():
|
||||||
|
text = "Texto\n<!-- osintdb:begin id=persons -->\nhuerfano\n"
|
||||||
|
try:
|
||||||
|
upsert_sentinel_block(text, "persons", "x")
|
||||||
|
except ValueError as e:
|
||||||
|
assert "persons" in str(e)
|
||||||
|
assert "corrupto" in str(e)
|
||||||
|
else:
|
||||||
|
raise AssertionError("Esperaba ValueError por bloque corrupto (solo inicio)")
|
||||||
|
|
||||||
|
|
||||||
|
def test_bloque_corrupto_solo_fin_lanza_valueerror():
|
||||||
|
text = "Texto\n<!-- osintdb:end id=persons -->\nhuerfano\n"
|
||||||
|
try:
|
||||||
|
upsert_sentinel_block(text, "persons", "x")
|
||||||
|
except ValueError as e:
|
||||||
|
assert "persons" in str(e)
|
||||||
|
assert "corrupto" in str(e)
|
||||||
|
else:
|
||||||
|
raise AssertionError("Esperaba ValueError por bloque corrupto (solo fin)")
|
||||||
|
|
||||||
|
|
||||||
|
def test_multiples_bloques_ids_distintos_en_mismo_texto():
|
||||||
|
text = "# Dossier\n\nResumen."
|
||||||
|
# Insertar dos bloques con ids distintos.
|
||||||
|
text = upsert_sentinel_block(text, "persons", "- Alice")
|
||||||
|
text = upsert_sentinel_block(text, "places", "- Madrid")
|
||||||
|
|
||||||
|
assert text.count("<!-- osintdb:begin id=persons -->") == 1
|
||||||
|
assert text.count("<!-- osintdb:begin id=places -->") == 1
|
||||||
|
assert "- Alice" in text
|
||||||
|
assert "- Madrid" in text
|
||||||
|
|
||||||
|
# Reemplazar solo el bloque persons no afecta al de places.
|
||||||
|
updated = upsert_sentinel_block(text, "persons", "- Alice\n- Bob")
|
||||||
|
assert "- Alice\n- Bob" in updated
|
||||||
|
assert "- Madrid" in updated
|
||||||
|
assert updated.count("<!-- osintdb:begin id=places -->") == 1
|
||||||
|
# El contenido de places sigue intacto.
|
||||||
|
assert "places -->\n- Madrid\n<!-- osintdb:end id=places -->" in updated
|
||||||
|
|
||||||
|
|
||||||
|
def test_marker_personalizado():
|
||||||
|
text = "Cuerpo."
|
||||||
|
result = upsert_sentinel_block(text, "tags", "- osint", marker="mydb")
|
||||||
|
|
||||||
|
assert "<!-- mydb:begin id=tags -->" in result
|
||||||
|
assert "<!-- mydb:end id=tags -->" in result
|
||||||
|
# Reemplazo idempotente con el mismo marker.
|
||||||
|
again = upsert_sentinel_block(result, "tags", "- osint", marker="mydb")
|
||||||
|
assert again == result
|
||||||
|
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
test_insercion_nueva_anade_bloque_al_final()
|
||||||
|
test_reemplazo_existente_sustituye_solo_el_contenido()
|
||||||
|
test_idempotencia_dos_aplicaciones_mismo_resultado()
|
||||||
|
test_bloque_corrupto_solo_inicio_lanza_valueerror()
|
||||||
|
test_bloque_corrupto_solo_fin_lanza_valueerror()
|
||||||
|
test_multiples_bloques_ids_distintos_en_mismo_texto()
|
||||||
|
test_marker_personalizado()
|
||||||
|
print("All tests passed.")
|
||||||
@@ -22,6 +22,33 @@ from .extract_mac_addresses import extract_mac_addresses
|
|||||||
from .extract_phone_numbers import extract_phone_numbers
|
from .extract_phone_numbers import extract_phone_numbers
|
||||||
from .extract_iocs import extract_iocs
|
from .extract_iocs import extract_iocs
|
||||||
|
|
||||||
|
# OSINT passive atomic functions (grupo osint-passive).
|
||||||
|
from .extract_exif_metadata import extract_exif_metadata
|
||||||
|
from .extract_pdf_metadata import extract_pdf_metadata
|
||||||
|
from .guess_email_formats import guess_email_formats
|
||||||
|
from .enumerate_username_sites import enumerate_username_sites
|
||||||
|
from .build_search_dorks import build_search_dorks
|
||||||
|
from .whois_lookup import whois_lookup
|
||||||
|
from .dns_records import dns_records
|
||||||
|
from .enum_subdomains_crtsh import enum_subdomains_crtsh
|
||||||
|
|
||||||
|
# Active recon (grupo recon).
|
||||||
|
from .nmap_scan import nmap_scan
|
||||||
|
from .rdap_lookup import rdap_lookup
|
||||||
|
from .ping_host import ping_host
|
||||||
|
from .traceroute_host import traceroute_host
|
||||||
|
from .scan_tcp_ports import scan_tcp_ports
|
||||||
|
from .grab_service_banner import grab_service_banner
|
||||||
|
from .identify_port_service import identify_port_service
|
||||||
|
from .save_scan_to_osint import save_scan_to_osint
|
||||||
|
from .fetch_http_fingerprint import fetch_http_fingerprint
|
||||||
|
from .detect_web_tech import detect_web_tech
|
||||||
|
|
||||||
|
# OSINT passive enrichment orchestrators (grupo osint-enrich).
|
||||||
|
from .scan_ficha_attachments_metadata import scan_ficha_attachments_metadata
|
||||||
|
from .enrich_person_passive import enrich_person_passive
|
||||||
|
from .enrich_org_passive import enrich_org_passive
|
||||||
|
|
||||||
__all__ = [
|
__all__ = [
|
||||||
"hash_sha256",
|
"hash_sha256",
|
||||||
"hash_md5",
|
"hash_md5",
|
||||||
@@ -44,4 +71,25 @@ __all__ = [
|
|||||||
"extract_mac_addresses",
|
"extract_mac_addresses",
|
||||||
"extract_phone_numbers",
|
"extract_phone_numbers",
|
||||||
"extract_iocs",
|
"extract_iocs",
|
||||||
|
"extract_exif_metadata",
|
||||||
|
"extract_pdf_metadata",
|
||||||
|
"guess_email_formats",
|
||||||
|
"enumerate_username_sites",
|
||||||
|
"build_search_dorks",
|
||||||
|
"whois_lookup",
|
||||||
|
"dns_records",
|
||||||
|
"enum_subdomains_crtsh",
|
||||||
|
"nmap_scan",
|
||||||
|
"rdap_lookup",
|
||||||
|
"ping_host",
|
||||||
|
"traceroute_host",
|
||||||
|
"scan_tcp_ports",
|
||||||
|
"grab_service_banner",
|
||||||
|
"identify_port_service",
|
||||||
|
"save_scan_to_osint",
|
||||||
|
"fetch_http_fingerprint",
|
||||||
|
"detect_web_tech",
|
||||||
|
"scan_ficha_attachments_metadata",
|
||||||
|
"enrich_person_passive",
|
||||||
|
"enrich_org_passive",
|
||||||
]
|
]
|
||||||
|
|||||||
@@ -0,0 +1,64 @@
|
|||||||
|
---
|
||||||
|
name: build_search_dorks
|
||||||
|
kind: function
|
||||||
|
lang: py
|
||||||
|
domain: cybersecurity
|
||||||
|
version: "1.0.0"
|
||||||
|
purity: pure
|
||||||
|
signature: "def build_search_dorks(target: str, tipo: str = 'persona', extra_domains: list | None = None) -> list"
|
||||||
|
description: "Genera consultas (dorks) de motor de busqueda para investigar un target segun su tipo (persona|email|dominio|usuario): frase exacta, site:linkedin, filetype:pdf, intext con cv/curriculum, site:<dominio> filetype:xlsx, dorks de leaks/pastebin para email, redes sociales para usuario, etc. extra_domains acota via site:. OSINT pasivo puro, sin red."
|
||||||
|
tags: [osint-passive, dork, search, recon, google-dork, cybersecurity, python]
|
||||||
|
uses_functions: []
|
||||||
|
uses_types: []
|
||||||
|
returns: []
|
||||||
|
returns_optional: false
|
||||||
|
error_type: ""
|
||||||
|
imports: []
|
||||||
|
params:
|
||||||
|
- name: target
|
||||||
|
desc: "Cadena objetivo: nombre de persona, email, dominio o username segun el tipo."
|
||||||
|
- name: tipo
|
||||||
|
desc: "Uno de 'persona', 'email', 'dominio', 'usuario'. Cualquier otro valor devuelve solo la frase exacta. Default 'persona'."
|
||||||
|
- name: extra_domains
|
||||||
|
desc: "Lista opcional de dominios para añadir dorks 'site:<dominio> \"<target>\"' independientemente del tipo."
|
||||||
|
output: "Lista de strings de dork listos para pegar en un buscador, en orden de generacion."
|
||||||
|
tested: true
|
||||||
|
tests:
|
||||||
|
- "test_persona_genera_dorks_esperados"
|
||||||
|
- "test_email_genera_dorks_esperados"
|
||||||
|
- "test_dominio_genera_site_dorks"
|
||||||
|
- "test_usuario_genera_redes_sociales"
|
||||||
|
- "test_extra_domains_acota_con_site"
|
||||||
|
- "test_tipo_desconocido_solo_frase_exacta"
|
||||||
|
test_file_path: "python/functions/cybersecurity/build_search_dorks_test.py"
|
||||||
|
file_path: "python/functions/cybersecurity/build_search_dorks.py"
|
||||||
|
---
|
||||||
|
|
||||||
|
## Ejemplo
|
||||||
|
|
||||||
|
```python
|
||||||
|
build_search_dorks("Juan Perez", tipo="persona", extra_domains=["acme.com"])
|
||||||
|
# ['"Juan Perez"',
|
||||||
|
# '"Juan Perez" filetype:pdf',
|
||||||
|
# 'site:linkedin.com/in "Juan Perez"',
|
||||||
|
# 'site:twitter.com "Juan Perez"',
|
||||||
|
# 'intext:"Juan Perez" (curriculum OR cv OR resume)',
|
||||||
|
# '"Juan Perez" (email OR correo OR contacto)',
|
||||||
|
# '"Juan Perez" filetype:doc OR "Juan Perez" filetype:docx',
|
||||||
|
# 'site:acme.com "Juan Perez"']
|
||||||
|
|
||||||
|
build_search_dorks("empresa.com", tipo="dominio")
|
||||||
|
# ['"empresa.com"', 'site:empresa.com', 'site:empresa.com filetype:pdf',
|
||||||
|
# 'site:empresa.com filetype:xlsx', 'site:empresa.com (login OR admin OR dashboard)', ...]
|
||||||
|
```
|
||||||
|
|
||||||
|
## Cuando usarla
|
||||||
|
|
||||||
|
Usala cuando ya tengas un target identificado (persona, email, dominio o alias) y quieras una bateria de consultas de buscador listas para pegar manualmente y mapear documentos, perfiles y posibles filtraciones. Encaja despues de `guess_email_formats` (dorks de email) o `enumerate_username_sites` (dorks de usuario/persona) en una investigacion autorizada.
|
||||||
|
|
||||||
|
## Gotchas
|
||||||
|
|
||||||
|
- Funcion pura: solo genera strings de consulta, NO ejecuta busquedas ni toca la red. Los dorks se pegan a mano en el buscador.
|
||||||
|
- La sintaxis usa operadores de Google (site:, filetype:, intext:, inurl:); otros buscadores soportan un subconjunto distinto y algunos dorks no funcionaran igual.
|
||||||
|
- Para tipos no reconocidos devuelve unicamente la frase exacta entre comillas (mas los `extra_domains` si se pasan), no falla.
|
||||||
|
- Uso solo para investigacion OSINT autorizada; los dorks de leaks/breaches/pastebin pueden devolver datos sensibles cuyo tratamiento esta sujeto a ley.
|
||||||
@@ -0,0 +1,81 @@
|
|||||||
|
"""Genera consultas (dorks) de motor de busqueda para investigar un target.
|
||||||
|
|
||||||
|
Funcion pura de OSINT pasivo: a partir de un target y su tipo (persona,
|
||||||
|
email, dominio o usuario) produce una lista de cadenas de busqueda listas
|
||||||
|
para pegar en un buscador. No toca la red.
|
||||||
|
"""
|
||||||
|
|
||||||
|
|
||||||
|
def build_search_dorks(
|
||||||
|
target: str,
|
||||||
|
tipo: str = "persona",
|
||||||
|
extra_domains: list | None = None,
|
||||||
|
) -> list:
|
||||||
|
"""Genera dorks de motor de busqueda adaptados al tipo de target.
|
||||||
|
|
||||||
|
Segun el tipo seleccionado produce las consultas mas utiles para
|
||||||
|
investigar:
|
||||||
|
|
||||||
|
- persona: nombre exacto, en linkedin, con cv/curriculum, en ficheros.
|
||||||
|
- email: el email entre comillas, en pastebin/breaches, en filetypes.
|
||||||
|
- dominio: site:dominio, ficheros expuestos, subdominios, paneles.
|
||||||
|
- usuario: el alias en redes sociales y foros.
|
||||||
|
|
||||||
|
`extra_domains` añade dorks `site:<dominio> "<target>"` para acotar la
|
||||||
|
busqueda a dominios concretos, independientemente del tipo.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
target: cadena objetivo (nombre, email, dominio o username).
|
||||||
|
tipo: uno de "persona", "email", "dominio", "usuario". Cualquier
|
||||||
|
otro valor cae al conjunto generico (solo la frase exacta).
|
||||||
|
extra_domains: lista opcional de dominios para acotar via site:.
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
lista de strings de dork en orden de generacion (sin deduplicar
|
||||||
|
salvo el dork de frase exacta, que es la base comun).
|
||||||
|
"""
|
||||||
|
q = f'"{target}"' # frase exacta, base de casi todo dork
|
||||||
|
dorks = [q]
|
||||||
|
t = tipo.strip().lower()
|
||||||
|
|
||||||
|
if t == "persona":
|
||||||
|
dorks += [
|
||||||
|
f"{q} filetype:pdf",
|
||||||
|
f'site:linkedin.com/in {q}',
|
||||||
|
f'site:twitter.com {q}',
|
||||||
|
f'intext:{q} (curriculum OR cv OR resume)',
|
||||||
|
f'{q} (email OR correo OR contacto)',
|
||||||
|
f'{q} filetype:doc OR {q} filetype:docx',
|
||||||
|
]
|
||||||
|
elif t == "email":
|
||||||
|
dorks += [
|
||||||
|
f'{q} site:pastebin.com',
|
||||||
|
f'{q} (leak OR breach OR dump OR password)',
|
||||||
|
f'{q} filetype:txt',
|
||||||
|
f'{q} filetype:csv',
|
||||||
|
f'{q} site:github.com',
|
||||||
|
]
|
||||||
|
elif t == "dominio":
|
||||||
|
dorks += [
|
||||||
|
f'site:{target}',
|
||||||
|
f'site:{target} filetype:pdf',
|
||||||
|
f'site:{target} filetype:xlsx',
|
||||||
|
f'site:{target} (login OR admin OR dashboard)',
|
||||||
|
f'site:{target} intext:(password OR contraseña)',
|
||||||
|
f'site:*.{target}',
|
||||||
|
f'-www site:{target}',
|
||||||
|
]
|
||||||
|
elif t == "usuario":
|
||||||
|
dorks += [
|
||||||
|
f'{q} site:github.com',
|
||||||
|
f'{q} site:reddit.com',
|
||||||
|
f'{q} (profile OR perfil OR user OR usuario)',
|
||||||
|
f'inurl:{target}',
|
||||||
|
]
|
||||||
|
# Cualquier otro tipo: solo la frase exacta (ya añadida).
|
||||||
|
|
||||||
|
if extra_domains:
|
||||||
|
for dom in extra_domains:
|
||||||
|
dorks.append(f'site:{dom} {q}')
|
||||||
|
|
||||||
|
return dorks
|
||||||
@@ -0,0 +1,56 @@
|
|||||||
|
"""Tests para build_search_dorks."""
|
||||||
|
|
||||||
|
from build_search_dorks import build_search_dorks
|
||||||
|
|
||||||
|
|
||||||
|
def test_persona_genera_dorks_esperados():
|
||||||
|
"""El tipo persona produce frase exacta, linkedin, cv y filetypes."""
|
||||||
|
out = build_search_dorks("Juan Perez", tipo="persona")
|
||||||
|
assert '"Juan Perez"' in out
|
||||||
|
assert '"Juan Perez" filetype:pdf' in out
|
||||||
|
assert any("linkedin.com" in d for d in out)
|
||||||
|
assert any("curriculum OR cv OR resume" in d for d in out)
|
||||||
|
|
||||||
|
|
||||||
|
def test_email_genera_dorks_esperados():
|
||||||
|
"""El tipo email busca en pastebin, breaches y filetypes."""
|
||||||
|
out = build_search_dorks("bob@corp.com", tipo="email")
|
||||||
|
assert '"bob@corp.com"' in out
|
||||||
|
assert any("pastebin.com" in d for d in out)
|
||||||
|
assert any("leak OR breach OR dump OR password" in d for d in out)
|
||||||
|
assert '"bob@corp.com" filetype:csv' in out
|
||||||
|
|
||||||
|
|
||||||
|
def test_dominio_genera_site_dorks():
|
||||||
|
"""El tipo dominio produce site: y ficheros expuestos."""
|
||||||
|
out = build_search_dorks("empresa.com", tipo="dominio")
|
||||||
|
assert "site:empresa.com" in out
|
||||||
|
assert "site:empresa.com filetype:xlsx" in out
|
||||||
|
assert any("login OR admin OR dashboard" in d for d in out)
|
||||||
|
assert "site:*.empresa.com" in out
|
||||||
|
|
||||||
|
|
||||||
|
def test_usuario_genera_redes_sociales():
|
||||||
|
"""El tipo usuario busca en github, reddit y por inurl."""
|
||||||
|
out = build_search_dorks("jdoe", tipo="usuario")
|
||||||
|
assert '"jdoe"' in out
|
||||||
|
assert any("github.com" in d for d in out)
|
||||||
|
assert any("reddit.com" in d for d in out)
|
||||||
|
assert "inurl:jdoe" in out
|
||||||
|
|
||||||
|
|
||||||
|
def test_extra_domains_acota_con_site():
|
||||||
|
"""extra_domains añade dorks site:<dominio> con la frase exacta."""
|
||||||
|
out = build_search_dorks(
|
||||||
|
"Ana Ruiz", tipo="persona", extra_domains=["uni.edu", "gov.es"]
|
||||||
|
)
|
||||||
|
assert 'site:uni.edu "Ana Ruiz"' in out
|
||||||
|
assert 'site:gov.es "Ana Ruiz"' in out
|
||||||
|
|
||||||
|
|
||||||
|
def test_tipo_desconocido_solo_frase_exacta():
|
||||||
|
"""Un tipo no reconocido devuelve solo la frase exacta (mas extras)."""
|
||||||
|
out = build_search_dorks("algo", tipo="otracosa")
|
||||||
|
assert out[0] == '"algo"'
|
||||||
|
# Sin extras y tipo desconocido, solo la frase base.
|
||||||
|
assert out == ['"algo"']
|
||||||
@@ -0,0 +1,111 @@
|
|||||||
|
---
|
||||||
|
name: detect_web_tech
|
||||||
|
kind: function
|
||||||
|
lang: py
|
||||||
|
domain: cybersecurity
|
||||||
|
version: "1.0.0"
|
||||||
|
purity: pure
|
||||||
|
signature: "def detect_web_tech(headers: dict, html: str = '', cookies: list[str] | None = None, final_url: str = '') -> dict"
|
||||||
|
description: "Detector de tecnologia web estilo Wappalyzer: identifica el stack tecnologico de un sitio (web fingerprint) matcheando una tabla de firmas regex embebida contra las cabeceras HTTP, el HTML, los nombres de cookies y la URL final. Detecta servidor (nginx, Apache, IIS, LiteSpeed, Caddy), lenguaje (PHP, ASP.NET, Java, Python, Ruby, Node.js), CMS (WordPress, Drupal, Joomla, Shopify, Wix, Squarespace, Ghost), frameworks JS (React, Vue, Angular, Svelte, Next.js, Nuxt), librerias (jQuery, Bootstrap, Lodash, Modernizr), analytics/tag (Google Analytics, GTM, Facebook Pixel, Hotjar, Matomo), CDN (Cloudflare, Fastly, Akamai, CloudFront, jsDelivr, unpkg), ecommerce (WooCommerce, Magento, PrestaShop, Shopify) y WAF/seguridad (Cloudflare, Sucuri, Imperva Incapsula). Pieza pura del detector: no toca la red, recibe las senales ya recogidas por fetch_http_fingerprint."
|
||||||
|
tags: [recon, cybersecurity, web-recon, wappalyzer, fingerprint, tech-detection, cms, stack]
|
||||||
|
params:
|
||||||
|
- name: headers
|
||||||
|
desc: "dict de cabeceras de respuesta HTTP con claves en minusculas (tal como las devuelve fetch_http_fingerprint en su campo headers). Valores string. Si las claves vienen en mayusculas se normalizan internamente."
|
||||||
|
- name: html
|
||||||
|
desc: "HTML de la pagina como string. Default '' para detectar solo por cabeceras y cookies. De aqui se extraen meta generator y src de los <script>."
|
||||||
|
- name: cookies
|
||||||
|
desc: "lista de NOMBRES de cookies (no valores). Default None -> []. Ej: ['PHPSESSID', 'wordpress_logged_in']."
|
||||||
|
- name: final_url
|
||||||
|
desc: "URL final tras redirects, para firmas basadas en host/path. Opcional, default ''."
|
||||||
|
output: "dict con technologies (lista de {name, category, version, confidence, evidence} ordenada deterministicamente por categoria y nombre), by_category (dict categoria -> lista de nombres) y count (entero). confidence es 'high' para match directo de header/meta/cookie/url y 'medium' para HTML generico, script src o tecnologia implicada. version es best-effort (a menudo ''). Para entrada vacia devuelve technologies [], by_category {}, count 0. Nunca lanza."
|
||||||
|
uses_functions: []
|
||||||
|
uses_types: []
|
||||||
|
returns: []
|
||||||
|
returns_optional: false
|
||||||
|
error_type: ""
|
||||||
|
imports: []
|
||||||
|
tested: true
|
||||||
|
tests: ["test_nginx_por_header_con_version", "test_wordpress_por_html_y_meta_implica_php", "test_php_por_cookie", "test_cloudflare_por_header", "test_entrada_vacia", "test_entrada_vacia_explicita_headers_y_html", "test_determinismo", "test_count_y_by_category_consistentes", "test_headers_claves_mayusculas_se_normalizan", "test_jquery_por_script_src_es_medium"]
|
||||||
|
test_file_path: "python/functions/cybersecurity/detect_web_tech_test.py"
|
||||||
|
file_path: "python/functions/cybersecurity/detect_web_tech.py"
|
||||||
|
---
|
||||||
|
|
||||||
|
## Ejemplo
|
||||||
|
|
||||||
|
```python
|
||||||
|
from cybersecurity import detect_web_tech
|
||||||
|
|
||||||
|
# Senales fake de un sitio WordPress sobre nginx.
|
||||||
|
headers = {
|
||||||
|
"server": "nginx/1.24.0",
|
||||||
|
"x-powered-by": "PHP/8.2",
|
||||||
|
}
|
||||||
|
html = (
|
||||||
|
'<html><head>'
|
||||||
|
'<meta name="generator" content="WordPress 6.4">'
|
||||||
|
'</head><body><link href="/wp-content/themes/x/style.css"></body></html>'
|
||||||
|
)
|
||||||
|
cookies = ["PHPSESSID", "wordpress_logged_in_abc"]
|
||||||
|
|
||||||
|
result = detect_web_tech(headers, html=html, cookies=cookies)
|
||||||
|
# result["count"] == 3
|
||||||
|
# result["by_category"] == {
|
||||||
|
# "cms": ["WordPress"],
|
||||||
|
# "programming-language": ["PHP"],
|
||||||
|
# "web-server": ["nginx"],
|
||||||
|
# }
|
||||||
|
# nginx -> version "1.24.0", confidence "high", evidence "header server: nginx/1.24.0"
|
||||||
|
# WordPress -> version "6.4", confidence "high", evidence "meta generator: WordPress 6.4"
|
||||||
|
# PHP -> version "8.2", confidence "high", evidence "header x-powered-by: PHP/8.2"
|
||||||
|
```
|
||||||
|
|
||||||
|
Flujo real componiendo con la capa impura hermana (recoleccion -> matching):
|
||||||
|
|
||||||
|
```python
|
||||||
|
from cybersecurity import fetch_http_fingerprint, detect_web_tech
|
||||||
|
|
||||||
|
# Capa impura: recoge las senales con un GET real (red).
|
||||||
|
fp = fetch_http_fingerprint("https://example.com")
|
||||||
|
# fp = {"headers": {...lowercase...}, "html": "...", "cookies": [...], "final_url": "..."}
|
||||||
|
|
||||||
|
# Capa pura: identifica el stack sobre las senales recogidas (sin red).
|
||||||
|
tech = detect_web_tech(
|
||||||
|
fp["headers"],
|
||||||
|
html=fp.get("html", ""),
|
||||||
|
cookies=fp.get("cookies"),
|
||||||
|
final_url=fp.get("final_url", ""),
|
||||||
|
)
|
||||||
|
for t in tech["technologies"]:
|
||||||
|
print(t["category"], t["name"], t["version"], t["confidence"])
|
||||||
|
```
|
||||||
|
|
||||||
|
## Cuando usarla
|
||||||
|
|
||||||
|
Cuando ya tienes los headers + html (+ cookies/URL) de una URL — recogidos por
|
||||||
|
`fetch_http_fingerprint_py_cybersecurity` — y quieres saber el stack tecnologico
|
||||||
|
del sitio: servidor, lenguaje, CMS, frameworks JS, librerias, analytics, CDN,
|
||||||
|
ecommerce y WAF. Usala como pieza de matching pura y testeable. Para el flujo
|
||||||
|
one-shot `url -> tecnologias` (recoger + detectar en una llamada) usa el pipeline
|
||||||
|
`fingerprint_web_stack`.
|
||||||
|
|
||||||
|
## Gotchas
|
||||||
|
|
||||||
|
- La tabla `SIGNATURES` es un **subconjunto curado** de lo que cubre Wappalyzer
|
||||||
|
(~50 tecnologias), no es exhaustiva. Para ampliarla, anade entradas nuevas a la
|
||||||
|
constante `SIGNATURES` del modulo siguiendo el formato documentado en el codigo
|
||||||
|
(matchers `headers`/`html`/`meta_generator`/`cookies`/`script_src`/`url`,
|
||||||
|
opcionales `version_group` e `implies`).
|
||||||
|
- La deteccion por HTML generico puede dar **falsos positivos**: un sitio que
|
||||||
|
mencione "wordpress" o "woocommerce" en su texto/blog puede matchear sin usarlo
|
||||||
|
realmente. Por eso esos matches tienen `confidence: "medium"` mientras que
|
||||||
|
header/meta/cookie directos son `"high"`.
|
||||||
|
- Las **SPAs cargan los frameworks por JS en runtime**. Un fetch estatico (sin
|
||||||
|
ejecutar JavaScript) ve el HTML inicial, que en muchas SPAs esta casi vacio
|
||||||
|
(`<div id="root"></div>`). React/Vue/Angular pueden NO detectarse si el HTML
|
||||||
|
servido no contiene aun sus marcadores. Para esos casos hace falta renderizar
|
||||||
|
con un navegador headless, fuera del alcance de esta funcion pura.
|
||||||
|
- Las **versiones son best-effort**: solo se extraen cuando el regex que disparo
|
||||||
|
tiene un group de version y este matcheo. A menudo quedan en `""`.
|
||||||
|
- Es PURA y determinista: misma entrada -> misma salida. Para entrada vacia
|
||||||
|
(`headers={}, html=""`) devuelve `technologies: [], count: 0` y NUNCA lanza ni
|
||||||
|
reporta status/error.
|
||||||
@@ -0,0 +1,431 @@
|
|||||||
|
"""Detector de tecnologia web estilo Wappalyzer (pieza pura).
|
||||||
|
|
||||||
|
Dado el resultado crudo de un fetch HTTP (cabeceras, HTML, cookies, URL final),
|
||||||
|
identifica las tecnologias web que usa un sitio matcheando contra una tabla de
|
||||||
|
firmas embebida (regex): servidor, lenguaje, CMS, frameworks JS, librerias,
|
||||||
|
analytics, CDN, e-commerce, WAF, etc.
|
||||||
|
|
||||||
|
Esta funcion es PURA: no toca la red ni hace I/O. Recibe las senales ya
|
||||||
|
recogidas por la capa impura hermana (`fetch_http_fingerprint_py_cybersecurity`)
|
||||||
|
y se limita a aplicar regex deterministas sobre ellas. Separar el matching de la
|
||||||
|
recoleccion permite testear las firmas sin red y reutilizar la tabla.
|
||||||
|
|
||||||
|
La tabla `SIGNATURES` es un subconjunto curado de lo que cubre Wappalyzer (no es
|
||||||
|
exhaustiva). Para ampliarla, anadir entradas nuevas a `SIGNATURES` siguiendo el
|
||||||
|
formato documentado mas abajo.
|
||||||
|
"""
|
||||||
|
|
||||||
|
import re
|
||||||
|
|
||||||
|
__all__ = ["detect_web_tech", "SIGNATURES"]
|
||||||
|
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# Tabla de firmas embebida.
|
||||||
|
#
|
||||||
|
# Cada firma es un dict con un `name`, una `category` y uno o varios matchers
|
||||||
|
# (todos opcionales; OR entre tipos: basta que UNO matchee para detectar):
|
||||||
|
#
|
||||||
|
# "headers": {"<header-lowercase>": r"<regex>"} -> regex por header
|
||||||
|
# "html": r"<regex>" -> regex sobre el HTML
|
||||||
|
# "meta_generator": r"<regex>" -> regex sobre <meta name=generator content=...>
|
||||||
|
# "cookies": r"<regex>" -> regex sobre nombres de cookies
|
||||||
|
# "script_src": r"<regex>" -> regex sobre src de <script>
|
||||||
|
# "url": r"<regex>" -> regex sobre la URL final
|
||||||
|
#
|
||||||
|
# Campos opcionales:
|
||||||
|
# "version_group": <int> -> de que group del matcher que disparo sacar la version
|
||||||
|
# "implies": ["Tech", ...] -> tecnologias implicadas (confidence menor)
|
||||||
|
#
|
||||||
|
# Confidence: "high" si matchea header/meta/cookie/url directo; "medium" si por
|
||||||
|
# HTML/script_src generico o por `implies`.
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
SIGNATURES = [
|
||||||
|
# ---- web-server -------------------------------------------------------
|
||||||
|
{"name": "nginx", "category": "web-server",
|
||||||
|
"headers": {"server": r"nginx(?:/([\d.]+))?"}, "version_group": 1},
|
||||||
|
{"name": "Apache", "category": "web-server",
|
||||||
|
"headers": {"server": r"Apache(?:/([\d.]+))?"}, "version_group": 1},
|
||||||
|
{"name": "IIS", "category": "web-server",
|
||||||
|
"headers": {"server": r"(?:Microsoft-)?IIS(?:/([\d.]+))?"}, "version_group": 1},
|
||||||
|
{"name": "LiteSpeed", "category": "web-server",
|
||||||
|
"headers": {"server": r"LiteSpeed"}},
|
||||||
|
{"name": "Caddy", "category": "web-server",
|
||||||
|
"headers": {"server": r"Caddy"}},
|
||||||
|
{"name": "Gunicorn", "category": "web-server",
|
||||||
|
"headers": {"server": r"gunicorn(?:/([\d.]+))?"}, "version_group": 1,
|
||||||
|
"implies": ["Python"]},
|
||||||
|
{"name": "Werkzeug", "category": "web-server",
|
||||||
|
"headers": {"server": r"Werkzeug(?:/([\d.]+))?"}, "version_group": 1,
|
||||||
|
"implies": ["Python"]},
|
||||||
|
{"name": "Phusion Passenger", "category": "web-server",
|
||||||
|
"headers": {"server": r"Phusion Passenger(?:[ /]([\d.]+))?"}, "version_group": 1,
|
||||||
|
"implies": ["Ruby"]},
|
||||||
|
{"name": "Tomcat", "category": "web-server",
|
||||||
|
"headers": {"server": r"(?:Apache-Coyote|Tomcat)(?:/([\d.]+))?"}, "version_group": 1,
|
||||||
|
"implies": ["Java"]},
|
||||||
|
|
||||||
|
# ---- programming-language --------------------------------------------
|
||||||
|
{"name": "PHP", "category": "programming-language",
|
||||||
|
"headers": {"x-powered-by": r"PHP(?:/([\d.]+))?"},
|
||||||
|
"cookies": r"^PHPSESSID$", "version_group": 1},
|
||||||
|
{"name": "ASP.NET", "category": "programming-language",
|
||||||
|
"headers": {"x-aspnet-version": r"([\d.]+)", "x-powered-by": r"ASP\.NET"},
|
||||||
|
"cookies": r"^ASP\.NET_SessionId$", "version_group": 1},
|
||||||
|
{"name": "Java", "category": "programming-language",
|
||||||
|
"cookies": r"^JSESSIONID$"},
|
||||||
|
{"name": "Python", "category": "programming-language",
|
||||||
|
"headers": {"x-powered-by": r"Python(?:/([\d.]+))?"}, "version_group": 1},
|
||||||
|
{"name": "Ruby", "category": "programming-language",
|
||||||
|
"headers": {"x-powered-by": r"Phusion Passenger|mod_rails"}},
|
||||||
|
|
||||||
|
# ---- web-framework ----------------------------------------------------
|
||||||
|
{"name": "Express", "category": "web-framework",
|
||||||
|
"headers": {"x-powered-by": r"Express"}, "implies": ["Node.js"]},
|
||||||
|
{"name": "Django", "category": "web-framework",
|
||||||
|
"cookies": r"^csrftoken$|^django", "implies": ["Python"]},
|
||||||
|
{"name": "Flask", "category": "web-framework",
|
||||||
|
"cookies": r"^session$", "headers": {"server": r"Werkzeug"},
|
||||||
|
"implies": ["Python"]},
|
||||||
|
{"name": "Laravel", "category": "web-framework",
|
||||||
|
"cookies": r"^laravel_session$|^XSRF-TOKEN$", "implies": ["PHP"]},
|
||||||
|
{"name": "Ruby on Rails", "category": "web-framework",
|
||||||
|
"headers": {"x-runtime": r"([\d.]+)"},
|
||||||
|
"cookies": r"_session_id$|^_rails", "version_group": 1, "implies": ["Ruby"]},
|
||||||
|
{"name": "ASP.NET MVC", "category": "web-framework",
|
||||||
|
"headers": {"x-aspnetmvc-version": r"([\d.]+)"}, "version_group": 1,
|
||||||
|
"implies": ["ASP.NET"]},
|
||||||
|
{"name": "Next.js", "category": "web-framework",
|
||||||
|
"html": r"id=[\"']__NEXT_DATA__[\"']|/_next/static/",
|
||||||
|
"headers": {"x-powered-by": r"Next\.js"}, "implies": ["React", "Node.js"]},
|
||||||
|
{"name": "Nuxt.js", "category": "web-framework",
|
||||||
|
"html": r"window\.__NUXT__|/_nuxt/", "implies": ["Vue.js"]},
|
||||||
|
|
||||||
|
# ---- cms --------------------------------------------------------------
|
||||||
|
{"name": "WordPress", "category": "cms",
|
||||||
|
"html": r"wp-content|wp-includes",
|
||||||
|
"meta_generator": r"WordPress(?:[ /]?([\d.]+))?",
|
||||||
|
"cookies": r"^wordpress_|^wp-settings",
|
||||||
|
"script_src": r"/wp-includes/js/", "version_group": 1, "implies": ["PHP"]},
|
||||||
|
{"name": "Drupal", "category": "cms",
|
||||||
|
"headers": {"x-generator": r"Drupal(?:[ /]?([\d.]+))?"},
|
||||||
|
"html": r"Drupal\.settings|sites/(?:all|default)/(?:themes|modules)",
|
||||||
|
"meta_generator": r"Drupal(?:[ /]?([\d.]+))?", "version_group": 1,
|
||||||
|
"implies": ["PHP"]},
|
||||||
|
{"name": "Joomla", "category": "cms",
|
||||||
|
"meta_generator": r"Joomla!?(?:[ /]?([\d.]+))?",
|
||||||
|
"html": r"/media/jui/|com_content", "version_group": 1, "implies": ["PHP"]},
|
||||||
|
{"name": "Ghost", "category": "cms",
|
||||||
|
"meta_generator": r"Ghost(?:[ /]?([\d.]+))?",
|
||||||
|
"html": r"content=[\"']Ghost", "version_group": 1, "implies": ["Node.js"]},
|
||||||
|
{"name": "Wix", "category": "cms",
|
||||||
|
"headers": {"x-wix-request-id": r".+"},
|
||||||
|
"html": r"static\.wixstatic\.com|X-Wix"},
|
||||||
|
{"name": "Squarespace", "category": "cms",
|
||||||
|
"html": r"static\.squarespace\.com|squarespace\.com",
|
||||||
|
"meta_generator": r"Squarespace"},
|
||||||
|
|
||||||
|
# ---- js-framework -----------------------------------------------------
|
||||||
|
{"name": "React", "category": "js-framework",
|
||||||
|
"html": r"data-reactroot|data-reactid|__REACT_DEVTOOLS"},
|
||||||
|
{"name": "Vue.js", "category": "js-framework",
|
||||||
|
"html": r"data-v-[0-9a-f]{6,}|__VUE__|id=[\"']app[\"'][^>]*data-v-"},
|
||||||
|
{"name": "Angular", "category": "js-framework",
|
||||||
|
"html": r"ng-version=[\"']([\d.]+)[\"']|ng-app|<app-root", "version_group": 1},
|
||||||
|
{"name": "Svelte", "category": "js-framework",
|
||||||
|
"html": r"svelte-[0-9a-z]{6,}|__svelte"},
|
||||||
|
{"name": "Ember.js", "category": "js-framework",
|
||||||
|
"html": r"ember-application|id=[\"']ember"},
|
||||||
|
{"name": "Backbone.js", "category": "js-framework",
|
||||||
|
"script_src": r"backbone(?:\.min)?\.js"},
|
||||||
|
|
||||||
|
# ---- js-library -------------------------------------------------------
|
||||||
|
{"name": "jQuery", "category": "js-library",
|
||||||
|
"script_src": r"jquery[.-]?([\d.]+)?(?:\.min)?\.js", "version_group": 1},
|
||||||
|
{"name": "Bootstrap", "category": "js-library",
|
||||||
|
"script_src": r"bootstrap[.-]?([\d.]+)?(?:\.min)?\.js",
|
||||||
|
"html": r"class=[\"'][^\"']*\b(?:container-fluid|navbar-toggler|col-md-)\b",
|
||||||
|
"version_group": 1},
|
||||||
|
{"name": "Lodash", "category": "js-library",
|
||||||
|
"script_src": r"lodash(?:\.min)?\.js"},
|
||||||
|
{"name": "Underscore.js", "category": "js-library",
|
||||||
|
"script_src": r"underscore(?:\.min)?\.js"},
|
||||||
|
{"name": "Modernizr", "category": "js-library",
|
||||||
|
"script_src": r"modernizr(?:[.-]?[\d.]+)?(?:\.min)?\.js",
|
||||||
|
"html": r"class=[\"'][^\"']*\bjs\b[^\"']*\bno-js\b"},
|
||||||
|
{"name": "Moment.js", "category": "js-library",
|
||||||
|
"script_src": r"moment(?:\.min)?\.js"},
|
||||||
|
|
||||||
|
# ---- analytics / tag --------------------------------------------------
|
||||||
|
{"name": "Google Analytics", "category": "analytics",
|
||||||
|
"html": r"google-analytics\.com/(?:ga|analytics)\.js|gtag\(|googletagmanager\.com/gtag/js",
|
||||||
|
"script_src": r"google-analytics\.com|googletagmanager\.com/gtag"},
|
||||||
|
{"name": "Google Tag Manager", "category": "analytics",
|
||||||
|
"html": r"googletagmanager\.com/gtm\.js|GTM-[A-Z0-9]+",
|
||||||
|
"script_src": r"googletagmanager\.com/gtm\.js"},
|
||||||
|
{"name": "Facebook Pixel", "category": "analytics",
|
||||||
|
"html": r"connect\.facebook\.net/[^/]+/fbevents\.js|fbq\("},
|
||||||
|
{"name": "Hotjar", "category": "analytics",
|
||||||
|
"html": r"static\.hotjar\.com|hotjar\.com/c/hotjar|hjid:",
|
||||||
|
"script_src": r"static\.hotjar\.com"},
|
||||||
|
{"name": "Matomo", "category": "analytics",
|
||||||
|
"html": r"matomo\.js|piwik\.js|_paq\.push",
|
||||||
|
"script_src": r"matomo\.js|piwik\.js"},
|
||||||
|
|
||||||
|
# ---- cdn --------------------------------------------------------------
|
||||||
|
{"name": "Cloudflare", "category": "cdn",
|
||||||
|
"headers": {"cf-ray": r".+", "server": r"cloudflare"}},
|
||||||
|
{"name": "Fastly", "category": "cdn",
|
||||||
|
"headers": {"x-served-by": r"cache-.+|.+fastly.*", "x-fastly-request-id": r".+",
|
||||||
|
"via": r".*Fastly.*"}},
|
||||||
|
{"name": "Akamai", "category": "cdn",
|
||||||
|
"headers": {"x-akamai-transformed": r".+", "server": r"AkamaiGHost"}},
|
||||||
|
{"name": "Amazon CloudFront", "category": "cdn",
|
||||||
|
"headers": {"x-amz-cf-id": r".+", "via": r".*CloudFront.*",
|
||||||
|
"x-cache": r".*cloudfront.*"}},
|
||||||
|
{"name": "jsDelivr", "category": "cdn",
|
||||||
|
"script_src": r"cdn\.jsdelivr\.net"},
|
||||||
|
{"name": "unpkg", "category": "cdn",
|
||||||
|
"script_src": r"unpkg\.com"},
|
||||||
|
|
||||||
|
# ---- ecommerce --------------------------------------------------------
|
||||||
|
{"name": "Shopify", "category": "ecommerce",
|
||||||
|
"headers": {"x-shopify-stage": r".+", "x-sorting-hat-shopid": r".+"},
|
||||||
|
"html": r"cdn\.shopify\.com|Shopify\.theme|shopify\.com/s/",
|
||||||
|
"script_src": r"cdn\.shopify\.com"},
|
||||||
|
{"name": "WooCommerce", "category": "ecommerce",
|
||||||
|
"html": r"woocommerce|wc-(?:cart|checkout)|class=[\"'][^\"']*woocommerce",
|
||||||
|
"cookies": r"^woocommerce_", "implies": ["WordPress", "PHP"]},
|
||||||
|
{"name": "Magento", "category": "ecommerce",
|
||||||
|
"html": r"Mage\.Cookies|/skin/frontend/|Magento_",
|
||||||
|
"cookies": r"^frontend$|^X-Magento", "implies": ["PHP"]},
|
||||||
|
{"name": "PrestaShop", "category": "ecommerce",
|
||||||
|
"html": r"prestashop|/modules/.*prestashop",
|
||||||
|
"meta_generator": r"PrestaShop", "cookies": r"^PrestaShop-",
|
||||||
|
"implies": ["PHP"]},
|
||||||
|
|
||||||
|
# ---- security / waf ---------------------------------------------------
|
||||||
|
{"name": "Sucuri", "category": "security",
|
||||||
|
"headers": {"x-sucuri-id": r".+", "x-sucuri-cache": r".+",
|
||||||
|
"server": r"Sucuri/Cloudproxy"}},
|
||||||
|
{"name": "Imperva Incapsula", "category": "security",
|
||||||
|
"headers": {"x-iinfo": r".+", "x-cdn": r"Incapsula"},
|
||||||
|
"cookies": r"^(?:incap_ses|visid_incap)"},
|
||||||
|
{"name": "Cloudflare WAF", "category": "security",
|
||||||
|
"cookies": r"^__cf(?:duid|_bm)$|^cf_clearance$"},
|
||||||
|
|
||||||
|
# ---- runtime ----------------------------------------------------------
|
||||||
|
{"name": "Node.js", "category": "programming-language",
|
||||||
|
"headers": {"x-powered-by": r"Express|Node"}},
|
||||||
|
]
|
||||||
|
|
||||||
|
|
||||||
|
def _compile_signatures(signatures):
|
||||||
|
"""Compila los regex de cada firma a nivel modulo (una sola vez).
|
||||||
|
|
||||||
|
Devuelve una lista paralela a `signatures` donde cada matcher textual ha
|
||||||
|
sido reemplazado por un patron compilado con re.IGNORECASE. Es una
|
||||||
|
transformacion pura sobre la constante; no muta `signatures`.
|
||||||
|
"""
|
||||||
|
compiled = []
|
||||||
|
for sig in signatures:
|
||||||
|
c = {"name": sig["name"], "category": sig["category"]}
|
||||||
|
if "version_group" in sig:
|
||||||
|
c["version_group"] = sig["version_group"]
|
||||||
|
if "implies" in sig:
|
||||||
|
c["implies"] = list(sig["implies"])
|
||||||
|
if "headers" in sig:
|
||||||
|
c["headers"] = {
|
||||||
|
k.lower(): re.compile(v, re.IGNORECASE)
|
||||||
|
for k, v in sig["headers"].items()
|
||||||
|
}
|
||||||
|
for key in ("html", "meta_generator", "cookies", "script_src", "url"):
|
||||||
|
if key in sig:
|
||||||
|
c[key] = re.compile(sig[key], re.IGNORECASE)
|
||||||
|
compiled.append(c)
|
||||||
|
return compiled
|
||||||
|
|
||||||
|
|
||||||
|
# Regex compilados a nivel modulo: constante, inmutable en la practica.
|
||||||
|
_COMPILED = _compile_signatures(SIGNATURES)
|
||||||
|
|
||||||
|
# Regex auxiliares para extraer senales del HTML (compilados una vez).
|
||||||
|
_META_GENERATOR_RE = re.compile(
|
||||||
|
r"<meta[^>]+name=[\"']generator[\"'][^>]+content=[\"']([^\"']*)[\"']",
|
||||||
|
re.IGNORECASE,
|
||||||
|
)
|
||||||
|
_SCRIPT_SRC_RE = re.compile(
|
||||||
|
r"<script[^>]+src=[\"']([^\"']+)[\"']", re.IGNORECASE
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def _version_from(match, version_group):
|
||||||
|
"""Extrae la version de un match dado el group, best-effort.
|
||||||
|
|
||||||
|
Devuelve "" si no hay group, el group esta vacio o el indice no existe.
|
||||||
|
"""
|
||||||
|
if not match or not version_group:
|
||||||
|
return ""
|
||||||
|
try:
|
||||||
|
v = match.group(version_group)
|
||||||
|
except (IndexError, re.error):
|
||||||
|
return ""
|
||||||
|
return v or ""
|
||||||
|
|
||||||
|
|
||||||
|
def detect_web_tech(headers, html="", cookies=None, final_url=""):
|
||||||
|
"""Detecta tecnologias web a partir de senales de un fetch HTTP.
|
||||||
|
|
||||||
|
Pieza PURA de un detector estilo Wappalyzer: matchea una tabla de firmas
|
||||||
|
embebida (regex) contra las cabeceras, el HTML, los nombres de cookies y la
|
||||||
|
URL final ya recogidos por la capa impura hermana
|
||||||
|
(`fetch_http_fingerprint_py_cybersecurity`). No toca la red ni hace I/O.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
headers: dict de cabeceras de respuesta con claves LOWERCASE (tal como
|
||||||
|
las devuelve fetch_http_fingerprint en su campo `headers`). Los
|
||||||
|
valores son strings. Si las claves no vinieran en minusculas se
|
||||||
|
normalizan internamente.
|
||||||
|
html: HTML de la pagina como string. Default "" (permite detectar solo
|
||||||
|
por cabeceras y cookies).
|
||||||
|
cookies: lista de NOMBRES de cookies (no valores). Default None -> [].
|
||||||
|
final_url: URL final tras redirects (para firmas basadas en host/path).
|
||||||
|
Opcional, default "".
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
dict con:
|
||||||
|
- "technologies": lista de dicts
|
||||||
|
{name, category, version, confidence, evidence}, ordenada por
|
||||||
|
(categoria, nombre) de forma determinista.
|
||||||
|
- "by_category": dict categoria -> lista ordenada de nombres.
|
||||||
|
- "count": numero de tecnologias detectadas.
|
||||||
|
|
||||||
|
Para entrada vacia (headers={}, html="") devuelve
|
||||||
|
{"technologies": [], "by_category": {}, "count": 0}. Nunca lanza.
|
||||||
|
"""
|
||||||
|
headers = {str(k).lower(): str(v) for k, v in (headers or {}).items()}
|
||||||
|
cookies = list(cookies or [])
|
||||||
|
html = html or ""
|
||||||
|
final_url = final_url or ""
|
||||||
|
|
||||||
|
# Pre-extrae senales derivadas del HTML una sola vez.
|
||||||
|
meta_generators = _META_GENERATOR_RE.findall(html)
|
||||||
|
script_srcs = _SCRIPT_SRC_RE.findall(html)
|
||||||
|
|
||||||
|
# name -> registro acumulado de la deteccion.
|
||||||
|
detected = {}
|
||||||
|
|
||||||
|
def _record(name, category, version, confidence, evidence):
|
||||||
|
prev = detected.get(name)
|
||||||
|
if prev is None:
|
||||||
|
detected[name] = {
|
||||||
|
"name": name,
|
||||||
|
"category": category,
|
||||||
|
"version": version or "",
|
||||||
|
"confidence": confidence,
|
||||||
|
"evidence": evidence,
|
||||||
|
}
|
||||||
|
return
|
||||||
|
# Dedup: combina. Mejor version no vacia y mejor confidence ganan.
|
||||||
|
if not prev["version"] and version:
|
||||||
|
prev["version"] = version
|
||||||
|
if confidence == "high" and prev["confidence"] != "high":
|
||||||
|
prev["confidence"] = "high"
|
||||||
|
prev["evidence"] = evidence
|
||||||
|
|
||||||
|
for sig in _COMPILED:
|
||||||
|
name = sig["name"]
|
||||||
|
category = sig["category"]
|
||||||
|
vgroup = sig.get("version_group", 0)
|
||||||
|
|
||||||
|
# ---- headers (high) ----
|
||||||
|
matched = False
|
||||||
|
if "headers" in sig:
|
||||||
|
for hkey, hre in sig["headers"].items():
|
||||||
|
val = headers.get(hkey)
|
||||||
|
if val is None:
|
||||||
|
continue
|
||||||
|
m = hre.search(val)
|
||||||
|
if m:
|
||||||
|
version = _version_from(m, vgroup)
|
||||||
|
_record(name, category, version, "high",
|
||||||
|
f"header {hkey}: {val}")
|
||||||
|
matched = True
|
||||||
|
break
|
||||||
|
|
||||||
|
# ---- meta generator (high) ----
|
||||||
|
if not matched and "meta_generator" in sig:
|
||||||
|
for gen in meta_generators:
|
||||||
|
m = sig["meta_generator"].search(gen)
|
||||||
|
if m:
|
||||||
|
version = _version_from(m, vgroup)
|
||||||
|
_record(name, category, version, "high",
|
||||||
|
f"meta generator: {gen}")
|
||||||
|
matched = True
|
||||||
|
break
|
||||||
|
|
||||||
|
# ---- cookies (high) ----
|
||||||
|
if not matched and "cookies" in sig:
|
||||||
|
for ck in cookies:
|
||||||
|
m = sig["cookies"].search(ck)
|
||||||
|
if m:
|
||||||
|
_record(name, category, "", "high", f"cookie: {ck}")
|
||||||
|
matched = True
|
||||||
|
break
|
||||||
|
|
||||||
|
# ---- url (high) ----
|
||||||
|
if not matched and "url" in sig and final_url:
|
||||||
|
m = sig["url"].search(final_url)
|
||||||
|
if m:
|
||||||
|
version = _version_from(m, vgroup)
|
||||||
|
_record(name, category, version, "high",
|
||||||
|
f"url: {final_url}")
|
||||||
|
matched = True
|
||||||
|
|
||||||
|
# ---- script src (medium) ----
|
||||||
|
if not matched and "script_src" in sig:
|
||||||
|
for src in script_srcs:
|
||||||
|
m = sig["script_src"].search(src)
|
||||||
|
if m:
|
||||||
|
version = _version_from(m, vgroup)
|
||||||
|
_record(name, category, version, "medium",
|
||||||
|
f"script src: {src}")
|
||||||
|
matched = True
|
||||||
|
break
|
||||||
|
|
||||||
|
# ---- html generico (medium) ----
|
||||||
|
if not matched and "html" in sig and html:
|
||||||
|
m = sig["html"].search(html)
|
||||||
|
if m:
|
||||||
|
version = _version_from(m, vgroup)
|
||||||
|
_record(name, category, version, "medium",
|
||||||
|
"html pattern")
|
||||||
|
matched = True
|
||||||
|
|
||||||
|
# ---- implies: anade tecnologias implicadas (confidence medium) ----
|
||||||
|
# Itera sobre una copia: si una tech directa implica otra, la implicada se
|
||||||
|
# anade solo si no estaba ya detectada directamente.
|
||||||
|
catalog = {sig["name"]: sig["category"] for sig in _COMPILED}
|
||||||
|
for sig in _COMPILED:
|
||||||
|
if sig["name"] not in detected or "implies" not in sig:
|
||||||
|
continue
|
||||||
|
for imp_name in sig["implies"]:
|
||||||
|
if imp_name in detected:
|
||||||
|
continue
|
||||||
|
imp_cat = catalog.get(imp_name, "unknown")
|
||||||
|
_record(imp_name, imp_cat, "", "medium",
|
||||||
|
f"implied by {sig['name']}")
|
||||||
|
|
||||||
|
technologies = sorted(
|
||||||
|
detected.values(), key=lambda t: (t["category"], t["name"])
|
||||||
|
)
|
||||||
|
|
||||||
|
by_category = {}
|
||||||
|
for tech in technologies:
|
||||||
|
by_category.setdefault(tech["category"], []).append(tech["name"])
|
||||||
|
|
||||||
|
return {
|
||||||
|
"technologies": technologies,
|
||||||
|
"by_category": by_category,
|
||||||
|
"count": len(technologies),
|
||||||
|
}
|
||||||
@@ -0,0 +1,108 @@
|
|||||||
|
"""Tests para detect_web_tech (deteccion de tecnologia web estilo Wappalyzer)."""
|
||||||
|
|
||||||
|
from detect_web_tech import detect_web_tech
|
||||||
|
|
||||||
|
|
||||||
|
def _names(result):
|
||||||
|
return {t["name"] for t in result["technologies"]}
|
||||||
|
|
||||||
|
|
||||||
|
def _by_name(result, name):
|
||||||
|
for t in result["technologies"]:
|
||||||
|
if t["name"] == name:
|
||||||
|
return t
|
||||||
|
return None
|
||||||
|
|
||||||
|
|
||||||
|
def test_nginx_por_header_con_version():
|
||||||
|
result = detect_web_tech({"server": "nginx/1.24.0"})
|
||||||
|
assert "nginx" in _names(result)
|
||||||
|
nginx = _by_name(result, "nginx")
|
||||||
|
assert nginx["version"] == "1.24.0"
|
||||||
|
assert nginx["category"] == "web-server"
|
||||||
|
assert nginx["confidence"] == "high"
|
||||||
|
|
||||||
|
|
||||||
|
def test_wordpress_por_html_y_meta_implica_php():
|
||||||
|
html = (
|
||||||
|
'<html><head>'
|
||||||
|
'<meta name="generator" content="WordPress 6.4">'
|
||||||
|
'</head><body>'
|
||||||
|
'<link href="/wp-content/themes/x/style.css">'
|
||||||
|
'</body></html>'
|
||||||
|
)
|
||||||
|
result = detect_web_tech({}, html=html)
|
||||||
|
names = _names(result)
|
||||||
|
assert "WordPress" in names
|
||||||
|
assert "PHP" in names # implied
|
||||||
|
wp = _by_name(result, "WordPress")
|
||||||
|
assert wp["version"] == "6.4"
|
||||||
|
assert wp["confidence"] == "high"
|
||||||
|
php = _by_name(result, "PHP")
|
||||||
|
assert php["confidence"] == "medium"
|
||||||
|
assert "implied by WordPress" in php["evidence"]
|
||||||
|
|
||||||
|
|
||||||
|
def test_php_por_cookie():
|
||||||
|
result = detect_web_tech({}, cookies=["PHPSESSID"])
|
||||||
|
php = _by_name(result, "PHP")
|
||||||
|
assert php is not None
|
||||||
|
assert php["category"] == "programming-language"
|
||||||
|
assert php["confidence"] == "high"
|
||||||
|
assert "PHPSESSID" in php["evidence"]
|
||||||
|
|
||||||
|
|
||||||
|
def test_cloudflare_por_header():
|
||||||
|
result = detect_web_tech({"server": "cloudflare", "cf-ray": "8a1b2c3d4e5f-MAD"})
|
||||||
|
cf = _by_name(result, "Cloudflare")
|
||||||
|
assert cf is not None
|
||||||
|
assert cf["category"] == "cdn"
|
||||||
|
assert cf["confidence"] == "high"
|
||||||
|
|
||||||
|
|
||||||
|
def test_entrada_vacia():
|
||||||
|
result = detect_web_tech({})
|
||||||
|
assert result["technologies"] == []
|
||||||
|
assert result["by_category"] == {}
|
||||||
|
assert result["count"] == 0
|
||||||
|
|
||||||
|
|
||||||
|
def test_entrada_vacia_explicita_headers_y_html():
|
||||||
|
result = detect_web_tech({}, html="", cookies=None, final_url="")
|
||||||
|
assert result["count"] == 0
|
||||||
|
assert result["technologies"] == []
|
||||||
|
|
||||||
|
|
||||||
|
def test_determinismo():
|
||||||
|
headers = {"server": "nginx/1.24.0", "x-powered-by": "PHP/8.2"}
|
||||||
|
html = '<meta name="generator" content="WordPress 6.4">wp-content'
|
||||||
|
a = detect_web_tech(headers, html=html, cookies=["PHPSESSID"])
|
||||||
|
b = detect_web_tech(headers, html=html, cookies=["PHPSESSID"])
|
||||||
|
assert a == b
|
||||||
|
|
||||||
|
|
||||||
|
def test_count_y_by_category_consistentes():
|
||||||
|
headers = {"server": "nginx/1.24.0"}
|
||||||
|
html = '<meta name="generator" content="WordPress 6.4">wp-content'
|
||||||
|
result = detect_web_tech(headers, html=html)
|
||||||
|
assert result["count"] == len(result["technologies"])
|
||||||
|
total_in_categories = sum(len(v) for v in result["by_category"].values())
|
||||||
|
assert total_in_categories == result["count"]
|
||||||
|
assert "nginx" in result["by_category"]["web-server"]
|
||||||
|
assert "WordPress" in result["by_category"]["cms"]
|
||||||
|
|
||||||
|
|
||||||
|
def test_headers_claves_mayusculas_se_normalizan():
|
||||||
|
result = detect_web_tech({"Server": "Apache/2.4.57"})
|
||||||
|
apache = _by_name(result, "Apache")
|
||||||
|
assert apache is not None
|
||||||
|
assert apache["version"] == "2.4.57"
|
||||||
|
|
||||||
|
|
||||||
|
def test_jquery_por_script_src_es_medium():
|
||||||
|
html = '<script src="/static/jquery-3.6.0.min.js"></script>'
|
||||||
|
result = detect_web_tech({}, html=html)
|
||||||
|
jq = _by_name(result, "jQuery")
|
||||||
|
assert jq is not None
|
||||||
|
assert jq["confidence"] == "medium"
|
||||||
|
assert jq["version"] == "3.6.0"
|
||||||
@@ -0,0 +1,77 @@
|
|||||||
|
---
|
||||||
|
name: dns_records
|
||||||
|
kind: function
|
||||||
|
lang: py
|
||||||
|
domain: cybersecurity
|
||||||
|
version: "2.0.0"
|
||||||
|
purity: impure
|
||||||
|
signature: "def dns_records(domain: str, record_types: list[str] | None = None, timeout_s: int = 20) -> dict"
|
||||||
|
description: "Recoleccion OSINT pasiva de registros DNS de un dominio ejecutando `dig +short <domain> <TYPE>` por subprocess para cada tipo (default A, AAAA, MX, NS, SOA, TXT, CNAME). Devuelve dict de estado {status, domain, records:{TYPE:[valores]}, raw} sin lanzar; `raw` concatena un bloque legible por tipo para guardar la evidencia en un vault OSINT. Pasivo: solo consulta DNS publico."
|
||||||
|
tags: [recon, dns, cybersecurity, osint-passive, dig]
|
||||||
|
params:
|
||||||
|
- name: domain
|
||||||
|
desc: "Dominio a resolver, ej. google.com. Vacio devuelve status error."
|
||||||
|
- name: record_types
|
||||||
|
desc: "Lista de tipos de registro DNS (ej. ['A','MX']). None usa los 7 defaults: A, AAAA, MX, NS, SOA, TXT, CNAME."
|
||||||
|
- name: timeout_s
|
||||||
|
desc: "Timeout total en segundos repartido entre las consultas (cada dig recibe timeout_s/N, minimo 2s)."
|
||||||
|
output: "dict de estado. En exito {status:'ok', domain, records:{TYPE:[valores]}, raw}: records es un dict por tipo con la lista de lineas de `dig +short` (lista vacia si no hay registro o el dominio no existe); raw es texto '=== TYPE ===\\n...' por cada tipo. En fallo {status:'error', error:str, raw:''}."
|
||||||
|
uses_functions: []
|
||||||
|
uses_types: []
|
||||||
|
returns: []
|
||||||
|
returns_optional: false
|
||||||
|
error_type: "error_py_core"
|
||||||
|
imports: []
|
||||||
|
tested: true
|
||||||
|
tests: ["test_parsea_salida_de_dig", "test_dominio_inexistente_listas_vacias", "test_usa_tipos_default", "test_timeout_devuelve_lista_vacia", "test_dominio_vacio_status_error"]
|
||||||
|
test_file_path: "python/functions/cybersecurity/dns_records_test.py"
|
||||||
|
file_path: "python/functions/cybersecurity/dns_records.py"
|
||||||
|
---
|
||||||
|
|
||||||
|
## Ejemplo
|
||||||
|
|
||||||
|
```python
|
||||||
|
import sys, os
|
||||||
|
sys.path.insert(0, os.path.join("python", "functions"))
|
||||||
|
from cybersecurity import dns_records
|
||||||
|
|
||||||
|
# Resolver todos los tipos por defecto
|
||||||
|
res = dns_records("google.com")
|
||||||
|
print(res["status"]) # "ok"
|
||||||
|
print(res["records"]["A"]) # ['142.250.x.x', ...]
|
||||||
|
print(res["records"]["MX"]) # ['10 smtp.google.com.', ...]
|
||||||
|
print(res["raw"]) # bloque "=== A ===\n...\n=== MX ===\n..." para el vault
|
||||||
|
|
||||||
|
# Solo los tipos que interesan
|
||||||
|
solo_a_mx = dns_records("google.com", record_types=["A", "MX"], timeout_s=10)
|
||||||
|
```
|
||||||
|
|
||||||
|
## Cuando usarla
|
||||||
|
|
||||||
|
Usala al iniciar el reconocimiento pasivo de un dominio para mapear su
|
||||||
|
infraestructura DNS publica (IPs, servidores de correo, nameservers, SOA, TXT
|
||||||
|
con SPF/DKIM/verificaciones). Es el primer paso barato antes de enumerar
|
||||||
|
subdominios o consultar RDAP. Guarda `raw` directamente en la nota OSINT como
|
||||||
|
evidencia.
|
||||||
|
|
||||||
|
## Gotchas
|
||||||
|
|
||||||
|
- Funcion impura: hace red (consulta DNS via `dig`). No determinista entre
|
||||||
|
resolvers ni en el tiempo (TTL, propagacion).
|
||||||
|
- Requiere el binario `dig` en PATH (paquete `dnsutils`/`bind-utils`). Si
|
||||||
|
falta, devuelve `{"status":"error",...}` (no lanza).
|
||||||
|
- Nunca lanza: errores se reportan en `status`. Un dominio inexistente o sin un
|
||||||
|
registro concreto devuelve `status:"ok"` con listas vacias — distingue
|
||||||
|
"sin datos" mirando las listas, no el status.
|
||||||
|
- La salida es la de `dig +short` cruda: los MX incluyen prioridad
|
||||||
|
("10 mail..."), los nombres pueden venir con punto final (FQDN), y los TXT
|
||||||
|
entre comillas. No se normaliza para mantener fidelidad.
|
||||||
|
- El `timeout_s` se reparte entre las N consultas (minimo 2s por consulta); si
|
||||||
|
una expira, esa clave queda como lista vacia, su bloque `raw` dice "(timeout)"
|
||||||
|
y el resto continua.
|
||||||
|
- Resuelve contra el resolver configurado en el sistema; resultados pueden
|
||||||
|
variar segun el DNS recursivo usado.
|
||||||
|
|
||||||
|
## Capability growth log
|
||||||
|
|
||||||
|
v2.0.0 (2026-06-14) — reescritura del contrato: firma `(domain, record_types, timeout_s)`, retorno dict de estado `{status, domain, records, raw}` sin lanzar (antes `{tipo:[valores]}` con RuntimeError), `raw` legible por tipo para vault OSINT, default amplia a NS+SOA, `error_type` pasa a `error_py_core`.
|
||||||
@@ -0,0 +1,106 @@
|
|||||||
|
"""Recoleccion OSINT pasiva de registros DNS via el binario `dig`.
|
||||||
|
|
||||||
|
Funcion IMPURA: para cada tipo de registro ejecuta `dig +short <domain> <TYPE>`
|
||||||
|
como subprocess y parsea la salida (una linea por valor). Es OSINT pasivo:
|
||||||
|
consulta DNS publico, no envia trafico intrusivo al objetivo.
|
||||||
|
|
||||||
|
Nunca lanza: devuelve un dict con `status` ("ok"/"error"). El campo `raw`
|
||||||
|
siempre esta presente para guardar la evidencia legible en un vault OSINT.
|
||||||
|
"""
|
||||||
|
|
||||||
|
import subprocess
|
||||||
|
|
||||||
|
DEFAULT_TYPES = ["A", "AAAA", "MX", "NS", "SOA", "TXT", "CNAME"]
|
||||||
|
|
||||||
|
|
||||||
|
def dns_records(
|
||||||
|
domain: str,
|
||||||
|
record_types: list[str] | None = None,
|
||||||
|
timeout_s: int = 20,
|
||||||
|
) -> dict:
|
||||||
|
"""Resuelve registros DNS de un dominio ejecutando `dig +short`.
|
||||||
|
|
||||||
|
Para cada tipo en ``record_types`` ejecuta ``dig +short <domain> <TYPE>``
|
||||||
|
y parsea la salida: cada linea no vacia es un valor del registro. Un
|
||||||
|
dominio o registro inexistente produce una lista vacia (no error).
|
||||||
|
|
||||||
|
Args:
|
||||||
|
domain: Dominio a resolver (ej. ``"google.com"``).
|
||||||
|
record_types: Lista de tipos de registro DNS (ej. ``["A", "MX"]``).
|
||||||
|
None usa los defaults: A, AAAA, MX, NS, SOA, TXT, CNAME.
|
||||||
|
timeout_s: Timeout total en segundos repartido entre las consultas
|
||||||
|
(cada `dig` recibe una porcion del presupuesto, minimo 2s).
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
Dict de estado. En exito::
|
||||||
|
|
||||||
|
{
|
||||||
|
"status": "ok",
|
||||||
|
"domain": <domain>,
|
||||||
|
"records": {"A": [...], "MX": [...], ...},
|
||||||
|
"raw": "=== A ===\\n...\\n=== MX ===\\n...",
|
||||||
|
}
|
||||||
|
|
||||||
|
En fallo (binario ausente, dominio vacio)::
|
||||||
|
|
||||||
|
{"status": "error", "error": <str>, "raw": ""}
|
||||||
|
"""
|
||||||
|
if not domain or not domain.strip():
|
||||||
|
return {"status": "error", "error": "dns_records: domain vacio", "raw": ""}
|
||||||
|
|
||||||
|
domain = domain.strip()
|
||||||
|
query_types = record_types if record_types is not None else list(DEFAULT_TYPES)
|
||||||
|
per_query_timeout = max(2.0, float(timeout_s) / max(1, len(query_types)))
|
||||||
|
|
||||||
|
records: dict[str, list[str]] = {}
|
||||||
|
raw_parts: list[str] = []
|
||||||
|
|
||||||
|
for record_type in query_types:
|
||||||
|
rt = record_type.strip().upper()
|
||||||
|
try:
|
||||||
|
proc = subprocess.run(
|
||||||
|
["dig", "+short", domain, rt],
|
||||||
|
capture_output=True,
|
||||||
|
text=True,
|
||||||
|
timeout=per_query_timeout,
|
||||||
|
)
|
||||||
|
values = [
|
||||||
|
line.strip()
|
||||||
|
for line in proc.stdout.splitlines()
|
||||||
|
if line.strip()
|
||||||
|
]
|
||||||
|
section_body = proc.stdout.rstrip("\n") if proc.stdout.strip() else "(sin registros)"
|
||||||
|
except FileNotFoundError:
|
||||||
|
return {
|
||||||
|
"status": "error",
|
||||||
|
"error": "dns_records: binario `dig` no encontrado en PATH (paquete dnsutils)",
|
||||||
|
"raw": "",
|
||||||
|
}
|
||||||
|
except subprocess.TimeoutExpired:
|
||||||
|
values = []
|
||||||
|
section_body = "(timeout)"
|
||||||
|
|
||||||
|
records[rt] = values
|
||||||
|
raw_parts.append(f"=== {rt} ===\n{section_body}")
|
||||||
|
|
||||||
|
return {
|
||||||
|
"status": "ok",
|
||||||
|
"domain": domain,
|
||||||
|
"records": records,
|
||||||
|
"raw": "\n".join(raw_parts),
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
try:
|
||||||
|
result = dns_records("google.com", record_types=["A", "MX", "NS", "TXT"])
|
||||||
|
print(result["status"])
|
||||||
|
if result["status"] == "ok":
|
||||||
|
print("A:", result["records"].get("A"))
|
||||||
|
print("MX:", result["records"].get("MX"))
|
||||||
|
print("--- raw ---")
|
||||||
|
print(result["raw"])
|
||||||
|
else:
|
||||||
|
print("error:", result.get("error"))
|
||||||
|
except Exception as exc: # smoke: tolera cualquier fallo de red sin romper
|
||||||
|
print("smoke fallo (tolerado):", exc)
|
||||||
@@ -0,0 +1,99 @@
|
|||||||
|
"""Tests para dns_records."""
|
||||||
|
|
||||||
|
import os
|
||||||
|
import subprocess
|
||||||
|
import sys
|
||||||
|
|
||||||
|
sys.path.insert(0, os.path.dirname(__file__))
|
||||||
|
|
||||||
|
from dns_records import dns_records
|
||||||
|
|
||||||
|
|
||||||
|
class _FakeProc:
|
||||||
|
def __init__(self, stdout: str):
|
||||||
|
self.stdout = stdout
|
||||||
|
self.returncode = 0
|
||||||
|
|
||||||
|
|
||||||
|
def test_parsea_salida_de_dig(monkeypatch):
|
||||||
|
"""Cada linea no vacia de dig se convierte en un valor del registro."""
|
||||||
|
fixtures = {
|
||||||
|
"A": "135.125.201.30\n",
|
||||||
|
"MX": "10 mail.organic-machine.com.\n20 mail2.organic-machine.com.\n",
|
||||||
|
"TXT": '"v=spf1 -all"\n',
|
||||||
|
}
|
||||||
|
|
||||||
|
def fake_run(cmd, **kwargs):
|
||||||
|
# cmd = ["dig", "+short", domain, TYPE]
|
||||||
|
record_type = cmd[3]
|
||||||
|
return _FakeProc(fixtures.get(record_type, ""))
|
||||||
|
|
||||||
|
monkeypatch.setattr(subprocess, "run", fake_run)
|
||||||
|
|
||||||
|
res = dns_records("organic-machine.com", record_types=["A", "MX", "TXT"])
|
||||||
|
|
||||||
|
assert res["status"] == "ok"
|
||||||
|
assert res["domain"] == "organic-machine.com"
|
||||||
|
assert res["records"]["A"] == ["135.125.201.30"]
|
||||||
|
assert res["records"]["MX"] == [
|
||||||
|
"10 mail.organic-machine.com.",
|
||||||
|
"20 mail2.organic-machine.com.",
|
||||||
|
]
|
||||||
|
assert res["records"]["TXT"] == ['"v=spf1 -all"']
|
||||||
|
assert "=== A ===" in res["raw"]
|
||||||
|
assert "=== MX ===" in res["raw"]
|
||||||
|
|
||||||
|
|
||||||
|
def test_dominio_inexistente_listas_vacias(monkeypatch):
|
||||||
|
"""Salida vacia de dig (dominio inexistente) produce listas vacias, status ok."""
|
||||||
|
|
||||||
|
def fake_run(cmd, **kwargs):
|
||||||
|
return _FakeProc("")
|
||||||
|
|
||||||
|
monkeypatch.setattr(subprocess, "run", fake_run)
|
||||||
|
|
||||||
|
res = dns_records("nope-no-existe-xyz.invalid", record_types=["A", "AAAA"])
|
||||||
|
|
||||||
|
assert res["status"] == "ok"
|
||||||
|
assert res["records"] == {"A": [], "AAAA": []}
|
||||||
|
assert "(sin registros)" in res["raw"]
|
||||||
|
|
||||||
|
|
||||||
|
def test_usa_tipos_default(monkeypatch):
|
||||||
|
"""Sin record_types consulta los 7 tipos por defecto."""
|
||||||
|
consultados = []
|
||||||
|
|
||||||
|
def fake_run(cmd, **kwargs):
|
||||||
|
consultados.append(cmd[3])
|
||||||
|
return _FakeProc("")
|
||||||
|
|
||||||
|
monkeypatch.setattr(subprocess, "run", fake_run)
|
||||||
|
|
||||||
|
res = dns_records("organic-machine.com")
|
||||||
|
|
||||||
|
assert res["status"] == "ok"
|
||||||
|
assert set(res["records"].keys()) == {"A", "AAAA", "MX", "NS", "SOA", "TXT", "CNAME"}
|
||||||
|
assert consultados == ["A", "AAAA", "MX", "NS", "SOA", "TXT", "CNAME"]
|
||||||
|
|
||||||
|
|
||||||
|
def test_timeout_devuelve_lista_vacia(monkeypatch):
|
||||||
|
"""Un timeout en una consulta deja lista vacia y no aborta el resto."""
|
||||||
|
|
||||||
|
def fake_run(cmd, **kwargs):
|
||||||
|
raise subprocess.TimeoutExpired(cmd, 10.0)
|
||||||
|
|
||||||
|
monkeypatch.setattr(subprocess, "run", fake_run)
|
||||||
|
|
||||||
|
res = dns_records("organic-machine.com", record_types=["A"])
|
||||||
|
|
||||||
|
assert res["status"] == "ok"
|
||||||
|
assert res["records"] == {"A": []}
|
||||||
|
assert "(timeout)" in res["raw"]
|
||||||
|
|
||||||
|
|
||||||
|
def test_dominio_vacio_status_error():
|
||||||
|
"""Dominio vacio devuelve status error sin lanzar."""
|
||||||
|
res = dns_records("")
|
||||||
|
assert res["status"] == "error"
|
||||||
|
assert res["raw"] == ""
|
||||||
|
assert "domain" in res["error"]
|
||||||
@@ -0,0 +1,52 @@
|
|||||||
|
---
|
||||||
|
name: enrich_org_passive
|
||||||
|
kind: function
|
||||||
|
lang: py
|
||||||
|
domain: cybersecurity
|
||||||
|
version: "1.0.0"
|
||||||
|
purity: impure
|
||||||
|
signature: "def enrich_org_passive(dominio: str) -> dict"
|
||||||
|
description: "Orquestador OSINT pasivo: perfil de una organizacion por su dominio usando solo fuentes publicas. Compone whois_lookup (registro WHOIS), dns_records (registros DNS) y enum_subdomains_crtsh (subdominios via Certificate Transparency / crt.sh). Devuelve whois + dns + subdomains."
|
||||||
|
tags: [osint-enrich, osint-passive, cybersecurity, org, whois, dns, subdomains]
|
||||||
|
uses_functions: [whois_lookup_py_cybersecurity, dns_records_py_cybersecurity, enum_subdomains_crtsh_py_cybersecurity]
|
||||||
|
uses_types: []
|
||||||
|
returns: []
|
||||||
|
returns_optional: false
|
||||||
|
error_type: "error_go_core"
|
||||||
|
imports: []
|
||||||
|
params:
|
||||||
|
- name: dominio
|
||||||
|
desc: "dominio de la organizacion, p.ej. organic-machine.com. No puede estar vacio (ValueError). Se hace strip de espacios"
|
||||||
|
output: "dict con whois (salida de whois_lookup(dominio)), dns (salida de dns_records(dominio)) y subdomains (salida de enum_subdomains_crtsh(dominio))"
|
||||||
|
tested: true
|
||||||
|
tests: ["test_golden_compone_whois_dns_subdomains", "test_dominio_vacio_lanza", "test_dominio_con_espacios_se_normaliza"]
|
||||||
|
test_file_path: "python/functions/cybersecurity/enrich_org_passive_test.py"
|
||||||
|
file_path: "python/functions/cybersecurity/enrich_org_passive.py"
|
||||||
|
---
|
||||||
|
|
||||||
|
## Ejemplo
|
||||||
|
|
||||||
|
```python
|
||||||
|
import sys, os
|
||||||
|
sys.path.insert(0, os.path.join("python", "functions"))
|
||||||
|
from cybersecurity import enrich_org_passive
|
||||||
|
|
||||||
|
res = enrich_org_passive("organic-machine.com")
|
||||||
|
|
||||||
|
print(res["whois"]["registrar"]) # registrar segun WHOIS
|
||||||
|
print(res["dns"].get("A")) # registros A del dominio
|
||||||
|
print(res["subdomains"][:5]) # primeros subdominios vistos en crt.sh
|
||||||
|
```
|
||||||
|
|
||||||
|
## Cuando usarla
|
||||||
|
|
||||||
|
- Cuando arrancas la ficha OSINT de una organizacion y quieres su perfil pasivo (quien registro el dominio, su DNS y su superficie de subdominios) sin enviar trafico ofensivo a la infraestructura.
|
||||||
|
- Como reconocimiento previo, completamente pasivo, antes de cualquier evaluacion activa (que requeriria otra autorizacion explicita).
|
||||||
|
- Para mapear de un golpe la huella publica de un dominio: WHOIS + DNS + Certificate Transparency en una sola llamada.
|
||||||
|
|
||||||
|
## Gotchas
|
||||||
|
|
||||||
|
- **Uso solo para investigacion autorizada.** El reconocimiento de infraestructura ajena debe contar con permiso del propietario o ampararse en una base legitima.
|
||||||
|
- Funcion IMPURA: hace consultas de red (WHOIS, DNS y HTTP a crt.sh). Es pasiva respecto al objetivo (no le envia trafico ofensivo), pero **deja huella** en los servicios consultados (logs de crt.sh, resolvers DNS, servidores WHOIS).
|
||||||
|
- La salida depende de la disponibilidad y rate-limiting de las fuentes: WHOIS puede venir truncado/redactado (GDPR), crt.sh puede limitar o tardar, y algunos registros DNS pueden no existir. Maneja claves ausentes en el dict resultante.
|
||||||
|
- crt.sh solo ve subdominios que hayan emitido certificados TLS publicos: la lista NO es exhaustiva (no incluye subdominios sin cert o con certs privados).
|
||||||
@@ -0,0 +1,47 @@
|
|||||||
|
"""Orquestador OSINT pasivo: perfil de una organizacion por su dominio.
|
||||||
|
|
||||||
|
Compone funciones atomicas del registro (`whois_lookup`, `dns_records`,
|
||||||
|
`enum_subdomains_crtsh`) para construir un perfil pasivo de una organizacion a
|
||||||
|
partir de su dominio, usando solo fuentes publicas (WHOIS, DNS, Certificate
|
||||||
|
Transparency via crt.sh) sin contactar directamente con la infraestructura del
|
||||||
|
objetivo mas alla de las consultas DNS estandar.
|
||||||
|
|
||||||
|
Funcion IMPURA: hace consultas de red (WHOIS, DNS, HTTP a crt.sh).
|
||||||
|
"""
|
||||||
|
|
||||||
|
from cybersecurity import dns_records, enum_subdomains_crtsh, whois_lookup
|
||||||
|
|
||||||
|
|
||||||
|
def enrich_org_passive(dominio: str) -> dict:
|
||||||
|
"""Construye un perfil OSINT pasivo de una organizacion por su dominio.
|
||||||
|
|
||||||
|
Agrega tres fuentes publicas: el registro WHOIS del dominio, sus registros
|
||||||
|
DNS y los subdominios observados en Certificate Transparency (crt.sh).
|
||||||
|
|
||||||
|
Args:
|
||||||
|
dominio: dominio de la organizacion, p.ej. `organic-machine.com`.
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
dict con las claves:
|
||||||
|
- whois: salida de whois_lookup(dominio).
|
||||||
|
- dns: salida de dns_records(dominio).
|
||||||
|
- subdomains: salida de enum_subdomains_crtsh(dominio).
|
||||||
|
|
||||||
|
Raises:
|
||||||
|
ValueError: si el dominio esta vacio.
|
||||||
|
"""
|
||||||
|
if not (dominio or "").strip():
|
||||||
|
raise ValueError("dominio no puede estar vacio")
|
||||||
|
|
||||||
|
dominio = dominio.strip()
|
||||||
|
|
||||||
|
# Resiliente a fallo parcial: si una fuente externa falla (p.ej. crt.sh lento o
|
||||||
|
# rate-limitado), se registra el error y se devuelven las demas igualmente.
|
||||||
|
result = {"whois": {}, "dns": {}, "subdomains": [], "errors": {}}
|
||||||
|
for key, fn in (("whois", whois_lookup), ("dns", dns_records),
|
||||||
|
("subdomains", enum_subdomains_crtsh)):
|
||||||
|
try:
|
||||||
|
result[key] = fn(dominio)
|
||||||
|
except Exception as exc: # noqa: BLE001 — captura amplia a proposito por fuente
|
||||||
|
result["errors"][key] = f"{type(exc).__name__}: {exc}"
|
||||||
|
return result
|
||||||
@@ -0,0 +1,87 @@
|
|||||||
|
"""Tests para enrich_org_passive.
|
||||||
|
|
||||||
|
Las funciones compuestas (whois_lookup, dns_records, enum_subdomains_crtsh) se
|
||||||
|
monkeypatchean para no tocar la red. Solo se verifica la orquestacion: que
|
||||||
|
ensambla las tres fuentes bajo las claves correctas y normaliza el dominio.
|
||||||
|
"""
|
||||||
|
|
||||||
|
import importlib
|
||||||
|
import os
|
||||||
|
import sys
|
||||||
|
|
||||||
|
import pytest
|
||||||
|
|
||||||
|
sys.path.insert(0, os.path.join(os.path.dirname(__file__), "..", ".."))
|
||||||
|
|
||||||
|
from cybersecurity import enrich_org_passive
|
||||||
|
|
||||||
|
# El paquete re-exporta la funcion bajo el mismo nombre que el submodulo; para
|
||||||
|
# parchear los globals del modulo lo tomamos via importlib.
|
||||||
|
mod = importlib.import_module("cybersecurity.enrich_org_passive")
|
||||||
|
|
||||||
|
|
||||||
|
def test_golden_compone_whois_dns_subdomains(monkeypatch):
|
||||||
|
monkeypatch.setattr(mod, "whois_lookup", lambda d: {"domain": d, "registrar": "OVH"})
|
||||||
|
monkeypatch.setattr(mod, "dns_records", lambda d: {"A": ["1.2.3.4"], "MX": ["mx.x"]})
|
||||||
|
monkeypatch.setattr(mod, "enum_subdomains_crtsh", lambda d: [f"www.{d}", f"api.{d}"])
|
||||||
|
|
||||||
|
res = enrich_org_passive("organic-machine.com")
|
||||||
|
|
||||||
|
assert res["whois"] == {"domain": "organic-machine.com", "registrar": "OVH"}
|
||||||
|
assert res["dns"] == {"A": ["1.2.3.4"], "MX": ["mx.x"]}
|
||||||
|
assert res["subdomains"] == ["www.organic-machine.com", "api.organic-machine.com"]
|
||||||
|
assert set(res.keys()) == {"whois", "dns", "subdomains", "errors"}
|
||||||
|
assert res["errors"] == {} # sin fallos cuando todas las fuentes responden
|
||||||
|
|
||||||
|
|
||||||
|
def test_fuente_que_falla_se_captura_en_errors(monkeypatch):
|
||||||
|
"""Si una fuente externa peta (p.ej. crt.sh), se registra en errors y el resto se devuelve."""
|
||||||
|
monkeypatch.setattr(mod, "whois_lookup", lambda d: {"registrar": "OVH"})
|
||||||
|
monkeypatch.setattr(mod, "dns_records", lambda d: {"A": ["1.2.3.4"]})
|
||||||
|
|
||||||
|
def boom(d):
|
||||||
|
raise RuntimeError("crt.sh 404")
|
||||||
|
monkeypatch.setattr(mod, "enum_subdomains_crtsh", boom)
|
||||||
|
|
||||||
|
res = enrich_org_passive("organic-machine.com")
|
||||||
|
assert res["whois"] == {"registrar": "OVH"}
|
||||||
|
assert res["dns"] == {"A": ["1.2.3.4"]}
|
||||||
|
assert res["subdomains"] == []
|
||||||
|
assert "subdomains" in res["errors"] and "crt.sh 404" in res["errors"]["subdomains"]
|
||||||
|
|
||||||
|
|
||||||
|
def test_dominio_vacio_lanza(monkeypatch):
|
||||||
|
monkeypatch.setattr(mod, "whois_lookup", lambda d: {})
|
||||||
|
monkeypatch.setattr(mod, "dns_records", lambda d: {})
|
||||||
|
monkeypatch.setattr(mod, "enum_subdomains_crtsh", lambda d: [])
|
||||||
|
with pytest.raises(ValueError):
|
||||||
|
enrich_org_passive(" ")
|
||||||
|
|
||||||
|
|
||||||
|
def test_dominio_con_espacios_se_normaliza(monkeypatch):
|
||||||
|
seen = {}
|
||||||
|
|
||||||
|
def whois(d):
|
||||||
|
seen["whois"] = d
|
||||||
|
return {}
|
||||||
|
|
||||||
|
def dns(d):
|
||||||
|
seen["dns"] = d
|
||||||
|
return {}
|
||||||
|
|
||||||
|
def subs(d):
|
||||||
|
seen["subs"] = d
|
||||||
|
return []
|
||||||
|
|
||||||
|
monkeypatch.setattr(mod, "whois_lookup", whois)
|
||||||
|
monkeypatch.setattr(mod, "dns_records", dns)
|
||||||
|
monkeypatch.setattr(mod, "enum_subdomains_crtsh", subs)
|
||||||
|
|
||||||
|
enrich_org_passive(" organic-machine.com ")
|
||||||
|
|
||||||
|
# Las tres funciones reciben el dominio ya sin espacios.
|
||||||
|
assert seen == {
|
||||||
|
"whois": "organic-machine.com",
|
||||||
|
"dns": "organic-machine.com",
|
||||||
|
"subs": "organic-machine.com",
|
||||||
|
}
|
||||||
@@ -0,0 +1,64 @@
|
|||||||
|
---
|
||||||
|
name: enrich_person_passive
|
||||||
|
kind: function
|
||||||
|
lang: py
|
||||||
|
domain: cybersecurity
|
||||||
|
version: "1.0.0"
|
||||||
|
purity: impure
|
||||||
|
signature: "def enrich_person_passive(nombre: str, apellidos: str, dominios: list | None = None, usernames: list | None = None) -> dict"
|
||||||
|
description: "Orquestador OSINT pasivo: genera candidatos de enriquecimiento de una persona SIN tocar al objetivo. Compone guess_email_formats (emails candidatos por cada dominio dado, o gmail/outlook por defecto), enumerate_username_sites (comprobacion de usernames en servicios publicos) y build_search_dorks (dorks tipo persona, que NO se ejecutan, solo se generan)."
|
||||||
|
tags: [osint-enrich, osint-passive, cybersecurity, person, email, username, dorks]
|
||||||
|
uses_functions: [guess_email_formats_py_cybersecurity, enumerate_username_sites_py_cybersecurity, build_search_dorks_py_cybersecurity]
|
||||||
|
uses_types: []
|
||||||
|
returns: []
|
||||||
|
returns_optional: false
|
||||||
|
error_type: "error_go_core"
|
||||||
|
imports: []
|
||||||
|
params:
|
||||||
|
- name: nombre
|
||||||
|
desc: "nombre de pila de la persona"
|
||||||
|
- name: apellidos
|
||||||
|
desc: "apellido(s) de la persona. nombre y apellidos no pueden estar ambos vacios (ValueError)"
|
||||||
|
- name: dominios
|
||||||
|
desc: "lista de dominios de correo donde generar formatos de email candidatos. None o vacia => usa los dominios comunes gmail.com/outlook.com"
|
||||||
|
- name: usernames
|
||||||
|
desc: "lista de usernames a comprobar en sitios publicos via enumerate_username_sites. None o vacia => no se comprueba ningun username"
|
||||||
|
output: "dict con email_candidates (lista de emails candidatos NO verificados, deduplicada y ordenada), username_hits (lista de {username, hits} con el resultado de enumerate_username_sites por username) y dorks (lista de dorks de busqueda tipo persona generados pero NO ejecutados)"
|
||||||
|
tested: true
|
||||||
|
tests: ["test_golden_compone_emails_usernames_y_dorks", "test_sin_dominios_usa_comunes", "test_sin_usernames_no_comprueba", "test_nombre_y_apellidos_vacios_lanza", "test_emails_deduplicados"]
|
||||||
|
test_file_path: "python/functions/cybersecurity/enrich_person_passive_test.py"
|
||||||
|
file_path: "python/functions/cybersecurity/enrich_person_passive.py"
|
||||||
|
---
|
||||||
|
|
||||||
|
## Ejemplo
|
||||||
|
|
||||||
|
```python
|
||||||
|
import sys, os
|
||||||
|
sys.path.insert(0, os.path.join("python", "functions"))
|
||||||
|
from cybersecurity import enrich_person_passive
|
||||||
|
|
||||||
|
res = enrich_person_passive(
|
||||||
|
"Juan", "Perez",
|
||||||
|
dominios=["organic-machine.com"],
|
||||||
|
usernames=["jperez", "juanp"],
|
||||||
|
)
|
||||||
|
|
||||||
|
print(res["email_candidates"]) # ['juan.perez@organic-machine.com', 'jperez@organic-machine.com', ...]
|
||||||
|
for u in res["username_hits"]:
|
||||||
|
print(u["username"], len(u["hits"])) # sitios publicos donde aparece cada username
|
||||||
|
print(res["dorks"]) # dorks listos para pegar en un buscador (no se ejecutan aqui)
|
||||||
|
```
|
||||||
|
|
||||||
|
## Cuando usarla
|
||||||
|
|
||||||
|
- Cuando arrancas la ficha OSINT de una persona y quieres una primera tanda de candidatos (emails probables, presencia de usernames, dorks) sin enviar nada al objetivo ni a su correo.
|
||||||
|
- Antes de verificar manualmente: genera el espacio de candidatos para que despues confirmes cuales son reales.
|
||||||
|
- Como paso pasivo previo a cualquier accion activa (que requeriria otra autorizacion y otras funciones).
|
||||||
|
|
||||||
|
## Gotchas
|
||||||
|
|
||||||
|
- **Uso solo para investigacion autorizada.** Generar candidatos sobre una persona sin base legitima puede vulnerar privacidad/leyes de proteccion de datos.
|
||||||
|
- Los `email_candidates` son **candidatos NO verificados**: son permutaciones plausibles del nombre, NO emails confirmados. No asumas que existen ni los uses para envio.
|
||||||
|
- Funcion IMPURA: `enumerate_username_sites` consulta servicios publicos por red, lo que **deja una huella minima** (requests a esos sitios). `build_search_dorks` y `guess_email_formats` son locales.
|
||||||
|
- Los dorks se **generan pero NO se ejecutan** aqui: ejecutarlos en un buscador es un paso aparte y deja su propia huella.
|
||||||
|
- Si no aportas `dominios`, se usan gmail.com/outlook.com como heuristica; ajusta la lista a los dominios reales del entorno de la persona para candidatos utiles.
|
||||||
@@ -0,0 +1,79 @@
|
|||||||
|
"""Orquestador OSINT pasivo: genera candidatos de enriquecimiento de una persona.
|
||||||
|
|
||||||
|
Compone funciones atomicas del registro (`guess_email_formats`,
|
||||||
|
`enumerate_username_sites`, `build_search_dorks`) para producir candidatos OSINT
|
||||||
|
de una persona SIN contactar ni atacar al objetivo. Los dorks se generan pero
|
||||||
|
NO se ejecutan.
|
||||||
|
|
||||||
|
Funcion IMPURA: `enumerate_username_sites` consulta servicios publicos (red).
|
||||||
|
"""
|
||||||
|
|
||||||
|
from cybersecurity import (
|
||||||
|
build_search_dorks,
|
||||||
|
enumerate_username_sites,
|
||||||
|
guess_email_formats,
|
||||||
|
)
|
||||||
|
|
||||||
|
# Dominios de correo comunes usados cuando el caller no aporta dominios propios.
|
||||||
|
_COMMON_EMAIL_DOMAINS = ("gmail.com", "outlook.com")
|
||||||
|
|
||||||
|
|
||||||
|
def enrich_person_passive(
|
||||||
|
nombre: str,
|
||||||
|
apellidos: str,
|
||||||
|
dominios: list | None = None,
|
||||||
|
usernames: list | None = None,
|
||||||
|
) -> dict:
|
||||||
|
"""Genera candidatos OSINT pasivos para una persona.
|
||||||
|
|
||||||
|
Para cada dominio aportado (o los dominios comunes gmail/outlook si no se da
|
||||||
|
ninguno) genera los formatos de email candidatos. Para cada username
|
||||||
|
aportado comprueba en que sitios publicos existe. Ademas genera dorks de
|
||||||
|
busqueda de tipo persona (que NO se ejecutan, solo se devuelven).
|
||||||
|
|
||||||
|
Args:
|
||||||
|
nombre: nombre de pila de la persona.
|
||||||
|
apellidos: apellido(s) de la persona.
|
||||||
|
dominios: lista de dominios de correo donde buscar formatos de email.
|
||||||
|
Si es None o vacia, se usan los dominios comunes gmail/outlook.
|
||||||
|
usernames: lista de usernames a comprobar en sitios publicos. Si es None
|
||||||
|
o vacia, no se realiza ninguna comprobacion de username.
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
dict con las claves:
|
||||||
|
- email_candidates: lista de emails candidatos (no verificados).
|
||||||
|
- username_hits: lista de resultados de enumerate_username_sites por
|
||||||
|
cada username comprobado.
|
||||||
|
- dorks: lista de dorks de busqueda generados (no ejecutados).
|
||||||
|
|
||||||
|
Raises:
|
||||||
|
ValueError: si nombre y apellidos estan ambos vacios.
|
||||||
|
"""
|
||||||
|
if not (nombre or "").strip() and not (apellidos or "").strip():
|
||||||
|
raise ValueError("nombre y apellidos no pueden estar ambos vacios")
|
||||||
|
|
||||||
|
target_domains = [d for d in (dominios or []) if d] or list(_COMMON_EMAIL_DOMAINS)
|
||||||
|
|
||||||
|
email_candidates: list = []
|
||||||
|
for dominio in target_domains:
|
||||||
|
candidates = guess_email_formats(nombre, apellidos, dominio)
|
||||||
|
if candidates:
|
||||||
|
email_candidates.extend(candidates)
|
||||||
|
# Deduplicar preservando orden.
|
||||||
|
email_candidates = list(dict.fromkeys(email_candidates))
|
||||||
|
|
||||||
|
username_hits: list = []
|
||||||
|
for username in usernames or []:
|
||||||
|
if not username:
|
||||||
|
continue
|
||||||
|
hits = enumerate_username_sites(username)
|
||||||
|
username_hits.append({"username": username, "hits": hits})
|
||||||
|
|
||||||
|
target = f"{nombre} {apellidos}".strip()
|
||||||
|
dorks = build_search_dorks(target, "persona")
|
||||||
|
|
||||||
|
return {
|
||||||
|
"email_candidates": email_candidates,
|
||||||
|
"username_hits": username_hits,
|
||||||
|
"dorks": dorks,
|
||||||
|
}
|
||||||
@@ -0,0 +1,97 @@
|
|||||||
|
"""Tests para enrich_person_passive.
|
||||||
|
|
||||||
|
Las funciones compuestas (guess_email_formats, enumerate_username_sites,
|
||||||
|
build_search_dorks) se monkeypatchean. Solo se verifica la orquestacion: que
|
||||||
|
itera por dominios/usernames, deduplica emails, usa dominios comunes por
|
||||||
|
defecto y genera (sin ejecutar) los dorks.
|
||||||
|
"""
|
||||||
|
|
||||||
|
import importlib
|
||||||
|
import os
|
||||||
|
import sys
|
||||||
|
|
||||||
|
import pytest
|
||||||
|
|
||||||
|
sys.path.insert(0, os.path.join(os.path.dirname(__file__), "..", ".."))
|
||||||
|
|
||||||
|
from cybersecurity import enrich_person_passive
|
||||||
|
|
||||||
|
# El paquete re-exporta la funcion bajo el mismo nombre que el submodulo; para
|
||||||
|
# parchear los globals del modulo lo tomamos via importlib.
|
||||||
|
mod = importlib.import_module("cybersecurity.enrich_person_passive")
|
||||||
|
|
||||||
|
|
||||||
|
def _patch(monkeypatch, *, emails_fn=None, usernames_fn=None, dorks_fn=None):
|
||||||
|
monkeypatch.setattr(
|
||||||
|
mod,
|
||||||
|
"guess_email_formats",
|
||||||
|
emails_fn or (lambda n, a, d: [f"{n.lower()}.{a.lower()}@{d}", f"{n[0].lower()}{a.lower()}@{d}"]),
|
||||||
|
)
|
||||||
|
monkeypatch.setattr(
|
||||||
|
mod,
|
||||||
|
"enumerate_username_sites",
|
||||||
|
usernames_fn or (lambda u: [{"site": "github", "url": f"https://github.com/{u}", "exists": True}]),
|
||||||
|
)
|
||||||
|
monkeypatch.setattr(
|
||||||
|
mod,
|
||||||
|
"build_search_dorks",
|
||||||
|
dorks_fn or (lambda target, tipo: [f'"{target}" {tipo}', f'intext:"{target}"']),
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def test_golden_compone_emails_usernames_y_dorks(monkeypatch):
|
||||||
|
_patch(monkeypatch)
|
||||||
|
|
||||||
|
res = enrich_person_passive(
|
||||||
|
"Juan", "Perez",
|
||||||
|
dominios=["organic-machine.com"],
|
||||||
|
usernames=["jperez", "juanp"],
|
||||||
|
)
|
||||||
|
|
||||||
|
assert res["email_candidates"] == [
|
||||||
|
"juan.perez@organic-machine.com",
|
||||||
|
"jperez@organic-machine.com",
|
||||||
|
]
|
||||||
|
assert [u["username"] for u in res["username_hits"]] == ["jperez", "juanp"]
|
||||||
|
assert res["username_hits"][0]["hits"][0]["site"] == "github"
|
||||||
|
assert res["dorks"] == ['"Juan Perez" persona', 'intext:"Juan Perez"']
|
||||||
|
|
||||||
|
|
||||||
|
def test_sin_dominios_usa_comunes(monkeypatch):
|
||||||
|
seen_domains = []
|
||||||
|
|
||||||
|
def emails(n, a, d):
|
||||||
|
seen_domains.append(d)
|
||||||
|
return [f"{n}@{d}"]
|
||||||
|
|
||||||
|
_patch(monkeypatch, emails_fn=emails)
|
||||||
|
|
||||||
|
res = enrich_person_passive("Ana", "Lopez")
|
||||||
|
|
||||||
|
assert seen_domains == ["gmail.com", "outlook.com"]
|
||||||
|
assert res["email_candidates"] == ["Ana@gmail.com", "Ana@outlook.com"]
|
||||||
|
|
||||||
|
|
||||||
|
def test_sin_usernames_no_comprueba(monkeypatch):
|
||||||
|
called = []
|
||||||
|
_patch(monkeypatch, usernames_fn=lambda u: called.append(u) or [])
|
||||||
|
|
||||||
|
res = enrich_person_passive("Ana", "Lopez", dominios=["x.com"])
|
||||||
|
|
||||||
|
assert res["username_hits"] == []
|
||||||
|
assert called == [] # enumerate_username_sites no se invoca
|
||||||
|
|
||||||
|
|
||||||
|
def test_nombre_y_apellidos_vacios_lanza(monkeypatch):
|
||||||
|
_patch(monkeypatch)
|
||||||
|
with pytest.raises(ValueError):
|
||||||
|
enrich_person_passive("", " ")
|
||||||
|
|
||||||
|
|
||||||
|
def test_emails_deduplicados(monkeypatch):
|
||||||
|
# Dos dominios distintos que devuelven el mismo email -> debe deduplicar.
|
||||||
|
_patch(monkeypatch, emails_fn=lambda n, a, d: ["dup@same.com", "dup@same.com"])
|
||||||
|
|
||||||
|
res = enrich_person_passive("Juan", "Perez", dominios=["a.com", "b.com"])
|
||||||
|
|
||||||
|
assert res["email_candidates"] == ["dup@same.com"]
|
||||||
@@ -0,0 +1,66 @@
|
|||||||
|
---
|
||||||
|
name: enum_subdomains_crtsh
|
||||||
|
kind: function
|
||||||
|
lang: py
|
||||||
|
domain: cybersecurity
|
||||||
|
version: "1.0.0"
|
||||||
|
purity: impure
|
||||||
|
signature: "def enum_subdomains_crtsh(dominio: str, timeout_s: float = 20.0) -> list"
|
||||||
|
description: "Enumeracion OSINT pasiva de subdominios desde Certificate Transparency (crt.sh). Consulta https://crt.sh/?q=%25.<dominio>&output=json con http_get_json, extrae name_value de cada certificado, separa por saltos de linea, deduplica, filtra wildcards (*.) y devuelve la lista ordenada de subdominios unicos. Pasivo: no toca al objetivo, consulta logs CT publicos."
|
||||||
|
tags: [osint-passive, subdomains, crtsh, certificate-transparency, recon, cybersecurity]
|
||||||
|
params:
|
||||||
|
- name: dominio
|
||||||
|
desc: "Dominio base a enumerar, ej. organic-machine.com. Vacio lanza RuntimeError."
|
||||||
|
- name: timeout_s
|
||||||
|
desc: "Segundos maximo de espera de la peticion HTTP a crt.sh (default 20.0)."
|
||||||
|
output: "Lista ordenada de subdominios unicos (en minusculas, sin wildcards) que aparecen en certificados emitidos para el dominio. Lista vacia si crt.sh no devuelve resultados."
|
||||||
|
uses_functions: ["http_get_json_py_infra"]
|
||||||
|
uses_types: []
|
||||||
|
returns: []
|
||||||
|
returns_optional: false
|
||||||
|
error_type: "error_go_core"
|
||||||
|
imports: []
|
||||||
|
tested: true
|
||||||
|
tests: ["test_dedup_y_orden", "test_filtra_wildcards", "test_respuesta_vacia", "test_respuesta_no_lista_lanza_error", "test_dominio_vacio_lanza_error"]
|
||||||
|
test_file_path: "python/functions/cybersecurity/enum_subdomains_crtsh_test.py"
|
||||||
|
file_path: "python/functions/cybersecurity/enum_subdomains_crtsh.py"
|
||||||
|
---
|
||||||
|
|
||||||
|
## Ejemplo
|
||||||
|
|
||||||
|
```python
|
||||||
|
import sys, os
|
||||||
|
sys.path.insert(0, os.path.join("python", "functions"))
|
||||||
|
from cybersecurity import enum_subdomains_crtsh
|
||||||
|
|
||||||
|
subs = enum_subdomains_crtsh("organic-machine.com")
|
||||||
|
for s in subs:
|
||||||
|
print(s)
|
||||||
|
# api.organic-machine.com
|
||||||
|
# mail.organic-machine.com
|
||||||
|
# organic-machine.com
|
||||||
|
# www.organic-machine.com
|
||||||
|
```
|
||||||
|
|
||||||
|
## Cuando usarla
|
||||||
|
|
||||||
|
Usala para descubrir la superficie de subdominios de un objetivo sin enviarle
|
||||||
|
trafico: los logs de Certificate Transparency listan todos los nombres para
|
||||||
|
los que se han emitido certificados TLS. Complementa a `dns_records`
|
||||||
|
(resolucion) y `whois_lookup` (registro) en el reconocimiento pasivo inicial.
|
||||||
|
|
||||||
|
## Gotchas
|
||||||
|
|
||||||
|
- Es OSINT **pasivo**: consulta los logs CT publicos via crt.sh, NO toca al
|
||||||
|
dominio objetivo ni resuelve los subdominios encontrados (algunos pueden
|
||||||
|
estar muertos o no resolver).
|
||||||
|
- crt.sh suele ir lento o rate-limitear bajo carga; para dominios grandes la
|
||||||
|
respuesta puede tardar varios segundos o agotar el `timeout_s`. Subir el
|
||||||
|
timeout o reintentar si falla.
|
||||||
|
- Solo encuentra subdominios que han tenido certificado TLS emitido y logueado
|
||||||
|
en CT; subdominios internos sin certificado publico no apareceran.
|
||||||
|
- Los wildcards (`*.dominio`) se filtran porque no son hosts concretos.
|
||||||
|
- crt.sh devuelve un array JSON (no un objeto); por eso si la respuesta no es
|
||||||
|
una lista se lanza `RuntimeError`.
|
||||||
|
- Puede incluir subdominios de niveles arbitrarios y dominios relacionados que
|
||||||
|
compartieron certificado SAN; revisa la salida antes de usarla como verdad.
|
||||||
@@ -0,0 +1,68 @@
|
|||||||
|
"""Enumeracion OSINT pasiva de subdominios via Certificate Transparency (crt.sh).
|
||||||
|
|
||||||
|
Funcion IMPURA: consulta los logs publicos de Certificate Transparency a
|
||||||
|
traves de crt.sh y extrae los subdominios que han aparecido en certificados
|
||||||
|
emitidos para el dominio. Es OSINT pasivo: no toca al dominio objetivo, solo
|
||||||
|
consulta registros CT publicos.
|
||||||
|
"""
|
||||||
|
|
||||||
|
import os
|
||||||
|
import sys
|
||||||
|
|
||||||
|
sys.path.insert(
|
||||||
|
0, os.path.join(os.path.dirname(__file__), "..", "..", "functions")
|
||||||
|
)
|
||||||
|
|
||||||
|
from infra.http_get_json import http_get_json # noqa: E402
|
||||||
|
|
||||||
|
|
||||||
|
def enum_subdomains_crtsh(dominio: str, timeout_s: float = 20.0) -> list:
|
||||||
|
"""Enumera subdominios de un dominio desde Certificate Transparency.
|
||||||
|
|
||||||
|
Consulta ``https://crt.sh/?q=%25.<dominio>&output=json`` (``%25`` = ``%``,
|
||||||
|
el wildcard de crt.sh) usando ``http_get_json`` del registry. crt.sh
|
||||||
|
devuelve un array JSON de certificados; de cada uno se toma el campo
|
||||||
|
``name_value`` (que puede contener varios nombres separados por saltos de
|
||||||
|
linea, uno por SAN). Se separan, deduplican, se filtran los wildcards
|
||||||
|
(``*.``) y se devuelve la lista ordenada de subdominios unicos.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
dominio: Dominio base a enumerar (ej. ``"organic-machine.com"``).
|
||||||
|
timeout_s: Segundos maximo de espera de la peticion HTTP (default 20).
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
Lista ordenada de subdominios unicos (sin wildcards). Lista vacia si
|
||||||
|
crt.sh no devuelve resultados.
|
||||||
|
|
||||||
|
Raises:
|
||||||
|
RuntimeError: Si el dominio esta vacio o la peticion HTTP falla.
|
||||||
|
"""
|
||||||
|
if not dominio or not dominio.strip():
|
||||||
|
raise RuntimeError("enum_subdomains_crtsh: dominio vacio")
|
||||||
|
|
||||||
|
dom = dominio.strip().lower()
|
||||||
|
# %25 es el wildcard '%' de crt.sh: busca cualquier nombre que termine en .<dominio>
|
||||||
|
url = f"https://crt.sh/?q=%25.{dom}&output=json"
|
||||||
|
|
||||||
|
data = http_get_json(url, timeout=timeout_s)
|
||||||
|
|
||||||
|
if not isinstance(data, list):
|
||||||
|
raise RuntimeError(
|
||||||
|
f"enum_subdomains_crtsh: respuesta crt.sh inesperada "
|
||||||
|
f"(tipo {type(data).__name__})"
|
||||||
|
)
|
||||||
|
|
||||||
|
found: set = set()
|
||||||
|
for cert in data:
|
||||||
|
if not isinstance(cert, dict):
|
||||||
|
continue
|
||||||
|
name_value = cert.get("name_value", "")
|
||||||
|
for raw_name in name_value.split("\n"):
|
||||||
|
name = raw_name.strip().lower()
|
||||||
|
if not name:
|
||||||
|
continue
|
||||||
|
if name.startswith("*."):
|
||||||
|
continue
|
||||||
|
found.add(name)
|
||||||
|
|
||||||
|
return sorted(found)
|
||||||
@@ -0,0 +1,81 @@
|
|||||||
|
"""Tests para enum_subdomains_crtsh."""
|
||||||
|
|
||||||
|
import os
|
||||||
|
import sys
|
||||||
|
|
||||||
|
sys.path.insert(0, os.path.dirname(__file__))
|
||||||
|
|
||||||
|
import enum_subdomains_crtsh as esc
|
||||||
|
from enum_subdomains_crtsh import enum_subdomains_crtsh
|
||||||
|
|
||||||
|
|
||||||
|
def _crtsh_sample() -> list:
|
||||||
|
# crt.sh devuelve un array; name_value puede traer varios SAN separados
|
||||||
|
# por '\n', con duplicados y wildcards.
|
||||||
|
return [
|
||||||
|
{"name_value": "www.organic-machine.com\norganic-machine.com"},
|
||||||
|
{"name_value": "api.organic-machine.com"},
|
||||||
|
{"name_value": "www.organic-machine.com"}, # duplicado
|
||||||
|
{"name_value": "*.organic-machine.com"}, # wildcard, se filtra
|
||||||
|
{"name_value": "MAIL.Organic-Machine.com"}, # case distinto
|
||||||
|
]
|
||||||
|
|
||||||
|
|
||||||
|
def test_dedup_y_orden(monkeypatch):
|
||||||
|
"""Subdominios deduplicados, en minusculas y ordenados."""
|
||||||
|
monkeypatch.setattr(
|
||||||
|
esc, "http_get_json", lambda url, timeout=20.0: _crtsh_sample()
|
||||||
|
)
|
||||||
|
|
||||||
|
result = enum_subdomains_crtsh("organic-machine.com")
|
||||||
|
|
||||||
|
assert result == [
|
||||||
|
"api.organic-machine.com",
|
||||||
|
"mail.organic-machine.com",
|
||||||
|
"organic-machine.com",
|
||||||
|
"www.organic-machine.com",
|
||||||
|
]
|
||||||
|
|
||||||
|
|
||||||
|
def test_filtra_wildcards(monkeypatch):
|
||||||
|
"""Las entradas '*.dominio' se descartan."""
|
||||||
|
monkeypatch.setattr(
|
||||||
|
esc,
|
||||||
|
"http_get_json",
|
||||||
|
lambda url, timeout=20.0: [{"name_value": "*.organic-machine.com"}],
|
||||||
|
)
|
||||||
|
|
||||||
|
result = enum_subdomains_crtsh("organic-machine.com")
|
||||||
|
|
||||||
|
assert result == []
|
||||||
|
|
||||||
|
|
||||||
|
def test_respuesta_vacia(monkeypatch):
|
||||||
|
"""crt.sh sin resultados devuelve lista vacia."""
|
||||||
|
monkeypatch.setattr(esc, "http_get_json", lambda url, timeout=20.0: [])
|
||||||
|
|
||||||
|
result = enum_subdomains_crtsh("organic-machine.com")
|
||||||
|
|
||||||
|
assert result == []
|
||||||
|
|
||||||
|
|
||||||
|
def test_respuesta_no_lista_lanza_error(monkeypatch):
|
||||||
|
"""Una respuesta que no es lista lanza RuntimeError."""
|
||||||
|
monkeypatch.setattr(
|
||||||
|
esc, "http_get_json", lambda url, timeout=20.0: {"unexpected": "obj"}
|
||||||
|
)
|
||||||
|
|
||||||
|
try:
|
||||||
|
enum_subdomains_crtsh("organic-machine.com")
|
||||||
|
assert False, "deberia haber lanzado RuntimeError"
|
||||||
|
except RuntimeError:
|
||||||
|
pass
|
||||||
|
|
||||||
|
|
||||||
|
def test_dominio_vacio_lanza_error():
|
||||||
|
"""Dominio vacio lanza RuntimeError."""
|
||||||
|
try:
|
||||||
|
enum_subdomains_crtsh("")
|
||||||
|
assert False, "deberia haber lanzado RuntimeError"
|
||||||
|
except RuntimeError:
|
||||||
|
pass
|
||||||
@@ -0,0 +1,60 @@
|
|||||||
|
---
|
||||||
|
name: enumerate_username_sites
|
||||||
|
kind: function
|
||||||
|
lang: py
|
||||||
|
domain: cybersecurity
|
||||||
|
version: "1.0.0"
|
||||||
|
purity: impure
|
||||||
|
signature: "def enumerate_username_sites(username: str, timeout_s: float = 8.0, sites: list | None = None) -> list"
|
||||||
|
description: "Comprueba si un username existe en ~12 sitios publicos (github, twitter/x, instagram, tiktok, reddit, gitlab, keybase, medium, telegram, youtube, pinterest, about.me) consultando la URL de perfil estilo sherlock ligero. Detecta por codigo HTTP (200 existe, 404 no existe). Cada sitio se consulta aislado en try/except con User-Agent de navegador y allow_redirects: un timeout no aborta el resto. OSINT pasivo."
|
||||||
|
tags: [osint-passive, username, enumeration, recon, identity, sherlock, cybersecurity, python]
|
||||||
|
uses_functions: []
|
||||||
|
uses_types: []
|
||||||
|
returns: []
|
||||||
|
returns_optional: false
|
||||||
|
error_type: "error_go_core"
|
||||||
|
imports: [requests]
|
||||||
|
params:
|
||||||
|
- name: username
|
||||||
|
desc: "Nombre de usuario a buscar, sin arroba ni URL (ej. 'jdoe')."
|
||||||
|
- name: timeout_s
|
||||||
|
desc: "Timeout en segundos por peticion HTTP. Default 8.0."
|
||||||
|
- name: sites
|
||||||
|
desc: "Lista opcional de dicts {'site','url'} donde 'url' lleva el placeholder '{u}'. Si es None usa DEFAULT_SITES (~12 sitios)."
|
||||||
|
output: "Lista de dicts {'site','url','exists','status'} en el orden de los sitios. 'exists' es True/False/None y 'status' el codigo HTTP (int) o None si la peticion fallo."
|
||||||
|
tested: true
|
||||||
|
tests:
|
||||||
|
- "test_status_200_es_existe"
|
||||||
|
- "test_status_404_es_no_existe"
|
||||||
|
- "test_status_otro_es_indeterminado"
|
||||||
|
- "test_excepcion_por_sitio_no_aborta_el_resto"
|
||||||
|
- "test_estructura_y_url_formateada"
|
||||||
|
- "test_lista_por_defecto_tiene_doce_sitios"
|
||||||
|
test_file_path: "python/functions/cybersecurity/enumerate_username_sites_test.py"
|
||||||
|
file_path: "python/functions/cybersecurity/enumerate_username_sites.py"
|
||||||
|
---
|
||||||
|
|
||||||
|
## Ejemplo
|
||||||
|
|
||||||
|
```python
|
||||||
|
enumerate_username_sites("torvalds", timeout_s=8.0)
|
||||||
|
# [{"site": "github", "url": "https://github.com/torvalds", "exists": True, "status": 200},
|
||||||
|
# {"site": "twitter", "url": "https://x.com/torvalds", "exists": None, "status": 403},
|
||||||
|
# {"site": "instagram","url": "https://www.instagram.com/torvalds/","exists": False, "status": 404},
|
||||||
|
# ...]
|
||||||
|
|
||||||
|
# Lista propia de sitios:
|
||||||
|
enumerate_username_sites("jdoe", sites=[{"site": "github", "url": "https://github.com/{u}"}])
|
||||||
|
```
|
||||||
|
|
||||||
|
## Cuando usarla
|
||||||
|
|
||||||
|
Usala cuando tengas un username (o alias) y quieras un barrido rapido de presencia en redes/plataformas publicas para mapear la huella digital de un objetivo en una investigacion autorizada. Util tras `guess_email_formats` para pivotar de identidad a perfiles, o como entrada para construir dorks con `build_search_dorks`.
|
||||||
|
|
||||||
|
## Gotchas
|
||||||
|
|
||||||
|
- **Deja huella**: aunque es recoleccion "pasiva" desde el punto de vista del objetivo, lanza una peticion HTTP real a cada sitio. Esas peticiones quedan en logs del sitio y pueden asociarse a tu IP. Usa proxy/VPN si la investigacion lo requiere.
|
||||||
|
- **Falsos positivos/negativos por anti-bot**: muchos sitios (instagram, tiktok, x) devuelven 200 con paginas de login/captcha o bloquean por User-Agent, dando exists=True erroneo o status indeterminado. El 200/404 no es garantia; verifica manualmente los hits relevantes.
|
||||||
|
- **Respeta rate limits**: lanzar muchas comprobaciones seguidas puede activar bloqueos o baneos temporales por IP. Espacia las consultas en barridos grandes.
|
||||||
|
- **Estados intermedios**: cualquier codigo distinto de 200/404 (301, 403, 429, 5xx) deja `exists=None`; un fallo de red por sitio deja `status=None` y `exists=None` sin abortar el resto.
|
||||||
|
- **Solo para investigacion autorizada.** No uses esta funcion para acoso, doxing ni vigilancia sin base legal.
|
||||||
@@ -0,0 +1,91 @@
|
|||||||
|
"""Comprueba la existencia de un username en sitios publicos (sherlock ligero).
|
||||||
|
|
||||||
|
OSINT pasivo: para un username dado, consulta la URL de perfil de una lista
|
||||||
|
de sitios conocidos y deduce si la cuenta existe por el codigo de estado
|
||||||
|
HTTP (200 = existe, 404 = no existe). Cada sitio se consulta de forma
|
||||||
|
aislada: un fallo (timeout, error de red) no aborta el resto.
|
||||||
|
"""
|
||||||
|
|
||||||
|
import requests
|
||||||
|
|
||||||
|
# Cada entrada describe un sitio: como construir la URL de perfil a partir
|
||||||
|
# del username. La deteccion es por codigo de estado (200 existe / 404 no).
|
||||||
|
DEFAULT_SITES = [
|
||||||
|
{"site": "github", "url": "https://github.com/{u}"},
|
||||||
|
{"site": "twitter", "url": "https://x.com/{u}"},
|
||||||
|
{"site": "instagram", "url": "https://www.instagram.com/{u}/"},
|
||||||
|
{"site": "tiktok", "url": "https://www.tiktok.com/@{u}"},
|
||||||
|
{"site": "reddit", "url": "https://www.reddit.com/user/{u}"},
|
||||||
|
{"site": "gitlab", "url": "https://gitlab.com/{u}"},
|
||||||
|
{"site": "keybase", "url": "https://keybase.io/{u}"},
|
||||||
|
{"site": "medium", "url": "https://medium.com/@{u}"},
|
||||||
|
{"site": "telegram", "url": "https://t.me/{u}"},
|
||||||
|
{"site": "youtube", "url": "https://www.youtube.com/@{u}"},
|
||||||
|
{"site": "pinterest", "url": "https://www.pinterest.com/{u}/"},
|
||||||
|
{"site": "about_me", "url": "https://about.me/{u}"},
|
||||||
|
]
|
||||||
|
|
||||||
|
_BROWSER_UA = (
|
||||||
|
"Mozilla/5.0 (X11; Linux x86_64) AppleWebKit/537.36 "
|
||||||
|
"(KHTML, like Gecko) Chrome/124.0.0.0 Safari/537.36"
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def enumerate_username_sites(
|
||||||
|
username: str,
|
||||||
|
timeout_s: float = 8.0,
|
||||||
|
sites: list | None = None,
|
||||||
|
) -> list:
|
||||||
|
"""Comprueba si un username existe en una lista de sitios publicos.
|
||||||
|
|
||||||
|
Para cada sitio construye la URL de perfil con el username y hace un
|
||||||
|
GET con User-Agent de navegador siguiendo redirecciones. Interpreta el
|
||||||
|
codigo de estado final: 200 -> existe, 404 -> no existe, cualquier otro
|
||||||
|
-> indeterminado (exists=None). Los errores de red por sitio (timeout,
|
||||||
|
conexion) se capturan y se reportan con status=None y exists=None sin
|
||||||
|
interrumpir la enumeracion del resto.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
username: nombre de usuario a buscar (sin arroba ni URL).
|
||||||
|
timeout_s: timeout en segundos por peticion. Default 8.0.
|
||||||
|
sites: lista opcional de dicts {"site", "url"} donde "url" contiene
|
||||||
|
el placeholder "{u}". Si es None se usa DEFAULT_SITES.
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
lista de dicts {"site", "url", "exists", "status"} en el mismo orden
|
||||||
|
que la lista de sitios. "exists" es True/False/None y "status" es el
|
||||||
|
codigo HTTP (int) o None si la peticion fallo.
|
||||||
|
"""
|
||||||
|
targets = sites if sites is not None else DEFAULT_SITES
|
||||||
|
headers = {"User-Agent": _BROWSER_UA}
|
||||||
|
results = []
|
||||||
|
|
||||||
|
for entry in targets:
|
||||||
|
site = entry.get("site", "")
|
||||||
|
url = entry["url"].format(u=username)
|
||||||
|
status = None
|
||||||
|
exists = None
|
||||||
|
try:
|
||||||
|
resp = requests.get(
|
||||||
|
url,
|
||||||
|
headers=headers,
|
||||||
|
timeout=timeout_s,
|
||||||
|
allow_redirects=True,
|
||||||
|
)
|
||||||
|
status = resp.status_code
|
||||||
|
if status == 200:
|
||||||
|
exists = True
|
||||||
|
elif status == 404:
|
||||||
|
exists = False
|
||||||
|
else:
|
||||||
|
exists = None
|
||||||
|
except requests.RequestException:
|
||||||
|
# Timeout, error de conexion, demasiados redirects, etc.
|
||||||
|
status = None
|
||||||
|
exists = None
|
||||||
|
|
||||||
|
results.append(
|
||||||
|
{"site": site, "url": url, "exists": exists, "status": status}
|
||||||
|
)
|
||||||
|
|
||||||
|
return results
|
||||||
@@ -0,0 +1,109 @@
|
|||||||
|
"""Tests para enumerate_username_sites (red mockeada con monkeypatch)."""
|
||||||
|
|
||||||
|
import requests
|
||||||
|
|
||||||
|
import enumerate_username_sites as mod
|
||||||
|
from enumerate_username_sites import enumerate_username_sites
|
||||||
|
|
||||||
|
|
||||||
|
class _FakeResp:
|
||||||
|
"""Respuesta HTTP minima con solo status_code."""
|
||||||
|
|
||||||
|
def __init__(self, status_code):
|
||||||
|
self.status_code = status_code
|
||||||
|
|
||||||
|
|
||||||
|
def _make_get(status_by_url):
|
||||||
|
"""Construye un fake de requests.get que devuelve status por URL."""
|
||||||
|
|
||||||
|
def _fake_get(url, **kwargs):
|
||||||
|
return _FakeResp(status_by_url[url])
|
||||||
|
|
||||||
|
return _fake_get
|
||||||
|
|
||||||
|
|
||||||
|
SITES = [
|
||||||
|
{"site": "alpha", "url": "https://alpha.test/{u}"},
|
||||||
|
{"site": "beta", "url": "https://beta.test/{u}"},
|
||||||
|
{"site": "gamma", "url": "https://gamma.test/{u}"},
|
||||||
|
]
|
||||||
|
|
||||||
|
|
||||||
|
def test_status_200_es_existe(monkeypatch):
|
||||||
|
"""Un 200 marca exists=True."""
|
||||||
|
status_map = {
|
||||||
|
"https://alpha.test/bob": 200,
|
||||||
|
"https://beta.test/bob": 200,
|
||||||
|
"https://gamma.test/bob": 200,
|
||||||
|
}
|
||||||
|
monkeypatch.setattr(requests, "get", _make_get(status_map))
|
||||||
|
out = enumerate_username_sites("bob", sites=SITES)
|
||||||
|
assert all(r["exists"] is True for r in out)
|
||||||
|
assert all(r["status"] == 200 for r in out)
|
||||||
|
|
||||||
|
|
||||||
|
def test_status_404_es_no_existe(monkeypatch):
|
||||||
|
"""Un 404 marca exists=False."""
|
||||||
|
status_map = {
|
||||||
|
"https://alpha.test/ghost": 404,
|
||||||
|
"https://beta.test/ghost": 404,
|
||||||
|
"https://gamma.test/ghost": 404,
|
||||||
|
}
|
||||||
|
monkeypatch.setattr(requests, "get", _make_get(status_map))
|
||||||
|
out = enumerate_username_sites("ghost", sites=SITES)
|
||||||
|
assert all(r["exists"] is False for r in out)
|
||||||
|
assert all(r["status"] == 404 for r in out)
|
||||||
|
|
||||||
|
|
||||||
|
def test_status_otro_es_indeterminado(monkeypatch):
|
||||||
|
"""Un codigo distinto de 200/404 deja exists=None pero conserva status."""
|
||||||
|
status_map = {
|
||||||
|
"https://alpha.test/x": 301,
|
||||||
|
"https://beta.test/x": 403,
|
||||||
|
"https://gamma.test/x": 500,
|
||||||
|
}
|
||||||
|
monkeypatch.setattr(requests, "get", _make_get(status_map))
|
||||||
|
out = enumerate_username_sites("x", sites=SITES)
|
||||||
|
assert [r["exists"] for r in out] == [None, None, None]
|
||||||
|
assert [r["status"] for r in out] == [301, 403, 500]
|
||||||
|
|
||||||
|
|
||||||
|
def test_excepcion_por_sitio_no_aborta_el_resto(monkeypatch):
|
||||||
|
"""Un fallo de red en un sitio no interrumpe la enumeracion."""
|
||||||
|
|
||||||
|
def _fake_get(url, **kwargs):
|
||||||
|
if "beta" in url:
|
||||||
|
raise requests.Timeout("simulado")
|
||||||
|
return _FakeResp(200)
|
||||||
|
|
||||||
|
monkeypatch.setattr(requests, "get", _fake_get)
|
||||||
|
out = enumerate_username_sites("u", sites=SITES)
|
||||||
|
assert len(out) == 3
|
||||||
|
by_site = {r["site"]: r for r in out}
|
||||||
|
assert by_site["alpha"]["exists"] is True
|
||||||
|
assert by_site["alpha"]["status"] == 200
|
||||||
|
# El sitio que fallo queda con status/exists None.
|
||||||
|
assert by_site["beta"]["exists"] is None
|
||||||
|
assert by_site["beta"]["status"] is None
|
||||||
|
assert by_site["gamma"]["exists"] is True
|
||||||
|
|
||||||
|
|
||||||
|
def test_estructura_y_url_formateada(monkeypatch):
|
||||||
|
"""Cada resultado lleva las claves esperadas y la URL con el username."""
|
||||||
|
status_map = {
|
||||||
|
"https://alpha.test/neo": 200,
|
||||||
|
"https://beta.test/neo": 404,
|
||||||
|
"https://gamma.test/neo": 200,
|
||||||
|
}
|
||||||
|
monkeypatch.setattr(requests, "get", _make_get(status_map))
|
||||||
|
out = enumerate_username_sites("neo", sites=SITES)
|
||||||
|
for r in out:
|
||||||
|
assert set(r.keys()) == {"site", "url", "exists", "status"}
|
||||||
|
assert "neo" in r["url"]
|
||||||
|
|
||||||
|
|
||||||
|
def test_lista_por_defecto_tiene_doce_sitios():
|
||||||
|
"""La lista DEFAULT_SITES cubre ~12 sitios publicos."""
|
||||||
|
assert len(mod.DEFAULT_SITES) == 12
|
||||||
|
sites = {s["site"] for s in mod.DEFAULT_SITES}
|
||||||
|
assert {"github", "instagram", "reddit", "telegram"} <= sites
|
||||||
@@ -0,0 +1,66 @@
|
|||||||
|
---
|
||||||
|
name: extract_exif_metadata
|
||||||
|
kind: function
|
||||||
|
lang: py
|
||||||
|
domain: cybersecurity
|
||||||
|
version: "1.0.0"
|
||||||
|
purity: impure
|
||||||
|
signature: "def extract_exif_metadata(image_path: str) -> dict"
|
||||||
|
description: "Lee los metadatos EXIF de una imagen con Pillow y los devuelve normalizados (fecha, camara, software, GPS en grados decimales) mas el volcado completo de tags en `raw`. OSINT pasiva sobre documentos propios: revela cuando, con que dispositivo y donde se tomo una foto."
|
||||||
|
tags: [osint-passive, exif, metadata, image, gps, forensics, pillow, extract, cybersecurity, python]
|
||||||
|
uses_functions: []
|
||||||
|
uses_types: []
|
||||||
|
returns: []
|
||||||
|
returns_optional: false
|
||||||
|
error_type: "error_go_core"
|
||||||
|
imports: [PIL]
|
||||||
|
params:
|
||||||
|
- name: image_path
|
||||||
|
desc: "ruta al archivo de imagen en disco (JPEG, PNG, TIFF, ...)"
|
||||||
|
output: "dict con datetime, camera_make, camera_model, software, gps_lat, gps_lon (grados decimales o None) y raw (dict con todos los tags EXIF legibles por nombre). Si la imagen no tiene EXIF, los campos van a None y raw={}."
|
||||||
|
tested: true
|
||||||
|
tests:
|
||||||
|
- "imagen sin EXIF (PNG) devuelve campos None y raw vacio"
|
||||||
|
- "imagen con EXIF devuelve camara, software y fecha"
|
||||||
|
- "GPSInfo DMS se convierte a grados decimales con signo por hemisferio"
|
||||||
|
test_file_path: "python/functions/cybersecurity/extract_exif_metadata_test.py"
|
||||||
|
file_path: "python/functions/cybersecurity/extract_exif_metadata.py"
|
||||||
|
---
|
||||||
|
|
||||||
|
## Ejemplo
|
||||||
|
|
||||||
|
```python
|
||||||
|
import sys, os
|
||||||
|
sys.path.insert(0, os.path.join("python", "functions"))
|
||||||
|
from cybersecurity.extract_exif_metadata import extract_exif_metadata
|
||||||
|
|
||||||
|
meta = extract_exif_metadata(
|
||||||
|
"/home/enmanuel/Obsidian/osint/attachments/personas/objetivo_01.jpg"
|
||||||
|
)
|
||||||
|
print(meta["datetime"]) # '2024:08:12 19:43:07' (cuando se tomo)
|
||||||
|
print(meta["camera_model"]) # 'iPhone 13 Pro' (con que dispositivo)
|
||||||
|
print(meta["gps_lat"], meta["gps_lon"]) # 40.4168 -3.7038 (donde) o None, None
|
||||||
|
```
|
||||||
|
|
||||||
|
## Cuando usarla
|
||||||
|
|
||||||
|
Cuando recolectes inteligencia pasiva sobre una imagen propia o de un objetivo
|
||||||
|
y necesites saber cuando, con que dispositivo y desde donde se capturo, antes de
|
||||||
|
publicarla o compartirla. Usala tambien para auditar tus propios documentos y
|
||||||
|
detectar fugas de metadatos (geolocalizacion, modelo de telefono, software de
|
||||||
|
edicion) antes de subirlos a un sitio publico.
|
||||||
|
|
||||||
|
## Gotchas
|
||||||
|
|
||||||
|
- Funcion impura: abre el archivo del disco. Lanza si la ruta no existe o no es
|
||||||
|
una imagen valida (Pillow `UnidentifiedImageError` / `FileNotFoundError`).
|
||||||
|
- JPEG y HEIC de moviles suelen traer GPS embebido; PNG normalmente no lleva
|
||||||
|
EXIF y devolvera todos los campos en None con `raw={}`.
|
||||||
|
- El GPS revela la ubicacion fisica donde se tomo la foto — dato sensible. No
|
||||||
|
lo loguees ni lo compartas sin consentimiento.
|
||||||
|
- `DateTimeOriginal` vive en el sub-IFD EXIF, no en el IFD raiz; algunas
|
||||||
|
imagenes solo tienen `DateTime` (fecha de modificacion del archivo), que se
|
||||||
|
usa como fallback.
|
||||||
|
- Las coordenadas se convierten de DMS (grados/minutos/segundos) a grados
|
||||||
|
decimales y se les aplica el signo segun `GPSLatitudeRef`/`GPSLongitudeRef`
|
||||||
|
(S y W => negativo).
|
||||||
@@ -0,0 +1,99 @@
|
|||||||
|
"""Extrae metadatos EXIF de una imagen (OSINT pasiva sobre documentos propios)."""
|
||||||
|
|
||||||
|
from PIL import ExifTags, Image
|
||||||
|
|
||||||
|
# Mapa inverso nombre -> id no hace falta: usamos ExifTags.TAGS (id -> nombre).
|
||||||
|
_GPS_TAGS = ExifTags.GPSTAGS # id -> nombre para el sub-IFD de GPS.
|
||||||
|
|
||||||
|
|
||||||
|
def _to_degrees(value) -> float | None:
|
||||||
|
"""Convierte una coordenada GPS en formato DMS (grados, minutos, segundos) a grados decimales.
|
||||||
|
|
||||||
|
Pillow devuelve cada componente como un IFDRational o una tupla (num, den).
|
||||||
|
"""
|
||||||
|
try:
|
||||||
|
d, m, s = value
|
||||||
|
return float(d) + float(m) / 60.0 + float(s) / 3600.0
|
||||||
|
except (TypeError, ValueError, ZeroDivisionError):
|
||||||
|
return None
|
||||||
|
|
||||||
|
|
||||||
|
def _extract_gps(gps_info: dict) -> tuple[float | None, float | None]:
|
||||||
|
"""Devuelve (lat, lon) en grados decimales desde el sub-IFD GPSInfo, o (None, None)."""
|
||||||
|
if not gps_info:
|
||||||
|
return None, None
|
||||||
|
|
||||||
|
named = {_GPS_TAGS.get(k, k): v for k, v in gps_info.items()}
|
||||||
|
|
||||||
|
lat = _to_degrees(named.get("GPSLatitude")) if "GPSLatitude" in named else None
|
||||||
|
lon = _to_degrees(named.get("GPSLongitude")) if "GPSLongitude" in named else None
|
||||||
|
|
||||||
|
if lat is not None and str(named.get("GPSLatitudeRef", "N")).upper() == "S":
|
||||||
|
lat = -lat
|
||||||
|
if lon is not None and str(named.get("GPSLongitudeRef", "E")).upper() == "W":
|
||||||
|
lon = -lon
|
||||||
|
|
||||||
|
return lat, lon
|
||||||
|
|
||||||
|
|
||||||
|
def extract_exif_metadata(image_path: str) -> dict:
|
||||||
|
"""Lee los metadatos EXIF de una imagen y los devuelve normalizados.
|
||||||
|
|
||||||
|
Abre la imagen con Pillow y extrae los tags EXIF. Normaliza los campos
|
||||||
|
mas relevantes para OSINT (fecha, camara, software, GPS) y adjunta el
|
||||||
|
diccionario completo de tags legibles por nombre en `raw`.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
image_path: ruta al archivo de imagen (JPEG, PNG, TIFF, ...).
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
dict con las claves: datetime, camera_make, camera_model, software,
|
||||||
|
gps_lat, gps_lon (grados decimales o None) y raw (dict tag->valor).
|
||||||
|
Si la imagen no tiene EXIF, los campos van a None y raw queda {}.
|
||||||
|
"""
|
||||||
|
result = {
|
||||||
|
"datetime": None,
|
||||||
|
"camera_make": None,
|
||||||
|
"camera_model": None,
|
||||||
|
"software": None,
|
||||||
|
"gps_lat": None,
|
||||||
|
"gps_lon": None,
|
||||||
|
"raw": {},
|
||||||
|
}
|
||||||
|
|
||||||
|
with Image.open(image_path) as img:
|
||||||
|
exif = img.getexif()
|
||||||
|
|
||||||
|
if not exif:
|
||||||
|
return result
|
||||||
|
|
||||||
|
# Tags de nivel raiz por nombre.
|
||||||
|
raw = {ExifTags.TAGS.get(tag_id, tag_id): value for tag_id, value in exif.items()}
|
||||||
|
|
||||||
|
# Sub-IFD EXIF (DateTimeOriginal vive aqui, no en el IFD raiz).
|
||||||
|
try:
|
||||||
|
exif_ifd = exif.get_ifd(ExifTags.IFD.Exif)
|
||||||
|
except (AttributeError, KeyError, ValueError):
|
||||||
|
exif_ifd = {}
|
||||||
|
for tag_id, value in exif_ifd.items():
|
||||||
|
raw[ExifTags.TAGS.get(tag_id, tag_id)] = value
|
||||||
|
|
||||||
|
# GPS IFD.
|
||||||
|
try:
|
||||||
|
gps_ifd = exif.get_ifd(ExifTags.IFD.GPSInfo)
|
||||||
|
except (AttributeError, KeyError, ValueError):
|
||||||
|
gps_ifd = {}
|
||||||
|
if gps_ifd:
|
||||||
|
raw["GPSInfo"] = {_GPS_TAGS.get(k, k): v for k, v in gps_ifd.items()}
|
||||||
|
|
||||||
|
result["raw"] = raw
|
||||||
|
result["datetime"] = raw.get("DateTimeOriginal") or raw.get("DateTime")
|
||||||
|
result["camera_make"] = raw.get("Make")
|
||||||
|
result["camera_model"] = raw.get("Model")
|
||||||
|
result["software"] = raw.get("Software")
|
||||||
|
|
||||||
|
lat, lon = _extract_gps(gps_ifd)
|
||||||
|
result["gps_lat"] = lat
|
||||||
|
result["gps_lon"] = lon
|
||||||
|
|
||||||
|
return result
|
||||||
@@ -0,0 +1,110 @@
|
|||||||
|
"""Tests para extract_exif_metadata."""
|
||||||
|
|
||||||
|
import os
|
||||||
|
import sys
|
||||||
|
|
||||||
|
from PIL import Image
|
||||||
|
from PIL.ExifTags import Base, GPS, IFD
|
||||||
|
from PIL.TiffImagePlugin import IFDRational
|
||||||
|
|
||||||
|
sys.path.insert(0, os.path.join(os.path.dirname(__file__), ".."))
|
||||||
|
|
||||||
|
from cybersecurity.extract_exif_metadata import extract_exif_metadata
|
||||||
|
|
||||||
|
|
||||||
|
def _make_png_without_exif(path: str) -> None:
|
||||||
|
"""Crea un PNG pequeño sin EXIF."""
|
||||||
|
Image.new("RGB", (4, 4), (10, 20, 30)).save(path, format="PNG")
|
||||||
|
|
||||||
|
|
||||||
|
def _make_jpeg_with_exif(path: str) -> None:
|
||||||
|
"""Crea un JPEG pequeño con EXIF de camara/software/fecha."""
|
||||||
|
img = Image.new("RGB", (4, 4), (200, 100, 50))
|
||||||
|
exif = img.getexif()
|
||||||
|
exif[Base.Make.value] = "TestCam"
|
||||||
|
exif[Base.Model.value] = "Model X"
|
||||||
|
exif[Base.Software.value] = "PyTestRig 1.0"
|
||||||
|
exif[Base.DateTime.value] = "2024:08:12 19:43:07"
|
||||||
|
# DateTimeOriginal vive en el sub-IFD EXIF.
|
||||||
|
exif_ifd = exif.get_ifd(IFD.Exif)
|
||||||
|
exif_ifd[Base.DateTimeOriginal.value] = "2024:08:12 19:43:07"
|
||||||
|
img.save(path, format="JPEG", exif=exif)
|
||||||
|
|
||||||
|
|
||||||
|
def _make_jpeg_with_gps(path: str) -> None:
|
||||||
|
"""Crea un JPEG con GPSInfo en DMS, hemisferio sur y oeste."""
|
||||||
|
img = Image.new("RGB", (4, 4), (0, 128, 64))
|
||||||
|
exif = img.getexif()
|
||||||
|
gps_ifd = exif.get_ifd(IFD.GPSInfo)
|
||||||
|
# 40 deg, 25 min, 0.48 seg => 40.41680 grados.
|
||||||
|
gps_ifd[GPS.GPSLatitude.value] = (
|
||||||
|
IFDRational(40, 1),
|
||||||
|
IFDRational(25, 1),
|
||||||
|
IFDRational(48, 100),
|
||||||
|
)
|
||||||
|
gps_ifd[GPS.GPSLatitudeRef.value] = "S" # hemisferio sur => negativo
|
||||||
|
# 3 deg, 42 min, 13.68 seg => 3.7038 grados.
|
||||||
|
gps_ifd[GPS.GPSLongitude.value] = (
|
||||||
|
IFDRational(3, 1),
|
||||||
|
IFDRational(42, 1),
|
||||||
|
IFDRational(1368, 100),
|
||||||
|
)
|
||||||
|
gps_ifd[GPS.GPSLongitudeRef.value] = "W" # oeste => negativo
|
||||||
|
img.save(path, format="JPEG", exif=exif)
|
||||||
|
|
||||||
|
|
||||||
|
def test_imagen_sin_exif_png_devuelve_none(tmp_path):
|
||||||
|
"""imagen sin EXIF (PNG) devuelve campos None y raw vacio."""
|
||||||
|
p = str(tmp_path / "plain.png")
|
||||||
|
_make_png_without_exif(p)
|
||||||
|
|
||||||
|
meta = extract_exif_metadata(p)
|
||||||
|
|
||||||
|
assert meta["datetime"] is None
|
||||||
|
assert meta["camera_make"] is None
|
||||||
|
assert meta["camera_model"] is None
|
||||||
|
assert meta["software"] is None
|
||||||
|
assert meta["gps_lat"] is None
|
||||||
|
assert meta["gps_lon"] is None
|
||||||
|
assert meta["raw"] == {}
|
||||||
|
|
||||||
|
|
||||||
|
def test_imagen_con_exif_devuelve_camara_software_fecha(tmp_path):
|
||||||
|
"""imagen con EXIF devuelve camara, software y fecha."""
|
||||||
|
p = str(tmp_path / "withexif.jpg")
|
||||||
|
_make_jpeg_with_exif(p)
|
||||||
|
|
||||||
|
meta = extract_exif_metadata(p)
|
||||||
|
|
||||||
|
assert meta["camera_make"] == "TestCam"
|
||||||
|
assert meta["camera_model"] == "Model X"
|
||||||
|
assert meta["software"] == "PyTestRig 1.0"
|
||||||
|
assert meta["datetime"] == "2024:08:12 19:43:07"
|
||||||
|
assert meta["raw"] # no vacio
|
||||||
|
|
||||||
|
|
||||||
|
def test_gps_dms_a_grados_decimales_con_signo(tmp_path):
|
||||||
|
"""GPSInfo DMS se convierte a grados decimales con signo por hemisferio."""
|
||||||
|
p = str(tmp_path / "withgps.jpg")
|
||||||
|
_make_jpeg_with_gps(p)
|
||||||
|
|
||||||
|
meta = extract_exif_metadata(p)
|
||||||
|
|
||||||
|
assert meta["gps_lat"] is not None
|
||||||
|
assert meta["gps_lon"] is not None
|
||||||
|
# Sur y Oeste => ambos negativos.
|
||||||
|
assert meta["gps_lat"] < 0
|
||||||
|
assert meta["gps_lon"] < 0
|
||||||
|
assert abs(meta["gps_lat"] - (-40.41680)) < 1e-4
|
||||||
|
assert abs(meta["gps_lon"] - (-3.7038)) < 1e-4
|
||||||
|
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
import tempfile
|
||||||
|
from pathlib import Path
|
||||||
|
|
||||||
|
with tempfile.TemporaryDirectory() as d:
|
||||||
|
test_imagen_sin_exif_png_devuelve_none(Path(d))
|
||||||
|
test_imagen_con_exif_devuelve_camara_software_fecha(Path(d))
|
||||||
|
test_gps_dms_a_grados_decimales_con_signo(Path(d))
|
||||||
|
print("Todos los tests pasaron.")
|
||||||
@@ -0,0 +1,66 @@
|
|||||||
|
---
|
||||||
|
name: extract_pdf_metadata
|
||||||
|
kind: function
|
||||||
|
lang: py
|
||||||
|
domain: cybersecurity
|
||||||
|
version: "1.0.0"
|
||||||
|
purity: impure
|
||||||
|
signature: "def extract_pdf_metadata(pdf_path: str) -> dict"
|
||||||
|
description: "Lee los metadatos del Document Info de un PDF con pypdf (titulo, autor, creador, productor, fechas, numero de paginas) mas el volcado completo en `raw`. OSINT pasiva sobre documentos propios: revela quien y con que software genero el documento. Tolerante a PDFs cifrados (no falla, rellena `error`)."
|
||||||
|
tags: [osint-passive, pdf, metadata, document, forensics, pypdf, extract, cybersecurity, python]
|
||||||
|
uses_functions: []
|
||||||
|
uses_types: []
|
||||||
|
returns: []
|
||||||
|
returns_optional: false
|
||||||
|
error_type: "error_go_core"
|
||||||
|
imports: [pypdf]
|
||||||
|
params:
|
||||||
|
- name: pdf_path
|
||||||
|
desc: "ruta al archivo PDF en disco"
|
||||||
|
output: "dict con title, author, creator, producer, creation_date, mod_date (ISO 8601 si parseables, sino valor crudo), num_pages, raw (todo el doc info) y error (None si todo fue bien, mensaje en caso contrario)."
|
||||||
|
tested: true
|
||||||
|
tests:
|
||||||
|
- "PDF con metadatos devuelve titulo, autor y num_pages"
|
||||||
|
- "PDF sin doc info devuelve campos None sin petar"
|
||||||
|
- "fechas parseables se devuelven en ISO 8601"
|
||||||
|
test_file_path: "python/functions/cybersecurity/extract_pdf_metadata_test.py"
|
||||||
|
file_path: "python/functions/cybersecurity/extract_pdf_metadata.py"
|
||||||
|
---
|
||||||
|
|
||||||
|
## Ejemplo
|
||||||
|
|
||||||
|
```python
|
||||||
|
import sys, os
|
||||||
|
sys.path.insert(0, os.path.join("python", "functions"))
|
||||||
|
from cybersecurity.extract_pdf_metadata import extract_pdf_metadata
|
||||||
|
|
||||||
|
meta = extract_pdf_metadata(
|
||||||
|
"/home/enmanuel/Obsidian/osint/attachments/personas/cv_objetivo.pdf"
|
||||||
|
)
|
||||||
|
print(meta["author"]) # 'Enmanuel G.' (quien lo creo)
|
||||||
|
print(meta["producer"]) # 'Microsoft Word 2021' (con que software)
|
||||||
|
print(meta["creation_date"]) # '2024-03-11T10:22:00+01:00'
|
||||||
|
print(meta["num_pages"]) # 3
|
||||||
|
```
|
||||||
|
|
||||||
|
## Cuando usarla
|
||||||
|
|
||||||
|
Cuando recolectes inteligencia pasiva sobre un PDF propio o de un objetivo y
|
||||||
|
necesites saber quien lo creo, con que herramienta y cuando. Usala tambien para
|
||||||
|
auditar tus propios documentos antes de publicarlos: el campo `author` y
|
||||||
|
`producer` suelen filtrar el nombre real del usuario, la version del software y
|
||||||
|
la organizacion, datos que no quieres exponer.
|
||||||
|
|
||||||
|
## Gotchas
|
||||||
|
|
||||||
|
- Funcion impura: abre el archivo del disco. Captura la excepcion en lugar de
|
||||||
|
propagarla — si el PDF esta corrupto, cifrado o no es un PDF, devuelve el dict
|
||||||
|
con lo que pudo leer y un mensaje en `error` (no lanza).
|
||||||
|
- PDFs cifrados: intenta abrir con password vacio (caso comun de "restriccion
|
||||||
|
de copia"). Si requiere password real, `error` empieza por `encrypted:` y los
|
||||||
|
campos pueden quedar None.
|
||||||
|
- Muchas fechas de PDF vienen en formato `D:YYYYMMDDHHmmSS+ZZ`; se convierten a
|
||||||
|
ISO 8601 cuando pypdf las parsea, sino se devuelven crudas.
|
||||||
|
- `raw` serializa los valores a string para evitar tipos no JSON-friendly
|
||||||
|
(IndirectObject, ByteStringObject). Los campos normalizados conservan el
|
||||||
|
contenido textual.
|
||||||
@@ -0,0 +1,86 @@
|
|||||||
|
"""Extrae metadatos de un PDF con pypdf (OSINT pasiva sobre documentos propios)."""
|
||||||
|
|
||||||
|
from pypdf import PdfReader
|
||||||
|
|
||||||
|
|
||||||
|
def _iso_or_raw(reader: PdfReader, getter_name: str, raw_value):
|
||||||
|
"""Devuelve una fecha en ISO 8601 si pypdf la sabe parsear, sino el valor crudo.
|
||||||
|
|
||||||
|
pypdf expone `creation_date`/`modification_date` (datetime) ademas del
|
||||||
|
string crudo `/CreationDate`/`/ModDate` en formato `D:YYYYMMDDHHmmSS`.
|
||||||
|
"""
|
||||||
|
try:
|
||||||
|
dt = getattr(reader.metadata, getter_name, None)
|
||||||
|
except Exception:
|
||||||
|
dt = None
|
||||||
|
if dt is not None:
|
||||||
|
try:
|
||||||
|
return dt.isoformat()
|
||||||
|
except Exception:
|
||||||
|
pass
|
||||||
|
return str(raw_value) if raw_value is not None else None
|
||||||
|
|
||||||
|
|
||||||
|
def extract_pdf_metadata(pdf_path: str) -> dict:
|
||||||
|
"""Lee los metadatos del Document Info de un PDF.
|
||||||
|
|
||||||
|
Abre el PDF con pypdf y extrae los campos estandar del diccionario de
|
||||||
|
informacion (titulo, autor, creador, productor, fechas) mas el numero de
|
||||||
|
paginas. Las fechas se devuelven en ISO 8601 cuando son parseables, en su
|
||||||
|
valor crudo en caso contrario. No falla si el PDF esta cifrado: captura la
|
||||||
|
excepcion, devuelve lo que pueda y rellena el campo `error`.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
pdf_path: ruta al archivo PDF en disco.
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
dict con las claves: title, author, creator, producer, creation_date,
|
||||||
|
mod_date, num_pages, raw (dict con todo el doc info) y error (None si
|
||||||
|
todo fue bien, mensaje de error en caso contrario).
|
||||||
|
"""
|
||||||
|
result = {
|
||||||
|
"title": None,
|
||||||
|
"author": None,
|
||||||
|
"creator": None,
|
||||||
|
"producer": None,
|
||||||
|
"creation_date": None,
|
||||||
|
"mod_date": None,
|
||||||
|
"num_pages": None,
|
||||||
|
"raw": {},
|
||||||
|
"error": None,
|
||||||
|
}
|
||||||
|
|
||||||
|
try:
|
||||||
|
reader = PdfReader(pdf_path)
|
||||||
|
|
||||||
|
# PDF cifrado: intentar abrir con password vacio (caso comun).
|
||||||
|
if getattr(reader, "is_encrypted", False):
|
||||||
|
try:
|
||||||
|
reader.decrypt("")
|
||||||
|
except Exception as exc:
|
||||||
|
result["error"] = f"encrypted: {exc}"
|
||||||
|
|
||||||
|
try:
|
||||||
|
result["num_pages"] = len(reader.pages)
|
||||||
|
except Exception as exc:
|
||||||
|
result["error"] = result["error"] or f"pages: {exc}"
|
||||||
|
|
||||||
|
meta = reader.metadata
|
||||||
|
if meta is not None:
|
||||||
|
result["title"] = str(meta.title) if meta.title is not None else None
|
||||||
|
result["author"] = str(meta.author) if meta.author is not None else None
|
||||||
|
result["creator"] = str(meta.creator) if meta.creator is not None else None
|
||||||
|
result["producer"] = (
|
||||||
|
str(meta.producer) if meta.producer is not None else None
|
||||||
|
)
|
||||||
|
result["creation_date"] = _iso_or_raw(
|
||||||
|
reader, "creation_date", meta.get("/CreationDate")
|
||||||
|
)
|
||||||
|
result["mod_date"] = _iso_or_raw(
|
||||||
|
reader, "modification_date", meta.get("/ModDate")
|
||||||
|
)
|
||||||
|
result["raw"] = {str(k): str(v) for k, v in meta.items()}
|
||||||
|
except Exception as exc:
|
||||||
|
result["error"] = result["error"] or str(exc)
|
||||||
|
|
||||||
|
return result
|
||||||
@@ -0,0 +1,92 @@
|
|||||||
|
"""Tests para extract_pdf_metadata."""
|
||||||
|
|
||||||
|
import os
|
||||||
|
import sys
|
||||||
|
|
||||||
|
from pypdf import PdfWriter
|
||||||
|
|
||||||
|
sys.path.insert(0, os.path.join(os.path.dirname(__file__), ".."))
|
||||||
|
|
||||||
|
from cybersecurity.extract_pdf_metadata import extract_pdf_metadata
|
||||||
|
|
||||||
|
|
||||||
|
def _make_pdf_with_metadata(path: str) -> None:
|
||||||
|
"""Crea un PDF de 2 paginas con doc info (titulo, autor, fechas)."""
|
||||||
|
writer = PdfWriter()
|
||||||
|
writer.add_blank_page(width=200, height=200)
|
||||||
|
writer.add_blank_page(width=200, height=200)
|
||||||
|
writer.add_metadata(
|
||||||
|
{
|
||||||
|
"/Title": "Documento OSINT",
|
||||||
|
"/Author": "Enmanuel G.",
|
||||||
|
"/Creator": "PyTestRig",
|
||||||
|
"/Producer": "pypdf",
|
||||||
|
"/CreationDate": "D:20240311102200+01'00'",
|
||||||
|
"/ModDate": "D:20240312113000+01'00'",
|
||||||
|
}
|
||||||
|
)
|
||||||
|
with open(path, "wb") as fh:
|
||||||
|
writer.write(fh)
|
||||||
|
|
||||||
|
|
||||||
|
def _make_pdf_without_metadata(path: str) -> None:
|
||||||
|
"""Crea un PDF de 1 pagina sin doc info."""
|
||||||
|
writer = PdfWriter()
|
||||||
|
writer.add_blank_page(width=100, height=100)
|
||||||
|
with open(path, "wb") as fh:
|
||||||
|
writer.write(fh)
|
||||||
|
|
||||||
|
|
||||||
|
def test_pdf_con_metadatos_devuelve_titulo_autor_paginas(tmp_path):
|
||||||
|
"""PDF con metadatos devuelve titulo, autor y num_pages."""
|
||||||
|
p = str(tmp_path / "withmeta.pdf")
|
||||||
|
_make_pdf_with_metadata(p)
|
||||||
|
|
||||||
|
meta = extract_pdf_metadata(p)
|
||||||
|
|
||||||
|
assert meta["error"] is None
|
||||||
|
assert meta["title"] == "Documento OSINT"
|
||||||
|
assert meta["author"] == "Enmanuel G."
|
||||||
|
assert meta["creator"] == "PyTestRig"
|
||||||
|
assert meta["producer"] == "pypdf"
|
||||||
|
assert meta["num_pages"] == 2
|
||||||
|
assert meta["raw"] # no vacio
|
||||||
|
|
||||||
|
|
||||||
|
def test_pdf_sin_doc_info_devuelve_none_sin_petar(tmp_path):
|
||||||
|
"""PDF sin doc info devuelve campos None sin petar."""
|
||||||
|
p = str(tmp_path / "nometa.pdf")
|
||||||
|
_make_pdf_without_metadata(p)
|
||||||
|
|
||||||
|
meta = extract_pdf_metadata(p)
|
||||||
|
|
||||||
|
assert meta["error"] is None
|
||||||
|
assert meta["num_pages"] == 1
|
||||||
|
assert meta["title"] is None
|
||||||
|
assert meta["author"] is None
|
||||||
|
|
||||||
|
|
||||||
|
def test_fechas_parseables_en_iso_8601(tmp_path):
|
||||||
|
"""fechas parseables se devuelven en ISO 8601."""
|
||||||
|
p = str(tmp_path / "dates.pdf")
|
||||||
|
_make_pdf_with_metadata(p)
|
||||||
|
|
||||||
|
meta = extract_pdf_metadata(p)
|
||||||
|
|
||||||
|
# pypdf parsea D:YYYYMMDDHHmmSS a datetime; isoformat() lleva 'T'.
|
||||||
|
assert meta["creation_date"] is not None
|
||||||
|
assert "2024-03-11" in meta["creation_date"]
|
||||||
|
assert "T" in meta["creation_date"]
|
||||||
|
assert meta["mod_date"] is not None
|
||||||
|
assert "2024-03-12" in meta["mod_date"]
|
||||||
|
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
import tempfile
|
||||||
|
from pathlib import Path
|
||||||
|
|
||||||
|
with tempfile.TemporaryDirectory() as d:
|
||||||
|
test_pdf_con_metadatos_devuelve_titulo_autor_paginas(Path(d))
|
||||||
|
test_pdf_sin_doc_info_devuelve_none_sin_petar(Path(d))
|
||||||
|
test_fechas_parseables_en_iso_8601(Path(d))
|
||||||
|
print("Todos los tests pasaron.")
|
||||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user