284 lines
7.4 KiB
Go

// geoip-update downloads GeoLite2 edition tarballs listed in GeoIP.conf
// via conditional HTTP GETs, writing them to the configured directory.
package main
import (
"context"
"encoding/base64"
"encoding/csv"
"encoding/json"
"errors"
"flag"
"fmt"
"io"
"net/http"
"os"
"os/signal"
"path/filepath"
"runtime/debug"
"strings"
"syscall"
"time"
"github.com/therootcompany/golib/net/geoip"
"github.com/therootcompany/golib/net/httpcache"
)
// Replaced by goreleaser / ldflags at build time.
var (
name = "geoip-update"
version = "0.0.0-dev"
commit = "0000000"
date = "0001-01-01"
licenseYear = "2026"
licenseOwner = "AJ ONeal"
licenseType = "MPL-2.0"
)
func printVersion(w io.Writer) {
v, c, d := version, commit, date
if c == "0000000" {
if bi, ok := debug.ReadBuildInfo(); ok {
for _, setting := range bi.Settings {
switch setting.Key {
case "vcs.revision":
c = setting.Value
case "vcs.time":
d = setting.Value
case "vcs.modified":
if setting.Value == "true" {
d += "+dirty"
}
}
}
}
}
if len(c) > 7 {
c = c[:7]
}
_, _ = fmt.Fprintf(w, "%s v%s %s (%s)\n", name, v, c, d)
_, _ = fmt.Fprintf(w, "Copyright (C) %s %s\n", licenseYear, licenseOwner)
_, _ = fmt.Fprintf(w, "Licensed under %s\n", licenseType)
}
const defaultFailureBackoff = 6 * time.Hour
type Config struct {
ConfPath string
Dir string
BaseURL string
FreshDays int
Format string
HumanReadable bool
}
type result struct {
State string `json:"state"`
Edition string `json:"edition"`
Path string `json:"path"`
Date string `json:"date,omitempty"`
Error string `json:"error,omitempty"`
}
func main() {
cfg := Config{}
fs := flag.NewFlagSet(os.Args[0], flag.ContinueOnError)
fs.StringVar(&cfg.ConfPath, "config", "GeoIP.conf", "path to GeoIP.conf")
fs.StringVar(&cfg.Dir, "dir", "", "directory to store .tar.gz files (overrides DatabaseDirectory in config)")
fs.StringVar(&cfg.BaseURL, "base-url", "", "base URL for database downloads (overrides DownloadBase in geoip package)")
fs.IntVar(&cfg.FreshDays, "fresh-days", 3, "skip download if file is younger than N days")
fs.StringVar(&cfg.Format, "format", "", "output format: pretty, tsv, csv, json (default: auto)")
fs.BoolVar(&cfg.HumanReadable, "human-readable", false, "use human-readable output (same as --format pretty)")
fs.BoolVar(&cfg.HumanReadable, "h", false, "use human-readable output (same as --format pretty)")
fs.Usage = func() {
w := fs.Output()
_, _ = fmt.Fprintf(w, "USAGE\n %s [flags]\n\nFLAGS\n", name)
fs.PrintDefaults()
_, _ = fmt.Fprintln(w, "\nCONFIGURATION\n GeoIP.conf uses AccountID, LicenseKey, EditionIDs, and DatabaseDirectory.")
}
if len(os.Args) > 1 {
switch os.Args[1] {
case "-V", "-version", "--version", "version":
printVersion(os.Stdout)
os.Exit(0)
case "help", "-help", "--help":
printVersion(os.Stdout)
fmt.Fprintln(os.Stdout, "")
fs.SetOutput(os.Stdout)
fs.Usage()
os.Exit(0)
}
}
if err := fs.Parse(os.Args[1:]); err != nil {
if errors.Is(err, flag.ErrHelp) {
os.Exit(0)
}
os.Exit(2)
}
if fs.NArg() != 0 {
_, _ = fmt.Fprintf(os.Stderr, "error: unexpected arguments: %s\n", strings.Join(fs.Args(), " "))
os.Exit(2)
}
if cfg.FreshDays < 0 {
_, _ = fmt.Fprintln(os.Stderr, "error: -fresh-days must not be negative")
os.Exit(2)
}
if cfg.HumanReadable {
if cfg.Format != "" && cfg.Format != "pretty" {
_, _ = fmt.Fprintln(os.Stderr, "error: -h conflicts with a non-pretty -format")
os.Exit(2)
}
cfg.Format = "pretty"
}
format, err := resolveFormat(cfg.Format, os.Stdout)
if err != nil {
_, _ = fmt.Fprintf(os.Stderr, "error: %v\n", err)
os.Exit(2)
}
data, err := os.ReadFile(cfg.ConfPath)
if err != nil {
_, _ = fmt.Fprintf(os.Stderr, "error: %v\n", err)
os.Exit(1)
}
conf, err := geoip.ParseConf(string(data))
if err != nil {
_, _ = fmt.Fprintf(os.Stderr, "error: %v\n", err)
os.Exit(1)
}
outDir := cfg.Dir
if outDir == "" {
outDir = conf.DatabaseDirectory
}
if outDir == "" {
outDir = "."
}
if err := os.MkdirAll(outDir, 0o755); err != nil {
_, _ = fmt.Fprintf(os.Stderr, "error: mkdir %s: %v\n", outDir, err)
os.Exit(1)
}
if len(conf.EditionIDs) == 0 {
_, _ = fmt.Fprintf(os.Stderr, "error: no EditionIDs found in %s\n", cfg.ConfPath)
os.Exit(1)
}
creds := base64.StdEncoding.EncodeToString([]byte(conf.AccountID + ":" + conf.LicenseKey))
authHeader := http.Header{"Authorization": []string{"Basic " + creds}}
maxAge := time.Duration(cfg.FreshDays) * 24 * time.Hour
ctx, stop := signal.NotifyContext(context.Background(), os.Interrupt, syscall.SIGTERM)
defer stop()
results := make([]result, 0, len(conf.EditionIDs))
exitCode := 0
for _, edition := range conf.EditionIDs {
path := filepath.Join(outDir, geoip.TarGzName(edition))
base := conf.BaseURL
if cfg.BaseURL != "" {
base = cfg.BaseURL
}
if base == "" {
base = geoip.DownloadBase
}
cacher := httpcache.New(
base+"/"+edition+"/download?suffix=tar.gz",
path,
)
cacher.MaxAge = maxAge
cacher.FailureBackoff = defaultFailureBackoff
cacher.Header = authHeader
_, _ = fmt.Fprintf(os.Stderr, "Fetching %s... ", edition)
t := time.Now()
updated, err := cacher.Update(ctx)
if err != nil {
_, _ = fmt.Fprintln(os.Stderr)
_, _ = fmt.Fprintf(os.Stderr, "error: %s: %v\n", edition, err)
results = append(results, result{State: "error", Edition: edition, Path: path, Error: err.Error()})
exitCode = 1
continue
}
state := "fresh"
if updated {
state = "updated"
}
_, _ = fmt.Fprintf(os.Stderr, "%s (%s)\n", time.Since(t).Round(time.Millisecond), state)
info, statErr := os.Stat(path)
date := ""
if statErr == nil {
date = info.ModTime().Format("2006-01-02")
}
results = append(results, result{State: state, Edition: edition, Path: path, Date: date})
}
if err := writeResults(os.Stdout, format, results); err != nil {
_, _ = fmt.Fprintf(os.Stderr, "error: write output: %v\n", err)
exitCode = 1
}
os.Exit(exitCode)
}
func resolveFormat(format string, out *os.File) (string, error) {
if format != "" {
return parseFormat(format)
}
if isTTYish(out) {
return "pretty", nil
}
return "tsv", nil
}
func parseFormat(format string) (string, error) {
switch format {
case "pretty", "tsv", "csv", "json":
return format, nil
default:
return "", fmt.Errorf("invalid format %q: want pretty, tsv, csv, or json", format)
}
}
func isTTYish(f *os.File) bool {
fi, err := f.Stat()
if err != nil {
return false
}
m := os.ModeDevice | os.ModeCharDevice
return fi.Mode()&m == m
}
func writeResults(w io.Writer, format string, results []result) error {
switch format {
case "json":
enc := json.NewEncoder(w)
enc.SetIndent("", " ")
return enc.Encode(results)
case "csv", "tsv":
cw := csv.NewWriter(w)
if format == "tsv" {
cw.Comma = '\t'
} else if err := cw.Write([]string{"state", "edition", "path", "date", "error"}); err != nil {
return err
}
for _, r := range results {
if err := cw.Write([]string{r.State, r.Edition, r.Path, r.Date, r.Error}); err != nil {
return err
}
}
cw.Flush()
return cw.Error()
default:
for _, r := range results {
if r.State == "error" {
if _, err := fmt.Fprintf(w, "%-10s %s -> %s (%s)\n", r.State+":", r.Edition, r.Path, r.Error); err != nil {
return err
}
continue
}
if _, err := fmt.Fprintf(w, "%-10s %s -> %s (%s)\n", r.State+":", r.Edition, r.Path, r.Date); err != nil {
return err
}
}
return nil
}
}