simple geo ip server
This commit is contained in:
16
.gitignore
vendored
Normal file
16
.gitignore
vendored
Normal 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
35
AGENTS.md
Normal 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 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).
|
||||
26
Dockerfile
Normal file
26
Dockerfile
Normal 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
BIN
GeoLite2-City.mmdb
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 60 MiB |
54
README.md
Normal file
54
README.md
Normal 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
68
cmd/server/main.go
Normal 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
11
docker-compose.yml
Normal 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
23
go.mod
Normal 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
39
go.sum
Normal 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
80
internal/geo/resolver.go
Normal 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
|
||||
}
|
||||
50
internal/geo/resolver_test.go
Normal file
50
internal/geo/resolver_test.go
Normal 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
16
to-do.md
Normal 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 픽스처 사용)
|
||||
Reference in New Issue
Block a user