From e9d87838590a02c7ca028fad8cd16e42a582dd32 Mon Sep 17 00:00:00 2001 From: historia <[not public]> Date: Mon, 29 Jun 2026 01:00:17 -0400 Subject: feat: self-signed certificates --- AGENTS.md | 2 +- README.md | 2 +- cmd/mud/main.go | 4 +- config.yaml | 28 ++++++----- data/help/queue_silently.yaml | 15 ------ data/help/show_queued_cmds.yaml | 15 ++++++ internal/config/config.go | 32 ++++++------ internal/game/cmd_consume.go | 4 +- internal/game/cmd_verbs.go | 22 ++++---- internal/game/combat_mob.go | 2 +- internal/game/core_login_char.go | 4 +- internal/game/game.go | 8 +-- internal/net/server.go | 106 ++++++++++++++++++++++++++++++++------- internal/player/player.go | 2 +- 14 files changed, 158 insertions(+), 88 deletions(-) delete mode 100644 data/help/queue_silently.yaml create mode 100644 data/help/show_queued_cmds.yaml diff --git a/AGENTS.md b/AGENTS.md index ef87de7..4f547ab 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -69,7 +69,7 @@ Comprehensive integrity checks on all data files (errors logged, server still st ## Tick-Based Action Queue -`ProcessQueuedCommands()`: clear stale one-tick actions → execute free commands → execute active commands (sorted, first-queued wins conflicts) → advance walks → flush shared depletions. `ActionState` tracks player activity for `look`. Queue feedback suppressed by `queue_silently`. +`ProcessQueuedCommands()`: clear stale one-tick actions → execute free commands → execute active commands (sorted, first-queued wins conflicts) → advance walks → flush shared depletions. `ActionState` tracks player activity for `look`. Queue feedback shown by `show_queued_cmds`. ## Action System diff --git a/README.md b/README.md index 454758d..2444c4c 100644 --- a/README.md +++ b/README.md @@ -63,7 +63,7 @@ Telnet to the port configured in `config.yaml` or bring up the web client. The `thoi` binary checks for a file named config.yaml in $XDG_CONFIG_HOME/thoi or the local directory. It creates one if it's not found. -The game is designed around 600ms ticks, but you can change the `tick_length` here. You can also set up telnet, SSL telnet, HTTP, and HTTPS on different ports here. For the secure options you must provide a private key and certificate file path. THOI does not handle x509 certificates for you. Use something like [certbot](https://certbot.eff.org/) on your server if you want SSL telnet/HTTPS support. +The game is designed around 600ms ticks, but you can change the `tick_length` here. You can also set up telnet, SSL telnet, HTTP, and HTTPS on different ports here. For the secure options can provide a private key and certificate. Use something like [certbot](https://certbot.eff.org/). If you enable HTTPS/Telnet TLS without providing certs, a self-signed cert will be generated and used for you. # Setting diff --git a/cmd/mud/main.go b/cmd/mud/main.go index 4427a7d..db801e8 100644 --- a/cmd/mud/main.go +++ b/cmd/mud/main.go @@ -35,8 +35,8 @@ func main() { } } - g := game.New(dataDir, &cfg.Colors, cfg.Game.StartupValidation) - g.Ticks.Start(cfg.Game.TickLength) + g := game.New(dataDir, &cfg.DefaultColors, cfg.StartupValidation, cfg.StartingRoom) + g.Ticks.Start(cfg.TickLength) defer g.Ticks.Stop() g.Ticks.Subscribe(1, func() bool { diff --git a/config.yaml b/config.yaml index 3bcaada..a55e032 100644 --- a/config.yaml +++ b/config.yaml @@ -1,30 +1,35 @@ -game: - tick_length: 600 - startup_validation: - root_rooms: [1001] - ignore_unreachable: [] +tick_length: 600 + +starting_room: 1001 +startup_validation: + root_rooms: [1001] + ignore_unreachable: [] telnet: enabled: true port: 4000 +http: + enabled: true + port: 8888 + +# To use Telnet TLS or HTTPS, configure the path of a certificate and private +# key. If cert_file and key_file are left empty, a self-signed certificate is +# generated automatically on startup. + telnet_tls: enabled: false port: 4001 cert_file: "" key_file: "" -http: - enabled: true - port: 8888 - https: enabled: false port: 8443 cert_file: "" key_file: "" -colors: +default_colors: room_name: "51 bold" room_number: "F0" room_desc: "FC" @@ -34,7 +39,6 @@ colors: protected_mob: "off" mob: "off" damage: "C4" - xp: "DE" level_up: "E2 bold" item: "DF" @@ -50,8 +54,6 @@ colors: eat_food: "9C" drop_message: "BA" credits_pickup: "DC" - map_at: "0F" map_blocked: "C4" - diff --git a/data/help/queue_silently.yaml b/data/help/queue_silently.yaml deleted file mode 100644 index f1ca8b6..0000000 --- a/data/help/queue_silently.yaml +++ /dev/null @@ -1,15 +0,0 @@ -name: "queue_silently" -category: "Options" -description: | - Controls whether queued game actions produce confirmation messages. - - When on (default): No message is shown when you queue an action. The action - simply executes on the next game tick without fanfare. - - When off: A brief "You prepare to X." message is shown for each queued - action, confirming that it has been added to the queue. - - Usage: option queue_silently on - option queue_silently off - - See also: help queued, help actions, help option diff --git a/data/help/show_queued_cmds.yaml b/data/help/show_queued_cmds.yaml new file mode 100644 index 0000000..7f2a26a --- /dev/null +++ b/data/help/show_queued_cmds.yaml @@ -0,0 +1,15 @@ +name: "show_queued_cmds" +category: "Options" +description: | + Controls whether queued game actions show confirmation messages. + + When on: A brief "You prepare to X." message is shown for each queued + action, confirming that it has been added to the queue. + + When off (default): No message is shown when you queue an action. The action + simply executes on the next game tick without fanfare. + + Usage: option show_queued_cmds on + option show_queued_cmds off + + See also: help queued, help actions, help option diff --git a/internal/config/config.go b/internal/config/config.go index 91d5986..6393816 100644 --- a/internal/config/config.go +++ b/internal/config/config.go @@ -7,12 +7,14 @@ import ( ) type Config struct { - Game GameConfig `yaml:"game"` - Colors ColorsConfig `yaml:"colors"` - Telnet TelnetConfig `yaml:"telnet"` - TelnetTLS TelnetTLSConfig `yaml:"telnet_tls"` - HTTP HTTPConfig `yaml:"http"` - HTTPS HTTPSConfig `yaml:"https"` + TickLength int `yaml:"tick_length"` + StartingRoom int `yaml:"starting_room"` + StartupValidation ValidationConfig `yaml:"startup_validation"` + DefaultColors ColorsConfig `yaml:"default_colors"` + Telnet TelnetConfig `yaml:"telnet"` + TelnetTLS TelnetTLSConfig `yaml:"telnet_tls"` + HTTP HTTPConfig `yaml:"http"` + HTTPS HTTPSConfig `yaml:"https"` } type ColorsConfig map[string]string @@ -63,11 +65,6 @@ func DefaultColors() ColorsConfig { } } -type GameConfig struct { - TickLength int `yaml:"tick_length"` - StartupValidation ValidationConfig `yaml:"startup_validation"` -} - type ValidationConfig struct { RootRooms []int `yaml:"root_rooms"` IgnoreUnreachable []int `yaml:"ignore_unreachable"` @@ -99,14 +96,13 @@ type HTTPSConfig struct { func Default() *Config { return &Config{ - Game: GameConfig{ - TickLength: 600, - StartupValidation: ValidationConfig{ - RootRooms: []int{1}, - IgnoreUnreachable: []int{}, - }, + TickLength: 600, + StartingRoom: 1001, + StartupValidation: ValidationConfig{ + RootRooms: []int{1}, + IgnoreUnreachable: []int{}, }, - Colors: DefaultColors(), + DefaultColors: DefaultColors(), Telnet: TelnetConfig{ Enabled: true, Port: 4000, diff --git a/internal/game/cmd_consume.go b/internal/game/cmd_consume.go index 423cddb..aec33c5 100644 --- a/internal/game/cmd_consume.go +++ b/internal/game/cmd_consume.go @@ -71,7 +71,7 @@ func (g *Game) doEat(sess *net.Session, input string) { Timestamp: time.Now(), }) - if !p.OptionBool("queue_silently") { + if p.OptionBool("show_queued_cmds") { sess.WriteLine(fmt.Sprintf("You prepare to eat %s.", g.itemColorize(sess, def, def.Name))) } } @@ -206,7 +206,7 @@ func (g *Game) doDrink(sess *net.Session, args []string) { Timestamp: time.Now(), }) - if !p.OptionBool("queue_silently") { + if p.OptionBool("show_queued_cmds") { sess.WriteLine(fmt.Sprintf("You prepare to drink the %s.", g.itemColorize(sess, def, def.Name))) } } diff --git a/internal/game/cmd_verbs.go b/internal/game/cmd_verbs.go index d07ef2f..70b2fb2 100644 --- a/internal/game/cmd_verbs.go +++ b/internal/game/cmd_verbs.go @@ -9,7 +9,7 @@ import ( ) var alwaysAvailable = []string{ - "look", "l", + "look", "say", "global", "who", @@ -17,30 +17,30 @@ var alwaysAvailable = []string{ "map", "walk", "score", "stats", - "inventory", "inv", "i", - "equipment", "eq", "equip", "wear", "wield", - "remove", "unwear", "unwield", + "inventory", + "equip", + "remove", "style", - "tech", "t", - "option", "options", - "color", "colors", "colortable", + "tech", + "option", + "color", "colortable", "prompt", "alias", "unalias", "help", "queued", "stop", "aps", - "autotrigger", "auto", + "autotrigger", "trigger", "sneak", - "mods", "modules", "modlist", "mod", "module", - "id", "identify", + "mods", + "identify", "eat", "drink", "fletch", "clean", "burn", "stoke", "search", - "get", "take", "grab", "pick", + "get", "drop", } diff --git a/internal/game/combat_mob.go b/internal/game/combat_mob.go index a4bf435..4cdd27b 100644 --- a/internal/game/combat_mob.go +++ b/internal/game/combat_mob.go @@ -270,7 +270,7 @@ func (g *Game) killPlayer(sess *net.Session, p *player.Player, mob *world.MobIns } g.dropItemsOnDeath(p) p.HP = p.MaxHP() - p.RoomID = 1001 + p.RoomID = g.StartingRoom g.AccountStore.SaveCharacter(p) if g.Hub != nil { g.Hub.EnterRoom(sess, p.RoomID) diff --git a/internal/game/core_login_char.go b/internal/game/core_login_char.go index d705684..b1d3d26 100644 --- a/internal/game/core_login_char.go +++ b/internal/game/core_login_char.go @@ -41,8 +41,8 @@ func (g *Game) handleNewCharName(sess *net.Session, input string) { } p := player.New(name) - p.RoomID = 1001 - p.Stats.RecordRoomVisit(1001) + p.RoomID = g.StartingRoom + p.Stats.RecordRoomVisit(g.StartingRoom) if err := g.AccountStore.SaveCharacter(p); err != nil { sess.WriteLine(fmt.Sprintf("Error creating character: %v", err)) diff --git a/internal/game/game.go b/internal/game/game.go index bed75b0..c30f9aa 100644 --- a/internal/game/game.go +++ b/internal/game/game.go @@ -56,6 +56,7 @@ type Game struct { queue *CommandQueue safespot *SafespotManager ValidationConfig config.ValidationConfig + StartingRoom int // Per-tick / per-session runtime bookkeeping. charsMu sync.Mutex @@ -73,7 +74,7 @@ type Game struct { shutdownMu sync.Mutex } -func New(dataDir string, colorConfig *config.ColorsConfig, valConfig config.ValidationConfig) *Game { +func New(dataDir string, colorConfig *config.ColorsConfig, valConfig config.ValidationConfig, startingRoom int) *Game { g := &Game{ Deps: Deps{ World: world.New(dataDir), @@ -93,6 +94,7 @@ func New(dataDir string, colorConfig *config.ColorsConfig, valConfig config.Vali queue: NewCommandQueue(), safespot: NewSafespotManager(), ValidationConfig: valConfig, + StartingRoom: startingRoom, loggedInChars: make(map[string]*net.Session), restTimers: make(map[string]uint64), guardWatchTimers: make(map[string]int), @@ -251,7 +253,7 @@ func (g *Game) handleGameCommand(sess *net.Session, input string) { Args: strings.Join(parts[1:], " "), Timestamp: time.Now(), }) - if !p.OptionBool("queue_silently") { + if p.OptionBool("show_queued_cmds") { sess.WriteLine(fmt.Sprintf("You prepare to %s.", cmd)) } return @@ -270,7 +272,7 @@ func (g *Game) handleGameCommand(sess *net.Session, input string) { Timestamp: time.Now(), }) g.cancelRest(p.Name) - if !p.OptionBool("queue_silently") { + if p.OptionBool("show_queued_cmds") { sess.WriteLine(fmt.Sprintf("You prepare to %s.", cmd)) } } diff --git a/internal/net/server.go b/internal/net/server.go index 851a4b5..345122e 100644 --- a/internal/net/server.go +++ b/internal/net/server.go @@ -1,12 +1,18 @@ package net import ( + "crypto/rand" + "crypto/rsa" "crypto/tls" + "crypto/x509" + "crypto/x509/pkix" "fmt" "log" + "math/big" "net" "net/http" "sync" + "time" "thehouseoficarus/internal/behavior" "thehouseoficarus/internal/color" @@ -70,13 +76,14 @@ type Session struct { } type Server struct { - config *config.Config - telnetLn net.Listener - telnetTLSLn net.Listener - httpServer *http.Server - httpsServer *http.Server - hub *Hub - handler func(*Session, string) + config *config.Config + telnetLn net.Listener + telnetTLSLn net.Listener + httpServer *http.Server + httpsServer *http.Server + httpsTLSConfig *tls.Config + hub *Hub + handler func(*Session, string) } type Hub struct { @@ -192,12 +199,20 @@ func NewServer(cfg *config.Config) (*Server, error) { } if cfg.TelnetTLS.Enabled { + var cert tls.Certificate if cfg.TelnetTLS.CertFile == "" || cfg.TelnetTLS.KeyFile == "" { - return nil, fmt.Errorf("telnet_tls enabled but cert_file and key_file are required") - } - cert, err := tls.LoadX509KeyPair(cfg.TelnetTLS.CertFile, cfg.TelnetTLS.KeyFile) - if err != nil { - return nil, fmt.Errorf("telnet_tls cert: %w", err) + var err error + cert, err = generateSelfSignedCert() + if err != nil { + return nil, fmt.Errorf("telnet_tls self-signed cert: %w", err) + } + log.Printf("telnet_tls: using self-signed certificate (no cert_file/key_file configured)") + } else { + var err error + cert, err = tls.LoadX509KeyPair(cfg.TelnetTLS.CertFile, cfg.TelnetTLS.KeyFile) + if err != nil { + return nil, fmt.Errorf("telnet_tls cert: %w", err) + } } tlsCfg := &tls.Config{ Certificates: []tls.Certificate{cert}, @@ -219,19 +234,68 @@ func NewServer(cfg *config.Config) (*Server, error) { } if cfg.HTTPS.Enabled { + var cert tls.Certificate if cfg.HTTPS.CertFile == "" || cfg.HTTPS.KeyFile == "" { - return nil, fmt.Errorf("https enabled but cert_file and key_file are required") + var err error + cert, err = generateSelfSignedCert() + if err != nil { + return nil, fmt.Errorf("https self-signed cert: %w", err) + } + log.Printf("https: using self-signed certificate (no cert_file/key_file configured)") + } else { + var err error + cert, err = tls.LoadX509KeyPair(cfg.HTTPS.CertFile, cfg.HTTPS.KeyFile) + if err != nil { + return nil, fmt.Errorf("https cert: %w", err) + } + } + s.httpsTLSConfig = &tls.Config{ + Certificates: []tls.Certificate{cert}, + MinVersion: tls.VersionTLS12, } s.httpsServer = &http.Server{ - Addr: fmt.Sprintf(":%d", cfg.HTTPS.Port), - Handler: newHTTPMux(s), - TLSConfig: &tls.Config{MinVersion: tls.VersionTLS12}, + Addr: fmt.Sprintf(":%d", cfg.HTTPS.Port), + Handler: newHTTPMux(s), } } return s, nil } +func generateSelfSignedCert() (tls.Certificate, error) { + key, err := rsa.GenerateKey(rand.Reader, 2048) + if err != nil { + return tls.Certificate{}, fmt.Errorf("rsa key generation: %w", err) + } + serial, err := rand.Int(rand.Reader, new(big.Int).Lsh(big.NewInt(1), 128)) + if err != nil { + return tls.Certificate{}, fmt.Errorf("serial number: %w", err) + } + now := time.Now() + template := &x509.Certificate{ + SerialNumber: serial, + Subject: pkix.Name{ + CommonName: "THOI Self-Signed Certificate", + }, + NotBefore: now.Add(-1 * time.Hour), + NotAfter: now.Add(365 * 24 * time.Hour), + KeyUsage: x509.KeyUsageKeyEncipherment | x509.KeyUsageDigitalSignature, + ExtKeyUsage: []x509.ExtKeyUsage{x509.ExtKeyUsageServerAuth}, + BasicConstraintsValid: true, + IsCA: true, + DNSNames: []string{"localhost"}, + IPAddresses: []net.IP{net.ParseIP("127.0.0.1"), net.ParseIP("::1")}, + } + certDER, err := x509.CreateCertificate(rand.Reader, template, template, &key.PublicKey, key) + if err != nil { + return tls.Certificate{}, fmt.Errorf("x509 creation: %w", err) + } + return tls.Certificate{ + Certificate: [][]byte{certDER}, + PrivateKey: key, + }, nil +} + func (s *Server) Hub() *Hub { return s.hub } @@ -300,8 +364,14 @@ func (s *Server) serveHTTP() { } func (s *Server) serveHTTPS() { - err := s.httpsServer.ListenAndServeTLS(s.config.HTTPS.CertFile, s.config.HTTPS.KeyFile) - if err != nil && err != http.ErrServerClosed { + addr := fmt.Sprintf(":%d", s.config.HTTPS.Port) + ln, err := net.Listen("tcp", addr) + if err != nil { + log.Printf("https server error: %v", err) + return + } + tlsLn := tls.NewListener(ln, s.httpsTLSConfig) + if err := s.httpsServer.Serve(tlsLn); err != nil && err != http.ErrServerClosed { log.Printf("https server error: %v", err) } } diff --git a/internal/player/player.go b/internal/player/player.go index 7d9b397..845dbb0 100644 --- a/internal/player/player.go +++ b/internal/player/player.go @@ -124,7 +124,7 @@ var OptionDefs = []OptionDef{ {"map_height", OptInt, 20, nil, "Map height for the map command"}, {"map_padding", OptString, "none", []string{"none", "x", "y", "xy"}, "Map output padding mode"}, {"automap", OptBool, false, nil, "Show map automatically after moving"}, - {"queue_silently", OptBool, true, nil, "Suppress messages for queued tick actions"}, + {"show_queued_cmds", OptBool, false, nil, "Show confirmation messages for queued tick actions"}, {"room_desc_width", OptInt, 70, nil, "Maximum width for room descriptions"}, {"wrap_width", OptInt, 120, nil, "Wrap all output to this many columns (minimum 80)"}, {"unicode", OptBool, true, nil, "Unicode box-drawing characters"}, -- cgit v1.2.3