Merge feat/qrypt-invite-issuer: qrypt.chat invite issuer + IRC + Tor routes

This commit is contained in:
Anthony Ettinger 2026-06-14 11:44:46 +00:00
commit 641fa01d9a
18 changed files with 2710 additions and 4 deletions

View file

@ -19,6 +19,8 @@
// agentbbs map-domain DOMAIN NAME map a custom domain to a homepage
// agentbbs unmap-domain DOMAIN NAME remove a custom-domain mapping
// agentbbs mint-token NAME issue a WebSocket API token for NAME
// agentbbs qrypt-invite NAME mint a qrypt.chat anonymous invite for NAME
// agentbbs qrypt-issuer-keygen print a fresh qrypt issuer seed + public key
package main
import (
@ -61,9 +63,11 @@ import (
"github.com/profullstack/agentbbs/internal/sandbox"
"github.com/profullstack/agentbbs/internal/sites"
"github.com/profullstack/agentbbs/internal/store"
"github.com/profullstack/agentbbs/internal/tor"
"github.com/profullstack/agentbbs/plugins/about"
"github.com/profullstack/agentbbs/plugins/agentgames"
"github.com/profullstack/agentbbs/plugins/arcade"
qryptinviteplugin "github.com/profullstack/agentbbs/plugins/qryptinvite"
)
func env(k, def string) string {
@ -121,6 +125,14 @@ func main() {
mintToken(st, os.Args[2:])
return
}
if len(os.Args) > 1 && os.Args[1] == "qrypt-invite" {
qryptInviteCmd(st, os.Args[2:])
return
}
if len(os.Args) > 1 && os.Args[1] == "qrypt-issuer-keygen" {
qryptIssuerKeygen()
return
}
host := env("AGENTBBS_HOST", "bbs.profullstack.com")
fe := forwardemail.ConfigFromEnv()
@ -141,7 +153,7 @@ func main() {
a.mm = games.NewMatchmaker(a.gamesReg, a.st,
time.Duration(envInt("AGENTBBS_GAME_MOVE_TIMEOUT", 15))*time.Second,
time.Duration(envInt("AGENTBBS_GAME_QUEUE_WAIT", 120))*time.Second)
a.registry = []plugin.Plugin{arcade.Plugin{}, agentgames.New(a.gamesReg), about.Plugin{}}
a.registry = []plugin.Plugin{arcade.Plugin{}, agentgames.New(a.gamesReg), qryptinviteplugin.Plugin{}, about.Plugin{}}
// Custom domains: maintain the symlink farm Caddy serves and answer its
// on-demand-TLS "ask" query so certs are only issued for mapped domains.
@ -247,6 +259,12 @@ func (a *app) router() wish.Middleware {
a.handleGame(s)
case auth.IsPodName(user):
a.handlePod(s)
case auth.IsTorURLName(user):
a.handleTorURL(s)
case auth.IsTorIRCName(user):
a.handleTorIRC(s)
case auth.IsTorName(user):
a.handleTorCmd(s)
case isVideo:
a.handleVideo(s, code)
case user == "agent":
@ -841,6 +859,136 @@ func (a *app) handlePod(s ssh.Session) {
}
}
// torMember resolves the caller's key to a premium member for the tor routes,
// printing a reason and returning ok=false otherwise. It records the session.
func (a *app) torMember(s ssh.Session, route string) (store.User, bool) {
fp := auth.Fingerprint(s.PublicKey())
if fp == "" {
wish.Println(s, route+"@ needs your registered SSH key. New here? ssh join@"+a.host)
_ = s.Exit(1)
return store.User{}, false
}
u, found, err := a.st.UserByFingerprint(fp)
if err != nil || !found {
wish.Println(s, "key not registered — run: ssh join@"+a.host)
_ = s.Exit(1)
return store.User{}, false
}
if u.Banned {
wish.Println(s, "this account is suspended.")
_ = s.Exit(1)
return store.User{}, false
}
if !a.ensurePremium(&u) {
wish.Println(s, " "+route+" is a Premium feature ($10 lifetime). Upgrade: ssh join@"+a.host)
_ = s.Exit(1)
return store.User{}, false
}
_, _ = a.st.RecordSession(u.ID, s.User(), remoteIP(s), route)
return u, true
}
// handleTorURL fetches a single URL over Tor and writes the body back. One-shot,
// host-side, and constrained (timeout + size cap, http/https only). Premium.
func (a *app) handleTorURL(s ssh.Session) {
u, ok := a.torMember(s, "tor-url")
if !ok {
return
}
args := s.Command()
if len(args) == 0 {
wish.Println(s, "usage: ssh tor-url@"+a.host+" <http(s)-url> (e.g. an .onion address)")
_ = s.Exit(1)
return
}
url := args[0]
log.Info("tor-url fetch", "user", u.Name, "url", url)
body, err := tor.FetchURL(s.Context(), url)
if err != nil {
wish.Println(s, " "+err.Error())
_ = s.Exit(1)
return
}
_, _ = s.Write(body)
_ = s.Exit(0)
}
// handleTorIRC opens an interactive IRC-over-Tor session inside the member's
// pod (sandboxed). Premium; requires a PTY.
func (a *app) handleTorIRC(s ssh.Session) {
u, ok := a.torMember(s, "tor-irc")
if !ok {
return
}
args := s.Command()
if len(args) == 0 || !validIRCServer(args[0]) {
wish.Println(s, "usage: ssh -t tor-irc@"+a.host+" <server[:port]> (e.g. an .onion IRC server)")
_ = s.Exit(1)
return
}
if a.pods == nil {
wish.Println(s, "pods are temporarily unavailable on this host.")
_ = s.Exit(1)
return
}
log.Info("tor-irc connect", "user", u.Name, "server", args[0])
if err := a.pods.Exec(s, u.Name, tor.IRCArgv(args[0])); err != nil {
wish.Println(s, "tor-irc error: "+err.Error())
_ = s.Exit(1)
}
}
// 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) {
u, ok := a.torMember(s, "tor")
if !ok {
return
}
args := s.Command()
if len(args) == 0 {
wish.Println(s, "usage: ssh -t tor@"+a.host+" <command...> (runs in your pod, over Tor)")
_ = s.Exit(1)
return
}
if a.pods == nil {
wish.Println(s, "pods are temporarily unavailable on this host.")
_ = s.Exit(1)
return
}
log.Info("tor cmd", "user", u.Name, "argv", strings.Join(args, " "))
if err := a.pods.Exec(s, u.Name, tor.Torsocks(args)); err != nil {
wish.Println(s, "tor error: "+err.Error())
_ = s.Exit(1)
}
}
// validIRCServer accepts host or host:port with a sane charset (no shell/space).
func validIRCServer(s string) bool {
host := s
if i := strings.LastIndex(s, ":"); i > 0 {
port := s[i+1:]
host = s[:i]
if port == "" || len(port) > 5 {
return false
}
for _, r := range port {
if r < '0' || r > '9' {
return false
}
}
}
if host == "" || len(host) > 255 {
return false
}
for _, r := range host {
if !(r >= 'a' && r <= 'z' || r >= 'A' && r <= 'Z' || r >= '0' && r <= '9' || r == '.' || r == '-') {
return false
}
}
return true
}
// handleVideo joins a PairUX call rendered as ASCII (docs/video.md).
// `video@` prompts for a code; `video-<code>@` joins directly. Codes are
// minted by PairUX — starting a call requires already having one.

86
cmd/agentbbs/qrypt.go Normal file
View file

@ -0,0 +1,86 @@
package main
import (
"errors"
"fmt"
"os"
"strings"
qi "github.com/profullstack/agentbbs/internal/qryptinvite"
"github.com/profullstack/agentbbs/internal/store"
)
// qryptInviteCmd is the ops side of qrypt.chat invites:
// `agentbbs qrypt-invite <user>` mints a single-use anonymous invite on behalf
// of an existing member, respecting their per-account quota, and prints the
// token + redeem URL (docs/qrypt-invites.md).
func qryptInviteCmd(st store.Store, args []string) {
if len(args) < 1 {
fmt.Fprintln(os.Stderr, "usage: agentbbs qrypt-invite <username>")
os.Exit(2)
}
name := strings.ToLower(args[0])
if _, found, err := st.UserByName(name); err != nil {
fmt.Fprintln(os.Stderr, "lookup:", err)
os.Exit(1)
} else if !found {
fmt.Fprintf(os.Stderr, "no such account: %s (register via ssh join@)\n", name)
os.Exit(1)
}
cfg := qi.ConfigFromEnv()
priv, err := cfg.PrivateKey()
if err != nil {
fmt.Fprintln(os.Stderr, err)
os.Exit(1)
}
token, jti, err := qi.Mint(cfg.IssuerID, priv, cfg.TTL)
if err != nil {
fmt.Fprintln(os.Stderr, "mint:", err)
os.Exit(1)
}
if err := st.RecordQryptInvite(name, jti, cfg.Quota); err != nil {
if errors.Is(err, store.ErrQuotaExceeded) {
used, _ := st.QryptInviteCount(name)
fmt.Fprintf(os.Stderr, "%s is at their invite quota (%d/%d)\n", name, used, cfg.Quota)
os.Exit(1)
}
fmt.Fprintln(os.Stderr, "record:", err)
os.Exit(1)
}
used, _ := st.QryptInviteCount(name)
remaining := cfg.Quota - used
if remaining < 0 {
remaining = 0
}
fmt.Printf("qrypt.chat invite for %s (issuer %s, expires in %s, single-use):\n\n", name, cfg.IssuerID, cfg.TTL)
fmt.Printf(" redeem %s\n", cfg.RedeemURLFor(token))
fmt.Printf(" token %s\n", token)
fmt.Printf(" jti %s\n\n", jti)
if cfg.Quota > 0 {
fmt.Printf("invites left for %s: %d/%d\n", name, remaining, cfg.Quota)
}
}
// qryptIssuerKeygen prints a fresh Ed25519 issuer keypair for first-time setup:
// the base64 seed (private — goes in AGENTBBS_QRYPT_ISSUER_KEY) and the base64
// raw public key (goes in qrypt.chat's invite_issuers row).
func qryptIssuerKeygen() {
seed, pub, err := qi.GenerateIssuerKey()
if err != nil {
fmt.Fprintln(os.Stderr, "keygen:", err)
os.Exit(1)
}
cfg := qi.ConfigFromEnv()
fmt.Println("Fresh qrypt.chat invite-issuer keypair:")
fmt.Println()
fmt.Println(" 1) On agentbbs, set the PRIVATE seed (keep it secret):")
fmt.Printf(" AGENTBBS_QRYPT_ISSUER_KEY=%s\n\n", seed)
fmt.Println(" 2) In qrypt.chat, insert an invite_issuers row with the PUBLIC key:")
fmt.Printf(" id %s\n", cfg.IssuerID)
fmt.Printf(" ed25519_public_key %s\n\n", pub)
fmt.Printf(" INSERT INTO invite_issuers (id, name, ed25519_public_key)\n")
fmt.Printf(" VALUES ('%s', 'AgentBBS', '%s');\n", cfg.IssuerID, pub)
}