Compare commits

...

20 Commits

Author SHA1 Message Date
14aa8a84e5 feat: add logging to existing services 2026-03-22 13:54:43 +01:00
e96b8bca12 feat: add activity logs 2026-03-22 13:52:15 +01:00
85c5874bc7 update container port mapping 2026-03-22 13:23:35 +01:00
4441524447 fix acc server container restarting 2026-03-22 13:23:35 +01:00
c03234d339 update server app path to host's 2026-03-22 13:23:35 +01:00
d4b16d80f3 resolve server creation issues 2026-03-22 13:23:35 +01:00
9172f0256f add docker specifics 2026-03-22 13:23:35 +01:00
beb9b479df add platform interface according to env 2026-03-22 13:23:35 +01:00
4aa67db3fd add docker specific columns to server table 2026-03-22 13:23:35 +01:00
747adc77a5 version bump
Some checks failed
Release and Deploy / deploy (push) Has been skipped
Release and Deploy / build (push) Failing after 6m7s
2026-03-22 12:24:54 +01:00
c9a32e8889 feat: batch update config endpoint 2026-03-22 12:16:40 +01:00
7d470f3638 update headers 2026-03-05 21:50:05 +01:00
cf7473b373 update models to use uuid referencing
Some checks failed
Release and Deploy / build (push) Failing after 6m58s
Release and Deploy / deploy (push) Has been skipped
2026-03-01 16:46:06 +01:00
35c53cc54c public endpoint for leaderboard 2026-03-01 14:35:02 +01:00
016824e846 Merge branch 'feature/leaderboard'
Some checks failed
Release and Deploy / build (push) Failing after 7m6s
Release and Deploy / deploy (push) Has been skipped
2026-03-01 12:18:01 +01:00
d62c8cef34 add build scripts and resolve migration
Some checks failed
Release and Deploy / build (push) Failing after 8m24s
Release and Deploy / deploy (push) Has been skipped
2026-02-22 17:49:44 +01:00
190e1a3a16 add leaderboard 2026-02-22 12:28:48 +01:00
Fran Jurmanović
4004d83411 add step list for server creation
All checks were successful
Release and Deploy / build (push) Successful in 9m5s
Release and Deploy / deploy (push) Successful in 26s
2025-09-18 22:24:51 +02:00
Fran Jurmanović
901dbe697e Use sockets for server creation progress 2025-09-18 01:06:58 +02:00
Fran Jurmanović
760412d7db update state history session type
All checks were successful
Release and Deploy / build (push) Successful in 2m25s
Release and Deploy / deploy (push) Successful in 26s
2025-09-15 19:11:25 +02:00
152 changed files with 4457 additions and 2393 deletions

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

@@ -50,6 +50,35 @@ PASSWORD=change-this-default-admin-password
# 4. NEVER commit actual secrets to version control! # 4. NEVER commit actual secrets to version control!
# =============================================================================
# PLATFORM SELECTION
# =============================================================================
# Platform mode: "windows" (default) or "docker"
# PLATFORM=windows
# =============================================================================
# DOCKER MODE SETTINGS (required when PLATFORM=docker)
# =============================================================================
# Docker image name for ACC server containers (built from docker/acc-server/Dockerfile)
# ACC_IMAGE=acc-wine:latest
# Base path where per-server game installations are stored (inside the manager container).
# Each server gets its own subdirectory: ACC_SERVERS_PATH/<service-name>/
# ACC_SERVERS_PATH=/data/servers
# Host-side path for the same directory — used when creating sibling ACC server containers.
# Must match the host path bound to ACC_SERVERS_PATH in docker-compose.yml.
# ACC_SERVERS_HOST_PATH=/opt/acc-servers
# Port pool for Docker-mode ACC server containers (one port per server).
# PORT_POOL_MIN=9600
# PORT_POOL_MAX=9700
# Linux SteamCMD binary path (pre-installed in the manager Docker image).
# STEAMCMD_LINUX_PATH=/usr/games/steamcmd
# ============================================================================= # =============================================================================
# OPTIONAL SETTINGS (These are handled by system config in database) # OPTIONAL SETTINGS (These are handled by system config in database)
# ============================================================================= # =============================================================================

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

0
.gitignore vendored Normal file → Executable file
View File

63
Dockerfile Normal file
View File

@@ -0,0 +1,63 @@
# Multi-stage build for the acc-server-manager Go backend.
# CGO_ENABLED=1 is required for mattn/go-sqlite3 (uses cgo).
# The binary is compiled for Linux (Docker mode default).
# ─── Builder ──────────────────────────────────────────────────────────────────
FROM golang:1.25-bookworm AS builder
WORKDIR /build
# Install build dependencies for CGO + sqlite3.
RUN apt-get update && apt-get install -y --no-install-recommends \
gcc \
libc6-dev \
&& rm -rf /var/lib/apt/lists/*
# Download Go modules first (layer cache).
COPY go.mod go.sum ./
RUN go mod download
# Copy source and build.
COPY . .
RUN CGO_ENABLED=1 GOOS=linux GOARCH=amd64 \
go build -ldflags="-s -w" -o /acc-server-manager ./cmd/api
# ─── Runtime ──────────────────────────────────────────────────────────────────
FROM debian:bookworm-slim AS runtime
# Install runtime dependencies and SteamCMD.
# steamcmd is not in the standard Debian repo — download directly from Valve.
RUN dpkg --add-architecture i386 && \
apt-get update && \
apt-get install -y --no-install-recommends \
ca-certificates \
curl \
lib32gcc-s1 \
&& \
rm -rf /var/lib/apt/lists/*
RUN mkdir -p /usr/games && \
curl -sSL --retry 3 --retry-delay 5 \
-o /tmp/steamcmd.tar.gz \
"https://steamcdn-a.akamaihd.net/client/installer/steamcmd_linux.tar.gz" && \
tar -xzf /tmp/steamcmd.tar.gz -C /usr/games && \
rm /tmp/steamcmd.tar.gz && \
chmod +x /usr/games/steamcmd.sh && \
ln -sf /usr/games/steamcmd.sh /usr/games/steamcmd
WORKDIR /app
COPY --from=builder /acc-server-manager /app/acc-server-manager
# Persistent data directories.
RUN mkdir -p /data/servers /data/db
ENV PLATFORM=docker
ENV PORT=3000
ENV DB_NAME=/data/db/acc.db
ENV STEAMCMD_LINUX_PATH=/usr/games/steamcmd
ENV ACC_SERVERS_PATH=/data/servers
EXPOSE 3000
ENTRYPOINT ["/app/acc-server-manager"]

0
README.md Normal file → Executable file
View File

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

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

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

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

34
docker-compose.yml Normal file
View File

@@ -0,0 +1,34 @@
services:
manager:
build:
context: .
dockerfile: Dockerfile
image: acc-server-manager:latest
container_name: acc-server-manager
restart: unless-stopped
ports:
- "3000:3000"
environment:
PLATFORM: docker
DB_NAME: /data/db/acc.db
STEAMCMD_LINUX_PATH: /usr/games/steamcmd
PORT_POOL_MIN: "9600"
PORT_POOL_MAX: "9699"
ACC_IMAGE: acc-wine:latest
ACC_SERVERS_PATH: /data/servers
ACC_SERVERS_HOST_PATH: /opt/acc-servers
env_file:
- .env
volumes:
- /var/run/docker.sock:/var/run/docker.sock
- /opt/acc-servers:/data/servers
- acc-db:/data/db
networks:
- acc-net
volumes:
acc-db:
networks:
acc-net:
driver: bridge

View File

@@ -0,0 +1,35 @@
# ACC Server container: Wine + accServer.exe
# Game files are bind-mounted from the host at /acc/game.
FROM debian:bookworm-slim
# Install Wine, Xvfb (headless display for Wine), and minimal runtime deps.
RUN dpkg --add-architecture i386 && \
apt-get update && \
apt-get install -y --no-install-recommends \
wine \
wine32 \
wine64 \
xvfb \
ca-certificates \
tcpdump \
&& \
rm -rf /var/lib/apt/lists/*
RUN mkdir -p /tmp/.X11-unix && chmod 1777 /tmp/.X11-unix
WORKDIR /root
ENV WINEARCH=win64
ENV WINEPREFIX=/root/.wine
ENV WINEDEBUG=-all
# Copy entrypoint script.
COPY entrypoint.sh /entrypoint.sh
RUN chmod +x /entrypoint.sh
# Bind-mounted by DockerRuntime.Create: per-server game install (rw)
VOLUME ["/acc/game"]
ENV SERVER_PORT=9600
ENTRYPOINT ["/entrypoint.sh"]

View File

@@ -0,0 +1,78 @@
#!/bin/bash
set -e
DISPLAY_NUM=$((SERVER_PORT % 1000 + 100))
export DISPLAY=:${DISPLAY_NUM}
rm -f /tmp/.X${DISPLAY_NUM}-lock /tmp/.X11-unix/X${DISPLAY_NUM} 2>/dev/null || true
Xvfb ${DISPLAY} -screen 0 1024x768x16 -nolisten tcp &
XVFB_PID=$!
TCPDUMP_PID=""
cleanup() {
echo "Stopping ACC server..."
[ -n "${TCPDUMP_PID}" ] && kill "${TCPDUMP_PID}" 2>/dev/null || true
kill "${XVFB_PID}" 2>/dev/null || true
}
trap cleanup SIGTERM SIGINT EXIT
# Initialize Wine prefix (no-op if already done)
wineboot -u 2>/dev/null
mkdir -p /acc/game/log
# ─── Debug mode ───────────────────────────────────────────────────────────────
# Set DEBUG_MODE=1 on the container to enable extra diagnostics:
#
# docker run -e DEBUG_MODE=1 ...
# or in docker-compose:
# environment:
# DEBUG_MODE: "1"
#
# Produces:
# /acc/game/log/tcpdump_<port>.pcap — raw packet capture (open in Wireshark)
# /acc/game/log/wine_debug.log — Wine debug output
# Stderr: Wine version, WINEDEBUG channel, prefix state
#
# You can also override which Wine debug channels are enabled:
# WINEDEBUG_CHANNELS="+loaddll,+seh,+relay" (relay is very verbose)
# ─────────────────────────────────────────────────────────────────────────────
if [ "${DEBUG_MODE:-0}" = "1" ]; then
echo "[debug] Debug mode enabled for port ${SERVER_PORT}"
echo "[debug] Wine: $(wine --version 2>/dev/null || echo unknown)"
echo "[debug] WINEPREFIX: ${WINEPREFIX}"
# Configure Wine debug channels (default: loader + exception + module info)
export WINEDEBUG="${WINEDEBUG_CHANNELS:-+loaddll,+module,+seh,+ntdll}"
echo "[debug] WINEDEBUG=${WINEDEBUG}"
# Start packet capture on the server port — captures both TCP and UDP.
# The pcap file records every byte Kunos sends/receives so you can inspect
# the 2-byte "pakSizeMax" packet and anything that follows.
#
# NOTE: requires CAP_NET_RAW capability on the container:
# docker-compose: cap_add: [NET_RAW]
# docker run: --cap-add=NET_RAW
# Without it tcpdump will be skipped (no crash, just no capture).
CAPTURE_FILE="/acc/game/log/tcpdump_${SERVER_PORT}.pcap"
echo "[debug] Starting packet capture → ${CAPTURE_FILE}"
echo "[debug] (requires cap_add: [NET_RAW] in docker-compose — will exit silently if missing)"
tcpdump -i any -nn -s 0 -w "${CAPTURE_FILE}" \
"port ${SERVER_PORT}" 2>/acc/game/log/tcpdump.err &
TCPDUMP_PID=$!
# Redirect Wine debug output to a log file (Wine writes to stderr).
WINE_LOG="/acc/game/log/wine_debug.log"
echo "[debug] Wine debug log → ${WINE_LOG}"
echo "Starting ACC server on port ${SERVER_PORT} (DEBUG)..."
cd /acc/game
exec wine /acc/game/accServer.exe 2>"${WINE_LOG}"
else
# Production: suppress all Wine debug output.
export WINEDEBUG=-all
echo "Starting ACC server on port ${SERVER_PORT}..."
cd /acc/game
exec wine /acc/game/accServer.exe
fi

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

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

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

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

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

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

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

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

25
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,11 +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/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/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
@@ -34,12 +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/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
) )

82
go.sum Normal file → Executable file
View File

@@ -1,9 +1,23 @@
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/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=
github.com/go-openapi/jsonpointer v0.21.0/go.mod h1:IUyH9l/+uyhIYQ/PXVA41Rexl+kOkAPDdXEYns6fzUY= github.com/go-openapi/jsonpointer v0.21.0/go.mod h1:IUyH9l/+uyhIYQ/PXVA41Rexl+kOkAPDdXEYns6fzUY=
github.com/go-openapi/jsonreference v0.21.0 h1:Rs+Y7hSXT83Jacb7kFyjn4ijOuVGSvOdF2+tg1TRrwQ= github.com/go-openapi/jsonreference v0.21.0 h1:Rs+Y7hSXT83Jacb7kFyjn4ijOuVGSvOdF2+tg1TRrwQ=
@@ -16,8 +30,14 @@ github.com/gofiber/fiber/v2 v2.52.8 h1:xl4jJQ0BV5EJTA2aWiKw/VddRpHrKeZLF0QPUxqn0
github.com/gofiber/fiber/v2 v2.52.8/go.mod h1:YEcBbO/FB+5M1IZNBP9FO3J9281zgPAreiI1oqg8nDw= github.com/gofiber/fiber/v2 v2.52.8/go.mod h1:YEcBbO/FB+5M1IZNBP9FO3J9281zgPAreiI1oqg8nDw=
github.com/gofiber/swagger v1.1.0 h1:ff3rg1fB+Rp5JN/N8jfxTiZtMKe/9tB9QDc79fPiJKQ= 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/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=
@@ -28,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=
@@ -45,16 +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/stretchr/testify v1.9.0 h1:HtqpIVDClZ4nwg75+f6Lvsy/wHu+3BoSGCbBAcpTsTg= github.com/savsgio/gotils v0.0.0-20230208104028-c358bd845dee h1:8Iv5m6xEo1NR1AvpV+7XmhI4r39LGNzwUL4YpMuL5vk=
github.com/stretchr/testify v1.9.0/go.mod h1:r2ic/lqez/lEtzL7wO/rwa5dbSLXVDPFyf8C91i36aY= github.com/savsgio/gotils v0.0.0-20230208104028-c358bd845dee/go.mod h1:qwtSXrKuJh/zsFQ12yEE89xfCrGKK63Rr7ctU/uCo4g=
github.com/sirupsen/logrus v1.7.0 h1:ShrD1U9pZB12TX0cVy0DtePoCH97K8EtX+mg7ZARUtM=
github.com/sirupsen/logrus v1.7.0/go.mod h1:yWOB1SBYBC5VeMP7gHvWumXLIWorT60ONWic61uBYv0=
github.com/stretchr/testify v1.2.2/go.mod h1:a8OnRcib4nhh0OaRAV+Yts87kKdq0PP7pXfy6kDkUVs=
github.com/stretchr/testify v1.11.1 h1:7s2iGBzp5EwR7/aIZr8ao5+dra3wiQyKjjFuvgVKu7U=
github.com/stretchr/testify v1.11.1/go.mod h1:wZwfW3scLgRK+23gO65QZefKpKQRnfz6sD981Nm4B6U=
github.com/swaggo/files/v2 v2.0.0 h1:hmAt8Dkynw7Ssz46F6pn8ok6YmGZqHSVLZ+HQM7i0kw= github.com/swaggo/files/v2 v2.0.0 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=
@@ -65,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=
@@ -90,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=

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

@@ -31,7 +31,11 @@ func Init(di *dig.Container, app *fiber.App) {
StateHistory: serverIdGroup.Group("/state-history"), StateHistory: serverIdGroup.Group("/state-history"),
Membership: groups.Group("/membership"), Membership: groups.Group("/membership"),
System: groups.Group("/system"), System: groups.Group("/system"),
Steam2FA: groups.Group("/steam2fa"), WebSocket: groups.Group("/ws"),
Leaderboard: serverIdGroup.Group("/leaderboard"),
Steam: groups.Group("/steam"),
ActivityLog: serverIdGroup.Group("/activity-log"),
Groups: groups,
} }
accessKeyMiddleware := middleware.NewAccessKeyMiddleware() accessKeyMiddleware := middleware.NewAccessKeyMiddleware()

View File

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

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

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

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

@@ -55,8 +55,23 @@ func InitializeControllers(c *dig.Container) {
logging.Panic("unable to initialize membership controller") logging.Panic("unable to initialize membership controller")
} }
err = c.Invoke(NewSteam2FAController) err = c.Invoke(NewWebSocketController)
if err != nil { if err != nil {
logging.Panic("unable to initialize steam 2fa controller") logging.Panic("unable to initialize websocket controller")
}
err = c.Invoke(NewLeaderboardController)
if err != nil {
logging.Panic("unable to initialize leaderboard controller")
}
err = c.Invoke(NewSteamController)
if err != nil {
logging.Panic("unable to initialize steam controller")
}
err = c.Invoke(NewActivityLogController)
if err != nil {
logging.Panic("unable to initialize activity log controller")
} }
} }

77
local/controller/leaderboard.go Executable file
View File

@@ -0,0 +1,77 @@
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"
"github.com/google/uuid"
)
type LeaderboardController struct {
service *service.LeaderboardService
errorHandler *error_handler.ControllerErrorHandler
}
func NewLeaderboardController(ls *service.LeaderboardService, routeGroups *common.RouteGroups, auth *middleware.AuthMiddleware) *LeaderboardController {
lc := &LeaderboardController{
service: ls,
errorHandler: error_handler.NewControllerErrorHandler(),
}
apiServerRoutes := routeGroups.Api.Group("/server/:id")
apiServerRoutes.Get("/leaderboard", lc.Get)
routeGroups.Leaderboard.Get("/", lc.Get)
routeGroups.Leaderboard.Put("/", auth.Authenticate, lc.Update)
return lc
}
// Get returns the leaderboard for a server (public, no auth required).
func (lc *LeaderboardController) Get(c *fiber.Ctx) error {
serverIDStr := c.Params("id")
serverID, err := uuid.Parse(serverIDStr)
if err != nil {
return lc.errorHandler.HandleUUIDError(c, "server ID")
}
data, err := lc.service.Get(c.UserContext(), serverID)
if err != nil {
return lc.errorHandler.HandleServiceError(c, err)
}
return c.JSON(data)
}
// Update replaces the leaderboard for a server (requires JWT auth).
func (lc *LeaderboardController) Update(c *fiber.Ctx) error {
serverIDStr := c.Params("id")
serverID, err := uuid.Parse(serverIDStr)
if err != nil {
return lc.errorHandler.HandleUUIDError(c, "server ID")
}
var input model.Leaderboard
if err := c.BodyParser(&input); err != nil {
return lc.errorHandler.HandleParsingError(c, err)
}
userInfo, _ := c.Locals("userInfo").(*middleware.CachedUserInfo)
actorID, actorUsername := "", "system"
if userInfo != nil {
actorID = userInfo.UserID
actorUsername = userInfo.Username
}
data, err := lc.service.Update(c.UserContext(), serverID, &input, actorID, actorUsername)
if err != nil {
return lc.errorHandler.HandleServiceError(c, err)
}
return c.JSON(data)
}

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

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

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

@@ -29,7 +29,6 @@ func NewServerController(ss *service.ServerService, routeGroups *common.RouteGro
serverRoutes.Get("/", auth.HasPermission(model.ServerView), ac.GetAll) serverRoutes.Get("/", auth.HasPermission(model.ServerView), ac.GetAll)
serverRoutes.Get("/:id", auth.HasPermission(model.ServerView), ac.GetById) serverRoutes.Get("/:id", auth.HasPermission(model.ServerView), ac.GetById)
serverRoutes.Post("/", auth.HasPermission(model.ServerCreate), ac.CreateServer) serverRoutes.Post("/", auth.HasPermission(model.ServerCreate), ac.CreateServer)
serverRoutes.Put("/:id", auth.HasPermission(model.ServerUpdate), ac.UpdateServer)
serverRoutes.Delete("/:id", auth.HasPermission(model.ServerDelete), ac.DeleteServer) serverRoutes.Delete("/:id", auth.HasPermission(model.ServerDelete), ac.DeleteServer)
apiServerRoutes := routeGroups.Api.Group("/server") apiServerRoutes := routeGroups.Api.Group("/server")
@@ -139,45 +138,16 @@ func (ac *ServerController) CreateServer(c *fiber.Ctx) error {
if err := c.BodyParser(server); err != nil { if err := c.BodyParser(server); err != nil {
return ac.errorHandler.HandleParsingError(c, err) return ac.errorHandler.HandleParsingError(c, err)
} }
ac.service.GenerateServerPath(server)
if err := ac.service.CreateServer(c, server); err != nil { server.GenerateUUID()
// Use async server creation to avoid blocking other requests
if err := ac.service.CreateServerAsync(c, server); err != nil {
return ac.errorHandler.HandleServiceError(c, err) return ac.errorHandler.HandleServiceError(c, err)
} }
return c.JSON(server)
}
// UpdateServer updates an existing server // Return immediately with server details
// @Summary Update an ACC server // The actual creation will happen in the background with WebSocket updates
// @Description Update configuration for an existing ACC server
// @Tags Server
// @Accept json
// @Produce json
// @Param id path string true "Server ID (UUID format)"
// @Param server body model.Server true "Updated server configuration"
// @Success 200 {object} object "Updated server details"
// @Failure 400 {object} error_handler.ErrorResponse "Invalid server data or ID"
// @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} [put]
func (ac *ServerController) UpdateServer(c *fiber.Ctx) error {
serverIDStr := c.Params("id")
serverID, err := uuid.Parse(serverIDStr)
if err != nil {
return ac.errorHandler.HandleUUIDError(c, "server ID")
}
server := new(model.Server)
if err := c.BodyParser(server); err != nil {
return ac.errorHandler.HandleParsingError(c, err)
}
server.ID = serverID
if err := ac.service.UpdateServer(c, server); err != nil {
return ac.errorHandler.HandleServiceError(c, err)
}
return c.JSON(server) return c.JSON(server)
} }

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

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

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

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

View File

@@ -1,139 +0,0 @@
package controller
import (
"acc-server-manager/local/middleware"
"acc-server-manager/local/model"
"acc-server-manager/local/utl/common"
"acc-server-manager/local/utl/error_handler"
"acc-server-manager/local/utl/jwt"
"github.com/gofiber/fiber/v2"
)
type Steam2FAController struct {
tfaManager *model.Steam2FAManager
errorHandler *error_handler.ControllerErrorHandler
jwtHandler *jwt.OpenJWTHandler
}
func NewSteam2FAController(tfaManager *model.Steam2FAManager, routeGroups *common.RouteGroups, auth *middleware.AuthMiddleware, jwtHandler *jwt.OpenJWTHandler) *Steam2FAController {
controller := &Steam2FAController{
tfaManager: tfaManager,
errorHandler: error_handler.NewControllerErrorHandler(),
jwtHandler: jwtHandler,
}
steam2faRoutes := routeGroups.Steam2FA
steam2faRoutes.Use(auth.AuthenticateOpen)
// Define routes
steam2faRoutes.Get("/pending", auth.HasPermission(model.ServerView), controller.GetPendingRequests)
steam2faRoutes.Get("/:id", auth.HasPermission(model.ServerView), controller.GetRequest)
steam2faRoutes.Post("/:id/complete", auth.HasPermission(model.ServerUpdate), controller.CompleteRequest)
steam2faRoutes.Post("/:id/cancel", auth.HasPermission(model.ServerUpdate), controller.CancelRequest)
return controller
}
// GetPendingRequests gets all pending 2FA requests
//
// @Summary Get pending 2FA requests
// @Description Get all pending Steam 2FA authentication requests
// @Tags Steam 2FA
// @Accept json
// @Produce json
// @Success 200 {array} model.Steam2FARequest
// @Failure 500 {object} error_handler.ErrorResponse
// @Router /steam2fa/pending [get]
func (c *Steam2FAController) GetPendingRequests(ctx *fiber.Ctx) error {
requests := c.tfaManager.GetPendingRequests()
return ctx.JSON(requests)
}
// GetRequest gets a specific 2FA request by ID
//
// @Summary Get 2FA request
// @Description Get a specific Steam 2FA authentication request by ID
// @Tags Steam 2FA
// @Accept json
// @Produce json
// @Param id path string true "2FA Request ID"
// @Success 200 {object} model.Steam2FARequest
// @Failure 404 {object} error_handler.ErrorResponse
// @Failure 500 {object} error_handler.ErrorResponse
// @Router /steam2fa/{id} [get]
func (c *Steam2FAController) GetRequest(ctx *fiber.Ctx) error {
id := ctx.Params("id")
if id == "" {
return c.errorHandler.HandleError(ctx, fiber.ErrBadRequest, fiber.StatusBadRequest)
}
request, exists := c.tfaManager.GetRequest(id)
if !exists {
return c.errorHandler.HandleNotFoundError(ctx, "2FA request")
}
return ctx.JSON(request)
}
// CompleteRequest marks a 2FA request as completed
//
// @Summary Complete 2FA request
// @Description Mark a Steam 2FA authentication request as completed
// @Tags Steam 2FA
// @Accept json
// @Produce json
// @Param id path string true "2FA Request ID"
// @Success 200 {object} model.Steam2FARequest
// @Failure 400 {object} error_handler.ErrorResponse
// @Failure 404 {object} error_handler.ErrorResponse
// @Failure 500 {object} error_handler.ErrorResponse
// @Router /steam2fa/{id}/complete [post]
func (c *Steam2FAController) CompleteRequest(ctx *fiber.Ctx) error {
id := ctx.Params("id")
if id == "" {
return c.errorHandler.HandleError(ctx, fiber.ErrBadRequest, fiber.StatusBadRequest)
}
if err := c.tfaManager.CompleteRequest(id); err != nil {
return c.errorHandler.HandleError(ctx, err, fiber.StatusBadRequest)
}
request, exists := c.tfaManager.GetRequest(id)
if !exists {
return c.errorHandler.HandleNotFoundError(ctx, "2FA request")
}
return ctx.JSON(request)
}
// CancelRequest cancels a 2FA request
//
// @Summary Cancel 2FA request
// @Description Cancel a Steam 2FA authentication request
// @Tags Steam 2FA
// @Accept json
// @Produce json
// @Param id path string true "2FA Request ID"
// @Success 200 {object} model.Steam2FARequest
// @Failure 400 {object} error_handler.ErrorResponse
// @Failure 404 {object} error_handler.ErrorResponse
// @Failure 500 {object} error_handler.ErrorResponse
// @Router /steam2fa/{id}/cancel [post]
func (c *Steam2FAController) CancelRequest(ctx *fiber.Ctx) error {
id := ctx.Params("id")
if id == "" {
return c.errorHandler.HandleError(ctx, fiber.ErrBadRequest, fiber.StatusBadRequest)
}
if err := c.tfaManager.ErrorRequest(id, "cancelled by user"); err != nil {
return c.errorHandler.HandleError(ctx, err, fiber.StatusBadRequest)
}
request, exists := c.tfaManager.GetRequest(id)
if !exists {
return c.errorHandler.HandleNotFoundError(ctx, "2FA request")
}
return ctx.JSON(request)
}

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

138
local/controller/websocket.go Executable file
View File

@@ -0,0 +1,138 @@
package controller
import (
"acc-server-manager/local/middleware"
"acc-server-manager/local/service"
"acc-server-manager/local/utl/common"
"acc-server-manager/local/utl/jwt"
"acc-server-manager/local/utl/logging"
"github.com/gofiber/fiber/v2"
"github.com/gofiber/websocket/v2"
"github.com/google/uuid"
)
type WebSocketController struct {
webSocketService *service.WebSocketService
jwtHandler *jwt.OpenJWTHandler
}
func NewWebSocketController(
wsService *service.WebSocketService,
jwtHandler *jwt.OpenJWTHandler,
routeGroups *common.RouteGroups,
auth *middleware.AuthMiddleware,
) *WebSocketController {
wsc := &WebSocketController{
webSocketService: wsService,
jwtHandler: jwtHandler,
}
wsRoutes := routeGroups.WebSocket
wsRoutes.Use("/", wsc.upgradeWebSocket)
wsRoutes.Get("/", websocket.New(wsc.handleWebSocket))
return wsc
}
func (wsc *WebSocketController) upgradeWebSocket(c *fiber.Ctx) error {
if websocket.IsWebSocketUpgrade(c) {
token := c.Query("token")
if token == "" {
token = c.Get("Authorization")
if token != "" && len(token) > 7 && token[:7] == "Bearer " {
token = token[7:]
}
}
if token == "" {
return fiber.NewError(fiber.StatusUnauthorized, "Missing authentication token")
}
claims, err := wsc.jwtHandler.ValidateToken(token)
if err != nil {
return fiber.NewError(fiber.StatusUnauthorized, "Invalid authentication token")
}
userID, err := uuid.Parse(claims.UserID)
if err != nil {
return fiber.NewError(fiber.StatusUnauthorized, "Invalid user ID in token")
}
c.Locals("userID", userID)
c.Locals("username", claims.UserID)
return c.Next()
}
return fiber.NewError(fiber.StatusUpgradeRequired, "WebSocket upgrade required")
}
func (wsc *WebSocketController) handleWebSocket(c *websocket.Conn) {
connID := uuid.New().String()
userID, ok := c.Locals("userID").(uuid.UUID)
if !ok {
logging.Error("Failed to get user ID from WebSocket connection")
c.Close()
return
}
username, _ := c.Locals("username").(string)
logging.Info("WebSocket connection established for user: %s (ID: %s)", username, userID.String())
wsc.webSocketService.AddConnection(connID, c, &userID)
defer func() {
wsc.webSocketService.RemoveConnection(connID)
logging.Info("WebSocket connection closed for user: %s", username)
}()
for {
messageType, message, err := c.ReadMessage()
if err != nil {
if websocket.IsUnexpectedCloseError(err, websocket.CloseGoingAway, websocket.CloseAbnormalClosure) {
logging.Error("WebSocket error for user %s: %v", username, err)
}
break
}
switch messageType {
case websocket.TextMessage:
wsc.handleTextMessage(connID, userID, message)
case websocket.BinaryMessage:
logging.Debug("Received binary message from user %s (not supported)", username)
case websocket.PingMessage:
if err := c.WriteMessage(websocket.PongMessage, nil); err != nil {
logging.Error("Failed to send pong to user %s: %v", username, err)
break
}
}
}
}
func (wsc *WebSocketController) handleTextMessage(connID string, userID uuid.UUID, message []byte) {
logging.Debug("Received WebSocket message from user %s: %s", userID.String(), string(message))
messageStr := string(message)
if len(messageStr) > 10 && messageStr[:9] == "server_id" {
if serverIDStr := messageStr[10:]; len(serverIDStr) > 0 {
if serverID, err := uuid.Parse(serverIDStr); err == nil {
wsc.webSocketService.SetServerID(connID, serverID)
logging.Info("Associated WebSocket connection %s with server %s", connID, serverID.String())
}
}
}
}
func (wsc *WebSocketController) GetWebSocketUpgrade() fiber.Handler {
return wsc.upgradeWebSocket
}
func (wsc *WebSocketController) GetWebSocketHandler() func(*websocket.Conn) {
return wsc.handleWebSocket
}
func (wsc *WebSocketController) BroadcastServerCreationProgress(serverID uuid.UUID, step string, status string, message string) {
logging.Info("Broadcasting server creation progress: %s - %s: %s", serverID.String(), step, status)
}

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

@@ -10,12 +10,10 @@ import (
"github.com/google/uuid" "github.com/google/uuid"
) )
// AccessKeyMiddleware provides authentication and permission middleware.
type AccessKeyMiddleware struct { type AccessKeyMiddleware struct {
userInfo CachedUserInfo userInfo CachedUserInfo
} }
// NewAccessKeyMiddleware creates a new AccessKeyMiddleware.
func NewAccessKeyMiddleware() *AccessKeyMiddleware { func NewAccessKeyMiddleware() *AccessKeyMiddleware {
auth := &AccessKeyMiddleware{ auth := &AccessKeyMiddleware{
userInfo: CachedUserInfo{UserID: uuid.New().String(), Username: "access_key", RoleName: "Admin", Permissions: map[string]bool{ userInfo: CachedUserInfo{UserID: uuid.New().String(), Username: "access_key", RoleName: "Admin", Permissions: map[string]bool{
@@ -25,9 +23,7 @@ func NewAccessKeyMiddleware() *AccessKeyMiddleware {
return auth return auth
} }
// Authenticate is a middleware for JWT authentication with enhanced security.
func (m *AccessKeyMiddleware) Authenticate(ctx *fiber.Ctx) error { func (m *AccessKeyMiddleware) Authenticate(ctx *fiber.Ctx) error {
// Log authentication attempt
ip := ctx.IP() ip := ctx.IP()
userAgent := ctx.Get("User-Agent") userAgent := ctx.Get("User-Agent")

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

@@ -16,7 +16,6 @@ import (
"github.com/google/uuid" "github.com/google/uuid"
) )
// CachedUserInfo holds cached user authentication and permission data
type CachedUserInfo struct { type CachedUserInfo struct {
UserID string UserID string
Username string Username string
@@ -25,7 +24,6 @@ type CachedUserInfo struct {
CachedAt time.Time CachedAt time.Time
} }
// AuthMiddleware provides authentication and permission middleware.
type AuthMiddleware struct { type AuthMiddleware struct {
membershipService *service.MembershipService membershipService *service.MembershipService
cache *cache.InMemoryCache cache *cache.InMemoryCache
@@ -34,7 +32,6 @@ type AuthMiddleware struct {
openJWTHandler *jwt.OpenJWTHandler openJWTHandler *jwt.OpenJWTHandler
} }
// NewAuthMiddleware creates a new AuthMiddleware.
func NewAuthMiddleware(ms *service.MembershipService, cache *cache.InMemoryCache, jwtHandler *jwt.JWTHandler, openJWTHandler *jwt.OpenJWTHandler) *AuthMiddleware { func NewAuthMiddleware(ms *service.MembershipService, cache *cache.InMemoryCache, jwtHandler *jwt.JWTHandler, openJWTHandler *jwt.OpenJWTHandler) *AuthMiddleware {
auth := &AuthMiddleware{ auth := &AuthMiddleware{
membershipService: ms, membershipService: ms,
@@ -44,24 +41,20 @@ func NewAuthMiddleware(ms *service.MembershipService, cache *cache.InMemoryCache
openJWTHandler: openJWTHandler, openJWTHandler: openJWTHandler,
} }
// Set up bidirectional relationship for cache invalidation
ms.SetCacheInvalidator(auth) ms.SetCacheInvalidator(auth)
return auth return auth
} }
// Authenticate is a middleware for JWT authentication with enhanced security.
func (m *AuthMiddleware) AuthenticateOpen(ctx *fiber.Ctx) error { func (m *AuthMiddleware) AuthenticateOpen(ctx *fiber.Ctx) error {
return m.AuthenticateWithHandler(m.openJWTHandler.JWTHandler, true, ctx) return m.AuthenticateWithHandler(m.openJWTHandler.JWTHandler, true, ctx)
} }
// Authenticate is a middleware for JWT authentication with enhanced security.
func (m *AuthMiddleware) Authenticate(ctx *fiber.Ctx) error { func (m *AuthMiddleware) Authenticate(ctx *fiber.Ctx) error {
return m.AuthenticateWithHandler(m.jwtHandler, false, ctx) return m.AuthenticateWithHandler(m.jwtHandler, false, ctx)
} }
func (m *AuthMiddleware) AuthenticateWithHandler(jwtHandler *jwt.JWTHandler, isOpenToken bool, ctx *fiber.Ctx) error { func (m *AuthMiddleware) AuthenticateWithHandler(jwtHandler *jwt.JWTHandler, isOpenToken bool, ctx *fiber.Ctx) error {
// Log authentication attempt
ip := ctx.IP() ip := ctx.IP()
userAgent := ctx.Get("User-Agent") userAgent := ctx.Get("User-Agent")
@@ -89,7 +82,6 @@ func (m *AuthMiddleware) AuthenticateWithHandler(jwtHandler *jwt.JWTHandler, isO
}) })
} }
// Validate token length to prevent potential attacks
token := parts[1] token := parts[1]
if len(token) < 10 || len(token) > 2048 { if len(token) < 10 || len(token) > 2048 {
logging.Error("Authentication failed: invalid token length from IP %s", ip) logging.Error("Authentication failed: invalid token length from IP %s", ip)
@@ -113,7 +105,6 @@ func (m *AuthMiddleware) AuthenticateWithHandler(jwtHandler *jwt.JWTHandler, isO
}) })
} }
// Additional security: validate user ID format
if claims.UserID == "" || len(claims.UserID) < 10 { if claims.UserID == "" || len(claims.UserID) < 10 {
logging.Error("Authentication failed: invalid user ID in token from IP %s", ip) logging.Error("Authentication failed: invalid user ID in token from IP %s", ip)
return ctx.Status(fiber.StatusUnauthorized).JSON(fiber.Map{ return ctx.Status(fiber.StatusUnauthorized).JSON(fiber.Map{
@@ -124,10 +115,10 @@ 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 {
// Preload and cache user info to avoid database queries on permission checks
userInfo, err := m.getCachedUserInfo(ctx.UserContext(), claims.UserID) userInfo, err := m.getCachedUserInfo(ctx.UserContext(), claims.UserID)
if err != nil { if err != nil {
logging.Error("Authentication failed: unable to load user info for %s from IP %s: %v", claims.UserID, ip, err) logging.Error("Authentication failed: unable to load user info for %s from IP %s: %v", claims.UserID, ip, err)
@@ -137,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())
} }
@@ -145,7 +137,6 @@ func (m *AuthMiddleware) AuthenticateWithHandler(jwtHandler *jwt.JWTHandler, isO
return ctx.Next() return ctx.Next()
} }
// HasPermission is a middleware for checking user permissions with enhanced logging.
func (m *AuthMiddleware) HasPermission(requiredPermission string) fiber.Handler { func (m *AuthMiddleware) HasPermission(requiredPermission string) fiber.Handler {
return func(ctx *fiber.Ctx) error { return func(ctx *fiber.Ctx) error {
userID, ok := ctx.Locals("userID").(string) userID, ok := ctx.Locals("userID").(string)
@@ -160,7 +151,6 @@ func (m *AuthMiddleware) HasPermission(requiredPermission string) fiber.Handler
return ctx.Next() return ctx.Next()
} }
// Validate permission parameter
if requiredPermission == "" { if requiredPermission == "" {
logging.Error("Permission check failed: empty permission requirement") logging.Error("Permission check failed: empty permission requirement")
return ctx.Status(fiber.StatusInternalServerError).JSON(fiber.Map{ return ctx.Status(fiber.StatusInternalServerError).JSON(fiber.Map{
@@ -168,7 +158,6 @@ func (m *AuthMiddleware) HasPermission(requiredPermission string) fiber.Handler
}) })
} }
// Use cached user info from authentication step - no database queries needed
userInfo, ok := ctx.Locals("userInfo").(*CachedUserInfo) userInfo, ok := ctx.Locals("userInfo").(*CachedUserInfo)
if !ok { if !ok {
logging.Error("Permission check failed: no cached user info in context from IP %s", ctx.IP()) logging.Error("Permission check failed: no cached user info in context from IP %s", ctx.IP())
@@ -177,7 +166,6 @@ func (m *AuthMiddleware) HasPermission(requiredPermission string) fiber.Handler
}) })
} }
// Check if user has permission using cached data
has := m.hasPermissionFromCache(userInfo, requiredPermission) has := m.hasPermissionFromCache(userInfo, requiredPermission)
if !has { if !has {
@@ -192,16 +180,30 @@ func (m *AuthMiddleware) HasPermission(requiredPermission string) fiber.Handler
} }
} }
// AuthRateLimit applies rate limiting specifically for authentication endpoints 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()
} }
// RequireHTTPS redirects HTTP requests to HTTPS in production
func (m *AuthMiddleware) RequireHTTPS() fiber.Handler { func (m *AuthMiddleware) RequireHTTPS() fiber.Handler {
return func(ctx *fiber.Ctx) error { return func(ctx *fiber.Ctx) error {
if ctx.Protocol() != "https" && ctx.Get("X-Forwarded-Proto") != "https" { if ctx.Protocol() != "https" && ctx.Get("X-Forwarded-Proto") != "https" {
// Allow HTTP in development/testing
if ctx.Hostname() != "localhost" && ctx.Hostname() != "127.0.0.1" { if ctx.Hostname() != "localhost" && ctx.Hostname() != "127.0.0.1" {
httpsURL := "https://" + ctx.Hostname() + ctx.OriginalURL() httpsURL := "https://" + ctx.Hostname() + ctx.OriginalURL()
return ctx.Redirect(httpsURL, fiber.StatusMovedPermanently) return ctx.Redirect(httpsURL, fiber.StatusMovedPermanently)
@@ -211,11 +213,9 @@ func (m *AuthMiddleware) RequireHTTPS() fiber.Handler {
} }
} }
// getCachedUserInfo retrieves and caches complete user information including permissions
func (m *AuthMiddleware) getCachedUserInfo(ctx context.Context, userID string) (*CachedUserInfo, error) { func (m *AuthMiddleware) getCachedUserInfo(ctx context.Context, userID string) (*CachedUserInfo, error) {
cacheKey := fmt.Sprintf("userinfo:%s", userID) cacheKey := fmt.Sprintf("userinfo:%s", userID)
// Try cache first
if cached, found := m.cache.Get(cacheKey); found { if cached, found := m.cache.Get(cacheKey); found {
if userInfo, ok := cached.(*CachedUserInfo); ok { if userInfo, ok := cached.(*CachedUserInfo); ok {
logging.DebugWithContext("AUTH_CACHE", "User info for %s found in cache", userID) logging.DebugWithContext("AUTH_CACHE", "User info for %s found in cache", userID)
@@ -223,13 +223,11 @@ func (m *AuthMiddleware) getCachedUserInfo(ctx context.Context, userID string) (
} }
} }
// Cache miss - load from database
user, err := m.membershipService.GetUserWithPermissions(ctx, userID) user, err := m.membershipService.GetUserWithPermissions(ctx, userID)
if err != nil { if err != nil {
return nil, err return nil, err
} }
// Build permission map for fast lookups
permissions := make(map[string]bool) permissions := make(map[string]bool)
for _, p := range user.Role.Permissions { for _, p := range user.Role.Permissions {
permissions[p.Name] = true permissions[p.Name] = true
@@ -243,34 +241,26 @@ func (m *AuthMiddleware) getCachedUserInfo(ctx context.Context, userID string) (
CachedAt: time.Now(), CachedAt: time.Now(),
} }
// Cache for 15 minutes
m.cache.Set(cacheKey, userInfo, 15*time.Minute) m.cache.Set(cacheKey, userInfo, 15*time.Minute)
logging.DebugWithContext("AUTH_CACHE", "User info for %s cached with %d permissions", userID, len(permissions)) logging.DebugWithContext("AUTH_CACHE", "User info for %s cached with %d permissions", userID, len(permissions))
return userInfo, nil return userInfo, nil
} }
// hasPermissionFromCache checks permissions using cached user info (no database queries)
func (m *AuthMiddleware) hasPermissionFromCache(userInfo *CachedUserInfo, permission string) bool { func (m *AuthMiddleware) hasPermissionFromCache(userInfo *CachedUserInfo, permission string) bool {
// Super Admin and Admin have all permissions
if userInfo.RoleName == "Super Admin" || userInfo.RoleName == "Admin" { if userInfo.RoleName == "Super Admin" || userInfo.RoleName == "Admin" {
return true return true
} }
// Check specific permission in cached map
return userInfo.Permissions[permission] return userInfo.Permissions[permission]
} }
// InvalidateUserPermissions removes cached user info for a user
func (m *AuthMiddleware) InvalidateUserPermissions(userID string) { func (m *AuthMiddleware) InvalidateUserPermissions(userID string) {
cacheKey := fmt.Sprintf("userinfo:%s", userID) cacheKey := fmt.Sprintf("userinfo:%s", userID)
m.cache.Delete(cacheKey) m.cache.Delete(cacheKey)
logging.InfoWithContext("AUTH_CACHE", "User info cache invalidated for user %s", userID) logging.InfoWithContext("AUTH_CACHE", "User info cache invalidated for user %s", userID)
} }
// InvalidateAllUserPermissions clears all cached user info (useful for role/permission changes)
func (m *AuthMiddleware) InvalidateAllUserPermissions() { func (m *AuthMiddleware) InvalidateAllUserPermissions() {
// This would need to be implemented based on your cache interface
// For now, just log that invalidation was requested
logging.InfoWithContext("AUTH_CACHE", "All user info caches invalidation requested") logging.InfoWithContext("AUTH_CACHE", "All user info caches invalidation requested")
} }

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

@@ -7,25 +7,20 @@ import (
"github.com/gofiber/fiber/v2" "github.com/gofiber/fiber/v2"
) )
// RequestLoggingMiddleware logs HTTP requests and responses
type RequestLoggingMiddleware struct { type RequestLoggingMiddleware struct {
infoLogger *logging.InfoLogger infoLogger *logging.InfoLogger
} }
// NewRequestLoggingMiddleware creates a new request logging middleware
func NewRequestLoggingMiddleware() *RequestLoggingMiddleware { func NewRequestLoggingMiddleware() *RequestLoggingMiddleware {
return &RequestLoggingMiddleware{ return &RequestLoggingMiddleware{
infoLogger: logging.GetInfoLogger(), infoLogger: logging.GetInfoLogger(),
} }
} }
// Handler returns the middleware handler function
func (rlm *RequestLoggingMiddleware) Handler() fiber.Handler { func (rlm *RequestLoggingMiddleware) Handler() fiber.Handler {
return func(c *fiber.Ctx) error { return func(c *fiber.Ctx) error {
// Record start time
start := time.Now() start := time.Now()
// Log incoming request
userAgent := c.Get("User-Agent") userAgent := c.Get("User-Agent")
if userAgent == "" { if userAgent == "" {
userAgent = "Unknown" userAgent = "Unknown"
@@ -33,17 +28,13 @@ func (rlm *RequestLoggingMiddleware) Handler() fiber.Handler {
rlm.infoLogger.LogRequest(c.Method(), c.OriginalURL(), userAgent) rlm.infoLogger.LogRequest(c.Method(), c.OriginalURL(), userAgent)
// Continue to next handler
err := c.Next() err := c.Next()
// Calculate duration
duration := time.Since(start) duration := time.Since(start)
// Log response
statusCode := c.Response().StatusCode() statusCode := c.Response().StatusCode()
rlm.infoLogger.LogResponse(c.Method(), c.OriginalURL(), statusCode, duration.String()) rlm.infoLogger.LogResponse(c.Method(), c.OriginalURL(), statusCode, duration.String())
// Log error if present
if err != nil { if err != nil {
logging.ErrorWithContext("REQUEST_MIDDLEWARE", "Request failed: %v", err) logging.ErrorWithContext("REQUEST_MIDDLEWARE", "Request failed: %v", err)
} }
@@ -52,10 +43,8 @@ func (rlm *RequestLoggingMiddleware) Handler() fiber.Handler {
} }
} }
// Global request logging middleware instance
var globalRequestLoggingMiddleware *RequestLoggingMiddleware var globalRequestLoggingMiddleware *RequestLoggingMiddleware
// GetRequestLoggingMiddleware returns the global request logging middleware
func GetRequestLoggingMiddleware() *RequestLoggingMiddleware { func GetRequestLoggingMiddleware() *RequestLoggingMiddleware {
if globalRequestLoggingMiddleware == nil { if globalRequestLoggingMiddleware == nil {
globalRequestLoggingMiddleware = NewRequestLoggingMiddleware() globalRequestLoggingMiddleware = NewRequestLoggingMiddleware()
@@ -63,7 +52,6 @@ func GetRequestLoggingMiddleware() *RequestLoggingMiddleware {
return globalRequestLoggingMiddleware return globalRequestLoggingMiddleware
} }
// Handler returns the global request logging middleware handler
func Handler() fiber.Handler { func Handler() fiber.Handler {
return GetRequestLoggingMiddleware().Handler() return GetRequestLoggingMiddleware().Handler()
} }

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

@@ -11,19 +11,16 @@ import (
"github.com/gofiber/fiber/v2" "github.com/gofiber/fiber/v2"
) )
// RateLimiter stores rate limiting information
type RateLimiter struct { type RateLimiter struct {
requests map[string][]time.Time requests map[string][]time.Time
mutex sync.RWMutex mutex sync.RWMutex
} }
// NewRateLimiter creates a new rate limiter
func NewRateLimiter() *RateLimiter { func NewRateLimiter() *RateLimiter {
rl := &RateLimiter{ rl := &RateLimiter{
requests: make(map[string][]time.Time), requests: make(map[string][]time.Time),
} }
// Use graceful shutdown for cleanup goroutine
shutdownManager := graceful.GetManager() shutdownManager := graceful.GetManager()
shutdownManager.RunGoroutine(func(ctx context.Context) { shutdownManager.RunGoroutine(func(ctx context.Context) {
rl.cleanupWithContext(ctx) rl.cleanupWithContext(ctx)
@@ -32,7 +29,6 @@ func NewRateLimiter() *RateLimiter {
return rl return rl
} }
// cleanup removes old entries from the rate limiter
func (rl *RateLimiter) cleanupWithContext(ctx context.Context) { func (rl *RateLimiter) cleanupWithContext(ctx context.Context) {
ticker := time.NewTicker(5 * time.Minute) ticker := time.NewTicker(5 * time.Minute)
defer ticker.Stop() defer ticker.Stop()
@@ -62,44 +58,29 @@ func (rl *RateLimiter) cleanupWithContext(ctx context.Context) {
} }
} }
// SecurityMiddleware provides comprehensive security middleware
type SecurityMiddleware struct { type SecurityMiddleware struct {
rateLimiter *RateLimiter rateLimiter *RateLimiter
} }
// NewSecurityMiddleware creates a new security middleware
func NewSecurityMiddleware() *SecurityMiddleware { func NewSecurityMiddleware() *SecurityMiddleware {
return &SecurityMiddleware{ return &SecurityMiddleware{
rateLimiter: NewRateLimiter(), rateLimiter: NewRateLimiter(),
} }
} }
// SecurityHeaders adds security headers to responses
func (sm *SecurityMiddleware) SecurityHeaders() fiber.Handler { func (sm *SecurityMiddleware) SecurityHeaders() fiber.Handler {
return func(c *fiber.Ctx) error { return func(c *fiber.Ctx) error {
// Prevent MIME type sniffing
c.Set("X-Content-Type-Options", "nosniff") c.Set("X-Content-Type-Options", "nosniff")
// Prevent clickjacking
c.Set("X-Frame-Options", "DENY") c.Set("X-Frame-Options", "DENY")
// Enable XSS protection
c.Set("X-XSS-Protection", "1; mode=block") c.Set("X-XSS-Protection", "1; mode=block")
// Prevent referrer leakage
c.Set("Referrer-Policy", "strict-origin-when-cross-origin") c.Set("Referrer-Policy", "strict-origin-when-cross-origin")
// Content Security Policy
c.Set("Content-Security-Policy", "default-src 'self'; script-src 'self' 'unsafe-inline'; style-src 'self' 'unsafe-inline'; img-src 'self' data: https:; font-src 'self' data:; connect-src 'self'; frame-ancestors 'none'") c.Set("Content-Security-Policy", "default-src 'self'; script-src 'self' 'unsafe-inline'; style-src 'self' 'unsafe-inline'; img-src 'self' data: https:; font-src 'self' data:; connect-src 'self'; frame-ancestors 'none'")
// Permissions Policy
c.Set("Permissions-Policy", "camera=(), microphone=(), geolocation=(), interest-cohort=()") c.Set("Permissions-Policy", "camera=(), microphone=(), geolocation=(), interest-cohort=()")
return c.Next() return c.Next()
} }
} }
// RateLimit implements rate limiting for API endpoints
func (sm *SecurityMiddleware) RateLimit(maxRequests int, duration time.Duration) fiber.Handler { func (sm *SecurityMiddleware) RateLimit(maxRequests int, duration time.Duration) fiber.Handler {
return func(c *fiber.Ctx) error { return func(c *fiber.Ctx) error {
ip := c.IP() ip := c.IP()
@@ -111,7 +92,6 @@ func (sm *SecurityMiddleware) RateLimit(maxRequests int, duration time.Duration)
now := time.Now() now := time.Now()
requests := sm.rateLimiter.requests[key] requests := sm.rateLimiter.requests[key]
// Remove requests older than duration
filtered := make([]time.Time, 0, len(requests)) filtered := make([]time.Time, 0, len(requests))
for _, t := range requests { for _, t := range requests {
if now.Sub(t) < duration { if now.Sub(t) < duration {
@@ -119,7 +99,6 @@ func (sm *SecurityMiddleware) RateLimit(maxRequests int, duration time.Duration)
} }
} }
// Check if limit is exceeded
if len(filtered) >= maxRequests { if len(filtered) >= maxRequests {
return c.Status(fiber.StatusTooManyRequests).JSON(fiber.Map{ return c.Status(fiber.StatusTooManyRequests).JSON(fiber.Map{
"error": "Rate limit exceeded", "error": "Rate limit exceeded",
@@ -127,7 +106,6 @@ func (sm *SecurityMiddleware) RateLimit(maxRequests int, duration time.Duration)
}) })
} }
// Add current request
filtered = append(filtered, now) filtered = append(filtered, now)
sm.rateLimiter.requests[key] = filtered sm.rateLimiter.requests[key] = filtered
@@ -135,7 +113,6 @@ func (sm *SecurityMiddleware) RateLimit(maxRequests int, duration time.Duration)
} }
} }
// AuthRateLimit implements stricter rate limiting for authentication endpoints
func (sm *SecurityMiddleware) AuthRateLimit() fiber.Handler { func (sm *SecurityMiddleware) AuthRateLimit() fiber.Handler {
return func(c *fiber.Ctx) error { return func(c *fiber.Ctx) error {
ip := c.IP() ip := c.IP()
@@ -148,7 +125,6 @@ func (sm *SecurityMiddleware) AuthRateLimit() fiber.Handler {
now := time.Now() now := time.Now()
requests := sm.rateLimiter.requests[key] requests := sm.rateLimiter.requests[key]
// Remove requests older than 15 minutes
filtered := make([]time.Time, 0, len(requests)) filtered := make([]time.Time, 0, len(requests))
for _, t := range requests { for _, t := range requests {
if now.Sub(t) < 15*time.Minute { if now.Sub(t) < 15*time.Minute {
@@ -156,7 +132,6 @@ func (sm *SecurityMiddleware) AuthRateLimit() fiber.Handler {
} }
} }
// Check if limit is exceeded (5 requests per 15 minutes for auth)
if len(filtered) >= 5 { if len(filtered) >= 5 {
return c.Status(fiber.StatusTooManyRequests).JSON(fiber.Map{ return c.Status(fiber.StatusTooManyRequests).JSON(fiber.Map{
"error": "Too many authentication attempts", "error": "Too many authentication attempts",
@@ -164,7 +139,6 @@ func (sm *SecurityMiddleware) AuthRateLimit() fiber.Handler {
}) })
} }
// Add current request
filtered = append(filtered, now) filtered = append(filtered, now)
sm.rateLimiter.requests[key] = filtered sm.rateLimiter.requests[key] = filtered
@@ -172,20 +146,16 @@ func (sm *SecurityMiddleware) AuthRateLimit() fiber.Handler {
} }
} }
// InputSanitization sanitizes user input to prevent XSS and injection attacks
func (sm *SecurityMiddleware) InputSanitization() fiber.Handler { func (sm *SecurityMiddleware) InputSanitization() fiber.Handler {
return func(c *fiber.Ctx) error { return func(c *fiber.Ctx) error {
// Sanitize query parameters
c.Request().URI().QueryArgs().VisitAll(func(key, value []byte) { c.Request().URI().QueryArgs().VisitAll(func(key, value []byte) {
sanitized := sanitizeInput(string(value)) sanitized := sanitizeInput(string(value))
c.Request().URI().QueryArgs().Set(string(key), sanitized) c.Request().URI().QueryArgs().Set(string(key), sanitized)
}) })
// Store original body for processing
if c.Method() == "POST" || c.Method() == "PUT" || c.Method() == "PATCH" { if c.Method() == "POST" || c.Method() == "PUT" || c.Method() == "PATCH" {
body := c.Body() body := c.Body()
if len(body) > 0 { if len(body) > 0 {
// Basic sanitization - remove potentially dangerous patterns
sanitized := sanitizeInput(string(body)) sanitized := sanitizeInput(string(body))
c.Request().SetBodyString(sanitized) c.Request().SetBodyString(sanitized)
} }
@@ -195,7 +165,6 @@ func (sm *SecurityMiddleware) InputSanitization() fiber.Handler {
} }
} }
// sanitizeInput removes potentially dangerous patterns from input
func sanitizeInput(input string) string { func sanitizeInput(input string) string {
dangerous := []string{ dangerous := []string{
"<script", "<script",
@@ -254,7 +223,6 @@ func sanitizeInput(input string) string {
return result return result
} }
// ValidateContentType ensures only expected content types are accepted
func (sm *SecurityMiddleware) ValidateContentType(allowedTypes ...string) fiber.Handler { func (sm *SecurityMiddleware) ValidateContentType(allowedTypes ...string) fiber.Handler {
return func(c *fiber.Ctx) error { return func(c *fiber.Ctx) error {
if c.Method() == "POST" || c.Method() == "PUT" || c.Method() == "PATCH" { if c.Method() == "POST" || c.Method() == "PUT" || c.Method() == "PATCH" {
@@ -265,7 +233,6 @@ func (sm *SecurityMiddleware) ValidateContentType(allowedTypes ...string) fiber.
}) })
} }
// Check if content type is allowed
allowed := false allowed := false
for _, allowedType := range allowedTypes { for _, allowedType := range allowedTypes {
if strings.Contains(contentType, allowedType) { if strings.Contains(contentType, allowedType) {
@@ -285,7 +252,6 @@ func (sm *SecurityMiddleware) ValidateContentType(allowedTypes ...string) fiber.
} }
} }
// ValidateUserAgent blocks requests with suspicious or missing user agents
func (sm *SecurityMiddleware) ValidateUserAgent() fiber.Handler { func (sm *SecurityMiddleware) ValidateUserAgent() fiber.Handler {
suspiciousAgents := []string{ suspiciousAgents := []string{
"sqlmap", "sqlmap",
@@ -296,21 +262,19 @@ func (sm *SecurityMiddleware) ValidateUserAgent() fiber.Handler {
"dirb", "dirb",
"dirbuster", "dirbuster",
"wpscan", "wpscan",
"curl/7.0", // Very old curl versions "curl/7.0",
"wget/1.0", // Very old wget versions "wget/1.0",
} }
return func(c *fiber.Ctx) error { return func(c *fiber.Ctx) error {
userAgent := strings.ToLower(c.Get("User-Agent")) userAgent := strings.ToLower(c.Get("User-Agent"))
// Block empty user agents
if userAgent == "" { if userAgent == "" {
return c.Status(fiber.StatusBadRequest).JSON(fiber.Map{ return c.Status(fiber.StatusBadRequest).JSON(fiber.Map{
"error": "User-Agent header is required", "error": "User-Agent header is required",
}) })
} }
// Block suspicious user agents
for _, suspicious := range suspiciousAgents { for _, suspicious := range suspiciousAgents {
if strings.Contains(userAgent, suspicious) { if strings.Contains(userAgent, suspicious) {
return c.Status(fiber.StatusForbidden).JSON(fiber.Map{ return c.Status(fiber.StatusForbidden).JSON(fiber.Map{
@@ -323,7 +287,6 @@ func (sm *SecurityMiddleware) ValidateUserAgent() fiber.Handler {
} }
} }
// RequestSizeLimit limits the size of incoming requests
func (sm *SecurityMiddleware) RequestSizeLimit(maxSize int) fiber.Handler { func (sm *SecurityMiddleware) RequestSizeLimit(maxSize int) fiber.Handler {
return func(c *fiber.Ctx) error { return func(c *fiber.Ctx) error {
if c.Method() == "POST" || c.Method() == "PUT" || c.Method() == "PATCH" { if c.Method() == "POST" || c.Method() == "PUT" || c.Method() == "PATCH" {
@@ -340,19 +303,15 @@ func (sm *SecurityMiddleware) RequestSizeLimit(maxSize int) fiber.Handler {
} }
} }
// LogSecurityEvents logs security-related events
func (sm *SecurityMiddleware) LogSecurityEvents() fiber.Handler { func (sm *SecurityMiddleware) LogSecurityEvents() fiber.Handler {
return func(c *fiber.Ctx) error { return func(c *fiber.Ctx) error {
start := time.Now() start := time.Now()
// Process request
err := c.Next() err := c.Next()
// Log suspicious activity
status := c.Response().StatusCode() status := c.Response().StatusCode()
if status == 401 || status == 403 || status == 429 { if status == 401 || status == 403 || status == 429 {
duration := time.Since(start) duration := time.Since(start)
// In a real implementation, you would send this to your logging system
fmt.Printf("[SECURITY] %s %s %s %d %v %s\n", fmt.Printf("[SECURITY] %s %s %s %d %v %s\n",
time.Now().Format(time.RFC3339), time.Now().Format(time.RFC3339),
c.IP(), c.IP(),
@@ -367,7 +326,6 @@ func (sm *SecurityMiddleware) LogSecurityEvents() fiber.Handler {
} }
} }
// TimeoutMiddleware adds request timeout
func (sm *SecurityMiddleware) TimeoutMiddleware(timeout time.Duration) fiber.Handler { func (sm *SecurityMiddleware) TimeoutMiddleware(timeout time.Duration) fiber.Handler {
return func(c *fiber.Ctx) error { return func(c *fiber.Ctx) error {
ctx, cancel := context.WithTimeout(c.UserContext(), timeout) ctx, cancel := context.WithTimeout(c.UserContext(), timeout)

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

@@ -9,21 +9,17 @@ import (
"gorm.io/gorm" "gorm.io/gorm"
) )
// Migration001UpgradePasswordSecurity migrates existing user passwords from encrypted to hashed format
type Migration001UpgradePasswordSecurity struct { type Migration001UpgradePasswordSecurity struct {
DB *gorm.DB DB *gorm.DB
} }
// NewMigration001UpgradePasswordSecurity creates a new password security migration
func NewMigration001UpgradePasswordSecurity(db *gorm.DB) *Migration001UpgradePasswordSecurity { func NewMigration001UpgradePasswordSecurity(db *gorm.DB) *Migration001UpgradePasswordSecurity {
return &Migration001UpgradePasswordSecurity{DB: db} return &Migration001UpgradePasswordSecurity{DB: db}
} }
// Up executes the migration
func (m *Migration001UpgradePasswordSecurity) Up() error { func (m *Migration001UpgradePasswordSecurity) Up() error {
logging.Info("Starting password security upgrade migration...") logging.Info("Starting password security upgrade migration...")
// Check if migration has already been applied
var migrationRecord MigrationRecord var migrationRecord MigrationRecord
err := m.DB.Where("migration_name = ?", "001_upgrade_password_security").First(&migrationRecord).Error err := m.DB.Where("migration_name = ?", "001_upgrade_password_security").First(&migrationRecord).Error
if err == nil { if err == nil {
@@ -31,12 +27,10 @@ func (m *Migration001UpgradePasswordSecurity) Up() error {
return nil return nil
} }
// Create migration tracking table if it doesn't exist
if err := m.DB.AutoMigrate(&MigrationRecord{}); err != nil { if err := m.DB.AutoMigrate(&MigrationRecord{}); err != nil {
return fmt.Errorf("failed to create migration tracking table: %v", err) return fmt.Errorf("failed to create migration tracking table: %v", err)
} }
// Start transaction
tx := m.DB.Begin() tx := m.DB.Begin()
if tx.Error != nil { if tx.Error != nil {
return fmt.Errorf("failed to start transaction: %v", tx.Error) return fmt.Errorf("failed to start transaction: %v", tx.Error)
@@ -47,16 +41,13 @@ func (m *Migration001UpgradePasswordSecurity) Up() error {
} }
}() }()
// Add a backup column for old passwords (temporary)
if err := tx.Exec("ALTER TABLE users ADD COLUMN password_backup TEXT").Error; err != nil { if err := tx.Exec("ALTER TABLE users ADD COLUMN password_backup TEXT").Error; err != nil {
// Column might already exist, ignore if it's a duplicate column error
if !isDuplicateColumnError(err) { if !isDuplicateColumnError(err) {
tx.Rollback() tx.Rollback()
return fmt.Errorf("failed to add backup column: %v", err) return fmt.Errorf("failed to add backup column: %v", err)
} }
} }
// Get all users with encrypted passwords
var users []UserForMigration var users []UserForMigration
if err := tx.Find(&users).Error; err != nil { if err := tx.Find(&users).Error; err != nil {
tx.Rollback() tx.Rollback()
@@ -72,19 +63,15 @@ func (m *Migration001UpgradePasswordSecurity) Up() error {
if err := m.migrateUserPassword(tx, &user); err != nil { if err := m.migrateUserPassword(tx, &user); err != nil {
logging.Error("Failed to migrate user %s (ID: %s): %v", user.Username, user.ID, err) logging.Error("Failed to migrate user %s (ID: %s): %v", user.Username, user.ID, err)
failedCount++ failedCount++
// Continue with other users rather than failing completely
continue continue
} }
migratedCount++ migratedCount++
} }
// Remove backup column after successful migration
if err := tx.Exec("ALTER TABLE users DROP COLUMN password_backup").Error; err != nil { if err := tx.Exec("ALTER TABLE users DROP COLUMN password_backup").Error; err != nil {
logging.Error("Failed to remove backup column (non-critical): %v", err) logging.Error("Failed to remove backup column (non-critical): %v", err)
// Don't fail the migration for this
} }
// Record successful migration
migrationRecord = MigrationRecord{ migrationRecord = MigrationRecord{
MigrationName: "001_upgrade_password_security", MigrationName: "001_upgrade_password_security",
AppliedAt: "datetime('now')", AppliedAt: "datetime('now')",
@@ -97,7 +84,6 @@ func (m *Migration001UpgradePasswordSecurity) Up() error {
return fmt.Errorf("failed to record migration: %v", err) return fmt.Errorf("failed to record migration: %v", err)
} }
// Commit transaction
if err := tx.Commit().Error; err != nil { if err := tx.Commit().Error; err != nil {
return fmt.Errorf("failed to commit migration: %v", err) return fmt.Errorf("failed to commit migration: %v", err)
} }
@@ -111,32 +97,24 @@ func (m *Migration001UpgradePasswordSecurity) Up() error {
return nil return nil
} }
// migrateUserPassword migrates a single user's password
func (m *Migration001UpgradePasswordSecurity) migrateUserPassword(tx *gorm.DB, user *UserForMigration) error { func (m *Migration001UpgradePasswordSecurity) migrateUserPassword(tx *gorm.DB, user *UserForMigration) error {
// Skip if password is already hashed (bcrypt hashes start with $2a$, $2b$, or $2y$)
if isAlreadyHashed(user.Password) { if isAlreadyHashed(user.Password) {
logging.Debug("User %s already has hashed password, skipping", user.Username) logging.Debug("User %s already has hashed password, skipping", user.Username)
return nil return nil
} }
// Backup original password
if err := tx.Model(user).Update("password_backup", user.Password).Error; err != nil { if err := tx.Model(user).Update("password_backup", user.Password).Error; err != nil {
return fmt.Errorf("failed to backup password: %v", err) return fmt.Errorf("failed to backup password: %v", err)
} }
// Try to decrypt the old password
var plainPassword string var plainPassword string
// First, try to decrypt using the old encryption method
decrypted, err := decryptOldPassword(user.Password) decrypted, err := decryptOldPassword(user.Password)
if err != nil { if err != nil {
// If decryption fails, the password might already be plain text or corrupted
logging.Error("Failed to decrypt password for user %s, treating as plain text: %v", user.Username, err) logging.Error("Failed to decrypt password for user %s, treating as plain text: %v", user.Username, err)
// Use original password as-is (might be plain text from development)
plainPassword = user.Password plainPassword = user.Password
// Validate it's not obviously encrypted data
if len(plainPassword) > 100 || containsBinaryData(plainPassword) { if len(plainPassword) > 100 || containsBinaryData(plainPassword) {
return fmt.Errorf("password appears to be corrupted encrypted data") return fmt.Errorf("password appears to be corrupted encrypted data")
} }
@@ -144,7 +122,6 @@ func (m *Migration001UpgradePasswordSecurity) migrateUserPassword(tx *gorm.DB, u
plainPassword = decrypted plainPassword = decrypted
} }
// Validate plain password
if plainPassword == "" { if plainPassword == "" {
return errors.New("decrypted password is empty") return errors.New("decrypted password is empty")
} }
@@ -153,13 +130,11 @@ func (m *Migration001UpgradePasswordSecurity) migrateUserPassword(tx *gorm.DB, u
return errors.New("password too short after decryption") return errors.New("password too short after decryption")
} }
// Hash the plain password using bcrypt
hashedPassword, err := password.HashPassword(plainPassword) hashedPassword, err := password.HashPassword(plainPassword)
if err != nil { if err != nil {
return fmt.Errorf("failed to hash password: %v", err) return fmt.Errorf("failed to hash password: %v", err)
} }
// Update with hashed password
if err := tx.Model(user).Update("password", hashedPassword).Error; err != nil { if err := tx.Model(user).Update("password", hashedPassword).Error; err != nil {
return fmt.Errorf("failed to update password: %v", err) return fmt.Errorf("failed to update password: %v", err)
} }
@@ -168,19 +143,16 @@ func (m *Migration001UpgradePasswordSecurity) migrateUserPassword(tx *gorm.DB, u
return nil return nil
} }
// UserForMigration represents a user record for migration purposes
type UserForMigration struct { type UserForMigration struct {
ID string `gorm:"column:id"` ID string `gorm:"column:id"`
Username string `gorm:"column:username"` Username string `gorm:"column:username"`
Password string `gorm:"column:password"` Password string `gorm:"column:password"`
} }
// TableName specifies the table name for GORM
func (UserForMigration) TableName() string { func (UserForMigration) TableName() string {
return "users" return "users"
} }
// MigrationRecord tracks applied migrations
type MigrationRecord struct { type MigrationRecord struct {
ID uint `gorm:"primaryKey"` ID uint `gorm:"primaryKey"`
MigrationName string `gorm:"unique;not null"` MigrationName string `gorm:"unique;not null"`
@@ -189,49 +161,38 @@ type MigrationRecord struct {
Notes string Notes string
} }
// TableName specifies the table name for GORM
func (MigrationRecord) TableName() string { func (MigrationRecord) TableName() string {
return "migration_records" return "migration_records"
} }
// isAlreadyHashed checks if a password is already bcrypt hashed
func isAlreadyHashed(password string) bool { func isAlreadyHashed(password string) bool {
return len(password) >= 60 && (password[:4] == "$2a$" || password[:4] == "$2b$" || password[:4] == "$2y$") return len(password) >= 60 && (password[:4] == "$2a$" || password[:4] == "$2b$" || password[:4] == "$2y$")
} }
// containsBinaryData checks if a string contains binary data
func containsBinaryData(s string) bool { func containsBinaryData(s string) bool {
for _, b := range []byte(s) { for _, b := range []byte(s) {
if b < 32 && b != 9 && b != 10 && b != 13 { // Allow tab, newline, carriage return if b < 32 && b != 9 && b != 10 && b != 13 {
return true return true
} }
} }
return false return false
} }
// isDuplicateColumnError checks if an error is due to duplicate column
func isDuplicateColumnError(err error) bool { func isDuplicateColumnError(err error) bool {
errStr := err.Error() errStr := err.Error()
return fmt.Sprintf("%v", errStr) == "duplicate column name: password_backup" || return fmt.Sprintf("%v", errStr) == "duplicate column name: password_backup" ||
fmt.Sprintf("%v", errStr) == "SQLITE_ERROR: duplicate column name: password_backup" fmt.Sprintf("%v", errStr) == "SQLITE_ERROR: duplicate column name: password_backup"
} }
// decryptOldPassword attempts to decrypt using the old encryption method
// This is a simplified version of the old DecryptPassword function
func decryptOldPassword(encryptedPassword string) (string, error) { func decryptOldPassword(encryptedPassword string) (string, error) {
// This would use the old decryption logic
// For now, we'll return an error to force treating as plain text
// In a real scenario, you'd implement the old decryption here
return "", errors.New("old decryption not implemented - treating as plain text") return "", errors.New("old decryption not implemented - treating as plain text")
} }
// Down reverses the migration (if needed)
func (m *Migration001UpgradePasswordSecurity) Down() error { func (m *Migration001UpgradePasswordSecurity) Down() error {
logging.Error("Password security migration rollback is not supported for security reasons") logging.Error("Password security migration rollback is not supported for security reasons")
return errors.New("password security migration rollback is not supported") return errors.New("password security migration rollback is not supported")
} }
// RunMigration is a convenience function to run the migration
func RunPasswordSecurityMigration(db *gorm.DB) error { func RunPasswordSecurityMigration(db *gorm.DB) error {
migration := NewMigration001UpgradePasswordSecurity(db) migration := NewMigration001UpgradePasswordSecurity(db)
return migration.Up() return migration.Up()

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

@@ -9,21 +9,17 @@ import (
"gorm.io/gorm" "gorm.io/gorm"
) )
// Migration002MigrateToUUID migrates tables from integer IDs to UUIDs
type Migration002MigrateToUUID struct { type Migration002MigrateToUUID struct {
DB *gorm.DB DB *gorm.DB
} }
// NewMigration002MigrateToUUID creates a new UUID migration
func NewMigration002MigrateToUUID(db *gorm.DB) *Migration002MigrateToUUID { func NewMigration002MigrateToUUID(db *gorm.DB) *Migration002MigrateToUUID {
return &Migration002MigrateToUUID{DB: db} return &Migration002MigrateToUUID{DB: db}
} }
// Up executes the migration
func (m *Migration002MigrateToUUID) Up() error { func (m *Migration002MigrateToUUID) Up() error {
logging.Info("Checking UUID migration...") logging.Info("Checking UUID migration...")
// Check if migration is needed by looking at the servers table structure
if !m.needsMigration() { if !m.needsMigration() {
logging.Info("UUID migration not needed - tables already use UUID primary keys") logging.Info("UUID migration not needed - tables already use UUID primary keys")
return nil return nil
@@ -31,7 +27,6 @@ func (m *Migration002MigrateToUUID) Up() error {
logging.Info("Starting UUID migration...") logging.Info("Starting UUID migration...")
// Check if migration has already been applied
var migrationRecord MigrationRecord var migrationRecord MigrationRecord
err := m.DB.Where("migration_name = ?", "002_migrate_to_uuid").First(&migrationRecord).Error err := m.DB.Where("migration_name = ?", "002_migrate_to_uuid").First(&migrationRecord).Error
if err == nil { if err == nil {
@@ -39,12 +34,10 @@ func (m *Migration002MigrateToUUID) Up() error {
return nil return nil
} }
// Create migration tracking table if it doesn't exist
if err := m.DB.AutoMigrate(&MigrationRecord{}); err != nil { if err := m.DB.AutoMigrate(&MigrationRecord{}); err != nil {
return fmt.Errorf("failed to create migration tracking table: %v", err) return fmt.Errorf("failed to create migration tracking table: %v", err)
} }
// Execute the UUID migration using the existing migration function
logging.Info("Executing UUID migration...") logging.Info("Executing UUID migration...")
if err := runUUIDMigrationSQL(m.DB); err != nil { if err := runUUIDMigrationSQL(m.DB); err != nil {
return fmt.Errorf("failed to execute UUID migration: %v", err) return fmt.Errorf("failed to execute UUID migration: %v", err)
@@ -54,9 +47,7 @@ func (m *Migration002MigrateToUUID) Up() error {
return nil return nil
} }
// needsMigration checks if the UUID migration is needed by examining table structure
func (m *Migration002MigrateToUUID) needsMigration() bool { func (m *Migration002MigrateToUUID) needsMigration() bool {
// Check if servers table exists and has integer primary key
var result struct { var result struct {
Type string `gorm:"column:type"` Type string `gorm:"column:type"`
} }
@@ -67,29 +58,22 @@ func (m *Migration002MigrateToUUID) needsMigration() bool {
`).Scan(&result).Error `).Scan(&result).Error
if err != nil || result.Type == "" { if err != nil || result.Type == "" {
// Table doesn't exist or no primary key found - assume no migration needed
return false return false
} }
// If the primary key is INTEGER, we need migration
// If it's TEXT (UUID), migration already done
return result.Type == "INTEGER" || result.Type == "integer" return result.Type == "INTEGER" || result.Type == "integer"
} }
// Down reverses the migration (not implemented for safety)
func (m *Migration002MigrateToUUID) Down() error { func (m *Migration002MigrateToUUID) Down() error {
logging.Error("UUID migration rollback is not supported for data safety reasons") logging.Error("UUID migration rollback is not supported for data safety reasons")
return fmt.Errorf("UUID migration rollback is not supported") return fmt.Errorf("UUID migration rollback is not supported")
} }
// runUUIDMigrationSQL executes the UUID migration using the SQL file
func runUUIDMigrationSQL(db *gorm.DB) error { func runUUIDMigrationSQL(db *gorm.DB) error {
// Disable foreign key constraints during migration
if err := db.Exec("PRAGMA foreign_keys=OFF").Error; err != nil { if err := db.Exec("PRAGMA foreign_keys=OFF").Error; err != nil {
return fmt.Errorf("failed to disable foreign keys: %v", err) return fmt.Errorf("failed to disable foreign keys: %v", err)
} }
// Start transaction
tx := db.Begin() tx := db.Begin()
if tx.Error != nil { if tx.Error != nil {
return fmt.Errorf("failed to start transaction: %v", tx.Error) return fmt.Errorf("failed to start transaction: %v", tx.Error)
@@ -101,25 +85,21 @@ func runUUIDMigrationSQL(db *gorm.DB) error {
} }
}() }()
// Read the migration SQL from file
sqlPath := filepath.Join("scripts", "migrations", "002_migrate_servers_to_uuid.sql") sqlPath := filepath.Join("scripts", "migrations", "002_migrate_servers_to_uuid.sql")
migrationSQL, err := ioutil.ReadFile(sqlPath) migrationSQL, err := ioutil.ReadFile(sqlPath)
if err != nil { if err != nil {
return fmt.Errorf("failed to read migration SQL file: %v", err) return fmt.Errorf("failed to read migration SQL file: %v", err)
} }
// Execute the migration
if err := tx.Exec(string(migrationSQL)).Error; err != nil { if err := tx.Exec(string(migrationSQL)).Error; err != nil {
tx.Rollback() tx.Rollback()
return fmt.Errorf("failed to execute migration: %v", err) return fmt.Errorf("failed to execute migration: %v", err)
} }
// Commit transaction
if err := tx.Commit().Error; err != nil { if err := tx.Commit().Error; err != nil {
return fmt.Errorf("failed to commit migration: %v", err) return fmt.Errorf("failed to commit migration: %v", err)
} }
// Re-enable foreign key constraints
if err := db.Exec("PRAGMA foreign_keys=ON").Error; err != nil { if err := db.Exec("PRAGMA foreign_keys=ON").Error; err != nil {
return fmt.Errorf("failed to re-enable foreign keys: %v", err) return fmt.Errorf("failed to re-enable foreign keys: %v", err)
} }
@@ -127,7 +107,6 @@ func runUUIDMigrationSQL(db *gorm.DB) error {
return nil return nil
} }
// RunUUIDMigration is a convenience function to run the migration
func RunUUIDMigration(db *gorm.DB) error { func RunUUIDMigration(db *gorm.DB) error {
migration := NewMigration002MigrateToUUID(db) migration := NewMigration002MigrateToUUID(db)
return migration.Up() return migration.Up()

View File

@@ -0,0 +1,106 @@
package migrations
import (
"acc-server-manager/local/utl/logging"
"fmt"
"gorm.io/gorm"
)
type UpdateStateHistorySessions struct {
DB *gorm.DB
}
func NewUpdateStateHistorySessions(db *gorm.DB) *UpdateStateHistorySessions {
return &UpdateStateHistorySessions{DB: db}
}
func (m *UpdateStateHistorySessions) Up() error {
logging.Info("Checking UUID migration...")
if !m.needsMigration() {
logging.Info("UUID migration not needed - tables already use UUID primary keys")
return nil
}
logging.Info("Starting UUID migration...")
var migrationRecord MigrationRecord
err := m.DB.Where("migration_name = ?", "002_migrate_to_uuid").First(&migrationRecord).Error
if err == nil {
logging.Info("UUID migration already applied, skipping")
return nil
}
if err := m.DB.AutoMigrate(&MigrationRecord{}); err != nil {
return fmt.Errorf("failed to create migration tracking table: %v", err)
}
logging.Info("Executing UUID migration...")
if err := runUUIDMigrationSQL(m.DB); err != nil {
return fmt.Errorf("failed to execute UUID migration: %v", err)
}
logging.Info("UUID migration completed successfully")
return nil
}
func (m *UpdateStateHistorySessions) needsMigration() bool {
var result struct {
Exists bool `gorm:"column:exists"`
}
err := m.DB.Raw(`
SELECT count(*) > 0 as exists FROM state_history
WHERE length(session) > 1 LIMIT 1;
`).Scan(&result).Error
if err != nil || !result.Exists {
return false
}
return result.Exists
}
func (m *UpdateStateHistorySessions) Down() error {
logging.Error("UUID migration rollback is not supported for data safety reasons")
return fmt.Errorf("UUID migration rollback is not supported")
}
func runUpdateStateHistorySessionsMigration(db *gorm.DB) error {
if err := db.Exec("PRAGMA foreign_keys=OFF").Error; err != nil {
return fmt.Errorf("failed to disable foreign keys: %v", err)
}
tx := db.Begin()
if tx.Error != nil {
return fmt.Errorf("failed to start transaction: %v", tx.Error)
}
defer func() {
if r := recover(); r != nil {
tx.Rollback()
}
}()
migrationSQL := "UPDATE state_history SET session = upper(substr(session, 1, 1));"
if err := tx.Exec(string(migrationSQL)).Error; err != nil {
tx.Rollback()
return fmt.Errorf("failed to execute migration: %v", err)
}
if err := tx.Commit().Error; err != nil {
return fmt.Errorf("failed to commit migration: %v", err)
}
if err := db.Exec("PRAGMA foreign_keys=ON").Error; err != nil {
return fmt.Errorf("failed to re-enable foreign keys: %v", err)
}
return nil
}
func RunUpdateStateHistorySessionsMigration(db *gorm.DB) error {
migration := NewUpdateStateHistorySessions(db)
return migration.Up()
}

View File

@@ -0,0 +1,106 @@
package migrations
import (
"acc-server-manager/local/utl/logging"
"fmt"
"regexp"
"strings"
"gorm.io/gorm"
)
var sqlLineCommentRegexp = regexp.MustCompile(`--[^\n]*`)
type Migration004FixDDLComments struct {
DB *gorm.DB
}
func NewMigration004FixDDLComments(db *gorm.DB) *Migration004FixDDLComments {
return &Migration004FixDDLComments{DB: db}
}
func (m *Migration004FixDDLComments) Up() error {
if err := m.DB.AutoMigrate(&MigrationRecord{}); err != nil {
return fmt.Errorf("failed to create migration tracking table: %v", err)
}
var record MigrationRecord
if err := m.DB.Where("migration_name = ?", "004_fix_ddl_comments").First(&record).Error; err == nil {
logging.Info("DDL comment fix migration already applied, skipping")
return nil
}
var tables []string
if err := m.DB.Raw(
"SELECT name FROM sqlite_master WHERE type = 'table' AND sql LIKE '%--%' ORDER BY name",
).Scan(&tables).Error; err != nil {
return fmt.Errorf("failed to query tables with DDL comments: %v", err)
}
if len(tables) == 0 {
logging.Info("No tables with DDL comments found, skipping")
}
for _, table := range tables {
if err := m.rebuildTable(table); err != nil {
return fmt.Errorf("failed to rebuild table %q: %v", table, err)
}
}
record = MigrationRecord{
MigrationName: "004_fix_ddl_comments",
AppliedAt: "datetime('now')",
Success: true,
Notes: fmt.Sprintf("Rebuilt %d table(s): %s", len(tables), strings.Join(tables, ", ")),
}
if err := m.DB.Create(&record).Error; err != nil {
return fmt.Errorf("failed to record migration: %v", err)
}
return nil
}
func (m *Migration004FixDDLComments) rebuildTable(table string) error {
var originalDDL string
if err := m.DB.Raw(
"SELECT sql FROM sqlite_master WHERE type = 'table' AND name = ?", table,
).Row().Scan(&originalDDL); err != nil {
return err
}
cleanDDL := sqlLineCommentRegexp.ReplaceAllString(originalDDL, "")
tmpTable := table + "__fixed"
createTmp := strings.Replace(cleanDDL, `"`+table+`"`, "`"+tmpTable+"`", 1)
if createTmp == cleanDDL {
createTmp = strings.Replace(cleanDDL, table, "`"+tmpTable+"`", 1)
}
logging.Info("Rebuilding table %q to remove DDL comments...", table)
return m.DB.Transaction(func(tx *gorm.DB) error {
for _, sql := range []string{
"PRAGMA foreign_keys = OFF",
fmt.Sprintf("DROP TABLE IF EXISTS `%s`", tmpTable),
createTmp,
fmt.Sprintf("INSERT INTO `%s` SELECT * FROM `%s`", tmpTable, table),
fmt.Sprintf("DROP TABLE `%s`", table),
fmt.Sprintf("ALTER TABLE `%s` RENAME TO `%s`", tmpTable, table),
"PRAGMA foreign_keys = ON",
} {
if err := tx.Exec(sql).Error; err != nil {
return fmt.Errorf("exec %q: %w", sql, err)
}
}
logging.Info("Table %q rebuilt successfully", table)
return nil
})
}
func (m *Migration004FixDDLComments) Down() error {
return fmt.Errorf("DDL comment fix migration rollback is not supported")
}
func RunFixDDLCommentsMigration(db *gorm.DB) error {
return NewMigration004FixDDLComments(db).Up()
}

View File

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

View File

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

View File

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

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

@@ -6,20 +6,17 @@ import (
"time" "time"
) )
// StatusCache represents a cached server status with expiration
type StatusCache struct { type StatusCache struct {
Status ServiceStatus Status ServiceStatus
UpdatedAt time.Time UpdatedAt time.Time
} }
// CacheConfig holds configuration for cache behavior
type CacheConfig struct { type CacheConfig struct {
ExpirationTime time.Duration // How long before a cache entry expires ExpirationTime time.Duration
ThrottleTime time.Duration // Minimum time between status checks ThrottleTime time.Duration
DefaultStatus ServiceStatus // Default status to return when throttled DefaultStatus ServiceStatus
} }
// ServerStatusCache manages cached server statuses
type ServerStatusCache struct { type ServerStatusCache struct {
sync.RWMutex sync.RWMutex
cache map[string]*StatusCache cache map[string]*StatusCache
@@ -27,7 +24,6 @@ type ServerStatusCache struct {
lastChecked map[string]time.Time lastChecked map[string]time.Time
} }
// NewServerStatusCache creates a new server status cache
func NewServerStatusCache(config CacheConfig) *ServerStatusCache { func NewServerStatusCache(config CacheConfig) *ServerStatusCache {
return &ServerStatusCache{ return &ServerStatusCache{
cache: make(map[string]*StatusCache), cache: make(map[string]*StatusCache),
@@ -36,12 +32,10 @@ func NewServerStatusCache(config CacheConfig) *ServerStatusCache {
} }
} }
// GetStatus retrieves the cached status or indicates if a fresh check is needed
func (c *ServerStatusCache) GetStatus(serviceName string) (ServiceStatus, bool) { func (c *ServerStatusCache) GetStatus(serviceName string) (ServiceStatus, bool) {
c.RLock() c.RLock()
defer c.RUnlock() defer c.RUnlock()
// Check if we're being throttled
if lastCheck, exists := c.lastChecked[serviceName]; exists { if lastCheck, exists := c.lastChecked[serviceName]; exists {
if time.Since(lastCheck) < c.config.ThrottleTime { if time.Since(lastCheck) < c.config.ThrottleTime {
if cached, ok := c.cache[serviceName]; ok { if cached, ok := c.cache[serviceName]; ok {
@@ -51,7 +45,6 @@ func (c *ServerStatusCache) GetStatus(serviceName string) (ServiceStatus, bool)
} }
} }
// Check if we have a valid cached entry
if cached, ok := c.cache[serviceName]; ok { if cached, ok := c.cache[serviceName]; ok {
if time.Since(cached.UpdatedAt) < c.config.ExpirationTime { if time.Since(cached.UpdatedAt) < c.config.ExpirationTime {
return cached.Status, false return cached.Status, false
@@ -61,7 +54,6 @@ func (c *ServerStatusCache) GetStatus(serviceName string) (ServiceStatus, bool)
return StatusUnknown, true return StatusUnknown, true
} }
// UpdateStatus updates the cache with a new status
func (c *ServerStatusCache) UpdateStatus(serviceName string, status ServiceStatus) { func (c *ServerStatusCache) UpdateStatus(serviceName string, status ServiceStatus) {
c.Lock() c.Lock()
defer c.Unlock() defer c.Unlock()
@@ -73,7 +65,6 @@ func (c *ServerStatusCache) UpdateStatus(serviceName string, status ServiceStatu
c.lastChecked[serviceName] = time.Now() c.lastChecked[serviceName] = time.Now()
} }
// InvalidateStatus removes a specific service from the cache
func (c *ServerStatusCache) InvalidateStatus(serviceName string) { func (c *ServerStatusCache) InvalidateStatus(serviceName string) {
c.Lock() c.Lock()
defer c.Unlock() defer c.Unlock()
@@ -82,7 +73,6 @@ func (c *ServerStatusCache) InvalidateStatus(serviceName string) {
delete(c.lastChecked, serviceName) delete(c.lastChecked, serviceName)
} }
// Clear removes all entries from the cache
func (c *ServerStatusCache) Clear() { func (c *ServerStatusCache) Clear() {
c.Lock() c.Lock()
defer c.Unlock() defer c.Unlock()
@@ -91,13 +81,11 @@ func (c *ServerStatusCache) Clear() {
c.lastChecked = make(map[string]time.Time) c.lastChecked = make(map[string]time.Time)
} }
// LookupCache provides a generic cache for lookup data
type LookupCache struct { type LookupCache struct {
sync.RWMutex sync.RWMutex
data map[string]interface{} data map[string]interface{}
} }
// NewLookupCache creates a new lookup cache
func NewLookupCache() *LookupCache { func NewLookupCache() *LookupCache {
logging.Debug("Initializing new LookupCache") logging.Debug("Initializing new LookupCache")
return &LookupCache{ return &LookupCache{
@@ -105,7 +93,6 @@ func NewLookupCache() *LookupCache {
} }
} }
// Get retrieves a cached value by key
func (c *LookupCache) Get(key string) (interface{}, bool) { func (c *LookupCache) Get(key string) (interface{}, bool) {
c.RLock() c.RLock()
defer c.RUnlock() defer c.RUnlock()
@@ -119,7 +106,6 @@ func (c *LookupCache) Get(key string) (interface{}, bool) {
return value, exists return value, exists
} }
// Set stores a value in the cache
func (c *LookupCache) Set(key string, value interface{}) { func (c *LookupCache) Set(key string, value interface{}) {
c.Lock() c.Lock()
defer c.Unlock() defer c.Unlock()
@@ -128,7 +114,6 @@ func (c *LookupCache) Set(key string, value interface{}) {
logging.Debug("Cache SET for key: %s", key) logging.Debug("Cache SET for key: %s", key)
} }
// Clear removes all entries from the cache
func (c *LookupCache) Clear() { func (c *LookupCache) Clear() {
c.Lock() c.Lock()
defer c.Unlock() defer c.Unlock()
@@ -137,13 +122,11 @@ func (c *LookupCache) Clear() {
logging.Debug("Cache CLEARED") logging.Debug("Cache CLEARED")
} }
// ConfigEntry represents a cached configuration entry with its update time
type ConfigEntry[T any] struct { type ConfigEntry[T any] struct {
Data T Data T
UpdatedAt time.Time UpdatedAt time.Time
} }
// getConfigFromCache is a generic helper function to retrieve cached configs
func getConfigFromCache[T any](cache map[string]*ConfigEntry[T], serverID string, expirationTime time.Duration) (*T, bool) { func getConfigFromCache[T any](cache map[string]*ConfigEntry[T], serverID string, expirationTime time.Duration) (*T, bool) {
if entry, ok := cache[serverID]; ok { if entry, ok := cache[serverID]; ok {
if time.Since(entry.UpdatedAt) < expirationTime { if time.Since(entry.UpdatedAt) < expirationTime {
@@ -157,7 +140,6 @@ func getConfigFromCache[T any](cache map[string]*ConfigEntry[T], serverID string
return nil, false return nil, false
} }
// updateConfigInCache is a generic helper function to update cached configs
func updateConfigInCache[T any](cache map[string]*ConfigEntry[T], serverID string, data T) { func updateConfigInCache[T any](cache map[string]*ConfigEntry[T], serverID string, data T) {
cache[serverID] = &ConfigEntry[T]{ cache[serverID] = &ConfigEntry[T]{
Data: data, Data: data,
@@ -166,7 +148,6 @@ func updateConfigInCache[T any](cache map[string]*ConfigEntry[T], serverID strin
logging.Debug("Config cache SET for server ID: %s", serverID) logging.Debug("Config cache SET for server ID: %s", serverID)
} }
// ServerConfigCache manages cached server configurations
type ServerConfigCache struct { type ServerConfigCache struct {
sync.RWMutex sync.RWMutex
configuration map[string]*ConfigEntry[Configuration] configuration map[string]*ConfigEntry[Configuration]
@@ -177,7 +158,6 @@ type ServerConfigCache struct {
config CacheConfig config CacheConfig
} }
// NewServerConfigCache creates a new server configuration cache
func NewServerConfigCache(config CacheConfig) *ServerConfigCache { func NewServerConfigCache(config CacheConfig) *ServerConfigCache {
logging.Debug("Initializing new ServerConfigCache with expiration time: %v, throttle time: %v", config.ExpirationTime, config.ThrottleTime) logging.Debug("Initializing new ServerConfigCache with expiration time: %v, throttle time: %v", config.ExpirationTime, config.ThrottleTime)
return &ServerConfigCache{ return &ServerConfigCache{
@@ -190,7 +170,6 @@ func NewServerConfigCache(config CacheConfig) *ServerConfigCache {
} }
} }
// GetConfiguration retrieves a cached configuration
func (c *ServerConfigCache) GetConfiguration(serverID string) (*Configuration, bool) { func (c *ServerConfigCache) GetConfiguration(serverID string) (*Configuration, bool) {
c.RLock() c.RLock()
defer c.RUnlock() defer c.RUnlock()
@@ -198,7 +177,6 @@ func (c *ServerConfigCache) GetConfiguration(serverID string) (*Configuration, b
return getConfigFromCache(c.configuration, serverID, c.config.ExpirationTime) return getConfigFromCache(c.configuration, serverID, c.config.ExpirationTime)
} }
// GetAssistRules retrieves cached assist rules
func (c *ServerConfigCache) GetAssistRules(serverID string) (*AssistRules, bool) { func (c *ServerConfigCache) GetAssistRules(serverID string) (*AssistRules, bool) {
c.RLock() c.RLock()
defer c.RUnlock() defer c.RUnlock()
@@ -206,7 +184,6 @@ func (c *ServerConfigCache) GetAssistRules(serverID string) (*AssistRules, bool)
return getConfigFromCache(c.assistRules, serverID, c.config.ExpirationTime) return getConfigFromCache(c.assistRules, serverID, c.config.ExpirationTime)
} }
// GetEvent retrieves cached event configuration
func (c *ServerConfigCache) GetEvent(serverID string) (*EventConfig, bool) { func (c *ServerConfigCache) GetEvent(serverID string) (*EventConfig, bool) {
c.RLock() c.RLock()
defer c.RUnlock() defer c.RUnlock()
@@ -214,7 +191,6 @@ func (c *ServerConfigCache) GetEvent(serverID string) (*EventConfig, bool) {
return getConfigFromCache(c.event, serverID, c.config.ExpirationTime) return getConfigFromCache(c.event, serverID, c.config.ExpirationTime)
} }
// GetEventRules retrieves cached event rules
func (c *ServerConfigCache) GetEventRules(serverID string) (*EventRules, bool) { func (c *ServerConfigCache) GetEventRules(serverID string) (*EventRules, bool) {
c.RLock() c.RLock()
defer c.RUnlock() defer c.RUnlock()
@@ -222,7 +198,6 @@ func (c *ServerConfigCache) GetEventRules(serverID string) (*EventRules, bool) {
return getConfigFromCache(c.eventRules, serverID, c.config.ExpirationTime) return getConfigFromCache(c.eventRules, serverID, c.config.ExpirationTime)
} }
// GetSettings retrieves cached server settings
func (c *ServerConfigCache) GetSettings(serverID string) (*ServerSettings, bool) { func (c *ServerConfigCache) GetSettings(serverID string) (*ServerSettings, bool) {
c.RLock() c.RLock()
defer c.RUnlock() defer c.RUnlock()
@@ -230,7 +205,6 @@ func (c *ServerConfigCache) GetSettings(serverID string) (*ServerSettings, bool)
return getConfigFromCache(c.settings, serverID, c.config.ExpirationTime) return getConfigFromCache(c.settings, serverID, c.config.ExpirationTime)
} }
// UpdateConfiguration updates the configuration cache
func (c *ServerConfigCache) UpdateConfiguration(serverID string, config Configuration) { func (c *ServerConfigCache) UpdateConfiguration(serverID string, config Configuration) {
c.Lock() c.Lock()
defer c.Unlock() defer c.Unlock()
@@ -238,7 +212,6 @@ func (c *ServerConfigCache) UpdateConfiguration(serverID string, config Configur
updateConfigInCache(c.configuration, serverID, config) updateConfigInCache(c.configuration, serverID, config)
} }
// UpdateAssistRules updates the assist rules cache
func (c *ServerConfigCache) UpdateAssistRules(serverID string, rules AssistRules) { func (c *ServerConfigCache) UpdateAssistRules(serverID string, rules AssistRules) {
c.Lock() c.Lock()
defer c.Unlock() defer c.Unlock()
@@ -246,7 +219,6 @@ func (c *ServerConfigCache) UpdateAssistRules(serverID string, rules AssistRules
updateConfigInCache(c.assistRules, serverID, rules) updateConfigInCache(c.assistRules, serverID, rules)
} }
// UpdateEvent updates the event configuration cache
func (c *ServerConfigCache) UpdateEvent(serverID string, event EventConfig) { func (c *ServerConfigCache) UpdateEvent(serverID string, event EventConfig) {
c.Lock() c.Lock()
defer c.Unlock() defer c.Unlock()
@@ -254,7 +226,6 @@ func (c *ServerConfigCache) UpdateEvent(serverID string, event EventConfig) {
updateConfigInCache(c.event, serverID, event) updateConfigInCache(c.event, serverID, event)
} }
// UpdateEventRules updates the event rules cache
func (c *ServerConfigCache) UpdateEventRules(serverID string, rules EventRules) { func (c *ServerConfigCache) UpdateEventRules(serverID string, rules EventRules) {
c.Lock() c.Lock()
defer c.Unlock() defer c.Unlock()
@@ -262,7 +233,6 @@ func (c *ServerConfigCache) UpdateEventRules(serverID string, rules EventRules)
updateConfigInCache(c.eventRules, serverID, rules) updateConfigInCache(c.eventRules, serverID, rules)
} }
// UpdateSettings updates the server settings cache
func (c *ServerConfigCache) UpdateSettings(serverID string, settings ServerSettings) { func (c *ServerConfigCache) UpdateSettings(serverID string, settings ServerSettings) {
c.Lock() c.Lock()
defer c.Unlock() defer c.Unlock()
@@ -270,7 +240,6 @@ func (c *ServerConfigCache) UpdateSettings(serverID string, settings ServerSetti
updateConfigInCache(c.settings, serverID, settings) updateConfigInCache(c.settings, serverID, settings)
} }
// InvalidateServerCache removes all cached configurations for a specific server
func (c *ServerConfigCache) InvalidateServerCache(serverID string) { func (c *ServerConfigCache) InvalidateServerCache(serverID string) {
c.Lock() c.Lock()
defer c.Unlock() defer c.Unlock()
@@ -283,7 +252,6 @@ func (c *ServerConfigCache) InvalidateServerCache(serverID string) {
delete(c.settings, serverID) delete(c.settings, serverID)
} }
// Clear removes all entries from the cache
func (c *ServerConfigCache) Clear() { func (c *ServerConfigCache) Clear() {
c.Lock() c.Lock()
defer c.Unlock() defer c.Unlock()

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

@@ -13,17 +13,15 @@ import (
type IntString int type IntString int
type IntBool int type IntBool int
// Config tracks configuration modifications
type Config struct { type Config struct {
ID uuid.UUID `json:"id" gorm:"type:uuid;primary_key;"` ID uuid.UUID `json:"id" gorm:"type:uuid;primary_key;"`
ServerID uuid.UUID `json:"serverId" gorm:"not null;type:uuid"` ServerID uuid.UUID `json:"serverId" gorm:"not null;type:uuid"`
ConfigFile string `json:"configFile" gorm:"not null"` // e.g. "settings.json" ConfigFile string `json:"configFile" gorm:"not null"`
OldConfig string `json:"oldConfig" gorm:"type:text"` OldConfig string `json:"oldConfig" gorm:"type:text"`
NewConfig string `json:"newConfig" gorm:"type:text"` NewConfig string `json:"newConfig" gorm:"type:text"`
ChangedAt time.Time `json:"changedAt" gorm:"default:CURRENT_TIMESTAMP"` ChangedAt time.Time `json:"changedAt" gorm:"default:CURRENT_TIMESTAMP"`
} }
// BeforeCreate is a GORM hook that runs before creating new config entries
func (c *Config) BeforeCreate(tx *gorm.DB) error { func (c *Config) BeforeCreate(tx *gorm.DB) error {
if c.ID == uuid.Nil { if c.ID == uuid.Nil {
c.ID = uuid.New() c.ID = uuid.New()
@@ -42,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"`
@@ -79,11 +85,11 @@ type EventConfig struct {
} }
type Session struct { type Session struct {
HourOfDay IntString `json:"hourOfDay"` HourOfDay IntString `json:"hourOfDay"`
DayOfWeekend IntString `json:"dayOfWeekend"` DayOfWeekend IntString `json:"dayOfWeekend"`
TimeMultiplier IntString `json:"timeMultiplier"` TimeMultiplier IntString `json:"timeMultiplier"`
SessionType string `json:"sessionType"` SessionType TrackSession `json:"sessionType"`
SessionDurationMinutes IntString `json:"sessionDurationMinutes"` SessionDurationMinutes IntString `json:"sessionDurationMinutes"`
} }
type AssistRules struct { type AssistRules struct {
@@ -121,8 +127,6 @@ type Configuration struct {
ConfigVersion IntString `json:"configVersion"` ConfigVersion IntString `json:"configVersion"`
} }
// Known configuration keys
func (i *IntBool) UnmarshalJSON(b []byte) error { func (i *IntBool) UnmarshalJSON(b []byte) error {
var str int var str int
if err := json.Unmarshal(b, &str); err == nil && str <= 1 { if err := json.Unmarshal(b, &str); err == nil && str <= 1 {

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

@@ -7,7 +7,6 @@ import (
"gorm.io/gorm" "gorm.io/gorm"
) )
// BaseFilter contains common filter fields that can be embedded in other filters
type BaseFilter struct { type BaseFilter struct {
Page int `query:"page"` Page int `query:"page"`
PageSize int `query:"page_size"` PageSize int `query:"page_size"`
@@ -15,18 +14,15 @@ type BaseFilter struct {
SortDesc bool `query:"sort_desc"` SortDesc bool `query:"sort_desc"`
} }
// DateRangeFilter adds date range filtering capabilities
type DateRangeFilter struct { type DateRangeFilter struct {
StartDate time.Time `query:"start_date" time_format:"2006-01-02T15:04:05Z07:00"` StartDate time.Time `query:"start_date" time_format:"2006-01-02T15:04:05Z07:00"`
EndDate time.Time `query:"end_date" time_format:"2006-01-02T15:04:05Z07:00"` EndDate time.Time `query:"end_date" time_format:"2006-01-02T15:04:05Z07:00"`
} }
// ServerBasedFilter adds server ID filtering capability
type ServerBasedFilter struct { type ServerBasedFilter struct {
ServerID string `param:"id"` ServerID string `param:"id"`
} }
// ConfigFilter defines filtering options for Config queries
type ConfigFilter struct { type ConfigFilter struct {
BaseFilter BaseFilter
ServerBasedFilter ServerBasedFilter
@@ -34,13 +30,11 @@ 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"`
} }
// ApiFilter defines filtering options for Api queries
type ServiceControlFilter struct { type ServiceControlFilter struct {
BaseFilter BaseFilter
ServiceControl string `query:"serviceControl"` ServiceControl string `query:"serviceControl"`
} }
// MembershipFilter defines filtering options for User queries
type MembershipFilter struct { type MembershipFilter struct {
BaseFilter BaseFilter
Username string `query:"username"` Username string `query:"username"`
@@ -48,36 +42,32 @@ type MembershipFilter struct {
RoleID string `query:"role_id"` RoleID string `query:"role_id"`
} }
// Pagination returns the offset and limit for database queries
func (f *BaseFilter) Pagination() (offset, limit int) { func (f *BaseFilter) Pagination() (offset, limit int) {
if f.Page < 1 { if f.Page < 1 {
f.Page = 1 f.Page = 1
} }
if f.PageSize < 1 { if f.PageSize < 1 {
f.PageSize = 10 // Default page size f.PageSize = 10
} }
offset = (f.Page - 1) * f.PageSize offset = (f.Page - 1) * f.PageSize
limit = f.PageSize limit = f.PageSize
return return
} }
// GetSorting returns the sort field and direction for database queries
func (f *BaseFilter) GetSorting() (field string, desc bool) { func (f *BaseFilter) GetSorting() (field string, desc bool) {
if f.SortBy == "" { if f.SortBy == "" {
return "id", false // Default sorting return "id", false
} }
return f.SortBy, f.SortDesc return f.SortBy, f.SortDesc
} }
// IsDateRangeValid checks if both dates are set and start date is before end date
func (f *DateRangeFilter) IsDateRangeValid() bool { func (f *DateRangeFilter) IsDateRangeValid() bool {
if f.StartDate.IsZero() || f.EndDate.IsZero() { if f.StartDate.IsZero() || f.EndDate.IsZero() {
return true // If either date is not set, consider it valid return true
} }
return f.StartDate.Before(f.EndDate) return f.StartDate.Before(f.EndDate)
} }
// ApplyFilter applies the membership filter to a GORM query
func (f *MembershipFilter) ApplyFilter(query *gorm.DB) *gorm.DB { func (f *MembershipFilter) ApplyFilter(query *gorm.DB) *gorm.DB {
if f.Username != "" { if f.Username != "" {
query = query.Where("username LIKE ?", "%"+f.Username+"%") query = query.Where("username LIKE ?", "%"+f.Username+"%")
@@ -93,12 +83,10 @@ func (f *MembershipFilter) ApplyFilter(query *gorm.DB) *gorm.DB {
return query return query
} }
// Pagination returns the offset and limit for database queries
func (f *MembershipFilter) Pagination() (offset, limit int) { func (f *MembershipFilter) Pagination() (offset, limit int) {
return f.BaseFilter.Pagination() return f.BaseFilter.Pagination()
} }
// GetSorting returns the sort field and direction for database queries
func (f *MembershipFilter) GetSorting() (field string, desc bool) { func (f *MembershipFilter) GetSorting() (field string, desc bool) {
return f.BaseFilter.GetSorting() return f.BaseFilter.GetSorting()
} }

113
local/model/leaderboard.go Executable file
View File

@@ -0,0 +1,113 @@
package model
import (
"encoding/json"
"github.com/google/uuid"
"gorm.io/gorm"
"strconv"
)
// Score is a custom type that accepts both numbers and strings in JSON.
type Score string
func (s *Score) UnmarshalJSON(data []byte) error {
var n json.Number
if err := json.Unmarshal(data, &n); err == nil {
*s = Score(n.String())
return nil
}
var str string
if err := json.Unmarshal(data, &str); err != nil {
return err
}
*s = Score(str)
return nil
}
func (s Score) MarshalJSON() ([]byte, error) {
if n, err := strconv.Atoi(string(s)); err == nil {
return json.Marshal(n)
}
return json.Marshal(string(s))
}
type Leaderboard struct {
ID uuid.UUID `gorm:"type:uuid;primary_key;" json:"id"`
ServerID uuid.UUID `gorm:"uniqueIndex;not null;type:uuid" json:"serverId"`
FLPoints int `gorm:"default:1" json:"flPoints"`
FLColor string `gorm:"default:'#8b5cf6'" json:"flColor"`
FLTextColor string `gorm:"default:'#000000'" json:"flTextColor"`
Drivers []LeaderboardDriver `gorm:"foreignKey:LeaderboardID;constraint:OnDelete:CASCADE" json:"drivers"`
Races []LeaderboardRace `gorm:"foreignKey:LeaderboardID;constraint:OnDelete:CASCADE" json:"tracks"`
PointRows []LeaderboardPointRow `gorm:"foreignKey:LeaderboardID;constraint:OnDelete:CASCADE" json:"pointsTable"`
}
type LeaderboardDriver struct {
ID uuid.UUID `gorm:"type:uuid;primary_key;" json:"id"`
LeaderboardID uuid.UUID `gorm:"not null;type:uuid" json:"-"`
Name string `gorm:"not null" json:"name"`
Initials string `json:"initials"`
Color string `json:"color"`
Position int `json:"-"`
}
type LeaderboardRace struct {
ID uuid.UUID `gorm:"type:uuid;primary_key;" json:"id"`
LeaderboardID uuid.UUID `gorm:"not null;type:uuid" json:"-"`
Name string `gorm:"not null" json:"name"`
Position int `json:"-"`
FastestLapDriverID *uuid.UUID `gorm:"type:uuid" json:"fastestLapDriverId"`
Results []LeaderboardResult `gorm:"foreignKey:RaceID;constraint:OnDelete:CASCADE" json:"results"`
}
type LeaderboardResult struct {
ID uuid.UUID `gorm:"type:uuid;primary_key;" json:"-"`
RaceID uuid.UUID `gorm:"not null;type:uuid" json:"-"`
DriverID uuid.UUID `gorm:"not null;type:uuid" json:"driverId"`
Score Score `json:"score"`
}
type LeaderboardPointRow struct {
ID uuid.UUID `gorm:"type:uuid;primary_key;" json:"id"`
LeaderboardID uuid.UUID `gorm:"not null;type:uuid" json:"-"`
Label string `json:"label"`
Points int `json:"points"`
Color string `json:"color"`
TextColor string `json:"textColor"`
Priority int `json:"priority"`
}
func (l *Leaderboard) BeforeCreate(tx *gorm.DB) error {
if l.ID == uuid.Nil {
l.ID = uuid.New()
}
return nil
}
func (d *LeaderboardDriver) BeforeCreate(tx *gorm.DB) error {
if d.ID == uuid.Nil {
d.ID = uuid.New()
}
return nil
}
func (r *LeaderboardRace) BeforeCreate(tx *gorm.DB) error {
if r.ID == uuid.Nil {
r.ID = uuid.New()
}
return nil
}
func (r *LeaderboardResult) BeforeCreate(tx *gorm.DB) error {
if r.ID == uuid.Nil {
r.ID = uuid.New()
}
return nil
}
func (p *LeaderboardPointRow) BeforeCreate(tx *gorm.DB) error {
if p.ID == uuid.Nil {
p.ID = uuid.New()
}
return nil
}

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

@@ -1,31 +1,26 @@
package model package model
// Track represents a track and its capacity
type Track struct { type Track struct {
Name string `json:"track" gorm:"primaryKey;size:50"` Name string `json:"track" gorm:"primaryKey;size:50"`
UniquePitBoxes int `json:"unique_pit_boxes"` UniquePitBoxes int `json:"unique_pit_boxes"`
PrivateServerSlots int `json:"private_server_slots"` PrivateServerSlots int `json:"private_server_slots"`
} }
// CarModel represents a car model mapping
type CarModel struct { type CarModel struct {
Value int `json:"value" gorm:"primaryKey"` Value int `json:"value" gorm:"primaryKey"`
CarModel string `json:"car_model"` CarModel string `json:"car_model"`
} }
// DriverCategory represents driver skill categories
type DriverCategory struct { type DriverCategory struct {
Value int `json:"value" gorm:"primaryKey"` Value int `json:"value" gorm:"primaryKey"`
Category string `json:"category"` Category string `json:"category"`
} }
// CupCategory represents championship cup categories
type CupCategory struct { type CupCategory struct {
Value int `json:"value" gorm:"primaryKey"` Value int `json:"value" gorm:"primaryKey"`
Category string `json:"category"` Category string `json:"category"`
} }
// SessionType represents session types
type SessionType struct { type SessionType struct {
Value int `json:"value" gorm:"primaryKey"` Value int `json:"value" gorm:"primaryKey"`
SessionType string `json:"session_type"` SessionType string `json:"session_type"`

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

@@ -32,8 +32,6 @@ type BaseModel struct {
DateUpdated time.Time `json:"dateUpdated"` DateUpdated time.Time `json:"dateUpdated"`
} }
// Init
// Initializes base model with DateCreated, DateUpdated, and Id values.
func (cm *BaseModel) Init() { func (cm *BaseModel) Init() {
date := time.Now() date := time.Now()
cm.Id = uuid.NewString() cm.Id = uuid.NewString()

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

@@ -5,13 +5,11 @@ import (
"gorm.io/gorm" "gorm.io/gorm"
) )
// Permission represents an action that can be performed in the system.
type Permission struct { type Permission struct {
ID uuid.UUID `json:"id" gorm:"type:uuid;primary_key;"` ID uuid.UUID `json:"id" gorm:"type:uuid;primary_key;"`
Name string `json:"name" gorm:"unique_index;not null"` Name string `json:"name" gorm:"unique_index;not null"`
} }
// BeforeCreate is a GORM hook that runs before creating new credentials
func (s *Permission) BeforeCreate(tx *gorm.DB) error { func (s *Permission) BeforeCreate(tx *gorm.DB) error {
s.ID = uuid.New() s.ID = uuid.New()

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

@@ -1,6 +1,5 @@
package model package model
// Permission constants
const ( const (
ServerView = "server.view" ServerView = "server.view"
ServerCreate = "server.create" ServerCreate = "server.create"
@@ -27,7 +26,6 @@ const (
MembershipEdit = "membership.edit" MembershipEdit = "membership.edit"
) )
// AllPermissions returns a slice of all permission strings.
func AllPermissions() []string { func AllPermissions() []string {
return []string{ return []string{
ServerView, ServerView,

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

@@ -5,14 +5,12 @@ import (
"gorm.io/gorm" "gorm.io/gorm"
) )
// Role represents a user role in the system.
type Role struct { type Role struct {
ID uuid.UUID `json:"id" gorm:"type:uuid;primary_key;"` ID uuid.UUID `json:"id" gorm:"type:uuid;primary_key;"`
Name string `json:"name" gorm:"unique_index;not null"` Name string `json:"name" gorm:"unique_index;not null"`
Permissions []Permission `json:"permissions" gorm:"many2many:role_permissions;"` Permissions []Permission `json:"permissions" gorm:"many2many:role_permissions;"`
} }
// BeforeCreate is a GORM hook that runs before creating new credentials
func (s *Role) BeforeCreate(tx *gorm.DB) error { func (s *Role) BeforeCreate(tx *gorm.DB) error {
s.ID = uuid.New() s.ID = uuid.New()

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

@@ -16,7 +16,6 @@ const (
ServiceNamePrefix = "ACC-Server" ServiceNamePrefix = "ACC-Server"
) )
// Server represents an ACC server instance
type ServerAPI struct { type ServerAPI struct {
Name string `json:"name"` Name string `json:"name"`
Status ServiceStatus `json:"status"` Status ServiceStatus `json:"status"`
@@ -35,28 +34,30 @@ func (s *Server) ToServerAPI() *ServerAPI {
} }
} }
// Server represents an ACC server instance
type Server struct { type Server struct {
ID uuid.UUID `gorm:"type:uuid;primary_key;" json:"id"` ID uuid.UUID `gorm:"type:uuid;primary_key;" json:"id"`
Name string `gorm:"not null" json:"name"` Name string `gorm:"not null" json:"name"`
Status ServiceStatus `json:"status" gorm:"-"` Status ServiceStatus `json:"status" gorm:"-"`
IP string `gorm:"not null" json:"-"` IP string `gorm:"not null" json:"-"`
Port int `gorm:"not null" json:"-"` Port int `gorm:"not null" json:"-"`
Path string `gorm:"not null" json:"path"` // e.g. "/acc/servers/server1/" Path string `gorm:"not null" json:"path"`
ServiceName string `gorm:"not null" json:"serviceName"` // Windows service name ServiceName string `gorm:"not null" json:"serviceName"`
State *ServerState `gorm:"-" json:"state"` Platform string `gorm:"not null;default:'windows'" json:"platform"`
DateCreated time.Time `json:"dateCreated"` ContainerID string `gorm:"default:''" json:"-"`
FromSteamCMD bool `gorm:"not null; default:true" json:"-"` State *ServerState `gorm:"-" json:"state"`
LastActivity *LastActivityInfo `gorm:"-" json:"lastActivity,omitempty"`
DateCreated time.Time `json:"dateCreated"`
FromSteamCMD bool `gorm:"not null; default:true" json:"-"`
} }
type PlayerState struct { type PlayerState struct {
CarID int // Car ID in broadcast packets CarID int
DriverName string // Optional: pulled from registration packet DriverName string
TeamName string TeamName string
CarModel string CarModel string
CurrentLap int CurrentLap int
LastLapTime int // in milliseconds LastLapTime int
BestLapTime int // in milliseconds BestLapTime int
Position int Position int
ConnectedAt time.Time ConnectedAt time.Time
DisconnectedAt *time.Time DisconnectedAt *time.Time
@@ -67,23 +68,18 @@ type State struct {
Session string `json:"session"` Session string `json:"session"`
SessionStart time.Time `json:"sessionStart"` SessionStart time.Time `json:"sessionStart"`
PlayerCount int `json:"playerCount"` PlayerCount int `json:"playerCount"`
// Players map[int]*PlayerState
// etc.
} }
type ServerState struct { type ServerState struct {
sync.RWMutex `swaggerignore:"-" json:"-"` sync.RWMutex `swaggerignore:"-" json:"-"`
Session string `json:"session"` Session TrackSession `json:"session"`
SessionStart time.Time `json:"sessionStart"` SessionStart time.Time `json:"sessionStart"`
PlayerCount int `json:"playerCount"` PlayerCount int `json:"playerCount"`
Track string `json:"track"` Track string `json:"track"`
MaxConnections int `json:"maxConnections"` MaxConnections int `json:"maxConnections"`
SessionDurationMinutes int `json:"sessionDurationMinutes"` SessionDurationMinutes int `json:"sessionDurationMinutes"`
// Players map[int]*PlayerState
// etc.
} }
// ServerFilter defines filtering options for Server queries
type ServerFilter struct { type ServerFilter struct {
BaseFilter BaseFilter
ServerBasedFilter ServerBasedFilter
@@ -92,9 +88,7 @@ type ServerFilter struct {
Status string `query:"status"` Status string `query:"status"`
} }
// ApplyFilter implements the Filterable interface
func (f *ServerFilter) ApplyFilter(query *gorm.DB) *gorm.DB { func (f *ServerFilter) ApplyFilter(query *gorm.DB) *gorm.DB {
// Apply server filter
if f.ServerID != "" { if f.ServerID != "" {
if serverUUID, err := uuid.Parse(f.ServerID); err == nil { if serverUUID, err := uuid.Parse(f.ServerID); err == nil {
query = query.Where("id = ?", serverUUID) query = query.Where("id = ?", serverUUID)
@@ -104,18 +98,19 @@ func (f *ServerFilter) ApplyFilter(query *gorm.DB) *gorm.DB {
return query return query
} }
// BeforeCreate is a GORM hook that runs before creating a new server func (s *Server) GenerateUUID() {
if s.ID == uuid.Nil {
s.ID = uuid.New()
}
}
func (s *Server) BeforeCreate(tx *gorm.DB) error { func (s *Server) BeforeCreate(tx *gorm.DB) error {
if s.Name == "" { if s.Name == "" {
return errors.New("server name is required") return errors.New("server name is required")
} }
// Generate UUID if not set s.GenerateUUID()
if s.ID == uuid.Nil {
s.ID = uuid.New()
}
// Generate service name and config path if not set
if s.ServiceName == "" { if s.ServiceName == "" {
s.ServiceName = s.GenerateServiceName() s.ServiceName = s.GenerateServiceName()
} }
@@ -123,7 +118,6 @@ func (s *Server) BeforeCreate(tx *gorm.DB) error {
s.Path = s.GenerateServerPath(BaseServerPath) s.Path = s.GenerateServerPath(BaseServerPath)
} }
// Set creation date if not set
if s.DateCreated.IsZero() { if s.DateCreated.IsZero() {
s.DateCreated = time.Now().UTC() s.DateCreated = time.Now().UTC()
} }
@@ -131,19 +125,14 @@ func (s *Server) BeforeCreate(tx *gorm.DB) error {
return nil return nil
} }
// GenerateServiceName creates a unique service name based on the server name
func (s *Server) GenerateServiceName() string { func (s *Server) GenerateServiceName() string {
// If ID is set, use it
if s.ID != uuid.Nil { if s.ID != uuid.Nil {
return fmt.Sprintf("%s-%s", ServiceNamePrefix, s.ID.String()[:8]) return fmt.Sprintf("%s-%s", ServiceNamePrefix, s.ID.String()[:8])
} }
// Otherwise use a timestamp-based unique identifier
return fmt.Sprintf("%s-%d", ServiceNamePrefix, time.Now().UnixNano()) return fmt.Sprintf("%s-%d", ServiceNamePrefix, time.Now().UnixNano())
} }
// GenerateServerPath creates the config path based on the service name
func (s *Server) GenerateServerPath(steamCMDPath string) string { func (s *Server) GenerateServerPath(steamCMDPath string) string {
// Ensure service name is set
if s.ServiceName == "" { if s.ServiceName == "" {
s.ServiceName = s.GenerateServiceName() s.ServiceName = s.GenerateServiceName()
} }

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

@@ -17,7 +17,6 @@ const (
StatusRunning StatusRunning
) )
// String converts the ServiceStatus to its string representation
func (s ServiceStatus) String() string { func (s ServiceStatus) String() string {
switch s { switch s {
case StatusRunning: case StatusRunning:
@@ -35,7 +34,6 @@ func (s ServiceStatus) String() string {
} }
} }
// ParseServiceStatus converts a string to ServiceStatus
func ParseServiceStatus(s string) ServiceStatus { func ParseServiceStatus(s string) ServiceStatus {
switch s { switch s {
case "SERVICE_RUNNING": case "SERVICE_RUNNING":
@@ -53,31 +51,24 @@ func ParseServiceStatus(s string) ServiceStatus {
} }
} }
// MarshalJSON implements json.Marshaler interface
func (s ServiceStatus) MarshalJSON() ([]byte, error) { func (s ServiceStatus) MarshalJSON() ([]byte, error) {
// Return the numeric value instead of string
return []byte(strconv.Itoa(int(s))), nil return []byte(strconv.Itoa(int(s))), nil
} }
// UnmarshalJSON implements json.Unmarshaler interface
func (s *ServiceStatus) UnmarshalJSON(data []byte) error { func (s *ServiceStatus) UnmarshalJSON(data []byte) error {
// Try to parse as number first
if i, err := strconv.Atoi(string(data)); err == nil { if i, err := strconv.Atoi(string(data)); err == nil {
*s = ServiceStatus(i) *s = ServiceStatus(i)
return nil return nil
} }
// Fallback to string parsing for backward compatibility
str := string(data) str := string(data)
if len(str) >= 2 { if len(str) >= 2 {
// Remove quotes if present
str = str[1 : len(str)-1] str = str[1 : len(str)-1]
} }
*s = ParseServiceStatus(str) *s = ParseServiceStatus(str)
return nil return nil
} }
// Scan implements the sql.Scanner interface
func (s *ServiceStatus) Scan(value interface{}) error { func (s *ServiceStatus) Scan(value interface{}) error {
if value == nil { if value == nil {
*s = StatusUnknown *s = StatusUnknown
@@ -99,7 +90,6 @@ func (s *ServiceStatus) Scan(value interface{}) error {
} }
} }
// Value implements the driver.Valuer interface
func (s ServiceStatus) Value() (driver.Value, error) { func (s ServiceStatus) Value() (driver.Value, error) {
return s.String(), nil return s.String(), nil
} }

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

@@ -1,33 +1,31 @@
package model package model
import ( import (
"encoding/json"
"fmt"
"strings"
"time" "time"
"github.com/google/uuid" "github.com/google/uuid"
"gorm.io/gorm" "gorm.io/gorm"
) )
// StateHistoryFilter combines common filter capabilities
type StateHistoryFilter struct { type StateHistoryFilter struct {
ServerBasedFilter // Adds server ID from path parameter ServerBasedFilter
DateRangeFilter // Adds date range filtering DateRangeFilter
// Additional fields specific to state history Session TrackSession `query:"session"`
Session string `query:"session"` MinPlayers *int `query:"min_players"`
MinPlayers *int `query:"min_players"` MaxPlayers *int `query:"max_players"`
MaxPlayers *int `query:"max_players"`
} }
// ApplyFilter implements the Filterable interface
func (f *StateHistoryFilter) ApplyFilter(query *gorm.DB) *gorm.DB { func (f *StateHistoryFilter) ApplyFilter(query *gorm.DB) *gorm.DB {
// Apply server filter
if f.ServerID != "" { if f.ServerID != "" {
if serverUUID, err := uuid.Parse(f.ServerID); err == nil { if serverUUID, err := uuid.Parse(f.ServerID); err == nil {
query = query.Where("server_id = ?", serverUUID) query = query.Where("server_id = ?", serverUUID)
} }
} }
// Apply date range filter if set
timeZero := time.Time{} timeZero := time.Time{}
if f.StartDate != timeZero { if f.StartDate != timeZero {
query = query.Where("date_created >= ?", f.StartDate) query = query.Where("date_created >= ?", f.StartDate)
@@ -36,12 +34,10 @@ func (f *StateHistoryFilter) ApplyFilter(query *gorm.DB) *gorm.DB {
query = query.Where("date_created <= ?", f.EndDate) query = query.Where("date_created <= ?", f.EndDate)
} }
// Apply session filter if set
if f.Session != "" { if f.Session != "" {
query = query.Where("session = ?", f.Session) query = query.Where("session = ?", f.Session)
} }
// Apply player count filters if set
if f.MinPlayers != nil { if f.MinPlayers != nil {
query = query.Where("player_count >= ?", *f.MinPlayers) query = query.Where("player_count >= ?", *f.MinPlayers)
} }
@@ -52,19 +48,68 @@ func (f *StateHistoryFilter) ApplyFilter(query *gorm.DB) *gorm.DB {
return query return query
} }
type StateHistory struct { type TrackSession string
ID uuid.UUID `gorm:"type:uuid;primary_key;" json:"id"`
ServerID uuid.UUID `json:"serverId" gorm:"not null;type:uuid"` const (
Session string `json:"session"` SessionPractice TrackSession = "P"
Track string `json:"track"` SessionQualify TrackSession = "Q"
PlayerCount int `json:"playerCount"` SessionRace TrackSession = "R"
DateCreated time.Time `json:"dateCreated"` SessionUnknown TrackSession = "U"
SessionStart time.Time `json:"sessionStart"` )
SessionDurationMinutes int `json:"sessionDurationMinutes"`
SessionID uuid.UUID `json:"sessionId" gorm:"not null;type:uuid"` // Unique identifier for each session/event func (i *TrackSession) UnmarshalJSON(b []byte) error {
var str string
if err := json.Unmarshal(b, &str); err == nil {
*i = ToTrackSession(str)
return nil
}
return fmt.Errorf("invalid TrackSession value")
}
func (i TrackSession) Humanize() string {
switch i {
case SessionPractice:
return "Practice"
case SessionQualify:
return "Qualifying"
case SessionRace:
return "Race"
default:
return "Unknown"
}
}
func ToTrackSession(i string) TrackSession {
sessionAbrv := strings.ToUpper(i[:1])
switch sessionAbrv {
case "P":
return SessionPractice
case "Q":
return SessionQualify
case "R":
return SessionRace
default:
return SessionUnknown
}
}
func (i TrackSession) ToString() string {
return string(i)
}
type StateHistory struct {
ID uuid.UUID `gorm:"type:uuid;primary_key;" json:"id"`
ServerID uuid.UUID `json:"serverId" gorm:"not null;type:uuid"`
Session TrackSession `json:"session"`
Track string `json:"track"`
PlayerCount int `json:"playerCount"`
DateCreated time.Time `json:"dateCreated"`
SessionStart time.Time `json:"sessionStart"`
SessionDurationMinutes int `json:"sessionDurationMinutes"`
SessionID uuid.UUID `json:"sessionId" gorm:"not null;type:uuid"`
} }
// BeforeCreate is a GORM hook that runs before creating new state history entries
func (sh *StateHistory) BeforeCreate(tx *gorm.DB) error { func (sh *StateHistory) BeforeCreate(tx *gorm.DB) error {
if sh.ID == uuid.Nil { if sh.ID == uuid.Nil {
sh.ID = uuid.New() sh.ID = uuid.New()

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

@@ -3,8 +3,8 @@ package model
import "github.com/google/uuid" import "github.com/google/uuid"
type SessionCount struct { type SessionCount struct {
Name string `json:"name"` Name TrackSession `json:"name"`
Count int `json:"count"` Count int `json:"count"`
} }
type DailyActivity struct { type DailyActivity struct {
@@ -21,7 +21,7 @@ type StateHistoryStats struct {
AveragePlayers float64 `json:"averagePlayers"` AveragePlayers float64 `json:"averagePlayers"`
PeakPlayers int `json:"peakPlayers"` PeakPlayers int `json:"peakPlayers"`
TotalSessions int `json:"totalSessions"` TotalSessions int `json:"totalSessions"`
TotalPlaytime int `json:"totalPlaytime" gorm:"-"` // in minutes TotalPlaytime int `json:"totalPlaytime" gorm:"-"`
PlayerCountOverTime []PlayerCountPoint `json:"playerCountOverTime" gorm:"-"` PlayerCountOverTime []PlayerCountPoint `json:"playerCountOverTime" gorm:"-"`
SessionTypes []SessionCount `json:"sessionTypes" gorm:"-"` SessionTypes []SessionCount `json:"sessionTypes" gorm:"-"`
DailyActivity []DailyActivity `json:"dailyActivity" gorm:"-"` DailyActivity []DailyActivity `json:"dailyActivity" gorm:"-"`
@@ -29,10 +29,10 @@ type StateHistoryStats struct {
} }
type RecentSession struct { type RecentSession struct {
ID uuid.UUID `json:"id"` ID uuid.UUID `json:"id"`
Date string `json:"date"` Date string `json:"date"`
Type string `json:"type"` Type TrackSession `json:"type"`
Track string `json:"track"` Track string `json:"track"`
Duration int `json:"duration"` Duration int `json:"duration"`
Players int `json:"players"` Players int `json:"players"`
} }

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

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

@@ -16,21 +16,18 @@ import (
"gorm.io/gorm" "gorm.io/gorm"
) )
// SteamCredentials represents stored Steam login credentials
type SteamCredentials struct { type SteamCredentials struct {
ID uuid.UUID `gorm:"type:uuid;primary_key;" json:"id"` ID uuid.UUID `gorm:"type:uuid;primary_key;" json:"id"`
Username string `gorm:"not null" json:"username"` Username string `gorm:"not null" json:"username"`
Password string `gorm:"not null" json:"-"` // Encrypted, not exposed in JSON Password string `gorm:"not null" json:"-"`
DateCreated time.Time `json:"dateCreated"` DateCreated time.Time `json:"dateCreated"`
LastUpdated time.Time `json:"lastUpdated"` LastUpdated time.Time `json:"lastUpdated"`
} }
// TableName specifies the table name for GORM
func (SteamCredentials) TableName() string { func (SteamCredentials) TableName() string {
return "steam_credentials" return "steam_credentials"
} }
// BeforeCreate is a GORM hook that runs before creating new credentials
func (s *SteamCredentials) BeforeCreate(tx *gorm.DB) error { func (s *SteamCredentials) BeforeCreate(tx *gorm.DB) error {
if s.ID == uuid.Nil { if s.ID == uuid.Nil {
s.ID = uuid.New() s.ID = uuid.New()
@@ -42,7 +39,6 @@ func (s *SteamCredentials) BeforeCreate(tx *gorm.DB) error {
} }
s.LastUpdated = now s.LastUpdated = now
// Encrypt password before saving
encrypted, err := EncryptPassword(s.Password) encrypted, err := EncryptPassword(s.Password)
if err != nil { if err != nil {
return err return err
@@ -52,11 +48,9 @@ func (s *SteamCredentials) BeforeCreate(tx *gorm.DB) error {
return nil return nil
} }
// BeforeUpdate is a GORM hook that runs before updating credentials
func (s *SteamCredentials) BeforeUpdate(tx *gorm.DB) error { func (s *SteamCredentials) BeforeUpdate(tx *gorm.DB) error {
s.LastUpdated = time.Now().UTC() s.LastUpdated = time.Now().UTC()
// Only encrypt if password field is being updated
if tx.Statement.Changed("Password") { if tx.Statement.Changed("Password") {
encrypted, err := EncryptPassword(s.Password) encrypted, err := EncryptPassword(s.Password)
if err != nil { if err != nil {
@@ -68,9 +62,7 @@ func (s *SteamCredentials) BeforeUpdate(tx *gorm.DB) error {
return nil return nil
} }
// AfterFind is a GORM hook that runs after fetching credentials
func (s *SteamCredentials) AfterFind(tx *gorm.DB) error { func (s *SteamCredentials) AfterFind(tx *gorm.DB) error {
// Decrypt password after fetching
if s.Password != "" { if s.Password != "" {
decrypted, err := DecryptPassword(s.Password) decrypted, err := DecryptPassword(s.Password)
if err != nil { if err != nil {
@@ -81,18 +73,15 @@ func (s *SteamCredentials) AfterFind(tx *gorm.DB) error {
return nil return nil
} }
// Validate checks if the credentials are valid with enhanced security checks
func (s *SteamCredentials) Validate() error { func (s *SteamCredentials) Validate() error {
if s.Username == "" { if s.Username == "" {
return errors.New("username is required") return errors.New("username is required")
} }
// Enhanced username validation
if len(s.Username) < 3 || len(s.Username) > 64 { if len(s.Username) < 3 || len(s.Username) > 64 {
return errors.New("username must be between 3 and 64 characters") return errors.New("username must be between 3 and 64 characters")
} }
// Check for valid characters in username (alphanumeric, underscore, hyphen)
if matched, _ := regexp.MatchString(`^[a-zA-Z0-9_-]+$`, s.Username); !matched { if matched, _ := regexp.MatchString(`^[a-zA-Z0-9_-]+$`, s.Username); !matched {
return errors.New("username contains invalid characters") return errors.New("username contains invalid characters")
} }
@@ -101,7 +90,6 @@ func (s *SteamCredentials) Validate() error {
return errors.New("password is required") return errors.New("password is required")
} }
// Basic password validation
if len(s.Password) < 6 { if len(s.Password) < 6 {
return errors.New("password must be at least 6 characters long") return errors.New("password must be at least 6 characters long")
} }
@@ -110,7 +98,6 @@ func (s *SteamCredentials) Validate() error {
return errors.New("password is too long") return errors.New("password is too long")
} }
// Check for obvious weak passwords
weakPasswords := []string{"password", "123456", "steam", "admin", "user"} weakPasswords := []string{"password", "123456", "steam", "admin", "user"}
lowerPass := strings.ToLower(s.Password) lowerPass := strings.ToLower(s.Password)
for _, weak := range weakPasswords { for _, weak := range weakPasswords {
@@ -122,8 +109,6 @@ func (s *SteamCredentials) Validate() error {
return nil return nil
} }
// GetEncryptionKey returns the encryption key from config.
// The key is loaded from the ENCRYPTION_KEY environment variable.
func GetEncryptionKey() []byte { func GetEncryptionKey() []byte {
key := []byte(configs.EncryptionKey) key := []byte(configs.EncryptionKey)
if len(key) != 32 { if len(key) != 32 {
@@ -132,7 +117,6 @@ func GetEncryptionKey() []byte {
return key return key
} }
// EncryptPassword encrypts a password using AES-256-GCM with enhanced security
func EncryptPassword(password string) (string, error) { func EncryptPassword(password string) (string, error) {
if password == "" { if password == "" {
return "", errors.New("password cannot be empty") return "", errors.New("password cannot be empty")
@@ -148,33 +132,27 @@ func EncryptPassword(password string) (string, error) {
return "", err return "", err
} }
// Create a new GCM cipher
gcm, err := cipher.NewGCM(block) gcm, err := cipher.NewGCM(block)
if err != nil { if err != nil {
return "", err return "", err
} }
// Create a cryptographically secure nonce
nonce := make([]byte, gcm.NonceSize()) nonce := make([]byte, gcm.NonceSize())
if _, err := io.ReadFull(rand.Reader, nonce); err != nil { if _, err := io.ReadFull(rand.Reader, nonce); err != nil {
return "", err return "", err
} }
// Encrypt the password with authenticated encryption
ciphertext := gcm.Seal(nonce, nonce, []byte(password), nil) ciphertext := gcm.Seal(nonce, nonce, []byte(password), nil)
// Return base64 encoded encrypted password
return base64.StdEncoding.EncodeToString(ciphertext), nil return base64.StdEncoding.EncodeToString(ciphertext), nil
} }
// DecryptPassword decrypts an encrypted password with enhanced validation
func DecryptPassword(encryptedPassword string) (string, error) { func DecryptPassword(encryptedPassword string) (string, error) {
if encryptedPassword == "" { if encryptedPassword == "" {
return "", errors.New("encrypted password cannot be empty") return "", errors.New("encrypted password cannot be empty")
} }
// Validate base64 format if len(encryptedPassword) < 24 {
if len(encryptedPassword) < 24 { // Minimum reasonable length
return "", errors.New("invalid encrypted password format") return "", errors.New("invalid encrypted password format")
} }
@@ -184,13 +162,11 @@ func DecryptPassword(encryptedPassword string) (string, error) {
return "", err return "", err
} }
// Create a new GCM cipher
gcm, err := cipher.NewGCM(block) gcm, err := cipher.NewGCM(block)
if err != nil { if err != nil {
return "", err return "", err
} }
// Decode base64 encoded password
ciphertext, err := base64.StdEncoding.DecodeString(encryptedPassword) ciphertext, err := base64.StdEncoding.DecodeString(encryptedPassword)
if err != nil { if err != nil {
return "", errors.New("invalid base64 encoding") return "", errors.New("invalid base64 encoding")
@@ -207,7 +183,6 @@ func DecryptPassword(encryptedPassword string) (string, error) {
return "", errors.New("decryption failed - invalid ciphertext or key") return "", errors.New("decryption failed - invalid ciphertext or key")
} }
// Validate decrypted content
decrypted := string(plaintext) decrypted := string(plaintext)
if len(decrypted) == 0 || len(decrypted) > 1024 { if len(decrypted) == 0 || len(decrypted) > 1024 {
return "", errors.New("invalid decrypted password") return "", errors.New("invalid decrypted password")

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

@@ -8,7 +8,6 @@ import (
"gorm.io/gorm" "gorm.io/gorm"
) )
// User represents a user account in the system.
type User struct { type User struct {
ID uuid.UUID `json:"id" gorm:"type:uuid;primary_key;"` ID uuid.UUID `json:"id" gorm:"type:uuid;primary_key;"`
Username string `json:"username" gorm:"unique_index;not null"` Username string `json:"username" gorm:"unique_index;not null"`
@@ -17,16 +16,13 @@ type User struct {
Role Role `json:"role"` Role Role `json:"role"`
} }
// BeforeCreate is a GORM hook that runs before creating new users
func (s *User) BeforeCreate(tx *gorm.DB) error { func (s *User) BeforeCreate(tx *gorm.DB) error {
s.ID = uuid.New() s.ID = uuid.New()
// Validate password strength
if err := password.ValidatePasswordStrength(s.Password); err != nil { if err := password.ValidatePasswordStrength(s.Password); err != nil {
return err return err
} }
// Hash password before saving
hashed, err := password.HashPassword(s.Password) hashed, err := password.HashPassword(s.Password)
if err != nil { if err != nil {
return err return err
@@ -36,11 +32,8 @@ func (s *User) BeforeCreate(tx *gorm.DB) error {
return nil return nil
} }
// BeforeUpdate is a GORM hook that runs before updating users
func (s *User) BeforeUpdate(tx *gorm.DB) error { func (s *User) BeforeUpdate(tx *gorm.DB) error {
// Only hash if password field is being updated
if tx.Statement.Changed("Password") { if tx.Statement.Changed("Password") {
// Validate password strength
if err := password.ValidatePasswordStrength(s.Password); err != nil { if err := password.ValidatePasswordStrength(s.Password); err != nil {
return err return err
} }
@@ -55,14 +48,10 @@ func (s *User) BeforeUpdate(tx *gorm.DB) error {
return nil return nil
} }
// AfterFind is a GORM hook that runs after fetching users
func (s *User) AfterFind(tx *gorm.DB) error { func (s *User) AfterFind(tx *gorm.DB) error {
// Password remains hashed - never decrypt
// This hook is kept for potential future use
return nil return nil
} }
// Validate checks if the user data is valid
func (s *User) Validate() error { func (s *User) Validate() error {
if s.Username == "" { if s.Username == "" {
return errors.New("username is required") return errors.New("username is required")
@@ -73,7 +62,6 @@ func (s *User) Validate() error {
return nil return nil
} }
// VerifyPassword verifies a plain text password against the stored hash
func (s *User) VerifyPassword(plainPassword string) error { func (s *User) VerifyPassword(plainPassword string) error {
return password.VerifyPassword(s.Password, plainPassword) return password.VerifyPassword(s.Password, plainPassword)
} }

80
local/model/websocket.go Executable file
View File

@@ -0,0 +1,80 @@
package model
import (
"github.com/google/uuid"
)
type ServerCreationStep string
const (
StepValidation ServerCreationStep = "validation"
StepDirectoryCreation ServerCreationStep = "directory_creation"
StepSteamDownload ServerCreationStep = "steam_download"
StepConfigGeneration ServerCreationStep = "config_generation"
StepServiceCreation ServerCreationStep = "service_creation"
StepFirewallRules ServerCreationStep = "firewall_rules"
StepDatabaseSave ServerCreationStep = "database_save"
StepCompleted ServerCreationStep = "completed"
)
type StepStatus string
const (
StatusPending StepStatus = "pending"
StatusInProgress StepStatus = "in_progress"
StatusCompleted StepStatus = "completed"
StatusFailed StepStatus = "failed"
)
type WebSocketMessageType string
const (
MessageTypeStep WebSocketMessageType = "step"
MessageTypeSteamOutput WebSocketMessageType = "steam_output"
MessageTypeError WebSocketMessageType = "error"
MessageTypeComplete WebSocketMessageType = "complete"
)
type WebSocketMessage struct {
Type WebSocketMessageType `json:"type"`
ServerID *uuid.UUID `json:"server_id,omitempty"`
Timestamp int64 `json:"timestamp"`
Data interface{} `json:"data"`
}
type StepMessage struct {
Step ServerCreationStep `json:"step"`
Status StepStatus `json:"status"`
Message string `json:"message,omitempty"`
Error string `json:"error,omitempty"`
}
type SteamOutputMessage struct {
Output string `json:"output"`
IsError bool `json:"is_error"`
}
type ErrorMessage struct {
Error string `json:"error"`
Details string `json:"details,omitempty"`
}
type CompleteMessage struct {
ServerID uuid.UUID `json:"server_id"`
Success bool `json:"success"`
Message string `json:"message"`
}
func GetStepDescription(step ServerCreationStep) string {
descriptions := map[ServerCreationStep]string{
StepValidation: "Validating server configuration",
StepDirectoryCreation: "Creating server directories",
StepSteamDownload: "Downloading server files via Steam",
StepConfigGeneration: "Generating server configuration files",
StepServiceCreation: "Creating Windows service",
StepFirewallRules: "Configuring firewall rules",
StepDatabaseSave: "Saving server to database",
StepCompleted: "Server creation completed",
}
return descriptions[step]
}

View File

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

View File

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

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

@@ -7,13 +7,11 @@ import (
"gorm.io/gorm" "gorm.io/gorm"
) )
// BaseRepository provides generic CRUD operations for any model
type BaseRepository[T any, F any] struct { type BaseRepository[T any, F any] struct {
db *gorm.DB db *gorm.DB
modelType T modelType T
} }
// NewBaseRepository creates a new base repository for the given model type
func NewBaseRepository[T any, F any](db *gorm.DB, model T) *BaseRepository[T, F] { func NewBaseRepository[T any, F any](db *gorm.DB, model T) *BaseRepository[T, F] {
return &BaseRepository[T, F]{ return &BaseRepository[T, F]{
db: db, db: db,
@@ -21,23 +19,19 @@ func NewBaseRepository[T any, F any](db *gorm.DB, model T) *BaseRepository[T, F]
} }
} }
// GetAll retrieves all records based on the filter
func (r *BaseRepository[T, F]) GetAll(ctx context.Context, filter *F) (*[]T, error) { func (r *BaseRepository[T, F]) GetAll(ctx context.Context, filter *F) (*[]T, error) {
result := new([]T) result := new([]T)
query := r.db.WithContext(ctx).Model(&r.modelType) query := r.db.WithContext(ctx).Model(&r.modelType)
// Apply filter conditions if filter implements Filterable
if filterable, ok := any(filter).(Filterable); ok { if filterable, ok := any(filter).(Filterable); ok {
query = filterable.ApplyFilter(query) query = filterable.ApplyFilter(query)
} }
// Apply pagination if filter implements Pageable
if pageable, ok := any(filter).(Pageable); ok { if pageable, ok := any(filter).(Pageable); ok {
offset, limit := pageable.Pagination() offset, limit := pageable.Pagination()
query = query.Offset(offset).Limit(limit) query = query.Offset(offset).Limit(limit)
} }
// Apply sorting if filter implements Sortable
if sortable, ok := any(filter).(Sortable); ok { if sortable, ok := any(filter).(Sortable); ok {
field, desc := sortable.GetSorting() field, desc := sortable.GetSorting()
if desc { if desc {
@@ -54,7 +48,6 @@ func (r *BaseRepository[T, F]) GetAll(ctx context.Context, filter *F) (*[]T, err
return result, nil return result, nil
} }
// GetByID retrieves a single record by ID
func (r *BaseRepository[T, F]) GetByID(ctx context.Context, id interface{}) (*T, error) { func (r *BaseRepository[T, F]) GetByID(ctx context.Context, id interface{}) (*T, error) {
result := new(T) result := new(T)
if err := r.db.WithContext(ctx).Where("id = ?", id).First(result).Error; err != nil { if err := r.db.WithContext(ctx).Where("id = ?", id).First(result).Error; err != nil {
@@ -66,7 +59,6 @@ func (r *BaseRepository[T, F]) GetByID(ctx context.Context, id interface{}) (*T,
return result, nil return result, nil
} }
// Insert creates a new record
func (r *BaseRepository[T, F]) Insert(ctx context.Context, model *T) error { func (r *BaseRepository[T, F]) Insert(ctx context.Context, model *T) error {
if err := r.db.WithContext(ctx).Create(model).Error; err != nil { if err := r.db.WithContext(ctx).Create(model).Error; err != nil {
return fmt.Errorf("error creating record: %w", err) return fmt.Errorf("error creating record: %w", err)
@@ -74,7 +66,6 @@ func (r *BaseRepository[T, F]) Insert(ctx context.Context, model *T) error {
return nil return nil
} }
// Update modifies an existing record
func (r *BaseRepository[T, F]) Update(ctx context.Context, model *T) error { func (r *BaseRepository[T, F]) Update(ctx context.Context, model *T) error {
if err := r.db.WithContext(ctx).Save(model).Error; err != nil { if err := r.db.WithContext(ctx).Save(model).Error; err != nil {
return fmt.Errorf("error updating record: %w", err) return fmt.Errorf("error updating record: %w", err)
@@ -82,7 +73,6 @@ func (r *BaseRepository[T, F]) Update(ctx context.Context, model *T) error {
return nil return nil
} }
// Delete removes a record by ID
func (r *BaseRepository[T, F]) Delete(ctx context.Context, id interface{}) error { func (r *BaseRepository[T, F]) Delete(ctx context.Context, id interface{}) error {
if err := r.db.WithContext(ctx).Delete(new(T), id).Error; err != nil { if err := r.db.WithContext(ctx).Delete(new(T), id).Error; err != nil {
return fmt.Errorf("error deleting record: %w", err) return fmt.Errorf("error deleting record: %w", err)
@@ -90,7 +80,6 @@ func (r *BaseRepository[T, F]) Delete(ctx context.Context, id interface{}) error
return nil return nil
} }
// Count returns the total number of records matching the filter
func (r *BaseRepository[T, F]) Count(ctx context.Context, filter *F) (int64, error) { func (r *BaseRepository[T, F]) Count(ctx context.Context, filter *F) (int64, error) {
var count int64 var count int64
query := r.db.WithContext(ctx).Model(&r.modelType) query := r.db.WithContext(ctx).Model(&r.modelType)
@@ -106,8 +95,6 @@ func (r *BaseRepository[T, F]) Count(ctx context.Context, filter *F) (int64, err
return count, nil return count, nil
} }
// Interfaces for filter capabilities
type Filterable interface { type Filterable interface {
ApplyFilter(*gorm.DB) *gorm.DB ApplyFilter(*gorm.DB) *gorm.DB
} }

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

@@ -17,10 +17,8 @@ func NewConfigRepository(db *gorm.DB) *ConfigRepository {
} }
} }
// UpdateConfig updates or creates a Config record
func (r *ConfigRepository) UpdateConfig(ctx context.Context, config *model.Config) *model.Config { func (r *ConfigRepository) UpdateConfig(ctx context.Context, config *model.Config) *model.Config {
if err := r.Update(ctx, config); err != nil { if err := r.Update(ctx, config); err != nil {
// If update fails, try to insert
if err := r.Insert(ctx, config); err != nil { if err := r.Insert(ctx, config); err != nil {
return nil return nil
} }

144
local/repository/leaderboard.go Executable file
View File

@@ -0,0 +1,144 @@
package repository
import (
"acc-server-manager/local/model"
"context"
"errors"
"fmt"
"github.com/google/uuid"
"gorm.io/gorm"
)
type LeaderboardRepository struct {
db *gorm.DB
}
func NewLeaderboardRepository(db *gorm.DB) *LeaderboardRepository {
return &LeaderboardRepository{db: db}
}
// GetOrCreateByServerID fetches the leaderboard for a server, creating an empty one if it doesn't exist.
func (r *LeaderboardRepository) GetOrCreateByServerID(ctx context.Context, serverID uuid.UUID) (*model.Leaderboard, error) {
lb := new(model.Leaderboard)
err := r.db.WithContext(ctx).
Preload("Drivers").
Preload("Races.Results").
Preload("PointRows").
Where("server_id = ?", serverID).
First(lb).Error
if err == nil {
return lb, nil
}
if !errors.Is(err, gorm.ErrRecordNotFound) {
return nil, fmt.Errorf("error fetching leaderboard: %w", err)
}
lb = &model.Leaderboard{
ServerID: serverID,
FLPoints: 1,
FLColor: "#8b5cf6",
FLTextColor: "#000000",
}
if err := r.db.WithContext(ctx).Create(lb).Error; err != nil {
return nil, fmt.Errorf("error creating leaderboard: %w", err)
}
return lb, nil
}
// FullReplace replaces all leaderboard data for a server in a single transaction.
func (r *LeaderboardRepository) FullReplace(ctx context.Context, serverID uuid.UUID, lb *model.Leaderboard) (*model.Leaderboard, error) {
err := r.db.WithContext(ctx).Transaction(func(tx *gorm.DB) error {
existing := new(model.Leaderboard)
err := tx.Where("server_id = ?", serverID).First(existing).Error
if err != nil && !errors.Is(err, gorm.ErrRecordNotFound) {
return fmt.Errorf("error fetching existing leaderboard: %w", err)
}
if errors.Is(err, gorm.ErrRecordNotFound) {
lb.ServerID = serverID
return tx.Create(lb).Error
}
// Delete children
if err := tx.Where("leaderboard_id = ?", existing.ID).Delete(&model.LeaderboardPointRow{}).Error; err != nil {
return err
}
if err := tx.Where("leaderboard_id = ?", existing.ID).Delete(&model.LeaderboardDriver{}).Error; err != nil {
return err
}
// Get race IDs to delete results
var raceIDs []uuid.UUID
tx.Model(&model.LeaderboardRace{}).Where("leaderboard_id = ?", existing.ID).Pluck("id", &raceIDs)
if len(raceIDs) > 0 {
if err := tx.Where("race_id IN ?", raceIDs).Delete(&model.LeaderboardResult{}).Error; err != nil {
return err
}
}
if err := tx.Where("leaderboard_id = ?", existing.ID).Delete(&model.LeaderboardRace{}).Error; err != nil {
return err
}
// Update parent fields
existing.FLPoints = lb.FLPoints
existing.FLColor = lb.FLColor
existing.FLTextColor = lb.FLTextColor
if err := tx.Save(existing).Error; err != nil {
return err
}
// Re-insert children with correct leaderboard ID
for i := range lb.Drivers {
lb.Drivers[i].LeaderboardID = existing.ID
}
if len(lb.Drivers) > 0 {
if err := tx.Create(&lb.Drivers).Error; err != nil {
return err
}
}
for i := range lb.PointRows {
lb.PointRows[i].LeaderboardID = existing.ID
lb.PointRows[i].ID = uuid.Nil
}
if len(lb.PointRows) > 0 {
if err := tx.Create(&lb.PointRows).Error; err != nil {
return err
}
}
for i := range lb.Races {
lb.Races[i].LeaderboardID = existing.ID
}
if len(lb.Races) > 0 {
// Create races first (without results) to get IDs
for i := range lb.Races {
results := lb.Races[i].Results
lb.Races[i].Results = nil
if err := tx.Create(&lb.Races[i]).Error; err != nil {
return err
}
// Re-attach results with correct race ID
for j := range results {
results[j].RaceID = lb.Races[i].ID
results[j].ID = uuid.Nil
}
if len(results) > 0 {
if err := tx.Create(&results).Error; err != nil {
return err
}
}
}
}
lb.ID = existing.ID
return nil
})
if err != nil {
return nil, err
}
return r.GetOrCreateByServerID(ctx, serverID)
}

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

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

@@ -8,20 +8,16 @@ import (
"gorm.io/gorm" "gorm.io/gorm"
) )
// MembershipRepository handles database operations for users, roles, and permissions.
type MembershipRepository struct { type MembershipRepository struct {
*BaseRepository[model.User, model.MembershipFilter] *BaseRepository[model.User, model.MembershipFilter]
} }
// NewMembershipRepository creates a new MembershipRepository.
func NewMembershipRepository(db *gorm.DB) *MembershipRepository { func NewMembershipRepository(db *gorm.DB) *MembershipRepository {
return &MembershipRepository{ return &MembershipRepository{
BaseRepository: NewBaseRepository[model.User, model.MembershipFilter](db, model.User{}), BaseRepository: NewBaseRepository[model.User, model.MembershipFilter](db, model.User{}),
} }
} }
// FindUserByUsername finds a user by their username.
// It preloads the user's role and the role's permissions.
func (r *MembershipRepository) FindUserByUsername(ctx context.Context, username string) (*model.User, error) { func (r *MembershipRepository) FindUserByUsername(ctx context.Context, username string) (*model.User, error) {
var user model.User var user model.User
db := r.db.WithContext(ctx) db := r.db.WithContext(ctx)
@@ -32,7 +28,6 @@ func (r *MembershipRepository) FindUserByUsername(ctx context.Context, username
return &user, nil return &user, nil
} }
// FindUserByIDWithPermissions finds a user by their ID and preloads Role and Permissions.
func (r *MembershipRepository) FindUserByIDWithPermissions(ctx context.Context, userID string) (*model.User, error) { func (r *MembershipRepository) FindUserByIDWithPermissions(ctx context.Context, userID string) (*model.User, error) {
var user model.User var user model.User
db := r.db.WithContext(ctx) db := r.db.WithContext(ctx)
@@ -43,13 +38,11 @@ func (r *MembershipRepository) FindUserByIDWithPermissions(ctx context.Context,
return &user, nil return &user, nil
} }
// CreateUser creates a new user.
func (r *MembershipRepository) CreateUser(ctx context.Context, user *model.User) error { func (r *MembershipRepository) CreateUser(ctx context.Context, user *model.User) error {
db := r.db.WithContext(ctx) db := r.db.WithContext(ctx)
return db.Create(user).Error return db.Create(user).Error
} }
// FindRoleByName finds a role by its name.
func (r *MembershipRepository) FindRoleByName(ctx context.Context, name string) (*model.Role, error) { func (r *MembershipRepository) FindRoleByName(ctx context.Context, name string) (*model.Role, error) {
var role model.Role var role model.Role
db := r.db.WithContext(ctx) db := r.db.WithContext(ctx)
@@ -60,13 +53,11 @@ func (r *MembershipRepository) FindRoleByName(ctx context.Context, name string)
return &role, nil return &role, nil
} }
// CreateRole creates a new role.
func (r *MembershipRepository) CreateRole(ctx context.Context, role *model.Role) error { func (r *MembershipRepository) CreateRole(ctx context.Context, role *model.Role) error {
db := r.db.WithContext(ctx) db := r.db.WithContext(ctx)
return db.Create(role).Error return db.Create(role).Error
} }
// FindPermissionByName finds a permission by its name.
func (r *MembershipRepository) FindPermissionByName(ctx context.Context, name string) (*model.Permission, error) { func (r *MembershipRepository) FindPermissionByName(ctx context.Context, name string) (*model.Permission, error) {
var permission model.Permission var permission model.Permission
db := r.db.WithContext(ctx) db := r.db.WithContext(ctx)
@@ -77,19 +68,16 @@ func (r *MembershipRepository) FindPermissionByName(ctx context.Context, name st
return &permission, nil return &permission, nil
} }
// CreatePermission creates a new permission.
func (r *MembershipRepository) CreatePermission(ctx context.Context, permission *model.Permission) error { func (r *MembershipRepository) CreatePermission(ctx context.Context, permission *model.Permission) error {
db := r.db.WithContext(ctx) db := r.db.WithContext(ctx)
return db.Create(permission).Error return db.Create(permission).Error
} }
// AssignPermissionsToRole assigns a set of permissions to a role.
func (r *MembershipRepository) AssignPermissionsToRole(ctx context.Context, role *model.Role, permissions []model.Permission) error { func (r *MembershipRepository) AssignPermissionsToRole(ctx context.Context, role *model.Role, permissions []model.Permission) error {
db := r.db.WithContext(ctx) db := r.db.WithContext(ctx)
return db.Model(role).Association("Permissions").Replace(permissions) return db.Model(role).Association("Permissions").Replace(permissions)
} }
// GetUserPermissions retrieves all permissions for a given user ID.
func (r *MembershipRepository) GetUserPermissions(ctx context.Context, userID uuid.UUID) ([]string, error) { func (r *MembershipRepository) GetUserPermissions(ctx context.Context, userID uuid.UUID) ([]string, error) {
var user model.User var user model.User
db := r.db.WithContext(ctx) db := r.db.WithContext(ctx)
@@ -106,7 +94,6 @@ func (r *MembershipRepository) GetUserPermissions(ctx context.Context, userID uu
return permissions, nil return permissions, nil
} }
// ListUsers retrieves all users.
func (r *MembershipRepository) ListUsers(ctx context.Context) ([]*model.User, error) { func (r *MembershipRepository) ListUsers(ctx context.Context) ([]*model.User, error) {
var users []*model.User var users []*model.User
db := r.db.WithContext(ctx) db := r.db.WithContext(ctx)
@@ -114,13 +101,11 @@ func (r *MembershipRepository) ListUsers(ctx context.Context) ([]*model.User, er
return users, err return users, err
} }
// DeleteUser deletes a user.
func (r *MembershipRepository) DeleteUser(ctx context.Context, userID uuid.UUID) error { func (r *MembershipRepository) DeleteUser(ctx context.Context, userID uuid.UUID) error {
db := r.db.WithContext(ctx) db := r.db.WithContext(ctx)
return db.Delete(&model.User{}, "id = ?", userID).Error return db.Delete(&model.User{}, "id = ?", userID).Error
} }
// FindUserByID finds a user by their ID.
func (r *MembershipRepository) FindUserByID(ctx context.Context, userID uuid.UUID) (*model.User, error) { func (r *MembershipRepository) FindUserByID(ctx context.Context, userID uuid.UUID) (*model.User, error) {
var user model.User var user model.User
db := r.db.WithContext(ctx) db := r.db.WithContext(ctx)
@@ -131,13 +116,11 @@ func (r *MembershipRepository) FindUserByID(ctx context.Context, userID uuid.UUI
return &user, nil return &user, nil
} }
// UpdateUser updates a user's details in the database.
func (r *MembershipRepository) UpdateUser(ctx context.Context, user *model.User) error { func (r *MembershipRepository) UpdateUser(ctx context.Context, user *model.User) error {
db := r.db.WithContext(ctx) db := r.db.WithContext(ctx)
return db.Save(user).Error return db.Save(user).Error
} }
// FindRoleByID finds a role by its ID.
func (r *MembershipRepository) FindRoleByID(ctx context.Context, roleID uuid.UUID) (*model.Role, error) { func (r *MembershipRepository) FindRoleByID(ctx context.Context, roleID uuid.UUID) (*model.Role, error) {
var role model.Role var role model.Role
db := r.db.WithContext(ctx) db := r.db.WithContext(ctx)
@@ -148,12 +131,10 @@ func (r *MembershipRepository) FindRoleByID(ctx context.Context, roleID uuid.UUI
return &role, nil return &role, nil
} }
// ListUsersWithFilter retrieves users based on the membership filter.
func (r *MembershipRepository) ListUsersWithFilter(ctx context.Context, filter *model.MembershipFilter) (*[]model.User, error) { func (r *MembershipRepository) ListUsersWithFilter(ctx context.Context, filter *model.MembershipFilter) (*[]model.User, error) {
return r.BaseRepository.GetAll(ctx, filter) return r.BaseRepository.GetAll(ctx, filter)
} }
// ListRoles retrieves all roles.
func (r *MembershipRepository) ListRoles(ctx context.Context) ([]*model.Role, error) { func (r *MembershipRepository) ListRoles(ctx context.Context) ([]*model.Role, error) {
var roles []*model.Role var roles []*model.Role
db := r.db.WithContext(ctx) db := r.db.WithContext(ctx)

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

@@ -10,11 +10,7 @@ import (
"go.uber.org/dig" "go.uber.org/dig"
) )
// InitializeRepositories // *dig.Container: Dig Container
// Initializes Dependency Injection modules for repositories
//
// Args:
// *dig.Container: Dig Container
func InitializeRepositories(c *dig.Container) { func InitializeRepositories(c *dig.Container) {
c.Provide(NewServiceControlRepository) c.Provide(NewServiceControlRepository)
c.Provide(NewStateHistoryRepository) c.Provide(NewStateHistoryRepository)
@@ -23,12 +19,12 @@ func InitializeRepositories(c *dig.Container) {
c.Provide(NewLookupRepository) c.Provide(NewLookupRepository)
c.Provide(NewSteamCredentialsRepository) c.Provide(NewSteamCredentialsRepository)
c.Provide(NewMembershipRepository) c.Provide(NewMembershipRepository)
c.Provide(NewLeaderboardRepository)
c.Provide(NewActivityLogRepository)
// Provide the Steam2FAManager as a singleton
if err := c.Provide(func() *model.Steam2FAManager { if err := c.Provide(func() *model.Steam2FAManager {
manager := model.NewSteam2FAManager() manager := model.NewSteam2FAManager()
// Use graceful shutdown manager for cleanup goroutine
shutdownManager := graceful.GetManager() shutdownManager := graceful.GetManager()
shutdownManager.RunGoroutine(func(ctx context.Context) { shutdownManager.RunGoroutine(func(ctx context.Context) {
ticker := time.NewTicker(15 * time.Minute) ticker := time.NewTicker(15 * time.Minute)

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

@@ -20,10 +20,6 @@ func NewServerRepository(db *gorm.DB) *ServerRepository {
return repo return repo
} }
// GetFirstByServiceName
// Gets first row from Server table.
//
// Args:
// context.Context: Application context // context.Context: Application context
// Returns: // Returns:
// model.ServerModel: Server object from database. // model.ServerModel: Server object from database.

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

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

@@ -18,17 +18,14 @@ func NewStateHistoryRepository(db *gorm.DB) *StateHistoryRepository {
} }
} }
// GetAll retrieves all state history records with the given filter
func (r *StateHistoryRepository) GetAll(ctx context.Context, filter *model.StateHistoryFilter) (*[]model.StateHistory, error) { func (r *StateHistoryRepository) GetAll(ctx context.Context, filter *model.StateHistoryFilter) (*[]model.StateHistory, error) {
return r.BaseRepository.GetAll(ctx, filter) return r.BaseRepository.GetAll(ctx, filter)
} }
// Insert creates a new state history record
func (r *StateHistoryRepository) Insert(ctx context.Context, model *model.StateHistory) error { func (r *StateHistoryRepository) Insert(ctx context.Context, model *model.StateHistory) error {
return r.BaseRepository.Insert(ctx, model) return r.BaseRepository.Insert(ctx, model)
} }
// GetLastSessionID gets the last session ID for a server
func (r *StateHistoryRepository) GetLastSessionID(ctx context.Context, serverID uuid.UUID) (uuid.UUID, error) { func (r *StateHistoryRepository) GetLastSessionID(ctx context.Context, serverID uuid.UUID) (uuid.UUID, error) {
var lastSession model.StateHistory var lastSession model.StateHistory
result := r.BaseRepository.db.WithContext(ctx). result := r.BaseRepository.db.WithContext(ctx).
@@ -38,7 +35,7 @@ func (r *StateHistoryRepository) GetLastSessionID(ctx context.Context, serverID
if result.Error != nil { if result.Error != nil {
if result.Error == gorm.ErrRecordNotFound { if result.Error == gorm.ErrRecordNotFound {
return uuid.Nil, nil // Return nil UUID if no sessions found return uuid.Nil, nil
} }
return uuid.Nil, result.Error return uuid.Nil, result.Error
} }
@@ -46,10 +43,8 @@ func (r *StateHistoryRepository) GetLastSessionID(ctx context.Context, serverID
return lastSession.SessionID, nil return lastSession.SessionID, nil
} }
// GetSummaryStats calculates peak players, total sessions, and average players.
func (r *StateHistoryRepository) GetSummaryStats(ctx context.Context, filter *model.StateHistoryFilter) (model.StateHistoryStats, error) { func (r *StateHistoryRepository) GetSummaryStats(ctx context.Context, filter *model.StateHistoryFilter) (model.StateHistoryStats, error) {
var stats model.StateHistoryStats var stats model.StateHistoryStats
// Parse ServerID to UUID for query
serverUUID, err := uuid.Parse(filter.ServerID) serverUUID, err := uuid.Parse(filter.ServerID)
if err != nil { if err != nil {
return model.StateHistoryStats{}, err return model.StateHistoryStats{}, err
@@ -73,12 +68,10 @@ func (r *StateHistoryRepository) GetSummaryStats(ctx context.Context, filter *mo
return stats, nil return stats, nil
} }
// GetTotalPlaytime calculates the total playtime in minutes.
func (r *StateHistoryRepository) GetTotalPlaytime(ctx context.Context, filter *model.StateHistoryFilter) (int, error) { func (r *StateHistoryRepository) GetTotalPlaytime(ctx context.Context, filter *model.StateHistoryFilter) (int, error) {
var totalPlaytime struct { var totalPlaytime struct {
TotalMinutes float64 TotalMinutes float64
} }
// Parse ServerID to UUID for query
serverUUID, err := uuid.Parse(filter.ServerID) serverUUID, err := uuid.Parse(filter.ServerID)
if err != nil { if err != nil {
return 0, err return 0, err
@@ -100,10 +93,8 @@ func (r *StateHistoryRepository) GetTotalPlaytime(ctx context.Context, filter *m
return int(totalPlaytime.TotalMinutes), nil return int(totalPlaytime.TotalMinutes), nil
} }
// GetPlayerCountOverTime gets downsampled player count data.
func (r *StateHistoryRepository) GetPlayerCountOverTime(ctx context.Context, filter *model.StateHistoryFilter) ([]model.PlayerCountPoint, error) { func (r *StateHistoryRepository) GetPlayerCountOverTime(ctx context.Context, filter *model.StateHistoryFilter) ([]model.PlayerCountPoint, error) {
var points []model.PlayerCountPoint var points []model.PlayerCountPoint
// Parse ServerID to UUID for query
serverUUID, err := uuid.Parse(filter.ServerID) serverUUID, err := uuid.Parse(filter.ServerID)
if err != nil { if err != nil {
return points, err return points, err
@@ -122,10 +113,8 @@ func (r *StateHistoryRepository) GetPlayerCountOverTime(ctx context.Context, fil
return points, err return points, err
} }
// GetSessionTypes counts sessions by type.
func (r *StateHistoryRepository) GetSessionTypes(ctx context.Context, filter *model.StateHistoryFilter) ([]model.SessionCount, error) { func (r *StateHistoryRepository) GetSessionTypes(ctx context.Context, filter *model.StateHistoryFilter) ([]model.SessionCount, error) {
var sessionTypes []model.SessionCount var sessionTypes []model.SessionCount
// Parse ServerID to UUID for query
serverUUID, err := uuid.Parse(filter.ServerID) serverUUID, err := uuid.Parse(filter.ServerID)
if err != nil { if err != nil {
return sessionTypes, err return sessionTypes, err
@@ -145,10 +134,8 @@ func (r *StateHistoryRepository) GetSessionTypes(ctx context.Context, filter *mo
return sessionTypes, err return sessionTypes, err
} }
// GetDailyActivity counts sessions per day.
func (r *StateHistoryRepository) GetDailyActivity(ctx context.Context, filter *model.StateHistoryFilter) ([]model.DailyActivity, error) { func (r *StateHistoryRepository) GetDailyActivity(ctx context.Context, filter *model.StateHistoryFilter) ([]model.DailyActivity, error) {
var dailyActivity []model.DailyActivity var dailyActivity []model.DailyActivity
// Parse ServerID to UUID for query
serverUUID, err := uuid.Parse(filter.ServerID) serverUUID, err := uuid.Parse(filter.ServerID)
if err != nil { if err != nil {
return dailyActivity, err return dailyActivity, err
@@ -167,10 +154,8 @@ func (r *StateHistoryRepository) GetDailyActivity(ctx context.Context, filter *m
return dailyActivity, err return dailyActivity, err
} }
// GetRecentSessions retrieves the 10 most recent sessions.
func (r *StateHistoryRepository) GetRecentSessions(ctx context.Context, filter *model.StateHistoryFilter) ([]model.RecentSession, error) { func (r *StateHistoryRepository) GetRecentSessions(ctx context.Context, filter *model.StateHistoryFilter) ([]model.RecentSession, error) {
var recentSessions []model.RecentSession var recentSessions []model.RecentSession
// Parse ServerID to UUID for query
serverUUID, err := uuid.Parse(filter.ServerID) serverUUID, err := uuid.Parse(filter.ServerID)
if err != nil { if err != nil {
return recentSessions, err return recentSessions, err

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

View File

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

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

@@ -68,17 +68,19 @@ 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, // Cache configs for 5 minutes ExpirationTime: 5 * time.Minute,
ThrottleTime: 1 * time.Second, // Prevent rapid re-reads ThrottleTime: 1 * time.Second,
DefaultStatus: model.StatusUnknown, DefaultStatus: model.StatusUnknown,
}), }),
} }
@@ -88,23 +90,155 @@ func (as *ConfigService) SetServerService(serverService *ServerService) {
as.serverService = serverService as.serverService = serverService
} }
// UpdateConfig
// Updates physical config file and caches it in database.
//
// Args:
// context.Context: Application context // context.Context: Application context
// Returns: // Returns:
// string: Application version // string: Application version
func (as *ConfigService) UpdateAllConfigs(ctx *fiber.Ctx, req *model.ConfigurationsUpdateRequest) ([]*model.Config, error) {
serverID := ctx.Locals("serverId").(string)
override := ctx.QueryBool("override", false)
serverUUID, err := uuid.Parse(serverID)
if err != nil {
logging.Error("Invalid server ID format: %v", err)
return nil, fmt.Errorf("invalid server ID format")
}
server, err := as.serverRepository.GetByID(ctx.UserContext(), serverUUID)
if err != nil {
logging.Error("Server not found")
return nil, fmt.Errorf("server not found")
}
type configEntry struct {
fileName string
body *map[string]interface{}
}
entries := []configEntry{
{ConfigurationJson, req.Configuration},
{AssistRulesJson, req.AssistRules},
{EventJson, req.Event},
{EventRulesJson, req.EventRules},
{SettingsJson, req.Settings},
}
actorID, _ := ctx.Locals("userID").(string)
actorUsername, _ := ctx.Locals("actorUsername").(string)
if actorUsername == "" {
actorUsername = "system"
}
var results []*model.Config
for _, entry := range entries {
if entry.body == nil {
continue
}
oldDataUTF8, newData, err := as.updateConfigFiles(ctx.UserContext(), server, entry.fileName, entry.body, override)
if err != nil {
return nil, err
}
result := as.repository.UpdateConfig(ctx.UserContext(), &model.Config{
ServerID: serverUUID,
ConfigFile: entry.fileName,
OldConfig: string(oldDataUTF8),
NewConfig: string(newData),
ChangedAt: time.Now(),
})
results = append(results, result)
if as.activityLog != nil {
details := BuildConfigDetails(entry.fileName, oldDataUTF8, newData)
go func(details string) {
_ = as.activityLog.Log(context.Background(), &model.ActivityLog{
ServerID: serverUUID,
UserID: actorID,
Username: actorUsername,
Action: model.ActionConfigUpdate,
Details: details,
})
}(details)
}
}
if len(results) > 0 {
as.configCache.InvalidateServerCache(serverID)
as.serverService.StartAccServerRuntime(server)
}
return results, nil
}
func (as *ConfigService) UpdateConfig(ctx *fiber.Ctx, body *map[string]interface{}) (*model.Config, error) { func (as *ConfigService) UpdateConfig(ctx *fiber.Ctx, body *map[string]interface{}) (*model.Config, error) {
serverID := ctx.Locals("serverId").(string) serverID := ctx.Locals("serverId").(string)
configFile := ctx.Params("file") configFile := ctx.Params("file")
override := ctx.QueryBool("override", false) override := ctx.QueryBool("override", false)
return as.updateConfigInternal(ctx.UserContext(), serverID, configFile, body, override) actorID, _ := ctx.Locals("userID").(string)
actorUsername, _ := ctx.Locals("actorUsername").(string)
if actorUsername == "" {
actorUsername = "system"
}
return as.updateConfigInternal(ctx.UserContext(), serverID, configFile, body, override, actorID, actorUsername)
} }
// updateConfigInternal handles the actual config update logic without Fiber dependencies func (as *ConfigService) updateConfigFiles(ctx context.Context, server *model.Server, configFile string, body *map[string]interface{}, override bool) ([]byte, []byte, error) {
func (as *ConfigService) updateConfigInternal(ctx context.Context, serverID string, configFile string, body *map[string]interface{}, override bool) (*model.Config, error) { if server == nil {
logging.Error("Server not found")
return nil, nil, fmt.Errorf("server not found")
}
configPath := filepath.Join(server.GetConfigPath(), configFile)
oldData, err := os.ReadFile(configPath)
if err != nil {
if os.IsNotExist(err) {
dir := filepath.Dir(configPath)
if err := os.MkdirAll(dir, 0755); err != nil {
return nil, nil, err
}
if err := os.WriteFile(configPath, []byte("{}"), 0644); err != nil {
return nil, nil, err
}
oldData = []byte("{}")
} else {
return nil, nil, err
}
}
oldDataUTF8, err := DecodeUTF16LEBOM(oldData)
if err != nil {
return nil, nil, err
}
newData, err := json.Marshal(&body)
if err != nil {
return nil, nil, err
}
if !override {
newData, err = jsons.Merge(oldDataUTF8, newData)
if err != nil {
return nil, nil, err
}
}
newData, err = common.IndentJson(newData)
if err != nil {
return nil, nil, err
}
newDataUTF16, err := EncodeUTF16LEBOM(newData)
if err != nil {
return nil, nil, err
}
if err := os.WriteFile(configPath, newDataUTF16, 0644); err != nil {
return nil, nil, err
}
return oldDataUTF8, newData, nil
}
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)
@@ -117,63 +251,27 @@ func (as *ConfigService) updateConfigInternal(ctx context.Context, serverID stri
return nil, fmt.Errorf("server not found") return nil, fmt.Errorf("server not found")
} }
// Read existing config oldDataUTF8, newData, err := as.updateConfigFiles(ctx, server, configFile, body, override)
configPath := filepath.Join(server.GetConfigPath(), configFile)
oldData, err := os.ReadFile(configPath)
if err != nil {
if os.IsNotExist(err) {
// Create directory if it doesn't exist
dir := filepath.Dir(configPath)
if err := os.MkdirAll(dir, 0755); err != nil {
return nil, err
}
// Create empty JSON file
if err := os.WriteFile(configPath, []byte("{}"), 0644); err != nil {
return nil, err
}
oldData = []byte("{}")
} else {
return nil, err
}
}
oldDataUTF8, err := DecodeUTF16LEBOM(oldData)
if err != nil { if err != nil {
return nil, err return nil, err
} }
// Write new config
newData, err := json.Marshal(&body)
if err != nil {
return nil, err
}
if !override {
newData, err = jsons.Merge(oldDataUTF8, newData)
if err != nil {
return nil, err
}
}
newData, err = common.IndentJson(newData)
if err != nil {
return nil, err
}
newDataUTF16, err := EncodeUTF16LEBOM(newData)
if err != nil {
return nil, err
}
if err := os.WriteFile(configPath, newDataUTF16, 0644); err != nil {
return nil, err
}
// Invalidate all configs for this server since configs can be interdependent
as.configCache.InvalidateServerCache(serverID) as.configCache.InvalidateServerCache(serverID)
as.serverService.StartAccServerRuntime(server) as.serverService.StartAccServerRuntime(server)
// Log change if as.activityLog != nil {
details := BuildConfigDetails(configFile, oldDataUTF8, newData)
go func() {
_ = as.activityLog.Log(context.Background(), &model.ActivityLog{
ServerID: serverUUID,
UserID: actorID,
Username: actorUsername,
Action: model.ActionConfigUpdate,
Details: details,
})
}()
}
return as.repository.UpdateConfig(ctx, &model.Config{ return as.repository.UpdateConfig(ctx, &model.Config{
ServerID: serverUUID, ServerID: serverUUID,
ConfigFile: configFile, ConfigFile: configFile,
@@ -183,10 +281,6 @@ func (as *ConfigService) updateConfigInternal(ctx context.Context, serverID stri
}), nil }), nil
} }
// GetConfig
// Gets physical config file and caches it in database.
//
// Args:
// context.Context: Application context // context.Context: Application context
// Returns: // Returns:
// string: Application version // string: Application version
@@ -197,44 +291,47 @@ func (as *ConfigService) GetConfig(ctx *fiber.Ctx) (interface{}, error) {
logging.Debug("Getting config for server ID: %s, file: %s", serverIDStr, configFile) logging.Debug("Getting config for server ID: %s, file: %s", serverIDStr, configFile)
server, err := as.serverRepository.GetByID(ctx.UserContext(), serverIDStr) server, err := as.serverRepository.GetByID(ctx.UserContext(), serverIDStr)
if err != nil { if err != nil {
logging.Error("Server not found") logging.Error("Server not found")
return nil, fiber.NewError(404, "Server not found") return nil, fiber.NewError(404, "Server not found")
} }
return as.getConfigFile(server, configFile)
}
// Try to get from cache based on config file type func (as *ConfigService) getConfigFile(server *model.Server, configFile string) (interface{}, error) {
serverIDStr := server.ID.String()
switch configFile { switch configFile {
case ConfigurationJson: case ConfigurationJson:
if cached, ok := as.configCache.GetConfiguration(serverIDStr); ok { if cached, ok := as.configCache.GetConfiguration(serverIDStr); ok {
logging.Debug("Returning cached configuration for server ID: %s", serverIDStr) logging.Debug("Returning cached configuration for server ID: %s", serverIDStr)
return cached, nil return *cached, nil
} }
case AssistRulesJson: case AssistRulesJson:
if cached, ok := as.configCache.GetAssistRules(serverIDStr); ok { if cached, ok := as.configCache.GetAssistRules(serverIDStr); ok {
logging.Debug("Returning cached assist rules for server ID: %s", serverIDStr) logging.Debug("Returning cached assist rules for server ID: %s", serverIDStr)
return cached, nil return *cached, nil
} }
case EventJson: case EventJson:
if cached, ok := as.configCache.GetEvent(serverIDStr); ok { if cached, ok := as.configCache.GetEvent(serverIDStr); ok {
logging.Debug("Returning cached event config for server ID: %s", serverIDStr) logging.Debug("Returning cached event config for server ID: %s", serverIDStr)
return cached, nil return *cached, nil
} }
case EventRulesJson: case EventRulesJson:
if cached, ok := as.configCache.GetEventRules(serverIDStr); ok { if cached, ok := as.configCache.GetEventRules(serverIDStr); ok {
logging.Debug("Returning cached event rules for server ID: %s", serverIDStr) logging.Debug("Returning cached event rules for server ID: %s", serverIDStr)
return cached, nil return *cached, nil
} }
case SettingsJson: case SettingsJson:
if cached, ok := as.configCache.GetSettings(serverIDStr); ok { if cached, ok := as.configCache.GetSettings(serverIDStr); ok {
logging.Debug("Returning cached settings for server ID: %s", serverIDStr) logging.Debug("Returning cached settings for server ID: %s", serverIDStr)
return cached, nil return *cached, nil
} }
} }
logging.Debug("Cache miss for server ID: %s, file: %s - loading from disk", serverIDStr, configFile) logging.Debug("Cache miss for server ID: %s, file: %s - loading from disk", serverIDStr, configFile)
// Not in cache, load from disk configPath := server.GetConfigPath()
configPath := filepath.Join(server.GetConfigPath(), configFile)
decoder := DecodeFileName(configFile) decoder := DecodeFileName(configFile)
if decoder == nil { if decoder == nil {
return nil, errors.New("invalid config file") return nil, errors.New("invalid config file")
@@ -244,43 +341,39 @@ func (as *ConfigService) GetConfig(ctx *fiber.Ctx) (interface{}, error) {
if err != nil { if err != nil {
if os.IsNotExist(err) { if os.IsNotExist(err) {
logging.Debug("Config file not found, creating default for server ID: %s, file: %s", serverIDStr, configFile) logging.Debug("Config file not found, creating default for server ID: %s, file: %s", serverIDStr, configFile)
// Return empty config if file doesn't exist
switch configFile { switch configFile {
case ConfigurationJson: case ConfigurationJson:
return &model.Configuration{}, nil return model.Configuration{}, nil
case AssistRulesJson: case AssistRulesJson:
return &model.AssistRules{}, nil return model.AssistRules{}, nil
case EventJson: case EventJson:
return &model.EventConfig{}, nil return model.EventConfig{}, nil
case EventRulesJson: case EventRulesJson:
return &model.EventRules{}, nil return model.EventRules{}, nil
case SettingsJson: case SettingsJson:
return &model.ServerSettings{}, nil return model.ServerSettings{}, nil
} }
} }
return nil, err return nil, err
} }
// Cache the loaded config
switch configFile { switch configFile {
case ConfigurationJson: case ConfigurationJson:
as.configCache.UpdateConfiguration(serverIDStr, *config.(*model.Configuration)) as.configCache.UpdateConfiguration(serverIDStr, config.(model.Configuration))
case AssistRulesJson: case AssistRulesJson:
as.configCache.UpdateAssistRules(serverIDStr, *config.(*model.AssistRules)) as.configCache.UpdateAssistRules(serverIDStr, config.(model.AssistRules))
case EventJson: case EventJson:
as.configCache.UpdateEvent(serverIDStr, *config.(*model.EventConfig)) as.configCache.UpdateEvent(serverIDStr, config.(model.EventConfig))
case EventRulesJson: case EventRulesJson:
as.configCache.UpdateEventRules(serverIDStr, *config.(*model.EventRules)) as.configCache.UpdateEventRules(serverIDStr, config.(model.EventRules))
case SettingsJson: case SettingsJson:
as.configCache.UpdateSettings(serverIDStr, *config.(*model.ServerSettings)) as.configCache.UpdateSettings(serverIDStr, config.(model.ServerSettings))
} }
logging.Debug("Successfully loaded and cached config for server ID: %s, file: %s", serverIDStr, configFile) logging.Debug("Successfully loaded and cached config for server ID: %s, file: %s", serverIDStr, configFile)
return config, nil return config, nil
} }
// GetConfigs
// Gets all configurations for a server, using cache when possible.
func (as *ConfigService) GetConfigs(ctx *fiber.Ctx) (*model.Configurations, error) { func (as *ConfigService) GetConfigs(ctx *fiber.Ctx) (*model.Configurations, error) {
serverID := ctx.Params("id") serverID := ctx.Params("id")
@@ -296,82 +389,33 @@ func (as *ConfigService) GetConfigs(ctx *fiber.Ctx) (*model.Configurations, erro
func (as *ConfigService) LoadConfigs(server *model.Server) (*model.Configurations, error) { func (as *ConfigService) LoadConfigs(server *model.Server) (*model.Configurations, error) {
serverIDStr := server.ID.String() serverIDStr := server.ID.String()
logging.Info("Loading configs for server ID: %s at path: %s", serverIDStr, server.GetConfigPath()) logging.Info("Loading configs for server ID: %s at path: %s", serverIDStr, server.GetConfigPath())
configs := &model.Configurations{}
// Load configuration settingsConf, err := as.getConfigFile(server, SettingsJson)
if cached, ok := as.configCache.GetConfiguration(serverIDStr); ok { if err != nil {
logging.Debug("Using cached configuration for server %s", serverIDStr) return nil, err
configs.Configuration = *cached
} else {
logging.Debug("Loading configuration from disk for server %s", serverIDStr)
config, err := mustDecode[model.Configuration](ConfigurationJson, server.GetConfigPath())
if err != nil {
logging.Error("Failed to load configuration for server %s: %v", serverIDStr, err)
return nil, fmt.Errorf("failed to load configuration: %v", err)
}
configs.Configuration = config
as.configCache.UpdateConfiguration(serverIDStr, config)
} }
eventRulesConf, err := as.getConfigFile(server, EventRulesJson)
// Load assist rules if err != nil {
if cached, ok := as.configCache.GetAssistRules(serverIDStr); ok { return nil, err
logging.Debug("Using cached assist rules for server %s", serverIDStr)
configs.AssistRules = *cached
} else {
logging.Debug("Loading assist rules from disk for server %s", serverIDStr)
rules, err := mustDecode[model.AssistRules](AssistRulesJson, server.GetConfigPath())
if err != nil {
logging.Error("Failed to load assist rules for server %s: %v", serverIDStr, err)
return nil, fmt.Errorf("failed to load assist rules: %v", err)
}
configs.AssistRules = rules
as.configCache.UpdateAssistRules(serverIDStr, rules)
} }
eventConf, err := as.getConfigFile(server, EventJson)
// Load event config if err != nil {
if cached, ok := as.configCache.GetEvent(serverIDStr); ok { return nil, err
logging.Debug("Using cached event config for server %s", serverIDStr)
configs.Event = *cached
} else {
logging.Debug("Loading event config from disk for server %s", serverIDStr)
event, err := mustDecode[model.EventConfig](EventJson, server.GetConfigPath())
if err != nil {
logging.Error("Failed to load event config for server %s: %v", serverIDStr, err)
return nil, fmt.Errorf("failed to load event config: %v", err)
}
configs.Event = event
logging.Debug("Updating event config for server %s with track: %s", serverIDStr, event.Track)
as.configCache.UpdateEvent(serverIDStr, event)
} }
assistRulesConf, err := as.getConfigFile(server, AssistRulesJson)
// Load event rules if err != nil {
if cached, ok := as.configCache.GetEventRules(serverIDStr); ok { return nil, err
logging.Debug("Using cached event rules for server %s", serverIDStr)
configs.EventRules = *cached
} else {
logging.Debug("Loading event rules from disk for server %s", serverIDStr)
rules, err := mustDecode[model.EventRules](EventRulesJson, server.GetConfigPath())
if err != nil {
logging.Error("Failed to load event rules for server %s: %v", serverIDStr, err)
return nil, fmt.Errorf("failed to load event rules: %v", err)
}
configs.EventRules = rules
as.configCache.UpdateEventRules(serverIDStr, rules)
} }
configurationConf, err := as.getConfigFile(server, ConfigurationJson)
// Load settings if err != nil {
if cached, ok := as.configCache.GetSettings(serverIDStr); ok { return nil, err
logging.Debug("Using cached settings for server %s", serverIDStr) }
configs.Settings = *cached configs := &model.Configurations{
} else { Settings: settingsConf.(model.ServerSettings),
logging.Debug("Loading settings from disk for server %s", serverIDStr) EventRules: eventRulesConf.(model.EventRules),
settings, err := mustDecode[model.ServerSettings](SettingsJson, server.GetConfigPath()) Event: eventConf.(model.EventConfig),
if err != nil { AssistRules: assistRulesConf.(model.AssistRules),
logging.Error("Failed to load settings for server %s: %v", serverIDStr, err) Configuration: configurationConf.(model.Configuration),
return nil, fmt.Errorf("failed to load settings: %v", err)
}
configs.Settings = settings
as.configCache.UpdateSettings(serverIDStr, settings)
} }
logging.Info("Successfully loaded all configs for server %s", serverIDStr) logging.Info("Successfully loaded all configs for server %s", serverIDStr)
@@ -396,9 +440,6 @@ func readFile(path string, configFile string) ([]byte, error) {
configPath := filepath.Join(path, configFile) configPath := filepath.Join(path, configFile)
oldData, err := os.ReadFile(configPath) oldData, err := os.ReadFile(configPath)
if err != nil { if err != nil {
if errors.Is(err, os.ErrNotExist) {
return nil, fmt.Errorf("config file %s does not exist at %s", configFile, configPath)
}
return nil, err return nil, err
} }
return oldData, nil return oldData, nil
@@ -475,9 +516,15 @@ func (as *ConfigService) GetConfiguration(server *model.Server) (*model.Configur
return &config, nil return &config, nil
} }
// SaveConfiguration saves the configuration for a server 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 {
// Convert config to map for UpdateConfig
configMap := make(map[string]interface{}) configMap := make(map[string]interface{})
configBytes, err := json.Marshal(config) configBytes, err := json.Marshal(config)
if err != nil { if err != nil {
@@ -487,7 +534,6 @@ func (as *ConfigService) SaveConfiguration(server *model.Server, config *model.C
return fmt.Errorf("failed to unmarshal configuration: %v", err) return fmt.Errorf("failed to unmarshal configuration: %v", err)
} }
// Update the configuration using the internal method _, _, err = as.updateConfigFiles(context.Background(), server, ConfigurationJson, &configMap, true)
_, err = as.updateConfigInternal(context.Background(), server.ID.String(), ConfigurationJson, &configMap, true)
return err return err
} }

View File

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

View File

@@ -0,0 +1,238 @@
package service
import (
"acc-server-manager/local/model"
"acc-server-manager/local/repository"
"acc-server-manager/local/utl/env"
"acc-server-manager/local/utl/logging"
"context"
"fmt"
"path/filepath"
"strconv"
"strings"
"github.com/docker/docker/api/types"
"github.com/docker/docker/api/types/container"
"github.com/docker/docker/api/types/filters"
dockerclient "github.com/docker/docker/client"
"github.com/docker/go-connections/nat"
"github.com/google/uuid"
)
type DockerRuntime struct {
client *dockerclient.Client
repo *repository.ServerRepository
}
func NewDockerRuntime(client *dockerclient.Client, repo *repository.ServerRepository) *DockerRuntime {
return &DockerRuntime{
client: client,
repo: repo,
}
}
func (r *DockerRuntime) Create(ctx context.Context, server *model.Server) error {
image := env.GetACCImage()
// ACC uses the same port number for both TCP (client connections) and UDP (car positions/ping).
portStr := strconv.Itoa(server.Port)
natTCP, err := nat.NewPort("tcp", portStr)
if err != nil {
return fmt.Errorf("invalid port %d: %v", server.Port, err)
}
natUDP, err := nat.NewPort("udp", portStr)
if err != nil {
return fmt.Errorf("invalid port %d: %v", server.Port, err)
}
portBindings := nat.PortMap{
natTCP: []nat.PortBinding{{HostPort: portStr}},
natUDP: []nat.PortBinding{{HostPort: portStr}},
}
exposedPorts := nat.PortSet{
natTCP: struct{}{},
natUDP: struct{}{},
}
containerBase := env.GetACCServersPath()
hostBase := env.GetACCServersHostPath()
serverSubPath := strings.TrimPrefix(server.GetServerPath(), containerBase)
hostServerPath := filepath.Join(hostBase, serverSubPath)
binds := []string{
fmt.Sprintf("%s:/acc/game:rw", hostServerPath),
}
resp, err := r.client.ContainerCreate(ctx, &container.Config{
Image: image,
ExposedPorts: exposedPorts,
Env: []string{
fmt.Sprintf("SERVER_PORT=%d", server.Port),
},
}, &container.HostConfig{
Binds: binds,
PortBindings: portBindings,
RestartPolicy: container.RestartPolicy{Name: "unless-stopped"},
// NET_RAW is required for tcpdump packet capture in debug mode (DEBUG_MODE=1).
CapAdd: []string{"NET_RAW"},
}, nil, nil, server.ServiceName)
if err != nil {
return fmt.Errorf("failed to create container %s: %v", server.ServiceName, err)
}
server.ContainerID = resp.ID
logging.Info("Created Docker container %s (ID: %s) for server %s", server.ServiceName, resp.ID[:12], server.ID)
return nil
}
func (r *DockerRuntime) Delete(ctx context.Context, serverID uuid.UUID) error {
containerID, err := r.resolveContainerID(ctx, serverID)
if err != nil {
return err
}
if containerID == "" {
return nil
}
err = r.client.ContainerRemove(ctx, containerID, types.ContainerRemoveOptions{
Force: true,
RemoveVolumes: false,
})
if err != nil {
return fmt.Errorf("failed to remove container %s: %v", containerID[:12], err)
}
logging.Info("Removed Docker container %s for server %s", containerID[:12], serverID)
return nil
}
func (r *DockerRuntime) Start(ctx context.Context, serverID uuid.UUID) (string, error) {
server, err := r.repo.GetByID(ctx, serverID)
if err != nil {
return "", fmt.Errorf("failed to look up server %s: %v", serverID, err)
}
if server == nil {
return "", fmt.Errorf("server %s not found", serverID)
}
containerID, err := r.ensureContainer(ctx, server)
if err != nil {
return "", err
}
if err := r.client.ContainerStart(ctx, containerID, types.ContainerStartOptions{}); err != nil {
return "", fmt.Errorf("failed to start container: %v", err)
}
return model.StatusRunning.String(), nil
}
func (r *DockerRuntime) Stop(ctx context.Context, serverID uuid.UUID) (string, error) {
containerID, err := r.resolveContainerID(ctx, serverID)
if err != nil {
return "", err
}
if err := r.client.ContainerStop(ctx, containerID, container.StopOptions{}); err != nil {
return "", fmt.Errorf("failed to stop container: %v", err)
}
return model.StatusStopped.String(), nil
}
func (r *DockerRuntime) Restart(ctx context.Context, serverID uuid.UUID) (string, error) {
server, err := r.repo.GetByID(ctx, serverID)
if err != nil {
return "", fmt.Errorf("failed to look up server %s: %v", serverID, err)
}
if server == nil {
return "", fmt.Errorf("server %s not found", serverID)
}
containerID, err := r.ensureContainer(ctx, server)
if err != nil {
return "", err
}
if err := r.client.ContainerRestart(ctx, containerID, container.StopOptions{}); err != nil {
return "", fmt.Errorf("failed to restart container: %v", err)
}
return model.StatusRunning.String(), nil
}
// ensureContainer returns the container ID for a server, recreating the container
// from the server's current DB record if it no longer exists in Docker.
func (r *DockerRuntime) ensureContainer(ctx context.Context, server *model.Server) (string, error) {
if server.ContainerID != "" {
if _, err := r.client.ContainerInspect(ctx, server.ContainerID); err == nil {
return server.ContainerID, nil
}
logging.Info("Container %s for server %s no longer exists, recreating", server.ContainerID[:min(12, len(server.ContainerID))], server.ID)
}
if err := r.Create(ctx, server); err != nil {
return "", fmt.Errorf("failed to recreate container: %v", err)
}
if err := r.repo.Update(ctx, server); err != nil {
return "", fmt.Errorf("container recreated but failed to persist container ID: %v", err)
}
return server.ContainerID, nil
}
func (r *DockerRuntime) Status(ctx context.Context, serverID uuid.UUID) (string, error) {
server, err := r.repo.GetByID(ctx, serverID)
if err != nil || server == nil {
return model.StatusUnknown.String(), err
}
containerID := server.ContainerID
if containerID == "" {
containerID, err = r.findContainerByName(ctx, server.ServiceName)
if err != nil || containerID == "" {
return model.StatusStopped.String(), nil
}
}
info, err := r.client.ContainerInspect(ctx, containerID)
if err != nil {
return model.StatusUnknown.String(), nil
}
return dockerStateToStatus(info.State.Status), nil
}
func (r *DockerRuntime) resolveContainerID(ctx context.Context, serverID uuid.UUID) (string, error) {
server, err := r.repo.GetByID(ctx, serverID)
if err != nil {
return "", fmt.Errorf("failed to look up server %s: %v", serverID, err)
}
if server == nil {
return "", fmt.Errorf("server %s not found", serverID)
}
return server.ContainerID, nil
}
func (r *DockerRuntime) findContainerByName(ctx context.Context, name string) (string, error) {
containers, err := r.client.ContainerList(ctx, types.ContainerListOptions{
All: true,
Filters: filters.NewArgs(filters.Arg("name", "/"+name)),
})
if err != nil {
return "", err
}
if len(containers) == 0 {
return "", nil
}
return containers[0].ID, nil
}
func dockerStateToStatus(state string) string {
switch state {
case "running":
return model.StatusRunning.String()
case "exited", "dead":
return model.StatusStopped.String()
case "restarting":
return model.StatusRestarting.String()
case "paused":
return model.StatusStopped.String()
default:
return model.StatusUnknown.String()
}
}

View File

@@ -0,0 +1,38 @@
package service
import (
"acc-server-manager/local/model"
"acc-server-manager/local/utl/tracking"
"context"
"path/filepath"
"sync"
"github.com/google/uuid"
)
type FileLogStreamer struct {
tailers sync.Map // uuid.UUID → *tracking.LogTailer
}
func NewFileLogStreamer() *FileLogStreamer {
return &FileLogStreamer{}
}
func (s *FileLogStreamer) Start(_ context.Context, server *model.Server, handleLine func(string)) error {
if _, exists := s.tailers.Load(server.ID); exists {
return nil
}
logPath := filepath.Join(server.GetLogPath(), "server.log")
tailer := tracking.NewLogTailer(logPath, handleLine)
s.tailers.Store(server.ID, tailer)
go tailer.Start()
return nil
}
func (s *FileLogStreamer) Stop(serverID uuid.UUID) {
if v, exists := s.tailers.LoadAndDelete(serverID); exists {
v.(*tracking.LogTailer).Stop()
}
}

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

@@ -96,11 +96,9 @@ func (s *FirewallService) DeleteServerRules(serverName string, tcpPorts, udpPort
} }
func (s *FirewallService) UpdateServerRules(serverName string, tcpPorts, udpPorts []int) error { func (s *FirewallService) UpdateServerRules(serverName string, tcpPorts, udpPorts []int) error {
// First delete existing rules
if err := s.DeleteServerRules(serverName, tcpPorts, udpPorts); err != nil { if err := s.DeleteServerRules(serverName, tcpPorts, udpPorts); err != nil {
return err return err
} }
// Then create new rules
return s.CreateServerRules(serverName, tcpPorts, udpPorts) return s.CreateServerRules(serverName, tcpPorts, udpPorts)
} }

59
local/service/leaderboard.go Executable file
View File

@@ -0,0 +1,59 @@
package service
import (
"acc-server-manager/local/model"
"acc-server-manager/local/repository"
"acc-server-manager/local/utl/logging"
"context"
"fmt"
"github.com/google/uuid"
)
type LeaderboardService struct {
repo *repository.LeaderboardRepository
activityLog *ActivityLogService
}
func NewLeaderboardService(repo *repository.LeaderboardRepository, activityLog *ActivityLogService) *LeaderboardService {
return &LeaderboardService{repo: repo, activityLog: activityLog}
}
// Get returns the leaderboard for a server.
func (s *LeaderboardService) Get(ctx context.Context, serverID uuid.UUID) (*model.Leaderboard, error) {
return s.repo.GetOrCreateByServerID(ctx, serverID)
}
// Update replaces the leaderboard for a server.
func (s *LeaderboardService) Update(ctx context.Context, serverID uuid.UUID, lb *model.Leaderboard, actorID, actorUsername string) (*model.Leaderboard, error) {
if lb == nil {
return nil, fmt.Errorf("input is required")
}
for i := range lb.Drivers {
lb.Drivers[i].Position = i
}
for i := range lb.Races {
lb.Races[i].Position = i
}
result, err := s.repo.FullReplace(ctx, serverID, lb)
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

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

@@ -12,13 +12,11 @@ import (
"github.com/google/uuid" "github.com/google/uuid"
) )
// CacheInvalidator interface for cache invalidation
type CacheInvalidator interface { type CacheInvalidator interface {
InvalidateUserPermissions(userID string) InvalidateUserPermissions(userID string)
InvalidateAllUserPermissions() InvalidateAllUserPermissions()
} }
// MembershipService provides business logic for membership-related operations.
type MembershipService struct { type MembershipService struct {
repo *repository.MembershipRepository repo *repository.MembershipRepository
cacheInvalidator CacheInvalidator cacheInvalidator CacheInvalidator
@@ -26,29 +24,25 @@ type MembershipService struct {
openJwtHandler *jwt.OpenJWTHandler openJwtHandler *jwt.OpenJWTHandler
} }
// NewMembershipService creates a new MembershipService.
func NewMembershipService(repo *repository.MembershipRepository, jwtHandler *jwt.JWTHandler, openJwtHandler *jwt.OpenJWTHandler) *MembershipService { func NewMembershipService(repo *repository.MembershipRepository, jwtHandler *jwt.JWTHandler, openJwtHandler *jwt.OpenJWTHandler) *MembershipService {
return &MembershipService{ return &MembershipService{
repo: repo, repo: repo,
cacheInvalidator: nil, // Will be set later via SetCacheInvalidator cacheInvalidator: nil,
jwtHandler: jwtHandler, jwtHandler: jwtHandler,
openJwtHandler: openJwtHandler, openJwtHandler: openJwtHandler,
} }
} }
// SetCacheInvalidator sets the cache invalidator after service initialization
func (s *MembershipService) SetCacheInvalidator(invalidator CacheInvalidator) { func (s *MembershipService) SetCacheInvalidator(invalidator CacheInvalidator) {
s.cacheInvalidator = invalidator s.cacheInvalidator = invalidator
} }
// Login authenticates a user and returns a JWT.
func (s *MembershipService) HandleLogin(ctx context.Context, username, password string) (*model.User, error) { func (s *MembershipService) HandleLogin(ctx context.Context, username, password string) (*model.User, error) {
user, err := s.repo.FindUserByUsername(ctx, username) user, err := s.repo.FindUserByUsername(ctx, username)
if err != nil { if err != nil {
return nil, errors.New("invalid credentials") return nil, errors.New("invalid credentials")
} }
// Use secure password verification with constant-time comparison
if err := user.VerifyPassword(password); err != nil { if err := user.VerifyPassword(password); err != nil {
return nil, errors.New("invalid credentials") return nil, errors.New("invalid credentials")
} }
@@ -56,7 +50,6 @@ func (s *MembershipService) HandleLogin(ctx context.Context, username, password
return user, nil return user, nil
} }
// Login authenticates a user and returns a JWT.
func (s *MembershipService) Login(ctx context.Context, username, password string) (string, error) { func (s *MembershipService) Login(ctx context.Context, username, password string) (string, error) {
user, err := s.HandleLogin(ctx, username, password) user, err := s.HandleLogin(ctx, username, password)
if err != nil { if err != nil {
@@ -70,7 +63,6 @@ func (s *MembershipService) GenerateOpenToken(ctx context.Context, userId string
return s.openJwtHandler.GenerateToken(userId) return s.openJwtHandler.GenerateToken(userId)
} }
// CreateUser creates a new user.
func (s *MembershipService) CreateUser(ctx context.Context, username, password, roleName string) (*model.User, error) { func (s *MembershipService) CreateUser(ctx context.Context, username, password, roleName string) (*model.User, error) {
role, err := s.repo.FindRoleByName(ctx, roleName) role, err := s.repo.FindRoleByName(ctx, roleName)
@@ -94,43 +86,35 @@ func (s *MembershipService) CreateUser(ctx context.Context, username, password,
return user, nil return user, nil
} }
// ListUsers retrieves all users.
func (s *MembershipService) ListUsers(ctx context.Context) ([]*model.User, error) { func (s *MembershipService) ListUsers(ctx context.Context) ([]*model.User, error) {
return s.repo.ListUsers(ctx) return s.repo.ListUsers(ctx)
} }
// GetUser retrieves a single user by ID.
func (s *MembershipService) GetUser(ctx context.Context, userID uuid.UUID) (*model.User, error) { func (s *MembershipService) GetUser(ctx context.Context, userID uuid.UUID) (*model.User, error) {
return s.repo.FindUserByID(ctx, userID) return s.repo.FindUserByID(ctx, userID)
} }
// GetUserWithPermissions retrieves a single user by ID with their role and permissions.
func (s *MembershipService) GetUserWithPermissions(ctx context.Context, userID string) (*model.User, error) { func (s *MembershipService) GetUserWithPermissions(ctx context.Context, userID string) (*model.User, error) {
return s.repo.FindUserByIDWithPermissions(ctx, userID) return s.repo.FindUserByIDWithPermissions(ctx, userID)
} }
// UpdateUserRequest defines the request body for updating a user.
type UpdateUserRequest struct { type UpdateUserRequest struct {
Username *string `json:"username"` Username *string `json:"username"`
Password *string `json:"password"` Password *string `json:"password"`
RoleID *uuid.UUID `json:"roleId"` RoleID *uuid.UUID `json:"roleId"`
} }
// DeleteUser deletes a user with validation to prevent Super Admin deletion.
func (s *MembershipService) DeleteUser(ctx context.Context, userID uuid.UUID) error { func (s *MembershipService) DeleteUser(ctx context.Context, userID uuid.UUID) error {
// Get user with role information
user, err := s.repo.FindUserByID(ctx, userID) user, err := s.repo.FindUserByID(ctx, userID)
if err != nil { if err != nil {
return errors.New("user not found") return errors.New("user not found")
} }
// Get role to check if it's Super Admin
role, err := s.repo.FindRoleByID(ctx, user.RoleID) role, err := s.repo.FindRoleByID(ctx, user.RoleID)
if err != nil { if err != nil {
return errors.New("user role not found") return errors.New("user role not found")
} }
// Prevent deletion of Super Admin users
if role.Name == "Super Admin" { if role.Name == "Super Admin" {
return errors.New("cannot delete Super Admin user") return errors.New("cannot delete Super Admin user")
} }
@@ -140,7 +124,6 @@ func (s *MembershipService) DeleteUser(ctx context.Context, userID uuid.UUID) er
return err return err
} }
// Invalidate cache for deleted user
if s.cacheInvalidator != nil { if s.cacheInvalidator != nil {
s.cacheInvalidator.InvalidateUserPermissions(userID.String()) s.cacheInvalidator.InvalidateUserPermissions(userID.String())
} }
@@ -149,7 +132,6 @@ func (s *MembershipService) DeleteUser(ctx context.Context, userID uuid.UUID) er
return nil return nil
} }
// UpdateUser updates a user's details.
func (s *MembershipService) UpdateUser(ctx context.Context, userID uuid.UUID, req UpdateUserRequest) (*model.User, error) { func (s *MembershipService) UpdateUser(ctx context.Context, userID uuid.UUID, req UpdateUserRequest) (*model.User, error) {
user, err := s.repo.FindUserByID(ctx, userID) user, err := s.repo.FindUserByID(ctx, userID)
if err != nil { if err != nil {
@@ -161,12 +143,10 @@ func (s *MembershipService) UpdateUser(ctx context.Context, userID uuid.UUID, re
} }
if req.Password != nil && *req.Password != "" { if req.Password != nil && *req.Password != "" {
// Password will be automatically hashed in BeforeUpdate hook
user.Password = *req.Password user.Password = *req.Password
} }
if req.RoleID != nil { if req.RoleID != nil {
// Check if role exists
_, err := s.repo.FindRoleByID(ctx, *req.RoleID) _, err := s.repo.FindRoleByID(ctx, *req.RoleID)
if err != nil { if err != nil {
return nil, errors.New("role not found") return nil, errors.New("role not found")
@@ -178,7 +158,6 @@ func (s *MembershipService) UpdateUser(ctx context.Context, userID uuid.UUID, re
return nil, err return nil, err
} }
// Invalidate cache if role was changed
if req.RoleID != nil && s.cacheInvalidator != nil { if req.RoleID != nil && s.cacheInvalidator != nil {
s.cacheInvalidator.InvalidateUserPermissions(userID.String()) s.cacheInvalidator.InvalidateUserPermissions(userID.String())
} }
@@ -187,14 +166,12 @@ func (s *MembershipService) UpdateUser(ctx context.Context, userID uuid.UUID, re
return user, nil return user, nil
} }
// HasPermission checks if a user has a specific permission.
func (s *MembershipService) HasPermission(ctx context.Context, userID string, permissionName string) (bool, error) { func (s *MembershipService) HasPermission(ctx context.Context, userID string, permissionName string) (bool, error) {
user, err := s.repo.FindUserByIDWithPermissions(ctx, userID) user, err := s.repo.FindUserByIDWithPermissions(ctx, userID)
if err != nil { if err != nil {
return false, err return false, err
} }
// Super admin and Admin have all permissions
if user.Role.Name == "Super Admin" || user.Role.Name == "Admin" { if user.Role.Name == "Super Admin" || user.Role.Name == "Admin" {
return true, nil return true, nil
} }
@@ -208,15 +185,13 @@ func (s *MembershipService) HasPermission(ctx context.Context, userID string, pe
return false, nil return false, nil
} }
// SetupInitialData creates the initial roles and permissions.
func (s *MembershipService) SetupInitialData(ctx context.Context) error { func (s *MembershipService) SetupInitialData(ctx context.Context) error {
// Define all permissions
permissions := model.AllPermissions() permissions := model.AllPermissions()
createdPermissions := make([]model.Permission, 0) createdPermissions := make([]model.Permission, 0)
for _, pName := range permissions { for _, pName := range permissions {
perm, err := s.repo.FindPermissionByName(ctx, pName) perm, err := s.repo.FindPermissionByName(ctx, pName)
if err != nil { // Assuming error means not found if err != nil {
perm = &model.Permission{Name: pName} perm = &model.Permission{Name: pName}
if err := s.repo.CreatePermission(ctx, perm); err != nil { if err := s.repo.CreatePermission(ctx, perm); err != nil {
return err return err
@@ -225,7 +200,6 @@ func (s *MembershipService) SetupInitialData(ctx context.Context) error {
createdPermissions = append(createdPermissions, *perm) createdPermissions = append(createdPermissions, *perm)
} }
// Create Super Admin role with all permissions
superAdminRole, err := s.repo.FindRoleByName(ctx, "Super Admin") superAdminRole, err := s.repo.FindRoleByName(ctx, "Super Admin")
if err != nil { if err != nil {
superAdminRole = &model.Role{Name: "Super Admin"} superAdminRole = &model.Role{Name: "Super Admin"}
@@ -237,7 +211,6 @@ func (s *MembershipService) SetupInitialData(ctx context.Context) error {
return err return err
} }
// Create Admin role with same permissions as Super Admin
adminRole, err := s.repo.FindRoleByName(ctx, "Admin") adminRole, err := s.repo.FindRoleByName(ctx, "Admin")
if err != nil { if err != nil {
adminRole = &model.Role{Name: "Admin"} adminRole = &model.Role{Name: "Admin"}
@@ -249,7 +222,6 @@ func (s *MembershipService) SetupInitialData(ctx context.Context) error {
return err return err
} }
// Create Manager role with limited permissions (excluding membership, role, user, server create/delete)
managerRole, err := s.repo.FindRoleByName(ctx, "Manager") managerRole, err := s.repo.FindRoleByName(ctx, "Manager")
if err != nil { if err != nil {
managerRole = &model.Role{Name: "Manager"} managerRole = &model.Role{Name: "Manager"}
@@ -258,7 +230,6 @@ func (s *MembershipService) SetupInitialData(ctx context.Context) error {
} }
} }
// Define manager permissions (limited set)
managerPermissionNames := []string{ managerPermissionNames := []string{
model.ServerView, model.ServerView,
model.ServerUpdate, model.ServerUpdate,
@@ -282,16 +253,14 @@ func (s *MembershipService) SetupInitialData(ctx context.Context) error {
return err return err
} }
// Invalidate all caches after role setup changes
if s.cacheInvalidator != nil { if s.cacheInvalidator != nil {
s.cacheInvalidator.InvalidateAllUserPermissions() s.cacheInvalidator.InvalidateAllUserPermissions()
} }
// Create a default admin user if one doesn't exist
_, err = s.repo.FindUserByUsername(ctx, "admin") _, err = s.repo.FindUserByUsername(ctx, "admin")
if err != nil { if err != nil {
logging.Debug("Creating default admin user") logging.Debug("Creating default admin user")
_, err = s.CreateUser(ctx, "admin", os.Getenv("PASSWORD"), "Super Admin") // Default password, should be changed _, err = s.CreateUser(ctx, "admin", os.Getenv("PASSWORD"), "Super Admin")
if err != nil { if err != nil {
return err return err
} }
@@ -300,7 +269,6 @@ func (s *MembershipService) SetupInitialData(ctx context.Context) error {
return nil return nil
} }
// GetAllRoles retrieves all roles for dropdown selection.
func (s *MembershipService) GetAllRoles(ctx context.Context) ([]*model.Role, error) { func (s *MembershipService) GetAllRoles(ctx context.Context) ([]*model.Role, error) {
return s.repo.ListRoles(ctx) return s.repo.ListRoles(ctx)
} }

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 }

448
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,30 +14,31 @@ 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"
) )
const ( const (
DefaultStartPort = 9600 DefaultStartPort = 9600
RequiredPortCount = 1 // Update this if ACC needs more ports RequiredPortCount = 1
) )
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
instances sync.Map // Track instances per server logStreamer platform.LogStreamer
lastInsertTimes sync.Map // Track last insert time per server portPool *network.PortPoolManager
debouncers sync.Map // Track debounce timers per server webSocketService *WebSocketService
logTailers sync.Map // Track log tailers per server instances sync.Map // uuid.UUID → *tracking.AccServerInstance
sessionIDs sync.Map // Track current session ID per server lastInsertTimes sync.Map // uuid.UUID → time.Time
debouncers sync.Map // uuid.UUID → *pendingState
sessionIDs sync.Map // uuid.UUID → uuid.UUID
} }
type pendingState struct { type pendingState struct {
@@ -44,42 +47,40 @@ type pendingState struct {
} }
func (s *ServerService) ensureLogTailing(server *model.Server, instance *tracking.AccServerInstance) { func (s *ServerService) ensureLogTailing(server *model.Server, instance *tracking.AccServerInstance) {
// Check if we already have a tailer
if _, exists := s.logTailers.Load(server.ID); exists {
return
}
// Start tailing in a goroutine that handles file creation/deletion
go func() { go func() {
logPath := filepath.Join(server.GetLogPath(), "server.log") if err := s.logStreamer.Start(context.Background(), server, instance.HandleLogLine); err != nil {
tailer := tracking.NewLogTailer(logPath, instance.HandleLogLine) logging.Error("Failed to start log streaming for server %s: %v", server.ID, err)
s.logTailers.Store(server.ID, tailer) }
// Start tailing and automatically handle file changes
tailer.Start()
}() }()
} }
func NewServerService( func NewServerService(
repository *repository.ServerRepository, repository *repository.ServerRepository,
stateHistoryRepo *repository.StateHistoryRepository, stateHistoryRepo *repository.StateHistoryRepository,
activityLogRepo *repository.ActivityLogRepository,
apiService *ServiceControlService, apiService *ServiceControlService,
configService *ConfigService, configService *ConfigService,
steamService *SteamService, steamService *SteamService,
windowsService *WindowsService, runtime platform.ServerRuntime,
firewallService *FirewallService, firewall platform.FirewallManager,
logStreamer platform.LogStreamer,
portPool *network.PortPoolManager,
webSocketService *WebSocketService,
) *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,
} }
// Initialize server instances
servers, err := repository.GetAll(context.Background(), &model.ServerFilter{}) servers, err := repository.GetAll(context.Background(), &model.ServerFilter{})
if err != nil { if err != nil {
logging.Error("Failed to get servers: %v", err) logging.Error("Failed to get servers: %v", err)
@@ -87,7 +88,6 @@ func NewServerService(
} }
for i := range *servers { for i := range *servers {
// Initialize instance regardless of status
logging.Info("Starting server runtime for server ID: %d", (*servers)[i].ID) logging.Info("Starting server runtime for server ID: %d", (*servers)[i].ID)
service.StartAccServerRuntime(&(*servers)[i]) service.StartAccServerRuntime(&(*servers)[i])
} }
@@ -96,7 +96,7 @@ func NewServerService(
} }
func (s *ServerService) shouldInsertStateHistory(serverID uuid.UUID) bool { func (s *ServerService) shouldInsertStateHistory(serverID uuid.UUID) bool {
insertInterval := 5 * time.Minute // Configure this as needed insertInterval := 5 * time.Minute
lastInsertInterface, exists := s.lastInsertTimes.Load(serverID) lastInsertInterface, exists := s.lastInsertTimes.Load(serverID)
if !exists { if !exists {
@@ -119,16 +119,15 @@ func (s *ServerService) getNextSessionID(serverID uuid.UUID) uuid.UUID {
lastID, err := s.stateHistoryRepo.GetLastSessionID(context.Background(), serverID) lastID, err := s.stateHistoryRepo.GetLastSessionID(context.Background(), serverID)
if err != nil { if err != nil {
logging.Error("Failed to get last session ID for server %s: %v", serverID, err) logging.Error("Failed to get last session ID for server %s: %v", serverID, err)
return uuid.New() // Return new UUID as fallback return uuid.New()
} }
if lastID == uuid.Nil { if lastID == uuid.Nil {
return uuid.New() // Return new UUID if no previous session return uuid.New()
} }
return uuid.New() // Always generate new UUID for each session return uuid.New()
} }
func (s *ServerService) insertStateHistory(serverID uuid.UUID, state *model.ServerState) { func (s *ServerService) insertStateHistory(serverID uuid.UUID, state *model.ServerState) {
// Get or create session ID when session changes
currentSessionInterface, exists := s.instances.Load(serverID) currentSessionInterface, exists := s.instances.Load(serverID)
var sessionID uuid.UUID var sessionID uuid.UUID
if !exists { if !exists {
@@ -159,8 +158,7 @@ func (s *ServerService) insertStateHistory(serverID uuid.UUID, state *model.Serv
}) })
} }
func (s *ServerService) updateSessionDuration(server *model.Server, sessionType string) { func (s *ServerService) updateSessionDuration(server *model.Server, sessionType model.TrackSession) {
// Get configs using helper methods
event, err := s.configService.GetEventConfig(server) event, err := s.configService.GetEventConfig(server)
if err != nil { if err != nil {
event = &model.EventConfig{} event = &model.EventConfig{}
@@ -178,9 +176,7 @@ func (s *ServerService) updateSessionDuration(server *model.Server, sessionType
serverInstance.State.Track = event.Track serverInstance.State.Track = event.Track
serverInstance.State.MaxConnections = configuration.MaxConnections.ToInt() serverInstance.State.MaxConnections = configuration.MaxConnections.ToInt()
// Check if session type has changed
if serverInstance.State.Session != sessionType { if serverInstance.State.Session != sessionType {
// Get new session ID for the new session
sessionID := s.getNextSessionID(server.ID) sessionID := s.getNextSessionID(server.ID)
s.sessionIDs.Store(server.ID, sessionID) s.sessionIDs.Store(server.ID, sessionID)
} }
@@ -201,32 +197,37 @@ func (s *ServerService) updateSessionDuration(server *model.Server, sessionType
} }
func (s *ServerService) GenerateServerPath(server *model.Server) { func (s *ServerService) GenerateServerPath(server *model.Server) {
// Get the base steamcmd path from environment variable server.GenerateUUID()
steamCMDPath := env.GetSteamCMDDirPath() if server.ServiceName == "" {
server.Path = server.GenerateServerPath(steamCMDPath) server.ServiceName = server.GenerateServiceName()
}
if env.IsDockerPlatform() {
server.Path = filepath.Join(env.GetACCServersPath(), server.ServiceName)
server.FromSteamCMD = true
} else {
steamCMDPath := env.GetSteamCMDDirPath()
server.Path = server.GenerateServerPath(steamCMDPath)
server.FromSteamCMD = true
}
} }
func (s *ServerService) handleStateChange(server *model.Server, state *model.ServerState) { func (s *ServerService) handleStateChange(server *model.Server, state *model.ServerState) {
// Update session duration when session changes
s.updateSessionDuration(server, state.Session) s.updateSessionDuration(server, state.Session)
// Invalidate status cache when server state changes
s.apiService.statusCache.InvalidateStatus(server.ServiceName) s.apiService.statusCache.InvalidateStatus(server.ServiceName)
// Cancel existing timer if any
if debouncer, exists := s.debouncers.Load(server.ID); exists { if debouncer, exists := s.debouncers.Load(server.ID); exists {
pending := debouncer.(*pendingState) pending := debouncer.(*pendingState)
pending.timer.Stop() pending.timer.Stop()
} }
// Create new timer
timer := time.NewTimer(5 * time.Minute) timer := time.NewTimer(5 * time.Minute)
s.debouncers.Store(server.ID, &pendingState{ s.debouncers.Store(server.ID, &pendingState{
timer: timer, timer: timer,
state: state, state: state,
}) })
// Start goroutine to handle the delayed insert
go func() { go func() {
<-timer.C <-timer.C
if debouncer, exists := s.debouncers.Load(server.ID); exists { if debouncer, exists := s.debouncers.Load(server.ID); exists {
@@ -236,14 +237,12 @@ func (s *ServerService) handleStateChange(server *model.Server, state *model.Ser
} }
}() }()
// If enough time has passed since last insert, insert immediately
if s.shouldInsertStateHistory(server.ID) { if s.shouldInsertStateHistory(server.ID) {
s.insertStateHistory(server.ID, state) s.insertStateHistory(server.ID, state)
} }
} }
func (s *ServerService) StartAccServerRuntime(server *model.Server) { func (s *ServerService) StartAccServerRuntime(server *model.Server) {
// Get or create instance
instanceInterface, exists := s.instances.Load(server.ID) instanceInterface, exists := s.instances.Load(server.ID)
var instance *tracking.AccServerInstance var instance *tracking.AccServerInstance
if !exists { if !exists {
@@ -255,23 +254,14 @@ func (s *ServerService) StartAccServerRuntime(server *model.Server) {
instance = instanceInterface.(*tracking.AccServerInstance) instance = instanceInterface.(*tracking.AccServerInstance)
} }
// Invalidate config cache for this server before loading new configs
serverIDStr := server.ID.String() serverIDStr := server.ID.String()
s.configService.configCache.InvalidateServerCache(serverIDStr) s.configService.configCache.InvalidateServerCache(serverIDStr)
s.updateSessionDuration(server, instance.State.Session) s.updateSessionDuration(server, instance.State.Session)
// Ensure log tailing is running (regardless of server status)
s.ensureLogTailing(server, instance) s.ensureLogTailing(server, instance)
} }
// GetAll
// Gets All rows from Server table.
//
// Args:
// 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 {
@@ -279,6 +269,16 @@ func (s *ServerService) GetAll(ctx *fiber.Ctx, filter *model.ServerFilter) (*[]m
return nil, err return nil, err
} }
// Collect server IDs for a single last-activity lookup.
serverIDs := make([]uuid.UUID, len(*servers))
for i, srv := range *servers {
serverIDs[i] = srv.ID
}
lastActivities, laErr := s.activityLogRepo.GetLastActivityByServerIDs(ctx.UserContext(), serverIDs)
if laErr != nil {
logging.Error("Failed to get last activities for server list: %v", laErr)
}
for i := range *servers { for i := range *servers {
server := &(*servers)[i] server := &(*servers)[i]
status, err := s.apiService.GetCachedStatus(server.ServiceName) status, err := s.apiService.GetCachedStatus(server.ServiceName)
@@ -295,18 +295,16 @@ func (s *ServerService) GetAll(ctx *fiber.Ctx, filter *model.ServerFilter) (*[]m
server.State = serverInstance.State server.State = serverInstance.State
} }
} }
if lastActivities != nil {
if info, ok := lastActivities[server.ID]; ok {
server.LastActivity = info
}
}
} }
return servers, nil return servers, nil
} }
// GetById
// Gets rows by ID from Server table.
//
// Args:
// 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 {
@@ -314,7 +312,7 @@ func (as *ServerService) GetById(ctx *fiber.Ctx, serverID uuid.UUID) (*model.Ser
} }
status, err := as.apiService.GetCachedStatus(server.ServiceName) status, err := as.apiService.GetCachedStatus(server.ServiceName)
if err != nil { if err != nil {
logging.Error(err.Error()) logging.Error("Failed to get cached status: %v", err)
} }
server.Status = model.ParseServiceStatus(status) server.Status = model.ParseServiceStatus(status)
instance, ok := as.instances.Load(server.ID) instance, ok := as.instances.Load(server.ID)
@@ -330,184 +328,255 @@ func (as *ServerService) GetById(ctx *fiber.Ctx, serverID uuid.UUID) (*model.Ser
return server, nil return server, nil
} }
func (s *ServerService) CreateServer(ctx *fiber.Ctx, server *model.Server) error { func (s *ServerService) CreateServerAsync(ctx *fiber.Ctx, server *model.Server) error {
// Validate basic server configuration logging.Info("create server start")
if err := server.Validate(); err != nil { if err := server.Validate(); err != nil {
logging.Info("create server validation failed")
return err return err
} }
// Install server using SteamCMD s.GenerateServerPath(server)
if err := s.steamService.InstallServer(ctx.UserContext(), server.GetServerPath(), &server.ID); err != nil {
return fmt.Errorf("failed to install server: %v", err)
}
// Create Windows service with correct paths bgCtx := context.Background()
execPath := filepath.Join(server.GetServerPath(), "accServer.exe")
serverWorkingDir := filepath.Join(server.GetServerPath(), "server")
if err := s.windowsService.CreateService(ctx.UserContext(), server.ServiceName, execPath, serverWorkingDir, nil); err != nil {
// Cleanup on failure
s.steamService.UninstallServer(server.Path)
return fmt.Errorf("failed to create Windows service: %v", err)
}
s.configureFirewall(server) go func() {
ports, err := network.FindAvailablePortRange(DefaultStartPort, RequiredPortCount) logging.Info("create server start background")
if err != nil { if err := s.createServerBackground(bgCtx, server); err != nil {
return fmt.Errorf("failed to find available ports: %v", err) logging.Error("Async server creation failed for server %s: %v", server.ID, err)
} s.webSocketService.BroadcastError(server.ID, "Server creation failed", err.Error())
s.webSocketService.BroadcastComplete(server.ID, false, fmt.Sprintf("Server creation failed: %v", err))
// Use the first port for both TCP and UDP }
serverPort := ports[0] }()
tcpPorts := []int{serverPort}
udpPorts := []int{serverPort}
if err := s.firewallService.CreateServerRules(server.ServiceName, tcpPorts, udpPorts); err != nil {
// Cleanup on failure
s.windowsService.DeleteService(ctx.UserContext(), server.ServiceName)
s.steamService.UninstallServer(server.Path)
return fmt.Errorf("failed to create firewall rules: %v", err)
}
// Update server configuration with the allocated port
if err := s.updateServerPort(server, serverPort); err != nil {
return fmt.Errorf("failed to update server configuration: %v", err)
}
// Insert server into database
if err := s.repository.Insert(ctx.UserContext(), server); err != nil {
// Cleanup on failure
s.firewallService.DeleteServerRules(server.ServiceName, tcpPorts, udpPorts)
s.windowsService.DeleteService(ctx.UserContext(), server.ServiceName)
s.steamService.UninstallServer(server.Path)
return fmt.Errorf("failed to insert server into database: %v", err)
}
// Initialize server runtime
s.StartAccServerRuntime(server)
return nil return nil
} }
type createServerStep struct {
stepType model.ServerCreationStep
important bool
callback func() (string, error)
description string
}
func (s *ServerService) createServerBackground(ctx context.Context, server *model.Server) error {
var serverPort int
var tcpPorts, udpPorts []int
steps := []createServerStep{
{
stepType: model.StepValidation,
important: true,
description: "Server configuration validated successfully",
callback: func() (string, error) {
if err := server.Validate(); err != nil {
return "", fmt.Errorf("validation failed: %v", err)
}
return "Server configuration validated successfully", nil
},
},
{
stepType: model.StepDirectoryCreation,
important: true,
description: "Server directories prepared",
callback: func() (string, error) {
return "Server directories prepared", nil
},
},
{
stepType: model.StepSteamDownload,
important: true,
description: "Server files downloaded successfully",
callback: func() (string, error) {
if err := s.steamService.InstallServerWithWebSocket(ctx, server.Path, &server.ID, s.webSocketService); err != nil {
return "", fmt.Errorf("failed to install server: %v", err)
}
return "Server files downloaded successfully", nil
},
},
{
stepType: model.StepConfigGeneration,
important: true,
description: "",
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)
if err != nil {
return "", fmt.Errorf("failed to find available ports: %v", err)
}
port = ports[0]
}
serverPort = port
server.Port = port
if err := s.updateServerPort(server, serverPort); err != nil {
return "", fmt.Errorf("failed to update server configuration: %v", err)
}
return fmt.Sprintf("Server configuration generated (Port: %d)", serverPort), nil
},
},
{
stepType: model.StepServiceCreation,
important: true,
description: "",
callback: func() (string, error) {
server.Platform = env.GetPlatform()
if err := s.runtime.Create(ctx, server); err != nil {
return "", fmt.Errorf("failed to create server instance: %v", err)
}
return fmt.Sprintf("Server instance '%s' created successfully", server.ServiceName), nil
},
},
{
stepType: model.StepFirewallRules,
important: false,
description: "",
callback: func() (string, error) {
tcpPorts = []int{serverPort}
udpPorts = []int{serverPort}
if err := s.firewall.CreateServerRules(server.ServiceName, tcpPorts, udpPorts); err != nil {
return "", fmt.Errorf("failed to create firewall rules: %v", err)
}
return fmt.Sprintf("Firewall rules configured for port %d", serverPort), nil
},
},
{
stepType: model.StepDatabaseSave,
important: true,
description: "Server saved to database successfully",
callback: func() (string, error) {
if err := s.repository.Insert(ctx, server); err != nil {
return "", fmt.Errorf("failed to insert server into database: %v", err)
}
return "Server saved to database successfully", nil
},
},
}
for i, step := range steps {
s.webSocketService.BroadcastStep(server.ID, step.stepType, model.StatusInProgress,
model.GetStepDescription(step.stepType), "")
successMessage, err := step.callback()
if err != nil {
s.webSocketService.BroadcastStep(server.ID, step.stepType, model.StatusFailed,
"", err.Error())
if step.important {
s.rollbackSteps(ctx, server, steps[:i], tcpPorts, udpPorts)
return err
}
}
s.webSocketService.BroadcastStep(server.ID, step.stepType, model.StatusCompleted,
successMessage, "")
}
s.StartAccServerRuntime(server)
s.webSocketService.BroadcastStep(server.ID, model.StepCompleted, model.StatusCompleted,
model.GetStepDescription(model.StepCompleted), "")
s.webSocketService.BroadcastComplete(server.ID, true,
fmt.Sprintf("Server '%s' created successfully on port %d", server.Name, serverPort))
return nil
}
func (s *ServerService) rollbackSteps(ctx context.Context, server *model.Server, completedSteps []createServerStep, tcpPorts, udpPorts []int) {
for i := len(completedSteps) - 1; i >= 0; i-- {
step := completedSteps[i]
switch step.stepType {
case model.StepDatabaseSave:
s.repository.Delete(ctx, server.ID)
case model.StepFirewallRules:
if len(tcpPorts) > 0 && len(udpPorts) > 0 {
s.firewall.DeleteServerRules(server.ServiceName, tcpPorts, udpPorts)
}
case model.StepServiceCreation:
s.runtime.Delete(ctx, server.ID)
case model.StepSteamDownload:
s.steamService.UninstallServer(server.Path)
case model.StepDirectoryCreation:
s.steamService.UninstallServer(server.Path)
case model.StepConfigGeneration:
if env.IsDockerPlatform() && server.Port > 0 {
s.portPool.Release(ctx, server.Port)
}
}
}
}
func (s *ServerService) DeleteServer(ctx *fiber.Ctx, serverID uuid.UUID) error { func (s *ServerService) DeleteServer(ctx *fiber.Ctx, serverID uuid.UUID) error {
// Get server details
server, err := s.repository.GetByID(ctx.UserContext(), serverID) server, err := s.repository.GetByID(ctx.UserContext(), serverID)
if err != nil { if err != nil {
return fmt.Errorf("failed to get server details: %v", err) return fmt.Errorf("failed to get server details: %v", err)
} }
// Stop and remove Windows service if err := s.runtime.Delete(ctx.UserContext(), server.ID); err != nil {
if err := s.windowsService.DeleteService(ctx.UserContext(), server.ServiceName); err != nil { logging.Error("Failed to delete server instance: %v", err)
logging.Error("Failed to delete Windows service: %v", err)
} }
// Remove firewall rules
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)
} }
tcpPorts := []int{configuration.TcpPort.ToInt()} if configuration != nil {
udpPorts := []int{configuration.UdpPort.ToInt()} tcpPorts := []int{configuration.TcpPort.ToInt()}
if err := s.firewallService.DeleteServerRules(server.ServiceName, tcpPorts, udpPorts); err != nil { udpPorts := []int{configuration.UdpPort.ToInt()}
logging.Error("Failed to delete firewall rules: %v", err) if err := s.firewall.DeleteServerRules(server.ServiceName, tcpPorts, udpPorts); err != nil {
logging.Error("Failed to delete firewall rules: %v", err)
}
} }
// Uninstall server files
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)
} }
// Remove from database 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)
} }
// Cleanup runtime resources s.logStreamer.Stop(server.ID)
if tailer, exists := s.logTailers.Load(server.ID); exists {
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)
s.sessionIDs.Delete(server.ID) s.sessionIDs.Delete(server.ID)
// Invalidate status cache for deleted server
s.apiService.statusCache.InvalidateStatus(server.ServiceName) s.apiService.statusCache.InvalidateStatus(server.ServiceName)
return nil return nil
} }
func (s *ServerService) UpdateServer(ctx *fiber.Ctx, server *model.Server) error {
// Validate server configuration
if err := server.Validate(); err != nil {
return err
}
// Get existing server details
existingServer, err := s.repository.GetByID(ctx.UserContext(), server.ID)
if err != nil {
return fmt.Errorf("failed to get existing server details: %v", err)
}
// Update server files if path changed
if existingServer.Path != server.Path {
if err := s.steamService.InstallServer(ctx.UserContext(), server.Path, &server.ID); err != nil {
return fmt.Errorf("failed to install server to new location: %v", err)
}
// Clean up old installation
if err := s.steamService.UninstallServer(existingServer.Path); err != nil {
logging.Error("Failed to remove old server installation: %v", err)
}
}
// Update Windows service if necessary
if existingServer.ServiceName != server.ServiceName || existingServer.Path != server.Path {
execPath := filepath.Join(server.GetServerPath(), "accServer.exe")
serverWorkingDir := server.GetServerPath()
if err := s.windowsService.UpdateService(ctx.UserContext(), server.ServiceName, execPath, serverWorkingDir, nil); err != nil {
return fmt.Errorf("failed to update Windows service: %v", err)
}
}
// Update firewall rules if service name changed
if existingServer.ServiceName != server.ServiceName {
if err := s.configureFirewall(server); err != nil {
return fmt.Errorf("failed to update firewall rules: %v", err)
}
// Invalidate cache for old service name
s.apiService.statusCache.InvalidateStatus(existingServer.ServiceName)
}
// Update database record
if err := s.repository.Update(ctx.UserContext(), server); err != nil {
return fmt.Errorf("failed to update server in database: %v", err)
}
// Restart server runtime
s.StartAccServerRuntime(server)
return nil
}
func (s *ServerService) configureFirewall(server *model.Server) error { func (s *ServerService) configureFirewall(server *model.Server) error {
// Find available ports for the server
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)
} }
// Use the first port for both TCP and UDP
serverPort := ports[0] serverPort := ports[0]
tcpPorts := []int{serverPort} tcpPorts := []int{serverPort}
udpPorts := []int{serverPort} udpPorts := []int{serverPort}
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)
// Configure firewall rules if err := s.firewall.UpdateServerRules(server.Name, tcpPorts, udpPorts); err != nil {
if err := s.firewallService.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)
} }
// Update server configuration with the allocated 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)
} }
@@ -516,7 +585,6 @@ func (s *ServerService) configureFirewall(server *model.Server) error {
} }
func (s *ServerService) updateServerPort(server *model.Server, port int) error { func (s *ServerService) updateServerPort(server *model.Server, port int) error {
// Load current configuration
config, err := s.configService.GetConfiguration(server) config, err := s.configService.GetConfiguration(server)
if err != nil { if err != nil {
return fmt.Errorf("failed to load server configuration: %v", err) return fmt.Errorf("failed to load server configuration: %v", err)
@@ -524,11 +592,17 @@ func (s *ServerService) updateServerPort(server *model.Server, port int) error {
config.TcpPort = model.IntString(port) config.TcpPort = model.IntString(port)
config.UdpPort = model.IntString(port) config.UdpPort = model.IntString(port)
config.RegisterToLobby = model.IntString(1)
// Save the updated configuration
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
} }

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

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

147
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, // Cache expires after 30 seconds ExpirationTime: 30 * time.Second,
ThrottleTime: 5 * time.Second, // Minimum 5 seconds between checks ThrottleTime: 5 * time.Second,
DefaultStatus: model.StatusRunning, // Default to running if throttled 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
} }
@@ -42,30 +71,27 @@ func (as *ServiceControlService) GetStatus(ctx *fiber.Ctx) (string, error) {
return "", err return "", err
} }
// Try to get status from cache
if status, shouldCheck := as.statusCache.GetStatus(serviceName); !shouldCheck { if status, shouldCheck := as.statusCache.GetStatus(serviceName); !shouldCheck {
return status.String(), nil return status.String(), nil
} }
// If cache miss or expired, check actual status
statusStr, err := as.StatusServer(serviceName) statusStr, err := as.StatusServer(serviceName)
if err != nil { if err != nil {
return "", err return "", err
} }
// Parse and update cache with new status
status := model.ParseServiceStatus(statusStr) status := model.ParseServiceStatus(statusStr)
as.statusCache.UpdateStatus(serviceName, status) as.statusCache.UpdateStatus(serviceName, status)
return status.String(), nil return status.String(), nil
} }
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
// Update status cache for this service before starting
as.statusCache.UpdateStatus(serviceName, model.StatusStarting) as.statusCache.UpdateStatus(serviceName, model.StatusStarting)
_, err = as.StartServer(serviceName) _, err = as.StartServer(serviceName)
@@ -77,19 +103,32 @@ func (as *ServiceControlService) ServiceControlStartServer(ctx *fiber.Ctx) (stri
return "", err return "", err
} }
// Parse and update cache with new status
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
// Update status cache for this service before stopping
as.statusCache.UpdateStatus(serviceName, model.StatusStopping) as.statusCache.UpdateStatus(serviceName, model.StatusStopping)
_, err = as.StopServer(serviceName) _, err = as.StopServer(serviceName)
@@ -101,19 +140,32 @@ func (as *ServiceControlService) ServiceControlStopServer(ctx *fiber.Ctx) (strin
return "", err return "", err
} }
// Parse and update cache with new status
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
// Update status cache for this service before restarting
as.statusCache.UpdateStatus(serviceName, model.StatusRestarting) as.statusCache.UpdateStatus(serviceName, model.StatusRestarting)
_, err = as.RestartServer(serviceName) _, err = as.RestartServer(serviceName)
@@ -125,74 +177,89 @@ func (as *ServiceControlService) ServiceControlRestartServer(ctx *fiber.Ctx) (st
return "", err return "", err
} }
// Parse and update cache with new status
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)
} }
// GetCachedStatus gets the cached status for a service name without requiring fiber context
func (as *ServiceControlService) GetCachedStatus(serviceName string) (string, error) { func (as *ServiceControlService) GetCachedStatus(serviceName string) (string, error) {
// Try to get status from cache
if status, shouldCheck := as.statusCache.GetStatus(serviceName); !shouldCheck { if status, shouldCheck := as.statusCache.GetStatus(serviceName); !shouldCheck {
return status.String(), nil return status.String(), nil
} }
// If cache miss or expired, check actual status
statusStr, err := as.StatusServer(serviceName) statusStr, err := as.StatusServer(serviceName)
if err != nil { if err != nil {
return "", err return "", err
} }
// Parse and update cache with new status
status := model.ParseServiceStatus(statusStr) status := model.ParseServiceStatus(statusStr)
as.statusCache.UpdateStatus(serviceName, status) as.statusCache.UpdateStatus(serviceName, status)
return status.String(), nil return status.String(), nil
} }
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
} }

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

@@ -6,7 +6,7 @@ import (
) )
type ServiceManager struct { type ServiceManager struct {
executor *command.CommandExecutor executor *command.CommandExecutor
psExecutor *command.CommandExecutor psExecutor *command.CommandExecutor
} }
@@ -24,15 +24,12 @@ func NewServiceManager() *ServiceManager {
} }
func (s *ServiceManager) ManageService(serviceName, action string) (string, error) { func (s *ServiceManager) ManageService(serviceName, action string) (string, error) {
// Run NSSM command through PowerShell to ensure elevation
output, err := s.psExecutor.ExecuteWithOutput("-nologo", "-noprofile", ".\\nssm", action, serviceName) output, err := s.psExecutor.ExecuteWithOutput("-nologo", "-noprofile", ".\\nssm", action, serviceName)
if err != nil { if err != nil {
return "", err return "", err
} }
// Clean up output by removing null bytes and trimming whitespace
cleaned := strings.TrimSpace(strings.ReplaceAll(output, "\x00", "")) cleaned := strings.TrimSpace(strings.ReplaceAll(output, "\x00", ""))
// Remove \r\n from status strings
cleaned = strings.TrimSuffix(cleaned, "\r\n") cleaned = strings.TrimSuffix(cleaned, "\r\n")
return cleaned, nil return cleaned, nil
@@ -51,11 +48,9 @@ func (s *ServiceManager) Stop(serviceName string) (string, error) {
} }
func (s *ServiceManager) Restart(serviceName string) (string, error) { func (s *ServiceManager) Restart(serviceName string) (string, error) {
// First stop the service
if _, err := s.Stop(serviceName); err != nil { if _, err := s.Stop(serviceName); err != nil {
return "", err return "", err
} }
// Then start it again
return s.Start(serviceName) return s.Start(serviceName)
} }

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

@@ -46,7 +46,6 @@ func (s *StateHistoryService) GetStatistics(ctx *fiber.Ctx, filter *model.StateH
eg, gCtx := errgroup.WithContext(ctx.UserContext()) eg, gCtx := errgroup.WithContext(ctx.UserContext())
// Get Summary Stats (Peak/Avg Players, Total Sessions)
eg.Go(func() error { eg.Go(func() error {
summary, err := s.repository.GetSummaryStats(gCtx, filter) summary, err := s.repository.GetSummaryStats(gCtx, filter)
if err != nil { if err != nil {
@@ -61,7 +60,6 @@ func (s *StateHistoryService) GetStatistics(ctx *fiber.Ctx, filter *model.StateH
return nil return nil
}) })
// Get Total Playtime
eg.Go(func() error { eg.Go(func() error {
playtime, err := s.repository.GetTotalPlaytime(gCtx, filter) playtime, err := s.repository.GetTotalPlaytime(gCtx, filter)
if err != nil { if err != nil {
@@ -74,7 +72,6 @@ func (s *StateHistoryService) GetStatistics(ctx *fiber.Ctx, filter *model.StateH
return nil return nil
}) })
// Get Player Count Over Time
eg.Go(func() error { eg.Go(func() error {
playerCount, err := s.repository.GetPlayerCountOverTime(gCtx, filter) playerCount, err := s.repository.GetPlayerCountOverTime(gCtx, filter)
if err != nil { if err != nil {
@@ -87,7 +84,6 @@ func (s *StateHistoryService) GetStatistics(ctx *fiber.Ctx, filter *model.StateH
return nil return nil
}) })
// Get Session Types
eg.Go(func() error { eg.Go(func() error {
sessionTypes, err := s.repository.GetSessionTypes(gCtx, filter) sessionTypes, err := s.repository.GetSessionTypes(gCtx, filter)
if err != nil { if err != nil {
@@ -100,7 +96,6 @@ func (s *StateHistoryService) GetStatistics(ctx *fiber.Ctx, filter *model.StateH
return nil return nil
}) })
// Get Daily Activity
eg.Go(func() error { eg.Go(func() error {
dailyActivity, err := s.repository.GetDailyActivity(gCtx, filter) dailyActivity, err := s.repository.GetDailyActivity(gCtx, filter)
if err != nil { if err != nil {
@@ -113,7 +108,6 @@ func (s *StateHistoryService) GetStatistics(ctx *fiber.Ctx, filter *model.StateH
return nil return nil
}) })
// Get Recent Sessions
eg.Go(func() error { eg.Go(func() error {
recentSessions, err := s.repository.GetRecentSessions(gCtx, filter) recentSessions, err := s.repository.GetRecentSessions(gCtx, filter)
if err != nil { if err != nil {

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

@@ -11,6 +11,7 @@ import (
"fmt" "fmt"
"os" "os"
"path/filepath" "path/filepath"
"strings"
"time" "time"
"github.com/google/uuid" "github.com/google/uuid"
@@ -21,12 +22,11 @@ const (
) )
type SteamService struct { type SteamService struct {
executor *command.CommandExecutor executor *command.CommandExecutor
interactiveExecutor *command.InteractiveCommandExecutor repository *repository.SteamCredentialsRepository
repository *repository.SteamCredentialsRepository tfaManager *model.Steam2FAManager
tfaManager *model.Steam2FAManager pathValidator *security.PathValidator
pathValidator *security.PathValidator downloadVerifier *security.DownloadVerifier
downloadVerifier *security.DownloadVerifier
} }
func NewSteamService(repository *repository.SteamCredentialsRepository, tfaManager *model.Steam2FAManager) *SteamService { func NewSteamService(repository *repository.SteamCredentialsRepository, tfaManager *model.Steam2FAManager) *SteamService {
@@ -36,15 +36,22 @@ func NewSteamService(repository *repository.SteamCredentialsRepository, tfaManag
} }
return &SteamService{ return &SteamService{
executor: baseExecutor, executor: baseExecutor,
interactiveExecutor: command.NewInteractiveCommandExecutor(baseExecutor, tfaManager), repository: repository,
repository: repository, tfaManager: tfaManager,
tfaManager: tfaManager, pathValidator: security.NewPathValidator(),
pathValidator: security.NewPathValidator(), downloadVerifier: security.NewDownloadVerifier(),
downloadVerifier: security.NewDownloadVerifier(),
} }
} }
// 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)
} }
@@ -57,21 +64,22 @@ func (s *SteamService) SaveCredentials(ctx context.Context, creds *model.SteamCr
} }
func (s *SteamService) ensureSteamCMD(_ context.Context) error { func (s *SteamService) ensureSteamCMD(_ context.Context) error {
// Get SteamCMD path from environment variable // 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)
// Check if SteamCMD exists
if _, err := os.Stat(steamCMDPath); !os.IsNotExist(err) { if _, err := os.Stat(steamCMDPath); !os.IsNotExist(err) {
return nil return nil
} }
// Create directory if it doesn't exist
if err := os.MkdirAll(steamCMDDir, 0755); err != nil { if err := os.MkdirAll(steamCMDDir, 0755); err != nil {
return fmt.Errorf("failed to create SteamCMD directory: %v", err) return fmt.Errorf("failed to create SteamCMD directory: %v", err)
} }
// Download and install SteamCMD securely
logging.Info("Downloading SteamCMD...") logging.Info("Downloading SteamCMD...")
steamCMDZip := filepath.Join(steamCMDDir, "steamcmd.zip") steamCMDZip := filepath.Join(steamCMDDir, "steamcmd.zip")
if err := s.downloadVerifier.VerifyAndDownload( if err := s.downloadVerifier.VerifyAndDownload(
@@ -81,106 +89,263 @@ func (s *SteamService) ensureSteamCMD(_ context.Context) error {
return fmt.Errorf("failed to download SteamCMD: %v", err) return fmt.Errorf("failed to download SteamCMD: %v", err)
} }
// Extract SteamCMD
logging.Info("Extracting SteamCMD...") logging.Info("Extracting SteamCMD...")
if err := s.executor.Execute("-Command", if err := s.executor.Execute("-Command",
fmt.Sprintf("Expand-Archive -Path 'steamcmd.zip' -DestinationPath '%s'", steamCMDDir)); err != nil { fmt.Sprintf("Expand-Archive -Path 'steamcmd.zip' -DestinationPath '%s'", steamCMDDir)); err != nil {
return fmt.Errorf("failed to extract SteamCMD: %v", err) return fmt.Errorf("failed to extract SteamCMD: %v", err)
} }
// Clean up zip file
os.Remove("steamcmd.zip") os.Remove("steamcmd.zip")
return nil return nil
} }
func (s *SteamService) InstallServer(ctx context.Context, installPath string, serverID *uuid.UUID) error { func (s *SteamService) InstallServerWithWebSocket(ctx context.Context, installPath string, serverID *uuid.UUID, wsService *WebSocketService) error {
if err := s.ensureSteamCMD(ctx); err != nil { if err := s.ensureSteamCMD(ctx); err != nil {
wsService.BroadcastSteamOutput(*serverID, fmt.Sprintf("Error ensuring SteamCMD: %v", err), true)
return err return err
} }
// Validate installation path for security if !env.IsDockerPlatform() {
if err := s.pathValidator.ValidateInstallPath(installPath); err != nil { if err := s.pathValidator.ValidateInstallPath(installPath); err != nil {
return fmt.Errorf("invalid installation path: %v", err) wsService.BroadcastSteamOutput(*serverID, fmt.Sprintf("Invalid installation path: %v", err), true)
return fmt.Errorf("invalid installation path: %v", err)
}
} }
// Convert to absolute path and ensure proper Windows path format
absPath, err := filepath.Abs(installPath) absPath, err := filepath.Abs(installPath)
if err != nil { if err != nil {
wsService.BroadcastSteamOutput(*serverID, fmt.Sprintf("Failed to get absolute path: %v", err), true)
return fmt.Errorf("failed to get absolute path: %v", err) return fmt.Errorf("failed to get absolute path: %v", err)
} }
absPath = filepath.Clean(absPath) absPath = filepath.Clean(absPath)
// Ensure install path exists
if err := os.MkdirAll(absPath, 0755); err != nil { if err := os.MkdirAll(absPath, 0755); err != nil {
wsService.BroadcastSteamOutput(*serverID, fmt.Sprintf("Failed to create install directory: %v", err), true)
return fmt.Errorf("failed to create install directory: %v", err) return fmt.Errorf("failed to create install directory: %v", err)
} }
// Get Steam credentials wsService.BroadcastSteamOutput(*serverID, fmt.Sprintf("Installation directory prepared: %s", absPath), false)
creds, err := s.GetCredentials(ctx) creds, err := s.GetCredentials(ctx)
if err != nil { if err != nil {
wsService.BroadcastSteamOutput(*serverID, fmt.Sprintf("Failed to get Steam credentials: %v", err), true)
return fmt.Errorf("failed to get Steam credentials: %v", err) return fmt.Errorf("failed to get Steam credentials: %v", err)
} }
// Get SteamCMD path from environment variable steamCMDPath := getSteamCMDPath()
steamCMDPath := env.GetSteamCMDPath()
// Build SteamCMD command arguments
steamCMDArgs := []string{ steamCMDArgs := []string{
"+force_install_dir", absPath, "+force_install_dir", absPath,
"+login", "+login",
} }
if creds != nil && creds.Username != "" { if creds != nil && creds.Username != "" {
wsService.BroadcastSteamOutput(*serverID, fmt.Sprintf("Using Steam credentials for user: %s", creds.Username), false)
steamCMDArgs = append(steamCMDArgs, creds.Username) steamCMDArgs = append(steamCMDArgs, creds.Username)
if creds.Password != "" { if creds.Password != "" {
steamCMDArgs = append(steamCMDArgs, creds.Password) steamCMDArgs = append(steamCMDArgs, creds.Password)
} }
} else { } else {
wsService.BroadcastSteamOutput(*serverID, "Using anonymous Steam login", false)
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",
"+quit", "+quit",
) )
// Build PowerShell arguments to execute SteamCMD directly args := steamCMDArgs
// This matches the format: powershell -nologo -noprofile c:\steamcmd\steamcmd.exe +args...
args := []string{"-nologo", "-noprofile"}
args = append(args, steamCMDPath)
args = append(args, steamCMDArgs...)
// Use interactive executor to handle potential 2FA prompts with timeout wsService.BroadcastSteamOutput(*serverID, fmt.Sprintf("Starting SteamCMD: %s %s", steamCMDPath, strings.Join(args, " ")), false)
logging.Info("Installing ACC server to %s...", absPath)
// Create a context with timeout to prevent hanging indefinitely timeoutCtx, cancel := context.WithTimeout(ctx, 15*time.Minute)
timeoutCtx, cancel := context.WithTimeout(ctx, 10*time.Minute)
defer cancel() defer cancel()
if err := s.interactiveExecutor.ExecuteInteractive(timeoutCtx, serverID, args...); err != nil { callbackConfig := &command.CallbackConfig{
OnOutput: func(serverID uuid.UUID, output string, isError bool) {
wsService.BroadcastSteamOutput(serverID, output, isError)
},
OnCommand: func(serverID uuid.UUID, command string, args []string, completed bool, success bool, error string) {
if completed {
if success {
wsService.BroadcastSteamOutput(serverID, "Command completed successfully", false)
} else {
wsService.BroadcastSteamOutput(serverID, fmt.Sprintf("Command failed: %s", error), true)
}
}
},
}
callbackInteractiveExecutor := command.NewCallbackInteractiveCommandExecutor(s.executor, s.tfaManager, callbackConfig, *serverID)
callbackInteractiveExecutor.ExePath = steamCMDPath
if err := callbackInteractiveExecutor.ExecuteInteractive(timeoutCtx, serverID, args...); err != nil {
wsService.BroadcastSteamOutput(*serverID, fmt.Sprintf("SteamCMD execution failed: %v", err), true)
if timeoutCtx.Err() == context.DeadlineExceeded { if timeoutCtx.Err() == context.DeadlineExceeded {
return fmt.Errorf("SteamCMD operation timed out after 10 minutes") return fmt.Errorf("SteamCMD operation timed out after 15 minutes - this usually means Steam Guard confirmation is required")
} }
return fmt.Errorf("failed to run SteamCMD: %v", err) return fmt.Errorf("failed to run SteamCMD: %v", err)
} }
// Add a delay to allow Steam to properly cleanup wsService.BroadcastSteamOutput(*serverID, "SteamCMD execution completed successfully, proceeding with verification...", false)
logging.Info("Waiting for Steam operations to complete...")
wsService.BroadcastSteamOutput(*serverID, "Waiting for Steam operations to complete...", false)
time.Sleep(5 * time.Second) time.Sleep(5 * time.Second)
// Verify installation
exePath := filepath.Join(absPath, "server", "accServer.exe") exePath := filepath.Join(absPath, "server", "accServer.exe")
wsService.BroadcastSteamOutput(*serverID, fmt.Sprintf("Checking for ACC server executable at: %s", exePath), false)
if _, err := os.Stat(exePath); os.IsNotExist(err) { if _, err := os.Stat(exePath); os.IsNotExist(err) {
return fmt.Errorf("server installation failed: accServer.exe not found in %s", absPath) wsService.BroadcastSteamOutput(*serverID, "accServer.exe not found, checking directory contents...", false)
if entries, dirErr := os.ReadDir(absPath); dirErr == nil {
wsService.BroadcastSteamOutput(*serverID, fmt.Sprintf("Contents of %s:", absPath), false)
for _, entry := range entries {
wsService.BroadcastSteamOutput(*serverID, fmt.Sprintf(" - %s (dir: %v)", entry.Name(), entry.IsDir()), false)
}
}
serverDir := filepath.Join(absPath, "server")
if entries, dirErr := os.ReadDir(serverDir); dirErr == nil {
wsService.BroadcastSteamOutput(*serverID, fmt.Sprintf("Contents of %s:", serverDir), false)
for _, entry := range entries {
wsService.BroadcastSteamOutput(*serverID, fmt.Sprintf(" - %s (dir: %v)", entry.Name(), entry.IsDir()), false)
}
} else {
wsService.BroadcastSteamOutput(*serverID, fmt.Sprintf("Server directory %s does not exist or cannot be read: %v", serverDir, dirErr), true)
}
wsService.BroadcastSteamOutput(*serverID, fmt.Sprintf("Server installation failed: accServer.exe not found in %s", exePath), true)
return fmt.Errorf("server installation failed: accServer.exe not found in %s", exePath)
} }
logging.Info("Server installation completed successfully") wsService.BroadcastSteamOutput(*serverID, fmt.Sprintf("Server installation completed successfully - accServer.exe found at %s", exePath), false)
return nil return nil
} }
func (s *SteamService) UpdateServer(ctx context.Context, installPath string, serverID *uuid.UUID) error { func (s *SteamService) InstallServerWithCallbacks(ctx context.Context, installPath string, serverID *uuid.UUID, outputCallback command.OutputCallback) error {
return s.InstallServer(ctx, installPath, serverID) // Same process as install if err := s.ensureSteamCMD(ctx); err != nil {
outputCallback(*serverID, fmt.Sprintf("Error ensuring SteamCMD: %v", err), true)
return err
}
if !env.IsDockerPlatform() {
if err := s.pathValidator.ValidateInstallPath(installPath); err != nil {
outputCallback(*serverID, fmt.Sprintf("Invalid installation path: %v", err), true)
return fmt.Errorf("invalid installation path: %v", err)
}
}
absPath, err := filepath.Abs(installPath)
if err != nil {
outputCallback(*serverID, fmt.Sprintf("Failed to get absolute path: %v", err), true)
return fmt.Errorf("failed to get absolute path: %v", err)
}
absPath = filepath.Clean(absPath)
if err := os.MkdirAll(absPath, 0755); err != nil {
outputCallback(*serverID, fmt.Sprintf("Failed to create install directory: %v", err), true)
return fmt.Errorf("failed to create install directory: %v", err)
}
outputCallback(*serverID, fmt.Sprintf("Installation directory prepared: %s", absPath), false)
creds, err := s.GetCredentials(ctx)
if err != nil {
outputCallback(*serverID, fmt.Sprintf("Failed to get Steam credentials: %v", err), true)
return fmt.Errorf("failed to get Steam credentials: %v", err)
}
steamCMDPath := getSteamCMDPath()
steamCMDArgs := []string{
"+force_install_dir", absPath,
"+login",
}
if creds != nil && creds.Username != "" {
outputCallback(*serverID, fmt.Sprintf("Using Steam credentials for user: %s", creds.Username), false)
steamCMDArgs = append(steamCMDArgs, creds.Username)
if creds.Password != "" {
steamCMDArgs = append(steamCMDArgs, creds.Password)
}
} else {
outputCallback(*serverID, "Using anonymous Steam login", false)
steamCMDArgs = append(steamCMDArgs, "anonymous")
}
if env.IsDockerPlatform() {
steamCMDArgs = append(steamCMDArgs, "+@sSteamCmdForcePlatformType", "windows")
}
steamCMDArgs = append(steamCMDArgs,
"+app_update", ACCServerAppID,
"validate",
"+quit",
)
args := steamCMDArgs
outputCallback(*serverID, fmt.Sprintf("Starting SteamCMD: %s %s", steamCMDPath, strings.Join(args, " ")), false)
timeoutCtx, cancel := context.WithTimeout(ctx, 15*time.Minute)
defer cancel()
callbacks := &command.CallbackConfig{
OnOutput: outputCallback,
}
callbackExecutor := command.NewCallbackInteractiveCommandExecutor(s.executor, s.tfaManager, callbacks, *serverID)
callbackExecutor.ExePath = steamCMDPath
if err := callbackExecutor.ExecuteInteractive(timeoutCtx, serverID, args...); err != nil {
outputCallback(*serverID, fmt.Sprintf("SteamCMD execution failed: %v", err), true)
if timeoutCtx.Err() == context.DeadlineExceeded {
return fmt.Errorf("SteamCMD operation timed out after 15 minutes - this usually means Steam Guard confirmation is required")
}
return fmt.Errorf("failed to run SteamCMD: %v", err)
}
outputCallback(*serverID, "SteamCMD execution completed successfully, proceeding with verification...", false)
outputCallback(*serverID, "Waiting for Steam operations to complete...", false)
time.Sleep(5 * time.Second)
exePath := filepath.Join(absPath, "server", "accServer.exe")
outputCallback(*serverID, fmt.Sprintf("Checking for ACC server executable at: %s", exePath), false)
if _, err := os.Stat(exePath); os.IsNotExist(err) {
outputCallback(*serverID, "accServer.exe not found, checking directory contents...", false)
if entries, dirErr := os.ReadDir(absPath); dirErr == nil {
outputCallback(*serverID, fmt.Sprintf("Contents of %s:", absPath), false)
for _, entry := range entries {
outputCallback(*serverID, fmt.Sprintf(" - %s (dir: %v)", entry.Name(), entry.IsDir()), false)
}
}
serverDir := filepath.Join(absPath, "server")
if entries, dirErr := os.ReadDir(serverDir); dirErr == nil {
outputCallback(*serverID, fmt.Sprintf("Contents of %s:", serverDir), false)
for _, entry := range entries {
outputCallback(*serverID, fmt.Sprintf(" - %s (dir: %v)", entry.Name(), entry.IsDir()), false)
}
} else {
outputCallback(*serverID, fmt.Sprintf("Server directory %s does not exist or cannot be read: %v", serverDir, dirErr), true)
}
outputCallback(*serverID, fmt.Sprintf("Server installation failed: accServer.exe not found in %s", exePath), true)
return fmt.Errorf("server installation failed: accServer.exe not found in %s", exePath)
}
outputCallback(*serverID, fmt.Sprintf("Server installation completed successfully - accServer.exe found at %s", exePath), false)
return nil
} }
func (s *SteamService) UninstallServer(installPath string) error { func (s *SteamService) UninstallServer(installPath string) error {

192
local/service/websocket.go Executable file
View File

@@ -0,0 +1,192 @@
package service
import (
"acc-server-manager/local/model"
"acc-server-manager/local/utl/logging"
"encoding/json"
"sync"
"time"
"github.com/gofiber/websocket/v2"
"github.com/google/uuid"
)
type WebSocketConnection struct {
conn *websocket.Conn
serverID *uuid.UUID
userID *uuid.UUID
mu sync.Mutex
}
type WebSocketService struct {
connections sync.Map
mu sync.RWMutex
}
func NewWebSocketService() *WebSocketService {
return &WebSocketService{}
}
func (ws *WebSocketService) AddConnection(connID string, conn *websocket.Conn, userID *uuid.UUID) {
wsConn := &WebSocketConnection{
conn: conn,
userID: userID,
}
ws.connections.Store(connID, wsConn)
logging.Info("WebSocket connection added: %s for user: %v", connID, userID)
}
func (ws *WebSocketService) RemoveConnection(connID string) {
if conn, exists := ws.connections.LoadAndDelete(connID); exists {
if wsConn, ok := conn.(*WebSocketConnection); ok {
wsConn.conn.Close()
}
}
logging.Info("WebSocket connection removed: %s", connID)
}
func (ws *WebSocketService) SetServerID(connID string, serverID uuid.UUID) {
if conn, exists := ws.connections.Load(connID); exists {
if wsConn, ok := conn.(*WebSocketConnection); ok {
wsConn.serverID = &serverID
}
}
}
func (ws *WebSocketService) BroadcastStep(serverID uuid.UUID, step model.ServerCreationStep, status model.StepStatus, message string, errorMsg string) {
stepMsg := model.StepMessage{
Step: step,
Status: status,
Message: message,
Error: errorMsg,
}
wsMsg := model.WebSocketMessage{
Type: model.MessageTypeStep,
ServerID: &serverID,
Timestamp: time.Now().Unix(),
Data: stepMsg,
}
ws.broadcastToServer(serverID, wsMsg)
}
func (ws *WebSocketService) BroadcastSteamOutput(serverID uuid.UUID, output string, isError bool) {
steamMsg := model.SteamOutputMessage{
Output: output,
IsError: isError,
}
wsMsg := model.WebSocketMessage{
Type: model.MessageTypeSteamOutput,
ServerID: &serverID,
Timestamp: time.Now().Unix(),
Data: steamMsg,
}
ws.broadcastToServer(serverID, wsMsg)
}
func (ws *WebSocketService) BroadcastError(serverID uuid.UUID, error string, details string) {
errorMsg := model.ErrorMessage{
Error: error,
Details: details,
}
wsMsg := model.WebSocketMessage{
Type: model.MessageTypeError,
ServerID: &serverID,
Timestamp: time.Now().Unix(),
Data: errorMsg,
}
ws.broadcastToServer(serverID, wsMsg)
}
func (ws *WebSocketService) BroadcastComplete(serverID uuid.UUID, success bool, message string) {
completeMsg := model.CompleteMessage{
ServerID: serverID,
Success: success,
Message: message,
}
wsMsg := model.WebSocketMessage{
Type: model.MessageTypeComplete,
ServerID: &serverID,
Timestamp: time.Now().Unix(),
Data: completeMsg,
}
ws.broadcastToServer(serverID, wsMsg)
}
func (ws *WebSocketService) broadcastToServer(serverID uuid.UUID, message model.WebSocketMessage) {
data, err := json.Marshal(message)
if err != nil {
logging.Error("Failed to marshal WebSocket message: %v", err)
return
}
sentToAssociatedConnections := false
ws.connections.Range(func(key, value interface{}) bool {
if wsConn, ok := value.(*WebSocketConnection); ok {
if wsConn.serverID != nil && *wsConn.serverID == serverID {
if err := wsConn.writeMessage(websocket.TextMessage, data); err != nil {
logging.Error("Failed to send WebSocket message to connection %s: %v", key, err)
ws.RemoveConnection(key.(string))
} else {
sentToAssociatedConnections = true
}
}
}
return true
})
if !sentToAssociatedConnections && (message.Type == model.MessageTypeStep || message.Type == model.MessageTypeError || message.Type == model.MessageTypeComplete) {
ws.connections.Range(func(key, value interface{}) bool {
if wsConn, ok := value.(*WebSocketConnection); ok {
if err := wsConn.writeMessage(websocket.TextMessage, data); err != nil {
logging.Error("Failed to send WebSocket message to connection %s: %v", key, err)
ws.RemoveConnection(key.(string))
}
}
return true
})
}
}
func (ws *WebSocketService) BroadcastToUser(userID uuid.UUID, message model.WebSocketMessage) {
data, err := json.Marshal(message)
if err != nil {
logging.Error("Failed to marshal WebSocket message: %v", err)
return
}
ws.connections.Range(func(key, value interface{}) bool {
if wsConn, ok := value.(*WebSocketConnection); ok {
if wsConn.userID != nil && *wsConn.userID == userID {
if err := wsConn.writeMessage(websocket.TextMessage, data); err != nil {
logging.Error("Failed to send WebSocket message to connection %s: %v", key, err)
ws.RemoveConnection(key.(string))
}
}
}
return true
})
}
func (wc *WebSocketConnection) writeMessage(messageType int, data []byte) error {
wc.mu.Lock()
defer wc.mu.Unlock()
return wc.conn.WriteMessage(messageType, data)
}
func (ws *WebSocketService) GetActiveConnections() int {
count := 0
ws.connections.Range(func(key, value interface{}) bool {
count++
return true
})
return count
}

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

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

@@ -23,34 +23,25 @@ func NewWindowsService() *WindowsService {
} }
} }
// executeNSSM runs an NSSM command through PowerShell with elevation
func (s *WindowsService) ExecuteNSSM(ctx context.Context, args ...string) (string, error) { func (s *WindowsService) ExecuteNSSM(ctx context.Context, args ...string) (string, error) {
// Get NSSM path from environment variable
nssmPath := env.GetNSSMPath() nssmPath := env.GetNSSMPath()
// Prepend NSSM path to arguments
nssmArgs := append([]string{"-NoProfile", "-NonInteractive", "-Command", "& " + nssmPath}, args...) nssmArgs := append([]string{"-NoProfile", "-NonInteractive", "-Command", "& " + nssmPath}, args...)
output, err := s.executor.ExecuteWithOutput(nssmArgs...) output, err := s.executor.ExecuteWithOutput(nssmArgs...)
if err != nil { if err != nil {
// Log the full command and error for debugging
logging.Error("NSSM command failed: powershell %s", strings.Join(nssmArgs, " ")) logging.Error("NSSM command failed: powershell %s", strings.Join(nssmArgs, " "))
logging.Error("NSSM error output: %s", output) logging.Error("NSSM error output: %s", output)
return "", err return "", err
} }
// Clean up output by removing null bytes and trimming whitespace
cleaned := strings.TrimSpace(strings.ReplaceAll(output, "\x00", "")) cleaned := strings.TrimSpace(strings.ReplaceAll(output, "\x00", ""))
// Remove \r\n from status strings
cleaned = strings.TrimSuffix(cleaned, "\r\n") cleaned = strings.TrimSuffix(cleaned, "\r\n")
return cleaned, nil return cleaned, nil
} }
// Service Installation/Configuration Methods
func (s *WindowsService) CreateService(ctx context.Context, serviceName, execPath, workingDir string, args []string) error { func (s *WindowsService) CreateService(ctx context.Context, serviceName, execPath, workingDir string, args []string) error {
// Ensure paths are absolute and properly formatted for Windows
absExecPath, err := filepath.Abs(execPath) absExecPath, err := filepath.Abs(execPath)
if err != nil { if err != nil {
return fmt.Errorf("failed to get absolute path for executable: %v", err) return fmt.Errorf("failed to get absolute path for executable: %v", err)
@@ -63,30 +54,24 @@ func (s *WindowsService) CreateService(ctx context.Context, serviceName, execPat
} }
absWorkingDir = filepath.Clean(absWorkingDir) absWorkingDir = filepath.Clean(absWorkingDir)
// Log the paths being used
logging.Info("Creating service '%s' with:", serviceName) logging.Info("Creating service '%s' with:", serviceName)
logging.Info(" Executable: %s", absExecPath) logging.Info(" Executable: %s", absExecPath)
logging.Info(" Working Directory: %s", absWorkingDir) logging.Info(" Working Directory: %s", absWorkingDir)
// First remove any existing service with the same name
s.ExecuteNSSM(ctx, "remove", serviceName, "confirm") s.ExecuteNSSM(ctx, "remove", serviceName, "confirm")
// Install service
if _, err := s.ExecuteNSSM(ctx, "install", serviceName, absExecPath); err != nil { if _, err := s.ExecuteNSSM(ctx, "install", serviceName, absExecPath); err != nil {
return fmt.Errorf("failed to install service: %v", err) return fmt.Errorf("failed to install service: %v", err)
} }
// Set arguments if provided
if len(args) > 0 { if len(args) > 0 {
cmdArgs := append([]string{"set", serviceName, "AppParameters"}, args...) cmdArgs := append([]string{"set", serviceName, "AppParameters"}, args...)
if _, err := s.ExecuteNSSM(ctx, cmdArgs...); err != nil { if _, err := s.ExecuteNSSM(ctx, cmdArgs...); err != nil {
// Try to clean up on failure
s.ExecuteNSSM(ctx, "remove", serviceName, "confirm") s.ExecuteNSSM(ctx, "remove", serviceName, "confirm")
return fmt.Errorf("failed to set arguments: %v", err) return fmt.Errorf("failed to set arguments: %v", err)
} }
} }
// Verify service was created
if _, err := s.ExecuteNSSM(ctx, "get", serviceName, "Application"); err != nil { if _, err := s.ExecuteNSSM(ctx, "get", serviceName, "Application"); err != nil {
return fmt.Errorf("service creation verification failed: %v", err) return fmt.Errorf("service creation verification failed: %v", err)
} }
@@ -105,17 +90,13 @@ func (s *WindowsService) DeleteService(ctx context.Context, serviceName string)
} }
func (s *WindowsService) UpdateService(ctx context.Context, serviceName, execPath, workingDir string, args []string) error { func (s *WindowsService) UpdateService(ctx context.Context, serviceName, execPath, workingDir string, args []string) error {
// First remove the existing service
if err := s.DeleteService(ctx, serviceName); err != nil { if err := s.DeleteService(ctx, serviceName); err != nil {
return err return err
} }
// Then create it again with new parameters
return s.CreateService(ctx, serviceName, execPath, workingDir, args) return s.CreateService(ctx, serviceName, execPath, workingDir, args)
} }
// Service Control Methods
func (s *WindowsService) Status(ctx context.Context, serviceName string) (string, error) { func (s *WindowsService) Status(ctx context.Context, serviceName string) (string, error) {
return s.ExecuteNSSM(ctx, "status", serviceName) return s.ExecuteNSSM(ctx, "status", serviceName)
} }
@@ -129,11 +110,9 @@ func (s *WindowsService) Stop(ctx context.Context, serviceName string) (string,
} }
func (s *WindowsService) Restart(ctx context.Context, serviceName string) (string, error) { func (s *WindowsService) Restart(ctx context.Context, serviceName string) (string, error) {
// First stop the service
if _, err := s.Stop(ctx, serviceName); err != nil { if _, err := s.Stop(ctx, serviceName); err != nil {
return "", err return "", err
} }
// Then start it again
return s.Start(ctx, serviceName) return s.Start(ctx, serviceName)
} }

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

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

@@ -9,26 +9,22 @@ import (
"go.uber.org/dig" "go.uber.org/dig"
) )
// CacheItem represents an item in the cache
type CacheItem struct { type CacheItem struct {
Value interface{} Value interface{}
Expiration int64 Expiration int64
} }
// InMemoryCache is a thread-safe in-memory cache
type InMemoryCache struct { type InMemoryCache struct {
items map[string]CacheItem items map[string]CacheItem
mu sync.RWMutex mu sync.RWMutex
} }
// NewInMemoryCache creates and returns a new InMemoryCache instance
func NewInMemoryCache() *InMemoryCache { func NewInMemoryCache() *InMemoryCache {
return &InMemoryCache{ return &InMemoryCache{
items: make(map[string]CacheItem), items: make(map[string]CacheItem),
} }
} }
// Set adds an item to the cache with an expiration duration (in seconds)
func (c *InMemoryCache) Set(key string, value interface{}, duration time.Duration) { func (c *InMemoryCache) Set(key string, value interface{}, duration time.Duration) {
c.mu.Lock() c.mu.Lock()
defer c.mu.Unlock() defer c.mu.Unlock()
@@ -44,7 +40,6 @@ func (c *InMemoryCache) Set(key string, value interface{}, duration time.Duratio
} }
} }
// Get retrieves an item from the cache
func (c *InMemoryCache) Get(key string) (interface{}, bool) { func (c *InMemoryCache) Get(key string) (interface{}, bool) {
c.mu.RLock() c.mu.RLock()
defer c.mu.RUnlock() defer c.mu.RUnlock()
@@ -55,24 +50,18 @@ func (c *InMemoryCache) Get(key string) (interface{}, bool) {
} }
if item.Expiration > 0 && time.Now().UnixNano() > item.Expiration { if item.Expiration > 0 && time.Now().UnixNano() > item.Expiration {
// Item has expired, but don't delete here to avoid lock upgrade.
// It will be overwritten on the next Set.
return nil, false return nil, false
} }
return item.Value, true return item.Value, true
} }
// Delete removes an item from the cache
func (c *InMemoryCache) Delete(key string) { func (c *InMemoryCache) Delete(key string) {
c.mu.Lock() c.mu.Lock()
defer c.mu.Unlock() defer c.mu.Unlock()
delete(c.items, key) delete(c.items, key)
} }
// GetOrSet retrieves an item from the cache. If the item is not found, it
// calls the provided function to get the value, sets it in the cache, and
// returns it.
func GetOrSet[T any](c *InMemoryCache, key string, duration time.Duration, fetcher func() (T, error)) (T, error) { func GetOrSet[T any](c *InMemoryCache, key string, duration time.Duration, fetcher func() (T, error)) (T, error) {
if cached, found := c.Get(key); found { if cached, found := c.Get(key); found {
if value, ok := cached.(T); ok { if value, ok := cached.(T); ok {
@@ -90,7 +79,6 @@ func GetOrSet[T any](c *InMemoryCache, key string, duration time.Duration, fetch
return value, nil return value, nil
} }
// Start initializes the cache and provides it to the DI container.
func Start(di *dig.Container) { func Start(di *dig.Container) {
cache := NewInMemoryCache() cache := NewInMemoryCache()
err := di.Provide(func() *InMemoryCache { err := di.Provide(func() *InMemoryCache {

View File

@@ -0,0 +1,248 @@
package command
import (
"acc-server-manager/local/model"
"acc-server-manager/local/utl/logging"
"bufio"
"context"
"fmt"
"io"
"os/exec"
"strings"
"time"
"github.com/google/uuid"
)
type CallbackInteractiveCommandExecutor struct {
*InteractiveCommandExecutor
callbacks *CallbackConfig
serverID uuid.UUID
}
func NewCallbackInteractiveCommandExecutor(baseExecutor *CommandExecutor, tfaManager *model.Steam2FAManager, callbacks *CallbackConfig, serverID uuid.UUID) *CallbackInteractiveCommandExecutor {
if callbacks == nil {
callbacks = DefaultCallbackConfig()
}
return &CallbackInteractiveCommandExecutor{
InteractiveCommandExecutor: &InteractiveCommandExecutor{
CommandExecutor: baseExecutor,
tfaManager: tfaManager,
},
callbacks: callbacks,
serverID: serverID,
}
}
func (e *CallbackInteractiveCommandExecutor) ExecuteInteractive(ctx context.Context, serverID *uuid.UUID, args ...string) error {
cmd := exec.CommandContext(ctx, e.ExePath, args...)
if e.WorkDir != "" {
cmd.Dir = e.WorkDir
}
stdin, err := cmd.StdinPipe()
if err != nil {
return fmt.Errorf("failed to create stdin pipe: %v", err)
}
defer stdin.Close()
stdout, err := cmd.StdoutPipe()
if err != nil {
return fmt.Errorf("failed to create stdout pipe: %v", err)
}
defer stdout.Close()
stderr, err := cmd.StderrPipe()
if err != nil {
return fmt.Errorf("failed to create stderr pipe: %v", err)
}
defer stderr.Close()
logging.Info("Executing interactive command with callbacks: %s %s", e.ExePath, strings.Join(args, " "))
e.callbacks.OnCommand(e.serverID, e.ExePath, args, false, false, "")
e.callbacks.OnOutput(e.serverID, fmt.Sprintf("Starting command: %s %s", e.ExePath, strings.Join(args, " ")), false)
if err := cmd.Start(); err != nil {
e.callbacks.OnOutput(e.serverID, fmt.Sprintf("Failed to start command: %v", err), true)
return fmt.Errorf("failed to start command: %v", err)
}
outputDone := make(chan error, 1)
cmdDone := make(chan error, 1)
go e.monitorOutputWithCallbacks(ctx, stdout, stderr, serverID, outputDone)
go func() {
cmdDone <- cmd.Wait()
}()
var cmdErr, outputErr error
completedCount := 0
for completedCount < 2 {
select {
case cmdErr = <-cmdDone:
completedCount++
logging.Info("Command execution completed")
e.callbacks.OnOutput(e.serverID, "Command execution completed", false)
case outputErr = <-outputDone:
completedCount++
logging.Info("Output monitoring completed")
case <-ctx.Done():
e.callbacks.OnOutput(e.serverID, "Command execution cancelled", true)
return ctx.Err()
}
}
if outputErr != nil {
logging.Warn("Output monitoring error: %v", outputErr)
e.callbacks.OnOutput(e.serverID, fmt.Sprintf("Output monitoring error: %v", outputErr), true)
}
success := cmdErr == nil
errorMsg := ""
if cmdErr != nil {
errorMsg = cmdErr.Error()
}
e.callbacks.OnCommand(e.serverID, e.ExePath, args, true, success, errorMsg)
return cmdErr
}
func (e *CallbackInteractiveCommandExecutor) monitorOutputWithCallbacks(ctx context.Context, stdout, stderr io.Reader, serverID *uuid.UUID, done chan error) {
defer func() {
select {
case done <- nil:
default:
}
}()
stdoutScanner := bufio.NewScanner(stdout)
stderrScanner := bufio.NewScanner(stderr)
outputChan := make(chan outputLine, 10000)
readersDone := make(chan struct{}, 2)
steamConsoleStarted := false
tfaRequestCreated := false
tfaAutoCompleted := false
go func() {
defer func() { readersDone <- struct{}{} }()
for stdoutScanner.Scan() {
line := stdoutScanner.Text()
if e.LogOutput {
logging.Info("STDOUT: %s", line)
}
e.callbacks.OnOutput(e.serverID, line, false)
select {
case outputChan <- outputLine{text: line, isError: false}:
case <-ctx.Done():
return
}
}
if err := stdoutScanner.Err(); err != nil {
logging.Warn("Stdout scanner error: %v", err)
e.callbacks.OnOutput(e.serverID, fmt.Sprintf("Stdout scanner error: %v", err), true)
}
}()
go func() {
defer func() { readersDone <- struct{}{} }()
for stderrScanner.Scan() {
line := stderrScanner.Text()
if e.LogOutput {
logging.Info("STDERR: %s", line)
}
e.callbacks.OnOutput(e.serverID, line, true)
select {
case outputChan <- outputLine{text: line, isError: true}:
case <-ctx.Done():
return
}
}
if err := stderrScanner.Err(); err != nil {
logging.Warn("Stderr scanner error: %v", err)
e.callbacks.OnOutput(e.serverID, fmt.Sprintf("Stderr scanner error: %v", err), true)
}
}()
readersFinished := 0
for {
select {
case <-ctx.Done():
done <- ctx.Err()
return
case <-readersDone:
readersFinished++
if readersFinished == 2 {
close(outputChan)
for lineData := range outputChan {
if e.is2FAPrompt(lineData.text) && !tfaRequestCreated {
tfaRequestCreated = true
go func(prompt string) {
if err := e.handle2FAPrompt(ctx, prompt, serverID); err != nil {
logging.Error("Failed to handle 2FA prompt: %v", err)
e.callbacks.OnOutput(e.serverID, fmt.Sprintf("Failed to handle 2FA prompt: %v", err), true)
}
}(lineData.text)
}
}
return
}
case lineData, ok := <-outputChan:
if !ok {
return
}
lowerLine := strings.ToLower(lineData.text)
if strings.Contains(lowerLine, "steam console client") && strings.Contains(lowerLine, "valve corporation") {
steamConsoleStarted = true
logging.Info("Steam Console Client startup detected - will monitor for 2FA hang")
e.callbacks.OnOutput(e.serverID, "Steam Console Client startup detected", false)
}
if e.is2FAPrompt(lineData.text) {
if !tfaRequestCreated {
tfaRequestCreated = true
e.callbacks.OnOutput(e.serverID, "2FA prompt detected - waiting for user confirmation", false)
go func(prompt string) {
if err := e.handle2FAPrompt(ctx, prompt, serverID); err != nil {
logging.Error("Failed to handle 2FA prompt: %v", err)
e.callbacks.OnOutput(e.serverID, fmt.Sprintf("Failed to handle 2FA prompt: %v", err), true)
}
}(lineData.text)
}
}
if tfaRequestCreated && !tfaAutoCompleted && e.isSteamContinuing(lineData.text) {
logging.Info("Steam CMD appears to have continued after 2FA confirmation")
tfaAutoCompleted = true
e.callbacks.OnOutput(e.serverID, "Steam CMD continued after 2FA confirmation", false)
e.autoCompletePendingRequests(serverID)
}
case <-time.After(15 * time.Second):
if steamConsoleStarted && !tfaRequestCreated {
tfaRequestCreated = true
logging.Info("Steam Console started but no output for 15 seconds - likely waiting for Steam Guard 2FA")
e.callbacks.OnOutput(e.serverID, "Waiting for Steam Guard 2FA confirmation...", false)
go func() {
if err := e.handle2FAPrompt(ctx, "Steam CMD appears to be waiting for Steam Guard confirmation after startup", serverID); err != nil {
logging.Error("Failed to handle Steam Guard 2FA prompt: %v", err)
e.callbacks.OnOutput(e.serverID, fmt.Sprintf("Failed to handle Steam Guard 2FA prompt: %v", err), true)
}
}()
}
}
}
}
type outputLine struct {
text string
isError bool
}

19
local/utl/command/callbacks.go Executable file
View File

@@ -0,0 +1,19 @@
package command
import "github.com/google/uuid"
type OutputCallback func(serverID uuid.UUID, output string, isError bool)
type CommandCallback func(serverID uuid.UUID, command string, args []string, completed bool, success bool, error string)
type CallbackConfig struct {
OnOutput OutputCallback
OnCommand CommandCallback
}
func DefaultCallbackConfig() *CallbackConfig {
return &CallbackConfig{
OnOutput: func(uuid.UUID, string, bool) {},
OnCommand: func(uuid.UUID, string, []string, bool, bool, string) {},
}
}

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

@@ -8,17 +8,12 @@ import (
"strings" "strings"
) )
// CommandExecutor provides a base structure for executing commands
type CommandExecutor struct { type CommandExecutor struct {
// Base executable path ExePath string
ExePath string WorkDir string
// Working directory for commands
WorkDir string
// Whether to capture and log output
LogOutput bool LogOutput bool
} }
// CommandBuilder helps build command arguments
type CommandBuilder struct { type CommandBuilder struct {
args []string args []string
} }
@@ -48,7 +43,6 @@ func (b *CommandBuilder) Build() []string {
return b.args return b.args
} }
// Execute runs a command with the given arguments
func (e *CommandExecutor) Execute(args ...string) error { func (e *CommandExecutor) Execute(args ...string) error {
cmd := exec.Command(e.ExePath, args...) cmd := exec.Command(e.ExePath, args...)
@@ -65,12 +59,10 @@ func (e *CommandExecutor) Execute(args ...string) error {
return cmd.Run() return cmd.Run()
} }
// ExecuteWithBuilder runs a command using a CommandBuilder
func (e *CommandExecutor) ExecuteWithBuilder(builder *CommandBuilder) error { func (e *CommandExecutor) ExecuteWithBuilder(builder *CommandBuilder) error {
return e.Execute(builder.Build()...) return e.Execute(builder.Build()...)
} }
// ExecuteWithOutput runs a command and returns its output
func (e *CommandExecutor) ExecuteWithOutput(args ...string) (string, error) { func (e *CommandExecutor) ExecuteWithOutput(args ...string) (string, error) {
cmd := exec.Command(e.ExePath, args...) cmd := exec.Command(e.ExePath, args...)
@@ -83,7 +75,6 @@ func (e *CommandExecutor) ExecuteWithOutput(args ...string) (string, error) {
return string(output), err return string(output), err
} }
// ExecuteWithEnv runs a command with custom environment variables
func (e *CommandExecutor) ExecuteWithEnv(env []string, args ...string) error { func (e *CommandExecutor) ExecuteWithEnv(env []string, args ...string) error {
cmd := exec.Command(e.ExePath, args...) cmd := exec.Command(e.ExePath, args...)

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