mirror of
https://github.com/dalbodeule/hop-gate.git
synced 2025-12-08 04:45:43 +09:00
[feat](server): enhance DTLS handshake with DNS/IP-based domain validation
- Added `canonicalizeDomainForDNS` to normalize domain strings for DNS and DB lookups. - Implemented `domainGateValidator` to verify if client-provided domains resolve to expected IPs (`HOP_ACME_EXPECT_IPS`) using A/AAAA DNS queries. - Included a fallback for DB-only validation if `HOP_ACME_EXPECT_IPS` is unset or empty. - Updated `parseExpectedIPsFromEnv` to parse and validate IP lists from environment variables. - Marked relevant handshake enhancements in `progress.md` as completed.
This commit is contained in:
@@ -34,31 +34,128 @@ type dtlsSessionWrapper struct {
|
|||||||
mu sync.Mutex
|
mu sync.Mutex
|
||||||
}
|
}
|
||||||
|
|
||||||
// domainGateValidator 는 DTLS 핸드셰이크에서 허용된 도메인(HOP_SERVER_DOMAIN)만 통과시키기 위한 래퍼입니다. (ko)
|
// canonicalizeDomainForDNS 는 DTLS 핸드셰이크에서 전달된 도메인 문자열을
|
||||||
// domainGateValidator wraps another DomainValidator and allows only the configured HOP_SERVER_DOMAIN. (en)
|
// DNS 조회 및 DB 조회에 사용할 수 있는 정규화된 호스트명으로 변환합니다. (ko)
|
||||||
|
// canonicalizeDomainForDNS normalizes the domain string from the DTLS handshake
|
||||||
|
// into a host name suitable for DNS and DB lookups. (en)
|
||||||
|
func canonicalizeDomainForDNS(raw string) string {
|
||||||
|
d := strings.TrimSpace(raw)
|
||||||
|
if d == "" {
|
||||||
|
return ""
|
||||||
|
}
|
||||||
|
// "host:port" 형태가 들어온 경우 포트를 제거합니다. (ko)
|
||||||
|
// Strip port if the value is in "host:port" form. (en)
|
||||||
|
if h, _, err := net.SplitHostPort(d); err == nil && strings.TrimSpace(h) != "" {
|
||||||
|
d = h
|
||||||
|
}
|
||||||
|
return strings.ToLower(d)
|
||||||
|
}
|
||||||
|
|
||||||
|
// domainGateValidator 는 DTLS 핸드셰이크 시 도메인이 EXPECT_IPS(HOP_ACME_EXPECT_IPS)에
|
||||||
|
// 설정된 IP(IPv4/IPv6)로 해석되는지 검사한 뒤, 내부 DomainValidator 로 위임합니다. (ko)
|
||||||
|
// domainGateValidator first checks that the domain resolves to one of the
|
||||||
|
// expected IPs (from HOP_ACME_EXPECT_IPS), then delegates to the inner
|
||||||
|
// DomainValidator for (domain, client_api_key) validation. (en)
|
||||||
type domainGateValidator struct {
|
type domainGateValidator struct {
|
||||||
allowed string
|
expectedIPs []net.IP
|
||||||
inner dtls.DomainValidator
|
inner dtls.DomainValidator
|
||||||
logger logging.Logger
|
logger logging.Logger
|
||||||
}
|
}
|
||||||
|
|
||||||
func (v *domainGateValidator) ValidateDomainAPIKey(ctx context.Context, domain, clientAPIKey string) error {
|
func (v *domainGateValidator) ValidateDomainAPIKey(ctx context.Context, domain, clientAPIKey string) error {
|
||||||
d := strings.ToLower(strings.TrimSpace(domain))
|
d := canonicalizeDomainForDNS(domain)
|
||||||
if v.allowed != "" && d != v.allowed {
|
if d == "" {
|
||||||
if v.logger != nil {
|
return fmt.Errorf("empty domain is not allowed for dtls handshake")
|
||||||
v.logger.Warn("dtls handshake rejected due to mismatched domain", logging.Fields{
|
|
||||||
"expected_domain": v.allowed,
|
|
||||||
"received_domain": d,
|
|
||||||
})
|
|
||||||
}
|
|
||||||
return fmt.Errorf("domain %s is not allowed for dtls handshake", domain)
|
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// EXPECT_IPS(HOP_ACME_EXPECT_IPS)가 설정된 경우, 도메인이 해당 IP(IPv4/IPv6)들로
|
||||||
|
// 해석되는지 DNS(A/AAAA) 조회를 통해 검증합니다. (ko)
|
||||||
|
// If EXPECT_IPS (HOP_ACME_EXPECT_IPS) is configured, ensure that the domain
|
||||||
|
// resolves (via A/AAAA) to at least one of the expected IPs. (en)
|
||||||
|
if len(v.expectedIPs) > 0 {
|
||||||
|
resolver := net.DefaultResolver
|
||||||
|
if ctx == nil {
|
||||||
|
ctx = context.Background()
|
||||||
|
}
|
||||||
|
ips, err := resolver.LookupIP(ctx, "ip", d)
|
||||||
|
if err != nil {
|
||||||
|
if v.logger != nil {
|
||||||
|
v.logger.Warn("dtls handshake dns resolution failed", logging.Fields{
|
||||||
|
"domain": d,
|
||||||
|
"error": err.Error(),
|
||||||
|
})
|
||||||
|
}
|
||||||
|
return fmt.Errorf("dns resolution failed for %s: %w", d, err)
|
||||||
|
}
|
||||||
|
|
||||||
|
match := false
|
||||||
|
for _, ip := range ips {
|
||||||
|
for _, expected := range v.expectedIPs {
|
||||||
|
if ip.Equal(expected) {
|
||||||
|
match = true
|
||||||
|
break
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if match {
|
||||||
|
break
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if !match {
|
||||||
|
if v.logger != nil {
|
||||||
|
v.logger.Warn("dtls handshake rejected due to unexpected resolved IPs", logging.Fields{
|
||||||
|
"domain": d,
|
||||||
|
"resolved_ips": ips,
|
||||||
|
"expected_ips": v.expectedIPs,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
return fmt.Errorf("domain %s does not resolve to any expected IPs", d)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
if v.inner != nil {
|
if v.inner != nil {
|
||||||
return v.inner.ValidateDomainAPIKey(ctx, domain, clientAPIKey)
|
return v.inner.ValidateDomainAPIKey(ctx, d, clientAPIKey)
|
||||||
}
|
}
|
||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// parseExpectedIPsFromEnv 는 HOP_ACME_EXPECT_IPS 와 같이 콤마로 구분된 IP 목록
|
||||||
|
// 환경변수를 파싱해 net.IP 슬라이스로 변환합니다. IPv4/IPv6 모두 지원합니다. (ko)
|
||||||
|
// parseExpectedIPsFromEnv parses a comma-separated list of IPs from env (e.g. HOP_ACME_EXPECT_IPS)
|
||||||
|
// into a slice of net.IP, supporting both IPv4 and IPv6 literals. (en)
|
||||||
|
func parseExpectedIPsFromEnv(logger logging.Logger, envKey string) []net.IP {
|
||||||
|
raw := strings.TrimSpace(os.Getenv(envKey))
|
||||||
|
if raw == "" {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
parts := strings.Split(raw, ",")
|
||||||
|
var result []net.IP
|
||||||
|
for _, p := range parts {
|
||||||
|
p = strings.TrimSpace(p)
|
||||||
|
if p == "" {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
ip := net.ParseIP(p)
|
||||||
|
if ip == nil {
|
||||||
|
if logger != nil {
|
||||||
|
logger.Warn("invalid ip in env, skipping", logging.Fields{
|
||||||
|
"env": envKey,
|
||||||
|
"value": p,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
result = append(result, ip)
|
||||||
|
}
|
||||||
|
if logger != nil {
|
||||||
|
logger.Info("loaded expected handshake ips from env", logging.Fields{
|
||||||
|
"env": envKey,
|
||||||
|
"ips": result,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
return result
|
||||||
|
}
|
||||||
|
|
||||||
// ForwardHTTP 는 단일 HTTP 요청을 DTLS 세션으로 포워딩하고 응답을 돌려받습니다.
|
// ForwardHTTP 는 단일 HTTP 요청을 DTLS 세션으로 포워딩하고 응답을 돌려받습니다.
|
||||||
// ForwardHTTP forwards a single HTTP request over the DTLS session and returns the response.
|
// ForwardHTTP forwards a single HTTP request over the DTLS session and returns the response.
|
||||||
func (w *dtlsSessionWrapper) ForwardHTTP(ctx context.Context, logger logging.Logger, req *http.Request, serviceName string) (*protocol.Response, error) {
|
func (w *dtlsSessionWrapper) ForwardHTTP(ctx context.Context, logger logging.Logger, req *http.Request, serviceName string) (*protocol.Response, error) {
|
||||||
@@ -725,12 +822,17 @@ func main() {
|
|||||||
// Admin Plane 에서 관리하는 Domain 테이블을 사용해 (domain, client_api_key) 조합을 검증합니다.
|
// Admin Plane 에서 관리하는 Domain 테이블을 사용해 (domain, client_api_key) 조합을 검증합니다.
|
||||||
domainValidator := admin.NewEntDomainValidator(logger, dbClient)
|
domainValidator := admin.NewEntDomainValidator(logger, dbClient)
|
||||||
|
|
||||||
// DTLS 핸드셰이크 단계에서 HOP_SERVER_DOMAIN 으로 설정된 도메인만 허용하도록 래핑합니다.
|
// DTLS 핸드셰이크 단계에서는 클라이언트가 제시한 도메인의 DNS(A/AAAA)가
|
||||||
allowedDomain = strings.ToLower(strings.TrimSpace(cfg.Domain))
|
// HOP_ACME_EXPECT_IPS 에 설정된 IP들 중 하나 이상을 가리키는지 추가로 검증합니다. (ko)
|
||||||
|
// During DTLS handshake, additionally verify that the presented domain resolves
|
||||||
|
// (via A/AAAA) to at least one IP configured in HOP_ACME_EXPECT_IPS. (en)
|
||||||
|
// EXPECT_IPS 가 비어 있으면 DNS 기반 검증은 생략하고 DB 검증만 수행합니다. (ko)
|
||||||
|
// If EXPECT_IPS is empty, only DB-based validation is performed. (en)
|
||||||
|
expectedHandshakeIPs := parseExpectedIPsFromEnv(logger, "HOP_ACME_EXPECT_IPS")
|
||||||
var validator dtls.DomainValidator = &domainGateValidator{
|
var validator dtls.DomainValidator = &domainGateValidator{
|
||||||
allowed: allowedDomain,
|
expectedIPs: expectedHandshakeIPs,
|
||||||
inner: domainValidator,
|
inner: domainValidator,
|
||||||
logger: logger,
|
logger: logger,
|
||||||
}
|
}
|
||||||
|
|
||||||
// 7. DTLS Accept 루프 + Handshake
|
// 7. DTLS Accept 루프 + Handshake
|
||||||
|
|||||||
@@ -102,6 +102,9 @@ This document tracks implementation progress against the HopGate architecture an
|
|||||||
- ent.Client + PostgreSQL 기반으로 `Domain` 테이블 조회.
|
- ent.Client + PostgreSQL 기반으로 `Domain` 테이블 조회.
|
||||||
- 도메인 문자열은 `"host"` 또는 `"host:port"` 모두 허용하되, DB 조회 시에는 host 부분만 사용.
|
- 도메인 문자열은 `"host"` 또는 `"host:port"` 모두 허용하되, DB 조회 시에는 host 부분만 사용.
|
||||||
- `(domain, client_api_key)` 조합이 정확히 일치하는지 검증.
|
- `(domain, client_api_key)` 조합이 정확히 일치하는지 검증.
|
||||||
|
- DTLS 핸드셰이크 DNS/IP 게이트: [`cmd/server/main.go`](cmd/server/main.go:37)
|
||||||
|
- `canonicalizeDomainForDNS` + `domainGateValidator` 를 사용해, 클라이언트가 제시한 도메인의 A/AAAA 레코드가 `HOP_ACME_EXPECT_IPS` 에 설정된 IPv4/IPv6 IP 중 하나 이상과 일치하는지 검사한 뒤 DB 기반 `DomainValidator` 에 위임.
|
||||||
|
- `HOP_ACME_EXPECT_IPS` 가 비어 있는 경우에는 DNS/IP 검증을 생략하고 DB 검증만 수행.
|
||||||
- 기존 Dummy 구현: [`internal/dtls/validator_dummy.go`](internal/dtls/validator_dummy.go) 는 이제 개발/테스트용 참고 구현으로만 유지.
|
- 기존 Dummy 구현: [`internal/dtls/validator_dummy.go`](internal/dtls/validator_dummy.go) 는 이제 개발/테스트용 참고 구현으로만 유지.
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|||||||
Reference in New Issue
Block a user