@@ -64,6 +64,12 @@ // Telnet opens a keyless, read-only door for telnet clients (telnet.go). Telnet *TelnetConfig `yaml:"telnet,omitempty"`
// Web puts a keyless, read-only terminal in the browser (web.go).
Web *WebConfig `yaml:"web,omitempty"`
+ // Doors are the door games on the [d] menu (doors.go).
+ Doors []DoorConfig `yaml:"doors,omitempty"`
+ // DOSBox runs DOS doors (doors_dos.go).
+ DOSBox *DOSBoxConfig `yaml:"dosbox,omitempty"`
+ // DoorServers are remote door servers (doors_remote.go).
+ DoorServers *DoorServersConfig `yaml:"doorServers,omitempty"`
// Version is the binary version, injected by main; not a config key.
Version string `yaml:"-"`
@@ -174,6 +180,9 @@ if err := c.Forums.validate(); err != nil { return err
}
if err := c.Telnet.validate(); err != nil {
+ return err
+ }
+ if err := validateDoors(c.Doors); err != nil {
return err
}
if c.Files != nil && (c.Files.QuotaBytes < 0 || c.Files.MaxFileInput < 0) {
@@ -0,0 +1,391 @@+package bbs
+
+import (
+ "fmt"
+ "log"
+ "os"
+ "os/exec"
+ "path/filepath"
+ "runtime"
+ "strconv"
+ "strings"
+ "time"
+
+ "github.com/runawaydevil/hammer/internal/cp437"
+ "github.com/runawaydevil/hammer/internal/door"
+)
+
+// Door types.
+const (
+ DoorNative = "native" // a program on this host
+ DoorDOS = "dos" // a DOS door run in DOSBox
+ DoorANet = "anet" // a door on A-Net Online's game server (rlogin)
+ DoorDoorParty = "doorparty" // a door on DoorParty (rlogin through SSH)
+ DoorBBSLink = "bbslink" // a door on BBSLink (telnet after an HTTP token)
+)
+
+// Drop file kinds, and the names doors look for.
+var dropFileNames = map[string]string{
+ "bbsdev": "", // always written as BBSDEV.DRP
+ "door32": "door32.sys",
+ "doorsys": "DOOR.SYS",
+ "dorinfo": "DORINFO1.DEF",
+ "chain": "CHAIN.TXT",
+}
+
+const (
+ defaultDoorNodes = 4
+ defaultDoorMinutes = 25
+)
+
+// DoorConfig is one entry of "bbs.doors".
+type DoorConfig struct {
+ Name string `yaml:"name"`
+ Description string `yaml:"description"`
+ Type string `yaml:"type"` // native (default), dos, anet, doorparty, bbslink
+
+ // native
+ Command string `yaml:"command"` // the program
+ Args []string `yaml:"args"` // {dropdir}, {node}, {alias}, {usernum}, {timeleft}, {width}, {height}
+ Dir string `yaml:"dir"` // working directory (native and dos)
+ Dropfile string `yaml:"dropfile"` // bbsdev (default), door32, doorsys, dorinfo, chain
+ IO string `yaml:"io"` // stdio (default) or socket (descriptor 3)
+ Charset string `yaml:"charset"` // what the door speaks: cp437 (default) or utf-8
+
+ // dos
+ Exe string `yaml:"exe"` // the DOS command line, run in Dir as drive C:, e.g. "LORD.EXE"
+
+ // remote: the door's code on the door server, e.g. "LORD" or "TW2002"
+ Door string `yaml:"door"`
+
+ Nodes int `yaml:"nodes"` // people playing at once (4)
+ MaxMinutes int `yaml:"maxMinutes"` // per sitting (25)
+ Anonymous bool `yaml:"anonymous"` // keyless visitors may play too
+}
+
+func validateDoors(doors []DoorConfig) error {
+ seen := map[string]bool{}
+ for i, d := range doors {
+ name := strings.TrimSpace(d.Name)
+ if name == "" {
+ return fmt.Errorf("bbs.doors[%d]: a door needs a name", i)
+ }
+ if seen[strings.ToLower(name)] {
+ return fmt.Errorf("bbs.doors: %q is listed twice", name)
+ }
+ seen[strings.ToLower(name)] = true
+
+ switch doorType(d) {
+ case DoorNative:
+ if d.Command == "" {
+ return fmt.Errorf("bbs.doors %q: a native door needs a command", name)
+ }
+ case DoorDOS:
+ if d.Dir == "" || d.Exe == "" {
+ return fmt.Errorf("bbs.doors %q: a DOS door needs dir and exe", name)
+ }
+ case DoorANet, DoorDoorParty, DoorBBSLink:
+ if d.Door == "" {
+ return fmt.Errorf("bbs.doors %q: a %s door needs the door's code (door:)", name, d.Type)
+ }
+ default:
+ return fmt.Errorf("bbs.doors %q: unknown type %q", name, d.Type)
+ }
+ if _, ok := dropFileNames[dropKind(d)]; !ok {
+ return fmt.Errorf("bbs.doors %q: unknown dropfile %q", name, d.Dropfile)
+ }
+ if d.IO != "" && d.IO != "stdio" && d.IO != "socket" {
+ return fmt.Errorf("bbs.doors %q: io must be stdio or socket", name)
+ }
+ if d.Charset != "" && d.Charset != CharsetCP437 && d.Charset != CharsetUTF8 {
+ return fmt.Errorf("bbs.doors %q: charset must be cp437 or utf-8", name)
+ }
+ if d.Nodes < 0 || d.MaxMinutes < 0 {
+ return fmt.Errorf("bbs.doors %q: nodes and maxMinutes must be >= 0", name)
+ }
+ }
+
+ return nil
+}
+
+func doorType(d DoorConfig) string {
+ if d.Type == "" {
+ return DoorNative
+ }
+
+ return d.Type
+}
+
+func dropKind(d DoorConfig) string {
+ if d.Dropfile == "" {
+ return "bbsdev"
+ }
+
+ return d.Dropfile
+}
+
+// doorEntry is a configured door that can run on this host.
+type doorEntry struct {
+ cfg DoorConfig
+ slots *door.Slots
+ maxTime time.Duration
+ program string // native: the resolved command
+}
+
+// resolveDoors checks each configured door against the host and keeps the
+// ones that can run, logging why the others cannot.
+func (s *Server) resolveDoors(doors []DoorConfig) []*doorEntry {
+ var out []*doorEntry
+ for _, d := range doors {
+ e := &doorEntry{cfg: d}
+ nodes := d.Nodes
+ if nodes <= 0 {
+ nodes = defaultDoorNodes
+ }
+ e.slots = door.NewSlots(nodes)
+ minutes := d.MaxMinutes
+ if minutes <= 0 {
+ minutes = defaultDoorMinutes
+ }
+ e.maxTime = time.Duration(minutes) * time.Minute
+
+ switch doorType(d) {
+ case DoorNative:
+ if d.IO == "socket" && runtime.GOOS == "windows" {
+ log.Printf("bbs: door %q off: socket doors need a Unix host", d.Name)
+
+ continue
+ }
+ e.program = findProgram(d.Command, d.Dir)
+ if e.program == "" {
+ log.Printf("bbs: door %q off: %s not found", d.Name, d.Command)
+
+ continue
+ }
+ case DoorDOS:
+ if s.dosbox == "" {
+ log.Printf("bbs: door %q off: DOSBox is not installed or bbs.dosbox is not set", d.Name)
+
+ continue
+ }
+ case DoorANet, DoorDoorParty, DoorBBSLink:
+ if !s.doorServerReady(doorType(d)) {
+ log.Printf("bbs: door %q off: bbs.doorServers.%s is not set up", d.Name, doorType(d))
+
+ continue
+ }
+ }
+ out = append(out, e)
+ }
+
+ return out
+}
+
+// findProgram resolves a door's command: a path, relative to the door's
+// folder or absolute, or a name on the PATH.
+func findProgram(command, dir string) string {
+ if strings.ContainsRune(command, '/') || strings.ContainsRune(command, filepath.Separator) {
+ p := command
+ if !filepath.IsAbs(p) && dir != "" {
+ p = filepath.Join(dir, p)
+ }
+ if _, err := os.Stat(p); err == nil {
+ return p
+ }
+
+ return ""
+ }
+ if p, err := exec.LookPath(command); err == nil {
+ return p
+ }
+
+ return ""
+}
+
+// doorsOn reports whether the menu offers doors.
+func (st *session) doorsOn() bool {
+ return st.srv != nil && len(st.srv.doors) > 0
+}
+
+// doorsMenu lists the doors and plays the one chosen.
+func (st *session) doorsMenu() {
+ for st.alive() {
+ st.clear()
+ rows := []string{""}
+ for i, e := range st.srv.doors {
+ busy := e.slots.InUse()
+ extra := e.cfg.Description
+ if busy > 0 {
+ extra = st.tf("BBS_DOORS_PLAYING", busy)
+ }
+ rows = append(rows, st.menuEntry(strconv.Itoa(i+1), e.cfg.Name, extra))
+ }
+ rows = append(rows, "")
+ box := st.th.Box(st.th.Heading(st.t("BBS_TITLE_DOORS")), rows, 56)
+ box = append(box, st.th.HintBar([][2]string{{fmt.Sprintf("1-%d", len(st.srv.doors)), st.t("BBS_GAMES_PLAY")}, {"b", st.t("BBS_BACK")}}))
+ st.screen(box)
+
+ in := strings.ToLower(strings.TrimSpace(st.readLine("doors> ")))
+ if in == "" || in == "b" || in == "q" {
+ return
+ }
+ n, err := strconv.Atoi(in)
+ if err != nil || n < 1 || n > len(st.srv.doors) {
+ st.note = st.t("BBS_FORUM_UNKNOWN")
+
+ continue
+ }
+ st.playEntry(st.srv.doors[n-1])
+ }
+}
+
+// playEntry plays one configured door.
+func (st *session) playEntry(e *doorEntry) {
+ if st.anon && !e.cfg.Anonymous {
+ st.note = st.t("BBS_GAMES_ANON")
+
+ return
+ }
+
+ play := doorPlay{
+ name: "door " + e.cfg.Name,
+ slots: e.slots,
+ maxPlayers: e.slots.Size(),
+ maxTime: e.maxTime,
+ }
+ // Classic doors speak CP437: a UTF-8 terminal gets their output decoded
+ // and its keys encoded.
+ cp := e.cfg.Charset != CharsetUTF8
+ if cp {
+ play.encode = cp437.EncodeBytes
+ }
+
+ switch doorType(e.cfg) {
+ case DoorNative:
+ play.start = func(node int, deadline time.Time) (door.Session, error) {
+ return door.Start(st.nativeSpec(e, node, deadline), st.doorWriter(cp))
+ }
+ case DoorDOS:
+ play.start = func(node int, deadline time.Time) (door.Session, error) {
+ return st.startDOS(e, node, deadline, st.doorWriter(cp))
+ }
+ default:
+ play.start = func(node int, deadline time.Time) (door.Session, error) {
+ return st.startRemote(e, node, deadline, st.doorWriter(cp))
+ }
+ }
+ st.playDoor(play)
+}
+
+// doorCaller is what the classic drop files say about this caller.
+func (st *session) doorCaller(node int, deadline time.Time) door.Caller {
+ num := 0
+ if !st.anon && st.srv.store != nil {
+ if n, err := st.srv.store.Number(st.fp); err == nil {
+ num = int(n)
+ }
+ }
+
+ return door.Caller{
+ Alias: st.profile.Nick,
+ UserNumber: num,
+ Node: node,
+ Width: st.width0(),
+ Height: st.height0(),
+ TimeLeft: time.Until(deadline),
+ Security: 50,
+ BBS: "Hammer",
+ Sysop: "sysop",
+ ANSI: st.level != 0,
+ }
+}
+
+// nativeSpec builds the launch of a native door.
+func (st *session) nativeSpec(e *doorEntry, node int, deadline time.Time) door.Spec {
+ c := st.doorCaller(node, deadline)
+ socket := e.cfg.IO == "socket"
+ files := map[string][]byte{}
+ switch dropKind(e.cfg) {
+ case "door32":
+ if socket {
+ files["door32.sys"] = door.Door32(c, door.CommTelnet, door.SocketFD)
+ } else {
+ files["door32.sys"] = door.Door32(c, door.CommLocal, 0)
+ }
+ case "doorsys":
+ files["DOOR.SYS"] = door.DoorSys(c)
+ case "dorinfo":
+ files["DORINFO1.DEF"] = door.DorInfo(c)
+ case "chain":
+ files["CHAIN.TXT"] = door.Chain(c)
+ }
+
+ return door.Spec{
+ Name: e.cfg.Name,
+ Program: e.program,
+ Args: e.cfg.Args,
+ Dir: e.cfg.Dir,
+ TempDir: defaultDoorTempDir,
+ Drop: st.bbsdevDrop(node, deadline, e.cfg.Charset != CharsetUTF8),
+ Files: files,
+ Vars: st.doorVars(c),
+ Socket: socket,
+ }
+}
+
+// bbsdevDrop is the BBSDEV.DRP every door gets.
+func (st *session) bbsdevDrop(node int, deadline time.Time, cp bool) door.Drop {
+ enc := "UTF-8"
+ if cp {
+ enc = "IBM437"
+ }
+
+ return door.Drop{
+ Alias: st.profile.Nick,
+ UserKey: st.fp,
+ Width: st.width0(),
+ Height: st.height0(),
+ Deadline: deadline,
+ Language: "en",
+ Encoding: enc,
+ Board: "Hammer",
+ Sysop: "sysop",
+ Node: node,
+ }
+}
+
+func (st *session) doorVars(c door.Caller) map[string]string {
+ return map[string]string{
+ "node": strconv.Itoa(c.Node),
+ "alias": c.Alias,
+ "usernum": strconv.Itoa(max(c.UserNumber, 1)),
+ "timeleft": strconv.Itoa(max(int(c.TimeLeft/time.Minute), 1)),
+ "width": strconv.Itoa(c.Width),
+ "height": strconv.Itoa(c.Height),
+ }
+}
+
+// doorWriter is where a door's output goes: the terminal, decoded from
+// CP437 when the door speaks CP437 and the terminal does not.
+func (st *session) doorWriter(cp bool) doorOutputWriter {
+ return doorOutputWriter{st: st, decode: cp}
+}
+
+// doorOutputWriter writes a door's output to the session whole, under the
+// write lock, decoding CP437 for UTF-8 terminals when asked.
+type doorOutputWriter struct {
+ st *session
+ decode bool
+}
+
+func (w doorOutputWriter) Write(b []byte) (int, error) {
+ if w.decode && !w.st.cp437.Load() {
+ if _, err := (doorOutput{w.st}).Write([]byte(cp437.Decode(b))); err != nil {
+ return 0, err
+ }
+
+ return len(b), nil
+ }
+
+ return doorOutput{w.st}.Write(b)
+}
@@ -0,0 +1,271 @@+package bbs
+
+import (
+ "errors"
+ "fmt"
+ "io"
+ "log"
+ "net"
+ "os"
+ "os/exec"
+ "path/filepath"
+ "strings"
+ "sync"
+ "time"
+
+ "github.com/runawaydevil/hammer/internal/door"
+)
+
+// DOS doors run in DOSBox with no screen. Hammer listens on a local TCP
+// port and DOSBox connects its COM1 to it as a null modem; a FOSSIL driver
+// inside DOS (BNU or X00, which the sysop supplies) gives the door its
+// serial line. The pattern is the one ANetBBS and binkterm-php use.
+
+// DOSBoxConfig is the optional "bbs.dosbox" block.
+type DOSBoxConfig struct {
+ // Program is DOSBox. Absent = "dosbox" on the PATH.
+ Program string `yaml:"program"`
+ // FossilDir holds the FOSSIL driver, mounted as D:. Absent = none.
+ FossilDir string `yaml:"fossilDir"`
+ // FossilCommand loads it, e.g. "BNU /P1" (with BNU.COM in FossilDir).
+ FossilCommand string `yaml:"fossilCommand"`
+}
+
+// dosSettings is a resolved DOSBox setup.
+type dosSettings struct {
+ program string
+ xvfb string // xvfb-run, when there is no display to draw on
+ fossilDir string
+ fossilCmd string
+}
+
+// resolveDOSBox finds DOSBox, and xvfb-run when there is no display. It
+// returns "" (DOS doors off) when either is missing.
+func resolveDOSBox(c *DOSBoxConfig) string {
+ s := resolveDOSSettings(c)
+ if s == nil {
+ return ""
+ }
+
+ return s.program
+}
+
+var dosConfigs sync.Map // program -> *dosSettings, filled by resolveDOSSettings
+
+func resolveDOSSettings(c *DOSBoxConfig) *dosSettings {
+ if c == nil {
+ return nil
+ }
+ prog := c.Program
+ if prog == "" {
+ prog = "dosbox"
+ }
+ path := findProgram(prog, "")
+ if path == "" {
+ log.Printf("bbs: DOS doors off: %s not found", prog)
+
+ return nil
+ }
+ s := &dosSettings{program: path, fossilDir: c.FossilDir, fossilCmd: c.FossilCommand}
+ if os.Getenv("DISPLAY") == "" {
+ if s.xvfb = findProgram("xvfb-run", ""); s.xvfb == "" {
+ log.Printf("bbs: DOS doors off: no display and no xvfb-run")
+
+ return nil
+ }
+ }
+ dosConfigs.Store(path, s)
+
+ return s
+}
+
+func (st *session) dosSettings() *dosSettings {
+ if v, ok := dosConfigs.Load(st.srv.dosbox); ok {
+ return v.(*dosSettings)
+ }
+
+ return nil
+}
+
+// dosConnectWait is how long DOSBox gets to start and dial in.
+const dosConnectWait = 30 * time.Second
+
+// startDOS starts a DOS door and returns once DOSBox has connected.
+func (st *session) startDOS(e *doorEntry, node int, deadline time.Time, out io.Writer) (door.Session, error) {
+ ds := st.dosSettings()
+ if ds == nil {
+ return nil, errors.New("DOSBox is not set up")
+ }
+
+ if err := os.MkdirAll(defaultDoorTempDir, 0o700); err != nil {
+ return nil, err
+ }
+ tmp, err := os.MkdirTemp(defaultDoorTempDir, "dos-*")
+ if err != nil {
+ return nil, err
+ }
+ if abs, err := filepath.Abs(tmp); err == nil {
+ tmp = abs
+ }
+ cleanup := func() { os.RemoveAll(tmp) }
+
+ // Drop files on E:. DOS doors behind a FOSSIL want COM1, or they fall
+ // back to local output.
+ c := st.doorCaller(node, deadline)
+ c.COMPort, c.Baud = "COM1:", 38400
+ files := map[string][]byte{"DOOR.SYS": door.DoorSys(c), "DORINFO1.DEF": door.DorInfo(c), "CHAIN.TXT": door.Chain(c)}
+ files["DOOR32.SYS"] = door.Door32(c, door.CommSerial, 0)
+ for name, data := range files {
+ if err := os.WriteFile(filepath.Join(tmp, name), data, 0o600); err != nil {
+ cleanup()
+
+ return nil, err
+ }
+ }
+
+ ln, err := net.Listen("tcp", "127.0.0.1:0")
+ if err != nil {
+ cleanup()
+
+ return nil, err
+ }
+ port := ln.Addr().(*net.TCPAddr).Port
+
+ dir, err := filepath.Abs(e.cfg.Dir)
+ if err != nil {
+ ln.Close()
+ cleanup()
+
+ return nil, err
+ }
+ conf := dosboxConf(dir, tmp, ds, port, door.Expand(e.cfg.Exe, st.doorVars(c)), e.slots.Size() <= 1)
+ confPath := filepath.Join(tmp, "hammer.conf")
+ if err := os.WriteFile(confPath, []byte(conf), 0o600); err != nil {
+ ln.Close()
+ cleanup()
+
+ return nil, err
+ }
+
+ args := []string{"-conf", confPath, "-noconsole", "-exit"}
+ cmd := exec.Command(ds.program, args...)
+ if ds.xvfb != "" {
+ cmd = exec.Command(ds.xvfb, append([]string{"-a", ds.program}, args...)...)
+ }
+ cmd.Dir = tmp
+ cmd.Env = append(door.Env(filepath.Join(tmp, "DOOR.SYS"), tmp), "SDL_AUDIODRIVER=dummy")
+ cmd.Stdout = io.Discard
+ cmd.Stderr = io.Discard
+ if err := cmd.Start(); err != nil {
+ ln.Close()
+ cleanup()
+
+ return nil, err
+ }
+
+ // Wait for DOSBox to dial in, or to give up.
+ type accepted struct {
+ c net.Conn
+ err error
+ }
+ got := make(chan accepted, 1)
+ go func() {
+ _ = ln.(*net.TCPListener).SetDeadline(time.Now().Add(dosConnectWait))
+ c, err := ln.Accept()
+ got <- accepted{c, err}
+ }()
+ a := <-got
+ ln.Close()
+ if a.err != nil {
+ _ = cmd.Process.Kill()
+ _ = cmd.Wait()
+ cleanup()
+
+ return nil, fmt.Errorf("DOSBox did not connect: %w", a.err)
+ }
+
+ return newLineSession(a.c, out, func() {
+ // The door hung up (the bridge closed); DOSBox exits right after.
+ done := make(chan struct{})
+ go func() { _ = cmd.Wait(); close(done) }()
+ select {
+ case <-done:
+ case <-time.After(10 * time.Second):
+ _ = cmd.Process.Kill()
+ <-done
+ }
+ cleanup()
+ }), nil
+}
+
+// dosboxConf is the DOSBox configuration for one sitting: COM1 dials the
+// bridge, C: is the door, D: the FOSSIL driver, E: the node's drop files.
+// copyDrop copies the drop files into the door's folder too, for single-node
+// doors that only look there.
+func dosboxConf(doorDir, nodeDir string, ds *dosSettings, port int, exe string, copyDrop bool) string {
+ var b strings.Builder
+ fmt.Fprintf(&b, "[sdl]\noutput=surface\n\n[cpu]\ncycles=auto\n\n[mixer]\nnosound=true\n\n")
+ fmt.Fprintf(&b, "[serial]\nserial1=nullmodem server:127.0.0.1 port:%d transparent:1\nserial2=disabled\n\n", port)
+ fmt.Fprintf(&b, "[dos]\nxms=true\nems=true\n\n[autoexec]\n@echo off\n")
+ fmt.Fprintf(&b, "mount c \"%s\"\n", doorDir)
+ if ds.fossilDir != "" {
+ fmt.Fprintf(&b, "mount d \"%s\"\n", ds.fossilDir)
+ }
+ fmt.Fprintf(&b, "mount e \"%s\"\n", nodeDir)
+ if ds.fossilDir != "" && ds.fossilCmd != "" {
+ fmt.Fprintf(&b, "d:\n%s\n", ds.fossilCmd)
+ }
+ if copyDrop {
+ for _, name := range []string{"DOOR.SYS", "DORINFO1.DEF", "CHAIN.TXT", "DOOR32.SYS"} {
+ fmt.Fprintf(&b, "copy e:\\%s c:\\%s >nul\n", name, name)
+ }
+ }
+ fmt.Fprintf(&b, "c:\n%s\nexit\n", exe)
+
+ return b.String()
+}
+
+// lineSession is a door on the other end of a byte stream: a DOSBox serial
+// line, or a remote door server.
+type lineSession struct {
+ c net.Conn
+ done chan struct{}
+ err error
+ once sync.Once
+ onEnd func() // runs once the line has closed, before Done
+}
+
+// newLineSession copies c to out until the line closes; onEnd (may be nil)
+// runs then, before Done.
+func newLineSession(c net.Conn, out io.Writer, onEnd func()) *lineSession {
+ s := &lineSession{c: c, done: make(chan struct{}), onEnd: onEnd}
+ go func() {
+ _, err := io.Copy(out, c)
+ if err != nil && !errors.Is(err, net.ErrClosed) {
+ s.err = err
+ }
+ c.Close()
+ if s.onEnd != nil {
+ s.onEnd()
+ }
+ close(s.done)
+ }()
+
+ return s
+}
+
+func (s *lineSession) Write(b []byte) (int, error) { return s.c.Write(b) }
+
+func (s *lineSession) Done() <-chan struct{} { return s.done }
+
+func (s *lineSession) Err() error { return s.err }
+
+// Hangup drops the line, the way losing carrier would, and waits for the
+// door's end.
+func (s *lineSession) Hangup(grace time.Duration) {
+ s.once.Do(func() { s.c.Close() })
+ select {
+ case <-s.done:
+ case <-time.After(grace + 10*time.Second):
+ }
+}
@@ -0,0 +1,47 @@+package bbs
+
+import (
+ "os/exec"
+ "strings"
+ "testing"
+)
+
+func TestDOSBoxConf(t *testing.T) {
+ conf := dosboxConf("/doors/lord", "/tmp/node1", &dosSettings{fossilDir: "/fossil", fossilCmd: "BNU /P1"}, 5050, "LORD.EXE", true)
+ for _, want := range []string{
+ "serial1=nullmodem server:127.0.0.1 port:5050 transparent:1",
+ `mount c "/doors/lord"`, `mount d "/fossil"`, `mount e "/tmp/node1"`,
+ "d:\nBNU /P1", `copy e:\DOOR.SYS c:\DOOR.SYS`, "c:\nLORD.EXE\nexit",
+ } {
+ if !strings.Contains(conf, want) {
+ t.Errorf("conf lacks %q:\n%s", want, conf)
+ }
+ }
+ if strings.Contains(dosboxConf("/d", "/n", &dosSettings{}, 1, "X", false), "copy e:") {
+ t.Error("multi-node doors must read their drop files from E:")
+ }
+}
+
+// TestDOSDoorThroughDOSBox runs real DOSBox when it is installed: a DOS
+// command writing to COM1 must reach the caller through the bridge.
+func TestDOSDoorThroughDOSBox(t *testing.T) {
+ if _, err := exec.LookPath("dosbox"); err != nil {
+ t.Skip("dosbox not installed")
+ }
+ if _, err := exec.LookPath("xvfb-run"); err != nil {
+ t.Skip("xvfb-run not installed")
+ }
+ srv := testServer(t, &Config{
+ DOSBox: &DOSBoxConfig{},
+ Doors: []DoorConfig{{Name: "Echo", Type: DoorDOS, Dir: t.TempDir(), Exe: "ECHO DOS SAYS HELLO>COM1", Nodes: 1}},
+ })
+ if len(srv.doors) != 1 {
+ t.Fatal("the DOS door was not offered")
+ }
+ ana := login(t, listen(t, srv), "ana", newSigner(t))
+ ana.send("d1\r")
+ // DOSBox 0.74 drops the last bytes of a line when it exits right after
+ // writing it; a real door keeps running, so only the start is checked.
+ ana.waitForWithin("DOS SAYS", 40)
+ ana.waitForWithin("DOORS", 40)
+}
@@ -0,0 +1,302 @@+package bbs
+
+import (
+ "bufio"
+ "crypto/md5"
+ "encoding/hex"
+ "fmt"
+ "io"
+ "net"
+ "net/http"
+ "net/http/httptest"
+ "os"
+ "strings"
+ "sync"
+ "testing"
+
+ "github.com/gliderlabs/ssh"
+ gossh "golang.org/x/crypto/ssh"
+
+ "github.com/runawaydevil/hammer/internal/rlogin"
+)
+
+// fakeClassicDoor reads the DOOR32.SYS named in args, greets the caller
+// in CP437 box drawing, echoes keys in upper case and leaves on "q".
+func fakeClassicDoor(args []string) {
+ data, err := os.ReadFile(args[0])
+ if err != nil {
+ fmt.Print("NO DROP FILE\r\n")
+
+ return
+ }
+ l := strings.Split(string(data), "\r\n")
+ // \xc9\xcd\xbb is ╔═╗ in CP437.
+ fmt.Printf("\xc9\xcd\xbb CLASSIC %s NODE %s\r\n", l[6], l[10])
+ r := bufio.NewReader(os.Stdin)
+ for {
+ b, err := r.ReadByte()
+ if err != nil || b == 'q' {
+ fmt.Print("CLASSIC BYE\r\n")
+
+ return
+ }
+ fmt.Print(strings.ToUpper(string(b)))
+ }
+}
+
+func TestNativeClassicDoor(t *testing.T) {
+ srv := testServer(t, &Config{Doors: []DoorConfig{{
+ Name: "Fake Door",
+ Description: "a test door",
+ Command: os.Args[0],
+ Args: []string{"-fake-classic-door", "{dropdir}/door32.sys"},
+ Dropfile: "door32",
+ Nodes: 2,
+ }}})
+ addr := listen(t, srv)
+
+ ana := login(t, addr, "ana", newSigner(t))
+ ana.waitFor("classic door games")
+ ana.send("d")
+ ana.waitFor("DOORS")
+ ana.waitFor("Fake Door")
+ ana.send("1\r")
+ // The door wrote CP437; an xterm sees it as UTF-8.
+ ana.waitFor("╔═╗ CLASSIC ana NODE 1")
+ ana.send("x")
+ ana.waitFor("X")
+ mark := ana.mark()
+ ana.send("q")
+ ana.waitFor("CLASSIC BYE")
+ ana.waitSince(mark, "DOORS")
+
+ // Keyless visitors may not play a door unless it says so.
+ srv2 := testServer(t, &Config{AllowAnonymous: true, Doors: srv.cfg.Doors})
+ guest := login(t, listen(t, srv2), "guest", nil)
+ guest.send("d1\r")
+ guest.waitFor("need an SSH key")
+}
+
+func TestDoorsHiddenWhenNoneRun(t *testing.T) {
+ srv := testServer(t, &Config{Doors: []DoorConfig{{Name: "Missing", Command: "/no/such/door"}}})
+ if len(srv.doors) != 0 {
+ t.Fatal("a door whose program is missing was offered")
+ }
+ ana := login(t, listen(t, srv), "ana", newSigner(t))
+ ana.never("classic door games")
+}
+
+func TestDoorConfigValidation(t *testing.T) {
+ bad := [][]DoorConfig{
+ {{Name: ""}},
+ {{Name: "a", Command: "x"}, {Name: "A", Command: "y"}},
+ {{Name: "a"}},
+ {{Name: "a", Type: "dos", Dir: "/d"}},
+ {{Name: "a", Type: "anet"}},
+ {{Name: "a", Type: "teleport"}},
+ {{Name: "a", Command: "x", Dropfile: "foo.sys"}},
+ {{Name: "a", Command: "x", IO: "carrier pigeon"}},
+ }
+ for _, doors := range bad {
+ if validateDoors(doors) == nil {
+ t.Errorf("accepted %+v", doors)
+ }
+ }
+ if err := validateDoors([]DoorConfig{{Name: "LORD", Type: "bbslink", Door: "lord"}}); err != nil {
+ t.Fatal(err)
+ }
+}
+
+// fakeDoorServer is an rlogin door server: it records the handshake and
+// greets the caller.
+type fakeDoorServer struct {
+ addr string
+ mu sync.Mutex
+ hs rlogin.Handshake
+}
+
+func newFakeDoorServer(t *testing.T) *fakeDoorServer {
+ t.Helper()
+ ln, err := net.Listen("tcp", "127.0.0.1:0")
+ if err != nil {
+ t.Fatal(err)
+ }
+ t.Cleanup(func() { ln.Close() })
+ f := &fakeDoorServer{addr: ln.Addr().String()}
+ go func() {
+ for {
+ c, err := ln.Accept()
+ if err != nil {
+ return
+ }
+ go func() {
+ defer c.Close()
+ hs, r, err := rlogin.Accept(c, 0)
+ if err != nil {
+ return
+ }
+ f.mu.Lock()
+ f.hs = hs
+ f.mu.Unlock()
+ fmt.Fprintf(c, "REMOTE %s %s\r\n", hs.ServerUser, hs.Terminal)
+ buf := make([]byte, 1)
+ for {
+ if _, err := r.Read(buf); err != nil || buf[0] == 'q' {
+ return
+ }
+ c.Write([]byte(strings.ToUpper(string(buf))))
+ }
+ }()
+ }
+ }()
+
+ return f
+}
+
+func (f *fakeDoorServer) handshake() rlogin.Handshake {
+ f.mu.Lock()
+ defer f.mu.Unlock()
+
+ return f.hs
+}
+
+func TestANetDoor(t *testing.T) {
+ fake := newFakeDoorServer(t)
+ srv := testServer(t, &Config{
+ DoorServers: &DoorServersConfig{ANet: &ANetConfig{Host: fake.addr, Tag: "HAMR", Secret: "s3cret"}},
+ Doors: []DoorConfig{{Name: "LORD", Type: DoorANet, Door: "LORD408"}},
+ })
+ ana := login(t, listen(t, srv), "ana", newSigner(t))
+ ana.send("d1\r")
+ ana.waitFor("REMOTE ana-HAMR xtrn=LORD408")
+ ana.send("z")
+ ana.waitFor("Z")
+ hs := fake.handshake()
+ if len(hs.ClientUser) != 16 {
+ t.Fatalf("password field = %q", hs.ClientUser)
+ }
+ ana.send("q")
+ ana.waitFor("DOORS")
+
+ // The same key gets the same password next time.
+ if remotePassword("s3cret", "fp:x") != remotePassword("s3cret", "fp:x") || remotePassword("s3cret", "fp:x") == remotePassword("s3cret", "fp:y") {
+ t.Fatal("passwords are not stable per key")
+ }
+}
+
+func TestDoorPartyDoorThroughSSH(t *testing.T) {
+ fake := newFakeDoorServer(t)
+
+ // A stand-in for DoorParty's SSH tunnel.
+ hostKey := newSigner(t)
+ tunnel := &ssh.Server{
+ PasswordHandler: func(ctx ssh.Context, pw string) bool { return ctx.User() == "dpuser" && pw == "dppass" },
+ LocalPortForwardingCallback: func(ssh.Context, string, uint32) bool { return true },
+ ChannelHandlers: map[string]ssh.ChannelHandler{
+ "session": ssh.DefaultSessionHandler,
+ "direct-tcpip": ssh.DirectTCPIPHandler,
+ },
+ }
+ tunnel.AddHostKey(hostKey)
+ ln, err := net.Listen("tcp", "127.0.0.1:0")
+ if err != nil {
+ t.Fatal(err)
+ }
+ t.Cleanup(func() { ln.Close() })
+ go tunnel.Serve(ln)
+
+ srv := testServer(t, &Config{
+ DoorServers: &DoorServersConfig{DoorParty: &DoorPartyConfig{
+ SSHHost: ln.Addr().String(), SSHUser: "dpuser", SSHPassword: "dppass",
+ HostKey: string(gossh.MarshalAuthorizedKey(hostKey.PublicKey())),
+ RloginHost: fake.addr, Tag: "HAM", Secret: "x",
+ }},
+ Doors: []DoorConfig{{Name: "TW2002", Type: DoorDoorParty, Door: "TW"}},
+ })
+ ana := login(t, listen(t, srv), "ana", newSigner(t))
+ ana.send("d1\r")
+ ana.waitFor("REMOTE [HAM]ana xtrn=TW")
+ mark := ana.mark()
+ ana.send("q")
+ ana.waitSince(mark, "DOORS")
+
+ // A wrong host key is refused.
+ other := newSigner(t)
+ srv.doorServers.DoorParty.HostKey = string(gossh.MarshalAuthorizedKey(other.PublicKey()))
+ ana.send("1\r")
+ ana.waitFor("could not start")
+}
+
+func TestBBSLinkDoor(t *testing.T) {
+ const sys, auth, scheme = "SYS1", "AUTH", "SCHEME"
+ var gotHeaders http.Header
+ var mu sync.Mutex
+ api := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
+ switch r.URL.Path {
+ case "/token.php":
+ io.WriteString(w, "tok123")
+ case "/auth.php":
+ mu.Lock()
+ gotHeaders = r.Header.Clone()
+ mu.Unlock()
+ sum := md5.Sum([]byte(auth + "tok123"))
+ if r.Header.Get("X-Auth") == hex.EncodeToString(sum[:]) && r.Header.Get("X-System") == sys {
+ io.WriteString(w, "complete")
+ } else {
+ io.WriteString(w, "bad auth")
+ }
+ }
+ }))
+ t.Cleanup(api.Close)
+
+ // A telnet door server that negotiates first.
+ tln, err := net.Listen("tcp", "127.0.0.1:0")
+ if err != nil {
+ t.Fatal(err)
+ }
+ t.Cleanup(func() { tln.Close() })
+ go func() {
+ c, err := tln.Accept()
+ if err != nil {
+ return
+ }
+ defer c.Close()
+ c.Write([]byte{255, 251, 1, 255, 253, 24}) // WILL ECHO, DO TTYPE
+ c.Write([]byte("BBSLINK LORD\r\n"))
+ buf := make([]byte, 64)
+ for {
+ n, err := c.Read(buf)
+ if err != nil {
+ return
+ }
+ for _, b := range buf[:n] {
+ if b == 'q' {
+ return
+ }
+ if b >= 'a' && b <= 'z' {
+ c.Write([]byte(strings.ToUpper(string(b))))
+ }
+ }
+ }
+ }()
+
+ srv := testServer(t, &Config{
+ DoorServers: &DoorServersConfig{BBSLink: &BBSLinkConfig{SysCode: sys, AuthCode: auth, SchemeCode: scheme,
+ HTTPURL: api.URL, TelnetAddr: tln.Addr().String()}},
+ Doors: []DoorConfig{{Name: "LORD", Type: DoorBBSLink, Door: "lord"}},
+ })
+ ana := login(t, listen(t, srv), "ana", newSigner(t))
+ ana.send("d1\r")
+ ana.waitFor("BBSLINK LORD")
+ ana.never("\xff")
+ ana.send("k")
+ ana.waitFor("K")
+ ana.send("q")
+ ana.waitFor("DOORS")
+
+ mu.Lock()
+ defer mu.Unlock()
+ if gotHeaders.Get("X-Door") != "lord" || gotHeaders.Get("X-Rows") == "" || gotHeaders.Get("X-User") == "" {
+ t.Fatalf("auth headers = %v", gotHeaders)
+ }
+}
@@ -0,0 +1,294 @@+package bbs
+
+import (
+ "crypto/hmac"
+ "crypto/md5"
+ "crypto/rand"
+ "crypto/sha256"
+ "encoding/hex"
+ "errors"
+ "fmt"
+ "io"
+ "net"
+ "net/http"
+ "strconv"
+ "strings"
+ "time"
+
+ gossh "golang.org/x/crypto/ssh"
+
+ "github.com/runawaydevil/hammer/internal/door"
+ "github.com/runawaydevil/hammer/internal/rlogin"
+ "github.com/runawaydevil/hammer/internal/telnet"
+)
+
+// Remote door servers keep hundreds of classic doors and let a board send
+// its callers into them. Each caller gets a password derived from a secret
+// and their key, so the server recognises them on every visit without
+// anyone typing one.
+
+// DoorServersConfig is the optional "bbs.doorServers" block. Secrets belong
+// in .env and come in through ${VAR} in the YAML.
+type DoorServersConfig struct {
+ ANet *ANetConfig `yaml:"anet,omitempty"`
+ DoorParty *DoorPartyConfig `yaml:"doorparty,omitempty"`
+ BBSLink *BBSLinkConfig `yaml:"bbslink,omitempty"`
+}
+
+// ANetConfig is A-Net Online's game server: rlogin, "nick-TAG" names.
+type ANetConfig struct {
+ Host string `yaml:"host"` // absent = game.a-net-online.lol:513
+ Tag string `yaml:"tag"` // this board's 3-4 letter tag
+ Secret string `yaml:"secret"` // derives each caller's password
+}
+
+// DoorPartyConfig is DoorParty: rlogin through an SSH tunnel, "[TAG]nick".
+type DoorPartyConfig struct {
+ SSHHost string `yaml:"sshHost"` // absent = dp.throwbackbbs.com:2022
+ SSHUser string `yaml:"sshUser"` // issued by DoorParty
+ SSHPassword string `yaml:"sshPassword"` // issued by DoorParty
+ // HostKey pins the tunnel's SSH host key, as an authorized_keys line
+ // ("ssh-ed25519 AAAA..."); ssh-keyscan -p 2022 dp.throwbackbbs.com shows it.
+ HostKey string `yaml:"hostKey"`
+ RloginHost string `yaml:"rloginHost"` // absent = dp.throwbackbbs.com:513, dialled through the tunnel
+ Tag string `yaml:"tag"` // the system tag DoorParty issued
+ Secret string `yaml:"secret"`
+ // UserFirst swaps the rlogin fields: the user name in both, as some
+ // connectors send it, instead of the password first.
+ UserFirst bool `yaml:"userFirst"`
+}
+
+// BBSLinkConfig is BBSLink: an HTTP token exchange, then telnet.
+type BBSLinkConfig struct {
+ Host string `yaml:"host"` // absent = games.bbslink.net
+ SysCode string `yaml:"sysCode"`
+ AuthCode string `yaml:"authCode"`
+ SchemeCode string `yaml:"schemeCode"`
+ // HTTPURL and TelnetAddr override where the token exchange and the
+ // telnet connection go; absent = http://<host> and <host>:23.
+ HTTPURL string `yaml:"httpUrl"`
+ TelnetAddr string `yaml:"telnetAddr"`
+}
+
+const remoteDialWait = 15 * time.Second
+
+func (s *Server) doorServerReady(kind string) bool {
+ ds := s.doorServers
+ if ds == nil {
+ return false
+ }
+ switch kind {
+ case DoorANet:
+ return ds.ANet != nil && ds.ANet.Tag != "" && ds.ANet.Secret != ""
+ case DoorDoorParty:
+ d := ds.DoorParty
+ return d != nil && d.SSHUser != "" && d.SSHPassword != "" && d.HostKey != "" && d.Tag != "" && d.Secret != ""
+ case DoorBBSLink:
+ b := ds.BBSLink
+ return b != nil && b.SysCode != "" && b.AuthCode != "" && b.SchemeCode != ""
+ }
+
+ return false
+}
+
+// startRemote connects the caller to a door on a remote door server.
+func (st *session) startRemote(e *doorEntry, node int, deadline time.Time, out io.Writer) (door.Session, error) {
+ ds := st.srv.doorServers
+ nick := remoteNick(st.profile.Nick)
+
+ switch doorType(e.cfg) {
+ case DoorANet:
+ a := ds.ANet
+ host := a.Host
+ if host == "" {
+ host = "game.a-net-online.lol:513"
+ }
+ c, err := net.DialTimeout("tcp", host, remoteDialWait)
+ if err != nil {
+ return nil, err
+ }
+ // Synchronet game-server convention: password, then name; the
+ // terminal field picks the door.
+ if err := rlogin.Login(c, remotePassword(a.Secret, st.fp), nick+"-"+a.Tag, "xtrn="+e.cfg.Door, remoteDialWait); err != nil {
+ c.Close()
+
+ return nil, err
+ }
+
+ return newLineSession(c, out, nil), nil
+
+ case DoorDoorParty:
+ d := ds.DoorParty
+ key, _, _, _, err := gossh.ParseAuthorizedKey([]byte(d.HostKey))
+ if err != nil {
+ return nil, fmt.Errorf("doorparty: hostKey: %w", err)
+ }
+ sshHost := d.SSHHost
+ if sshHost == "" {
+ sshHost = "dp.throwbackbbs.com:2022"
+ }
+ client, err := gossh.Dial("tcp", sshHost, &gossh.ClientConfig{
+ User: d.SSHUser,
+ Auth: []gossh.AuthMethod{gossh.Password(d.SSHPassword)},
+ HostKeyCallback: gossh.FixedHostKey(key),
+ Timeout: remoteDialWait,
+ })
+ if err != nil {
+ return nil, fmt.Errorf("doorparty: tunnel: %w", err)
+ }
+ rhost := d.RloginHost
+ if rhost == "" {
+ rhost = "dp.throwbackbbs.com:513"
+ }
+ c, err := client.Dial("tcp", rhost)
+ if err != nil {
+ client.Close()
+
+ return nil, fmt.Errorf("doorparty: %w", err)
+ }
+ user := "[" + d.Tag + "]" + nick
+ first := remotePassword(d.Secret, st.fp)
+ if d.UserFirst {
+ first = nick
+ }
+ if err := rlogin.Login(tunnelConn{c}, first, user, "xtrn="+e.cfg.Door, remoteDialWait); err != nil {
+ c.Close()
+ client.Close()
+
+ return nil, err
+ }
+
+ return newLineSession(c, out, func() { client.Close() }), nil
+
+ case DoorBBSLink:
+ b := ds.BBSLink
+ host := b.Host
+ if host == "" {
+ host = "games.bbslink.net"
+ }
+ num := 1
+ if n, err := st.srv.store.Number(st.fp); err == nil {
+ num = int(n)
+ }
+ base, addr := b.HTTPURL, b.TelnetAddr
+ if base == "" {
+ base = "http://" + host
+ }
+ if addr == "" {
+ addr = net.JoinHostPort(host, "23")
+ }
+ if err := bbsLinkAuth(b, base, e.cfg.Door, num, st.height0()); err != nil {
+ return nil, err
+ }
+ c, err := net.DialTimeout("tcp", addr, remoteDialWait)
+ if err != nil {
+ return nil, err
+ }
+
+ return newLineSession(telnetClientConn{telnet.NewClient(c), c}, out, nil), nil
+ }
+
+ return nil, fmt.Errorf("unknown door type %q", e.cfg.Type)
+}
+
+// bbsLinkAuth asks BBSLink for a token and authenticates this caller and
+// door with it; the telnet connection that follows is then let in.
+func bbsLinkAuth(b *BBSLinkConfig, base, doorCode string, user, rows int) error {
+ key := randomKey(6)
+ hc := &http.Client{Timeout: remoteDialWait}
+
+ resp, err := hc.Get(base + "/token.php?key=" + key)
+ if err != nil {
+ return fmt.Errorf("bbslink: token: %w", err)
+ }
+ tokenBody, err := io.ReadAll(io.LimitReader(resp.Body, 256))
+ resp.Body.Close()
+ if err != nil {
+ return err
+ }
+ token := strings.TrimSpace(string(tokenBody))
+ if token == "" {
+ return errors.New("bbslink: empty token")
+ }
+
+ req, err := http.NewRequest(http.MethodGet, base+"/auth.php?key="+key, nil)
+ if err != nil {
+ return err
+ }
+ md5hex := func(s string) string { h := md5.Sum([]byte(s)); return hex.EncodeToString(h[:]) }
+ req.Header.Set("X-User", strconv.Itoa(user))
+ req.Header.Set("X-System", b.SysCode)
+ req.Header.Set("X-Auth", md5hex(b.AuthCode+token))
+ req.Header.Set("X-Code", md5hex(b.SchemeCode+token))
+ req.Header.Set("X-Rows", strconv.Itoa(rows))
+ req.Header.Set("X-Key", key)
+ req.Header.Set("X-Door", doorCode)
+ req.Header.Set("X-Token", token)
+ req.Header.Set("X-Type", "hammer")
+ req.Header.Set("X-Version", "1")
+ resp, err = hc.Do(req)
+ if err != nil {
+ return fmt.Errorf("bbslink: auth: %w", err)
+ }
+ body, _ := io.ReadAll(io.LimitReader(resp.Body, 512))
+ resp.Body.Close()
+ if strings.TrimSpace(string(body)) != "complete" {
+ return fmt.Errorf("bbslink: refused: %s", strings.TrimSpace(string(body)))
+ }
+
+ return nil
+}
+
+// remotePassword is a caller's password on a door server: stable for the
+// same key, unguessable without the secret, never shown to anyone.
+func remotePassword(secret, identity string) string {
+ m := hmac.New(sha256.New, []byte(secret))
+ m.Write([]byte(identity))
+
+ return hex.EncodeToString(m.Sum(nil))[:16]
+}
+
+// remoteNick makes a nickname safe for door servers: letters, digits, '-'
+// and '_', at most 20 characters.
+func remoteNick(nick string) string {
+ var b strings.Builder
+ for _, r := range nick {
+ if r < 0x80 && (r == '-' || r == '_' || r >= '0' && r <= '9' || r >= 'a' && r <= 'z' || r >= 'A' && r <= 'Z') {
+ b.WriteRune(r)
+ }
+ if b.Len() >= 20 {
+ break
+ }
+ }
+ if b.Len() == 0 {
+ return "player"
+ }
+
+ return b.String()
+}
+
+func randomKey(n int) string {
+ const letters = "ABCDEFGHIJKLMNOPQRSTUVWXYZ0123456789"
+ b := make([]byte, n)
+ _, _ = rand.Read(b)
+ for i := range b {
+ b[i] = letters[int(b[i])%len(letters)]
+ }
+
+ return string(b)
+}
+
+// tunnelConn lets rlogin.Login use an SSH channel, which has no deadlines.
+type tunnelConn struct{ net.Conn }
+
+func (t tunnelConn) SetReadDeadline(time.Time) error { return nil }
+
+// telnetClientConn is a telnet client seen as a connection.
+type telnetClientConn struct {
+ *telnet.Client
+ net.Conn
+}
+
+func (t telnetClientConn) Read(p []byte) (int, error) { return t.Client.Read(p) }
+func (t telnetClientConn) Write(p []byte) (int, error) { return t.Client.Write(p) }
+func (t telnetClientConn) Close() error { return t.Client.Close() }
@@ -14,6 +14,10 @@ // The test binary doubles as a fake door: with HAMMER_FAKE_DOOR set it
// behaves like nesdoor would, instead of running the tests.
func TestMain(m *testing.M) {
+ if len(os.Args) > 1 && os.Args[1] == "-fake-classic-door" {
+ fakeClassicDoor(os.Args[2:])
+ os.Exit(0)
+ }
if os.Getenv("HAMMER_FAKE_DOOR") != "" {
fakeGameDoor()
os.Exit(0)
@@ -40,7 +40,7 @@ "BBS_QUIT": "quit", "BBS_BACK": "back",
"BBS_CHANGE": "change",
"BBS_NOT_SET": "(not set)",
- "BBS_UNKNOWN_MAIN": "unknown key — try %s, f, w, c, l, s, a, m or q",
+ "BBS_UNKNOWN_MAIN": "unknown key — try %s, f, w, c, l, d, s, a, m or q",
"BBS_SEE_YOU": "⚡ hammer — see you",
"BBS_TAGLINE": "tools over SSH",
"BBS_NOW": "now",
@@ -293,4 +293,8 @@ "BBS_NODES_WHICH": "hang up on node: ", "BBS_NODES_KICKED": "Node %d hung up",
"BBS_NODES_NONE": "No node %d to hang up",
"BBS_NODES_SELF": "That is you — use q on the main menu to leave",
+ "BBS_DOORS": "doors",
+ "BBS_DOORS_SUM": "classic door games",
+ "BBS_TITLE_DOORS": "DOORS",
+ "BBS_DOORS_PLAYING": "%d playing now",
}
@@ -64,6 +64,9 @@ ) if st.gamesOn() {
lines = append(lines, st.menuEntry("g", st.t("BBS_GAMES"), st.t("BBS_GAMES_SUM")))
}
+ if st.doorsOn() {
+ lines = append(lines, st.menuEntry("d", st.t("BBS_DOORS"), st.t("BBS_DOORS_SUM")))
+ }
if st.srv.isAdmin(st.fp) {
lines = append(lines, st.menuEntry("n", st.t("BBS_NODES"), st.t("BBS_NODES_SUM")))
}
@@ -133,6 +133,15 @@ return err }
// NickTaken reports whether another identity already holds nick (any case).
+// Number is a stable number for a profile (its row in the table), for doors
+// that key their players on a user number.
+func (s *ProfileStore) Number(fingerprint string) (int64, error) {
+ var n int64
+ err := s.db.CurrentDB().QueryRow(`select rowid from profiles where fingerprint = ?`, fingerprint).Scan(&n)
+
+ return n, err
+}
+
func (s *ProfileStore) NickTaken(nick, exceptIdentity string) bool {
s.m.Lock()
defer s.m.Unlock()
@@ -62,6 +62,10 @@ chat *community.Hub cstore *community.Store // bans, chat log, last callers
forums *forum.Store
+ doors []*doorEntry // the [d] menu; empty when none can run
+ dosbox string // the DOSBox program, "" when DOS doors are off
+ doorServers *DoorServersConfig // credentials for remote door servers
+
games *gamesSettings // nil when games are off or the door is missing
gameSlots *door.Slots // nodes, one per person playing
}
@@ -128,6 +132,9 @@ return nil, fmt.Errorf("forums: %w", err) }
s.setGames(resolveGames(cfg.Games))
+ s.dosbox = resolveDOSBox(cfg.DOSBox)
+ s.doorServers = cfg.DoorServers
+ s.doors = s.resolveDoors(cfg.Doors)
catalog, err := dispatch.NewCatalog(s.dispatchTools()...)
if err != nil {
@@ -176,6 +176,12 @@ case 'l', 'L': st.lastCallersScreen()
case 'f', 'F':
st.forumsMenu()
+ case 'd', 'D':
+ if st.doorsOn() {
+ st.doorsMenu()
+ } else {
+ st.note = st.tf("BBS_UNKNOWN_MAIN", st.mainNumericRange())
+ }
case 'n', 'N':
if st.srv.isAdmin(st.fp) {
st.nodesScreen()
@@ -163,3 +163,21 @@ cr.buf = cr.buf[n:]
return n, nil
}
+
+// EncodeBytes converts UTF-8 bytes to CP437, for keys sent to a door.
+func EncodeBytes(b []byte) []byte {
+ out := make([]byte, 0, len(b))
+ for len(b) > 0 {
+ r, size := utf8.DecodeRune(b)
+ if r == utf8.RuneError && size <= 1 {
+ out = append(out, b[0]) // not UTF-8: pass the byte as it is
+ b = b[1:]
+
+ continue
+ }
+ out = EncodeRune(out, r)
+ b = b[size:]
+ }
+
+ return out
+}
@@ -159,6 +159,9 @@ return p, nil
}
+// Expand fills {name} placeholders from vars; unknown ones stay as they are.
+func Expand(s string, vars map[string]string) string { return expand(s, vars) }
+
// 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, "{") {
@@ -211,6 +214,10 @@ _ = p.cmd.Process.Kill() <-p.done
}
}
+
+// Env is the minimal environment a door runs with, for programs Hammer
+// starts around a door (such as DOSBox): no secrets from Hammer's own.
+func Env(dropPath, home string) []string { return childEnv(dropPath, home) }
// childEnv is the environment a door runs with.
func childEnv(drop, home string) []string {
@@ -54,3 +54,11 @@ }
return n
}
+
+// Size is how many nodes the pool has.
+func (s *Slots) Size() int {
+ s.mu.Lock()
+ defer s.mu.Unlock()
+
+ return len(s.used)
+}
@@ -0,0 +1,116 @@+// Package rlogin speaks the rlogin protocol (RFC 1282): the client's opening
+// handshake, and the server side of it. BBS door servers use rlogin to let a
+// board send its callers straight into a door, with the caller's name in
+// the handshake.
+package rlogin
+
+import (
+ "bufio"
+ "errors"
+ "fmt"
+ "io"
+ "net"
+ "strings"
+ "time"
+)
+
+// maxField bounds each handshake field read by a server.
+const maxField = 64
+
+// Login sends the client handshake on c — an empty field, then the client
+// user, the server user and "terminal/speed" — and waits for the server's
+// single zero byte. After it, c is a plain byte stream.
+func Login(c net.Conn, clientUser, serverUser, terminal string, wait time.Duration) error {
+ for _, f := range []string{clientUser, serverUser, terminal} {
+ if strings.ContainsRune(f, 0) {
+ return errors.New("rlogin: a field contains a NUL byte")
+ }
+ }
+ msg := "\x00" + clientUser + "\x00" + serverUser + "\x00" + terminal + "\x00"
+ if _, err := io.WriteString(c, msg); err != nil {
+ return err
+ }
+
+ _ = c.SetReadDeadline(time.Now().Add(wait))
+ defer c.SetReadDeadline(time.Time{})
+ var ack [1]byte
+ if _, err := io.ReadFull(c, ack[:]); err != nil {
+ return fmt.Errorf("rlogin: no answer: %w", err)
+ }
+ if ack[0] != 0 {
+ return fmt.Errorf("rlogin: refused (0x%02x)", ack[0])
+ }
+
+ return nil
+}
+
+// Handshake is what a client sent when it connected.
+type Handshake struct {
+ ClientUser string
+ ServerUser string
+ Terminal string // e.g. "xterm"
+ Speed string // e.g. "38400"
+}
+
+// Accept reads a client's handshake within wait (0 = no limit) and answers with the
+// zero byte. The returned reader holds anything the client sent after it.
+func Accept(c net.Conn, wait time.Duration) (Handshake, *bufio.Reader, error) {
+ if wait > 0 {
+ _ = c.SetReadDeadline(time.Now().Add(wait))
+ defer c.SetReadDeadline(time.Time{})
+ }
+
+ r := bufio.NewReaderSize(c, 512)
+ var fields [4]string
+ for i := range fields {
+ f, err := readField(r)
+ if err != nil {
+ return Handshake{}, nil, err
+ }
+ fields[i] = f
+ }
+ if fields[0] != "" {
+ return Handshake{}, nil, errors.New("rlogin: the handshake must start with an empty field")
+ }
+ if _, err := c.Write([]byte{0}); err != nil {
+ return Handshake{}, nil, err
+ }
+
+ h := Handshake{ClientUser: fields[1], ServerUser: fields[2]}
+ h.Terminal, h.Speed, _ = strings.Cut(fields[3], "/")
+
+ return h, r, nil
+}
+
+func readField(r *bufio.Reader) (string, error) {
+ var b []byte
+ for {
+ c, err := r.ReadByte()
+ if err != nil {
+ return "", err
+ }
+ if c == 0 {
+ return string(b), nil
+ }
+ if len(b) >= maxField {
+ return "", errors.New("rlogin: handshake field too long")
+ }
+ b = append(b, c)
+ }
+}
+
+// WindowSize finds a client's window-size report (FF FF 's' 's' rows cols
+// xpix ypix) in b. It returns the size and b with the report removed.
+func WindowSize(b []byte) (cols, rows int, rest []byte, ok bool) {
+ for i := 0; i+12 <= len(b); i++ {
+ if b[i] == 0xff && b[i+1] == 0xff && b[i+2] == 's' && b[i+3] == 's' {
+ rows = int(b[i+4])<<8 | int(b[i+5])
+ cols = int(b[i+6])<<8 | int(b[i+7])
+ rest = append(append([]byte{}, b[:i]...), b[i+12:]...)
+
+ return cols, rows, rest, true
+ }
+ }
+
+ return 0, 0, b, false
+}
@@ -0,0 +1,68 @@+package rlogin
+
+import (
+ "io"
+ "net"
+ "strings"
+ "testing"
+ "time"
+)
+
+func TestLoginAndAccept(t *testing.T) {
+ server, client := net.Pipe()
+ defer server.Close()
+ defer client.Close()
+
+ got := make(chan Handshake, 1)
+ go func() {
+ hs, r, err := Accept(server, time.Second)
+ if err != nil {
+ t.Error(err)
+
+ return
+ }
+ got <- hs
+ b := make([]byte, 2)
+ io.ReadFull(r, b)
+ server.Write(b)
+ }()
+
+ if err := Login(client, "secret", "ana-HAMR", "xtrn=LORD/38400", time.Second); err != nil {
+ t.Fatal(err)
+ }
+ hs := <-got
+ if hs.ClientUser != "secret" || hs.ServerUser != "ana-HAMR" || hs.Terminal != "xtrn=LORD" || hs.Speed != "38400" {
+ t.Fatalf("handshake = %+v", hs)
+ }
+ client.Write([]byte("ok"))
+ b := make([]byte, 2)
+ if _, err := io.ReadFull(client, b); err != nil || string(b) != "ok" {
+ t.Fatalf("stream after the handshake: %q %v", b, err)
+ }
+}
+
+func TestAcceptRejectsBadHandshakes(t *testing.T) {
+ for name, msg := range map[string]string{
+ "no empty first field": "x\x00a\x00b\x00c\x00",
+ "field too long": "\x00" + strings.Repeat("a", 100) + "z",
+ } {
+ server, client := net.Pipe()
+ go client.Write([]byte(msg))
+ if _, _, err := Accept(server, 200*time.Millisecond); err == nil {
+ t.Errorf("%s: accepted", name)
+ }
+ server.Close()
+ client.Close()
+ }
+}
+
+func TestWindowSize(t *testing.T) {
+ in := []byte{'a', 0xff, 0xff, 's', 's', 0, 25, 0, 80, 0, 0, 0, 0, 'b'}
+ cols, rows, rest, ok := WindowSize(in)
+ if !ok || cols != 80 || rows != 25 || string(rest) != "ab" {
+ t.Fatalf("got %d x %d, rest %q, ok %v", cols, rows, rest, ok)
+ }
+ if _, _, _, ok := WindowSize([]byte("plain")); ok {
+ t.Fatal("found a report in plain text")
+ }
+}
@@ -0,0 +1,114 @@+package telnet
+
+import (
+ "bytes"
+ "net"
+ "sync"
+)
+
+// Client is the calling side of a telnet connection, for reaching a remote
+// door server as a plain terminal: it refuses every option the server
+// offers, takes telnet commands out of what the server sends, and doubles
+// 0xFF in what we send.
+type Client struct {
+ c net.Conn
+ wm sync.Mutex
+ state int
+ opt byte
+ raw []byte
+ data []byte
+}
+
+// NewClient wraps c.
+func NewClient(c net.Conn) *Client {
+ return &Client{c: c, raw: make([]byte, 4096)}
+}
+
+// Read returns what the server sent, without telnet commands.
+func (t *Client) Read(p []byte) (int, error) {
+ for len(t.data) == 0 {
+ n, err := t.c.Read(t.raw)
+ if n > 0 {
+ t.parse(t.raw[:n])
+ }
+ if err != nil && len(t.data) == 0 {
+ return 0, err
+ }
+ }
+ n := copy(p, t.data)
+ t.data = t.data[n:]
+
+ return n, nil
+}
+
+// Write sends p, doubling 0xFF.
+func (t *Client) Write(p []byte) (int, error) {
+ out := p
+ if bytes.IndexByte(p, cmdIAC) >= 0 {
+ out = bytes.ReplaceAll(p, []byte{cmdIAC}, []byte{cmdIAC, cmdIAC})
+ }
+ t.wm.Lock()
+ defer t.wm.Unlock()
+ if _, err := t.c.Write(out); err != nil {
+ return 0, err
+ }
+
+ return len(p), nil
+}
+
+// Close closes the connection.
+func (t *Client) Close() error { return t.c.Close() }
+
+func (t *Client) answer(b ...byte) {
+ t.wm.Lock()
+ defer t.wm.Unlock()
+ _, _ = t.c.Write(b)
+}
+
+func (t *Client) parse(b []byte) {
+ for _, c := range b {
+ switch t.state {
+ case stData:
+ if c == cmdIAC {
+ t.state = stIAC
+ } else {
+ t.data = append(t.data, c)
+ }
+ case stIAC:
+ switch c {
+ case cmdIAC:
+ t.data = append(t.data, cmdIAC)
+ t.state = stData
+ case cmdWILL, cmdWONT, cmdDO, cmdDONT:
+ t.opt = c
+ t.state = stOpt
+ case cmdSB:
+ t.state = stSB
+ default:
+ t.state = stData
+ }
+ case stOpt:
+ switch t.opt {
+ case cmdDO:
+ t.answer(cmdIAC, cmdWONT, c) // we do nothing special
+ case cmdWILL:
+ if c == optEcho || c == optSGA {
+ t.answer(cmdIAC, cmdDO, c) // let the server echo, char at a time
+ } else {
+ t.answer(cmdIAC, cmdDONT, c)
+ }
+ }
+ t.state = stData
+ case stSB:
+ if c == cmdIAC {
+ t.state = stSBIAC
+ }
+ case stSBIAC:
+ if c == cmdSE {
+ t.state = stData
+ } else {
+ t.state = stSB
+ }
+ }
+ }
+}
@@ -0,0 +1,41 @@+package telnet
+
+import (
+ "bytes"
+ "io"
+ "net"
+ "testing"
+ "time"
+)
+
+func TestClientRefusesOptionsAndStripsCommands(t *testing.T) {
+ server, client := net.Pipe()
+ defer server.Close()
+ tc := NewClient(client)
+
+ answers := &bytes.Buffer{}
+ done := make(chan struct{})
+ go func() {
+ defer close(done)
+ buf := make([]byte, 64)
+ server.SetReadDeadline(time.Now().Add(time.Second))
+ for answers.Len() < 9 {
+ n, err := server.Read(buf)
+ answers.Write(buf[:n])
+ if err != nil {
+ return
+ }
+ }
+ }()
+ go server.Write([]byte{cmdIAC, cmdWILL, optEcho, cmdIAC, cmdDO, optTType, cmdIAC, cmdWILL, 99, 'h', 'i', cmdIAC, cmdIAC})
+
+ got := make([]byte, 3)
+ if _, err := io.ReadFull(tc, got); err != nil || string(got) != "hi\xff" {
+ t.Fatalf("read %q %v", got, err)
+ }
+ <-done
+ want := []byte{cmdIAC, cmdDO, optEcho, cmdIAC, cmdWONT, optTType, cmdIAC, cmdDONT, 99}
+ if !bytes.Equal(answers.Bytes(), want) {
+ t.Fatalf("answers % x, want % x", answers.Bytes(), want)
+ }
+}
@@ -35,7 +35,7 @@ "BBS_QUIT": "quit", "BBS_BACK": "back",
"BBS_CHANGE": "change",
"BBS_NOT_SET": "(not set)",
- "BBS_UNKNOWN_MAIN": "unknown key — try %s, f, w, c, l, s, a, m or q",
+ "BBS_UNKNOWN_MAIN": "unknown key — try %s, f, w, c, l, d, s, a, m or q",
"BBS_SEE_YOU": "⚡ hammer — see you",
"BBS_TAGLINE": "tools over SSH",
"BBS_NOW": "now",
@@ -287,5 +287,9 @@ "BBS_NODES_KICK": "hang up on a node", "BBS_NODES_WHICH": "hang up on node: ",
"BBS_NODES_KICKED": "Node %d hung up",
"BBS_NODES_NONE": "No node %d to hang up",
- "BBS_NODES_SELF": "That is you — use q on the main menu to leave"
+ "BBS_NODES_SELF": "That is you — use q on the main menu to leave",
+ "BBS_DOORS": "doors",
+ "BBS_DOORS_SUM": "classic door games",
+ "BBS_TITLE_DOORS": "DOORS",
+ "BBS_DOORS_PLAYING": "%d playing now"
}
@@ -35,7 +35,7 @@ "BBS_QUIT": "sair", "BBS_BACK": "voltar",
"BBS_CHANGE": "alterar",
"BBS_NOT_SET": "(não definido)",
- "BBS_UNKNOWN_MAIN": "tecla desconhecida — use %s, f, w, c, l, s, a, m ou q",
+ "BBS_UNKNOWN_MAIN": "tecla desconhecida — use %s, f, w, c, l, d, s, a, m ou q",
"BBS_SEE_YOU": "⚡ hammer — até logo",
"BBS_TAGLINE": "ferramentas via SSH",
"BBS_NOW": "agora",
@@ -287,5 +287,9 @@ "BBS_NODES_KICK": "derrubar um nó", "BBS_NODES_WHICH": "derrubar o nó: ",
"BBS_NODES_KICKED": "Nó %d derrubado",
"BBS_NODES_NONE": "Não há nó %d para derrubar",
- "BBS_NODES_SELF": "Esse é você — use q no menu principal para sair"
+ "BBS_NODES_SELF": "Esse é você — use q no menu principal para sair",
+ "BBS_DOORS": "doors",
+ "BBS_DOORS_SUM": "jogos de door clássicos",
+ "BBS_TITLE_DOORS": "DOORS",
+ "BBS_DOORS_PLAYING": "%d jogando agora"
}