- A room opened with Play now never loads /join: the API's answer to POST /api/rooms is counted as rooms opened here. - Link previews (Slack, Discord, Facebook, iMessage, WhatsApp and the rest) are reported as where the link was shared, since the people who click them arrive with no referrer. WhatsApp's fetcher is a bot now. - An address that asks for a scanner's paths, or keeps coming back without opening a game, is a bot's all day; so is a browser that loads pages at a steady interval from a new address each time, never playing. A declared bot is still judged request by request. - The site's own addresses, sslip.io included, are not referrers. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01Jm2auWk6RP71CjaAb4FMoG
154 lines
6.6 KiB
Python
154 lines
6.6 KiB
Python
"""Caddy's access logs for one UTC day: the hall's visitors, people and
|
|
bots apart, and the people each game on this host received from the hall.
|
|
The digest and the nightly rollup both count through here, so they agree."""
|
|
|
|
import datetime
|
|
import glob
|
|
import gzip
|
|
import json
|
|
import os
|
|
import re
|
|
from collections import Counter
|
|
|
|
# The bot, link-preview and scanner patterns are kept in step with template/deploy/traffic.py.
|
|
BOT = re.compile(r"bot|crawl|spider|slurp|facebookexternalhit|slack|discord|twitter|whatsapp|telegram|preview|fetch|curl|python|go-http|wget|headless|scan|monitor|uptime", re.I)
|
|
# Link previews: the app that fetched the page to show it in a conversation. The people
|
|
# who click such a link arrive with no referrer, so this is the best sign of where it went.
|
|
UNFURL = [
|
|
("Slack", re.compile(r"slack", re.I)),
|
|
("Discord", re.compile(r"discord", re.I)),
|
|
("iMessage", re.compile(r"facebookexternalhit.*twitterbot|applebot.*preview", re.I)),
|
|
("Facebook", re.compile(r"facebookexternalhit|facebot|meta-externalagent", re.I)),
|
|
("WhatsApp", re.compile(r"whatsapp", re.I)),
|
|
("Telegram", re.compile(r"telegram", re.I)),
|
|
("Reddit", re.compile(r"redditbot", re.I)),
|
|
("LinkedIn", re.compile(r"linkedinbot", re.I)),
|
|
("Mastodon", re.compile(r"mastodon", re.I)),
|
|
("Bluesky", re.compile(r"bluesky|cardyb", re.I)),
|
|
("X", re.compile(r"twitterbot", re.I)),
|
|
]
|
|
# Paths nobody asks the hall for but a vulnerability scanner: its address is a bot's all day.
|
|
SCANNER = re.compile(r"\.php$|(^|/)(\.env|\.git|\.aws|wp-admin|wp-login|wp-content|wp-includes|wordpress|phpmyadmin|cgi-bin|vendor|actuator|boaform|owa|admin)(/|$)", re.I)
|
|
# Requests from one address more than this far apart are separate visits.
|
|
VISIT_GAP = 30 * 60
|
|
SITE = "gamehall.kestrelsnest.social"
|
|
LOGS = "/var/lib/caddy/gamehall-access*.log*"
|
|
# The games that share this host, by the name the hall links to. A visitor
|
|
# who follows a card arrives with the hall as referrer (the hall sends its
|
|
# origin and nothing more). Wiz-War keeps its own host and its own count.
|
|
GAMES = {"dip": "dip.kestrelsnest.social", "waving-hands": "hands.kestrelsnest.social", "hnefatafl": "tafl.kestrelsnest.social"}
|
|
ASSET = (".png", ".jpg", ".svg", ".ico", ".txt", ".webmanifest")
|
|
|
|
|
|
def unfurled_by(ua):
|
|
"""The app a link preview came from, or None."""
|
|
for name, pattern in UNFURL:
|
|
if pattern.search(ua):
|
|
return name
|
|
return None
|
|
|
|
|
|
def _visits(times):
|
|
n, last = 0, None
|
|
for ts in sorted(times):
|
|
if last is None or ts - last > VISIT_GAP:
|
|
n += 1
|
|
last = ts
|
|
return n
|
|
|
|
|
|
def _lines(pattern, t0):
|
|
for f in sorted(glob.glob(pattern)):
|
|
if os.path.getmtime(f) < t0:
|
|
continue
|
|
opener = gzip.open if f.endswith(".gz") else open
|
|
try:
|
|
with opener(f, "rt", errors="ignore") as fh:
|
|
for line in fh:
|
|
try:
|
|
yield json.loads(line)
|
|
except ValueError:
|
|
continue
|
|
except OSError:
|
|
continue
|
|
|
|
|
|
def _bounds(day):
|
|
d0 = datetime.datetime.fromisoformat(day).replace(tzinfo=datetime.timezone.utc)
|
|
return d0.timestamp(), (d0 + datetime.timedelta(days=1)).timestamp()
|
|
|
|
|
|
def scan(day, since=None):
|
|
"""Counts for one UTC day (YYYY-MM-DD), or from `since` (a timestamp)
|
|
to the end of it: page views and addresses of people and of bots, where
|
|
links to the hall were shared, referrers, campaign tags (once per
|
|
address), and the people each game here received from the hall.
|
|
|
|
A request is a bot's when its browser says so. An address is a bot's for
|
|
the day when it asks for a scanner's paths, or loads the hall three
|
|
visits or more without ever following a card to a game: a monitor."""
|
|
t0, t1 = _bounds(day)
|
|
if since is not None:
|
|
t0 = max(t0, since)
|
|
t = dict(views=0, addresses=set(), botAddresses=set(), shared=Counter(), scanners=0, monitors=0, referrers=Counter(), campaigns=Counter(), sent={})
|
|
followed = set()
|
|
for slug, host in GAMES.items():
|
|
people = set()
|
|
for r in _lines("/var/lib/caddy/%s-access*.log*" % slug, t0):
|
|
if not (t0 <= r.get("ts", 0) < t1):
|
|
continue
|
|
q = r.get("request", {})
|
|
h = q.get("headers", {})
|
|
if SITE not in " ".join(h.get("Referer", [""])) or BOT.search(" ".join(h.get("User-Agent", [""]))):
|
|
continue
|
|
if q.get("uri", "").split("?")[0].endswith(ASSET):
|
|
continue
|
|
people.add(q.get("client_ip") or q.get("remote_ip") or "?")
|
|
t["sent"][slug] = len(people)
|
|
followed |= people
|
|
|
|
hall = [r for r in _lines(LOGS, t0) if t0 <= r.get("ts", 0) < t1 and r.get("request", {}).get("host", "").split(":")[0] == SITE]
|
|
times, scanning = {}, set()
|
|
for r in hall:
|
|
q = r.get("request", {})
|
|
ua = " ".join(q.get("headers", {}).get("User-Agent", [""]))
|
|
if BOT.search(ua) or not ua:
|
|
continue
|
|
ip = q.get("client_ip") or q.get("remote_ip") or "?"
|
|
times.setdefault(ip, []).append(r.get("ts", 0))
|
|
if SCANNER.search(q.get("uri", "").split("?")[0]):
|
|
scanning.add(ip)
|
|
monitors = {ip for ip, ts in times.items() if ip not in scanning and ip not in followed and _visits(ts) >= 3}
|
|
posing = scanning | monitors
|
|
t["scanners"], t["monitors"] = len(scanning), len(monitors)
|
|
|
|
seen_campaign = set()
|
|
seen_share = set()
|
|
for r in hall:
|
|
q = r.get("request", {})
|
|
h = q.get("headers", {})
|
|
ua = " ".join(h.get("User-Agent", [""]))
|
|
ip = q.get("client_ip") or q.get("remote_ip") or "?"
|
|
uri = q.get("uri", "")
|
|
path = uri.split("?")[0]
|
|
if ip in posing or BOT.search(ua) or not ua:
|
|
t["botAddresses"].add(ip)
|
|
service = unfurled_by(ua)
|
|
# One share per app and page: a preview is often fetched twice.
|
|
if service and (service, path) not in seen_share:
|
|
seen_share.add((service, path))
|
|
t["shared"][service] += 1
|
|
continue
|
|
if path not in ("/", "/index.html"):
|
|
continue
|
|
t["views"] += 1
|
|
t["addresses"].add(ip)
|
|
ref = " ".join(h.get("Referer", [""]))
|
|
if ref and SITE not in ref:
|
|
t["referrers"][re.sub(r"^https?://", "", ref).split("/")[0]] += 1
|
|
m = re.search(r"[?&](ref|utm_source|fbclid)=([^&]*)", uri)
|
|
if m and (ip, m.group(1)) not in seen_campaign:
|
|
seen_campaign.add((ip, m.group(1)))
|
|
t["campaigns"][m.group(1) + ("=" + m.group(2)[:24] if m.group(1) != "fbclid" else "")] += 1
|
|
return t
|