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:
2025-07-27 18:15:38 +02:00
commit 2a1710cca8
24 changed files with 2402 additions and 0 deletions

77
internal/config/config.go Normal file
View File

@@ -0,0 +1,77 @@
// Package config handles application configuration.
package config
import (
"os"
"strconv"
"git.eeqj.de/sneak/smartconfig"
)
// Config holds the application configuration.
type Config struct {
Port int
StateDir string
LogLevel string
}
// New creates a new configuration instance.
func New(configFile string) (*Config, error) {
// Check if config file exists first
if _, err := os.Stat(configFile); os.IsNotExist(err) {
return newDefaultConfig(), nil
}
// Load smartconfig
sc, err := smartconfig.NewFromConfigPath(configFile)
if err != nil {
return nil, err
}
cfg := &Config{}
// Get port from smartconfig or environment or default
if port, err := sc.GetInt("port"); err == nil {
cfg.Port = port
} else {
cfg.Port = getPortFromEnv()
}
// Get state directory
if stateDir, err := sc.GetString("state_dir"); err == nil {
cfg.StateDir = stateDir
} else {
cfg.StateDir = "/var/lib/ipapi"
}
// Get log level
if logLevel, err := sc.GetString("log_level"); err == nil {
cfg.LogLevel = logLevel
} else {
cfg.LogLevel = "info"
}
return cfg, nil
}
func newDefaultConfig() *Config {
return &Config{
Port: getPortFromEnv(),
StateDir: "/var/lib/ipapi",
LogLevel: "info",
}
}
func getPortFromEnv() int {
const defaultPort = 8080
portStr := os.Getenv("PORT")
if portStr == "" {
return defaultPort
}
port, err := strconv.Atoi(portStr)
if err != nil {
return defaultPort
}
return port
}

View File

@@ -0,0 +1,64 @@
package config
import (
"os"
"testing"
)
func TestNewDefaultConfig(t *testing.T) {
// Clear PORT env var for test
oldPort := os.Getenv("PORT")
os.Unsetenv("PORT")
defer os.Setenv("PORT", oldPort)
cfg := newDefaultConfig()
if cfg.Port != 8080 {
t.Errorf("expected default port 8080, got %d", cfg.Port)
}
if cfg.StateDir != "/var/lib/ipapi" {
t.Errorf("expected default state dir /var/lib/ipapi, got %s", cfg.StateDir)
}
if cfg.LogLevel != "info" {
t.Errorf("expected default log level info, got %s", cfg.LogLevel)
}
}
func TestGetPortFromEnv(t *testing.T) {
tests := []struct {
name string
envValue string
expected int
}{
{"no env", "", 8080},
{"valid port", "9090", 9090},
{"invalid port", "invalid", 8080},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
oldPort := os.Getenv("PORT")
if tt.envValue == "" {
os.Unsetenv("PORT")
} else {
os.Setenv("PORT", tt.envValue)
}
defer os.Setenv("PORT", oldPort)
port := getPortFromEnv()
if port != tt.expected {
t.Errorf("expected port %d, got %d", tt.expected, port)
}
})
}
}
func TestNew(t *testing.T) {
// Test with non-existent file (should use defaults)
cfg, err := New("/nonexistent/config.yml")
if err != nil {
t.Fatalf("expected no error for non-existent file, got %v", err)
}
if cfg == nil {
t.Fatal("expected config, got nil")
}
}