ANetBBS Changelog
Versions are internal build numbers. Public releases are tagged
separately. Current release: v1.0b2.33 (July 2026). Full release: August 1 2026.
v287.20 — BinkP: day-of-week bundle extensions + persistent inbound (May 2026)
Mystic hubs deliver bundled mail to nodes using FTS-5003 day-of-week
extensions: .mo[0-z] (Monday), .tu[0-z] (Tuesday), … .fr[0-z]
(Friday), .sa[0-z], .su[0-z]. Our acceptor regex only covered
Wednesday (.we[0-9a-f]) — every other day's mail got silently
filed to the inbound dir and ignored by the TIC scanner. StingRay's
%RESCAN of 26,909 messages dropped on the floor as .frk through
.fro (Friday bundles k-o).
Also fixed two adjacent issues that made this hard to diagnose:
BINKP_INBOUND_DIRdefaulted to/tmp/binkp-inbound— tmpfs on
most Linux distros, so unrecognized files vanished on every service
restart. New default:data/binkp/inbound(persistent).- No log line when a file failed to match anything. Now logs an INFO
line on every unrecognised file: filename, size, where it landed.
Future "where did my mail go?" debugging is onejournalctl | grep
away.
Recovery for the v287.20 deploy: after deploying + restarting, the
sysop should send another %RESCAN to the hub — Mystic will re-bundle
and the new regex will now accept the resulting .fr* files.
v287.19 — Federation self-registration client (May 2026)
Day 4 of the federation build. Completes the federation loop:
- v287.13 added the hub side (accept registrations).
- v287.15 added the puller (read anetbbs.lst → BbsDirectoryEntry).
- v287.19 (this) adds the self-register / heartbeat client so a
brand-new ANetBBS install can opt in to the federation directory by
flipping one flag in .env.
How it works:
- Set
REGISTRY_SELF_REGISTER=true,BBS_DOMAIN=<your-public-hostname>,
SYSOP_EMAIL=<sysop-inbox>, and the friendly fields (SYSOP_NAME,
BBS_LOCATION) in.env. - On service start, a daemon thread POSTs
/registry/api/v1/register
to the configuredREGISTRY_URL(defaulthttps://bbs.a-net.fyi). - The hub returns a verify token + URL, which we persist to
data/registry_state.json(sysop-private, not committed). - Daily, the thread heartbeats to keep
last_seencurrent. If the
hub 404s us (we got removed, or rehosted), it falls back to a
full re-register. - New admin page
/admin/registry/selfshows: hub URL, our
metadata, last hub response, the verify URL (so the sysop can
click it without digging through gunicorn logs), and a "Register /
Heartbeat Now" button for manual ticks.
Three new config keys: SYSOP_NAME, SYSOP_EMAIL, BBS_LOCATION.
Required for self-registration; otherwise harmless metadata.
v287.18 — Dialout: telnet IAC + raw key reads (May 2026)
First bug filed against the pre-alpha public release. The dialout
feature (terminal → another BBS via outbound telnet) was broken in
two visible ways:
- No ANSI rendering on the remote — we never negotiated the
telnet protocol, so the remote BBS asked our terminal "do you
support TTYPE / BINARY / NAWS?" and got no reply. It fell back
to dumb-terminal mode and stripped ANSI escapes from everything
it sent back. - Single keypresses didn't reach the remote —
_proxyread the
user's input viasession.read_line(), which is line-buffered and
blocks until Enter. Hotkeys (ESC,*, menu shortcuts, bot-defense
challenges) never made it across until the user pressed Enter.
Rewrote _proxy in anetbbs/features/dialout.py with:
- Minimal telnet IAC state machine on the remote→user direction
that strips protocol bytes, handles DO/DONT/WILL/WONT, responds
to subnegotiation (TTYPE → "ANSI"), and announces our capabilities
up-front so the remote enters full-ANSI mode. - Raw single-byte reads via
session.read_raw(1)on the user→
remote direction. Each keypress is shuttled immediately, IAC bytes
are doubled per RFC 854. - Ctrl+] escape actually works now — was a half-implemented dead
constant before. Ctrl+], Q to quit; any other key resumes.
v287.17 — Admin user delete cascade fix (May 2026)
Admin → Users → Delete returned 500 Internal Server Error because
UserSession.user_id is NOT NULL but the relationship had no cascade.
SQLAlchemy tried UPDATE user_sessions SET user_id=NULL to detach the
session before deleting the user, which the constraint rejected.
Fixed by switching the backref to db.backref('session', uselist=False,
cascade='all, delete-orphan') so the session row is deleted instead
of unlinked. Bonus: corrects the relationship cardinality (UserSession
is 1:1 with User via the unique=True user_id, so uselist=False is
the right shape anyway).
v287.16 — SYSTAT now sees web users (May 2026)
Peer BBSes querying /imsg/directory/<host>/who against ANetBBS hit
SYSTAT (UDP/11), which historically read only the NodeActivity table
— the multi-node terminal slot tracker. Any user signed in via the web
front-end (the majority on ANetBBS) lived in UserSession and was
invisible to SYSTAT. Result: "Who's online" pages on peer BBSes
showed No users currently active. even when the BBS was busy.
Fixed by unioning NodeActivity (terminal) + UserSession (web)
inside _build_response. Dedupes on username so a user logged in via
both transports counts once. Web sessions get synthetic slot names
web1, web2, ... and their page paths are sanitized through the
same _friendly_where() map as /who/ so peer BBSes don't learn the
exact URL each user is on.
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';