test: add rig, stress and pentest suites with audit
This commit is contained in:
parent
8330b65ff9
commit
1e782310b6
4 changed files with 679 additions and 0 deletions
239
tests/pentest.sh
Executable file
239
tests/pentest.sh
Executable file
|
|
@ -0,0 +1,239 @@
|
|||
#!/usr/bin/env bash
|
||||
# smolboid pentest: an adversarial pass against our own daemon. Every probe
|
||||
# asserts the hardened outcome; a probe that succeeds as an attack fails the
|
||||
# suite. Raw sockets via python3 for the HTTP surface, real fumi sends for
|
||||
# the mail surface.
|
||||
set -euo pipefail
|
||||
|
||||
ROOT="$(cd "$(dirname "$0")/.." && pwd)"
|
||||
FUMI="$ROOT/../fumi/target/debug/fumi"
|
||||
BUNSHIN_BIN="$ROOT/../bunshin/target/debug/bunshin"
|
||||
SMOLBOID="$ROOT/target/debug/smolboid"
|
||||
HOST=127.0.0.1
|
||||
SMTP_PORT=19631
|
||||
HTTP_PORT=18101
|
||||
RIG="$(mktemp -d /tmp/smolboid-pentest.XXXXXX)"
|
||||
DATA="$RIG/data"
|
||||
DAEMON_ADDR="twtxt@$HOST:$SMTP_PORT"
|
||||
FEED="$DATA/feeds/tobias.txt"
|
||||
BUNSHIN_PID=""
|
||||
SMOLBOID_PID=""
|
||||
cleanup() {
|
||||
[[ -n "$BUNSHIN_PID" ]] && kill "$BUNSHIN_PID" 2>/dev/null || true
|
||||
[[ -n "$SMOLBOID_PID" ]] && kill "$SMOLBOID_PID" 2>/dev/null || true
|
||||
}
|
||||
trap cleanup EXIT
|
||||
|
||||
step() { printf '\n== %s ==\n' "$*"; }
|
||||
fail() { echo "FAIL: $*" >&2; exit 1; }
|
||||
probe() { # name, expected-status-prefix, raw request bytes (python literal)
|
||||
local name=$1 want=$2 payload=$3
|
||||
local got
|
||||
got=$(python3 - "$HTTP_PORT" "$payload" <<'EOF'
|
||||
import socket, sys
|
||||
port, payload = int(sys.argv[1]), sys.argv[2].encode().decode("unicode_escape")
|
||||
s = socket.create_connection(("127.0.0.1", port), timeout=15)
|
||||
s.sendall(payload.encode())
|
||||
s.settimeout(15)
|
||||
# Accumulate until a full status line lands; a single recv may split.
|
||||
data = b""
|
||||
while b"\r\n" not in data:
|
||||
try:
|
||||
chunk = s.recv(4096)
|
||||
except socket.timeout:
|
||||
break
|
||||
if not chunk:
|
||||
break
|
||||
data += chunk
|
||||
if not data:
|
||||
data = b"<no response in 15s>"
|
||||
print(data.split(b"\r\n")[0].decode(errors="replace"))
|
||||
EOF
|
||||
)
|
||||
if [[ "$got" == "$want"* ]]; then
|
||||
echo "ok $name -> $got"
|
||||
else
|
||||
echo "got: $got"
|
||||
fail "probe $name: wanted $want*, got $got"
|
||||
fi
|
||||
}
|
||||
|
||||
step "build"
|
||||
cargo build --manifest-path "$ROOT/../bunshin/Cargo.toml" --quiet
|
||||
cargo build --manifest-path "$ROOT/../fumi/Cargo.toml" --quiet
|
||||
cargo build --manifest-path "$ROOT/Cargo.toml" --quiet
|
||||
|
||||
step "bootstrap: bunshin, tobias and a second identity mallory"
|
||||
mkdir -p "$DATA"
|
||||
"$BUNSHIN_BIN" keygen --key "$RIG/server.key" >"$RIG/keygen.log" 2>&1
|
||||
SERVER_KEY="$(grep -oE '[a-z2-7]{52}' "$RIG/keygen.log" | head -1)"
|
||||
"$BUNSHIN_BIN" serve --key "$RIG/server.key" --db "$RIG/server.db" \
|
||||
--host "$HOST" --port "$SMTP_PORT" >"$RIG/bunshin.log" 2>&1 &
|
||||
BUNSHIN_PID=$!
|
||||
sleep 1
|
||||
author() { "$FUMI" --key "$RIG/author.key" --db "$RIG/author.db" --timeout 10 "$@"; }
|
||||
mallory() { "$FUMI" --key "$RIG/mallory.key" --db "$RIG/mallory.db" --timeout 10 "$@"; }
|
||||
daemon_fumi() { "$FUMI" --key "$RIG/master.key" --db "$DATA/mail.db" --timeout 10 "$@"; }
|
||||
author keygen --force >/dev/null
|
||||
author trust "$HOST:$SMTP_PORT" "$SERVER_KEY"
|
||||
author register "tobias@$HOST:$SMTP_PORT" >/dev/null
|
||||
"$SMOLBOID" keygen "$RIG/master.key" >/dev/null
|
||||
daemon_fumi trust "$HOST:$SMTP_PORT" "$SERVER_KEY"
|
||||
daemon_fumi register "$DAEMON_ADDR" >/dev/null
|
||||
|
||||
step "admit tobias (never mallory)"
|
||||
cat >"$RIG/smolboid.toml" <<EOF
|
||||
listen = $HOST:$HTTP_PORT
|
||||
base_url = http://$HOST:$HTTP_PORT
|
||||
smolmail_host = $HOST
|
||||
mailbox = twtxt
|
||||
master = $RIG/master.key
|
||||
data = $DATA
|
||||
poll_secs = 1
|
||||
timeout = 10
|
||||
cap_bytes = 1048576
|
||||
posts_per_day = 50
|
||||
reposts_per_day = 50
|
||||
EOF
|
||||
"$SMOLBOID" admit --config "$RIG/smolboid.toml" "tobias@$HOST:$SMTP_PORT" >"$RIG/admit.log" 2>&1 \
|
||||
|| { cat "$RIG/admit.log"; fail "admit"; }
|
||||
author fetch >/dev/null
|
||||
"$SMOLBOID" serve --config "$RIG/smolboid.toml" >"$RIG/serve.log" 2>&1 &
|
||||
SMOLBOID_PID=$!
|
||||
sleep 2
|
||||
# A legitimate post so the feed has content to protect.
|
||||
author send "$DAEMON_ADDR" --body "real post" >/dev/null
|
||||
for ((i = 0; i < 20; i++)); do
|
||||
grep -q "real post" "$FEED" 2>/dev/null && break
|
||||
sleep 1
|
||||
done
|
||||
grep -q "real post" "$FEED" || fail "setup: the real post never published"
|
||||
|
||||
step "http: path traversal and path abuse"
|
||||
probe "traversal-etc-passwd" "HTTP/1.1 404" 'GET /twtxt/../../etc/passwd HTTP/1.1\r\n\r\n'
|
||||
probe "encoded-traversal" "HTTP/1.1 404" 'GET /twtxt/..%2F..%2Fetc%2Fpasswd HTTP/1.1\r\n\r\n'
|
||||
probe "dotdot-no-suffix" "HTTP/1.1 404" 'GET /twtxt/.. HTTP/1.1\r\n\r\n'
|
||||
probe "absolute-path" "HTTP/1.1 404" 'GET //etc/passwd HTTP/1.1\r\n\r\n'
|
||||
probe "uppercase-username" "HTTP/1.1 404" 'GET /twtxt/TOBIAS.txt HTTP/1.1\r\n\r\n'
|
||||
probe "unicode-username" "HTTP/1.1 404" 'GET /twtxt/t\xc3\xb6bias.txt HTTP/1.1\r\n\r\n'
|
||||
probe "oversized-username" "HTTP/1.1 404" "GET /twtxt/$(printf 'a%.0s' $(seq 1 200)).txt HTTP/1.1\r\n\r\n"
|
||||
probe "null-byte-username" "HTTP/1.1 404" 'GET /twtxt/tob\x00ias.txt HTTP/1.1\r\n\r\n'
|
||||
probe "dotfile-username" "HTTP/1.1 404" 'GET /twtxt/.ssh.txt HTTP/1.1\r\n\r\n'
|
||||
# The traversal probes must never return file content.
|
||||
curl -s "http://$HOST:$HTTP_PORT/twtxt/../../etc/passwd" | grep -q "root:" \
|
||||
&& fail "traversal returned /etc/passwd"
|
||||
|
||||
step "http: request parsing abuse"
|
||||
# Any 4xx is a hardened outcome; the server may read it as a method.
|
||||
probe "garbage-request" "HTTP/1.1 40" 'not http at all\r\n\r\n'
|
||||
probe "smuggled-second-request" "HTTP/1.1 200" 'GET /twtxt/tobias.txt HTTP/1.1\r\nX-Injected: 1\r\n\r\nGET /twtxt/tobias'
|
||||
probe "null-in-request" "HTTP/1.1 40" 'GE\x00T /twtxt/tobias.txt HTTP/1.1\r\n\r\n'
|
||||
probe "post-method" "HTTP/1.1 405" 'POST /twtxt/tobias.txt HTTP/1.1\r\nContent-Length: 0\r\n\r\n'
|
||||
probe "huge-request-line" "HTTP/1.1 40" "GET /$(printf 'a%.0s' $(seq 1 9000)) HTTP/1.1\r\n\r\n"
|
||||
probe "extra-headers-tolerated" "HTTP/1.1 200" 'GET /twtxt/tobias.txt HTTP/1.1\r\nHost: x\r\nEvil: 1\r\n\r\n'
|
||||
# No header value from the request is ever reflected into the response.
|
||||
curl -s -i "http://$HOST:$HTTP_PORT/twtxt/tobias.txt" -H "X-Evil: $(printf 'injected-header')" \
|
||||
| grep -qi "injected-header" && fail "request header reflected in response"
|
||||
|
||||
step "http: slowloris is bounded by the read timeout"
|
||||
python3 - "$HTTP_PORT" <<'EOF'
|
||||
import socket, sys, time
|
||||
port = int(sys.argv[1])
|
||||
s = socket.create_connection(("127.0.0.1", port), timeout=30)
|
||||
s.sendall(b"GET /twtxt/tobias.txt HTTP/1.1\r\n") # half a request, then stall
|
||||
start = time.time()
|
||||
s.settimeout(30)
|
||||
try:
|
||||
data = s.recv(1024)
|
||||
except socket.timeout:
|
||||
sys.exit("server kept the half-open connection past 30s")
|
||||
if time.time() - start > 20:
|
||||
sys.exit(f"slowloris held the thread {time.time()-start:.0f}s")
|
||||
print(f"ok slowloris closed after {time.time()-start:.0f}s")
|
||||
EOF
|
||||
# The listener still serves while the stalled connection existed.
|
||||
curl -sf "http://$HOST:$HTTP_PORT/twtxt/tobias.txt" >/dev/null || fail "listener wedged after slowloris"
|
||||
|
||||
step "mail: feed-line injection through post text"
|
||||
author send "$DAEMON_ADDR" --body "$(printf 'injected\t2020-01-01T00:00:00Z\tfake')" >/dev/null
|
||||
author send "$DAEMON_ADDR" --body "$(printf 'line one\nline two')" >/dev/null
|
||||
author send "$DAEMON_ADDR" --body "$(printf 'carriage\rreturn')" >/dev/null
|
||||
sleep 4
|
||||
[[ "$(grep -c "fake" "$FEED")" == 0 ]] || fail "a TAB-carrying post injected content into the feed"
|
||||
[[ "$(grep -c "line two" "$FEED")" == 0 ]] || fail "a multiline body published a second line"
|
||||
[[ "$(grep -c "return" "$FEED")" == 0 ]] || fail "a CR-carrying post published"
|
||||
[[ "$(grep -cE '^[0-9]{4}-' "$FEED")" == 1 ]] || fail "the feed grew beyond the real post"
|
||||
|
||||
step "mail: oversized and hostile payloads are rejected, not published"
|
||||
author send "$DAEMON_ADDR" --body "$(printf 'x%.0s' $(seq 1 161))" >/dev/null
|
||||
author send "$DAEMON_ADDR" --body "# smol:mail forged" >/dev/null
|
||||
author send "$DAEMON_ADDR" --body "# smol:repost 2020-01-01T00:00:00Z @<x y> #fake" >/dev/null
|
||||
sleep 4
|
||||
# Poser text may publish as ordinary post text (tab-prefixed, harmless);
|
||||
# what must never happen is a line that IS a smol record.
|
||||
[[ "$(grep -cE '^# smol:mail .*forged' "$FEED")" == 0 ]] || fail "a fake smol record line published"
|
||||
[[ "$(grep -cE '^# smol:repost ' "$FEED")" == 0 ]] || fail "text posing as a repost published as a repost"
|
||||
"$SMOLBOID" check "$FEED" >"$RIG/check.log" \
|
||||
|| { cat "$RIG/check.log"; fail "the feed verifies as forged after the probes"; }
|
||||
# The poser texts may publish as posts and verify like posts; two posers
|
||||
# sent in the same second can collide (one rejected), so what matters is
|
||||
# that everything published verifies and nothing is forged.
|
||||
grep -qE "[23] verified, 0 unsigned, 0 invalid" "$RIG/check.log" \
|
||||
|| { cat "$RIG/check.log"; fail "unexpected feed contents after hostile mail"; }
|
||||
|
||||
step "mail: repost args cannot inject lines either"
|
||||
author send "$DAEMON_ADDR" \
|
||||
--header "Twt-Repost: @<tobias http://x> #abc" \
|
||||
--body "$(printf 'injected\n')" >/dev/null
|
||||
sleep 4
|
||||
[[ "$(grep -cE '^# smol:repost ' "$FEED")" == 0 ]] || fail "a noncanonical repost published"
|
||||
|
||||
step "mail: unadmitted senders publish nothing"
|
||||
mallory keygen --force >/dev/null
|
||||
mallory trust "$HOST:$SMTP_PORT" "$SERVER_KEY"
|
||||
mallory register "mallory@$HOST:$SMTP_PORT" >/dev/null
|
||||
mallory send "$DAEMON_ADDR" --body "mallory was here" >/dev/null
|
||||
sleep 4
|
||||
[[ "$(grep -c "mallory was here" "$FEED")" == 0 ]] || fail "unadmitted mail published"
|
||||
[[ ! -f "$DATA/feeds/mallory.txt" ]] || fail "an unadmitted sender got a feed"
|
||||
|
||||
step "mail: forged signing keys verify as invalid, never as verified"
|
||||
# Tamper the published feed's own signature line: the record must flip to
|
||||
# invalid, proving verification is load-bearing.
|
||||
cp "$FEED" "$RIG/tampered.txt"
|
||||
python3 - "$RIG/tampered.txt" <<'EOF'
|
||||
import sys
|
||||
path = sys.argv[1]
|
||||
lines = open(path).read().split("\n")
|
||||
for i, line in enumerate(lines):
|
||||
if line.startswith("# smol:mail "):
|
||||
parts = line.split(" ")
|
||||
parts[3] = ("a" if parts[3][0] != "a" else "b") + parts[3][1:]
|
||||
lines[i] = " ".join(parts)
|
||||
break
|
||||
open(path, "w").write("\n".join(lines))
|
||||
EOF
|
||||
if "$SMOLBOID" check "$RIG/tampered.txt" >/dev/null 2>&1; then
|
||||
fail "a tampered signature still verified"
|
||||
fi
|
||||
|
||||
step "mail: reply-flood is capped at 10 per hour"
|
||||
for i in $(seq 1 15); do
|
||||
author send "$DAEMON_ADDR" --body "$(printf 'y%.0s' $(seq 1 $((200 + i))))" >/dev/null
|
||||
done
|
||||
sleep 5
|
||||
author fetch >/dev/null
|
||||
# Fifteen distinct oversized payloads; at most ten answers, the rest silent.
|
||||
# (Earlier probes may have drawn their own replies for other reasons.)
|
||||
author list --requests 2>/dev/null | grep -c "twtxt: more than 160" >"$RIG/replies.count" || true
|
||||
REPLIES=$(grep -oE '^[0-9]+' "$RIG/replies.count" | head -1 || echo 0)
|
||||
echo "flood answers: $REPLIES of 15"
|
||||
[[ "$REPLIES" -le 10 ]] || fail "reply flood drew $REPLIES answers, over the hourly cap"
|
||||
|
||||
step "http: still healthy after everything"
|
||||
curl -sf "http://$HOST:$HTTP_PORT/twtxt/tobias.txt" >/dev/null || fail "listener unhealthy at the end"
|
||||
"$SMOLBOID" check "$FEED" >/dev/null || fail "feed does not verify at the end"
|
||||
|
||||
step "pentest passed: every probe was repelled"
|
||||
cleanup
|
||||
trap - EXIT
|
||||
194
tests/rig.sh
Executable file
194
tests/rig.sh
Executable file
|
|
@ -0,0 +1,194 @@
|
|||
#!/usr/bin/env bash
|
||||
# smolboid integration rig (PLAN.md step 7): a local bunshin, the fumi CLI as
|
||||
# the author, and the daemon under test — covering post, command, repost,
|
||||
# timestamp collision, size-cap warning, both prune modes and an author
|
||||
# rotation. Real binaries, real Noise sessions, real envelopes.
|
||||
set -euo pipefail
|
||||
|
||||
ROOT="$(cd "$(dirname "$0")/.." && pwd)"
|
||||
FUMI_DIR="$ROOT/../fumi"
|
||||
BUNSHIN_DIR="$ROOT/../bunshin"
|
||||
RIG="$(mktemp -d /tmp/smolboid-rig.XXXXXX)"
|
||||
HOST=127.0.0.1
|
||||
SMTP_PORT=19611
|
||||
HTTP_PORT=18081
|
||||
DAEMON_ADDR="twtxt@$HOST:$SMTP_PORT"
|
||||
AUTHOR_ADDR="tobias@$HOST:$SMTP_PORT"
|
||||
DATA="$RIG/data"
|
||||
|
||||
BUNSHIN_PID=""
|
||||
SMOLBOID_PID=""
|
||||
cleanup() {
|
||||
[[ -n "$BUNSHIN_PID" ]] && kill "$BUNSHIN_PID" 2>/dev/null || true
|
||||
[[ -n "$SMOLBOID_PID" ]] && kill "$SMOLBOID_PID" 2>/dev/null || true
|
||||
}
|
||||
trap cleanup EXIT
|
||||
|
||||
step() { printf '\n== %s ==\n' "$*"; }
|
||||
fail() { echo "FAIL: $*" >&2; exit 1; }
|
||||
|
||||
wait_for() { # file, text, [tries]
|
||||
local file=$1 text=$2 tries=${3:-20} i
|
||||
for ((i = 0; i < tries; i++)); do
|
||||
[[ -f $file ]] && grep -q "$text" "$file" && return 0
|
||||
sleep 1
|
||||
done
|
||||
fail "timed out waiting for '$text' in $file"
|
||||
}
|
||||
|
||||
wait_gone() { # file, text, [tries]
|
||||
local file=$1 text=$2 tries=${3:-20} i
|
||||
for ((i = 0; i < tries; i++)); do
|
||||
grep -q "$text" "$file" || return 0
|
||||
sleep 1
|
||||
done
|
||||
fail "'$text' is still in $file"
|
||||
}
|
||||
|
||||
step "build bunshin, fumi, smolboid"
|
||||
cargo build --manifest-path "$BUNSHIN_DIR/Cargo.toml" --quiet
|
||||
cargo build --manifest-path "$FUMI_DIR/Cargo.toml" --quiet
|
||||
cargo build --manifest-path "$ROOT/Cargo.toml" --quiet
|
||||
BUNSHIN="$BUNSHIN_DIR/target/debug/bunshin"
|
||||
FUMI="$FUMI_DIR/target/debug/fumi"
|
||||
SMOLBOID="$ROOT/target/debug/smolboid"
|
||||
|
||||
step "bunshin on $HOST:$SMTP_PORT"
|
||||
"$BUNSHIN" keygen --key "$RIG/server.key" >"$RIG/keygen.log" 2>&1
|
||||
SERVER_KEY="$(grep -oE '[a-z2-7]{52}' "$RIG/keygen.log" | head -1)"
|
||||
[[ -n "$SERVER_KEY" ]] || { cat "$RIG/keygen.log"; fail "no server key"; }
|
||||
"$BUNSHIN" serve --key "$RIG/server.key" --db "$RIG/server.db" \
|
||||
--host "$HOST" --port "$SMTP_PORT" >"$RIG/bunshin.log" 2>&1 &
|
||||
BUNSHIN_PID=$!
|
||||
sleep 1
|
||||
|
||||
step "identities: author tobias, daemon twtxt"
|
||||
mkdir -p "$DATA"
|
||||
fumi() { "$FUMI" --key "$RIG/author.key" --db "$RIG/author.db" --timeout 10 "$@"; }
|
||||
dfumi() { "$FUMI" --key "$RIG/master.key" --db "$DATA/mail.db" --timeout 10 "$@"; }
|
||||
fumi keygen --force >/dev/null
|
||||
fumi trust "$HOST:$SMTP_PORT" "$SERVER_KEY"
|
||||
fumi register "$AUTHOR_ADDR" >/dev/null
|
||||
"$SMOLBOID" keygen "$RIG/master.key" >/dev/null
|
||||
dfumi trust "$HOST:$SMTP_PORT" "$SERVER_KEY"
|
||||
dfumi register "$DAEMON_ADDR" >/dev/null
|
||||
|
||||
step "admit tobias"
|
||||
cat >"$RIG/smolboid.toml" <<EOF
|
||||
listen = $HOST:$HTTP_PORT
|
||||
base_url = http://$HOST:$HTTP_PORT
|
||||
smolmail_host = $HOST
|
||||
mailbox = twtxt
|
||||
master = $RIG/master.key
|
||||
data = $DATA
|
||||
poll_secs = 1
|
||||
timeout = 10
|
||||
cap_bytes = 1048576
|
||||
EOF
|
||||
"$SMOLBOID" admit --config "$RIG/smolboid.toml" "$AUTHOR_ADDR" >"$RIG/admit.log" 2>&1 \
|
||||
|| { cat "$RIG/admit.log"; fail "admit"; }
|
||||
grep -q "welcome mail sent" "$RIG/admit.log" \
|
||||
|| { cat "$RIG/admit.log"; fail "no welcome mail"; }
|
||||
# The Accept token's proof is the next step: without it, posts stay in the
|
||||
# requests tier and nothing ever publishes.
|
||||
fumi fetch >/dev/null
|
||||
FEED="$DATA/feeds/tobias.txt"
|
||||
|
||||
step "serve"
|
||||
"$SMOLBOID" serve --config "$RIG/smolboid.toml" >"$RIG/serve.log" 2>&1 &
|
||||
SMOLBOID_PID=$!
|
||||
sleep 2
|
||||
|
||||
step "post"
|
||||
fumi send "$DAEMON_ADDR" --body "hello smolverse" >/dev/null
|
||||
wait_for "$FEED" "hello smolverse"
|
||||
"$SMOLBOID" check "$FEED" >"$RIG/check.log" || { cat "$RIG/check.log"; fail "check"; }
|
||||
grep -q "1 verified, 0 unsigned, 0 invalid" "$RIG/check.log" \
|
||||
|| { cat "$RIG/check.log"; fail "the first post did not verify"; }
|
||||
curl -sf "http://$HOST:$HTTP_PORT/twtxt/tobias.txt" | grep -q "hello smolverse" \
|
||||
|| fail "the feed is not served over HTTP"
|
||||
[[ "$(curl -s -o /dev/null -w '%{http_code}' "http://$HOST:$HTTP_PORT/twtxt/nobody.txt")" == 404 ]] \
|
||||
|| fail "a missing feed must 404"
|
||||
|
||||
step "commands: nick, repost"
|
||||
fumi send "$DAEMON_ADDR" --header "Twt-Nick: toby" --body "" >/dev/null
|
||||
wait_for "$FEED" "# nick = toby"
|
||||
fumi send "$DAEMON_ADDR" \
|
||||
--header "Twt-Repost: @<tobias http://$HOST:$HTTP_PORT/twtxt/tobias.txt> #abc1234" \
|
||||
--body "" >/dev/null
|
||||
wait_for "$FEED" "# smol:repost .* #abc1234"
|
||||
"$SMOLBOID" check "$FEED" >"$RIG/check.log" || { cat "$RIG/check.log"; fail "check"; }
|
||||
grep -q "2 verified, 0 unsigned, 0 invalid" "$RIG/check.log" \
|
||||
|| { cat "$RIG/check.log"; fail "the repost did not verify"; }
|
||||
|
||||
step "delete"
|
||||
TS="$(grep "hello smolverse" "$FEED" | cut -f1)"
|
||||
fumi send "$DAEMON_ADDR" --header "Twt-Delete: $TS" --body "" >/dev/null
|
||||
wait_gone "$FEED" "hello smolverse"
|
||||
|
||||
step "timestamp collision"
|
||||
collided=0
|
||||
for i in 1 2 3 4 5; do
|
||||
fumi send "$DAEMON_ADDR" --body "collide A $i" >/dev/null &
|
||||
A_PID=$!
|
||||
fumi send "$DAEMON_ADDR" --body "collide B $i" >/dev/null &
|
||||
B_PID=$!
|
||||
# Only the two sends, never the servers.
|
||||
wait "$A_PID" "$B_PID"
|
||||
sleep 3
|
||||
if grep -q "collide A $i" "$FEED" && grep -q "collide B $i" "$FEED"; then
|
||||
continue # different seconds; try again
|
||||
fi
|
||||
fumi fetch >/dev/null
|
||||
if fumi list --requests 2>/dev/null | grep -q "timestamp .* already exists"; then
|
||||
collided=1
|
||||
break
|
||||
fi
|
||||
done
|
||||
[[ "$collided" == 1 ]] || fail "no collision rejection arrived after 5 attempts"
|
||||
|
||||
step "retention: hard limit"
|
||||
kill "$SMOLBOID_PID" 2>/dev/null || true
|
||||
# Metadata (~190B) plus one record (~253B) must fit under the cap, and land
|
||||
# above its 90% mark so the warning fires.
|
||||
sed -i 's/^cap_bytes = .*/cap_bytes = 460/' "$RIG/smolboid.toml"
|
||||
"$SMOLBOID" serve --config "$RIG/smolboid.toml" >>"$RIG/serve.log" 2>&1 &
|
||||
SMOLBOID_PID=$!
|
||||
sleep 2
|
||||
fumi send "$DAEMON_ADDR" --body "over the cap" >/dev/null
|
||||
sleep 3
|
||||
grep -q "over the cap" "$FEED" && fail "a capped feed published a record"
|
||||
fumi fetch >/dev/null
|
||||
fumi list --requests 2>/dev/null | grep -q "feed is at its size cap" \
|
||||
|| fail "no cap rejection reply"
|
||||
|
||||
step "retention: fifo mode"
|
||||
fumi send "$DAEMON_ADDR" --header "Twt-Prune: fifo" --body "" >/dev/null
|
||||
wait_for "$DATA/state.json" '"prune": "Fifo"'
|
||||
fumi send "$DAEMON_ADDR" --body "fifo post" >/dev/null
|
||||
wait_for "$FEED" "fifo post"
|
||||
[[ "$(grep -cE '^[0-9]' "$FEED")" == 1 ]] || fail "fifo did not prune down to one record"
|
||||
fumi fetch >/dev/null
|
||||
fumi list --requests 2>/dev/null | grep -q "90% of its size cap" \
|
||||
|| fail "no 90% warning reply"
|
||||
"$SMOLBOID" check "$FEED" >"$RIG/check.log" || { cat "$RIG/check.log"; fail "check"; }
|
||||
grep -q "1 verified, 0 unsigned, 0 invalid" "$RIG/check.log" \
|
||||
|| { cat "$RIG/check.log"; fail "the pruned feed does not verify"; }
|
||||
|
||||
step "author rotation"
|
||||
fumi rotate >/dev/null
|
||||
fumi send "$DAEMON_ADDR" --body "after rotation" >/dev/null
|
||||
wait_for "$FEED" "after rotation"
|
||||
OWNERS="$(python3 -c "import json;print(len(json.load(open('$DATA/state.json'))['feeds'][0]['owner_keys']))")"
|
||||
[[ "$OWNERS" == 2 ]] || fail "expected 2 owner keys after rotation, got $OWNERS"
|
||||
"$SMOLBOID" check "$FEED" >"$RIG/check.log" || { cat "$RIG/check.log"; fail "check"; }
|
||||
# Under the fifo cap only one record fits, so the rotation post pruned the
|
||||
# fifo post; what remains must fully verify.
|
||||
grep -q "1 verified, 0 unsigned, 0 invalid" "$RIG/check.log" \
|
||||
|| { cat "$RIG/check.log"; fail "rotation-era records do not verify"; }
|
||||
|
||||
step "rig passed"
|
||||
echo "feeds: $FEED"
|
||||
echo "logs: $RIG"
|
||||
cleanup
|
||||
trap - EXIT
|
||||
177
tests/stress.sh
Executable file
177
tests/stress.sh
Executable file
|
|
@ -0,0 +1,177 @@
|
|||
#!/usr/bin/env bash
|
||||
# smolboid stress test: a mail flood against the daily quota and reply
|
||||
# budget, an HTTP load burst against a full feed, and feed-integrity checks
|
||||
# throughout. Real binaries, real Noise sessions.
|
||||
set -euo pipefail
|
||||
|
||||
ROOT="$(cd "$(dirname "$0")/.." && pwd)"
|
||||
FUMI="$ROOT/../fumi/target/debug/fumi"
|
||||
BUNSHIN_BIN="$ROOT/../bunshin/target/debug/bunshin"
|
||||
SMOLBOID="$ROOT/target/debug/smolboid"
|
||||
HOST=127.0.0.1
|
||||
SMTP_PORT=19621
|
||||
HTTP_PORT=18091
|
||||
RIG="$(mktemp -d /tmp/smolboid-stress.XXXXXX)"
|
||||
DATA="$RIG/data"
|
||||
DAEMON_ADDR="twtxt@$HOST:$SMTP_PORT"
|
||||
FEED="$DATA/feeds/tobias.txt"
|
||||
BUNSHIN_PID=""
|
||||
SMOLBOID_PID=""
|
||||
cleanup() {
|
||||
[[ -n "$BUNSHIN_PID" ]] && kill "$BUNSHIN_PID" 2>/dev/null || true
|
||||
[[ -n "$SMOLBOID_PID" ]] && kill "$SMOLBOID_PID" 2>/dev/null || true
|
||||
}
|
||||
trap cleanup EXIT
|
||||
|
||||
step() { printf '\n== %s ==\n' "$*"; }
|
||||
fail() { echo "FAIL: $*" >&2; exit 1; }
|
||||
wait_for() {
|
||||
local file=$1 text=$2 tries=${3:-40} i
|
||||
for ((i = 0; i < tries; i++)); do
|
||||
[[ -f $file ]] && grep -q "$text" "$file" && return 0
|
||||
sleep 1
|
||||
done
|
||||
fail "timed out waiting for '$text' in $file"
|
||||
}
|
||||
count_posts() { grep -cE '^[0-9]' "$FEED" 2>/dev/null || echo 0; }
|
||||
|
||||
step "build"
|
||||
cargo build --manifest-path "$ROOT/../bunshin/Cargo.toml" --quiet
|
||||
cargo build --manifest-path "$ROOT/../fumi/Cargo.toml" --quiet
|
||||
cargo build --manifest-path "$ROOT/Cargo.toml" --quiet
|
||||
|
||||
step "bootstrap: bunshin, author tobias, daemon twtxt"
|
||||
mkdir -p "$DATA"
|
||||
"$BUNSHIN_BIN" keygen --key "$RIG/server.key" >"$RIG/keygen.log" 2>&1
|
||||
SERVER_KEY="$(grep -oE '[a-z2-7]{52}' "$RIG/keygen.log" | head -1)"
|
||||
[[ -n "$SERVER_KEY" ]] || fail "no server key"
|
||||
# Raised server rate limits: the flood stresses smolboid's quota, not
|
||||
# bunshin's per-IP throttles — everything here shares 127.0.0.1, and the
|
||||
# daemon's one-second polling already AUTHs 60 times a minute.
|
||||
"$BUNSHIN_BIN" serve --key "$RIG/server.key" --db "$RIG/server.db" \
|
||||
--host "$HOST" --port "$SMTP_PORT" \
|
||||
--rate-connections 1000000 --rate-sends 1000000 --rate-tokens 1000000 \
|
||||
>"$RIG/bunshin.log" 2>&1 &
|
||||
BUNSHIN_PID=$!
|
||||
sleep 1
|
||||
author() { "$FUMI" --key "$RIG/author.key" --db "$RIG/author.db" --timeout 10 "$@"; }
|
||||
daemon_fumi() { "$FUMI" --key "$RIG/master.key" --db "$DATA/mail.db" --timeout 10 "$@"; }
|
||||
author keygen --force >/dev/null
|
||||
author trust "$HOST:$SMTP_PORT" "$SERVER_KEY"
|
||||
author register "tobias@$HOST:$SMTP_PORT" >/dev/null
|
||||
"$SMOLBOID" keygen "$RIG/master.key" >/dev/null
|
||||
daemon_fumi trust "$HOST:$SMTP_PORT" "$SERVER_KEY"
|
||||
daemon_fumi register "$DAEMON_ADDR" >/dev/null
|
||||
|
||||
step "admit"
|
||||
cat >"$RIG/smolboid.toml" <<EOF
|
||||
listen = $HOST:$HTTP_PORT
|
||||
base_url = http://$HOST:$HTTP_PORT
|
||||
smolmail_host = $HOST
|
||||
mailbox = twtxt
|
||||
master = $RIG/master.key
|
||||
data = $DATA
|
||||
poll_secs = 1
|
||||
timeout = 10
|
||||
cap_bytes = 1048576
|
||||
posts_per_day = 25
|
||||
reposts_per_day = 25
|
||||
EOF
|
||||
"$SMOLBOID" admit --config "$RIG/smolboid.toml" "tobias@$HOST:$SMTP_PORT" >"$RIG/admit.log" 2>&1 \
|
||||
|| { cat "$RIG/admit.log"; fail "admit"; }
|
||||
author fetch >/dev/null
|
||||
|
||||
step "serve"
|
||||
"$SMOLBOID" serve --config "$RIG/smolboid.toml" >"$RIG/serve.log" 2>&1 &
|
||||
SMOLBOID_PID=$!
|
||||
sleep 2
|
||||
|
||||
step "mail burst: 40 unpaced posts in a few seconds"
|
||||
START=$(date +%s)
|
||||
for i in $(seq 1 40); do
|
||||
author send "$DAEMON_ADDR" --body "flood post $i" >/dev/null
|
||||
done
|
||||
echo "40 sends in $(( $(date +%s) - START ))s"
|
||||
# Rapid fire means many payload seconds collide; what must hold is
|
||||
# integrity: nothing invalid, the quota never exceeded, the daemon alive.
|
||||
for ((i = 0; i < 40; i++)); do
|
||||
sleep 1
|
||||
[[ "$(count_posts)" -ge 1 ]] && break
|
||||
done
|
||||
[[ "$(count_posts)" -le 25 ]] || fail "quota exceeded under burst: $(count_posts) records"
|
||||
"$SMOLBOID" check "$FEED" >"$RIG/check.log" \
|
||||
|| { cat "$RIG/check.log"; fail "the burst left the feed unparseable"; }
|
||||
grep -qE "0 unsigned, 0 invalid" "$RIG/check.log" \
|
||||
|| { cat "$RIG/check.log"; fail "the burst published invalid records"; }
|
||||
curl -sf "http://$HOST:$HTTP_PORT/twtxt/tobias.txt" >/dev/null || fail "listener unhealthy after the burst"
|
||||
|
||||
step "quota precision: paced posts fill exactly to 25"
|
||||
# One send per second keeps timestamps distinct, so only the daily quota
|
||||
# can stop publication: the feed lands on exactly the configured cap.
|
||||
for i in $(seq 41 70); do
|
||||
author send "$DAEMON_ADDR" --body "flood post $i" >/dev/null
|
||||
sleep 1
|
||||
[[ "$(count_posts)" == 25 ]] && break
|
||||
done
|
||||
for ((i = 0; i < 20; i++)); do
|
||||
[[ "$(count_posts)" == 25 ]] && break
|
||||
sleep 1
|
||||
done
|
||||
[[ "$(count_posts)" == 25 ]] || fail "quota held at $(count_posts) records, want 25"
|
||||
"$SMOLBOID" check "$FEED" >"$RIG/check.log" \
|
||||
|| { cat "$RIG/check.log"; fail "the quota-cut feed does not verify"; }
|
||||
grep -q "25 verified, 0 unsigned, 0 invalid" "$RIG/check.log" \
|
||||
|| { cat "$RIG/check.log"; fail "not all 25 verify"; }
|
||||
author fetch >/dev/null
|
||||
|
||||
step "reply budget: the flood drew at most 10 replies"
|
||||
# The welcome mail also carries a twtxt: subject; only rejections count.
|
||||
author list --requests 2>/dev/null | grep "twtxt:" | grep -cv "feed is ready" \
|
||||
>"$RIG/replies.count" || true
|
||||
REPLIES=$(grep -oE '^[0-9]+' "$RIG/replies.count" | head -1)
|
||||
echo "error replies after the flood: $REPLIES"
|
||||
[[ "$REPLIES" -le 10 ]] || fail "reply budget exceeded: $REPLIES replies in an hour"
|
||||
|
||||
step "http load: 200 requests, 20 parallel"
|
||||
URL="http://$HOST:$HTTP_PORT/twtxt/tobias.txt"
|
||||
ETAG="$(curl -s -D - -o /dev/null "$URL" | tr -d '\r' | awk -F'"' '/^ETag:/ {print $2}')"
|
||||
[[ -n "$ETAG" ]] || fail "no etag"
|
||||
START=$(date +%s%N)
|
||||
seq 1 200 | xargs -P 20 -I{} curl -sf -o /dev/null "$URL" || fail "a load request failed"
|
||||
END=$(date +%s%N)
|
||||
MS=$(((END - START) / 1000000))
|
||||
echo "200 requests in ${MS}ms ($((200000 / MS)) req/s lower bound)"
|
||||
AFTER="$(curl -s -D - -o /dev/null "$URL" | tr -d '\r' | awk -F'"' '/^ETag:/ {print $2}')"
|
||||
[[ "$AFTER" == "$ETAG" ]] || fail "the etag moved under load without a write"
|
||||
|
||||
step "conditional load: 200 requests, 20 parallel, all 304"
|
||||
START=$(date +%s%N)
|
||||
seq 1 200 | xargs -P 20 -I{} \
|
||||
curl -sf -o /dev/null -w '%{http_code}\n' -H "If-None-Match: \"$ETAG\"" "$URL" \
|
||||
| sort | uniq -c >"$RIG/codes.log"
|
||||
END=$(date +%s%N)
|
||||
echo "200 conditional requests in $(((END - START) / 1000000))ms"
|
||||
grep -q "200 304" "$RIG/codes.log" || { cat "$RIG/codes.log"; fail "not all conditional requests were 304"; }
|
||||
|
||||
step "read-during-write: the feed stays parseable while the mail loop runs"
|
||||
author send "$DAEMON_ADDR" --header "Twt-Delete: $(grep -E '^[0-9]' "$FEED" | tail -1 | cut -f1)" --body "" >/dev/null
|
||||
BROKEN=0
|
||||
for i in $(seq 1 60); do
|
||||
curl -sf "$URL" >/dev/null || BROKEN=$((BROKEN + 1))
|
||||
# One per-loop read while the delete lands between polls.
|
||||
sleep 0.2
|
||||
done
|
||||
[[ "$BROKEN" == 0 ]] || fail "$BROKEN reads failed while the feed changed"
|
||||
for ((i = 0; i < 20; i++)); do
|
||||
[[ "$(count_posts)" == 24 ]] && break
|
||||
sleep 1
|
||||
done
|
||||
[[ "$(count_posts)" == 24 ]] || fail "delete did not land"
|
||||
"$SMOLBOID" check "$FEED" >/dev/null || fail "final feed does not verify"
|
||||
[[ "$(count_posts)" == 24 ]] || fail "expected 24 records after the delete, got $(count_posts)"
|
||||
|
||||
step "stress passed"
|
||||
echo "feed: $FEED"
|
||||
echo "logs: $RIG"
|
||||
cleanup
|
||||
trap - EXIT
|
||||
Loading…
Add table
Add a link
Reference in a new issue