build(deps): add ent and x libs dependencies

This commit is contained in:
dalbodeule
2025-11-26 16:32:54 +09:00
parent 98bc949db1
commit 4d5b7f15f3
19 changed files with 1111 additions and 0 deletions

63
.env.example Normal file
View File

@@ -0,0 +1,63 @@
# hop-gate .env example
#
# 이 파일을 복사해서 `.env` 로 이름을 바꾼 뒤 값을 채워서 사용하세요.
# internal/config 패키지의 LoadServerConfigFromEnv / LoadClientConfigFromEnv 가
# .env를 먼저 읽고, 이후 환경변수로부터 설정을 구성합니다.
# ---- Logging / Loki ----
# 로그 레벨: debug/info/warn/error
HOP_LOG_LEVEL=info
# Loki HTTP push 사용 여부 (Promtail 없이 직접 push 할 때 true)
HOP_LOKI_ENABLE=false
# Loki HTTP push 엔드포인트 (예: http://loki:3100/loki/api/v1/push)
#HOP_LOKI_ENDPOINT=http://loki:3100/loki/api/v1/push
# 멀티 테넌시 Loki 사용 시 Tenant ID (X-Scope-OrgID 등으로 사용)
#HOP_LOKI_TENANT_ID=
# Basic Auth 자격 증명 (필요할 때만)
#HOP_LOKI_USERNAME=
#HOP_LOKI_PASSWORD=
# 모든 로그에 공통으로 붙일 라벨 (콤마 구분: key=value,key2=value2)
# 예: app=hop-gate,env=dev,region=ap-northeast-2
#HOP_LOKI_STATIC_LABELS=app=hop-gate,env=dev,region=ap-northeast-2
# ---- Server ports & domains ----
# HTTP 리스닝 포트 (보통 :80, ACME HTTP-01 및 HTTPS 리다이렉트용)
HOP_SERVER_HTTP_LISTEN=:80
# HTTPS 리스닝 포트 (보통 :443)
HOP_SERVER_HTTPS_LISTEN=:443
# DTLS 리스닝 포트 (보통 :443, 필요시 별도 포트 사용)
HOP_SERVER_DTLS_LISTEN=:443
# 메인 도메인 (예: example.com)
HOP_SERVER_DOMAIN=example.com
# 프록시용 서브도메인/별도 도메인 목록 (콤마 구분)
# 예: api.example.com,edge.example.com
HOP_SERVER_PROXY_DOMAINS=api.example.com,edge.example.com
# ---- Client settings ----
# DTLS 서버 주소 (host:port)
# 예: example.com:443
HOP_CLIENT_SERVER_ADDR=example.com:443
# 클라이언트 식별자
HOP_CLIENT_ID=client-1
# 선택적 인증 토큰 (서버에서 검증용으로 사용 가능)
HOP_CLIENT_AUTH_TOKEN=
# 서비스 매핑: name=host:port 형태, 콤마 구분
# 예: web=127.0.0.1:8080,admin=127.0.0.1:9000
HOP_CLIENT_SERVICE_PORTS=web=127.0.0.1:8080,admin=127.0.0.1:9000

137
ARCHITECTURE.md Normal file
View File

@@ -0,0 +1,137 @@
# hop-gate 아키텍처 개요
이 프로젝트는 인터넷에서 들어오는 HTTP(S) 트래픽을 여러 클라이언트로 터널링해 주는 게이트웨이(서버)와, 서버의 지시에 따라 로컬 네트워크에 HTTP 요청을 수행하는 클라이언트로 구성된다.
## 전체 구조
- 단일 Go 모듈: `hop-gate`
- 실행 바이너리 2개:
- 서버: 공인 포트 80/443 점유, ACME로 인증서 자동 발급/갱신, HTTP Reverse Proxy 및 DTLS 터널 엔드포인트
- 클라이언트: DTLS를 통해 서버에 접속, 서버가 전달한 HTTP 요청을 로컬에서 실행 후 응답을 서버로 전달
## 디렉터리 레이아웃
```text
.
├── cmd/
│ ├── server/ # 서버 바이너리 엔트리 포인트
│ └── client/ # 클라이언트 바이너리 엔트리 포인트
├── internal/
│ ├── config/ # 서버/클라이언트 공통 설정 로딩
│ ├── acme/ # ACME(예: Let's Encrypt) 인증서 발급/갱신 로직
│ ├── dtls/ # DTLS 세션 관리 및 암호화 채널 추상화
│ ├── proxy/ # HTTP Proxy / 터널링 코어 로직
│ ├── protocol/ # 서버-클라이언트 메시지 프로토콜 정의
│ └── logging/ # 공통 로깅 유틸
└── pkg/
└── util/ # 재사용 가능한 유틸리티 (선택 사항)
```
### cmd/
- [`cmd/server/main.go`](cmd/server/main.go)
- 서버 설정 로딩 (리스닝 주소, ACME 도메인, Proxy 라우팅 설정 등)
- ACME 매니저 초기화 및 인증서 자동 관리
- HTTP(80) → ACME HTTP-01 챌린지 및 80 리다이렉트 처리
- HTTPS(443/TCP) → 외부 클라이언트의 HTTP(S) 요청 수신
- 443/UDP(또는 별도 포트) → DTLS 서버 소켓 생성
- DTLS 세션과 HTTP Proxy 코어를 연결
- [`cmd/client/main.go`](cmd/client/main.go)
- 클라이언트 설정 로딩 (접속할 서버 주소, 인증 정보, 로컬 HTTP 타깃 포트 매핑 등)
- DTLS 클라이언트 세션 생성 및 재접속 로직
- 서버에서 내려오는 HTTP 요청 메시지를 받아 로컬 HTTP 서버/서비스로 프록시
- HTTP 응답을 서버로 전송
### internal/config
서버와 클라이언트가 공통으로 사용하는 설정 스키마를 정의한다.
- 서버 설정 예시
- 리스닝 주소: `http_listen`, `https_listen`, `dtls_listen`
- ACME 설정: `acme_email`, `acme_ca`, `acme_cache_dir`
- 도메인/서브도메인: 메인 도메인, 프록시 서브도메인 목록
- 라우팅 규칙: 도메인/패스 → 클라이언트 ID 매핑
- 클라이언트 설정 예시
- 서버 주소 및 포트 (DTLS)
- 클라이언트 식별자 및 인증 토큰/키
- 로컬 HTTP 타깃 매핑: `service_name``127.0.0.1:PORT`
### internal/acme
- ACME 클라이언트 래퍼
- 메인 도메인 및 Proxy 서브도메인(또는 별도 정의 도메인)용 인증서 발급
- HTTP-01 또는 TLS-ALPN-01 챌린지를 위한 훅 제공
- 자동 갱신 및 인증서 캐시(파일 또는 디렉터리) 관리
서버 바이너리에서는 이 패키지에서 제공하는 인증서 매니저를 이용해 HTTPS(443)와 DTLS(443/UDP)의 인증서를 동일하게 또는 별도로 주입할 수 있다.
### internal/dtls
- DTLS 라이브러리(pion/dtls 등)에 대한 얇은 추상화 레이어
- 서버:
- 다중 클라이언트 세션 관리 (클라이언트 ID 매핑)
- 재접속 및 세션 타임아웃 처리
- 클라이언트:
- 서버와의 DTLS 핸드셰이크 및 재연결 로직
- 서버 인증(ACME로 발급된 인증서 체인 검증)
이 레이어는 단순히 `io.ReadWriteCloser` 또는 스트림 추상화를 제공해 상위 `proxy`/`protocol` 레이어에서 재사용 가능하게 한다.
### internal/protocol
서버와 클라이언트가 DTLS 위에서 교환하는 메시지 포맷과 흐름을 정의한다.
- 요청/응답 단위의 메시지 구조:
- `request_id`: 요청 식별자
- HTTP 메서드, URL, 헤더, 바디
- 타깃 서비스/포트 식별자
- 응답 메시지 구조:
- `request_id`
- HTTP 상태 코드, 헤더, 바디
- 인코딩 방식: JSON, MsgPack 또는 Protobuf 등 (초기에는 JSON으로 시작해도 됨)
- Flow 제어 및 에러 코드 정의
### internal/proxy
#### 서버 측 역할
- 공인 HTTPS 엔드포인트에서 들어오는 HTTP 요청 수신
- 도메인/패스 → 클라이언트 ID/서비스 매핑 룰에 따라 대상 클라이언트 선택
- `protocol` 패키지를 사용해 HTTP 요청을 메시지로 직렬화 후 DTLS 세션으로 전송
- 클라이언트로부터 받은 응답 메시지를 HTTP 응답으로 복원해 외부 클라이언트에 반환
- 타임아웃, 재시도, 클라이언트 장애 시 fallback 정책 등 처리
#### 클라이언트 측 역할
- DTLS 채널을 통해 서버가 내려보낸 HTTP 요청 메시지 수신
- 로컬에서 `net/http` 클라이언트 또는 직접 TCP 접속으로 지정된 `127.0.0.1:PORT`에 요청 수행
- 응답을 수신해 `protocol` 포맷으로 직렬화 후 서버로 전송
- 서버로부터 전달된 취소/타임아웃 신호에 따라 로컬 요청 중단
### internal/logging
- 구조적 로깅 래퍼 (예: zap, zerolog 등)
- 공통 로그 포맷 및 필드 (request_id, client_id, route 등) 정의
### pkg/util (선택)
- 재사용 가능한 헬퍼 유틸리티(에러 래핑, context 유틸 등)를 배치할 수 있는 공간이다.
## 요청 흐름 요약
1. 외부 사용자가 `https://proxy.example.com/service-a/path` 로 요청을 보낸다.
2. 서버의 HTTPS 리스너가 요청을 수신한다.
3. `proxy` 레이어가 라우팅 규칙에 따라 이 요청을 처리할 클라이언트(예: `client-1`)와 로컬 서비스(`service-a`)를 결정한다.
4. 요청을 `protocol` 포맷으로 직렬화해 `dtls` 레이어를 통해 `client-1`로 전송한다.
5. 클라이언트의 `proxy` 레이어가 메시지를 받아 로컬 `127.0.0.1:8080` 등으로 HTTP 요청을 수행한다.
6. 클라이언트는 응답을 수신해 `protocol` 포맷으로 직렬화 후 DTLS로 서버에 전송한다.
7. 서버는 응답 메시지를 HTTP 응답으로 복원해 원래의 외부 요청에 대한 응답으로 반환한다.
## 다음 단계
- 위 레이아웃대로 디렉터리와 최소한의 엔트리 포인트 파일을 생성한 뒤,
- `internal/config`에 설정 구조체와 YAML/JSON 로더를 정의하고,
- `internal/acme`에서 certmagic 또는 lego를 사용한 ACME 매니저를 구현하고,
- `internal/dtls`에서 pion/dtls 기반의 세션 래퍼를 만든 다음,
- `internal/protocol``internal/proxy`를 순차적으로 구현하면 된다.

53
Dockerfile.server Normal file
View File

@@ -0,0 +1,53 @@
# Multi-stage Dockerfile for hop-gate server
#
# 빌드 단계와 런타임 단계를 분리한 기본 Dockerfile 입니다.
# 최종 이미지는 경량 alpine 기반이며, /app 디렉터리에서 서버를 실행합니다.
#
# 빌드:
# docker build -f Dockerfile.server -t hop-gate-server:dev .
#
# 실행 예:
# docker run --rm -p 80:80 -p 443:443 \\
# --env-file ./.env \\
# hop-gate-server:dev
# ---------- Build stage ----------
FROM golang:1.22-alpine AS builder
WORKDIR /src
# 모듈/의존성 캐시를 최대한 활용하기 위해 go.mod, go.sum 먼저 복사
COPY go.mod ./
# go.sum 이 있다면 같이 복사 (없으면 무시)
# hadolint ignore=DL3059
RUN if [ -f go.sum ]; then cp go.sum ./; fi
RUN go env -w GOPROXY=https://proxy.golang.org,direct
RUN go mod download || true
# 실제 소스 코드 복사
COPY . .
# 서버 바이너리 빌드
RUN CGO_ENABLED=0 GOOS=linux GOARCH=amd64 go build -o /out/hop-gate-server ./cmd/server
# ---------- Runtime stage ----------
FROM alpine:3.20
WORKDIR /app
# ca-certificates 설치 (ACME / HTTPS 통신 등을 위해 필요)
RUN apk add --no-cache ca-certificates tzdata
# 서버 바이너리 복사
COPY --from=builder /out/hop-gate-server /app/hop-gate-server
# 예시용 .env 파일도 같이 복사 (실운영에서는 보통 외부에서 마운트하거나 --env-file 사용)
COPY .env.example /app/.env.example
# 기본 포트 노출 (실제 포트는 .env / 설정에 따라 변경 가능)
EXPOSE 80 443/udp 443
# 기본 실행 명령
ENTRYPOINT ["/app/hop-gate-server"]

54
Makefile Normal file
View File

@@ -0,0 +1,54 @@
# Makefile for hop-gate
#
# 기본 빌드/테스트/도커 이미지 생성을 위한 Makefile 입니다.
# 사용 예:
# make all
# make server
# make client
# make docker-server
# make clean
GO ?= go
MODULE ?= github.com/dalbodeule/hop-gate
SERVER_PKG := ./cmd/server
CLIENT_PKG := ./cmd/client
BIN_DIR := ./bin
SERVER_BIN := $(BIN_DIR)/hop-gate-server
CLIENT_BIN := $(BIN_DIR)/hop-gate-client
VERSION ?= $(shell git describe --tags --dirty --always 2>/dev/null || echo dev)
.PHONY: all server client clean docker-server run-server run-client
all: server client
server:
@echo "Building server..."
@mkdir -p $(BIN_DIR)
$(GO) build -ldflags "-X main.version=$(VERSION)" -o $(SERVER_BIN) $(SERVER_PKG)
@echo "Server binary: $(SERVER_BIN)"
client:
@echo "Building client..."
@mkdir -p $(BIN_DIR)
$(GO) build -ldflags "-X main.version=$(VERSION)" -o $(CLIENT_BIN) $(CLIENT_PKG)
@echo "Client binary: $(CLIENT_BIN)"
clean:
@echo "Cleaning binaries..."
rm -rf $(BIN_DIR)
run-server: server
@echo "Running server..."
$(SERVER_BIN)
run-client: client
@echo "Running client..."
$(CLIENT_BIN)
docker-server:
@echo "Building server Docker image..."
docker build -f Dockerfile.server -t hop-gate-server:$(VERSION) .

16
cmd/client/main.go Normal file
View File

@@ -0,0 +1,16 @@
package main
import (
"github.com/dalbodeule/hop-gate/internal/logging"
)
func main() {
logger := logging.NewStdJSONLogger("client")
logger.Info("hop-gate client starting", logging.Fields{
"stack": "prometheus-loki-grafana",
})
// TODO: load configuration from internal/config
// TODO: initialize logging details (instance, env, version) via logger.With(...)
// TODO: establish DTLS connection to server via internal/dtls
// TODO: start request handling loop using internal/proxy and internal/protocol
}

16
cmd/server/main.go Normal file
View File

@@ -0,0 +1,16 @@
package main
import (
"github.com/dalbodeule/hop-gate/internal/logging"
)
func main() {
logger := logging.NewStdJSONLogger("server")
logger.Info("hop-gate server starting", logging.Fields{
"stack": "prometheus-loki-grafana",
})
// TODO: load configuration from internal/config
// TODO: initialize logging details (instance, env, version) via logger.With(...)
// TODO: initialize ACME manager from internal/acme
// TODO: start HTTP/HTTPS listeners and DTLS listener
}

51
ent/schema/domain.go Normal file
View File

@@ -0,0 +1,51 @@
package schema
import (
"time"
"github.com/google/uuid"
"entgo.io/ent"
"entgo.io/ent/schema/field"
"entgo.io/ent/schema/index"
)
// Domain 는 클라이언트가 사용할 도메인과 API Key 를 저장하는 엔티티입니다.
// - id: UUID 기본 키
// - domain: FQDN (예: app.example.com)
// - client_api_key: 클라이언트 인증용 랜덤 문자열(64자)
// - memo: 관리자 메모
// - created_at / updated_at: 감사용 타임스탬프
type Domain struct {
ent.Schema
}
// Fields of the Domain.
func (Domain) Fields() []ent.Field {
return []ent.Field{
field.UUID("id", uuid.UUID{}).
Default(uuid.New).
Immutable(),
field.String("domain").
NotEmpty().
Unique().
Immutable(),
field.String("client_api_key").
NotEmpty().
MaxLen(64),
field.String("memo").
Default(""),
field.Time("created_at").
Default(time.Now),
field.Time("updated_at").
Default(time.Now).
UpdateDefault(time.Now),
}
}
// Indexes of the Domain.
func (Domain) Indexes() []ent.Index {
return []ent.Index{
index.Fields("client_api_key").Unique(),
}
}

8
go.mod
View File

@@ -1,3 +1,11 @@
module github.com/dalbodeule/hop-gate
go 1.25.4
require (
entgo.io/ent v0.14.5
github.com/google/uuid v1.3.0
golang.org/x/net v0.47.0
)
require golang.org/x/text v0.31.0 // indirect

16
go.sum Normal file
View File

@@ -0,0 +1,16 @@
entgo.io/ent v0.14.5 h1:Rj2WOYJtCkWyFo6a+5wB3EfBRP0rnx1fMk6gGA0UUe4=
entgo.io/ent v0.14.5/go.mod h1:zTzLmWtPvGpmSwtkaayM2cm5m819NdM7z7tYPq3vN0U=
github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c=
github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
github.com/google/uuid v1.3.0 h1:t6JiXgmwXMjEs8VusXIJk2BXHsn+wx8BZdTaoZ5fu7I=
github.com/google/uuid v1.3.0/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo=
github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM=
github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4=
github.com/stretchr/testify v1.8.4 h1:CcVxjf3Q8PM0mHUKJCdn+eZZtm5yQwehR5yeSVQQcUk=
github.com/stretchr/testify v1.8.4/go.mod h1:sz/lmYIOXD/1dqDmKjjqLyZ2RngseejIcXlSw2iwfAo=
golang.org/x/net v0.47.0 h1:Mx+4dIFzqraBXUugkia1OOvlD6LemFo1ALMHjrXDOhY=
golang.org/x/net v0.47.0/go.mod h1:/jNxtkgq5yWUGYkaZGqo27cfGZ1c5Nen03aYrrKpVRU=
golang.org/x/text v0.31.0 h1:aC8ghyu4JhP8VojJ2lEHBnochRno1sgL6nEi9WGFGMM=
golang.org/x/text v0.31.0/go.mod h1:tKRAlv61yKIjGGHX/4tP1LTbc13YSec1pxVEWXzfoeM=
gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA=
gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=

22
internal/acme/acme.go Normal file
View File

@@ -0,0 +1,22 @@
package acme
import "crypto/tls"
// Manager 는 ACME 기반 인증서 관리를 추상화합니다.
type Manager interface {
// TLSConfig 는 HTTPS 및 DTLS 서버에 주입할 tls.Config 를 반환합니다.
TLSConfig() *tls.Config
}
// NewDummyManager 는 초기 개발 단계를 위한 더미 구현입니다.
// 실제 ACME 연동 전까지 self-signed 등의 임시 인증서를 제공하도록 확장할 수 있습니다.
func NewDummyManager() Manager {
return &dummyManager{}
}
type dummyManager struct{}
func (d *dummyManager) TLSConfig() *tls.Config {
// TODO: 실제 인증서 로딩/ACME 연동 구현
return &tls.Config{}
}

190
internal/admin/http.go Normal file
View File

@@ -0,0 +1,190 @@
package admin
import (
"encoding/json"
"net/http"
"strings"
"github.com/dalbodeule/hop-gate/internal/logging"
)
// Handler 는 /api/v1/admin 관리 plane HTTP 엔드포인트를 제공합니다.
type Handler struct {
Logger logging.Logger
AdminAPIKey string
Service DomainService
}
// NewHandler 는 새로운 Handler 를 생성합니다.
func NewHandler(logger logging.Logger, adminAPIKey string, svc DomainService) *Handler {
return &Handler{
Logger: logger.With(logging.Fields{"component": "admin_api"}),
AdminAPIKey: strings.TrimSpace(adminAPIKey),
Service: svc,
}
}
// RegisterRoutes 는 전달받은 mux 에 관리 API 라우트를 등록합니다.
// - POST /api/v1/admin/domains/register
// - POST /api/v1/admin/domains/unregister
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)))
}
// authMiddleware 는 Authorization: Bearer {ADMIN_API_KEY} 헤더를 검증합니다.
func (h *Handler) authMiddleware(next http.Handler) http.Handler {
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
if !h.authenticate(r) {
w.Header().Set("Content-Type", "application/json")
w.WriteHeader(http.StatusUnauthorized)
_ = json.NewEncoder(w).Encode(map[string]any{
"success": false,
"error": "unauthorized",
})
return
}
next.ServeHTTP(w, r)
})
}
func (h *Handler) authenticate(r *http.Request) bool {
if h.AdminAPIKey == "" {
// Admin API 키가 설정되지 않았다면 모든 요청을 거부
return false
}
auth := r.Header.Get("Authorization")
if auth == "" {
return false
}
const prefix = "Bearer "
if !strings.HasPrefix(auth, prefix) {
return false
}
token := strings.TrimSpace(strings.TrimPrefix(auth, prefix))
return token == h.AdminAPIKey
}
type domainRegisterRequest struct {
Domain string `json:"domain"`
Memo string `json:"memo"`
}
type domainRegisterResponse struct {
ClientAPIKey string `json:"client_api_key,omitempty"`
Success bool `json:"success"`
Error string `json:"error,omitempty"`
}
func (h *Handler) handleDomainRegister(w http.ResponseWriter, r *http.Request) {
if r.Method != http.MethodPost {
h.writeMethodNotAllowed(w, r)
return
}
var req domainRegisterRequest
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
h.Logger.Warn("invalid register request body", logging.Fields{"error": err.Error()})
h.writeJSON(w, http.StatusBadRequest, domainRegisterResponse{
Success: false,
Error: "invalid request body",
})
return
}
req.Domain = strings.TrimSpace(req.Domain)
if req.Domain == "" {
h.writeJSON(w, http.StatusBadRequest, domainRegisterResponse{
Success: false,
Error: "domain is required",
})
return
}
clientKey, err := h.Service.RegisterDomain(r.Context(), req.Domain, req.Memo)
if err != nil {
h.Logger.Error("failed to register domain", logging.Fields{
"domain": req.Domain,
"error": err.Error(),
})
h.writeJSON(w, http.StatusInternalServerError, domainRegisterResponse{
Success: false,
Error: "internal error",
})
return
}
h.writeJSON(w, http.StatusOK, domainRegisterResponse{
Success: true,
ClientAPIKey: clientKey,
})
}
type domainUnregisterRequest struct {
Domain string `json:"domain"`
ClientAPIKey string `json:"client_api_key"`
}
type domainUnregisterResponse struct {
Success bool `json:"success"`
Error string `json:"error,omitempty"`
}
func (h *Handler) handleDomainUnregister(w http.ResponseWriter, r *http.Request) {
if r.Method != http.MethodPost {
h.writeMethodNotAllowed(w, r)
return
}
var req domainUnregisterRequest
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
h.Logger.Warn("invalid unregister request body", logging.Fields{"error": err.Error()})
h.writeJSON(w, http.StatusBadRequest, domainUnregisterResponse{
Success: false,
Error: "invalid request body",
})
return
}
req.Domain = strings.TrimSpace(req.Domain)
req.ClientAPIKey = strings.TrimSpace(req.ClientAPIKey)
if req.Domain == "" || req.ClientAPIKey == "" {
h.writeJSON(w, http.StatusBadRequest, domainUnregisterResponse{
Success: false,
Error: "domain and client_api_key are required",
})
return
}
if err := h.Service.UnregisterDomain(r.Context(), req.Domain, req.ClientAPIKey); err != nil {
h.Logger.Error("failed to unregister domain", logging.Fields{
"domain": req.Domain,
"client_api_key": "***",
"error": err.Error(),
})
h.writeJSON(w, http.StatusInternalServerError, domainUnregisterResponse{
Success: false,
Error: "internal error",
})
return
}
h.writeJSON(w, http.StatusOK, domainUnregisterResponse{
Success: true,
})
}
func (h *Handler) writeMethodNotAllowed(w http.ResponseWriter, r *http.Request) {
h.writeJSON(w, http.StatusMethodNotAllowed, map[string]any{
"success": false,
"error": "method not allowed",
})
}
func (h *Handler) writeJSON(w http.ResponseWriter, status int, v any) {
w.Header().Set("Content-Type", "application/json")
w.WriteHeader(status)
if err := json.NewEncoder(w).Encode(v); err != nil {
h.Logger.Error("failed to write json response", logging.Fields{"error": err.Error()})
}
}

13
internal/admin/service.go Normal file
View File

@@ -0,0 +1,13 @@
package admin
import "context"
// 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
}

252
internal/config/config.go Normal file
View File

@@ -0,0 +1,252 @@
package config
import (
"bufio"
"errors"
"os"
"strconv"
"strings"
"sync"
)
// LoggingConfig 는 공통 로그 설정을 담습니다.
// Loki push 에 필요한 엔드포인트/인증/정적 라벨 등을 포함합니다.
type LoggingConfig struct {
Level string // 예: "debug", "info", "warn", "error"
Loki LokiConfig // Loki 관련 설정
}
// LokiConfig 는 Loki HTTP push 설정을 담습니다.
type LokiConfig struct {
Enable bool // true 인 경우 Loki 로도 push
Endpoint string // 예: "http://loki:3100/loki/api/v1/push"
TenantID string // multi-tenant Loki 사용 시 X-Scope-OrgID 등에 사용
Username string // basic auth 사용자명(선택)
Password string // basic auth 비밀번호(선택)
StaticLabels map[string]string // 모든 로그에 공통으로 붙일 라벨 (app=hop-gate,env=dev 등)
}
// ServerConfig 는 서버 프로세스 설정을 담습니다.
type ServerConfig struct {
HTTPListen string // 예: ":80"
HTTPSListen string // 예: ":443"
DTLSListen string // 예: ":443"
Domain string // 메인 도메인
ProxyDomains []string // 프록시 서브도메인 또는 별도 도메인
Logging LoggingConfig // 서버용 로그 설정
}
// ClientConfig 는 클라이언트 프로세스 설정을 담습니다.
type ClientConfig struct {
ServerAddr string // DTLS 서버 주소 (host:port)
ClientID string // 클라이언트 식별자
AuthToken string // 선택적 인증 토큰
ServicePorts map[string]string // service name -> "127.0.0.1:PORT"
Logging LoggingConfig // 클라이언트용 로그 설정
}
var (
dotenvOnce sync.Once
dotenvErr error
)
// loadDotEnvOnce 는 현재 작업 디렉터리의 .env 파일을 한 번만 읽어서 os.Environ 에 주입합니다.
// - KEY=VALUE, export KEY=VALUE 형식을 지원
// - # 으로 시작하는 줄은 주석으로 간주합니다.
func loadDotEnvOnce() {
dotenvOnce.Do(func() {
fi, err := os.Stat(".env")
if err != nil {
if errors.Is(err, os.ErrNotExist) {
// .env 가 없으면 조용히 무시
return
}
dotenvErr = err
return
}
if fi.IsDir() {
// 디렉터리이면 무시
return
}
f, err := os.Open(".env")
if err != nil {
dotenvErr = err
return
}
defer f.Close()
scanner := bufio.NewScanner(f)
for scanner.Scan() {
line := strings.TrimSpace(scanner.Text())
if line == "" || strings.HasPrefix(line, "#") {
continue
}
if strings.HasPrefix(line, "export ") {
line = strings.TrimSpace(strings.TrimPrefix(line, "export "))
}
parts := strings.SplitN(line, "=", 2)
if len(parts) != 2 {
continue
}
key := strings.TrimSpace(parts[0])
val := strings.TrimSpace(parts[1])
// 양 끝의 작은/큰따옴표 제거
val = strings.Trim(val, `"'`)
if key != "" {
_ = os.Setenv(key, val)
}
}
if err := scanner.Err(); err != nil {
dotenvErr = err
return
}
})
}
func getEnvOrDefault(key, def string) string {
if v := os.Getenv(key); v != "" {
return v
}
return def
}
func getEnvBool(key string, def bool) bool {
v := strings.ToLower(strings.TrimSpace(os.Getenv(key)))
if v == "" {
return def
}
switch v {
case "1", "true", "yes", "y", "on":
return true
case "0", "false", "no", "n", "off":
return false
default:
return def
}
}
func parseCSVEnv(key string) []string {
raw := os.Getenv(key)
if raw == "" {
return nil
}
parts := strings.Split(raw, ",")
out := make([]string, 0, len(parts))
for _, p := range parts {
p = strings.TrimSpace(p)
if p != "" {
out = append(out, p)
}
}
return out
}
// parseKeyValueCSV 는 "k1=v1,k2=v2" 형태의 문자열을 map 으로 변환합니다.
func parseKeyValueCSV(raw string) map[string]string {
if raw == "" {
return nil
}
m := make(map[string]string)
for _, part := range strings.Split(raw, ",") {
part = strings.TrimSpace(part)
if part == "" {
continue
}
kv := strings.SplitN(part, "=", 2)
if len(kv) != 2 {
continue
}
k := strings.TrimSpace(kv[0])
v := strings.TrimSpace(kv[1])
if k != "" {
m[k] = v
}
}
return m
}
// parseServicePortsEnv 는 "name1=127.0.0.1:8080,name2=127.0.0.1:9000" 형식을 파싱합니다.
func parseServicePortsEnv(key string) map[string]string {
raw := os.Getenv(key)
return parseKeyValueCSV(raw)
}
// loadLoggingFromEnv 는 공통 로그 설정을 .env/환경변수에서 읽어옵니다.
func loadLoggingFromEnv() LoggingConfig {
level := getEnvOrDefault("HOP_LOG_LEVEL", "info")
lokiEnable := getEnvBool("HOP_LOKI_ENABLE", false)
lokiEndpoint := os.Getenv("HOP_LOKI_ENDPOINT")
lokiTenantID := os.Getenv("HOP_LOKI_TENANT_ID")
lokiUsername := os.Getenv("HOP_LOKI_USERNAME")
lokiPassword := os.Getenv("HOP_LOKI_PASSWORD")
lokiStaticLabels := parseKeyValueCSV(os.Getenv("HOP_LOKI_STATIC_LABELS"))
return LoggingConfig{
Level: level,
Loki: LokiConfig{
Enable: lokiEnable,
Endpoint: lokiEndpoint,
TenantID: lokiTenantID,
Username: lokiUsername,
Password: lokiPassword,
StaticLabels: lokiStaticLabels,
},
}
}
// LoadServerConfigFromEnv 는 .env 를 우선 읽고, 이후 환경 변수를 기반으로 서버 설정을 구성합니다.
func LoadServerConfigFromEnv() (*ServerConfig, error) {
loadDotEnvOnce()
if dotenvErr != nil {
return nil, dotenvErr
}
cfg := &ServerConfig{
HTTPListen: getEnvOrDefault("HOP_SERVER_HTTP_LISTEN", ":80"),
HTTPSListen: getEnvOrDefault("HOP_SERVER_HTTPS_LISTEN", ":443"),
DTLSListen: getEnvOrDefault("HOP_SERVER_DTLS_LISTEN", ":443"),
Domain: os.Getenv("HOP_SERVER_DOMAIN"),
ProxyDomains: parseCSVEnv("HOP_SERVER_PROXY_DOMAINS"),
Logging: loadLoggingFromEnv(),
}
return cfg, nil
}
// LoadClientConfigFromEnv 는 .env 를 우선 읽고, 이후 환경 변수를 기반으로 클라이언트 설정을 구성합니다.
func LoadClientConfigFromEnv() (*ClientConfig, error) {
loadDotEnvOnce()
if dotenvErr != nil {
return nil, dotenvErr
}
cfg := &ClientConfig{
ServerAddr: os.Getenv("HOP_CLIENT_SERVER_ADDR"),
ClientID: os.Getenv("HOP_CLIENT_ID"),
AuthToken: os.Getenv("HOP_CLIENT_AUTH_TOKEN"),
ServicePorts: parseServicePortsEnv("HOP_CLIENT_SERVICE_PORTS"),
Logging: loadLoggingFromEnv(),
}
return cfg, nil
}
// Optional: 숫자 포트만 지정하고 싶을 경우를 위한 헬퍼 (예: "80" -> ":80").
// 현재는 사용하지 않지만, 향후 유효성 검사/정규화에 사용할 수 있습니다.
func normalizePort(p string, def string) string {
p = strings.TrimSpace(p)
if p == "" {
return def
}
if strings.HasPrefix(p, ":") {
return p
}
// 숫자로만 구성된 경우 ":" prefix 를 붙입니다.
if _, err := strconv.Atoi(p); err == nil {
return ":" + p
}
return p
}

23
internal/dtls/dtls.go Normal file
View File

@@ -0,0 +1,23 @@
package dtls
import "io"
// Session 은 DTLS 위의 양방향 스트림을 추상화합니다.
type Session interface {
io.ReadWriteCloser
ID() string
}
// Server 는 다중 클라이언트 DTLS 세션을 관리하는 추상 인터페이스입니다.
type Server interface {
Accept() (Session, error)
Close() error
}
// Client 는 단일 서버와의 DTLS 세션을 관리하는 추상 인터페이스입니다.
type Client interface {
Connect() (Session, error)
Close() error
}
// 실제 구현은 향후 pion/dtls 등을 사용해 추가합니다.

109
internal/logging/logging.go Normal file
View File

@@ -0,0 +1,109 @@
package logging
import (
"encoding/json"
"log"
"os"
"time"
)
// Level 은 로그의 심각도 레벨을 나타냅니다.
type Level string
const (
DebugLevel Level = "debug"
InfoLevel Level = "info"
WarnLevel Level = "warn"
ErrorLevel Level = "error"
)
// Fields 는 구조적 로그의 key/value 필드를 표현합니다.
// Loki/Promtail 에서 라벨/필드로 활용할 수 있습니다.
type Fields map[string]any
// Logger 는 Loki/Grafana 스택에 적합한 구조적 로그 인터페이스입니다.
//
// - 모든 구현체는 단일 라인 JSON 을 stdout/stderr 로 출력하는 것을 목표로 합니다.
// - Promtail 은 stdout 을 수집해 Loki 로 전송하고, Grafana 에서 쿼리/대시보딩 할 수 있습니다.
type Logger interface {
// Debug 는 디버그 레벨 로그를 기록합니다.
Debug(msg string, fields Fields)
// Info 는 정보 레벨 로그를 기록합니다.
Info(msg string, fields Fields)
// Warn 는 경고 레벨 로그를 기록합니다.
Warn(msg string, fields Fields)
// Error 는 에러 레벨 로그를 기록합니다.
Error(msg string, fields Fields)
// With 는 추가 필드를 항상 포함하는 child logger 를 생성합니다.
With(fields Fields) Logger
}
// stdLogger 는 표준 log.Logger 를 감싼 구현체입니다.
// 개발 단계에서 간단히 사용하거나 JSON 형식이 필요 없을 때 사용할 수 있습니다.
type stdLogger struct {
l *log.Logger
fields Fields
}
func (s *stdLogger) log(level Level, msg string, fields Fields) {
entry := map[string]any{
"ts": time.Now().UTC().Format(time.RFC3339Nano),
"level": level,
"msg": msg,
}
// 공통 필드 병합
for k, v := range s.fields {
entry[k] = v
}
// 호출 시 전달된 필드 병합(우선순위 높음)
for k, v := range fields {
entry[k] = v
}
b, err := json.Marshal(entry)
if err != nil {
// JSON 마샬 실패 시 fallback 으로 기본 포맷 사용
s.l.Printf("level=%s msg=%s marshal_error=%v", level, msg, err)
return
}
s.l.Println(string(b))
}
func (s *stdLogger) Debug(msg string, fields Fields) { s.log(DebugLevel, msg, fields) }
func (s *stdLogger) Info(msg string, fields Fields) { s.log(InfoLevel, msg, fields) }
func (s *stdLogger) Warn(msg string, fields Fields) { s.log(WarnLevel, msg, fields) }
func (s *stdLogger) Error(msg string, fields Fields) { s.log(ErrorLevel, msg, fields) }
func (s *stdLogger) With(fields Fields) Logger {
merged := Fields{}
for k, v := range s.fields {
merged[k] = v
}
for k, v := range fields {
merged[k] = v
}
return &stdLogger{
l: s.l,
fields: merged,
}
}
// NewStdJSONLogger 는 stdout 으로 단일 라인 JSON 로그를 출력하는 기본 Logger 를 생성합니다.
// Promtail 이 stdout 을 Loki 로 수집하는 전형적인 구성에 적합합니다.
//
// component, service, client_id, request_id 같은 필드를 With 로 미리 설정해 두면
// Grafana 에서 필터링/그룹핑에 활용할 수 있습니다.
func NewStdJSONLogger(component string) Logger {
baseFields := Fields{
"component": component,
}
return &stdLogger{
l: log.New(os.Stdout, "", 0), // 프리픽스/타임스탬프는 JSON 필드로만 사용
fields: baseFields,
}
}

View File

@@ -0,0 +1,22 @@
package protocol
// Request 는 서버-클라이언트 간에 전달되는 HTTP 요청을 표현합니다.
type Request struct {
RequestID string
ClientID string // 대상 클라이언트 식별자
ServiceName string // 클라이언트 내부 서비스 이름
Method string
URL string
Header map[string][]string
Body []byte
}
// Response 는 서버-클라이언트 간에 전달되는 HTTP 응답을 표현합니다.
type Response struct {
RequestID string
Status int
Header map[string][]string
Body []byte
Error string // 에러 발생 시 설명 메시지
}

19
internal/proxy/client.go Normal file
View File

@@ -0,0 +1,19 @@
package proxy
import (
"context"
"net/http"
)
// ClientProxy 는 서버로부터 받은 요청을 로컬 HTTP 서비스로 전달하는 클라이언트 측 프록시입니다.
type ClientProxy struct {
HTTPClient *http.Client
}
// StartLoop 는 DTLS 세션에서 protocol.Request 를 읽고 로컬 HTTP 요청을 수행한 뒤
// protocol.Response 를 다시 세션으로 쓰는 루프를 의미합니다.
// 실제 구현은 dtls.Session, protocol.{Request,Response} 를 조합해 작성합니다.
func (p *ClientProxy) StartLoop(ctx context.Context) error {
// TODO: DTLS 세션 읽기/쓰기 및 로컬 HTTP 호출 구현
return nil
}

43
internal/proxy/server.go Normal file
View File

@@ -0,0 +1,43 @@
package proxy
import (
"context"
"net/http"
"golang.org/x/net/http2"
)
// ServerProxy 는 공인 HTTP(S) 엔드포인트에서 들어오는 요청을
// 적절한 클라이언트로 라우팅하는 서버 측 프록시입니다.
type ServerProxy struct {
Router Router
HTTPServer *http.Server
}
// Router 는 도메인/패스 기준으로 어떤 클라이언트/서비스로 보낼지 결정하는 인터페이스입니다.
type Router interface {
Route(req *http.Request) (clientID string, serviceName string, err error)
}
// NewHTTPServer 는 H1/H2 를 지원하는 기본 HTTP 서버를 생성합니다.
func NewHTTPServer(addr string, handler http.Handler) *http.Server {
srv := &http.Server{
Addr: addr,
Handler: handler,
}
http2.ConfigureServer(srv, &http2.Server{})
return srv
}
// Start / Shutdown 등은 추후 구현합니다.
func (p *ServerProxy) Start(ctx context.Context) error {
// TODO: HTTP/HTTPS 리스너 시작 및 DTLS 연동
return nil
}
func (p *ServerProxy) Shutdown(ctx context.Context) error {
if p.HTTPServer != nil {
return p.HTTPServer.Shutdown(ctx)
}
return nil
}

4
pkg/util/util.go Normal file
View File

@@ -0,0 +1,4 @@
package util
// Placeholder 는 패키지가 비어 있지 않도록 하기 위한 더미 타입입니다.
type Placeholder struct{}