zlh-agent/internal/http/websocket.go
2025-12-13 20:54:18 +00:00

169 lines
3.9 KiB
Go

package agenthttp
import (
"bufio"
"crypto/sha1"
"encoding/base64"
"fmt"
"log"
"net"
"net/http"
"os"
"path/filepath"
"strings"
"time"
"zlh-agent/internal/provision"
"zlh-agent/internal/state"
)
/*
websocket.go
ZeroLagHub Agent — Real-time Log WebSocket
Endpoint:
GET /console/stream
*/
const maxInitialTail = 4096 // 4 KB
/* --------------------------------------------------------------------------
Minimal WebSocket Upgrader (stdlib only)
----------------------------------------------------------------------------*/
type WebSocketConn struct {
Conn net.Conn
Rw *bufio.ReadWriter
}
func upgradeToWebSocket(w http.ResponseWriter, r *http.Request) (*WebSocketConn, error) {
if !strings.Contains(strings.ToLower(r.Header.Get("Connection")), "upgrade") ||
strings.ToLower(r.Header.Get("Upgrade")) != "websocket" {
return nil, fmt.Errorf("invalid websocket upgrade request")
}
key := r.Header.Get("Sec-WebSocket-Key")
if key == "" {
return nil, fmt.Errorf("missing Sec-WebSocket-Key")
}
accept := computeAcceptKey(key)
h := w.Header()
h.Set("Upgrade", "websocket")
h.Set("Connection", "Upgrade")
h.Set("Sec-WebSocket-Accept", accept)
h.Set("Sec-WebSocket-Version", "13")
w.WriteHeader(http.StatusSwitchingProtocols)
hj, ok := w.(http.Hijacker)
if !ok {
return nil, fmt.Errorf("websocket: hijacking not supported")
}
conn, rw, err := hj.Hijack()
if err != nil {
return nil, fmt.Errorf("websocket hijack: %w", err)
}
return &WebSocketConn{
Conn: conn,
Rw: rw,
}, nil
}
func (c *WebSocketConn) WriteText(msg string) error {
payload := []byte(msg)
// FIN + opcode(1 = text)
header := []byte{0x81}
// Length encoding
if len(payload) < 126 {
header = append(header, byte(len(payload)))
} else {
header = append(header, 126, byte(len(payload)>>8), byte(len(payload)))
}
if _, err := c.Conn.Write(header); err != nil {
return err
}
_, err := c.Conn.Write(payload)
return err
}
func (c *WebSocketConn) Close() error {
return c.Conn.Close()
}
/* --------------------------------------------------------------------------
SHA-1 + Base64 for Sec-WebSocket-Accept
----------------------------------------------------------------------------*/
func computeAcceptKey(key string) string {
const magic = "258EAFA5-E914-47DA-95CA-C5AB0DC85B11"
h := sha1.Sum([]byte(key + magic))
return base64.StdEncoding.EncodeToString(h[:])
}
/* --------------------------------------------------------------------------
MAIN HANDLER — /console/stream
----------------------------------------------------------------------------*/
func handleConsoleStream(w http.ResponseWriter, r *http.Request) {
cfg, err := state.LoadConfig()
if err != nil {
http.Error(w, "no config loaded", http.StatusBadRequest)
return
}
ws, err := upgradeToWebSocket(w, r)
if err != nil {
log.Println("[ws] upgrade failed:", err)
http.Error(w, "websocket upgrade failed", http.StatusBadRequest)
return
}
defer ws.Close()
dir := provision.ServerDir(*cfg)
logfile := filepath.Join(dir, "logs", "latest.log")
f, err := os.Open(logfile)
if err != nil {
_ = ws.WriteText(fmt.Sprintf("[error] cannot open log: %v", err))
return
}
defer f.Close()
// 1) Send last 4 KB (initial tail)
stat, _ := f.Stat()
sz := stat.Size()
if sz > maxInitialTail {
_, _ = f.Seek(sz-maxInitialTail, 0)
}
scanner := bufio.NewScanner(f)
for scanner.Scan() {
_ = ws.WriteText(scanner.Text())
}
// 2) Follow the file — stream new log lines live
for {
time.Sleep(500 * time.Millisecond)
for scanner.Scan() {
line := scanner.Text()
_ = ws.WriteText(line)
}
// on EOF, loop continues and scanner will pick up new lines
}
}
/* --------------------------------------------------------------------------
Register handler in NewMux()
----------------------------------------------------------------------------*/
func registerWebSocket(m *http.ServeMux) {
m.HandleFunc("/console/stream", handleConsoleStream)
}