Add project
This commit is contained in:
parent
ae52ecabe8
commit
d9ffefd5f6
19
.env.example
Normal file
19
.env.example
Normal 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
33
Dockerfile
Normal 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
2
SSL/create_cert.txt
Normal 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
85
cmd/gateway/main.go
Normal 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
18
configs/config.yml
Normal 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
64
configs/routes.json
Normal 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
|
||||
}
|
||||
]
|
||||
}
|
||||
}
|
13
docker-compose-example.yml
Normal file
13
docker-compose-example.yml
Normal 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
BIN
geodb/GeoLite2-Country.mmdb
Normal file
Binary file not shown.
17
go.mod
Normal file
17
go.mod
Normal 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
24
go.sum
Normal 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
111
internal/config/config.go
Normal 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
63
internal/logger/logger.go
Normal 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))
|
||||
}
|
75
internal/middleware/middleware.go
Normal file
75
internal/middleware/middleware.go
Normal 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
86
internal/proxy/handler.go
Normal 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
105
internal/proxy/routing.go
Normal 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
92
internal/server/server.go
Normal 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
77
pkg/geoip/geoip.go
Normal 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()
|
||||
}
|
Loading…
x
Reference in New Issue
Block a user