code.pablomurad.com

bbs-hammer

SSH BBS with forums, chat, door games, retro terminals and a weather service, written in Go

feat(door): drop files and socket doors
author
Pablo Murad
date
commit
d5b5efc99b084cd4b5db58852408f1d62bc0a52b
parent
75c838e8b0a00aa761b1a01641837195baaf0361
changes
6 files +583 −17
@@ -0,0 +1,254 @@+package door
+
+import (
+	"strconv"
+	"strings"
+	"time"
+)
+
+// Caller is what the classic drop files tell a door about who is calling.
+// The formats come from the 1990s BBS packages; Synchronet's wiki keeps the
+// reference layouts (wiki.synchro.net/ref:door32.sys, ref:door.sys,
+// ref:dorinfo1.def, ref:chain.txt).
+type Caller struct {
+	Alias      string        // the caller's nickname, also used as the real name
+	UserNumber int           // a stable number for the caller, which doors key players on
+	Node       int           // the node number
+	Width      int           // terminal columns
+	Height     int           // terminal rows
+	TimeLeft   time.Duration // until the door must end
+	Security   int           // access level (0-255)
+	BBS        string        // the board's name
+	Sysop      string        // the sysop's name
+	Location   string        // where the caller is, if known
+	ANSI       bool          // the caller sees ANSI colour
+	// COMPort and Baud name the line for doors that think in serial ports.
+	// Native doors want "COM0:" (local); DOS doors behind a FOSSIL driver
+	// want "COM1:", or they fall back to local output.
+	COMPort string
+	Baud    int
+}
+
+// Comm types of DOOR32.SYS line 1.
+const (
+	CommLocal  = 0 // stdio, or no remote line
+	CommSerial = 1
+	CommTelnet = 2 // a socket handle on line 2
+)
+
+// Door32 renders DOOR32.SYS (11 lines). handle is the socket descriptor
+// for CommTelnet, ignored otherwise.
+func Door32(c Caller, commType, handle int) []byte {
+	if commType != CommTelnet {
+		handle = -1
+	}
+	alias := field(c.Alias, "player")
+
+	return crlf(
+		strconv.Itoa(commType),
+		strconv.Itoa(handle),
+		strconv.Itoa(baud(c)),
+		field(c.BBS, "Hammer"),
+		strconv.Itoa(max(c.UserNumber, 1)),
+		alias,
+		alias,
+		strconv.Itoa(security(c)),
+		strconv.Itoa(minutesLeft(c)),
+		boolDigit(c.ANSI),
+		strconv.Itoa(max(c.Node, 1)),
+	)
+}
+
+// DoorSys renders DOOR.SYS in the 52-line GAP/PCBoard layout.
+func DoorSys(c Caller) []byte {
+	alias := field(c.Alias, "player")
+	graphics := "NG"
+	if c.ANSI {
+		graphics = "GR"
+	}
+	today := time.Now().Format("01/02/06")
+	now := time.Now().Format("15:04")
+
+	return crlf(
+		field(c.COMPort, "COM0:"),            // 1 COM port
+		strconv.Itoa(baud(c)),                // 2 baud
+		"8",                                  // 3 data bits
+		strconv.Itoa(max(c.Node, 1)),         // 4 node
+		strconv.Itoa(baud(c)),                // 5 DTE rate
+		"Y",                                  // 6 screen display
+		"N",                                  // 7 printer
+		"N",                                  // 8 page bell
+		"N",                                  // 9 caller alarm
+		alias,                                // 10 full name
+		field(c.Location, "Hammer BBS"),      // 11 calling from
+		"000-000-0000",                       // 12 home phone
+		"000-000-0000",                       // 13 work phone
+		"PASSWORD",                           // 14 password (never the real one)
+		strconv.Itoa(security(c)),            // 15 security level
+		"1",                                  // 16 times on (old doors crash above 255)
+		today,                                // 17 last date called
+		strconv.Itoa(minutesLeft(c)*60),      // 18 seconds left
+		strconv.Itoa(minutesLeft(c)),         // 19 minutes left
+		graphics,                             // 20 graphics
+		strconv.Itoa(clampDim(c.Height, 24)), // 21 page length
+		"Y",                                  // 22 expert mode
+		"",                                   // 23 conferences
+		"",                                   // 24 conference exited from
+		"12/31/99",                           // 25 expiration date
+		strconv.Itoa(max(c.UserNumber, 1)),   // 26 user record number
+		"Z",                                  // 27 default protocol
+		"0",                                  // 28 uploads
+		"0",                                  // 29 downloads
+		"0",                                  // 30 KB downloaded today
+		"9999",                               // 31 daily KB limit
+		"01/01/80",                           // 32 birthdate
+		`C:\`,                                // 33 main directory
+		`C:\`,                                // 34 general directory
+		field(c.Sysop, "sysop"),              // 35 sysop name
+		alias,                                // 36 alias
+		"00:00",                              // 37 event time
+		"Y",                                  // 38 error-correcting connection
+		"N",                                  // 39 ANSI supported but NG mode
+		"Y",                                  // 40 record locking
+		"7",                                  // 41 default colour
+		"0",                                  // 42 time credits
+		today,                                // 43 last new-file scan
+		now,                                  // 44 time of this call
+		now,                                  // 45 time of last call
+		"9999",                               // 46 max daily files
+		"0",                                  // 47 files downloaded today
+		"0",                                  // 48 KB uploaded
+		"0",                                  // 49 KB downloaded
+		"",                                   // 50 comment
+		"0",                                  // 51 doors opened
+		"0",                                  // 52 messages left
+	)
+}
+
+// DorInfo renders DORINFO1.DEF (12 lines, upper case like the BBSes that
+// wrote it).
+func DorInfo(c Caller) []byte {
+	first, last := splitName(field(c.Alias, "player"))
+	sysFirst, sysLast := splitName(field(c.Sysop, "sysop"))
+	graphics := "0"
+	if c.ANSI {
+		graphics = "1"
+	}
+	port := "0"
+	if p := field(c.COMPort, "COM0:"); strings.HasPrefix(strings.ToUpper(p), "COM") {
+		port = strings.TrimSuffix(strings.TrimPrefix(strings.ToUpper(p), "COM"), ":")
+	}
+
+	return crlf(
+		strings.ToUpper(field(c.BBS, "Hammer")),
+		strings.ToUpper(sysFirst),
+		strings.ToUpper(sysLast),
+		"COM"+port,
+		strconv.Itoa(baud(c))+" BAUD,N,8,1",
+		"0",
+		strings.ToUpper(first),
+		strings.ToUpper(last),
+		strings.ToUpper(field(c.Location, "Hammer BBS")),
+		graphics,
+		strconv.Itoa(security(c)),
+		strconv.Itoa(minutesLeft(c)),
+	)
+}
+
+// Chain renders CHAIN.TXT (WWIV, 30 lines), one of the few formats that
+// carries both columns and rows.
+func Chain(c Caller) []byte {
+	alias := field(c.Alias, "player")
+	now := time.Now()
+	midnight := time.Date(now.Year(), now.Month(), now.Day(), 0, 0, 0, 0, now.Location())
+
+	return crlf(
+		strconv.Itoa(max(c.UserNumber, 1)),   // 1 user number
+		alias,                                // 2 handle
+		alias,                                // 3 real name
+		"",                                   // 4 callsign
+		"21",                                 // 5 age
+		"M",                                  // 6 sex
+		"0",                                  // 7 gold
+		now.Format("01/02/06"),               // 8 last date
+		strconv.Itoa(clampDim(c.Width, 80)),  // 9 columns
+		strconv.Itoa(clampDim(c.Height, 24)), // 10 rows
+		strconv.Itoa(security(c)),            // 11 security level
+		"0",                                  // 12 co-sysop
+		"0",                                  // 13 sysop
+		boolDigit(c.ANSI),                    // 14 ANSI
+		"1",                                  // 15 remote
+		strconv.Itoa(minutesLeft(c)*60),      // 16 seconds left
+		`C:\`,                                // 17 gfiles directory
+		`C:\`,                                // 18 data directory
+		"door.log",                           // 19 log file
+		strconv.Itoa(baud(c)),                // 20 speed
+		"1",                                  // 21 COM port
+		field(c.BBS, "Hammer"),               // 22 BBS name
+		field(c.Sysop, "sysop"),              // 23 sysop name
+		strconv.Itoa(int(now.Sub(midnight).Seconds())), // 24 seconds past midnight
+		"0",   // 25 seconds used
+		"0",   // 26 KB uploaded
+		"0",   // 27 uploads
+		"0",   // 28 KB downloaded
+		"0",   // 29 downloads
+		"8N1", // 30 line settings
+	)
+}
+
+func crlf(lines ...string) []byte {
+	for i, l := range lines {
+		lines[i] = clean(l)
+	}
+
+	return []byte(strings.Join(lines, "\r\n") + "\r\n")
+}
+
+// clean strips what could break a line-based file: control characters and
+// surrounding spaces. Unlike field it leaves empty values empty.
+func clean(s string) string {
+	return strings.TrimSpace(strings.Map(func(r rune) rune {
+		if r < 0x20 || (r >= 0x7f && r < 0xa0) {
+			return -1
+		}
+
+		return r
+	}, s))
+}
+
+func baud(c Caller) int {
+	if c.Baud > 0 {
+		return c.Baud
+	}
+
+	return 38400
+}
+
+func security(c Caller) int {
+	return min(max(c.Security, 10), 255)
+}
+
+func minutesLeft(c Caller) int {
+	m := int(c.TimeLeft / time.Minute)
+
+	return min(max(m, 1), 999)
+}
+
+func boolDigit(b bool) string {
+	if b {
+		return "1"
+	}
+
+	return "0"
+}
+
+// splitName splits a nickname into the first and last names old formats
+// want, repeating it when it is one word.
+func splitName(name string) (string, string) {
+	first, last, ok := strings.Cut(name, " ")
+	if !ok || strings.TrimSpace(last) == "" {
+		return name, name
+	}
+
+	return first, strings.TrimSpace(last)
+}
@@ -0,0 +1,157 @@+package door
+
+import (
+	"os"
+	"path/filepath"
+	"runtime"
+	"strings"
+	"testing"
+	"time"
+)
+
+func sampleCaller() Caller {
+	return Caller{
+		Alias: "Ana Silva", UserNumber: 42, Node: 3, Width: 100, Height: 30,
+		TimeLeft: 25 * time.Minute, Security: 50, BBS: "Hammer", Sysop: "pablo", ANSI: true,
+	}
+}
+
+func lines(t *testing.T, b []byte, want int) []string {
+	t.Helper()
+	s := string(b)
+	if !strings.HasSuffix(s, "\r\n") || strings.Contains(strings.ReplaceAll(s, "\r\n", ""), "\n") {
+		t.Fatal("drop files are CRLF throughout")
+	}
+	l := strings.Split(strings.TrimSuffix(s, "\r\n"), "\r\n")
+	if len(l) != want {
+		t.Fatalf("%d lines, want %d", len(l), want)
+	}
+
+	return l
+}
+
+func TestDoor32(t *testing.T) {
+	l := lines(t, Door32(sampleCaller(), CommTelnet, 3), 11)
+	want := []string{"2", "3", "38400", "Hammer", "42", "Ana Silva", "Ana Silva", "50", "25", "1", "3"}
+	for i := range want {
+		if l[i] != want[i] {
+			t.Errorf("line %d = %q, want %q", i+1, l[i], want[i])
+		}
+	}
+	// Local doors get no handle.
+	if l := lines(t, Door32(sampleCaller(), CommLocal, 3), 11); l[0] != "0" || l[1] != "-1" {
+		t.Errorf("local: %q %q", l[0], l[1])
+	}
+}
+
+func TestDoorSys(t *testing.T) {
+	c := sampleCaller()
+	c.COMPort = "COM1:"
+	l := lines(t, DoorSys(c), 52)
+	checks := map[int]string{1: "COM1:", 4: "3", 10: "Ana Silva", 15: "50", 16: "1", 18: "1500", 19: "25", 20: "GR", 21: "30", 26: "42", 35: "pablo", 36: "Ana Silva"}
+	for n, want := range checks {
+		if l[n-1] != want {
+			t.Errorf("line %d = %q, want %q", n, l[n-1], want)
+		}
+	}
+	if l := lines(t, DoorSys(Caller{Alias: "x"}), 52); l[0] != "COM0:" {
+		t.Errorf("a native door's port = %q, want COM0:", l[0])
+	}
+}
+
+func TestDorInfoAndChain(t *testing.T) {
+	d := lines(t, DorInfo(sampleCaller()), 12)
+	if d[0] != "HAMMER" || d[6] != "ANA" || d[7] != "SILVA" || d[9] != "1" || d[11] != "25" {
+		t.Errorf("DORINFO1.DEF = %q", d)
+	}
+	c := lines(t, Chain(sampleCaller()), 30)
+	if c[0] != "42" || c[1] != "Ana Silva" || c[8] != "100" || c[9] != "30" || c[15] != "1500" || c[21] != "Hammer" {
+		t.Errorf("CHAIN.TXT = %q", c)
+	}
+}
+
+func TestDropFieldsCannotInjectLines(t *testing.T) {
+	c := sampleCaller()
+	c.Alias = "evil\r\nCOM0:\x1b[2J"
+	for name, b := range map[string][]byte{
+		"door32": Door32(c, CommLocal, 0), "doorsys": DoorSys(c), "dorinfo": DorInfo(c), "chain": Chain(c),
+	} {
+		if strings.Contains(string(b), "\x1b") || !strings.Contains(strings.ToLower(string(b)), "evilcom0") {
+			t.Errorf("%s: control characters were not stripped", name)
+		}
+	}
+}
+
+func TestStartWritesFilesAndFillsArgs(t *testing.T) {
+	spec := fakeSpec(t, "args")
+	spec.Files = map[string][]byte{"door32.sys": Door32(sampleCaller(), CommLocal, 0)}
+	spec.Vars = map[string]string{"node": "3"}
+	spec.Args = []string{"{dropdir}/door32.sys", "{node}", "{unknown}"}
+	out := &syncBuf{}
+	p, err := Start(spec, out)
+	if err != nil {
+		t.Fatal(err)
+	}
+	waitOut(t, out, "ARGS")
+	<-p.Done()
+	got := out.String()
+	if !strings.Contains(got, "door32.sys exists") || !strings.Contains(got, " 3 {unknown}") {
+		t.Fatalf("output %q", got)
+	}
+
+	bad := fakeSpec(t, "args")
+	bad.Files = map[string][]byte{"../escape": nil}
+	if _, err := Start(bad, out); err == nil {
+		t.Fatal("a drop file name with a path was accepted")
+	}
+}
+
+func TestSocketDoor(t *testing.T) {
+	if runtime.GOOS == "windows" {
+		t.Skip("socket doors need a Unix host")
+	}
+	spec := fakeSpec(t, "socket")
+	spec.Socket = true
+	out := &syncBuf{}
+	p, err := Start(spec, out)
+	if err != nil {
+		t.Fatal(err)
+	}
+	waitOut(t, out, "SOCKET HELLO")
+	_, _ = p.Write([]byte("hi"))
+	waitOut(t, out, "HI")
+	p.Hangup(5 * time.Second)
+	waitOut(t, out, "SOCKET BYE")
+}
+
+// fakeArgs is the "args" mode of the fake door: it reports its arguments.
+func fakeArgs() {
+	args := os.Args[1:]
+	report := "ARGS"
+	for _, a := range args {
+		if _, err := os.Stat(a); err == nil && filepath.Base(a) == "door32.sys" {
+			report += " door32.sys exists"
+		} else {
+			report += " " + a
+		}
+	}
+	os.Stdout.WriteString(report + "\n")
+}
+
+// fakeSocket is the "socket" mode: it talks over file descriptor 3.
+func fakeSocket() {
+	line := os.NewFile(SocketFD, "line")
+	line.WriteString("SOCKET HELLO\r\n")
+	buf := make([]byte, 64)
+	for {
+		n, err := line.Read(buf)
+		if n > 0 {
+			line.WriteString(strings.ToUpper(string(buf[:n])))
+		}
+		if err != nil {
+			line.WriteString("SOCKET BYE\r\n")
+
+			return
+		}
+	}
+}
@@ -2,11 +2,13 @@ package door 
 import (
 	"bytes"
+	"fmt"
 	"io"
 	"log"
 	"os"
 	"os/exec"
 	"path/filepath"
+	"strings"
 	"sync"
 	"time"
 )
@@ -15,11 +17,29 @@ // Spec describes one run of a door. type Spec struct {
 	Name    string   // for log lines, e.g. "nesdoor"
 	Program string   // the executable, e.g. a Python interpreter
-	Args    []string // its arguments
+	Args    []string // its arguments; {dropdir} and the Vars keys in braces are filled in
 	Dir     string   // working directory, also the door's HOME
-	TempDir string   // parent of the per-run folder holding the drop file; "" = os.TempDir()
+	TempDir string   // parent of the per-run folder holding the drop files; "" = os.TempDir()
 	Env     []string // extra environment, after the minimal one
-	Drop    Drop
+	Drop    Drop     // written as BBSDEV.DRP, and named in $BBSDEV_DRP
+
+	// Files are more drop files to write next to BBSDEV.DRP, by name
+	// (door32.sys, DOOR.SYS, DORINFO1.DEF, CHAIN.TXT...).
+	Files map[string][]byte
+	// Vars fill {name} placeholders in Args, e.g. {node} or {alias}.
+	Vars map[string]string
+	// Socket gives the door its line as a Unix socket on file descriptor 3
+	// instead of stdin and stdout, the way DOOR32.SYS socket doors expect.
+	Socket bool
+}
+
+// Session is a door in progress, local or remote: the relay feeds it the
+// caller's keys and waits for it to end.
+type Session interface {
+	Write(b []byte) (int, error)
+	Done() <-chan struct{}
+	Err() error
+	Hangup(grace time.Duration)
 }
 
 // Process is a running door. Feed it the caller's keys with Write; its
@@ -31,7 +51,10 @@ done  chan struct{} 	err   error
 }
 
-// Start writes the drop file and starts the door with its stdout on out.
+// SocketFD is the descriptor a socket door finds its line on.
+const SocketFD = 3
+
+// Start writes the drop files and starts the door with its output on out.
 // The door gets a minimal environment, not Hammer's, so secrets such as API
 // keys never reach it.
 func Start(spec Spec, out io.Writer) (*Process, error) {
@@ -49,41 +72,118 @@ // somewhere else from there. 	if abs, err := filepath.Abs(tmp); err == nil {
 		tmp = abs
 	}
+	fail := func(err error) (*Process, error) {
+		os.RemoveAll(tmp)
+
+		return nil, err
+	}
+
 	drop := filepath.Join(tmp, "BBSDEV.DRP")
 	if err := os.WriteFile(drop, spec.Drop.Bytes(), 0o600); err != nil {
-		os.RemoveAll(tmp)
+		return fail(err)
+	}
+	for name, data := range spec.Files {
+		if name != filepath.Base(name) || name == "." || name == ".." {
+			return fail(fmt.Errorf("door: drop file name %q", name))
+		}
+		if err := os.WriteFile(filepath.Join(tmp, name), data, 0o600); err != nil {
+			return fail(err)
+		}
+	}
 
-		return nil, err
+	vars := map[string]string{"dropdir": tmp}
+	for k, v := range spec.Vars {
+		vars[k] = v
+	}
+	args := make([]string, len(spec.Args))
+	for i, a := range spec.Args {
+		args[i] = expand(a, vars)
 	}
 
-	cmd := exec.Command(spec.Program, spec.Args...)
+	cmd := exec.Command(spec.Program, args...)
 	cmd.Dir = spec.Dir
 	cmd.Env = append(childEnv(drop, spec.Dir), spec.Env...)
-	cmd.Stdout = out
 	cmd.Stderr = &logWriter{prefix: "door " + spec.Name + ": "}
 	// A grandchild holding the output pipe open must not hang Wait.
 	cmd.WaitDelay = 2 * time.Second
 
-	stdin, err := cmd.StdinPipe()
-	if err != nil {
-		os.RemoveAll(tmp)
-
-		return nil, err
-	}
-	if err := cmd.Start(); err != nil {
-		os.RemoveAll(tmp)
+	var (
+		stdin  io.WriteCloser
+		copied chan struct{} // closed when the socket's output is drained
+		line   io.ReadWriteCloser
+	)
+	if spec.Socket {
+		parent, child, err := socketPair()
+		if err != nil {
+			return fail(err)
+		}
+		cmd.ExtraFiles = []*os.File{child} // file descriptor 3
+		cmd.Stdout = &logWriter{prefix: "door " + spec.Name + " (stdout): "}
+		if err := cmd.Start(); err != nil {
+			child.Close()
+			parent.Close()
 
-		return nil, err
+			return fail(err)
+		}
+		child.Close()
+		line, stdin = parent, halfCloser{parent}
+		copied = make(chan struct{})
+		go func() {
+			defer close(copied)
+			_, _ = io.Copy(out, parent)
+		}()
+	} else {
+		cmd.Stdout = out
+		if stdin, err = cmd.StdinPipe(); err != nil {
+			return fail(err)
+		}
+		if err := cmd.Start(); err != nil {
+			return fail(err)
+		}
 	}
 
 	p := &Process{cmd: cmd, stdin: stdin, done: make(chan struct{})}
 	go func() {
 		p.err = cmd.Wait()
+		if copied != nil {
+			select { // the door is gone; take what it last wrote, briefly
+			case <-copied:
+			case <-time.After(2 * time.Second):
+			}
+			line.Close()
+		}
 		os.RemoveAll(tmp)
 		close(p.done)
 	}()
 
 	return p, nil
+}
+
+// expand fills {name} placeholders from vars; unknown ones stay as they are.
+func expand(s string, vars map[string]string) string {
+	if !strings.Contains(s, "{") {
+		return s
+	}
+	pairs := make([]string, 0, 2*len(vars))
+	for k, v := range vars {
+		pairs = append(pairs, "{"+k+"}", v)
+	}
+
+	return strings.NewReplacer(pairs...).Replace(s)
+}
+
+// halfCloser closes only our sending side of the socket, so the door sees
+// the caller hang up but can still write its goodbye.
+type halfCloser struct{ c io.ReadWriteCloser }
+
+func (h halfCloser) Write(b []byte) (int, error) { return h.c.Write(b) }
+
+func (h halfCloser) Close() error {
+	if cw, ok := h.c.(interface{ CloseWrite() error }); ok {
+		return cw.CloseWrite()
+	}
+
+	return h.c.Close()
 }
 
 // Write sends the caller's input to the door.
@@ -27,6 +27,16 @@ // fakeDoor greets the caller by the drop file's alias, then echoes input in // upper case until "q". On hang-up, "save" prints SAVED and exits, while
 // "stubborn" ignores the hang-up and never exits.
 func fakeDoor(mode string) {
+	switch mode {
+	case "args":
+		fakeArgs()
+
+		return
+	case "socket":
+		fakeSocket()
+
+		return
+	}
 	data, err := os.ReadFile(os.Getenv("BBSDEV_DRP"))
 	if err != nil {
 		fmt.Println("NO DROP FILE")
@@ -0,0 +1,14 @@+//go:build !unix
+
+package door
+
+import (
+	"errors"
+	"io"
+	"os"
+)
+
+// socketPair is not available off Unix; socket doors need a Unix host.
+func socketPair() (io.ReadWriteCloser, *os.File, error) {
+	return nil, nil, errors.New("door: socket doors need a Unix host")
+}
@@ -0,0 +1,31 @@+//go:build unix
+
+package door
+
+import (
+	"io"
+	"net"
+	"os"
+	"syscall"
+)
+
+// socketPair returns a connected pair of Unix stream sockets: ours as a
+// connection, the door's as a file to hand to the child process.
+func socketPair() (io.ReadWriteCloser, *os.File, error) {
+	fds, err := syscall.Socketpair(syscall.AF_UNIX, syscall.SOCK_STREAM, 0)
+	if err != nil {
+		return nil, nil, err
+	}
+	syscall.CloseOnExec(fds[0])
+
+	ours := os.NewFile(uintptr(fds[0]), "door-line")
+	conn, err := net.FileConn(ours)
+	ours.Close() // FileConn made its own copy
+	if err != nil {
+		syscall.Close(fds[1])
+
+		return nil, nil, err
+	}
+
+	return conn, os.NewFile(uintptr(fds[1]), "door-socket"), nil
+}