feat: 인프라 개선 — 헬스체크, 로깅, 보안, CI 검증
Some checks failed
Server CI/CD / lint-and-build (push) Failing after 1m13s
Server CI/CD / deploy (push) Has been skipped

- /health + /ready 엔드포인트 추가 (DB/Redis 상태 확인)
- RequestID 미들웨어 + 구조화 JSON 로깅
- 체인 트랜잭션 per-user rate limit (20 req/min)
- DB 커넥션 풀 설정 (MaxOpen 25, MaxIdle 10, MaxLifetime 5m)
- Graceful Shutdown 시 Redis/MySQL 연결 정리
- Dockerfile HEALTHCHECK 추가
- CI에 go vet + 빌드 검증 단계 추가 (deploy 전 실행)
- 보스 레이드 클라이언트 입장 API (JWT 인증)
- Player 프로필 모듈 추가

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
2026-03-15 03:41:34 +09:00
parent d597ef2d46
commit cc8368dfba
19 changed files with 759 additions and 33 deletions

View File

@@ -77,19 +77,30 @@ func Load() {
}
// WarnInsecureDefaults logs warnings for security-sensitive settings left at defaults.
// In production mode (APP_ENV=production), insecure defaults cause a fatal exit.
func WarnInsecureDefaults() {
isProd := getEnv("APP_ENV", "") == "production"
insecure := false
if C.JWTSecret == "secret" {
log.Println("WARNING: JWT_SECRET is using the default value — set a strong secret for production")
insecure = true
}
if C.RefreshSecret == "refresh-secret" {
log.Println("WARNING: REFRESH_SECRET is using the default value — set a strong secret for production")
insecure = true
}
if C.AdminPassword == "admin1234" {
log.Println("WARNING: ADMIN_PASSWORD is using the default value — change it for production")
insecure = true
}
if C.WalletEncryptionKey == "" {
log.Println("WARNING: WALLET_ENCRYPTION_KEY is empty — blockchain wallet features will fail")
}
if isProd && insecure {
log.Fatal("FATAL: insecure default secrets detected in production — set JWT_SECRET, REFRESH_SECRET, and ADMIN_PASSWORD")
}
}
func getEnv(key, fallback string) string {

View File

@@ -2,6 +2,7 @@ package database
import (
"fmt"
"time"
"a301_server/pkg/config"
"gorm.io/driver/mysql"
@@ -19,6 +20,15 @@ func ConnectMySQL() error {
if err != nil {
return err
}
sqlDB, err := db.DB()
if err != nil {
return fmt.Errorf("sql.DB 획득 실패: %w", err)
}
sqlDB.SetMaxOpenConns(25)
sqlDB.SetMaxIdleConns(10)
sqlDB.SetConnMaxLifetime(5 * time.Minute)
DB = db
return nil
}

View File

@@ -76,7 +76,7 @@ func ServerAuth(c *fiber.Ctx) error {
key := c.Get("X-API-Key")
expected := config.C.InternalAPIKey
if key == "" || expected == "" || subtle.ConstantTimeCompare([]byte(key), []byte(expected)) != 1 {
log.Printf("ServerAuth 실패: IP=%s, Path=%s, KeyPresent=%v", c.IP(), c.Path(), key != "")
log.Printf("ServerAuth 실패: IP=%s, Path=%s", c.IP(), c.Path())
return c.Status(fiber.StatusUnauthorized).JSON(fiber.Map{"error": "유효하지 않은 API 키입니다"})
}
return c.Next()

View File

@@ -26,6 +26,9 @@ func Idempotency(c *fiber.Ctx) error {
if key == "" {
return c.Next()
}
if len(key) > 256 {
return c.Status(fiber.StatusBadRequest).JSON(fiber.Map{"error": "Idempotency-Key가 너무 깁니다"})
}
// userID가 있으면 키에 포함하여 사용자 간 캐시 충돌 방지
redisKey := "idempotency:"

View File

@@ -0,0 +1,17 @@
package middleware
import (
"github.com/gofiber/fiber/v2"
"github.com/google/uuid"
)
// RequestID generates a unique request ID for each request and stores it in Locals and response header.
func RequestID(c *fiber.Ctx) error {
id := c.Get("X-Request-ID")
if id == "" {
id = uuid.NewString()
}
c.Locals("requestID", id)
c.Set("X-Request-ID", id)
return c.Next()
}

View File

@@ -9,5 +9,6 @@ func SecurityHeaders(c *fiber.Ctx) error {
c.Set("X-XSS-Protection", "0")
c.Set("Referrer-Policy", "strict-origin-when-cross-origin")
c.Set("Content-Security-Policy", "default-src 'none'; frame-ancestors 'none'")
c.Set("Strict-Transport-Security", "max-age=31536000; includeSubDomains")
return c.Next()
}