Compare commits

...

29 Commits

Author SHA1 Message Date
8a96378c64 Merge branch 'feature/config-presets' into develop 2026-03-27 17:44:49 +01:00
f2580b6de0 fix: nullable server id in activity log and fix preset logging 2026-03-26 22:48:59 +01:00
f23f276ee5 fix: preset logging 2026-03-26 22:32:24 +01:00
180555c903 feat: config presets 2026-03-26 21:45:15 +01:00
efc6b72838 bump version
Some checks failed
Release and Deploy / build (push) Failing after 3m41s
Release and Deploy / deploy (push) Has been skipped
2026-03-23 22:14:55 +01:00
0e04da031b fix: resolve empty configs logging 2026-03-23 22:13:19 +01:00
908adf4468 fix: stream only new logs back to client after starting server 2026-03-23 21:31:32 +01:00
6c1e9b236e fix: build errors 2026-03-23 21:18:02 +01:00
f563d52396 Merge branch 'feature/sync-server-name-with-config' into develop 2026-03-23 21:17:09 +01:00
a490a73392 fix: revert debug mode 2026-03-23 21:16:59 +01:00
0ef47ef241 Merge branch 'feature/server-log-endpoint-and-ws-streaming' into develop 2026-03-23 21:13:12 +01:00
ea2e871c9b Merge branch 'feature/activity-log-system' into develop 2026-03-23 21:02:35 +01:00
c2c6543068 Merge branch 'feature/containers' into develop 2026-03-23 21:02:26 +01:00
14aa8a84e5 feat: add logging to existing services 2026-03-22 13:54:43 +01:00
e96b8bca12 feat: add activity logs 2026-03-22 13:52:15 +01:00
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
1100072245 feat: sync server name when updating all configs 2026-03-22 13:22:46 +01:00
49dfa38475 feat: sync server name with config 2026-03-22 13:19:43 +01:00
747adc77a5 version bump
Some checks failed
Release and Deploy / deploy (push) Has been skipped
Release and Deploy / build (push) Failing after 6m7s
2026-03-22 12:24:54 +01:00
c9a32e8889 feat: batch update config endpoint 2026-03-22 12:16:40 +01:00
7d470f3638 update headers 2026-03-05 21:50:05 +01:00
156 changed files with 2752 additions and 219 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,26 @@
#!/bin/bash
set -e
# Use a display number derived from the server port to avoid conflicts
# between containers (each server gets its own unique display).
DISPLAY_NUM=$((SERVER_PORT % 1000 + 100))
export DISPLAY=:${DISPLAY_NUM}
# Clean up any stale X lock from a previous container run.
rm -f /tmp/.X${DISPLAY_NUM}-lock /tmp/.X11-unix/X${DISPLAY_NUM} 2>/dev/null || true
Xvfb ${DISPLAY} -screen 0 1024x768x16 -nolisten tcp &
XVFB_PID=$!
cleanup() {
echo "Stopping ACC server..."
kill $XVFB_PID 2>/dev/null || true
}
trap cleanup SIGTERM SIGINT
mkdir -p /acc/game/log
echo "Starting ACC server on port ${SERVER_PORT}..."
cd /acc/game
exec wine /acc/game/accServer.exe

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=

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

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

View File

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

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

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

View File

@@ -0,0 +1,246 @@
package controller
import (
"acc-server-manager/local/middleware"
"acc-server-manager/local/model"
"acc-server-manager/local/service"
"acc-server-manager/local/utl/common"
"acc-server-manager/local/utl/error_handler"
"acc-server-manager/local/utl/logging"
"github.com/gofiber/fiber/v2"
"github.com/google/uuid"
)
type ConfigPresetController struct {
service *service.ConfigPresetService
apiService *service.ServiceControlService
errorHandler *error_handler.ControllerErrorHandler
}
// NewConfigPresetController initializes ConfigPresetController.
func NewConfigPresetController(s *service.ConfigPresetService, apiService *service.ServiceControlService, routeGroups *common.RouteGroups, auth *middleware.AuthMiddleware) *ConfigPresetController {
c := &ConfigPresetController{
service: s,
apiService: apiService,
errorHandler: error_handler.NewControllerErrorHandler(),
}
presetRoutes := routeGroups.Presets
presetRoutes.Use(auth.Authenticate)
presetRoutes.Get("/", auth.HasPermission(model.PresetView), c.ListPresets)
presetRoutes.Post("/", auth.HasPermission(model.PresetCreate), c.CreatePreset)
presetRoutes.Get("/:presetId", auth.HasPermission(model.PresetView), c.GetPreset)
presetRoutes.Put("/:presetId", auth.HasPermission(model.PresetUpdate), c.UpdatePreset)
presetRoutes.Delete("/:presetId", auth.HasPermission(model.PresetDelete), c.DeletePreset)
configRoutes := routeGroups.Config
configRoutes.Post("/presets/:presetId/apply", auth.HasPermission(model.ConfigUpdate), c.ApplyPreset)
return c
}
// ListPresets returns all config presets.
//
// @Summary List config presets
// @Description Get a list of all saved configuration presets
// @Tags Config Presets
// @Accept json
// @Produce json
// @Param name query string false "Filter by preset name (partial match)"
// @Success 200 {array} model.ConfigPreset "List of presets"
// @Failure 401 {object} error_handler.ErrorResponse "Unauthorized"
// @Failure 403 {object} error_handler.ErrorResponse "Insufficient permissions"
// @Failure 500 {object} error_handler.ErrorResponse "Internal server error"
// @Security BearerAuth
// @Router /presets [get]
func (c *ConfigPresetController) ListPresets(ctx *fiber.Ctx) error {
var filter model.ConfigPresetFilter
if err := common.ParseQueryFilter(ctx, &filter); err != nil {
return c.errorHandler.HandleParsingError(ctx, err)
}
presets, err := c.service.ListPresets(ctx.UserContext(), &filter)
if err != nil {
return c.errorHandler.HandleServiceError(ctx, err)
}
return ctx.JSON(presets)
}
// GetPreset returns a single config preset by ID.
//
// @Summary Get config preset
// @Description Retrieve a single configuration preset by ID
// @Tags Config Presets
// @Accept json
// @Produce json
// @Param presetId path string true "Preset ID (UUID format)"
// @Success 200 {object} model.ConfigPreset "Preset"
// @Failure 400 {object} error_handler.ErrorResponse "Invalid preset ID"
// @Failure 401 {object} error_handler.ErrorResponse "Unauthorized"
// @Failure 403 {object} error_handler.ErrorResponse "Insufficient permissions"
// @Failure 404 {object} error_handler.ErrorResponse "Preset not found"
// @Failure 500 {object} error_handler.ErrorResponse "Internal server error"
// @Security BearerAuth
// @Router /presets/{presetId} [get]
func (c *ConfigPresetController) GetPreset(ctx *fiber.Ctx) error {
id, err := uuid.Parse(ctx.Params("presetId"))
if err != nil {
return c.errorHandler.HandleUUIDError(ctx, "preset ID")
}
preset, err := c.service.GetPreset(ctx.UserContext(), id)
if err != nil {
return c.errorHandler.HandleServiceError(ctx, err)
}
return ctx.JSON(preset)
}
// CreatePreset creates a new config preset.
//
// @Summary Create config preset
// @Description Save a new configuration preset with one or more ACC config sections
// @Tags Config Presets
// @Accept json
// @Produce json
// @Param content body model.ConfigPresetCreateRequest true "Preset data"
// @Success 201 {object} model.ConfigPreset "Created preset"
// @Failure 400 {object} error_handler.ErrorResponse "Invalid request"
// @Failure 401 {object} error_handler.ErrorResponse "Unauthorized"
// @Failure 403 {object} error_handler.ErrorResponse "Insufficient permissions"
// @Failure 500 {object} error_handler.ErrorResponse "Internal server error"
// @Security BearerAuth
// @Router /presets [post]
func (c *ConfigPresetController) CreatePreset(ctx *fiber.Ctx) error {
var req model.ConfigPresetCreateRequest
if err := ctx.BodyParser(&req); err != nil {
return c.errorHandler.HandleParsingError(ctx, err)
}
actorID, _ := ctx.Locals("userID").(string)
actorUsername, _ := ctx.Locals("actorUsername").(string)
if actorUsername == "" {
actorUsername = "system"
}
preset, err := c.service.CreatePreset(ctx.UserContext(), &req, actorID, actorUsername)
if err != nil {
return c.errorHandler.HandleServiceError(ctx, err)
}
return ctx.Status(fiber.StatusCreated).JSON(preset)
}
// UpdatePreset updates an existing config preset.
//
// @Summary Update config preset
// @Description Update name, description, or any config sections of an existing preset
// @Tags Config Presets
// @Accept json
// @Produce json
// @Param presetId path string true "Preset ID (UUID format)"
// @Param content body model.ConfigPresetUpdateRequest true "Fields to update (omit to keep existing)"
// @Success 200 {object} model.ConfigPreset "Updated preset"
// @Failure 400 {object} error_handler.ErrorResponse "Invalid request or preset ID"
// @Failure 401 {object} error_handler.ErrorResponse "Unauthorized"
// @Failure 403 {object} error_handler.ErrorResponse "Insufficient permissions"
// @Failure 404 {object} error_handler.ErrorResponse "Preset not found"
// @Failure 500 {object} error_handler.ErrorResponse "Internal server error"
// @Security BearerAuth
// @Router /presets/{presetId} [put]
func (c *ConfigPresetController) UpdatePreset(ctx *fiber.Ctx) error {
id, err := uuid.Parse(ctx.Params("presetId"))
if err != nil {
return c.errorHandler.HandleUUIDError(ctx, "preset ID")
}
var req model.ConfigPresetUpdateRequest
if err := ctx.BodyParser(&req); err != nil {
return c.errorHandler.HandleParsingError(ctx, err)
}
preset, err := c.service.UpdatePreset(ctx.UserContext(), id, &req)
if err != nil {
return c.errorHandler.HandleServiceError(ctx, err)
}
return ctx.JSON(preset)
}
// DeletePreset deletes a config preset.
//
// @Summary Delete config preset
// @Description Permanently delete a configuration preset
// @Tags Config Presets
// @Accept json
// @Produce json
// @Param presetId path string true "Preset ID (UUID format)"
// @Success 204 "No Content"
// @Failure 400 {object} error_handler.ErrorResponse "Invalid preset ID"
// @Failure 401 {object} error_handler.ErrorResponse "Unauthorized"
// @Failure 403 {object} error_handler.ErrorResponse "Insufficient permissions"
// @Failure 500 {object} error_handler.ErrorResponse "Internal server error"
// @Security BearerAuth
// @Router /presets/{presetId} [delete]
func (c *ConfigPresetController) DeletePreset(ctx *fiber.Ctx) error {
id, err := uuid.Parse(ctx.Params("presetId"))
if err != nil {
return c.errorHandler.HandleUUIDError(ctx, "preset ID")
}
if err := c.service.DeletePreset(ctx.UserContext(), id); err != nil {
return c.errorHandler.HandleServiceError(ctx, err)
}
return ctx.SendStatus(fiber.StatusNoContent)
}
// ApplyPreset applies a saved preset to a server's config files.
//
// @Summary Apply config preset to server
// @Description Apply a saved configuration preset to an ACC server, writing only the sections stored in the preset
// @Tags Server Configuration
// @Accept json
// @Produce json
// @Param id path string true "Server ID (UUID format)"
// @Param presetId path string true "Preset ID (UUID format)"
// @Param restart query bool false "Restart the server after applying the preset"
// @Success 200 {array} model.Config "List of updated config audit records"
// @Failure 400 {object} error_handler.ErrorResponse "Invalid server or preset ID"
// @Failure 401 {object} error_handler.ErrorResponse "Unauthorized"
// @Failure 403 {object} error_handler.ErrorResponse "Insufficient permissions"
// @Failure 404 {object} error_handler.ErrorResponse "Server or preset not found"
// @Failure 500 {object} error_handler.ErrorResponse "Internal server error"
// @Security BearerAuth
// @Router /server/{id}/config/presets/{presetId}/apply [post]
func (c *ConfigPresetController) ApplyPreset(ctx *fiber.Ctx) error {
restart := ctx.QueryBool("restart")
serverID := ctx.Params("id")
if _, err := uuid.Parse(serverID); err != nil {
return c.errorHandler.HandleUUIDError(ctx, "server ID")
}
ctx.Locals("serverId", serverID)
presetID, err := uuid.Parse(ctx.Params("presetId"))
if err != nil {
return c.errorHandler.HandleUUIDError(ctx, "preset ID")
}
actorID, _ := ctx.Locals("userID").(string)
actorUsername, _ := ctx.Locals("actorUsername").(string)
if actorUsername == "" {
actorUsername = "system"
}
configs, err := c.service.ApplyPreset(ctx.UserContext(), serverID, presetID, actorID, actorUsername)
if err != nil {
return c.errorHandler.HandleServiceError(ctx, err)
}
if restart {
if _, err := c.apiService.ServiceControlRestartServer(ctx); err != nil {
logging.ErrorWithContext("PRESET_APPLY_RESTART", "Failed to restart server after preset apply: %v", err)
}
}
return ctx.JSON(configs)
}

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

@@ -35,6 +35,11 @@ func InitializeControllers(c *dig.Container) {
logging.Panic("unable to initialize config controller") logging.Panic("unable to initialize config controller")
} }
err = c.Invoke(NewConfigPresetController)
if err != nil {
logging.Panic("unable to initialize config preset controller")
}
err = c.Invoke(NewServerController) err = c.Invoke(NewServerController)
if err != nil { if err != nil {
logging.Panic("unable to initialize server controller") logging.Panic("unable to initialize server controller")
@@ -64,4 +69,19 @@ func InitializeControllers(c *dig.Container) {
if err != nil { if err != nil {
logging.Panic("unable to initialize leaderboard controller") logging.Panic("unable to initialize leaderboard controller")
} }
err = c.Invoke(NewSteamController)
if err != nil {
logging.Panic("unable to initialize steam controller")
}
err = c.Invoke(NewActivityLogController)
if err != nil {
logging.Panic("unable to initialize activity log controller")
}
err = c.Invoke(NewLogController)
if err != nil {
logging.Panic("unable to initialize log controller")
}
} }

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

@@ -11,6 +11,7 @@ import (
"github.com/google/uuid" "github.com/google/uuid"
) )
type LeaderboardController struct { type LeaderboardController struct {
service *service.LeaderboardService service *service.LeaderboardService
errorHandler *error_handler.ControllerErrorHandler errorHandler *error_handler.ControllerErrorHandler
@@ -60,7 +61,14 @@ func (lc *LeaderboardController) Update(c *fiber.Ctx) error {
return lc.errorHandler.HandleParsingError(c, err) return lc.errorHandler.HandleParsingError(c, err)
} }
data, err := lc.service.Update(c.UserContext(), serverID, &input) userInfo, _ := c.Locals("userInfo").(*middleware.CachedUserInfo)
actorID, actorUsername := "", "system"
if userInfo != nil {
actorID = userInfo.UserID
actorUsername = userInfo.Username
}
data, err := lc.service.Update(c.UserContext(), serverID, &input, actorID, actorUsername)
if err != nil { if err != nil {
return lc.errorHandler.HandleServiceError(c, err) return lc.errorHandler.HandleServiceError(c, err)
} }

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

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

@@ -29,6 +29,7 @@ func NewServiceControlController(as *service.ServiceControlService, routeGroups
} }
serviceRoutes := routeGroups.Server.Group("/:id/service") serviceRoutes := routeGroups.Server.Group("/:id/service")
serviceRoutes.Use(auth.Authenticate)
serviceRoutes.Get("/:service", ac.getStatus) serviceRoutes.Get("/:service", ac.getStatus)
serviceRoutes.Post("/start", ac.startServer) serviceRoutes.Post("/start", ac.startServer)
serviceRoutes.Post("/stop", ac.stopServer) serviceRoutes.Post("/stop", ac.stopServer)

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

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

@@ -0,0 +1,57 @@
package controller
import (
"acc-server-manager/local/middleware"
"acc-server-manager/local/model"
"acc-server-manager/local/service"
"acc-server-manager/local/utl/common"
"acc-server-manager/local/utl/error_handler"
"github.com/gofiber/fiber/v2"
)
type SteamController struct {
steamService *service.SteamService
auth *middleware.AuthMiddleware
errorHandler *error_handler.ControllerErrorHandler
}
func NewSteamController(steamService *service.SteamService, auth *middleware.AuthMiddleware, routeGroups *common.RouteGroups) *SteamController {
sc := &SteamController{
steamService: steamService,
auth: auth,
errorHandler: error_handler.NewControllerErrorHandler(),
}
routeGroups.Steam.Use(auth.Authenticate, auth.RequireSuperAdmin())
routeGroups.Steam.Post("/credentials", sc.SetCredentials)
return sc
}
func (sc *SteamController) SetCredentials(c *fiber.Ctx) error {
type request struct {
Username string `json:"username"`
Password string `json:"password"`
}
var req request
if err := c.BodyParser(&req); err != nil {
return sc.errorHandler.HandleParsingError(c, err)
}
creds := &model.SteamCredentials{
Username: req.Username,
Password: req.Password,
}
if err := creds.Validate(); err != nil {
return sc.errorHandler.HandleServiceError(c, err)
}
if err := sc.steamService.SaveCredentials(c.UserContext(), creds); err != nil {
return sc.errorHandler.HandleServiceError(c, err)
}
return c.Status(fiber.StatusCreated).JSON(fiber.Map{"id": creds.ID})
}

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

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

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

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

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

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

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

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

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

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

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

View File

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

View File

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

View File

@@ -0,0 +1,51 @@
package migrations
import (
"acc-server-manager/local/utl/logging"
"fmt"
"time"
"gorm.io/gorm"
)
type ConfigPresetMigration struct {
ID uint `gorm:"primaryKey"`
Name string `gorm:"not null"`
Description string
Configuration string `gorm:"type:text"`
AssistRules string `gorm:"type:text"`
Event string `gorm:"type:text"`
EventRules string `gorm:"type:text"`
Settings string `gorm:"type:text"`
CreatedAt string
UpdatedAt string
}
func (ConfigPresetMigration) TableName() string {
return "config_presets"
}
func RunAddConfigPresetsMigration(db *gorm.DB) error {
if err := db.AutoMigrate(&MigrationRecord{}); err != nil {
return fmt.Errorf("failed to ensure migration_records table: %v", err)
}
var record MigrationRecord
if err := db.Where("migration_name = ?", "007_add_config_presets").First(&record).Error; err == nil {
logging.Info("Config presets migration already applied, skipping")
return nil
}
record = MigrationRecord{
MigrationName: "007_add_config_presets",
AppliedAt: time.Now().UTC().Format(time.RFC3339),
Success: true,
Notes: "Added ConfigPreset table via GORM AutoMigrate",
}
if err := db.Create(&record).Error; err != nil {
return fmt.Errorf("failed to record migration 007: %v", err)
}
logging.Info("Config presets migration recorded")
return nil
}

View File

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

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

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

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

View File

@@ -0,0 +1,48 @@
package model
import (
"time"
"github.com/google/uuid"
"gorm.io/gorm"
)
type ConfigPreset struct {
ID uuid.UUID `json:"id" gorm:"type:uuid;primary_key;"`
Name string `json:"name" gorm:"not null"`
Description string `json:"description"`
Configuration string `json:"configuration" gorm:"type:text"`
AssistRules string `json:"assistRules" gorm:"type:text"`
Event string `json:"event" gorm:"type:text"`
EventRules string `json:"eventRules" gorm:"type:text"`
Settings string `json:"settings" gorm:"type:text"`
CreatedAt time.Time `json:"createdAt"`
UpdatedAt time.Time `json:"updatedAt"`
}
func (p *ConfigPreset) BeforeCreate(tx *gorm.DB) error {
if p.ID == uuid.Nil {
p.ID = uuid.New()
}
return nil
}
type ConfigPresetCreateRequest struct {
Name string `json:"name"`
Description string `json:"description"`
Configuration *map[string]interface{} `json:"configuration"`
AssistRules *map[string]interface{} `json:"assistRules"`
Event *map[string]interface{} `json:"event"`
EventRules *map[string]interface{} `json:"eventRules"`
Settings *map[string]interface{} `json:"settings"`
}
type ConfigPresetUpdateRequest struct {
Name *string `json:"name"`
Description *string `json:"description"`
Configuration *map[string]interface{} `json:"configuration"`
AssistRules *map[string]interface{} `json:"assistRules"`
Event *map[string]interface{} `json:"event"`
EventRules *map[string]interface{} `json:"eventRules"`
Settings *map[string]interface{} `json:"settings"`
}

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

@@ -30,6 +30,18 @@ type ConfigFilter struct {
ChangedAt time.Time `query:"changed_at" time_format:"2006-01-02T15:04:05Z07:00"` ChangedAt time.Time `query:"changed_at" time_format:"2006-01-02T15:04:05Z07:00"`
} }
type ConfigPresetFilter struct {
BaseFilter
Name string `query:"name"`
}
func (f *ConfigPresetFilter) ApplyFilter(query *gorm.DB) *gorm.DB {
if f.Name != "" {
query = query.Where("name LIKE ?", "%"+f.Name+"%")
}
return query
}
type ServiceControlFilter struct { type ServiceControlFilter struct {
BaseFilter BaseFilter
ServiceControl string `query:"serviceControl"` ServiceControl string `query:"serviceControl"`

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

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

@@ -24,6 +24,11 @@ const (
MembershipCreate = "membership.create" MembershipCreate = "membership.create"
MembershipView = "membership.view" MembershipView = "membership.view"
MembershipEdit = "membership.edit" MembershipEdit = "membership.edit"
PresetView = "preset.view"
PresetCreate = "preset.create"
PresetUpdate = "preset.update"
PresetDelete = "preset.delete"
) )
func AllPermissions() []string { func AllPermissions() []string {
@@ -47,5 +52,9 @@ func AllPermissions() []string {
MembershipCreate, MembershipCreate,
MembershipView, MembershipView,
MembershipEdit, MembershipEdit,
PresetView,
PresetCreate,
PresetUpdate,
PresetDelete,
} }
} }

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

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

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

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

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

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

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

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

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

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

View File

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

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

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

View File

@@ -0,0 +1,17 @@
package repository
import (
"acc-server-manager/local/model"
"gorm.io/gorm"
)
type ConfigPresetRepository struct {
*BaseRepository[model.ConfigPreset, model.ConfigPresetFilter]
}
func NewConfigPresetRepository(db *gorm.DB) *ConfigPresetRepository {
return &ConfigPresetRepository{
BaseRepository: NewBaseRepository[model.ConfigPreset, model.ConfigPresetFilter](db, model.ConfigPreset{}),
}
}

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

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

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

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

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

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

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

View File

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

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

@@ -68,14 +68,16 @@ type ConfigService struct {
repository *repository.ConfigRepository repository *repository.ConfigRepository
serverRepository *repository.ServerRepository serverRepository *repository.ServerRepository
serverService *ServerService serverService *ServerService
activityLog *ActivityLogService
configCache *model.ServerConfigCache configCache *model.ServerConfigCache
} }
func NewConfigService(repository *repository.ConfigRepository, serverRepository *repository.ServerRepository) *ConfigService { func NewConfigService(repository *repository.ConfigRepository, serverRepository *repository.ServerRepository, activityLog *ActivityLogService) *ConfigService {
logging.Debug("Initializing ConfigService with 5m expiration and 1s throttle") logging.Debug("Initializing ConfigService with 5m expiration and 1s throttle")
return &ConfigService{ return &ConfigService{
repository: repository, repository: repository,
serverRepository: serverRepository, serverRepository: serverRepository,
activityLog: activityLog,
configCache: model.NewServerConfigCache(model.CacheConfig{ configCache: model.NewServerConfigCache(model.CacheConfig{
ExpirationTime: 5 * time.Minute, ExpirationTime: 5 * time.Minute,
ThrottleTime: 1 * time.Second, ThrottleTime: 1 * time.Second,
@@ -91,12 +93,104 @@ func (as *ConfigService) SetServerService(serverService *ServerService) {
// context.Context: Application context // context.Context: Application context
// Returns: // Returns:
// string: Application version // string: Application version
func (as *ConfigService) UpdateAllConfigs(ctx *fiber.Ctx, req *model.ConfigurationsUpdateRequest) ([]*model.Config, error) {
serverID := ctx.Locals("serverId").(string)
override := ctx.QueryBool("override", false)
serverUUID, err := uuid.Parse(serverID)
if err != nil {
logging.Error("Invalid server ID format: %v", err)
return nil, fmt.Errorf("invalid server ID format")
}
server, err := as.serverRepository.GetByID(ctx.UserContext(), serverUUID)
if err != nil {
logging.Error("Server not found")
return nil, fmt.Errorf("server not found")
}
type configEntry struct {
fileName string
body *map[string]interface{}
}
entries := []configEntry{
{ConfigurationJson, req.Configuration},
{AssistRulesJson, req.AssistRules},
{EventJson, req.Event},
{EventRulesJson, req.EventRules},
{SettingsJson, req.Settings},
}
actorID, _ := ctx.Locals("userID").(string)
actorUsername, _ := ctx.Locals("actorUsername").(string)
if actorUsername == "" {
actorUsername = "system"
}
var results []*model.Config
for _, entry := range entries {
if entry.body == nil {
continue
}
oldDataUTF8, newData, err := as.updateConfigFiles(ctx.UserContext(), server, entry.fileName, entry.body, override)
if err != nil {
return nil, err
}
if entry.fileName == SettingsJson {
if nameVal, ok := (*entry.body)["serverName"]; ok {
if nameStr, ok := nameVal.(string); ok && nameStr != "" && nameStr != server.Name {
server.Name = nameStr
if err := as.serverRepository.Update(ctx.UserContext(), server); err != nil {
logging.Error("Failed to update server name in DB: %v", err)
}
}
}
}
result := as.repository.UpdateConfig(ctx.UserContext(), &model.Config{
ServerID: serverUUID,
ConfigFile: entry.fileName,
OldConfig: string(oldDataUTF8),
NewConfig: string(newData),
ChangedAt: time.Now(),
})
results = append(results, result)
if as.activityLog != nil {
if details := BuildConfigDetails(entry.fileName, oldDataUTF8, newData); details != "" {
go func(details string) {
_ = as.activityLog.Log(context.Background(), &model.ActivityLog{
ServerID: &serverUUID,
UserID: actorID,
Username: actorUsername,
Action: model.ActionConfigUpdate,
Details: details,
})
}(details)
}
}
}
if len(results) > 0 {
as.configCache.InvalidateServerCache(serverID)
as.serverService.StartAccServerRuntime(server)
}
return results, nil
}
func (as *ConfigService) UpdateConfig(ctx *fiber.Ctx, body *map[string]interface{}) (*model.Config, error) { func (as *ConfigService) UpdateConfig(ctx *fiber.Ctx, body *map[string]interface{}) (*model.Config, error) {
serverID := ctx.Locals("serverId").(string) serverID := ctx.Locals("serverId").(string)
configFile := ctx.Params("file") configFile := ctx.Params("file")
override := ctx.QueryBool("override", false) override := ctx.QueryBool("override", false)
return as.updateConfigInternal(ctx.UserContext(), serverID, configFile, body, override) actorID, _ := ctx.Locals("userID").(string)
actorUsername, _ := ctx.Locals("actorUsername").(string)
if actorUsername == "" {
actorUsername = "system"
}
return as.updateConfigInternal(ctx.UserContext(), serverID, configFile, body, override, actorID, actorUsername)
} }
func (as *ConfigService) updateConfigFiles(ctx context.Context, server *model.Server, configFile string, body *map[string]interface{}, override bool) ([]byte, []byte, error) { func (as *ConfigService) updateConfigFiles(ctx context.Context, server *model.Server, configFile string, body *map[string]interface{}, override bool) ([]byte, []byte, error) {
@@ -155,7 +249,7 @@ func (as *ConfigService) updateConfigFiles(ctx context.Context, server *model.Se
return oldDataUTF8, newData, nil return oldDataUTF8, newData, nil
} }
func (as *ConfigService) updateConfigInternal(ctx context.Context, serverID string, configFile string, body *map[string]interface{}, override bool) (*model.Config, error) { func (as *ConfigService) updateConfigInternal(ctx context.Context, serverID string, configFile string, body *map[string]interface{}, override bool, actorID, actorUsername string) (*model.Config, error) {
serverUUID, err := uuid.Parse(serverID) serverUUID, err := uuid.Parse(serverID)
if err != nil { if err != nil {
logging.Error("Invalid server ID format: %v", err) logging.Error("Invalid server ID format: %v", err)
@@ -173,9 +267,34 @@ func (as *ConfigService) updateConfigInternal(ctx context.Context, serverID stri
return nil, err return nil, err
} }
as.configCache.InvalidateServerCache(serverID) if configFile == SettingsJson {
if nameVal, ok := (*body)["serverName"]; ok {
if nameStr, ok := nameVal.(string); ok && nameStr != "" && nameStr != server.Name {
server.Name = nameStr
if err := as.serverRepository.Update(ctx, server); err != nil {
logging.Error("Failed to update server name in DB: %v", err)
}
}
}
}
as.configCache.InvalidateServerCache(serverID)
as.serverService.StartAccServerRuntime(server) as.serverService.StartAccServerRuntime(server)
if as.activityLog != nil {
if details := BuildConfigDetails(configFile, oldDataUTF8, newData); details != "" {
go func() {
_ = as.activityLog.Log(context.Background(), &model.ActivityLog{
ServerID: &serverUUID,
UserID: actorID,
Username: actorUsername,
Action: model.ActionConfigUpdate,
Details: details,
})
}()
}
}
return as.repository.UpdateConfig(ctx, &model.Config{ return as.repository.UpdateConfig(ctx, &model.Config{
ServerID: serverUUID, ServerID: serverUUID,
ConfigFile: configFile, ConfigFile: configFile,
@@ -420,6 +539,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)
@@ -433,3 +560,17 @@ func (as *ConfigService) SaveConfiguration(server *model.Server, config *model.C
_, _, err = as.updateConfigFiles(context.Background(), server, ConfigurationJson, &configMap, true) _, _, err = as.updateConfigFiles(context.Background(), server, ConfigurationJson, &configMap, true)
return err return err
} }
// ApplySection writes a single config section to the server's config file and
// records the change. It is used by ConfigPresetService to apply preset sections.
func (as *ConfigService) ApplySection(ctx context.Context, serverID, configFile string, body *map[string]interface{}, actorID, actorUsername string) (*model.Config, error) {
return as.updateConfigInternal(ctx, serverID, configFile, body, true, actorID, actorUsername)
}
func (as *ConfigService) SetServerName(server *model.Server, name string) error {
configMap := map[string]interface{}{
"serverName": name,
}
_, _, err := as.updateConfigFiles(context.Background(), server, SettingsJson, &configMap, false)
return err
}

View File

@@ -0,0 +1,210 @@
package service
import (
"acc-server-manager/local/model"
"acc-server-manager/local/repository"
"acc-server-manager/local/utl/logging"
"context"
"encoding/json"
"fmt"
"github.com/google/uuid"
)
const (
presetMaxNameLen = 100
presetMaxDescLen = 500
presetMaxSectionBytes = 64 * 1024 // 64 KB per config section
)
type ConfigPresetService struct {
repository *repository.ConfigPresetRepository
configService *ConfigService
activityLog *ActivityLogService
}
func NewConfigPresetService(repo *repository.ConfigPresetRepository, configService *ConfigService, activityLog *ActivityLogService) *ConfigPresetService {
return &ConfigPresetService{
repository: repo,
configService: configService,
activityLog: activityLog,
}
}
func (s *ConfigPresetService) ListPresets(ctx context.Context, filter *model.ConfigPresetFilter) (*[]model.ConfigPreset, error) {
return s.repository.GetAll(ctx, filter)
}
func (s *ConfigPresetService) GetPreset(ctx context.Context, id uuid.UUID) (*model.ConfigPreset, error) {
preset, err := s.repository.GetByID(ctx, id)
if err != nil {
return nil, err
}
if preset == nil {
return nil, fmt.Errorf("preset not found")
}
return preset, nil
}
func (s *ConfigPresetService) CreatePreset(ctx context.Context, req *model.ConfigPresetCreateRequest, actorID, actorUsername string) (*model.ConfigPreset, error) {
if req.Name == "" {
return nil, fmt.Errorf("preset name is required")
}
if len(req.Name) > presetMaxNameLen {
return nil, fmt.Errorf("preset name exceeds maximum length of %d characters", presetMaxNameLen)
}
if len(req.Description) > presetMaxDescLen {
return nil, fmt.Errorf("preset description exceeds maximum length of %d characters", presetMaxDescLen)
}
preset := &model.ConfigPreset{
Name: req.Name,
Description: req.Description,
}
if err := marshalSections(req.Configuration, req.AssistRules, req.Event, req.EventRules, req.Settings, preset); err != nil {
return nil, err
}
if err := s.repository.Insert(ctx, preset); err != nil {
return nil, err
}
details := fmt.Sprintf(`{"preset_id":%q,"preset_name":%q}`, preset.ID, preset.Name)
go func() {
if logErr := s.activityLog.Log(context.Background(), &model.ActivityLog{
UserID: actorID,
Username: actorUsername,
Action: model.ActionPresetCreate,
Details: details,
}); logErr != nil {
logging.Error("Failed to log preset create: %v", logErr)
}
}()
return preset, nil
}
func (s *ConfigPresetService) UpdatePreset(ctx context.Context, id uuid.UUID, req *model.ConfigPresetUpdateRequest) (*model.ConfigPreset, error) {
preset, err := s.GetPreset(ctx, id)
if err != nil {
return nil, err
}
if req.Name != nil {
if len(*req.Name) == 0 {
return nil, fmt.Errorf("preset name cannot be empty")
}
if len(*req.Name) > presetMaxNameLen {
return nil, fmt.Errorf("preset name exceeds maximum length of %d characters", presetMaxNameLen)
}
preset.Name = *req.Name
}
if req.Description != nil {
if len(*req.Description) > presetMaxDescLen {
return nil, fmt.Errorf("preset description exceeds maximum length of %d characters", presetMaxDescLen)
}
preset.Description = *req.Description
}
if err := marshalSections(req.Configuration, req.AssistRules, req.Event, req.EventRules, req.Settings, preset); err != nil {
return nil, err
}
if err := s.repository.Update(ctx, preset); err != nil {
return nil, err
}
return preset, nil
}
func (s *ConfigPresetService) DeletePreset(ctx context.Context, id uuid.UUID) error {
return s.repository.Delete(ctx, id)
}
func (s *ConfigPresetService) ApplyPreset(ctx context.Context, serverID string, presetID uuid.UUID, actorID, actorUsername string) ([]*model.Config, error) {
preset, err := s.GetPreset(ctx, presetID)
if err != nil {
return nil, err
}
serverUUID, err := uuid.Parse(serverID)
if err != nil {
return nil, fmt.Errorf("invalid server ID: %v", err)
}
type sectionEntry struct {
fileName string
raw string
}
sections := []sectionEntry{
{ConfigurationJson, preset.Configuration},
{AssistRulesJson, preset.AssistRules},
{EventJson, preset.Event},
{EventRulesJson, preset.EventRules},
{SettingsJson, preset.Settings},
}
var results []*model.Config
for _, section := range sections {
if section.raw == "" {
continue
}
var body map[string]interface{}
if err := json.Unmarshal([]byte(section.raw), &body); err != nil {
return nil, fmt.Errorf("failed to unmarshal preset section %s: %v", section.fileName, err)
}
result, err := s.configService.ApplySection(ctx, serverID, section.fileName, &body, actorID, actorUsername)
if err != nil {
return nil, fmt.Errorf("failed to apply preset section %s: %v", section.fileName, err)
}
if result != nil {
results = append(results, result)
}
}
details := fmt.Sprintf(`{"preset_id":%q,"preset_name":%q,"sections_applied":%d}`, presetID, preset.Name, len(results))
go func() {
if logErr := s.activityLog.Log(context.Background(), &model.ActivityLog{
ServerID: &serverUUID,
UserID: actorID,
Username: actorUsername,
Action: model.ActionPresetApply,
Details: details,
}); logErr != nil {
logging.Error("Failed to log preset apply: %v", logErr)
}
}()
return results, nil
}
// marshalSections marshals each non-nil config section into the preset's JSON string fields,
// enforcing the per-section byte size limit.
func marshalSections(configuration, assistRules, event, eventRules, settings *map[string]interface{}, preset *model.ConfigPreset) error {
type entry struct {
src *map[string]interface{}
name string
dest *string
}
entries := []entry{
{configuration, "configuration", &preset.Configuration},
{assistRules, "assistRules", &preset.AssistRules},
{event, "event", &preset.Event},
{eventRules, "eventRules", &preset.EventRules},
{settings, "settings", &preset.Settings},
}
for _, e := range entries {
if e.src == nil {
continue
}
b, err := json.Marshal(e.src)
if err != nil {
return fmt.Errorf("failed to marshal %s: %v", e.name, err)
}
if len(b) > presetMaxSectionBytes {
return fmt.Errorf("%s section exceeds maximum size of %d bytes", e.name, presetMaxSectionBytes)
}
*e.dest = string(b)
}
return nil
}

View File

@@ -0,0 +1,108 @@
package service
import (
"acc-server-manager/local/model"
"acc-server-manager/local/utl/logging"
"bufio"
"context"
"io"
"strconv"
"sync"
"time"
"github.com/docker/docker/api/types"
dockerclient "github.com/docker/docker/client"
"github.com/docker/docker/pkg/stdcopy"
"github.com/google/uuid"
)
type DockerLogStreamer struct {
client *dockerclient.Client
cancels sync.Map // uuid.UUID → context.CancelFunc
}
func NewDockerLogStreamer(client *dockerclient.Client) *DockerLogStreamer {
return &DockerLogStreamer{client: client}
}
func (s *DockerLogStreamer) Start(ctx context.Context, server *model.Server, handleLine func(string)) error {
s.Stop(server.ID)
if server.ContainerID == "" {
logging.Warn("DockerLogStreamer.Start: no container ID for server %s, skipping", server.ID)
return nil
}
since := time.Now().UTC().Format(time.RFC3339)
streamCtx, cancel := context.WithCancel(ctx)
s.cancels.Store(server.ID, cancel)
go func() {
defer s.cancels.Delete(server.ID)
rc, err := s.client.ContainerLogs(streamCtx, server.ContainerID, types.ContainerLogsOptions{
ShowStdout: true,
ShowStderr: true,
Follow: true,
Timestamps: false,
Since: since,
})
if err != nil {
logging.Error("DockerLogStreamer: failed to attach to container %s: %v", server.ContainerID[:12], err)
return
}
defer rc.Close()
pr, pw := io.Pipe()
go func() {
defer pw.Close()
if _, err := stdcopy.StdCopy(pw, pw, rc); err != nil && err != io.EOF {
logging.Warn("DockerLogStreamer: stdcopy error for %s: %v", server.ServiceName, err)
}
}()
scanner := bufio.NewScanner(pr)
for scanner.Scan() {
handleLine(scanner.Text())
}
}()
return nil
}
func (s *DockerLogStreamer) Stop(serverID uuid.UUID) {
if v, ok := s.cancels.LoadAndDelete(serverID); ok {
v.(context.CancelFunc)()
}
}
func (s *DockerLogStreamer) GetLastLines(ctx context.Context, server *model.Server, n int) ([]string, error) {
if n <= 0 || server.ContainerID == "" {
return []string{}, nil
}
rc, err := s.client.ContainerLogs(ctx, server.ContainerID, types.ContainerLogsOptions{
ShowStdout: true,
ShowStderr: true,
Tail: strconv.Itoa(n),
})
if err != nil {
return nil, err
}
defer rc.Close()
pr, pw := io.Pipe()
go func() {
defer pw.Close()
if _, err := stdcopy.StdCopy(pw, pw, rc); err != nil && err != io.EOF {
logging.Warn("DockerLogStreamer.GetLastLines: stdcopy error for %s: %v", server.ServiceName, err)
}
}()
var lines []string
scanner := bufio.NewScanner(pr)
for scanner.Scan() {
lines = append(lines, scanner.Text())
}
return lines, scanner.Err()
}

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

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

@@ -3,6 +3,7 @@ package service
import ( import (
"acc-server-manager/local/model" "acc-server-manager/local/model"
"acc-server-manager/local/repository" "acc-server-manager/local/repository"
"acc-server-manager/local/utl/logging"
"context" "context"
"fmt" "fmt"
@@ -11,10 +12,11 @@ import (
type LeaderboardService struct { type LeaderboardService struct {
repo *repository.LeaderboardRepository repo *repository.LeaderboardRepository
activityLog *ActivityLogService
} }
func NewLeaderboardService(repo *repository.LeaderboardRepository) *LeaderboardService { func NewLeaderboardService(repo *repository.LeaderboardRepository, activityLog *ActivityLogService) *LeaderboardService {
return &LeaderboardService{repo: repo} return &LeaderboardService{repo: repo, activityLog: activityLog}
} }
// Get returns the leaderboard for a server. // Get returns the leaderboard for a server.
@@ -23,7 +25,7 @@ func (s *LeaderboardService) Get(ctx context.Context, serverID uuid.UUID) (*mode
} }
// Update replaces the leaderboard for a server. // Update replaces the leaderboard for a server.
func (s *LeaderboardService) Update(ctx context.Context, serverID uuid.UUID, lb *model.Leaderboard) (*model.Leaderboard, error) { func (s *LeaderboardService) Update(ctx context.Context, serverID uuid.UUID, lb *model.Leaderboard, actorID, actorUsername string) (*model.Leaderboard, error) {
if lb == nil { if lb == nil {
return nil, fmt.Errorf("input is required") return nil, fmt.Errorf("input is required")
} }
@@ -33,5 +35,25 @@ func (s *LeaderboardService) Update(ctx context.Context, serverID uuid.UUID, lb
for i := range lb.Races { for i := range lb.Races {
lb.Races[i].Position = i lb.Races[i].Position = i
} }
return s.repo.FullReplace(ctx, serverID, lb) result, err := s.repo.FullReplace(ctx, serverID, lb)
if err != nil {
return nil, err
}
driverCount := len(lb.Drivers)
raceCount := len(lb.Races)
details := fmt.Sprintf(`{"driver_count":%d,"race_count":%d}`, driverCount, raceCount)
go func() {
if logErr := s.activityLog.Log(context.Background(), &model.ActivityLog{
ServerID: &serverID,
UserID: actorID,
Username: actorUsername,
Action: model.ActionLeaderboardUpdate,
Details: details,
}); logErr != nil {
logging.Error("Failed to log leaderboard update: %v", logErr)
}
}()
return result, nil
} }

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 }

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

@@ -2,9 +2,11 @@ package service
import ( import (
"acc-server-manager/local/model" "acc-server-manager/local/model"
"acc-server-manager/local/platform"
"acc-server-manager/local/repository" "acc-server-manager/local/repository"
"acc-server-manager/local/utl/env" "acc-server-manager/local/utl/env"
"acc-server-manager/local/utl/logging" "acc-server-manager/local/utl/logging"
"acc-server-manager/local/utl/network"
"acc-server-manager/local/utl/tracking" "acc-server-manager/local/utl/tracking"
"context" "context"
"fmt" "fmt"
@@ -12,8 +14,6 @@ import (
"sync" "sync"
"time" "time"
"acc-server-manager/local/utl/network"
"github.com/gofiber/fiber/v2" "github.com/gofiber/fiber/v2"
"github.com/google/uuid" "github.com/google/uuid"
) )
@@ -26,17 +26,19 @@ const (
type ServerService struct { type ServerService struct {
repository *repository.ServerRepository repository *repository.ServerRepository
stateHistoryRepo *repository.StateHistoryRepository stateHistoryRepo *repository.StateHistoryRepository
activityLogRepo *repository.ActivityLogRepository
apiService *ServiceControlService apiService *ServiceControlService
configService *ConfigService configService *ConfigService
steamService *SteamService steamService *SteamService
windowsService *WindowsService runtime platform.ServerRuntime
firewallService *FirewallService firewall platform.FirewallManager
logStreamer platform.LogStreamer
portPool *network.PortPoolManager
webSocketService *WebSocketService webSocketService *WebSocketService
instances sync.Map // Track instances per server instances sync.Map // uuid.UUID → *tracking.AccServerInstance
lastInsertTimes sync.Map // Track last insert time per server lastInsertTimes sync.Map // uuid.UUID → time.Time
debouncers sync.Map // Track debounce timers per server debouncers sync.Map // uuid.UUID → *pendingState
logTailers sync.Map // Track log tailers per server sessionIDs sync.Map // uuid.UUID → uuid.UUID
sessionIDs sync.Map // Track current session ID per server
} }
type pendingState struct { type pendingState struct {
@@ -45,37 +47,52 @@ type pendingState struct {
} }
func (s *ServerService) ensureLogTailing(server *model.Server, instance *tracking.AccServerInstance) { func (s *ServerService) ensureLogTailing(server *model.Server, instance *tracking.AccServerInstance) {
if _, exists := s.logTailers.Load(server.ID); exists {
return
}
go func() { go func() {
logPath := filepath.Join(server.GetLogPath(), "server.log") handleFn := func(line string) {
tailer := tracking.NewLogTailer(logPath, instance.HandleLogLine) instance.HandleLogLine(line)
s.logTailers.Store(server.ID, tailer) s.webSocketService.BroadcastLogLine(server.ID, line)
}
tailer.Start() if err := s.logStreamer.Start(context.Background(), server, handleFn); err != nil {
logging.Error("Failed to start log streaming for server %s: %v", server.ID, err)
}
}() }()
} }
func (s *ServerService) GetLastLogLines(ctx *fiber.Ctx, serverID uuid.UUID, n int) ([]string, error) {
server, err := s.repository.GetByID(ctx.UserContext(), serverID)
if err != nil {
return nil, err
}
if n <= 0 {
n = 100
}
return s.logStreamer.GetLastLines(ctx.UserContext(), server, n)
}
func NewServerService( func NewServerService(
repository *repository.ServerRepository, repository *repository.ServerRepository,
stateHistoryRepo *repository.StateHistoryRepository, stateHistoryRepo *repository.StateHistoryRepository,
activityLogRepo *repository.ActivityLogRepository,
apiService *ServiceControlService, apiService *ServiceControlService,
configService *ConfigService, configService *ConfigService,
steamService *SteamService, steamService *SteamService,
windowsService *WindowsService, runtime platform.ServerRuntime,
firewallService *FirewallService, firewall platform.FirewallManager,
logStreamer platform.LogStreamer,
portPool *network.PortPoolManager,
webSocketService *WebSocketService, webSocketService *WebSocketService,
) *ServerService { ) *ServerService {
service := &ServerService{ service := &ServerService{
repository: repository, repository: repository,
stateHistoryRepo: stateHistoryRepo, stateHistoryRepo: stateHistoryRepo,
activityLogRepo: activityLogRepo,
apiService: apiService, apiService: apiService,
configService: configService, configService: configService,
steamService: steamService, steamService: steamService,
windowsService: windowsService, runtime: runtime,
firewallService: firewallService, firewall: firewall,
logStreamer: logStreamer,
portPool: portPool,
webSocketService: webSocketService, webSocketService: webSocketService,
} }
@@ -195,9 +212,19 @@ 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) {
@@ -250,9 +277,6 @@ func (s *ServerService) StartAccServerRuntime(server *model.Server) {
s.ensureLogTailing(server, instance) s.ensureLogTailing(server, instance)
} }
// context.Context: Application context
// Returns:
// string: Application version
func (s *ServerService) GetAll(ctx *fiber.Ctx, filter *model.ServerFilter) (*[]model.Server, error) { func (s *ServerService) GetAll(ctx *fiber.Ctx, filter *model.ServerFilter) (*[]model.Server, error) {
servers, err := s.repository.GetAll(ctx.UserContext(), filter) servers, err := s.repository.GetAll(ctx.UserContext(), filter)
if err != nil { if err != nil {
@@ -260,6 +284,16 @@ func (s *ServerService) GetAll(ctx *fiber.Ctx, filter *model.ServerFilter) (*[]m
return nil, err return nil, err
} }
// Collect server IDs for a single last-activity lookup.
serverIDs := make([]uuid.UUID, len(*servers))
for i, srv := range *servers {
serverIDs[i] = srv.ID
}
lastActivities, laErr := s.activityLogRepo.GetLastActivityByServerIDs(ctx.UserContext(), serverIDs)
if laErr != nil {
logging.Error("Failed to get last activities for server list: %v", laErr)
}
for i := range *servers { for i := range *servers {
server := &(*servers)[i] server := &(*servers)[i]
status, err := s.apiService.GetCachedStatus(server.ServiceName) status, err := s.apiService.GetCachedStatus(server.ServiceName)
@@ -276,14 +310,16 @@ func (s *ServerService) GetAll(ctx *fiber.Ctx, filter *model.ServerFilter) (*[]m
server.State = serverInstance.State server.State = serverInstance.State
} }
} }
if lastActivities != nil {
if info, ok := lastActivities[server.ID]; ok {
server.LastActivity = info
}
}
} }
return servers, nil return servers, nil
} }
// context.Context: Application context
// Returns:
// string: Application version
func (as *ServerService) GetById(ctx *fiber.Ctx, serverID uuid.UUID) (*model.Server, error) { func (as *ServerService) GetById(ctx *fiber.Ctx, serverID uuid.UUID) (*model.Server, error) {
server, err := as.repository.GetByID(ctx.UserContext(), serverID) server, err := as.repository.GetByID(ctx.UserContext(), serverID)
if err != nil { if err != nil {
@@ -291,7 +327,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,17 +413,33 @@ 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)
} }
if err := s.configService.SetServerName(server, server.Name); err != nil {
logging.Error("Failed to set server name in config: %v", err)
}
return fmt.Sprintf("Server configuration generated (Port: %d)", serverPort), nil return fmt.Sprintf("Server configuration generated (Port: %d)", serverPort), nil
}, },
}, },
@@ -396,12 +448,11 @@ func (s *ServerService) createServerBackground(ctx context.Context, server *mode
important: true, 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 +460,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 +519,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 +541,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 +592,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 +611,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
} }

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

@@ -1,39 +1,103 @@
package service package service
import ( import (
"acc-server-manager/local/platform"
"acc-server-manager/local/repository" "acc-server-manager/local/repository"
"acc-server-manager/local/utl/docker"
"acc-server-manager/local/utl/env"
"acc-server-manager/local/utl/logging" "acc-server-manager/local/utl/logging"
"acc-server-manager/local/utl/network"
"fmt"
dockerclient "github.com/docker/docker/client"
"go.uber.org/dig" "go.uber.org/dig"
"gorm.io/gorm"
) )
// *dig.Container: Dig Container // InitializeServices registers all service providers into the DI container.
// Platform-specific components (ServerRuntime, FirewallManager, LogStreamer)
// are conditionally provided based on the PLATFORM environment variable.
func InitializeServices(c *dig.Container) { func InitializeServices(c *dig.Container) {
logging.Debug("Initializing repositories") logging.Debug("Initializing repositories")
repository.InitializeRepositories(c) repository.InitializeRepositories(c)
logging.Debug("Registering services") logging.Debug("Registering services")
c.Provide(NewSteamService) c.Provide(NewSteamService)
c.Provide(NewActivityLogService)
c.Provide(NewServerService) c.Provide(NewServerService)
c.Provide(NewStateHistoryService) c.Provide(NewStateHistoryService)
c.Provide(NewServiceControlService) c.Provide(NewServiceControlService)
c.Provide(NewConfigService) c.Provide(NewConfigService)
c.Provide(NewLookupService) c.Provide(NewLookupService)
c.Provide(NewWindowsService)
c.Provide(NewFirewallService)
c.Provide(NewMembershipService) c.Provide(NewMembershipService)
c.Provide(NewWebSocketService) c.Provide(NewWebSocketService)
c.Provide(NewLeaderboardService) c.Provide(NewLeaderboardService)
c.Provide(NewConfigPresetService)
// Always provide the PortPoolManager; in Windows mode it seeds from an
// empty result set and sits idle.
c.Provide(func(db *gorm.DB) (*network.PortPoolManager, error) {
return network.NewPortPoolManager(db)
})
if env.IsDockerPlatform() {
registerDockerProviders(c)
} else {
registerWindowsProviders(c)
}
logging.Debug("Initializing service dependencies") logging.Debug("Initializing service dependencies")
err := c.Invoke(func(server *ServerService, api *ServiceControlService, config *ConfigService) { err := c.Invoke(func(server *ServerService, api *ServiceControlService, config *ConfigService) {
logging.Debug("Setting up service cross-references") logging.Debug("Setting up service cross-references")
api.SetServerService(server) api.SetServerService(server)
config.SetServerService(server) config.SetServerService(server)
}) })
if err != nil { if err != nil {
logging.Panic("unable to initialize services: " + err.Error()) logging.Panic("unable to initialize services: " + err.Error())
} }
logging.Debug("Completed service initialization") logging.Debug("Completed service initialization")
} }
func registerWindowsProviders(c *dig.Container) {
logging.Debug("Registering Windows platform providers")
c.Provide(NewWindowsService)
c.Provide(func(ws *WindowsService, repo *repository.ServerRepository) platform.ServerRuntime {
return NewWindowsRuntime(ws, repo)
})
c.Provide(func() platform.FirewallManager {
return NewFirewallService()
})
c.Provide(func() platform.LogStreamer {
return NewFileLogStreamer()
})
}
func registerDockerProviders(c *dig.Container) {
logging.Debug("Registering Docker platform providers")
if err := c.Provide(func() (*dockerclient.Client, error) {
client, err := docker.NewDockerClient()
if err != nil {
return nil, fmt.Errorf("failed to connect to Docker daemon: %v", err)
}
return client, nil
}); err != nil {
logging.Panic("failed to provide Docker client: " + err.Error())
}
c.Provide(func(client *dockerclient.Client, repo *repository.ServerRepository) platform.ServerRuntime {
return NewDockerRuntime(client, repo)
})
c.Provide(func() platform.FirewallManager {
return &NoOpFirewall{}
})
c.Provide(func(client *dockerclient.Client) platform.LogStreamer {
return NewDockerLogStreamer(client)
})
}

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

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

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

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

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

@@ -44,6 +44,14 @@ func NewSteamService(repository *repository.SteamCredentialsRepository, tfaManag
} }
} }
// getSteamCMDPath returns the platform-appropriate SteamCMD binary path.
func getSteamCMDPath() string {
if env.IsDockerPlatform() {
return env.GetLinuxSteamCMDPath()
}
return env.GetSteamCMDPath()
}
func (s *SteamService) GetCredentials(ctx context.Context) (*model.SteamCredentials, error) { func (s *SteamService) GetCredentials(ctx context.Context) (*model.SteamCredentials, error) {
return s.repository.GetCurrent(ctx) return s.repository.GetCurrent(ctx)
} }
@@ -56,6 +64,11 @@ func (s *SteamService) SaveCredentials(ctx context.Context, creds *model.SteamCr
} }
func (s *SteamService) ensureSteamCMD(_ context.Context) error { func (s *SteamService) ensureSteamCMD(_ context.Context) error {
// In Docker mode, SteamCMD is pre-installed in the manager container image.
if env.IsDockerPlatform() {
return nil
}
steamCMDPath := env.GetSteamCMDPath() steamCMDPath := env.GetSteamCMDPath()
steamCMDDir := filepath.Dir(steamCMDPath) steamCMDDir := filepath.Dir(steamCMDPath)
@@ -92,10 +105,12 @@ func (s *SteamService) InstallServerWithWebSocket(ctx context.Context, installPa
return err return err
} }
if !env.IsDockerPlatform() {
if err := s.pathValidator.ValidateInstallPath(installPath); err != nil { if err := s.pathValidator.ValidateInstallPath(installPath); err != nil {
wsService.BroadcastSteamOutput(*serverID, fmt.Sprintf("Invalid installation path: %v", err), true) wsService.BroadcastSteamOutput(*serverID, fmt.Sprintf("Invalid installation path: %v", err), true)
return fmt.Errorf("invalid installation path: %v", err) return fmt.Errorf("invalid installation path: %v", err)
} }
}
absPath, err := filepath.Abs(installPath) absPath, err := filepath.Abs(installPath)
if err != nil { if err != nil {
@@ -117,7 +132,7 @@ func (s *SteamService) InstallServerWithWebSocket(ctx context.Context, installPa
return fmt.Errorf("failed to get Steam credentials: %v", err) return fmt.Errorf("failed to get Steam credentials: %v", err)
} }
steamCMDPath := env.GetSteamCMDPath() steamCMDPath := getSteamCMDPath()
steamCMDArgs := []string{ steamCMDArgs := []string{
"+force_install_dir", absPath, "+force_install_dir", absPath,
@@ -135,6 +150,10 @@ func (s *SteamService) InstallServerWithWebSocket(ctx context.Context, installPa
steamCMDArgs = append(steamCMDArgs, "anonymous") steamCMDArgs = append(steamCMDArgs, "anonymous")
} }
if env.IsDockerPlatform() {
steamCMDArgs = append(steamCMDArgs, "+@sSteamCmdForcePlatformType", "windows")
}
steamCMDArgs = append(steamCMDArgs, steamCMDArgs = append(steamCMDArgs,
"+app_update", ACCServerAppID, "+app_update", ACCServerAppID,
"validate", "validate",
@@ -216,10 +235,12 @@ func (s *SteamService) InstallServerWithCallbacks(ctx context.Context, installPa
return err return err
} }
if !env.IsDockerPlatform() {
if err := s.pathValidator.ValidateInstallPath(installPath); err != nil { if err := s.pathValidator.ValidateInstallPath(installPath); err != nil {
outputCallback(*serverID, fmt.Sprintf("Invalid installation path: %v", err), true) outputCallback(*serverID, fmt.Sprintf("Invalid installation path: %v", err), true)
return fmt.Errorf("invalid installation path: %v", err) return fmt.Errorf("invalid installation path: %v", err)
} }
}
absPath, err := filepath.Abs(installPath) absPath, err := filepath.Abs(installPath)
if err != nil { if err != nil {
@@ -241,7 +262,7 @@ func (s *SteamService) InstallServerWithCallbacks(ctx context.Context, installPa
return fmt.Errorf("failed to get Steam credentials: %v", err) return fmt.Errorf("failed to get Steam credentials: %v", err)
} }
steamCMDPath := env.GetSteamCMDPath() steamCMDPath := getSteamCMDPath()
steamCMDArgs := []string{ steamCMDArgs := []string{
"+force_install_dir", absPath, "+force_install_dir", absPath,
@@ -259,6 +280,10 @@ func (s *SteamService) InstallServerWithCallbacks(ctx context.Context, installPa
steamCMDArgs = append(steamCMDArgs, "anonymous") steamCMDArgs = append(steamCMDArgs, "anonymous")
} }
if env.IsDockerPlatform() {
steamCMDArgs = append(steamCMDArgs, "+@sSteamCmdForcePlatformType", "windows")
}
steamCMDArgs = append(steamCMDArgs, steamCMDArgs = append(steamCMDArgs,
"+app_update", ACCServerAppID, "+app_update", ACCServerAppID,
"validate", "validate",

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

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