Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
137 changes: 137 additions & 0 deletions cmd/agentbbs/main.go
Original file line number Diff line number Diff line change
Expand Up @@ -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"
Expand Down Expand Up @@ -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":
Expand Down Expand Up @@ -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).
// `video@` prompts for a code; `video-<code>@` joins directly. Codes are
// minted by PairUX — starting a call requires already having one.
Expand Down
22 changes: 21 additions & 1 deletion internal/auth/auth.go
Original file line number Diff line number Diff line change
Expand Up @@ -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}
Expand All @@ -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.
Expand All @@ -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
Expand Down
41 changes: 41 additions & 0 deletions internal/pods/pods.go
Original file line number Diff line number Diff line change
Expand Up @@ -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()
Expand Down
80 changes: 80 additions & 0 deletions internal/tor/tor.go
Original file line number Diff line number Diff line change
@@ -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 changes: 6 additions & 0 deletions setup.sh
Original file line number Diff line number Diff line change
Expand Up @@ -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"
Expand Down
Loading