502 lines
15 KiB
Go
502 lines
15 KiB
Go
// 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-2])))
|
|
switch string(strings.TrimSpace(string(res.Buffer[:res.Size-2]))) {
|
|
|
|
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
|
|
}
|