Compare commits
3
Commits
6f3175ac9a
..
main
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
676f85e753 | ||
|
|
4d6f162b8f | ||
|
|
8bf163a931 |
@@ -12,12 +12,17 @@ jobs:
|
|||||||
- uses: actions/checkout@v4
|
- uses: actions/checkout@v4
|
||||||
- uses: actions/setup-go@v5
|
- uses: actions/setup-go@v5
|
||||||
with:
|
with:
|
||||||
go-version: "1.25"
|
go-version: "1.24"
|
||||||
|
cache: true
|
||||||
|
cache-dependency-path: backend/go.sum
|
||||||
- uses: actions/setup-node@v4
|
- uses: actions/setup-node@v4
|
||||||
with:
|
with:
|
||||||
node-version: "22"
|
node-version: "22"
|
||||||
|
cache: "npm"
|
||||||
|
cache-dependency-path: frontend/package-lock.json
|
||||||
- run: cd frontend && npm ci && npm run build
|
- run: cd frontend && npm ci && npm run build
|
||||||
- run: mkdir -p backend/embed/dist && cp -r frontend/dist/. backend/embed/dist/
|
- run: mkdir -p backend/embed/dist && cp -r frontend/dist/. backend/embed/dist/
|
||||||
- run: cd backend && go build ./...
|
- run: cd backend && go build ./...
|
||||||
- run: cd backend && go test ./...
|
- run: cd backend && go test -race ./...
|
||||||
|
- run: cd frontend && npm run lint
|
||||||
- run: cd frontend && npm test
|
- run: cd frontend && npm test
|
||||||
|
|||||||
@@ -13,7 +13,7 @@ jobs:
|
|||||||
fetch-depth: 0
|
fetch-depth: 0
|
||||||
- uses: actions/setup-go@v5
|
- uses: actions/setup-go@v5
|
||||||
with:
|
with:
|
||||||
go-version: "1.25"
|
go-version: "1.24"
|
||||||
- uses: actions/setup-node@v4
|
- uses: actions/setup-node@v4
|
||||||
with:
|
with:
|
||||||
node-version: "22"
|
node-version: "22"
|
||||||
|
|||||||
+8
-7
@@ -1,10 +1,3 @@
|
|||||||
FROM golang:1.25-bookworm AS backend
|
|
||||||
WORKDIR /src
|
|
||||||
COPY backend/go.mod backend/go.sum ./
|
|
||||||
RUN go mod download
|
|
||||||
COPY backend/ .
|
|
||||||
RUN CGO_ENABLED=0 go build -o /server ./cmd/server/
|
|
||||||
|
|
||||||
FROM node:22-alpine AS frontend
|
FROM node:22-alpine AS frontend
|
||||||
WORKDIR /src
|
WORKDIR /src
|
||||||
COPY frontend/package.json frontend/package-lock.json ./
|
COPY frontend/package.json frontend/package-lock.json ./
|
||||||
@@ -12,6 +5,14 @@ RUN npm ci
|
|||||||
COPY frontend/ .
|
COPY frontend/ .
|
||||||
RUN npm run build
|
RUN npm run build
|
||||||
|
|
||||||
|
FROM golang:1.24-bookworm AS backend
|
||||||
|
WORKDIR /src
|
||||||
|
COPY backend/go.mod backend/go.sum ./
|
||||||
|
RUN go mod download
|
||||||
|
COPY --from=frontend /src/dist /src/embed/dist
|
||||||
|
COPY backend/ .
|
||||||
|
RUN CGO_ENABLED=0 go build -o /server ./cmd/server/
|
||||||
|
|
||||||
FROM debian:bookworm-slim
|
FROM debian:bookworm-slim
|
||||||
RUN apt-get update && \
|
RUN apt-get update && \
|
||||||
apt-get install -y --no-install-recommends \
|
apt-get install -y --no-install-recommends \
|
||||||
|
|||||||
@@ -42,7 +42,7 @@ clean:
|
|||||||
rm -rf frontend/dist backend/embed/dist bin
|
rm -rf frontend/dist backend/embed/dist bin
|
||||||
|
|
||||||
test-backend:
|
test-backend:
|
||||||
cd backend && go test ./...
|
cd backend && go test -race ./...
|
||||||
|
|
||||||
test-frontend:
|
test-frontend:
|
||||||
cd frontend && npm test
|
cd frontend && npm test
|
||||||
|
|||||||
@@ -24,18 +24,22 @@ The web UI is served on `http://localhost:8080`.
|
|||||||
|
|
||||||
### Manual
|
### Manual
|
||||||
|
|
||||||
```bash
|
**Prerequisites:**
|
||||||
# Backend
|
- [Go](https://go.dev/dl/) >= 1.24
|
||||||
cd backend
|
- [Node.js](https://nodejs.org/) >= 22 with npm
|
||||||
go build -o arma3-web-server ./cmd/server
|
- GNU Make (optional, for `make` targets)
|
||||||
./arma3-web-server
|
|
||||||
|
|
||||||
# Frontend (development)
|
```bash
|
||||||
cd frontend
|
# Build for production (frontend + backend in one binary)
|
||||||
npm install
|
make build
|
||||||
npm run dev
|
./backend/arma3-web-server
|
||||||
|
|
||||||
|
# Or develop with hot reload (uses test stubs, no real SteamCMD needed)
|
||||||
|
make dev
|
||||||
```
|
```
|
||||||
|
|
||||||
|
The web UI is served on `http://localhost:8080`.
|
||||||
|
|
||||||
## Configuration
|
## Configuration
|
||||||
|
|
||||||
All paths are configurable via environment variables:
|
All paths are configurable via environment variables:
|
||||||
@@ -51,6 +55,9 @@ All paths are configurable via environment variables:
|
|||||||
| `SERVER_BINARY` | `arma3server_x64` | Server binary filename (overrides platform default) |
|
| `SERVER_BINARY` | `arma3server_x64` | Server binary filename (overrides platform default) |
|
||||||
| `SERVER_PARAMS` | `-server -world=empty ...` | Override server launch parameters |
|
| `SERVER_PARAMS` | `-server -world=empty ...` | Override server launch parameters |
|
||||||
| `STEAMCMD_PATH` | `steamcmd` | Path to steamcmd binary |
|
| `STEAMCMD_PATH` | `steamcmd` | Path to steamcmd binary |
|
||||||
|
| `AUTH_TOKEN` | _(empty)_ | Bearer token for API/WS auth. When set, all requests must include `Authorization: Bearer <token>`. When empty, no auth required. |
|
||||||
|
| `GIN_MODE` | `debug` | Gin framework mode. Use `release` for production (set automatically in Docker). |
|
||||||
|
| `SERVERS_DIR` | `./serverfiles` | Docker Compose only: host directory mounted as `/servers` in the container. |
|
||||||
|
|
||||||
## Automation
|
## Automation
|
||||||
|
|
||||||
@@ -66,7 +73,7 @@ Configured in the **Automation** section of the Settings UI:
|
|||||||
| **Auto-update mods on startup** | Downloads workshop updates for every enabled mod in the active modlist when the web service starts. |
|
| **Auto-update mods on startup** | Downloads workshop updates for every enabled mod in the active modlist when the web service starts. |
|
||||||
| **Auto-start server on startup** | Restarts the game server if it was running when the web service last stopped. Useful for recovery after host backup cycles or container restarts. |
|
| **Auto-start server on startup** | Restarts the game server if it was running when the web service last stopped. Useful for recovery after host backup cycles or container restarts. |
|
||||||
|
|
||||||
All startup tasks run asynchronously — the web UI is available immediately.
|
All startup tasks run asynchronously — the web UI is available immediately. Server and mod updates run in parallel; auto-start waits for both to complete before launching the game server.
|
||||||
|
|
||||||
### Scheduled updates
|
### Scheduled updates
|
||||||
|
|
||||||
|
|||||||
@@ -10,6 +10,7 @@ import (
|
|||||||
"os/signal"
|
"os/signal"
|
||||||
"path/filepath"
|
"path/filepath"
|
||||||
"strings"
|
"strings"
|
||||||
|
"sync"
|
||||||
"syscall"
|
"syscall"
|
||||||
"time"
|
"time"
|
||||||
|
|
||||||
@@ -91,8 +92,12 @@ func main() {
|
|||||||
if err != nil {
|
if err != nil {
|
||||||
log.Printf("startup: load settings: %v", err)
|
log.Printf("startup: load settings: %v", err)
|
||||||
} else {
|
} else {
|
||||||
|
var updatesWg sync.WaitGroup
|
||||||
|
|
||||||
if s.AutoUpdateOnStartup && s.SteamUser != "" {
|
if s.AutoUpdateOnStartup && s.SteamUser != "" {
|
||||||
|
updatesWg.Add(1)
|
||||||
go func() {
|
go func() {
|
||||||
|
defer updatesWg.Done()
|
||||||
log.Print("startup: auto-updating gameserver")
|
log.Print("startup: auto-updating gameserver")
|
||||||
if err := steamcmd.UpdateGame(s.SteamBranch, s.SteamUser); err != nil {
|
if err := steamcmd.UpdateGame(s.SteamBranch, s.SteamUser); err != nil {
|
||||||
log.Printf("startup: auto-update game failed: %v", err)
|
log.Printf("startup: auto-update game failed: %v", err)
|
||||||
@@ -101,7 +106,9 @@ func main() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
if s.AutoUpdateModsOnStartup && s.ActiveModlist != "" {
|
if s.AutoUpdateModsOnStartup && s.ActiveModlist != "" {
|
||||||
|
updatesWg.Add(1)
|
||||||
go func() {
|
go func() {
|
||||||
|
defer updatesWg.Done()
|
||||||
log.Print("startup: auto-updating mods")
|
log.Print("startup: auto-updating mods")
|
||||||
ml, err := modlistMgr.Get(s.ActiveModlist)
|
ml, err := modlistMgr.Get(s.ActiveModlist)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
@@ -124,6 +131,7 @@ func main() {
|
|||||||
|
|
||||||
if s.AutoStartOnStartup && s.WasRunning {
|
if s.AutoStartOnStartup && s.WasRunning {
|
||||||
go func() {
|
go func() {
|
||||||
|
updatesWg.Wait()
|
||||||
log.Print("startup: auto-restarting server (was running before)")
|
log.Print("startup: auto-restarting server (was running before)")
|
||||||
if err := process.Start(); err != nil {
|
if err := process.Start(); err != nil {
|
||||||
log.Printf("startup: auto-start failed: %v", err)
|
log.Printf("startup: auto-start failed: %v", err)
|
||||||
@@ -152,6 +160,13 @@ func main() {
|
|||||||
log.Print("shutting down server...")
|
log.Print("shutting down server...")
|
||||||
scheduler.Stop()
|
scheduler.Stop()
|
||||||
|
|
||||||
|
if process.IsRunning() {
|
||||||
|
log.Print("stopping game server...")
|
||||||
|
if err := process.Stop(); err != nil {
|
||||||
|
log.Printf("stop game server: %v", err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
ctx, cancel := context.WithTimeout(context.Background(), 10*time.Second)
|
ctx, cancel := context.WithTimeout(context.Background(), 10*time.Second)
|
||||||
defer cancel()
|
defer cancel()
|
||||||
if err := srv.Shutdown(ctx); err != nil {
|
if err := srv.Shutdown(ctx); err != nil {
|
||||||
|
|||||||
+114
-27
@@ -19,6 +19,25 @@ var upgrader = websocket.Upgrader{
|
|||||||
CheckOrigin: func(r *http.Request) bool { return true },
|
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) {
|
func (h *Handler) StreamLogs(c *gin.Context) {
|
||||||
conn, err := upgrader.Upgrade(c.Writer, c.Request, nil)
|
conn, err := upgrader.Upgrade(c.Writer, c.Request, nil)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
@@ -27,13 +46,24 @@ func (h *Handler) StreamLogs(c *gin.Context) {
|
|||||||
}
|
}
|
||||||
defer conn.Close()
|
defer conn.Close()
|
||||||
|
|
||||||
|
done := make(chan struct{})
|
||||||
|
go readPump(conn, done)
|
||||||
|
|
||||||
clientID := conn.RemoteAddr().String()
|
clientID := conn.RemoteAddr().String()
|
||||||
ch := h.streamer.Subscribe("server", clientID)
|
ch := h.streamer.Subscribe("server", clientID)
|
||||||
defer h.streamer.Unsubscribe("server", clientID)
|
defer h.streamer.Unsubscribe("server", clientID)
|
||||||
|
|
||||||
for line := range ch {
|
for {
|
||||||
if err := conn.WriteMessage(websocket.TextMessage, []byte(line)); err != nil {
|
select {
|
||||||
break
|
case line, ok := <-ch:
|
||||||
|
if !ok {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if err := conn.WriteMessage(websocket.TextMessage, []byte(line)); err != nil {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
case <-done:
|
||||||
|
return
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -83,13 +113,24 @@ func (h *Handler) StreamSteamCMDLogs(c *gin.Context) {
|
|||||||
}
|
}
|
||||||
defer conn.Close()
|
defer conn.Close()
|
||||||
|
|
||||||
|
done := make(chan struct{})
|
||||||
|
go readPump(conn, done)
|
||||||
|
|
||||||
clientID := conn.RemoteAddr().String()
|
clientID := conn.RemoteAddr().String()
|
||||||
ch := h.streamer.Subscribe("steamcmd", clientID)
|
ch := h.streamer.Subscribe("steamcmd", clientID)
|
||||||
defer h.streamer.Unsubscribe("steamcmd", clientID)
|
defer h.streamer.Unsubscribe("steamcmd", clientID)
|
||||||
|
|
||||||
for line := range ch {
|
for {
|
||||||
if err := conn.WriteMessage(websocket.TextMessage, []byte(line)); err != nil {
|
select {
|
||||||
break
|
case line, ok := <-ch:
|
||||||
|
if !ok {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if err := conn.WriteMessage(websocket.TextMessage, []byte(line)); err != nil {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
case <-done:
|
||||||
|
return
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -102,13 +143,22 @@ func (h *Handler) StreamRPTLogs(c *gin.Context) {
|
|||||||
}
|
}
|
||||||
defer conn.Close()
|
defer conn.Close()
|
||||||
|
|
||||||
|
done := make(chan struct{})
|
||||||
|
go readPump(conn, done)
|
||||||
|
|
||||||
profilesDir := h.process.ProfilesDir()
|
profilesDir := h.process.ProfilesDir()
|
||||||
var currentPath string
|
var currentPath string
|
||||||
var currentOffset int64
|
var currentOffset int64
|
||||||
ticker := time.NewTicker(250 * time.Millisecond)
|
ticker := time.NewTicker(250 * time.Millisecond)
|
||||||
defer ticker.Stop()
|
defer ticker.Stop()
|
||||||
|
|
||||||
for range ticker.C {
|
for {
|
||||||
|
select {
|
||||||
|
case <-done:
|
||||||
|
return
|
||||||
|
case <-ticker.C:
|
||||||
|
}
|
||||||
|
|
||||||
path := findLatestRPT(profilesDir)
|
path := findLatestRPT(profilesDir)
|
||||||
|
|
||||||
if path != currentPath {
|
if path != currentPath {
|
||||||
@@ -134,24 +184,38 @@ func (h *Handler) StreamRPTLogs(c *gin.Context) {
|
|||||||
continue
|
continue
|
||||||
}
|
}
|
||||||
|
|
||||||
f, err := os.Open(currentPath)
|
if err := func() error {
|
||||||
if err != nil {
|
f, err := os.Open(currentPath)
|
||||||
currentPath = ""
|
if err != nil {
|
||||||
continue
|
currentPath = ""
|
||||||
}
|
return nil
|
||||||
f.Seek(currentOffset, io.SeekStart)
|
}
|
||||||
buf := make([]byte, fi.Size()-currentOffset)
|
defer f.Close()
|
||||||
n, _ := io.ReadFull(f, buf)
|
|
||||||
f.Close()
|
|
||||||
|
|
||||||
currentOffset += int64(n)
|
if _, err := f.Seek(currentOffset, io.SeekStart); err != nil {
|
||||||
for _, line := range strings.Split(string(buf[:n]), "\n") {
|
return nil
|
||||||
if line == "" {
|
|
||||||
continue
|
|
||||||
}
|
}
|
||||||
if err := conn.WriteMessage(websocket.TextMessage, []byte(line)); err != nil {
|
remaining := fi.Size() - currentOffset
|
||||||
return
|
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
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -162,12 +226,25 @@ func findLatestRPT(dir string) string {
|
|||||||
if err != nil || len(matches) == 0 {
|
if err != nil || len(matches) == 0 {
|
||||||
return ""
|
return ""
|
||||||
}
|
}
|
||||||
sort.Slice(matches, func(i, j int) bool {
|
type rptInfo struct {
|
||||||
fi, _ := os.Stat(matches[i])
|
path string
|
||||||
fj, _ := os.Stat(matches[j])
|
time time.Time
|
||||||
return fi.ModTime().After(fj.ModTime())
|
}
|
||||||
|
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 matches[0]
|
return infos[0].path
|
||||||
}
|
}
|
||||||
|
|
||||||
func (h *Handler) GetLog(c *gin.Context) {
|
func (h *Handler) GetLog(c *gin.Context) {
|
||||||
@@ -189,6 +266,16 @@ func (h *Handler) GetLog(c *gin.Context) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
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)
|
data, err := os.ReadFile(path)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
|
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
|
||||||
|
|||||||
@@ -16,7 +16,7 @@ type createModlistInput struct {
|
|||||||
}
|
}
|
||||||
|
|
||||||
type updateModlistInput struct {
|
type updateModlistInput struct {
|
||||||
Name string `json:"name" binding:"required"`
|
Name string `json:"name" binding:"required"`
|
||||||
Mods []models.ModEntry `json:"mods"`
|
Mods []models.ModEntry `json:"mods"`
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -49,6 +49,14 @@ func (h *Handler) ListMods(c *gin.Context) {
|
|||||||
c.JSON(http.StatusOK, all)
|
c.JSON(http.StatusOK, all)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func isUnderDir(path, dir string) bool {
|
||||||
|
rel, err := filepath.Rel(dir, path)
|
||||||
|
if err != nil {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
return rel != ".." && !strings.HasPrefix(rel, ".."+string(filepath.Separator))
|
||||||
|
}
|
||||||
|
|
||||||
func (h *Handler) DeleteMod(c *gin.Context) {
|
func (h *Handler) DeleteMod(c *gin.Context) {
|
||||||
var input deleteModInput
|
var input deleteModInput
|
||||||
if err := c.ShouldBindJSON(&input); err != nil {
|
if err := c.ShouldBindJSON(&input); err != nil {
|
||||||
@@ -62,7 +70,7 @@ func (h *Handler) DeleteMod(c *gin.Context) {
|
|||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
if !strings.HasPrefix(absPath, h.serverfileDir) && !strings.HasPrefix(absPath, h.modsDir) {
|
if !isUnderDir(absPath, h.serverfileDir) && !isUnderDir(absPath, h.modsDir) {
|
||||||
c.JSON(http.StatusForbidden, gin.H{"error": "path outside allowed directories"})
|
c.JSON(http.StatusForbidden, gin.H{"error": "path outside allowed directories"})
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,6 +1,10 @@
|
|||||||
package api
|
package api
|
||||||
|
|
||||||
import (
|
import (
|
||||||
|
"net/http"
|
||||||
|
"os"
|
||||||
|
"strings"
|
||||||
|
|
||||||
"arma3-web-server/internal/services"
|
"arma3-web-server/internal/services"
|
||||||
|
|
||||||
"github.com/gin-gonic/gin"
|
"github.com/gin-gonic/gin"
|
||||||
@@ -20,6 +24,7 @@ type Handler struct {
|
|||||||
cfgDir string
|
cfgDir string
|
||||||
profilesDir string
|
profilesDir string
|
||||||
frontendServed bool
|
frontendServed bool
|
||||||
|
authToken string
|
||||||
}
|
}
|
||||||
|
|
||||||
func New(
|
func New(
|
||||||
@@ -47,11 +52,44 @@ func New(
|
|||||||
cfgDir: cfgDir,
|
cfgDir: cfgDir,
|
||||||
profilesDir: profilesDir,
|
profilesDir: profilesDir,
|
||||||
frontendServed: frontendServed,
|
frontendServed: frontendServed,
|
||||||
|
authToken: os.Getenv("AUTH_TOKEN"),
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func (h *Handler) authMiddleware() gin.HandlerFunc {
|
||||||
|
return func(c *gin.Context) {
|
||||||
|
if h.authToken == "" {
|
||||||
|
c.Next()
|
||||||
|
return
|
||||||
|
}
|
||||||
|
auth := c.GetHeader("Authorization")
|
||||||
|
if !strings.HasPrefix(auth, "Bearer ") {
|
||||||
|
c.AbortWithStatusJSON(http.StatusUnauthorized, gin.H{"error": "missing or invalid authorization header"})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
token := strings.TrimPrefix(auth, "Bearer ")
|
||||||
|
if !secureCompare(token, h.authToken) {
|
||||||
|
c.AbortWithStatusJSON(http.StatusUnauthorized, gin.H{"error": "invalid token"})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
c.Next()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func secureCompare(a, b string) bool {
|
||||||
|
if len(a) != len(b) {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
for i := 0; i < len(a); i++ {
|
||||||
|
if a[i] != b[i] {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
|
||||||
func (h *Handler) SetupRoutes(r *gin.Engine) {
|
func (h *Handler) SetupRoutes(r *gin.Engine) {
|
||||||
api := r.Group("/api")
|
api := r.Group("/api", h.authMiddleware())
|
||||||
{
|
{
|
||||||
api.GET("/server/settings", h.GetSettings)
|
api.GET("/server/settings", h.GetSettings)
|
||||||
api.PUT("/server/settings", h.UpdateSettings)
|
api.PUT("/server/settings", h.UpdateSettings)
|
||||||
@@ -93,7 +131,8 @@ func (h *Handler) SetupRoutes(r *gin.Engine) {
|
|||||||
api.POST("/mods/cleanup", h.CleanupMods)
|
api.POST("/mods/cleanup", h.CleanupMods)
|
||||||
}
|
}
|
||||||
|
|
||||||
r.GET("/ws/server/logs", h.StreamLogs)
|
ws := r.Group("/ws", h.authMiddleware())
|
||||||
r.GET("/ws/steamcmd/logs", h.StreamSteamCMDLogs)
|
ws.GET("/server/logs", h.StreamLogs)
|
||||||
r.GET("/ws/server/rpt", h.StreamRPTLogs)
|
ws.GET("/steamcmd/logs", h.StreamSteamCMDLogs)
|
||||||
|
ws.GET("/server/rpt", h.StreamRPTLogs)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,26 +1,32 @@
|
|||||||
package api
|
package api
|
||||||
|
|
||||||
import (
|
import (
|
||||||
|
"fmt"
|
||||||
|
"net"
|
||||||
"net/http"
|
"net/http"
|
||||||
|
"strings"
|
||||||
|
|
||||||
|
"arma3-web-server/internal/models"
|
||||||
|
|
||||||
"github.com/gin-gonic/gin"
|
"github.com/gin-gonic/gin"
|
||||||
|
"github.com/robfig/cron/v3"
|
||||||
)
|
)
|
||||||
|
|
||||||
type updateSettingsInput struct {
|
type updateSettingsInput struct {
|
||||||
IPPort *string `json:"ip_port"`
|
IPPort *string `json:"ip_port"`
|
||||||
ServerParameters *string `json:"server_parameters"`
|
ServerParameters *string `json:"server_parameters"`
|
||||||
SteamBranch *string `json:"steam_branch"`
|
SteamBranch *string `json:"steam_branch"`
|
||||||
SteamUser *string `json:"steam_user"`
|
SteamUser *string `json:"steam_user"`
|
||||||
Platform *string `json:"platform"`
|
Platform *string `json:"platform"`
|
||||||
CBASettings *string `json:"cba_settings"`
|
CBASettings *string `json:"cba_settings"`
|
||||||
AILevelPresets *string `json:"ai_level_presets"`
|
AILevelPresets *string `json:"ai_level_presets"`
|
||||||
DifficultyPresets *string `json:"difficulty_presets"`
|
DifficultyPresets *string `json:"difficulty_presets"`
|
||||||
ActiveConfig *string `json:"active_config"`
|
ActiveConfig *string `json:"active_config"`
|
||||||
ActiveModlist *string `json:"active_modlist"`
|
ActiveModlist *string `json:"active_modlist"`
|
||||||
AutoUpdateOnStartup *bool `json:"auto_update_on_startup"`
|
AutoUpdateOnStartup *bool `json:"auto_update_on_startup"`
|
||||||
AutoStartOnStartup *bool `json:"auto_start_on_startup"`
|
AutoStartOnStartup *bool `json:"auto_start_on_startup"`
|
||||||
AutoUpdateModsOnStartup *bool `json:"auto_update_mods_on_startup"`
|
AutoUpdateModsOnStartup *bool `json:"auto_update_mods_on_startup"`
|
||||||
ScheduledUpdate *string `json:"scheduled_update"`
|
ScheduledUpdate *string `json:"scheduled_update"`
|
||||||
}
|
}
|
||||||
|
|
||||||
func (h *Handler) GetSettings(c *gin.Context) {
|
func (h *Handler) GetSettings(c *gin.Context) {
|
||||||
@@ -39,37 +45,86 @@ func (h *Handler) UpdateSettings(c *gin.Context) {
|
|||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
s, err := h.settings.Load()
|
if input.IPPort != nil && *input.IPPort != "" {
|
||||||
|
if _, _, err := net.SplitHostPort(*input.IPPort); err != nil {
|
||||||
|
c.JSON(http.StatusBadRequest, gin.H{"error": fmt.Sprintf("invalid ip_port: must be host:port format: %v", err)})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if input.ServerParameters != nil && len(*input.ServerParameters) > 8192 {
|
||||||
|
c.JSON(http.StatusBadRequest, gin.H{"error": "server_parameters too long (max 8192)"})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if input.ScheduledUpdate != nil && *input.ScheduledUpdate != "" {
|
||||||
|
parser := cron.NewParser(cron.Minute | cron.Hour | cron.Dom | cron.Month | cron.Dow)
|
||||||
|
if _, err := parser.Parse(*input.ScheduledUpdate); err != nil {
|
||||||
|
c.JSON(http.StatusBadRequest, gin.H{"error": fmt.Sprintf("invalid cron expression: %v", err)})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if input.ServerParameters != nil {
|
||||||
|
for _, forbidden := range []string{"--dry-run", "--rm", "--privileged"} {
|
||||||
|
if strings.Contains(*input.ServerParameters, forbidden) {
|
||||||
|
c.JSON(http.StatusBadRequest, gin.H{"error": fmt.Sprintf("server_parameters contains forbidden flag: %s", forbidden)})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
s, err := h.settings.Update(func(s *models.ServerSettings) {
|
||||||
|
if input.IPPort != nil {
|
||||||
|
s.IPPort = *input.IPPort
|
||||||
|
}
|
||||||
|
if input.ServerParameters != nil {
|
||||||
|
s.ServerParameters = *input.ServerParameters
|
||||||
|
}
|
||||||
|
if input.SteamBranch != nil {
|
||||||
|
s.SteamBranch = *input.SteamBranch
|
||||||
|
}
|
||||||
|
if input.SteamUser != nil {
|
||||||
|
s.SteamUser = *input.SteamUser
|
||||||
|
}
|
||||||
|
if input.Platform != nil {
|
||||||
|
s.Platform = *input.Platform
|
||||||
|
}
|
||||||
|
if input.CBASettings != nil {
|
||||||
|
s.CBASettings = *input.CBASettings
|
||||||
|
}
|
||||||
|
if input.AILevelPresets != nil {
|
||||||
|
s.AILevelPresets = *input.AILevelPresets
|
||||||
|
}
|
||||||
|
if input.DifficultyPresets != nil {
|
||||||
|
s.DifficultyPresets = *input.DifficultyPresets
|
||||||
|
}
|
||||||
|
if input.ActiveConfig != nil {
|
||||||
|
s.ActiveConfig = *input.ActiveConfig
|
||||||
|
}
|
||||||
|
if input.ActiveModlist != nil {
|
||||||
|
s.ActiveModlist = *input.ActiveModlist
|
||||||
|
}
|
||||||
|
if input.AutoUpdateOnStartup != nil {
|
||||||
|
s.AutoUpdateOnStartup = *input.AutoUpdateOnStartup
|
||||||
|
}
|
||||||
|
if input.AutoStartOnStartup != nil {
|
||||||
|
s.AutoStartOnStartup = *input.AutoStartOnStartup
|
||||||
|
}
|
||||||
|
if input.AutoUpdateModsOnStartup != nil {
|
||||||
|
s.AutoUpdateModsOnStartup = *input.AutoUpdateModsOnStartup
|
||||||
|
}
|
||||||
|
if input.ScheduledUpdate != nil {
|
||||||
|
s.ScheduledUpdate = *input.ScheduledUpdate
|
||||||
|
}
|
||||||
|
})
|
||||||
if err != nil {
|
if err != nil {
|
||||||
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
|
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
if input.IPPort != nil { s.IPPort = *input.IPPort }
|
|
||||||
if input.ServerParameters != nil { s.ServerParameters = *input.ServerParameters }
|
|
||||||
if input.SteamBranch != nil { s.SteamBranch = *input.SteamBranch }
|
|
||||||
if input.SteamUser != nil { s.SteamUser = *input.SteamUser }
|
|
||||||
if input.Platform != nil { s.Platform = *input.Platform }
|
|
||||||
if input.CBASettings != nil { s.CBASettings = *input.CBASettings }
|
|
||||||
if input.AILevelPresets != nil { s.AILevelPresets = *input.AILevelPresets }
|
|
||||||
if input.DifficultyPresets != nil { s.DifficultyPresets = *input.DifficultyPresets }
|
|
||||||
if input.ActiveConfig != nil { s.ActiveConfig = *input.ActiveConfig }
|
|
||||||
if input.ActiveModlist != nil { s.ActiveModlist = *input.ActiveModlist }
|
|
||||||
if input.AutoUpdateOnStartup != nil { s.AutoUpdateOnStartup = *input.AutoUpdateOnStartup }
|
|
||||||
if input.AutoStartOnStartup != nil { s.AutoStartOnStartup = *input.AutoStartOnStartup }
|
|
||||||
if input.AutoUpdateModsOnStartup != nil { s.AutoUpdateModsOnStartup = *input.AutoUpdateModsOnStartup }
|
|
||||||
if input.ScheduledUpdate != nil { s.ScheduledUpdate = *input.ScheduledUpdate }
|
|
||||||
|
|
||||||
if err := h.process.WriteUserconfigFiles(s); err != nil {
|
if err := h.process.WriteUserconfigFiles(s); err != nil {
|
||||||
c.JSON(http.StatusInternalServerError, gin.H{"error": "write userconfig: " + err.Error()})
|
c.JSON(http.StatusInternalServerError, gin.H{"error": "write userconfig: " + err.Error()})
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
if err := h.settings.Save(s); err != nil {
|
|
||||||
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
if h.scheduler != nil {
|
if h.scheduler != nil {
|
||||||
h.scheduler.Refresh()
|
h.scheduler.Refresh()
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -59,11 +59,11 @@ func (cm *ConfigManager) Create(name, content string) error {
|
|||||||
if _, err := os.Stat(path); err == nil {
|
if _, err := os.Stat(path); err == nil {
|
||||||
return fmt.Errorf("config already exists")
|
return fmt.Errorf("config already exists")
|
||||||
}
|
}
|
||||||
return os.WriteFile(path, []byte(content), 0644)
|
return writeFileAtomic(path, []byte(content))
|
||||||
}
|
}
|
||||||
|
|
||||||
func (cm *ConfigManager) Update(name, content string) error {
|
func (cm *ConfigManager) Update(name, content string) error {
|
||||||
return os.WriteFile(cm.path(name), []byte(content), 0644)
|
return writeFileAtomic(cm.path(name), []byte(content))
|
||||||
}
|
}
|
||||||
|
|
||||||
func (cm *ConfigManager) Delete(name string) error {
|
func (cm *ConfigManager) Delete(name string) error {
|
||||||
|
|||||||
@@ -2,6 +2,7 @@ package services
|
|||||||
|
|
||||||
import (
|
import (
|
||||||
"bufio"
|
"bufio"
|
||||||
|
"context"
|
||||||
"io"
|
"io"
|
||||||
"log"
|
"log"
|
||||||
"sync"
|
"sync"
|
||||||
@@ -26,6 +27,10 @@ func (ls *LogStreamer) Subscribe(serverID, clientID string) chan string {
|
|||||||
ls.subs[serverID] = make(map[string]chan string)
|
ls.subs[serverID] = make(map[string]chan string)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if old, ok := ls.subs[serverID][clientID]; ok {
|
||||||
|
close(old)
|
||||||
|
}
|
||||||
|
|
||||||
ch := make(chan string, 256)
|
ch := make(chan string, 256)
|
||||||
ls.subs[serverID][clientID] = ch
|
ls.subs[serverID][clientID] = ch
|
||||||
return ch
|
return ch
|
||||||
@@ -46,14 +51,23 @@ func (ls *LogStreamer) Unsubscribe(serverID, clientID string) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
func (ls *LogStreamer) Stream(serverID string, reader io.Reader, closeMsg string) {
|
func (ls *LogStreamer) Stream(ctx context.Context, serverID string, reader io.Reader, closeMsg string) {
|
||||||
scanner := bufio.NewScanner(reader)
|
scanner := bufio.NewScanner(reader)
|
||||||
for scanner.Scan() {
|
for scanner.Scan() {
|
||||||
|
select {
|
||||||
|
case <-ctx.Done():
|
||||||
|
return
|
||||||
|
default:
|
||||||
|
}
|
||||||
line := scanner.Text()
|
line := scanner.Text()
|
||||||
ls.broadcast(serverID, line)
|
ls.broadcast(serverID, line)
|
||||||
}
|
}
|
||||||
if err := scanner.Err(); err != nil {
|
if err := scanner.Err(); err != nil {
|
||||||
log.Printf("log stream error for server %s: %v", serverID, err)
|
select {
|
||||||
|
case <-ctx.Done():
|
||||||
|
default:
|
||||||
|
log.Printf("log stream error for server %s: %v", serverID, err)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
if closeMsg != "" {
|
if closeMsg != "" {
|
||||||
ls.broadcast(serverID, closeMsg)
|
ls.broadcast(serverID, closeMsg)
|
||||||
|
|||||||
@@ -1,6 +1,7 @@
|
|||||||
package services
|
package services
|
||||||
|
|
||||||
import (
|
import (
|
||||||
|
"context"
|
||||||
"strings"
|
"strings"
|
||||||
"sync"
|
"sync"
|
||||||
"testing"
|
"testing"
|
||||||
@@ -95,7 +96,7 @@ func TestLogStreamer_Stream(t *testing.T) {
|
|||||||
ch := ls.Subscribe("server", "client1")
|
ch := ls.Subscribe("server", "client1")
|
||||||
|
|
||||||
reader := strings.NewReader("line1\nline2\nline3\n")
|
reader := strings.NewReader("line1\nline2\nline3\n")
|
||||||
go ls.Stream("server", reader, "DONE")
|
go ls.Stream(context.Background(), "server", reader, "DONE")
|
||||||
|
|
||||||
lines := []string{}
|
lines := []string{}
|
||||||
for i := 0; i < 4; i++ { // 3 lines + DONE close message
|
for i := 0; i < 4; i++ { // 3 lines + DONE close message
|
||||||
@@ -174,3 +175,68 @@ func TestLogStreamer_ConcurrentSubscribeUnsubscribe(t *testing.T) {
|
|||||||
|
|
||||||
wg.Wait()
|
wg.Wait()
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func TestLogStreamer_SubscribeOverwriteClosesOld(t *testing.T) {
|
||||||
|
ls := NewLogStreamer()
|
||||||
|
|
||||||
|
ch1 := ls.Subscribe("server", "client1")
|
||||||
|
ls.Broadcast("server", "first")
|
||||||
|
<-ch1
|
||||||
|
|
||||||
|
// Re-subscribe with same clientID — old channel should be closed
|
||||||
|
ch2 := ls.Subscribe("server", "client1")
|
||||||
|
|
||||||
|
// Old channel should be closed
|
||||||
|
select {
|
||||||
|
case _, ok := <-ch1:
|
||||||
|
if ok {
|
||||||
|
t.Error("old channel should be closed after re-subscribe")
|
||||||
|
}
|
||||||
|
case <-time.After(100 * time.Millisecond):
|
||||||
|
t.Error("old channel not closed within timeout")
|
||||||
|
}
|
||||||
|
|
||||||
|
// New channel should work
|
||||||
|
ls.Broadcast("server", "second")
|
||||||
|
select {
|
||||||
|
case line := <-ch2:
|
||||||
|
if line != "second" {
|
||||||
|
t.Errorf("new channel received %q, want %q", line, "second")
|
||||||
|
}
|
||||||
|
case <-time.After(100 * time.Millisecond):
|
||||||
|
t.Error("timeout waiting on new channel")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestLogStreamer_StreamContextCancel(t *testing.T) {
|
||||||
|
ls := NewLogStreamer()
|
||||||
|
ch := ls.Subscribe("server", "client1")
|
||||||
|
|
||||||
|
ctx, cancel := context.WithCancel(context.Background())
|
||||||
|
reader := contextReader{ctx: ctx}
|
||||||
|
go ls.Stream(ctx, "server", reader, "")
|
||||||
|
|
||||||
|
// Broadcast should still work while stream is running
|
||||||
|
ls.Broadcast("server", "live")
|
||||||
|
select {
|
||||||
|
case line := <-ch:
|
||||||
|
if line != "live" {
|
||||||
|
t.Errorf("received %q, want %q", line, "live")
|
||||||
|
}
|
||||||
|
case <-time.After(100 * time.Millisecond):
|
||||||
|
t.Error("timeout waiting for broadcast")
|
||||||
|
}
|
||||||
|
|
||||||
|
// Cancel context — stream should stop
|
||||||
|
cancel()
|
||||||
|
time.Sleep(100 * time.Millisecond)
|
||||||
|
}
|
||||||
|
|
||||||
|
type contextReader struct {
|
||||||
|
ctx context.Context
|
||||||
|
}
|
||||||
|
|
||||||
|
func (r contextReader) Read(p []byte) (int, error) {
|
||||||
|
<-r.ctx.Done()
|
||||||
|
return 0, r.ctx.Err()
|
||||||
|
}
|
||||||
|
|||||||
@@ -0,0 +1,209 @@
|
|||||||
|
package services
|
||||||
|
|
||||||
|
import (
|
||||||
|
"os"
|
||||||
|
"path/filepath"
|
||||||
|
"testing"
|
||||||
|
|
||||||
|
"arma3-web-server/internal/models"
|
||||||
|
)
|
||||||
|
|
||||||
|
func TestListWorkshopMods_Empty(t *testing.T) {
|
||||||
|
dir := t.TempDir()
|
||||||
|
mods := ListWorkshopMods(dir)
|
||||||
|
if len(mods) != 0 {
|
||||||
|
t.Fatalf("expected 0 mods, got %d", len(mods))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestListWorkshopMods_WithMods(t *testing.T) {
|
||||||
|
dir := t.TempDir()
|
||||||
|
workshopDir := filepath.Join(dir, "steamapps", "workshop", "content", "107410")
|
||||||
|
if err := os.MkdirAll(filepath.Join(workshopDir, "123456"), 0755); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
if err := os.MkdirAll(filepath.Join(workshopDir, "789012"), 0755); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
|
||||||
|
mods := ListWorkshopMods(dir)
|
||||||
|
if len(mods) != 2 {
|
||||||
|
t.Fatalf("expected 2 mods, got %d", len(mods))
|
||||||
|
}
|
||||||
|
|
||||||
|
ids := map[string]bool{}
|
||||||
|
for _, m := range mods {
|
||||||
|
ids[m.ID] = true
|
||||||
|
if m.Source != "workshop" {
|
||||||
|
t.Errorf("expected source 'workshop', got %q", m.Source)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if !ids["123456"] || !ids["789012"] {
|
||||||
|
t.Errorf("expected both mod IDs, got %v", ids)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestListWorkshopMods_SkipsFiles(t *testing.T) {
|
||||||
|
dir := t.TempDir()
|
||||||
|
workshopDir := filepath.Join(dir, "steamapps", "workshop", "content", "107410")
|
||||||
|
if err := os.MkdirAll(workshopDir, 0755); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
if err := os.WriteFile(filepath.Join(workshopDir, "notadir.txt"), []byte("hi"), 0644); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
if err := os.MkdirAll(filepath.Join(workshopDir, "111111"), 0755); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
|
||||||
|
mods := ListWorkshopMods(dir)
|
||||||
|
if len(mods) != 1 {
|
||||||
|
t.Fatalf("expected 1 mod, got %d", len(mods))
|
||||||
|
}
|
||||||
|
if mods[0].ID != "111111" {
|
||||||
|
t.Errorf("expected ID '111111', got %q", mods[0].ID)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestListLocalMods_Empty(t *testing.T) {
|
||||||
|
dir := t.TempDir()
|
||||||
|
mods := ListLocalMods(dir)
|
||||||
|
if len(mods) != 0 {
|
||||||
|
t.Fatalf("expected 0 mods, got %d", len(mods))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestListLocalMods_WithMods(t *testing.T) {
|
||||||
|
dir := t.TempDir()
|
||||||
|
if err := os.MkdirAll(filepath.Join(dir, "@ACE3"), 0755); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
if err := os.MkdirAll(filepath.Join(dir, "@TFAR"), 0755); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
|
||||||
|
mods := ListLocalMods(dir)
|
||||||
|
if len(mods) != 2 {
|
||||||
|
t.Fatalf("expected 2 mods, got %d", len(mods))
|
||||||
|
}
|
||||||
|
|
||||||
|
for _, m := range mods {
|
||||||
|
if m.Source != "local" {
|
||||||
|
t.Errorf("expected source 'local', got %q", m.Source)
|
||||||
|
}
|
||||||
|
if len(m.Name) > 0 && m.Name[0] != '@' {
|
||||||
|
t.Errorf("expected name to start with '@', got %q", m.Name)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestListLocalMods_SkipsFiles(t *testing.T) {
|
||||||
|
dir := t.TempDir()
|
||||||
|
if err := os.WriteFile(filepath.Join(dir, "readme.txt"), []byte("hi"), 0644); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
if err := os.MkdirAll(filepath.Join(dir, "@Mod"), 0755); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
|
||||||
|
mods := ListLocalMods(dir)
|
||||||
|
if len(mods) != 1 {
|
||||||
|
t.Fatalf("expected 1 mod, got %d", len(mods))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestBuildUsageMap(t *testing.T) {
|
||||||
|
dataDir := t.TempDir()
|
||||||
|
mm := NewModlistManager(dataDir)
|
||||||
|
|
||||||
|
ml, err := mm.Create("Test List")
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
|
||||||
|
ml, err = mm.Update(ml.ID, ml.Name, []models.ModEntry{
|
||||||
|
{ID: "111111", Name: "ACE3", Enabled: true},
|
||||||
|
{ID: "222222", Name: "", Enabled: true},
|
||||||
|
})
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
_ = ml
|
||||||
|
|
||||||
|
usedWorkshop, usedLocal := BuildUsageMap(mm)
|
||||||
|
|
||||||
|
if lists, ok := usedWorkshop["111111"]; !ok || len(lists) == 0 {
|
||||||
|
t.Errorf("expected workshop mod 111111 to be in use")
|
||||||
|
}
|
||||||
|
if _, ok := usedWorkshop["222222"]; !ok {
|
||||||
|
t.Errorf("expected workshop mod 222222 to be in use")
|
||||||
|
}
|
||||||
|
if _, ok := usedLocal["ACE3"]; !ok {
|
||||||
|
t.Errorf("expected local mod ACE3 to be in use")
|
||||||
|
}
|
||||||
|
if _, ok := usedLocal["@ACE3"]; !ok {
|
||||||
|
t.Errorf("expected local mod @ACE3 to be in use")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestBuildUsageMap_Empty(t *testing.T) {
|
||||||
|
dataDir := t.TempDir()
|
||||||
|
mm := NewModlistManager(dataDir)
|
||||||
|
|
||||||
|
usedWorkshop, usedLocal := BuildUsageMap(mm)
|
||||||
|
if len(usedWorkshop) != 0 || len(usedLocal) != 0 {
|
||||||
|
t.Errorf("expected empty maps, got workshop=%v local=%v", usedWorkshop, usedLocal)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestRemoveMod(t *testing.T) {
|
||||||
|
dir := t.TempDir()
|
||||||
|
modDir := filepath.Join(dir, "@TestMod")
|
||||||
|
if err := os.MkdirAll(modDir, 0755); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
if err := os.WriteFile(filepath.Join(modDir, "config.cpp"), []byte("x"), 0644); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
|
||||||
|
if err := RemoveMod(modDir); err != nil {
|
||||||
|
t.Fatalf("RemoveMod failed: %v", err)
|
||||||
|
}
|
||||||
|
if _, err := os.Stat(modDir); !os.IsNotExist(err) {
|
||||||
|
t.Errorf("expected directory to be removed")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestRemoveMod_Nonexistent(t *testing.T) {
|
||||||
|
err := RemoveMod("/nonexistent/path/that/does/not/exist")
|
||||||
|
if err != nil {
|
||||||
|
t.Errorf("RemoveMod on nonexistent path should not error, got: %v", err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestDirSize(t *testing.T) {
|
||||||
|
dir := t.TempDir()
|
||||||
|
if err := os.WriteFile(filepath.Join(dir, "a.txt"), []byte("hello"), 0644); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
sub := filepath.Join(dir, "sub")
|
||||||
|
if err := os.MkdirAll(sub, 0755); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
if err := os.WriteFile(filepath.Join(sub, "b.txt"), []byte("world!"), 0644); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
|
||||||
|
size := dirSize(dir)
|
||||||
|
if size != 11 {
|
||||||
|
t.Errorf("expected size 11, got %d", size)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestDirSize_Empty(t *testing.T) {
|
||||||
|
dir := t.TempDir()
|
||||||
|
size := dirSize(dir)
|
||||||
|
if size != 0 {
|
||||||
|
t.Errorf("expected size 0, got %d", size)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -103,7 +103,7 @@ func (mm *ModlistManager) Create(name string) (*models.Modlist, error) {
|
|||||||
if err := os.MkdirAll(mm.dir, 0755); err != nil {
|
if err := os.MkdirAll(mm.dir, 0755); err != nil {
|
||||||
return nil, err
|
return nil, err
|
||||||
}
|
}
|
||||||
if err := os.WriteFile(mm.path(m.ID), data, 0644); err != nil {
|
if err := writeFileAtomic(mm.path(m.ID), data); err != nil {
|
||||||
return nil, err
|
return nil, err
|
||||||
}
|
}
|
||||||
return m, nil
|
return m, nil
|
||||||
@@ -126,7 +126,7 @@ func (mm *ModlistManager) Update(id, name string, mods []models.ModEntry) (*mode
|
|||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, err
|
return nil, err
|
||||||
}
|
}
|
||||||
if err := os.WriteFile(mm.path(id), data, 0644); err != nil {
|
if err := writeFileAtomic(mm.path(id), data); err != nil {
|
||||||
return nil, err
|
return nil, err
|
||||||
}
|
}
|
||||||
return m, nil
|
return m, nil
|
||||||
@@ -160,7 +160,7 @@ func (mm *ModlistManager) Duplicate(id, newName string) (*models.Modlist, error)
|
|||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, err
|
return nil, err
|
||||||
}
|
}
|
||||||
if err := os.WriteFile(mm.path(dup.ID), data, 0644); err != nil {
|
if err := writeFileAtomic(mm.path(dup.ID), data); err != nil {
|
||||||
return nil, err
|
return nil, err
|
||||||
}
|
}
|
||||||
return dup, nil
|
return dup, nil
|
||||||
|
|||||||
@@ -0,0 +1,136 @@
|
|||||||
|
package services
|
||||||
|
|
||||||
|
import (
|
||||||
|
"testing"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"arma3-web-server/internal/models"
|
||||||
|
)
|
||||||
|
|
||||||
|
func TestNewScheduler(t *testing.T) {
|
||||||
|
dataDir := t.TempDir()
|
||||||
|
settings := NewSettingsManager(dataDir)
|
||||||
|
modlists := NewModlistManager(dataDir)
|
||||||
|
streamer := NewLogStreamer()
|
||||||
|
steamcmd := NewSteamCmdManager(t.TempDir(), streamer)
|
||||||
|
|
||||||
|
s := NewScheduler(settings, modlists, steamcmd)
|
||||||
|
if s == nil {
|
||||||
|
t.Fatal("expected non-nil scheduler")
|
||||||
|
}
|
||||||
|
if s.cron == nil {
|
||||||
|
t.Fatal("expected non-nil cron")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestScheduler_StartStop(t *testing.T) {
|
||||||
|
dataDir := t.TempDir()
|
||||||
|
settings := NewSettingsManager(dataDir)
|
||||||
|
modlists := NewModlistManager(dataDir)
|
||||||
|
streamer := NewLogStreamer()
|
||||||
|
steamcmd := NewSteamCmdManager(t.TempDir(), streamer)
|
||||||
|
|
||||||
|
s := NewScheduler(settings, modlists, steamcmd)
|
||||||
|
s.Start()
|
||||||
|
time.Sleep(50 * time.Millisecond)
|
||||||
|
s.Stop()
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestScheduler_Refresh_Empty(t *testing.T) {
|
||||||
|
dataDir := t.TempDir()
|
||||||
|
settings := NewSettingsManager(dataDir)
|
||||||
|
modlists := NewModlistManager(dataDir)
|
||||||
|
streamer := NewLogStreamer()
|
||||||
|
steamcmd := NewSteamCmdManager(t.TempDir(), streamer)
|
||||||
|
|
||||||
|
s := NewScheduler(settings, modlists, steamcmd)
|
||||||
|
s.Start()
|
||||||
|
defer s.Stop()
|
||||||
|
|
||||||
|
s.Refresh()
|
||||||
|
if s.entryID != 0 {
|
||||||
|
t.Errorf("expected entryID 0 with empty settings, got %d", s.entryID)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestScheduler_Refresh_InvalidCron(t *testing.T) {
|
||||||
|
dataDir := t.TempDir()
|
||||||
|
settings := NewSettingsManager(dataDir)
|
||||||
|
modlists := NewModlistManager(dataDir)
|
||||||
|
streamer := NewLogStreamer()
|
||||||
|
steamcmd := NewSteamCmdManager(t.TempDir(), streamer)
|
||||||
|
|
||||||
|
_, err := settings.Update(func(s *models.ServerSettings) {
|
||||||
|
s.ScheduledUpdate = "not-a-cron"
|
||||||
|
})
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
|
||||||
|
sched := NewScheduler(settings, modlists, steamcmd)
|
||||||
|
sched.Start()
|
||||||
|
defer sched.Stop()
|
||||||
|
|
||||||
|
sched.Refresh()
|
||||||
|
if sched.entryID != 0 {
|
||||||
|
t.Errorf("expected entryID 0 with invalid cron, got %d", sched.entryID)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestScheduler_Refresh_ValidCron(t *testing.T) {
|
||||||
|
dataDir := t.TempDir()
|
||||||
|
settings := NewSettingsManager(dataDir)
|
||||||
|
modlists := NewModlistManager(dataDir)
|
||||||
|
streamer := NewLogStreamer()
|
||||||
|
steamcmd := NewSteamCmdManager(t.TempDir(), streamer)
|
||||||
|
|
||||||
|
_, err := settings.Update(func(s *models.ServerSettings) {
|
||||||
|
s.ScheduledUpdate = "0 4 * * *"
|
||||||
|
})
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
|
||||||
|
sched := NewScheduler(settings, modlists, steamcmd)
|
||||||
|
sched.Start()
|
||||||
|
defer sched.Stop()
|
||||||
|
|
||||||
|
sched.Refresh()
|
||||||
|
if sched.entryID == 0 {
|
||||||
|
t.Errorf("expected non-zero entryID with valid cron")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestScheduler_Refresh_Replace(t *testing.T) {
|
||||||
|
dataDir := t.TempDir()
|
||||||
|
settings := NewSettingsManager(dataDir)
|
||||||
|
modlists := NewModlistManager(dataDir)
|
||||||
|
streamer := NewLogStreamer()
|
||||||
|
steamcmd := NewSteamCmdManager(t.TempDir(), streamer)
|
||||||
|
|
||||||
|
sched := NewScheduler(settings, modlists, steamcmd)
|
||||||
|
sched.Start()
|
||||||
|
defer sched.Stop()
|
||||||
|
|
||||||
|
_, err := settings.Update(func(s *models.ServerSettings) {
|
||||||
|
s.ScheduledUpdate = "0 4 * * *"
|
||||||
|
})
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
sched.Refresh()
|
||||||
|
id1 := sched.entryID
|
||||||
|
|
||||||
|
_, err = settings.Update(func(s *models.ServerSettings) {
|
||||||
|
s.ScheduledUpdate = "0 5 * * *"
|
||||||
|
})
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
sched.Refresh()
|
||||||
|
id2 := sched.entryID
|
||||||
|
|
||||||
|
if id1 == id2 {
|
||||||
|
t.Errorf("expected different entryIDs after refresh, both got %d", id1)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -3,6 +3,7 @@ package services
|
|||||||
import (
|
import (
|
||||||
"context"
|
"context"
|
||||||
"fmt"
|
"fmt"
|
||||||
|
"log"
|
||||||
"net"
|
"net"
|
||||||
"os"
|
"os"
|
||||||
"os/exec"
|
"os/exec"
|
||||||
@@ -148,8 +149,8 @@ func (pm *ProcessManager) Start() error {
|
|||||||
pm.mu.Unlock()
|
pm.mu.Unlock()
|
||||||
pm.state.Store(int32(procRunning))
|
pm.state.Store(int32(procRunning))
|
||||||
|
|
||||||
go pm.streamer.Stream("server", stdout, "")
|
go pm.streamer.Stream(ctx, "server", stdout, "")
|
||||||
go pm.streamer.Stream("server", stderr, "")
|
go pm.streamer.Stream(ctx, "server", stderr, "")
|
||||||
|
|
||||||
go func() {
|
go func() {
|
||||||
cmd.Wait()
|
cmd.Wait()
|
||||||
@@ -184,7 +185,9 @@ func (pm *ProcessManager) Stop() error {
|
|||||||
s, err := pm.settings.Load()
|
s, err := pm.settings.Load()
|
||||||
if err == nil {
|
if err == nil {
|
||||||
s.WasRunning = false
|
s.WasRunning = false
|
||||||
pm.settings.Save(s)
|
if err := pm.settings.Save(s); err != nil {
|
||||||
|
log.Printf("stop: save was_running: %v", err)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
return nil
|
return nil
|
||||||
@@ -307,7 +310,7 @@ func (pm *ProcessManager) WriteUserconfigFiles(s *models.ServerSettings) error {
|
|||||||
}
|
}
|
||||||
for name, content := range files {
|
for name, content := range files {
|
||||||
path := filepath.Join(userconfigDir, name)
|
path := filepath.Join(userconfigDir, name)
|
||||||
if err := os.WriteFile(path, []byte(content), 0644); err != nil {
|
if err := writeFileAtomic(path, []byte(content)); err != nil {
|
||||||
return fmt.Errorf("write %s: %w", name, err)
|
return fmt.Errorf("write %s: %w", name, err)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -983,3 +983,29 @@ func TestStart_StubWritesRPTLog(t *testing.T) {
|
|||||||
pm.Stop()
|
pm.Stop()
|
||||||
time.Sleep(100 * time.Millisecond)
|
time.Sleep(100 * time.Millisecond)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func TestRestart(t *testing.T) {
|
||||||
|
pm, _ := setupStubPM(t, "-t 10")
|
||||||
|
|
||||||
|
if err := pm.Start(); err != nil {
|
||||||
|
t.Fatalf("Start() error = %v", err)
|
||||||
|
}
|
||||||
|
if !pm.IsRunning() {
|
||||||
|
t.Fatal("IsRunning() should be true after Start()")
|
||||||
|
}
|
||||||
|
|
||||||
|
if err := pm.Restart(); err != nil {
|
||||||
|
t.Fatalf("Restart() error = %v", err)
|
||||||
|
}
|
||||||
|
if !pm.IsRunning() {
|
||||||
|
t.Fatal("IsRunning() should be true after Restart()")
|
||||||
|
}
|
||||||
|
|
||||||
|
if err := pm.Stop(); err != nil {
|
||||||
|
t.Fatalf("Stop() error = %v", err)
|
||||||
|
}
|
||||||
|
time.Sleep(100 * time.Millisecond)
|
||||||
|
if pm.IsRunning() {
|
||||||
|
t.Fatal("IsRunning() should be false after Stop()")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
@@ -10,6 +10,29 @@ import (
|
|||||||
"arma3-web-server/internal/models"
|
"arma3-web-server/internal/models"
|
||||||
)
|
)
|
||||||
|
|
||||||
|
func writeFileAtomic(path string, data []byte) error {
|
||||||
|
dir := filepath.Dir(path)
|
||||||
|
tmp, err := os.CreateTemp(dir, ".tmp-*")
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
tmpPath := tmp.Name()
|
||||||
|
if _, err := tmp.Write(data); err != nil {
|
||||||
|
tmp.Close()
|
||||||
|
os.Remove(tmpPath)
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
if err := tmp.Close(); err != nil {
|
||||||
|
os.Remove(tmpPath)
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
if err := os.Rename(tmpPath, path); err != nil {
|
||||||
|
os.Remove(tmpPath)
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
type SettingsManager struct {
|
type SettingsManager struct {
|
||||||
path string
|
path string
|
||||||
mu sync.Mutex
|
mu sync.Mutex
|
||||||
@@ -49,7 +72,45 @@ func (sm *SettingsManager) Save(s *models.ServerSettings) error {
|
|||||||
if err != nil {
|
if err != nil {
|
||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
return os.WriteFile(sm.path, data, 0644)
|
return writeFileAtomic(sm.path, data)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (sm *SettingsManager) Update(fn func(*models.ServerSettings)) (*models.ServerSettings, error) {
|
||||||
|
sm.mu.Lock()
|
||||||
|
defer sm.mu.Unlock()
|
||||||
|
|
||||||
|
data, err := os.ReadFile(sm.path)
|
||||||
|
if err != nil {
|
||||||
|
if os.IsNotExist(err) {
|
||||||
|
s := sm.defaults()
|
||||||
|
fn(s)
|
||||||
|
s.UpdatedAt = time.Now().UTC()
|
||||||
|
out, err := json.MarshalIndent(s, "", " ")
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
if err := writeFileAtomic(sm.path, out); err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
return s, nil
|
||||||
|
}
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
|
||||||
|
var s models.ServerSettings
|
||||||
|
if err := json.Unmarshal(data, &s); err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
fn(&s)
|
||||||
|
s.UpdatedAt = time.Now().UTC()
|
||||||
|
out, err := json.MarshalIndent(s, "", " ")
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
if err := writeFileAtomic(sm.path, out); err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
return &s, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
func (sm *SettingsManager) defaults() *models.ServerSettings {
|
func (sm *SettingsManager) defaults() *models.ServerSettings {
|
||||||
|
|||||||
@@ -25,7 +25,6 @@ type SteamCmdManager struct {
|
|||||||
serverfileDir string
|
serverfileDir string
|
||||||
streamer *LogStreamer
|
streamer *LogStreamer
|
||||||
running atomic.Bool
|
running atomic.Bool
|
||||||
cancel context.CancelFunc
|
|
||||||
}
|
}
|
||||||
|
|
||||||
func NewSteamCmdManager(serverfileDir string, streamer *LogStreamer) *SteamCmdManager {
|
func NewSteamCmdManager(serverfileDir string, streamer *LogStreamer) *SteamCmdManager {
|
||||||
@@ -115,15 +114,13 @@ func (s *SteamCmdManager) run(label string, args []string) error {
|
|||||||
return fmt.Errorf("start steamcmd: %w", err)
|
return fmt.Errorf("start steamcmd: %w", err)
|
||||||
}
|
}
|
||||||
|
|
||||||
s.cancel = cancel
|
go s.streamer.Stream(ctx, "steamcmd", stdout, "")
|
||||||
|
go s.streamer.Stream(ctx, "steamcmd", stderr, "")
|
||||||
go s.streamer.Stream("steamcmd", stdout, "")
|
|
||||||
go s.streamer.Stream("steamcmd", stderr, "")
|
|
||||||
|
|
||||||
go func() {
|
go func() {
|
||||||
err := cmd.Wait()
|
err := cmd.Wait()
|
||||||
|
cancel()
|
||||||
s.running.Store(false)
|
s.running.Store(false)
|
||||||
s.cancel = nil
|
|
||||||
if err == nil {
|
if err == nil {
|
||||||
s.streamer.Broadcast("steamcmd", "[STEAMCMD] SUCCESS: "+label+" finished")
|
s.streamer.Broadcast("steamcmd", "[STEAMCMD] SUCCESS: "+label+" finished")
|
||||||
} else {
|
} else {
|
||||||
|
|||||||
Reference in New Issue
Block a user