update-hub 연동 가이드
AI 에이전트에게: 이 문서는 당신이 사람 도움 없이(승인 한 번만 받고) 앱에 자동 업데이트를 붙이도록 쓰였습니다. 끝까지 읽은 뒤 작업 순서대로 진행하세요. 서버:
https://update.kimhwan.kr· 이 문서:https://update.kimhwan.kr/agents.md
한눈에 보기
update-hub 는 여러 제품이 같이 쓰는 업데이트 서버입니다.
- 프로젝트 = 제품(앱) 하나. 키 예:
my-app - 컴포넌트 = 프로젝트 안에서 따로 버전이 붙고 따로 내려받는 단위. 예:
launcher(실행 파일),content(데이터 묶음) - 채널 =
stable(기본),beta… 컴포넌트마다 채널별로 "최신"이 있습니다 - 릴리스 = 컴포넌트의 한 버전 + 그 버전을 설치하는 데 필요한 파일 전체. 상태:
DRAFT(초안) →PUBLISHED(게시, 서명됨) →REVOKED(철회) - 최신 = 그 채널에서 가장 최근에 게시된 릴리스입니다 (버전 문자열 비교가 아님). 되돌리기 = 최신을 철회하거나 이전 릴리스를 다시 게시
앱은 매니페스트 한 번으로 모든 컴포넌트의 최신 릴리스를 알고, 서명을 검증한 뒤, 바뀐 파일만 받습니다. 게시된 릴리스는 서버의 ECDSA P-256 키로 서명되므로, 서버·DNS·TLS 가 뚫려도 가짜 업데이트를 밀어 넣을 수 없습니다(앱이 검증한다면).
| 주소 | 용도 |
|---|---|
https://update.kimhwan.kr |
콘솔, 관리 API, 매니페스트(앱이 읽는 곳) |
https://files.update.kimhwan.kr |
릴리스 업로드(CI)와 파일 다운로드 — 프록시를 거치지 않아 크기 제한·속도 문제가 없음 |
지켜야 할 규칙
- 비밀을 출력하지 마세요. 명령 출력은 대화 기록에 남습니다. 토큰·키는
echo/print/로그로 내보내지 말고 파이프로 바로 저장하세요(아래 예시가 모두 그렇게 되어 있습니다). 사람에게 비밀을 채팅으로 달라고 하지도 마세요. - 비밀을 저장소에 커밋하지 마세요. 업로드 토큰은 CI 시크릿 저장소에만, 개인 토큰은
~/.config/update-hub/에만 둡니다..env같은 파일을 만들었다면.gitignore를 확인하세요. stable에 게시하는 것은 사람이 시켰을 때만. 확인용 업로드는publish=false(초안) 또는channel=beta로 하세요.- 게시된 버전은 바뀌지 않습니다. 같은 버전을 다시 올리면
409— 새 버전 번호를 쓰세요. 초안·철회본은 같은 버전으로 덮어쓸 수 있습니다. - 이미 있는 것을 깨뜨리지 마세요. 프로젝트에 업로드 토큰이 이미 있으면(
uploadToken: true) 재발급하면 기존 CI 가 멈춥니다. 클라이언트 키 교체도 배포된 앱에 영향을 줍니다. 둘 다 사람에게 먼저 물어보세요. - 토큰으로 못 하는 일: 프로젝트 삭제, 키 보호 끄기, 다른 사람 프로젝트 접근. 필요하면 사람에게 콘솔에서 하도록 안내하세요.
- 끝나면 토큰을 폐기하세요 (마무리) — 사람이 계속 쓰라고 하지 않았다면.
작업 순서
- 인증 — 기기 로그인으로 개인 액세스 토큰을 받는다 (사람이 브라우저에서 한 번 승인)
- 설계 — 프로젝트 키와 컴포넌트를 정한다
- 만들기 — 프로젝트·컴포넌트를 만든다 (이미 있으면 그대로 쓴다)
- 업로드 토큰 — 발급해서 CI 시크릿에 바로 넣는다
- CI — 빌드 끝에 업로드 단계를 넣는다
- 앱 — 매니페스트 확인 → 서명 검증 → 다운로드 → SHA-256 확인 → 적용
- 확인 — 초안/beta 로 올려 보고 앱의 업데이트 경로를 시험한다
- 마무리 — 토큰 폐기, 사람에게 보고
인증: 기기 로그인
에이전트는 기기 로그인(OAuth device flow 와 같은 방식)으로 토큰을 받습니다. 비밀번호나 토큰을 채팅으로 주고받지 않습니다.
- 에이전트가 코드를 요청한다 → 8자리 코드(예:
BCDF-GHJK)와 승인 주소를 받는다 - 사람에게 주소와 코드를 알려 준다 → 사람이 콘솔에서 요청한 클라이언트·IP·권한을 확인하고 승인한다
- 에이전트가 기다렸다가 토큰을 받아
~/.config/update-hub/auth-header에 저장한다 (화면에는 토큰 id 앞부분만 나옴)
토큰 형식은 uhp_<16자 id>_<비밀>, 기본 만료 30일, 권한(scope)은 projects(프로젝트·컴포넌트·업로드 토큰·클라이언트 키)와 releases(릴리스 업로드·게시·철회·삭제)입니다. 토큰은 승인한 사람의 권한 안에서만 동작합니다.
이미 로그인돼 있는지 확인
H="$HOME/.config/update-hub/auth-header"
[ -s "$H" ] && curl -fsS -H @"$H" https://update.kimhwan.kr/api/v1/me
200 이면 그대로 쓰세요. 401 이거나 파일이 없으면 아래로 로그인합니다.
도우미 스크립트로 로그인 (권장)
짧은 스크립트이니 원하면 먼저 읽어 보세요. 두 단계로 나뉘어 있어서 첫 단계는 바로 끝나고, 그 사이에 사람에게 코드를 전할 수 있습니다.
curl -fsSL https://update.kimhwan.kr/cli/login.sh -o "${TMPDIR:-/tmp}/uh-login.sh"
bash "${TMPDIR:-/tmp}/uh-login.sh" start "Claude Code - my-app" # 주소와 코드를 출력하고 끝남
# → 사람에게: "브라우저에서 <주소> 를 열고 코드 <코드> 를 승인해 주세요"
bash "${TMPDIR:-/tmp}/uh-login.sh" wait # 승인될 때까지 기다림 (최대 10분)
wait 는 최대 10분 동안 5초 간격으로 확인하므로 명령 타임아웃을 10분 이상으로 주거나 백그라운드로 실행하세요. status 는 현재 토큰 확인, logout 은 폐기입니다.
PowerShell(Windows):
irm https://update.kimhwan.kr/cli/login.ps1 -OutFile "$env:TEMP\uh-login.ps1"
powershell -ExecutionPolicy Bypass -File "$env:TEMP\uh-login.ps1" start -ClientName "Claude Code - my-app"
powershell -ExecutionPolicy Bypass -File "$env:TEMP\uh-login.ps1" wait
curl 만으로 로그인
도우미를 쓸 수 없을 때. device_code 도 비밀이니 출력하지 말고 변수에만 둡니다.
SITE=https://update.kimhwan.kr; H="$HOME/.config/update-hub/auth-header"; mkdir -p "$(dirname "$H")"
R=$(curl -fsS -X POST "$SITE/api/v1/auth/device/code" --data-urlencode "client_name=Claude Code - my-app" --data-urlencode "scope=projects releases")
DC=$(printf %s "$R" | sed -E 's/.*"device_code":"([^"]+)".*/\1/')
printf %s "$R" | sed -E 's/.*"verification_uri_complete":"([^"]+)".*/승인 주소: \1/'
# 사람이 승인할 때까지 5초 간격으로:
R=$(curl -sS -X POST "$SITE/api/v1/auth/device/token" --data-urlencode "device_code=$DC")
case "$R" in
*access_token*) (umask 077; printf 'Authorization: Bearer %s\n' "$(printf %s "$R" | sed -E 's/.*"access_token":"([^"]+)".*/\1/')" > "$H"); echo saved ;;
*) printf %s "$R" | sed -E 's/.*"error":"([^"]+)".*/\1/' ;; # authorization_pending → 다시, slow_down → 간격 늘리기
esac
응답 error |
뜻 |
|---|---|
authorization_pending |
아직 승인 전 — interval(5초) 뒤 다시 |
slow_down |
너무 자주 물음 — 간격을 5초 늘리기 |
access_denied |
사람이 거부함 — 멈추고 이유를 물어보기 |
expired_token |
코드 만료(10분) — 처음부터 |
invalid_grant |
모르는 코드이거나 이미 토큰을 받은 코드 |
토큰 쓰기
curl -fsS -H @"$HOME/.config/update-hub/auth-header" https://update.kimhwan.kr/api/v1/me
$auth = (Get-Content "$HOME\.config\update-hub\auth-header" -Raw).Trim() -replace '^Authorization:\s*', ''
Invoke-RestMethod https://update.kimhwan.kr/api/v1/me -Headers @{ Authorization = $auth }
사람이 콘솔(액세스 토큰 → 새 토큰)에서 직접 만든 토큰을 같은 파일에 넣어 줄 수도 있습니다. 그때도 채팅으로 받지 말고, 콘솔이 보여 주는 저장 명령을 사람이 자기 터미널에서 실행하게 하세요.
프로젝트 설계
- 프로젝트 키: 제품 이름을 소문자로 (
a-z 0-9 . _ -, 최대 64자). API 경로에 쓰이고 바꿀 수 없습니다. - 컴포넌트: "따로 바뀌고 따로 받는 것" 단위로 나눕니다.
- 실행 파일 하나짜리 앱 →
app하나 - 런처 + 큰 데이터 →
launcher,content(데이터만 바뀔 때 실행 파일을 다시 받지 않게) - 플랫폼별 빌드 →
app-win-x64,app-linux-x64처럼 컴포넌트를 나누거나, 한 릴리스 안에서 경로로 나눕니다
- 실행 파일 하나짜리 앱 →
- 릴리스 = 전체 파일 묶음(차분이 아님). 파일마다 SHA-256 이 있으니 앱은 바뀐 파일만 받으면 됩니다.
- 파일 경로(
paths)는 앱이 설치 폴더에 쓸 상대 경로입니다. 예:MyApp.exe,data/pack.bin - 버전:
A-Z a-z 0-9 . _ + -, 최대 100자, 컴포넌트 안에서 유일. 서버는 버전을 비교하지 않지만, 앱이 다운그레이드를 막으려면 비교 가능한 형식(SemVer,2026.11.05.1같은 날짜형)을 쓰세요. - metadata: 앱이 읽을 자유 JSON 객체(서명에 포함). 예:
{"minLauncher":"2.0.0","releaseNotesUrl":"…"}
프로젝트와 컴포넌트 만들기
H="$HOME/.config/update-hub/auth-header"; API=https://update.kimhwan.kr/api/v1
# 내가 관리하는 프로젝트 목록 (이미 있으면 새로 만들지 말고 그걸 씁니다)
curl -fsS -H @"$H" "$API/manage/projects"
printf '%s' '{"key":"my-app","name":"My App","description":"Windows 데스크톱 앱"}' \
| curl -fsS -H @"$H" -H 'Content-Type: application/json' --data-binary @- "$API/manage/projects"
printf '%s' '{"key":"app","name":"My App (Windows x64)"}' \
| curl -fsS -H @"$H" -H 'Content-Type: application/json' --data-binary @- "$API/manage/projects/my-app/components"
curl -fsS -H @"$H" "$API/manage/projects/my-app" # 상태 확인: components, uploadToken, clientKey, urls
409 {"error":"project exists: …"} 는 이미 있다는 뜻입니다. 남의 프로젝트라면 이후 요청이 404 가 되니, 다른 키를 고르세요.
Windows 주의: Windows 의
curl.exe는 명령줄 인자를 ANSI 코드 페이지(한국어 Windows 는 CP949)로 받아서,-d '{"name":"한글"}'처럼 인자에 넣은 한글이 깨지고400 request body is not valid UTF-8 JSON이 납니다. 위 예시처럼 JSON 은printf … | curl --data-binary @-로 stdin 에 넘기세요. 업로드의 한글 릴리스 노트는 파일에 써 두고-F "notes=<notes.txt"로 보내면 됩니다.
업로드 토큰을 CI 시크릿으로
업로드 토큰(uh_…)은 그 프로젝트의 릴리스 쓰기 전용이라 CI 에 넣기에 알맞습니다. 응답에 한 번만 나오므로 출력하지 말고 바로 시크릿 저장소로 보내세요. 먼저 GET …/manage/projects/my-app 의 uploadToken 이 false 인지 확인하세요(true 면 규칙 5).
GitHub Actions (gh CLI 로그인 필요):
curl -fsS -X POST -H @"$H" "$API/manage/projects/my-app/upload-token" \
| sed -E 's/.*"token":"([^"]+)".*/\1/' \
| gh secret set UPDATE_HUB_UPLOAD_TOKEN --repo OWNER/REPO
Jenkins 등 CLI 로 시크릿을 넣을 수 없는 곳: 파일로 받아 두고 사람에게 등록을 부탁하세요.
(umask 077; curl -fsS -X POST -H @"$H" "$API/manage/projects/my-app/upload-token" \
| sed -E 's/.*"token":"([^"]+)".*/\1/' > "$HOME/.config/update-hub/my-app-upload-token")
# → 사람에게: "~/.config/update-hub/my-app-upload-token 내용을 Jenkins 'Secret text' 크리덴셜 update-hub-my-app 으로 등록해 주세요"
CI 에서 릴리스 올리기
POST https://files.update.kimhwan.kr/api/v1/projects/{project}/components/{component}/releases
Authorization: Bearer <업로드 토큰 또는 releases 권한의 개인 토큰>
multipart/form-data:
version (필수)
channel 기본 stable
notes 릴리스 노트
metadata JSON 객체 문자열
publish true 면 바로 서명·게시 (기본 false = 초안)
files 파일 (반복)
paths 각 파일의 경로 (files 와 같은 순서로 반복, 생략 시 파일 이름)
응답 201 은 릴리스 JSON 입니다. 같은 버전이 게시돼 있으면 409, 초안·철회 상태면 덮어씁니다(CI 재실행 안전).
업로드는 https://files.update.kimhwan.kr 로 보내세요(100 MB 넘는 파일도 됨). 큰 파일에는 -H "Expect:" 를 붙이면 빨라집니다.
GitHub Actions:
- name: Publish to update-hub
if: startsWith(github.ref, 'refs/tags/v')
env:
UPDATE_HUB_UPLOAD_TOKEN: ${{ secrets.UPDATE_HUB_UPLOAD_TOKEN }}
run: |
curl -fsS -H "Expect:" -H "Authorization: Bearer $UPDATE_HUB_UPLOAD_TOKEN" \
-F "version=${GITHUB_REF_NAME#v}" -F channel=stable -F publish=true \
-F "notes=Release $GITHUB_REF_NAME" \
-F files=@dist/MyApp.exe -F paths=MyApp.exe \
https://files.update.kimhwan.kr/api/v1/projects/my-app/components/app/releases
Jenkins:
stage('Publish') {
steps {
withCredentials([string(credentialsId: 'update-hub-my-app', variable: 'UH_TOKEN')]) {
sh '''
curl -fsS -H "Expect:" -H "Authorization: Bearer $UH_TOKEN" \
-F version=$BUILD_VERSION -F channel=stable -F publish=true \
-F files=@dist/MyApp.exe -F paths=MyApp.exe \
https://files.update.kimhwan.kr/api/v1/projects/my-app/components/app/releases
'''
}
}
}
나중에 게시·철회·삭제:
POST https://files.update.kimhwan.kr/api/v1/projects/{p}/components/{c}/releases/{version}/publish
POST https://files.update.kimhwan.kr/api/v1/projects/{p}/components/{c}/releases/{version}/revoke
DELETE https://files.update.kimhwan.kr/api/v1/projects/{p}/components/{c}/releases/{version}
앱에 업데이트 기능 넣기
흐름
GET https://update.kimhwan.kr/api/v1/projects/{project}/manifest?channel=stable— 키 보호 프로젝트면X-Update-Key헤더와 정해진 User-Agent 를 함께 보냅니다. 매니페스트와 파일 요청 모두에X-Install-Id(설치마다 처음 한 번 만든 임의의 값, 8–64자A-Za-z0-9._:-)와X-Client-Version(지금 실행 중인 버전)을 붙이세요. 필수는 아니지만, 붙이면 콘솔의 사용 통계가 사용자 수·버전 분포를 설치 단위로 정확히 셉니다(안 붙이면 IP + User-Agent 로 셈). 개인정보가 아닌 임의 값이어야 합니다.- 쓰는 컴포넌트의
latest가null이면 게시된 것이 없음 → 끝. - 서명 검증:
signature(base64, DER) 를signedPayload의 UTF-8 바이트에 대해 앱에 내장한 공개키로 검증합니다(SHA256withECDSA, P-256). 실패하면 아무것도 설치하지 말고 중단합니다. signedPayload를 JSON 으로 파싱해서 그 안의 값만 믿습니다.project·component·channel이 기대한 값인지 확인하세요 — 모든 프로젝트가 같은 서명키를 쓰므로, 이 확인이 없으면 다른 프로젝트의 정상 서명 릴리스를 바꿔치기 당할 수 있습니다.version이 설치된 것과 같으면 최신. 더 낮으면(버전이 비교 가능할 때) 거부합니다(오래된 취약 버전 재생 공격 방지).- payload 의
files각각: 로컬 파일의 SHA-256 이 같으면 건너뛰고, 다르면 매니페스트의 같은path항목url에서 받습니다. 링크는 15분 뒤 만료되니(403) 바로 받고, 만료되면 매니페스트를 다시 읽습니다.Range이어받기를 지원합니다. - 받은 파일의 크기와 SHA-256 을 payload 값과 대조합니다. 다르면 버립니다.
- 임시 폴더에 다 받은 뒤 한 번에 교체합니다(폴더 교체·rename). 실행 중인 exe 는 새 이름으로 받고 재시작 때 바꿉니다.
매니페스트 예시
{
"project": "my-app",
"channel": "stable",
"generatedAt": "2026-11-05T03:12:45Z",
"signingKeyFingerprint": "8e7a6fe0acdd5116",
"components": [
{
"key": "app",
"name": "My App (Windows x64)",
"latest": {
"project": "my-app", "component": "app", "version": "1.4.0", "channel": "stable", "status": "PUBLISHED",
"notes": "…", "metadata": {"minLauncher": "2.0.0"},
"createdAt": "2026-11-05T03:10:02Z", "publishedAt": "2026-11-05T03:10:03Z", "totalSize": 48213504,
"files": [
{"path": "MyApp.exe", "size": 48213504, "sha256": "9f2c…", "url": "https://files.update.kimhwan.kr/api/v1/projects/my-app/components/app/releases/1.4.0/files/MyApp.exe?exp=1762313265&sig=…"}
],
"signedPayload": "{\"project\":\"my-app\",\"component\":\"app\",\"version\":\"1.4.0\",\"channel\":\"stable\",\"publishedAt\":\"2026-11-05T03:10:03Z\",\"metadata\":{\"minLauncher\":\"2.0.0\"},\"files\":[{\"path\":\"MyApp.exe\",\"size\":48213504,\"sha256\":\"9f2c…\"}]}",
"signature": "MEUCIQ…"
}
}
]
}
서명 공개키
앱 소스에 상수로 넣으세요(비밀 아님). 실행 중에 서버에서 받아 쓰면 검증 의미가 없습니다.
MFkwEwYHKoZIzj0CAQYIKoZIzj0DAQcDQgAEB5NseUrIEq9ihVWnkl+Kc6dsq3q5YB1eGDwFLQUIYXfJ6vFOQ1iyb0b7Q892UMjMLXJjtRche0rTRX9WdLHTTA==
fingerprint 8e7a6fe0acdd5116 · SHA256withECDSA (P-256) · SubjectPublicKeyInfo DER, base64 · GET https://update.kimhwan.kr/api/v1/signing-key
C# (.NET 6 이상)
패키지 없이 동작합니다. 업데이트 확인 → 검증 → 바뀐 파일만 받기까지.
using System.Security.Cryptography;
using System.Text;
using System.Text.Json;
public sealed class UpdateClient
{
const string Server = "https://update.kimhwan.kr";
// 릴리스 서명 공개키 (update-hub, fingerprint 8e7a6fe0acdd5116)
const string PublicKeySpki = "MFkwEwYHKoZIzj0CAQYIKoZIzj0DAQcDQgAEB5NseUrIEq9ihVWnkl+Kc6dsq3q5YB1eGDwFLQUIYXfJ6vFOQ1iyb0b7Q892UMjMLXJjtRche0rTRX9WdLHTTA==";
readonly string _project, _channel;
readonly HttpClient _http;
/// <param name="clientKey">키 보호 프로젝트의 클라이언트 키(uk_…) — 빌드 때 주입, 없으면 null</param>
/// <param name="installedVersion">지금 실행 중인 버전 (사용 통계의 버전 분포용)</param>
public UpdateClient(string project, string channel = "stable", string? clientKey = null, string userAgent = "MyApp/1.0",
string? installedVersion = null)
{
_project = project;
_channel = channel;
_http = new HttpClient { Timeout = TimeSpan.FromMinutes(30) };
_http.DefaultRequestHeaders.UserAgent.ParseAdd(userAgent);
if (clientKey != null) _http.DefaultRequestHeaders.Add("X-Update-Key", clientKey);
_http.DefaultRequestHeaders.Add("X-Install-Id", InstallId());
if (installedVersion != null) _http.DefaultRequestHeaders.Add("X-Client-Version", installedVersion);
}
/// <summary>설치마다 한 번 만들어 두는 임의의 id (사용자 정보가 아님) — 사용 통계가 설치 단위로 셀 수 있게.</summary>
static string InstallId()
{
string file = Path.Combine(Environment.GetFolderPath(Environment.SpecialFolder.LocalApplicationData), "MyApp", "install-id");
try { if (File.Exists(file)) { string id = File.ReadAllText(file).Trim(); if (id.Length is >= 8 and <= 64) return id; } } catch (IOException) { }
string fresh = Guid.NewGuid().ToString("N");
try { Directory.CreateDirectory(Path.GetDirectoryName(file)!); File.WriteAllText(file, fresh); } catch (IOException) { }
return fresh;
}
public sealed record SignedFile(string Path, long Size, string Sha256);
public sealed record Update(string Component, string Version, JsonElement Metadata,
IReadOnlyList<SignedFile> Files, IReadOnlyDictionary<string, string> Urls);
/// <summary>검증된 최신 릴리스. 채널에 게시된 것이 없으면 null.</summary>
public async Task<Update?> CheckAsync(string component, CancellationToken ct = default)
{
string json = await _http.GetStringAsync($"{Server}/api/v1/projects/{_project}/manifest?channel={_channel}", ct);
using var doc = JsonDocument.Parse(json);
JsonElement? latest = null;
foreach (var c in doc.RootElement.GetProperty("components").EnumerateArray())
if (c.GetProperty("key").GetString() == component && c.GetProperty("latest").ValueKind == JsonValueKind.Object)
latest = c.GetProperty("latest");
if (latest is not { } l) return null;
string payload = l.GetProperty("signedPayload").GetString()!;
if (!Verify(payload, l.GetProperty("signature").GetString()!))
throw new CryptographicException("update signature is invalid");
using var signed = JsonDocument.Parse(payload); // 서명된 내용만 믿는다
var p = signed.RootElement;
if (p.GetProperty("project").GetString() != _project || p.GetProperty("component").GetString() != component
|| p.GetProperty("channel").GetString() != _channel)
throw new CryptographicException("signed release is for another project/component/channel");
var files = p.GetProperty("files").EnumerateArray()
.Select(f => new SignedFile(f.GetProperty("path").GetString()!, f.GetProperty("size").GetInt64(), f.GetProperty("sha256").GetString()!))
.ToList();
var urls = l.GetProperty("files").EnumerateArray()
.ToDictionary(f => f.GetProperty("path").GetString()!, f => f.GetProperty("url").GetString()!);
return new Update(component, p.GetProperty("version").GetString()!, p.GetProperty("metadata").Clone(), files, urls);
}
/// <summary>설치 폴더와 다른 파일만 받아 staging 폴더에 완성본을 만든다(크기·SHA-256 확인).</summary>
public async Task StageAsync(Update u, string installDir, string stagingDir, CancellationToken ct = default)
{
foreach (var f in u.Files)
{
string target = SafePath(stagingDir, f.Path), current = SafePath(installDir, f.Path);
Directory.CreateDirectory(Path.GetDirectoryName(target)!);
if (File.Exists(current) && await Sha256Async(current, ct) == f.Sha256) { File.Copy(current, target, true); continue; }
using (var res = await _http.GetAsync(u.Urls[f.Path], HttpCompletionOption.ResponseHeadersRead, ct))
{
res.EnsureSuccessStatusCode(); // 403 = 링크 만료(15분) → CheckAsync 부터 다시
await using var output = File.Create(target);
await res.Content.CopyToAsync(output, ct);
}
if (new FileInfo(target).Length != f.Size || await Sha256Async(target, ct) != f.Sha256)
{
File.Delete(target);
throw new InvalidDataException($"checksum mismatch: {f.Path}");
}
}
}
static bool Verify(string payload, string signatureB64)
{
using var ecdsa = ECDsa.Create();
ecdsa.ImportSubjectPublicKeyInfo(Convert.FromBase64String(PublicKeySpki), out _);
return ecdsa.VerifyData(Encoding.UTF8.GetBytes(payload), Convert.FromBase64String(signatureB64),
HashAlgorithmName.SHA256, DSASignatureFormat.Rfc3279DerSequence);
}
static async Task<string> Sha256Async(string file, CancellationToken ct)
{
using var sha = SHA256.Create();
await using var s = File.OpenRead(file);
return Convert.ToHexString(await sha.ComputeHashAsync(s, ct)).ToLowerInvariant();
}
static string SafePath(string root, string relative)
{
string baseDir = Path.GetFullPath(root).TrimEnd(Path.DirectorySeparatorChar) + Path.DirectorySeparatorChar;
string full = Path.GetFullPath(Path.Combine(baseDir, relative.Replace('/', Path.DirectorySeparatorChar)));
if (!full.StartsWith(baseDir, StringComparison.OrdinalIgnoreCase)) throw new InvalidDataException("path escapes the folder: " + relative);
return full;
}
}
다른 언어의 서명 검증
모두 signedPayload 의 UTF-8 바이트, base64 DER 서명, base64 SPKI 공개키를 씁니다. 검증 뒤 payload 의 project/component/channel 확인은 똑같이 하세요.
Node.js / Electron:
import { createPublicKey, verify } from 'node:crypto';
const key = createPublicKey({ key: Buffer.from(PUBLIC_KEY_SPKI, 'base64'), format: 'der', type: 'spki' });
const ok = verify('sha256', Buffer.from(release.signedPayload, 'utf8'), key, Buffer.from(release.signature, 'base64'));
Python (cryptography):
import base64
from cryptography.hazmat.primitives import hashes, serialization
from cryptography.hazmat.primitives.asymmetric import ec
key = serialization.load_der_public_key(base64.b64decode(PUBLIC_KEY_SPKI))
key.verify(base64.b64decode(release["signature"]), release["signedPayload"].encode("utf-8"), ec.ECDSA(hashes.SHA256())) # 실패 시 InvalidSignature
Go:
der, _ := base64.StdEncoding.DecodeString(publicKeySPKI)
pub, _ := x509.ParsePKIXPublicKey(der)
sig, _ := base64.StdEncoding.DecodeString(release.Signature)
sum := sha256.Sum256([]byte(release.SignedPayload))
ok := ecdsa.VerifyASN1(pub.(*ecdsa.PublicKey), sum[:], sig)
Java / Kotlin (JVM, Android):
var key = KeyFactory.getInstance("EC").generatePublic(new X509EncodedKeySpec(Base64.getDecoder().decode(PUBLIC_KEY_SPKI)));
var s = Signature.getInstance("SHA256withECDSA");
s.initVerify(key);
s.update(release.signedPayload().getBytes(StandardCharsets.UTF_8));
boolean ok = s.verify(Base64.getDecoder().decode(release.signature()));
다운로드를 우리 앱으로만 제한하기 (선택)
기본은 누구나 받을 수 있는 공개 프로젝트입니다. 사람이 원하면 클라이언트 키를 켭니다. 켜면 읽기 API 가 X-Update-Key 헤더 없이는 401 이 되고, 파일 링크는 매니페스트가 준 15분짜리 서명 링크만 통합니다.
# 발급 (응답에 한 번만 나옴) → CI 시크릿으로. 앱 빌드 때 주입하고 소스에 하드코딩하지 마세요.
curl -fsS -X POST -H @"$H" "$API/manage/projects/my-app/client-key" \
| sed -E 's/.*"key":"([^"]+)".*/\1/' | gh secret set UPDATE_HUB_CLIENT_KEY --repo OWNER/REPO
# User-Agent 패턴 (봇·브라우저를 거르는 가벼운 문)
printf '%s' '{"uaPattern":"^MyApp/"}' | curl -fsS -X PATCH -H @"$H" -H 'Content-Type: application/json' --data-binary @- "$API/manage/projects/my-app"
공개 exe 에 넣은 키는 결국 꺼낼 수 있으므로 이것은 "가벼운 문"입니다. 진짜 보증은 서명 검증입니다. 이미 키가 있는 프로젝트에서 다시 발급하면 기존 키는 "이전 키"가 되어 폐기 전까지 계속 통합니다(배포 중인 앱 보호). 이전 키 폐기는 새 앱이 충분히 퍼진 뒤 사람이 결정합니다.
처음 설치하는 사람에게 주는 링크
업데이트는 앱이 스스로 받지만, 앱이 아직 없는 사람에게는 받을 곳이 필요합니다. 프로젝트 소유자가 콘솔(프로젝트 → 설정 → 공개 다운로드 페이지)에서 켜면, 고른 컴포넌트의 최신 게시 릴리스에서 파일 하나(설치 파일이나 단일 exe)를 클라이언트 키 없이 받을 수 있습니다.
| 주소 | 용도 |
|---|---|
https://update.kimhwan.kr/d/{project} |
다운로드 페이지 (버전·크기·SHA-256·다운로드 버튼) — 사람에게 주는 링크 |
https://files.update.kimhwan.kr/d/{project}/download |
파일 직접 받기 (항상 최신, Range 지원) — README·스크립트용 |
- 켜는 것은 사람만 할 수 있습니다(다운로드 범위를 넓히는 일이라 토큰으로는 불가). 필요하면 사람에게 켜 달라고 하고, 어떤 컴포넌트·파일을 고를지 알려 주세요.
- 상태는
GET /api/v1/manage/projects/{p}의publicDownload(enabled, component, channel, pageUrl, downloadUrl, version, file)로 확인합니다. - 설치 파일을 업데이트 파일과 따로 두려면
installer같은 컴포넌트를 만들어 CI 가 거기에도 게시하게 하세요. 새 버전을 게시하면 페이지도 자동으로 바뀝니다. - 저장소 README 에는 다운로드 페이지 주소를 적으면 됩니다. 매니페스트·업데이트 파일·다른 컴포넌트는 계속 보호됩니다.
첫 릴리스로 확인
개인 토큰(releases 권한)으로도 업로드 API 를 쓸 수 있습니다. 확인용은 초안이나 beta 로:
curl -fsS -H "Expect:" -H @"$H" -F version=0.0.1-test -F channel=beta -F publish=true \
-F files=@dist/MyApp.exe -F paths=MyApp.exe \
https://files.update.kimhwan.kr/api/v1/projects/my-app/components/app/releases > /dev/null && echo uploaded
curl -fsS https://update.kimhwan.kr/api/v1/projects/my-app/manifest?channel=beta # 공개 프로젝트일 때
curl -fsS -H @"$H" "$API/manage/projects/my-app/components/app/releases" # 초안 포함 전체 목록
앱을 beta 채널로 돌려 업데이트가 받아지고 검증되는지 보세요. 끝나면 시험 릴리스를 지웁니다:
curl -fsS -X DELETE -H @"$H" https://files.update.kimhwan.kr/api/v1/projects/my-app/components/app/releases/0.0.1-test
마무리
curl -fsS -X DELETE -H @"$HOME/.config/update-hub/auth-header" https://update.kimhwan.kr/api/v1/auth/token && rm -f "$HOME/.config/update-hub/auth-header"
사람에게 보고할 것: 프로젝트 키와 컴포넌트, 업로드 토큰을 넣은 곳(시크릿 이름), CI 에 추가한 단계, 앱에 넣은 코드 위치, stable 게시 방법, 남겨 둔 시험 릴리스나 확인이 필요한 일.
API 레퍼런스
모든 오류는 {"error": "…"} JSON 입니다. 시간은 ISO-8601(UTC).
인증
| 요청 | 설명 |
|---|---|
POST /api/v1/auth/device/code |
form: client_name, scope(기본 projects releases) → device_code, user_code, verification_uri, verification_uri_complete, expires_in, interval |
POST /api/v1/auth/device/token |
form: device_code → 200 {access_token, token_type, scope, expires_at, token_id} 또는 400 {error} |
DELETE /api/v1/auth/token |
지금 쓰는 개인 토큰을 폐기 |
GET /api/v1/me |
토큰의 사용자·권한·만료, 서버 주소, 서명 공개키 |
관리 (개인 토큰: 읽기는 아무 권한, 쓰기는 projects)
| 요청 | 설명 |
|---|---|
GET /api/v1/manage/projects |
내가 관리하는 프로젝트 (관리자는 전체) |
POST /api/v1/manage/projects |
JSON {key, name, description} → 201 |
GET /api/v1/manage/projects/{p} |
컴포넌트(채널별 최신 버전), uploadToken, clientKey, uaPattern, urls |
PATCH /api/v1/manage/projects/{p} |
JSON {name?, description?, uaPattern?} ("" = 지우기) |
POST /api/v1/manage/projects/{p}/components |
JSON {key, name, description} → 201 |
POST /api/v1/manage/projects/{p}/upload-token |
{token} 한 번만. 이전 업로드 토큰은 즉시 무효 |
POST /api/v1/manage/projects/{p}/client-key |
{key, id, previousId?} 한 번만. 이전 키는 계속 유효 |
POST /api/v1/manage/projects/{p}/client-key/revoke-previous |
이전 클라이언트 키 폐기 |
GET /api/v1/manage/projects/{p}/components/{c}/releases |
초안·철회 포함 전체 릴리스 |
GET /api/v1/manage/projects/{p}/usage?days=30 |
사용 통계: 업데이트 확인·다운로드 시작/완료·전송량·고유 클라이언트(기간·전체), 일별 추이, 버전별, 많이 받은 클라이언트. days = 1 · 7 · 30 · 90 · 0(전체) |
GET /api/v1/manage/projects/{p}/clients?q=&sort=&active=&page=&size= |
클라이언트별 누적(확인·다운로드·전송량·가진 버전·마지막 IP). sort = bytes · downloads · checks · recent · first |
GET /api/v1/manage/projects/{p}/downloads?kind=&result=&component=&version=&client=&q=&from=&to=&page=&size= |
상세 로그(업데이트 확인·파일 응답 하나하나, 실제 전송 바이트·완료 여부). kind = file · check, result = ok · completed · partial · rejected, 날짜는 YYYY-MM-DD |
릴리스 쓰기 (업로드 토큰, 또는 releases 권한의 개인 토큰)
| 요청 | 설명 |
|---|---|
POST /api/v1/projects/{p}/components/{c}/releases |
multipart 업로드 (CI 참고) → 201 릴리스 |
POST /api/v1/projects/{p}/components/{c}/releases/{v}/publish |
서명·게시 (이 채널의 최신이 됨) |
POST /api/v1/projects/{p}/components/{c}/releases/{v}/revoke |
철회 (이전 게시본이 최신이 됨) |
DELETE /api/v1/projects/{p}/components/{c}/releases/{v} |
삭제 (파일 포함) |
POST /api/v1/projects/{p}/components |
form key, name, description (업로드 토큰 또는 projects 권한) |
앱이 읽는 API (인증 없음, 키 보호 프로젝트는 X-Update-Key)
| 요청 | 설명 |
|---|---|
GET /api/v1/projects/{p}/manifest?channel=stable |
모든 컴포넌트의 최신 릴리스 (latest: null = 게시 없음) |
GET /api/v1/projects/{p}/components/{c}/latest?channel=stable |
한 컴포넌트의 최신 (없으면 404) |
GET /api/v1/projects/{p}/components/{c}/releases?channel= |
게시된 릴리스 목록 |
GET /api/v1/projects/{p}/components/{c}/releases/{v} |
특정 릴리스 |
GET …/releases/{v}/files/{path}?exp=&sig= |
파일. Range 지원, X-Sha256 헤더 |
GET /api/v1/signing-key |
서명 공개키 + fingerprint |
문제 해결
| 상태 | 흔한 원인 |
|---|---|
401 |
토큰이 없거나 만료·폐기됨 → 다시 로그인. 앱 쪽이면 X-Update-Key 누락 |
403 |
토큰 권한(scope) 부족, 업로드 토큰을 관리 API 에 씀, User-Agent 패턴 불일치, 파일 링크 만료(매니페스트 다시 읽기) |
404 |
키 오타, 또는 내가 관리하지 않는 프로젝트(남의 프로젝트는 존재 여부도 보이지 않음) |
409 |
이미 있는 프로젝트·컴포넌트, 또는 이미 게시된 버전 |
413 |
업로드가 너무 큼 — https://update.kimhwan.kr 대신 https://files.update.kimhwan.kr 로 올리기 |
415 / 400 |
관리 API 에 Content-Type: application/json 누락, JSON 오류(Windows 에서 인자로 넘긴 한글 — stdin 으로), 잘못된 키·버전 형식 |
429 |
요청이 너무 많음 (IP 당 분당 API 120회, 파일 60회) — 잠시 뒤 다시 |