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