Skip to content

Repository files navigation

ssh.night.ms

image

A retro BBS written in Go, served over SSH and the web. Connect via:

ssh -p 2222 <handle>@<host>      # SSH terminal client
# or open the web terminal in a browser

Authenticated users land in a TUI lobby with chat, message boards, news (Hacker News), weather, an ANSI gallery, finance (stocks / crypto / FX), a world map, doors games (slots, video poker, blackjack, multiplayer hold'em), and a real Chromium-based browser ("Rich Mode" via Carbonyl).

Architecture in one paragraph

A single nightms binary runs two listeners that share auth, providers, and the Postgres pool: an SSH server (charmbracelet/wish + bubbletea) and an HTTP server (chi) whose /ws/bbs WebSocket bridges the same bubbletea root model into the browser. Persistent state lives in Postgres (sqlc-generated queries, golang-migrate migrations embedded via go:embed); realtime fan-out (chat, presence, wall, leaderboards, live hold'em tables) goes through Redis pub/sub. The composition root in cmd/nightms/main.go reads as a numbered table of contents; deeper architecture notes live in CLAUDE.md.

Quick start

The dev loop is driven by run.ps1 (works on Windows PowerShell 5.1+ and on Linux/macOS via PowerShell 7+). It boots Postgres and Redis in Docker, builds the binary natively, and runs it. Defaults: SSH on :2222, HTTP on :5080, Postgres :55432, Redis :56379.

# Linux / macOS
pwsh ./run.ps1                          # boot dependencies + build + run
pwsh ./run.ps1 -SysopHandle alice -Reset # wipe DB + seed; alice becomes sysop
pwsh ./run.ps1 -Docker                  # build + run the prod image instead
pwsh ./run.ps1 -Stop                    # tear down containers
# Windows
./run.ps1

On first run, fetch the Carbonyl rich-mode bundle (Git LFS, ~103 MB):

git lfs install
git lfs pull

Without the bundle the BBS still boots fine; the Web lobby entry just prints "rich mode unavailable".

Note on Git LFS bandwidth. The Carbonyl bundle lives in Git LFS and counts against the forking account's LFS bandwidth quota (GitHub's free tier is 1 GB/month). If you're forking and don't need rich-mode browsing, you can leave the bundle out — git clone --filter=blob:none or simply not running git lfs pull skips the download, and the BBS still boots with rich mode disabled. If you're publishing your own image, expect the bundle to dominate clone time on cold CI runs.

Pure-Go alternative without Docker (you supply Postgres + Redis yourself and set the NIGHTMS_* env vars):

go build -o bin/nightms ./cmd/nightms
go run ./cmd/nightms

Tests and codegen

go test ./...                                  # full suite, plain `go test`
go test -run TestCarousel ./internal/tui/components  # one test
sqlc generate                                  # after editing SQL or migrations

Configuration

Everything is env-var driven; see internal/config/options.go for the canonical list. Important knobs:

  • NIGHTMS_DATABASE_URL — Postgres URL (postgres://user:pw@host:port/db?sslmode=...)
  • NIGHTMS_REDIS_ADDR — Redis address
  • NIGHTMS_SSH_LISTEN_ADDR, NIGHTMS_HTTP_LISTEN_ADDR — listener binds
  • NIGHTMS_PUBLIC_HOST — used for CSRF + WebSocket origin allowlist
  • NIGHTMS_COOKIE_SECRET — optional; auto-generated on first boot if a host-key directory is writable, ephemeral otherwise
  • NIGHTMS_LOG_LEVELdebug | info | warn | error (default info)
  • NIGHTMS_DEBUG_ADDR — optional host:port to mount /debug/pprof and /debug/vars. Leave unset in production, or bind to localhost only.

OAuth (Google + Microsoft) is enabled by setting the corresponding client ID / secret pairs.

Deployment

deploy/compose.yml runs the prod stack (app + Postgres + Redis on a private Docker network). Dockerfile produces a ~750 MB debian:bookworm-slim-based image; the Carbonyl Chromium bundle accounts for ~400 MB of that. CI builds + deploys via .github/workflows/deploy.yml (it uses GitHub Secrets and will only work in the project's own GitHub repo unless you wire up your own secrets).

Optional WireGuard VPN

The Compose stack includes a repository-owned nightms-vpn sidecar. VPN access ships disabled (vpn_enabled=false) and must be enabled by a sysop after the direct UDP endpoint is configured and tested. The sidecar uses only NET_ADMIN; it does not run privileged and never receives the Docker socket.

Set these values in deploy/.env:

NIGHTMS_VPN_CONTROL_SECRET=<at-least-32-random-bytes>
NIGHTMS_VPN_ENDPOINT=vpn.night.ms:51820
NIGHTMS_VPN_SERVER_PUBLIC_KEY=<public-key-for-the-persisted-sidecar-key>

vpn.night.ms must be a direct DNS record (Cloudflare proxy off) and UDP 51820 must reach the host. The first sidecar start creates /data/server.key in the vpn-data volume; obtain its public key with docker compose exec vpn wg show wg0 public-key, put that value in .env, and recreate the app container before enabling VPN. The host must have kernel WireGuard/IFB support and permit IPv4 forwarding.

Profiles are full tunnel. IPv6 is deliberately blocked at the sidecar in v1 to prevent bypass. Ordinary TCP/UDP DNS is forced through the logging proxy and DoT is blocked; DNS-over-HTTPS can evade domain accounting. The app keeps daily byte totals for 365 days and normalized queried domains for 30 days, never full URLs, DNS answers, or packet contents.

Security

SECURITY.md covers how to report a vulnerability. CLAUDE.md has a long "Security" section documenting the three layers of defense (protocol, network, auth) and the audit pipeline. The SSH listener is exposed direct to the public internet; HTTPS terminates at a CDN (we use Cloudflare in Flexible mode).

License

MIT — see LICENSE. Bundled third-party software (Carbonyl, Chromium components, Go dependencies) carries its own attribution; see NOTICE.

About

My little SSH BBS where people can play games and socialize.

Topics

Resources

Contributing

Security policy

Stars

Watchers

Forks

Releases

Packages

Contributors

Languages