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