// go-bbs provides a basic framework for a multiuser bulletin board server application // it is indended to be customized by the operator of the bulletin board package main import ( "bytes" "context" "errors" "flag" "fmt" "io" "log" "net" "os" "os/exec" "strconv" "strings" "sync" "text/template" "time" "github.com/creack/pty" "github.com/xtaci/gaio" "golang.org/x/term" "versestudios.com/go-bbs/openconn" ) type InChanKey string type OutChanKey string // ioHandler is the gaio.Watcher's main loop // it wait for I/O calls to the watcher's Read and Write methods // and tracks the state of the call, dispatching the appropriate gaio.OpResult // to the proper channel based on whether it was a read (input from the remote client) // or a write (output to the remote client) func ioHandler(w *gaio.Watcher, WatcherControl *chan string) { for { select { case msg := <-*WatcherControl: switch msg { case "": log.Println("empty WatcherControl command") case "stop": log.Println("stopping ioHandler via WatcherControl stop command") return default: log.Println("unknown WatcherControl command:", msg) } default: // loop wait for any IO events results, err := w.WaitIO() if err != nil { log.Println(err) return } IOLoop: for _, res := range results { if res.Context != nil && nil != res.Context.(context.Context) { if inChan, _, ok := openconn.FromContext(res.Context.(context.Context)); ok { switch res.Operation { case gaio.OpRead: // read completion event inChan <- res // queue next read w.Read(res.Context, res.Conn, nil) default: // anything else (meaning write completions) continue IOLoop } } else { log.Printf("error getting inChan and outChan from context: %v\n", res.Context) } } else { log.Printf("nil context! res: %v\n", res) } } } time.Sleep(time.Millisecond) } } // main is the main loop of the server // it sets up both the network listener as well as the gaio.Watcher that will handle // I/O for each incoming network connection func main() { port := flag.Int("port", 3333, "Port to accept connections on.") host := flag.String("host", "127.0.0.1", "Host or IP to bind to") flag.Parse() w, err := gaio.NewWatcher() if err != nil { log.Fatal(err) } defer w.Close() l, err := net.Listen("tcp", *host+":"+strconv.Itoa(*port)) if err != nil { log.Panicln(err) } log.Println("Listening to connections at '"+*host+"' on port", strconv.Itoa(*port)) log.Println("(If you want to run this on a different address or port, the command line flags -host and -port are available to you.)") defer l.Close() for { conn, err := l.Accept() if err != nil { log.Panicln(err) } log.Println("new client: ", conn.RemoteAddr()) // chan to terminate ioHandler when neccessary WatcherControl := make(chan string) // get new context with inChan and outChan for this client connection ctx := openconn.NewContext(context.Background()) log.Printf("new context for client %v: %v\n", conn.RemoteAddr(), ctx) // submit the first async write IO request welcomeScreen, err := screenHandler("welcome", nil) if err != nil { log.Printf("err loading initial welcome screen: %v\n", err) } err = w.Write(ctx, conn, welcomeScreen) if err != nil { log.Printf("err sending welcomeHandler: %v\n", err) return } // now that a prompt is (or will be) displayed, go ahead and listen for input err = w.Read(ctx, conn, nil) if err != nil { log.Printf("err queueing w.Read: %v\n", err) return } time.Sleep(time.Millisecond) // menu handler for this connection go menuHandler(ctx, conn, w) // io handler for this connection go ioHandler(w, &WatcherControl) log.Println("main thread looping") } } // menuHandler is the main loop for each individual remote connection // it receives input over the connection's InChan channel (part of its context) // as a gaio.OpResult func menuHandler(ctx context.Context, conn net.Conn, w *gaio.Watcher) { var wg sync.WaitGroup MenuControl := make(chan string) var inChan, outChan chan gaio.OpResult var ok bool if inChan, outChan, ok = openconn.FromContext(ctx); !ok { log.Println("Could not get inChan/outChan from context!", ok, ctx) } wg.Add(1) go func(inChan, outChan chan gaio.OpResult, MenuControl chan string) { defer wg.Done() log.Println("starting menu loop") paused := false ControlLoop: for { select { case msg := <-MenuControl: log.Println("msg on MenuControl:", msg) switch string(msg) { case "stop": log.Println("closing menu loop") paused = true break ControlLoop case "pause": log.Println("pausing menu loop via menucontrol") paused = true case "unpause": log.Println("unpausing menu loop via menucontrol") paused = false default: log.Println("menu received unknown command: ", msg) } default: if !paused { select { case res := <-inChan: if res.Error != nil { log.Println("error on inChan: ", res.Error) } if res.Operation == gaio.OpRead && res.Size > 0 && res.Conn == conn { log.Printf("received on inChan: conn: %v, buffer size: %v\n", res.Conn.RemoteAddr(), res.Size) log.Println("menu receive: ", strings.TrimSpace(string(res.Buffer[:res.Size-1]))) switch string(strings.TrimSpace(string(res.Buffer[:res.Size-1]))) { case "welcome": welcomeStr, err := screenHandler("welcome", nil) if err != nil { log.Printf("err loading welcome screen: %v\n", err) } if err := w.Write(ctx, conn, welcomeStr); err != nil { log.Printf("error sending screenHandler from cmd `welcome`: %v", err) } case "help": helpStr, err := screenHandler("help", nil) if err != nil { log.Printf("err loading help screen: %v\n", err) } if err := w.Write(ctx, conn, helpStr); err != nil { log.Printf("error sending screenHandler from cmd `help`: %v", err) } case "contact": contactStr, err := screenHandler("contact", nil) if err != nil { log.Printf("err loading contact screen: %v\n", err) } if err := w.Write(ctx, conn, contactStr); err != nil { log.Printf("error sending screenHandler from cmd `contact`: %v", err) } case "operator": opStr, err := screenHandler("operator", nil) if err != nil { log.Printf("err loading operator screen: %v\n", err) } var lineNum int var line string baudrate := 40 // 300 baud is ~ 30 characters per second; I'll be nice and bump it to 400 for lineNum, line = range strings.Split(string(opStr), "\n") { // changed my mind - I actually like it at a nice steady 400 baud, tyvm // if baudrate < 120 && i > 1 { // 1200 baud is still very readable in realtime without being too fast // baudrate += 1 //} if lineNum < 10 { // spit the banner text out quickly if err := w.Write(ctx, conn, []byte(line+"\r\n")); err != nil { log.Printf("error sending rune from cmd `operator`: %v", err) } } else { ms := float64(1) / float64(baudrate) for _, c := range line { if err := w.Write(ctx, conn, []byte{byte(c)}); err != nil { log.Printf("error sending rune from cmd `operator`: %v", err) } time.Sleep(time.Duration(ms*1000) * time.Millisecond) } if err := w.Write(ctx, conn, []byte("\r\n")); err != nil { log.Printf("error sending rune from cmd `operator`: %v", err) } } } // add prompt at end if err := w.Write(ctx, conn, []byte("\r\n> ")); err != nil { log.Printf("error sending rune from cmd `operator`: %v", err) } case "adventure": // start the door and wait var wg sync.WaitGroup log.Println("starting door handler...") wg.Add(1) go doorHandler(ctx, conn, w, &wg) log.Println("menu handler waiting for wg to return from door") wg.Wait() log.Println("returning from door") // welcome back and prompt err := w.Write(ctx, conn, []byte("Welcome back from your adventure!\n\n> ")) if err != nil { log.Printf("error writing to connection: %v", err) } case "info": wg := new(sync.WaitGroup) wg.Add(2) uptime := getUptime(wg) tubes := getTubePeers(wg) wg.Wait() data := struct{ Uptime, TubesInfo string }{uptime, tubes} infoStr, err := screenHandler("info", data) if err != nil { log.Printf("err loading info screen: %v\n", err) } if err := w.Write(ctx, conn, infoStr); err != nil { log.Printf("error sending screenHandler from cmd `info`: %v", err) } case "exit": exitStr, err := screenHandler("exit", nil) if err != nil { log.Printf("err loading exit screen: %v\n", err) } if err := w.Write(ctx, conn, exitStr); err != nil { log.Printf("error sending exitHandler from cmd `exit`: %v", err) } // wait a second before really closing the connection time.Sleep(time.Second) w.Free(conn) default: err := w.Write(ctx, conn, []byte("huh?\n\n> ")) if err != nil { log.Printf("error writing to connection: %v", err) } } } else { // noop log.Printf("received on inChan: conn: %v, buffer: %v\n", res.Conn.RemoteAddr(), string(res.Buffer)) } default: // noop - wait a lil bit time.Sleep(time.Millisecond) } if conn == nil { log.Println("conn is nil!") } } } time.Sleep(time.Millisecond) } log.Println("menu controlloop closed!") }(inChan, outChan, MenuControl) log.Println("menu waiting on waitgroup") wg.Wait() log.Println("terminating menucontrol for client") } // screenHandler handles non-interactive commands // it takes a screen name and an optional struct of data to be parsed into the screen's template // it returns the populated screen as a byte array and an error if there's a problem loading the template // screen templates are simple text files with optional go template directives. if the text file includes // ANSI control sequences they will be passed on to the remote client as well. // TODO: add caching of static screens, move ANSI handling out to optional template directives as well func screenHandler(screenName string, tplData interface{}) (screen []byte, err error) { var screenBuf *bytes.Buffer if tmpl, err := template.ParseFiles("screens/" + screenName + ".ans"); err != nil { log.Printf("err loading welcome template: %v\n", err) // // TODO: better error handling } else { screenBuf = new(bytes.Buffer) tmpl.Execute(screenBuf, tplData) } return screenBuf.Bytes(), err } // doorHandler handles interactive commands // it halts the processing of the menuHandler and allows the doorHandler full duplex I/O // between the remote connection and a local program's stdin/stdout // doorHandler returns control to menuHandler once the program completes and exits // TODO: add argument for program, program's prompt, launch text, and exit text func doorHandler(ctx context.Context, c net.Conn, w *gaio.Watcher, menuwg *sync.WaitGroup) error { defer menuwg.Done() var wg sync.WaitGroup var inChan, _ chan gaio.OpResult var ok bool if inChan, _, ok = openconn.FromContext(ctx); !ok { log.Println("Could not get inChan/outChan from context!", ok, ctx) // TODO: better error handling } cmd := exec.Command("/usr/games/adventure") // TODO: move command path and args to an argument to doorHandler ptmx, err := pty.Start(cmd) if err != nil { return err } // Make sure to close the pty at the end. defer func() { _ = ptmx.Close() }() // Best effort. TODO: better error handling, better zombie handling if term.IsTerminal(int(ptmx.Fd())) { oldState, _ := term.MakeRaw(int(ptmx.Fd())) defer term.Restore(int(ptmx.Fd()), oldState) } Terminator := make(chan bool) wg.Add(1) go func() { defer wg.Done() waitingForInput := false IOLoop: for { select { case result, ok := <-inChan: var l int if ok { if result.Operation == gaio.OpRead && result.Size > 0 { l, err = ptmx.Write(result.Buffer[:result.Size]) if err != nil { if err == io.EOF { log.Println("EOF on cmd.write!!!") // TODO: better error handling break IOLoop } } if l > 0 { waitingForInput = false } } } else { log.Println("yikes, problem getting a result from inChan in doorHandler!") // TODO: better error handling } case <-Terminator: log.Println("Terminator'd!!!") break IOLoop default: if !waitingForInput { ptmx.SetReadDeadline(time.Now().Add(1 * time.Second)) var l int outBuf := make([]byte, 4096) if l, err = ptmx.Read(outBuf); err != nil { if err == io.EOF { // EOF isn't actually an error in this case, it just means we're done break IOLoop } else if errors.Is(err, os.ErrDeadlineExceeded) { // output deadline exceeded - get some input instead w.Read(ctx, c, nil) waitingForInput = true } else if errors.Is(err, os.ErrClosed) { // program exited - get outta here break IOLoop } else { log.Printf("err reading from cmd term: %v\n", err) // TODO: better error handling break IOLoop } } if l > 0 { w.Write(ctx, c, outBuf[:l]) if strings.Contains(string(outBuf[:l]), ">") { // TODO: abstract out prompt string as argument to doorHandler // a prompt appeared! waitingForInput = true } } else { // zero output? let's get some input opportunistically w.Read(ctx, c, nil) } } } time.Sleep(time.Millisecond) } }() wg.Wait() log.Println("waiting for cmd") exitErr := cmd.Wait() if exitErr != nil { log.Printf("cmd exited with err: %v\n", exitErr) // TODO: better error handling } log.Println("leaving door handler") return nil } // returns output of `uptime` as a string func getUptime(wg *sync.WaitGroup) string { defer wg.Done() out, err := exec.Command("uptime").Output() if err != nil { return string(err.Error()) // TODO: better error handling } return string(out) } // returns output of a bash script that collects current wireguard peering connections and their traffic totals func getTubePeers(wg *sync.WaitGroup) string { defer wg.Done() out, err := exec.Command("/usr/local/bin/getTubesPeers.bash").Output() outStr := "" // clean up the output formatting a little for _, line := range strings.Split(string(out), "\n") { formattedLine := "" for j, field := range strings.Fields(line) { switch j { case 0: formattedLine = field case 1: formattedLine += "\x1b[9G" + field case 2: offset := 10 - len(field) + 57 formattedLine += "\x1b[" + fmt.Sprintf("%d", offset) + "G" + field case 3: offset := 10 - len(field) + 74 formattedLine += "\x1b[" + fmt.Sprintf("%d", offset) + "G" + field } } outStr = outStr + formattedLine + "\n" } if err != nil { log.Printf("err loading getTubePeers: %v\n", err) return string(err.Error()) // TODO: better error handling } return outStr }