mirror of
https://github.com/dalbodeule/hop-gate.git
synced 2025-12-07 20:35:44 +09:00
- 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`.
240 lines
6.7 KiB
Go
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")
|
|
)
|