454 lines
		
	
	
		
			11 KiB
		
	
	
	
		
			Go
		
	
	
	
	
	
			
		
		
	
	
			454 lines
		
	
	
		
			11 KiB
		
	
	
	
		
			Go
		
	
	
	
	
	
// Copyright 2009 The freegeoip authors. All rights reserved.
 | 
						|
// Use of this source code is governed by a BSD-style license that can be
 | 
						|
// found in the LICENSE file.
 | 
						|
 | 
						|
package freegeoip
 | 
						|
 | 
						|
import (
 | 
						|
	"compress/gzip"
 | 
						|
	"crypto/md5"
 | 
						|
	"encoding/hex"
 | 
						|
	"errors"
 | 
						|
	"fmt"
 | 
						|
	"io"
 | 
						|
	"io/ioutil"
 | 
						|
	"math"
 | 
						|
	"net"
 | 
						|
	"net/http"
 | 
						|
	"net/url"
 | 
						|
	"os"
 | 
						|
	"path/filepath"
 | 
						|
	"sync"
 | 
						|
	"time"
 | 
						|
 | 
						|
	"github.com/howeyc/fsnotify"
 | 
						|
	"github.com/oschwald/maxminddb-golang"
 | 
						|
)
 | 
						|
 | 
						|
var (
 | 
						|
	// ErrUnavailable may be returned by DB.Lookup when the database
 | 
						|
	// points to a URL and is not yet available because it's being
 | 
						|
	// downloaded in background.
 | 
						|
	ErrUnavailable = errors.New("no database available")
 | 
						|
 | 
						|
	// Local cached copy of a database downloaded from a URL.
 | 
						|
	defaultDB = filepath.Join(os.TempDir(), "freegeoip", "db.gz")
 | 
						|
 | 
						|
	// MaxMindDB is the URL of the free MaxMind GeoLite2 database.
 | 
						|
	MaxMindDB = "http://geolite.maxmind.com/download/geoip/database/GeoLite2-City.mmdb.gz"
 | 
						|
)
 | 
						|
 | 
						|
// DB is the IP geolocation database.
 | 
						|
type DB struct {
 | 
						|
	file        string            // Database file name.
 | 
						|
	checksum    string            // MD5 of the unzipped database file
 | 
						|
	reader      *maxminddb.Reader // Actual db object.
 | 
						|
	notifyQuit  chan struct{}     // Stop auto-update and watch goroutines.
 | 
						|
	notifyOpen  chan string       // Notify when a db file is open.
 | 
						|
	notifyError chan error        // Notify when an error occurs.
 | 
						|
	notifyInfo  chan string       // Notify random actions for logging
 | 
						|
	closed      bool              // Mark this db as closed.
 | 
						|
	lastUpdated time.Time         // Last time the db was updated.
 | 
						|
	mu          sync.RWMutex      // Protects all the above.
 | 
						|
 | 
						|
	updateInterval   time.Duration // Update interval.
 | 
						|
	maxRetryInterval time.Duration // Max retry interval in case of failure.
 | 
						|
}
 | 
						|
 | 
						|
// Open creates and initializes a DB from a local file.
 | 
						|
//
 | 
						|
// The database file is monitored by fsnotify and automatically
 | 
						|
// reloads when the file is updated or overwritten.
 | 
						|
func Open(dsn string) (*DB, error) {
 | 
						|
	db := &DB{
 | 
						|
		file:        dsn,
 | 
						|
		notifyQuit:  make(chan struct{}),
 | 
						|
		notifyOpen:  make(chan string, 1),
 | 
						|
		notifyError: make(chan error, 1),
 | 
						|
		notifyInfo:  make(chan string, 1),
 | 
						|
	}
 | 
						|
	err := db.openFile()
 | 
						|
	if err != nil {
 | 
						|
		db.Close()
 | 
						|
		return nil, err
 | 
						|
	}
 | 
						|
	err = db.watchFile()
 | 
						|
	if err != nil {
 | 
						|
		db.Close()
 | 
						|
		return nil, fmt.Errorf("fsnotify failed for %s: %s", dsn, err)
 | 
						|
	}
 | 
						|
	return db, nil
 | 
						|
}
 | 
						|
 | 
						|
// MaxMindUpdateURL generates the URL for MaxMind paid databases.
 | 
						|
func MaxMindUpdateURL(hostname, productID, userID, licenseKey string) (string, error) {
 | 
						|
	limiter := func(r io.Reader) *io.LimitedReader {
 | 
						|
		return &io.LimitedReader{R: r, N: 1 << 30}
 | 
						|
	}
 | 
						|
	baseurl := "https://" + hostname + "/app/"
 | 
						|
	// Get the file name for the product ID.
 | 
						|
	u := baseurl + "update_getfilename?product_id=" + productID
 | 
						|
	resp, err := http.Get(u)
 | 
						|
	if err != nil {
 | 
						|
		return "", err
 | 
						|
	}
 | 
						|
	defer resp.Body.Close()
 | 
						|
	md5hash := md5.New()
 | 
						|
	_, err = io.Copy(md5hash, limiter(resp.Body))
 | 
						|
	if err != nil {
 | 
						|
		return "", err
 | 
						|
	}
 | 
						|
	sum := md5hash.Sum(nil)
 | 
						|
	hexdigest1 := hex.EncodeToString(sum[:])
 | 
						|
	// Get our client IP address.
 | 
						|
	resp, err = http.Get(baseurl + "update_getipaddr")
 | 
						|
	if err != nil {
 | 
						|
		return "", err
 | 
						|
	}
 | 
						|
	defer resp.Body.Close()
 | 
						|
	md5hash = md5.New()
 | 
						|
	io.WriteString(md5hash, licenseKey)
 | 
						|
	_, err = io.Copy(md5hash, limiter(resp.Body))
 | 
						|
	if err != nil {
 | 
						|
		return "", err
 | 
						|
	}
 | 
						|
	sum = md5hash.Sum(nil)
 | 
						|
	hexdigest2 := hex.EncodeToString(sum[:])
 | 
						|
	// Generate the URL.
 | 
						|
	params := url.Values{
 | 
						|
		"db_md5":        {hexdigest1},
 | 
						|
		"challenge_md5": {hexdigest2},
 | 
						|
		"user_id":       {userID},
 | 
						|
		"edition_id":    {productID},
 | 
						|
	}
 | 
						|
	u = baseurl + "update_secure?" + params.Encode()
 | 
						|
	return u, nil
 | 
						|
}
 | 
						|
 | 
						|
// OpenURL creates and initializes a DB from a URL.
 | 
						|
// It automatically downloads and updates the file in background, and
 | 
						|
// keeps a local copy on $TMPDIR.
 | 
						|
func OpenURL(url string, updateInterval, maxRetryInterval time.Duration) (*DB, error) {
 | 
						|
	db := &DB{
 | 
						|
		file:             defaultDB,
 | 
						|
		notifyQuit:       make(chan struct{}),
 | 
						|
		notifyOpen:       make(chan string, 1),
 | 
						|
		notifyError:      make(chan error, 1),
 | 
						|
		notifyInfo:       make(chan string, 1),
 | 
						|
		updateInterval:   updateInterval,
 | 
						|
		maxRetryInterval: maxRetryInterval,
 | 
						|
	}
 | 
						|
	db.openFile() // Optional, might fail.
 | 
						|
	go db.autoUpdate(url)
 | 
						|
	err := db.watchFile()
 | 
						|
	if err != nil {
 | 
						|
		db.Close()
 | 
						|
		return nil, fmt.Errorf("fsnotify failed for %s: %s", db.file, err)
 | 
						|
	}
 | 
						|
	return db, nil
 | 
						|
}
 | 
						|
 | 
						|
func (db *DB) watchFile() error {
 | 
						|
	watcher, err := fsnotify.NewWatcher()
 | 
						|
	if err != nil {
 | 
						|
		return err
 | 
						|
	}
 | 
						|
	dbdir, err := db.makeDir()
 | 
						|
	if err != nil {
 | 
						|
		return err
 | 
						|
	}
 | 
						|
	go db.watchEvents(watcher)
 | 
						|
	return watcher.Watch(dbdir)
 | 
						|
}
 | 
						|
 | 
						|
func (db *DB) watchEvents(watcher *fsnotify.Watcher) {
 | 
						|
	for {
 | 
						|
		select {
 | 
						|
		case ev := <-watcher.Event:
 | 
						|
			if ev.Name == db.file && (ev.IsCreate() || ev.IsModify()) {
 | 
						|
				db.openFile()
 | 
						|
			}
 | 
						|
		case <-watcher.Error:
 | 
						|
		case <-db.notifyQuit:
 | 
						|
			watcher.Close()
 | 
						|
			return
 | 
						|
		}
 | 
						|
		time.Sleep(time.Second) // Suppress high-rate events.
 | 
						|
	}
 | 
						|
}
 | 
						|
 | 
						|
func (db *DB) openFile() error {
 | 
						|
	reader, checksum, err := db.newReader(db.file)
 | 
						|
	if err != nil {
 | 
						|
		return err
 | 
						|
	}
 | 
						|
	stat, err := os.Stat(db.file)
 | 
						|
	if err != nil {
 | 
						|
		return err
 | 
						|
	}
 | 
						|
	db.setReader(reader, stat.ModTime(), checksum)
 | 
						|
	return nil
 | 
						|
}
 | 
						|
 | 
						|
func (db *DB) newReader(dbfile string) (*maxminddb.Reader, string, error) {
 | 
						|
	f, err := os.Open(dbfile)
 | 
						|
	if err != nil {
 | 
						|
		return nil, "", err
 | 
						|
	}
 | 
						|
	defer f.Close()
 | 
						|
	gzf, err := gzip.NewReader(f)
 | 
						|
	if err != nil {
 | 
						|
		return nil, "", err
 | 
						|
	}
 | 
						|
	defer gzf.Close()
 | 
						|
	b, err := ioutil.ReadAll(gzf)
 | 
						|
	if err != nil {
 | 
						|
		return nil, "", err
 | 
						|
	}
 | 
						|
	checksum := fmt.Sprintf("%x", md5.Sum(b))
 | 
						|
	mmdb, err := maxminddb.FromBytes(b)
 | 
						|
	return mmdb, checksum, err
 | 
						|
}
 | 
						|
 | 
						|
func (db *DB) setReader(reader *maxminddb.Reader, modtime time.Time, checksum string) {
 | 
						|
	db.mu.Lock()
 | 
						|
	defer db.mu.Unlock()
 | 
						|
	if db.closed {
 | 
						|
		reader.Close()
 | 
						|
		return
 | 
						|
	}
 | 
						|
	if db.reader != nil {
 | 
						|
		db.reader.Close()
 | 
						|
	}
 | 
						|
	db.reader = reader
 | 
						|
	db.lastUpdated = modtime.UTC()
 | 
						|
	db.checksum = checksum
 | 
						|
	select {
 | 
						|
	case db.notifyOpen <- db.file:
 | 
						|
	default:
 | 
						|
	}
 | 
						|
}
 | 
						|
 | 
						|
func (db *DB) autoUpdate(url string) {
 | 
						|
	backoff := time.Second
 | 
						|
	for {
 | 
						|
		db.sendInfo("starting update")
 | 
						|
		err := db.runUpdate(url)
 | 
						|
		if err != nil {
 | 
						|
			bs := backoff.Seconds()
 | 
						|
			ms := db.maxRetryInterval.Seconds()
 | 
						|
			backoff = time.Duration(math.Min(bs*math.E, ms)) * time.Second
 | 
						|
			db.sendError(fmt.Errorf("download failed (will retry in %s): %s", backoff, err))
 | 
						|
		} else {
 | 
						|
			backoff = db.updateInterval
 | 
						|
		}
 | 
						|
		db.sendInfo("finished update")
 | 
						|
		select {
 | 
						|
		case <-db.notifyQuit:
 | 
						|
			return
 | 
						|
		case <-time.After(backoff):
 | 
						|
			// Sleep till time for the next update attempt.
 | 
						|
		}
 | 
						|
	}
 | 
						|
}
 | 
						|
 | 
						|
func (db *DB) runUpdate(url string) error {
 | 
						|
	yes, err := db.needUpdate(url)
 | 
						|
	if err != nil {
 | 
						|
		return err
 | 
						|
	}
 | 
						|
	if !yes {
 | 
						|
		return nil
 | 
						|
	}
 | 
						|
	tmpfile, err := db.download(url)
 | 
						|
	if err != nil {
 | 
						|
		return err
 | 
						|
	}
 | 
						|
	err = db.renameFile(tmpfile)
 | 
						|
	if err != nil {
 | 
						|
		// Cleanup the tempfile if renaming failed.
 | 
						|
		os.RemoveAll(tmpfile)
 | 
						|
	}
 | 
						|
	return err
 | 
						|
}
 | 
						|
 | 
						|
func (db *DB) needUpdate(url string) (bool, error) {
 | 
						|
	stat, err := os.Stat(db.file)
 | 
						|
	if err != nil {
 | 
						|
		return true, nil // Local db is missing, must be downloaded.
 | 
						|
	}
 | 
						|
 | 
						|
	resp, err := http.Head(url)
 | 
						|
	if err != nil {
 | 
						|
		return false, err
 | 
						|
	}
 | 
						|
	defer resp.Body.Close()
 | 
						|
 | 
						|
	// Check X-Database-MD5 if it exists
 | 
						|
	headerMd5 := resp.Header.Get("X-Database-MD5")
 | 
						|
	if len(headerMd5) > 0 && db.checksum != headerMd5 {
 | 
						|
		return true, nil
 | 
						|
	}
 | 
						|
 | 
						|
	if stat.Size() != resp.ContentLength {
 | 
						|
		return true, nil
 | 
						|
	}
 | 
						|
	return false, nil
 | 
						|
}
 | 
						|
 | 
						|
func (db *DB) download(url string) (tmpfile string, err error) {
 | 
						|
	resp, err := http.Get(url)
 | 
						|
	if err != nil {
 | 
						|
		return "", err
 | 
						|
	}
 | 
						|
	defer resp.Body.Close()
 | 
						|
	tmpfile = filepath.Join(os.TempDir(),
 | 
						|
		fmt.Sprintf("_freegeoip.%d.db.gz", time.Now().UnixNano()))
 | 
						|
	f, err := os.Create(tmpfile)
 | 
						|
	if err != nil {
 | 
						|
		return "", err
 | 
						|
	}
 | 
						|
	defer f.Close()
 | 
						|
	_, err = io.Copy(f, resp.Body)
 | 
						|
	if err != nil {
 | 
						|
		return "", err
 | 
						|
	}
 | 
						|
	return tmpfile, nil
 | 
						|
}
 | 
						|
 | 
						|
func (db *DB) makeDir() (dbdir string, err error) {
 | 
						|
	dbdir = filepath.Dir(db.file)
 | 
						|
	_, err = os.Stat(dbdir)
 | 
						|
	if err != nil {
 | 
						|
		err = os.MkdirAll(dbdir, 0755)
 | 
						|
		if err != nil {
 | 
						|
			return "", err
 | 
						|
		}
 | 
						|
	}
 | 
						|
	return dbdir, nil
 | 
						|
}
 | 
						|
 | 
						|
func (db *DB) renameFile(name string) error {
 | 
						|
	os.Rename(db.file, db.file+".bak") // Optional, might fail.
 | 
						|
	_, err := db.makeDir()
 | 
						|
	if err != nil {
 | 
						|
		return err
 | 
						|
	}
 | 
						|
	return os.Rename(name, db.file)
 | 
						|
}
 | 
						|
 | 
						|
// Date returns the UTC date the database file was last modified.
 | 
						|
// If no database file has been opened the behaviour of Date is undefined.
 | 
						|
func (db *DB) Date() time.Time {
 | 
						|
	db.mu.RLock()
 | 
						|
	defer db.mu.RUnlock()
 | 
						|
	return db.lastUpdated
 | 
						|
}
 | 
						|
 | 
						|
// NotifyClose returns a channel that is closed when the database is closed.
 | 
						|
func (db *DB) NotifyClose() <-chan struct{} {
 | 
						|
	return db.notifyQuit
 | 
						|
}
 | 
						|
 | 
						|
// NotifyOpen returns a channel that notifies when a new database is
 | 
						|
// loaded or reloaded. This can be used to monitor background updates
 | 
						|
// when the DB points to a URL.
 | 
						|
func (db *DB) NotifyOpen() (filename <-chan string) {
 | 
						|
	return db.notifyOpen
 | 
						|
}
 | 
						|
 | 
						|
// NotifyError returns a channel that notifies when an error occurs
 | 
						|
// while downloading or reloading a DB that points to a URL.
 | 
						|
func (db *DB) NotifyError() (errChan <-chan error) {
 | 
						|
	return db.notifyError
 | 
						|
}
 | 
						|
 | 
						|
// NotifyInfo returns a channel that notifies informational messages
 | 
						|
// while downloading or reloading.
 | 
						|
func (db *DB) NotifyInfo() <-chan string {
 | 
						|
	return db.notifyInfo
 | 
						|
}
 | 
						|
 | 
						|
func (db *DB) sendError(err error) {
 | 
						|
	db.mu.RLock()
 | 
						|
	defer db.mu.RUnlock()
 | 
						|
	if db.closed {
 | 
						|
		return
 | 
						|
	}
 | 
						|
	select {
 | 
						|
	case db.notifyError <- err:
 | 
						|
	default:
 | 
						|
	}
 | 
						|
}
 | 
						|
 | 
						|
func (db *DB) sendInfo(message string) {
 | 
						|
	db.mu.RLock()
 | 
						|
	defer db.mu.RUnlock()
 | 
						|
	if db.closed {
 | 
						|
		return
 | 
						|
	}
 | 
						|
	select {
 | 
						|
	case db.notifyInfo <- message:
 | 
						|
	default:
 | 
						|
	}
 | 
						|
}
 | 
						|
 | 
						|
// Lookup performs a database lookup of the given IP address, and stores
 | 
						|
// the response into the result value. The result value must be a struct
 | 
						|
// with specific fields and tags as described here:
 | 
						|
// https://godoc.org/github.com/oschwald/maxminddb-golang#Reader.Lookup
 | 
						|
//
 | 
						|
// See the DefaultQuery for an example of the result struct.
 | 
						|
func (db *DB) Lookup(addr net.IP, result interface{}) error {
 | 
						|
	db.mu.RLock()
 | 
						|
	defer db.mu.RUnlock()
 | 
						|
	if db.reader != nil {
 | 
						|
		return db.reader.Lookup(addr, result)
 | 
						|
	}
 | 
						|
	return ErrUnavailable
 | 
						|
}
 | 
						|
 | 
						|
// DefaultQuery is the default query used for database lookups.
 | 
						|
type DefaultQuery struct {
 | 
						|
	Continent struct {
 | 
						|
		Names map[string]string `maxminddb:"names"`
 | 
						|
	} `maxminddb:"continent"`
 | 
						|
	Country struct {
 | 
						|
		ISOCode string            `maxminddb:"iso_code"`
 | 
						|
		Names   map[string]string `maxminddb:"names"`
 | 
						|
	} `maxminddb:"country"`
 | 
						|
	Region []struct {
 | 
						|
		ISOCode string            `maxminddb:"iso_code"`
 | 
						|
		Names   map[string]string `maxminddb:"names"`
 | 
						|
	} `maxminddb:"subdivisions"`
 | 
						|
	City struct {
 | 
						|
		Names map[string]string `maxminddb:"names"`
 | 
						|
	} `maxminddb:"city"`
 | 
						|
	Location struct {
 | 
						|
		Latitude  float64 `maxminddb:"latitude"`
 | 
						|
		Longitude float64 `maxminddb:"longitude"`
 | 
						|
		MetroCode uint    `maxminddb:"metro_code"`
 | 
						|
		TimeZone  string  `maxminddb:"time_zone"`
 | 
						|
	} `maxminddb:"location"`
 | 
						|
	Postal struct {
 | 
						|
		Code string `maxminddb:"code"`
 | 
						|
	} `maxminddb:"postal"`
 | 
						|
}
 | 
						|
 | 
						|
// Close closes the database.
 | 
						|
func (db *DB) Close() {
 | 
						|
	db.mu.Lock()
 | 
						|
	defer db.mu.Unlock()
 | 
						|
	if !db.closed {
 | 
						|
		db.closed = true
 | 
						|
		close(db.notifyQuit)
 | 
						|
		close(db.notifyOpen)
 | 
						|
		close(db.notifyError)
 | 
						|
		close(db.notifyInfo)
 | 
						|
	}
 | 
						|
	if db.reader != nil {
 | 
						|
		db.reader.Close()
 | 
						|
		db.reader = nil
 | 
						|
	}
 | 
						|
}
 |