Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
13 changes: 13 additions & 0 deletions _datafiles/config.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
43 changes: 35 additions & 8 deletions _datafiles/html/public/index.html
Original file line number Diff line number Diff line change
@@ -1,13 +1,40 @@
{{template "header" .}}

<div class="play-button">
<a href="/webclient">
<img src="{{ .CONFIG.FilePaths.WebCDNLocation }}/static/images/btn_play.png" alt="Play" />
</a>
<div class="play-button">
<a href="/webclient">
<img
src="{{ .CONFIG.FilePaths.WebCDNLocation }}/static/images/btn_play.png"
alt="Play"
/>
</a>
</div>
<p>&nbsp;</p>
<div style="text-align: center;">
<div class="underlay" style="width: auto; display: inline-block; padding-left: 20px; padding-right: 20px;">
<div style="display: table; margin: 0 auto">
<div style="display: table-row">
<h3 style="display: table-cell; text-align: right; padding-right: 0.5em">
Telnet Port{{ if gt (len .CONFIG.Network.TelnetPort) 1 }}s{{end}}
</h3>
<h3 style="display: table-cell; text-align: left">
: {{ join .CONFIG.Network.TelnetPort ", " }}
</h3>
</div>
{{ if and .CONFIG.Network.SecureTelnetPort (ne (index
.CONFIG.Network.SecureTelnetPort 0) "0") }}
<br />
<div style="display: table-row">
<h3 style="display: table-cell; text-align: right; padding-right: 0.5em">
Secure Telnet Port{{ if gt (len .CONFIG.Network.SecureTelnetPort) 1
}}s{{end}}
</h3>
<h3 style="display: table-cell; text-align: left">
: {{ join .CONFIG.Network.SecureTelnetPort ", " }}
</h3>
</div>
{{ end }}
</div>
<p>&nbsp;</p>
<div class="underlay">
<h3>Telnet Port{{ if gt (len .CONFIG.Network.TelnetPort) 1 }}s{{end}}: {{ join .CONFIG.Network.TelnetPort ", " }}</h3>
</div>
</div>

{{template "footer" .}}
{{template "footer" .}}
2 changes: 2 additions & 0 deletions _datafiles/html/public/online.html
Original file line number Diff line number Diff line change
Expand Up @@ -12,6 +12,7 @@ <h3>Users Online: </h3>
<th>Alignment</th>
<th>Profession</th>
<th>Time Online</th>
<th>Connection</th>
<th>Role</th>
</tr>
{{range $index, $uInfo := .STATS.OnlineUsers}}
Expand All @@ -22,6 +23,7 @@ <h3>Users Online: </h3>
<td align="center">{{ $uInfo.Alignment }}</td>
<td align="center">{{ $uInfo.Profession }}</td>
<td align="center">{{ $uInfo.OnlineTimeStr }}{{ if $uInfo.IsAFK }} (AFK){{end}}</td>
<td align="center">{{ $uInfo.ConnectionType }}</td>
<td align="center">{{ $uInfo.Role }}</td>
</tr>
{{end}}
Expand Down
24 changes: 13 additions & 11 deletions internal/configs/config.network.go
Original file line number Diff line number Diff line change
@@ -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() {
Expand Down
27 changes: 27 additions & 0 deletions internal/connections/connectiondetails.go
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,7 @@ package connections
import (
"errors"
"net"
"strconv"
"strings"
"sync"
"sync/atomic"
Expand Down Expand Up @@ -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)))
Expand Down
11 changes: 11 additions & 0 deletions internal/connections/connections.go
Original file line number Diff line number Diff line change
Expand Up @@ -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()
Expand Down
8 changes: 4 additions & 4 deletions internal/mapper/mapper.go
Original file line number Diff line number Diff line change
Expand Up @@ -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)
Expand All @@ -1022,7 +1022,7 @@ func validateRoomBiomes() {
}
}
}

if missingBiomeCount > 0 || invalidBiomeCount > 0 {
mudlog.Info("Biome validation complete", "missing", missingBiomeCount, "invalid", invalidBiomeCount)
}
Expand Down
1 change: 0 additions & 1 deletion internal/rooms/rooms.go
Original file line number Diff line number Diff line change
Expand Up @@ -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()
Expand Down
19 changes: 10 additions & 9 deletions internal/users/onlineinfo.go
Original file line number Diff line number Diff line change
@@ -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"
}
20 changes: 20 additions & 0 deletions internal/users/userrecord.go
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,7 @@ import (
"fmt"
"math"
"math/big"
"strconv"
"strings"
"time"

Expand Down Expand Up @@ -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,
Expand All @@ -629,6 +648,7 @@ func (u *UserRecord) GetOnlineInfo() OnlineInfo {
timeStr,
isAfk,
u.Role,
connectionType,
}
}

Expand Down
15 changes: 9 additions & 6 deletions internal/web/stats.go
Original file line number Diff line number Diff line change
Expand Up @@ -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{},
}
)

Expand All @@ -41,4 +43,5 @@ func (s *Stats) Reset() {
s.WebSocketPort = 0
s.OnlineUsers = []users.OnlineInfo{}
s.TelnetPorts = []int{}
s.SecureTelnetPorts = []int{}
}
39 changes: 37 additions & 2 deletions internal/web/web.go
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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)
Expand All @@ -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" {
Expand Down
9 changes: 9 additions & 0 deletions main.go
Original file line number Diff line number Diff line change
Expand Up @@ -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)

Expand Down
7 changes: 7 additions & 0 deletions world.go
Original file line number Diff line number Diff line change
Expand Up @@ -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)
Expand Down