250 lines
6.9 KiB
Go
250 lines
6.9 KiB
Go
// Package session provides the core I/O abstraction for a BBS connection.
|
|
//
|
|
// In the original TAG-BBS, I/O was managed through global variables
|
|
// (IO_Flags[], ReadSerReq, WriteConReq, etc.) and errors like carrier
|
|
// loss or timeout were handled by longjmp() back to the main loop.
|
|
//
|
|
// Session replaces all of that. Each connected user gets a Session that
|
|
// wraps their network connection, manages input/output, tracks timing,
|
|
// and uses Go's context.Context for cancellation instead of longjmp.
|
|
// Everything above this layer — menus, commands, editors — talks to
|
|
// a Session and never touches the raw connection.
|
|
package session
|
|
|
|
import (
|
|
"context"
|
|
"fmt"
|
|
"net"
|
|
"sync"
|
|
"time"
|
|
)
|
|
|
|
// DisconnectReason describes why a session ended.
|
|
// These map to the original TAG-BBS logoff types in DEFINES.H.
|
|
type DisconnectReason int
|
|
|
|
const (
|
|
DisconnectNormal DisconnectReason = iota // User typed 'goodbye' (STANDARD_LOGOFF)
|
|
DisconnectTimeout // Idle timeout (SLEEP_LOGOFF)
|
|
DisconnectKicked // Sysop force-disconnect (ILLEGAL_LOGOFF)
|
|
DisconnectDropped // Connection lost (CARRIER_LOGOFF)
|
|
DisconnectOvertime // Time limit exceeded (OVERTIME_LOGOFF)
|
|
)
|
|
|
|
func (d DisconnectReason) String() string {
|
|
switch d {
|
|
case DisconnectNormal:
|
|
return "normal logoff"
|
|
case DisconnectTimeout:
|
|
return "idle timeout"
|
|
case DisconnectKicked:
|
|
return "kicked by sysop"
|
|
case DisconnectDropped:
|
|
return "connection lost"
|
|
case DisconnectOvertime:
|
|
return "time limit exceeded"
|
|
default:
|
|
return "unknown"
|
|
}
|
|
}
|
|
|
|
// Session represents a single user's connection to the BBS.
|
|
// It is the modern equivalent of the original's combination of
|
|
// serial/console device handles, IO_Flags, and the jmp_buf Environment.
|
|
type Session struct {
|
|
conn net.Conn
|
|
filter *telnetFilter
|
|
|
|
// Context controls the session lifetime. Cancelling it is the
|
|
// equivalent of longjmp(Environment, ...) in the original — it
|
|
// unwinds all blocking I/O and returns control to the connection
|
|
// handler.
|
|
ctx context.Context
|
|
cancel context.CancelFunc
|
|
|
|
// Node number for this session (like the original's implicit
|
|
// single-node, but now we can have many).
|
|
Node int
|
|
|
|
// User identity — set after authentication. Used by NodeManager
|
|
// to report who's logged in at each node.
|
|
UserName string
|
|
UserID int64
|
|
|
|
// Terminal dimensions reported by the client via NAWS.
|
|
// Zero means unknown.
|
|
Width int
|
|
Height int
|
|
|
|
// Timing — replaces Time_connect, Time_limit, Time_menu_entry, etc.
|
|
ConnectedAt time.Time
|
|
TimeLimit time.Duration
|
|
timeUsed time.Duration
|
|
lastCheck time.Time
|
|
|
|
// Disconnect tracking
|
|
disconnectReason DisconnectReason
|
|
mu sync.Mutex
|
|
|
|
// Input buffer — raw bytes from the connection are read here,
|
|
// filtered through the telnet IAC stripper, then individual
|
|
// bytes are delivered to ReadChar() via the channel.
|
|
inputCh chan byte
|
|
inputDone chan struct{}
|
|
}
|
|
|
|
// New creates a Session wrapping the given network connection.
|
|
// The session starts its input reader goroutine immediately.
|
|
func New(conn net.Conn, node int) *Session {
|
|
ctx, cancel := context.WithCancel(context.Background())
|
|
|
|
s := &Session{
|
|
conn: conn,
|
|
filter: newTelnetFilter(),
|
|
ctx: ctx,
|
|
cancel: cancel,
|
|
Node: node,
|
|
ConnectedAt: time.Now(),
|
|
TimeLimit: 2 * time.Hour, // Default; overridden after auth
|
|
lastCheck: time.Now(),
|
|
inputCh: make(chan byte, 256),
|
|
inputDone: make(chan struct{}),
|
|
}
|
|
|
|
go s.readLoop()
|
|
|
|
return s
|
|
}
|
|
|
|
// Context returns the session's context. Command handlers can select
|
|
// on ctx.Done() to detect disconnection, or pass it to other functions
|
|
// that accept a context.
|
|
func (s *Session) Context() context.Context {
|
|
return s.ctx
|
|
}
|
|
|
|
// Close ends the session. This cancels the context (which unblocks any
|
|
// pending ReadChar), closes the network connection (which terminates
|
|
// the read loop), and waits for the read loop to finish.
|
|
func (s *Session) Close(reason DisconnectReason) {
|
|
s.mu.Lock()
|
|
s.disconnectReason = reason
|
|
s.mu.Unlock()
|
|
|
|
s.cancel()
|
|
s.conn.Close()
|
|
<-s.inputDone // Wait for readLoop to exit
|
|
}
|
|
|
|
// DisconnectReason returns why the session ended.
|
|
func (s *Session) Reason() DisconnectReason {
|
|
s.mu.Lock()
|
|
defer s.mu.Unlock()
|
|
return s.disconnectReason
|
|
}
|
|
|
|
// RemoteAddr returns the client's network address.
|
|
func (s *Session) RemoteAddr() string {
|
|
return s.conn.RemoteAddr().String()
|
|
}
|
|
|
|
// TerminalSize returns the client's terminal dimensions if known.
|
|
// Returns 80x24 as defaults if the client didn't send NAWS.
|
|
func (s *Session) TerminalSize() (width, height int) {
|
|
s.mu.Lock()
|
|
defer s.mu.Unlock()
|
|
w, h := s.Width, s.Height
|
|
if w == 0 {
|
|
w = 80
|
|
}
|
|
if h == 0 {
|
|
h = 24
|
|
}
|
|
return w, h
|
|
}
|
|
|
|
// CheckTime updates the session's time accounting and returns an error
|
|
// if the time limit has been exceeded. This replaces Check_Online_Status()
|
|
// from the original, minus the carrier detect check (TCP handles that
|
|
// via the read loop detecting a closed connection).
|
|
func (s *Session) CheckTime() error {
|
|
now := time.Now()
|
|
elapsed := now.Sub(s.lastCheck)
|
|
s.lastCheck = now
|
|
s.timeUsed += elapsed
|
|
|
|
remaining := s.TimeLimit - s.timeUsed
|
|
if remaining <= 0 {
|
|
return fmt.Errorf("time limit exceeded")
|
|
}
|
|
|
|
// One-minute warning, like the original
|
|
if remaining <= time.Minute && remaining+elapsed > time.Minute {
|
|
s.WriteString("\r\n*** One minute warning ***\r\n")
|
|
}
|
|
|
|
return nil
|
|
}
|
|
|
|
// TimeRemaining returns how much time the user has left.
|
|
func (s *Session) TimeRemaining() time.Duration {
|
|
remaining := s.TimeLimit - s.timeUsed
|
|
if remaining < 0 {
|
|
return 0
|
|
}
|
|
return remaining
|
|
}
|
|
|
|
// Negotiate sends the initial telnet option negotiation to the client.
|
|
// This must be called before any other I/O on the session.
|
|
func (s *Session) Negotiate() error {
|
|
_, err := s.conn.Write(telnetNegotiation())
|
|
return err
|
|
}
|
|
|
|
// readLoop runs in its own goroutine for the lifetime of the session.
|
|
// It reads raw bytes from the connection, strips telnet IAC sequences
|
|
// via the filter, and feeds clean data bytes into inputCh one at a time.
|
|
//
|
|
// This is the modern equivalent of the original's SendIO(ReadSerReq)
|
|
// that kept an async read pending on the serial port at all times.
|
|
// When the connection closes (or the context is cancelled), the loop
|
|
// exits and closes inputDone to signal completion.
|
|
func (s *Session) readLoop() {
|
|
defer close(s.inputDone)
|
|
defer close(s.inputCh)
|
|
|
|
buf := make([]byte, 256)
|
|
for {
|
|
n, err := s.conn.Read(buf)
|
|
if err != nil {
|
|
// Connection closed or errored — equivalent to carrier loss.
|
|
s.mu.Lock()
|
|
if s.disconnectReason == DisconnectNormal {
|
|
s.disconnectReason = DisconnectDropped
|
|
}
|
|
s.mu.Unlock()
|
|
s.cancel()
|
|
return
|
|
}
|
|
|
|
clean := s.filter.Filter(buf[:n])
|
|
|
|
// Update terminal size if NAWS was received
|
|
if s.filter.Width > 0 {
|
|
s.mu.Lock()
|
|
s.Width = s.filter.Width
|
|
s.Height = s.filter.Height
|
|
s.mu.Unlock()
|
|
}
|
|
|
|
for _, b := range clean {
|
|
select {
|
|
case s.inputCh <- b:
|
|
case <-s.ctx.Done():
|
|
return
|
|
}
|
|
}
|
|
}
|
|
}
|