ANetBBS Changelog — Beta History (pre-v1.0.0)
Archived history of every internal beta build number (v1.0a1.1 through
v1.0b2.239) from before ANetBBS's v1.0.0 full release in August 2026.
Preserved as-is — a real record of what changed at each step during
development — but split out of the main CHANGELOG.md
once it passed 6,000 lines, to keep that file scannable for the
v1.0.0-onward history that actually matters day to day. Newest-first,
same as the main changelog.
v1.0b2.88 — Fix Synchronet-JS door games leaking temp files on every launch (July 2026)
- FIX: found while a sysop was clearing space to get past the new disk-space check from v1.0b2.87 — a
/tmpdirectory listing on a real, long-running install turned up over a hundredanetbbs_*_synchronet_run.jsandanetbbs_*_synchronet_compat.jsfiles, none of them ever cleaned up. Traced toanetbbs/games/door_runner.py's_build_command(): fordoor_synchronetgames, when no real Synchronetjsexecbinary is installed (the common case for most sysops, who don't run a full Synchronet install alongside ANetBBS), it falls back to a Node.js compat shim and writes two files that must persist on disk for the life of the door process — a generatedsynchronet_compat.js(viaanetbbs/games/synchronet_compat.py'swrite_compat_script()) and a combinedsynchronet_run.jscontaining both the compat shim and the actual door script concatenated together (tempfile.NamedTemporaryFile(..., delete=False), since the file needs to still exist on disk after Python's own handle closes, for the forked Node.js child process to read). Both are legitimately necessary at creation time — the bug is that nothing, anywhere, ever deleted them afterward._cleanup_session()(door_runner.py), the single function responsible for releasing a door session's resources no matter how the session ended — PTY EOF, the browser/bridge closing the connection, forced termination, or the waitpid watcher reaping a crashed child — had no reference to these paths at all; they were local variables scoped entirely inside_build_command(), already out of scope by the time any session actually ends. A second, independent leak source doubled the damage:launch_door_game()'s terminal-session code path calls_build_command()a second time purely as a pre-flight validation, "so we can show a clear error" before the real launch — that call's return value (including the two temp files it creates) was discarded entirely, so every terminal-initiated Synchronet-JS launch attempt orphaned temp files twice, once from the validation dry run and once from the real launch. - Fixed by giving
_build_command()an optionaltemp_files_outlist parameter — when the Node.js compat path creates its two files, it appends both paths to this list if the caller provided one (Noneby default, so every pre-existing caller that doesn't care about cleanup keeps working unchanged; all other game types — native binaries, Mystic, DOSBox — never append anything, since they don't create files with this lifetime problem). DoorSession(the class already tracking a running door's PTY/process/bridge state) gained atemp_fileslist, populated from_build_command()'s collector right after a real launch succeeds.DoorSession.close()— already responsible for the exact same kind of cleanup fordoor_dosemu's pts symlink (self.pts_path) — now unlinks everything intemp_filesthe same defensive way (try/except OSError, tolerant of a file already being gone), so it's cleaned up through every single path that can end a session, not just the happy path.- Also added cleanup to
launch_door_game()'s own early-failure branches (PTY open failure, fork failure,_build_command()itself raising) — a session that never makes it far enough to construct aDoorSessionat all would otherwise still orphan any temp files created before the failure. - Fixed the validation-dry-run leak at its source: it now passes its own throwaway collector list and deletes everything in it in a
finallyblock immediately after the dry run completes (whether it succeeded or raised), since that code path never uses the built command for anything beyond checking whether_build_command()raises. - 5 new tests in
tests/test_door_synchronet_temp_cleanup.py, run against the real Node.js code path (a realgamestand-in, a real door script, real Node.js execution via the actual/usr/bin/nodeon the test machine, skipped only if no Node.js binary is present) rather than mocked — confirmstemp_files_outis populated with exactly the two real files the command actually depends on (including that the file_build_command()'s own returned command line points at is one of the tracked paths, so cleanup can never delete a file still needed to run the door), thatDoorSession.close()actually deletes them from disk, and that close() doesn't raise for game types with no temp files or for a file that's already gone.
v1.0b2.87 — update.sh: disk-space check + backup rotation + fix silent backup failure (July 2026)
- FIX/FEATURE: a real sysop reported their disk hit zero free space partway through running
update.sh, corrupting the install beyond recovery. They'd accumulated a dozen old pre-update backup snapshots without realizing it — nothing had ever pruned them — andupdate.shhad no check for available disk space before it started writing. Investigated by readingupdate.sh's actual backup step directly rather than assuming the existing safety net worked as expected, and found three real, concrete gaps: - No disk-space check anywhere.
update.shnever calleddfor checked free space at any point before starting Step 2 (the pre-update backup) or any later step. Added a pre-flight check requiring at least 500MB free on both/tmp(where backups land) and$INSTALL_DIR(where the update itself writes) before proceeding — on most single-disk VPS installs these are the same filesystem, but checking both costs nothing and covers a split-filesystem setup too. If either is below the threshold, the script now refuses to start at all, with a message pointing the sysop at Admin → Backups to clear old snapshots first, rather than proceeding into a disk-full corruption risk. - Backups accumulated forever, by design.
anetbbs/web/backups_admin.py's own docstring stated this explicitly: "Those dirs accumulate forever because we don't trust ourselves to GC them automatically." That caution turned out to be the wrong tradeoff in practice — unbounded accumulation of backup snapshots (each containing a full SQLite DB copy) is exactly how a sysop who wasn't actively managing them via the admin UI ended up silently consuming disk space until an update ran the filesystem to zero.update.shnow prunes to the 3 most recent pre-update backups after each successful backup, sorted by the existingYYYYMMDDHHMMSStimestamp in the directory name. Updated the now-stale docstring inbackups_admin.pyto match. - A real silent-failure bug in the backup step itself. The
.envbackup was two separate, unchained statements —cp "$ENV_FILE" "$BACKUP_DIR/.env.bak"followed unconditionally byok "Backed up .env"— meaning if thecpfailed for any reason (disk full being the obvious one), the script printed a success message anyway and continued straight into the actual update, with no valid.envbackup to roll back to if something then went wrong.backup_sqlite()(used for both the production and dev SQLite databases) had the same class of gap in its finalcpfallback path. All three backup steps now check their real result and abort the entire update immediately if a backup genuinely can't be written, rather than silently proceeding with what looks like a safety net but isn't one.backup_sqlite()deliberately treats a backup failure on eitheranetbbs.dboranetbbs_dev.dbas equally fatal, since some installs run their real production data against either filename via a customDATABASE_URLand there's no reliable way for the script to know which one is actually in use. - Verified the pruning logic and the disk-space arithmetic directly (created real timestamped backup directories and confirmed exactly the 3 newest survive; confirmed the free-space check correctly triggers on a simulated low-space value and reports accurate real numbers against the actual filesystem) before trusting either.
v1.0b2.86 — Fix SELinux blocking nginx's reverse proxy entirely on Fedora/RHEL (July 2026)
- FIX: found while live-debugging the v1.0b2.85 MRC fix on a real fresh Fedora install — even after that fix, web MRC still didn't connect, and it turned out the main web UI didn't fully work through nginx either (accessing the site required going straight to the app's own port). Diagnosed by walking through the actual evidence step by step rather than assuming the first fix was wrong: the MRC bridge's own log (
journalctl -u anetbbs-mrc-bridge) showed a completely clean, stable upstream connection with no errors at all, which ruled out the bridge itself; the browser-side "Connection lost — reconnecting" message turned out to originate fromanetbbs/static/mrc/client.js's own WebSocket reconnect logic (confirmed by reading the code — the message text is templated client-side,data.attempt/data.delayMscome from the browser's own exponential-backoff counter, not anything the Python bridge sends), meaning the browser's connection to the local bridge (via nginx's/mrcwsproxy) was the thing failing, not the bridge's connection to the real MRC network. That pointed straight at nginx, and nginx's own error log confirmed it directly:connect() to 127.0.0.1:5000 failed (13: Permission denied)— repeating for every proxied request, to both the main app's port and the MRC bridge's port alike. - Errno 13 (
EACCES) on an outboundconnect()call from nginx, on a system wherenginx -tpasses clean and the service itself reports "active (running)," is the well-documented signature of SELinux blocking it — Fedora/RHEL/CentOS ship SELinux enforcing by default, and nginx (running under thehttpd_tpolicy domain) is denied permission to open connections to arbitrary backend ports unless thehttpd_can_network_connectboolean is explicitly turned on. This is a stock, out-of-the-box SELinux restriction, not anything specific to this app — butinstall.shnever touched SELinux at all, on any Fedora/RHEL install, ever. - This fully explains every symptom observed on the fresh Fedora install: the main web UI needing the app's raw port instead of nginx's port 80/443, and web MRC "sometimes" appearing to connect before dropping again (SELinux denials aren't perfectly deterministic under concurrent connection attempts, so an occasional connection slipping through before the policy blocks the next one produces exactly this flaky, intermittent-looking pattern).
- Fixed in
install.sh: right after generating the nginx config (same step that writes/etc/nginx/sites-available/anetbbs), detects SELinux enforcing mode viagetenforceand, if enforcing, runssetsebool -P httpd_can_network_connect 1(the-Pmakes it persist across reboots, not just the current session). Systems without SELinux at all (Debian/Ubuntu, the other officially supported install targets) are completely unaffected —getenforcesimply doesn't exist there, so the whole check is skipped, not just a no-op branch. - Extended
update.shwith the matching self-heal check (samegetenforce/setseboollogic, gated on the boolean not already being on) so installs that predate this fix get it applied automatically on next update, following the same self-heal precedent established by the v1.0b2.85 MRC port fix in the same file. - This was found and fixed on the same day as, and is entirely independent of, the MRC-specific nginx port-mismatch bug fixed in v1.0b2.85 — both were real, separate bugs affecting the same fresh-install report, but this one is more fundamental on any SELinux-enforcing system: it blocks nginx's reverse proxy globally (the main app included), not just the MRC bridge specifically.
v1.0b2.85 — Fix web MRC never connecting on fresh installs (July 2026)
- FIX: web MRC chat consistently failed to connect on fresh installs — reproduced independently on multiple separate fresh installs, including a stock Fedora box, with identical results each time. Investigated by tracing
install.shin full alongside the actual bridge/web-side connection requirements, rather than guessing from the symptom. Root cause:install.sh's generated nginx config for thelocation /mrcwsblock (install.shline ~1473) hardcodedproxy_pass http://127.0.0.1:8080/ws;, but the MRC bridge's actual listen port is deliberately derived asWEB_PORT+1(MRC_BRIDGE_PORT_DEFAULT=$((WEB_PORT + 1)),install.shline 347) — 5001 on the defaultproduction-modeWEB_PORTof 5000. That derivation was introduced correctly in v1.0b2.35 to fix a different port-collision bug (the bridge and gunicorn both defaulting to 8080 in test/behind modes), and got wired correctly into.envand the bridge's ownconfig.jsonat the time — but the nginx/mrcwsproxy target was never updated to match, and the two pieces of port logic have been silently drifting apart ever since. The bridge process itself starts and runs with zero errors regardless (journalctl -u anetbbs-mrc-bridgeshows a perfectly healthy service bound to 5001), because it's never told anything is wrong — nginx is the only thing that knows about the mismatch, and it fails the WebSocket upgrade silently from the browser's perspective, with no signal anywhere ininstall.sh's own post-install health checks (which only confirm the service is active, not that nginx's proxy target actually points at it). This reproduces identically on every OS and every defaultproduction-mode install, which is the wizard's first-listed and default mode — exactly matching "happens on every fresh install, regardless of environment." - Fixed the hardcoded port in three places:
install.sh's generated nginx config (now interpolates${MRC_BRIDGE_PORT_DEFAULT}, the same variable already used correctly everywhere else in that block), the staticdeploy/anetbbs-nginx.conf.templatereference file (used for manualbehind-mode nginx setup — corrected to5001with a comment tying it toWEB_PORT), and.env.example(was documenting the same stale8080, now5001with an explanatory comment). - Extended
update.sh's existing nginx auto-repair logic (which already had precedent for self-healing known-bad nginx values from oldinstall.shtemplates, e.g. a prior/mrcwspath fix and a missing-block auto-insert) to detect this exact port mismatch — reads the real configured port from.env'sMRC_BRIDGE_PORT(which was always correct) and patches nginx's proxy target to match if they differ. This means installs that are already carrying this bug get it fixed automatically on their next update, not just future fresh installs. Also fixed the same stale8080/5000literals in the "add missing/mrcwsblock entirely" repair branch (for sysops upgrading from before web MRC existed), which now reads the real ports from.envtoo instead of hardcoding defaults that may not match a customized install. - Confirmed via direct code tracing (not assumption) that
/mrcwsis handled exclusively by nginx's proxy — Flask has no route for it at all (anetbbs/web/mrc_web.pyonly exposes the internal/mrc/auth-checkendpoint nginx'sauth_requestcalls, never the WebSocket itself). This meanstest/behindinstall modes, which default to nginx disabled, have no working path for web MRC at all, independent of the port bug — a real gap, not something a port fix alone resolves. Added explicit warnings to both modes' install-time summaries:testmode now states plainly that web MRC won't connect without nginx (with the fix being either switching toproductionmode or manually proxying/mrcws), andbehindmode's sample nginx block (which previously showed only the mainlocation /proxy) now tells the sysop they need two more locations fromdeploy/anetbbs-nginx.conf.template, since copying the printed example alone would never cover MRC. - Also confirmed terminal MRC (over SSH/telnet) is entirely unaffected by any of this —
anetbbs/features/mrc_chat.py'sMRCChatclass connects directly to127.0.0.1:{MRC_BRIDGE_PORT}server-side (reading the same.envvalue that was always correct), bypassing nginx and the browser entirely. - Caught and fixed a bug in my own fix during testing: an early version of the
update.shauto-repair's port-extraction regex (grep -oE '[0-9]+'applied to the whole127.0.0.1:PORT/ws;string) matched the IP address's own leading octet (127) before the actual port, sincehead -1just takes whichever number appears first in the string — verified with a simulated before/after repair test before trusting it, and corrected to anchor specifically on the:PORT/ws;segment.
v1.0b2.84 — BinkP: inbound CRAM-MD5 support + poll log for inbound-delivered mail (July 2026)
- FIX: a real downstream FidoNet peer running binkd reported
CRAM-MD5 is not supported by remotewhen polling IN to an ANetBBS install, while the reverse direction (this install polling that peer) worked fine. Root-caused by reading the code directly rather than guessing:anetbbs/echomail/binkp_server.py— the inbound BinkP listener, which accepts connections FROM remote systems — never implemented CRAM-MD5 as the answering side at all. Its M_NUL handshake preamble (SYS/ZYZ/LOC/NDL/TIME/VER) never included theOPT CRAM-MD5-<hex-challenge>line FTS-1027 requires an answering side to offer, and its password check (binkp_server.py, both the upstream-EchomailNetworkand downstream-BinkPNodebranches) only ever did a literal string comparison againstM_PWD— nohmac/hashlibusage anywhere in the file. Meanwhileanetbbs/echomail/binkp.py's outbound client has a correct, previously-verified CRAM-MD5 implementation (confirmed spec-correct in an earlier, unrelated investigation into a different bug) — but it is exclusively calling-side logic (BinkPClient._connect()always initiates outbound; the class never accepts a connection), so it has no bearing on the inbound path. The two sides had simply never been given matching implementations — this wasn't a regression, CRAM-MD5 support for inbound sessions never existed. binkd's "CRAM-MD5 is not supported by remote" is its literal, correct message for exactly this condition: a caller that expects/prefers CRAM-MD5 sees no challenge offered in the preamble it receives. Fixed by generating a random 32-byte challenge per inbound session (secrets.token_bytes(32)), advertising it viaOPT CRAM-MD5-<hex>in the preamble (sent beforeM_ADR, matching the outbound client's own ordering convention), and adding_verify_binkp_password()— checks for aCRAM-MD5-prefix on the caller'sM_PWDresponse and verifies it viahmac.new(password, challenge_bytes, hashlib.md5).hexdigest()against the session's own challenge, falling back to the original plain-text comparison for callers that don't send a CRAM-MD5 response — so peers that don't support it keep working exactly as before. 13 new tests, including one that specifically verifies a correct-looking digest computed against the wrong challenge is rejected (the single most likely way this class of bug hides — verifying a real digest against stale/mismatched challenge bytes would silently accept it). - FIX: found while investigating a related report — echomail was genuinely arriving from two connected networks (messages landing correctly in message areas) but the admin Poll Log showed 0 messages for both, every time. Traced the full pipeline rather than assuming a miscount:
EchomailPollLog(the model backing the Poll Log page) turned out to be written from exactly one place in the entire codebase —anetbbs/echomail/poller.py's_do_poll(), which only runs for outbound sessions (ANetBBS dialing out to a hub). The actual real-time delivery path for those networks is the inbound listener (binkp_server.py) — their hubs call IN and push mail, rather than waiting to be polled — and that path's_import_pkt_payload()(which does correctly compute and even log, at INFO level, a real imported-message count) had that count silently discarded by its caller; noEchomailPollLogrow was ever created for an inbound-initiated session, success or failure. The 0s seen weren't corrupted data — they were the outbound poller honestly reporting that a later dial-out to the same network found nothing new left to pull, since the hub had already pushed everything via the inbound path moments earlier. This is a known, previously-documented gap, not a new regression:docs/CHANGELOG.md's own v1.0b2.47 entry (which added the poll-log transcript feature) explicitly noted "the inbound/server side has no equivalent session model today, noted as a natural follow-up" — this release is that follow-up. Fixed by having_handle_connection()track a realimported_totalacross every packet it imports during a session (previously computed and immediately thrown away), and write anEchomailPollLogrow after each authenticated inbound session that received or sent anything, with accuratemessages_received/messages_sentcounts, apoll_typeof'receive'/'send'/'both'computed by the new pure_inbound_poll_type()helper, and the real session start/end timestamps. Only written for upstream-hub sessions (net_idset) — a downstream node polling this install as its hub has noEchomailNetworkrow to log against, matching the model's existingnetwork_id NOT NULLconstraint. Wrapped in its own try/except so a logging failure can never break the actual mail exchange it's describing.
v1.0b2.83 — Network join review: detail view, sequential BinkP numbering, visible passwords, secure archive (July 2026)
- FEATURE/FIX: the sysop processed the first real "apply to join this network" application (for ANotherNetwork) and hit three real gaps in
/admin/echomail/hub/join/requests. (1) The review screen only allowed Approve/Deny off a truncated list row (join_requests.html's pending table shows BBS Name/Applicant/Email/BinkP/QWK/60-char-truncated-Notes only) — addedjoin_request_detail()(anetbbs/web/hub_admin.py) + a newjoin_request_detail.htmltemplate showing every fieldNetworkJoinRequestactually stores (bbs_software, bbs_os, telnet_address, website_url, full untruncated notes, ip_address, user_agent, rules_ack, etc. — none of which were rendered anywhere before), with Approve/Deny/Archive actions available directly from the page, linked from both the pending and reviewed rows in the list. (2) BinkP node addresses were always whatever the applicant typed into the public form, used verbatim —approve_join_request()had a uniqueness check but no assignment logic at all. This hub is at1200:1/1and the first approved node's address had to be manually edited to1200:1/2after the fact. Added two new optionalNetworkJoinConfigcolumns (binkp_zone,binkp_net, configurable in the join-form admin tab) — when both are set,_next_binkp_node_address()computes the next unused{zone}:{net}/Nby scanning existingBinkPNode.ftn_addressvalues with the existinganetbbs/echomail/routing.pyFTN parser (which safely no-ops on non-numeric addresses, like the real domain-style one —theunderground.network— the first applicant actually submitted, and on addresses in other zone:net pairs), defaulting to node 2 when none exist yet (node 1 reserved for the hub, matchingnodelist()'s existing hardcoded convention a few hundred lines down in the same file). Leaving zone/net unset preserves the exact old verbatim-address behavior — this is opt-in, not a forced migration. QWK'sqwk_packet_idis completely unchanged, by design: that stays sysop/applicant-chosen, no auto-numbering wanted there. Also fixed the post-approval credentials email, which was still referencing the applicant's originally-requested BinkP address rather than the actually-assigned one — a real bug this same change surfaced, since those two can now differ. (3) Confirmed via a direct live database query that the generated node password was never actually lost —approve_join_request()correctly generates and persists it in three places (BinkPNode.password/QWKNode.password, plusNetworkJoinRequest.generated_binkp_password/generated_qwk_password) — but there was no read path anywhere in the admin UI, so once approved without SMTP configured, the sysop had no way to retrieve it short of a raw SQL query. Added a plain<code>display to bothbinkp_node_detail.htmlandqwk_node_detail.html(both already login+admin-gated, no extra masking needed) and inline next to the "view node" links in the review list's reviewed table. Also added a deliberate, sysop-triggered "Archive Application to Disk" action (archive_join_request()) that snapshots the full application to a timestamped, pretty-printed JSON file underdata/network_join_archive/— confirmed this directory has no web route pointing at it anywhere in the app (the only things ever served out ofDATA_DIRare narrow, named, single-file routes like the existing infopack download), so it's genuinely unreachable except by direct filesystem/SSH access, matching the "no one else could reach it" requirement. 8 new tests covering node-number computation (empty/existing/cross-zone/non-numeric cases), end-to-end auto-numbered approval, the detail view, the password now showing in the review list, and the archive file landing on disk while staying unreachable via any URL.
v1.0b2.82 — Remove remaining box-drawing borders, refresh main menu content (July 2026)
- CLEANUP: two prior passes (v1.0b2.12 "Widescreen: 132-col art border removal", v1.0b2.23 "Multi-screen welcome/goodbye/newuser sequences") removed the old full-rectangle
╔═╗║╚═╝box-drawing border from most stock terminal screens in favor of the borderless block-shaded bar style used byanetbbs/features/ansi_ui.py'sbanner()helper — but neither pass ever touched the plain 80-column.ansfiles foranetbbs/screens/newuser.ans,anetbbs/screens/menus/main.ans,menus/chat.ans, andmenus/game_center.ans. Those four were still carrying the old box border while their 132-col widescreen siblings andwelcome.ans/goodbye.anshad already been fixed. Rebuilt all four with a small script (\xdc/\xdfblock-shaded header/footer bars,\xb0shade fill behind the centered title,\xc4single-line separator — no\xbaside walls) to exactly match the already-correct convention, generating the raw CP437 bytes directly rather than hand-editing (these files are CP437-encoded binary, not UTF-8 text — a text editor would corrupt the block/line-drawing glyphs).newuser.ansintentionally ships with no@PAUSE@marker, matchingnewuser132.ans's existing (and, on inspection, deliberate) behavior: registration continues straight into security-question prompts with no separate pause screen. Confirmed via a full scan of every stock.ansfile that zero box-drawing bytes (\xc9\xcd\xbb\xba\xc8\xbc) remain anywhere. - FIX: while rebuilding
menus/main.ans, found it (andmain.asc) had also drifted out of sync with the actual menu system over several past releases — missing[J] Send InterBBS IM,[K] Ebook Reader,[A] Page Sysop,[D] Dial Out, and[W] Change Password, none of which had ever been back-ported into these static hand-crafted art files after their corresponding features shipped.main132.answas missing[K]too. All three main-menu variants (main.ans,main132.ans,main.asc) now render the complete, current 22-item hotkey list — including the new[L] Ask Anetfrom this release — via a small Python column-layout generator (2 columns for 80-col, 5 for 132-col, plain aligned columns for the.ascfallback) instead of hand-typed spacing, so column alignment is guaranteed consistent and future hotkey additions are a one-line list edit instead of manual retyping.
v1.0b2.81 — Ask Anet: fix CP437 mojibake + raw markdown leaking into result snippets (July 2026)
- FIX: found live on the Pi3 terminal (the web UI was unaffected) — search result snippets in
anetbbs/guru/search.pyshowed garbled "?" characters and raw markdown syntax instead of clean text, e.g.# TIC Processorand***«BinkP»instead ofTIC Processorand**BinkP**. Two compounding causes, both confirmed against a real screenshot of the broken terminal output: (1) thesnippet()SQL call's highlight/ellipsis markers were Unicode guillemets («/») and an ellipsis (…) — these render fine in a browser (UTF-8) but as mojibake on a real terminal session, which this codebase runs in CP437, not UTF-8; (2)snippet()extracts text straight from the raw markdown source stored in the FTS5 index (kept as markdown deliberately, since stripping it before indexing wasn't needed for word-level matching), so literal#heading marks and**bold**markup leaked directly into the displayed snippet, unprocessed — visible for any page whose matched snippet window happened to start at or cross a heading/bold-emphasis boundary. Fixed by switching the SQL-level markers to ASCII-only (>>/<</..., chosen specifically soanetbbs/guru/render_plain.py's markdown-stripping regexes can't mistake them for real markdown and strip them too) and running every returned snippet through that same markdown-to-plain-text stripper already used for the terminal's full-page reader, rather than displaying FTS5's raw extract untouched. Also improvedanetbbs/features/bbs_ui.py:show_guru()'s results list to truncate snippets on a word boundary instead of an arbitrary character cut (which was contributing to the "ragged" look reported alongside the character corruption), and made its columns responsive to terminal width viaui_width()(was a fixed 40/30-column split) so results use the extra room on a 132-col widescreen session instead of looking sparse, matching how the RSS reader and other doors already size their columns.
v1.0b2.80 — Ask Anet: fix silently-broken search on upgrading installs (July 2026)
- FIX: found live on the Pi3 test server immediately after v1.0b2.79's first deploy — every search in the new Ask Anet guru door returned "nothing matched", even for exact words like "netmail" that are definitely present in the wiki. Root cause:
wiki_pages_fts(anetbbs/guru/fts.py) is an external content FTS5 table — it stores no copy oftitle/body, only the inverted search index, reading the real text straight fromwiki_pageson demand.count(*)against an external-content FTS5 table is a passthrough to the content table's own row count, regardless of whether the actual search index has ever been populated — a genuine SQLite FTS5 quirk, confirmed by direct reproduction (deterministic, no concurrency needed) rather than assumed.ensure_fts_index()'s original "does this need a one-time backfill rebuild" check compared that always-equal count againstwiki_pages's count, so the realINSERT INTO wiki_pages_fts(wiki_pages_fts) VALUES('rebuild')command never actually ran on any install wherewiki_pagesalready had rows before this feature ever shipped — which is every real ANetBBS install, Pi3 and live both, since the wiki has been seeded for many releases already. Direct non-MATCH reads of the table (SELECT body FROM wiki_pages_fts WHERE rowid=...) looked completely normal throughout — same passthrough behavior — which is exactly what made this look like a populated, working index right up until an actualMATCHquery was tried. Fixed the detection to checksqlite_masterfor whetherwiki_pages_ftsexisted before this particular call, instead of comparing row counts — that's only ever true on the one process, one time, that actually creates the table, immune to the passthrough-count trap. Also moved the whole DDL-creation-plus-rebuild sequence off SQLAlchemy'sengine.begin()(a deferred transaction for sqlite that doesn't take a write lock until the first write statement) onto the rawsqlite3driver under an explicitBEGIN IMMEDIATE, so the several separate OS processes that each independently call this at their own startup (gunicorn web workers, the telnet/SSH terminal service, binkp, etc.) can't race on the one-time rebuild — verified with a dedicated 6-process concurrency stress test in addition to the direct single-process bug reproduction that pinned down the actual root cause. 1 new regression test (simulates the exact "wiki_pages already had rows before the FTS5 table/triggers existed" upgrade scenario).
v1.0b2.79 — "Ask Anet" help guru door, terminal + web (July 2026)
- FEATURE: a retro-styled in-BBS help assistant, a callback to the old "Lisa AI" chat door of classic BBS culture, named "Anet" for this BBS. Ask a plain-language question — "where can I view netmail", "where do I see my notifications", "how do I chat" — and it searches the wiki's help pages for a match, surfacing the right page. Available in both the terminal (new
Lhotkey on the main menu, "Ask Anet (Help Guru)",anetbbs/features/bbs_ui.py:show_guru()) and the web UI (/guru/,anetbbs/web/guru.py). Deliberately not an LLM or any AI model — must run unmodified on a Raspberry Pi 3, which has no spare compute for one. Retrieval-only: SQLite FTS5 (bundled with Python, zero new dependencies) searches the existing 45-page wiki, ranked by bm25, with a small hand-maintained alias dict (anetbbs/guru/aliases.py) so different phrasings ("netmail" / "PM" / "direct message") land on the same results. The personality wrapper (anetbbs/guru/personality.py) is entirely fixed template strings — no generation. A persistent, always-visible disclosure — shown before the first prompt in the terminal door and permanently at the top of the web page, never a dismissible one-time toast — explains in plain, non-jargony language that this is a smart search tool with a friendly wrapper, not a live AI chatbot; this was an explicit requirement so non-technical users aren't misled about what's actually running. The FTS5 index (anetbbs/guru/fts.py, external-content virtual table overwiki_pages) is created and kept in sync via SQL triggers scoped totitle/bodychanges only (notview_count, which changes on every page view) inside the existing idempotent_lightweight_migrate()schema path — self-heals on fresh installs (populates naturally as wiki pages are seeded) and on upgrades of already-populated servers (one-time FTS5rebuildcommand if the index doesn't match the row count). TheLhotkey backfills onto existing installs automatically via the existingseed_default_menus()mechanism — no separate migration needed. 15 new tests across search/menu-wiring/web layers.
v1.0b2.78 — Terminal uploads into disk-backed file areas never showed on the web (July 2026)
- FIX: a file uploaded via the terminal's ZMODEM upload (
anetbbs/features/bbs_ui.py:_upload_terminal_file()) into a file area with a configuredFileArea.storage_pathshowed up correctly in the terminal's own file listing but never appeared on the web UI's file-area page for the same area — confirmed live immediately after the v1.0b2.77 ZMODEM handshake fix let an upload actually succeed. Root cause: the terminal upload always saved into a single genericuploads_dir(creating aFileUploadDB row), regardless of which area was selected — the area's ownstorage_pathwas computed by the caller but never passed down or used. Meanwhileanetbbs/web/file_areas.py's_scan_area()— which both the terminal's own primary listing branch (when a storage_path exists) and the web view use — only ever scansarea.storage_pathon disk and has noFileUploadDB fallback at all. The file existed; it just wasn't where either of them ever looked. Fixed by branching on whether a storage_path was supplied: if so, save directly under it with the real filename and create no DB row (matchingfile_areas.py's own web upload route, and every other file already in that area); otherwise, the original uuid-named-file-plus-FileUpload-row behavior is preserved unchanged for areas with no disk storage at all (the "General / Top-level" case). 2 new tests — and along the way, found and worked around a realIsolatedAsyncioTestCasehang (near-zero CPU, no progress) triggered specifically by creating a Flask app inside one of its tests; a bareasyncio.run()of the identical coroutine in the identical process completes in under a second, so the tests use that instead, matching howtests/test_terminal_node_monitor.pyalready avoids this same class of problem.
v1.0b2.77 — Fix ZMODEM upload handshake failure with SyncTERM (July 2026)
- FIX: every terminal file upload via ZMODEM over SSH from SyncTERM failed identically — the client logged
UNEXPECTED ZRPOS received instead of ZRINITafter repeatedZRQINITretries, then cancelled. 100% reproducible (confirmed live), which pointed at a protocol/flag mismatch rather than a timing race. This codebase already had a proven fix for the mirror-image bug on the send side (anetbbs/features/xfer.py):sz --escapecausesszto sendZSINITto negotiate extended escaping, and SyncTERM replies withZRINITinstead of the expectedZACK, breaking the handshake — so--escapewas deliberately omitted from ZMODEM's outbound flags. The receive side (rz --escape, which sets theESCCTLbit in ourZRINITto request the sender escape control characters) turned out to trigger the same class of SyncTERM handshake failure in the opposite direction. Removed--escapefrom ZMODEM'srecv_flagsto mirror the already-proven send-side fix. XMODEM has no escape flag (not part of the ZMODEM family, noZSINIT/ZRINITnegotiation); YMODEM's--escapewas left untouched — no live report of a YMODEM failure, and changing it wouldn't be justified by evidence. 3 new tests.
v1.0b2.76 — Diagnostics missed the inbound-listener path entirely (July 2026)
- DIAGNOSTIC: even the v1.0b2.75 manifest never appeared on the live server after a fresh, confirmed post-deploy
%RESCAN— despite real messages continuing to land in the database. Root cause:anetbbs/echomail/binkp_server.py(the separateanetbbs-binkpsystemd service, which handles inbound connections when a hub calls us rather than the other way around) has its own completely independent_receive_files()→_import_pkt_payload()→_parse_ftn_packet()chain that never routes throughbinkp.py's_import_completed()at all. Every diagnostic added in v1.0b2.71-75 was blind to any traffic arriving this way. (The v1.0b2.70/73/74 parsing fixes themselves do apply here too, since both files call the same shared_parse_ftn_packet()— only the diagnostic capture was missing.) Added matching_debug_manifest()/_debug_dump_packet()calls to this file's inbound loop, prefixedSRV:/_srv_in the shared manifest/dump directory so captures from both paths are distinguishable. Same opt-in, zero-cost-unless-BINKP_DEBUG_DUMP_DIR-is-set gating throughout.
v1.0b2.75 — Manifest diagnostic: log-based capture confirmed unreliable in production (July 2026)
- DIAGNOSTIC: v1.0b2.74 fixed the "Men In Black" desync trigger, confirmed against the real captured corpus (0 suspicious out of 3,751 messages) — but a different article ("SN_ALIEN" area) still corrupts on every rescan, and
BINKP_DEBUG_DUMP_DIRonly captured one small, unrelated packet from that delivery instead of the actual bulk content. While investigating, found that production'sLOG_LEVELsilently swallows everylogging-module message from this file — zero"BinkP"lines anywhere ingunicorn-error.logdespite real traffic flowing through it the whole time, meaning the existinglogger.info/logger.warningcalls (including the debug dump's own confirmation message) can't be trusted as a diagnostic signal at all in this environment. Added_debug_manifest(): an unconditional, plain-file-I/O log (bypassingloggingentirely) that records every file handed to_import_completed()— filename, size, and detected type (fts/zip/other) — regardless of whether it matches a recognized format. Same opt-in, zero-cost-unless-BINKP_DEBUG_DUMP_DIR-is-set gating as the existing dump. Root-cause fix for the SN_ALIEN trigger to follow once the manifest shows what's actually arriving.
v1.0b2.74 — Chain-validate packet boundaries; fix a second desync vector found while testing (July 2026)
- FIX: v1.0b2.73's marker+date check still wasn't enough. A second
BINKP_DEBUG_DUMP_DIRcapture showed a real message body containing a byte sequence that wasn't justMSG_TYPE_2by coincidence, but a fully well-formed date+time+null field too — almost certainly quoted/reposted old FidoNet message content embedded verbatim within the article's own prose (a BBS-history piece), not random noise. That candidate's own immediate to/from/subject fields were clean, so a single-level check accepted it; the corruption (a raw\rin a header field) only became visible one level further into what turned out to be a fake nested chain. Fix:anetbbs/echomail/binkp.py:_chain_looks_like_real_messages()now tentatively parses up to 3 levels deep before accepting a candidate boundary, rejecting it if any header field along the chain contains a raw control character — something no genuine to/from/subject ever does. - FIX (found while building the above): once a candidate is correctly rejected, its own bytes get treated as unparsed body content — and its routing header's
attr/costfields (2 bytes each, both commonly0in real traffic) can produce an adjacent zero-byte pair that the old code trusted outright as the packet's own end-of-data marker, silently truncating the rest of the packet. The real FTS-0001 terminator is always the literal last bytes of the buffer;_is_real_packet_end()now requires the candidate to actually be there instead of matching anywhere. 2 new regression tests, both reproducing the exact structures found live.