mirror of
https://github.com/profullstack/agentbbs.git
synced 2026-08-13 14:27:27 +00:00
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:
parent
ac4b0873d9
commit
4be87440d5
5 changed files with 285 additions and 1 deletions
|
|
@ -60,6 +60,7 @@ 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"
|
||||||
|
|
@ -246,6 +247,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":
|
||||||
|
|
@ -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+" <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.
|
||||||
|
|
|
||||||
|
|
@ -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()
|
||||||
|
|
|
||||||
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
|
||||||
|
}
|
||||||
6
setup.sh
6
setup.sh
|
|
@ -87,8 +87,14 @@ 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 \
|
||||||
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"
|
||||||
|
|
|
||||||
Loading…
Add table
Add a link
Reference in a new issue