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_DIR defaulted 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 one journalctl | 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:

  1. Set REGISTRY_SELF_REGISTER=true, BBS_DOMAIN=<your-public-hostname>,
    SYSOP_EMAIL=<sysop-inbox>, and the friendly fields (SYSOP_NAME,
    BBS_LOCATION) in .env.
  2. On service start, a daemon thread POSTs /registry/api/v1/register
    to the configured REGISTRY_URL (default https://bbs.a-net.fyi).
  3. The hub returns a verify token + URL, which we persist to
    data/registry_state.json (sysop-private, not committed).
  4. Daily, the thread heartbeats to keep last_seen current. If the
    hub 404s us (we got removed, or rehosted), it falls back to a
    full re-register.
  5. New admin page /admin/registry/self shows: 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:

  1. 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.
  2. Single keypresses didn't reach the remote_proxy read the
    user's input via session.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.lst daily, upserts each peer into
    BbsDirectoryEntry with source='anetbbs'.
  • New columns on bbs_directory: sysop, location, software,
    software_version, msp_port, systat_port, source. Synchronet
    rows keep their source='sbbsimsg'; ANetBBS rows get 'anetbbs'.
    _lightweight_migrate adds 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_URL set (default https://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 for CAP_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 create FileUpload rows.
  • docs/01-installing.md — port list updated.
  • docs/00-overview.md — architecture diagram now shows FTP next to
    telnet/SSH/rlogin inside anetbbs.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_ENABLED
  • FTP_PORT
  • FTP_ANON_ENABLED
  • FTP_PASV_PORTS
  • FTP_TLS_CERTFILE
  • FTP_TLS_KEYFILE
  • FTP_ROOT_DIR
  • FTP_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 at FileArea.storage_path. The
    authorizer maps the right tree onto each session at login.
  • SymlinkAwareFS — pyftpdlib's default AbstractedFS uses
    os.path.realpath() for path safety, which dereferences symlinks and
    treats every CWD through one as "outside the user's home." We
    override realpath()abspath() and validpath() to compare
    against abspath — preserves .. traversal safety while letting our
    deliberate symlink tree work.
  • Upload tracking. on_file_received hook creates a FileUpload
    row keyed to the parent directory's FileArea, so files uploaded
    via FTP show up in the web UI's file-area browser. Per-area
    upload_permission enforced 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 can AUTH 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 by FTP_ENABLED in
    the .env file. 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_domain columns on file_areas. When the sysop flags an
    area (e.g. Z1DAILY for FidoNet, tqwinfo for TQWnet) and sets a
    domain, every inbound TIC for that area is unwrapped (ZIPs included
    — TQWnet's tqwnet.zNN style works without naming the .zip
    extension) and auto-imported into the Nodelist table. Tagged by
    domain so /nodelist/?domain=tqwnet filters 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 with to_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 your 1337:3/231 AKA, a fsxnet entry from your
    21:1/100 AKA, etc.
  • Bulk-import admin route at /nodelist/admin/bulk-import. Scans a
    configurable directory (default data/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_LABELS in anetbbs/web/who.py — extend as new
    blueprints land. Telnet/SSH/rlogin sessions are unaffected (their
    where is already a friendly menu/game name, not a URL).
  • Inbound netmail status was sometimes draft. The listener path
    (binkp_server.py) correctly set status='received' on incoming
    netmail, but the poller path (poller.py, where we dial out to
    pull mail) was creating NetmailMessage rows without setting status —
    so they inherited the model's draft default (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:458 now sets status='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';