Critical fixes: - Fix Dockerfile: reorder stages so frontend assets embed into Go binary - Fix Go version 1.25 (nonexistent) to 1.24 across Dockerfile, go.mod, CI - Add graceful game server shutdown on SIGTERM/SIGINT - Order startup tasks: updates complete before auto-start - Fix TOCTOU race in UpdateSettings with atomic Update() method Security: - Add optional AUTH_TOKEN bearer auth middleware on API/WS routes - Fix path traversal in DeleteMod using filepath.Rel instead of HasPrefix - Add input validation for IPPort, ServerParameters, ScheduledUpdate Memory safety: - Cap RPT buffer allocation to 64KB to prevent OOM on large logs - Cap GetLog file read to 10MB - Fix context cancel leak in SteamCmdManager.run() - Remove data-raced cancel field in steamcmd.go - Atomic file writes (write-temp-then-rename) across all managers Reliability: - Log save errors in ProcessManager.Stop() - Atomic file writes prevent corruption on crash Tests: - Add mod_manager_test.go (12 tests: ListWorkshopMods, ListLocalMods, BuildUsageMap, RemoveMod, dirSize) - Add scheduler_test.go (6 tests: Start/Stop, Refresh with empty, invalid, valid, and replaced cron expressions) - Add TestRestart to server_process_test.go CI/Docs: - Add -race flag to go test in CI and Makefile - Add npm lint step to CI - Add Go/npm module caching to CI - Update README: prerequisites, AUTH_TOKEN/GIN_MODE/SERVERS_DIR docs, fix manual quickstart to use make build
286 lines
6.0 KiB
Go
286 lines
6.0 KiB
Go
package api
|
|
|
|
import (
|
|
"io"
|
|
"log"
|
|
"net/http"
|
|
"os"
|
|
"path/filepath"
|
|
"slices"
|
|
"sort"
|
|
"strings"
|
|
"time"
|
|
|
|
"github.com/gin-gonic/gin"
|
|
"github.com/gorilla/websocket"
|
|
)
|
|
|
|
var upgrader = websocket.Upgrader{
|
|
CheckOrigin: func(r *http.Request) bool { return true },
|
|
}
|
|
|
|
const wsReadTimeout = 60 * time.Second
|
|
|
|
func readPump(conn *websocket.Conn, done chan struct{}) {
|
|
defer close(done)
|
|
conn.SetReadLimit(4096)
|
|
conn.SetReadDeadline(time.Now().Add(wsReadTimeout))
|
|
conn.SetPongHandler(func(string) error {
|
|
conn.SetReadDeadline(time.Now().Add(wsReadTimeout))
|
|
return nil
|
|
})
|
|
for {
|
|
_, _, err := conn.ReadMessage()
|
|
if err != nil {
|
|
return
|
|
}
|
|
conn.SetReadDeadline(time.Now().Add(wsReadTimeout))
|
|
}
|
|
}
|
|
|
|
func (h *Handler) StreamLogs(c *gin.Context) {
|
|
conn, err := upgrader.Upgrade(c.Writer, c.Request, nil)
|
|
if err != nil {
|
|
log.Printf("websocket upgrade error: %v", err)
|
|
return
|
|
}
|
|
defer conn.Close()
|
|
|
|
done := make(chan struct{})
|
|
go readPump(conn, done)
|
|
|
|
clientID := conn.RemoteAddr().String()
|
|
ch := h.streamer.Subscribe("server", clientID)
|
|
defer h.streamer.Unsubscribe("server", clientID)
|
|
|
|
for {
|
|
select {
|
|
case line, ok := <-ch:
|
|
if !ok {
|
|
return
|
|
}
|
|
if err := conn.WriteMessage(websocket.TextMessage, []byte(line)); err != nil {
|
|
return
|
|
}
|
|
case <-done:
|
|
return
|
|
}
|
|
}
|
|
}
|
|
|
|
func (h *Handler) ListLogs(c *gin.Context) {
|
|
profilesDir := h.process.ProfilesDir()
|
|
files := h.collectLogFiles(profilesDir, "")
|
|
|
|
logsDir := filepath.Join(profilesDir, "logs")
|
|
if fi, err := os.Stat(logsDir); err == nil && fi.IsDir() {
|
|
files = append(files, h.collectLogFiles(logsDir, "logs")...)
|
|
}
|
|
|
|
slices.Sort(files)
|
|
if files == nil {
|
|
files = []string{}
|
|
}
|
|
c.JSON(http.StatusOK, files)
|
|
}
|
|
|
|
func (h *Handler) collectLogFiles(dir, prefix string) []string {
|
|
entries, err := os.ReadDir(dir)
|
|
if err != nil {
|
|
return nil
|
|
}
|
|
var files []string
|
|
for _, e := range entries {
|
|
if !e.IsDir() {
|
|
ext := filepath.Ext(e.Name())
|
|
if ext == ".log" || ext == ".rpt" || e.Name() == "script.log" {
|
|
if prefix != "" {
|
|
files = append(files, prefix+"/"+e.Name())
|
|
} else {
|
|
files = append(files, e.Name())
|
|
}
|
|
}
|
|
}
|
|
}
|
|
return files
|
|
}
|
|
|
|
func (h *Handler) StreamSteamCMDLogs(c *gin.Context) {
|
|
conn, err := upgrader.Upgrade(c.Writer, c.Request, nil)
|
|
if err != nil {
|
|
log.Printf("websocket upgrade error: %v", err)
|
|
return
|
|
}
|
|
defer conn.Close()
|
|
|
|
done := make(chan struct{})
|
|
go readPump(conn, done)
|
|
|
|
clientID := conn.RemoteAddr().String()
|
|
ch := h.streamer.Subscribe("steamcmd", clientID)
|
|
defer h.streamer.Unsubscribe("steamcmd", clientID)
|
|
|
|
for {
|
|
select {
|
|
case line, ok := <-ch:
|
|
if !ok {
|
|
return
|
|
}
|
|
if err := conn.WriteMessage(websocket.TextMessage, []byte(line)); err != nil {
|
|
return
|
|
}
|
|
case <-done:
|
|
return
|
|
}
|
|
}
|
|
}
|
|
|
|
func (h *Handler) StreamRPTLogs(c *gin.Context) {
|
|
conn, err := upgrader.Upgrade(c.Writer, c.Request, nil)
|
|
if err != nil {
|
|
log.Printf("websocket upgrade error: %v", err)
|
|
return
|
|
}
|
|
defer conn.Close()
|
|
|
|
done := make(chan struct{})
|
|
go readPump(conn, done)
|
|
|
|
profilesDir := h.process.ProfilesDir()
|
|
var currentPath string
|
|
var currentOffset int64
|
|
ticker := time.NewTicker(250 * time.Millisecond)
|
|
defer ticker.Stop()
|
|
|
|
for {
|
|
select {
|
|
case <-done:
|
|
return
|
|
case <-ticker.C:
|
|
}
|
|
|
|
path := findLatestRPT(profilesDir)
|
|
|
|
if path != currentPath {
|
|
currentPath = path
|
|
currentOffset = 0
|
|
if currentPath == "" {
|
|
continue
|
|
}
|
|
if err := conn.WriteMessage(websocket.TextMessage, []byte("--- tailing: "+filepath.Base(currentPath)+" ---")); err != nil {
|
|
return
|
|
}
|
|
}
|
|
if currentPath == "" {
|
|
continue
|
|
}
|
|
|
|
fi, err := os.Stat(currentPath)
|
|
if err != nil {
|
|
currentPath = ""
|
|
continue
|
|
}
|
|
if fi.Size() <= currentOffset {
|
|
continue
|
|
}
|
|
|
|
if err := func() error {
|
|
f, err := os.Open(currentPath)
|
|
if err != nil {
|
|
currentPath = ""
|
|
return nil
|
|
}
|
|
defer f.Close()
|
|
|
|
if _, err := f.Seek(currentOffset, io.SeekStart); err != nil {
|
|
return nil
|
|
}
|
|
remaining := fi.Size() - currentOffset
|
|
const maxBuf = 64 * 1024
|
|
bufSize := remaining
|
|
if bufSize > maxBuf {
|
|
bufSize = maxBuf
|
|
}
|
|
buf := make([]byte, bufSize)
|
|
n, _ := io.ReadFull(f, buf)
|
|
|
|
currentOffset += int64(n)
|
|
for _, line := range strings.Split(string(buf[:n]), "\n") {
|
|
if line == "" {
|
|
continue
|
|
}
|
|
if err := conn.WriteMessage(websocket.TextMessage, []byte(line)); err != nil {
|
|
return err
|
|
}
|
|
}
|
|
return nil
|
|
}(); err != nil {
|
|
return
|
|
}
|
|
}
|
|
}
|
|
|
|
func findLatestRPT(dir string) string {
|
|
pattern := filepath.Join(dir, "arma3server_x64_*.rpt")
|
|
matches, err := filepath.Glob(pattern)
|
|
if err != nil || len(matches) == 0 {
|
|
return ""
|
|
}
|
|
type rptInfo struct {
|
|
path string
|
|
time time.Time
|
|
}
|
|
var infos []rptInfo
|
|
for _, m := range matches {
|
|
fi, err := os.Stat(m)
|
|
if err != nil {
|
|
continue
|
|
}
|
|
infos = append(infos, rptInfo{path: m, time: fi.ModTime()})
|
|
}
|
|
if len(infos) == 0 {
|
|
return ""
|
|
}
|
|
sort.Slice(infos, func(i, j int) bool {
|
|
return infos[i].time.After(infos[j].time)
|
|
})
|
|
return infos[0].path
|
|
}
|
|
|
|
func (h *Handler) GetLog(c *gin.Context) {
|
|
filename := filepath.Base(c.Param("file"))
|
|
if filename == "" || filename == "." || strings.ContainsAny(filename, "/\\") {
|
|
c.JSON(http.StatusBadRequest, gin.H{"error": "invalid filename"})
|
|
return
|
|
}
|
|
profilesDir := h.process.ProfilesDir()
|
|
path := filepath.Join(profilesDir, filename)
|
|
|
|
if _, err := os.Stat(path); os.IsNotExist(err) {
|
|
logsPath := filepath.Join(profilesDir, "logs", filename)
|
|
if _, err2 := os.Stat(logsPath); err2 == nil {
|
|
path = logsPath
|
|
} else {
|
|
c.JSON(http.StatusNotFound, gin.H{"error": "file not found"})
|
|
return
|
|
}
|
|
}
|
|
|
|
fi, err := os.Stat(path)
|
|
if err != nil {
|
|
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
|
|
return
|
|
}
|
|
const maxLogSize = 10 * 1024 * 1024
|
|
if fi.Size() > maxLogSize {
|
|
c.JSON(http.StatusRequestEntityTooLarge, gin.H{"error": "log file too large, use live streaming"})
|
|
return
|
|
}
|
|
data, err := os.ReadFile(path)
|
|
if err != nil {
|
|
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
|
|
return
|
|
}
|
|
c.String(http.StatusOK, string(data))
|
|
}
|