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.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';

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 alltemplates/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's users.theme_id was 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 HTML5 form= attribute,
    so the visual layout is unchanged but the markup is now valid.
  • anetbbs-deploy-latest.sh step 4.5 path bomb — the script was
    copying deploy/*.service straight into /etc/systemd/system. The
    source units ship /opt/anetbbs as 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-time sudo 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 .local email.
    WTForms Email() delegates to email-validator 2.x, which rejects
    RFC 6761 special-use TLDs (.local, .test, .invalid, .example).
    The seeded admin user is admin@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. Replaced Email with a permissive regex validator
    (anetbbs/web/validators.py:PermissiveEmail) in auth.py,
    profile.py, and admin.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 default client_max_body_size. Added an
    explicit client_max_body_size 110m; directive (covers the 100 MB
    UPLOAD_MAX_SIZE plus headroom). On upgrade, regenerate the live
    nginx config or add the directive by hand inside the server {}
    block and nginx -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-BBSANetBBS 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:

  1. launch_door_game() starts the PTY-reader thread synchronously.
    Within milliseconds the reader calls _emit_output(welcome_bytes).
  2. At that instant sid_box[0] is still None (set on the next
    line), so _emit_output returned early — bytes lost.
  3. Even after sid_box[0] was set, the next emit went to
    room=str(session_id) — but join_room() hadn't run yet, so the
    client wasn't in the room. Also dropped.
  4. 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.