#!/usr/bin/env python3
"""Receive CoreDesktop error reports and file them as Gitea issues.
The `report` command on CoreDesktop machines POSTs reports here, through Caddy
on federated.sh. See the top of `report` for the request format. Each report
is saved under the state directory first, so none is lost if Gitea is down,
then filed as an issue in a private repository, with the full log attached.
Reports Gitea didn't take are tried again every few minutes.
Runs with only the standard library. Settings come from the environment:
GITEA_URL e.g. https://gitea.federated.computer
GITEA_REPO e.g. Federated_Computer/coredesktop-reports
GITEA_TOKEN a token that can create issues in that repository
LISTEN_PORT port on 127.0.0.1 to listen on (default 8650)
STATE_DIRECTORY where reports are kept (systemd sets this)
Saved reports are deleted after KEEP_DAYS days; their Gitea issues stay.
"""
import email.parser
import email.policy
import gzip
import http.server
import io
import json
import os
import re
import secrets
import shutil
import sys
import threading
import time
import urllib.error
import urllib.request
from collections import deque
from datetime import datetime, timezone
from pathlib import Path
MAX_REQUEST_BYTES = 11 * 1024 * 1024 # the client sends at most 10 MB of log
MAX_LOG_BYTES = 64 * 1024 * 1024 # uncompressed, so a gzip bomb can't fill memory
FIELD_LIMITS = {"kind": 20, "version": 100, "failed": 1000, "kernel": 100, "siblings": 4000}
KINDS = {"install", "update", "other"}
PER_ADDRESS_LIMITS = [(3600, 5), (86400, 20)] # (seconds, reports) per client address
DAILY_LIMIT = 500 # from everyone together
EXCERPT_LINES = 150
RETRY_SECONDS = 600
KEEP_DAYS = 90
class Rejected(Exception):
"""A request we turn away, with the status and the message for the user."""
def __init__(self, status, message):
super().__init__(message)
self.status = status
self.message = message
class RateLimiter:
def __init__(self, per_address=PER_ADDRESS_LIMITS, daily=DAILY_LIMIT):
self.per_address = per_address
self.daily = daily
self.times = {}
self.everyone = deque()
self.lock = threading.Lock()
def check(self, address, now=None):
"""Count a report from address, or raise Rejected if it's one too many."""
now = time.time() if now is None else now
longest = max(window for window, _ in self.per_address)
with self.lock:
while self.everyone and self.everyone[0] <= now - 86400:
self.everyone.popleft()
if len(self.everyone) >= self.daily:
raise Rejected(429, "We're getting a lot of reports right now; please try again tomorrow.")
times = self.times.setdefault(address, deque())
while times and times[0] <= now - longest:
times.popleft()
for window, limit in self.per_address:
if sum(1 for t in times if t > now - window) >= limit:
raise Rejected(429, "Too many reports from this address; please try again later.")
times.append(now)
self.everyone.append(now)
# Forget addresses with nothing recent, so the table can't grow without end
if len(self.times) > 10000:
self.times = {a: t for a, t in self.times.items() if t and t[-1] > now - longest}
def parse_multipart(content_type, body):
"""The fields of a multipart/form-data body, as {name: bytes}."""
if not content_type.lower().startswith("multipart/form-data"):
raise Rejected(400, "That doesn't look like a CoreDesktop report.")
head = f"Content-Type: {content_type}\r\nMIME-Version: 1.0\r\n\r\n".encode()
message = email.parser.BytesParser(policy=email.policy.HTTP).parsebytes(head + body)
if not message.is_multipart():
raise Rejected(400, "That doesn't look like a CoreDesktop report.")
fields = {}
for part in message.iter_parts():
name = part.get_param("name", header="content-disposition")
if name and name not in fields:
fields[name] = part.get_payload(decode=True) or b""
return fields
def clean_text(value, limit):
"""value as text: no control characters but newlines and tabs, at most limit characters."""
text = value.decode("utf-8", errors="replace")
text = re.sub(r"[^\S\n\t ]|[\x00-\x08\x0b-\x1f\x7f]", "", text)
return text[:limit].strip()
def read_report(fields):
"""The report in fields: its details as text, and its log, uncompressed."""
if "log" not in fields:
raise Rejected(400, "That doesn't look like a CoreDesktop report.")
details = {name: clean_text(fields.get(name, b""), limit) for name, limit in FIELD_LIMITS.items()}
if details["kind"] not in KINDS:
details["kind"] = "other"
try:
with gzip.GzipFile(fileobj=io.BytesIO(fields["log"])) as stream:
log = stream.read(MAX_LOG_BYTES + 1)
except (OSError, EOFError):
raise Rejected(400, "The log in this report couldn't be read.")
if len(log) > MAX_LOG_BYTES:
raise Rejected(413, "This report's log is too big.")
return details, log.decode("utf-8", errors="replace")
def new_id():
"""A report ID such as CD-7Q2K9M: easy to read out, with no 0/O or 1/I."""
alphabet = "23456789ABCDEFGHJKLMNPQRSTUVWXYZ"
return "CD-" + "".join(secrets.choice(alphabet) for _ in range(6))
def fence(text):
"""A code fence longer than any run of backticks in text."""
longest = max((len(run) for run in re.findall(r"`+", text)), default=0)
return "`" * max(3, longest + 1)
def cell(text):
"""text for one cell of a Markdown table."""
return text.replace("\\", "\\\\").replace("|", "\\|").replace("\n", "
") or "–"
def issue_title(details):
failed = details["failed"].replace("\n", " ") or "an unknown step"
title = f"{details['kind'].capitalize()} failed in {failed}"
if details["version"]:
title += f" ({details['version']})"
return title[:200]
def issue_body(report_id, details, log, received):
lines = log.rstrip("\n").split("\n")
excerpt = "\n".join(lines[-EXCERPT_LINES:])
marks = fence(excerpt)
shown = "Its last %d lines:" % EXCERPT_LINES if len(lines) > EXCERPT_LINES else "The log:"
return "\n".join([
f"Report **{report_id}**, received {received}.",
"",
"| | |",
"|---|---|",
f"| Kind | {cell(details['kind'])} |",
f"| CoreDesktop | {cell(details['version'])} |",
f"| Failed | {cell(details['failed'])} |",
f"| Kernel | {cell(details['kernel'])} |",
f"| Siblings | {cell(details['siblings'])} |",
"",
f"The full log ({len(lines)} lines) is attached. {shown}",
"",
marks,
excerpt,
marks,
"",
"Sent with `report` from a CoreDesktop machine. Its user name, host name, home folder, "
"email and IP addresses were replaced before sending.",
])
class Gitea:
def __init__(self, url, repo, token, timeout=30):
self.base = f"{url.rstrip('/')}/api/v1/repos/{repo}"
self.token = token
self.timeout = timeout
def _request(self, path, data, content_type):
request = urllib.request.Request(self.base + path, data=data, method="POST", headers={
"Authorization": f"token {self.token}",
"Content-Type": content_type,
"Accept": "application/json",
})
with urllib.request.urlopen(request, timeout=self.timeout) as response:
return json.load(response)
def create_issue(self, title, body):
"""The new issue's number."""
data = json.dumps({"title": title, "body": body}).encode()
return self._request("/issues", data, "application/json")["number"]
def attach(self, number, filename, text):
boundary = "coredesktop-" + secrets.token_hex(16)
data = b"".join([
f"--{boundary}\r\n".encode(),
f'Content-Disposition: form-data; name="attachment"; filename="{filename}"\r\n'.encode(),
b"Content-Type: text/plain; charset=utf-8\r\n\r\n",
text.encode("utf-8"),
f"\r\n--{boundary}--\r\n".encode(),
])
self._request(f"/issues/{number}/assets", data, f"multipart/form-data; boundary={boundary}")
class Store:
"""Reports on disk: /report.json and /log.txt, and /pending
until it's filed in Gitea."""
def __init__(self, directory, gitea):
self.directory = Path(directory)
self.gitea = gitea
self.lock = threading.Lock() # one delivery at a time, so none is filed twice
def save(self, details, log):
received = datetime.now(timezone.utc).strftime("%Y-%m-%d %H:%M UTC")
for _ in range(10):
report_id = new_id()
folder = self.directory / report_id
try:
folder.mkdir(parents=True)
break
except FileExistsError:
continue
else:
raise Rejected(503, "The report couldn't be saved; please try again later.")
(folder / "log.txt").write_text(log, encoding="utf-8")
(folder / "report.json").write_text(json.dumps({**details, "id": report_id, "received": received}))
(folder / "pending").touch()
return report_id
def deliver(self, report_id):
"""File one saved report in Gitea; True once it's filed."""
folder = self.directory / report_id
with self.lock:
if not (folder / "pending").exists():
return True
report = json.loads((folder / "report.json").read_text())
log = (folder / "log.txt").read_text(encoding="utf-8")
try:
number = report.get("issue")
if not number:
number = self.gitea.create_issue(issue_title(report), issue_body(report_id, report, log, report["received"]))
# Remember the issue, so a failed attachment doesn't make a second one
report["issue"] = number
(folder / "report.json").write_text(json.dumps(report))
self.gitea.attach(number, f"{report_id}-{report['kind']}.log", log)
except (urllib.error.URLError, OSError, ValueError, KeyError) as error:
print(f"{report_id}: couldn't file it in Gitea yet: {error}", file=sys.stderr, flush=True)
return False
(folder / "pending").unlink()
print(f"{report_id}: filed as issue #{number}", file=sys.stderr, flush=True)
return True
def retry_and_prune(self):
"""Try the reports Gitea hasn't taken again; delete filed ones older than KEEP_DAYS."""
if not self.directory.is_dir():
return
cutoff = time.time() - KEEP_DAYS * 86400
for folder in sorted(self.directory.iterdir()):
if not folder.is_dir():
continue
if (folder / "pending").exists():
self.deliver(folder.name)
elif folder.stat().st_mtime < cutoff:
shutil.rmtree(folder, ignore_errors=True)
def make_handler(store, limiter):
class Handler(http.server.BaseHTTPRequestHandler):
timeout = 60
server_version = "coredesktop-reports"
sys_version = ""
def reply(self, status, text):
data = (text + "\n").encode()
self.send_response(status)
self.send_header("Content-Type", "text/plain; charset=utf-8")
self.send_header("Content-Length", str(len(data)))
self.end_headers()
self.wfile.write(data)
def client_address_seen(self):
# Behind Caddy, the client is the first address it forwarded
forwarded = self.headers.get("X-Forwarded-For", "")
if self.client_address[0] in ("127.0.0.1", "::1") and forwarded:
return forwarded.split(",")[0].strip()
return self.client_address[0]
def do_GET(self):
if self.path == "/report/health":
self.reply(200, "ok")
else:
self.reply(404, "Not found.")
def do_POST(self):
if self.path != "/report":
self.reply(404, "Not found.")
return
try:
length = int(self.headers.get("Content-Length", ""))
except ValueError:
self.reply(411, "A report needs a Content-Length.")
return
if length > MAX_REQUEST_BYTES:
self.reply(413, "This report is too big.")
return
try:
limiter.check(self.client_address_seen())
fields = parse_multipart(self.headers.get("Content-Type", ""), self.rfile.read(length))
details, log = read_report(fields)
report_id = store.save(details, log)
except Rejected as rejected:
self.reply(rejected.status, rejected.message)
return
self.reply(200, report_id)
# Filed after replying, so the user isn't kept waiting on Gitea
threading.Thread(target=store.deliver, args=(report_id,), daemon=True).start()
def log_message(self, format, *args):
print(f"{self.client_address_seen()} {format % args}", file=sys.stderr, flush=True)
return Handler
def main():
missing = [name for name in ("GITEA_URL", "GITEA_REPO", "GITEA_TOKEN") if not os.environ.get(name)]
if missing:
sys.exit(f"Set {', '.join(missing)} (see the top of this file).")
gitea = Gitea(os.environ["GITEA_URL"], os.environ["GITEA_REPO"], os.environ["GITEA_TOKEN"])
store = Store(os.environ.get("STATE_DIRECTORY", "./reports"), gitea)
port = int(os.environ.get("LISTEN_PORT", "8650"))
server = http.server.ThreadingHTTPServer(("127.0.0.1", port), make_handler(store, RateLimiter()))
def retry_loop():
while True:
store.retry_and_prune()
time.sleep(RETRY_SECONDS)
threading.Thread(target=retry_loop, daemon=True).start()
print(f"Listening on 127.0.0.1:{port}, filing reports in {os.environ['GITEA_REPO']}", file=sys.stderr, flush=True)
server.serve_forever()
if __name__ == "__main__":
main()