Compare commits

...

14 Commits

Author SHA1 Message Date
14aa8a84e5 feat: add logging to existing services 2026-03-22 13:54:43 +01:00
e96b8bca12 feat: add activity logs 2026-03-22 13:52:15 +01:00
85c5874bc7 update container port mapping 2026-03-22 13:23:35 +01:00
4441524447 fix acc server container restarting 2026-03-22 13:23:35 +01:00
c03234d339 update server app path to host's 2026-03-22 13:23:35 +01:00
d4b16d80f3 resolve server creation issues 2026-03-22 13:23:35 +01:00
9172f0256f add docker specifics 2026-03-22 13:23:35 +01:00
beb9b479df add platform interface according to env 2026-03-22 13:23:35 +01:00
4aa67db3fd add docker specific columns to server table 2026-03-22 13:23:35 +01:00
747adc77a5 version bump
Some checks failed
Release and Deploy / deploy (push) Has been skipped
Release and Deploy / build (push) Failing after 6m7s
2026-03-22 12:24:54 +01:00
c9a32e8889 feat: batch update config endpoint 2026-03-22 12:16:40 +01:00
7d470f3638 update headers 2026-03-05 21:50:05 +01:00
cf7473b373 update models to use uuid referencing
Some checks failed
Release and Deploy / build (push) Failing after 6m58s
Release and Deploy / deploy (push) Has been skipped
2026-03-01 16:46:06 +01:00
35c53cc54c public endpoint for leaderboard 2026-03-01 14:35:02 +01:00
150 changed files with 2025 additions and 442 deletions

29
.env.example Normal file → Executable file
View File

@@ -50,6 +50,35 @@ PASSWORD=change-this-default-admin-password
# 4. NEVER commit actual secrets to version control! # 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/<service-name>/
# 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) # OPTIONAL SETTINGS (These are handled by system config in database)
# ============================================================================= # =============================================================================

0
.gitea/workflows/release.yml Normal file → Executable file
View File

0
.gitignore vendored Normal file → Executable file
View File

63
Dockerfile Normal file
View File

@@ -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"]

0
README.md Normal file → Executable file
View File

0
cmd/api/main.go Normal file → Executable file
View File

0
cmd/api/swagger.go Normal file → Executable file
View File

0
cmd/migrate/main.go Normal file → Executable file
View File

0
cmd/steam-crypt/main.go Normal file → Executable file
View File

34
docker-compose.yml Normal file
View File

@@ -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

View File

@@ -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"]

View File

@@ -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_<port>.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

0
docs/API.md Normal file → Executable file
View File

0
docs/CONFIG.md Normal file → Executable file
View File

0
docs/DEPLOYMENT.md Normal file → Executable file
View File

0
docs/MIGRATION.md Normal file → Executable file
View File

0
docs/SETUP.md Normal file → Executable file
View File

0
docs/STEAM_2FA_IMPLEMENTATION.md Normal file → Executable file
View File

0
docs/SWAGGER.md Normal file → Executable file
View File

0
docs/TROUBLESHOOTING.md Normal file → Executable file
View File

24
go.mod Normal file → Executable file
View File

@@ -1,15 +1,19 @@
module acc-server-manager module acc-server-manager
go 1.23.0 go 1.25.0
require ( 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/fiber/v2 v2.52.8
github.com/gofiber/swagger v1.1.0 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/golang-jwt/jwt/v4 v4.5.2
github.com/google/uuid v1.6.0 github.com/google/uuid v1.6.0
github.com/joho/godotenv v1.5.1 github.com/joho/godotenv v1.5.1
github.com/qjebbs/go-jsons v0.0.0-20221222033332-a534c5fc1c4c github.com/qjebbs/go-jsons v0.0.0-20221222033332-a534c5fc1c4c
github.com/swaggo/swag v1.16.3 github.com/swaggo/swag v1.16.3
github.com/valyala/fasthttp v1.51.0
go.uber.org/dig v1.17.1 go.uber.org/dig v1.17.1
golang.org/x/crypto v0.39.0 golang.org/x/crypto v0.39.0
golang.org/x/sync v0.15.0 golang.org/x/sync v0.15.0
@@ -20,13 +24,17 @@ require (
require ( require (
github.com/KyleBanks/depth v1.2.1 // indirect 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/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/fasthttp/websocket v1.5.3 // indirect
github.com/go-openapi/jsonpointer v0.21.0 // indirect github.com/go-openapi/jsonpointer v0.21.0 // indirect
github.com/go-openapi/jsonreference 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/spec v0.21.0 // indirect
github.com/go-openapi/swag v0.23.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/inflection v1.0.0 // indirect
github.com/jinzhu/now v1.1.5 // indirect github.com/jinzhu/now v1.1.5 // indirect
github.com/josharian/intern v1.0.0 // 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-isatty v0.0.20 // indirect
github.com/mattn/go-runewidth v0.0.16 // indirect github.com/mattn/go-runewidth v0.0.16 // indirect
github.com/mattn/go-sqlite3 v1.14.22 // 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/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/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/swaggo/files/v2 v2.0.0 // indirect
github.com/valyala/bytebufferpool v1.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 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 golang.org/x/tools v0.33.0 // indirect
gopkg.in/yaml.v3 v3.0.1 // indirect gopkg.in/yaml.v3 v3.0.1 // indirect
gotest.tools/v3 v3.5.2 // indirect
) )

76
go.sum Normal file → Executable file
View File

@@ -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 h1:5h8fQADFrWtarTdtDudMmGsC7GPbOAu6RVB3ffsVFHc=
github.com/KyleBanks/depth v1.2.1/go.mod h1:jzSb9d0L43HxTQfT+oSA1EEp2q+ne2uh6XgeJcm8brE= 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 h1:eLKJA0d02Lf0mVpIDgYnqXcUn0GqVmEFny3VuID1U3M=
github.com/andybalholm/brotli v1.1.0/go.mod h1:sms7XGricyQI9K10gOSf56VKKWS4oLer58Q+mhRPtnY= 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 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c=
github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= 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 h1:TPpQuLwJYfd4LJPXvHDYPMFWbLjsT91n3GpWtCQtdek=
github.com/fasthttp/websocket v1.5.3/go.mod h1:46gg/UBmTU1kUaTcwQXpUxtRwG2PvIZYeA8oL6vF3Fs= github.com/fasthttp/websocket v1.5.3/go.mod h1:46gg/UBmTU1kUaTcwQXpUxtRwG2PvIZYeA8oL6vF3Fs=
github.com/go-openapi/jsonpointer v0.21.0 h1:YgdVicSA9vH5RiHs9TZW5oyafXZFc6+2Vc1rr/O9oNQ= 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/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 h1:C9cjxvloojayOp9AovmpQrk8VqvVnT8Oao3+IUygH7w=
github.com/gofiber/websocket/v2 v2.2.1/go.mod h1:Ao/+nyNnX5u/hIFPuHl28a+NIkrqK7PRimyKaj4JxVU= 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 h1:YtQM7lnr8iZ+j5q71MGKkNw9Mn7AjHM68uc9g5fXeUI=
github.com/golang-jwt/jwt/v4 v4.5.2/go.mod h1:m21LjoU+eqJr34lmDMbreY2eSTRJ1cv77w39/MY0Ch0= 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 h1:NIvaJDMOsjHA8n1jAhLSgzrAzy1Hgr+hNrb57e+94F0=
github.com/google/uuid v1.6.0/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo= 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= 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/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 h1:vlS4z54oSdjm0bgjRigI+G1HpF+tI+9rE5LLzOg8HmY=
github.com/josharian/intern v1.0.0/go.mod h1:5DoeVV0s6jJacbCEi61lwdGj/aVlrQvzHFFd8Hwg//Y= 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 h1:6KIumPrER1LHsvBVuDa0r5xaG0Es51mhhB9BQB2qeMA=
github.com/klauspost/compress v1.17.9/go.mod h1:Di0epgTjJY877eYKx5yC51cX2A2Vl2ibi7bDH9ttBbw= github.com/klauspost/compress v1.17.9/go.mod h1:Di0epgTjJY877eYKx5yC51cX2A2Vl2ibi7bDH9ttBbw=
github.com/kr/pretty v0.3.1 h1:flRD4NNwYAUpkphVc1HcthR4KEIFJ65n8Mw5qdRn3LE= 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-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 h1:2gZY6PC6kBnID23Tichd1K+Z0oS6nE/XwU+Vz/5o4kU=
github.com/mattn/go-sqlite3 v1.14.22/go.mod h1:Uh1q+B4BYcTPb+yiD3kU8Ct7aC0hY9fxUwlHK0RXw+Y= 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 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM=
github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4= 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 h1:kmzxiX+OB0knCo1V0dkEkdPelzCdAzCURCfmFArn2/A=
github.com/qjebbs/go-jsons v0.0.0-20221222033332-a534c5fc1c4c/go.mod h1:wNJrtinHyC3YSf6giEh4FJN8+yZV7nXBjvmfjhBIcw4= 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 h1:S1pD9weZBuJdFmowNwbpi7BJ8TNftyUImj/0WQi72jY=
github.com/rivo/uniseg v0.2.0/go.mod h1:J6wj4VEh+S6ZtnVlnTBMWIodfgj8LQOQFoIToxlJtxc= 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.14.1 h1:UQB4HGPB6osV0SQTLymcB4TgvyWu6ZyliaW0tI/otEQ=
github.com/rogpeppe/go-internal v1.11.0/go.mod h1:ddIwULY96R17DhadqLgMfk9H9tvdUzkipdSkR5nkCZA= 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 h1:8Iv5m6xEo1NR1AvpV+7XmhI4r39LGNzwUL4YpMuL5vk=
github.com/savsgio/gotils v0.0.0-20230208104028-c358bd845dee/go.mod h1:qwtSXrKuJh/zsFQ12yEE89xfCrGKK63Rr7ctU/uCo4g= 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/sirupsen/logrus v1.7.0 h1:ShrD1U9pZB12TX0cVy0DtePoCH97K8EtX+mg7ZARUtM=
github.com/stretchr/testify v1.9.0/go.mod h1:r2ic/lqez/lEtzL7wO/rwa5dbSLXVDPFyf8C91i36aY= 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 h1:hmAt8Dkynw7Ssz46F6pn8ok6YmGZqHSVLZ+HQM7i0kw=
github.com/swaggo/files/v2 v2.0.0/go.mod h1:24kk2Y9NYEJ5lHuCra6iVwkMjIekMCaFq/0JQj66kyM= github.com/swaggo/files/v2 v2.0.0/go.mod h1:24kk2Y9NYEJ5lHuCra6iVwkMjIekMCaFq/0JQj66kyM=
github.com/swaggo/swag v1.16.3 h1:PnCYjPCah8FK4I26l2F/KQ4yz3sILcVUN3cTlBFA9Pg= 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/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 h1:rBHj/Xf+E1tRGZyWIWwJDiRY0zc1Js+CV5DqwacVSA8=
github.com/valyala/tcplisten v1.0.0/go.mod h1:T0xQ8SeCZGxckz9qRXTfG43PvQ/mcWh7FwZEA7Ioqkc= 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 h1:Tga8Lz8PcYNsWsyHMZ1Vm0OQOUaJNDyvPImgbAu9YSc=
go.uber.org/dig v1.17.1/go.mod h1:Us0rSJiThwCv2GteUN0Q7OKvU7n5J4dxZ9JKUXozFdE= 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 h1:SHs+kF4LP+f+p14esP5jAoDpHU8Gu/v9lFRK6IT5imM=
golang.org/x/crypto v0.39.0/go.mod h1:L+Xg3Wf6HoL4Bn4238Z6ft6KfEpN0tJGo53AAPC632U= 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 h1:n7a+ZbQKQA/Ysbyb0/6IbB1H/X41mKgbhfv7AfG/44w=
golang.org/x/mod v0.25.0/go.mod h1:IXM97Txy2VM4PJ3gI61r1YEk/gAj6zAHN3AdZt6S9Ww= 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 h1:KWH3jNZsfyT6xfAfKiz6MRNmd46ByHDYaZ7KSkCtdW8=
golang.org/x/sync v0.15.0/go.mod h1:1dzgHSNfp02xaA81J2MS99Qcpr2w7fw1gpm99rleRqA= 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.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.6.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.33.0 h1:q3i8TbbEz+JRD9ywIRlyRAQbM0qF7hu24q3teo2hbuw= golang.org/x/sys v0.41.0 h1:Ivj+2Cp/ylzLiEU89QhWblYnOE9zerudt9Ftecq2C6k=
golang.org/x/sys v0.33.0/go.mod h1:BJP2sWEmIv4KK5OTEluFJCKSidICx8ciO85XgH3Ak8k= 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 h1:P42AVeLghgTYr4+xUnTRKDMqpar+PtX7KWuNQL21L8M=
golang.org/x/text v0.26.0/go.mod h1:QK15LZJUUQVJxhz7wXgxSy/CJaTFjd0G+YLonydOVQA= 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 h1:4qz2S3zmRxbGIhDIAgjxvFutSvH5EfnsYrRBj0UI0bc=
golang.org/x/tools v0.33.0/go.mod h1:CIJMaWEY88juyUfo7UbgPqbC8rU2OqfAV1h2Qp0oMYI= 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 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 h1:Hei/4ADfdWqJk1ZMxUNpqntNwaWcugrBjAiHlqqRiVk=
gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c/go.mod h1:JHkPIbrfpd72SG/EVd6muEfDQjcINNoR0C8j2r3qZ4Q= 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/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 h1:/Wfyg1B/je1hnDx3sMkX+gAlxrlZpn6X0BXRlwXlvHg=
gorm.io/gorm v1.25.11/go.mod h1:xh7N7RHfYlNc5EmcI/El95gXusucDrQnHXe0+CgWcLQ= 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=

3
local/api/api.go Normal file → Executable file
View File

@@ -33,6 +33,9 @@ func Init(di *dig.Container, app *fiber.App) {
System: groups.Group("/system"), System: groups.Group("/system"),
WebSocket: groups.Group("/ws"), WebSocket: groups.Group("/ws"),
Leaderboard: serverIdGroup.Group("/leaderboard"), Leaderboard: serverIdGroup.Group("/leaderboard"),
Steam: groups.Group("/steam"),
ActivityLog: serverIdGroup.Group("/activity-log"),
Groups: groups,
} }
accessKeyMiddleware := middleware.NewAccessKeyMiddleware() accessKeyMiddleware := middleware.NewAccessKeyMiddleware()

View File

@@ -0,0 +1,72 @@
package controller
import (
"acc-server-manager/local/middleware"
"acc-server-manager/local/model"
"acc-server-manager/local/service"
"acc-server-manager/local/utl/common"
"acc-server-manager/local/utl/error_handler"
"github.com/gofiber/fiber/v2"
)
type ActivityLogController struct {
service *service.ActivityLogService
errorHandler *error_handler.ControllerErrorHandler
}
func NewActivityLogController(as *service.ActivityLogService, routeGroups *common.RouteGroups, auth *middleware.AuthMiddleware) *ActivityLogController {
ac := &ActivityLogController{
service: as,
errorHandler: error_handler.NewControllerErrorHandler(),
}
// Per-server activity log: GET /server/:id/activity-log
routeGroups.ActivityLog.Use(auth.Authenticate)
routeGroups.ActivityLog.Get("/", ac.GetServerLogs)
// Global activity log: GET /activity-log (admin use)
routeGroups.Groups.Group("/activity-log").Use(auth.Authenticate).Get("/", ac.GetAllLogs)
return ac
}
// GetServerLogs returns activity logs for a specific server.
//
// @Summary Return activity logs for a server
// @Tags ActivityLog
// @Success 200 {array} model.ActivityLog
// @Router /server/{id}/activity-log [get]
func (ac *ActivityLogController) GetServerLogs(c *fiber.Ctx) error {
var filter model.ActivityLogFilter
if err := common.ParseQueryFilter(c, &filter); err != nil {
return ac.errorHandler.HandleValidationError(c, err, "query_filter")
}
result, err := ac.service.GetAll(c.UserContext(), &filter)
if err != nil {
return ac.errorHandler.HandleServiceError(c, err)
}
return c.JSON(result)
}
// GetAllLogs returns activity logs across all servers (admin).
//
// @Summary Return all activity logs
// @Tags ActivityLog
// @Success 200 {array} model.ActivityLog
// @Router /activity-log [get]
func (ac *ActivityLogController) GetAllLogs(c *fiber.Ctx) error {
var filter model.ActivityLogFilter
if err := common.ParseQueryFilter(c, &filter); err != nil {
return ac.errorHandler.HandleValidationError(c, err, "query_filter")
}
result, err := ac.service.GetAll(c.UserContext(), &filter)
if err != nil {
return ac.errorHandler.HandleServiceError(c, err)
}
return c.JSON(result)
}

50
local/controller/config.go Normal file → Executable file
View File

@@ -2,6 +2,7 @@ package controller
import ( import (
"acc-server-manager/local/middleware" "acc-server-manager/local/middleware"
"acc-server-manager/local/model"
"acc-server-manager/local/service" "acc-server-manager/local/service"
"acc-server-manager/local/utl/common" "acc-server-manager/local/utl/common"
"acc-server-manager/local/utl/error_handler" "acc-server-manager/local/utl/error_handler"
@@ -34,6 +35,7 @@ func NewConfigController(as *service.ConfigService, routeGroups *common.RouteGro
configGroup := routeGroups.Config configGroup := routeGroups.Config
configGroup.Use(auth.Authenticate) configGroup.Use(auth.Authenticate)
configGroup.Patch("/", ac.UpdateAllConfigs)
configGroup.Put("/:file", ac.UpdateConfig) configGroup.Put("/:file", ac.UpdateConfig)
configGroup.Get("/:file", ac.GetConfig) configGroup.Get("/:file", ac.GetConfig)
configGroup.Get("/", ac.GetConfigs) configGroup.Get("/", ac.GetConfigs)
@@ -41,6 +43,54 @@ func NewConfigController(as *service.ConfigService, routeGroups *common.RouteGro
return ac return ac
} }
// updateAllConfigs updates all configuration files
//
// @Summary Bulk update all server configuration files
// @Description Update multiple ACC server configuration files in one request. Null or omitted sections are skipped.
// @Tags Server Configuration
// @Accept json
// @Produce json
// @Param id path string true "Server ID (UUID format)"
// @Param restart query bool false "Restart the server after update"
// @Param override query bool false "Override instead of merging with existing config"
// @Param content body model.ConfigurationsUpdateRequest true "Configuration sections to update (null sections are skipped)"
// @Success 200 {array} model.Config "List of updated config audit records"
// @Failure 400 {object} error_handler.ErrorResponse "Invalid request or JSON format"
// @Failure 401 {object} error_handler.ErrorResponse "Unauthorized"
// @Failure 403 {object} error_handler.ErrorResponse "Insufficient permissions"
// @Failure 404 {object} error_handler.ErrorResponse "Server not found"
// @Failure 500 {object} error_handler.ErrorResponse "Internal server error"
// @Security BearerAuth
// @Router /server/{id}/config [patch]
func (ac *ConfigController) UpdateAllConfigs(c *fiber.Ctx) error {
restart := c.QueryBool("restart")
serverID := c.Params("id")
if _, err := uuid.Parse(serverID); err != nil {
return ac.errorHandler.HandleUUIDError(c, "server ID")
}
c.Locals("serverId", serverID)
var req model.ConfigurationsUpdateRequest
if err := c.BodyParser(&req); err != nil {
return ac.errorHandler.HandleParsingError(c, err)
}
configs, err := ac.service.UpdateAllConfigs(c, &req)
if err != nil {
return ac.errorHandler.HandleServiceError(c, err)
}
if restart {
if _, err := ac.apiService.ServiceControlRestartServer(c); err != nil {
logging.ErrorWithContext("CONFIG_RESTART", "Failed to restart server after bulk config update: %v", err)
}
}
return c.JSON(configs)
}
// updateConfig returns Config // updateConfig returns Config
// //
// @Summary Update server configuration file // @Summary Update server configuration file

10
local/controller/controller.go Normal file → Executable file
View File

@@ -64,4 +64,14 @@ func InitializeControllers(c *dig.Container) {
if err != nil { if err != nil {
logging.Panic("unable to initialize leaderboard controller") logging.Panic("unable to initialize leaderboard controller")
} }
err = c.Invoke(NewSteamController)
if err != nil {
logging.Panic("unable to initialize steam controller")
}
err = c.Invoke(NewActivityLogController)
if err != nil {
logging.Panic("unable to initialize activity log controller")
}
} }

15
local/controller/leaderboard.go Normal file → Executable file
View File

@@ -11,6 +11,7 @@ import (
"github.com/google/uuid" "github.com/google/uuid"
) )
type LeaderboardController struct { type LeaderboardController struct {
service *service.LeaderboardService service *service.LeaderboardService
errorHandler *error_handler.ControllerErrorHandler errorHandler *error_handler.ControllerErrorHandler
@@ -22,6 +23,9 @@ func NewLeaderboardController(ls *service.LeaderboardService, routeGroups *commo
errorHandler: error_handler.NewControllerErrorHandler(), errorHandler: error_handler.NewControllerErrorHandler(),
} }
apiServerRoutes := routeGroups.Api.Group("/server/:id")
apiServerRoutes.Get("/leaderboard", lc.Get)
routeGroups.Leaderboard.Get("/", lc.Get) routeGroups.Leaderboard.Get("/", lc.Get)
routeGroups.Leaderboard.Put("/", auth.Authenticate, lc.Update) routeGroups.Leaderboard.Put("/", auth.Authenticate, lc.Update)
@@ -52,12 +56,19 @@ func (lc *LeaderboardController) Update(c *fiber.Ctx) error {
return lc.errorHandler.HandleUUIDError(c, "server ID") return lc.errorHandler.HandleUUIDError(c, "server ID")
} }
var input model.LeaderboardInput var input model.Leaderboard
if err := c.BodyParser(&input); err != nil { if err := c.BodyParser(&input); err != nil {
return lc.errorHandler.HandleParsingError(c, err) return lc.errorHandler.HandleParsingError(c, err)
} }
data, err := lc.service.Update(c.UserContext(), serverID, &input) userInfo, _ := c.Locals("userInfo").(*middleware.CachedUserInfo)
actorID, actorUsername := "", "system"
if userInfo != nil {
actorID = userInfo.UserID
actorUsername = userInfo.Username
}
data, err := lc.service.Update(c.UserContext(), serverID, &input, actorID, actorUsername)
if err != nil { if err != nil {
return lc.errorHandler.HandleServiceError(c, err) return lc.errorHandler.HandleServiceError(c, err)
} }

0
local/controller/lookup.go Normal file → Executable file
View File

0
local/controller/membership.go Normal file → Executable file
View File

0
local/controller/server.go Normal file → Executable file
View File

0
local/controller/service_control.go Normal file → Executable file
View File

0
local/controller/state_history.go Normal file → Executable file
View File

57
local/controller/steam.go Normal file
View File

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

0
local/controller/system.go Normal file → Executable file
View File

0
local/controller/websocket.go Normal file → Executable file
View File

0
local/middleware/access_key.go Normal file → Executable file
View File

19
local/middleware/auth.go Normal file → Executable file
View File

@@ -115,6 +115,7 @@ func (m *AuthMiddleware) AuthenticateWithHandler(jwtHandler *jwt.JWTHandler, isO
if os.Getenv("TESTING_ENV") == "true" { if os.Getenv("TESTING_ENV") == "true" {
userInfo := CachedUserInfo{UserID: uuid.New().String(), Username: "test@example.com", RoleName: "Admin", Permissions: make(map[string]bool), CachedAt: time.Now()} userInfo := CachedUserInfo{UserID: uuid.New().String(), Username: "test@example.com", RoleName: "Admin", Permissions: make(map[string]bool), CachedAt: time.Now()}
ctx.Locals("userID", userInfo.UserID) ctx.Locals("userID", userInfo.UserID)
ctx.Locals("actorUsername", userInfo.Username)
ctx.Locals("userInfo", userInfo) ctx.Locals("userInfo", userInfo)
ctx.Locals("authTime", time.Now()) ctx.Locals("authTime", time.Now())
} else { } else {
@@ -127,6 +128,7 @@ func (m *AuthMiddleware) AuthenticateWithHandler(jwtHandler *jwt.JWTHandler, isO
} }
ctx.Locals("userID", claims.UserID) ctx.Locals("userID", claims.UserID)
ctx.Locals("actorUsername", userInfo.Username)
ctx.Locals("userInfo", userInfo) ctx.Locals("userInfo", userInfo)
ctx.Locals("authTime", time.Now()) ctx.Locals("authTime", time.Now())
} }
@@ -178,6 +180,23 @@ func (m *AuthMiddleware) HasPermission(requiredPermission string) fiber.Handler
} }
} }
func (m *AuthMiddleware) RequireSuperAdmin() fiber.Handler {
return func(ctx *fiber.Ctx) error {
if os.Getenv("TESTING_ENV") == "true" {
return ctx.Next()
}
userInfo, ok := ctx.Locals("userInfo").(*CachedUserInfo)
if !ok || userInfo.RoleName != "Super Admin" {
return ctx.Status(fiber.StatusForbidden).JSON(fiber.Map{
"error": "Forbidden",
})
}
return ctx.Next()
}
}
func (m *AuthMiddleware) AuthRateLimit() fiber.Handler { func (m *AuthMiddleware) AuthRateLimit() fiber.Handler {
return m.securityMW.AuthRateLimit() return m.securityMW.AuthRateLimit()
} }

0
local/middleware/logging/request_logging.go Normal file → Executable file
View File

0
local/middleware/security/security.go Normal file → Executable file
View File

0
local/migrations/001_upgrade_password_security.go Normal file → Executable file
View File

0
local/migrations/002_migrate_to_uuid.go Normal file → Executable file
View File

0
local/migrations/003_update_state_history_sessions.go Normal file → Executable file
View File

0
local/migrations/004_fix_ddl_comments.go Normal file → Executable file
View File

View File

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

View File

@@ -0,0 +1,33 @@
package migrations
import (
"acc-server-manager/local/utl/logging"
"fmt"
"gorm.io/gorm"
)
func RunAddActivityLogMigration(db *gorm.DB) error {
if err := db.AutoMigrate(&MigrationRecord{}); err != nil {
return fmt.Errorf("failed to ensure migration_records table: %v", err)
}
var record MigrationRecord
if err := db.Where("migration_name = ?", "006_add_activity_log").First(&record).Error; err == nil {
logging.Info("Activity log migration already applied, skipping")
return nil
}
record = MigrationRecord{
MigrationName: "006_add_activity_log",
AppliedAt: "datetime('now')",
Success: true,
Notes: "Added ActivityLog table via GORM AutoMigrate",
}
if err := db.Create(&record).Error; err != nil {
return fmt.Errorf("failed to record migration 006: %v", err)
}
logging.Info("Activity log migration recorded")
return nil
}

View File

@@ -0,0 +1,81 @@
package model
import (
"time"
"github.com/google/uuid"
"gorm.io/gorm"
)
type ActionType string
const (
ActionConfigUpdate ActionType = "config_update"
ActionServerStart ActionType = "server_start"
ActionServerStop ActionType = "server_stop"
ActionServerRestart ActionType = "server_restart"
ActionLeaderboardUpdate ActionType = "leaderboard_update"
)
type ActivityLog struct {
ID uuid.UUID `json:"id" gorm:"type:uuid;primary_key;"`
ServerID uuid.UUID `json:"serverId" gorm:"not null;type:uuid;index"`
UserID string `json:"userId" gorm:"not null"`
Username string `json:"username" gorm:"not null"`
Action ActionType `json:"action" gorm:"not null"`
Details string `json:"details" gorm:"type:text"`
CreatedAt time.Time `json:"createdAt"`
}
func (a *ActivityLog) BeforeCreate(tx *gorm.DB) error {
if a.ID == uuid.Nil {
a.ID = uuid.New()
}
if a.CreatedAt.IsZero() {
a.CreatedAt = time.Now().UTC()
}
return nil
}
type ActivityLogFilter struct {
BaseFilter
ServerBasedFilter
DateRangeFilter
UserID string `query:"user_id"`
Action ActionType `query:"action"`
}
func (f *ActivityLogFilter) ApplyFilter(query *gorm.DB) *gorm.DB {
if f.ServerID != "" {
if serverUUID, err := uuid.Parse(f.ServerID); err == nil {
query = query.Where("server_id = ?", serverUUID)
}
}
if f.UserID != "" {
query = query.Where("user_id = ?", f.UserID)
}
if f.Action != "" {
query = query.Where("action = ?", f.Action)
}
timeZero := time.Time{}
if f.StartDate != timeZero {
query = query.Where("created_at >= ?", f.StartDate)
}
if f.EndDate != timeZero {
query = query.Where("created_at <= ?", f.EndDate)
}
return query
}
func (f *ActivityLogFilter) GetSorting() (field string, desc bool) {
if f.SortBy == "" {
return "created_at", true
}
return f.SortBy, f.SortDesc
}
type LastActivityInfo struct {
UpdatedAt time.Time `json:"updatedAt"`
Username string `json:"username"`
Action ActionType `json:"action"`
}

0
local/model/cache.go Normal file → Executable file
View File

8
local/model/config.go Normal file → Executable file
View File

@@ -40,6 +40,14 @@ type Configurations struct {
Settings ServerSettings `json:"settings"` Settings ServerSettings `json:"settings"`
} }
type ConfigurationsUpdateRequest struct {
Configuration *map[string]interface{} `json:"configuration"`
AssistRules *map[string]interface{} `json:"assistRules"`
Event *map[string]interface{} `json:"event"`
EventRules *map[string]interface{} `json:"eventRules"`
Settings *map[string]interface{} `json:"settings"`
}
type ServerSettings struct { type ServerSettings struct {
ServerName string `json:"serverName"` ServerName string `json:"serverName"`
AdminPassword string `json:"adminPassword"` AdminPassword string `json:"adminPassword"`

0
local/model/filter.go Normal file → Executable file
View File

84
local/model/leaderboard.go Normal file → Executable file
View File

@@ -1,10 +1,36 @@
package model package model
import ( import (
"encoding/json"
"github.com/google/uuid" "github.com/google/uuid"
"gorm.io/gorm" "gorm.io/gorm"
"strconv"
) )
// Score is a custom type that accepts both numbers and strings in JSON.
type Score string
func (s *Score) UnmarshalJSON(data []byte) error {
var n json.Number
if err := json.Unmarshal(data, &n); err == nil {
*s = Score(n.String())
return nil
}
var str string
if err := json.Unmarshal(data, &str); err != nil {
return err
}
*s = Score(str)
return nil
}
func (s Score) MarshalJSON() ([]byte, error) {
if n, err := strconv.Atoi(string(s)); err == nil {
return json.Marshal(n)
}
return json.Marshal(string(s))
}
type Leaderboard struct { type Leaderboard struct {
ID uuid.UUID `gorm:"type:uuid;primary_key;" json:"id"` ID uuid.UUID `gorm:"type:uuid;primary_key;" json:"id"`
ServerID uuid.UUID `gorm:"uniqueIndex;not null;type:uuid" json:"serverId"` ServerID uuid.UUID `gorm:"uniqueIndex;not null;type:uuid" json:"serverId"`
@@ -12,38 +38,38 @@ type Leaderboard struct {
FLColor string `gorm:"default:'#8b5cf6'" json:"flColor"` FLColor string `gorm:"default:'#8b5cf6'" json:"flColor"`
FLTextColor string `gorm:"default:'#000000'" json:"flTextColor"` FLTextColor string `gorm:"default:'#000000'" json:"flTextColor"`
Drivers []LeaderboardDriver `gorm:"foreignKey:LeaderboardID;constraint:OnDelete:CASCADE" json:"drivers"` Drivers []LeaderboardDriver `gorm:"foreignKey:LeaderboardID;constraint:OnDelete:CASCADE" json:"drivers"`
Races []LeaderboardRace `gorm:"foreignKey:LeaderboardID;constraint:OnDelete:CASCADE" json:"races"` Races []LeaderboardRace `gorm:"foreignKey:LeaderboardID;constraint:OnDelete:CASCADE" json:"tracks"`
PointRows []LeaderboardPointRow `gorm:"foreignKey:LeaderboardID;constraint:OnDelete:CASCADE" json:"pointRows"` PointRows []LeaderboardPointRow `gorm:"foreignKey:LeaderboardID;constraint:OnDelete:CASCADE" json:"pointsTable"`
} }
type LeaderboardDriver struct { type LeaderboardDriver struct {
ID uuid.UUID `gorm:"type:uuid;primary_key;" json:"id"` ID uuid.UUID `gorm:"type:uuid;primary_key;" json:"id"`
LeaderboardID uuid.UUID `gorm:"not null;type:uuid" json:"leaderboardId"` LeaderboardID uuid.UUID `gorm:"not null;type:uuid" json:"-"`
Name string `gorm:"not null" json:"name"` Name string `gorm:"not null" json:"name"`
Initials string `json:"initials"` Initials string `json:"initials"`
Color string `json:"color"` Color string `json:"color"`
Position int `json:"position"` Position int `json:"-"`
} }
type LeaderboardRace struct { type LeaderboardRace struct {
ID uuid.UUID `gorm:"type:uuid;primary_key;" json:"id"` ID uuid.UUID `gorm:"type:uuid;primary_key;" json:"id"`
LeaderboardID uuid.UUID `gorm:"not null;type:uuid" json:"leaderboardId"` LeaderboardID uuid.UUID `gorm:"not null;type:uuid" json:"-"`
Name string `gorm:"not null" json:"name"` Name string `gorm:"not null" json:"name"`
Position int `json:"position"` Position int `json:"-"`
FastestLapInitials string `json:"fastestLapInitials"` FastestLapDriverID *uuid.UUID `gorm:"type:uuid" json:"fastestLapDriverId"`
Results []LeaderboardResult `gorm:"foreignKey:RaceID;constraint:OnDelete:CASCADE" json:"results"` Results []LeaderboardResult `gorm:"foreignKey:RaceID;constraint:OnDelete:CASCADE" json:"results"`
} }
type LeaderboardResult struct { type LeaderboardResult struct {
ID uuid.UUID `gorm:"type:uuid;primary_key;" json:"id"` ID uuid.UUID `gorm:"type:uuid;primary_key;" json:"-"`
RaceID uuid.UUID `gorm:"not null;type:uuid" json:"raceId"` RaceID uuid.UUID `gorm:"not null;type:uuid" json:"-"`
DriverID uuid.UUID `gorm:"not null;type:uuid" json:"driverId"` DriverID uuid.UUID `gorm:"not null;type:uuid" json:"driverId"`
Score string `json:"score"` Score Score `json:"score"`
} }
type LeaderboardPointRow struct { type LeaderboardPointRow struct {
ID uuid.UUID `gorm:"type:uuid;primary_key;" json:"id"` ID uuid.UUID `gorm:"type:uuid;primary_key;" json:"id"`
LeaderboardID uuid.UUID `gorm:"not null;type:uuid" json:"leaderboardId"` LeaderboardID uuid.UUID `gorm:"not null;type:uuid" json:"-"`
Label string `json:"label"` Label string `json:"label"`
Points int `json:"points"` Points int `json:"points"`
Color string `json:"color"` Color string `json:"color"`
@@ -51,42 +77,6 @@ type LeaderboardPointRow struct {
Priority int `json:"priority"` Priority int `json:"priority"`
} }
// LeaderboardInput is the wire format matching the HTML's appData shape.
type LeaderboardInput struct {
Drivers []LeaderboardDriverInput `json:"drivers"`
PointsTable []LeaderboardPointRowInput `json:"pointsTable"`
FLPoints LeaderboardFLInput `json:"flPoints"`
Tracks []LeaderboardTrackInput `json:"tracks"`
}
type LeaderboardDriverInput struct {
Name string `json:"name"`
Color string `json:"color"`
Initials string `json:"initials"`
}
type LeaderboardPointRowInput struct {
Points int `json:"points"`
Label string `json:"label"`
Color string `json:"color"`
TextColor string `json:"textColor"`
Priority int `json:"priority"`
}
type LeaderboardFLInput struct {
Points int `json:"points"`
Label string `json:"label"`
Color string `json:"color"`
TextColor string `json:"textColor"`
Priority int `json:"priority"`
}
type LeaderboardTrackInput struct {
Name string `json:"name"`
Results []interface{} `json:"results"`
FastestLapInitials string `json:"fastestLapInitials"`
}
func (l *Leaderboard) BeforeCreate(tx *gorm.DB) error { func (l *Leaderboard) BeforeCreate(tx *gorm.DB) error {
if l.ID == uuid.Nil { if l.ID == uuid.Nil {
l.ID = uuid.New() l.ID = uuid.New()

0
local/model/lookup.go Normal file → Executable file
View File

0
local/model/model.go Normal file → Executable file
View File

0
local/model/permission.go Normal file → Executable file
View File

0
local/model/permissions.go Normal file → Executable file
View File

0
local/model/role.go Normal file → Executable file
View File

3
local/model/server.go Normal file → Executable file
View File

@@ -42,7 +42,10 @@ type Server struct {
Port int `gorm:"not null" json:"-"` Port int `gorm:"not null" json:"-"`
Path string `gorm:"not null" json:"path"` Path string `gorm:"not null" json:"path"`
ServiceName string `gorm:"not null" json:"serviceName"` 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"` State *ServerState `gorm:"-" json:"state"`
LastActivity *LastActivityInfo `gorm:"-" json:"lastActivity,omitempty"`
DateCreated time.Time `json:"dateCreated"` DateCreated time.Time `json:"dateCreated"`
FromSteamCMD bool `gorm:"not null; default:true" json:"-"` FromSteamCMD bool `gorm:"not null; default:true" json:"-"`
} }

0
local/model/service_control.go Normal file → Executable file
View File

0
local/model/state_history.go Normal file → Executable file
View File

0
local/model/state_history_stats.go Normal file → Executable file
View File

0
local/model/steam_2fa.go Normal file → Executable file
View File

0
local/model/steam_credentials.go Normal file → Executable file
View File

0
local/model/user.go Normal file → Executable file
View File

0
local/model/websocket.go Normal file → Executable file
View File

View File

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

View File

@@ -0,0 +1,75 @@
package repository
import (
"acc-server-manager/local/model"
"context"
"time"
"github.com/google/uuid"
"gorm.io/gorm"
)
type ActivityLogRepository struct {
*BaseRepository[model.ActivityLog, model.ActivityLogFilter]
}
func NewActivityLogRepository(db *gorm.DB) *ActivityLogRepository {
return &ActivityLogRepository{
BaseRepository: NewBaseRepository[model.ActivityLog, model.ActivityLogFilter](db, model.ActivityLog{}),
}
}
func (r *ActivityLogRepository) Insert(ctx context.Context, entry *model.ActivityLog) error {
return r.BaseRepository.Insert(ctx, entry)
}
func (r *ActivityLogRepository) GetAll(ctx context.Context, filter *model.ActivityLogFilter) (*[]model.ActivityLog, error) {
return r.BaseRepository.GetAll(ctx, filter)
}
type lastActivityRow struct {
ServerID string `gorm:"column:server_id"`
CreatedAt time.Time `gorm:"column:created_at"`
Username string `gorm:"column:username"`
Action model.ActionType `gorm:"column:action"`
}
// GetLastActivityByServerIDs returns the most recent activity entry per server.
// Uses a single GROUP BY query with MAX(created_at); SQLite selects the other
// columns from the row that holds the max value — documented SQLite behaviour.
func (r *ActivityLogRepository) GetLastActivityByServerIDs(ctx context.Context, serverIDs []uuid.UUID) (map[uuid.UUID]*model.LastActivityInfo, error) {
out := make(map[uuid.UUID]*model.LastActivityInfo)
if len(serverIDs) == 0 {
return out, nil
}
// Convert to string slice so the IN clause works with SQLite UUID strings.
ids := make([]string, len(serverIDs))
for i, id := range serverIDs {
ids[i] = id.String()
}
var rows []lastActivityRow
err := r.BaseRepository.db.WithContext(ctx).
Model(&model.ActivityLog{}).
Select("server_id, MAX(created_at) as created_at, username, action").
Where("server_id IN ?", ids).
Group("server_id").
Scan(&rows).Error
if err != nil {
return nil, err
}
for _, row := range rows {
serverUUID, err := uuid.Parse(row.ServerID)
if err != nil {
continue
}
out[serverUUID] = &model.LastActivityInfo{
UpdatedAt: row.CreatedAt,
Username: row.Username,
Action: row.Action,
}
}
return out, nil
}

0
local/repository/base.go Normal file → Executable file
View File

0
local/repository/config.go Normal file → Executable file
View File

2
local/repository/leaderboard.go Normal file → Executable file
View File

@@ -92,7 +92,6 @@ func (r *LeaderboardRepository) FullReplace(ctx context.Context, serverID uuid.U
// Re-insert children with correct leaderboard ID // Re-insert children with correct leaderboard ID
for i := range lb.Drivers { for i := range lb.Drivers {
lb.Drivers[i].LeaderboardID = existing.ID lb.Drivers[i].LeaderboardID = existing.ID
lb.Drivers[i].ID = uuid.Nil // force new ID
} }
if len(lb.Drivers) > 0 { if len(lb.Drivers) > 0 {
if err := tx.Create(&lb.Drivers).Error; err != nil { if err := tx.Create(&lb.Drivers).Error; err != nil {
@@ -112,7 +111,6 @@ func (r *LeaderboardRepository) FullReplace(ctx context.Context, serverID uuid.U
for i := range lb.Races { for i := range lb.Races {
lb.Races[i].LeaderboardID = existing.ID lb.Races[i].LeaderboardID = existing.ID
lb.Races[i].ID = uuid.Nil
} }
if len(lb.Races) > 0 { if len(lb.Races) > 0 {
// Create races first (without results) to get IDs // Create races first (without results) to get IDs

0
local/repository/lookup.go Normal file → Executable file
View File

0
local/repository/membership.go Normal file → Executable file
View File

1
local/repository/repository.go Normal file → Executable file
View File

@@ -20,6 +20,7 @@ func InitializeRepositories(c *dig.Container) {
c.Provide(NewSteamCredentialsRepository) c.Provide(NewSteamCredentialsRepository)
c.Provide(NewMembershipRepository) c.Provide(NewMembershipRepository)
c.Provide(NewLeaderboardRepository) c.Provide(NewLeaderboardRepository)
c.Provide(NewActivityLogRepository)
if err := c.Provide(func() *model.Steam2FAManager { if err := c.Provide(func() *model.Steam2FAManager {
manager := model.NewSteam2FAManager() manager := model.NewSteam2FAManager()

0
local/repository/server.go Normal file → Executable file
View File

0
local/repository/service_control.go Normal file → Executable file
View File

0
local/repository/state_history.go Normal file → Executable file
View File

0
local/repository/steam_credentials.go Normal file → Executable file
View File

View File

@@ -0,0 +1,62 @@
package service
import (
"acc-server-manager/local/model"
"acc-server-manager/local/repository"
"acc-server-manager/local/utl/logging"
"context"
"encoding/json"
"fmt"
)
type ActivityLogService struct {
repo *repository.ActivityLogRepository
}
func NewActivityLogService(repo *repository.ActivityLogRepository) *ActivityLogService {
return &ActivityLogService{repo: repo}
}
// Log persists an activity log entry. Errors are logged but not returned so
// callers can treat this as fire-and-forget.
func (s *ActivityLogService) Log(ctx context.Context, entry *model.ActivityLog) error {
if err := s.repo.Insert(ctx, entry); err != nil {
logging.Error("Failed to insert activity log: %v", err)
return err
}
return nil
}
func (s *ActivityLogService) GetAll(ctx context.Context, filter *model.ActivityLogFilter) (*[]model.ActivityLog, error) {
return s.repo.GetAll(ctx, filter)
}
// BuildConfigDetails produces a JSON string summarising which top-level keys
// changed between oldJSON and newJSON.
func BuildConfigDetails(file string, oldJSON, newJSON []byte) string {
var oldMap, newMap map[string]interface{}
if err := json.Unmarshal(oldJSON, &oldMap); err != nil {
oldMap = map[string]interface{}{}
}
if err := json.Unmarshal(newJSON, &newMap); err != nil {
newMap = map[string]interface{}{}
}
var changedKeys []string
for key, newVal := range newMap {
oldVal, exists := oldMap[key]
if !exists {
changedKeys = append(changedKeys, key)
continue
}
oldBytes, _ := json.Marshal(oldVal)
newBytes, _ := json.Marshal(newVal)
if string(oldBytes) != string(newBytes) {
changedKeys = append(changedKeys, key)
}
}
keysJSON, _ := json.Marshal(changedKeys)
return fmt.Sprintf(`{"file":%q,"changed_keys":%s}`, file, string(keysJSON))
}

112
local/service/config.go Normal file → Executable file
View File

@@ -68,14 +68,16 @@ type ConfigService struct {
repository *repository.ConfigRepository repository *repository.ConfigRepository
serverRepository *repository.ServerRepository serverRepository *repository.ServerRepository
serverService *ServerService serverService *ServerService
activityLog *ActivityLogService
configCache *model.ServerConfigCache configCache *model.ServerConfigCache
} }
func NewConfigService(repository *repository.ConfigRepository, serverRepository *repository.ServerRepository) *ConfigService { func NewConfigService(repository *repository.ConfigRepository, serverRepository *repository.ServerRepository, activityLog *ActivityLogService) *ConfigService {
logging.Debug("Initializing ConfigService with 5m expiration and 1s throttle") logging.Debug("Initializing ConfigService with 5m expiration and 1s throttle")
return &ConfigService{ return &ConfigService{
repository: repository, repository: repository,
serverRepository: serverRepository, serverRepository: serverRepository,
activityLog: activityLog,
configCache: model.NewServerConfigCache(model.CacheConfig{ configCache: model.NewServerConfigCache(model.CacheConfig{
ExpirationTime: 5 * time.Minute, ExpirationTime: 5 * time.Minute,
ThrottleTime: 1 * time.Second, ThrottleTime: 1 * time.Second,
@@ -91,12 +93,93 @@ func (as *ConfigService) SetServerService(serverService *ServerService) {
// context.Context: Application context // context.Context: Application context
// Returns: // Returns:
// string: Application version // string: Application version
func (as *ConfigService) UpdateAllConfigs(ctx *fiber.Ctx, req *model.ConfigurationsUpdateRequest) ([]*model.Config, error) {
serverID := ctx.Locals("serverId").(string)
override := ctx.QueryBool("override", false)
serverUUID, err := uuid.Parse(serverID)
if err != nil {
logging.Error("Invalid server ID format: %v", err)
return nil, fmt.Errorf("invalid server ID format")
}
server, err := as.serverRepository.GetByID(ctx.UserContext(), serverUUID)
if err != nil {
logging.Error("Server not found")
return nil, fmt.Errorf("server not found")
}
type configEntry struct {
fileName string
body *map[string]interface{}
}
entries := []configEntry{
{ConfigurationJson, req.Configuration},
{AssistRulesJson, req.AssistRules},
{EventJson, req.Event},
{EventRulesJson, req.EventRules},
{SettingsJson, req.Settings},
}
actorID, _ := ctx.Locals("userID").(string)
actorUsername, _ := ctx.Locals("actorUsername").(string)
if actorUsername == "" {
actorUsername = "system"
}
var results []*model.Config
for _, entry := range entries {
if entry.body == nil {
continue
}
oldDataUTF8, newData, err := as.updateConfigFiles(ctx.UserContext(), server, entry.fileName, entry.body, override)
if err != nil {
return nil, err
}
result := as.repository.UpdateConfig(ctx.UserContext(), &model.Config{
ServerID: serverUUID,
ConfigFile: entry.fileName,
OldConfig: string(oldDataUTF8),
NewConfig: string(newData),
ChangedAt: time.Now(),
})
results = append(results, result)
if as.activityLog != nil {
details := BuildConfigDetails(entry.fileName, oldDataUTF8, newData)
go func(details string) {
_ = as.activityLog.Log(context.Background(), &model.ActivityLog{
ServerID: serverUUID,
UserID: actorID,
Username: actorUsername,
Action: model.ActionConfigUpdate,
Details: details,
})
}(details)
}
}
if len(results) > 0 {
as.configCache.InvalidateServerCache(serverID)
as.serverService.StartAccServerRuntime(server)
}
return results, nil
}
func (as *ConfigService) UpdateConfig(ctx *fiber.Ctx, body *map[string]interface{}) (*model.Config, error) { func (as *ConfigService) UpdateConfig(ctx *fiber.Ctx, body *map[string]interface{}) (*model.Config, error) {
serverID := ctx.Locals("serverId").(string) serverID := ctx.Locals("serverId").(string)
configFile := ctx.Params("file") configFile := ctx.Params("file")
override := ctx.QueryBool("override", false) override := ctx.QueryBool("override", false)
return as.updateConfigInternal(ctx.UserContext(), serverID, configFile, body, override) actorID, _ := ctx.Locals("userID").(string)
actorUsername, _ := ctx.Locals("actorUsername").(string)
if actorUsername == "" {
actorUsername = "system"
}
return as.updateConfigInternal(ctx.UserContext(), serverID, configFile, body, override, actorID, actorUsername)
} }
func (as *ConfigService) updateConfigFiles(ctx context.Context, server *model.Server, configFile string, body *map[string]interface{}, override bool) ([]byte, []byte, error) { func (as *ConfigService) updateConfigFiles(ctx context.Context, server *model.Server, configFile string, body *map[string]interface{}, override bool) ([]byte, []byte, error) {
@@ -155,7 +238,7 @@ func (as *ConfigService) updateConfigFiles(ctx context.Context, server *model.Se
return oldDataUTF8, newData, nil return oldDataUTF8, newData, nil
} }
func (as *ConfigService) updateConfigInternal(ctx context.Context, serverID string, configFile string, body *map[string]interface{}, override bool) (*model.Config, error) { func (as *ConfigService) updateConfigInternal(ctx context.Context, serverID string, configFile string, body *map[string]interface{}, override bool, actorID, actorUsername string) (*model.Config, error) {
serverUUID, err := uuid.Parse(serverID) serverUUID, err := uuid.Parse(serverID)
if err != nil { if err != nil {
logging.Error("Invalid server ID format: %v", err) logging.Error("Invalid server ID format: %v", err)
@@ -174,8 +257,21 @@ func (as *ConfigService) updateConfigInternal(ctx context.Context, serverID stri
} }
as.configCache.InvalidateServerCache(serverID) as.configCache.InvalidateServerCache(serverID)
as.serverService.StartAccServerRuntime(server) as.serverService.StartAccServerRuntime(server)
if as.activityLog != nil {
details := BuildConfigDetails(configFile, oldDataUTF8, newData)
go func() {
_ = as.activityLog.Log(context.Background(), &model.ActivityLog{
ServerID: serverUUID,
UserID: actorID,
Username: actorUsername,
Action: model.ActionConfigUpdate,
Details: details,
})
}()
}
return as.repository.UpdateConfig(ctx, &model.Config{ return as.repository.UpdateConfig(ctx, &model.Config{
ServerID: serverUUID, ServerID: serverUUID,
ConfigFile: configFile, ConfigFile: configFile,
@@ -420,6 +516,14 @@ func (as *ConfigService) GetConfiguration(server *model.Server) (*model.Configur
return &config, nil 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 { func (as *ConfigService) SaveConfiguration(server *model.Server, config *model.Configuration) error {
configMap := make(map[string]interface{}) configMap := make(map[string]interface{})
configBytes, err := json.Marshal(config) configBytes, err := json.Marshal(config)

View File

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

View File

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

View File

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

0
local/service/firewall_service.go Normal file → Executable file
View File

204
local/service/leaderboard.go Normal file → Executable file
View File

@@ -3,6 +3,7 @@ package service
import ( import (
"acc-server-manager/local/model" "acc-server-manager/local/model"
"acc-server-manager/local/repository" "acc-server-manager/local/repository"
"acc-server-manager/local/utl/logging"
"context" "context"
"fmt" "fmt"
@@ -11,195 +12,48 @@ import (
type LeaderboardService struct { type LeaderboardService struct {
repo *repository.LeaderboardRepository repo *repository.LeaderboardRepository
activityLog *ActivityLogService
} }
func NewLeaderboardService(repo *repository.LeaderboardRepository) *LeaderboardService { func NewLeaderboardService(repo *repository.LeaderboardRepository, activityLog *ActivityLogService) *LeaderboardService {
return &LeaderboardService{repo: repo} return &LeaderboardService{repo: repo, activityLog: activityLog}
} }
// Get returns the leaderboard for a server in the wire format (LeaderboardInput). // Get returns the leaderboard for a server.
func (s *LeaderboardService) Get(ctx context.Context, serverID uuid.UUID) (*model.LeaderboardInput, error) { func (s *LeaderboardService) Get(ctx context.Context, serverID uuid.UUID) (*model.Leaderboard, error) {
lb, err := s.repo.GetOrCreateByServerID(ctx, serverID) return s.repo.GetOrCreateByServerID(ctx, serverID)
if err != nil {
return nil, err
}
return toInput(lb), nil
} }
// Update replaces the leaderboard for a server from wire format input. // Update replaces the leaderboard for a server.
func (s *LeaderboardService) Update(ctx context.Context, serverID uuid.UUID, input *model.LeaderboardInput) (*model.LeaderboardInput, error) { func (s *LeaderboardService) Update(ctx context.Context, serverID uuid.UUID, lb *model.Leaderboard, actorID, actorUsername string) (*model.Leaderboard, error) {
lb, err := fromInput(serverID, input) if lb == nil {
if err != nil { return nil, fmt.Errorf("input is required")
return nil, err }
for i := range lb.Drivers {
lb.Drivers[i].Position = i
}
for i := range lb.Races {
lb.Races[i].Position = i
} }
result, err := s.repo.FullReplace(ctx, serverID, lb) result, err := s.repo.FullReplace(ctx, serverID, lb)
if err != nil { if err != nil {
return nil, err return nil, err
} }
return toInput(result), nil
}
// toInput converts the normalized DB model to the flat wire format.
func toInput(lb *model.Leaderboard) *model.LeaderboardInput {
// Build driver map (id -> index in slice) for result ordering
driverIndexByID := make(map[uuid.UUID]int, len(lb.Drivers))
// Sort drivers by position
drivers := make([]model.LeaderboardDriverInput, len(lb.Drivers))
for i, d := range lb.Drivers {
drivers[i] = model.LeaderboardDriverInput{
Name: d.Name,
Color: d.Color,
Initials: d.Initials,
}
driverIndexByID[d.ID] = d.Position
}
// Re-index by position order
driverByPos := make(map[int]model.LeaderboardDriver, len(lb.Drivers))
for _, d := range lb.Drivers {
driverByPos[d.Position] = d
}
driverCount := len(lb.Drivers) driverCount := len(lb.Drivers)
orderedDrivers := make([]model.LeaderboardDriver, driverCount) raceCount := len(lb.Races)
for i := 0; i < driverCount; i++ { details := fmt.Sprintf(`{"driver_count":%d,"race_count":%d}`, driverCount, raceCount)
if d, ok := driverByPos[i]; ok { go func() {
orderedDrivers[i] = d if logErr := s.activityLog.Log(context.Background(), &model.ActivityLog{
drivers[i] = model.LeaderboardDriverInput{
Name: d.Name,
Color: d.Color,
Initials: d.Initials,
}
driverIndexByID[d.ID] = i
}
}
// Build tracks
raceByPos := make(map[int]model.LeaderboardRace, len(lb.Races))
for _, r := range lb.Races {
raceByPos[r.Position] = r
}
tracks := make([]model.LeaderboardTrackInput, len(lb.Races))
for i := 0; i < len(lb.Races); i++ {
race, ok := raceByPos[i]
if !ok {
continue
}
results := make([]interface{}, driverCount)
for j := range results {
results[j] = "0"
}
for _, res := range race.Results {
idx, found := driverIndexByID[res.DriverID]
if found && idx < driverCount {
results[idx] = res.Score
}
}
tracks[i] = model.LeaderboardTrackInput{
Name: race.Name,
Results: results,
FastestLapInitials: race.FastestLapInitials,
}
}
// Build points table
pointsTable := make([]model.LeaderboardPointRowInput, len(lb.PointRows))
for i, p := range lb.PointRows {
pointsTable[i] = model.LeaderboardPointRowInput{
Points: p.Points,
Label: p.Label,
Color: p.Color,
TextColor: p.TextColor,
Priority: p.Priority,
}
}
flColor := lb.FLColor
if flColor == "" {
flColor = "#8b5cf6"
}
flTextColor := lb.FLTextColor
if flTextColor == "" {
flTextColor = "#000000"
}
return &model.LeaderboardInput{
Drivers: drivers,
PointsTable: pointsTable,
FLPoints: model.LeaderboardFLInput{
Points: lb.FLPoints,
Label: "FL +1",
Color: flColor,
TextColor: flTextColor,
Priority: len(pointsTable) + 1,
},
Tracks: tracks,
}
}
// fromInput converts the wire format to the normalized DB model.
func fromInput(serverID uuid.UUID, input *model.LeaderboardInput) (*model.Leaderboard, error) {
if input == nil {
return nil, fmt.Errorf("input is required")
}
lb := &model.Leaderboard{
ServerID: serverID, ServerID: serverID,
FLPoints: input.FLPoints.Points, UserID: actorID,
FLColor: input.FLPoints.Color, Username: actorUsername,
FLTextColor: input.FLPoints.TextColor, Action: model.ActionLeaderboardUpdate,
Details: details,
}); logErr != nil {
logging.Error("Failed to log leaderboard update: %v", logErr)
} }
}()
// Build drivers return result, nil
drivers := make([]model.LeaderboardDriver, len(input.Drivers))
driverIDs := make([]uuid.UUID, len(input.Drivers))
for i, d := range input.Drivers {
id := uuid.New()
driverIDs[i] = id
drivers[i] = model.LeaderboardDriver{
ID: id,
Name: d.Name,
Initials: d.Initials,
Color: d.Color,
Position: i,
}
}
lb.Drivers = drivers
// Build point rows
pointRows := make([]model.LeaderboardPointRow, len(input.PointsTable))
for i, p := range input.PointsTable {
pointRows[i] = model.LeaderboardPointRow{
Label: p.Label,
Points: p.Points,
Color: p.Color,
TextColor: p.TextColor,
Priority: p.Priority,
}
}
lb.PointRows = pointRows
// Build races
races := make([]model.LeaderboardRace, len(input.Tracks))
for i, t := range input.Tracks {
results := make([]model.LeaderboardResult, 0, len(t.Results))
for j, score := range t.Results {
if j >= len(driverIDs) {
break
}
scoreStr := fmt.Sprintf("%v", score)
results = append(results, model.LeaderboardResult{
DriverID: driverIDs[j],
Score: scoreStr,
})
}
races[i] = model.LeaderboardRace{
Name: t.Name,
Position: i,
FastestLapInitials: t.FastestLapInitials,
Results: results,
}
}
lb.Races = races
return lb, nil
} }

0
local/service/lookup.go Normal file → Executable file
View File

0
local/service/membership.go Normal file → Executable file
View File

View File

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

143
local/service/server.go Normal file → Executable file
View File

@@ -2,9 +2,11 @@ package service
import ( import (
"acc-server-manager/local/model" "acc-server-manager/local/model"
"acc-server-manager/local/platform"
"acc-server-manager/local/repository" "acc-server-manager/local/repository"
"acc-server-manager/local/utl/env" "acc-server-manager/local/utl/env"
"acc-server-manager/local/utl/logging" "acc-server-manager/local/utl/logging"
"acc-server-manager/local/utl/network"
"acc-server-manager/local/utl/tracking" "acc-server-manager/local/utl/tracking"
"context" "context"
"fmt" "fmt"
@@ -12,8 +14,6 @@ import (
"sync" "sync"
"time" "time"
"acc-server-manager/local/utl/network"
"github.com/gofiber/fiber/v2" "github.com/gofiber/fiber/v2"
"github.com/google/uuid" "github.com/google/uuid"
) )
@@ -26,17 +26,19 @@ const (
type ServerService struct { type ServerService struct {
repository *repository.ServerRepository repository *repository.ServerRepository
stateHistoryRepo *repository.StateHistoryRepository stateHistoryRepo *repository.StateHistoryRepository
activityLogRepo *repository.ActivityLogRepository
apiService *ServiceControlService apiService *ServiceControlService
configService *ConfigService configService *ConfigService
steamService *SteamService steamService *SteamService
windowsService *WindowsService runtime platform.ServerRuntime
firewallService *FirewallService firewall platform.FirewallManager
logStreamer platform.LogStreamer
portPool *network.PortPoolManager
webSocketService *WebSocketService webSocketService *WebSocketService
instances sync.Map // Track instances per server instances sync.Map // uuid.UUID → *tracking.AccServerInstance
lastInsertTimes sync.Map // Track last insert time per server lastInsertTimes sync.Map // uuid.UUID → time.Time
debouncers sync.Map // Track debounce timers per server debouncers sync.Map // uuid.UUID → *pendingState
logTailers sync.Map // Track log tailers per server sessionIDs sync.Map // uuid.UUID → uuid.UUID
sessionIDs sync.Map // Track current session ID per server
} }
type pendingState struct { type pendingState struct {
@@ -45,37 +47,37 @@ type pendingState struct {
} }
func (s *ServerService) ensureLogTailing(server *model.Server, instance *tracking.AccServerInstance) { func (s *ServerService) ensureLogTailing(server *model.Server, instance *tracking.AccServerInstance) {
if _, exists := s.logTailers.Load(server.ID); exists {
return
}
go func() { go func() {
logPath := filepath.Join(server.GetLogPath(), "server.log") if err := s.logStreamer.Start(context.Background(), server, instance.HandleLogLine); err != nil {
tailer := tracking.NewLogTailer(logPath, instance.HandleLogLine) logging.Error("Failed to start log streaming for server %s: %v", server.ID, err)
s.logTailers.Store(server.ID, tailer) }
tailer.Start()
}() }()
} }
func NewServerService( func NewServerService(
repository *repository.ServerRepository, repository *repository.ServerRepository,
stateHistoryRepo *repository.StateHistoryRepository, stateHistoryRepo *repository.StateHistoryRepository,
activityLogRepo *repository.ActivityLogRepository,
apiService *ServiceControlService, apiService *ServiceControlService,
configService *ConfigService, configService *ConfigService,
steamService *SteamService, steamService *SteamService,
windowsService *WindowsService, runtime platform.ServerRuntime,
firewallService *FirewallService, firewall platform.FirewallManager,
logStreamer platform.LogStreamer,
portPool *network.PortPoolManager,
webSocketService *WebSocketService, webSocketService *WebSocketService,
) *ServerService { ) *ServerService {
service := &ServerService{ service := &ServerService{
repository: repository, repository: repository,
stateHistoryRepo: stateHistoryRepo, stateHistoryRepo: stateHistoryRepo,
activityLogRepo: activityLogRepo,
apiService: apiService, apiService: apiService,
configService: configService, configService: configService,
steamService: steamService, steamService: steamService,
windowsService: windowsService, runtime: runtime,
firewallService: firewallService, firewall: firewall,
logStreamer: logStreamer,
portPool: portPool,
webSocketService: webSocketService, webSocketService: webSocketService,
} }
@@ -195,10 +197,20 @@ func (s *ServerService) updateSessionDuration(server *model.Server, sessionType
} }
func (s *ServerService) GenerateServerPath(server *model.Server) { func (s *ServerService) GenerateServerPath(server *model.Server) {
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() steamCMDPath := env.GetSteamCMDDirPath()
server.Path = server.GenerateServerPath(steamCMDPath) server.Path = server.GenerateServerPath(steamCMDPath)
server.FromSteamCMD = true server.FromSteamCMD = true
} }
}
func (s *ServerService) handleStateChange(server *model.Server, state *model.ServerState) { func (s *ServerService) handleStateChange(server *model.Server, state *model.ServerState) {
s.updateSessionDuration(server, state.Session) s.updateSessionDuration(server, state.Session)
@@ -250,9 +262,6 @@ func (s *ServerService) StartAccServerRuntime(server *model.Server) {
s.ensureLogTailing(server, instance) 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) { func (s *ServerService) GetAll(ctx *fiber.Ctx, filter *model.ServerFilter) (*[]model.Server, error) {
servers, err := s.repository.GetAll(ctx.UserContext(), filter) servers, err := s.repository.GetAll(ctx.UserContext(), filter)
if err != nil { if err != nil {
@@ -260,6 +269,16 @@ func (s *ServerService) GetAll(ctx *fiber.Ctx, filter *model.ServerFilter) (*[]m
return nil, err return nil, err
} }
// Collect server IDs for a single last-activity lookup.
serverIDs := make([]uuid.UUID, len(*servers))
for i, srv := range *servers {
serverIDs[i] = srv.ID
}
lastActivities, laErr := s.activityLogRepo.GetLastActivityByServerIDs(ctx.UserContext(), serverIDs)
if laErr != nil {
logging.Error("Failed to get last activities for server list: %v", laErr)
}
for i := range *servers { for i := range *servers {
server := &(*servers)[i] server := &(*servers)[i]
status, err := s.apiService.GetCachedStatus(server.ServiceName) status, err := s.apiService.GetCachedStatus(server.ServiceName)
@@ -276,14 +295,16 @@ func (s *ServerService) GetAll(ctx *fiber.Ctx, filter *model.ServerFilter) (*[]m
server.State = serverInstance.State server.State = serverInstance.State
} }
} }
if lastActivities != nil {
if info, ok := lastActivities[server.ID]; ok {
server.LastActivity = info
}
}
} }
return servers, nil return servers, nil
} }
// context.Context: Application context
// Returns:
// string: Application version
func (as *ServerService) GetById(ctx *fiber.Ctx, serverID uuid.UUID) (*model.Server, error) { func (as *ServerService) GetById(ctx *fiber.Ctx, serverID uuid.UUID) (*model.Server, error) {
server, err := as.repository.GetByID(ctx.UserContext(), serverID) server, err := as.repository.GetByID(ctx.UserContext(), serverID)
if err != nil { if err != nil {
@@ -291,7 +312,7 @@ func (as *ServerService) GetById(ctx *fiber.Ctx, serverID uuid.UUID) (*model.Ser
} }
status, err := as.apiService.GetCachedStatus(server.ServiceName) status, err := as.apiService.GetCachedStatus(server.ServiceName)
if err != nil { if err != nil {
logging.Error(err.Error()) logging.Error("Failed to get cached status: %v", err)
} }
server.Status = model.ParseServiceStatus(status) server.Status = model.ParseServiceStatus(status)
instance, ok := as.instances.Load(server.ID) instance, ok := as.instances.Load(server.ID)
@@ -377,12 +398,24 @@ func (s *ServerService) createServerBackground(ctx context.Context, server *mode
important: true, important: true,
description: "", description: "",
callback: func() (string, error) { callback: func() (string, error) {
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) ports, err := network.FindAvailablePortRange(DefaultStartPort, RequiredPortCount)
if err != nil { if err != nil {
return "", fmt.Errorf("failed to find available ports: %v", err) 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 { if err := s.updateServerPort(server, serverPort); err != nil {
return "", fmt.Errorf("failed to update server configuration: %v", err) return "", fmt.Errorf("failed to update server configuration: %v", err)
@@ -396,12 +429,11 @@ func (s *ServerService) createServerBackground(ctx context.Context, server *mode
important: true, important: true,
description: "", description: "",
callback: func() (string, error) { callback: func() (string, error) {
execPath := filepath.Join(server.GetServerPath(), "accServer.exe") server.Platform = env.GetPlatform()
serverWorkingDir := filepath.Join(server.GetServerPath(), "server") if err := s.runtime.Create(ctx, server); err != nil {
if err := s.windowsService.CreateService(ctx, server.ServiceName, execPath, serverWorkingDir, nil); err != nil { return "", fmt.Errorf("failed to create server instance: %v", err)
return "", fmt.Errorf("failed to create Windows service: %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 +441,12 @@ func (s *ServerService) createServerBackground(ctx context.Context, server *mode
important: false, important: false,
description: "", description: "",
callback: func() (string, error) { callback: func() (string, error) {
s.configureFirewall(server)
tcpPorts = []int{serverPort} tcpPorts = []int{serverPort}
udpPorts = []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.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 +500,18 @@ func (s *ServerService) rollbackSteps(ctx context.Context, server *model.Server,
s.repository.Delete(ctx, server.ID) s.repository.Delete(ctx, server.ID)
case model.StepFirewallRules: case model.StepFirewallRules:
if len(tcpPorts) > 0 && len(udpPorts) > 0 { if len(tcpPorts) > 0 && len(udpPorts) > 0 {
s.firewallService.DeleteServerRules(server.ServiceName, tcpPorts, udpPorts) s.firewall.DeleteServerRules(server.ServiceName, tcpPorts, udpPorts)
} }
case model.StepServiceCreation: case model.StepServiceCreation:
s.windowsService.DeleteService(ctx, server.ServiceName) s.runtime.Delete(ctx, server.ID)
case model.StepSteamDownload: case model.StepSteamDownload:
s.steamService.UninstallServer(server.Path) 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 +522,35 @@ func (s *ServerService) DeleteServer(ctx *fiber.Ctx, serverID uuid.UUID) error {
return fmt.Errorf("failed to get server details: %v", err) return fmt.Errorf("failed to get server details: %v", err)
} }
if err := s.windowsService.DeleteService(ctx.UserContext(), server.ServiceName); err != nil { if err := s.runtime.Delete(ctx.UserContext(), server.ID); err != nil {
logging.Error("Failed to delete Windows service: %v", err) logging.Error("Failed to delete server instance: %v", err)
} }
configuration, err := s.configService.GetConfiguration(server) configuration, err := s.configService.GetConfiguration(server)
if err != nil { if err != nil {
logging.Error("Failed to get configuration for server %d: %v", server.ID, err) logging.Error("Failed to get configuration for server %d: %v", server.ID, err)
} }
if configuration != nil {
tcpPorts := []int{configuration.TcpPort.ToInt()} tcpPorts := []int{configuration.TcpPort.ToInt()}
udpPorts := []int{configuration.UdpPort.ToInt()} udpPorts := []int{configuration.UdpPort.ToInt()}
if err := s.firewallService.DeleteServerRules(server.ServiceName, tcpPorts, udpPorts); err != nil { if err := s.firewall.DeleteServerRules(server.ServiceName, tcpPorts, udpPorts); err != nil {
logging.Error("Failed to delete firewall rules: %v", err) logging.Error("Failed to delete firewall rules: %v", err)
} }
}
if err := s.steamService.UninstallServer(server.Path); err != nil { if err := s.steamService.UninstallServer(server.Path); err != nil {
logging.Error("Failed to uninstall server: %v", err) 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 { if err := s.repository.Delete(ctx.UserContext(), serverID); err != nil {
return fmt.Errorf("failed to delete server from database: %v", err) return fmt.Errorf("failed to delete server from database: %v", err)
} }
if tailer, exists := s.logTailers.Load(server.ID); exists { s.logStreamer.Stop(server.ID)
tailer.(*tracking.LogTailer).Stop()
s.logTailers.Delete(server.ID)
}
s.instances.Delete(server.ID) s.instances.Delete(server.ID)
s.lastInsertTimes.Delete(server.ID) s.lastInsertTimes.Delete(server.ID)
s.debouncers.Delete(server.ID) s.debouncers.Delete(server.ID)
@@ -533,7 +573,7 @@ func (s *ServerService) configureFirewall(server *model.Server) error {
logging.Info("Configuring firewall for server %d with port %d", server.ID, serverPort) 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) return fmt.Errorf("failed to configure firewall: %v", err)
} }
@@ -552,10 +592,17 @@ func (s *ServerService) updateServerPort(server *model.Server, port int) error {
config.TcpPort = model.IntString(port) config.TcpPort = model.IntString(port)
config.UdpPort = model.IntString(port) config.UdpPort = model.IntString(port)
config.RegisterToLobby = model.IntString(1)
if err := s.configService.SaveConfiguration(server, config); err != nil { if err := s.configService.SaveConfiguration(server, config); err != nil {
return fmt.Errorf("failed to save server configuration: %v", err) 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 return nil
} }

71
local/service/service.go Normal file → Executable file
View File

@@ -1,39 +1,102 @@
package service package service
import ( import (
"acc-server-manager/local/platform"
"acc-server-manager/local/repository" "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/logging"
"acc-server-manager/local/utl/network"
"fmt"
dockerclient "github.com/docker/docker/client"
"go.uber.org/dig" "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) { func InitializeServices(c *dig.Container) {
logging.Debug("Initializing repositories") logging.Debug("Initializing repositories")
repository.InitializeRepositories(c) repository.InitializeRepositories(c)
logging.Debug("Registering services") logging.Debug("Registering services")
c.Provide(NewSteamService) c.Provide(NewSteamService)
c.Provide(NewActivityLogService)
c.Provide(NewServerService) c.Provide(NewServerService)
c.Provide(NewStateHistoryService) c.Provide(NewStateHistoryService)
c.Provide(NewServiceControlService) c.Provide(NewServiceControlService)
c.Provide(NewConfigService) c.Provide(NewConfigService)
c.Provide(NewLookupService) c.Provide(NewLookupService)
c.Provide(NewWindowsService)
c.Provide(NewFirewallService)
c.Provide(NewMembershipService) c.Provide(NewMembershipService)
c.Provide(NewWebSocketService) c.Provide(NewWebSocketService)
c.Provide(NewLeaderboardService) 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") logging.Debug("Initializing service dependencies")
err := c.Invoke(func(server *ServerService, api *ServiceControlService, config *ConfigService) { err := c.Invoke(func(server *ServerService, api *ServiceControlService, config *ConfigService) {
logging.Debug("Setting up service cross-references") logging.Debug("Setting up service cross-references")
api.SetServerService(server) api.SetServerService(server)
config.SetServerService(server) config.SetServerService(server)
}) })
if err != nil { if err != nil {
logging.Panic("unable to initialize services: " + err.Error()) logging.Panic("unable to initialize services: " + err.Error())
} }
logging.Debug("Completed service initialization") 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)
})
}

128
local/service/service_control.go Normal file → Executable file
View File

@@ -2,6 +2,7 @@ package service
import ( import (
"acc-server-manager/local/model" "acc-server-manager/local/model"
"acc-server-manager/local/platform"
"acc-server-manager/local/repository" "acc-server-manager/local/repository"
"context" "context"
"errors" "errors"
@@ -14,24 +15,52 @@ type ServiceControlService struct {
repository *repository.ServiceControlRepository repository *repository.ServiceControlRepository
serverRepository *repository.ServerRepository serverRepository *repository.ServerRepository
serverService *ServerService serverService *ServerService
activityLog *ActivityLogService
statusCache *model.ServerStatusCache statusCache *model.ServerStatusCache
windowsService *WindowsService runtime platform.ServerRuntime
} }
func NewServiceControlService(repository *repository.ServiceControlRepository, func NewServiceControlService(
serverRepository *repository.ServerRepository) *ServiceControlService { repository *repository.ServiceControlRepository,
serverRepository *repository.ServerRepository,
runtime platform.ServerRuntime,
activityLog *ActivityLogService,
) *ServiceControlService {
return &ServiceControlService{ return &ServiceControlService{
repository: repository, repository: repository,
serverRepository: serverRepository, serverRepository: serverRepository,
runtime: runtime,
activityLog: activityLog,
statusCache: model.NewServerStatusCache(model.CacheConfig{ statusCache: model.NewServerStatusCache(model.CacheConfig{
ExpirationTime: 30 * time.Second, ExpirationTime: 30 * time.Second,
ThrottleTime: 5 * time.Second, ThrottleTime: 5 * time.Second,
DefaultStatus: model.StatusRunning, DefaultStatus: model.StatusRunning,
}), }),
windowsService: NewWindowsService(),
} }
} }
// getServerFromCtx resolves the server from ctx locals (serverId or service name).
func (as *ServiceControlService) getServerFromCtx(ctx *fiber.Ctx) (*model.Server, error) {
serviceName, ok := ctx.Locals("service").(string)
if !ok || serviceName == "" {
serverId, ok2 := ctx.Locals("serverId").(string)
if !ok2 || serverId == "" {
return nil, errors.New("service name missing")
}
return as.serverRepository.GetByID(ctx.UserContext(), serverId)
}
return as.serverRepository.GetFirstByServiceName(ctx.UserContext(), serviceName)
}
func extractActor(ctx *fiber.Ctx) (actorID, actorUsername string) {
actorID, _ = ctx.Locals("userID").(string)
actorUsername, _ = ctx.Locals("actorUsername").(string)
if actorUsername == "" {
actorUsername = "system"
}
return
}
func (as *ServiceControlService) SetServerService(serverService *ServerService) { func (as *ServiceControlService) SetServerService(serverService *ServerService) {
as.serverService = serverService as.serverService = serverService
} }
@@ -57,10 +86,11 @@ func (as *ServiceControlService) GetStatus(ctx *fiber.Ctx) (string, error) {
} }
func (as *ServiceControlService) ServiceControlStartServer(ctx *fiber.Ctx) (string, error) { func (as *ServiceControlService) ServiceControlStartServer(ctx *fiber.Ctx) (string, error) {
serviceName, err := as.GetServiceName(ctx) server, err := as.getServerFromCtx(ctx)
if err != nil { if err != nil {
return "", err return "", err
} }
serviceName := server.ServiceName
as.statusCache.UpdateStatus(serviceName, model.StatusStarting) as.statusCache.UpdateStatus(serviceName, model.StatusStarting)
@@ -75,14 +105,29 @@ func (as *ServiceControlService) ServiceControlStartServer(ctx *fiber.Ctx) (stri
status := model.ParseServiceStatus(statusStr) status := model.ParseServiceStatus(statusStr)
as.statusCache.UpdateStatus(serviceName, status) as.statusCache.UpdateStatus(serviceName, status)
if as.activityLog != nil {
actorID, actorUsername := extractActor(ctx)
go func() {
_ = as.activityLog.Log(context.Background(), &model.ActivityLog{
ServerID: server.ID,
UserID: actorID,
Username: actorUsername,
Action: model.ActionServerStart,
Details: `{"service":"` + serviceName + `"}`,
})
}()
}
return status.String(), nil return status.String(), nil
} }
func (as *ServiceControlService) ServiceControlStopServer(ctx *fiber.Ctx) (string, error) { func (as *ServiceControlService) ServiceControlStopServer(ctx *fiber.Ctx) (string, error) {
serviceName, err := as.GetServiceName(ctx) server, err := as.getServerFromCtx(ctx)
if err != nil { if err != nil {
return "", err return "", err
} }
serviceName := server.ServiceName
as.statusCache.UpdateStatus(serviceName, model.StatusStopping) as.statusCache.UpdateStatus(serviceName, model.StatusStopping)
@@ -97,14 +142,29 @@ func (as *ServiceControlService) ServiceControlStopServer(ctx *fiber.Ctx) (strin
status := model.ParseServiceStatus(statusStr) status := model.ParseServiceStatus(statusStr)
as.statusCache.UpdateStatus(serviceName, status) as.statusCache.UpdateStatus(serviceName, status)
if as.activityLog != nil {
actorID, actorUsername := extractActor(ctx)
go func() {
_ = as.activityLog.Log(context.Background(), &model.ActivityLog{
ServerID: server.ID,
UserID: actorID,
Username: actorUsername,
Action: model.ActionServerStop,
Details: `{"service":"` + serviceName + `"}`,
})
}()
}
return status.String(), nil return status.String(), nil
} }
func (as *ServiceControlService) ServiceControlRestartServer(ctx *fiber.Ctx) (string, error) { func (as *ServiceControlService) ServiceControlRestartServer(ctx *fiber.Ctx) (string, error) {
serviceName, err := as.GetServiceName(ctx) server, err := as.getServerFromCtx(ctx)
if err != nil { if err != nil {
return "", err return "", err
} }
serviceName := server.ServiceName
as.statusCache.UpdateStatus(serviceName, model.StatusRestarting) as.statusCache.UpdateStatus(serviceName, model.StatusRestarting)
@@ -119,11 +179,29 @@ func (as *ServiceControlService) ServiceControlRestartServer(ctx *fiber.Ctx) (st
status := model.ParseServiceStatus(statusStr) status := model.ParseServiceStatus(statusStr)
as.statusCache.UpdateStatus(serviceName, status) as.statusCache.UpdateStatus(serviceName, status)
if as.activityLog != nil {
actorID, actorUsername := extractActor(ctx)
go func() {
_ = as.activityLog.Log(context.Background(), &model.ActivityLog{
ServerID: server.ID,
UserID: actorID,
Username: actorUsername,
Action: model.ActionServerRestart,
Details: `{"service":"` + serviceName + `"}`,
})
}()
}
return status.String(), nil return status.String(), nil
} }
func (as *ServiceControlService) StatusServer(serviceName string) (string, error) { 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) { func (as *ServiceControlService) GetCachedStatus(serviceName string) (string, error) {
@@ -142,44 +220,46 @@ func (as *ServiceControlService) GetCachedStatus(serviceName string) (string, er
} }
func (as *ServiceControlService) StartServer(serviceName string) (string, error) { 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) server, err := as.serverRepository.GetFirstByServiceName(context.Background(), serviceName)
if err != nil { if err != nil {
return "", err return "", err
} }
status, err := as.runtime.Start(context.Background(), server.ID)
if err != nil {
return "", err
}
as.serverService.StartAccServerRuntime(server) as.serverService.StartAccServerRuntime(server)
return status, err return status, err
} }
func (as *ServiceControlService) StopServer(serviceName string) (string, error) { 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) server, err := as.serverRepository.GetFirstByServiceName(context.Background(), serviceName)
if err != nil { if err != nil {
return "", err 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 return status, err
} }
func (as *ServiceControlService) RestartServer(serviceName string) (string, error) { 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) server, err := as.serverRepository.GetFirstByServiceName(context.Background(), serviceName)
if err != nil { if err != nil {
return "", err return "", err
} }
status, err := as.runtime.Restart(context.Background(), server.ID)
if err != nil {
return "", err
}
as.serverService.StartAccServerRuntime(server) as.serverService.StartAccServerRuntime(server)
return status, err return status, err
} }

0
local/service/service_manager.go Normal file → Executable file
View File

0
local/service/state_history.go Normal file → Executable file
View File

29
local/service/steam_service.go Normal file → Executable file
View File

@@ -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) { func (s *SteamService) GetCredentials(ctx context.Context) (*model.SteamCredentials, error) {
return s.repository.GetCurrent(ctx) 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 { 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() steamCMDPath := env.GetSteamCMDPath()
steamCMDDir := filepath.Dir(steamCMDPath) steamCMDDir := filepath.Dir(steamCMDPath)
@@ -92,10 +105,12 @@ func (s *SteamService) InstallServerWithWebSocket(ctx context.Context, installPa
return err return err
} }
if !env.IsDockerPlatform() {
if err := s.pathValidator.ValidateInstallPath(installPath); err != nil { if err := s.pathValidator.ValidateInstallPath(installPath); err != nil {
wsService.BroadcastSteamOutput(*serverID, fmt.Sprintf("Invalid installation path: %v", err), true) wsService.BroadcastSteamOutput(*serverID, fmt.Sprintf("Invalid installation path: %v", err), true)
return fmt.Errorf("invalid installation path: %v", err) return fmt.Errorf("invalid installation path: %v", err)
} }
}
absPath, err := filepath.Abs(installPath) absPath, err := filepath.Abs(installPath)
if err != nil { if err != nil {
@@ -117,7 +132,7 @@ func (s *SteamService) InstallServerWithWebSocket(ctx context.Context, installPa
return fmt.Errorf("failed to get Steam credentials: %v", err) return fmt.Errorf("failed to get Steam credentials: %v", err)
} }
steamCMDPath := env.GetSteamCMDPath() steamCMDPath := getSteamCMDPath()
steamCMDArgs := []string{ steamCMDArgs := []string{
"+force_install_dir", absPath, "+force_install_dir", absPath,
@@ -135,6 +150,10 @@ func (s *SteamService) InstallServerWithWebSocket(ctx context.Context, installPa
steamCMDArgs = append(steamCMDArgs, "anonymous") steamCMDArgs = append(steamCMDArgs, "anonymous")
} }
if env.IsDockerPlatform() {
steamCMDArgs = append(steamCMDArgs, "+@sSteamCmdForcePlatformType", "windows")
}
steamCMDArgs = append(steamCMDArgs, steamCMDArgs = append(steamCMDArgs,
"+app_update", ACCServerAppID, "+app_update", ACCServerAppID,
"validate", "validate",
@@ -216,10 +235,12 @@ func (s *SteamService) InstallServerWithCallbacks(ctx context.Context, installPa
return err return err
} }
if !env.IsDockerPlatform() {
if err := s.pathValidator.ValidateInstallPath(installPath); err != nil { if err := s.pathValidator.ValidateInstallPath(installPath); err != nil {
outputCallback(*serverID, fmt.Sprintf("Invalid installation path: %v", err), true) outputCallback(*serverID, fmt.Sprintf("Invalid installation path: %v", err), true)
return fmt.Errorf("invalid installation path: %v", err) return fmt.Errorf("invalid installation path: %v", err)
} }
}
absPath, err := filepath.Abs(installPath) absPath, err := filepath.Abs(installPath)
if err != nil { if err != nil {
@@ -241,7 +262,7 @@ func (s *SteamService) InstallServerWithCallbacks(ctx context.Context, installPa
return fmt.Errorf("failed to get Steam credentials: %v", err) return fmt.Errorf("failed to get Steam credentials: %v", err)
} }
steamCMDPath := env.GetSteamCMDPath() steamCMDPath := getSteamCMDPath()
steamCMDArgs := []string{ steamCMDArgs := []string{
"+force_install_dir", absPath, "+force_install_dir", absPath,
@@ -259,6 +280,10 @@ func (s *SteamService) InstallServerWithCallbacks(ctx context.Context, installPa
steamCMDArgs = append(steamCMDArgs, "anonymous") steamCMDArgs = append(steamCMDArgs, "anonymous")
} }
if env.IsDockerPlatform() {
steamCMDArgs = append(steamCMDArgs, "+@sSteamCmdForcePlatformType", "windows")
}
steamCMDArgs = append(steamCMDArgs, steamCMDArgs = append(steamCMDArgs,
"+app_update", ACCServerAppID, "+app_update", ACCServerAppID,
"validate", "validate",

13
local/service/websocket.go Normal file → Executable file
View File

@@ -15,6 +15,7 @@ type WebSocketConnection struct {
conn *websocket.Conn conn *websocket.Conn
serverID *uuid.UUID serverID *uuid.UUID
userID *uuid.UUID userID *uuid.UUID
mu sync.Mutex
} }
type WebSocketService struct { type WebSocketService struct {
@@ -131,7 +132,7 @@ func (ws *WebSocketService) broadcastToServer(serverID uuid.UUID, message model.
ws.connections.Range(func(key, value interface{}) bool { ws.connections.Range(func(key, value interface{}) bool {
if wsConn, ok := value.(*WebSocketConnection); ok { if wsConn, ok := value.(*WebSocketConnection); ok {
if wsConn.serverID != nil && *wsConn.serverID == serverID { 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) logging.Error("Failed to send WebSocket message to connection %s: %v", key, err)
ws.RemoveConnection(key.(string)) ws.RemoveConnection(key.(string))
} else { } 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) { if !sentToAssociatedConnections && (message.Type == model.MessageTypeStep || message.Type == model.MessageTypeError || message.Type == model.MessageTypeComplete) {
ws.connections.Range(func(key, value interface{}) bool { ws.connections.Range(func(key, value interface{}) bool {
if wsConn, ok := value.(*WebSocketConnection); ok { 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) logging.Error("Failed to send WebSocket message to connection %s: %v", key, err)
ws.RemoveConnection(key.(string)) 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 { ws.connections.Range(func(key, value interface{}) bool {
if wsConn, ok := value.(*WebSocketConnection); ok { if wsConn, ok := value.(*WebSocketConnection); ok {
if wsConn.userID != nil && *wsConn.userID == userID { 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) logging.Error("Failed to send WebSocket message to connection %s: %v", key, err)
ws.RemoveConnection(key.(string)) 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 { func (ws *WebSocketService) GetActiveConnections() int {
count := 0 count := 0
ws.connections.Range(func(key, value interface{}) bool { ws.connections.Range(func(key, value interface{}) bool {

View File

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

0
local/service/windows_service.go Normal file → Executable file
View File

0
local/utl/audit/audit.go Normal file → Executable file
View File

0
local/utl/cache/cache.go vendored Normal file → Executable file
View File

29
local/utl/command/callback_executor.go Normal file → Executable file
View File

@@ -123,11 +123,12 @@ func (e *CallbackInteractiveCommandExecutor) monitorOutputWithCallbacks(ctx cont
stdoutScanner := bufio.NewScanner(stdout) stdoutScanner := bufio.NewScanner(stdout)
stderrScanner := bufio.NewScanner(stderr) stderrScanner := bufio.NewScanner(stderr)
outputChan := make(chan outputLine, 100) outputChan := make(chan outputLine, 10000)
readersDone := make(chan struct{}, 2) readersDone := make(chan struct{}, 2)
steamConsoleStarted := false steamConsoleStarted := false
tfaRequestCreated := false tfaRequestCreated := false
tfaAutoCompleted := false
go func() { go func() {
defer func() { readersDone <- struct{}{} }() defer func() { readersDone <- struct{}{} }()
@@ -182,13 +183,14 @@ func (e *CallbackInteractiveCommandExecutor) monitorOutputWithCallbacks(ctx cont
if readersFinished == 2 { if readersFinished == 2 {
close(outputChan) close(outputChan)
for lineData := range outputChan { for lineData := range outputChan {
if e.is2FAPrompt(lineData.text) { if e.is2FAPrompt(lineData.text) && !tfaRequestCreated {
if err := e.handle2FAPrompt(ctx, lineData.text, serverID); err != nil { tfaRequestCreated = true
go func(prompt string) {
if err := e.handle2FAPrompt(ctx, prompt, serverID); err != nil {
logging.Error("Failed to handle 2FA prompt: %v", err) logging.Error("Failed to handle 2FA prompt: %v", err)
e.callbacks.OnOutput(e.serverID, fmt.Sprintf("Failed to handle 2FA prompt: %v", err), true) e.callbacks.OnOutput(e.serverID, fmt.Sprintf("Failed to handle 2FA prompt: %v", err), true)
done <- err
return
} }
}(lineData.text)
} }
} }
return return
@@ -207,33 +209,34 @@ func (e *CallbackInteractiveCommandExecutor) monitorOutputWithCallbacks(ctx cont
if e.is2FAPrompt(lineData.text) { if e.is2FAPrompt(lineData.text) {
if !tfaRequestCreated { if !tfaRequestCreated {
tfaRequestCreated = true
e.callbacks.OnOutput(e.serverID, "2FA prompt detected - waiting for user confirmation", false) e.callbacks.OnOutput(e.serverID, "2FA prompt detected - waiting for user confirmation", false)
if err := e.handle2FAPrompt(ctx, lineData.text, serverID); err != nil { go func(prompt string) {
if err := e.handle2FAPrompt(ctx, prompt, serverID); err != nil {
logging.Error("Failed to handle 2FA prompt: %v", err) logging.Error("Failed to handle 2FA prompt: %v", err)
e.callbacks.OnOutput(e.serverID, fmt.Sprintf("Failed to handle 2FA prompt: %v", err), true) e.callbacks.OnOutput(e.serverID, fmt.Sprintf("Failed to handle 2FA prompt: %v", err), true)
done <- err
return
} }
tfaRequestCreated = 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") 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.callbacks.OnOutput(e.serverID, "Steam CMD continued after 2FA confirmation", false)
e.autoCompletePendingRequests(serverID) e.autoCompletePendingRequests(serverID)
} }
case <-time.After(15 * time.Second): case <-time.After(15 * time.Second):
if steamConsoleStarted && !tfaRequestCreated { if steamConsoleStarted && !tfaRequestCreated {
tfaRequestCreated = true
logging.Info("Steam Console started but no output for 15 seconds - likely waiting for Steam Guard 2FA") 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) e.callbacks.OnOutput(e.serverID, "Waiting for Steam Guard 2FA confirmation...", false)
go func() {
if err := e.handle2FAPrompt(ctx, "Steam CMD appears to be waiting for Steam Guard confirmation after startup", serverID); err != nil { 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) 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) e.callbacks.OnOutput(e.serverID, fmt.Sprintf("Failed to handle Steam Guard 2FA prompt: %v", err), true)
done <- err
return
} }
tfaRequestCreated = true }()
} }
} }
} }

0
local/utl/command/callbacks.go Normal file → Executable file
View File

0
local/utl/command/executor.go Normal file → Executable file
View File

59
local/utl/command/interactive_executor.go Normal file → Executable file
View File

@@ -106,11 +106,12 @@ func (e *InteractiveCommandExecutor) monitorOutput(ctx context.Context, stdout,
stdoutScanner := bufio.NewScanner(stdout) stdoutScanner := bufio.NewScanner(stdout)
stderrScanner := bufio.NewScanner(stderr) stderrScanner := bufio.NewScanner(stderr)
outputChan := make(chan string, 100) outputChan := make(chan string, 10000)
readersDone := make(chan struct{}, 2) readersDone := make(chan struct{}, 2)
steamConsoleStarted := false steamConsoleStarted := false
tfaRequestCreated := false tfaRequestCreated := false
tfaAutoCompleted := false
go func() { go func() {
defer func() { readersDone <- struct{}{} }() defer func() { readersDone <- struct{}{} }()
@@ -165,12 +166,13 @@ func (e *InteractiveCommandExecutor) monitorOutput(ctx context.Context, stdout,
if readersFinished == 2 { if readersFinished == 2 {
close(outputChan) close(outputChan)
for line := range outputChan { for line := range outputChan {
if e.is2FAPrompt(line) { if e.is2FAPrompt(line) && !tfaRequestCreated {
if err := e.handle2FAPrompt(ctx, line, serverID); err != nil { tfaRequestCreated = true
go func(prompt string) {
if err := e.handle2FAPrompt(ctx, prompt, serverID); err != nil {
logging.Error("Failed to handle 2FA prompt: %v", err) logging.Error("Failed to handle 2FA prompt: %v", err)
done <- err
return
} }
}(line)
} }
} }
return return
@@ -188,28 +190,29 @@ func (e *InteractiveCommandExecutor) monitorOutput(ctx context.Context, stdout,
if e.is2FAPrompt(line) { if e.is2FAPrompt(line) {
if !tfaRequestCreated { 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 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") logging.Info("Steam CMD appears to have continued after 2FA confirmation - auto-completing 2FA request")
tfaAutoCompleted = true
e.autoCompletePendingRequests(serverID) e.autoCompletePendingRequests(serverID)
} }
case <-time.After(15 * time.Second): case <-time.After(15 * time.Second):
if steamConsoleStarted && !tfaRequestCreated { if steamConsoleStarted && !tfaRequestCreated {
tfaRequestCreated = true
logging.Info("Steam Console started but no output for 15 seconds - likely waiting for Steam Guard 2FA") 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 { 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) logging.Error("Failed to handle Steam Guard 2FA prompt: %v", err)
done <- err
return
} }
tfaRequestCreated = true }()
} else if !steamConsoleStarted { } else if !steamConsoleStarted {
logging.Info("No output for 15 seconds (Steam Console not yet started)") 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 { func (e *InteractiveCommandExecutor) is2FAPrompt(line string) bool {
twoFAKeywords := []string{ twoFAKeywords := []string{
"please enter your steam guard code", "please enter your steam guard code",
"steam guard", "steam guard code",
"two-factor", "two-factor authentication",
"authentication code",
"please check your steam mobile app", "please check your steam mobile app",
"confirm in application",
"enter the current code from your steam mobile app", "enter the current code from your steam mobile app",
"steam guard mobile authenticator", "steam guard mobile authenticator",
"waiting for user info",
"login failure",
"two factor code required", "two factor code required",
"enter steam guard code", "enter steam guard code",
"mobile authenticator code", "mobile authenticator code",
"authenticator app", "2fa prompt detected",
"guard code",
"mobile app",
"confirmation required",
} }
lowerLine := strings.ToLower(line) 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 return false
} }

Some files were not shown because too many files have changed in this diff Show More