diff options
| author | historia <[not public]> | 2026-06-11 04:46:27 -0400 |
|---|---|---|
| committer | historia <[not public]> | 2026-06-11 08:58:37 +0000 |
| commit | 1b9e2da3b3c438d8dc53d3489725dd5ba0022777 (patch) | |
| tree | 62264f24420bf4cfaa734942c65cc8dd45ba3d3b /internal/net | |
| parent | 06c02a697e5daf8832a462de5970dd4c0e13a02c (diff) | |
| download | thehouseoficarus-1b9e2da3b3c438d8dc53d3489725dd5ba0022777.tar.gz | |
feat: web client, get/drop updates and fixes
Diffstat (limited to 'internal/net')
| -rw-r--r-- | internal/net/conn.go | 101 | ||||
| -rw-r--r-- | internal/net/server.go | 153 | ||||
| -rw-r--r-- | internal/net/terminal.html | 164 | ||||
| -rw-r--r-- | internal/net/web.go | 72 | ||||
| -rw-r--r-- | internal/net/wsconn.go | 34 |
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>></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 "&"; + if (c === "<") return "<"; + if (c === ">") return ">"; + 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")) +} |
