From f6b27fe51c91a6d9755ad4a65657fdc6b699b756 Mon Sep 17 00:00:00 2001 From: Djeex Date: Sat, 22 Aug 2026 23:50:32 +0200 Subject: [PATCH 1/4] Add bats test suite for entrypoint.sh MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Covers env-var validation, socket path preparation, target connectivity messaging, DEBUG_LEVEL to socat-flag mapping, and graceful shutdown vs. crash detection โ€” run against the real socat/nc binaries rather than mocks, since UNIX-LISTEN binds without needing the TCP target reachable. Co-Authored-By: Claude Sonnet 5 --- tests/entrypoint.bats | 223 ++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 223 insertions(+) create mode 100644 tests/entrypoint.bats diff --git a/tests/entrypoint.bats b/tests/entrypoint.bats new file mode 100644 index 0000000..6ae2c7e --- /dev/null +++ b/tests/entrypoint.bats @@ -0,0 +1,223 @@ +#!/usr/bin/env bats +# +# Behavioral tests for entrypoint.sh. +# +# Strategy: run the real entrypoint.sh (real socat/nc, no mocks) against a +# throwaway TCP target and a scratch socket directory, so what's exercised +# matches production exactly. UNIX-LISTEN binds immediately without the TCP +# target being reachable, so TARGET_HOST/TARGET_PORT can safely point at +# nothing for most tests. +# +# Process PIDs are looked up on demand via `pgrep -f` against a unique, +# per-test path/name rather than captured with `$!` โ€” under bats-core's own +# subshell/job-control plumbing, `$!` does not reliably resolve to the +# process actually started here. + +setup() { + TEST_DIR="$(mktemp -d)" + cp "$BATS_TEST_DIRNAME/../entrypoint.sh" "$TEST_DIR/" + cp "$BATS_TEST_DIRNAME/../VERSION" "$TEST_DIR/" + chmod +x "$TEST_DIR/entrypoint.sh" + + export TARGET_HOST=127.0.0.1 + export TARGET_PORT=1 + export UNIX_SOCKET_NAME="test-$BATS_TEST_NUMBER.sock" + export UNIX_SOCKET_PATH="$TEST_DIR/socket" + export HOST_SOCKET_PATH="$TEST_DIR/host" + unset DEBUG_LEVEL +} + +teardown() { + pkill -9 -f "$TEST_DIR/entrypoint.sh" 2>/dev/null + pkill -9 -f "UNIX-LISTEN:.*$UNIX_SOCKET_NAME" 2>/dev/null + pkill -9 -f "nc .*34599" 2>/dev/null + rm -rf "$TEST_DIR" +} + +run_entrypoint_bg() { + LOG="$TEST_DIR/out.log" + ( cd "$TEST_DIR" && exec ./entrypoint.sh >"$LOG" 2>&1 ) & + disown 2>/dev/null || true +} + +entrypoint_pid() { + pgrep -f "$TEST_DIR/entrypoint.sh" | head -n1 +} + +socat_pid() { + pgrep -f "UNIX-LISTEN:.*$UNIX_SOCKET_NAME" | head -n1 +} + +wait_for_log() { + pattern="$1" + tries=0 + while [ "$tries" -lt 20 ]; do + grep -qE "$pattern" "$LOG" 2>/dev/null && return 0 + tries=$((tries + 1)) + sleep 0.25 + done + return 1 +} + +wait_for_pid_gone() { + pid="$1" + tries=0 + while kill -0 "$pid" 2>/dev/null && [ "$tries" -lt 20 ]; do + tries=$((tries + 1)) + sleep 0.25 + done + ! kill -0 "$pid" 2>/dev/null +} + +# ---- Environment variable validation ------------------------------------- + +@test "fails when TARGET_HOST is missing" { + unset TARGET_HOST + run "$TEST_DIR/entrypoint.sh" + [ "$status" -eq 1 ] + [[ "$output" == *"TARGET_HOST environment variable is required"* ]] +} + +@test "fails when TARGET_PORT is missing" { + unset TARGET_PORT + run "$TEST_DIR/entrypoint.sh" + [ "$status" -eq 1 ] + [[ "$output" == *"TARGET_PORT environment variable is required"* ]] +} + +@test "fails when UNIX_SOCKET_NAME is missing" { + unset UNIX_SOCKET_NAME + run "$TEST_DIR/entrypoint.sh" + [ "$status" -eq 1 ] + [[ "$output" == *"UNIX_SOCKET_NAME environment variable is required"* ]] +} + +@test "fails when UNIX_SOCKET_PATH is missing" { + unset UNIX_SOCKET_PATH + run "$TEST_DIR/entrypoint.sh" + [ "$status" -eq 1 ] + [[ "$output" == *"UNIX_SOCKET_PATH environment variable is required"* ]] +} + +@test "fails when HOST_SOCKET_PATH is missing" { + unset HOST_SOCKET_PATH + run "$TEST_DIR/entrypoint.sh" + [ "$status" -eq 1 ] + [[ "$output" == *"HOST_SOCKET_PATH environment variable is required"* ]] +} + +@test "prints the version banner from the VERSION file" { + echo "9.9.9" > "$TEST_DIR/VERSION" + run_entrypoint_bg + wait_for_log "Socat socket is active|Socat socket not found" + grep -q "Version 9.9.9" "$LOG" +} + +# ---- Socket path preparation ---------------------------------------------- + +@test "removes a pre-existing file at the socket path before starting" { + mkdir -p "$UNIX_SOCKET_PATH" + touch "$UNIX_SOCKET_PATH/$UNIX_SOCKET_NAME" + run_entrypoint_bg + wait_for_log "Socat socket is active|Socat socket not found" + grep -q "exists, removing it" "$LOG" + grep -q "Removed existing socket" "$LOG" +} + +@test "creates the socket directory when it does not exist" { + [ ! -d "$UNIX_SOCKET_PATH" ] + run_entrypoint_bg + wait_for_log "Socat socket is active|Socat socket not found" + grep -q "Created directory $UNIX_SOCKET_PATH" "$LOG" + [ -d "$UNIX_SOCKET_PATH" ] +} + +@test "reports the socat-created socket as active once it is listening" { + run_entrypoint_bg + wait_for_log "Socat socket is active|Socat socket not found" + grep -q "Socat socket is active" "$LOG" + [ -S "$UNIX_SOCKET_PATH/$UNIX_SOCKET_NAME" ] +} + +# ---- Target connectivity check (informational, non-fatal) ----------------- + +@test "warns but continues when the TCP target is unreachable" { + export TARGET_PORT=1 + run_entrypoint_bg + wait_for_log "Socat socket is active|Socat socket not found" + grep -q "Cannot connect to $TARGET_HOST:$TARGET_PORT" "$LOG" + grep -q "Container is ready and running" "$LOG" +} + +@test "reports success when the TCP target is reachable" { + ( nc -l 127.0.0.1 34599 >/dev/null 2>&1 & ) + disown 2>/dev/null || true + sleep 0.3 + export TARGET_PORT=34599 + run_entrypoint_bg + wait_for_log "Socat socket is active|Socat socket not found" + grep -q "Connection to $TARGET_HOST:$TARGET_PORT is working" "$LOG" +} + +# ---- Debug level -> socat flags mapping ----------------------------------- + +@test "maps an unset DEBUG_LEVEL default of 1 to a single -d flag" { + run_entrypoint_bg + wait_for_log "Using debug level" + grep -q "Using debug level: 1 (-d)" "$LOG" +} + +@test "maps DEBUG_LEVEL=2 to two -d flags" { + export DEBUG_LEVEL=2 + run_entrypoint_bg + wait_for_log "Using debug level" + grep -q "Using debug level: 2 (-d -d)" "$LOG" +} + +@test "maps DEBUG_LEVEL=3 to three -d flags" { + export DEBUG_LEVEL=3 + run_entrypoint_bg + wait_for_log "Using debug level" + grep -q "Using debug level: 3 (-d -d -d)" "$LOG" +} + +@test "maps an unrecognized DEBUG_LEVEL to no debug flags" { + export DEBUG_LEVEL=0 + run_entrypoint_bg + wait_for_log "Using debug level" + grep -q "Using debug level: 0 ()" "$LOG" +} + +# ---- Shutdown behavior ------------------------------------------------------ + +@test "shuts down gracefully and stops socat on SIGTERM" { + run_entrypoint_bg + wait_for_log "Socat socket is active|Socat socket not found" + + ep_pid="$(entrypoint_pid)" + sc_pid="$(socat_pid)" + [ -n "$ep_pid" ] + [ -n "$sc_pid" ] + + kill -TERM "$ep_pid" + wait_for_pid_gone "$ep_pid" + + grep -q "Received SIGTERM, shutting down gracefully" "$LOG" + grep -q "Cleanup completed, exiting" "$LOG" + ! kill -0 "$sc_pid" 2>/dev/null +} + +@test "exits 1 once socat's process dies unexpectedly" { + run_entrypoint_bg + wait_for_log "Socat socket is active|Socat socket not found" + + ep_pid="$(entrypoint_pid)" + sc_pid="$(socat_pid)" + [ -n "$ep_pid" ] + [ -n "$sc_pid" ] + + kill -9 "$sc_pid" + wait_for_pid_gone "$ep_pid" + + grep -q "Socat process has stopped" "$LOG" +} From b825dec8bee55d1d5e5f3258d36ce7fd230f9702 Mon Sep 17 00:00:00 2001 From: Djeex Date: Sat, 22 Aug 2026 23:50:40 +0200 Subject: [PATCH 2/4] Multi-stage Dockerfile: pin base image, add test/lint stages Pin alpine:latest to the full patch-level tag alpine:3.22.1 so Renovate can classify patch/minor/major bumps on it. Add a `test` stage (bats) and a `lint` stage (shellcheck, severity=error) that build from `base` before ENTRYPOINT is set, so CI can run them without an --entrypoint override. A trailing `FROM base` keeps the lean prod image as the default `docker build .` target despite the extra stages. Co-Authored-By: Claude Sonnet 5 --- Dockerfile | 20 ++++++++++++++++++-- 1 file changed, 18 insertions(+), 2 deletions(-) diff --git a/Dockerfile b/Dockerfile index 21ceff2..4620787 100644 --- a/Dockerfile +++ b/Dockerfile @@ -1,4 +1,4 @@ -FROM alpine:latest +FROM alpine:3.22.1 AS base RUN apk add --no-cache socat netcat-openbsd \ && rm -rf /var/cache/apk/* /tmp/* @@ -7,4 +7,20 @@ COPY entrypoint.sh VERSION / RUN mkdir -p /socket \ && chmod +x /entrypoint.sh -ENTRYPOINT ["/entrypoint.sh"] \ No newline at end of file +FROM base AS test + +RUN apk add --no-cache bats bash procps + +WORKDIR /app +COPY entrypoint.sh VERSION /app/ +COPY tests/ /app/tests/ + +FROM base AS lint + +RUN apk add --no-cache shellcheck +RUN shellcheck --severity=error -s sh /entrypoint.sh + +# Kept as the last stage so `docker build .` (no --target) still produces +# the lean prod image, not the `test`/`lint` stages above. +FROM base +ENTRYPOINT ["/entrypoint.sh"] From d75172dd62fc49c76b934f95a74f3c84070e31fe Mon Sep 17 00:00:00 2001 From: Djeex Date: Sat, 22 Aug 2026 23:50:47 +0200 Subject: [PATCH 3/4] Add Gitea Actions CI workflow Adapted from adguard-cidre's pipeline: build, syntax smoke-test, bats unit tests, shellcheck lint, Trivy critical/high scans, then on push to main a versioned publish (VERSION auto-bump, :latest/:X.Y/:X.Y.Z tags retagged from the already-scanned image, and a categorized Gitea Release). Requires the REGISTRY_TOKEN and CI_PUSH_TOKEN repo secrets and branch protection on main (not yet configured on the Gitea side). Co-Authored-By: Claude Sonnet 5 --- .gitea/workflows/ci.yml | 163 ++++++++++++++++++++++++++++++++++++++++ 1 file changed, 163 insertions(+) create mode 100644 .gitea/workflows/ci.yml diff --git a/.gitea/workflows/ci.yml b/.gitea/workflows/ci.yml new file mode 100644 index 0000000..dc545c1 --- /dev/null +++ b/.gitea/workflows/ci.yml @@ -0,0 +1,163 @@ +name: CI + +on: + push: + branches: [main] + pull_request: + branches: [main] + schedule: + - cron: "0 6 * * 1" + +jobs: + build-and-scan: + runs-on: ubuntu-latest + steps: + - name: Checkout + uses: actions/checkout@v7 + with: + fetch-depth: 0 + persist-credentials: false + + - name: Build Docker image + run: docker build -t socat-proxy:ci . + + - name: Smoke test (syntax check) + run: docker run --rm --entrypoint sh socat-proxy:ci -n /entrypoint.sh + + - name: Run unit tests + run: | + docker build --target test -t socat-proxy:test . + docker run --rm socat-proxy:test bats /app/tests/entrypoint.bats + + - name: Lint entrypoint.sh with shellcheck + run: docker build --target lint -t socat-proxy:lint . + + - name: Scan with Trivy (critical - blocking) + run: | + docker run --rm \ + -e DOCKER_HOST=tcp://dockerhost:2375 \ + --add-host=dockerhost:host-gateway \ + aquasec/trivy:0.74.0 image --exit-code 1 --severity CRITICAL socat-proxy:ci + + - name: Scan with Trivy (high - informative) + run: | + docker run --rm \ + -e DOCKER_HOST=tcp://dockerhost:2375 \ + --add-host=dockerhost:host-gateway \ + aquasec/trivy:0.74.0 image --exit-code 0 --severity HIGH socat-proxy:ci + + - name: Publish tagged image + if: github.event_name == 'push' && github.ref == 'refs/heads/main' + run: | + BEFORE="${{ github.event.before }}" + if [ -n "$BEFORE" ] && [ "$BEFORE" != "0000000000000000000000000000000000000000" ] && git cat-file -e "$BEFORE" 2>/dev/null; then + CHANGED=$(git diff --name-only "$BEFORE" "${{ github.sha }}") + else + CHANGED=$(git diff --name-only HEAD~1 HEAD) + fi + echo "Changed files:" + echo "$CHANGED" + + if ! echo "$CHANGED" | grep -qE '^(Dockerfile|entrypoint\.sh|VERSION)$'; then + echo "No container-relevant file changed, skipping publish." + exit 0 + fi + + if echo "$CHANGED" | grep -qE '^VERSION$'; then + echo "VERSION was manually edited in this push, using it as-is." + else + echo "VERSION untouched but container files changed, auto-bumping the build number (Z)." + OLD_VERSION=$(tr -d '[:space:]' < VERSION) + IFS='.' read -r MAJOR MINOR PATCH <<< "$OLD_VERSION" + NEW_VERSION="${MAJOR}.${MINOR}.$((PATCH + 1))" + echo "$NEW_VERSION" > VERSION + + git config user.name "socat-proxy-ci" + git config user.email "ci@git.djeex.fr" + git add VERSION + git commit -m "Bump build version to $NEW_VERSION [skip ci]" + + # Belt and suspenders: actions/checkout can leave its own ephemeral + # credential injected as an extraheader, which would silently override + # the URL-embedded token below. persist-credentials:false on checkout + # should already prevent this, but strip it here too just in case. + git config --unset-all http.https://git.djeex.fr/.extraheader || true + + git push "https://Djeex:${{ secrets.CI_PUSH_TOKEN }}@git.djeex.fr/Djeex/socat-proxy.git" HEAD:main + fi + + VERSION=$(tr -d '[:space:]' < VERSION) + IFS='.' read -r MAJOR MINOR PATCH <<< "$VERSION" + MINOR_TAG="${MAJOR}.${MINOR}" + + IMAGE=git.djeex.fr/djeex/socat-proxy + echo "${{ secrets.REGISTRY_TOKEN }}" | docker login git.djeex.fr -u Djeex --password-stdin + + # Retag the already-built, already-scanned image โ€” never rebuild for publish, + # so what ships is byte-for-byte what Trivy just scanned. + docker tag socat-proxy:ci "$IMAGE:latest" + docker tag socat-proxy:ci "$IMAGE:$MINOR_TAG" + docker tag socat-proxy:ci "$IMAGE:$VERSION" + docker push "$IMAGE:latest" + docker push "$IMAGE:$MINOR_TAG" + docker push "$IMAGE:$VERSION" + + TRIGGER_MSG=$(git log -1 --format=%s "${{ github.sha }}") + PR_NUM=$(echo "$TRIGGER_MSG" | grep -oE '#[0-9]+' | head -1 | tr -d '#' || true) + + CATEGORY="๐Ÿ”ง Maintenance" + CHANGE_TITLE="$TRIGGER_MSG" + + if [ -n "$PR_NUM" ]; then + PR_JSON=$(curl -s -H "Authorization: token ${{ secrets.CI_PUSH_TOKEN }}" \ + "https://git.djeex.fr/api/v1/repos/Djeex/socat-proxy/pulls/$PR_NUM") + PR_TITLE=$(echo "$PR_JSON" | jq -r '.title // empty' 2>/dev/null || true) + LABELS=$(echo "$PR_JSON" | jq -r '.labels[]?.name' 2>/dev/null || true) + + if [ -n "$PR_TITLE" ]; then + CHANGE_TITLE="$PR_TITLE" + fi + + if echo "$LABELS" | grep -qx 'bug'; then + CATEGORY="โš ๏ธ Hotfix" + elif echo "$LABELS" | grep -qx 'major'; then + CATEGORY="๐Ÿ’ฅ Breaking change" + elif echo "$LABELS" | grep -qx 'minor'; then + CATEGORY="โœจ Update" + fi + fi + + CHANGED_LIST=$(echo "$CHANGED" | sed 's/^/- /') + + REPO_URL="https://git.djeex.fr/Djeex/socat-proxy" + SHORT_SHA=$(echo "${{ github.sha }}" | cut -c1-7) + SOURCE_LINE="[${SHORT_SHA}](${REPO_URL}/commit/${{ github.sha }})" + if [ -n "$PR_NUM" ]; then + SOURCE_LINE="[#${PR_NUM}](${REPO_URL}/pulls/${PR_NUM}) ยท ${SOURCE_LINE}" + fi + + BODY=$(cat < Date: Sun, 23 Aug 2026 00:02:11 +0200 Subject: [PATCH 4/4] Add Renovate config MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Same policy as adguard-cidre: patch-level Dockerfile bumps automerge, minor/major get a labeled PR, vulnerability alerts labeled bug. No pip_requirements rule โ€” this repo has no Python dependencies to manage. Still needed on the Gitea/Renovate side (manual, not done here): uncomment socat-proxy in the shared ~/renovate/config.js repositories array on stockeex, and create the bot/major/minor/bug labels in this repo's Issues โ†’ Labels (Renovate silently drops labels that don't already exist). Co-Authored-By: Claude Sonnet 5 --- renovate.json | 25 +++++++++++++++++++++++++ 1 file changed, 25 insertions(+) create mode 100644 renovate.json diff --git a/renovate.json b/renovate.json new file mode 100644 index 0000000..0b79022 --- /dev/null +++ b/renovate.json @@ -0,0 +1,25 @@ +{ + "$schema": "https://docs.renovatebot.com/renovate-schema.json", + "extends": ["config:recommended"], + "timezone": "Europe/Paris", + "labels": ["bot"], + "packageRules": [ + { + "matchManagers": ["dockerfile"], + "matchUpdateTypes": ["patch"], + "automerge": true + }, + { + "matchUpdateTypes": ["major"], + "addLabels": ["major"] + }, + { + "matchUpdateTypes": ["minor"], + "addLabels": ["minor"] + } + ], + "vulnerabilityAlerts": { + "enabled": true, + "addLabels": ["bug"] + } +}