v0.22.0 several enhancements and fixes for security, network, doc, test, packaging
Test / test (push) Canceled after 0s
Test / e2e (push) Canceled after 0s

This commit is contained in:
lixen committed 2026-10-03 14:52:16 -04:00
1 parent 9ab8c356d2
commit 71d1b82f83
102 files changed
+7102 -2178

No files matched your search

+57 -166
View File
@@ -1,95 +1,33 @@
#!/usr/bin/env bash
# logwisp chain topology test
#
# random --> tcp_chain sink --> :15801 tcp_chain src --> :15803 tcp sink
# random --> http_chain sink --> :15802 http_chain src --> :15804 http sink (SSE)
#
# Usage:
# ./chain_aggregate_test.sh manual mode: 2 edge daemons + relay foreground
# ./chain_aggregate_test.sh --auto all daemonized, automated curl//dev/tcp checks, teardown
# ./chain_aggregate_test.sh --keep (with --auto) skip teardown on success
#
# The relay aggregates both sinks and fans out aggregated streams into both http and tcp sinks
#
# logwisp chain aggregation test: edge-tcp and edge-http feed one relay
# pipeline that fans both chain sources in and out to a tcp and an http sink.
# Requires: bash 5+, coreutils (timeout), curl. Linux dev host only.
set -u
. "$(dirname -- "${BASH_SOURCE[0]}")/lib.sh"
SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
BIN="${LOGWISP_BIN:-$SCRIPT_DIR/../bin/logwisp}"
RUN="$SCRIPT_DIR/run"
CONF="$RUN/conf"
LOG="$RUN/log"
RUN=$E2E_DIR/run
PORT_TCP_CHAIN=15801
PORT_HTTP_CHAIN=15802
PORT_TCP_SINK=15803
PORT_HTTP_SINK=15804
PORTS="$PORT_TCP_CHAIN $PORT_HTTP_CHAIN $PORT_TCP_SINK $PORT_HTTP_SINK"
e2e_init "$@"
AUTO=0; KEEP=0
for a in "$@"; do case "$a" in
--auto) AUTO=1 ;;
--keep) KEEP=1 ;;
*) echo "unknown arg: $a" >&2; exit 1 ;;
esac; done
PIDS=()
cleanup() {
local rc=$?
trap - EXIT INT TERM
if (( ${#PIDS[@]} )); then
echo "--- teardown: stopping ${#PIDS[@]} daemon(s)"
kill -TERM "${PIDS[@]}" 2>/dev/null
local deadline=$(( SECONDS + 10 ))
for pid in "${PIDS[@]}"; do
while kill -0 "$pid" 2>/dev/null && (( SECONDS < deadline )); do sleep 0.2; done
kill -KILL "$pid" 2>/dev/null
done
fi
exit "$rc"
}
trap cleanup EXIT INT TERM
port_open() { (exec 3<>"/dev/tcp/127.0.0.1/$1") 2>/dev/null && exec 3>&-; }
wait_port() { # port timeout_s
local i; for (( i=0; i < $2 * 10; i++ )); do
port_open "$1" && return 0
sleep 0.1
done
return 1
}
# Reads TCP sink stream for N seconds; connection counts as a sink client
tcp_read() { # port secs
timeout "$2" bash -c "exec 3<>/dev/tcp/127.0.0.1/$1; cat <&3" 2>/dev/null || true
}
start_daemon() { # name conf
"$BIN" -c "$CONF/$2" > "$LOG/$1.out" 2>&1 &
PIDS+=($!)
echo "started $1 (pid $!)"
}
# --- Preflight ---
[[ -x "$BIN" ]] || { echo "binary not found: $BIN (build: go build -o bin/logwisp ./cmd/logwisp)" >&2; exit 1; }
for p in $PORT_TCP_CHAIN $PORT_HTTP_CHAIN $PORT_TCP_SINK $PORT_HTTP_SINK; do
port_open "$p" && { echo "port $p already in use" >&2; exit 1; }
done
section "Setup"
need curl
ports_free $PORTS
mkdir -p "$CONF" "$LOG"
# --- Config generation ---
cat > "$CONF/edge_tcp.toml" <<EOF
edge_conf() { # name sink_type port [sink options]
cat >"$CONF/$1.toml" <<EOF
status_reporter = false
[logging]
output = "file"
output = "stdout"
level = "info"
[logging.file]
directory = "$LOG"
name = "edge_tcp"
[[pipelines]]
name = "edge_tcp"
name = "$1"
[[pipelines.plugin_sources]]
id = "rand"
type = "random"
@@ -99,42 +37,18 @@ format = "txt"
length = 24
[[pipelines.plugin_sinks]]
id = "to_relay"
type = "tcp_chain"
type = "$2"
[pipelines.plugin_sinks.config]
host = "127.0.0.1"
port = $PORT_TCP_CHAIN
node = "edge-tcp"
port = $3
node = "${1/_/-}"
${4:-}
EOF
}
edge_conf edge_tcp tcp_chain $PORT_TCP_CHAIN
edge_conf edge_http http_chain $PORT_HTTP_CHAIN "flush_interval_ms = 500"
cat > "$CONF/edge_http.toml" <<EOF
status_reporter = false
[logging]
output = "file"
level = "info"
[logging.file]
directory = "$LOG"
name = "edge_http"
[[pipelines]]
name = "edge_http"
[[pipelines.plugin_sources]]
id = "rand"
type = "random"
[pipelines.plugin_sources.config]
interval_ms = 200
format = "txt"
length = 24
[[pipelines.plugin_sinks]]
id = "to_relay"
type = "http_chain"
[pipelines.plugin_sinks.config]
host = "127.0.0.1"
port = $PORT_HTTP_CHAIN
node = "edge-http"
flush_interval_ms = 500
EOF
cat > "$CONF/relay.toml" <<EOF
cat >"$CONF/relay.toml" <<EOF
status_reporter = false
[logging]
output = "stdout"
@@ -175,74 +89,51 @@ type = "http"
host = "127.0.0.1"
port = $PORT_HTTP_SINK
EOF
info "configurations in $(short "$CONF")/"
# --- Guide ---
cat <<EOF
================================================================
logwisp chain test — port map
$PORT_TCP_CHAIN relay ingest (tcp_chain, from edge-tcp)
$PORT_HTTP_CHAIN relay ingest (http_chain, from edge-http, POST /ingest)
$PORT_TCP_SINK TCP sink -> live check: nc 127.0.0.1 $PORT_TCP_SINK
$PORT_HTTP_SINK HTTP sink -> live check: browser/curl:
http://127.0.0.1:$PORT_HTTP_SINK/stream (SSE)
http://127.0.0.1:$PORT_HTTP_SINK/status (JSON stats)
Use 127.0.0.1, not localhost — sinks reject IPv6.
Expected: json entries from BOTH nodes ("edge-tcp", "edge-http")
interleaved on :$PORT_TCP_SINK and :$PORT_HTTP_SINK.
Logs: $LOG/
================================================================
EOF
if (( AUTO == 0 )); then
# Manual mode: edges daemonized first (chain sinks backoff-retry), relay foreground
start_daemon edge_tcp edge_tcp.toml
start_daemon edge_http edge_http.toml
echo "--- relay starting in FOREGROUND; Ctrl-C stops relay and both edges"
"$BIN" -c "$CONF/relay.toml"
exit 0
fi
# --- Auto mode ---
section "Startup"
start_daemon relay relay.toml
for p in $PORT_TCP_CHAIN $PORT_HTTP_CHAIN $PORT_TCP_SINK $PORT_HTTP_SINK; do
wait_port "$p" 10 || { echo "FAIL: relay port $p not listening (see $LOG/relay.out)"; exit 1; }
for p in $PORTS; do
wait_port "$p" || abort "relay port $p is not listening" relay
done
start_daemon edge_tcp edge_tcp.toml
# Chain sinks retry with backoff, so the edges could start first too
start_daemon edge_tcp edge_tcp.toml
start_daemon edge_http edge_http.toml
daemons_up
echo "--- settling 3s (connect + first http_chain flush)"
guide "logwisp chain aggregation test" <<EOF
Ports (IPv4 only: 127.0.0.1, not localhost):
$PORT_TCP_CHAIN relay tcp_chain ingest, from edge-tcp
$PORT_HTTP_CHAIN relay http_chain ingest (POST /ingest), from edge-http
$PORT_TCP_SINK tcp sink: both edges' entries interleaved, as JSON
$PORT_HTTP_SINK http sink: the same, SSE /stream, JSON /status
Watch:
> nc 127.0.0.1 $PORT_TCP_SINK
> curl -N http://127.0.0.1:$PORT_HTTP_SINK/stream
> curl http://127.0.0.1:$PORT_HTTP_SINK/status
Logs: $(short "$LOG")/
EOF
manual_hold
section "Checks"
info "settling 3 s (connect, first http_chain flush)"
sleep 3
fail=0
check() { # label condition_result
if (( $2 )); then echo "PASS: $1"; else echo "FAIL: $1"; fail=1; fi
}
# 1. TCP chain: edge-tcp -> relay -> tcp sink
# 1. tcp sink: both edges, aggregated
tcp_out="$(tcp_read "$PORT_TCP_SINK" 4)"
#nt=$(grep -c '"node":"edge-tcp"' <<< "$tcp_out")
#nh=$(grep -c '"node":"edge-http"' <<< "$tcp_out")
nt=$(grep -c 'edge-tcp/' <<< "$tcp_out")
nh=$(grep -c 'edge-http/' <<< "$tcp_out")
check "tcp sink: aggregated edge-tcp ($nt) + edge-http ($nh)" $(( nt >= 1 && nh >= 1 ))
nt=$(grep -c 'edge-tcp/' <<<"$tcp_out")
nh=$(grep -c 'edge-http/' <<<"$tcp_out")
check "tcp sink: aggregated edge-tcp ($nt) + edge-http ($nh)" $((nt >= 1 && nh >= 1))
# 2. HTTP chain: edge-http -> relay -> SSE sink
sse_out="$(curl -sN --max-time 4 "http://127.0.0.1:$PORT_HTTP_SINK/stream" || true)"
nt=$(grep -c '^data:.*edge-tcp/' <<< "$sse_out")
nh=$(grep -c '^data:.*edge-http/' <<< "$sse_out")
check "http sink: aggregated edge-tcp ($nt) + edge-http ($nh)" $(( nt >= 1 && nh >= 1 ))
# 2. http sink: both edges, aggregated
sse_out="$(curl -sN --noproxy '*' --max-time 4 "http://127.0.0.1:$PORT_HTTP_SINK/stream" || true)"
nt=$(grep -c '^data:.*edge-tcp/' <<<"$sse_out")
nh=$(grep -c '^data:.*edge-http/' <<<"$sse_out")
check "http sink: aggregated edge-tcp ($nt) + edge-http ($nh)" $((nt >= 1 && nh >= 1))
# 3. HTTP sink status endpoint
status="$(curl -s --max-time 3 "http://127.0.0.1:$PORT_HTTP_SINK/status" || true)"
proc=$(grep -o '"total_processed"[ :] *[0-9]*' <<< "$status" | grep -o '[0-9]*' || echo 0)
check "status endpoint: total_processed=$proc > 0" $(( proc > 0 ))
echo "================================================================"
if (( fail == 0 )); then
echo "RESULT: ALL PASS"
(( KEEP )) && { echo "--keep: daemons left running (pids: ${PIDS[*]})"; PIDS=(); }
else
echo "RESULT: FAILURES — inspect $LOG/*.out and $LOG/*.log"
fi
exit "$fail"
status="$(curl -s --noproxy '*' --max-time 3 "http://127.0.0.1:$PORT_HTTP_SINK/status" || true)"
proc=$(grep -o '"total_processed"[ :] *[0-9]*' <<<"$status" | grep -o '[0-9]*' || echo 0)
check "status endpoint: total_processed=$proc > 0" $((proc > 0))
summary
+53 -163
View File
@@ -1,98 +1,33 @@
#!/usr/bin/env bash
# logwisp chain topology test
#
# random --> tcp_chain sink --> :15801 tcp_chain src --> :15803 tcp sink
# random --> http_chain sink --> :15802 http_chain src --> :15804 http sink (SSE)
#
# Usage:
# ./chain_test.sh manual mode: 2 edge daemons + relay foreground
# ./chain_test.sh --auto all daemonized, automated curl//dev/tcp checks, teardown
# ./chain_test.sh --keep (with --auto) skip teardown on success
#
# relay runs TWO independent pipelines; no cross-path aggregation:
# relay_tcp: tcp_chain src :15801 -> tcp sink :15803 (edge-tcp only)
# relay_http: http_chain src :15802 -> http sink :15804 (edge-http only)
# For fan-in aggregation (both edges -> both sinks) see chain_agg_test.sh.
#
# logwisp chain test: edge-tcp and edge-http feed a relay that runs one
# pipeline per transport, without fan-in (chain-aggregate-test.sh fans in).
# Requires: bash 5+, coreutils (timeout), curl. Linux dev host only.
set -u
. "$(dirname -- "${BASH_SOURCE[0]}")/lib.sh"
SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
BIN="${LOGWISP_BIN:-$SCRIPT_DIR/../bin/logwisp}"
RUN="$SCRIPT_DIR/run"
CONF="$RUN/conf"
LOG="$RUN/log"
RUN=$E2E_DIR/run
PORT_TCP_CHAIN=15801
PORT_HTTP_CHAIN=15802
PORT_TCP_SINK=15803
PORT_HTTP_SINK=15804
PORTS="$PORT_TCP_CHAIN $PORT_HTTP_CHAIN $PORT_TCP_SINK $PORT_HTTP_SINK"
e2e_init "$@"
AUTO=0; KEEP=0
for a in "$@"; do case "$a" in
--auto) AUTO=1 ;;
--keep) KEEP=1 ;;
*) echo "unknown arg: $a" >&2; exit 1 ;;
esac; done
PIDS=()
cleanup() {
local rc=$?
trap - EXIT INT TERM
if (( ${#PIDS[@]} )); then
echo "--- teardown: stopping ${#PIDS[@]} daemon(s)"
kill -TERM "${PIDS[@]}" 2>/dev/null
local deadline=$(( SECONDS + 10 ))
for pid in "${PIDS[@]}"; do
while kill -0 "$pid" 2>/dev/null && (( SECONDS < deadline )); do sleep 0.2; done
kill -KILL "$pid" 2>/dev/null
done
fi
exit "$rc"
}
trap cleanup EXIT INT TERM
port_open() { (exec 3<>"/dev/tcp/127.0.0.1/$1") 2>/dev/null && exec 3>&-; }
wait_port() { # port timeout_s
local i; for (( i=0; i < $2 * 10; i++ )); do
port_open "$1" && return 0
sleep 0.1
done
return 1
}
# Reads TCP sink stream for N seconds; connection counts as a sink client
tcp_read() { # port secs
timeout "$2" bash -c "exec 3<>/dev/tcp/127.0.0.1/$1; cat <&3" 2>/dev/null || true
}
start_daemon() { # name conf
"$BIN" -c "$CONF/$2" > "$LOG/$1.out" 2>&1 &
PIDS+=($!)
echo "started $1 (pid $!)"
}
# --- Preflight ---
[[ -x "$BIN" ]] || { echo "binary not found: $BIN (build: go build -o bin/logwisp ./cmd/logwisp)" >&2; exit 1; }
for p in $PORT_TCP_CHAIN $PORT_HTTP_CHAIN $PORT_TCP_SINK $PORT_HTTP_SINK; do
port_open "$p" && { echo "port $p already in use" >&2; exit 1; }
done
section "Setup"
need curl
ports_free $PORTS
mkdir -p "$CONF" "$LOG"
# --- Config generation ---
cat > "$CONF/edge_tcp.toml" <<EOF
edge_conf() { # name sink_type port [sink options]
cat >"$CONF/$1.toml" <<EOF
status_reporter = false
[logging]
output = "file"
output = "stdout"
level = "info"
[logging.file]
directory = "$LOG"
name = "edge_tcp"
[[pipelines]]
name = "edge_tcp"
name = "$1"
[[pipelines.plugin_sources]]
id = "rand"
type = "random"
@@ -102,42 +37,18 @@ format = "txt"
length = 24
[[pipelines.plugin_sinks]]
id = "to_relay"
type = "tcp_chain"
type = "$2"
[pipelines.plugin_sinks.config]
host = "127.0.0.1"
port = $PORT_TCP_CHAIN
node = "edge-tcp"
port = $3
node = "${1/_/-}"
${4:-}
EOF
}
edge_conf edge_tcp tcp_chain $PORT_TCP_CHAIN
edge_conf edge_http http_chain $PORT_HTTP_CHAIN "flush_interval_ms = 500"
cat > "$CONF/edge_http.toml" <<EOF
status_reporter = false
[logging]
output = "file"
level = "info"
[logging.file]
directory = "$LOG"
name = "edge_http"
[[pipelines]]
name = "edge_http"
[[pipelines.plugin_sources]]
id = "rand"
type = "random"
[pipelines.plugin_sources.config]
interval_ms = 200
format = "txt"
length = 24
[[pipelines.plugin_sinks]]
id = "to_relay"
type = "http_chain"
[pipelines.plugin_sinks.config]
host = "127.0.0.1"
port = $PORT_HTTP_CHAIN
node = "edge-http"
flush_interval_ms = 500
EOF
cat > "$CONF/relay.toml" <<EOF
cat >"$CONF/relay.toml" <<EOF
status_reporter = false
[logging]
output = "stdout"
@@ -179,70 +90,49 @@ type = "http"
host = "127.0.0.1"
port = $PORT_HTTP_SINK
EOF
info "configurations in $(short "$CONF")/"
# --- Guide ---
cat <<EOF
================================================================
logwisp chain test — port map
$PORT_TCP_CHAIN relay ingest (tcp_chain, from edge-tcp)
$PORT_HTTP_CHAIN relay ingest (http_chain, from edge-http, POST /ingest)
$PORT_TCP_SINK TCP sink -> live check: nc 127.0.0.1 $PORT_TCP_SINK
$PORT_HTTP_SINK HTTP sink -> live check: browser/curl:
http://127.0.0.1:$PORT_HTTP_SINK/stream (SSE)
http://127.0.0.1:$PORT_HTTP_SINK/status (JSON stats)
Use 127.0.0.1, not localhost — sinks reject IPv6.
Expected: json entries, node "edge-tcp" on :$PORT_TCP_SINK, "edge-http" on :$PORT_HTTP_SINK.
Logs: $LOG/
================================================================
EOF
if (( AUTO == 0 )); then
# Manual mode: edges daemonized first (chain sinks backoff-retry), relay foreground
start_daemon edge_tcp edge_tcp.toml
start_daemon edge_http edge_http.toml
echo "--- relay starting in FOREGROUND; Ctrl-C stops relay and both edges"
"$BIN" -c "$CONF/relay.toml"
exit 0
fi
# --- Auto mode ---
section "Startup"
start_daemon relay relay.toml
for p in $PORT_TCP_CHAIN $PORT_HTTP_CHAIN $PORT_TCP_SINK $PORT_HTTP_SINK; do
wait_port "$p" 10 || { echo "FAIL: relay port $p not listening (see $LOG/relay.out)"; exit 1; }
for p in $PORTS; do
wait_port "$p" || abort "relay port $p is not listening" relay
done
start_daemon edge_tcp edge_tcp.toml
# Chain sinks retry with backoff, so the edges could start first too
start_daemon edge_tcp edge_tcp.toml
start_daemon edge_http edge_http.toml
daemons_up
echo "--- settling 3s (connect + first http_chain flush)"
guide "logwisp chain test" <<EOF
Ports (IPv4 only: 127.0.0.1, not localhost):
$PORT_TCP_CHAIN relay tcp_chain ingest, from edge-tcp
$PORT_HTTP_CHAIN relay http_chain ingest (POST /ingest), from edge-http
$PORT_TCP_SINK tcp sink: edge-tcp's entries as JSON
$PORT_HTTP_SINK http sink: edge-http's entries, SSE /stream, JSON /status
Watch:
> nc 127.0.0.1 $PORT_TCP_SINK
> curl -N http://127.0.0.1:$PORT_HTTP_SINK/stream
> curl http://127.0.0.1:$PORT_HTTP_SINK/status
Logs: $(short "$LOG")/
EOF
manual_hold
section "Checks"
info "settling 3 s (connect, first http_chain flush)"
sleep 3
fail=0
check() { # label condition_result
if (( $2 )); then echo "PASS: $1"; else echo "FAIL: $1"; fail=1; fi
}
# 1. TCP chain: edge-tcp -> relay -> tcp sink
tcp_out="$(tcp_read "$PORT_TCP_SINK" 4)"
#n=$(grep -c '"node":"edge-tcp"' <<< "$tcp_out")
n=$(grep -c 'edge-tcp/' <<< "$tcp_out")
check "tcp path: entries on :$PORT_TCP_SINK with node=edge-tcp ($n lines)" $(( n >= 1 ))
n=$(grep -c 'edge-tcp/' <<<"$tcp_out")
check "tcp path: entries on :$PORT_TCP_SINK with node=edge-tcp ($n lines)" $((n >= 1))
# 2. HTTP chain: edge-http -> relay -> SSE sink
sse_out="$(curl -sN --max-time 4 "http://127.0.0.1:$PORT_HTTP_SINK/stream" || true)"
n=$(grep -c '^data:.*edge-http/' <<< "$sse_out")
check "http path: SSE events on :$PORT_HTTP_SINK with node=edge-http ($n events)" $(( n >= 1 ))
sse_out="$(curl -sN --noproxy '*' --max-time 4 "http://127.0.0.1:$PORT_HTTP_SINK/stream" || true)"
n=$(grep -c '^data:.*edge-http/' <<<"$sse_out")
check "http path: SSE events on :$PORT_HTTP_SINK with node=edge-http ($n events)" $((n >= 1))
# 3. HTTP sink status endpoint
status="$(curl -s --max-time 3 "http://127.0.0.1:$PORT_HTTP_SINK/status" || true)"
proc=$(grep -o '"total_processed"[ :] *[0-9]*' <<< "$status" | grep -o '[0-9]*' || echo 0)
check "status endpoint: total_processed=$proc > 0" $(( proc > 0 ))
echo "================================================================"
if (( fail == 0 )); then
echo "RESULT: ALL PASS"
(( KEEP )) && { echo "--keep: daemons left running (pids: ${PIDS[*]})"; PIDS=(); }
else
echo "RESULT: FAILURES — inspect $LOG/*.out and $LOG/*.log"
fi
exit "$fail"
status="$(curl -s --noproxy '*' --max-time 3 "http://127.0.0.1:$PORT_HTTP_SINK/status" || true)"
proc=$(grep -o '"total_processed"[ :] *[0-9]*' <<<"$status" | grep -o '[0-9]*' || echo 0)
check "status endpoint: total_processed=$proc > 0" $((proc > 0))
summary
+347
View File
@@ -0,0 +1,347 @@
#!/usr/bin/env bash
# deploy/lw-deploy.sh test: --yes generates edges, aggregators and a standalone
# node for the manual runtime, plain and with TLS and scram, that bin/lw runs;
# --yes fails closed, --dry-run changes nothing (manual, native, docker), and a
# root host with docker and the logwisp:dev image runs the container too.
# Requires: bash 5+, coreutils, curl, openssl. Linux dev host only.
set -u
. "$(dirname -- "${BASH_SOURCE[0]}")/lib.sh"
RUN=$E2E_DIR/run-deploy
DEPLOY=${E2E_DIR%/*}/deploy/lw-deploy.sh
PKI=$RUN/pki
OUT=$RUN/out
APP=$RUN/app # the directory the edges tail
SECRETS=$RUN/secrets
PORT_CHAIN=15871
PORT_HTTP=15872
PORT_TCP=15873
PORT_SA_HTTP=15874
PORT_S_CHAIN=15875
PORT_S_HTTP=15876
PORT_D_CHAIN=15877
PORT_D_HTTP=15878
PORT_DRY=15879 # only written by the dry run, never bound
PORTS="$PORT_CHAIN $PORT_HTTP $PORT_TCP $PORT_SA_HTTP $PORT_S_CHAIN $PORT_S_HTTP $PORT_D_CHAIN $PORT_D_HTTP"
IMAGE=logwisp:dev
CONTAINER=lw-e2e-deploy-aggregator
e2e_init "$@"
deploy() { # NAME ARGS...: lw-deploy.sh --yes, its output in $LOG/deploy-NAME.out
local name=$1
shift
"$DEPLOY" --yes "$@" >"$LOG/deploy-$name.out" 2>&1
}
said() { grep -qF -- "$2" "$LOG/deploy-$1.out"; } # NAME TEXT: lw-deploy.sh printed TEXT
declare -A GEN # NAME -> lw-deploy.sh's exit status
generate() { deploy "$1" --runtime manual --config-dir "$RUN/$1" --bin "$BIN" "${@:2}"; GEN[$1]=$?; }
section "Setup"
need curl openssl
[[ -x $DEPLOY ]] || abort "no $(short "$DEPLOY")"
# A container an earlier run left (--keep) holds its published ports
if [[ -d $RUN/docker ]] && command -v docker >/dev/null; then docker rm -f "$CONTAINER" >/dev/null 2>&1; fi
ports_free $PORTS
rm -rf "$RUN"
mkdir -p "$LOG" "$APP" "$OUT"
pki_ca
pki_leaf aggregator aggregator.internal serverAuth "IP:127.0.0.1"
echo "first line, there before the edges start" >"$APP/app.log"
info "test PKI in $(short "$PKI")/; the edges tail $(short "$APP")/*.log"
generate aggregator --role aggregator --listen 127.0.0.1 --chain-port $PORT_CHAIN \
--file-dir "$OUT/aggregator" --http-port $PORT_HTTP --tcp-port $PORT_TCP
generate edge --role edge --log-dir "$APP" --from start --aggregator 127.0.0.1 --chain-port $PORT_CHAIN \
--node deploy-edge
generate standalone --role standalone --log-dir "$APP" --from start --listen 127.0.0.1 \
--file-dir "$OUT/standalone" --http-port $PORT_SA_HTTP --format json
SECURE_AGGREGATOR=(--role aggregator --listen 127.0.0.1 --chain http --chain-port $PORT_S_CHAIN
--file-dir "$OUT/secure" --http-port $PORT_S_HTTP --tls --cert-file "$PKI/aggregator.crt"
--key-file "$PKI/aggregator.key" --auth scram --add-user edge-01 --sink-auth scram
--add-viewer viewer-01 --secrets-dir "$SECRETS")
generate secure-aggregator "${SECURE_AGGREGATOR[@]}"
generate secure-edge --role edge --log-dir "$APP" --from start --chain http --aggregator 127.0.0.1 \
--chain-port $PORT_S_CHAIN --node deploy-secure --tls --ca-file "$PKI/ca.crt" --auth scram \
--password-file "$SECRETS/edge-01.pass"
info "generated with --yes --runtime manual: aggregator, edge, standalone, secure-aggregator, secure-edge"
section "Startup"
for name in aggregator standalone secure-aggregator edge secure-edge; do
if [[ ${GEN[$name]} != 0 ]]; then
tail -n 5 "$LOG/deploy-$name.out" | sed 's/^/ /'
abort "lw-deploy.sh did not generate $name (exit ${GEN[$name]})"
fi
spawn "$name" "$BIN" -c "$RUN/$name/logwisp.toml"
done
for p in $PORT_CHAIN $PORT_HTTP $PORT_TCP $PORT_SA_HTTP $PORT_S_CHAIN $PORT_S_HTTP; do
wait_port "$p" || abort "port $p is not listening"
done
daemons_up
write_env LW="$BIN" CA="$PKI/ca.crt" PW="$SECRETS/viewer-01.pass" APP="$APP"
guide "lw-deploy.sh test" <<EOF
Ports (generated by lw-deploy.sh --yes --runtime manual):
$PORT_CHAIN aggregator tcp_chain ingest, from edge (node deploy-edge)
$PORT_HTTP aggregator http sink: SSE /stream, JSON /status
$PORT_TCP aggregator tcp sink
$PORT_SA_HTTP standalone http sink, json entries of the same log
$PORT_S_CHAIN secure-aggregator http_chain ingest, TLS + scram user edge-01
$PORT_S_HTTP secure-aggregator http sink, TLS + scram viewer viewer-01
$PORT_D_CHAIN docker aggregator chain ingest ($CONTAINER, --auto only)
$PORT_D_HTTP docker aggregator http sink
Configurations: $(short "$RUN")/NAME/logwisp.toml; outputs: $(short "$OUT")/
Shell setup (LW, CA, PW: viewer-01's password file, APP: the tailed directory):
> . $(short "$RUN")/env
Append a line and watch it arrive:
> curl -N http://127.0.0.1:$PORT_HTTP/stream
> echo hello >>\$APP/app.log
The secured sink (bearer keeps the token off argv):
> token=\$(\$LW auth token -url https://127.0.0.1:$PORT_S_HTTP -user viewer-01 -password-file \$PW -ca-file \$CA)
> curl -N --cacert \$CA -H @<(bearer) https://127.0.0.1:$PORT_S_HTTP/stream
Logs: $(short "$LOG")/
EOF
manual_hold
total_processed() { # PORT: of a plain http sink
curl -s --noproxy '*' --max-time 3 "http://127.0.0.1:$1/status" 2>/dev/null |
grep -o '"total_processed":[0-9]*' | grep -o '[0-9]*$' || echo 0
}
section "Generated configurations"
for name in aggregator edge standalone secure-aggregator secure-edge; do
conf="$RUN/$name/logwisp.toml" ok=0
"$BIN" --check -c "$conf" >>"$LOG/lw-check.out" 2>&1 && ok=1
check "$name: --yes wrote $(short "$conf") (exit ${GEN[$name]}), lw --check accepts it" $ok
done
section "Plain chain and outputs"
wait_until 10 grep -q 'deploy-edge/app.log "first line' "$OUT/aggregator/logwisp.log"
n=$(grep -c 'deploy-edge/app.log "first line' "$OUT/aggregator/logwisp.log")
check "edge -> aggregator: the line there at start reached the file output ($n)" $((n == 1))
live="appended line $$"
tcp_read $PORT_TCP 4 >"$RUN/tcp.out" &
tcp_pid=$!
curl -sN --noproxy '*' --max-time 4 "http://127.0.0.1:$PORT_HTTP/stream" >"$RUN/sse.out" 2>/dev/null &
sse_pid=$!
sleep 1
echo "$live" >>"$APP/app.log"
wait "$tcp_pid" "$sse_pid"
wait_until 10 grep -q "$live" "$OUT/aggregator/logwisp.log"
n=$(grep -c "deploy-edge/app.log \"$live" "$OUT/aggregator/logwisp.log")
check "edge -> aggregator: a line appended later reached the file output ($n)" $((n == 1))
n=$(grep -c "^data:.*deploy-edge/app.log \"$live" "$RUN/sse.out")
check "aggregator http sink: /stream delivered it live ($n events)" $((n == 1))
n=$(grep -c "deploy-edge/app.log \"$live" "$RUN/tcp.out")
check "aggregator tcp sink: delivered it live ($n lines)" $((n == 1))
n=$(total_processed $PORT_HTTP)
check "aggregator http sink: /status counts both lines (total_processed=$n)" $((n >= 2))
wait_until 10 grep -q "$live" "$OUT/standalone/logwisp.log"
n=$(grep -c -e '"fields":\["first line' -e "\"fields\":\[\"$live" "$OUT/standalone/logwisp.log")
check "standalone: both lines in its file output, as json ($n)" $((n == 2))
n=$(total_processed $PORT_SA_HTTP)
check "standalone http sink: /status counts them (total_processed=$n)" $((n >= 2))
section "TLS and scram"
wait_until 20 grep -q "edge-01/app.log \"$live" "$OUT/secure/logwisp.log"
n=$(grep -c 'edge-01/app.log' "$OUT/secure/logwisp.log")
check "secure-edge -> secure-aggregator: entries over http_chain, TLS and scram ($n lines)" $((n >= 2))
n=$(grep -c 'deploy-secure/' "$OUT/secure/logwisp.log")
check "secure-aggregator: the node label is the username, not the edge's own ($n deploy-secure lines)" $((n == 0))
modes="$(stat -c %a "$SECRETS/edge-01.pass" "$SECRETS/viewer-01.pass" 2>/dev/null | paste -sd' ')"
ok=$(is "$modes" "600 600")
grep -qx 'username = "edge-01"' "$RUN/secure-aggregator/users.toml" || ok=0
grep -qx 'username = "viewer-01"' "$RUN/secure-aggregator/viewers.toml" || ok=0
grep -q 'viewer-01' "$RUN/secure-aggregator/users.toml" && ok=0
check "secure-aggregator: passwords generated (modes $modes), edge-01 in users.toml, viewer-01 in viewers.toml" $ok
ok=1
cmp -s "$PKI/aggregator.crt" "$RUN/secure-aggregator/tls/node.crt" || ok=0
cmp -s "$PKI/aggregator.key" "$RUN/secure-aggregator/tls/node.key" || ok=0
cmp -s "$PKI/ca.crt" "$RUN/secure-edge/tls/ca.crt" || ok=0
cmp -s "$SECRETS/edge-01.pass" "$RUN/secure-edge/chain.pass" || ok=0
check "certificates, key and the edge's password copied into the configuration directories" $ok
token="$("$BIN" auth token -url "https://127.0.0.1:$PORT_S_HTTP" -user viewer-01 \
-password-file "$SECRETS/viewer-01.pass" -ca-file "$PKI/ca.crt" 2>>"$LOG/auth-cli.out")"
code="$(curl -s -o /dev/null -w '%{http_code}' --max-time 5 --noproxy '*' --cacert "$PKI/ca.crt" \
-H @<(printf 'Authorization: Bearer %s\n' "$token") "https://127.0.0.1:$PORT_S_HTTP/status" 2>/dev/null)"
check "secure-aggregator http sink: viewer-01's token opens /status (HTTP $code)" $(is "$code" 200)
code="$(curl -s -o /dev/null -w '%{http_code}' --max-time 5 --noproxy '*' --cacert "$PKI/ca.crt" \
"https://127.0.0.1:$PORT_S_HTTP/status" 2>/dev/null)"
check "secure-aggregator http sink: /status refused without a token (HTTP $code)" $(is "$code" 401)
section "Re-running a deployment"
conf="$RUN/secure-aggregator/logwisp.toml"
before="$(cksum <"$conf")" pass_before="$(cksum <"$SECRETS/edge-01.pass")"
deploy rerun "${SECURE_AGGREGATOR[@]}" --runtime manual --config-dir "$RUN/secure-aggregator" --bin "$BIN"
rc=$?
ok=$((rc != 0))
grep -q 'exists (--force replaces it)' "$LOG/deploy-rerun.out" || ok=0
[[ $(cksum <"$conf") == "$before" ]] || ok=0
check "without --force, --yes refuses to replace a configuration (exit $rc)" $ok
deploy rerun-force "${SECURE_AGGREGATOR[@]}" --runtime manual --config-dir "$RUN/secure-aggregator" --bin "$BIN" --force
rc=$?
ok=$((rc == 0))
[[ -f $conf.bak && $(cksum <"$conf.bak") == "$before" ]] || ok=0
[[ $(cksum <"$SECRETS/edge-01.pass") == "$pass_before" ]] || ok=0
check "with --force: the old configuration kept as logwisp.toml.bak, the generated passwords reused (exit $rc)" $ok
section "--yes fails closed"
deploy fail-edge --runtime manual --role edge --config-dir "$RUN/fail-edge" --bin "$BIN" --log-dir "$APP"
rc=$?
ok=$((rc != 0))
grep -q 'missing value: --aggregator' "$LOG/deploy-fail-edge.out" || ok=0
[[ -e $RUN/fail-edge ]] && ok=0
check "an edge without --aggregator stops before writing anything (exit $rc)" $ok
deploy fail-aggregator --runtime manual --role aggregator --config-dir "$RUN/fail-aggregator" --bin "$BIN"
rc=$?
ok=$((rc != 0))
grep -q 'no output: give --file-dir, --http-port or --tcp-port' "$LOG/deploy-fail-aggregator.out" || ok=0
[[ -e $RUN/fail-aggregator ]] && ok=0
check "an aggregator without an output stops before writing anything (exit $rc)" $ok
deploy implied-auth --dry-run --runtime manual --role aggregator --config-dir "$RUN/dry/conf" --listen 127.0.0.1 \
--http-port $PORT_DRY --tls --cert-file "$PKI/aggregator.crt" --key-file "$PKI/aggregator.key" \
--ca-file "$PKI/ca.crt" --add-user edge-01 --sink-allow viewer-01 --secrets-dir "$RUN/dry/secrets"
rc=$?
ok=$((rc == 0))
said implied-auth "over tcp_chain (TLS, auth scram)" || ok=0
said implied-auth "/stream /status (txt, TLS, auth mtls)" || ok=0
check "without --auth and --sink-auth, --add-user implies scram and --sink-allow mtls (exit $rc)" $ok
deploy fail-auth --runtime manual --role aggregator --config-dir "$RUN/fail-auth" --bin "$BIN" \
--file-dir "$RUN/fail-auth/out" --tls --cert-file "$PKI/aggregator.crt" --key-file "$PKI/aggregator.key" \
--auth none --add-user edge-01 --secrets-dir "$RUN/fail-auth/secrets"
rc=$?
ok=$((rc != 0))
said fail-auth '--add-user needs --auth scram' || ok=0
[[ -e $RUN/fail-auth ]] && ok=0
check "an --add-user that --auth none contradicts stops before writing anything (exit $rc)" $ok
deploy fail-pattern --runtime manual --role standalone --config-dir "$RUN/fail-pattern" --bin "$BIN" --log-dir "$APP" \
--file-dir "$RUN/fail-pattern/out" --log-pattern $'*.log\nfrom = "start"'
rc=$?
ok=$((rc != 0))
said fail-pattern 'invalid --log-pattern' || ok=0
[[ -e $RUN/fail-pattern ]] && ok=0
check "a --log-pattern with a newline stops before writing anything (exit $rc)" $ok
(cd "$RUN" && deploy fail-secrets --runtime manual --role aggregator --config-dir "$RUN/fail-secrets" \
--bin "$BIN" --file-dir "$RUN/fail-secrets/out" --tls --cert-file "$PKI/aggregator.crt" \
--key-file "$PKI/aggregator.key" --auth scram --add-user edge-01 --secrets-dir '~/secrets')
rc=$?
ok=$((rc != 0))
said fail-secrets 'not an absolute path: ~/secrets' || ok=0
[[ -e $RUN/fail-secrets || -e "$RUN/~" ]] && ok=0
check "a --secrets-dir of ~/secrets stops before writing anything (exit $rc)" $ok
deploy fail-check --runtime manual --role aggregator --config-dir "$RUN/fail-check" --bin "$BIN" \
--file-dir "$RUN/fail-check/out" --tls --cert-file "$APP/app.log" --key-file "$APP/app.log"
rc=$?
ok=$((rc != 0))
said fail-check 'lw --check rejects' || ok=0
check "a configuration lw --check rejects (a log file as the certificate) stops the run (exit $rc)" $ok
deploy fail-tmp --dry-run --runtime native --role standalone --log-dir "$APP" --file-dir /tmp/lw-deploy-test \
--bin "$BIN"
rc=$?
ok=$((rc != 0))
said fail-tmp 'PrivateTmp=yes' || ok=0
check "native Linux: a --file-dir under /tmp, which the unit makes private, stops the run (exit $rc)" $ok
# A failing install stands in for the real one: a regression must not install onto this host
mkdir -p "$RUN/stub"
printf '#!/bin/sh\nexit 1\n' >"$RUN/stub/install"
chmod +x "$RUN/stub/install"
PATH="$RUN/stub:$PATH" deploy fail-os --runtime native --os freebsd --role standalone --log-dir "$APP" \
--file-dir "$RUN/fail-os" --bin "$BIN"
rc=$?
ok=$((rc != 0))
said fail-os 'needs a freebsd host' || ok=0
said fail-os '+ install' && ok=0
check "--os freebsd --runtime native on this Linux host stops before changing anything (exit $rc)" $ok
section "--dry-run changes nothing"
# What a dry run could touch: the run directory (minus what the daemons write)
# and what a native install writes
snapshot() {
find "$RUN" \( -path "$LOG" -o -path "$OUT" \) -prune -o -printf '%p %s %T@ %m\n' | sort | cksum
stat -c '%n %s %Y' /usr/local/bin/lw /etc/logwisp /etc/systemd/system/logwisp.service \
/etc/systemd/system/logwisp.service.d /etc/sysusers.d/logwisp.conf /etc/tmpfiles.d/logwisp.conf 2>&1
}
before="$(snapshot)"
deploy dry-manual --dry-run --runtime manual --role aggregator --config-dir "$RUN/dry/conf" --bin "$BIN" \
--listen 127.0.0.1 --http-port $PORT_DRY --file-dir "$RUN/dry/out" --tls --cert-file "$PKI/aggregator.crt" \
--key-file "$PKI/aggregator.key" --auth scram --add-user edge-01 --sink-auth scram --add-viewer viewer-01 \
--secrets-dir "$RUN/dry/secrets"
rc=$?
ok=$((rc == 0))
said dry-manual "+ write $RUN/dry/conf/logwisp.toml" || ok=0
said dry-manual "+ generate a password into $RUN/dry/secrets/edge-01.pass" || ok=0
[[ -e $RUN/dry || $(snapshot) != "$before" ]] && ok=0
check "manual: printed the configuration and the passwords it would generate, wrote nothing (exit $rc)" $ok
deploy dry-zone --dry-run --runtime manual --role aggregator --config-dir "$RUN/dry/conf" --listen fe80::1%lo \
--http-port $PORT_DRY --file-dir "$RUN/dry/out" --tls --cert-file "$PKI/aggregator.crt" \
--key-file "$PKI/aggregator.key" --sink-auth scram --add-viewer viewer-01 --secrets-dir "$RUN/dry/secrets"
rc=$?
ok=$((rc == 0))
said dry-zone "-url https://[fe80::1%25lo]:$PORT_DRY " || ok=0
said dry-zone 'host = "fe80::1%lo"' || ok=0
check "a link-local listener: its URLs escape the zone as %25, its host keeps it (exit $rc)" $ok
deploy dry-native --dry-run --runtime native --role edge --log-dir "$APP" --aggregator 127.0.0.1 \
--chain-port $PORT_CHAIN --node deploy-native --bin "$BIN"
rc=$?
ok=$((rc == 0))
said dry-native "+ install -m 0755 $BIN /usr/local/bin/lw" || ok=0
said dry-native "+ write /etc/logwisp/logwisp.toml" || ok=0
said dry-native "+ systemctl enable logwisp" || ok=0
[[ $(snapshot) != "$before" ]] && ok=0
check "native: printed the install, the configuration and systemctl, changed nothing (exit $rc)" $ok
deploy dry-docker --dry-run --runtime docker --role aggregator --name "$CONTAINER" --image "$IMAGE" --no-build \
--config-dir "$RUN/docker/conf" --file-dir "$OUT/docker" --listen 127.0.0.1 \
--chain-port $PORT_D_CHAIN --http-port $PORT_D_HTTP
rc=$?
# The command breaks before each option; joined, it is one line
cmd="$(sed -e ':a' -e '/\\$/N; s/\\\n//; ta' "$LOG/deploy-dry-docker.out" | tr -s ' ' | grep '^+ docker run -d ')"
ok=$((rc == 0))
for want in "--name $CONTAINER" "--restart unless-stopped" "--read-only" "--cap-drop ALL" \
"--security-opt no-new-privileges" "-u 65532:65532" "-v $RUN/docker/conf:/etc/logwisp:ro" \
"-v $OUT/docker:$OUT/docker" "-p 127.0.0.1:$PORT_D_CHAIN:$PORT_D_CHAIN" \
"-p 127.0.0.1:$PORT_D_HTTP:$PORT_D_HTTP" "$IMAGE -c /etc/logwisp/logwisp.toml"; do
[[ $cmd == *" $want"* ]] || { ok=0; info "the docker run command lacks: $want"; }
done
said dry-docker '| host = "0.0.0.0"' || ok=0
# The snapshot leaves out $OUT, where its --file-dir lies
[[ -e $OUT/docker || $(snapshot) != "$before" ]] && ok=0
check "docker: a hardened docker run publishing on --listen, the container binding 0.0.0.0, nothing written (exit $rc)" $ok
section "Docker runtime"
if ! command -v docker >/dev/null || ! docker info >/dev/null 2>&1; then
skip "docker: no docker daemon here"
elif ((EUID != 0)); then
skip "docker: lw-deploy.sh needs root to give the configuration to the image user"
elif ! docker image inspect "$IMAGE" >/dev/null 2>&1; then
skip "docker: no $IMAGE image here (make image)"
else
at_exit "docker rm -f $CONTAINER"
deploy docker --runtime docker --role aggregator --name "$CONTAINER" --image "$IMAGE" --no-build \
--config-dir "$RUN/docker/conf" --file-dir "$OUT/docker" --listen 127.0.0.1 \
--chain-port $PORT_D_CHAIN --http-port $PORT_D_HTTP
rc=$?
check "docker: the aggregator container is running (exit $rc)" $(is "$rc" 0)
generate docker-edge --role edge --log-dir "$APP" --aggregator 127.0.0.1 --chain-port $PORT_D_CHAIN \
--node deploy-docker
spawn docker-edge "$BIN" -c "$RUN/docker-edge/logwisp.toml"
for i in $(seq 1 15); do
echo "line $i for the container" >>"$APP/app.log"
sleep 1
grep -q 'deploy-docker/app.log' "$OUT/docker/logwisp.log" 2>/dev/null && break
done
n=$(grep -c 'deploy-docker/app.log' "$OUT/docker/logwisp.log" 2>/dev/null)
check "docker: a native edge's entries reached the container's file output (${n:-0} lines)" $((${n:-0} >= 1))
n=$(total_processed $PORT_D_HTTP)
check "docker: the published http sink answers /status (total_processed=$n)" $((n >= 1))
fi
summary
+244
View File
@@ -0,0 +1,244 @@
#!/usr/bin/env bash
# logwisp IPv6 test: a relay on ::1 takes both chain transports and serves
# viewers over TLS with an IPv6 SAN, and listeners keep to their address
# family. Skips (exit 77) on a host without an IPv6 loopback.
# Requires: bash 5+, openssl, curl, an IPv6 loopback. Linux dev host only.
set -u
. "$(dirname -- "${BASH_SOURCE[0]}")/lib.sh"
RUN=$E2E_DIR/run-ipv6
PKI=$RUN/pki
AUTH=$RUN/auth
OUT=$RUN/out
USERS=$AUTH/users.toml
PORT_TCP_CHAIN=15851
PORT_HTTP_CHAIN=15852
PORT_TCP_SINK=15853
PORT_HTTP_SINK=15854
PORT_FAMILY=15855
PORTS="$PORT_TCP_CHAIN $PORT_HTTP_CHAIN $PORT_TCP_SINK $PORT_HTTP_SINK $PORT_FAMILY"
e2e_init "$@"
section "Setup"
# /proc/net/if_inet6 lists ::1 as 31 zeros and a 1; it is absent without IPv6
grep -qs '^0\{31\}1 ' /proc/net/if_inet6 || skip_all "this host has no IPv6 loopback (::1)"
need openssl curl
ports_free $PORTS
rm -rf "$RUN"
mkdir -p "$CONF" "$LOG" "$PKI" "$AUTH" "$OUT"
# The relay certificate names only ::1: every dialer verifies an IPv6 SAN
pki_ca
pki_leaf relay relay.internal serverAuth "IP:::1"
info "test PKI in $(short "$PKI")/: ca, relay (SAN IP ::1)"
add_users "$USERS" edge-01 viewer-01
info "users edge-01, viewer-01 in $(short "$USERS")"
tls_server() { printf '[%s.tls]\nenabled = true\ncert_file = "%s"\nkey_file = "%s"\n' "$1" "$PKI/relay.crt" "$PKI/relay.key"; }
scram_server() { printf '[%s.auth]\ntype = "scram"\ncredentials_file = "%s"\n' "$1" "$USERS"; }
relay_pipeline() { # name source_type source_port sink_type sink_port
cat <<EOF
[[pipelines]]
name = "$1"
[pipelines.flow.format]
type = "json"
sanitizer_policy = "json"
[[pipelines.plugin_sources]]
id = "in"
type = "$2"
[pipelines.plugin_sources.config]
host = "::1"
port = $3
EOF
tls_server pipelines.plugin_sources.config
scram_server pipelines.plugin_sources.config
cat <<EOF
[[pipelines.plugin_sinks]]
id = "file"
type = "file"
[pipelines.plugin_sinks.config]
directory = "$OUT"
name = "$2"
flush_interval_ms = 200
[[pipelines.plugin_sinks]]
id = "out"
type = "$4"
[pipelines.plugin_sinks.config]
host = "::1"
port = $5
EOF
tls_server pipelines.plugin_sinks.config
scram_server pipelines.plugin_sinks.config
}
{
printf 'status_reporter = false\n[logging]\noutput = "stdout"\nlevel = "info"\n'
relay_pipeline relay_tcp tcp_chain $PORT_TCP_CHAIN tcp $PORT_TCP_SINK
relay_pipeline relay_http http_chain $PORT_HTTP_CHAIN http $PORT_HTTP_SINK
} >"$CONF/relay.toml"
edge_conf() { # name sink_type port
cat >"$CONF/$1.toml" <<EOF
status_reporter = false
[logging]
output = "stdout"
level = "info"
[[pipelines]]
name = "$1"
[[pipelines.plugin_sources]]
id = "rand"
type = "random"
[pipelines.plugin_sources.config]
interval_ms = 200
format = "txt"
length = 24
[[pipelines.plugin_sinks]]
id = "to_relay"
type = "$2"
[pipelines.plugin_sinks.config]
host = "::1"
port = $3
node = "$1"
[pipelines.plugin_sinks.config.tls]
enabled = true
ca_file = "$PKI/ca.crt"
[pipelines.plugin_sinks.config.auth]
type = "scram"
username = "edge-01"
password_file = "$AUTH/edge-01.pass"
EOF
}
edge_conf edge_tcp tcp_chain $PORT_TCP_CHAIN
edge_conf edge_http http_chain $PORT_HTTP_CHAIN
# Plain http sinks whose /status names the listener that answered
family_conf() { # name host
cat >"$CONF/$1.toml" <<EOF
status_reporter = false
[logging]
output = "stdout"
level = "info"
[[pipelines]]
name = "$1"
[[pipelines.plugin_sources]]
id = "none"
type = "null"
[[pipelines.plugin_sinks]]
id = "$1"
type = "http"
[pipelines.plugin_sinks.config]
host = "$2"
port = $PORT_FAMILY
EOF
}
family_conf any6 "::"
family_conf any4 "0.0.0.0"
info "configurations in $(short "$CONF")/"
section "Startup"
start_daemon relay relay.toml
for p in $PORT_TCP_CHAIN $PORT_HTTP_CHAIN $PORT_TCP_SINK $PORT_HTTP_SINK; do
wait_port "$p" ::1 || abort "relay port $p is not listening on ::1" relay
done
start_daemon edge_tcp edge_tcp.toml
start_daemon edge_http edge_http.toml
start_daemon any6 any6.toml
ANY6_PID=${PIDS[-1]}
wait_port $PORT_FAMILY ::1 || abort "any6 is not listening on [::]:$PORT_FAMILY" any6
# The checks start any4 themselves, after testing :: alone
((AUTO)) || start_daemon any4 any4.toml
daemons_up
write_env LW="$BIN" CA="$PKI/ca.crt" PW="$AUTH/viewer-01.pass"
guide "logwisp IPv6 test" <<EOF
Ports (relay on ::1, TLS with SAN IP ::1, scram):
$PORT_TCP_CHAIN relay tcp_chain ingest, edge_tcp dials [::1]
$PORT_HTTP_CHAIN relay http_chain ingest, edge_http dials [::1]
$PORT_TCP_SINK tcp sink
$PORT_HTTP_SINK http sink
$PORT_FAMILY plain http sinks side by side: any6 on ::, any4 on 0.0.0.0
Shell setup (LW, CA, PW: viewer-01's password file):
> . $(short "$RUN")/env
Read the tcp sink as viewer-01:
> \$LW auth stream -addr [::1]:$PORT_TCP_SINK -user viewer-01 -password-file \$PW -ca-file \$CA
Read the http sink (bearer keeps the token off argv; -g leaves the brackets to the URL):
> token=\$(\$LW auth token -url https://[::1]:$PORT_HTTP_SINK -user viewer-01 -password-file \$PW -ca-file \$CA)
> curl -gN --cacert \$CA -H @<(bearer) https://[::1]:$PORT_HTTP_SINK/stream
Which listener answers on $PORT_FAMILY:
> curl -s http://127.0.0.1:$PORT_FAMILY/status; curl -gs http://[::1]:$PORT_FAMILY/status
Ingested entries: $(short "$OUT")/ Passwords: $(short "$AUTH")/
Logs: $(short "$LOG")/
EOF
manual_hold
relay_log="$LOG/relay.out"
curl_args=(-g -s --max-time 5 --noproxy '*')
answered_by() { # url -> the instance whose /status answered, empty if refused
curl "${curl_args[@]}" "$1/status" 2>/dev/null | grep -o '"instance_id":"[^"]*"' | cut -d'"' -f4
}
section "Scenario 1: chain links and viewers over [::1]"
# 1. Both chain transports deliver; the http_chain edge logged in at
# https://[::1]:PORT/auth first
wait_until 20 has_entry tcp_chain 'edge-01/'
n=$(ingested tcp_chain | grep -c 'edge-01/')
check "tcp_chain: entries from the edge dialing [::1] reached the relay ($n lines)" $((n >= 1))
wait_until 20 has_entry http_chain 'edge-01/'
n=$(ingested http_chain | grep -c 'edge-01/')
check "http_chain: entries from the edge dialing [::1] reached the relay ($n lines)" $((n >= 1))
# The logger quotes a value holding brackets
n=$(grep -cF "addr \"[::1]:$PORT_TCP_CHAIN\"" "$relay_log")
check "relay logged its listener as [::1]:$PORT_TCP_CHAIN ($n)" $((n >= 1))
# 2. HTTP sink: lw auth token at an IPv6 URL, the token carried by curl -g
token="$("$BIN" auth token -url "https://[::1]:$PORT_HTTP_SINK" -user viewer-01 \
-password-file "$AUTH/viewer-01.pass" -ca-file "$PKI/ca.crt" 2>>"$LOG/auth-cli.out")"
check "http sink: lw auth token -url https://[::1]:$PORT_HTTP_SINK issued a token" $(is_set "$token")
code="$(curl "${curl_args[@]}" -o /dev/null -w '%{http_code}' --cacert "$PKI/ca.crt" \
-H @<(printf 'Authorization: Bearer %s\n' "$token") "https://[::1]:$PORT_HTTP_SINK/status" 2>/dev/null || true)"
check "http sink: curl -g https://[::1]:$PORT_HTTP_SINK/status with the token (HTTP $code)" $(is "$code" 200)
sse="$(timeout 4 curl -gsN --noproxy '*' --cacert "$PKI/ca.crt" \
-H @<(printf 'Authorization: Bearer %s\n' "$token") \
"https://[::1]:$PORT_HTTP_SINK/stream" 2>/dev/null || true)"
n=$(grep -c '^data:.*edge-01/' <<<"$sse")
check "http sink: /stream delivered SSE events over [::1] ($n events)" $((n >= 1))
# 3. TCP sink: lw auth stream at an IPv6 address
timeout 4 "$BIN" auth stream -addr "[::1]:$PORT_TCP_SINK" -user viewer-01 \
-password-file "$AUTH/viewer-01.pass" -ca-file "$PKI/ca.crt" \
>"$RUN/stream.out" 2>"$LOG/stream.err"
n=$(grep -c 'edge-01/' "$RUN/stream.out")
check "tcp sink: lw auth stream -addr [::1]:$PORT_TCP_SINK streamed entries ($n lines)" $((n >= 1))
section "Scenario 2: each listener keeps to its address family"
# 4. :: is IPv6-only: it takes [::1] and leaves IPv4, and its port, alone
who="$(answered_by "http://[::1]:$PORT_FAMILY")"
check ":: listener accepts [::1] (answered by ${who:-nobody})" $(is "$who" any6)
who="$(answered_by "http://127.0.0.1:$PORT_FAMILY")"
check ":: listener refuses 127.0.0.1 (answered by ${who:-nobody})" $(is "$who" "")
start_daemon any4 any4.toml
wait_port $PORT_FAMILY 127.0.0.1
who="$(answered_by "http://127.0.0.1:$PORT_FAMILY")"
check "0.0.0.0 binds the same port beside :: and takes 127.0.0.1 (answered by ${who:-nobody})" $(is "$who" any4)
# 5. 0.0.0.0 is IPv4-only: with :: gone, [::1] finds nobody
kill -TERM "$ANY6_PID"
wait "$ANY6_PID" 2>/dev/null
who="$(answered_by "http://[::1]:$PORT_FAMILY")"
check "0.0.0.0 listener refuses [::1] (answered by ${who:-nobody})" $(is "$who" "")
summary
Executable
+288
View File
@@ -0,0 +1,288 @@
# Shared by the e2e scripts (test/*-test.sh): sourced after the script sets RUN,
# then e2e_init parses the common flags. Daemons log to $LOG/NAME.out only.
# Exit codes: 0 every check passed, 1 a check failed, 77 the script skipped.
E2E_DIR=$(cd -- "$(dirname -- "${BASH_SOURCE[0]}")" && pwd)
BIN=${LOGWISP_BIN:-${E2E_DIR%/*}/bin/lw}
AUTO=0 KEEP=0 FOLLOW=0
PIDS=() DAEMONS=() AT_EXIT=()
E2E_PASSED=0 E2E_FAILED=0 E2E_SKIPPED=0
# What --follow shows besides WARN and ERROR lines; a script may extend it
FOLLOW_KEYS='LogWisp started|hot reload completed|Login accepted|rejected|refused'
PKI_DAYS=45 # past the 30-day expiry warning, which would fill the daemon logs
# https://no-color.org; FORCE_COLOR=1 keeps colour through a pipe
if [[ ${FORCE_COLOR:-0} != 0 ]] || [[ -t 1 && -z ${NO_COLOR:-} ]]; then
C_BOLD=$'\e[1m' C_DIM=$'\e[2m' C_RED=$'\e[31m' C_GREEN=$'\e[32m'
C_YELLOW=$'\e[33m' C_CYAN=$'\e[36m' C_OFF=$'\e[0m'
else
C_BOLD='' C_DIM='' C_RED='' C_GREEN='' C_YELLOW='' C_CYAN='' C_OFF=''
fi
usage() {
sed -n '2,/^[^#]/s/^# \{0,1\}//p' "$0"
cat <<EOF
Usage: $0 [--auto [--keep]] [--follow]
(none) start the daemons, print the guide, keep them up until Ctrl-C
--auto run the checks, then stop the daemons
--keep with --auto: leave them up when every check passes
--follow show the daemons' warnings, errors and key events as they log
Daemon logs: $(short "$RUN")/log/NAME.out. NO_COLOR=1 or FORCE_COLOR=1 sets
colour; LOGWISP_BIN names another lw binary.
EOF
}
e2e_init() { # "$@" of the script
local a
for a; do
case $a in
--auto) AUTO=1 ;;
--keep) KEEP=1 ;;
--follow) FOLLOW=1 ;;
-h | --help) usage; exit 0 ;;
*) printf '%s: unknown argument %s (see --help)\n' "${0##*/}" "$a" >&2; exit 2 ;;
esac
done
CONF=$RUN/conf LOG=$RUN/log
trap cleanup EXIT
trap 'exit 130' INT
trap 'exit 143' TERM
trap 'exit 129' HUP
trap 'exit 141' PIPE
printf '%s%s%s\n' "$C_BOLD" "${0##*/}$( ((AUTO)) && echo ' --auto')" "$C_OFF"
}
# --- output ---
rule() { local s='' n=$1; while ((n-- > 0)); do s+='─'; done; printf '%s' "$s"; }
short() { if [[ $1 == "$PWD"/* ]]; then printf '%s' "${1#"$PWD"/}"; else printf '%s' "$1"; fi; }
section() { printf '\n%s── %s %s%s\n' "$C_BOLD$C_CYAN" "$1" "$(rule $((60 - ${#1})))" "$C_OFF"; }
info() { printf ' %s·%s %s\n' "$C_DIM" "$C_OFF" "$*"; }
pass() { E2E_PASSED=$((E2E_PASSED + 1)); printf ' %sPASS%s %s\n' "$C_BOLD$C_GREEN" "$C_OFF" "$1"; }
fail() { E2E_FAILED=$((E2E_FAILED + 1)); printf ' %sFAIL%s %s\n' "$C_BOLD$C_RED" "$C_OFF" "$1"; }
skip() { E2E_SKIPPED=$((E2E_SKIPPED + 1)); printf ' %sSKIP%s %s\n' "$C_BOLD$C_YELLOW" "$C_OFF" "$1"; }
check() { if (($2)); then pass "$1"; else fail "$1"; fi; } # LABEL 1|0
is() { [[ $1 == "$2" ]] && echo 1 || echo 0; } # ACTUAL EXPECTED
is_set() { [[ -n $1 ]] && echo 1 || echo 0; }
# guide TITLE, the body on stdin: "> " lines are commands, " NNNNN " lines
# ports, lines ending in ":" headings.
guide() {
local line
printf '\n%s┌── %s %s%s\n' "$C_BOLD" "$1" "$(rule $((59 - ${#1})))" "$C_OFF"
while IFS= read -r line; do
case $line in
'> '*) line=" $C_CYAN${line#> }$C_OFF" ;;
' '[0-9][0-9][0-9][0-9][0-9]' '*) line=" $C_YELLOW${line:2:5}$C_OFF${line:7}" ;;
*:) line="$C_BOLD$line$C_OFF" ;;
esac
printf '%s│%s %s\n' "$C_BOLD" "$C_OFF" "$line"
done
printf '%s└%s%s\n' "$C_BOLD" "$(rule 63)" "$C_OFF"
}
# write_env NAME=VALUE...: $RUN/env, which the guide's commands source
write_env() {
local kv
{
echo "# . $(short "$RUN")/env: what the guide of ${0##*/} refers to"
echo 'export no_proxy="127.0.0.1,127.0.0.2,::1${no_proxy:+,$no_proxy}"; export NO_PROXY=$no_proxy'
for kv; do printf 'export %s=%q\n' "${kv%%=*}" "${kv#*=}"; done
# keeps a bearer token off argv: curl -H @<(bearer)
echo "bearer() { printf 'Authorization: Bearer %s\\n' \"\$token\"; }"
} >"$RUN/env"
}
# --- results ---
# abort MESSAGE [DAEMON]: a failure the checks cannot run past; shows the
# daemon's last warnings and errors
abort() {
fail "$1"
ABORTED=${2:-}
[[ -z $ABORTED ]] || excerpt "$ABORTED" '^([^ ]+ (WARN|ERROR) |Error: )' 5
summary
}
summary() {
local name counts="$E2E_PASSED passed, $E2E_FAILED failed, $E2E_SKIPPED skipped"
stop_follow
if ((E2E_FAILED == 0 && KEEP)); then
info "--keep: left running: pids ${PIDS[*]}${AT_EXIT[*]:+; later: ${AT_EXIT[*]}}"
PIDS=() AT_EXIT=()
fi
stop_all
if ((E2E_FAILED)); then
for name in "${DAEMONS[@]}"; do
[[ $name == "${ABORTED:-}" ]] || excerpt "$name" '^([^ ]+ ERROR |Error: )'
done
fi
printf '%s%s%s\n' "$C_DIM" "$(rule 64)" "$C_OFF"
if ((E2E_FAILED)); then
printf '%sRESULT: FAILURES%s (%s); logs in %s/\n' "$C_BOLD$C_RED" "$C_OFF" "$counts" "$(short "$LOG")"
exit 1
fi
if ((E2E_SKIPPED)); then
printf '%sRESULT: PASS%s (%s)\n' "$C_BOLD$C_GREEN" "$C_OFF" "$counts"
else
printf '%sRESULT: ALL PASS%s (%s)\n' "$C_BOLD$C_GREEN" "$C_OFF" "$counts"
fi
exit 0
}
skip_all() { # REASON: nothing in the script applies to this host
skip "$1"
printf '%s%s%s\n%sRESULT: SKIPPED%s\n' "$C_DIM" "$(rule 64)" "$C_OFF" "$C_BOLD$C_YELLOW" "$C_OFF"
exit 77
}
# --- processes ---
spawn() { # NAME COMMAND...: in the background, output to $LOG/NAME.out
local name=$1
shift
"$@" >"$LOG/$name.out" 2>&1 &
PIDS+=($!) DAEMONS+=("$name")
}
start_daemon() { spawn "$1" "$BIN" -c "$CONF/$2"; } # NAME CONF_FILE
daemons_up() { info "running: ${DAEMONS[*]} (logs: $(short "$LOG")/NAME.out)"; }
at_exit() { AT_EXIT+=("$1"); } # COMMAND, run at teardown
stop_all() { # TERM every process, KILL what outlives 10 s
local pid deadline=$((SECONDS + 10))
((${#PIDS[@]})) || return 0
kill -TERM "${PIDS[@]}" 2>/dev/null
for pid in "${PIDS[@]}"; do
while kill -0 "$pid" 2>/dev/null && ((SECONDS < deadline)); do sleep 0.1; done
kill -KILL "$pid" 2>/dev/null
done
info "stopped ${#PIDS[@]} process(es)"
PIDS=()
}
cleanup() {
local rc=$? c
trap '' PIPE
trap - EXIT
stop_follow
stop_all
for c in "${AT_EXIT[@]}"; do eval "$c" >/dev/null 2>&1; done
exit "$rc"
}
# follow_logs: with --follow, WARN, ERROR and FOLLOW_KEYS lines of every daemon
# started so far, as they are logged
follow_logs() {
local name files=()
((FOLLOW)) || return 0
for name in "${DAEMONS[@]}"; do files+=("$LOG/$name.out"); done
tail -v -n +1 -F "${files[@]}" 2>/dev/null > >(follow_filter) &
FOLLOW_PID=$!
info "following warnings, errors and key events of: ${DAEMONS[*]}"
}
follow_filter() { # tail -v output -> "NAME HH:MM:SS LEVEL message"; read, not awk, for line buffering
local line name='' ts lvl rest c
while IFS= read -r line; do
case $line in '==> '*' <==')
name=${line#==> } name=${name% <==} name=${name##*/} name=${name%.out}
continue ;;
esac
read -r ts lvl rest <<<"$line"
# lw's own startup failure, before its logger runs: "Error: ..."
[[ $ts == Error: ]] && rest="$lvl $rest" lvl=ERROR ts=''
case $lvl in
ERROR) c=$C_RED ;;
WARN) c=$C_YELLOW ;;
*) [[ $line =~ $FOLLOW_KEYS ]] || continue; c=$C_CYAN ;;
esac
printf ' %s%-12s %s%s %s%-5s %s%s\n' "$C_DIM" "$name" "${ts:11:8}" "$C_OFF" "$c" "$lvl" "${rest#msg }" "$C_OFF"
done
}
stop_follow() { [[ -z ${FOLLOW_PID:-} ]] || kill "$FOLLOW_PID" 2>/dev/null; FOLLOW_PID=''; }
# manual_hold: starts --follow; without --auto, keeps the daemons up until Ctrl-C
manual_hold() {
follow_logs
((AUTO)) && return 0
info "manual mode: the daemons stay up until Ctrl-C (--auto runs the checks)"
while :; do sleep 1; done
}
# --- network and files ---
# listening PORT [HOST]: a listener on PORT in HOST's address family. It reads
# the kernel's table: a probe connection would land in the daemon logs, as a
# failed TLS handshake or hello the checks count.
listening() {
local table=/proc/net/tcp
[[ ${2:-} == *:* ]] && table=/proc/net/tcp6
awk -v port="$(printf ':%04X' "$1")" '$4 == "0A" && substr($2, length($2) - 4) == port { found = 1 }
END { exit !found }' "$table" 2>/dev/null
}
wait_port() { # PORT [HOST]: up to 10 s
local i
for ((i = 0; i < 100; i++)); do
listening "$@" && return 0
sleep 0.1
done
return 1
}
wait_until() { # SECONDS COMMAND...
local deadline=$((SECONDS + $1))
shift
until "$@"; do
((SECONDS >= deadline)) && return 1
sleep 0.2
done
}
ports_free() { # PORT...: in either family, as a dual-stack [::] listener holds IPv4 too
local p
for p; do
listening "$p" || listening "$p" :: && abort "port $p is already in use"
done
return 0
}
need() { # TOOL...: the binary and each tool, or abort
[[ -x $BIN ]] || abort "no lw binary at $(short "$BIN"): make build"
local t
for t; do command -v "$t" >/dev/null || abort "$t not found"; done
}
# What a plain TCP sink client receives in SECONDS
tcp_read() { timeout "$2" bash -c "exec 3<>/dev/tcp/127.0.0.1/$1; cat <&3" 2>/dev/null || true; }
ingested() { cat "$OUT/$1"* 2>/dev/null; } # FILE_PREFIX: the relay's file sinks
has_entry() { ingested "$1" | grep -q -- "$2"; } # FILE_PREFIX PATTERN
count() { grep -c -- "$2" "$LOG/$1.out" 2>/dev/null; } # DAEMON PATTERN
logged() { local n; n=$(count "$1" "$2"); ((${n:-0} >= ${3:-1})); } # DAEMON PATTERN [MIN]
excerpt() { # DAEMON ERE [LINES]: its last matching WARN or ERROR lines
{ echo "==> $1 <=="; grep -E "$2" "$LOG/$1.out" 2>/dev/null | tail -n "${3:-3}"; } | follow_filter
}
# --- credentials ---
pki_key() { openssl genpkey -algorithm EC -pkeyopt ec_paramgen_curve:P-256 -out "$1" 2>/dev/null; }
pki_ca() { # the test CA in $PKI
mkdir -p "$PKI"
pki_key "$PKI/ca.key"
openssl req -x509 -new -key "$PKI/ca.key" -days "$PKI_DAYS" -out "$PKI/ca.crt" \
-subj "/CN=LogWisp Test CA" 2>/dev/null || abort "PKI: cannot create the CA"
}
pki_leaf() { # NAME CN EKU [SAN]: $PKI/NAME.crt and NAME.key from the test CA
local name=$1 ext="extendedKeyUsage=$3"
[[ -n ${4:-} ]] && ext+=$'\n'"subjectAltName=$4"
printf '%s\n' "$ext" >"$PKI/$name.ext"
pki_key "$PKI/$name.key"
openssl req -new -key "$PKI/$name.key" -out "$PKI/$name.csr" -subj "/CN=$2" 2>/dev/null
openssl x509 -req -in "$PKI/$name.csr" -CA "$PKI/ca.crt" -CAkey "$PKI/ca.key" -CAcreateserial \
-out "$PKI/$name.crt" -days "$PKI_DAYS" -extfile "$PKI/$name.ext" 2>/dev/null
[[ -s $PKI/$name.crt ]] || abort "PKI: cannot issue $name"
}
add_users() { # CREDENTIALS USER...: through the CLI, passwords in $AUTH/USER.pass
local u
for u in "${@:2}"; do
"$BIN" auth add-user -credentials "$1" -user "$u" -password-file "$AUTH/$u.pass" \
>>"$LOG/auth-cli.out" 2>&1 || abort "lw auth add-user $u failed" auth-cli
done
}
+121 -313
View File
@@ -1,123 +1,43 @@
#!/usr/bin/env bash
# logwisp mTLS authentication test
#
# Scenario 1 — chained instances, client authenticates with mTLS:
# edge-01 cert --> tcp_chain sink --> :15811 tcp_chain src --> file sink
# edge-01 cert --> http_chain sink --> :15812 http_chain src --> file sink
# edge-99 cert --> tcp_chain sink --> :15811 rejected by the allow list
#
# Scenario 2 — a viewer client reads a streaming sink over mTLS:
# viewer-01 cert --> :15813 tcp sink (openssl s_client)
# viewer-01 cert --> :15814 http sink (curl, /stream and /status)
# rogue cert --> both, rejected by the allow list
#
# Also covers: node binding (a peer holding the edge-01 certificate cannot
# label its entries anything else), dialer-side server identity pinning, and
# a peer presenting no certificate at all.
#
# Usage:
# ./mtls-chain-test.sh manual mode: relay + edges up, guide printed
# ./mtls-chain-test.sh --auto automated checks and teardown
# ./mtls-chain-test.sh --keep (with --auto) skip teardown on success
#
# logwisp mTLS test: edges authenticate to a relay with client certificates,
# viewers to its tcp and http sinks; covers the allow lists, node binding (an
# edge-01 certificate labels its entries edge-01), dialer-side server pinning
# and a peer without a certificate.
# Requires: bash 5+, coreutils (timeout), openssl, curl. Linux dev host only.
set -u
. "$(dirname -- "${BASH_SOURCE[0]}")/lib.sh"
SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
BIN="${LOGWISP_BIN:-$SCRIPT_DIR/../bin/logwisp}"
RUN="$SCRIPT_DIR/run-mtls"
CONF="$RUN/conf"
LOG="$RUN/log"
PKI="$RUN/pki"
OUT="$RUN/out"
RUN=$E2E_DIR/run-mtls
PKI=$RUN/pki
OUT=$RUN/out
PORT_TCP_CHAIN=15811
PORT_HTTP_CHAIN=15812
PORT_TCP_SINK=15813
PORT_HTTP_SINK=15814
PORTS="$PORT_TCP_CHAIN $PORT_HTTP_CHAIN $PORT_TCP_SINK $PORT_HTTP_SINK"
e2e_init "$@"
AUTO=0; KEEP=0
for a in "$@"; do case "$a" in
--auto) AUTO=1 ;;
--keep) KEEP=1 ;;
*) echo "unknown arg: $a" >&2; exit 1 ;;
esac; done
PIDS=()
cleanup() {
local rc=$?
trap - EXIT INT TERM
if (( ${#PIDS[@]} )); then
echo "--- teardown: stopping ${#PIDS[@]} daemon(s)"
kill -TERM "${PIDS[@]}" 2>/dev/null
local deadline=$(( SECONDS + 10 ))
for pid in "${PIDS[@]}"; do
while kill -0 "$pid" 2>/dev/null && (( SECONDS < deadline )); do sleep 0.2; done
kill -KILL "$pid" 2>/dev/null
done
fi
exit "$rc"
}
trap cleanup EXIT INT TERM
port_open() { (exec 3<>"/dev/tcp/127.0.0.1/$1") 2>/dev/null && exec 3>&-; }
wait_port() { # port timeout_s
local i; for (( i=0; i < $2 * 10; i++ )); do
port_open "$1" && return 0
sleep 0.1
done
return 1
}
start_daemon() { # name conf
"$BIN" -c "$CONF/$2" > "$LOG/$1.out" 2>&1 &
PIDS+=($!)
echo "started $1 (pid $!)"
}
# --- Preflight ---
[[ -x "$BIN" ]] || { echo "binary not found: $BIN (build: go build -o bin/logwisp ./cmd/logwisp)" >&2; exit 1; }
command -v openssl >/dev/null || { echo "openssl not found" >&2; exit 1; }
command -v curl >/dev/null || { echo "curl not found" >&2; exit 1; }
for p in $PORT_TCP_CHAIN $PORT_HTTP_CHAIN $PORT_TCP_SINK $PORT_HTTP_SINK; do
port_open "$p" && { echo "port $p already in use" >&2; exit 1; }
done
section "Setup"
need openssl curl
ports_free $PORTS
rm -rf "$RUN"
mkdir -p "$CONF" "$LOG" "$PKI" "$OUT"
# --- PKI ---
# One CA for every peer: the point of the test is that CA membership alone is
# no longer sufficient, so the identities must all be issued by the same CA.
gen_key() { openssl genpkey -algorithm EC -pkeyopt ec_paramgen_curve:P-256 -out "$1" 2>/dev/null; }
pki_ca
pki_leaf relay relay.internal serverAuth "IP:127.0.0.1,DNS:relay.internal"
pki_leaf edge-01 edge-01 clientAuth
pki_leaf edge-02 edge-02 clientAuth
pki_leaf edge-99 edge-99 clientAuth
pki_leaf viewer-01 viewer-01 clientAuth
pki_leaf rogue rogue-viewer clientAuth
info "test PKI in $(short "$PKI")/: ca, relay, edge-01, edge-02, edge-99, viewer-01, rogue"
gen_leaf() { # name CN eku [SAN]
local name=$1 cn=$2 eku=$3 san=${4:-}
gen_key "$PKI/$name.key"
openssl req -new -key "$PKI/$name.key" -out "$PKI/$name.csr" -subj "/CN=$cn" 2>/dev/null
local ext="extendedKeyUsage=$eku"
[[ -n $san ]] && ext+=$'\n'"subjectAltName=$san"
printf '%s\n' "$ext" > "$PKI/$name.ext"
openssl x509 -req -in "$PKI/$name.csr" -CA "$PKI/ca.crt" -CAkey "$PKI/ca.key" \
-CAcreateserial -out "$PKI/$name.crt" -days 2 -extfile "$PKI/$name.ext" 2>/dev/null
}
echo "--- generating test PKI in $PKI"
gen_key "$PKI/ca.key"
openssl req -x509 -new -key "$PKI/ca.key" -days 2 -out "$PKI/ca.crt" \
-subj "/CN=LogWisp Test CA" 2>/dev/null
gen_leaf relay relay.internal serverAuth "IP:127.0.0.1,DNS:relay.internal"
gen_leaf edge-01 edge-01 clientAuth
gen_leaf edge-99 edge-99 clientAuth
gen_leaf viewer-01 viewer-01 clientAuth
gen_leaf rogue rogue-viewer clientAuth
[[ -s "$PKI/rogue.crt" ]] || { echo "PKI generation failed" >&2; exit 1; }
# --- Config generation ---
# Relay: both ingest ports authorize edge-01 only and bind the node label to
# the certificate identity; both streaming sinks authorize viewer-01 only.
cat > "$CONF/relay.toml" <<EOF
cat >"$CONF/relay.toml" <<EOF
status_reporter = false
[logging]
output = "stdout"
@@ -144,7 +64,7 @@ client_ca_file = "$PKI/ca.crt"
[pipelines.plugin_sources.config.auth]
type = "mtls"
identity = "cn"
allow = ["edge-01"]
allow = ["edge-01", "edge-02"]
node_binding = "force"
[[pipelines.plugin_sinks]]
@@ -219,123 +139,15 @@ type = "mtls"
allow = ["viewer-01"]
EOF
# edge_tcp holds the edge-01 certificate but declares node "edge-tcp":
# node_binding = "force" must relabel its entries to "edge-01".
cat > "$CONF/edge_tcp.toml" <<EOF
status_reporter = false
[logging]
output = "file"
level = "info"
[logging.file]
directory = "$LOG"
name = "edge_tcp"
[[pipelines]]
name = "edge_tcp"
[[pipelines.plugin_sources]]
id = "rand"
type = "random"
[pipelines.plugin_sources.config]
interval_ms = 200
format = "txt"
length = 24
[[pipelines.plugin_sinks]]
id = "to_relay"
type = "tcp_chain"
[pipelines.plugin_sinks.config]
host = "127.0.0.1"
port = $PORT_TCP_CHAIN
node = "edge-tcp"
[pipelines.plugin_sinks.config.tls]
enabled = true
ca_file = "$PKI/ca.crt"
cert_file = "$PKI/edge-01.crt"
key_file = "$PKI/edge-01.key"
[pipelines.plugin_sinks.config.auth]
type = "mtls"
allow = ["relay.internal"]
EOF
cat > "$CONF/edge_http.toml" <<EOF
status_reporter = false
[logging]
output = "file"
level = "info"
[logging.file]
directory = "$LOG"
name = "edge_http"
[[pipelines]]
name = "edge_http"
[[pipelines.plugin_sources]]
id = "rand"
type = "random"
[pipelines.plugin_sources.config]
interval_ms = 200
format = "txt"
length = 24
[[pipelines.plugin_sinks]]
id = "to_relay"
type = "http_chain"
[pipelines.plugin_sinks.config]
host = "127.0.0.1"
port = $PORT_HTTP_CHAIN
node = "edge-http"
flush_interval_ms = 500
[pipelines.plugin_sinks.config.tls]
enabled = true
ca_file = "$PKI/ca.crt"
cert_file = "$PKI/edge-01.crt"
key_file = "$PKI/edge-01.key"
[pipelines.plugin_sinks.config.auth]
type = "mtls"
allow = ["relay.internal"]
EOF
# edge_rogue holds a CA-issued certificate the relay does not authorize, and
# claims to be edge-01 on top of it.
cat > "$CONF/edge_rogue.toml" <<EOF
status_reporter = false
[logging]
output = "file"
level = "info"
[logging.file]
directory = "$LOG"
name = "edge_rogue"
[[pipelines]]
name = "edge_rogue"
[[pipelines.plugin_sources]]
id = "rand"
type = "random"
[pipelines.plugin_sources.config]
interval_ms = 200
format = "txt"
length = 24
[[pipelines.plugin_sinks]]
id = "to_relay"
type = "tcp_chain"
[pipelines.plugin_sinks.config]
host = "127.0.0.1"
port = $PORT_TCP_CHAIN
node = "edge-01"
[pipelines.plugin_sinks.config.tls]
enabled = true
ca_file = "$PKI/ca.crt"
cert_file = "$PKI/edge-99.crt"
key_file = "$PKI/edge-99.key"
EOF
# edge_pinfail pins a server identity the relay does not have: the dialer must
# refuse the handshake even though the certificate chains to the trusted CA.
cat > "$CONF/edge_pinfail.toml" <<EOF
edge_conf() { # name sink_type port node cert level [sink options]: the auth block follows
cat >"$CONF/$1.toml" <<EOF
status_reporter = false
[logging]
output = "stdout"
level = "debug"
level = "$6"
[[pipelines]]
name = "edge_pinfail"
name = "$1"
[[pipelines.plugin_sources]]
id = "rand"
type = "random"
@@ -345,67 +157,71 @@ format = "txt"
length = 24
[[pipelines.plugin_sinks]]
id = "to_relay"
type = "tcp_chain"
type = "$2"
[pipelines.plugin_sinks.config]
host = "127.0.0.1"
port = $PORT_TCP_CHAIN
node = "edge-pinfail"
backoff_max_ms = 1000
port = $3
node = "$4"
${7:-}
[pipelines.plugin_sinks.config.tls]
enabled = true
ca_file = "$PKI/ca.crt"
cert_file = "$PKI/edge-01.crt"
key_file = "$PKI/edge-01.key"
[pipelines.plugin_sinks.config.auth]
type = "mtls"
allow = ["some-other-relay.internal"]
cert_file = "$PKI/$5.crt"
key_file = "$PKI/$5.key"
EOF
# --- Guide ---
cat <<EOF
================================================================
logwisp mTLS auth test — port map
$PORT_TCP_CHAIN relay ingest (tcp_chain, mTLS, allow = edge-01)
$PORT_HTTP_CHAIN relay ingest (http_chain, mTLS, allow = edge-01)
$PORT_TCP_SINK TCP sink (mTLS, allow = viewer-01)
$PORT_HTTP_SINK HTTP sink (mTLS, allow = viewer-01)
Read the TCP sink as an authorized viewer:
openssl s_client -quiet -connect 127.0.0.1:$PORT_TCP_SINK \\
-CAfile $PKI/ca.crt -cert $PKI/viewer-01.crt -key $PKI/viewer-01.key
Read the HTTP sink:
curl -N --noproxy '*' --cacert $PKI/ca.crt \\
--cert $PKI/viewer-01.crt --key $PKI/viewer-01.key \\
https://127.0.0.1:$PORT_HTTP_SINK/stream
Swap in rogue.crt/rogue.key for either and the policy refuses it.
Ingested entries land in $OUT/ (node label forced to the certificate CN).
Logs: $LOG/
================================================================
EOF
# --- Startup ---
start_daemon relay relay.toml
for p in $PORT_TCP_CHAIN $PORT_HTTP_CHAIN $PORT_TCP_SINK $PORT_HTTP_SINK; do
wait_port "$p" 10 || { echo "FAIL: relay port $p not listening (see $LOG/relay.out)"; exit 1; }
done
start_daemon edge_tcp edge_tcp.toml
start_daemon edge_http edge_http.toml
start_daemon edge_rogue edge_rogue.toml
start_daemon edge_pinfail edge_pinfail.toml
if (( AUTO == 0 )); then
echo "--- daemons running; Ctrl-C to stop"
while :; do sleep 1; done
fi
echo "--- settling 4s (connect + first http_chain flush)"
sleep 4
fail=0
check() { # label condition_result
if (( $2 )); then echo "PASS: $1"; else echo "FAIL: $1"; fail=1; fi
}
pin() { printf '[pipelines.plugin_sinks.config.auth]\ntype = "mtls"\nallow = ["%s"]\n' "$2" >>"$CONF/$1.toml"; }
# edge_tcp holds the edge-01 certificate but declares node "edge-tcp":
# node_binding = "force" must relabel its entries to "edge-01".
edge_conf edge_tcp tcp_chain $PORT_TCP_CHAIN edge-tcp edge-01 info
pin edge_tcp relay.internal
edge_conf edge_http http_chain $PORT_HTTP_CHAIN edge-http edge-01 info "flush_interval_ms = 500"
pin edge_http relay.internal
# edge_rogue holds a CA-issued certificate the relay does not authorize, and
# claims to be edge-01 on top of it.
edge_conf edge_rogue tcp_chain $PORT_TCP_CHAIN edge-01 edge-99 info
# edge_pinfail pins a server identity the relay does not have: the dialer must
# refuse the handshake even though the certificate chains to the trusted CA.
# The relay admits its edge-02, so only the pin can keep its entries out.
edge_conf edge_pinfail tcp_chain $PORT_TCP_CHAIN edge-pinfail edge-02 debug "backoff_max_ms = 1000"
pin edge_pinfail some-other-relay.internal
info "configurations in $(short "$CONF")/"
section "Startup"
start_daemon relay relay.toml
for p in $PORTS; do
wait_port "$p" || abort "relay port $p is not listening" relay
done
start_daemon edge_tcp edge_tcp.toml
start_daemon edge_http edge_http.toml
start_daemon edge_rogue edge_rogue.toml
start_daemon edge_pinfail edge_pinfail.toml
daemons_up
write_env LW="$BIN" PKI="$PKI" CA="$PKI/ca.crt" CERT="$PKI/viewer-01.crt" KEY="$PKI/viewer-01.key"
guide "logwisp mTLS test" <<EOF
Ports:
$PORT_TCP_CHAIN relay tcp_chain ingest, mTLS, allow edge-01, edge-02
$PORT_HTTP_CHAIN relay http_chain ingest, mTLS, allow edge-01
$PORT_TCP_SINK tcp sink, mTLS, allow viewer-01
$PORT_HTTP_SINK http sink, mTLS, allow viewer-01
Edges: edge_tcp, edge_http (edge-01) deliver; edge_rogue (edge-99) is
refused; edge_pinfail (edge-02, admitted) pins a server name the relay does
not have.
Shell setup (CA, and viewer-01's CERT and KEY):
> . $(short "$RUN")/env
Read the tcp sink, then the http sink, as viewer-01:
> openssl s_client -quiet -connect 127.0.0.1:$PORT_TCP_SINK -CAfile \$CA -cert \$CERT -key \$KEY
> curl -N --cacert \$CA --cert \$CERT --key \$KEY https://127.0.0.1:$PORT_HTTP_SINK/stream
With \$PKI/rogue.crt and rogue.key instead, the policy refuses either.
Ingested entries (node label forced to the certificate CN): $(short "$OUT")/
Logs: $(short "$LOG")/
EOF
manual_hold
info "settling 4 s (connect, first http_chain flush)"
sleep 4
# Viewer helpers
tcp_view() { # cert_basename secs
@@ -414,7 +230,7 @@ tcp_view() { # cert_basename secs
-cert "$PKI/$1.crt" -key "$PKI/$1.key" </dev/null 2>/dev/null || true
}
http_get() { # path cert_basename|"" -> "<http_code>|<body>"
http_get() { # path cert_basename|"" -> "<http_code>"
local path=$1 name=${2:-}
local args=(-s -o /dev/null -w '%{http_code}' --max-time 5 --noproxy '*'
--cacert "$PKI/ca.crt")
@@ -423,91 +239,83 @@ http_get() { # path cert_basename|"" -> "<http_code>|<body>"
}
relay_log="$LOG/relay.out"
ingested() { cat "$OUT"/${1}* 2>/dev/null; }
echo "=== Scenario 1: chained instances over mTLS ==="
section "Scenario 1: chained instances over mTLS"
# 1. An authorized edge delivers entries into the relay's file sink
tcp_file="$(ingested tcp_chain)"
n=$(grep -c 'edge-01/' <<< "$tcp_file")
check "tcp_chain: authorized edge-01 entries reached the file sink ($n lines)" $(( n >= 1 ))
n=$(grep -c 'edge-01/' <<<"$tcp_file")
check "tcp_chain: authorized edge-01 entries reached the file sink ($n lines)" $((n >= 1))
http_file="$(ingested http_chain)"
n=$(grep -c 'edge-01/' <<< "$http_file")
check "http_chain: authorized edge-01 entries reached the file sink ($n lines)" $(( n >= 1 ))
n=$(grep -c 'edge-01/' <<<"$http_file")
check "http_chain: authorized edge-01 entries reached the file sink ($n lines)" $((n >= 1))
# 2. node_binding = "force" overrode the label the sender configured
n=$(grep -c 'edge-tcp/' <<< "$tcp_file")
check "node binding: sender's own label \"edge-tcp\" was not honored ($n lines)" $(( n == 0 ))
n=$(grep -c 'edge-http/' <<< "$http_file")
check "node binding: sender's own label \"edge-http\" was not honored ($n lines)" $(( n == 0 ))
n=$(grep -c 'edge-tcp/' <<<"$tcp_file")
check "node binding: sender's own label \"edge-tcp\" was not honored ($n lines)" $((n == 0))
n=$(grep -c 'edge-http/' <<<"$http_file")
check "node binding: sender's own label \"edge-http\" was not honored ($n lines)" $((n == 0))
# 3. An identity outside the allow list is refused, even claiming to be edge-01
n=$(grep -c 'Connection rejected by auth policy' "$relay_log")
check "allow list: unauthorized edge-99 connection rejected ($n rejections)" $(( n >= 1 ))
n=$(grep -c 'edge-99' <<< "$tcp_file")
check "allow list: no edge-99 entry was ingested" $(( n == 0 ))
check "allow list: unauthorized edge-99 connection rejected ($n rejections)" $((n >= 1))
n=$(grep -c 'edge-99' <<<"$tcp_file")
check "allow list: no edge-99 entry was ingested" $((n == 0))
# 4. A peer with no certificate cannot complete the handshake
# 4. A peer with no certificate cannot complete the handshake; edge_pinfail
# fails handshakes too, with "bad certificate"
timeout 5 openssl s_client -connect 127.0.0.1:$PORT_TCP_CHAIN \
-CAfile "$PKI/ca.crt" </dev/null >/dev/null 2>&1
sleep 0.5
n=$(grep -c 'TLS handshake failed' "$relay_log")
check "client_auth: a peer with no certificate was refused ($n handshake errors)" $(( n >= 1 ))
n=$(grep -c "TLS handshake failed.*didn't provide a certificate" "$relay_log")
check "client_auth: a peer with no certificate was refused ($n handshake errors)" $((n >= 1))
# 5. Dialer-side pinning: the relay's identity is not the one edge_pinfail pins
n=$(grep -c 'is not allowed' "$LOG/edge_pinfail.out")
check "server pinning: dialer refused a CA-valid server it does not pin ($n refusals)" $(( n >= 1 ))
n=$(grep -c 'edge-pinfail' <<< "$tcp_file")
check "server pinning: pin-failing edge delivered nothing" $(( n == 0 ))
check "server pinning: dialer refused a CA-valid server it does not pin ($n refusals)" $((n >= 1))
n=$(grep -c 'edge-02/' <<<"$tcp_file")
check "server pinning: pin-failing edge-02 delivered nothing ($n lines)" $((n == 0))
echo "=== Scenario 2: viewer clients on mTLS-gated sinks ==="
section "Scenario 2: viewer clients on mTLS-gated sinks"
# 6. TCP sink: authorized viewer streams, rogue gets nothing
out="$(tcp_view viewer-01 4)"
n=$(grep -c 'edge-01/' <<< "$out")
check "tcp sink: viewer-01 streamed entries ($n lines)" $(( n >= 1 ))
n=$(grep -c 'edge-01/' <<<"$out")
check "tcp sink: viewer-01 streamed entries ($n lines)" $((n >= 1))
out="$(tcp_view rogue 4)"
n=$(grep -c '"message"' <<< "$out")
check "tcp sink: rogue viewer received no entries" $(( n == 0 ))
n=$(grep -c 'edge-01/' <<<"$out")
check "tcp sink: rogue viewer received no entries ($n lines)" $((n == 0))
# 7. HTTP sink: stream and status both gated
code="$(http_get /status viewer-01)"
check "http sink: /status served to viewer-01 (HTTP $code)" $([[ $code == 200 ]] && echo 1 || echo 0)
check "http sink: /status served to viewer-01 (HTTP $code)" $(is "$code" 200)
code="$(http_get /status rogue)"
check "http sink: /status refused to rogue viewer (HTTP $code)" $([[ $code == 403 ]] && echo 1 || echo 0)
check "http sink: /status refused to rogue viewer (HTTP $code)" $(is "$code" 403)
code="$(http_get /stream rogue)"
check "http sink: /stream refused to rogue viewer (HTTP $code)" $([[ $code == 403 ]] && echo 1 || echo 0)
check "http sink: /stream refused to rogue viewer (HTTP $code)" $(is "$code" 403)
# curl reports 000 when the handshake itself fails, which is what a client
# with no certificate must hit
code="$(http_get /status)"
check "http sink: client with no certificate failed the handshake (curl $code)" \
$([[ $code == 000 ]] && echo 1 || echo 0)
check "http sink: client with no certificate failed the handshake (curl $code)" $(is "$code" 000)
sse="$(timeout 4 curl -sN --noproxy '*' --cacert "$PKI/ca.crt" \
--cert "$PKI/viewer-01.crt" --key "$PKI/viewer-01.key" \
"https://127.0.0.1:$PORT_HTTP_SINK/stream" 2>/dev/null || true)"
n=$(grep -c '^data:.*edge-01/' <<< "$sse")
check "http sink: viewer-01 received SSE events ($n events)" $(( n >= 1 ))
n=$(grep -c '^data:.*edge-01/' <<<"$sse")
check "http sink: viewer-01 received SSE events ($n events)" $((n >= 1))
# 8. The status endpoint reports the policy and its rejection count
status="$(curl -s --max-time 5 --noproxy '*' --cacert "$PKI/ca.crt" \
--cert "$PKI/viewer-01.crt" --key "$PKI/viewer-01.key" \
"https://127.0.0.1:$PORT_HTTP_SINK/status" 2>/dev/null || true)"
n=$(grep -c 'mtls' <<< "$status")
check "http sink: status endpoint reports the auth policy" $(( n >= 1 ))
rej=$(grep -o '"auth_rejected"[ :]*[0-9]*' <<< "$status" | grep -o '[0-9]*$' || echo 0)
check "http sink: status endpoint counts auth rejections (auth_rejected=$rej)" $(( rej >= 1 ))
n=$(grep -c 'mtls' <<<"$status")
check "http sink: status endpoint reports the auth policy" $((n >= 1))
rej=$(grep -o '"auth_rejected"[ :]*[0-9]*' <<<"$status" | grep -o '[0-9]*$' || echo 0)
check "http sink: status endpoint counts auth rejections (auth_rejected=$rej)" $((rej >= 1))
echo "================================================================"
if (( fail == 0 )); then
echo "RESULT: ALL PASS"
(( KEEP )) && { echo "--keep: daemons left running (pids: ${PIDS[*]})"; PIDS=(); }
else
echo "RESULT: FAILURES — inspect $LOG/*.out and $LOG/*.log"
fi
exit "$fail"
summary
+22 -36
View File
@@ -1,27 +1,21 @@
#!/usr/bin/env bash
# logwisp file source pass-through test
#
# file src (raw, from=start) --> raw format --> file sink byte-exact relay
# file src (defaults) --> raw format --> file sink no key dropped
#
# The fixture is a record whose envelope is wider than time/level/msg/fields,
# which is what the narrow JSON branch used to reduce to an empty message.
#
# Usage: ./passthrough-test.sh
# logwisp file pass-through test: a file source relays a record whose envelope
# is wider than time/level/msg/fields byte for byte (raw = true) and, parsed,
# with no key dropped. One-shot: both modes run the checks; no ports.
# Requires: bash 5+, coreutils (timeout). Linux dev host only.
set -u
. "$(dirname -- "${BASH_SOURCE[0]}")/lib.sh"
SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
BIN="${LOGWISP_BIN:-$SCRIPT_DIR/../bin/logwisp}"
RUN="$SCRIPT_DIR/run/passthrough"
[[ -x $BIN ]] || { echo "no binary at $BIN; run make build" >&2; exit 1; }
RUN=$E2E_DIR/run/passthrough
e2e_init "$@"
section "Setup"
need
rm -rf "$RUN"
mkdir -p "$RUN/in" "$RUN/out-raw" "$RUN/out-parsed"
mkdir -p "$RUN/in" "$RUN/out-raw" "$RUN/out-parsed" "$CONF" "$LOG"
cat > "$RUN/in/wide.jsonl" <<'EOF'
cat >"$RUN/in/wide.jsonl" <<'EOF'
{"time":"2026-09-08T21:35:44.178372768-04:00","level":"INFO","sub":"app","run":0,"tick":0,"frame":0,"fields":{"msg":"init begin","mode":"play"}}
{"time":"2026-09-08T21:35:44.178581366-04:00","level":"PROC","run":0,"tick":0,"frame":0,"fields":{"seq":1,"seed":1788917744178374836}}
plain text line, not JSON at all
@@ -56,37 +50,29 @@ name = "relay"
flush_interval_ms = 100
EOF
}
conf "$RUN/out-raw" true >"$CONF/raw.toml"
conf "$RUN/out-parsed" false >"$CONF/parsed.toml"
info "fixture $(short "$RUN/in/wide.jsonl"), configurations in $(short "$CONF")/"
conf "$RUN/out-raw" true > "$RUN/raw.toml"
conf "$RUN/out-parsed" false > "$RUN/parsed.toml"
section "Relay"
for c in raw parsed; do
timeout 5 "$BIN" -c "$RUN/$c.toml" > "$RUN/$c.out" 2>&1
info "lw -c $(short "$CONF/$c.toml"), 5 s"
timeout 5 "$BIN" -c "$CONF/$c.toml" >"$LOG/$c.out" 2>&1
done
fail=0
check() { # label condition_result
if (( $2 )); then echo "PASS: $1"; else echo "FAIL: $1"; fail=1; fi
}
section "Checks"
# 1. raw = true relays the file byte for byte
if diff -q "$RUN/in/wide.jsonl" "$RUN/out-raw/relay.log" > /dev/null; then
if diff -q "$RUN/in/wide.jsonl" "$RUN/out-raw/relay.log" >/dev/null; then
check "raw = true: output identical to input" 1
else
check "raw = true: output identical to input" 0
diff "$RUN/in/wide.jsonl" "$RUN/out-raw/relay.log" | head -6
diff "$RUN/in/wide.jsonl" "$RUN/out-raw/relay.log" | head -6 | sed 's/^/ /'
fi
# 2. the default parse keeps every key, JSON branch refused on the wide envelope
n=$(grep -c '"sub":"app"' "$RUN/out-parsed/relay.log")
check "defaults: wide envelope reaches the sink whole ($n line(s) carry sub)" $(( n == 1 ))
check "defaults: wide envelope reaches the sink whole ($n line(s) carry sub)" $((n == 1))
n=$(grep -c '1788917744178374836' "$RUN/out-parsed/relay.log")
check "defaults: large integers are not re-encoded through float64 ($n)" $(( n == 1 ))
check "defaults: large integers are not re-encoded through float64 ($n)" $((n == 1))
echo "================================================================"
if (( fail == 0 )); then
echo "RESULT: ALL PASS"
else
echo "RESULT: FAILURES — inspect $RUN/"
fi
exit "$fail"
summary
+113 -216
View File
@@ -1,122 +1,46 @@
#!/usr/bin/env bash
# logwisp SCRAM authentication test: chain edges and sink viewers log in with
# passwords managed by `logwisp auth`; the guide printed below maps the ports.
# Usage: ./scram-chain-test.sh [--auto [--keep]] manual mode keeps the daemons
# up; --auto runs the checks and tears down, --keep skips that on success.
# logwisp SCRAM test: chain edges and sink viewers log in with passwords
# managed by `lw auth`: wrong and missing credentials, a certificate bound to
# its user, a stream past the exchange deadline, revocation and throttling.
# Requires: bash 5+, coreutils (timeout), openssl, curl. Linux dev host only.
set -u
. "$(dirname -- "${BASH_SOURCE[0]}")/lib.sh"
SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
BIN="${LOGWISP_BIN:-$SCRIPT_DIR/../bin/logwisp}"
RUN="$SCRIPT_DIR/run-scram"
CONF="$RUN/conf"
LOG="$RUN/log"
PKI="$RUN/pki"
AUTH="$RUN/auth"
OUT="$RUN/out"
USERS="$AUTH/users.toml"
RUN=$E2E_DIR/run-scram
PKI=$RUN/pki
AUTH=$RUN/auth
OUT=$RUN/out
USERS=$AUTH/users.toml
PORT_TCP_CHAIN=15821
PORT_HTTP_CHAIN=15822
PORT_TCP_SINK=15823
PORT_HTTP_SINK=15824
PORT_BOUND=15825
PORTS="$PORT_TCP_CHAIN $PORT_HTTP_CHAIN $PORT_TCP_SINK $PORT_HTTP_SINK $PORT_BOUND"
e2e_init "$@"
AUTO=0; KEEP=0
for a in "$@"; do case "$a" in
--auto) AUTO=1 ;;
--keep) KEEP=1 ;;
*) echo "unknown arg: $a" >&2; exit 1 ;;
esac; done
PIDS=()
cleanup() {
local rc=$?
trap - EXIT INT TERM
if (( ${#PIDS[@]} )); then
echo "--- teardown: stopping ${#PIDS[@]} process(es)"
kill -TERM "${PIDS[@]}" 2>/dev/null
local deadline=$(( SECONDS + 10 ))
for pid in "${PIDS[@]}"; do
while kill -0 "$pid" 2>/dev/null && (( SECONDS < deadline )); do sleep 0.2; done
kill -KILL "$pid" 2>/dev/null
done
fi
exit "$rc"
}
trap cleanup EXIT INT TERM
port_open() { (exec 3<>"/dev/tcp/127.0.0.1/$1") 2>/dev/null && exec 3>&-; }
wait_port() { # port timeout_s
local i; for (( i=0; i < $2 * 10; i++ )); do
port_open "$1" && return 0
sleep 0.1
done
return 1
}
wait_until() { # timeout_s command...
local deadline=$(( SECONDS + $1 )); shift
until "$@"; do
(( SECONDS >= deadline )) && return 1
sleep 0.2
done
}
start_daemon() { # name conf
"$BIN" -c "$CONF/$2" > "$LOG/$1.out" 2>&1 &
PIDS+=($!)
echo "started $1 (pid $!)"
}
# --- Preflight ---
[[ -x "$BIN" ]] || { echo "binary not found: $BIN (build: go build -o bin/logwisp ./cmd/logwisp)" >&2; exit 1; }
command -v openssl >/dev/null || { echo "openssl not found" >&2; exit 1; }
command -v curl >/dev/null || { echo "curl not found" >&2; exit 1; }
for p in $PORT_TCP_CHAIN $PORT_HTTP_CHAIN $PORT_TCP_SINK $PORT_HTTP_SINK $PORT_BOUND; do
port_open "$p" && { echo "port $p already in use" >&2; exit 1; }
done
section "Setup"
need openssl curl
ports_free $PORTS
rm -rf "$RUN"
mkdir -p "$CONF" "$LOG" "$PKI" "$AUTH" "$OUT"
# --- PKI ---
gen_key() { openssl genpkey -algorithm EC -pkeyopt ec_paramgen_curve:P-256 -out "$1" 2>/dev/null; }
pki_ca
pki_leaf relay relay.internal serverAuth "IP:127.0.0.1"
pki_leaf edge-01 edge-01 clientAuth
pki_leaf edge-02 edge-02 clientAuth
info "test PKI in $(short "$PKI")/: ca, relay, edge-01, edge-02"
gen_leaf() { # name CN eku [SAN]
local name=$1 cn=$2 eku=$3 san=${4:-}
gen_key "$PKI/$name.key"
openssl req -new -key "$PKI/$name.key" -out "$PKI/$name.csr" -subj "/CN=$cn" 2>/dev/null
local ext="extendedKeyUsage=$eku"
[[ -n $san ]] && ext+=$'\n'"subjectAltName=$san"
printf '%s\n' "$ext" > "$PKI/$name.ext"
openssl x509 -req -in "$PKI/$name.csr" -CA "$PKI/ca.crt" -CAkey "$PKI/ca.key" \
-CAcreateserial -out "$PKI/$name.crt" -days 2 -extfile "$PKI/$name.ext" 2>/dev/null
}
# Credentials through the CLI only, and a password no user has, for the
# wrong-password and unknown-user edges
add_users "$USERS" edge-01 edge-02 viewer-01
(umask 077; openssl rand -base64 24 >"$AUTH/wrong.pass")
info "users edge-01, edge-02, viewer-01 in $(short "$USERS")"
echo "--- generating test PKI in $PKI"
gen_key "$PKI/ca.key"
openssl req -x509 -new -key "$PKI/ca.key" -days 2 -out "$PKI/ca.crt" \
-subj "/CN=LogWisp Test CA" 2>/dev/null
gen_leaf relay relay.internal serverAuth "IP:127.0.0.1"
gen_leaf edge-01 edge-01 clientAuth
gen_leaf edge-02 edge-02 clientAuth
[[ -s "$PKI/edge-02.crt" ]] || { echo "PKI generation failed" >&2; exit 1; }
# --- Credentials, through the CLI only ---
echo "--- adding users in $USERS"
for u in edge-01 edge-02 viewer-01; do
"$BIN" auth add-user -credentials "$USERS" -user "$u" -password-file "$AUTH/$u.pass" \
2>>"$LOG/auth-cli.out" || { echo "add-user $u failed (see $LOG/auth-cli.out)" >&2; exit 1; }
done
# A password no user has, for the wrong-password and unknown-user edges
(umask 077; openssl rand -base64 24 > "$AUTH/wrong.pass")
# --- Config generation ---
# One credentials file serves every listener, so revoking viewer-01 leaves the
# file with users (the CLI refuses to remove the last one).
cat > "$CONF/relay.toml" <<EOF
cat >"$CONF/relay.toml" <<EOF
status_reporter = false
[logging]
output = "stdout"
@@ -271,79 +195,58 @@ ca_file = "$PKI/ca.crt"
EOF
[[ -n $cert ]] && printf 'cert_file = "%s"\nkey_file = "%s"\n' "$PKI/$cert.crt" "$PKI/$cert.key"
[[ -n $user ]] && printf '[pipelines.plugin_sinks.config.auth]\ntype = "scram"\nusername = "%s"\npassword_file = "%s"\n' "$user" "$pass"
} > "$CONF/$name.toml"
} >"$CONF/$name.toml"
}
# edge_tcp declares node "edge-tcp": node_binding = "force" must relabel it edge-01.
# Under force a refused edge that got through would carry its username, so
# edge-02 and edge-99 entries in the edge-01 sinks would expose it.
edge_conf edge_tcp tcp_chain $PORT_TCP_CHAIN edge-tcp edge-01 "$AUTH/edge-01.pass"
edge_conf edge_http http_chain $PORT_HTTP_CHAIN edge-http edge-01 "$AUTH/edge-01.pass"
edge_conf edge_wrongpw tcp_chain $PORT_TCP_CHAIN edge-wrongpw edge-02 "$AUTH/wrong.pass"
edge_conf edge_unknown tcp_chain $PORT_TCP_CHAIN edge-unknown edge-99 "$AUTH/wrong.pass"
edge_conf edge_noauth tcp_chain $PORT_TCP_CHAIN edge-noauth "" ""
edge_conf edge_bound tcp_chain $PORT_BOUND edge-bound edge-01 "$AUTH/edge-01.pass" edge-01
edge_conf edge_cross tcp_chain $PORT_BOUND edge-cross edge-02 "$AUTH/edge-02.pass" edge-01
edge_conf edge_tcp tcp_chain $PORT_TCP_CHAIN edge-tcp edge-01 "$AUTH/edge-01.pass"
edge_conf edge_http http_chain $PORT_HTTP_CHAIN edge-http edge-01 "$AUTH/edge-01.pass"
edge_conf edge_wrongpw tcp_chain $PORT_TCP_CHAIN edge-wrongpw edge-02 "$AUTH/wrong.pass"
edge_conf edge_unknown tcp_chain $PORT_TCP_CHAIN edge-unknown edge-99 "$AUTH/wrong.pass"
edge_conf edge_noauth tcp_chain $PORT_TCP_CHAIN edge-noauth "" ""
edge_conf edge_bound tcp_chain $PORT_BOUND edge-bound edge-01 "$AUTH/edge-01.pass" edge-01
edge_conf edge_cross tcp_chain $PORT_BOUND edge-cross edge-02 "$AUTH/edge-02.pass" edge-01
info "configurations in $(short "$CONF")/"
# --- Guide ---
cat <<EOF
================================================================
logwisp SCRAM auth test — port map
$PORT_TCP_CHAIN relay ingest (tcp_chain, scram, node_binding = force)
$PORT_HTTP_CHAIN relay ingest (http_chain, scram)
$PORT_TCP_SINK TCP sink (scram)
$PORT_HTTP_SINK HTTP sink (scram)
$PORT_BOUND relay ingest (tcp_chain, client_auth + scram, certificate CN = user)
Edges: edge_tcp and edge_http log in as edge-01 and deliver; edge_wrongpw,
edge_unknown and edge_noauth are refused on $PORT_TCP_CHAIN; on $PORT_BOUND
edge_bound (edge-01 cert, user edge-01) delivers and edge_cross (edge-01
cert, user edge-02) is refused; $PKI/edge-02.crt pairs with user edge-02.
Read the TCP sink as viewer-01:
$BIN auth stream -addr 127.0.0.1:$PORT_TCP_SINK -user viewer-01 \\
-password-file $AUTH/viewer-01.pass -ca-file $PKI/ca.crt
Read the HTTP sink (the token stays off argv):
token=\$($BIN auth token -url https://127.0.0.1:$PORT_HTTP_SINK -user viewer-01 \\
-password-file $AUTH/viewer-01.pass -ca-file $PKI/ca.crt)
curl -N --noproxy '*' --cacert $PKI/ca.crt \\
-H @<(printf 'Authorization: Bearer %s\n' "\$token") https://127.0.0.1:$PORT_HTTP_SINK/stream
Revoke: $BIN auth remove-user -credentials $USERS -user viewer-01
then kill -HUP the relay.
Ingested entries land in $OUT/ (node label forced to the username).
Passwords and credentials: $AUTH/ Logs: $LOG/
================================================================
EOF
# --- Startup ---
section "Startup"
start_daemon relay relay.toml
RELAY_PID=${PIDS[-1]}
for p in $PORT_TCP_CHAIN $PORT_HTTP_CHAIN $PORT_TCP_SINK $PORT_HTTP_SINK $PORT_BOUND; do
wait_port "$p" 10 || { echo "FAIL: relay port $p not listening (see $LOG/relay.out)"; exit 1; }
for p in $PORTS; do
wait_port "$p" || abort "relay port $p is not listening" relay
done
for e in edge_tcp edge_http edge_wrongpw edge_unknown edge_noauth edge_bound edge_cross; do
start_daemon "$e" "$e.toml"
done
daemons_up
write_env LW="$BIN" CA="$PKI/ca.crt" PW="$AUTH/viewer-01.pass" USERS="$USERS" RELAY="$RELAY_PID"
if (( AUTO == 0 )); then
echo "--- daemons running; Ctrl-C to stop"
while :; do sleep 1; done
fi
fail=0
check() { # label condition_result
if (( $2 )); then echo "PASS: $1"; else echo "FAIL: $1"; fail=1; fi
}
guide "logwisp SCRAM test" <<EOF
Ports:
$PORT_TCP_CHAIN relay tcp_chain ingest, scram, node_binding = force
$PORT_HTTP_CHAIN relay http_chain ingest, scram
$PORT_TCP_SINK tcp sink, scram
$PORT_HTTP_SINK http sink, scram
$PORT_BOUND relay tcp_chain ingest, client_auth + scram, certificate CN = user
Edges: edge_tcp and edge_http log in as edge-01 and deliver; edge_wrongpw,
edge_unknown and edge_noauth are refused on $PORT_TCP_CHAIN. On $PORT_BOUND edge_bound
(edge-01 certificate and user) delivers, edge_cross (user edge-02) is refused.
Shell setup (LW, CA, PW: viewer-01's password file, USERS, RELAY: its pid):
> . $(short "$RUN")/env
Read the tcp sink as viewer-01:
> \$LW auth stream -addr 127.0.0.1:$PORT_TCP_SINK -user viewer-01 -password-file \$PW -ca-file \$CA
Read the http sink (bearer keeps the token off argv):
> token=\$(\$LW auth token -url https://127.0.0.1:$PORT_HTTP_SINK -user viewer-01 -password-file \$PW -ca-file \$CA)
> curl -N --cacert \$CA -H @<(bearer) https://127.0.0.1:$PORT_HTTP_SINK/stream
Revoke viewer-01:
> \$LW auth remove-user -credentials \$USERS -user viewer-01 && kill -HUP \$RELAY
Ingested entries (node label forced to the username): $(short "$OUT")/
Passwords: $(short "$AUTH")/ Logs: $(short "$LOG")/
EOF
manual_hold
relay_log="$LOG/relay.out"
ingested() { cat "$OUT"/${1}* 2>/dev/null; }
has_entry() { ingested "$1" | grep -q -- "$2"; } # file_prefix pattern
logged() { # log_name pattern [min_count]
local n; n=$(grep -c -- "$2" "$LOG/$1.out" 2>/dev/null)
(( ${n:-0} >= ${3:-1} ))
}
token_for() { # user -> token on stdout; the exit status is the CLI's
"$BIN" auth token -url "https://127.0.0.1:$PORT_HTTP_SINK" -user "$1" \
@@ -360,72 +263,73 @@ http_get() { # path [token] -> http_code; the header goes through a pipe, never
fi
}
echo "=== Scenario 1: chained instances over SCRAM ==="
section "Scenario 1: chained instances over SCRAM"
# 1. An edge with the right password delivers on both transports
wait_until 20 has_entry tcp_chain 'edge-01/'
tcp_file="$(ingested tcp_chain)"
n=$(grep -c 'edge-01/' <<< "$tcp_file")
check "tcp_chain: edge-01 entries reached the file sink ($n lines)" $(( n >= 1 ))
n=$(grep -c 'edge-01/' <<<"$tcp_file")
check "tcp_chain: edge-01 entries reached the file sink ($n lines)" $((n >= 1))
wait_until 20 has_entry http_chain 'edge-01/'
http_file="$(ingested http_chain)"
n=$(grep -c 'edge-01/' <<< "$http_file")
check "http_chain: edge-01 entries reached the file sink ($n lines)" $(( n >= 1 ))
n=$(grep -c 'edge-01/' <<<"$http_file")
check "http_chain: edge-01 entries reached the file sink ($n lines)" $((n >= 1))
# 2. node_binding = "force" replaced the label the sender configured
n=$(grep -c 'edge-tcp/' <<< "$tcp_file")
check "node binding: sender's own label \"edge-tcp\" was not honored ($n lines)" $(( n == 0 ))
n=$(grep -c 'edge-http/' <<< "$http_file")
check "node binding: sender's own label \"edge-http\" was not honored ($n lines)" $(( n == 0 ))
n=$(grep -c 'edge-tcp/' <<<"$tcp_file")
check "node binding: sender's own label \"edge-tcp\" was not honored ($n lines)" $((n == 0))
n=$(grep -c 'edge-http/' <<<"$http_file")
check "node binding: sender's own label \"edge-http\" was not honored ($n lines)" $((n == 0))
# 3. Wrong password, unknown user and no credentials are all refused
wait_until 20 logged edge_wrongpw 'Chain connect refused'
n=$(grep -c 'Chain connect refused' "$LOG/edge_wrongpw.out")
check "wrong password: edge-02 with a bad password was refused ($n refusals)" $(( n >= 1 ))
n=$(count edge_wrongpw 'Chain connect refused')
check "wrong password: edge-02 with a bad password was refused ($n refusals)" $((n >= 1))
wait_until 20 logged edge_unknown 'Chain connect refused'
n=$(grep -c 'Chain connect refused' "$LOG/edge_unknown.out")
check "unknown user: edge-99 was refused ($n refusals)" $(( n >= 1 ))
n=$(count edge_unknown 'Chain connect refused')
check "unknown user: edge-99 was refused ($n refusals)" $((n >= 1))
wait_until 20 logged relay 'peer offered no credentials'
n=$(grep -c 'peer offered no credentials' "$relay_log")
check "no auth: relay refused an edge that offered no credentials ($n refusals)" $(( n >= 1 ))
n=$(count relay 'peer offered no credentials')
check "no auth: relay refused an edge that offered no credentials ($n refusals)" $((n >= 1))
wait_until 20 logged edge_noauth 'Chain link refused'
n=$(grep -c 'Chain link refused' "$LOG/edge_noauth.out")
check "no auth: the edge read the refusal instead of writing into a closed link ($n)" $(( n >= 1 ))
n=$(count edge_noauth 'Chain link refused')
check "no auth: the edge read the refusal instead of writing into a closed link ($n)" $((n >= 1))
failed_login='Connection rejected by auth policy.*instance_id in_tcp .*invalid credentials'
wait_until 20 logged relay "$failed_login" 2
n=$(grep -c "$failed_login" "$relay_log")
check "relay logged the failed logins on :$PORT_TCP_CHAIN ($n rejections)" $(( n >= 2 ))
n=$(count relay "$failed_login")
check "relay logged the failed logins on :$PORT_TCP_CHAIN ($n rejections)" $((n >= 2))
tcp_file="$(ingested tcp_chain)"
n=$(grep -vc 'edge-01/' <<< "$tcp_file")
check "refused edges: nothing but edge-01 entries was ingested ($n foreign lines)" $(( n == 0 ))
n=$(grep -vc 'edge-01/' <<<"$tcp_file")
check "refused edges: nothing but edge-01 entries was ingested ($n foreign lines)" $((n == 0))
# 4. Certificate bound to the user: edge-01's certificate admits only edge-01
wait_until 20 has_entry bound 'edge-01/'
n=$(ingested bound | grep -c 'edge-01/')
check "certificate binding: edge-01 certificate + user edge-01 delivered ($n lines)" $(( n >= 1 ))
check "certificate binding: edge-01 certificate + user edge-01 delivered ($n lines)" $((n >= 1))
wait_until 20 logged edge_cross 'Chain connect refused'
n=$(grep -c 'Chain connect refused' "$LOG/edge_cross.out")
check "certificate binding: edge-01 certificate + user edge-02 was refused ($n refusals)" $(( n >= 1 ))
n=$(count edge_cross 'Chain connect refused')
check "certificate binding: edge-01 certificate + user edge-02 was refused ($n refusals)" $((n >= 1))
wait_until 20 logged relay 'does not match user \\"edge-02\\"'
n=$(grep -c 'does not match user \\"edge-02\\"' "$relay_log")
check "certificate binding: relay logged the certificate/user mismatch ($n)" $(( n >= 1 ))
n=$(count relay 'does not match user \\"edge-02\\"')
check "certificate binding: relay logged the certificate/user mismatch ($n)" $((n >= 1))
n=$(ingested bound | grep -vc 'edge-01/')
check "certificate binding: the cross-bound edge delivered nothing ($n foreign lines)" $(( n == 0 ))
check "certificate binding: the cross-bound edge delivered nothing ($n foreign lines)" $((n == 0))
echo "=== Scenario 2: viewers on SCRAM-gated sinks ==="
section "Scenario 2: viewers on SCRAM-gated sinks"
# 5. TCP sink: a logged-in viewer keeps streaming past the 10 s exchange
# deadline; a TLS client that sends no hello gets nothing and is dropped at it
info "streaming as viewer-01 beside a silent TLS client, 13 s"
"$BIN" auth stream -addr "127.0.0.1:$PORT_TCP_SINK" -user viewer-01 \
-password-file "$AUTH/viewer-01.pass" -ca-file "$PKI/ca.crt" \
> "$RUN/stream.out" 2>"$LOG/stream.err" &
>"$RUN/stream.out" 2>"$LOG/stream.err" &
stream_pid=$!
PIDS+=($stream_pid)
timeout 12 openssl s_client -quiet -connect "127.0.0.1:$PORT_TCP_SINK" \
@@ -437,47 +341,47 @@ sleep 2
n2=$(grep -c 'edge-01/' "$RUN/stream.out")
kill -TERM "$stream_pid" 2>/dev/null
wait "$stream_pid" "$raw_pid" 2>/dev/null
check "tcp sink: viewer-01 streamed entries through logwisp auth stream ($n1 lines at 11 s)" $(( n1 >= 1 ))
check "tcp sink: stream still live after the exchange deadline ($n1 -> $n2 lines, 11 s -> 13 s)" $(( n2 > n1 ))
n=$(wc -c < "$RUN/raw.out")
check "tcp sink: a TLS client with no hello received nothing ($n bytes)" $(( n == 0 ))
check "tcp sink: viewer-01 streamed entries through lw auth stream ($n1 lines at 11 s)" $((n1 >= 1))
check "tcp sink: stream still live after the exchange deadline ($n1 -> $n2 lines, 11 s -> 13 s)" $((n2 > n1))
n=$(wc -c <"$RUN/raw.out")
check "tcp sink: a TLS client with no hello received nothing ($n bytes)" $((n == 0))
n=$(grep 'component tcp_sink' "$relay_log" | grep -c 'read hello')
check "tcp sink: relay dropped the silent client at the exchange deadline ($n)" $(( n >= 1 ))
check "tcp sink: relay dropped the silent client at the exchange deadline ($n)" $((n >= 1))
# 6. HTTP sink: a bearer token from logwisp auth token, carried by curl
# 6. HTTP sink: a bearer token from lw auth token, carried by curl
token="$(token_for viewer-01)"
check "http sink: logwisp auth token issued a token for viewer-01" $([[ -n $token ]] && echo 1 || echo 0)
check "http sink: lw auth token issued a token for viewer-01" $(is_set "$token")
code="$(http_get /status "$token")"
check "http sink: /status served with the token (HTTP $code)" $([[ $code == 200 ]] && echo 1 || echo 0)
check "http sink: /status served with the token (HTTP $code)" $(is "$code" 200)
sse="$(timeout 4 curl -sN --noproxy '*' --cacert "$PKI/ca.crt" \
-H @<(printf 'Authorization: Bearer %s\n' "$token") \
"https://127.0.0.1:$PORT_HTTP_SINK/stream" 2>/dev/null || true)"
n=$(grep -c '^data:.*edge-01/' <<< "$sse")
check "http sink: /stream delivered SSE events with the token ($n events)" $(( n >= 1 ))
n=$(grep -c '^data:.*edge-01/' <<<"$sse")
check "http sink: /stream delivered SSE events with the token ($n events)" $((n >= 1))
code="$(http_get /status)"
check "http sink: /status refused without a token (HTTP $code)" $([[ $code == 401 ]] && echo 1 || echo 0)
check "http sink: /status refused without a token (HTTP $code)" $(is "$code" 401)
code="$(http_get /stream not.a.token)"
check "http sink: /stream refused with a garbage token (HTTP $code)" $([[ $code == 401 ]] && echo 1 || echo 0)
check "http sink: /stream refused with a garbage token (HTTP $code)" $(is "$code" 401)
# 7. Revocation: remove-user + SIGHUP kills the token and the login
reloads=$(grep -c 'Configuration hot reload completed successfully' "$relay_log")
reloaded() { (( $(grep -c 'Configuration hot reload completed successfully' "$relay_log") > reloads )); }
reloads=$(count relay 'Configuration hot reload completed successfully')
reloaded() { (($(count relay 'Configuration hot reload completed successfully') > reloads)); }
"$BIN" auth remove-user -credentials "$USERS" -user viewer-01 2>>"$LOG/auth-cli.out"
rc=$?
check "revocation: logwisp auth remove-user removed viewer-01 (exit $rc)" $(( rc == 0 ))
check "revocation: lw auth remove-user removed viewer-01 (exit $rc)" $((rc == 0))
kill -HUP "$RELAY_PID"
wait_until 20 reloaded && wait_port "$PORT_HTTP_SINK" 10
wait_until 20 reloaded && wait_port "$PORT_HTTP_SINK"
rc=$?
check "revocation: relay reloaded on SIGHUP" $(( rc == 0 ))
check "revocation: relay reloaded on SIGHUP" $((rc == 0))
code="$(http_get /status "$token")"
check "revocation: the old token is refused after the reload (HTTP $code)" $([[ $code == 401 ]] && echo 1 || echo 0)
check "revocation: the old token is refused after the reload (HTTP $code)" $(is "$code" 401)
token_for viewer-01 >/dev/null
rc=$?
check "revocation: logwisp auth token for viewer-01 now fails (exit $rc)" $(( rc == 1 ))
check "revocation: lw auth token for viewer-01 now fails (exit $rc)" $((rc == 1))
# 8. Throttling, last: it leaves this address throttled on the http sink.
# At most 4 exchanges may be unfinished per address, so hellos that are
@@ -492,13 +396,6 @@ for i in $(seq 1 12); do
[[ $code == 429 ]] && break
done
check "throttling: /auth answered 429 after ${#codes[@]} rapid hellos (${codes[*]})" \
$([[ $code == 429 ]] && (( ${#codes[@]} <= 6 )) && echo 1 || echo 0)
$([[ $code == 429 ]] && ((${#codes[@]} <= 6)) && echo 1 || echo 0)
echo "================================================================"
if (( fail == 0 )); then
echo "RESULT: ALL PASS"
(( KEEP )) && { echo "--keep: daemons left running (pids: ${PIDS[*]})"; PIDS=(); }
else
echo "RESULT: FAILURES — inspect $LOG/*.out"
fi
exit "$fail"
summary
+230 -162
View File
@@ -1,95 +1,50 @@
#!/usr/bin/env bash
# logwisp browser login test: an http sink in proxy mode (auth.trusted_proxies)
# behind a TLS-terminating reverse proxy that mounts it under /logs/. Headless
# Chromium logs in through the shipped pages; the CLI logs in unbound.
# Usage: ./scram-proxy-test.sh [--auto [--keep]] manual mode keeps the daemons
# up; --auto runs the checks and tears down, --keep skips that on success.
# Requires: bash 5+, go, openssl, curl; node with playwright for the browser
# checks (skipped, and reported, without it). Linux dev host only.
# logwisp browser login test: an http sink in proxy mode behind a TLS-terminating
# reverse proxy that mounts it under /logs/; headless Chromium signs in through
# the shipped pages, with cookies and without (token mode); the CLI unbound.
# Requires: bash 5+, go, openssl, curl; node with playwright and both builds
# `playwright install chromium` fetches, or the browser checks skip.
set -u
. "$(dirname -- "${BASH_SOURCE[0]}")/lib.sh"
SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
BIN="${LOGWISP_BIN:-$SCRIPT_DIR/../bin/logwisp}"
RUN="$SCRIPT_DIR/run-proxy"
CONF="$RUN/conf"
LOG="$RUN/log"
PKI="$RUN/pki"
AUTH="$RUN/auth"
PROXY_SRC="$RUN/proxy"
USERS="$AUTH/users.toml"
RUN=$E2E_DIR/run-proxy
PKI=$RUN/pki
AUTH=$RUN/auth
PROXY_SRC=$RUN/proxy
USERS=$AUTH/users.toml
PORT_PROXY=15831 # the site: TLS ends here, logwisp is under /logs/
PORT_SINK=15832 # logwisp http sink, plaintext, only for 127.0.0.2
PROXY_ADDR=127.0.0.2
SITE="https://127.0.0.1:$PORT_PROXY"
e2e_init "$@"
AUTO=0; KEEP=0
for a in "$@"; do case "$a" in
--auto) AUTO=1 ;;
--keep) KEEP=1 ;;
*) echo "unknown arg: $a" >&2; exit 1 ;;
esac; done
PIDS=()
cleanup() {
local rc=$?
trap - EXIT INT TERM
if (( ${#PIDS[@]} )); then
echo "--- teardown: stopping ${#PIDS[@]} process(es)"
kill -TERM "${PIDS[@]}" 2>/dev/null
local deadline=$(( SECONDS + 10 ))
for pid in "${PIDS[@]}"; do
while kill -0 "$pid" 2>/dev/null && (( SECONDS < deadline )); do sleep 0.2; done
kill -KILL "$pid" 2>/dev/null
done
fi
exit "$rc"
}
trap cleanup EXIT INT TERM
port_open() { (exec 3<>"/dev/tcp/127.0.0.1/$1") 2>/dev/null && exec 3>&-; }
wait_port() { # port timeout_s
local i; for (( i=0; i < $2 * 10; i++ )); do
port_open "$1" && return 0
sleep 0.1
done
return 1
}
# --- Preflight ---
[[ -x "$BIN" ]] || { echo "binary not found: $BIN (build: go build -o bin/logwisp ./cmd/logwisp)" >&2; exit 1; }
for tool in go openssl curl; do
command -v "$tool" >/dev/null || { echo "$tool not found" >&2; exit 1; }
done
for p in $PORT_PROXY $PORT_SINK; do
port_open "$p" && { echo "port $p already in use" >&2; exit 1; }
done
BROWSER=0
command -v node >/dev/null && node -e 'require.resolve("playwright")' 2>/dev/null && BROWSER=1
section "Setup"
need go openssl curl
ports_free $PORT_PROXY $PORT_SINK
# The launch each scenario makes: the module resolves without its browsers, and
# chromium.executablePath() names full Chromium, not the default headless shell
launches() { timeout 60 node -e 'require("playwright").chromium.launch(JSON.parse(process.argv[1])).then((b) => b.close())' "$1" >/dev/null 2>&1; }
BROWSER=0 FULL_CHROMIUM=0
if command -v node >/dev/null; then
launches '{}' && BROWSER=1
launches '{"channel": "chromium"}' && FULL_CHROMIUM=1
fi
rm -rf "$RUN"
mkdir -p "$CONF" "$LOG" "$PKI" "$AUTH" "$PROXY_SRC"
# --- PKI: the site's certificate; logwisp holds none ---
echo "--- generating the site certificate in $PKI"
openssl genpkey -algorithm EC -pkeyopt ec_paramgen_curve:P-256 -out "$PKI/ca.key" 2>/dev/null
openssl req -x509 -new -key "$PKI/ca.key" -days 2 -out "$PKI/ca.crt" -subj "/CN=LogWisp Test CA" 2>/dev/null
openssl genpkey -algorithm EC -pkeyopt ec_paramgen_curve:P-256 -out "$PKI/site.key" 2>/dev/null
openssl req -new -key "$PKI/site.key" -out "$PKI/site.csr" -subj "/CN=site.internal" 2>/dev/null
printf 'extendedKeyUsage=serverAuth\nsubjectAltName=IP:127.0.0.1\n' > "$PKI/site.ext"
openssl x509 -req -in "$PKI/site.csr" -CA "$PKI/ca.crt" -CAkey "$PKI/ca.key" -CAcreateserial \
-out "$PKI/site.crt" -days 2 -extfile "$PKI/site.ext" 2>/dev/null
[[ -s "$PKI/site.crt" ]] || { echo "PKI generation failed" >&2; exit 1; }
# The site's certificate; logwisp holds none
pki_ca
pki_leaf site site.internal serverAuth "IP:127.0.0.1"
info "site certificate in $(short "$PKI")/"
# --- Credentials, through the CLI only (the default 64 MiB profile) ---
"$BIN" auth add-user -credentials "$USERS" -user viewer-01 -password-file "$AUTH/viewer-01.pass" \
2>>"$LOG/auth-cli.out" || { echo "add-user failed (see $LOG/auth-cli.out)" >&2; exit 1; }
# The default 64 MiB Argon2 profile, as a deployment would have
add_users "$USERS" viewer-01
info "user viewer-01 in $(short "$USERS")"
# --- The site's reverse proxy ---
# /logs/ forwards to logwisp from $PROXY_ADDR; /plain/ forwards the same way
# but says the site was reached over http, which logwisp must refuse.
cat > "$PROXY_SRC/main.go" <<'EOF'
cat >"$PROXY_SRC/main.go" <<'EOF'
package main
import (
@@ -129,10 +84,13 @@ func main() {
log.Fatal(http.ListenAndServeTLS(listen, cert, key, mux))
}
EOF
(cd "$PROXY_SRC" && go build -o proxy main.go) || { echo "proxy build failed" >&2; exit 1; }
if ! (cd "$PROXY_SRC" && go build -o proxy main.go) >"$LOG/proxy-build.out" 2>&1; then
tail -n 5 "$LOG/proxy-build.out" | sed 's/^/ /'
abort "the reverse proxy did not build"
fi
info "reverse proxy built in $(short "$PROXY_SRC")/"
# --- Config ---
cat > "$CONF/logwisp.toml" <<EOF
cat >"$CONF/logwisp.toml" <<EOF
status_reporter = false
[logging]
output = "stdout"
@@ -162,35 +120,47 @@ credentials_file = "$USERS"
trusted_proxies = ["$PROXY_ADDR"]
EOF
cat > "$RUN/browser.cjs" <<'EOF'
// Logs in through the shipped pages, as a person would; prints key=value lines
cat >"$RUN/browser.cjs" <<'EOF'
// Signs in through the shipped pages, as a person would; prints key=value lines.
// cookie: a fresh browser. dropped: the session cookie never arrives. blocked:
// a profile that refuses every cookie; it also reloads logwisp (pid) mid-stream.
const { chromium } = require("playwright");
const [base, user, password] = process.argv.slice(2);
(async () => {
const out = { csp_violations: 0 };
const browser = await chromium.launch();
try {
const context = await browser.newContext({ ignoreHTTPSErrors: true });
const page = await context.newPage();
page.on("console", (m) => { if (/Content Security Policy/i.test(m.text())) out.csp_violations++; });
const status = () => page.evaluate(() => document.getElementById("status").textContent);
const fs = require("fs");
const [mode, base, user, passFile, profile, pid] = process.argv.slice(2);
const password = fs.readFileSync(passFile, "utf8").replace(/\n+$/, "");
const out = { csp_violations: 0 };
const lines = (page) => page.evaluate(() => document.querySelectorAll("#log > div").length);
const linesAtLeast = (page, n) =>
page.waitForFunction((n) => document.querySelectorAll("#log > div").length >= n, n, { timeout: 15000 });
const statusText = (page) => page.evaluate(() => document.getElementById("status").textContent);
const statusIs = (page, re) =>
page.waitForFunction((re) => new RegExp(re).test(document.getElementById("status").textContent), re.source, { timeout: 30000 });
const signIn = async (page, pass) => {
await page.fill('#login input[name="username"]', user);
await page.fill('#login input[name="password"]', pass);
await page.click('#login button[type="submit"]');
};
// The viewer's own form: the login page has a visible #login too, but no #log
const inlineLogin = (page) => page.waitForFunction(() =>
document.getElementById("log") && !document.getElementById("login").hidden, null, { timeout: 30000 });
const scenarios = {
async cookie(context, page) {
await page.goto(base + "auth/view");
await page.waitForURL(/\/auth\/login\?next=view$/, { timeout: 10000 });
await page.waitForURL(/\/auth\/login\?next=view%23signed-in$/, { timeout: 10000 });
out.redirected_to_login = 1;
await page.fill('input[name="username"]', user);
await page.fill('input[name="password"]', "not-the-password");
await page.click('button[type="submit"]');
await page.waitForFunction(() => /refused/.test(document.getElementById("status").textContent), null, { timeout: 30000 });
await signIn(page, "not-the-password");
await statusIs(page, /refused/);
out.wrong_password_refused = 1;
await page.fill('input[name="password"]', password);
const started = Date.now();
await page.click('button[type="submit"]');
await page.waitForURL(/\/auth\/view$/, { timeout: 30000 }).catch(async (e) => { throw new Error(`${e.message}: ${await status()}`); });
await signIn(page, password);
await page.waitForURL(/\/auth\/view$/, { timeout: 30000 }).catch(async (e) => { throw new Error(`${e.message}: ${await statusText(page)}`); });
out.login_ms = Date.now() - started;
await page.waitForFunction(() => document.querySelectorAll("#log > div").length >= 3, null, { timeout: 15000 });
out.events = await page.evaluate(() => document.querySelectorAll("#log > div").length);
await linesAtLeast(page, 3);
out.events = await lines(page);
out.inline_login = await page.evaluate(() => !document.getElementById("login").hidden);
const c = (await context.cookies()).find((c) => c.name === "logwisp_session");
out.cookie = c ? `path=${c.path};httponly=${c.httpOnly};secure=${c.secure};samesite=${c.sameSite}` : "none";
@@ -200,54 +170,120 @@ const [base, user, password] = process.argv.slice(2);
await page.waitForURL(/\/auth\/login$/, { timeout: 10000 });
out.cookie_after_logout = (await context.cookies()).some((c) => c.name === "logwisp_session") ? 1 : 0;
out.status_after_logout = await page.evaluate(async (b) => (await fetch(b + "status")).status, base);
},
// As if the browser dropped the cookie silently: the viewer must not send
// the person back to the login page in a loop
async dropped(context, page) {
await context.route(/\/auth$/, async (route) => {
const response = await route.fetch();
await context.clearCookies(); // route.fetch shares the context's cookie jar
const headers = response.headers();
delete headers["set-cookie"];
await route.fulfill({ response, headers });
});
await page.goto(base + "auth/view");
await page.waitForURL(/\/auth\/login\?next=/, { timeout: 10000 });
await signIn(page, password);
await page.waitForURL(/\/auth\/view$/, { timeout: 30000 });
await inlineLogin(page);
out.inline_login = 1;
const streamed = page.waitForRequest(/\/stream$/, { timeout: 30000 });
await signIn(page, password);
const token = ((await streamed).headers().authorization ?? "").replace(/^Bearer /, "");
await page.waitForFunction(() => document.getElementById("state").textContent === "live", null, { timeout: 30000 });
out.token_in_page_only = await page.evaluate((t) => t !== "" && localStorage.length === 0 &&
sessionStorage.length === 0 && !location.href.includes(t) && !document.cookie.includes(t), token) ? 1 : 0;
},
async blocked(context, page) {
out.proofs = out.proofs_with_session = 0;
page.on("request", (r) => {
const body = r.method() === "POST" && /\/auth$/.test(r.url()) ? JSON.parse(r.postData() ?? "{}") : {};
if (body.proof) out.proofs++;
if (body.proof && "session" in body) out.proofs_with_session++;
});
await page.goto(base + "auth/login");
out.cookie_enabled = await page.evaluate(() => navigator.cookieEnabled);
out.document_cookie_kept = JSON.stringify(await page.evaluate(() => { document.cookie = "t=1"; return document.cookie; }));
await page.waitForSelector('#status a[href="view"]', { timeout: 10000 });
out.login_page_disabled = await page.evaluate(() => document.querySelector("#login button").disabled);
await page.click('#status a[href="view"]');
await inlineLogin(page);
out.inline_login = 1;
const streamed = page.waitForRequest(/\/stream$/, { timeout: 30000 });
await signIn(page, password);
const bearer = (await streamed).headers().authorization ?? "";
out.stream_bearer = /^Bearer \S+$/.test(bearer) ? 1 : 0;
await linesAtLeast(page, 3);
out.events = await lines(page);
const statusWith = () => page.evaluate(async ([b, h]) =>
(await fetch(b + "status", { headers: { Authorization: h } })).status, [base, bearer]);
out.token_status = await statusWith();
await page.click("#logout");
await statusIs(page, /^Signed out\.$/);
await inlineLogin(page);
out.token_status_after_logout = await statusWith();
// A reload ends the open stream and every token; the reconnect's 401 asks again
await signIn(page, password);
await page.waitForFunction(() => document.getElementById("state").textContent === "live", null, { timeout: 30000 });
process.kill(Number(pid), "SIGHUP");
await statusIs(page, /session ended/);
await inlineLogin(page);
out.expired_asks_again = 1;
},
};
(async () => {
let context;
try {
if (mode === "blocked") {
// Full Chromium: the headless shell ignores a profile's Preferences
context = await chromium.launchPersistentContext(profile, { ignoreHTTPSErrors: true, channel: "chromium" });
} else {
context = await (await chromium.launch()).newContext({ ignoreHTTPSErrors: true });
}
const page = await context.newPage();
page.on("console", (m) => { if (/Content Security Policy/i.test(m.text())) out.csp_violations++; });
await scenarios[mode](context, page);
} catch (e) {
out.error = JSON.stringify(e.message.split("\n")[0]);
} finally {
await browser.close();
await (context?.browser() ?? context)?.close();
}
for (const [k, v] of Object.entries(out)) console.log(`${k}=${v}`);
})();
EOF
info "configuration in $(short "$CONF")/, browser driver $(short "$RUN/browser.cjs")"
# --- Guide ---
cat <<EOF
================================================================
logwisp browser login test — port map
$PORT_PROXY the site: TLS-terminating reverse proxy, logwisp under /logs/
$PORT_SINK logwisp http sink, plaintext, trusted_proxies = ["$PROXY_ADDR"]
section "Startup"
# logwisp stays PIDS[0]: the blocked-cookie scenario reloads it with SIGHUP
start_daemon logwisp logwisp.toml
spawn proxy "$PROXY_SRC/proxy" "127.0.0.1:$PORT_PROXY" "http://127.0.0.1:$PORT_SINK" "$PROXY_ADDR" \
"$PKI/site.crt" "$PKI/site.key"
wait_port $PORT_SINK || abort "logwisp port $PORT_SINK is not listening" logwisp
wait_port $PORT_PROXY || abort "proxy port $PORT_PROXY is not listening" proxy
daemons_up
write_env LW="$BIN" CA="$PKI/ca.crt" PW="$AUTH/viewer-01.pass" SITE="$SITE"
Open $SITE/logs/auth/view (CA: $PKI/ca.crt) and sign in as viewer-01
with the password in $AUTH/viewer-01.pass.
CLI, unbound through the proxy (the token stays off argv):
token=\$($BIN auth token -unbound -url $SITE/logs -user viewer-01 \\
-password-file $AUTH/viewer-01.pass -ca-file $PKI/ca.crt)
curl -N --noproxy '*' --cacert $PKI/ca.crt \\
-H @<(printf 'Authorization: Bearer %s\n' "\$token") $SITE/logs/stream
Logs: $LOG/
================================================================
guide "logwisp browser login test" <<EOF
Ports:
$PORT_PROXY the site: TLS-terminating reverse proxy, logwisp under /logs/
$PORT_SINK logwisp http sink, plaintext, trusted_proxies = ["$PROXY_ADDR"]
In a browser trusting $(short "$PKI/ca.crt"), sign in as viewer-01 with the
password in $(short "$AUTH/viewer-01.pass"):
> $SITE/logs/auth/view
Shell setup (LW, CA, PW: viewer-01's password file, SITE):
> . $(short "$RUN")/env
The CLI, unbound through the proxy (bearer keeps the token off argv):
> token=\$(\$LW auth token -unbound -url \$SITE/logs -user viewer-01 -password-file \$PW -ca-file \$CA)
> curl -N --cacert \$CA -H @<(bearer) \$SITE/logs/stream
Logs: $(short "$LOG")/
EOF
# --- Startup ---
"$BIN" -c "$CONF/logwisp.toml" > "$LOG/logwisp.out" 2>&1 &
PIDS+=($!)
"$PROXY_SRC/proxy" "127.0.0.1:$PORT_PROXY" "http://127.0.0.1:$PORT_SINK" "$PROXY_ADDR" \
"$PKI/site.crt" "$PKI/site.key" > "$LOG/proxy.out" 2>&1 &
PIDS+=($!)
for p in $PORT_SINK $PORT_PROXY; do
wait_port "$p" 10 || { echo "FAIL: port $p not listening (see $LOG/)"; exit 1; }
done
if (( AUTO == 0 )); then
echo "--- daemons running; Ctrl-C to stop"
while :; do sleep 1; done
fi
fail=0
check() { # label condition_result
if (( $2 )); then echo "PASS: $1"; else echo "FAIL: $1"; fail=1; fi
}
is() { [[ $1 == "$2" ]] && echo 1 || echo 0; } # actual expected -> 1 or 0
is_set() { [[ -n $1 ]] && echo 1 || echo 0; }
manual_hold
code_of() { # url [token] -> http_code; the header goes through a pipe, never argv
local args=(-s -o /dev/null -w '%{http_code}' --max-time 5 --noproxy '*' --cacert "$PKI/ca.crt")
@@ -258,48 +294,87 @@ code_of() { # url [token] -> http_code; the header goes through a pipe, never ar
fi
}
echo "=== Scenario 1: a person in a browser ==="
if (( BROWSER )); then
browser_out="$(timeout 120 node "$RUN/browser.cjs" "$SITE/logs/" viewer-01 "$(cat "$AUTH/viewer-01.pass")" 2>"$LOG/browser.err")"
echo "$browser_out" > "$LOG/browser.out"
val() { sed -n "s/^$1=//p" <<< "$browser_out"; }
[[ -n $(val error) ]] && echo "browser error: $(val error)"
browser() { # mode: runs browser.cjs, whose key=value lines val reads
info "browser: $1 (output in $(short "$LOG")/browser-$1.out)"
timeout 120 node "$RUN/browser.cjs" "$1" "$SITE/logs/" viewer-01 "$AUTH/viewer-01.pass" \
"$RUN/profile" "${PIDS[0]}" >"$LOG/browser-$1.out" 2>"$LOG/browser-$1.err"
browser_out="$(cat "$LOG/browser-$1.out")"
[[ -z $(val error) ]] || info "${C_RED}browser error ($1): $(val error)$C_OFF"
}
val() { sed -n "s/^$1=//p" <<<"$browser_out"; }
# The count covers the whole scenario only when it ran to its end without error
csp_clean() { echo $(($(is "$(val error)" "") && $(is "$(val csp_violations)" 0))); }
section "Scenario 1: a person in a browser"
if ((BROWSER)); then
browser cookie
check "browser: an unauthenticated viewer is sent to the login page" $(is "$(val redirected_to_login)" 1)
check "browser: a wrong password is refused on the page" $(is "$(val wrong_password_refused)" 1)
check "browser: login (Argon2 in the page, $(val login_ms) ms) opened the viewer" $(is_set "$(val login_ms)")
n=$(val events)
check "browser: the viewer showed events through EventSource (${n:-0} lines)" $(( ${n:-0} >= 3 ))
check "browser: the viewer showed events through EventSource (${n:-0} lines)" $((${n:-0} >= 3))
check "browser: with the cookie working the viewer shows no sign-in form" $(is "$(val inline_login)" false)
check "browser: session cookie $(val cookie)" \
$(is "$(val cookie)" "path=/logs;httponly=true;secure=true;samesite=Strict")
check "browser: page scripts cannot read the session (document.cookie $(val document_cookie))" \
$(is "$(val document_cookie)" '""')
check "browser: sign out cleared the cookie" $(is "$(val cookie_after_logout)" 0)
check "browser: status refused after sign out (HTTP $(val status_after_logout))" $(is "$(val status_after_logout)" 401)
check "browser: no CSP violations ($(val csp_violations))" $(is "$(val csp_violations)" 0)
check "browser: no CSP violations ($(val csp_violations))" $(csp_clean)
n=$(grep 'Login accepted' "$LOG/logwisp.out" | grep -c 'remote_addr 127.0.0.1')
check "logwisp logged the forwarded client, not the proxy ($n logins from 127.0.0.1)" $(( n >= 1 ))
check "logwisp logged the forwarded client, not the proxy ($n logins from 127.0.0.1)" $((n >= 1))
browser dropped
check "browser: a session cookie the browser dropped brings the viewer's own sign-in form, not a loop" \
$(is "$(val inline_login)" 1)
check "browser: its token stays out of storage, the URL and cookies" $(is "$(val token_in_page_only)" 1)
else
echo "SKIP: browser checks (node with playwright not found)"
skip "browser checks: node, playwright or its Chromium headless shell missing"
fi
echo "=== Scenario 2: the CLI through the proxy ==="
section "Scenario 2: a browser that keeps no cookies"
if ((FULL_CHROMIUM)); then
mkdir -p "$RUN/profile/Default"
echo '{"profile": {"default_content_setting_values": {"cookies": 2}}}' >"$RUN/profile/Default/Preferences"
browser blocked
check "no cookies: the profile blocks cookies (document.cookie kept $(val document_cookie_kept); navigator.cookieEnabled $(val cookie_enabled))" \
$(is "$(val document_cookie_kept)" '""')
check "no cookies: the login page says so, links to the viewer and stays disabled" \
$(is "$(val login_page_disabled)" true)
check "no cookies: the viewer shows its own sign-in form" $(is "$(val inline_login)" 1)
n=$(val events)
check "no cookies: the viewer streamed events with a bearer token (${n:-0} lines)" \
$((${n:-0} >= 3 && $(is "$(val stream_bearer)" 1)))
n=$(val proofs)
check "no cookies: the viewer's proofs asked for no cookie (${n:-0} sent, $(val proofs_with_session) with a session)" \
$((${n:-0} >= 1 && $(is "$(val proofs_with_session)" 0)))
check "no cookies: the page's token opened status (HTTP $(val token_status))" $(is "$(val token_status)" 200)
check "no cookies: sign out revoked it (HTTP $(val token_status_after_logout))" \
$(is "$(val token_status_after_logout)" 401)
check "no cookies: a reload ended the session and the viewer asked to sign in again" \
$(is "$(val expired_asks_again)" 1)
check "no cookies: no CSP violations ($(val csp_violations))" $(csp_clean)
else
skip "browser checks: node, playwright or its full Chromium missing"
fi
section "Scenario 3: the CLI through the proxy"
token="$("$BIN" auth token -unbound -url "$SITE/logs" -user viewer-01 -password-file "$AUTH/viewer-01.pass" \
-ca-file "$PKI/ca.crt" 2>>"$LOG/auth-cli.out")"
check "cli: logwisp auth token -unbound logged in through the proxy" $(is_set "$token")
check "cli: lw auth token -unbound logged in through the proxy" $(is_set "$token")
code="$(code_of "$SITE/logs/status" "$token")"
check "cli: /logs/status served with the token (HTTP $code)" $(is "$code" 200)
sse="$(timeout 3 curl -sN --noproxy '*' --cacert "$PKI/ca.crt" \
-H @<(printf 'Authorization: Bearer %s\n' "$token") "$SITE/logs/stream" 2>/dev/null || true)"
n=$(grep -c '^data:' <<< "$sse")
check "cli: /logs/stream delivered SSE events with the token ($n events)" $(( n >= 1 ))
n=$(grep -c '^data:' <<<"$sse")
check "cli: /logs/stream delivered SSE events with the token ($n events)" $((n >= 1))
code="$(code_of "$SITE/logs/status")"
check "cli: /logs/status refused without a token (HTTP $code)" $(is "$code" 401)
"$BIN" auth token -url "$SITE/logs" -user viewer-01 -password-file "$AUTH/viewer-01.pass" \
-ca-file "$PKI/ca.crt" >/dev/null 2>>"$LOG/auth-cli.out"
rc=$?
check "cli: a path without -unbound is a usage error (exit $rc)" $(( rc == 2 ))
check "cli: a path without -unbound is a usage error (exit $rc)" $((rc == 2))
echo "=== Scenario 3: around the proxy ==="
section "Scenario 4: around the proxy"
code="$(code_of "http://127.0.0.1:$PORT_SINK/auth/login")"
check "direct: the login page refuses a peer that is not the proxy (HTTP $code)" $(is "$code" 403)
code="$(code_of "http://127.0.0.1:$PORT_SINK/status" "$token")"
@@ -307,11 +382,4 @@ check "direct: a valid token does not open the sink around the proxy (HTTP $code
code="$(code_of "$SITE/plain/status" "$token")"
check "plaintext site: a request the proxy received over http is refused (HTTP $code)" $(is "$code" 403)
echo "================================================================"
if (( fail == 0 )); then
echo "RESULT: ALL PASS"
(( KEEP )) && { echo "--keep: daemons left running (pids: ${PIDS[*]})"; PIDS=(); }
else
echo "RESULT: FAILURES — inspect $LOG/*"
fi
exit "$fail"
summary