ANetBBS Changelog

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

v1.0.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 — "Stingray" became "Stingray 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.

v1.0.27 — ASCII MRC chat client; word-wrap fix for embedded newlines (August 2026)

New AsciiMRCChat client for term_mode == 'ascii' sessions. ascii has always been a real, selectable terminal mode, but it never had its own MRC client the way PETSCII now does — chat.py's ChatManager handed every session the full ANSI split-screen MRCChat regardless of mode. session.write() strips every ANSI escape sequence outright for ascii sessions, so that split-screen mode's DECSTBM scroll-region setup, CPR terminal-size probe, and cursor-addressed status/input/ticker draws were all silently dropped — a real, structural bug (ascii+MRC had no usable layout at all), not just a missing feature. AsciiMRCChat (anetbbs/features/mrc_chat_ascii.py) is the same plain-scroll-mode override pattern already proven by PetsciiMRCChat, simplified since ASCII has no case-inversion, no color-byte translation, and no special DEL key — just standard \x7f/\x08 backspace. ChatManager.__init__ now picks AsciiMRCChat for term_mode == 'ascii' and MRCChat for everything else.

_word_wrap() fix for embedded newlines — real bug found live on the Pi. A multi-line MOTD/banner from the MRC bridge arrives as one string with its own intentional \n line breaks. The word-wrap tokenizer (shared by both MRCChat's ANSI split-screen _emit() and PetsciiMRCChat/AsciiMRCChat's plain-scroll _emit()) only charged an embedded \n 1 column against its width budget, but the terminal itself resets to column 0 there — so the algorithm's internal column count and the real cursor position diverged, leaving whatever word came right after the newline in the source text stranded alone at the left margin (seen live at 40 columns: "at", "!list", "or", and a URL each appearing as isolated fragments). Fixed by treating \n/\r\n in the input as hard breaks, word-wrapped independently, before the normal width-based reflow runs.

v1.0.26 — PETSCII MRC chat: real word-wrap instead of raw terminal auto-wrap (August 2026)

Another real bug found live-testing on the Pi, worse at 40 columns than 80. PetsciiMRCChat._emit() was just writing each message's raw text and letting the terminal's own hardware auto-wrap break it wherever the physical column happened to land — no word-boundary awareness, so long messages could split mid-word and continuation text had no relationship to the original line. Fixed by reusing MRCChat's own _word_wrap() helper (the same one the ANSI split-screen client already uses) so messages wrap cleanly at word boundaries regardless of screen width, written as a single atomic write (still serialized against incoming/outgoing keystrokes via the shared lock from the previous release).

v1.0.25 — PETSCII MRC chat: password masking, AFK interruption, message-splicing fixes (August 2026)

Three real bugs found live-testing v1.0.24's new PETSCII MRC client on the Pi, all traced to the same root cause. PetsciiMRCChat._read_chat_line() originally delegated to the generic session.read_line() for simplicity — that turned out to be wrong three ways:

  1. /identify <password> echoed the password in the clear, unmasked. read_line() has no masking; the real per-keystroke masking logic lives in the ANSI client's raw input loop, which the PETSCII override bypassed entirely.
  2. The AFK warning/screensaver could interrupt an active chat session. read_line() always opts into AFK tracking internally — MRC is specifically designed to never go through that path at all.
  3. An incoming message arriving mid-keystroke spliced into the line being typed, corrupting the display (the message actually sent was still correct — this was a rendering race, not data corruption). ANSI split-screen mode avoids this because incoming messages and the input line are drawn to separate cursor-addressed regions; plain-scroll mode has no such separation, so both now share the same lock the ANSI client already uses for this.

Fixed by replacing the delegated read_line() call with a proper PETSCII-safe character-by-character input loop — reading raw off the connection (matching the ANSI client's own approach, which also sidesteps AFK), reimplementing password masking at the single-character level, and serializing with incoming-message writes via the shared input lock. Also fixed _term_columns never being set for PETSCII sessions (stuck at its 80-column default), which affected a couple of width calculations.