Compare commits

..

10 Commits

Author SHA1 Message Date
a6f67dd652 fix: use ring buffer for log reading 2026-03-22 13:39:31 +01:00
e637b5e649 feat: add server log endpoint and streaming 2026-03-22 13:38:00 +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
146 changed files with 1506 additions and 189 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=

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

@@ -33,6 +33,8 @@ 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"),
Log: serverIdGroup.Group("/log"),
} }
accessKeyMiddleware := middleware.NewAccessKeyMiddleware() accessKeyMiddleware := middleware.NewAccessKeyMiddleware()

0
local/controller/config.go Normal file → Executable file
View 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(NewLogController)
if err != nil {
logging.Panic("unable to initialize log controller")
}
} }

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

62
local/controller/log.go Normal file
View File

@@ -0,0 +1,62 @@
package controller
import (
"acc-server-manager/local/middleware"
"acc-server-manager/local/model"
"acc-server-manager/local/service"
"acc-server-manager/local/utl/common"
"acc-server-manager/local/utl/error_handler"
"strconv"
"github.com/gofiber/fiber/v2"
"github.com/google/uuid"
)
type LogController struct {
service *service.ServerService
errorHandler *error_handler.ControllerErrorHandler
}
func NewLogController(ss *service.ServerService, routeGroups *common.RouteGroups, auth *middleware.AuthMiddleware) *LogController {
lc := &LogController{
service: ss,
errorHandler: error_handler.NewControllerErrorHandler(),
}
logRoutes := routeGroups.Log
logRoutes.Use(auth.Authenticate)
logRoutes.Get("/", auth.HasPermission(model.ServerView), lc.GetLogLines)
return lc
}
// GetLogLines godoc
// @Summary Get last N lines of server log
// @Tags Server
// @Param id path string true "Server ID"
// @Param lines query int false "Number of lines to return (default 100)"
// @Success 200 {object} object{lines=[]string}
// @Security BearerAuth
// @Router /server/{id}/log [get]
func (lc *LogController) GetLogLines(c *fiber.Ctx) error {
serverIDStr := c.Params("id")
serverID, err := uuid.Parse(serverIDStr)
if err != nil {
return lc.errorHandler.HandleUUIDError(c, "server ID")
}
n, _ := strconv.Atoi(c.Query("lines", "100"))
if n <= 0 {
n = 100
}
if n > 10000 {
n = 10000
}
lines, err := lc.service.GetLastLogLines(c, serverID, n)
if err != nil {
return lc.errorHandler.HandleServiceError(c, err)
}
return c.JSON(fiber.Map{"lines": lines})
}

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

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

@@ -178,6 +178,23 @@ func (m *AuthMiddleware) HasPermission(requiredPermission string) fiber.Handler
} }
} }
func (m *AuthMiddleware) RequireSuperAdmin() fiber.Handler {
return func(ctx *fiber.Ctx) error {
if os.Getenv("TESTING_ENV") == "true" {
return ctx.Next()
}
userInfo, ok := ctx.Locals("userInfo").(*CachedUserInfo)
if !ok || userInfo.RoleName != "Super Admin" {
return ctx.Status(fiber.StatusForbidden).JSON(fiber.Map{
"error": "Forbidden",
})
}
return ctx.Next()
}
}
func (m *AuthMiddleware) AuthRateLimit() fiber.Handler { 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
}

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

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

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

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

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

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

@@ -42,6 +42,8 @@ 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"`
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

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

@@ -33,6 +33,7 @@ const (
MessageTypeSteamOutput WebSocketMessageType = "steam_output" MessageTypeSteamOutput WebSocketMessageType = "steam_output"
MessageTypeError WebSocketMessageType = "error" MessageTypeError WebSocketMessageType = "error"
MessageTypeComplete WebSocketMessageType = "complete" MessageTypeComplete WebSocketMessageType = "complete"
MessageTypeLogLine WebSocketMessageType = "log_line"
) )
type WebSocketMessage struct { type WebSocketMessage struct {
@@ -65,6 +66,10 @@ type CompleteMessage struct {
Message string `json:"message"` Message string `json:"message"`
} }
type LogLineMessage struct {
Line string `json:"line"`
}
func GetStepDescription(step ServerCreationStep) string { func GetStepDescription(step ServerCreationStep) string {
descriptions := map[ServerCreationStep]string{ descriptions := map[ServerCreationStep]string{
StepValidation: "Validating server configuration", StepValidation: "Validating server configuration",

View File

@@ -0,0 +1,29 @@
package platform
import (
"acc-server-manager/local/model"
"context"
"github.com/google/uuid"
)
type ServerRuntime interface {
Create(ctx context.Context, server *model.Server) error
Delete(ctx context.Context, serverID uuid.UUID) error
Start(ctx context.Context, serverID uuid.UUID) (string, error)
Stop(ctx context.Context, serverID uuid.UUID) (string, error)
Restart(ctx context.Context, serverID uuid.UUID) (string, error)
Status(ctx context.Context, serverID uuid.UUID) (string, error)
}
type FirewallManager interface {
CreateServerRules(serverName string, tcpPorts, udpPorts []int) error
DeleteServerRules(serverName string, tcpPorts, udpPorts []int) error
UpdateServerRules(serverName string, tcpPorts, udpPorts []int) error
}
type LogStreamer interface {
Start(ctx context.Context, server *model.Server, handleLine func(string)) error
Stop(serverID uuid.UUID)
GetLastLines(ctx context.Context, server *model.Server, n int) ([]string, error)
}

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

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

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

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

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

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

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

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

@@ -476,6 +476,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,106 @@
package service
import (
"acc-server-manager/local/model"
"acc-server-manager/local/utl/logging"
"bufio"
"context"
"io"
"strconv"
"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)()
}
}
func (s *DockerLogStreamer) GetLastLines(ctx context.Context, server *model.Server, n int) ([]string, error) {
if n <= 0 || server.ContainerID == "" {
return []string{}, nil
}
rc, err := s.client.ContainerLogs(ctx, server.ContainerID, types.ContainerLogsOptions{
ShowStdout: true,
ShowStderr: true,
Tail: strconv.Itoa(n),
})
if err != nil {
return nil, err
}
defer rc.Close()
pr, pw := io.Pipe()
go func() {
defer pw.Close()
if _, err := stdcopy.StdCopy(pw, pw, rc); err != nil && err != io.EOF {
logging.Warn("DockerLogStreamer.GetLastLines: stdcopy error for %s: %v", server.ServiceName, err)
}
}()
var lines []string
scanner := bufio.NewScanner(pr)
for scanner.Scan() {
lines = append(lines, scanner.Text())
}
return lines, scanner.Err()
}

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,77 @@
package service
import (
"acc-server-manager/local/model"
"acc-server-manager/local/utl/tracking"
"bufio"
"context"
"os"
"path/filepath"
"sync"
"github.com/google/uuid"
)
type FileLogStreamer struct {
tailers sync.Map // uuid.UUID → *tracking.LogTailer
}
func NewFileLogStreamer() *FileLogStreamer {
return &FileLogStreamer{}
}
func (s *FileLogStreamer) Start(_ context.Context, server *model.Server, handleLine func(string)) error {
if _, exists := s.tailers.Load(server.ID); exists {
return nil
}
logPath := filepath.Join(server.GetLogPath(), "server.log")
tailer := tracking.NewLogTailer(logPath, handleLine)
s.tailers.Store(server.ID, tailer)
go tailer.Start()
return nil
}
func (s *FileLogStreamer) Stop(serverID uuid.UUID) {
if v, exists := s.tailers.LoadAndDelete(serverID); exists {
v.(*tracking.LogTailer).Stop()
}
}
func (s *FileLogStreamer) GetLastLines(_ context.Context, server *model.Server, n int) ([]string, error) {
if n <= 0 {
return []string{}, nil
}
logPath := filepath.Join(server.GetLogPath(), "server.log")
f, err := os.Open(logPath)
if err != nil {
if os.IsNotExist(err) {
return []string{}, nil
}
return nil, err
}
defer f.Close()
ring := make([]string, n)
head := 0
count := 0
scanner := bufio.NewScanner(f)
for scanner.Scan() {
ring[head] = scanner.Text()
head = (head + 1) % n
if count < n {
count++
}
}
if err := scanner.Err(); err != nil {
return nil, err
}
if count < n {
return ring[:count], nil
}
out := make([]string, n)
copy(out, ring[head:])
copy(out[n-head:], ring[:head])
return out, nil
}

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

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

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 }

140
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"
) )
@@ -29,14 +29,15 @@ type ServerService struct {
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,17 +46,26 @@ 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 { go func() {
return handleFn := func(line string) {
instance.HandleLogLine(line)
s.webSocketService.BroadcastLogLine(server.ID, line)
}
if err := s.logStreamer.Start(context.Background(), server, handleFn); err != nil {
logging.Error("Failed to start log streaming for server %s: %v", server.ID, err)
}
}()
} }
go func() { func (s *ServerService) GetLastLogLines(ctx *fiber.Ctx, serverID uuid.UUID, n int) ([]string, error) {
logPath := filepath.Join(server.GetLogPath(), "server.log") server, err := s.repository.GetByID(ctx.UserContext(), serverID)
tailer := tracking.NewLogTailer(logPath, instance.HandleLogLine) if err != nil {
s.logTailers.Store(server.ID, tailer) return nil, err
}
tailer.Start() if n <= 0 {
}() n = 100
}
return s.logStreamer.GetLastLines(ctx.UserContext(), server, n)
} }
func NewServerService( func NewServerService(
@@ -64,8 +74,10 @@ func NewServerService(
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{
@@ -74,8 +86,10 @@ func NewServerService(
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 +209,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 +274,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 {
@@ -281,9 +302,6 @@ func (s *ServerService) GetAll(ctx *fiber.Ctx, filter *model.ServerFilter) (*[]m
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 +309,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 +395,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 +426,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 +438,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 +497,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 +519,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 +570,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 +589,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
} }

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

@@ -1,13 +1,22 @@
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)
@@ -19,21 +28,74 @@ func InitializeServices(c *dig.Container) {
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)
})
}

52
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"
@@ -15,20 +16,23 @@ type ServiceControlService struct {
serverRepository *repository.ServerRepository serverRepository *repository.ServerRepository
serverService *ServerService serverService *ServerService
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,
) *ServiceControlService {
return &ServiceControlService{ return &ServiceControlService{
repository: repository, repository: repository,
serverRepository: serverRepository, serverRepository: serverRepository,
runtime: runtime,
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(),
} }
} }
@@ -123,7 +127,11 @@ func (as *ServiceControlService) ServiceControlRestartServer(ctx *fiber.Ctx) (st
} }
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 +150,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",

23
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 {
@@ -102,6 +103,16 @@ func (ws *WebSocketService) BroadcastError(serverID uuid.UUID, error string, det
ws.broadcastToServer(serverID, wsMsg) ws.broadcastToServer(serverID, wsMsg)
} }
func (ws *WebSocketService) BroadcastLogLine(serverID uuid.UUID, line string) {
msg := model.WebSocketMessage{
Type: model.MessageTypeLogLine,
ServerID: &serverID,
Timestamp: time.Now().Unix(),
Data: model.LogLineMessage{Line: line},
}
ws.broadcastToServer(serverID, msg)
}
func (ws *WebSocketService) BroadcastComplete(serverID uuid.UUID, success bool, message string) { func (ws *WebSocketService) BroadcastComplete(serverID uuid.UUID, success bool, message string) {
completeMsg := model.CompleteMessage{ completeMsg := model.CompleteMessage{
ServerID: serverID, ServerID: serverID,
@@ -131,7 +142,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 +156,7 @@ func (ws *WebSocketService) broadcastToServer(serverID uuid.UUID, message model.
if !sentToAssociatedConnections && (message.Type == model.MessageTypeStep || message.Type == model.MessageTypeError || message.Type == model.MessageTypeComplete) { 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 +176,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 +186,12 @@ func (ws *WebSocketService) BroadcastToUser(userID uuid.UUID, message model.WebS
}) })
} }
func (wc *WebSocketConnection) writeMessage(messageType int, data []byte) error {
wc.mu.Lock()
defer wc.mu.Unlock()
return wc.conn.WriteMessage(messageType, data)
}
func (ws *WebSocketService) GetActiveConnections() int { 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
} }

2
local/utl/common/common.go Normal file → Executable file
View File

@@ -27,6 +27,8 @@ type RouteGroups struct {
System fiber.Router System fiber.Router
WebSocket fiber.Router WebSocket fiber.Router
Leaderboard fiber.Router Leaderboard fiber.Router
Steam fiber.Router
Log fiber.Router
} }
func CheckError(err error) { func CheckError(err error) {

2
local/utl/configs/configs.go Normal file → Executable file
View File

@@ -8,7 +8,7 @@ import (
) )
var ( var (
Version = "0.10.7" Version = "0.12.3"
Prefix = "v1" Prefix = "v1"
Secret string Secret string
SecretCode string SecretCode string

4
local/utl/db/db.go Normal file → Executable file
View File

@@ -76,6 +76,10 @@ func runMigrations(db *gorm.DB) {
logging.Error("Failed to run password security migration: %v", err) logging.Error("Failed to run password security migration: %v", err)
} }
if err := migrations.RunAddPlatformColumnsMigration(db); err != nil {
logging.Error("Failed to run platform columns migration: %v", err)
}
logging.Info("Custom database migrations completed") logging.Info("Custom database migrations completed")
} }

View File

@@ -0,0 +1,12 @@
package docker
import (
dockerclient "github.com/docker/docker/client"
)
func NewDockerClient() (*dockerclient.Client, error) {
return dockerclient.NewClientWithOpts(
dockerclient.FromEnv,
dockerclient.WithAPIVersionNegotiation(),
)
}

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