ANetBBS Changelog

Current release: v1.0.87 (September 2026). This file covers v1.0.0
onward, which follows standard semantic versioning — patch releases are
v1.0.1, v1.0.2, and so on. The full internal beta build-number
history (v1.0a1.1 through v1.0b2.239) that got the project to this
release is preserved in
CHANGELOG-beta.md.

v1.0.42 — A genuine no-root install path, documented and verified end to end (August 2026)

New: a fully documented, verified no-root installation path (docs/01b-no-root-install.md) for anyone without sudo/root access. The capability mostly already existed in anetbbs-install but was undocumented and had one real bug — its final instructions always said sudo systemctl start ... even after declining systemd entirely. Added a third, genuinely rootless service option (systemd user units, persisting via loginctl enable-linger), fixed the final instructions to match whichever choice was made, and fixed a real live bug found while proving this end to end: MSP/SYSTAT default to enabled and were never addressed by the wizard, so every install logged a permission-denied bind failure on every boot — now correctly disabled by default. Verified with a real install run as an ordinary non-root user, followed by actually booting the result and confirming clean HTTP 200s with zero permission errors. 22 new tests.

v1.0.41 — Fixed a real bug where the MRC bridge always showed blank BBS info to the wider network; manual install docs now cover MRC setup (August 2026)

Fixed a real bug where every BBS running the MRC bridge showed blank telnet/SSH/website/sysop/description info to the wider MRC network — the default outcome for every install, not an edge case. install.sh/update.sh wrote the wrong config key names and never wrote telnet/SSH contact info at all; fixed in both, with real telnet/SSH strings now derived at install time. Existing installs need their mrc/bridge/config.json corrected by hand (neither script overwrites an existing one) — see docs/INSTALL.md's troubleshooting section. 12 new tests.

The manual install guide (docs/INSTALL.md) now covers MRC bridge setup, the one real gap found in a full audit of the doc against current code (everything else already checked out).

v1.0.40 — Fixed a live BinkP process-corruption bug; hub node polling and message flavor (Crash/Hold) now do what they say (August 2026)

Fixed a real production incident where BinkP delivery silently stopped working after running clean for days. The in-app notification helper's live-toast push imported web_app on the fly to reach its SocketIO instance — safe from the real web process (already running under eventlet from the start), but anetbbs-binkp.service is a plain asyncio process that was never meant to load eventlet at all. The first time an inbound BinkP session delivered netmail to a real local user, that import triggered eventlet.monkey_patch() mid-process, silently corrupting already-created threading primitives and breaking the SQLAlchemy connection pool for the rest of that process's life (RuntimeError: cannot notify on un-acquired lock on every DB write afterward) — so outbound TIC/echomail delivery on later sessions from that same process quietly stopped working, looking like a routing problem rather than what it actually was. Fixed by checking current_app.extensions instead of importing web_app fresh — the same safe pattern already used elsewhere in the codebase for this exact class of bug. Reproduced the production traceback exactly by reverting the fix locally before confirming it, and added a regression test that does the same.

Hub-initiated polling of downstream BinkP nodes can now run on a real schedule, not just manually. Each node can now be given its own auto-poll interval on its admin page; leaving it blank keeps the node exactly as it works today (manual/poll-in-only).

Crash and Hold now actually change delivery timing, not just a cosmetic packet flag. Crash now fires an immediate out-of-schedule delivery attempt the moment a netmail or file-echo item is queued; Hold now actually excludes an item from our own outbound dial-out, shipping only once that peer polls in to us. File-echo/TIC distribution gets these flags for the first time, exposed as Crash/Hold checkboxes on file-area uploads.

New: inline in-place animation for sysop-defined ANSI/CP437 screens. Any screen shown at login/logoff/etc. can now embed a short looping animation directly in its body (@ANIMSTART@...@FRAME@...@ANIMEND@ markers, alongside the existing @PAUSE@ pagination convention) instead of only ever being static art. No code changes needed to use it — drop an animated screen into data/mods/text/ the same way any other custom screen override already works. Documented in docs/04-ansi-screens.md and the in-app wiki.

Fixed a real live bug where a SAUCE-tagged .ans file showed its own metadata as literal garbage text on screen — including the main menu's own file-based art override, and every welcome/goodbye/newuser/custom screen. SAUCE stripping previously only existed inline inside the ANSI Editor's import route; now a single shared sauce.strip() is used everywhere a raw .ans/.asc file is read from disk.

Over 2,780 tests, all green.

v1.0.39 — Second, deeper security and performance audit pass, including docs (August 2026)

A follow-up audit was carried out across the whole codebase, documentation, and deployment tooling — broader in scope than v1.0.38's pass, this round also covered the wiki feature and every doc file, not just application code and scripts. Additional real issues turned up and were fixed, again addressed in strict severity order: further hardening of network-facing protocol handlers, closing a couple more resource-leak and unbounded-growth points in long-running background processes, tightening several more file-permission and credential-handling spots in the install/update/container tooling, adding a few more missing rate limits and size caps, and correcting some troubleshooting documentation that could otherwise lead a sysop toward a weaker configuration than intended. New regression tests were added throughout for every fix — the full suite now sits at over 2,750 tests, all green. As with the previous round, this entry intentionally omits specifics in the interest of responsible disclosure; sysops running an older release should update at their earliest convenience.

v1.0.38 — Full security and performance audit, hardening pass across the codebase (August 2026)

A comprehensive, self-directed security and performance audit was carried out across the whole codebase, documentation, and deployment scripts — covering the web application, terminal/telnet/SSH session handling, echomail (FidoNet BinkP/QWK), MRC chat, door-game launching, and the install/update tooling. A number of real issues turned up and were fixed, addressed in order of severity: hardening of remote/cross-network-facing attack surfaces, closing several memory- and resource-leak points in long-running processes, tightening file and credential permissions across the install/update/backup scripts, adding missing resource limits and concurrency caps, and bumping several third-party dependencies to their current patched releases (see requirements.txt). New regression tests were added throughout — the suite now sits at over 2,600 tests, all green. In keeping with responsible disclosure, this entry intentionally omits specifics; sysops running an older release should update at their earliest convenience.

v1.0.37 — install.sh/update.sh portability audit: real fixes for Alpine, Pi, and minimal/Docker hosts (August 2026)

A sysop report that visudo rejected deploy/sudoers.anetbbs on a host whose sudo build has no I/O-logging support (an "unknown setting: log_input" error) prompted a full portability audit of both install.sh and update.sh against the stated goal of working on any distro, including Raspberry Pi and Docker. Several real, independently-confirmed bugs were found and fixed:

  • Sudoers refresh no longer fails outright on sudo builds without I/O-logging support. Both scripts now retry once with the offending !log_input, !log_output Defaults lines stripped if the full file fails visudo -cf, rather than leaving a stale sudoers file in place (which had been silently missing whatever commands changed since the sysop's last successful refresh).
  • install.sh --uninstall ran without checking it was root, letting every destructive command fail silently while still reporting a clean "uninstalled" success.
  • install.sh --defaults (the documented non-interactive install path) could still block on an interactive prompt and auto-abort on any OS not in the package-manager detection list — exactly the situation a non-interactive install (Docker build, CI) is most likely to hit.
  • Both scripts checked only that the systemctl binary exists, not that systemd is actually running as PID 1. Some Docker images have the binary layered in without real systemd, causing both scripts to fail opaquely partway through instead of failing fast with a clear message — more dangerous in update.sh, which actively stops the live BBS mid-upgrade with no other restart path.
  • update.sh's main file-sync step had no error checking at all, printing "Files synced" unconditionally even if rsync failed — meaning a failure there could leave the database migrated against stale, un-synced application code. It now aborts cleanly and restarts whatever it had stopped.
  • update.sh's database-migration fallback (used when sudo itself is missing) silently skipped all column-migration logic, reporting "Database schema updated" while actually adding no new columns. It now runs the same real migration as the primary path.
  • Fixed several GNU-coreutils-only constructs (grep -oP, head -n -N) that silently break on BusyBox/Alpine, and a missing file command dependency that silently defeated the ARM/x86 binary-architecture safety check for bundled doors.
  • Raspberry Pi's older ID=raspbian releases, and other unlisted distro derivatives (via /etc/os-release's ID_LIKE), are now correctly detected instead of falling through to "unknown package manager."
  • Added full Alpine (apk) package-manager support to install.sh — detection, package installation, and per-package name mapping, plus installing Alpine's shadow package so the existing user/group-management commands work unchanged. Note: Alpine natively uses OpenRC rather than systemd, and both scripts still require systemd, so this support currently only applies to an Alpine host that also runs systemd.

docker/ (Dockerfile, entrypoint scripts, compose file, single-container supervisord config) was also audited separately and found to already be in good shape, with no changes needed.

v1.0.36 — New data/mods/ sysop-override tree, matching Synchronet's own mods/ convention (August 2026)

Added a central data/mods/ directory where a sysop can drop a customized replacement for anything ANetBBS ships, guaranteed to survive a package update untouched — the same guarantee real Synchronet's own /sbbs/mods/ tree gives (wiki.synchro.net/dir:mods), and built to the same unified layout: one root directory whose subdirectories mirror what's being overridden, not a scattered set of one-off override points.

Three things are covered:

  • Synchronet-compat door/game scripts. synchronet_compat.py's shim already computed a mods_dir value and threaded it through as bbs.mods_dir/system.mods_dir (real Synchronet API properties some doors read directly), but the internal js.mods_dir its own load() resolver needed to actually use that value never existed — so nothing ever consulted it. Fixed: js.mods_dir now exists and is checked first in load()'s search order, and door_runner.py applies the same override to a door's own top-level entry-point script (the one file that never goes through load() at all, since it's handed directly to jsexec/Node as the process entry point).
  • ANSI/menu screen overrides. data/mods/text/ and data/mods/text/menus/ are now checked ahead of the existing data/text/ and data/text/menus/ override locations for welcome/goodbye/newuser/custom ANSI screens and built-in terminal menu art — matching real Synchronet's own mods/text//mods/text/menu/ convention. The older data/text/ locations keep working unchanged for anyone already using them; data/mods/text/ is simply preferred when both exist. See doc 4 — ANSI screens and doc 14 — door games.
  • ANetBBS's own native core Python screens. Real Synchronet's login.js/logon.js are core system scripts (not doors) a sysop can customize in mods/ the same way as anything else, because Synchronet's whole engine loads everything by filename. ANetBBS's own core isn't script-driven the same way, so a matching capability needed a new, explicit mechanism: core/mods_override.py's call_core_override() checks data/mods/core/<name>.py for a full replacement Python file and falls back to the built-in version if it's missing, broken, or throws — never breaking the screen it's overriding. See doc 14 — door games.

data/mods/ (and its text//text/menus//core/ subdirectories) sits inside the part of the install update.sh already excludes wholesale, so a sysop's dropped-in override survives a package update with zero extra deploy-script changes.

v1.0.35 — Network-join credentials email now tracked, with a resend button (August 2026)

"The last two people said they have not received [the join-approval credentials email]... should have a resend feature." Real gap: the auto-send at approval time was always a one-shot, best-effort attempt with NOTHING persisted about whether it actually succeeded — just a flash message that's gone on the next page load, and a log line. If the sysop approved several requests in a row (or just didn't catch the banner), a silent SMTP-side failure (relay bounce, greylisting, quota) for one specific applicant was completely invisible and unrecoverable short of grepping the app log.

Added three tracking columns to NetworkJoinRequest (email_sent_at, email_last_attempt_at, email_error) and a shared _send_join_approval_email() helper used by both the automatic send-on-approval and a new Resend button (on both the Join Requests list and each request's detail page) — so the two paths can never drift apart on wording, and delivery status is now visible at a glance instead of relying on catching a transient banner. Resend deliberately re-sends the same already-generated credentials rather than regenerating a new password, since regenerating would silently invalidate whatever the applicant may have already received or configured their own mailer with from an earlier, partially-successful attempt.

v1.0.34 — New BinkP outbound spool directory; fixed a redelivered-TIC sysop confusion (August 2026)

Added a real BinkP outbound spool directory. Real gap found live: ANetBBS's own echomail has always been entirely DB-queue-driven — outbound netmail/echomail as EchomailMessage rows, outbound file distribution as HatchQueue rows — with no way at all for an external program (e.g. a door that writes its own FTS-0001 netmail packets straight to disk, the same way any traditional FTN mailer's flat-file outbound spool works) to hand ANetBBS a file to transmit. Any file dropped in a peer's spool directory is now sent as-is on the next BinkP session with that peer (dial-out via poller.py, dial-in via binkp_server.py — both directions covered) and archived to a sent/ subfolder on success, never deleted outright. Spool directories are keyed per peer (not one shared folder like the inbound directory), since a loose file has no address of its own to route by and more than one network/node can be configured at once — <DATA_DIR>/binkp/outbound/<peer address>, overridable via BINKP_OUTBOUND_DIR. The resolved path for each configured peer is now shown directly on the Echomail Networks list and a BinkP node's own detail page, so a sysop doesn't have to read source to find it.

Fixed a real live bug: a redelivered TIC file that was already successfully filed weeks earlier looked stuck, but "Rescan Inbound Now" reported nothing was wrong. Root cause, traced from a real report of a .tic/binary pair sitting in the inbound directory that the rescan button claimed didn't exist: some file echoes periodically redistribute already-delivered files unchanged, and scan_inbound()'s dedup-skip branch (correctly refusing to re-file something already marked filed in the DB) did a bare continue without ever reaching the cleanup step that moves a processed file out of the inbound directory — so a genuine redelivery piled up in inbound forever, invisible to the rescan button's "no unprocessed .tic files found" message. Fixed by extracting the existing cleanup logic into a shared _move_to_processed() helper and calling it from the dedup-skip path too, with the rescan count now reflecting files it swept.

v1.0.33 — anetbbs-cfg now reachable from the terminal Sysop Menu, SSH only (August 2026)

The standalone anetbbs-cfg full-screen config tool can now be launched directly from a live terminal session, instead of needing separate shell access — a new "Config Tool" entry in the Sysop Menu. Restricted to SSH sessions only, by explicit design: the tool can edit user security levels, echomail/hub credentials, and other sensitive config, and telnet sends everything in plaintext. Gated twice — the menu entry itself only appears at all on an SSH session (not just hidden/greyed out on telnet), and the launch function independently re-checks the same thing, so there's no path that bypasses it even if the menu-gating logic changes later. Implemented by registering anetbbs-cfg as a hidden Game row (is_active=False, so it never appears in the normal games list to anyone) and reusing door_runner.py's already-hardened PTY-bridging code — the same machinery every native door already uses, rather than reimplementing terminal I/O handling from scratch.

Fixed a real crash found live testing the above: launching anetbbs-cfg through the Sysop Menu immediately crashed with _curses.error: curs_set() returned ERR. Root cause: doors launched via door_runner.py inherit TERM=ansi, a minimal terminfo entry meant for the raw-ANSI-escape doors that are every OTHER door this launch path has ever run — it has no cursor-visibility (civis/cnorm) capability, and anetbbs-cfg is the first curses-based program to go through this path. New anetbbs.cfg.ui.safe_curs_set() wraps every curs_set() call site (5 in ui.py, 1 in app.py) so a missing capability degrades to "cursor stays visible" instead of crashing the tool.

v1.0.32 — Fixed a dropfile username bug, door-config path trimming, and added CHAIN.TXT/SFDOORS.DAT support (August 2026)

Every single-word username showed up inside doors with a phantom "User" suffix — "ExampleUser" became "ExampleUser User". generate_dorinfo() and generate_door32() both used the literal string 'User' as a placeholder last name whenever splitting the username produced no second word, instead of an empty string — generate_door_sys() already got this right, the other two just never matched it. Fixed to match; all three now produce a plain username when there's no real last name.

A trailing space or two on a door's Working Directory (or any other path/command field) silently broke it. Easy to pick up copy-pasting a path from elsewhere — nothing in the admin form flagged it, the value looked completely normal in the form — and door_runner.py then crashed with a raw FileNotFoundError on os.chdir() referencing a path that LOOKED right in every error message except for invisible trailing whitespace. _populate_game() now strips every path/command field (executable path, working directory, command-line args, drop-file path, Mystic/Synchronet script paths, BBS tag) before saving, for every game type.

Added CHAIN.TXT and SFDOORS.DAT drop-file generation, for launching doors that expect those formats — OpenDoors-based doors (including ANetCHESS) support both natively. Field layout for each verified directly against OpenDoors' own real parser source (ODInEx1.c's FOUND_CHAIN_TXT branch and ODInitReadSFDoorsDAT()), not guessed from a spec, matching this project's established discipline for drop-file formats — same approach that caught real bugs in the DOOR.SYS/DOOR32.SYS generators previously. PCBOARD.SYS was also investigated for the same purpose, but turned out to be dead code inside OpenDoors itself (a struct and a pointer declared, never actually wired into its dropfile auto-detection) — not added, since it wouldn't do anything for any OpenDoors-based door.

v1.0.31 — Fixed the actual root cause behind Minesweeper's missing DOVE-Net scores (August 2026)

iniGetObject() silently discarded a sysop's whole modopts.ini override when called with real Synchronet's overloaded boolean-first-argument form. After v1.0.30's readAll() fix, "view winners" still showed nothing — traced live with a real sysop, step by step, against real production data: file permissions checked out, and a pre-existing debug log line in Minesweeper itself revealed options.sub was resolving to false despite a correctly-placed, correctly-permissioned modopts.ini containing sub=2013. Root cause: real, unmodified Synchronet library code (modopts.js's own iniGetObject(/* lowercase */false, /* blanks */true), also used identically by install-3rdp-xtrn.js) routinely omits the section argument entirely and passes the boolean flags positionally instead — a boolean can never legitimately be a section name. The compat shim's File.prototype.iniGetObject() didn't account for this, so section === false fell through to looking up a section literally named "false", found nothing, and returned null — discarding the entire root section (every plain key=value line before any [header]) with no error anywhere. Fixed by detecting a boolean first argument and treating it as the lowercase flag, defaulting section to root. This was the actual final blocker in the whole DOVE-Net score-sharing chain — Minesweeper's get_winners(), the MsgBase caching (v1.0.29), and the readAll() fix (v1.0.30) were all correct the entire time.

v1.0.30 — Fixed a data-loss bug in the JSONL file-reading compat shim (August 2026)

Minesweeper's "view winners" showed a totally empty list even after the v1.0.29 lockup fix — real report, traced all the way to a live data dump. After ruling out every filtering/checksum step in get_winners() against real production data (confirmed live: the message's To:/Subject:/direction all matched correctly, and the MD5 checksum verified byte-for-byte), the actual culprit turned out to be File.prototype.readAll() in the JS compat shim (anetbbs/games/synchronet_compat.py): every line written via writeln() — the standard JSONL-append pattern json_lines.js's add() uses — ends with a trailing \n, so a naive content.split('\n') produces one spurious empty-string "line" after the real content. json_lines.js's own get() then calls JSON.parse('') on that phantom line, which throws — and since get() has no recovery flag by default, that ONE synthetic empty line made it return an error string instead of the parsed array, silently discarding every real entry. Confirmed live: Minesweeper's netwins.jsonl had 85+ correctly-imported real win entries from DOVE-Net the whole time — get_winners() was throwing all of them away every single call. Fixed by stripping exactly one trailing newline before splitting (a genuine blank line elsewhere in a file is still preserved). This affects any door using the standard JSONL-append idiom, not just Minesweeper.

Added the missing file_getcase() global — real bug found live via LORD2. l2lib.js's getfname() calls it to resolve asset filenames case-insensitively, a legacy pattern from DOS/Windows-era door development that only ever mattered once running on a real case-sensitive filesystem (every Linux install, including ANetBBS). The global didn't exist in the compat shim at all, so any door calling it hit a ReferenceError immediately. Implemented to match real Synchronet's documented behavior (case-insensitive directory scan, returns the actual on-disk filename or undefined).

v1.0.29 — Fixed a real lockup in InterBBS door score-sharing (August 2026)

Minesweeper's "view winners" screen looked like a total lockup — real report after setting up DOVE-Net/syncdata score sharing. Not an infinite loop: get_winners() scans a synced echo area and calls get_msg_header()/get_msg_body() once per matching message, and the JS MsgBase compat shim backed each of those with a separate subprocess spawn — a fresh Python process, fresh Flask app, fresh SQLAlchemy init, every single call. Against a DOVE-Net area with real accumulated InterBBS history, "view winners" meant potentially hundreds of sequential spawns before anything displayed — easily minutes with no progress indicator. msgbase_bridge.py's get_index op now embeds each entry's header/body fields inline (the one query already has them loaded), and MsgBase caches them per message number in anetbbs/games/synchronet_compat.py, so get_msg_header()/get_msg_body() serve from memory instead of shelling out again — the whole scan is now one subprocess call instead of hundreds. Also added a 30s timeout to the subprocess spawn itself as a safety net, so a single genuinely-stuck call (e.g. real DB lock contention) fails cleanly instead of hanging forever. This fixes score-sharing for any door using the real MsgBase API against a configured echo area, not just Minesweeper.

v1.0.28 — PETSCII new-user registration fixes (August 2026)

The newuser welcome banner displayed as literal garbage on PETSCII — real bug found live on the Pi. _show_ansi_screen() writes raw CP437/ANSI bytes directly to the socket, bypassing write()'s petscii translation branch entirely — the exact same limitation already guarded against for the 'welcome' and 'goodbye' screen slots, just missed for 'newuser'. A real PETSCII session saw the sysop's newuser.ans banner as literal ESC[...m escape codes with case-inverted text instead of a rendered screen. Fixed with the same if self.term_mode != 'petscii' guard already used for the other two slots — "Registration successful!" (which already goes through write() correctly) still confirms the account was created; petscii users just don't get the customizable ANSI banner, the same tradeoff already accepted for 'welcome'/'goodbye'.

Security-question and newuser-questionnaire prompts broke mid-word on a 40-column PETSCII screen — another real bug from the same screenshots. These prompts were written as long unwrapped lines via session.write() and left to the terminal's own hardware auto-wrap, with no word-boundary awareness. New _prompt_width()/_wrap_text_lines() helpers in session.py (petscii_width-aware, falling back to window_size/80 for every other term_mode) now word-wrap the security-question list, the "Question N of 3" selection prompt, and the sysop-defined newuser questionnaire prompts.