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

This commit is contained in:
Fran Jurmanović
2025-08-16 16:43:54 +02:00
parent 1683d5c2f1
commit aab5d2ad61
32 changed files with 2191 additions and 98 deletions

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,179 @@
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)
// Monitor stdout and stderr for 2FA prompts
go e.monitorOutput(ctx, stdout, stderr, serverID, outputDone)
// Wait for either the command to finish or output monitoring to complete
cmdErr := cmd.Wait()
outputErr := <-outputDone
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 close(done)
// Create scanners for both outputs
stdoutScanner := bufio.NewScanner(stdout)
stderrScanner := bufio.NewScanner(stderr)
outputChan := make(chan string)
// Read from stdout
go func() {
for stdoutScanner.Scan() {
line := stdoutScanner.Text()
if e.LogOutput {
logging.Info("STDOUT: %s", line)
}
outputChan <- line
}
}()
// Read from stderr
go func() {
for stderrScanner.Scan() {
line := stderrScanner.Text()
if e.LogOutput {
logging.Info("STDERR: %s", line)
}
outputChan <- line
}
}()
// Monitor for 2FA prompts
for {
select {
case <-ctx.Done():
done <- ctx.Err()
return
case line, ok := <-outputChan:
if !ok {
done <- nil
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.10.3"
Version = "0.10.5"
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,57 +2,73 @@ 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"`
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
}
type OpenJWTHandler struct {
*JWTHandler
}
// NewJWTHandler creates a new JWTHandler instance with the provided secret key.
func NewOpenJWTHandler(jwtSecret string) *OpenJWTHandler {
jwtHandler := NewJWTHandler(jwtSecret)
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(user *model.User) (string, error) {
expirationTime := time.Now().Add(24 * time.Hour)
claims := &Claims{
UserID: user.ID.String(),
@@ -62,10 +78,10 @@ func GenerateToken(user *model.User) (string, error) {
}
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(),
@@ -75,15 +91,15 @@ func GenerateTokenWithExpiry(user *model.User, expiry time.Time) (string, error)
}
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 +107,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,95 @@
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(`[<>:"|?*]`),
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"))