feat: 런처 자동 업데이트 + 보안 수정

자동 업데이트:
- 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) <noreply@anthropic.com>
This commit is contained in:
2026-03-18 10:37:57 +09:00
parent 574a6ee277
commit 66dc8a14de

173
main.go
View File

@@ -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
}