+
+
+
+
+ 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)