Compare commits
49 Commits
v0.10.2
...
efc6b72838
| Author | SHA1 | Date | |
|---|---|---|---|
| efc6b72838 | |||
| 0e04da031b | |||
| 908adf4468 | |||
| 6c1e9b236e | |||
| f563d52396 | |||
| a490a73392 | |||
| 0ef47ef241 | |||
| ea2e871c9b | |||
| c2c6543068 | |||
| 14aa8a84e5 | |||
| e96b8bca12 | |||
| a6f67dd652 | |||
| e637b5e649 | |||
| 85c5874bc7 | |||
| 4441524447 | |||
| c03234d339 | |||
| d4b16d80f3 | |||
| 9172f0256f | |||
| beb9b479df | |||
| 4aa67db3fd | |||
| 1100072245 | |||
| 49dfa38475 | |||
| 747adc77a5 | |||
| c9a32e8889 | |||
| 7d470f3638 | |||
| cf7473b373 | |||
| 35c53cc54c | |||
| 016824e846 | |||
| d62c8cef34 | |||
| 190e1a3a16 | |||
|
|
4004d83411 | ||
|
|
901dbe697e | ||
|
|
760412d7db | ||
|
|
4ab94de529 | ||
|
|
b3f89593fb | ||
|
|
2a863c51e9 | ||
|
|
a70d923a6a | ||
|
|
f660511b63 | ||
|
|
044af60699 | ||
|
|
384036bcdd | ||
|
|
ef300d233b | ||
|
|
edad65d6a9 | ||
|
|
486c972bba | ||
|
|
aab5d2ad61 | ||
|
|
1683d5c2f1 | ||
|
|
87d4af0bec | ||
|
|
35449a090d | ||
|
|
5324a41e05 | ||
|
|
ac61ba5223 |
29
.env.example
Normal file → Executable file
29
.env.example
Normal file → Executable 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
0
.gitea/workflows/release.yml
Normal file → Executable file
0
.gitignore
vendored
Normal file → Executable file
0
.gitignore
vendored
Normal file → Executable file
63
Dockerfile
Normal file
63
Dockerfile
Normal 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"]
|
||||
3
cmd/api/main.go
Normal file → Executable file
3
cmd/api/main.go
Normal file → Executable file
@@ -17,7 +17,6 @@ import (
|
||||
|
||||
func main() {
|
||||
configs.Init()
|
||||
jwt.Init()
|
||||
// Initialize new logging system
|
||||
if err := logging.InitializeLogging(); err != nil {
|
||||
fmt.Printf("Failed to initialize logging system: %v\n", err)
|
||||
@@ -37,6 +36,8 @@ func main() {
|
||||
logging.InfoStartup("APPLICATION", "ACC Server Manager starting up")
|
||||
|
||||
di := dig.New()
|
||||
di.Provide(func() *jwt.JWTHandler { return jwt.NewJWTHandler(os.Getenv("JWT_SECRET")) })
|
||||
di.Provide(func() *jwt.OpenJWTHandler { return jwt.NewOpenJWTHandler(os.Getenv("JWT_SECRET_OPEN")) })
|
||||
cache.Start(di)
|
||||
db.Start(di)
|
||||
server.Start(di)
|
||||
|
||||
8
cmd/api/swagger.go
Normal file → Executable file
8
cmd/api/swagger.go
Normal file → Executable file
@@ -5,14 +5,14 @@
|
||||
// @description API for managing Assetto Corsa Competizione dedicated servers
|
||||
//
|
||||
// @contact.name ACC Server Manager Support
|
||||
// @contact.url https://github.com/yourusername/acc-server-manager
|
||||
// @contact.url https://github.com/FJurmanovic/acc-server-manager
|
||||
//
|
||||
// @license.name MIT
|
||||
// @license.url https://opensource.org/licenses/MIT
|
||||
//
|
||||
// @host localhost:3000
|
||||
// @BasePath /api/v1
|
||||
// @schemes http https
|
||||
// @host acc-api.jurmanovic.com
|
||||
// @BasePath /v1
|
||||
// @schemes https
|
||||
//
|
||||
// @securityDefinitions.apikey BearerAuth
|
||||
// @in header
|
||||
|
||||
0
cmd/migrate/main.go
Normal file → Executable file
0
cmd/migrate/main.go
Normal file → Executable file
93
cmd/steam-crypt/main.go
Executable file
93
cmd/steam-crypt/main.go
Executable 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
34
docker-compose.yml
Normal 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
|
||||
35
docker/acc-server/Dockerfile
Normal file
35
docker/acc-server/Dockerfile
Normal 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"]
|
||||
26
docker/acc-server/entrypoint.sh
Normal file
26
docker/acc-server/entrypoint.sh
Normal file
@@ -0,0 +1,26 @@
|
||||
#!/bin/bash
|
||||
set -e
|
||||
|
||||
# Use a display number derived from the server port to avoid conflicts
|
||||
# between containers (each server gets its own unique display).
|
||||
DISPLAY_NUM=$((SERVER_PORT % 1000 + 100))
|
||||
export DISPLAY=:${DISPLAY_NUM}
|
||||
|
||||
# Clean up any stale X lock from a previous container run.
|
||||
rm -f /tmp/.X${DISPLAY_NUM}-lock /tmp/.X11-unix/X${DISPLAY_NUM} 2>/dev/null || true
|
||||
|
||||
Xvfb ${DISPLAY} -screen 0 1024x768x16 -nolisten tcp &
|
||||
XVFB_PID=$!
|
||||
|
||||
cleanup() {
|
||||
echo "Stopping ACC server..."
|
||||
kill $XVFB_PID 2>/dev/null || true
|
||||
}
|
||||
trap cleanup SIGTERM SIGINT
|
||||
|
||||
mkdir -p /acc/game/log
|
||||
|
||||
echo "Starting ACC server on port ${SERVER_PORT}..."
|
||||
|
||||
cd /acc/game
|
||||
exec wine /acc/game/accServer.exe
|
||||
0
docs/API.md
Normal file → Executable file
0
docs/API.md
Normal file → Executable file
0
docs/CONFIG.md
Normal file → Executable file
0
docs/CONFIG.md
Normal file → Executable file
0
docs/DEPLOYMENT.md
Normal file → Executable file
0
docs/DEPLOYMENT.md
Normal file → Executable file
0
docs/MIGRATION.md
Normal file → Executable file
0
docs/MIGRATION.md
Normal file → Executable file
0
docs/SETUP.md
Normal file → Executable file
0
docs/SETUP.md
Normal file → Executable file
243
docs/STEAM_2FA_IMPLEMENTATION.md
Executable file
243
docs/STEAM_2FA_IMPLEMENTATION.md
Executable file
@@ -0,0 +1,243 @@
|
||||
# Steam 2FA Implementation Documentation
|
||||
|
||||
## Overview
|
||||
|
||||
This document describes the implementation of Steam Two-Factor Authentication (2FA) support for the ACC Server Manager. When SteamCMD requires 2FA confirmation during server installation or updates, the system now signals the frontend and waits for user confirmation before proceeding.
|
||||
|
||||
## Architecture
|
||||
|
||||
The 2FA implementation consists of several interconnected components:
|
||||
|
||||
### Backend Components
|
||||
|
||||
1. **Steam2FAManager** (`local/model/steam_2fa.go`)
|
||||
- Thread-safe management of 2FA requests
|
||||
- Request lifecycle tracking (pending → complete/error)
|
||||
- Channel-based waiting mechanism for synchronization
|
||||
|
||||
2. **InteractiveCommandExecutor** (`local/utl/command/interactive_executor.go`)
|
||||
- Monitors SteamCMD output for 2FA prompts
|
||||
- Creates 2FA requests when prompts are detected
|
||||
- Waits for user confirmation before proceeding
|
||||
|
||||
3. **Steam2FAController** (`local/controller/steam_2fa.go`)
|
||||
- REST API endpoints for 2FA management
|
||||
- Handles frontend requests to complete/cancel 2FA
|
||||
|
||||
4. **Updated SteamService** (`local/service/steam_service.go`)
|
||||
- Uses InteractiveCommandExecutor for SteamCMD operations
|
||||
- Passes server context to 2FA requests
|
||||
|
||||
### Frontend Components
|
||||
|
||||
1. **Steam2FA Store** (`src/stores/steam2fa.ts`)
|
||||
- Svelte store for managing 2FA state
|
||||
- Automatic polling for pending requests
|
||||
- API communication methods
|
||||
|
||||
2. **Steam2FANotification Component** (`src/components/Steam2FANotification.svelte`)
|
||||
- Modal UI for 2FA confirmation
|
||||
- Automatic display when requests are pending
|
||||
- User interaction handling
|
||||
|
||||
3. **Type Definitions** (`src/models/steam2fa.ts`)
|
||||
- TypeScript interfaces for 2FA data structures
|
||||
|
||||
## API Endpoints
|
||||
|
||||
### GET /v1/steam2fa/pending
|
||||
Returns all pending 2FA requests.
|
||||
|
||||
**Response:**
|
||||
```json
|
||||
[
|
||||
{
|
||||
"id": "uuid-string",
|
||||
"status": "pending",
|
||||
"message": "Steam Guard prompt message",
|
||||
"requestTime": "2024-01-01T12:00:00Z",
|
||||
"serverId": "server-uuid"
|
||||
}
|
||||
]
|
||||
```
|
||||
|
||||
### GET /v1/steam2fa/{id}
|
||||
Returns a specific 2FA request by ID.
|
||||
|
||||
### POST /v1/steam2fa/{id}/complete
|
||||
Marks a 2FA request as completed, allowing SteamCMD to proceed.
|
||||
|
||||
### POST /v1/steam2fa/{id}/cancel
|
||||
Cancels a 2FA request, causing the SteamCMD operation to fail.
|
||||
|
||||
## Flow Diagram
|
||||
|
||||
```
|
||||
SteamCMD Operation
|
||||
↓
|
||||
InteractiveCommandExecutor monitors output
|
||||
↓
|
||||
2FA prompt detected
|
||||
↓
|
||||
Steam2FARequest created
|
||||
↓
|
||||
Frontend polls and detects request
|
||||
↓
|
||||
Modal appears for user
|
||||
↓
|
||||
User confirms in Steam Mobile App
|
||||
↓
|
||||
User clicks "I've Confirmed"
|
||||
↓
|
||||
API call to complete request
|
||||
↓
|
||||
SteamCMD operation continues
|
||||
```
|
||||
|
||||
## Configuration
|
||||
|
||||
### Backend Configuration
|
||||
|
||||
The system uses existing configuration patterns. No additional environment variables are required.
|
||||
|
||||
### Frontend Configuration
|
||||
|
||||
The API base URL is automatically configured as `/v1` to match the backend prefix.
|
||||
|
||||
Polling interval is set to 5 seconds by default and can be modified in `steam2fa.ts`:
|
||||
|
||||
```typescript
|
||||
const POLLING_INTERVAL = 5000; // milliseconds
|
||||
```
|
||||
|
||||
## Security Considerations
|
||||
|
||||
1. **Authentication Required**: All 2FA endpoints require user authentication
|
||||
2. **Permission-Based Access**: Uses existing `ServerView` and `ServerUpdate` permissions
|
||||
3. **Request Cleanup**: Automatic cleanup of old requests (30 minutes) prevents memory leaks
|
||||
4. **No Sensitive Data**: No Steam credentials are exposed through the 2FA system
|
||||
|
||||
## Error Handling
|
||||
|
||||
### Backend Error Handling
|
||||
- Timeouts after 5 minutes if no user response
|
||||
- Proper error propagation to calling services
|
||||
- Comprehensive logging for debugging
|
||||
|
||||
### Frontend Error Handling
|
||||
- Network error handling with user feedback
|
||||
- Automatic retry mechanisms
|
||||
- Graceful degradation when API is unavailable
|
||||
|
||||
## Usage Instructions
|
||||
|
||||
### For Developers
|
||||
|
||||
1. **Adding New 2FA Prompts**: Extend the `is2FAPrompt` function in `interactive_executor.go`
|
||||
2. **Customizing Timeouts**: Modify the timeout duration in `handle2FAPrompt`
|
||||
3. **UI Customization**: Modify the `Steam2FANotification.svelte` component
|
||||
|
||||
### For Users
|
||||
|
||||
1. When creating or updating a server, watch for the 2FA notification
|
||||
2. Check your Steam Mobile App when prompted
|
||||
3. Confirm the login request in the Steam app
|
||||
4. Click "I've Confirmed" in the web interface
|
||||
5. The server operation will continue automatically
|
||||
|
||||
## Monitoring and Debugging
|
||||
|
||||
### Backend Logs
|
||||
The system logs important events:
|
||||
- 2FA prompt detection
|
||||
- Request creation and completion
|
||||
- Timeout events
|
||||
- Error conditions
|
||||
|
||||
Search for log entries containing:
|
||||
- `2FA prompt detected`
|
||||
- `Created 2FA request`
|
||||
- `2FA completed successfully`
|
||||
- `2FA completion failed`
|
||||
|
||||
### Frontend Debugging
|
||||
The Steam2FA store provides debugging information:
|
||||
- `$steam2fa.error` - Current error state
|
||||
- `$steam2fa.isLoading` - Loading state
|
||||
- `$steam2fa.lastChecked` - Last polling timestamp
|
||||
|
||||
## Performance Considerations
|
||||
|
||||
1. **Polling Frequency**: 5-second polling provides good responsiveness without excessive load
|
||||
2. **Request Cleanup**: Automatic cleanup prevents memory accumulation
|
||||
3. **Efficient UI Updates**: Reactive Svelte stores minimize unnecessary re-renders
|
||||
|
||||
## Limitations
|
||||
|
||||
1. **Single User Sessions**: Currently designed for single-user scenarios
|
||||
2. **Steam Mobile App Required**: Users must have Steam Mobile App installed
|
||||
3. **Manual Confirmation**: No automatic 2FA code input support
|
||||
|
||||
## Future Enhancements
|
||||
|
||||
1. **WebSocket Support**: Real-time communication instead of polling
|
||||
2. **Multiple User Support**: Handle multiple simultaneous 2FA requests
|
||||
3. **Enhanced Prompt Detection**: More sophisticated Steam output parsing
|
||||
4. **Notification System**: Browser notifications for 2FA requests
|
||||
|
||||
## Testing
|
||||
|
||||
### Manual Testing
|
||||
1. Create a new server to trigger SteamCMD
|
||||
2. Ensure Steam account has 2FA enabled
|
||||
3. Verify modal appears when 2FA is required
|
||||
4. Test both "confirm" and "cancel" workflows
|
||||
|
||||
### Automated Testing
|
||||
The system includes comprehensive error handling but manual testing is recommended for 2FA workflows due to the interactive nature.
|
||||
|
||||
## Troubleshooting
|
||||
|
||||
### Common Issues
|
||||
|
||||
1. **Modal doesn't appear**
|
||||
- Check browser console for errors
|
||||
- Verify API connectivity
|
||||
- Ensure user has proper permissions
|
||||
|
||||
2. **SteamCMD hangs**
|
||||
- Check if 2FA request was created (backend logs)
|
||||
- Verify Steam Mobile App connectivity
|
||||
- Check for timeout errors
|
||||
|
||||
3. **API errors**
|
||||
- Verify user authentication
|
||||
- Check server permissions
|
||||
- Review backend error logs
|
||||
|
||||
### Debug Commands
|
||||
|
||||
```bash
|
||||
# Check backend logs for 2FA events
|
||||
grep -i "2fa" logs/app.log
|
||||
|
||||
# Monitor API requests
|
||||
tail -f logs/app.log | grep "steam2fa"
|
||||
```
|
||||
|
||||
## Version History
|
||||
|
||||
- **v1.0.0**: Initial implementation with polling-based frontend and REST API
|
||||
- Added comprehensive error handling and logging
|
||||
- Implemented automatic request cleanup
|
||||
- Added responsive UI components
|
||||
|
||||
## Contributing
|
||||
|
||||
When contributing to the 2FA system:
|
||||
|
||||
1. Follow existing error handling patterns
|
||||
2. Add comprehensive logging for new features
|
||||
3. Update this documentation for any API changes
|
||||
4. Test with actual Steam 2FA scenarios
|
||||
5. Consider security implications of any changes
|
||||
0
docs/SWAGGER.md
Normal file → Executable file
0
docs/SWAGGER.md
Normal file → Executable file
0
docs/TROUBLESHOOTING.md
Normal file → Executable file
0
docs/TROUBLESHOOTING.md
Normal file → Executable file
25
go.mod
Normal file → Executable file
25
go.mod
Normal file → Executable 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
82
go.sum
Normal file → Executable 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
6
local/api/api.go
Normal file → Executable file
@@ -31,6 +31,12 @@ func Init(di *dig.Container, app *fiber.App) {
|
||||
StateHistory: serverIdGroup.Group("/state-history"),
|
||||
Membership: groups.Group("/membership"),
|
||||
System: groups.Group("/system"),
|
||||
WebSocket: groups.Group("/ws"),
|
||||
Leaderboard: serverIdGroup.Group("/leaderboard"),
|
||||
Steam: groups.Group("/steam"),
|
||||
ActivityLog: serverIdGroup.Group("/activity-log"),
|
||||
Groups: groups,
|
||||
Log: serverIdGroup.Group("/log"),
|
||||
}
|
||||
|
||||
accessKeyMiddleware := middleware.NewAccessKeyMiddleware()
|
||||
|
||||
48
local/controller/activity_log.go
Normal file
48
local/controller/activity_log.go
Normal file
@@ -0,0 +1,48 @@
|
||||
package controller
|
||||
|
||||
import (
|
||||
"acc-server-manager/local/middleware"
|
||||
"acc-server-manager/local/model"
|
||||
"acc-server-manager/local/service"
|
||||
"acc-server-manager/local/utl/common"
|
||||
"acc-server-manager/local/utl/error_handler"
|
||||
|
||||
"github.com/gofiber/fiber/v2"
|
||||
)
|
||||
|
||||
type ActivityLogController struct {
|
||||
service *service.ActivityLogService
|
||||
errorHandler *error_handler.ControllerErrorHandler
|
||||
}
|
||||
|
||||
func NewActivityLogController(as *service.ActivityLogService, routeGroups *common.RouteGroups, auth *middleware.AuthMiddleware) *ActivityLogController {
|
||||
ac := &ActivityLogController{
|
||||
service: as,
|
||||
errorHandler: error_handler.NewControllerErrorHandler(),
|
||||
}
|
||||
|
||||
// Global activity log: GET /activity-log (admin use)
|
||||
routeGroups.Groups.Group("/activity-log").Use(auth.Authenticate).Get("/", ac.GetAllLogs)
|
||||
|
||||
return ac
|
||||
}
|
||||
|
||||
// GetAllLogs returns activity logs across all servers (admin).
|
||||
//
|
||||
// @Summary Return all activity logs
|
||||
// @Tags ActivityLog
|
||||
// @Success 200 {array} model.ActivityLog
|
||||
// @Router /activity-log [get]
|
||||
func (ac *ActivityLogController) GetAllLogs(c *fiber.Ctx) error {
|
||||
var filter model.ActivityLogFilter
|
||||
if err := common.ParseQueryFilter(c, &filter); err != nil {
|
||||
return ac.errorHandler.HandleValidationError(c, err, "query_filter")
|
||||
}
|
||||
|
||||
result, err := ac.service.GetAll(c.UserContext(), &filter)
|
||||
if err != nil {
|
||||
return ac.errorHandler.HandleServiceError(c, err)
|
||||
}
|
||||
|
||||
return c.JSON(result)
|
||||
}
|
||||
56
local/controller/config.go
Normal file → Executable file
56
local/controller/config.go
Normal file → Executable 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
|
||||
@@ -58,7 +108,7 @@ func NewConfigController(as *service.ConfigService, routeGroups *common.RouteGro
|
||||
// @Failure 404 {object} error_handler.ErrorResponse "Server or config file not found"
|
||||
// @Failure 500 {object} error_handler.ErrorResponse "Internal server error"
|
||||
// @Security BearerAuth
|
||||
// @Router /v1/server/{id}/config/{file} [put]
|
||||
// @Router /server/{id}/config/{file} [put]
|
||||
func (ac *ConfigController) UpdateConfig(c *fiber.Ctx) error {
|
||||
restart := c.QueryBool("restart")
|
||||
serverID := c.Params("id")
|
||||
@@ -106,7 +156,7 @@ func (ac *ConfigController) UpdateConfig(c *fiber.Ctx) error {
|
||||
// @Failure 404 {object} error_handler.ErrorResponse "Server or config file not found"
|
||||
// @Failure 500 {object} error_handler.ErrorResponse "Internal server error"
|
||||
// @Security BearerAuth
|
||||
// @Router /v1/server/{id}/config/{file} [get]
|
||||
// @Router /server/{id}/config/{file} [get]
|
||||
func (ac *ConfigController) GetConfig(c *fiber.Ctx) error {
|
||||
Model, err := ac.service.GetConfig(c)
|
||||
if err != nil {
|
||||
@@ -130,7 +180,7 @@ func (ac *ConfigController) GetConfig(c *fiber.Ctx) error {
|
||||
// @Failure 404 {object} error_handler.ErrorResponse "Server not found"
|
||||
// @Failure 500 {object} error_handler.ErrorResponse "Internal server error"
|
||||
// @Security BearerAuth
|
||||
// @Router /v1/server/{id}/config [get]
|
||||
// @Router /server/{id}/config [get]
|
||||
func (ac *ConfigController) GetConfigs(c *fiber.Ctx) error {
|
||||
Model, err := ac.service.GetConfigs(c)
|
||||
if err != nil {
|
||||
|
||||
25
local/controller/controller.go
Normal file → Executable file
25
local/controller/controller.go
Normal file → Executable file
@@ -54,4 +54,29 @@ func InitializeControllers(c *dig.Container) {
|
||||
if err != nil {
|
||||
logging.Panic("unable to initialize membership controller")
|
||||
}
|
||||
|
||||
err = c.Invoke(NewWebSocketController)
|
||||
if err != nil {
|
||||
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")
|
||||
}
|
||||
|
||||
err = c.Invoke(NewLogController)
|
||||
if err != nil {
|
||||
logging.Panic("unable to initialize log controller")
|
||||
}
|
||||
}
|
||||
|
||||
77
local/controller/leaderboard.go
Executable file
77
local/controller/leaderboard.go
Executable 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)
|
||||
}
|
||||
62
local/controller/log.go
Normal file
62
local/controller/log.go
Normal file
@@ -0,0 +1,62 @@
|
||||
package controller
|
||||
|
||||
import (
|
||||
"acc-server-manager/local/middleware"
|
||||
"acc-server-manager/local/model"
|
||||
"acc-server-manager/local/service"
|
||||
"acc-server-manager/local/utl/common"
|
||||
"acc-server-manager/local/utl/error_handler"
|
||||
"strconv"
|
||||
|
||||
"github.com/gofiber/fiber/v2"
|
||||
"github.com/google/uuid"
|
||||
)
|
||||
|
||||
type LogController struct {
|
||||
service *service.ServerService
|
||||
errorHandler *error_handler.ControllerErrorHandler
|
||||
}
|
||||
|
||||
func NewLogController(ss *service.ServerService, routeGroups *common.RouteGroups, auth *middleware.AuthMiddleware) *LogController {
|
||||
lc := &LogController{
|
||||
service: ss,
|
||||
errorHandler: error_handler.NewControllerErrorHandler(),
|
||||
}
|
||||
|
||||
logRoutes := routeGroups.Log
|
||||
logRoutes.Use(auth.Authenticate)
|
||||
logRoutes.Get("/", auth.HasPermission(model.ServerView), lc.GetLogLines)
|
||||
|
||||
return lc
|
||||
}
|
||||
|
||||
// GetLogLines godoc
|
||||
// @Summary Get last N lines of server log
|
||||
// @Tags Server
|
||||
// @Param id path string true "Server ID"
|
||||
// @Param lines query int false "Number of lines to return (default 100)"
|
||||
// @Success 200 {object} object{lines=[]string}
|
||||
// @Security BearerAuth
|
||||
// @Router /server/{id}/log [get]
|
||||
func (lc *LogController) GetLogLines(c *fiber.Ctx) error {
|
||||
serverIDStr := c.Params("id")
|
||||
serverID, err := uuid.Parse(serverIDStr)
|
||||
if err != nil {
|
||||
return lc.errorHandler.HandleUUIDError(c, "server ID")
|
||||
}
|
||||
|
||||
n, _ := strconv.Atoi(c.Query("lines", "100"))
|
||||
if n <= 0 {
|
||||
n = 100
|
||||
}
|
||||
if n > 10000 {
|
||||
n = 10000
|
||||
}
|
||||
|
||||
lines, err := lc.service.GetLastLogLines(c, serverID, n)
|
||||
if err != nil {
|
||||
return lc.errorHandler.HandleServiceError(c, err)
|
||||
}
|
||||
|
||||
return c.JSON(fiber.Map{"lines": lines})
|
||||
}
|
||||
10
local/controller/lookup.go
Normal file → Executable file
10
local/controller/lookup.go
Normal file → Executable file
@@ -46,7 +46,7 @@ func NewLookupController(as *service.LookupService, routeGroups *common.RouteGro
|
||||
// @Failure 401 {object} error_handler.ErrorResponse "Unauthorized"
|
||||
// @Failure 500 {object} error_handler.ErrorResponse "Internal server error"
|
||||
// @Security BearerAuth
|
||||
// @Router /v1/lookup/tracks [get]
|
||||
// @Router /lookup/tracks [get]
|
||||
func (ac *LookupController) GetTracks(c *fiber.Ctx) error {
|
||||
result, err := ac.service.GetTracks(c)
|
||||
if err != nil {
|
||||
@@ -66,7 +66,7 @@ func (ac *LookupController) GetTracks(c *fiber.Ctx) error {
|
||||
// @Failure 401 {object} error_handler.ErrorResponse "Unauthorized"
|
||||
// @Failure 500 {object} error_handler.ErrorResponse "Internal server error"
|
||||
// @Security BearerAuth
|
||||
// @Router /v1/lookup/car-models [get]
|
||||
// @Router /lookup/car-models [get]
|
||||
func (ac *LookupController) GetCarModels(c *fiber.Ctx) error {
|
||||
result, err := ac.service.GetCarModels(c)
|
||||
if err != nil {
|
||||
@@ -86,7 +86,7 @@ func (ac *LookupController) GetCarModels(c *fiber.Ctx) error {
|
||||
// @Failure 401 {object} error_handler.ErrorResponse "Unauthorized"
|
||||
// @Failure 500 {object} error_handler.ErrorResponse "Internal server error"
|
||||
// @Security BearerAuth
|
||||
// @Router /v1/lookup/driver-categories [get]
|
||||
// @Router /lookup/driver-categories [get]
|
||||
func (ac *LookupController) GetDriverCategories(c *fiber.Ctx) error {
|
||||
result, err := ac.service.GetDriverCategories(c)
|
||||
if err != nil {
|
||||
@@ -106,7 +106,7 @@ func (ac *LookupController) GetDriverCategories(c *fiber.Ctx) error {
|
||||
// @Failure 401 {object} error_handler.ErrorResponse "Unauthorized"
|
||||
// @Failure 500 {object} error_handler.ErrorResponse "Internal server error"
|
||||
// @Security BearerAuth
|
||||
// @Router /v1/lookup/cup-categories [get]
|
||||
// @Router /lookup/cup-categories [get]
|
||||
func (ac *LookupController) GetCupCategories(c *fiber.Ctx) error {
|
||||
result, err := ac.service.GetCupCategories(c)
|
||||
if err != nil {
|
||||
@@ -126,7 +126,7 @@ func (ac *LookupController) GetCupCategories(c *fiber.Ctx) error {
|
||||
// @Failure 401 {object} error_handler.ErrorResponse "Unauthorized"
|
||||
// @Failure 500 {object} error_handler.ErrorResponse "Internal server error"
|
||||
// @Security BearerAuth
|
||||
// @Router /v1/lookup/session-types [get]
|
||||
// @Router /lookup/session-types [get]
|
||||
func (ac *LookupController) GetSessionTypes(c *fiber.Ctx) error {
|
||||
result, err := ac.service.GetSessionTypes(c)
|
||||
if err != nil {
|
||||
|
||||
81
local/controller/membership.go
Normal file → Executable file
81
local/controller/membership.go
Normal file → Executable file
@@ -34,6 +34,7 @@ func NewMembershipController(service *service.MembershipService, auth *middlewar
|
||||
}
|
||||
|
||||
routeGroups.Auth.Post("/login", mc.Login)
|
||||
routeGroups.Auth.Post("/open-token", mc.auth.Authenticate, mc.GenerateOpenToken)
|
||||
|
||||
usersGroup := routeGroups.Membership
|
||||
usersGroup.Use(mc.auth.Authenticate)
|
||||
@@ -82,6 +83,26 @@ func (c *MembershipController) Login(ctx *fiber.Ctx) error {
|
||||
return ctx.JSON(fiber.Map{"token": token})
|
||||
}
|
||||
|
||||
// GenerateOpenToken generates an open token for a user.
|
||||
// @Summary Generate an open token
|
||||
// @Description Generate an open token for a user
|
||||
// @Tags Authentication
|
||||
// @Accept json
|
||||
// @Produce json
|
||||
// @Success 200 {object} object{token=string} "JWT token"
|
||||
// @Failure 400 {object} error_handler.ErrorResponse "Invalid request body"
|
||||
// @Failure 401 {object} error_handler.ErrorResponse "Invalid credentials"
|
||||
// @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))
|
||||
if err != nil {
|
||||
return c.errorHandler.HandleAuthError(ctx, err)
|
||||
}
|
||||
|
||||
return ctx.JSON(fiber.Map{"token": token})
|
||||
}
|
||||
|
||||
// CreateUser creates a new user.
|
||||
// @Summary Create a new user
|
||||
// @Description Create a new user account with specified role
|
||||
@@ -139,6 +160,18 @@ func (mc *MembershipController) ListUsers(c *fiber.Ctx) error {
|
||||
}
|
||||
|
||||
// GetUser gets a single user by ID.
|
||||
// @Summary Get user by ID
|
||||
// @Description Get detailed information about a specific user
|
||||
// @Tags User Management
|
||||
// @Accept json
|
||||
// @Produce json
|
||||
// @Param id path string true "User ID (UUID format)"
|
||||
// @Success 200 {object} model.User "User details"
|
||||
// @Failure 400 {object} error_handler.ErrorResponse "Invalid user ID format"
|
||||
// @Failure 401 {object} error_handler.ErrorResponse "Unauthorized"
|
||||
// @Failure 404 {object} error_handler.ErrorResponse "User not found"
|
||||
// @Security BearerAuth
|
||||
// @Router /membership/{id} [get]
|
||||
func (mc *MembershipController) GetUser(c *fiber.Ctx) error {
|
||||
id, err := uuid.Parse(c.Params("id"))
|
||||
if err != nil {
|
||||
@@ -154,6 +187,16 @@ func (mc *MembershipController) GetUser(c *fiber.Ctx) error {
|
||||
}
|
||||
|
||||
// GetMe returns the currently authenticated user's details.
|
||||
// @Summary Get current user details
|
||||
// @Description Get details of the currently authenticated user
|
||||
// @Tags Authentication
|
||||
// @Accept json
|
||||
// @Produce json
|
||||
// @Success 200 {object} model.User "Current user details"
|
||||
// @Failure 401 {object} error_handler.ErrorResponse "Unauthorized"
|
||||
// @Failure 404 {object} error_handler.ErrorResponse "User not found"
|
||||
// @Security BearerAuth
|
||||
// @Router /auth/me [get]
|
||||
func (mc *MembershipController) GetMe(c *fiber.Ctx) error {
|
||||
userID, ok := c.Locals("userID").(string)
|
||||
if !ok || userID == "" {
|
||||
@@ -172,6 +215,19 @@ func (mc *MembershipController) GetMe(c *fiber.Ctx) error {
|
||||
}
|
||||
|
||||
// DeleteUser deletes a user.
|
||||
// @Summary Delete user
|
||||
// @Description Delete a specific user by ID
|
||||
// @Tags User Management
|
||||
// @Accept json
|
||||
// @Produce json
|
||||
// @Param id path string true "User ID (UUID format)"
|
||||
// @Success 204 "User successfully deleted"
|
||||
// @Failure 400 {object} error_handler.ErrorResponse "Invalid user ID format"
|
||||
// @Failure 401 {object} error_handler.ErrorResponse "Unauthorized"
|
||||
// @Failure 403 {object} error_handler.ErrorResponse "Insufficient permissions"
|
||||
// @Failure 404 {object} error_handler.ErrorResponse "User not found"
|
||||
// @Security BearerAuth
|
||||
// @Router /membership/{id} [delete]
|
||||
func (mc *MembershipController) DeleteUser(c *fiber.Ctx) error {
|
||||
id, err := uuid.Parse(c.Params("id"))
|
||||
if err != nil {
|
||||
@@ -187,6 +243,20 @@ func (mc *MembershipController) DeleteUser(c *fiber.Ctx) error {
|
||||
}
|
||||
|
||||
// UpdateUser updates a user.
|
||||
// @Summary Update user
|
||||
// @Description Update user details by ID
|
||||
// @Tags User Management
|
||||
// @Accept json
|
||||
// @Produce json
|
||||
// @Param id path string true "User ID (UUID format)"
|
||||
// @Param user body service.UpdateUserRequest true "Updated user details"
|
||||
// @Success 200 {object} model.User "Updated user details"
|
||||
// @Failure 400 {object} error_handler.ErrorResponse "Invalid request body or ID format"
|
||||
// @Failure 401 {object} error_handler.ErrorResponse "Unauthorized"
|
||||
// @Failure 403 {object} error_handler.ErrorResponse "Insufficient permissions"
|
||||
// @Failure 404 {object} error_handler.ErrorResponse "User not found"
|
||||
// @Security BearerAuth
|
||||
// @Router /membership/{id} [put]
|
||||
func (mc *MembershipController) UpdateUser(c *fiber.Ctx) error {
|
||||
id, err := uuid.Parse(c.Params("id"))
|
||||
if err != nil {
|
||||
@@ -207,6 +277,17 @@ func (mc *MembershipController) UpdateUser(c *fiber.Ctx) error {
|
||||
}
|
||||
|
||||
// GetRoles returns all available roles.
|
||||
// @Summary Get all roles
|
||||
// @Description Get a list of all available user roles
|
||||
// @Tags User Management
|
||||
// @Accept json
|
||||
// @Produce json
|
||||
// @Success 200 {array} model.Role "List of roles"
|
||||
// @Failure 401 {object} error_handler.ErrorResponse "Unauthorized"
|
||||
// @Failure 403 {object} error_handler.ErrorResponse "Insufficient permissions"
|
||||
// @Failure 500 {object} error_handler.ErrorResponse "Internal server error"
|
||||
// @Security BearerAuth
|
||||
// @Router /membership/roles [get]
|
||||
func (mc *MembershipController) GetRoles(c *fiber.Ctx) error {
|
||||
roles, err := mc.service.GetAllRoles(c.UserContext())
|
||||
if err != nil {
|
||||
|
||||
50
local/controller/server.go
Normal file → Executable file
50
local/controller/server.go
Normal file → Executable 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")
|
||||
@@ -50,7 +49,7 @@ func NewServerController(ss *service.ServerService, routeGroups *common.RouteGro
|
||||
// @Failure 401 {object} error_handler.ErrorResponse "Unauthorized"
|
||||
// @Failure 500 {object} error_handler.ErrorResponse "Internal server error"
|
||||
// @Security BearerAuth
|
||||
// @Router /server [get]
|
||||
// @Router /api/server [get]
|
||||
func (ac *ServerController) GetAllApi(c *fiber.Ctx) error {
|
||||
var filter model.ServerFilter
|
||||
if err := common.ParseQueryFilter(c, &filter); err != nil {
|
||||
@@ -79,7 +78,7 @@ func (ac *ServerController) GetAllApi(c *fiber.Ctx) error {
|
||||
// @Failure 401 {object} error_handler.ErrorResponse "Unauthorized"
|
||||
// @Failure 500 {object} error_handler.ErrorResponse "Internal server error"
|
||||
// @Security BearerAuth
|
||||
// @Router /v1/server [get]
|
||||
// @Router /server [get]
|
||||
func (ac *ServerController) GetAll(c *fiber.Ctx) error {
|
||||
var filter model.ServerFilter
|
||||
if err := common.ParseQueryFilter(c, &filter); err != nil {
|
||||
@@ -105,7 +104,7 @@ func (ac *ServerController) GetAll(c *fiber.Ctx) error {
|
||||
// @Failure 404 {object} error_handler.ErrorResponse "Server not found"
|
||||
// @Failure 500 {object} error_handler.ErrorResponse "Internal server error"
|
||||
// @Security BearerAuth
|
||||
// @Router /v1/server/{id} [get]
|
||||
// @Router /server/{id} [get]
|
||||
func (ac *ServerController) GetById(c *fiber.Ctx) error {
|
||||
serverIDStr := c.Params("id")
|
||||
serverID, err := uuid.Parse(serverIDStr)
|
||||
@@ -133,55 +132,40 @@ func (ac *ServerController) GetById(c *fiber.Ctx) error {
|
||||
// @Failure 403 {object} error_handler.ErrorResponse "Insufficient permissions"
|
||||
// @Failure 500 {object} error_handler.ErrorResponse "Internal server error"
|
||||
// @Security BearerAuth
|
||||
// @Router /v1/server [post]
|
||||
// @Router /server [post]
|
||||
func (ac *ServerController) CreateServer(c *fiber.Ctx) error {
|
||||
server := new(model.Server)
|
||||
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 immediately with server details
|
||||
// The actual creation will happen in the background with WebSocket updates
|
||||
return c.JSON(server)
|
||||
}
|
||||
|
||||
// UpdateServer updates an existing server
|
||||
// @Summary Update an ACC server
|
||||
// @Description Update configuration for an existing ACC server
|
||||
// DeleteServer deletes an existing server
|
||||
// @Summary Delete an ACC server
|
||||
// @Description Delete 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"
|
||||
// @Success 200 {object} object "Deleted 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 /v1/server/{id} [put]
|
||||
func (ac *ServerController) UpdateServer(c *fiber.Ctx) error {
|
||||
serverIDStr := c.Params("id")
|
||||
serverID, err := uuid.Parse(serverIDStr)
|
||||
if err != nil {
|
||||
return ac.errorHandler.HandleUUIDError(c, "server ID")
|
||||
}
|
||||
|
||||
server := new(model.Server)
|
||||
if err := c.BodyParser(server); err != nil {
|
||||
return ac.errorHandler.HandleParsingError(c, err)
|
||||
}
|
||||
server.ID = serverID
|
||||
|
||||
if err := ac.service.UpdateServer(c, server); err != nil {
|
||||
return ac.errorHandler.HandleServiceError(c, err)
|
||||
}
|
||||
return c.JSON(server)
|
||||
}
|
||||
|
||||
// DeleteServer deletes a server
|
||||
// @Router /server/{id} [delete]
|
||||
func (ac *ServerController) DeleteServer(c *fiber.Ctx) error {
|
||||
serverIDStr := c.Params("id")
|
||||
serverID, err := uuid.Parse(serverIDStr)
|
||||
|
||||
9
local/controller/service_control.go
Normal file → Executable file
9
local/controller/service_control.go
Normal file → Executable file
@@ -29,6 +29,7 @@ func NewServiceControlController(as *service.ServiceControlService, routeGroups
|
||||
}
|
||||
|
||||
serviceRoutes := routeGroups.Server.Group("/:id/service")
|
||||
serviceRoutes.Use(auth.Authenticate)
|
||||
serviceRoutes.Get("/:service", ac.getStatus)
|
||||
serviceRoutes.Post("/start", ac.startServer)
|
||||
serviceRoutes.Post("/stop", ac.stopServer)
|
||||
@@ -51,7 +52,7 @@ func NewServiceControlController(as *service.ServiceControlService, routeGroups
|
||||
// @Failure 404 {object} error_handler.ErrorResponse "Service not found"
|
||||
// @Failure 500 {object} error_handler.ErrorResponse "Internal server error"
|
||||
// @Security BearerAuth
|
||||
// @Router /v1/service-control/{service} [get]
|
||||
// @Router /server/{id}/service/{service} [get]
|
||||
func (ac *ServiceControlController) getStatus(c *fiber.Ctx) error {
|
||||
id := c.Params("id")
|
||||
c.Locals("serverId", id)
|
||||
@@ -78,7 +79,7 @@ func (ac *ServiceControlController) getStatus(c *fiber.Ctx) error {
|
||||
// @Failure 409 {object} error_handler.ErrorResponse "Service already running"
|
||||
// @Failure 500 {object} error_handler.ErrorResponse "Internal server error"
|
||||
// @Security BearerAuth
|
||||
// @Router /v1/service-control/start [post]
|
||||
// @Router /server/{id}/service/start [post]
|
||||
func (ac *ServiceControlController) startServer(c *fiber.Ctx) error {
|
||||
id := c.Params("id")
|
||||
c.Locals("serverId", id)
|
||||
@@ -105,7 +106,7 @@ func (ac *ServiceControlController) startServer(c *fiber.Ctx) error {
|
||||
// @Failure 409 {object} error_handler.ErrorResponse "Service already stopped"
|
||||
// @Failure 500 {object} error_handler.ErrorResponse "Internal server error"
|
||||
// @Security BearerAuth
|
||||
// @Router /v1/service-control/stop [post]
|
||||
// @Router /server/{id}/service/stop [post]
|
||||
func (ac *ServiceControlController) stopServer(c *fiber.Ctx) error {
|
||||
id := c.Params("id")
|
||||
c.Locals("serverId", id)
|
||||
@@ -131,7 +132,7 @@ func (ac *ServiceControlController) stopServer(c *fiber.Ctx) error {
|
||||
// @Failure 404 {object} error_handler.ErrorResponse "Service not found"
|
||||
// @Failure 500 {object} error_handler.ErrorResponse "Internal server error"
|
||||
// @Security BearerAuth
|
||||
// @Router /v1/service-control/restart [post]
|
||||
// @Router /server/{id}/service/restart [post]
|
||||
func (ac *ServiceControlController) restartServer(c *fiber.Ctx) error {
|
||||
id := c.Params("id")
|
||||
c.Locals("serverId", id)
|
||||
|
||||
4
local/controller/state_history.go
Normal file → Executable file
4
local/controller/state_history.go
Normal file → Executable file
@@ -42,7 +42,7 @@ func NewStateHistoryController(as *service.StateHistoryService, routeGroups *com
|
||||
// @Description Return StateHistorys
|
||||
// @Tags StateHistory
|
||||
// @Success 200 {array} string
|
||||
// @Router /v1/state-history [get]
|
||||
// @Router /state-history [get]
|
||||
func (ac *StateHistoryController) GetAll(c *fiber.Ctx) error {
|
||||
var filter model.StateHistoryFilter
|
||||
if err := common.ParseQueryFilter(c, &filter); err != nil {
|
||||
@@ -63,7 +63,7 @@ func (ac *StateHistoryController) GetAll(c *fiber.Ctx) error {
|
||||
// @Description Return StateHistorys
|
||||
// @Tags StateHistory
|
||||
// @Success 200 {array} string
|
||||
// @Router /v1/state-history/statistics [get]
|
||||
// @Router /state-history/statistics [get]
|
||||
func (ac *StateHistoryController) GetStatistics(c *fiber.Ctx) error {
|
||||
var filter model.StateHistoryFilter
|
||||
if err := common.ParseQueryFilter(c, &filter); err != nil {
|
||||
|
||||
57
local/controller/steam.go
Normal file
57
local/controller/steam.go
Normal 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})
|
||||
}
|
||||
4
local/controller/system.go
Normal file → Executable file
4
local/controller/system.go
Normal file → Executable file
@@ -31,9 +31,9 @@ func NewSystemController(routeGroups *common.RouteGroups) *SystemController {
|
||||
//
|
||||
// @Summary Return service control status
|
||||
// @Description Return service control status
|
||||
// @Tags service-control
|
||||
// @Tags system
|
||||
// @Success 200 {array} string
|
||||
// @Router /v1/service-control [get]
|
||||
// @Router /system/health [get]
|
||||
func (ac *SystemController) getFirst(c *fiber.Ctx) error {
|
||||
return c.SendString(configs.Version)
|
||||
}
|
||||
|
||||
138
local/controller/websocket.go
Executable file
138
local/controller/websocket.go
Executable 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
4
local/middleware/access_key.go
Normal file → Executable 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")
|
||||
|
||||
|
||||
78
local/middleware/auth.go
Normal file → Executable file
78
local/middleware/auth.go
Normal file → Executable 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,34 +24,49 @@ type CachedUserInfo struct {
|
||||
CachedAt time.Time
|
||||
}
|
||||
|
||||
// AuthMiddleware provides authentication and permission middleware.
|
||||
type AuthMiddleware struct {
|
||||
membershipService *service.MembershipService
|
||||
cache *cache.InMemoryCache
|
||||
securityMW *security.SecurityMiddleware
|
||||
jwtHandler *jwt.JWTHandler
|
||||
openJWTHandler *jwt.OpenJWTHandler
|
||||
}
|
||||
|
||||
// NewAuthMiddleware creates a new AuthMiddleware.
|
||||
func NewAuthMiddleware(ms *service.MembershipService, cache *cache.InMemoryCache) *AuthMiddleware {
|
||||
func NewAuthMiddleware(ms *service.MembershipService, cache *cache.InMemoryCache, jwtHandler *jwt.JWTHandler, openJWTHandler *jwt.OpenJWTHandler) *AuthMiddleware {
|
||||
auth := &AuthMiddleware{
|
||||
membershipService: ms,
|
||||
cache: cache,
|
||||
securityMW: security.NewSecurityMiddleware(),
|
||||
jwtHandler: jwtHandler,
|
||||
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)
|
||||
}
|
||||
|
||||
func (m *AuthMiddleware) Authenticate(ctx *fiber.Ctx) error {
|
||||
// Log authentication attempt
|
||||
return m.AuthenticateWithHandler(m.jwtHandler, false, ctx)
|
||||
}
|
||||
|
||||
func (m *AuthMiddleware) AuthenticateWithHandler(jwtHandler *jwt.JWTHandler, isOpenToken bool, ctx *fiber.Ctx) error {
|
||||
ip := ctx.IP()
|
||||
userAgent := ctx.Get("User-Agent")
|
||||
|
||||
authHeader := ctx.Get("Authorization")
|
||||
|
||||
if jwtHandler.IsOpenToken && !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 authHeader == "" {
|
||||
logging.Error("Authentication failed: missing Authorization header from IP %s", ip)
|
||||
return ctx.Status(fiber.StatusUnauthorized).JSON(fiber.Map{
|
||||
@@ -68,7 +82,6 @@ func (m *AuthMiddleware) Authenticate(ctx *fiber.Ctx) error {
|
||||
})
|
||||
}
|
||||
|
||||
// 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)
|
||||
@@ -77,7 +90,7 @@ func (m *AuthMiddleware) Authenticate(ctx *fiber.Ctx) error {
|
||||
})
|
||||
}
|
||||
|
||||
claims, err := jwt.ValidateToken(token)
|
||||
claims, err := jwtHandler.ValidateToken(token)
|
||||
if err != nil {
|
||||
logging.Error("Authentication failed: invalid token from IP %s, User-Agent: %s, Error: %v", ip, userAgent, err)
|
||||
return ctx.Status(fiber.StatusUnauthorized).JSON(fiber.Map{
|
||||
@@ -85,7 +98,13 @@ func (m *AuthMiddleware) Authenticate(ctx *fiber.Ctx) error {
|
||||
})
|
||||
}
|
||||
|
||||
// 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{
|
||||
@@ -96,10 +115,10 @@ func (m *AuthMiddleware) Authenticate(ctx *fiber.Ctx) error {
|
||||
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)
|
||||
@@ -109,6 +128,7 @@ func (m *AuthMiddleware) Authenticate(ctx *fiber.Ctx) error {
|
||||
}
|
||||
|
||||
ctx.Locals("userID", claims.UserID)
|
||||
ctx.Locals("actorUsername", userInfo.Username)
|
||||
ctx.Locals("userInfo", userInfo)
|
||||
ctx.Locals("authTime", time.Now())
|
||||
}
|
||||
@@ -117,7 +137,6 @@ func (m *AuthMiddleware) Authenticate(ctx *fiber.Ctx) error {
|
||||
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)
|
||||
@@ -132,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{
|
||||
@@ -140,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())
|
||||
@@ -149,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 {
|
||||
@@ -164,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)
|
||||
@@ -183,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)
|
||||
@@ -195,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
|
||||
@@ -215,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
12
local/middleware/logging/request_logging.go
Normal file → Executable 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()
|
||||
}
|
||||
|
||||
142
local/middleware/security/security.go
Normal file → Executable file
142
local/middleware/security/security.go
Normal file → Executable file
@@ -1,6 +1,7 @@
|
||||
package security
|
||||
|
||||
import (
|
||||
"acc-server-manager/local/utl/graceful"
|
||||
"context"
|
||||
"fmt"
|
||||
"strings"
|
||||
@@ -10,88 +11,76 @@ 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),
|
||||
}
|
||||
|
||||
// Clean up old entries every 5 minutes
|
||||
go rl.cleanup()
|
||||
shutdownManager := graceful.GetManager()
|
||||
shutdownManager.RunGoroutine(func(ctx context.Context) {
|
||||
rl.cleanupWithContext(ctx)
|
||||
})
|
||||
|
||||
return rl
|
||||
}
|
||||
|
||||
// cleanup removes old entries from the rate limiter
|
||||
func (rl *RateLimiter) cleanup() {
|
||||
func (rl *RateLimiter) cleanupWithContext(ctx context.Context) {
|
||||
ticker := time.NewTicker(5 * time.Minute)
|
||||
defer ticker.Stop()
|
||||
|
||||
for range ticker.C {
|
||||
rl.mutex.Lock()
|
||||
now := time.Now()
|
||||
for key, times := range rl.requests {
|
||||
// Remove entries older than 1 hour
|
||||
filtered := make([]time.Time, 0, len(times))
|
||||
for _, t := range times {
|
||||
if now.Sub(t) < time.Hour {
|
||||
filtered = append(filtered, t)
|
||||
for {
|
||||
select {
|
||||
case <-ctx.Done():
|
||||
return
|
||||
case <-ticker.C:
|
||||
rl.mutex.Lock()
|
||||
now := time.Now()
|
||||
for key, times := range rl.requests {
|
||||
filtered := make([]time.Time, 0, len(times))
|
||||
for _, t := range times {
|
||||
if now.Sub(t) < time.Hour {
|
||||
filtered = append(filtered, t)
|
||||
}
|
||||
}
|
||||
if len(filtered) == 0 {
|
||||
delete(rl.requests, key)
|
||||
} else {
|
||||
rl.requests[key] = filtered
|
||||
}
|
||||
}
|
||||
if len(filtered) == 0 {
|
||||
delete(rl.requests, key)
|
||||
} else {
|
||||
rl.requests[key] = filtered
|
||||
}
|
||||
rl.mutex.Unlock()
|
||||
}
|
||||
rl.mutex.Unlock()
|
||||
}
|
||||
}
|
||||
|
||||
// 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()
|
||||
@@ -103,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 {
|
||||
@@ -111,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",
|
||||
@@ -119,7 +106,6 @@ func (sm *SecurityMiddleware) RateLimit(maxRequests int, duration time.Duration)
|
||||
})
|
||||
}
|
||||
|
||||
// Add current request
|
||||
filtered = append(filtered, now)
|
||||
sm.rateLimiter.requests[key] = filtered
|
||||
|
||||
@@ -127,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()
|
||||
@@ -140,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 {
|
||||
@@ -148,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",
|
||||
@@ -156,7 +139,6 @@ func (sm *SecurityMiddleware) AuthRateLimit() fiber.Handler {
|
||||
})
|
||||
}
|
||||
|
||||
// Add current request
|
||||
filtered = append(filtered, now)
|
||||
sm.rateLimiter.requests[key] = filtered
|
||||
|
||||
@@ -164,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)
|
||||
}
|
||||
@@ -187,15 +165,14 @@ func (sm *SecurityMiddleware) InputSanitization() fiber.Handler {
|
||||
}
|
||||
}
|
||||
|
||||
// sanitizeInput removes potentially dangerous patterns from input
|
||||
func sanitizeInput(input string) string {
|
||||
// Remove common XSS patterns
|
||||
dangerous := []string{
|
||||
"<script",
|
||||
"</script>",
|
||||
"javascript:",
|
||||
"vbscript:",
|
||||
"data:text/html",
|
||||
"data:application",
|
||||
"onload=",
|
||||
"onerror=",
|
||||
"onclick=",
|
||||
@@ -204,28 +181,48 @@ func sanitizeInput(input string) string {
|
||||
"onblur=",
|
||||
"onchange=",
|
||||
"onsubmit=",
|
||||
"onkeydown=",
|
||||
"onkeyup=",
|
||||
"<iframe",
|
||||
"<object",
|
||||
"<embed",
|
||||
"<link",
|
||||
"<meta",
|
||||
"<style",
|
||||
"<form",
|
||||
"<input",
|
||||
"<button",
|
||||
"<svg",
|
||||
"<math",
|
||||
"expression(",
|
||||
"@import",
|
||||
"url(",
|
||||
"\\x",
|
||||
"\\u",
|
||||
"&#x",
|
||||
"&#",
|
||||
}
|
||||
|
||||
result := strings.ToLower(input)
|
||||
result := input
|
||||
lowerInput := strings.ToLower(input)
|
||||
|
||||
for _, pattern := range dangerous {
|
||||
result = strings.ReplaceAll(result, pattern, "")
|
||||
if strings.Contains(lowerInput, pattern) {
|
||||
return ""
|
||||
}
|
||||
}
|
||||
|
||||
// If the sanitized version is very different, it might be malicious
|
||||
if len(result) < len(input)/2 {
|
||||
if strings.Contains(result, "\x00") {
|
||||
return ""
|
||||
}
|
||||
|
||||
return input
|
||||
if len(strings.TrimSpace(result)) == 0 && len(input) > 0 {
|
||||
return ""
|
||||
}
|
||||
|
||||
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" {
|
||||
@@ -236,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) {
|
||||
@@ -256,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",
|
||||
@@ -267,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{
|
||||
@@ -294,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" {
|
||||
@@ -311,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(),
|
||||
@@ -338,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)
|
||||
@@ -349,3 +336,24 @@ func (sm *SecurityMiddleware) TimeoutMiddleware(timeout time.Duration) fiber.Han
|
||||
return c.Next()
|
||||
}
|
||||
}
|
||||
|
||||
func (sm *SecurityMiddleware) RequestContextTimeout(timeout time.Duration) fiber.Handler {
|
||||
return func(c *fiber.Ctx) error {
|
||||
ctx, cancel := context.WithTimeout(c.UserContext(), timeout)
|
||||
defer cancel()
|
||||
|
||||
done := make(chan error, 1)
|
||||
go func() {
|
||||
done <- c.Next()
|
||||
}()
|
||||
|
||||
select {
|
||||
case err := <-done:
|
||||
return err
|
||||
case <-ctx.Done():
|
||||
return c.Status(fiber.StatusRequestTimeout).JSON(fiber.Map{
|
||||
"error": "Request timeout",
|
||||
})
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
41
local/migrations/001_upgrade_password_security.go
Normal file → Executable file
41
local/migrations/001_upgrade_password_security.go
Normal file → Executable 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
21
local/migrations/002_migrate_to_uuid.go
Normal file → Executable 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()
|
||||
|
||||
106
local/migrations/003_update_state_history_sessions.go
Executable file
106
local/migrations/003_update_state_history_sessions.go
Executable 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()
|
||||
}
|
||||
106
local/migrations/004_fix_ddl_comments.go
Executable file
106
local/migrations/004_fix_ddl_comments.go
Executable 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()
|
||||
}
|
||||
33
local/migrations/005_add_platform_columns.go
Normal file
33
local/migrations/005_add_platform_columns.go
Normal 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
|
||||
}
|
||||
33
local/migrations/006_add_activity_log.go
Normal file
33
local/migrations/006_add_activity_log.go
Normal 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
|
||||
}
|
||||
82
local/model/activity_log.go
Normal file
82
local/model/activity_log.go
Normal file
@@ -0,0 +1,82 @@
|
||||
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"`
|
||||
Server *Server `json:"server,omitempty" gorm:"foreignKey:ServerID"`
|
||||
UserID string `json:"userId" gorm:"not null"`
|
||||
Username string `json:"username" gorm:"not null"`
|
||||
Action ActionType `json:"action" gorm:"not null"`
|
||||
Details string `json:"details" gorm:"type:text"`
|
||||
CreatedAt time.Time `json:"createdAt"`
|
||||
}
|
||||
|
||||
func (a *ActivityLog) BeforeCreate(tx *gorm.DB) error {
|
||||
if a.ID == uuid.Nil {
|
||||
a.ID = uuid.New()
|
||||
}
|
||||
if a.CreatedAt.IsZero() {
|
||||
a.CreatedAt = time.Now().UTC()
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
type ActivityLogFilter struct {
|
||||
BaseFilter
|
||||
DateRangeFilter
|
||||
UserID string `query:"user_id"`
|
||||
Action ActionType `query:"action"`
|
||||
ServerID string `query:"server_id"`
|
||||
}
|
||||
|
||||
func (f *ActivityLogFilter) ApplyFilter(query *gorm.DB) *gorm.DB {
|
||||
if f.ServerID != "" {
|
||||
if serverUUID, err := uuid.Parse(f.ServerID); err == nil {
|
||||
query = query.Where("server_id = ?", serverUUID)
|
||||
}
|
||||
}
|
||||
if f.UserID != "" {
|
||||
query = query.Where("user_id = ?", f.UserID)
|
||||
}
|
||||
if f.Action != "" {
|
||||
query = query.Where("action = ?", f.Action)
|
||||
}
|
||||
timeZero := time.Time{}
|
||||
if f.StartDate != timeZero {
|
||||
query = query.Where("created_at >= ?", f.StartDate)
|
||||
}
|
||||
if f.EndDate != timeZero {
|
||||
query = query.Where("created_at <= ?", f.EndDate)
|
||||
}
|
||||
return query
|
||||
}
|
||||
|
||||
func (f *ActivityLogFilter) GetSorting() (field string, desc bool) {
|
||||
if f.SortBy == "" {
|
||||
return "created_at", true
|
||||
}
|
||||
return f.SortBy, f.SortDesc
|
||||
}
|
||||
|
||||
type LastActivityInfo struct {
|
||||
UpdatedAt time.Time `json:"updatedAt"`
|
||||
Username string `json:"username"`
|
||||
Action ActionType `json:"action"`
|
||||
}
|
||||
38
local/model/cache.go
Normal file → Executable file
38
local/model/cache.go
Normal file → Executable 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
24
local/model/config.go
Normal file → Executable 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
18
local/model/filter.go
Normal file → Executable 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
113
local/model/leaderboard.go
Executable 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
5
local/model/lookup.go
Normal file → Executable 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
2
local/model/model.go
Normal file → Executable 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
4
local/model/permission.go
Normal file → Executable 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
2
local/model/permissions.go
Normal file → Executable 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
4
local/model/role.go
Normal file → Executable 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
71
local/model/server.go
Normal file → Executable 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
10
local/model/service_control.go
Normal file → Executable 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
91
local/model/state_history.go
Normal file → Executable 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
36
local/model/state_history_stats.go
Normal file → Executable 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"`
|
||||
}
|
||||
|
||||
168
local/model/steam_2fa.go
Executable file
168
local/model/steam_2fa.go
Executable file
@@ -0,0 +1,168 @@
|
||||
package model
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"sync"
|
||||
"time"
|
||||
|
||||
"github.com/google/uuid"
|
||||
)
|
||||
|
||||
type Steam2FAStatus string
|
||||
|
||||
const (
|
||||
Steam2FAStatusIdle Steam2FAStatus = "idle"
|
||||
Steam2FAStatusPending Steam2FAStatus = "pending"
|
||||
Steam2FAStatusComplete Steam2FAStatus = "complete"
|
||||
Steam2FAStatusError Steam2FAStatus = "error"
|
||||
)
|
||||
|
||||
type Steam2FARequest struct {
|
||||
ID string `json:"id"`
|
||||
Status Steam2FAStatus `json:"status"`
|
||||
Message string `json:"message"`
|
||||
RequestTime time.Time `json:"requestTime"`
|
||||
CompletedAt *time.Time `json:"completedAt,omitempty"`
|
||||
ErrorMsg string `json:"errorMsg,omitempty"`
|
||||
ServerID *uuid.UUID `json:"serverId,omitempty"`
|
||||
}
|
||||
|
||||
// Steam2FAManager manages 2FA requests and responses
|
||||
type Steam2FAManager struct {
|
||||
mu sync.RWMutex
|
||||
requests map[string]*Steam2FARequest
|
||||
channels map[string]chan bool
|
||||
}
|
||||
|
||||
func NewSteam2FAManager() *Steam2FAManager {
|
||||
return &Steam2FAManager{
|
||||
requests: make(map[string]*Steam2FARequest),
|
||||
channels: make(map[string]chan bool),
|
||||
}
|
||||
}
|
||||
|
||||
func (m *Steam2FAManager) CreateRequest(message string, serverID *uuid.UUID) *Steam2FARequest {
|
||||
m.mu.Lock()
|
||||
defer m.mu.Unlock()
|
||||
|
||||
id := uuid.New().String()
|
||||
request := &Steam2FARequest{
|
||||
ID: id,
|
||||
Status: Steam2FAStatusPending,
|
||||
Message: message,
|
||||
RequestTime: time.Now(),
|
||||
ServerID: serverID,
|
||||
}
|
||||
|
||||
m.requests[id] = request
|
||||
m.channels[id] = make(chan bool, 1)
|
||||
|
||||
return request
|
||||
}
|
||||
|
||||
func (m *Steam2FAManager) GetRequest(id string) (*Steam2FARequest, bool) {
|
||||
m.mu.RLock()
|
||||
defer m.mu.RUnlock()
|
||||
|
||||
req, exists := m.requests[id]
|
||||
return req, exists
|
||||
}
|
||||
|
||||
func (m *Steam2FAManager) GetPendingRequests() []*Steam2FARequest {
|
||||
m.mu.RLock()
|
||||
defer m.mu.RUnlock()
|
||||
|
||||
var pending []*Steam2FARequest
|
||||
for _, req := range m.requests {
|
||||
if req.Status == Steam2FAStatusPending {
|
||||
pending = append(pending, req)
|
||||
}
|
||||
}
|
||||
return pending
|
||||
}
|
||||
|
||||
func (m *Steam2FAManager) CompleteRequest(id string) error {
|
||||
m.mu.Lock()
|
||||
defer m.mu.Unlock()
|
||||
|
||||
req, exists := m.requests[id]
|
||||
if !exists {
|
||||
return fmt.Errorf("request %s not found", id)
|
||||
}
|
||||
|
||||
if req.Status != Steam2FAStatusPending {
|
||||
return fmt.Errorf("request %s is not pending", id)
|
||||
}
|
||||
|
||||
now := time.Now()
|
||||
req.Status = Steam2FAStatusComplete
|
||||
req.CompletedAt = &now
|
||||
|
||||
// Signal the waiting goroutine
|
||||
if ch, exists := m.channels[id]; exists {
|
||||
select {
|
||||
case ch <- true:
|
||||
default:
|
||||
}
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
func (m *Steam2FAManager) ErrorRequest(id string, errorMsg string) error {
|
||||
m.mu.Lock()
|
||||
defer m.mu.Unlock()
|
||||
|
||||
req, exists := m.requests[id]
|
||||
if !exists {
|
||||
return fmt.Errorf("request %s not found", id)
|
||||
}
|
||||
|
||||
req.Status = Steam2FAStatusError
|
||||
req.ErrorMsg = errorMsg
|
||||
|
||||
// Signal the waiting goroutine with error
|
||||
if ch, exists := m.channels[id]; exists {
|
||||
select {
|
||||
case ch <- false:
|
||||
default:
|
||||
}
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
func (m *Steam2FAManager) WaitForCompletion(id string, timeout time.Duration) (bool, error) {
|
||||
m.mu.RLock()
|
||||
ch, exists := m.channels[id]
|
||||
m.mu.RUnlock()
|
||||
|
||||
if !exists {
|
||||
return false, fmt.Errorf("request %s not found", id)
|
||||
}
|
||||
|
||||
select {
|
||||
case success := <-ch:
|
||||
return success, nil
|
||||
case <-time.After(timeout):
|
||||
// Timeout - mark as error
|
||||
m.ErrorRequest(id, "timeout waiting for 2FA confirmation")
|
||||
return false, fmt.Errorf("timeout waiting for 2FA confirmation")
|
||||
}
|
||||
}
|
||||
|
||||
func (m *Steam2FAManager) CleanupOldRequests(maxAge time.Duration) {
|
||||
m.mu.Lock()
|
||||
defer m.mu.Unlock()
|
||||
|
||||
cutoff := time.Now().Add(-maxAge)
|
||||
for id, req := range m.requests {
|
||||
if req.RequestTime.Before(cutoff) {
|
||||
delete(m.requests, id)
|
||||
if ch, exists := m.channels[id]; exists {
|
||||
close(ch)
|
||||
delete(m.channels, id)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
29
local/model/steam_credentials.go
Normal file → Executable file
29
local/model/steam_credentials.go
Normal file → Executable 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
12
local/model/user.go
Normal file → Executable 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)
|
||||
}
|
||||
|
||||
85
local/model/websocket.go
Executable file
85
local/model/websocket.go
Executable file
@@ -0,0 +1,85 @@
|
||||
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"
|
||||
MessageTypeLogLine WebSocketMessageType = "log_line"
|
||||
)
|
||||
|
||||
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"`
|
||||
}
|
||||
|
||||
type LogLineMessage struct {
|
||||
Line string `json:"line"`
|
||||
}
|
||||
|
||||
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]
|
||||
}
|
||||
29
local/platform/interfaces.go
Normal file
29
local/platform/interfaces.go
Normal file
@@ -0,0 +1,29 @@
|
||||
package platform
|
||||
|
||||
import (
|
||||
"acc-server-manager/local/model"
|
||||
"context"
|
||||
|
||||
"github.com/google/uuid"
|
||||
)
|
||||
|
||||
type ServerRuntime interface {
|
||||
Create(ctx context.Context, server *model.Server) error
|
||||
Delete(ctx context.Context, serverID uuid.UUID) error
|
||||
Start(ctx context.Context, serverID uuid.UUID) (string, error)
|
||||
Stop(ctx context.Context, serverID uuid.UUID) (string, error)
|
||||
Restart(ctx context.Context, serverID uuid.UUID) (string, error)
|
||||
Status(ctx context.Context, serverID uuid.UUID) (string, error)
|
||||
}
|
||||
|
||||
type FirewallManager interface {
|
||||
CreateServerRules(serverName string, tcpPorts, udpPorts []int) error
|
||||
DeleteServerRules(serverName string, tcpPorts, udpPorts []int) error
|
||||
UpdateServerRules(serverName string, tcpPorts, udpPorts []int) error
|
||||
}
|
||||
|
||||
type LogStreamer interface {
|
||||
Start(ctx context.Context, server *model.Server, handleLine func(string)) error
|
||||
Stop(serverID uuid.UUID)
|
||||
GetLastLines(ctx context.Context, server *model.Server, n int) ([]string, error)
|
||||
}
|
||||
96
local/repository/activity_log.go
Normal file
96
local/repository/activity_log.go
Normal file
@@ -0,0 +1,96 @@
|
||||
package repository
|
||||
|
||||
import (
|
||||
"acc-server-manager/local/model"
|
||||
"context"
|
||||
"fmt"
|
||||
"time"
|
||||
|
||||
"github.com/google/uuid"
|
||||
"gorm.io/gorm"
|
||||
)
|
||||
|
||||
type ActivityLogRepository struct {
|
||||
*BaseRepository[model.ActivityLog, model.ActivityLogFilter]
|
||||
}
|
||||
|
||||
func NewActivityLogRepository(db *gorm.DB) *ActivityLogRepository {
|
||||
return &ActivityLogRepository{
|
||||
BaseRepository: NewBaseRepository[model.ActivityLog, model.ActivityLogFilter](db, model.ActivityLog{}),
|
||||
}
|
||||
}
|
||||
|
||||
func (r *ActivityLogRepository) Insert(ctx context.Context, entry *model.ActivityLog) error {
|
||||
return r.BaseRepository.Insert(ctx, entry)
|
||||
}
|
||||
|
||||
func (r *ActivityLogRepository) GetAll(ctx context.Context, filter *model.ActivityLogFilter) (*[]model.ActivityLog, error) {
|
||||
result := &[]model.ActivityLog{}
|
||||
query := r.BaseRepository.db.WithContext(ctx).
|
||||
Model(&model.ActivityLog{}).
|
||||
Preload("Server")
|
||||
|
||||
query = filter.ApplyFilter(query)
|
||||
|
||||
offset, limit := filter.Pagination()
|
||||
query = query.Offset(offset).Limit(limit)
|
||||
|
||||
field, desc := filter.GetSorting()
|
||||
if desc {
|
||||
query = query.Order(field + " DESC")
|
||||
} else {
|
||||
query = query.Order(field)
|
||||
}
|
||||
|
||||
if err := query.Find(result).Error; err != nil {
|
||||
return nil, fmt.Errorf("error getting activity logs: %w", err)
|
||||
}
|
||||
return result, nil
|
||||
}
|
||||
|
||||
type lastActivityRow struct {
|
||||
ServerID string `gorm:"column:server_id"`
|
||||
CreatedAt time.Time `gorm:"column:created_at"`
|
||||
Username string `gorm:"column:username"`
|
||||
Action model.ActionType `gorm:"column:action"`
|
||||
}
|
||||
|
||||
// GetLastActivityByServerIDs returns the most recent activity entry per server.
|
||||
// Uses a single GROUP BY query with MAX(created_at); SQLite selects the other
|
||||
// columns from the row that holds the max value — documented SQLite behaviour.
|
||||
func (r *ActivityLogRepository) GetLastActivityByServerIDs(ctx context.Context, serverIDs []uuid.UUID) (map[uuid.UUID]*model.LastActivityInfo, error) {
|
||||
out := make(map[uuid.UUID]*model.LastActivityInfo)
|
||||
if len(serverIDs) == 0 {
|
||||
return out, nil
|
||||
}
|
||||
|
||||
// Convert to string slice so the IN clause works with SQLite UUID strings.
|
||||
ids := make([]string, len(serverIDs))
|
||||
for i, id := range serverIDs {
|
||||
ids[i] = id.String()
|
||||
}
|
||||
|
||||
var rows []lastActivityRow
|
||||
err := r.BaseRepository.db.WithContext(ctx).
|
||||
Model(&model.ActivityLog{}).
|
||||
Select("server_id, MAX(created_at) as created_at, username, action").
|
||||
Where("server_id IN ?", ids).
|
||||
Group("server_id").
|
||||
Scan(&rows).Error
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
for _, row := range rows {
|
||||
serverUUID, err := uuid.Parse(row.ServerID)
|
||||
if err != nil {
|
||||
continue
|
||||
}
|
||||
out[serverUUID] = &model.LastActivityInfo{
|
||||
UpdatedAt: row.CreatedAt,
|
||||
Username: row.Username,
|
||||
Action: row.Action,
|
||||
}
|
||||
}
|
||||
return out, nil
|
||||
}
|
||||
13
local/repository/base.go
Normal file → Executable file
13
local/repository/base.go
Normal file → Executable 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
4
local/repository/config.go
Normal file → Executable 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
144
local/repository/leaderboard.go
Executable 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
0
local/repository/lookup.go
Normal file → Executable file
19
local/repository/membership.go
Normal file → Executable file
19
local/repository/membership.go
Normal file → Executable 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)
|
||||
|
||||
37
local/repository/repository.go
Normal file → Executable file
37
local/repository/repository.go
Normal file → Executable file
@@ -1,14 +1,16 @@
|
||||
package repository
|
||||
|
||||
import (
|
||||
"acc-server-manager/local/model"
|
||||
"acc-server-manager/local/utl/graceful"
|
||||
"acc-server-manager/local/utl/logging"
|
||||
"context"
|
||||
"time"
|
||||
|
||||
"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)
|
||||
@@ -17,4 +19,29 @@ func InitializeRepositories(c *dig.Container) {
|
||||
c.Provide(NewLookupRepository)
|
||||
c.Provide(NewSteamCredentialsRepository)
|
||||
c.Provide(NewMembershipRepository)
|
||||
c.Provide(NewLeaderboardRepository)
|
||||
c.Provide(NewActivityLogRepository)
|
||||
|
||||
if err := c.Provide(func() *model.Steam2FAManager {
|
||||
manager := model.NewSteam2FAManager()
|
||||
|
||||
shutdownManager := graceful.GetManager()
|
||||
shutdownManager.RunGoroutine(func(ctx context.Context) {
|
||||
ticker := time.NewTicker(15 * time.Minute)
|
||||
defer ticker.Stop()
|
||||
|
||||
for {
|
||||
select {
|
||||
case <-ctx.Done():
|
||||
return
|
||||
case <-ticker.C:
|
||||
manager.CleanupOldRequests(30 * time.Minute)
|
||||
}
|
||||
}
|
||||
})
|
||||
|
||||
return manager
|
||||
}); err != nil {
|
||||
logging.Panic("unable to initialize steam 2fa manager")
|
||||
}
|
||||
}
|
||||
|
||||
4
local/repository/server.go
Normal file → Executable file
4
local/repository/server.go
Normal file → Executable 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
0
local/repository/service_control.go
Normal file → Executable file
19
local/repository/state_history.go
Normal file → Executable file
19
local/repository/state_history.go
Normal file → Executable 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
0
local/repository/steam_credentials.go
Normal file → Executable file
65
local/service/activity_log.go
Normal file
65
local/service/activity_log.go
Normal file
@@ -0,0 +1,65 @@
|
||||
package service
|
||||
|
||||
import (
|
||||
"acc-server-manager/local/model"
|
||||
"acc-server-manager/local/repository"
|
||||
"acc-server-manager/local/utl/logging"
|
||||
"context"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
)
|
||||
|
||||
type ActivityLogService struct {
|
||||
repo *repository.ActivityLogRepository
|
||||
}
|
||||
|
||||
func NewActivityLogService(repo *repository.ActivityLogRepository) *ActivityLogService {
|
||||
return &ActivityLogService{repo: repo}
|
||||
}
|
||||
|
||||
// Log persists an activity log entry. Errors are logged but not returned so
|
||||
// callers can treat this as fire-and-forget.
|
||||
func (s *ActivityLogService) Log(ctx context.Context, entry *model.ActivityLog) error {
|
||||
if err := s.repo.Insert(ctx, entry); err != nil {
|
||||
logging.Error("Failed to insert activity log: %v", err)
|
||||
return err
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func (s *ActivityLogService) GetAll(ctx context.Context, filter *model.ActivityLogFilter) (*[]model.ActivityLog, error) {
|
||||
return s.repo.GetAll(ctx, filter)
|
||||
}
|
||||
|
||||
// BuildConfigDetails produces a JSON string summarising which top-level keys
|
||||
// changed between oldJSON and newJSON.
|
||||
func BuildConfigDetails(file string, oldJSON, newJSON []byte) string {
|
||||
var oldMap, newMap map[string]interface{}
|
||||
|
||||
if err := json.Unmarshal(oldJSON, &oldMap); err != nil {
|
||||
oldMap = map[string]interface{}{}
|
||||
}
|
||||
if err := json.Unmarshal(newJSON, &newMap); err != nil {
|
||||
newMap = map[string]interface{}{}
|
||||
}
|
||||
|
||||
var changedKeys []string
|
||||
for key, newVal := range newMap {
|
||||
oldVal, exists := oldMap[key]
|
||||
if !exists {
|
||||
changedKeys = append(changedKeys, key)
|
||||
continue
|
||||
}
|
||||
oldBytes, _ := json.Marshal(oldVal)
|
||||
newBytes, _ := json.Marshal(newVal)
|
||||
if string(oldBytes) != string(newBytes) {
|
||||
changedKeys = append(changedKeys, key)
|
||||
}
|
||||
}
|
||||
|
||||
if len(changedKeys) == 0 {
|
||||
return ""
|
||||
}
|
||||
keysJSON, _ := json.Marshal(changedKeys)
|
||||
return fmt.Sprintf(`{"file":%q,"changed_keys":%s}`, file, string(keysJSON))
|
||||
}
|
||||
403
local/service/config.go
Normal file → Executable file
403
local/service/config.go
Normal file → Executable 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,166 @@ 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
|
||||
}
|
||||
if entry.fileName == SettingsJson {
|
||||
if nameVal, ok := (*entry.body)["serverName"]; ok {
|
||||
if nameStr, ok := nameVal.(string); ok && nameStr != "" && nameStr != server.Name {
|
||||
server.Name = nameStr
|
||||
if err := as.serverRepository.Update(ctx.UserContext(), server); err != nil {
|
||||
logging.Error("Failed to update server name in DB: %v", err)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
result := as.repository.UpdateConfig(ctx.UserContext(), &model.Config{
|
||||
ServerID: serverUUID,
|
||||
ConfigFile: entry.fileName,
|
||||
OldConfig: string(oldDataUTF8),
|
||||
NewConfig: string(newData),
|
||||
ChangedAt: time.Now(),
|
||||
})
|
||||
results = append(results, result)
|
||||
|
||||
if as.activityLog != nil {
|
||||
if details := BuildConfigDetails(entry.fileName, oldDataUTF8, newData); details != "" {
|
||||
go func(details string) {
|
||||
_ = as.activityLog.Log(context.Background(), &model.ActivityLog{
|
||||
ServerID: serverUUID,
|
||||
UserID: actorID,
|
||||
Username: actorUsername,
|
||||
Action: model.ActionConfigUpdate,
|
||||
Details: details,
|
||||
})
|
||||
}(details)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if len(results) > 0 {
|
||||
as.configCache.InvalidateServerCache(serverID)
|
||||
as.serverService.StartAccServerRuntime(server)
|
||||
}
|
||||
|
||||
return results, nil
|
||||
}
|
||||
|
||||
func (as *ConfigService) UpdateConfig(ctx *fiber.Ctx, body *map[string]interface{}) (*model.Config, error) {
|
||||
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 +262,39 @@ 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)
|
||||
oldDataUTF8, newData, err := as.updateConfigFiles(ctx, server, configFile, body, override)
|
||||
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
|
||||
return nil, err
|
||||
}
|
||||
|
||||
if configFile == SettingsJson {
|
||||
if nameVal, ok := (*body)["serverName"]; ok {
|
||||
if nameStr, ok := nameVal.(string); ok && nameStr != "" && nameStr != server.Name {
|
||||
server.Name = nameStr
|
||||
if err := as.serverRepository.Update(ctx, server); err != nil {
|
||||
logging.Error("Failed to update server name in DB: %v", err)
|
||||
}
|
||||
}
|
||||
// Create empty JSON file
|
||||
if err := os.WriteFile(configPath, []byte("{}"), 0644); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
oldData = []byte("{}")
|
||||
} else {
|
||||
return nil, err
|
||||
}
|
||||
}
|
||||
|
||||
oldDataUTF8, err := DecodeUTF16LEBOM(oldData)
|
||||
if err != nil {
|
||||
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 {
|
||||
if details := BuildConfigDetails(configFile, oldDataUTF8, newData); details != "" {
|
||||
go func() {
|
||||
_ = as.activityLog.Log(context.Background(), &model.ActivityLog{
|
||||
ServerID: serverUUID,
|
||||
UserID: actorID,
|
||||
Username: actorUsername,
|
||||
Action: model.ActionConfigUpdate,
|
||||
Details: details,
|
||||
})
|
||||
}()
|
||||
}
|
||||
}
|
||||
|
||||
return as.repository.UpdateConfig(ctx, &model.Config{
|
||||
ServerID: serverUUID,
|
||||
ConfigFile: configFile,
|
||||
@@ -183,10 +304,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 +314,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 +364,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 +412,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 +463,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 +539,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 +557,14 @@ 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
|
||||
}
|
||||
|
||||
func (as *ConfigService) SetServerName(server *model.Server, name string) error {
|
||||
configMap := map[string]interface{}{
|
||||
"serverName": name,
|
||||
}
|
||||
_, _, err := as.updateConfigFiles(context.Background(), server, SettingsJson, &configMap, false)
|
||||
return err
|
||||
}
|
||||
|
||||
108
local/service/docker_log_streamer.go
Normal file
108
local/service/docker_log_streamer.go
Normal file
@@ -0,0 +1,108 @@
|
||||
package service
|
||||
|
||||
import (
|
||||
"acc-server-manager/local/model"
|
||||
"acc-server-manager/local/utl/logging"
|
||||
"bufio"
|
||||
"context"
|
||||
"io"
|
||||
"strconv"
|
||||
"sync"
|
||||
"time"
|
||||
|
||||
"github.com/docker/docker/api/types"
|
||||
dockerclient "github.com/docker/docker/client"
|
||||
"github.com/docker/docker/pkg/stdcopy"
|
||||
"github.com/google/uuid"
|
||||
)
|
||||
|
||||
type DockerLogStreamer struct {
|
||||
client *dockerclient.Client
|
||||
cancels sync.Map // uuid.UUID → context.CancelFunc
|
||||
}
|
||||
|
||||
func NewDockerLogStreamer(client *dockerclient.Client) *DockerLogStreamer {
|
||||
return &DockerLogStreamer{client: client}
|
||||
}
|
||||
|
||||
func (s *DockerLogStreamer) Start(ctx context.Context, server *model.Server, handleLine func(string)) error {
|
||||
s.Stop(server.ID)
|
||||
|
||||
if server.ContainerID == "" {
|
||||
logging.Warn("DockerLogStreamer.Start: no container ID for server %s, skipping", server.ID)
|
||||
return nil
|
||||
}
|
||||
|
||||
since := time.Now().UTC().Format(time.RFC3339)
|
||||
streamCtx, cancel := context.WithCancel(ctx)
|
||||
s.cancels.Store(server.ID, cancel)
|
||||
|
||||
go func() {
|
||||
defer s.cancels.Delete(server.ID)
|
||||
|
||||
rc, err := s.client.ContainerLogs(streamCtx, server.ContainerID, types.ContainerLogsOptions{
|
||||
ShowStdout: true,
|
||||
ShowStderr: true,
|
||||
Follow: true,
|
||||
Timestamps: false,
|
||||
Since: since,
|
||||
})
|
||||
if err != nil {
|
||||
logging.Error("DockerLogStreamer: failed to attach to container %s: %v", server.ContainerID[:12], err)
|
||||
return
|
||||
}
|
||||
defer rc.Close()
|
||||
|
||||
pr, pw := io.Pipe()
|
||||
go func() {
|
||||
defer pw.Close()
|
||||
if _, err := stdcopy.StdCopy(pw, pw, rc); err != nil && err != io.EOF {
|
||||
logging.Warn("DockerLogStreamer: stdcopy error for %s: %v", server.ServiceName, err)
|
||||
}
|
||||
}()
|
||||
|
||||
scanner := bufio.NewScanner(pr)
|
||||
for scanner.Scan() {
|
||||
handleLine(scanner.Text())
|
||||
}
|
||||
}()
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
func (s *DockerLogStreamer) Stop(serverID uuid.UUID) {
|
||||
if v, ok := s.cancels.LoadAndDelete(serverID); ok {
|
||||
v.(context.CancelFunc)()
|
||||
}
|
||||
}
|
||||
|
||||
func (s *DockerLogStreamer) GetLastLines(ctx context.Context, server *model.Server, n int) ([]string, error) {
|
||||
if n <= 0 || server.ContainerID == "" {
|
||||
return []string{}, nil
|
||||
}
|
||||
|
||||
rc, err := s.client.ContainerLogs(ctx, server.ContainerID, types.ContainerLogsOptions{
|
||||
ShowStdout: true,
|
||||
ShowStderr: true,
|
||||
Tail: strconv.Itoa(n),
|
||||
})
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
defer rc.Close()
|
||||
|
||||
pr, pw := io.Pipe()
|
||||
go func() {
|
||||
defer pw.Close()
|
||||
if _, err := stdcopy.StdCopy(pw, pw, rc); err != nil && err != io.EOF {
|
||||
logging.Warn("DockerLogStreamer.GetLastLines: stdcopy error for %s: %v", server.ServiceName, err)
|
||||
}
|
||||
}()
|
||||
|
||||
var lines []string
|
||||
scanner := bufio.NewScanner(pr)
|
||||
for scanner.Scan() {
|
||||
lines = append(lines, scanner.Text())
|
||||
}
|
||||
return lines, scanner.Err()
|
||||
}
|
||||
238
local/service/docker_runtime.go
Normal file
238
local/service/docker_runtime.go
Normal 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()
|
||||
}
|
||||
}
|
||||
77
local/service/file_log_streamer.go
Normal file
77
local/service/file_log_streamer.go
Normal file
@@ -0,0 +1,77 @@
|
||||
package service
|
||||
|
||||
import (
|
||||
"acc-server-manager/local/model"
|
||||
"acc-server-manager/local/utl/tracking"
|
||||
"bufio"
|
||||
"context"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"sync"
|
||||
|
||||
"github.com/google/uuid"
|
||||
)
|
||||
|
||||
type FileLogStreamer struct {
|
||||
tailers sync.Map // uuid.UUID → *tracking.LogTailer
|
||||
}
|
||||
|
||||
func NewFileLogStreamer() *FileLogStreamer {
|
||||
return &FileLogStreamer{}
|
||||
}
|
||||
|
||||
func (s *FileLogStreamer) Start(_ context.Context, server *model.Server, handleLine func(string)) error {
|
||||
if _, exists := s.tailers.Load(server.ID); exists {
|
||||
return nil
|
||||
}
|
||||
|
||||
logPath := filepath.Join(server.GetLogPath(), "server.log")
|
||||
tailer := tracking.NewLogTailer(logPath, handleLine)
|
||||
s.tailers.Store(server.ID, tailer)
|
||||
|
||||
go tailer.Start()
|
||||
return nil
|
||||
}
|
||||
|
||||
func (s *FileLogStreamer) Stop(serverID uuid.UUID) {
|
||||
if v, exists := s.tailers.LoadAndDelete(serverID); exists {
|
||||
v.(*tracking.LogTailer).Stop()
|
||||
}
|
||||
}
|
||||
|
||||
func (s *FileLogStreamer) GetLastLines(_ context.Context, server *model.Server, n int) ([]string, error) {
|
||||
if n <= 0 {
|
||||
return []string{}, nil
|
||||
}
|
||||
logPath := filepath.Join(server.GetLogPath(), "server.log")
|
||||
f, err := os.Open(logPath)
|
||||
if err != nil {
|
||||
if os.IsNotExist(err) {
|
||||
return []string{}, nil
|
||||
}
|
||||
return nil, err
|
||||
}
|
||||
defer f.Close()
|
||||
|
||||
ring := make([]string, n)
|
||||
head := 0
|
||||
count := 0
|
||||
scanner := bufio.NewScanner(f)
|
||||
for scanner.Scan() {
|
||||
ring[head] = scanner.Text()
|
||||
head = (head + 1) % n
|
||||
if count < n {
|
||||
count++
|
||||
}
|
||||
}
|
||||
if err := scanner.Err(); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if count < n {
|
||||
return ring[:count], nil
|
||||
}
|
||||
out := make([]string, n)
|
||||
copy(out, ring[head:])
|
||||
copy(out[n-head:], ring[:head])
|
||||
return out, nil
|
||||
}
|
||||
4
local/service/firewall_service.go
Normal file → Executable file
4
local/service/firewall_service.go
Normal file → Executable 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
59
local/service/leaderboard.go
Executable 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
0
local/service/lookup.go
Normal file → Executable file
64
local/service/membership.go
Normal file → Executable file
64
local/service/membership.go
Normal file → Executable file
@@ -12,47 +12,57 @@ 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
|
||||
jwtHandler *jwt.JWTHandler
|
||||
openJwtHandler *jwt.OpenJWTHandler
|
||||
}
|
||||
|
||||
// NewMembershipService creates a new MembershipService.
|
||||
func NewMembershipService(repo *repository.MembershipRepository) *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) Login(ctx context.Context, username, password string) (string, error) {
|
||||
func (s *MembershipService) HandleLogin(ctx context.Context, username, password string) (*model.User, error) {
|
||||
user, err := s.repo.FindUserByUsername(ctx, username)
|
||||
if err != nil {
|
||||
return "", errors.New("invalid credentials")
|
||||
return nil, errors.New("invalid credentials")
|
||||
}
|
||||
|
||||
// Use secure password verification with constant-time comparison
|
||||
if err := user.VerifyPassword(password); err != nil {
|
||||
return "", errors.New("invalid credentials")
|
||||
return nil, errors.New("invalid credentials")
|
||||
}
|
||||
|
||||
return jwt.GenerateToken(user)
|
||||
return user, nil
|
||||
}
|
||||
|
||||
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.ID.String())
|
||||
}
|
||||
|
||||
func (s *MembershipService) GenerateOpenToken(ctx context.Context, userId string) (string, error) {
|
||||
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)
|
||||
@@ -76,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")
|
||||
}
|
||||
@@ -122,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())
|
||||
}
|
||||
@@ -131,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 {
|
||||
@@ -143,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")
|
||||
@@ -160,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())
|
||||
}
|
||||
@@ -169,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
|
||||
}
|
||||
@@ -190,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
|
||||
@@ -207,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"}
|
||||
@@ -219,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"}
|
||||
@@ -231,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"}
|
||||
@@ -240,7 +230,6 @@ func (s *MembershipService) SetupInitialData(ctx context.Context) error {
|
||||
}
|
||||
}
|
||||
|
||||
// Define manager permissions (limited set)
|
||||
managerPermissionNames := []string{
|
||||
model.ServerView,
|
||||
model.ServerUpdate,
|
||||
@@ -264,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
|
||||
}
|
||||
@@ -282,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)
|
||||
}
|
||||
|
||||
7
local/service/noop_firewall.go
Normal file
7
local/service/noop_firewall.go
Normal 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 }
|
||||
467
local/service/server.go
Normal file → Executable file
467
local/service/server.go
Normal file → Executable 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,55 @@ 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()
|
||||
handleFn := func(line string) {
|
||||
instance.HandleLogLine(line)
|
||||
s.webSocketService.BroadcastLogLine(server.ID, line)
|
||||
}
|
||||
if err := s.logStreamer.Start(context.Background(), server, handleFn); err != nil {
|
||||
logging.Error("Failed to start log streaming for server %s: %v", server.ID, err)
|
||||
}
|
||||
}()
|
||||
}
|
||||
|
||||
func (s *ServerService) GetLastLogLines(ctx *fiber.Ctx, serverID uuid.UUID, n int) ([]string, error) {
|
||||
server, err := s.repository.GetByID(ctx.UserContext(), serverID)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if n <= 0 {
|
||||
n = 100
|
||||
}
|
||||
return s.logStreamer.GetLastLines(ctx.UserContext(), server, n)
|
||||
}
|
||||
|
||||
func NewServerService(
|
||||
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 +103,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 +111,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 +134,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 +173,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 +191,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 +212,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 +252,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 +269,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 +284,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 +310,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 +327,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 +343,259 @@ 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()); 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)
|
||||
}
|
||||
|
||||
if err := s.configService.SetServerName(server, server.Name); err != nil {
|
||||
logging.Error("Failed to set server name in config: %v", err)
|
||||
}
|
||||
|
||||
return fmt.Sprintf("Server configuration generated (Port: %d)", serverPort), nil
|
||||
},
|
||||
},
|
||||
{
|
||||
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); 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 +604,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 +611,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
|
||||
}
|
||||
|
||||
80
local/service/service.go
Normal file → Executable file
80
local/service/service.go
Normal file → Executable 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(NewSteamService)
|
||||
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
147
local/service/service_control.go
Normal file → Executable 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
11
local/service/service_manager.go
Normal file → Executable 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
6
local/service/state_history.go
Normal file → Executable 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 {
|
||||
|
||||
288
local/service/steam_service.go
Normal file → Executable file
288
local/service/steam_service.go
Normal file → Executable file
@@ -6,10 +6,15 @@ import (
|
||||
"acc-server-manager/local/utl/command"
|
||||
"acc-server-manager/local/utl/env"
|
||||
"acc-server-manager/local/utl/logging"
|
||||
"acc-server-manager/local/utl/security"
|
||||
"context"
|
||||
"fmt"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"github.com/google/uuid"
|
||||
)
|
||||
|
||||
const (
|
||||
@@ -17,18 +22,34 @@ const (
|
||||
)
|
||||
|
||||
type SteamService struct {
|
||||
executor *command.CommandExecutor
|
||||
repository *repository.SteamCredentialsRepository
|
||||
executor *command.CommandExecutor
|
||||
repository *repository.SteamCredentialsRepository
|
||||
tfaManager *model.Steam2FAManager
|
||||
pathValidator *security.PathValidator
|
||||
downloadVerifier *security.DownloadVerifier
|
||||
}
|
||||
|
||||
func NewSteamService(repository *repository.SteamCredentialsRepository) *SteamService {
|
||||
return &SteamService{
|
||||
executor: &command.CommandExecutor{
|
||||
ExePath: "powershell",
|
||||
LogOutput: true,
|
||||
},
|
||||
repository: repository,
|
||||
func NewSteamService(repository *repository.SteamCredentialsRepository, tfaManager *model.Steam2FAManager) *SteamService {
|
||||
baseExecutor := &command.CommandExecutor{
|
||||
ExePath: "powershell",
|
||||
LogOutput: true,
|
||||
}
|
||||
|
||||
return &SteamService{
|
||||
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) {
|
||||
@@ -42,114 +63,289 @@ func (s *SteamService) SaveCredentials(ctx context.Context, creds *model.SteamCr
|
||||
return s.repository.Save(ctx, creds)
|
||||
}
|
||||
|
||||
func (s *SteamService) ensureSteamCMD(ctx context.Context) error {
|
||||
// Get SteamCMD path from environment variable
|
||||
func (s *SteamService) ensureSteamCMD(_ context.Context) error {
|
||||
// In Docker mode, SteamCMD is pre-installed in the manager container image.
|
||||
if env.IsDockerPlatform() {
|
||||
return nil
|
||||
}
|
||||
|
||||
steamCMDPath := env.GetSteamCMDPath()
|
||||
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
|
||||
logging.Info("Downloading SteamCMD...")
|
||||
if err := s.executor.Execute("-Command",
|
||||
"Invoke-WebRequest -Uri 'https://steamcdn-a.akamaihd.net/client/installer/steamcmd.zip' -OutFile 'steamcmd.zip'"); err != nil {
|
||||
steamCMDZip := filepath.Join(steamCMDDir, "steamcmd.zip")
|
||||
if err := s.downloadVerifier.VerifyAndDownload(
|
||||
"https://steamcdn-a.akamaihd.net/client/installer/steamcmd.zip",
|
||||
steamCMDZip,
|
||||
""); err != nil {
|
||||
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) 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
|
||||
}
|
||||
|
||||
// Convert to absolute path and ensure proper Windows path format
|
||||
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)
|
||||
}
|
||||
}
|
||||
|
||||
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",
|
||||
)
|
||||
|
||||
// Run SteamCMD
|
||||
logging.Info("Installing ACC server to %s...", absPath)
|
||||
if err := s.executor.Execute(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...")
|
||||
if err := s.executor.Execute("-Command", "Start-Sleep -Seconds 5"); err != nil {
|
||||
logging.Warn("Failed to wait after Steam operations: %v", err)
|
||||
}
|
||||
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) error {
|
||||
return s.InstallServer(ctx, installPath) // 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 {
|
||||
|
||||
202
local/service/websocket.go
Executable file
202
local/service/websocket.go
Executable file
@@ -0,0 +1,202 @@
|
||||
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) BroadcastLogLine(serverID uuid.UUID, line string) {
|
||||
msg := model.WebSocketMessage{
|
||||
Type: model.MessageTypeLogLine,
|
||||
ServerID: &serverID,
|
||||
Timestamp: time.Now().Unix(),
|
||||
Data: model.LogLineMessage{Line: line},
|
||||
}
|
||||
ws.broadcastToServer(serverID, msg)
|
||||
}
|
||||
|
||||
func (ws *WebSocketService) BroadcastComplete(serverID uuid.UUID, success bool, message string) {
|
||||
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
|
||||
}
|
||||
68
local/service/windows_runtime.go
Normal file
68
local/service/windows_runtime.go
Normal 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
21
local/service/windows_service.go
Normal file → Executable 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)
|
||||
}
|
||||
|
||||
64
local/utl/audit/audit.go
Executable file
64
local/utl/audit/audit.go
Executable file
@@ -0,0 +1,64 @@
|
||||
package audit
|
||||
|
||||
import (
|
||||
"acc-server-manager/local/utl/logging"
|
||||
"context"
|
||||
"time"
|
||||
)
|
||||
|
||||
type AuditAction string
|
||||
|
||||
const (
|
||||
ActionLogin AuditAction = "LOGIN"
|
||||
ActionLogout AuditAction = "LOGOUT"
|
||||
ActionServerCreate AuditAction = "SERVER_CREATE"
|
||||
ActionServerUpdate AuditAction = "SERVER_UPDATE"
|
||||
ActionServerDelete AuditAction = "SERVER_DELETE"
|
||||
ActionServerStart AuditAction = "SERVER_START"
|
||||
ActionServerStop AuditAction = "SERVER_STOP"
|
||||
ActionUserCreate AuditAction = "USER_CREATE"
|
||||
ActionUserUpdate AuditAction = "USER_UPDATE"
|
||||
ActionUserDelete AuditAction = "USER_DELETE"
|
||||
ActionConfigUpdate AuditAction = "CONFIG_UPDATE"
|
||||
ActionSteamAuth AuditAction = "STEAM_AUTH"
|
||||
ActionPermissionGrant AuditAction = "PERMISSION_GRANT"
|
||||
ActionPermissionRevoke AuditAction = "PERMISSION_REVOKE"
|
||||
)
|
||||
|
||||
type AuditEntry struct {
|
||||
Timestamp time.Time `json:"timestamp"`
|
||||
UserID string `json:"user_id"`
|
||||
Username string `json:"username"`
|
||||
Action AuditAction `json:"action"`
|
||||
Resource string `json:"resource"`
|
||||
Details string `json:"details"`
|
||||
IPAddress string `json:"ip_address"`
|
||||
UserAgent string `json:"user_agent"`
|
||||
Success bool `json:"success"`
|
||||
}
|
||||
|
||||
func LogAction(ctx context.Context, userID, username string, action AuditAction, resource, details, ipAddress, userAgent string, success bool) {
|
||||
logging.InfoWithContext("AUDIT", "User %s (%s) performed %s on %s from %s - Success: %t - Details: %s",
|
||||
username, userID, action, resource, ipAddress, success, details)
|
||||
}
|
||||
|
||||
func LogAuthAction(ctx context.Context, username, ipAddress, userAgent string, success bool, details string) {
|
||||
action := ActionLogin
|
||||
if !success {
|
||||
details = "Failed: " + details
|
||||
}
|
||||
|
||||
LogAction(ctx, "", username, action, "authentication", details, ipAddress, userAgent, success)
|
||||
}
|
||||
|
||||
func LogServerAction(ctx context.Context, userID, username string, action AuditAction, serverID, ipAddress, userAgent string, success bool, details string) {
|
||||
LogAction(ctx, userID, username, action, "server:"+serverID, details, ipAddress, userAgent, success)
|
||||
}
|
||||
|
||||
func LogUserManagementAction(ctx context.Context, adminUserID, adminUsername string, action AuditAction, targetUserID, ipAddress, userAgent string, success bool, details string) {
|
||||
LogAction(ctx, adminUserID, adminUsername, action, "user:"+targetUserID, details, ipAddress, userAgent, success)
|
||||
}
|
||||
|
||||
func LogConfigAction(ctx context.Context, userID, username string, configType, ipAddress, userAgent string, success bool, details string) {
|
||||
LogAction(ctx, userID, username, ActionConfigUpdate, "config:"+configType, details, ipAddress, userAgent, success)
|
||||
}
|
||||
12
local/utl/cache/cache.go
vendored
Normal file → Executable file
12
local/utl/cache/cache.go
vendored
Normal file → Executable 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 {
|
||||
|
||||
248
local/utl/command/callback_executor.go
Executable file
248
local/utl/command/callback_executor.go
Executable 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
19
local/utl/command/callbacks.go
Executable 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
21
local/utl/command/executor.go
Normal file → Executable 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
Reference in New Issue
Block a user