Files
hop-gate/internal/admin/service.go
dalbodeule 98aed77342 [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`.
2025-12-02 20:35:45 +09:00

240 lines
6.7 KiB
Go

package admin
import (
"context"
"crypto/rand"
"encoding/hex"
"errors"
"fmt"
"strings"
"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자)를 생성해 반환합니다.
RegisterDomain(ctx context.Context, domain, memo string) (clientAPIKey string, err error)
// 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")
)