505 lines
15 KiB
Go
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)
|
|
}
|