Merge pull request #4 from dalbodeule/develop

[feat](server): add ACME standalone-only mode for certificate management
This commit is contained in:
JinU Choi
2025-12-02 20:53:19 +09:00
committed by GitHub
5 changed files with 839 additions and 6 deletions

485
API.md Normal file
View 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"
}
```

View File

@@ -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: 평문 포트

View File

@@ -4,6 +4,7 @@ import (
"encoding/json"
"net/http"
"strings"
"time"
"github.com/dalbodeule/hop-gate/internal/logging"
)
@@ -27,9 +28,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 +135,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 time.Time `json:"created_at,omitempty"`
UpdatedAt time.Time `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 +195,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,

View File

@@ -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")
)

View File

@@ -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에 주입.