github.com/m-lab/locate@v0.17.6/clientgeo/maxmind.go (about)

     1  package clientgeo
     2  
     3  import (
     4  	"context"
     5  	"errors"
     6  	"fmt"
     7  	"log"
     8  	"net"
     9  	"net/http"
    10  	"strings"
    11  	"sync"
    12  
    13  	"github.com/m-lab/go/content"
    14  	"github.com/m-lab/go/rtx"
    15  	"github.com/oschwald/geoip2-golang"
    16  
    17  	"github.com/m-lab/uuid-annotator/tarreader"
    18  )
    19  
    20  // NewMaxmindLocator creates a new MaxmindLocator and loads the current copy of
    21  // MaxMind data stored in GCS.
    22  func NewMaxmindLocator(ctx context.Context, mm content.Provider) *MaxmindLocator {
    23  	mml := &MaxmindLocator{
    24  		dataSource: mm,
    25  	}
    26  	var err error
    27  	mml.maxmind, err = mml.load(ctx)
    28  	rtx.Must(err, "Could not load annotation db")
    29  	return mml
    30  }
    31  
    32  // MaxmindLocator manages access to the maxmind database.
    33  type MaxmindLocator struct {
    34  	mut        sync.RWMutex
    35  	dataSource content.Provider
    36  	maxmind    *geoip2.Reader
    37  }
    38  
    39  var emptyResult = geoip2.City{}
    40  
    41  // Locate finds the Location of the given request using client's remote IP or IP
    42  // from X-Forwarded-For header.
    43  func (mml *MaxmindLocator) Locate(req *http.Request) (*Location, error) {
    44  	mml.mut.RLock()
    45  	defer mml.mut.RUnlock()
    46  
    47  	ip, err := ipFromRequest(req)
    48  	if err != nil {
    49  		return nil, err
    50  	}
    51  	if ip == nil {
    52  		return nil, errors.New("cannot locate nil IP")
    53  	}
    54  	if mml.maxmind == nil {
    55  		log.Println("No maxmind DB present. This should only occur during testing.")
    56  		return nil, errors.New("no maxmind db loaded")
    57  	}
    58  	record, err := mml.maxmind.City(ip)
    59  	if err != nil {
    60  		return nil, err
    61  	}
    62  
    63  	// Check for empty results because "not found" is not an error. Instead the
    64  	// geoip2 package returns an empty result. May be fixed in a future version:
    65  	// https://github.com/oschwald/geoip2-golang/issues/32
    66  	//
    67  	// "Not found" in a well-functioning database should not be an error.
    68  	// Instead, it is an accurate reflection of data that is missing.
    69  	if isEmpty(record) {
    70  		return nil, errors.New("unknown location; empty result")
    71  	}
    72  
    73  	lat := fmt.Sprintf("%f", record.Location.Latitude)
    74  	lon := fmt.Sprintf("%f", record.Location.Longitude)
    75  	tmp := &Location{
    76  		Latitude:  lat,
    77  		Longitude: lon,
    78  		Headers: http.Header{
    79  			hLocateClientlatlon:       []string{lat + "," + lon},
    80  			hLocateClientlatlonMethod: []string{"maxmind-remoteip"},
    81  		},
    82  	}
    83  	return tmp, nil
    84  }
    85  
    86  func ipFromRequest(req *http.Request) (net.IP, error) {
    87  	fwdIPs := strings.Split(req.Header.Get("X-Forwarded-For"), ", ")
    88  	var ip net.IP
    89  	if fwdIPs[0] != "" {
    90  		ip = net.ParseIP(fwdIPs[0])
    91  	} else {
    92  		h, _, err := net.SplitHostPort(req.RemoteAddr)
    93  		if err != nil {
    94  			return nil, errors.New("failed to parse remote addr")
    95  		}
    96  		ip = net.ParseIP(h)
    97  	}
    98  	return ip, nil
    99  }
   100  
   101  // Reload is intended to be regularly called in a loop. It should check whether
   102  // the data in GCS is newer than the local data, and, if it is, then download
   103  // and load that new data into memory and then replace it in the annotator.
   104  func (mml *MaxmindLocator) Reload(ctx context.Context) {
   105  	mm, err := mml.load(ctx)
   106  	if err != nil {
   107  		log.Println("Could not reload maxmind dataset:", err)
   108  		return
   109  	}
   110  	// Don't acquire the lock until after the data is in RAM.
   111  	mml.mut.Lock()
   112  	defer mml.mut.Unlock()
   113  	mml.maxmind = mm
   114  }
   115  
   116  func isEmpty(r *geoip2.City) bool {
   117  	// The record has no associated city, country, or continent.
   118  	return r.City.GeoNameID == 0 && r.Country.GeoNameID == 0 && r.Continent.GeoNameID == 0
   119  }
   120  
   121  // load unconditionally loads datasets and returns them.
   122  func (mml *MaxmindLocator) load(ctx context.Context) (*geoip2.Reader, error) {
   123  	tgz, err := mml.dataSource.Get(ctx)
   124  	if err == content.ErrNoChange {
   125  		return mml.maxmind, nil
   126  	}
   127  	if err != nil {
   128  		return nil, err
   129  	}
   130  	data, err := tarreader.FromTarGZ(tgz, "GeoLite2-City.mmdb")
   131  	if err != nil {
   132  		return nil, err
   133  	}
   134  	return geoip2.FromBytes(data)
   135  }