Skip to content

Commit decbcdf

Browse files
authored
Adds a local service for decorating IPs with annotations. (#23)
* Adds a local service for decorating IPs with annotations. Does not (yet) hook it up to main. * Missing: true now exists and is not an error * all external methods now respect locking in asnannotator and geoannotator. * Add tests for logging methods. TODO: consider moving these methods to a wider scope if we need them elsewhere. * Better URL and better command-line arguments
1 parent fe6d3c3 commit decbcdf

File tree

12 files changed

+716
-18
lines changed

12 files changed

+716
-18
lines changed

annotator/annotator.go

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -43,6 +43,8 @@ type Geolocation struct {
4343
Latitude float64 `json:",omitempty"` // Latitude
4444
Longitude float64 `json:",omitempty"` // Longitude
4545
AccuracyRadiusKm int64 `json:",omitempty"` // Geo2: Accuracy Radius (since 2018)
46+
47+
Missing bool `json:",omitempty"` // True when the Geolocation data is missing from MaxMind.
4648
}
4749

4850
// We currently use CAIDA RouteView data to populate ASN annotations.

asnannotator/routeview.go

Lines changed: 13 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -14,10 +14,11 @@ import (
1414
"github.com/m-lab/uuid-annotator/routeview"
1515
)
1616

17-
// ReloadingAnnotator is just a regular annotator with a Reload method.
18-
type ReloadingAnnotator interface {
17+
// ASNAnnotator is just a regular annotator with a Reload method and an AnnotateIP method.
18+
type ASNAnnotator interface {
1919
annotator.Annotator
2020
Reload(context.Context)
21+
AnnotateIP(src string) *annotator.Network
2122
}
2223

2324
// asnAnnotator is the central struct for this module.
@@ -32,7 +33,7 @@ type asnAnnotator struct {
3233

3334
// New makes a new Annotator that uses IP addresses to lookup ASN metadata for
3435
// that IP based on the current copy of RouteViews data stored in the given providers.
35-
func New(ctx context.Context, as4 rawfile.Provider, as6 rawfile.Provider, localIPs []net.IP) ReloadingAnnotator {
36+
func New(ctx context.Context, as4 rawfile.Provider, as6 rawfile.Provider, localIPs []net.IP) ASNAnnotator {
3637
a := &asnAnnotator{
3738
as4: as4,
3839
as6: as6,
@@ -59,14 +60,20 @@ func (a *asnAnnotator) Annotate(ID *inetdiag.SockID, annotations *annotator.Anno
5960
// TODO: annotate the server IP with siteinfo data.
6061
switch dir {
6162
case annotator.DstIsServer:
62-
annotations.Client.Network = a.annotate(ID.SrcIP)
63+
annotations.Client.Network = a.annotateIPHoldingLock(ID.SrcIP)
6364
case annotator.SrcIsServer:
64-
annotations.Client.Network = a.annotate(ID.DstIP)
65+
annotations.Client.Network = a.annotateIPHoldingLock(ID.DstIP)
6566
}
6667
return nil
6768
}
6869

69-
func (a *asnAnnotator) annotate(src string) *annotator.Network {
70+
func (a *asnAnnotator) AnnotateIP(src string) *annotator.Network {
71+
a.m.RLock()
72+
defer a.m.RUnlock()
73+
return a.annotateIPHoldingLock(src)
74+
}
75+
76+
func (a *asnAnnotator) annotateIPHoldingLock(src string) *annotator.Network {
7077
ann := &annotator.Network{}
7178
// Check IPv4 first.
7279
ipnet, err := a.asn4.Search(src)

asnannotator/routeview_test.go

Lines changed: 23 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -166,6 +166,29 @@ func Test_asnAnnotator_Annotate(t *testing.T) {
166166
}
167167
}
168168

169+
func Test_asnAnnotator_AnnotateIP(t *testing.T) {
170+
171+
localV4 := "9.0.0.9"
172+
localV6 := "2002::1"
173+
localIPs = []net.IP{
174+
net.ParseIP(localV4),
175+
net.ParseIP(localV6),
176+
}
177+
ctx := context.Background()
178+
a := New(ctx, local4Rawfile, local6Rawfile, localIPs)
179+
got := a.AnnotateIP("2001:200::1")
180+
want := annotator.Network{
181+
CIDR: "2001:200::/32",
182+
ASNumber: 2500,
183+
Systems: []annotator.System{
184+
{ASNs: []uint32{2500}},
185+
},
186+
}
187+
if diff := deep.Equal(*got, want); diff != nil {
188+
log.Println("got!=want", diff)
189+
}
190+
}
191+
169192
type badProvider struct {
170193
err error
171194
}

geoannotator/ipannotator.go

Lines changed: 28 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -2,6 +2,7 @@ package geoannotator
22

33
import (
44
"context"
5+
"errors"
56
"fmt"
67
"log"
78
"net"
@@ -15,10 +16,11 @@ import (
1516
"github.com/m-lab/uuid-annotator/rawfile"
1617
)
1718

18-
// ReloadingAnnotator is just a regular annotator with a Reload method.
19-
type ReloadingAnnotator interface {
19+
// GeoAnnotator is just a regular annotator with a Reload method and an AnnotateIP method.
20+
type GeoAnnotator interface {
2021
annotator.Annotator
2122
Reload(context.Context)
23+
AnnotateIP(ip net.IP, geo **annotator.Geolocation) error
2224
}
2325

2426
// geoannotator is the central struct for this module.
@@ -41,9 +43,9 @@ func (g *geoannotator) Annotate(ID *inetdiag.SockID, annotations *annotator.Anno
4143

4244
switch dir {
4345
case annotator.DstIsServer:
44-
err = g.annotate(ID.SrcIP, &annotations.Client.Geo)
46+
err = g.annotateHoldingLock(ID.SrcIP, &annotations.Client.Geo)
4547
case annotator.SrcIsServer:
46-
err = g.annotate(ID.DstIP, &annotations.Client.Geo)
48+
err = g.annotateHoldingLock(ID.DstIP, &annotations.Client.Geo)
4749
}
4850
if err != nil {
4951
return annotator.ErrNoAnnotation
@@ -53,11 +55,24 @@ func (g *geoannotator) Annotate(ID *inetdiag.SockID, annotations *annotator.Anno
5355

5456
var emptyResult = geoip2.City{}
5557

56-
func (g *geoannotator) annotate(src string, geo **annotator.Geolocation) error {
58+
func (g *geoannotator) annotateHoldingLock(src string, geo **annotator.Geolocation) error {
5759
ip := net.ParseIP(src)
5860
if ip == nil {
5961
return fmt.Errorf("failed to parse IP %q", src)
6062
}
63+
return g.annotateIPHoldingLock(ip, geo)
64+
}
65+
66+
func (g *geoannotator) AnnotateIP(ip net.IP, geo **annotator.Geolocation) error {
67+
g.mut.RLock()
68+
defer g.mut.RUnlock()
69+
return g.annotateIPHoldingLock(ip, geo)
70+
}
71+
72+
func (g *geoannotator) annotateIPHoldingLock(ip net.IP, geo **annotator.Geolocation) error {
73+
if ip == nil {
74+
return errors.New("can't annotate nil IP")
75+
}
6176
record, err := g.maxmind.City(ip)
6277
if err != nil {
6378
return err
@@ -66,8 +81,14 @@ func (g *geoannotator) annotate(src string, geo **annotator.Geolocation) error {
6681
// Check for empty results because "not found" is not an error. Instead the
6782
// geoip2 package returns an empty result. May be fixed in a future version:
6883
// https://github.com/oschwald/geoip2-golang/issues/32
84+
//
85+
// "Not found" in a well-functioning database should not be an error.
86+
// Instead, it is an accurate reflection of data that is missing.
6987
if isEmpty(record) {
70-
return fmt.Errorf("not found %q", src)
88+
*geo = &annotator.Geolocation{
89+
Missing: true,
90+
}
91+
return nil
7192
}
7293

7394
tmp := &annotator.Geolocation{
@@ -132,7 +153,7 @@ func (g *geoannotator) load(ctx context.Context) (*geoip2.Reader, error) {
132153
// New makes a new Annotator that uses IP addresses to generate geolocation and
133154
// ASNumber metadata for that IP based on the current copy of MaxMind data
134155
// stored in GCS.
135-
func New(ctx context.Context, geo rawfile.Provider, localIPs []net.IP) ReloadingAnnotator {
156+
func New(ctx context.Context, geo rawfile.Provider, localIPs []net.IP) GeoAnnotator {
136157
g := &geoannotator{
137158
backingDataSource: geo,
138159
localIPs: localIPs,

geoannotator/ipannotator_test.go

Lines changed: 13 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -9,6 +9,7 @@ import (
99
"net/url"
1010
"testing"
1111

12+
"github.com/go-test/deep"
1213
"github.com/m-lab/go/pretty"
1314
"github.com/m-lab/go/rtx"
1415
"github.com/m-lab/tcp-info/inetdiag"
@@ -70,6 +71,13 @@ func TestIPAnnotationS2C(t *testing.T) {
7071
if math.Abs(ann.Client.Geo.Latitude-51.75) > .01 {
7172
t.Error("Bad Client latitude:", ann.Client.Geo.Latitude, "!~=", 51.75)
7273
}
74+
75+
ann2 := &annotator.Annotations{}
76+
g.AnnotateIP(net.ParseIP(remoteIP), &ann2.Client.Geo)
77+
78+
if diff := deep.Equal(ann, ann2); diff != nil {
79+
log.Println("Annotate and AnnotateIP should do the same thing, but they differ:", diff)
80+
}
7381
}
7482

7583
func TestIPAnnotationC2S(t *testing.T) {
@@ -131,7 +139,7 @@ func TestIPAnnotationBadDst(t *testing.T) {
131139
conn := &inetdiag.SockID{
132140
SrcIP: "1.0.0.1",
133141
SPort: 1,
134-
DstIP: "0.0.0.0", // A local IP
142+
DstIP: "this is not an IP address",
135143
DPort: 2,
136144
Cookie: 4,
137145
}
@@ -140,11 +148,11 @@ func TestIPAnnotationBadDst(t *testing.T) {
140148
err := g.Annotate(conn, ann)
141149

142150
if err == nil {
143-
t.Errorf("Annotate succeeded with a bad IP: %q", conn.SrcIP)
151+
t.Errorf("Annotate succeeded with a bad IP: %q", conn.DstIP)
144152
}
145153
}
146154

147-
func TestIPAnnotationUknownDirection(t *testing.T) {
155+
func TestIPAnnotationUnknownDirection(t *testing.T) {
148156
localaddrs := []net.IP{net.ParseIP("1.0.0.1")}
149157
g := New(context.Background(), localRawfile, localaddrs)
150158

@@ -179,9 +187,9 @@ func TestIPAnnotationUnknownIP(t *testing.T) {
179187

180188
ann := &annotator.Annotations{}
181189
err := g.Annotate(conn, ann)
182-
if !errors.Is(err, annotator.ErrNoAnnotation) {
190+
if err != nil || ann.Client.Geo == nil || !ann.Client.Geo.Missing {
183191
pretty.Print(ann)
184-
t.Error("Should have had an ErrNoAnnotation error due to IP missing from our dataset, but got", err)
192+
t.Error("Should have had a client annotation with everything set to Missing, but got", err)
185193
}
186194
}
187195

ipservice/client.go

Lines changed: 89 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,89 @@
1+
package ipservice
2+
3+
import (
4+
"context"
5+
"encoding/json"
6+
"fmt"
7+
"io/ioutil"
8+
"log"
9+
"net"
10+
"net/http"
11+
12+
"github.com/m-lab/uuid-annotator/annotator"
13+
"github.com/m-lab/uuid-annotator/metrics"
14+
)
15+
16+
// Client is the interface for all users who want an IP annotated from the
17+
// uuid-annotator service.
18+
//
19+
// Behind the scenes, the client is an http client and the server is an http
20+
// server, connecting to each other via unix-domain sockets. Except for the name
21+
// of the socket, all details of how the IPC is done should be considered
22+
// internal and subject to change without notice. In particular, if the overhead
23+
// of encoding and decoding lots of HTTP transactions ends up being too high, we
24+
// reserve the right to change away from HTTP without warning.
25+
type Client interface {
26+
// Annotate gets the ClientAnnotations associated with a particular IP address.
27+
Annotate(ctx context.Context, ip net.IP) (*annotator.ClientAnnotations, error)
28+
}
29+
30+
// getter defines the subset of the interface of http.Client that we use, in an
31+
// effort to enable mocking and testing.
32+
type getter interface {
33+
Get(url string) (resp *http.Response, err error)
34+
}
35+
36+
type client struct {
37+
sockfilename string
38+
httpc getter
39+
}
40+
41+
func (c *client) Annotate(ctx context.Context, ip net.IP) (*annotator.ClientAnnotations, error) {
42+
u := "http://unix/v1/annotate/ip?ip=" + ip.String()
43+
resp, err := c.httpc.Get(u)
44+
if err != nil {
45+
metrics.ClientRPCCount.WithLabelValues("get_error").Inc()
46+
return nil, err
47+
}
48+
if resp.StatusCode != 200 {
49+
metrics.ClientRPCCount.WithLabelValues("http_status_error").Inc()
50+
return nil, fmt.Errorf("Got HTTP %d, but wanted HTTP 200", resp.StatusCode)
51+
}
52+
ann := &annotator.ClientAnnotations{}
53+
b, err := ioutil.ReadAll(resp.Body)
54+
if err != nil {
55+
metrics.ClientRPCCount.WithLabelValues("read_error").Inc()
56+
return nil, err
57+
}
58+
err = json.Unmarshal(b, ann)
59+
if err == nil {
60+
metrics.ClientRPCCount.WithLabelValues("success").Inc()
61+
} else {
62+
metrics.ClientRPCCount.WithLabelValues("unmarshal_error").Inc()
63+
}
64+
return ann, err
65+
}
66+
67+
// NewClient creates an RPC client for annotating IP addresses. The only RPC
68+
// that is performed should happen through objects returned from this function.
69+
// All other forms of RPC to the local IP annotation service have no long-term
70+
// compatibility guarantees.
71+
//
72+
// The recommended value to pass into this function is the value of the
73+
// command-line flag `--ipservice.SocketFilename`, which is pointed to by
74+
// `ipservice.SocketFilename`.
75+
func NewClient(sockfilename string) Client {
76+
if sockfilename != *SocketFilename {
77+
log.Printf("WARNING: socket filename of %q differs from command-line flag value of %q\n", sockfilename, *SocketFilename)
78+
}
79+
return &client{
80+
sockfilename: sockfilename,
81+
httpc: &http.Client{
82+
Transport: &http.Transport{
83+
DialContext: func(ctx context.Context, _, _ string) (net.Conn, error) {
84+
return net.Dial("unix", sockfilename)
85+
},
86+
},
87+
},
88+
}
89+
}

ipservice/ipservice.go

Lines changed: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,10 @@
1+
// Package ipservice sets up and queries and runs the RPC service for annotating IP addresses.
2+
package ipservice
3+
4+
import "flag"
5+
6+
// SocketFilename is a flag to allow both clients and servers to use the same command-line flag.
7+
var SocketFilename = flag.String(
8+
"ipservice.sock",
9+
"",
10+
"The filename to use as a UNIX domain socket for the local annotation service.")

0 commit comments

Comments
 (0)