From ce7cf08d600f7d561898fc8a2c661e51f95388a7 Mon Sep 17 00:00:00 2001 From: David Young Date: Sun, 8 Nov 2026 11:36:43 -0700 Subject: [PATCH] Add the receiver that files error reports as Gitea issues server/report-receiver/receiver.py takes what report sends to federated.sh/report, behind Caddy, and files each report as an issue in the private Federated_Computer/coredesktop-reports repository with the full log attached. It saves each report before replying and tries Gitea again later if it's down, limits how many reports one address can send, and refuses oversized logs, gzip bombs included. Standard library only. The README has the deployment steps: a Gitea user that can only file issues there, the systemd unit, and the Caddy route. make test runs its tests, which send reports with the real report client. Co-Authored-By: Claude Opus 5.5 --- Makefile | 1 + server/report-receiver/Caddyfile.example | 20 + server/report-receiver/README.md | 61 +++ .../coredesktop-reports.service | 33 ++ server/report-receiver/receiver.py | 354 ++++++++++++++++++ server/report-receiver/test_receiver.py | 212 +++++++++++ 6 files changed, 681 insertions(+) create mode 100644 server/report-receiver/Caddyfile.example create mode 100644 server/report-receiver/README.md create mode 100644 server/report-receiver/coredesktop-reports.service create mode 100644 server/report-receiver/receiver.py create mode 100644 server/report-receiver/test_receiver.py diff --git a/Makefile b/Makefile index 2728c15..fd39a67 100644 --- a/Makefile +++ b/Makefile @@ -12,3 +12,4 @@ lint: test: bats tests + python3 -I -m unittest discover -s server/report-receiver diff --git a/server/report-receiver/Caddyfile.example b/server/report-receiver/Caddyfile.example new file mode 100644 index 0000000..098d53f --- /dev/null +++ b/server/report-receiver/Caddyfile.example @@ -0,0 +1,20 @@ +# What federated.sh's site block needs, wherever its Caddyfile is. `handle` +# blocks are tried in order and only the first match runs, so /report goes to +# the receiver and everything else does what it did before. A top-level `redir` +# runs before any `handle`, so the existing redirect has to move into the +# catch-all `handle` below, or POSTs to /report would be redirected too. + +federated.sh { + @report path /report /report/* + handle @report { + request_body { + max_size 11MB + } + reverse_proxy 127.0.0.1:8650 + } + + handle { + # ...the existing directives for federated.sh, unchanged, e.g. the + # redirect that sends wget to install.sh + } +} diff --git a/server/report-receiver/README.md b/server/report-receiver/README.md new file mode 100644 index 0000000..c0bfc67 --- /dev/null +++ b/server/report-receiver/README.md @@ -0,0 +1,61 @@ +# Report receiver + +Takes the error reports CoreDesktop machines send with `report` (see the top of +`../../report` for the request format) at `https://federated.sh/report`, and +files each as an issue in the private Gitea repository +`Federated_Computer/coredesktop-reports`, with the full log attached. + +- `receiver.py` listens on 127.0.0.1:8650, behind Caddy. Standard library only. +- It saves each report in `/var/lib/coredesktop-reports//` before replying, + so a report isn't lost if Gitea is down, and tries again every 10 minutes. + Saved reports are deleted after 90 days; the Gitea issues stay. +- It turns away more than 5 reports an hour or 20 a day from one address, and + more than 500 a day in all, and logs bigger than 10 MB compressed or 64 MB + uncompressed. +- `make test` in the repository runs `test_receiver.py`, which sends reports + with the real `report` client to the receiver and a fake Gitea. + +## Deploying on federated.sh + +1. **A Gitea user that can only file issues there.** Gitea tokens can't be + limited to one repository, so make a separate user (say + `coredesktop-reports-bot`), add it to `Federated_Computer/coredesktop-reports` + as a collaborator with write access, and make it a token with only the + `write:issue` permission. Nothing else should be shared with that user. + +2. **Install the receiver** (as root): + + ``` + install -d -m 755 /opt/coredesktop-reports + install -m 644 receiver.py /opt/coredesktop-reports/receiver.py + install -m 644 coredesktop-reports.service /etc/systemd/system/coredesktop-reports.service + install -m 600 /dev/null /etc/coredesktop-reports.env + ``` + + Then put the settings in `/etc/coredesktop-reports.env`: + + ``` + GITEA_URL=https://gitea.federated.computer + GITEA_REPO=Federated_Computer/coredesktop-reports + GITEA_TOKEN= + ``` + + ``` + systemctl daemon-reload + systemctl enable --now coredesktop-reports + curl -s http://127.0.0.1:8650/report/health # prints ok + ``` + +3. **Route /report to it in Caddy.** See `Caddyfile.example`: `/report` goes + in its own `handle` block, and the site's existing directives go in a + catch-all `handle`, so the installer redirect keeps working. Check with + `caddy validate`, then `systemctl reload caddy`. + +4. **Check it end to end.** `curl -s https://federated.sh/report/health` + prints ok, `wget -qO- https://federated.sh | head -3` still shows + install.sh, and from a CoreDesktop machine `report` sends its newest log + and prints an ID, which appears as a new issue in the repository. + +To update it later, install the new `receiver.py` the same way and run +`systemctl restart coredesktop-reports`. `journalctl -u coredesktop-reports` +shows each report it receives and files. diff --git a/server/report-receiver/coredesktop-reports.service b/server/report-receiver/coredesktop-reports.service new file mode 100644 index 0000000..4b5bb2a --- /dev/null +++ b/server/report-receiver/coredesktop-reports.service @@ -0,0 +1,33 @@ +[Unit] +Description=CoreDesktop error-report receiver (federated.sh/report) +After=network-online.target +Wants=network-online.target + +[Service] +ExecStart=/usr/bin/python3 -I /opt/coredesktop-reports/receiver.py +# GITEA_URL, GITEA_REPO and GITEA_TOKEN; readable by root only +EnvironmentFile=/etc/coredesktop-reports.env +DynamicUser=yes +StateDirectory=coredesktop-reports +StateDirectoryMode=0700 +Restart=on-failure +RestartSec=5 + +# It only needs to listen on 127.0.0.1, reach Gitea and write its state directory +NoNewPrivileges=yes +ProtectSystem=strict +ProtectHome=yes +PrivateTmp=yes +PrivateDevices=yes +ProtectKernelTunables=yes +ProtectKernelModules=yes +ProtectControlGroups=yes +RestrictAddressFamilies=AF_INET AF_INET6 AF_UNIX +RestrictNamespaces=yes +LockPersonality=yes +MemoryDenyWriteExecute=yes +SystemCallArchitectures=native +MemoryMax=512M + +[Install] +WantedBy=multi-user.target diff --git a/server/report-receiver/receiver.py b/server/report-receiver/receiver.py new file mode 100644 index 0000000..730b0e1 --- /dev/null +++ b/server/report-receiver/receiver.py @@ -0,0 +1,354 @@ +#!/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() diff --git a/server/report-receiver/test_receiver.py b/server/report-receiver/test_receiver.py new file mode 100644 index 0000000..8e9f3de --- /dev/null +++ b/server/report-receiver/test_receiver.py @@ -0,0 +1,212 @@ +"""Tests for receiver.py. Run with: python3 -I -m unittest discover -s server/report-receiver + +They start the receiver on a free port with a fake Gitea, and send it reports +with the real `report` client, the way a CoreDesktop machine would.""" + +import gzip +import http.server +import json +import os +import shutil +import subprocess +import sys +import tempfile +import threading +import time +import unittest +import urllib.request +from pathlib import Path + +sys.path.insert(0, str(Path(__file__).resolve().parent)) +import receiver # noqa: E402 + +REPO = Path(__file__).resolve().parents[2] + + +class FakeGitea: + """Records the issues and attachments it's given; fail=True answers 500.""" + + def __init__(self): + self.issues = [] + self.attachments = [] + self.fail = False + fake = self + + class Handler(http.server.BaseHTTPRequestHandler): + def do_POST(self): + data = self.rfile.read(int(self.headers["Content-Length"])) + if fake.fail: + self.send_response(500) + self.end_headers() + return + if self.path.endswith("/issues"): + fake.issues.append({"path": self.path, "auth": self.headers["Authorization"], **json.loads(data)}) + reply = {"number": len(fake.issues)} + else: + fields = receiver.parse_multipart(self.headers["Content-Type"], data) + fake.attachments.append({"path": self.path, "text": fields["attachment"].decode()}) + reply = {"id": len(fake.attachments)} + body = json.dumps(reply).encode() + self.send_response(201) + self.send_header("Content-Length", str(len(body))) + self.end_headers() + self.wfile.write(body) + + def log_message(self, *args): + pass + + self.server = http.server.ThreadingHTTPServer(("127.0.0.1", 0), Handler) + self.url = f"http://127.0.0.1:{self.server.server_port}" + threading.Thread(target=self.server.serve_forever, daemon=True).start() + + def close(self): + self.server.shutdown() + + +class ReceiverTest(unittest.TestCase): + def setUp(self): + self.tmp = Path(tempfile.mkdtemp()) + self.gitea = FakeGitea() + self.store = receiver.Store(self.tmp / "state", receiver.Gitea(self.gitea.url, "Federated_Computer/coredesktop-reports", "secret")) + self.limiter = receiver.RateLimiter() + handler = receiver.make_handler(self.store, self.limiter) + handler.log_message = lambda *args: None + self.server = http.server.ThreadingHTTPServer(("127.0.0.1", 0), handler) + self.url = f"http://127.0.0.1:{self.server.server_port}/report" + threading.Thread(target=self.server.serve_forever, daemon=True).start() + + # A CoreDesktop home with one failed update's log + self.home = self.tmp / "home" + logs = self.home / ".local/state/coredesktop/logs" + logs.mkdir(parents=True) + self.log = logs / "update-2026-11-08-110307.log" + self.log.write_text(f"Updating CoreDesktop...\nCloning into '{self.home}/x'...\n" + "Done, with errors in: 71-siblings.sh\n") + + def tearDown(self): + self.server.shutdown() + self.gitea.close() + shutil.rmtree(self.tmp) + + def send(self, *args): + """Run the report client, answering yes, and return its output.""" + tty = self.tmp / "tty" + tty.write_text("y\n") + env = {"HOME": str(self.home), "USER": "alice", "PATH": os.environ["PATH"], + "TMPDIR": str(self.tmp), "COREDESKTOP_REPORT_URL": self.url, "COREDESKTOP_TTY": str(tty)} + result = subprocess.run(["bash", str(REPO / "report"), *args], env=env, + capture_output=True, text=True, timeout=60) + return result.stdout + result.stderr + + def wait_for(self, condition): + for _ in range(100): + if condition(): + return + time.sleep(0.05) + self.fail("timed out") + + def test_a_report_from_the_client_is_filed_with_its_log(self): + output = self.send("--offer", str(self.log), "71-siblings.sh", "46-neovim.sh") + self.assertIn("Your report's ID is CD-", output) + report_id = output.split("Your report's ID is ")[1].split(";")[0] + self.wait_for(lambda: self.gitea.attachments) + + issue = self.gitea.issues[0] + self.assertEqual(issue["path"], "/api/v1/repos/Federated_Computer/coredesktop-reports/issues") + self.assertEqual(issue["auth"], "token secret") + self.assertTrue(issue["title"].startswith("Update failed in 71-siblings.sh, 46-neovim.sh")) + self.assertIn(report_id, issue["body"]) + self.assertIn("Cloning into '~/x'...", issue["body"]) + self.assertNotIn(str(self.home), issue["body"]) + attachment = self.gitea.attachments[0] + self.assertEqual(attachment["path"], "/api/v1/repos/Federated_Computer/coredesktop-reports/issues/1/assets") + self.assertIn("Done, with errors in: 71-siblings.sh", attachment["text"]) + self.assertFalse((self.tmp / "state" / report_id / "pending").exists()) + + def test_a_report_gitea_refuses_is_kept_and_filed_later(self): + self.gitea.fail = True + output = self.send(str(self.log)) + self.assertIn("Your report's ID is CD-", output) + report_id = output.split("Your report's ID is ")[1].split(";")[0] + self.wait_for(lambda: not self.store.lock.locked() and (self.tmp / "state" / report_id / "pending").exists()) + time.sleep(0.2) + self.assertEqual(self.gitea.issues, []) + + self.gitea.fail = False + self.store.retry_and_prune() + self.assertEqual(len(self.gitea.issues), 1) + self.assertEqual(len(self.gitea.attachments), 1) + self.assertFalse((self.tmp / "state" / report_id / "pending").exists()) + + def test_an_attachment_that_failed_is_retried_without_a_second_issue(self): + report_id = self.store.save({"kind": "update", "version": "", "failed": "x", "kernel": "", "siblings": ""}, "log\n") + original = self.store.gitea.attach + self.store.gitea.attach = lambda *args: (_ for _ in ()).throw(OSError("Gitea is down")) + self.assertFalse(self.store.deliver(report_id)) + self.store.gitea.attach = original + self.assertTrue(self.store.deliver(report_id)) + self.assertEqual(len(self.gitea.issues), 1) + self.assertEqual(len(self.gitea.attachments), 1) + + def post(self, body, content_type="multipart/form-data; boundary=x"): + request = urllib.request.Request(self.url, data=body, method="POST", headers={"Content-Type": content_type}) + try: + with urllib.request.urlopen(request) as response: + return response.status, response.read().decode() + except urllib.error.HTTPError as error: + return error.code, error.read().decode() + + def test_things_that_arent_reports_are_turned_away(self): + self.assertEqual(self.post(b"hello", "text/plain")[0], 400) + no_log = b'--x\r\nContent-Disposition: form-data; name="kind"\r\n\r\nupdate\r\n--x--\r\n' + self.assertEqual(self.post(no_log)[0], 400) + not_gzip = b'--x\r\nContent-Disposition: form-data; name="log"; filename="log.gz"\r\n\r\nplain\r\n--x--\r\n' + self.assertEqual(self.post(not_gzip), (400, "The log in this report couldn't be read.\n")) + self.assertEqual(self.gitea.issues, []) + + def test_a_gzip_bomb_is_refused(self): + bomb = gzip.compress(b"\0" * (receiver.MAX_LOG_BYTES + 1)) + body = (b'--x\r\nContent-Disposition: form-data; name="log"; filename="log.gz"\r\n' + b'Content-Type: application/gzip\r\n\r\n' + bomb + b'\r\n--x--\r\n') + self.assertEqual(self.post(body)[0], 413) + + def test_too_many_reports_from_one_address_are_refused(self): + for _ in range(5): + self.assertIn("Your report's ID is", self.send(str(self.log))) + output = self.send(str(self.log)) + self.assertIn("Couldn't send the report: Too many reports from this address", output) + + def test_other_paths_are_not_found(self): + request = urllib.request.Request(self.url.replace("/report", "/other"), data=b"x", method="POST") + with self.assertRaises(urllib.error.HTTPError) as caught: + urllib.request.urlopen(request) + self.assertEqual(caught.exception.code, 404) + + +class PartsTest(unittest.TestCase): + def test_the_rate_limiter_counts_per_address_and_forgets_old_reports(self): + limiter = receiver.RateLimiter(per_address=[(3600, 2)], daily=3) + limiter.check("a", now=0) + limiter.check("a", now=1) + with self.assertRaises(receiver.Rejected): + limiter.check("a", now=2) + limiter.check("a", now=3700) + with self.assertRaises(receiver.Rejected): # the daily limit for everyone + limiter.check("b", now=3701) + + def test_issue_text_cant_break_out_of_its_table_or_code_block(self): + details = {"kind": "update", "version": "v | 1", "failed": "a\nb", "kernel": "", "siblings": ""} + body = receiver.issue_body("CD-TEST23", details, "````\nlog\n", "now") + self.assertIn("| CoreDesktop | v \\| 1 |", body) + self.assertIn("| Failed | a
b |", body) + self.assertIn("`````\n````\nlog\n`````", body) + self.assertEqual(receiver.issue_title(details), "Update failed in a b (v | 1)") + + def test_ids_are_easy_to_read_out(self): + for _ in range(100): + report_id = receiver.new_id() + self.assertRegex(report_id, r"^CD-[2-9A-HJ-NP-Z]{6}$") + + +if __name__ == "__main__": + unittest.main()