agentbbs/internal/tor/tor.go
Anthony Ettinger 4be87440d5 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>
2026-06-14 10:54:21 +00:00

80 lines
2.7 KiB
Go

// 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
}