package main import ( "context" "errors" "fmt" "log" "net/url" "os" "path/filepath" "strconv" "strings" "sync" "time" "github.com/gofiber/fiber/v2" "github.com/gofiber/fiber/v2/middleware/logger" "geoip-rest/internal/geo" "geoip-rest/internal/schedule" ) const ( defaultPort = "8080" defaultDBPath = "/initial_data/GeoLite2-City.mmdb" defaultCron = "5 0 * * *" // 매일 00:05 KST defaultJob = "user-program-sync" ) 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(geo.Config{ Backend: backend, MMDBPath: dbPath, DatabaseURL: dbURL, LookupQuery: lookupQuery, }) if err != nil { log.Fatalf("failed to initialize resolver: %v", err) } defer resolver.Close() app := fiber.New(fiber.Config{ DisableStartupMessage: true, ReadBufferSize: 16 * 1024, // allow larger request headers (e.g., proxy cookies) }) app.Use(newFileLogger(env("ACCESS_LOG_PATH", "/log/api-access.log"))) app.Get("/", func(c *fiber.Ctx) error { return c.JSON(fiber.Map{ "service": "geoip-rest", "endpoints": []string{ "/health", "/lookup?ip=", }, }) }) 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 { 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.JSON(location) }) 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) } stopScheduler := maybeStartScheduler() defer func() { if stopScheduler != nil { ctx := stopScheduler() <-ctx.Done() } }() if err := app.Listen(":" + port); err != nil { log.Fatalf("server stopped: %v", err) } } func newFileLogger(path string) fiber.Handler { if path == "" { return func(c *fiber.Ctx) error { return c.Next() } } if err := os.MkdirAll(filepath.Dir(path), 0o755); err != nil { log.Printf("access log disabled (mkdir failed: %v)", err) return func(c *fiber.Ctx) error { return c.Next() } } maxBytes := int64(envInt("ACCESS_LOG_MAX_BYTES", 10*1024*1024)) writer, err := newRotatingWriter(path, maxBytes) if err != nil { log.Printf("access log disabled (open failed: %v)", err) return func(c *fiber.Ctx) error { return c.Next() } } format := "${time} ip=${ip} real_ip=${header:X-Real-IP} forwarded=${header:X-Forwarded-For} ${method} ${path} ${protocol} ${status} ${latency_human} ua=\"${ua}\" headers=\"${reqHeadersShort}\"\n" cfg := logger.Config{ Format: format, TimeFormat: time.RFC3339, TimeZone: "Asia/Seoul", Output: writer, CustomTags: map[string]logger.LogFunc{ "reqHeadersShort": func(output logger.Buffer, c *fiber.Ctx, data *logger.Data, param string) (int, error) { const max = 1024 h := c.Request().Header.String() if len(h) > max { h = h[:max] + "...(truncated)" } return output.WriteString(strings.TrimSpace(h)) }, }, } return logger.New(cfg) } type rotatingWriter struct { mu sync.Mutex path string maxBytes int64 file *os.File } func newRotatingWriter(path string, maxBytes int64) (*rotatingWriter, error) { f, err := os.OpenFile(path, os.O_APPEND|os.O_CREATE|os.O_WRONLY, 0o644) if err != nil { return nil, err } return &rotatingWriter{ path: path, maxBytes: maxBytes, file: f, }, nil } func (w *rotatingWriter) Write(p []byte) (int, error) { w.mu.Lock() defer w.mu.Unlock() if err := w.rotateIfNeeded(len(p)); err != nil { return 0, err } return w.file.Write(p) } func (w *rotatingWriter) rotateIfNeeded(incoming int) error { info, err := w.file.Stat() if err != nil { return err } if info.Size()+int64(incoming) <= w.maxBytes { return nil } _ = w.file.Close() ts := time.Now().Format("20060102-150405") rotated := fmt.Sprintf("%s.%s", w.path, ts) if err := os.Rename(w.path, rotated); err != nil { // attempt to reopen original to keep logging w.file, _ = os.OpenFile(w.path, os.O_APPEND|os.O_CREATE|os.O_WRONLY, 0o644) return err } f, err := os.OpenFile(w.path, os.O_APPEND|os.O_CREATE|os.O_WRONLY, 0o644) if err != nil { return err } w.file = f return nil } func env(key, fallback string) string { if val := os.Getenv(key); val != "" { return val } return fallback } func envBool(key string, fallback bool) bool { val := os.Getenv(key) if val == "" { return fallback } switch strings.ToLower(val) { case "1", "t", "true", "y", "yes", "on": return true case "0", "f", "false", "n", "no", "off": return false default: return fallback } } func envInt(key string, fallback int) int { val := os.Getenv(key) if val == "" { return fallback } parsed, err := strconv.Atoi(val) if err != nil { return fallback } return parsed } func sanitizeDBURL(raw string) string { u, err := url.Parse(raw) if err != nil { return "postgres" } return u.Redacted() } func maybeStartScheduler() func() context.Context { enabled := envBool("USER_PROGRAM_CRON_ENABLE", false) if !enabled { return nil } cronExpr := defaultCron command := defaultJob sched, err := schedule.Start(schedule.Config{ CronExpr: cronExpr, Command: command, Logger: log.Default(), }) if err != nil { log.Printf("scheduler not started (error=%v)", err) return nil } return func() context.Context { ctx := sched.Stop() timer := time.NewTimer(2 * time.Second) select { case <-ctx.Done(): timer.Stop() return ctx case <-timer.C: return ctx } } }