@@ -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
+}