diff --git a/.env.example b/.env.example old mode 100644 new mode 100755 index 7592a3a..95f73c6 --- a/.env.example +++ b/.env.example @@ -50,6 +50,35 @@ PASSWORD=change-this-default-admin-password # 4. NEVER commit actual secrets to version control! +# ============================================================================= +# PLATFORM SELECTION +# ============================================================================= + +# Platform mode: "windows" (default) or "docker" +# PLATFORM=windows + +# ============================================================================= +# DOCKER MODE SETTINGS (required when PLATFORM=docker) +# ============================================================================= + +# Docker image name for ACC server containers (built from docker/acc-server/Dockerfile) +# ACC_IMAGE=acc-wine:latest + +# Base path where per-server game installations are stored (inside the manager container). +# Each server gets its own subdirectory: ACC_SERVERS_PATH// +# ACC_SERVERS_PATH=/data/servers + +# Host-side path for the same directory — used when creating sibling ACC server containers. +# Must match the host path bound to ACC_SERVERS_PATH in docker-compose.yml. +# ACC_SERVERS_HOST_PATH=/opt/acc-servers + +# Port pool for Docker-mode ACC server containers (one port per server). +# PORT_POOL_MIN=9600 +# PORT_POOL_MAX=9700 + +# Linux SteamCMD binary path (pre-installed in the manager Docker image). +# STEAMCMD_LINUX_PATH=/usr/games/steamcmd + # ============================================================================= # OPTIONAL SETTINGS (These are handled by system config in database) # ============================================================================= diff --git a/.gitea/workflows/release.yml b/.gitea/workflows/release.yml old mode 100644 new mode 100755 diff --git a/.gitignore b/.gitignore old mode 100644 new mode 100755 diff --git a/Dockerfile b/Dockerfile new file mode 100644 index 0000000..4354d63 --- /dev/null +++ b/Dockerfile @@ -0,0 +1,63 @@ +# Multi-stage build for the acc-server-manager Go backend. +# CGO_ENABLED=1 is required for mattn/go-sqlite3 (uses cgo). +# The binary is compiled for Linux (Docker mode default). + +# ─── Builder ────────────────────────────────────────────────────────────────── +FROM golang:1.25-bookworm AS builder + +WORKDIR /build + +# Install build dependencies for CGO + sqlite3. +RUN apt-get update && apt-get install -y --no-install-recommends \ + gcc \ + libc6-dev \ + && rm -rf /var/lib/apt/lists/* + +# Download Go modules first (layer cache). +COPY go.mod go.sum ./ +RUN go mod download + +# Copy source and build. +COPY . . +RUN CGO_ENABLED=1 GOOS=linux GOARCH=amd64 \ + go build -ldflags="-s -w" -o /acc-server-manager ./cmd/api + +# ─── Runtime ────────────────────────────────────────────────────────────────── +FROM debian:bookworm-slim AS runtime + +# Install runtime dependencies and SteamCMD. +# steamcmd is not in the standard Debian repo — download directly from Valve. +RUN dpkg --add-architecture i386 && \ + apt-get update && \ + apt-get install -y --no-install-recommends \ + ca-certificates \ + curl \ + lib32gcc-s1 \ + && \ + rm -rf /var/lib/apt/lists/* + +RUN mkdir -p /usr/games && \ + curl -sSL --retry 3 --retry-delay 5 \ + -o /tmp/steamcmd.tar.gz \ + "https://steamcdn-a.akamaihd.net/client/installer/steamcmd_linux.tar.gz" && \ + tar -xzf /tmp/steamcmd.tar.gz -C /usr/games && \ + rm /tmp/steamcmd.tar.gz && \ + chmod +x /usr/games/steamcmd.sh && \ + ln -sf /usr/games/steamcmd.sh /usr/games/steamcmd + +WORKDIR /app + +COPY --from=builder /acc-server-manager /app/acc-server-manager + +# Persistent data directories. +RUN mkdir -p /data/servers /data/db + +ENV PLATFORM=docker +ENV PORT=3000 +ENV DB_NAME=/data/db/acc.db +ENV STEAMCMD_LINUX_PATH=/usr/games/steamcmd +ENV ACC_SERVERS_PATH=/data/servers + +EXPOSE 3000 + +ENTRYPOINT ["/app/acc-server-manager"] diff --git a/README.md b/README.md old mode 100644 new mode 100755 diff --git a/cmd/api/main.go b/cmd/api/main.go old mode 100644 new mode 100755 diff --git a/cmd/api/swagger.go b/cmd/api/swagger.go old mode 100644 new mode 100755 diff --git a/cmd/migrate/main.go b/cmd/migrate/main.go old mode 100644 new mode 100755 diff --git a/cmd/steam-crypt/main.go b/cmd/steam-crypt/main.go old mode 100644 new mode 100755 diff --git a/docker-compose.yml b/docker-compose.yml new file mode 100644 index 0000000..aae6f03 --- /dev/null +++ b/docker-compose.yml @@ -0,0 +1,34 @@ +services: + manager: + build: + context: . + dockerfile: Dockerfile + image: acc-server-manager:latest + container_name: acc-server-manager + restart: unless-stopped + ports: + - "3000:3000" + environment: + PLATFORM: docker + DB_NAME: /data/db/acc.db + STEAMCMD_LINUX_PATH: /usr/games/steamcmd + PORT_POOL_MIN: "9600" + PORT_POOL_MAX: "9699" + ACC_IMAGE: acc-wine:latest + ACC_SERVERS_PATH: /data/servers + ACC_SERVERS_HOST_PATH: /opt/acc-servers + env_file: + - .env + volumes: + - /var/run/docker.sock:/var/run/docker.sock + - /opt/acc-servers:/data/servers + - acc-db:/data/db + networks: + - acc-net + +volumes: + acc-db: + +networks: + acc-net: + driver: bridge diff --git a/docker/acc-server/Dockerfile b/docker/acc-server/Dockerfile new file mode 100644 index 0000000..0e67a18 --- /dev/null +++ b/docker/acc-server/Dockerfile @@ -0,0 +1,35 @@ +# ACC Server container: Wine + accServer.exe +# Game files are bind-mounted from the host at /acc/game. +FROM debian:bookworm-slim + +# Install Wine, Xvfb (headless display for Wine), and minimal runtime deps. +RUN dpkg --add-architecture i386 && \ + apt-get update && \ + apt-get install -y --no-install-recommends \ + wine \ + wine32 \ + wine64 \ + xvfb \ + ca-certificates \ + tcpdump \ + && \ + rm -rf /var/lib/apt/lists/* + +RUN mkdir -p /tmp/.X11-unix && chmod 1777 /tmp/.X11-unix + +WORKDIR /root + +ENV WINEARCH=win64 +ENV WINEPREFIX=/root/.wine +ENV WINEDEBUG=-all + +# Copy entrypoint script. +COPY entrypoint.sh /entrypoint.sh +RUN chmod +x /entrypoint.sh + +# Bind-mounted by DockerRuntime.Create: per-server game install (rw) +VOLUME ["/acc/game"] + +ENV SERVER_PORT=9600 + +ENTRYPOINT ["/entrypoint.sh"] diff --git a/docker/acc-server/entrypoint.sh b/docker/acc-server/entrypoint.sh new file mode 100644 index 0000000..67d6e1b --- /dev/null +++ b/docker/acc-server/entrypoint.sh @@ -0,0 +1,26 @@ +#!/bin/bash +set -e + +# Use a display number derived from the server port to avoid conflicts +# between containers (each server gets its own unique display). +DISPLAY_NUM=$((SERVER_PORT % 1000 + 100)) +export DISPLAY=:${DISPLAY_NUM} + +# Clean up any stale X lock from a previous container run. +rm -f /tmp/.X${DISPLAY_NUM}-lock /tmp/.X11-unix/X${DISPLAY_NUM} 2>/dev/null || true + +Xvfb ${DISPLAY} -screen 0 1024x768x16 -nolisten tcp & +XVFB_PID=$! + +cleanup() { + echo "Stopping ACC server..." + kill $XVFB_PID 2>/dev/null || true +} +trap cleanup SIGTERM SIGINT + +mkdir -p /acc/game/log + +echo "Starting ACC server on port ${SERVER_PORT}..." + +cd /acc/game +exec wine /acc/game/accServer.exe diff --git a/docs/API.md b/docs/API.md old mode 100644 new mode 100755 diff --git a/docs/CONFIG.md b/docs/CONFIG.md old mode 100644 new mode 100755 diff --git a/docs/DEPLOYMENT.md b/docs/DEPLOYMENT.md old mode 100644 new mode 100755 diff --git a/docs/MIGRATION.md b/docs/MIGRATION.md old mode 100644 new mode 100755 diff --git a/docs/SETUP.md b/docs/SETUP.md old mode 100644 new mode 100755 diff --git a/docs/STEAM_2FA_IMPLEMENTATION.md b/docs/STEAM_2FA_IMPLEMENTATION.md old mode 100644 new mode 100755 diff --git a/docs/SWAGGER.md b/docs/SWAGGER.md old mode 100644 new mode 100755 diff --git a/docs/TROUBLESHOOTING.md b/docs/TROUBLESHOOTING.md old mode 100644 new mode 100755 diff --git a/go.mod b/go.mod old mode 100644 new mode 100755 index 13282e2..50acfa1 --- a/go.mod +++ b/go.mod @@ -1,15 +1,19 @@ module acc-server-manager -go 1.23.0 +go 1.25.0 require ( + github.com/docker/docker v24.0.9+incompatible + github.com/docker/go-connections v0.6.0 github.com/gofiber/fiber/v2 v2.52.8 github.com/gofiber/swagger v1.1.0 + github.com/gofiber/websocket/v2 v2.2.1 github.com/golang-jwt/jwt/v4 v4.5.2 github.com/google/uuid v1.6.0 github.com/joho/godotenv v1.5.1 github.com/qjebbs/go-jsons v0.0.0-20221222033332-a534c5fc1c4c github.com/swaggo/swag v1.16.3 + github.com/valyala/fasthttp v1.51.0 go.uber.org/dig v1.17.1 golang.org/x/crypto v0.39.0 golang.org/x/sync v0.15.0 @@ -20,13 +24,17 @@ require ( require ( github.com/KyleBanks/depth v1.2.1 // indirect + github.com/Microsoft/go-winio v0.4.21 // indirect github.com/andybalholm/brotli v1.1.0 // indirect + github.com/docker/distribution v2.7.1+incompatible // indirect + github.com/docker/go-units v0.5.0 // indirect github.com/fasthttp/websocket v1.5.3 // indirect github.com/go-openapi/jsonpointer v0.21.0 // indirect github.com/go-openapi/jsonreference v0.21.0 // indirect github.com/go-openapi/spec v0.21.0 // indirect github.com/go-openapi/swag v0.23.0 // indirect - github.com/gofiber/websocket/v2 v2.2.1 // indirect + github.com/gogo/protobuf v1.3.2 // indirect + github.com/google/go-cmp v0.7.0 // indirect github.com/jinzhu/inflection v1.0.0 // indirect github.com/jinzhu/now v1.1.5 // indirect github.com/josharian/intern v1.0.0 // indirect @@ -36,13 +44,21 @@ require ( github.com/mattn/go-isatty v0.0.20 // indirect github.com/mattn/go-runewidth v0.0.16 // indirect github.com/mattn/go-sqlite3 v1.14.22 // indirect + github.com/moby/term v0.5.2 // indirect + github.com/morikuni/aec v1.1.0 // indirect + github.com/opencontainers/go-digest v1.0.0 // indirect + github.com/opencontainers/image-spec v1.1.1 // indirect + github.com/pkg/errors v0.9.1 // indirect github.com/rivo/uniseg v0.2.0 // indirect + github.com/rogpeppe/go-internal v1.14.1 // indirect github.com/savsgio/gotils v0.0.0-20230208104028-c358bd845dee // indirect + github.com/stretchr/testify v1.11.1 // indirect github.com/swaggo/files/v2 v2.0.0 // indirect github.com/valyala/bytebufferpool v1.0.0 // indirect - github.com/valyala/fasthttp v1.51.0 // indirect github.com/valyala/tcplisten v1.0.0 // indirect - golang.org/x/sys v0.33.0 // indirect + golang.org/x/sys v0.41.0 // indirect + golang.org/x/time v0.9.0 // indirect golang.org/x/tools v0.33.0 // indirect gopkg.in/yaml.v3 v3.0.1 // indirect + gotest.tools/v3 v3.5.2 // indirect ) diff --git a/go.sum b/go.sum old mode 100644 new mode 100755 index ceaa612..563e7b0 --- a/go.sum +++ b/go.sum @@ -1,9 +1,21 @@ +github.com/Azure/go-ansiterm v0.0.0-20250102033503-faa5f7b0171c h1:udKWzYgxTojEKWjV8V+WSxDXJ4NFATAsZjh8iIbsQIg= +github.com/Azure/go-ansiterm v0.0.0-20250102033503-faa5f7b0171c/go.mod h1:xomTg63KZ2rFqZQzSB4Vz2SUXa1BpHTVz9L5PTmPC4E= github.com/KyleBanks/depth v1.2.1 h1:5h8fQADFrWtarTdtDudMmGsC7GPbOAu6RVB3ffsVFHc= github.com/KyleBanks/depth v1.2.1/go.mod h1:jzSb9d0L43HxTQfT+oSA1EEp2q+ne2uh6XgeJcm8brE= +github.com/Microsoft/go-winio v0.4.21 h1:+6mVbXh4wPzUrl1COX9A+ZCvEpYsOBZ6/+kwDnvLyro= +github.com/Microsoft/go-winio v0.4.21/go.mod h1:JPGBdM1cNvN/6ISo+n8V5iA4v8pBzdOpzfwIujj1a84= github.com/andybalholm/brotli v1.1.0 h1:eLKJA0d02Lf0mVpIDgYnqXcUn0GqVmEFny3VuID1U3M= github.com/andybalholm/brotli v1.1.0/go.mod h1:sms7XGricyQI9K10gOSf56VKKWS4oLer58Q+mhRPtnY= github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c= github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= +github.com/docker/distribution v2.7.1+incompatible h1:a5mlkVzth6W5A4fOsS3D2EO5BUmsJpcB+cRlLU7cSug= +github.com/docker/distribution v2.7.1+incompatible/go.mod h1:J2gT2udsDAN96Uj4KfcMRqY0/ypR+oyYUYmja8H+y+w= +github.com/docker/docker v24.0.9+incompatible h1:HPGzNmwfLZWdxHqK9/II92pyi1EpYKsAqcl4G0Of9v0= +github.com/docker/docker v24.0.9+incompatible/go.mod h1:eEKB0N0r5NX/I1kEveEz05bcu8tLC/8azJZsviup8Sk= +github.com/docker/go-connections v0.6.0 h1:LlMG9azAe1TqfR7sO+NJttz1gy6KO7VJBh+pMmjSD94= +github.com/docker/go-connections v0.6.0/go.mod h1:AahvXYshr6JgfUJGdDCs2b5EZG/vmaMAntpSFH5BFKE= +github.com/docker/go-units v0.5.0 h1:69rxXcBk27SvSaaxTtLh/8llcHD8vYHT7WSdRZ/jvr4= +github.com/docker/go-units v0.5.0/go.mod h1:fgPhTUdO+D/Jk86RDLlptpiXQzgHJF7gydDDbaIK4Dk= github.com/fasthttp/websocket v1.5.3 h1:TPpQuLwJYfd4LJPXvHDYPMFWbLjsT91n3GpWtCQtdek= github.com/fasthttp/websocket v1.5.3/go.mod h1:46gg/UBmTU1kUaTcwQXpUxtRwG2PvIZYeA8oL6vF3Fs= github.com/go-openapi/jsonpointer v0.21.0 h1:YgdVicSA9vH5RiHs9TZW5oyafXZFc6+2Vc1rr/O9oNQ= @@ -20,8 +32,12 @@ github.com/gofiber/swagger v1.1.0 h1:ff3rg1fB+Rp5JN/N8jfxTiZtMKe/9tB9QDc79fPiJKQ github.com/gofiber/swagger v1.1.0/go.mod h1:pRZL0Np35sd+lTODTE5The0G+TMHfNY+oC4hM2/i5m8= github.com/gofiber/websocket/v2 v2.2.1 h1:C9cjxvloojayOp9AovmpQrk8VqvVnT8Oao3+IUygH7w= github.com/gofiber/websocket/v2 v2.2.1/go.mod h1:Ao/+nyNnX5u/hIFPuHl28a+NIkrqK7PRimyKaj4JxVU= +github.com/gogo/protobuf v1.3.2 h1:Ov1cvc58UF3b5XjBnZv7+opcTcQFZebYjWzi34vdm4Q= +github.com/gogo/protobuf v1.3.2/go.mod h1:P1XiOD3dCwIKUDQYPy72D8LYyHL2YPYrpS2s69NZV8Q= github.com/golang-jwt/jwt/v4 v4.5.2 h1:YtQM7lnr8iZ+j5q71MGKkNw9Mn7AjHM68uc9g5fXeUI= github.com/golang-jwt/jwt/v4 v4.5.2/go.mod h1:m21LjoU+eqJr34lmDMbreY2eSTRJ1cv77w39/MY0Ch0= +github.com/google/go-cmp v0.7.0 h1:wk8382ETsv4JYUZwIsn6YpYiWiBsYLSJiTsyBybVuN8= +github.com/google/go-cmp v0.7.0/go.mod h1:pXiqmnSA92OHEEa9HXL2W4E7lf9JzCmGVUdgjX3N/iU= github.com/google/uuid v1.6.0 h1:NIvaJDMOsjHA8n1jAhLSgzrAzy1Hgr+hNrb57e+94F0= github.com/google/uuid v1.6.0/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo= github.com/jinzhu/inflection v1.0.0 h1:K317FqzuhWc8YvSVlFMCCUb36O/S9MCKRDI7QkRKD/E= @@ -32,6 +48,8 @@ github.com/joho/godotenv v1.5.1 h1:7eLL/+HRGLY0ldzfGMeQkb7vMd0as4CfYvUVzLqw0N0= github.com/joho/godotenv v1.5.1/go.mod h1:f4LDr5Voq0i2e/R5DDNOoa2zzDfwtkZa6DnEwAbqwq4= github.com/josharian/intern v1.0.0 h1:vlS4z54oSdjm0bgjRigI+G1HpF+tI+9rE5LLzOg8HmY= github.com/josharian/intern v1.0.0/go.mod h1:5DoeVV0s6jJacbCEi61lwdGj/aVlrQvzHFFd8Hwg//Y= +github.com/kisielk/errcheck v1.5.0/go.mod h1:pFxgyoBC7bSaBwPgfKdkLd5X25qrDl4LWUI2bnpBCr8= +github.com/kisielk/gotool v1.0.0/go.mod h1:XhKaO+MFFWcvkIS/tQcRk01m1F5IRFswLeQ+oQHNcck= github.com/klauspost/compress v1.17.9 h1:6KIumPrER1LHsvBVuDa0r5xaG0Es51mhhB9BQB2qeMA= github.com/klauspost/compress v1.17.9/go.mod h1:Di0epgTjJY877eYKx5yC51cX2A2Vl2ibi7bDH9ttBbw= github.com/kr/pretty v0.3.1 h1:flRD4NNwYAUpkphVc1HcthR4KEIFJ65n8Mw5qdRn3LE= @@ -49,18 +67,31 @@ github.com/mattn/go-runewidth v0.0.16 h1:E5ScNMtiwvlvB5paMFdw9p4kSQzbXFikJ5SQO6T github.com/mattn/go-runewidth v0.0.16/go.mod h1:Jdepj2loyihRzMpdS35Xk/zdY8IAYHsh153qUoGf23w= github.com/mattn/go-sqlite3 v1.14.22 h1:2gZY6PC6kBnID23Tichd1K+Z0oS6nE/XwU+Vz/5o4kU= github.com/mattn/go-sqlite3 v1.14.22/go.mod h1:Uh1q+B4BYcTPb+yiD3kU8Ct7aC0hY9fxUwlHK0RXw+Y= +github.com/moby/term v0.5.2 h1:6qk3FJAFDs6i/q3W/pQ97SX192qKfZgGjCQqfCJkgzQ= +github.com/moby/term v0.5.2/go.mod h1:d3djjFCrjnB+fl8NJux+EJzu0msscUP+f8it8hPkFLc= +github.com/morikuni/aec v1.1.0 h1:vBBl0pUnvi/Je71dsRrhMBtreIqNMYErSAbEeb8jrXQ= +github.com/morikuni/aec v1.1.0/go.mod h1:xDRgiq/iw5l+zkao76YTKzKttOp2cwPEne25HDkJnBw= +github.com/opencontainers/go-digest v1.0.0 h1:apOUWs51W5PlhuyGyz9FCeeBIOUDA/6nW8Oi/yOhh5U= +github.com/opencontainers/go-digest v1.0.0/go.mod h1:0JzlMkj0TRzQZfJkVvzbP0HBR3IKzErnv2BNG4W4MAM= +github.com/opencontainers/image-spec v1.1.1 h1:y0fUlFfIZhPF1W537XOLg0/fcx6zcHCJwooC2xJA040= +github.com/opencontainers/image-spec v1.1.1/go.mod h1:qpqAh3Dmcf36wStyyWU+kCeDgrGnAve2nCC8+7h8Q0M= +github.com/pkg/errors v0.9.1 h1:FEBLx1zS214owpjy7qsBeixbURkuhQAwrK5UwLGTwt4= +github.com/pkg/errors v0.9.1/go.mod h1:bwawxfHBFNV+L2hUp1rHADufV3IMtnDRdf1r5NINEl0= github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM= github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4= github.com/qjebbs/go-jsons v0.0.0-20221222033332-a534c5fc1c4c h1:kmzxiX+OB0knCo1V0dkEkdPelzCdAzCURCfmFArn2/A= github.com/qjebbs/go-jsons v0.0.0-20221222033332-a534c5fc1c4c/go.mod h1:wNJrtinHyC3YSf6giEh4FJN8+yZV7nXBjvmfjhBIcw4= github.com/rivo/uniseg v0.2.0 h1:S1pD9weZBuJdFmowNwbpi7BJ8TNftyUImj/0WQi72jY= github.com/rivo/uniseg v0.2.0/go.mod h1:J6wj4VEh+S6ZtnVlnTBMWIodfgj8LQOQFoIToxlJtxc= -github.com/rogpeppe/go-internal v1.11.0 h1:cWPaGQEPrBb5/AsnsZesgZZ9yb1OQ+GOISoDNXVBh4M= -github.com/rogpeppe/go-internal v1.11.0/go.mod h1:ddIwULY96R17DhadqLgMfk9H9tvdUzkipdSkR5nkCZA= +github.com/rogpeppe/go-internal v1.14.1 h1:UQB4HGPB6osV0SQTLymcB4TgvyWu6ZyliaW0tI/otEQ= +github.com/rogpeppe/go-internal v1.14.1/go.mod h1:MaRKkUm5W0goXpeCfT7UZI6fk/L7L7so1lCWt35ZSgc= github.com/savsgio/gotils v0.0.0-20230208104028-c358bd845dee h1:8Iv5m6xEo1NR1AvpV+7XmhI4r39LGNzwUL4YpMuL5vk= github.com/savsgio/gotils v0.0.0-20230208104028-c358bd845dee/go.mod h1:qwtSXrKuJh/zsFQ12yEE89xfCrGKK63Rr7ctU/uCo4g= -github.com/stretchr/testify v1.9.0 h1:HtqpIVDClZ4nwg75+f6Lvsy/wHu+3BoSGCbBAcpTsTg= -github.com/stretchr/testify v1.9.0/go.mod h1:r2ic/lqez/lEtzL7wO/rwa5dbSLXVDPFyf8C91i36aY= +github.com/sirupsen/logrus v1.7.0 h1:ShrD1U9pZB12TX0cVy0DtePoCH97K8EtX+mg7ZARUtM= +github.com/sirupsen/logrus v1.7.0/go.mod h1:yWOB1SBYBC5VeMP7gHvWumXLIWorT60ONWic61uBYv0= +github.com/stretchr/testify v1.2.2/go.mod h1:a8OnRcib4nhh0OaRAV+Yts87kKdq0PP7pXfy6kDkUVs= +github.com/stretchr/testify v1.11.1 h1:7s2iGBzp5EwR7/aIZr8ao5+dra3wiQyKjjFuvgVKu7U= +github.com/stretchr/testify v1.11.1/go.mod h1:wZwfW3scLgRK+23gO65QZefKpKQRnfz6sD981Nm4B6U= github.com/swaggo/files/v2 v2.0.0 h1:hmAt8Dkynw7Ssz46F6pn8ok6YmGZqHSVLZ+HQM7i0kw= github.com/swaggo/files/v2 v2.0.0/go.mod h1:24kk2Y9NYEJ5lHuCra6iVwkMjIekMCaFq/0JQj66kyM= github.com/swaggo/swag v1.16.3 h1:PnCYjPCah8FK4I26l2F/KQ4yz3sILcVUN3cTlBFA9Pg= @@ -71,22 +102,53 @@ github.com/valyala/fasthttp v1.51.0 h1:8b30A5JlZ6C7AS81RsWjYMQmrZG6feChmgAolCl1S github.com/valyala/fasthttp v1.51.0/go.mod h1:oI2XroL+lI7vdXyYoQk03bXBThfFl2cVdIA3Xl7cH8g= github.com/valyala/tcplisten v1.0.0 h1:rBHj/Xf+E1tRGZyWIWwJDiRY0zc1Js+CV5DqwacVSA8= github.com/valyala/tcplisten v1.0.0/go.mod h1:T0xQ8SeCZGxckz9qRXTfG43PvQ/mcWh7FwZEA7Ioqkc= +github.com/yuin/goldmark v1.1.27/go.mod h1:3hX8gzYuyVAZsxl0MRgGTJEmQBFcNTphYh9decYSb74= +github.com/yuin/goldmark v1.2.1/go.mod h1:3hX8gzYuyVAZsxl0MRgGTJEmQBFcNTphYh9decYSb74= go.uber.org/dig v1.17.1 h1:Tga8Lz8PcYNsWsyHMZ1Vm0OQOUaJNDyvPImgbAu9YSc= go.uber.org/dig v1.17.1/go.mod h1:Us0rSJiThwCv2GteUN0Q7OKvU7n5J4dxZ9JKUXozFdE= +golang.org/x/crypto v0.0.0-20190308221718-c2843e01d9a2/go.mod h1:djNgcEr1/C05ACkg1iLfiJU5Ep61QUkGW8qpdssI0+w= +golang.org/x/crypto v0.0.0-20191011191535-87dc89f01550/go.mod h1:yigFU9vqHzYiE8UmvKecakEJjdnWj3jj499lnFckfCI= +golang.org/x/crypto v0.0.0-20200622213623-75b288015ac9/go.mod h1:LzIPMQfyMNhhGPhUkYOs5KpL4U8rLKemX1yGLhDgUto= golang.org/x/crypto v0.39.0 h1:SHs+kF4LP+f+p14esP5jAoDpHU8Gu/v9lFRK6IT5imM= golang.org/x/crypto v0.39.0/go.mod h1:L+Xg3Wf6HoL4Bn4238Z6ft6KfEpN0tJGo53AAPC632U= +golang.org/x/mod v0.2.0/go.mod h1:s0Qsj1ACt9ePp/hMypM3fl4fZqREWJwdYDEqhRiZZUA= +golang.org/x/mod v0.3.0/go.mod h1:s0Qsj1ACt9ePp/hMypM3fl4fZqREWJwdYDEqhRiZZUA= golang.org/x/mod v0.25.0 h1:n7a+ZbQKQA/Ysbyb0/6IbB1H/X41mKgbhfv7AfG/44w= golang.org/x/mod v0.25.0/go.mod h1:IXM97Txy2VM4PJ3gI61r1YEk/gAj6zAHN3AdZt6S9Ww= +golang.org/x/net v0.0.0-20190404232315-eb5bcb51f2a3/go.mod h1:t9HGtf8HONx5eT2rtn7q6eTqICYqUVnKs3thJo3Qplg= +golang.org/x/net v0.0.0-20190620200207-3b0461eec859/go.mod h1:z5CRVTTTmAJ677TzLLGU+0bjPO0LkuOLi4/5GtJWs/s= +golang.org/x/net v0.0.0-20200226121028-0de0cce0169b/go.mod h1:z5CRVTTTmAJ677TzLLGU+0bjPO0LkuOLi4/5GtJWs/s= +golang.org/x/net v0.0.0-20201021035429-f5854403a974/go.mod h1:sp8m0HH+o8qH0wwXwYZr8TS3Oi6o0r6Gce1SSxlDquU= +golang.org/x/sync v0.0.0-20190423024810-112230192c58/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= +golang.org/x/sync v0.0.0-20190911185100-cd5d95a43a6e/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= +golang.org/x/sync v0.0.0-20201020160332-67f06af15bc9/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= golang.org/x/sync v0.15.0 h1:KWH3jNZsfyT6xfAfKiz6MRNmd46ByHDYaZ7KSkCtdW8= golang.org/x/sync v0.15.0/go.mod h1:1dzgHSNfp02xaA81J2MS99Qcpr2w7fw1gpm99rleRqA= +golang.org/x/sys v0.0.0-20190215142949-d0b11bdaac8a/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY= +golang.org/x/sys v0.0.0-20190412213103-97732733099d/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= +golang.org/x/sys v0.0.0-20191026070338-33540a1f6037/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= +golang.org/x/sys v0.0.0-20200930185726-fdedc70b468f/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= +golang.org/x/sys v0.0.0-20210124154548-22da62e12c0c/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= golang.org/x/sys v0.0.0-20220811171246-fbc7d0a398ab/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= golang.org/x/sys v0.6.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= -golang.org/x/sys v0.33.0 h1:q3i8TbbEz+JRD9ywIRlyRAQbM0qF7hu24q3teo2hbuw= -golang.org/x/sys v0.33.0/go.mod h1:BJP2sWEmIv4KK5OTEluFJCKSidICx8ciO85XgH3Ak8k= +golang.org/x/sys v0.41.0 h1:Ivj+2Cp/ylzLiEU89QhWblYnOE9zerudt9Ftecq2C6k= +golang.org/x/sys v0.41.0/go.mod h1:OgkHotnGiDImocRcuBABYBEXf8A9a87e/uXjp9XT3ks= +golang.org/x/text v0.3.0/go.mod h1:NqM8EUOU14njkJ3fqMW+pc6Ldnwhi/IjpwHt7yyuwOQ= +golang.org/x/text v0.3.3/go.mod h1:5Zoc/QRtKVWzQhOtBMvqHzDpF6irO9z98xDceosuGiQ= golang.org/x/text v0.26.0 h1:P42AVeLghgTYr4+xUnTRKDMqpar+PtX7KWuNQL21L8M= golang.org/x/text v0.26.0/go.mod h1:QK15LZJUUQVJxhz7wXgxSy/CJaTFjd0G+YLonydOVQA= +golang.org/x/time v0.9.0 h1:EsRrnYcQiGH+5FfbgvV4AP7qEZstoyrHB0DzarOQ4ZY= +golang.org/x/time v0.9.0/go.mod h1:3BpzKBy/shNhVucY/MWOyx10tF3SFh9QdLuxbVysPQM= +golang.org/x/tools v0.0.0-20180917221912-90fa682c2a6e/go.mod h1:n7NCudcB/nEzxVGmLbDWY5pfWTLqBcC2KZ6jyYvM4mQ= +golang.org/x/tools v0.0.0-20191119224855-298f0cb1881e/go.mod h1:b+2E5dAYhXwXZwtnZ6UAqBI28+e2cm9otk0dWdXHAEo= +golang.org/x/tools v0.0.0-20200619180055-7c47624df98f/go.mod h1:EkVYQZoAsY45+roYkvgYkIh4xh/qjgUK9TdY2XT94GE= +golang.org/x/tools v0.0.0-20210106214847-113979e3529a/go.mod h1:emZCQorbCU4vsT4fOWvOPXz4eW1wZW4PmDk9uLelYpA= golang.org/x/tools v0.33.0 h1:4qz2S3zmRxbGIhDIAgjxvFutSvH5EfnsYrRBj0UI0bc= golang.org/x/tools v0.33.0/go.mod h1:CIJMaWEY88juyUfo7UbgPqbC8rU2OqfAV1h2Qp0oMYI= +golang.org/x/xerrors v0.0.0-20190717185122-a985d3407aa7/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0= +golang.org/x/xerrors v0.0.0-20191011141410-1b5146add898/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0= +golang.org/x/xerrors v0.0.0-20191204190536-9bdfabe68543/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0= +golang.org/x/xerrors v0.0.0-20200804184101-5ec99f83aff1/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0= gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c h1:Hei/4ADfdWqJk1ZMxUNpqntNwaWcugrBjAiHlqqRiVk= gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c/go.mod h1:JHkPIbrfpd72SG/EVd6muEfDQjcINNoR0C8j2r3qZ4Q= @@ -96,3 +158,5 @@ gorm.io/driver/sqlite v1.5.6 h1:fO/X46qn5NUEEOZtnjJRWRzZMe8nqJiQ9E+0hi+hKQE= gorm.io/driver/sqlite v1.5.6/go.mod h1:U+J8craQU6Fzkcvu8oLeAQmi50TkwPEhHDEjQZXDah4= gorm.io/gorm v1.25.11 h1:/Wfyg1B/je1hnDx3sMkX+gAlxrlZpn6X0BXRlwXlvHg= gorm.io/gorm v1.25.11/go.mod h1:xh7N7RHfYlNc5EmcI/El95gXusucDrQnHXe0+CgWcLQ= +gotest.tools/v3 v3.5.2 h1:7koQfIKdy+I8UTetycgUqXWSDwpgv193Ka+qRsmBY8Q= +gotest.tools/v3 v3.5.2/go.mod h1:LtdLGcnqToBH83WByAAi/wiwSFCArdFIUV/xxN4pcjA= diff --git a/local/api/api.go b/local/api/api.go old mode 100644 new mode 100755 index f8b6114..d98697f --- a/local/api/api.go +++ b/local/api/api.go @@ -33,6 +33,11 @@ func Init(di *dig.Container, app *fiber.App) { System: groups.Group("/system"), WebSocket: groups.Group("/ws"), Leaderboard: serverIdGroup.Group("/leaderboard"), + Steam: groups.Group("/steam"), + ActivityLog: serverIdGroup.Group("/activity-log"), + Groups: groups, + Log: serverIdGroup.Group("/log"), + Presets: groups.Group("/presets"), } accessKeyMiddleware := middleware.NewAccessKeyMiddleware() diff --git a/local/controller/activity_log.go b/local/controller/activity_log.go new file mode 100644 index 0000000..0711bb4 --- /dev/null +++ b/local/controller/activity_log.go @@ -0,0 +1,48 @@ +package controller + +import ( + "acc-server-manager/local/middleware" + "acc-server-manager/local/model" + "acc-server-manager/local/service" + "acc-server-manager/local/utl/common" + "acc-server-manager/local/utl/error_handler" + + "github.com/gofiber/fiber/v2" +) + +type ActivityLogController struct { + service *service.ActivityLogService + errorHandler *error_handler.ControllerErrorHandler +} + +func NewActivityLogController(as *service.ActivityLogService, routeGroups *common.RouteGroups, auth *middleware.AuthMiddleware) *ActivityLogController { + ac := &ActivityLogController{ + service: as, + errorHandler: error_handler.NewControllerErrorHandler(), + } + + // Global activity log: GET /activity-log (admin use) + routeGroups.Groups.Group("/activity-log").Use(auth.Authenticate).Get("/", ac.GetAllLogs) + + return ac +} + +// GetAllLogs returns activity logs across all servers (admin). +// +// @Summary Return all activity logs +// @Tags ActivityLog +// @Success 200 {array} model.ActivityLog +// @Router /activity-log [get] +func (ac *ActivityLogController) GetAllLogs(c *fiber.Ctx) error { + var filter model.ActivityLogFilter + if err := common.ParseQueryFilter(c, &filter); err != nil { + return ac.errorHandler.HandleValidationError(c, err, "query_filter") + } + + result, err := ac.service.GetAll(c.UserContext(), &filter) + if err != nil { + return ac.errorHandler.HandleServiceError(c, err) + } + + return c.JSON(result) +} diff --git a/local/controller/config.go b/local/controller/config.go old mode 100644 new mode 100755 diff --git a/local/controller/config_preset.go b/local/controller/config_preset.go new file mode 100644 index 0000000..fb792b1 --- /dev/null +++ b/local/controller/config_preset.go @@ -0,0 +1,246 @@ +package controller + +import ( + "acc-server-manager/local/middleware" + "acc-server-manager/local/model" + "acc-server-manager/local/service" + "acc-server-manager/local/utl/common" + "acc-server-manager/local/utl/error_handler" + "acc-server-manager/local/utl/logging" + + "github.com/gofiber/fiber/v2" + "github.com/google/uuid" +) + +type ConfigPresetController struct { + service *service.ConfigPresetService + apiService *service.ServiceControlService + errorHandler *error_handler.ControllerErrorHandler +} + +// NewConfigPresetController initializes ConfigPresetController. +func NewConfigPresetController(s *service.ConfigPresetService, apiService *service.ServiceControlService, routeGroups *common.RouteGroups, auth *middleware.AuthMiddleware) *ConfigPresetController { + c := &ConfigPresetController{ + service: s, + apiService: apiService, + errorHandler: error_handler.NewControllerErrorHandler(), + } + + presetRoutes := routeGroups.Presets + presetRoutes.Use(auth.Authenticate) + presetRoutes.Get("/", auth.HasPermission(model.PresetView), c.ListPresets) + presetRoutes.Post("/", auth.HasPermission(model.PresetCreate), c.CreatePreset) + presetRoutes.Get("/:presetId", auth.HasPermission(model.PresetView), c.GetPreset) + presetRoutes.Put("/:presetId", auth.HasPermission(model.PresetUpdate), c.UpdatePreset) + presetRoutes.Delete("/:presetId", auth.HasPermission(model.PresetDelete), c.DeletePreset) + + configRoutes := routeGroups.Config + configRoutes.Post("/presets/:presetId/apply", auth.HasPermission(model.ConfigUpdate), c.ApplyPreset) + + return c +} + +// ListPresets returns all config presets. +// +// @Summary List config presets +// @Description Get a list of all saved configuration presets +// @Tags Config Presets +// @Accept json +// @Produce json +// @Param name query string false "Filter by preset name (partial match)" +// @Success 200 {array} model.ConfigPreset "List of presets" +// @Failure 401 {object} error_handler.ErrorResponse "Unauthorized" +// @Failure 403 {object} error_handler.ErrorResponse "Insufficient permissions" +// @Failure 500 {object} error_handler.ErrorResponse "Internal server error" +// @Security BearerAuth +// @Router /presets [get] +func (c *ConfigPresetController) ListPresets(ctx *fiber.Ctx) error { + var filter model.ConfigPresetFilter + if err := common.ParseQueryFilter(ctx, &filter); err != nil { + return c.errorHandler.HandleParsingError(ctx, err) + } + + presets, err := c.service.ListPresets(ctx.UserContext(), &filter) + if err != nil { + return c.errorHandler.HandleServiceError(ctx, err) + } + return ctx.JSON(presets) +} + +// GetPreset returns a single config preset by ID. +// +// @Summary Get config preset +// @Description Retrieve a single configuration preset by ID +// @Tags Config Presets +// @Accept json +// @Produce json +// @Param presetId path string true "Preset ID (UUID format)" +// @Success 200 {object} model.ConfigPreset "Preset" +// @Failure 400 {object} error_handler.ErrorResponse "Invalid preset ID" +// @Failure 401 {object} error_handler.ErrorResponse "Unauthorized" +// @Failure 403 {object} error_handler.ErrorResponse "Insufficient permissions" +// @Failure 404 {object} error_handler.ErrorResponse "Preset not found" +// @Failure 500 {object} error_handler.ErrorResponse "Internal server error" +// @Security BearerAuth +// @Router /presets/{presetId} [get] +func (c *ConfigPresetController) GetPreset(ctx *fiber.Ctx) error { + id, err := uuid.Parse(ctx.Params("presetId")) + if err != nil { + return c.errorHandler.HandleUUIDError(ctx, "preset ID") + } + + preset, err := c.service.GetPreset(ctx.UserContext(), id) + if err != nil { + return c.errorHandler.HandleServiceError(ctx, err) + } + return ctx.JSON(preset) +} + +// CreatePreset creates a new config preset. +// +// @Summary Create config preset +// @Description Save a new configuration preset with one or more ACC config sections +// @Tags Config Presets +// @Accept json +// @Produce json +// @Param content body model.ConfigPresetCreateRequest true "Preset data" +// @Success 201 {object} model.ConfigPreset "Created preset" +// @Failure 400 {object} error_handler.ErrorResponse "Invalid request" +// @Failure 401 {object} error_handler.ErrorResponse "Unauthorized" +// @Failure 403 {object} error_handler.ErrorResponse "Insufficient permissions" +// @Failure 500 {object} error_handler.ErrorResponse "Internal server error" +// @Security BearerAuth +// @Router /presets [post] +func (c *ConfigPresetController) CreatePreset(ctx *fiber.Ctx) error { + var req model.ConfigPresetCreateRequest + if err := ctx.BodyParser(&req); err != nil { + return c.errorHandler.HandleParsingError(ctx, err) + } + + actorID, _ := ctx.Locals("userID").(string) + actorUsername, _ := ctx.Locals("actorUsername").(string) + if actorUsername == "" { + actorUsername = "system" + } + + preset, err := c.service.CreatePreset(ctx.UserContext(), &req, actorID, actorUsername) + if err != nil { + return c.errorHandler.HandleServiceError(ctx, err) + } + return ctx.Status(fiber.StatusCreated).JSON(preset) +} + +// UpdatePreset updates an existing config preset. +// +// @Summary Update config preset +// @Description Update name, description, or any config sections of an existing preset +// @Tags Config Presets +// @Accept json +// @Produce json +// @Param presetId path string true "Preset ID (UUID format)" +// @Param content body model.ConfigPresetUpdateRequest true "Fields to update (omit to keep existing)" +// @Success 200 {object} model.ConfigPreset "Updated preset" +// @Failure 400 {object} error_handler.ErrorResponse "Invalid request or preset ID" +// @Failure 401 {object} error_handler.ErrorResponse "Unauthorized" +// @Failure 403 {object} error_handler.ErrorResponse "Insufficient permissions" +// @Failure 404 {object} error_handler.ErrorResponse "Preset not found" +// @Failure 500 {object} error_handler.ErrorResponse "Internal server error" +// @Security BearerAuth +// @Router /presets/{presetId} [put] +func (c *ConfigPresetController) UpdatePreset(ctx *fiber.Ctx) error { + id, err := uuid.Parse(ctx.Params("presetId")) + if err != nil { + return c.errorHandler.HandleUUIDError(ctx, "preset ID") + } + + var req model.ConfigPresetUpdateRequest + if err := ctx.BodyParser(&req); err != nil { + return c.errorHandler.HandleParsingError(ctx, err) + } + + preset, err := c.service.UpdatePreset(ctx.UserContext(), id, &req) + if err != nil { + return c.errorHandler.HandleServiceError(ctx, err) + } + return ctx.JSON(preset) +} + +// DeletePreset deletes a config preset. +// +// @Summary Delete config preset +// @Description Permanently delete a configuration preset +// @Tags Config Presets +// @Accept json +// @Produce json +// @Param presetId path string true "Preset ID (UUID format)" +// @Success 204 "No Content" +// @Failure 400 {object} error_handler.ErrorResponse "Invalid preset ID" +// @Failure 401 {object} error_handler.ErrorResponse "Unauthorized" +// @Failure 403 {object} error_handler.ErrorResponse "Insufficient permissions" +// @Failure 500 {object} error_handler.ErrorResponse "Internal server error" +// @Security BearerAuth +// @Router /presets/{presetId} [delete] +func (c *ConfigPresetController) DeletePreset(ctx *fiber.Ctx) error { + id, err := uuid.Parse(ctx.Params("presetId")) + if err != nil { + return c.errorHandler.HandleUUIDError(ctx, "preset ID") + } + + if err := c.service.DeletePreset(ctx.UserContext(), id); err != nil { + return c.errorHandler.HandleServiceError(ctx, err) + } + return ctx.SendStatus(fiber.StatusNoContent) +} + +// ApplyPreset applies a saved preset to a server's config files. +// +// @Summary Apply config preset to server +// @Description Apply a saved configuration preset to an ACC server, writing only the sections stored in the preset +// @Tags Server Configuration +// @Accept json +// @Produce json +// @Param id path string true "Server ID (UUID format)" +// @Param presetId path string true "Preset ID (UUID format)" +// @Param restart query bool false "Restart the server after applying the preset" +// @Success 200 {array} model.Config "List of updated config audit records" +// @Failure 400 {object} error_handler.ErrorResponse "Invalid server or preset ID" +// @Failure 401 {object} error_handler.ErrorResponse "Unauthorized" +// @Failure 403 {object} error_handler.ErrorResponse "Insufficient permissions" +// @Failure 404 {object} error_handler.ErrorResponse "Server or preset not found" +// @Failure 500 {object} error_handler.ErrorResponse "Internal server error" +// @Security BearerAuth +// @Router /server/{id}/config/presets/{presetId}/apply [post] +func (c *ConfigPresetController) ApplyPreset(ctx *fiber.Ctx) error { + restart := ctx.QueryBool("restart") + serverID := ctx.Params("id") + + if _, err := uuid.Parse(serverID); err != nil { + return c.errorHandler.HandleUUIDError(ctx, "server ID") + } + + ctx.Locals("serverId", serverID) + + presetID, err := uuid.Parse(ctx.Params("presetId")) + if err != nil { + return c.errorHandler.HandleUUIDError(ctx, "preset ID") + } + + actorID, _ := ctx.Locals("userID").(string) + actorUsername, _ := ctx.Locals("actorUsername").(string) + if actorUsername == "" { + actorUsername = "system" + } + + configs, err := c.service.ApplyPreset(ctx.UserContext(), serverID, presetID, actorID, actorUsername) + if err != nil { + return c.errorHandler.HandleServiceError(ctx, err) + } + + if restart { + if _, err := c.apiService.ServiceControlRestartServer(ctx); err != nil { + logging.ErrorWithContext("PRESET_APPLY_RESTART", "Failed to restart server after preset apply: %v", err) + } + } + + return ctx.JSON(configs) +} diff --git a/local/controller/controller.go b/local/controller/controller.go old mode 100644 new mode 100755 index 96c5692..185d511 --- a/local/controller/controller.go +++ b/local/controller/controller.go @@ -35,6 +35,11 @@ func InitializeControllers(c *dig.Container) { logging.Panic("unable to initialize config controller") } + err = c.Invoke(NewConfigPresetController) + if err != nil { + logging.Panic("unable to initialize config preset controller") + } + err = c.Invoke(NewServerController) if err != nil { logging.Panic("unable to initialize server controller") @@ -64,4 +69,19 @@ func InitializeControllers(c *dig.Container) { if err != nil { logging.Panic("unable to initialize leaderboard controller") } + + err = c.Invoke(NewSteamController) + if err != nil { + logging.Panic("unable to initialize steam controller") + } + + err = c.Invoke(NewActivityLogController) + if err != nil { + logging.Panic("unable to initialize activity log controller") + } + + err = c.Invoke(NewLogController) + if err != nil { + logging.Panic("unable to initialize log controller") + } } diff --git a/local/controller/leaderboard.go b/local/controller/leaderboard.go old mode 100644 new mode 100755 index d985fd9..a9074da --- a/local/controller/leaderboard.go +++ b/local/controller/leaderboard.go @@ -11,6 +11,7 @@ import ( "github.com/google/uuid" ) + type LeaderboardController struct { service *service.LeaderboardService errorHandler *error_handler.ControllerErrorHandler @@ -60,7 +61,14 @@ func (lc *LeaderboardController) Update(c *fiber.Ctx) error { return lc.errorHandler.HandleParsingError(c, err) } - data, err := lc.service.Update(c.UserContext(), serverID, &input) + userInfo, _ := c.Locals("userInfo").(*middleware.CachedUserInfo) + actorID, actorUsername := "", "system" + if userInfo != nil { + actorID = userInfo.UserID + actorUsername = userInfo.Username + } + + data, err := lc.service.Update(c.UserContext(), serverID, &input, actorID, actorUsername) if err != nil { return lc.errorHandler.HandleServiceError(c, err) } diff --git a/local/controller/log.go b/local/controller/log.go new file mode 100644 index 0000000..9d27487 --- /dev/null +++ b/local/controller/log.go @@ -0,0 +1,62 @@ +package controller + +import ( + "acc-server-manager/local/middleware" + "acc-server-manager/local/model" + "acc-server-manager/local/service" + "acc-server-manager/local/utl/common" + "acc-server-manager/local/utl/error_handler" + "strconv" + + "github.com/gofiber/fiber/v2" + "github.com/google/uuid" +) + +type LogController struct { + service *service.ServerService + errorHandler *error_handler.ControllerErrorHandler +} + +func NewLogController(ss *service.ServerService, routeGroups *common.RouteGroups, auth *middleware.AuthMiddleware) *LogController { + lc := &LogController{ + service: ss, + errorHandler: error_handler.NewControllerErrorHandler(), + } + + logRoutes := routeGroups.Log + logRoutes.Use(auth.Authenticate) + logRoutes.Get("/", auth.HasPermission(model.ServerView), lc.GetLogLines) + + return lc +} + +// GetLogLines godoc +// @Summary Get last N lines of server log +// @Tags Server +// @Param id path string true "Server ID" +// @Param lines query int false "Number of lines to return (default 100)" +// @Success 200 {object} object{lines=[]string} +// @Security BearerAuth +// @Router /server/{id}/log [get] +func (lc *LogController) GetLogLines(c *fiber.Ctx) error { + serverIDStr := c.Params("id") + serverID, err := uuid.Parse(serverIDStr) + if err != nil { + return lc.errorHandler.HandleUUIDError(c, "server ID") + } + + n, _ := strconv.Atoi(c.Query("lines", "100")) + if n <= 0 { + n = 100 + } + if n > 10000 { + n = 10000 + } + + lines, err := lc.service.GetLastLogLines(c, serverID, n) + if err != nil { + return lc.errorHandler.HandleServiceError(c, err) + } + + return c.JSON(fiber.Map{"lines": lines}) +} diff --git a/local/controller/lookup.go b/local/controller/lookup.go old mode 100644 new mode 100755 diff --git a/local/controller/membership.go b/local/controller/membership.go old mode 100644 new mode 100755 diff --git a/local/controller/server.go b/local/controller/server.go old mode 100644 new mode 100755 diff --git a/local/controller/service_control.go b/local/controller/service_control.go old mode 100644 new mode 100755 index d010e35..eb3eca2 --- a/local/controller/service_control.go +++ b/local/controller/service_control.go @@ -29,6 +29,7 @@ func NewServiceControlController(as *service.ServiceControlService, routeGroups } serviceRoutes := routeGroups.Server.Group("/:id/service") + serviceRoutes.Use(auth.Authenticate) serviceRoutes.Get("/:service", ac.getStatus) serviceRoutes.Post("/start", ac.startServer) serviceRoutes.Post("/stop", ac.stopServer) diff --git a/local/controller/state_history.go b/local/controller/state_history.go old mode 100644 new mode 100755 diff --git a/local/controller/steam.go b/local/controller/steam.go new file mode 100644 index 0000000..16832da --- /dev/null +++ b/local/controller/steam.go @@ -0,0 +1,57 @@ +package controller + +import ( + "acc-server-manager/local/middleware" + "acc-server-manager/local/model" + "acc-server-manager/local/service" + "acc-server-manager/local/utl/common" + "acc-server-manager/local/utl/error_handler" + + "github.com/gofiber/fiber/v2" +) + +type SteamController struct { + steamService *service.SteamService + auth *middleware.AuthMiddleware + errorHandler *error_handler.ControllerErrorHandler +} + +func NewSteamController(steamService *service.SteamService, auth *middleware.AuthMiddleware, routeGroups *common.RouteGroups) *SteamController { + sc := &SteamController{ + steamService: steamService, + auth: auth, + errorHandler: error_handler.NewControllerErrorHandler(), + } + + routeGroups.Steam.Use(auth.Authenticate, auth.RequireSuperAdmin()) + routeGroups.Steam.Post("/credentials", sc.SetCredentials) + + return sc +} + +func (sc *SteamController) SetCredentials(c *fiber.Ctx) error { + type request struct { + Username string `json:"username"` + Password string `json:"password"` + } + + var req request + if err := c.BodyParser(&req); err != nil { + return sc.errorHandler.HandleParsingError(c, err) + } + + creds := &model.SteamCredentials{ + Username: req.Username, + Password: req.Password, + } + + if err := creds.Validate(); err != nil { + return sc.errorHandler.HandleServiceError(c, err) + } + + if err := sc.steamService.SaveCredentials(c.UserContext(), creds); err != nil { + return sc.errorHandler.HandleServiceError(c, err) + } + + return c.Status(fiber.StatusCreated).JSON(fiber.Map{"id": creds.ID}) +} diff --git a/local/controller/system.go b/local/controller/system.go old mode 100644 new mode 100755 diff --git a/local/controller/websocket.go b/local/controller/websocket.go old mode 100644 new mode 100755 diff --git a/local/middleware/access_key.go b/local/middleware/access_key.go old mode 100644 new mode 100755 diff --git a/local/middleware/auth.go b/local/middleware/auth.go old mode 100644 new mode 100755 index 467d966..fbb0ec3 --- a/local/middleware/auth.go +++ b/local/middleware/auth.go @@ -115,6 +115,7 @@ func (m *AuthMiddleware) AuthenticateWithHandler(jwtHandler *jwt.JWTHandler, isO if os.Getenv("TESTING_ENV") == "true" { userInfo := CachedUserInfo{UserID: uuid.New().String(), Username: "test@example.com", RoleName: "Admin", Permissions: make(map[string]bool), CachedAt: time.Now()} ctx.Locals("userID", userInfo.UserID) + ctx.Locals("actorUsername", userInfo.Username) ctx.Locals("userInfo", userInfo) ctx.Locals("authTime", time.Now()) } else { @@ -127,6 +128,7 @@ func (m *AuthMiddleware) AuthenticateWithHandler(jwtHandler *jwt.JWTHandler, isO } ctx.Locals("userID", claims.UserID) + ctx.Locals("actorUsername", userInfo.Username) ctx.Locals("userInfo", userInfo) ctx.Locals("authTime", time.Now()) } @@ -178,6 +180,23 @@ func (m *AuthMiddleware) HasPermission(requiredPermission string) fiber.Handler } } +func (m *AuthMiddleware) RequireSuperAdmin() fiber.Handler { + return func(ctx *fiber.Ctx) error { + if os.Getenv("TESTING_ENV") == "true" { + return ctx.Next() + } + + userInfo, ok := ctx.Locals("userInfo").(*CachedUserInfo) + if !ok || userInfo.RoleName != "Super Admin" { + return ctx.Status(fiber.StatusForbidden).JSON(fiber.Map{ + "error": "Forbidden", + }) + } + + return ctx.Next() + } +} + func (m *AuthMiddleware) AuthRateLimit() fiber.Handler { return m.securityMW.AuthRateLimit() } diff --git a/local/middleware/logging/request_logging.go b/local/middleware/logging/request_logging.go old mode 100644 new mode 100755 diff --git a/local/middleware/security/security.go b/local/middleware/security/security.go old mode 100644 new mode 100755 diff --git a/local/migrations/001_upgrade_password_security.go b/local/migrations/001_upgrade_password_security.go old mode 100644 new mode 100755 diff --git a/local/migrations/002_migrate_to_uuid.go b/local/migrations/002_migrate_to_uuid.go old mode 100644 new mode 100755 diff --git a/local/migrations/003_update_state_history_sessions.go b/local/migrations/003_update_state_history_sessions.go old mode 100644 new mode 100755 diff --git a/local/migrations/004_fix_ddl_comments.go b/local/migrations/004_fix_ddl_comments.go old mode 100644 new mode 100755 diff --git a/local/migrations/005_add_platform_columns.go b/local/migrations/005_add_platform_columns.go new file mode 100644 index 0000000..113df0f --- /dev/null +++ b/local/migrations/005_add_platform_columns.go @@ -0,0 +1,33 @@ +package migrations + +import ( + "acc-server-manager/local/utl/logging" + "fmt" + + "gorm.io/gorm" +) + +func RunAddPlatformColumnsMigration(db *gorm.DB) error { + if err := db.AutoMigrate(&MigrationRecord{}); err != nil { + return fmt.Errorf("failed to ensure migration_records table: %v", err) + } + + var record MigrationRecord + if err := db.Where("migration_name = ?", "005_add_platform_columns").First(&record).Error; err == nil { + logging.Info("Platform columns migration already applied, skipping") + return nil + } + + record = MigrationRecord{ + MigrationName: "005_add_platform_columns", + AppliedAt: "datetime('now')", + Success: true, + Notes: "Added Platform and ContainerID columns to servers table via GORM AutoMigrate", + } + if err := db.Create(&record).Error; err != nil { + return fmt.Errorf("failed to record migration 005: %v", err) + } + + logging.Info("Platform columns migration recorded") + return nil +} diff --git a/local/migrations/006_add_activity_log.go b/local/migrations/006_add_activity_log.go new file mode 100644 index 0000000..952f80c --- /dev/null +++ b/local/migrations/006_add_activity_log.go @@ -0,0 +1,33 @@ +package migrations + +import ( + "acc-server-manager/local/utl/logging" + "fmt" + + "gorm.io/gorm" +) + +func RunAddActivityLogMigration(db *gorm.DB) error { + if err := db.AutoMigrate(&MigrationRecord{}); err != nil { + return fmt.Errorf("failed to ensure migration_records table: %v", err) + } + + var record MigrationRecord + if err := db.Where("migration_name = ?", "006_add_activity_log").First(&record).Error; err == nil { + logging.Info("Activity log migration already applied, skipping") + return nil + } + + record = MigrationRecord{ + MigrationName: "006_add_activity_log", + AppliedAt: "datetime('now')", + Success: true, + Notes: "Added ActivityLog table via GORM AutoMigrate", + } + if err := db.Create(&record).Error; err != nil { + return fmt.Errorf("failed to record migration 006: %v", err) + } + + logging.Info("Activity log migration recorded") + return nil +} diff --git a/local/migrations/007_add_config_presets.go b/local/migrations/007_add_config_presets.go new file mode 100644 index 0000000..574e102 --- /dev/null +++ b/local/migrations/007_add_config_presets.go @@ -0,0 +1,51 @@ +package migrations + +import ( + "acc-server-manager/local/utl/logging" + "fmt" + "time" + + "gorm.io/gorm" +) + +type ConfigPresetMigration struct { + ID uint `gorm:"primaryKey"` + Name string `gorm:"not null"` + Description string + Configuration string `gorm:"type:text"` + AssistRules string `gorm:"type:text"` + Event string `gorm:"type:text"` + EventRules string `gorm:"type:text"` + Settings string `gorm:"type:text"` + CreatedAt string + UpdatedAt string +} + +func (ConfigPresetMigration) TableName() string { + return "config_presets" +} + +func RunAddConfigPresetsMigration(db *gorm.DB) error { + if err := db.AutoMigrate(&MigrationRecord{}); err != nil { + return fmt.Errorf("failed to ensure migration_records table: %v", err) + } + + var record MigrationRecord + if err := db.Where("migration_name = ?", "007_add_config_presets").First(&record).Error; err == nil { + logging.Info("Config presets migration already applied, skipping") + return nil + } + + record = MigrationRecord{ + MigrationName: "007_add_config_presets", + AppliedAt: time.Now().UTC().Format(time.RFC3339), + Success: true, + Notes: "Added ConfigPreset table via GORM AutoMigrate", + } + if err := db.Create(&record).Error; err != nil { + return fmt.Errorf("failed to record migration 007: %v", err) + } + + logging.Info("Config presets migration recorded") + return nil +} diff --git a/local/model/activity_log.go b/local/model/activity_log.go new file mode 100644 index 0000000..66c79e8 --- /dev/null +++ b/local/model/activity_log.go @@ -0,0 +1,84 @@ +package model + +import ( + "time" + + "github.com/google/uuid" + "gorm.io/gorm" +) + +type ActionType string + +const ( + ActionConfigUpdate ActionType = "config_update" + ActionServerStart ActionType = "server_start" + ActionServerStop ActionType = "server_stop" + ActionServerRestart ActionType = "server_restart" + ActionLeaderboardUpdate ActionType = "leaderboard_update" + ActionPresetCreate ActionType = "preset_create" + ActionPresetApply ActionType = "preset_apply" +) + +type ActivityLog struct { + ID uuid.UUID `json:"id" gorm:"type:uuid;primary_key;"` + ServerID *uuid.UUID `json:"serverId" gorm:"type:uuid;index"` + Server *Server `json:"server,omitempty" gorm:"foreignKey:ServerID"` + UserID string `json:"userId" gorm:"not null"` + Username string `json:"username" gorm:"not null"` + Action ActionType `json:"action" gorm:"not null"` + Details string `json:"details" gorm:"type:text"` + CreatedAt time.Time `json:"createdAt"` +} + +func (a *ActivityLog) BeforeCreate(tx *gorm.DB) error { + if a.ID == uuid.Nil { + a.ID = uuid.New() + } + if a.CreatedAt.IsZero() { + a.CreatedAt = time.Now().UTC() + } + return nil +} + +type ActivityLogFilter struct { + BaseFilter + DateRangeFilter + UserID string `query:"user_id"` + Action ActionType `query:"action"` + ServerID string `query:"server_id"` +} + +func (f *ActivityLogFilter) ApplyFilter(query *gorm.DB) *gorm.DB { + if f.ServerID != "" { + if serverUUID, err := uuid.Parse(f.ServerID); err == nil { + query = query.Where("server_id = ?", serverUUID) + } + } + if f.UserID != "" { + query = query.Where("user_id = ?", f.UserID) + } + if f.Action != "" { + query = query.Where("action = ?", f.Action) + } + timeZero := time.Time{} + if f.StartDate != timeZero { + query = query.Where("created_at >= ?", f.StartDate) + } + if f.EndDate != timeZero { + query = query.Where("created_at <= ?", f.EndDate) + } + return query +} + +func (f *ActivityLogFilter) GetSorting() (field string, desc bool) { + if f.SortBy == "" { + return "created_at", true + } + return f.SortBy, f.SortDesc +} + +type LastActivityInfo struct { + UpdatedAt time.Time `json:"updatedAt"` + Username string `json:"username"` + Action ActionType `json:"action"` +} diff --git a/local/model/cache.go b/local/model/cache.go old mode 100644 new mode 100755 diff --git a/local/model/config.go b/local/model/config.go old mode 100644 new mode 100755 diff --git a/local/model/config_preset.go b/local/model/config_preset.go new file mode 100644 index 0000000..fe62b36 --- /dev/null +++ b/local/model/config_preset.go @@ -0,0 +1,48 @@ +package model + +import ( + "time" + + "github.com/google/uuid" + "gorm.io/gorm" +) + +type ConfigPreset struct { + ID uuid.UUID `json:"id" gorm:"type:uuid;primary_key;"` + Name string `json:"name" gorm:"not null"` + Description string `json:"description"` + Configuration string `json:"configuration" gorm:"type:text"` + AssistRules string `json:"assistRules" gorm:"type:text"` + Event string `json:"event" gorm:"type:text"` + EventRules string `json:"eventRules" gorm:"type:text"` + Settings string `json:"settings" gorm:"type:text"` + CreatedAt time.Time `json:"createdAt"` + UpdatedAt time.Time `json:"updatedAt"` +} + +func (p *ConfigPreset) BeforeCreate(tx *gorm.DB) error { + if p.ID == uuid.Nil { + p.ID = uuid.New() + } + return nil +} + +type ConfigPresetCreateRequest struct { + Name string `json:"name"` + Description string `json:"description"` + Configuration *map[string]interface{} `json:"configuration"` + AssistRules *map[string]interface{} `json:"assistRules"` + Event *map[string]interface{} `json:"event"` + EventRules *map[string]interface{} `json:"eventRules"` + Settings *map[string]interface{} `json:"settings"` +} + +type ConfigPresetUpdateRequest struct { + Name *string `json:"name"` + Description *string `json:"description"` + Configuration *map[string]interface{} `json:"configuration"` + AssistRules *map[string]interface{} `json:"assistRules"` + Event *map[string]interface{} `json:"event"` + EventRules *map[string]interface{} `json:"eventRules"` + Settings *map[string]interface{} `json:"settings"` +} diff --git a/local/model/filter.go b/local/model/filter.go old mode 100644 new mode 100755 index b9df064..138afd0 --- a/local/model/filter.go +++ b/local/model/filter.go @@ -30,6 +30,18 @@ type ConfigFilter struct { ChangedAt time.Time `query:"changed_at" time_format:"2006-01-02T15:04:05Z07:00"` } +type ConfigPresetFilter struct { + BaseFilter + Name string `query:"name"` +} + +func (f *ConfigPresetFilter) ApplyFilter(query *gorm.DB) *gorm.DB { + if f.Name != "" { + query = query.Where("name LIKE ?", "%"+f.Name+"%") + } + return query +} + type ServiceControlFilter struct { BaseFilter ServiceControl string `query:"serviceControl"` diff --git a/local/model/leaderboard.go b/local/model/leaderboard.go old mode 100644 new mode 100755 diff --git a/local/model/lookup.go b/local/model/lookup.go old mode 100644 new mode 100755 diff --git a/local/model/model.go b/local/model/model.go old mode 100644 new mode 100755 diff --git a/local/model/permission.go b/local/model/permission.go old mode 100644 new mode 100755 diff --git a/local/model/permissions.go b/local/model/permissions.go old mode 100644 new mode 100755 index d57c097..8748d25 --- a/local/model/permissions.go +++ b/local/model/permissions.go @@ -24,6 +24,11 @@ const ( MembershipCreate = "membership.create" MembershipView = "membership.view" MembershipEdit = "membership.edit" + + PresetView = "preset.view" + PresetCreate = "preset.create" + PresetUpdate = "preset.update" + PresetDelete = "preset.delete" ) func AllPermissions() []string { @@ -47,5 +52,9 @@ func AllPermissions() []string { MembershipCreate, MembershipView, MembershipEdit, + PresetView, + PresetCreate, + PresetUpdate, + PresetDelete, } } diff --git a/local/model/role.go b/local/model/role.go old mode 100644 new mode 100755 diff --git a/local/model/server.go b/local/model/server.go old mode 100644 new mode 100755 index bab75d6..2c0cb18 --- a/local/model/server.go +++ b/local/model/server.go @@ -35,16 +35,19 @@ func (s *Server) ToServerAPI() *ServerAPI { } type Server struct { - ID uuid.UUID `gorm:"type:uuid;primary_key;" json:"id"` - Name string `gorm:"not null" json:"name"` - Status ServiceStatus `json:"status" gorm:"-"` - IP string `gorm:"not null" json:"-"` - Port int `gorm:"not null" json:"-"` - Path string `gorm:"not null" json:"path"` - ServiceName string `gorm:"not null" json:"serviceName"` - State *ServerState `gorm:"-" json:"state"` - DateCreated time.Time `json:"dateCreated"` - FromSteamCMD bool `gorm:"not null; default:true" json:"-"` + ID uuid.UUID `gorm:"type:uuid;primary_key;" json:"id"` + Name string `gorm:"not null" json:"name"` + Status ServiceStatus `json:"status" gorm:"-"` + IP string `gorm:"not null" json:"-"` + Port int `gorm:"not null" json:"-"` + Path string `gorm:"not null" json:"path"` + ServiceName string `gorm:"not null" json:"serviceName"` + Platform string `gorm:"not null;default:'windows'" json:"platform"` + ContainerID string `gorm:"default:''" json:"-"` + State *ServerState `gorm:"-" json:"state"` + LastActivity *LastActivityInfo `gorm:"-" json:"lastActivity,omitempty"` + DateCreated time.Time `json:"dateCreated"` + FromSteamCMD bool `gorm:"not null; default:true" json:"-"` } type PlayerState struct { diff --git a/local/model/service_control.go b/local/model/service_control.go old mode 100644 new mode 100755 diff --git a/local/model/state_history.go b/local/model/state_history.go old mode 100644 new mode 100755 diff --git a/local/model/state_history_stats.go b/local/model/state_history_stats.go old mode 100644 new mode 100755 diff --git a/local/model/steam_2fa.go b/local/model/steam_2fa.go old mode 100644 new mode 100755 diff --git a/local/model/steam_credentials.go b/local/model/steam_credentials.go old mode 100644 new mode 100755 diff --git a/local/model/user.go b/local/model/user.go old mode 100644 new mode 100755 diff --git a/local/model/websocket.go b/local/model/websocket.go old mode 100644 new mode 100755 index f894a9e..6db5a26 --- a/local/model/websocket.go +++ b/local/model/websocket.go @@ -33,6 +33,7 @@ const ( MessageTypeSteamOutput WebSocketMessageType = "steam_output" MessageTypeError WebSocketMessageType = "error" MessageTypeComplete WebSocketMessageType = "complete" + MessageTypeLogLine WebSocketMessageType = "log_line" ) type WebSocketMessage struct { @@ -65,6 +66,10 @@ type CompleteMessage struct { Message string `json:"message"` } +type LogLineMessage struct { + Line string `json:"line"` +} + func GetStepDescription(step ServerCreationStep) string { descriptions := map[ServerCreationStep]string{ StepValidation: "Validating server configuration", diff --git a/local/platform/interfaces.go b/local/platform/interfaces.go new file mode 100644 index 0000000..d292260 --- /dev/null +++ b/local/platform/interfaces.go @@ -0,0 +1,29 @@ +package platform + +import ( + "acc-server-manager/local/model" + "context" + + "github.com/google/uuid" +) + +type ServerRuntime interface { + Create(ctx context.Context, server *model.Server) error + Delete(ctx context.Context, serverID uuid.UUID) error + Start(ctx context.Context, serverID uuid.UUID) (string, error) + Stop(ctx context.Context, serverID uuid.UUID) (string, error) + Restart(ctx context.Context, serverID uuid.UUID) (string, error) + Status(ctx context.Context, serverID uuid.UUID) (string, error) +} + +type FirewallManager interface { + CreateServerRules(serverName string, tcpPorts, udpPorts []int) error + DeleteServerRules(serverName string, tcpPorts, udpPorts []int) error + UpdateServerRules(serverName string, tcpPorts, udpPorts []int) error +} + +type LogStreamer interface { + Start(ctx context.Context, server *model.Server, handleLine func(string)) error + Stop(serverID uuid.UUID) + GetLastLines(ctx context.Context, server *model.Server, n int) ([]string, error) +} diff --git a/local/repository/activity_log.go b/local/repository/activity_log.go new file mode 100644 index 0000000..f71f577 --- /dev/null +++ b/local/repository/activity_log.go @@ -0,0 +1,96 @@ +package repository + +import ( + "acc-server-manager/local/model" + "context" + "fmt" + "time" + + "github.com/google/uuid" + "gorm.io/gorm" +) + +type ActivityLogRepository struct { + *BaseRepository[model.ActivityLog, model.ActivityLogFilter] +} + +func NewActivityLogRepository(db *gorm.DB) *ActivityLogRepository { + return &ActivityLogRepository{ + BaseRepository: NewBaseRepository[model.ActivityLog, model.ActivityLogFilter](db, model.ActivityLog{}), + } +} + +func (r *ActivityLogRepository) Insert(ctx context.Context, entry *model.ActivityLog) error { + return r.BaseRepository.Insert(ctx, entry) +} + +func (r *ActivityLogRepository) GetAll(ctx context.Context, filter *model.ActivityLogFilter) (*[]model.ActivityLog, error) { + result := &[]model.ActivityLog{} + query := r.BaseRepository.db.WithContext(ctx). + Model(&model.ActivityLog{}). + Preload("Server") + + query = filter.ApplyFilter(query) + + offset, limit := filter.Pagination() + query = query.Offset(offset).Limit(limit) + + field, desc := filter.GetSorting() + if desc { + query = query.Order(field + " DESC") + } else { + query = query.Order(field) + } + + if err := query.Find(result).Error; err != nil { + return nil, fmt.Errorf("error getting activity logs: %w", err) + } + return result, nil +} + +type lastActivityRow struct { + ServerID string `gorm:"column:server_id"` + CreatedAt time.Time `gorm:"column:created_at"` + Username string `gorm:"column:username"` + Action model.ActionType `gorm:"column:action"` +} + +// GetLastActivityByServerIDs returns the most recent activity entry per server. +// Uses a single GROUP BY query with MAX(created_at); SQLite selects the other +// columns from the row that holds the max value — documented SQLite behaviour. +func (r *ActivityLogRepository) GetLastActivityByServerIDs(ctx context.Context, serverIDs []uuid.UUID) (map[uuid.UUID]*model.LastActivityInfo, error) { + out := make(map[uuid.UUID]*model.LastActivityInfo) + if len(serverIDs) == 0 { + return out, nil + } + + // Convert to string slice so the IN clause works with SQLite UUID strings. + ids := make([]string, len(serverIDs)) + for i, id := range serverIDs { + ids[i] = id.String() + } + + var rows []lastActivityRow + err := r.BaseRepository.db.WithContext(ctx). + Model(&model.ActivityLog{}). + Select("server_id, MAX(created_at) as created_at, username, action"). + Where("server_id IN ?", ids). + Group("server_id"). + Scan(&rows).Error + if err != nil { + return nil, err + } + + for _, row := range rows { + serverUUID, err := uuid.Parse(row.ServerID) + if err != nil { + continue + } + out[serverUUID] = &model.LastActivityInfo{ + UpdatedAt: row.CreatedAt, + Username: row.Username, + Action: row.Action, + } + } + return out, nil +} diff --git a/local/repository/base.go b/local/repository/base.go old mode 100644 new mode 100755 diff --git a/local/repository/config.go b/local/repository/config.go old mode 100644 new mode 100755 diff --git a/local/repository/config_preset.go b/local/repository/config_preset.go new file mode 100644 index 0000000..264c03a --- /dev/null +++ b/local/repository/config_preset.go @@ -0,0 +1,17 @@ +package repository + +import ( + "acc-server-manager/local/model" + + "gorm.io/gorm" +) + +type ConfigPresetRepository struct { + *BaseRepository[model.ConfigPreset, model.ConfigPresetFilter] +} + +func NewConfigPresetRepository(db *gorm.DB) *ConfigPresetRepository { + return &ConfigPresetRepository{ + BaseRepository: NewBaseRepository[model.ConfigPreset, model.ConfigPresetFilter](db, model.ConfigPreset{}), + } +} diff --git a/local/repository/leaderboard.go b/local/repository/leaderboard.go old mode 100644 new mode 100755 diff --git a/local/repository/lookup.go b/local/repository/lookup.go old mode 100644 new mode 100755 diff --git a/local/repository/membership.go b/local/repository/membership.go old mode 100644 new mode 100755 diff --git a/local/repository/repository.go b/local/repository/repository.go old mode 100644 new mode 100755 index c8feb17..6305709 --- a/local/repository/repository.go +++ b/local/repository/repository.go @@ -20,6 +20,8 @@ func InitializeRepositories(c *dig.Container) { c.Provide(NewSteamCredentialsRepository) c.Provide(NewMembershipRepository) c.Provide(NewLeaderboardRepository) + c.Provide(NewActivityLogRepository) + c.Provide(NewConfigPresetRepository) if err := c.Provide(func() *model.Steam2FAManager { manager := model.NewSteam2FAManager() diff --git a/local/repository/server.go b/local/repository/server.go old mode 100644 new mode 100755 diff --git a/local/repository/service_control.go b/local/repository/service_control.go old mode 100644 new mode 100755 diff --git a/local/repository/state_history.go b/local/repository/state_history.go old mode 100644 new mode 100755 diff --git a/local/repository/steam_credentials.go b/local/repository/steam_credentials.go old mode 100644 new mode 100755 diff --git a/local/service/activity_log.go b/local/service/activity_log.go new file mode 100644 index 0000000..7ff0b00 --- /dev/null +++ b/local/service/activity_log.go @@ -0,0 +1,65 @@ +package service + +import ( + "acc-server-manager/local/model" + "acc-server-manager/local/repository" + "acc-server-manager/local/utl/logging" + "context" + "encoding/json" + "fmt" +) + +type ActivityLogService struct { + repo *repository.ActivityLogRepository +} + +func NewActivityLogService(repo *repository.ActivityLogRepository) *ActivityLogService { + return &ActivityLogService{repo: repo} +} + +// Log persists an activity log entry. Errors are logged but not returned so +// callers can treat this as fire-and-forget. +func (s *ActivityLogService) Log(ctx context.Context, entry *model.ActivityLog) error { + if err := s.repo.Insert(ctx, entry); err != nil { + logging.Error("Failed to insert activity log: %v", err) + return err + } + return nil +} + +func (s *ActivityLogService) GetAll(ctx context.Context, filter *model.ActivityLogFilter) (*[]model.ActivityLog, error) { + return s.repo.GetAll(ctx, filter) +} + +// BuildConfigDetails produces a JSON string summarising which top-level keys +// changed between oldJSON and newJSON. +func BuildConfigDetails(file string, oldJSON, newJSON []byte) string { + var oldMap, newMap map[string]interface{} + + if err := json.Unmarshal(oldJSON, &oldMap); err != nil { + oldMap = map[string]interface{}{} + } + if err := json.Unmarshal(newJSON, &newMap); err != nil { + newMap = map[string]interface{}{} + } + + var changedKeys []string + for key, newVal := range newMap { + oldVal, exists := oldMap[key] + if !exists { + changedKeys = append(changedKeys, key) + continue + } + oldBytes, _ := json.Marshal(oldVal) + newBytes, _ := json.Marshal(newVal) + if string(oldBytes) != string(newBytes) { + changedKeys = append(changedKeys, key) + } + } + + if len(changedKeys) == 0 { + return "" + } + keysJSON, _ := json.Marshal(changedKeys) + return fmt.Sprintf(`{"file":%q,"changed_keys":%s}`, file, string(keysJSON)) +} diff --git a/local/service/config.go b/local/service/config.go old mode 100644 new mode 100755 index b632854..84ddeaa --- a/local/service/config.go +++ b/local/service/config.go @@ -68,14 +68,16 @@ type ConfigService struct { repository *repository.ConfigRepository serverRepository *repository.ServerRepository serverService *ServerService + activityLog *ActivityLogService configCache *model.ServerConfigCache } -func NewConfigService(repository *repository.ConfigRepository, serverRepository *repository.ServerRepository) *ConfigService { +func NewConfigService(repository *repository.ConfigRepository, serverRepository *repository.ServerRepository, activityLog *ActivityLogService) *ConfigService { logging.Debug("Initializing ConfigService with 5m expiration and 1s throttle") return &ConfigService{ repository: repository, serverRepository: serverRepository, + activityLog: activityLog, configCache: model.NewServerConfigCache(model.CacheConfig{ ExpirationTime: 5 * time.Minute, ThrottleTime: 1 * time.Second, @@ -120,6 +122,12 @@ func (as *ConfigService) UpdateAllConfigs(ctx *fiber.Ctx, req *model.Configurati {SettingsJson, req.Settings}, } + actorID, _ := ctx.Locals("userID").(string) + actorUsername, _ := ctx.Locals("actorUsername").(string) + if actorUsername == "" { + actorUsername = "system" + } + var results []*model.Config for _, entry := range entries { if entry.body == nil { @@ -129,6 +137,16 @@ func (as *ConfigService) UpdateAllConfigs(ctx *fiber.Ctx, req *model.Configurati if err != nil { return nil, err } + if entry.fileName == SettingsJson { + if nameVal, ok := (*entry.body)["serverName"]; ok { + if nameStr, ok := nameVal.(string); ok && nameStr != "" && nameStr != server.Name { + server.Name = nameStr + if err := as.serverRepository.Update(ctx.UserContext(), server); err != nil { + logging.Error("Failed to update server name in DB: %v", err) + } + } + } + } result := as.repository.UpdateConfig(ctx.UserContext(), &model.Config{ ServerID: serverUUID, ConfigFile: entry.fileName, @@ -137,6 +155,20 @@ func (as *ConfigService) UpdateAllConfigs(ctx *fiber.Ctx, req *model.Configurati ChangedAt: time.Now(), }) results = append(results, result) + + if as.activityLog != nil { + if details := BuildConfigDetails(entry.fileName, oldDataUTF8, newData); details != "" { + go func(details string) { + _ = as.activityLog.Log(context.Background(), &model.ActivityLog{ + ServerID: &serverUUID, + UserID: actorID, + Username: actorUsername, + Action: model.ActionConfigUpdate, + Details: details, + }) + }(details) + } + } } if len(results) > 0 { @@ -152,7 +184,13 @@ func (as *ConfigService) UpdateConfig(ctx *fiber.Ctx, body *map[string]interface configFile := ctx.Params("file") override := ctx.QueryBool("override", false) - return as.updateConfigInternal(ctx.UserContext(), serverID, configFile, body, override) + actorID, _ := ctx.Locals("userID").(string) + actorUsername, _ := ctx.Locals("actorUsername").(string) + if actorUsername == "" { + actorUsername = "system" + } + + return as.updateConfigInternal(ctx.UserContext(), serverID, configFile, body, override, actorID, actorUsername) } func (as *ConfigService) updateConfigFiles(ctx context.Context, server *model.Server, configFile string, body *map[string]interface{}, override bool) ([]byte, []byte, error) { @@ -211,7 +249,7 @@ func (as *ConfigService) updateConfigFiles(ctx context.Context, server *model.Se return oldDataUTF8, newData, nil } -func (as *ConfigService) updateConfigInternal(ctx context.Context, serverID string, configFile string, body *map[string]interface{}, override bool) (*model.Config, error) { +func (as *ConfigService) updateConfigInternal(ctx context.Context, serverID string, configFile string, body *map[string]interface{}, override bool, actorID, actorUsername string) (*model.Config, error) { serverUUID, err := uuid.Parse(serverID) if err != nil { logging.Error("Invalid server ID format: %v", err) @@ -229,9 +267,34 @@ func (as *ConfigService) updateConfigInternal(ctx context.Context, serverID stri return nil, err } - as.configCache.InvalidateServerCache(serverID) + if configFile == SettingsJson { + if nameVal, ok := (*body)["serverName"]; ok { + if nameStr, ok := nameVal.(string); ok && nameStr != "" && nameStr != server.Name { + server.Name = nameStr + if err := as.serverRepository.Update(ctx, server); err != nil { + logging.Error("Failed to update server name in DB: %v", err) + } + } + } + } + as.configCache.InvalidateServerCache(serverID) as.serverService.StartAccServerRuntime(server) + + if as.activityLog != nil { + if details := BuildConfigDetails(configFile, oldDataUTF8, newData); details != "" { + go func() { + _ = as.activityLog.Log(context.Background(), &model.ActivityLog{ + ServerID: &serverUUID, + UserID: actorID, + Username: actorUsername, + Action: model.ActionConfigUpdate, + Details: details, + }) + }() + } + } + return as.repository.UpdateConfig(ctx, &model.Config{ ServerID: serverUUID, ConfigFile: configFile, @@ -476,6 +539,14 @@ func (as *ConfigService) GetConfiguration(server *model.Server) (*model.Configur return &config, nil } +func (as *ConfigService) SetIgnorePrematureDisconnects(server *model.Server, value int) error { + configMap := map[string]interface{}{ + "ignorePrematureDisconnects": value, + } + _, _, err := as.updateConfigFiles(context.Background(), server, SettingsJson, &configMap, false) + return err +} + func (as *ConfigService) SaveConfiguration(server *model.Server, config *model.Configuration) error { configMap := make(map[string]interface{}) configBytes, err := json.Marshal(config) @@ -489,3 +560,17 @@ func (as *ConfigService) SaveConfiguration(server *model.Server, config *model.C _, _, err = as.updateConfigFiles(context.Background(), server, ConfigurationJson, &configMap, true) return err } + +// ApplySection writes a single config section to the server's config file and +// records the change. It is used by ConfigPresetService to apply preset sections. +func (as *ConfigService) ApplySection(ctx context.Context, serverID, configFile string, body *map[string]interface{}, actorID, actorUsername string) (*model.Config, error) { + return as.updateConfigInternal(ctx, serverID, configFile, body, true, actorID, actorUsername) +} + +func (as *ConfigService) SetServerName(server *model.Server, name string) error { + configMap := map[string]interface{}{ + "serverName": name, + } + _, _, err := as.updateConfigFiles(context.Background(), server, SettingsJson, &configMap, false) + return err +} diff --git a/local/service/config_preset.go b/local/service/config_preset.go new file mode 100644 index 0000000..caada5d --- /dev/null +++ b/local/service/config_preset.go @@ -0,0 +1,210 @@ +package service + +import ( + "acc-server-manager/local/model" + "acc-server-manager/local/repository" + "acc-server-manager/local/utl/logging" + "context" + "encoding/json" + "fmt" + + "github.com/google/uuid" +) + +const ( + presetMaxNameLen = 100 + presetMaxDescLen = 500 + presetMaxSectionBytes = 64 * 1024 // 64 KB per config section +) + +type ConfigPresetService struct { + repository *repository.ConfigPresetRepository + configService *ConfigService + activityLog *ActivityLogService +} + +func NewConfigPresetService(repo *repository.ConfigPresetRepository, configService *ConfigService, activityLog *ActivityLogService) *ConfigPresetService { + return &ConfigPresetService{ + repository: repo, + configService: configService, + activityLog: activityLog, + } +} + +func (s *ConfigPresetService) ListPresets(ctx context.Context, filter *model.ConfigPresetFilter) (*[]model.ConfigPreset, error) { + return s.repository.GetAll(ctx, filter) +} + +func (s *ConfigPresetService) GetPreset(ctx context.Context, id uuid.UUID) (*model.ConfigPreset, error) { + preset, err := s.repository.GetByID(ctx, id) + if err != nil { + return nil, err + } + if preset == nil { + return nil, fmt.Errorf("preset not found") + } + return preset, nil +} + +func (s *ConfigPresetService) CreatePreset(ctx context.Context, req *model.ConfigPresetCreateRequest, actorID, actorUsername string) (*model.ConfigPreset, error) { + if req.Name == "" { + return nil, fmt.Errorf("preset name is required") + } + if len(req.Name) > presetMaxNameLen { + return nil, fmt.Errorf("preset name exceeds maximum length of %d characters", presetMaxNameLen) + } + if len(req.Description) > presetMaxDescLen { + return nil, fmt.Errorf("preset description exceeds maximum length of %d characters", presetMaxDescLen) + } + + preset := &model.ConfigPreset{ + Name: req.Name, + Description: req.Description, + } + + if err := marshalSections(req.Configuration, req.AssistRules, req.Event, req.EventRules, req.Settings, preset); err != nil { + return nil, err + } + + if err := s.repository.Insert(ctx, preset); err != nil { + return nil, err + } + + details := fmt.Sprintf(`{"preset_id":%q,"preset_name":%q}`, preset.ID, preset.Name) + go func() { + if logErr := s.activityLog.Log(context.Background(), &model.ActivityLog{ + UserID: actorID, + Username: actorUsername, + Action: model.ActionPresetCreate, + Details: details, + }); logErr != nil { + logging.Error("Failed to log preset create: %v", logErr) + } + }() + + return preset, nil +} + +func (s *ConfigPresetService) UpdatePreset(ctx context.Context, id uuid.UUID, req *model.ConfigPresetUpdateRequest) (*model.ConfigPreset, error) { + preset, err := s.GetPreset(ctx, id) + if err != nil { + return nil, err + } + + if req.Name != nil { + if len(*req.Name) == 0 { + return nil, fmt.Errorf("preset name cannot be empty") + } + if len(*req.Name) > presetMaxNameLen { + return nil, fmt.Errorf("preset name exceeds maximum length of %d characters", presetMaxNameLen) + } + preset.Name = *req.Name + } + if req.Description != nil { + if len(*req.Description) > presetMaxDescLen { + return nil, fmt.Errorf("preset description exceeds maximum length of %d characters", presetMaxDescLen) + } + preset.Description = *req.Description + } + if err := marshalSections(req.Configuration, req.AssistRules, req.Event, req.EventRules, req.Settings, preset); err != nil { + return nil, err + } + + if err := s.repository.Update(ctx, preset); err != nil { + return nil, err + } + return preset, nil +} + +func (s *ConfigPresetService) DeletePreset(ctx context.Context, id uuid.UUID) error { + return s.repository.Delete(ctx, id) +} + +func (s *ConfigPresetService) ApplyPreset(ctx context.Context, serverID string, presetID uuid.UUID, actorID, actorUsername string) ([]*model.Config, error) { + preset, err := s.GetPreset(ctx, presetID) + if err != nil { + return nil, err + } + + serverUUID, err := uuid.Parse(serverID) + if err != nil { + return nil, fmt.Errorf("invalid server ID: %v", err) + } + + type sectionEntry struct { + fileName string + raw string + } + + sections := []sectionEntry{ + {ConfigurationJson, preset.Configuration}, + {AssistRulesJson, preset.AssistRules}, + {EventJson, preset.Event}, + {EventRulesJson, preset.EventRules}, + {SettingsJson, preset.Settings}, + } + + var results []*model.Config + for _, section := range sections { + if section.raw == "" { + continue + } + var body map[string]interface{} + if err := json.Unmarshal([]byte(section.raw), &body); err != nil { + return nil, fmt.Errorf("failed to unmarshal preset section %s: %v", section.fileName, err) + } + result, err := s.configService.ApplySection(ctx, serverID, section.fileName, &body, actorID, actorUsername) + if err != nil { + return nil, fmt.Errorf("failed to apply preset section %s: %v", section.fileName, err) + } + if result != nil { + results = append(results, result) + } + } + + details := fmt.Sprintf(`{"preset_id":%q,"preset_name":%q,"sections_applied":%d}`, presetID, preset.Name, len(results)) + go func() { + if logErr := s.activityLog.Log(context.Background(), &model.ActivityLog{ + ServerID: &serverUUID, + UserID: actorID, + Username: actorUsername, + Action: model.ActionPresetApply, + Details: details, + }); logErr != nil { + logging.Error("Failed to log preset apply: %v", logErr) + } + }() + + return results, nil +} + +// marshalSections marshals each non-nil config section into the preset's JSON string fields, +// enforcing the per-section byte size limit. +func marshalSections(configuration, assistRules, event, eventRules, settings *map[string]interface{}, preset *model.ConfigPreset) error { + type entry struct { + src *map[string]interface{} + name string + dest *string + } + entries := []entry{ + {configuration, "configuration", &preset.Configuration}, + {assistRules, "assistRules", &preset.AssistRules}, + {event, "event", &preset.Event}, + {eventRules, "eventRules", &preset.EventRules}, + {settings, "settings", &preset.Settings}, + } + for _, e := range entries { + if e.src == nil { + continue + } + b, err := json.Marshal(e.src) + if err != nil { + return fmt.Errorf("failed to marshal %s: %v", e.name, err) + } + if len(b) > presetMaxSectionBytes { + return fmt.Errorf("%s section exceeds maximum size of %d bytes", e.name, presetMaxSectionBytes) + } + *e.dest = string(b) + } + return nil +} diff --git a/local/service/docker_log_streamer.go b/local/service/docker_log_streamer.go new file mode 100644 index 0000000..ba47bef --- /dev/null +++ b/local/service/docker_log_streamer.go @@ -0,0 +1,108 @@ +package service + +import ( + "acc-server-manager/local/model" + "acc-server-manager/local/utl/logging" + "bufio" + "context" + "io" + "strconv" + "sync" + "time" + + "github.com/docker/docker/api/types" + dockerclient "github.com/docker/docker/client" + "github.com/docker/docker/pkg/stdcopy" + "github.com/google/uuid" +) + +type DockerLogStreamer struct { + client *dockerclient.Client + cancels sync.Map // uuid.UUID → context.CancelFunc +} + +func NewDockerLogStreamer(client *dockerclient.Client) *DockerLogStreamer { + return &DockerLogStreamer{client: client} +} + +func (s *DockerLogStreamer) Start(ctx context.Context, server *model.Server, handleLine func(string)) error { + s.Stop(server.ID) + + if server.ContainerID == "" { + logging.Warn("DockerLogStreamer.Start: no container ID for server %s, skipping", server.ID) + return nil + } + + since := time.Now().UTC().Format(time.RFC3339) + streamCtx, cancel := context.WithCancel(ctx) + s.cancels.Store(server.ID, cancel) + + go func() { + defer s.cancels.Delete(server.ID) + + rc, err := s.client.ContainerLogs(streamCtx, server.ContainerID, types.ContainerLogsOptions{ + ShowStdout: true, + ShowStderr: true, + Follow: true, + Timestamps: false, + Since: since, + }) + if err != nil { + logging.Error("DockerLogStreamer: failed to attach to container %s: %v", server.ContainerID[:12], err) + return + } + defer rc.Close() + + pr, pw := io.Pipe() + go func() { + defer pw.Close() + if _, err := stdcopy.StdCopy(pw, pw, rc); err != nil && err != io.EOF { + logging.Warn("DockerLogStreamer: stdcopy error for %s: %v", server.ServiceName, err) + } + }() + + scanner := bufio.NewScanner(pr) + for scanner.Scan() { + handleLine(scanner.Text()) + } + }() + + return nil +} + +func (s *DockerLogStreamer) Stop(serverID uuid.UUID) { + if v, ok := s.cancels.LoadAndDelete(serverID); ok { + v.(context.CancelFunc)() + } +} + +func (s *DockerLogStreamer) GetLastLines(ctx context.Context, server *model.Server, n int) ([]string, error) { + if n <= 0 || server.ContainerID == "" { + return []string{}, nil + } + + rc, err := s.client.ContainerLogs(ctx, server.ContainerID, types.ContainerLogsOptions{ + ShowStdout: true, + ShowStderr: true, + Tail: strconv.Itoa(n), + }) + if err != nil { + return nil, err + } + defer rc.Close() + + pr, pw := io.Pipe() + go func() { + defer pw.Close() + if _, err := stdcopy.StdCopy(pw, pw, rc); err != nil && err != io.EOF { + logging.Warn("DockerLogStreamer.GetLastLines: stdcopy error for %s: %v", server.ServiceName, err) + } + }() + + var lines []string + scanner := bufio.NewScanner(pr) + for scanner.Scan() { + lines = append(lines, scanner.Text()) + } + return lines, scanner.Err() +} diff --git a/local/service/docker_runtime.go b/local/service/docker_runtime.go new file mode 100644 index 0000000..addcc16 --- /dev/null +++ b/local/service/docker_runtime.go @@ -0,0 +1,238 @@ +package service + +import ( + "acc-server-manager/local/model" + "acc-server-manager/local/repository" + "acc-server-manager/local/utl/env" + "acc-server-manager/local/utl/logging" + "context" + "fmt" + "path/filepath" + "strconv" + "strings" + + "github.com/docker/docker/api/types" + "github.com/docker/docker/api/types/container" + "github.com/docker/docker/api/types/filters" + dockerclient "github.com/docker/docker/client" + "github.com/docker/go-connections/nat" + "github.com/google/uuid" +) + +type DockerRuntime struct { + client *dockerclient.Client + repo *repository.ServerRepository +} + +func NewDockerRuntime(client *dockerclient.Client, repo *repository.ServerRepository) *DockerRuntime { + return &DockerRuntime{ + client: client, + repo: repo, + } +} + +func (r *DockerRuntime) Create(ctx context.Context, server *model.Server) error { + image := env.GetACCImage() + // ACC uses the same port number for both TCP (client connections) and UDP (car positions/ping). + portStr := strconv.Itoa(server.Port) + + natTCP, err := nat.NewPort("tcp", portStr) + if err != nil { + return fmt.Errorf("invalid port %d: %v", server.Port, err) + } + natUDP, err := nat.NewPort("udp", portStr) + if err != nil { + return fmt.Errorf("invalid port %d: %v", server.Port, err) + } + + portBindings := nat.PortMap{ + natTCP: []nat.PortBinding{{HostPort: portStr}}, + natUDP: []nat.PortBinding{{HostPort: portStr}}, + } + exposedPorts := nat.PortSet{ + natTCP: struct{}{}, + natUDP: struct{}{}, + } + + containerBase := env.GetACCServersPath() + hostBase := env.GetACCServersHostPath() + serverSubPath := strings.TrimPrefix(server.GetServerPath(), containerBase) + hostServerPath := filepath.Join(hostBase, serverSubPath) + + binds := []string{ + fmt.Sprintf("%s:/acc/game:rw", hostServerPath), + } + + resp, err := r.client.ContainerCreate(ctx, &container.Config{ + Image: image, + ExposedPorts: exposedPorts, + Env: []string{ + fmt.Sprintf("SERVER_PORT=%d", server.Port), + }, + }, &container.HostConfig{ + Binds: binds, + PortBindings: portBindings, + RestartPolicy: container.RestartPolicy{Name: "unless-stopped"}, + // NET_RAW is required for tcpdump packet capture in debug mode (DEBUG_MODE=1). + CapAdd: []string{"NET_RAW"}, + }, nil, nil, server.ServiceName) + if err != nil { + return fmt.Errorf("failed to create container %s: %v", server.ServiceName, err) + } + + server.ContainerID = resp.ID + logging.Info("Created Docker container %s (ID: %s) for server %s", server.ServiceName, resp.ID[:12], server.ID) + return nil +} + +func (r *DockerRuntime) Delete(ctx context.Context, serverID uuid.UUID) error { + containerID, err := r.resolveContainerID(ctx, serverID) + if err != nil { + return err + } + if containerID == "" { + return nil + } + + err = r.client.ContainerRemove(ctx, containerID, types.ContainerRemoveOptions{ + Force: true, + RemoveVolumes: false, + }) + if err != nil { + return fmt.Errorf("failed to remove container %s: %v", containerID[:12], err) + } + logging.Info("Removed Docker container %s for server %s", containerID[:12], serverID) + return nil +} + +func (r *DockerRuntime) Start(ctx context.Context, serverID uuid.UUID) (string, error) { + server, err := r.repo.GetByID(ctx, serverID) + if err != nil { + return "", fmt.Errorf("failed to look up server %s: %v", serverID, err) + } + if server == nil { + return "", fmt.Errorf("server %s not found", serverID) + } + + containerID, err := r.ensureContainer(ctx, server) + if err != nil { + return "", err + } + + if err := r.client.ContainerStart(ctx, containerID, types.ContainerStartOptions{}); err != nil { + return "", fmt.Errorf("failed to start container: %v", err) + } + return model.StatusRunning.String(), nil +} + +func (r *DockerRuntime) Stop(ctx context.Context, serverID uuid.UUID) (string, error) { + containerID, err := r.resolveContainerID(ctx, serverID) + if err != nil { + return "", err + } + if err := r.client.ContainerStop(ctx, containerID, container.StopOptions{}); err != nil { + return "", fmt.Errorf("failed to stop container: %v", err) + } + return model.StatusStopped.String(), nil +} + +func (r *DockerRuntime) Restart(ctx context.Context, serverID uuid.UUID) (string, error) { + server, err := r.repo.GetByID(ctx, serverID) + if err != nil { + return "", fmt.Errorf("failed to look up server %s: %v", serverID, err) + } + if server == nil { + return "", fmt.Errorf("server %s not found", serverID) + } + + containerID, err := r.ensureContainer(ctx, server) + if err != nil { + return "", err + } + + if err := r.client.ContainerRestart(ctx, containerID, container.StopOptions{}); err != nil { + return "", fmt.Errorf("failed to restart container: %v", err) + } + return model.StatusRunning.String(), nil +} + +// ensureContainer returns the container ID for a server, recreating the container +// from the server's current DB record if it no longer exists in Docker. +func (r *DockerRuntime) ensureContainer(ctx context.Context, server *model.Server) (string, error) { + if server.ContainerID != "" { + if _, err := r.client.ContainerInspect(ctx, server.ContainerID); err == nil { + return server.ContainerID, nil + } + logging.Info("Container %s for server %s no longer exists, recreating", server.ContainerID[:min(12, len(server.ContainerID))], server.ID) + } + + if err := r.Create(ctx, server); err != nil { + return "", fmt.Errorf("failed to recreate container: %v", err) + } + if err := r.repo.Update(ctx, server); err != nil { + return "", fmt.Errorf("container recreated but failed to persist container ID: %v", err) + } + return server.ContainerID, nil +} + +func (r *DockerRuntime) Status(ctx context.Context, serverID uuid.UUID) (string, error) { + server, err := r.repo.GetByID(ctx, serverID) + if err != nil || server == nil { + return model.StatusUnknown.String(), err + } + + containerID := server.ContainerID + if containerID == "" { + containerID, err = r.findContainerByName(ctx, server.ServiceName) + if err != nil || containerID == "" { + return model.StatusStopped.String(), nil + } + } + + info, err := r.client.ContainerInspect(ctx, containerID) + if err != nil { + return model.StatusUnknown.String(), nil + } + + return dockerStateToStatus(info.State.Status), nil +} + +func (r *DockerRuntime) resolveContainerID(ctx context.Context, serverID uuid.UUID) (string, error) { + server, err := r.repo.GetByID(ctx, serverID) + if err != nil { + return "", fmt.Errorf("failed to look up server %s: %v", serverID, err) + } + if server == nil { + return "", fmt.Errorf("server %s not found", serverID) + } + return server.ContainerID, nil +} + +func (r *DockerRuntime) findContainerByName(ctx context.Context, name string) (string, error) { + containers, err := r.client.ContainerList(ctx, types.ContainerListOptions{ + All: true, + Filters: filters.NewArgs(filters.Arg("name", "/"+name)), + }) + if err != nil { + return "", err + } + if len(containers) == 0 { + return "", nil + } + return containers[0].ID, nil +} + +func dockerStateToStatus(state string) string { + switch state { + case "running": + return model.StatusRunning.String() + case "exited", "dead": + return model.StatusStopped.String() + case "restarting": + return model.StatusRestarting.String() + case "paused": + return model.StatusStopped.String() + default: + return model.StatusUnknown.String() + } +} diff --git a/local/service/file_log_streamer.go b/local/service/file_log_streamer.go new file mode 100644 index 0000000..7b0fa7e --- /dev/null +++ b/local/service/file_log_streamer.go @@ -0,0 +1,77 @@ +package service + +import ( + "acc-server-manager/local/model" + "acc-server-manager/local/utl/tracking" + "bufio" + "context" + "os" + "path/filepath" + "sync" + + "github.com/google/uuid" +) + +type FileLogStreamer struct { + tailers sync.Map // uuid.UUID → *tracking.LogTailer +} + +func NewFileLogStreamer() *FileLogStreamer { + return &FileLogStreamer{} +} + +func (s *FileLogStreamer) Start(_ context.Context, server *model.Server, handleLine func(string)) error { + if _, exists := s.tailers.Load(server.ID); exists { + return nil + } + + logPath := filepath.Join(server.GetLogPath(), "server.log") + tailer := tracking.NewLogTailer(logPath, handleLine) + s.tailers.Store(server.ID, tailer) + + go tailer.Start() + return nil +} + +func (s *FileLogStreamer) Stop(serverID uuid.UUID) { + if v, exists := s.tailers.LoadAndDelete(serverID); exists { + v.(*tracking.LogTailer).Stop() + } +} + +func (s *FileLogStreamer) GetLastLines(_ context.Context, server *model.Server, n int) ([]string, error) { + if n <= 0 { + return []string{}, nil + } + logPath := filepath.Join(server.GetLogPath(), "server.log") + f, err := os.Open(logPath) + if err != nil { + if os.IsNotExist(err) { + return []string{}, nil + } + return nil, err + } + defer f.Close() + + ring := make([]string, n) + head := 0 + count := 0 + scanner := bufio.NewScanner(f) + for scanner.Scan() { + ring[head] = scanner.Text() + head = (head + 1) % n + if count < n { + count++ + } + } + if err := scanner.Err(); err != nil { + return nil, err + } + if count < n { + return ring[:count], nil + } + out := make([]string, n) + copy(out, ring[head:]) + copy(out[n-head:], ring[:head]) + return out, nil +} diff --git a/local/service/firewall_service.go b/local/service/firewall_service.go old mode 100644 new mode 100755 diff --git a/local/service/leaderboard.go b/local/service/leaderboard.go old mode 100644 new mode 100755 index 64cdae0..c1407e0 --- a/local/service/leaderboard.go +++ b/local/service/leaderboard.go @@ -3,6 +3,7 @@ package service import ( "acc-server-manager/local/model" "acc-server-manager/local/repository" + "acc-server-manager/local/utl/logging" "context" "fmt" @@ -10,11 +11,12 @@ import ( ) type LeaderboardService struct { - repo *repository.LeaderboardRepository + repo *repository.LeaderboardRepository + activityLog *ActivityLogService } -func NewLeaderboardService(repo *repository.LeaderboardRepository) *LeaderboardService { - return &LeaderboardService{repo: repo} +func NewLeaderboardService(repo *repository.LeaderboardRepository, activityLog *ActivityLogService) *LeaderboardService { + return &LeaderboardService{repo: repo, activityLog: activityLog} } // Get returns the leaderboard for a server. @@ -23,7 +25,7 @@ func (s *LeaderboardService) Get(ctx context.Context, serverID uuid.UUID) (*mode } // Update replaces the leaderboard for a server. -func (s *LeaderboardService) Update(ctx context.Context, serverID uuid.UUID, lb *model.Leaderboard) (*model.Leaderboard, error) { +func (s *LeaderboardService) Update(ctx context.Context, serverID uuid.UUID, lb *model.Leaderboard, actorID, actorUsername string) (*model.Leaderboard, error) { if lb == nil { return nil, fmt.Errorf("input is required") } @@ -33,5 +35,25 @@ func (s *LeaderboardService) Update(ctx context.Context, serverID uuid.UUID, lb for i := range lb.Races { lb.Races[i].Position = i } - return s.repo.FullReplace(ctx, serverID, lb) + result, err := s.repo.FullReplace(ctx, serverID, lb) + if err != nil { + return nil, err + } + + driverCount := len(lb.Drivers) + raceCount := len(lb.Races) + details := fmt.Sprintf(`{"driver_count":%d,"race_count":%d}`, driverCount, raceCount) + go func() { + if logErr := s.activityLog.Log(context.Background(), &model.ActivityLog{ + ServerID: &serverID, + UserID: actorID, + Username: actorUsername, + Action: model.ActionLeaderboardUpdate, + Details: details, + }); logErr != nil { + logging.Error("Failed to log leaderboard update: %v", logErr) + } + }() + + return result, nil } diff --git a/local/service/lookup.go b/local/service/lookup.go old mode 100644 new mode 100755 diff --git a/local/service/membership.go b/local/service/membership.go old mode 100644 new mode 100755 diff --git a/local/service/noop_firewall.go b/local/service/noop_firewall.go new file mode 100644 index 0000000..e57e598 --- /dev/null +++ b/local/service/noop_firewall.go @@ -0,0 +1,7 @@ +package service + +type NoOpFirewall struct{} + +func (f *NoOpFirewall) CreateServerRules(_ string, _, _ []int) error { return nil } +func (f *NoOpFirewall) DeleteServerRules(_ string, _, _ []int) error { return nil } +func (f *NoOpFirewall) UpdateServerRules(_ string, _, _ []int) error { return nil } diff --git a/local/service/server.go b/local/service/server.go old mode 100644 new mode 100755 index 00adc45..659eaf9 --- a/local/service/server.go +++ b/local/service/server.go @@ -2,9 +2,11 @@ package service import ( "acc-server-manager/local/model" + "acc-server-manager/local/platform" "acc-server-manager/local/repository" "acc-server-manager/local/utl/env" "acc-server-manager/local/utl/logging" + "acc-server-manager/local/utl/network" "acc-server-manager/local/utl/tracking" "context" "fmt" @@ -12,8 +14,6 @@ import ( "sync" "time" - "acc-server-manager/local/utl/network" - "github.com/gofiber/fiber/v2" "github.com/google/uuid" ) @@ -26,17 +26,19 @@ const ( type ServerService struct { repository *repository.ServerRepository stateHistoryRepo *repository.StateHistoryRepository + activityLogRepo *repository.ActivityLogRepository apiService *ServiceControlService configService *ConfigService steamService *SteamService - windowsService *WindowsService - firewallService *FirewallService + runtime platform.ServerRuntime + firewall platform.FirewallManager + logStreamer platform.LogStreamer + portPool *network.PortPoolManager webSocketService *WebSocketService - instances sync.Map // Track instances per server - lastInsertTimes sync.Map // Track last insert time per server - debouncers sync.Map // Track debounce timers per server - logTailers sync.Map // Track log tailers per server - sessionIDs sync.Map // Track current session ID per server + instances sync.Map // uuid.UUID → *tracking.AccServerInstance + lastInsertTimes sync.Map // uuid.UUID → time.Time + debouncers sync.Map // uuid.UUID → *pendingState + sessionIDs sync.Map // uuid.UUID → uuid.UUID } type pendingState struct { @@ -45,37 +47,52 @@ type pendingState struct { } func (s *ServerService) ensureLogTailing(server *model.Server, instance *tracking.AccServerInstance) { - if _, exists := s.logTailers.Load(server.ID); exists { - return - } - go func() { - logPath := filepath.Join(server.GetLogPath(), "server.log") - tailer := tracking.NewLogTailer(logPath, instance.HandleLogLine) - s.logTailers.Store(server.ID, tailer) - - tailer.Start() + handleFn := func(line string) { + instance.HandleLogLine(line) + s.webSocketService.BroadcastLogLine(server.ID, line) + } + if err := s.logStreamer.Start(context.Background(), server, handleFn); err != nil { + logging.Error("Failed to start log streaming for server %s: %v", server.ID, err) + } }() } +func (s *ServerService) GetLastLogLines(ctx *fiber.Ctx, serverID uuid.UUID, n int) ([]string, error) { + server, err := s.repository.GetByID(ctx.UserContext(), serverID) + if err != nil { + return nil, err + } + if n <= 0 { + n = 100 + } + return s.logStreamer.GetLastLines(ctx.UserContext(), server, n) +} + func NewServerService( repository *repository.ServerRepository, stateHistoryRepo *repository.StateHistoryRepository, + activityLogRepo *repository.ActivityLogRepository, apiService *ServiceControlService, configService *ConfigService, steamService *SteamService, - windowsService *WindowsService, - firewallService *FirewallService, + runtime platform.ServerRuntime, + firewall platform.FirewallManager, + logStreamer platform.LogStreamer, + portPool *network.PortPoolManager, webSocketService *WebSocketService, ) *ServerService { service := &ServerService{ repository: repository, stateHistoryRepo: stateHistoryRepo, + activityLogRepo: activityLogRepo, apiService: apiService, configService: configService, steamService: steamService, - windowsService: windowsService, - firewallService: firewallService, + runtime: runtime, + firewall: firewall, + logStreamer: logStreamer, + portPool: portPool, webSocketService: webSocketService, } @@ -195,9 +212,19 @@ func (s *ServerService) updateSessionDuration(server *model.Server, sessionType } func (s *ServerService) GenerateServerPath(server *model.Server) { - steamCMDPath := env.GetSteamCMDDirPath() - server.Path = server.GenerateServerPath(steamCMDPath) - server.FromSteamCMD = true + server.GenerateUUID() + if server.ServiceName == "" { + server.ServiceName = server.GenerateServiceName() + } + + if env.IsDockerPlatform() { + server.Path = filepath.Join(env.GetACCServersPath(), server.ServiceName) + server.FromSteamCMD = true + } else { + steamCMDPath := env.GetSteamCMDDirPath() + server.Path = server.GenerateServerPath(steamCMDPath) + server.FromSteamCMD = true + } } func (s *ServerService) handleStateChange(server *model.Server, state *model.ServerState) { @@ -250,9 +277,6 @@ func (s *ServerService) StartAccServerRuntime(server *model.Server) { s.ensureLogTailing(server, instance) } -// context.Context: Application context -// Returns: -// string: Application version func (s *ServerService) GetAll(ctx *fiber.Ctx, filter *model.ServerFilter) (*[]model.Server, error) { servers, err := s.repository.GetAll(ctx.UserContext(), filter) if err != nil { @@ -260,6 +284,16 @@ func (s *ServerService) GetAll(ctx *fiber.Ctx, filter *model.ServerFilter) (*[]m return nil, err } + // Collect server IDs for a single last-activity lookup. + serverIDs := make([]uuid.UUID, len(*servers)) + for i, srv := range *servers { + serverIDs[i] = srv.ID + } + lastActivities, laErr := s.activityLogRepo.GetLastActivityByServerIDs(ctx.UserContext(), serverIDs) + if laErr != nil { + logging.Error("Failed to get last activities for server list: %v", laErr) + } + for i := range *servers { server := &(*servers)[i] status, err := s.apiService.GetCachedStatus(server.ServiceName) @@ -276,14 +310,16 @@ func (s *ServerService) GetAll(ctx *fiber.Ctx, filter *model.ServerFilter) (*[]m server.State = serverInstance.State } } + if lastActivities != nil { + if info, ok := lastActivities[server.ID]; ok { + server.LastActivity = info + } + } } return servers, nil } -// context.Context: Application context -// Returns: -// string: Application version func (as *ServerService) GetById(ctx *fiber.Ctx, serverID uuid.UUID) (*model.Server, error) { server, err := as.repository.GetByID(ctx.UserContext(), serverID) if err != nil { @@ -291,7 +327,7 @@ func (as *ServerService) GetById(ctx *fiber.Ctx, serverID uuid.UUID) (*model.Ser } status, err := as.apiService.GetCachedStatus(server.ServiceName) if err != nil { - logging.Error(err.Error()) + logging.Error("Failed to get cached status: %v", err) } server.Status = model.ParseServiceStatus(status) instance, ok := as.instances.Load(server.ID) @@ -377,17 +413,33 @@ func (s *ServerService) createServerBackground(ctx context.Context, server *mode important: true, description: "", callback: func() (string, error) { - ports, err := network.FindAvailablePortRange(DefaultStartPort, RequiredPortCount) - if err != nil { - return "", fmt.Errorf("failed to find available ports: %v", err) + var port int + var err error + + if env.IsDockerPlatform() { + port, err = s.portPool.Acquire(ctx) + if err != nil { + return "", fmt.Errorf("failed to acquire port from pool: %v", err) + } + } else { + ports, err := network.FindAvailablePortRange(DefaultStartPort, RequiredPortCount) + if err != nil { + return "", fmt.Errorf("failed to find available ports: %v", err) + } + port = ports[0] } - serverPort = ports[0] + serverPort = port + server.Port = port if err := s.updateServerPort(server, serverPort); err != nil { return "", fmt.Errorf("failed to update server configuration: %v", err) } + if err := s.configService.SetServerName(server, server.Name); err != nil { + logging.Error("Failed to set server name in config: %v", err) + } + return fmt.Sprintf("Server configuration generated (Port: %d)", serverPort), nil }, }, @@ -396,12 +448,11 @@ func (s *ServerService) createServerBackground(ctx context.Context, server *mode important: true, description: "", callback: func() (string, error) { - execPath := filepath.Join(server.GetServerPath(), "accServer.exe") - serverWorkingDir := filepath.Join(server.GetServerPath(), "server") - if err := s.windowsService.CreateService(ctx, server.ServiceName, execPath, serverWorkingDir, nil); err != nil { - return "", fmt.Errorf("failed to create Windows service: %v", err) + server.Platform = env.GetPlatform() + if err := s.runtime.Create(ctx, server); err != nil { + return "", fmt.Errorf("failed to create server instance: %v", err) } - return fmt.Sprintf("Windows service '%s' created successfully", server.ServiceName), nil + return fmt.Sprintf("Server instance '%s' created successfully", server.ServiceName), nil }, }, { @@ -409,13 +460,12 @@ func (s *ServerService) createServerBackground(ctx context.Context, server *mode important: false, description: "", callback: func() (string, error) { - s.configureFirewall(server) tcpPorts = []int{serverPort} udpPorts = []int{serverPort} - if err := s.firewallService.CreateServerRules(server.ServiceName, tcpPorts, udpPorts); err != nil { + if err := s.firewall.CreateServerRules(server.ServiceName, tcpPorts, udpPorts); err != nil { return "", fmt.Errorf("failed to create firewall rules: %v", err) } - return fmt.Sprintf("Firewall rules created for port %d", serverPort), nil + return fmt.Sprintf("Firewall rules configured for port %d", serverPort), nil }, }, { @@ -469,12 +519,18 @@ func (s *ServerService) rollbackSteps(ctx context.Context, server *model.Server, s.repository.Delete(ctx, server.ID) case model.StepFirewallRules: if len(tcpPorts) > 0 && len(udpPorts) > 0 { - s.firewallService.DeleteServerRules(server.ServiceName, tcpPorts, udpPorts) + s.firewall.DeleteServerRules(server.ServiceName, tcpPorts, udpPorts) } case model.StepServiceCreation: - s.windowsService.DeleteService(ctx, server.ServiceName) + s.runtime.Delete(ctx, server.ID) case model.StepSteamDownload: s.steamService.UninstallServer(server.Path) + case model.StepDirectoryCreation: + s.steamService.UninstallServer(server.Path) + case model.StepConfigGeneration: + if env.IsDockerPlatform() && server.Port > 0 { + s.portPool.Release(ctx, server.Port) + } } } } @@ -485,32 +541,35 @@ func (s *ServerService) DeleteServer(ctx *fiber.Ctx, serverID uuid.UUID) error { return fmt.Errorf("failed to get server details: %v", err) } - if err := s.windowsService.DeleteService(ctx.UserContext(), server.ServiceName); err != nil { - logging.Error("Failed to delete Windows service: %v", err) + if err := s.runtime.Delete(ctx.UserContext(), server.ID); err != nil { + logging.Error("Failed to delete server instance: %v", err) } configuration, err := s.configService.GetConfiguration(server) if err != nil { logging.Error("Failed to get configuration for server %d: %v", server.ID, err) } - tcpPorts := []int{configuration.TcpPort.ToInt()} - udpPorts := []int{configuration.UdpPort.ToInt()} - if err := s.firewallService.DeleteServerRules(server.ServiceName, tcpPorts, udpPorts); err != nil { - logging.Error("Failed to delete firewall rules: %v", err) + if configuration != nil { + tcpPorts := []int{configuration.TcpPort.ToInt()} + udpPorts := []int{configuration.UdpPort.ToInt()} + if err := s.firewall.DeleteServerRules(server.ServiceName, tcpPorts, udpPorts); err != nil { + logging.Error("Failed to delete firewall rules: %v", err) + } } if err := s.steamService.UninstallServer(server.Path); err != nil { logging.Error("Failed to uninstall server: %v", err) } + if env.IsDockerPlatform() && server.Port > 0 { + s.portPool.Release(ctx.UserContext(), server.Port) + } + if err := s.repository.Delete(ctx.UserContext(), serverID); err != nil { return fmt.Errorf("failed to delete server from database: %v", err) } - if tailer, exists := s.logTailers.Load(server.ID); exists { - tailer.(*tracking.LogTailer).Stop() - s.logTailers.Delete(server.ID) - } + s.logStreamer.Stop(server.ID) s.instances.Delete(server.ID) s.lastInsertTimes.Delete(server.ID) s.debouncers.Delete(server.ID) @@ -533,7 +592,7 @@ func (s *ServerService) configureFirewall(server *model.Server) error { logging.Info("Configuring firewall for server %d with port %d", server.ID, serverPort) - if err := s.firewallService.UpdateServerRules(server.Name, tcpPorts, udpPorts); err != nil { + if err := s.firewall.UpdateServerRules(server.Name, tcpPorts, udpPorts); err != nil { return fmt.Errorf("failed to configure firewall: %v", err) } @@ -552,10 +611,17 @@ func (s *ServerService) updateServerPort(server *model.Server, port int) error { config.TcpPort = model.IntString(port) config.UdpPort = model.IntString(port) + config.RegisterToLobby = model.IntString(1) if err := s.configService.SaveConfiguration(server, config); err != nil { return fmt.Errorf("failed to save server configuration: %v", err) } + if env.IsDockerPlatform() { + if err := s.configService.SetIgnorePrematureDisconnects(server, 0); err != nil { + logging.Error("Failed to set ignorePrematureDisconnects for Docker server: %v", err) + } + } + return nil } diff --git a/local/service/service.go b/local/service/service.go old mode 100644 new mode 100755 index 64f3e9e..43271b0 --- a/local/service/service.go +++ b/local/service/service.go @@ -1,39 +1,103 @@ package service import ( + "acc-server-manager/local/platform" "acc-server-manager/local/repository" + "acc-server-manager/local/utl/docker" + "acc-server-manager/local/utl/env" "acc-server-manager/local/utl/logging" + "acc-server-manager/local/utl/network" + "fmt" + dockerclient "github.com/docker/docker/client" "go.uber.org/dig" + "gorm.io/gorm" ) -// *dig.Container: Dig Container +// InitializeServices registers all service providers into the DI container. +// Platform-specific components (ServerRuntime, FirewallManager, LogStreamer) +// are conditionally provided based on the PLATFORM environment variable. func InitializeServices(c *dig.Container) { logging.Debug("Initializing repositories") repository.InitializeRepositories(c) logging.Debug("Registering services") c.Provide(NewSteamService) + c.Provide(NewActivityLogService) c.Provide(NewServerService) c.Provide(NewStateHistoryService) c.Provide(NewServiceControlService) c.Provide(NewConfigService) c.Provide(NewLookupService) - c.Provide(NewWindowsService) - c.Provide(NewFirewallService) c.Provide(NewMembershipService) c.Provide(NewWebSocketService) c.Provide(NewLeaderboardService) + c.Provide(NewConfigPresetService) + + // Always provide the PortPoolManager; in Windows mode it seeds from an + // empty result set and sits idle. + c.Provide(func(db *gorm.DB) (*network.PortPoolManager, error) { + return network.NewPortPoolManager(db) + }) + + if env.IsDockerPlatform() { + registerDockerProviders(c) + } else { + registerWindowsProviders(c) + } logging.Debug("Initializing service dependencies") err := c.Invoke(func(server *ServerService, api *ServiceControlService, config *ConfigService) { logging.Debug("Setting up service cross-references") api.SetServerService(server) config.SetServerService(server) - }) if err != nil { logging.Panic("unable to initialize services: " + err.Error()) } logging.Debug("Completed service initialization") } + +func registerWindowsProviders(c *dig.Container) { + logging.Debug("Registering Windows platform providers") + + c.Provide(NewWindowsService) + + c.Provide(func(ws *WindowsService, repo *repository.ServerRepository) platform.ServerRuntime { + return NewWindowsRuntime(ws, repo) + }) + + c.Provide(func() platform.FirewallManager { + return NewFirewallService() + }) + + c.Provide(func() platform.LogStreamer { + return NewFileLogStreamer() + }) +} + +func registerDockerProviders(c *dig.Container) { + logging.Debug("Registering Docker platform providers") + + if err := c.Provide(func() (*dockerclient.Client, error) { + client, err := docker.NewDockerClient() + if err != nil { + return nil, fmt.Errorf("failed to connect to Docker daemon: %v", err) + } + return client, nil + }); err != nil { + logging.Panic("failed to provide Docker client: " + err.Error()) + } + + c.Provide(func(client *dockerclient.Client, repo *repository.ServerRepository) platform.ServerRuntime { + return NewDockerRuntime(client, repo) + }) + + c.Provide(func() platform.FirewallManager { + return &NoOpFirewall{} + }) + + c.Provide(func(client *dockerclient.Client) platform.LogStreamer { + return NewDockerLogStreamer(client) + }) +} diff --git a/local/service/service_control.go b/local/service/service_control.go old mode 100644 new mode 100755 index b1794d5..06dc391 --- a/local/service/service_control.go +++ b/local/service/service_control.go @@ -2,6 +2,7 @@ package service import ( "acc-server-manager/local/model" + "acc-server-manager/local/platform" "acc-server-manager/local/repository" "context" "errors" @@ -14,24 +15,52 @@ type ServiceControlService struct { repository *repository.ServiceControlRepository serverRepository *repository.ServerRepository serverService *ServerService + activityLog *ActivityLogService statusCache *model.ServerStatusCache - windowsService *WindowsService + runtime platform.ServerRuntime } -func NewServiceControlService(repository *repository.ServiceControlRepository, - serverRepository *repository.ServerRepository) *ServiceControlService { +func NewServiceControlService( + repository *repository.ServiceControlRepository, + serverRepository *repository.ServerRepository, + runtime platform.ServerRuntime, + activityLog *ActivityLogService, +) *ServiceControlService { return &ServiceControlService{ repository: repository, serverRepository: serverRepository, + runtime: runtime, + activityLog: activityLog, statusCache: model.NewServerStatusCache(model.CacheConfig{ ExpirationTime: 30 * time.Second, ThrottleTime: 5 * time.Second, DefaultStatus: model.StatusRunning, }), - windowsService: NewWindowsService(), } } +// getServerFromCtx resolves the server from ctx locals (serverId or service name). +func (as *ServiceControlService) getServerFromCtx(ctx *fiber.Ctx) (*model.Server, error) { + serviceName, ok := ctx.Locals("service").(string) + if !ok || serviceName == "" { + serverId, ok2 := ctx.Locals("serverId").(string) + if !ok2 || serverId == "" { + return nil, errors.New("service name missing") + } + return as.serverRepository.GetByID(ctx.UserContext(), serverId) + } + return as.serverRepository.GetFirstByServiceName(ctx.UserContext(), serviceName) +} + +func extractActor(ctx *fiber.Ctx) (actorID, actorUsername string) { + actorID, _ = ctx.Locals("userID").(string) + actorUsername, _ = ctx.Locals("actorUsername").(string) + if actorUsername == "" { + actorUsername = "system" + } + return +} + func (as *ServiceControlService) SetServerService(serverService *ServerService) { as.serverService = serverService } @@ -57,10 +86,11 @@ func (as *ServiceControlService) GetStatus(ctx *fiber.Ctx) (string, error) { } func (as *ServiceControlService) ServiceControlStartServer(ctx *fiber.Ctx) (string, error) { - serviceName, err := as.GetServiceName(ctx) + server, err := as.getServerFromCtx(ctx) if err != nil { return "", err } + serviceName := server.ServiceName as.statusCache.UpdateStatus(serviceName, model.StatusStarting) @@ -75,14 +105,29 @@ func (as *ServiceControlService) ServiceControlStartServer(ctx *fiber.Ctx) (stri status := model.ParseServiceStatus(statusStr) as.statusCache.UpdateStatus(serviceName, status) + + if as.activityLog != nil { + actorID, actorUsername := extractActor(ctx) + go func() { + _ = as.activityLog.Log(context.Background(), &model.ActivityLog{ + ServerID: &server.ID, + UserID: actorID, + Username: actorUsername, + Action: model.ActionServerStart, + Details: `{"service":"` + serviceName + `"}`, + }) + }() + } + return status.String(), nil } func (as *ServiceControlService) ServiceControlStopServer(ctx *fiber.Ctx) (string, error) { - serviceName, err := as.GetServiceName(ctx) + server, err := as.getServerFromCtx(ctx) if err != nil { return "", err } + serviceName := server.ServiceName as.statusCache.UpdateStatus(serviceName, model.StatusStopping) @@ -97,14 +142,29 @@ func (as *ServiceControlService) ServiceControlStopServer(ctx *fiber.Ctx) (strin status := model.ParseServiceStatus(statusStr) as.statusCache.UpdateStatus(serviceName, status) + + if as.activityLog != nil { + actorID, actorUsername := extractActor(ctx) + go func() { + _ = as.activityLog.Log(context.Background(), &model.ActivityLog{ + ServerID: &server.ID, + UserID: actorID, + Username: actorUsername, + Action: model.ActionServerStop, + Details: `{"service":"` + serviceName + `"}`, + }) + }() + } + return status.String(), nil } func (as *ServiceControlService) ServiceControlRestartServer(ctx *fiber.Ctx) (string, error) { - serviceName, err := as.GetServiceName(ctx) + server, err := as.getServerFromCtx(ctx) if err != nil { return "", err } + serviceName := server.ServiceName as.statusCache.UpdateStatus(serviceName, model.StatusRestarting) @@ -119,11 +179,29 @@ func (as *ServiceControlService) ServiceControlRestartServer(ctx *fiber.Ctx) (st status := model.ParseServiceStatus(statusStr) as.statusCache.UpdateStatus(serviceName, status) + + if as.activityLog != nil { + actorID, actorUsername := extractActor(ctx) + go func() { + _ = as.activityLog.Log(context.Background(), &model.ActivityLog{ + ServerID: &server.ID, + UserID: actorID, + Username: actorUsername, + Action: model.ActionServerRestart, + Details: `{"service":"` + serviceName + `"}`, + }) + }() + } + return status.String(), nil } func (as *ServiceControlService) StatusServer(serviceName string) (string, error) { - return as.windowsService.Status(context.Background(), serviceName) + server, err := as.serverRepository.GetFirstByServiceName(context.Background(), serviceName) + if err != nil { + return "", err + } + return as.runtime.Status(context.Background(), server.ID) } func (as *ServiceControlService) GetCachedStatus(serviceName string) (string, error) { @@ -142,44 +220,46 @@ func (as *ServiceControlService) GetCachedStatus(serviceName string) (string, er } func (as *ServiceControlService) StartServer(serviceName string) (string, error) { - status, err := as.windowsService.Start(context.Background(), serviceName) - if err != nil { - return "", err - } - server, err := as.serverRepository.GetFirstByServiceName(context.Background(), serviceName) if err != nil { return "", err } + + status, err := as.runtime.Start(context.Background(), server.ID) + if err != nil { + return "", err + } + as.serverService.StartAccServerRuntime(server) return status, err } func (as *ServiceControlService) StopServer(serviceName string) (string, error) { - status, err := as.windowsService.Stop(context.Background(), serviceName) - if err != nil { - return "", err - } - server, err := as.serverRepository.GetFirstByServiceName(context.Background(), serviceName) if err != nil { return "", err } - as.serverService.instances.Delete(server.ID) + status, err := as.runtime.Stop(context.Background(), server.ID) + if err != nil { + return "", err + } + + as.serverService.instances.Delete(server.ID) return status, err } func (as *ServiceControlService) RestartServer(serviceName string) (string, error) { - status, err := as.windowsService.Restart(context.Background(), serviceName) - if err != nil { - return "", err - } - server, err := as.serverRepository.GetFirstByServiceName(context.Background(), serviceName) if err != nil { return "", err } + + status, err := as.runtime.Restart(context.Background(), server.ID) + if err != nil { + return "", err + } + as.serverService.StartAccServerRuntime(server) return status, err } diff --git a/local/service/service_manager.go b/local/service/service_manager.go old mode 100644 new mode 100755 diff --git a/local/service/state_history.go b/local/service/state_history.go old mode 100644 new mode 100755 diff --git a/local/service/steam_service.go b/local/service/steam_service.go old mode 100644 new mode 100755 index f6ef459..b14e113 --- a/local/service/steam_service.go +++ b/local/service/steam_service.go @@ -44,6 +44,14 @@ func NewSteamService(repository *repository.SteamCredentialsRepository, tfaManag } } +// getSteamCMDPath returns the platform-appropriate SteamCMD binary path. +func getSteamCMDPath() string { + if env.IsDockerPlatform() { + return env.GetLinuxSteamCMDPath() + } + return env.GetSteamCMDPath() +} + func (s *SteamService) GetCredentials(ctx context.Context) (*model.SteamCredentials, error) { return s.repository.GetCurrent(ctx) } @@ -56,6 +64,11 @@ func (s *SteamService) SaveCredentials(ctx context.Context, creds *model.SteamCr } func (s *SteamService) ensureSteamCMD(_ context.Context) error { + // In Docker mode, SteamCMD is pre-installed in the manager container image. + if env.IsDockerPlatform() { + return nil + } + steamCMDPath := env.GetSteamCMDPath() steamCMDDir := filepath.Dir(steamCMDPath) @@ -92,9 +105,11 @@ func (s *SteamService) InstallServerWithWebSocket(ctx context.Context, installPa return err } - if err := s.pathValidator.ValidateInstallPath(installPath); err != nil { - wsService.BroadcastSteamOutput(*serverID, fmt.Sprintf("Invalid installation path: %v", err), true) - return fmt.Errorf("invalid installation path: %v", err) + if !env.IsDockerPlatform() { + if err := s.pathValidator.ValidateInstallPath(installPath); err != nil { + wsService.BroadcastSteamOutput(*serverID, fmt.Sprintf("Invalid installation path: %v", err), true) + return fmt.Errorf("invalid installation path: %v", err) + } } absPath, err := filepath.Abs(installPath) @@ -117,7 +132,7 @@ func (s *SteamService) InstallServerWithWebSocket(ctx context.Context, installPa return fmt.Errorf("failed to get Steam credentials: %v", err) } - steamCMDPath := env.GetSteamCMDPath() + steamCMDPath := getSteamCMDPath() steamCMDArgs := []string{ "+force_install_dir", absPath, @@ -135,6 +150,10 @@ func (s *SteamService) InstallServerWithWebSocket(ctx context.Context, installPa steamCMDArgs = append(steamCMDArgs, "anonymous") } + if env.IsDockerPlatform() { + steamCMDArgs = append(steamCMDArgs, "+@sSteamCmdForcePlatformType", "windows") + } + steamCMDArgs = append(steamCMDArgs, "+app_update", ACCServerAppID, "validate", @@ -216,9 +235,11 @@ func (s *SteamService) InstallServerWithCallbacks(ctx context.Context, installPa return err } - if err := s.pathValidator.ValidateInstallPath(installPath); err != nil { - outputCallback(*serverID, fmt.Sprintf("Invalid installation path: %v", err), true) - return fmt.Errorf("invalid installation path: %v", err) + if !env.IsDockerPlatform() { + if err := s.pathValidator.ValidateInstallPath(installPath); err != nil { + outputCallback(*serverID, fmt.Sprintf("Invalid installation path: %v", err), true) + return fmt.Errorf("invalid installation path: %v", err) + } } absPath, err := filepath.Abs(installPath) @@ -241,7 +262,7 @@ func (s *SteamService) InstallServerWithCallbacks(ctx context.Context, installPa return fmt.Errorf("failed to get Steam credentials: %v", err) } - steamCMDPath := env.GetSteamCMDPath() + steamCMDPath := getSteamCMDPath() steamCMDArgs := []string{ "+force_install_dir", absPath, @@ -259,6 +280,10 @@ func (s *SteamService) InstallServerWithCallbacks(ctx context.Context, installPa steamCMDArgs = append(steamCMDArgs, "anonymous") } + if env.IsDockerPlatform() { + steamCMDArgs = append(steamCMDArgs, "+@sSteamCmdForcePlatformType", "windows") + } + steamCMDArgs = append(steamCMDArgs, "+app_update", ACCServerAppID, "validate", diff --git a/local/service/websocket.go b/local/service/websocket.go old mode 100644 new mode 100755 index 9ecacca..6394050 --- a/local/service/websocket.go +++ b/local/service/websocket.go @@ -15,6 +15,7 @@ type WebSocketConnection struct { conn *websocket.Conn serverID *uuid.UUID userID *uuid.UUID + mu sync.Mutex } type WebSocketService struct { @@ -102,6 +103,16 @@ func (ws *WebSocketService) BroadcastError(serverID uuid.UUID, error string, det ws.broadcastToServer(serverID, wsMsg) } +func (ws *WebSocketService) BroadcastLogLine(serverID uuid.UUID, line string) { + msg := model.WebSocketMessage{ + Type: model.MessageTypeLogLine, + ServerID: &serverID, + Timestamp: time.Now().Unix(), + Data: model.LogLineMessage{Line: line}, + } + ws.broadcastToServer(serverID, msg) +} + func (ws *WebSocketService) BroadcastComplete(serverID uuid.UUID, success bool, message string) { completeMsg := model.CompleteMessage{ ServerID: serverID, @@ -131,7 +142,7 @@ func (ws *WebSocketService) broadcastToServer(serverID uuid.UUID, message model. ws.connections.Range(func(key, value interface{}) bool { if wsConn, ok := value.(*WebSocketConnection); ok { if wsConn.serverID != nil && *wsConn.serverID == serverID { - if err := wsConn.conn.WriteMessage(websocket.TextMessage, data); err != nil { + if err := wsConn.writeMessage(websocket.TextMessage, data); err != nil { logging.Error("Failed to send WebSocket message to connection %s: %v", key, err) ws.RemoveConnection(key.(string)) } else { @@ -145,7 +156,7 @@ func (ws *WebSocketService) broadcastToServer(serverID uuid.UUID, message model. if !sentToAssociatedConnections && (message.Type == model.MessageTypeStep || message.Type == model.MessageTypeError || message.Type == model.MessageTypeComplete) { ws.connections.Range(func(key, value interface{}) bool { if wsConn, ok := value.(*WebSocketConnection); ok { - if err := wsConn.conn.WriteMessage(websocket.TextMessage, data); err != nil { + if err := wsConn.writeMessage(websocket.TextMessage, data); err != nil { logging.Error("Failed to send WebSocket message to connection %s: %v", key, err) ws.RemoveConnection(key.(string)) } @@ -165,7 +176,7 @@ func (ws *WebSocketService) BroadcastToUser(userID uuid.UUID, message model.WebS ws.connections.Range(func(key, value interface{}) bool { if wsConn, ok := value.(*WebSocketConnection); ok { if wsConn.userID != nil && *wsConn.userID == userID { - if err := wsConn.conn.WriteMessage(websocket.TextMessage, data); err != nil { + if err := wsConn.writeMessage(websocket.TextMessage, data); err != nil { logging.Error("Failed to send WebSocket message to connection %s: %v", key, err) ws.RemoveConnection(key.(string)) } @@ -175,6 +186,12 @@ func (ws *WebSocketService) BroadcastToUser(userID uuid.UUID, message model.WebS }) } +func (wc *WebSocketConnection) writeMessage(messageType int, data []byte) error { + wc.mu.Lock() + defer wc.mu.Unlock() + return wc.conn.WriteMessage(messageType, data) +} + func (ws *WebSocketService) GetActiveConnections() int { count := 0 ws.connections.Range(func(key, value interface{}) bool { diff --git a/local/service/windows_runtime.go b/local/service/windows_runtime.go new file mode 100644 index 0000000..9eb13c5 --- /dev/null +++ b/local/service/windows_runtime.go @@ -0,0 +1,68 @@ +package service + +import ( + "acc-server-manager/local/model" + "acc-server-manager/local/repository" + "context" + "path/filepath" + + "github.com/google/uuid" +) + +type WindowsRuntime struct { + windowsService *WindowsService + repo *repository.ServerRepository +} + +func NewWindowsRuntime(windowsService *WindowsService, repo *repository.ServerRepository) *WindowsRuntime { + return &WindowsRuntime{ + windowsService: windowsService, + repo: repo, + } +} + +func (r *WindowsRuntime) Create(ctx context.Context, server *model.Server) error { + execPath := filepath.Join(server.GetServerPath(), "accServer.exe") + serverWorkingDir := server.GetServerPath() + return r.windowsService.CreateService(ctx, server.ServiceName, execPath, serverWorkingDir, nil) +} + +func (r *WindowsRuntime) Delete(ctx context.Context, serverID uuid.UUID) error { + server, err := r.repo.GetByID(ctx, serverID) + if err != nil { + return err + } + return r.windowsService.DeleteService(ctx, server.ServiceName) +} + +func (r *WindowsRuntime) Start(ctx context.Context, serverID uuid.UUID) (string, error) { + server, err := r.repo.GetByID(ctx, serverID) + if err != nil { + return "", err + } + return r.windowsService.Start(ctx, server.ServiceName) +} + +func (r *WindowsRuntime) Stop(ctx context.Context, serverID uuid.UUID) (string, error) { + server, err := r.repo.GetByID(ctx, serverID) + if err != nil { + return "", err + } + return r.windowsService.Stop(ctx, server.ServiceName) +} + +func (r *WindowsRuntime) Restart(ctx context.Context, serverID uuid.UUID) (string, error) { + server, err := r.repo.GetByID(ctx, serverID) + if err != nil { + return "", err + } + return r.windowsService.Restart(ctx, server.ServiceName) +} + +func (r *WindowsRuntime) Status(ctx context.Context, serverID uuid.UUID) (string, error) { + server, err := r.repo.GetByID(ctx, serverID) + if err != nil { + return "", err + } + return r.windowsService.Status(ctx, server.ServiceName) +} diff --git a/local/service/windows_service.go b/local/service/windows_service.go old mode 100644 new mode 100755 diff --git a/local/utl/audit/audit.go b/local/utl/audit/audit.go old mode 100644 new mode 100755 diff --git a/local/utl/cache/cache.go b/local/utl/cache/cache.go old mode 100644 new mode 100755 diff --git a/local/utl/command/callback_executor.go b/local/utl/command/callback_executor.go old mode 100644 new mode 100755 index 2125317..6547810 --- a/local/utl/command/callback_executor.go +++ b/local/utl/command/callback_executor.go @@ -123,11 +123,12 @@ func (e *CallbackInteractiveCommandExecutor) monitorOutputWithCallbacks(ctx cont stdoutScanner := bufio.NewScanner(stdout) stderrScanner := bufio.NewScanner(stderr) - outputChan := make(chan outputLine, 100) + outputChan := make(chan outputLine, 10000) readersDone := make(chan struct{}, 2) steamConsoleStarted := false tfaRequestCreated := false + tfaAutoCompleted := false go func() { defer func() { readersDone <- struct{}{} }() @@ -182,13 +183,14 @@ func (e *CallbackInteractiveCommandExecutor) monitorOutputWithCallbacks(ctx cont if readersFinished == 2 { close(outputChan) for lineData := range outputChan { - if e.is2FAPrompt(lineData.text) { - if err := e.handle2FAPrompt(ctx, lineData.text, serverID); err != nil { - logging.Error("Failed to handle 2FA prompt: %v", err) - e.callbacks.OnOutput(e.serverID, fmt.Sprintf("Failed to handle 2FA prompt: %v", err), true) - done <- err - return - } + if e.is2FAPrompt(lineData.text) && !tfaRequestCreated { + tfaRequestCreated = true + go func(prompt string) { + if err := e.handle2FAPrompt(ctx, prompt, serverID); err != nil { + logging.Error("Failed to handle 2FA prompt: %v", err) + e.callbacks.OnOutput(e.serverID, fmt.Sprintf("Failed to handle 2FA prompt: %v", err), true) + } + }(lineData.text) } } return @@ -207,33 +209,34 @@ func (e *CallbackInteractiveCommandExecutor) monitorOutputWithCallbacks(ctx cont if e.is2FAPrompt(lineData.text) { if !tfaRequestCreated { - e.callbacks.OnOutput(e.serverID, "2FA prompt detected - waiting for user confirmation", false) - if err := e.handle2FAPrompt(ctx, lineData.text, serverID); err != nil { - logging.Error("Failed to handle 2FA prompt: %v", err) - e.callbacks.OnOutput(e.serverID, fmt.Sprintf("Failed to handle 2FA prompt: %v", err), true) - done <- err - return - } tfaRequestCreated = true + e.callbacks.OnOutput(e.serverID, "2FA prompt detected - waiting for user confirmation", false) + go func(prompt string) { + if err := e.handle2FAPrompt(ctx, prompt, serverID); err != nil { + logging.Error("Failed to handle 2FA prompt: %v", err) + e.callbacks.OnOutput(e.serverID, fmt.Sprintf("Failed to handle 2FA prompt: %v", err), true) + } + }(lineData.text) } } - if tfaRequestCreated && e.isSteamContinuing(lineData.text) { + if tfaRequestCreated && !tfaAutoCompleted && e.isSteamContinuing(lineData.text) { logging.Info("Steam CMD appears to have continued after 2FA confirmation") + tfaAutoCompleted = true e.callbacks.OnOutput(e.serverID, "Steam CMD continued after 2FA confirmation", false) e.autoCompletePendingRequests(serverID) } case <-time.After(15 * time.Second): if steamConsoleStarted && !tfaRequestCreated { + tfaRequestCreated = true logging.Info("Steam Console started but no output for 15 seconds - likely waiting for Steam Guard 2FA") e.callbacks.OnOutput(e.serverID, "Waiting for Steam Guard 2FA confirmation...", false) - if err := e.handle2FAPrompt(ctx, "Steam CMD appears to be waiting for Steam Guard confirmation after startup", serverID); err != nil { - logging.Error("Failed to handle Steam Guard 2FA prompt: %v", err) - e.callbacks.OnOutput(e.serverID, fmt.Sprintf("Failed to handle Steam Guard 2FA prompt: %v", err), true) - done <- err - return - } - tfaRequestCreated = true + go func() { + if err := e.handle2FAPrompt(ctx, "Steam CMD appears to be waiting for Steam Guard confirmation after startup", serverID); err != nil { + logging.Error("Failed to handle Steam Guard 2FA prompt: %v", err) + e.callbacks.OnOutput(e.serverID, fmt.Sprintf("Failed to handle Steam Guard 2FA prompt: %v", err), true) + } + }() } } } diff --git a/local/utl/command/callbacks.go b/local/utl/command/callbacks.go old mode 100644 new mode 100755 diff --git a/local/utl/command/executor.go b/local/utl/command/executor.go old mode 100644 new mode 100755 diff --git a/local/utl/command/interactive_executor.go b/local/utl/command/interactive_executor.go old mode 100644 new mode 100755 index 1e6ff47..f3bd369 --- a/local/utl/command/interactive_executor.go +++ b/local/utl/command/interactive_executor.go @@ -106,11 +106,12 @@ func (e *InteractiveCommandExecutor) monitorOutput(ctx context.Context, stdout, stdoutScanner := bufio.NewScanner(stdout) stderrScanner := bufio.NewScanner(stderr) - outputChan := make(chan string, 100) + outputChan := make(chan string, 10000) readersDone := make(chan struct{}, 2) steamConsoleStarted := false tfaRequestCreated := false + tfaAutoCompleted := false go func() { defer func() { readersDone <- struct{}{} }() @@ -165,12 +166,13 @@ func (e *InteractiveCommandExecutor) monitorOutput(ctx context.Context, stdout, if readersFinished == 2 { close(outputChan) for line := range outputChan { - if e.is2FAPrompt(line) { - if err := e.handle2FAPrompt(ctx, line, serverID); err != nil { - logging.Error("Failed to handle 2FA prompt: %v", err) - done <- err - return - } + if e.is2FAPrompt(line) && !tfaRequestCreated { + tfaRequestCreated = true + go func(prompt string) { + if err := e.handle2FAPrompt(ctx, prompt, serverID); err != nil { + logging.Error("Failed to handle 2FA prompt: %v", err) + } + }(line) } } return @@ -188,28 +190,29 @@ func (e *InteractiveCommandExecutor) monitorOutput(ctx context.Context, stdout, if e.is2FAPrompt(line) { if !tfaRequestCreated { - if err := e.handle2FAPrompt(ctx, line, serverID); err != nil { - logging.Error("Failed to handle 2FA prompt: %v", err) - done <- err - return - } tfaRequestCreated = true + go func(prompt string) { + if err := e.handle2FAPrompt(ctx, prompt, serverID); err != nil { + logging.Error("Failed to handle 2FA prompt: %v", err) + } + }(line) } } - if tfaRequestCreated && e.isSteamContinuing(line) { + if tfaRequestCreated && !tfaAutoCompleted && e.isSteamContinuing(line) { logging.Info("Steam CMD appears to have continued after 2FA confirmation - auto-completing 2FA request") + tfaAutoCompleted = true e.autoCompletePendingRequests(serverID) } case <-time.After(15 * time.Second): if steamConsoleStarted && !tfaRequestCreated { - logging.Info("Steam Console started but no output for 15 seconds - likely waiting for Steam Guard 2FA") - if err := e.handle2FAPrompt(ctx, "Steam CMD appears to be waiting for Steam Guard confirmation after startup", serverID); err != nil { - logging.Error("Failed to handle Steam Guard 2FA prompt: %v", err) - done <- err - return - } tfaRequestCreated = true + logging.Info("Steam Console started but no output for 15 seconds - likely waiting for Steam Guard 2FA") + go func() { + if err := e.handle2FAPrompt(ctx, "Steam CMD appears to be waiting for Steam Guard confirmation after startup", serverID); err != nil { + logging.Error("Failed to handle Steam Guard 2FA prompt: %v", err) + } + }() } else if !steamConsoleStarted { logging.Info("No output for 15 seconds (Steam Console not yet started)") } @@ -220,22 +223,15 @@ func (e *InteractiveCommandExecutor) monitorOutput(ctx context.Context, stdout, func (e *InteractiveCommandExecutor) is2FAPrompt(line string) bool { twoFAKeywords := []string{ "please enter your steam guard code", - "steam guard", - "two-factor", - "authentication code", + "steam guard code", + "two-factor authentication", "please check your steam mobile app", - "confirm in application", "enter the current code from your steam mobile app", "steam guard mobile authenticator", - "waiting for user info", - "login failure", "two factor code required", "enter steam guard code", "mobile authenticator code", - "authenticator app", - "guard code", - "mobile app", - "confirmation required", + "2fa prompt detected", } lowerLine := strings.ToLower(line) @@ -246,21 +242,6 @@ func (e *InteractiveCommandExecutor) is2FAPrompt(line string) bool { } } - waitingPatterns := []string{ - "waiting for", - "please enter", - "enter code", - "code:", - "authenticator:", - } - - for _, pattern := range waitingPatterns { - if strings.Contains(lowerLine, pattern) { - logging.Info("Potential 2FA waiting pattern found: '%s' in line: '%s'", pattern, line) - return true - } - } - return false } diff --git a/local/utl/common/common.go b/local/utl/common/common.go old mode 100644 new mode 100755 index 8e94bbe..47f963a --- a/local/utl/common/common.go +++ b/local/utl/common/common.go @@ -27,6 +27,11 @@ type RouteGroups struct { System fiber.Router WebSocket fiber.Router Leaderboard fiber.Router + Steam fiber.Router + ActivityLog fiber.Router + Groups fiber.Router + Log fiber.Router + Presets fiber.Router } func CheckError(err error) { diff --git a/local/utl/configs/configs.go b/local/utl/configs/configs.go old mode 100644 new mode 100755 index e4e011e..3b0bfaa --- a/local/utl/configs/configs.go +++ b/local/utl/configs/configs.go @@ -8,7 +8,7 @@ import ( ) var ( - Version = "0.12.3" + Version = "0.13.0" Prefix = "v1" Secret string SecretCode string diff --git a/local/utl/db/db.go b/local/utl/db/db.go old mode 100644 new mode 100755 index 55ca071..5a02000 --- a/local/utl/db/db.go +++ b/local/utl/db/db.go @@ -54,6 +54,8 @@ func Migrate(db *gorm.DB) { &model.LeaderboardRace{}, &model.LeaderboardResult{}, &model.LeaderboardPointRow{}, + &model.ActivityLog{}, + &model.ConfigPreset{}, ) if err != nil { @@ -76,6 +78,18 @@ func runMigrations(db *gorm.DB) { logging.Error("Failed to run password security migration: %v", err) } + if err := migrations.RunAddPlatformColumnsMigration(db); err != nil { + logging.Error("Failed to run platform columns migration: %v", err) + } + + if err := migrations.RunAddActivityLogMigration(db); err != nil { + logging.Error("Failed to run activity log migration: %v", err) + } + + if err := migrations.RunAddConfigPresetsMigration(db); err != nil { + logging.Error("Failed to run config presets migration: %v", err) + } + logging.Info("Custom database migrations completed") } diff --git a/local/utl/docker/client.go b/local/utl/docker/client.go new file mode 100644 index 0000000..87be22f --- /dev/null +++ b/local/utl/docker/client.go @@ -0,0 +1,12 @@ +package docker + +import ( + dockerclient "github.com/docker/docker/client" +) + +func NewDockerClient() (*dockerclient.Client, error) { + return dockerclient.NewClientWithOpts( + dockerclient.FromEnv, + dockerclient.WithAPIVersionNegotiation(), + ) +} diff --git a/local/utl/env/env.go b/local/utl/env/env.go old mode 100644 new mode 100755 index 0f41b84..a176d11 --- a/local/utl/env/env.go +++ b/local/utl/env/env.go @@ -6,10 +6,25 @@ import ( ) const ( - DefaultSteamCMDPath = "c:\\steamcmd\\steamcmd.exe" - DefaultNSSMPath = ".\\nssm.exe" + DefaultSteamCMDPath = "c:\\steamcmd\\steamcmd.exe" + DefaultNSSMPath = ".\\nssm.exe" + DefaultPlatform = "windows" + DefaultACCImage = "acc-wine:latest" + DefaultACCServersPath = "/data/servers" + DefaultLinuxSteamCMDPath = "/usr/games/steamcmd" ) +func GetPlatform() string { + if p := os.Getenv("PLATFORM"); p != "" { + return p + } + return DefaultPlatform +} + +func IsDockerPlatform() bool { + return GetPlatform() == "docker" +} + func GetSteamCMDPath() string { if path := os.Getenv("STEAMCMD_PATH"); path != "" { return path @@ -17,6 +32,13 @@ func GetSteamCMDPath() string { return DefaultSteamCMDPath } +func GetLinuxSteamCMDPath() string { + if path := os.Getenv("STEAMCMD_LINUX_PATH"); path != "" { + return path + } + return DefaultLinuxSteamCMDPath +} + func GetSteamCMDDirPath() string { steamCMDPath := GetSteamCMDPath() return filepath.Dir(steamCMDPath) @@ -29,17 +51,40 @@ func GetNSSMPath() string { return DefaultNSSMPath } +func GetACCImage() string { + if img := os.Getenv("ACC_IMAGE"); img != "" { + return img + } + return DefaultACCImage +} + +func GetACCServersPath() string { + if p := os.Getenv("ACC_SERVERS_PATH"); p != "" { + return p + } + return DefaultACCServersPath +} + +func GetACCServersHostPath() string { + if p := os.Getenv("ACC_SERVERS_HOST_PATH"); p != "" { + return p + } + return GetACCServersPath() +} + func ValidatePaths() map[string]error { errors := make(map[string]error) - steamCMDPath := GetSteamCMDPath() - if _, err := os.Stat(steamCMDPath); os.IsNotExist(err) { - errors["STEAMCMD_PATH"] = err - } + if !IsDockerPlatform() { + steamCMDPath := GetSteamCMDPath() + if _, err := os.Stat(steamCMDPath); os.IsNotExist(err) { + errors["STEAMCMD_PATH"] = err + } - nssmPath := GetNSSMPath() - if _, err := os.Stat(nssmPath); os.IsNotExist(err) { - errors["NSSM_PATH"] = err + nssmPath := GetNSSMPath() + if _, err := os.Stat(nssmPath); os.IsNotExist(err) { + errors["NSSM_PATH"] = err + } } return errors diff --git a/local/utl/error_handler/controller_error_handler.go b/local/utl/error_handler/controller_error_handler.go old mode 100644 new mode 100755 diff --git a/local/utl/errors/safe_error.go b/local/utl/errors/safe_error.go old mode 100644 new mode 100755 diff --git a/local/utl/graceful/shutdown.go b/local/utl/graceful/shutdown.go old mode 100644 new mode 100755 diff --git a/local/utl/jwt/jwt.go b/local/utl/jwt/jwt.go old mode 100644 new mode 100755 diff --git a/local/utl/logging/base.go b/local/utl/logging/base.go old mode 100644 new mode 100755 diff --git a/local/utl/logging/debug.go b/local/utl/logging/debug.go old mode 100644 new mode 100755 diff --git a/local/utl/logging/error.go b/local/utl/logging/error.go old mode 100644 new mode 100755 diff --git a/local/utl/logging/info.go b/local/utl/logging/info.go old mode 100644 new mode 100755 diff --git a/local/utl/logging/logger.go b/local/utl/logging/logger.go old mode 100644 new mode 100755 diff --git a/local/utl/logging/warn.go b/local/utl/logging/warn.go old mode 100644 new mode 100755 diff --git a/local/utl/network/port.go b/local/utl/network/port.go old mode 100644 new mode 100755 diff --git a/local/utl/network/port_pool.go b/local/utl/network/port_pool.go new file mode 100644 index 0000000..c43e58f --- /dev/null +++ b/local/utl/network/port_pool.go @@ -0,0 +1,72 @@ +package network + +import ( + "context" + "fmt" + "os" + "strconv" + "sync" + + "gorm.io/gorm" +) + +const ( + defaultPortPoolMin = 9600 + defaultPortPoolMax = 9700 +) + +type PortPoolManager struct { + mu sync.Mutex + min int + max int + inUse map[int]struct{} +} + +func NewPortPoolManager(db *gorm.DB) (*PortPoolManager, error) { + min := portEnvInt("PORT_POOL_MIN", defaultPortPoolMin) + max := portEnvInt("PORT_POOL_MAX", defaultPortPoolMax) + + var ports []int + if err := db.Raw("SELECT port FROM servers WHERE platform = 'docker' AND port > 0").Scan(&ports).Error; err != nil { + return nil, fmt.Errorf("failed to seed port pool from database: %v", err) + } + + inUse := make(map[int]struct{}, len(ports)) + for _, p := range ports { + inUse[p] = struct{}{} + } + + return &PortPoolManager{ + min: min, + max: max, + inUse: inUse, + }, nil +} + +func (p *PortPoolManager) Acquire(_ context.Context) (int, error) { + p.mu.Lock() + defer p.mu.Unlock() + + for port := p.min; port <= p.max; port++ { + if _, used := p.inUse[port]; !used { + p.inUse[port] = struct{}{} + return port, nil + } + } + return 0, fmt.Errorf("no available ports in pool [%d-%d]", p.min, p.max) +} + +func (p *PortPoolManager) Release(_ context.Context, port int) { + p.mu.Lock() + defer p.mu.Unlock() + delete(p.inUse, port) +} + +func portEnvInt(key string, defaultVal int) int { + if v := os.Getenv(key); v != "" { + if n, err := strconv.Atoi(v); err == nil { + return n + } + } + return defaultVal +} diff --git a/local/utl/password/password.go b/local/utl/password/password.go old mode 100644 new mode 100755 diff --git a/local/utl/regex_handler/regex_handler.go b/local/utl/regex_handler/regex_handler.go old mode 100644 new mode 100755 diff --git a/local/utl/security/download_verifier.go b/local/utl/security/download_verifier.go old mode 100644 new mode 100755 diff --git a/local/utl/security/path_validator.go b/local/utl/security/path_validator.go old mode 100644 new mode 100755 diff --git a/local/utl/server/server.go b/local/utl/server/server.go index 1571486..d5da435 100755 --- a/local/utl/server/server.go +++ b/local/utl/server/server.go @@ -44,7 +44,7 @@ func Start(di *dig.Container) *fiber.App { app.Use(cors.New(cors.Config{ AllowOrigins: allowedOrigin, - AllowHeaders: "Origin, Content-Type, Accept, Authorization, Access-Token", + AllowHeaders: "Origin, Content-Type, Accept, Authorization, Access-Key", AllowMethods: "GET, POST, PUT, DELETE, OPTIONS", AllowCredentials: true, MaxAge: 86400, // 24 hours diff --git a/local/utl/tracking/log_tailer.go b/local/utl/tracking/log_tailer.go old mode 100644 new mode 100755 diff --git a/local/utl/tracking/position_tracker.go b/local/utl/tracking/position_tracker.go old mode 100644 new mode 100755 diff --git a/local/utl/tracking/tracking.go b/local/utl/tracking/tracking.go old mode 100644 new mode 100755 diff --git a/local/utl/websocket/websocket.go b/local/utl/websocket/websocket.go old mode 100644 new mode 100755 diff --git a/scripts/README.md b/scripts/README.md old mode 100644 new mode 100755 diff --git a/scripts/build.ps1 b/scripts/build.ps1 old mode 100644 new mode 100755 diff --git a/scripts/deploy.bat b/scripts/deploy.bat old mode 100644 new mode 100755 diff --git a/scripts/deploy.config.example b/scripts/deploy.config.example old mode 100644 new mode 100755 diff --git a/scripts/deploy.ps1 b/scripts/deploy.ps1 old mode 100644 new mode 100755 diff --git a/scripts/generate-secrets.ps1 b/scripts/generate-secrets.ps1 old mode 100644 new mode 100755 diff --git a/scripts/generate-secrets.sh b/scripts/generate-secrets.sh old mode 100644 new mode 100755 diff --git a/swagger/docs.go b/swagger/docs.go old mode 100644 new mode 100755 diff --git a/swagger/swagger.json b/swagger/swagger.json old mode 100644 new mode 100755 diff --git a/swagger/swagger.yaml b/swagger/swagger.yaml old mode 100644 new mode 100755 diff --git a/tests/auth_helper.go b/tests/auth_helper.go old mode 100644 new mode 100755 diff --git a/tests/mocks/auth_middleware_mock.go b/tests/mocks/auth_middleware_mock.go old mode 100644 new mode 100755 diff --git a/tests/mocks/repository_mock.go b/tests/mocks/repository_mock.go old mode 100644 new mode 100755 diff --git a/tests/mocks/state_history_mock.go b/tests/mocks/state_history_mock.go old mode 100644 new mode 100755 diff --git a/tests/test_helper.go b/tests/test_helper.go old mode 100644 new mode 100755 index b6d0132..b029921 --- a/tests/test_helper.go +++ b/tests/test_helper.go @@ -109,7 +109,7 @@ func createTestData(t *testing.T, tempDir string) *TestData { configFiles := map[string]string{ "configuration.json": `{ "udpPort": "9231", - "tcpPort": "9232", + "tcpPort": "9231", "maxConnections": "30", "lanDiscovery": "1", "registerToLobby": "1", @@ -198,7 +198,7 @@ func createTestData(t *testing.T, tempDir string) *TestData { sampleConfig := &model.Configuration{ UdpPort: model.IntString(9231), - TcpPort: model.IntString(9232), + TcpPort: model.IntString(9231), MaxConnections: model.IntString(30), LanDiscovery: model.IntString(1), RegisterToLobby: model.IntString(1), diff --git a/tests/testdata/state_history_data.go b/tests/testdata/state_history_data.go old mode 100644 new mode 100755 diff --git a/tests/unit/controller/controller_simple_test.go b/tests/unit/controller/controller_simple_test.go old mode 100644 new mode 100755 index e01581c..cd14601 --- a/tests/unit/controller/controller_simple_test.go +++ b/tests/unit/controller/controller_simple_test.go @@ -294,7 +294,7 @@ func TestController_ConfigurationModel_JSONSerialization(t *testing.T) { app.Get("/config", func(c *fiber.Ctx) error { config := &model.Configuration{ UdpPort: model.IntString(9231), - TcpPort: model.IntString(9232), + TcpPort: model.IntString(9231), MaxConnections: model.IntString(30), LanDiscovery: model.IntString(1), RegisterToLobby: model.IntString(1), @@ -316,7 +316,7 @@ func TestController_ConfigurationModel_JSONSerialization(t *testing.T) { tests.AssertNoError(t, err) tests.AssertEqual(t, model.IntString(9231), response.UdpPort) - tests.AssertEqual(t, model.IntString(9232), response.TcpPort) + tests.AssertEqual(t, model.IntString(9231), response.TcpPort) tests.AssertEqual(t, model.IntString(30), response.MaxConnections) tests.AssertEqual(t, model.IntString(1), response.LanDiscovery) tests.AssertEqual(t, model.IntString(1), response.RegisterToLobby) diff --git a/tests/unit/controller/helper.go b/tests/unit/controller/helper.go old mode 100644 new mode 100755 diff --git a/tests/unit/controller/state_history_controller_test.go b/tests/unit/controller/state_history_controller_test.go old mode 100644 new mode 100755 diff --git a/tests/unit/repository/state_history_repository_test.go b/tests/unit/repository/state_history_repository_test.go old mode 100644 new mode 100755 diff --git a/tests/unit/service/auth_simple_test.go b/tests/unit/service/auth_simple_test.go old mode 100644 new mode 100755 diff --git a/tests/unit/service/cache_service_test.go b/tests/unit/service/cache_service_test.go old mode 100644 new mode 100755 index 95862af..2d69942 --- a/tests/unit/service/cache_service_test.go +++ b/tests/unit/service/cache_service_test.go @@ -464,7 +464,7 @@ func TestServerConfigCache_Configuration(t *testing.T) { serverID := uuid.New().String() configuration := model.Configuration{ UdpPort: model.IntString(9231), - TcpPort: model.IntString(9232), + TcpPort: model.IntString(9231), MaxConnections: model.IntString(30), LanDiscovery: model.IntString(1), RegisterToLobby: model.IntString(1), diff --git a/tests/unit/service/config_service_test.go b/tests/unit/service/config_service_test.go old mode 100644 new mode 100755 index 3d441e8..5169a0d --- a/tests/unit/service/config_service_test.go +++ b/tests/unit/service/config_service_test.go @@ -20,14 +20,14 @@ func TestConfigService_GetConfiguration_ValidFile(t *testing.T) { configRepo := repository.NewConfigRepository(helper.DB) serverRepo := repository.NewServerRepository(helper.DB) - configService := service.NewConfigService(configRepo, serverRepo) + configService := service.NewConfigService(configRepo, serverRepo, nil) config, err := configService.GetConfiguration(helper.TestData.Server) tests.AssertNoError(t, err) tests.AssertNotNil(t, config) tests.AssertEqual(t, model.IntString(9231), config.UdpPort) - tests.AssertEqual(t, model.IntString(9232), config.TcpPort) + tests.AssertEqual(t, model.IntString(9231), config.TcpPort) tests.AssertEqual(t, model.IntString(30), config.MaxConnections) tests.AssertEqual(t, model.IntString(1), config.LanDiscovery) tests.AssertEqual(t, model.IntString(1), config.RegisterToLobby) @@ -43,7 +43,7 @@ func TestConfigService_GetEventConfig_ValidFile(t *testing.T) { configRepo := repository.NewConfigRepository(helper.DB) serverRepo := repository.NewServerRepository(helper.DB) - configService := service.NewConfigService(configRepo, serverRepo) + configService := service.NewConfigService(configRepo, serverRepo, nil) eventConfig, err := configService.GetEventConfig(helper.TestData.Server) tests.AssertNoError(t, err) @@ -73,7 +73,7 @@ func TestConfigService_SaveConfiguration_Success(t *testing.T) { configRepo := repository.NewConfigRepository(helper.DB) serverRepo := repository.NewServerRepository(helper.DB) - configService := service.NewConfigService(configRepo, serverRepo) + configService := service.NewConfigService(configRepo, serverRepo, nil) newConfig := &model.Configuration{ UdpPort: model.IntString(9999), @@ -115,14 +115,14 @@ func TestConfigService_LoadConfigs_Success(t *testing.T) { configRepo := repository.NewConfigRepository(helper.DB) serverRepo := repository.NewServerRepository(helper.DB) - configService := service.NewConfigService(configRepo, serverRepo) + configService := service.NewConfigService(configRepo, serverRepo, nil) configs, err := configService.LoadConfigs(helper.TestData.Server) tests.AssertNoError(t, err) tests.AssertNotNil(t, configs) tests.AssertEqual(t, model.IntString(9231), configs.Configuration.UdpPort) - tests.AssertEqual(t, model.IntString(9232), configs.Configuration.TcpPort) + tests.AssertEqual(t, model.IntString(9231), configs.Configuration.TcpPort) tests.AssertEqual(t, "Test ACC Server", configs.Settings.ServerName) tests.AssertEqual(t, "admin123", configs.Settings.AdminPassword) tests.AssertEqual(t, "spa", configs.Event.Track) @@ -142,7 +142,7 @@ func TestConfigService_MalformedJSON(t *testing.T) { configRepo := repository.NewConfigRepository(helper.DB) serverRepo := repository.NewServerRepository(helper.DB) - configService := service.NewConfigService(configRepo, serverRepo) + configService := service.NewConfigService(configRepo, serverRepo, nil) config, err := configService.GetConfiguration(helper.TestData.Server) if err == nil { @@ -245,7 +245,7 @@ func TestConfigService_Caching_Configuration(t *testing.T) { configRepo := repository.NewConfigRepository(helper.DB) serverRepo := repository.NewServerRepository(helper.DB) - configService := service.NewConfigService(configRepo, serverRepo) + configService := service.NewConfigService(configRepo, serverRepo, nil) config1, err := configService.GetConfiguration(helper.TestData.Server) tests.AssertNoError(t, err) @@ -264,7 +264,7 @@ func TestConfigService_Caching_Configuration(t *testing.T) { tests.AssertNotNil(t, config2) tests.AssertEqual(t, model.IntString(9231), config2.UdpPort) - tests.AssertEqual(t, model.IntString(9232), config2.TcpPort) + tests.AssertEqual(t, model.IntString(9231), config2.TcpPort) } func TestConfigService_Caching_EventConfig(t *testing.T) { @@ -276,7 +276,7 @@ func TestConfigService_Caching_EventConfig(t *testing.T) { configRepo := repository.NewConfigRepository(helper.DB) serverRepo := repository.NewServerRepository(helper.DB) - configService := service.NewConfigService(configRepo, serverRepo) + configService := service.NewConfigService(configRepo, serverRepo, nil) event1, err := configService.GetEventConfig(helper.TestData.Server) tests.AssertNoError(t, err) diff --git a/tests/unit/service/state_history_service_test.go b/tests/unit/service/state_history_service_test.go old mode 100644 new mode 100755