Registration has been dead since 2026-09-13. `ssh join@bbs.profullstack.com`
creates the account, then fails at the confirmation step with "couldn't email
the code" and disconnects, so nobody can finish signing up.
Cause: Caddy owns ACME for mail.profullstack.com and renewed on 2026-08-14
(valid to Nov 12), but Mailu went on serving the certificate it loaded at
container start (Jun 15 -> Sep 13). When that lapsed, the STARTTLS handshake
from internal/mail started failing verification and every transactional send
died with it -- confirmation codes, signup notifications, credential mail.
Reproduced against production; 25/465/993 all still present the expired cert
while :443 serves the renewed one.
Three things let a single stale certificate take registration down:
- setup.sh installed the refresher and enabled its *timer*, but never ran it.
`systemctl enable --now <timer>` starts the timer, not the service, so a
redeploy left a stale cert in place (and did nothing at all if the timer was
never scheduled). The news and IRC sections already run theirs at provision
time; the Mailu section now does too, which is what repairs the live host.
- refresh-certs.sh only compared files, so a copy whose reload silently failed
left a fresh cert on disk and an expiring one on the wire -- invisible. It now
reads back what the relay actually serves, forces a reload when that disagrees
with /certs, refuses to copy a source cert that is itself expired, and no
longer swallows the `docker compose restart` failure. It restarts `front`
alone, the only container that mounts ./certs.
- internal/mail verified the relay's certificate even on loopback, where there
is nothing to intercept. It now skips verification for a loopback relay (the
reasoning docs/mail.md already applies to the plaintext Dovecot hand-off) and
gains AGENTBBS_SMTP_SERVERNAME, mirroring AGENTBBS_MAIL_SMTP_SERVERNAME, so
the documented 127.0.0.1:25 config can verify against the mail host instead of
an IP literal. A non-loopback relay is still verified. Errors are wrapped with
the address and the failing stage so the next failure is one journal line to
diagnose rather than nine days of silence.
Tests cover the envelope, the unreachable-relay message, and both halves of the
TLS decision: a loopback relay with an expired cert delivers, a non-loopback one
with the same cert is refused.
Co-authored-by: Claude Opus 5 (1M context) <noreply@anthropic.com>
Dependabot's go-modules bump (#123) fails to build: livekit/protocol
1.51.0 changed TransferSIPParticipant to return
*TransferSIPParticipantResponse, but server-sdk-go v2.18.1 still
returns *emptypb.Empty from its wrapper, so the module no longer
compiles. No tagged server-sdk-go release carries the fix yet, so pin
the SDK to a main pseudo-version (1da58cd, 2026-09-04) which was built
against the new signature and requires go >= 1.26.3.
- github.com/livekit/protocol v1.50.4 -> v1.51.1-0.20260903060125-0cf5ba018b8e
- github.com/livekit/server-sdk-go/v2 v2.18.1 -> v2.18.2-0.20260904062056-1da58cd7b795
- github.com/pion/webrtc/v4 v4.2.18 -> v4.2.19
- go directive 1.26 -> 1.26.3
Supersedes #123.
Claude-Session: https://claude.ai/code/session_01Y9ZqGBKouuFipzsRn6cUfa
Co-authored-by: Claude Fable 5.1 <noreply@anthropic.com>
Installs threatcrush-scan@1.1.0 from the sh1pt Actions Store.
Scans pull requests for hardcoded credentials, injection, SSRF, unsafe
deserialisation and dependency tampering; uploads SARIF to the Security
tab.
Report-only — it will not fail a pull request. Set the pack's failOn
input to critical,high once the existing findings are triaged.
Co-authored-by: Claude Opus 5 (1M context) <noreply@anthropic.com>
vu1nz reviews a diff by calling Claude, which needs ANTHROPIC_API_KEY
supplied through the ENV_FILE secret. That key is not present on this
repository, so the scanner has never reviewed a pull request. On pack
1.0.0 and 1.0.1 that failure was silent: the job reported "0 finding(s),
no high/critical issues" on a diff nothing had read, which is worse than
no scanner at all.
threatcrush-scan covers the same ground deterministically - credentials,
injection, SSRF, unsafe deserialisation, XXE, dependency tampering - with
no API key and no per-pull-request cost.
Reinstallable from the sh1pt Actions Store if the key is ever provisioned.
Co-authored-by: Claude Opus 5 <noreply@anthropic.com>
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>