ANetBBS Changelog
Versions are internal build numbers. Public releases are tagged
separately. Current release: v1.0b2.58 (July 2026). Full release: August 1 2026.
v287.15 — anetbbs.lst → BbsDirectoryEntry pull (May 2026)
Bridges the federation registry into the existing inter-BBS IM
directory. Without this, peers registered against the hub appeared in
anetbbs.lst but not in /imsg/directory/, because that view
reads from BbsDirectoryEntry (historically populated only by
Vertrauen's sbbsimsg.lst for Synchronet hosts).
- New module
anetbbs/msp/anetbbs_directory.py— pulls
REGISTRY_URL/anetbbs.lstdaily, upserts each peer into
BbsDirectoryEntrywithsource='anetbbs'. - New columns on
bbs_directory:sysop,location,software,
software_version,msp_port,systat_port,source. Synchronet
rows keep theirsource='sbbsimsg'; ANetBBS rows get'anetbbs'.
_lightweight_migrateadds the columns automatically. - Pruning: rows with
source='anetbbs'that disappear from the
upstream list get deleted on the next refresh. Synchronet + manual
rows are left untouched (owned by other refresh paths). /imsg/directory/template now shows Software and Sysop /
Location columns with a badge per BBS family (ANetBBS = blue,
Synchronet = grey).- Refresher runs in a daemon thread on every install with
REGISTRY_URLset (defaulthttps://bbs.a-net.fyi), independent of
the hub-mode flag.
v287.14 — Registry CSRF hotfix (May 2026)
v287.13 shipped the registry API but the POST /registry/api/v1/*
endpoints required a CSRF token — fine for browser forms, not for
peer ANetBBS hosts calling the API. First attempt to register against
the live hub returned 400 The CSRF token is missing.. Fixed by
exempting the entire registry blueprint from CSRF protection at
register-time (csrf.exempt(registry_bp) in web_app.create_app).
Admin-side routes at /admin/registry/* still require CSRF because
they're part of the admin blueprint, which keeps its protection.
v287.13 — Federation registry, days 2-3 (May 2026)
First two days of the v1.0a3 federation registry build. Adds the
"central hub" half — the side that accepts registrations + emits
anetbbs.lst. The peer-side client (auto-register + heartbeat + daily
pull) lands in v287.14 tomorrow.
Day 2 — registry API:
- New RegistryEntry model (registry_entries table)
- New anetbbs/web/registry.py blueprint:
- POST /registry/api/v1/register — peer announces, gets verify token
- POST /registry/api/v1/heartbeat — daily keep-alive + soft-metadata update
- GET /registry/verify/<token> — sysop confirms ownership via email link
- GET /anetbbs.lst + GET /registry/api/v1/list — JSON of listed peers
- Rate limits: per-host (5s register / 10s heartbeat) + per-IP hourly caps
- Hub-mode gate (REGISTRY_MODE_ENABLED=true) — endpoints 404 on non-hub installs
Day 3 — sysop admin UI + prober:
- /admin/registry/ — full approval queue UI:
- Counts cards: pending verify / pending approval / listed / total
- Approve / reject / edit / delete per-entry
- Inline edit form for soft metadata (name, sysop, location, ports, notes)
- Approve button gated on is_verified=True (can't approve unverified
entries — prevents drive-by sysop approval of typo'd emails)
- Reject de-lists but keeps the row; delete is one-click-with-confirm
- New anetbbs/msp/probe.py — periodic SYSTAT prober:
- Runs in a daemon thread inside the hub's web service
- Probes every approved+verified+active entry on
REGISTRY_PROBE_INTERVAL_SEC (default 1 hour)
- Drops is_listed=False after REGISTRY_PROBE_FAILURE_THRESHOLD
consecutive failures (default 3)
- Auto-re-lists if a previously-dropped entry starts probing OK again
- Admin nav link added under Subsystems
Two acceptance gates before an entry shows up on the public list:
email verification + sysop approval. Designed to keep
anetbbs.lst clean even if the hub is publicly exposed.
v287.12 — FTP user docs (May 2026)
The FTP server shipped in v287.8 but the user docs hadn't caught up.
Updated:
docs/PORTS.md— new rows for FTP control (21) + passive range
(40000-40050), updated privileged-ports section with the systemd
drop-in recipe forCAP_NET_BIND_SERVICE, added the iptables rule.docs/07-file-areas.md— new FTP access section explaining the
three permission tiers (anonymous / authenticated / sysop) and how
uploads createFileUploadrows.docs/01-installing.md— port list updated.docs/00-overview.md— architecture diagram now shows FTP next to
telnet/SSH/rlogin insideanetbbs.service.README.md— front-page protocol list mentions FTP + IFC.FEATURES.md— protocol table row.
v287.11 — FTP settings in /admin/settings (May 2026)
Added eight FTP rows to the sysop settings page so the config matches
the other protocol settings (telnet / SSH / rlogin) rather than being
.env-only:
FTP_ENABLEDFTP_PORTFTP_ANON_ENABLEDFTP_PASV_PORTSFTP_TLS_CERTFILEFTP_TLS_KEYFILEFTP_ROOT_DIRFTP_BANNER
All marked requires_restart=True (same as the other protocol toggles)
because the FTP daemon binds its ports at startup.
v287.10 — FTP: hide server-side path in symlink listings (May 2026)
LIST output was leaking the absolute server-side storage_path to
every client (including anon) as the symlink target:
lrwxrwxrwx 1 anetbbs anetbbs 39 May 15 01:34 FILES.GAMES -> /home/stingray/anetbbs/data/files/games
That exposes internal directory structure and could help a remote
attacker fingerprint the install. Fixed by overriding lstat() in
SymlinkAwareFS to use os.stat() instead, so symlinks resolve
through and look like regular directories to the FTP client:
drwxrwxr-x 2 anetbbs anetbbs 4096 May 15 01:34 FILES.GAMES
v287.9 — FTP hotfix: don't break telnet/SSH login (May 2026)
v287.8 shipped FTP integration that broke telnet/SSH login with
Session error: cannot notify on un-acquired lock. Root cause:
anetbbs.main was calling anetbbs.web_app.create_app() to obtain a
Flask app for the FTP daemon thread — but that path registers all
~50 blueprints AND starts the echomail / RSS / MSP / SYSTAT
background pollers, each of which uses threading primitives. Turning
the previously pure-asyncio terminal-server process into a mixed
asyncio+threading process broke the threading.Condition semantics
that the login flow's session lock relies on.
Fixed by adding anetbbs.ftp.server.build_minimal_app() — a 5-line
Flask app builder that initializes only db.init_app(app) and the
config. Zero blueprints, zero pollers. anetbbs.main now uses this
instead of create_app(), so the FTP thread has just enough to do
User.check_password + FileUpload writes without dragging the
full app surface into the process.
Verified post-fix:
- Minimal app: 0 blueprints registered, DB queries work.
- SSH/telnet login no longer raises the lock error when FTP is enabled.
- FTP server still serves anonymous + authenticated correctly.
v287.8 — FTP server (May 2026)
FTP front-end. A new anetbbs/ftp/ module serves the existing
FileArea tree to the internet, completing the four-protocol set
(web / telnet / SSH / rlogin / FTP). Earns you the FTN nodelist IFC
flag (Internet File transfer Capability) once you advertise it.
Architecture:
- pyftpdlib backend. Mature, async-friendly. Drives the whole wire
protocol — we add the auth + filesystem + upload-tracking layers. - Auth via existing
User.check_password— same bcrypt as web /
telnet / SSH. Anonymous login is enabled by default (FTP_ANON_ENABLED=true). - Three-tier symlink trees at
data/ftp_root/{anon,users,admin}/,
rebuilt on every server start: anon/— public areas (is_active AND NOT is_sysop_only), read-only.users/— non-sysop-only areas, full r/w subject to per-area perms.admin/— every active area including sysop-only.
Each tree is just symlinks pointing atFileArea.storage_path. The
authorizer maps the right tree onto each session at login.SymlinkAwareFS— pyftpdlib's defaultAbstractedFSuses
os.path.realpath()for path safety, which dereferences symlinks and
treats every CWD through one as "outside the user's home." We
overriderealpath()→abspath()andvalidpath()to compare
against abspath — preserves..traversal safety while letting our
deliberate symlink tree work.- Upload tracking.
on_file_receivedhook creates aFileUpload
row keyed to the parent directory'sFileArea, so files uploaded
via FTP show up in the web UI's file-area browser. Per-area
upload_permissionenforced post-hoc: if the user lacks permission
the file is deleted and the violation logged. - Optional FTPS — set
FTP_TLS_CERTFILE+FTP_TLS_KEYFILE(reuse
the same Let's Encrypt cert nginx uses) and connections canAUTH TLS
on the same port. - Passive port range configurable via
FTP_PASV_PORTS(default
40000-40050). Open + forward those on the firewall. - Process integration. Runs in a daemon thread inside the existing
anetbbs.service— no new systemd unit. Driven byFTP_ENABLEDin
the.envfile. If pyftpdlib is missing, the server logs a warning
and skips startup instead of crashing the BBS.
Verified end-to-end with curl:
- Anonymous lists only public areas, can download.
- Anonymous upload is denied at the auth layer (perm string is elr).
- Authenticated user sees non-sysop-only areas; admin sees all.
- Authenticated upload lands on disk and creates a FileUpload row.
v287.7 — Nodelist auto-import + send-netmail-to-sysop (May 2026)
Two enhancements inspired by Craig Hendricks's (codefenix) NetLister
door for Synchronet (https://conchaos.synchro.net):
- FileArea → Nodelist auto-import. New
is_nodelist_source+
nodelist_domaincolumns onfile_areas. When the sysop flags an
area (e.g.Z1DAILYfor FidoNet,tqwinfofor TQWnet) and sets a
domain, every inbound TIC for that area is unwrapped (ZIPs included
— TQWnet'stqwnet.zNNstyle works without naming the .zip
extension) and auto-imported into theNodelisttable. Tagged by
domain so/nodelist/?domain=tqwnetfilters work. Verified
end-to-end against real-world archives —tqwnet.z46(145 entries),
fsxnet.zip(323),Z1DAILY.ZIP(1208). - Send-Netmail-to-Sysop button on
/nodelist/<id>. One click jumps
to the existing netmail composer withto_address+to_name
pre-filled from the nodelist row. The compose route already auto-picks
the FROM AKA whose zone matches the destination, so clicking a TQWnet
entry composes from your1337:3/231AKA, a fsxnet entry from your
21:1/100AKA, etc. - Bulk-import admin route at
/nodelist/admin/bulk-import. Scans a
configurable directory (defaultdata/nodelists, override via
NODELIST_SCAN_DIR), lists every plausible nodelist file or ZIP,
and lets the sysop tick + tag → import in a single submit. Useful
for first-run when the sysop already has a stash of nodelist
archives on disk (e.g. from infopack downloads). import_from_path()is the new public entry point in
anetbbs/echomail/nodelist.py. Accepts text files, archives, or
ZIPs with non-standard extensions (.z46,.a07, etc — detected by
magic bytes, not suffix). Picks the highest-priority nodelist member
from a ZIP using_looks_like_nodelist()heuristics.
v287.6 — /who/ privacy + inbound netmail status (May 2026)
Two fixes surfaced while a sysop was watching their own /who/ page:
/who/"Where" column sanitized for non-admins. The column used
to leak the exact URL each user was viewing (e.g./echomail/53/25407)
to every other logged-in user. Anyone could copy-paste the link and
follow someone around. Sysops still see raw paths; non-admins now see
a coarse area label ("Echomail", "Profile", "Boards", "MRC Chat", …).
The map is_WEB_AREA_LABELSinanetbbs/web/who.py— extend as new
blueprints land. Telnet/SSH/rlogin sessions are unaffected (their
whereis already a friendly menu/game name, not a URL).- Inbound netmail status was sometimes
draft. The listener path
(binkp_server.py) correctly setstatus='received'on incoming
netmail, but the poller path (poller.py, where we dial out to
pull mail) was creatingNetmailMessagerows without setting status —
so they inherited the model'sdraftdefault (which is right for the
compose flow but wrong for inbound). AREAFIX responses received via
poll-out vanished from the sysop's inbox UI as a result. Fixed:
poller.py:458now setsstatus='received'+received_at. Existing
stuck rows can be backfilled with
UPDATE netmail_messages SET status='received', received_at=created_at WHERE direction='inbound' AND status='draft';
v287.5 — profile form nested-<form> fix, deploy-script path bomb (May 2026)
Two more profile-edit bugs found while v287.4 was being deployed:
- Profile edits never submitted at all —
templates/profile/edit.html
had a nested<form action="/profile/avatar/remove">inside the main
profile form. HTML forbids nested forms; browsers auto-close the outer
form at the inner<form>tag, so the Privacy / Signature / Tagline /
Theme dropdown / Update Profile submit button ended up outside any
form. Clicking Update did literally nothing — no POST left the browser,
no field ever saved. Why nobody'susers.theme_idwas ever non-NULL.
Fixed by lifting the avatar-remove form out of the main form and
pointing the Remove Avatar button at it via the HTML5form=attribute,
so the visual layout is unchanged but the markup is now valid. anetbbs-deploy-latest.shstep 4.5 path bomb — the script was
copyingdeploy/*.servicestraight into/etc/systemd/system. The
source units ship/opt/anetbbsas the install path, but real installs
often live elsewhere (e.g./home/stingray/anetbbs). After a deploy,
systemd would try to exec/opt/anetbbs/venv/bin/gunicorn, fail with
status=203/EXEC, and crash-loop the web service. Step 4.5 now rewrites
/opt/anetbbs→$INSTALL(the script's own install-path variable)
before installing the unit. Existing live boxes that already got
clobbered need a one-timesudo sed -i 's|/opt/anetbbs|/home/stingray/anetbbs|g' /etc/systemd/system/anetbbs*.service && sudo systemctl daemon-reload.
v287.4 — profile form + nginx upload limit (May 2026)
Two reported bugs in one cut:
- Profile edits silently failed for anyone with a
.localemail.
WTFormsEmail()delegates toemail-validator2.x, which rejects
RFC 6761 special-use TLDs (.local,.test,.invalid,.example).
The seeded admin user isadmin@anetbbs.local, so every profile save
— including the theme dropdown — bounced off email validation before
reaching the DB. Theme picker looked broken, but the real problem
was upstream. ReplacedEmailwith a permissive regex validator
(anetbbs/web/validators.py:PermissiveEmail) inauth.py,
profile.py, andadmin.py. Any RFC-shaped address now passes,
including FidoNet-style aliases and internal-only TLDs. - Avatar uploads under 2 MB returned 413 Request Entity Too Large.
The bundled nginx template (deploy/anetbbs-nginx.conf.template)
inherited nginx's 1 MB defaultclient_max_body_size. Added an
explicitclient_max_body_size 110m;directive (covers the 100 MB
UPLOAD_MAX_SIZEplus headroom). On upgrade, regenerate the live
nginx config or add the directive by hand inside theserver {}
block andnginx -t && systemctl reload nginx.
v1.0b.1 — branding consistency pass (May 2026)
Tiny but everywhere: replaced ANET BBS / Anet BBS / ANet BBS /
anet bbs / ANET-BBS → ANetBBS across 61 source files.
User-visible places include the "Powered by …" footer line, default
BBS_NAME fallbacks, systat banner, copyright lines, log messages,
admin tool descriptions, etc.
Functional code unchanged. Just one consistent spelling.
v1.0b — alpha 2 (May 2026)
Cut from internal build v287.1. Bundles every internal release from
v197 through v287.1; the per-build notes below capture the granular
changes. Headline additions:
- LORD ships pre-installed and plays end-to-end under the Node +
Synchronet compat shim (v283.x–v287 added the deep API stubs the
game exercises). - Wiki at
/wiki/with revision history, diff, search,
wanted/orphan reports, 41 seeded pages (v284). - RSS reader — web + terminal (v281).
- Web doors at 80×25 with the real CGA palette, plus pre-join
output buffering so welcome screens render immediately (v287, v287.1). - NodeSpy kick for stuck terminal sessions, cross-process via
DB-flag/poll (v283.x). - Echomail terminal reader properly paged with
[Q]=quit,
CP437 body passthrough (v283.5–v283.7).
v287.1 — Web doors: buffer pre-join output (May 2026)
Some doors (ANetSIMS, A-Net Sixel TV, anything that draws a welcome
screen then waits for input before the user does anything) showed
nothing in the web terminal until the user pressed Enter. The
keystroke produced new output which then DID appear. Telnet / SSH /
rlogin worked fine because they don't have this race.
Root cause was a race in web/games.py:handle_start_game:
launch_door_game()starts the PTY-reader thread synchronously.
Within milliseconds the reader calls_emit_output(welcome_bytes).- At that instant
sid_box[0]is stillNone(set on the next
line), so_emit_outputreturned early — bytes lost. - Even after
sid_box[0]was set, the next emit went to
room=str(session_id)— butjoin_room()hadn't run yet, so the
client wasn't in the room. Also dropped. - First user keystroke → door writes more output → by then
join_room()had run → bytes flow.
Fix: _emit_output now appends to a pre-join buffer (under a lock,
the reader is on its own thread) while a _buffering flag is true.
After launch_door_game returns, sid_box is set, the room is
joined, game_started is emitted, and _flush_pre_join_buffer()
drains the buffer as one chunk and flips the flag — subsequent
reads bypass the buffer and emit directly.