diff --git a/_datafiles/config.yaml b/_datafiles/config.yaml index d42ea917..43000486 100755 --- a/_datafiles/config.yaml +++ b/_datafiles/config.yaml @@ -383,6 +383,19 @@ Network: # The port the server listens on for telnet connections. Listen on multiple # ports by separating them with commas. For example, [33333, 33334, 33335] TelnetPort: [33333, 44444] + # - SecureTelnetPort - + # Display-only: External ports where users connect via TLS proxy (e.g., stunnel4). + # These ports are shown on the website but NOT bound by the game server. + # Example: [33334] if stunnel4 listens on port 33334 + # Set to [0] to disable display. + SecureTelnetPort: [0] + # - SecureTelnetLocalPort - + # Internal ports where TLS proxy forwards secure connections (localhost only). + # Game server binds to these ports to receive forwarded TLS connections. + # Example: [9998] if stunnel4 forwards to localhost:9998 + # Multiple ports supported: [9998, 9997] for multiple TLS proxies + # Set to [0] to disable. + SecureTelnetLocalPort: [0] # - LocalPort - # A port that can only be accessed via localhost, but will not limit based on connection count LocalPort: 9999 diff --git a/_datafiles/html/public/index.html b/_datafiles/html/public/index.html index 8315eac6..fe60aaca 100644 --- a/_datafiles/html/public/index.html +++ b/_datafiles/html/public/index.html @@ -1,13 +1,40 @@ {{template "header" .}} -
- - Play - +
+ + Play + +
+

 

+
+
+
+
+

+ Telnet Port{{ if gt (len .CONFIG.Network.TelnetPort) 1 }}s{{end}} +

+

+ : {{ join .CONFIG.Network.TelnetPort ", " }} +

+
+ {{ if and .CONFIG.Network.SecureTelnetPort (ne (index + .CONFIG.Network.SecureTelnetPort 0) "0") }} +
+
+

+ Secure Telnet Port{{ if gt (len .CONFIG.Network.SecureTelnetPort) 1 + }}s{{end}} +

+

+ : {{ join .CONFIG.Network.SecureTelnetPort ", " }} +

+
+ {{ end }}
-

 

-
-

Telnet Port{{ if gt (len .CONFIG.Network.TelnetPort) 1 }}s{{end}}: {{ join .CONFIG.Network.TelnetPort ", " }}

+
-{{template "footer" .}} \ No newline at end of file +{{template "footer" .}} diff --git a/_datafiles/html/public/online.html b/_datafiles/html/public/online.html index 216da07a..31007d9f 100644 --- a/_datafiles/html/public/online.html +++ b/_datafiles/html/public/online.html @@ -12,6 +12,7 @@

Users Online:

Alignment Profession Time Online + Connection Role {{range $index, $uInfo := .STATS.OnlineUsers}} @@ -22,6 +23,7 @@

Users Online:

{{ $uInfo.Alignment }} {{ $uInfo.Profession }} {{ $uInfo.OnlineTimeStr }}{{ if $uInfo.IsAFK }} (AFK){{end}} + {{ $uInfo.ConnectionType }} {{ $uInfo.Role }} {{end}} diff --git a/internal/configs/config.network.go b/internal/configs/config.network.go index 89130f2b..712bcb37 100644 --- a/internal/configs/config.network.go +++ b/internal/configs/config.network.go @@ -1,17 +1,19 @@ package configs type Network struct { - MaxTelnetConnections ConfigInt `yaml:"MaxTelnetConnections"` // Maximum number of telnet connections to accept - TelnetPort ConfigSliceString `yaml:"TelnetPort"` // One or more Ports used to accept telnet connections - LocalPort ConfigInt `yaml:"LocalPort"` // Port used for admin connections, localhost only - HttpPort ConfigInt `yaml:"HttpPort"` // Port used for web requests - HttpsPort ConfigInt `yaml:"HttpsPort"` // Port used for web https requests - HttpsRedirect ConfigBool `yaml:"HttpsRedirect"` // If true, http traffic will be redirected to https - AfkSeconds ConfigInt `yaml:"AfkSeconds"` // How long until a player is marked as afk? - MaxIdleSeconds ConfigInt `yaml:"MaxIdleSeconds"` // How many seconds a player can go without a command in game before being kicked. - TimeoutMods ConfigBool `yaml:"TimeoutMods"` // Whether to kick admin/mods when idle too long. - ZombieSeconds ConfigInt `yaml:"ZombieSeconds"` // How many seconds a player will be a zombie allowing them to reconnect. - LogoutRounds ConfigInt `yaml:"LogoutRounds"` // How many rounds of uninterrupted meditation must be completed to log out. + MaxTelnetConnections ConfigInt `yaml:"MaxTelnetConnections"` // Maximum number of telnet connections to accept + TelnetPort ConfigSliceString `yaml:"TelnetPort"` // One or more Ports used to accept telnet connections + SecureTelnetPort ConfigSliceString `yaml:"SecureTelnetPort"` // Display-only: external ports where users connect via TLS + SecureTelnetLocalPort ConfigSliceString `yaml:"SecureTelnetLocalPort"` // Internal ports where TLS proxy forwards to (localhost only) + LocalPort ConfigInt `yaml:"LocalPort"` // Port used for admin connections, localhost only + HttpPort ConfigInt `yaml:"HttpPort"` // Port used for web requests + HttpsPort ConfigInt `yaml:"HttpsPort"` // Port used for web https requests + HttpsRedirect ConfigBool `yaml:"HttpsRedirect"` // If true, http traffic will be redirected to https + AfkSeconds ConfigInt `yaml:"AfkSeconds"` // How long until a player is marked as afk? + MaxIdleSeconds ConfigInt `yaml:"MaxIdleSeconds"` // How many seconds a player can go without a command in game before being kicked. + TimeoutMods ConfigBool `yaml:"TimeoutMods"` // Whether to kick admin/mods when idle too long. + ZombieSeconds ConfigInt `yaml:"ZombieSeconds"` // How many seconds a player will be a zombie allowing them to reconnect. + LogoutRounds ConfigInt `yaml:"LogoutRounds"` // How many rounds of uninterrupted meditation must be completed to log out. } func (n *Network) Validate() { diff --git a/internal/connections/connectiondetails.go b/internal/connections/connectiondetails.go index bcdfda48..8df05aa3 100644 --- a/internal/connections/connectiondetails.go +++ b/internal/connections/connectiondetails.go @@ -3,6 +3,7 @@ package connections import ( "errors" "net" + "strconv" "strings" "sync" "sync/atomic" @@ -278,6 +279,32 @@ func (cd *ConnectionDetails) RemoteAddr() net.Addr { return cd.conn.RemoteAddr() } +// GetLocalPort returns the local port the connection came in on +func (cd *ConnectionDetails) GetLocalPort() int { + var localAddr net.Addr + if cd.wsConn != nil { + localAddr = cd.wsConn.LocalAddr() + } else if cd.conn != nil { + localAddr = cd.conn.LocalAddr() + } else { + return 0 + } + + // Extract port from address + if tcpAddr, ok := localAddr.(*net.TCPAddr); ok { + return tcpAddr.Port + } + + // Try parsing as string + _, portStr, err := net.SplitHostPort(localAddr.String()) + if err != nil { + return 0 + } + + port, _ := strconv.Atoi(portStr) + return port +} + // get for uniqueId func (cd *ConnectionDetails) ConnectionId() ConnectionId { return ConnectionId(atomic.LoadUint64((*uint64)(&cd.connectionId))) diff --git a/internal/connections/connections.go b/internal/connections/connections.go index 5b3161ba..3fb3e6f7 100644 --- a/internal/connections/connections.go +++ b/internal/connections/connections.go @@ -80,6 +80,17 @@ func IsWebsocket(id ConnectionId) bool { return false } +func GetConnectionPort(id ConnectionId) int { + lock.Lock() + defer lock.Unlock() + + if cd, ok := netConnections[id]; ok { + return cd.GetLocalPort() + } + + return 0 +} + func GetAllConnectionIds() []ConnectionId { lock.Lock() diff --git a/internal/mapper/mapper.go b/internal/mapper/mapper.go index 044d5d50..a6c15492 100644 --- a/internal/mapper/mapper.go +++ b/internal/mapper/mapper.go @@ -998,15 +998,15 @@ func PreCacheMaps() { func validateRoomBiomes() { missingBiomeCount := 0 invalidBiomeCount := 0 - + for _, roomId := range rooms.GetAllRoomIds() { room := rooms.LoadRoom(roomId) if room == nil { continue } - + originalBiome := room.Biome - + // Check if room has no biome if originalBiome == "" { zoneBiome := rooms.GetZoneBiome(room.Zone) @@ -1022,7 +1022,7 @@ func validateRoomBiomes() { } } } - + if missingBiomeCount > 0 || invalidBiomeCount > 0 { mudlog.Info("Biome validation complete", "missing", missingBiomeCount, "invalid", invalidBiomeCount) } diff --git a/internal/rooms/rooms.go b/internal/rooms/rooms.go index 49906960..285d34f1 100644 --- a/internal/rooms/rooms.go +++ b/internal/rooms/rooms.go @@ -2198,7 +2198,6 @@ func (r *Room) Validate() error { } } - // Make sure all items are validated (and have uids) for i := range r.Items { r.Items[i].Validate() diff --git a/internal/users/onlineinfo.go b/internal/users/onlineinfo.go index 63e147f9..e4e80a4d 100644 --- a/internal/users/onlineinfo.go +++ b/internal/users/onlineinfo.go @@ -1,13 +1,14 @@ package users type OnlineInfo struct { - Username string - CharacterName string - Level int - Alignment string - Profession string - OnlineTime int64 - OnlineTimeStr string - IsAFK bool - Role string + Username string + CharacterName string + Level int + Alignment string + Profession string + OnlineTime int64 + OnlineTimeStr string + IsAFK bool + Role string + ConnectionType string // "Web", "Telnet", or "TLS" } diff --git a/internal/users/userrecord.go b/internal/users/userrecord.go index f98d36ef..9200a00a 100644 --- a/internal/users/userrecord.go +++ b/internal/users/userrecord.go @@ -5,6 +5,7 @@ import ( "fmt" "math" "math/big" + "strconv" "strings" "time" @@ -619,6 +620,24 @@ func (u *UserRecord) GetOnlineInfo() OnlineInfo { isAfk = true } + // Determine connection type + connectionType := "Telnet" + if connections.IsWebsocket(u.connectionId) { + connectionType = "Web" + } else { + // Check if connected through a secure telnet local port (where TLS proxy forwards) + port := connections.GetConnectionPort(u.connectionId) + networkConfig := configs.GetNetworkConfig() + + for _, securePortStr := range networkConfig.SecureTelnetLocalPort { + securePort, _ := strconv.Atoi(securePortStr) + if securePort > 0 && port == securePort { + connectionType = "TLS" + break + } + } + } + return OnlineInfo{ u.Username, u.Character.Name, @@ -629,6 +648,7 @@ func (u *UserRecord) GetOnlineInfo() OnlineInfo { timeStr, isAfk, u.Role, + connectionType, } } diff --git a/internal/web/stats.go b/internal/web/stats.go index e7ebd011..35f9231d 100644 --- a/internal/web/stats.go +++ b/internal/web/stats.go @@ -7,17 +7,19 @@ import ( ) type Stats struct { - OnlineUsers []users.OnlineInfo - TelnetPorts []int - WebSocketPort int + OnlineUsers []users.OnlineInfo + TelnetPorts []int + SecureTelnetPorts []int + WebSocketPort int } var ( statsLock = sync.RWMutex{} serverStats = Stats{ - WebSocketPort: 0, - OnlineUsers: []users.OnlineInfo{}, - TelnetPorts: []int{}, + WebSocketPort: 0, + OnlineUsers: []users.OnlineInfo{}, + TelnetPorts: []int{}, + SecureTelnetPorts: []int{}, } ) @@ -41,4 +43,5 @@ func (s *Stats) Reset() { s.WebSocketPort = 0 s.OnlineUsers = []users.OnlineInfo{} s.TelnetPorts = []int{} + s.SecureTelnetPorts = []int{} } diff --git a/internal/web/web.go b/internal/web/web.go index 0192756c..611843ed 100644 --- a/internal/web/web.go +++ b/internal/web/web.go @@ -42,6 +42,41 @@ type WebNav struct { Target string } +// getClientIP extracts the real client IP address from the request. +// It checks for X-Real-IP and X-Forwarded-For headers when the direct +// connection is from localhost (trusted proxy), otherwise returns the +// direct connection IP. +func getClientIP(r *http.Request) string { + remoteAddr := r.RemoteAddr + + host, _, err := net.SplitHostPort(remoteAddr) + if err != nil { + host = remoteAddr + } + + // Only trust proxy headers if the connection is from localhost + if host == "127.0.0.1" || host == "::1" || host == "localhost" { + // X-Real-IP has higher priority than X-Forwarded-For + if realIP := r.Header.Get("X-Real-IP"); realIP != "" { + return realIP + } + + if forwardedFor := r.Header.Get("X-Forwarded-For"); forwardedFor != "" { + // X-Forwarded-For can contain comma-separated IPs + // The first one is the original client + ips := strings.Split(forwardedFor, ",") + if len(ips) > 0 { + clientIP := strings.TrimSpace(ips[0]) + if clientIP != "" { + return clientIP + } + } + } + } + + return host +} + type WebPlugin interface { NavLinks() map[string]string // Name=>Path pairs WebRequest(r *http.Request) (html string, templateData map[string]any, ok bool) // Get the first handler of a given request @@ -106,7 +141,7 @@ func serveTemplate(w http.ResponseWriter, r *http.Request) { } if !pageFound || len(fileBase) > 0 && fileBase[0] == '_' { - mudlog.Info("Web", "ip", r.RemoteAddr, "ref", r.Header.Get("Referer"), "file path", fullPath, "file extension", fileExt, "error", "Not found") + mudlog.Info("Web", "ip", getClientIP(r), "ref", r.Header.Get("Referer"), "file path", fullPath, "file extension", fileExt, "error", "Not found") fullPath = filepath.Join(httpRoot, `404.html`) fInfo, err = os.Stat(fullPath) @@ -122,7 +157,7 @@ func serveTemplate(w http.ResponseWriter, r *http.Request) { } // Log the request - mudlog.Info("Web", "ip", r.RemoteAddr, "ref", r.Header.Get("Referer"), "file path", fullPath, "file extension", fileExt, "file source", source, "size", fmt.Sprintf(`%.2fk`, float64(fSize)/1024)) + mudlog.Info("Web", "ip", getClientIP(r), "ref", r.Header.Get("Referer"), "file path", fullPath, "file extension", fileExt, "file source", source, "size", fmt.Sprintf(`%.2fk`, float64(fSize)/1024)) // For non-HTML files, serve them statically. if fileExt != ".html" { diff --git a/main.go b/main.go index c782abfa..0542d7a2 100644 --- a/main.go +++ b/main.go @@ -271,6 +271,15 @@ func main() { TelnetListenOnPort(`127.0.0.1`, int(c.Network.LocalPort), &wg, 0) } + // Secure telnet local ports - where TLS proxy forwards to + for _, port := range c.Network.SecureTelnetLocalPort { + if p, err := strconv.Atoi(port); err == nil && p > 0 { + mudlog.Info("Telnet", "stage", "Listening on secure local port (localhost only)", "port", p) + // Same as LocalPort - localhost only, no connection limit + TelnetListenOnPort(`127.0.0.1`, p, &wg, 0) + } + } + go worldManager.InputWorker(workerShutdownChan, &wg) go worldManager.MainWorker(workerShutdownChan, &wg) diff --git a/world.go b/world.go index 16842d08..5ef0fb04 100644 --- a/world.go +++ b/world.go @@ -1057,6 +1057,13 @@ func (w *World) UpdateStats() { } } + for _, t := range c.SecureTelnetPort { + p, _ := strconv.Atoi(t) + if p > 0 { + s.SecureTelnetPorts = append(s.SecureTelnetPorts, p) + } + } + s.WebSocketPort = int(c.HttpPort) web.UpdateStats(s)