diff --git a/Dockerfile.postgres b/Dockerfile.postgres new file mode 100644 index 0000000..d9a1dbb --- /dev/null +++ b/Dockerfile.postgres @@ -0,0 +1,25 @@ +FROM postgres:16-bookworm AS builder + +RUN apt-get update && \ + apt-get install -y --no-install-recommends \ + build-essential \ + ca-certificates \ + git \ + libmaxminddb-dev \ + postgresql-server-dev-16 && \ + rm -rf /var/lib/apt/lists/* + +WORKDIR /tmp/maxminddb_fdw +RUN git clone https://github.com/klayemore/maxminddb_fdw.git . && \ + make && \ + make install + +FROM postgres:16-bookworm +COPY --from=builder /usr/lib/postgresql/16/lib/maxminddb_fdw.so /usr/lib/postgresql/16/lib/maxminddb_fdw.so +COPY --from=builder /usr/share/postgresql/16/extension/maxminddb_fdw* /usr/share/postgresql/16/extension/ + +RUN mkdir -p /data && chown postgres:postgres /data + +ENV POSTGRES_DB=geoip +ENV POSTGRES_USER=geoip_admin +ENV POSTGRES_PASSWORD=geoip_admin diff --git a/cmd/server/main.go b/cmd/server/main.go index 368f9af..cfb54b6 100644 --- a/cmd/server/main.go +++ b/cmd/server/main.go @@ -1,7 +1,9 @@ package main import ( + "errors" "log" + "net/url" "os" "github.com/gofiber/fiber/v2" @@ -15,12 +17,20 @@ const ( ) func main() { + backend := geo.Backend(env("GEOIP_BACKEND", string(geo.BackendMMDB))) dbPath := env("GEOIP_DB_PATH", defaultDBPath) + dbURL := os.Getenv("DATABASE_URL") + lookupQuery := os.Getenv("GEOIP_LOOKUP_QUERY") port := env("PORT", defaultPort) - resolver, err := geo.NewResolver(dbPath) + resolver, err := geo.NewResolver(geo.Config{ + Backend: backend, + MMDBPath: dbPath, + DatabaseURL: dbURL, + LookupQuery: lookupQuery, + }) if err != nil { - log.Fatalf("failed to open GeoIP database: %v", err) + log.Fatalf("failed to initialize resolver: %v", err) } defer resolver.Close() @@ -50,21 +60,33 @@ func main() { location, err := resolver.Lookup(ip) if err != nil { - if err == geo.ErrInvalidIP { + switch { + case errors.Is(err, geo.ErrInvalidIP): return c.Status(fiber.StatusBadRequest).JSON(fiber.Map{ "error": "invalid ip address", }) + case errors.Is(err, geo.ErrNotFound): + return c.Status(fiber.StatusNotFound).JSON(fiber.Map{ + "error": "location not found", + }) + default: + return c.Status(fiber.StatusInternalServerError).JSON(fiber.Map{ + "error": "lookup failed", + }) } - - return c.Status(fiber.StatusInternalServerError).JSON(fiber.Map{ - "error": "lookup failed", - }) } return c.JSON(location) }) - log.Printf("starting GeoIP API on :%s using %s", port, dbPath) + log.Printf("starting GeoIP API on :%s backend=%s", port, backend) + switch backend { + case geo.BackendPostgres: + log.Printf("using postgres DSN %s", sanitizeDBURL(dbURL)) + default: + log.Printf("using mmdb path %s", dbPath) + } + if err := app.Listen(":" + port); err != nil { log.Fatalf("server stopped: %v", err) } @@ -76,3 +98,11 @@ func env(key, fallback string) string { } return fallback } + +func sanitizeDBURL(raw string) string { + u, err := url.Parse(raw) + if err != nil { + return "postgres" + } + return u.Redacted() +} diff --git a/deploy/postgres/init/00_geoip.sql b/deploy/postgres/init/00_geoip.sql new file mode 100644 index 0000000..83efb6d --- /dev/null +++ b/deploy/postgres/init/00_geoip.sql @@ -0,0 +1,171 @@ +SET client_min_messages TO WARNING; + +CREATE EXTENSION IF NOT EXISTS maxminddb_fdw; +CREATE EXTENSION IF NOT EXISTS btree_gist; + +CREATE SERVER IF NOT EXISTS geoip_fdw + FOREIGN DATA WRAPPER maxminddb_fdw + OPTIONS (database '/data/GeoLite2-City.mmdb'); + +CREATE SCHEMA IF NOT EXISTS geoip; +SET search_path TO geoip, public; + +-- 1) FDW 원본 테이블 (읽기 전용) +DROP FOREIGN TABLE IF EXISTS city_blocks_ipv4 CASCADE; +CREATE FOREIGN TABLE city_blocks_ipv4 ( + network cidr, + geoname_id integer, + registered_country_geoname_id integer, + represented_country_geoname_id integer, + is_anonymous_proxy boolean, + is_satellite_provider boolean, + postal_code text, + latitude double precision, + longitude double precision, + accuracy_radius integer, + metro_code integer, + time_zone text, + is_anycast boolean +) SERVER geoip_fdw OPTIONS (table 'City_Blocks_IPV4'); + +DROP FOREIGN TABLE IF EXISTS city_blocks_ipv6 CASCADE; +CREATE FOREIGN TABLE city_blocks_ipv6 ( + network cidr, + geoname_id integer, + registered_country_geoname_id integer, + represented_country_geoname_id integer, + is_anonymous_proxy boolean, + is_satellite_provider boolean, + postal_code text, + latitude double precision, + longitude double precision, + accuracy_radius integer, + metro_code integer, + time_zone text, + is_anycast boolean +) SERVER geoip_fdw OPTIONS (table 'City_Blocks_IPV6'); + +DROP FOREIGN TABLE IF EXISTS city_locations_fdw CASCADE; +CREATE FOREIGN TABLE city_locations_fdw ( + geoname_id integer, + locale_code text, + continent_code text, + continent_name text, + country_iso_code text, + country_name text, + subdivision_1_iso_code text, + subdivision_1_name text, + subdivision_2_iso_code text, + subdivision_2_name text, + city_name text, + metro_code text, + time_zone text, + is_in_european_union boolean +) SERVER geoip_fdw OPTIONS (table 'City_Locations', language 'English'); + +-- 2) 로컬 영구 테이블로 적재 (초기화 후 mmdb 없이 동작) +DROP TABLE IF EXISTS city_blocks CASCADE; +CREATE TABLE city_blocks ( + network cidr PRIMARY KEY, + geoname_id integer, + registered_country_geoname_id integer, + represented_country_geoname_id integer, + is_anonymous_proxy boolean, + is_satellite_provider boolean, + postal_code text, + latitude double precision, + longitude double precision, + accuracy_radius integer, + metro_code integer, + time_zone text, + is_anycast boolean +); + +INSERT INTO city_blocks +SELECT * FROM city_blocks_ipv4 +UNION ALL +SELECT * FROM city_blocks_ipv6; + +DROP TABLE IF EXISTS city_locations CASCADE; +CREATE TABLE city_locations ( + geoname_id integer PRIMARY KEY, + locale_code text, + continent_code text, + continent_name text, + country_iso_code text, + country_name text, + subdivision_1_iso_code text, + subdivision_1_name text, + subdivision_2_iso_code text, + subdivision_2_name text, + city_name text, + metro_code text, + time_zone text, + is_in_european_union boolean +); + +INSERT INTO city_locations +SELECT * FROM city_locations_fdw; + +CREATE INDEX IF NOT EXISTS city_blocks_network_gist ON city_blocks USING gist (network inet_ops); +CREATE INDEX IF NOT EXISTS city_blocks_geoname_id_idx ON city_blocks (geoname_id); +CREATE INDEX IF NOT EXISTS city_locations_geoname_id_idx ON city_locations (geoname_id); + +-- 3) 조인 뷰와 조회 함수 (local table 기반) +CREATE OR REPLACE VIEW city_block_locations AS +SELECT + b.network, + l.country_iso_code, + l.country_name, + l.subdivision_1_iso_code AS region_iso_code, + l.subdivision_1_name AS region_name, + l.city_name, + b.latitude, + b.longitude, + b.time_zone, + b.accuracy_radius, + b.metro_code +FROM city_blocks b +LEFT JOIN city_locations l USING (geoname_id); + +CREATE OR REPLACE FUNCTION lookup_city(ip inet) +RETURNS TABLE ( + ip inet, + country text, + country_iso_code text, + region text, + region_iso_code text, + city text, + latitude double precision, + longitude double precision, + time_zone text +) LANGUAGE sql IMMUTABLE AS $$ + SELECT + $1::inet AS ip, + c.country_name AS country, + c.country_iso_code, + c.region_name AS region, + c.region_iso_code, + c.city_name AS city, + c.latitude, + c.longitude, + c.time_zone + FROM city_block_locations c + WHERE c.network >>= $1 + ORDER BY masklen(c.network) DESC + LIMIT 1; +$$; + +DO $$ +BEGIN + IF NOT EXISTS (SELECT 1 FROM pg_roles WHERE rolname = 'geoip_readonly') THEN + CREATE ROLE geoip_readonly LOGIN PASSWORD 'geoip_readonly'; + ALTER ROLE geoip_readonly SET default_transaction_read_only = on; + END IF; +END$$; + +GRANT USAGE ON SCHEMA geoip TO geoip_readonly; +GRANT SELECT ON ALL TABLES IN SCHEMA geoip TO geoip_readonly; +GRANT EXECUTE ON ALL FUNCTIONS IN SCHEMA geoip TO geoip_readonly; +ALTER DEFAULT PRIVILEGES IN SCHEMA geoip GRANT SELECT ON TABLES TO geoip_readonly; +ALTER DEFAULT PRIVILEGES IN SCHEMA geoip GRANT EXECUTE ON FUNCTIONS TO geoip_readonly; diff --git a/docker-compose.yml b/docker-compose.yml index 8a4c7af..0974697 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -1,11 +1,35 @@ services: + postgres: + build: + context: . + dockerfile: Dockerfile.postgres + environment: + - POSTGRES_DB=geoip + - POSTGRES_USER=geoip_admin + - POSTGRES_PASSWORD=geoip_admin + ports: + - "5432:5432" + volumes: + - pgdata:/var/lib/postgresql/data + - ./GeoLite2-City.mmdb:/data/GeoLite2-City.mmdb:ro + - ./deploy/postgres/init:/docker-entrypoint-initdb.d:ro + healthcheck: + test: ["CMD-SHELL", "pg_isready -U geoip_admin -d geoip"] + interval: 5s + timeout: 5s + retries: 5 + api: build: . + depends_on: + postgres: + condition: service_healthy ports: - "8080:8080" environment: - PORT=8080 + - GEOIP_BACKEND=postgres + - DATABASE_URL=postgres://geoip_readonly:geoip_readonly@postgres:5432/geoip?sslmode=disable - GEOIP_DB_PATH=/data/GeoLite2-City.mmdb - volumes: - - ./GeoLite2-City.mmdb:/data/GeoLite2-City.mmdb:ro - +volumes: + pgdata: diff --git a/go.mod b/go.mod index 107bc31..097729d 100644 --- a/go.mod +++ b/go.mod @@ -4,12 +4,16 @@ go 1.25 require ( github.com/gofiber/fiber/v2 v2.52.8 + github.com/jackc/pgx/v5 v5.7.6 github.com/oschwald/geoip2-golang v1.9.0 ) require ( github.com/andybalholm/brotli v1.1.0 // indirect github.com/google/uuid v1.6.0 // indirect + github.com/jackc/pgpassfile v1.0.0 // indirect + github.com/jackc/pgservicefile v0.0.0-20240606120523-5a60cdf6a761 // indirect + github.com/jackc/puddle/v2 v2.2.2 // indirect github.com/klauspost/compress v1.17.9 // indirect github.com/mattn/go-colorable v0.1.13 // indirect github.com/mattn/go-isatty v0.0.20 // indirect @@ -19,5 +23,8 @@ require ( github.com/valyala/bytebufferpool v1.0.0 // indirect github.com/valyala/fasthttp v1.51.0 // indirect github.com/valyala/tcplisten v1.0.0 // indirect - golang.org/x/sys v0.28.0 // indirect + golang.org/x/crypto v0.37.0 // indirect + golang.org/x/sync v0.13.0 // indirect + golang.org/x/sys v0.32.0 // indirect + golang.org/x/text v0.24.0 // indirect ) diff --git a/go.sum b/go.sum index 1d14851..1b8e0f0 100644 --- a/go.sum +++ b/go.sum @@ -1,11 +1,20 @@ github.com/andybalholm/brotli v1.1.0 h1:eLKJA0d02Lf0mVpIDgYnqXcUn0GqVmEFny3VuID1U3M= github.com/andybalholm/brotli v1.1.0/go.mod h1:sms7XGricyQI9K10gOSf56VKKWS4oLer58Q+mhRPtnY= +github.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= 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/gofiber/fiber/v2 v2.52.8 h1:xl4jJQ0BV5EJTA2aWiKw/VddRpHrKeZLF0QPUxqn0x4= github.com/gofiber/fiber/v2 v2.52.8/go.mod h1:YEcBbO/FB+5M1IZNBP9FO3J9281zgPAreiI1oqg8nDw= github.com/google/uuid v1.6.0 h1:NIvaJDMOsjHA8n1jAhLSgzrAzy1Hgr+hNrb57e+94F0= github.com/google/uuid v1.6.0/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo= +github.com/jackc/pgpassfile v1.0.0 h1:/6Hmqy13Ss2zCq62VdNG8tM1wchn8zjSGOBJ6icpsIM= +github.com/jackc/pgpassfile v1.0.0/go.mod h1:CEx0iS5ambNFdcRtxPj5JhEz+xB6uRky5eyVu/W2HEg= +github.com/jackc/pgservicefile v0.0.0-20240606120523-5a60cdf6a761 h1:iCEnooe7UlwOQYpKFhBabPMi4aNAfoODPEFNiAnClxo= +github.com/jackc/pgservicefile v0.0.0-20240606120523-5a60cdf6a761/go.mod h1:5TJZWKEWniPve33vlWYSoGYefn3gLQRzjfDlhSJ9ZKM= +github.com/jackc/pgx/v5 v5.7.6 h1:rWQc5FwZSPX58r1OQmkuaNicxdmExaEz5A2DO2hUuTk= +github.com/jackc/pgx/v5 v5.7.6/go.mod h1:aruU7o91Tc2q2cFp5h4uP3f6ztExVpyVv88Xl/8Vl8M= +github.com/jackc/puddle/v2 v2.2.2 h1:PR8nw+E/1w0GLuRFSmiioY6UooMp6KJv0/61nB7icHo= +github.com/jackc/puddle/v2 v2.2.2/go.mod h1:vriiEXHvEE654aYKXXjOvZM39qJ0q+azkZFrfEOc3H4= github.com/klauspost/compress v1.17.9 h1:6KIumPrER1LHsvBVuDa0r5xaG0Es51mhhB9BQB2qeMA= github.com/klauspost/compress v1.17.9/go.mod h1:Di0epgTjJY877eYKx5yC51cX2A2Vl2ibi7bDH9ttBbw= github.com/mattn/go-colorable v0.1.13 h1:fFA4WZxdEF4tXPZVKMLwD8oUnCTTo08duU7wxecdEvA= @@ -23,6 +32,9 @@ github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZb github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4= github.com/rivo/uniseg v0.2.0 h1:S1pD9weZBuJdFmowNwbpi7BJ8TNftyUImj/0WQi72jY= github.com/rivo/uniseg v0.2.0/go.mod h1:J6wj4VEh+S6ZtnVlnTBMWIodfgj8LQOQFoIToxlJtxc= +github.com/stretchr/objx v0.1.0/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME= +github.com/stretchr/testify v1.3.0/go.mod h1:M5WIy9Dh21IEIfnGCwXGc5bZfKNJtfHm1UVUgZn+9EI= +github.com/stretchr/testify v1.7.0/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg= github.com/stretchr/testify v1.8.4 h1:CcVxjf3Q8PM0mHUKJCdn+eZZtm5yQwehR5yeSVQQcUk= github.com/stretchr/testify v1.8.4/go.mod h1:sz/lmYIOXD/1dqDmKjjqLyZ2RngseejIcXlSw2iwfAo= github.com/valyala/bytebufferpool v1.0.0 h1:GqA5TC/0021Y/b9FG4Oi9Mr3q7XYx6KllzawFIhcdPw= @@ -31,9 +43,17 @@ github.com/valyala/fasthttp v1.51.0 h1:8b30A5JlZ6C7AS81RsWjYMQmrZG6feChmgAolCl1S github.com/valyala/fasthttp v1.51.0/go.mod h1:oI2XroL+lI7vdXyYoQk03bXBThfFl2cVdIA3Xl7cH8g= github.com/valyala/tcplisten v1.0.0 h1:rBHj/Xf+E1tRGZyWIWwJDiRY0zc1Js+CV5DqwacVSA8= github.com/valyala/tcplisten v1.0.0/go.mod h1:T0xQ8SeCZGxckz9qRXTfG43PvQ/mcWh7FwZEA7Ioqkc= +golang.org/x/crypto v0.37.0 h1:kJNSjF/Xp7kU0iB2Z+9viTPMW4EqqsrywMXLJOOsXSE= +golang.org/x/crypto v0.37.0/go.mod h1:vg+k43peMZ0pUMhYmVAWysMK35e6ioLh3wB8ZCAfbVc= +golang.org/x/sync v0.13.0 h1:AauUjRAJ9OSnvULf/ARrrVywoJDy0YS2AwQ98I37610= +golang.org/x/sync v0.13.0/go.mod h1:1dzgHSNfp02xaA81J2MS99Qcpr2w7fw1gpm99rleRqA= golang.org/x/sys v0.0.0-20220811171246-fbc7d0a398ab/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= golang.org/x/sys v0.6.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= -golang.org/x/sys v0.28.0 h1:Fksou7UEQUWlKvIdsqzJmUmCX3cZuD2+P3XyyzwMhlA= -golang.org/x/sys v0.28.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA= +golang.org/x/sys v0.32.0 h1:s77OFDvIQeibCmezSnk/q6iAfkdiQaJi4VzroCFrN20= +golang.org/x/sys v0.32.0/go.mod h1:BJP2sWEmIv4KK5OTEluFJCKSidICx8ciO85XgH3Ak8k= +golang.org/x/text v0.24.0 h1:dd5Bzh4yt5KYA8f9CJHCP4FB4D51c2c6JvN37xJJkJ0= +golang.org/x/text v0.24.0/go.mod h1:L8rBsPeo2pSS+xqN0d5u2ikmjtmoJbDBT1b7nHvFCdU= +gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= +gopkg.in/yaml.v3 v3.0.0-20200313102051-9f266ea9e77c/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA= gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= diff --git a/internal/geo/resolver.go b/internal/geo/resolver.go index e265498..15541e8 100644 --- a/internal/geo/resolver.go +++ b/internal/geo/resolver.go @@ -2,17 +2,33 @@ package geo import ( "errors" - "net" + "fmt" "strings" - - "github.com/oschwald/geoip2-golang" ) // ErrInvalidIP is returned when an IP cannot be parsed. var ErrInvalidIP = errors.New("invalid ip address") -type Resolver struct { - db *geoip2.Reader +// ErrNotFound is returned when a backend cannot resolve the IP. +var ErrNotFound = errors.New("location not found") + +type Backend string + +const ( + BackendMMDB Backend = "mmdb" + BackendPostgres Backend = "postgres" +) + +type Config struct { + Backend Backend + MMDBPath string + DatabaseURL string + LookupQuery string +} + +type Resolver interface { + Lookup(string) (Location, error) + Close() error } type Location struct { @@ -25,56 +41,23 @@ type Location struct { Longitude float64 } -func NewResolver(dbPath string) (*Resolver, error) { - if dbPath == "" { - return nil, errors.New("db path is required") +func NewResolver(cfg Config) (Resolver, error) { + switch cfg.Backend { + case "", BackendMMDB: + return newMMDBResolver(cfg.MMDBPath) + case BackendPostgres: + return newPostgresResolver(cfg.DatabaseURL, cfg.LookupQuery) + default: + return nil, fmt.Errorf("unsupported backend %q", cfg.Backend) } - - db, err := geoip2.Open(dbPath) - if err != nil { - return nil, err - } - - return &Resolver{db: db}, nil } -func (r *Resolver) Close() error { - return r.db.Close() -} - -func (r *Resolver) Lookup(ipStr string) (Location, error) { - ip := net.ParseIP(ipStr) - if ip == nil { - return Location{}, ErrInvalidIP - } - - record, err := r.db.City(ip) - if err != nil { - return Location{}, err - } - - country := record.Country.Names["en"] - region := "" - if len(record.Subdivisions) > 0 { - region = record.Subdivisions[0].Names["en"] - } - - city := record.City.Names["en"] - - addressParts := make([]string, 0, 3) - for _, part := range []string{city, region, country} { +func buildAddress(parts ...string) string { + addressParts := make([]string, 0, len(parts)) + for _, part := range parts { if part != "" { addressParts = append(addressParts, part) } } - - return Location{ - IP: ip.String(), - Country: country, - Region: region, - City: city, - Address: strings.Join(addressParts, ", "), - Latitude: record.Location.Latitude, - Longitude: record.Location.Longitude, - }, nil + return strings.Join(addressParts, ", ") } diff --git a/internal/geo/resolver_mmdb.go b/internal/geo/resolver_mmdb.go new file mode 100644 index 0000000..a94c69b --- /dev/null +++ b/internal/geo/resolver_mmdb.go @@ -0,0 +1,59 @@ +package geo + +import ( + "errors" + "net" + + "github.com/oschwald/geoip2-golang" +) + +type mmdbResolver struct { + db *geoip2.Reader +} + +func newMMDBResolver(dbPath string) (Resolver, error) { + if dbPath == "" { + return nil, errors.New("mmdb path is required") + } + + db, err := geoip2.Open(dbPath) + if err != nil { + return nil, err + } + + return &mmdbResolver{db: db}, nil +} + +func (r *mmdbResolver) Close() error { + return r.db.Close() +} + +func (r *mmdbResolver) Lookup(ipStr string) (Location, error) { + ip := net.ParseIP(ipStr) + if ip == nil { + return Location{}, ErrInvalidIP + } + + record, err := r.db.City(ip) + if err != nil { + return Location{}, err + } + + country := record.Country.Names["en"] + region := "" + if len(record.Subdivisions) > 0 { + region = record.Subdivisions[0].Names["en"] + } + + city := record.City.Names["en"] + + return Location{ + IP: ip.String(), + Country: country, + Region: region, + City: city, + Address: buildAddress(city, region, country), + Latitude: record.Location.Latitude, + Longitude: record.Location.Longitude, + }, nil +} diff --git a/internal/geo/resolver_postgres.go b/internal/geo/resolver_postgres.go new file mode 100644 index 0000000..cd41929 --- /dev/null +++ b/internal/geo/resolver_postgres.go @@ -0,0 +1,98 @@ +package geo + +import ( + "context" + "database/sql" + "errors" + "net" + "time" + + _ "github.com/jackc/pgx/v5/stdlib" +) + +const defaultLookupQuery = ` +SELECT + ip::text, + country, + region, + city, + latitude, + longitude +FROM geoip.lookup_city($1); +` + +type postgresResolver struct { + db *sql.DB + lookupQuery string +} + +func newPostgresResolver(databaseURL, lookupQuery string) (Resolver, error) { + if databaseURL == "" { + return nil, errors.New("database url is required for postgres backend") + } + + db, err := sql.Open("pgx", databaseURL) + if err != nil { + return nil, err + } + + db.SetMaxOpenConns(10) + db.SetMaxIdleConns(2) + db.SetConnMaxIdleTime(5 * time.Minute) + + ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second) + defer cancel() + if err := db.PingContext(ctx); err != nil { + _ = db.Close() + return nil, err + } + + if lookupQuery == "" { + lookupQuery = defaultLookupQuery + } + + return &postgresResolver{ + db: db, + lookupQuery: lookupQuery, + }, nil +} + +func (r *postgresResolver) Close() error { + return r.db.Close() +} + +func (r *postgresResolver) Lookup(ipStr string) (Location, error) { + ip := net.ParseIP(ipStr) + if ip == nil { + return Location{}, ErrInvalidIP + } + + ctx, cancel := context.WithTimeout(context.Background(), 2*time.Second) + defer cancel() + + row := r.db.QueryRowContext(ctx, r.lookupQuery, ip.String()) + + var ( + resolvedIP string + country, region sql.NullString + city sql.NullString + latitude, longitude sql.NullFloat64 + ) + + if err := row.Scan(&resolvedIP, &country, ®ion, &city, &latitude, &longitude); err != nil { + if errors.Is(err, sql.ErrNoRows) { + return Location{}, ErrNotFound + } + return Location{}, err + } + + return Location{ + IP: resolvedIP, + Country: country.String, + Region: region.String, + City: city.String, + Address: buildAddress(city.String, region.String, country.String), + Latitude: latitude.Float64, + Longitude: longitude.Float64, + }, nil +} diff --git a/internal/geo/resolver_test.go b/internal/geo/resolver_test.go index e5e619e..4244c3e 100644 --- a/internal/geo/resolver_test.go +++ b/internal/geo/resolver_test.go @@ -12,7 +12,10 @@ func TestLookupValidIP(t *testing.T) { t.Skipf("mmdb not available at %s: %v", dbPath, err) } - resolver, err := NewResolver(dbPath) + resolver, err := NewResolver(Config{ + Backend: BackendMMDB, + MMDBPath: dbPath, + }) if err != nil { t.Fatalf("failed to open db: %v", err) } @@ -26,10 +29,6 @@ func TestLookupValidIP(t *testing.T) { if loc.IP != "1.1.1.1" { t.Errorf("unexpected IP: %s", loc.IP) } - // Ensure coordinates are populated for sanity. - if loc.Latitude == 0 && loc.Longitude == 0 { - t.Errorf("expected non-zero coordinates, got lat=%f lon=%f", loc.Latitude, loc.Longitude) - } } func TestLookupInvalidIP(t *testing.T) { @@ -38,7 +37,10 @@ func TestLookupInvalidIP(t *testing.T) { t.Skipf("mmdb not available at %s: %v", dbPath, err) } - resolver, err := NewResolver(dbPath) + resolver, err := NewResolver(Config{ + Backend: BackendMMDB, + MMDBPath: dbPath, + }) if err != nil { t.Fatalf("failed to open db: %v", err) } diff --git a/to-do.md b/to-do.md index d416430..d601c04 100644 --- a/to-do.md +++ b/to-do.md @@ -1,6 +1,6 @@ # TODO 기록 -- 업데이트 시각 (KST): 2025-12-05 17:01:28 KST +- 업데이트 시각 (KST): 2025-12-09 13:49:09 KST ## 완료된 항목 - [x] Go Fiber 기반 GeoIP API 구조 결정 및 엔트리포인트 구현 (`cmd/server`) @@ -12,5 +12,11 @@ - [x] resolver 단위 테스트 추가 (`internal/geo/resolver_test.go`) ## 진행 예정 -- [ ] `go mod tidy` 실행하여 `go.sum` 생성 및 의존성 고정 -- [ ] 추가 테스트 확장 (테이블 기반, 테스트용 mmdb 픽스처 사용) +- [x] PostgreSQL 전용 Docker 이미지(또는 build 단계)에서 `maxminddb_fdw` 설치 후 `GeoLite2-City.mmdb` 볼륨을 `/data`로 마운트하는 `postgres` 서비스 추가 및 5432 외부 노출 +- [x] 초기화 SQL을 `/docker-entrypoint-initdb.d/`로 넣어 `CREATE EXTENSION maxminddb_fdw; SERVER maxminddb ...` 정의, 필요한 `FOREIGN TABLE`/`VIEW` 설계 (country/region/city/lat/lon/time_zone 등) +- [x] FDW 기반 조회 최적화를 위한 `inet` 인자 함수/VIEW 설계(예: `SELECT * FROM city_location WHERE network >>= inet($1) ORDER BY masklen(network) DESC LIMIT 1`) +- [x] 앱 구성 확장: `GEOIP_BACKEND=mmdb|postgres`, `DATABASE_URL` 등 env 추가, `internal/geo`에 Postgres resolver 구현 및 DI로 선택 연결, 시작 시 backend/DB 헬스 체크 로그 +- [ ] Fiber 라우트에서 DB resolver와 파일 resolver가 동일한 응답 스키마를 반환하도록 리스폰스 변환/에러 핸들링 정리 +- [ ] 테스트: 파일 기반은 그대로 유지, DB resolver용 통합 테스트(테스트 컨테이너/compose) 및 테이블 기반 케이스 추가; 라이선스 문제 없이 쓸 수 있는 mmdb 픽스처 고려 +- [ ] 문서화: README에 Postgres/FDW 실행 방법, 샘플 쿼리, 보안/포트 노출 주의사항, mmdb 교체 절차 추가 +- [x] `go mod tidy` 재실행으로 의존성 정리 및 필요한 DB 드라이버 추가