diff --git a/.gitignore b/.gitignore index 6b1add2..55cd582 100644 --- a/.gitignore +++ b/.gitignore @@ -11,6 +11,7 @@ cheerfy_pentest_report.md # Backups y temporales *.bak *.tmp +__pycache__/ # Archivos de auth del CLI de Codex/OpenAI .codex diff --git a/changedetection/docker-compose.yml b/changedetection/docker-compose.yml index 74b9b9c..bd81e00 100644 --- a/changedetection/docker-compose.yml +++ b/changedetection/docker-compose.yml @@ -39,5 +39,7 @@ services: networks: changedetection_internal: driver: bridge + labels: + prune.protect: "true" proxy: external: true diff --git a/finance/docker-compose.yml b/finance/docker-compose.yml index acdb2d9..ccd3f80 100644 --- a/finance/docker-compose.yml +++ b/finance/docker-compose.yml @@ -25,5 +25,7 @@ services: networks: finance_internal: driver: bridge + labels: + prune.protect: "true" proxy: external: true diff --git a/guacamole/docker-compose.yml b/guacamole/docker-compose.yml index 1d66b13..5290a57 100644 --- a/guacamole/docker-compose.yml +++ b/guacamole/docker-compose.yml @@ -66,5 +66,7 @@ services: networks: guacamole: driver: bridge + labels: + prune.protect: "true" proxy: external: true diff --git a/it/Dockerfile b/it/Dockerfile new file mode 100644 index 0000000..73b81ad --- /dev/null +++ b/it/Dockerfile @@ -0,0 +1,8 @@ +ARG IT_TOOLS_BASE_IMAGE=ghcr.io/corentinth/it-tools:latest +FROM ${IT_TOOLS_BASE_IMAGE} + +RUN set -eu; \ + asset="$(find /usr/share/nginx/html/assets -name 'ascii-text-drawer-*.js' -print -quit)"; \ + test -n "$asset"; \ + sed -i 's#fontPath:"//unpkg.com/figlet@1.6.0/fonts/"#fontPath:"https://unpkg.com/figlet@1.6.0/fonts"#g' "$asset"; \ + grep -q 'fontPath:"https://unpkg.com/figlet@1.6.0/fonts"' "$asset" diff --git a/it/README.md b/it/README.md index 2857d2e..3d3742c 100644 --- a/it/README.md +++ b/it/README.md @@ -11,6 +11,11 @@ Coleccion de herramientas tecnicas self-hosted. ```bash docker compose --env-file .env config +docker compose --env-file .env build docker compose --env-file .env up -d docker compose --env-file .env logs -f ``` + +El `Dockerfile` aplica un parche local para el generador de ASCII art: corrige +la URL de fuentes FIGlet que en la imagen upstream termina cargando +`fonts//Standard.flf` y falla en navegador por CORS. diff --git a/it/docker-compose.yml b/it/docker-compose.yml index 5641afa..7e8cc36 100644 --- a/it/docker-compose.yml +++ b/it/docker-compose.yml @@ -1,5 +1,9 @@ services: it-tools: + build: + context: . + args: + IT_TOOLS_BASE_IMAGE: ${IT_TOOLS_BASE_IMAGE:-ghcr.io/corentinth/it-tools:latest} image: ${IT_TOOLS_IMAGE} container_name: it-tools restart: unless-stopped @@ -21,5 +25,7 @@ services: networks: it_internal: driver: bridge + labels: + prune.protect: "true" proxy: external: true diff --git a/pdf/docker-compose.yml b/pdf/docker-compose.yml index 023acff..c00da38 100644 --- a/pdf/docker-compose.yml +++ b/pdf/docker-compose.yml @@ -30,5 +30,7 @@ services: networks: pdf_internal: driver: bridge + labels: + prune.protect: "true" proxy: external: true diff --git a/scripts/prune-safe.py b/scripts/prune-safe.py new file mode 100755 index 0000000..bc8f796 --- /dev/null +++ b/scripts/prune-safe.py @@ -0,0 +1,178 @@ +#!/usr/bin/env python3 +"""Careful Docker pruning for this compose/Sablier host. + +The important guardrail is that networks are not pruned by default. Sablier +keeps some containers intentionally stopped, and deleting their compose-created +networks can leave those stopped containers unable to start later. +""" + +from __future__ import annotations + +import argparse +import json +import subprocess +import sys +from dataclasses import dataclass + + +PROTECT_LABEL = "prune.protect" +PROTECT_VALUE = "true" +NETWORK_NEVER_REMOVE = {"bridge", "host", "none", "proxy", "mail_internal"} + + +@dataclass +class DockerObject: + id: str + name: str + labels: dict[str, str] + state: str = "" + + @property + def protected(self) -> bool: + return self.labels.get(PROTECT_LABEL, "").lower() == PROTECT_VALUE + + +def docker(args: list[str], *, check: bool = True) -> str: + result = subprocess.run( + ["docker", *args], + check=False, + text=True, + stdout=subprocess.PIPE, + stderr=subprocess.PIPE, + ) + if check and result.returncode != 0: + sys.stderr.write(result.stderr) + raise SystemExit(result.returncode) + return result.stdout + + +def parse_labels(raw: str | None) -> dict[str, str]: + labels: dict[str, str] = {} + if not raw: + return labels + for item in raw.split(","): + if not item: + continue + key, sep, value = item.partition("=") + labels[key] = value if sep else "" + return labels + + +def list_containers() -> list[DockerObject]: + rows = docker(["container", "ls", "-a", "--format", "{{json .}}"]).splitlines() + containers: list[DockerObject] = [] + for row in rows: + data = json.loads(row) + containers.append( + DockerObject( + id=data["ID"], + name=data["Names"], + labels=parse_labels(data.get("Labels")), + state=data.get("State", ""), + ) + ) + return containers + + +def list_networks() -> list[DockerObject]: + rows = docker(["network", "ls", "--format", "{{json .}}"]).splitlines() + networks: list[DockerObject] = [] + for row in rows: + data = json.loads(row) + networks.append( + DockerObject( + id=data["ID"], + name=data["Name"], + labels=parse_labels(data.get("Labels")), + ) + ) + return networks + + +def network_has_containers(network_id: str) -> bool: + raw = docker(["network", "inspect", network_id, "--format", "{{json .Containers}}"]) + containers = json.loads(raw) + return bool(containers) + + +def remove_containers(dry_run: bool) -> int: + removable_states = {"created", "exited", "dead"} + candidates = [ + c + for c in list_containers() + if c.state.lower() in removable_states and not c.protected + ] + if not candidates: + print("containers: none") + return 0 + print("containers:") + for container in candidates: + print(f" remove {container.name} ({container.state})") + if not dry_run: + docker(["container", "rm", container.id]) + return len(candidates) + + +def prune_images(dry_run: bool) -> None: + cmd = ["image", "prune", "-af", "--filter", f"label!={PROTECT_LABEL}={PROTECT_VALUE}"] + print("images:") + if dry_run: + print(" would run: docker " + " ".join(cmd)) + return + print(docker(cmd).rstrip() or " none") + + +def prune_build_cache(dry_run: bool) -> None: + cmd = ["builder", "prune", "-af"] + print("builder-cache:") + if dry_run: + print(" would run: docker " + " ".join(cmd)) + return + print(docker(cmd).rstrip() or " none") + + +def prune_networks(dry_run: bool) -> int: + candidates: list[DockerObject] = [] + for network in list_networks(): + if network.name in NETWORK_NEVER_REMOVE or network.protected: + continue + if network_has_containers(network.id): + continue + candidates.append(network) + + if not candidates: + print("networks: none") + return 0 + + print("networks:") + for network in candidates: + print(f" remove {network.name}") + if not dry_run: + docker(["network", "rm", network.id]) + return len(candidates) + + +def main() -> int: + parser = argparse.ArgumentParser() + parser.add_argument("--dry-run", action="store_true", help="show what would be removed") + parser.add_argument( + "--prune-networks", + action="store_true", + help="also remove unused, unprotected networks", + ) + args = parser.parse_args() + + print(f"mode: {'dry-run' if args.dry_run else 'execute'}") + removed_containers = remove_containers(args.dry_run) + prune_images(args.dry_run) + prune_build_cache(args.dry_run) + if args.prune_networks: + prune_networks(args.dry_run) + else: + print("networks: skipped") + print(f"summary: removable_containers={removed_containers}") + return 0 + + +if __name__ == "__main__": + raise SystemExit(main()) diff --git a/scripts/prune-safe.sh b/scripts/prune-safe.sh index 1acf112..8d24829 100755 --- a/scripts/prune-safe.sh +++ b/scripts/prune-safe.sh @@ -1,4 +1,10 @@ #!/usr/bin/env sh set -eu -docker system prune -af --filter "label!=prune.protect=true" +SCRIPT_PATH="$0" +if command -v readlink >/dev/null 2>&1; then + SCRIPT_PATH="$(readlink -f "$0")" +fi +SCRIPT_DIR="$(dirname "$SCRIPT_PATH")" + +exec python3 "$SCRIPT_DIR/prune-safe.py" "$@"