From 615e46f3abf629cad72c93cc998e6dc08dce85e2 Mon Sep 17 00:00:00 2001 From: Luiz Ribeiro Date: Mon, 2 Nov 2026 18:14:15 -0500 Subject: [PATCH 1/4] Stop logging Ergo's quit lines, which name client addresses When a blocking write to a dropped connection fails, Ergo logs a "quit" line with the socket error, and Go's error text carries both ends of the connection: "send error to client : : write tcp ->: write: broken pipe". Production logged five of these, against our promise not to keep members' addresses. Exclude the quit log type, as connect-ip already is. In Ergo 2.19.1 the only other quit line is " is no longer on the server", which names no address but is itself a record of when members leave, which we have no use for. Patching the address out of the error would keep a line nobody reads. The ergo VM test now has a client reset its TLS connection mid-burst: it sends 20 PINGs, waits for the first PONG, then closes with SO_LINGER 0. Fakelag holds the remaining replies past the RST, so Ergo's next write fails. Without the fix the subtest fails on that line; irc-client gains --close=reset for it. --- services/ergo.nix | 5 +++-- tests/e2e/clients/irc-client.py | 16 ++++++++++++++++ tests/e2e/ergo.py | 10 +++++++++- 3 files changed, 28 insertions(+), 3 deletions(-) diff --git a/services/ergo.nix b/services/ergo.nix index fb955a95..7c2e32de 100644 --- a/services/ergo.nix +++ b/services/ergo.nix @@ -162,10 +162,11 @@ in }; # nixpkgs' ergo ships no translations for the default path to find. languages.enabled = false; - # connect-ip lines carry client addresses, which we promise not to keep. + # connect-ip lines, and quit lines for failed writes, carry client + # addresses, which we promise not to keep. logging = [{ method = "stderr"; - type = "* -userinput -useroutput -connect-ip"; + type = "* -userinput -useroutput -connect-ip -quit"; level = "info"; }]; }; diff --git a/tests/e2e/clients/irc-client.py b/tests/e2e/clients/irc-client.py index a40e641b..98791836 100644 --- a/tests/e2e/clients/irc-client.py +++ b/tests/e2e/clients/irc-client.py @@ -9,6 +9,7 @@ import os import socket import ssl +import struct QUIET_SECONDS = 2 @@ -117,6 +118,11 @@ def lines(self): return self.buffer += data + def reset(self): + """Drops the connection with a TCP RST, as a phone losing its network can leave it.""" + self.sock.setsockopt(socket.SOL_SOCKET, socket.SO_LINGER, struct.pack("ii", 1, 0)) + self.sock.close() + def drain(self, seconds): self.sock.settimeout(seconds) for _ in self.lines(): @@ -155,6 +161,12 @@ def main(): metavar="SECONDS", help="stay connected after the commands until the server is quiet this long or closes the connection", ) + parser.add_argument( + "--close", + choices=["quit", "reset"], + default="quit", + help="leave with QUIT once the server is quiet, or reset the connection once it answers the first PING", + ) parser.add_argument("commands", nargs="*") args = parser.parse_args() if args.account and not (args.password or args.cert): @@ -182,6 +194,10 @@ def main(): for command in args.commands: conn.send(command) + if args.close == "reset": + conn.read_until("PONG") + conn.reset() + return conn.drain(args.linger) with contextlib.suppress(OSError): conn.send("QUIT") diff --git a/tests/e2e/ergo.py b/tests/e2e/ergo.py index 40a5a3b0..7062f78c 100644 --- a/tests/e2e/ergo.py +++ b/tests/e2e/ergo.py @@ -4,7 +4,7 @@ if TYPE_CHECKING: from driver import client, credenza, subtest - from helpers import Account, concierge_admin, ergo_api, irc, irc_command, register + from helpers import Account, assert_client_address_absent, concierge_admin, ergo_api, irc, irc_command, register alice: Account = {"account": "alice", "password": "correct-horse"} bob: Account = {"account": "bob", "password": "battery-staple"} @@ -102,3 +102,11 @@ whois = irc("WHOIS alice", "WHOIS carol", **renewed) hosts = dict(re.findall(r" 311 carol (\S+) \S+ (\S+) ", whois)) assert hosts == {"alice": "weblounge", "carol": "weblounge"}, whois + +with subtest("a client that drops its connection mid-burst leaves no address in Ergo's log"): + # Fakelag holds the rest of the burst's replies until after the reset, so Ergo's next write fails. + irc(*["PING burst"] * 20, close="reset", **renewed) + # Carol isn't always-on, so she leaves the network once Ergo gives up on the connection. + whois_carol = irc_command("WHOIS carol", **alice) + client.wait_until_succeeds(f"{whois_carol} | grep ' 401 alice carol ' >/dev/null", timeout=60) + assert_client_address_absent("journalctl -u ergochat") From 379a81fd6eb5ef5a6dc44653fdc013c1c32a2d71 Mon Sep 17 00:00:00 2001 From: Luiz Ribeiro Date: Tue, 3 Nov 2026 15:49:18 -0500 Subject: [PATCH 2/4] Backport Ergo's fix for webpush always sending high urgency Ergo v2.19.1 sends every push at high urgency, ignoring the urgency each caller picks. Upstream PR #2471 (merged 2026-10-22, in no release yet) passes it through, so maintenance PINGs and MARKREADs go out at normal urgency and stop waking phones; messages and the registration probe stay high. The commit applies to v2.19.1 unchanged. Upstream added no tests; Ergo's own suite still runs in the build. --- pkgs/ergochat.nix | 8 +++++++- 1 file changed, 7 insertions(+), 1 deletion(-) diff --git a/pkgs/ergochat.nix b/pkgs/ergochat.nix index 894c2c46..a29c618c 100644 --- a/pkgs/ergochat.nix +++ b/pkgs/ergochat.nix @@ -1,7 +1,7 @@ # Concierge needs /v1/ns/passwd (2.18) and /v1/ns/saget and saset (2.19); # drop the version bump once nixpkgs ships them. nixpkgs builds Ergo # without SQLite, which keeps channel history across restarts. -{ ergochat, fetchFromGitHub }: +{ ergochat, fetchFromGitHub, fetchpatch }: ergochat.overrideAttrs (finalAttrs: old: { version = "2.19.1"; @@ -23,5 +23,11 @@ ergochat.overrideAttrs (finalAttrs: old: { # Members manage their accounts through concierge, which Ergo's own # NickServ and HostServ commands would bypass. Not offered upstream yet. ./ergochat-disable-service-commands.patch + # Maintenance PINGs and MARKREADs went out at high urgency, which costs + # phones battery. Upstream's PR #2471; drop it once a release has it. + (fetchpatch { + url = "https://github.com/ergochat/ergo/commit/9975d8dc0bff2f51e79b7307b9955da375a945fe.patch"; + hash = "sha256-fLQisMdGqdD5CBlxhlN5sD2iMTWWlOhWl+rUkRU19Ec="; + }) ]; }) From 5e1b2d408b33217102b783ddc33734efede9d5ad Mon Sep 17 00:00:00 2001 From: Luiz Ribeiro Date: Tue, 3 Nov 2026 15:54:33 -0500 Subject: [PATCH 3/4] Build VM tests' minica certificates in one place The ergo test will need a certificate from a test CA too, as the mail node has. --- tests/e2e/lib/certificates.nix | 7 +++++++ tests/e2e/lib/mail.nix | 4 +--- 2 files changed, 8 insertions(+), 3 deletions(-) create mode 100644 tests/e2e/lib/certificates.nix diff --git a/tests/e2e/lib/certificates.nix b/tests/e2e/lib/certificates.nix new file mode 100644 index 00000000..a57a3fb5 --- /dev/null +++ b/tests/e2e/lib/certificates.nix @@ -0,0 +1,7 @@ +# A test CA and a certificate from it for domain: minica.pem is the CA's, +# and /cert.pem and /key.pem the server's. +{ pkgs }: + +domain: pkgs.runCommand "${domain}-test-certificates" { nativeBuildInputs = [ pkgs.minica ]; } '' + mkdir $out && cd $out && minica --domains ${domain} +'' diff --git a/tests/e2e/lib/mail.nix b/tests/e2e/lib/mail.nix index bab70a07..121aacfa 100644 --- a/tests/e2e/lib/mail.nix +++ b/tests/e2e/lib/mail.nix @@ -8,9 +8,7 @@ let server = "mail"; # secrets/vm.yaml's smtp-password, which the mailer logs in with. password = "vm-smtp-password"; - certificates = pkgs.runCommand "mail-test-certificates" { nativeBuildInputs = [ pkgs.minica ]; } '' - mkdir $out && cd $out && minica --domains ${server} - ''; + certificates = import ./certificates.nix { inherit pkgs; } server; logins = username: pkgs.runCommand "mailpit-logins" { nativeBuildInputs = [ pkgs.apacheHttpd ]; } '' htpasswd -nbB ${pkgs.lib.escapeShellArgs [ username password ]} > $out ''; From a63b8703722c8164c1159212cf666d3c7f484349 Mon Sep 17 00:00:00 2001 From: Luiz Ribeiro Date: Tue, 3 Nov 2026 15:54:42 -0500 Subject: [PATCH 4/4] Let members keep ten web push subscriptions, not four Each browser or phone a member turns notifications on in takes a subscription, and Ergo's default of four runs out quickly for anyone with a few devices and a beta client besides. The ergo VM test registers eleven endpoints and sees ten accepted and the eleventh refused. Ergo probes each endpoint with a push before accepting it, over HTTPS and only to a public address, so the client serves one at 203.0.113.7 with a certificate credenza trusts. --- services/ergo.nix | 1 + tests/e2e/ergo.nix | 30 ++++++++++++++++++++++++++++++ tests/e2e/ergo.py | 11 +++++++++++ 3 files changed, 42 insertions(+) create mode 100644 tests/e2e/ergo.nix diff --git a/services/ergo.nix b/services/ergo.nix index 7c2e32de..7efdb82c 100644 --- a/services/ergo.nix +++ b/services/ergo.nix @@ -155,6 +155,7 @@ in webpush = { enabled = true; subscriber = "https://${domain}"; + max-subscriptions = 10; }; api = { enabled = true; diff --git a/tests/e2e/ergo.nix b/tests/e2e/ergo.nix new file mode 100644 index 00000000..090fadf1 --- /dev/null +++ b/tests/e2e/ergo.nix @@ -0,0 +1,30 @@ +{ vmTest, pkgs, ... }: + +let + # Ergo probes every push endpoint it's given, and only over HTTPS to a + # public address; the client stands in for a push service at one. + pushService = "push.test"; + pushAddress = "203.0.113.7"; + certificates = import ./lib/certificates.nix { inherit pkgs; } pushService; +in +vmTest { + credenza = { + security.pki.certificateFiles = [ "${certificates}/minica.pem" ]; + networking.hosts.${pushAddress} = [ pushService ]; + networking.interfaces.eth1.ipv4.routes = [{ address = pushAddress; prefixLength = 32; }]; + }; + + client = { + networking.interfaces.eth1.ipv4.addresses = [{ address = pushAddress; prefixLength = 32; }]; + networking.firewall.allowedTCPPorts = [ 443 ]; + services.nginx = { + enable = true; + virtualHosts.${pushService} = { + onlySSL = true; + sslCertificate = "${certificates}/${pushService}/cert.pem"; + sslCertificateKey = "${certificates}/${pushService}/key.pem"; + locations."/".return = "201"; + }; + }; + }; +} diff --git a/tests/e2e/ergo.py b/tests/e2e/ergo.py index 7062f78c..a044397a 100644 --- a/tests/e2e/ergo.py +++ b/tests/e2e/ergo.py @@ -110,3 +110,14 @@ whois_carol = irc_command("WHOIS carol", **alice) client.wait_until_succeeds(f"{whois_carol} | grep ' 401 alice carol ' >/dev/null", timeout=60) assert_client_address_absent("journalctl -u ergochat") + +with subtest("a member may keep push subscriptions on ten devices, and no more"): + keys = ( + "p256dh=BB-oXlVJ4NSC4u0q_xDR-sBNQvDtdOQkBoiAHSGfdjMYGgisDueQhqo_qJPXT9ZdtR6nxu40lmMYe5SfMkDfK6g;" + "auth=saK8t84e6IvjXtf1fv0KIg" + ) + devices = [f"https://push.test/device-{n}" for n in range(11)] + replies = irc(*[f"WEBPUSH REGISTER {device} {keys}" for device in devices], **alice) + registered = re.findall(r" WEBPUSH REGISTER (\S+) ", replies) + assert registered == devices[:10], replies + assert f"FAIL WEBPUSH MAX_REGISTRATIONS REGISTER {devices[10]} " in replies, replies