commit 5d0221f67d464d1c2644f325a0a8936afd75ad97 Author: Lectom C Han Date: Fri Dec 5 17:40:46 2025 +0900 simple geo ip server diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..9d77ccb --- /dev/null +++ b/.gitignore @@ -0,0 +1,16 @@ +# Go build artifacts +bin/ +*.exe +*.test +*.out + +# Modules and vendor +vendor/ + +# Editor and OS files +.DS_Store +.idea/ +.vscode/ + +# Env files +.env diff --git a/AGENTS.md b/AGENTS.md new file mode 100644 index 0000000..ff33c62 --- /dev/null +++ b/AGENTS.md @@ -0,0 +1,35 @@ +# Repository Guidelines + +## Project Structure & Module Organization +- `cmd/server/main.go` is the Fiber entrypoint that wires config, routes, and startup logging. +- `internal/geo` owns GeoLite2 lookups, IP validation, and response shaping. +- `docker-compose.yml` defines the container entry; `Dockerfile` builds a static binary. `GeoLite2-City.mmdb` sits at the repo root and is mounted to `/data/GeoLite2-City.mmdb`. +- Keep `cmd/server` thin; place new logic in `internal/` with clear boundaries. + +## Build, Test, and Development Commands +- `PORT=8080 GEOIP_DB_PATH=./GeoLite2-City.mmdb go run ./cmd/server` runs the API locally without Docker. +- `docker compose up --build` builds and starts the containerized service (mounts the local database). +- `curl "http://localhost:8080/lookup?ip=1.1.1.1"` exercises the lookup endpoint; omit `ip` to use the caller’s address. +- `go build ./...` validates compilation before pushing changes. + +## Coding Style & Naming Conventions +- Go 1.22; always format with `gofmt -w`. +- Favor small packages and dependency injection for services (e.g., pass resolvers, do not use globals). +- Package names are lowercase; exported identifiers use CamelCase; environment variables use UPPER_SNAKE. +- Keep JSON response shapes stable; prefer explicit structs over maps for API outputs. + +## Testing Guidelines +- Use Go’s standard testing with `*_test.go` files and run `go test ./...`. +- Prefer table-driven tests for lookup edges (IPv4/IPv6, invalid IP, missing city/region data). +- For integration tests, allow overriding `GEOIP_DB_PATH` with a fixture `.mmdb` to avoid mutating the bundled dataset. + +## Commit & Pull Request Guidelines +- Commit titles should be concise, imperative English with an optional scope (`geo`, `server`, `build`). +- Add a short body describing intent and behavioral impact when relevant. +- PR descriptions should list linked issues, test commands executed, and a sample request/response payload when API output changes. +- Keep images lean; avoid adding unused assets or oversized binaries to the image layers. + +## Security & Configuration Tips +- GeoLite2 carries licensing; refresh by replacing `GeoLite2-City.mmdb` with the latest download rather than mirroring it elsewhere. +- Validate IP inputs and avoid logging full client IPs with stack traces in sensitive environments. +- Behind proxies, configure trusted proxy headers so `X-Forwarded-For` is respected (Fiber `app.Config.ProxyHeader` can be set if needed). diff --git a/Dockerfile b/Dockerfile new file mode 100644 index 0000000..0caa8e3 --- /dev/null +++ b/Dockerfile @@ -0,0 +1,26 @@ +FROM golang:1.25.5-trixie AS builder + +ENV GOCACHE=/tmp/go-build \ + GOPROXY=https://proxy.golang.org,direct + +WORKDIR /src +COPY go.mod ./ +RUN go mod download + +COPY . . +RUN CGO_ENABLED=0 go build -o /bin/geoip ./cmd/server + +FROM debian:trixie-slim + +RUN useradd --create-home --shell /usr/sbin/nologin appuser + +WORKDIR /app + +COPY --from=builder /bin/geoip /usr/local/bin/geoip +COPY GeoLite2-City.mmdb /data/GeoLite2-City.mmdb + +ENV GEOIP_DB_PATH=/data/GeoLite2-City.mmdb +USER appuser + +EXPOSE 8080 +CMD ["geoip"] diff --git a/GeoLite2-City.mmdb b/GeoLite2-City.mmdb new file mode 100644 index 0000000..d39d93a Binary files /dev/null and b/GeoLite2-City.mmdb differ diff --git a/README.md b/README.md new file mode 100644 index 0000000..6e3a4de --- /dev/null +++ b/README.md @@ -0,0 +1,54 @@ +# GeoIP REST (Go Fiber) + +간단한 GeoIP 조회 API입니다. `GeoLite2-City.mmdb`를 사용해 IP를 나라/지역/도시/위도/경도로 매핑합니다. + +## 요구 사항 +- Go 1.25+ +- `GeoLite2-City.mmdb` (레포지토리 루트에 위치) +- Docker / Docker Compose (컨테이너 실행 시) + +## 설치 및 실행 +### 로컬 (Go) +```bash +go mod tidy # 필요한 경우 go.sum 생성 +PORT=8080 GEOIP_DB_PATH=./GeoLite2-City.mmdb go run ./cmd/server +``` + +### Docker Compose +```bash +docker compose up --build +``` +- `GeoLite2-City.mmdb`가 컨테이너에 read-only로 마운트됩니다. +- 기본 포트: `8080`. + +## 환경 변수 +- `PORT` (기본 `8080`): 서버 리스닝 포트 +- `GEOIP_DB_PATH` (기본 `/data/GeoLite2-City.mmdb`): GeoIP 데이터베이스 경로 + +## 사용법 +- 헬스체크: `GET /health` +- 조회: `GET /lookup?ip=` + - `ip` 생략 시 호출자 IP를 사용 + +예시: +```bash +curl "http://localhost:8080/lookup?ip=1.1.1.1" +``` + +응답 예시: +```json +{ + "IP": "1.1.1.1", + "Country": "Australia", + "Region": "Queensland", + "City": "South Brisbane", + "Address": "South Brisbane, Queensland, Australia", + "Latitude": -27.4748, + "Longitude": 153.017 +} +``` + +## 개발 참고 +- 주요 코드: `cmd/server/main.go`, `internal/geo/resolver.go` +- 테스트 실행: `go test ./...` +- 컨테이너 빌드: `docker build -t geoip:local .` diff --git a/cmd/server/main.go b/cmd/server/main.go new file mode 100644 index 0000000..282dd1b --- /dev/null +++ b/cmd/server/main.go @@ -0,0 +1,68 @@ +package main + +import ( + "log" + "os" + + "github.com/gofiber/fiber/v2" + + "geoip-rest/internal/geo" +) + +const ( + defaultPort = "8080" + defaultDBPath = "/data/GeoLite2-City.mmdb" +) + +func main() { + dbPath := env("GEOIP_DB_PATH", defaultDBPath) + port := env("PORT", defaultPort) + + resolver, err := geo.NewResolver(dbPath) + if err != nil { + log.Fatalf("failed to open GeoIP database: %v", err) + } + defer resolver.Close() + + app := fiber.New(fiber.Config{ + DisableStartupMessage: true, + }) + + app.Get("/health", func(c *fiber.Ctx) error { + return c.JSON(fiber.Map{"status": "ok"}) + }) + + app.Get("/lookup", func(c *fiber.Ctx) error { + ip := c.Query("ip") + if ip == "" { + ip = c.IP() + } + + location, err := resolver.Lookup(ip) + if err != nil { + if err == geo.ErrInvalidIP { + return c.Status(fiber.StatusBadRequest).JSON(fiber.Map{ + "error": "invalid ip address", + }) + } + + 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) + if err := app.Listen(":" + port); err != nil { + log.Fatalf("server stopped: %v", err) + } +} + +func env(key, fallback string) string { + if val := os.Getenv(key); val != "" { + return val + } + return fallback +} diff --git a/docker-compose.yml b/docker-compose.yml new file mode 100644 index 0000000..8a4c7af --- /dev/null +++ b/docker-compose.yml @@ -0,0 +1,11 @@ +services: + api: + build: . + ports: + - "8080:8080" + environment: + - PORT=8080 + - GEOIP_DB_PATH=/data/GeoLite2-City.mmdb + volumes: + - ./GeoLite2-City.mmdb:/data/GeoLite2-City.mmdb:ro + diff --git a/go.mod b/go.mod new file mode 100644 index 0000000..107bc31 --- /dev/null +++ b/go.mod @@ -0,0 +1,23 @@ +module geoip-rest + +go 1.25 + +require ( + github.com/gofiber/fiber/v2 v2.52.8 + 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/klauspost/compress v1.17.9 // indirect + github.com/mattn/go-colorable v0.1.13 // indirect + github.com/mattn/go-isatty v0.0.20 // indirect + github.com/mattn/go-runewidth v0.0.16 // indirect + github.com/oschwald/maxminddb-golang v1.11.0 // indirect + github.com/rivo/uniseg v0.2.0 // indirect + 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 +) diff --git a/go.sum b/go.sum new file mode 100644 index 0000000..1d14851 --- /dev/null +++ b/go.sum @@ -0,0 +1,39 @@ +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.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/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= +github.com/mattn/go-colorable v0.1.13/go.mod h1:7S9/ev0klgBDR4GtXTXX8a3vIGJpMovkB8vQcUbaXHg= +github.com/mattn/go-isatty v0.0.16/go.mod h1:kYGgaQfpe5nmfYZH+SKPsOc2e4SrIfOl2e/yFXSvRLM= +github.com/mattn/go-isatty v0.0.20 h1:xfD0iDuEKnDkl03q4limB+vH+GxLEtL/jb4xVJSWWEY= +github.com/mattn/go-isatty v0.0.20/go.mod h1:W+V8PltTTMOvKvAeJH7IuucS94S2C6jfK/D7dTCTo3Y= +github.com/mattn/go-runewidth v0.0.16 h1:E5ScNMtiwvlvB5paMFdw9p4kSQzbXFikJ5SQO6TULQc= +github.com/mattn/go-runewidth v0.0.16/go.mod h1:Jdepj2loyihRzMpdS35Xk/zdY8IAYHsh153qUoGf23w= +github.com/oschwald/geoip2-golang v1.9.0 h1:uvD3O6fXAXs+usU+UGExshpdP13GAqp4GBrzN7IgKZc= +github.com/oschwald/geoip2-golang v1.9.0/go.mod h1:BHK6TvDyATVQhKNbQBdrj9eAvuwOMi2zSFXizL3K81Y= +github.com/oschwald/maxminddb-golang v1.11.0 h1:aSXMqYR/EPNjGE8epgqwDay+P30hCBZIveY0WZbAWh0= +github.com/oschwald/maxminddb-golang v1.11.0/go.mod h1:YmVI+H0zh3ySFR3w+oz8PCfglAFj3PuCmui13+P9zDg= +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/rivo/uniseg v0.2.0 h1:S1pD9weZBuJdFmowNwbpi7BJ8TNftyUImj/0WQi72jY= +github.com/rivo/uniseg v0.2.0/go.mod h1:J6wj4VEh+S6ZtnVlnTBMWIodfgj8LQOQFoIToxlJtxc= +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= +github.com/valyala/bytebufferpool v1.0.0/go.mod h1:6bBcMArwyJ5K/AmCkWv1jt77kVWyCJ6HpOuEn7z0Csc= +github.com/valyala/fasthttp v1.51.0 h1:8b30A5JlZ6C7AS81RsWjYMQmrZG6feChmgAolCl1SqA= +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/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= +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 new file mode 100644 index 0000000..e265498 --- /dev/null +++ b/internal/geo/resolver.go @@ -0,0 +1,80 @@ +package geo + +import ( + "errors" + "net" + "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 +} + +type Location struct { + IP string + Country string + Region string + City string + Address string + Latitude float64 + Longitude float64 +} + +func NewResolver(dbPath string) (*Resolver, error) { + if dbPath == "" { + return nil, errors.New("db path is required") + } + + 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} { + 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 +} diff --git a/internal/geo/resolver_test.go b/internal/geo/resolver_test.go new file mode 100644 index 0000000..e5e619e --- /dev/null +++ b/internal/geo/resolver_test.go @@ -0,0 +1,50 @@ +package geo + +import ( + "os" + "path/filepath" + "testing" +) + +func TestLookupValidIP(t *testing.T) { + dbPath := filepath.Join("..", "..", "GeoLite2-City.mmdb") + if _, err := os.Stat(dbPath); err != nil { + t.Skipf("mmdb not available at %s: %v", dbPath, err) + } + + resolver, err := NewResolver(dbPath) + if err != nil { + t.Fatalf("failed to open db: %v", err) + } + defer resolver.Close() + + loc, err := resolver.Lookup("1.1.1.1") + if err != nil { + t.Fatalf("lookup failed: %v", err) + } + + 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) { + dbPath := filepath.Join("..", "..", "GeoLite2-City.mmdb") + if _, err := os.Stat(dbPath); err != nil { + t.Skipf("mmdb not available at %s: %v", dbPath, err) + } + + resolver, err := NewResolver(dbPath) + if err != nil { + t.Fatalf("failed to open db: %v", err) + } + defer resolver.Close() + + if _, err := resolver.Lookup("not-an-ip"); err == nil { + t.Fatal("expected error for invalid IP") + } +} diff --git a/to-do.md b/to-do.md new file mode 100644 index 0000000..d416430 --- /dev/null +++ b/to-do.md @@ -0,0 +1,16 @@ +# TODO 기록 + +- 업데이트 시각 (KST): 2025-12-05 17:01:28 KST + +## 완료된 항목 +- [x] Go Fiber 기반 GeoIP API 구조 결정 및 엔트리포인트 구현 (`cmd/server`) +- [x] GeoLite2 조회 로직 작성 (`internal/geo/resolver.go`) +- [x] Dockerfile, docker-compose로 컨테이너 실행 경로 구성 (GeoLite2 DB 볼륨 마운트) +- [x] 기여 가이드 문서 작성 (`AGENTS.md`) +- [x] Dockerfile 빌더/런타임 이미지 1.25.5-trixie로 전환하고 불필요 패키지 제거 +- [x] README 작성 및 응답 샘플 추가 +- [x] resolver 단위 테스트 추가 (`internal/geo/resolver_test.go`) + +## 진행 예정 +- [ ] `go mod tidy` 실행하여 `go.sum` 생성 및 의존성 고정 +- [ ] 추가 테스트 확장 (테이블 기반, 테스트용 mmdb 픽스처 사용)