Compare commits

...

29 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
Fran Jurmanović
4ab94de529 resolve test failing
All checks were successful
Release and Deploy / build (push) Successful in 2m12s
Release and Deploy / deploy (push) Successful in 27s
2025-09-14 22:05:25 +02:00
Fran Jurmanović
b3f89593fb replace id type
Some checks failed
Release and Deploy / build (push) Failing after 2m7s
Release and Deploy / deploy (push) Has been skipped
2025-09-14 17:08:50 +02:00
Fran Jurmanović
2a863c51e9 update state history query
All checks were successful
Release and Deploy / build (push) Successful in 3m18s
Release and Deploy / deploy (push) Successful in 27s
2025-09-13 14:41:52 +02:00
Fran Jurmanović
a70d923a6a revert to powershell executable 2025-08-17 16:53:44 +02:00
Fran Jurmanović
f660511b63 change steamCMD executor
All checks were successful
Release and Deploy / build (push) Successful in 2m1s
Release and Deploy / deploy (push) Successful in 23s
2025-08-17 16:42:10 +02:00
Fran Jurmanović
044af60699 steam-crypt app and fix the interactive executor
All checks were successful
Release and Deploy / build (push) Successful in 2m2s
Release and Deploy / deploy (push) Successful in 22s
2025-08-17 16:26:28 +02:00
Fran Jurmanović
384036bcdd remove blocker pattern
All checks were successful
Release and Deploy / build (push) Successful in 2m5s
Release and Deploy / deploy (push) Successful in 26s
2025-08-17 15:53:55 +02:00
Fran Jurmanović
ef300d233b fix wrong userID from context
All checks were successful
Release and Deploy / build (push) Successful in 3m26s
Release and Deploy / deploy (push) Successful in 24s
2025-08-17 13:12:36 +02:00
Fran Jurmanović
edad65d6a9 generate open token using normal token
All checks were successful
Release and Deploy / build (push) Successful in 3m1s
Release and Deploy / deploy (push) Successful in 23s
2025-08-17 12:46:37 +02:00
152 changed files with 4647 additions and 2414 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!
# =============================================================================
# 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)
# =============================================================================

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

93
cmd/steam-crypt/main.go Executable file
View File

@@ -0,0 +1,93 @@
package main
import (
"acc-server-manager/local/model"
"acc-server-manager/local/utl/configs"
"flag"
"fmt"
"os"
)
func main() {
var (
encrypt = flag.Bool("encrypt", false, "Encrypt a password")
decrypt = flag.Bool("decrypt", false, "Decrypt a password")
password = flag.String("password", "", "Password to encrypt/decrypt")
help = flag.Bool("help", false, "Show help")
)
flag.Parse()
if *help || (!*encrypt && !*decrypt) {
showHelp()
return
}
if *encrypt && *decrypt {
fmt.Fprintf(os.Stderr, "Error: Cannot specify both -encrypt and -decrypt\n")
os.Exit(1)
}
if *password == "" {
fmt.Fprintf(os.Stderr, "Error: Password is required\n")
showHelp()
os.Exit(1)
}
// Initialize configs to load encryption key
configs.Init()
if *encrypt {
encrypted, err := model.EncryptPassword(*password)
if err != nil {
fmt.Fprintf(os.Stderr, "Error encrypting password: %v\n", err)
os.Exit(1)
}
fmt.Println(encrypted)
}
if *decrypt {
decrypted, err := model.DecryptPassword(*password)
if err != nil {
fmt.Fprintf(os.Stderr, "Error decrypting password: %v\n", err)
os.Exit(1)
}
fmt.Println(decrypted)
}
}
func showHelp() {
fmt.Println("Steam Credentials Encryption/Decryption Utility")
fmt.Println()
fmt.Println("This utility encrypts and decrypts Steam credentials using the same")
fmt.Println("AES-256-GCM encryption used by the ACC Server Manager application.")
fmt.Println()
fmt.Println("Usage:")
fmt.Println(" steam-crypt -encrypt -password \"your_password\"")
fmt.Println(" steam-crypt -decrypt -password \"encrypted_string\"")
fmt.Println()
fmt.Println("Options:")
fmt.Println(" -encrypt Encrypt the provided password")
fmt.Println(" -decrypt Decrypt the provided encrypted string")
fmt.Println(" -password The password to encrypt or encrypted string to decrypt")
fmt.Println(" -help Show this help message")
fmt.Println()
fmt.Println("Environment Variables Required:")
fmt.Println(" ENCRYPTION_KEY - 32-byte encryption key (same as main application)")
fmt.Println(" APP_SECRET - Application secret (required by configs)")
fmt.Println(" APP_SECRET_CODE - Application secret code (required by configs)")
fmt.Println(" ACCESS_KEY - Access key (required by configs)")
fmt.Println()
fmt.Println("Examples:")
fmt.Println(" # Encrypt a password")
fmt.Println(" steam-crypt -encrypt -password \"mysteampassword\"")
fmt.Println()
fmt.Println(" # Decrypt an encrypted password")
fmt.Println(" steam-crypt -decrypt -password \"base64encryptedstring\"")
fmt.Println()
fmt.Println("Security Notes:")
fmt.Println(" - The encryption key must be exactly 32 bytes for AES-256")
fmt.Println(" - Uses AES-256-GCM for authenticated encryption")
fmt.Println(" - Each encryption includes a unique nonce for security")
fmt.Println(" - Passwords are validated for length and basic security")
}

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
go 1.23.0
go 1.25.0
require (
github.com/docker/docker v24.0.9+incompatible
github.com/docker/go-connections v0.6.0
github.com/gofiber/fiber/v2 v2.52.8
github.com/gofiber/swagger v1.1.0
github.com/gofiber/websocket/v2 v2.2.1
github.com/golang-jwt/jwt/v4 v4.5.2
github.com/google/uuid v1.6.0
github.com/joho/godotenv v1.5.1
github.com/qjebbs/go-jsons v0.0.0-20221222033332-a534c5fc1c4c
github.com/swaggo/swag v1.16.3
github.com/valyala/fasthttp v1.51.0
go.uber.org/dig v1.17.1
golang.org/x/crypto v0.39.0
golang.org/x/sync v0.15.0
@@ -20,11 +24,17 @@ require (
require (
github.com/KyleBanks/depth v1.2.1 // indirect
github.com/Microsoft/go-winio v0.4.21 // indirect
github.com/andybalholm/brotli v1.1.0 // indirect
github.com/docker/distribution v2.7.1+incompatible // indirect
github.com/docker/go-units v0.5.0 // indirect
github.com/fasthttp/websocket v1.5.3 // indirect
github.com/go-openapi/jsonpointer v0.21.0 // indirect
github.com/go-openapi/jsonreference v0.21.0 // indirect
github.com/go-openapi/spec v0.21.0 // indirect
github.com/go-openapi/swag v0.23.0 // indirect
github.com/gogo/protobuf v1.3.2 // indirect
github.com/google/go-cmp v0.7.0 // indirect
github.com/jinzhu/inflection v1.0.0 // indirect
github.com/jinzhu/now v1.1.5 // indirect
github.com/josharian/intern v1.0.0 // indirect
@@ -34,12 +44,21 @@ require (
github.com/mattn/go-isatty v0.0.20 // indirect
github.com/mattn/go-runewidth v0.0.16 // indirect
github.com/mattn/go-sqlite3 v1.14.22 // indirect
github.com/moby/term v0.5.2 // indirect
github.com/morikuni/aec v1.1.0 // indirect
github.com/opencontainers/go-digest v1.0.0 // indirect
github.com/opencontainers/image-spec v1.1.1 // indirect
github.com/pkg/errors v0.9.1 // indirect
github.com/rivo/uniseg v0.2.0 // indirect
github.com/rogpeppe/go-internal v1.14.1 // indirect
github.com/savsgio/gotils v0.0.0-20230208104028-c358bd845dee // indirect
github.com/stretchr/testify v1.11.1 // indirect
github.com/swaggo/files/v2 v2.0.0 // indirect
github.com/valyala/bytebufferpool v1.0.0 // indirect
github.com/valyala/fasthttp v1.51.0 // indirect
github.com/valyala/tcplisten v1.0.0 // indirect
golang.org/x/sys v0.33.0 // indirect
golang.org/x/sys v0.41.0 // indirect
golang.org/x/time v0.9.0 // indirect
golang.org/x/tools v0.33.0 // indirect
gopkg.in/yaml.v3 v3.0.1 // indirect
gotest.tools/v3 v3.5.2 // indirect
)

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/go.mod h1:jzSb9d0L43HxTQfT+oSA1EEp2q+ne2uh6XgeJcm8brE=
github.com/Microsoft/go-winio v0.4.21 h1:+6mVbXh4wPzUrl1COX9A+ZCvEpYsOBZ6/+kwDnvLyro=
github.com/Microsoft/go-winio v0.4.21/go.mod h1:JPGBdM1cNvN/6ISo+n8V5iA4v8pBzdOpzfwIujj1a84=
github.com/andybalholm/brotli v1.1.0 h1:eLKJA0d02Lf0mVpIDgYnqXcUn0GqVmEFny3VuID1U3M=
github.com/andybalholm/brotli v1.1.0/go.mod h1:sms7XGricyQI9K10gOSf56VKKWS4oLer58Q+mhRPtnY=
github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c=
github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
github.com/docker/distribution v2.7.1+incompatible h1:a5mlkVzth6W5A4fOsS3D2EO5BUmsJpcB+cRlLU7cSug=
github.com/docker/distribution v2.7.1+incompatible/go.mod h1:J2gT2udsDAN96Uj4KfcMRqY0/ypR+oyYUYmja8H+y+w=
github.com/docker/docker v24.0.9+incompatible h1:HPGzNmwfLZWdxHqK9/II92pyi1EpYKsAqcl4G0Of9v0=
github.com/docker/docker v24.0.9+incompatible/go.mod h1:eEKB0N0r5NX/I1kEveEz05bcu8tLC/8azJZsviup8Sk=
github.com/docker/go-connections v0.6.0 h1:LlMG9azAe1TqfR7sO+NJttz1gy6KO7VJBh+pMmjSD94=
github.com/docker/go-connections v0.6.0/go.mod h1:AahvXYshr6JgfUJGdDCs2b5EZG/vmaMAntpSFH5BFKE=
github.com/docker/go-units v0.5.0 h1:69rxXcBk27SvSaaxTtLh/8llcHD8vYHT7WSdRZ/jvr4=
github.com/docker/go-units v0.5.0/go.mod h1:fgPhTUdO+D/Jk86RDLlptpiXQzgHJF7gydDDbaIK4Dk=
github.com/fasthttp/websocket v1.5.3 h1:TPpQuLwJYfd4LJPXvHDYPMFWbLjsT91n3GpWtCQtdek=
github.com/fasthttp/websocket v1.5.3/go.mod h1:46gg/UBmTU1kUaTcwQXpUxtRwG2PvIZYeA8oL6vF3Fs=
github.com/go-openapi/jsonpointer v0.21.0 h1:YgdVicSA9vH5RiHs9TZW5oyafXZFc6+2Vc1rr/O9oNQ=
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=
@@ -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/swagger v1.1.0 h1:ff3rg1fB+Rp5JN/N8jfxTiZtMKe/9tB9QDc79fPiJKQ=
github.com/gofiber/swagger v1.1.0/go.mod h1:pRZL0Np35sd+lTODTE5The0G+TMHfNY+oC4hM2/i5m8=
github.com/gofiber/websocket/v2 v2.2.1 h1:C9cjxvloojayOp9AovmpQrk8VqvVnT8Oao3+IUygH7w=
github.com/gofiber/websocket/v2 v2.2.1/go.mod h1:Ao/+nyNnX5u/hIFPuHl28a+NIkrqK7PRimyKaj4JxVU=
github.com/gogo/protobuf v1.3.2 h1:Ov1cvc58UF3b5XjBnZv7+opcTcQFZebYjWzi34vdm4Q=
github.com/gogo/protobuf v1.3.2/go.mod h1:P1XiOD3dCwIKUDQYPy72D8LYyHL2YPYrpS2s69NZV8Q=
github.com/golang-jwt/jwt/v4 v4.5.2 h1:YtQM7lnr8iZ+j5q71MGKkNw9Mn7AjHM68uc9g5fXeUI=
github.com/golang-jwt/jwt/v4 v4.5.2/go.mod h1:m21LjoU+eqJr34lmDMbreY2eSTRJ1cv77w39/MY0Ch0=
github.com/google/go-cmp v0.7.0 h1:wk8382ETsv4JYUZwIsn6YpYiWiBsYLSJiTsyBybVuN8=
github.com/google/go-cmp v0.7.0/go.mod h1:pXiqmnSA92OHEEa9HXL2W4E7lf9JzCmGVUdgjX3N/iU=
github.com/google/uuid v1.6.0 h1:NIvaJDMOsjHA8n1jAhLSgzrAzy1Hgr+hNrb57e+94F0=
github.com/google/uuid v1.6.0/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo=
github.com/jinzhu/inflection v1.0.0 h1:K317FqzuhWc8YvSVlFMCCUb36O/S9MCKRDI7QkRKD/E=
@@ -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/josharian/intern v1.0.0 h1:vlS4z54oSdjm0bgjRigI+G1HpF+tI+9rE5LLzOg8HmY=
github.com/josharian/intern v1.0.0/go.mod h1:5DoeVV0s6jJacbCEi61lwdGj/aVlrQvzHFFd8Hwg//Y=
github.com/kisielk/errcheck v1.5.0/go.mod h1:pFxgyoBC7bSaBwPgfKdkLd5X25qrDl4LWUI2bnpBCr8=
github.com/kisielk/gotool v1.0.0/go.mod h1:XhKaO+MFFWcvkIS/tQcRk01m1F5IRFswLeQ+oQHNcck=
github.com/klauspost/compress v1.17.9 h1:6KIumPrER1LHsvBVuDa0r5xaG0Es51mhhB9BQB2qeMA=
github.com/klauspost/compress v1.17.9/go.mod h1:Di0epgTjJY877eYKx5yC51cX2A2Vl2ibi7bDH9ttBbw=
github.com/kr/pretty v0.3.1 h1:flRD4NNwYAUpkphVc1HcthR4KEIFJ65n8Mw5qdRn3LE=
@@ -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-sqlite3 v1.14.22 h1:2gZY6PC6kBnID23Tichd1K+Z0oS6nE/XwU+Vz/5o4kU=
github.com/mattn/go-sqlite3 v1.14.22/go.mod h1:Uh1q+B4BYcTPb+yiD3kU8Ct7aC0hY9fxUwlHK0RXw+Y=
github.com/moby/term v0.5.2 h1:6qk3FJAFDs6i/q3W/pQ97SX192qKfZgGjCQqfCJkgzQ=
github.com/moby/term v0.5.2/go.mod h1:d3djjFCrjnB+fl8NJux+EJzu0msscUP+f8it8hPkFLc=
github.com/morikuni/aec v1.1.0 h1:vBBl0pUnvi/Je71dsRrhMBtreIqNMYErSAbEeb8jrXQ=
github.com/morikuni/aec v1.1.0/go.mod h1:xDRgiq/iw5l+zkao76YTKzKttOp2cwPEne25HDkJnBw=
github.com/opencontainers/go-digest v1.0.0 h1:apOUWs51W5PlhuyGyz9FCeeBIOUDA/6nW8Oi/yOhh5U=
github.com/opencontainers/go-digest v1.0.0/go.mod h1:0JzlMkj0TRzQZfJkVvzbP0HBR3IKzErnv2BNG4W4MAM=
github.com/opencontainers/image-spec v1.1.1 h1:y0fUlFfIZhPF1W537XOLg0/fcx6zcHCJwooC2xJA040=
github.com/opencontainers/image-spec v1.1.1/go.mod h1:qpqAh3Dmcf36wStyyWU+kCeDgrGnAve2nCC8+7h8Q0M=
github.com/pkg/errors v0.9.1 h1:FEBLx1zS214owpjy7qsBeixbURkuhQAwrK5UwLGTwt4=
github.com/pkg/errors v0.9.1/go.mod h1:bwawxfHBFNV+L2hUp1rHADufV3IMtnDRdf1r5NINEl0=
github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM=
github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4=
github.com/qjebbs/go-jsons v0.0.0-20221222033332-a534c5fc1c4c h1:kmzxiX+OB0knCo1V0dkEkdPelzCdAzCURCfmFArn2/A=
github.com/qjebbs/go-jsons v0.0.0-20221222033332-a534c5fc1c4c/go.mod h1:wNJrtinHyC3YSf6giEh4FJN8+yZV7nXBjvmfjhBIcw4=
github.com/rivo/uniseg v0.2.0 h1:S1pD9weZBuJdFmowNwbpi7BJ8TNftyUImj/0WQi72jY=
github.com/rivo/uniseg v0.2.0/go.mod h1:J6wj4VEh+S6ZtnVlnTBMWIodfgj8LQOQFoIToxlJtxc=
github.com/rogpeppe/go-internal v1.11.0 h1:cWPaGQEPrBb5/AsnsZesgZZ9yb1OQ+GOISoDNXVBh4M=
github.com/rogpeppe/go-internal v1.11.0/go.mod h1:ddIwULY96R17DhadqLgMfk9H9tvdUzkipdSkR5nkCZA=
github.com/stretchr/testify v1.9.0 h1:HtqpIVDClZ4nwg75+f6Lvsy/wHu+3BoSGCbBAcpTsTg=
github.com/stretchr/testify v1.9.0/go.mod h1:r2ic/lqez/lEtzL7wO/rwa5dbSLXVDPFyf8C91i36aY=
github.com/rogpeppe/go-internal v1.14.1 h1:UQB4HGPB6osV0SQTLymcB4TgvyWu6ZyliaW0tI/otEQ=
github.com/rogpeppe/go-internal v1.14.1/go.mod h1:MaRKkUm5W0goXpeCfT7UZI6fk/L7L7so1lCWt35ZSgc=
github.com/savsgio/gotils v0.0.0-20230208104028-c358bd845dee h1:8Iv5m6xEo1NR1AvpV+7XmhI4r39LGNzwUL4YpMuL5vk=
github.com/savsgio/gotils v0.0.0-20230208104028-c358bd845dee/go.mod h1:qwtSXrKuJh/zsFQ12yEE89xfCrGKK63Rr7ctU/uCo4g=
github.com/sirupsen/logrus v1.7.0 h1:ShrD1U9pZB12TX0cVy0DtePoCH97K8EtX+mg7ZARUtM=
github.com/sirupsen/logrus v1.7.0/go.mod h1:yWOB1SBYBC5VeMP7gHvWumXLIWorT60ONWic61uBYv0=
github.com/stretchr/testify v1.2.2/go.mod h1:a8OnRcib4nhh0OaRAV+Yts87kKdq0PP7pXfy6kDkUVs=
github.com/stretchr/testify v1.11.1 h1:7s2iGBzp5EwR7/aIZr8ao5+dra3wiQyKjjFuvgVKu7U=
github.com/stretchr/testify v1.11.1/go.mod h1:wZwfW3scLgRK+23gO65QZefKpKQRnfz6sD981Nm4B6U=
github.com/swaggo/files/v2 v2.0.0 h1:hmAt8Dkynw7Ssz46F6pn8ok6YmGZqHSVLZ+HQM7i0kw=
github.com/swaggo/files/v2 v2.0.0/go.mod h1:24kk2Y9NYEJ5lHuCra6iVwkMjIekMCaFq/0JQj66kyM=
github.com/swaggo/swag v1.16.3 h1:PnCYjPCah8FK4I26l2F/KQ4yz3sILcVUN3cTlBFA9Pg=
@@ -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/tcplisten v1.0.0 h1:rBHj/Xf+E1tRGZyWIWwJDiRY0zc1Js+CV5DqwacVSA8=
github.com/valyala/tcplisten v1.0.0/go.mod h1:T0xQ8SeCZGxckz9qRXTfG43PvQ/mcWh7FwZEA7Ioqkc=
github.com/yuin/goldmark v1.1.27/go.mod h1:3hX8gzYuyVAZsxl0MRgGTJEmQBFcNTphYh9decYSb74=
github.com/yuin/goldmark v1.2.1/go.mod h1:3hX8gzYuyVAZsxl0MRgGTJEmQBFcNTphYh9decYSb74=
go.uber.org/dig v1.17.1 h1:Tga8Lz8PcYNsWsyHMZ1Vm0OQOUaJNDyvPImgbAu9YSc=
go.uber.org/dig v1.17.1/go.mod h1:Us0rSJiThwCv2GteUN0Q7OKvU7n5J4dxZ9JKUXozFdE=
golang.org/x/crypto v0.0.0-20190308221718-c2843e01d9a2/go.mod h1:djNgcEr1/C05ACkg1iLfiJU5Ep61QUkGW8qpdssI0+w=
golang.org/x/crypto v0.0.0-20191011191535-87dc89f01550/go.mod h1:yigFU9vqHzYiE8UmvKecakEJjdnWj3jj499lnFckfCI=
golang.org/x/crypto v0.0.0-20200622213623-75b288015ac9/go.mod h1:LzIPMQfyMNhhGPhUkYOs5KpL4U8rLKemX1yGLhDgUto=
golang.org/x/crypto v0.39.0 h1:SHs+kF4LP+f+p14esP5jAoDpHU8Gu/v9lFRK6IT5imM=
golang.org/x/crypto v0.39.0/go.mod h1:L+Xg3Wf6HoL4Bn4238Z6ft6KfEpN0tJGo53AAPC632U=
golang.org/x/mod v0.2.0/go.mod h1:s0Qsj1ACt9ePp/hMypM3fl4fZqREWJwdYDEqhRiZZUA=
golang.org/x/mod v0.3.0/go.mod h1:s0Qsj1ACt9ePp/hMypM3fl4fZqREWJwdYDEqhRiZZUA=
golang.org/x/mod v0.25.0 h1:n7a+ZbQKQA/Ysbyb0/6IbB1H/X41mKgbhfv7AfG/44w=
golang.org/x/mod v0.25.0/go.mod h1:IXM97Txy2VM4PJ3gI61r1YEk/gAj6zAHN3AdZt6S9Ww=
golang.org/x/net v0.0.0-20190404232315-eb5bcb51f2a3/go.mod h1:t9HGtf8HONx5eT2rtn7q6eTqICYqUVnKs3thJo3Qplg=
golang.org/x/net v0.0.0-20190620200207-3b0461eec859/go.mod h1:z5CRVTTTmAJ677TzLLGU+0bjPO0LkuOLi4/5GtJWs/s=
golang.org/x/net v0.0.0-20200226121028-0de0cce0169b/go.mod h1:z5CRVTTTmAJ677TzLLGU+0bjPO0LkuOLi4/5GtJWs/s=
golang.org/x/net v0.0.0-20201021035429-f5854403a974/go.mod h1:sp8m0HH+o8qH0wwXwYZr8TS3Oi6o0r6Gce1SSxlDquU=
golang.org/x/sync v0.0.0-20190423024810-112230192c58/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
golang.org/x/sync v0.0.0-20190911185100-cd5d95a43a6e/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
golang.org/x/sync v0.0.0-20201020160332-67f06af15bc9/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
golang.org/x/sync v0.15.0 h1:KWH3jNZsfyT6xfAfKiz6MRNmd46ByHDYaZ7KSkCtdW8=
golang.org/x/sync v0.15.0/go.mod h1:1dzgHSNfp02xaA81J2MS99Qcpr2w7fw1gpm99rleRqA=
golang.org/x/sys v0.0.0-20190215142949-d0b11bdaac8a/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY=
golang.org/x/sys v0.0.0-20190412213103-97732733099d/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
golang.org/x/sys v0.0.0-20191026070338-33540a1f6037/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
golang.org/x/sys v0.0.0-20200930185726-fdedc70b468f/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
golang.org/x/sys v0.0.0-20210124154548-22da62e12c0c/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
golang.org/x/sys v0.0.0-20220811171246-fbc7d0a398ab/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.6.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.33.0 h1:q3i8TbbEz+JRD9ywIRlyRAQbM0qF7hu24q3teo2hbuw=
golang.org/x/sys v0.33.0/go.mod h1:BJP2sWEmIv4KK5OTEluFJCKSidICx8ciO85XgH3Ak8k=
golang.org/x/sys v0.41.0 h1:Ivj+2Cp/ylzLiEU89QhWblYnOE9zerudt9Ftecq2C6k=
golang.org/x/sys v0.41.0/go.mod h1:OgkHotnGiDImocRcuBABYBEXf8A9a87e/uXjp9XT3ks=
golang.org/x/text v0.3.0/go.mod h1:NqM8EUOU14njkJ3fqMW+pc6Ldnwhi/IjpwHt7yyuwOQ=
golang.org/x/text v0.3.3/go.mod h1:5Zoc/QRtKVWzQhOtBMvqHzDpF6irO9z98xDceosuGiQ=
golang.org/x/text v0.26.0 h1:P42AVeLghgTYr4+xUnTRKDMqpar+PtX7KWuNQL21L8M=
golang.org/x/text v0.26.0/go.mod h1:QK15LZJUUQVJxhz7wXgxSy/CJaTFjd0G+YLonydOVQA=
golang.org/x/time v0.9.0 h1:EsRrnYcQiGH+5FfbgvV4AP7qEZstoyrHB0DzarOQ4ZY=
golang.org/x/time v0.9.0/go.mod h1:3BpzKBy/shNhVucY/MWOyx10tF3SFh9QdLuxbVysPQM=
golang.org/x/tools v0.0.0-20180917221912-90fa682c2a6e/go.mod h1:n7NCudcB/nEzxVGmLbDWY5pfWTLqBcC2KZ6jyYvM4mQ=
golang.org/x/tools v0.0.0-20191119224855-298f0cb1881e/go.mod h1:b+2E5dAYhXwXZwtnZ6UAqBI28+e2cm9otk0dWdXHAEo=
golang.org/x/tools v0.0.0-20200619180055-7c47624df98f/go.mod h1:EkVYQZoAsY45+roYkvgYkIh4xh/qjgUK9TdY2XT94GE=
golang.org/x/tools v0.0.0-20210106214847-113979e3529a/go.mod h1:emZCQorbCU4vsT4fOWvOPXz4eW1wZW4PmDk9uLelYpA=
golang.org/x/tools v0.33.0 h1:4qz2S3zmRxbGIhDIAgjxvFutSvH5EfnsYrRBj0UI0bc=
golang.org/x/tools v0.33.0/go.mod h1:CIJMaWEY88juyUfo7UbgPqbC8rU2OqfAV1h2Qp0oMYI=
golang.org/x/xerrors v0.0.0-20190717185122-a985d3407aa7/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0=
golang.org/x/xerrors v0.0.0-20191011141410-1b5146add898/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0=
golang.org/x/xerrors v0.0.0-20191204190536-9bdfabe68543/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0=
golang.org/x/xerrors v0.0.0-20200804184101-5ec99f83aff1/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0=
gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0=
gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c h1:Hei/4ADfdWqJk1ZMxUNpqntNwaWcugrBjAiHlqqRiVk=
gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c/go.mod h1:JHkPIbrfpd72SG/EVd6muEfDQjcINNoR0C8j2r3qZ4Q=
@@ -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/gorm v1.25.11 h1:/Wfyg1B/je1hnDx3sMkX+gAlxrlZpn6X0BXRlwXlvHg=
gorm.io/gorm v1.25.11/go.mod h1:xh7N7RHfYlNc5EmcI/El95gXusucDrQnHXe0+CgWcLQ=
gotest.tools/v3 v3.5.2 h1:7koQfIKdy+I8UTetycgUqXWSDwpgv193Ka+qRsmBY8Q=
gotest.tools/v3 v3.5.2/go.mod h1:LtdLGcnqToBH83WByAAi/wiwSFCArdFIUV/xxN4pcjA=

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"),
Membership: groups.Group("/membership"),
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()

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 (
"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"
@@ -34,6 +35,7 @@ func NewConfigController(as *service.ConfigService, routeGroups *common.RouteGro
configGroup := routeGroups.Config
configGroup.Use(auth.Authenticate)
configGroup.Patch("/", ac.UpdateAllConfigs)
configGroup.Put("/:file", ac.UpdateConfig)
configGroup.Get("/:file", ac.GetConfig)
configGroup.Get("/", ac.GetConfigs)
@@ -41,6 +43,54 @@ func NewConfigController(as *service.ConfigService, routeGroups *common.RouteGro
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
//
// @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")
}
err = c.Invoke(NewSteam2FAController)
err = c.Invoke(NewWebSocketController)
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

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

@@ -95,7 +95,7 @@ func (c *MembershipController) Login(ctx *fiber.Ctx) error {
// @Failure 500 {object} error_handler.ErrorResponse "Internal server error"
// @Router /auth/open-token [post]
func (c *MembershipController) GenerateOpenToken(ctx *fiber.Ctx) error {
token, err := c.service.GenerateOpenToken(ctx.UserContext(), ctx.Locals("userId").(string))
token, err := c.service.GenerateOpenToken(ctx.UserContext(), ctx.Locals("userID").(string))
if err != nil {
return c.errorHandler.HandleAuthError(ctx, err)
}

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("/:id", auth.HasPermission(model.ServerView), ac.GetById)
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)
apiServerRoutes := routeGroups.Api.Group("/server")
@@ -139,45 +138,16 @@ func (ac *ServerController) CreateServer(c *fiber.Ctx) error {
if err := c.BodyParser(server); err != nil {
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 c.JSON(server)
}
// UpdateServer updates an existing server
// @Summary Update an ACC server
// @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 immediately with server details
// The actual creation will happen in the background with WebSocket updates
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"
)
// AccessKeyMiddleware provides authentication and permission middleware.
type AccessKeyMiddleware struct {
userInfo CachedUserInfo
}
// NewAccessKeyMiddleware creates a new AccessKeyMiddleware.
func NewAccessKeyMiddleware() *AccessKeyMiddleware {
auth := &AccessKeyMiddleware{
userInfo: CachedUserInfo{UserID: uuid.New().String(), Username: "access_key", RoleName: "Admin", Permissions: map[string]bool{
@@ -25,9 +23,7 @@ func NewAccessKeyMiddleware() *AccessKeyMiddleware {
return auth
}
// Authenticate is a middleware for JWT authentication with enhanced security.
func (m *AccessKeyMiddleware) Authenticate(ctx *fiber.Ctx) error {
// Log authentication attempt
ip := ctx.IP()
userAgent := ctx.Get("User-Agent")

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

@@ -16,7 +16,6 @@ import (
"github.com/google/uuid"
)
// CachedUserInfo holds cached user authentication and permission data
type CachedUserInfo struct {
UserID string
Username string
@@ -25,7 +24,6 @@ type CachedUserInfo struct {
CachedAt time.Time
}
// AuthMiddleware provides authentication and permission middleware.
type AuthMiddleware struct {
membershipService *service.MembershipService
cache *cache.InMemoryCache
@@ -34,7 +32,6 @@ type AuthMiddleware struct {
openJWTHandler *jwt.OpenJWTHandler
}
// NewAuthMiddleware creates a new AuthMiddleware.
func NewAuthMiddleware(ms *service.MembershipService, cache *cache.InMemoryCache, jwtHandler *jwt.JWTHandler, openJWTHandler *jwt.OpenJWTHandler) *AuthMiddleware {
auth := &AuthMiddleware{
membershipService: ms,
@@ -44,24 +41,20 @@ func NewAuthMiddleware(ms *service.MembershipService, cache *cache.InMemoryCache
openJWTHandler: openJWTHandler,
}
// Set up bidirectional relationship for cache invalidation
ms.SetCacheInvalidator(auth)
return auth
}
// Authenticate is a middleware for JWT authentication with enhanced security.
func (m *AuthMiddleware) AuthenticateOpen(ctx *fiber.Ctx) error {
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 {
return m.AuthenticateWithHandler(m.jwtHandler, false, ctx)
}
func (m *AuthMiddleware) AuthenticateWithHandler(jwtHandler *jwt.JWTHandler, isOpenToken bool, ctx *fiber.Ctx) error {
// Log authentication attempt
ip := ctx.IP()
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]
if len(token) < 10 || len(token) > 2048 {
logging.Error("Authentication failed: invalid token length from IP %s", ip)
@@ -106,7 +98,13 @@ func (m *AuthMiddleware) AuthenticateWithHandler(jwtHandler *jwt.JWTHandler, isO
})
}
// Additional security: validate user ID format
if !jwtHandler.IsOpenToken && claims.IsOpenToken {
logging.Error("Authentication failed: attempting to authenticate with open token")
return ctx.Status(fiber.StatusUnauthorized).JSON(fiber.Map{
"error": "Wrong token type used",
})
}
if claims.UserID == "" || len(claims.UserID) < 10 {
logging.Error("Authentication failed: invalid user ID in token from IP %s", ip)
return ctx.Status(fiber.StatusUnauthorized).JSON(fiber.Map{
@@ -117,10 +115,10 @@ func (m *AuthMiddleware) AuthenticateWithHandler(jwtHandler *jwt.JWTHandler, isO
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()}
ctx.Locals("userID", userInfo.UserID)
ctx.Locals("actorUsername", userInfo.Username)
ctx.Locals("userInfo", userInfo)
ctx.Locals("authTime", time.Now())
} else {
// Preload and cache user info to avoid database queries on permission checks
userInfo, err := m.getCachedUserInfo(ctx.UserContext(), claims.UserID)
if err != nil {
logging.Error("Authentication failed: unable to load user info for %s from IP %s: %v", claims.UserID, ip, err)
@@ -130,6 +128,7 @@ func (m *AuthMiddleware) AuthenticateWithHandler(jwtHandler *jwt.JWTHandler, isO
}
ctx.Locals("userID", claims.UserID)
ctx.Locals("actorUsername", userInfo.Username)
ctx.Locals("userInfo", userInfo)
ctx.Locals("authTime", time.Now())
}
@@ -138,7 +137,6 @@ func (m *AuthMiddleware) AuthenticateWithHandler(jwtHandler *jwt.JWTHandler, isO
return ctx.Next()
}
// HasPermission is a middleware for checking user permissions with enhanced logging.
func (m *AuthMiddleware) HasPermission(requiredPermission string) fiber.Handler {
return func(ctx *fiber.Ctx) error {
userID, ok := ctx.Locals("userID").(string)
@@ -153,7 +151,6 @@ func (m *AuthMiddleware) HasPermission(requiredPermission string) fiber.Handler
return ctx.Next()
}
// Validate permission parameter
if requiredPermission == "" {
logging.Error("Permission check failed: empty permission requirement")
return ctx.Status(fiber.StatusInternalServerError).JSON(fiber.Map{
@@ -161,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)
if !ok {
logging.Error("Permission check failed: no cached user info in context from IP %s", ctx.IP())
@@ -170,7 +166,6 @@ func (m *AuthMiddleware) HasPermission(requiredPermission string) fiber.Handler
})
}
// Check if user has permission using cached data
has := m.hasPermissionFromCache(userInfo, requiredPermission)
if !has {
@@ -185,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 {
return m.securityMW.AuthRateLimit()
}
// RequireHTTPS redirects HTTP requests to HTTPS in production
func (m *AuthMiddleware) RequireHTTPS() fiber.Handler {
return func(ctx *fiber.Ctx) error {
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" {
httpsURL := "https://" + ctx.Hostname() + ctx.OriginalURL()
return ctx.Redirect(httpsURL, fiber.StatusMovedPermanently)
@@ -204,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) {
cacheKey := fmt.Sprintf("userinfo:%s", userID)
// Try cache first
if cached, found := m.cache.Get(cacheKey); found {
if userInfo, ok := cached.(*CachedUserInfo); ok {
logging.DebugWithContext("AUTH_CACHE", "User info for %s found in cache", userID)
@@ -216,13 +223,11 @@ func (m *AuthMiddleware) getCachedUserInfo(ctx context.Context, userID string) (
}
}
// Cache miss - load from database
user, err := m.membershipService.GetUserWithPermissions(ctx, userID)
if err != nil {
return nil, err
}
// Build permission map for fast lookups
permissions := make(map[string]bool)
for _, p := range user.Role.Permissions {
permissions[p.Name] = true
@@ -236,34 +241,26 @@ func (m *AuthMiddleware) getCachedUserInfo(ctx context.Context, userID string) (
CachedAt: time.Now(),
}
// Cache for 15 minutes
m.cache.Set(cacheKey, userInfo, 15*time.Minute)
logging.DebugWithContext("AUTH_CACHE", "User info for %s cached with %d permissions", userID, len(permissions))
return userInfo, nil
}
// hasPermissionFromCache checks permissions using cached user info (no database queries)
func (m *AuthMiddleware) hasPermissionFromCache(userInfo *CachedUserInfo, permission string) bool {
// Super Admin and Admin have all permissions
if userInfo.RoleName == "Super Admin" || userInfo.RoleName == "Admin" {
return true
}
// Check specific permission in cached map
return userInfo.Permissions[permission]
}
// InvalidateUserPermissions removes cached user info for a user
func (m *AuthMiddleware) InvalidateUserPermissions(userID string) {
cacheKey := fmt.Sprintf("userinfo:%s", userID)
m.cache.Delete(cacheKey)
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() {
// 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")
}

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

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

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

@@ -11,19 +11,16 @@ import (
"github.com/gofiber/fiber/v2"
)
// RateLimiter stores rate limiting information
type RateLimiter struct {
requests map[string][]time.Time
mutex sync.RWMutex
}
// NewRateLimiter creates a new rate limiter
func NewRateLimiter() *RateLimiter {
rl := &RateLimiter{
requests: make(map[string][]time.Time),
}
// Use graceful shutdown for cleanup goroutine
shutdownManager := graceful.GetManager()
shutdownManager.RunGoroutine(func(ctx context.Context) {
rl.cleanupWithContext(ctx)
@@ -32,7 +29,6 @@ func NewRateLimiter() *RateLimiter {
return rl
}
// cleanup removes old entries from the rate limiter
func (rl *RateLimiter) cleanupWithContext(ctx context.Context) {
ticker := time.NewTicker(5 * time.Minute)
defer ticker.Stop()
@@ -62,44 +58,29 @@ func (rl *RateLimiter) cleanupWithContext(ctx context.Context) {
}
}
// SecurityMiddleware provides comprehensive security middleware
type SecurityMiddleware struct {
rateLimiter *RateLimiter
}
// NewSecurityMiddleware creates a new security middleware
func NewSecurityMiddleware() *SecurityMiddleware {
return &SecurityMiddleware{
rateLimiter: NewRateLimiter(),
}
}
// SecurityHeaders adds security headers to responses
func (sm *SecurityMiddleware) SecurityHeaders() fiber.Handler {
return func(c *fiber.Ctx) error {
// Prevent MIME type sniffing
c.Set("X-Content-Type-Options", "nosniff")
// Prevent clickjacking
c.Set("X-Frame-Options", "DENY")
// Enable XSS protection
c.Set("X-XSS-Protection", "1; mode=block")
// Prevent referrer leakage
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'")
// Permissions Policy
c.Set("Permissions-Policy", "camera=(), microphone=(), geolocation=(), interest-cohort=()")
return c.Next()
}
}
// RateLimit implements rate limiting for API endpoints
func (sm *SecurityMiddleware) RateLimit(maxRequests int, duration time.Duration) fiber.Handler {
return func(c *fiber.Ctx) error {
ip := c.IP()
@@ -111,7 +92,6 @@ func (sm *SecurityMiddleware) RateLimit(maxRequests int, duration time.Duration)
now := time.Now()
requests := sm.rateLimiter.requests[key]
// Remove requests older than duration
filtered := make([]time.Time, 0, len(requests))
for _, t := range requests {
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 {
return c.Status(fiber.StatusTooManyRequests).JSON(fiber.Map{
"error": "Rate limit exceeded",
@@ -127,7 +106,6 @@ func (sm *SecurityMiddleware) RateLimit(maxRequests int, duration time.Duration)
})
}
// Add current request
filtered = append(filtered, now)
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 {
return func(c *fiber.Ctx) error {
ip := c.IP()
@@ -148,7 +125,6 @@ func (sm *SecurityMiddleware) AuthRateLimit() fiber.Handler {
now := time.Now()
requests := sm.rateLimiter.requests[key]
// Remove requests older than 15 minutes
filtered := make([]time.Time, 0, len(requests))
for _, t := range requests {
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 {
return c.Status(fiber.StatusTooManyRequests).JSON(fiber.Map{
"error": "Too many authentication attempts",
@@ -164,7 +139,6 @@ func (sm *SecurityMiddleware) AuthRateLimit() fiber.Handler {
})
}
// Add current request
filtered = append(filtered, now)
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 {
return func(c *fiber.Ctx) error {
// Sanitize query parameters
c.Request().URI().QueryArgs().VisitAll(func(key, value []byte) {
sanitized := sanitizeInput(string(value))
c.Request().URI().QueryArgs().Set(string(key), sanitized)
})
// Store original body for processing
if c.Method() == "POST" || c.Method() == "PUT" || c.Method() == "PATCH" {
body := c.Body()
if len(body) > 0 {
// Basic sanitization - remove potentially dangerous patterns
sanitized := sanitizeInput(string(body))
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 {
dangerous := []string{
"<script",
@@ -236,7 +205,7 @@ func sanitizeInput(input string) string {
result := input
lowerInput := strings.ToLower(input)
for _, pattern := range dangerous {
if strings.Contains(lowerInput, pattern) {
return ""
@@ -254,7 +223,6 @@ func sanitizeInput(input string) string {
return result
}
// ValidateContentType ensures only expected content types are accepted
func (sm *SecurityMiddleware) ValidateContentType(allowedTypes ...string) fiber.Handler {
return func(c *fiber.Ctx) error {
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
for _, allowedType := range allowedTypes {
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 {
suspiciousAgents := []string{
"sqlmap",
@@ -296,21 +262,19 @@ func (sm *SecurityMiddleware) ValidateUserAgent() fiber.Handler {
"dirb",
"dirbuster",
"wpscan",
"curl/7.0", // Very old curl versions
"wget/1.0", // Very old wget versions
"curl/7.0",
"wget/1.0",
}
return func(c *fiber.Ctx) error {
userAgent := strings.ToLower(c.Get("User-Agent"))
// Block empty user agents
if userAgent == "" {
return c.Status(fiber.StatusBadRequest).JSON(fiber.Map{
"error": "User-Agent header is required",
})
}
// Block suspicious user agents
for _, suspicious := range suspiciousAgents {
if strings.Contains(userAgent, suspicious) {
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 {
return func(c *fiber.Ctx) error {
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 {
return func(c *fiber.Ctx) error {
start := time.Now()
// Process request
err := c.Next()
// Log suspicious activity
status := c.Response().StatusCode()
if status == 401 || status == 403 || status == 429 {
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",
time.Now().Format(time.RFC3339),
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 {
return func(c *fiber.Ctx) error {
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"
)
// Migration001UpgradePasswordSecurity migrates existing user passwords from encrypted to hashed format
type Migration001UpgradePasswordSecurity struct {
DB *gorm.DB
}
// NewMigration001UpgradePasswordSecurity creates a new password security migration
func NewMigration001UpgradePasswordSecurity(db *gorm.DB) *Migration001UpgradePasswordSecurity {
return &Migration001UpgradePasswordSecurity{DB: db}
}
// Up executes the migration
func (m *Migration001UpgradePasswordSecurity) Up() error {
logging.Info("Starting password security upgrade migration...")
// Check if migration has already been applied
var migrationRecord MigrationRecord
err := m.DB.Where("migration_name = ?", "001_upgrade_password_security").First(&migrationRecord).Error
if err == nil {
@@ -31,12 +27,10 @@ func (m *Migration001UpgradePasswordSecurity) Up() error {
return nil
}
// Create migration tracking table if it doesn't exist
if err := m.DB.AutoMigrate(&MigrationRecord{}); err != nil {
return fmt.Errorf("failed to create migration tracking table: %v", err)
}
// Start transaction
tx := m.DB.Begin()
if tx.Error != nil {
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 {
// Column might already exist, ignore if it's a duplicate column error
if !isDuplicateColumnError(err) {
tx.Rollback()
return fmt.Errorf("failed to add backup column: %v", err)
}
}
// Get all users with encrypted passwords
var users []UserForMigration
if err := tx.Find(&users).Error; err != nil {
tx.Rollback()
@@ -72,19 +63,15 @@ func (m *Migration001UpgradePasswordSecurity) Up() error {
if err := m.migrateUserPassword(tx, &user); err != nil {
logging.Error("Failed to migrate user %s (ID: %s): %v", user.Username, user.ID, err)
failedCount++
// Continue with other users rather than failing completely
continue
}
migratedCount++
}
// Remove backup column after successful migration
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)
// Don't fail the migration for this
}
// Record successful migration
migrationRecord = MigrationRecord{
MigrationName: "001_upgrade_password_security",
AppliedAt: "datetime('now')",
@@ -97,7 +84,6 @@ func (m *Migration001UpgradePasswordSecurity) Up() error {
return fmt.Errorf("failed to record migration: %v", err)
}
// Commit transaction
if err := tx.Commit().Error; err != nil {
return fmt.Errorf("failed to commit migration: %v", err)
}
@@ -111,32 +97,24 @@ func (m *Migration001UpgradePasswordSecurity) Up() error {
return nil
}
// migrateUserPassword migrates a single user's password
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) {
logging.Debug("User %s already has hashed password, skipping", user.Username)
return nil
}
// Backup original password
if err := tx.Model(user).Update("password_backup", user.Password).Error; err != nil {
return fmt.Errorf("failed to backup password: %v", err)
}
// Try to decrypt the old password
var plainPassword string
// First, try to decrypt using the old encryption method
decrypted, err := decryptOldPassword(user.Password)
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)
// Use original password as-is (might be plain text from development)
plainPassword = user.Password
// Validate it's not obviously encrypted data
if len(plainPassword) > 100 || containsBinaryData(plainPassword) {
return fmt.Errorf("password appears to be corrupted encrypted data")
}
@@ -144,7 +122,6 @@ func (m *Migration001UpgradePasswordSecurity) migrateUserPassword(tx *gorm.DB, u
plainPassword = decrypted
}
// Validate plain password
if plainPassword == "" {
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")
}
// Hash the plain password using bcrypt
hashedPassword, err := password.HashPassword(plainPassword)
if err != nil {
return fmt.Errorf("failed to hash password: %v", err)
}
// Update with hashed password
if err := tx.Model(user).Update("password", hashedPassword).Error; err != nil {
return fmt.Errorf("failed to update password: %v", err)
}
@@ -168,19 +143,16 @@ func (m *Migration001UpgradePasswordSecurity) migrateUserPassword(tx *gorm.DB, u
return nil
}
// UserForMigration represents a user record for migration purposes
type UserForMigration struct {
ID string `gorm:"column:id"`
Username string `gorm:"column:username"`
Password string `gorm:"column:password"`
}
// TableName specifies the table name for GORM
func (UserForMigration) TableName() string {
return "users"
}
// MigrationRecord tracks applied migrations
type MigrationRecord struct {
ID uint `gorm:"primaryKey"`
MigrationName string `gorm:"unique;not null"`
@@ -189,49 +161,38 @@ type MigrationRecord struct {
Notes string
}
// TableName specifies the table name for GORM
func (MigrationRecord) TableName() string {
return "migration_records"
}
// isAlreadyHashed checks if a password is already bcrypt hashed
func isAlreadyHashed(password string) bool {
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 {
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 false
}
// isDuplicateColumnError checks if an error is due to duplicate column
func isDuplicateColumnError(err error) bool {
errStr := err.Error()
return fmt.Sprintf("%v", errStr) == "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) {
// 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")
}
// Down reverses the migration (if needed)
func (m *Migration001UpgradePasswordSecurity) Down() error {
logging.Error("Password security migration rollback is not supported for security reasons")
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 {
migration := NewMigration001UpgradePasswordSecurity(db)
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"
)
// Migration002MigrateToUUID migrates tables from integer IDs to UUIDs
type Migration002MigrateToUUID struct {
DB *gorm.DB
}
// NewMigration002MigrateToUUID creates a new UUID migration
func NewMigration002MigrateToUUID(db *gorm.DB) *Migration002MigrateToUUID {
return &Migration002MigrateToUUID{DB: db}
}
// Up executes the migration
func (m *Migration002MigrateToUUID) Up() error {
logging.Info("Checking UUID migration...")
// Check if migration is needed by looking at the servers table structure
if !m.needsMigration() {
logging.Info("UUID migration not needed - tables already use UUID primary keys")
return nil
@@ -31,7 +27,6 @@ func (m *Migration002MigrateToUUID) Up() error {
logging.Info("Starting UUID migration...")
// Check if migration has already been applied
var migrationRecord MigrationRecord
err := m.DB.Where("migration_name = ?", "002_migrate_to_uuid").First(&migrationRecord).Error
if err == nil {
@@ -39,12 +34,10 @@ func (m *Migration002MigrateToUUID) Up() error {
return nil
}
// Create migration tracking table if it doesn't exist
if err := m.DB.AutoMigrate(&MigrationRecord{}); err != nil {
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...")
if err := runUUIDMigrationSQL(m.DB); err != nil {
return fmt.Errorf("failed to execute UUID migration: %v", err)
@@ -54,9 +47,7 @@ func (m *Migration002MigrateToUUID) Up() error {
return nil
}
// needsMigration checks if the UUID migration is needed by examining table structure
func (m *Migration002MigrateToUUID) needsMigration() bool {
// Check if servers table exists and has integer primary key
var result struct {
Type string `gorm:"column:type"`
}
@@ -67,29 +58,22 @@ func (m *Migration002MigrateToUUID) needsMigration() bool {
`).Scan(&result).Error
if err != nil || result.Type == "" {
// Table doesn't exist or no primary key found - assume no migration needed
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"
}
// Down reverses the migration (not implemented for safety)
func (m *Migration002MigrateToUUID) Down() error {
logging.Error("UUID migration rollback is not supported for data safety reasons")
return fmt.Errorf("UUID migration rollback is not supported")
}
// runUUIDMigrationSQL executes the UUID migration using the SQL file
func runUUIDMigrationSQL(db *gorm.DB) error {
// Disable foreign key constraints during migration
if err := db.Exec("PRAGMA foreign_keys=OFF").Error; err != nil {
return fmt.Errorf("failed to disable foreign keys: %v", err)
}
// Start transaction
tx := db.Begin()
if tx.Error != nil {
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")
migrationSQL, err := ioutil.ReadFile(sqlPath)
if err != nil {
return fmt.Errorf("failed to read migration SQL file: %v", err)
}
// Execute the migration
if err := tx.Exec(string(migrationSQL)).Error; err != nil {
tx.Rollback()
return fmt.Errorf("failed to execute migration: %v", err)
}
// Commit transaction
if err := tx.Commit().Error; err != nil {
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 {
return fmt.Errorf("failed to re-enable foreign keys: %v", err)
}
@@ -127,7 +107,6 @@ func runUUIDMigrationSQL(db *gorm.DB) error {
return nil
}
// RunUUIDMigration is a convenience function to run the migration
func RunUUIDMigration(db *gorm.DB) error {
migration := NewMigration002MigrateToUUID(db)
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"
)
// StatusCache represents a cached server status with expiration
type StatusCache struct {
Status ServiceStatus
UpdatedAt time.Time
}
// CacheConfig holds configuration for cache behavior
type CacheConfig struct {
ExpirationTime time.Duration // How long before a cache entry expires
ThrottleTime time.Duration // Minimum time between status checks
DefaultStatus ServiceStatus // Default status to return when throttled
ExpirationTime time.Duration
ThrottleTime time.Duration
DefaultStatus ServiceStatus
}
// ServerStatusCache manages cached server statuses
type ServerStatusCache struct {
sync.RWMutex
cache map[string]*StatusCache
@@ -27,7 +24,6 @@ type ServerStatusCache struct {
lastChecked map[string]time.Time
}
// NewServerStatusCache creates a new server status cache
func NewServerStatusCache(config CacheConfig) *ServerStatusCache {
return &ServerStatusCache{
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) {
c.RLock()
defer c.RUnlock()
// Check if we're being throttled
if lastCheck, exists := c.lastChecked[serviceName]; exists {
if time.Since(lastCheck) < c.config.ThrottleTime {
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 time.Since(cached.UpdatedAt) < c.config.ExpirationTime {
return cached.Status, false
@@ -61,7 +54,6 @@ func (c *ServerStatusCache) GetStatus(serviceName string) (ServiceStatus, bool)
return StatusUnknown, true
}
// UpdateStatus updates the cache with a new status
func (c *ServerStatusCache) UpdateStatus(serviceName string, status ServiceStatus) {
c.Lock()
defer c.Unlock()
@@ -73,7 +65,6 @@ func (c *ServerStatusCache) UpdateStatus(serviceName string, status ServiceStatu
c.lastChecked[serviceName] = time.Now()
}
// InvalidateStatus removes a specific service from the cache
func (c *ServerStatusCache) InvalidateStatus(serviceName string) {
c.Lock()
defer c.Unlock()
@@ -82,7 +73,6 @@ func (c *ServerStatusCache) InvalidateStatus(serviceName string) {
delete(c.lastChecked, serviceName)
}
// Clear removes all entries from the cache
func (c *ServerStatusCache) Clear() {
c.Lock()
defer c.Unlock()
@@ -91,13 +81,11 @@ func (c *ServerStatusCache) Clear() {
c.lastChecked = make(map[string]time.Time)
}
// LookupCache provides a generic cache for lookup data
type LookupCache struct {
sync.RWMutex
data map[string]interface{}
}
// NewLookupCache creates a new lookup cache
func NewLookupCache() *LookupCache {
logging.Debug("Initializing new 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) {
c.RLock()
defer c.RUnlock()
@@ -119,7 +106,6 @@ func (c *LookupCache) Get(key string) (interface{}, bool) {
return value, exists
}
// Set stores a value in the cache
func (c *LookupCache) Set(key string, value interface{}) {
c.Lock()
defer c.Unlock()
@@ -128,7 +114,6 @@ func (c *LookupCache) Set(key string, value interface{}) {
logging.Debug("Cache SET for key: %s", key)
}
// Clear removes all entries from the cache
func (c *LookupCache) Clear() {
c.Lock()
defer c.Unlock()
@@ -137,13 +122,11 @@ func (c *LookupCache) Clear() {
logging.Debug("Cache CLEARED")
}
// ConfigEntry represents a cached configuration entry with its update time
type ConfigEntry[T any] struct {
Data T
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) {
if entry, ok := cache[serverID]; ok {
if time.Since(entry.UpdatedAt) < expirationTime {
@@ -157,7 +140,6 @@ func getConfigFromCache[T any](cache map[string]*ConfigEntry[T], serverID string
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) {
cache[serverID] = &ConfigEntry[T]{
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)
}
// ServerConfigCache manages cached server configurations
type ServerConfigCache struct {
sync.RWMutex
configuration map[string]*ConfigEntry[Configuration]
@@ -177,7 +158,6 @@ type ServerConfigCache struct {
config CacheConfig
}
// NewServerConfigCache creates a new server configuration cache
func NewServerConfigCache(config CacheConfig) *ServerConfigCache {
logging.Debug("Initializing new ServerConfigCache with expiration time: %v, throttle time: %v", config.ExpirationTime, config.ThrottleTime)
return &ServerConfigCache{
@@ -190,7 +170,6 @@ func NewServerConfigCache(config CacheConfig) *ServerConfigCache {
}
}
// GetConfiguration retrieves a cached configuration
func (c *ServerConfigCache) GetConfiguration(serverID string) (*Configuration, bool) {
c.RLock()
defer c.RUnlock()
@@ -198,7 +177,6 @@ func (c *ServerConfigCache) GetConfiguration(serverID string) (*Configuration, b
return getConfigFromCache(c.configuration, serverID, c.config.ExpirationTime)
}
// GetAssistRules retrieves cached assist rules
func (c *ServerConfigCache) GetAssistRules(serverID string) (*AssistRules, bool) {
c.RLock()
defer c.RUnlock()
@@ -206,7 +184,6 @@ func (c *ServerConfigCache) GetAssistRules(serverID string) (*AssistRules, bool)
return getConfigFromCache(c.assistRules, serverID, c.config.ExpirationTime)
}
// GetEvent retrieves cached event configuration
func (c *ServerConfigCache) GetEvent(serverID string) (*EventConfig, bool) {
c.RLock()
defer c.RUnlock()
@@ -214,7 +191,6 @@ func (c *ServerConfigCache) GetEvent(serverID string) (*EventConfig, bool) {
return getConfigFromCache(c.event, serverID, c.config.ExpirationTime)
}
// GetEventRules retrieves cached event rules
func (c *ServerConfigCache) GetEventRules(serverID string) (*EventRules, bool) {
c.RLock()
defer c.RUnlock()
@@ -222,7 +198,6 @@ func (c *ServerConfigCache) GetEventRules(serverID string) (*EventRules, bool) {
return getConfigFromCache(c.eventRules, serverID, c.config.ExpirationTime)
}
// GetSettings retrieves cached server settings
func (c *ServerConfigCache) GetSettings(serverID string) (*ServerSettings, bool) {
c.RLock()
defer c.RUnlock()
@@ -230,7 +205,6 @@ func (c *ServerConfigCache) GetSettings(serverID string) (*ServerSettings, bool)
return getConfigFromCache(c.settings, serverID, c.config.ExpirationTime)
}
// UpdateConfiguration updates the configuration cache
func (c *ServerConfigCache) UpdateConfiguration(serverID string, config Configuration) {
c.Lock()
defer c.Unlock()
@@ -238,7 +212,6 @@ func (c *ServerConfigCache) UpdateConfiguration(serverID string, config Configur
updateConfigInCache(c.configuration, serverID, config)
}
// UpdateAssistRules updates the assist rules cache
func (c *ServerConfigCache) UpdateAssistRules(serverID string, rules AssistRules) {
c.Lock()
defer c.Unlock()
@@ -246,7 +219,6 @@ func (c *ServerConfigCache) UpdateAssistRules(serverID string, rules AssistRules
updateConfigInCache(c.assistRules, serverID, rules)
}
// UpdateEvent updates the event configuration cache
func (c *ServerConfigCache) UpdateEvent(serverID string, event EventConfig) {
c.Lock()
defer c.Unlock()
@@ -254,7 +226,6 @@ func (c *ServerConfigCache) UpdateEvent(serverID string, event EventConfig) {
updateConfigInCache(c.event, serverID, event)
}
// UpdateEventRules updates the event rules cache
func (c *ServerConfigCache) UpdateEventRules(serverID string, rules EventRules) {
c.Lock()
defer c.Unlock()
@@ -262,7 +233,6 @@ func (c *ServerConfigCache) UpdateEventRules(serverID string, rules EventRules)
updateConfigInCache(c.eventRules, serverID, rules)
}
// UpdateSettings updates the server settings cache
func (c *ServerConfigCache) UpdateSettings(serverID string, settings ServerSettings) {
c.Lock()
defer c.Unlock()
@@ -270,7 +240,6 @@ func (c *ServerConfigCache) UpdateSettings(serverID string, settings ServerSetti
updateConfigInCache(c.settings, serverID, settings)
}
// InvalidateServerCache removes all cached configurations for a specific server
func (c *ServerConfigCache) InvalidateServerCache(serverID string) {
c.Lock()
defer c.Unlock()
@@ -283,7 +252,6 @@ func (c *ServerConfigCache) InvalidateServerCache(serverID string) {
delete(c.settings, serverID)
}
// Clear removes all entries from the cache
func (c *ServerConfigCache) Clear() {
c.Lock()
defer c.Unlock()

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

@@ -13,17 +13,15 @@ import (
type IntString int
type IntBool int
// Config tracks configuration modifications
type Config struct {
ID uuid.UUID `json:"id" gorm:"type:uuid;primary_key;"`
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"`
NewConfig string `json:"newConfig" gorm:"type:text"`
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 {
if c.ID == uuid.Nil {
c.ID = uuid.New()
@@ -42,6 +40,14 @@ type Configurations struct {
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 {
ServerName string `json:"serverName"`
AdminPassword string `json:"adminPassword"`
@@ -79,11 +85,11 @@ type EventConfig struct {
}
type Session struct {
HourOfDay IntString `json:"hourOfDay"`
DayOfWeekend IntString `json:"dayOfWeekend"`
TimeMultiplier IntString `json:"timeMultiplier"`
SessionType string `json:"sessionType"`
SessionDurationMinutes IntString `json:"sessionDurationMinutes"`
HourOfDay IntString `json:"hourOfDay"`
DayOfWeekend IntString `json:"dayOfWeekend"`
TimeMultiplier IntString `json:"timeMultiplier"`
SessionType TrackSession `json:"sessionType"`
SessionDurationMinutes IntString `json:"sessionDurationMinutes"`
}
type AssistRules struct {
@@ -121,8 +127,6 @@ type Configuration struct {
ConfigVersion IntString `json:"configVersion"`
}
// Known configuration keys
func (i *IntBool) UnmarshalJSON(b []byte) error {
var str int
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"
)
// BaseFilter contains common filter fields that can be embedded in other filters
type BaseFilter struct {
Page int `query:"page"`
PageSize int `query:"page_size"`
@@ -15,18 +14,15 @@ type BaseFilter struct {
SortDesc bool `query:"sort_desc"`
}
// DateRangeFilter adds date range filtering capabilities
type DateRangeFilter struct {
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"`
}
// ServerBasedFilter adds server ID filtering capability
type ServerBasedFilter struct {
ServerID string `param:"id"`
}
// ConfigFilter defines filtering options for Config queries
type ConfigFilter struct {
BaseFilter
ServerBasedFilter
@@ -34,13 +30,11 @@ type ConfigFilter struct {
ChangedAt time.Time `query:"changed_at" time_format:"2006-01-02T15:04:05Z07:00"`
}
// ApiFilter defines filtering options for Api queries
type ServiceControlFilter struct {
BaseFilter
ServiceControl string `query:"serviceControl"`
}
// MembershipFilter defines filtering options for User queries
type MembershipFilter struct {
BaseFilter
Username string `query:"username"`
@@ -48,36 +42,32 @@ type MembershipFilter struct {
RoleID string `query:"role_id"`
}
// Pagination returns the offset and limit for database queries
func (f *BaseFilter) Pagination() (offset, limit int) {
if f.Page < 1 {
f.Page = 1
}
if f.PageSize < 1 {
f.PageSize = 10 // Default page size
f.PageSize = 10
}
offset = (f.Page - 1) * f.PageSize
limit = f.PageSize
return
}
// GetSorting returns the sort field and direction for database queries
func (f *BaseFilter) GetSorting() (field string, desc bool) {
if f.SortBy == "" {
return "id", false // Default sorting
return "id", false
}
return f.SortBy, f.SortDesc
}
// IsDateRangeValid checks if both dates are set and start date is before end date
func (f *DateRangeFilter) IsDateRangeValid() bool {
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)
}
// ApplyFilter applies the membership filter to a GORM query
func (f *MembershipFilter) ApplyFilter(query *gorm.DB) *gorm.DB {
if f.Username != "" {
query = query.Where("username LIKE ?", "%"+f.Username+"%")
@@ -93,12 +83,10 @@ func (f *MembershipFilter) ApplyFilter(query *gorm.DB) *gorm.DB {
return query
}
// Pagination returns the offset and limit for database queries
func (f *MembershipFilter) Pagination() (offset, limit int) {
return f.BaseFilter.Pagination()
}
// GetSorting returns the sort field and direction for database queries
func (f *MembershipFilter) GetSorting() (field string, desc bool) {
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
// Track represents a track and its capacity
type Track struct {
Name string `json:"track" gorm:"primaryKey;size:50"`
UniquePitBoxes int `json:"unique_pit_boxes"`
PrivateServerSlots int `json:"private_server_slots"`
}
// CarModel represents a car model mapping
type CarModel struct {
Value int `json:"value" gorm:"primaryKey"`
CarModel string `json:"car_model"`
}
// DriverCategory represents driver skill categories
type DriverCategory struct {
Value int `json:"value" gorm:"primaryKey"`
Category string `json:"category"`
}
// CupCategory represents championship cup categories
type CupCategory struct {
Value int `json:"value" gorm:"primaryKey"`
Category string `json:"category"`
}
// SessionType represents session types
type SessionType struct {
Value int `json:"value" gorm:"primaryKey"`
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"`
}
// Init
// Initializes base model with DateCreated, DateUpdated, and Id values.
func (cm *BaseModel) Init() {
date := time.Now()
cm.Id = uuid.NewString()

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

@@ -5,15 +5,13 @@ import (
"gorm.io/gorm"
)
// Permission represents an action that can be performed in the system.
type Permission struct {
ID uuid.UUID `json:"id" gorm:"type:uuid;primary_key;"`
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 {
s.ID = uuid.New()
return nil
}
}

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

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

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

@@ -5,16 +5,14 @@ import (
"gorm.io/gorm"
)
// Role represents a user role in the system.
type Role struct {
ID uuid.UUID `json:"id" gorm:"type:uuid;primary_key;"`
Name string `json:"name" gorm:"unique_index;not null"`
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 {
s.ID = uuid.New()
return nil
}
}

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

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

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

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

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

@@ -1,33 +1,31 @@
package model
import (
"encoding/json"
"fmt"
"strings"
"time"
"github.com/google/uuid"
"gorm.io/gorm"
)
// StateHistoryFilter combines common filter capabilities
type StateHistoryFilter struct {
ServerBasedFilter // Adds server ID from path parameter
DateRangeFilter // Adds date range filtering
ServerBasedFilter
DateRangeFilter
// Additional fields specific to state history
Session string `query:"session"`
MinPlayers *int `query:"min_players"`
MaxPlayers *int `query:"max_players"`
Session TrackSession `query:"session"`
MinPlayers *int `query:"min_players"`
MaxPlayers *int `query:"max_players"`
}
// ApplyFilter implements the Filterable interface
func (f *StateHistoryFilter) ApplyFilter(query *gorm.DB) *gorm.DB {
// Apply server filter
if f.ServerID != "" {
if serverUUID, err := uuid.Parse(f.ServerID); err == nil {
query = query.Where("server_id = ?", serverUUID)
}
}
// Apply date range filter if set
timeZero := time.Time{}
if f.StartDate != timeZero {
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)
}
// Apply session filter if set
if f.Session != "" {
query = query.Where("session = ?", f.Session)
}
// Apply player count filters if set
if f.MinPlayers != nil {
query = query.Where("player_count >= ?", *f.MinPlayers)
}
@@ -52,19 +48,68 @@ func (f *StateHistoryFilter) ApplyFilter(query *gorm.DB) *gorm.DB {
return query
}
type StateHistory struct {
ID uuid.UUID `gorm:"type:uuid;primary_key;" json:"id"`
ServerID uuid.UUID `json:"serverId" gorm:"not null;type:uuid"`
Session string `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"` // Unique identifier for each session/event
type TrackSession string
const (
SessionPractice TrackSession = "P"
SessionQualify TrackSession = "Q"
SessionRace TrackSession = "R"
SessionUnknown TrackSession = "U"
)
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 {
if sh.ID == uuid.Nil {
sh.ID = uuid.New()

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

@@ -1,36 +1,38 @@
package model
import "github.com/google/uuid"
type SessionCount struct {
Name string `json:"name"`
Count int `json:"count"`
Name TrackSession `json:"name"`
Count int `json:"count"`
}
type DailyActivity struct {
Date string `json:"date"`
SessionsCount int `json:"sessionsCount"`
SessionsCount int `json:"sessionsCount"`
}
type PlayerCountPoint struct {
Timestamp string `json:"timestamp"`
Count float64 `json:"count"`
Timestamp string `json:"timestamp"`
Count float64 `json:"count"`
}
type StateHistoryStats struct {
AveragePlayers float64 `json:"averagePlayers"`
PeakPlayers int `json:"peakPlayers"`
TotalSessions int `json:"totalSessions"`
TotalPlaytime int `json:"totalPlaytime" gorm:"-"` // in minutes
AveragePlayers float64 `json:"averagePlayers"`
PeakPlayers int `json:"peakPlayers"`
TotalSessions int `json:"totalSessions"`
TotalPlaytime int `json:"totalPlaytime" gorm:"-"`
PlayerCountOverTime []PlayerCountPoint `json:"playerCountOverTime" gorm:"-"`
SessionTypes []SessionCount `json:"sessionTypes" gorm:"-"`
DailyActivity []DailyActivity `json:"dailyActivity" gorm:"-"`
RecentSessions []RecentSession `json:"recentSessions" gorm:"-"`
}
}
type RecentSession struct {
ID uint `json:"id"`
Date string `json:"date"`
Type string `json:"type"`
Track string `json:"track"`
Duration int `json:"duration"`
Players int `json:"players"`
}
ID uuid.UUID `json:"id"`
Date string `json:"date"`
Type TrackSession `json:"type"`
Track string `json:"track"`
Duration int `json:"duration"`
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"
)
// SteamCredentials represents stored Steam login credentials
type SteamCredentials struct {
ID uuid.UUID `gorm:"type:uuid;primary_key;" json:"id"`
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"`
LastUpdated time.Time `json:"lastUpdated"`
}
// TableName specifies the table name for GORM
func (SteamCredentials) TableName() string {
return "steam_credentials"
}
// BeforeCreate is a GORM hook that runs before creating new credentials
func (s *SteamCredentials) BeforeCreate(tx *gorm.DB) error {
if s.ID == uuid.Nil {
s.ID = uuid.New()
@@ -42,7 +39,6 @@ func (s *SteamCredentials) BeforeCreate(tx *gorm.DB) error {
}
s.LastUpdated = now
// Encrypt password before saving
encrypted, err := EncryptPassword(s.Password)
if err != nil {
return err
@@ -52,11 +48,9 @@ func (s *SteamCredentials) BeforeCreate(tx *gorm.DB) error {
return nil
}
// BeforeUpdate is a GORM hook that runs before updating credentials
func (s *SteamCredentials) BeforeUpdate(tx *gorm.DB) error {
s.LastUpdated = time.Now().UTC()
// Only encrypt if password field is being updated
if tx.Statement.Changed("Password") {
encrypted, err := EncryptPassword(s.Password)
if err != nil {
@@ -68,9 +62,7 @@ func (s *SteamCredentials) BeforeUpdate(tx *gorm.DB) error {
return nil
}
// AfterFind is a GORM hook that runs after fetching credentials
func (s *SteamCredentials) AfterFind(tx *gorm.DB) error {
// Decrypt password after fetching
if s.Password != "" {
decrypted, err := DecryptPassword(s.Password)
if err != nil {
@@ -81,18 +73,15 @@ func (s *SteamCredentials) AfterFind(tx *gorm.DB) error {
return nil
}
// Validate checks if the credentials are valid with enhanced security checks
func (s *SteamCredentials) Validate() error {
if s.Username == "" {
return errors.New("username is required")
}
// Enhanced username validation
if len(s.Username) < 3 || len(s.Username) > 64 {
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 {
return errors.New("username contains invalid characters")
}
@@ -101,7 +90,6 @@ func (s *SteamCredentials) Validate() error {
return errors.New("password is required")
}
// Basic password validation
if len(s.Password) < 6 {
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")
}
// Check for obvious weak passwords
weakPasswords := []string{"password", "123456", "steam", "admin", "user"}
lowerPass := strings.ToLower(s.Password)
for _, weak := range weakPasswords {
@@ -122,8 +109,6 @@ func (s *SteamCredentials) Validate() error {
return nil
}
// GetEncryptionKey returns the encryption key from config.
// The key is loaded from the ENCRYPTION_KEY environment variable.
func GetEncryptionKey() []byte {
key := []byte(configs.EncryptionKey)
if len(key) != 32 {
@@ -132,7 +117,6 @@ func GetEncryptionKey() []byte {
return key
}
// EncryptPassword encrypts a password using AES-256-GCM with enhanced security
func EncryptPassword(password string) (string, error) {
if password == "" {
return "", errors.New("password cannot be empty")
@@ -148,33 +132,27 @@ func EncryptPassword(password string) (string, error) {
return "", err
}
// Create a new GCM cipher
gcm, err := cipher.NewGCM(block)
if err != nil {
return "", err
}
// Create a cryptographically secure nonce
nonce := make([]byte, gcm.NonceSize())
if _, err := io.ReadFull(rand.Reader, nonce); err != nil {
return "", err
}
// Encrypt the password with authenticated encryption
ciphertext := gcm.Seal(nonce, nonce, []byte(password), nil)
// Return base64 encoded encrypted password
return base64.StdEncoding.EncodeToString(ciphertext), nil
}
// DecryptPassword decrypts an encrypted password with enhanced validation
func DecryptPassword(encryptedPassword string) (string, error) {
if encryptedPassword == "" {
return "", errors.New("encrypted password cannot be empty")
}
// Validate base64 format
if len(encryptedPassword) < 24 { // Minimum reasonable length
if len(encryptedPassword) < 24 {
return "", errors.New("invalid encrypted password format")
}
@@ -184,13 +162,11 @@ func DecryptPassword(encryptedPassword string) (string, error) {
return "", err
}
// Create a new GCM cipher
gcm, err := cipher.NewGCM(block)
if err != nil {
return "", err
}
// Decode base64 encoded password
ciphertext, err := base64.StdEncoding.DecodeString(encryptedPassword)
if err != nil {
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")
}
// Validate decrypted content
decrypted := string(plaintext)
if len(decrypted) == 0 || len(decrypted) > 1024 {
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"
)
// User represents a user account in the system.
type User struct {
ID uuid.UUID `json:"id" gorm:"type:uuid;primary_key;"`
Username string `json:"username" gorm:"unique_index;not null"`
@@ -17,16 +16,13 @@ type User struct {
Role Role `json:"role"`
}
// BeforeCreate is a GORM hook that runs before creating new users
func (s *User) BeforeCreate(tx *gorm.DB) error {
s.ID = uuid.New()
// Validate password strength
if err := password.ValidatePasswordStrength(s.Password); err != nil {
return err
}
// Hash password before saving
hashed, err := password.HashPassword(s.Password)
if err != nil {
return err
@@ -36,11 +32,8 @@ func (s *User) BeforeCreate(tx *gorm.DB) error {
return nil
}
// BeforeUpdate is a GORM hook that runs before updating users
func (s *User) BeforeUpdate(tx *gorm.DB) error {
// Only hash if password field is being updated
if tx.Statement.Changed("Password") {
// Validate password strength
if err := password.ValidatePasswordStrength(s.Password); err != nil {
return err
}
@@ -55,14 +48,10 @@ func (s *User) BeforeUpdate(tx *gorm.DB) error {
return nil
}
// AfterFind is a GORM hook that runs after fetching users
func (s *User) AfterFind(tx *gorm.DB) error {
// Password remains hashed - never decrypt
// This hook is kept for potential future use
return nil
}
// Validate checks if the user data is valid
func (s *User) Validate() error {
if s.Username == "" {
return errors.New("username is required")
@@ -73,7 +62,6 @@ func (s *User) Validate() error {
return nil
}
// VerifyPassword verifies a plain text password against the stored hash
func (s *User) VerifyPassword(plainPassword string) error {
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"
)
// BaseRepository provides generic CRUD operations for any model
type BaseRepository[T any, F any] struct {
db *gorm.DB
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] {
return &BaseRepository[T, F]{
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) {
result := new([]T)
query := r.db.WithContext(ctx).Model(&r.modelType)
// Apply filter conditions if filter implements Filterable
if filterable, ok := any(filter).(Filterable); ok {
query = filterable.ApplyFilter(query)
}
// Apply pagination if filter implements Pageable
if pageable, ok := any(filter).(Pageable); ok {
offset, limit := pageable.Pagination()
query = query.Offset(offset).Limit(limit)
}
// Apply sorting if filter implements Sortable
if sortable, ok := any(filter).(Sortable); ok {
field, desc := sortable.GetSorting()
if desc {
@@ -54,7 +48,6 @@ func (r *BaseRepository[T, F]) GetAll(ctx context.Context, filter *F) (*[]T, err
return result, nil
}
// GetByID retrieves a single record by ID
func (r *BaseRepository[T, F]) GetByID(ctx context.Context, id interface{}) (*T, error) {
result := new(T)
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
}
// Insert creates a new record
func (r *BaseRepository[T, F]) Insert(ctx context.Context, model *T) error {
if err := r.db.WithContext(ctx).Create(model).Error; err != nil {
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
}
// Update modifies an existing record
func (r *BaseRepository[T, F]) Update(ctx context.Context, model *T) error {
if err := r.db.WithContext(ctx).Save(model).Error; err != nil {
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
}
// Delete removes a record by ID
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 {
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
}
// Count returns the total number of records matching the filter
func (r *BaseRepository[T, F]) Count(ctx context.Context, filter *F) (int64, error) {
var count int64
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
}
// Interfaces for filter capabilities
type Filterable interface {
ApplyFilter(*gorm.DB) *gorm.DB
}

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

@@ -17,13 +17,11 @@ 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 {
if err := r.Update(ctx, config); err != nil {
// If update fails, try to insert
if err := r.Insert(ctx, config); err != nil {
return nil
}
}
return config
}
}

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

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

@@ -10,11 +10,7 @@ import (
"go.uber.org/dig"
)
// InitializeRepositories
// Initializes Dependency Injection modules for repositories
//
// Args:
// *dig.Container: Dig Container
// *dig.Container: Dig Container
func InitializeRepositories(c *dig.Container) {
c.Provide(NewServiceControlRepository)
c.Provide(NewStateHistoryRepository)
@@ -23,17 +19,17 @@ func InitializeRepositories(c *dig.Container) {
c.Provide(NewLookupRepository)
c.Provide(NewSteamCredentialsRepository)
c.Provide(NewMembershipRepository)
c.Provide(NewLeaderboardRepository)
c.Provide(NewActivityLogRepository)
// Provide the Steam2FAManager as a singleton
if err := c.Provide(func() *model.Steam2FAManager {
manager := model.NewSteam2FAManager()
// Use graceful shutdown manager for cleanup goroutine
shutdownManager := graceful.GetManager()
shutdownManager.RunGoroutine(func(ctx context.Context) {
ticker := time.NewTicker(15 * time.Minute)
defer ticker.Stop()
for {
select {
case <-ctx.Done():
@@ -43,7 +39,7 @@ func InitializeRepositories(c *dig.Container) {
}
}
})
return manager
}); err != nil {
logging.Panic("unable to initialize steam 2fa manager")

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

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

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

19
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) {
return r.BaseRepository.GetAll(ctx, filter)
}
// Insert creates a new state history record
func (r *StateHistoryRepository) Insert(ctx context.Context, model *model.StateHistory) error {
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) {
var lastSession model.StateHistory
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 == gorm.ErrRecordNotFound {
return uuid.Nil, nil // Return nil UUID if no sessions found
return uuid.Nil, nil
}
return uuid.Nil, result.Error
}
@@ -46,10 +43,8 @@ func (r *StateHistoryRepository) GetLastSessionID(ctx context.Context, serverID
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) {
var stats model.StateHistoryStats
// Parse ServerID to UUID for query
serverUUID, err := uuid.Parse(filter.ServerID)
if err != nil {
return model.StateHistoryStats{}, err
@@ -73,12 +68,10 @@ func (r *StateHistoryRepository) GetSummaryStats(ctx context.Context, filter *mo
return stats, nil
}
// GetTotalPlaytime calculates the total playtime in minutes.
func (r *StateHistoryRepository) GetTotalPlaytime(ctx context.Context, filter *model.StateHistoryFilter) (int, error) {
var totalPlaytime struct {
TotalMinutes float64
}
// Parse ServerID to UUID for query
serverUUID, err := uuid.Parse(filter.ServerID)
if err != nil {
return 0, err
@@ -100,10 +93,8 @@ func (r *StateHistoryRepository) GetTotalPlaytime(ctx context.Context, filter *m
return int(totalPlaytime.TotalMinutes), nil
}
// GetPlayerCountOverTime gets downsampled player count data.
func (r *StateHistoryRepository) GetPlayerCountOverTime(ctx context.Context, filter *model.StateHistoryFilter) ([]model.PlayerCountPoint, error) {
var points []model.PlayerCountPoint
// Parse ServerID to UUID for query
serverUUID, err := uuid.Parse(filter.ServerID)
if err != nil {
return points, err
@@ -122,10 +113,8 @@ func (r *StateHistoryRepository) GetPlayerCountOverTime(ctx context.Context, fil
return points, err
}
// GetSessionTypes counts sessions by type.
func (r *StateHistoryRepository) GetSessionTypes(ctx context.Context, filter *model.StateHistoryFilter) ([]model.SessionCount, error) {
var sessionTypes []model.SessionCount
// Parse ServerID to UUID for query
serverUUID, err := uuid.Parse(filter.ServerID)
if err != nil {
return sessionTypes, err
@@ -145,10 +134,8 @@ func (r *StateHistoryRepository) GetSessionTypes(ctx context.Context, filter *mo
return sessionTypes, err
}
// GetDailyActivity counts sessions per day.
func (r *StateHistoryRepository) GetDailyActivity(ctx context.Context, filter *model.StateHistoryFilter) ([]model.DailyActivity, error) {
var dailyActivity []model.DailyActivity
// Parse ServerID to UUID for query
serverUUID, err := uuid.Parse(filter.ServerID)
if err != nil {
return dailyActivity, err
@@ -167,10 +154,8 @@ func (r *StateHistoryRepository) GetDailyActivity(ctx context.Context, filter *m
return dailyActivity, err
}
// GetRecentSessions retrieves the 10 most recent sessions.
func (r *StateHistoryRepository) GetRecentSessions(ctx context.Context, filter *model.StateHistoryFilter) ([]model.RecentSession, error) {
var recentSessions []model.RecentSession
// Parse ServerID to UUID for query
serverUUID, err := uuid.Parse(filter.ServerID)
if err != nil {
return recentSessions, err
@@ -187,7 +172,7 @@ func (r *StateHistoryRepository) GetRecentSessions(ctx context.Context, filter *
FROM state_histories
WHERE server_id = ? AND date_created BETWEEN ? AND ?
GROUP BY session_id
HAVING COUNT(*) > 1 AND MAX(player_count) > 0
HAVING MAX(player_count) > 0
ORDER BY date DESC
LIMIT 10
`

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
serverRepository *repository.ServerRepository
serverService *ServerService
activityLog *ActivityLogService
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")
return &ConfigService{
repository: repository,
serverRepository: serverRepository,
activityLog: activityLog,
configCache: model.NewServerConfigCache(model.CacheConfig{
ExpirationTime: 5 * time.Minute, // Cache configs for 5 minutes
ThrottleTime: 1 * time.Second, // Prevent rapid re-reads
ExpirationTime: 5 * time.Minute,
ThrottleTime: 1 * time.Second,
DefaultStatus: model.StatusUnknown,
}),
}
@@ -88,23 +90,155 @@ func (as *ConfigService) SetServerService(serverService *ServerService) {
as.serverService = serverService
}
// UpdateConfig
// Updates physical config file and caches it in database.
//
// Args:
// context.Context: Application context
// Returns:
// 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) {
serverID := ctx.Locals("serverId").(string)
configFile := ctx.Params("file")
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) updateConfigInternal(ctx context.Context, serverID string, configFile string, body *map[string]interface{}, override bool) (*model.Config, error) {
func (as *ConfigService) updateConfigFiles(ctx context.Context, server *model.Server, configFile string, body *map[string]interface{}, override bool) ([]byte, []byte, 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)
if err != nil {
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")
}
// Read existing config
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)
oldDataUTF8, newData, err := as.updateConfigFiles(ctx, server, configFile, body, override)
if err != nil {
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.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{
ServerID: serverUUID,
ConfigFile: configFile,
@@ -183,10 +281,6 @@ func (as *ConfigService) updateConfigInternal(ctx context.Context, serverID stri
}), nil
}
// GetConfig
// Gets physical config file and caches it in database.
//
// Args:
// context.Context: Application context
// Returns:
// 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)
server, err := as.serverRepository.GetByID(ctx.UserContext(), serverIDStr)
if err != nil {
logging.Error("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 {
case ConfigurationJson:
if cached, ok := as.configCache.GetConfiguration(serverIDStr); ok {
logging.Debug("Returning cached configuration for server ID: %s", serverIDStr)
return cached, nil
return *cached, nil
}
case AssistRulesJson:
if cached, ok := as.configCache.GetAssistRules(serverIDStr); ok {
logging.Debug("Returning cached assist rules for server ID: %s", serverIDStr)
return cached, nil
return *cached, nil
}
case EventJson:
if cached, ok := as.configCache.GetEvent(serverIDStr); ok {
logging.Debug("Returning cached event config for server ID: %s", serverIDStr)
return cached, nil
return *cached, nil
}
case EventRulesJson:
if cached, ok := as.configCache.GetEventRules(serverIDStr); ok {
logging.Debug("Returning cached event rules for server ID: %s", serverIDStr)
return cached, nil
return *cached, nil
}
case SettingsJson:
if cached, ok := as.configCache.GetSettings(serverIDStr); ok {
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)
// Not in cache, load from disk
configPath := filepath.Join(server.GetConfigPath(), configFile)
configPath := server.GetConfigPath()
decoder := DecodeFileName(configFile)
if decoder == nil {
return nil, errors.New("invalid config file")
@@ -244,43 +341,39 @@ func (as *ConfigService) GetConfig(ctx *fiber.Ctx) (interface{}, error) {
if err != nil {
if os.IsNotExist(err) {
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 {
case ConfigurationJson:
return &model.Configuration{}, nil
return model.Configuration{}, nil
case AssistRulesJson:
return &model.AssistRules{}, nil
return model.AssistRules{}, nil
case EventJson:
return &model.EventConfig{}, nil
return model.EventConfig{}, nil
case EventRulesJson:
return &model.EventRules{}, nil
return model.EventRules{}, nil
case SettingsJson:
return &model.ServerSettings{}, nil
return model.ServerSettings{}, nil
}
}
return nil, err
}
// Cache the loaded config
switch configFile {
case ConfigurationJson:
as.configCache.UpdateConfiguration(serverIDStr, *config.(*model.Configuration))
as.configCache.UpdateConfiguration(serverIDStr, config.(model.Configuration))
case AssistRulesJson:
as.configCache.UpdateAssistRules(serverIDStr, *config.(*model.AssistRules))
as.configCache.UpdateAssistRules(serverIDStr, config.(model.AssistRules))
case EventJson:
as.configCache.UpdateEvent(serverIDStr, *config.(*model.EventConfig))
as.configCache.UpdateEvent(serverIDStr, config.(model.EventConfig))
case EventRulesJson:
as.configCache.UpdateEventRules(serverIDStr, *config.(*model.EventRules))
as.configCache.UpdateEventRules(serverIDStr, config.(model.EventRules))
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)
return config, nil
}
// GetConfigs
// Gets all configurations for a server, using cache when possible.
func (as *ConfigService) GetConfigs(ctx *fiber.Ctx) (*model.Configurations, error) {
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) {
serverIDStr := server.ID.String()
logging.Info("Loading configs for server ID: %s at path: %s", serverIDStr, server.GetConfigPath())
configs := &model.Configurations{}
// Load configuration
if cached, ok := as.configCache.GetConfiguration(serverIDStr); ok {
logging.Debug("Using cached configuration for server %s", serverIDStr)
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)
settingsConf, err := as.getConfigFile(server, SettingsJson)
if err != nil {
return nil, err
}
// Load assist rules
if cached, ok := as.configCache.GetAssistRules(serverIDStr); ok {
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)
eventRulesConf, err := as.getConfigFile(server, EventRulesJson)
if err != nil {
return nil, err
}
// Load event config
if cached, ok := as.configCache.GetEvent(serverIDStr); ok {
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)
eventConf, err := as.getConfigFile(server, EventJson)
if err != nil {
return nil, err
}
// Load event rules
if cached, ok := as.configCache.GetEventRules(serverIDStr); ok {
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)
assistRulesConf, err := as.getConfigFile(server, AssistRulesJson)
if err != nil {
return nil, err
}
// Load settings
if cached, ok := as.configCache.GetSettings(serverIDStr); ok {
logging.Debug("Using cached settings for server %s", serverIDStr)
configs.Settings = *cached
} else {
logging.Debug("Loading settings from disk for server %s", serverIDStr)
settings, err := mustDecode[model.ServerSettings](SettingsJson, server.GetConfigPath())
if err != nil {
logging.Error("Failed to load settings for server %s: %v", serverIDStr, err)
return nil, fmt.Errorf("failed to load settings: %v", err)
}
configs.Settings = settings
as.configCache.UpdateSettings(serverIDStr, settings)
configurationConf, err := as.getConfigFile(server, ConfigurationJson)
if err != nil {
return nil, err
}
configs := &model.Configurations{
Settings: settingsConf.(model.ServerSettings),
EventRules: eventRulesConf.(model.EventRules),
Event: eventConf.(model.EventConfig),
AssistRules: assistRulesConf.(model.AssistRules),
Configuration: configurationConf.(model.Configuration),
}
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)
oldData, err := os.ReadFile(configPath)
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 oldData, nil
@@ -475,9 +516,15 @@ func (as *ConfigService) GetConfiguration(server *model.Server) (*model.Configur
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 {
// Convert config to map for UpdateConfig
configMap := make(map[string]interface{})
configBytes, err := json.Marshal(config)
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)
}
// Update the configuration using the internal method
_, err = as.updateConfigInternal(context.Background(), server.ID.String(), ConfigurationJson, &configMap, true)
_, _, err = as.updateConfigFiles(context.Background(), server, ConfigurationJson, &configMap, true)
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()
}
}

4
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 {
// First delete existing rules
if err := s.DeleteServerRules(serverName, tcpPorts, udpPorts); err != nil {
return err
}
// Then create new rules
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

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

@@ -12,13 +12,11 @@ import (
"github.com/google/uuid"
)
// CacheInvalidator interface for cache invalidation
type CacheInvalidator interface {
InvalidateUserPermissions(userID string)
InvalidateAllUserPermissions()
}
// MembershipService provides business logic for membership-related operations.
type MembershipService struct {
repo *repository.MembershipRepository
cacheInvalidator CacheInvalidator
@@ -26,29 +24,25 @@ type MembershipService struct {
openJwtHandler *jwt.OpenJWTHandler
}
// NewMembershipService creates a new MembershipService.
func NewMembershipService(repo *repository.MembershipRepository, jwtHandler *jwt.JWTHandler, openJwtHandler *jwt.OpenJWTHandler) *MembershipService {
return &MembershipService{
repo: repo,
cacheInvalidator: nil, // Will be set later via SetCacheInvalidator
cacheInvalidator: nil,
jwtHandler: jwtHandler,
openJwtHandler: openJwtHandler,
}
}
// SetCacheInvalidator sets the cache invalidator after service initialization
func (s *MembershipService) SetCacheInvalidator(invalidator CacheInvalidator) {
s.cacheInvalidator = invalidator
}
// Login authenticates a user and returns a JWT.
func (s *MembershipService) HandleLogin(ctx context.Context, username, password string) (*model.User, error) {
user, err := s.repo.FindUserByUsername(ctx, username)
if err != nil {
return nil, errors.New("invalid credentials")
}
// Use secure password verification with constant-time comparison
if err := user.VerifyPassword(password); err != nil {
return nil, errors.New("invalid credentials")
}
@@ -56,26 +50,19 @@ func (s *MembershipService) HandleLogin(ctx context.Context, username, password
return user, nil
}
// Login authenticates a user and returns a JWT.
func (s *MembershipService) Login(ctx context.Context, username, password string) (string, error) {
user, err := s.HandleLogin(ctx, username, password)
if err != nil {
return "", err
}
return s.jwtHandler.GenerateToken(user)
return s.jwtHandler.GenerateToken(user.ID.String())
}
func (s *MembershipService) GenerateOpenToken(ctx context.Context, userId string) (string, error) {
user, err := s.repo.GetByID(ctx, userId)
if err != nil {
return "", err
}
return s.openJwtHandler.GenerateToken(user)
return s.openJwtHandler.GenerateToken(userId)
}
// CreateUser creates a new user.
func (s *MembershipService) CreateUser(ctx context.Context, username, password, roleName string) (*model.User, error) {
role, err := s.repo.FindRoleByName(ctx, roleName)
@@ -99,43 +86,35 @@ func (s *MembershipService) CreateUser(ctx context.Context, username, password,
return user, nil
}
// ListUsers retrieves all users.
func (s *MembershipService) ListUsers(ctx context.Context) ([]*model.User, error) {
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) {
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) {
return s.repo.FindUserByIDWithPermissions(ctx, userID)
}
// UpdateUserRequest defines the request body for updating a user.
type UpdateUserRequest struct {
Username *string `json:"username"`
Password *string `json:"password"`
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 {
// Get user with role information
user, err := s.repo.FindUserByID(ctx, userID)
if err != nil {
return errors.New("user not found")
}
// Get role to check if it's Super Admin
role, err := s.repo.FindRoleByID(ctx, user.RoleID)
if err != nil {
return errors.New("user role not found")
}
// Prevent deletion of Super Admin users
if role.Name == "Super Admin" {
return errors.New("cannot delete Super Admin user")
}
@@ -145,7 +124,6 @@ func (s *MembershipService) DeleteUser(ctx context.Context, userID uuid.UUID) er
return err
}
// Invalidate cache for deleted user
if s.cacheInvalidator != nil {
s.cacheInvalidator.InvalidateUserPermissions(userID.String())
}
@@ -154,7 +132,6 @@ func (s *MembershipService) DeleteUser(ctx context.Context, userID uuid.UUID) er
return nil
}
// UpdateUser updates a user's details.
func (s *MembershipService) UpdateUser(ctx context.Context, userID uuid.UUID, req UpdateUserRequest) (*model.User, error) {
user, err := s.repo.FindUserByID(ctx, userID)
if err != nil {
@@ -166,12 +143,10 @@ func (s *MembershipService) UpdateUser(ctx context.Context, userID uuid.UUID, re
}
if req.Password != nil && *req.Password != "" {
// Password will be automatically hashed in BeforeUpdate hook
user.Password = *req.Password
}
if req.RoleID != nil {
// Check if role exists
_, err := s.repo.FindRoleByID(ctx, *req.RoleID)
if err != nil {
return nil, errors.New("role not found")
@@ -183,7 +158,6 @@ func (s *MembershipService) UpdateUser(ctx context.Context, userID uuid.UUID, re
return nil, err
}
// Invalidate cache if role was changed
if req.RoleID != nil && s.cacheInvalidator != nil {
s.cacheInvalidator.InvalidateUserPermissions(userID.String())
}
@@ -192,14 +166,12 @@ func (s *MembershipService) UpdateUser(ctx context.Context, userID uuid.UUID, re
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) {
user, err := s.repo.FindUserByIDWithPermissions(ctx, userID)
if err != nil {
return false, err
}
// Super admin and Admin have all permissions
if user.Role.Name == "Super Admin" || user.Role.Name == "Admin" {
return true, nil
}
@@ -213,15 +185,13 @@ func (s *MembershipService) HasPermission(ctx context.Context, userID string, pe
return false, nil
}
// SetupInitialData creates the initial roles and permissions.
func (s *MembershipService) SetupInitialData(ctx context.Context) error {
// Define all permissions
permissions := model.AllPermissions()
createdPermissions := make([]model.Permission, 0)
for _, pName := range permissions {
perm, err := s.repo.FindPermissionByName(ctx, pName)
if err != nil { // Assuming error means not found
if err != nil {
perm = &model.Permission{Name: pName}
if err := s.repo.CreatePermission(ctx, perm); err != nil {
return err
@@ -230,7 +200,6 @@ func (s *MembershipService) SetupInitialData(ctx context.Context) error {
createdPermissions = append(createdPermissions, *perm)
}
// Create Super Admin role with all permissions
superAdminRole, err := s.repo.FindRoleByName(ctx, "Super Admin")
if err != nil {
superAdminRole = &model.Role{Name: "Super Admin"}
@@ -242,7 +211,6 @@ func (s *MembershipService) SetupInitialData(ctx context.Context) error {
return err
}
// Create Admin role with same permissions as Super Admin
adminRole, err := s.repo.FindRoleByName(ctx, "Admin")
if err != nil {
adminRole = &model.Role{Name: "Admin"}
@@ -254,7 +222,6 @@ func (s *MembershipService) SetupInitialData(ctx context.Context) error {
return err
}
// Create Manager role with limited permissions (excluding membership, role, user, server create/delete)
managerRole, err := s.repo.FindRoleByName(ctx, "Manager")
if err != nil {
managerRole = &model.Role{Name: "Manager"}
@@ -263,7 +230,6 @@ func (s *MembershipService) SetupInitialData(ctx context.Context) error {
}
}
// Define manager permissions (limited set)
managerPermissionNames := []string{
model.ServerView,
model.ServerUpdate,
@@ -287,16 +253,14 @@ func (s *MembershipService) SetupInitialData(ctx context.Context) error {
return err
}
// Invalidate all caches after role setup changes
if s.cacheInvalidator != nil {
s.cacheInvalidator.InvalidateAllUserPermissions()
}
// Create a default admin user if one doesn't exist
_, err = s.repo.FindUserByUsername(ctx, "admin")
if err != nil {
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 {
return err
}
@@ -305,7 +269,6 @@ func (s *MembershipService) SetupInitialData(ctx context.Context) error {
return nil
}
// GetAllRoles retrieves all roles for dropdown selection.
func (s *MembershipService) GetAllRoles(ctx context.Context) ([]*model.Role, error) {
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 (
"acc-server-manager/local/model"
"acc-server-manager/local/platform"
"acc-server-manager/local/repository"
"acc-server-manager/local/utl/env"
"acc-server-manager/local/utl/logging"
"acc-server-manager/local/utl/network"
"acc-server-manager/local/utl/tracking"
"context"
"fmt"
@@ -12,30 +14,31 @@ import (
"sync"
"time"
"acc-server-manager/local/utl/network"
"github.com/gofiber/fiber/v2"
"github.com/google/uuid"
)
const (
DefaultStartPort = 9600
RequiredPortCount = 1 // Update this if ACC needs more ports
RequiredPortCount = 1
)
type ServerService struct {
repository *repository.ServerRepository
stateHistoryRepo *repository.StateHistoryRepository
activityLogRepo *repository.ActivityLogRepository
apiService *ServiceControlService
configService *ConfigService
steamService *SteamService
windowsService *WindowsService
firewallService *FirewallService
instances sync.Map // Track instances per server
lastInsertTimes sync.Map // Track last insert time per server
debouncers sync.Map // Track debounce timers per server
logTailers sync.Map // Track log tailers per server
sessionIDs sync.Map // Track current session ID per server
runtime platform.ServerRuntime
firewall platform.FirewallManager
logStreamer platform.LogStreamer
portPool *network.PortPoolManager
webSocketService *WebSocketService
instances sync.Map // uuid.UUID → *tracking.AccServerInstance
lastInsertTimes sync.Map // uuid.UUID → time.Time
debouncers sync.Map // uuid.UUID → *pendingState
sessionIDs sync.Map // uuid.UUID → uuid.UUID
}
type pendingState struct {
@@ -44,42 +47,40 @@ type pendingState struct {
}
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() {
logPath := filepath.Join(server.GetLogPath(), "server.log")
tailer := tracking.NewLogTailer(logPath, instance.HandleLogLine)
s.logTailers.Store(server.ID, tailer)
// Start tailing and automatically handle file changes
tailer.Start()
if err := s.logStreamer.Start(context.Background(), server, instance.HandleLogLine); err != nil {
logging.Error("Failed to start log streaming for server %s: %v", server.ID, err)
}
}()
}
func NewServerService(
repository *repository.ServerRepository,
stateHistoryRepo *repository.StateHistoryRepository,
activityLogRepo *repository.ActivityLogRepository,
apiService *ServiceControlService,
configService *ConfigService,
steamService *SteamService,
windowsService *WindowsService,
firewallService *FirewallService,
runtime platform.ServerRuntime,
firewall platform.FirewallManager,
logStreamer platform.LogStreamer,
portPool *network.PortPoolManager,
webSocketService *WebSocketService,
) *ServerService {
service := &ServerService{
repository: repository,
stateHistoryRepo: stateHistoryRepo,
activityLogRepo: activityLogRepo,
apiService: apiService,
configService: configService,
steamService: steamService,
windowsService: windowsService,
firewallService: firewallService,
runtime: runtime,
firewall: firewall,
logStreamer: logStreamer,
portPool: portPool,
webSocketService: webSocketService,
}
// Initialize server instances
servers, err := repository.GetAll(context.Background(), &model.ServerFilter{})
if err != nil {
logging.Error("Failed to get servers: %v", err)
@@ -87,7 +88,6 @@ func NewServerService(
}
for i := range *servers {
// Initialize instance regardless of status
logging.Info("Starting server runtime for server ID: %d", (*servers)[i].ID)
service.StartAccServerRuntime(&(*servers)[i])
}
@@ -96,7 +96,7 @@ func NewServerService(
}
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)
if !exists {
@@ -119,16 +119,15 @@ func (s *ServerService) getNextSessionID(serverID uuid.UUID) uuid.UUID {
lastID, err := s.stateHistoryRepo.GetLastSessionID(context.Background(), serverID)
if err != nil {
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 {
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) {
// Get or create session ID when session changes
currentSessionInterface, exists := s.instances.Load(serverID)
var sessionID uuid.UUID
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) {
// Get configs using helper methods
func (s *ServerService) updateSessionDuration(server *model.Server, sessionType model.TrackSession) {
event, err := s.configService.GetEventConfig(server)
if err != nil {
event = &model.EventConfig{}
@@ -178,9 +176,7 @@ func (s *ServerService) updateSessionDuration(server *model.Server, sessionType
serverInstance.State.Track = event.Track
serverInstance.State.MaxConnections = configuration.MaxConnections.ToInt()
// Check if session type has changed
if serverInstance.State.Session != sessionType {
// Get new session ID for the new session
sessionID := s.getNextSessionID(server.ID)
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) {
// Get the base steamcmd path from environment variable
steamCMDPath := env.GetSteamCMDDirPath()
server.Path = server.GenerateServerPath(steamCMDPath)
server.GenerateUUID()
if server.ServiceName == "" {
server.ServiceName = server.GenerateServiceName()
}
if env.IsDockerPlatform() {
server.Path = filepath.Join(env.GetACCServersPath(), server.ServiceName)
server.FromSteamCMD = true
} else {
steamCMDPath := env.GetSteamCMDDirPath()
server.Path = server.GenerateServerPath(steamCMDPath)
server.FromSteamCMD = true
}
}
func (s *ServerService) handleStateChange(server *model.Server, state *model.ServerState) {
// Update session duration when session changes
s.updateSessionDuration(server, state.Session)
// Invalidate status cache when server state changes
s.apiService.statusCache.InvalidateStatus(server.ServiceName)
// Cancel existing timer if any
if debouncer, exists := s.debouncers.Load(server.ID); exists {
pending := debouncer.(*pendingState)
pending.timer.Stop()
}
// Create new timer
timer := time.NewTimer(5 * time.Minute)
s.debouncers.Store(server.ID, &pendingState{
timer: timer,
state: state,
})
// Start goroutine to handle the delayed insert
go func() {
<-timer.C
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) {
s.insertStateHistory(server.ID, state)
}
}
func (s *ServerService) StartAccServerRuntime(server *model.Server) {
// Get or create instance
instanceInterface, exists := s.instances.Load(server.ID)
var instance *tracking.AccServerInstance
if !exists {
@@ -255,23 +254,14 @@ func (s *ServerService) StartAccServerRuntime(server *model.Server) {
instance = instanceInterface.(*tracking.AccServerInstance)
}
// Invalidate config cache for this server before loading new configs
serverIDStr := server.ID.String()
s.configService.configCache.InvalidateServerCache(serverIDStr)
s.updateSessionDuration(server, instance.State.Session)
// Ensure log tailing is running (regardless of server status)
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) {
servers, err := s.repository.GetAll(ctx.UserContext(), filter)
if err != nil {
@@ -279,6 +269,16 @@ func (s *ServerService) GetAll(ctx *fiber.Ctx, filter *model.ServerFilter) (*[]m
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 {
server := &(*servers)[i]
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
}
}
if lastActivities != nil {
if info, ok := lastActivities[server.ID]; ok {
server.LastActivity = info
}
}
}
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) {
server, err := as.repository.GetByID(ctx.UserContext(), serverID)
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)
if err != nil {
logging.Error(err.Error())
logging.Error("Failed to get cached status: %v", err)
}
server.Status = model.ParseServiceStatus(status)
instance, ok := as.instances.Load(server.ID)
@@ -330,184 +328,255 @@ func (as *ServerService) GetById(ctx *fiber.Ctx, serverID uuid.UUID) (*model.Ser
return server, nil
}
func (s *ServerService) CreateServer(ctx *fiber.Ctx, server *model.Server) error {
// Validate basic server configuration
func (s *ServerService) CreateServerAsync(ctx *fiber.Ctx, server *model.Server) error {
logging.Info("create server start")
if err := server.Validate(); err != nil {
logging.Info("create server validation failed")
return err
}
// Install server using SteamCMD
if err := s.steamService.InstallServer(ctx.UserContext(), server.GetServerPath(), &server.ID); err != nil {
return fmt.Errorf("failed to install server: %v", err)
}
s.GenerateServerPath(server)
// Create Windows service with correct paths
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)
}
bgCtx := context.Background()
s.configureFirewall(server)
ports, err := network.FindAvailablePortRange(DefaultStartPort, RequiredPortCount)
if err != nil {
return fmt.Errorf("failed to find available ports: %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)
go func() {
logging.Info("create server start background")
if err := s.createServerBackground(bgCtx, server); err != nil {
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))
}
}()
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 {
// Get server details
server, err := s.repository.GetByID(ctx.UserContext(), serverID)
if err != nil {
return fmt.Errorf("failed to get server details: %v", err)
}
// Stop and remove Windows service
if err := s.windowsService.DeleteService(ctx.UserContext(), server.ServiceName); err != nil {
logging.Error("Failed to delete Windows service: %v", err)
if err := s.runtime.Delete(ctx.UserContext(), server.ID); err != nil {
logging.Error("Failed to delete server instance: %v", err)
}
// Remove firewall rules
configuration, err := s.configService.GetConfiguration(server)
if err != nil {
logging.Error("Failed to get configuration for server %d: %v", server.ID, err)
}
tcpPorts := []int{configuration.TcpPort.ToInt()}
udpPorts := []int{configuration.UdpPort.ToInt()}
if err := s.firewallService.DeleteServerRules(server.ServiceName, tcpPorts, udpPorts); err != nil {
logging.Error("Failed to delete firewall rules: %v", err)
if configuration != nil {
tcpPorts := []int{configuration.TcpPort.ToInt()}
udpPorts := []int{configuration.UdpPort.ToInt()}
if err := s.firewall.DeleteServerRules(server.ServiceName, tcpPorts, udpPorts); err != nil {
logging.Error("Failed to delete firewall rules: %v", err)
}
}
// Uninstall server files
if err := s.steamService.UninstallServer(server.Path); err != nil {
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 {
return fmt.Errorf("failed to delete server from database: %v", err)
}
// Cleanup runtime resources
if tailer, exists := s.logTailers.Load(server.ID); exists {
tailer.(*tracking.LogTailer).Stop()
s.logTailers.Delete(server.ID)
}
s.logStreamer.Stop(server.ID)
s.instances.Delete(server.ID)
s.lastInsertTimes.Delete(server.ID)
s.debouncers.Delete(server.ID)
s.sessionIDs.Delete(server.ID)
// Invalidate status cache for deleted server
s.apiService.statusCache.InvalidateStatus(server.ServiceName)
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 {
// Find available ports for the server
ports, err := network.FindAvailablePortRange(DefaultStartPort, RequiredPortCount)
if err != nil {
return fmt.Errorf("failed to find available ports: %v", err)
}
// Use the first port for both TCP and UDP
serverPort := ports[0]
tcpPorts := []int{serverPort}
udpPorts := []int{serverPort}
logging.Info("Configuring firewall for server %d with port %d", server.ID, serverPort)
// Configure firewall rules
if err := s.firewallService.UpdateServerRules(server.Name, tcpPorts, udpPorts); err != nil {
if err := s.firewall.UpdateServerRules(server.Name, tcpPorts, udpPorts); err != nil {
return fmt.Errorf("failed to configure firewall: %v", err)
}
// 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)
}
@@ -516,7 +585,6 @@ func (s *ServerService) configureFirewall(server *model.Server) error {
}
func (s *ServerService) updateServerPort(server *model.Server, port int) error {
// Load current configuration
config, err := s.configService.GetConfiguration(server)
if err != nil {
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.UdpPort = model.IntString(port)
config.RegisterToLobby = model.IntString(1)
// Save the updated configuration
if err := s.configService.SaveConfiguration(server, config); err != nil {
return fmt.Errorf("failed to save server configuration: %v", err)
}
if env.IsDockerPlatform() {
if err := s.configService.SetIgnorePrematureDisconnects(server, 0); err != nil {
logging.Error("Failed to set ignorePrematureDisconnects for Docker server: %v", err)
}
}
return nil
}

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

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

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

@@ -2,6 +2,7 @@ package service
import (
"acc-server-manager/local/model"
"acc-server-manager/local/platform"
"acc-server-manager/local/repository"
"context"
"errors"
@@ -14,24 +15,52 @@ type ServiceControlService struct {
repository *repository.ServiceControlRepository
serverRepository *repository.ServerRepository
serverService *ServerService
activityLog *ActivityLogService
statusCache *model.ServerStatusCache
windowsService *WindowsService
runtime platform.ServerRuntime
}
func NewServiceControlService(repository *repository.ServiceControlRepository,
serverRepository *repository.ServerRepository) *ServiceControlService {
func NewServiceControlService(
repository *repository.ServiceControlRepository,
serverRepository *repository.ServerRepository,
runtime platform.ServerRuntime,
activityLog *ActivityLogService,
) *ServiceControlService {
return &ServiceControlService{
repository: repository,
serverRepository: serverRepository,
runtime: runtime,
activityLog: activityLog,
statusCache: model.NewServerStatusCache(model.CacheConfig{
ExpirationTime: 30 * time.Second, // Cache expires after 30 seconds
ThrottleTime: 5 * time.Second, // Minimum 5 seconds between checks
DefaultStatus: model.StatusRunning, // Default to running if throttled
ExpirationTime: 30 * time.Second,
ThrottleTime: 5 * time.Second,
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) {
as.serverService = serverService
}
@@ -42,30 +71,27 @@ func (as *ServiceControlService) GetStatus(ctx *fiber.Ctx) (string, error) {
return "", err
}
// Try to get status from cache
if status, shouldCheck := as.statusCache.GetStatus(serviceName); !shouldCheck {
return status.String(), nil
}
// If cache miss or expired, check actual status
statusStr, err := as.StatusServer(serviceName)
if err != nil {
return "", err
}
// Parse and update cache with new status
status := model.ParseServiceStatus(statusStr)
as.statusCache.UpdateStatus(serviceName, status)
return status.String(), nil
}
func (as *ServiceControlService) ServiceControlStartServer(ctx *fiber.Ctx) (string, error) {
serviceName, err := as.GetServiceName(ctx)
server, err := as.getServerFromCtx(ctx)
if err != nil {
return "", err
}
serviceName := server.ServiceName
// Update status cache for this service before starting
as.statusCache.UpdateStatus(serviceName, model.StatusStarting)
_, err = as.StartServer(serviceName)
@@ -77,19 +103,32 @@ func (as *ServiceControlService) ServiceControlStartServer(ctx *fiber.Ctx) (stri
return "", err
}
// Parse and update cache with new status
status := model.ParseServiceStatus(statusStr)
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
}
func (as *ServiceControlService) ServiceControlStopServer(ctx *fiber.Ctx) (string, error) {
serviceName, err := as.GetServiceName(ctx)
server, err := as.getServerFromCtx(ctx)
if err != nil {
return "", err
}
serviceName := server.ServiceName
// Update status cache for this service before stopping
as.statusCache.UpdateStatus(serviceName, model.StatusStopping)
_, err = as.StopServer(serviceName)
@@ -101,19 +140,32 @@ func (as *ServiceControlService) ServiceControlStopServer(ctx *fiber.Ctx) (strin
return "", err
}
// Parse and update cache with new status
status := model.ParseServiceStatus(statusStr)
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
}
func (as *ServiceControlService) ServiceControlRestartServer(ctx *fiber.Ctx) (string, error) {
serviceName, err := as.GetServiceName(ctx)
server, err := as.getServerFromCtx(ctx)
if err != nil {
return "", err
}
serviceName := server.ServiceName
// Update status cache for this service before restarting
as.statusCache.UpdateStatus(serviceName, model.StatusRestarting)
_, err = as.RestartServer(serviceName)
@@ -125,74 +177,89 @@ func (as *ServiceControlService) ServiceControlRestartServer(ctx *fiber.Ctx) (st
return "", err
}
// Parse and update cache with new status
status := model.ParseServiceStatus(statusStr)
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
}
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) {
// Try to get status from cache
if status, shouldCheck := as.statusCache.GetStatus(serviceName); !shouldCheck {
return status.String(), nil
}
// If cache miss or expired, check actual status
statusStr, err := as.StatusServer(serviceName)
if err != nil {
return "", err
}
// Parse and update cache with new status
status := model.ParseServiceStatus(statusStr)
as.statusCache.UpdateStatus(serviceName, status)
return status.String(), nil
}
func (as *ServiceControlService) StartServer(serviceName string) (string, error) {
status, err := as.windowsService.Start(context.Background(), serviceName)
if err != nil {
return "", err
}
server, err := as.serverRepository.GetFirstByServiceName(context.Background(), serviceName)
if err != nil {
return "", err
}
status, err := as.runtime.Start(context.Background(), server.ID)
if err != nil {
return "", err
}
as.serverService.StartAccServerRuntime(server)
return status, err
}
func (as *ServiceControlService) StopServer(serviceName string) (string, error) {
status, err := as.windowsService.Stop(context.Background(), serviceName)
if err != nil {
return "", err
}
server, err := as.serverRepository.GetFirstByServiceName(context.Background(), serviceName)
if err != nil {
return "", err
}
as.serverService.instances.Delete(server.ID)
status, err := as.runtime.Stop(context.Background(), server.ID)
if err != nil {
return "", err
}
as.serverService.instances.Delete(server.ID)
return status, err
}
func (as *ServiceControlService) RestartServer(serviceName string) (string, error) {
status, err := as.windowsService.Restart(context.Background(), serviceName)
if err != nil {
return "", err
}
server, err := as.serverRepository.GetFirstByServiceName(context.Background(), serviceName)
if err != nil {
return "", err
}
status, err := as.runtime.Restart(context.Background(), server.ID)
if err != nil {
return "", err
}
as.serverService.StartAccServerRuntime(server)
return status, err
}

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

@@ -6,7 +6,7 @@ import (
)
type ServiceManager struct {
executor *command.CommandExecutor
executor *command.CommandExecutor
psExecutor *command.CommandExecutor
}
@@ -24,17 +24,14 @@ func NewServiceManager() *ServiceManager {
}
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)
if err != nil {
return "", err
}
// Clean up output by removing null bytes and trimming whitespace
cleaned := strings.TrimSpace(strings.ReplaceAll(output, "\x00", ""))
// Remove \r\n from status strings
cleaned = strings.TrimSuffix(cleaned, "\r\n")
return cleaned, nil
}
@@ -51,11 +48,9 @@ func (s *ServiceManager) Stop(serviceName string) (string, error) {
}
func (s *ServiceManager) Restart(serviceName string) (string, error) {
// First stop the service
if _, err := s.Stop(serviceName); err != nil {
return "", err
}
// Then start it again
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())
// Get Summary Stats (Peak/Avg Players, Total Sessions)
eg.Go(func() error {
summary, err := s.repository.GetSummaryStats(gCtx, filter)
if err != nil {
@@ -61,7 +60,6 @@ func (s *StateHistoryService) GetStatistics(ctx *fiber.Ctx, filter *model.StateH
return nil
})
// Get Total Playtime
eg.Go(func() error {
playtime, err := s.repository.GetTotalPlaytime(gCtx, filter)
if err != nil {
@@ -74,7 +72,6 @@ func (s *StateHistoryService) GetStatistics(ctx *fiber.Ctx, filter *model.StateH
return nil
})
// Get Player Count Over Time
eg.Go(func() error {
playerCount, err := s.repository.GetPlayerCountOverTime(gCtx, filter)
if err != nil {
@@ -87,7 +84,6 @@ func (s *StateHistoryService) GetStatistics(ctx *fiber.Ctx, filter *model.StateH
return nil
})
// Get Session Types
eg.Go(func() error {
sessionTypes, err := s.repository.GetSessionTypes(gCtx, filter)
if err != nil {
@@ -100,7 +96,6 @@ func (s *StateHistoryService) GetStatistics(ctx *fiber.Ctx, filter *model.StateH
return nil
})
// Get Daily Activity
eg.Go(func() error {
dailyActivity, err := s.repository.GetDailyActivity(gCtx, filter)
if err != nil {
@@ -113,7 +108,6 @@ func (s *StateHistoryService) GetStatistics(ctx *fiber.Ctx, filter *model.StateH
return nil
})
// Get Recent Sessions
eg.Go(func() error {
recentSessions, err := s.repository.GetRecentSessions(gCtx, filter)
if err != nil {

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

@@ -11,6 +11,7 @@ import (
"fmt"
"os"
"path/filepath"
"strings"
"time"
"github.com/google/uuid"
@@ -21,12 +22,11 @@ const (
)
type SteamService struct {
executor *command.CommandExecutor
interactiveExecutor *command.InteractiveCommandExecutor
repository *repository.SteamCredentialsRepository
tfaManager *model.Steam2FAManager
pathValidator *security.PathValidator
downloadVerifier *security.DownloadVerifier
executor *command.CommandExecutor
repository *repository.SteamCredentialsRepository
tfaManager *model.Steam2FAManager
pathValidator *security.PathValidator
downloadVerifier *security.DownloadVerifier
}
func NewSteamService(repository *repository.SteamCredentialsRepository, tfaManager *model.Steam2FAManager) *SteamService {
@@ -36,15 +36,22 @@ func NewSteamService(repository *repository.SteamCredentialsRepository, tfaManag
}
return &SteamService{
executor: baseExecutor,
interactiveExecutor: command.NewInteractiveCommandExecutor(baseExecutor, tfaManager),
repository: repository,
tfaManager: tfaManager,
pathValidator: security.NewPathValidator(),
downloadVerifier: security.NewDownloadVerifier(),
executor: baseExecutor,
repository: repository,
tfaManager: tfaManager,
pathValidator: security.NewPathValidator(),
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) {
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 {
// 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()
steamCMDDir := filepath.Dir(steamCMDPath)
// Check if SteamCMD exists
if _, err := os.Stat(steamCMDPath); !os.IsNotExist(err) {
return nil
}
// Create directory if it doesn't exist
if err := os.MkdirAll(steamCMDDir, 0755); err != nil {
return fmt.Errorf("failed to create SteamCMD directory: %v", err)
}
// Download and install SteamCMD securely
logging.Info("Downloading SteamCMD...")
steamCMDZip := filepath.Join(steamCMDDir, "steamcmd.zip")
if err := s.downloadVerifier.VerifyAndDownload(
@@ -81,95 +89,263 @@ func (s *SteamService) ensureSteamCMD(_ context.Context) error {
return fmt.Errorf("failed to download SteamCMD: %v", err)
}
// Extract SteamCMD
logging.Info("Extracting SteamCMD...")
if err := s.executor.Execute("-Command",
fmt.Sprintf("Expand-Archive -Path 'steamcmd.zip' -DestinationPath '%s'", steamCMDDir)); err != nil {
return fmt.Errorf("failed to extract SteamCMD: %v", err)
}
// Clean up zip file
os.Remove("steamcmd.zip")
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 {
wsService.BroadcastSteamOutput(*serverID, fmt.Sprintf("Error ensuring SteamCMD: %v", err), true)
return err
}
// Validate installation path for security
if err := s.pathValidator.ValidateInstallPath(installPath); err != nil {
return fmt.Errorf("invalid installation path: %v", err)
if !env.IsDockerPlatform() {
if err := s.pathValidator.ValidateInstallPath(installPath); err != nil {
wsService.BroadcastSteamOutput(*serverID, fmt.Sprintf("Invalid installation path: %v", err), true)
return fmt.Errorf("invalid installation path: %v", err)
}
}
// Convert to absolute path and ensure proper Windows path format
absPath, err := filepath.Abs(installPath)
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)
}
absPath = filepath.Clean(absPath)
// Ensure install path exists
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)
}
// Get Steam credentials
wsService.BroadcastSteamOutput(*serverID, fmt.Sprintf("Installation directory prepared: %s", absPath), false)
creds, err := s.GetCredentials(ctx)
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)
}
// Get SteamCMD path from environment variable
steamCMDPath := env.GetSteamCMDPath()
steamCMDPath := getSteamCMDPath()
// Build SteamCMD command
args := []string{
"-nologo",
"-noprofile",
steamCMDPath,
steamCMDArgs := []string{
"+force_install_dir", absPath,
"+login",
}
if creds != nil && creds.Username != "" {
args = append(args, creds.Username)
wsService.BroadcastSteamOutput(*serverID, fmt.Sprintf("Using Steam credentials for user: %s", creds.Username), false)
steamCMDArgs = append(steamCMDArgs, creds.Username)
if creds.Password != "" {
args = append(args, creds.Password)
steamCMDArgs = append(steamCMDArgs, creds.Password)
}
} else {
args = append(args, "anonymous")
wsService.BroadcastSteamOutput(*serverID, "Using anonymous Steam login", false)
steamCMDArgs = append(steamCMDArgs, "anonymous")
}
args = append(args,
if env.IsDockerPlatform() {
steamCMDArgs = append(steamCMDArgs, "+@sSteamCmdForcePlatformType", "windows")
}
steamCMDArgs = append(steamCMDArgs,
"+app_update", ACCServerAppID,
"validate",
"+quit",
)
// Use interactive executor to handle potential 2FA prompts
logging.Info("Installing ACC server to %s...", absPath)
if err := s.interactiveExecutor.ExecuteInteractive(ctx, serverID, args...); err != nil {
args := steamCMDArgs
wsService.BroadcastSteamOutput(*serverID, fmt.Sprintf("Starting SteamCMD: %s %s", steamCMDPath, strings.Join(args, " ")), false)
timeoutCtx, cancel := context.WithTimeout(ctx, 15*time.Minute)
defer cancel()
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 {
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)
}
// Add a delay to allow Steam to properly cleanup
logging.Info("Waiting for Steam operations to complete...")
wsService.BroadcastSteamOutput(*serverID, "SteamCMD execution completed successfully, proceeding with verification...", false)
wsService.BroadcastSteamOutput(*serverID, "Waiting for Steam operations to complete...", false)
time.Sleep(5 * time.Second)
// Verify installation
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) {
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
}
func (s *SteamService) UpdateServer(ctx context.Context, installPath string, serverID *uuid.UUID) error {
return s.InstallServer(ctx, installPath, serverID) // Same process as install
func (s *SteamService) InstallServerWithCallbacks(ctx context.Context, installPath string, serverID *uuid.UUID, outputCallback command.OutputCallback) error {
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 {

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) {
// Get NSSM path from environment variable
nssmPath := env.GetNSSMPath()
// Prepend NSSM path to arguments
nssmArgs := append([]string{"-NoProfile", "-NonInteractive", "-Command", "& " + nssmPath}, args...)
output, err := s.executor.ExecuteWithOutput(nssmArgs...)
if err != nil {
// Log the full command and error for debugging
logging.Error("NSSM command failed: powershell %s", strings.Join(nssmArgs, " "))
logging.Error("NSSM error output: %s", output)
return "", err
}
// Clean up output by removing null bytes and trimming whitespace
cleaned := strings.TrimSpace(strings.ReplaceAll(output, "\x00", ""))
// Remove \r\n from status strings
cleaned = strings.TrimSuffix(cleaned, "\r\n")
return cleaned, nil
}
// Service Installation/Configuration Methods
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)
if err != nil {
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)
// Log the paths being used
logging.Info("Creating service '%s' with:", serviceName)
logging.Info(" Executable: %s", absExecPath)
logging.Info(" Working Directory: %s", absWorkingDir)
// First remove any existing service with the same name
s.ExecuteNSSM(ctx, "remove", serviceName, "confirm")
// Install service
if _, err := s.ExecuteNSSM(ctx, "install", serviceName, absExecPath); err != nil {
return fmt.Errorf("failed to install service: %v", err)
}
// Set arguments if provided
if len(args) > 0 {
cmdArgs := append([]string{"set", serviceName, "AppParameters"}, args...)
if _, err := s.ExecuteNSSM(ctx, cmdArgs...); err != nil {
// Try to clean up on failure
s.ExecuteNSSM(ctx, "remove", serviceName, "confirm")
return fmt.Errorf("failed to set arguments: %v", err)
}
}
// Verify service was created
if _, err := s.ExecuteNSSM(ctx, "get", serviceName, "Application"); err != nil {
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 {
// First remove the existing service
if err := s.DeleteService(ctx, serviceName); err != nil {
return err
}
// Then create it again with new parameters
return s.CreateService(ctx, serviceName, execPath, workingDir, args)
}
// Service Control Methods
func (s *WindowsService) Status(ctx context.Context, serviceName string) (string, error) {
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) {
// First stop the service
if _, err := s.Stop(ctx, serviceName); err != nil {
return "", err
}
// Then start it again
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"
)
// CacheItem represents an item in the cache
type CacheItem struct {
Value interface{}
Expiration int64
}
// InMemoryCache is a thread-safe in-memory cache
type InMemoryCache struct {
items map[string]CacheItem
mu sync.RWMutex
}
// NewInMemoryCache creates and returns a new InMemoryCache instance
func NewInMemoryCache() *InMemoryCache {
return &InMemoryCache{
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) {
c.mu.Lock()
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) {
c.mu.RLock()
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 {
// Item has expired, but don't delete here to avoid lock upgrade.
// It will be overwritten on the next Set.
return nil, false
}
return item.Value, true
}
// Delete removes an item from the cache
func (c *InMemoryCache) Delete(key string) {
c.mu.Lock()
defer c.mu.Unlock()
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) {
if cached, found := c.Get(key); found {
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
}
// Start initializes the cache and provides it to the DI container.
func Start(di *dig.Container) {
cache := NewInMemoryCache()
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) {},
}
}

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

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

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