mirror of
https://github.com/profullstack/agentbbs.git
synced 2026-08-13 22:37:28 +00:00
Merge feat/qrypt-invite-issuer: qrypt.chat invite issuer + IRC + Tor routes
This commit is contained in:
commit
641fa01d9a
18 changed files with 2710 additions and 4 deletions
19
README.md
19
README.md
|
|
@ -33,6 +33,7 @@ plugins around one shared account system; the full product plan is in
|
||||||
| `agent@` chat (configurable agent backend) + finger | ✅ |
|
| `agent@` chat (configurable agent backend) + finger | ✅ |
|
||||||
| M2 — admin console (`admin@`: users, sessions, moderation, plugins) | ✅ |
|
| M2 — admin console (`admin@`: users, sessions, moderation, plugins) | ✅ |
|
||||||
| M3 — AgentGames (`game@` + WebSocket; TTT/C4, ELO ladder, replays) | ✅ |
|
| 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) | ⬜ |
|
| M4 — Files (cl1.tech SFTP workspaces) | ⬜ |
|
||||||
| M5 — AgentAd marketplace (built on the AgentAd standard in logicsrc) | ⬜ |
|
| M5 — AgentAd marketplace (built on the AgentAd standard in logicsrc) | ⬜ |
|
||||||
|
|
||||||
|
|
@ -99,6 +100,24 @@ The production host (`bbs.profullstack.com`) is provisioned by the idempotent
|
||||||
|
|
||||||
Full details, required secrets, and ops commands: [`docs/deploy.md`](docs/deploy.md).
|
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
|
||||||
|
# native client — SASL account = your BBS member name
|
||||||
|
/connect irc.bbs.profullstack.com 6697
|
||||||
|
# browser / agent over WebSocket
|
||||||
|
wss://bbs.profullstack.com/irc
|
||||||
|
```
|
||||||
|
|
||||||
|
Set `IRC=0` to skip it. Full details: [`docs/irc.md`](docs/irc.md).
|
||||||
|
|
||||||
## Architecture
|
## Architecture
|
||||||
|
|
||||||
- **Go + charmbracelet** — `wish` SSH server, `bubbletea` TUIs, `lipgloss` styling.
|
- **Go + charmbracelet** — `wish` SSH server, `bubbletea` TUIs, `lipgloss` styling.
|
||||||
|
|
|
||||||
|
|
@ -19,6 +19,8 @@
|
||||||
// agentbbs map-domain DOMAIN NAME map a custom domain to a homepage
|
// agentbbs map-domain DOMAIN NAME map a custom domain to a homepage
|
||||||
// agentbbs unmap-domain DOMAIN NAME remove a custom-domain mapping
|
// agentbbs unmap-domain DOMAIN NAME remove a custom-domain mapping
|
||||||
// agentbbs mint-token NAME issue a WebSocket API token for NAME
|
// 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
|
package main
|
||||||
|
|
||||||
import (
|
import (
|
||||||
|
|
@ -61,9 +63,11 @@ import (
|
||||||
"github.com/profullstack/agentbbs/internal/sandbox"
|
"github.com/profullstack/agentbbs/internal/sandbox"
|
||||||
"github.com/profullstack/agentbbs/internal/sites"
|
"github.com/profullstack/agentbbs/internal/sites"
|
||||||
"github.com/profullstack/agentbbs/internal/store"
|
"github.com/profullstack/agentbbs/internal/store"
|
||||||
|
"github.com/profullstack/agentbbs/internal/tor"
|
||||||
"github.com/profullstack/agentbbs/plugins/about"
|
"github.com/profullstack/agentbbs/plugins/about"
|
||||||
"github.com/profullstack/agentbbs/plugins/agentgames"
|
"github.com/profullstack/agentbbs/plugins/agentgames"
|
||||||
"github.com/profullstack/agentbbs/plugins/arcade"
|
"github.com/profullstack/agentbbs/plugins/arcade"
|
||||||
|
qryptinviteplugin "github.com/profullstack/agentbbs/plugins/qryptinvite"
|
||||||
)
|
)
|
||||||
|
|
||||||
func env(k, def string) string {
|
func env(k, def string) string {
|
||||||
|
|
@ -121,6 +125,14 @@ func main() {
|
||||||
mintToken(st, os.Args[2:])
|
mintToken(st, os.Args[2:])
|
||||||
return
|
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")
|
host := env("AGENTBBS_HOST", "bbs.profullstack.com")
|
||||||
fe := forwardemail.ConfigFromEnv()
|
fe := forwardemail.ConfigFromEnv()
|
||||||
|
|
@ -141,7 +153,7 @@ func main() {
|
||||||
a.mm = games.NewMatchmaker(a.gamesReg, a.st,
|
a.mm = games.NewMatchmaker(a.gamesReg, a.st,
|
||||||
time.Duration(envInt("AGENTBBS_GAME_MOVE_TIMEOUT", 15))*time.Second,
|
time.Duration(envInt("AGENTBBS_GAME_MOVE_TIMEOUT", 15))*time.Second,
|
||||||
time.Duration(envInt("AGENTBBS_GAME_QUEUE_WAIT", 120))*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
|
// Custom domains: maintain the symlink farm Caddy serves and answer its
|
||||||
// on-demand-TLS "ask" query so certs are only issued for mapped domains.
|
// 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)
|
a.handleGame(s)
|
||||||
case auth.IsPodName(user):
|
case auth.IsPodName(user):
|
||||||
a.handlePod(s)
|
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:
|
case isVideo:
|
||||||
a.handleVideo(s, code)
|
a.handleVideo(s, code)
|
||||||
case user == "agent":
|
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).
|
// handleVideo joins a PairUX call rendered as ASCII (docs/video.md).
|
||||||
// `video@` prompts for a code; `video-<code>@` joins directly. Codes are
|
// `video@` prompts for a code; `video-<code>@` joins directly. Codes are
|
||||||
// minted by PairUX — starting a call requires already having one.
|
// minted by PairUX — starting a call requires already having one.
|
||||||
|
|
|
||||||
86
cmd/agentbbs/qrypt.go
Normal file
86
cmd/agentbbs/qrypt.go
Normal 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)
|
||||||
|
}
|
||||||
48
deploy/ergo/auth-script.sh
Normal file
48
deploy/ergo/auth-script.sh
Normal file
|
|
@ -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
|
||||||
1189
deploy/ergo/ircd.yaml
Normal file
1189
deploy/ergo/ircd.yaml
Normal file
File diff suppressed because it is too large
Load diff
42
deploy/ergo/refresh-certs.sh
Executable file
42
deploy/ergo/refresh-certs.sh
Executable file
|
|
@ -0,0 +1,42 @@
|
||||||
|
#!/usr/bin/env bash
|
||||||
|
#
|
||||||
|
# refresh-certs.sh — copy Caddy's Let's Encrypt cert for $DOMAIN into Ergo's
|
||||||
|
# TLS dir and reload Ergo if it changed. setup.sh installs this to
|
||||||
|
# /usr/local/bin/ergo-refresh-certs and runs it from the ergo-certs.timer so
|
||||||
|
# the IRC server's 6697 cert tracks Caddy's auto-renewals.
|
||||||
|
#
|
||||||
|
# Ergo and Caddy share the same hostname (${DOMAIN}); Caddy is the only ACME
|
||||||
|
# client on the box, so we reuse its cert rather than running a second ACME
|
||||||
|
# client. Exits non-zero (without touching anything) if Caddy hasn't issued the
|
||||||
|
# cert yet — on first boot that's expected, and setup.sh falls back to a
|
||||||
|
# self-signed cert until this timer picks up the real one.
|
||||||
|
set -euo pipefail
|
||||||
|
|
||||||
|
DOMAIN="${DOMAIN:?set DOMAIN}"
|
||||||
|
ERGO_DATA="${ERGO_DATA:-/var/lib/ergo}"
|
||||||
|
CADDY_DATA="${CADDY_DATA:-/var/lib/caddy/.local/share/caddy}"
|
||||||
|
|
||||||
|
# Caddy stores certs under certificates/<acme-dir>/<host>/<host>.{crt,key};
|
||||||
|
# the ACME directory segment varies (prod vs staging), so glob for it.
|
||||||
|
crt="$(ls "$CADDY_DATA"/certificates/*/"$DOMAIN"/"$DOMAIN".crt 2>/dev/null | head -1 || true)"
|
||||||
|
key="$(ls "$CADDY_DATA"/certificates/*/"$DOMAIN"/"$DOMAIN".key 2>/dev/null | head -1 || true)"
|
||||||
|
if [ -z "$crt" ] || [ -z "$key" ]; then
|
||||||
|
echo "no Caddy cert for $DOMAIN yet (looked under $CADDY_DATA/certificates)"
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
|
||||||
|
dst="$ERGO_DATA/tls"
|
||||||
|
install -d -m 0755 "$dst"
|
||||||
|
|
||||||
|
changed=0
|
||||||
|
if ! cmp -s "$crt" "$dst/fullchain.pem"; then install -m 0644 "$crt" "$dst/fullchain.pem"; changed=1; fi
|
||||||
|
if ! cmp -s "$key" "$dst/privkey.pem"; then install -m 0640 "$key" "$dst/privkey.pem"; changed=1; fi
|
||||||
|
chown -R ergo:ergo "$dst" 2>/dev/null || true
|
||||||
|
|
||||||
|
if [ "$changed" = 1 ]; then
|
||||||
|
echo "updated Ergo TLS cert for $DOMAIN"
|
||||||
|
# Ergo rehashes config + reloads certs on SIGHUP (systemctl reload).
|
||||||
|
systemctl reload ergo 2>/dev/null || systemctl restart ergo 2>/dev/null || true
|
||||||
|
else
|
||||||
|
echo "Ergo TLS cert for $DOMAIN already current"
|
||||||
|
fi
|
||||||
126
docs/irc.md
Normal file
126
docs/irc.md
Normal file
|
|
@ -0,0 +1,126 @@
|
||||||
|
# IRC — `irc.bbs.profullstack.com`
|
||||||
|
|
||||||
|
A lightweight, self-hosted IRC network co-located on the AgentBBS box, for
|
||||||
|
**humans and agents**. It runs [Ergo](https://ergo.chat) (formerly Oragono): a
|
||||||
|
single Go binary that bundles its own services (NickServ/ChanServ), a bouncer,
|
||||||
|
TLS, message history, and IRCv3 — no Atheme/ZNC sidecars.
|
||||||
|
|
||||||
|
It shares the box and the `bbs.profullstack.com` TLS cert with the BBS but runs
|
||||||
|
as its **own service on its own ports** (`ergo.service`, user `ergo`), so it is
|
||||||
|
operationally independent of the wish server.
|
||||||
|
|
||||||
|
## Connect
|
||||||
|
|
||||||
|
| Path | Address | For |
|
||||||
|
|---|---|---|
|
||||||
|
| Native TLS | `irc.bbs.profullstack.com:6697` (TLS) | desktop/CLI clients (HexChat, irssi, WeeChat, Halloy…) |
|
||||||
|
| WebSocket | `wss://bbs.profullstack.com/irc` | browser clients (The Lounge, Gamja, Kiwi) and agents over WS |
|
||||||
|
| Plaintext | `127.0.0.1:6667` | **loopback only** — on-box tooling/bridges; firewalled off |
|
||||||
|
|
||||||
|
The WebSocket path is fronted by Caddy (it terminates TLS and reverse-proxies to
|
||||||
|
Ergo's loopback `127.0.0.1:8097`), so no extra public port is opened for the web.
|
||||||
|
|
||||||
|
### Membership (who can connect)
|
||||||
|
|
||||||
|
The network is **members-only**. There is **no self-service registration** —
|
||||||
|
every client must authenticate with SASL, and a login is approved only if the
|
||||||
|
account name is an existing AgentBBS member, i.e. someone who has registered via
|
||||||
|
`ssh join@bbs.profullstack.com` (which creates their home dir under
|
||||||
|
`/var/lib/agentbbs/users/<name>/`). Non-members are refused at connect.
|
||||||
|
|
||||||
|
Authenticate with SASL using **your BBS username as the account name**. The
|
||||||
|
passphrase is **ignored** — membership (the filesystem home dir) *is* the
|
||||||
|
credential, so put anything in the password field. (Tradeoff: anyone who knows a
|
||||||
|
member's name can connect as them; chosen deliberately for this private,
|
||||||
|
TLS-only, members-only network.)
|
||||||
|
|
||||||
|
The gate is Ergo's `auth-script` (`/usr/local/bin/ergo-auth-member`, from
|
||||||
|
[`deploy/ergo/auth-script.sh`](../deploy/ergo/auth-script.sh)) with
|
||||||
|
`accounts.require-sasl` on and `accounts.registration` off. On first successful
|
||||||
|
login the Ergo account is auto-created (`autocreate`), so members never register.
|
||||||
|
|
||||||
|
> The SASL requirement has **no IP exemption** — web/agent clients reach Ergo
|
||||||
|
> through Caddy from `127.0.0.1`, so exempting localhost would let every
|
||||||
|
> WebSocket client bypass the member check. On-box bridges/tooling must also
|
||||||
|
> SASL as a member.
|
||||||
|
|
||||||
|
### Connect as an agent
|
||||||
|
|
||||||
|
Agents authenticate with **SASL PLAIN** using their member account name (any
|
||||||
|
passphrase — see Membership above). **CHATHISTORY** is enabled so an agent that
|
||||||
|
reconnects can replay what it missed:
|
||||||
|
|
||||||
|
```
|
||||||
|
CAP REQ :sasl message-tags server-time draft/chathistory
|
||||||
|
AUTHENTICATE PLAIN
|
||||||
|
AUTHENTICATE <base64(\0account\0password)>
|
||||||
|
...
|
||||||
|
CHATHISTORY LATEST #lobby * 100
|
||||||
|
```
|
||||||
|
|
||||||
|
Any standard IRC library works — e.g. `irc-framework` (Node), `pydle` /
|
||||||
|
`irc` (Python), `girc` (Go).
|
||||||
|
|
||||||
|
## Network identity
|
||||||
|
|
||||||
|
- **Network name:** `ProfullstackBBS` (`IRC_NETWORK` in `setup.sh`)
|
||||||
|
- **Server name:** `irc.bbs.profullstack.com`
|
||||||
|
- Access: **members-only** (SASL required; account = BBS member, see [Membership](#membership-who-can-connect))
|
||||||
|
- Self-service account registration: **off**
|
||||||
|
- Message history: **in-memory**, ~7-day window, `CHATHISTORY` enabled
|
||||||
|
|
||||||
|
## Operating it
|
||||||
|
|
||||||
|
It is provisioned by [`../setup.sh`](../setup.sh) (section 9b) and redeployed by
|
||||||
|
the same self-update timer as the BBS. Toggle with `IRC=0`.
|
||||||
|
|
||||||
|
| Thing | Where |
|
||||||
|
|---|---|
|
||||||
|
| Config (rendered) | `/etc/ergo/ircd.yaml` |
|
||||||
|
| Config template | [`deploy/ergo/ircd.yaml`](../deploy/ergo/ircd.yaml) (`__TOKENS__` filled in by setup.sh) |
|
||||||
|
| State / db | `/var/lib/ergo/ircd.db` (`ERGO_DATA`) |
|
||||||
|
| TLS cert | `/var/lib/ergo/tls/{fullchain,privkey}.pem` — copied from Caddy's Let's Encrypt cert by `ergo-certs.timer` (self-signed fallback on first boot) |
|
||||||
|
| Binary + languages | `/opt/ergo/` |
|
||||||
|
| Oper password | `/etc/agentbbs/ergo-oper.txt` (root-only) — `/OPER admin <pw>` |
|
||||||
|
| Logs | `journalctl -u ergo -f` |
|
||||||
|
| Reload (rehash + reload certs) | `systemctl reload ergo` (SIGHUP) |
|
||||||
|
|
||||||
|
### TLS
|
||||||
|
|
||||||
|
Caddy is the only ACME client on the box and already holds a valid cert for
|
||||||
|
`bbs.profullstack.com`. Rather than run a second ACME client, the
|
||||||
|
`ergo-certs.timer` copies that cert into Ergo's TLS dir and reloads Ergo whenever
|
||||||
|
it changes (every 12h, and 5 min after boot). On the very first deploy — before
|
||||||
|
Caddy has issued the cert — setup.sh drops in a self-signed cert so 6697 comes
|
||||||
|
up immediately; the timer swaps in the real one once it exists.
|
||||||
|
|
||||||
|
> Native clients connect to **`irc.bbs.profullstack.com`**, so make sure that
|
||||||
|
> hostname resolves to the box (an A record, or a CNAME to `bbs.profullstack.com`).
|
||||||
|
> The TLS cert is for `bbs.profullstack.com`; if you want a clean match on the
|
||||||
|
> `irc.` hostname, add it as a SAN to the Caddy site or use a wildcard cert.
|
||||||
|
|
||||||
|
### Config knobs (`setup.sh` env)
|
||||||
|
|
||||||
|
| Var | Default | Meaning |
|
||||||
|
|---|---|---|
|
||||||
|
| `IRC` | `1` | install the IRC server (`0` to skip/disable) |
|
||||||
|
| `ERGO_VERSION` | `2.18.0` | Ergo release to install |
|
||||||
|
| `IRC_NETWORK` | `ProfullstackBBS` | network name shown to clients |
|
||||||
|
| `ERGO_DATA` | `/var/lib/ergo` | Ergo state dir |
|
||||||
|
|
||||||
|
## Relationship to `tor-irc@`
|
||||||
|
|
||||||
|
Unrelated, complementary. `ssh tor-irc@bbs.profullstack.com <server>` is a
|
||||||
|
**client** that connects *out* to a remote (e.g. `.onion`) IRC server from inside
|
||||||
|
a member's pod. This is the BBS hosting **its own** IRC network for people and
|
||||||
|
agents to meet on.
|
||||||
|
|
||||||
|
## Ideas / next steps
|
||||||
|
|
||||||
|
- **In-BBS `irc@` route** — an SSH route that drops a member straight into the
|
||||||
|
local network (mirroring `tor-irc@` but pointed at `127.0.0.1:6667`), so
|
||||||
|
`ssh irc@bbs.profullstack.com` is an instant client with no setup.
|
||||||
|
- **Bridge to `internal/chat`** — relay the BBS hub chat ↔ an IRC channel.
|
||||||
|
- **Per-pod / per-game channels** — auto-create `#pod-<name>`, `#game-<id>`.
|
||||||
|
- **Persistent history** — switch `datastore.mysql` on if replay must survive
|
||||||
|
restarts.
|
||||||
105
docs/qrypt-invites.md
Normal file
105
docs/qrypt-invites.md
Normal file
|
|
@ -0,0 +1,105 @@
|
||||||
|
# qrypt.chat anonymous invites
|
||||||
|
|
||||||
|
AgentBBS is a **trusted issuer** for [qrypt.chat](https://qrypt.chat) anonymous
|
||||||
|
accounts. A verified AgentBBS member can mint a signed, single-use invite token;
|
||||||
|
the separate qrypt.chat app verifies the signature and redeems the token into an
|
||||||
|
**anonymous** account (no phone number). AgentBBS holds the private key and
|
||||||
|
signs; qrypt.chat only ever sees the public key.
|
||||||
|
|
||||||
|
This is additive and isolated: it touches nothing in the join/SMS/pod paths.
|
||||||
|
|
||||||
|
## The bridge
|
||||||
|
|
||||||
|
```
|
||||||
|
member --ssh--> AgentBBS (issuer, has Ed25519 priv key) --signed token--> qrypt.chat (verifier, has pub key)
|
||||||
|
```
|
||||||
|
|
||||||
|
- AgentBBS mints `qci1.<payload>.<sig>` tokens with `crypto/ed25519`.
|
||||||
|
- qrypt.chat looks up the issuer's public key by `payload.iss` in its
|
||||||
|
`invite_issuers` table, verifies the signature, checks expiry, and burns the
|
||||||
|
`jti` (single use) on redeem.
|
||||||
|
|
||||||
|
## Token format (v1)
|
||||||
|
|
||||||
|
A single fixed algorithm (Ed25519) — not a JWT, no `alg` field.
|
||||||
|
|
||||||
|
```
|
||||||
|
token = "qci1." + b64url(payloadJSON) + "." + b64url(sig)
|
||||||
|
signing input = "qci1." + b64url(payloadJSON) # the first two segments
|
||||||
|
sig = Ed25519.Sign(issuerPriv, []byte(signingInput))
|
||||||
|
b64url = base64 URL-encoding, NO padding
|
||||||
|
```
|
||||||
|
|
||||||
|
`payloadJSON`:
|
||||||
|
|
||||||
|
```json
|
||||||
|
{ "jti": "16-random-bytes-hex", "iss": "agentbbs", "tier": "anonymous",
|
||||||
|
"iat": 1700000000, "exp": 1700604800, "uses": 1 }
|
||||||
|
```
|
||||||
|
|
||||||
|
Implemented in [`internal/qryptinvite`](../internal/qryptinvite). Verifier rules
|
||||||
|
(qrypt.chat side): exactly 3 segments; `segment[0] == "qci1"`; known/enabled
|
||||||
|
issuer; valid signature; `now <= exp`; `jti` not already redeemed.
|
||||||
|
|
||||||
|
## Setup (operator, once)
|
||||||
|
|
||||||
|
1. Generate a keypair on the AgentBBS host:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
agentbbs qrypt-issuer-keygen
|
||||||
|
```
|
||||||
|
|
||||||
|
It prints a **private seed** (base64) and a **public key** (base64).
|
||||||
|
|
||||||
|
2. Set the private seed in `agentbbs.env` (see `setup.sh`) and restart:
|
||||||
|
|
||||||
|
```
|
||||||
|
AGENTBBS_QRYPT_ISSUER_KEY=<base64 seed>
|
||||||
|
```
|
||||||
|
|
||||||
|
Until this is set, minting is disabled (the plugin says "not configured").
|
||||||
|
|
||||||
|
3. Register the **public** key in qrypt.chat (service-role / SQL):
|
||||||
|
|
||||||
|
```sql
|
||||||
|
INSERT INTO invite_issuers (id, name, ed25519_public_key)
|
||||||
|
VALUES ('agentbbs', 'AgentBBS', '<base64 public key>');
|
||||||
|
```
|
||||||
|
|
||||||
|
The `id` must match `AGENTBBS_QRYPT_ISSUER_ID` (default `agentbbs`).
|
||||||
|
|
||||||
|
## Config (env)
|
||||||
|
|
||||||
|
| Var | Default | Meaning |
|
||||||
|
|---|---|---|
|
||||||
|
| `AGENTBBS_QRYPT_ISSUER_ID` | `agentbbs` | issuer id; must match qrypt's `invite_issuers.id` |
|
||||||
|
| `AGENTBBS_QRYPT_ISSUER_KEY` | unset | base64 Ed25519 seed (32B) or full key (64B); **required to mint** |
|
||||||
|
| `AGENTBBS_QRYPT_INVITE_TTL` | `168h` | token lifetime (Go duration) |
|
||||||
|
| `AGENTBBS_QRYPT_REDEEM_URL` | `https://qrypt.chat/anon?invite=` | redeem URL; the token is appended |
|
||||||
|
| `AGENTBBS_QRYPT_INVITE_QUOTA` | `5` | per-member cap (0 = unlimited) |
|
||||||
|
|
||||||
|
## Member usage (over SSH)
|
||||||
|
|
||||||
|
From the hub, pick **"qrypt.chat invite"**:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
ssh <name>@bbs.profullstack.com # hub → "qrypt.chat invite"
|
||||||
|
```
|
||||||
|
|
||||||
|
It checks the member's quota, mints a single-use token, records it (incrementing
|
||||||
|
the quota), and prints the redeem URL plus the raw token. The member opens the
|
||||||
|
URL on qrypt.chat to create their anonymous account.
|
||||||
|
|
||||||
|
## Ops usage (CLI)
|
||||||
|
|
||||||
|
```bash
|
||||||
|
agentbbs qrypt-invite <username> # mint on behalf of a member (respects quota)
|
||||||
|
agentbbs qrypt-issuer-keygen # print a fresh seed + public key (first-time setup)
|
||||||
|
```
|
||||||
|
|
||||||
|
## Quota storage
|
||||||
|
|
||||||
|
Per-member issuance is counted in the AgentBBS SQLite `qrypt_invites` table
|
||||||
|
(`jti` PRIMARY KEY, `username`, `created_at`). `RecordQryptInvite` enforces the
|
||||||
|
cap inside a transaction, so concurrent mints can't both exceed it. The stored
|
||||||
|
`jti` values are also an audit trail of what AgentBBS handed out.
|
||||||
|
|
@ -46,6 +46,16 @@ var DomainNames = map[string]bool{"domain": true, "domains": true}
|
||||||
// (see IsAdmin); the name itself confers nothing.
|
// (see IsAdmin); the name itself confers nothing.
|
||||||
var AdminNames = map[string]bool{"admin": true, "sysop": true}
|
var AdminNames = map[string]bool{"admin": true, "sysop": true}
|
||||||
|
|
||||||
|
// TorURLNames route to the one-shot "fetch a URL over Tor" command (premium).
|
||||||
|
var TorURLNames = map[string]bool{"tor-url": true}
|
||||||
|
|
||||||
|
// TorIRCNames route to an interactive IRC-over-Tor client in the member's pod.
|
||||||
|
var TorIRCNames = map[string]bool{"tor-irc": true}
|
||||||
|
|
||||||
|
// TorNames route to the generic "run a command over Tor" passthrough in the
|
||||||
|
// member's pod (premium). Checked after the more specific tor-* routes.
|
||||||
|
var TorNames = map[string]bool{"tor": true}
|
||||||
|
|
||||||
// GameNames are usernames that route to AgentGames: the line-delimited-JSON
|
// GameNames are usernames that route to AgentGames: the line-delimited-JSON
|
||||||
// agent-vs-agent match protocol (PRD §5.2). `play@` stays a guest hub alias.
|
// agent-vs-agent match protocol (PRD §5.2). `play@` stays a guest hub alias.
|
||||||
var GameNames = map[string]bool{"game": true, "games": true}
|
var GameNames = map[string]bool{"game": true, "games": true}
|
||||||
|
|
@ -65,6 +75,15 @@ func IsDomainName(u string) bool { return DomainNames[strings.ToLower(u)] }
|
||||||
// IsAdminName reports whether the SSH username requests the admin console.
|
// IsAdminName reports whether the SSH username requests the admin console.
|
||||||
func IsAdminName(u string) bool { return AdminNames[strings.ToLower(u)] }
|
func IsAdminName(u string) bool { return AdminNames[strings.ToLower(u)] }
|
||||||
|
|
||||||
|
// IsTorURLName reports whether the SSH username requests the tor-url fetch.
|
||||||
|
func IsTorURLName(u string) bool { return TorURLNames[strings.ToLower(u)] }
|
||||||
|
|
||||||
|
// IsTorIRCName reports whether the SSH username requests the tor-irc client.
|
||||||
|
func IsTorIRCName(u string) bool { return TorIRCNames[strings.ToLower(u)] }
|
||||||
|
|
||||||
|
// IsTorName reports whether the SSH username requests the generic tor passthrough.
|
||||||
|
func IsTorName(u string) bool { return TorNames[strings.ToLower(u)] }
|
||||||
|
|
||||||
// systemReserved are names that don't drive an SSH route but would still
|
// systemReserved are names that don't drive an SSH route but would still
|
||||||
// collide with a per-user subdomain (<name>.<host>), the agent route, or common
|
// collide with a per-user subdomain (<name>.<host>), the agent route, or common
|
||||||
// infra hostnames — so members may not claim them as account names.
|
// infra hostnames — so members may not claim them as account names.
|
||||||
|
|
@ -80,7 +99,8 @@ var systemReserved = map[string]bool{
|
||||||
// therefore cannot be used as a member's account name.
|
// therefore cannot be used as a member's account name.
|
||||||
func IsReservedName(name string) bool {
|
func IsReservedName(name string) bool {
|
||||||
n := strings.ToLower(name)
|
n := strings.ToLower(name)
|
||||||
if GuestNames[n] || PodNames[n] || JoinNames[n] || DomainNames[n] || AdminNames[n] || systemReserved[n] {
|
if GuestNames[n] || PodNames[n] || JoinNames[n] || DomainNames[n] || AdminNames[n] ||
|
||||||
|
TorURLNames[n] || TorIRCNames[n] || TorNames[n] || systemReserved[n] {
|
||||||
return true
|
return true
|
||||||
}
|
}
|
||||||
return strings.HasPrefix(n, "video-") // video-<code> call routes
|
return strings.HasPrefix(n, "video-") // video-<code> call routes
|
||||||
|
|
|
||||||
|
|
@ -144,6 +144,47 @@ func (m *Manager) Attach(s ssh.Session, user string) error {
|
||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Exec provisions the user's pod and runs argv inside it wired to the SSH
|
||||||
|
// session (PTY required). Used for tor@/tor-irc@ so arbitrary or interactive
|
||||||
|
// commands run sandboxed in the member's container, never on the host. Blocks
|
||||||
|
// until the command exits or the session closes.
|
||||||
|
func (m *Manager) Exec(s ssh.Session, user string, argv []string) error {
|
||||||
|
if len(argv) == 0 {
|
||||||
|
return fmt.Errorf("pods: no command given")
|
||||||
|
}
|
||||||
|
ptyReq, winCh, hasPty := s.Pty()
|
||||||
|
if !hasPty {
|
||||||
|
return fmt.Errorf("pods: a PTY is required (ssh -t)")
|
||||||
|
}
|
||||||
|
name, err := m.ensure(user)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
args := append([]string{"exec", "-it", "-e", "TERM=" + ptyReq.Term, name}, argv...)
|
||||||
|
cmd := exec.Command(m.engine, args...)
|
||||||
|
f, err := pty.Start(cmd)
|
||||||
|
if err != nil {
|
||||||
|
return fmt.Errorf("pods: exec failed: %w", err)
|
||||||
|
}
|
||||||
|
defer f.Close()
|
||||||
|
|
||||||
|
m.ref(name, +1)
|
||||||
|
defer m.deref(name)
|
||||||
|
|
||||||
|
_ = pty.Setsize(f, &pty.Winsize{Rows: uint16(ptyReq.Window.Height), Cols: uint16(ptyReq.Window.Width)})
|
||||||
|
go func() {
|
||||||
|
for w := range winCh {
|
||||||
|
_ = pty.Setsize(f, &pty.Winsize{Rows: uint16(w.Height), Cols: uint16(w.Width)})
|
||||||
|
}
|
||||||
|
}()
|
||||||
|
|
||||||
|
go func() { _, _ = io.Copy(f, s) }() // ssh -> pod
|
||||||
|
_, _ = io.Copy(s, f) // pod -> ssh
|
||||||
|
_ = cmd.Wait()
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
func (m *Manager) ref(name string, d int) {
|
func (m *Manager) ref(name string, d int) {
|
||||||
m.mu.Lock()
|
m.mu.Lock()
|
||||||
defer m.mu.Unlock()
|
defer m.mu.Unlock()
|
||||||
|
|
|
||||||
74
internal/qryptinvite/config.go
Normal file
74
internal/qryptinvite/config.go
Normal file
|
|
@ -0,0 +1,74 @@
|
||||||
|
package qryptinvite
|
||||||
|
|
||||||
|
import (
|
||||||
|
"crypto/ed25519"
|
||||||
|
"errors"
|
||||||
|
"os"
|
||||||
|
"strconv"
|
||||||
|
"time"
|
||||||
|
)
|
||||||
|
|
||||||
|
// Config is the resolved qrypt.chat invite-issuer configuration, read from the
|
||||||
|
// environment (see docs/qrypt-invites.md). It is shared by the SSH plugin and
|
||||||
|
// the ops CLI so they mint identical tokens.
|
||||||
|
type Config struct {
|
||||||
|
IssuerID string // AGENTBBS_QRYPT_ISSUER_ID (default "agentbbs")
|
||||||
|
Key string // AGENTBBS_QRYPT_ISSUER_KEY (base64 seed/priv)
|
||||||
|
TTL time.Duration // AGENTBBS_QRYPT_INVITE_TTL (default 168h)
|
||||||
|
RedeemURL string // AGENTBBS_QRYPT_REDEEM_URL (default https://qrypt.chat/anon?invite=)
|
||||||
|
Quota int // AGENTBBS_QRYPT_INVITE_QUOTA (default 5)
|
||||||
|
}
|
||||||
|
|
||||||
|
// DefaultIssuerID, DefaultRedeemURL, DefaultTTL and DefaultQuota are the
|
||||||
|
// fallbacks when the corresponding env var is unset.
|
||||||
|
const (
|
||||||
|
DefaultIssuerID = "agentbbs"
|
||||||
|
DefaultRedeemURL = "https://qrypt.chat/anon?invite="
|
||||||
|
DefaultTTL = 168 * time.Hour
|
||||||
|
DefaultQuota = 5
|
||||||
|
)
|
||||||
|
|
||||||
|
// ConfigFromEnv reads the AGENTBBS_QRYPT_* environment variables, applying
|
||||||
|
// defaults. The key is not validated here; call PrivateKey to parse it.
|
||||||
|
func ConfigFromEnv() Config {
|
||||||
|
c := Config{
|
||||||
|
IssuerID: DefaultIssuerID,
|
||||||
|
Key: os.Getenv("AGENTBBS_QRYPT_ISSUER_KEY"),
|
||||||
|
TTL: DefaultTTL,
|
||||||
|
RedeemURL: DefaultRedeemURL,
|
||||||
|
Quota: DefaultQuota,
|
||||||
|
}
|
||||||
|
if v := os.Getenv("AGENTBBS_QRYPT_ISSUER_ID"); v != "" {
|
||||||
|
c.IssuerID = v
|
||||||
|
}
|
||||||
|
if v := os.Getenv("AGENTBBS_QRYPT_REDEEM_URL"); v != "" {
|
||||||
|
c.RedeemURL = v
|
||||||
|
}
|
||||||
|
if v := os.Getenv("AGENTBBS_QRYPT_INVITE_TTL"); v != "" {
|
||||||
|
if d, err := time.ParseDuration(v); err == nil {
|
||||||
|
c.TTL = d
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if v := os.Getenv("AGENTBBS_QRYPT_INVITE_QUOTA"); v != "" {
|
||||||
|
if n, err := strconv.Atoi(v); err == nil {
|
||||||
|
c.Quota = n
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return c
|
||||||
|
}
|
||||||
|
|
||||||
|
// ErrNoKey means AGENTBBS_QRYPT_ISSUER_KEY is unset, so no tokens can be minted.
|
||||||
|
var ErrNoKey = errors.New("qryptinvite: AGENTBBS_QRYPT_ISSUER_KEY is not set (run: agentbbs qrypt-issuer-keygen)")
|
||||||
|
|
||||||
|
// PrivateKey parses the configured issuer key, or returns ErrNoKey if unset.
|
||||||
|
func (c Config) PrivateKey() (ed25519.PrivateKey, error) {
|
||||||
|
if c.Key == "" {
|
||||||
|
return nil, ErrNoKey
|
||||||
|
}
|
||||||
|
return ParsePrivateKey(c.Key)
|
||||||
|
}
|
||||||
|
|
||||||
|
// RedeemLink returns the full URL a member opens to redeem token.
|
||||||
|
func (c Config) RedeemURLFor(token string) string {
|
||||||
|
return c.RedeemURL + token
|
||||||
|
}
|
||||||
141
internal/qryptinvite/qryptinvite.go
Normal file
141
internal/qryptinvite/qryptinvite.go
Normal file
|
|
@ -0,0 +1,141 @@
|
||||||
|
// Package qryptinvite mints single-use, Ed25519-signed invite tokens that the
|
||||||
|
// qrypt.chat app accepts to create an ANONYMOUS account. AgentBBS is the
|
||||||
|
// trusted issuer: it holds the private key and signs tokens; qrypt.chat verifies
|
||||||
|
// them against the issuer's public key registered in its invite_issuers table.
|
||||||
|
//
|
||||||
|
// Token format (v1, see the shared qrypt-invite contract):
|
||||||
|
//
|
||||||
|
// token = "qci1." + b64url(payloadJSON) + "." + b64url(sig)
|
||||||
|
// signing input = "qci1." + b64url(payloadJSON) (the first two segments)
|
||||||
|
// sig = Ed25519.Sign(issuerPriv, []byte(signing input))
|
||||||
|
// b64url = base64 URL-encoding, NO padding
|
||||||
|
//
|
||||||
|
// There is no alg field and this is not a JWT: Ed25519 is the single fixed
|
||||||
|
// algorithm.
|
||||||
|
package qryptinvite
|
||||||
|
|
||||||
|
import (
|
||||||
|
"crypto/ed25519"
|
||||||
|
"crypto/rand"
|
||||||
|
"encoding/base64"
|
||||||
|
"encoding/hex"
|
||||||
|
"encoding/json"
|
||||||
|
"errors"
|
||||||
|
"fmt"
|
||||||
|
"time"
|
||||||
|
)
|
||||||
|
|
||||||
|
// Prefix is segment[0] of every v1 token; verifiers must reject anything else.
|
||||||
|
const Prefix = "qci1"
|
||||||
|
|
||||||
|
// b64 is the URL-safe, unpadded base64 alphabet the contract mandates.
|
||||||
|
var b64 = base64.RawURLEncoding
|
||||||
|
|
||||||
|
// Payload is the JSON body carried in segment[1] of a token. Field tags match
|
||||||
|
// the contract exactly; qrypt.chat decodes the same shape.
|
||||||
|
type Payload struct {
|
||||||
|
JTI string `json:"jti"` // 16 random bytes, hex (32 chars); burns the token
|
||||||
|
Iss string `json:"iss"` // issuer id, e.g. "agentbbs"
|
||||||
|
Tier string `json:"tier"` // always "anonymous" in v1
|
||||||
|
Iat int64 `json:"iat"` // issued-at (unix seconds)
|
||||||
|
Exp int64 `json:"exp"` // expiry (unix seconds)
|
||||||
|
Uses int `json:"uses"` // single-use: 1
|
||||||
|
}
|
||||||
|
|
||||||
|
// Mint produces a signed single-use anonymous invite token for issuerID, valid
|
||||||
|
// for ttl. It returns the token string and its jti (the unique id qrypt.chat
|
||||||
|
// stores on redeem to prevent double-spend).
|
||||||
|
func Mint(issuerID string, priv ed25519.PrivateKey, ttl time.Duration) (token string, jti string, err error) {
|
||||||
|
if issuerID == "" {
|
||||||
|
return "", "", errors.New("qryptinvite: empty issuer id")
|
||||||
|
}
|
||||||
|
if len(priv) != ed25519.PrivateKeySize {
|
||||||
|
return "", "", fmt.Errorf("qryptinvite: private key is %d bytes, want %d", len(priv), ed25519.PrivateKeySize)
|
||||||
|
}
|
||||||
|
if ttl <= 0 {
|
||||||
|
return "", "", errors.New("qryptinvite: ttl must be positive")
|
||||||
|
}
|
||||||
|
|
||||||
|
jti, err = newJTI()
|
||||||
|
if err != nil {
|
||||||
|
return "", "", err
|
||||||
|
}
|
||||||
|
now := time.Now()
|
||||||
|
payload := Payload{
|
||||||
|
JTI: jti,
|
||||||
|
Iss: issuerID,
|
||||||
|
Tier: "anonymous",
|
||||||
|
Iat: now.Unix(),
|
||||||
|
Exp: now.Add(ttl).Unix(),
|
||||||
|
Uses: 1,
|
||||||
|
}
|
||||||
|
pj, err := json.Marshal(payload)
|
||||||
|
if err != nil {
|
||||||
|
return "", "", err
|
||||||
|
}
|
||||||
|
signingInput := Prefix + "." + b64.EncodeToString(pj)
|
||||||
|
sig := ed25519.Sign(priv, []byte(signingInput))
|
||||||
|
return signingInput + "." + b64.EncodeToString(sig), jti, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// newJTI returns 16 random bytes hex-encoded (32 chars), per the contract.
|
||||||
|
func newJTI() (string, error) {
|
||||||
|
var b [16]byte
|
||||||
|
if _, err := rand.Read(b[:]); err != nil {
|
||||||
|
return "", err
|
||||||
|
}
|
||||||
|
return hex.EncodeToString(b[:]), nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// GenerateIssuerKey creates a fresh Ed25519 issuer keypair for first-time setup.
|
||||||
|
// seedB64 is the 32-byte seed (the PRIVATE half — set it as AGENTBBS_QRYPT_ISSUER_KEY);
|
||||||
|
// publicKeyB64 is the raw 32-byte public key (register it in qrypt.chat's
|
||||||
|
// invite_issuers.ed25519_public_key). Both are standard base64.
|
||||||
|
func GenerateIssuerKey() (seedB64 string, publicKeyB64 string, err error) {
|
||||||
|
pub, priv, err := ed25519.GenerateKey(rand.Reader)
|
||||||
|
if err != nil {
|
||||||
|
return "", "", err
|
||||||
|
}
|
||||||
|
seed := priv.Seed() // 32 bytes
|
||||||
|
return base64.StdEncoding.EncodeToString(seed),
|
||||||
|
base64.StdEncoding.EncodeToString(pub), nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// ParsePrivateKey decodes a base64 issuer key into an ed25519.PrivateKey. It
|
||||||
|
// accepts either a 32-byte seed (preferred, what GenerateIssuerKey emits) or a
|
||||||
|
// full 64-byte private key. Base64 may be standard or URL-encoded, padded or not.
|
||||||
|
func ParsePrivateKey(b64key string) (ed25519.PrivateKey, error) {
|
||||||
|
raw, err := decodeBase64Any(b64key)
|
||||||
|
if err != nil {
|
||||||
|
return nil, fmt.Errorf("qryptinvite: decode private key: %w", err)
|
||||||
|
}
|
||||||
|
switch len(raw) {
|
||||||
|
case ed25519.SeedSize: // 32
|
||||||
|
return ed25519.NewKeyFromSeed(raw), nil
|
||||||
|
case ed25519.PrivateKeySize: // 64
|
||||||
|
return ed25519.PrivateKey(raw), nil
|
||||||
|
default:
|
||||||
|
return nil, fmt.Errorf("qryptinvite: private key is %d bytes, want %d (seed) or %d (full key)",
|
||||||
|
len(raw), ed25519.SeedSize, ed25519.PrivateKeySize)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// PublicKeyB64 returns the raw 32-byte public key (standard base64) for a
|
||||||
|
// private key — the value an operator registers in qrypt.chat.
|
||||||
|
func PublicKeyB64(priv ed25519.PrivateKey) string {
|
||||||
|
pub := priv.Public().(ed25519.PublicKey)
|
||||||
|
return base64.StdEncoding.EncodeToString(pub)
|
||||||
|
}
|
||||||
|
|
||||||
|
// decodeBase64Any tries the four base64 variants the contract may produce.
|
||||||
|
func decodeBase64Any(s string) ([]byte, error) {
|
||||||
|
for _, enc := range []*base64.Encoding{
|
||||||
|
base64.StdEncoding, base64.RawStdEncoding,
|
||||||
|
base64.URLEncoding, base64.RawURLEncoding,
|
||||||
|
} {
|
||||||
|
if b, err := enc.DecodeString(s); err == nil {
|
||||||
|
return b, nil
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return nil, errors.New("not valid base64")
|
||||||
|
}
|
||||||
214
internal/qryptinvite/qryptinvite_test.go
Normal file
214
internal/qryptinvite/qryptinvite_test.go
Normal file
|
|
@ -0,0 +1,214 @@
|
||||||
|
package qryptinvite
|
||||||
|
|
||||||
|
import (
|
||||||
|
"crypto/ed25519"
|
||||||
|
"encoding/base64"
|
||||||
|
"encoding/json"
|
||||||
|
"strings"
|
||||||
|
"testing"
|
||||||
|
"time"
|
||||||
|
)
|
||||||
|
|
||||||
|
// verifyAsQrypt independently checks a token the way the qrypt.chat backend
|
||||||
|
// would: split into 3 segments, require segment[0] == "qci1", verify the
|
||||||
|
// Ed25519 signature over "qci1."+payloadSeg with the issuer's public key, and
|
||||||
|
// decode the payload. It deliberately does NOT reuse Mint's internals.
|
||||||
|
func verifyAsQrypt(t *testing.T, token string, pub ed25519.PublicKey) Payload {
|
||||||
|
t.Helper()
|
||||||
|
parts := strings.Split(token, ".")
|
||||||
|
if len(parts) != 3 {
|
||||||
|
t.Fatalf("token has %d segments, want 3", len(parts))
|
||||||
|
}
|
||||||
|
if parts[0] != "qci1" {
|
||||||
|
t.Fatalf("segment[0] = %q, want qci1", parts[0])
|
||||||
|
}
|
||||||
|
signingInput := parts[0] + "." + parts[1]
|
||||||
|
sig, err := base64.RawURLEncoding.DecodeString(parts[2])
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("decode sig: %v", err)
|
||||||
|
}
|
||||||
|
if !ed25519.Verify(pub, []byte(signingInput), sig) {
|
||||||
|
t.Fatal("signature did not verify")
|
||||||
|
}
|
||||||
|
pj, err := base64.RawURLEncoding.DecodeString(parts[1])
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("decode payload: %v", err)
|
||||||
|
}
|
||||||
|
var p Payload
|
||||||
|
if err := json.Unmarshal(pj, &p); err != nil {
|
||||||
|
t.Fatalf("unmarshal payload: %v", err)
|
||||||
|
}
|
||||||
|
return p
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestMintAndVerify(t *testing.T) {
|
||||||
|
seedB64, pubB64, err := GenerateIssuerKey()
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
priv, err := ParsePrivateKey(seedB64)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("ParsePrivateKey(seed): %v", err)
|
||||||
|
}
|
||||||
|
pubRaw, err := base64.StdEncoding.DecodeString(pubB64)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
pub := ed25519.PublicKey(pubRaw)
|
||||||
|
|
||||||
|
before := time.Now()
|
||||||
|
token, jti, err := Mint("agentbbs", priv, 168*time.Hour)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("Mint: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
p := verifyAsQrypt(t, token, pub)
|
||||||
|
|
||||||
|
if p.Iss != "agentbbs" {
|
||||||
|
t.Errorf("iss = %q, want agentbbs", p.Iss)
|
||||||
|
}
|
||||||
|
if p.Tier != "anonymous" {
|
||||||
|
t.Errorf("tier = %q, want anonymous", p.Tier)
|
||||||
|
}
|
||||||
|
if p.Uses != 1 {
|
||||||
|
t.Errorf("uses = %d, want 1", p.Uses)
|
||||||
|
}
|
||||||
|
if p.JTI != jti {
|
||||||
|
t.Errorf("payload jti %q != returned jti %q", p.JTI, jti)
|
||||||
|
}
|
||||||
|
if len(p.JTI) != 32 {
|
||||||
|
t.Errorf("jti len = %d, want 32 hex chars", len(p.JTI))
|
||||||
|
}
|
||||||
|
if p.Exp <= time.Now().Unix() {
|
||||||
|
t.Errorf("exp %d is not in the future", p.Exp)
|
||||||
|
}
|
||||||
|
if p.Iat < before.Unix()-1 || p.Iat > time.Now().Unix()+1 {
|
||||||
|
t.Errorf("iat %d outside the mint window", p.Iat)
|
||||||
|
}
|
||||||
|
// exp == iat + ttl
|
||||||
|
if got, want := p.Exp-p.Iat, int64((168 * time.Hour).Seconds()); got != want {
|
||||||
|
t.Errorf("exp-iat = %d, want %d", got, want)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestJTIUnique(t *testing.T) {
|
||||||
|
seedB64, _, err := GenerateIssuerKey()
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
priv, err := ParsePrivateKey(seedB64)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
seen := map[string]bool{}
|
||||||
|
for i := 0; i < 100; i++ {
|
||||||
|
_, jti, err := Mint("agentbbs", priv, time.Hour)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
if seen[jti] {
|
||||||
|
t.Fatalf("duplicate jti %q on iteration %d", jti, i)
|
||||||
|
}
|
||||||
|
seen[jti] = true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestTamperedTokenFails(t *testing.T) {
|
||||||
|
seedB64, pubB64, err := GenerateIssuerKey()
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
priv, _ := ParsePrivateKey(seedB64)
|
||||||
|
pubRaw, _ := base64.StdEncoding.DecodeString(pubB64)
|
||||||
|
pub := ed25519.PublicKey(pubRaw)
|
||||||
|
|
||||||
|
token, _, err := Mint("agentbbs", priv, time.Hour)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
parts := strings.Split(token, ".")
|
||||||
|
|
||||||
|
// Tamper with the payload: flip the tier to "verified" and re-encode. The
|
||||||
|
// signature was made over the original payload, so verification must fail.
|
||||||
|
pj, _ := base64.RawURLEncoding.DecodeString(parts[1])
|
||||||
|
var p Payload
|
||||||
|
if err := json.Unmarshal(pj, &p); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
p.Tier = "verified"
|
||||||
|
p.Uses = 9999
|
||||||
|
tj, _ := json.Marshal(p)
|
||||||
|
tampered := parts[0] + "." + base64.RawURLEncoding.EncodeToString(tj) + "." + parts[2]
|
||||||
|
|
||||||
|
if got := verifySig(tampered, pub); got {
|
||||||
|
t.Fatal("tampered token verified but should have failed")
|
||||||
|
}
|
||||||
|
// The untouched token still verifies, proving the key is right.
|
||||||
|
if !verifySig(token, pub) {
|
||||||
|
t.Fatal("original token failed to verify")
|
||||||
|
}
|
||||||
|
|
||||||
|
// Tampering with the signature segment must also fail.
|
||||||
|
badSig := parts[0] + "." + parts[1] + "." + flipLastChar(parts[2])
|
||||||
|
if verifySig(badSig, pub) {
|
||||||
|
t.Fatal("token with corrupted signature verified but should have failed")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// verifySig is a minimal boolean form of the qrypt verify path.
|
||||||
|
func verifySig(token string, pub ed25519.PublicKey) bool {
|
||||||
|
parts := strings.Split(token, ".")
|
||||||
|
if len(parts) != 3 || parts[0] != "qci1" {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
sig, err := base64.RawURLEncoding.DecodeString(parts[2])
|
||||||
|
if err != nil {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
return ed25519.Verify(pub, []byte(parts[0]+"."+parts[1]), sig)
|
||||||
|
}
|
||||||
|
|
||||||
|
func flipLastChar(s string) string {
|
||||||
|
if s == "" {
|
||||||
|
return s
|
||||||
|
}
|
||||||
|
b := []byte(s)
|
||||||
|
last := b[len(b)-1]
|
||||||
|
if last == 'A' {
|
||||||
|
b[len(b)-1] = 'B'
|
||||||
|
} else {
|
||||||
|
b[len(b)-1] = 'A'
|
||||||
|
}
|
||||||
|
return string(b)
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestParsePrivateKeyAcceptsSeedAndFull(t *testing.T) {
|
||||||
|
_, priv, err := ed25519.GenerateKey(nil)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
seedB64 := base64.StdEncoding.EncodeToString(priv.Seed())
|
||||||
|
fullB64 := base64.StdEncoding.EncodeToString(priv)
|
||||||
|
|
||||||
|
fromSeed, err := ParsePrivateKey(seedB64)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("seed: %v", err)
|
||||||
|
}
|
||||||
|
fromFull, err := ParsePrivateKey(fullB64)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("full: %v", err)
|
||||||
|
}
|
||||||
|
if !fromSeed.Equal(fromFull) {
|
||||||
|
t.Fatal("seed and full-key parses produced different keys")
|
||||||
|
}
|
||||||
|
if !fromSeed.Equal(priv) {
|
||||||
|
t.Fatal("parsed key differs from original")
|
||||||
|
}
|
||||||
|
|
||||||
|
if _, err := ParsePrivateKey("not-base64-@@@"); err == nil {
|
||||||
|
t.Error("expected error on garbage input")
|
||||||
|
}
|
||||||
|
if _, err := ParsePrivateKey(base64.StdEncoding.EncodeToString([]byte("short"))); err == nil {
|
||||||
|
t.Error("expected error on wrong-length key")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -152,6 +152,15 @@ type Store interface {
|
||||||
// UserByToken resolves an API token to its account name.
|
// UserByToken resolves an API token to its account name.
|
||||||
UserByToken(token string) (string, bool, error)
|
UserByToken(token string) (string, bool, error)
|
||||||
|
|
||||||
|
// qrypt.chat anonymous-invite issuance (docs/qrypt-invites.md).
|
||||||
|
|
||||||
|
// QryptInviteCount reports how many qrypt.chat invites username has issued.
|
||||||
|
QryptInviteCount(username string) (int, error)
|
||||||
|
// RecordQryptInvite records one issued invite (its jti, for audit and as
|
||||||
|
// the per-member quota counter) against username. It returns
|
||||||
|
// ErrQuotaExceeded if the member is already at or above quota.
|
||||||
|
RecordQryptInvite(username, jti string, quota int) error
|
||||||
|
|
||||||
Close() error
|
Close() error
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -226,6 +235,9 @@ var ErrKeyMismatch = errors.New("username registered with a different key")
|
||||||
// ErrDomainTaken means a domain is already mapped to a different member.
|
// ErrDomainTaken means a domain is already mapped to a different member.
|
||||||
var ErrDomainTaken = errors.New("domain already mapped to another account")
|
var ErrDomainTaken = errors.New("domain already mapped to another account")
|
||||||
|
|
||||||
|
// ErrQuotaExceeded means a member has hit their qrypt.chat invite quota.
|
||||||
|
var ErrQuotaExceeded = errors.New("qrypt invite quota exceeded")
|
||||||
|
|
||||||
type sqliteStore struct{ db *sql.DB }
|
type sqliteStore struct{ db *sql.DB }
|
||||||
|
|
||||||
// Open opens (and migrates) the SQLite store at path.
|
// Open opens (and migrates) the SQLite store at path.
|
||||||
|
|
@ -381,6 +393,12 @@ CREATE TABLE IF NOT EXISTS api_tokens (
|
||||||
created_at TEXT NOT NULL DEFAULT (strftime('%Y-%m-%dT%H:%M:%fZ','now'))
|
created_at TEXT NOT NULL DEFAULT (strftime('%Y-%m-%dT%H:%M:%fZ','now'))
|
||||||
);
|
);
|
||||||
CREATE INDEX IF NOT EXISTS idx_api_tokens_user ON api_tokens(username);
|
CREATE INDEX IF NOT EXISTS idx_api_tokens_user ON api_tokens(username);
|
||||||
|
CREATE TABLE IF NOT EXISTS qrypt_invites (
|
||||||
|
jti TEXT PRIMARY KEY,
|
||||||
|
username TEXT NOT NULL,
|
||||||
|
created_at TEXT NOT NULL DEFAULT (strftime('%Y-%m-%dT%H:%M:%fZ','now'))
|
||||||
|
);
|
||||||
|
CREATE INDEX IF NOT EXISTS idx_qrypt_invites_user ON qrypt_invites(username);
|
||||||
`
|
`
|
||||||
|
|
||||||
func (s *sqliteStore) EnsureUser(name, kind, fp string) (User, error) {
|
func (s *sqliteStore) EnsureUser(name, kind, fp string) (User, error) {
|
||||||
|
|
@ -807,4 +825,36 @@ func (s *sqliteStore) SetPluginDisabled(id string, disabled bool) error {
|
||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func (s *sqliteStore) QryptInviteCount(username string) (int, error) {
|
||||||
|
var n int
|
||||||
|
err := s.db.QueryRow(`SELECT COUNT(*) FROM qrypt_invites WHERE username = ?`, username).Scan(&n)
|
||||||
|
if err != nil {
|
||||||
|
return 0, err
|
||||||
|
}
|
||||||
|
return n, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// RecordQryptInvite atomically enforces the per-member quota and records the
|
||||||
|
// invite's jti. The count check and the insert run in one transaction so two
|
||||||
|
// concurrent issuances can't both slip past the cap.
|
||||||
|
func (s *sqliteStore) RecordQryptInvite(username, jti string, quota int) error {
|
||||||
|
tx, err := s.db.Begin()
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
defer func() { _ = tx.Rollback() }()
|
||||||
|
|
||||||
|
var n int
|
||||||
|
if err := tx.QueryRow(`SELECT COUNT(*) FROM qrypt_invites WHERE username = ?`, username).Scan(&n); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
if quota > 0 && n >= quota {
|
||||||
|
return ErrQuotaExceeded
|
||||||
|
}
|
||||||
|
if _, err := tx.Exec(`INSERT INTO qrypt_invites (jti, username) VALUES (?,?)`, jti, username); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
return tx.Commit()
|
||||||
|
}
|
||||||
|
|
||||||
func (s *sqliteStore) Close() error { return s.db.Close() }
|
func (s *sqliteStore) Close() error { return s.db.Close() }
|
||||||
|
|
|
||||||
52
internal/store/store_qrypt_test.go
Normal file
52
internal/store/store_qrypt_test.go
Normal file
|
|
@ -0,0 +1,52 @@
|
||||||
|
package store
|
||||||
|
|
||||||
|
import (
|
||||||
|
"errors"
|
||||||
|
"path/filepath"
|
||||||
|
"testing"
|
||||||
|
)
|
||||||
|
|
||||||
|
func TestQryptInviteQuota(t *testing.T) {
|
||||||
|
st, err := Open(filepath.Join(t.TempDir(), "t.db"))
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("open: %v", err)
|
||||||
|
}
|
||||||
|
defer st.Close()
|
||||||
|
|
||||||
|
const quota = 3
|
||||||
|
|
||||||
|
if n, err := st.QryptInviteCount("alice"); err != nil || n != 0 {
|
||||||
|
t.Fatalf("initial count = %d, %v; want 0, nil", n, err)
|
||||||
|
}
|
||||||
|
|
||||||
|
for i := 0; i < quota; i++ {
|
||||||
|
jti := "jti-alice-" + string(rune('a'+i))
|
||||||
|
if err := st.RecordQryptInvite("alice", jti, quota); err != nil {
|
||||||
|
t.Fatalf("record %d: %v", i, err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if n, err := st.QryptInviteCount("alice"); err != nil || n != quota {
|
||||||
|
t.Fatalf("count after fill = %d, %v; want %d", n, err, quota)
|
||||||
|
}
|
||||||
|
|
||||||
|
// One over the cap must be rejected with ErrQuotaExceeded and not stored.
|
||||||
|
if err := st.RecordQryptInvite("alice", "jti-over", quota); !errors.Is(err, ErrQuotaExceeded) {
|
||||||
|
t.Fatalf("over-quota err = %v; want ErrQuotaExceeded", err)
|
||||||
|
}
|
||||||
|
if n, _ := st.QryptInviteCount("alice"); n != quota {
|
||||||
|
t.Fatalf("count after rejected insert = %d; want %d", n, quota)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Quotas are per-member: bob is unaffected.
|
||||||
|
if err := st.RecordQryptInvite("bob", "jti-bob", quota); err != nil {
|
||||||
|
t.Fatalf("bob record: %v", err)
|
||||||
|
}
|
||||||
|
if n, _ := st.QryptInviteCount("bob"); n != 1 {
|
||||||
|
t.Fatalf("bob count = %d; want 1", n)
|
||||||
|
}
|
||||||
|
|
||||||
|
// quota <= 0 means unlimited.
|
||||||
|
if err := st.RecordQryptInvite("carol", "jti-carol-1", 0); err != nil {
|
||||||
|
t.Fatalf("unlimited record: %v", err)
|
||||||
|
}
|
||||||
|
}
|
||||||
80
internal/tor/tor.go
Normal file
80
internal/tor/tor.go
Normal file
|
|
@ -0,0 +1,80 @@
|
||||||
|
// Package tor fetches URLs and wraps commands so they egress through the host's
|
||||||
|
// Tor SOCKS proxy. tor-url fetches run on the host (constrained); generic and
|
||||||
|
// IRC commands run inside the member's pod via torsocks (see cmd/agentbbs).
|
||||||
|
package tor
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"fmt"
|
||||||
|
"net/url"
|
||||||
|
"os"
|
||||||
|
"os/exec"
|
||||||
|
"strings"
|
||||||
|
"time"
|
||||||
|
)
|
||||||
|
|
||||||
|
// SocksAddr is the host-local Tor SOCKS5 endpoint that setup.sh's tor service
|
||||||
|
// listens on. Overridable so a dev host can point elsewhere.
|
||||||
|
var SocksAddr = envOr("AGENTBBS_TOR_SOCKS", "127.0.0.1:9050")
|
||||||
|
|
||||||
|
// Fetch limits so a single member can't tie up the host.
|
||||||
|
const (
|
||||||
|
fetchTimeout = 30 * time.Second
|
||||||
|
maxBytes = 2_000_000 // 2 MB
|
||||||
|
)
|
||||||
|
|
||||||
|
// FetchURL retrieves rawURL over Tor and returns the body (capped at maxBytes).
|
||||||
|
// Only http/https are allowed and the call is bounded by a timeout, so the URL
|
||||||
|
// (passed to curl as a single argv element — never a shell) can't be abused to
|
||||||
|
// reach the local network or run unboundedly.
|
||||||
|
func FetchURL(ctx context.Context, rawURL string) ([]byte, error) {
|
||||||
|
u, err := url.Parse(strings.TrimSpace(rawURL))
|
||||||
|
if err != nil || (u.Scheme != "http" && u.Scheme != "https") || u.Host == "" {
|
||||||
|
return nil, fmt.Errorf("give an http(s) URL, e.g. http://example.onion")
|
||||||
|
}
|
||||||
|
ctx, cancel := context.WithTimeout(ctx, fetchTimeout)
|
||||||
|
defer cancel()
|
||||||
|
cmd := exec.CommandContext(ctx, "curl",
|
||||||
|
"-sS", "-L", "--max-redirs", "3",
|
||||||
|
"--max-time", fmt.Sprint(int(fetchTimeout.Seconds())),
|
||||||
|
"--max-filesize", fmt.Sprint(maxBytes),
|
||||||
|
"--proto", "=http,https",
|
||||||
|
"--socks5-hostname", SocksAddr, // resolve the hostname through Tor too
|
||||||
|
u.String(),
|
||||||
|
)
|
||||||
|
out, err := cmd.Output()
|
||||||
|
if err != nil {
|
||||||
|
if ctx.Err() == context.DeadlineExceeded {
|
||||||
|
return nil, fmt.Errorf("timed out after %s", fetchTimeout)
|
||||||
|
}
|
||||||
|
return nil, fmt.Errorf("fetch failed (is the host reachable over Tor?): %v", err)
|
||||||
|
}
|
||||||
|
if len(out) > maxBytes {
|
||||||
|
out = out[:maxBytes]
|
||||||
|
}
|
||||||
|
return out, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// Torsocks prefixes argv with torsocks so the command's network traffic is
|
||||||
|
// routed through Tor when run inside a pod that has torsocks configured.
|
||||||
|
func Torsocks(argv []string) []string {
|
||||||
|
return append([]string{"torsocks"}, argv...)
|
||||||
|
}
|
||||||
|
|
||||||
|
// IRCArgv builds the argv for an interactive IRC-over-Tor session to server
|
||||||
|
// (host[:port]); it runs irssi through torsocks. server is validated by the
|
||||||
|
// caller. Defaults to the standard IRC port when none is given.
|
||||||
|
func IRCArgv(server string) []string {
|
||||||
|
host, port := server, "6667"
|
||||||
|
if i := strings.LastIndex(server, ":"); i > 0 {
|
||||||
|
host, port = server[:i], server[i+1:]
|
||||||
|
}
|
||||||
|
return Torsocks([]string{"irssi", "--connect=" + host, "--port=" + port})
|
||||||
|
}
|
||||||
|
|
||||||
|
func envOr(k, def string) string {
|
||||||
|
if v := strings.TrimSpace(os.Getenv(k)); v != "" {
|
||||||
|
return v
|
||||||
|
}
|
||||||
|
return def
|
||||||
|
}
|
||||||
111
plugins/qryptinvite/qryptinvite.go
Normal file
111
plugins/qryptinvite/qryptinvite.go
Normal file
|
|
@ -0,0 +1,111 @@
|
||||||
|
// Package qryptinvite is the hub plugin that lets an authenticated member mint
|
||||||
|
// a single-use qrypt.chat anonymous invite. AgentBBS is the trusted issuer: the
|
||||||
|
// plugin signs a token with the operator's Ed25519 key, records it against the
|
||||||
|
// member's per-account quota, and prints the token + redeem URL. The separate
|
||||||
|
// qrypt.chat app verifies the signature and burns the jti on redeem.
|
||||||
|
//
|
||||||
|
// See internal/qryptinvite for the token format and docs/qrypt-invites.md.
|
||||||
|
package qryptinvite
|
||||||
|
|
||||||
|
import (
|
||||||
|
"errors"
|
||||||
|
"strconv"
|
||||||
|
|
||||||
|
tea "github.com/charmbracelet/bubbletea"
|
||||||
|
"github.com/charmbracelet/lipgloss"
|
||||||
|
|
||||||
|
"github.com/profullstack/agentbbs/internal/auth"
|
||||||
|
"github.com/profullstack/agentbbs/internal/plugin"
|
||||||
|
qi "github.com/profullstack/agentbbs/internal/qryptinvite"
|
||||||
|
"github.com/profullstack/agentbbs/internal/store"
|
||||||
|
)
|
||||||
|
|
||||||
|
// Plugin is the hub registration. It admits members only (guests have no
|
||||||
|
// account to quota against).
|
||||||
|
type Plugin struct{}
|
||||||
|
|
||||||
|
func (Plugin) ID() string { return "qrypt-invite" }
|
||||||
|
func (Plugin) Title() string { return "qrypt.chat invite" }
|
||||||
|
func (Plugin) Description() string { return "Mint an anonymous qrypt.chat signup invite" }
|
||||||
|
func (Plugin) RequiresAuth() bool { return true }
|
||||||
|
|
||||||
|
func (Plugin) New(user auth.User, ctx plugin.Context) tea.Model {
|
||||||
|
cfg := qi.ConfigFromEnv()
|
||||||
|
m := model{user: user, store: ctx.Store, cfg: cfg}
|
||||||
|
m.issue() // do the work once up front; the view just reports the result
|
||||||
|
return m
|
||||||
|
}
|
||||||
|
|
||||||
|
type model struct {
|
||||||
|
user auth.User
|
||||||
|
store store.Store
|
||||||
|
cfg qi.Config
|
||||||
|
body string // rendered result, ready to display
|
||||||
|
}
|
||||||
|
|
||||||
|
// issue runs the full flow: check quota, mint, record, build the output.
|
||||||
|
func (m *model) issue() {
|
||||||
|
if m.user.Kind == auth.Guest || m.user.Name == "" {
|
||||||
|
m.body = errStyle.Render("Sign in with your SSH key to mint an invite.")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
priv, err := m.cfg.PrivateKey()
|
||||||
|
if err != nil {
|
||||||
|
m.body = errStyle.Render("Invites are not configured on this host yet.\n") +
|
||||||
|
dStyle.Render(" ("+err.Error()+")")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
used, err := m.store.QryptInviteCount(m.user.Name)
|
||||||
|
if err != nil {
|
||||||
|
m.body = errStyle.Render("Couldn't read your invite count: " + err.Error())
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if m.cfg.Quota > 0 && used >= m.cfg.Quota {
|
||||||
|
m.body = errStyle.Render("You've used all your invites ") +
|
||||||
|
dStyle.Render("("+strconv.Itoa(used)+"/"+strconv.Itoa(m.cfg.Quota)+"). Ask an operator for more.")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
token, jti, err := qi.Mint(m.cfg.IssuerID, priv, m.cfg.TTL)
|
||||||
|
if err != nil {
|
||||||
|
m.body = errStyle.Render("Mint failed: " + err.Error())
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if err := m.store.RecordQryptInvite(m.user.Name, jti, m.cfg.Quota); err != nil {
|
||||||
|
if errors.Is(err, store.ErrQuotaExceeded) {
|
||||||
|
m.body = errStyle.Render("You've used all your invites. Ask an operator for more.")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
m.body = errStyle.Render("Couldn't record the invite: " + err.Error())
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
remaining := m.cfg.Quota - (used + 1)
|
||||||
|
m.body = hStyle.Render("Your qrypt.chat anonymous invite") + "\n\n" +
|
||||||
|
" Redeem at:\n" +
|
||||||
|
urlStyle.Render(" "+m.cfg.RedeemURLFor(token)) + "\n\n" +
|
||||||
|
dStyle.Render(" Token (same thing, if you'd rather paste it):") + "\n" +
|
||||||
|
" " + token + "\n\n" +
|
||||||
|
dStyle.Render(" Single-use · expires in "+m.cfg.TTL.String()+" · invites left: "+strconv.Itoa(max(remaining, 0)))
|
||||||
|
}
|
||||||
|
|
||||||
|
func (m model) Init() tea.Cmd { return nil }
|
||||||
|
|
||||||
|
func (m model) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
|
||||||
|
if _, ok := msg.(tea.KeyMsg); ok {
|
||||||
|
return m, plugin.Exit
|
||||||
|
}
|
||||||
|
return m, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (m model) View() string {
|
||||||
|
return lipgloss.NewStyle().Padding(1, 2).Render(
|
||||||
|
m.body + "\n\n" + dStyle.Render("press any key to return"))
|
||||||
|
}
|
||||||
|
|
||||||
|
var (
|
||||||
|
hStyle = lipgloss.NewStyle().Bold(true).Foreground(lipgloss.Color("#4ade80"))
|
||||||
|
dStyle = lipgloss.NewStyle().Foreground(lipgloss.Color("241"))
|
||||||
|
errStyle = lipgloss.NewStyle().Foreground(lipgloss.Color("203"))
|
||||||
|
urlStyle = lipgloss.NewStyle().Foreground(lipgloss.Color("#60a5fa"))
|
||||||
|
)
|
||||||
164
setup.sh
164
setup.sh
|
|
@ -38,6 +38,10 @@ SKIP_BUILD="${SKIP_BUILD:-0}" # set 1 to use prebuilt /usr/local/bin/{agen
|
||||||
SWAP_SIZE="${SWAP_SIZE:-3G}" # swapfile size added on low-RAM hosts (set 0 to skip)
|
SWAP_SIZE="${SWAP_SIZE:-3G}" # swapfile size added on low-RAM hosts (set 0 to skip)
|
||||||
SELF_UPDATE="${SELF_UPDATE:-1}" # set 0 to skip the autonomous self-update systemd timer
|
SELF_UPDATE="${SELF_UPDATE:-1}" # set 0 to skip the autonomous self-update systemd timer
|
||||||
SELF_UPDATE_INTERVAL="${SELF_UPDATE_INTERVAL:-15min}" # how often the box polls origin for new commits
|
SELF_UPDATE_INTERVAL="${SELF_UPDATE_INTERVAL:-15min}" # how often the box polls origin for new commits
|
||||||
|
IRC="${IRC:-1}" # set 0 to skip the co-located Ergo IRC server (irc.${DOMAIN})
|
||||||
|
ERGO_VERSION="${ERGO_VERSION:-2.18.0}" # Ergo IRCd release to install
|
||||||
|
IRC_NETWORK="${IRC_NETWORK:-ProfullstackBBS}" # IRC network name shown to clients
|
||||||
|
ERGO_DATA="${ERGO_DATA:-/var/lib/ergo}" # Ergo state dir (ircd.db, tls/)
|
||||||
|
|
||||||
log() { printf '\033[1;36m==>\033[0m %s\n' "$*"; }
|
log() { printf '\033[1;36m==>\033[0m %s\n' "$*"; }
|
||||||
warn() { printf '\033[1;33m[warn]\033[0m %s\n' "$*" >&2; }
|
warn() { printf '\033[1;33m[warn]\033[0m %s\n' "$*" >&2; }
|
||||||
|
|
@ -85,10 +89,16 @@ log "installing packages"
|
||||||
export DEBIAN_FRONTEND=noninteractive
|
export DEBIAN_FRONTEND=noninteractive
|
||||||
apt-get update -qq
|
apt-get update -qq
|
||||||
apt-get install -y -qq \
|
apt-get install -y -qq \
|
||||||
git ca-certificates curl ufw ffmpeg unzip \
|
git ca-certificates curl ufw ffmpeg unzip jq \
|
||||||
podman uidmap slirp4netns fuse-overlayfs \
|
podman uidmap slirp4netns fuse-overlayfs \
|
||||||
|
tor torsocks \
|
||||||
debian-keyring debian-archive-keyring apt-transport-https >/dev/null
|
debian-keyring debian-archive-keyring apt-transport-https >/dev/null
|
||||||
|
|
||||||
|
# Tor SOCKS proxy for the tor-url@/tor@/tor-irc@ routes. Ships listening on
|
||||||
|
# 127.0.0.1:9050 by default; keep it loopback-only (never expose it).
|
||||||
|
log "enabling tor (SOCKS 127.0.0.1:9050)"
|
||||||
|
systemctl enable --now tor >/dev/null 2>&1 || warn "tor service not enabled — tor-url@ will be unavailable"
|
||||||
|
|
||||||
# yt-dlp from pip is fresher than apt; fall back to apt if pip is unavailable.
|
# yt-dlp from pip is fresher than apt; fall back to apt if pip is unavailable.
|
||||||
if ! command -v yt-dlp >/dev/null; then
|
if ! command -v yt-dlp >/dev/null; then
|
||||||
log "installing yt-dlp"
|
log "installing yt-dlp"
|
||||||
|
|
@ -254,6 +264,16 @@ AGENTBBS_HTTP_ADDR=${HTTP_ADDR}
|
||||||
|
|
||||||
# Agent chat backend (agent@), stdin->stdout, e.g. "claude -p":
|
# Agent chat backend (agent@), stdin->stdout, e.g. "claude -p":
|
||||||
# AGENTBBS_AGENT_CMD=
|
# AGENTBBS_AGENT_CMD=
|
||||||
|
|
||||||
|
# qrypt.chat anonymous-invite issuer (docs/qrypt-invites.md). Members mint a
|
||||||
|
# signed single-use token here that qrypt.chat redeems into an anon account.
|
||||||
|
# Run \`agentbbs qrypt-issuer-keygen\` once: paste the seed below, register the
|
||||||
|
# public key in qrypt.chat's invite_issuers row. Without the key, minting is off.
|
||||||
|
# AGENTBBS_QRYPT_ISSUER_KEY=<base64 ed25519 seed from qrypt-issuer-keygen>
|
||||||
|
# AGENTBBS_QRYPT_ISSUER_ID=agentbbs
|
||||||
|
# AGENTBBS_QRYPT_INVITE_TTL=168h
|
||||||
|
# AGENTBBS_QRYPT_REDEEM_URL=https://qrypt.chat/anon?invite=
|
||||||
|
# AGENTBBS_QRYPT_INVITE_QUOTA=5
|
||||||
ENV
|
ENV
|
||||||
chmod 0640 "$ENV_DIR/agentbbs.env"
|
chmod 0640 "$ENV_DIR/agentbbs.env"
|
||||||
fi
|
fi
|
||||||
|
|
@ -422,6 +442,14 @@ ${DOMAIN} {
|
||||||
reverse_proxy http://${HTTP_ADDR}
|
reverse_proxy http://${HTTP_ADDR}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
# IRC over WebSocket: Caddy terminates TLS and proxies to Ergo's loopback
|
||||||
|
# WebSocket listener, so web clients hit wss://${DOMAIN}/irc and agents get a
|
||||||
|
# WebSocket transport without exposing another public port. (No-op if IRC=0;
|
||||||
|
# Ergo just isn't listening on 8097, so /irc returns 502.)
|
||||||
|
handle /irc {
|
||||||
|
reverse_proxy 127.0.0.1:8097
|
||||||
|
}
|
||||||
|
|
||||||
# tilde.town-style homepages: /~name[/path] -> users/name/public_html/path
|
# tilde.town-style homepages: /~name[/path] -> users/name/public_html/path
|
||||||
@tilde path_regexp tilde ^/~([^/]+)(/.*)?\$
|
@tilde path_regexp tilde ^/~([^/]+)(/.*)?\$
|
||||||
handle @tilde {
|
handle @tilde {
|
||||||
|
|
@ -471,6 +499,135 @@ ufw allow 80/tcp >/dev/null
|
||||||
ufw allow 443/tcp >/dev/null
|
ufw allow 443/tcp >/dev/null
|
||||||
systemctl reload caddy 2>/dev/null || systemctl restart caddy
|
systemctl reload caddy 2>/dev/null || systemctl restart caddy
|
||||||
|
|
||||||
|
# ---- 9b. Ergo IRC server (co-located irc.${DOMAIN}; humans + agents) --------
|
||||||
|
# A lightweight single-binary IRC network on its own ports, sharing this box and
|
||||||
|
# this hostname's TLS cert. Native clients hit irc.${DOMAIN}:6697 (TLS); web
|
||||||
|
# clients and agents hit wss://${DOMAIN}/irc (Caddy fronts Ergo's loopback
|
||||||
|
# WebSocket). See docs/irc.md. Disable with IRC=0.
|
||||||
|
if [ "$IRC" = "1" ]; then
|
||||||
|
log "installing Ergo IRC server v${ERGO_VERSION} (irc.${DOMAIN})"
|
||||||
|
case "$GOARCH" in
|
||||||
|
amd64) ERGO_ARCH=x86_64 ;;
|
||||||
|
arm64) ERGO_ARCH=arm64 ;;
|
||||||
|
*) ERGO_ARCH="$GOARCH" ;;
|
||||||
|
esac
|
||||||
|
id ergo >/dev/null 2>&1 || useradd --system --home-dir "$ERGO_DATA" --shell /usr/sbin/nologin ergo
|
||||||
|
install -d -m 0755 /opt/ergo "$ERGO_DATA" "$ERGO_DATA/tls" /etc/ergo
|
||||||
|
|
||||||
|
# Install/upgrade the binary + bundled languages (idempotent: only on version change).
|
||||||
|
if [ "$(/opt/ergo/ergo --version 2>/dev/null)" != "ergo-${ERGO_VERSION}" ]; then
|
||||||
|
tmp="$(mktemp -d)"
|
||||||
|
curl -fsSL "https://github.com/ergochat/ergo/releases/download/v${ERGO_VERSION}/ergo-${ERGO_VERSION}-linux-${ERGO_ARCH}.tar.gz" -o "$tmp/ergo.tgz" \
|
||||||
|
|| die "could not download Ergo ${ERGO_VERSION}"
|
||||||
|
tar -C "$tmp" -xzf "$tmp/ergo.tgz"
|
||||||
|
d="$tmp/ergo-${ERGO_VERSION}-linux-${ERGO_ARCH}"
|
||||||
|
install -m 0755 "$d/ergo" /opt/ergo/ergo
|
||||||
|
rm -rf /opt/ergo/languages && cp -r "$d/languages" /opt/ergo/languages
|
||||||
|
rm -rf "$tmp"
|
||||||
|
fi
|
||||||
|
|
||||||
|
# Operator password: generate once, keep the plaintext root-only, embed only the hash.
|
||||||
|
if [ ! -f "$ENV_DIR/ergo-oper.txt" ]; then
|
||||||
|
OPER_PASS="$(head -c18 /dev/urandom | base64 | tr -dc 'A-Za-z0-9' | head -c24)"
|
||||||
|
printf '%s\n' "$OPER_PASS" > "$ENV_DIR/ergo-oper.txt"
|
||||||
|
chmod 600 "$ENV_DIR/ergo-oper.txt"
|
||||||
|
fi
|
||||||
|
OPER_PASS="$(cat "$ENV_DIR/ergo-oper.txt")"
|
||||||
|
OPER_HASH="$(printf '%s\n%s\n' "$OPER_PASS" "$OPER_PASS" | /opt/ergo/ergo genpasswd 2>/dev/null | tail -1)"
|
||||||
|
|
||||||
|
# Render the config template from the repo (the __TOKENS__ become real values).
|
||||||
|
sed -e "s|__NETWORK__|${IRC_NETWORK}|g" \
|
||||||
|
-e "s|__DOMAIN__|${DOMAIN}|g" \
|
||||||
|
-e "s|__DATA__|${ERGO_DATA}|g" \
|
||||||
|
-e "s|__TLS_DIR__|${ERGO_DATA}/tls|g" \
|
||||||
|
-e "s|__LANG_DIR__|/opt/ergo/languages|g" \
|
||||||
|
-e "s|__OPER_PASSWORD_HASH__|${OPER_HASH}|g" \
|
||||||
|
-e "s|__USERS_DIR__|${DATA_DIR}/users|g" \
|
||||||
|
"${SRC_DIR}/deploy/ergo/ircd.yaml" > /etc/ergo/ircd.yaml
|
||||||
|
chmod 640 /etc/ergo/ircd.yaml
|
||||||
|
|
||||||
|
# IRC is members-only: this auth-script approves a SASL login only if the
|
||||||
|
# account name maps to an AgentBBS member home dir under ${DATA_DIR}/users.
|
||||||
|
install -m 0755 "${SRC_DIR}/deploy/ergo/auth-script.sh" /usr/local/bin/ergo-auth-member
|
||||||
|
|
||||||
|
# TLS for 6697: reuse Caddy's Let's Encrypt cert for ${DOMAIN}; self-signed
|
||||||
|
# fallback on first run before Caddy has issued it (the timer swaps it in).
|
||||||
|
install -m 0755 "${SRC_DIR}/deploy/ergo/refresh-certs.sh" /usr/local/bin/ergo-refresh-certs
|
||||||
|
DOMAIN="$DOMAIN" ERGO_DATA="$ERGO_DATA" /usr/local/bin/ergo-refresh-certs || true
|
||||||
|
if [ ! -s "$ERGO_DATA/tls/fullchain.pem" ]; then
|
||||||
|
warn "no Caddy cert for ${DOMAIN} yet — using a self-signed cert on 6697 until the ergo-certs timer swaps in the real one"
|
||||||
|
( cd /etc/ergo && /opt/ergo/ergo mkcerts --conf /etc/ergo/ircd.yaml --quiet 2>/dev/null ) \
|
||||||
|
|| openssl req -newkey rsa:2048 -nodes -days 90 -x509 \
|
||||||
|
-keyout "$ERGO_DATA/tls/privkey.pem" -out "$ERGO_DATA/tls/fullchain.pem" \
|
||||||
|
-subj "/CN=irc.${DOMAIN}" 2>/dev/null
|
||||||
|
fi
|
||||||
|
chown -R ergo:ergo "$ERGO_DATA" /etc/ergo
|
||||||
|
|
||||||
|
# Initialize the datastore once.
|
||||||
|
[ -f "$ERGO_DATA/ircd.db" ] || sudo -u ergo /opt/ergo/ergo initdb --conf /etc/ergo/ircd.yaml --quiet
|
||||||
|
|
||||||
|
log "installing ergo.service"
|
||||||
|
cat > /etc/systemd/system/ergo.service <<UNIT
|
||||||
|
[Unit]
|
||||||
|
Description=Ergo IRC server (AgentBBS — irc.${DOMAIN})
|
||||||
|
After=network-online.target
|
||||||
|
Wants=network-online.target
|
||||||
|
|
||||||
|
[Service]
|
||||||
|
User=ergo
|
||||||
|
Group=ergo
|
||||||
|
WorkingDirectory=/opt/ergo
|
||||||
|
ExecStart=/opt/ergo/ergo run --conf /etc/ergo/ircd.yaml
|
||||||
|
# Ergo rehashes config + reloads TLS certs on SIGHUP.
|
||||||
|
ExecReload=/bin/kill -HUP \$MAINPID
|
||||||
|
Restart=always
|
||||||
|
RestartSec=2
|
||||||
|
NoNewPrivileges=true
|
||||||
|
ProtectSystem=strict
|
||||||
|
ProtectHome=true
|
||||||
|
PrivateTmp=true
|
||||||
|
ReadWritePaths=${ERGO_DATA} /etc/ergo
|
||||||
|
|
||||||
|
[Install]
|
||||||
|
WantedBy=multi-user.target
|
||||||
|
UNIT
|
||||||
|
|
||||||
|
# Daily cert refresh from Caddy (tracks auto-renewals).
|
||||||
|
cat > /etc/systemd/system/ergo-certs.service <<UNIT
|
||||||
|
[Unit]
|
||||||
|
Description=Refresh Ergo TLS cert from Caddy for ${DOMAIN}
|
||||||
|
|
||||||
|
[Service]
|
||||||
|
Type=oneshot
|
||||||
|
Environment=DOMAIN=${DOMAIN}
|
||||||
|
Environment=ERGO_DATA=${ERGO_DATA}
|
||||||
|
ExecStart=/usr/local/bin/ergo-refresh-certs
|
||||||
|
UNIT
|
||||||
|
cat > /etc/systemd/system/ergo-certs.timer <<UNIT
|
||||||
|
[Unit]
|
||||||
|
Description=Periodic Ergo TLS cert refresh from Caddy
|
||||||
|
|
||||||
|
[Timer]
|
||||||
|
OnBootSec=5min
|
||||||
|
OnUnitActiveSec=12h
|
||||||
|
Persistent=true
|
||||||
|
|
||||||
|
[Install]
|
||||||
|
WantedBy=timers.target
|
||||||
|
UNIT
|
||||||
|
|
||||||
|
systemctl daemon-reload
|
||||||
|
systemctl enable ergo >/dev/null 2>&1 || true
|
||||||
|
systemctl restart ergo
|
||||||
|
systemctl enable --now ergo-certs.timer >/dev/null 2>&1 || true
|
||||||
|
ufw allow 6697/tcp >/dev/null
|
||||||
|
sleep 1
|
||||||
|
systemctl is-active --quiet ergo \
|
||||||
|
|| warn "ergo failed to start — check: journalctl -u ergo -n50"
|
||||||
|
else
|
||||||
|
systemctl disable --now ergo ergo-certs.timer >/dev/null 2>&1 || true
|
||||||
|
fi
|
||||||
|
|
||||||
# ---- 10. firewall + start agentbbs on :22 ----------------------------------
|
# ---- 10. firewall + start agentbbs on :22 ----------------------------------
|
||||||
log "configuring firewall + starting agentbbs"
|
log "configuring firewall + starting agentbbs"
|
||||||
ufw allow 22/tcp >/dev/null
|
ufw allow 22/tcp >/dev/null
|
||||||
|
|
@ -497,9 +654,12 @@ cat <<DONE
|
||||||
Web https://${DOMAIN}/ site root
|
Web https://${DOMAIN}/ site root
|
||||||
https://${DOMAIN}/~<name> a member's homepage
|
https://${DOMAIN}/~<name> a member's homepage
|
||||||
https://<your-domain> a member's homepage on a custom domain (auto-HTTPS)
|
https://<your-domain> a member's homepage on a custom domain (auto-HTTPS)
|
||||||
|
IRC irc.${DOMAIN}:6697 (TLS) native clients ${IRC:+(set IRC=0 to disable)}
|
||||||
|
wss://${DOMAIN}/irc web clients + agents over WebSocket
|
||||||
|
/OPER admin <pw> oper password in ${ENV_DIR}/ergo-oper.txt
|
||||||
|
|
||||||
Config ${ENV_DIR}/agentbbs.env (set CoinPay + LiveKit, then: systemctl restart agentbbs)
|
Config ${ENV_DIR}/agentbbs.env (set CoinPay + LiveKit, then: systemctl restart agentbbs)
|
||||||
Logs journalctl -u agentbbs -f
|
Logs journalctl -u agentbbs -f (IRC: journalctl -u ergo -f)
|
||||||
Update re-run this script (git pull + rebuild + restart)
|
Update re-run this script (git pull + rebuild + restart)
|
||||||
DONE
|
DONE
|
||||||
warn "Before you log out: open a new terminal and confirm ssh -p ${ADMIN_SSH_PORT} <you>@${DOMAIN} works."
|
warn "Before you log out: open a new terminal and confirm ssh -p ${ADMIN_SSH_PORT} <you>@${DOMAIN} works."
|
||||||
|
|
|
||||||
Loading…
Add table
Add a link
Reference in a new issue