22 Commits

Author SHA1 Message Date
Fran Jurmanović
f660511b63 change steamCMD executor
All checks were successful
Release and Deploy / build (push) Successful in 2m1s
Release and Deploy / deploy (push) Successful in 23s
2025-08-17 16:42:10 +02:00
Fran Jurmanović
044af60699 steam-crypt app and fix the interactive executor
All checks were successful
Release and Deploy / build (push) Successful in 2m2s
Release and Deploy / deploy (push) Successful in 22s
2025-08-17 16:26:28 +02:00
Fran Jurmanović
384036bcdd remove blocker pattern
All checks were successful
Release and Deploy / build (push) Successful in 2m5s
Release and Deploy / deploy (push) Successful in 26s
2025-08-17 15:53:55 +02:00
Fran Jurmanović
ef300d233b fix wrong userID from context
All checks were successful
Release and Deploy / build (push) Successful in 3m26s
Release and Deploy / deploy (push) Successful in 24s
2025-08-17 13:12:36 +02:00
Fran Jurmanović
edad65d6a9 generate open token using normal token
All checks were successful
Release and Deploy / build (push) Successful in 3m1s
Release and Deploy / deploy (push) Successful in 23s
2025-08-17 12:46:37 +02:00
Fran Jurmanović
486c972bba open token authentication
All checks were successful
Release and Deploy / build (push) Successful in 3m51s
Release and Deploy / deploy (push) Successful in 28s
2025-08-17 12:15:39 +02:00
Fran Jurmanović
aab5d2ad61 steam 2fa for polling and security
All checks were successful
Release and Deploy / build (push) Successful in 6m8s
Release and Deploy / deploy (push) Successful in 27s
2025-08-16 16:43:54 +02:00
Fran Jurmanović
1683d5c2f1 generate swagger docs
All checks were successful
Release and Deploy / build (push) Successful in 1m50s
Release and Deploy / deploy (push) Successful in 21s
2025-08-05 17:09:05 +02:00
Fran Jurmanović
87d4af0bec update host and schemes swagger
All checks were successful
Release and Deploy / build (push) Successful in 1m51s
Release and Deploy / deploy (push) Successful in 21s
2025-08-05 16:51:33 +02:00
Fran Jurmanović
35449a090d update swagger host
All checks were successful
Release and Deploy / build (push) Successful in 1m49s
Release and Deploy / deploy (push) Successful in 21s
2025-08-05 14:52:02 +02:00
Fran Jurmanović
5324a41e05 update swagger base path
All checks were successful
Release and Deploy / build (push) Successful in 1m48s
Release and Deploy / deploy (push) Successful in 22s
2025-08-05 14:39:44 +02:00
Fran Jurmanović
ac61ba5223 update swagger docs
All checks were successful
Release and Deploy / build (push) Successful in 2m16s
Release and Deploy / deploy (push) Successful in 25s
2025-08-05 14:32:37 +02:00
Fran Jurmanović
56c51e5d02 fix env not passing
All checks were successful
Release and Deploy / build (push) Successful in 1m57s
Release and Deploy / deploy (push) Successful in 22s
2025-07-31 18:24:12 +02:00
Fran Jurmanović
1c57da9aba update release.yml health
Some checks failed
Release and Deploy / build (push) Successful in 1m58s
Release and Deploy / deploy (push) Failing after 1m19s
2025-07-31 18:17:41 +02:00
Fran Jurmanović
b2d88f1aa3 update version 2025-07-31 18:14:43 +02:00
Fran Jurmanović
45d9681203 fix system controller not initialized
Some checks failed
Release and Deploy / build (push) Successful in 1m58s
Release and Deploy / deploy (push) Failing after 1m19s
2025-07-31 18:13:43 +02:00
Fran Jurmanović
e259f2235a update deploy path cd
Some checks failed
Release and Deploy / build (push) Successful in 1m57s
Release and Deploy / deploy (push) Failing after 26s
2025-07-31 18:01:09 +02:00
Fran Jurmanović
e575f641c5 update migration script
Some checks failed
Release and Deploy / build (push) Successful in 1m59s
Release and Deploy / deploy (push) Failing after 15s
2025-07-31 17:56:11 +02:00
Fran Jurmanović
bb75a8c325 update release.yml
Some checks failed
Release and Deploy / build (push) Successful in 5m4s
Release and Deploy / deploy (push) Has been cancelled
2025-07-31 17:46:45 +02:00
Fran Jurmanović
99b1a2d1e9 CGO_ENABLED
Some checks failed
Release and Deploy / build (push) Failing after 1m34s
Release and Deploy / deploy (push) Has been skipped
2025-07-31 17:33:17 +02:00
Fran Jurmanović
a34b08072e update runner
Some checks failed
Release and Deploy / build (push) Failing after 6m38s
Release and Deploy / deploy (push) Has been skipped
2025-07-30 02:03:28 +02:00
Fran Jurmanović
8057420f09 Merge branch 'feature/add-delete-servers' 2025-07-30 01:59:31 +02:00
41 changed files with 4190 additions and 974 deletions

View File

@@ -8,13 +8,13 @@ env:
GO_VERSION: "1.21"
BINARY_NAME: "acc-server-manager"
MIGRATE_BINARY: "acc-server-migration"
API_BINARY: "api"
DEPLOY_PATH: 'C:\acc-server-manager'
SERVICE_NAME: "ACC Server Manager"
HEALTH_URL: "http://localhost:4000/v1/system/health"
jobs:
build:
runs-on: windows-latest
runs-on: windows
steps:
- name: Checkout code
uses: actions/checkout@v3
@@ -27,35 +27,17 @@ jobs:
go-version: ${{ env.GO_VERSION }}
- name: Run tests
env:
CGO_ENABLED: 1
run: go test -v ./...
- name: Build binaries
env:
CGO_ENABLED: 1
run: |
mkdir -p build
go build -v -o ./build/${{ env.BINARY_NAME }}.exe ./cmd/server
go build -v -o ./build/${{ env.MIGRATE_BINARY }}.exe ./cmd/migration
go build -v -o ./build/${{ env.API_BINARY }}.exe ./cmd/api
- name: Create release
id: create_release
uses: actions/create-release@v1
env:
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
with:
tag_name: ${{ github.ref }}
release_name: Release ${{ github.ref }}
draft: false
prerelease: false
- name: Upload release assets
uses: actions/upload-release-asset@v1
env:
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
with:
upload_url: ${{ steps.create_release.outputs.upload_url }}
asset_path: ./build/
asset_name: acc-server-manager-${{ github.ref_name }}.zip
asset_content_type: application/zip
go build -v -o ./build/${{ env.MIGRATE_BINARY }}.exe ./cmd/migrate
go build -v -o ./build/${{ env.BINARY_NAME }}.exe ./cmd/api
- name: Upload build artifacts
uses: actions/upload-artifact@v3
@@ -66,7 +48,7 @@ jobs:
deploy:
needs: build
runs-on: windows-latest
runs-on: windows
environment: production
steps:
- name: Download build artifacts
@@ -113,7 +95,8 @@ jobs:
# Run database migrations
Write-Host "Running database migrations..."
try {
& "${{ env.DEPLOY_PATH }}\${{ env.MIGRATE_BINARY }}.exe"
& cd "${{ env.DEPLOY_PATH }}"
& ".\${{ env.MIGRATE_BINARY }}.exe"
} catch {
Write-Warning "Migration failed: $_"
throw "Migration failed"
@@ -153,7 +136,7 @@ jobs:
while ($attempt -le $maxAttempts -and -not $success) {
try {
$response = Invoke-WebRequest -Uri "http://localhost:8080/health" -TimeoutSec 5
$response = Invoke-WebRequest -Uri "${{ env.HEALTH_URL }}" -TimeoutSec 5
if ($response.StatusCode -eq 200) {
Write-Host "Health check passed!"
$success = $true
@@ -168,31 +151,3 @@ jobs:
if (-not $success) {
throw "Health check failed after $maxAttempts attempts"
}
- name: Notify on success
if: success()
uses: actions/github-script@v6
with:
script: |
const { repo, owner } = context.repo;
const release = context.ref.replace('refs/tags/', '');
await github.rest.issues.createComment({
owner,
repo,
issue_number: context.issue.number,
body: `✅ Successfully deployed ${release} to production!`
});
- name: Notify on failure
if: failure()
uses: actions/github-script@v6
with:
script: |
const { repo, owner } = context.repo;
const release = context.ref.replace('refs/tags/', '');
await github.rest.issues.createComment({
owner,
repo,
issue_number: context.issue.number,
body: `❌ Failed to deploy ${release} to production. Check the workflow logs for details.`
});

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

View 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

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

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

View 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

View File

@@ -31,6 +31,7 @@ func Init(di *dig.Container, app *fiber.App) {
StateHistory: serverIdGroup.Group("/state-history"),
Membership: groups.Group("/membership"),
System: groups.Group("/system"),
Steam2FA: groups.Group("/steam2fa"),
}
accessKeyMiddleware := middleware.NewAccessKeyMiddleware()

View File

@@ -58,7 +58,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 +106,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 +130,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 {

View File

@@ -20,7 +20,12 @@ func InitializeControllers(c *dig.Container) {
logging.Panic("unable to initialize auth middleware")
}
err := c.Invoke(NewServiceControlController)
err := c.Invoke(NewSystemController)
if err != nil {
logging.Panic("unable to initialize system controller")
}
err = c.Invoke(NewServiceControlController)
if err != nil {
logging.Panic("unable to initialize service control controller")
}
@@ -49,4 +54,9 @@ func InitializeControllers(c *dig.Container) {
if err != nil {
logging.Panic("unable to initialize membership controller")
}
err = c.Invoke(NewSteam2FAController)
if err != nil {
logging.Panic("unable to initialize steam 2fa controller")
}
}

View 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 {

View 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 {

View File

@@ -50,7 +50,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 +79,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 +105,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,7 +133,7 @@ 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 {
@@ -161,7 +161,7 @@ func (ac *ServerController) CreateServer(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} [put]
// @Router /server/{id} [put]
func (ac *ServerController) UpdateServer(c *fiber.Ctx) error {
serverIDStr := c.Params("id")
serverID, err := uuid.Parse(serverIDStr)
@@ -181,7 +181,21 @@ func (ac *ServerController) UpdateServer(c *fiber.Ctx) error {
return c.JSON(server)
}
// DeleteServer deletes a 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)"
// @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 /server/{id} [delete]
func (ac *ServerController) DeleteServer(c *fiber.Ctx) error {
serverIDStr := c.Params("id")
serverID, err := uuid.Parse(serverIDStr)

View File

@@ -51,7 +51,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 +78,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 +105,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 +131,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)

View 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 {

View File

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

View File

@@ -2,6 +2,7 @@ package controller
import (
"acc-server-manager/local/utl/common"
"acc-server-manager/local/utl/configs"
"github.com/gofiber/fiber/v2"
)
@@ -30,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.SendStatus(fiber.StatusOK)
return c.SendString(configs.Version)
}

View File

@@ -30,14 +30,18 @@ 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
@@ -46,13 +50,30 @@ func NewAuthMiddleware(ms *service.MembershipService, cache *cache.InMemoryCache
return auth
}
// Authenticate is a middleware for JWT authentication with enhanced security.
func (m *AuthMiddleware) AuthenticateOpen(ctx *fiber.Ctx) error {
return m.AuthenticateWithHandler(m.openJWTHandler.JWTHandler, true, ctx)
}
// Authenticate is a middleware for JWT authentication with enhanced security.
func (m *AuthMiddleware) Authenticate(ctx *fiber.Ctx) error {
return m.AuthenticateWithHandler(m.jwtHandler, false, ctx)
}
func (m *AuthMiddleware) AuthenticateWithHandler(jwtHandler *jwt.JWTHandler, isOpenToken bool, ctx *fiber.Ctx) error {
// Log authentication attempt
ip := ctx.IP()
userAgent := ctx.Get("User-Agent")
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{
@@ -77,7 +98,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,6 +106,13 @@ func (m *AuthMiddleware) Authenticate(ctx *fiber.Ctx) error {
})
}
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",
})
}
// Additional security: validate user ID format
if claims.UserID == "" || len(claims.UserID) < 10 {
logging.Error("Authentication failed: invalid user ID in token from IP %s", ip)

View File

@@ -1,6 +1,7 @@
package security
import (
"acc-server-manager/local/utl/graceful"
"context"
"fmt"
"strings"
@@ -22,22 +23,28 @@ func NewRateLimiter() *RateLimiter {
requests: make(map[string][]time.Time),
}
// Clean up old entries every 5 minutes
go rl.cleanup()
// Use graceful shutdown for cleanup goroutine
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 {
for {
select {
case <-ctx.Done():
return
case <-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 {
@@ -53,6 +60,7 @@ func (rl *RateLimiter) cleanup() {
rl.mutex.Unlock()
}
}
}
// SecurityMiddleware provides comprehensive security middleware
type SecurityMiddleware struct {
@@ -189,13 +197,13 @@ 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,25 +212,46 @@ 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
@@ -349,3 +378,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",
})
}
}
}

168
local/model/steam_2fa.go Normal file
View 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)
}
}
}
}

View File

@@ -1,6 +1,12 @@
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"
)
@@ -17,4 +23,29 @@ func InitializeRepositories(c *dig.Container) {
c.Provide(NewLookupRepository)
c.Provide(NewSteamCredentialsRepository)
c.Provide(NewMembershipRepository)
// Provide the Steam2FAManager as a singleton
if err := c.Provide(func() *model.Steam2FAManager {
manager := model.NewSteam2FAManager()
// Use graceful shutdown manager for cleanup goroutine
shutdownManager := graceful.GetManager()
shutdownManager.RunGoroutine(func(ctx context.Context) {
ticker := time.NewTicker(15 * time.Minute)
defer ticker.Stop()
for {
select {
case <-ctx.Done():
return
case <-ticker.C:
manager.CleanupOldRequests(30 * time.Minute)
}
}
})
return manager
}); err != nil {
logging.Panic("unable to initialize steam 2fa manager")
}
}

View File

@@ -22,13 +22,17 @@ type CacheInvalidator interface {
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
jwtHandler: jwtHandler,
openJwtHandler: openJwtHandler,
}
}
@@ -38,18 +42,32 @@ func (s *MembershipService) SetCacheInvalidator(invalidator CacheInvalidator) {
}
// 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
}
// Login authenticates a user and returns a JWT.
func (s *MembershipService) Login(ctx context.Context, username, password string) (string, error) {
user, err := s.HandleLogin(ctx, username, password)
if err != nil {
return "", err
}
return s.jwtHandler.GenerateToken(user.ID.String())
}
func (s *MembershipService) GenerateOpenToken(ctx context.Context, userId string) (string, error) {
return s.openJwtHandler.GenerateToken(userId)
}
// CreateUser creates a new user.

View File

@@ -337,7 +337,7 @@ func (s *ServerService) CreateServer(ctx *fiber.Ctx, server *model.Server) error
}
// Install server using SteamCMD
if err := s.steamService.InstallServer(ctx.UserContext(), server.GetServerPath()); err != nil {
if err := s.steamService.InstallServer(ctx.UserContext(), server.GetServerPath(), &server.ID); err != nil {
return fmt.Errorf("failed to install server: %v", err)
}
@@ -450,7 +450,7 @@ func (s *ServerService) UpdateServer(ctx *fiber.Ctx, server *model.Server) error
// Update server files if path changed
if existingServer.Path != server.Path {
if err := s.steamService.InstallServer(ctx.UserContext(), server.Path); err != nil {
if err := s.steamService.InstallServer(ctx.UserContext(), server.Path, &server.ID); err != nil {
return fmt.Errorf("failed to install server to new location: %v", err)
}
// Clean up old installation

View File

@@ -18,12 +18,12 @@ func InitializeServices(c *dig.Container) {
logging.Debug("Registering services")
// Provide services
c.Provide(NewSteamService)
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)

View File

@@ -6,10 +6,14 @@ 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"
"time"
"github.com/google/uuid"
)
const (
@@ -18,16 +22,32 @@ const (
type SteamService struct {
executor *command.CommandExecutor
interactiveExecutor *command.InteractiveCommandExecutor
repository *repository.SteamCredentialsRepository
tfaManager *model.Steam2FAManager
pathValidator *security.PathValidator
downloadVerifier *security.DownloadVerifier
}
func NewSteamService(repository *repository.SteamCredentialsRepository) *SteamService {
return &SteamService{
executor: &command.CommandExecutor{
func NewSteamService(repository *repository.SteamCredentialsRepository, tfaManager *model.Steam2FAManager) *SteamService {
baseExecutor := &command.CommandExecutor{
ExePath: "powershell",
LogOutput: true,
},
}
// Create a separate executor for SteamCMD that doesn't use PowerShell
steamCMDExecutor := &command.CommandExecutor{
ExePath: env.GetSteamCMDPath(),
LogOutput: true,
}
return &SteamService{
executor: baseExecutor,
interactiveExecutor: command.NewInteractiveCommandExecutor(steamCMDExecutor, tfaManager),
repository: repository,
tfaManager: tfaManager,
pathValidator: security.NewPathValidator(),
downloadVerifier: security.NewDownloadVerifier(),
}
}
@@ -42,7 +62,7 @@ func (s *SteamService) SaveCredentials(ctx context.Context, creds *model.SteamCr
return s.repository.Save(ctx, creds)
}
func (s *SteamService) ensureSteamCMD(ctx context.Context) error {
func (s *SteamService) ensureSteamCMD(_ context.Context) error {
// Get SteamCMD path from environment variable
steamCMDPath := env.GetSteamCMDPath()
steamCMDDir := filepath.Dir(steamCMDPath)
@@ -57,10 +77,13 @@ func (s *SteamService) ensureSteamCMD(ctx context.Context) error {
return fmt.Errorf("failed to create SteamCMD directory: %v", err)
}
// Download and install SteamCMD
// Download and install SteamCMD securely
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)
}
@@ -76,11 +99,16 @@ func (s *SteamService) ensureSteamCMD(ctx context.Context) error {
return nil
}
func (s *SteamService) InstallServer(ctx context.Context, installPath string) error {
func (s *SteamService) InstallServer(ctx context.Context, installPath string, serverID *uuid.UUID) error {
if err := s.ensureSteamCMD(ctx); err != nil {
return err
}
// Validate installation path for security
if err := s.pathValidator.ValidateInstallPath(installPath); err != nil {
return fmt.Errorf("invalid installation path: %v", err)
}
// Convert to absolute path and ensure proper Windows path format
absPath, err := filepath.Abs(installPath)
if err != nil {
@@ -99,14 +127,8 @@ func (s *SteamService) InstallServer(ctx context.Context, installPath string) er
return fmt.Errorf("failed to get Steam credentials: %v", err)
}
// Get SteamCMD path from environment variable
steamCMDPath := env.GetSteamCMDPath()
// Build SteamCMD command
// Build SteamCMD command (no PowerShell args needed since we call SteamCMD directly)
args := []string{
"-nologo",
"-noprofile",
steamCMDPath,
"+force_install_dir", absPath,
"+login",
}
@@ -126,17 +148,23 @@ func (s *SteamService) InstallServer(ctx context.Context, installPath string) er
"+quit",
)
// Run SteamCMD
// Use interactive executor to handle potential 2FA prompts with timeout
logging.Info("Installing ACC server to %s...", absPath)
if err := s.executor.Execute(args...); err != nil {
// Create a context with timeout to prevent hanging indefinitely
timeoutCtx, cancel := context.WithTimeout(ctx, 10*time.Minute)
defer cancel()
if err := s.interactiveExecutor.ExecuteInteractive(timeoutCtx, serverID, args...); err != nil {
if timeoutCtx.Err() == context.DeadlineExceeded {
return fmt.Errorf("SteamCMD operation timed out after 10 minutes")
}
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)
}
time.Sleep(5 * time.Second)
// Verify installation
exePath := filepath.Join(absPath, "server", "accServer.exe")
@@ -148,8 +176,8 @@ func (s *SteamService) InstallServer(ctx context.Context, installPath string) er
return nil
}
func (s *SteamService) UpdateServer(ctx context.Context, installPath string) error {
return s.InstallServer(ctx, installPath) // Same process as install
func (s *SteamService) UpdateServer(ctx context.Context, installPath string, serverID *uuid.UUID) error {
return s.InstallServer(ctx, installPath, serverID) // Same process as install
}
func (s *SteamService) UninstallServer(installPath string) error {

64
local/utl/audit/audit.go Normal file
View 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)
}

View File

@@ -0,0 +1,238 @@
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"
)
// InteractiveCommandExecutor extends CommandExecutor to handle interactive commands
type InteractiveCommandExecutor struct {
*CommandExecutor
tfaManager *model.Steam2FAManager
}
func NewInteractiveCommandExecutor(baseExecutor *CommandExecutor, tfaManager *model.Steam2FAManager) *InteractiveCommandExecutor {
return &InteractiveCommandExecutor{
CommandExecutor: baseExecutor,
tfaManager: tfaManager,
}
}
// ExecuteInteractive runs a command that may require 2FA input
func (e *InteractiveCommandExecutor) ExecuteInteractive(ctx context.Context, serverID *uuid.UUID, args ...string) error {
cmd := exec.CommandContext(ctx, e.ExePath, args...)
if e.WorkDir != "" {
cmd.Dir = e.WorkDir
}
// Create pipes for stdin, stdout, and stderr
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: %s %s", e.ExePath, strings.Join(args, " "))
if err := cmd.Start(); err != nil {
return fmt.Errorf("failed to start command: %v", err)
}
// Create channels for output monitoring
outputDone := make(chan error, 1)
cmdDone := make(chan error, 1)
// Monitor stdout and stderr for 2FA prompts
go e.monitorOutput(ctx, stdout, stderr, serverID, outputDone)
// Wait for the command to finish in a separate goroutine
go func() {
cmdDone <- cmd.Wait()
}()
// Wait for both command and output monitoring to complete
var cmdErr, outputErr error
completedCount := 0
for completedCount < 2 {
select {
case cmdErr = <-cmdDone:
completedCount++
logging.Info("Command execution completed")
case outputErr = <-outputDone:
completedCount++
logging.Info("Output monitoring completed")
case <-ctx.Done():
return ctx.Err()
}
}
if outputErr != nil {
logging.Warn("Output monitoring error: %v", outputErr)
}
return cmdErr
}
func (e *InteractiveCommandExecutor) monitorOutput(ctx context.Context, stdout, stderr io.Reader, serverID *uuid.UUID, done chan error) {
defer func() {
select {
case done <- nil:
default:
}
}()
// Create scanners for both outputs
stdoutScanner := bufio.NewScanner(stdout)
stderrScanner := bufio.NewScanner(stderr)
outputChan := make(chan string, 100) // Buffered channel to prevent blocking
readersDone := make(chan struct{}, 2)
// Read from stdout
go func() {
defer func() { readersDone <- struct{}{} }()
for stdoutScanner.Scan() {
line := stdoutScanner.Text()
if e.LogOutput {
logging.Info("STDOUT: %s", line)
}
select {
case outputChan <- line:
case <-ctx.Done():
return
}
}
if err := stdoutScanner.Err(); err != nil {
logging.Warn("Stdout scanner error: %v", err)
}
}()
// Read from stderr
go func() {
defer func() { readersDone <- struct{}{} }()
for stderrScanner.Scan() {
line := stderrScanner.Text()
if e.LogOutput {
logging.Info("STDERR: %s", line)
}
select {
case outputChan <- line:
case <-ctx.Done():
return
}
}
if err := stderrScanner.Err(); err != nil {
logging.Warn("Stderr scanner error: %v", err)
}
}()
// Monitor for completion and 2FA prompts
readersFinished := 0
for {
select {
case <-ctx.Done():
done <- ctx.Err()
return
case <-readersDone:
readersFinished++
if readersFinished == 2 {
// Both readers are done, close output channel and finish monitoring
close(outputChan)
// Drain any remaining output
for line := range outputChan {
if e.is2FAPrompt(line) {
if err := e.handle2FAPrompt(ctx, line, serverID); err != nil {
logging.Error("Failed to handle 2FA prompt: %v", err)
done <- err
return
}
}
}
return
}
case line, ok := <-outputChan:
if !ok {
// Channel closed, we're done
return
}
// Check if this line indicates a 2FA prompt
if e.is2FAPrompt(line) {
if err := e.handle2FAPrompt(ctx, line, serverID); err != nil {
logging.Error("Failed to handle 2FA prompt: %v", err)
done <- err
return
}
}
}
}
}
func (e *InteractiveCommandExecutor) is2FAPrompt(line string) bool {
// Common SteamCMD 2FA prompts
twoFAKeywords := []string{
"please enter your steam guard code",
"steam guard",
"two-factor",
"authentication code",
"please check your steam mobile app",
"confirm in application",
}
lowerLine := strings.ToLower(line)
for _, keyword := range twoFAKeywords {
if strings.Contains(lowerLine, keyword) {
return true
}
}
return false
}
func (e *InteractiveCommandExecutor) handle2FAPrompt(_ context.Context, promptLine string, serverID *uuid.UUID) error {
logging.Info("2FA prompt detected: %s", promptLine)
// Create a 2FA request
request := e.tfaManager.CreateRequest(promptLine, serverID)
logging.Info("Created 2FA request with ID: %s", request.ID)
// Wait for user to complete the 2FA process
// Use a reasonable timeout (e.g., 5 minutes)
timeout := 5 * time.Minute
success, err := e.tfaManager.WaitForCompletion(request.ID, timeout)
if err != nil {
logging.Error("2FA completion failed: %v", err)
return err
}
if !success {
logging.Error("2FA was not completed successfully")
return fmt.Errorf("2FA authentication failed")
}
logging.Info("2FA completed successfully")
return nil
}

View File

@@ -25,6 +25,7 @@ type RouteGroups struct {
StateHistory fiber.Router
Membership fiber.Router
System fiber.Router
Steam2FA fiber.Router
}
func CheckError(err error) {

View File

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

View File

@@ -0,0 +1,67 @@
package errors
import (
"acc-server-manager/local/utl/logging"
"fmt"
"os"
)
type SafeError struct {
Message string
Code int
Fatal bool
}
func (e *SafeError) Error() string {
return e.Message
}
func NewSafeError(message string, code int) *SafeError {
return &SafeError{
Message: message,
Code: code,
Fatal: false,
}
}
func NewFatalError(message string, code int) *SafeError {
return &SafeError{
Message: message,
Code: code,
Fatal: true,
}
}
func HandleError(err error, context string) {
if err == nil {
return
}
if safeErr, ok := err.(*SafeError); ok {
if safeErr.Fatal {
logging.Error("Fatal error in %s: %s", context, safeErr.Message)
if os.Getenv("ENVIRONMENT") == "production" {
logging.Error("Application shutting down due to fatal error")
os.Exit(safeErr.Code)
} else {
logging.Warn("Fatal error occurred but not exiting in non-production environment")
}
} else {
logging.Error("Error in %s: %s", context, safeErr.Message)
}
} else {
logging.Error("Unexpected error in %s: %v", context, err)
}
}
func SafeFatal(message string, args ...interface{}) {
formattedMessage := fmt.Sprintf(message, args...)
err := NewFatalError(formattedMessage, 1)
HandleError(err, "application")
}
func SafeLog(message string, args ...interface{}) {
formattedMessage := fmt.Sprintf(message, args...)
err := NewSafeError(formattedMessage, 0)
HandleError(err, "application")
}

View File

@@ -0,0 +1,91 @@
package graceful
import (
"context"
"os"
"os/signal"
"sync"
"syscall"
"time"
)
type ShutdownManager struct {
ctx context.Context
cancel context.CancelFunc
wg sync.WaitGroup
handlers []func() error
mutex sync.Mutex
}
var globalManager *ShutdownManager
var once sync.Once
func GetManager() *ShutdownManager {
once.Do(func() {
ctx, cancel := context.WithCancel(context.Background())
globalManager = &ShutdownManager{
ctx: ctx,
cancel: cancel,
handlers: make([]func() error, 0),
}
go globalManager.watchSignals()
})
return globalManager
}
func (sm *ShutdownManager) watchSignals() {
sigChan := make(chan os.Signal, 1)
signal.Notify(sigChan, syscall.SIGINT, syscall.SIGTERM)
<-sigChan
sm.Shutdown(30 * time.Second)
}
func (sm *ShutdownManager) AddHandler(handler func() error) {
sm.mutex.Lock()
defer sm.mutex.Unlock()
sm.handlers = append(sm.handlers, handler)
}
func (sm *ShutdownManager) Context() context.Context {
return sm.ctx
}
func (sm *ShutdownManager) AddGoroutine() {
sm.wg.Add(1)
}
func (sm *ShutdownManager) GoroutineDone() {
sm.wg.Done()
}
func (sm *ShutdownManager) RunGoroutine(fn func(ctx context.Context)) {
sm.wg.Add(1)
go func() {
defer sm.wg.Done()
fn(sm.ctx)
}()
}
func (sm *ShutdownManager) Shutdown(timeout time.Duration) {
sm.cancel()
done := make(chan struct{})
go func() {
sm.wg.Wait()
sm.mutex.Lock()
for _, handler := range sm.handlers {
handler()
}
sm.mutex.Unlock()
close(done)
}()
select {
case <-done:
case <-time.After(timeout):
}
}

View File

@@ -2,88 +2,109 @@ package jwt
import (
"acc-server-manager/local/model"
"acc-server-manager/local/utl/errors"
"crypto/rand"
"encoding/base64"
"errors"
"log"
"os"
goerrors "errors"
"time"
"github.com/golang-jwt/jwt/v4"
)
// SecretKey holds the JWT signing key loaded from environment
var SecretKey []byte
// Claims represents the JWT claims.
type Claims struct {
UserID string `json:"user_id"`
IsOpenToken bool `json:"is_open_token"`
jwt.RegisteredClaims
}
// init initializes the JWT secret key from environment variable
func Init() {
jwtSecret := os.Getenv("JWT_SECRET")
if jwtSecret == "" {
log.Fatal("JWT_SECRET environment variable is required and cannot be empty")
type JWTHandler struct {
SecretKey []byte
IsOpenToken bool
}
type OpenJWTHandler struct {
*JWTHandler
}
// NewJWTHandler creates a new JWTHandler instance with the provided secret key.
func NewOpenJWTHandler(jwtSecret string) *OpenJWTHandler {
jwtHandler := NewJWTHandler(jwtSecret)
jwtHandler.IsOpenToken = true
return &OpenJWTHandler{
JWTHandler: jwtHandler,
}
}
// NewJWTHandler creates a new JWTHandler instance with the provided secret key.
func NewJWTHandler(jwtSecret string) *JWTHandler {
if jwtSecret == "" {
errors.SafeFatal("JWT_SECRET environment variable is required and cannot be empty")
}
var secretKey []byte
// Decode base64 secret if it looks like base64, otherwise use as-is
if decoded, err := base64.StdEncoding.DecodeString(jwtSecret); err == nil && len(decoded) >= 32 {
SecretKey = decoded
secretKey = decoded
} else {
SecretKey = []byte(jwtSecret)
secretKey = []byte(jwtSecret)
}
// Ensure minimum key length for security
if len(SecretKey) < 32 {
log.Fatal("JWT_SECRET must be at least 32 bytes long for security")
if len(secretKey) < 32 {
errors.SafeFatal("JWT_SECRET must be at least 32 bytes long for security")
}
return &JWTHandler{
SecretKey: secretKey,
}
}
// GenerateSecretKey generates a cryptographically secure random key for JWT signing
// This is a utility function for generating new secrets, not used in normal operation
func GenerateSecretKey() string {
func (jh *JWTHandler) GenerateSecretKey() string {
key := make([]byte, 64) // 512 bits
if _, err := rand.Read(key); err != nil {
log.Fatal("Failed to generate random key: ", err)
errors.SafeFatal("Failed to generate random key: %v", err)
}
return base64.StdEncoding.EncodeToString(key)
}
// GenerateToken generates a new JWT for a given user.
func GenerateToken(user *model.User) (string, error) {
func (jh *JWTHandler) GenerateToken(userId string) (string, error) {
expirationTime := time.Now().Add(24 * time.Hour)
claims := &Claims{
UserID: user.ID.String(),
UserID: userId,
RegisteredClaims: jwt.RegisteredClaims{
ExpiresAt: jwt.NewNumericDate(expirationTime),
},
IsOpenToken: jh.IsOpenToken,
}
token := jwt.NewWithClaims(jwt.SigningMethodHS256, claims)
return token.SignedString(SecretKey)
return token.SignedString(jh.SecretKey)
}
func GenerateTokenWithExpiry(user *model.User, expiry time.Time) (string, error) {
func (jh *JWTHandler) GenerateTokenWithExpiry(user *model.User, expiry time.Time) (string, error) {
expirationTime := expiry
claims := &Claims{
UserID: user.ID.String(),
RegisteredClaims: jwt.RegisteredClaims{
ExpiresAt: jwt.NewNumericDate(expirationTime),
},
IsOpenToken: jh.IsOpenToken,
}
token := jwt.NewWithClaims(jwt.SigningMethodHS256, claims)
return token.SignedString(SecretKey)
return token.SignedString(jh.SecretKey)
}
// ValidateToken validates a JWT and returns the claims if the token is valid.
func ValidateToken(tokenString string) (*Claims, error) {
func (jh *JWTHandler) ValidateToken(tokenString string) (*Claims, error) {
claims := &Claims{}
token, err := jwt.ParseWithClaims(tokenString, claims, func(token *jwt.Token) (interface{}, error) {
return SecretKey, nil
return jh.SecretKey, nil
})
if err != nil {
@@ -91,7 +112,7 @@ func ValidateToken(tokenString string) (*Claims, error) {
}
if !token.Valid {
return nil, errors.New("invalid token")
return nil, goerrors.New("invalid token")
}
return claims, nil

View File

@@ -0,0 +1,76 @@
package security
import (
"crypto/sha256"
"fmt"
"io"
"net/http"
"os"
"time"
)
type DownloadVerifier struct {
client *http.Client
}
func NewDownloadVerifier() *DownloadVerifier {
return &DownloadVerifier{
client: &http.Client{
Timeout: 30 * time.Second,
Transport: &http.Transport{
DisableKeepAlives: true,
},
},
}
}
func (dv *DownloadVerifier) VerifyAndDownload(url, outputPath, expectedSHA256 string) error {
if url == "" {
return fmt.Errorf("URL cannot be empty")
}
if outputPath == "" {
return fmt.Errorf("output path cannot be empty")
}
req, err := http.NewRequest("GET", url, nil)
if err != nil {
return fmt.Errorf("failed to create request: %v", err)
}
req.Header.Set("User-Agent", "ACC-Server-Manager/1.0")
resp, err := dv.client.Do(req)
if err != nil {
return fmt.Errorf("failed to download: %v", err)
}
defer resp.Body.Close()
if resp.StatusCode != http.StatusOK {
return fmt.Errorf("download failed with status: %d", resp.StatusCode)
}
file, err := os.Create(outputPath)
if err != nil {
return fmt.Errorf("failed to create output file: %v", err)
}
defer file.Close()
hash := sha256.New()
writer := io.MultiWriter(file, hash)
_, err = io.Copy(writer, resp.Body)
if err != nil {
os.Remove(outputPath)
return fmt.Errorf("failed to write file: %v", err)
}
if expectedSHA256 != "" {
actualHash := fmt.Sprintf("%x", hash.Sum(nil))
if actualHash != expectedSHA256 {
os.Remove(outputPath)
return fmt.Errorf("file hash mismatch: expected %s, got %s", expectedSHA256, actualHash)
}
}
return nil
}

View File

@@ -0,0 +1,94 @@
package security
import (
"fmt"
"os"
"path/filepath"
"regexp"
"strings"
)
type PathValidator struct {
allowedBasePaths []string
blockedPatterns []*regexp.Regexp
}
func NewPathValidator() *PathValidator {
blockedPatterns := []*regexp.Regexp{
regexp.MustCompile(`\.\.`),
regexp.MustCompile(`^(CON|PRN|AUX|NUL|COM[1-9]|LPT[1-9])$`),
regexp.MustCompile(`\x00`),
regexp.MustCompile(`^\\\\`),
regexp.MustCompile(`^[a-zA-Z]:\\Windows`),
regexp.MustCompile(`^[a-zA-Z]:\\Program Files`),
}
return &PathValidator{
allowedBasePaths: []string{
`C:\ACC-Servers`,
`D:\ACC-Servers`,
`E:\ACC-Servers`,
`C:\SteamCMD`,
`D:\SteamCMD`,
`E:\SteamCMD`,
},
blockedPatterns: blockedPatterns,
}
}
func (pv *PathValidator) ValidateInstallPath(path string) error {
if path == "" {
return fmt.Errorf("path cannot be empty")
}
cleanPath := filepath.Clean(path)
absPath, err := filepath.Abs(cleanPath)
if err != nil {
return fmt.Errorf("invalid path: %v", err)
}
for _, pattern := range pv.blockedPatterns {
if pattern.MatchString(absPath) || pattern.MatchString(strings.ToUpper(filepath.Base(absPath))) {
return fmt.Errorf("path contains forbidden patterns")
}
}
allowed := false
for _, basePath := range pv.allowedBasePaths {
if strings.HasPrefix(strings.ToLower(absPath), strings.ToLower(basePath)) {
allowed = true
break
}
}
if !allowed {
return fmt.Errorf("path must be within allowed directories: %v", pv.allowedBasePaths)
}
if len(absPath) > 260 {
return fmt.Errorf("path too long (max 260 characters)")
}
parentDir := filepath.Dir(absPath)
if parentInfo, err := os.Stat(parentDir); err == nil {
if !parentInfo.IsDir() {
return fmt.Errorf("parent path is not a directory")
}
}
return nil
}
func (pv *PathValidator) AddAllowedBasePath(path string) error {
absPath, err := filepath.Abs(path)
if err != nil {
return fmt.Errorf("invalid base path: %v", err)
}
pv.allowedBasePaths = append(pv.allowedBasePaths, absPath)
return nil
}
func (pv *PathValidator) GetAllowedBasePaths() []string {
return append([]string(nil), pv.allowedBasePaths...)
}

View File

@@ -30,6 +30,7 @@ func Start(di *dig.Container) *fiber.App {
app.Use(securityMW.SecurityHeaders())
app.Use(securityMW.LogSecurityEvents())
app.Use(securityMW.TimeoutMiddleware(30 * time.Second))
app.Use(securityMW.RequestContextTimeout(60 * time.Second))
app.Use(securityMW.RequestSizeLimit(10 * 1024 * 1024)) // 10MB
app.Use(securityMW.ValidateUserAgent())
app.Use(securityMW.ValidateContentType("application/json", "application/x-www-form-urlencoded", "multipart/form-data"))

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

View File

@@ -1,4 +1,4 @@
basePath: /api/v1
basePath: /v1
definitions:
error_handler.ErrorResponse:
properties:
@@ -92,6 +92,35 @@ definitions:
- StatusRestarting
- StatusStarting
- StatusRunning
model.Steam2FARequest:
properties:
completedAt:
type: string
errorMsg:
type: string
id:
type: string
message:
type: string
requestTime:
type: string
serverId:
type: string
status:
$ref: '#/definitions/model.Steam2FAStatus'
type: object
model.Steam2FAStatus:
enum:
- idle
- pending
- complete
- error
type: string
x-enum-varnames:
- Steam2FAStatusIdle
- Steam2FAStatusPending
- Steam2FAStatusComplete
- Steam2FAStatusError
model.User:
properties:
id:
@@ -103,11 +132,20 @@ definitions:
username:
type: string
type: object
host: localhost:3000
service.UpdateUserRequest:
properties:
password:
type: string
roleId:
type: string
username:
type: string
type: object
host: acc-api.jurmanovic.com
info:
contact:
name: ACC Server Manager Support
url: https://github.com/yourusername/acc-server-manager
url: https://github.com/FJurmanovic/acc-server-manager
description: API for managing Assetto Corsa Competizione dedicated servers
license:
name: MIT
@@ -115,6 +153,62 @@ info:
title: ACC Server Manager API
version: "1.0"
paths:
/api/server:
get:
consumes:
- application/json
description: Get a list of all ACC servers with filtering options
parameters:
- in: query
name: name
type: string
- in: query
name: page
type: integer
- in: query
name: pageSize
type: integer
- in: query
name: serverID
type: string
- in: query
name: serviceName
type: string
- in: query
name: sortBy
type: string
- in: query
name: sortDesc
type: boolean
- in: query
name: status
type: string
produces:
- application/json
responses:
"200":
description: List of servers
schema:
items:
$ref: '#/definitions/model.ServerAPI'
type: array
"400":
description: Invalid filter parameters
schema:
$ref: '#/definitions/error_handler.ErrorResponse'
"401":
description: Unauthorized
schema:
$ref: '#/definitions/error_handler.ErrorResponse'
"500":
description: Internal server error
schema:
$ref: '#/definitions/error_handler.ErrorResponse'
security:
- BearerAuth: []
summary: List all servers (API format)
tags:
- Server
/auth/login:
post:
consumes:
@@ -157,6 +251,228 @@ paths:
summary: User login
tags:
- Authentication
/auth/me:
get:
consumes:
- application/json
description: Get details of the currently authenticated user
produces:
- application/json
responses:
"200":
description: Current user details
schema:
$ref: '#/definitions/model.User'
"401":
description: Unauthorized
schema:
$ref: '#/definitions/error_handler.ErrorResponse'
"404":
description: User not found
schema:
$ref: '#/definitions/error_handler.ErrorResponse'
security:
- BearerAuth: []
summary: Get current user details
tags:
- Authentication
/auth/open-token:
post:
consumes:
- application/json
description: Generate an open token for a user
produces:
- application/json
responses:
"200":
description: JWT token
schema:
properties:
token:
type: string
type: object
"400":
description: Invalid request body
schema:
$ref: '#/definitions/error_handler.ErrorResponse'
"401":
description: Invalid credentials
schema:
$ref: '#/definitions/error_handler.ErrorResponse'
"500":
description: Internal server error
schema:
$ref: '#/definitions/error_handler.ErrorResponse'
summary: Generate an open token
tags:
- Authentication
/lookup/car-models:
get:
consumes:
- application/json
description: Get a list of all available ACC car models with their identifiers
produces:
- application/json
responses:
"200":
description: List of car models
schema:
items:
properties:
class:
type: string
id:
type: string
name:
type: string
type: object
type: array
"401":
description: Unauthorized
schema:
$ref: '#/definitions/error_handler.ErrorResponse'
"500":
description: Internal server error
schema:
$ref: '#/definitions/error_handler.ErrorResponse'
security:
- BearerAuth: []
summary: Get available car models
tags:
- Lookups
/lookup/cup-categories:
get:
consumes:
- application/json
description: Get a list of all available racing cup categories
produces:
- application/json
responses:
"200":
description: List of cup categories
schema:
items:
properties:
id:
type: number
name:
type: string
type: object
type: array
"401":
description: Unauthorized
schema:
$ref: '#/definitions/error_handler.ErrorResponse'
"500":
description: Internal server error
schema:
$ref: '#/definitions/error_handler.ErrorResponse'
security:
- BearerAuth: []
summary: Get cup categories
tags:
- Lookups
/lookup/driver-categories:
get:
consumes:
- application/json
description: Get a list of all driver categories (Bronze, Silver, Gold, Platinum)
produces:
- application/json
responses:
"200":
description: List of driver categories
schema:
items:
properties:
description:
type: string
id:
type: number
name:
type: string
type: object
type: array
"401":
description: Unauthorized
schema:
$ref: '#/definitions/error_handler.ErrorResponse'
"500":
description: Internal server error
schema:
$ref: '#/definitions/error_handler.ErrorResponse'
security:
- BearerAuth: []
summary: Get driver categories
tags:
- Lookups
/lookup/session-types:
get:
consumes:
- application/json
description: Get a list of all available session types (Practice, Qualifying,
Race)
produces:
- application/json
responses:
"200":
description: List of session types
schema:
items:
properties:
code:
type: string
id:
type: string
name:
type: string
type: object
type: array
"401":
description: Unauthorized
schema:
$ref: '#/definitions/error_handler.ErrorResponse'
"500":
description: Internal server error
schema:
$ref: '#/definitions/error_handler.ErrorResponse'
security:
- BearerAuth: []
summary: Get session types
tags:
- Lookups
/lookup/tracks:
get:
consumes:
- application/json
description: Get a list of all available ACC tracks with their identifiers
produces:
- application/json
responses:
"200":
description: List of tracks
schema:
items:
properties:
id:
type: string
name:
type: string
type: object
type: array
"401":
description: Unauthorized
schema:
$ref: '#/definitions/error_handler.ErrorResponse'
"500":
description: Internal server error
schema:
$ref: '#/definitions/error_handler.ErrorResponse'
security:
- BearerAuth: []
summary: Get available tracks
tags:
- Lookups
/membership:
get:
consumes:
@@ -238,117 +554,141 @@ paths:
summary: Create a new user
tags:
- User Management
/server:
get:
/membership/{id}:
delete:
consumes:
- application/json
description: Get a list of all ACC servers with filtering options
description: Delete a specific user by ID
parameters:
- in: query
name: name
type: string
- in: query
name: page
type: integer
- in: query
name: pageSize
type: integer
- in: query
name: serverID
type: string
- in: query
name: serviceName
type: string
- in: query
name: sortBy
type: string
- in: query
name: sortDesc
type: boolean
- in: query
name: status
- description: User ID (UUID format)
in: path
name: id
required: true
type: string
produces:
- application/json
responses:
"200":
description: List of servers
schema:
items:
$ref: '#/definitions/model.ServerAPI'
type: array
"204":
description: User successfully deleted
"400":
description: Invalid filter parameters
description: Invalid user ID format
schema:
$ref: '#/definitions/error_handler.ErrorResponse'
"401":
description: Unauthorized
schema:
$ref: '#/definitions/error_handler.ErrorResponse'
"500":
description: Internal server error
"403":
description: Insufficient permissions
schema:
$ref: '#/definitions/error_handler.ErrorResponse'
"404":
description: User not found
schema:
$ref: '#/definitions/error_handler.ErrorResponse'
security:
- BearerAuth: []
summary: List all servers (API format)
summary: Delete user
tags:
- Server
/v1/lookup/car-models:
- User Management
get:
consumes:
- application/json
description: Get a list of all available ACC car models with their identifiers
description: Get detailed information about a specific user
parameters:
- description: User ID (UUID format)
in: path
name: id
required: true
type: string
produces:
- application/json
responses:
"200":
description: List of car models
description: User details
schema:
$ref: '#/definitions/model.User'
"400":
description: Invalid user ID format
schema:
$ref: '#/definitions/error_handler.ErrorResponse'
"401":
description: Unauthorized
schema:
$ref: '#/definitions/error_handler.ErrorResponse'
"404":
description: User not found
schema:
$ref: '#/definitions/error_handler.ErrorResponse'
security:
- BearerAuth: []
summary: Get user by ID
tags:
- User Management
put:
consumes:
- application/json
description: Update user details by ID
parameters:
- description: User ID (UUID format)
in: path
name: id
required: true
type: string
- description: Updated user details
in: body
name: user
required: true
schema:
$ref: '#/definitions/service.UpdateUserRequest'
produces:
- application/json
responses:
"200":
description: Updated user details
schema:
$ref: '#/definitions/model.User'
"400":
description: Invalid request body or ID format
schema:
$ref: '#/definitions/error_handler.ErrorResponse'
"401":
description: Unauthorized
schema:
$ref: '#/definitions/error_handler.ErrorResponse'
"403":
description: Insufficient permissions
schema:
$ref: '#/definitions/error_handler.ErrorResponse'
"404":
description: User not found
schema:
$ref: '#/definitions/error_handler.ErrorResponse'
security:
- BearerAuth: []
summary: Update user
tags:
- User Management
/membership/roles:
get:
consumes:
- application/json
description: Get a list of all available user roles
produces:
- application/json
responses:
"200":
description: List of roles
schema:
items:
properties:
class:
type: string
id:
type: string
name:
type: string
type: object
$ref: '#/definitions/model.Role'
type: array
"401":
description: Unauthorized
schema:
$ref: '#/definitions/error_handler.ErrorResponse'
"500":
description: Internal server error
schema:
$ref: '#/definitions/error_handler.ErrorResponse'
security:
- BearerAuth: []
summary: Get available car models
tags:
- Lookups
/v1/lookup/cup-categories:
get:
consumes:
- application/json
description: Get a list of all available racing cup categories
produces:
- application/json
responses:
"200":
description: List of cup categories
schema:
items:
properties:
id:
type: number
name:
type: string
type: object
type: array
"401":
description: Unauthorized
"403":
description: Insufficient permissions
schema:
$ref: '#/definitions/error_handler.ErrorResponse'
"500":
@@ -357,111 +697,10 @@ paths:
$ref: '#/definitions/error_handler.ErrorResponse'
security:
- BearerAuth: []
summary: Get cup categories
summary: Get all roles
tags:
- Lookups
/v1/lookup/driver-categories:
get:
consumes:
- application/json
description: Get a list of all driver categories (Bronze, Silver, Gold, Platinum)
produces:
- application/json
responses:
"200":
description: List of driver categories
schema:
items:
properties:
description:
type: string
id:
type: number
name:
type: string
type: object
type: array
"401":
description: Unauthorized
schema:
$ref: '#/definitions/error_handler.ErrorResponse'
"500":
description: Internal server error
schema:
$ref: '#/definitions/error_handler.ErrorResponse'
security:
- BearerAuth: []
summary: Get driver categories
tags:
- Lookups
/v1/lookup/session-types:
get:
consumes:
- application/json
description: Get a list of all available session types (Practice, Qualifying,
Race)
produces:
- application/json
responses:
"200":
description: List of session types
schema:
items:
properties:
code:
type: string
id:
type: string
name:
type: string
type: object
type: array
"401":
description: Unauthorized
schema:
$ref: '#/definitions/error_handler.ErrorResponse'
"500":
description: Internal server error
schema:
$ref: '#/definitions/error_handler.ErrorResponse'
security:
- BearerAuth: []
summary: Get session types
tags:
- Lookups
/v1/lookup/tracks:
get:
consumes:
- application/json
description: Get a list of all available ACC tracks with their identifiers
produces:
- application/json
responses:
"200":
description: List of tracks
schema:
items:
properties:
id:
type: string
name:
type: string
type: object
type: array
"401":
description: Unauthorized
schema:
$ref: '#/definitions/error_handler.ErrorResponse'
"500":
description: Internal server error
schema:
$ref: '#/definitions/error_handler.ErrorResponse'
security:
- BearerAuth: []
summary: Get available tracks
tags:
- Lookups
/v1/server:
- User Management
/server:
get:
consumes:
- application/json
@@ -556,7 +795,49 @@ paths:
summary: Create a new ACC server
tags:
- Server
/v1/server/{id}:
/server/{id}:
delete:
consumes:
- application/json
description: Delete an existing ACC server
parameters:
- description: Server ID (UUID format)
in: path
name: id
required: true
type: string
produces:
- application/json
responses:
"200":
description: Deleted server details
schema:
type: object
"400":
description: Invalid server data or ID
schema:
$ref: '#/definitions/error_handler.ErrorResponse'
"401":
description: Unauthorized
schema:
$ref: '#/definitions/error_handler.ErrorResponse'
"403":
description: Insufficient permissions
schema:
$ref: '#/definitions/error_handler.ErrorResponse'
"404":
description: Server not found
schema:
$ref: '#/definitions/error_handler.ErrorResponse'
"500":
description: Internal server error
schema:
$ref: '#/definitions/error_handler.ErrorResponse'
security:
- BearerAuth: []
summary: Delete an ACC server
tags:
- Server
get:
consumes:
- application/json
@@ -643,7 +924,7 @@ paths:
summary: Update an ACC server
tags:
- Server
/v1/server/{id}/config:
/server/{id}/config:
get:
consumes:
- application/json
@@ -688,7 +969,7 @@ paths:
summary: List available configuration files
tags:
- Server Configuration
/v1/server/{id}/config/{file}:
/server/{id}/config/{file}:
get:
consumes:
- application/json
@@ -792,20 +1073,7 @@ paths:
summary: Update server configuration file
tags:
- Server Configuration
/v1/service-control:
get:
description: Return service control status
responses:
"200":
description: OK
schema:
items:
type: string
type: array
summary: Return service control status
tags:
- service-control
/v1/service-control/{service}:
/server/{id}/service/{service}:
get:
consumes:
- application/json
@@ -849,7 +1117,7 @@ paths:
summary: Get service status
tags:
- Service Control
/v1/service-control/restart:
/server/{id}/service/restart:
post:
consumes:
- application/json
@@ -899,7 +1167,7 @@ paths:
summary: Restart a Windows service
tags:
- Service Control
/v1/service-control/start:
/server/{id}/service/start:
post:
consumes:
- application/json
@@ -953,7 +1221,7 @@ paths:
summary: Start a Windows service
tags:
- Service Control
/v1/service-control/stop:
/server/{id}/service/stop:
post:
consumes:
- application/json
@@ -1007,7 +1275,7 @@ paths:
summary: Stop a Windows service
tags:
- Service Control
/v1/state-history:
/state-history:
get:
description: Return StateHistorys
responses:
@@ -1020,7 +1288,7 @@ paths:
summary: Return StateHistorys
tags:
- StateHistory
/v1/state-history/statistics:
/state-history/statistics:
get:
description: Return StateHistorys
responses:
@@ -1033,8 +1301,136 @@ paths:
summary: Return StateHistorys
tags:
- StateHistory
/steam2fa/{id}:
get:
consumes:
- application/json
description: Get a specific Steam 2FA authentication request by ID
parameters:
- description: 2FA Request ID
in: path
name: id
required: true
type: string
produces:
- application/json
responses:
"200":
description: OK
schema:
$ref: '#/definitions/model.Steam2FARequest'
"404":
description: Not Found
schema:
$ref: '#/definitions/error_handler.ErrorResponse'
"500":
description: Internal Server Error
schema:
$ref: '#/definitions/error_handler.ErrorResponse'
summary: Get 2FA request
tags:
- Steam 2FA
/steam2fa/{id}/cancel:
post:
consumes:
- application/json
description: Cancel a Steam 2FA authentication request
parameters:
- description: 2FA Request ID
in: path
name: id
required: true
type: string
produces:
- application/json
responses:
"200":
description: OK
schema:
$ref: '#/definitions/model.Steam2FARequest'
"400":
description: Bad Request
schema:
$ref: '#/definitions/error_handler.ErrorResponse'
"404":
description: Not Found
schema:
$ref: '#/definitions/error_handler.ErrorResponse'
"500":
description: Internal Server Error
schema:
$ref: '#/definitions/error_handler.ErrorResponse'
summary: Cancel 2FA request
tags:
- Steam 2FA
/steam2fa/{id}/complete:
post:
consumes:
- application/json
description: Mark a Steam 2FA authentication request as completed
parameters:
- description: 2FA Request ID
in: path
name: id
required: true
type: string
produces:
- application/json
responses:
"200":
description: OK
schema:
$ref: '#/definitions/model.Steam2FARequest'
"400":
description: Bad Request
schema:
$ref: '#/definitions/error_handler.ErrorResponse'
"404":
description: Not Found
schema:
$ref: '#/definitions/error_handler.ErrorResponse'
"500":
description: Internal Server Error
schema:
$ref: '#/definitions/error_handler.ErrorResponse'
summary: Complete 2FA request
tags:
- Steam 2FA
/steam2fa/pending:
get:
consumes:
- application/json
description: Get all pending Steam 2FA authentication requests
produces:
- application/json
responses:
"200":
description: OK
schema:
items:
$ref: '#/definitions/model.Steam2FARequest'
type: array
"500":
description: Internal Server Error
schema:
$ref: '#/definitions/error_handler.ErrorResponse'
summary: Get pending 2FA requests
tags:
- Steam 2FA
/system/health:
get:
description: Return service control status
responses:
"200":
description: OK
schema:
items:
type: string
type: array
summary: Return service control status
tags:
- system
schemes:
- http
- https
securityDefinitions:
BearerAuth:

View File

@@ -4,6 +4,7 @@ import (
"acc-server-manager/local/model"
"acc-server-manager/local/utl/jwt"
"fmt"
"os"
"time"
"github.com/google/uuid"
@@ -18,8 +19,16 @@ func GenerateTestToken() (string, error) {
RoleID: uuid.New(),
}
// Use the environment JWT_SECRET for consistency with middleware
testSecret := os.Getenv("JWT_SECRET")
if testSecret == "" {
// Fallback to a test secret if env var is not set
testSecret = "test-secret-that-is-at-least-32-bytes-long-for-security"
}
jwtHandler := jwt.NewJWTHandler(testSecret)
// Generate JWT token
token, err := jwt.GenerateToken(user)
token, err := jwtHandler.GenerateToken(user.ID.String())
if err != nil {
return "", fmt.Errorf("failed to generate test token: %w", err)
}
@@ -39,6 +48,14 @@ func MustGenerateTestToken() string {
// GenerateTestTokenWithExpiry creates a JWT token with a specific expiry time
func GenerateTestTokenWithExpiry(expiryTime time.Time) (string, error) {
// Use the environment JWT_SECRET for consistency with middleware
testSecret := os.Getenv("JWT_SECRET")
if testSecret == "" {
// Fallback to a test secret if env var is not set
testSecret = "test-secret-that-is-at-least-32-bytes-long-for-security"
}
jwtHandler := jwt.NewJWTHandler(testSecret)
// Create test user
user := &model.User{
ID: uuid.New(),
@@ -47,7 +64,7 @@ func GenerateTestTokenWithExpiry(expiryTime time.Time) (string, error) {
}
// Generate JWT token with custom expiry
token, err := jwt.GenerateTokenWithExpiry(user, expiryTime)
token, err := jwtHandler.GenerateTokenWithExpiry(user, expiryTime)
if err != nil {
return "", fmt.Errorf("failed to generate test token with expiry: %w", err)
}

View File

@@ -3,7 +3,6 @@ package tests
import (
"acc-server-manager/local/model"
"acc-server-manager/local/utl/configs"
"acc-server-manager/local/utl/jwt"
"bytes"
"context"
"errors"
@@ -52,7 +51,6 @@ func SetTestEnv() {
os.Setenv("TESTING_ENV", "true") // Used to bypass
configs.Init()
jwt.Init()
}
// NewTestHelper creates a new test helper with in-memory database

View File

@@ -4,7 +4,9 @@ import (
"acc-server-manager/local/middleware"
"acc-server-manager/local/service"
"acc-server-manager/local/utl/cache"
"acc-server-manager/local/utl/jwt"
"acc-server-manager/tests"
"os"
"github.com/gofiber/fiber/v2"
)
@@ -15,9 +17,18 @@ type MockMiddleware struct{}
// GetTestAuthMiddleware returns a mock auth middleware that can be used in place of the real one
// This works because we're adding real authentication tokens to requests
func GetTestAuthMiddleware(ms *service.MembershipService, cache *cache.InMemoryCache) *middleware.AuthMiddleware {
// Use environment JWT secrets for consistency with token generation
jwtSecret := os.Getenv("JWT_SECRET")
if jwtSecret == "" {
jwtSecret = "test-secret-that-is-at-least-32-bytes-long-for-security"
}
jwtHandler := jwt.NewJWTHandler(jwtSecret)
openJWTHandler := jwt.NewOpenJWTHandler(jwtSecret) // Use same secret for test consistency
// Cast our mock to the real type for testing
// This is a type-unsafe cast but works for testing because we're using real JWT tokens
return middleware.NewAuthMiddleware(ms, cache)
return middleware.NewAuthMiddleware(ms, cache, jwtHandler, openJWTHandler)
}
// AddAuthToRequest adds a valid authentication token to a test request

View File

@@ -7,6 +7,7 @@ import (
"acc-server-manager/local/service"
"acc-server-manager/local/utl/cache"
"acc-server-manager/local/utl/common"
"acc-server-manager/local/utl/jwt"
"acc-server-manager/tests"
"acc-server-manager/tests/testdata"
"encoding/json"
@@ -14,6 +15,7 @@ import (
"io"
"net/http"
"net/http/httptest"
"os"
"testing"
"github.com/gofiber/fiber/v2"
@@ -32,7 +34,13 @@ func TestStateHistoryController_GetAll_Success(t *testing.T) {
stateHistoryService := service.NewStateHistoryService(repo)
membershipRepo := repository.NewMembershipRepository(helper.DB)
membershipService := service.NewMembershipService(membershipRepo)
jwtSecret := os.Getenv("JWT_SECRET")
if jwtSecret == "" {
jwtSecret = "test-secret-that-is-at-least-32-bytes-long-for-security"
}
jwtHandler := jwt.NewJWTHandler(jwtSecret)
openJWTHandler := jwt.NewOpenJWTHandler(jwtSecret)
membershipService := service.NewMembershipService(membershipRepo, jwtHandler, openJWTHandler)
inMemCache := cache.NewInMemoryCache()
@@ -86,7 +94,13 @@ func TestStateHistoryController_GetAll_WithSessionFilter(t *testing.T) {
stateHistoryService := service.NewStateHistoryService(repo)
membershipRepo := repository.NewMembershipRepository(helper.DB)
membershipService := service.NewMembershipService(membershipRepo)
jwtSecret := os.Getenv("JWT_SECRET")
if jwtSecret == "" {
jwtSecret = "test-secret-that-is-at-least-32-bytes-long-for-security"
}
jwtHandler := jwt.NewJWTHandler(jwtSecret)
openJWTHandler := jwt.NewOpenJWTHandler(jwtSecret)
membershipService := service.NewMembershipService(membershipRepo, jwtHandler, openJWTHandler)
inMemCache := cache.NewInMemoryCache()
@@ -145,7 +159,13 @@ func TestStateHistoryController_GetAll_EmptyResult(t *testing.T) {
stateHistoryService := service.NewStateHistoryService(repo)
membershipRepo := repository.NewMembershipRepository(helper.DB)
membershipService := service.NewMembershipService(membershipRepo)
jwtSecret := os.Getenv("JWT_SECRET")
if jwtSecret == "" {
jwtSecret = "test-secret-that-is-at-least-32-bytes-long-for-security"
}
jwtHandler := jwt.NewJWTHandler(jwtSecret)
openJWTHandler := jwt.NewOpenJWTHandler(jwtSecret)
membershipService := service.NewMembershipService(membershipRepo, jwtHandler, openJWTHandler)
inMemCache := cache.NewInMemoryCache()
@@ -185,7 +205,13 @@ func TestStateHistoryController_GetStatistics_Success(t *testing.T) {
stateHistoryService := service.NewStateHistoryService(repo)
membershipRepo := repository.NewMembershipRepository(helper.DB)
membershipService := service.NewMembershipService(membershipRepo)
jwtSecret := os.Getenv("JWT_SECRET")
if jwtSecret == "" {
jwtSecret = "test-secret-that-is-at-least-32-bytes-long-for-security"
}
jwtHandler := jwt.NewJWTHandler(jwtSecret)
openJWTHandler := jwt.NewOpenJWTHandler(jwtSecret)
membershipService := service.NewMembershipService(membershipRepo, jwtHandler, openJWTHandler)
inMemCache := cache.NewInMemoryCache()
@@ -262,7 +288,13 @@ func TestStateHistoryController_GetStatistics_NoData(t *testing.T) {
stateHistoryService := service.NewStateHistoryService(repo)
membershipRepo := repository.NewMembershipRepository(helper.DB)
membershipService := service.NewMembershipService(membershipRepo)
jwtSecret := os.Getenv("JWT_SECRET")
if jwtSecret == "" {
jwtSecret = "test-secret-that-is-at-least-32-bytes-long-for-security"
}
jwtHandler := jwt.NewJWTHandler(jwtSecret)
openJWTHandler := jwt.NewOpenJWTHandler(jwtSecret)
membershipService := service.NewMembershipService(membershipRepo, jwtHandler, openJWTHandler)
inMemCache := cache.NewInMemoryCache()
@@ -321,7 +353,13 @@ func TestStateHistoryController_GetStatistics_InvalidQueryParams(t *testing.T) {
stateHistoryService := service.NewStateHistoryService(repo)
membershipRepo := repository.NewMembershipRepository(helper.DB)
membershipService := service.NewMembershipService(membershipRepo)
jwtSecret := os.Getenv("JWT_SECRET")
if jwtSecret == "" {
jwtSecret = "test-secret-that-is-at-least-32-bytes-long-for-security"
}
jwtHandler := jwt.NewJWTHandler(jwtSecret)
openJWTHandler := jwt.NewOpenJWTHandler(jwtSecret)
membershipService := service.NewMembershipService(membershipRepo, jwtHandler, openJWTHandler)
inMemCache := cache.NewInMemoryCache()
@@ -365,7 +403,13 @@ func TestStateHistoryController_HTTPMethods(t *testing.T) {
stateHistoryService := service.NewStateHistoryService(repo)
membershipRepo := repository.NewMembershipRepository(helper.DB)
membershipService := service.NewMembershipService(membershipRepo)
jwtSecret := os.Getenv("JWT_SECRET")
if jwtSecret == "" {
jwtSecret = "test-secret-that-is-at-least-32-bytes-long-for-security"
}
jwtHandler := jwt.NewJWTHandler(jwtSecret)
openJWTHandler := jwt.NewOpenJWTHandler(jwtSecret)
membershipService := service.NewMembershipService(membershipRepo, jwtHandler, openJWTHandler)
inMemCache := cache.NewInMemoryCache()
@@ -419,7 +463,13 @@ func TestStateHistoryController_ContentType(t *testing.T) {
stateHistoryService := service.NewStateHistoryService(repo)
membershipRepo := repository.NewMembershipRepository(helper.DB)
membershipService := service.NewMembershipService(membershipRepo)
jwtSecret := os.Getenv("JWT_SECRET")
if jwtSecret == "" {
jwtSecret = "test-secret-that-is-at-least-32-bytes-long-for-security"
}
jwtHandler := jwt.NewJWTHandler(jwtSecret)
openJWTHandler := jwt.NewOpenJWTHandler(jwtSecret)
membershipService := service.NewMembershipService(membershipRepo, jwtHandler, openJWTHandler)
inMemCache := cache.NewInMemoryCache()
@@ -481,7 +531,13 @@ func TestStateHistoryController_ResponseStructure(t *testing.T) {
stateHistoryService := service.NewStateHistoryService(repo)
membershipRepo := repository.NewMembershipRepository(helper.DB)
membershipService := service.NewMembershipService(membershipRepo)
jwtSecret := os.Getenv("JWT_SECRET")
if jwtSecret == "" {
jwtSecret = "test-secret-that-is-at-least-32-bytes-long-for-security"
}
jwtHandler := jwt.NewJWTHandler(jwtSecret)
openJWTHandler := jwt.NewOpenJWTHandler(jwtSecret)
membershipService := service.NewMembershipService(membershipRepo, jwtHandler, openJWTHandler)
inMemCache := cache.NewInMemoryCache()

View File

@@ -5,6 +5,7 @@ import (
"acc-server-manager/local/utl/jwt"
"acc-server-manager/local/utl/password"
"acc-server-manager/tests"
"os"
"testing"
"github.com/google/uuid"
@@ -15,6 +16,8 @@ func TestJWT_GenerateAndValidateToken(t *testing.T) {
helper := tests.NewTestHelper(t)
defer helper.Cleanup()
jwtHandler := jwt.NewJWTHandler(os.Getenv("JWT_SECRET"))
// Create test user
user := &model.User{
ID: uuid.New(),
@@ -23,7 +26,7 @@ func TestJWT_GenerateAndValidateToken(t *testing.T) {
}
// Test JWT generation
token, err := jwt.GenerateToken(user)
token, err := jwtHandler.GenerateToken(user.ID.String())
tests.AssertNoError(t, err)
tests.AssertNotNil(t, token)
@@ -33,7 +36,7 @@ func TestJWT_GenerateAndValidateToken(t *testing.T) {
}
// Test JWT validation
claims, err := jwt.ValidateToken(token)
claims, err := jwtHandler.ValidateToken(token)
tests.AssertNoError(t, err)
tests.AssertNotNil(t, claims)
tests.AssertEqual(t, user.ID.String(), claims.UserID)
@@ -43,9 +46,10 @@ func TestJWT_ValidateToken_InvalidToken(t *testing.T) {
// Setup
helper := tests.NewTestHelper(t)
defer helper.Cleanup()
jwtHandler := jwt.NewJWTHandler(os.Getenv("JWT_SECRET"))
// Test with invalid token
claims, err := jwt.ValidateToken("invalid-token")
claims, err := jwtHandler.ValidateToken("invalid-token")
if err == nil {
t.Fatal("Expected error for invalid token, got nil")
}
@@ -59,9 +63,10 @@ func TestJWT_ValidateToken_EmptyToken(t *testing.T) {
// Setup
helper := tests.NewTestHelper(t)
defer helper.Cleanup()
jwtHandler := jwt.NewJWTHandler(os.Getenv("JWT_SECRET"))
// Test with empty token
claims, err := jwt.ValidateToken("")
claims, err := jwtHandler.ValidateToken("")
if err == nil {
t.Fatal("Expected error for empty token, got nil")
}