Files
ArmA-3-web-server/backend/internal/api/logs.go
T
MrFastwind 4d6f162b8f fix: code quality, memory safety, and install improvements
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
2026-07-25 02:47:04 +02:00

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))
}