ANetBBS Changelog
Versions are internal build numbers. Public releases are tagged
separately. Current release: v1.0b2.58 (July 2026). Full release: August 1 2026.
v1.0b2.58 — Fix session disconnect right after a telnet transfer completes (July 2026)
- FIX (live-caught testing v1.0b2.57 against a real telnet client): a ZMODEM download completed correctly (byte-perfect, the actual fix worked), but the session disconnected straight to the goodbye screen the instant the transfer finished. Root cause: the transfer's own reader task — the one whose telnet-unescape codec consumes the client's replies to our BINARY-mode negotiation — was cancelled before the post-transfer "turn BINARY back off" command was sent. The client dutifully replies to that with its own WONT/DONT bytes, but nothing was listening anymore; those bytes sat in the socket buffer and got picked up raw by the very next normal read (the "Press Enter..." prompt), which read them as a disconnect. The "turn BINARY on" side never had this problem, since the transfer's reader is already running by the time that reply comes back — only the "turn it back off" step was affected. Fixed by not sending that command at all: leaving a session in telnet BINARY mode for the rest of the connection is standard, harmless practice (it only suppresses NVT ASCII translation, no effect on normal text/ANSI output), and avoids the reply-timing hazard entirely. Same underlying feature as v1.0b2.57 — see that entry for credit to andy5995's original PR #6 diagnosis.
v1.0b2.57 — Fix ZMODEM/XMODEM/YMODEM corruption on RFC-compliant telnet clients (July 2026)
- FIX: the terminal file-transfer bridge (
xfer.py) fed raw socket bytes straight to sz/rz and wrote sz/rz output straight to the socket. On a telnet channel,0xFFis IAC and a compliant client doubles it on the wire (RFC 854/856); the bridge never undid that doubling, so every0xFFbyte in a transfer reached rz as a spurious extra byte and corrupted the stream, which ZMODEM answered with ZCAN. Lenient clients that send raw 8-bit bytes with no telnet processing happened to work by accident; anything RFC-compliant did not. Root-caused and originally patched by andy5995 (GitHub PR #6, drafted with Claude Opus 4.8's assistance) — thank you for tracking this down. Their fix added a telnet escape/unescape codec plus BINARY+SGA negotiation around the transfer, which is correct for telnet — butsend_file()/recv_file()are shared by telnet, SSH, and rlogin sessions with no protocol check anywhere before they're called, and SSH/rlogin channels have no IAC concept at all (already 8-bit clean at the transport layer), so applying that codec unconditionally would have fixed telnet by corrupting SSH/rlogin transfers instead. Landed with a protocol gate added on top so the new codec only ever runs for telnet sessions; SSH/rlogin pass bytes through exactly as before. 21 new tests intests/test_xfer_telnet_framing.py—xfer.pyhad no test coverage prior to this.
v1.0b2.56 — Wire up the remaining 7 webhook event types (July 2026)
- FEATURE: the webhooks admin form (Admin → Webhooks) has always offered 8 event types, but only
shout(shoutbox posts) actually fired anything —post,bulletin,login,achievement,broadcast,sysop_page, andechomailwere selectable and would sit there showing "never called" forever, since nothing in the codebase ever calledfire()for them. All 7 are now wired to their real trigger points: new board posts and replies (both web and terminal), sysop-created bulletins, logins (web, telnet, SSH, and rlogin — all four, not just web), newly-awarded achievement badges, multinode "Local Chat" broadcasts, sysop pages, and newly-imported inbound echomail messages (BinkP and QWK alike). Each event has its own payload shape (documented indocs/23-webhooks.md, which also covers the (lack of) HMAC signing, no-retry delivery semantics, and other gotchas already found documenting the pre-existingshoutevent). 8 new tests intests/test_webhooks_all_events_wired.py. - New doc:
23-webhooks.md— full sysop-facing setup guide covering all 8 events, payload formats, the Bearer-secret auth model (not HMAC), delivery mechanics, and a worked Discord example.
v1.0b2.55 — Fix QWK REP uploads never actually importing (July 2026)
- FIX (live-caught, found while verifying the v1.0b2.54 conference-number fix end to end): a reply uploaded from a QWK node showed "sent" on the sending side but never appeared on the hub, with no error visible anywhere a sysop would look. Root cause:
process_rep_upload()(the hub's REP-file importer) read the parsed message dict using the wrong key names (conference/from/to) — the parser actually returnsconf_num/from_name/to_name, so every field silently fell back to its default instead of raising a KeyError.conferencedefaulting to0meant every uploaded reply, from every QWK node, always resolved to conference 0 (private mail) — which no node ever has a real subscription to — so the message was silently dropped every time. This was never specific to one node or one test message: inbound REP processing has been completely non-functional since this function was written, for every QWK node on the hub. Fixed by reading the correct keys. 1 new regression test intests/test_qwk_rep_upload_detached_instance_fix.py, which also covers a second, unrelated bug found in the same investigation: a trailing log statement outside the function's app-context block crashed withDetachedInstanceErrorafter a successful import, misleadingly logging "REP processing failed" for uploads that had actually already succeeded. - FIX: the "Pending Outbound" dashboard counter (and the terminal sysop status screen's matching "Echo out (queue)" stat) counted
sent_at IS NULLregardless of transport — but QWK never setssent_atat all (delivery is tracked via a per-node high-water mark instead), so every QWK message ever sent stayed counted forever whether it was actually delivered or not. The number could only ever climb, never reflect reality. Scoped to BinkP only, the one transport wheresent_atactually means "not yet sent." 1 new test.
v1.0b2.54 — Fix QWK areas silently dropping messages, InterBBS Wall/Last Callers restricted to BinkP (July 2026)
- FIX (live-caught testing InterBBS Wall against a real QWK-connected node): a message posted from a QWK node vanished with no error anywhere — reported "sent" locally, never arrived at the hub. Root cause: the QWK wire format only ever carries a numeric conference number (no field for anything else), but the ANotherNetwork install seeder created the QWK-side copy of all 26 built-in echo areas using the same symbolic tag as the BinkP side (
ANN.LINUX,ANN.GENERAL, etc.) A post into one of these fell back to conference 0 (reserved for private mail), and the hub's REP importer has no subscription ever registered at conf 0, so the message was dropped outright, not just misfiled — affecting every install that had ever activated the QWK side of this network, not one area in isolation. Underneath that, conference numbers were being assigned per-node-subscription (auto-incremented independently each time a node subscribed) rather than as a fixed property of the area itself, so two different nodes could even get two different numbers for "the same" area. Fixed with a stable, fixed conference number per area (shared by every subscriber), a self-healing migration that renumbers any already-seeded install's QWK areas and fixes existing subscriptions to match on next startup, and form validation that blocks creating a QWK-network area with a non-numeric tag going forward. 10 new tests intests/test_qwk_conf_number_fix.py. - FIX: InterBBS Wall/Last Callers admin network picker offered QWK networks even though QWK's numeric-only wire format can never carry the special
ANET_WALL/ANET_LASTCALLERStag — restricted to BinkP networks only, with a server-side guard against a stale QWK network id too. - FIX: enabling InterBBS Wall/Last Callers against a valid BinkP network created nothing at all in Echomail Areas — the special area was only ever created lazily, the first time someone posted new local content after enabling. Now created eagerly the moment the feature is enabled.
- FEATURE: QWK hub's "Subscribe to All" bulk action swept in every QWK network on the install with no way to scope it to just one — added per-network checkboxes. BinkP had no bulk-subscribe at all (only one area at a time); added the same feature with the same checkboxes.
- New
tools/manage_qwk_requests.py— list/hard-delete stuckQWKNodeRequestrows that show pending indefinitely (e.g. an application submitted before a feature was finished, later abandoned). - 21 more new tests across
tests/test_interbbs_binkp_only.py,tests/test_qwk_subscribe_all.py,tests/test_binkp_subscribe_all.py.
v1.0b2.53 — InterBBS Wall + InterBBS Last Callers (July 2026)
- FEATURE: opt-in InterBBS Graffiti Wall — share Wall posts with other ANetBBS installs over a dedicated
ANET_WALLechomail area, riding the existing QWK/BinkP transport (real per-node auth, FTN dedup/threading fields, hub subscribe/approve UI) instead of a bespoke sync protocol, matching how fsxnet's own wall echo works. Toggle + network picker on the Wall admin page; a scheduled job (auto-created on enable, every 15 min) imports inbound posts. Remote posts are tagged with their origin BBS in the moderation view. Loop-prevention: an imported post is taggedorigin_bbs, and the relay hook refuses to ever re-relay a tagged post — the only thing standing between this design and an infinite bounce between two hubs, since a re-composed message would get a brand-new msg_id no downstream dedup could catch. - FEATURE: Last Callers — new paginated terminal screen (mirrors the existing "Last 10 Callers" style already on the one-liners screen) plus a same-shape opt-in InterBBS sharing toggle. Fixed a real gap found while building this:
CallerLog(the "last callers" table) was only ever written from the web login route — telnet/SSH/rlogin logins never recorded one, so "Last Callers" only ever showed web users on an otherwise telnet-first BBS. Onlyserviceand login time are ever shared over InterBBS — IP addresses are never relayed across BBS boundaries. - FIX: BinkP handshakes advertised each address in two forms at once (
addr@domainand bareaddr) in the sameM_ADRline. Real binkd treats each form as a separate token and self-collides on its own busy-lock for the second one, dropping the session withSecure AKA busybefore password checking ever runs — happens both when polling out to a real binkd hub and when a real binkd hub polls in. Fixed on both the outbound and inbound sides to send one address form only. - 17 new tests in
tests/test_interbbs_sync.pycovering the loop-prevention invariant, global (not per-area) dedup, NULL-msg_id handling, and the IP-address privacy guarantee, plus 4 more intests/test_binkp_dual_adr_fix.pyandtests/test_binkp_server_single_adr_fix.pyfor the BinkP fix. Full suite: 291/291 passing.
v1.0b2.52 — Fix .env never actually being loaded (July 2026)
- FIX: found live, mid-incident, while walking the sysop through cleaning up the v1.0b2.51 QWK duplicate-message backlog on the Pi3 — running the new
tools/dedupe_qwk_messages.pyby hand reported "nothing to clean up" even though the real database had hundreds of duplicate rows, no error either way. Root cause:python-dotenvhas been a declared dependency (requirements.txt,setup.py) for a long time, butload_dotenv()was never actually called anywhere in this codebase. The real systemd services (anetbbs.service,anetbbs-web.service, etc.) worked fine regardless, because they setEnvironmentFile=/opt/anetbbs/.env, which injects.env's key=value pairs as real OS environment variables before Python even starts. But any script run by hand from an interactive shell — a one-shottools/*.pymaintenance script, a barepython -m ..., even just testing something at a prompt — never saw.envat all, soDATABASE_URLcame back unset andconfig.pysilently fell back toDevelopmentConfig'sanetbbs_dev.dbpath instead of the realanetbbs.db. This wasn't unique to the new dedupe script either — the pre-existingtools/purge_bad_qwk_areas.pyhad the exact same latent gap. Fixed by callingload_dotenv(BASE_DIR / '.env')once at import time inanetbbs/config.py, before anyos.environ.get(...)calls read from it;load_dotenv()'s defaultoverride=Falseguarantees it can never clobber a value systemd already injected, so the real services' behavior is unchanged. 3 new tests intests/test_dotenv_loading.py. (One of those tests initially broke unrelated tests elsewhere in the suite by deletinganetbbs.configfromsys.modulesto force a fresh re-import — that wiped other test files'TestingConfig.SQLALCHEMY_DATABASE_URImonkeypatches when the whole suite ran in one process. Fixed by testingload_dotenv()directly instead, which never needed the module-cache manipulation in the first place.)
v1.0b2.51 — Fix QWK message duplication, CP437 body corruption, and node-edit lockout (July 2026)
- FIX: a sysop reported the Pi3 test install (subscribed as a QWK client to the real ANotherNetwork hub) showing wildly inflated area message counts — 220 messages in an area that should have had roughly 22 — while the main live server didn't show the same problem. Root cause:
_parse_messages_dat()(anetbbs/echomail/qwk.py) only populated a message'smsg_idfrom a literal@MSGID:kludge line in the body. Vanilla QWK hubs that don't tunnel FTN kludges (the normal case — confirmed against the real ANotherNetwork hub) never send one, somsg_idcame backNonefor nearly every inbound message. The dedup check inpoller.py:_import_message(if msg_id: ...) silently no-ops whenevermsg_idis falsy, and since there's no persisted per-network poll checkpoint anywhere in this codebase either, any poll that received any content overlapping a previous poll re-imported every message as brand new — and sinceEchoArea.total_messagesonly increments on a real committed insert, this was genuine duplicate data accumulating in the database, not a cosmetic display bug. Becausetosser.toss_message()runs unconditionally after any poll that imports messages, this could also have forwarded duplicates to any downstream nodes subscribed to the same area. Fixed by synthesizing a deterministic content-hashmsg_idwhenever no real@MSGID:is present — the same real message re-served on a later poll now hashes identically and gets caught by the existing dedup check — mirroring a fallback the outbound REP-packet writer already had for the same problem in the other direction. Newtools/dedupe_qwk_messages.pyone-shot maintenance script (dry-run by default,--applyto execute) wipes affected QWK areas so the next poll repopulates them cleanly under the fixed logic. 5 new tests intests/test_qwk_inbound_msgid_dedup.py, including an end-to-end simulation of the exact bug (same content parsed on two overlapping polls asserts only one row is created). - FIX: found while testing the above —
_build_messages_dat()(the QWK hub's own outbound packet writer,anetbbs/echomail/qwk_hub_ftp.py) built the QWK paragraph-separator by replacing\nwith the Python string literal'\xe3'(Unicode codepoint U+00E3, "ã") before CP437-encoding the message body. CP437 has no character mapped to U+00E3, soencode(errors='replace')silently substituted a literal?in place of every line break instead of the real QWK separator byte (raw 0xE3) — corrupting every multi-paragraph message this BBS sends outbound to downstream QWK nodes. Fixed by doing the newline substitution at the byte level, after CP437 encoding (\r/\nare plain ASCII and survive the encode step unchanged, so a byte-level replace is safe and unambiguous). 2 new tests intests/test_qwk_packet_roundtrip.pycovering both multi-line plain text and extended CP437 characters (box-drawing, block glyphs). - FIX: editing an existing BinkP or QWK hub node — for example to fix a sysop's typo'd tag or BBS name — was silently impossible without also retyping a brand-new password.
QWKNodeForm.password/BinkPNodeForm.passwordwere declaredDataRequired(), which rejected the entire form submission whenever the password box was left blank — which is always, sincePasswordFieldnever re-renders a stored value on a GET of the edit page. The route logic (if form.password.data: node.password = ...) and the template's own "Leave blank to keep current password" hint both already assumed blank-means-keep-current; the validator just made that path unreachable. A sysop hitting this could only fix a typo by resetting credentials that node's own sysop might not have safely on hand, or by deleting and re-registering the node from scratch — reported live after exactly that happened. Fixed by makingpasswordoptional on both forms, with an explicit "password required" check added to the two node-creation routes so a brand-new node still can't be saved with a blank password — only editing an existing node's other fields without touching its password is now possible. 4 new tests intests/test_hub_node_edit_password.py.
v1.0b2.50 — Fix SyncTerm sixel auto-detect + terminal sixel profile option (July 2026)
- FIX: a sysop reported sixel auto-detect (
sixel_mode= Automatic) never worked on SyncTerm, even though forcing it on/off always worked and auto-detect worked fine on other sixel-capable clients (proving the DA1 mechanism itself wasn't generally broken). Root cause, confirmed against SyncTerm's own CTerm manual: SyncTerm's DA1 reply doesn't use the standard?-prefixed capability-flag list that xterm/mlterm/wezterm/contour use — it spells "CTerm" out in decimal ASCII (CSI = 67;84;101;114;109;rev c) and never reports sixel support in that reply at all, regardless of whether the terminal actually has it. Sixel support is only exposed through a second, CTerm-specific extended device-attributes query (CSI < 0 c→CSI < 0 ; Ps... c, where flag4means pixel/sixel graphics are supported)._detect_sixel_support()(anetbbs/features/bbs_ui.py) now recognizes the CTerm signature in the primary DA1 reply and, only then, sends the follow-up CTDA query and checks flag 4 there — other terminals are untouched, still a single round-trip. 3 new regression tests intests/test_sixel_detection.pycover the xterm-style path (no follow-up query sent), a bare CTerm DA1 reply with no CTDA response available (must not be misread as sixel support), and the full CTerm → CTDA handshake. - FEATURE: the terminal "Edit Profile" menu (telnet/ssh) had no way to set the
sixel_modepreference at all — only the web/profile/editpage did, since the preference shipped in v1.0b2.48. Added a matching Automatic/Always On/Always Off prompt to_edit_profile(). - FIX (dev tooling):
docs/17-development.mdclaimedpython -m unittest discoverneeded no pytest install, buttests/test_mrc_integration.pyuses real@pytest.fixturedecorators — that file fails to even import without pytest present, breaking a full localunittest discoverrun for anyone who hadn't separately installed it. Addedrequirements-dev.txt(-r requirements.txtpluspytest>=7.0.0) and corrected the docs to install it first. Verified: a fresh venv with onlyrequirements-dev.txtinstalled runs the full 258-test suite (256 unittest-discovered + the 3 pytest-fixture tests intest_mrc_integration.py, 2 of which skip because the MRC bridge isn't present in a bare dev checkout) with zero import errors.
v1.0b2.49 — Three real Docker bugs found testing against an actual daemon (July 2026)
- FIX: the single-container "quick start" image's documented
docker runcommand referenced/usr/local/bin/entrypoint-single.sh, but the Dockerfile never copied the script there — onlychmod +x'd it at its original/app/docker/single/entrypoint-single.shpath. Every quick-start attempt failed immediately with "no such file or directory". Fixed by copying it to/usr/local/binlike the other two entrypoints. - FIX: the terminal service (both single-container
docker/single/supervisord.confand docker-compose'sterminal:service) invoked the bareanetbbsconsole-script, which failed withModuleNotFoundError: No module named 'anetbbs'— its editable-install shim (pip install -e ., built against/srcin the Dockerfile's builder stage) doesn't survive into the runtime image, and the console-script's ownsys.path[0]is the directory it lives in (/usr/local/bin), not the working directory, sodirectory=/appalone didn't fix it either. Fixed by invokingpython -m anetbbs.maindirectly instead, matching how every other service (web, mrc-bridge, finger, binkp) already starts. - FIX: MRC web chat 404'd in single-container mode (terminal MRC worked fine — it's a direct server-to-server bridge connection, no browser/nginx involved). Root cause: the single-container image has no nginx, so
/mrcwsonly exists on the MRC bridge's own port (8080, published directly), not the web app's port (5000) the browser was requesting it on. The initial fix targeted the wrong file —anetbbs/web/mrc_web.pycomputed abridge_ws_urlthat the actual page never read; the real WebSocket URL is built client-side inmrc/index.html'sbuildWsUrlForChoice()(multi-server picker), hardcoded tolocation.hostwith no override mechanism at all. Fixed by rendering awindow.MRC_WS_HOST_OVERRIDEJS global (empty for nginx-fronted deployments, which stay on the already-correctlocation.hostdefault; browser-hostname + bridge-port forANETBBS_RUNTIME=docker-single; the explicit publicMRC_BRIDGE_HOSTfor docker-compose) that the JS now actually consumes. Also documented the resulting tradeoff: since the browser talks to the bridge directly in both Docker modes, the bridge itself has no login check of its own — don't publish port 8080 to a public interface unless you're OK with unauthenticated MRC access. - Real-world testing note: this is the first time the single-container Docker path has been run end-to-end against an actual Docker daemon (build, run, all 5 services reaching
RUNNING, web + terminal + MRC web + MRC terminal all confirmed working after these fixes) — updateddocs/22-containers.mdto reflect that. The docker-compose path is still only unit-tested with mocked Docker calls. 5 new tests intests/test_mrc_web_docker_routing.py.
v1.0b2.48 — Sixel capability preference + door-game output queue fix (July 2026)
- FEATURE: scoped the highest-priority piece of a Firehawke feature request ("Fuller CTerm support and Display Codes") — sixel detection. Found sixel auto-detection (DA1 device-attributes query) already existed but was dead code in practice: the RSS reader's entry point unconditionally asked a manual "Does your terminal support sixel? [Y/N]" prompt on every session, pre-populating the same cache flag the DA1 detector checks first, so the DA1 logic never actually ran in production. It was also RSS-specific and per-session only, with no way to force it on for a client that supports sixel but doesn't self-report via DA1 (e.g. Windows Terminal over SSH), or force it off. Added a new
sixel_modeprofile preference (auto/forced_on/forced_off, defaultauto, editable at/profile/edit), promoted the detector to a general-purpose_detect_sixel_support()usable by any feature, and fixed the RSS reader to actually call it instead of the old always-on manual prompt. - FIX: door-game output (
anetbbs/web/games.py) previously calledsocketio.emit()directly from the PTY-reader thread; now marshaled through a proper thread-safe queue drained by asocketio.start_background_task(), matching documented Flask-SocketIO practice — purely additive, same order and content for the text output that already works. Added alongside a DEBUG-level diagnostic that logs sixel/DCS-shaped output chunks, to help confirm (on a real DSR test session, not reproducible in a sandbox) whether 8-bit C1 control-code framing is getting silently corrupted by thecp437decode — a known, already-documented dead end where sixel image rendering has never worked through the gunicorn-spawned PTY chain for Synchronet-compatible doors, despite the frontend already having working sixel rendering capability (xterm-addon-image). 9 new tests acrosstests/test_sixel_detection.pyandtests/test_door_output_queue.py.
v1.0b2.47 — Full BinkP session transcripts for failed polls (July 2026)
- FEATURE: a sysop reported not having enough BinkP logging to diagnose a failing session. Two real gaps found: the generic poll-failure handler (
anetbbs/echomail/poller.py) stored onlystr(exc), which can be empty or unhelpful for some exception types (baresocket.timeout, someConnectionErrors); and there was zero frame-level logging anywhere in the BinkP client —_send_cmd()/_send_data()logged nothing, received frames were only logged sporadically during the handshake. Fixed both: every poll failure's error message now always includes the exception type name, and every BinkP poll now captures a full, timestamped, frame-by-frame transcript (what was sent, what was received, connect/disconnect) into a newEchomailPollLog.transcriptcolumn — viewable from a new "Transcript" link on the Poll Logs admin page, so a sysop can see exactly what happened on the wire without needing server SSH/journalctl access at all. The transcript survives even when a session fails partway through (captured into a list owned by the poller, not the BinkP client object itself, since the client goes out of scope with the exception). Capped in size (~500 lines / ~100KB) since there's no retention/cleanup for poll-log rows. Client-side only for now (a sysop's own install polling out) — the inbound/server side has no equivalent session model today, noted as a natural follow-up. 14 new tests intests/test_binkp_transcript.py.
v1.0b2.46 — Fill the two docs gaps flagged after v1.0b2.45 (July 2026)
- WIKI: new dedicated
Notificationspage — the[[Notifications]]wiki-link had pointed to a nonexistent page since before today's changes; now covers all five user-facing kinds and all five sysop admin-review kinds in one place.sysop-control-panelpage updated to link to it, and its "Hub Management" section now mentions the second (generic BinkP+QWK) join-request queue, the new Join Form tab, the QWK "Subscribe to All" button, and that new applications trigger a notification.
v1.0b2.45 — Document today's QWK/notification/join-form changes (July 2026)
- DOCS:
docs/06-echomail.mdanddocs/20-federation.mdupdated to describe the current self-referential-poll-skip behavior (no poll-log row created at all, as of v1.0b2.41 — was previously described as astatus='skipped'log entry), QWK node conference subscription management including the new "Subscribe to All" button (v1.0b2.40), and the new public "apply to join this network" form end to end (v1.0b2.43-44) — gating, applicant flow, infopack upload + rules-text auto-pick, the Join Requests review queue, and the approval logic.docs/02-sysop-daily-ops.mdgets a new "Admin notifications" section covering all five admin-review notification kinds (v1.0b2.42). Wiki pagesqwkandanothernetworkupdated to match, including fixing a self-introduced contradiction (the QWK page said "no self-service BinkP flow exists" right next to a new bullet describing exactly that).
v1.0b2.44 — Make the network join form discoverable (July 2026)
- FIX: the new
/join/page (v1.0b2.43) had no link to it anywhere except Hub Management's own "view live" button — a real visitor had no way to find it. Added a "Join Our Network" entry to the Tools nav dropdown, shown only to logged-in users (deliberately not exposed to anonymous visitors) and only when the feature is actually reachable (hub install + enabled), via a newnetwork_join_enabledcontext processor. 1 new test covering all four combinations of logged-in/enabled state.