ANetBBS Changelog
Current release: v1.0.8 (August 2026). This file covers v1.0.0
onward, which follows standard semantic versioning — patch releases are
v1.0.1, v1.0.2, and so on. The full internal beta build-number
history (v1.0a1.1 through v1.0b2.239) that got the project to this
release is preserved in
CHANGELOG-beta.md.
v1.0.8 — Poll log dedup guard could block a network's polls forever (August 2026)
Found live, right after the public release announcement: DOVE-Net (a QWK network) had simply stopped appearing in echomail poll activity, with no error anywhere — just silence for over a day.
Root cause: _do_poll()'s concurrent-poll dedup guard (anetbbs/echomail/poller.py, added in an earlier audit to stop a sysop's manual "Poll Now" from racing the scheduled poller's own tick for the same network) treats any EchomailPollLog row still at status='running' as proof a poll is genuinely in progress, and skips starting a new one. That's correct for a poll that's actually still running — but a poll interrupted mid-flight (a service restart landing while a session was still open, which is exactly what happens during any update.sh run) leaves its row stuck at 'running' forever, since no exit path ever gets a chance to run and flip it. Every subsequent poll attempt for that network then silently self-skips, permanently, with nothing logged anywhere a sysop would think to look — confirmed live: DOVE-Net's last poll log row was status='running', started_at over a day in the past, and nothing after it at all.
Fixed: a 'running' row older than 30 minutes (_STALE_RUNNING_POLL_MINUTES) is now treated as abandoned rather than as a lock — it's flipped to 'error' with a note explaining why, and the new poll proceeds normally instead of skipping forever. A genuinely recent 'running' row still blocks a second concurrent attempt exactly as before. 2 new tests (stale-row recovery, and a sanity check that a fresh row still blocks normally) alongside the 3 existing dedup-guard tests, all passing.
This is the kind of bug an automated update can trigger on any network, not just QWK — any BinkP network poll interrupted by a service restart mid-session would hit the same silent-forever-skip. Sysops on v1.0.6/v1.0.7 whose polling has quietly gone silent for a network should check Admin → Echomail → Poll Log for an old 'running' row for that network; the fix here is automatic once updated, no manual DB edit needed.
v1.0.7 — MRC bridge reconnect storm against a live hub (August 2026)
Found testing a fresh install on an otherwise-idle VM: the web MRC client showed "MRC upstream disconnected (reconnecting…)" in an endless loop. The bridge's own log showed why — a real TCP-level connection reset from the upstream hub, immediately after every single connect attempt, at a flat ~1-second retry cadence with no growing delay between attempts. A one-off manual TLS probe from the same machine got reset the exact same way, consistent with the hub's own flood/abuse protection having flagged the source IP in response to the retry storm itself.
Root cause: MRCConnection._reconnect_loop() (mrc/bridge/main.py) only grew its exponential backoff when the initial connect() call failed outright (a TCP/TLS-level error). But the actual failure mode here was different — connect() completes the TCP/TLS handshake, sends the handshake packet, and returns success immediately; the hub's reset is only noticed moments later by a separate, concurrently-running receive loop, which has no say in the backoff decision at all. Every such cycle reset the backoff delay straight back to its floor, so a hub that starts rejecting connections shortly after they're established got hammered at a constant rate forever instead of backed away from — turning one bad connection into a self-perpetuating one.
Fixed: a connection now has to stay up for a configurable minimum "stable" duration (mrc_reconnect_stable_seconds, default 10s) before the backoff resets. A connection that drops before then is treated as a failed cycle — same real delay-and-grow treatment as an outright failed connect() call — instead of silently falling through to the flat per-second retry. 2 new tests, confirmed to fail against the old code (zero backoff growth across five simulated fast-flap cycles) and pass against the fix.
A second, more serious bug turned up once the backoff fix let the real rejection reason surface: the hub was actively refusing every connection with an explicit OLDVERSION packet — "our advertised version (ANETBBS/Linux.x86_64/v1.0.6) is too old ... hub wants: 1.2.9". Traced back through docs/CHANGELOG-beta.md: an earlier pre-release audit found platform_info's version component hardcoded to a stale, disconnected "1.3.7" in three separate places and "fixed" it by deriving it from ANetBBS's own VERSION file instead, on the assumption the old value was meaningless drift. It wasn't — that field identifies MRC client/protocol compatibility to the hub in the hub's own numbering scheme, unrelated to ANetBBS's v1.0.x release series. Every fresh install from that point on was guaranteed to fail this check, since ANetBBS's version has nothing to do with whatever floor the hub currently enforces — and because mrc/bridge/config.json is deliberately never regenerated on an existing install, this was silent on already-running installs and only surfaced on a fresh one.
Fixed by decoupling platform_info from BBS_VERSION/NEW_VERSION entirely in both install.sh and update.sh, using a fixed, independent value (1.3.9, matching the real reference client's own version — comfortably above the hub's observed-live floor of 1.2.9) via a new MRC_CLIENT_COMPAT_VERSION constant. Also fixed the shipped mrc/bridge/config.example.json's placeholder, which previously read "VERSION-HERE" — an open invitation to fill in ANetBBS's own version, exactly the wrong instinct. 5 new tests extracting and running the real generation lines from both shell scripts in actual bash, confirming neither can leak the host's own release version into the field.
Also updated four docs files' install-command examples from v1.0.6 to v1.0.7 (README.md, docs/01-installing.md, docs/INSTALL-PI.md, docs/preinstall-tutorial.html) — same drift the previous release's docs sweep addressed, now current again.
Note for any install whose mrc/bridge/config.json was generated between the original version-unification audit and this fix: this source-code fix only changes what new config.json files get generated — an existing one is never touched by update.sh. If web MRC chat isn't connecting and the bridge log shows an OLDVERSION rejection, manually edit platform_info in that install's mrc/bridge/config.json to end in 1.3.9 (or whatever the hub currently requires) and restart anetbbs-mrc-bridge.
v1.0.6 — Pre-release docs sweep: stale install-command versions (August 2026)
A final documentation pass ahead of the public release announcement, checking for exactly the kind of drift a fast string of patch releases (v1.0.1 through v1.0.5, all in the same week) tends to leave behind: install/update instructions still quoting an old tarball filename.
Found and fixed in four files: README.md's two "Quick install" blocks, docs/01-installing.md's Fresh Install section, docs/INSTALL-PI.md's install and update sections, and docs/preinstall-tutorial.html's walkthrough — all still hardcoded ANetBBS-v1.0.0.tar.gz/cd ANetBBS-v1.0.0, five patch releases stale. The most consequential of the four: docs/INSTALL-PI.md's "Updating ANetBBS on Pi" section built its wget URL from github.com/anetonline/ANetBBS/releases/latest/download/ANetBBS-v1.0.0.tar.gz — GitHub's "latest" release alias resolves to whatever the current release actually is, but the literal asset filename in that URL has to match an asset that exists in it. With the latest release now shipping ANetBBS-v1.0.5.tar.gz, that link would 404 for any Pi user following the doc verbatim, right as the public announcement was about to send more people to it than usual.
Swept everything else that could plausibly carry a stale version claim in the same pass and found it already clean: the in-BBS wiki (anetbbs/wiki/seed.py) has no duplicated install instructions and no "current version" claims of its own; banner.ans/banner.utf8.ans and the welcome/goodbye ANSI screens contain no version text at all (or already use the existing @VERSION@ template substitution); the main web templates already read the version dynamically rather than hardcoding it; and the various "as of v1.0b2.NNN" mentions scattered through the rest of the docs are legitimate historical notes about when a specific feature shipped, not claims about the current release — left alone on purpose.
A real bug also caught in this window, before deploy. A live TIC delivery (tqwinfo.zip, a legitimate file-echo distribution) sat unfiled for hours despite arriving cleanly and passing CRC. Root cause: process_tic() (anetbbs/echomail/tic.py) falls back to a default storage path whenever a FileArea doesn't have one explicitly configured — but that default was hardcoded to /var/lib/anetbbs/file_areas/<TAG>, a location the BBS service (running as an unprivileged user in every real install) has no permission to create. Every retry failed with a permission error one directory level deeper than the last, only ever recovering if someone manually built the path by hand as root — not something that happens unattended. This would hit any file area with no storage path set, including ones auto-created on the fly from an unrecognized TIC area tag, so it wasn't a one-off. Fixed: the default now lives under {DATA_DIR}/file_areas/<TAG>, the same convention every other on-disk default in the app already follows (uploads, avatars, echomail). Also updated the two admin-form placeholder examples that suggested the broken /var/lib/anetbbs path by example. 1 new test (test_unset_storage_path_defaults_under_data_dir_not_var_lib), confirming an area with no storage path files successfully with zero manual intervention.
Folded into the same v1.0.6 rather than a separate release, since it was caught before Jerry deployed the docs-only build — same pattern as v1.0.5's find_aka_for_network fix landing before its own first deploy.
v1.0.5 — MRC protocol audit: message-length and color bugs (August 2026)
A sysop-requested audit of ANetBBS's MRC chat implementation (anetbbs/features/mrc_chat.py terminal client, anetbbs/templates/mrc/index.html/static/mrc/client.js web client, and the standalone bridge mrc/bridge/main.py) against the published MRC protocol documentation, prompted by two real reports: room-chat messages losing their color partway through a long message, and not being able to type/send anywhere close to the documented 140-character limit.
Color lost on split messages. A room-chat line long enough to need splitting into multiple wire-safe chunks (_split_for_wire()) only had the sender's active color pipe-code at the very start of the pre-split string. Each chunk is sent as its own fully independent send_message call — a separate MRC packet on the wire, not a client-side word-wrap of one received message — so only the first chunk ever carried the color; every chunk after it arrived with none and rendered in whatever default/leftover color happened to be active on each recipient's own client. Fixed by giving _split_for_wire() a repeat_prefix parameter that gets budgeted into the per-chunk cap and re-applied to every chunk, not just the first, and updating the room-chat call site to use it instead of pre-concatenating the color onto the whole string before splitting.
/me and /broadcast silently truncated. Both commands budgeted their outgoing text against the bare 140-char hub limit with zero reservation for the wrapper the bridge adds before transmission: /me gets wrapped in a fixed-color "|15* |13{nick} ...|07" (not the user's own style), and /broadcast gets the literal "BROADCAST " prefixed onto Field 7 verbatim. Anything within that wrapper's length of the 140-char limit had its tail silently cut off server-side (_truncate_wire_message()) with no warning — in both the terminal and web clients — the exact class of bug handle_overhead/dm_overhead already existed to prevent for plain chat and DMs, just never extended to these two. Fixed by having the bridge compute and push a new action_overhead figure on join (same established pattern as the other two), consumed by a new _action_wire_cap() in the terminal client and _actionTypedLimit() in the web client; /broadcast's fixed literal-prefix overhead needed no bridge round-trip, just a local constant matching the bridge's own string. The terminal's status-bar remaining-character counter for /me was also silently assuming zero overhead (showing more room than truly existed) — same fix closes that too.
USERNICK: parsing bug, found auditing the wire format directly against the spec rather than inferring it from behavior: the real packet carries exactly ONE nick value (SERVER~~~CLIENT~~~USERNICK:nick~), not an "old new" pair. The terminal client's handler split on whitespace expecting two tokens; a single-token value always produced only one element, so the code's "new nick" half was permanently empty while the "old nick" half (actually just the one real value) got unconditionally discarded from the local known-users roster every time this fired, never restored — a slow leak silently shrinking tab-completion and mention-highlighting coverage the longer a session ran. Fixed by parsing it as the single value it actually is and adding it to the roster, with no erroneous discard.
19 new tests (test_mrc_terminal_wire_overhead_and_color.py, test_mrc_bridge_action_overhead.py) covering the color carryover, the /me//broadcast overhead accounting end-to-end through the real call sites, the status-bar counter, and the USERNICK: parsing fix.
A separate, unrelated bug also caught in this release. Found live while verifying v1.0.4's "reply via arrival network" fix actually worked end to end: the reply's own From address turned out wrong. Root cause: find_aka_for_network() (anetbbs/echomail/routing.py) picks the UserAka whose zone matches the network being sent through — correct when there's a match, but when there wasn't one, it fell back to the sysop's "primary" AKA (or just the first one on file) rather than giving up. Both real callers (netmail.py's compose(), telegram.py's send()) already have their own correct fallback for exactly this situation — aka.address if aka else network.our_address — but it never got a chance to run, since this function always returned something non-None as long as the user had any AKA configured at all, confidently wrong or not.
Confirmed live: a reply through a real, correctly-configured zone-1 Fidonet network went out From the sysop's zone-1200 AKA (configured for an entirely different, unrelated network, and happened to be marked "primary") instead of that Fidonet network's own our_address. The receiving peer's own upstream relay rejected the origin address as unroutable and explicitly warned against replying to it again — a real risk to nodelist standing, not just a cosmetic header mismatch. Fixed by returning None on no zone match (same as the pre-existing no-AKAs-at-all case) instead of guessing, letting the already-correct, already-tested fallback in both callers finally do its job. 6 new tests (test_find_aka_for_network_zone_mismatch.py).
v1.0.4 — Reply to crash-delivered netmail with no configured network (August 2026)
Direct follow-up to v1.0.3: a sysop received real crashmail from an address covered by no configured EchomailNetwork (exactly the case v1.0.3 made ANetBBS newly compliant to accept), then tried to reply and hit "No active FTN network covers zone of ..." — find_network_for_address() (anetbbs/echomail/routing.py) requires a same-zone configured network to route outbound netmail through, which is correct for ordinary hub-routed mail but wrong here: there's no hub in this relationship at all. The sender crash-delivered straight to the BBS by dialing in directly; the only correct reply is to crash-deliver straight back, dialing them.
Inbound side. NetmailMessage gained an origin_ip column. binkp_server.py's _import_pkt_payload() now accepts an origin_ip parameter and stores it on any netmail imported with network_id=None (the anonymous-crashmail case) — the real socket IP the peer connected from, threaded through from _handle_connection()'s own peer tuple via closure. Left None for every other case (known upstream hub, known downstream node), since those already have a real hub_address/ftn_address to route a reply through and don't need this.
Outbound side. anetbbs/web/netmail.py's compose() route detects a reply to netmail with network_id is None and origin_ip set, and takes a different path entirely: skips find_network_for_address(), forces the destination address to the parent message's own from_address/from_name (never trusts the posted form fields for this — the only thing that can ever change where a direct-dial reply actually gets sent is the parent's own DB-stored origin_ip, which a request body can't influence), and queues the new NetmailMessage with network_id=None and origin_ip copied from the parent. A new send_netmail_direct_now() in poller.py then dials that IP directly on the standard BinkP port (24554 — there's no local nodelist INA/IBN entry to learn a custom one from) with no session password (there's no shared secret with an unlisted peer), fired in a background thread immediately on submit rather than waiting on a scheduled poll that can't exist for an address with no network row to attach one to — matches the existing "Poll Now" admin-button pattern. The compose template shows a banner explaining what's about to happen and renders the To fields read-only for this case.
Explicitly out of scope for this fix (by design, not oversight): echomail from an unlisted peer is still dropped, not accepted, same as v1.0.3 — this only restores netmail deliverability, not open echo distribution. And a peer's own nodelist INA/IBN port, if non-standard, still isn't looked up anywhere — a sysop mentioned a separate BinkD-format hostname list (Z1BINKD.TXT) that could seed a future nodelist-based lookup, but confirmed with him this isn't needed for this fix and deliberately left out to keep this change scoped to the actual reported problem.
13 new tests (test_poller_direct_crash_reply.py, test_netmail_direct_crash_reply_compose.py, plus 2 more in test_binkp_anonymous_crashmail.py), and 5 existing test files (test_binkp_downstream_node_import_network_id.py, test_binkp_eob_sent_before_receive.py, test_binkp_finish_before_import_ordering.py, test_binkp_import_off_event_loop.py, test_binkp_node_network_disambiguation.py) fixed after their mocked _import_pkt_payload() signatures didn't accept the new origin_ip keyword argument — caught by a full regression sweep before this shipped, not live.
A second, related bug turned up live testing the first one. A sysop's actual reply attempt still hit "No active FTN network covers zone", even after the above shipped — but the netmail he was replying to turned out to have a real, non-NULL network_id, not the anonymous-crashmail case at all. It had arrived via completely ordinary FTN store-and-forward routing: his own real, active network's hub relayed a netmail originally sent from a different zone than that network's own our_address — hubs commonly carry cross-zone traffic via zone gates, and this one had just proven exactly that by delivering the message in the first place. find_network_for_address() only matches a network whose own our_address zone equals the destination's zone — a reasonable test when composing a brand-new message with no prior routing evidence, but the wrong test for a reply, where the network that just delivered the original already demonstrated it can carry traffic for that zone.
Fixed in compose(): a reply (a parent message exists) with a real parent.network_id now routes through that same network directly, skipping the zone-matching gate entirely — falls back to find_network_for_address() only for a fresh compose with no parent, or if the parent's network is no longer active (with a clear "no longer active" error in that case, rather than silently falling through). 3 more new tests (test_netmail_reply_uses_arrival_network.py).
v1.0.3 — BinkP crashmail compliance: accept netmail from unlisted addresses (August 2026)
Real report: a sysop forwarded a netmail from a net's nodelist coordinator flagging that ANetBBS's own node was non-compliant with standard FTN nodelist policy, which requires a listed node (not flagged Hold or Pvt) to accept crashmail from any address, not only addresses it has pre-configured as an upstream hub or downstream node. The coordinator's own connectivity test to the node got Try result: unknown address ....
Root cause: anetbbs/echomail/binkp_server.py's inbound BinkP listener matched the connecting peer's claimed AKA against EchomailNetwork.hub_address (configured upstream hubs) and BinkPNode.ftn_address (configured downstream nodes) only — anything else got M_ERR unknown address and an immediate disconnect, with no password check ever attempted (there was nothing to authenticate against). That's correct for echomail (distribution requires real network membership) but wrong for netmail, which FTN policy expects any listed node to accept from anyone.
Fixed by accepting the session for an unrecognized peer instead of rejecting it (net_id and downstream_node_id both stay None — no password is checked, matching how real binkd/ifcico treat unlisted callers, since there is no shared secret to verify with a peer we've never configured). The import path (_import_pkt_payload()) now accepts network_id=None: netmail imports normally (NetmailMessage.network_id is nullable) and, if addressed to a real local user, still resolves and notifies that user exactly as any other netmail would — this is the actual deliverable, since a sysop's own netmail inbox query filters by recipient only, not by network. Echomail in the same packet is logged and dropped rather than imported, since EchoArea/EchomailMessage.network_id is NOT NULL and an unlisted caller has no real subscription to route it against. AreaFix/FileFix netmail from an unrecognized peer still fails closed with "Network not configured" and applies no subscription change — that safeguard (process_request(network=None, ...)) already existed from an earlier audit and needed no changes, just confirmed it's reached safely with network_id=None instead of raising.
3 new tests (tests/test_binkp_anonymous_crashmail.py) cover the import-level netmail/echomail split and the AreaFix fail-closed path at the DB level; 1 existing test in tests/test_binkp_multi_hub_identity.py (test_unknown_address_gets_err_not_ok, renamed test_unknown_address_now_accepted_as_anonymous_crashmail) updated to assert the new M_OK/accept outcome instead of the old M_ERR/reject one. tests/test_binkp_server_engine_disposed.py's disposal-count assertion needed no change — the per-connection engine still gets disposed exactly twice either way, just via the normal end-of-session finally block now instead of an early-return path.
v1.0.2 — Real bug: a URL inside dense ANSI art corrupted the message layout (August 2026)
Found live: a sysop composed a CP437 ad screen (bordered box, shading bar, a centered https:// URL inside one row) and posting it in the web UI corrupted the box border and everything at and after that row — the row's intended white padding turned into default-gray blanks with wrong spacing.
Root cause: the URL auto-linkifier (_linkify() in anetbbs/web/render_msg.py) split the message text into fragments around each matched https?:// URL and ran the CP437/ANSI grid renderer (_to_html_vt/_run_vt) independently on each fragment. That renderer always pads a row out to the full 80-column width with default-color blanks when it reaches the end of its input mid-row — it has no way to know the row was artificially cut short by the URL split rather than genuinely ending there. Any row with a URL inside dense box-drawing/shaded art got its layout corrupted this way; plain-colored text or art with no shading characters never triggered the bug (the dispatch to the grid renderer is gated on the presence of solid block/shade glyphs █▄▀▌▍▎▏░▒▓, which is why a border-only repro didn't reproduce it during debugging, only the real shading-bar-containing ad screen did).
Fixed by rendering the full message as one continuous pass, then substituting the clickable link into the already-rendered HTML afterward — the grid renderer always sees the complete, uncut text now, so a URL landing mid-row can no longer corrupt that row's layout. 5 new tests, confirmed to fail against the old code and pass against the fix.
v1.0.1 — Unclaimed-netmail AreaFix-reply noise; File Areas network filter (August 2026)
Two fixes/features found while wrapping up the v1.0.0 rollout.
Unclaimed netmail piling up with AreaFix confirmation noise. Admin → Echomail → Unclaimed Netmail was meant to exclude AreaFix/FileFix bot traffic from its review queue, but the filter only checked the recipient name (to_name). It missed the reverse case: a peer's AreaFix robot replying with an automated "AREAFIX response" confirmation, generically addressed To: Sysop (a common FTN default, not a real local username) rather than To: AREAFIX. Found live: 50+ of these had piled up unbounded on a single network, all pure noise. Fixed by checking both to_name and from_name against the bot-name list — extracted into a shared _unclaimed_netmail_query() helper so the list view and the new bulk-clear action below can't drift out of sync. Also added an admin-only "Clear All" button (same filter criteria as the list, not a raw wildcard delete) so an existing backlog can be discarded in one click instead of one-at-a-time. 6 new tests.
File Areas had no way to filter by network. A real usability complaint once a sysop has enough file areas (local + several FTN networks) that hunting through one long flat list gets tedious. New client-side "Show:" filter dropdown on Admin → File Areas — no new route, no server round-trip — reusing the same data-network-id values the existing bulk-select-network dropdown already relies on. "Select all" now only selects currently-visible areas (previously it selected everything regardless of any filter, which combined with a filtered view and a bulk-delete could have silently caught areas from a different network scrolled off-screen). 3 new tests.
v1.0.0 — Full release (August 2026)
Primarily the version cutover from the internal beta build-number
scheme to standard semantic versioning, marking ANetBBS's first stable
release — no other behavior changes from v1.0b2.239.
One real fix caught live during this rollout: a sysop ran update.sh
and got a garbled warning — nginx /mrcws proxy points at
port 127 followed by 0, 0, 1, and 8080 each on their own line
— on an install that was actually configured correctly. The MRC
nginx-proxy verification check (added v1.0b2.232-235) extracted the
configured port with grep -oE '127\.0\.0\.1:[0-9]+/ws;' ... | grep
-oE '[0-9]+', but that second grep matches every run of digits in
the matched line, not just the port — 127.0.0.1:8080/ws; contains
five separate digit runs (127, 0, 0, 1, 8080), so the
extracted value could never equal the bridge's actual port and the
check false-positived on every correctly-configured install. Fixed by
capturing just the port group with sed instead. 3 new tests run the
real extraction line from update.sh in actual bash against synthetic
nginx configs, so this can't silently regress again.
A second real fix, also caught live: Admin → Upgrades 500'd with
AttributeError: 'str' object has no attribute 'strftime' the moment
it tried to display the upstream release's publish date.
datetime.fromisoformat() only accepts a trailing Z (the shape
/api/releases/latest actually emits) as of Python 3.11 — the live
server's venv is 3.10. Fixed by normalizing a trailing Z to +00:00
before parsing in to_eastern(). Also hardened fmt_eastern() to
check the value is actually a datetime before calling .strftime()
on it — to_eastern()'s own docstring already promised to fail open
and return the raw string on genuinely unparseable input, but
fmt_eastern() wasn't honoring that contract, so any malformed
timestamp (not just this one) would have crashed the same way. 2 new
tests, including one reproducing the exact reported crash.
Also rewrote FILE_ID.DIZ: the old one had grown to 18 lines, well
past the classic BBS-standard 10-line/45-char DIZ convention. Now 9
lines, leads with the version and "Full Release!", and calls out the
web UI explicitly right after the terminal-access list (telnet/SSH/
rlogin/PETSCII) rather than folding it in as just one more word among
them. Confirmed (with 2 new tests) that a FILE_ID.DIZ nested inside
the release tarball's own ANetBBS-v1.0.0/ wrapper directory — not at
the archive root — still gets picked up correctly if a sysop uploads
the release tarball to a file area.