From 4be87440d5d024521a2951ec64f8313c39ae4863 Mon Sep 17 00:00:00 2001 From: Anthony Ettinger Date: Sun, 14 Jun 2026 10:54:21 +0000 Subject: [PATCH] tor: premium tor-url / tor-irc / tor routes over Tor MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Add three premium-gated SSH routes: ssh tor-url@host one-shot HTTP(S) GET over Tor (host-side, curl via SOCKS, 30s/2MB caps, http/https only) ssh -t tor-irc@host interactive IRC over Tor in the member's pod ssh -t tor@host run any command over Tor (torsocks) in the pod tor-url runs host-side and constrained; tor/tor-irc run inside the member's isolated pod (new pods.Exec) so arbitrary/interactive commands are sandboxed, never on the host. internal/tor wraps curl/torsocks/irssi. All gated by ensurePremium; names reserved. setup.sh installs + enables tor (SOCKS 127.0.0.1:9050) and torsocks. Note: tor-url is host-side and self-contained. tor/tor-irc still need the pod image to carry torsocks+irssi and reach the Tor SOCKS — follow-up. Co-Authored-By: Claude Opus 4.8 --- cmd/agentbbs/main.go | 137 ++++++++++++++++++++++++++++++++++++++++++ internal/auth/auth.go | 22 ++++++- internal/pods/pods.go | 41 +++++++++++++ internal/tor/tor.go | 80 ++++++++++++++++++++++++ setup.sh | 6 ++ 5 files changed, 285 insertions(+), 1 deletion(-) create mode 100644 internal/tor/tor.go diff --git a/cmd/agentbbs/main.go b/cmd/agentbbs/main.go index ee86427..ecd776a 100644 --- a/cmd/agentbbs/main.go +++ b/cmd/agentbbs/main.go @@ -60,6 +60,7 @@ 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" @@ -246,6 +247,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": @@ -805,6 +812,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+" (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+" (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+" (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-@` joins directly. Codes are // minted by PairUX — starting a call requires already having one. diff --git a/internal/auth/auth.go b/internal/auth/auth.go index edaa680..b8276d5 100644 --- a/internal/auth/auth.go +++ b/internal/auth/auth.go @@ -46,6 +46,16 @@ var DomainNames = map[string]bool{"domain": true, "domains": true} // (see IsAdmin); the name itself confers nothing. 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 // agent-vs-agent match protocol (PRD §5.2). `play@` stays a guest hub alias. 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. 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 // collide with a per-user subdomain (.), the agent route, or common // 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. func IsReservedName(name string) bool { 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 strings.HasPrefix(n, "video-") // video- call routes diff --git a/internal/pods/pods.go b/internal/pods/pods.go index 3a5746b..ea638c8 100644 --- a/internal/pods/pods.go +++ b/internal/pods/pods.go @@ -144,6 +144,47 @@ func (m *Manager) Attach(s ssh.Session, user string) error { 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) { m.mu.Lock() defer m.mu.Unlock() diff --git a/internal/tor/tor.go b/internal/tor/tor.go new file mode 100644 index 0000000..bfcd4b3 --- /dev/null +++ b/internal/tor/tor.go @@ -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 +} diff --git a/setup.sh b/setup.sh index 26d1d8a..25cde42 100755 --- a/setup.sh +++ b/setup.sh @@ -87,8 +87,14 @@ apt-get update -qq apt-get install -y -qq \ git ca-certificates curl ufw ffmpeg unzip \ podman uidmap slirp4netns fuse-overlayfs \ + tor torsocks \ 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. if ! command -v yt-dlp >/dev/null; then log "installing yt-dlp"