Implement IP API daemon with GeoIP database support
- Create modular architecture with separate packages for config, database, HTTP, logging, and state management - Implement Cobra CLI with daemon command - Set up Uber FX dependency injection - Add Chi router with health check and IP lookup endpoints - Implement GeoIP database downloader with automatic updates - Add state persistence for tracking database download times - Include comprehensive test coverage for all components - Configure structured logging with slog - Add Makefile with test, lint, and build targets - Support both IPv4 and IPv6 lookups - Return country, city, ASN, and location data in JSON format
This commit is contained in:
237
internal/database/database.go
Normal file
237
internal/database/database.go
Normal file
@@ -0,0 +1,237 @@
|
||||
// Package database handles GeoIP database management and downloads.
|
||||
package database
|
||||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
"io"
|
||||
"log/slog"
|
||||
"net/http"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"time"
|
||||
|
||||
"git.eeqj.de/sneak/ipapi/internal/config"
|
||||
"git.eeqj.de/sneak/ipapi/internal/state"
|
||||
"github.com/oschwald/geoip2-golang"
|
||||
)
|
||||
|
||||
const (
|
||||
asnURL = "https://git.io/GeoLite2-ASN.mmdb"
|
||||
cityURL = "https://git.io/GeoLite2-City.mmdb"
|
||||
countryURL = "https://git.io/GeoLite2-Country.mmdb"
|
||||
|
||||
asnFile = "GeoLite2-ASN.mmdb"
|
||||
cityFile = "GeoLite2-City.mmdb"
|
||||
countryFile = "GeoLite2-Country.mmdb"
|
||||
|
||||
downloadTimeout = 5 * time.Minute
|
||||
updateInterval = 7 * 24 * time.Hour // 1 week
|
||||
|
||||
defaultDirPermissions = 0750
|
||||
defaultFilePermissions = 0640
|
||||
)
|
||||
|
||||
// Manager handles GeoIP database operations.
|
||||
type Manager struct {
|
||||
config *config.Config
|
||||
logger *slog.Logger
|
||||
state *state.Manager
|
||||
dataDir string
|
||||
asnDB *geoip2.Reader
|
||||
cityDB *geoip2.Reader
|
||||
countryDB *geoip2.Reader
|
||||
httpClient *http.Client
|
||||
}
|
||||
|
||||
// New creates a new database manager.
|
||||
func New(cfg *config.Config, logger *slog.Logger, state *state.Manager) (*Manager, error) {
|
||||
dataDir := filepath.Join(cfg.StateDir, "databases")
|
||||
|
||||
return &Manager{
|
||||
config: cfg,
|
||||
logger: logger,
|
||||
state: state,
|
||||
dataDir: dataDir,
|
||||
httpClient: &http.Client{
|
||||
Timeout: downloadTimeout,
|
||||
},
|
||||
}, nil
|
||||
}
|
||||
|
||||
// EnsureDatabases downloads missing or outdated databases.
|
||||
func (m *Manager) EnsureDatabases(ctx context.Context) error {
|
||||
// Create data directory if it doesn't exist
|
||||
if err := os.MkdirAll(m.dataDir, defaultDirPermissions); err != nil {
|
||||
return fmt.Errorf("failed to create data directory: %w", err)
|
||||
}
|
||||
|
||||
// Load current state
|
||||
currentState, err := m.state.Load()
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to load state: %w", err)
|
||||
}
|
||||
|
||||
// Check and download ASN database
|
||||
asnPath := filepath.Join(m.dataDir, asnFile)
|
||||
if needsUpdate(asnPath, currentState.LastASNDownload) {
|
||||
m.logger.Info("Downloading ASN database")
|
||||
if err := m.downloadFile(ctx, asnURL, asnPath); err != nil {
|
||||
return fmt.Errorf("failed to download ASN database: %w", err)
|
||||
}
|
||||
if err := m.state.UpdateASNDownloadTime(); err != nil {
|
||||
return fmt.Errorf("failed to update ASN download time: %w", err)
|
||||
}
|
||||
}
|
||||
|
||||
// Check and download City database
|
||||
cityPath := filepath.Join(m.dataDir, cityFile)
|
||||
if needsUpdate(cityPath, currentState.LastCityDownload) {
|
||||
m.logger.Info("Downloading City database")
|
||||
if err := m.downloadFile(ctx, cityURL, cityPath); err != nil {
|
||||
return fmt.Errorf("failed to download City database: %w", err)
|
||||
}
|
||||
if err := m.state.UpdateCityDownloadTime(); err != nil {
|
||||
return fmt.Errorf("failed to update City download time: %w", err)
|
||||
}
|
||||
}
|
||||
|
||||
// Check and download Country database
|
||||
countryPath := filepath.Join(m.dataDir, countryFile)
|
||||
if needsUpdate(countryPath, currentState.LastCountryDownload) {
|
||||
m.logger.Info("Downloading Country database")
|
||||
if err := m.downloadFile(ctx, countryURL, countryPath); err != nil {
|
||||
return fmt.Errorf("failed to download Country database: %w", err)
|
||||
}
|
||||
if err := m.state.UpdateCountryDownloadTime(); err != nil {
|
||||
return fmt.Errorf("failed to update Country download time: %w", err)
|
||||
}
|
||||
}
|
||||
|
||||
// Open databases
|
||||
if err := m.openDatabases(); err != nil {
|
||||
return fmt.Errorf("failed to open databases: %w", err)
|
||||
}
|
||||
|
||||
m.logger.Info("All databases ready")
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
func (m *Manager) downloadFile(ctx context.Context, url, destPath string) error {
|
||||
req, err := http.NewRequestWithContext(ctx, "GET", url, nil)
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to create request: %w", err)
|
||||
}
|
||||
|
||||
resp, err := m.httpClient.Do(req)
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to download: %w", err)
|
||||
}
|
||||
defer func() { _ = resp.Body.Close() }()
|
||||
|
||||
if resp.StatusCode != http.StatusOK {
|
||||
return fmt.Errorf("unexpected status code: %d", resp.StatusCode)
|
||||
}
|
||||
|
||||
// Write to temporary file first
|
||||
tmpPath := destPath + ".tmp"
|
||||
tmpFile, err := os.Create(tmpPath) //nolint:gosec // temporary file with predictable name is ok
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to create temp file: %w", err)
|
||||
}
|
||||
defer func() { _ = os.Remove(tmpPath) }()
|
||||
|
||||
_, err = io.Copy(tmpFile, resp.Body)
|
||||
if err2 := tmpFile.Close(); err2 != nil {
|
||||
return fmt.Errorf("failed to close temp file: %w", err2)
|
||||
}
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to write file: %w", err)
|
||||
}
|
||||
|
||||
// Move to final location
|
||||
if err := os.Rename(tmpPath, destPath); err != nil {
|
||||
return fmt.Errorf("failed to move file: %w", err)
|
||||
}
|
||||
|
||||
m.logger.Debug("Downloaded file", "url", url, "path", destPath)
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
func (m *Manager) openDatabases() error {
|
||||
var err error
|
||||
|
||||
// Open ASN database
|
||||
asnPath := filepath.Join(m.dataDir, asnFile)
|
||||
m.asnDB, err = geoip2.Open(asnPath)
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to open ASN database: %w", err)
|
||||
}
|
||||
|
||||
// Open City database
|
||||
cityPath := filepath.Join(m.dataDir, cityFile)
|
||||
m.cityDB, err = geoip2.Open(cityPath)
|
||||
if err != nil {
|
||||
_ = m.asnDB.Close()
|
||||
|
||||
return fmt.Errorf("failed to open City database: %w", err)
|
||||
}
|
||||
|
||||
// Open Country database
|
||||
countryPath := filepath.Join(m.dataDir, countryFile)
|
||||
m.countryDB, err = geoip2.Open(countryPath)
|
||||
if err != nil {
|
||||
_ = m.asnDB.Close()
|
||||
_ = m.cityDB.Close()
|
||||
|
||||
return fmt.Errorf("failed to open Country database: %w", err)
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
// Close closes all open databases.
|
||||
func (m *Manager) Close() error {
|
||||
if m.asnDB != nil {
|
||||
_ = m.asnDB.Close()
|
||||
}
|
||||
if m.cityDB != nil {
|
||||
_ = m.cityDB.Close()
|
||||
}
|
||||
if m.countryDB != nil {
|
||||
_ = m.countryDB.Close()
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
// GetASNDB returns the ASN database reader.
|
||||
func (m *Manager) GetASNDB() *geoip2.Reader {
|
||||
return m.asnDB
|
||||
}
|
||||
|
||||
// GetCityDB returns the City database reader.
|
||||
func (m *Manager) GetCityDB() *geoip2.Reader {
|
||||
return m.cityDB
|
||||
}
|
||||
|
||||
// GetCountryDB returns the Country database reader.
|
||||
func (m *Manager) GetCountryDB() *geoip2.Reader {
|
||||
return m.countryDB
|
||||
}
|
||||
|
||||
func needsUpdate(filePath string, lastDownload time.Time) bool {
|
||||
// Check if file exists
|
||||
if _, err := os.Stat(filePath); os.IsNotExist(err) {
|
||||
return true
|
||||
}
|
||||
|
||||
// Check if it's time to update
|
||||
if time.Since(lastDownload) > updateInterval {
|
||||
return true
|
||||
}
|
||||
|
||||
return false
|
||||
}
|
||||
87
internal/database/database_test.go
Normal file
87
internal/database/database_test.go
Normal file
@@ -0,0 +1,87 @@
|
||||
package database
|
||||
|
||||
import (
|
||||
"log/slog"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"git.eeqj.de/sneak/ipapi/internal/config"
|
||||
"git.eeqj.de/sneak/ipapi/internal/state"
|
||||
)
|
||||
|
||||
func TestNeedsUpdate(t *testing.T) {
|
||||
tmpFile, err := os.CreateTemp("", "test-db")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
defer os.Remove(tmpFile.Name())
|
||||
tmpFile.Close()
|
||||
|
||||
tests := []struct {
|
||||
name string
|
||||
filePath string
|
||||
lastDownload time.Time
|
||||
expected bool
|
||||
}{
|
||||
{
|
||||
name: "file doesn't exist",
|
||||
filePath: "/nonexistent/file",
|
||||
lastDownload: time.Now(),
|
||||
expected: true,
|
||||
},
|
||||
{
|
||||
name: "recent download",
|
||||
filePath: tmpFile.Name(),
|
||||
lastDownload: time.Now().Add(-time.Hour),
|
||||
expected: false,
|
||||
},
|
||||
{
|
||||
name: "old download",
|
||||
filePath: tmpFile.Name(),
|
||||
lastDownload: time.Now().Add(-8 * 24 * time.Hour),
|
||||
expected: true,
|
||||
},
|
||||
}
|
||||
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
result := needsUpdate(tt.filePath, tt.lastDownload)
|
||||
if result != tt.expected {
|
||||
t.Errorf("expected %v, got %v", tt.expected, result)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestNew(t *testing.T) {
|
||||
tmpDir, err := os.MkdirTemp("", "ipapi-db-test")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
defer os.RemoveAll(tmpDir)
|
||||
|
||||
cfg := &config.Config{
|
||||
StateDir: tmpDir,
|
||||
}
|
||||
logger := slog.Default()
|
||||
stateManager, err := state.New(cfg, logger)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
manager, err := New(cfg, logger, stateManager)
|
||||
if err != nil {
|
||||
t.Fatalf("failed to create database manager: %v", err)
|
||||
}
|
||||
|
||||
if manager == nil {
|
||||
t.Fatal("expected manager, got nil")
|
||||
}
|
||||
|
||||
expectedDataDir := filepath.Join(tmpDir, "databases")
|
||||
if manager.dataDir != expectedDataDir {
|
||||
t.Errorf("expected data dir %s, got %s", expectedDataDir, manager.dataDir)
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user