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()