code.pablomurad.com

bbs-hammer

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

feat(bbs): native, DOS and remote doors
author
Pablo Murad
date
commit
03e6015836c55100833642f8625aba23ceb23874
parent
d5b5efc99b084cd4b5db58852408f1d62bc0a52b
changes
23 files +1813 −48
@@ -62,7 +62,13 @@ // waitFor blocks until the output contains want.
 func (tm *term) waitFor(want string) {
 	tm.t.Helper()
-	deadline := time.Now().Add(5 * time.Second)
+	tm.waitForWithin(want, 5)
+}
+
+// waitForWithin is waitFor with a longer wait, in seconds.
+func (tm *term) waitForWithin(want string, seconds int) {
+	tm.t.Helper()
+	deadline := time.Now().Add(time.Duration(seconds) * time.Second)
 	for time.Now().Before(deadline) {
 		if strings.Contains(tm.out.String(), want) {
 			return
@@ -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",
 }
@@ -63,86 +63,118 @@ if c := st.choose("games> "); c != '\r' && c != '\n' { 		return
 	}
 
+	lang := "en"
+	if st.profile.Lang != "" {
+		lang = localization.Canonical(st.profile.Lang)
+	}
+	played := st.playDoor(doorPlay{
+		name:       "nesdoor",
+		slots:      st.srv.gameSlots,
+		maxPlayers: g.maxPlayers,
+		maxTime:    g.maxTime,
+		start: func(node int, deadline time.Time) (door.Session, error) {
+			return door.Start(door.Spec{
+				Name:    "nesdoor",
+				Program: g.program,
+				Args:    g.args,
+				Dir:     g.dir,
+				TempDir: g.tempDir,
+				Env:     g.env,
+				Drop: door.Drop{
+					Alias:    st.profile.Nick,
+					UserKey:  st.fp,
+					Width:    st.width0(),
+					Height:   st.height0(),
+					Deadline: deadline,
+					Language: lang,
+					Encoding: st.doorEncoding(),
+					Board:    "Hammer",
+					Sysop:    "hammer",
+					Node:     node,
+				},
+			}, doorOutput{st})
+		},
+	})
+	if played && st.anon {
+		st.forgetAnonymousSaves(g)
+	}
+}
+
+// doorPlay is one door ready to be played.
+type doorPlay struct {
+	name       string
+	slots      *door.Slots
+	maxPlayers int
+	maxTime    time.Duration
+	// start launches the door on node, to end by deadline.
+	start func(node int, deadline time.Time) (door.Session, error)
+	// encode turns the caller's keys (UTF-8) into what the door reads;
+	// nil passes them through.
+	encode func([]byte) []byte
+}
+
+// playDoor runs one sitting: it finds the time left before the
+// connection's own limit, takes a node, marks the caller as playing and
+// hands the terminal to the door until it ends. It reports whether the door
+// ran. The caller has already checked who may play.
+func (st *session) playDoor(d doorPlay) bool {
 	// The door saves and exits at the deadline; it must come before the SSH
 	// connection's own limit cuts the line.
-	deadline := time.Now().Add(g.maxTime)
+	deadline := time.Now().Add(d.maxTime)
 	if end := st.since.Add(maxTimeout - time.Minute); end.Before(deadline) {
 		deadline = end
 	}
 	if time.Until(deadline) < 2*time.Minute {
 		st.note = st.t("BBS_GAMES_LATE")
 
-		return
+		return false
 	}
 
-	node, ok := st.srv.gameSlots.Acquire()
+	node, ok := d.slots.Acquire()
 	if !ok {
-		st.note = st.tf("BBS_GAMES_FULL", g.maxPlayers)
+		st.note = st.tf("BBS_GAMES_FULL", d.maxPlayers)
 
-		return
+		return false
 	}
-	defer st.srv.gameSlots.Release(node)
+	defer d.slots.Release(node)
 
 	if st.pres != nil {
 		st.pres.SetWhere(community.WhereGame)
 		defer st.pres.SetWhere(community.WhereMenu)
 	}
 
-	lang := "en"
-	if st.profile.Lang != "" {
-		lang = localization.Canonical(st.profile.Lang)
-	}
-	spec := door.Spec{
-		Name:    "nesdoor",
-		Program: g.program,
-		Args:    g.args,
-		Dir:     g.dir,
-		TempDir: g.tempDir,
-		Env:     g.env,
-		Drop: door.Drop{
-			Alias:    st.profile.Nick,
-			UserKey:  st.fp,
-			Width:    st.width0(),
-			Height:   st.height0(),
-			Deadline: deadline,
-			Language: lang,
-			Encoding: st.doorEncoding(),
-			Board:    "Hammer",
-			Sysop:    "hammer",
-			Node:     node,
-		},
-	}
-
 	st.clear()
-	p, err := door.Start(spec, doorOutput{st})
+	p, err := d.start(node, deadline)
 	if err != nil {
-		log.Println("bbs: games:", err)
+		log.Printf("bbs: %s: %v", d.name, err)
 		st.note = st.t("BBS_GAMES_FAILED")
 
-		return
+		return false
 	}
-	st.relayDoor(p, deadline)
+	st.relayDoor(p, deadline, d.encode)
 	if err := p.Err(); err != nil && st.alive() {
-		log.Println("bbs: games: door exited:", err)
-	}
-	if st.anon {
-		st.forgetAnonymousSaves(g)
+		log.Printf("bbs: %s: door exited: %v", d.name, err)
 	}
 
 	st.writeString("\x1b[0m\x1b[?25h")
 	st.clear()
+
+	return true
 }
 
 // relayDoor passes the caller's keys to the door until the door exits, the
 // caller hangs up, or the sitting runs past its deadline. Keys go through
 // raw: Ctrl+C, Esc and the rest belong to the game now.
-func (st *session) relayDoor(p *door.Process, deadline time.Time) {
+func (st *session) relayDoor(p door.Session, deadline time.Time, encode func([]byte) []byte) {
+	if encode == nil {
+		encode = func(b []byte) []byte { return b }
+	}
 	// Typed-ahead keys go first, but not the LF or NUL some terminals send
 	// after the Enter that opened the door: it would pick the first game.
 	pending := bytes.TrimLeft(st.inbuf, "\n\x00")
 	st.inbuf = nil
 	if len(pending) > 0 {
-		_, _ = p.Write(pending)
+		_, _ = p.Write(encode(pending))
 	}
 
 	// A door that ignores its own deadline is hung up on shortly after.
@@ -165,7 +197,7 @@ 				return
 			}
 			// A failed write means the door is on its way out; Done follows.
-			_, _ = p.Write(ch.b)
+			_, _ = p.Write(encode(ch.b))
 		case <-late.C:
 			p.Hangup(doorGrace)
 
@@ -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"
 }