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.223 — Zip-archive image galleries (July 2026)
Requested: image galleries should support the same one-photo-per-zip layout Digital Showroom (Codefenix's Synchronet gallery door) already uses — several TIC-fed file areas (a daily NASA photo feed among them) arrive in exactly that shape, and pointing a gallery straight at that same directory previously showed nothing, since only loose image files were recognized.
- FEATURE: gallery directories can now hold
.ziparchives alongside (or instead of) loose image files. Each zip is treated as one gallery entry — its one image member is extracted in memory only, never to disk — for both the thumbnail grid and the full-size view. A zip with more than one image inside just shows the first (sorted by name, skipping__MACOSX/junk and dotfiles); a zip with no images inside 404s cleanly if someone clicks it. Works transparently through the existing gallery config — no new per-gallery setting, just point thepathat a directory with zips in it (e.g. the samestorage_patha matching file area already unpacks its TIC feed into). - Extended to every gallery surface: the public browse grid, the admin file-manager thumbnail grid (same underlying route), and admin drag-and-drop upload (now accepts
.zipalongside images). - Also extended the terminal fallback viewer (
deploy/anet-gallery.sh, chafa/img2sixel for legacy telnet/SSH callers) with the same zip-extraction logic, so the two gallery surfaces don't drift — matching this project's now-familiar "web vs. terminal sibling path" pattern from the recent audit series. - 14 new tests (
test_gallery_zip_archives.py).
Also fixes a live CI failure: an in-progress edit toward this feature (added-but-not-yet-used imports in anetbbs/web/gallery.py) got synced to GitHub mid-edit and failed the pyflakes gate. That's what the failed check on the previous push was — nothing wrong with v1.0b2.222's actual audit content, just this feature caught mid-flight. Confirmed clean now: pyflakes anetbbs/ and bandit -r anetbbs/ -ll both pass locally, matching CI exactly.
v1.0b2.222 — Full install/update re-verify + webhook board scoping (July 2026)
Phase 4 (the final phase) of the 4-part audit list (auth/session core, file areas, message boards, install/update re-verify). Two parallel research passes (install.sh's fresh-install path, update.sh + anetbbs/installer/'s upgrade path), every finding personally verified against the real code before fixing. Also includes a follow-up fix requested after seeing it listed as deferred in v1.0b2.221: webhook board scoping.
High:
- anetbbs/installer/upgrade.py (the anetbbs-upgrade in-app wizard) never actually restarted the real running service. It stopped/started the legacy pre-merge unit names (anetbbs-telnet/anetbbs-ssh/anetbbs-rlogin) — units that don't exist on any current install (telnet/SSH/rlogin/FTP/PETSCII have been one combined anetbbs.service for a while, since Ubuntu's systemd EnvironmentFile directive winning over per-unit Environment= overrides meant the old split units couldn't reliably share .env and fought each other for ports). Since systemctl stop/start on a nonexistent unit just no-ops, every upgrade via this wizard rsynced new code to disk but kept the real terminal/FTP process serving pre-upgrade code indefinitely, completely silently — the health check only ever probed the web app. Fixed to use the real unit names, matching update.sh's own restart logic.
- Same wizard's rsync exclude list was missing the /doors/ and MRC-bridge-config protections update.sh already has — added after a real v195-era incident where a --delete deploy wiped a production install's entire doors tree (DSR, BotWars, RDQ3, the 6500-GIF library). That fix was applied to update.sh but never mirrored to this second, separate upgrade tool. Fixed by mirroring the same exclude list; doors/ is now left untouched by this wizard (with a note pointing sysops at update.sh's more elaborate arch-aware door sync if they want bundled door updates too, rather than reimplementing that logic a second time).
- Every fresh install.sh install silently created two full-admin accounts. anetbbs/web_app.py's _create_default_data() bootstraps a fallback account literally named "admin" whenever no user with that exact username exists — but install.sh's wizard creates the sysop's own account (e.g. "ANetBBS Sysop") by calling create_app() first, which runs this seeding function before the wizard's own explicit account creation even gets a chance to run. Since the sysop's chosen username is (almost) never literally "admin", this fired on every fresh install, leaving an unlisted second admin account with a random password only ever shown in a log line / data/admin_password.txt. Fixed two ways: the seeder now checks for any existing admin (is_admin=True) rather than the literal username, and install.sh now cleans up the redundant, never-logged-into fallback account after creating the sysop's real one.
- anetbbs-install (the alternative Python installer wizard) installed the same broken legacy split units install.sh already moved off of — meaning telnet/SSH/rlogin were effectively non-functional for anyone using this documented, supported alternative install path, and it was never granted CAP_NET_BIND_SERVICE, so FTP silently failed to bind too. Fixed to install the single correct anetbbs.service with the same capability grant install.sh/update.sh already add.
- TRUST_PROXY_HEADERS (new in v1.0b2.218) was never written by install.sh, even in its default "production" mode where it generates an nginx reverse proxy in front of the app — meaning every visitor's IP appeared as 127.0.0.1 to Flask on a stock install, silently breaking IP bans, country blocking, and login rate-limit auto-ban. Now written as true exactly when (and only when) install.sh itself set up nginx and confirmed (via its own UFW rules) that Flask's own port isn't directly exposed — the precise precondition the setting's own security warning requires.
- MSP_ENABLED/SYSTAT_ENABLED were hardcoded True in config.py, unlike every sibling *_ENABLED flag — install.sh's wizard prompt and Admin → Settings' own MSP toggle both wrote the correct value to .env, but neither ever took effect: the listeners started on every boot regardless, and the "live" config override only lasted until the next restart. Now reads from the environment like everything else.
Medium:
- deploy/run_upgrade.sh (the privileged helper behind the web UI's "Check for Updates" button) resolved the correct install directory from /etc/anetbbs.install but passed it to update.sh as an environment variable — which update.sh's own argument parser unconditionally discards before reading --install-dir from argv. Usually resolved to the same path by coincidence (via update.sh's own fallback detection), but the explicit resolution was dead plumbing. Now passed as the real flag.
- anetbbs/installer/upgrade.py had no disk-space preflight check, unlike update.sh (added after a real incident where a sysop's disk hit zero mid-update and corrupted the install). Same 500MB floor added.
- tzdata (added to requirements.txt in v1.0b2.202 for Eastern-time display on minimal Docker images / distros without system tzdata) was never added to setup.py's install_requires — and both upgrade paths only ever run pip install -e ., never pip install -r requirements.txt, so an existing install upgrading through either path never picked it up. Backfilled — same gap class this project has hit before with requests/aiosmtpd/aiosmtplib.
- FTP (21/tcp + 40000-40050/tcp passive) and PETSCII40/80 (6400/6401/tcp) are off by default and not wizard-prompted by install.sh — unlike every wizard-prompted transport, enabling them later never got a UFW rule or even a mention that one's needed. Now noted in the install summary.
Follow-up (sysop request, not from the audit itself): webhooks.fire('post', ...) fired for every board with no way to scope one to a single board — an admin wiring up a public "new post" Discord/Slack mirror also silently mirrored sysop-only/VIP-restricted board content externally. Added Webhook.board_id (nullable, self-migrates via the existing auto-sweep — no manual migration needed), fire() now skips a scoped webhook when the post's board doesn't match, and the admin form got a Board dropdown (ignored for every non-post event).
~20 new/updated tests across 2 new test files (test_install_update_reverify_v222.py, test_webhooks_board_scoping.py). Shell-script-only fixes (install.sh, update.sh-adjacent upgrade.py, wizard.py) have no test harness in this repo and were verified by direct code reading, consistent with this project's established practice for that class of change. Full suite verified clean: 1744 passed, 2 skipped.
This closes the 4-phase audit list: auth/session core (v1.0b2.218) → file areas + FTP (v1.0b2.220) → message boards (v1.0b2.221) → install/update re-verify (this release).
v1.0b2.221 — Full message-boards security audit (July 2026)
Phase 3 of the 4-part audit list (auth/session core, file areas, message boards, install/update re-verify). Two parallel research passes (web boards.py routes, terminal ANSI + PETSCII board posting/threading), every finding personally verified against the real code before fixing. This phase's earlier passes (echomail/QWK, then file-areas) had already fixed the equivalent READ-side gap (test_boards_access_control.py); this pass found the same bug class had never been mirrored to the WRITE and interaction side.
High:
- reply_post() had ZERO board-access enforcement — not even the read-level gate view_post() already has, let alone Board.min_write_level. Any authenticated user, regardless of access_level, could POST a reply into a sysop-only/VIP-restricted board's thread just by knowing or guessing a post_id. Fixed to check both, matching new_post()'s existing pattern.
- subscribe() had no access check — a below-level user could subscribe to a restricted board and have every future post's subject/author leaked via new_post()'s subscriber-notification fan-out, with zero further action needed after the initial subscribe. Fixed (unsubscribing still always works, even if access was revoked after the fact).
- notify_mentions() leaked restricted board content via @mentions — a post in a sysop-only/VIP board that mentions any username pushed that user a live notification containing up to 280 characters of the restricted content, regardless of their own access level. notify_mentions() now takes an optional min_access_level and skips mentioned users who don't meet it; both board call sites (new post, reply) now pass the post's board level.
- Terminal board posting (ANSI + PETSCII) checked neither Board.min_write_level nor Post.is_locked at all. Unlike the web routes, any authenticated telnet/SSH/rlogin/PETSCII user could post/reply regardless of a board's configured posting level or a moderator having locked the thread. PETSCII's new-thread path ('N') was already correctly gated on write-level — only its reply path ('R') was missing it, the same "fix applied to one sibling, not the other" pattern this project keeps finding. Both terminal composers now gate on a single choke point (_post_compose/_board_post) so neither caller can be the weak link.
Medium:
- votes.py's _can_vote_on() only checked existence for post/echomail message types, never the board/area's own min_access_level, despite the module's own docstring claiming otherwise — any authenticated user (or, via the unauthenticated /api/vote/tally lookup, any anonymous visitor) could vote on or read the tally of a restricted post/echo-area message by id. Both branches fixed.
- react() had no board-access check — an IDOR letting any authenticated user confirm a restricted post's existence and react to it by id. Fixed.
- saved.py's bookmark feature let a user save any post_id regardless of board access, permanently showing its subject/author on their own /saved/ page even though visiting the real thread would 403. Fixed for the post kind (this phase's scope); echomail/netmail/pm bookmarking have their own separate access models, noted but not touched here.
- /sitemap.xml enumerated every board and its 500 most recent posts — including sysop-only/VIP-restricted ones — to any unauthenticated crawler, unlike every other listing route in this codebase. Now filtered the same way.
- No flood protection at all on board posting, unlike /api/vote (60/min) or /imsg/send (30/hr) elsewhere in this codebase — added a 20-per-5-minutes rate limit shared between new posts and replies.
- Terminal board posts skipped the sysop word-filter blocklist entirely — web already runs subject/content through it. Both terminal composers now do too.
- Closed a latent IDOR defense-in-depth gap: read_thread_v2()/_thread_read() never re-verified a fetched post's board_id matched the board_id they were called with — not currently reachable (today's only callers always pre-scope correctly) but now closed so a future "jump to post #"/search/notification-deep-link feature can't reopen it silently.
Deliberately deferred (noted here, not fixed): webhooks.fire('post', ...) broadcasts full post content to every configured webhook with no per-board scoping — an admin who wires up a "new post" mirror also silently mirrors restricted-board content externally. Requires an admin to have configured a webhook in the first place (admin-trust-boundary, same posture as other admin-configured integrations in this codebase), and per-board webhook scoping is a feature addition, not a bug fix, so left as a known limitation for now.
~30 new/updated tests across 2 new test files (test_boards_write_security_audit_v221.py, test_boards_terminal_security_audit_v221.py). Full suite verified clean: 1732 passed, 2 skipped.
v1.0b2.220 — Full file-areas security audit (July 2026)
Phase 2 of the 4-part audit list (auth/session core, file areas, message boards, install/update re-verify). Two parallel research passes (web file-area routes, FTP server), every finding personally verified against the real code before fixing.
High:
- smart_upload() moderation-queue bypass. The per-area upload() route already routed non-admin uploads into FileQueueEntry quarantine when FILE_MOD_QUEUE_ENABLED was on — its sibling smart_upload() route (auto-detects/lets a user pick the target area by tag) never checked the flag at all, saving straight to disk and TIC-hatching to network peers immediately. Any user with upload permission on the target area could use this route instead of the per-area form to get a file live with zero sysop review, even with moderation explicitly turned on. Fixed to match upload()'s quarantine behavior exactly (admins still bypass the queue, same as before).
- FTP login bypassed account-lock and NUV-verification gates. AnetbbsAuthorizer.validate_authentication() only checked User.is_active — unlike web login and every terminal transport, it never checked is_locked or is_verified. A locked-out or not-yet-approved account could still fully authenticate over FTP and read/write every non-sysop file area. Now checks both, with the same admin bypass on is_verified the other login paths use.
- Zero brute-force protection on FTP auth. No AutoBanConfig/IpBan/rate-limit integration anywhere in the FTP server — the same bug class already fixed for telnet/SSH/rlogin/PETSCII in v1.0b2.218, unaddressed on this transport. Fixed with the same models, a dedicated ftp_login:<ip> rate-limit bucket.
- CRITICAL — unsanitized QWK packet_id from the public network-join form flowed into a filesystem path. The public, unauthenticated network-join application form's qwk_packet_id field only enforced Length(max=8) — no charset check — unlike the admin QWKNodeForm and the self-service QWK apply API, both of which already regex-validate specifically because packet_id becomes the FTP server's per-node home directory (os.path.join(qwk_root, packet_id.upper())). "../../.." is exactly 8 characters. Once a sysop approved such a request, that string became a real QWKNode.packet_id and, on the node's next FTP login, its session root — escaping data/qwk-hub/ outward with full read/write/delete/rename/mkdir permission. Fixed at both layers: the public form now rejects non-alphanumeric input at submission time, and approve_join_request() re-validates server-side as defense in depth (matching the sibling QWKNodeRequest approval flow's existing precedent).
Medium:
- Regular FTP users got a flat read/write permission over their entire session home directory, so upload_permission='none'/'sysop' areas (meant to be sysop-curated/read-only for regular users) could still be deleted from, renamed within, or have directories created/removed — the existing upload-permission check only ever gated STOR. Added a pre-check on DELE/RNFR/RNTO/MKD/RMD mirroring the same area permission.
- FTP uploads never went through the ClamAV scan every web upload route uses — on_file_received() wrote straight to a FileUpload row with zero AV check. Added the same scan-and-reject (fail-open on scanner errors, matching the web routes' posture).
- QWK node password comparisons (FTP login and the QWK hub's HTTP Basic Auth) used !=/== instead of a constant-time comparison — a low-value but real timing side channel. Switched both to hmac.compare_digest. Storage itself stays plaintext by design, same as BinkPNode.password: both are shared secrets the server must read back verbatim (QWK/BinkP session auth, AreaFix passthrough), not hashable login credentials.
- manage_desc() was missing the path-traversal confinement check its sibling manage_delete() already has.
- Ratio-enforcement crashes were silently swallowed (except Exception: pass) instead of logged — a real bug in the check would have disabled ratio enforcement with zero trace.
- Quarantine filenames used a 1-second-resolution timestamp prefix, risking a same-second collision; switched to secrets.token_hex.
Deliberately deferred (noted here, not fixed): a quota TOCTOU race in features/file_quota.py, and a FileQueueEntry.approve() TOCTOU — both low-severity (soft caps / admin-only double-click edge cases), not worth the added row-locking complexity right now.
~40 new/updated tests across 3 new test files. Full suite verified clean: 1703 passed, 2 skipped.
v1.0b2.219 — Hotfix: PETSCII/multinode login crash (July 2026)
Emergency fix, live-caught immediately after v1.0b2.218 deployed: PETSCII terminal login was completely broken, and the multinode "Who's Online" screen crashed the same way.
- FIX (critical, live-caught):
features/petscii_ui.py's profile screen andfeatures/multinode.py's node list both used an f-string likef'...{fmt_eastern(x, '%Y-%m-%d')}...'— a single-quoted f-string with a single-quoted argument nested inside its{}. That's valid on Python 3.12 (PEP 701 relaxed the rule), which is what this dev sandbox runs, sopy_compilehere never caught it — but it's a hardSyntaxErroron Python 3.10/3.11, which is what production actually runs, so the module failed to even import there. Fixed by switching the outer f-string to double quotes in both spots. Swept the wholeanetbbs/tree for the same quote-nesting pattern (a custom scanner, since this dev environment's Python version can't detect it) — confirmed these were the only two instances.
v1.0b2.218 — Full auth/session security audit (July 2026)
First item of a 4-part audit list (auth/session core, file areas, message boards, install/update re-verify). Three parallel research passes (web auth, terminal auth, access-control primitives + rate limiting/session config), every finding personally verified against the real code before fixing. This is the single most security-critical batch shipped this project — see docs/SECURITY.md for the sysop-facing summary of what changed.
Critical:
- evaluate_access()'s anonymous default was 10, not 0. The shared read-access gate behind boards, echomail areas, QWK, RSS, and file areas fell through to access_level 10 ("registered") for a logged-out visitor instead of 0, contradicting its own docstring — an anonymous visitor silently passed the "registered users only" gate on anything using the standard default. This directly defeated a fix boards.py's own code comments describe having already made. Independently confirmed by games.py's own hand-rolled workaround for the exact same bug.
- X-Forwarded-For was trusted unconditionally, with no reverse-proxy boundary, in four places (web/auth.py, web/file_areas.py, web/registry.py, web/network_join.py). A direct connection could spoof it to bypass IP bans/country blocks, dodge every per-IP rate limiter, or — worst case — make the login-rate-limit auto-ban land on an arbitrary victim IP instead of the attacker's own (the rate-limit bucket was keyed on the real IP, but the auto-ban target read the spoofable header). Fixed via a new opt-in TRUST_PROXY_HEADERS setting (off by default) that wires up Werkzeug's ProxyFix at the WSGI layer — the only place the header is trusted now, and only when a sysop has confirmed Flask sits behind their own trusted proxy.
- Telnet/SSH/rlogin/PETSCII login had zero rate-limiting or lockout of any kind, unlike the web login (protected by IP bans + a configurable auto-ban). Worse on SSH specifically, since asyncssh's own password validator always accepts by design (to support the "client already sent credentials" convenience flow) — the SSH layer itself never rejected an attempt either. Every terminal transport funnels through one UserManager.authenticate() call, so this closes it in one place: the same AutoBanConfig/IpBan policy the web login already enforces.
High:
- /auth/forgot was a reliable username/email enumeration oracle. Since registration requires 3 security questions, the redirect target (security-question verify page vs. a generic "if that account exists" message) reliably revealed whether an account existed for almost every real user. Fixed so every submission redirects to the same verify page — a nonexistent (or answerless) account now gets a random, unanswerable decoy question, indistinguishable from a real one by redirect target or page content. An answerless account's real recovery path (email/journal token) still fires normally in the background.
- Security-question brute force had no rate limit or attempt cap — a wrong guess left the session state untouched, so the same question could be retried indefinitely. Now capped at 5 attempts per recovery session plus a route-level rate limit, matching /auth/forgot's own new limit.
Medium:
- rlogin's header parser (_read_rlogin_header) had no cap on accumulated buffer size — a client that never completed the handshake could grow it unbounded. Now capped at 4096 bytes (real headers are a few hundred at most).
- Terminal read_line()/read_password() had no length cap either — same unbounded-growth risk for username/password/every other terminal text prompt. Capped at 2048/256 chars respectively; excess input is silently truncated rather than causing a failure.
- web/auth.py's /auth/forgot and /auth/verify/resend had no rate limiting at all (only /auth/login and /auth/register did) — both added.
Low (functional, not security):
- UserManager.create_user() (terminal registration) mislabeled a genuine username-collision race as an email collision, telling the second registrant their email was taken when it was actually their username. web/auth.py's register() had the identical race with no handling at all (raw 500 instead of a friendly message). Both fixed the same way: inspect which unique constraint actually fired.
~40 new/updated tests across ~15 new test files. Full suite verified clean (1680 passed, 2 skipped).
v1.0b2.217 — Uncapped admin pending-queue lists (July 2026)
Follow-up from the previous version's audit report: two admin pending-request lists (Network Join Requests, QWK Node Requests) had no cap at all, unlike their "recently reviewed" counterparts (capped at 50) — both are fed by public, unauthenticated forms, so a spam/scan burst could grow either list unbounded and slow the page down. Capped at 500 (well above any realistic legitimate backlog), with a warning flash if the cap is ever actually hit so a real flood beyond it isn't silently hidden from the sysop — every pending item still needs an actual decision, so just truncating it silently would be worse than the slow-page problem this fixes.
Full suite verified clean.
v1.0b2.216 — Full echomail/QWK subsystem audit (July 2026)
Sysop asked for a full pass over echomail/QWK — every error/gap, docs kept in sync. Five parallel research passes (BinkP transport, AreaFix/FileFix, QWK, web admin layer, docs/wiki), every finding personally verified against the real code before fixing. 20 real bugs fixed, most with new regression tests.
Security fixes:
- AreaFix/FileFix leaf-side password bypass: process_request() in both areafix.py and filefix.py only ever rejected a request when a password was configured AND wrong — a network with no areafix_password/binkp_password set at all sailed straight through with zero authentication, letting any spoofable inbound netmail addressed to "areafix"/"filefix" freely subscribe/unsubscribe every echo/file area. The hub-side sibling function (_process_node_request) had already been fixed for this exact bug class in an earlier session — the fix was never mirrored back to the leaf-side function. Fixed both.
- Cross-area message leak: echomail.thread() fetched its seed message by bare ID with no area_id scoping, unlike every sibling route in the file — a logged-in user could view a message's from/to/subject/body from a sysop-only or restricted-access area by URL, bypassing the area access check entirely.
- QWK bypassed all echo-area access control: /qwk/download and /qwk/upload queried EchoArea.query.filter_by(is_active=True) with zero gating, unlike every other echomail entry point — any logged-in user's QWK packet included sysop-only/restricted area content, and REP upload let them post into those same areas.
- BinkP inbound listener auto-created echo areas with no review: any peer that completed a BinkP handshake (a downstream node, or the hub calling in) caused an unrecognized AREA: tag to silently create a new, immediately-active, immediately-subscribed echo area. The outbound-dial receive path already routed unknown tags to BadAreaLog for sysop review (SBBSecho's BadAreaFile convention) — the inbound-listener path never got the same treatment.
- Minor: missing CSRF token on the hub-identity "Make Default" button (functional bug, not exploitable — the button just silently 400'd); QWKNodeForm.packet_id and the QWK node-request approval path had no regex validation, unlike the self-service API (packet_id flows into filesystem paths).
Functional fixes:
- "Poll Node" (hub dials out to a downstream node) never flushed that node's queued netmail — only its echomail hold queue. The inbound-listener direction already fixed this for the exact same node; the dial-out counterpart never got it.
- Nodelist import silently dropped every point-address entry (12.5 style) — int(raw_node) was tried before the dedicated point-parsing block, which made that block permanently unreachable for the one input shape it existed to handle.
- QWK HTTP-hub REP import tagged every message with an arbitrary "first active network" instead of the target area's own network on any multi-network install (same "network misattribution" bug class already fixed once for BinkP). Also added per-message SAVEPOINT isolation (a single bad message no longer poisons the whole batch) and msg_id dedup (a retried upload no longer duplicates every message) — both already fixed once in the sibling FTP-hub importer, never mirrored to this one. Added the same msg_id dedup to the FTP-hub importer's own upload path too.
- binkp.py's outbound client sends the BinkP-convention - placeholder for "no password configured"; the server never recognized it as equivalent to a blank stored password, so two intentionally-unsecured ANetBBS links (e.g. testing a leaf before adding real credentials) couldn't actually authenticate.
- AreaFix/FileFix hub-side log rows never recorded network_id, so a downstream node's subscription activity was invisible whenever a sysop filtered the AreaFix Log by network.
New: AreaFix Log now shows which bot handled each row (Bot column + AreaFix/FileFix filter — FileFix has always logged into the same table, just never surfaced).
Docs/wiki: fixed a wrong "only in-UI path" claim about %RESCAN, a fabricated Network Join form field list on the wiki, added FileFix mentions where only AreaFix was documented, and cross-referenced the new TIC Out Log (/admin/hatch-log, shipped last version) from both docs pages and the wiki.
Deliberately not fixed this round (flagged, lower-confidence or design judgment calls): BinkPHoldQueue.retry_count is a dead column (same shape as the HatchQueue fix from last version, not yet wired up); inbound-dialed BinkP sessions don't fire the echomail webhook (only outbound-dial does); netmail attribute flags (private/crash/hold) are parsed off the wire but discarded on import; QWK node passwords have no CRAM-MD5-equivalent challenge/response (BinkP does) — a QWK/FTP/HTTP-Basic protocol-level constraint, not straightforward to fix; the per-user QWK upload's conference-number mapping still isn't stable across area-set changes between a download and a later upload (would need a persistent per-user table, bigger than a bugfix).
Full suite verified clean (1639 passed, 2 skipped).
v1.0b2.215 — Hub-management logging pass + Network Join fixes (July 2026)
Sysop asked for a TIC-out log for hub management and to make sure hub-management logging is complete, plus two Network Join Requests fixes.
TIC/file-echo delivery logging — HatchQueue.retry_count/error_message/status='failed' were part of the model from the start (its own docstring: "Failures bump retry_count; we cap at retry_max and mark failed"), but neither delivery path (outbound-dial in binkp.py, inbound-listener in binkp_server.py) ever actually wrote to them — a failed delivery attempt was silently indistinguishable from one never even tried, and the Hub admin page's "Failed" counter was permanently 0. Now both paths record every failed attempt (bumping retry_count, storing the reason) and give up after 20 attempts, flipping the row to failed instead of retrying forever with zero visibility.
New TIC Out Log (/admin/hatch-log) — the Hub admin "TIC" tab only ever showed two aggregate counters with no way to see what's actually queued, for which peer, or why something failed. New page lists every outbound hatch item (pending/sent/failed, filterable), linked from the Hub TIC tab alongside the existing (previously orphaned — no nav link anywhere) TIC In Log.
AreaFix Log now shows which bot handled each row — FileFix (file-echo subscriptions) has always logged into the same AreafixLog table as AreaFix (echomail subscriptions), distinguished only by an unshown bot column. The page was titled/described as AreaFix-only, with FileFix activity silently commingled and unlabeled. Added a Bot column + filter.
Network Join Requests:
- Node auto-numbering (added v1.0b2.83) only fired when NetworkJoinConfig.binkp_zone/binkp_net were configured separately from the hub's own HubIdentity.binkp_zone/binkp_net (already set and used elsewhere — nodelist, default outbound address). A sysop who'd already told the system its own zone:net had no reason to expect a second, redundant setting, and approvals silently used whatever address the applicant typed into the public form instead of auto-numbering. Now falls back to the hub identity's own zone/net; NetworkJoinConfig's fields still override when explicitly set.
- Added an explicit "View" button to the pending requests list (the full detail page already existed, linked only via the BBS Name text — easy to miss next to the prominent Approve/Deny buttons).
Full suite verified clean (1618 passed, 2 skipped).
v1.0b2.214 — TIC/file-echo hatch-out: third delivery direction fixed (July 2026)
Sysop-reported "TIC isn't working right" led to a full re-audit of the TIC/file-echo pipeline. The manifest parsing, security checks, and two of the three BinkP delivery directions were already correct and covered by tests from an earlier audit — but the third was silently missing:
- Upstream hub dialing IN to us never flushed pending file-echo hatch-out. binkp_server.py's inbound listener already handled outbound echomail/netmail correctly for this direction (the hub calls us), and already handled file-echo hatch-out for the other two directions (we dial our hub; a downstream node dials us) — but never queried
HatchQueueat all when the connecting peer matched by networkhub_addressinstead of a specific node. A file subscribed via FileFix to go out to the hub would sit instatus='pending'forever unless the sysop happened to poll out to the hub first themselves, with the admin's "Pending" counter the only visible symptom.
Added regression tests for the fixed direction and fixed 8 existing BinkP-listener tests whose hand-rolled query fakes needed to account for the new HatchQueue lookup.
Also documented a real-world dosemu2 gotcha reported live: ERROR: MFS: failed to get xattrs for .../TWERR.LOG, Numerical result out of range when running door games (e.g. TW2002) means the filesystem /opt (or wherever DOS game data lives) is mounted without the user_xattr option — dosemu2's MFS layer needs it. Fix is an fstab mount-option change, not an ANetBBS bug.
Full suite verified clean (1605 passed, 2 skipped).
v1.0b2.213 — Pre-release audit #2: pyflakes CI gate green (July 2026)
The new pyflakes CI job (added last batch) was failing on ~40 pre-existing findings across ~20 files that predated the CI gate itself — none were new regressions from recent work. All genuinely dead code (unused local variables, redundant f-string prefixes with no interpolation, global declarations that were never needed since the function only mutated the object in place rather than rebinding the name). Also fixed 2 spots where a # noqa comment was silently doing nothing: plain pyflakes (unlike flake8) has no inline suppression syntax at all, so two "load-bearing side-effect import" cases now use a real reference instead of a comment to satisfy the checker.
One small real bug found along the way: the sysop setup wizard's BBS Name field was captured from the form but never actually persisted anywhere — silently ignored on every submission. Now writes to .env like the other wizard fields.
Full suite verified clean (1601 passed, 2 skipped). bandit re-verified clean (0 medium/high).
v1.0b2.212 — Access-control audit batch, web IRC client fixes, full docs/wiki accuracy pass (July 2026)
Access-control fixes (Phase 1 of the pre-release audit, remaining routes):
- Vote tallies on private netmail/PMs were readable by anyone who could guess the message id — the read-only tally endpoint skipped the same visibility check the actual voting endpoint enforces.
- The leaderboard page leaked restricted/sysop-only board names and activity (post counts, top posters, reactions) to any visitor.
- Game Center's global scoreboard and per-game leaderboard both skipped the level-gating check every other game route already enforces — a gated game's name and scores were visible by id.
- A user could self-issue an anonymous file-share link for something they can already see and re-download it repeatedly to route around their own daily download quota.
Web IRC client (reported live — commands looked broken, had to fall back to the terminal client):
- /msg target text never echoed anything back to the sender, so e.g. /msg nickserv identify ... looked like it silently did nothing.
- A private reply from someone else (NickServ's included, since those are almost always sent as a NOTICE) landed in a browser tab that was never actually created — captured but invisible.
- Added /query <nick> and /help.
- The connect form's default server ignored Admin → IRC Server Presets entirely, always falling back to a hardcoded irc.libera.chat — now uses the sysop's configured default the same way the terminal client already does.
Docs/wiki accuracy pass — first full systematic sweep of all 52 wiki pages and 30 docs files (previous passes were reactive to specific bug reports). ~22 stale/wrong claims fixed: wrong routes (several pages pointed at URLs that don't exist), a wiki page describing an SSH public-key-auth feature that was never built (and is in fact intentionally disabled), swapped Mystic Python/Pascal door-type descriptions, a stale /tmp backup path doc (moved to the install dir a while back), a missing built-in game (ANetDarkForces) absent from two different game listings, and more.
Full suite verified clean (1601 passed, 2 skipped).
v1.0b2.211 — Pre-release audit, batch 3: install/update script fixes (July 2026)
Full re-read of install.sh and update.sh (the actual Full Release critical path) via two independent audit passes, cross-checked against current code. Real bugs found and fixed:
- install.sh only wrote the anetbbs.service systemd unit if the sysop enabled Telnet or SSH during setup — but that same service also owns rlogin/FTP/PETSCII40/PETSCII80, which are documented as "enable later by editing .env" and have no unit to start without it. A sysop who declined both Telnet and SSH but later turned on PETSCII or FTP had nothing to
systemctl enable. Now always written, matching how update.sh already handles it. - update.sh's self-healing MRC bridge config generator (used when config.json is missing, e.g. upgrading a pre-MRC-bridge install) hardcoded the bridge's web_listen_port to 8080 instead of reading the real value from .env — silently mismatched nginx's proxy target on any install using a non-default web port.
- update.sh's rollback-on-failure path only restored the production database backup, never the dev database backup, even though both are backed up unconditionally up front. An install running against anetbbs_dev.db (via DATABASE_URL) had no real rollback if an update failed partway.
- Removed a harmless but dead RLOGIN_ENABLED check in install.sh's install summary (rlogin isn't wizard-configurable, the variable was never actually set).
Full suite verified clean; targeted upgrade/install test files re-run clean (27 passed).
Note for later: a cosmetic-only rollback gap was also found (legacy anetbbs-telnet/anetbbs-ssh unit files can be resurrected-but-disabled after a rollback that follows the telnet+ssh-unification migration) — doesn't break anything, left as-is.
v1.0b2.210 — Pre-release audit, batch 2: install/update version-string fix (July 2026)
- Fixed a 3-way inconsistent hardcoded version string used for the MRC bridge
platform_infofield:install.shandupdate.shboth had a stale, unrelatedBBS_VERSION="1.3.7"literal (matching no real ANetBBS version, ever), andmrc/bridge/config.example.jsonhad a third, different stale value ("1.3.20").install.shnow derivesBBS_VERSIONdynamically from theVERSIONfile;update.shreuses its existingNEW_VERSIONvariable instead of a separate hardcoded one; the example config now uses an obvious placeholder instead of a fake-looking real version number. - Part of the pre-Full-Release audit (install/upgrade path re-verification).
Full suite verified clean (1588 passed, 2 skipped).
v1.0b2.209 — Prepare for the v1.0.0 stable release version format (July 2026)
Getting ready for Full Release: v1.0.0 will be a plain stable version number (no alpha/beta marker), not a continuation of the beta build-number sequence. This build teaches every part of the update pipeline to recognize and correctly rank that new format alongside the existing vX.Y[ab]Z.NN beta/alpha form — shipping it now, ahead of the actual v1.0.0 tarball, so every install (including this one) has already updated its understanding before the cutover happens. Covers: the update-checker's version parser and comparison logic, the tarball-name matcher, the release-notes "what's new" panel, and the privileged upgrade wrapper's argument validation (plus its existing self-healing patcher, extended with a 3rd migration step, so an older already-deployed wrapper upgrades itself automatically on next use — no manual server-side intervention needed).
Full suite verified clean (1588 passed, 2 skipped).