feat(muxterm): WebSocket server with HMAC token auth and PTY relay

This commit is contained in:
Ken
2026-05-28 06:15:05 +00:00
parent 29aabcfd4f
commit 10b57c8f60
6 changed files with 364 additions and 8 deletions
+188
View File
@@ -0,0 +1,188 @@
package main
import (
"crypto/hmac"
"crypto/sha256"
"encoding/hex"
"io"
"log"
"net/http"
"os"
"strconv"
"strings"
"sync"
"syscall"
"time"
"github.com/gorilla/websocket"
)
// ValidateToken checks an HMAC-SHA256 signed token of the form "signature.timestamp".
// It validates the timestamp is within ttl seconds (not too old) and within 5 seconds
// of clock skew (not too far in the future), then verifies the HMAC signature.
func ValidateToken(token, secret string, ttl int64) bool {
parts := strings.SplitN(token, ".", 2)
if len(parts) != 2 {
return false
}
sig := parts[0]
tsStr := parts[1]
ts, err := strconv.ParseInt(tsStr, 10, 64)
if err != nil {
return false
}
now := time.Now().Unix()
if now-ts > ttl {
return false
}
if ts-now > 5 {
return false
}
mac := hmac.New(sha256.New, []byte(secret))
mac.Write([]byte(tsStr))
expected := hex.EncodeToString(mac.Sum(nil))
return hmac.Equal([]byte(sig), []byte(expected))
}
var upgrader = websocket.Upgrader{
CheckOrigin: func(r *http.Request) bool {
host := r.Host
if strings.Contains(host, ":") {
host = strings.Split(host, ":")[0]
}
return host == "localhost" || host == "127.0.0.1"
},
}
// HandleWebSocket validates the token query parameter and upgrades the connection
// to a WebSocket, then runs the main read loop for control and binary messages.
func HandleWebSocket(pool *Pool, secret string, w http.ResponseWriter, r *http.Request) {
token := r.URL.Query().Get("token")
if !ValidateToken(token, secret, 30) {
http.Error(w, "forbidden", http.StatusForbidden)
return
}
conn, err := upgrader.Upgrade(w, r, nil)
if err != nil {
log.Printf("ws upgrade: %v", err)
return
}
defer conn.Close()
var (
mu sync.Mutex
activeSession string
activePTY *os.File
ptyCancel chan struct{}
)
startRelay := func(sess *Session, name string) {
cancel := make(chan struct{})
mu.Lock()
ptyCancel = cancel
mu.Unlock()
go func() {
buf := make([]byte, 32*1024)
for {
select {
case <-cancel:
return
default:
}
n, err := sess.PTY.Read(buf)
if n > 0 {
if writeErr := conn.WriteMessage(websocket.BinaryMessage, buf[:n]); writeErr != nil {
return
}
}
if err != nil {
if err == io.EOF || strings.Contains(err.Error(), "input/output error") {
conn.WriteMessage(websocket.TextMessage, MarshalExited(name))
}
return
}
}
}()
}
stopRelay := func() {
mu.Lock()
defer mu.Unlock()
if ptyCancel != nil {
close(ptyCancel)
ptyCancel = nil
}
}
defer stopRelay()
for {
msgType, data, err := conn.ReadMessage()
if err != nil {
return
}
switch msgType {
case websocket.TextMessage:
kind, msg := ParseControlMessage(data)
switch kind {
case "attach":
attach := msg.(*AttachMsg)
stopRelay()
sess, err := pool.Attach(attach.Attach, 80, 24)
if err != nil {
conn.WriteMessage(websocket.TextMessage, MarshalError(err.Error()))
continue
}
mu.Lock()
activeSession = attach.Attach
activePTY = sess.PTY
mu.Unlock()
sendSIGWINCH(sess)
startRelay(sess, attach.Attach)
conn.WriteMessage(websocket.TextMessage, MarshalAttached(attach.Attach))
case "resize":
resize := msg.(*ResizeMsg)
mu.Lock()
name := activeSession
mu.Unlock()
if name != "" {
pool.Resize(name, uint16(resize.Resize.Cols), uint16(resize.Resize.Rows))
}
case "detach":
stopRelay()
mu.Lock()
activeSession = ""
activePTY = nil
mu.Unlock()
}
case websocket.BinaryMessage:
mu.Lock()
pty := activePTY
mu.Unlock()
if pty != nil {
pty.Write(data)
}
}
}
}
// sendSIGWINCH sends SIGWINCH to the session's process to trigger tmux repaint.
func sendSIGWINCH(s *Session) {
if s == nil || s.Cmd == nil || s.Cmd.Process == nil {
return
}
s.Cmd.Process.Signal(syscall.SIGWINCH)
}