diff --git a/.env.example b/.env.example new file mode 100644 index 0000000..a319fbf --- /dev/null +++ b/.env.example @@ -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 \ No newline at end of file diff --git a/ARCHITECTURE.md b/ARCHITECTURE.md new file mode 100644 index 0000000..f73f9d6 --- /dev/null +++ b/ARCHITECTURE.md @@ -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`를 순차적으로 구현하면 된다. \ No newline at end of file diff --git a/Dockerfile.server b/Dockerfile.server new file mode 100644 index 0000000..503e0f4 --- /dev/null +++ b/Dockerfile.server @@ -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"] diff --git a/Makefile b/Makefile new file mode 100644 index 0000000..587b710 --- /dev/null +++ b/Makefile @@ -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) . + diff --git a/cmd/client/main.go b/cmd/client/main.go new file mode 100644 index 0000000..add3ad5 --- /dev/null +++ b/cmd/client/main.go @@ -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 +} diff --git a/cmd/server/main.go b/cmd/server/main.go new file mode 100644 index 0000000..c7d6e5d --- /dev/null +++ b/cmd/server/main.go @@ -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 +} diff --git a/ent/schema/domain.go b/ent/schema/domain.go new file mode 100644 index 0000000..da40fc5 --- /dev/null +++ b/ent/schema/domain.go @@ -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(), + } +} diff --git a/go.mod b/go.mod index 2285b02..a2d2862 100644 --- a/go.mod +++ b/go.mod @@ -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 diff --git a/go.sum b/go.sum new file mode 100644 index 0000000..4a6966c --- /dev/null +++ b/go.sum @@ -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= diff --git a/internal/acme/acme.go b/internal/acme/acme.go new file mode 100644 index 0000000..5e0ab96 --- /dev/null +++ b/internal/acme/acme.go @@ -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{} +} diff --git a/internal/admin/http.go b/internal/admin/http.go new file mode 100644 index 0000000..9e463f6 --- /dev/null +++ b/internal/admin/http.go @@ -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()}) + } +} diff --git a/internal/admin/service.go b/internal/admin/service.go new file mode 100644 index 0000000..aace315 --- /dev/null +++ b/internal/admin/service.go @@ -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 +} diff --git a/internal/config/config.go b/internal/config/config.go new file mode 100644 index 0000000..bce3829 --- /dev/null +++ b/internal/config/config.go @@ -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 +} diff --git a/internal/dtls/dtls.go b/internal/dtls/dtls.go new file mode 100644 index 0000000..e11169a --- /dev/null +++ b/internal/dtls/dtls.go @@ -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 등을 사용해 추가합니다. diff --git a/internal/logging/logging.go b/internal/logging/logging.go new file mode 100644 index 0000000..7fbcf29 --- /dev/null +++ b/internal/logging/logging.go @@ -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, + } +} diff --git a/internal/protocol/protocol.go b/internal/protocol/protocol.go new file mode 100644 index 0000000..ff0d304 --- /dev/null +++ b/internal/protocol/protocol.go @@ -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 // 에러 발생 시 설명 메시지 +} diff --git a/internal/proxy/client.go b/internal/proxy/client.go new file mode 100644 index 0000000..67b1eab --- /dev/null +++ b/internal/proxy/client.go @@ -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 +} diff --git a/internal/proxy/server.go b/internal/proxy/server.go new file mode 100644 index 0000000..348d5c9 --- /dev/null +++ b/internal/proxy/server.go @@ -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 +} diff --git a/pkg/util/util.go b/pkg/util/util.go new file mode 100644 index 0000000..cefc407 --- /dev/null +++ b/pkg/util/util.go @@ -0,0 +1,4 @@ +package util + +// Placeholder 는 패키지가 비어 있지 않도록 하기 위한 더미 타입입니다. +type Placeholder struct{}