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, 0xFF is IAC and a compliant client doubles it on the wire (RFC 854/856); the bridge never undid that doubling, so every 0xFF byte 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 — but send_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 in tests/test_xfer_telnet_framing.pyxfer.py had 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, and echomail were selectable and would sit there showing "never called" forever, since nothing in the codebase ever called fire() 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 in docs/23-webhooks.md, which also covers the (lack of) HMAC signing, no-retry delivery semantics, and other gotchas already found documenting the pre-existing shout event). 8 new tests in tests/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 returns conf_num/from_name/to_name, so every field silently fell back to its default instead of raising a KeyError. conference defaulting to 0 meant 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 in tests/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 with DetachedInstanceError after 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 NULL regardless of transport — but QWK never sets sent_at at 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 where sent_at actually 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 in tests/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_LASTCALLERS tag — 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 stuck QWKNodeRequest rows 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_WALL echomail 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 tagged origin_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. Only service and 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@domain and bare addr) in the same M_ADR line. Real binkd treats each form as a separate token and self-collides on its own busy-lock for the second one, dropping the session with Secure AKA busy before 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.py covering the loop-prevention invariant, global (not per-area) dedup, NULL-msg_id handling, and the IP-address privacy guarantee, plus 4 more in tests/test_binkp_dual_adr_fix.py and tests/test_binkp_server_single_adr_fix.py for 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.py by hand reported "nothing to clean up" even though the real database had hundreds of duplicate rows, no error either way. Root cause: python-dotenv has been a declared dependency (requirements.txt, setup.py) for a long time, but load_dotenv() was never actually called anywhere in this codebase. The real systemd services (anetbbs.service, anetbbs-web.service, etc.) worked fine regardless, because they set EnvironmentFile=/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-shot tools/*.py maintenance script, a bare python -m ..., even just testing something at a prompt — never saw .env at all, so DATABASE_URL came back unset and config.py silently fell back to DevelopmentConfig's anetbbs_dev.db path instead of the real anetbbs.db. This wasn't unique to the new dedupe script either — the pre-existing tools/purge_bad_qwk_areas.py had the exact same latent gap. Fixed by calling load_dotenv(BASE_DIR / '.env') once at import time in anetbbs/config.py, before any os.environ.get(...) calls read from it; load_dotenv()'s default override=False guarantees it can never clobber a value systemd already injected, so the real services' behavior is unchanged. 3 new tests in tests/test_dotenv_loading.py. (One of those tests initially broke unrelated tests elsewhere in the suite by deleting anetbbs.config from sys.modules to force a fresh re-import — that wiped other test files' TestingConfig.SQLALCHEMY_DATABASE_URI monkeypatches when the whole suite ran in one process. Fixed by testing load_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's msg_id from 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, so msg_id came back None for nearly every inbound message. The dedup check in poller.py:_import_message (if msg_id: ...) silently no-ops whenever msg_id is 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 since EchoArea.total_messages only increments on a real committed insert, this was genuine duplicate data accumulating in the database, not a cosmetic display bug. Because tosser.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-hash msg_id whenever 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. New tools/dedupe_qwk_messages.py one-shot maintenance script (dry-run by default, --apply to execute) wipes affected QWK areas so the next poll repopulates them cleanly under the fixed logic. 5 new tests in tests/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 \n with the Python string literal '\xe3' (Unicode codepoint U+00E3, "ã") before CP437-encoding the message body. CP437 has no character mapped to U+00E3, so encode(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/\n are plain ASCII and survive the encode step unchanged, so a byte-level replace is safe and unambiguous). 2 new tests in tests/test_qwk_packet_roundtrip.py covering 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.password were declared DataRequired(), which rejected the entire form submission whenever the password box was left blank — which is always, since PasswordField never 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 making password optional 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 in tests/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 cCSI < 0 ; Ps... c, where flag 4 means 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 in tests/test_sixel_detection.py cover 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_mode preference at all — only the web /profile/edit page 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.md claimed python -m unittest discover needed no pytest install, but tests/test_mrc_integration.py uses real @pytest.fixture decorators — that file fails to even import without pytest present, breaking a full local unittest discover run for anyone who hadn't separately installed it. Added requirements-dev.txt (-r requirements.txt plus pytest>=7.0.0) and corrected the docs to install it first. Verified: a fresh venv with only requirements-dev.txt installed runs the full 258-test suite (256 unittest-discovered + the 3 pytest-fixture tests in test_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 run command referenced /usr/local/bin/entrypoint-single.sh, but the Dockerfile never copied the script there — only chmod +x'd it at its original /app/docker/single/entrypoint-single.sh path. Every quick-start attempt failed immediately with "no such file or directory". Fixed by copying it to /usr/local/bin like the other two entrypoints.
  • FIX: the terminal service (both single-container docker/single/supervisord.conf and docker-compose's terminal: service) invoked the bare anetbbs console-script, which failed with ModuleNotFoundError: No module named 'anetbbs' — its editable-install shim (pip install -e ., built against /src in the Dockerfile's builder stage) doesn't survive into the runtime image, and the console-script's own sys.path[0] is the directory it lives in (/usr/local/bin), not the working directory, so directory=/app alone didn't fix it either. Fixed by invoking python -m anetbbs.main directly 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 /mrcws only 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.py computed a bridge_ws_url that the actual page never read; the real WebSocket URL is built client-side in mrc/index.html's buildWsUrlForChoice() (multi-server picker), hardcoded to location.host with no override mechanism at all. Fixed by rendering a window.MRC_WS_HOST_OVERRIDE JS global (empty for nginx-fronted deployments, which stay on the already-correct location.host default; browser-hostname + bridge-port for ANETBBS_RUNTIME=docker-single; the explicit public MRC_BRIDGE_HOST for 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) — updated docs/22-containers.md to reflect that. The docker-compose path is still only unit-tested with mocked Docker calls. 5 new tests in tests/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_mode profile preference (auto/forced_on/forced_off, default auto, 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 called socketio.emit() directly from the PTY-reader thread; now marshaled through a proper thread-safe queue drained by a socketio.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 the cp437 decode — 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 across tests/test_sixel_detection.py and tests/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 only str(exc), which can be empty or unhelpful for some exception types (bare socket.timeout, some ConnectionErrors); 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 new EchomailPollLog.transcript column — 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 in tests/test_binkp_transcript.py.

v1.0b2.46 — Fill the two docs gaps flagged after v1.0b2.45 (July 2026)

  • WIKI: new dedicated Notifications page — 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-panel page 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.md and docs/20-federation.md updated 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 a status='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.md gets a new "Admin notifications" section covering all five admin-review notification kinds (v1.0b2.42). Wiki pages qwk and anothernetwork updated 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 new network_join_enabled context processor. 1 new test covering all four combinations of logged-in/enabled state.