# 연동 가이드 — 새 프로그램에 업데이트 기능 붙이기

대상 독자: 이 서버로 자기 업데이트를 하려는 **새 서비스를 만드는 사람·LLM**. 이 문서만 읽고 붙일 수 있게 썼다. 엔드포인트의 세부는 `api.md`.

## 0. 한 장 요약

```
프로그램 시작 / 주기적으로(예: 6시간마다 + 무작위 지터)
  └─ GET /v1/products/<제품>/check?current=<내 버전>&os=<os>&arch=<cpu>   (헤더 X-Update-Key)
       ├─ 204            → 할 일 없음
       ├─ 200            → download.url 을 받아(같은 키 헤더) 임시 파일에 저장
       │                    → 크기·sha256 확인 → (서명 확인) → 설치 → 재시작
       └─ 401/404/429/5xx → 조용히 건너뛰고 나중에 다시 (아래 '오류 처리')
```

- 서버는 **읽기 전용**이다. 클라이언트가 올리는 API는 없다.
- 인증은 **키 하나**: 헤더 `X-Update-Key`(또는 `Authorization: Bearer`). 키는 프로그램마다 따로, 그 프로그램의 제품만 보게 발급한다.
- 참조 구현(복사해서 쓰기, 공개 · 키 불필요): `/clients/update_client.py`(Python, 표준 라이브러리만) · `/clients/update-client.mjs`(Node 18+) · `/clients/update-check.sh`(bash+curl+jq) · `/clients/update-check.ps1`(PowerShell). 모두 종료코드 **0 = 새 버전 있음 / 10 = 최신 / 1 = 오류**, 받은 파일은 sha256 확인 뒤에만 최종 이름으로 저장하고 실패하면 지운다. 이 구현들은 `test/clients.test.js`가 실제 서버에 붙여 검증한다(PowerShell만 이 서버 PC에 pwsh가 없어 **미검증**).

## 1. 새 서비스를 온보딩하는 순서 (서버 소유자 쪽)

```bash
cd ~/ai_workspace/apps/update-server
node scripts/us.mjs product add mytool --name "내 도구" --description "한 줄 설명"      # 1) 제품 등록 (id: 소문자·숫자·.-_)
node scripts/us.mjs key create --label "mytool 클라이언트" --product mytool            # 2) 키 발급 (한 번만 보임 — 프로그램의 설정·비밀 저장소에)
node scripts/us.mjs publish mytool 1.0.0 --notes "첫 릴리스" \
    --file windows-x86_64=dist/mytool-1.0.0-x64.msi \
    --file linux-x86_64=dist/mytool_1.0.0_amd64.deb \
    --file linux-aarch64=dist/mytool_1.0.0_arm64.deb                                   # 3) 릴리스 올리기
curl -s -H "X-Update-Key: $KEY" https://update.ngnear.dev/v1/products/mytool/latest    # 4) 확인
```

빌드 파이프라인(CI·릴리스 스크립트) 끝에 3)을 넣는 게 보통의 연동이다. 아키텍처마다 빌드가 따로 끝나면 `publish`는 먼저 끝난 것으로 하고, 나머지는 `add-files`로 나중에 덧붙인다(`operations.md`).

## 2. 플랫폼 값 보내기

서버가 별명을 합쳐 주므로 **런타임이 아는 값을 그대로** 보내면 된다.

| 환경 | os | arch |
|---|---|---|
| Node | `process.platform` (`win32`·`linux`·`darwin`) | `process.arch` (`x64`·`arm64`·`ia32`·`arm`) |
| Python | `platform.system()` (`Windows`·`Linux`·`Darwin`) | `platform.machine()` (`AMD64`·`x86_64`·`aarch64`·`arm64`) |
| bash | `uname -s` | `uname -m` |
| PowerShell | `windows` | `$env:PROCESSOR_ARCHITECTURE` (`AMD64`·`ARM64`·`x86`) |
| Go | `runtime.GOOS` (`windows`·`linux`·`darwin`) | `runtime.GOARCH` (`amd64`·`arm64`·`386`·`arm`) |
| Rust | `std::env::consts::OS` | `std::env::consts::ARCH` (`x86_64`·`aarch64`) |
| .NET | `OperatingSystem.IsWindows()`·`IsLinux()`·`IsMacOS()`로 골라 문자열 지정 | `RuntimeInformation.OSArchitecture` (`X64`·`Arm64`) |

**주의(아키텍처):** 에뮬레이션으로 돌고 있는 프로세스(예: ARM Windows에서 x64로 실행 중인 앱)는 OS가 알려 주는 CPU 값이 실제 하드웨어와 다를 수 있다. 이 프로그램이 **계속 x64 빌드로** 업데이트되길 원하면 프로세스가 보는 값(`x64`)을 보내고, **네이티브 arm64 빌드로 옮겨 가길** 원하면 `arm64`를 보낸다. 네이티브 빌드가 없을 수도 있다면 `arm64`를 보내고 `emulated=1`을 붙인다 — "arm64 빌드가 있으면 그것, 없으면 x64"가 된다(서버 쪽 동작은 시험으로 검증했고, 각 OS가 실제로 어떤 값을 보여 주는지는 프로그램 쪽에서 확인할 것).

규칙 요약(자세히는 `api.md` '아키텍처 단위 지원'): x64 클라이언트는 arm64 파일을 받지 않고(반대도 마찬가지), 플랫폼마다 최신이 따로 계산되며, 그 CPU 빌드가 없으면 `404 no_release` + `available_platforms`로 알려 준다.

## 3. 응답을 어떻게 다루나

| 필드 | 의미 | 클라이언트 동작 |
|---|---|---|
| 204 | 이미 최신 | 아무것도 안 함 |
| `latest.version` | 새 버전 | UI·로그에 표시 |
| `latest.notes` | 릴리스 노트 | 사용자에게 보여 줄 수 있음 |
| `mandatory` | 필수 업데이트(릴리스가 필수로 표시됐거나 `current < min_supported_version`) | 건너뛰기 버튼을 없애고 업데이트 후 계속 사용하게 함 |
| `download.url/size/sha256` | 파일 | 받아서 **반드시** 크기·sha256 확인 |
| `download.kind` | `msi`·`exe`·`deb`·`zip`·`tar.gz`·`appimage` … | 설치 방식 선택 (`artifacts`에 같은 플랫폼의 다른 형식 전부) |
| `download.signature` | 게시자 서명(있을 때) | 아래 '서명' |
| `emulated` | 에뮬레이션으로 돌릴 다른 CPU 빌드를 받게 됨 | 필요하면 사용자에게 알림 |
| `latest.meta` | 제품이 정한 자유 JSON | 예: `{"restart":true,"installer_args":"/qn"}` 같은 약속을 제품 안에서 정해 쓴다 |

## 4. 안전하게 설치하기 (가장 중요)

1. **임시 파일에 받는다.** `파일.part`로 받고, 크기와 sha256이 `download`와 같을 때만 최종 이름으로 바꾼다. 다르면 지우고 중단(재시도는 한 번까지). 참조 클라이언트가 이 방식이다.
2. **TLS를 끄지 않는다.** 서버는 HTTPS(Cloudflare)로만 열려 있다. sha256은 같은 채널에서 받은 값이라 TLS가 무결성의 전제다. **서버·네트워크가 뚫려도 안전해야 하는 프로그램**은 아래 서명을 쓴다.
3. **실행 중인 파일을 덮어쓰지 않는다.**
   - **Windows**: 실행 중인 exe는 덮어쓰거나 지울 수 없지만 **이름은 바꿀 수 있다**. 흐름: 새 파일을 옆에 받음 → 실행 중 exe를 `app.exe.old`로 rename → 새 파일을 `app.exe`로 → 재시작 → 시작할 때 `.old` 삭제. MSI/설치 관리자는 `msiexec /i <파일.msi> /qn /norestart`(관리자 권한 필요)로 조용히 설치하고 재시작.
   - **Ubuntu/Linux**: 실행 파일은 `mv`(같은 파일시스템에서 rename)로 원자적으로 교체해도 실행 중인 프로세스는 옛 파일을 계속 쓴다 — 교체 뒤 재시작(`systemctl --user restart …`)한다. `.deb`는 `sudo dpkg -i`(또는 `apt install ./파일.deb`) 권한이 필요하므로, 권한 없는 사용자 프로그램은 `tar.gz`/AppImage를 `~/.local/`에 푸는 방식이 쉽다. 같은 제품이 `deb`와 `tar.gz`를 같이 올릴 수 있고 `?kind=`로 고른다.
   - **macOS**: `.app`을 zip/dmg로 받아 교체(코드 서명·공증은 제품 쪽 책임).
4. **되돌릴 길을 남긴다.** 교체 전 현재 버전을 `.old`(또는 버전 폴더)로 두고, 새 버전이 시작 직후 이상하면(크래시·헬스 체크 실패) 되돌린다. 서버 쪽 회수(yank)로 문제 릴리스를 목록·`/check`에서 빼는 것은 보조 수단이다.
5. **사용자 작업을 끊지 않는다.** 받는 것은 백그라운드에서, 설치·재시작은 유휴 시점이나 사용자 확인 뒤에. `mandatory`가 아니면 미룰 수 있게 한다.

## 5. 호출 주기·오류 처리

- 시작할 때 한 번 + 주기적으로(권장 6~24시간, **±20% 무작위 지터** — 수백 대가 같은 시각에 몰리지 않게).
- 수동 "업데이트 확인" 버튼은 즉시 호출해도 된다.
- 헤더 `X-Update-Client: <제품>/<현재버전>`을 함께 보내면 서버 접근 기록(`data/logs/access.jsonl`)에서 누가 어느 버전인지 보인다(권장).

| 응답 | 의미 | 동작 |
|---|---|---|
| 네트워크 오류·5xx | 서버/회선 문제 | 조용히 건너뜀, 다음 주기에. 지수 백오프(최대 24시간) |
| 401 | 키 문제(없음·틀림·폐기) | **재시도 반복 금지.** 로그에 남기고 업데이트를 끈다(키 교체 필요) — 틀린 키를 반복하면 IP가 429로 막힌다 |
| 404 `no_product` | 제품 id 오타이거나 그 키로 볼 수 없는 제품 | 설정 오류 — 사용자에게 보이지 말고 로그 |
| 404 `no_release` | 이 플랫폼용 빌드가 아직 없음 | 업데이트 없음으로 처리. `error.available_platforms`로 어느 플랫폼이 있는지 확인 |
| 410 `yanked` | 받으려던 릴리스가 회수됨 | 다시 `check`부터 |
| 429 | 틀린 키 과다 | `Retry-After` 초 뒤 |
| 400 | 파라미터 오류 | 코드 버그 — `error.message` 확인 |

## 6. 서명 (서버가 뚫려도 안전해야 할 때)

서버는 `--sig`로 올린 서명 문자열을 `download.signature`로 **그대로 전달만** 한다(검증하지 않는다 — 서버를 믿을 수 없는 상황이 서명의 존재 이유이기 때문). 권장:

1. 게시자(빌드 PC)가 **개인 키로 파일에 서명**한다 — [minisign](https://jedisct1.github.io/minisign/), `openssl pkeyutl -sign`(Ed25519), Tauri의 `tauri signer`, Sparkle의 EdDSA 등 제품 생태계의 도구.
2. 서명 문자열(또는 `.sig` 파일 내용)을 `us.mjs publish … --file win=app.msi --sig-file app.msi.sig`로 올린다.
3. 프로그램에 **공개 키를 빌드 때 박아 둔다**(서버에서 받지 않는다). 다운로드 후 sha256 확인에 더해 서명을 공개 키로 검증하고, 실패하면 설치하지 않는다.
4. 공개 키를 바꿔야 할 때를 위해 두 개까지 박아 두면 키 교체가 쉽다.

Windows 설치 프로그램·macOS 앱은 OS의 코드 서명(Authenticode·공증)도 따로 있다 — 서버와 무관하게 제품이 책임진다.

## 7. 언어별 최소 예제

### Python (표준 라이브러리) — `/clients/update_client.py`를 복사해서

```python
import update_client as uc

info = uc.check("https://update.ngnear.dev", KEY, "mytool", "1.4.2")   # os/arch는 자동 감지
if info:
    path = uc.download(info, KEY)                 # sha256 확인된 파일 경로 (실패하면 UpdateError)
    if info["mandatory"]: ...                     # 건너뛰기 금지
    install(path)                                  # 4절
```

### Node — `/clients/update-client.mjs`

```js
import { check, download } from './update-client.mjs';
const info = await check({ product: 'mytool', current: '1.4.2' });   // UPDATE_URL·UPDATE_KEY 환경 변수
if (info) install(await download(info));
```

### bash (Ubuntu)

```bash
export UPDATE_URL=https://update.ngnear.dev UPDATE_KEY=upk_…
if file=$(./update-check.sh download mytool "$CURRENT" stable /var/tmp/mytool-update); then
  sudo dpkg -i "$file"            # 종료코드 0 = 받음, 10 = 이미 최신, 1 = 오류
fi
```

### PowerShell (Windows) — ⚠ pwsh가 없어 서버 PC에서 실행 검증은 못 했다(문법은 표준 cmdlet만 사용)

```powershell
$env:UPDATE_KEY = 'upk_…'
$f = .\update-check.ps1 -Command download -Product mytool -Current 1.4.2
if ($LASTEXITCODE -eq 0) { Start-Process msiexec -ArgumentList '/i', $f, '/qn', '/norestart' -Wait }
```

### 직접 호출(어떤 언어든)

```
GET /v1/products/mytool/check?current=1.4.2&os=linux&arch=aarch64   X-Update-Key: …   → 204 | 200 JSON
GET <download.url>                                                  X-Update-Key: …   → 파일 (Range·ETag 지원)
```

## 8. Tauri·기존 업데이터와 붙이기

`GET /v1/products/<id>/latest` 응답은 Tauri 업데이터의 `latest.json`(`version`·`notes`·`pub_date`·`platforms{os-arch:{url,signature}}`)과 같은 모양이다. Tauri 앱의 `endpoints`에 이 주소를 넣고 키는 `headers`로 넘기면 된다(Tauri 업데이터 설정 문서 참조). 서명은 Tauri가 요구하는 `.sig` 파일 내용을 `--sig-file`로 올린 것을 쓴다. **이 연동은 서버 응답 모양만 맞춘 것으로, 실제 Tauri 앱에 붙여 본 적은 없다(미검증).**

## 9. 체크리스트 (붙이기 전에)

- [ ] 제품 id·키를 서버 소유자에게 받았다(키는 소스에 커밋하지 않는다 — 환경 변수·OS 비밀 저장소)
- [ ] `os`/`arch`를 런타임 값 그대로 보내고, 에뮬레이션을 허용할지 정했다(`emulated`)
- [ ] 204·401·404·429·네트워크 오류를 각각 처리한다(5절)
- [ ] 받은 파일의 크기·sha256을 확인하고, 실패 시 지운다
- [ ] 실행 중인 파일을 덮어쓰지 않는다(4절), 되돌릴 길이 있다
- [ ] 주기 호출에 지터를 넣었다, `X-Update-Client` 헤더를 보낸다
- [ ] 필요하면 서명 검증(공개 키를 프로그램에 내장)
