Skip to content

Commit 641fa01

Browse files
committed
Merge feat/qrypt-invite-issuer: qrypt.chat invite issuer + IRC + Tor routes
2 parents f44f0c9 + 97da723 commit 641fa01

18 files changed

Lines changed: 2710 additions & 4 deletions

File tree

README.md

Lines changed: 19 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -33,6 +33,7 @@ plugins around one shared account system; the full product plan is in
3333
| `agent@` chat (configurable agent backend) + finger ||
3434
| M2 — admin console (`admin@`: users, sessions, moderation, plugins) ||
3535
| M3 — AgentGames (`game@` + WebSocket; TTT/C4, ELO ladder, replays) ||
36+
| IRC (`irc.bbs.profullstack.com` — Ergo network for humans + agents) ||
3637
| M4 — Files (cl1.tech SFTP workspaces) ||
3738
| M5 — AgentAd marketplace (built on the AgentAd standard in logicsrc) ||
3839

@@ -99,6 +100,24 @@ The production host (`bbs.profullstack.com`) is provisioned by the idempotent
99100

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

103+
### IRC network
104+
105+
`setup.sh` also stands up a co-located [Ergo](https://ergo.chat) IRC server (its
106+
own `ergo.service`, ports 6697/TLS + a Caddy-fronted WebSocket) so humans and
107+
agents can meet on a real IRC network. It is **members-only**: every client must
108+
authenticate with SASL, and an auth-script approves a login only if the account
109+
name is an existing AgentBBS member (registration is off — your BBS account *is*
110+
your IRC identity):
111+
112+
```bash
113+
# native client — SASL account = your BBS member name
114+
/connect irc.bbs.profullstack.com 6697
115+
# browser / agent over WebSocket
116+
wss://bbs.profullstack.com/irc
117+
```
118+
119+
Set `IRC=0` to skip it. Full details: [`docs/irc.md`](docs/irc.md).
120+
102121
## Architecture
103122

104123
- **Go + charmbracelet**`wish` SSH server, `bubbletea` TUIs, `lipgloss` styling.

cmd/agentbbs/main.go

Lines changed: 149 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -19,6 +19,8 @@
1919
// agentbbs map-domain DOMAIN NAME map a custom domain to a homepage
2020
// agentbbs unmap-domain DOMAIN NAME remove a custom-domain mapping
2121
// agentbbs mint-token NAME issue a WebSocket API token for NAME
22+
// agentbbs qrypt-invite NAME mint a qrypt.chat anonymous invite for NAME
23+
// agentbbs qrypt-issuer-keygen print a fresh qrypt issuer seed + public key
2224
package main
2325

2426
import (
@@ -61,9 +63,11 @@ import (
6163
"github.com/profullstack/agentbbs/internal/sandbox"
6264
"github.com/profullstack/agentbbs/internal/sites"
6365
"github.com/profullstack/agentbbs/internal/store"
66+
"github.com/profullstack/agentbbs/internal/tor"
6467
"github.com/profullstack/agentbbs/plugins/about"
6568
"github.com/profullstack/agentbbs/plugins/agentgames"
6669
"github.com/profullstack/agentbbs/plugins/arcade"
70+
qryptinviteplugin "github.com/profullstack/agentbbs/plugins/qryptinvite"
6771
)
6872

6973
func env(k, def string) string {
@@ -121,6 +125,14 @@ func main() {
121125
mintToken(st, os.Args[2:])
122126
return
123127
}
128+
if len(os.Args) > 1 && os.Args[1] == "qrypt-invite" {
129+
qryptInviteCmd(st, os.Args[2:])
130+
return
131+
}
132+
if len(os.Args) > 1 && os.Args[1] == "qrypt-issuer-keygen" {
133+
qryptIssuerKeygen()
134+
return
135+
}
124136

125137
host := env("AGENTBBS_HOST", "bbs.profullstack.com")
126138
fe := forwardemail.ConfigFromEnv()
@@ -141,7 +153,7 @@ func main() {
141153
a.mm = games.NewMatchmaker(a.gamesReg, a.st,
142154
time.Duration(envInt("AGENTBBS_GAME_MOVE_TIMEOUT", 15))*time.Second,
143155
time.Duration(envInt("AGENTBBS_GAME_QUEUE_WAIT", 120))*time.Second)
144-
a.registry = []plugin.Plugin{arcade.Plugin{}, agentgames.New(a.gamesReg), about.Plugin{}}
156+
a.registry = []plugin.Plugin{arcade.Plugin{}, agentgames.New(a.gamesReg), qryptinviteplugin.Plugin{}, about.Plugin{}}
145157

146158
// Custom domains: maintain the symlink farm Caddy serves and answer its
147159
// on-demand-TLS "ask" query so certs are only issued for mapped domains.
@@ -247,6 +259,12 @@ func (a *app) router() wish.Middleware {
247259
a.handleGame(s)
248260
case auth.IsPodName(user):
249261
a.handlePod(s)
262+
case auth.IsTorURLName(user):
263+
a.handleTorURL(s)
264+
case auth.IsTorIRCName(user):
265+
a.handleTorIRC(s)
266+
case auth.IsTorName(user):
267+
a.handleTorCmd(s)
250268
case isVideo:
251269
a.handleVideo(s, code)
252270
case user == "agent":
@@ -841,6 +859,136 @@ func (a *app) handlePod(s ssh.Session) {
841859
}
842860
}
843861

862+
// torMember resolves the caller's key to a premium member for the tor routes,
863+
// printing a reason and returning ok=false otherwise. It records the session.
864+
func (a *app) torMember(s ssh.Session, route string) (store.User, bool) {
865+
fp := auth.Fingerprint(s.PublicKey())
866+
if fp == "" {
867+
wish.Println(s, route+"@ needs your registered SSH key. New here? ssh join@"+a.host)
868+
_ = s.Exit(1)
869+
return store.User{}, false
870+
}
871+
u, found, err := a.st.UserByFingerprint(fp)
872+
if err != nil || !found {
873+
wish.Println(s, "key not registered — run: ssh join@"+a.host)
874+
_ = s.Exit(1)
875+
return store.User{}, false
876+
}
877+
if u.Banned {
878+
wish.Println(s, "this account is suspended.")
879+
_ = s.Exit(1)
880+
return store.User{}, false
881+
}
882+
if !a.ensurePremium(&u) {
883+
wish.Println(s, " "+route+" is a Premium feature ($10 lifetime). Upgrade: ssh join@"+a.host)
884+
_ = s.Exit(1)
885+
return store.User{}, false
886+
}
887+
_, _ = a.st.RecordSession(u.ID, s.User(), remoteIP(s), route)
888+
return u, true
889+
}
890+
891+
// handleTorURL fetches a single URL over Tor and writes the body back. One-shot,
892+
// host-side, and constrained (timeout + size cap, http/https only). Premium.
893+
func (a *app) handleTorURL(s ssh.Session) {
894+
u, ok := a.torMember(s, "tor-url")
895+
if !ok {
896+
return
897+
}
898+
args := s.Command()
899+
if len(args) == 0 {
900+
wish.Println(s, "usage: ssh tor-url@"+a.host+" <http(s)-url> (e.g. an .onion address)")
901+
_ = s.Exit(1)
902+
return
903+
}
904+
url := args[0]
905+
log.Info("tor-url fetch", "user", u.Name, "url", url)
906+
body, err := tor.FetchURL(s.Context(), url)
907+
if err != nil {
908+
wish.Println(s, " "+err.Error())
909+
_ = s.Exit(1)
910+
return
911+
}
912+
_, _ = s.Write(body)
913+
_ = s.Exit(0)
914+
}
915+
916+
// handleTorIRC opens an interactive IRC-over-Tor session inside the member's
917+
// pod (sandboxed). Premium; requires a PTY.
918+
func (a *app) handleTorIRC(s ssh.Session) {
919+
u, ok := a.torMember(s, "tor-irc")
920+
if !ok {
921+
return
922+
}
923+
args := s.Command()
924+
if len(args) == 0 || !validIRCServer(args[0]) {
925+
wish.Println(s, "usage: ssh -t tor-irc@"+a.host+" <server[:port]> (e.g. an .onion IRC server)")
926+
_ = s.Exit(1)
927+
return
928+
}
929+
if a.pods == nil {
930+
wish.Println(s, "pods are temporarily unavailable on this host.")
931+
_ = s.Exit(1)
932+
return
933+
}
934+
log.Info("tor-irc connect", "user", u.Name, "server", args[0])
935+
if err := a.pods.Exec(s, u.Name, tor.IRCArgv(args[0])); err != nil {
936+
wish.Println(s, "tor-irc error: "+err.Error())
937+
_ = s.Exit(1)
938+
}
939+
}
940+
941+
// handleTorCmd runs an arbitrary command through Tor (torsocks) inside the
942+
// member's pod, never on the host. Premium; requires a PTY.
943+
func (a *app) handleTorCmd(s ssh.Session) {
944+
u, ok := a.torMember(s, "tor")
945+
if !ok {
946+
return
947+
}
948+
args := s.Command()
949+
if len(args) == 0 {
950+
wish.Println(s, "usage: ssh -t tor@"+a.host+" <command...> (runs in your pod, over Tor)")
951+
_ = s.Exit(1)
952+
return
953+
}
954+
if a.pods == nil {
955+
wish.Println(s, "pods are temporarily unavailable on this host.")
956+
_ = s.Exit(1)
957+
return
958+
}
959+
log.Info("tor cmd", "user", u.Name, "argv", strings.Join(args, " "))
960+
if err := a.pods.Exec(s, u.Name, tor.Torsocks(args)); err != nil {
961+
wish.Println(s, "tor error: "+err.Error())
962+
_ = s.Exit(1)
963+
}
964+
}
965+
966+
// validIRCServer accepts host or host:port with a sane charset (no shell/space).
967+
func validIRCServer(s string) bool {
968+
host := s
969+
if i := strings.LastIndex(s, ":"); i > 0 {
970+
port := s[i+1:]
971+
host = s[:i]
972+
if port == "" || len(port) > 5 {
973+
return false
974+
}
975+
for _, r := range port {
976+
if r < '0' || r > '9' {
977+
return false
978+
}
979+
}
980+
}
981+
if host == "" || len(host) > 255 {
982+
return false
983+
}
984+
for _, r := range host {
985+
if !(r >= 'a' && r <= 'z' || r >= 'A' && r <= 'Z' || r >= '0' && r <= '9' || r == '.' || r == '-') {
986+
return false
987+
}
988+
}
989+
return true
990+
}
991+
844992
// handleVideo joins a PairUX call rendered as ASCII (docs/video.md).
845993
// `video@` prompts for a code; `video-<code>@` joins directly. Codes are
846994
// minted by PairUX — starting a call requires already having one.

cmd/agentbbs/qrypt.go

Lines changed: 86 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,86 @@
1+
package main
2+
3+
import (
4+
"errors"
5+
"fmt"
6+
"os"
7+
"strings"
8+
9+
qi "github.com/profullstack/agentbbs/internal/qryptinvite"
10+
"github.com/profullstack/agentbbs/internal/store"
11+
)
12+
13+
// qryptInviteCmd is the ops side of qrypt.chat invites:
14+
// `agentbbs qrypt-invite <user>` mints a single-use anonymous invite on behalf
15+
// of an existing member, respecting their per-account quota, and prints the
16+
// token + redeem URL (docs/qrypt-invites.md).
17+
func qryptInviteCmd(st store.Store, args []string) {
18+
if len(args) < 1 {
19+
fmt.Fprintln(os.Stderr, "usage: agentbbs qrypt-invite <username>")
20+
os.Exit(2)
21+
}
22+
name := strings.ToLower(args[0])
23+
if _, found, err := st.UserByName(name); err != nil {
24+
fmt.Fprintln(os.Stderr, "lookup:", err)
25+
os.Exit(1)
26+
} else if !found {
27+
fmt.Fprintf(os.Stderr, "no such account: %s (register via ssh join@)\n", name)
28+
os.Exit(1)
29+
}
30+
31+
cfg := qi.ConfigFromEnv()
32+
priv, err := cfg.PrivateKey()
33+
if err != nil {
34+
fmt.Fprintln(os.Stderr, err)
35+
os.Exit(1)
36+
}
37+
38+
token, jti, err := qi.Mint(cfg.IssuerID, priv, cfg.TTL)
39+
if err != nil {
40+
fmt.Fprintln(os.Stderr, "mint:", err)
41+
os.Exit(1)
42+
}
43+
if err := st.RecordQryptInvite(name, jti, cfg.Quota); err != nil {
44+
if errors.Is(err, store.ErrQuotaExceeded) {
45+
used, _ := st.QryptInviteCount(name)
46+
fmt.Fprintf(os.Stderr, "%s is at their invite quota (%d/%d)\n", name, used, cfg.Quota)
47+
os.Exit(1)
48+
}
49+
fmt.Fprintln(os.Stderr, "record:", err)
50+
os.Exit(1)
51+
}
52+
53+
used, _ := st.QryptInviteCount(name)
54+
remaining := cfg.Quota - used
55+
if remaining < 0 {
56+
remaining = 0
57+
}
58+
fmt.Printf("qrypt.chat invite for %s (issuer %s, expires in %s, single-use):\n\n", name, cfg.IssuerID, cfg.TTL)
59+
fmt.Printf(" redeem %s\n", cfg.RedeemURLFor(token))
60+
fmt.Printf(" token %s\n", token)
61+
fmt.Printf(" jti %s\n\n", jti)
62+
if cfg.Quota > 0 {
63+
fmt.Printf("invites left for %s: %d/%d\n", name, remaining, cfg.Quota)
64+
}
65+
}
66+
67+
// qryptIssuerKeygen prints a fresh Ed25519 issuer keypair for first-time setup:
68+
// the base64 seed (private — goes in AGENTBBS_QRYPT_ISSUER_KEY) and the base64
69+
// raw public key (goes in qrypt.chat's invite_issuers row).
70+
func qryptIssuerKeygen() {
71+
seed, pub, err := qi.GenerateIssuerKey()
72+
if err != nil {
73+
fmt.Fprintln(os.Stderr, "keygen:", err)
74+
os.Exit(1)
75+
}
76+
cfg := qi.ConfigFromEnv()
77+
fmt.Println("Fresh qrypt.chat invite-issuer keypair:")
78+
fmt.Println()
79+
fmt.Println(" 1) On agentbbs, set the PRIVATE seed (keep it secret):")
80+
fmt.Printf(" AGENTBBS_QRYPT_ISSUER_KEY=%s\n\n", seed)
81+
fmt.Println(" 2) In qrypt.chat, insert an invite_issuers row with the PUBLIC key:")
82+
fmt.Printf(" id %s\n", cfg.IssuerID)
83+
fmt.Printf(" ed25519_public_key %s\n\n", pub)
84+
fmt.Printf(" INSERT INTO invite_issuers (id, name, ed25519_public_key)\n")
85+
fmt.Printf(" VALUES ('%s', 'AgentBBS', '%s');\n", cfg.IssuerID, pub)
86+
}

deploy/ergo/auth-script.sh

Lines changed: 48 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,48 @@
1+
#!/usr/bin/env bash
2+
#
3+
# auth-script.sh — Ergo auth-script that gates the IRC network on AgentBBS
4+
# membership. setup.sh installs this to /usr/local/bin/ergo-auth-member and
5+
# wires it into /etc/ergo/ircd.yaml (accounts.auth-script).
6+
#
7+
# "Member" == a user with a home dir under the AgentBBS users dir (created when
8+
# someone registers via `ssh join@`). IRC is members-only, so a login is
9+
# approved iff the requested account name maps to such a dir. The passphrase is
10+
# intentionally IGNORED — membership (a filesystem dir) IS the credential, by
11+
# design (see docs/irc.md). Anyone who knows a member's name can connect as
12+
# them; that tradeoff was chosen deliberately for this private, TLS-only network.
13+
#
14+
# Protocol (Ergo): one JSON object on stdin per attempt, one JSON line on stdout
15+
# then exit. Input keys: accountName, passphrase, certfp, ip. Output:
16+
# {"success":bool,"accountName":str,"error":str}.
17+
#
18+
# args: ["<users-dir>"] # defaults to /var/lib/agentbbs/users
19+
set -uo pipefail
20+
21+
USERS_DIR="${1:-/var/lib/agentbbs/users}"
22+
23+
# Always emit valid JSON and exit 0 — Ergo reads the JSON, not the exit code;
24+
# a non-zero exit / no output is treated as a script error, not a clean deny.
25+
deny() { printf '{"success":false,"error":"%s"}\n' "${1:-not a member}"; exit 0; }
26+
27+
# Don't gate on read's exit code: a final line without a trailing newline still
28+
# carries data (read returns non-zero at EOF but populates $line).
29+
line=""
30+
read -r line || true
31+
[ -n "$line" ] || deny "no input"
32+
33+
acct="$(printf '%s' "$line" | jq -r '.accountName // ""' 2>/dev/null || true)"
34+
35+
# certfp-only attempts carry no account name; we don't support cert auth here.
36+
[ -n "$acct" ] || deny "membership requires an account name"
37+
38+
# Defense in depth against path traversal. IRC account names are a restricted
39+
# charset anyway, but never let one escape USERS_DIR.
40+
case "$acct" in
41+
*[!A-Za-z0-9._-]* | "." | ".." | *..* | */* ) deny "invalid account name" ;;
42+
esac
43+
44+
if [ -d "$USERS_DIR/$acct" ]; then
45+
printf '{"success":true,"accountName":"%s"}\n' "$acct"
46+
else
47+
deny "not a member"
48+
fi

0 commit comments

Comments
 (0)