Mail clients (internal/mailclients): launch himalaya and meli as
alternatives to the built-in AgentMail reader, pointed at the member's
mailbox with the same master IMAP / loopback-SMTP creds. They run
host-side (secrets never enter a pod), from a throwaway per-session
config that is deleted on exit; operators can override the config
template and argv via env. Wired into the hub ("Mail · Himalaya/Meli",
locked when the binary is absent) and the mail@ route
(ssh -t mail@host himalaya|meli).
Shedding Snake (plugins/arcade): a molting twist on Snake inspired by
cha.rlie.co/shedding-snake. The snake barely grows — each apple sheds
its whole body as a permanent field of scales you must not bite. Walls
wrap, speed ramps up, scales age fresh-teal → dusty-gray, and molt
counts feed a global "shedsnake" leaderboard. Grace period lets you
slither off a fresh molt.
Co-authored-by: Claude Opus 4.8 <noreply@anthropic.com>
Add Gopher (RFC 1436) as a co-located protocol service, following the
internal/news pattern. Two surfaces share one read-only Resolve engine:
- Public Gopher on :70 (RFC 1436) for any gopher client (lynx, Lagrange).
Classic gopher is stateless with no auth verb, so this surface serves
only public content.
- `ssh gopher@` = "hedgehog": the same gopher wire semantics carried over
the authenticated SSH channel (the member's key is the credential), so it
additionally reaches members-only selectors. Gopher where gopher can,
our own gopher-like thing over SSH where it can't.
Menus surface the member directory + homepages (public_html), an About page
(brand + MOTD), public newsgroups (allowlisted on :70, all groups on
hedgehog), and members' public files. Selectors are confined to each member's
area (path-traversal guarded). New AGENTBBS_GOPHER* env vars; docs/gopher.md
and README updated (incl. the setcap note for binding privileged :70).
Co-authored-by: Claude Opus 4.8 <noreply@anthropic.com>
The Update key switch bound "r" to restart, but returned early even when
the game was still active, so lowercase r could never be entered as a
letter guess (Shift+R worked because "R" didn't match the case). Now r
only restarts when dead; otherwise it falls through to the guess logic.
Co-authored-by: Claude Opus 4.8 <noreply@anthropic.com>
The setup help page hardcoded chat.${DOMAIN}, producing
chat.bbs.profullstack.com — a host the Caddy route never serves. The
The Lounge web IRC client is fronted at ${CHAT_DOMAIN}
(chat.${DOMAIN#*.} = chat.profullstack.com). Use ${CHAT_DOMAIN} so the
"web client" link and label match the actual host.
Co-authored-by: Claude Opus 4.8 <noreply@anthropic.com>
* ci(deploy): preempt self-update timer so it can't starve the deploy lock
The self-update systemd timer redeploys from source (no SKIP_BUILD) and
can hold setup.sh's flock for >5min while compiling on a tiny droplet.
When it fires close to a CI push it starves the deploy, which waits the
full 5min on the lock and then fails with 'another setup.sh run is in
progress (lock held >5m)'.
The CI push is authoritative (ships prebuilt binaries + resets to the
exact commit), so stop any in-flight timer run to release the lock and
pause the timer before taking it. setup.sh re-enables the timer at the
end of its run.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
* test(files): isolate over-quota test from seeded-README baseline
TestWebSaveOverQuotaPreservesExistingFile set quota=5 but left sess.used
at the newSession baseline, which already counts the README.txt that
ensureUserPub seeds into /public (added in d19c5c4). That baseline alone
exceeds 5 bytes, so the initial 2-byte save was rejected with
'quota exceeded' before the test could exercise the over-quota replace.
Reset sess.used to 0 after setting the tiny quota, mirroring
TestQuotaEnforced, so the writer starts from a clean gauge.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
---------
Co-authored-by: Claude Opus 4.8 <noreply@anthropic.com>
When the configured storage root (or a system temp dir on macOS where
/var → /private/var) is reached through a symlink, filepath.EvalSymlinks
on a child path resolves to the canonical form, but within() was comparing
against the lexical root — causing valid paths to be rejected with
"files: path escapes its area".
Fix: resolve the root once with EvalSymlinks before the symlink guard
loop, and compare resolved paths against the canonical root. The initial
lexical containment check (line 108) still uses the original root so
that the returned path keeps the caller's expected prefix.
Adds two regression tests:
- TestSafeJoinSymlinkedRoot: valid file under a symlinked root is accepted
- TestSafeJoinChildSymlinkEscapeStillBlocked: escaping child symlink is still rejected
Fixes#62
Co-authored-by: Kyle Paul Zengo <kylezengo@mac2012kylezengo.tail2f018b.ts.net>
* ci: add mailu-update workflow to keep the mail stack current
The deploy/mailu compose stack pins the floating series tags
(ghcr.io/mailu/*:2024.06); patch releases within the series only land when
someone runs `docker compose pull`, so the box drifts behind on security fixes.
Add a scheduled (weekly) + on-demand workflow that SSHes to the droplet
(reusing deploy.yml's DEPLOY_* secrets), backs up DKIM keys + the admin DB,
pulls the latest images for the pinned series, recreates the containers, and
health-checks the Mailu front on 127.0.0.1:8080. Shares deploy.yml's
concurrency group so it never races a code deploy. Stays within the pinned
series on purpose — crossing to a future series stays a deliberate PR.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
* chore: bump Forgejo to 11.0.15 + add Dependabot to keep deps current
Audit of every version pin in the repo: Ergo (2.18.0), Go (1.26 → latest
patch via setup-go), the Ubuntu pod base (24.04 LTS), and the GitHub Action
majors are all already current. Only Forgejo was stale — bump 11.0.1 →
11.0.15 (latest patch of the 11.x LTS line; a 15.x major stays a deliberate,
tested upgrade because of DB migrations).
Add .github/dependabot.yml so github-actions, Go modules, and the Docker
image tags (Mailu compose + pod Containerfile) get review-gated update PRs
weekly. Shell-string pins (FORGEJO_VERSION/ERGO_VERSION in setup.sh) can't be
watched by Dependabot; noted inline. Mailu runtime patch level is handled by
the mailu-update workflow.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
* mailu: document RECIPIENT_DELIMITER=+ in mailu.env.example
Plus-addressing (chovy+tag@ -> chovy@) is a hard prerequisite for qaaas.dev's
packages/mail but was missing from the example, so tagged mail bounces as an
unknown recipient until an operator sets it by hand. Add it with a note that it
governs DELIVERY only, not login (Mailu auths the exact address; base <name>@
is the single login and already receives all +tagged mail).
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
---------
Co-authored-by: Claude Opus 4.8 <noreply@anthropic.com>
ensureUserPub only ran os.MkdirAll, so a freshly-provisioned /public
(and thus ~<name>/public on the web) came up empty — only ~chovy had a
README because it was uploaded by hand. Embed that help text as a
default and write it whenever the area has no README.txt.
ensureUserPub is hit on SFTP connect (fs.go) and when the web host
materializes ~<name>/public (AnonRoot), so this self-heals every
existing empty member the next time they connect or their page is
viewed — no manual backfill. A member's own README is never clobbered.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Admin Users view appends 'joined YYYY-MM-DD'. Public member directory
shows 'last active: N days ago' for offline members plus the joined date,
both sourced from store.User.CreatedAt.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
TronBrowser's in-extension IRC client connects to the Ergo WebSocket from a
chrome-extension:// page; the ID is path-derived (differs per install) so use a
wildcard. Safe — the network is members-only and every WS client still passes
SASL, so Origin isn't the access gate. Also add the irc domain itself.
Needs deploy (push to main → setup.sh re-renders /etc/ergo/ircd.yaml + restarts
ergo) for the web/extension client to connect.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
passwd@ reset the IRC SASL credential (Ergo store + The Lounge saslPassword) but
NOT The Lounge's own web-login password (the bcrypt field used to sign in to
chat.<domain> itself). So a member who reset their password could connect to IRC
but got "auth failed" at chat.profullstack.com/sign-in.
set-irc-password.sh now sets all THREE chat credentials to the new password:
Ergo SASL, the Lounge saslPassword, and the Lounge web-login password via
`thelounge reset <member>` (AGENTBBS_LOUNGE_RESET_CMD, default targets the
dockerized The Lounge). The password is piped on stdin to `thelounge reset`, so
it never lands on a command line. Best-effort: a Lounge web-reset failure warns
but doesn't fail the run (Ergo SASL stays the primary IRC secret).
Bumped ircpass SetPassword's context timeout 20s→60s since the helper now also
runs a `docker exec thelounge ...` step.
Verified live on bbs.profullstack.com: the modified helper sets all three for an
existing member (web bcrypt match + saslPassword + Ergo store), and the affected
member's web login was reconciled.
Co-authored-by: Claude Opus 4.8 <noreply@anthropic.com>
Members can now message a group or everyone, and the operator can announce to
the whole membership by email.
Inbox channel (any member):
- msg@ recipient spec accepts a comma list (alice,bob,carol) or all/*/everyone
to broadcast: `ssh msg@host alice,bob hi`, `ssh msg@host all hi`.
- Members hub TUI gains multi-select: space toggles, `a` selects all, `m`
messages the selected group (header names the audience); selection clears
after send.
- store.SendMessageMulti delivers one body to many inboxes in a single
transaction (dedupes, skips empties); resolveRecipients validates names,
excludes the sender, and skips banned members on broadcast.
Email channel (operator, explicit):
- new `agentbbs broadcast` subcommand sends an announcement to ALL members via
inbox + email. Preview by default (like notify-creds); --send delivers;
--no-inbox/--no-email pick a channel; --subject/--from/--user refine it.
Email reaches only verified addresses and refuses --send without SMTP.
Tests: SendMessageMulti (dedupe/empty), resolveRecipients (list/unknown/all
tokens, banned + sender exclusion), TUI selection (toggle/select-all/group
compose). Docs: docs/messaging.md. build/vet/gofmt/`go test ./...` green.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Add a key-gated `ssh passwd@host` route (alias `password@`) that sets ONE
member-chosen password across every service with its own credential:
- git (Forgejo) new forgejo.SetPassword (PATCH /admin/users, clears
must_change; EnsureUser first so the account exists)
- mail (Mailu webmail) existing mailu.SetPassword
- chat (IRC/Ergo + The Lounge) new internal/ircpass package
Because the route authenticates by the member's registered SSH key, it also
serves as the forgot-password path — no old password required.
The BBS runs as a non-root service user, but the Ergo password store and The
Lounge user files are root-owned. internal/ircpass bridges this by shelling out
to scripts/set-irc-password.sh through a narrow sudoers rule (installed by
setup.sh). The new password travels on stdin (a new `set-irc-password.sh
<member> -` form), so it never appears in the process table or sudo's log.
UX: masked entry typed twice (readSecret); no-PTY reads stdin; empty input
generates a strong password and shows it once. Each service leg is independent
and best-effort with a per-service ✓/✗ summary, plus a confirmation email that
never contains the password.
Tests: ircpass (stdin contract + member/password rejection), forgejo.SetPassword,
auth IsPasswdName + reservation. Docs: credentials.md (passwd@ section) + irc.md.
Co-authored-by: Claude Opus 4.8 <noreply@anthropic.com>
Per feedback: /me is PRIVATE and the public folder must be its own
top-level area, not nested under /me.
- A member now has two sibling areas over SFTP: /me (private,
<root>/files/users/<name>) and /public (their own public files,
<root>/files/public/<name>), served anonymously at ~<name>/public.
- Drop the global shared /public web route and the /me/public nesting.
The anon surface only exposes ~name/public; /me has no anon route.
- Both owned areas count toward the quota gauge.
- Index publish hint, docs, and setup.sh updated to scp :/public/.
files.<host> stays a file server; member sites remain on the BBS.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Re-model the web file host as a file server (not a website host):
- Drop the misnamed /site area. A member's public files are now their
/me/public subfolder (unix ~/public), served anonymously at
~<name>/public. The rest of /me stays private; only ~name/public is
ever exposed. Bare /~name redirects to /~name/public/.
- The root / is now a directory of ALL members, each linked to their BBS
site (https://<bbs-host>/~name via WebConfig.SiteBase) AND their public
files here (~name/public). No longer hides empty members.
- Sites/homepages stay on the BBS — files.<host> only links to them.
- Usage gauge is just /me again (which includes /me/public).
setup.sh + docs updated; tests cover ~name/public browse/download, the
bare-~name redirect, empty-member empty-listing, /public-only exposure,
and traversal confinement.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
A valid member's ~name 404'd until they'd opened their first SFTP/web
session, because joining onto a not-yet-created site dir tripped the
safeJoin escape guard (it walks up to sites/, outside the per-user root).
AnonRoot now materializes the idempotent site dir for a known member, so
~name renders an empty listing the moment the account exists. Missing
sub-paths and unknown members still 404. Adds a regression test.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Add a third storage area, /site — each member's own public root, served
unauthenticated on the web at ~<name> alongside the shared /public.
Web file host (files.<host>) is no longer a login wall:
- GET / -> directory of members' ~user sites (+ sign-in link)
- GET /~<name>/... -> anon read-only browse + clean file URLs of /site
- GET /public/... -> anon read-only browse + clean file URLs of shared
area (fixes bare /public requiring login: the old
Caddy `handle_path /public/*` never matched /public)
Login is now optional and gates only private /me + writes. The anon
surface has no route into anyone's /me and safeJoin rejects traversal.
Usage gauge now sums the member-owned areas (/me + /site) instead of
/me alone; shared /public stays operator-managed and unmetered.
Caddy: route all of files.<host> to the Go manager. Docs + tests updated
(anon download/browse, traversal confinement, /site metering).
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
* feat(files): provision-user CLI + anonymous public HTTP serving
Lets external services (the TronBrowser extension store) host files on
files.profullstack.com without the interactive `ssh join@` onboarding.
- `agentbbs provision-user --name <h> --pubkey "<ssh key>"`: registers a member
from an SSH *public* key (account = handle + key fingerprint). Reuses
SanitizeUsername (same rules as join@) + EnsureUser; Files/SFTP access is free
for members, so the account can immediately
`scp … files@host:/public/extensions/<slug>/`. JSON output; refuses on key/
handle collision. New auth.FingerprintAuthorizedKey() parses an
authorized_keys line to the same SHA256 fp as a live session key (tested).
- setup.sh: the files.<host> Caddy site now serves the shared /public area as
unauthenticated, read-only static files (handle_path /public/*), so .crx/.zip
download links work for anyone — mapping 1:1 to the SFTP path. Non-/public
paths still hit the auth'd web file manager.
- docs/files.md updated.
Note: not compiled here — repo go.mod requires go 1.26 and this sandbox has
1.22.2; changes pass gofmt parse/format checks. Reuses existing store/auth APIs.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
* fix(vet): redundant newline in wish.Println premium-flow messages
`go test ./...` / `go vet ./...` fail on `wish.Println(… "…\n")` — Println
already appends a newline. Pre-existing on main (its CI is red for the same two
lines); surfaced here. Switched both to `wish.Print` with an explicit trailing
"\n\n" so output bytes are unchanged and vet is satisfied.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
---------
Co-authored-by: Claude Opus 4.8 <noreply@anthropic.com>
`go test ./...` / `go vet ./...` fail on `wish.Println(… "…\n")` — Println
already appends a newline. Pre-existing on main (its CI is red for the same two
lines); surfaced here. Switched both to `wish.Print` with an explicit trailing
"\n\n" so output bytes are unchanged and vet is satisfied.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Lets external services (the TronBrowser extension store) host files on
files.profullstack.com without the interactive `ssh join@` onboarding.
- `agentbbs provision-user --name <h> --pubkey "<ssh key>"`: registers a member
from an SSH *public* key (account = handle + key fingerprint). Reuses
SanitizeUsername (same rules as join@) + EnsureUser; Files/SFTP access is free
for members, so the account can immediately
`scp … files@host:/public/extensions/<slug>/`. JSON output; refuses on key/
handle collision. New auth.FingerprintAuthorizedKey() parses an
authorized_keys line to the same SHA256 fp as a live session key (tested).
- setup.sh: the files.<host> Caddy site now serves the shared /public area as
unauthenticated, read-only static files (handle_path /public/*), so .crx/.zip
download links work for anyone — mapping 1:1 to the SFTP path. Non-/public
paths still hit the auth'd web file manager.
- docs/files.md updated.
Note: not compiled here — repo go.mod requires go 1.26 and this sandbox has
1.22.2; changes pass gofmt parse/format checks. Reuses existing store/auth APIs.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Use profullstack.com/motd as the shared Message of the Day across both
the SSH BBS hub and the Ergo IRC server.
- internal/motd: fetch + in-memory cache with background refresh (stdlib
only); Current() never blocks session start, keeps last value on error.
Source overridable via AGENTBBS_MOTD_URL.
- hub: append the daily MOTD below the existing welcome/onboarding text.
- IRC: deploy/ergo/refresh-motd.sh pulls /motd into Ergo's ergo.motd and
rehashes; setup.sh installs it + an ergo-motd.timer (hourly) mirroring
the ergo-certs timer, with a seeded fallback if the source is offline.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
offerPremium ran on every join@ and called CreatePremiumCharge just to
show the pitch, so CoinPay minted a $99 payment for everyone who
connected. Show the pitch with no charge, then create the payment only
when the member types "yes" at the prompt (the SSH equivalent of
clicking "Become a paid member").
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
The mail Caddy route (mail.<host>) and the entire §9e Mailu provisioning block
were gated on $MAIL, but MAIL is a standard env var (the mail-spool path) that
PAM's pam_mail sets for the sudo session the CI deploy runs under. So deploys saw
MAIL=/var/mail/root (!= "1"), silently skipping: the mail.<host> Caddy block
(webmail went down with TLS handshake aborts), the AGENTBBS_MAIL_SMTP_* env
upserts (incl. the new SMTP_SERVERNAME), AND it ran the else-branch which disabled
the mail cert-refresh timer every deploy. Rename the toggle to MAIL_STACK (no
collision) so all of it actually runs. Matches the intermittent 'mail block
dropped after a deploy' seen before.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
The chat.profullstack.com Caddy block was only ever appended manually, so every
setup.sh deploy (which rewrites the whole Caddyfile) silently dropped it and
broke https://chat.profullstack.com (fell into the on-demand-TLS catch-all whose
ask endpoint denies it -> TLS handshake aborts). Generate it like the mail/news/
files sites (CHAT=1 toggle, CHAT_DOMAIN/CHAT_ADDR). The Lounge container is still
provisioned out-of-band; this only owns the route.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
AgentMail compose/send failed with 'cannot validate certificate for 127.0.0.1
because it doesn't contain any IP SANs': the sender dialed the local relay at
127.0.0.1:25 and net/smtp pinned the TLS ServerName to the dial host, but the
relay's cert is for mail.<host>. Reimplement smtpSend (mirrors net/smtp.SendMail)
with an overridable IMAPConfig.SMTPServerName; default it to the mail host
(AGENTBBS_MAIL_SMTP_SERVERNAME). Now we dial the loopback for relay permission
yet verify the real hostname cert — no /etc/hosts hack. setup.sh upserts the new
var. Tested against a fake SMTP server (full MAIL/RCPT/DATA flow).
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Auth model change: ergo-auth-member now verifies the SASL passphrase against a
per-member pbkdf2 hash (/var/lib/ergo/irc-passwd) in addition to BBS membership,
replacing the old "membership is the credential, passphrase ignored" gate that
let anyone who knew a member name connect as them. Rewrote deploy/ergo/auth-script.sh
in python3 (drops jq/curl dep); setup.sh already installs it as ergo-auth-member.
- scripts/set-irc-password.sh: set/rotate a member's IRC password (or --all to
backfill); also syncs The Lounge saslPassword so the web client keeps working.
- setup.sh landing page: new "IRC from a desktop client" section with irssi/HexChat/
WeeChat SASL setup (connect by network name ProfullstackBBS, not hostname).
- docs/irc.md: document password auth, the helper, the 6697 cloud-firewall note,
and an irssi quick-start.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Adds a browser-based file manager so members can use their files without an
SSH key. Served on a loopback HTTP server (AGENTBBS_FILES_WEB_ADDR, default
127.0.0.1:8092) that Caddy fronts at files.<host>. Members sign in with their
webmail username + password, verified against the Mailu IMAP backend
(mailbox.VerifyLogin), and browse the same virtual /me + /public areas as SFTP
— no home directory is ever exposed. Upload/download/mkdir/delete with the
private-area quota enforced; reuses internal/files confinement (fs.go).
setup.sh renders the files.<DOMAIN> Caddy site + env knob. Unit tests cover
the auth gate and an upload/list/download/delete round trip.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
The interactive reader (ssh mail@ / hub Mail) was read-only; the send path
existed only for agents (bot mode). Add compose (c), reply (r), reply-all (a)
to the TUI with a To/Cc/Subject/Body form (tab/arrows to move fields, ctrl+d
send, esc cancel). Reply prefills + quotes the original and threads via
In-Reply-To. Tests drive the model key-by-key through send.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
WorkingDirectory is /opt/ergo with ProtectSystem=strict (only /var/lib/ergo
and /etc/ergo are writable), but lock-file was relative ("ircd.lock"), so Ergo
tried to create /opt/ergo/ircd.lock on a read-only path and crash-looped with
"failed to acquire flock on ircd.lock: read-only file system" — nothing ever
listened on 6697/6667/8097 (clients got ECONNREFUSED). datastore.path is already
absolute (__DATA__/ircd.db); per Ergo's own config comment the lock-file should
match. Render it to __DATA__/ircd.lock (/var/lib/ergo, ergo-owned + in
ReadWritePaths).
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
New docs/credentials.md covers member git-account provisioning, the
notify-creds backfill command (flags + behavior), required env, and the
two SMTP paths — transactional AGENTBBS_SMTP_* (STARTTLS :587) vs. the
gateway loopback relay AGENTBBS_MAIL_SMTP_ADDR (:25 into Mailu),
clarifying why :25 is correct and that 465 (not "467") is implicit-TLS.
Cross-linked from README ops and docs/mail.md.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
* feat(files): SFTP member storage — private workspaces + shared public area + mgmt TUI
Implements M4 (Files). A fully virtual Go SFTP server (pkg/sftp + crypto/ssh,
no OS users) wired as an "sftp" subsystem on the existing :22 wish listener, so
members reach their files with their login key:
sftp files@bbs.profullstack.com # scp/rsync ride the same endpoint
Identity is the SSH key (the username is conventional/ignored). Two areas per
session: a private, quota-limited /me workspace and a single shared public file
area /public (old-school BBS file area; world-read, members-only write by
default, operator-moderated). This reverses the old NG1 "no sharing" boundary in
favour of one sanctioned, inspectable sharing surface (PRD §9.3 amended).
internal/files:
- backend.go service, layout, quota/usage, live-session registry, operator API
- fs.go per-session virtual FS; resolve() is the single security
chokepoint (area confinement + symlink-escape guard) + pkg/sftp
request handlers
- server.go subsystem handler: key auth -> member session -> request server,
with byte metering and force-disconnect
- tui.go in-BBS member browser (hub plugin "Files")
- admin.go operator management TUI: sessions, workspaces/quotas, public area
Operator console: ssh sftp@<host> (allowlist-gated; sftpadmin@/filesadmin@
aliases) — list/disconnect sessions, set per-user quotas, revoke SFTP access,
toggle public write, moderate the public area.
store: files_access (per-user quota override + revoked) and files_settings
(public-write mode) tables + methods. main.go wiring guarded by AGENTBBS_FILES
(+ AGENTBBS_FILES_QUOTA_MB, default 1 GiB). Route names reserved.
Tests (incl -race): path traversal/confinement, symlink-escape rejection,
public-write ACL, quota enforcement, usage accounting, and an end-to-end run
against a real SFTP client. Docs: docs/files.md; PRD §5.3/§5.3.1/§9.3 + README
updated.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
* Add notify-creds subcommand to (re)email members git + mailbox creds
`agentbbs notify-creds` backfills credential emails to verified members
who signed up before the git/mailbox welcome emails existed.
- git (all verified): forgejo.EnsureUserReset resets each account to a
fresh one-time password (must-change) and emails the web login link,
username, and password. New method since the original one-time
password is not recoverable for existing accounts.
- mailbox (all verified): ensures the forwardemail alias and emails the
address + webmail link.
- Preview by default; --send executes. --git/--mail/--user filters.
Refuses --send without SMTP; warns+skips when Forgejo/forwardemail
are unconfigured.
Also folds in the welcome-email functions (gitWelcomeEmailBody,
mailWelcomeEmailBody, EnsureUser password return, provisionGit/
ensurePremium sends) that this builds on. README ops + forgejo tests.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
---------
Co-authored-by: Claude Opus 4.8 <noreply@anthropic.com>
* feat(mail): give every verified member a free @bbs.profullstack.com mailbox
Email was built but paid-only (Founding Lifetime gate) and never wired to a
running backend. Make it a free benefit of membership and split the address
domain from the mail-server host.
- internal/mailu: Mailu admin-API client; EnsureUser idempotently provisions a
mailbox via the loopback admin REST API (token = mailu.env API_TOKEN).
- main.go: auto-provision <name>@<mailDomain> at join@ verification and on first
Mail open; un-gate the Mail hub entry + mail@ (membership/email-verified, not
Premium); address domain (AGENTBBS_MAIL_ADDR_DOMAIN, default the BBS host) is
now distinct from the mail server host (AGENTBBS_MAIL_DOMAIN) and the webmail
URL. Drop the forwardemail alias path (Mailu now owns delivery for everyone).
- mailbox: gate on membership (a registered handle) instead of Paid;
ErrNotPaid -> ErrNotMember.
- join@ copy: list email under free membership; premium now pitches custom
domains + Tor only.
- setup.sh / docs/mail.md / deploy/mailu: address-domain vs server-host split,
Mailu API token, MX for the address domain, local-relay SMTP for verify codes.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
* chore(mailu): pin Docker network subnet to match SUBNET; ignore runtime state
The base compose declares no network, so Docker assigns the default bridge an
arbitrary subnet that won't match mailu.env SUBNET — breaking Mailu's internal
service auth/relay. Add a docker-compose.override.yml.example that pins the
default network to 192.168.203.0/24, and gitignore the live override + Mailu
runtime state (mailu.env, certs/, data/).
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
* feat(mail): plaintext loopback IMAP so the gateway bypasses Mailu's front
Mailu's front (nginx mail proxy) pre-authenticates against Mailu's user DB before
proxying to Dovecot, which rejects the Dovecot master-user login <addr>*gateway.
The gateway must reach Dovecot directly. The imap container has no TLS cert (only
the front does), so the bypass is plaintext over loopback — the master password
never leaves the host.
- mailbox: IMAPConfig.Plaintext dials with DialInsecure (loopback only).
- main.go: mailClientFor sets Plaintext from AGENTBBS_MAIL_IMAP_PLAINTEXT.
- override.example: add the unbound resolver (admin needs DNSSEC), webmail image
fix (2024.06 uses mailu/webmail), and publish Dovecot 143 on 127.0.0.1:14143.
- docs/mail.md: document the front-bypass, the dovecot.conf master passdb (Mailu
includes that exact filename), and the 644 master-users perms (640 = temp_fail).
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
* deploy(mailu): wire gateway IMAP to the loopback Dovecot path in setup.sh
setup.sh §9e set AGENTBBS_MAIL_IMAP_ADDR to the front's :993, which the front's
auth proxy rejects for the master-user login (and would clobber the working
loopback wiring on every self-update). Point it at 127.0.0.1:14143 +
AGENTBBS_MAIL_IMAP_PLAINTEXT=1 instead, matching the override + docs.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
* feat(mail): give free members a webmail password at join@
The gateway opens mailboxes via the Dovecot master user (no member password),
but webmail (Roundcube) needs the member to have a password. join@ now sets a
fresh, readable webmail password via the Mailu API and shows it with the webmail
URL + login, so free members can use webmail at mail.profullstack.com.
- mailu: SetPassword (PATCH /user/<email> raw_password) + test.
- main.go: setWebmailPassword + readablePassword; join@ displays url/login/password.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
---------
Co-authored-by: Claude Opus 4.8 <noreply@anthropic.com>
`agentbbs notify-creds` backfills credential emails to verified members
who signed up before the git/mailbox welcome emails existed.
- git (all verified): forgejo.EnsureUserReset resets each account to a
fresh one-time password (must-change) and emails the web login link,
username, and password. New method since the original one-time
password is not recoverable for existing accounts.
- mailbox (all verified): ensures the forwardemail alias and emails the
address + webmail link.
- Preview by default; --send executes. --git/--mail/--user filters.
Refuses --send without SMTP; warns+skips when Forgejo/forwardemail
are unconfigured.
Also folds in the welcome-email functions (gitWelcomeEmailBody,
mailWelcomeEmailBody, EnsureUser password return, provisionGit/
ensurePremium sends) that this builds on. README ops + forgejo tests.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Implements M4 (Files). A fully virtual Go SFTP server (pkg/sftp + crypto/ssh,
no OS users) wired as an "sftp" subsystem on the existing :22 wish listener, so
members reach their files with their login key:
sftp files@bbs.profullstack.com # scp/rsync ride the same endpoint
Identity is the SSH key (the username is conventional/ignored). Two areas per
session: a private, quota-limited /me workspace and a single shared public file
area /public (old-school BBS file area; world-read, members-only write by
default, operator-moderated). This reverses the old NG1 "no sharing" boundary in
favour of one sanctioned, inspectable sharing surface (PRD §9.3 amended).
internal/files:
- backend.go service, layout, quota/usage, live-session registry, operator API
- fs.go per-session virtual FS; resolve() is the single security
chokepoint (area confinement + symlink-escape guard) + pkg/sftp
request handlers
- server.go subsystem handler: key auth -> member session -> request server,
with byte metering and force-disconnect
- tui.go in-BBS member browser (hub plugin "Files")
- admin.go operator management TUI: sessions, workspaces/quotas, public area
Operator console: ssh sftp@<host> (allowlist-gated; sftpadmin@/filesadmin@
aliases) — list/disconnect sessions, set per-user quotas, revoke SFTP access,
toggle public write, moderate the public area.
store: files_access (per-user quota override + revoked) and files_settings
(public-write mode) tables + methods. main.go wiring guarded by AGENTBBS_FILES
(+ AGENTBBS_FILES_QUOTA_MB, default 1 GiB). Route names reserved.
Tests (incl -race): path traversal/confinement, symlink-escape rejection,
public-write ACL, quota enforcement, usage accounting, and an end-to-end run
against a real SFTP client. Docs: docs/files.md; PRD §5.3/§5.3.1/§9.3 + README
updated.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
The deploy's rootless-podman context intermittently fails (pre-pull/build/even
image-exists), which made setup.sh upsert AGENTBBS_POD_IMAGE back to the base
ubuntu and silently strip Claude Code/Codex from pods. Only upsert when we
actually have localhost/agentbbs-pod:latest; otherwise leave the configured
value untouched (the agentbbs daemon uses the local image from its own session).
Co-authored-by: Claude Opus 4.8 <noreply@anthropic.com>