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:
173
main.go
173
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
|
||||
}
|
||||
|
||||
|
||||
Reference in New Issue
Block a user