From d5d3d4b051d97dd81b09b804680e4b801debf7ce Mon Sep 17 00:00:00 2001 From: Mark Garratt Date: Sun, 15 Feb 2026 12:15:45 +0000 Subject: [PATCH 1/3] proton-bridge: expose prometheus metrics endpoint --- images/proton-bridge/Dockerfile | 5 + images/proton-bridge/README.md | 26 ++++ images/proton-bridge/entrypoint.sh | 3 +- images/proton-bridge/healthcheck.sh | 6 +- images/proton-bridge/metrics/metrics.cgi | 139 +++++++++++++++++++ images/proton-bridge/services/metrics/finish | 4 + images/proton-bridge/services/metrics/run | 12 ++ 7 files changed, 193 insertions(+), 2 deletions(-) create mode 100644 images/proton-bridge/metrics/metrics.cgi create mode 100644 images/proton-bridge/services/metrics/finish create mode 100644 images/proton-bridge/services/metrics/run diff --git a/images/proton-bridge/Dockerfile b/images/proton-bridge/Dockerfile index c43e4b2..2e7ebfe 100644 --- a/images/proton-bridge/Dockerfile +++ b/images/proton-bridge/Dockerfile @@ -55,11 +55,13 @@ ARG ENV_BRIDGE_HOST=127.0.0.1 # Change ENV_CONTAINER_SMTP_PORT only if you have a docker port conflict on host network namespace. ARG ENV_CONTAINER_SMTP_PORT=1026 ARG ENV_CONTAINER_IMAP_PORT=1144 +ARG ENV_CONTAINER_METRICS_PORT=9154 ENV PROTON_BRIDGE_SMTP_PORT=$ENV_BRIDGE_SMTP_PORT \ PROTON_BRIDGE_IMAP_PORT=$ENV_BRIDGE_IMAP_PORT \ PROTON_BRIDGE_HOST=$ENV_BRIDGE_HOST \ CONTAINER_SMTP_PORT=$ENV_CONTAINER_SMTP_PORT \ CONTAINER_IMAP_PORT=$ENV_CONTAINER_IMAP_PORT \ + CONTAINER_METRICS_PORT=$ENV_CONTAINER_METRICS_PORT \ ENV_TARGET_PLATFORM=$TARGETPLATFORM # Install dependencies @@ -93,6 +95,7 @@ COPY --from=build /build/proton-bridge/bridge /build/proton-bridge/proton-bridge WORKDIR /app/ COPY --chmod=0755 entrypoint.sh /app/entrypoint.sh COPY --chmod=0755 healthcheck.sh /app/healthcheck.sh +COPY --chmod=0755 metrics/metrics.cgi /app/metrics/metrics.cgi COPY GPGparams.txt /app/GPGparams.txt COPY LICENSE /app/licenses/LICENSE COPY NOTICE /app/licenses/NOTICE @@ -105,6 +108,8 @@ COPY --chmod=0755 services/socat-smtp/run /app/services/socat-smtp/run COPY --chmod=0755 services/socat-smtp/finish /app/services/socat-smtp/finish COPY --chmod=0755 services/socat-imap/run /app/services/socat-imap/run COPY --chmod=0755 services/socat-imap/finish /app/services/socat-imap/finish +COPY --chmod=0755 services/metrics/run /app/services/metrics/run +COPY --chmod=0755 services/metrics/finish /app/services/metrics/finish COPY --from=build /build/BRIDGE_VERSION /app/VERSION RUN chown -R bridge:bridge /app diff --git a/images/proton-bridge/README.md b/images/proton-bridge/README.md index 8d1f2bb..ba0647f 100644 --- a/images/proton-bridge/README.md +++ b/images/proton-bridge/README.md @@ -10,6 +10,7 @@ The container runs these long-lived services under `s6`: - `gpg-agent` (launched and monitored via `gpgconf`) - SMTP forwarder (`socat` on `${CONTAINER_SMTP_PORT}` -> `${PROTON_BRIDGE_HOST}:${PROTON_BRIDGE_SMTP_PORT}`) - IMAP forwarder (`socat` on `${CONTAINER_IMAP_PORT}` -> `${PROTON_BRIDGE_HOST}:${PROTON_BRIDGE_IMAP_PORT}`) +- metrics endpoint (`busybox httpd` on `${CONTAINER_METRICS_PORT}` serving `/cgi-bin/metrics`) Bootstrap-only initialization in `entrypoint.sh`: @@ -25,6 +26,7 @@ Required: - `PROTON_BRIDGE_HOST` - `CONTAINER_SMTP_PORT` - `CONTAINER_IMAP_PORT` +- `CONTAINER_METRICS_PORT` Optional runtime tuning: @@ -83,6 +85,30 @@ The container `HEALTHCHECK` script validates: - `gpg-agent` control socket responsiveness (`gpg-connect-agent /bye`) - listening state for SMTP/IMAP container ports - lightweight SMTP and IMAP banner-level handshake probes on local forwarded ports +- metrics endpoint HTTP readiness (`/cgi-bin/metrics`) + +## Metrics + +The image now exposes Prometheus-formatted metrics from inside the container. + +- listen port: `${CONTAINER_METRICS_PORT}` (default `9154`) +- scrape path: `/cgi-bin/metrics` + +Current metrics include: + +- `proton_bridge_service_up{service=...}` +- `proton_bridge_service_restart_count{service=...}` +- `proton_bridge_service_start_time_seconds{service=...}` +- `proton_bridge_gpg_agent_up` +- `proton_bridge_port_listening{listener=...,port=...}` +- `proton_bridge_smtp_banner_probe_up` +- `proton_bridge_imap_banner_probe_up` +- `proton_bridge_pass_entry_count` + +Limitations: + +- Proton Bridge does not currently expose a stable machine API for per-account sync status in this image mode. +- You can scrape transport/process readiness today; account-level sync state likely requires either upstream Bridge support or a dedicated log/CLI parser sidecar. ## Build Supply Chain diff --git a/images/proton-bridge/entrypoint.sh b/images/proton-bridge/entrypoint.sh index 83e89f2..9307846 100644 --- a/images/proton-bridge/entrypoint.sh +++ b/images/proton-bridge/entrypoint.sh @@ -15,7 +15,8 @@ for required_var in \ PROTON_BRIDGE_IMAP_PORT \ PROTON_BRIDGE_HOST \ CONTAINER_SMTP_PORT \ - CONTAINER_IMAP_PORT + CONTAINER_IMAP_PORT \ + CONTAINER_METRICS_PORT do require_env "${required_var}" done diff --git a/images/proton-bridge/healthcheck.sh b/images/proton-bridge/healthcheck.sh index 2dd45a6..c4f66cc 100644 --- a/images/proton-bridge/healthcheck.sh +++ b/images/proton-bridge/healthcheck.sh @@ -1,7 +1,7 @@ #!/usr/bin/env sh set -eu -for service in bridge gpg-agent socat-smtp socat-imap; do +for service in bridge gpg-agent socat-smtp socat-imap metrics; do s6-svstat "/app/services/${service}" | grep -q '^up ' done @@ -9,6 +9,10 @@ gpg-connect-agent /bye >/dev/null 2>&1 netstat -ltn 2>/dev/null | grep -q "[.:]${CONTAINER_SMTP_PORT}[[:space:]]" netstat -ltn 2>/dev/null | grep -q "[.:]${CONTAINER_IMAP_PORT}[[:space:]]" +netstat -ltn 2>/dev/null | grep -q "[.:]${CONTAINER_METRICS_PORT}[[:space:]]" printf 'QUIT\r\n' | nc -w 3 127.0.0.1 "${CONTAINER_SMTP_PORT}" | grep -Eq '^220 ' printf 'a1 LOGOUT\r\n' | nc -w 3 127.0.0.1 "${CONTAINER_IMAP_PORT}" | grep -Eq '^\* (OK|PREAUTH|BYE) ' +printf 'GET /cgi-bin/metrics HTTP/1.0\r\nHost: localhost\r\n\r\n' \ + | nc -w 3 127.0.0.1 "${CONTAINER_METRICS_PORT}" \ + | grep -Eq '^HTTP/[0-9.]+ 200 ' diff --git a/images/proton-bridge/metrics/metrics.cgi b/images/proton-bridge/metrics/metrics.cgi new file mode 100644 index 0000000..3d71f58 --- /dev/null +++ b/images/proton-bridge/metrics/metrics.cgi @@ -0,0 +1,139 @@ +#!/bin/sh +set -eu + +state_dir="/tmp/proton-bridge-s6" + +metric_bool() { + name="$1" + value="$2" + printf '%s %s\n' "${name}" "${value}" +} + +service_up() { + service="$1" + if s6-svstat "/app/services/${service}" 2>/dev/null | grep -q '^up '; then + value=1 + else + value=0 + fi + printf 'proton_bridge_service_up{service="%s"} %s\n' "${service}" "${value}" +} + +service_restart_count() { + service="$1" + state_file="${state_dir}/${service}.state" + count=0 + if [ -f "${state_file}" ]; then + count="$(cat "${state_file}" 2>/dev/null || echo 0)" + fi + printf 'proton_bridge_service_restart_count{service="%s"} %s\n' "${service}" "${count}" +} + +service_start_time() { + service="$1" + start_file="${state_dir}/${service}.start" + if [ -f "${start_file}" ]; then + started_at="$(cat "${start_file}" 2>/dev/null || echo 0)" + else + started_at=0 + fi + printf 'proton_bridge_service_start_time_seconds{service="%s"} %s\n' "${service}" "${started_at}" +} + +port_listening() { + name="$1" + port="$2" + if netstat -ltn 2>/dev/null | grep -q "[.:]${port}[[:space:]]"; then + value=1 + else + value=0 + fi + printf 'proton_bridge_port_listening{listener="%s",port="%s"} %s\n' "${name}" "${port}" "${value}" +} + +smtp_probe_up() { + if printf 'QUIT\r\n' | nc -w 3 127.0.0.1 "${CONTAINER_SMTP_PORT}" 2>/dev/null | grep -Eq '^220 '; then + value=1 + else + value=0 + fi + metric_bool proton_bridge_smtp_banner_probe_up "${value}" +} + +imap_probe_up() { + if printf 'a1 LOGOUT\r\n' | nc -w 3 127.0.0.1 "${CONTAINER_IMAP_PORT}" 2>/dev/null | grep -Eq '^\* (OK|PREAUTH|BYE) '; then + value=1 + else + value=0 + fi + metric_bool proton_bridge_imap_banner_probe_up "${value}" +} + +gpg_agent_up() { + if gpg-connect-agent /bye >/dev/null 2>&1; then + value=1 + else + value=0 + fi + metric_bool proton_bridge_gpg_agent_up "${value}" +} + +pass_entry_count() { + count=0 + if [ -d "${HOME}/.password-store" ]; then + count="$(find "${HOME}/.password-store" -type f -name '*.gpg' 2>/dev/null | wc -l | tr -d ' ')" + fi + metric_bool proton_bridge_pass_entry_count "${count}" +} + +print_metrics() { + printf 'Content-Type: text/plain; version=0.0.4\r\n\r\n' + + printf '# HELP proton_bridge_service_up Whether an s6 service is currently up (1) or down (0).\n' + printf '# TYPE proton_bridge_service_up gauge\n' + service_up bridge + service_up gpg-agent + service_up socat-smtp + service_up socat-imap + service_up metrics + + printf '# HELP proton_bridge_service_restart_count Number of rapid restarts seen by the s6 finish policy per service.\n' + printf '# TYPE proton_bridge_service_restart_count gauge\n' + service_restart_count bridge + service_restart_count gpg-agent + service_restart_count socat-smtp + service_restart_count socat-imap + service_restart_count metrics + + printf '# HELP proton_bridge_service_start_time_seconds Last recorded service start time as Unix epoch seconds.\n' + printf '# TYPE proton_bridge_service_start_time_seconds gauge\n' + service_start_time bridge + service_start_time gpg-agent + service_start_time socat-smtp + service_start_time socat-imap + service_start_time metrics + + printf '# HELP proton_bridge_gpg_agent_up Whether gpg-agent responds to gpg-connect-agent.\n' + printf '# TYPE proton_bridge_gpg_agent_up gauge\n' + gpg_agent_up + + printf '# HELP proton_bridge_port_listening Whether the expected listener port is open inside the container.\n' + printf '# TYPE proton_bridge_port_listening gauge\n' + port_listening smtp "${CONTAINER_SMTP_PORT}" + port_listening imap "${CONTAINER_IMAP_PORT}" + port_listening metrics "${CONTAINER_METRICS_PORT}" + + printf '# HELP proton_bridge_smtp_banner_probe_up Whether SMTP banner probe on localhost succeeds.\n' + printf '# TYPE proton_bridge_smtp_banner_probe_up gauge\n' + smtp_probe_up + + printf '# HELP proton_bridge_imap_banner_probe_up Whether IMAP banner probe on localhost succeeds.\n' + printf '# TYPE proton_bridge_imap_banner_probe_up gauge\n' + imap_probe_up + + printf '# HELP proton_bridge_pass_entry_count Number of pass entries in the mounted bridge password store.\n' + printf '# TYPE proton_bridge_pass_entry_count gauge\n' + pass_entry_count +} + +print_metrics diff --git a/images/proton-bridge/services/metrics/finish b/images/proton-bridge/services/metrics/finish new file mode 100644 index 0000000..7a2361d --- /dev/null +++ b/images/proton-bridge/services/metrics/finish @@ -0,0 +1,4 @@ +#!/bin/sh +set -eu + +exec /app/services/.s6-finish-policy.sh "metrics" "false" "${1:-0}" "${2:-0}" diff --git a/images/proton-bridge/services/metrics/run b/images/proton-bridge/services/metrics/run new file mode 100644 index 0000000..747ec3e --- /dev/null +++ b/images/proton-bridge/services/metrics/run @@ -0,0 +1,12 @@ +#!/bin/sh +set -eu + +state_dir="/tmp/proton-bridge-s6" +mkdir -p "${state_dir}" +date +%s > "${state_dir}/metrics.start" + +web_root="/tmp/proton-bridge-metrics-www" +mkdir -p "${web_root}/cgi-bin" +ln -sf /app/metrics/metrics.cgi "${web_root}/cgi-bin/metrics" + +exec busybox httpd -f -p "${CONTAINER_METRICS_PORT}" -h "${web_root}" From 9d69aa82386029d31bcd60dac4a157d4a17c660c Mon Sep 17 00:00:00 2001 From: Mark Garratt Date: Sun, 15 Feb 2026 13:30:36 +0000 Subject: [PATCH 2/3] proton-bridge: replace busybox httpd metrics with socat --- images/proton-bridge/Dockerfile | 1 + images/proton-bridge/README.md | 2 +- images/proton-bridge/metrics/metrics.cgi | 2 -- images/proton-bridge/metrics/serve-http.sh | 29 ++++++++++++++++++++++ images/proton-bridge/services/metrics/run | 8 +++--- 5 files changed, 34 insertions(+), 8 deletions(-) create mode 100644 images/proton-bridge/metrics/serve-http.sh diff --git a/images/proton-bridge/Dockerfile b/images/proton-bridge/Dockerfile index 2e7ebfe..e6f763b 100644 --- a/images/proton-bridge/Dockerfile +++ b/images/proton-bridge/Dockerfile @@ -96,6 +96,7 @@ WORKDIR /app/ COPY --chmod=0755 entrypoint.sh /app/entrypoint.sh COPY --chmod=0755 healthcheck.sh /app/healthcheck.sh COPY --chmod=0755 metrics/metrics.cgi /app/metrics/metrics.cgi +COPY --chmod=0755 metrics/serve-http.sh /app/metrics/serve-http.sh COPY GPGparams.txt /app/GPGparams.txt COPY LICENSE /app/licenses/LICENSE COPY NOTICE /app/licenses/NOTICE diff --git a/images/proton-bridge/README.md b/images/proton-bridge/README.md index ba0647f..d372e5a 100644 --- a/images/proton-bridge/README.md +++ b/images/proton-bridge/README.md @@ -10,7 +10,7 @@ The container runs these long-lived services under `s6`: - `gpg-agent` (launched and monitored via `gpgconf`) - SMTP forwarder (`socat` on `${CONTAINER_SMTP_PORT}` -> `${PROTON_BRIDGE_HOST}:${PROTON_BRIDGE_SMTP_PORT}`) - IMAP forwarder (`socat` on `${CONTAINER_IMAP_PORT}` -> `${PROTON_BRIDGE_HOST}:${PROTON_BRIDGE_IMAP_PORT}`) -- metrics endpoint (`busybox httpd` on `${CONTAINER_METRICS_PORT}` serving `/cgi-bin/metrics`) +- metrics endpoint (`socat` listener on `${CONTAINER_METRICS_PORT}` serving `/cgi-bin/metrics`) Bootstrap-only initialization in `entrypoint.sh`: diff --git a/images/proton-bridge/metrics/metrics.cgi b/images/proton-bridge/metrics/metrics.cgi index 3d71f58..d6314dd 100644 --- a/images/proton-bridge/metrics/metrics.cgi +++ b/images/proton-bridge/metrics/metrics.cgi @@ -87,8 +87,6 @@ pass_entry_count() { } print_metrics() { - printf 'Content-Type: text/plain; version=0.0.4\r\n\r\n' - printf '# HELP proton_bridge_service_up Whether an s6 service is currently up (1) or down (0).\n' printf '# TYPE proton_bridge_service_up gauge\n' service_up bridge diff --git a/images/proton-bridge/metrics/serve-http.sh b/images/proton-bridge/metrics/serve-http.sh new file mode 100644 index 0000000..34165e9 --- /dev/null +++ b/images/proton-bridge/metrics/serve-http.sh @@ -0,0 +1,29 @@ +#!/bin/sh +set -eu + +method="" +path="" +protocol="" +IFS=' ' read -r method path protocol || true + +while IFS= read -r header; do + case "${header}" in + ''|$'\r') + break + ;; + esac +done + +if [ "${method}" = "GET" ] && [ "${path}" = "/cgi-bin/metrics" ]; then + printf 'HTTP/1.1 200 OK\r\n' + printf 'Content-Type: text/plain; version=0.0.4\r\n' + printf 'Connection: close\r\n' + printf '\r\n' + exec /app/metrics/metrics.cgi +fi + +printf 'HTTP/1.1 404 Not Found\r\n' +printf 'Content-Type: text/plain\r\n' +printf 'Connection: close\r\n' +printf '\r\n' +printf 'not found\n' diff --git a/images/proton-bridge/services/metrics/run b/images/proton-bridge/services/metrics/run index 747ec3e..522b245 100644 --- a/images/proton-bridge/services/metrics/run +++ b/images/proton-bridge/services/metrics/run @@ -5,8 +5,6 @@ state_dir="/tmp/proton-bridge-s6" mkdir -p "${state_dir}" date +%s > "${state_dir}/metrics.start" -web_root="/tmp/proton-bridge-metrics-www" -mkdir -p "${web_root}/cgi-bin" -ln -sf /app/metrics/metrics.cgi "${web_root}/cgi-bin/metrics" - -exec busybox httpd -f -p "${CONTAINER_METRICS_PORT}" -h "${web_root}" +exec socat \ + TCP-LISTEN:"${CONTAINER_METRICS_PORT}",fork,reuseaddr \ + SYSTEM:"/app/metrics/serve-http.sh" From f24f93114292232f667c3018a55f56669f79d040 Mon Sep 17 00:00:00 2001 From: Mark Garratt Date: Sun, 15 Feb 2026 13:34:00 +0000 Subject: [PATCH 3/3] proton-bridge: support /metrics and quiet scrape pipe noise --- images/proton-bridge/README.md | 4 ++-- images/proton-bridge/healthcheck.sh | 6 +++--- images/proton-bridge/metrics/serve-http.sh | 2 +- 3 files changed, 6 insertions(+), 6 deletions(-) diff --git a/images/proton-bridge/README.md b/images/proton-bridge/README.md index d372e5a..db32e73 100644 --- a/images/proton-bridge/README.md +++ b/images/proton-bridge/README.md @@ -85,14 +85,14 @@ The container `HEALTHCHECK` script validates: - `gpg-agent` control socket responsiveness (`gpg-connect-agent /bye`) - listening state for SMTP/IMAP container ports - lightweight SMTP and IMAP banner-level handshake probes on local forwarded ports -- metrics endpoint HTTP readiness (`/cgi-bin/metrics`) +- metrics endpoint HTTP readiness (`/metrics`) ## Metrics The image now exposes Prometheus-formatted metrics from inside the container. - listen port: `${CONTAINER_METRICS_PORT}` (default `9154`) -- scrape path: `/cgi-bin/metrics` +- scrape path: `/metrics` (`/cgi-bin/metrics` also supported) Current metrics include: diff --git a/images/proton-bridge/healthcheck.sh b/images/proton-bridge/healthcheck.sh index c4f66cc..16b35bb 100644 --- a/images/proton-bridge/healthcheck.sh +++ b/images/proton-bridge/healthcheck.sh @@ -13,6 +13,6 @@ netstat -ltn 2>/dev/null | grep -q "[.:]${CONTAINER_METRICS_PORT}[[:space:]]" printf 'QUIT\r\n' | nc -w 3 127.0.0.1 "${CONTAINER_SMTP_PORT}" | grep -Eq '^220 ' printf 'a1 LOGOUT\r\n' | nc -w 3 127.0.0.1 "${CONTAINER_IMAP_PORT}" | grep -Eq '^\* (OK|PREAUTH|BYE) ' -printf 'GET /cgi-bin/metrics HTTP/1.0\r\nHost: localhost\r\n\r\n' \ - | nc -w 3 127.0.0.1 "${CONTAINER_METRICS_PORT}" \ - | grep -Eq '^HTTP/[0-9.]+ 200 ' +metrics_resp="$(printf 'GET /metrics HTTP/1.0\r\nHost: localhost\r\n\r\n' \ + | nc -w 3 127.0.0.1 "${CONTAINER_METRICS_PORT}")" +printf '%s\n' "${metrics_resp}" | grep -Eq '^HTTP/[0-9.]+ 200 ' diff --git a/images/proton-bridge/metrics/serve-http.sh b/images/proton-bridge/metrics/serve-http.sh index 34165e9..bca2343 100644 --- a/images/proton-bridge/metrics/serve-http.sh +++ b/images/proton-bridge/metrics/serve-http.sh @@ -14,7 +14,7 @@ while IFS= read -r header; do esac done -if [ "${method}" = "GET" ] && [ "${path}" = "/cgi-bin/metrics" ]; then +if [ "${method}" = "GET" ] && { [ "${path}" = "/metrics" ] || [ "${path}" = "/cgi-bin/metrics" ]; }; then printf 'HTTP/1.1 200 OK\r\n' printf 'Content-Type: text/plain; version=0.0.4\r\n' printf 'Connection: close\r\n'