Compare commits
14 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 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
|
||||||
|
|||||||
@@ -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 {
|
||||||
|
|||||||
@@ -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,7 @@ 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 |
|
||||||
| [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 +50,9 @@ 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) | 14 | 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. Sin app GUI |
|
||||||
|
| [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 |
|
||||||
|
|
||||||
## 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,80 @@
|
|||||||
|
# Capability: obsidian
|
||||||
|
|
||||||
|
CRUD headless de vaults y notas de Obsidian, tratadas como Markdown plano con frontmatter YAML y wikilinks `[[...]]`. NO depende de la app GUI de Obsidian ni de su URI scheme — manipula los archivos `.md` directamente en disco. 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. |
|
||||||
|
|
||||||
|
## 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)
|
||||||
|
|
||||||
|
- **No habla con la app GUI** (no usa el URI scheme `obsidian://`, no abre notas en la interfaz, no dispara plugins). Si la app esta abierta, escribir en disco puede chocar con sus locks/cache — cerrar la app o refrescar manualmente.
|
||||||
|
- **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.
|
||||||
|
- **No indexa el grafo** de enlaces entre notas (solo extrae links por nota). Para grafo agregado, componer sobre `list_obsidian_notes` + `extract_obsidian_wikilinks`.
|
||||||
|
|
||||||
|
## 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,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,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,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
|
||||||
@@ -22,6 +22,21 @@ 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
|
||||||
|
|
||||||
|
# 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 +59,15 @@ __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",
|
||||||
|
"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,58 @@
|
|||||||
|
---
|
||||||
|
name: dns_records
|
||||||
|
kind: function
|
||||||
|
lang: py
|
||||||
|
domain: cybersecurity
|
||||||
|
version: "1.0.0"
|
||||||
|
purity: impure
|
||||||
|
signature: "def dns_records(dominio: str, types: list | None = None) -> dict"
|
||||||
|
description: "Recoleccion OSINT pasiva de registros DNS de un dominio ejecutando `dig +short <tipo> <dominio>` por subprocess para cada tipo (default A, AAAA, MX, TXT, NS, CNAME). Parsea la salida (una linea por valor) y devuelve un dict {tipo: [valores]}. Pasivo: solo consulta DNS publico."
|
||||||
|
tags: [osint-passive, dns, recon, cybersecurity, dig]
|
||||||
|
params:
|
||||||
|
- name: dominio
|
||||||
|
desc: "Dominio a resolver, ej. organic-machine.com. Vacio lanza RuntimeError."
|
||||||
|
- name: types
|
||||||
|
desc: "Lista de tipos de registro DNS (ej. ['A','MX']). None usa los 6 defaults: A, AAAA, MX, TXT, NS, CNAME."
|
||||||
|
output: "dict {tipo: [valores]} con una clave por tipo consultado; cada valor es la lista de lineas devueltas por `dig +short` para ese tipo (lista vacia si no hay registro o el dominio no existe)."
|
||||||
|
uses_functions: []
|
||||||
|
uses_types: []
|
||||||
|
returns: []
|
||||||
|
returns_optional: false
|
||||||
|
error_type: "error_go_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_lanza_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
|
||||||
|
records = dns_records("organic-machine.com")
|
||||||
|
print(records["A"]) # ['135.125.201.30']
|
||||||
|
print(records["MX"]) # ['10 mail.organic-machine.com.', ...]
|
||||||
|
|
||||||
|
# Solo los tipos que interesan
|
||||||
|
solo_a_mx = dns_records("organic-machine.com", types=["A", "MX"])
|
||||||
|
```
|
||||||
|
|
||||||
|
## Cuando usarla
|
||||||
|
|
||||||
|
Usala al iniciar el reconocimiento pasivo de un dominio para mapear su
|
||||||
|
infraestructura DNS publica (IPs, servidores de correo, nameservers, TXT con
|
||||||
|
SPF/DKIM/verificaciones). Es el primer paso barato antes de enumerar
|
||||||
|
subdominios o consultar RDAP.
|
||||||
|
|
||||||
|
## Gotchas
|
||||||
|
|
||||||
|
- Requiere el binario `dig` en PATH (paquete `dnsutils`/`bind-utils`). Si falta, lanza `RuntimeError`.
|
||||||
|
- Cada consulta tiene timeout de 10s; si una expira, esa clave queda como lista vacia y el resto continua.
|
||||||
|
- 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.
|
||||||
|
- Un dominio inexistente o sin un registro concreto devuelve lista vacia (no error): distingue "sin datos" mirando las listas vacias.
|
||||||
|
- Resuelve contra el resolver configurado en el sistema; resultados pueden variar segun el DNS recursivo usado.
|
||||||
@@ -0,0 +1,63 @@
|
|||||||
|
"""Recoleccion OSINT pasiva de registros DNS via el binario `dig`.
|
||||||
|
|
||||||
|
Funcion IMPURA: ejecuta `dig +short <tipo> <dominio>` como subprocess para
|
||||||
|
cada tipo de registro y parsea la salida (una linea por valor). Es OSINT
|
||||||
|
pasivo: consulta DNS publico, no envia trafico intrusivo al objetivo.
|
||||||
|
"""
|
||||||
|
|
||||||
|
import subprocess
|
||||||
|
|
||||||
|
DEFAULT_TYPES = ["A", "AAAA", "MX", "TXT", "NS", "CNAME"]
|
||||||
|
|
||||||
|
|
||||||
|
def dns_records(dominio: str, types: list | None = None) -> dict:
|
||||||
|
"""Resuelve registros DNS de un dominio ejecutando `dig +short`.
|
||||||
|
|
||||||
|
Para cada tipo en ``types`` ejecuta ``dig +short <tipo> <dominio>`` y
|
||||||
|
parsea la salida: cada linea no vacia es un valor del registro. Un
|
||||||
|
dominio inexistente (o un registro ausente) produce una lista vacia.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
dominio: Dominio a resolver (ej. ``"organic-machine.com"``).
|
||||||
|
types: Lista de tipos de registro DNS (ej. ``["A", "MX"]``). Si es
|
||||||
|
None usa los defaults: A, AAAA, MX, TXT, NS, CNAME.
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
Dict ``{tipo: [valores]}`` con una clave por tipo consultado. Cada
|
||||||
|
valor es la lista de lineas devueltas por dig para ese tipo.
|
||||||
|
|
||||||
|
Raises:
|
||||||
|
RuntimeError: Si el binario `dig` no esta instalado o el dominio
|
||||||
|
esta vacio.
|
||||||
|
"""
|
||||||
|
if not dominio or not dominio.strip():
|
||||||
|
raise RuntimeError("dns_records: dominio vacio")
|
||||||
|
|
||||||
|
query_types = types if types is not None else list(DEFAULT_TYPES)
|
||||||
|
result: dict = {}
|
||||||
|
|
||||||
|
for record_type in query_types:
|
||||||
|
try:
|
||||||
|
proc = subprocess.run(
|
||||||
|
["dig", "+short", record_type, dominio],
|
||||||
|
capture_output=True,
|
||||||
|
text=True,
|
||||||
|
timeout=10.0,
|
||||||
|
)
|
||||||
|
except FileNotFoundError as e:
|
||||||
|
raise RuntimeError(
|
||||||
|
"dns_records: binario `dig` no encontrado en PATH"
|
||||||
|
) from e
|
||||||
|
except subprocess.TimeoutExpired:
|
||||||
|
# Timeout en una consulta concreta: dejamos lista vacia y seguimos.
|
||||||
|
result[record_type] = []
|
||||||
|
continue
|
||||||
|
|
||||||
|
values = [
|
||||||
|
line.strip()
|
||||||
|
for line in proc.stdout.splitlines()
|
||||||
|
if line.strip()
|
||||||
|
]
|
||||||
|
result[record_type] = values
|
||||||
|
|
||||||
|
return result
|
||||||
@@ -0,0 +1,90 @@
|
|||||||
|
"""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):
|
||||||
|
record_type = cmd[2]
|
||||||
|
return _FakeProc(fixtures.get(record_type, ""))
|
||||||
|
|
||||||
|
monkeypatch.setattr(subprocess, "run", fake_run)
|
||||||
|
|
||||||
|
result = dns_records("organic-machine.com", types=["A", "MX", "TXT"])
|
||||||
|
|
||||||
|
assert result["A"] == ["135.125.201.30"]
|
||||||
|
assert result["MX"] == [
|
||||||
|
"10 mail.organic-machine.com.",
|
||||||
|
"20 mail2.organic-machine.com.",
|
||||||
|
]
|
||||||
|
assert result["TXT"] == ['"v=spf1 -all"']
|
||||||
|
|
||||||
|
|
||||||
|
def test_dominio_inexistente_listas_vacias(monkeypatch):
|
||||||
|
"""Salida vacia de dig (dominio inexistente) produce listas vacias."""
|
||||||
|
|
||||||
|
def fake_run(cmd, **kwargs):
|
||||||
|
return _FakeProc("")
|
||||||
|
|
||||||
|
monkeypatch.setattr(subprocess, "run", fake_run)
|
||||||
|
|
||||||
|
result = dns_records("nope-no-existe-xyz.invalid", types=["A", "AAAA"])
|
||||||
|
|
||||||
|
assert result == {"A": [], "AAAA": []}
|
||||||
|
|
||||||
|
|
||||||
|
def test_usa_tipos_default(monkeypatch):
|
||||||
|
"""Sin types consulta los 6 tipos por defecto."""
|
||||||
|
consultados = []
|
||||||
|
|
||||||
|
def fake_run(cmd, **kwargs):
|
||||||
|
consultados.append(cmd[2])
|
||||||
|
return _FakeProc("")
|
||||||
|
|
||||||
|
monkeypatch.setattr(subprocess, "run", fake_run)
|
||||||
|
|
||||||
|
result = dns_records("organic-machine.com")
|
||||||
|
|
||||||
|
assert set(result.keys()) == {"A", "AAAA", "MX", "TXT", "NS", "CNAME"}
|
||||||
|
assert consultados == ["A", "AAAA", "MX", "TXT", "NS", "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)
|
||||||
|
|
||||||
|
result = dns_records("organic-machine.com", types=["A"])
|
||||||
|
|
||||||
|
assert result == {"A": []}
|
||||||
|
|
||||||
|
|
||||||
|
def test_dominio_vacio_lanza_error():
|
||||||
|
"""Dominio vacio lanza RuntimeError."""
|
||||||
|
try:
|
||||||
|
dns_records("")
|
||||||
|
assert False, "deberia haber lanzado RuntimeError"
|
||||||
|
except RuntimeError:
|
||||||
|
pass
|
||||||
@@ -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.")
|
||||||
@@ -0,0 +1,63 @@
|
|||||||
|
---
|
||||||
|
name: guess_email_formats
|
||||||
|
kind: function
|
||||||
|
lang: py
|
||||||
|
domain: cybersecurity
|
||||||
|
version: "1.0.0"
|
||||||
|
purity: pure
|
||||||
|
signature: "def guess_email_formats(nombre: str, apellidos: str, dominio: str) -> list"
|
||||||
|
description: "Genera candidatos de email comunes (nombre.apellido, n.apellido, apellido.nombre, inicial+apellido, variantes con dos apellidos, etc.) a partir de nombre, apellidos y dominio. Normaliza acentos y ñ a ASCII en minusculas y deduplica preservando el orden. OSINT pasivo puro, sin red."
|
||||||
|
tags: [osint-passive, email, enumeration, recon, identity, cybersecurity, python]
|
||||||
|
uses_functions: []
|
||||||
|
uses_types: []
|
||||||
|
returns: []
|
||||||
|
returns_optional: false
|
||||||
|
error_type: ""
|
||||||
|
imports: [unicodedata]
|
||||||
|
params:
|
||||||
|
- name: nombre
|
||||||
|
desc: "Nombre de pila. Si tiene varios tokens se usa el primero como nombre principal."
|
||||||
|
- name: apellidos
|
||||||
|
desc: "Uno o dos apellidos separados por espacio. Con dos apellidos se generan variantes que los unen."
|
||||||
|
- name: dominio
|
||||||
|
desc: "Dominio de correo sin arroba (ej. 'empresa.com'). Si viene con '@' delante se limpia."
|
||||||
|
output: "Lista de strings '<local>@<dominio>' con los candidatos de email en orden de generacion, sin duplicados."
|
||||||
|
tested: true
|
||||||
|
tests:
|
||||||
|
- "test_nombre_simple_un_apellido"
|
||||||
|
- "test_acentos_y_enie_normalizados"
|
||||||
|
- "test_dos_apellidos_genera_variantes_unidas"
|
||||||
|
- "test_dedup_preserva_orden"
|
||||||
|
- "test_dominio_con_arroba_se_limpia"
|
||||||
|
test_file_path: "python/functions/cybersecurity/guess_email_formats_test.py"
|
||||||
|
file_path: "python/functions/cybersecurity/guess_email_formats.py"
|
||||||
|
---
|
||||||
|
|
||||||
|
## Ejemplo
|
||||||
|
|
||||||
|
```python
|
||||||
|
guess_email_formats("José", "García López", "empresa.com")
|
||||||
|
# ['jose@empresa.com',
|
||||||
|
# 'jose.garcia@empresa.com',
|
||||||
|
# 'josegarcia@empresa.com',
|
||||||
|
# 'j.garcia@empresa.com',
|
||||||
|
# 'jgarcia@empresa.com',
|
||||||
|
# 'jose_garcia@empresa.com',
|
||||||
|
# 'garcia.jose@empresa.com',
|
||||||
|
# 'joseg@empresa.com',
|
||||||
|
# 'jose.garcialopez@empresa.com',
|
||||||
|
# 'josegarcialopez@empresa.com',
|
||||||
|
# 'jose.lopez@empresa.com',
|
||||||
|
# 'jgarcialopez@empresa.com']
|
||||||
|
```
|
||||||
|
|
||||||
|
## Cuando usarla
|
||||||
|
|
||||||
|
Usala al arrancar una investigacion de identidad cuando conoces nombre + apellidos + dominio de una organizacion y quieres una lista de direcciones probables para verificar despues (MX, catch-all, validacion SMTP, breach lookup). Es el primer paso antes de cualquier comprobacion activa.
|
||||||
|
|
||||||
|
## Gotchas
|
||||||
|
|
||||||
|
- Funcion pura: NO valida que el email exista ni hace ninguna comprobacion de red. Solo genera candidatos sintacticos.
|
||||||
|
- La lista de patrones es heuristica (los formatos mas comunes), no exhaustiva: organizaciones con esquemas propios (ej. id numerico) no quedaran cubiertas.
|
||||||
|
- La normalizacion translitera acentos y ñ a ASCII y elimina cualquier caracter no alfanumerico del local part; nombres con guiones o apostrofes pierden esos separadores.
|
||||||
|
- Solo usa el primer token del nombre como nombre principal; nombres compuestos (ej. "Maria Jose") no generan variantes con el segundo nombre.
|
||||||
@@ -0,0 +1,84 @@
|
|||||||
|
"""Genera candidatos de email a partir de nombre, apellidos y dominio.
|
||||||
|
|
||||||
|
Funcion pura de OSINT pasivo: produce los patrones de direccion de correo
|
||||||
|
mas habituales en organizaciones, sin tocar la red. Util como punto de
|
||||||
|
partida para verificacion posterior (MX, catch-all, validacion SMTP, etc.).
|
||||||
|
"""
|
||||||
|
|
||||||
|
import unicodedata
|
||||||
|
|
||||||
|
|
||||||
|
def _ascii_lower(text: str) -> str:
|
||||||
|
"""Normaliza un texto a ASCII en minusculas.
|
||||||
|
|
||||||
|
Translitera acentos y caracteres latinos (a, e, n, ...) a su forma
|
||||||
|
ASCII, pasa a minusculas y elimina cualquier caracter que no sea
|
||||||
|
alfanumerico. No introduce separadores (a diferencia de un slug).
|
||||||
|
|
||||||
|
Args:
|
||||||
|
text: texto de entrada (puede contener acentos, ñ, mayusculas).
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
cadena ASCII en minusculas formada solo por [a-z0-9].
|
||||||
|
"""
|
||||||
|
# NFKD descompone los caracteres acentuados en base + diacritico.
|
||||||
|
decomposed = unicodedata.normalize("NFKD", text)
|
||||||
|
stripped = "".join(c for c in decomposed if not unicodedata.combining(c))
|
||||||
|
lowered = stripped.lower()
|
||||||
|
return "".join(c for c in lowered if c.isalnum())
|
||||||
|
|
||||||
|
|
||||||
|
def guess_email_formats(nombre: str, apellidos: str, dominio: str) -> list:
|
||||||
|
"""Genera candidatos de email comunes a partir de identidad y dominio.
|
||||||
|
|
||||||
|
Combina el nombre y los apellidos en los patrones de direccion mas
|
||||||
|
frecuentes (nombre.apellido, inicial+apellido, apellido.nombre, etc.),
|
||||||
|
normalizando acentos/ñ a ASCII y minusculas. Si hay dos apellidos
|
||||||
|
tambien genera variantes que los unen. Deduplica preservando el orden
|
||||||
|
de aparicion.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
nombre: nombre de pila (puede incluir varios tokens separados por
|
||||||
|
espacio; se usa el primero como nombre principal).
|
||||||
|
apellidos: uno o dos apellidos separados por espacio.
|
||||||
|
dominio: dominio de correo sin arroba (ej. "empresa.com").
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
lista de strings "<local>@<dominio>" con los candidatos, en el
|
||||||
|
orden en que se generan los patrones y sin duplicados.
|
||||||
|
"""
|
||||||
|
n = _ascii_lower(nombre.split()[0]) if nombre.split() else ""
|
||||||
|
apellido_tokens = [_ascii_lower(a) for a in apellidos.split() if _ascii_lower(a)]
|
||||||
|
a1 = apellido_tokens[0] if apellido_tokens else ""
|
||||||
|
a2 = apellido_tokens[1] if len(apellido_tokens) > 1 else ""
|
||||||
|
dom = dominio.strip().lstrip("@").lower()
|
||||||
|
|
||||||
|
ni = n[0] if n else ""
|
||||||
|
a1i = a1[0] if a1 else ""
|
||||||
|
apellido_full = "".join(apellido_tokens) # apellido1apellido2 unidos
|
||||||
|
|
||||||
|
locals_raw = [
|
||||||
|
n, # nombre
|
||||||
|
f"{n}.{a1}" if n and a1 else "", # nombre.apellido
|
||||||
|
f"{n}{a1}" if n and a1 else "", # nombreapellido
|
||||||
|
f"{ni}.{a1}" if ni and a1 else "", # n.apellido
|
||||||
|
f"{ni}{a1}" if ni and a1 else "", # napellido
|
||||||
|
f"{n}_{a1}" if n and a1 else "", # nombre_apellido
|
||||||
|
f"{a1}.{n}" if a1 and n else "", # apellido.nombre
|
||||||
|
f"{n}{a1i}" if n and a1i else "", # nombre+inicial_apellido
|
||||||
|
f"{n}.{apellido_full}" if n and a2 else "", # nombre.apellido1apellido2
|
||||||
|
f"{n}{apellido_full}" if n and a2 else "", # nombreapellido1apellido2
|
||||||
|
f"{n}.{a2}" if n and a2 else "", # nombre.apellido2
|
||||||
|
f"{ni}{apellido_full}" if ni and a2 else "", # n+apellidos unidos
|
||||||
|
]
|
||||||
|
|
||||||
|
seen = set()
|
||||||
|
out = []
|
||||||
|
for local in locals_raw:
|
||||||
|
if not local:
|
||||||
|
continue
|
||||||
|
email = f"{local}@{dom}"
|
||||||
|
if email not in seen:
|
||||||
|
seen.add(email)
|
||||||
|
out.append(email)
|
||||||
|
return out
|
||||||
@@ -0,0 +1,53 @@
|
|||||||
|
"""Tests para guess_email_formats."""
|
||||||
|
|
||||||
|
from guess_email_formats import guess_email_formats
|
||||||
|
|
||||||
|
|
||||||
|
def test_nombre_simple_un_apellido():
|
||||||
|
"""Nombre simple con un apellido produce los patrones base."""
|
||||||
|
result = guess_email_formats("Juan", "Perez", "empresa.com")
|
||||||
|
assert "juan.perez@empresa.com" in result
|
||||||
|
assert "juanperez@empresa.com" in result
|
||||||
|
assert "j.perez@empresa.com" in result
|
||||||
|
assert "jperez@empresa.com" in result
|
||||||
|
assert "juan_perez@empresa.com" in result
|
||||||
|
assert "perez.juan@empresa.com" in result
|
||||||
|
assert "juan@empresa.com" in result
|
||||||
|
# Sin segundo apellido no hay variantes unidas.
|
||||||
|
assert all("@empresa.com" in e for e in result)
|
||||||
|
|
||||||
|
|
||||||
|
def test_acentos_y_enie_normalizados():
|
||||||
|
"""Acentos y ñ se transliteran a ASCII en minusculas."""
|
||||||
|
result = guess_email_formats("José", "Muñoz", "acme.org")
|
||||||
|
assert "jose.munoz@acme.org" in result
|
||||||
|
assert "j.munoz@acme.org" in result
|
||||||
|
# No debe aparecer ningun caracter no ASCII.
|
||||||
|
for email in result:
|
||||||
|
assert email == email.encode("ascii", "ignore").decode("ascii")
|
||||||
|
|
||||||
|
|
||||||
|
def test_dos_apellidos_genera_variantes_unidas():
|
||||||
|
"""Dos apellidos producen variantes que los unen."""
|
||||||
|
result = guess_email_formats("Maria", "Garcia Lopez", "uni.edu")
|
||||||
|
assert "maria.garcialopez@uni.edu" in result
|
||||||
|
assert "mariagarcialopez@uni.edu" in result
|
||||||
|
assert "maria.lopez@uni.edu" in result
|
||||||
|
assert "mgarcialopez@uni.edu" in result
|
||||||
|
# El patron de primer apellido sigue presente.
|
||||||
|
assert "maria.garcia@uni.edu" in result
|
||||||
|
|
||||||
|
|
||||||
|
def test_dedup_preserva_orden():
|
||||||
|
"""Los candidatos no se repiten y mantienen el orden de generacion."""
|
||||||
|
result = guess_email_formats("Ana", "Ruiz", "x.com")
|
||||||
|
assert len(result) == len(set(result))
|
||||||
|
# nombre.apellido aparece antes que apellido.nombre.
|
||||||
|
assert result.index("ana.ruiz@x.com") < result.index("ruiz.ana@x.com")
|
||||||
|
|
||||||
|
|
||||||
|
def test_dominio_con_arroba_se_limpia():
|
||||||
|
"""Un dominio prefijado con @ se normaliza sin duplicar la arroba."""
|
||||||
|
result = guess_email_formats("Luis", "Soto", "@corp.io")
|
||||||
|
assert "luis.soto@corp.io" in result
|
||||||
|
assert all(e.count("@") == 1 for e in result)
|
||||||
@@ -0,0 +1,57 @@
|
|||||||
|
---
|
||||||
|
name: scan_ficha_attachments_metadata
|
||||||
|
kind: function
|
||||||
|
lang: py
|
||||||
|
domain: cybersecurity
|
||||||
|
version: "1.0.0"
|
||||||
|
purity: impure
|
||||||
|
signature: "def scan_ficha_attachments_metadata(attachments_dir: str) -> dict"
|
||||||
|
description: "Orquestador OSINT pasivo: recorre un directorio de attachments de una ficha (imagenes y PDFs), extrae sus metadatos componiendo extract_exif_metadata (imagenes .jpg/.jpeg/.png/.heic) y extract_pdf_metadata (.pdf), y agrega los puntos GPS y las fechas encontradas. Devuelve files + gps_points + dates + summary."
|
||||||
|
tags: [osint-enrich, osint-passive, cybersecurity, metadata, exif, pdf]
|
||||||
|
uses_functions: [extract_exif_metadata_py_cybersecurity, extract_pdf_metadata_py_cybersecurity]
|
||||||
|
uses_types: []
|
||||||
|
returns: []
|
||||||
|
returns_optional: false
|
||||||
|
error_type: "error_go_core"
|
||||||
|
imports: [os]
|
||||||
|
params:
|
||||||
|
- name: attachments_dir
|
||||||
|
desc: "ruta a un directorio de attachments, p.ej. /home/enmanuel/Obsidian/osint/attachments/personas/<slug>/. Se recorre recursivamente. Si no es un directorio existente lanza NotADirectoryError"
|
||||||
|
output: "dict con files (lista de {path, type, metadata} por archivo procesado: type es 'image' o 'pdf'), gps_points (lista de {file, lat, lon} agregando las coordenadas EXIF), dates (lista de fechas str de EXIF y PDF) y summary ({n_files, n_images, n_pdfs, n_gps_points, n_dates, errors}). Los archivos cuya extraccion falla quedan con metadata={'error': ...} y suman a summary.errors"
|
||||||
|
tested: true
|
||||||
|
tests: ["test_golden_agrega_gps_y_fechas_de_imagen_y_pdf", "test_directorio_inexistente_lanza", "test_ignora_extensiones_no_soportadas", "test_error_en_archivo_se_captura_en_summary"]
|
||||||
|
test_file_path: "python/functions/cybersecurity/scan_ficha_attachments_metadata_test.py"
|
||||||
|
file_path: "python/functions/cybersecurity/scan_ficha_attachments_metadata.py"
|
||||||
|
---
|
||||||
|
|
||||||
|
## Ejemplo
|
||||||
|
|
||||||
|
```python
|
||||||
|
import sys, os
|
||||||
|
sys.path.insert(0, os.path.join("python", "functions"))
|
||||||
|
from cybersecurity import scan_ficha_attachments_metadata
|
||||||
|
|
||||||
|
# Escanea los attachments de una persona en el vault OSINT.
|
||||||
|
res = scan_ficha_attachments_metadata(
|
||||||
|
"/home/enmanuel/Obsidian/osint/attachments/personas/juan-perez/"
|
||||||
|
)
|
||||||
|
|
||||||
|
print(res["summary"]) # {'n_files': 7, 'n_images': 5, 'n_pdfs': 2, 'n_gps_points': 2, 'n_dates': 4, 'errors': 0}
|
||||||
|
for p in res["gps_points"]:
|
||||||
|
print(p["file"], p["lat"], p["lon"]) # coordenadas extraidas de las fotos
|
||||||
|
print(res["dates"]) # fechas EXIF + fechas de creacion/modificacion de los PDFs
|
||||||
|
```
|
||||||
|
|
||||||
|
## Cuando usarla
|
||||||
|
|
||||||
|
- Cuando montas la ficha OSINT de una persona u organizacion y quieres saber de un vistazo que huella de metadatos dejan los documentos/fotos que ya tienes guardados (donde y cuando se hicieron).
|
||||||
|
- Antes de publicar/compartir attachments propios: para auditar que GPS y fechas se filtrarian.
|
||||||
|
- Como paso de enriquecimiento dentro de un pipeline de investigacion, justo despues de descargar los attachments al directorio de la ficha.
|
||||||
|
|
||||||
|
## Gotchas
|
||||||
|
|
||||||
|
- **Uso solo para investigacion autorizada.** Extraer metadatos de archivos ajenos sin permiso puede ser ilegal segun jurisdiccion. Limitate a tus propios documentos o a material que estes autorizado a analizar.
|
||||||
|
- Funcion IMPURA: hace I/O sobre el sistema de archivos (recorrido recursivo con `os.walk`). Si `attachments_dir` no existe lanza `NotADirectoryError`.
|
||||||
|
- La extraccion por archivo se aisla con try/except: un archivo corrupto no aborta el escaneo, queda con `metadata={"error": ...}` y suma a `summary.errors`.
|
||||||
|
- Solo procesa imagenes .jpg/.jpeg/.png/.heic y PDFs .pdf; el resto se ignora silenciosamente. El soporte real de HEIC/EXIF depende de lo que `extract_exif_metadata` (Pillow) pueda abrir en el sistema.
|
||||||
|
- Las fechas se devuelven tal cual las reportan EXIF/PDF (formatos heterogeneos, sin normalizar a ISO).
|
||||||
@@ -0,0 +1,107 @@
|
|||||||
|
"""Orquestador OSINT pasivo: escanea metadatos de los attachments de una ficha.
|
||||||
|
|
||||||
|
Recorre un directorio de attachments (imagenes y PDFs) y extrae sus metadatos
|
||||||
|
componiendo las funciones atomicas del registro (`extract_exif_metadata`,
|
||||||
|
`extract_pdf_metadata`). Agrega los puntos GPS y las fechas encontradas para
|
||||||
|
dar una vista rapida de la huella de metadatos de una persona/organizacion.
|
||||||
|
|
||||||
|
Funcion IMPURA: hace I/O sobre el sistema de archivos.
|
||||||
|
"""
|
||||||
|
|
||||||
|
import os
|
||||||
|
|
||||||
|
from cybersecurity import extract_exif_metadata, extract_pdf_metadata
|
||||||
|
|
||||||
|
_IMAGE_EXTS = (".jpg", ".jpeg", ".png", ".heic")
|
||||||
|
_PDF_EXTS = (".pdf",)
|
||||||
|
|
||||||
|
|
||||||
|
def _iter_files(attachments_dir: str):
|
||||||
|
"""Recorre recursivamente el directorio devolviendo rutas de archivo ordenadas."""
|
||||||
|
for root, _dirs, files in os.walk(attachments_dir):
|
||||||
|
for name in sorted(files):
|
||||||
|
yield os.path.join(root, name)
|
||||||
|
|
||||||
|
|
||||||
|
def scan_ficha_attachments_metadata(attachments_dir: str) -> dict:
|
||||||
|
"""Escanea un directorio de attachments y agrega los metadatos extraidos.
|
||||||
|
|
||||||
|
Aplica `extract_exif_metadata` a las imagenes (.jpg/.jpeg/.png/.heic) y
|
||||||
|
`extract_pdf_metadata` a los PDFs (.pdf). Agrega los puntos GPS de las
|
||||||
|
imagenes y todas las fechas detectadas (EXIF + PDF).
|
||||||
|
|
||||||
|
Args:
|
||||||
|
attachments_dir: ruta a un directorio de attachments, p.ej.
|
||||||
|
`/home/enmanuel/Obsidian/osint/attachments/personas/<slug>/`.
|
||||||
|
Se recorre recursivamente.
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
dict con las claves:
|
||||||
|
- files: lista de {path, type, metadata} por archivo procesado.
|
||||||
|
- gps_points: lista de {file, lat, lon} con las coordenadas EXIF.
|
||||||
|
- dates: lista de fechas (str) encontradas en EXIF y PDF.
|
||||||
|
- summary: {n_files, n_images, n_pdfs, n_gps_points, n_dates,
|
||||||
|
errors} con conteos agregados.
|
||||||
|
|
||||||
|
Raises:
|
||||||
|
NotADirectoryError: si `attachments_dir` no es un directorio existente.
|
||||||
|
"""
|
||||||
|
if not os.path.isdir(attachments_dir):
|
||||||
|
raise NotADirectoryError(f"no es un directorio: {attachments_dir}")
|
||||||
|
|
||||||
|
files: list[dict] = []
|
||||||
|
gps_points: list[dict] = []
|
||||||
|
dates: list[str] = []
|
||||||
|
n_images = 0
|
||||||
|
n_pdfs = 0
|
||||||
|
errors = 0
|
||||||
|
|
||||||
|
for path in _iter_files(attachments_dir):
|
||||||
|
ext = os.path.splitext(path)[1].lower()
|
||||||
|
|
||||||
|
if ext in _IMAGE_EXTS:
|
||||||
|
ftype = "image"
|
||||||
|
elif ext in _PDF_EXTS:
|
||||||
|
ftype = "pdf"
|
||||||
|
else:
|
||||||
|
continue
|
||||||
|
|
||||||
|
try:
|
||||||
|
if ftype == "image":
|
||||||
|
metadata = extract_exif_metadata(path)
|
||||||
|
n_images += 1
|
||||||
|
lat = metadata.get("gps_lat")
|
||||||
|
lon = metadata.get("gps_lon")
|
||||||
|
if lat is not None and lon is not None:
|
||||||
|
gps_points.append({"file": path, "lat": lat, "lon": lon})
|
||||||
|
dt = metadata.get("datetime")
|
||||||
|
if dt:
|
||||||
|
dates.append(str(dt))
|
||||||
|
else:
|
||||||
|
metadata = extract_pdf_metadata(path)
|
||||||
|
n_pdfs += 1
|
||||||
|
for key in ("creation_date", "modification_date", "creationDate", "modDate"):
|
||||||
|
val = metadata.get(key)
|
||||||
|
if val:
|
||||||
|
dates.append(str(val))
|
||||||
|
except Exception as exc: # noqa: BLE001 - I/O sobre archivos heterogeneos
|
||||||
|
errors += 1
|
||||||
|
metadata = {"error": f"{type(exc).__name__}: {exc}"}
|
||||||
|
|
||||||
|
files.append({"path": path, "type": ftype, "metadata": metadata})
|
||||||
|
|
||||||
|
summary = {
|
||||||
|
"n_files": len(files),
|
||||||
|
"n_images": n_images,
|
||||||
|
"n_pdfs": n_pdfs,
|
||||||
|
"n_gps_points": len(gps_points),
|
||||||
|
"n_dates": len(dates),
|
||||||
|
"errors": errors,
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
"files": files,
|
||||||
|
"gps_points": gps_points,
|
||||||
|
"dates": dates,
|
||||||
|
"summary": summary,
|
||||||
|
}
|
||||||
@@ -0,0 +1,113 @@
|
|||||||
|
"""Tests para scan_ficha_attachments_metadata.
|
||||||
|
|
||||||
|
Las funciones compuestas (extract_exif_metadata / extract_pdf_metadata) se
|
||||||
|
monkeypatchean para no depender ni de archivos reales ni de Pillow. Solo se
|
||||||
|
verifica la orquestacion y la agregacion (GPS, fechas, summary, manejo de
|
||||||
|
errores y filtrado de extensiones).
|
||||||
|
"""
|
||||||
|
|
||||||
|
import importlib
|
||||||
|
import os
|
||||||
|
import sys
|
||||||
|
|
||||||
|
import pytest
|
||||||
|
|
||||||
|
sys.path.insert(0, os.path.join(os.path.dirname(__file__), "..", ".."))
|
||||||
|
|
||||||
|
from cybersecurity import scan_ficha_attachments_metadata
|
||||||
|
|
||||||
|
# El paquete re-exporta la funcion bajo el mismo nombre que el submodulo, asi
|
||||||
|
# que `cybersecurity.scan_ficha_attachments_metadata` resuelve a la funcion.
|
||||||
|
# Para parchear los globals del modulo orquestador lo tomamos via importlib.
|
||||||
|
mod = importlib.import_module("cybersecurity.scan_ficha_attachments_metadata")
|
||||||
|
|
||||||
|
|
||||||
|
def _make_tree(tmp_path, names):
|
||||||
|
"""Crea archivos vacios en tmp_path; devuelve el directorio."""
|
||||||
|
d = tmp_path / "ficha"
|
||||||
|
d.mkdir()
|
||||||
|
for name in names:
|
||||||
|
(d / name).write_bytes(b"")
|
||||||
|
return str(d)
|
||||||
|
|
||||||
|
|
||||||
|
def test_golden_agrega_gps_y_fechas_de_imagen_y_pdf(tmp_path, monkeypatch):
|
||||||
|
attachments = _make_tree(tmp_path, ["foto1.jpg", "foto2.png", "doc.pdf"])
|
||||||
|
|
||||||
|
exif_by_name = {
|
||||||
|
"foto1.jpg": {"datetime": "2024:01:02 10:11:12", "gps_lat": 40.4, "gps_lon": -3.7},
|
||||||
|
"foto2.png": {"datetime": None, "gps_lat": None, "gps_lon": None},
|
||||||
|
}
|
||||||
|
|
||||||
|
def fake_exif(path):
|
||||||
|
return exif_by_name[os.path.basename(path)]
|
||||||
|
|
||||||
|
def fake_pdf(path):
|
||||||
|
return {"creation_date": "D:20230101000000", "modification_date": "D:20230202000000"}
|
||||||
|
|
||||||
|
monkeypatch.setattr(mod, "extract_exif_metadata", fake_exif)
|
||||||
|
monkeypatch.setattr(mod, "extract_pdf_metadata", fake_pdf)
|
||||||
|
|
||||||
|
res = scan_ficha_attachments_metadata(attachments)
|
||||||
|
|
||||||
|
# 3 archivos procesados (2 imagenes + 1 pdf).
|
||||||
|
assert res["summary"] == {
|
||||||
|
"n_files": 3,
|
||||||
|
"n_images": 2,
|
||||||
|
"n_pdfs": 1,
|
||||||
|
"n_gps_points": 1,
|
||||||
|
"n_dates": 3, # 1 de la imagen + 2 del pdf
|
||||||
|
"errors": 0,
|
||||||
|
}
|
||||||
|
|
||||||
|
# GPS solo de foto1.
|
||||||
|
assert res["gps_points"] == [
|
||||||
|
{"file": os.path.join(attachments, "foto1.jpg"), "lat": 40.4, "lon": -3.7}
|
||||||
|
]
|
||||||
|
|
||||||
|
# Fechas agregadas de EXIF + PDF.
|
||||||
|
assert "2024:01:02 10:11:12" in res["dates"]
|
||||||
|
assert "D:20230101000000" in res["dates"]
|
||||||
|
assert "D:20230202000000" in res["dates"]
|
||||||
|
|
||||||
|
# files lleva path/type/metadata por cada uno.
|
||||||
|
types = {os.path.basename(f["path"]): f["type"] for f in res["files"]}
|
||||||
|
assert types == {"foto1.jpg": "image", "foto2.png": "image", "doc.pdf": "pdf"}
|
||||||
|
|
||||||
|
|
||||||
|
def test_directorio_inexistente_lanza(tmp_path):
|
||||||
|
with pytest.raises(NotADirectoryError):
|
||||||
|
scan_ficha_attachments_metadata(str(tmp_path / "no-existe"))
|
||||||
|
|
||||||
|
|
||||||
|
def test_ignora_extensiones_no_soportadas(tmp_path, monkeypatch):
|
||||||
|
attachments = _make_tree(tmp_path, ["nota.txt", "video.mp4", "foto.jpg"])
|
||||||
|
|
||||||
|
monkeypatch.setattr(mod, "extract_exif_metadata", lambda p: {"gps_lat": None, "gps_lon": None, "datetime": None})
|
||||||
|
monkeypatch.setattr(mod, "extract_pdf_metadata", lambda p: {})
|
||||||
|
|
||||||
|
res = scan_ficha_attachments_metadata(attachments)
|
||||||
|
|
||||||
|
# Solo la imagen cuenta; txt y mp4 se ignoran.
|
||||||
|
assert res["summary"]["n_files"] == 1
|
||||||
|
assert res["summary"]["n_images"] == 1
|
||||||
|
assert res["summary"]["n_pdfs"] == 0
|
||||||
|
assert [os.path.basename(f["path"]) for f in res["files"]] == ["foto.jpg"]
|
||||||
|
|
||||||
|
|
||||||
|
def test_error_en_archivo_se_captura_en_summary(tmp_path, monkeypatch):
|
||||||
|
attachments = _make_tree(tmp_path, ["roto.jpg", "ok.pdf"])
|
||||||
|
|
||||||
|
def boom(path):
|
||||||
|
raise OSError("imagen corrupta")
|
||||||
|
|
||||||
|
monkeypatch.setattr(mod, "extract_exif_metadata", boom)
|
||||||
|
monkeypatch.setattr(mod, "extract_pdf_metadata", lambda p: {"creation_date": "D:20200101"})
|
||||||
|
|
||||||
|
res = scan_ficha_attachments_metadata(attachments)
|
||||||
|
|
||||||
|
assert res["summary"]["errors"] == 1
|
||||||
|
assert res["summary"]["n_files"] == 2 # ambos archivos quedan registrados
|
||||||
|
roto = next(f for f in res["files"] if os.path.basename(f["path"]) == "roto.jpg")
|
||||||
|
assert "error" in roto["metadata"]
|
||||||
|
assert "imagen corrupta" in roto["metadata"]["error"]
|
||||||
@@ -0,0 +1,68 @@
|
|||||||
|
---
|
||||||
|
name: whois_lookup
|
||||||
|
kind: function
|
||||||
|
lang: py
|
||||||
|
domain: cybersecurity
|
||||||
|
version: "1.0.0"
|
||||||
|
purity: impure
|
||||||
|
signature: "def whois_lookup(dominio: str, timeout_s: float = 15.0) -> dict"
|
||||||
|
description: "Recoleccion OSINT pasiva de datos de registro de dominio via RDAP (reemplazo moderno de WHOIS sobre HTTP/JSON). Consulta https://rdap.org/domain/<dominio> con http_get_json y normaliza registrar, fechas de creacion/expiracion/ultimo cambio, nameservers, estados y entidades. Devuelve {found: False} si el dominio no existe (404)."
|
||||||
|
tags: [osint-passive, whois, rdap, recon, cybersecurity]
|
||||||
|
params:
|
||||||
|
- name: dominio
|
||||||
|
desc: "Dominio a consultar, ej. organic-machine.com. Vacio lanza RuntimeError."
|
||||||
|
- name: timeout_s
|
||||||
|
desc: "Segundos maximo de espera de la peticion HTTP a rdap.org (default 15.0)."
|
||||||
|
output: "dict normalizado con found (bool), registrar, creation_date, expiration_date, last_changed, nameservers (lista), status (lista), entities (lista de {handle, roles}) y raw (RDAP completo). Si el dominio no existe (HTTP 404) devuelve {found: False}."
|
||||||
|
uses_functions: ["http_get_json_py_infra"]
|
||||||
|
uses_types: []
|
||||||
|
returns: []
|
||||||
|
returns_optional: false
|
||||||
|
error_type: "error_go_core"
|
||||||
|
imports: []
|
||||||
|
tested: true
|
||||||
|
tests: ["test_normaliza_respuesta_rdap", "test_dominio_no_encontrado_404", "test_otro_error_http_se_propaga", "test_sin_registrar_ni_fechas", "test_dominio_vacio_lanza_error"]
|
||||||
|
test_file_path: "python/functions/cybersecurity/whois_lookup_test.py"
|
||||||
|
file_path: "python/functions/cybersecurity/whois_lookup.py"
|
||||||
|
---
|
||||||
|
|
||||||
|
## Ejemplo
|
||||||
|
|
||||||
|
```python
|
||||||
|
import sys, os
|
||||||
|
sys.path.insert(0, os.path.join("python", "functions"))
|
||||||
|
from cybersecurity import whois_lookup
|
||||||
|
|
||||||
|
info = whois_lookup("organic-machine.com")
|
||||||
|
if info["found"]:
|
||||||
|
print(info["registrar"]) # 'Example Registrar Inc.'
|
||||||
|
print(info["creation_date"]) # '2020-01-15T10:00:00Z'
|
||||||
|
print(info["expiration_date"]) # '2027-01-15T10:00:00Z'
|
||||||
|
print(info["nameservers"]) # ['ns1.example.net', 'ns2.example.net']
|
||||||
|
print(info["status"]) # ['client transfer prohibited']
|
||||||
|
else:
|
||||||
|
print("dominio no registrado")
|
||||||
|
```
|
||||||
|
|
||||||
|
## Cuando usarla
|
||||||
|
|
||||||
|
Usala para obtener metadatos de registro de un dominio sin depender del CLI
|
||||||
|
`whois` (no instalado): edad del dominio, fecha de expiracion (dominios a
|
||||||
|
punto de caducar), registrar y nameservers autoritativos. Util en perfilado
|
||||||
|
pasivo, deteccion de dominios recien creados (typosquatting/phishing) y
|
||||||
|
validacion de propiedad.
|
||||||
|
|
||||||
|
## Gotchas
|
||||||
|
|
||||||
|
- RDAP no esta uniformemente desplegado en todos los TLD: algunos devuelven
|
||||||
|
campos vacios o ni siquiera responden. Por eso los campos opcionales pueden
|
||||||
|
quedar `None` y `nameservers`/`status`/`entities` listas vacias.
|
||||||
|
- rdap.org actua como bootstrap y redirige al servidor RDAP autoritativo del
|
||||||
|
TLD; depende de su disponibilidad.
|
||||||
|
- El registrante (`entities` con rol distinto de `registrar`) suele estar
|
||||||
|
redactado por privacy/GDPR: casi siempre solo veras `handle` y `roles`, sin
|
||||||
|
datos personales.
|
||||||
|
- Un dominio no registrado devuelve `{"found": False}` (HTTP 404); cualquier
|
||||||
|
otro error HTTP (rate limit 429, 5xx) se propaga como `RuntimeError`.
|
||||||
|
- Las fechas se devuelven tal cual las da RDAP (ISO 8601 UTC), sin parsear a
|
||||||
|
objetos `datetime`.
|
||||||
@@ -0,0 +1,114 @@
|
|||||||
|
"""Recoleccion OSINT pasiva de datos de registro de dominio via RDAP.
|
||||||
|
|
||||||
|
Funcion IMPURA: consulta el servicio RDAP publico (reemplazo moderno de
|
||||||
|
WHOIS, sobre HTTP/JSON) y normaliza la respuesta. Es OSINT pasivo: no toca
|
||||||
|
al dominio objetivo, solo el directorio RDAP publico.
|
||||||
|
"""
|
||||||
|
|
||||||
|
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 _events_by_action(raw: dict) -> dict:
|
||||||
|
"""Indexa la lista RDAP ``events`` por ``eventAction`` -> ``eventDate``."""
|
||||||
|
out: dict = {}
|
||||||
|
for event in raw.get("events", []) or []:
|
||||||
|
action = event.get("eventAction")
|
||||||
|
date = event.get("eventDate")
|
||||||
|
if action and date:
|
||||||
|
out[action] = date
|
||||||
|
return out
|
||||||
|
|
||||||
|
|
||||||
|
def _extract_registrar(raw: dict) -> str | None:
|
||||||
|
"""Busca la entidad con rol ``registrar`` y devuelve su nombre vCard."""
|
||||||
|
for entity in raw.get("entities", []) or []:
|
||||||
|
roles = entity.get("roles", []) or []
|
||||||
|
if "registrar" not in roles:
|
||||||
|
continue
|
||||||
|
vcard = entity.get("vcardArray")
|
||||||
|
if isinstance(vcard, list) and len(vcard) == 2:
|
||||||
|
for field in vcard[1]:
|
||||||
|
if isinstance(field, list) and field and field[0] == "fn":
|
||||||
|
return field[3]
|
||||||
|
return entity.get("handle")
|
||||||
|
return None
|
||||||
|
|
||||||
|
|
||||||
|
def _extract_nameservers(raw: dict) -> list:
|
||||||
|
"""Extrae los ldhName de los nameservers RDAP, ordenados."""
|
||||||
|
servers = []
|
||||||
|
for ns in raw.get("nameservers", []) or []:
|
||||||
|
name = ns.get("ldhName")
|
||||||
|
if name:
|
||||||
|
servers.append(name.lower())
|
||||||
|
return sorted(set(servers))
|
||||||
|
|
||||||
|
|
||||||
|
def whois_lookup(dominio: str, timeout_s: float = 15.0) -> dict:
|
||||||
|
"""Consulta RDAP de un dominio y normaliza la informacion de registro.
|
||||||
|
|
||||||
|
Usa ``http_get_json`` del registry contra ``https://rdap.org/domain/<dominio>``
|
||||||
|
(rdap.org redirige al servidor RDAP autoritativo del TLD). Normaliza
|
||||||
|
registrar, fechas (creacion / expiracion / ultimo cambio), nameservers,
|
||||||
|
estados y entidades, e incluye la respuesta cruda en ``raw``.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
dominio: Dominio a consultar (ej. ``"organic-machine.com"``).
|
||||||
|
timeout_s: Segundos maximo de espera de la peticion HTTP (default 15).
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
Dict normalizado con claves: ``found`` (bool), ``registrar``,
|
||||||
|
``creation_date``, ``expiration_date``, ``last_changed``,
|
||||||
|
``nameservers`` (lista), ``status`` (lista), ``entities`` (lista de
|
||||||
|
roles/handles) y ``raw`` (respuesta RDAP completa). Si el dominio no
|
||||||
|
existe (HTTP 404) devuelve ``{"found": False}``.
|
||||||
|
|
||||||
|
Raises:
|
||||||
|
RuntimeError: Si el dominio esta vacio o la peticion falla por una
|
||||||
|
razon distinta de 404.
|
||||||
|
"""
|
||||||
|
if not dominio or not dominio.strip():
|
||||||
|
raise RuntimeError("whois_lookup: dominio vacio")
|
||||||
|
|
||||||
|
url = f"https://rdap.org/domain/{dominio.strip()}"
|
||||||
|
|
||||||
|
try:
|
||||||
|
raw = http_get_json(url, timeout=timeout_s)
|
||||||
|
except RuntimeError as e:
|
||||||
|
# http_get_json envuelve los HTTPError como "HTTP <code>".
|
||||||
|
if "HTTP 404" in str(e):
|
||||||
|
return {"found": False}
|
||||||
|
raise
|
||||||
|
|
||||||
|
if not isinstance(raw, dict):
|
||||||
|
raise RuntimeError(
|
||||||
|
f"whois_lookup: respuesta RDAP inesperada (tipo {type(raw).__name__})"
|
||||||
|
)
|
||||||
|
|
||||||
|
events = _events_by_action(raw)
|
||||||
|
entities = [
|
||||||
|
{
|
||||||
|
"handle": ent.get("handle"),
|
||||||
|
"roles": ent.get("roles", []) or [],
|
||||||
|
}
|
||||||
|
for ent in raw.get("entities", []) or []
|
||||||
|
]
|
||||||
|
|
||||||
|
return {
|
||||||
|
"found": True,
|
||||||
|
"registrar": _extract_registrar(raw),
|
||||||
|
"creation_date": events.get("registration"),
|
||||||
|
"expiration_date": events.get("expiration"),
|
||||||
|
"last_changed": events.get("last changed"),
|
||||||
|
"nameservers": _extract_nameservers(raw),
|
||||||
|
"status": raw.get("status", []) or [],
|
||||||
|
"entities": entities,
|
||||||
|
"raw": raw,
|
||||||
|
}
|
||||||
@@ -0,0 +1,109 @@
|
|||||||
|
"""Tests para whois_lookup."""
|
||||||
|
|
||||||
|
import os
|
||||||
|
import sys
|
||||||
|
|
||||||
|
sys.path.insert(0, os.path.dirname(__file__))
|
||||||
|
|
||||||
|
import whois_lookup as wl
|
||||||
|
from whois_lookup import whois_lookup
|
||||||
|
|
||||||
|
|
||||||
|
def _rdap_sample() -> dict:
|
||||||
|
return {
|
||||||
|
"ldhName": "organic-machine.com",
|
||||||
|
"status": ["client transfer prohibited"],
|
||||||
|
"events": [
|
||||||
|
{"eventAction": "registration", "eventDate": "2020-01-15T10:00:00Z"},
|
||||||
|
{"eventAction": "expiration", "eventDate": "2027-01-15T10:00:00Z"},
|
||||||
|
{"eventAction": "last changed", "eventDate": "2026-01-10T08:30:00Z"},
|
||||||
|
],
|
||||||
|
"nameservers": [
|
||||||
|
{"ldhName": "ns1.example.net"},
|
||||||
|
{"ldhName": "NS2.EXAMPLE.NET"},
|
||||||
|
],
|
||||||
|
"entities": [
|
||||||
|
{
|
||||||
|
"handle": "REG-123",
|
||||||
|
"roles": ["registrar"],
|
||||||
|
"vcardArray": [
|
||||||
|
"vcard",
|
||||||
|
[
|
||||||
|
["version", {}, "text", "4.0"],
|
||||||
|
["fn", {}, "text", "Example Registrar Inc."],
|
||||||
|
],
|
||||||
|
],
|
||||||
|
},
|
||||||
|
{"handle": "REGISTRANT-9", "roles": ["registrant"]},
|
||||||
|
],
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
def test_normaliza_respuesta_rdap(monkeypatch):
|
||||||
|
"""Extrae registrar, fechas, nameservers, status y entities."""
|
||||||
|
monkeypatch.setattr(wl, "http_get_json", lambda url, timeout=15.0: _rdap_sample())
|
||||||
|
|
||||||
|
result = whois_lookup("organic-machine.com")
|
||||||
|
|
||||||
|
assert result["found"] is True
|
||||||
|
assert result["registrar"] == "Example Registrar Inc."
|
||||||
|
assert result["creation_date"] == "2020-01-15T10:00:00Z"
|
||||||
|
assert result["expiration_date"] == "2027-01-15T10:00:00Z"
|
||||||
|
assert result["last_changed"] == "2026-01-10T08:30:00Z"
|
||||||
|
assert result["nameservers"] == ["ns1.example.net", "ns2.example.net"]
|
||||||
|
assert result["status"] == ["client transfer prohibited"]
|
||||||
|
assert {"handle": "REGISTRANT-9", "roles": ["registrant"]} in result["entities"]
|
||||||
|
assert result["raw"]["ldhName"] == "organic-machine.com"
|
||||||
|
|
||||||
|
|
||||||
|
def test_dominio_no_encontrado_404(monkeypatch):
|
||||||
|
"""Un HTTP 404 de http_get_json devuelve {'found': False}."""
|
||||||
|
|
||||||
|
def fake(url, timeout=15.0):
|
||||||
|
raise RuntimeError("http_get_json: HTTP 404 at 'rdap.org' — not found")
|
||||||
|
|
||||||
|
monkeypatch.setattr(wl, "http_get_json", fake)
|
||||||
|
|
||||||
|
result = whois_lookup("nope-no-existe-xyz.invalid")
|
||||||
|
|
||||||
|
assert result == {"found": False}
|
||||||
|
|
||||||
|
|
||||||
|
def test_otro_error_http_se_propaga(monkeypatch):
|
||||||
|
"""Un error HTTP distinto de 404 se propaga como RuntimeError."""
|
||||||
|
|
||||||
|
def fake(url, timeout=15.0):
|
||||||
|
raise RuntimeError("http_get_json: HTTP 500 at 'rdap.org' — boom")
|
||||||
|
|
||||||
|
monkeypatch.setattr(wl, "http_get_json", fake)
|
||||||
|
|
||||||
|
try:
|
||||||
|
whois_lookup("organic-machine.com")
|
||||||
|
assert False, "deberia haberse propagado el error 500"
|
||||||
|
except RuntimeError as e:
|
||||||
|
assert "HTTP 500" in str(e)
|
||||||
|
|
||||||
|
|
||||||
|
def test_sin_registrar_ni_fechas(monkeypatch):
|
||||||
|
"""RDAP minimo: campos opcionales quedan None / listas vacias."""
|
||||||
|
monkeypatch.setattr(
|
||||||
|
wl, "http_get_json", lambda url, timeout=15.0: {"ldhName": "x.com"}
|
||||||
|
)
|
||||||
|
|
||||||
|
result = whois_lookup("x.com")
|
||||||
|
|
||||||
|
assert result["found"] is True
|
||||||
|
assert result["registrar"] is None
|
||||||
|
assert result["creation_date"] is None
|
||||||
|
assert result["nameservers"] == []
|
||||||
|
assert result["status"] == []
|
||||||
|
assert result["entities"] == []
|
||||||
|
|
||||||
|
|
||||||
|
def test_dominio_vacio_lanza_error():
|
||||||
|
"""Dominio vacio lanza RuntimeError."""
|
||||||
|
try:
|
||||||
|
whois_lookup("")
|
||||||
|
assert False, "deberia haber lanzado RuntimeError"
|
||||||
|
except RuntimeError:
|
||||||
|
pass
|
||||||
@@ -1,10 +1,44 @@
|
|||||||
from .setup_logger import setup_logger, get_logger
|
from .setup_logger import setup_logger, get_logger
|
||||||
from .generate_app_icon import generate_app_icon
|
from .generate_app_icon import generate_app_icon
|
||||||
|
from .generate_initials_avatar import generate_initials_avatar
|
||||||
from .http_replay_sequence import http_replay_sequence
|
from .http_replay_sequence import http_replay_sequence
|
||||||
|
from .hoppscotch_login import hoppscotch_login
|
||||||
|
from .hoppscotch_create_request import hoppscotch_create_request
|
||||||
|
from .hoppscotch_update_request import hoppscotch_update_request
|
||||||
|
from .hoppscotch_delete_request import hoppscotch_delete_request
|
||||||
|
from .hoppscotch_list_requests import hoppscotch_list_requests
|
||||||
|
from .pass_get_secret import pass_get_secret
|
||||||
|
from .hoppscotch_set_environment import hoppscotch_set_environment
|
||||||
|
from .hoppscotch_run_request import hoppscotch_run_request
|
||||||
|
from .split_vcards import split_vcards
|
||||||
|
from .split_vevents_to_vcalendars import split_vevents_to_vcalendars
|
||||||
|
from .extract_or_make_uid import extract_or_make_uid
|
||||||
|
from .carddav_put_vcard import carddav_put_vcard
|
||||||
|
from .caldav_put_event import caldav_put_event
|
||||||
|
from .dav_list_resources import dav_list_resources
|
||||||
|
from .dav_get_resource import dav_get_resource
|
||||||
|
from .dav_delete_resource import dav_delete_resource
|
||||||
|
|
||||||
__all__ = [
|
__all__ = [
|
||||||
"setup_logger",
|
"setup_logger",
|
||||||
"get_logger",
|
"get_logger",
|
||||||
"generate_app_icon",
|
"generate_app_icon",
|
||||||
|
"generate_initials_avatar",
|
||||||
"http_replay_sequence",
|
"http_replay_sequence",
|
||||||
|
"hoppscotch_login",
|
||||||
|
"hoppscotch_create_request",
|
||||||
|
"hoppscotch_update_request",
|
||||||
|
"hoppscotch_delete_request",
|
||||||
|
"hoppscotch_list_requests",
|
||||||
|
"pass_get_secret",
|
||||||
|
"hoppscotch_set_environment",
|
||||||
|
"hoppscotch_run_request",
|
||||||
|
"split_vcards",
|
||||||
|
"split_vevents_to_vcalendars",
|
||||||
|
"extract_or_make_uid",
|
||||||
|
"carddav_put_vcard",
|
||||||
|
"caldav_put_event",
|
||||||
|
"dav_list_resources",
|
||||||
|
"dav_get_resource",
|
||||||
|
"dav_delete_resource",
|
||||||
]
|
]
|
||||||
|
|||||||
@@ -0,0 +1,115 @@
|
|||||||
|
---
|
||||||
|
name: build_hoppscotch_collection
|
||||||
|
kind: function
|
||||||
|
lang: py
|
||||||
|
domain: infra
|
||||||
|
version: "1.0.0"
|
||||||
|
purity: pure
|
||||||
|
signature: "def build_hoppscotch_collection(calls: list[dict], *, name: str = \"Collection\", request_names: list[str] | None = None) -> dict"
|
||||||
|
description: "Helper interno de serializacion del grupo hoppscotch: convierte call specs (method/url/headers/body/body_type) en el formato HoppRESTRequest/coleccion Hoppscotch (request v:2). Lo usan hoppscotch_create_request y hoppscotch_update_request para construir el campo request de las mutations GraphQL del self-host. NO uses el dict resultante para escribir un .json e importarlo a mano: el flujo canonico es operar el self-host por la API (ver docs/capabilities/hoppscotch.md). Pura: solo stdlib, sin red."
|
||||||
|
tags: [hoppscotch, flow-replay, http, infra, python]
|
||||||
|
uses_functions: []
|
||||||
|
uses_types: []
|
||||||
|
returns: []
|
||||||
|
returns_optional: false
|
||||||
|
error_type: ""
|
||||||
|
imports: []
|
||||||
|
params:
|
||||||
|
- name: calls
|
||||||
|
desc: "lista de call specs (tipicamente la salida de har_extract_calls). Cada elemento es un dict con claves opcionales: method (str), url (str), headers (dict name->value), cookies (dict name->value), body (str|None), body_type (json|form|raw|None). Otras claves como status o sets_cookies se ignoran."
|
||||||
|
- name: name
|
||||||
|
desc: "nombre de la coleccion Hoppscotch resultante. Default 'Collection'."
|
||||||
|
- name: request_names
|
||||||
|
desc: "nombres explicitos por request, alineados por indice con calls. Si se pasa y existe el indice, sobreescribe el nombre derivado. None = derivar todos como '<METHOD> <path>'."
|
||||||
|
output: "dict de coleccion Hoppscotch JSON-serializable: {\"v\": 1, \"name\", \"folders\": [], \"requests\": [...]}. Cada request lleva v:'2', endpoint, name, method (upper), headers (lista key/value/active con header Cookie inyectado si habia cookies), body (contentType+body segun body_type), auth none, params/requestVariables vacios."
|
||||||
|
tested: true
|
||||||
|
tests:
|
||||||
|
- "test_golden_get_simple"
|
||||||
|
- "test_edge_post_json_con_headers_y_cookies"
|
||||||
|
- "test_request_names_sobreescribe_nombre_derivado"
|
||||||
|
- "test_form_body_genera_contenttype_urlencoded"
|
||||||
|
- "test_raw_body_genera_contenttype_text_plain"
|
||||||
|
- "test_body_type_desconocido_da_body_null"
|
||||||
|
- "test_lista_vacia"
|
||||||
|
- "test_call_spec_sin_url_ni_method"
|
||||||
|
- "test_sin_cookies_no_anade_header_cookie"
|
||||||
|
test_file_path: "python/functions/infra/build_hoppscotch_collection_test.py"
|
||||||
|
file_path: "python/functions/infra/build_hoppscotch_collection.py"
|
||||||
|
---
|
||||||
|
|
||||||
|
## Ejemplo
|
||||||
|
|
||||||
|
```python
|
||||||
|
import json
|
||||||
|
from build_hoppscotch_collection import build_hoppscotch_collection
|
||||||
|
|
||||||
|
# Call specs tal cual salen de har_extract_calls.
|
||||||
|
calls = [
|
||||||
|
{
|
||||||
|
"method": "GET",
|
||||||
|
"url": "https://api.example.com/api/search?q=foo",
|
||||||
|
"headers": {"Accept": "application/json"},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"method": "POST",
|
||||||
|
"url": "https://api.example.com/login",
|
||||||
|
"headers": {"Content-Type": "application/json"},
|
||||||
|
"cookies": {"session": "abc", "csrf": "xyz"},
|
||||||
|
"body": '{"user":"neo","pass":"<<password>>"}',
|
||||||
|
"body_type": "json",
|
||||||
|
},
|
||||||
|
]
|
||||||
|
|
||||||
|
collection = build_hoppscotch_collection(calls, name="Example flow")
|
||||||
|
# {
|
||||||
|
# "v": 1,
|
||||||
|
# "name": "Example flow",
|
||||||
|
# "folders": [],
|
||||||
|
# "requests": [
|
||||||
|
# {
|
||||||
|
# "v": "2",
|
||||||
|
# "endpoint": "https://api.example.com/api/search?q=foo",
|
||||||
|
# "name": "GET /api/search",
|
||||||
|
# "params": [],
|
||||||
|
# "headers": [{"key": "Accept", "value": "application/json", "active": True}],
|
||||||
|
# "method": "GET",
|
||||||
|
# "auth": {"authType": "none", "authActive": True},
|
||||||
|
# "preRequestScript": "",
|
||||||
|
# "testScript": "",
|
||||||
|
# "body": {"contentType": None, "body": None},
|
||||||
|
# "requestVariables": [],
|
||||||
|
# },
|
||||||
|
# {
|
||||||
|
# "v": "2",
|
||||||
|
# "endpoint": "https://api.example.com/login",
|
||||||
|
# "name": "POST /login",
|
||||||
|
# "params": [],
|
||||||
|
# "headers": [
|
||||||
|
# {"key": "Content-Type", "value": "application/json", "active": True},
|
||||||
|
# {"key": "Cookie", "value": "session=abc; csrf=xyz", "active": True},
|
||||||
|
# ],
|
||||||
|
# "method": "POST",
|
||||||
|
# "auth": {"authType": "none", "authActive": True},
|
||||||
|
# "preRequestScript": "",
|
||||||
|
# "testScript": "",
|
||||||
|
# "body": {"contentType": "application/json", "body": '{"user":"neo","pass":"<<password>>"}'},
|
||||||
|
# "requestVariables": [],
|
||||||
|
# },
|
||||||
|
# ],
|
||||||
|
# }
|
||||||
|
|
||||||
|
# Listo para escribir a disco e importar en la app Desktop / hopp CLI.
|
||||||
|
with open("flow.collection.json", "w") as f:
|
||||||
|
json.dump(collection, f, indent=2)
|
||||||
|
```
|
||||||
|
|
||||||
|
## Cuando usarla
|
||||||
|
|
||||||
|
Usala cuando quieras abrir en la GUI de Hoppscotch unas peticiones que ya grabaste y destilaste con el patron grabar->destilar->reproducir (HAR -> har_filter_flows -> har_extract_calls). Pasas las call specs por esta funcion, guardas el dict resultante como `.json` y lo importas en la app Desktop o con el CLI `hopp`. Es la salida amigable para humanos del flujo de replay: cuando prefieras inspeccionar/tocar las peticiones a mano en el GUI antes de promoverlas a una funcion-accion del registry con http_replay_sequence. La funcion inversa, parse_hoppscotch_collection, reimporta una coleccion editada en el GUI de vuelta a call specs.
|
||||||
|
|
||||||
|
## Gotchas
|
||||||
|
|
||||||
|
- **Genera el formato canonico estable v1/v2.** La coleccion sale como `v:1` y cada request como `v:"2"` — la forma de los fixtures oficiales de Hoppscotch, garantizada importable. Hoppscotch la migra automaticamente a su ultima version interna al importar; no intentes emitir la version "ultima" a mano.
|
||||||
|
- **Las cookies se inyectan como header Cookie.** Si un call spec trae `cookies` no vacio, se anade un unico header `Cookie` al final con formato `k1=v1; k2=v2`. Hoppscotch no tiene un slot de cookies separado en el request, asi que viajan en headers; al reimportar con parse_hoppscotch_collection se vuelven a separar.
|
||||||
|
- **Los secretos NO se sustituyen.** La funcion copia headers, cookies y body tal cual. Tokens de sesion, `Authorization` y contrasenas viajan en claro en el dict resultante. Si quieres parametrizar, es el caller quien debe marcar los valores con `<<var>>` (referencia a variable de environment de Hoppscotch) antes de llamar a esta funcion. NO commitear el `.json` resultante sin redactar.
|
||||||
|
- **Claves extra del call spec se ignoran.** `status`, `sets_cookies` y cualquier otra clave que no sea method/url/headers/cookies/body/body_type no aparecen en la coleccion (son metadata de la captura, no del request a reproducir).
|
||||||
@@ -0,0 +1,135 @@
|
|||||||
|
"""Convierte call specs del registry en una coleccion Hoppscotch importable.
|
||||||
|
|
||||||
|
Mitad "exportar al GUI" del puente entre el motor de replay del registry y
|
||||||
|
Hoppscotch. La funcion inversa es parse_hoppscotch_collection.
|
||||||
|
"""
|
||||||
|
|
||||||
|
from urllib.parse import urlparse
|
||||||
|
|
||||||
|
|
||||||
|
def _request_name(call: dict, fallback_index: int) -> str:
|
||||||
|
"""Deriva un nombre legible para la request a partir del metodo y el path.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
call: call spec con (opcional) method y url.
|
||||||
|
fallback_index: indice de la call dentro de la lista (no usado en el
|
||||||
|
nombre derivado, reservado para desambiguar si hiciera falta).
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
nombre del estilo "GET /api/search".
|
||||||
|
"""
|
||||||
|
method = str(call.get("method") or "GET").upper()
|
||||||
|
url = str(call.get("url") or "")
|
||||||
|
path = urlparse(url).path or "/"
|
||||||
|
return f"{method} {path}"
|
||||||
|
|
||||||
|
|
||||||
|
def _build_headers(call: dict) -> list[dict]:
|
||||||
|
"""Construye la lista de headers Hoppscotch desde el dict del call spec.
|
||||||
|
|
||||||
|
Convierte el dict headers (preservando orden de insercion) a la lista
|
||||||
|
[{"key", "value", "active": True}, ...] y, si el call spec trae cookies no
|
||||||
|
vacias, anade un header extra "Cookie" al final con formato "k1=v1; k2=v2".
|
||||||
|
|
||||||
|
Args:
|
||||||
|
call: call spec con (opcional) headers y cookies.
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
lista de headers Hoppscotch.
|
||||||
|
"""
|
||||||
|
headers: list[dict] = []
|
||||||
|
raw_headers = call.get("headers") or {}
|
||||||
|
for key, value in raw_headers.items():
|
||||||
|
headers.append({"key": key, "value": value, "active": True})
|
||||||
|
|
||||||
|
cookies = call.get("cookies") or {}
|
||||||
|
if cookies:
|
||||||
|
cookie_value = "; ".join(f"{name}={val}" for name, val in cookies.items())
|
||||||
|
headers.append({"key": "Cookie", "value": cookie_value, "active": True})
|
||||||
|
|
||||||
|
return headers
|
||||||
|
|
||||||
|
|
||||||
|
def _build_body(call: dict) -> dict:
|
||||||
|
"""Construye el objeto body Hoppscotch segun body_type del call spec.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
call: call spec con (opcional) body y body_type.
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
dict con contentType y body. Si no hay body o el body_type es
|
||||||
|
desconocido/None, ambos campos son None.
|
||||||
|
"""
|
||||||
|
body = call.get("body")
|
||||||
|
body_type = call.get("body_type")
|
||||||
|
|
||||||
|
content_types = {
|
||||||
|
"json": "application/json",
|
||||||
|
"form": "application/x-www-form-urlencoded",
|
||||||
|
"raw": "text/plain",
|
||||||
|
}
|
||||||
|
|
||||||
|
if body is None or body_type not in content_types:
|
||||||
|
return {"contentType": None, "body": None}
|
||||||
|
|
||||||
|
return {"contentType": content_types[body_type], "body": body}
|
||||||
|
|
||||||
|
|
||||||
|
def build_hoppscotch_collection(
|
||||||
|
calls: list[dict],
|
||||||
|
*,
|
||||||
|
name: str = "Collection",
|
||||||
|
request_names: list[str] | None = None,
|
||||||
|
) -> dict:
|
||||||
|
"""Convierte una lista de call specs en una coleccion Hoppscotch importable.
|
||||||
|
|
||||||
|
Genera el formato canonico estable (coleccion v:1, request v:"2") que
|
||||||
|
Hoppscotch migra a la ultima version al importar. Pura: sin I/O ni red,
|
||||||
|
solo stdlib, determinista.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
calls: lista de call specs (salida de har_extract_calls). Cada elemento
|
||||||
|
es un dict con claves opcionales: method, url, headers, cookies,
|
||||||
|
body, body_type. Otras claves (status, sets_cookies, ...) se ignoran.
|
||||||
|
name: nombre de la coleccion Hoppscotch.
|
||||||
|
request_names: nombres explicitos por request, alineados por indice. Si
|
||||||
|
se pasa y existe el indice, sobreescribe el nombre derivado. None =
|
||||||
|
derivar todos los nombres como "<METHOD> <path>".
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
dict con la coleccion Hoppscotch: {"v": 1, "name", "folders": [],
|
||||||
|
"requests": [...]}. JSON-serializable.
|
||||||
|
"""
|
||||||
|
requests: list[dict] = []
|
||||||
|
|
||||||
|
for index, call in enumerate(calls):
|
||||||
|
if request_names is not None and index < len(request_names):
|
||||||
|
req_name = request_names[index]
|
||||||
|
else:
|
||||||
|
req_name = _request_name(call, index)
|
||||||
|
|
||||||
|
endpoint = str(call.get("url") or "")
|
||||||
|
method = str(call.get("method") or "GET").upper()
|
||||||
|
|
||||||
|
requests.append(
|
||||||
|
{
|
||||||
|
"v": "2",
|
||||||
|
"endpoint": endpoint,
|
||||||
|
"name": req_name,
|
||||||
|
"params": [],
|
||||||
|
"headers": _build_headers(call),
|
||||||
|
"method": method,
|
||||||
|
"auth": {"authType": "none", "authActive": True},
|
||||||
|
"preRequestScript": "",
|
||||||
|
"testScript": "",
|
||||||
|
"body": _build_body(call),
|
||||||
|
"requestVariables": [],
|
||||||
|
}
|
||||||
|
)
|
||||||
|
|
||||||
|
return {
|
||||||
|
"v": 1,
|
||||||
|
"name": name,
|
||||||
|
"folders": [],
|
||||||
|
"requests": requests,
|
||||||
|
}
|
||||||
@@ -0,0 +1,172 @@
|
|||||||
|
"""Tests para build_hoppscotch_collection."""
|
||||||
|
|
||||||
|
import json
|
||||||
|
|
||||||
|
from build_hoppscotch_collection import build_hoppscotch_collection
|
||||||
|
|
||||||
|
|
||||||
|
def test_golden_get_simple():
|
||||||
|
calls = [
|
||||||
|
{
|
||||||
|
"method": "GET",
|
||||||
|
"url": "https://api.example.com/api/search?q=foo",
|
||||||
|
"headers": {"Accept": "application/json"},
|
||||||
|
}
|
||||||
|
]
|
||||||
|
|
||||||
|
result = build_hoppscotch_collection(calls, name="MyCol")
|
||||||
|
|
||||||
|
assert result["v"] == 1
|
||||||
|
assert result["name"] == "MyCol"
|
||||||
|
assert result["folders"] == []
|
||||||
|
assert len(result["requests"]) == 1
|
||||||
|
|
||||||
|
req = result["requests"][0]
|
||||||
|
assert req["v"] == "2"
|
||||||
|
assert req["endpoint"] == "https://api.example.com/api/search?q=foo"
|
||||||
|
assert req["name"] == "GET /api/search"
|
||||||
|
assert req["method"] == "GET"
|
||||||
|
assert req["params"] == []
|
||||||
|
assert req["headers"] == [
|
||||||
|
{"key": "Accept", "value": "application/json", "active": True}
|
||||||
|
]
|
||||||
|
assert req["auth"] == {"authType": "none", "authActive": True}
|
||||||
|
assert req["preRequestScript"] == ""
|
||||||
|
assert req["testScript"] == ""
|
||||||
|
assert req["requestVariables"] == []
|
||||||
|
assert req["body"] == {"contentType": None, "body": None}
|
||||||
|
|
||||||
|
# JSON-serializable
|
||||||
|
json.dumps(result)
|
||||||
|
|
||||||
|
|
||||||
|
def test_edge_post_json_con_headers_y_cookies():
|
||||||
|
calls = [
|
||||||
|
{
|
||||||
|
"method": "post",
|
||||||
|
"url": "https://api.example.com/login",
|
||||||
|
"headers": {
|
||||||
|
"Content-Type": "application/json",
|
||||||
|
"X-Csrf": "tok",
|
||||||
|
},
|
||||||
|
"cookies": {"session": "abc", "csrf": "xyz"},
|
||||||
|
"body": '{"user":"neo"}',
|
||||||
|
"body_type": "json",
|
||||||
|
}
|
||||||
|
]
|
||||||
|
|
||||||
|
result = build_hoppscotch_collection(calls)
|
||||||
|
|
||||||
|
req = result["requests"][0]
|
||||||
|
assert req["method"] == "POST"
|
||||||
|
assert req["name"] == "POST /login"
|
||||||
|
|
||||||
|
# Header Cookie generado al final con formato "; " join
|
||||||
|
assert req["headers"] == [
|
||||||
|
{"key": "Content-Type", "value": "application/json", "active": True},
|
||||||
|
{"key": "X-Csrf", "value": "tok", "active": True},
|
||||||
|
{"key": "Cookie", "value": "session=abc; csrf=xyz", "active": True},
|
||||||
|
]
|
||||||
|
|
||||||
|
assert req["body"] == {
|
||||||
|
"contentType": "application/json",
|
||||||
|
"body": '{"user":"neo"}',
|
||||||
|
}
|
||||||
|
|
||||||
|
json.dumps(result)
|
||||||
|
|
||||||
|
|
||||||
|
def test_request_names_sobreescribe_nombre_derivado():
|
||||||
|
calls = [
|
||||||
|
{"method": "GET", "url": "https://api.example.com/a"},
|
||||||
|
{"method": "GET", "url": "https://api.example.com/b"},
|
||||||
|
]
|
||||||
|
|
||||||
|
result = build_hoppscotch_collection(
|
||||||
|
calls, request_names=["Custom A"]
|
||||||
|
)
|
||||||
|
|
||||||
|
# Indice 0 usa el nombre explicito; indice 1 cae al derivado.
|
||||||
|
assert result["requests"][0]["name"] == "Custom A"
|
||||||
|
assert result["requests"][1]["name"] == "GET /b"
|
||||||
|
|
||||||
|
|
||||||
|
def test_form_body_genera_contenttype_urlencoded():
|
||||||
|
calls = [
|
||||||
|
{
|
||||||
|
"method": "POST",
|
||||||
|
"url": "https://api.example.com/form",
|
||||||
|
"body": "a=1&b=2",
|
||||||
|
"body_type": "form",
|
||||||
|
}
|
||||||
|
]
|
||||||
|
|
||||||
|
req = build_hoppscotch_collection(calls)["requests"][0]
|
||||||
|
assert req["body"] == {
|
||||||
|
"contentType": "application/x-www-form-urlencoded",
|
||||||
|
"body": "a=1&b=2",
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
def test_raw_body_genera_contenttype_text_plain():
|
||||||
|
calls = [
|
||||||
|
{
|
||||||
|
"method": "POST",
|
||||||
|
"url": "https://api.example.com/raw",
|
||||||
|
"body": "hello",
|
||||||
|
"body_type": "raw",
|
||||||
|
}
|
||||||
|
]
|
||||||
|
|
||||||
|
req = build_hoppscotch_collection(calls)["requests"][0]
|
||||||
|
assert req["body"] == {"contentType": "text/plain", "body": "hello"}
|
||||||
|
|
||||||
|
|
||||||
|
def test_body_type_desconocido_da_body_null():
|
||||||
|
calls = [
|
||||||
|
{
|
||||||
|
"method": "POST",
|
||||||
|
"url": "https://api.example.com/x",
|
||||||
|
"body": "ignored",
|
||||||
|
"body_type": "binary",
|
||||||
|
}
|
||||||
|
]
|
||||||
|
|
||||||
|
req = build_hoppscotch_collection(calls)["requests"][0]
|
||||||
|
assert req["body"] == {"contentType": None, "body": None}
|
||||||
|
|
||||||
|
|
||||||
|
def test_lista_vacia():
|
||||||
|
result = build_hoppscotch_collection([], name="Empty")
|
||||||
|
assert result == {
|
||||||
|
"v": 1,
|
||||||
|
"name": "Empty",
|
||||||
|
"folders": [],
|
||||||
|
"requests": [],
|
||||||
|
}
|
||||||
|
json.dumps(result)
|
||||||
|
|
||||||
|
|
||||||
|
def test_call_spec_sin_url_ni_method():
|
||||||
|
calls = [{}]
|
||||||
|
|
||||||
|
req = build_hoppscotch_collection(calls)["requests"][0]
|
||||||
|
assert req["endpoint"] == ""
|
||||||
|
assert req["method"] == "GET"
|
||||||
|
assert req["name"] == "GET /"
|
||||||
|
assert req["headers"] == []
|
||||||
|
assert req["body"] == {"contentType": None, "body": None}
|
||||||
|
|
||||||
|
|
||||||
|
def test_sin_cookies_no_anade_header_cookie():
|
||||||
|
calls = [
|
||||||
|
{
|
||||||
|
"method": "GET",
|
||||||
|
"url": "https://api.example.com/x",
|
||||||
|
"headers": {"Accept": "*/*"},
|
||||||
|
"cookies": {},
|
||||||
|
}
|
||||||
|
]
|
||||||
|
|
||||||
|
req = build_hoppscotch_collection(calls)["requests"][0]
|
||||||
|
assert all(h["key"] != "Cookie" for h in req["headers"])
|
||||||
@@ -0,0 +1,91 @@
|
|||||||
|
---
|
||||||
|
name: caldav_put_event
|
||||||
|
kind: function
|
||||||
|
lang: py
|
||||||
|
domain: infra
|
||||||
|
version: "1.0.0"
|
||||||
|
purity: impure
|
||||||
|
signature: "def caldav_put_event(base_url: str, username: str, password: str, collection_path: str, uid: str, vcalendar_text: str, *, timeout_s: float = 20.0, verify_tls: bool = True) -> dict"
|
||||||
|
description: "Sube (HTTP PUT) un VCALENDAR (con un VEVENT) a una coleccion CalDAV con HTTP Basic auth. Construye el header Authorization: Basic base64(user:pass) a mano con stdlib. El nombre del recurso se deriva del UID saneado (safe(uid)+'.ics'). verify_tls=True por defecto. Idempotente por UID: re-subir el mismo UID sobrescribe el recurso. Maneja errores sin lanzar (HTTPError/URLError -> {status:'error'}). Solo stdlib (urllib, base64, re, ssl). Probado contra Xandikos."
|
||||||
|
tags: [dav, caldav, ical, ics, vevent, http, put, calendar, infra, upload]
|
||||||
|
uses_functions: []
|
||||||
|
uses_types: []
|
||||||
|
returns: []
|
||||||
|
returns_optional: false
|
||||||
|
error_type: "error_go_core"
|
||||||
|
imports: [base64, re, ssl, urllib.error, urllib.request]
|
||||||
|
params:
|
||||||
|
- name: base_url
|
||||||
|
desc: "URL base del servidor DAV (p.ej. 'https://dav-eedeb681c4ab89ab8e444ac9.organic-machine.com')."
|
||||||
|
- name: username
|
||||||
|
desc: "usuario para HTTP Basic auth (p.ej. 'enmanuel')."
|
||||||
|
- name: password
|
||||||
|
desc: "contrasena para HTTP Basic auth. Resolver desde pass con pass_get_secret, nunca hardcodear."
|
||||||
|
- name: collection_path
|
||||||
|
desc: "ruta de la coleccion CalDAV (p.ej. '/enmanuel/calendars/calendar/')."
|
||||||
|
- name: uid
|
||||||
|
desc: "UID del evento; se sanea ([^A-Za-z0-9_.-]->_ , max 120 chars) para formar el nombre del recurso .ics."
|
||||||
|
- name: vcalendar_text
|
||||||
|
desc: "texto completo del VCALENDAR (BEGIN:VCALENDAR..END:VCALENDAR) con un VEVENT. Se asegura terminacion en CRLF."
|
||||||
|
- name: timeout_s
|
||||||
|
desc: "timeout de la peticion HTTP en segundos. Default 20.0."
|
||||||
|
- name: verify_tls
|
||||||
|
desc: "si True (default) verifica el certificado TLS. No desactivar salvo entorno de prueba."
|
||||||
|
output: "dict. En exito: {status:'ok', http_status:int, url:str}. En error (sin lanzar): {status:'error', error:str, http_status:int|None}. http_status es el codigo HTTP devuelto (201 created / 204 no content tipico en CalDAV)."
|
||||||
|
tested: true
|
||||||
|
tests:
|
||||||
|
- "test_construye_request_put_con_headers_correctos"
|
||||||
|
- "test_url_se_forma_con_uid_saneado"
|
||||||
|
- "test_content_type_es_text_calendar"
|
||||||
|
- "test_extension_es_ics"
|
||||||
|
- "test_httperror_devuelve_status_error"
|
||||||
|
test_file_path: "python/functions/infra/caldav_put_event_test.py"
|
||||||
|
file_path: "python/functions/infra/caldav_put_event.py"
|
||||||
|
---
|
||||||
|
|
||||||
|
## Ejemplo
|
||||||
|
|
||||||
|
```python
|
||||||
|
import sys
|
||||||
|
sys.path.insert(0, "python/functions")
|
||||||
|
from infra.pass_get_secret import pass_get_secret
|
||||||
|
from infra.caldav_put_event import caldav_put_event
|
||||||
|
|
||||||
|
pw = pass_get_secret("dav/xandikos-enmanuel")["value"] # NO logear
|
||||||
|
|
||||||
|
cal = (
|
||||||
|
"BEGIN:VCALENDAR\r\nVERSION:2.0\r\nPRODID:-//x//EN\r\nCALSCALE:GREGORIAN\r\n"
|
||||||
|
"BEGIN:VEVENT\r\nUID:evt-1@google.com\r\nSUMMARY:Reunion\r\n"
|
||||||
|
"DTSTART:20260101T100000Z\r\nDTEND:20260101T110000Z\r\nEND:VEVENT\r\n"
|
||||||
|
"END:VCALENDAR\r\n"
|
||||||
|
)
|
||||||
|
res = caldav_put_event(
|
||||||
|
base_url="https://dav-eedeb681c4ab89ab8e444ac9.organic-machine.com",
|
||||||
|
username="enmanuel",
|
||||||
|
password=pw,
|
||||||
|
collection_path="/enmanuel/calendars/calendar/",
|
||||||
|
uid="evt-1@google.com",
|
||||||
|
vcalendar_text=cal,
|
||||||
|
)
|
||||||
|
print(res) # {"status": "ok", "http_status": 201, "url": ".../evt-1_google.com.ics"}
|
||||||
|
```
|
||||||
|
|
||||||
|
## Cuando usarla
|
||||||
|
|
||||||
|
Cuando quieres subir un evento individual a Xandikos (u otro servidor CalDAV)
|
||||||
|
por HTTP. Es la primitiva de escritura de calendario del grupo `dav`; el
|
||||||
|
pipeline `import_ics_to_caldav` la invoca por cada VCALENDAR producido por
|
||||||
|
`split_vevents_to_vcalendars`. Antes de llamarla, resuelve el UID con
|
||||||
|
`extract_or_make_uid` y la password con `pass_get_secret`.
|
||||||
|
|
||||||
|
## Gotchas
|
||||||
|
|
||||||
|
- Escritura remota real: re-subir el mismo UID SOBRESCRIBE el recurso
|
||||||
|
(idempotente, no duplica).
|
||||||
|
- El VCALENDAR debe ser completo y autonomo (header + VTIMEZONE necesarias +
|
||||||
|
un VEVENT). Subir un VEVENT suelto sin envolver en VCALENDAR fallara.
|
||||||
|
- Contrasena en header Basic sobre TLS; nunca hardcodear, leer de `pass`. No se
|
||||||
|
logea.
|
||||||
|
- `verify_tls=False` solo en pruebas; abre MITM.
|
||||||
|
- Devuelve dict (status/http_status/error), NO un int crudo: captura errores
|
||||||
|
HTTP/red sin lanzar.
|
||||||
@@ -0,0 +1,83 @@
|
|||||||
|
"""Sube (PUT) un VCALENDAR a una coleccion CalDAV via HTTP Basic auth.
|
||||||
|
|
||||||
|
Funcion impura: hace una peticion HTTP PUT. Construye el header
|
||||||
|
`Authorization: Basic base64(user:pass)` a mano con stdlib. El nombre del
|
||||||
|
recurso se deriva del UID saneado (`safe(uid) + '.ics'`). Maneja errores sin
|
||||||
|
lanzar: devuelve {status: 'ok', http_status: int} en exito o
|
||||||
|
{status: 'error', error: str}. Solo usa stdlib (urllib, base64, re, ssl).
|
||||||
|
"""
|
||||||
|
|
||||||
|
import base64
|
||||||
|
import re
|
||||||
|
import ssl
|
||||||
|
import urllib.error
|
||||||
|
import urllib.request
|
||||||
|
|
||||||
|
_UNSAFE_RE = re.compile(r"[^A-Za-z0-9_.-]")
|
||||||
|
|
||||||
|
|
||||||
|
def _basic_auth_header(username: str, password: str) -> str:
|
||||||
|
raw = ("%s:%s" % (username, password)).encode("utf-8")
|
||||||
|
return "Basic " + base64.b64encode(raw).decode("ascii")
|
||||||
|
|
||||||
|
|
||||||
|
def _safe_resource_name(uid: str, ext: str) -> str:
|
||||||
|
safe = _UNSAFE_RE.sub("_", uid)[:120]
|
||||||
|
return safe + ext
|
||||||
|
|
||||||
|
|
||||||
|
def _join_url(base_url: str, collection_path: str, resource: str) -> str:
|
||||||
|
return base_url.rstrip("/") + "/" + collection_path.strip("/") + "/" + resource
|
||||||
|
|
||||||
|
|
||||||
|
def caldav_put_event(
|
||||||
|
base_url: str,
|
||||||
|
username: str,
|
||||||
|
password: str,
|
||||||
|
collection_path: str,
|
||||||
|
uid: str,
|
||||||
|
vcalendar_text: str,
|
||||||
|
*,
|
||||||
|
timeout_s: float = 20.0,
|
||||||
|
verify_tls: bool = True,
|
||||||
|
) -> dict:
|
||||||
|
"""Sube un VCALENDAR (con un VEVENT) a una coleccion CalDAV (PUT).
|
||||||
|
|
||||||
|
Args:
|
||||||
|
base_url: URL base del servidor DAV (p.ej. 'https://dav-x.example.com').
|
||||||
|
username: usuario para HTTP Basic auth.
|
||||||
|
password: contrasena para HTTP Basic auth.
|
||||||
|
collection_path: ruta de la coleccion CalDAV (p.ej.
|
||||||
|
'/enmanuel/calendars/calendar/').
|
||||||
|
uid: UID del evento; se sanea para formar el nombre del recurso.
|
||||||
|
vcalendar_text: texto completo del VCALENDAR (BEGIN:VCALENDAR..END).
|
||||||
|
timeout_s: timeout de la peticion en segundos. Default 20.0.
|
||||||
|
verify_tls: si True (default) verifica el certificado TLS. No desactivar
|
||||||
|
salvo en entornos de prueba controlados.
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
dict. En exito: {status: 'ok', http_status: int, url: str}. En error
|
||||||
|
(sin lanzar): {status: 'error', error: str, http_status: int|None}.
|
||||||
|
"""
|
||||||
|
resource = _safe_resource_name(uid, ".ics")
|
||||||
|
url = _join_url(base_url, collection_path, resource)
|
||||||
|
body = vcalendar_text
|
||||||
|
if not body.endswith("\r\n"):
|
||||||
|
body = body.rstrip("\r\n") + "\r\n"
|
||||||
|
data = body.encode("utf-8")
|
||||||
|
headers = {
|
||||||
|
"Authorization": _basic_auth_header(username, password),
|
||||||
|
"Content-Type": "text/calendar; charset=utf-8",
|
||||||
|
}
|
||||||
|
req = urllib.request.Request(url, data=data, method="PUT", headers=headers)
|
||||||
|
|
||||||
|
context = None if verify_tls else ssl._create_unverified_context()
|
||||||
|
try:
|
||||||
|
with urllib.request.urlopen(req, timeout=timeout_s, context=context) as resp:
|
||||||
|
return {"status": "ok", "http_status": resp.status, "url": url}
|
||||||
|
except urllib.error.HTTPError as e:
|
||||||
|
return {"status": "error", "error": "http %s" % e.code, "http_status": e.code}
|
||||||
|
except urllib.error.URLError as e:
|
||||||
|
return {"status": "error", "error": str(e.reason), "http_status": None}
|
||||||
|
except Exception as e: # noqa: BLE001
|
||||||
|
return {"status": "error", "error": str(e), "http_status": None}
|
||||||
@@ -0,0 +1,88 @@
|
|||||||
|
"""Tests para caldav_put_event.
|
||||||
|
|
||||||
|
Smoke deterministas: monkeypatchean urllib.request.urlopen para capturar el
|
||||||
|
Request object (URL, method, headers) sin enviarlo a un servidor real.
|
||||||
|
"""
|
||||||
|
|
||||||
|
import sys
|
||||||
|
|
||||||
|
import infra.caldav_put_event # noqa: F401
|
||||||
|
|
||||||
|
mod = sys.modules["infra.caldav_put_event"]
|
||||||
|
caldav_put_event = mod.caldav_put_event
|
||||||
|
|
||||||
|
_CAL = (
|
||||||
|
"BEGIN:VCALENDAR\r\nVERSION:2.0\r\nPRODID:-//x//EN\r\nCALSCALE:GREGORIAN\r\n"
|
||||||
|
"BEGIN:VEVENT\r\nUID:evt-1@google.com\r\nSUMMARY:Reunion\r\nEND:VEVENT\r\n"
|
||||||
|
"END:VCALENDAR\r\n"
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
class _FakeResp:
|
||||||
|
status = 201
|
||||||
|
|
||||||
|
def __enter__(self):
|
||||||
|
return self
|
||||||
|
|
||||||
|
def __exit__(self, *a):
|
||||||
|
return False
|
||||||
|
|
||||||
|
|
||||||
|
def _capture(monkeypatch):
|
||||||
|
captured = {}
|
||||||
|
|
||||||
|
def fake_urlopen(req, timeout=None, context=None):
|
||||||
|
captured["url"] = req.full_url
|
||||||
|
captured["method"] = req.get_method()
|
||||||
|
captured["headers"] = {k.lower(): v for k, v in req.header_items()}
|
||||||
|
return _FakeResp()
|
||||||
|
|
||||||
|
monkeypatch.setattr(mod.urllib.request, "urlopen", fake_urlopen)
|
||||||
|
return captured
|
||||||
|
|
||||||
|
|
||||||
|
def _call():
|
||||||
|
return caldav_put_event(
|
||||||
|
base_url="https://dav.example.com",
|
||||||
|
username="enmanuel",
|
||||||
|
password="secret-pw",
|
||||||
|
collection_path="/enmanuel/calendars/calendar/",
|
||||||
|
uid="evt-1@google.com",
|
||||||
|
vcalendar_text=_CAL,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def test_construye_request_put_con_headers_correctos(monkeypatch):
|
||||||
|
cap = _capture(monkeypatch)
|
||||||
|
res = _call()
|
||||||
|
assert res["status"] == "ok"
|
||||||
|
assert res["http_status"] == 201
|
||||||
|
assert cap["method"] == "PUT"
|
||||||
|
|
||||||
|
|
||||||
|
def test_url_se_forma_con_uid_saneado(monkeypatch):
|
||||||
|
cap = _capture(monkeypatch)
|
||||||
|
_call()
|
||||||
|
assert cap["url"].endswith("/enmanuel/calendars/calendar/evt-1_google.com.ics")
|
||||||
|
|
||||||
|
|
||||||
|
def test_content_type_es_text_calendar(monkeypatch):
|
||||||
|
cap = _capture(monkeypatch)
|
||||||
|
_call()
|
||||||
|
assert cap["headers"]["content-type"] == "text/calendar; charset=utf-8"
|
||||||
|
|
||||||
|
|
||||||
|
def test_extension_es_ics(monkeypatch):
|
||||||
|
cap = _capture(monkeypatch)
|
||||||
|
_call()
|
||||||
|
assert cap["url"].endswith(".ics")
|
||||||
|
|
||||||
|
|
||||||
|
def test_httperror_devuelve_status_error(monkeypatch):
|
||||||
|
def fake_urlopen(req, timeout=None, context=None):
|
||||||
|
raise mod.urllib.error.HTTPError(req.full_url, 403, "Forbidden", {}, None)
|
||||||
|
|
||||||
|
monkeypatch.setattr(mod.urllib.request, "urlopen", fake_urlopen)
|
||||||
|
res = _call()
|
||||||
|
assert res["status"] == "error"
|
||||||
|
assert res["http_status"] == 403
|
||||||
@@ -0,0 +1,85 @@
|
|||||||
|
---
|
||||||
|
name: carddav_put_vcard
|
||||||
|
kind: function
|
||||||
|
lang: py
|
||||||
|
domain: infra
|
||||||
|
version: "1.0.0"
|
||||||
|
purity: impure
|
||||||
|
signature: "def carddav_put_vcard(base_url: str, username: str, password: str, collection_path: str, uid: str, vcard_text: str, *, timeout_s: float = 20.0, verify_tls: bool = True) -> dict"
|
||||||
|
description: "Sube (HTTP PUT) un VCARD a una coleccion CardDAV con HTTP Basic auth. Construye el header Authorization: Basic base64(user:pass) a mano con stdlib. El nombre del recurso se deriva del UID saneado (safe(uid)+'.vcf'). verify_tls=True por defecto. Idempotente por UID: re-subir el mismo UID sobrescribe el recurso. Maneja errores sin lanzar (HTTPError/URLError -> {status:'error'}). Solo stdlib (urllib, base64, re, ssl). Probado contra Xandikos."
|
||||||
|
tags: [dav, carddav, vcard, http, put, contacts, infra, upload]
|
||||||
|
uses_functions: []
|
||||||
|
uses_types: []
|
||||||
|
returns: []
|
||||||
|
returns_optional: false
|
||||||
|
error_type: "error_go_core"
|
||||||
|
imports: [base64, re, ssl, urllib.error, urllib.request]
|
||||||
|
params:
|
||||||
|
- name: base_url
|
||||||
|
desc: "URL base del servidor DAV (p.ej. 'https://dav-eedeb681c4ab89ab8e444ac9.organic-machine.com')."
|
||||||
|
- name: username
|
||||||
|
desc: "usuario para HTTP Basic auth (p.ej. 'enmanuel')."
|
||||||
|
- name: password
|
||||||
|
desc: "contrasena para HTTP Basic auth. Resolver desde pass con pass_get_secret, nunca hardcodear."
|
||||||
|
- name: collection_path
|
||||||
|
desc: "ruta de la coleccion CardDAV (p.ej. '/enmanuel/contacts/addressbook/')."
|
||||||
|
- name: uid
|
||||||
|
desc: "UID del contacto; se sanea ([^A-Za-z0-9_.-]->_ , max 120 chars) para formar el nombre del recurso .vcf."
|
||||||
|
- name: vcard_text
|
||||||
|
desc: "texto completo del VCARD (BEGIN:VCARD..END:VCARD). Se asegura terminacion en CRLF."
|
||||||
|
- name: timeout_s
|
||||||
|
desc: "timeout de la peticion HTTP en segundos. Default 20.0."
|
||||||
|
- name: verify_tls
|
||||||
|
desc: "si True (default) verifica el certificado TLS. No desactivar salvo entorno de prueba."
|
||||||
|
output: "dict. En exito: {status:'ok', http_status:int, url:str}. En error (sin lanzar): {status:'error', error:str, http_status:int|None}. http_status es el codigo HTTP devuelto por el servidor (201 created / 204 no content tipico en CardDAV)."
|
||||||
|
tested: true
|
||||||
|
tests:
|
||||||
|
- "test_construye_request_put_con_headers_correctos"
|
||||||
|
- "test_url_se_forma_con_uid_saneado"
|
||||||
|
- "test_content_type_es_text_vcard"
|
||||||
|
- "test_basic_auth_header_correcto"
|
||||||
|
- "test_httperror_devuelve_status_error"
|
||||||
|
test_file_path: "python/functions/infra/carddav_put_vcard_test.py"
|
||||||
|
file_path: "python/functions/infra/carddav_put_vcard.py"
|
||||||
|
---
|
||||||
|
|
||||||
|
## Ejemplo
|
||||||
|
|
||||||
|
```python
|
||||||
|
import sys
|
||||||
|
sys.path.insert(0, "python/functions")
|
||||||
|
from infra.pass_get_secret import pass_get_secret
|
||||||
|
from infra.carddav_put_vcard import carddav_put_vcard
|
||||||
|
|
||||||
|
pw = pass_get_secret("dav/xandikos-enmanuel")["value"] # NO logear
|
||||||
|
|
||||||
|
res = carddav_put_vcard(
|
||||||
|
base_url="https://dav-eedeb681c4ab89ab8e444ac9.organic-machine.com",
|
||||||
|
username="enmanuel",
|
||||||
|
password=pw,
|
||||||
|
collection_path="/enmanuel/contacts/addressbook/",
|
||||||
|
uid="abc-123@google.com",
|
||||||
|
vcard_text="BEGIN:VCARD\r\nVERSION:3.0\r\nFN:Ada Lovelace\r\nUID:abc-123@google.com\r\nEND:VCARD\r\n",
|
||||||
|
)
|
||||||
|
print(res) # {"status": "ok", "http_status": 201, "url": ".../abc-123_google.com.vcf"}
|
||||||
|
```
|
||||||
|
|
||||||
|
## Cuando usarla
|
||||||
|
|
||||||
|
Cuando quieres subir un contacto individual a Xandikos (u otro servidor CardDAV)
|
||||||
|
por HTTP. Es la primitiva de escritura del grupo `dav`; el pipeline
|
||||||
|
`import_vcf_to_carddav` la invoca por cada tarjeta de un .vcf. Antes de llamarla,
|
||||||
|
resuelve el UID con `extract_or_make_uid` y la password con `pass_get_secret`.
|
||||||
|
|
||||||
|
## Gotchas
|
||||||
|
|
||||||
|
- Hace una escritura remota real: re-subir el mismo UID SOBRESCRIBE el recurso
|
||||||
|
en el servidor (idempotente, no acumula duplicados — esa es la intencion).
|
||||||
|
- La contrasena va en el header Basic en claro sobre TLS; nunca hardcodear, leer
|
||||||
|
de `pass`. La funcion no logea la password.
|
||||||
|
- `verify_tls=False` solo para entornos de prueba; deja un agujero MITM.
|
||||||
|
- El servidor puede rechazar (4xx) si el path de la coleccion no existe o el UID
|
||||||
|
del nombre del recurso no coincide con el UID dentro del VCARD: asegurate de
|
||||||
|
que el mismo UID se usa para el nombre del archivo y para el campo UID:.
|
||||||
|
- Devuelve dict (status/http_status/error), NO un int crudo: asi captura errores
|
||||||
|
HTTP/red sin lanzar. Consulta `res["http_status"]` para el codigo.
|
||||||
@@ -0,0 +1,83 @@
|
|||||||
|
"""Sube (PUT) un VCARD a una coleccion CardDAV via HTTP Basic auth.
|
||||||
|
|
||||||
|
Funcion impura: hace una peticion HTTP PUT. Construye el header
|
||||||
|
`Authorization: Basic base64(user:pass)` a mano con stdlib. El nombre del
|
||||||
|
recurso se deriva del UID saneado (`safe(uid) + '.vcf'`). Maneja errores sin
|
||||||
|
lanzar: devuelve {status: 'ok', http_status: int} en exito o
|
||||||
|
{status: 'error', error: str}. Solo usa stdlib (urllib, base64, re, ssl).
|
||||||
|
"""
|
||||||
|
|
||||||
|
import base64
|
||||||
|
import re
|
||||||
|
import ssl
|
||||||
|
import urllib.error
|
||||||
|
import urllib.request
|
||||||
|
|
||||||
|
_UNSAFE_RE = re.compile(r"[^A-Za-z0-9_.-]")
|
||||||
|
|
||||||
|
|
||||||
|
def _basic_auth_header(username: str, password: str) -> str:
|
||||||
|
raw = ("%s:%s" % (username, password)).encode("utf-8")
|
||||||
|
return "Basic " + base64.b64encode(raw).decode("ascii")
|
||||||
|
|
||||||
|
|
||||||
|
def _safe_resource_name(uid: str, ext: str) -> str:
|
||||||
|
safe = _UNSAFE_RE.sub("_", uid)[:120]
|
||||||
|
return safe + ext
|
||||||
|
|
||||||
|
|
||||||
|
def _join_url(base_url: str, collection_path: str, resource: str) -> str:
|
||||||
|
return base_url.rstrip("/") + "/" + collection_path.strip("/") + "/" + resource
|
||||||
|
|
||||||
|
|
||||||
|
def carddav_put_vcard(
|
||||||
|
base_url: str,
|
||||||
|
username: str,
|
||||||
|
password: str,
|
||||||
|
collection_path: str,
|
||||||
|
uid: str,
|
||||||
|
vcard_text: str,
|
||||||
|
*,
|
||||||
|
timeout_s: float = 20.0,
|
||||||
|
verify_tls: bool = True,
|
||||||
|
) -> dict:
|
||||||
|
"""Sube un VCARD a una coleccion CardDAV (PUT).
|
||||||
|
|
||||||
|
Args:
|
||||||
|
base_url: URL base del servidor DAV (p.ej. 'https://dav-x.example.com').
|
||||||
|
username: usuario para HTTP Basic auth.
|
||||||
|
password: contrasena para HTTP Basic auth.
|
||||||
|
collection_path: ruta de la coleccion CardDAV (p.ej.
|
||||||
|
'/enmanuel/contacts/addressbook/').
|
||||||
|
uid: UID del contacto; se sanea para formar el nombre del recurso.
|
||||||
|
vcard_text: texto completo del VCARD (BEGIN:VCARD..END:VCARD).
|
||||||
|
timeout_s: timeout de la peticion en segundos. Default 20.0.
|
||||||
|
verify_tls: si True (default) verifica el certificado TLS. No desactivar
|
||||||
|
salvo en entornos de prueba controlados.
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
dict. En exito: {status: 'ok', http_status: int, url: str}. En error
|
||||||
|
(sin lanzar): {status: 'error', error: str, http_status: int|None}.
|
||||||
|
"""
|
||||||
|
resource = _safe_resource_name(uid, ".vcf")
|
||||||
|
url = _join_url(base_url, collection_path, resource)
|
||||||
|
body = vcard_text
|
||||||
|
if not body.endswith("\r\n"):
|
||||||
|
body = body.rstrip("\r\n") + "\r\n"
|
||||||
|
data = body.encode("utf-8")
|
||||||
|
headers = {
|
||||||
|
"Authorization": _basic_auth_header(username, password),
|
||||||
|
"Content-Type": "text/vcard; charset=utf-8",
|
||||||
|
}
|
||||||
|
req = urllib.request.Request(url, data=data, method="PUT", headers=headers)
|
||||||
|
|
||||||
|
context = None if verify_tls else ssl._create_unverified_context()
|
||||||
|
try:
|
||||||
|
with urllib.request.urlopen(req, timeout=timeout_s, context=context) as resp:
|
||||||
|
return {"status": "ok", "http_status": resp.status, "url": url}
|
||||||
|
except urllib.error.HTTPError as e:
|
||||||
|
return {"status": "error", "error": "http %s" % e.code, "http_status": e.code}
|
||||||
|
except urllib.error.URLError as e:
|
||||||
|
return {"status": "error", "error": str(e.reason), "http_status": None}
|
||||||
|
except Exception as e: # noqa: BLE001
|
||||||
|
return {"status": "error", "error": str(e), "http_status": None}
|
||||||
@@ -0,0 +1,88 @@
|
|||||||
|
"""Tests para carddav_put_vcard.
|
||||||
|
|
||||||
|
Smoke deterministas: monkeypatchean urllib.request.urlopen para capturar el
|
||||||
|
Request object (URL, method, headers, body) sin enviarlo a un servidor real.
|
||||||
|
"""
|
||||||
|
|
||||||
|
import base64
|
||||||
|
import sys
|
||||||
|
|
||||||
|
import infra.carddav_put_vcard # noqa: F401
|
||||||
|
|
||||||
|
mod = sys.modules["infra.carddav_put_vcard"]
|
||||||
|
carddav_put_vcard = mod.carddav_put_vcard
|
||||||
|
|
||||||
|
|
||||||
|
class _FakeResp:
|
||||||
|
status = 201
|
||||||
|
|
||||||
|
def __enter__(self):
|
||||||
|
return self
|
||||||
|
|
||||||
|
def __exit__(self, *a):
|
||||||
|
return False
|
||||||
|
|
||||||
|
|
||||||
|
def _capture(monkeypatch):
|
||||||
|
captured = {}
|
||||||
|
|
||||||
|
def fake_urlopen(req, timeout=None, context=None):
|
||||||
|
captured["url"] = req.full_url
|
||||||
|
captured["method"] = req.get_method()
|
||||||
|
captured["headers"] = dict(req.header_items())
|
||||||
|
captured["body"] = req.data
|
||||||
|
return _FakeResp()
|
||||||
|
|
||||||
|
monkeypatch.setattr(mod.urllib.request, "urlopen", fake_urlopen)
|
||||||
|
return captured
|
||||||
|
|
||||||
|
|
||||||
|
def _call():
|
||||||
|
return carddav_put_vcard(
|
||||||
|
base_url="https://dav.example.com",
|
||||||
|
username="enmanuel",
|
||||||
|
password="secret-pw",
|
||||||
|
collection_path="/enmanuel/contacts/addressbook/",
|
||||||
|
uid="abc-123@google.com",
|
||||||
|
vcard_text="BEGIN:VCARD\r\nFN:Ada\r\nUID:abc-123@google.com\r\nEND:VCARD",
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def test_construye_request_put_con_headers_correctos(monkeypatch):
|
||||||
|
cap = _capture(monkeypatch)
|
||||||
|
res = _call()
|
||||||
|
assert res == {"status": "ok", "http_status": 201, "url": cap["url"]}
|
||||||
|
assert cap["method"] == "PUT"
|
||||||
|
|
||||||
|
|
||||||
|
def test_url_se_forma_con_uid_saneado(monkeypatch):
|
||||||
|
cap = _capture(monkeypatch)
|
||||||
|
_call()
|
||||||
|
# El '@' del uid se sanea a '_'.
|
||||||
|
assert cap["url"].endswith("/enmanuel/contacts/addressbook/abc-123_google.com.vcf")
|
||||||
|
|
||||||
|
|
||||||
|
def test_content_type_es_text_vcard(monkeypatch):
|
||||||
|
cap = _capture(monkeypatch)
|
||||||
|
_call()
|
||||||
|
# urllib capitaliza las claves de header.
|
||||||
|
headers = {k.lower(): v for k, v in cap["headers"].items()}
|
||||||
|
assert headers["content-type"] == "text/vcard; charset=utf-8"
|
||||||
|
|
||||||
|
|
||||||
|
def test_basic_auth_header_correcto(monkeypatch):
|
||||||
|
cap = _capture(monkeypatch)
|
||||||
|
_call()
|
||||||
|
headers = {k.lower(): v for k, v in cap["headers"].items()}
|
||||||
|
expected = "Basic " + base64.b64encode(b"enmanuel:secret-pw").decode("ascii")
|
||||||
|
assert headers["authorization"] == expected
|
||||||
|
|
||||||
|
|
||||||
|
def test_httperror_devuelve_status_error(monkeypatch):
|
||||||
|
def fake_urlopen(req, timeout=None, context=None):
|
||||||
|
raise mod.urllib.error.HTTPError(req.full_url, 409, "Conflict", {}, None)
|
||||||
|
|
||||||
|
monkeypatch.setattr(mod.urllib.request, "urlopen", fake_urlopen)
|
||||||
|
res = _call()
|
||||||
|
assert res["status"] == "error"
|
||||||
|
assert res["http_status"] == 409
|
||||||
@@ -0,0 +1,88 @@
|
|||||||
|
---
|
||||||
|
name: dav_collection_ctag
|
||||||
|
kind: function
|
||||||
|
lang: py
|
||||||
|
domain: infra
|
||||||
|
version: "1.0.0"
|
||||||
|
purity: impure
|
||||||
|
signature: "def dav_collection_ctag(base_url: str, username: str, password: str, collection_path: str, *, timeout_s: float = 10.0, verify_tls: bool = True) -> dict"
|
||||||
|
description: "Lee el ctag (token de version) de una coleccion DAV en UNA peticion PROPFIND Depth:0 barata. Pide el getctag de CalendarServer (http://calendarserver.org/ns/) y, como respaldo, el getetag DAV de la propia coleccion. El ctag cambia solo cuando cambia algun recurso de la coleccion: comparandolo con un ctag cacheado se decide si recargar el contenido (REPORT) o servir de cache sin tocar la red. Construye el header Authorization: Basic base64(user:pass) a mano con stdlib y parsea el multistatus con regex simple. verify_tls=True por defecto. Maneja errores sin lanzar. Solo stdlib (urllib, base64, re, ssl). Probado contra Xandikos."
|
||||||
|
tags: [dav, carddav, caldav, ctag, getctag, propfind, cache, sync, http, infra]
|
||||||
|
uses_functions: []
|
||||||
|
uses_types: []
|
||||||
|
returns: []
|
||||||
|
returns_optional: false
|
||||||
|
error_type: "error_go_core"
|
||||||
|
imports: [base64, re, ssl, urllib.error, urllib.request]
|
||||||
|
params:
|
||||||
|
- name: base_url
|
||||||
|
desc: "URL base del servidor DAV (p.ej. 'https://dav-eedeb681c4ab89ab8e444ac9.organic-machine.com')."
|
||||||
|
- name: username
|
||||||
|
desc: "usuario para HTTP Basic auth (p.ej. 'enmanuel')."
|
||||||
|
- name: password
|
||||||
|
desc: "contrasena para HTTP Basic auth. Resolver desde pass con pass_get_secret, nunca hardcodear."
|
||||||
|
- name: collection_path
|
||||||
|
desc: "ruta de la coleccion (CardDAV '/enmanuel/contacts/addressbook/' o CalDAV '/enmanuel/calendars/calendar/')."
|
||||||
|
- name: timeout_s
|
||||||
|
desc: "timeout de la peticion HTTP en segundos. Default 10.0 (la respuesta es minuscula)."
|
||||||
|
- name: verify_tls
|
||||||
|
desc: "si True (default) verifica el certificado TLS. No desactivar salvo entorno de prueba."
|
||||||
|
output: "dict. En exito: {status:'ok', http_status:int, ctag:str} donde ctag es el getctag de CalendarServer si el servidor lo expone, o el getetag DAV de la coleccion como respaldo. En error (sin lanzar): {status:'error', error:str, http_status:int|None}."
|
||||||
|
tested: true
|
||||||
|
tests:
|
||||||
|
- "test_construye_propfind_depth_0"
|
||||||
|
- "test_basic_auth_header_correcto"
|
||||||
|
- "test_devuelve_getctag"
|
||||||
|
- "test_fallback_a_getetag"
|
||||||
|
- "test_sin_ctag_ni_etag_devuelve_error"
|
||||||
|
- "test_httperror_devuelve_status_error"
|
||||||
|
test_file_path: "python/functions/infra/dav_collection_ctag_test.py"
|
||||||
|
file_path: "python/functions/infra/dav_collection_ctag.py"
|
||||||
|
---
|
||||||
|
|
||||||
|
## Ejemplo
|
||||||
|
|
||||||
|
```python
|
||||||
|
import sys
|
||||||
|
sys.path.insert(0, "python/functions")
|
||||||
|
from infra.pass_get_secret import pass_get_secret
|
||||||
|
from infra.dav_collection_ctag import dav_collection_ctag
|
||||||
|
|
||||||
|
pw = pass_get_secret("dav/xandikos-enmanuel")["value"] # NO logear
|
||||||
|
|
||||||
|
res = dav_collection_ctag(
|
||||||
|
base_url="https://dav-eedeb681c4ab89ab8e444ac9.organic-machine.com",
|
||||||
|
username="enmanuel",
|
||||||
|
password=pw,
|
||||||
|
collection_path="/enmanuel/calendars/calendar/",
|
||||||
|
)
|
||||||
|
print(res["status"], res["ctag"]) # ok e8b39a8b180d25a674b35f0fee3013992b59e51e
|
||||||
|
|
||||||
|
# Patron de cache: si el ctag no cambio, sirve del disco sin descargar.
|
||||||
|
if res["ctag"] == cached_ctag:
|
||||||
|
return cached_payload
|
||||||
|
# ...si cambio, recargar con dav_get_collection y guardar el nuevo ctag.
|
||||||
|
```
|
||||||
|
|
||||||
|
## Cuando usarla
|
||||||
|
|
||||||
|
Antes de descargar una coleccion DAV completa: pides el ctag (peticion
|
||||||
|
minuscula, ~10ms) y lo comparas con el ctag de tu cache. Si coincide, sirves de
|
||||||
|
cache sin tocar la red (arranque instantaneo); si difiere, recargas con
|
||||||
|
`dav_get_collection` y guardas el nuevo ctag. Es el primitivo de validacion de
|
||||||
|
cache para CardDAV/CalDAV: una sola comprobacion barata decide si la copia local
|
||||||
|
sigue vigente.
|
||||||
|
|
||||||
|
## Gotchas
|
||||||
|
|
||||||
|
- El `getctag` es la extension de CalendarServer (`http://calendarserver.org/ns/`),
|
||||||
|
ampliamente soportada (Xandikos la expone). Si el servidor no la implementa, la
|
||||||
|
funcion cae al `getetag` DAV de la coleccion, que en Xandikos tambien cambia al
|
||||||
|
cambiar cualquier recurso — sirve igual como token de version.
|
||||||
|
- El ctag es OPACO: no lo interpretes, solo comparalo por igualdad con el que
|
||||||
|
guardaste. No asumas orden ni formato (Xandikos usa un hash hex; otros
|
||||||
|
servidores usan timestamps u otros formatos).
|
||||||
|
- No garantiza deteccion de cambios sub-recurso (etag por recurso): solo dice si
|
||||||
|
ALGO cambio en la coleccion. Para sync incremental fino combina con
|
||||||
|
`dav_list_resources` (mapa href->etag).
|
||||||
|
- Lectura remota real sobre TLS; password de `pass`, no se logea.
|
||||||
@@ -0,0 +1,104 @@
|
|||||||
|
"""Lee el ctag de una coleccion DAV en UNA peticion barata (PROPFIND Depth:0).
|
||||||
|
|
||||||
|
Funcion impura: hace un unico PROPFIND Depth:0 sobre la coleccion pidiendo el
|
||||||
|
`getctag` de CalendarServer (`http://calendarserver.org/ns/`) y, como respaldo,
|
||||||
|
el `getetag` DAV de la propia coleccion. El ctag es un token opaco que cambia
|
||||||
|
SOLO cuando cambia algun recurso de la coleccion: comparandolo con el ctag
|
||||||
|
cacheado se decide si hay que recargar el contenido (REPORT) o servir de cache
|
||||||
|
sin tocar la red.
|
||||||
|
|
||||||
|
Construye el header `Authorization: Basic base64(user:pass)` a mano con stdlib y
|
||||||
|
parsea el multistatus con regex simple. Maneja errores sin lanzar. Solo usa
|
||||||
|
stdlib (urllib, base64, re, ssl). Probado contra Xandikos.
|
||||||
|
"""
|
||||||
|
|
||||||
|
import base64
|
||||||
|
import re
|
||||||
|
import ssl
|
||||||
|
import urllib.error
|
||||||
|
import urllib.request
|
||||||
|
|
||||||
|
_CTAG_RE = re.compile(
|
||||||
|
r"<(?:[A-Za-z0-9]+:)?getctag>\s*(.*?)\s*</(?:[A-Za-z0-9]+:)?getctag>",
|
||||||
|
re.DOTALL | re.IGNORECASE,
|
||||||
|
)
|
||||||
|
_ETAG_RE = re.compile(
|
||||||
|
r"<(?:[A-Za-z0-9]+:)?getetag>\s*(.*?)\s*</(?:[A-Za-z0-9]+:)?getetag>",
|
||||||
|
re.DOTALL | re.IGNORECASE,
|
||||||
|
)
|
||||||
|
|
||||||
|
_PROPFIND_BODY = (
|
||||||
|
'<?xml version="1.0" encoding="utf-8" ?>'
|
||||||
|
'<D:propfind xmlns:D="DAV:" xmlns:CS="http://calendarserver.org/ns/">'
|
||||||
|
"<D:prop><CS:getctag/><D:getetag/></D:prop>"
|
||||||
|
"</D:propfind>"
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def _basic_auth_header(username: str, password: str) -> str:
|
||||||
|
raw = ("%s:%s" % (username, password)).encode("utf-8")
|
||||||
|
return "Basic " + base64.b64encode(raw).decode("ascii")
|
||||||
|
|
||||||
|
|
||||||
|
def _join_url(base_url: str, collection_path: str) -> str:
|
||||||
|
return base_url.rstrip("/") + "/" + collection_path.strip("/") + "/"
|
||||||
|
|
||||||
|
|
||||||
|
def dav_collection_ctag(
|
||||||
|
base_url: str,
|
||||||
|
username: str,
|
||||||
|
password: str,
|
||||||
|
collection_path: str,
|
||||||
|
*,
|
||||||
|
timeout_s: float = 10.0,
|
||||||
|
verify_tls: bool = True,
|
||||||
|
) -> dict:
|
||||||
|
"""Lee el ctag (token de version) de una coleccion DAV.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
base_url: URL base del servidor DAV.
|
||||||
|
username: usuario para HTTP Basic auth.
|
||||||
|
password: contrasena para HTTP Basic auth. Resolver desde pass.
|
||||||
|
collection_path: ruta de la coleccion (CardDAV o CalDAV).
|
||||||
|
timeout_s: timeout de la peticion en segundos. Default 10.0.
|
||||||
|
verify_tls: si True (default) verifica el certificado TLS.
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
dict. En exito: {status:'ok', http_status:int, ctag:str} donde ctag es
|
||||||
|
el getctag de CalendarServer si el servidor lo expone, o el getetag DAV
|
||||||
|
de la coleccion como respaldo. En error (sin lanzar):
|
||||||
|
{status:'error', error:str, http_status:int|None}.
|
||||||
|
"""
|
||||||
|
url = _join_url(base_url, collection_path)
|
||||||
|
headers = {
|
||||||
|
"Authorization": _basic_auth_header(username, password),
|
||||||
|
"Content-Type": "application/xml; charset=utf-8",
|
||||||
|
"Depth": "0",
|
||||||
|
}
|
||||||
|
req = urllib.request.Request(
|
||||||
|
url, data=_PROPFIND_BODY.encode("utf-8"), method="PROPFIND", headers=headers
|
||||||
|
)
|
||||||
|
|
||||||
|
context = None if verify_tls else ssl._create_unverified_context()
|
||||||
|
try:
|
||||||
|
with urllib.request.urlopen(req, timeout=timeout_s, context=context) as resp:
|
||||||
|
status = resp.status
|
||||||
|
xml = resp.read().decode("utf-8", "replace")
|
||||||
|
except urllib.error.HTTPError as e:
|
||||||
|
return {"status": "error", "error": "http %s" % e.code, "http_status": e.code}
|
||||||
|
except urllib.error.URLError as e:
|
||||||
|
return {"status": "error", "error": str(e.reason), "http_status": None}
|
||||||
|
except Exception as e: # noqa: BLE001
|
||||||
|
return {"status": "error", "error": str(e), "http_status": None}
|
||||||
|
|
||||||
|
ctag_m = _CTAG_RE.search(xml)
|
||||||
|
if ctag_m:
|
||||||
|
return {"status": "ok", "http_status": status, "ctag": ctag_m.group(1).strip()}
|
||||||
|
etag_m = _ETAG_RE.search(xml)
|
||||||
|
if etag_m:
|
||||||
|
return {"status": "ok", "http_status": status, "ctag": etag_m.group(1).strip()}
|
||||||
|
return {
|
||||||
|
"status": "error",
|
||||||
|
"error": "ni getctag ni getetag en la respuesta",
|
||||||
|
"http_status": status,
|
||||||
|
}
|
||||||
@@ -0,0 +1,119 @@
|
|||||||
|
"""Tests para dav_collection_ctag.
|
||||||
|
|
||||||
|
Smoke deterministas: monkeypatchean urllib.request.urlopen para capturar el
|
||||||
|
Request (method PROPFIND, Depth:0, auth) y devolver un multistatus simulado.
|
||||||
|
Cubren: getctag presente, fallback a getetag cuando no hay getctag, ninguno de
|
||||||
|
los dos, y el path de error HTTP.
|
||||||
|
"""
|
||||||
|
|
||||||
|
import base64
|
||||||
|
import sys
|
||||||
|
|
||||||
|
import infra.dav_collection_ctag # noqa: F401
|
||||||
|
|
||||||
|
mod = sys.modules["infra.dav_collection_ctag"]
|
||||||
|
dav_collection_ctag = mod.dav_collection_ctag
|
||||||
|
|
||||||
|
_XML_CTAG = (
|
||||||
|
'<?xml version="1.0"?>'
|
||||||
|
'<ns0:multistatus xmlns:ns0="DAV:" xmlns:ns1="http://calendarserver.org/ns/">'
|
||||||
|
"<ns0:response><ns0:href>/enmanuel/calendars/calendar/</ns0:href>"
|
||||||
|
"<ns0:propstat><ns0:status>HTTP/1.1 200 OK</ns0:status><ns0:prop>"
|
||||||
|
"<ns1:getctag>ctag-abc123</ns1:getctag>"
|
||||||
|
'<ns0:getetag>"etag-abc123"</ns0:getetag>'
|
||||||
|
"</ns0:prop></ns0:propstat></ns0:response></ns0:multistatus>"
|
||||||
|
)
|
||||||
|
|
||||||
|
_XML_SOLO_ETAG = (
|
||||||
|
'<?xml version="1.0"?>'
|
||||||
|
'<ns0:multistatus xmlns:ns0="DAV:">'
|
||||||
|
"<ns0:response><ns0:href>/enmanuel/contacts/addressbook/</ns0:href>"
|
||||||
|
"<ns0:propstat><ns0:status>HTTP/1.1 200 OK</ns0:status><ns0:prop>"
|
||||||
|
'<ns0:getetag>"etag-only-999"</ns0:getetag>'
|
||||||
|
"</ns0:prop></ns0:propstat></ns0:response></ns0:multistatus>"
|
||||||
|
)
|
||||||
|
|
||||||
|
_XML_VACIO = (
|
||||||
|
'<?xml version="1.0"?>'
|
||||||
|
'<ns0:multistatus xmlns:ns0="DAV:">'
|
||||||
|
"<ns0:response><ns0:href>/enmanuel/contacts/addressbook/</ns0:href>"
|
||||||
|
"<ns0:propstat><ns0:status>HTTP/1.1 404 Not Found</ns0:status>"
|
||||||
|
"<ns0:prop></ns0:prop></ns0:propstat></ns0:response></ns0:multistatus>"
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
class _FakeResp:
|
||||||
|
def __init__(self, payload: str):
|
||||||
|
self._payload = payload
|
||||||
|
self.status = 207
|
||||||
|
|
||||||
|
def __enter__(self):
|
||||||
|
return self
|
||||||
|
|
||||||
|
def __exit__(self, *a):
|
||||||
|
return False
|
||||||
|
|
||||||
|
def read(self):
|
||||||
|
return self._payload.encode("utf-8")
|
||||||
|
|
||||||
|
|
||||||
|
def _capture(monkeypatch, payload: str):
|
||||||
|
captured = {}
|
||||||
|
|
||||||
|
def fake_urlopen(req, timeout=None, context=None):
|
||||||
|
captured["method"] = req.get_method()
|
||||||
|
captured["headers"] = {k.lower(): v for k, v in req.header_items()}
|
||||||
|
return _FakeResp(payload)
|
||||||
|
|
||||||
|
monkeypatch.setattr(mod.urllib.request, "urlopen", fake_urlopen)
|
||||||
|
return captured
|
||||||
|
|
||||||
|
|
||||||
|
def _call(path="/enmanuel/calendars/calendar/"):
|
||||||
|
return dav_collection_ctag(
|
||||||
|
"https://dav.example.com", "enmanuel", "secret-pw", path
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def test_construye_propfind_depth_0(monkeypatch):
|
||||||
|
cap = _capture(monkeypatch, _XML_CTAG)
|
||||||
|
_call()
|
||||||
|
assert cap["method"] == "PROPFIND"
|
||||||
|
assert cap["headers"]["depth"] == "0"
|
||||||
|
|
||||||
|
|
||||||
|
def test_basic_auth_header_correcto(monkeypatch):
|
||||||
|
cap = _capture(monkeypatch, _XML_CTAG)
|
||||||
|
_call()
|
||||||
|
expected = "Basic " + base64.b64encode(b"enmanuel:secret-pw").decode("ascii")
|
||||||
|
assert cap["headers"]["authorization"] == expected
|
||||||
|
|
||||||
|
|
||||||
|
def test_devuelve_getctag(monkeypatch):
|
||||||
|
_capture(monkeypatch, _XML_CTAG)
|
||||||
|
res = _call()
|
||||||
|
assert res["status"] == "ok"
|
||||||
|
assert res["ctag"] == "ctag-abc123"
|
||||||
|
|
||||||
|
|
||||||
|
def test_fallback_a_getetag(monkeypatch):
|
||||||
|
_capture(monkeypatch, _XML_SOLO_ETAG)
|
||||||
|
res = _call("/enmanuel/contacts/addressbook/")
|
||||||
|
assert res["status"] == "ok"
|
||||||
|
assert res["ctag"] == '"etag-only-999"'
|
||||||
|
|
||||||
|
|
||||||
|
def test_sin_ctag_ni_etag_devuelve_error(monkeypatch):
|
||||||
|
_capture(monkeypatch, _XML_VACIO)
|
||||||
|
res = _call("/enmanuel/contacts/addressbook/")
|
||||||
|
assert res["status"] == "error"
|
||||||
|
|
||||||
|
|
||||||
|
def test_httperror_devuelve_status_error(monkeypatch):
|
||||||
|
def fake_urlopen(req, timeout=None, context=None):
|
||||||
|
raise mod.urllib.error.HTTPError(req.full_url, 401, "Unauthorized", {}, None)
|
||||||
|
|
||||||
|
monkeypatch.setattr(mod.urllib.request, "urlopen", fake_urlopen)
|
||||||
|
res = _call()
|
||||||
|
assert res["status"] == "error"
|
||||||
|
assert res["http_status"] == 401
|
||||||
@@ -0,0 +1,87 @@
|
|||||||
|
---
|
||||||
|
name: dav_delete_resource
|
||||||
|
kind: function
|
||||||
|
lang: py
|
||||||
|
domain: infra
|
||||||
|
version: "1.0.0"
|
||||||
|
purity: impure
|
||||||
|
signature: "def dav_delete_resource(base_url: str, username: str, password: str, resource_path: str, *, etag: str = '', timeout_s: float = 20.0, verify_tls: bool = True) -> dict"
|
||||||
|
description: "Borra (HTTP DELETE) un recurso DAV individual (un VCARD o un VCALENDAR) con HTTP Basic auth. Construye el header Authorization: Basic base64(user:pass) a mano con stdlib. El resource_path puede ser un href absoluto (como los que devuelven dav_list_resources / dav_get_collection) o una URL completa. Opcionalmente envia If-Match: <etag> para un borrado condicional que evita pisar una edicion concurrente. DESTRUCTIVO e IRREVERSIBLE: usar con confirmacion explicita, nunca a ciegas. verify_tls=True por defecto. Maneja errores sin lanzar. Solo stdlib (urllib, base64, ssl). Probado contra Xandikos."
|
||||||
|
tags: [dav, carddav, caldav, delete, remove, http, infra]
|
||||||
|
uses_functions: []
|
||||||
|
uses_types: []
|
||||||
|
returns: []
|
||||||
|
returns_optional: false
|
||||||
|
error_type: "error_go_core"
|
||||||
|
imports: [base64, ssl, urllib.error, urllib.request]
|
||||||
|
params:
|
||||||
|
- name: base_url
|
||||||
|
desc: "URL base del servidor DAV. Se ignora si resource_path ya es una URL absoluta."
|
||||||
|
- name: username
|
||||||
|
desc: "usuario para HTTP Basic auth (p.ej. 'enmanuel')."
|
||||||
|
- name: password
|
||||||
|
desc: "contrasena para HTTP Basic auth. Resolver desde pass con pass_get_secret, nunca hardcodear."
|
||||||
|
- name: resource_path
|
||||||
|
desc: "href absoluto (p.ej. '/enmanuel/contacts/addressbook/x.vcf') o URL completa del recurso a borrar. Acepta directamente los hrefs que devuelven dav_list_resources / dav_get_collection."
|
||||||
|
- name: etag
|
||||||
|
desc: "etag del recurso para borrado condicional via If-Match. Si se da, el servidor solo borra cuando el etag actual coincide (412 si cambio). Vacio = borrado incondicional."
|
||||||
|
- name: timeout_s
|
||||||
|
desc: "timeout de la peticion HTTP en segundos. Default 20.0."
|
||||||
|
- name: verify_tls
|
||||||
|
desc: "si True (default) verifica el certificado TLS. No desactivar salvo entorno de prueba."
|
||||||
|
output: "dict. En exito: {status:'ok', http_status:int, url:str} (DELETE devuelve normalmente 204 No Content o 200). En error (sin lanzar): {status:'error', error:str, http_status:int|None}. Un 404 (ya no existe) llega como error con http_status=404, tratable como idempotente."
|
||||||
|
tested: true
|
||||||
|
tests:
|
||||||
|
- "test_construye_request_delete_con_auth"
|
||||||
|
- "test_resource_path_relativo_se_resuelve_con_base_url"
|
||||||
|
- "test_resource_path_absoluto_se_respeta"
|
||||||
|
- "test_if_match_se_envia_cuando_hay_etag"
|
||||||
|
- "test_sin_etag_no_envia_if_match"
|
||||||
|
- "test_204_devuelve_ok"
|
||||||
|
- "test_404_devuelve_status_error"
|
||||||
|
test_file_path: "python/functions/infra/dav_delete_resource_test.py"
|
||||||
|
file_path: "python/functions/infra/dav_delete_resource.py"
|
||||||
|
---
|
||||||
|
|
||||||
|
## Ejemplo
|
||||||
|
|
||||||
|
```python
|
||||||
|
import sys
|
||||||
|
sys.path.insert(0, "python/functions")
|
||||||
|
from infra.pass_get_secret import pass_get_secret
|
||||||
|
from infra.carddav_put_vcard import carddav_put_vcard
|
||||||
|
from infra.dav_delete_resource import dav_delete_resource
|
||||||
|
|
||||||
|
base = "https://dav-eedeb681c4ab89ab8e444ac9.organic-machine.com"
|
||||||
|
coll = "/enmanuel/contacts/addressbook/"
|
||||||
|
pw = pass_get_secret("dav/xandikos-enmanuel")["value"] # NO logear
|
||||||
|
|
||||||
|
# Sube un vCard de prueba y luego lo borra (limpieza de un test):
|
||||||
|
vcard = "BEGIN:VCARD\r\nVERSION:3.0\r\nFN:Tmp\r\nUID:zz-tmp\r\nEND:VCARD\r\n"
|
||||||
|
carddav_put_vcard(base, "enmanuel", pw, coll, "zz-tmp", vcard)
|
||||||
|
res = dav_delete_resource(base, "enmanuel", pw, coll + "zz-tmp.vcf")
|
||||||
|
print(res["status"], res["http_status"]) # ok 204
|
||||||
|
```
|
||||||
|
|
||||||
|
## Cuando usarla
|
||||||
|
|
||||||
|
Cuando necesitas RETIRAR un recurso de una coleccion CardDAV/CalDAV: limpiar el
|
||||||
|
vCard de prueba que subiste para validar un sync, borrar un contacto obsoleto,
|
||||||
|
o eliminar un evento cancelado. Completa el CRUD del grupo `dav` (put / get /
|
||||||
|
list / get-collection / **delete**). Para limpieza segura tras un test usa el
|
||||||
|
href que devuelve `carddav_put_vcard` (campo `url`) o el `href` de
|
||||||
|
`dav_get_collection`.
|
||||||
|
|
||||||
|
## Gotchas
|
||||||
|
|
||||||
|
- DESTRUCTIVO e IRREVERSIBLE en el servidor. No llamarla en un bucle de sync sin
|
||||||
|
confirmacion explicita (`confirm=True` / `--yes` en el caller). Pensada para
|
||||||
|
acciones puntuales controladas, no para reconciliacion automatica.
|
||||||
|
- Un 404 (el recurso ya no existe) llega como `{status:'error', http_status:404}`.
|
||||||
|
Para un borrado idempotente, el caller puede tratar 404 como exito ("ya estaba
|
||||||
|
borrado").
|
||||||
|
- Pasa `etag` para borrado condicional (If-Match): si el recurso cambio desde que
|
||||||
|
lo leiste, el servidor responde 412 Precondition Failed y NO borra — evita
|
||||||
|
pisar una edicion concurrente del movil.
|
||||||
|
- Borrado remoto real sobre TLS; password de `pass`, no se logea.
|
||||||
|
- `verify_tls=False` solo en pruebas; abre MITM.
|
||||||
@@ -0,0 +1,83 @@
|
|||||||
|
"""Borra (DELETE) un recurso DAV individual via HTTP Basic auth.
|
||||||
|
|
||||||
|
Funcion impura: hace una peticion HTTP DELETE. Construye el header
|
||||||
|
`Authorization: Basic base64(user:pass)` a mano con stdlib. El resource_path
|
||||||
|
puede ser un href absoluto (como los que devuelve dav_list_resources /
|
||||||
|
dav_get_collection) o una ruta relativa al base_url. Opcionalmente envia el
|
||||||
|
header `If-Match: <etag>` para un borrado condicional (solo borra si el etag
|
||||||
|
coincide, evita pisar una edicion concurrente). Maneja errores sin lanzar.
|
||||||
|
Solo usa stdlib (urllib, base64, ssl).
|
||||||
|
|
||||||
|
ATENCION: DELETE es DESTRUCTIVO e IRREVERSIBLE en el servidor. Usar con
|
||||||
|
confirmacion explicita del caller (nunca a ciegas en un bucle de sync). Pensado
|
||||||
|
para limpiar recursos de prueba o retirar contactos obsoletos de forma
|
||||||
|
controlada.
|
||||||
|
"""
|
||||||
|
|
||||||
|
import base64
|
||||||
|
import ssl
|
||||||
|
import urllib.error
|
||||||
|
import urllib.request
|
||||||
|
|
||||||
|
|
||||||
|
def _basic_auth_header(username: str, password: str) -> str:
|
||||||
|
raw = ("%s:%s" % (username, password)).encode("utf-8")
|
||||||
|
return "Basic " + base64.b64encode(raw).decode("ascii")
|
||||||
|
|
||||||
|
|
||||||
|
def _resolve_url(base_url: str, resource_path: str) -> str:
|
||||||
|
if resource_path.startswith("http://") or resource_path.startswith("https://"):
|
||||||
|
return resource_path
|
||||||
|
return base_url.rstrip("/") + "/" + resource_path.lstrip("/")
|
||||||
|
|
||||||
|
|
||||||
|
def dav_delete_resource(
|
||||||
|
base_url: str,
|
||||||
|
username: str,
|
||||||
|
password: str,
|
||||||
|
resource_path: str,
|
||||||
|
*,
|
||||||
|
etag: str = "",
|
||||||
|
timeout_s: float = 20.0,
|
||||||
|
verify_tls: bool = True,
|
||||||
|
) -> dict:
|
||||||
|
"""Borra un recurso DAV (DELETE). DESTRUCTIVO e IRREVERSIBLE.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
base_url: URL base del servidor DAV. Se ignora si resource_path ya es
|
||||||
|
una URL absoluta.
|
||||||
|
username: usuario para HTTP Basic auth.
|
||||||
|
password: contrasena para HTTP Basic auth. Resolver desde pass.
|
||||||
|
resource_path: href absoluto (p.ej. '/enmanuel/contacts/addressbook/x.vcf')
|
||||||
|
o URL completa del recurso a borrar. Acepta directamente los hrefs
|
||||||
|
que devuelven dav_list_resources / dav_get_collection.
|
||||||
|
etag: si se da, se envia como header If-Match para un borrado
|
||||||
|
condicional (el servidor solo borra si el etag actual coincide;
|
||||||
|
devuelve 412 Precondition Failed si cambio). Vacio = borrado
|
||||||
|
incondicional.
|
||||||
|
timeout_s: timeout de la peticion en segundos. Default 20.0.
|
||||||
|
verify_tls: si True (default) verifica el certificado TLS.
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
dict. En exito: {status:'ok', http_status:int, url:str} (DELETE devuelve
|
||||||
|
normalmente 204 No Content o 200). En error (sin lanzar):
|
||||||
|
{status:'error', error:str, http_status:int|None}. Un 404 (ya no existe)
|
||||||
|
se devuelve como error con http_status=404; el caller puede tratarlo
|
||||||
|
como idempotente (ya borrado).
|
||||||
|
"""
|
||||||
|
url = _resolve_url(base_url, resource_path)
|
||||||
|
headers = {"Authorization": _basic_auth_header(username, password)}
|
||||||
|
if etag:
|
||||||
|
headers["If-Match"] = etag
|
||||||
|
req = urllib.request.Request(url, method="DELETE", headers=headers)
|
||||||
|
|
||||||
|
context = None if verify_tls else ssl._create_unverified_context()
|
||||||
|
try:
|
||||||
|
with urllib.request.urlopen(req, timeout=timeout_s, context=context) as resp:
|
||||||
|
return {"status": "ok", "http_status": resp.status, "url": url}
|
||||||
|
except urllib.error.HTTPError as e:
|
||||||
|
return {"status": "error", "error": "http %s" % e.code, "http_status": e.code}
|
||||||
|
except urllib.error.URLError as e:
|
||||||
|
return {"status": "error", "error": str(e.reason), "http_status": None}
|
||||||
|
except Exception as e: # noqa: BLE001
|
||||||
|
return {"status": "error", "error": str(e), "http_status": None}
|
||||||
@@ -0,0 +1,96 @@
|
|||||||
|
"""Tests para dav_delete_resource.
|
||||||
|
|
||||||
|
Smoke deterministas: monkeypatchean urllib.request.urlopen para capturar el
|
||||||
|
Request (method DELETE, auth, URL, headers If-Match) y simular respuestas.
|
||||||
|
"""
|
||||||
|
|
||||||
|
import base64
|
||||||
|
import sys
|
||||||
|
|
||||||
|
import infra.dav_delete_resource # noqa: F401
|
||||||
|
|
||||||
|
mod = sys.modules["infra.dav_delete_resource"]
|
||||||
|
dav_delete_resource = mod.dav_delete_resource
|
||||||
|
|
||||||
|
|
||||||
|
class _FakeResp:
|
||||||
|
def __init__(self, status=204):
|
||||||
|
self.status = status
|
||||||
|
|
||||||
|
def __enter__(self):
|
||||||
|
return self
|
||||||
|
|
||||||
|
def __exit__(self, *a):
|
||||||
|
return False
|
||||||
|
|
||||||
|
|
||||||
|
def _capture(monkeypatch, status=204):
|
||||||
|
captured = {}
|
||||||
|
|
||||||
|
def fake_urlopen(req, timeout=None, context=None):
|
||||||
|
captured["url"] = req.full_url
|
||||||
|
captured["method"] = req.get_method()
|
||||||
|
captured["headers"] = {k.lower(): v for k, v in req.header_items()}
|
||||||
|
return _FakeResp(status)
|
||||||
|
|
||||||
|
monkeypatch.setattr(mod.urllib.request, "urlopen", fake_urlopen)
|
||||||
|
return captured
|
||||||
|
|
||||||
|
|
||||||
|
def test_construye_request_delete_con_auth(monkeypatch):
|
||||||
|
cap = _capture(monkeypatch)
|
||||||
|
res = dav_delete_resource(
|
||||||
|
"https://dav.example.com", "enmanuel", "secret-pw",
|
||||||
|
"/enmanuel/contacts/addressbook/ada.vcf",
|
||||||
|
)
|
||||||
|
assert res["status"] == "ok"
|
||||||
|
assert cap["method"] == "DELETE"
|
||||||
|
expected = "Basic " + base64.b64encode(b"enmanuel:secret-pw").decode("ascii")
|
||||||
|
assert cap["headers"]["authorization"] == expected
|
||||||
|
|
||||||
|
|
||||||
|
def test_resource_path_relativo_se_resuelve_con_base_url(monkeypatch):
|
||||||
|
cap = _capture(monkeypatch)
|
||||||
|
dav_delete_resource(
|
||||||
|
"https://dav.example.com", "u", "p",
|
||||||
|
"/enmanuel/contacts/addressbook/ada.vcf",
|
||||||
|
)
|
||||||
|
assert cap["url"] == "https://dav.example.com/enmanuel/contacts/addressbook/ada.vcf"
|
||||||
|
|
||||||
|
|
||||||
|
def test_resource_path_absoluto_se_respeta(monkeypatch):
|
||||||
|
cap = _capture(monkeypatch)
|
||||||
|
abs_url = "https://otra.example.com/path/x.vcf"
|
||||||
|
dav_delete_resource("https://dav.example.com", "u", "p", abs_url)
|
||||||
|
assert cap["url"] == abs_url
|
||||||
|
|
||||||
|
|
||||||
|
def test_if_match_se_envia_cuando_hay_etag(monkeypatch):
|
||||||
|
cap = _capture(monkeypatch)
|
||||||
|
dav_delete_resource(
|
||||||
|
"https://dav.example.com", "u", "p", "/x.vcf", etag='"abc123"',
|
||||||
|
)
|
||||||
|
assert cap["headers"]["if-match"] == '"abc123"'
|
||||||
|
|
||||||
|
|
||||||
|
def test_sin_etag_no_envia_if_match(monkeypatch):
|
||||||
|
cap = _capture(monkeypatch)
|
||||||
|
dav_delete_resource("https://dav.example.com", "u", "p", "/x.vcf")
|
||||||
|
assert "if-match" not in cap["headers"]
|
||||||
|
|
||||||
|
|
||||||
|
def test_204_devuelve_ok(monkeypatch):
|
||||||
|
_capture(monkeypatch, status=204)
|
||||||
|
res = dav_delete_resource("https://dav.example.com", "u", "p", "/x.vcf")
|
||||||
|
assert res["status"] == "ok"
|
||||||
|
assert res["http_status"] == 204
|
||||||
|
|
||||||
|
|
||||||
|
def test_404_devuelve_status_error(monkeypatch):
|
||||||
|
def fake_urlopen(req, timeout=None, context=None):
|
||||||
|
raise mod.urllib.error.HTTPError(req.full_url, 404, "Not Found", {}, None)
|
||||||
|
|
||||||
|
monkeypatch.setattr(mod.urllib.request, "urlopen", fake_urlopen)
|
||||||
|
res = dav_delete_resource("https://dav.example.com", "u", "p", "/x.vcf")
|
||||||
|
assert res["status"] == "error"
|
||||||
|
assert res["http_status"] == 404
|
||||||
@@ -0,0 +1,107 @@
|
|||||||
|
---
|
||||||
|
name: dav_get_collection
|
||||||
|
kind: function
|
||||||
|
lang: py
|
||||||
|
domain: infra
|
||||||
|
version: "1.0.0"
|
||||||
|
purity: impure
|
||||||
|
signature: "def dav_get_collection(base_url: str, username: str, password: str, collection_path: str, content_type: str = 'vcard', *, timeout_s: float = 30.0, verify_tls: bool = True) -> dict"
|
||||||
|
description: "Descarga TODOS los recursos de una coleccion DAV en UNA peticion HTTP REPORT con el contenido inline, evitando el patron N+1 (PROPFIND + un GET por recurso). Usa addressbook-query (CardDAV, content_type='vcard') o calendar-query (CalDAV, content_type='ical'); el servidor responde un multistatus con el vCard/VCALENDAR de cada recurso embebido. Construye el header Authorization: Basic base64(user:pass) a mano con stdlib, parsea el XML con regex simple y des-escapa las entidades XML del contenido. Para 1064 contactos baja de ~9s (N GETs) a ~1s (1 REPORT). verify_tls=True por defecto. Maneja errores sin lanzar. Solo stdlib (urllib, base64, re, ssl, html). Probado contra Xandikos."
|
||||||
|
tags: [dav, carddav, caldav, report, multiget, addressbook-query, calendar-query, bulk, http, infra]
|
||||||
|
uses_functions: []
|
||||||
|
uses_types: []
|
||||||
|
returns: []
|
||||||
|
returns_optional: false
|
||||||
|
error_type: "error_go_core"
|
||||||
|
imports: [base64, html, re, ssl, urllib.error, urllib.request]
|
||||||
|
params:
|
||||||
|
- name: base_url
|
||||||
|
desc: "URL base del servidor DAV (p.ej. 'https://dav-eedeb681c4ab89ab8e444ac9.organic-machine.com')."
|
||||||
|
- name: username
|
||||||
|
desc: "usuario para HTTP Basic auth (p.ej. 'enmanuel')."
|
||||||
|
- name: password
|
||||||
|
desc: "contrasena para HTTP Basic auth. Resolver desde pass con pass_get_secret, nunca hardcodear."
|
||||||
|
- name: collection_path
|
||||||
|
desc: "ruta de la coleccion (CardDAV '/enmanuel/contacts/addressbook/' o CalDAV '/enmanuel/calendars/calendar/')."
|
||||||
|
- name: content_type
|
||||||
|
desc: "tipo de la coleccion: 'vcard' (CardDAV, default) o 'ical' (CalDAV). Acepta sinonimos: 'carddav'/'contacts'/'addressbook' -> vcard; 'caldav'/'calendar'/'icalendar' -> ical."
|
||||||
|
- name: timeout_s
|
||||||
|
desc: "timeout de la peticion HTTP en segundos. Default 30.0 (la respuesta puede ser grande: ~600KB para 1000 contactos)."
|
||||||
|
- name: verify_tls
|
||||||
|
desc: "si True (default) verifica el certificado TLS. No desactivar salvo entorno de prueba."
|
||||||
|
output: "dict. En exito: {status:'ok', http_status:int, resources:[{href:str, etag:str|None, data:str}, ...]} con un elemento por recurso de la coleccion; data es el vCard / VCALENDAR completo ya des-escapado. En error (sin lanzar): {status:'error', error:str, http_status:int|None}."
|
||||||
|
tested: true
|
||||||
|
tests:
|
||||||
|
- "test_vcard_construye_report_addressbook_query"
|
||||||
|
- "test_ical_construye_report_calendar_query_con_filtro"
|
||||||
|
- "test_basic_auth_header_correcto"
|
||||||
|
- "test_parsea_resources_con_data_inline"
|
||||||
|
- "test_desescapa_entidades_xml_del_data"
|
||||||
|
- "test_ical_parsea_calendar_data"
|
||||||
|
- "test_acepta_sinonimos_de_content_type"
|
||||||
|
- "test_content_type_invalido_devuelve_error"
|
||||||
|
- "test_httperror_devuelve_status_error"
|
||||||
|
test_file_path: "python/functions/infra/dav_get_collection_test.py"
|
||||||
|
file_path: "python/functions/infra/dav_get_collection.py"
|
||||||
|
---
|
||||||
|
|
||||||
|
## Ejemplo
|
||||||
|
|
||||||
|
```python
|
||||||
|
import sys
|
||||||
|
sys.path.insert(0, "python/functions")
|
||||||
|
from infra.pass_get_secret import pass_get_secret
|
||||||
|
from infra.dav_get_collection import dav_get_collection
|
||||||
|
|
||||||
|
pw = pass_get_secret("dav/xandikos-enmanuel")["value"] # NO logear
|
||||||
|
|
||||||
|
# Todos los contactos en UNA peticion (~1s para 1064 vCards):
|
||||||
|
res = dav_get_collection(
|
||||||
|
base_url="https://dav-eedeb681c4ab89ab8e444ac9.organic-machine.com",
|
||||||
|
username="enmanuel",
|
||||||
|
password=pw,
|
||||||
|
collection_path="/enmanuel/contacts/addressbook/",
|
||||||
|
content_type="vcard",
|
||||||
|
)
|
||||||
|
print(res["status"], len(res["resources"])) # ok 1064
|
||||||
|
print(res["resources"][0]["data"][:40]) # BEGIN:VCARD\nVERSION:3.0\nFN:...
|
||||||
|
|
||||||
|
# Todos los eventos del calendario en UNA peticion:
|
||||||
|
cal = dav_get_collection(
|
||||||
|
base_url="https://dav-eedeb681c4ab89ab8e444ac9.organic-machine.com",
|
||||||
|
username="enmanuel",
|
||||||
|
password=pw,
|
||||||
|
collection_path="/enmanuel/calendars/calendar/",
|
||||||
|
content_type="ical",
|
||||||
|
)
|
||||||
|
print(cal["status"], len(cal["resources"])) # ok 98
|
||||||
|
```
|
||||||
|
|
||||||
|
## Cuando usarla
|
||||||
|
|
||||||
|
Cuando necesitas el contenido de TODOS los recursos de una coleccion CardDAV o
|
||||||
|
CalDAV (renderizar la agenda completa, listar todos los eventos, sincronizar en
|
||||||
|
bloque) y no solo sus hrefs. Sustituye a `dav_list_resources` + un
|
||||||
|
`dav_get_resource` por recurso: una sola ida y vuelta en lugar de N+1, lo que
|
||||||
|
para colecciones de cientos/miles de recursos es la diferencia entre ~9s y ~1s.
|
||||||
|
Si solo necesitas los hrefs/etags (sin contenido), usa `dav_list_resources`; si
|
||||||
|
necesitas un unico recurso, usa `dav_get_resource`.
|
||||||
|
|
||||||
|
## Gotchas
|
||||||
|
|
||||||
|
- Usa los REPORT `addressbook-query` / `calendar-query` (RFC 6352 / 4791) con
|
||||||
|
Depth:1, NO `addressbook-multiget` (que en Xandikos exige Depth:0 + una lista
|
||||||
|
explicita de hrefs en el cuerpo). El query no necesita conocer los hrefs de
|
||||||
|
antemano: una sola peticion trae todo.
|
||||||
|
- El namespace CardDAV/CalDAV es el "legacy" `urn:ietf:params:xml:ns:carddav`
|
||||||
|
(con `:ns:`), que es el que Xandikos anuncia en su `supported-report-set`. El
|
||||||
|
namespace sin `:ns:` (`urn:ietf:params:xml:carddav`) provoca un 403
|
||||||
|
"Unknown report" en Xandikos.
|
||||||
|
- El contenido inline viene XML-escapado en el multistatus (`<`, `>`,
|
||||||
|
`&`); la funcion lo des-escapa con `html.unescape` antes de devolverlo.
|
||||||
|
El `data` resultante es el vCard / VCALENDAR tal cual lo guardo el servidor.
|
||||||
|
- El parseo es regex simple sobre el multistatus (KISS, sin parser XML): robusto
|
||||||
|
para la salida estandar de Xandikos, podria fallar con XML muy exotico.
|
||||||
|
- La respuesta puede ser grande (~600KB para 1000 contactos): el timeout default
|
||||||
|
es 30s, mayor que el de `dav_list_resources` por eso.
|
||||||
|
- Lectura remota real sobre TLS; password de `pass`, no se logea.
|
||||||
@@ -0,0 +1,200 @@
|
|||||||
|
"""Descarga TODOS los recursos de una coleccion DAV en UNA peticion (REPORT).
|
||||||
|
|
||||||
|
Funcion impura: hace una unica peticion HTTP REPORT (`addressbook-query` para
|
||||||
|
CardDAV, `calendar-query` para CalDAV) que el servidor responde con un XML
|
||||||
|
multistatus que lleva el contenido de cada recurso INLINE (vCard / VCALENDAR).
|
||||||
|
Esto reemplaza el patron N+1 (un PROPFIND + un GET por recurso) por una sola
|
||||||
|
ida y vuelta: para 1064 contactos baja de ~9s a ~1s.
|
||||||
|
|
||||||
|
Construye el header `Authorization: Basic base64(user:pass)` a mano con stdlib y
|
||||||
|
parsea el multistatus con regex simple (sin parser XML externo), des-escapando
|
||||||
|
las entidades XML del contenido inline. Maneja errores sin lanzar. Solo usa
|
||||||
|
stdlib (urllib, base64, re, ssl, html). Probado contra Xandikos.
|
||||||
|
"""
|
||||||
|
|
||||||
|
import base64
|
||||||
|
import html
|
||||||
|
import re
|
||||||
|
import ssl
|
||||||
|
import urllib.error
|
||||||
|
import urllib.request
|
||||||
|
|
||||||
|
# Namespaces y elementos por tipo de coleccion. Xandikos (y la mayoria de
|
||||||
|
# servidores) usan el namespace "legacy" con `:ns:` que es el que aparece en el
|
||||||
|
# supported-report-set, no `urn:ietf:params:xml:carddav`.
|
||||||
|
_PROFILES = {
|
||||||
|
"vcard": {
|
||||||
|
"ns": "urn:ietf:params:xml:ns:carddav",
|
||||||
|
"report": "addressbook-query",
|
||||||
|
"data_prop": "address-data",
|
||||||
|
},
|
||||||
|
"ical": {
|
||||||
|
"ns": "urn:ietf:params:xml:ns:caldav",
|
||||||
|
"report": "calendar-query",
|
||||||
|
"data_prop": "calendar-data",
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
# Aceptamos sinonimos comunes para no atar al caller a un literal exacto.
|
||||||
|
_ALIASES = {
|
||||||
|
"vcard": "vcard",
|
||||||
|
"carddav": "vcard",
|
||||||
|
"contacts": "vcard",
|
||||||
|
"addressbook": "vcard",
|
||||||
|
"ical": "ical",
|
||||||
|
"icalendar": "ical",
|
||||||
|
"caldav": "ical",
|
||||||
|
"calendar": "ical",
|
||||||
|
}
|
||||||
|
|
||||||
|
_RESPONSE_RE = re.compile(
|
||||||
|
r"<(?:[A-Za-z0-9]+:)?response>(.*?)</(?:[A-Za-z0-9]+:)?response>",
|
||||||
|
re.DOTALL | re.IGNORECASE,
|
||||||
|
)
|
||||||
|
_HREF_RE = re.compile(
|
||||||
|
r"<(?:[A-Za-z0-9]+:)?href>\s*(.*?)\s*</(?:[A-Za-z0-9]+:)?href>",
|
||||||
|
re.DOTALL | re.IGNORECASE,
|
||||||
|
)
|
||||||
|
_ETAG_RE = re.compile(
|
||||||
|
r"<(?:[A-Za-z0-9]+:)?getetag>\s*(.*?)\s*</(?:[A-Za-z0-9]+:)?getetag>",
|
||||||
|
re.DOTALL | re.IGNORECASE,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def _basic_auth_header(username: str, password: str) -> str:
|
||||||
|
raw = ("%s:%s" % (username, password)).encode("utf-8")
|
||||||
|
return "Basic " + base64.b64encode(raw).decode("ascii")
|
||||||
|
|
||||||
|
|
||||||
|
def _join_url(base_url: str, collection_path: str) -> str:
|
||||||
|
return base_url.rstrip("/") + "/" + collection_path.strip("/") + "/"
|
||||||
|
|
||||||
|
|
||||||
|
def _report_body(profile: dict) -> str:
|
||||||
|
"""Construye el cuerpo XML del REPORT query para el perfil dado.
|
||||||
|
|
||||||
|
`addressbook-query` (CardDAV) no lleva filtro de tiempo: trae todos los
|
||||||
|
vCards. `calendar-query` (CalDAV) exige un `<filter>` con un comp-filter de
|
||||||
|
VCALENDAR; sin un comp-filter interno trae todos los componentes (todos los
|
||||||
|
eventos), que es lo que queremos.
|
||||||
|
"""
|
||||||
|
ns = profile["ns"]
|
||||||
|
report = profile["report"]
|
||||||
|
data_prop = profile["data_prop"]
|
||||||
|
prop = "<D:prop><D:getetag/><C:%s/></D:prop>" % data_prop
|
||||||
|
if report == "calendar-query":
|
||||||
|
filt = '<C:filter><C:comp-filter name="VCALENDAR"/></C:filter>'
|
||||||
|
else:
|
||||||
|
filt = ""
|
||||||
|
return (
|
||||||
|
'<?xml version="1.0" encoding="utf-8" ?>'
|
||||||
|
'<C:%s xmlns:D="DAV:" xmlns:C="%s">%s%s</C:%s>'
|
||||||
|
% (report, ns, prop, filt, report)
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def _data_re(data_prop: str) -> "re.Pattern":
|
||||||
|
"""Regex para extraer el contenido inline del elemento de datos.
|
||||||
|
|
||||||
|
El servidor namespacea el elemento (`<ns1:address-data>`); el contenido va
|
||||||
|
XML-escapado. Capturamos el cuerpo y lo des-escapamos con html.unescape.
|
||||||
|
"""
|
||||||
|
return re.compile(
|
||||||
|
r"<(?:[A-Za-z0-9]+:)?%s[^>]*>(.*?)</(?:[A-Za-z0-9]+:)?%s>"
|
||||||
|
% (re.escape(data_prop), re.escape(data_prop)),
|
||||||
|
re.DOTALL | re.IGNORECASE,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def dav_get_collection(
|
||||||
|
base_url: str,
|
||||||
|
username: str,
|
||||||
|
password: str,
|
||||||
|
collection_path: str,
|
||||||
|
content_type: str = "vcard",
|
||||||
|
*,
|
||||||
|
timeout_s: float = 30.0,
|
||||||
|
verify_tls: bool = True,
|
||||||
|
) -> dict:
|
||||||
|
"""Descarga el contenido de TODOS los recursos de una coleccion en 1 request.
|
||||||
|
|
||||||
|
Hace un REPORT `addressbook-query` (vcard) o `calendar-query` (ical) que
|
||||||
|
devuelve el multistatus con el contenido inline de cada recurso, evitando
|
||||||
|
el patron N+1 (PROPFIND + un GET por recurso).
|
||||||
|
|
||||||
|
Args:
|
||||||
|
base_url: URL base del servidor DAV.
|
||||||
|
username: usuario para HTTP Basic auth.
|
||||||
|
password: contrasena para HTTP Basic auth. Resolver desde pass.
|
||||||
|
collection_path: ruta de la coleccion (CardDAV o CalDAV).
|
||||||
|
content_type: 'vcard' (CardDAV) o 'ical' (CalDAV). Acepta sinonimos
|
||||||
|
('carddav', 'contacts', 'caldav', 'calendar', ...).
|
||||||
|
timeout_s: timeout de la peticion en segundos. Default 30.0 (la
|
||||||
|
respuesta puede ser grande: ~600KB para 1000 contactos).
|
||||||
|
verify_tls: si True (default) verifica el certificado TLS.
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
dict. En exito: {status:'ok', http_status:int,
|
||||||
|
resources:[{href:str, etag:str|None, data:str}, ...]} con un elemento
|
||||||
|
por recurso de la coleccion (el `data` es el vCard / VCALENDAR ya
|
||||||
|
des-escapado). En error (sin lanzar): {status:'error', error:str,
|
||||||
|
http_status:int|None}.
|
||||||
|
"""
|
||||||
|
key = _ALIASES.get((content_type or "").strip().lower())
|
||||||
|
if key is None:
|
||||||
|
return {
|
||||||
|
"status": "error",
|
||||||
|
"error": "content_type invalido: %r (usa 'vcard' o 'ical')"
|
||||||
|
% content_type,
|
||||||
|
"http_status": None,
|
||||||
|
}
|
||||||
|
profile = _PROFILES[key]
|
||||||
|
|
||||||
|
url = _join_url(base_url, collection_path)
|
||||||
|
headers = {
|
||||||
|
"Authorization": _basic_auth_header(username, password),
|
||||||
|
"Content-Type": "application/xml; charset=utf-8",
|
||||||
|
# RFC 6352 / 4791: el query REPORT se aplica con Depth:1 sobre la
|
||||||
|
# coleccion (multiget exigiria Depth:0 + lista de hrefs; query no).
|
||||||
|
"Depth": "1",
|
||||||
|
}
|
||||||
|
req = urllib.request.Request(
|
||||||
|
url,
|
||||||
|
data=_report_body(profile).encode("utf-8"),
|
||||||
|
method="REPORT",
|
||||||
|
headers=headers,
|
||||||
|
)
|
||||||
|
|
||||||
|
context = None if verify_tls else ssl._create_unverified_context()
|
||||||
|
try:
|
||||||
|
with urllib.request.urlopen(req, timeout=timeout_s, context=context) as resp:
|
||||||
|
status = resp.status
|
||||||
|
xml = resp.read().decode("utf-8", "replace")
|
||||||
|
except urllib.error.HTTPError as e:
|
||||||
|
return {"status": "error", "error": "http %s" % e.code, "http_status": e.code}
|
||||||
|
except urllib.error.URLError as e:
|
||||||
|
return {"status": "error", "error": str(e.reason), "http_status": None}
|
||||||
|
except Exception as e: # noqa: BLE001
|
||||||
|
return {"status": "error", "error": str(e), "http_status": None}
|
||||||
|
|
||||||
|
coll_tail = collection_path.strip("/").rsplit("/", 1)[-1]
|
||||||
|
data_re = _data_re(profile["data_prop"])
|
||||||
|
resources = []
|
||||||
|
for block in _RESPONSE_RE.findall(xml):
|
||||||
|
href_m = _HREF_RE.search(block)
|
||||||
|
if not href_m:
|
||||||
|
continue
|
||||||
|
href = href_m.group(1).strip()
|
||||||
|
# Skip la propia coleccion si el servidor la incluyera.
|
||||||
|
tail = href.rstrip("/").rsplit("/", 1)[-1]
|
||||||
|
if tail == coll_tail:
|
||||||
|
continue
|
||||||
|
data_m = data_re.search(block)
|
||||||
|
if not data_m:
|
||||||
|
# Recurso sin contenido inline (404 en ese propstat): se omite.
|
||||||
|
continue
|
||||||
|
data = html.unescape(data_m.group(1)).strip()
|
||||||
|
etag_m = _ETAG_RE.search(block)
|
||||||
|
etag = etag_m.group(1).strip() if etag_m else None
|
||||||
|
resources.append({"href": href, "etag": etag, "data": data})
|
||||||
|
return {"status": "ok", "http_status": status, "resources": resources}
|
||||||
@@ -0,0 +1,203 @@
|
|||||||
|
"""Tests para dav_get_collection.
|
||||||
|
|
||||||
|
Smoke deterministas: monkeypatchean urllib.request.urlopen para capturar el
|
||||||
|
Request (method REPORT, Depth, auth, cuerpo del query) y devolver un XML
|
||||||
|
multistatus simulado con contenido inline. Cubren ambos perfiles (vcard / ical),
|
||||||
|
el des-escapado de entidades XML, los sinonimos de content_type, el content_type
|
||||||
|
invalido y el path de error HTTP.
|
||||||
|
"""
|
||||||
|
|
||||||
|
import base64
|
||||||
|
import sys
|
||||||
|
|
||||||
|
import infra.dav_get_collection # noqa: F401
|
||||||
|
|
||||||
|
mod = sys.modules["infra.dav_get_collection"]
|
||||||
|
dav_get_collection = mod.dav_get_collection
|
||||||
|
|
||||||
|
# Multistatus de un addressbook-query: 2 vCards inline. El segundo contiene una
|
||||||
|
# entidad XML (<) que debe des-escaparse a '<' en el campo data.
|
||||||
|
_VCARD_XML = (
|
||||||
|
'<?xml version="1.0"?>'
|
||||||
|
'<ns0:multistatus xmlns:ns0="DAV:" xmlns:ns1="urn:ietf:params:xml:ns:carddav">'
|
||||||
|
"<ns0:response><ns0:href>/enmanuel/contacts/addressbook/ada.vcf</ns0:href>"
|
||||||
|
"<ns0:propstat><ns0:status>HTTP/1.1 200 OK</ns0:status><ns0:prop>"
|
||||||
|
'<ns0:getetag>"etag-ada"</ns0:getetag>'
|
||||||
|
"<ns1:address-data>BEGIN:VCARD\nFN:Ada\nUID:ada\nEND:VCARD\n</ns1:address-data>"
|
||||||
|
"</ns0:prop></ns0:propstat></ns0:response>"
|
||||||
|
"<ns0:response><ns0:href>/enmanuel/contacts/addressbook/alan.vcf</ns0:href>"
|
||||||
|
"<ns0:propstat><ns0:status>HTTP/1.1 200 OK</ns0:status><ns0:prop>"
|
||||||
|
'<ns0:getetag>"etag-alan"</ns0:getetag>'
|
||||||
|
"<ns1:address-data>BEGIN:VCARD\nFN:Alan <turing>\nUID:alan\nEND:VCARD\n</ns1:address-data>"
|
||||||
|
"</ns0:prop></ns0:propstat></ns0:response>"
|
||||||
|
"</ns0:multistatus>"
|
||||||
|
)
|
||||||
|
|
||||||
|
# Multistatus de un calendar-query: 1 VEVENT inline.
|
||||||
|
_ICAL_XML = (
|
||||||
|
'<?xml version="1.0"?>'
|
||||||
|
'<ns0:multistatus xmlns:ns0="DAV:" xmlns:ns1="urn:ietf:params:xml:ns:caldav">'
|
||||||
|
"<ns0:response><ns0:href>/enmanuel/calendars/calendar/e1.ics</ns0:href>"
|
||||||
|
"<ns0:propstat><ns0:status>HTTP/1.1 200 OK</ns0:status><ns0:prop>"
|
||||||
|
'<ns0:getetag>"etag-e1"</ns0:getetag>'
|
||||||
|
"<ns1:calendar-data>BEGIN:VCALENDAR\nBEGIN:VEVENT\nSUMMARY:Cita\nUID:e1\nEND:VEVENT\nEND:VCALENDAR\n</ns1:calendar-data>"
|
||||||
|
"</ns0:prop></ns0:propstat></ns0:response>"
|
||||||
|
"</ns0:multistatus>"
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
class _FakeResp:
|
||||||
|
def __init__(self, payload: str):
|
||||||
|
self._payload = payload
|
||||||
|
self.status = 207
|
||||||
|
|
||||||
|
def __enter__(self):
|
||||||
|
return self
|
||||||
|
|
||||||
|
def __exit__(self, *a):
|
||||||
|
return False
|
||||||
|
|
||||||
|
def read(self):
|
||||||
|
return self._payload.encode("utf-8")
|
||||||
|
|
||||||
|
|
||||||
|
def _capture(monkeypatch, payload: str):
|
||||||
|
captured = {}
|
||||||
|
|
||||||
|
def fake_urlopen(req, timeout=None, context=None):
|
||||||
|
captured["url"] = req.full_url
|
||||||
|
captured["method"] = req.get_method()
|
||||||
|
captured["headers"] = {k.lower(): v for k, v in req.header_items()}
|
||||||
|
captured["body"] = req.data.decode("utf-8") if req.data else ""
|
||||||
|
return _FakeResp(payload)
|
||||||
|
|
||||||
|
monkeypatch.setattr(mod.urllib.request, "urlopen", fake_urlopen)
|
||||||
|
return captured
|
||||||
|
|
||||||
|
|
||||||
|
def test_vcard_construye_report_addressbook_query(monkeypatch):
|
||||||
|
cap = _capture(monkeypatch, _VCARD_XML)
|
||||||
|
dav_get_collection(
|
||||||
|
"https://dav.example.com",
|
||||||
|
"enmanuel",
|
||||||
|
"secret-pw",
|
||||||
|
"/enmanuel/contacts/addressbook/",
|
||||||
|
content_type="vcard",
|
||||||
|
)
|
||||||
|
assert cap["method"] == "REPORT"
|
||||||
|
assert cap["headers"]["depth"] == "1"
|
||||||
|
assert "addressbook-query" in cap["body"]
|
||||||
|
assert "address-data" in cap["body"]
|
||||||
|
assert "urn:ietf:params:xml:ns:carddav" in cap["body"]
|
||||||
|
|
||||||
|
|
||||||
|
def test_ical_construye_report_calendar_query_con_filtro(monkeypatch):
|
||||||
|
cap = _capture(monkeypatch, _ICAL_XML)
|
||||||
|
dav_get_collection(
|
||||||
|
"https://dav.example.com",
|
||||||
|
"enmanuel",
|
||||||
|
"secret-pw",
|
||||||
|
"/enmanuel/calendars/calendar/",
|
||||||
|
content_type="ical",
|
||||||
|
)
|
||||||
|
assert cap["method"] == "REPORT"
|
||||||
|
assert "calendar-query" in cap["body"]
|
||||||
|
assert 'comp-filter name="VCALENDAR"' in cap["body"]
|
||||||
|
assert "urn:ietf:params:xml:ns:caldav" in cap["body"]
|
||||||
|
|
||||||
|
|
||||||
|
def test_basic_auth_header_correcto(monkeypatch):
|
||||||
|
cap = _capture(monkeypatch, _VCARD_XML)
|
||||||
|
dav_get_collection(
|
||||||
|
"https://dav.example.com",
|
||||||
|
"enmanuel",
|
||||||
|
"secret-pw",
|
||||||
|
"/enmanuel/contacts/addressbook/",
|
||||||
|
)
|
||||||
|
expected = "Basic " + base64.b64encode(b"enmanuel:secret-pw").decode("ascii")
|
||||||
|
assert cap["headers"]["authorization"] == expected
|
||||||
|
|
||||||
|
|
||||||
|
def test_parsea_resources_con_data_inline(monkeypatch):
|
||||||
|
_capture(monkeypatch, _VCARD_XML)
|
||||||
|
res = dav_get_collection(
|
||||||
|
"https://dav.example.com",
|
||||||
|
"enmanuel",
|
||||||
|
"secret-pw",
|
||||||
|
"/enmanuel/contacts/addressbook/",
|
||||||
|
)
|
||||||
|
assert res["status"] == "ok"
|
||||||
|
assert len(res["resources"]) == 2
|
||||||
|
by_href = {r["href"]: r for r in res["resources"]}
|
||||||
|
ada = by_href["/enmanuel/contacts/addressbook/ada.vcf"]
|
||||||
|
assert ada["etag"] == '"etag-ada"'
|
||||||
|
assert "BEGIN:VCARD" in ada["data"]
|
||||||
|
assert "FN:Ada" in ada["data"]
|
||||||
|
|
||||||
|
|
||||||
|
def test_desescapa_entidades_xml_del_data(monkeypatch):
|
||||||
|
_capture(monkeypatch, _VCARD_XML)
|
||||||
|
res = dav_get_collection(
|
||||||
|
"https://dav.example.com",
|
||||||
|
"enmanuel",
|
||||||
|
"secret-pw",
|
||||||
|
"/enmanuel/contacts/addressbook/",
|
||||||
|
)
|
||||||
|
alan = next(r for r in res["resources"] if r["href"].endswith("alan.vcf"))
|
||||||
|
# <turing> debe quedar des-escapado a <turing>.
|
||||||
|
assert "FN:Alan <turing>" in alan["data"]
|
||||||
|
|
||||||
|
|
||||||
|
def test_ical_parsea_calendar_data(monkeypatch):
|
||||||
|
_capture(monkeypatch, _ICAL_XML)
|
||||||
|
res = dav_get_collection(
|
||||||
|
"https://dav.example.com",
|
||||||
|
"enmanuel",
|
||||||
|
"secret-pw",
|
||||||
|
"/enmanuel/calendars/calendar/",
|
||||||
|
content_type="ical",
|
||||||
|
)
|
||||||
|
assert res["status"] == "ok"
|
||||||
|
assert len(res["resources"]) == 1
|
||||||
|
assert "BEGIN:VEVENT" in res["resources"][0]["data"]
|
||||||
|
|
||||||
|
|
||||||
|
def test_acepta_sinonimos_de_content_type(monkeypatch):
|
||||||
|
_capture(monkeypatch, _VCARD_XML)
|
||||||
|
for alias in ("contacts", "carddav", "addressbook"):
|
||||||
|
res = dav_get_collection(
|
||||||
|
"https://dav.example.com",
|
||||||
|
"enmanuel",
|
||||||
|
"secret-pw",
|
||||||
|
"/enmanuel/contacts/addressbook/",
|
||||||
|
content_type=alias,
|
||||||
|
)
|
||||||
|
assert res["status"] == "ok"
|
||||||
|
|
||||||
|
|
||||||
|
def test_content_type_invalido_devuelve_error(monkeypatch):
|
||||||
|
_capture(monkeypatch, _VCARD_XML)
|
||||||
|
res = dav_get_collection(
|
||||||
|
"https://dav.example.com",
|
||||||
|
"enmanuel",
|
||||||
|
"secret-pw",
|
||||||
|
"/enmanuel/contacts/addressbook/",
|
||||||
|
content_type="json",
|
||||||
|
)
|
||||||
|
assert res["status"] == "error"
|
||||||
|
assert res["http_status"] is None
|
||||||
|
|
||||||
|
|
||||||
|
def test_httperror_devuelve_status_error(monkeypatch):
|
||||||
|
def fake_urlopen(req, timeout=None, context=None):
|
||||||
|
raise mod.urllib.error.HTTPError(req.full_url, 401, "Unauthorized", {}, None)
|
||||||
|
|
||||||
|
monkeypatch.setattr(mod.urllib.request, "urlopen", fake_urlopen)
|
||||||
|
res = dav_get_collection(
|
||||||
|
"https://dav.example.com",
|
||||||
|
"enmanuel",
|
||||||
|
"secret-pw",
|
||||||
|
"/enmanuel/contacts/addressbook/",
|
||||||
|
)
|
||||||
|
assert res["status"] == "error"
|
||||||
|
assert res["http_status"] == 401
|
||||||
@@ -0,0 +1,75 @@
|
|||||||
|
---
|
||||||
|
name: dav_get_resource
|
||||||
|
kind: function
|
||||||
|
lang: py
|
||||||
|
domain: infra
|
||||||
|
version: "1.0.0"
|
||||||
|
purity: impure
|
||||||
|
signature: "def dav_get_resource(base_url: str, username: str, password: str, resource_path: str, *, timeout_s: float = 20.0, verify_tls: bool = True) -> dict"
|
||||||
|
description: "Descarga (HTTP GET) el contenido de un recurso DAV individual (un VCARD o un VCALENDAR) con HTTP Basic auth. Construye el header Authorization: Basic base64(user:pass) a mano con stdlib. El resource_path puede ser un href absoluto (como los que devuelve dav_list_resources) o una URL completa. verify_tls=True por defecto. Maneja errores sin lanzar. Solo stdlib (urllib, base64, ssl). Probado contra Xandikos."
|
||||||
|
tags: [dav, carddav, caldav, get, download, http, infra]
|
||||||
|
uses_functions: []
|
||||||
|
uses_types: []
|
||||||
|
returns: []
|
||||||
|
returns_optional: false
|
||||||
|
error_type: "error_go_core"
|
||||||
|
imports: [base64, ssl, urllib.error, urllib.request]
|
||||||
|
params:
|
||||||
|
- name: base_url
|
||||||
|
desc: "URL base del servidor DAV. Se ignora si resource_path ya es una URL absoluta."
|
||||||
|
- name: username
|
||||||
|
desc: "usuario para HTTP Basic auth (p.ej. 'enmanuel')."
|
||||||
|
- name: password
|
||||||
|
desc: "contrasena para HTTP Basic auth. Resolver desde pass con pass_get_secret, nunca hardcodear."
|
||||||
|
- name: resource_path
|
||||||
|
desc: "href absoluto (p.ej. '/enmanuel/contacts/addressbook/x.vcf') o URL completa del recurso a descargar. Acepta directamente los hrefs que devuelve dav_list_resources."
|
||||||
|
- name: timeout_s
|
||||||
|
desc: "timeout de la peticion HTTP en segundos. Default 20.0."
|
||||||
|
- name: verify_tls
|
||||||
|
desc: "si True (default) verifica el certificado TLS. No desactivar salvo entorno de prueba."
|
||||||
|
output: "dict. En exito: {status:'ok', http_status:int, text:str, url:str} donde text es el cuerpo del recurso (VCARD o VCALENDAR). En error (sin lanzar): {status:'error', error:str, http_status:int|None}."
|
||||||
|
tested: true
|
||||||
|
tests:
|
||||||
|
- "test_construye_request_get_con_auth"
|
||||||
|
- "test_resource_path_relativo_se_resuelve_con_base_url"
|
||||||
|
- "test_resource_path_absoluto_se_respeta"
|
||||||
|
- "test_devuelve_texto_del_recurso"
|
||||||
|
- "test_httperror_devuelve_status_error"
|
||||||
|
test_file_path: "python/functions/infra/dav_get_resource_test.py"
|
||||||
|
file_path: "python/functions/infra/dav_get_resource.py"
|
||||||
|
---
|
||||||
|
|
||||||
|
## Ejemplo
|
||||||
|
|
||||||
|
```python
|
||||||
|
import sys
|
||||||
|
sys.path.insert(0, "python/functions")
|
||||||
|
from infra.pass_get_secret import pass_get_secret
|
||||||
|
from infra.dav_list_resources import dav_list_resources
|
||||||
|
from infra.dav_get_resource import dav_get_resource
|
||||||
|
|
||||||
|
base = "https://dav-eedeb681c4ab89ab8e444ac9.organic-machine.com"
|
||||||
|
pw = pass_get_secret("dav/xandikos-enmanuel")["value"] # NO logear
|
||||||
|
|
||||||
|
listing = dav_list_resources(base, "enmanuel", pw, "/enmanuel/contacts/addressbook/")
|
||||||
|
first = listing["resources"][0]["href"]
|
||||||
|
res = dav_get_resource(base, "enmanuel", pw, first)
|
||||||
|
print(res["text"][:13]) # BEGIN:VCARD
|
||||||
|
```
|
||||||
|
|
||||||
|
## Cuando usarla
|
||||||
|
|
||||||
|
Cuando quieres leer el contenido de un recurso concreto cuyo href ya conoces
|
||||||
|
(por `dav_list_resources` o porque lo construyes tu). Util para hacer backup de
|
||||||
|
una coleccion (listar + get cada uno), validar que un import quedo bien escrito,
|
||||||
|
o comparar etags en un sync. Acepta directamente los hrefs del listing sin
|
||||||
|
reconstruir la URL.
|
||||||
|
|
||||||
|
## Gotchas
|
||||||
|
|
||||||
|
- Lectura remota real sobre TLS; password de `pass`, no se logea.
|
||||||
|
- Decodifica el cuerpo como UTF-8 con `errors='replace'`: bytes invalidos se
|
||||||
|
sustituyen por el caracter de reemplazo en vez de fallar.
|
||||||
|
- Si resource_path es relativo se concatena a base_url; si es absoluto
|
||||||
|
(http/https) se usa tal cual y base_url se ignora.
|
||||||
|
- `verify_tls=False` solo en pruebas; abre MITM.
|
||||||
@@ -0,0 +1,67 @@
|
|||||||
|
"""Descarga (GET) un recurso DAV individual via HTTP Basic auth.
|
||||||
|
|
||||||
|
Funcion impura: hace una peticion HTTP GET. Construye el header
|
||||||
|
`Authorization: Basic base64(user:pass)` a mano con stdlib. Devuelve el cuerpo
|
||||||
|
del recurso como texto (un VCARD o un VCALENDAR). El resource_path puede ser un
|
||||||
|
href absoluto (como los que devuelve dav_list_resources) o una ruta relativa al
|
||||||
|
base_url. Maneja errores sin lanzar. Solo usa stdlib (urllib, base64, ssl).
|
||||||
|
"""
|
||||||
|
|
||||||
|
import base64
|
||||||
|
import ssl
|
||||||
|
import urllib.error
|
||||||
|
import urllib.request
|
||||||
|
|
||||||
|
|
||||||
|
def _basic_auth_header(username: str, password: str) -> str:
|
||||||
|
raw = ("%s:%s" % (username, password)).encode("utf-8")
|
||||||
|
return "Basic " + base64.b64encode(raw).decode("ascii")
|
||||||
|
|
||||||
|
|
||||||
|
def _resolve_url(base_url: str, resource_path: str) -> str:
|
||||||
|
if resource_path.startswith("http://") or resource_path.startswith("https://"):
|
||||||
|
return resource_path
|
||||||
|
return base_url.rstrip("/") + "/" + resource_path.lstrip("/")
|
||||||
|
|
||||||
|
|
||||||
|
def dav_get_resource(
|
||||||
|
base_url: str,
|
||||||
|
username: str,
|
||||||
|
password: str,
|
||||||
|
resource_path: str,
|
||||||
|
*,
|
||||||
|
timeout_s: float = 20.0,
|
||||||
|
verify_tls: bool = True,
|
||||||
|
) -> dict:
|
||||||
|
"""Descarga el contenido de un recurso DAV (GET).
|
||||||
|
|
||||||
|
Args:
|
||||||
|
base_url: URL base del servidor DAV. Se ignora si resource_path ya es
|
||||||
|
una URL absoluta.
|
||||||
|
username: usuario para HTTP Basic auth.
|
||||||
|
password: contrasena para HTTP Basic auth.
|
||||||
|
resource_path: href absoluto (p.ej. '/enmanuel/contacts/addressbook/x.vcf')
|
||||||
|
o URL completa del recurso a descargar.
|
||||||
|
timeout_s: timeout de la peticion en segundos. Default 20.0.
|
||||||
|
verify_tls: si True (default) verifica el certificado TLS.
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
dict. En exito: {status:'ok', http_status:int, text:str, url:str} donde
|
||||||
|
text es el cuerpo del recurso (VCARD o VCALENDAR). En error (sin lanzar):
|
||||||
|
{status:'error', error:str, http_status:int|None}.
|
||||||
|
"""
|
||||||
|
url = _resolve_url(base_url, resource_path)
|
||||||
|
headers = {"Authorization": _basic_auth_header(username, password)}
|
||||||
|
req = urllib.request.Request(url, method="GET", headers=headers)
|
||||||
|
|
||||||
|
context = None if verify_tls else ssl._create_unverified_context()
|
||||||
|
try:
|
||||||
|
with urllib.request.urlopen(req, timeout=timeout_s, context=context) as resp:
|
||||||
|
text = resp.read().decode("utf-8", "replace")
|
||||||
|
return {"status": "ok", "http_status": resp.status, "text": text, "url": url}
|
||||||
|
except urllib.error.HTTPError as e:
|
||||||
|
return {"status": "error", "error": "http %s" % e.code, "http_status": e.code}
|
||||||
|
except urllib.error.URLError as e:
|
||||||
|
return {"status": "error", "error": str(e.reason), "http_status": None}
|
||||||
|
except Exception as e: # noqa: BLE001
|
||||||
|
return {"status": "error", "error": str(e), "http_status": None}
|
||||||
@@ -0,0 +1,88 @@
|
|||||||
|
"""Tests para dav_get_resource.
|
||||||
|
|
||||||
|
Smoke deterministas: monkeypatchean urllib.request.urlopen para capturar el
|
||||||
|
Request (method GET, auth, URL) y devolver un cuerpo simulado.
|
||||||
|
"""
|
||||||
|
|
||||||
|
import base64
|
||||||
|
import sys
|
||||||
|
|
||||||
|
import infra.dav_get_resource # noqa: F401
|
||||||
|
|
||||||
|
mod = sys.modules["infra.dav_get_resource"]
|
||||||
|
dav_get_resource = mod.dav_get_resource
|
||||||
|
|
||||||
|
_BODY = "BEGIN:VCARD\r\nVERSION:3.0\r\nFN:Ada Lovelace\r\nEND:VCARD\r\n"
|
||||||
|
|
||||||
|
|
||||||
|
class _FakeResp:
|
||||||
|
status = 200
|
||||||
|
|
||||||
|
def __enter__(self):
|
||||||
|
return self
|
||||||
|
|
||||||
|
def __exit__(self, *a):
|
||||||
|
return False
|
||||||
|
|
||||||
|
def read(self):
|
||||||
|
return _BODY.encode("utf-8")
|
||||||
|
|
||||||
|
|
||||||
|
def _capture(monkeypatch):
|
||||||
|
captured = {}
|
||||||
|
|
||||||
|
def fake_urlopen(req, timeout=None, context=None):
|
||||||
|
captured["url"] = req.full_url
|
||||||
|
captured["method"] = req.get_method()
|
||||||
|
captured["headers"] = {k.lower(): v for k, v in req.header_items()}
|
||||||
|
return _FakeResp()
|
||||||
|
|
||||||
|
monkeypatch.setattr(mod.urllib.request, "urlopen", fake_urlopen)
|
||||||
|
return captured
|
||||||
|
|
||||||
|
|
||||||
|
def test_construye_request_get_con_auth(monkeypatch):
|
||||||
|
cap = _capture(monkeypatch)
|
||||||
|
res = dav_get_resource(
|
||||||
|
"https://dav.example.com", "enmanuel", "secret-pw",
|
||||||
|
"/enmanuel/contacts/addressbook/ada.vcf",
|
||||||
|
)
|
||||||
|
assert res["status"] == "ok"
|
||||||
|
assert cap["method"] == "GET"
|
||||||
|
expected = "Basic " + base64.b64encode(b"enmanuel:secret-pw").decode("ascii")
|
||||||
|
assert cap["headers"]["authorization"] == expected
|
||||||
|
|
||||||
|
|
||||||
|
def test_resource_path_relativo_se_resuelve_con_base_url(monkeypatch):
|
||||||
|
cap = _capture(monkeypatch)
|
||||||
|
dav_get_resource(
|
||||||
|
"https://dav.example.com", "u", "p",
|
||||||
|
"/enmanuel/contacts/addressbook/ada.vcf",
|
||||||
|
)
|
||||||
|
assert cap["url"] == "https://dav.example.com/enmanuel/contacts/addressbook/ada.vcf"
|
||||||
|
|
||||||
|
|
||||||
|
def test_resource_path_absoluto_se_respeta(monkeypatch):
|
||||||
|
cap = _capture(monkeypatch)
|
||||||
|
abs_url = "https://otra.example.com/path/x.vcf"
|
||||||
|
dav_get_resource("https://dav.example.com", "u", "p", abs_url)
|
||||||
|
assert cap["url"] == abs_url
|
||||||
|
|
||||||
|
|
||||||
|
def test_devuelve_texto_del_recurso(monkeypatch):
|
||||||
|
_capture(monkeypatch)
|
||||||
|
res = dav_get_resource(
|
||||||
|
"https://dav.example.com", "u", "p", "/x.vcf",
|
||||||
|
)
|
||||||
|
assert res["text"] == _BODY
|
||||||
|
assert res["http_status"] == 200
|
||||||
|
|
||||||
|
|
||||||
|
def test_httperror_devuelve_status_error(monkeypatch):
|
||||||
|
def fake_urlopen(req, timeout=None, context=None):
|
||||||
|
raise mod.urllib.error.HTTPError(req.full_url, 404, "Not Found", {}, None)
|
||||||
|
|
||||||
|
monkeypatch.setattr(mod.urllib.request, "urlopen", fake_urlopen)
|
||||||
|
res = dav_get_resource("https://dav.example.com", "u", "p", "/x.vcf")
|
||||||
|
assert res["status"] == "error"
|
||||||
|
assert res["http_status"] == 404
|
||||||
@@ -0,0 +1,91 @@
|
|||||||
|
---
|
||||||
|
name: dav_list_addressbooks
|
||||||
|
kind: function
|
||||||
|
lang: py
|
||||||
|
domain: infra
|
||||||
|
version: "1.0.0"
|
||||||
|
purity: impure
|
||||||
|
signature: "def dav_list_addressbooks(base_url: str, username: str, password: str, contacts_home: str, *, timeout_s: float = 20.0, verify_tls: bool = True) -> dict"
|
||||||
|
description: "Lista las colecciones de libreta de contactos CardDAV bajo un contacts-home en UNA peticion PROPFIND Depth:1. Devuelve solo las colecciones hijas que son libretas CardDAV de verdad (resourcetype {urn:ietf:params:xml:ns:carddav}addressbook), cada una con su href, su displayname (DAV) y su descripcion (addressbook-description de CardDAV) si el servidor la expone. El propio contacts-home (coleccion plana sin el resourcetype addressbook) y cualquier coleccion no-addressbook se excluyen. Considera solo los propstat con estado 2xx para no leer props marcadas 404. Construye Authorization: Basic base64(user:pass) a mano y parsea el multistatus con regex (parser puro _parse_multistatus aislado para testeo sin red). verify_tls=True por defecto. Maneja errores sin lanzar. Solo stdlib (urllib, base64, re, ssl, html). Es la analoga CardDAV de dav_list_calendars. Probada contra Xandikos."
|
||||||
|
tags: [dav, carddav, addressbook, addressbooks, contacts, propfind, displayname, description, ingest, http, infra]
|
||||||
|
uses_functions: []
|
||||||
|
uses_types: []
|
||||||
|
returns: []
|
||||||
|
returns_optional: false
|
||||||
|
error_type: "error_go_core"
|
||||||
|
imports: [base64, html, re, ssl, urllib.error, urllib.request]
|
||||||
|
params:
|
||||||
|
- name: base_url
|
||||||
|
desc: "URL base del servidor DAV (p.ej. 'https://dav-eedeb681c4ab89ab8e444ac9.organic-machine.com')."
|
||||||
|
- name: username
|
||||||
|
desc: "usuario para HTTP Basic auth (p.ej. 'enmanuel')."
|
||||||
|
- name: password
|
||||||
|
desc: "contrasena para HTTP Basic auth. Resolver desde pass con pass_get_secret, nunca hardcodear."
|
||||||
|
- name: contacts_home
|
||||||
|
desc: "ruta del contacts-home del usuario (p.ej. '/enmanuel/contacts/'). Las libretas de contactos cuelgan de el."
|
||||||
|
- name: timeout_s
|
||||||
|
desc: "timeout de la peticion HTTP en segundos. Default 20.0."
|
||||||
|
- name: verify_tls
|
||||||
|
desc: "si True (default) verifica el certificado TLS. No desactivar salvo entorno de prueba."
|
||||||
|
output: "dict. En exito: {status:'ok', http_status:int, addressbooks:[{href:str, name:str, description:str|None}, ...]} con un elemento por libreta de contactos, ordenadas por nombre. description es el addressbook-description de CardDAV o None. En error (sin lanzar): {status:'error', error:str, http_status:int|None}."
|
||||||
|
tested: true
|
||||||
|
tests:
|
||||||
|
- "test_devuelve_dos_libretas_descarta_home"
|
||||||
|
- "test_ordenadas_por_nombre"
|
||||||
|
- "test_extrae_nombre_y_descripcion"
|
||||||
|
- "test_descripcion_ausente_es_none"
|
||||||
|
- "test_ignora_props_404"
|
||||||
|
- "test_multistatus_vacio_devuelve_lista_vacia"
|
||||||
|
- "test_home_sin_libretas_solo_home"
|
||||||
|
test_file_path: "python/functions/infra/dav_list_addressbooks_test.py"
|
||||||
|
file_path: "python/functions/infra/dav_list_addressbooks.py"
|
||||||
|
---
|
||||||
|
|
||||||
|
## Ejemplo
|
||||||
|
|
||||||
|
```python
|
||||||
|
import sys
|
||||||
|
sys.path.insert(0, "python/functions")
|
||||||
|
from infra.pass_get_secret import pass_get_secret
|
||||||
|
from infra.dav_list_addressbooks import dav_list_addressbooks
|
||||||
|
|
||||||
|
pw = pass_get_secret("dav/xandikos-enmanuel")["value"] # NO logear
|
||||||
|
|
||||||
|
res = dav_list_addressbooks(
|
||||||
|
base_url="https://dav-eedeb681c4ab89ab8e444ac9.organic-machine.com",
|
||||||
|
username="enmanuel",
|
||||||
|
password=pw,
|
||||||
|
contacts_home="/enmanuel/contacts/",
|
||||||
|
)
|
||||||
|
for a in res["addressbooks"]:
|
||||||
|
print(a["name"], a["href"], a["description"])
|
||||||
|
# Personal /enmanuel/contacts/personal/ Contactos personales & familia
|
||||||
|
# contacts /enmanuel/contacts/contacts/ None
|
||||||
|
```
|
||||||
|
|
||||||
|
## Cuando usarla
|
||||||
|
|
||||||
|
Cuando un ingest necesita recorrer **todas** las libretas de contactos del usuario,
|
||||||
|
no solo la principal: el contacts-home CardDAV puede tener varias colecciones
|
||||||
|
(personal, trabajo, familia, ...) y necesitas el href de cada una para luego
|
||||||
|
volcar sus vCards con `dav_list_resources` / `dav_get_collection`. Tambien sirve
|
||||||
|
para un selector de libreta en una UI (nombre + descripcion). Devuelve los hrefs
|
||||||
|
sin que el caller los conozca de antemano ni tenga que distinguir el contacts-home
|
||||||
|
de las libretas reales. Es la analoga CardDAV de `dav_list_calendars`.
|
||||||
|
|
||||||
|
## Gotchas
|
||||||
|
|
||||||
|
- **Impura: red + auth.** Lectura remota real sobre TLS; el password viene de
|
||||||
|
`pass` (`dav/xandikos-enmanuel`) y no se logea. `verify_tls=True` por defecto;
|
||||||
|
no desactivar salvo entorno de prueba.
|
||||||
|
- **Depende de que el servidor exponga `resourcetype` en el PROPFIND.** Filtra por
|
||||||
|
el resourcetype `carddav:addressbook`: el propio contacts-home es una coleccion
|
||||||
|
plana (sin ese resourcetype) y queda fuera, igual que carpetas intermedias. Si
|
||||||
|
un servidor no devuelve `resourcetype`, ninguna coleccion pasa el filtro y la
|
||||||
|
lista sale vacia. Si tu servidor anida libretas mas profundo que Depth:1, llama
|
||||||
|
con `contacts_home` apuntando al nivel correcto.
|
||||||
|
- La `description` es el `addressbook-description` de CardDAV
|
||||||
|
(`urn:ietf:params:xml:ns:carddav`), que Xandikos puede no tener seteado
|
||||||
|
(devuelve None).
|
||||||
|
- Solo lee los `<propstat>` con estado 2xx para no confundir un
|
||||||
|
`<addressbook-description/>` vacio de un propstat 404 con una descripcion real.
|
||||||
@@ -0,0 +1,202 @@
|
|||||||
|
"""Lista las colecciones de libreta de contactos CardDAV bajo un contacts-home (PROPFIND).
|
||||||
|
|
||||||
|
Funcion impura: hace UNA peticion HTTP PROPFIND Depth:1 sobre el directorio
|
||||||
|
contacts-home de un usuario (p.ej. `/enmanuel/contacts/`) y devuelve solo las
|
||||||
|
colecciones hijas que son libretas CardDAV de verdad — las que declaran el
|
||||||
|
resourcetype `{urn:ietf:params:xml:ns:carddav}addressbook`. Por cada una extrae
|
||||||
|
su href, su `displayname` (DAV) y, si el servidor lo expone, su descripcion
|
||||||
|
(`addressbook-description` de CardDAV). El propio contacts-home (que es una
|
||||||
|
coleccion plana sin el resourcetype addressbook) se excluye.
|
||||||
|
|
||||||
|
Es la analoga CardDAV de `dav_list_calendars`: lo que necesita un ingest o un
|
||||||
|
selector de libreta cuando el usuario tiene varias colecciones de contactos bajo
|
||||||
|
su contacts-home y hay que recorrerlas todas (o elegir una), con su nombre y su
|
||||||
|
descripcion. `dav_list_resources` solo devuelve hrefs+etag de los recursos de UNA
|
||||||
|
coleccion (las vCards), no las colecciones hijas con su metadata.
|
||||||
|
|
||||||
|
Construye el header `Authorization: Basic base64(user:pass)` a mano con stdlib y
|
||||||
|
parsea el multistatus con regex simple (sin parser XML externo), considerando
|
||||||
|
solo los `<propstat>` con estado 2xx para no recoger props que el servidor marca
|
||||||
|
404. Maneja errores sin lanzar. Solo usa stdlib (urllib, base64, re, ssl, html).
|
||||||
|
Probado contra Xandikos.
|
||||||
|
"""
|
||||||
|
|
||||||
|
import base64
|
||||||
|
import html
|
||||||
|
import re
|
||||||
|
import ssl
|
||||||
|
import urllib.error
|
||||||
|
import urllib.request
|
||||||
|
|
||||||
|
_RESPONSE_RE = re.compile(
|
||||||
|
r"<(?:[A-Za-z0-9]+:)?response>(.*?)</(?:[A-Za-z0-9]+:)?response>",
|
||||||
|
re.DOTALL | re.IGNORECASE,
|
||||||
|
)
|
||||||
|
_HREF_RE = re.compile(
|
||||||
|
r"<(?:[A-Za-z0-9]+:)?href>\s*(.*?)\s*</(?:[A-Za-z0-9]+:)?href>",
|
||||||
|
re.DOTALL | re.IGNORECASE,
|
||||||
|
)
|
||||||
|
_PROPSTAT_RE = re.compile(
|
||||||
|
r"<(?:[A-Za-z0-9]+:)?propstat>(.*?)</(?:[A-Za-z0-9]+:)?propstat>",
|
||||||
|
re.DOTALL | re.IGNORECASE,
|
||||||
|
)
|
||||||
|
_STATUS_RE = re.compile(
|
||||||
|
r"<(?:[A-Za-z0-9]+:)?status>\s*(.*?)\s*</(?:[A-Za-z0-9]+:)?status>",
|
||||||
|
re.DOTALL | re.IGNORECASE,
|
||||||
|
)
|
||||||
|
_DISPLAYNAME_RE = re.compile(
|
||||||
|
r"<(?:[A-Za-z0-9]+:)?displayname>\s*(.*?)\s*</(?:[A-Za-z0-9]+:)?displayname>",
|
||||||
|
re.DOTALL | re.IGNORECASE,
|
||||||
|
)
|
||||||
|
# Descripcion de CardDAV: <ns:addressbook-description>texto</ns:addressbook-description>.
|
||||||
|
_DESCRIPTION_RE = re.compile(
|
||||||
|
r"<(?:[A-Za-z0-9]+:)?addressbook-description[^>]*>\s*(.*?)\s*</(?:[A-Za-z0-9]+:)?addressbook-description>",
|
||||||
|
re.DOTALL | re.IGNORECASE,
|
||||||
|
)
|
||||||
|
# Marca de libreta CardDAV en el resourcetype. El elemento `<C:addressbook/>`
|
||||||
|
# puede venir con o sin prefijo de namespace (`<ns2:addressbook/>`, `<addressbook/>`).
|
||||||
|
_ADDRESSBOOK_TYPE_RE = re.compile(
|
||||||
|
r"<(?:[A-Za-z0-9]+:)?addressbook(?:\s[^>]*)?/?>", re.IGNORECASE
|
||||||
|
)
|
||||||
|
|
||||||
|
# El PROPFIND pide nombre, tipo y descripcion. Declarar el namespace de CardDAV
|
||||||
|
# permite que el servidor responda `addressbook-description` cuando exista.
|
||||||
|
_PROPFIND_BODY = (
|
||||||
|
'<?xml version="1.0" encoding="utf-8" ?>'
|
||||||
|
'<D:propfind xmlns:D="DAV:" '
|
||||||
|
'xmlns:C="urn:ietf:params:xml:ns:carddav">'
|
||||||
|
"<D:prop>"
|
||||||
|
"<D:displayname/><D:resourcetype/><C:addressbook-description/>"
|
||||||
|
"</D:prop></D:propfind>"
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def _basic_auth_header(username: str, password: str) -> str:
|
||||||
|
raw = ("%s:%s" % (username, password)).encode("utf-8")
|
||||||
|
return "Basic " + base64.b64encode(raw).decode("ascii")
|
||||||
|
|
||||||
|
|
||||||
|
def _join_url(base_url: str, collection_path: str) -> str:
|
||||||
|
return base_url.rstrip("/") + "/" + collection_path.strip("/") + "/"
|
||||||
|
|
||||||
|
|
||||||
|
def _ok_propstats(response_block: str) -> str:
|
||||||
|
"""Concatena solo los `<propstat>` con estado 2xx de un `<response>`.
|
||||||
|
|
||||||
|
El servidor agrupa las props por estado: las presentes en un propstat 200 y
|
||||||
|
las ausentes en un propstat 404. Tomar solo los 2xx evita leer un
|
||||||
|
`<addressbook-description/>` vacio del bloque 404 como si fuera el valor real.
|
||||||
|
"""
|
||||||
|
parts = []
|
||||||
|
for ps in _PROPSTAT_RE.findall(response_block):
|
||||||
|
status_m = _STATUS_RE.search(ps)
|
||||||
|
if status_m and " 2" in (" " + status_m.group(1)):
|
||||||
|
parts.append(ps)
|
||||||
|
# Si no hay propstat (servidor minimalista), usar el bloque entero.
|
||||||
|
return "".join(parts) if parts else response_block
|
||||||
|
|
||||||
|
|
||||||
|
def _parse_multistatus(xml_text: str, contacts_home: str) -> list:
|
||||||
|
"""Parsea un multistatus 207 y devuelve las libretas CardDAV hijas.
|
||||||
|
|
||||||
|
Helper puro (sin red): recorre los `<response>` del XML, descarta el propio
|
||||||
|
contacts-home y las colecciones que no declaran el resourcetype
|
||||||
|
`carddav:addressbook`, y por cada libreta extrae href, displayname y
|
||||||
|
addressbook-description (de los propstat 2xx). Devuelve la lista ordenada por
|
||||||
|
nombre. Aislado para poder testear el parseo sin tocar la red.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
xml_text: cuerpo XML del multistatus (respuesta del PROPFIND Depth:1).
|
||||||
|
contacts_home: ruta del contacts-home (p.ej. '/enmanuel/contacts/'),
|
||||||
|
usada para excluir la entrada del propio home.
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
list de dicts {href:str, name:str, description:str|None}, ordenada por
|
||||||
|
name (case-insensitive).
|
||||||
|
"""
|
||||||
|
home_tail = contacts_home.strip("/").rsplit("/", 1)[-1]
|
||||||
|
addressbooks = []
|
||||||
|
for block in _RESPONSE_RE.findall(xml_text):
|
||||||
|
href_m = _HREF_RE.search(block)
|
||||||
|
if not href_m:
|
||||||
|
continue
|
||||||
|
href = href_m.group(1).strip()
|
||||||
|
tail = href.rstrip("/").rsplit("/", 1)[-1]
|
||||||
|
# El propio contacts-home (o un href identico al home): se excluye.
|
||||||
|
if tail == home_tail:
|
||||||
|
continue
|
||||||
|
# Solo las colecciones marcadas como libreta CardDAV. El home plano no
|
||||||
|
# lleva el resourcetype `carddav:addressbook` y queda fuera.
|
||||||
|
if not _ADDRESSBOOK_TYPE_RE.search(block):
|
||||||
|
continue
|
||||||
|
ok = _ok_propstats(block)
|
||||||
|
name_m = _DISPLAYNAME_RE.search(ok)
|
||||||
|
name = html.unescape(name_m.group(1).strip()) if name_m else tail
|
||||||
|
if not name:
|
||||||
|
name = tail
|
||||||
|
desc_m = _DESCRIPTION_RE.search(ok)
|
||||||
|
description = html.unescape(desc_m.group(1).strip()) if desc_m else None
|
||||||
|
if description == "":
|
||||||
|
description = None
|
||||||
|
addressbooks.append({"href": href, "name": name, "description": description})
|
||||||
|
addressbooks.sort(key=lambda a: a["name"].lower())
|
||||||
|
return addressbooks
|
||||||
|
|
||||||
|
|
||||||
|
def dav_list_addressbooks(
|
||||||
|
base_url: str,
|
||||||
|
username: str,
|
||||||
|
password: str,
|
||||||
|
contacts_home: str,
|
||||||
|
*,
|
||||||
|
timeout_s: float = 20.0,
|
||||||
|
verify_tls: bool = True,
|
||||||
|
) -> dict:
|
||||||
|
"""Lista las colecciones de libreta de contactos CardDAV bajo un contacts-home.
|
||||||
|
|
||||||
|
Hace un PROPFIND Depth:1 sobre `contacts_home` y devuelve solo las colecciones
|
||||||
|
hijas marcadas como libreta CardDAV (resourcetype `carddav:addressbook`), con
|
||||||
|
su nombre y descripcion. El propio `contacts_home` y cualquier coleccion
|
||||||
|
no-addressbook se excluyen.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
base_url: URL base del servidor DAV (p.ej. 'https://dav-x.example.com').
|
||||||
|
username: usuario para HTTP Basic auth.
|
||||||
|
password: contrasena para HTTP Basic auth. Resolver desde pass.
|
||||||
|
contacts_home: ruta del contacts-home del usuario (p.ej.
|
||||||
|
'/enmanuel/contacts/'). Las libretas de contactos cuelgan de el.
|
||||||
|
timeout_s: timeout de la peticion en segundos. Default 20.0.
|
||||||
|
verify_tls: si True (default) verifica el certificado TLS.
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
dict. En exito: {status:'ok', http_status:int,
|
||||||
|
addressbooks:[{href:str, name:str, description:str|None}, ...]} con un
|
||||||
|
elemento por libreta de contactos (ordenadas por nombre). `description`
|
||||||
|
es el `addressbook-description` de CardDAV si el servidor lo expone, o
|
||||||
|
None. En error (sin lanzar): {status:'error', error:str,
|
||||||
|
http_status:int|None}.
|
||||||
|
"""
|
||||||
|
url = _join_url(base_url, contacts_home)
|
||||||
|
headers = {
|
||||||
|
"Authorization": _basic_auth_header(username, password),
|
||||||
|
"Content-Type": "application/xml; charset=utf-8",
|
||||||
|
"Depth": "1",
|
||||||
|
}
|
||||||
|
req = urllib.request.Request(
|
||||||
|
url, data=_PROPFIND_BODY.encode("utf-8"), method="PROPFIND", headers=headers
|
||||||
|
)
|
||||||
|
|
||||||
|
context = None if verify_tls else ssl._create_unverified_context()
|
||||||
|
try:
|
||||||
|
with urllib.request.urlopen(req, timeout=timeout_s, context=context) as resp:
|
||||||
|
status = resp.status
|
||||||
|
xml = resp.read().decode("utf-8", "replace")
|
||||||
|
except urllib.error.HTTPError as e:
|
||||||
|
return {"status": "error", "error": "http %s" % e.code, "http_status": e.code}
|
||||||
|
except urllib.error.URLError as e:
|
||||||
|
return {"status": "error", "error": str(e.reason), "http_status": None}
|
||||||
|
except Exception as e: # noqa: BLE001
|
||||||
|
return {"status": "error", "error": str(e), "http_status": None}
|
||||||
|
|
||||||
|
addressbooks = _parse_multistatus(xml, contacts_home)
|
||||||
|
return {"status": "ok", "http_status": status, "addressbooks": addressbooks}
|
||||||
@@ -0,0 +1,112 @@
|
|||||||
|
"""Tests para dav_list_addressbooks.
|
||||||
|
|
||||||
|
Sin red: ejercitan el parser puro `_parse_multistatus` con un multistatus 207
|
||||||
|
realista (2 libretas CardDAV + el contacts-home). Verifican que descarta el home,
|
||||||
|
extrae nombre y descripcion correctos, normaliza descripcion ausente a None e
|
||||||
|
ignora props marcadas 404 por el servidor.
|
||||||
|
"""
|
||||||
|
|
||||||
|
from infra.dav_list_addressbooks import _parse_multistatus
|
||||||
|
|
||||||
|
# Multistatus 207 realista al estilo Xandikos: el contacts-home plano + dos
|
||||||
|
# libretas CardDAV. La segunda no expone addressbook-description en su propstat
|
||||||
|
# 2xx (Xandikos la devuelve vacia en un propstat 404).
|
||||||
|
_MULTISTATUS = """<?xml version="1.0" encoding="utf-8"?>
|
||||||
|
<D:multistatus xmlns:D="DAV:" xmlns:C="urn:ietf:params:xml:ns:carddav">
|
||||||
|
<D:response>
|
||||||
|
<D:href>/enmanuel/contacts/</D:href>
|
||||||
|
<D:propstat>
|
||||||
|
<D:prop>
|
||||||
|
<D:resourcetype><D:collection/></D:resourcetype>
|
||||||
|
<D:displayname>contacts</D:displayname>
|
||||||
|
</D:prop>
|
||||||
|
<D:status>HTTP/1.1 200 OK</D:status>
|
||||||
|
</D:propstat>
|
||||||
|
<D:propstat>
|
||||||
|
<D:prop><C:addressbook-description/></D:prop>
|
||||||
|
<D:status>HTTP/1.1 404 Not Found</D:status>
|
||||||
|
</D:propstat>
|
||||||
|
</D:response>
|
||||||
|
<D:response>
|
||||||
|
<D:href>/enmanuel/contacts/personal/</D:href>
|
||||||
|
<D:propstat>
|
||||||
|
<D:prop>
|
||||||
|
<D:resourcetype><D:collection/><C:addressbook/></D:resourcetype>
|
||||||
|
<D:displayname>Personal</D:displayname>
|
||||||
|
<C:addressbook-description>Contactos personales & familia</C:addressbook-description>
|
||||||
|
</D:prop>
|
||||||
|
<D:status>HTTP/1.1 200 OK</D:status>
|
||||||
|
</D:propstat>
|
||||||
|
</D:response>
|
||||||
|
<D:response>
|
||||||
|
<D:href>/enmanuel/contacts/work/</D:href>
|
||||||
|
<D:propstat>
|
||||||
|
<D:prop>
|
||||||
|
<D:resourcetype><D:collection/><C:addressbook/></D:resourcetype>
|
||||||
|
<D:displayname>Trabajo</D:displayname>
|
||||||
|
</D:prop>
|
||||||
|
<D:status>HTTP/1.1 200 OK</D:status>
|
||||||
|
</D:propstat>
|
||||||
|
<D:propstat>
|
||||||
|
<D:prop><C:addressbook-description/></D:prop>
|
||||||
|
<D:status>HTTP/1.1 404 Not Found</D:status>
|
||||||
|
</D:propstat>
|
||||||
|
</D:response>
|
||||||
|
</D:multistatus>
|
||||||
|
"""
|
||||||
|
|
||||||
|
|
||||||
|
def test_devuelve_dos_libretas_descarta_home():
|
||||||
|
libros = _parse_multistatus(_MULTISTATUS, "/enmanuel/contacts/")
|
||||||
|
assert len(libros) == 2
|
||||||
|
hrefs = [a["href"] for a in libros]
|
||||||
|
assert "/enmanuel/contacts/" not in hrefs
|
||||||
|
|
||||||
|
|
||||||
|
def test_ordenadas_por_nombre():
|
||||||
|
libros = _parse_multistatus(_MULTISTATUS, "/enmanuel/contacts/")
|
||||||
|
# Orden case-insensitive: "Personal" < "Trabajo".
|
||||||
|
assert [a["name"] for a in libros] == ["Personal", "Trabajo"]
|
||||||
|
|
||||||
|
|
||||||
|
def test_extrae_nombre_y_descripcion():
|
||||||
|
libros = _parse_multistatus(_MULTISTATUS, "/enmanuel/contacts/")
|
||||||
|
personal = next(a for a in libros if a["href"] == "/enmanuel/contacts/personal/")
|
||||||
|
assert personal["name"] == "Personal"
|
||||||
|
# html.unescape convierte & en &.
|
||||||
|
assert personal["description"] == "Contactos personales & familia"
|
||||||
|
|
||||||
|
|
||||||
|
def test_descripcion_ausente_es_none():
|
||||||
|
libros = _parse_multistatus(_MULTISTATUS, "/enmanuel/contacts/")
|
||||||
|
trabajo = next(a for a in libros if a["href"] == "/enmanuel/contacts/work/")
|
||||||
|
assert trabajo["name"] == "Trabajo"
|
||||||
|
assert trabajo["description"] is None
|
||||||
|
|
||||||
|
|
||||||
|
def test_ignora_props_404():
|
||||||
|
# El home tiene un addressbook-description en un propstat 404; aunque no se
|
||||||
|
# descartara por resourcetype, no debe colarse como libreta ni su prop vacia
|
||||||
|
# mezclarse con otra entrada.
|
||||||
|
libros = _parse_multistatus(_MULTISTATUS, "/enmanuel/contacts/")
|
||||||
|
for a in libros:
|
||||||
|
# Ninguna descripcion debe ser cadena vacia (404 -> None, no "").
|
||||||
|
assert a["description"] != ""
|
||||||
|
|
||||||
|
|
||||||
|
def test_multistatus_vacio_devuelve_lista_vacia():
|
||||||
|
xml = '<D:multistatus xmlns:D="DAV:"></D:multistatus>'
|
||||||
|
assert _parse_multistatus(xml, "/enmanuel/contacts/") == []
|
||||||
|
|
||||||
|
|
||||||
|
def test_home_sin_libretas_solo_home():
|
||||||
|
xml = """<D:multistatus xmlns:D="DAV:">
|
||||||
|
<D:response>
|
||||||
|
<D:href>/enmanuel/contacts/</D:href>
|
||||||
|
<D:propstat>
|
||||||
|
<D:prop><D:resourcetype><D:collection/></D:resourcetype></D:prop>
|
||||||
|
<D:status>HTTP/1.1 200 OK</D:status>
|
||||||
|
</D:propstat>
|
||||||
|
</D:response>
|
||||||
|
</D:multistatus>"""
|
||||||
|
assert _parse_multistatus(xml, "/enmanuel/contacts/") == []
|
||||||
@@ -0,0 +1,85 @@
|
|||||||
|
---
|
||||||
|
name: dav_list_calendars
|
||||||
|
kind: function
|
||||||
|
lang: py
|
||||||
|
domain: infra
|
||||||
|
version: "1.0.0"
|
||||||
|
purity: impure
|
||||||
|
signature: "def dav_list_calendars(base_url: str, username: str, password: str, home_path: str, *, timeout_s: float = 15.0, verify_tls: bool = True) -> dict"
|
||||||
|
description: "Lista las colecciones de calendario CalDAV bajo un calendar-home en UNA peticion PROPFIND Depth:1. Devuelve solo las colecciones hijas que son calendarios CalDAV de verdad (resourcetype {urn:ietf:params:xml:ns:caldav}calendar), cada una con su href, su displayname (DAV) y su color (calendar-color de Apple, ej. #FF2968FF) si el servidor lo expone. El propio calendar-home (coleccion plana sin el resourcetype calendar) y cualquier coleccion no-calendario se excluyen. Considera solo los propstat con estado 2xx para no leer props marcadas 404. Construye Authorization: Basic base64(user:pass) a mano y parsea el multistatus con regex. verify_tls=True por defecto. Maneja errores sin lanzar. Solo stdlib (urllib, base64, re, ssl, html). Probado contra Xandikos."
|
||||||
|
tags: [dav, caldav, calendar, calendars, propfind, displayname, color, selector, http, infra]
|
||||||
|
uses_functions: []
|
||||||
|
uses_types: []
|
||||||
|
returns: []
|
||||||
|
returns_optional: false
|
||||||
|
error_type: "error_go_core"
|
||||||
|
imports: [base64, html, re, ssl, urllib.error, urllib.request]
|
||||||
|
params:
|
||||||
|
- name: base_url
|
||||||
|
desc: "URL base del servidor DAV (p.ej. 'https://dav-eedeb681c4ab89ab8e444ac9.organic-machine.com')."
|
||||||
|
- name: username
|
||||||
|
desc: "usuario para HTTP Basic auth (p.ej. 'enmanuel')."
|
||||||
|
- name: password
|
||||||
|
desc: "contrasena para HTTP Basic auth. Resolver desde pass con pass_get_secret, nunca hardcodear."
|
||||||
|
- name: home_path
|
||||||
|
desc: "ruta del calendar-home del usuario (p.ej. '/enmanuel/calendars/'). Las colecciones de calendario cuelgan de el."
|
||||||
|
- name: timeout_s
|
||||||
|
desc: "timeout de la peticion HTTP en segundos. Default 15.0."
|
||||||
|
- name: verify_tls
|
||||||
|
desc: "si True (default) verifica el certificado TLS. No desactivar salvo entorno de prueba."
|
||||||
|
output: "dict. En exito: {status:'ok', http_status:int, calendars:[{href:str, name:str, color:str|None}, ...]} con un elemento por coleccion de calendario, ordenadas por nombre. color es el calendar-color de Apple (ej. '#FF2968FF') o None. En error (sin lanzar): {status:'error', error:str, http_status:int|None}."
|
||||||
|
tested: true
|
||||||
|
tests:
|
||||||
|
- "test_lista_solo_calendarios"
|
||||||
|
- "test_excluye_home_plano"
|
||||||
|
- "test_extrae_nombre_y_color"
|
||||||
|
- "test_color_ausente_es_none"
|
||||||
|
- "test_ignora_props_404"
|
||||||
|
- "test_httperror_devuelve_status_error"
|
||||||
|
- "test_urlerror_sin_red"
|
||||||
|
test_file_path: "python/functions/infra/dav_list_calendars_test.py"
|
||||||
|
file_path: "python/functions/infra/dav_list_calendars.py"
|
||||||
|
---
|
||||||
|
|
||||||
|
## Ejemplo
|
||||||
|
|
||||||
|
```python
|
||||||
|
import sys
|
||||||
|
sys.path.insert(0, "python/functions")
|
||||||
|
from infra.pass_get_secret import pass_get_secret
|
||||||
|
from infra.dav_list_calendars import dav_list_calendars
|
||||||
|
|
||||||
|
pw = pass_get_secret("dav/xandikos-enmanuel")["value"] # NO logear
|
||||||
|
|
||||||
|
res = dav_list_calendars(
|
||||||
|
base_url="https://dav-eedeb681c4ab89ab8e444ac9.organic-machine.com",
|
||||||
|
username="enmanuel",
|
||||||
|
password=pw,
|
||||||
|
home_path="/enmanuel/calendars/",
|
||||||
|
)
|
||||||
|
for c in res["calendars"]:
|
||||||
|
print(c["name"], c["href"], c["color"])
|
||||||
|
# calendar /enmanuel/calendars/calendar/ None
|
||||||
|
```
|
||||||
|
|
||||||
|
## Cuando usarla
|
||||||
|
|
||||||
|
Cuando una UI necesita un selector de calendario: el usuario tiene varias
|
||||||
|
colecciones CalDAV bajo su calendar-home y quiere elegir una, con su nombre y su
|
||||||
|
color para pintarla. Devuelve el href de cada calendario (lo que luego pasas a
|
||||||
|
`dav_get_collection` / `caldav_put_event` como `collection_path`) sin que el
|
||||||
|
caller tenga que conocerlos de antemano ni distinguir el calendar-home de los
|
||||||
|
calendarios reales.
|
||||||
|
|
||||||
|
## Gotchas
|
||||||
|
|
||||||
|
- Filtra por el resourcetype `caldav:calendar`: el propio calendar-home es una
|
||||||
|
coleccion plana (sin ese resourcetype) y queda fuera, igual que carpetas
|
||||||
|
intermedias. Si tu servidor anida calendarios mas profundo que Depth:1, llama
|
||||||
|
con `home_path` apuntando al nivel correcto.
|
||||||
|
- El `color` es el `calendar-color` de Apple (`http://apple.com/ns/ical/`), que
|
||||||
|
Xandikos puede no tener seteado (devuelve None). Apple usa formato `#RRGGBBAA`
|
||||||
|
(8 digitos, con alfa); recortalo a `#RRGGBB` si tu UI no soporta alfa.
|
||||||
|
- Solo lee los `<propstat>` con estado 2xx para no confundir un
|
||||||
|
`<calendar-color/>` vacio de un propstat 404 con un color real.
|
||||||
|
- Lectura remota real sobre TLS; password de `pass`, no se logea.
|
||||||
@@ -0,0 +1,180 @@
|
|||||||
|
"""Lista las colecciones de calendario CalDAV bajo un calendar-home (PROPFIND).
|
||||||
|
|
||||||
|
Funcion impura: hace UNA peticion HTTP PROPFIND Depth:1 sobre el directorio
|
||||||
|
calendar-home de un usuario (p.ej. `/enmanuel/calendars/`) y devuelve solo las
|
||||||
|
colecciones hijas que son calendarios CalDAV de verdad — las que declaran el
|
||||||
|
resourcetype `{urn:ietf:params:xml:ns:caldav}calendar`. Por cada una extrae su
|
||||||
|
href, su `displayname` (DAV) y, si el servidor lo expone, su color
|
||||||
|
(`calendar-color` de Apple, ej. `#FF2968FF`). El propio calendar-home (que es una
|
||||||
|
coleccion plana sin el resourcetype calendar) se excluye.
|
||||||
|
|
||||||
|
Esto es lo que necesita un selector de calendario en una UI: el usuario tiene
|
||||||
|
varias colecciones bajo su calendar-home y quiere elegir una, con su nombre y su
|
||||||
|
color. `dav_list_resources` solo devuelve hrefs+etag de los recursos de UNA
|
||||||
|
coleccion (los eventos), no las colecciones hijas con su metadata.
|
||||||
|
|
||||||
|
Construye el header `Authorization: Basic base64(user:pass)` a mano con stdlib y
|
||||||
|
parsea el multistatus con regex simple (sin parser XML externo), considerando
|
||||||
|
solo los `<propstat>` con estado 2xx para no recoger props que el servidor marca
|
||||||
|
404. Maneja errores sin lanzar. Solo usa stdlib (urllib, base64, re, ssl, html).
|
||||||
|
Probado contra Xandikos.
|
||||||
|
"""
|
||||||
|
|
||||||
|
import base64
|
||||||
|
import html
|
||||||
|
import re
|
||||||
|
import ssl
|
||||||
|
import urllib.error
|
||||||
|
import urllib.request
|
||||||
|
|
||||||
|
_RESPONSE_RE = re.compile(
|
||||||
|
r"<(?:[A-Za-z0-9]+:)?response>(.*?)</(?:[A-Za-z0-9]+:)?response>",
|
||||||
|
re.DOTALL | re.IGNORECASE,
|
||||||
|
)
|
||||||
|
_HREF_RE = re.compile(
|
||||||
|
r"<(?:[A-Za-z0-9]+:)?href>\s*(.*?)\s*</(?:[A-Za-z0-9]+:)?href>",
|
||||||
|
re.DOTALL | re.IGNORECASE,
|
||||||
|
)
|
||||||
|
_PROPSTAT_RE = re.compile(
|
||||||
|
r"<(?:[A-Za-z0-9]+:)?propstat>(.*?)</(?:[A-Za-z0-9]+:)?propstat>",
|
||||||
|
re.DOTALL | re.IGNORECASE,
|
||||||
|
)
|
||||||
|
_STATUS_RE = re.compile(
|
||||||
|
r"<(?:[A-Za-z0-9]+:)?status>\s*(.*?)\s*</(?:[A-Za-z0-9]+:)?status>",
|
||||||
|
re.DOTALL | re.IGNORECASE,
|
||||||
|
)
|
||||||
|
_DISPLAYNAME_RE = re.compile(
|
||||||
|
r"<(?:[A-Za-z0-9]+:)?displayname>\s*(.*?)\s*</(?:[A-Za-z0-9]+:)?displayname>",
|
||||||
|
re.DOTALL | re.IGNORECASE,
|
||||||
|
)
|
||||||
|
# Color de Apple: <ns:calendar-color>#RRGGBBAA</ns:calendar-color> (o sin alfa).
|
||||||
|
_COLOR_RE = re.compile(
|
||||||
|
r"<(?:[A-Za-z0-9]+:)?calendar-color[^>]*>\s*(.*?)\s*</(?:[A-Za-z0-9]+:)?calendar-color>",
|
||||||
|
re.DOTALL | re.IGNORECASE,
|
||||||
|
)
|
||||||
|
# Marca de calendario CalDAV en el resourcetype. El elemento `<C:calendar/>`
|
||||||
|
# puede venir con o sin prefijo de namespace (`<ns2:calendar/>`, `<calendar/>`).
|
||||||
|
_CALENDAR_TYPE_RE = re.compile(
|
||||||
|
r"<(?:[A-Za-z0-9]+:)?calendar(?:\s[^>]*)?/?>", re.IGNORECASE
|
||||||
|
)
|
||||||
|
|
||||||
|
# El PROPFIND pide nombre, tipo y color (Apple). Declarar el namespace de Apple y
|
||||||
|
# el de CalDAV permite que el servidor responda esas props cuando existan.
|
||||||
|
_PROPFIND_BODY = (
|
||||||
|
'<?xml version="1.0" encoding="utf-8" ?>'
|
||||||
|
'<D:propfind xmlns:D="DAV:" '
|
||||||
|
'xmlns:A="http://apple.com/ns/ical/" '
|
||||||
|
'xmlns:C="urn:ietf:params:xml:ns:caldav">'
|
||||||
|
"<D:prop>"
|
||||||
|
"<D:displayname/><D:resourcetype/><A:calendar-color/>"
|
||||||
|
"</D:prop></D:propfind>"
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def _basic_auth_header(username: str, password: str) -> str:
|
||||||
|
raw = ("%s:%s" % (username, password)).encode("utf-8")
|
||||||
|
return "Basic " + base64.b64encode(raw).decode("ascii")
|
||||||
|
|
||||||
|
|
||||||
|
def _join_url(base_url: str, collection_path: str) -> str:
|
||||||
|
return base_url.rstrip("/") + "/" + collection_path.strip("/") + "/"
|
||||||
|
|
||||||
|
|
||||||
|
def _ok_propstats(response_block: str) -> str:
|
||||||
|
"""Concatena solo los `<propstat>` con estado 2xx de un `<response>`.
|
||||||
|
|
||||||
|
El servidor agrupa las props por estado: las presentes en un propstat 200 y
|
||||||
|
las ausentes en un propstat 404. Tomar solo los 2xx evita leer un
|
||||||
|
`<calendar-color/>` vacio del bloque 404 como si fuera el valor real.
|
||||||
|
"""
|
||||||
|
parts = []
|
||||||
|
for ps in _PROPSTAT_RE.findall(response_block):
|
||||||
|
status_m = _STATUS_RE.search(ps)
|
||||||
|
if status_m and " 2" in (" " + status_m.group(1)):
|
||||||
|
parts.append(ps)
|
||||||
|
# Si no hay propstat (servidor minimalista), usar el bloque entero.
|
||||||
|
return "".join(parts) if parts else response_block
|
||||||
|
|
||||||
|
|
||||||
|
def dav_list_calendars(
|
||||||
|
base_url: str,
|
||||||
|
username: str,
|
||||||
|
password: str,
|
||||||
|
home_path: str,
|
||||||
|
*,
|
||||||
|
timeout_s: float = 15.0,
|
||||||
|
verify_tls: bool = True,
|
||||||
|
) -> dict:
|
||||||
|
"""Lista las colecciones de calendario CalDAV bajo un calendar-home.
|
||||||
|
|
||||||
|
Hace un PROPFIND Depth:1 sobre `home_path` y devuelve solo las colecciones
|
||||||
|
hijas marcadas como calendario CalDAV (resourcetype `caldav:calendar`), con
|
||||||
|
su nombre y color. El propio `home_path` y cualquier coleccion no-calendario
|
||||||
|
se excluyen.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
base_url: URL base del servidor DAV (p.ej. 'https://dav-x.example.com').
|
||||||
|
username: usuario para HTTP Basic auth.
|
||||||
|
password: contrasena para HTTP Basic auth. Resolver desde pass.
|
||||||
|
home_path: ruta del calendar-home del usuario (p.ej.
|
||||||
|
'/enmanuel/calendars/'). Las colecciones de calendario cuelgan de el.
|
||||||
|
timeout_s: timeout de la peticion en segundos. Default 15.0.
|
||||||
|
verify_tls: si True (default) verifica el certificado TLS.
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
dict. En exito: {status:'ok', http_status:int,
|
||||||
|
calendars:[{href:str, name:str, color:str|None}, ...]} con un elemento
|
||||||
|
por coleccion de calendario (ordenadas por nombre). `color` es el
|
||||||
|
`calendar-color` de Apple (ej. '#FF2968FF') si el servidor lo expone, o
|
||||||
|
None. En error (sin lanzar): {status:'error', error:str,
|
||||||
|
http_status:int|None}.
|
||||||
|
"""
|
||||||
|
url = _join_url(base_url, home_path)
|
||||||
|
headers = {
|
||||||
|
"Authorization": _basic_auth_header(username, password),
|
||||||
|
"Content-Type": "application/xml; charset=utf-8",
|
||||||
|
"Depth": "1",
|
||||||
|
}
|
||||||
|
req = urllib.request.Request(
|
||||||
|
url, data=_PROPFIND_BODY.encode("utf-8"), method="PROPFIND", headers=headers
|
||||||
|
)
|
||||||
|
|
||||||
|
context = None if verify_tls else ssl._create_unverified_context()
|
||||||
|
try:
|
||||||
|
with urllib.request.urlopen(req, timeout=timeout_s, context=context) as resp:
|
||||||
|
status = resp.status
|
||||||
|
xml = resp.read().decode("utf-8", "replace")
|
||||||
|
except urllib.error.HTTPError as e:
|
||||||
|
return {"status": "error", "error": "http %s" % e.code, "http_status": e.code}
|
||||||
|
except urllib.error.URLError as e:
|
||||||
|
return {"status": "error", "error": str(e.reason), "http_status": None}
|
||||||
|
except Exception as e: # noqa: BLE001
|
||||||
|
return {"status": "error", "error": str(e), "http_status": None}
|
||||||
|
|
||||||
|
home_tail = home_path.strip("/").rsplit("/", 1)[-1]
|
||||||
|
calendars = []
|
||||||
|
for block in _RESPONSE_RE.findall(xml):
|
||||||
|
href_m = _HREF_RE.search(block)
|
||||||
|
if not href_m:
|
||||||
|
continue
|
||||||
|
href = href_m.group(1).strip()
|
||||||
|
tail = href.rstrip("/").rsplit("/", 1)[-1]
|
||||||
|
# El propio calendar-home (o un href identico al home): se excluye.
|
||||||
|
if tail == home_tail:
|
||||||
|
continue
|
||||||
|
# Solo las colecciones marcadas como calendario CalDAV. El home plano no
|
||||||
|
# lleva el resourcetype `caldav:calendar` y queda fuera.
|
||||||
|
if not _CALENDAR_TYPE_RE.search(block):
|
||||||
|
continue
|
||||||
|
ok = _ok_propstats(block)
|
||||||
|
name_m = _DISPLAYNAME_RE.search(ok)
|
||||||
|
name = html.unescape(name_m.group(1).strip()) if name_m else tail
|
||||||
|
if not name:
|
||||||
|
name = tail
|
||||||
|
color_m = _COLOR_RE.search(ok)
|
||||||
|
color = html.unescape(color_m.group(1).strip()) if color_m else None
|
||||||
|
if color == "":
|
||||||
|
color = None
|
||||||
|
calendars.append({"href": href, "name": name, "color": color})
|
||||||
|
calendars.sort(key=lambda c: c["name"].lower())
|
||||||
|
return {"status": "ok", "http_status": status, "calendars": calendars}
|
||||||
@@ -0,0 +1,180 @@
|
|||||||
|
"""Tests para dav_list_calendars.
|
||||||
|
|
||||||
|
Smoke deterministas: monkeypatchean urllib.request.urlopen para devolver un
|
||||||
|
multistatus simulado al estilo Xandikos (PROPFIND Depth:1 sobre un calendar-home
|
||||||
|
con el propio home plano + dos calendarios CalDAV, uno con color y otro sin) y
|
||||||
|
una coleccion no-calendario que debe quedar fuera. Cubren: filtrado por
|
||||||
|
resourcetype caldav:calendar, exclusion del home plano, extraccion de nombre y
|
||||||
|
color, color ausente -> None, props marcadas 404 ignoradas, y los paths de
|
||||||
|
error HTTP / sin red.
|
||||||
|
"""
|
||||||
|
|
||||||
|
import sys
|
||||||
|
|
||||||
|
import infra.dav_list_calendars # noqa: F401
|
||||||
|
|
||||||
|
mod = sys.modules["infra.dav_list_calendars"]
|
||||||
|
dav_list_calendars = mod.dav_list_calendars
|
||||||
|
|
||||||
|
# Multistatus estilo Xandikos:
|
||||||
|
# - /enmanuel/calendars/ -> home plano (collection, sin caldav:calendar)
|
||||||
|
# - /enmanuel/calendars/calendar/ -> calendario CalDAV sin color (color en 404)
|
||||||
|
# - /enmanuel/calendars/trabajo/ -> calendario CalDAV con calendar-color
|
||||||
|
# - /enmanuel/calendars/inbox/ -> coleccion NO-calendario (debe excluirse)
|
||||||
|
_XML_HOME = (
|
||||||
|
'<?xml version="1.0"?>'
|
||||||
|
'<ns0:multistatus xmlns:ns0="DAV:" '
|
||||||
|
'xmlns:ns1="http://apple.com/ns/ical/" '
|
||||||
|
'xmlns:ns2="urn:ietf:params:xml:ns:caldav">'
|
||||||
|
# Home plano: collection, sin <ns2:calendar/>.
|
||||||
|
"<ns0:response><ns0:href>/enmanuel/calendars/</ns0:href>"
|
||||||
|
"<ns0:propstat><ns0:status>HTTP/1.1 200 OK</ns0:status><ns0:prop>"
|
||||||
|
"<ns0:displayname>calendars</ns0:displayname>"
|
||||||
|
"<ns0:resourcetype><ns0:collection /></ns0:resourcetype>"
|
||||||
|
"</ns0:prop></ns0:propstat>"
|
||||||
|
"<ns0:propstat><ns0:status>HTTP/1.1 404 Not Found</ns0:status>"
|
||||||
|
"<ns0:prop><ns1:calendar-color /></ns0:prop></ns0:propstat>"
|
||||||
|
"</ns0:response>"
|
||||||
|
# Calendario sin color: el color va en un propstat 404 (no debe leerse).
|
||||||
|
"<ns0:response><ns0:href>/enmanuel/calendars/calendar/</ns0:href>"
|
||||||
|
"<ns0:propstat><ns0:status>HTTP/1.1 200 OK</ns0:status><ns0:prop>"
|
||||||
|
"<ns0:displayname>calendar</ns0:displayname>"
|
||||||
|
"<ns0:resourcetype><ns0:collection /><ns2:calendar /></ns0:resourcetype>"
|
||||||
|
"</ns0:prop></ns0:propstat>"
|
||||||
|
"<ns0:propstat><ns0:status>HTTP/1.1 404 Not Found</ns0:status>"
|
||||||
|
"<ns0:prop><ns1:calendar-color /></ns0:prop></ns0:propstat>"
|
||||||
|
"</ns0:response>"
|
||||||
|
# Calendario con color (Apple #RRGGBBAA) y displayname con acento.
|
||||||
|
"<ns0:response><ns0:href>/enmanuel/calendars/trabajo/</ns0:href>"
|
||||||
|
"<ns0:propstat><ns0:status>HTTP/1.1 200 OK</ns0:status><ns0:prop>"
|
||||||
|
"<ns0:displayname>Trabajo & ocio</ns0:displayname>"
|
||||||
|
"<ns0:resourcetype><ns0:collection /><ns2:calendar /></ns0:resourcetype>"
|
||||||
|
"<ns1:calendar-color>#FF2968FF</ns1:calendar-color>"
|
||||||
|
"</ns0:prop></ns0:propstat></ns0:response>"
|
||||||
|
# Coleccion NO-calendario (p.ej. un inbox de scheduling): excluida.
|
||||||
|
"<ns0:response><ns0:href>/enmanuel/calendars/inbox/</ns0:href>"
|
||||||
|
"<ns0:propstat><ns0:status>HTTP/1.1 200 OK</ns0:status><ns0:prop>"
|
||||||
|
"<ns0:displayname>inbox</ns0:displayname>"
|
||||||
|
"<ns0:resourcetype><ns0:collection /><ns2:schedule-inbox /></ns0:resourcetype>"
|
||||||
|
"</ns0:prop></ns0:propstat></ns0:response>"
|
||||||
|
"</ns0:multistatus>"
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
class _FakeResp:
|
||||||
|
def __init__(self, payload: str):
|
||||||
|
self._payload = payload
|
||||||
|
self.status = 207
|
||||||
|
|
||||||
|
def __enter__(self):
|
||||||
|
return self
|
||||||
|
|
||||||
|
def __exit__(self, *a):
|
||||||
|
return False
|
||||||
|
|
||||||
|
def read(self):
|
||||||
|
return self._payload.encode("utf-8")
|
||||||
|
|
||||||
|
|
||||||
|
def _capture(monkeypatch, payload: str):
|
||||||
|
captured = {}
|
||||||
|
|
||||||
|
def fake_urlopen(req, timeout=None, context=None):
|
||||||
|
captured["method"] = req.get_method()
|
||||||
|
captured["headers"] = {k.lower(): v for k, v in req.header_items()}
|
||||||
|
return _FakeResp(payload)
|
||||||
|
|
||||||
|
monkeypatch.setattr(mod.urllib.request, "urlopen", fake_urlopen)
|
||||||
|
return captured
|
||||||
|
|
||||||
|
|
||||||
|
def _call(home="/enmanuel/calendars/"):
|
||||||
|
return dav_list_calendars(
|
||||||
|
"https://dav.example.com", "enmanuel", "secret-pw", home
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def test_construye_propfind_depth_1(monkeypatch):
|
||||||
|
cap = _capture(monkeypatch, _XML_HOME)
|
||||||
|
_call()
|
||||||
|
assert cap["method"] == "PROPFIND"
|
||||||
|
assert cap["headers"]["depth"] == "1"
|
||||||
|
|
||||||
|
|
||||||
|
def test_lista_solo_calendarios(monkeypatch):
|
||||||
|
_capture(monkeypatch, _XML_HOME)
|
||||||
|
res = _call()
|
||||||
|
assert res["status"] == "ok"
|
||||||
|
hrefs = [c["href"] for c in res["calendars"]]
|
||||||
|
# calendar + trabajo (los dos caldav:calendar), nada mas.
|
||||||
|
assert "/enmanuel/calendars/calendar/" in hrefs
|
||||||
|
assert "/enmanuel/calendars/trabajo/" in hrefs
|
||||||
|
assert len(res["calendars"]) == 2
|
||||||
|
|
||||||
|
|
||||||
|
def test_excluye_home_plano(monkeypatch):
|
||||||
|
_capture(monkeypatch, _XML_HOME)
|
||||||
|
res = _call()
|
||||||
|
hrefs = [c["href"] for c in res["calendars"]]
|
||||||
|
assert "/enmanuel/calendars/" not in hrefs
|
||||||
|
|
||||||
|
|
||||||
|
def test_excluye_coleccion_no_calendario(monkeypatch):
|
||||||
|
_capture(monkeypatch, _XML_HOME)
|
||||||
|
res = _call()
|
||||||
|
hrefs = [c["href"] for c in res["calendars"]]
|
||||||
|
assert "/enmanuel/calendars/inbox/" not in hrefs
|
||||||
|
|
||||||
|
|
||||||
|
def test_extrae_nombre_y_color(monkeypatch):
|
||||||
|
_capture(monkeypatch, _XML_HOME)
|
||||||
|
res = _call()
|
||||||
|
by_href = {c["href"]: c for c in res["calendars"]}
|
||||||
|
trabajo = by_href["/enmanuel/calendars/trabajo/"]
|
||||||
|
assert trabajo["name"] == "Trabajo & ocio" # entidad XML des-escapada
|
||||||
|
assert trabajo["color"] == "#FF2968FF"
|
||||||
|
|
||||||
|
|
||||||
|
def test_color_ausente_es_none(monkeypatch):
|
||||||
|
_capture(monkeypatch, _XML_HOME)
|
||||||
|
res = _call()
|
||||||
|
by_href = {c["href"]: c for c in res["calendars"]}
|
||||||
|
cal = by_href["/enmanuel/calendars/calendar/"]
|
||||||
|
assert cal["name"] == "calendar"
|
||||||
|
assert cal["color"] is None
|
||||||
|
|
||||||
|
|
||||||
|
def test_ignora_props_404(monkeypatch):
|
||||||
|
"""El <calendar-color/> vacio de un propstat 404 NO se lee como color real."""
|
||||||
|
_capture(monkeypatch, _XML_HOME)
|
||||||
|
res = _call()
|
||||||
|
by_href = {c["href"]: c for c in res["calendars"]}
|
||||||
|
# calendar tiene calendar-color SOLO en el propstat 404 -> color None, no "".
|
||||||
|
assert by_href["/enmanuel/calendars/calendar/"]["color"] is None
|
||||||
|
|
||||||
|
|
||||||
|
def test_ordenado_por_nombre(monkeypatch):
|
||||||
|
_capture(monkeypatch, _XML_HOME)
|
||||||
|
res = _call()
|
||||||
|
names = [c["name"] for c in res["calendars"]]
|
||||||
|
assert names == sorted(names, key=str.lower)
|
||||||
|
|
||||||
|
|
||||||
|
def test_httperror_devuelve_status_error(monkeypatch):
|
||||||
|
def fake_urlopen(req, timeout=None, context=None):
|
||||||
|
raise mod.urllib.error.HTTPError(req.full_url, 401, "Unauthorized", {}, None)
|
||||||
|
|
||||||
|
monkeypatch.setattr(mod.urllib.request, "urlopen", fake_urlopen)
|
||||||
|
res = _call()
|
||||||
|
assert res["status"] == "error"
|
||||||
|
assert res["http_status"] == 401
|
||||||
|
|
||||||
|
|
||||||
|
def test_urlerror_sin_red(monkeypatch):
|
||||||
|
def fake_urlopen(req, timeout=None, context=None):
|
||||||
|
raise mod.urllib.error.URLError("sin red")
|
||||||
|
|
||||||
|
monkeypatch.setattr(mod.urllib.request, "urlopen", fake_urlopen)
|
||||||
|
res = _call()
|
||||||
|
assert res["status"] == "error"
|
||||||
|
assert res["http_status"] is None
|
||||||
@@ -0,0 +1,82 @@
|
|||||||
|
---
|
||||||
|
name: dav_list_resources
|
||||||
|
kind: function
|
||||||
|
lang: py
|
||||||
|
domain: infra
|
||||||
|
version: "1.0.0"
|
||||||
|
purity: impure
|
||||||
|
signature: "def dav_list_resources(base_url: str, username: str, password: str, collection_path: str, *, timeout_s: float = 20.0, verify_tls: bool = True) -> dict"
|
||||||
|
description: "Lista los recursos de una coleccion DAV (CardDAV o CalDAV) via PROPFIND Depth:1 con HTTP Basic auth. Construye el header Authorization: Basic base64(user:pass) a mano con stdlib. Parsea el XML multistatus con regex simple (sin parser XML externo) y devuelve los hrefs + getetag de cada recurso, excluyendo la propia coleccion. verify_tls=True por defecto. Maneja errores sin lanzar. Solo stdlib (urllib, base64, re, ssl). Probado contra Xandikos."
|
||||||
|
tags: [dav, carddav, caldav, propfind, list, http, infra]
|
||||||
|
uses_functions: []
|
||||||
|
uses_types: []
|
||||||
|
returns: []
|
||||||
|
returns_optional: false
|
||||||
|
error_type: "error_go_core"
|
||||||
|
imports: [base64, re, ssl, urllib.error, urllib.request]
|
||||||
|
params:
|
||||||
|
- name: base_url
|
||||||
|
desc: "URL base del servidor DAV (p.ej. 'https://dav-eedeb681c4ab89ab8e444ac9.organic-machine.com')."
|
||||||
|
- name: username
|
||||||
|
desc: "usuario para HTTP Basic auth (p.ej. 'enmanuel')."
|
||||||
|
- name: password
|
||||||
|
desc: "contrasena para HTTP Basic auth. Resolver desde pass con pass_get_secret, nunca hardcodear."
|
||||||
|
- name: collection_path
|
||||||
|
desc: "ruta de la coleccion a listar (CardDAV '/enmanuel/contacts/addressbook/' o CalDAV '/enmanuel/calendars/calendar/')."
|
||||||
|
- name: timeout_s
|
||||||
|
desc: "timeout de la peticion HTTP en segundos. Default 20.0."
|
||||||
|
- name: verify_tls
|
||||||
|
desc: "si True (default) verifica el certificado TLS. No desactivar salvo entorno de prueba."
|
||||||
|
output: "dict. En exito: {status:'ok', http_status:int, resources:[{href:str, etag:str|None}, ...]} con un elemento por recurso de la coleccion (excluida la propia coleccion). En error (sin lanzar): {status:'error', error:str, http_status:int|None}."
|
||||||
|
tested: true
|
||||||
|
tests:
|
||||||
|
- "test_construye_request_propfind_depth_1"
|
||||||
|
- "test_basic_auth_header_correcto"
|
||||||
|
- "test_parsea_hrefs_y_etags_del_multistatus"
|
||||||
|
- "test_excluye_la_propia_coleccion"
|
||||||
|
- "test_httperror_devuelve_status_error"
|
||||||
|
test_file_path: "python/functions/infra/dav_list_resources_test.py"
|
||||||
|
file_path: "python/functions/infra/dav_list_resources.py"
|
||||||
|
---
|
||||||
|
|
||||||
|
## Ejemplo
|
||||||
|
|
||||||
|
```python
|
||||||
|
import sys
|
||||||
|
sys.path.insert(0, "python/functions")
|
||||||
|
from infra.pass_get_secret import pass_get_secret
|
||||||
|
from infra.dav_list_resources import dav_list_resources
|
||||||
|
|
||||||
|
pw = pass_get_secret("dav/xandikos-enmanuel")["value"] # NO logear
|
||||||
|
|
||||||
|
res = dav_list_resources(
|
||||||
|
base_url="https://dav-eedeb681c4ab89ab8e444ac9.organic-machine.com",
|
||||||
|
username="enmanuel",
|
||||||
|
password=pw,
|
||||||
|
collection_path="/enmanuel/contacts/addressbook/",
|
||||||
|
)
|
||||||
|
print(res["status"], len(res["resources"]))
|
||||||
|
# ok 820
|
||||||
|
print(res["resources"][0]) # {"href": "/enmanuel/contacts/addressbook/abc.vcf", "etag": '"..."'}
|
||||||
|
```
|
||||||
|
|
||||||
|
## Cuando usarla
|
||||||
|
|
||||||
|
Cuando quieres enumerar lo que ya hay en una coleccion CardDAV/CalDAV: contar
|
||||||
|
contactos/eventos importados, verificar una migracion, o construir un mapa
|
||||||
|
href->etag para sync incremental. Sirve igual para libretas de direcciones y
|
||||||
|
calendarios (PROPFIND es generico). Combinala con `dav_get_resource` para
|
||||||
|
descargar el contenido de cada href.
|
||||||
|
|
||||||
|
## Gotchas
|
||||||
|
|
||||||
|
- Usa PROPFIND Depth:1 (no addressbook-query REPORT): lista TODOS los recursos
|
||||||
|
hijos de la coleccion. Para colecciones enormes la respuesta XML puede ser
|
||||||
|
grande; el timeout default es 20s.
|
||||||
|
- El parseo es regex simple sobre el multistatus, no un parser XML completo: es
|
||||||
|
robusto para la salida estandar de Xandikos pero podria fallar con servidores
|
||||||
|
que devuelvan XML muy exotico. La intencion es KISS sin dependencias.
|
||||||
|
- Excluye la propia coleccion comparando el ultimo segmento del href; si tu
|
||||||
|
coleccion y un recurso comparten exactamente el ultimo segmento (raro), ese
|
||||||
|
recurso se omitiria.
|
||||||
|
- Lectura remota real sobre TLS; password de `pass`, no se logea.
|
||||||
@@ -0,0 +1,101 @@
|
|||||||
|
"""Lista los recursos de una coleccion DAV via PROPFIND Depth:1.
|
||||||
|
|
||||||
|
Funcion impura: hace una peticion HTTP PROPFIND. Construye el header
|
||||||
|
`Authorization: Basic base64(user:pass)` a mano con stdlib. Devuelve los hrefs
|
||||||
|
(y getetag cuando el servidor los expone) de los recursos de la coleccion,
|
||||||
|
parseados del XML multistatus con regex simple (sin dependencias de parser XML
|
||||||
|
externas). Sirve tanto para colecciones CardDAV como CalDAV. Maneja errores sin
|
||||||
|
lanzar. Solo usa stdlib (urllib, base64, re, ssl).
|
||||||
|
"""
|
||||||
|
|
||||||
|
import base64
|
||||||
|
import re
|
||||||
|
import ssl
|
||||||
|
import urllib.error
|
||||||
|
import urllib.request
|
||||||
|
|
||||||
|
_HREF_RE = re.compile(r"<(?:[A-Za-z0-9]+:)?href>\s*(.*?)\s*</(?:[A-Za-z0-9]+:)?href>", re.DOTALL | re.IGNORECASE)
|
||||||
|
_RESPONSE_RE = re.compile(r"<(?:[A-Za-z0-9]+:)?response>(.*?)</(?:[A-Za-z0-9]+:)?response>", re.DOTALL | re.IGNORECASE)
|
||||||
|
_ETAG_RE = re.compile(r"<(?:[A-Za-z0-9]+:)?getetag>\s*(.*?)\s*</(?:[A-Za-z0-9]+:)?getetag>", re.DOTALL | re.IGNORECASE)
|
||||||
|
|
||||||
|
_PROPFIND_BODY = (
|
||||||
|
'<?xml version="1.0" encoding="utf-8" ?>'
|
||||||
|
'<D:propfind xmlns:D="DAV:"><D:prop>'
|
||||||
|
"<D:getetag/><D:resourcetype/>"
|
||||||
|
"</D:prop></D:propfind>"
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def _basic_auth_header(username: str, password: str) -> str:
|
||||||
|
raw = ("%s:%s" % (username, password)).encode("utf-8")
|
||||||
|
return "Basic " + base64.b64encode(raw).decode("ascii")
|
||||||
|
|
||||||
|
|
||||||
|
def _join_url(base_url: str, collection_path: str) -> str:
|
||||||
|
return base_url.rstrip("/") + "/" + collection_path.strip("/") + "/"
|
||||||
|
|
||||||
|
|
||||||
|
def dav_list_resources(
|
||||||
|
base_url: str,
|
||||||
|
username: str,
|
||||||
|
password: str,
|
||||||
|
collection_path: str,
|
||||||
|
*,
|
||||||
|
timeout_s: float = 20.0,
|
||||||
|
verify_tls: bool = True,
|
||||||
|
) -> dict:
|
||||||
|
"""Lista los recursos de una coleccion DAV (PROPFIND Depth:1).
|
||||||
|
|
||||||
|
Args:
|
||||||
|
base_url: URL base del servidor DAV.
|
||||||
|
username: usuario para HTTP Basic auth.
|
||||||
|
password: contrasena para HTTP Basic auth.
|
||||||
|
collection_path: ruta de la coleccion (CardDAV o CalDAV).
|
||||||
|
timeout_s: timeout de la peticion en segundos. Default 20.0.
|
||||||
|
verify_tls: si True (default) verifica el certificado TLS.
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
dict. En exito: {status:'ok', http_status:int,
|
||||||
|
resources:[{href:str, etag:str|None}, ...]}. El primer <response> suele
|
||||||
|
ser la propia coleccion; se excluye comparando su href con la ruta de la
|
||||||
|
coleccion. En error (sin lanzar): {status:'error', error:str,
|
||||||
|
http_status:int|None}.
|
||||||
|
"""
|
||||||
|
url = _join_url(base_url, collection_path)
|
||||||
|
headers = {
|
||||||
|
"Authorization": _basic_auth_header(username, password),
|
||||||
|
"Content-Type": "application/xml; charset=utf-8",
|
||||||
|
"Depth": "1",
|
||||||
|
}
|
||||||
|
req = urllib.request.Request(
|
||||||
|
url, data=_PROPFIND_BODY.encode("utf-8"), method="PROPFIND", headers=headers
|
||||||
|
)
|
||||||
|
|
||||||
|
context = None if verify_tls else ssl._create_unverified_context()
|
||||||
|
try:
|
||||||
|
with urllib.request.urlopen(req, timeout=timeout_s, context=context) as resp:
|
||||||
|
status = resp.status
|
||||||
|
xml = resp.read().decode("utf-8", "replace")
|
||||||
|
except urllib.error.HTTPError as e:
|
||||||
|
return {"status": "error", "error": "http %s" % e.code, "http_status": e.code}
|
||||||
|
except urllib.error.URLError as e:
|
||||||
|
return {"status": "error", "error": str(e.reason), "http_status": None}
|
||||||
|
except Exception as e: # noqa: BLE001
|
||||||
|
return {"status": "error", "error": str(e), "http_status": None}
|
||||||
|
|
||||||
|
coll_tail = collection_path.strip("/").rsplit("/", 1)[-1]
|
||||||
|
resources = []
|
||||||
|
for block in _RESPONSE_RE.findall(xml):
|
||||||
|
href_m = _HREF_RE.search(block)
|
||||||
|
if not href_m:
|
||||||
|
continue
|
||||||
|
href = href_m.group(1).strip()
|
||||||
|
# El ultimo segmento del href identifica el recurso. Si coincide con el
|
||||||
|
# ultimo segmento de la coleccion, ese <response> ES la coleccion: skip.
|
||||||
|
tail = href.rstrip("/").rsplit("/", 1)[-1]
|
||||||
|
if tail == coll_tail:
|
||||||
|
continue
|
||||||
|
etag_m = _ETAG_RE.search(block)
|
||||||
|
etag = etag_m.group(1).strip() if etag_m else None
|
||||||
|
resources.append({"href": href, "etag": etag})
|
||||||
|
return {"status": "ok", "http_status": status, "resources": resources}
|
||||||
@@ -0,0 +1,107 @@
|
|||||||
|
"""Tests para dav_list_resources.
|
||||||
|
|
||||||
|
Smoke deterministas: monkeypatchean urllib.request.urlopen para capturar el
|
||||||
|
Request (method PROPFIND, Depth, auth) y devolver un XML multistatus simulado.
|
||||||
|
"""
|
||||||
|
|
||||||
|
import base64
|
||||||
|
import io
|
||||||
|
import sys
|
||||||
|
|
||||||
|
import infra.dav_list_resources # noqa: F401
|
||||||
|
|
||||||
|
mod = sys.modules["infra.dav_list_resources"]
|
||||||
|
dav_list_resources = mod.dav_list_resources
|
||||||
|
|
||||||
|
_XML = (
|
||||||
|
'<?xml version="1.0"?>'
|
||||||
|
'<D:multistatus xmlns:D="DAV:">'
|
||||||
|
"<D:response><D:href>/enmanuel/contacts/addressbook/</D:href>"
|
||||||
|
"<D:propstat><D:prop><D:resourcetype><D:collection/></D:resourcetype>"
|
||||||
|
"</D:prop></D:propstat></D:response>"
|
||||||
|
"<D:response><D:href>/enmanuel/contacts/addressbook/ada.vcf</D:href>"
|
||||||
|
'<D:propstat><D:prop><D:getetag>"etag-ada"</D:getetag></D:prop></D:propstat>'
|
||||||
|
"</D:response>"
|
||||||
|
"<D:response><D:href>/enmanuel/contacts/addressbook/alan.vcf</D:href>"
|
||||||
|
'<D:propstat><D:prop><D:getetag>"etag-alan"</D:getetag></D:prop></D:propstat>'
|
||||||
|
"</D:response>"
|
||||||
|
"</D:multistatus>"
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
class _FakeResp:
|
||||||
|
status = 207
|
||||||
|
|
||||||
|
def __enter__(self):
|
||||||
|
return self
|
||||||
|
|
||||||
|
def __exit__(self, *a):
|
||||||
|
return False
|
||||||
|
|
||||||
|
def read(self):
|
||||||
|
return _XML.encode("utf-8")
|
||||||
|
|
||||||
|
|
||||||
|
def _capture(monkeypatch):
|
||||||
|
captured = {}
|
||||||
|
|
||||||
|
def fake_urlopen(req, timeout=None, context=None):
|
||||||
|
captured["url"] = req.full_url
|
||||||
|
captured["method"] = req.get_method()
|
||||||
|
captured["headers"] = {k.lower(): v for k, v in req.header_items()}
|
||||||
|
return _FakeResp()
|
||||||
|
|
||||||
|
monkeypatch.setattr(mod.urllib.request, "urlopen", fake_urlopen)
|
||||||
|
return captured
|
||||||
|
|
||||||
|
|
||||||
|
def _call():
|
||||||
|
return dav_list_resources(
|
||||||
|
base_url="https://dav.example.com",
|
||||||
|
username="enmanuel",
|
||||||
|
password="secret-pw",
|
||||||
|
collection_path="/enmanuel/contacts/addressbook/",
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def test_construye_request_propfind_depth_1(monkeypatch):
|
||||||
|
cap = _capture(monkeypatch)
|
||||||
|
_call()
|
||||||
|
assert cap["method"] == "PROPFIND"
|
||||||
|
assert cap["headers"]["depth"] == "1"
|
||||||
|
|
||||||
|
|
||||||
|
def test_basic_auth_header_correcto(monkeypatch):
|
||||||
|
cap = _capture(monkeypatch)
|
||||||
|
_call()
|
||||||
|
expected = "Basic " + base64.b64encode(b"enmanuel:secret-pw").decode("ascii")
|
||||||
|
assert cap["headers"]["authorization"] == expected
|
||||||
|
|
||||||
|
|
||||||
|
def test_parsea_hrefs_y_etags_del_multistatus(monkeypatch):
|
||||||
|
_capture(monkeypatch)
|
||||||
|
res = _call()
|
||||||
|
assert res["status"] == "ok"
|
||||||
|
hrefs = [r["href"] for r in res["resources"]]
|
||||||
|
assert "/enmanuel/contacts/addressbook/ada.vcf" in hrefs
|
||||||
|
assert "/enmanuel/contacts/addressbook/alan.vcf" in hrefs
|
||||||
|
etags = {r["href"]: r["etag"] for r in res["resources"]}
|
||||||
|
assert etags["/enmanuel/contacts/addressbook/ada.vcf"] == '"etag-ada"'
|
||||||
|
|
||||||
|
|
||||||
|
def test_excluye_la_propia_coleccion(monkeypatch):
|
||||||
|
_capture(monkeypatch)
|
||||||
|
res = _call()
|
||||||
|
hrefs = [r["href"] for r in res["resources"]]
|
||||||
|
assert "/enmanuel/contacts/addressbook/" not in hrefs
|
||||||
|
assert len(res["resources"]) == 2
|
||||||
|
|
||||||
|
|
||||||
|
def test_httperror_devuelve_status_error(monkeypatch):
|
||||||
|
def fake_urlopen(req, timeout=None, context=None):
|
||||||
|
raise mod.urllib.error.HTTPError(req.full_url, 401, "Unauthorized", {}, None)
|
||||||
|
|
||||||
|
monkeypatch.setattr(mod.urllib.request, "urlopen", fake_urlopen)
|
||||||
|
res = _call()
|
||||||
|
assert res["status"] == "error"
|
||||||
|
assert res["http_status"] == 401
|
||||||
@@ -0,0 +1,110 @@
|
|||||||
|
---
|
||||||
|
name: dav_make_addressbook
|
||||||
|
kind: function
|
||||||
|
lang: py
|
||||||
|
domain: infra
|
||||||
|
version: "1.0.0"
|
||||||
|
purity: impure
|
||||||
|
signature: "def dav_make_addressbook(base_url: str, username: str, password: str, contacts_home: str, slug: str, display_name: str = \"\", description: str = \"\", *, timeout_s: float = 20.0, verify_tls: bool = True) -> dict"
|
||||||
|
description: "Crea una nueva coleccion de contactos CardDAV (una libreta/agenda de contactos nueva) bajo el contacts-home de un principal via MKCOL extendido (RFC 5689), declarando el resourcetype como addressbook y fijando el displayname y la descripcion (addressbook-description) en el propio cuerpo XML. La coleccion se crea en <contacts_home><slug>/. El slug se sanea a [a-z0-9_-] (minusculas, espacios->guion); si queda vacio devuelve error de validacion. Idempotente: 201 Created es exito; 405/301 (ya existe) devuelve {status:'ok', existed:True}. Escapa display_name/description para XML. Construye Authorization: Basic base64(user:pass) a mano. Maneja errores sin lanzar (salvo validacion de args). Solo stdlib (urllib, base64, re, ssl, xml.sax.saxutils). Probado contra Xandikos. Analoga de dav_make_calendar para CardDAV."
|
||||||
|
tags: [dav, carddav, addressbook, contacts, mkcol, create, collection, http, infra]
|
||||||
|
uses_functions: []
|
||||||
|
uses_types: []
|
||||||
|
returns: []
|
||||||
|
returns_optional: false
|
||||||
|
error_type: "error_go_core"
|
||||||
|
imports: [base64, re, ssl, urllib.error, urllib.request, xml.sax.saxutils]
|
||||||
|
params:
|
||||||
|
- name: base_url
|
||||||
|
desc: "URL base del servidor DAV sin barra final (p.ej. 'https://dav-eedeb681c4ab89ab8e444ac9.organic-machine.com')."
|
||||||
|
- name: username
|
||||||
|
desc: "usuario para HTTP Basic auth (p.ej. 'enmanuel')."
|
||||||
|
- name: password
|
||||||
|
desc: "contrasena para HTTP Basic auth. Resolver desde pass con pass_get_secret, nunca hardcodear."
|
||||||
|
- name: contacts_home
|
||||||
|
desc: "ruta del contacts-home del principal con barra final (p.ej. '/enmanuel/contacts/'). La nueva coleccion cuelga de el."
|
||||||
|
- name: slug
|
||||||
|
desc: "segmento de path de la coleccion en la URL (p.ej. 'trabajo'); se sanea a [a-z0-9_-]. La coleccion se crea en <contacts_home><slug>/. Si queda vacio tras sanear, devuelve error de validacion."
|
||||||
|
- name: display_name
|
||||||
|
desc: "nombre visible de la coleccion (DAV:displayname). Si vacio, usa el slug saneado."
|
||||||
|
- name: description
|
||||||
|
desc: "descripcion de la coleccion (addressbook-description de CardDAV). Opcional; '' lo omite."
|
||||||
|
- name: timeout_s
|
||||||
|
desc: "timeout de cada peticion HTTP en segundos. Default 20.0."
|
||||||
|
- name: verify_tls
|
||||||
|
desc: "si True (default) verifica el certificado TLS. No desactivar salvo entorno de prueba."
|
||||||
|
output: "dict. En exito: {status:'ok', http_status:int, href:str} y, si la coleccion ya existia, ademas existed:True. En error (sin lanzar): {status:'error', http_status:int|None, href:str, error:str}. href es la ruta de la coleccion (contacts_home + slug saneado + '/')."
|
||||||
|
tested: true
|
||||||
|
tests:
|
||||||
|
- "test_sanitize_slug_minusculas"
|
||||||
|
- "test_sanitize_slug_espacios_a_guion"
|
||||||
|
- "test_sanitize_slug_elimina_caracteres_raros"
|
||||||
|
- "test_sanitize_slug_colapsa_guiones_y_recorta"
|
||||||
|
- "test_sanitize_slug_vacio"
|
||||||
|
- "test_join_url_compone_la_coleccion"
|
||||||
|
- "test_mkcol_xml_es_mkcol_extendido"
|
||||||
|
- "test_mkcol_xml_declara_resourcetype_addressbook"
|
||||||
|
- "test_mkcol_xml_incluye_displayname"
|
||||||
|
- "test_mkcol_xml_escapa_displayname"
|
||||||
|
- "test_mkcol_xml_incluye_y_escapa_descripcion"
|
||||||
|
- "test_mkcol_xml_omite_descripcion_vacia"
|
||||||
|
test_file_path: "python/functions/infra/dav_make_addressbook_test.py"
|
||||||
|
file_path: "python/functions/infra/dav_make_addressbook.py"
|
||||||
|
---
|
||||||
|
|
||||||
|
## Ejemplo
|
||||||
|
|
||||||
|
```python
|
||||||
|
import sys
|
||||||
|
sys.path.insert(0, "python/functions")
|
||||||
|
from infra.pass_get_secret import pass_get_secret
|
||||||
|
from infra.dav_make_addressbook import dav_make_addressbook
|
||||||
|
|
||||||
|
pw = pass_get_secret("dav/xandikos-enmanuel")["value"] # NO logear
|
||||||
|
|
||||||
|
res = dav_make_addressbook(
|
||||||
|
base_url="https://dav-eedeb681c4ab89ab8e444ac9.organic-machine.com",
|
||||||
|
username="enmanuel",
|
||||||
|
password=pw,
|
||||||
|
contacts_home="/enmanuel/contacts/",
|
||||||
|
slug="trabajo",
|
||||||
|
display_name="Trabajo",
|
||||||
|
)
|
||||||
|
print(res)
|
||||||
|
# {'status': 'ok', 'http_status': 201, 'href': '/enmanuel/contacts/trabajo/'}
|
||||||
|
# Volver a llamar con el mismo slug:
|
||||||
|
# {'status': 'ok', 'http_status': 405, 'href': '/enmanuel/contacts/trabajo/', 'existed': True}
|
||||||
|
```
|
||||||
|
|
||||||
|
## Cuando usarla
|
||||||
|
|
||||||
|
Cuando el usuario quiere una libreta/agenda de contactos nueva ademas de la
|
||||||
|
principal: una coleccion CardDAV separada ("Trabajo", "Personal", "Familia") con
|
||||||
|
su propio nombre visible, bajo el contacts-home del principal. Es la analoga de
|
||||||
|
`dav_make_calendar` para CardDAV. El `href` devuelto es la ruta de la coleccion
|
||||||
|
que luego usas para escribir vCards (PUT de cada contacto) o para listarla en el
|
||||||
|
selector de libretas.
|
||||||
|
|
||||||
|
## Gotchas
|
||||||
|
|
||||||
|
- Impura: requiere red + Basic auth contra el servidor DAV. El password viene de
|
||||||
|
`pass`, no se logea ni se hardcodea.
|
||||||
|
- Idempotente: si la coleccion ya existe en ese path el servidor responde 405
|
||||||
|
(Method Not Allowed) o 301; ambos se traducen a `{status:'ok', existed:True}`
|
||||||
|
en vez de error, asi que es seguro reintentar.
|
||||||
|
- A diferencia de los calendarios (que tienen el metodo HTTP dedicado
|
||||||
|
MKCALENDAR), CardDAV NO define un "MKADDRESSBOOK". La creacion se hace con
|
||||||
|
**MKCOL extendido (RFC 5689)**: metodo HTTP `MKCOL` con un cuerpo XML que
|
||||||
|
declara el `resourcetype` como `D:collection` + `C:addressbook`. Probado contra
|
||||||
|
Xandikos, que lo soporta.
|
||||||
|
- Fallback para servidores sin MKCOL extendido: algunos servidores CardDAV viejos
|
||||||
|
no aceptan cuerpo en MKCOL y devuelven 415/400. En ese caso el patron es
|
||||||
|
`MKCOL` simple (sin cuerpo) para crear la coleccion + un `PROPPATCH` posterior
|
||||||
|
que fije el `resourcetype` addressbook, el `displayname` y la
|
||||||
|
`addressbook-description`. Esta funcion implementa solo el camino extendido (un
|
||||||
|
request); si te topas con un servidor que no lo soporta, anade el fallback
|
||||||
|
MKCOL+PROPPATCH antes de promoverlo.
|
||||||
|
- El `slug` se sanea a `[a-z0-9_-]` (minusculas, espacios->guion, resto fuera).
|
||||||
|
Un slug que queda vacio tras sanear (p.ej. solo simbolos) devuelve error de
|
||||||
|
validacion sin tocar la red. El `display_name` y la `description` se escapan
|
||||||
|
para XML, pero el `slug` que va en la URL ya esta restringido al charset seguro.
|
||||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user