1919// agentbbs map-domain DOMAIN NAME map a custom domain to a homepage
2020// agentbbs unmap-domain DOMAIN NAME remove a custom-domain mapping
2121// agentbbs mint-token NAME issue a WebSocket API token for NAME
22+ // agentbbs qrypt-invite NAME mint a qrypt.chat anonymous invite for NAME
23+ // agentbbs qrypt-issuer-keygen print a fresh qrypt issuer seed + public key
2224package main
2325
2426import (
@@ -61,9 +63,11 @@ import (
6163 "github.com/profullstack/agentbbs/internal/sandbox"
6264 "github.com/profullstack/agentbbs/internal/sites"
6365 "github.com/profullstack/agentbbs/internal/store"
66+ "github.com/profullstack/agentbbs/internal/tor"
6467 "github.com/profullstack/agentbbs/plugins/about"
6568 "github.com/profullstack/agentbbs/plugins/agentgames"
6669 "github.com/profullstack/agentbbs/plugins/arcade"
70+ qryptinviteplugin "github.com/profullstack/agentbbs/plugins/qryptinvite"
6771)
6872
6973func env (k , def string ) string {
@@ -121,6 +125,14 @@ func main() {
121125 mintToken (st , os .Args [2 :])
122126 return
123127 }
128+ if len (os .Args ) > 1 && os .Args [1 ] == "qrypt-invite" {
129+ qryptInviteCmd (st , os .Args [2 :])
130+ return
131+ }
132+ if len (os .Args ) > 1 && os .Args [1 ] == "qrypt-issuer-keygen" {
133+ qryptIssuerKeygen ()
134+ return
135+ }
124136
125137 host := env ("AGENTBBS_HOST" , "bbs.profullstack.com" )
126138 fe := forwardemail .ConfigFromEnv ()
@@ -141,7 +153,7 @@ func main() {
141153 a .mm = games .NewMatchmaker (a .gamesReg , a .st ,
142154 time .Duration (envInt ("AGENTBBS_GAME_MOVE_TIMEOUT" , 15 ))* time .Second ,
143155 time .Duration (envInt ("AGENTBBS_GAME_QUEUE_WAIT" , 120 ))* time .Second )
144- a .registry = []plugin.Plugin {arcade.Plugin {}, agentgames .New (a .gamesReg ), about.Plugin {}}
156+ a .registry = []plugin.Plugin {arcade.Plugin {}, agentgames .New (a .gamesReg ), qryptinviteplugin. Plugin {}, about.Plugin {}}
145157
146158 // Custom domains: maintain the symlink farm Caddy serves and answer its
147159 // on-demand-TLS "ask" query so certs are only issued for mapped domains.
@@ -247,6 +259,12 @@ func (a *app) router() wish.Middleware {
247259 a .handleGame (s )
248260 case auth .IsPodName (user ):
249261 a .handlePod (s )
262+ case auth .IsTorURLName (user ):
263+ a .handleTorURL (s )
264+ case auth .IsTorIRCName (user ):
265+ a .handleTorIRC (s )
266+ case auth .IsTorName (user ):
267+ a .handleTorCmd (s )
250268 case isVideo :
251269 a .handleVideo (s , code )
252270 case user == "agent" :
@@ -841,6 +859,136 @@ func (a *app) handlePod(s ssh.Session) {
841859 }
842860}
843861
862+ // torMember resolves the caller's key to a premium member for the tor routes,
863+ // printing a reason and returning ok=false otherwise. It records the session.
864+ func (a * app ) torMember (s ssh.Session , route string ) (store.User , bool ) {
865+ fp := auth .Fingerprint (s .PublicKey ())
866+ if fp == "" {
867+ wish .Println (s , route + "@ needs your registered SSH key. New here? ssh join@" + a .host )
868+ _ = s .Exit (1 )
869+ return store.User {}, false
870+ }
871+ u , found , err := a .st .UserByFingerprint (fp )
872+ if err != nil || ! found {
873+ wish .Println (s , "key not registered — run: ssh join@" + a .host )
874+ _ = s .Exit (1 )
875+ return store.User {}, false
876+ }
877+ if u .Banned {
878+ wish .Println (s , "this account is suspended." )
879+ _ = s .Exit (1 )
880+ return store.User {}, false
881+ }
882+ if ! a .ensurePremium (& u ) {
883+ wish .Println (s , " " + route + " is a Premium feature ($10 lifetime). Upgrade: ssh join@" + a .host )
884+ _ = s .Exit (1 )
885+ return store.User {}, false
886+ }
887+ _ , _ = a .st .RecordSession (u .ID , s .User (), remoteIP (s ), route )
888+ return u , true
889+ }
890+
891+ // handleTorURL fetches a single URL over Tor and writes the body back. One-shot,
892+ // host-side, and constrained (timeout + size cap, http/https only). Premium.
893+ func (a * app ) handleTorURL (s ssh.Session ) {
894+ u , ok := a .torMember (s , "tor-url" )
895+ if ! ok {
896+ return
897+ }
898+ args := s .Command ()
899+ if len (args ) == 0 {
900+ wish .Println (s , "usage: ssh tor-url@" + a .host + " <http(s)-url> (e.g. an .onion address)" )
901+ _ = s .Exit (1 )
902+ return
903+ }
904+ url := args [0 ]
905+ log .Info ("tor-url fetch" , "user" , u .Name , "url" , url )
906+ body , err := tor .FetchURL (s .Context (), url )
907+ if err != nil {
908+ wish .Println (s , " " + err .Error ())
909+ _ = s .Exit (1 )
910+ return
911+ }
912+ _ , _ = s .Write (body )
913+ _ = s .Exit (0 )
914+ }
915+
916+ // handleTorIRC opens an interactive IRC-over-Tor session inside the member's
917+ // pod (sandboxed). Premium; requires a PTY.
918+ func (a * app ) handleTorIRC (s ssh.Session ) {
919+ u , ok := a .torMember (s , "tor-irc" )
920+ if ! ok {
921+ return
922+ }
923+ args := s .Command ()
924+ if len (args ) == 0 || ! validIRCServer (args [0 ]) {
925+ wish .Println (s , "usage: ssh -t tor-irc@" + a .host + " <server[:port]> (e.g. an .onion IRC server)" )
926+ _ = s .Exit (1 )
927+ return
928+ }
929+ if a .pods == nil {
930+ wish .Println (s , "pods are temporarily unavailable on this host." )
931+ _ = s .Exit (1 )
932+ return
933+ }
934+ log .Info ("tor-irc connect" , "user" , u .Name , "server" , args [0 ])
935+ if err := a .pods .Exec (s , u .Name , tor .IRCArgv (args [0 ])); err != nil {
936+ wish .Println (s , "tor-irc error: " + err .Error ())
937+ _ = s .Exit (1 )
938+ }
939+ }
940+
941+ // handleTorCmd runs an arbitrary command through Tor (torsocks) inside the
942+ // member's pod, never on the host. Premium; requires a PTY.
943+ func (a * app ) handleTorCmd (s ssh.Session ) {
944+ u , ok := a .torMember (s , "tor" )
945+ if ! ok {
946+ return
947+ }
948+ args := s .Command ()
949+ if len (args ) == 0 {
950+ wish .Println (s , "usage: ssh -t tor@" + a .host + " <command...> (runs in your pod, over Tor)" )
951+ _ = s .Exit (1 )
952+ return
953+ }
954+ if a .pods == nil {
955+ wish .Println (s , "pods are temporarily unavailable on this host." )
956+ _ = s .Exit (1 )
957+ return
958+ }
959+ log .Info ("tor cmd" , "user" , u .Name , "argv" , strings .Join (args , " " ))
960+ if err := a .pods .Exec (s , u .Name , tor .Torsocks (args )); err != nil {
961+ wish .Println (s , "tor error: " + err .Error ())
962+ _ = s .Exit (1 )
963+ }
964+ }
965+
966+ // validIRCServer accepts host or host:port with a sane charset (no shell/space).
967+ func validIRCServer (s string ) bool {
968+ host := s
969+ if i := strings .LastIndex (s , ":" ); i > 0 {
970+ port := s [i + 1 :]
971+ host = s [:i ]
972+ if port == "" || len (port ) > 5 {
973+ return false
974+ }
975+ for _ , r := range port {
976+ if r < '0' || r > '9' {
977+ return false
978+ }
979+ }
980+ }
981+ if host == "" || len (host ) > 255 {
982+ return false
983+ }
984+ for _ , r := range host {
985+ if ! (r >= 'a' && r <= 'z' || r >= 'A' && r <= 'Z' || r >= '0' && r <= '9' || r == '.' || r == '-' ) {
986+ return false
987+ }
988+ }
989+ return true
990+ }
991+
844992// handleVideo joins a PairUX call rendered as ASCII (docs/video.md).
845993// `video@` prompts for a code; `video-<code>@` joins directly. Codes are
846994// minted by PairUX — starting a call requires already having one.
0 commit comments