Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01GwFKMuQnPAEHJ5yA1q4orh
171 lines
7.5 KiB
Python
171 lines
7.5 KiB
Python
"""Caddy's access log for one day, people and bots apart. The visitors
|
|
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, defaultdict
|
|
|
|
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 a game for but a vulnerability scanner: its address is a bot's all day.
|
|
# Matched against whole path segments, never the site's own hashed assets under /_app/.
|
|
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)
|
|
PAGES = {"/": "hall", "/rules": "rules", "/guide": "guide"}
|
|
SITE = "__DOMAIN__"
|
|
# Each game on a shared host keeps its own access log.
|
|
LOGS = "/var/lib/caddy/__SLUG__-access*.log*"
|
|
# The site's own addresses, for telling a referrer from a move between its pages: its domain, and its sslip.io name.
|
|
OWN = re.compile(re.escape(SITE) + r"|\.sslip\.io\b", re.I)
|
|
# Requests from one address more than this far apart are separate visits.
|
|
VISIT_GAP = 30 * 60
|
|
|
|
|
|
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 parse(r):
|
|
"""The parts of one log line the counts read."""
|
|
q = r.get("request", {})
|
|
h = q.get("headers", {})
|
|
uri = q.get("uri", "")
|
|
return dict(
|
|
ts=r.get("ts", 0), status=r.get("status", 0), method=q.get("method"), uri=uri, path=uri.split("?")[0],
|
|
ip=q.get("client_ip") or q.get("remote_ip") or "?",
|
|
ua=" ".join(h.get("User-Agent", [""])), referer=" ".join(h.get("Referer", [""])),
|
|
)
|
|
|
|
|
|
def visit_starts(times):
|
|
"""When each visit began: requests more than VISIT_GAP apart are separate visits."""
|
|
starts, last = [], None
|
|
for ts in sorted(times):
|
|
if last is None or ts - last > VISIT_GAP:
|
|
starts.append(ts)
|
|
last = ts
|
|
return starts
|
|
|
|
|
|
def scan(day, logs=LOGS):
|
|
"""Counts for one UTC day (YYYY-MM-DD): every request, page requests by
|
|
people, the addresses of people and of bots, requests by page, room links
|
|
opened, rooms opened from the site itself, where links were shared,
|
|
referrers, and campaign tags (each counted once per address).
|
|
|
|
A request is a bot's when its browser says so. An address is a bot's for
|
|
the whole day when it asks for a scanner's paths, or comes back three
|
|
visits or more without ever opening or joining a game: a monitor,
|
|
not a person. A monitor that takes a new address every visit gives itself
|
|
away by keeping time: one browser, page loads at a steady interval, four
|
|
visits or more, never a game."""
|
|
d0 = datetime.datetime.fromisoformat(day).replace(tzinfo=datetime.timezone.utc)
|
|
t0, t1 = d0.timestamp(), (d0 + datetime.timedelta(days=1)).timestamp()
|
|
requests = []
|
|
for f in sorted(glob.glob(logs)):
|
|
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:
|
|
r = json.loads(line)
|
|
except ValueError:
|
|
continue
|
|
if t0 <= r.get("ts", 0) < t1:
|
|
requests.append(parse(r))
|
|
except OSError:
|
|
continue
|
|
|
|
# First pass: what each address did all day under a browser's name, to find scanners and monitors.
|
|
seen = defaultdict(lambda: dict(times=[], played=False, scanner=False))
|
|
for r in requests:
|
|
if BOT.search(r["ua"]) or not r["ua"]:
|
|
continue
|
|
path = r["path"]
|
|
a = seen[r["ip"]]
|
|
a["times"].append(r["ts"])
|
|
if not path.startswith("/_app/") and SCANNER.search(path):
|
|
a["scanner"] = True
|
|
if path.startswith(("/join/", "/api/rooms")) or path == "/ws":
|
|
a["played"] = True
|
|
|
|
def keeps_time(times):
|
|
starts = visit_starts(times)
|
|
if len(starts) < 4:
|
|
return False
|
|
gaps = [b - a for a, b in zip(starts, starts[1:])]
|
|
typical = sorted(gaps)[len(gaps) // 2]
|
|
return all(0.75 * typical <= g <= 1.25 * typical for g in gaps)
|
|
|
|
scanners = {ip for ip, a in seen.items() if a["scanner"]}
|
|
monitors = {ip for ip, a in seen.items() if not (a["scanner"] or a["played"]) and len(visit_starts(a["times"])) >= 3}
|
|
by_browser = defaultdict(list)
|
|
for r in requests:
|
|
if r["ua"] and not BOT.search(r["ua"]) and r["path"] in PAGES and not seen[r["ip"]]["played"]:
|
|
by_browser[r["ua"]].append((r["ts"], r["ip"]))
|
|
for hits in by_browser.values():
|
|
if keeps_time([ts for ts, _ in hits]):
|
|
monitors |= {ip for _, ip in hits}
|
|
posing = scanners | monitors
|
|
|
|
t = dict(
|
|
requests=0, human=0, bot=0, addresses=set(), botAddresses=set(), pages=Counter(), joins=Counter(),
|
|
opened=0, shared=Counter(), scanners=len(scanners), monitors=len(monitors), referrers=Counter(), campaigns=Counter(),
|
|
)
|
|
seen_campaign = set()
|
|
seen_share = set()
|
|
for r in requests:
|
|
ip, ua, uri, path = r["ip"], r["ua"], r["uri"], r["path"]
|
|
t["requests"] += 1
|
|
if ip in posing or BOT.search(ua) or not ua:
|
|
t["bot"] += 1
|
|
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
|
|
# A room opened from the site itself never passes through /join: count the API's answer.
|
|
if r["method"] == "POST" and path == "/api/rooms" and 200 <= r["status"] < 300:
|
|
t["opened"] += 1
|
|
if path.startswith(("/_app/", "/api/")) or path == "/ws" or path.endswith((".png", ".ico", ".txt")):
|
|
continue
|
|
t["human"] += 1
|
|
t["addresses"].add(ip)
|
|
if path in PAGES:
|
|
t["pages"][PAGES[path]] += 1
|
|
elif path.startswith("/join/"):
|
|
t["joins"][path.split("/")[2].upper()] += 1
|
|
ref = r["referer"]
|
|
if ref and not OWN.search(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
|