diff --git a/server.py b/server.py index a50ab05..478c9e7 100644 --- a/server.py +++ b/server.py @@ -15,6 +15,7 @@ import socketserver import struct import sys import threading +import time import webbrowser from http.server import SimpleHTTPRequestHandler, ThreadingHTTPServer @@ -26,6 +27,24 @@ SESSIONS = {} # session id -> set of live WSHandler LOCK = threading.Lock() _SERVERS = [] TOKENS = None # None = open relay; a set = hosts must present a valid access code +STATS = None # path to a JSONL file; None = no metrics at all + + +def anon(ip): + """Daily-salted hash: counts uniques per day without ever storing an IP.""" + day = time.strftime('%Y-%m-%d') + return hashlib.sha1((day + '|' + (ip or '?')).encode()).hexdigest()[:10] + + +def log_stat(event, **fields): + if not STATS: + return + fields.update(t=int(time.time()), e=event) + try: + with open(STATS, 'a', encoding='utf-8') as f: + f.write(json.dumps(fields) + '\n') + except OSError: + pass def local_ip(): @@ -60,6 +79,15 @@ class WSHandler(socketserver.BaseRequestHandler): def setup(self): self.session_id = None self.send_lock = threading.Lock() + self.hdrs = {} + self.role = None + self.joined_at = None + + def _client_ip(self): + xff = self.hdrs.get(b'x-forwarded-for') + if xff: + return xff.decode('utf-8', 'replace').split(',')[0].strip() + return self.client_address[0] def handle(self): if not self._handshake(): @@ -77,6 +105,9 @@ class WSHandler(socketserver.BaseRequestHandler): if self.session_id: with LOCK: SESSIONS.get(self.session_id, set()).discard(self) + if self.joined_at: + log_stat('session', role=self.role or '?', + secs=int(time.time() - self.joined_at)) # ---- relay protocol (same JSON messages as the old websockets server) ---- @@ -101,9 +132,12 @@ class WSHandler(socketserver.BaseRequestHandler): self._send_json({'type': 'join_denied', 'reason': 'access code required'}) return self.session_id = sid + self.role = data.get('role') or '?' + self.joined_at = time.time() with LOCK: SESSIONS.setdefault(self.session_id, set()).add(self) self._send_json({'type': 'joined', 'sessionId': self.session_id}) + log_stat('join', role=self.role, u=anon(self._client_ip())) elif t in ('state', 'patientViewport'): if TOKENS is not None and not self.session_id: return # hardened relay only forwards for joined connections @@ -142,6 +176,7 @@ class WSHandler(socketserver.BaseRequestHandler): if b':' in line: k, v = line.split(b':', 1) headers[k.strip().lower()] = v.strip() + self.hdrs = headers key = headers.get(b'sec-websocket-key') if not key: return False @@ -229,6 +264,13 @@ class _QuietHTTP(SimpleHTTPRequestHandler): def log_message(self, *args): pass + def log_request(self, code='-', size='-'): + path = self.path.split('?')[0].split('#')[0] + if path.endswith('/') or path.endswith('.html'): + ip = (self.headers.get('X-Forwarded-For') or + self.client_address[0]).split(',')[0].strip() + log_stat('page', p=path, u=anon(ip)) + def _argval(flag, default): args = sys.argv[1:] @@ -245,6 +287,10 @@ def main(): with open(tokens_file, encoding='utf-8') as f: TOKENS = {l.strip() for l in f if l.strip() and not l.startswith('#')} print('relay hardened: %d access codes loaded' % len(TOKENS)) + global STATS + STATS = _argval('--stats', None) # JSONL metrics: pages, joins, session lengths + if STATS: + print('metrics -> %s (anonymous: daily-salted hashes, no IPs stored)' % STATS) socketserver.ThreadingTCPServer.allow_reuse_address = True ThreadingHTTPServer.allow_reuse_address = True diff --git a/tools/stats_report.py b/tools/stats_report.py new file mode 100644 index 0000000..f0553f9 --- /dev/null +++ b/tools/stats_report.py @@ -0,0 +1,44 @@ +"""Traffic report from server.py --stats JSONL. + + python3 tools/stats_report.py /path/to/stats.jsonl [days] +""" +import json +import sys +import time +from collections import defaultdict + +path = sys.argv[1] if len(sys.argv) > 1 else 'stats.jsonl' +days_back = int(sys.argv[2]) if len(sys.argv) > 2 else 14 +cutoff = time.time() - days_back * 86400 + +daily = defaultdict(lambda: {'views': 0, 'uniq': set(), 'hosts': 0, 'patients': 0}) +sessions = [] +for line in open(path, encoding='utf-8'): + try: + d = json.loads(line) + except ValueError: + continue + if d.get('t', 0) < cutoff: + continue + day = time.strftime('%Y-%m-%d', time.localtime(d['t'])) + row = daily[day] + if d['e'] == 'page': + row['views'] += 1 + row['uniq'].add(d.get('u')) + elif d['e'] == 'join': + row['hosts' if d.get('role') == 'admin' else 'patients'] += 1 + elif d['e'] == 'session' and d.get('role') == 'admin': + sessions.append(d.get('secs', 0)) + +print(f"{'day':12} {'views':>6} {'uniques':>8} {'hosts':>6} {'patients':>9}") +for day in sorted(daily): + r = daily[day] + print(f"{day:12} {r['views']:>6} {len(r['uniq']):>8} {r['hosts']:>6} {r['patients']:>9}") + +if sessions: + sessions.sort() + med = sessions[len(sessions) // 2] + print(f"\n{len(sessions)} hosted relay sessions · median {med // 60}m{med % 60:02d}s " + f"· longest {max(sessions) // 60}m") +else: + print('\nno completed relay sessions in range')