simple geo ip server

This commit is contained in:
Lectom C Han
2025-12-05 17:40:46 +09:00
commit 5d0221f67d
12 changed files with 418 additions and 0 deletions

16
.gitignore vendored Normal file
View File

@@ -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

35
AGENTS.md Normal file
View File

@@ -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/<domain>` 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 callers 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 Gos 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).

26
Dockerfile Normal file
View File

@@ -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"]

BIN
GeoLite2-City.mmdb Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 60 MiB

54
README.md Normal file
View File

@@ -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=<IPv4|IPv6>`
- `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 .`

68
cmd/server/main.go Normal file
View File

@@ -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
}

11
docker-compose.yml Normal file
View File

@@ -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

23
go.mod Normal file
View File

@@ -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
)

39
go.sum Normal file
View File

@@ -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=

80
internal/geo/resolver.go Normal file
View File

@@ -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
}

View File

@@ -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")
}
}

16
to-do.md Normal file
View File

@@ -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 픽스처 사용)