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/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+" <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.
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue