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