tor: premium tor-url / tor-irc / tor routes over Tor

Add three premium-gated SSH routes:
  ssh tor-url@host <url>        one-shot HTTP(S) GET over Tor (host-side,
                               curl via SOCKS, 30s/2MB caps, http/https only)
  ssh -t tor-irc@host <server>  interactive IRC over Tor in the member's pod
  ssh -t tor@host <command...>  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 <noreply@anthropic.com>
This commit is contained in:
Anthony Ettinger 2026-06-14 10:54:21 +00:00
parent ac4b0873d9
commit 4be87440d5
5 changed files with 285 additions and 1 deletions

View file

@ -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 (<name>.<host>), 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-<code> call routes

View file

@ -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()

80
internal/tor/tor.go Normal file
View 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
}