--- /dev/null
+Load test bench
+===============
+
+Tools used to check the port to php 8.4 on debian 13. They exercise the daemon
+with many players connected and reproduce the corner cases of the transport
+(duplicate sessions, expired sessions, full queues).
+
+They are not part of the application: they are laboratory tools, meant to run
+on a test machine, never in production.
+
+
+What is needed first
+--------------------
+
+Test users in the database. The authenticated scripts use names of the form
+loadNNN with the password equal to the name:
+
+ insert into bsk_users (code, login, pass, email, type, tos_vers, guar_code)
+ select 20000+i, 'load'||to_char(i,'FM000'), md5('load'||to_char(i,'FM000')),
+ 'load'||to_char(i,'FM000')||'@example.invalid', 65536, '1.4', <guarantor>
+ from generate_series(1,400) i;
+
+where <guarantor> is the code of a user that already exists: the guar_code
+column has a foreign key constraint and the default value (-1) violates it.
+
+tos_vers has to be set to the current version, otherwise every login opens the
+dialog to accept the terms of service and pollutes the measurements.
+
+game.sh and hand.sh instead use the five users uno/due/tre/qua/cin with the
+passwords one/two/thr/for/fiv.
+
+The paths and ports are those of the test container (/home/brisk, 8444 for
+https): they have to be adapted elsewhere.
+
+
+The load
+--------
+
+brisk_load.py generator: N players that come in, keep the comet stream open
+ and write in the chat. A single process, asyncio, because the
+ generator has to be cheap: it runs on the same machine as the
+ daemon. It measures the write latency (p50/p95/p99), the
+ messages broadcast and how well the streams hold.
+ --clients N --base N --auth --silent --period S --port P
+
+brisk_step.sh one complete step: restarts the daemon so as to start from an
+ empty room, launches the generator and samples the resources.
+
+brisk_sample.sh sampler: cpu and memory of the daemon and of the frontend,
+ open descriptors, connections towards the sockets of the pool.
+
+ramp_probe.sh measures what a client receives while N others come into the
+ room: it weighs the cost of the protocol (every entry sends
+ everybody the complete list of the people present).
+
+
+A real game
+-----------
+
+game.sh [table] [port] five players come in, sit down, the table forms; they
+ stay connected.
+hand.sh [table] [port] plays the hand: auction, call and forty cards.
+
+sit4.sh four automatic players waiting for a fifth, human one
+play_human.sh drives the four while the fifth plays from the browser
+
+
+The corner cases of the transport
+---------------------------------
+
+wsprobe.py opens a real websocket and counts the frames: it tells a server
+ defect apart from the state of one browser tab.
+takeover.py two streams on the same session: the first one must be
+ dismissed, not closed in silence.
+ghostswap.py second access by the same user from another session: the first
+ one must go back to the login, where ghost_sess explains why.
+slowprobe.py slow client: it connects and stops reading while a burst
+ arrives, to see whether the message queue overflows.
+burst.py burst of simultaneous connections on the unix sockets: it
+ measures how many the backlog queues before refusing.
+
+
+At the end of a session
+-----------------------
+
+ ./stop.sh
+
+It shuts everything down and prints the state: running scripts, running curls,
+descriptors of the daemon, disk space, load. To be run ALWAYS.
+
+The scripts that start background loops carry a trap that kills their own
+process group. Without it the loops outlive their parent and are left spinning
+for nothing: that happened for two days, with eighty processes alive and the
+machine load at 19.
+
+stop.sh does not use "pkill -f": the pattern would end up in the command line
+of the script itself, which would kill itself and leave the targets alive.
+
+The working files (auth.txt, tok.txt, mani.txt, *.stream) are created in the
+working directory of the scripts and can grow large: the read streams grow all
+the time. stop.sh deletes them.
--- /dev/null
+#!/usr/bin/env python3
+"""Load generator for brisk.
+
+Simulates N players in the room: each one comes in as a guest, keeps the comet
+stream of index_rd.php open and now and then writes in the chat with
+index_wr.php.
+
+It measures the latency of the writes (which is what the player feels) and how
+well the read streams hold. A single process, asyncio: the generator has to be
+cheap, because it runs on the same machine as the daemon.
+"""
+
+import argparse
+import asyncio
+import hashlib
+import random
+import re
+import ssl
+import statistics
+import sys
+import time
+
+SESS_RE = re.compile(rb'sess = "([0-9a-f]+)"')
+
+
+class Stats:
+ def __init__(self):
+ self.write_lat = [] # write latencies, in ms
+ self.write_err = 0
+ self.login_err = 0
+ self.stream_bytes = 0
+ self.stream_deliv = 0 # chat messages delivered to the readers
+ self.stream_reconn = 0
+ self.stream_err = 0
+ self.http_err = {} # status code -> count
+
+ def http(self, code):
+ self.http_err[code] = self.http_err.get(code, 0) + 1
+
+
+class Client:
+ def __init__(self, idx, host, port, use_tls, prefix, stats, auth=False):
+ self.idx = idx
+ if auth:
+ self.name = "load%03d" % idx
+ self.passwd = self.name
+ else:
+ self.name = "Ospite%04d" % idx
+ self.passwd = None
+ self.host = host
+ self.port = port
+ self.use_tls = use_tls
+ self.prefix = prefix
+ self.st = stats
+ self.sess = None
+ self.step = 0
+
+ async def _open(self):
+ ctx = None
+ if self.use_tls:
+ ctx = ssl.create_default_context()
+ ctx.check_hostname = False
+ ctx.verify_mode = ssl.CERT_NONE
+ return await asyncio.open_connection(self.host, self.port, ssl=ctx)
+
+ async def _fetch(self, path, timeout=30):
+ """A request with Connection: close: the response ends with EOF, so the
+ latency is the time up to the last byte."""
+ r, w = await self._open()
+ try:
+ req = ("GET %s HTTP/1.1\r\nHost: %s\r\n"
+ "Connection: close\r\nUser-Agent: brisk-load\r\n" % (path, self.host))
+ if self.sess:
+ req += "Cookie: sess=%s\r\n" % self.sess
+ req += "\r\n"
+ w.write(req.encode())
+ await w.drain()
+ data = await asyncio.wait_for(r.read(), timeout)
+ return data
+ finally:
+ w.close()
+ try:
+ await w.wait_closed()
+ except Exception:
+ pass
+
+ async def login(self):
+ path = "%sindex.php?name=%s" % (self.prefix, self.name)
+ if self.passwd is not None:
+ # challenge login: the token has to be combined with the md5 of
+ # the password, exactly as j_login_manager() does in the real client
+ try:
+ chal = await self._fetch("%sindex_wr.php?mesg=getchallenge&cli_name=%s"
+ % (self.prefix, self.name))
+ tok = chal.rsplit(b"|", 1)[-1].strip().decode()
+ except Exception:
+ self.st.login_err += 1
+ return False
+ mp = hashlib.md5(self.passwd.encode()).hexdigest()
+ priv = hashlib.md5((tok + mp).encode()).hexdigest()
+ path += "&pass_private=" + priv
+ try:
+ body = await self._fetch(path)
+ except Exception:
+ self.st.login_err += 1
+ return False
+ m = SESS_RE.search(body)
+ if not m:
+ self.st.login_err += 1
+ code = body.split(b"\r\n", 1)[0].split(b" ")[1:2]
+ if code:
+ self.st.http(code[0].decode(errors="replace"))
+ return False
+ self.sess = m.group(1).decode()
+ return True
+
+ async def reader(self, stop):
+ """The comet stream: it reopens when the server closes it, as the real
+ client does."""
+ while not stop.is_set():
+ try:
+ r, w = await self._open()
+ except Exception:
+ self.st.stream_err += 1
+ await asyncio.sleep(1)
+ continue
+ try:
+ req = ("GET %sindex_rd.php?stat=&subst=&step=-1&from=index_php&transp=xhr"
+ " HTTP/1.1\r\nHost: %s\r\nCookie: sess=%s\r\n"
+ "User-Agent: brisk-load\r\n\r\n" % (self.prefix, self.host, self.sess))
+ w.write(req.encode())
+ await w.drain()
+ while not stop.is_set():
+ chunk = await asyncio.wait_for(r.read(65536), 120)
+ if not chunk:
+ break
+ self.st.stream_bytes += len(chunk)
+ self.st.stream_deliv += chunk.count(b"chatt_sub(")
+ except asyncio.TimeoutError:
+ self.st.stream_err += 1
+ except Exception:
+ self.st.stream_err += 1
+ finally:
+ w.close()
+ try:
+ await w.wait_closed()
+ except Exception:
+ pass
+ if not stop.is_set():
+ self.st.stream_reconn += 1
+ await asyncio.sleep(0.3)
+
+ async def writer(self, stop, period):
+ await asyncio.sleep(random.uniform(0, period))
+ while not stop.is_set():
+ mesg = "chatt%%7Ccarico%d" % random.randint(0, 99999)
+ path = "%sindex_wr.php?sess=%s&stp=%d&mesg=%s" % (
+ self.prefix, self.sess, self.step, mesg)
+ self.step += 1
+ t0 = time.monotonic()
+ try:
+ body = await self._fetch(path, timeout=30)
+ dt = (time.monotonic() - t0) * 1000.0
+ head = body.split(b"\r\n", 1)[0]
+ if b" 200" in head:
+ self.st.write_lat.append(dt)
+ else:
+ self.st.write_err += 1
+ self.st.http(head.decode(errors="replace")[:32])
+ except Exception:
+ self.st.write_err += 1
+ await asyncio.sleep(period * random.uniform(0.8, 1.2))
+
+
+async def run(args):
+ st = Stats()
+ stop = asyncio.Event()
+ clients = [Client(args.base + i, args.host, args.port, args.tls, args.prefix, st,
+ auth=args.auth)
+ for i in range(args.clients)]
+
+ # staggered entry: a wave of simultaneous logins would measure a transient,
+ # not the steady state
+ t0 = time.monotonic()
+ ok = 0
+ for i in range(0, len(clients), args.batch):
+ group = clients[i:i + args.batch]
+ res = await asyncio.gather(*[c.login() for c in group])
+ ok += sum(1 for r in res if r)
+ await asyncio.sleep(args.ramp)
+ print(" logins succeeded: %d/%d in %.1fs" % (ok, len(clients), time.monotonic() - t0),
+ flush=True)
+ if ok == 0:
+ return st, 0
+
+ live = [c for c in clients if c.sess]
+ tasks = []
+ for c in live:
+ tasks.append(asyncio.create_task(c.reader(stop)))
+ if not args.silent:
+ tasks.append(asyncio.create_task(c.writer(stop, args.period)))
+
+ await asyncio.sleep(args.warmup)
+ st.write_lat.clear() # the transient does not count
+ st.write_err = 0
+ base_bytes = st.stream_bytes
+ base_deliv = st.stream_deliv
+ t1 = time.monotonic()
+
+ await asyncio.sleep(args.duration)
+ dur = time.monotonic() - t1
+ st.stream_bytes -= base_bytes
+ st.stream_deliv -= base_deliv
+
+ stop.set()
+ for t in tasks:
+ t.cancel()
+ await asyncio.gather(*tasks, return_exceptions=True)
+ return st, dur
+
+
+def main():
+ p = argparse.ArgumentParser()
+ p.add_argument("--host", default="127.0.0.1")
+ p.add_argument("--port", type=int, default=8082)
+ p.add_argument("--tls", action="store_true")
+ p.add_argument("--prefix", default="/brisk/")
+ p.add_argument("--clients", type=int, default=25)
+ p.add_argument("--base", type=int, default=1)
+ p.add_argument("--batch", type=int, default=10)
+ p.add_argument("--ramp", type=float, default=0.5)
+ p.add_argument("--warmup", type=float, default=20)
+ p.add_argument("--duration", type=float, default=60)
+ p.add_argument("--period", type=float, default=10)
+ p.add_argument("--auth", action="store_true", help="registered users (load001...)")
+ p.add_argument("--silent", action="store_true", help="streams only, no writes")
+ args = p.parse_args()
+
+ st, dur = asyncio.run(run(args))
+ lat = sorted(st.write_lat)
+ def pct(q):
+ if not lat:
+ return float("nan")
+ return lat[min(len(lat) - 1, int(len(lat) * q))]
+
+ print(" writes: %d in %.0fs (%.1f/s)" % (len(lat), dur, len(lat) / dur if dur else 0))
+ if lat:
+ print(" latency ms: p50 %.1f p95 %.1f p99 %.1f max %.1f mean %.1f" % (
+ pct(.50), pct(.95), pct(.99), lat[-1], statistics.fmean(lat)))
+ print(" errors: writes %d, logins %d, streams %d" % (
+ st.write_err, st.login_err, st.stream_err))
+ print(" streams: %.0f KB read (%.0f KB/s), %d messages delivered (%.0f/s), %d reopenings" % (
+ st.stream_bytes / 1024.0, st.stream_bytes / 1024.0 / dur if dur else 0,
+ st.stream_deliv, st.stream_deliv / dur if dur else 0, st.stream_reconn))
+ if st.http_err:
+ print(" unexpected responses: %s" % st.http_err)
+ sys.stdout.flush()
+
+
+if __name__ == "__main__":
+ main()
--- /dev/null
+#!/bin/bash
+# Samples the resources of daemon and frontend during a load test.
+# usage: brisk_sample.sh <seconds> <interval> <label>
+DUR="${1:-60}"; INT="${2:-5}"; TAG="${3:-x}"
+
+dpid="$(pgrep -f 'php \./brisk-spush\.php' | head -1)"
+if [ -z "$dpid" ]; then echo "daemon not found"; exit 1; fi
+
+# cumulative cpu of a set of processes, in seconds
+cpusec() { local tot=0 t; for p in $*; do
+ t=$(awk '{print ($14+$15)/'"$(getconf CLK_TCK)"'}' /proc/$p/stat 2>/dev/null || echo 0)
+ tot=$(echo "$tot $t" | awk '{print $1+$2}'); done; echo "$tot"; }
+
+# the frontend: nginx (direct mode) or apache (descriptor handover)
+fpids() { { pgrep -f 'nginx: worker'; pgrep -x apache2; } | tr '\n' ' '; }
+
+d0=$(cpusec $dpid); f0=$(cpusec $(fpids)); t0=$(date +%s.%N)
+maxrss=0; maxfd=0; maxconn=0; n=0
+end=$(( $(date +%s) + DUR ))
+while [ $(date +%s) -lt $end ]; do
+ sleep "$INT"
+ rss=$(awk '/VmRSS/{print $2}' /proc/$dpid/status 2>/dev/null || echo 0)
+ fd=$(ls /proc/$dpid/fd 2>/dev/null | wc -l)
+ conn=$(ss -x 2>/dev/null | grep -c 'brisk[0-9]*\.sock')
+ [ "$rss" -gt "$maxrss" ] && maxrss=$rss
+ [ "$fd" -gt "$maxfd" ] && maxfd=$fd
+ [ "$conn" -gt "$maxconn" ] && maxconn=$conn
+ n=$((n+1))
+done
+d1=$(cpusec $dpid); f1=$(cpusec $(fpids)); t1=$(date +%s.%N)
+
+echo "$d0 $d1 $f0 $f1 $t0 $t1 $maxrss $maxfd $maxconn" | awk '{
+ el = $6 - $5;
+ printf " daemon: cpu %.0f%% of one core, rss %.0f MB, %d open fds\n", ($2-$1)/el*100, $7/1024, $8;
+ printf " frontend: cpu %.0f%% of one core (every process)\n", ($4-$3)/el*100;
+ printf " unix connections towards the daemon (max): %d\n", $9;
+}'
--- /dev/null
+#!/bin/bash
+# One step of the load test: restarts the daemon so as to start from an empty
+# room, launches the generator and samples the resources while it runs.
+#
+# usage: brisk_step.sh <clients> <duration> <write_period> [--silent] [--tls]
+# Subprocess loops outlive their parent: without this they are left orphaned,
+# spinning for nothing (it happened: eighty loops alive for two days). kill 0
+# kills the process group, which setsid makes exclusive to this script.
+trap "kill 0" EXIT INT TERM
+
+N="${1:-50}"; DUR="${2:-120}"; PER="${3:-10}"; shift 3
+EXTRA="$*"
+WARM=30
+PORT="${PORT:-8082}"
+echo "$EXTRA" | grep -q -- "--tls" && PORT=8444
+
+# empty room: the users of the previous step would stay connected
+p=$(pgrep -f 'php \./brisk-spush\.php'); [ -n "$p" ] && kill $p; sleep 2
+s=$(pgrep -u www-data -x screen); [ -n "$s" ] && kill $s 2>/dev/null; sleep 1
+rm -f /tmp/brisk.log
+su -s /bin/bash www-data -c "cd /home/brisk/web/brisk/spush && screen -d -m -S brisk -L -Logfile /tmp/brisk.log ./brisk-spush.php"
+sleep 4
+
+echo "=== $N clients, $DUR s, one write every $PER s $EXTRA ==="
+( sleep $((WARM + 3)); /root/load/brisk_sample.sh "$DUR" 5 "$N" ) > /tmp/sample.out 2>&1 &
+SAMP=$!
+cd /root/load
+timeout $((WARM + DUR + 120)) python3 brisk_load.py --auth --clients "$N" \
+ --warmup "$WARM" --duration "$DUR" --period "$PER" --port "$PORT" $EXTRA
+wait $SAMP
+cat /tmp/sample.out
+echo " daemon log: $(wc -l < /tmp/brisk.log) lines, of which unexpected: $(tr -d '#\r' < /tmp/brisk.log | grep -icE 'warning|notice|error|fatal')"
+echo " nginx errors: $(grep -cE ' \[(error|crit|alert)\] ' /var/log/nginx/error.log 2>/dev/null || echo 0)"
--- /dev/null
+#!/usr/bin/env python3
+"""Burst of simultaneous connections on the unix sockets of the daemon.
+
+It measures how many the listen queue refuses (EAGAIN) when they all arrive
+together: the case nginx meets when many players come in at the same instant.
+"""
+import socket
+import sys
+
+pfx = sys.argv[1] if len(sys.argv) > 1 else "/home/brisk/priv/brisk"
+npool = int(sys.argv[2]) if len(sys.argv) > 2 else 10
+n = int(sys.argv[3]) if len(sys.argv) > 3 else 200
+only0 = len(sys.argv) > 4 and sys.argv[4] == "single"
+
+ok = eagain = other = 0
+keep = []
+for i in range(n):
+ idx = 0 if only0 else i % npool
+ s = socket.socket(socket.AF_UNIX, socket.SOCK_STREAM)
+ s.setblocking(False)
+ try:
+ s.connect("%s%d.sock" % (pfx, idx))
+ ok += 1
+ keep.append(s)
+ except BlockingIOError:
+ # a non blocking connect on a unix socket either succeeds at once or
+ # fails: EAGAIN here means the listen queue is full
+ eagain += 1
+ s.close()
+ except OSError as e:
+ if e.errno == 11:
+ eagain += 1
+ else:
+ other += 1
+ s.close()
+
+print(" out of %d simultaneous connections: %d accepted, %d refused (queue full), %d other errors"
+ % (n, ok, eagain, other))
+for s in keep:
+ s.close()
--- /dev/null
+#!/bin/bash
+# Five automatic players come in, sit down and form the table.
+# Then they stay connected: the hand is played by hand.sh.
+#
+# usage: game.sh [table] [port]
+#
+# It lives in /root/load and not in /tmp, which is tmpfs: a restart of the
+# container used to wipe the scripts.
+
+# Subprocess loops outlive their parent: without this they are left orphaned,
+# spinning for nothing. kill 0 kills the group, which setsid makes exclusive.
+trap "kill 0" EXIT INT TERM
+
+TAB="${1:-4}"; PORTA="${2:-8444}"
+B="https://127.0.0.1:${PORTA}/brisk"
+CURL="curl -sSk"
+cd /root/load
+rm -f auth.txt r?.stream k?.stream mani.txt tok.txt
+
+echo "== 1. login of five authenticated users =="
+for pair in "uno one" "due two" "tre thr" "qua for" "cin fiv"; do
+ set -- $pair; U=$1; P=$2
+ TOK=$($CURL -m 15 "$B/index_wr.php?mesg=getchallenge&cli_name=$U" | cut -d'|' -f2)
+ MP=$(printf '%s' "$P" | md5sum | cut -d' ' -f1)
+ PRIV=$(printf '%s%s' "$TOK" "$MP" | md5sum | cut -d' ' -f1)
+ S=$($CURL -m 20 "$B/index.php?name=$U&pass_private=$PRIV" \
+ | grep -oE 'sess = "[0-9a-f]+"' | head -1 | sed 's/.*"\([0-9a-f]*\)".*/\1/')
+ echo "$U $S" >> auth.txt
+ printf " %-4s %s\n" "$U" "${S:-FALLITO}"
+done
+
+echo "== 2. room, and sitting down at table $TAB =="
+i=0
+while read U S; do
+ i=$((i+1))
+ ( while true; do
+ $CURL -N -m 90 -b "sess=$S" \
+ "$B/index_rd.php?stat=&subst=&step=-1&from=index_php&transp=xhr" >> r$i.stream 2>/dev/null
+ sleep 0.3
+ done ) &
+ sleep 1
+done < auth.txt
+sleep 3
+while read U S; do
+ $CURL -m 20 -o /dev/null -b "sess=$S" "$B/index_wr.php?sess=$S&stp=0&mesg=sitdown%7C$TAB"
+ sleep 1
+done < auth.txt
+sleep 4
+
+TK=$(grep -ohE 'createCookie\("table_token", "[0-9a-f]+"' r?.stream | head -1 | grep -oE '[0-9a-f]{10,}')
+echo " table_token: ${TK:-TABLE NOT FORMED}"
+echo "$TK" > tok.txt
+[ -z "$TK" ] && exit 1
+
+echo "== 3. game table =="
+i=0
+while read U S; do
+ i=$((i+1))
+ $CURL -m 20 -o /dev/null -b "sess=$S; table_idx=$TAB; table_token=$TK; lang=it" \
+ "$B/briskin5/index.php"
+ ( while true; do
+ $CURL -N -m 100 -b "sess=$S; table_idx=$TAB; table_token=$TK; lang=it" \
+ "$B/briskin5/index_rd.php?stat=&subst=&step=-1&from=table_php&transp=xhr" >> k$i.stream 2>/dev/null
+ sleep 0.3
+ done ) &
+ sleep 1
+done < auth.txt
+sleep 6
+
+for i in 1 2 3 4 5; do
+ U=$(sed -n "${i}p" auth.txt | awk '{print $1}')
+ P=$(grep -oE "card_send\([0-9]+,[0-9]+,[0-9]+" k$i.stream | head -1 | sed 's/card_send(\([0-9]*\),.*/\1/')
+ H=$(grep -oE "card_send\($P,[0-9]+,[0-9]+" k$i.stream | sed 's/.*,//' | sort -n -u | tr '\n' ' ')
+ echo "$i $U $P $H" >> mani.txt
+ printf " %-4s pos %s (%d cards)\n" "$U" "$P" "$(echo $H | wc -w)"
+done
+echo " ready: now hand.sh plays the hand"
+wait
--- /dev/null
+#!/usr/bin/env python3
+"""Access by the same user from a second browser (different sessions).
+
+The first stream must be sent back to the login, where ghost_sess explains
+that the session was assigned to another browser.
+"""
+import hashlib, re, socket, ssl, sys, time
+HOST, PORT, PFX = "127.0.0.1", 8444, "/brisk/"
+USER = sys.argv[1] if len(sys.argv) > 1 else "load310"
+
+def conn():
+ ctx = ssl.create_default_context(); ctx.check_hostname=False; ctx.verify_mode=ssl.CERT_NONE
+ return ctx.wrap_socket(socket.create_connection((HOST, PORT), 10), server_hostname=HOST)
+
+def get(path, cookie=None):
+ c = conn()
+ req = "GET %s HTTP/1.1\r\nHost: %s\r\nConnection: close\r\n" % (path, HOST)
+ if cookie: req += "Cookie: sess=%s\r\n" % cookie
+ c.sendall((req + "\r\n").encode())
+ buf = b""
+ while True:
+ d = c.recv(65536)
+ if not d: break
+ buf += d
+ c.close(); return buf
+
+def login():
+ chal = get("%sindex_wr.php?mesg=getchallenge&cli_name=%s" % (PFX, USER))
+ tok = chal.rsplit(b"|", 1)[-1].strip().decode()
+ priv = hashlib.md5((tok + hashlib.md5(USER.encode()).hexdigest()).encode()).hexdigest()
+ m = re.search(rb'sess = "([0-9a-f]+)"', get("%sindex.php?name=%s&pass_private=%s" % (PFX, USER, priv)))
+ return m.group(1).decode() if m else None
+
+def stream(sess):
+ c = conn()
+ c.sendall(("GET %sindex_rd.php?sess=%s&stat=&subst=&step=-1&from=index_php&transp=xhr HTTP/1.1\r\n"
+ "Host: %s\r\nCookie: sess=%s\r\n\r\n" % (PFX, sess, HOST, sess)).encode())
+ return c
+
+a = login(); print(" first access: %s" % a)
+sa = stream(a); sa.settimeout(5)
+t0 = time.monotonic(); iniz = b""
+while time.monotonic() - t0 < 3:
+ try: d = sa.recv(65536)
+ except socket.timeout: break
+ if not d: break
+ iniz += d
+print(" first stream open (%d bytes)" % len(iniz))
+
+b = login(); print(" second access: %s (session %s)" % (b, "different" if b != a else "THE SAME"))
+sb = stream(b)
+time.sleep(1)
+
+sa.settimeout(8); cong = b""
+t0 = time.monotonic()
+while time.monotonic() - t0 < 6:
+ try: d = sa.recv(65536)
+ except socket.timeout: break
+ if not d: break
+ cong += d
+print(" the first stream received %d bytes" % len(cong))
+print(" stops the stream: %s" % (b"xstm.stop" in cong))
+print(" goes back to login: %s" % (b"location.assign" in cong))
+print(" only shows a notice: %s" % (b"new notify" in cong))
+sa.close(); sb.close()
--- /dev/null
+#!/bin/bash
+# Plays a whole hand at the table formed by game.sh: auction, call and forty
+# cards.
+#
+# usage: hand.sh [table] [port]
+
+TAB="${1:-4}"; PORTA="${2:-8444}"
+B="https://127.0.0.1:${PORTA}/brisk"
+CURL="curl -sSk"
+cd /root/load
+TK=$(cat tok.txt)
+
+send() {
+ local T=$1 M=$2
+ local S=$(sed -n "${T}p" auth.txt | awk '{print $2}')
+ $CURL -m 15 -o /dev/null -b "sess=$S; table_idx=$TAB; table_token=$TK; lang=it" \
+ "$B/briskin5/index_wr.php?sess=$S&stp=0&mesg=$(printf '%s' "$M" | sed 's/|/%7C/g')"
+}
+
+# index of the player whose turn it is, 0 if nobody
+turn() {
+ for i in 1 2 3 4 5; do
+ [ "$(grep -oE 'remark_(on|off)' k$i.stream 2>/dev/null | tail -1)" = "remark_on" ] && { echo $i; return; }
+ done
+ echo 0
+}
+# an observer other than the player whose turn it is: the play is recognised
+# from its stream, because the one playing gets remark_off and not card_play
+obs() { [ "$1" = "1" ] && echo 2 || echo 1; }
+
+echo "== auction =="
+BID=0
+for r in $(seq 1 10); do
+ T=$(turn); [ "$T" = "0" ] && { sleep 2; T=$(turn); }; [ "$T" = "0" ] && break
+ U=$(sed -n "${T}p" auth.txt | awk '{print $1}')
+ if [ "$BID" = "0" ]; then send $T "asta|0|0"; BID=$T; echo " $U calls"
+ else send $T "asta|-1|0"; echo " $U passes"; fi
+ sleep 2
+ grep -q "choose_seed" k$BID.stream && { echo " $(sed -n "${BID}p" auth.txt | awk '{print $1}') wins"; break; }
+done
+
+HB=$(awk -v n=$BID 'NR==n {for(j=4;j<=NF;j++) printf "%s ", $j}' mani.txt)
+for a in 0 10 20 30; do echo " $HB " | grep -q " $a " || { CALL=$a; break; }; done
+echo " calls card $CALL"
+send $BID "choose|$CALL"
+sleep 3
+
+echo "== play =="
+declare -A HAND
+for i in 1 2 3 4 5; do HAND[$i]=$(awk -v n=$i 'NR==n {for(j=4;j<=NF;j++) printf "%s ", $j}' mani.txt); done
+played=0
+for n in $(seq 1 70); do
+ [ $played -ge 40 ] && break
+ T=$(turn); [ "$T" = "0" ] && { sleep 2; T=$(turn); }
+ [ "$T" = "0" ] && { echo " no active turn"; break; }
+ U=$(sed -n "${T}p" auth.txt | awk '{print $1}'); O=$(obs $T); OK=0
+ for C in ${HAND[$T]}; do
+ SZ=$(stat -c%s k$O.stream)
+ # the coordinates are those of the playing area: fixed ones would
+ # land every card on the same spot
+ send $T "play|$C|$((330 + RANDOM % 90))|$((260 + RANDOM % 60))"
+ sleep 1
+ if tail -c +$((SZ+1)) k$O.stream | grep -q "card_play("; then
+ HAND[$T]=$(echo ${HAND[$T]} | tr ' ' '\n' | grep -v "^$C$" | tr '\n' ' ')
+ played=$((played+1)); OK=1
+ printf " %2d. %-4s -> %2d\n" $played "$U" "$C"
+ break
+ fi
+ done
+ [ $OK -eq 0 ] && { echo " !! $U stuck"; break; }
+done
+echo " === cards played: $played ==="
--- /dev/null
+#!/bin/bash
+# Drives the four bots while the fifth seat is taken by a person.
+#
+# The bots never call: they pass the auction and leave the call to the human.
+# When it is their turn they first try to pass; if after a second the turn is
+# still theirs it means the auction is over, and then they play the first card
+# the server accepts. That way there is no need to know which phase we are in.
+B="https://127.0.0.1:8444/brisk"; TAB="${1:-4}"; cd /tmp
+CURL="curl -sSk"
+MAXWAIT="${2:-900}"
+
+echo "waiting for the table to form..."
+for n in $(seq 1 $MAXWAIT); do
+ [ -s tok.txt ] && [ -s mani.txt ] && break
+ sleep 1
+done
+TK=$(cat tok.txt 2>/dev/null)
+if [ -z "$TK" ]; then echo "table not formed, leaving"; exit 1; fi
+echo "table $TAB, token $TK"
+cat mani.txt
+
+send() { local T=$1 M=$2
+ local S=$(sed -n "${T}p" auth.txt | awk '{print $2}')
+ $CURL -m 15 -o /dev/null -b "sess=$S; table_idx=$TAB; table_token=$TK; lang=it" \
+ "$B/briskin5/index_wr.php?sess=$S&stp=0&mesg=$(printf '%s' "$M" | sed 's/|/%7C/g')"
+}
+
+# indice del bot a cui tocca, 0 se tocca all'umano o a nessuno
+turn() {
+ for i in 1 2 3 4; do
+ [ "$(grep -oE 'remark_(on|off)' k$i.stream 2>/dev/null | tail -1)" = "remark_on" ] && { echo $i; return; }
+ done
+ echo 0
+}
+# an observer other than the player whose turn it is
+obs() { [ "$1" = "1" ] && echo 2 || echo 1; }
+
+declare -A HAND
+for i in 1 2 3 4; do
+ HAND[$i]=$(awk -v n=$i 'NR==n {for(j=4;j<=NF;j++) printf "%s ", $j}' mani.txt)
+done
+
+played=0; idle=0
+echo "=== playing: the bots pass the auction, the call is up to the human ==="
+while [ $played -lt 40 ] && [ $idle -lt 300 ]; do
+ T=$(turn)
+ if [ "$T" = "0" ]; then
+ idle=$((idle + 1)); sleep 1; continue
+ fi
+ idle=0
+ U=$(sed -n "${T}p" auth.txt | awk '{print $1}')
+
+ # first guess: we are in the auction, the bot passes
+ SZ=$(stat -c%s k$T.stream)
+ send $T "asta|-1|0"
+ sleep 1
+ if [ "$(turn)" != "$T" ]; then
+ echo " $U passes"
+ continue
+ fi
+
+ # the turn did not move: we are playing
+ O=$(obs $T); OK=0
+ for C in ${HAND[$T]}; do
+ SZ=$(stat -c%s k$O.stream)
+ send $T "play|$C|$((330 + RANDOM % 90))|$((260 + RANDOM % 60))"
+ sleep 1
+ if tail -c +$((SZ+1)) k$O.stream | grep -q "card_play("; then
+ HAND[$T]=$(echo ${HAND[$T]} | tr ' ' '\n' | grep -v "^$C$" | tr '\n' ' ')
+ played=$((played+1)); OK=1
+ printf " %2d. %-8s plays %2d\n" $played "$U" "$C"
+ break
+ fi
+ done
+ [ $OK -eq 0 ] && { echo " !! $U cannot play, waiting"; sleep 2; }
+done
+echo "=== cards played by bots and human: $played (out of 40) ==="
--- /dev/null
+#!/bin/bash
+# Measures what a client receives while 150 users come into the room.
+# Subprocess loops outlive their parent: without this they are left orphaned,
+# spinning for nothing (it happened: eighty loops alive for two days). kill 0
+# kills the process group, which setsid makes exclusive to this script.
+trap "kill 0" EXIT INT TERM
+
+B="http://127.0.0.1:8082/brisk"
+TOK=$(curl -sS -m 10 "$B/index_wr.php?mesg=getchallenge&cli_name=load303" | cut -d"|" -f2)
+MP=$(printf "%s" "load303" | md5sum | cut -d" " -f1)
+PRIV=$(printf "%s%s" "$TOK" "$MP" | md5sum | cut -d" " -f1)
+S=$(curl -sS -m 20 "$B/index.php?name=load303&pass_private=$PRIV" | grep -oE "sess = \"[0-9a-f]+\"" | head -1 | sed "s/.*\"\(.*\)\"/\1/")
+( curl -sS -N -m 60 -b "sess=$S" "$B/index_rd.php?stat=&subst=&step=-1&from=index_php&transp=xhr" > /tmp/ramp.stream 2>/dev/null ) &
+sleep 3
+cd /root/load
+timeout 100 python3 brisk_load.py --auth --clients 150 --base 101 --silent --warmup 5 --duration 35 --port 8082 > /tmp/load5.log 2>&1
+wait
--- /dev/null
+#!/bin/bash
+# Four automatic players sit at the table and wait for the fifth: a human
+# being with a real browser. They stay connected and keep their streams open,
+# so the table forms as soon as the human sits down.
+# Subprocess loops outlive their parent: without this they are left orphaned,
+# spinning for nothing (it happened: eighty loops alive for two days). kill 0
+# kills the process group, which setsid makes exclusive to this script.
+trap "kill 0" EXIT INT TERM
+
+B="https://127.0.0.1:8444/brisk"; TAB="${1:-4}"; cd /tmp
+CURL="curl -sSk"
+rm -f auth.txt r?.stream k?.stream mani.txt tok.txt
+
+for u in load001 load002 load003 load004; do
+ TOK=$($CURL -m 15 "$B/index_wr.php?mesg=getchallenge&cli_name=$u" | cut -d'|' -f2)
+ MP=$(printf '%s' "$u" | md5sum | cut -d' ' -f1)
+ PRIV=$(printf '%s%s' "$TOK" "$MP" | md5sum | cut -d' ' -f1)
+ S=$($CURL -m 20 "$B/index.php?name=$u&pass_private=$PRIV" | grep -oE 'sess = "[0-9a-f]+"' | head -1 | sed 's/.*"\([0-9a-f]*\)".*/\1/')
+ echo "$u $S" >> auth.txt
+ printf " %s %s\n" "$u" "${S:-FAILED}"
+done
+
+i=0; while read U S; do i=$((i+1))
+ ( while true; do $CURL -N -m 90 -b "sess=$S" \
+ "$B/index_rd.php?stat=&subst=&step=-1&from=index_php&transp=xhr" >> r$i.stream 2>/dev/null
+ sleep 0.3; done ) &
+ sleep 0.5; done < auth.txt
+sleep 3
+
+while read U S; do
+ $CURL -m 20 -o /dev/null -b "sess=$S" "$B/index_wr.php?sess=$S&stp=0&mesg=sitdown%7C$TAB"
+ sleep 1
+done < auth.txt
+echo " four seated at table $TAB, waiting for the fifth"
+
+for n in $(seq 1 900); do
+ TK=$(grep -ohE 'createCookie\("table_token", "[0-9a-f]+"' r?.stream | head -1 | grep -oE '[0-9a-f]{10,}')
+ [ -n "$TK" ] && break
+ sleep 1
+done
+if [ -z "$TK" ]; then echo " table not formed within 15 minutes"; exit 1; fi
+echo "$TK" > tok.txt
+echo " table formed: $TK"
+
+i=0; while read U S; do i=$((i+1))
+ $CURL -m 20 -o /dev/null -b "sess=$S; table_idx=$TAB; table_token=$TK; lang=it" "$B/briskin5/index.php"
+ ( while true; do $CURL -N -m 100 -b "sess=$S; table_idx=$TAB; table_token=$TK; lang=it" \
+ "$B/briskin5/index_rd.php?stat=&subst=&step=-1&from=table_php&transp=xhr" >> k$i.stream 2>/dev/null
+ sleep 0.3; done ) &
+ sleep 0.7; done < auth.txt
+sleep 6
+
+for i in 1 2 3 4; do
+ U=$(sed -n "${i}p" auth.txt | awk '{print $1}')
+ P=$(grep -oE "card_send\([0-9]+,[0-9]+,[0-9]+" k$i.stream | head -1 | sed 's/card_send(\([0-9]*\),.*/\1/')
+ H=$(grep -oE "card_send\($P,[0-9]+,[0-9]+" k$i.stream | sed 's/.*,//' | sort -n -u | tr '\n' ' ')
+ echo "$i $U $P $H" >> mani.txt
+ printf " %s in position %s, %d cards\n" "$U" "$P" "$(echo $H | wc -w)"
+done
+echo " ready"
+wait
--- /dev/null
+#!/usr/bin/env python3
+"""Slow client: it connects, stops reading while a burst happens in the room,
+then resumes.
+
+If in the meantime the server queued more than COMM_N messages, the history is
+lost and the client is sent back to the initial page (splash included). That is
+recognised from the markers of the full initialisation, prefs_load() and
+notify_ex(), which in normal operation arrive only once.
+"""
+import hashlib
+import re
+import socket
+import ssl
+import sys
+import time
+
+HOST = "127.0.0.1"
+PORT = 8444
+USER = sys.argv[1] if len(sys.argv) > 1 else "load370"
+PAUSA = float(sys.argv[2]) if len(sys.argv) > 2 else 45.0
+PFX = "/brisk/"
+
+
+def conn():
+ s = socket.create_connection((HOST, PORT), 10)
+ ctx = ssl.create_default_context()
+ ctx.check_hostname = False
+ ctx.verify_mode = ssl.CERT_NONE
+ return ctx.wrap_socket(s, server_hostname=HOST)
+
+
+def get(path, cookie=None):
+ c = conn()
+ req = "GET %s HTTP/1.1\r\nHost: %s\r\nConnection: close\r\n" % (path, HOST)
+ if cookie:
+ req += "Cookie: sess=%s\r\n" % cookie
+ c.sendall((req + "\r\n").encode())
+ buf = b""
+ while True:
+ d = c.recv(65536)
+ if not d:
+ break
+ buf += d
+ c.close()
+ return buf
+
+
+chal = get("%sindex_wr.php?mesg=getchallenge&cli_name=%s" % (PFX, USER))
+tok = chal.rsplit(b"|", 1)[-1].strip().decode()
+mp = hashlib.md5(USER.encode()).hexdigest()
+priv = hashlib.md5((tok + mp).encode()).hexdigest()
+body = get("%sindex.php?name=%s&pass_private=%s" % (PFX, USER, priv))
+m = re.search(rb'sess = "([0-9a-f]+)"', body)
+if not m:
+ print("login fallito")
+ sys.exit(1)
+sess = m.group(1).decode()
+print(" session: %s" % sess)
+
+c = conn()
+req = ("GET %sindex_rd.php?stat=&subst=&step=-1&from=index_php&transp=xhr"
+ " HTTP/1.1\r\nHost: %s\r\nCookie: sess=%s\r\n\r\n" % (PFX, HOST, sess))
+c.sendall(req.encode())
+
+# phase 1: read the initialisation
+c.settimeout(8)
+iniz = b""
+t0 = time.monotonic()
+while time.monotonic() - t0 < 6:
+ try:
+ d = c.recv(65536)
+ except socket.timeout:
+ break
+ if not d:
+ break
+ iniz += d
+print(" initialisation: %d bytes, prefs_load: %d" % (
+ len(iniz), iniz.count(b"prefs_load(")))
+
+# phase 2: stop reading; the burst happens now
+print(" not reading for %.0f seconds..." % PAUSA)
+sys.stdout.flush()
+time.sleep(PAUSA)
+
+# phase 3: resume
+c.settimeout(15)
+dopo = b""
+t0 = time.monotonic()
+while time.monotonic() - t0 < 12:
+ try:
+ d = c.recv(65536)
+ except socket.timeout:
+ break
+ if not d:
+ break
+ dopo += d
+c.close()
+
+reinit = dopo.count(b"prefs_load(")
+print(" resumed: %d bytes, room updates: %d, reinitialisations: %d"
+ % (len(dopo), dopo.count(b"j_stand_cont("), reinit))
+print(" OUTCOME: %s" % ("HISTORY LOST, sent back to the initial page" if reinit
+ else "history preserved"))
--- /dev/null
+#!/bin/bash
+# Shuts the whole test bench down and shows the final state.
+# To be run ALWAYS at the end of a test session.
+#
+# It does not use "pkill -f": the pattern would end up in the command line of
+# this very script, which would kill itself and leave the targets alive. That
+# happened several times, and it is how eighty loops survived for two days.
+
+MIO=$$
+PAT="game\\.sh|hand\\.sh|game_pkg|game_https|game_t5|game_t9|hand_pkg|hand_https|sit4|play_human"
+PAT="$PAT|join4|gjoin|ramp_probe|brisk_load|slowprobe|wsprobe|takeover"
+PAT="$PAT|ghostswap|burst"
+
+pidlist() {
+ ps ax -o pid=,args= | awk -v mio="$MIO" '$1 != mio { print }' \
+ | grep -E "$PAT" | grep -v grep | awk '{ print $1 }'
+}
+
+echo "test scripts found: $(pidlist | grep -c .)"
+for p in $(pidlist); do kill "$p" 2>/dev/null; done
+sleep 3
+for p in $(pidlist); do kill -9 "$p" 2>/dev/null; done
+for p in $(pgrep -x curl); do kill -9 "$p" 2>/dev/null; done
+sleep 2
+
+# The capture files grow without bound: two days of runaway loops had filled
+# the disk, which is the same one as the host's.
+rm -f /tmp/*.stream /tmp/*.html 2>/dev/null
+
+d="$(pgrep -f 'php \./brisk-spush\.php' | head -1)"
+echo
+echo "=== final state ==="
+printf " test scripts running: %s\n" "$(pidlist | grep -c .)"
+printf " curl running: %s\n" "$(pgrep -x curl | wc -l)"
+printf " daemon: %s process, %s descriptors (10 are the listening sockets)\n" \
+ "$(pgrep -f 'php \./brisk-spush\.php' | wc -l)" "$(ls /proc/$d/fd 2>/dev/null | wc -l)"
+printf " disk space: %s\n" \
+ "$(df -h / | awk 'NR==2 { print $4 " free (" $5 " used)" }')"
+printf " daemon log: %s\n" "$(du -sh /tmp/brisk.log 2>/dev/null | cut -f1)"
+printf " load: %s\n" "$(uptime | sed 's/.*average: //')"
--- /dev/null
+#!/usr/bin/env python3
+"""Two streams on the same session: the first one must be dismissed.
+
+It opens a stream, opens a second one with the same session and looks at what
+the first receives before being closed. Before the fix it received only the
+closing of the socket, and the client on the other side reopened forever.
+"""
+import base64
+import hashlib
+import os
+import re
+import socket
+import ssl
+import sys
+import time
+
+HOST, PORT, PFX = "127.0.0.1", 8444, "/brisk/"
+USER = sys.argv[1] if len(sys.argv) > 1 else "load350"
+TRANSP = sys.argv[2] if len(sys.argv) > 2 else "xhr"
+
+
+def conn():
+ ctx = ssl.create_default_context()
+ ctx.check_hostname = False
+ ctx.verify_mode = ssl.CERT_NONE
+ return ctx.wrap_socket(socket.create_connection((HOST, PORT), 10), server_hostname=HOST)
+
+
+def get(path, cookie=None):
+ c = conn()
+ req = "GET %s HTTP/1.1\r\nHost: %s\r\nConnection: close\r\n" % (path, HOST)
+ if cookie:
+ req += "Cookie: sess=%s\r\n" % cookie
+ c.sendall((req + "\r\n").encode())
+ buf = b""
+ while True:
+ d = c.recv(65536)
+ if not d:
+ break
+ buf += d
+ c.close()
+ return buf
+
+
+chal = get("%sindex_wr.php?mesg=getchallenge&cli_name=%s" % (PFX, USER))
+tok = chal.rsplit(b"|", 1)[-1].strip().decode()
+priv = hashlib.md5((tok + hashlib.md5(USER.encode()).hexdigest()).encode()).hexdigest()
+m = re.search(rb'sess = "([0-9a-f]+)"',
+ get("%sindex.php?name=%s&pass_private=%s" % (PFX, USER, priv)))
+sess = m.group(1).decode()
+print(" session: %s, transport of the first stream: %s" % (sess, TRANSP))
+
+
+def apri(transp):
+ c = conn()
+ if transp.startswith("websocket"):
+ key = base64.b64encode(os.urandom(16)).decode()
+ req = ("GET %sindex_rd_wss.php?sess=%s&stat=&subst=&step=-1&from=index_php&transp=%s"
+ " HTTP/1.1\r\nHost: %s\r\nUpgrade: websocket\r\nConnection: Upgrade\r\n"
+ "Sec-WebSocket-Key: %s\r\nSec-WebSocket-Version: 13\r\nCookie: sess=%s\r\n\r\n"
+ % (PFX, sess, transp, HOST, key, sess))
+ else:
+ req = ("GET %sindex_rd.php?sess=%s&stat=&subst=&step=-1&from=index_php&transp=%s"
+ " HTTP/1.1\r\nHost: %s\r\nCookie: sess=%s\r\n\r\n"
+ % (PFX, sess, transp, HOST, sess))
+ c.sendall(req.encode())
+ return c
+
+
+primo = apri(TRANSP)
+primo.settimeout(6)
+iniz = b""
+t0 = time.monotonic()
+while time.monotonic() - t0 < 4:
+ try:
+ d = primo.recv(65536)
+ except socket.timeout:
+ break
+ if not d:
+ break
+ iniz += d
+print(" first stream open: %d bytes of initialisation" % len(iniz))
+
+secondo = apri("xhr")
+print(" second stream opened on the same session")
+
+primo.settimeout(10)
+congedo = b""
+chiuso = False
+t0 = time.monotonic()
+while time.monotonic() - t0 < 8:
+ try:
+ d = primo.recv(65536)
+ except socket.timeout:
+ break
+ if not d:
+ chiuso = True
+ break
+ congedo += d
+
+print(" the first stream received %d bytes, then %s"
+ % (len(congedo), "closed by the server" if chiuso else "no close"))
+print(" stops the stream (xstm.stop): %s" % (b"xstm.stop" in congedo))
+print(" shows the notice (new notify): %s" % (b"new notify" in congedo))
+print(" sends back to the login: %s" % (b"location.assign" in congedo))
+if TRANSP.startswith("websocket") and congedo:
+ print(" first byte 0x%02x (0x81 = text frame)" % congedo[0])
+primo.close()
+secondo.close()
--- /dev/null
+#!/usr/bin/env python3
+"""Minimal websocket client to exercise the brisk channel.
+
+It does a challenge login, asks for the upgrade on index_rd_wss.php and reads
+frames for a few seconds, reporting how many arrive and when the connection is
+closed. It tells a server defect apart from the state of one browser tab.
+"""
+import base64
+import hashlib
+import os
+import socket
+import ssl
+import sys
+import time
+import urllib.parse
+
+HOST = sys.argv[1] if len(sys.argv) > 1 else "127.0.0.1"
+PORT = int(sys.argv[2]) if len(sys.argv) > 2 else 8444
+USER = sys.argv[3] if len(sys.argv) > 3 else "load390"
+PASSWD = sys.argv[4] if len(sys.argv) > 4 else None
+SECS = float(sys.argv[5]) if len(sys.argv) > 5 else 20.0
+PFX = "/brisk/"
+if PASSWD is None:
+ PASSWD = USER
+
+
+def conn():
+ s = socket.create_connection((HOST, PORT), 10)
+ ctx = ssl.create_default_context()
+ ctx.check_hostname = False
+ ctx.verify_mode = ssl.CERT_NONE
+ return ctx.wrap_socket(s, server_hostname=HOST)
+
+
+def get(path, cookie=None):
+ c = conn()
+ req = "GET %s HTTP/1.1\r\nHost: %s\r\nConnection: close\r\n" % (path, HOST)
+ if cookie:
+ req += "Cookie: sess=%s\r\n" % cookie
+ req += "\r\n"
+ c.sendall(req.encode())
+ buf = b""
+ while True:
+ try:
+ d = c.recv(65536)
+ except Exception:
+ break
+ if not d:
+ break
+ buf += d
+ c.close()
+ return buf
+
+
+def login():
+ chal = get("%sindex_wr.php?mesg=getchallenge&cli_name=%s" % (PFX, USER))
+ tok = chal.rsplit(b"|", 1)[-1].strip().decode()
+ mp = hashlib.md5(PASSWD.encode()).hexdigest()
+ priv = hashlib.md5((tok + mp).encode()).hexdigest()
+ body = get("%sindex.php?name=%s&pass_private=%s" % (PFX, USER, priv))
+ import re
+ m = re.search(rb'sess = "([0-9a-f]+)"', body)
+ return m.group(1).decode() if m else None
+
+
+def ws(sess):
+ key = base64.b64encode(os.urandom(16)).decode()
+ c = conn()
+ path = ("%sindex_rd_wss.php?sess=%s&stat=&subst=&step=-1&from=index_php"
+ "&transp=websocketsec" % (PFX, sess))
+ req = ("GET %s HTTP/1.1\r\nHost: %s\r\nUpgrade: websocket\r\n"
+ "Connection: Upgrade\r\nSec-WebSocket-Key: %s\r\n"
+ "Sec-WebSocket-Version: 13\r\nCookie: sess=%s\r\n\r\n"
+ % (path, HOST, key, sess))
+ c.sendall(req.encode())
+ c.settimeout(SECS)
+ head = b""
+ while b"\r\n\r\n" not in head:
+ d = c.recv(4096)
+ if not d:
+ print(" connection closed during the handshake")
+ return
+ head += d
+ status = head.split(b"\r\n", 1)[0].decode(errors="replace")
+ print(" answer to the upgrade: %s" % status)
+ rest = head.split(b"\r\n\r\n", 1)[1]
+ if b"101" not in status.encode():
+ print(" body received: %d bytes (not a websocket)" % len(rest))
+ return
+
+ nbytes = len(rest)
+ nframes = 0
+ t0 = time.monotonic()
+ while time.monotonic() - t0 < SECS:
+ try:
+ d = c.recv(65536)
+ except socket.timeout:
+ break
+ except Exception as e:
+ print(" read error: %s" % e)
+ break
+ if not d:
+ print(" closed by the server after %.1f s and %d bytes" % (
+ time.monotonic() - t0, nbytes))
+ return
+ nbytes += len(d)
+ nframes += d.count(b"\x81") + d.count(b"\x82")
+ print(" stayed open %.1f s, %d bytes received, ~%d frames" % (
+ time.monotonic() - t0, nbytes, nframes))
+ c.close()
+
+
+s = login()
+print("session: %s" % s)
+if s:
+ ws(s)