mirror of
https://github.com/dalbodeule/hop-gate.git
synced 2025-12-08 04:45:43 +09:00
build(deps): add ent and x libs dependencies
This commit is contained in:
22
internal/acme/acme.go
Normal file
22
internal/acme/acme.go
Normal 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
190
internal/admin/http.go
Normal 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
13
internal/admin/service.go
Normal 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
252
internal/config/config.go
Normal 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
23
internal/dtls/dtls.go
Normal 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
109
internal/logging/logging.go
Normal 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,
|
||||
}
|
||||
}
|
||||
22
internal/protocol/protocol.go
Normal file
22
internal/protocol/protocol.go
Normal 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
19
internal/proxy/client.go
Normal 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
43
internal/proxy/server.go
Normal 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
|
||||
}
|
||||
Reference in New Issue
Block a user