Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
23 changes: 23 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -33,6 +33,7 @@ plugins around one shared account system; the full product plan is in
| `agent@` chat (configurable agent backend) + finger | ✅ |
| M2 — admin console (`admin@`: users, sessions, moderation, plugins) | ✅ |
| M3 — AgentGames (`game@` + WebSocket; TTT/C4, ELO ladder, replays) | ✅ |
| IRC (`irc.bbs.profullstack.com` — Ergo network for humans + agents) | ✅ |
| M4 — Files (cl1.tech SFTP workspaces) | ⬜ |
| M5 — AgentAd marketplace (built on the AgentAd standard in logicsrc) | ⬜ |

Expand Down Expand Up @@ -99,6 +100,28 @@ The production host (`bbs.profullstack.com`) is provisioned by the idempotent

Full details, required secrets, and ops commands: [`docs/deploy.md`](docs/deploy.md).

### IRC network

`setup.sh` also stands up a co-located [Ergo](https://ergo.chat) IRC server (its
own `ergo.service`, ports 6697/TLS + a Caddy-fronted WebSocket) so humans and
agents can meet on a real IRC network. It is **members-only**: every client must
authenticate with SASL, and an auth-script approves a login only if the account
name is an existing AgentBBS member (registration is off — your BBS account *is*
your IRC identity):

```bash
# zero-setup: built-in client over SSH (members only)
ssh -t [email protected]
# native client — SASL account = your BBS member name
/connect irc.bbs.profullstack.com 6697
# browser / agent over WebSocket
wss://bbs.profullstack.com/irc
```

`ssh irc@` is a built-in IRC client (`internal/irc`) that authenticates you to
the network automatically — no client to install. Set `IRC=0` to skip the
server. Full details: [`docs/irc.md`](docs/irc.md).

## Architecture

- **Go + charmbracelet** — `wish` SSH server, `bubbletea` TUIs, `lipgloss` styling.
Expand Down
87 changes: 84 additions & 3 deletions cmd/agentbbs/main.go
Original file line number Diff line number Diff line change
Expand Up @@ -28,6 +28,7 @@ import (
"encoding/binary"
"errors"
"fmt"
"io"
"net"
"net/http"
"os"
Expand All @@ -53,6 +54,7 @@ import (
"github.com/profullstack/agentbbs/internal/forwardemail"
"github.com/profullstack/agentbbs/internal/games"
"github.com/profullstack/agentbbs/internal/hub"
"github.com/profullstack/agentbbs/internal/irc"
"github.com/profullstack/agentbbs/internal/mail"
"github.com/profullstack/agentbbs/internal/payments"
"github.com/profullstack/agentbbs/internal/plugin"
Expand Down Expand Up @@ -253,6 +255,8 @@ func (a *app) router() wish.Middleware {
a.handleTorIRC(s)
case auth.IsTorName(user):
a.handleTorCmd(s)
case auth.IsIRCName(user):
a.handleIRC(s)
case isVideo:
a.handleVideo(s, code)
case user == "agent":
Expand Down Expand Up @@ -320,6 +324,41 @@ func (a *app) teaHandler(s ssh.Session) (tea.Model, []tea.ProgramOption) {
return hub.New(u, ctx, a.enabledPlugins()), []tea.ProgramOption{tea.WithAltScreen()}
}

// readLine reads one line of interactive input from an SSH session that is
// running under a client-allocated PTY. That detail is the whole reason this
// helper exists: when the client requests a PTY (which `ssh join@host` does by
// default) it puts its OWN terminal into raw mode, so it sends raw keystrokes —
// Enter arrives as '\r', not '\n' — and does NO local echo. bufio.ReadString
// ('\n') therefore blocks forever (the '\n' never comes) and the user sees a
// dead prompt. So we read byte-by-byte, accept either '\r' or '\n' as the line
// terminator, handle backspace, and echo printable bytes back ourselves.
func readLine(s ssh.Session, in *bufio.Reader) (string, error) {
var b []byte
for {
c, err := in.ReadByte()
if err != nil {
return "", err
}
switch c {
case '\r', '\n':
wish.Print(s, "\r\n")
return string(b), nil
case 0x03, 0x04: // Ctrl-C / Ctrl-D: treat as abort
return "", io.EOF
case 0x7f, '\b': // DEL / backspace: erase last char on screen too
if len(b) > 0 {
b = b[:len(b)-1]
wish.Print(s, "\b \b")
}
default:
if c >= 0x20 { // printable byte; ignore other control codes
b = append(b, c)
wish.Print(s, string(c))
}
}
}
}

// handleJoin runs onboarding interactively in one SSH session: register the
// visitor's key, confirm their email with a code we email them, then offer the
// $10 lifetime Premium membership (CoinPay). It then disconnects.
Expand Down Expand Up @@ -398,7 +437,7 @@ func (a *app) registerNewMember(s ssh.Session, in *bufio.Reader, fp string) (sto

for tries := 0; tries < 5; tries++ {
wish.Print(s, "\n Username ["+def+"]: ")
line, err := in.ReadString('\n')
line, err := readLine(s, in)
if err != nil {
return store.User{}, err
}
Expand Down Expand Up @@ -433,7 +472,7 @@ func (a *app) verifyEmailInteractive(s ssh.Session, in *bufio.Reader, u *store.U
var email string
for tries := 0; tries < 3; tries++ {
wish.Print(s, "\n Email: ")
line, err := in.ReadString('\n')
line, err := readLine(s, in)
if err != nil {
return false
}
Expand Down Expand Up @@ -472,7 +511,7 @@ func (a *app) verifyEmailInteractive(s ssh.Session, in *bufio.Reader, u *store.U

for tries := 0; tries < 3; tries++ {
wish.Print(s, " Enter the code: ")
line, err := in.ReadString('\n')
line, err := readLine(s, in)
if err != nil {
return false
}
Expand Down Expand Up @@ -891,6 +930,48 @@ func (a *app) handleTorIRC(s ssh.Session) {
}
}

// handleIRC drops a member into the BBS's own (members-only) IRC network using
// an in-process client: it authenticates to Ergo over SASL as the member and
// runs a Bubble Tea TUI. Free for any registered member; needs a PTY. Distinct
// from tor-irc@ (a client for remote servers over Tor).
func (a *app) handleIRC(s ssh.Session) {
fp := auth.Fingerprint(s.PublicKey())
if fp == "" {
wish.Println(s, "irc@ needs your registered SSH key. New here? ssh join@"+a.host)
_ = s.Exit(1)
return
}
u, found, err := a.st.UserByFingerprint(fp)
if err != nil || !found {
wish.Println(s, "the IRC network is members-only — register first: ssh join@"+a.host)
_ = s.Exit(1)
return
}
if u.Banned {
wish.Println(s, "this account is suspended.")
_ = s.Exit(1)
return
}
sessID, _ := a.st.RecordSession(u.ID, s.User(), remoteIP(s), "irc")
defer func() { _ = a.st.EndSession(sessID) }()

addr := strings.TrimSpace(os.Getenv("AGENTBBS_IRC_ADDR"))
if addr == "" {
addr = irc.DefaultAddr
}
log.Info("irc connect", "user", u.Name, "addr", addr)
c, err := irc.Dial(s.Context(), addr, u.Name)
if err != nil {
wish.Println(s, "irc: "+err.Error())
_ = s.Exit(1)
return
}
_ = c.Join(irc.DefaultChannel)
if err := irc.Run(s, c); err != nil {
wish.Println(s, "irc: "+err.Error())
}
}

// handleTorCmd runs an arbitrary command through Tor (torsocks) inside the
// member's pod, never on the host. Premium; requires a PTY.
func (a *app) handleTorCmd(s ssh.Session) {
Expand Down
48 changes: 48 additions & 0 deletions deploy/ergo/auth-script.sh
Original file line number Diff line number Diff line change
@@ -0,0 +1,48 @@
#!/usr/bin/env bash
#
# auth-script.sh — Ergo auth-script that gates the IRC network on AgentBBS
# membership. setup.sh installs this to /usr/local/bin/ergo-auth-member and
# wires it into /etc/ergo/ircd.yaml (accounts.auth-script).
#
# "Member" == a user with a home dir under the AgentBBS users dir (created when
# someone registers via `ssh join@`). IRC is members-only, so a login is
# approved iff the requested account name maps to such a dir. The passphrase is
# intentionally IGNORED — membership (a filesystem dir) IS the credential, by
# design (see docs/irc.md). Anyone who knows a member's name can connect as
# them; that tradeoff was chosen deliberately for this private, TLS-only network.
#
# Protocol (Ergo): one JSON object on stdin per attempt, one JSON line on stdout
# then exit. Input keys: accountName, passphrase, certfp, ip. Output:
# {"success":bool,"accountName":str,"error":str}.
#
# args: ["<users-dir>"] # defaults to /var/lib/agentbbs/users
set -uo pipefail

USERS_DIR="${1:-/var/lib/agentbbs/users}"

# Always emit valid JSON and exit 0 — Ergo reads the JSON, not the exit code;
# a non-zero exit / no output is treated as a script error, not a clean deny.
deny() { printf '{"success":false,"error":"%s"}\n' "${1:-not a member}"; exit 0; }

# Don't gate on read's exit code: a final line without a trailing newline still
# carries data (read returns non-zero at EOF but populates $line).
line=""
read -r line || true
[ -n "$line" ] || deny "no input"

acct="$(printf '%s' "$line" | jq -r '.accountName // ""' 2>/dev/null || true)"

# certfp-only attempts carry no account name; we don't support cert auth here.
[ -n "$acct" ] || deny "membership requires an account name"

# Defense in depth against path traversal. IRC account names are a restricted
# charset anyway, but never let one escape USERS_DIR.
case "$acct" in
*[!A-Za-z0-9._-]* | "." | ".." | *..* | */* ) deny "invalid account name" ;;
esac

if [ -d "$USERS_DIR/$acct" ]; then
printf '{"success":true,"accountName":"%s"}\n' "$acct"
else
deny "not a member"
fi
Loading
Loading