Add project

This commit is contained in:
AndreyIgorevich 2025-07-10 01:10:47 +07:00
parent ae52ecabe8
commit d9ffefd5f6
17 changed files with 884 additions and 0 deletions

19
.env.example Normal file
View File

@ -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

33
Dockerfile Normal file
View File

@ -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"]

2
SSL/create_cert.txt Normal file
View File

@ -0,0 +1,2 @@
openssl req -x509 -newkey rsa:4096 -nodes -keyout privkey.pem -out fullchain.pem -days 365 \
-subj "/CN=localhost"

85
cmd/gateway/main.go Normal file
View File

@ -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")
}

18
configs/config.yml Normal file
View File

@ -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

64
configs/routes.json Normal file
View File

@ -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
}
]
}
}

View File

@ -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

BIN
geodb/GeoLite2-Country.mmdb Normal file

Binary file not shown.

17
go.mod Normal file
View File

@ -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
)

24
go.sum Normal file
View File

@ -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=

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

@ -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
})
}

63
internal/logger/logger.go Normal file
View File

@ -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))
}

View File

@ -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)
})
}

86
internal/proxy/handler.go Normal file
View File

@ -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)
})
}

105
internal/proxy/routing.go Normal file
View File

@ -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
}

92
internal/server/server.go Normal file
View File

@ -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
}

77
pkg/geoip/geoip.go Normal file
View File

@ -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()
}