ANetBBS Changelog

Versions are internal build numbers. Public releases are tagged
separately. Current release: v1.0b2.58 (July 2026). Full release: August 1 2026.

v1.0a2.69 — Fix: web server database path wrong after non-editable pip install (May 2026)

anetbbs-web crashed with sqlite3.OperationalError: unable to open database file.
config.py computes BASE_DIR = Path(__file__).parent.parent at import time. With an
editable install (pip install -e .), __file__ points to the source tree and BASE_DIR
is the install root. When pip falls back to a non-editable install, __file__ is in
site-packages and BASE_DIR is wrong. The admin setup heredoc always worked because it
explicitly sets sys.path and DATABASE_URL. deploy/serve.py did not.

Fix: deploy/serve.py now inserts INSTALL_DIR (from its own __file__) at the front
of sys.path and sets DATABASE_URL to the absolute path if not already set — mirroring
what the install.sh admin setup has always done.

v1.0a2.68 — Python 3.12 fix: patch threading.get_ident for greenlet safety (May 2026)

eventlet.monkey_patch() replaces threading.get_ident with a greenlet-based call to
greenlet.getcurrent(). During Python 3.12's import-time GC/logging cleanup, the hub
greenlet isn't fully initialised yet, so getcurrent() raises
RuntimeError: greenlet is being finalized. Python 3.12 changed the threading/GC startup
interaction, triggering a race that Python 3.10/3.11 didn't have.

Fix (in deploy/serve.py): after eventlet.monkey_patch(), wrap both
threading.get_ident and eventlet.green.thread.get_ident to catch RuntimeError and
fall back to _thread.get_ident() (real C-level thread ID). Safe because the crash only
occurs in finalizer paths, not during request handling. Also added try/except around
create_app() and socketio.run() to surface any other startup errors via the journal.

v1.0a2.67 — Web server: drop gunicorn+eventlet, use eventlet native WSGI (May 2026)

gunicorn --worker-class=eventlet is broken on Python 3.12: fork()+greenlet
causes RuntimeError: greenlet is being finalized at worker boot (exit code 3).
Flask-SocketIO's own docs say not to use gunicorn+eventlet; use eventlet's
built-in WSGI server instead (no fork = no crash).

  • Added deploy/serve.py — calls socketio.run() which uses eventlet.wsgi.server
  • Updated deploy/anetbbs-web.service, install.sh, update.sh to use
    python deploy/serve.py instead of gunicorn
  • Logs now go to journalctl instead of a separate gunicorn-access.log

v1.0a2.66 — Install fix: Python 3.12 gunicorn/eventlet crash (May 2026)

anetbbs-web failed to start on Python 3.12 with gunicorn WORKER_BOOT_ERROR
(exit code 3). The eventlet worker crashed during init:

RuntimeError: greenlet is being finalized — eventlet/green/thread.py get_ident()

Python 3.12 changed threading/GC finalization in a way that breaks greenlet < 3.0.0.
eventlet>=0.33.0 did not constrain greenlet's version, so pip could resolve
greenlet 2.x, which crashes at startup. Fix: explicit greenlet>=3.0.0 +
eventlet>=0.35.2 in setup.py.

v1.0a2.65 — Install fix: PEP 440 version format (May 2026)

Fresh installs failed on Python 3.12+ with newer setuptools:
packaging.version.InvalidVersion: Invalid version: '1.0a2.64'.
The 1.0a2.NN format is not valid PEP 440. This was a long-standing bug hidden by
--quiet 2>/dev/null suppressing pip output until v1.0a2.62 made errors visible.

  • setup.py now uses 1.0a2.postNN (PEP 440 post-release notation).
  • Display version in admin panel / filenames stays v1.0a2.NN (from __init__.py).

v1.0a2.64 — TIC file-echo ZIP binary + duplicate error spam (May 2026)

Fixed two BinkP/TIC processor bugs that caused tqwinfo.zip and similar
hatched file-echo ZIPs to never land in the inbound directory:

  • binkp.py: inbound ZIPs were always treated as mail-bundle ZIPs and
    unpacked looking for FTS-0001 packets. If none found, the file was
    discarded rather than written to inbound for the TIC scanner. Fix: fall
    through to write as TIC binary when a ZIP contains no mail packets.

  • tic.py scan_inbound: duplicate-skip check compared the .tic filename
    against TicFile.filename (which stores the binary name). They never
    matched, so every echomail poll spawned a fresh error row for the same TIC.
    Fix: peek at the TIC content to get the binary filename before the DB check.

v1.0a2.63 — File gallery 500 fix (May 2026)

Fixed 500 error on /files/ when any files had been uploaded. The template
used f.user / f.user_id but FileUpload's relationship and FK column are
named uploader / uploader_id. Gallery was completely broken for anyone
who had uploaded files.

v1.0a2.62 — Installer fix: pip failure on Python 3.13 / ARM64 (May 2026)

Fixed a cascade of misleading install errors on Python 3.13 / ARM64 (Pi 5,
Debian trixie). The installer was suppressing all pip install output with
--quiet 2>/dev/null, then blindly continuing into admin setup and service
starts against an empty venv — producing "ModuleNotFoundError: dotenv",
"gunicorn not found", "sqlalchemy not found" etc., all of which were
consequences of pip never running.

  • Added --prefer-binary to pip install to use pre-built wheels on ARM64.
  • Actual pip error output is now displayed when pip fails, so sysops can
    diagnose the root cause.
  • Admin account setup is now skipped when pip failed (with a clear message).
  • Service starts are now skipped when pip failed (with a clear message).

v1.0a2.61 — Newsletter bugfix (May 2026)

Fixed 500 error when sending a newsletter. The admin newsletter route was
passing content=body when constructing PrivateMessage objects, but the
model field is body. No PMs were delivered; the newsletter record was
rolled back cleanly. Corrected to body=body.

v1.0a2.60 — In-browser DOS games: DOOM + Duke Nukem 3D (May 2026)

New game type door_dos_browser — play classic DOS games directly in
the browser via EmulatorJS (dosbox_pure libretro core). No DOSBox
install on the server required; games run fully client-side.

Two shareware titles now ship pre-bundled:

  • DOOM (Shareware) — id Software shareware, freely distributable.
    SoundBlaster audio via dosbox_pure's SB16 emulation (IRQ 7, DMA 1).
  • Duke Nukem 3D (Shareware) — 3D Realms shareware, freely
    distributable. Uses GUS/UltraSound emulation (--gus flag) because
    the v1.3D binary only accepts FXDevice=9 (Build Engine audiolib
    devices 3, 5, 13 are compiled out or silent in this release).

New files / changes:

  • anetbbs/web/games.pydoor_dos_browser play route,
    /games/dos-data/<file> ZIP server, /games/dos-frame/<slug> with
    COOP/COEP headers for SharedArrayBuffer.
  • anetbbs/templates/games/play_jsdos.html — landing page.
  • anetbbs/templates/games/play_jsdos_frame.html — EmulatorJS frame
    with pointer-lock exit overlay and GAME OVER replay loop.
  • anetbbs/features/games.py — terminal door menu now excludes
    door_dos_browser (and builtin_web) so browser-only games don't
    appear in the telnet/SSH game list.
  • tools/prepare_dos_games.py — packages a DOS game directory into a
    dosbox_pure ZIP bundle. New flags: --exclude (remove extra EXEs so
    dosbox_pure auto-starts), --gus (GUS/UltraSound emulation for
    Build Engine games), --dry-run.
  • data/dos-games/doom.zip and data/dos-games/duke3d.zip — the
    pre-built game bundles (included in the release tarball).
  • docs/14-door-games.md — new door_dos_browser section with setup
    guide, flags reference, GUS notes, and rebuild commands.

Deploy note for upgrades from v1.0.x:

The data/dos-games/ directory must be writable by the anetbbs
service user. SCP new ZIPs to /tmp/ on the server and move with
sudo:

sudo mv /tmp/*.zip /opt/anetbbs/data/dos-games/
sudo chown anetbbs:anetbbs /opt/anetbbs/data/dos-games/*.zip

Add the two Game rows manually if they're missing (see
docs/14-door-games.md → Pre-installed games).


Versions are internal build numbers. Public releases are tagged
separately. Previous internal series: v1.0a2.36 — alpha 2 (internal v287.35,
May 2026). v1.0a — alpha 1 (internal v196).

v287.35 — FTPS: pyOpenSSL dep + soft-fail import (May 2026)

After v1.0a2.34 wired up the cert-perms and supplementary group, FTP
started crashing on the actual TLS_FTPHandler import:

ImportError: cannot import name 'TLS_FTPHandler' from 'pyftpdlib.handlers'

pyftpdlib only exposes TLS_FTPHandler when pyOpenSSL is importable
— it's a soft optional dep, but FTPS doesn't work without it. We
never declared it, so every install was a coin-flip depending on
what else happened to pull pyOpenSSL into the venv.

Two fixes:

  1. pyopenssl>=24.0.0 added to requirements.txt + setup.py
    update.sh's pip install -e . step now pulls it on every run.
  2. build_server() catches the ImportError and falls back to
    plain FTP with a clear, actionable warning that names the package
  3. the venv pip command, so a future drift can't silently disable
    FTPS again.

After deploying this release the FTP listener finally comes up on
:21 with TLS enabled — AUTH TLS upgrades succeed and the journal
shows FTP: TLS enabled (/etc/letsencrypt/…).

v287.34 — install.sh em-dash banner alignment (May 2026)

Sysop has been hand-patching install.sh for every release: two
boxed banner lines contain an em-dash (, U+2014, 3 bytes UTF-8
but 1 terminal column) and the byte-counted whitespace layout was
1 column short of the right-edge . Fixed in the source tree so
future releases don't need the manual fix-up:

  • Line ~53: ║ ANetBBS — UNINSTALL ║
  • Line ~190: ║ ANetBBS — Installation Wizard ║

Each one gets one extra trailing space before the closing . The
sysop's notes are now stored in auto-memory so future agent
sessions remember the em-dash gotcha — anytime banner whitespace
gets regenerated.

v287.33 — FTPS auto-enable + renewal-hook (May 2026)

update.sh now wires up FTPS end-to-end whenever
FTP_TLS_CERTFILE in .env points at /etc/letsencrypt/…:

  1. ssl-cert group: created (system group) if missing.
  2. Service user → ssl-cert: added via usermod -aG so the
    group membership exists on disk.
  3. letsencrypt perms normalised: chgrp -R ssl-cert /etc/letsencrypt/{live,archive}, chmod g+rX on the dirs,
    0640 on privkey*.pem, 0644 on the other *.pem files.
  4. anetbbs.service patched: SupplementaryGroups=ssl-cert
    injected via sed (idempotent). Without this systemd's
    User=stingray doesn't pick up the new group at process start —
    systemd doesn't call initgroups() itself, supplementary groups
    have to be declared explicitly.
  5. Certbot renewal hook:
    /etc/letsencrypt/renewal-hooks/deploy/anetbbs-ssl-cert-perms.sh
    gets installed. Certbot otherwise resets archive/ to
    0700 root:root on every renewal, silently breaking FTPS
    overnight. The hook restores the ssl-cert group + perms
    after every successful cert renewal.

After deploying this release, restart anetbbs once so it picks
up the new supplementary group, then FTPS should work — AUTH TLS
upgrade on port 21 succeeds and the listener logs
FTP: TLS enabled (/etc/letsencrypt/…) instead of falling back to
plain.

The whole block is conditional on FTP_TLS_CERTFILE pointing at
/etc/letsencrypt/…. Sysops using other cert paths (self-signed
in data/ssl/, ACME from a different client) see no change.
Sysops with FTP_TLS_CERTFILE blank also see no change — FTP
keeps starting in plain mode.

v287.32 — CLI logger crash fix (May 2026)

Hotfix on v1.0a2.32: anetbbs --version (and any CLI invocation
from a non-install CWD) was crashing at module-import time with
PermissionError: '/tmp/bbs.log'. main.py ran
logging.basicConfig(... FileHandler('bbs.log')) at the top of the
module, with 'bbs.log' as a relative path — so running from /tmp
opened /tmp/bbs.log, which existed left over from an earlier
root-run and refused write to the service user.

Two changes:

  • bbs.log is now resolved to an absolute path next to the
    installed package (or honours ANETBBS_LOG_FILE= if you want to
    point it elsewhere — journald-only setups can =/dev/null).
  • The FileHandler is best-effort: if open fails (read-only fs,
    permission denied, noexec mount), we log only to stdout and write
    a one-line warning to stderr. systemd captures stdout regardless,
    so journald-side logging is unaffected.

This unblocks anetbbs --version / anetbbs-web --version from
every CWD.

v287.31 — Quality-of-life follow-ups (May 2026)

Five field-driven fixes from one diagnostic session on bbs.a-net.fyi.

FTP listener now survives a leftover root-owned tree.
_build_symlink_tree was crashing the whole FTP thread on a single
un-unlink()-able symlink (left over from a previous run as a
different user — typical when migrating from root → service user).
Now caught with a warning that names the file + the exact chown
command to fix it, and the rest of the tree builds. Belt-and-braces:
update.sh chown -Rs data/ftp_root to the service user on every
run, so the situation can't drift.

SCC Restart buttons work again. Past releases shipped
anetbbs-web.service with
CapabilityBoundingSet=CAP_NET_BIND_SERVICE. That bounding set
prevents the gunicorn worker's sudo -n systemctl restart … child
from re-acquiring CAP_SETUID / CAP_SETGID / CAP_AUDIT_WRITE
from its setuid-root bit, so every Restart click failed with
"sudo: unable to change to root gid" + "error initializing audit
plugin sudoers_audit". Removed the bounding set; AmbientCapabilities
alone is enough to bind MSP/SYSTAT on privileged ports. update.sh
strips the line from any pre-existing unit and daemon-reloads
before Step 8 picks up the change.

BBS journal is quiet during SCC probes. v1.0a2.31 killed the big
session-loop tracebacks but the per-call print("Error sending telnet command: …") / print("Write error: …") one-liners were
still hitting stderr → systemd journal on every probe. Now caught as
expected BrokenPipeError / ConnectionResetError /
ConnectionAbortedError and swallowed; anything else is logged at
DEBUG. Also turned asyncssh's logger down to ERROR so its own
"socket.send() raised exception" warnings stop multiplying.

Username validation now matches the web form. Terminal
registration was using str.isalnum() so "Dr Test" came back
invalid and the user had to type "drtest" — but the web form has
never been that strict. Aligned the two: 3–80 chars, must start
with a letter or digit, allow spaces / . / _ / - / ' in the
rest. Whitespace runs collapse, leading/trailing whitespace strips.

Version is now visible everywhere.

  • New anetbbs/version.py reads VERSION once at import time.
  • anetbbs --version and anetbbs-web --version print and exit.
  • Every web page footer now shows the running version next to the
    copyright. ({{ anetbbs_version }} is exposed via a context
    processor, so any template can use it.)