diff --git a/.env.example b/.env.example new file mode 100644 index 0000000..ee84822 --- /dev/null +++ b/.env.example @@ -0,0 +1,19 @@ +### SERVER SETTINGS +GATEWAY_SERVER_HOST=0.0.0.0 +GATEWAY_SERVER_PORT=5150 + +### ROUTE SETTINGS +GATEWAY_ROUTE_FILE_PATH=configs/routes.json +GATEWAY_GEO_DB_FILE_PATH=geodb/GeoLite2-Country.mmdb + +### TLS SETTINGS +GATEWAY_TLS_FULLCHAIN_CERT=SSL/fullchain.pem +GATEWAY_TLS_PRIVATE_KEY=SSL/privkey.pem + +### LOG SETTINGS +GATEWAY_LOG_LEVEL=debug +GATEWAY_LOG_FORMAT=text + + +# DOCKER COMPOSE ENVIRONMENT VARIABLES +EXTERNAL_GATEWAY_PORT=5150 \ No newline at end of file diff --git a/Dockerfile b/Dockerfile new file mode 100644 index 0000000..28a2003 --- /dev/null +++ b/Dockerfile @@ -0,0 +1,33 @@ +FROM golang:1.24.3 AS builder + +WORKDIR /app + +COPY go.mod ./ +COPY go.sum ./ +RUN go mod download + +COPY . . +RUN openssl req -x509 -newkey rsa:4096 \ + -nodes -keyout /app/SSL/privkey.pem \ + -out /app/SSL/fullchain.pem -days 365 \ + -subj "/CN=localhost" + +RUN CGO_ENABLED=0 GOOS=linux go build -o gateway ./cmd/gateway/main.go + +FROM alpine:latest + +RUN adduser -HD server + +WORKDIR /app + +COPY --from=builder /app/gateway /app/ +COPY --from=builder /app/SSL /app/SSL +COPY ./configs /app/configs +COPY ./geodb /app/geodb +COPY ./.env /app/ + +RUN chown -R server:server /app +USER server + +EXPOSE 5150 +CMD ["/app/gateway"] diff --git a/SSL/create_cert.txt b/SSL/create_cert.txt new file mode 100644 index 0000000..1a10ca5 --- /dev/null +++ b/SSL/create_cert.txt @@ -0,0 +1,2 @@ +openssl req -x509 -newkey rsa:4096 -nodes -keyout privkey.pem -out fullchain.pem -days 365 \ + -subj "/CN=localhost" diff --git a/cmd/gateway/main.go b/cmd/gateway/main.go new file mode 100644 index 0000000..badd95f --- /dev/null +++ b/cmd/gateway/main.go @@ -0,0 +1,85 @@ +package main + +import ( + "context" + "flag" + "log/slog" + "os" + "os/signal" + "syscall" + "time" + + "yobbly-gateway-go/internal/config" + "yobbly-gateway-go/internal/logger" + "yobbly-gateway-go/internal/server" + "yobbly-gateway-go/pkg/geoip" +) + +func main() { + var configPath string + flag.StringVar(&configPath, "config", "configs/config.yml", "config file path") + flag.Parse() + + if configPath == "" { + configPath = os.Getenv("CONFIG_PATH") + } + + logger.InitLogger("info", "text") + + op := "main.main" + log := logger.NewLoggerWithOp(op) + + cfg, err := config.Load(configPath) + if err != nil { + log.Error("failed to load configuration", slog.String("path", configPath), slog.Any("error", err)) + os.Exit(1) + } + + // Повторно инициализируем логгер с настройками из конфигурации + logger.InitLogger(cfg.Logging.Level, cfg.Logging.Format) + log = logger.NewLoggerWithOp(op) // Повторно создаем логгер с обновленным уровнем и форматом + + log.Info("configuration loaded successfully") + + // Инициализируем сервис GeoIP + geoipService, err := geoip.NewGeoIPService(cfg.Gateway.GeoIPDB, cfg.Gateway.BlockedCountries) + if err != nil { + log.Error("failed to initialize GeoIP service", slog.Any("error", err)) + os.Exit(1) + } + defer geoipService.Close() + + // Создаем экземпляр сервера + srv := server.NewServer(cfg, geoipService) + + // Запускаем сервер в отдельной горутине + done := make(chan struct{}) + errChan := srv.Start(cfg.TLS.FullChain, cfg.TLS.PrivateKey) + + // Настраиваем перехват системных сигналов для graceful shutdown + sigChan := make(chan os.Signal, 1) + signal.Notify(sigChan, syscall.SIGINT, syscall.SIGTERM) + + go func() { + select { + case err := <-errChan: + if err != nil { + log.Error("server error", slog.Any("error", err)) + } + case sig := <-sigChan: + log.Info("received signal", slog.String("signal", sig.String())) + // Создаем контекст с таймаутом для graceful shutdown + shutdownCtx, cancel := context.WithTimeout(context.Background(), 35*time.Second) // TODO: Make configurable + defer cancel() + + if err := srv.Shutdown(shutdownCtx); err != nil { + log.Error("server graceful shutdown failed", slog.Any("error", err)) + os.Exit(1) + } + } + close(done) + }() + + <-done + log.Info("application stopped") +} diff --git a/configs/config.yml b/configs/config.yml new file mode 100644 index 0000000..478aa9a --- /dev/null +++ b/configs/config.yml @@ -0,0 +1,18 @@ +server: + host: ${SERVER_HOST:0.0.0.0} + port: ${SERVER_PORT:5150} + +gateway: + debug: false + route_file: ${ROUTE_FILE_PATH:configs/routes.json} + geoip_db: ${GEO_DB_FILE_PATH:geodb/GeoLite2-Country.mmdb} + blocked_countries: + - US + +tls: + full_chain: ${TLS_FULLCHAIN_CERT:SSL/fullchain.pem} + private_key: ${TLS_PRIVATE_KEY:SSL/privkey.pem} + +logging: + level: ${LOG_LEVEL:debug} # debug, info, warn, error + format: ${LOG_FORMAT:text} # text, json diff --git a/configs/routes.json b/configs/routes.json new file mode 100644 index 0000000..bdf6570 --- /dev/null +++ b/configs/routes.json @@ -0,0 +1,64 @@ +{ + "v1": { + "/auth": [ + { + "url": "https://0.0.0.0:5201", + "role": "master", + "allow_self_signed": true + } + ], + "/user": [ + { + "url": "https://yobble-user-service:5202", + "role": "master", + "allow_self_signed": true + } + ], + "/profile": [ + { + "url": "https://yobble-profile-service:5203", + "role": "master", + "allow_self_signed": true + } + ], + "/feed": [ + { + "url": "https://yobble-feed-service:5204", + "role": "master", + "allow_self_signed": true + } + ], + "/chat/private": [ + { + "url": "https://localhost:5205", + "role": "master", + "allow_self_signed": true + } + ] + }, + "v2": { + "/auth": [ + { + "url": "http://auth_v2_service:5301", + "role": "master", + "allow_self_signed": false + } + ] + }, + "default": { + "/docs": [ + { + "url": "https://yobble-docs-service:5199", + "role": "slave", + "allow_self_signed": true + } + ], + "/test": [ + { + "url": "https://localhost:9097", + "role": "test", + "allow_self_signed": true + } + ] + } +} diff --git a/docker-compose-example.yml b/docker-compose-example.yml new file mode 100644 index 0000000..63cfe90 --- /dev/null +++ b/docker-compose-example.yml @@ -0,0 +1,13 @@ +services: + gateway: + build: . + container_name: gateway + ports: + - "127.0.0.1:${EXTERNAL_GATEWAY_PORT}:5150" + env_file: + - .env +# ПИТОНИСТ ЧТОБЫ ПРОКИНУТЬ SSL или иные конфиги примонтируй соответствующие volume +# volumes: +# - ./configs:/app/configs +# - ./geodb:/app/geodb +# - ./SSL:/app/SSL diff --git a/geodb/GeoLite2-Country.mmdb b/geodb/GeoLite2-Country.mmdb new file mode 100644 index 0000000..f9c374d Binary files /dev/null and b/geodb/GeoLite2-Country.mmdb differ diff --git a/go.mod b/go.mod new file mode 100644 index 0000000..dcbbfd4 --- /dev/null +++ b/go.mod @@ -0,0 +1,17 @@ +module yobbly-gateway-go + +go 1.24.3 + +require ( + github.com/joho/godotenv v1.5.1 + github.com/lmittmann/tint v1.1.2 + github.com/oschwald/geoip2-golang v1.11.0 + golang.org/x/net v0.41.0 + gopkg.in/yaml.v3 v3.0.1 +) + +require ( + github.com/oschwald/maxminddb-golang v1.13.0 // indirect + golang.org/x/sys v0.33.0 // indirect + golang.org/x/text v0.26.0 // indirect +) diff --git a/go.sum b/go.sum new file mode 100644 index 0000000..f74a872 --- /dev/null +++ b/go.sum @@ -0,0 +1,24 @@ +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/joho/godotenv v1.5.1 h1:7eLL/+HRGLY0ldzfGMeQkb7vMd0as4CfYvUVzLqw0N0= +github.com/joho/godotenv v1.5.1/go.mod h1:f4LDr5Voq0i2e/R5DDNOoa2zzDfwtkZa6DnEwAbqwq4= +github.com/lmittmann/tint v1.1.2 h1:2CQzrL6rslrsyjqLDwD11bZ5OpLBPU+g3G/r5LSfS8w= +github.com/lmittmann/tint v1.1.2/go.mod h1:HIS3gSy7qNwGCj+5oRjAutErFBl4BzdQP6cJZ0NfMwE= +github.com/oschwald/geoip2-golang v1.11.0 h1:hNENhCn1Uyzhf9PTmquXENiWS6AlxAEnBII6r8krA3w= +github.com/oschwald/geoip2-golang v1.11.0/go.mod h1:P9zG+54KPEFOliZ29i7SeYZ/GM6tfEL+rgSn03hYuUo= +github.com/oschwald/maxminddb-golang v1.13.0 h1:R8xBorY71s84yO06NgTmQvqvTvlS/bnYZrrWX1MElnU= +github.com/oschwald/maxminddb-golang v1.13.0/go.mod h1:BU0z8BfFVhi1LQaonTwwGQlsHUEu9pWNdMfmq4ztm0o= +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.9.0 h1:HtqpIVDClZ4nwg75+f6Lvsy/wHu+3BoSGCbBAcpTsTg= +github.com/stretchr/testify v1.9.0/go.mod h1:r2ic/lqez/lEtzL7wO/rwa5dbSLXVDPFyf8C91i36aY= +golang.org/x/net v0.41.0 h1:vBTly1HeNPEn3wtREYfy4GZ/NECgw2Cnl+nK6Nz3uvw= +golang.org/x/net v0.41.0/go.mod h1:B/K4NNqkfmg07DQYrbwvSluqCJOOXwUjeb/5lOisjbA= +golang.org/x/sys v0.33.0 h1:q3i8TbbEz+JRD9ywIRlyRAQbM0qF7hu24q3teo2hbuw= +golang.org/x/sys v0.33.0/go.mod h1:BJP2sWEmIv4KK5OTEluFJCKSidICx8ciO85XgH3Ak8k= +golang.org/x/text v0.26.0 h1:P42AVeLghgTYr4+xUnTRKDMqpar+PtX7KWuNQL21L8M= +golang.org/x/text v0.26.0/go.mod h1:QK15LZJUUQVJxhz7wXgxSy/CJaTFjd0G+YLonydOVQA= +gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405 h1:yhCVgyC4o1eVCa2tZl7eS0r+SDo693bJlVdllGtEeKM= +gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= +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/config/config.go b/internal/config/config.go new file mode 100644 index 0000000..366eda4 --- /dev/null +++ b/internal/config/config.go @@ -0,0 +1,111 @@ +package config + +import ( + "encoding/json" + "github.com/joho/godotenv" + "log/slog" + "os" + "regexp" + "yobbly-gateway-go/internal/logger" + + "gopkg.in/yaml.v3" +) + +// Backend представляет собой отдельный сервер бэкенда. +type Backend struct { + URL string `json:"url"` + Role string `json:"role"` + AllowSelfSigned bool `json:"allow_self_signed"` +} + +// Routes сопоставляет строки версий с префиксами маршрутов и бэкендами. +type Routes map[string]map[string][]Backend + +// ServerConfig содержит настройки, связанные с сервером. +type ServerConfig struct { + Host string `yaml:"host"` + Port string `yaml:"port"` +} + +// GatewayConfig содержит настройки, специфичные для шлюза. +type GatewayConfig struct { + RouteFile string `yaml:"route_file"` + GeoIPDB string `yaml:"geoip_db"` + BlockedCountries []string `yaml:"blocked_countries"` +} + +// TLSConfig содержит настройки, связанные с TLS. +type TLSConfig struct { + FullChain string `yaml:"full_chain"` + PrivateKey string `yaml:"private_key"` +} + +// LogConfig содержит настройки, связанные с логированием. +type LogConfig struct { + Level string `yaml:"level"` + Format string `yaml:"format"` +} + +// Settings содержит всю конфигурацию приложения. +type Settings struct { + Server ServerConfig `yaml:"server"` + Gateway GatewayConfig `yaml:"gateway"` + TLS TLSConfig `yaml:"tls"` + Logging LogConfig `yaml:"logging"` + + RouteConfig Routes `yaml:"-"` // Загружается отдельно, не из корня YAML +} + +// Load возвращает новый объект конфигурации, считывая YAML-файл. +func Load(configPath string) (*Settings, error) { + op := "config.Load" + log := logger.NewLoggerWithOp(op) + + if err := godotenv.Load(); err != nil { + log.Warn("[Attention]: No .env file found or failed to load, using STANDARD settings!") + } + + configFile, err := os.ReadFile(configPath) + if err != nil { + log.Error("failed to read config file", slog.String("path", configPath), slog.Any("error", err)) + return nil, err + } + processed := replaceEnvVars(string(configFile)) + + var settings Settings + if err := yaml.Unmarshal([]byte(processed), &settings); err != nil { + log.Error("failed to unmarshal config file", slog.String("path", configPath), slog.Any("error", err)) + return nil, err + } + + // Загружаем маршруты из JSON-файла + routeFile, err := os.ReadFile(settings.Gateway.RouteFile) + if err != nil { + log.Error("failed to read route file", slog.String("path", settings.Gateway.RouteFile), slog.Any("error", err)) + return nil, err + } + + var routes Routes + if err := json.Unmarshal(routeFile, &routes); err != nil { + log.Error("failed to unmarshal route file", slog.String("path", settings.Gateway.RouteFile), slog.Any("error", err)) + return nil, err + } + settings.RouteConfig = routes + + return &settings, nil +} + +// replaceEnvVars Поддержка ${ENV_VAR:default} AKA SpringBoot +func replaceEnvVars(input string) string { + re := regexp.MustCompile(`\$\{([^}:\s]+)(?::([^}]*))?\}`) + return re.ReplaceAllStringFunc(input, func(s string) string { + matches := re.FindStringSubmatch(s) + envVar := matches[1] + defVal := matches[2] + val := os.Getenv(envVar) + if val == "" { + val = defVal + } + return val + }) +} diff --git a/internal/logger/logger.go b/internal/logger/logger.go new file mode 100644 index 0000000..890451b --- /dev/null +++ b/internal/logger/logger.go @@ -0,0 +1,63 @@ +package logger + +import ( + "log/slog" + "os" + "strings" + + "github.com/lmittmann/tint" +) + +var defaultLogger *slog.Logger + +// InitLogger инициализирует логгер slog по умолчанию на основе предоставленного уровня и формата. +func InitLogger(levelStr, format string) *slog.Logger { + var level slog.Level + switch strings.ToLower(levelStr) { + case "debug": + level = slog.LevelDebug + case "info": + level = slog.LevelInfo + case "warn": + level = slog.LevelWarn + case "error": + level = slog.LevelError + default: + level = slog.LevelInfo + } + + var handler slog.Handler + output := os.Stdout + + switch strings.ToLower(format) { + case "json": + handler = slog.NewJSONHandler(output, &slog.HandlerOptions{ + Level: level, + }) + case "text": + handler = tint.NewHandler(output, &tint.Options{ + Level: level, + TimeFormat: "15:04:05", // HH:MM:SS + AddSource: false, // Optional: set to true to add file:line + }) + default: + handler = tint.NewHandler(output, &tint.Options{ + Level: level, + TimeFormat: "15:04:05", + AddSource: false, + }) // Default to colored Text if unknown + } + + defaultLogger = slog.New(handler) + slog.SetDefault(defaultLogger) + return defaultLogger +} + +// NewLoggerWithOp создает новый экземпляр логгера с установленным полем "op". +func NewLoggerWithOp(op string) *slog.Logger { + if defaultLogger == nil { + // Fallback if InitLogger was not called, though it should be. + defaultLogger = InitLogger("info", "text") + } + return defaultLogger.With(slog.String("op", op)) +} diff --git a/internal/middleware/middleware.go b/internal/middleware/middleware.go new file mode 100644 index 0000000..4bb26ef --- /dev/null +++ b/internal/middleware/middleware.go @@ -0,0 +1,75 @@ +package middleware + +import ( + "context" + "log/slog" + "net" + "net/http" + "strings" + + "yobbly-gateway-go/internal/logger" +) + +type contextKey string + +const realIPContextKey contextKey = "realIP" + +// RealIPMiddleware извлекает реальный IP-адрес клиента из заголовков и устанавливает его в контекст запроса. +func RealIPMiddleware(next http.Handler) http.Handler { + op := "middleware.RealIPMiddleware" + log := logger.NewLoggerWithOp(op) + + return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + realIP := r.Header.Get("X-Real-IP") + if realIP == "" { + forwardedFor := r.Header.Get("X-Forwarded-For") + if forwardedFor != "" { + parts := strings.Split(forwardedFor, ",") + realIP = strings.TrimSpace(parts[0]) + } + } + + if realIP != "" { + _, port, err := net.SplitHostPort(r.RemoteAddr) + if err == nil { + r.RemoteAddr = net.JoinHostPort(realIP, port) + } else { + r.RemoteAddr = realIP + } + ctx := context.WithValue(r.Context(), realIPContextKey, realIP) + r = r.WithContext(ctx) + log.Debug("real IP extracted", slog.String("ip", realIP)) + } + + next.ServeHTTP(w, r) + }) +} + +// GetRealIP извлекает реальный IP из контекста запроса. +func GetRealIP(r *http.Request) string { + if ip, ok := r.Context().Value(realIPContextKey).(string); ok { + return ip + } + return r.RemoteAddr +} + +// RemoveTrailingSlashMiddleware перенаправляет запросы с завершающим слэшем (если это не просто '/') +// на тот же путь без завершающего слэша. +func RemoveTrailingSlashMiddleware(next http.Handler) http.Handler { + op := "middleware.RemoveTrailingSlashMiddleware" + log := logger.NewLoggerWithOp(op) + + return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + urlPath := r.URL.Path + + if urlPath != "" && urlPath != "/" && strings.HasSuffix(urlPath, "/") { + newPath := strings.TrimSuffix(urlPath, "/") + newURL := *r.URL + newURL.Path = newPath + log.Debug("redirecting trailing slash", slog.String("old_path", urlPath), slog.String("new_path", newPath)) + http.Redirect(w, r, newURL.String(), http.StatusMovedPermanently) + return + } + next.ServeHTTP(w, r) + }) +} diff --git a/internal/proxy/handler.go b/internal/proxy/handler.go new file mode 100644 index 0000000..3bf39de --- /dev/null +++ b/internal/proxy/handler.go @@ -0,0 +1,86 @@ +package proxy + +import ( + "crypto/tls" + "log/slog" + "net/http" + "net/http/httputil" + "net/url" + + "yobbly-gateway-go/internal/config" + "yobbly-gateway-go/internal/logger" + "yobbly-gateway-go/internal/middleware" + "yobbly-gateway-go/pkg/geoip" +) + +// NewProxyHandler создает новый HTTP-обработчик, который выполняет обратное проксирование запросов. +func NewProxyHandler(cfg *config.Settings, geoIPService *geoip.GeoIPService) http.Handler { + op := "proxy.NewProxyHandler" + log := logger.NewLoggerWithOp(op) + + return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + // Проверка GeoIP + realIP := middleware.GetRealIP(r) + if realIP != "" { + isBlocked, countryCode := geoIPService.IsCountryBlocked(realIP) + if isBlocked { + log.Warn("access denied due to blocked country", slog.String("ip", realIP), slog.String("country_code", countryCode)) + http.Error(w, "Access denied due to service policy.\n", http.StatusForbidden) + return + } + } + + backend, tailPath := ResolveBackend(r.URL.Path, cfg.RouteConfig) + + if backend == nil { + log.Warn("no route found for path", slog.String("path", r.URL.Path)) + http.Error(w, "Route not found", http.StatusNotFound) + return + } + + backendURL, err := url.Parse(backend.URL) + if err != nil { + log.Error("failed to parse backend URL", slog.String("url", backend.URL), slog.Any("error", err)) + http.Error(w, "Upstream error", http.StatusBadGateway) + return + } + + // Создаем обратный прокси + proxy := httputil.NewSingleHostReverseProxy(backendURL) + + // Изменяем запрос, который будет отправлен на бэкенд + originalDirector := proxy.Director + proxy.Director = func(req *http.Request) { + originalDirector(req) // Устанавливает базовые заголовки и URL + + // Устанавливаем правильный путь для бэкенда + req.URL.Path = tailPath + + // Устанавливаем заголовки для информирования бэкенда об исходном запросе + req.Header.Set("X-Real-IP", realIP) + req.Header.Set("X-Forwarded-For", realIP) + req.Header.Set("X-Forwarded-Host", r.Host) + req.Header.Set("X-Forwarded-Proto", r.URL.Scheme) + // Удаляем заголовки, которые могут раскрыть информацию о внутреннем сервере + req.Header.Del("Server") + req.Header.Del("X-Powered-By") + } + + // Изменяем ответ от бэкенда перед отправкой клиенту + proxy.ModifyResponse = func(resp *http.Response) error { + resp.Header.Del("Server") + resp.Header.Del("X-Powered-By") + return nil + } + + // Пользовательский транспорт для обработки allow_self_signed + if backend.AllowSelfSigned { + proxy.Transport = &http.Transport{ + TLSClientConfig: &tls.Config{InsecureSkipVerify: true}, + } + } + + log.Info("forwarding request", slog.String("from", r.URL.Path), slog.String("to_backend", backendURL.String()), slog.String("with_path", tailPath)) + proxy.ServeHTTP(w, r) + }) +} \ No newline at end of file diff --git a/internal/proxy/routing.go b/internal/proxy/routing.go new file mode 100644 index 0000000..fa32a99 --- /dev/null +++ b/internal/proxy/routing.go @@ -0,0 +1,105 @@ +package proxy + +import ( + "log/slog" + "math/rand" + "sort" + "strings" + + "yobbly-gateway-go/internal/config" + "yobbly-gateway-go/internal/logger" +) + +// findBestMatch ищет самый длинный совпадающий префикс в заданных маршрутах. +// Он возвращает найденный префикс и остаток пути (хвост). +func findBestMatch(versionRoutes map[string][]config.Backend, path string) (string, string) { + op := "proxy.findBestMatch" + log := logger.NewLoggerWithOp(op) + + var prefixes []string + for prefix := range versionRoutes { + prefixes = append(prefixes, prefix) + } + + // Сортируем префиксы по длине в порядке убывания, чтобы сначала найти самый длинный + sort.Slice(prefixes, func(i, j int) bool { + return len(prefixes[i]) > len(prefixes[j]) + }) + + for _, prefix := range prefixes { + if strings.HasPrefix(path, prefix) { + log.Debug("found best match", slog.String("path", path), slog.String("prefix", prefix)) + return prefix, path[len(prefix):] + } + } + + log.Debug("no best match found", slog.String("path", path)) + return "", "" +} + +// SelectBackend выбирает бэкенд из списка доступных серверов. +// В настоящее время он выбирает случайный, что соответствует реализации на Python. +func selectBackend(backends []config.Backend) *config.Backend { + op := "proxy.selectBackend" + log := logger.NewLoggerWithOp(op) + + if len(backends) == 0 { + log.Warn("no backends available") + return nil + } + selected := &backends[rand.Intn(len(backends))] + log.Debug("selected backend", slog.String("url", selected.URL)) + return selected +} + +// ResolveBackend находит подходящий сервис бэкенда для заданного пути запроса. +// Он анализирует версию, находит наиболее подходящий маршрут и выбирает сервер бэкенда. +func ResolveBackend(path string, routes config.Routes) (*config.Backend, string) { + op := "proxy.ResolveBackend" + log := logger.NewLoggerWithOp(op) + + parts := strings.Split(strings.Trim(path, "/"), "/") + if len(parts) == 0 { + log.Debug("empty path after trimming", slog.String("path", path)) + return nil, "" + } + + var versionRoutes map[string][]config.Backend + var pathToMatch string + + // Проверяем, является ли первая часть пути строкой версии + if routes[parts[0]] != nil { + versionRoutes = routes[parts[0]] + if len(parts) > 1 { + pathToMatch = "/" + strings.Join(parts[1:], "/") + } else { + pathToMatch = "/" + } + log.Debug("versioned route detected", slog.String("version", parts[0]), slog.String("pathToMatch", pathToMatch)) + } else { + // Возвращаемся к версии по умолчанию + versionRoutes = routes["default"] + pathToMatch = path + log.Debug("default route used", slog.String("pathToMatch", pathToMatch)) + } + + if versionRoutes == nil { + log.Warn("no version routes found", slog.String("path", path)) + return nil, "" + } + + routePrefix, tailPath := findBestMatch(versionRoutes, pathToMatch) + if routePrefix == "" { + log.Warn("no route prefix found", slog.String("path", pathToMatch)) + return nil, "" + } + + backend := selectBackend(versionRoutes[routePrefix]) + if backend == nil { + log.Warn("no backend selected for route prefix", slog.String("route_prefix", routePrefix)) + return nil, "" + } + + log.Debug("resolved backend", slog.String("backend_url", backend.URL), slog.String("tail_path", tailPath)) + return backend, tailPath +} diff --git a/internal/server/server.go b/internal/server/server.go new file mode 100644 index 0000000..6e9ff31 --- /dev/null +++ b/internal/server/server.go @@ -0,0 +1,92 @@ +package server + +import ( + "context" + "errors" + "fmt" + "log/slog" + "net/http" + "time" + + "golang.org/x/net/http2" + + "yobbly-gateway-go/internal/config" + "yobbly-gateway-go/internal/logger" + "yobbly-gateway-go/internal/middleware" + "yobbly-gateway-go/internal/proxy" + "yobbly-gateway-go/pkg/geoip" +) + +// Server обертка для http.Server с дополнительными полями. +type Server struct { + httpServer *http.Server + log *slog.Logger +} + +// NewServer создает новый экземпляр Server. +func NewServer(cfg *config.Settings, geoIPService *geoip.GeoIPService) *Server { + op := "server.NewServer" + log := logger.NewLoggerWithOp(op) + + // Create the main proxy handler + proxyHandler := proxy.NewProxyHandler(cfg, geoIPService) + + // Apply middleware chain + chain := middleware.RemoveTrailingSlashMiddleware( + middleware.RealIPMiddleware( + proxyHandler, + ), + ) + + addr := fmt.Sprintf("%s:%s", cfg.Server.Host, cfg.Server.Port) + + httpServer := &http.Server{ + Addr: addr, + Handler: chain, // Use the chained middleware as the main handler + // TODO: Add ReadHeaderTimeout, ReadTimeout, WriteTimeout, IdleTimeout from config + } + + // Explicitly enable HTTP/2 + http2.ConfigureServer(httpServer, nil) + + return &Server{ + httpServer: httpServer, + log: log, + } +} + +// Start запускает HTTP/HTTPS сервер в отдельной горутине. +// Он возвращает канал, который будет закрыт, когда сервер завершит работу. +func (s *Server) Start(fullchain, privkey string) <-chan error { + errChan := make(chan error, 1) + + s.log.Info("server starting with TLS and HTTP/2", slog.String("address", s.httpServer.Addr)) + + go func() { + defer close(errChan) + if err := s.httpServer.ListenAndServeTLS(fullchain, privkey); err != nil && !errors.Is(err, http.ErrServerClosed) { + s.log.Error("server failed to listen and serve", slog.Any("error", err)) + errChan <- err + } + s.log.Info("server stopped listening") + }() + + return errChan +} + +// Shutdown gracefully shuts down the server. +func (s *Server) Shutdown(ctx context.Context) error { + s.log.Info("server is shutting down gracefully") + + // Give the server a grace period to finish active connections + shutdownCtx, cancel := context.WithTimeout(ctx, 30*time.Second) // TODO: Make timeout configurable + defer cancel() + + if err := s.httpServer.Shutdown(shutdownCtx); err != nil { + s.log.Error("server shutdown failed", slog.Any("error", err)) + return err + } + + s.log.Info("server shutdown complete") + return nil +} diff --git a/pkg/geoip/geoip.go b/pkg/geoip/geoip.go new file mode 100644 index 0000000..ead558e --- /dev/null +++ b/pkg/geoip/geoip.go @@ -0,0 +1,77 @@ +package geoip + +import ( + "log/slog" + "net" + "yobbly-gateway-go/internal/logger" + + "github.com/oschwald/geoip2-golang" +) + +// GeoIPService предоставляет методы для поиска GeoIP. +type GeoIPService struct { + reader *geoip2.Reader + blockedCountries map[string]struct{} +} + +// NewGeoIPService создает новый GeoIPService. +func NewGeoIPService(dbPath string, blockedCountries []string) (*GeoIPService, error) { + op := "geoip.NewGeoIPService" + log := logger.NewLoggerWithOp(op) + + reader, err := geoip2.Open(dbPath) + if err != nil { + log.Error("failed to open GeoIP database", slog.String("db_path", dbPath), slog.Any("error", err)) + return nil, err + } + + blockedMap := make(map[string]struct{}) + for _, country := range blockedCountries { + blockedMap[country] = struct{}{} + } + + log.Info("GeoIP service initialized", slog.String("db_path", dbPath), slog.Any("blocked_countries", blockedCountries)) + return &GeoIPService{ + reader: reader, + blockedCountries: blockedMap, + }, + nil +} + +// IsCountryBlocked проверяет, принадлежит ли данный IP-адрес заблокированной стране. +func (s *GeoIPService) IsCountryBlocked(ipStr string) (bool, string) { + op := "geoip.IsCountryBlocked" + log := logger.NewLoggerWithOp(op) + + ip := net.ParseIP(ipStr) + if ip == nil { + log.Warn("invalid IP address for GeoIP lookup", slog.String("ip", ipStr)) + return false, "" + } + + record, err := s.reader.Country(ip) + if err != nil { + // Логируем ошибку, но не блокируем, аналогично поведению Python + log.Error("GeoIP lookup failed", slog.String("ip", ipStr), slog.Any("error", err)) + return false, "" + } + + countryCode := record.Country.IsoCode + log.Debug("GeoIP lookup result", slog.String("ip", ipStr), slog.String("country_code", countryCode)) + + if _, ok := s.blockedCountries[countryCode]; ok { + log.Info("access denied for blocked country", slog.String("ip", ipStr), slog.String("country_code", countryCode)) + return true, countryCode + } + + return false, countryCode +} + +// Close закрывает ридер базы данных GeoIP. +func (s *GeoIPService) Close() error { + op := "geoip.Close" + log := logger.NewLoggerWithOp(op) + + log.Info("closing GeoIP service") + return s.reader.Close() +}