aboutsummaryrefslogtreecommitdiff
path: root/internal/net
diff options
context:
space:
mode:
authorhistoria <[not public]>2026-06-11 04:46:27 -0400
committerhistoria <[not public]>2026-06-11 08:58:37 +0000
commit1b9e2da3b3c438d8dc53d3489725dd5ba0022777 (patch)
tree62264f24420bf4cfaa734942c65cc8dd45ba3d3b /internal/net
parent06c02a697e5daf8832a462de5970dd4c0e13a02c (diff)
downloadthehouseoficarus-1b9e2da3b3c438d8dc53d3489725dd5ba0022777.tar.gz
feat: web client, get/drop updates and fixes
Diffstat (limited to 'internal/net')
-rw-r--r--internal/net/conn.go101
-rw-r--r--internal/net/server.go153
-rw-r--r--internal/net/terminal.html164
-rw-r--r--internal/net/web.go72
-rw-r--r--internal/net/wsconn.go34
5 files changed, 484 insertions, 40 deletions
diff --git a/internal/net/conn.go b/internal/net/conn.go
new file mode 100644
index 0000000..e46031b
--- /dev/null
+++ b/internal/net/conn.go
@@ -0,0 +1,101 @@
+package net
+
+import (
+ "bufio"
+ "bytes"
+ "io"
+ "net"
+ "strings"
+)
+
+type Conn interface {
+ ReadMessage() (string, error)
+ io.Writer
+ io.Closer
+ SetEcho(bool) error
+}
+
+type tcpConn struct {
+ conn net.Conn
+ reader *bufio.Reader
+ echoOff bool
+}
+
+func newTCPConn(conn net.Conn) *tcpConn {
+ return &tcpConn{
+ conn: conn,
+ reader: bufio.NewReader(conn),
+ }
+}
+
+func (t *tcpConn) ReadMessage() (string, error) {
+ line, err := t.reader.ReadString('\n')
+ if err != nil {
+ return "", err
+ }
+ line = strings.TrimRight(line, "\r\n")
+ line = stripIAC(line)
+ if t.echoOff {
+ t.conn.Write([]byte("\r\n"))
+ }
+ return line, nil
+}
+
+func (t *tcpConn) Write(b []byte) (int, error) {
+ return t.conn.Write(b)
+}
+
+func (t *tcpConn) Close() error {
+ return t.conn.Close()
+}
+
+func (t *tcpConn) SetEcho(on bool) error {
+ t.echoOff = !on
+ if on {
+ return t.writeTelnetCmd(wont, echo)
+ }
+ return t.writeTelnetCmd(will, echo)
+}
+
+const (
+ iac = 255
+ will = 251
+ wont = 252
+ do = 253
+ dont = 254
+ sb = 250
+ se = 240
+ echo = 1
+)
+
+func (t *tcpConn) writeTelnetCmd(cmd, opt byte) error {
+ _, err := t.conn.Write([]byte{iac, cmd, opt})
+ return err
+}
+
+func stripIAC(s string) string {
+ b := []byte(s)
+ var out []byte
+ i := 0
+ iacSE := []byte{iac, se}
+ for i < len(b) {
+ if b[i] == iac && i+2 < len(b) {
+ if b[i+1] == sb {
+ end := bytes.Index(b[i+2:], iacSE)
+ if end >= 0 {
+ i += end + 4
+ continue
+ }
+ }
+ i += 3
+ continue
+ }
+ if b[i] == iac {
+ i++
+ continue
+ }
+ out = append(out, b[i])
+ i++
+ }
+ return string(out)
+}
diff --git a/internal/net/server.go b/internal/net/server.go
index 08d427f..7e73bc9 100644
--- a/internal/net/server.go
+++ b/internal/net/server.go
@@ -1,11 +1,13 @@
package net
import (
- "bufio"
+ "crypto/tls"
"fmt"
"log"
"net"
- "strings"
+ "net/http"
+
+ "thirdcollapse/internal/config"
)
type SessionState int
@@ -30,14 +32,13 @@ const (
)
type Session struct {
- Conn net.Conn
- Reader *bufio.Reader
- State SessionState
- Account *AccountEntry
- Player interface{} // *player.Player once character is selected
- PendingChar string // char being renamed/deleted
- PendingPass string // first password during signup
- Disconnecting bool
+ Conn Conn
+ State SessionState
+ Account *AccountEntry
+ Player interface{}
+ PendingChar string
+ PendingPass string
+ Disconnecting bool
DisconnectTicks int
}
@@ -49,14 +50,18 @@ type AccountEntry struct {
}
type Server struct {
- listener net.Listener
- hub *Hub
+ config *config.Config
+ telnetLn net.Listener
+ httpServer *http.Server
+ httpsServer *http.Server
+ hub *Hub
+ handler func(*Session, string)
}
type Hub struct {
- sessions map[*Session]bool
- rooms map[int]map[*Session]bool
- onRemove func(*Session)
+ sessions map[*Session]bool
+ rooms map[int]map[*Session]bool
+ onRemove func(*Session)
}
func NewHub() *Hub {
@@ -125,12 +130,40 @@ func (h *Hub) PlayersInRoom(roomID int) []*Session {
return out
}
-func NewServer(addr string) (*Server, error) {
- l, err := net.Listen("tcp", addr)
- if err != nil {
- return nil, err
+func NewServer(cfg *config.Config) (*Server, error) {
+ s := &Server{
+ config: cfg,
+ hub: NewHub(),
+ }
+
+ if cfg.Telnet.Enabled {
+ addr := fmt.Sprintf(":%d", cfg.Telnet.Port)
+ ln, err := net.Listen("tcp", addr)
+ if err != nil {
+ return nil, fmt.Errorf("telnet listen: %w", err)
+ }
+ s.telnetLn = ln
+ }
+
+ if cfg.HTTP.Enabled {
+ s.httpServer = &http.Server{
+ Addr: fmt.Sprintf(":%d", cfg.HTTP.Port),
+ Handler: newHTTPMux(s),
+ }
+ }
+
+ if cfg.HTTPS.Enabled {
+ if cfg.HTTPS.CertFile == "" || cfg.HTTPS.KeyFile == "" {
+ return nil, fmt.Errorf("https enabled but cert_file and key_file are required")
+ }
+ s.httpsServer = &http.Server{
+ Addr: fmt.Sprintf(":%d", cfg.HTTPS.Port),
+ Handler: newHTTPMux(s),
+ TLSConfig: &tls.Config{MinVersion: tls.VersionTLS12},
+ }
}
- return &Server{listener: l, hub: NewHub()}, nil
+
+ return s, nil
}
func (s *Server) Hub() *Hub {
@@ -138,31 +171,66 @@ func (s *Server) Hub() *Hub {
}
func (s *Server) ListenAndServe(handler func(*Session, string)) error {
+ s.handler = handler
+
+ if s.telnetLn != nil {
+ go s.serveTelnet()
+ }
+
+ if s.httpServer != nil {
+ go s.serveHTTP()
+ }
+
+ if s.httpsServer != nil {
+ go s.serveHTTPS()
+ }
+
+ if s.telnetLn == nil && s.httpServer == nil && s.httpsServer == nil {
+ return fmt.Errorf("no listeners configured")
+ }
+
+ select {}
+}
+
+func (s *Server) serveTelnet() {
for {
- conn, err := s.listener.Accept()
+ conn, err := s.telnetLn.Accept()
if err != nil {
- return err
+ return
}
- session := &Session{
- Conn: conn,
- Reader: bufio.NewReader(conn),
- State: StateAccountName,
+ sess := &Session{
+ Conn: newTCPConn(conn),
+ State: StateAccountName,
}
- s.hub.Add(session)
- go s.handleSession(session, handler)
+ s.hub.Add(sess)
+ go s.handleSession(sess, s.handler)
}
}
-func (s *Server) handleSession(session *Session, handler func(*Session, string)) {
+func (s *Server) serveHTTP() {
+ err := s.httpServer.ListenAndServe()
+ if err != nil && err != http.ErrServerClosed {
+ log.Printf("http server error: %v", err)
+ }
+}
+
+func (s *Server) serveHTTPS() {
+ err := s.httpsServer.ListenAndServeTLS(s.config.HTTPS.CertFile, s.config.HTTPS.KeyFile)
+ if err != nil && err != http.ErrServerClosed {
+ log.Printf("https server error: %v", err)
+ }
+}
+
+func (s *Server) handleSession(sess *Session, handler func(*Session, string)) {
defer func() {
if r := recover(); r != nil {
log.Printf("session panic: %v", r)
}
- s.hub.Remove(session)
- session.Conn.Close()
+ s.hub.Remove(sess)
+ sess.Close()
}()
- _, _ = session.Conn.Write([]byte("\033[2J\033[H")) // clear screen
+ sess.Conn.Write([]byte("\033[2J\033[H"))
welcomeart := `
, 3333333 333 333 333 3333333 3333333
@@ -179,29 +247,30 @@ func (s *Server) handleSession(session *Session, handler func(*Session, string))
:!! !!: !!! !!: !!: !!: !!! !!: !:! !!:
:: :: : : :. : : ::.: : : ::.: : : : : : ::.: : : :: :::
-
`
- session.Write(welcomeart)
- session.Write("ACCOUNT NAME> ")
+ sess.Write(welcomeart)
+ sess.Write("What's your account name? ")
for {
- line, err := session.Reader.ReadString('\n')
+ line, err := sess.Conn.ReadMessage()
if err != nil {
log.Printf("session read error: %v", err)
return
}
- line = strings.TrimSpace(line)
- handler(session, line)
+ if len(line) > 1024 {
+ line = line[:1024]
+ }
+ handler(sess, line)
}
}
func (sess *Session) Write(msg string) {
- _, _ = sess.Conn.Write([]byte(msg))
+ sess.Conn.Write([]byte(msg))
}
func (sess *Session) WriteLine(msg string) {
- _, _ = sess.Conn.Write([]byte(msg + "\r\n"))
+ sess.Conn.Write([]byte(msg + "\r\n"))
}
func (sess *Session) WriteLines(lines ...string) {
@@ -213,3 +282,7 @@ func (sess *Session) WriteLines(lines ...string) {
func (sess *Session) Writef(format string, args ...interface{}) {
sess.Write(fmt.Sprintf(format, args...))
}
+
+func (sess *Session) Close() error {
+ return sess.Conn.Close()
+}
diff --git a/internal/net/terminal.html b/internal/net/terminal.html
new file mode 100644
index 0000000..2d98178
--- /dev/null
+++ b/internal/net/terminal.html
@@ -0,0 +1,164 @@
+<!DOCTYPE html>
+<html lang="en">
+<head>
+<meta charset="utf-8">
+<meta name="viewport" content="width=device-width, initial-scale=1">
+<title>Third Collapse</title>
+<style>
+* { margin: 0; padding: 0; box-sizing: border-box; }
+body {
+ background: #0a0a0a;
+ color: #c0c0c0;
+ font-family: "Courier New", monospace;
+ font-size: 15px;
+ line-height: 1.25;
+ height: 100vh;
+ display: flex;
+ flex-direction: column;
+}
+#output {
+ flex: 1;
+ overflow-y: auto;
+ padding: 10px 14px;
+ white-space: pre-wrap;
+ word-break: break-all;
+}
+#input-line {
+ display: flex;
+ border-top: 1px solid #222;
+}
+#input-line span {
+ padding: 8px 0 8px 14px;
+ color: #888;
+}
+#input {
+ flex: 1;
+ background: transparent;
+ border: none;
+ color: #c0c0c0;
+ font-family: inherit;
+ font-size: inherit;
+ padding: 8px 14px 8px 4px;
+ outline: none;
+}
+</style>
+</head>
+<body>
+<pre id="output"></pre>
+<div id="input-line">
+ <span>&gt;</span>
+ <input type="text" id="input" autofocus autocomplete="off" spellcheck="false">
+</div>
+<script>
+(function () {
+ const out = document.getElementById("output");
+ const inp = document.getElementById("input");
+ const proto = location.protocol === "https:" ? "wss:" : "ws:";
+ const ws = new WebSocket(proto + "//" + location.host + "/ws");
+
+ function ansiToHTML(text) {
+ let out = "";
+ let i = 0;
+ const stack = [];
+ while (i < text.length) {
+ if (text[i] === "\x1b" && text[i + 1] === "[") {
+ let j = i + 2;
+ while (j < text.length && (text[j] < "A" || text[j] > "z" || text[j] === "[" || text[j] === ";")) j++;
+ if (j < text.length) j++;
+ const seq = text.slice(i + 2, j);
+ out += ansiSeq(seq, stack);
+ i = j;
+ continue;
+ }
+ out += escapeHTML(text[i]);
+ i++;
+ }
+ for (let k = stack.length - 1; k >= 0; k--) out += "</span>";
+ return out;
+ }
+
+ function escapeHTML(c) {
+ if (c === "&") return "&amp;";
+ if (c === "<") return "&lt;";
+ if (c === ">") return "&gt;";
+ return c;
+ }
+
+ function ansiSeq(seq, stack) {
+ if (seq === "0" || seq === "") {
+ let s = "";
+ for (let k = stack.length - 1; k >= 0; k--) s += "</span>";
+ stack.length = 0;
+ return s;
+ }
+ const codes = seq.endsWith("m") ? seq.slice(0, -1).split(";") : [];
+ let style = "";
+ for (const c of codes) {
+ switch (c) {
+ case "1": style += "font-weight:bold;"; break;
+ case "2": style += "opacity:0.6;"; break;
+ case "30": style += "color:#000;"; break;
+ case "31": style += "color:#c44;"; break;
+ case "32": style += "color:#4c4;"; break;
+ case "33": style += "color:#cc4;"; break;
+ case "34": style += "color:#44c;"; break;
+ case "35": style += "color:#c4c;"; break;
+ case "36": style += "color:#4cc;"; break;
+ case "37": style += "color:#ccc;"; break;
+ case "40": style += "background:#000;"; break;
+ case "41": style += "background:#c44;"; break;
+ case "42": style += "background:#4c4;"; break;
+ case "43": style += "background:#cc4;"; break;
+ case "44": style += "background:#44c;"; break;
+ case "45": style += "background:#c4c;"; break;
+ case "46": style += "background:#4cc;"; break;
+ case "47": style += "background:#ccc;"; break;
+ }
+ }
+ if (style) {
+ stack.push(1);
+ return '<span style="' + style + '">';
+ }
+ return "";
+ }
+
+ ws.onmessage = function (e) {
+ var data = e.data;
+ var re = /\x1b\]10;([01])\x07/g;
+ var match;
+ while ((match = re.exec(data)) !== null) {
+ inp.type = match[1] === "1" ? "password" : "text";
+ }
+ data = data.replace(re, "");
+ if (data.length > 0) {
+ out.innerHTML += ansiToHTML(data);
+ out.scrollTop = out.scrollHeight;
+ }
+ };
+
+ inp.addEventListener("keydown", function (e) {
+ if (e.key === "Enter") {
+ e.preventDefault();
+ var text = inp.value;
+ inp.value = "";
+ if (inp.type !== "password") {
+ out.innerHTML += ansiToHTML(text + "\n");
+ } else {
+ out.innerHTML += "\n";
+ }
+ out.scrollTop = out.scrollHeight;
+ ws.send(text);
+ }
+ });
+
+ ws.onclose = function () {
+ out.innerHTML += ansiToHTML("\n\u001b[31mConnection lost.\u001b[0m\n");
+ };
+
+ ws.onerror = function () {
+ out.innerHTML += ansiToHTML("\n\u001b[31mConnection error.\u001b[0m\n");
+ };
+})();
+</script>
+</body>
+</html>
diff --git a/internal/net/web.go b/internal/net/web.go
new file mode 100644
index 0000000..e573aba
--- /dev/null
+++ b/internal/net/web.go
@@ -0,0 +1,72 @@
+package net
+
+import (
+ "embed"
+ "log"
+ "net"
+ "net/http"
+ "strings"
+
+ "github.com/gorilla/websocket"
+)
+
+//go:embed terminal.html
+var terminalHTML embed.FS
+
+var upgrader = websocket.Upgrader{
+ ReadBufferSize: 1024,
+ WriteBufferSize: 1024,
+ CheckOrigin: func(r *http.Request) bool {
+ origin := r.Header.Get("Origin")
+ if origin == "" {
+ return true
+ }
+ host := r.Host
+ hostname, _, err := net.SplitHostPort(host)
+ if err != nil {
+ hostname = host
+ }
+ if strings.EqualFold(origin, "http://"+host) ||
+ strings.EqualFold(origin, "https://"+host) ||
+ strings.EqualFold(origin, "http://"+hostname) ||
+ strings.EqualFold(origin, "https://"+hostname) {
+ return true
+ }
+ if hn, _, err := net.SplitHostPort(r.RemoteAddr); err == nil &&
+ (hn == "127.0.0.1" || hn == "::1") {
+ return true
+ }
+ log.Printf("ws rejected origin: %s (host: %s)", origin, r.Host)
+ return false
+ },
+}
+
+func newHTTPMux(s *Server) *http.ServeMux {
+ mux := http.NewServeMux()
+
+ mux.HandleFunc("/", func(w http.ResponseWriter, r *http.Request) {
+ data, err := terminalHTML.ReadFile("terminal.html")
+ if err != nil {
+ http.Error(w, "not found", http.StatusNotFound)
+ return
+ }
+ w.Header().Set("Content-Type", "text/html; charset=utf-8")
+ w.Write(data)
+ })
+
+ mux.HandleFunc("/ws", func(w http.ResponseWriter, r *http.Request) {
+ ws, err := upgrader.Upgrade(w, r, nil)
+ if err != nil {
+ log.Printf("ws upgrade error: %v", err)
+ return
+ }
+ sess := &Session{
+ Conn: newWSConn(ws),
+ State: StateAccountName,
+ }
+ s.hub.Add(sess)
+ go s.handleSession(sess, s.handler)
+ })
+
+ return mux
+}
diff --git a/internal/net/wsconn.go b/internal/net/wsconn.go
new file mode 100644
index 0000000..d6f45dc
--- /dev/null
+++ b/internal/net/wsconn.go
@@ -0,0 +1,34 @@
+package net
+
+import (
+ "github.com/gorilla/websocket"
+)
+
+type wsConn struct {
+ conn *websocket.Conn
+}
+
+func newWSConn(conn *websocket.Conn) *wsConn {
+ return &wsConn{conn: conn}
+}
+
+func (w *wsConn) ReadMessage() (string, error) {
+ _, msg, err := w.conn.ReadMessage()
+ return string(msg), err
+}
+
+func (w *wsConn) Write(b []byte) (int, error) {
+ err := w.conn.WriteMessage(websocket.TextMessage, b)
+ return len(b), err
+}
+
+func (w *wsConn) Close() error {
+ return w.conn.Close()
+}
+
+func (w *wsConn) SetEcho(on bool) error {
+ if on {
+ return w.conn.WriteMessage(websocket.TextMessage, []byte("\x1b]10;0\x07"))
+ }
+ return w.conn.WriteMessage(websocket.TextMessage, []byte("\x1b]10;1\x07"))
+}