fix: 보안 강화, 안정성 및 UX 개선

- 토큰 전달 방식 변경: 명령줄 인자(-token) → 환경변수(A301_TOKEN)로 프로세스 목록 노출 방지
- 고정 설치 경로: %LOCALAPPDATA%\A301\로 런처 복사 후 레지스트리 등록 (Downloads 정리 시 깨짐 방지)
- zip 추출 시 symlink 엔트리 스킵 (경로 탈출 방지)
- fetchServerInfo 3회 재시도 (exponential backoff)
- 다운로드 이어받기: Range 헤더 지원, 취소/오류 시 임시 파일 유지
- 416 응답 시 서버 파일 변경 감지하여 처음부터 재다운로드
- 단일 인스턴스 UX: 기존 창 FindWindow+SetForegroundWindow로 활성화
- uninstall 시 설치 디렉토리 정리

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
2026-03-06 11:10:11 +09:00
parent 9bb422f9b2
commit 48df55a82e
3 changed files with 413 additions and 20 deletions

134
main.go
View File

@@ -132,6 +132,8 @@ var (
getSystemMetricsProc = user32.NewProc("GetSystemMetrics")
getDpiForSystemProc = user32.NewProc("GetDpiForSystem")
setProcessDpiAwarenessContextProc = user32.NewProc("SetProcessDpiAwarenessContext")
findWindowWProc = user32.NewProc("FindWindowW")
setForegroundWindowProc = user32.NewProc("SetForegroundWindow")
createMutexWProc = kernel32.NewProc("CreateMutexW")
getModuleHandleWProc = kernel32.NewProc("GetModuleHandleW")
createFontIndirectWProc = gdi32.NewProc("CreateFontIndirectW")
@@ -424,41 +426,71 @@ func downloadWithProgress(downloadURL, destDir string) error {
}
func doDownload(downloadURL, destDir string) error {
resp, err := downloadClient.Get(downloadURL)
tmpPath := filepath.Join(os.TempDir(), "a301_game.zip")
// 이어받기: 기존 임시 파일 크기 확인
var resumeOffset int64
if fi, err := os.Stat(tmpPath); err == nil {
resumeOffset = fi.Size()
}
req, err := http.NewRequest("GET", downloadURL, nil)
if err != nil {
return fmt.Errorf("다운로드 요청 생성 실패: %w", err)
}
if resumeOffset > 0 {
req.Header.Set("Range", fmt.Sprintf("bytes=%d-", resumeOffset))
}
resp, err := downloadClient.Do(req)
if err != nil {
return fmt.Errorf("다운로드 연결 실패: %w", err)
}
defer resp.Body.Close()
if resp.StatusCode != http.StatusOK {
var downloaded int64
var total int64
var tmpFile *os.File
switch resp.StatusCode {
case http.StatusPartialContent:
// 서버가 Range 요청 수락 → 이어받기
downloaded = resumeOffset
total = resumeOffset + resp.ContentLength
tmpFile, err = os.OpenFile(tmpPath, os.O_WRONLY|os.O_APPEND, 0644)
case http.StatusOK:
// 서버가 Range 미지원이거나 파일이 변경됨 → 처음부터
total = resp.ContentLength
tmpFile, err = os.Create(tmpPath)
case http.StatusRequestedRangeNotSatisfiable:
// 임시 파일이 서버 파일보다 큼 (서버 파일 변경) → 처음부터
os.Remove(tmpPath)
return doDownload(downloadURL, destDir)
default:
return fmt.Errorf("다운로드 실패 (HTTP %d)", resp.StatusCode)
}
if err != nil {
return fmt.Errorf("임시 파일 열기 실패: %w", err)
}
total := resp.ContentLength
if total > maxDownloadSize {
tmpFile.Close()
os.Remove(tmpPath)
return fmt.Errorf("파일이 너무 큽니다 (%d bytes)", total)
}
tmpPath := filepath.Join(os.TempDir(), "a301_game.zip")
tmpFile, err := os.Create(tmpPath)
if err != nil {
return fmt.Errorf("임시 파일 생성 실패: %w", err)
}
var downloaded int64
buf := make([]byte, 32*1024)
for {
if downloadCancelled.Load() {
tmpFile.Close()
os.Remove(tmpPath)
// 취소 시 임시 파일 유지 (이어받기 가능)
return fmt.Errorf("다운로드가 취소되었습니다")
}
n, err := resp.Body.Read(buf)
if n > 0 {
if _, werr := tmpFile.Write(buf[:n]); werr != nil {
tmpFile.Close()
os.Remove(tmpPath)
return fmt.Errorf("파일 쓰기 실패: %w", werr)
}
downloaded += int64(n)
@@ -477,7 +509,7 @@ func doDownload(downloadURL, destDir string) error {
}
if err != nil {
tmpFile.Close()
os.Remove(tmpPath)
// 네트워크 오류 시 임시 파일 유지 (이어받기 가능)
return fmt.Errorf("다운로드 중 오류: %w", err)
}
}
@@ -542,6 +574,11 @@ func extractZip(zipPath, destDir string) error {
return fmt.Errorf("잘못된 zip 경로: %s", rel)
}
// Symlink 차단: zip 내 심볼릭 링크를 통한 경로 탈출 방지
if f.FileInfo().Mode()&os.ModeSymlink != 0 {
continue
}
if f.FileInfo().IsDir() {
os.MkdirAll(dest, 0755)
continue
@@ -621,7 +658,7 @@ type downloadInfo struct {
URL string `json:"url"`
}
func fetchServerInfo() (*downloadInfo, error) {
func fetchServerInfoOnce() (*downloadInfo, error) {
resp, err := apiClient.Get(serverInfoURL)
if err != nil {
return nil, fmt.Errorf("서버 연결 실패: %w", err)
@@ -642,6 +679,24 @@ func fetchServerInfo() (*downloadInfo, error) {
return &info, nil
}
func fetchServerInfo() (*downloadInfo, error) {
const maxRetries = 3
var lastErr error
for i := range maxRetries {
info, err := fetchServerInfoOnce()
if err == nil {
return info, nil
}
lastErr = err
// 4xx 에러는 재시도해도 의미 없음
if strings.Contains(err.Error(), "서버 오류") || strings.Contains(err.Error(), "준비되지") {
return nil, err
}
time.Sleep(time.Duration(1<<i) * time.Second) // 1s, 2s, 4s
}
return nil, fmt.Errorf("서버 연결 실패 (%d회 재시도): %w", maxRetries, lastErr)
}
func hashFile(path string) (string, error) {
f, err := os.Open(path)
if err != nil {
@@ -657,6 +712,16 @@ func hashFile(path string) (string, error) {
// ── Launcher path ────────────────────────────────────────────────────────────
// installDir returns the fixed install directory: %LOCALAPPDATA%\A301
func installDir() (string, error) {
localAppData := os.Getenv("LOCALAPPDATA")
if localAppData == "" {
return "", fmt.Errorf("LOCALAPPDATA 환경변수를 찾을 수 없습니다")
}
return filepath.Join(localAppData, "A301"), nil
}
// launcherPath returns the current executable's absolute path.
func launcherPath() (string, error) {
exe, err := os.Executable()
if err != nil {
@@ -668,11 +733,29 @@ func launcherPath() (string, error) {
// ── Protocol install / uninstall ─────────────────────────────────────────────
func install() error {
exePath, err := launcherPath()
srcPath, err := launcherPath()
if err != nil {
return fmt.Errorf("실행 파일 경로를 찾을 수 없습니다: %w", err)
}
dir, err := installDir()
if err != nil {
return err
}
if err := os.MkdirAll(dir, 0755); err != nil {
return fmt.Errorf("설치 디렉토리 생성 실패: %w", err)
}
dstPath := filepath.Join(dir, "launcher.exe")
// 이미 설치 위치에서 실행 중이 아니면 복사
if !strings.EqualFold(srcPath, dstPath) {
if err := copyFile(srcPath, dstPath); err != nil {
return fmt.Errorf("런처 설치 실패: %w", err)
}
}
// 설치된 경로를 레지스트리에 등록
key, _, err := registry.CreateKey(registry.CURRENT_USER, `Software\Classes\`+protocolName, registry.SET_VALUE)
if err != nil {
return fmt.Errorf("레지스트리 키 생성 실패: %w", err)
@@ -686,7 +769,7 @@ func install() error {
return fmt.Errorf("command 키 생성 실패: %w", err)
}
defer cmdKey.Close()
return cmdKey.SetStringValue("", fmt.Sprintf(`"%s" "%%1"`, exePath))
return cmdKey.SetStringValue("", fmt.Sprintf(`"%s" "%%1"`, dstPath))
}
func uninstall() error {
@@ -701,6 +784,10 @@ func uninstall() error {
return err
}
}
// 설치 디렉토리 삭제 (자기 자신은 실행 중이라 삭제 실패할 수 있음 — 무시)
if dir, err := installDir(); err == nil {
os.RemoveAll(dir)
}
return nil
}
@@ -760,11 +847,10 @@ func handleURI(rawURI string) error {
return fmt.Errorf("토큰이 없습니다")
}
exePath, err := launcherPath()
gameDir, err := installDir()
if err != nil {
return err
}
gameDir := filepath.Dir(exePath)
gamePath := filepath.Join(gameDir, gameExeName)
serverInfo, err := fetchServerInfo()
@@ -776,8 +862,9 @@ func handleURI(rawURI string) error {
return err
}
cmd := exec.Command(gamePath, "-token", token)
cmd := exec.Command(gamePath)
cmd.Dir = gameDir
cmd.Env = append(os.Environ(), "A301_TOKEN="+token)
if err := cmd.Start(); err != nil {
return fmt.Errorf("게임 실행 실패: %w", err)
}
@@ -786,6 +873,14 @@ func handleURI(rawURI string) error {
// ── Entry point ──────────────────────────────────────────────────────────────
func activateExistingWindow() {
className, _ := windows.UTF16PtrFromString("A301Progress")
hwnd, _, _ := findWindowWProc.Call(uintptr(unsafe.Pointer(className)), 0)
if hwnd != 0 {
setForegroundWindowProc.Call(hwnd)
}
}
func acquireSingleInstance() bool {
name, _ := windows.UTF16PtrFromString("Global\\A301LauncherMutex")
_, _, err := createMutexWProc.Call(0, 0, uintptr(unsafe.Pointer(name)))
@@ -800,6 +895,7 @@ func main() {
enableDPIAwareness()
if !acquireSingleInstance() {
activateExistingWindow()
return
}