handleJoin called setWebmailPassword unconditionally, so every visit to
join@ minted a fresh Roundcube password -- including for members who
registered and verified long ago.
join@ is the address people remember, so returning members type it out
of habit. Doing so silently invalidated their webmail login: the
replacement password scrolled past once in the join output, the old one
was already dead, and nothing in the session said a credential had
changed. The failure surfaces later, as "my password stopped working",
with no way to connect it back to having typed join@ -- and it reads
like a compromised account, which is an alarming thing to hand someone
over a no-op visit.
Mint the password only while actually onboarding: a new key registering,
or an account that completes email verification in this session. A
returning member is told the password is unchanged and pointed at
passwd@, which already sets one password across git, mail and chat and
is key-gated, so it doubles as the forgot-password path.
Also replace the bare webmail URL line shown when no password is minted
with the full url/login block, so the address and where to get
credentials are always visible, and distinguish "unchanged" (returning)
from "not set yet" (onboarding, but Mailu was unreachable).
Verified by inspection only: this box has no Go toolchain (repo needs
1.26), so the build is left to CI.
Co-authored-by: Claude Opus 5 (1M context) <noreply@anthropic.com>
Dependabot PRs run with a read-only GITHUB_TOKEN, and GitHub was returning
503 (the HTML "Unicorn" page) for the comment write; the step's catch only
handled 403 and re-threw everything else, failing the whole scan even though
the security scan itself passed.
- skip the comment step for github.actor == 'dependabot[bot]'
- continue-on-error: true
- warn-and-continue on any status instead of only 403
Mirrors sh1pt pack vu1nz-scan@1.0.1.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
The provision step aborted at "admin sshd is NOT listening on 2202"
because it decided ssh was socket-activated purely from `systemctl cat
ssh.socket` succeeding — but that unit file exists on every modern box,
including DigitalOcean images where the real listener is the standalone
ssh.service. It then restarted only ssh.socket, so nothing ended up
bound on the admin port and the safety check killed the deploy.
- Detect socket vs. standalone mode via `is-active`/`is-enabled`, not
mere unit-file existence.
- In socket mode, stop the standalone ssh.service first so it can't
fight the socket for the port.
- Fall back to the other restart path if the first doesn't bind.
- Dump listener + unit diagnostics before aborting.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
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>