Files
computer-playbook/roles/svc-db-openldap/files/test/test.sh
kevinveenbirkenbachandClaude Opus 5 04eac9d73d fix(svc-db-tests): report why the SOCKS connect failed instead of swallowing it
The Tor-path credential tests tunnel their client through a local SOCKS5 shim.
When the CONNECT fails, handle() closed the client socket and returned, so the
client library saw nothing but an empty socket and reported whatever its own
generic read-EOF error is.

For MySQL that is `(2013, 'Lost connection to MySQL server during query')` —
which claims a query was in flight when none ever was. Fediwall 1 printed that
twenty times and it sent the investigation looking for a server that had hung
up, when in truth no HiddenServicePort existed behind the dialled onion port.
Postgres and ldap3 are less misleading ("server closed the connection
unexpectedly", "Connection reset by peer") but equally silent about the cause.

Reproduced for all three against a SOCKS5 proxy that completes the no-auth
greeting and rejects CONNECT with rep=5, running the real python block out of
each test.sh: every one now prints `socks connect failed: SOCKS5 CONNECT failed
rep=5` ahead of its library error.

This does not change any verdict — the root cause is fixed by 2ba3a14b9, where
tor_reachable requires both `services.tor.enabled` and an `exposed: true` that
actually earns a forward. This only stops the next occurrence from lying.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-10 19:33:32 +02:00

179 lines
5.0 KiB
Bash

#!/usr/bin/env bash
# shellcheck shell=bash
#
# svc-db-openldap credential test. Bind to the directory with the administrator
# DN + password (a real credential check) over the transport chosen by the
# variant:
# tor enabled (variant 0, exposed) -> over the Tor network: bind to
# <node-onion>:<port> through the SOCKS proxy (ldap3 via a
# local SOCKS5 forward).
# tor disabled (variant 1) -> local: bind inside the LDAP container
# (ldapwhoami over TCP).
#
# Env (rendered into test.env from templates/test.env.j2):
# TOR_ENABLED true|false (lookup('tor_reachable', application_id))
# TOR_SOCKS SOCKS proxy (set in test.env.j2)
# ONION_HOST node onion host (svc-net-tor services.tor.node)
# DB_PORT onion-forwarded LDAP port
# DB_CONTAINER LDAP container name
# BIND_DN_B64 base64 administrator bind DN
# DB_PASSWORD_B64 base64 bind password
# RETRIES attempts (default 20)
# SLEEP_SECONDS wait between (default 15)
set -uo pipefail
RETRIES="${RETRIES:-20}"
SLEEP_SECONDS="${SLEEP_SECONDS:-15}"
BIND_DN="$(printf '%s' "${BIND_DN_B64}" | base64 -d 2>/dev/null)"
DB_PASSWORD="$(printf '%s' "${DB_PASSWORD_B64}" | base64 -d 2>/dev/null)"
[ -n "${BIND_DN}" ] || { echo "[FATAL] BIND_DN_B64 missing or undecodable" >&2; exit 2; }
[ -n "${DB_PASSWORD}" ] || { echo "[FATAL] DB_PASSWORD_B64 missing or undecodable" >&2; exit 2; }
# Local: bind inside the container over TCP — a genuine credential check.
auth_local() {
printf '%s' "${DB_PASSWORD}" | container exec -i "${DB_CONTAINER}" \
ldapwhoami -x -H ldap://127.0.0.1:389 -D "${BIND_DN}" -y /dev/stdin \
>/dev/null 2>&1
}
# Over Tor: ldap3 (on this controller) binds through a local SOCKS5 -> onion
# forward with the administrator credentials.
auth_tor() {
BIND_DN="${BIND_DN}" DB_PW="${DB_PASSWORD}" \
python3 - "${TOR_SOCKS}" "${ONION_HOST}" "${DB_PORT}" <<'PY'
import os, socket, sys, threading
proxy, onion, port = sys.argv[1], sys.argv[2], int(sys.argv[3])
ph, pp = proxy.rsplit(":", 1)
pp = int(pp)
def recvall(s, n):
buf = b""
while len(buf) < n:
chunk = s.recv(n - len(buf))
if not chunk:
raise OSError("short read from SOCKS")
buf += chunk
return buf
def socks_connect():
s = socket.create_connection((ph, pp), timeout=120)
s.settimeout(120)
s.sendall(b"\x05\x01\x00")
if recvall(s, 2) != b"\x05\x00":
raise OSError("SOCKS5 no-auth rejected")
host = onion.encode()
s.sendall(b"\x05\x01\x00\x03" + bytes([len(host)]) + host + port.to_bytes(2, "big"))
hdr = recvall(s, 4)
if hdr[1] != 0:
raise OSError("SOCKS5 CONNECT failed rep=%d" % hdr[1])
atyp = hdr[3]
if atyp == 1:
recvall(s, 4)
elif atyp == 3:
recvall(s, recvall(s, 1)[0])
elif atyp == 4:
recvall(s, 16)
recvall(s, 2)
s.settimeout(None)
return s
def pipe(a, b):
try:
while True:
data = a.recv(65536)
if not data:
break
b.sendall(data)
except OSError:
pass
finally:
try:
b.shutdown(socket.SHUT_WR)
except OSError:
pass
def handle(conn):
try:
up = socks_connect()
except OSError as exc:
print("socks connect failed: %s" % exc, file=sys.stderr)
conn.close()
return
threading.Thread(target=pipe, args=(conn, up), daemon=True).start()
pipe(up, conn)
lsock = socket.socket()
lsock.setsockopt(socket.SOL_SOCKET, socket.SO_REUSEADDR, 1)
lsock.bind(("127.0.0.1", 0))
lsock.listen(4)
lport = lsock.getsockname()[1]
def serve():
while True:
try:
conn, _ = lsock.accept()
except OSError:
return
threading.Thread(target=handle, args=(conn,), daemon=True).start()
threading.Thread(target=serve, daemon=True).start()
from ldap3 import Connection, Server
try:
server = Server("127.0.0.1", port=lport, connect_timeout=120)
conn = Connection(
server,
user=os.environ["BIND_DN"],
password=os.environ["DB_PW"],
auto_bind=True,
receive_timeout=120,
)
who = conn.extend.standard.who_am_i()
conn.unbind()
except Exception as exc:
print("bind error: %s" % exc, file=sys.stderr)
sys.exit(1)
sys.exit(0 if who else 1)
PY
}
run_auth() {
if [ "${TOR_ENABLED}" = "true" ]; then
auth_tor
else
auth_local
fi
}
if [ "${TOR_ENABLED}" = "true" ]; then
[ -n "${ONION_HOST}" ] || { echo "[FATAL] ONION_HOST unset but tor enabled" >&2; exit 2; }
mode="over Tor"
else
[ -n "${DB_CONTAINER}" ] || { echo "[FATAL] DB_CONTAINER unset for local auth" >&2; exit 2; }
mode="local"
fi
attempt=1
while [ "${attempt}" -le "${RETRIES}" ]; do
if run_auth; then
echo "[OK] openldap credential bind ${mode} succeeded"
exit 0
fi
attempt=$((attempt + 1))
[ "${attempt}" -le "${RETRIES}" ] && sleep "${SLEEP_SECONDS}"
done
echo "[FAIL] openldap credential bind ${mode} failed after ${RETRIES} attempts" >&2
exit 1