mirror of
https://github.com/dalbodeule/hop-gate.git
synced 2025-12-07 20:35:44 +09:00
[feat](server): add ACME standalone-only mode for certificate management
- Introduced `HOP_ACME_STANDALONE_ONLY` env variable to run the ACME client without starting HTTP/DTLS servers. - Allows certificate issuance/renewal solely and exits upon completion. - Includes initialization of the ACME manager with domain verification, certificate management, and caching mechanisms. DomainService and expand Admin API - Added `DomainServiceImpl` with support for registering, unregistering, and querying domains. - Expanded Admin API with new endpoints: - `GET /api/v1/admin/domains/exists` to check domain registration status. - `GET /api/v1/admin/domains/status` to retrieve detailed domain information. - Updated server initialization to wire `DomainService` and Admin API routes. - Documented new Admin API endpoints in `API.md`.
This commit is contained in:
485
API.md
Normal file
485
API.md
Normal file
@@ -0,0 +1,485 @@
|
||||
# HopGate API Reference / HopGate API 명세
|
||||
|
||||
This document describes the externally visible APIs currently implemented in HopGate, with English as the primary language and Korean descriptions in parallel.
|
||||
이 문서는 현재 HopGate에 구현된 외부 공개 API를 정리한 것으로, 영어를 기본으로 하며 한국어 설명을 병기합니다.
|
||||
|
||||
---
|
||||
|
||||
## 1. Admin Plane HTTP API / 관리 Plane HTTP API
|
||||
|
||||
The admin plane is exposed under the HTTPS endpoint of the HopGate server.
|
||||
관리 Plane은 HopGate 서버의 HTTPS 엔드포인트 아래에서 동작합니다.
|
||||
|
||||
- Base URL: `https://{HOP_SERVER_DOMAIN}/api/v1/admin`
|
||||
기본 URL: `https://{HOP_SERVER_DOMAIN}/api/v1/admin`
|
||||
- Implementation: [`internal/admin/http.go`](internal/admin/http.go)
|
||||
구현 위치: [`internal/admin/http.go`](internal/admin/http.go)
|
||||
- Wired into server main: [`cmd/server/main.go`](cmd/server/main.go)
|
||||
서버 메인에서의 연결: [`cmd/server/main.go`](cmd/server/main.go)
|
||||
|
||||
### 1.1 Authentication / 인증
|
||||
|
||||
- Header: `Authorization: Bearer {HOP_ADMIN_API_KEY}`
|
||||
헤더: `Authorization: Bearer {HOP_ADMIN_API_KEY}`
|
||||
- Env var: `HOP_ADMIN_API_KEY`
|
||||
환경 변수: `HOP_ADMIN_API_KEY`
|
||||
- If the key is missing or incorrect, the API responds with `401 Unauthorized`.
|
||||
키가 없거나 값이 올바르지 않으면 `401 Unauthorized` 로 응답합니다.
|
||||
|
||||
### 1.2 Domain Register API / 도메인 등록 API
|
||||
|
||||
- Method: `POST`
|
||||
메서드: `POST`
|
||||
- Path: `/api/v1/admin/domains/register`
|
||||
경로: `/api/v1/admin/domains/register`
|
||||
- Purpose: Register a new domain and issue a 64-character client API key bound to that domain.
|
||||
목적: 새로운 도메인을 등록하고 해당 도메인에 매핑된 64자 클라이언트 API 키를 발급합니다.
|
||||
|
||||
#### 1.2.1 Request / 요청
|
||||
|
||||
- Content-Type: `application/json`
|
||||
- Body:
|
||||
|
||||
```json
|
||||
{
|
||||
"domain": "app.example.com",
|
||||
"memo": "my staging app"
|
||||
}
|
||||
```
|
||||
|
||||
- Fields
|
||||
필드
|
||||
|
||||
- `domain` (string, required)
|
||||
- FQDN, must contain at least one dot, case-insensitive.
|
||||
- 공백이 없어야 하며, 최소 한 개 이상의 점(`.`)을 포함하는 FQDN이어야 합니다.
|
||||
- `memo` (string, optional)
|
||||
- Free-form memo for administrators; may be empty.
|
||||
- 관리자를 위한 자유 형식 메모로, 비어 있어도 됩니다.
|
||||
|
||||
#### 1.2.2 Successful Response / 성공 응답
|
||||
|
||||
- Status: `200 OK`
|
||||
- Body:
|
||||
|
||||
```json
|
||||
{
|
||||
"success": true,
|
||||
"client_api_key": "abcd1234...wxyz5678"
|
||||
}
|
||||
```
|
||||
|
||||
- Fields
|
||||
필드
|
||||
|
||||
- `success` (boolean) — always `true` on success.
|
||||
`success` (boolean) — 성공 시 항상 `true` 입니다.
|
||||
- `client_api_key` (string, length 64) — client API key bound to the registered domain.
|
||||
`client_api_key` (string, 길이 64) — 등록된 도메인에 매핑된 클라이언트 API 키입니다.
|
||||
|
||||
#### 1.2.3 Error Responses / 에러 응답
|
||||
|
||||
- `400 Bad Request`
|
||||
- Invalid JSON body or missing/empty `domain`.
|
||||
- JSON 바디가 잘못되었거나 `domain` 이 비어 있는 경우.
|
||||
- Body:
|
||||
|
||||
```json
|
||||
{
|
||||
"success": false,
|
||||
"error": "invalid request body"
|
||||
}
|
||||
```
|
||||
|
||||
or
|
||||
|
||||
```json
|
||||
{
|
||||
"success": false,
|
||||
"error": "domain is required"
|
||||
}
|
||||
```
|
||||
|
||||
- `401 Unauthorized`
|
||||
- Missing or invalid `Authorization` header.
|
||||
- `Authorization` 헤더가 없거나 잘못된 경우.
|
||||
- Body:
|
||||
|
||||
```json
|
||||
{
|
||||
"success": false,
|
||||
"error": "unauthorized"
|
||||
}
|
||||
```
|
||||
|
||||
- `500 Internal Server Error`
|
||||
- Database or internal logic error while registering domain.
|
||||
- 도메인 등록 처리 중 데이터베이스 또는 내부 로직 에러가 발생한 경우.
|
||||
- Body:
|
||||
|
||||
```json
|
||||
{
|
||||
"success": false,
|
||||
"error": "internal error"
|
||||
}
|
||||
```
|
||||
|
||||
### 1.3 Domain Unregister API / 도메인 해제 API
|
||||
|
||||
- Method: `POST`
|
||||
메서드: `POST`
|
||||
- Path: `/api/v1/admin/domains/unregister`
|
||||
경로: `/api/v1/admin/domains/unregister`
|
||||
- Purpose: Unregister a domain using the `(domain, client_api_key)` pair.
|
||||
목적: `(domain, client_api_key)` 조합을 사용해 도메인 등록을 해제합니다.
|
||||
|
||||
#### 1.3.1 Request / 요청
|
||||
|
||||
- Content-Type: `application/json`
|
||||
- Body:
|
||||
|
||||
```json
|
||||
{
|
||||
"domain": "app.example.com",
|
||||
"client_api_key": "abcd1234...wxyz5678"
|
||||
}
|
||||
```
|
||||
|
||||
- Fields
|
||||
필드
|
||||
|
||||
- `domain` (string, required)
|
||||
- Same normalization rule as the register API (lowercased, trimmed, FQDN-like).
|
||||
- 등록 API와 동일한 정규화 규칙(소문자, 공백 제거, FQDN 형태)을 따릅니다.
|
||||
- `client_api_key` (string, required)
|
||||
- Exact client API key previously issued for the domain.
|
||||
- 해당 도메인에 대해 이전에 발급된 클라이언트 API 키와 정확히 일치해야 합니다.
|
||||
|
||||
#### 1.3.2 Successful Response / 성공 응답
|
||||
|
||||
- Status: `200 OK`
|
||||
- Body:
|
||||
|
||||
```json
|
||||
{
|
||||
"success": true
|
||||
}
|
||||
```
|
||||
|
||||
- `success` (boolean) — `true` if the domain was found and deleted.
|
||||
`success` (boolean) — 해당 도메인이 존재했고 삭제되었을 때 `true` 입니다.
|
||||
|
||||
#### 1.3.3 Error Responses / 에러 응답
|
||||
|
||||
- `400 Bad Request`
|
||||
- Invalid JSON body, or `domain` or `client_api_key` is missing/empty.
|
||||
- JSON 바디가 잘못되었거나 `domain` 혹은 `client_api_key` 가 비어 있는 경우.
|
||||
- Body:
|
||||
|
||||
```json
|
||||
{
|
||||
"success": false,
|
||||
"error": "invalid request body"
|
||||
}
|
||||
```
|
||||
|
||||
or
|
||||
|
||||
```json
|
||||
{
|
||||
"success": false,
|
||||
"error": "domain and client_api_key are required"
|
||||
}
|
||||
```
|
||||
|
||||
- `401 Unauthorized`
|
||||
- Missing or invalid `Authorization` header.
|
||||
- `Authorization` 헤더가 없거나 잘못된 경우.
|
||||
- Same JSON structure as in the register API.
|
||||
- JSON 응답 구조는 등록 API와 동일합니다.
|
||||
|
||||
- `500 Internal Server Error`
|
||||
- Internal error while unregistering or deleting the domain.
|
||||
- 도메인 해제/삭제 처리 중 내부 에러가 발생한 경우.
|
||||
- Body:
|
||||
|
||||
```json
|
||||
{
|
||||
"success": false,
|
||||
"error": "internal error"
|
||||
}
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 2. Public HTTPS Reverse Proxy Entry / 공개 HTTPS 프록시 엔트리
|
||||
|
||||
HopGate acts as an HTTPS reverse proxy, forwarding incoming HTTP(S) requests for registered domains over DTLS to connected clients.
|
||||
HopGate는 등록된 도메인에 대한 HTTP(S) 요청을 DTLS를 통해 클라이언트로 전달하는 HTTPS 리버스 프록시 역할을 합니다.
|
||||
|
||||
- Entry points:
|
||||
진입점:
|
||||
- `http://{HOP_SERVER_DOMAIN}/...`
|
||||
- `https://{HOP_SERVER_DOMAIN}/...`
|
||||
- Implementation: [`cmd/server/main.go`](cmd/server/main.go)
|
||||
구현 위치: [`cmd/server/main.go`](cmd/server/main.go)
|
||||
|
||||
Behavior summary:
|
||||
동작 요약:
|
||||
|
||||
- If the path starts with `/.well-known/acme-challenge/`, HopGate serves static ACME HTTP-01 challenge files from `HOP_ACME_WEBROOT`.
|
||||
경로가 `/.well-known/acme-challenge/` 로 시작하면 HopGate는 `HOP_ACME_WEBROOT` 디렉터리에서 ACME HTTP-01 챌린지 파일을 정적으로 서빙합니다.
|
||||
- For other paths, HopGate looks up an active DTLS session for the incoming `Host` and forwards the HTTP request over that session.
|
||||
그 외 경로에 대해서는 들어온 `Host` 에 해당하는 활성 DTLS 세션을 찾은 뒤, HTTP 요청을 해당 세션을 통해 포워딩합니다.
|
||||
- If no DTLS session is available for the host, the server responds with `502 Bad Gateway`.
|
||||
해당 호스트에 대한 DTLS 세션이 없으면 서버는 `502 Bad Gateway` 로 응답합니다.
|
||||
|
||||
The reverse-proxy behavior is not a separate REST API but the core behavior of the HopGate server.
|
||||
이 프록시 동작은 별도의 REST API라기보다는 HopGate 서버의 핵심 동작입니다.
|
||||
|
||||
---
|
||||
|
||||
## 3. DTLS Handshake Protocol / DTLS 핸드셰이크 프로토콜
|
||||
|
||||
The DTLS handshake between server and client uses a small JSON-based protocol to authenticate the `(domain, client_api_key)` pair before establishing the HTTP tunneling session.
|
||||
서버와 클라이언트 사이의 DTLS 핸드셰이크는 HTTP 터널링 세션을 열기 전 `(domain, client_api_key)` 조합을 인증하기 위해 간단한 JSON 기반 프로토콜을 사용합니다.
|
||||
|
||||
- Implementation: [`internal/dtls/handshake.go`](internal/dtls/handshake.go)
|
||||
구현 위치: [`internal/dtls/handshake.go`](internal/dtls/handshake.go)
|
||||
|
||||
### 3.1 Handshake Request / 핸드셰이크 요청
|
||||
|
||||
The client sends a JSON message over the DTLS session:
|
||||
클라이언트는 DTLS 세션 위로 다음과 같은 JSON 메시지를 전송합니다.
|
||||
|
||||
```json
|
||||
{
|
||||
"domain": "app.example.com",
|
||||
"client_api_key": "abcd1234...wxyz5678"
|
||||
}
|
||||
```
|
||||
|
||||
- `domain` and `client_api_key` must match a registered domain entry for the handshake to succeed.
|
||||
핸드셰이크가 성공하려면 `domain` 과 `client_api_key` 가 등록된 도메인 정보와 일치해야 합니다.
|
||||
|
||||
### 3.2 Handshake Response / 핸드셰이크 응답
|
||||
|
||||
The server responds with:
|
||||
서버는 다음과 같은 구조로 응답합니다.
|
||||
|
||||
```json
|
||||
{
|
||||
"ok": true,
|
||||
"message": "handshake ok",
|
||||
"domain": "app.example.com"
|
||||
}
|
||||
```
|
||||
|
||||
- On failure, `ok` is `false` and `message` contains a human-readable reason (e.g., `"invalid domain or api key"`).
|
||||
실패 시 `ok` 는 `false` 이며, `message` 에 `"invalid domain or api key"` 와 같은 사람이 읽을 수 있는 이유가 담깁니다.
|
||||
|
||||
A successful handshake registers the DTLS session for the given domain so that subsequent HTTPS requests for that domain can be tunneled through the session.
|
||||
핸드셰이크가 성공하면 해당 도메인에 대해 DTLS 세션이 등록되어, 이후 그 도메인으로 들어오는 HTTPS 요청이 이 세션을 통해 터널링될 수 있습니다.
|
||||
|
||||
---
|
||||
|
||||
## 4. Additional Admin Plane APIs / 추가 관리 Plane API
|
||||
|
||||
This section describes two helper admin APIs for checking whether a domain is registered and retrieving its detailed status.
|
||||
이 섹션은 도메인 등록 여부를 확인하고 상세 상태를 조회하기 위한 두 가지 관리용 API를 설명합니다.
|
||||
|
||||
Implementation references / 구현 위치:
|
||||
|
||||
- Admin HTTP handlers: [`internal/admin/http.go`](internal/admin/http.go:197)
|
||||
- Domain service methods: [`internal/admin/service.go`](internal/admin/service.go:129)
|
||||
|
||||
### 4.1 Check Domain Registration (exists) / 도메인 등록 여부 확인
|
||||
|
||||
- Method / 메서드: `GET`
|
||||
- Path / 경로: `/api/v1/admin/domains/exists`
|
||||
- Authentication / 인증:
|
||||
- Same as other admin APIs: `Authorization: Bearer {HOP_ADMIN_API_KEY}`
|
||||
다른 Admin API와 동일하게 `Authorization: Bearer {HOP_ADMIN_API_KEY}` 헤더 사용.
|
||||
- Purpose / 목적:
|
||||
- Check if a given domain is already registered in the `Domain` table.
|
||||
특정 도메인이 `Domain` 테이블에 이미 등록되어 있는지 확인합니다.
|
||||
|
||||
#### 4.1.1 Request / 요청
|
||||
|
||||
- Query Parameters / 쿼리 파라미터:
|
||||
- `domain` (string, required) — domain to check.
|
||||
`domain` (string, 필수) — 확인할 도메인.
|
||||
|
||||
- Example / 예시:
|
||||
|
||||
```http
|
||||
GET /api/v1/admin/domains/exists?domain=app.example.com HTTP/1.1
|
||||
Host: {HOP_SERVER_DOMAIN}
|
||||
Authorization: Bearer {HOP_ADMIN_API_KEY}
|
||||
```
|
||||
|
||||
#### 4.1.2 Successful Response / 성공 응답
|
||||
|
||||
- Status: `200 OK`
|
||||
- Body:
|
||||
|
||||
```json
|
||||
{
|
||||
"success": true,
|
||||
"exists": true
|
||||
}
|
||||
```
|
||||
|
||||
- Fields / 필드:
|
||||
- `success` (bool) — request processed successfully.
|
||||
요청이 정상 처리되었는지 여부.
|
||||
- `exists` (bool) — whether the domain is currently registered.
|
||||
도메인이 현재 등록되어 있는지 여부.
|
||||
|
||||
If the domain is not registered:
|
||||
도메인이 등록되어 있지 않으면:
|
||||
|
||||
```json
|
||||
{
|
||||
"success": true,
|
||||
"exists": false
|
||||
}
|
||||
```
|
||||
|
||||
#### 4.1.3 Error Responses / 에러 응답
|
||||
|
||||
- `400 Bad Request`
|
||||
- Missing or empty `domain` query parameter.
|
||||
`domain` 쿼리 파라미터가 없거나 비어 있는 경우.
|
||||
|
||||
```json
|
||||
{
|
||||
"success": false,
|
||||
"error": "domain is required"
|
||||
}
|
||||
```
|
||||
|
||||
- `401 Unauthorized`
|
||||
- Missing or invalid `Authorization` header.
|
||||
`Authorization` 헤더가 없거나 잘못된 경우.
|
||||
|
||||
```json
|
||||
{
|
||||
"success": false,
|
||||
"error": "unauthorized"
|
||||
}
|
||||
```
|
||||
|
||||
- `500 Internal Server Error`
|
||||
- Internal error while checking domain existence (e.g., DB error).
|
||||
도메인 존재 여부 확인 중 내부(DB 등) 에러가 발생한 경우.
|
||||
|
||||
```json
|
||||
{
|
||||
"success": false,
|
||||
"error": "internal error"
|
||||
}
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
### 4.2 Domain Status API / 도메인 상태 조회 API
|
||||
|
||||
- Method / 메서드: `GET`
|
||||
- Path / 경로: `/api/v1/admin/domains/status`
|
||||
- Authentication / 인증:
|
||||
- `Authorization: Bearer {HOP_ADMIN_API_KEY}`
|
||||
- Purpose / 목적:
|
||||
- Retrieve detailed information about a domain if registered, including memo and timestamps.
|
||||
도메인이 등록되어 있다면 메모, 생성/수정 시각 등 상세 정보를 조회합니다.
|
||||
|
||||
#### 4.2.1 Request / 요청
|
||||
|
||||
- Query Parameters / 쿼리 파라미터:
|
||||
- `domain` (string, required) — domain to inspect.
|
||||
`domain` (string, 필수) — 조회할 도메인.
|
||||
|
||||
- Example / 예시:
|
||||
|
||||
```http
|
||||
GET /api/v1/admin/domains/status?domain=app.example.com HTTP/1.1
|
||||
Host: {HOP_SERVER_DOMAIN}
|
||||
Authorization: Bearer {HOP_ADMIN_API_KEY}
|
||||
```
|
||||
|
||||
#### 4.2.2 Successful Response (exists) / 성공 응답 (도메인 존재 시)
|
||||
|
||||
- Status: `200 OK`
|
||||
- Body:
|
||||
|
||||
```json
|
||||
{
|
||||
"success": true,
|
||||
"exists": true,
|
||||
"domain": "app.example.com",
|
||||
"memo": "my staging app",
|
||||
"created_at": "2025-01-01T12:34:56Z",
|
||||
"updated_at": "2025-01-02T08:00:00Z"
|
||||
}
|
||||
```
|
||||
|
||||
- Fields / 필드:
|
||||
- `success` (bool) — request processed successfully.
|
||||
요청이 정상 처리되었는지 여부.
|
||||
- `exists` (bool) — **true** if the domain record exists.
|
||||
도메인 레코드가 존재하면 `true`.
|
||||
- `domain` (string) — normalized domain name.
|
||||
정규화된 도메인 이름.
|
||||
- `memo` (string) — administrator memo.
|
||||
관리자 메모.
|
||||
- `created_at` (string, RFC3339) — creation timestamp.
|
||||
생성 시각(RFC3339 문자열).
|
||||
- `updated_at` (string, RFC3339) — last update timestamp.
|
||||
마지막 수정 시각(RFC3339 문자열).
|
||||
|
||||
#### 4.2.3 Successful Response (not exists) / 성공 응답 (도메인 미존재 시)
|
||||
|
||||
If the domain is not found in the database:
|
||||
해당 도메인이 DB에 존재하지 않으면:
|
||||
|
||||
```json
|
||||
{
|
||||
"success": true,
|
||||
"exists": false
|
||||
}
|
||||
```
|
||||
|
||||
- No error; this is a normal “not registered” state.
|
||||
에러가 아니며, “등록되지 않음” 상태를 의미합니다.
|
||||
|
||||
#### 4.2.4 Error Responses / 에러 응답
|
||||
|
||||
- `400 Bad Request`
|
||||
- Missing or empty `domain` query parameter.
|
||||
|
||||
```json
|
||||
{
|
||||
"success": false,
|
||||
"error": "domain is required"
|
||||
}
|
||||
```
|
||||
|
||||
- `401 Unauthorized`
|
||||
- Missing or invalid `Authorization` header.
|
||||
|
||||
```json
|
||||
{
|
||||
"success": false,
|
||||
"error": "unauthorized"
|
||||
}
|
||||
```
|
||||
|
||||
- `500 Internal Server Error`
|
||||
- Internal error while fetching domain status.
|
||||
|
||||
```json
|
||||
{
|
||||
"success": false,
|
||||
"error": "internal error"
|
||||
}
|
||||
```
|
||||
@@ -18,6 +18,7 @@ import (
|
||||
"github.com/prometheus/client_golang/prometheus/promhttp"
|
||||
|
||||
"github.com/dalbodeule/hop-gate/internal/acme"
|
||||
"github.com/dalbodeule/hop-gate/internal/admin"
|
||||
"github.com/dalbodeule/hop-gate/internal/config"
|
||||
"github.com/dalbodeule/hop-gate/internal/dtls"
|
||||
"github.com/dalbodeule/hop-gate/internal/logging"
|
||||
@@ -444,6 +445,18 @@ func main() {
|
||||
"component": "store",
|
||||
})
|
||||
|
||||
// 3.1 Admin Plane: DomainService + Admin HTTP handler 구성
|
||||
adminService := admin.NewDomainService(logger, dbClient)
|
||||
|
||||
// Admin API 키는 환경변수에서 읽어옵니다.
|
||||
// - HOP_ADMIN_API_KEY 가 비어 있으면, 모든 Admin API 요청이 거부됩니다.
|
||||
adminAPIKey := strings.TrimSpace(os.Getenv("HOP_ADMIN_API_KEY"))
|
||||
if adminAPIKey == "" {
|
||||
logger.Warn("HOP_ADMIN_API_KEY is not set; admin API will reject all requests", logging.Fields{
|
||||
"component": "admin_api",
|
||||
})
|
||||
}
|
||||
|
||||
// 3. TLS 설정: ACME(lego)로 인증서를 관리하고, Debug 모드에서는 DTLS에는 self-signed 를 사용하되
|
||||
// ACME 는 항상 시도하되 Staging 모드로 동작하도록 합니다.
|
||||
// 3. TLS setup: manage certificates via ACME (lego); in debug mode DTLS uses self-signed
|
||||
@@ -582,8 +595,16 @@ func main() {
|
||||
allowedDomain := strings.ToLower(strings.TrimSpace(cfg.Domain))
|
||||
|
||||
// /metrics 는 HOP_SERVER_DOMAIN 에 지정된 도메인으로만 접근 가능하도록 제한합니다.
|
||||
// Admin Plane HTTP mux 도 이후 hostDomainHandler 를 통해 동일하게 제한해야 합니다.
|
||||
httpMux.Handle("/metrics", hostDomainHandler(allowedDomain, logger, promhttp.Handler()))
|
||||
|
||||
// Admin Plane HTTP mux: /api/v1/admin/* 경로를 처리합니다.
|
||||
// - Authorization: Bearer {HOP_ADMIN_API_KEY} 헤더를 사용해 인증합니다.
|
||||
adminHandler := admin.NewHandler(logger, adminAPIKey, adminService)
|
||||
adminMux := http.NewServeMux()
|
||||
adminHandler.RegisterRoutes(adminMux)
|
||||
httpMux.Handle("/api/v1/admin/", hostDomainHandler(allowedDomain, logger, adminMux))
|
||||
|
||||
// 기본 HTTP → DTLS Proxy 엔트리 포인트
|
||||
httpMux.Handle("/", httpHandler)
|
||||
|
||||
// HTTP: 평문 포트
|
||||
|
||||
@@ -27,9 +27,13 @@ func NewHandler(logger logging.Logger, adminAPIKey string, svc DomainService) *H
|
||||
// RegisterRoutes 는 전달받은 mux 에 관리 API 라우트를 등록합니다.
|
||||
// - POST /api/v1/admin/domains/register
|
||||
// - POST /api/v1/admin/domains/unregister
|
||||
// - GET /api/v1/admin/domains/exists
|
||||
// - GET /api/v1/admin/domains/status
|
||||
func (h *Handler) RegisterRoutes(mux *http.ServeMux) {
|
||||
mux.Handle("/api/v1/admin/domains/register", h.authMiddleware(http.HandlerFunc(h.handleDomainRegister)))
|
||||
mux.Handle("/api/v1/admin/domains/unregister", h.authMiddleware(http.HandlerFunc(h.handleDomainUnregister)))
|
||||
mux.Handle("/api/v1/admin/domains/exists", h.authMiddleware(http.HandlerFunc(h.handleDomainExists)))
|
||||
mux.Handle("/api/v1/admin/domains/status", h.authMiddleware(http.HandlerFunc(h.handleDomainStatus)))
|
||||
}
|
||||
|
||||
// authMiddleware 는 Authorization: Bearer {ADMIN_API_KEY} 헤더를 검증합니다.
|
||||
@@ -130,6 +134,22 @@ type domainUnregisterResponse struct {
|
||||
Error string `json:"error,omitempty"`
|
||||
}
|
||||
|
||||
type domainExistsResponse struct {
|
||||
Success bool `json:"success"`
|
||||
Exists bool `json:"exists"`
|
||||
Error string `json:"error,omitempty"`
|
||||
}
|
||||
|
||||
type domainStatusResponse struct {
|
||||
Success bool `json:"success"`
|
||||
Exists bool `json:"exists"`
|
||||
Domain string `json:"domain,omitempty"`
|
||||
Memo string `json:"memo,omitempty"`
|
||||
CreatedAt any `json:"created_at,omitempty"`
|
||||
UpdatedAt any `json:"updated_at,omitempty"`
|
||||
Error string `json:"error,omitempty"`
|
||||
}
|
||||
|
||||
func (h *Handler) handleDomainUnregister(w http.ResponseWriter, r *http.Request) {
|
||||
if r.Method != http.MethodPost {
|
||||
h.writeMethodNotAllowed(w, r)
|
||||
@@ -174,6 +194,86 @@ func (h *Handler) handleDomainUnregister(w http.ResponseWriter, r *http.Request)
|
||||
})
|
||||
}
|
||||
|
||||
func (h *Handler) handleDomainExists(w http.ResponseWriter, r *http.Request) {
|
||||
if r.Method != http.MethodGet {
|
||||
h.writeMethodNotAllowed(w, r)
|
||||
return
|
||||
}
|
||||
|
||||
domain := strings.TrimSpace(r.URL.Query().Get("domain"))
|
||||
if domain == "" {
|
||||
h.writeJSON(w, http.StatusBadRequest, domainExistsResponse{
|
||||
Success: false,
|
||||
Error: "domain is required",
|
||||
})
|
||||
return
|
||||
}
|
||||
|
||||
exists, err := h.Service.IsDomainRegistered(r.Context(), domain)
|
||||
if err != nil {
|
||||
h.Logger.Error("failed to check domain existence", logging.Fields{
|
||||
"domain": domain,
|
||||
"error": err.Error(),
|
||||
})
|
||||
h.writeJSON(w, http.StatusInternalServerError, domainExistsResponse{
|
||||
Success: false,
|
||||
Error: "internal error",
|
||||
})
|
||||
return
|
||||
}
|
||||
|
||||
h.writeJSON(w, http.StatusOK, domainExistsResponse{
|
||||
Success: true,
|
||||
Exists: exists,
|
||||
})
|
||||
}
|
||||
|
||||
func (h *Handler) handleDomainStatus(w http.ResponseWriter, r *http.Request) {
|
||||
if r.Method != http.MethodGet {
|
||||
h.writeMethodNotAllowed(w, r)
|
||||
return
|
||||
}
|
||||
|
||||
domain := strings.TrimSpace(r.URL.Query().Get("domain"))
|
||||
if domain == "" {
|
||||
h.writeJSON(w, http.StatusBadRequest, domainStatusResponse{
|
||||
Success: false,
|
||||
Error: "domain is required",
|
||||
})
|
||||
return
|
||||
}
|
||||
|
||||
row, err := h.Service.GetDomain(r.Context(), domain)
|
||||
if err != nil {
|
||||
if err == ErrDomainNotFound {
|
||||
h.writeJSON(w, http.StatusOK, domainStatusResponse{
|
||||
Success: true,
|
||||
Exists: false,
|
||||
})
|
||||
return
|
||||
}
|
||||
|
||||
h.Logger.Error("failed to get domain status", logging.Fields{
|
||||
"domain": domain,
|
||||
"error": err.Error(),
|
||||
})
|
||||
h.writeJSON(w, http.StatusInternalServerError, domainStatusResponse{
|
||||
Success: false,
|
||||
Error: "internal error",
|
||||
})
|
||||
return
|
||||
}
|
||||
|
||||
h.writeJSON(w, http.StatusOK, domainStatusResponse{
|
||||
Success: true,
|
||||
Exists: true,
|
||||
Domain: row.Domain,
|
||||
Memo: row.Memo,
|
||||
CreatedAt: row.CreatedAt,
|
||||
UpdatedAt: row.UpdatedAt,
|
||||
})
|
||||
}
|
||||
|
||||
func (h *Handler) writeMethodNotAllowed(w http.ResponseWriter, r *http.Request) {
|
||||
h.writeJSON(w, http.StatusMethodNotAllowed, map[string]any{
|
||||
"success": false,
|
||||
|
||||
@@ -1,8 +1,19 @@
|
||||
package admin
|
||||
|
||||
import "context"
|
||||
import (
|
||||
"context"
|
||||
"crypto/rand"
|
||||
"encoding/hex"
|
||||
"errors"
|
||||
"fmt"
|
||||
"strings"
|
||||
|
||||
// DomainService 는 도메인 등록/해제를 담당하는 비즈니스 로직 인터페이스입니다.
|
||||
"github.com/dalbodeule/hop-gate/ent"
|
||||
entdomain "github.com/dalbodeule/hop-gate/ent/domain"
|
||||
"github.com/dalbodeule/hop-gate/internal/logging"
|
||||
)
|
||||
|
||||
// DomainService 는 도메인 등록/해제 및 조회를 담당하는 비즈니스 로직 인터페이스입니다.
|
||||
// 실제 구현에서는 ent.Client(PostgreSQL)를 주입받아 동작하게 됩니다.
|
||||
type DomainService interface {
|
||||
// RegisterDomain 은 새로운 도메인을 등록하고, 해당 도메인을 사용할 클라이언트 API Key(랜덤 64자)를 생성해 반환합니다.
|
||||
@@ -10,4 +21,219 @@ type DomainService interface {
|
||||
|
||||
// UnregisterDomain 은 도메인과 클라이언트 API Key를 함께 받아 등록을 해제합니다.
|
||||
UnregisterDomain(ctx context.Context, domain, clientAPIKey string) error
|
||||
|
||||
// IsDomainRegistered 는 주어진 도메인이 이미 등록되어 있는지 여부를 반환합니다.
|
||||
IsDomainRegistered(ctx context.Context, domain string) (bool, error)
|
||||
|
||||
// GetDomain 은 주어진 도메인에 대한 전체 엔티티 정보를 반환합니다.
|
||||
// 존재하지 않으면 ErrDomainNotFound 를 반환합니다.
|
||||
GetDomain(ctx context.Context, domain string) (*ent.Domain, error)
|
||||
}
|
||||
|
||||
// DomainServiceImpl 는 ent.Client 를 사용해 DomainService 를 구현한 구조체입니다.
|
||||
type DomainServiceImpl struct {
|
||||
logger logging.Logger
|
||||
client *ent.Client
|
||||
}
|
||||
|
||||
// NewDomainService 는 기본 DomainService 구현체를 생성합니다.
|
||||
func NewDomainService(logger logging.Logger, client *ent.Client) DomainService {
|
||||
return &DomainServiceImpl{
|
||||
logger: logger.With(logging.Fields{"component": "domain_service"}),
|
||||
client: client,
|
||||
}
|
||||
}
|
||||
|
||||
// RegisterDomain 은 새 도메인을 등록하고, 랜덤 64자 Client API Key 를 생성해 반환합니다.
|
||||
func (s *DomainServiceImpl) RegisterDomain(ctx context.Context, domain, memo string) (string, error) {
|
||||
d := normalizeDomain(domain)
|
||||
if d == "" {
|
||||
return "", ErrInvalidDomain
|
||||
}
|
||||
|
||||
if ctx == nil {
|
||||
ctx = context.Background()
|
||||
}
|
||||
|
||||
apiKey, err := generateClientAPIKey(64)
|
||||
if err != nil {
|
||||
return "", fmt.Errorf("generate client api key: %w", err)
|
||||
}
|
||||
|
||||
// ent schema 에서 memo 는 빈 문자열 허용
|
||||
if memo == "" {
|
||||
memo = ""
|
||||
}
|
||||
|
||||
_, err = s.client.Domain.Create().
|
||||
SetDomain(d).
|
||||
SetClientAPIKey(apiKey).
|
||||
SetMemo(memo).
|
||||
Save(ctx)
|
||||
if err != nil {
|
||||
s.logger.Error("failed to register domain", logging.Fields{
|
||||
"domain": d,
|
||||
"error": err.Error(),
|
||||
})
|
||||
return "", fmt.Errorf("register domain: %w", err)
|
||||
}
|
||||
|
||||
s.logger.Info("domain registered", logging.Fields{
|
||||
"domain": d,
|
||||
"client_api_key_masked": maskKey(apiKey),
|
||||
})
|
||||
|
||||
return apiKey, nil
|
||||
}
|
||||
|
||||
// UnregisterDomain 은 (domain, client_api_key) 조합이 일치하는 레코드를 삭제합니다.
|
||||
func (s *DomainServiceImpl) UnregisterDomain(ctx context.Context, domain, clientAPIKey string) error {
|
||||
d := normalizeDomain(domain)
|
||||
if d == "" {
|
||||
return ErrInvalidDomain
|
||||
}
|
||||
key := strings.TrimSpace(clientAPIKey)
|
||||
if key == "" {
|
||||
return ErrInvalidClientAPIKey
|
||||
}
|
||||
|
||||
if ctx == nil {
|
||||
ctx = context.Background()
|
||||
}
|
||||
|
||||
del := s.client.Domain.Delete().
|
||||
Where(
|
||||
entdomain.DomainEQ(d),
|
||||
entdomain.ClientAPIKeyEQ(key),
|
||||
)
|
||||
|
||||
n, err := del.Exec(ctx)
|
||||
if err != nil {
|
||||
s.logger.Error("failed to unregister domain", logging.Fields{
|
||||
"domain": d,
|
||||
"error": err.Error(),
|
||||
})
|
||||
return fmt.Errorf("unregister domain: %w", err)
|
||||
}
|
||||
if n == 0 {
|
||||
return ErrDomainNotFound
|
||||
}
|
||||
|
||||
s.logger.Info("domain unregistered", logging.Fields{
|
||||
"domain": d,
|
||||
"client_api_key_masked": maskKey(key),
|
||||
})
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
// IsDomainRegistered 는 주어진 도메인이 이미 등록되어 있는지 여부를 반환합니다.
|
||||
func (s *DomainServiceImpl) IsDomainRegistered(ctx context.Context, domain string) (bool, error) {
|
||||
d := normalizeDomain(domain)
|
||||
if d == "" {
|
||||
return false, ErrInvalidDomain
|
||||
}
|
||||
|
||||
if ctx == nil {
|
||||
ctx = context.Background()
|
||||
}
|
||||
|
||||
cnt, err := s.client.Domain.Query().
|
||||
Where(entdomain.DomainEQ(d)).
|
||||
Count(ctx)
|
||||
if err != nil {
|
||||
s.logger.Error("failed to check domain existence", logging.Fields{
|
||||
"domain": d,
|
||||
"error": err.Error(),
|
||||
})
|
||||
return false, fmt.Errorf("check domain existence: %w", err)
|
||||
}
|
||||
return cnt > 0, nil
|
||||
}
|
||||
|
||||
// GetDomain 은 주어진 도메인에 대한 전체 엔티티 정보를 반환합니다.
|
||||
// 존재하지 않으면 ErrDomainNotFound 를 반환합니다.
|
||||
func (s *DomainServiceImpl) GetDomain(ctx context.Context, domain string) (*ent.Domain, error) {
|
||||
d := normalizeDomain(domain)
|
||||
if d == "" {
|
||||
return nil, ErrInvalidDomain
|
||||
}
|
||||
|
||||
if ctx == nil {
|
||||
ctx = context.Background()
|
||||
}
|
||||
|
||||
row, err := s.client.Domain.Query().
|
||||
Where(entdomain.DomainEQ(d)).
|
||||
Only(ctx)
|
||||
if err != nil {
|
||||
if ent.IsNotFound(err) {
|
||||
return nil, ErrDomainNotFound
|
||||
}
|
||||
s.logger.Error("failed to get domain", logging.Fields{
|
||||
"domain": d,
|
||||
"error": err.Error(),
|
||||
})
|
||||
return nil, fmt.Errorf("get domain: %w", err)
|
||||
}
|
||||
return row, nil
|
||||
}
|
||||
|
||||
// generateClientAPIKey 는 랜덤 바이트를 생성하여 hex 문자열로 인코딩합니다.
|
||||
func generateClientAPIKey(length int) (string, error) {
|
||||
if length <= 0 {
|
||||
return "", fmt.Errorf("invalid key length: %d", length)
|
||||
}
|
||||
|
||||
// hex 인코딩 결과 길이가 length 이상이 되도록 필요한 바이트 수 계산
|
||||
byteLen := (length + 1) / 2
|
||||
b := make([]byte, byteLen)
|
||||
if _, err := rand.Read(b); err != nil {
|
||||
return "", err
|
||||
}
|
||||
s := hex.EncodeToString(b)
|
||||
if len(s) > length {
|
||||
s = s[:length]
|
||||
}
|
||||
return s, nil
|
||||
}
|
||||
|
||||
// normalizeDomain 은 도메인 문자열을 소문자/공백 트리밍하고, 간단한 형식을 검증합니다.
|
||||
func normalizeDomain(d string) string {
|
||||
d = strings.ToLower(strings.TrimSpace(d))
|
||||
if d == "" {
|
||||
return ""
|
||||
}
|
||||
// 매우 단순한 FQDN 검증: 점(.) 포함 및 공백 없음만 확인.
|
||||
if !strings.Contains(d, ".") {
|
||||
return ""
|
||||
}
|
||||
if strings.ContainsAny(d, " \t\r\n") {
|
||||
return ""
|
||||
}
|
||||
return d
|
||||
}
|
||||
|
||||
// maskKey 는 로그 등에 사용할 수 있도록 API 키를 마스킹합니다.
|
||||
func maskKey(key string) string {
|
||||
key = strings.TrimSpace(key)
|
||||
if len(key) <= 8 {
|
||||
if key == "" {
|
||||
return ""
|
||||
}
|
||||
return "***"
|
||||
}
|
||||
return key[:4] + "..." + key[len(key)-4:]
|
||||
}
|
||||
|
||||
// 에러 타입 정의 (추후 DomainValidator 구현에서도 재사용 가능).
|
||||
var (
|
||||
// ErrInvalidDomain 은 도메인 문자열이 비어있거나 형식이 잘못된 경우를 나타냅니다.
|
||||
ErrInvalidDomain = errors.New("invalid domain")
|
||||
|
||||
// ErrInvalidClientAPIKey 는 client_api_key 가 비어있는 경우를 나타냅니다.
|
||||
ErrInvalidClientAPIKey = errors.New("invalid client api key")
|
||||
|
||||
// ErrDomainNotFound 는 (domain, client_api_key) 조합에 해당하는 레코드가 없는 경우를 나타냅니다.
|
||||
ErrDomainNotFound = errors.New("domain not found")
|
||||
)
|
||||
|
||||
@@ -157,15 +157,15 @@ This document tracks implementation progress against the HopGate architecture an
|
||||
|
||||
### 3.1 Admin Plane Implementation / 관리 Plane 구현
|
||||
|
||||
- [ ] DomainService 실제 구현 추가: [`internal/admin/service.go`](internal/admin/service.go)
|
||||
- [x] DomainService 실제 구현 추가: [`internal/admin/service.go`](internal/admin/service.go)
|
||||
- ent.Client + PostgreSQL 기반 `RegisterDomain` / `UnregisterDomain` 구현.
|
||||
- domain + client_api_key 유효성 검증 로직 포함.
|
||||
|
||||
- [ ] Admin API와 서버 라우터 연결: [`cmd/server/main.go`](cmd/server/main.go)
|
||||
- [x] Admin API와 서버 라우터 연결: [`cmd/server/main.go`](cmd/server/main.go)
|
||||
- `http.ServeMux` 혹은 router에 `admin.Handler.RegisterRoutes` 연결.
|
||||
- Admin API용 HTTP/HTTPS 엔드포인트 구성.
|
||||
|
||||
- [ ] Admin API 키 관리
|
||||
- [x] Admin API 키 관리
|
||||
- env 혹은 설정에 `ADMIN_API_KEY` 추가 및 로딩.
|
||||
- Admin Handler에 주입.
|
||||
|
||||
|
||||
Reference in New Issue
Block a user