urit/internal/server/server.go
2026-05-02 21:11:50 -04:00

505 lines
15 KiB
Go

package server
import (
"fmt"
"log"
"net"
"sort"
"sync"
"time"
"github.com/urit/urit/internal/auth"
"github.com/urit/urit/internal/config"
"github.com/urit/urit/internal/menu"
"github.com/urit/urit/internal/session"
"github.com/urit/urit/internal/store"
)
// maxNodes is the fixed pool size for node numbers.
// Node numbers are recycled: when a user disconnects, their number
// goes back into the pool for the next connection. This provides a
// natural connection limit and keeps node numbers small and readable.
//
// The original TAG-BBS was single-node (serial port). Our pool of 256
// is generous for a retro BBS — even large boards rarely had more than
// a few dozen simultaneous callers.
const maxNodes = 256
// Server manages listener(s) and active connections.
type Server struct {
cfg *config.Config
store store.Store
listener net.Listener
// Session management — protected by mu.
mu sync.Mutex
sessions map[int]*session.Session
// Web access tokens — shared between telnet and HTTP.
// Telnet users generate tokens; HTTP uses them for auth.
Tokens *TokenStore
// Chat — inter-node real-time messaging.
Chat *ChatManager
// StartedAt records when the server was created, used for uptime.
StartedAt time.Time
// Node number recycling pool. nodeBitmap tracks which node numbers
// (1 through maxNodes) are currently assigned. AllocNode picks the
// lowest free number; FreeNode returns it to the pool.
//
// This replaces the monotonically-incrementing connCount from earlier
// versions. A bitmap is efficient and gives predictable, small node
// numbers that are easy for sysops to reference.
nodeBitmap [(maxNodes + 7) / 8]byte
}
// New creates a Server from the given configuration.
func New(cfg *config.Config, db store.Store) *Server {
return &Server{
cfg: cfg,
store: db,
sessions: make(map[int]*session.Session),
Tokens: NewTokenStore(),
Chat: NewChatManager(),
StartedAt: time.Now(),
}
}
// ListenAndServe starts the telnet listener and blocks, accepting
// connections until the listener is closed. Each connection is handled
// in its own goroutine — this is the multi-user equivalent of the
// original TAG-BBS's single-caller Await_Logon() loop.
func (s *Server) ListenAndServe() error {
if !s.cfg.Telnet.Enabled {
return fmt.Errorf("telnet is not enabled in configuration")
}
ln, err := net.Listen("tcp", s.cfg.Telnet.Address)
if err != nil {
return fmt.Errorf("telnet listen %s: %w", s.cfg.Telnet.Address, err)
}
s.listener = ln
log.Printf("Telnet listening on %s", s.cfg.Telnet.Address)
for {
conn, err := ln.Accept()
if err != nil {
// Listener was closed (shutdown)
if opErr, ok := err.(*net.OpError); ok && !opErr.Temporary() {
return nil
}
log.Printf("Accept error: %v", err)
continue
}
go s.handleConnection(conn)
}
}
// Close shuts down the listener and disconnects all active sessions.
func (s *Server) Close() error {
s.mu.Lock()
for _, sess := range s.sessions {
sess.Close(session.DisconnectKicked)
}
s.mu.Unlock()
if s.listener != nil {
return s.listener.Close()
}
return nil
}
// --- WebTokenizer implementation ---
// These methods let the menu layer generate web access tokens for
// HTTP file downloads without coupling to the token store directly.
func (s *Server) GenerateWebToken(userID int64, userName string, secLibrary int) (string, error) {
return s.Tokens.Generate(userID, userName, secLibrary)
}
func (s *Server) HTTPAddress() string {
if s.cfg.HTTP.Enabled {
return s.cfg.HTTP.Address
}
return ""
}
// --- ChatAgent implementation ---
// These methods let the menu layer manage chat sessions without
// coupling to the ChatManager directly.
func (s *Server) EnterChat(node int) <-chan string { return s.Chat.EnterChat(node) }
func (s *Server) LinkChat(a, b int) bool { return s.Chat.LinkChat(a, b) }
func (s *Server) SendChat(from int, msg string) bool { return s.Chat.SendChat(from, msg) }
func (s *Server) EndChat(node int) { s.Chat.EndChat(node) }
func (s *Server) ChatPartner(node int) int { return s.Chat.Partner(node) }
func (s *Server) IsInChat(node int) bool { return s.Chat.IsInChat(node) }
// --- NodeManager interface implementation ---
// These methods allow the menu layer to list, message, and disconnect
// nodes without coupling to the server's internal session management.
// ActiveNodes returns info about all currently connected sessions.
// Results are sorted by node number for stable display.
func (s *Server) ActiveNodes() []menu.NodeInfo {
s.mu.Lock()
defer s.mu.Unlock()
nodes := make([]menu.NodeInfo, 0, len(s.sessions))
for _, sess := range s.sessions {
nodes = append(nodes, menu.NodeInfo{
Node: sess.Node,
RemoteAddr: sess.RemoteAddr(),
UserName: sess.UserName,
UserID: sess.UserID,
ConnectedAt: sess.ConnectedAt,
})
}
sort.Slice(nodes, func(i, j int) bool {
return nodes[i].Node < nodes[j].Node
})
return nodes
}
// DisconnectNode forcibly disconnects the given node number.
func (s *Server) DisconnectNode(node int) error {
s.mu.Lock()
sess, ok := s.sessions[node]
s.mu.Unlock()
if !ok {
return fmt.Errorf("node %d not found", node)
}
// Send a courtesy message before disconnecting
sess.Color(session.AnsiFgRed, session.AnsiBold)
sess.WriteString("\r\n*** Disconnected by sysop ***\r\n")
sess.Color(session.AnsiReset)
sess.Close(session.DisconnectKicked)
return nil
}
// SendToNode sends a message string to the given node's terminal.
func (s *Server) SendToNode(node int, msg string) error {
s.mu.Lock()
sess, ok := s.sessions[node]
s.mu.Unlock()
if !ok {
return fmt.Errorf("node %d not found", node)
}
return sess.WriteString(msg)
}
// --- Node number recycling ---
// allocNode assigns the lowest available node number (1-maxNodes).
// Returns 0 if all nodes are in use.
// Must be called with s.mu held.
func (s *Server) allocNode() int {
for i := 1; i <= maxNodes; i++ {
byteIdx := (i - 1) / 8
bitIdx := uint((i - 1) % 8)
if s.nodeBitmap[byteIdx]&(1<<bitIdx) == 0 {
s.nodeBitmap[byteIdx] |= 1 << bitIdx
return i
}
}
return 0 // All nodes in use
}
// freeNode returns a node number to the pool.
// Must be called with s.mu held.
func (s *Server) freeNode(node int) {
if node < 1 || node > maxNodes {
return
}
byteIdx := (node - 1) / 8
bitIdx := uint((node - 1) % 8)
s.nodeBitmap[byteIdx] &^= 1 << bitIdx
}
// --- Connection handling ---
// handleConnection manages a single telnet session from connect to
// disconnect. In the original TAG-BBS, this entire flow was the main
// loop body — Reset_System, Await_Logon, Logon_Sequence, Menu, logoff.
// Here each connection gets its own goroutine and Session.
func (s *Server) handleConnection(conn net.Conn) {
s.mu.Lock()
nodeNum := s.allocNode()
if nodeNum == 0 {
s.mu.Unlock()
// All nodes in use — reject the connection.
conn.Write([]byte("All nodes are busy. Please try again later.\r\n"))
conn.Close()
log.Printf("Connection rejected (all %d nodes in use): %s",
maxNodes, conn.RemoteAddr())
return
}
s.mu.Unlock()
// Create the session — this replaces the global User struct,
// IO_Flags array, and jmp_buf Environment from the original.
sess := session.New(conn, nodeNum)
s.mu.Lock()
s.sessions[nodeNum] = sess
s.mu.Unlock()
log.Printf("[Node %d] Connected: %s", nodeNum, sess.RemoteAddr())
defer func() {
reason := sess.Reason()
sess.Close(reason)
// Clean up any active chat session for this node
s.Chat.EndChat(nodeNum)
s.mu.Lock()
delete(s.sessions, nodeNum)
s.freeNode(nodeNum)
s.mu.Unlock()
log.Printf("[Node %d] Disconnected: %s (%s)",
nodeNum, sess.RemoteAddr(), reason)
}()
// Send telnet negotiation — puts client into character mode with
// server-side echo. This replaces the modem handshake/baud detection
// that Await_Logon() did in the original.
if err := sess.Negotiate(); err != nil {
log.Printf("[Node %d] Negotiation error: %v", nodeNum, err)
return
}
// Run the BBS session: banner → login → menu → logoff.
// This is the modernized equivalent of the original's
// MenuSend(Logon.Text) → Logon_Sequence() → Menu() → logoff flow.
s.runSession(sess)
}
// runSession is the main BBS session handler. It runs the full
// lifecycle: welcome banner → authentication → main menu → logoff.
//
// In the original TAG-BBS, this was the body of the FOREVER loop in
// TAG.C: Reset_System → Await_Logon → Logon_Sequence → Menu → logoff.
func (s *Server) runSession(sess *session.Session) {
sess.ClearScreen()
// Welcome banner
sess.Color(session.AnsiFgCyan, session.AnsiBold)
sess.WriteString("━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━\r\n")
sess.Color(session.AnsiFgBrightWhite)
sess.Printf(" %s\r\n", s.cfg.System.Name)
sess.Color(session.AnsiFgCyan)
sess.WriteString(" Running URIT BBS v0.2.0\r\n")
sess.Printf(" Operated by %s\r\n", s.cfg.System.Sysop)
sess.Color(session.AnsiFgCyan, session.AnsiBold)
sess.WriteString("━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━\r\n")
sess.Color(session.AnsiReset)
sess.NewLine()
// Welcome screen file (optional)
sess.SendFile(s.cfg.System.Screens + "welcome.ans")
// Authentication
result, err := auth.Login(sess, s.store, s.cfg)
if err != nil {
log.Printf("[Node %d] Auth error: %v", sess.Node, err)
return
}
user := result.User
log.Printf("[Node %d] Logged in: %s (ID=%d, Status=%s)",
sess.Node, user.Name, user.ID, user.StatusLabel())
// Update session identity — makes this info available via NodeManager
// so cmdWho and the sysop console can show who's logged in.
sess.UserName = user.Name
sess.UserID = user.ID
// --- Login event logging and stats ---
// This replaces Append_Stat(STAT_LOGON) and the per-type call
// counters from the original TAG-BBS.
s.store.LogEvent("login", user.ID, user.Name, sess.Node,
sess.RemoteAddr(), user.StatusLabel())
s.store.IncrementStat("total_calls", 1)
switch {
case user.IsGuest():
s.store.IncrementStat("guest_calls", 1)
case user.IsNew():
s.store.IncrementStat("new_calls", 1)
default:
s.store.IncrementStat("valid_calls", 1)
}
if result.IsNew {
s.store.IncrementStat("new_accounts", 1)
}
// Deferred logoff tracking — runs when the session ends for any
// reason (normal logoff, timeout, kick, disconnect). This replaces
// Append_Stat(STAT_LOGOFF) and the time accounting from TAG.C.
defer func() {
reason := sess.Reason()
elapsed := time.Since(sess.ConnectedAt)
s.store.LogEvent("logoff", user.ID, user.Name, sess.Node,
sess.RemoteAddr(), reason.String())
s.store.IncrementStat("total_time_secs", int64(elapsed.Seconds()))
// Update user's time tracking in the database.
// TimeUsed is per-session; TimeTotal is cumulative.
if !result.IsGuest && user.ID > 0 {
secs := int64(elapsed.Seconds())
user.TimeUsed = secs
user.TimeTotal += secs
now := time.Now()
user.LastOn = &now
if err := s.store.UpdateUser(user); err != nil {
log.Printf("[Node %d] Error updating user time: %v", sess.Node, err)
}
}
}()
// --- Logon sequence (replaces TAG.C post-login and MENU.C preamble) ---
// 1. Time reset logic (matches original MENU.C)
// Must happen first — if the user has no time, we disconnect them
// immediately rather than showing informational screens.
// Original: if >12 hours since last login or sysop, reset TimeUsed;
// otherwise carry forward remaining time from previous session.
if !result.IsGuest && user.ID > 0 {
resetTime := user.SecStatus == 255 // Sysop always gets full time
if user.LastOn != nil {
elapsed := time.Since(*user.LastOn)
if elapsed >= 12*time.Hour {
resetTime = true
}
} else {
resetTime = true // First login ever
}
if resetTime {
user.TimeUsed = 0
}
// Apply user's time limit to the session, accounting for time
// already used (carried forward from a recent session).
limit := time.Duration(user.TimeLimit) * time.Second
used := time.Duration(user.TimeUsed) * time.Second
remaining := limit - used
if remaining <= 0 {
// No time left — show the "no time" screen and disconnect.
// Matches original: MenuSend("Logon24hrs.Text") then longjmp.
sess.SendFile(s.cfg.System.Screens + "notime.ans")
sess.Color(session.AnsiFgRed)
sess.WriteString(" You have no time remaining. Try again later.\r\n")
sess.Color(session.AnsiReset)
sess.NewLine()
log.Printf("[Node %d] No time remaining for %s, disconnecting",
sess.Node, user.Name)
return
}
sess.TimeLimit = remaining
}
// 2. Post-login screen files
// The original had separate files: Logon.Text, GuestLogon.Text.
// We add newuser.ans for first-time registrations.
if result.IsGuest {
sess.SendFile(s.cfg.System.Screens + "guest.ans")
} else if result.IsNew {
sess.SendFile(s.cfg.System.Screens + "newuser.ans")
} else {
sess.SendFile(s.cfg.System.Screens + "logon.ans")
}
// 3. Last caller display
// Not in the original (single-node Amiga), but a classic BBS feature.
// Shows who was the last person to log in before the current user.
if lastCaller, err := s.store.GetLastCaller(user.ID); err == nil && lastCaller != nil {
sess.Color(session.AnsiFgBrightBlack)
sess.Printf(" Last caller: %s on %s\r\n",
lastCaller.UserName,
lastCaller.CreatedAt.Format("Jan 02 at 3:04 PM"))
sess.Color(session.AnsiReset)
}
// 4. Unread mail check
if !result.IsGuest && user.ID > 0 {
unread, _ := s.store.CountUnreadMail(user.ID)
if unread > 0 {
sess.Color(session.AnsiFgBrightYellow)
sess.Printf("*** You have %d unread mail message(s) ***\r\n", unread)
sess.Color(session.AnsiReset)
sess.NewLine()
}
}
// 5. New user validation notice
// The original had security levels: 0=guest, 1=new/unvalidated,
// 2+=validated. New users could use the BBS but with restricted
// board/library access until the sysop validated them (which
// bumped their security levels up).
if !result.IsGuest && user.IsNew() {
sess.Color(session.AnsiFgYellow)
sess.WriteString(" Your account is awaiting validation by the sysop.\r\n")
sess.WriteString(" Some areas may have restricted access.\r\n")
sess.Color(session.AnsiReset)
sess.NewLine()
}
// 6. System call count and session info
totalCalls, _ := s.store.GetStat("total_calls")
if totalCalls > 0 {
sess.Color(session.AnsiFgBrightBlack)
sess.Printf(" Call #%d", totalCalls)
if !result.IsGuest && user.LastOn != nil {
sess.Printf(" | Last on: %s", user.LastOn.Format("Jan 02 at 3:04 PM"))
}
sess.WriteString("\r\n")
sess.Color(session.AnsiReset)
}
// Post-login info
sess.Color(session.AnsiFgBrightBlack)
width, height := sess.TerminalSize()
sess.Printf(" Node %d | %s | Terminal %dx%d | Time limit %s\r\n",
sess.Node, user.StatusLabel(), width, height,
fmtDuration(sess.TimeRemaining()))
sess.Color(session.AnsiReset)
sess.NewLine()
// Hand off to the main menu loop — replaces the original's Menu() call.
ctx := &menu.Context{
Sess: sess,
User: user,
Store: s.store,
Cfg: s.cfg,
Auth: result,
Nodes: s, // Server implements NodeManager
Tokens: s, // Server implements WebTokenizer
Chat: s, // Server implements ChatAgent
}
menu.Run(ctx)
}
// fmtDuration formats a time.Duration as "Xh Ym" for display.
func fmtDuration(d time.Duration) string {
mins := int(d.Minutes())
hours := mins / 60
mins = mins % 60
if hours > 0 {
return fmt.Sprintf("%dh %dm", hours, mins)
}
return fmt.Sprintf("%dm", mins)
}