diff --git a/crowdsec/Dockerfile b/crowdsec/Dockerfile new file mode 100644 index 0000000..1ca2151 --- /dev/null +++ b/crowdsec/Dockerfile @@ -0,0 +1,6 @@ +FROM crowdsecurity/crowdsec:latest + +COPY config/ /etc/crowdsec +# Install required components at build time +RUN cscli hub update && \ + cscli collections install crowdsecurity/traefik --force diff --git a/default-environment.env b/default-environment.env new file mode 100644 index 0000000..3e5be79 --- /dev/null +++ b/default-environment.env @@ -0,0 +1,18 @@ +PROJECT_ROOT=/home/raspi/raspi +REMOTE_PROJECT_ROOT=/home/nixos/raspi +DOMAIN=lan.ddnsgeek.com +TZ=Australia/Brisbane +EMAIL=wayne.bennett@live.com + +# Docker Socket Proxy +DOCKER_SOCKET_PROXY_LOG_LEVEL=info +DOCKER_SOCKET_PROXY_HOST=tcp://docker-socket-proxy:2375 + +# Docker Update Exporter +DOCKER_EXPORTER_LOG_LEVEL=INFO + +# Crowdsec +CROWDSEC_COLLECTIONS=crowdsecurity/traefik + + + diff --git a/docker-compose.yml b/docker-compose.yml new file mode 100644 index 0000000..054b785 --- /dev/null +++ b/docker-compose.yml @@ -0,0 +1,179 @@ +version: "3.9" + +services: + traefik: + profiles: ["all"] + image: traefik:3 + container_name: traefik + restart: always + depends_on: + - docker-socket-proxy +# environment: +# TRAEFIK_PROVIDERS_DOCKER_ENDPOINT: ${DOCKER_SOCKET_PROXY_HOST} + ports: + - "80:80" + - "443:443" + - "8080:8080" + volumes: +# - /var/run/docker.sock:/var/run/docker.sock:ro + - ${PROJECT_ROOT}/traefik/letsencrypt:/letsencrypt + - ${PROJECT_ROOT}/traefik/traefik.yml:/etc/traefik/traefik.yml:ro + - ${PROJECT_ROOT}/traefik/dynamic.yml:/etc/traefik/dynamic.yml:ro + networks: + - proxy + + uptime-kuma: + profiles: ["all"] + image: louislam/uptime-kuma + container_name: uptime-kuma + restart: always + depends_on: + - traefik + - docker-socket-proxy + environment: + DOCKER_HOST: ${DOCKER_SOCKET_PROXY_HOST} + volumes: + - ${PROJECT_ROOT}/uptime-kuma:/app/data +# - /var/run/docker.sock:/var/run/docker.sock + networks: + - proxy + labels: + - traefik.enable=true + + # Router + - traefik.http.routers.kuma.rule=Host(`kuma.lan.ddnsgeek.com`) + - traefik.http.routers.kuma.entrypoints=websecure + - traefik.http.routers.kuma.tls=true + - traefik.http.routers.kuma.tls.certresolver=le + + # Service -> container port + - traefik.http.services.kuma.loadbalancer.server.port=3001 +# - traefik.http.routers.kuma.middlewares=default-chain@file +# ports: +# - 8080:3001 + + portainer-agent: + profiles: ["all"] + image: portainer/agent + restart: always + depends_on: + - docker-socket-proxy + ports: + - 9001:9001 + container_name: portainer-agent + environment: + DOCKER_HOST: ${DOCKER_SOCKET_PROXY_HOST} + volumes: + # - /var/run/docker.sock:/var/run/docker.sock + - /var/lib/docker/volumes:/var/lib/docker/volumes + - /:/host + networks: + - proxy + + crowdsec: + profiles: ["all"] + image: crowdsecurity/crowdsec:latest +# build: ./crowdsec + container_name: crowdsec + restart: always + environment: + - COLLECTIONS=${CROWDSEC_COLLECTIONS} + volumes: + - ${PROJECT_ROOT}/data/logs:/logs:ro + - ${PROJECT_ROOT}/crowdsec/data:/var/lib/crowdsec/data + - ${PROJECT_ROOT}/crowdsec/config:/etc/crowdsec + networks: + - proxy + + docker-update-exporter: + profiles: ["all"] + build: ${REMOTE_PROJECT_ROOT}/docker-exporter + container_name: docker-update-exporter + volumes: +# - /var/run/docker.sock:/var/run/docker.sock + - ${PROJECT_ROOT}:/compose:ro +# - ${PROJECT_ROOT}/docker-compose.yml:/compose/docker-compose.yml:ro + - ${REMOTE_PROJECT_ROOT}/docker-exporter/data:/data:rw + - ${PROJECT_ROOT}/.docker/config.json:/root/.docker/config.json:ro + environment: + LOG_LEVEL: ${DOCKER_EXPORTER_LOG_LEVEL} + DOCKER_HOST: ${DOCKER_SOCKET_PROXY_HOST} + ports: + - "9105:9105" + restart: unless-stopped + depends_on: + - docker-socket-proxy + cap_drop: + - ALL + security_opt: + - no-new-privileges:true + networks: + - proxy + healthcheck: + test: ["CMD", "python", "-c", "import urllib.request; urllib.request.urlopen('http://localhost:9105/metrics')"] + interval: 30s + timeout: 5s + retries: 3 + start_period: 10s + + telegraf: + profiles: ["monitoring","all","prometheus"] + image: telegraf:latest +# group_add: +# - "123" +# privileged: true + container_name: telegraf + restart: unless-stopped + depends_on: + - docker-socket-proxy +# cap_drop: +# - ALL + security_opt: + - no-new-privileges:true + volumes: + # - /var/run/docker.sock:/var/run/docker.sock:ro + - ${PROJECT_ROOT}/telegraf.conf:/etc/telegraf/telegraf.conf:ro + networks: +# - edge + - proxy + ports: + - 9273:9273 + healthcheck: + test: ["CMD-SHELL", "curl -f http://localhost:9273/metrics || exit 1"] + interval: 30s + timeout: 5s + retries: 3 + start_period: 10s + + docker-socket-proxy: + profiles: ["monitoring","all","prometheus","prometheus-exporters"] + image: tecnativa/docker-socket-proxy:latest + container_name: docker-socket-proxy + restart: unless-stopped + environment: + LOG_LEVEL: ${DOCKER_SOCKET_PROXY_LOG_LEVEL} + DISTRIBUTION: 1 + CONTAINERS: 1 + EVENTS: 1 + IMAGES: 1 + INFO: 1 + NETWORKS: 1 + PING: 1 + POST: 1 + SERVICES: 1 + TASKS: 1 + VERSION: 1 + VOLUMES: 1 + volumes: + - /var/run/docker.sock:/var/run/docker.sock:ro + cap_drop: + - ALL + security_opt: + - no-new-privileges:true + networks: + - proxy + + +networks: + proxy: + name: proxy diff --git a/docker-exporter/Dockerfile b/docker-exporter/Dockerfile new file mode 100644 index 0000000..7d741b7 --- /dev/null +++ b/docker-exporter/Dockerfile @@ -0,0 +1,15 @@ +FROM python:3.11-slim + +#RUN groupadd -g 1000 appuser || true && \ +# useradd -m -u 1000 -g 1000 -s /bin/bash appuser +#RUN groupadd -g 999 docker || true && usermod -aG docker appuser +WORKDIR /app +COPY exporter.py . + +RUN mkdir -p /data + +RUN pip install --no-cache-dir docker prometheus_client requests pyyaml + +#USER appuser + +CMD ["python", "-u", "exporter.py"] diff --git a/docker-exporter/Dockerfile.old b/docker-exporter/Dockerfile.old new file mode 100644 index 0000000..9981774 --- /dev/null +++ b/docker-exporter/Dockerfile.old @@ -0,0 +1,8 @@ +FROM python:3.11-slim + +WORKDIR /app +COPY exporter.py . + +RUN pip install docker prometheus_client requests pyyaml + +CMD ["python", "exporter.py"] diff --git a/docker-exporter/TEST_EXAMPLE.md b/docker-exporter/TEST_EXAMPLE.md new file mode 100644 index 0000000..d6af05b --- /dev/null +++ b/docker-exporter/TEST_EXAMPLE.md @@ -0,0 +1,24 @@ +# Exporter image-mapping test example + +Run the exporter in dry-run mode to print the `service -> image:tag` mapping without starting the metrics loop: + +```bash +SERVICES_UP_SCRIPT=/workspace/docker/services-up.sh python monitoring/docker-exporter/exporter.py --dry-run +``` + +Example output excerpt: + +```json +{ + "crowdsec": "crowdsecurity/crowdsec:latest", + "docker-update-exporter": "python:3.11-slim", + "nextcloud-webapp": "nextcloud:production", + "node-red": "nodered/node-red:latest", + "prometheus": "prom/prometheus:latest", + "traefik": "traefik:3" +} +``` + +This confirms the exporter now reports images for both: +- services with explicit `image:` values, and +- services using `build:` contexts. diff --git a/docker-exporter/exporter.py b/docker-exporter/exporter.py new file mode 100644 index 0000000..1d82665 --- /dev/null +++ b/docker-exporter/exporter.py @@ -0,0 +1,528 @@ +#!/usr/bin/env python3 +import argparse +import os +import re +import time +import json +import logging +import docker +import yaml +from prometheus_client import Gauge, start_http_server + +# --- Logging --- +LOG_LEVEL = os.getenv("LOG_LEVEL", "DEBUG").upper() +logging.basicConfig( + level=getattr(logging, LOG_LEVEL, logging.DEBUG), + format="%(asctime)s [%(levelname)s] %(message)s" +) +logger = logging.getLogger("docker-update-exporter") + +# --- Config --- +EXPORTER_PORT = 9105 +CHECK_INTERVAL = 3600 +CACHE_TTL = int(os.getenv("CACHE_TTL", "300")) +SERVICES_UP_SCRIPT = os.getenv("SERVICES_UP_SCRIPT", "/compose/services-up.sh") +CACHE_FILE = os.getenv("CACHE_FILE", "/data/remote_digest_cache.json") +DRY_RUN = os.getenv("DRY_RUN", "false").lower() in ("1", "true", "yes") + +try: + client = docker.from_env() +except Exception as e: + logger.warning(f"Docker client unavailable at startup: {e}") + client = None + +# --- Metrics --- +CONTAINER_UPDATE = Gauge( + "docker_container_update_available", + "1 if container image is out of date (compose drift or registry), 0 otherwise", + ["container", "compose_image", "running_image", "com_docker_compose_project"] +) + +LAST_CHECK = Gauge( + "docker_image_update_last_check_timestamp", + "Last time the update check ran (unix timestamp)" +) + + +def set_container_update_metric(container_name, compose_image, running_image, project_name, update_flag): + """Set update metric for a container and log the emitted metric payload.""" + metric_labels = { + "container": container_name, + "compose_image": compose_image or "unknown", + "running_image": running_image, + "com_docker_compose_project": project_name, + } + CONTAINER_UPDATE.labels(**metric_labels).set(update_flag) + logger.info( + "Metric emitted: docker_container_update_available=%s labels=%s", + update_flag, + metric_labels, + ) + + +def set_last_check_metric(): + """Set and log the timestamp for the most recent check cycle.""" + ts = time.time() + LAST_CHECK.set(ts) + logger.info("Metric emitted: docker_image_update_last_check_timestamp=%s", ts) + +# --- Persistent Cache --- +def load_cache(): + if not os.path.exists(CACHE_FILE): + logger.info(f"Cache file does not exist yet: {CACHE_FILE}") + return {} + try: + with open(CACHE_FILE, "r") as f: + cache = json.load(f) + logger.info(f"Loaded {len(cache)} cached remote digests") + return cache + except Exception as e: + logger.error(f"Failed to load cache: {e}") + return {} + +def save_cache(): + try: + os.makedirs(os.path.dirname(CACHE_FILE), exist_ok=True) + with open(CACHE_FILE, "w") as f: + json.dump(REMOTE_DIGEST_CACHE, f) + logger.debug(f"Saved {len(REMOTE_DIGEST_CACHE)} remote digests to cache") + except Exception as e: + logger.error(f"Failed to save cache: {e}") + +REMOTE_DIGEST_CACHE = load_cache() +now = time.time() +REMOTE_DIGEST_CACHE = { + image: (digest, ts) + for image, (digest, ts) in REMOTE_DIGEST_CACHE.items() + if now - ts < CACHE_TTL +} + +# --- Helpers --- +def get_project_prefix_from_script(script_path): + prefix = "core-" + if not os.path.exists(script_path): + return prefix + try: + with open(script_path) as f: + for line in f: + m = re.match(r'PROJECT\s*=\s*["\']?([^"\']+)', line) + if m: + return m.group(1) + "-" + except Exception as e: + logger.warning(f"Failed reading project prefix: {e}") + return prefix + +def get_local_digest(image_name): + """ + Return the local digest for the specific image reference. + """ + if client is None: + return None + + try: + img = client.images.get(image_name) + digests = img.attrs.get("RepoDigests", []) + + logger.debug(f"RepoDigests for {image_name}: {digests}") + + for entry in digests: + if "@" in entry: + digest = entry.split("@", 1)[1] + logger.debug(f"Local digest for {image_name}: {digest}") + return digest + + logger.debug(f"No RepoDigest found for {image_name}") + + except Exception as e: + logger.debug(f"Could not get local digest for {image_name}: {e}") + + return None + +def get_remote_digest(image_name): + """ + Return the upstream digest for the exact platform-specific image that Docker + would pull on this host. This avoids false positives with multi-arch images + where the registry manifest-list digest differs from the pulled image digest. + """ + now = time.time() + + cached = REMOTE_DIGEST_CACHE.get(image_name) + if cached: + digest, ts = cached + if now - ts < CACHE_TTL: + logger.debug(f"Using cached remote digest for {image_name}: {digest}") + return digest + + if client is None: + return None + + try: + registry_data = client.images.get_registry_data(image_name) + + digest = None + + # docker SDK versions differ; try the common fields in order + if hasattr(registry_data, "id") and registry_data.id: + digest = registry_data.id + elif hasattr(registry_data, "attrs"): + digest = ( + registry_data.attrs.get("Descriptor", {}).get("digest") + or registry_data.attrs.get("digest") + ) + + if digest: + REMOTE_DIGEST_CACHE[image_name] = (digest, now) + save_cache() + logger.debug(f"Remote digest for {image_name}: {digest}") + return digest + + logger.warning(f"No remote digest found for {image_name}") + return None + except Exception as e: + logger.debug(f"Error fetching remote digest for {image_name}: {e}") + + return None + +# --- Dockerfile Image Extraction --- +def parse_dockerfile_for_image(dockerfile_path): + if not os.path.exists(dockerfile_path): + return None + + try: + arg_defaults = {} + last_from = None + with open(dockerfile_path) as df: + for line in df: + line = line.strip() + if not line or line.startswith("#"): + continue + + if line.upper().startswith("ARG "): + arg_body = line[4:].strip() + if "=" in arg_body: + key, value = arg_body.split("=", 1) + arg_defaults[key.strip()] = value.strip() + continue + + # Prefer LABEL with image if present. + if "LABEL" in line and "image=" in line: + match = re.search(r'image=["\']?([^"\']+)["\']?', line) + if match: + image_name = normalize_image_name(substitute_dockerfile_args(match.group(1), arg_defaults)) + logger.debug(f"Found LABEL image={image_name} in {dockerfile_path}") + return image_name + + if line.upper().startswith("FROM "): + from_clause = line[5:].strip() + if from_clause.startswith("--"): + split_clause = from_clause.split(None, 1) + if len(split_clause) < 2: + continue + from_clause = split_clause[1] + + parts = from_clause.split() + if not parts: + continue + + candidate = substitute_dockerfile_args(parts[0], arg_defaults) + if candidate and candidate.lower() != "scratch": + last_from = normalize_image_name(candidate) + + if last_from: + logger.debug(f"Found base FROM {last_from} in {dockerfile_path}") + return last_from + except Exception as e: + logger.debug(f"Error reading Dockerfile {dockerfile_path}: {e}") + + return None + +def normalize_image_name(image_name): + if not image_name: + return None + if "@" in image_name: + return image_name + if ":" in image_name.rsplit("/", 1)[-1]: + return image_name + return f"{image_name}:latest" + +def is_compose_build_placeholder(image_name, project_name): + if not image_name: + return False + candidate = str(image_name) + project_prefix = f"{project_name}-" + if candidate.startswith(project_prefix): + return True + # Keep backward-compatible behavior for historical default project prefix. + return candidate.startswith("core-") + +def substitute_dockerfile_args(value, arg_defaults): + if not value: + return value + + pattern = re.compile(r"\$\{([^}]+)\}|\$([A-Za-z_][A-Za-z0-9_]*)") + + def replacer(match): + expr = match.group(1) + simple = match.group(2) + if simple: + return arg_defaults.get(simple, "") + + if ":-" in expr: + var_name, default_value = expr.split(":-", 1) + return arg_defaults.get(var_name, default_value) + if "-" in expr: + var_name, default_value = expr.split("-", 1) + return arg_defaults.get(var_name, default_value) + return arg_defaults.get(expr, "") + + return pattern.sub(replacer, value) + +def expand_compose_path(path_value, project_root): + raw = str(path_value) + raw = raw.replace("${PROJECT_ROOT}", project_root).replace("$PROJECT_ROOT", project_root) + return os.path.expandvars(raw) + +def get_project_root_from_script(script_path): + if not script_path: + return os.getcwd() + return os.path.dirname(os.path.abspath(script_path)) + +# --- Compose parsing --- +def get_compose_files_from_script(script_path): + files = [] + if not os.path.exists(script_path): + return files + base_dir = get_project_root_from_script(script_path) + try: + with open(script_path) as f: + content = f.read() + match = re.search(r'FILES\s*=\s*\((.*?)\)', content, re.DOTALL) + if match: + for line in match.group(1).splitlines(): + line = line.strip() + if line.startswith("-f"): + path = line[2:].strip() + if path: + full = os.path.normpath(os.path.join(base_dir, path)) + files.append(full) + except Exception as e: + logger.warning(f"Failed parsing services-up.sh: {e}") + return files + +def parse_project_name_from_script(script_path): + project = "core" + if not os.path.exists(script_path): + return project + try: + with open(script_path) as f: + for line in f: + m = re.match(r'PROJECT\s*=\s*["\']?([^"\']+)', line) + if m: + project = m.group(1) + break + except Exception as e: + logger.warning(f"Failed reading project name: {e}") + return project + +def resolve_local_build_image(service_name, project_name): + if client is None: + return None + try: + images = client.images.list(filters={"label": f"com.docker.compose.service={service_name}"}) + for image in images: + labels = image.attrs.get("Config", {}).get("Labels", {}) or {} + if labels.get("com.docker.compose.project") != project_name: + continue + for tag in image.tags: + if tag and "" not in tag: + logger.debug(f"Resolved local compose image for {service_name}: {tag}") + return normalize_image_name(tag) + except Exception as e: + logger.debug(f"Could not inspect local build metadata for {service_name}: {e}") + return None + +def parse_compose_services(compose_files, project_name, project_root): + svc_map = {} + for f in compose_files: + if not os.path.exists(f): + logger.warning(f"Compose file from services-up.sh is missing: {f}") + continue + try: + with open(f) as stream: + data = yaml.safe_load(stream) or {} + for svc_name, svc_def in data.get("services", {}).items(): + image = normalize_image_name(svc_def.get("image")) + profiles = svc_def.get("profiles", []) + build_ctx = svc_def.get("build") + dockerfile_path = None + from_dockerfile = None + local_built_image = None + + if build_ctx: + if isinstance(build_ctx, dict): + context = build_ctx.get("context", ".") + dockerfile = build_ctx.get("dockerfile", "Dockerfile") + else: + context = build_ctx + dockerfile = "Dockerfile" + + compose_dir = os.path.dirname(f) + context_expanded = expand_compose_path(context, project_root) + if os.path.isabs(context_expanded): + context_path = context_expanded + else: + context_path = os.path.normpath(os.path.join(compose_dir, context_expanded)) + dockerfile_expanded = expand_compose_path(dockerfile, project_root) + dockerfile_path = os.path.normpath(os.path.join(context_path, dockerfile_expanded)) + from_dockerfile = normalize_image_name(parse_dockerfile_for_image(dockerfile_path)) + local_built_image = resolve_local_build_image(svc_name, project_name) + + placeholder_image = is_compose_build_placeholder(image, project_name) or is_compose_build_placeholder(local_built_image, project_name) + if placeholder_image: + resolved_image = from_dockerfile or image or local_built_image or f"{project_name}-{svc_name}:latest" + else: + resolved_image = image or local_built_image or from_dockerfile or f"{project_name}-{svc_name}:latest" + + svc_map[svc_name] = { + "image": resolved_image, + "profiles": profiles, + "build_context": build_ctx, + "compose_file": f, + "dockerfile": dockerfile_path + } + except Exception as e: + logger.warning(f"Failed parsing {f}: {e}") + + logger.debug(f"Service image mapping: {svc_map}") + return svc_map + +# --- Main check --- +def check_containers(): + if client is None: + logger.error("Docker client is unavailable; skipping check cycle") + return + + set_last_check_metric() + CONTAINER_UPDATE.clear() + + project_name = parse_project_name_from_script(SERVICES_UP_SCRIPT) + project_root = get_project_root_from_script(SERVICES_UP_SCRIPT) + compose_files = get_compose_files_from_script(SERVICES_UP_SCRIPT) + svc_map = parse_compose_services(compose_files, project_name, project_root) + + containers = client.containers.list() + pending_metrics = [] + remote_targets = set() + + for container in containers: + proj = container.labels.get("com.docker.compose.project") + if not proj: + continue + + svc = container.labels.get("com.docker.compose.service") + running = container.attrs["Config"]["Image"] + + compose_image = None + if svc in svc_map: + compose_image = svc_map[svc]["image"] + + local_digest = get_local_digest(running) + remote_target = compose_image or running + + # If we cannot determine a local digest, we cannot compare and should + # avoid spending a registry lookup for this container. + if local_digest: + remote_targets.add(remote_target) + + pending_metrics.append({ + "container_name": container.name, + "service": svc, + "compose_image": compose_image, + "running_image": running, + "project_name": proj, + "remote_target": remote_target, + "local_digest": local_digest, + }) + + remote_digests = {target: get_remote_digest(target) for target in remote_targets} + + for payload in pending_metrics: + local_digest = payload["local_digest"] + remote_target = payload["remote_target"] + remote_digest = remote_digests.get(remote_target) + update_flag = 1 if (local_digest and remote_digest and local_digest != remote_digest) else 0 + + logger.info( + "Digest comparison: container=%s service=%s running=%s target=%s local=%s remote=%s", + payload["container_name"], + payload["service"], + payload["running_image"], + remote_target, + local_digest, + remote_digest, + ) + + set_container_update_metric( + container_name=payload["container_name"], + compose_image=payload["compose_image"], + running_image=payload["running_image"], + project_name=payload["project_name"], + update_flag=update_flag, + ) + +def dump_service_image_mapping(): + project_name = parse_project_name_from_script(SERVICES_UP_SCRIPT) + project_root = get_project_root_from_script(SERVICES_UP_SCRIPT) + compose_files = get_compose_files_from_script(SERVICES_UP_SCRIPT) + svc_map = parse_compose_services(compose_files, project_name, project_root) + mapping = {name: data["image"] for name, data in sorted(svc_map.items())} + logger.info("Service to image mapping:") + logger.info(json.dumps(mapping, indent=2, sort_keys=True)) + return mapping + +# --- Runner --- +if __name__ == "__main__": + parser = argparse.ArgumentParser(description="Docker image update exporter") + parser.add_argument("--dry-run", action="store_true", help="Only print service->image mapping and exit") + parser.add_argument( + "--services-up-script", + default=SERVICES_UP_SCRIPT, + help=f"Path to services-up script (default: {SERVICES_UP_SCRIPT})", + ) + parser.add_argument( + "--cache-file", + default=CACHE_FILE, + help=f"Path to digest cache file (default: {CACHE_FILE})", + ) + parser.add_argument( + "--log-level", + default=LOG_LEVEL, + help=f"Logging level (default: {LOG_LEVEL})", + ) + args = parser.parse_args() + + effective_log_level = str(args.log_level).upper() + logging.getLogger().setLevel(getattr(logging, effective_log_level, logging.DEBUG)) + logger.setLevel(getattr(logging, effective_log_level, logging.DEBUG)) + + SERVICES_UP_SCRIPT = args.services_up_script + CACHE_FILE = args.cache_file + REMOTE_DIGEST_CACHE = load_cache() + now = time.time() + REMOTE_DIGEST_CACHE = { + image: (digest, ts) + for image, (digest, ts) in REMOTE_DIGEST_CACHE.items() + if now - ts < CACHE_TTL + } + + if DRY_RUN or args.dry_run: + dump_service_image_mapping() + raise SystemExit(0) + + start_http_server(EXPORTER_PORT) + while True: + try: + check_containers() + except Exception as e: + logger.exception(f"update check failed: {e}") + time.sleep(CHECK_INTERVAL) diff --git a/docker-exporter/exporter.py.old b/docker-exporter/exporter.py.old new file mode 100644 index 0000000..3d0c5f3 --- /dev/null +++ b/docker-exporter/exporter.py.old @@ -0,0 +1,514 @@ +#!/usr/bin/env python3 +import os +import re +import time +import json +import logging +import docker +import requests +import yaml +from prometheus_client import Gauge, start_http_server + +# --- Logging --- +LOG_LEVEL = os.getenv("LOG_LEVEL", "INFO").upper() + +logging.basicConfig( + level=getattr(logging, LOG_LEVEL, logging.INFO), + format="%(asctime)s [%(levelname)s] %(message)s" +) + +logger = logging.getLogger("docker-update-exporter") + +# --- Config --- +EXPORTER_PORT = 9105 +CHECK_INTERVAL = 60 +CACHE_TTL = 6 * 3600 +SERVICES_UP_SCRIPT = "/compose/services-up.sh" +CACHE_FILE = "/data/remote_digest_cache.json" + +client = docker.from_env() + +# --- Metrics --- +CONTAINER_UPDATE = Gauge( + "docker_container_update_available", + "1 if container image is out of date (compose drift or registry), 0 otherwise", + ["container", "compose_image", "running_image", "com_docker_compose_project"] +) + +LAST_CHECK = Gauge( + "docker_image_update_last_check_timestamp", + "Last time the update check ran (unix timestamp)" +) + +# --- Persistent Cache --- + +def load_cache(): + if not os.path.exists(CACHE_FILE): + logger.info(f"Cache file does not exist yet: {CACHE_FILE}") + return {} + + try: + with open(CACHE_FILE, "r") as f: + cache = json.load(f) + logger.info(f"Loaded {len(cache)} cached remote digests") + logger.debug(f"Cache contents: {cache}") + return cache + except Exception as e: + logger.error(f"Failed to load cache from {CACHE_FILE}: {e}") + return {} + +def save_cache(): + try: + os.makedirs(os.path.dirname(CACHE_FILE), exist_ok=True) + with open(CACHE_FILE, "w") as f: + json.dump(REMOTE_DIGEST_CACHE, f) + + logger.debug( + f"Saved {len(REMOTE_DIGEST_CACHE)} entries to cache file {CACHE_FILE}" + ) + except Exception as e: + logger.error(f"Failed to save cache to {CACHE_FILE}: {e}") + +REMOTE_DIGEST_CACHE = load_cache() + +# --- Helpers --- + +def get_project_prefix_from_script(script_path): + project_prefix = "core-" # fallback + + if not os.path.exists(script_path): + logger.warning( + f"services-up script not found at {script_path}, using fallback project prefix {project_prefix}" + ) + return project_prefix + + try: + with open(script_path, "r") as f: + for line in f: + line = line.strip() + m = re.match(r'PROJECT\s*=\s*["\']?([^"\']+)["\']?', line) + if m: + project_prefix = m.group(1) + "-" + logger.debug( + f"Detected compose project prefix from script: {project_prefix}" + ) + break + except Exception as e: + logger.error(f"Failed reading project prefix from {script_path}: {e}") + + return project_prefix + +def get_local_digest(image_name): + try: + img = client.images.get(image_name) + digests = img.attrs.get("RepoDigests", []) + + logger.debug(f"Local RepoDigests for {image_name}: {digests}") + + if digests: + digest = digests[0].split("@")[1] + logger.debug(f"Local digest for {image_name}: {digest}") + return digest + + logger.info(f"No local digest found for image {image_name}") + + except Exception as e: + logger.warning(f"Failed to retrieve local digest for {image_name}: {e}") + + return None + +def get_remote_digest(image_name): + now = time.time() + original = image_name + + # Cache hit + if original in REMOTE_DIGEST_CACHE: + digest, ts = REMOTE_DIGEST_CACHE[original] + age = now - ts + + if age < CACHE_TTL: + logger.debug( + f"Using cached remote digest for {original} " + f"(age={int(age)}s, ttl={CACHE_TTL}s): {digest}" + ) + return digest + + logger.info( + f"Cache entry expired for {original} " + f"(age={int(age)}s > ttl={CACHE_TTL}s)" + ) + + try: + if "/" not in image_name: + registry = "docker.io" + repo = "library/" + image_name + else: + parts = image_name.split("/") + if "." in parts[0] or ":" in parts[0]: + registry = parts[0] + repo = "/".join(parts[1:]) + else: + registry = "docker.io" + repo = image_name + + if ":" in repo: + repo, tag = repo.rsplit(":", 1) + else: + tag = "latest" + + logger.debug( + f"Resolving remote digest for {original}: " + f"registry={registry}, repo={repo}, tag={tag}" + ) + + token = None + manifest_url = None + + if registry in ["docker.io", "registry-1.docker.io"]: + logger.debug(f"Requesting Docker Hub token for {repo}") + + token_res = requests.get( + "https://auth.docker.io/token", + params={ + "service": "registry.docker.io", + "scope": f"repository:{repo}:pull" + }, + timeout=10 + ) + + logger.debug( + f"Docker Hub token response for {repo}: " + f"status={token_res.status_code}" + ) + + token = token_res.json().get("token") + manifest_url = ( + f"https://registry-1.docker.io/v2/{repo}/manifests/{tag}" + ) + + elif registry == "ghcr.io": + logger.debug(f"Requesting GHCR token for {repo}") + + token_res = requests.get( + "https://ghcr.io/token", + params={ + "service": "ghcr.io", + "scope": f"repository:{repo}:pull" + }, + timeout=10 + ) + + logger.debug( + f"GHCR token response for {repo}: " + f"status={token_res.status_code}" + ) + + token = token_res.json().get("token") + manifest_url = f"https://ghcr.io/v2/{repo}/manifests/{tag}" + + else: + logger.warning( + f"Unsupported registry '{registry}' for image {original}" + ) + return None + + if not token: + logger.warning( + f"No authentication token returned for {original}" + ) + return None + + logger.debug(f"Requesting manifest for {original}: {manifest_url}") + + res = requests.get( + manifest_url, + headers={ + "Authorization": f"Bearer {token}", + "Accept": "application/vnd.docker.distribution.manifest.v2+json" + }, + timeout=10 + ) + + logger.debug( + f"Manifest response for {original}: " + f"status={res.status_code}" + ) + + if res.status_code == 200: + digest = res.headers.get("Docker-Content-Digest") + + logger.info( + f"Fetched remote digest for {original}: {digest}" + ) + + REMOTE_DIGEST_CACHE[original] = (digest, now) + save_cache() + + logger.debug( + f"Cached remote digest for {original}: {digest}" + ) + + return digest + + if res.status_code == 429: + logger.warning( + f"Registry rate limit hit while fetching {original}" + ) + elif res.status_code in [401, 403]: + logger.warning( + f"Authentication failed while fetching {original}: " + f"status={res.status_code}" + ) + else: + logger.warning( + f"Unexpected manifest response for {original}: " + f"status={res.status_code}, body={res.text[:250]}" + ) + + except Exception as e: + logger.error(f"Failed to fetch remote digest for {original}: {e}") + + return None + +def get_compose_files_from_script(script_path): + files = [] + + if not os.path.exists(script_path): + logger.error(f"services-up script not found: {script_path}") + return files + + base_dir = os.path.dirname(script_path) + + try: + with open(script_path, "r") as f: + content = f.read() + + match = re.search(r'FILES\s*=\s*\((.*?)\)', content, re.DOTALL) + + if not match: + logger.warning( + f"No FILES=(...) block found in {script_path}" + ) + return files + + lines = match.group(1).splitlines() + + for line in lines: + line = line.strip() + + if line.startswith("-f"): + rel_path = line[2:].strip() + + if rel_path: + full_path = os.path.normpath( + os.path.join(base_dir, rel_path) + ) + + logger.debug( + f"Resolved compose file: {rel_path} -> {full_path}" + ) + + files.append(full_path) + + logger.info(f"Found {len(files)} compose files") + + except Exception as e: + logger.error(f"Failed parsing compose files from {script_path}: {e}") + + return files + +def parse_compose_files(compose_files): + service_to_image = {} + + for f in compose_files: + if not os.path.exists(f): + logger.warning(f"Compose file missing: {f}") + continue + + try: + with open(f, "r") as stream: + data = yaml.safe_load(stream) or {} + services = data.get("services", {}) + + logger.debug( + f"Parsing {len(services)} services from compose file {f}" + ) + + for service_name, service_def in services.items(): + image = service_def.get("image") + is_built = False + + if not image: + is_built = True + build_ctx = service_def.get("build") + + logger.debug( + f"Service {service_name} is build-based, build config={build_ctx}" + ) + + if isinstance(build_ctx, dict): + context_path = build_ctx.get("context", ".") + dockerfile_path = os.path.join( + context_path, + build_ctx.get("dockerfile", "Dockerfile") + ) + elif isinstance(build_ctx, str): + context_path = build_ctx + dockerfile_path = os.path.join( + context_path, "Dockerfile" + ) + else: + dockerfile_path = None + + if dockerfile_path and os.path.exists(dockerfile_path): + try: + with open(dockerfile_path, "r") as df: + for line in df: + line = line.strip() + if ( + line.upper().startswith("LABEL") + and "image=" in line + ): + m = re.search( + r'image=["\']?([^"\']+)["\']?', + line + ) + if m: + image = m.group(1) + logger.debug( + f"Found upstream image label for {service_name}: {image}" + ) + break + except Exception as e: + logger.warning( + f"Failed reading Dockerfile {dockerfile_path}: {e}" + ) + + if not image: + image = f"{service_name}:latest" + logger.info( + f"No image label found for build service {service_name}, " + f"defaulting to {image}" + ) + + service_to_image[service_name] = (image, is_built) + + except Exception as e: + logger.error(f"Failed parsing compose file {f}: {e}") + + logger.info(f"Mapped {len(service_to_image)} compose services to images") + logger.debug(f"Service/image mapping: {service_to_image}") + + return service_to_image + +def check_containers(): + logger.info("Starting container update check") + + CONTAINER_UPDATE.clear() + + project_prefix = get_project_prefix_from_script(SERVICES_UP_SCRIPT) + compose_files = get_compose_files_from_script(SERVICES_UP_SCRIPT) + service_to_image = parse_compose_files(compose_files) + + containers = client.containers.list() + logger.info(f"Checking {len(containers)} running containers") + + for container in containers: + project_label = container.labels.get("com.docker.compose.project") + + if not project_label: + logger.debug( + f"Skipping non-compose container {container.name}" + ) + continue + + service_label = container.labels.get("com.docker.compose.service") + running_image = container.attrs["Config"]["Image"] + + logger.debug( + f"Evaluating container={container.name}, " + f"service={service_label}, project={project_label}, " + f"running_image={running_image}" + ) + + compose_image = None + is_built = False + + if service_label and service_label in service_to_image: + compose_image, is_built = service_to_image[service_label] + + if is_built: + compose_image_name, _, _ = compose_image.partition(":") + compose_image = f"{project_prefix}{compose_image_name}" + + update_flag = 0 + + if is_built: + if running_image != compose_image: + logger.info( + f"Update detected for build-based container {container.name}: " + f"running image {running_image} != expected {compose_image}" + ) + update_flag = 1 + else: + local_digest = get_local_digest(running_image) + remote_digest = get_remote_digest( + service_to_image[service_label][0] + ) + + if local_digest and remote_digest and local_digest != remote_digest: + logger.info( + f"Remote image update available for {container.name}: " + f"{local_digest} != {remote_digest}" + ) + update_flag = 1 + else: + if running_image != compose_image: + logger.info( + f"Compose drift detected for {container.name}: " + f"running image {running_image} != compose image {compose_image}" + ) + update_flag = 1 + else: + local_digest = get_local_digest(running_image) + remote_digest = get_remote_digest(running_image) + + if local_digest and remote_digest and local_digest != remote_digest: + logger.info( + f"Registry update available for {container.name}: " + f"{local_digest} != {remote_digest}" + ) + update_flag = 1 + + CONTAINER_UPDATE.labels( + container=container.name, + compose_image=compose_image if compose_image else "unknown", + running_image=running_image, + com_docker_compose_project=project_label + ).set(update_flag) + + logger.info( + f"Container {container.name}: " + f"running={running_image}, " + f"compose={compose_image}, " + f"update_available={update_flag}" + ) + + LAST_CHECK.set(time.time()) + logger.info("Container update check complete") + +if __name__ == "__main__": + logger.info( + f"Starting Docker update exporter on port {EXPORTER_PORT} " + f"with LOG_LEVEL={LOG_LEVEL}" + ) + + start_http_server(EXPORTER_PORT) + + while True: + try: + check_containers() + except Exception as e: + logger.exception(f"Unhandled error during update check: {e}") + + time.sleep(CHECK_INTERVAL) diff --git a/services-up.sh b/services-up.sh new file mode 100755 index 0000000..fb724de --- /dev/null +++ b/services-up.sh @@ -0,0 +1,10 @@ +#!/usr/bin/env bash + +CONTEXT="raspi" +ENV="default-environment.env" +PROJECT="raspi" +FILES=( + -f docker-compose.yml +) + +docker --context $CONTEXT compose -p $PROJECT --env-file $ENV "${FILES[@]}" $1 $2 $3 $4 $5 $6 $7 $8 $9 diff --git a/telegraf.conf b/telegraf.conf new file mode 100644 index 0000000..9063a7e --- /dev/null +++ b/telegraf.conf @@ -0,0 +1,9 @@ +[agent] + interval = "10s" + +[[inputs.docker]] + endpoint = "tcp://docker-socket-proxy:2375" + gather_services = false + +[[outputs.prometheus_client]] + listen = ":9273" diff --git a/traefik/certs/.gitkeep b/traefik/certs/.gitkeep new file mode 100644 index 0000000..e69de29 diff --git a/traefik/dynamic.yml b/traefik/dynamic.yml new file mode 100644 index 0000000..626f759 --- /dev/null +++ b/traefik/dynamic.yml @@ -0,0 +1,27 @@ +http: + middlewares: + crowdsec: + plugin: + crowdsec-bouncer: + crowdsecMode: live + crowdsecLapiKey: HeneLa2mazFVzl5+DQRKOdchBuJxKdjrHsHBE/03Acs + crowdsecLapiHost: crowdsec:8080 + crowdsecLapiScheme: http + + secHeaders: + headers: + browserXssFilter: true + contentTypeNosniff: true + frameDeny: true +# sslRedirect: true + #HSTS Configuration + stsIncludeSubdomains: true + stsPreload: true + stsSeconds: 15552000 + forceSTSHeader: true + customFrameOptionsValue: "SAMEORIGIN" + default-chain: + chain: + middlewares: + - secHeaders + - crowdsec diff --git a/traefik/letsencrypt/acme.json b/traefik/letsencrypt/acme.json new file mode 100644 index 0000000..713d188 --- /dev/null +++ b/traefik/letsencrypt/acme.json @@ -0,0 +1,25 @@ +{ + "le": { + "Account": { + "Email": "wayne.bennett@live.com", + "Registration": { + "body": { + "status": "valid" + }, + "uri": "https://acme-v02.api.letsencrypt.org/acme/acct/2885735196" + }, + "PrivateKey": "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", + "KeyType": "4096" + }, + "Certificates": [ + { + "domain": { + "main": "kuma.lan.ddnsgeek.com" + }, + "certificate": "LS0tLS1CRUdJTiBDRVJUSUZJQ0FURS0tLS0tCk1JSUdBVENDQk9tZ0F3SUJBZ0lTQllZS0hucG1QRHBVUEpqYU9QSzkzZlNITUEwR0NTcUdTSWIzRFFFQkN3VUEKTURNeEN6QUpCZ05WQkFZVEFsVlRNUll3RkFZRFZRUUtFdzFNWlhRbmN5QkZibU55ZVhCME1Rd3dDZ1lEVlFRRApFd05TTVRNd0hoY05Nall3TkRBeE1UazBORFF6V2hjTk1qWXdOak13TVRrME5EUXlXakFnTVI0d0hBWURWUVFECkV4VnJkVzFoTG14aGJpNWtaRzV6WjJWbGF5NWpiMjB3Z2dJaU1BMEdDU3FHU0liM0RRRUJBUVVBQTRJQ0R3QXcKZ2dJS0FvSUNBUUMyYk55NVFqYUU1MktHbEhEeWlnZ3dNbTBZVVUxU2xBVXJIMUF3bjNlZkxublUyQ0t1Ri9OVApwTFAyYWhrdDdKcy9iVVJTSEpnSVNJN0Q4dFEzdVQxK0Q3WlpqbGpZa2FYUWhkckxSZENTTkNORGkzY21CRWNSCldVRDhjblMyalhaZ3dWNkM5c1UyblhneGlETkVuRnkzQU9KcHB1VW82VFUyZWlwZllNSHdzeitGSXovZ2hEM1oKbFRmK1Q4SE83MGNHUUxCUlFQLzRjdTFLYzVzTCt4RE9rODhTQi85MWo2RmFwVW1YcnNMYUlhTGVYN0VRbTdZeQoycEJrYVhlLzZVeHpOdUNBQVBxeHRrdU1QaTlUY21tN1g5ZnFvM0VyTWNjOEJ0WnFlR2NwMlp3bTFzZzNwWWpGCk1qVExUc0dBY3hmTUJXdlFpOG5oUTkyQWgrVm03TzBTZGRVRUZOUnZvT2lvRzlkUjMyQyt2NncwRTAwNFVwdCsKQlkwVVd0OUVnVXJDNEk4YXdHQnJMNC9FNXFnbmw2SGk1aG8yUTZQeDI4eWJJdW9TUTdhZUp0UzhnNW5CakN6cAo1bjgxVWNUdEQ5OGxoWktXUlJKT2JGUGg5WVAvbHhZejQxR1NJclJnQmc2UHR4VS8xbkowclpadmZ3aXVpZ3hyCk5kb01vNTYxZ2hheWQ2MDdHVlowSlBLdlk3c2J4RWtKNlhRNDVFMk1QNUtGOEVhUGR2YytWMnJQOURqeTgwK0QKWFVxNUZzbWhNK1lLMUZmQ3c2UzljTjdKUkQ5RjF3WlltZEh0SGRTN05tcGFnMi84dzQyVUpRZ045M1dzZmxFeApKS2ROT3VTOUpXajk1ajg0V21Ma2dXdWdXalpjcmdrMTFQTDZXa042Y2VjRHQ4K2pibVdqdlFJREFRQUJvNElDCklEQ0NBaHd3RGdZRFZSMFBBUUgvQkFRREFnV2dNQk1HQTFVZEpRUU1NQW9HQ0NzR0FRVUZCd01CTUF3R0ExVWQKRXdFQi93UUNNQUF3SFFZRFZSME9CQllFRkZYZVJpOWdUakwzZFhqOFVDSkpRZUo5MFVlZU1COEdBMVVkSXdRWQpNQmFBRk9lcm53OHNNNkJUMDE1UGVNaXloQTQ3MXBJek1ETUdDQ3NHQVFVRkJ3RUJCQ2N3SlRBakJnZ3JCZ0VGCkJRY3dBb1lYYUhSMGNEb3ZMM0l4TXk1cExteGxibU55TG05eVp5OHdJQVlEVlIwUkJCa3dGNElWYTNWdFlTNXMKWVc0dVpHUnVjMmRsWldzdVkyOXRNQk1HQTFVZElBUU1NQW93Q0FZR1o0RU1BUUlCTUMwR0ExVWRId1FtTUNRdwpJcUFnb0I2R0hHaDBkSEE2THk5eU1UTXVZeTVzWlc1amNpNXZjbWN2T0M1amNtd3dnZ0VLQmdvckJnRUVBZFo1CkFnUUNCSUg3QklINEFQWUFkUUFXZ3kycjhLa2xEdy93T3FWRi84aS95Q1BRaDB2MkJDa24rT2NmTXhQMStnQUEKQVoxS3lUYzBBQUFFQXdCR01FUUNJRmd3ZmVSaE9OYjZUNUxPRzVhM0NFbnY1dVU5R2dIUmV1M0ZnQ2JOV3ZhTwpBaUI0cW9VaWs2b2xFNmE3WGd5TXltUnRvdzNwVCtiNTFwOGpkdCtCYXhzd1BBQjlBS2dteStNS3hqVVNSbE0vCjRHWHhUeG5aYmhrSUU4UWQyVzE1QUxNU1BGVW5BQUFCblVySk9NRUFDQUFBQlFBRkszUElCQU1BUmpCRUFpQWgKMktyaUxjU3NBenZBcXJ1Y1h6SFNyWmdmRmY1dGd3ZFNBWUJic1VReGdBSWdNaFRLY0VwS3UzMWN2YVk1cmpFSQozQ1JpU0QwelJEcHowbXVSM0VEMFZpMHdEUVlKS29aSWh2Y05BUUVMQlFBRGdnRUJBRGJqZmJpYmg1SDhodzZMCjJLS3hPY0VHaTc4N1RJZ20wNThnWWxzbS8yc2txSzFWcXZTM1Ftb2o1SUEwNVlyNVFwTUZRRmY2WjFrQXk3OFAKREg4cWZhZElnQVUyTmJlMWNLOW81ZjRMaFRVOXdNNThDOVRpN2U1YWlOSEExVUNzcndudkpuSDUyM3V4RVZjRwpIQ3JuZTRCZXd2dHBmeWI2TTFSb1JwM1c0bEhTNGQ3dGhIbTZMWExFRUpnQkNGQTVZejZlejZtdGFpblNUNXNVCjZqNHMrSnQ1QlEyelRrYTJ5MmFvMThPVGZrZUx0R3FVRGZCM29aV01OcUsvbTZtOTBGS1RLWVNUeW9CdGRFU2MKbnE5YzhHNnBzR0hhOEswTzB0K2NQd1hIWlFsWmkybkFWTjJVaFYzR3J3ZkFha3p3M0J1MzF6OG5TOE9vRnhSdgpzc1pwQkdNPQotLS0tLUVORCBDRVJUSUZJQ0FURS0tLS0tCgotLS0tLUJFR0lOIENFUlRJRklDQVRFLS0tLS0KTUlJRkJUQ0NBdTJnQXdJQkFnSVFXZ0R5RXRqVXRJRHpra0ZYNmltREJUQU5CZ2txaGtpRzl3MEJBUXNGQURCUApNUXN3Q1FZRFZRUUdFd0pWVXpFcE1DY0dBMVVFQ2hNZ1NXNTBaWEp1WlhRZ1UyVmpkWEpwZEhrZ1VtVnpaV0Z5ClkyZ2dSM0p2ZFhBeEZUQVRCZ05WQkFNVERFbFRVa2NnVW05dmRDQllNVEFlRncweU5EQXpNVE13TURBd01EQmEKRncweU56QXpNVEl5TXpVNU5UbGFNRE14Q3pBSkJnTlZCQVlUQWxWVE1SWXdGQVlEVlFRS0V3MU1aWFFuY3lCRgpibU55ZVhCME1Rd3dDZ1lEVlFRREV3TlNNVE13Z2dFaU1BMEdDU3FHU0liM0RRRUJBUVVBQTRJQkR3QXdnZ0VLCkFvSUJBUUNsWjNDTjBGYUJaQlVYWWMyNUJ0U3RHWkNNSmxBM21CWmprbFRiMmN5RUJaUHMwK3dJRzZCZ1VVTkkKZlN2SFNKYWV0QzNhbmNnbk8xZWhuNnZ3MWc3VURqREtiNXV4MGRha25USStXRTQxYjBWWWFIRVgvRDdZWFlLZwpMN0pSYkxBYVhiaFp6alZseUl1aHJ4QTMvK09jWGNKSkZ6VC9qQ3VMamZDOGNTeVREQjBGeExySHphckpYbnpSCnlRSDNuQVAyL0FwZDlOcDc1dHQyUW5EcjlFMGkyZ0IzYjliSlh4ZjkyblV1cFZjTTl1cGN0dUJ6cFdqUG9YVGkKZFlKK0VKL0I5YUxyQWVrNHNRcEV6TlBDaWZWSk5ZSUtOTE1jNllqQ1IwNkNEZ28yOEVkUGl2RXBCSFhhemVHYQpYUDllblppVnVwcEQwRXFpRndVQkJERFRNck9QQWdNQkFBR2pnZmd3Z2ZVd0RnWURWUjBQQVFIL0JBUURBZ0dHCk1CMEdBMVVkSlFRV01CUUdDQ3NHQVFVRkJ3TUNCZ2dyQmdFRkJRY0RBVEFTQmdOVkhSTUJBZjhFQ0RBR0FRSC8KQWdFQU1CMEdBMVVkRGdRV0JCVG5xNThQTERPZ1U5TmVUM2pJc29RT085YVNNekFmQmdOVkhTTUVHREFXZ0JSNQp0Rm5tZTdibDVBRnpnQWlJeUJwWTl1bWJiakF5QmdnckJnRUZCUWNCQVFRbU1DUXdJZ1lJS3dZQkJRVUhNQUtHCkZtaDBkSEE2THk5NE1TNXBMbXhsYm1OeUxtOXlaeTh3RXdZRFZSMGdCQXd3Q2pBSUJnWm5nUXdCQWdFd0p3WUQKVlIwZkJDQXdIakFjb0JxZ0dJWVdhSFIwY0RvdkwzZ3hMbU11YkdWdVkzSXViM0puTHpBTkJna3Foa2lHOXcwQgpBUXNGQUFPQ0FnRUFVVGRZVXFFaW16VzdUYnJPeXBMcUNmTDdWT3dZZi9RNzlPSDVjSExDWmVnZ2ZRaERjb25sCms3S2doOGIwdmkrL1h1V3U3Q044bi9VUGVnMXZvM0crdGFYaXJyeXR0aFFpbkFIR3djL1VkYk95Z0phOXp1QmMKVnlxb0gzQ1hUWERJblQrOGErYzNhRVZNSjJTdCtwU240ZWQrV2tEcDhpanNpanZFeUZ3RTQ3aHVsVzBMdHpqZwo5Zk9WNVBtcmcvenhXYlJ1TCtrMERCREhFSmVubkNzQWVuN2MzNVBteDdqcG1KL0h0Z1JoY256MHlqU0J2eUl3CjZMMVFJdXBrQ3YyU0JPRFQveEREM2dmUVF5S3Y2cm9WNEcyRWhmRXlBc1dwbW9qeGpDVUNHaXlnOTdGdkR0bS8KTksyTFNjOWx5Ykt4QjczSTIrUDJHM0NhV3B2dnBBaUhDVnUzMGpXOEdDeEtkZmhzWHRuSXkyaW1za1FxVloybQowUG14b2JiMjhUdWNyN3hCSzdDdHd2UHJiNzlvczd1MlhQM081ZjliL0g2NkdOeVJyZ2xSWGxyWWpJMW9HWUwvCmY0STFuL1NndXNkYTZXdkE2QzE5MGt4alUxNVkxMm1IVTQrQnh5UjljeDJoaEdTOWZBak1aS0pzczI4cXh2ejYKQXh1NENhRG1STlpwSy9wUXJYRjE3eVhDWGttRVdndlNPRVp5Nlo5cGNiTElWRUdja1YvaVZlcTBBT28ycGtnOQpwNFFSSXkwdEsyZGlSRU5MU0YyS3lzRndiWTZCMjZCRmVGczN2MXNZVlJoRlc5bkxrT3JRVnBvckNTMEt5Wm1mCndWRDg5cVNUbG5jdExjWm5JYXZqS3NLVXUxbkExaVUweVlNZFllcEtSN2xXYm53aGR4M2V3b2s9Ci0tLS0tRU5EIENFUlRJRklDQVRFLS0tLS0K", + "key": "LS0tLS1CRUdJTiBSU0EgUFJJVkFURSBLRVktLS0tLQpNSUlKS0FJQkFBS0NBZ0VBdG16Y3VVSTJoT2RpaHBSdzhvb0lNREp0R0ZGTlVwUUZLeDlRTUo5M255NTUxTmdpCnJoZnpVNlN6OW1vWkxleWJQMjFFVWh5WUNFaU93L0xVTjdrOWZnKzJXWTVZMkpHbDBJWGF5MFhRa2pRalE0dDMKSmdSSEVWbEEvSEowdG8xMllNRmVndmJGTnAxNE1ZZ3pSSnhjdHdEaWFhYmxLT2sxTm5vcVgyREI4TE0vaFNNLwo0SVE5MlpVMy9rL0J6dTlIQmtDd1VVRC8rSEx0U25PYkMvc1F6cFBQRWdmL2RZK2hXcVZKbDY3QzJpR2kzbCt4CkVKdTJNdHFRWkdsM3YrbE1jemJnZ0FENnNiWkxqRDR2VTNKcHUxL1g2cU54S3pISFBBYldhbmhuS2RtY0p0YkkKTjZXSXhUSTB5MDdCZ0hNWHpBVnIwSXZKNFVQZGdJZmxadXp0RW5YVkJCVFViNkRvcUJ2WFVkOWd2citzTkJOTgpPRktiZmdXTkZGcmZSSUZLd3VDUEdzQmdheStQeE9hb0o1ZWg0dVlhTmtPajhkdk1teUxxRWtPMm5pYlV2SU9aCndZd3M2ZVovTlZIRTdRL2ZKWVdTbGtVU1RteFQ0ZldELzVjV00rTlJraUswWUFZT2o3Y1ZQOVp5ZEsyV2IzOEkKcm9vTWF6WGFES09ldFlJV3NuZXRPeGxXZENUeXIyTzdHOFJKQ2VsME9PUk5qRCtTaGZCR2ozYjNQbGRxei9RNAo4dk5QZzExS3VSYkpvVFBtQ3RSWHdzT2t2WERleVVRL1JkY0dXSm5SN1IzVXV6WnFXb052L01PTmxDVUlEZmQxCnJINVJNU1NuVFRya3ZTVm8vZVkvT0ZwaTVJRnJvRm8yWEs0Sk5kVHkrbHBEZW5IbkE3ZlBvMjVsbzcwQ0F3RUEKQVFLQ0FnQVR1SzhnQkExNnpyenpDYWN3UVVYaldBQTlTY1RQTVp0aGJ4QXZRWDRnSWxrZmJvSkpTZU9QWGxYcQpSTXdXRCtjMHN5NVlwaWkxZElxaW1tVGc1VkRGaVJOUW1UZjNxYjlBVE1nN2czK3AzcU1WVVVWL1FhVWhOZ0gxCmlMWWZhaW1yQ2h5YWtleitHNFg3N05xR0IxVnFuMWphelNVbkNCY3RuWVZwTEQrck11V2lFZzU4eWk4Wmo2MHUKL0JaYXRibDY3cEZoSjJMRXpEL2s5WnlFYmc4Q0ZpNmpwMk94cE9adnMwRENlaTBzZ2NobXUvZWJjUW9BYzJwNgp3SUFtcWlVT3E1Y1huNE42cE5lRUFORUM5M0tBeERyR2JnYjFBWHVsZktPL2ZPYkNPbmxNSlpvZGtmSzRSdFk0ClNIcFkxWmgwQVA0SDNLVGxpbjJ1d0lKSFgwTzl0VzlNS3NBR2NVTVJHSEIyRWprSjBhaVFya0o5Z0xNaG5PTkYKQkpwdTNsRVJHamsyOE5wZ0JZTjc1WXZmQWc5ZmNvbk9PZnFsVklOR3lFL2VKMFBINDF1akRTaWdEdVdVL0E3WApGTlJVMlBuTzJTbm9aZWRBRHMyMm0rQTE2V01hNlVTZkJvdWRTKytna2F4RnBhYjY2bVNvZGFac2Z1ckNDNi9mClFleFJaRXp6SkZ4TkFKZ01KV2JjVWM1anJhQkVmOUd2SWZCZ0p2NkNpN2NnZmNCdlBkazVIQW0vcGRMbDFSV2UKcCt3bkFwbEVFTmpkL0h1M0R0SVFzZkt2RmI4Q3J0RnlIWStzamdCS2lyUUdZd0hCc0Jkajg4MmVaTTdKOHF3TAo5QTJKUlF0OGRUSUJlRmhueUJ5dnR3QkVGVmk4RTZOVEhxVS9uUmdNQkpxTllNMHFRUUtDQVFFQTFsVllnTTNtCmU0WDBiajBFSHpuUjAwMTRQcWg3bVpHS0J3OFUrUjVqbk1sNUpxSXlZaWN2b3pxV0x4L1BYZ2NZbE55RGVuUkoKRmhYcFdnOHhLbmtOSXV1Q3NxbnRERjM0M05SdVcxblprUExzSmZZWktjQTREOUFxRUR1UWVDTk9EVFJUVXNQWApmUkVEaER0VnVPQllZS2FINUIyWHM5V3QzaHd6ZmhHRDQzSWRxTFgxSWpScEJhajZyQkwxMDREVXFVZkN3SzNFCkZWYWZkbEVUVi81SWZiLzZaMWhDM0RRRmE5RExGSWxKbjlPeTl1TGV0OS8xRkFUOEdCQ0FnSG1TNlVyWjY1OE0KZ0dGUm55T01kVTh3OTd1M0laR3A4R1lqSytYMlBFM1NlQXd1UEFOSmszV3lMT3d6VWwvSW4rbkRrSW9KTjJ5Uwp1cUtKOUtsMVNkbnU4UUtDQVFFQTJlT09LaDBjbDlYaFFtQ0NWY1lFSWFiZzMzb2xOaTMzVVZBQkRHMktIR2VWCkgrcWdHYXNnbU9OK2lHeUozN0ZhMmV2NWlwdm16bStUSDNFSHBib0p1c0p3MFVZU0tLdGE2MlR0ZmUvZ0dxR0MKRXMxUjNOd20xZ0EzZFhWMml1bWpGZnBDSVdWSW9mWDZTTGVjOEZpYVdweFZ6SFhlbWgyemRtS1VQOE5ZWjJJNQp4Mjh2UGVFRGx5UEcxUGlQNFlrNVp4NDZHYXRYVHl4OC96cGg1SjcwUUF6dmw2KzlTMHlsRTFvMjhPb2h5UzA4CjhTLzk1NENVckhKVndPMWVtT3dUSU1tR2ZzaE4vdUpqcUVNQk80N1ViQmdOdTJ2QTJZbUtrNTY4L2xHamdzNmMKWjNyMXoyd1hCL3pyd2xzUzVSVXJYSWcwbU15aVZDcjJRSUpJZUgrWmpRS0NBUUJRQXNJc0t6RDhNYkJpcXJKcwpPYmhqaWdyMlVRejY4Z0sxeWVLSmNOZTh3dFZRSDhKeE03R3IzSVlPQjNEdzUvTThOdWw2QUFqOGpxZHk4RExwCmFSRUhWU1dRTHJFSUtkaHFEMzgyNk42NEpvY1RVTUJwL3BDdkRvVjFVOWNGa2lCais0R1RadnA5ZktsMFpuWUUKc3ZNcStWL0hQMmpQUUZoTWp2aUYrbUhpdVVuckhpbnBYK2xJcktFUE5YREJTTm1hYStoKzV4OUkyL0poUjdqUgo0MklLVjIxUndQZ01uZnJjR2JDRjlRNGY5UFpkQmFvZ3VDRVVIUnhDbTM1czkzVnBVNER3WFlwY0xuZzYwUmx3Ci9XUUpSdkVCUGhCVFpyMkFlTE1vSmVFSTRYc3MrZ2t3WEFKc2xLdy85dk85QnBYejBpUElwZVpNZDdyS002eE8KS21YeEFvSUJBRzNFYmNKWm1DWUY0YzJHd1NKazkzL2NpajBMVStQUGxHQ3FaVnpWY2xhTStGdHozK1FPaWdWNwoxaXIwUXlFbVFCYTVBcVlKbEdVN1lHcmVqbk8zSFYrd3hSVW9yODA5UmppQVNBVEI2NngrNFFDSDMwM2N1dFBrCm1BdmNlVFBTTUE3S1hMWm1TQWtQMmw3MzRXQlR3cThUUTBZVnUyYjR2NGhNY1ZNT0htTTI0TlRQOW9TOTd3a2QKUlBYVm91YUVNeDczNDEwN0xHaitpSm9yMTFWTm5zOEZ0VTQ5Z3FlRW5VVDZsZU1Za0p6QTVZcThHcldkMlVobQo4VHlZV0tmQmhvRm1CUk9wNVdFYXZBbndkNWFCaGhKT3E5eFhsSGdEQ29VRDIwSmo3blBma251dlBsYVlwYUdvCmtUMEFsS3hEMWV4djBjK0loOTdQSmUrcHNzakJ2NkVDZ2dFQkFLbmtseUFDVmFBUUxteDk1RWZZanF5d2huV3AKaTVsdktqVHdINjJNM0VDWmsvQVVSVVJLc1hPNldEenlKV1lkS242SjdWa05PaU1RL2ViNmZ5NyswRXBucGo5dApUMWROcWU5MldWSkpmeTVtRTJtYTloUXN4Sk5oT2w4elhsbnNocjNud1UrcENWQWY3L29pbld4bGdhc1JzaW1lCnZyUTR0QTJ3Qi84Q3JXa1hveVR3YWJpT0xBVjZZOHF1dDdualdmZ2tKQVJnckhZMGNWbmJyQ05TTzRuU2poSzYKMUUrTWhlak95SFlXb2ZjNi9VWlY5RGpKdVl4bERZM3NHMHo0cEZUZEZCVGhIenJwanNTcVhKUGt2Ym42TjJvMAo2ai9TS1Z6dTBJYzdIZGxGVGp2dFlGaDdYaDBOQjAwQUl4Z3A1THhoZWNaSG5PRnhqU0ZVdDZ4bUxHYz0KLS0tLS1FTkQgUlNBIFBSSVZBVEUgS0VZLS0tLS0K", + "Store": "default" + } + ] + } +} \ No newline at end of file diff --git a/traefik/traefik.yml b/traefik/traefik.yml new file mode 100644 index 0000000..c978c39 --- /dev/null +++ b/traefik/traefik.yml @@ -0,0 +1,58 @@ +api: + dashboard: true + insecure: true # keep false if exposing via router +ping: {} + +log: + level: INFO + +accessLog: + filePath: /logs/access.log + format: json + +entryPoints: + web: + address: ":80" + http: + redirections: + entryPoint: + to: websecure + scheme: https + + websecure: + address: ":443" + http: + middlewares: + - default-chain@file + +providers: + docker: + endpoint: "tcp://docker-socket-proxy:2375" + exposedByDefault: false + file: + filename: /etc/traefik/dynamic.yml + watch: true + +experimental: + plugins: + crowdsec-bouncer: + moduleName: github.com/maxlerebourg/crowdsec-bouncer-traefik-plugin + version: v1.4.2 + +certificatesResolvers: + le: + acme: + email: wayne.bennett@live.com + storage: /letsencrypt/acme.json + httpChallenge: + entryPoint: web +metrics: + prometheus: +# entryPoint: metrics:9100 # optional, default is "metrics" + buckets: + - 0.1 + - 0.3 + - 1.2 + - 5.0 + addEntryPointsLabels: true # add labels for each entrypoint + addServicesLabels: true # add labels for each service