From 66dc8a14de74a8c6e41a4df10b066e1445caaade Mon Sep 17 00:00:00 2001 From: tolelom <98kimsungmin@naver.com> Date: Wed, 18 Mar 2026 10:37:57 +0900 Subject: [PATCH] =?UTF-8?q?feat:=20=EB=9F=B0=EC=B2=98=20=EC=9E=90=EB=8F=99?= =?UTF-8?q?=20=EC=97=85=EB=8D=B0=EC=9D=B4=ED=8A=B8=20+=20=EB=B3=B4?= =?UTF-8?q?=EC=95=88=20=EC=88=98=EC=A0=95?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 자동 업데이트: - ensureLauncher(): SHA256 비교 → 다운로드 → 해시 검증 → rename-dance 교체 - 첫 실행 시 확인 대화 제거 → 자동 설치 - handleURI() 진입 시 프로토콜 등록 자동 갱신 - .old/.new 잔여 파일 자동 정리 보안: - DLL Hijacking 방어 (SetDefaultDllDirectories) - ZIP 경로 탈출 강화 (절대경로/NTFS ADS 거부) - UTF16 에러 처리 (msgBox, setProgress) - out.Close() 에러 체크 Co-Authored-By: Claude Opus 4.6 (1M context) --- main.go | 173 +++++++++++++++++++++++++++++++++++++++++++++++++++----- 1 file changed, 160 insertions(+), 13 deletions(-) diff --git a/main.go b/main.go index 0fccfef..55028e2 100644 --- a/main.go +++ b/main.go @@ -257,8 +257,14 @@ func initCommonControls() { // ── Win32 helpers ──────────────────────────────────────────────────────────── func msgBox(title, text string, flags uintptr) int { - t, _ := windows.UTF16PtrFromString(title) - m, _ := windows.UTF16PtrFromString(text) + t, err := windows.UTF16PtrFromString(title) + if err != nil { + return 0 + } + m, err := windows.UTF16PtrFromString(text) + if err != nil { + return 0 + } ret, _, _ := messageBoxWProc.Call(0, uintptr(unsafe.Pointer(m)), uintptr(unsafe.Pointer(t)), flags) return int(ret) } @@ -297,8 +303,10 @@ func progressWndProc(hwnd, uMsg, wParam, lParam uintptr) uintptr { func setProgress(text string, pct int) { if text != "" { - t, _ := windows.UTF16PtrFromString(text) - sendMessageWProc.Call(progressLabelHwnd, uintptr(wmSetText), 0, uintptr(unsafe.Pointer(t))) + t, err := windows.UTF16PtrFromString(text) + if err == nil { + sendMessageWProc.Call(progressLabelHwnd, uintptr(wmSetText), 0, uintptr(unsafe.Pointer(t))) + } } if pct >= 0 { sendMessageWProc.Call(progressBarHwnd, uintptr(pbmSetPos), uintptr(pct), 0) @@ -591,6 +599,15 @@ func extractZip(zipPath, destDir string) error { continue } + // 절대 경로 거부 + if filepath.IsAbs(rel) { + return fmt.Errorf("잘못된 zip 경로 (절대 경로): %s", rel) + } + // NTFS ADS 방어: 경로에 ':' 포함 시 거부 + if strings.Contains(rel, ":") { + return fmt.Errorf("잘못된 zip 경로 (ADS): %s", rel) + } + // 런처 파일 건너뜀 if strings.ToLower(filepath.Base(rel)) == selfName { continue @@ -629,11 +646,14 @@ func extractZip(zipPath, destDir string) error { return err } _, err = io.Copy(out, io.LimitReader(rc, maxExtractFileSize)) - out.Close() + closeErr := out.Close() rc.Close() if err != nil { return err } + if closeErr != nil { + return fmt.Errorf("파일 닫기 실패: %w", closeErr) + } } return nil } @@ -696,8 +716,10 @@ func (e *errNoRetry) Error() string { return e.err.Error() } func (e *errNoRetry) Unwrap() error { return e.err } type downloadInfo struct { - FileHash string `json:"fileHash"` - URL string `json:"url"` + FileHash string `json:"fileHash"` + URL string `json:"url"` + LauncherURL string `json:"launcherUrl"` + LauncherHash string `json:"launcherHash"` } func fetchServerInfoOnce() (*downloadInfo, error) { @@ -883,6 +905,112 @@ func ensureGame(gameDir, gamePath string, serverInfo *downloadInfo) error { return nil } +// ── Launcher self-update ───────────────────────────────────────────────────── + +// downloadFile downloads a file from url to destPath using apiClient. +func downloadFile(dlURL, destPath string) error { + resp, err := apiClient.Get(dlURL) + if err != nil { + return err + } + defer resp.Body.Close() + if resp.StatusCode != http.StatusOK { + return fmt.Errorf("HTTP %d", resp.StatusCode) + } + f, err := os.Create(destPath) + if err != nil { + return err + } + _, err = io.Copy(f, io.LimitReader(resp.Body, maxDownloadSize)) + if closeErr := f.Close(); closeErr != nil && err == nil { + err = closeErr + } + return err +} + +// ensureLauncher checks if the installed launcher is up-to-date and replaces it if not. +// Since a running exe cannot overwrite itself on Windows, we: +// 1. Download the new launcher to a .new temp file +// 2. Rename the current exe to .old +// 3. Rename .new to the current exe path +// 4. Return updated=true so the caller can re-exec +func ensureLauncher(serverInfo *downloadInfo) (updated bool, err error) { + if serverInfo.LauncherHash == "" { + return false, nil // server doesn't provide launcher hash, skip + } + + installedPath, err := launcherPath() + if err != nil { + return false, nil // can't determine own path, skip + } + + localHash, err := hashFile(installedPath) + if err != nil { + return false, nil + } + + if strings.EqualFold(localHash, serverInfo.LauncherHash) { + return false, nil // already up-to-date + } + + // Determine download URL + dlURL := serverInfo.LauncherURL + if dlURL == "" { + return false, nil // no launcher URL available + } + + // Download new launcher to temp file + newPath := installedPath + ".new" + if err := downloadFile(dlURL, newPath); err != nil { + os.Remove(newPath) + return false, fmt.Errorf("런처 업데이트 다운로드 실패: %w", err) + } + + // Verify downloaded file hash + newHash, err := hashFile(newPath) + if err != nil { + os.Remove(newPath) + return false, fmt.Errorf("런처 검증 실패: %w", err) + } + if !strings.EqualFold(newHash, serverInfo.LauncherHash) { + os.Remove(newPath) + return false, fmt.Errorf("런처 해시 불일치") + } + + // Replace: current → .old, new → current + oldPath := installedPath + ".old" + os.Remove(oldPath) // remove previous .old if exists + if err := os.Rename(installedPath, oldPath); err != nil { + os.Remove(newPath) + return false, fmt.Errorf("런처 교체 실패: %w", err) + } + if err := os.Rename(newPath, installedPath); err != nil { + // Try to restore + if restoreErr := os.Rename(oldPath, installedPath); restoreErr != nil { + return false, fmt.Errorf("런처 교체 실패 및 복원 불가: %w (원인: %v)", restoreErr, err) + } + return false, fmt.Errorf("런처 교체 실패: %w", err) + } + + return true, nil +} + +// ── Cleanup helpers ────────────────────────────────────────────────────────── + +// cleanupOldFiles removes .old and .new leftover files from previous launcher updates. +func cleanupOldFiles(dir string) { + entries, err := os.ReadDir(dir) + if err != nil { + return + } + for _, e := range entries { + name := e.Name() + if strings.HasSuffix(name, ".old") || strings.HasSuffix(name, ".new") { + os.Remove(filepath.Join(dir, name)) + } + } +} + // ── URI handler ────────────────────────────────────────────────────────────── func handleURI(rawURI string) error { @@ -913,11 +1041,29 @@ func handleURI(rawURI string) error { } gamePath := filepath.Join(gameDir, gameExeName) + // 프로토콜 등록이 현재 런처를 가리키도록 갱신 (사일런트) + _ = install() + + // 이전 업데이트에서 남은 .old/.new 파일 정리 + cleanupOldFiles(gameDir) + serverInfo, err := fetchServerInfo() if err != nil { return fmt.Errorf("버전 확인 실패: %w", err) } + // 런처 자동 업데이트 체크 + if updated, updateErr := ensureLauncher(serverInfo); updateErr != nil { + _ = updateErr // 업데이트 실패는 치명적이지 않음 + } else if updated { + // 새 런처로 재실행 + cmd := exec.Command(os.Args[0], os.Args[1:]...) + if err := cmd.Start(); err != nil { + return fmt.Errorf("새 런처 시작 실패: %w", err) + } + os.Exit(0) + } + if err := ensureGame(gameDir, gamePath, serverInfo); err != nil { return err } @@ -952,6 +1098,11 @@ func acquireSingleInstance() bool { } func main() { + // DLL Hijacking 방어: 시스템 디렉토리에서만 DLL 로드 + // SetDefaultDllDirectories는 Windows 8+ (KB2533623)에서 사용 가능 + const loadLibrarySearchSystem32 = 0x00000800 + kernel32.NewProc("SetDefaultDllDirectories").Call(loadLibrarySearchSystem32) + enableDPIAwareness() if !acquireSingleInstance() { @@ -960,15 +1111,11 @@ func main() { } if len(os.Args) < 2 { - ret := msgBox("One of the plans 런처", "게임 실행을 위해 프로토콜을 등록합니다.\n계속하시겠습니까?", mbYesNo|mbQ) - if ret != idYes { - return - } if err := install(); err != nil { - msgBox("One of the plans 런처 - 오류", fmt.Sprintf("등록 실패:\n%v", err), mbOK|mbError) + msgBox("One of the plans 런처 - 오류", fmt.Sprintf("설치 실패:\n%v", err), mbOK|mbError) os.Exit(1) } - msgBox("One of the plans 런처", "a301:// 프로토콜이 등록되었습니다.\n이제 웹에서 게임 시작 버튼을 사용할 수 있습니다.", mbOK|mbInfo) + msgBox("One of the plans", "설치가 완료되었습니다.\n웹에서 게임 시작 버튼을 클릭하세요.", mbOK|mbInfo) return }