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..784273d --- /dev/null +++ b/docker/acc-server/entrypoint.sh @@ -0,0 +1,78 @@ +#!/bin/bash +set -e + +DISPLAY_NUM=$((SERVER_PORT % 1000 + 100)) +export DISPLAY=:${DISPLAY_NUM} + +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=$! + +TCPDUMP_PID="" + +cleanup() { + echo "Stopping ACC server..." + [ -n "${TCPDUMP_PID}" ] && kill "${TCPDUMP_PID}" 2>/dev/null || true + kill "${XVFB_PID}" 2>/dev/null || true +} +trap cleanup SIGTERM SIGINT EXIT + +# Initialize Wine prefix (no-op if already done) +wineboot -u 2>/dev/null + +mkdir -p /acc/game/log + +# ─── Debug mode ─────────────────────────────────────────────────────────────── +# Set DEBUG_MODE=1 on the container to enable extra diagnostics: +# +# docker run -e DEBUG_MODE=1 ... +# or in docker-compose: +# environment: +# DEBUG_MODE: "1" +# +# Produces: +# /acc/game/log/tcpdump_.pcap — raw packet capture (open in Wireshark) +# /acc/game/log/wine_debug.log — Wine debug output +# Stderr: Wine version, WINEDEBUG channel, prefix state +# +# You can also override which Wine debug channels are enabled: +# WINEDEBUG_CHANNELS="+loaddll,+seh,+relay" (relay is very verbose) +# ───────────────────────────────────────────────────────────────────────────── +if [ "${DEBUG_MODE:-0}" = "1" ]; then + echo "[debug] Debug mode enabled for port ${SERVER_PORT}" + echo "[debug] Wine: $(wine --version 2>/dev/null || echo unknown)" + echo "[debug] WINEPREFIX: ${WINEPREFIX}" + + # Configure Wine debug channels (default: loader + exception + module info) + export WINEDEBUG="${WINEDEBUG_CHANNELS:-+loaddll,+module,+seh,+ntdll}" + echo "[debug] WINEDEBUG=${WINEDEBUG}" + + # Start packet capture on the server port — captures both TCP and UDP. + # The pcap file records every byte Kunos sends/receives so you can inspect + # the 2-byte "pakSizeMax" packet and anything that follows. + # + # NOTE: requires CAP_NET_RAW capability on the container: + # docker-compose: cap_add: [NET_RAW] + # docker run: --cap-add=NET_RAW + # Without it tcpdump will be skipped (no crash, just no capture). + CAPTURE_FILE="/acc/game/log/tcpdump_${SERVER_PORT}.pcap" + echo "[debug] Starting packet capture → ${CAPTURE_FILE}" + echo "[debug] (requires cap_add: [NET_RAW] in docker-compose — will exit silently if missing)" + tcpdump -i any -nn -s 0 -w "${CAPTURE_FILE}" \ + "port ${SERVER_PORT}" 2>/acc/game/log/tcpdump.err & + TCPDUMP_PID=$! + + # Redirect Wine debug output to a log file (Wine writes to stderr). + WINE_LOG="/acc/game/log/wine_debug.log" + echo "[debug] Wine debug log → ${WINE_LOG}" + echo "Starting ACC server on port ${SERVER_PORT} (DEBUG)..." + cd /acc/game + exec wine /acc/game/accServer.exe 2>"${WINE_LOG}" +else + # Production: suppress all Wine debug output. + export WINEDEBUG=-all + echo "Starting ACC server on port ${SERVER_PORT}..." + cd /acc/game + exec wine /acc/game/accServer.exe +fi 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..0f3f775 --- a/local/api/api.go +++ b/local/api/api.go @@ -33,6 +33,7 @@ func Init(di *dig.Container, app *fiber.App) { System: groups.Group("/system"), WebSocket: groups.Group("/ws"), Leaderboard: serverIdGroup.Group("/leaderboard"), + Steam: groups.Group("/steam"), } accessKeyMiddleware := middleware.NewAccessKeyMiddleware() diff --git a/local/controller/config.go b/local/controller/config.go old mode 100644 new mode 100755 diff --git a/local/controller/controller.go b/local/controller/controller.go old mode 100644 new mode 100755 index 96c5692..79af86b --- a/local/controller/controller.go +++ b/local/controller/controller.go @@ -64,4 +64,9 @@ 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") + } } diff --git a/local/controller/leaderboard.go b/local/controller/leaderboard.go old mode 100644 new mode 100755 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 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..7abc5fb --- a/local/middleware/auth.go +++ b/local/middleware/auth.go @@ -178,6 +178,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/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/filter.go b/local/model/filter.go old mode 100644 new mode 100755 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 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..4693d6c --- a/local/model/server.go +++ b/local/model/server.go @@ -42,6 +42,8 @@ type Server struct { 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"` DateCreated time.Time `json:"dateCreated"` FromSteamCMD bool `gorm:"not null; default:true" json:"-"` 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 diff --git a/local/platform/interfaces.go b/local/platform/interfaces.go new file mode 100644 index 0000000..47de4ed --- /dev/null +++ b/local/platform/interfaces.go @@ -0,0 +1,28 @@ +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) +} 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/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 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/config.go b/local/service/config.go old mode 100644 new mode 100755 index b632854..4b9c8ea --- a/local/service/config.go +++ b/local/service/config.go @@ -476,6 +476,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) diff --git a/local/service/docker_log_streamer.go b/local/service/docker_log_streamer.go new file mode 100644 index 0000000..e84ad40 --- /dev/null +++ b/local/service/docker_log_streamer.go @@ -0,0 +1,74 @@ +package service + +import ( + "acc-server-manager/local/model" + "acc-server-manager/local/utl/logging" + "bufio" + "context" + "io" + "sync" + + "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 { + if _, exists := s.cancels.Load(server.ID); exists { + return nil + } + if server.ContainerID == "" { + logging.Warn("DockerLogStreamer.Start: no container ID for server %s, skipping", server.ID) + return nil + } + + 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, + }) + 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)() + } +} 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..62bd3e6 --- /dev/null +++ b/local/service/file_log_streamer.go @@ -0,0 +1,38 @@ +package service + +import ( + "acc-server-manager/local/model" + "acc-server-manager/local/utl/tracking" + "context" + "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() + } +} 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 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..84e8325 --- 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" ) @@ -29,14 +29,15 @@ type ServerService struct { 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,16 +46,10 @@ 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() + if err := s.logStreamer.Start(context.Background(), server, instance.HandleLogLine); err != nil { + logging.Error("Failed to start log streaming for server %s: %v", server.ID, err) + } }() } @@ -64,8 +59,10 @@ func NewServerService( 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{ @@ -74,8 +71,10 @@ func NewServerService( apiService: apiService, configService: configService, steamService: steamService, - windowsService: windowsService, - firewallService: firewallService, + runtime: runtime, + firewall: firewall, + logStreamer: logStreamer, + portPool: portPool, webSocketService: webSocketService, } @@ -195,9 +194,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 +259,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 { @@ -281,9 +287,6 @@ func (s *ServerService) GetAll(ctx *fiber.Ctx, filter *model.ServerFilter) (*[]m 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 +294,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,12 +380,24 @@ 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) @@ -396,12 +411,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 +423,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 +482,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 +504,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 +555,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 +574,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..d95639f --- a/local/service/service.go +++ b/local/service/service.go @@ -1,13 +1,22 @@ 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) @@ -19,21 +28,74 @@ func InitializeServices(c *dig.Container) { c.Provide(NewServiceControlService) c.Provide(NewConfigService) c.Provide(NewLookupService) - c.Provide(NewWindowsService) - c.Provide(NewFirewallService) c.Provide(NewMembershipService) c.Provide(NewWebSocketService) c.Provide(NewLeaderboardService) + // 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..7641357 --- 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" @@ -15,20 +16,23 @@ type ServiceControlService struct { serverRepository *repository.ServerRepository serverService *ServerService 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, +) *ServiceControlService { return &ServiceControlService{ repository: repository, serverRepository: serverRepository, + runtime: runtime, statusCache: model.NewServerStatusCache(model.CacheConfig{ ExpirationTime: 30 * time.Second, ThrottleTime: 5 * time.Second, DefaultStatus: model.StatusRunning, }), - windowsService: NewWindowsService(), } } @@ -123,7 +127,11 @@ func (as *ServiceControlService) ServiceControlRestartServer(ctx *fiber.Ctx) (st } 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 +150,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..1f2cbd2 --- 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 { @@ -131,7 +132,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 +146,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 +166,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 +176,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..0ad0917 --- a/local/utl/common/common.go +++ b/local/utl/common/common.go @@ -27,6 +27,7 @@ type RouteGroups struct { System fiber.Router WebSocket fiber.Router Leaderboard fiber.Router + Steam 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 diff --git a/local/utl/db/db.go b/local/utl/db/db.go old mode 100644 new mode 100755 index 55ca071..0500181 --- a/local/utl/db/db.go +++ b/local/utl/db/db.go @@ -76,6 +76,10 @@ 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) + } + 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..ecc3d41 --- a/tests/unit/service/config_service_test.go +++ b/tests/unit/service/config_service_test.go @@ -27,7 +27,7 @@ func TestConfigService_GetConfiguration_ValidFile(t *testing.T) { 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) @@ -122,7 +122,7 @@ func TestConfigService_LoadConfigs_Success(t *testing.T) { 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) @@ -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) { 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