bgpwtf/cccampix: add IRR daemon

We add a small IRR service for getting a parsed RPSL from IRRs. For now,
we only support RIPE and ARIN, and only the following attributes:
 - remarks
 - import
 - export

Since RPSL/RFC2622 is fucking insane, there is no guarantee that the
parser, especially the import/export parser, is correct. But it should
be good enough for our use. We even throw in some tests for good
measure.

    $ grpcurl -format text -plaintext -d 'as: "26625"' 127.0.0.1:4200 ix.IRR.Query
    source: SOURCE_ARIN
    attributes: <
      import: <
        expressions: <
          peering: "AS6083"
          actions: "pref=10"
        >
        filter: "ANY"
      >
    >
    attributes: <
      import: <
        expressions: <
          peering: "AS12491"
          actions: "pref=10"
        >
        filter: "ANY"
      >
    >

Change-Id: I8b240ffe2cd3553a25ce33dbd3917c0aef64e804
diff --git a/WORKSPACE b/WORKSPACE
index 5d0d981..5011b83 100644
--- a/WORKSPACE
+++ b/WORKSPACE
@@ -599,3 +599,15 @@
     commit = "c182affec369e30f25d3eb8cd8a478dee585ae7d",
     importpath = "github.com/matttproud/golang_protobuf_extensions",
 )
+
+go_repository(
+    name = "com_github_golang_collections_go_datastructures",
+    commit = "59788d5eb2591d3497ffb8fafed2f16fe00e7775",
+    importpath = "github.com/golang-collections/go-datastructures",
+)
+
+go_repository(
+    name = "com_github_go_test_deep",
+    commit = "cf67d735e69b4a4d50cdf571a92b0144786080f7",
+    importpath = "github.com/go-test/deep",
+)
diff --git a/bgpwtf/cccampix/irr/BUILD.bazel b/bgpwtf/cccampix/irr/BUILD.bazel
new file mode 100644
index 0000000..64b8e32
--- /dev/null
+++ b/bgpwtf/cccampix/irr/BUILD.bazel
@@ -0,0 +1,22 @@
+load("@io_bazel_rules_go//go:def.bzl", "go_binary", "go_library")
+
+go_library(
+    name = "go_default_library",
+    srcs = ["main.go"],
+    importpath = "code.hackerspace.pl/hscloud/bgpwtf/cccampix/irr",
+    visibility = ["//visibility:private"],
+    deps = [
+        "//bgpwtf/cccampix/irr/provider:go_default_library",
+        "//bgpwtf/cccampix/proto:go_default_library",
+        "//go/mirko:go_default_library",
+        "@com_github_golang_glog//:go_default_library",
+        "@org_golang_google_grpc//codes:go_default_library",
+        "@org_golang_google_grpc//status:go_default_library",
+    ],
+)
+
+go_binary(
+    name = "irr",
+    embed = [":go_default_library"],
+    visibility = ["//visibility:public"],
+)
diff --git a/bgpwtf/cccampix/irr/README.md b/bgpwtf/cccampix/irr/README.md
new file mode 100644
index 0000000..de88d8b
--- /dev/null
+++ b/bgpwtf/cccampix/irr/README.md
@@ -0,0 +1,69 @@
+irr
+===
+
+A proxy to access IRR RPSL data. It queries IANA for the responsible IRR, then the IRR directly.
+
+It currently support ARIN and RIPE.
+
+It currently supports querying for information about an aut-num, and returns the following attributes:
+ - remarks
+ - import
+ - export
+
+Example
+-------
+
+    $ grpcurl -format text -plaintext -d 'as: "26625"' 127.0.0.1:4220 ix.IRR.Query
+    source: SOURCE_ARIN
+    attributes: <
+      import: <
+        expressions: <
+          peering: "AS6083"
+          actions: "pref=10"
+        >
+        filter: "ANY"
+      >
+    >
+    attributes: <
+      import: <
+        expressions: <
+          peering: "AS12491"
+          actions: "pref=10"
+        >
+        filter: "ANY"
+      >
+    >
+    attributes: <
+      import: <
+        expressions: <
+          peering: "AS20459"
+          actions: "pref=10"
+        >
+        filter: "ANY"
+      >
+    >
+    attributes: <
+      export: <
+        expressions: <
+          peering: "AS6083"
+        >
+        filter: "AS26625"
+      >
+    >
+    attributes: <
+      export: <
+        expressions: <
+          peering: "AS12491"
+        >
+        filter: "AS26625"
+      >
+    >
+    attributes: <
+      export: <
+        expressions: <
+          peering: "AS20459"
+        >
+        filter: "AS26625"
+      >
+    >
+
diff --git a/bgpwtf/cccampix/irr/main.go b/bgpwtf/cccampix/irr/main.go
new file mode 100644
index 0000000..1492e6c
--- /dev/null
+++ b/bgpwtf/cccampix/irr/main.go
@@ -0,0 +1,76 @@
+package main
+
+import (
+	"context"
+	"flag"
+	"strconv"
+	"strings"
+
+	"github.com/golang/glog"
+	"google.golang.org/grpc/codes"
+	"google.golang.org/grpc/status"
+
+	"code.hackerspace.pl/hscloud/bgpwtf/cccampix/irr/provider"
+	pb "code.hackerspace.pl/hscloud/bgpwtf/cccampix/proto"
+	"code.hackerspace.pl/hscloud/go/mirko"
+)
+
+type service struct {
+	iana      *provider.IANA
+	providers map[provider.IRR]provider.Provider
+}
+
+func main() {
+	flag.Parse()
+	mi := mirko.New()
+
+	if err := mi.Listen(); err != nil {
+		glog.Exitf("Listen failed: %v", err)
+	}
+
+	s := &service{
+		iana: provider.NewIANA(),
+		providers: map[provider.IRR]provider.Provider{
+			provider.IRR_RIPE: provider.NewRIPE(),
+			provider.IRR_ARIN: provider.NewARIN(),
+		},
+	}
+	pb.RegisterIRRServer(mi.GRPC(), s)
+
+	if err := mi.Serve(); err != nil {
+		glog.Exitf("Serve failed: %v", err)
+	}
+
+	<-mi.Done()
+}
+
+// Query returns parsed RPSL data for a given aut-num objects in any of the
+// supported IRRs.
+func (s *service) Query(ctx context.Context, req *pb.IRRQueryRequest) (*pb.IRRQueryResponse, error) {
+	if req.As == "" {
+		return nil, status.Error(codes.InvalidArgument, "as must be given")
+	}
+
+	req.As = strings.ToLower(req.As)
+	if strings.HasPrefix(req.As, "as") {
+		req.As = req.As[2:]
+	}
+
+	asn, err := strconv.ParseUint(req.As, 10, 64)
+	if err != nil {
+		return nil, status.Error(codes.InvalidArgument, "as is invalid")
+	}
+
+	irr, err := s.iana.Who(ctx, asn)
+	if err != nil {
+		return nil, status.Errorf(codes.Unavailable, "could not find AS block delegation from IANA: %v", err)
+	}
+
+	prov, ok := s.providers[irr]
+	if !ok {
+		return nil, status.Errorf(codes.NotFound, "AS belongs to unhandled IRR %s", irr.String())
+	}
+
+	res, err := prov.Query(ctx, asn)
+	return res, err
+}
diff --git a/bgpwtf/cccampix/irr/provider/BUILD.bazel b/bgpwtf/cccampix/irr/provider/BUILD.bazel
new file mode 100644
index 0000000..f39744e
--- /dev/null
+++ b/bgpwtf/cccampix/irr/provider/BUILD.bazel
@@ -0,0 +1,30 @@
+load("@io_bazel_rules_go//go:def.bzl", "go_library", "go_test")
+
+go_library(
+    name = "go_default_library",
+    srcs = [
+        "arin.go",
+        "iana.go",
+        "provider.go",
+        "ripe.go",
+        "rpsl.go",
+    ],
+    importpath = "code.hackerspace.pl/hscloud/bgpwtf/cccampix/irr/provider",
+    visibility = ["//visibility:public"],
+    deps = [
+        "//bgpwtf/cccampix/irr/whois:go_default_library",
+        "//bgpwtf/cccampix/proto:go_default_library",
+        "@com_github_golang_collections_go_datastructures//augmentedtree:go_default_library",
+        "@com_github_golang_glog//:go_default_library",
+    ],
+)
+
+go_test(
+    name = "go_default_test",
+    srcs = ["rpsl_test.go"],
+    embed = [":go_default_library"],
+    deps = [
+        "//bgpwtf/cccampix/proto:go_default_library",
+        "@com_github_go_test_deep//:go_default_library",
+    ],
+)
diff --git a/bgpwtf/cccampix/irr/provider/arin.go b/bgpwtf/cccampix/irr/provider/arin.go
new file mode 100644
index 0000000..1781198
--- /dev/null
+++ b/bgpwtf/cccampix/irr/provider/arin.go
@@ -0,0 +1,78 @@
+package provider
+
+// Support for the ARIN IRR.
+// ARIN is special. We have to query them via whois. It's also not the same
+// whois as you usually see. And also not many autnums (even big players like
+// AS15169 and AS112) have IRR entries at all.
+
+import (
+	"context"
+	"fmt"
+	"strings"
+
+	"code.hackerspace.pl/hscloud/bgpwtf/cccampix/irr/whois"
+	pb "code.hackerspace.pl/hscloud/bgpwtf/cccampix/proto"
+)
+
+const ARINWhois = "rr.arin.net:43"
+
+type arin struct {
+}
+
+func NewARIN() Provider {
+	return &arin{}
+}
+
+func (r *arin) Query(ctx context.Context, asn uint64) (*pb.IRRQueryResponse, error) {
+	data, err := whois.Query(ctx, ARINWhois, fmt.Sprintf("AS%d", asn))
+	if err != nil {
+		return nil, fmt.Errorf("could not contact ARIN IRR: %v", err)
+	}
+
+	lines := strings.Split(data, "\n")
+
+	// Convert possibly 'continued' RPSL entries into single-line entries.
+	// eg.
+	//   import:         from AS6083
+	//                   action pref=10;
+	//                   accept ANY
+	// into
+	//  'import', 'from AS6083, action pref=10; accept ANY'
+
+	attrs := []rpslRawAttribute{}
+	for _, line := range lines {
+		if strings.HasPrefix(strings.TrimSpace(line), "%") {
+			// Comment
+			continue
+		}
+		if strings.TrimSpace(line) == "" {
+			// Empty line
+			continue
+		}
+
+		if strings.HasPrefix(line, " ") {
+			// Continuation
+			if len(attrs) < 1 {
+				return nil, fmt.Errorf("unparseable IRR, continuation with no previous atribute name: %q", line)
+			}
+
+			attrs[len(attrs)-1].value += " " + strings.TrimSpace(line)
+		} else {
+			parts := strings.SplitN(line, ":", 2)
+			if len(parts) != 2 {
+				return nil, fmt.Errorf("unparseable IRR, line with no attribute key: %q", line)
+			}
+			name := strings.TrimSpace(parts[0])
+			value := strings.TrimSpace(parts[1])
+			attrs = append(attrs, rpslRawAttribute{
+				name:  name,
+				value: value,
+			})
+		}
+	}
+
+	return &pb.IRRQueryResponse{
+		Source:     pb.IRRQueryResponse_SOURCE_ARIN,
+		Attributes: parseAttributes(attrs),
+	}, nil
+}
diff --git a/bgpwtf/cccampix/irr/provider/iana.go b/bgpwtf/cccampix/irr/provider/iana.go
new file mode 100644
index 0000000..8f085d2
--- /dev/null
+++ b/bgpwtf/cccampix/irr/provider/iana.go
@@ -0,0 +1,163 @@
+package provider
+
+// IANA is not a full provider - we use it to determine the relevant IRR for a given aut-num.
+
+import (
+	"context"
+	"fmt"
+	"strconv"
+	"strings"
+
+	"github.com/golang-collections/go-datastructures/augmentedtree"
+	"github.com/golang/glog"
+
+	"code.hackerspace.pl/hscloud/bgpwtf/cccampix/irr/whois"
+)
+
+// Possible IRRs that AS blocks can be delegated to.
+type IRR int
+
+const (
+	IRR_UNKNOWN IRR = iota
+	IRR_ARIN
+	IRR_RIPE
+)
+
+func (i IRR) String() string {
+	return []string{
+		"UNKNOWN",
+		"ARIN",
+		"RIPE",
+	}[i]
+}
+
+// IANA access service.
+type IANA struct {
+	// Interval tree of AS block delegation to IRRs. We use this to not
+	// keep hitting the IANA whois unnecessariy.
+	cache augmentedtree.Tree
+	// The tree library needs intervals to have a unique ID. We use a counter
+	// for this effect.
+	id uint64
+}
+
+func NewIANA() *IANA {
+	return &IANA{
+		cache: augmentedtree.New(1),
+	}
+}
+
+func (i *IANA) nextID() uint64 {
+	res := i.id
+	i.id += 1
+	return res
+}
+
+// delegation implements the Interval interface for the interval tree.
+type delegation struct {
+	to   IRR
+	id   uint64
+	low  int64
+	high int64
+}
+
+func (d *delegation) LowAtDimension(n uint64) int64 {
+	if n != 1 {
+		panic(fmt.Sprintf("dimension too high (%d)", n))
+	}
+	return d.low
+}
+
+func (d *delegation) HighAtDimension(n uint64) int64 {
+	if n != 1 {
+		panic(fmt.Sprintf("dimension too high (%d)", n))
+	}
+	return d.high
+}
+
+func (d *delegation) OverlapsAtDimension(i augmentedtree.Interval, n uint64) bool {
+	if n != 1 {
+		return false
+	}
+
+	if i.LowAtDimension(1) <= d.HighAtDimension(1) && i.HighAtDimension(1) >= d.LowAtDimension(1) {
+		return true
+	}
+
+	return false
+}
+
+func (d *delegation) ID() uint64 {
+	return d.id
+}
+
+// Who returns the responsible IRR (or UNKNOWN) for a given AS.
+func (i *IANA) Who(ctx context.Context, asn uint64) (IRR, error) {
+	q := &delegation{
+		id:   i.nextID(),
+		low:  int64(asn),
+		high: int64(asn),
+	}
+
+	res := i.cache.Query(q)
+	if len(res) > 0 {
+		return res[0].(*delegation).to, nil
+	}
+
+	// No cache entry, query whois.
+	glog.Infof("Cache miss for AS%d", asn)
+	data, err := whois.Query(ctx, "whois.iana.org:43", fmt.Sprintf("AS%d", asn))
+	if err != nil {
+		return IRR_UNKNOWN, err
+	}
+
+	// We not only find the responsible IRR, but also the delegation bounds
+	// to feed the interval tree.
+	lines := strings.Split(data, "\n")
+	var lower int64
+	var upper int64
+	var irr IRR
+	for _, line := range lines {
+		parts := strings.Fields(line)
+		if len(parts) == 2 && parts[0] == "as-block:" {
+			bounds := strings.Split(parts[1], "-")
+			if len(bounds) != 2 {
+				return IRR_UNKNOWN, fmt.Errorf("unparseable as-block: %v", parts[1])
+			}
+			lower, err = strconv.ParseInt(bounds[0], 10, 64)
+			if err != nil {
+				return IRR_UNKNOWN, fmt.Errorf("unparseable as-block: %v", parts[1])
+			}
+			upper, err = strconv.ParseInt(bounds[1], 10, 64)
+			if err != nil {
+				return IRR_UNKNOWN, fmt.Errorf("unparseable as-block: %v", parts[1])
+			}
+		}
+		if len(parts) > 2 && parts[0] == "organisation:" {
+			switch {
+			case strings.Contains(line, "RIPE NCC"):
+				irr = IRR_RIPE
+			case strings.Contains(line, "ARIN"):
+				irr = IRR_ARIN
+			}
+		}
+	}
+
+	if lower == 0 || upper == 0 {
+		return IRR_UNKNOWN, nil
+	}
+
+	glog.Infof("Saving %d-%d AS blocks delegation (%s) to cache", lower, upper, irr.String())
+
+	// Insert into tree.
+	q = &delegation{
+		id:   i.nextID(),
+		to:   irr,
+		low:  lower,
+		high: upper + 1,
+	}
+
+	i.cache.Add(q)
+
+	return irr, nil
+}
diff --git a/bgpwtf/cccampix/irr/provider/provider.go b/bgpwtf/cccampix/irr/provider/provider.go
new file mode 100644
index 0000000..4e2921b
--- /dev/null
+++ b/bgpwtf/cccampix/irr/provider/provider.go
@@ -0,0 +1,13 @@
+package provider
+
+import (
+	"context"
+
+	pb "code.hackerspace.pl/hscloud/bgpwtf/cccampix/proto"
+)
+
+// Provider is the interface exposed to the service by IRR proxies.
+type Provider interface {
+	// Return a proto response for a given AS.
+	Query(ctx context.Context, as uint64) (*pb.IRRQueryResponse, error)
+}
diff --git a/bgpwtf/cccampix/irr/provider/ripe.go b/bgpwtf/cccampix/irr/provider/ripe.go
new file mode 100644
index 0000000..5b09b9f
--- /dev/null
+++ b/bgpwtf/cccampix/irr/provider/ripe.go
@@ -0,0 +1,81 @@
+package provider
+
+// Support for the RIPE IRR.
+// We use the RIPE REST DB API.
+
+import (
+	"context"
+	"encoding/json"
+	"fmt"
+	"io/ioutil"
+	"net/http"
+
+	pb "code.hackerspace.pl/hscloud/bgpwtf/cccampix/proto"
+)
+
+type ripeResponse struct {
+	Objects struct {
+		Object []ripeObject `json:"object"`
+	} `json:"objects"`
+}
+
+type ripeObject struct {
+	Type       string `json:"type"`
+	Attributes struct {
+		Attribute []ripeAttribute `json:"attribute"`
+	} `json:"attributes"`
+}
+
+type ripeAttribute struct {
+	Name  string `json:"name"`
+	Value string `json:"value"`
+}
+
+type ripe struct {
+}
+
+func NewRIPE() Provider {
+	return &ripe{}
+}
+
+func (r *ripe) Query(ctx context.Context, as uint64) (*pb.IRRQueryResponse, error) {
+	req, err := http.NewRequest("GET", fmt.Sprintf("http://rest.db.ripe.net/ripe/aut-num/AS%d.json", as), nil)
+	if err != nil {
+		return nil, err
+	}
+
+	req = req.WithContext(ctx)
+	client := http.DefaultClient
+
+	res, err := client.Do(req)
+	if err != nil {
+		return nil, fmt.Errorf("could not run GET to RIPE: %v", err)
+	}
+	defer res.Body.Close()
+	bytes, err := ioutil.ReadAll(res.Body)
+	if err != nil {
+		return nil, fmt.Errorf("could not read response from RIPE: %v", err)
+	}
+
+	data := ripeResponse{}
+	err = json.Unmarshal(bytes, &data)
+	if err != nil {
+		return nil, fmt.Errorf("could not decode response from RIPE: %v", err)
+	}
+
+	if len(data.Objects.Object) != 1 {
+		return nil, fmt.Errorf("could not retriev aut-num from RIPE")
+	}
+
+	attributes := make([]rpslRawAttribute, len(data.Objects.Object[0].Attributes.Attribute))
+
+	for i, attr := range data.Objects.Object[0].Attributes.Attribute {
+		attributes[i].name = attr.Name
+		attributes[i].value = attr.Value
+	}
+
+	return &pb.IRRQueryResponse{
+		Source:     pb.IRRQueryResponse_SOURCE_RIPE,
+		Attributes: parseAttributes(attributes),
+	}, nil
+}
diff --git a/bgpwtf/cccampix/irr/provider/rpsl.go b/bgpwtf/cccampix/irr/provider/rpsl.go
new file mode 100644
index 0000000..68a1f51
--- /dev/null
+++ b/bgpwtf/cccampix/irr/provider/rpsl.go
@@ -0,0 +1,146 @@
+package provider
+
+import (
+	"strings"
+
+	pb "code.hackerspace.pl/hscloud/bgpwtf/cccampix/proto"
+)
+
+// A raw, unparsed RPSL name/value pair.
+type rpslRawAttribute struct {
+	name  string
+	value string
+}
+
+// parseAttributes converts raw RPSL attributes into parsed, structured protos.
+func parseAttributes(attrs []rpslRawAttribute) []*pb.IRRAttribute {
+	res := []*pb.IRRAttribute{}
+
+	for _, attr := range attrs {
+		switch attr.name {
+		case "remarks":
+			res = append(res, &pb.IRRAttribute{
+				Value: &pb.IRRAttribute_Remarks{attr.value},
+			})
+		case "import":
+			ie := parseImportExport(attr.value)
+			if ie != nil {
+				res = append(res, &pb.IRRAttribute{
+					Value: &pb.IRRAttribute_Import{ie},
+				})
+			}
+		case "export":
+			ie := parseImportExport(attr.value)
+			if ie != nil {
+				res = append(res, &pb.IRRAttribute{
+					Value: &pb.IRRAttribute_Export{ie},
+				})
+			}
+		}
+	}
+
+	return res
+}
+
+// parseImportExport tries to parse the RPSL subset for import/export
+// attributes.
+// It's a hand-written single-pass parser which makes it not very good.
+// See rpsl_test.go for examples.
+func parseImportExport(expression string) *pb.IRRAttribute_ImportExport {
+	popToken := func(s string) (string, string) {
+		s = strings.TrimSpace(s)
+		fields := strings.Fields(s)
+		if len(fields) < 2 {
+			return "", s
+		}
+		return fields[0], strings.TrimSpace(s[len(fields[0]):])
+	}
+
+	res := &pb.IRRAttribute_ImportExport{}
+
+	expr := expression
+	var t string
+	t, expr = popToken(expr)
+
+	for {
+		switch t {
+		case "":
+			break
+		case "protocol":
+			t, expr = popToken(expr)
+			res.ProtocolFrom = t
+			t, expr = popToken(expr)
+			continue
+		case "into":
+			t, expr = popToken(expr)
+			res.ProtocolInto = t
+			t, expr = popToken(expr)
+			continue
+		case "from":
+			fallthrough
+		case "to":
+			t, expr = popToken(expr)
+			peering := t
+			actions := []string{}
+			router_us := ""
+			router_them := ""
+
+			t2, expr2 := popToken(expr)
+			for {
+				switch t2 {
+				case "at":
+					t2, expr2 = popToken(expr2)
+					router_us = t2
+					t2, expr2 = popToken(expr2)
+					continue
+				case "action":
+					parts := strings.SplitN(expr2, ";", 2)
+					if len(parts) != 2 {
+						// malformed action, no ';' found - bail
+						return nil
+					}
+					actions = append(actions, parts[0])
+					expr2 = strings.TrimSpace(parts[1])
+					t2, expr2 = popToken(expr2)
+					continue
+				case "":
+					fallthrough
+				case "accept":
+					fallthrough
+				case "announce":
+					t = t2
+					expr = expr2
+					break
+				default:
+					if router_them == "" {
+						router_them = t2
+						t2, expr2 = popToken(expr2)
+						continue
+					}
+					t = t2
+					expr = expr2
+					break
+				}
+				break
+			}
+			res.Expressions = append(res.Expressions, &pb.IRRAttribute_ImportExport_Expression{
+				Peering:    peering,
+				RouterUs:   router_us,
+				RouterThem: router_them,
+				Actions:    actions,
+			})
+			continue
+		case "accept":
+			fallthrough
+		case "announce":
+			res.Filter = expr
+			t = ""
+			continue
+		default:
+			return nil
+		}
+		break
+	}
+
+	return res
+}
diff --git a/bgpwtf/cccampix/irr/provider/rpsl_test.go b/bgpwtf/cccampix/irr/provider/rpsl_test.go
new file mode 100644
index 0000000..2c0fe57
--- /dev/null
+++ b/bgpwtf/cccampix/irr/provider/rpsl_test.go
@@ -0,0 +1,64 @@
+package provider
+
+import (
+	"testing"
+
+	pb "code.hackerspace.pl/hscloud/bgpwtf/cccampix/proto"
+	"github.com/go-test/deep"
+)
+
+func TestParseImportExport(t *testing.T) {
+	tests := []struct {
+		ut   string
+		want *pb.IRRAttribute_ImportExport
+	}{
+		{
+			ut: `
+				from AS10674 209.251.128.177 at 216.155.103.20 action pref = 1;
+				accept ANY AND NOT AS-ACCELERATION-CUST
+			`,
+			want: &pb.IRRAttribute_ImportExport{
+				Expressions: []*pb.IRRAttribute_ImportExport_Expression{
+					{
+						Peering:    "AS10674",
+						RouterUs:   "216.155.103.20",
+						RouterThem: "209.251.128.177",
+						Actions:    []string{"pref = 1"},
+					},
+				},
+				Filter: "ANY AND NOT AS-ACCELERATION-CUST",
+			},
+		},
+		{
+			ut: `
+				to AS201054 94.246.185.174 at 94.246.185.175 announce AS-BGPWTF
+			`,
+			want: &pb.IRRAttribute_ImportExport{
+				Expressions: []*pb.IRRAttribute_ImportExport_Expression{
+					{
+						Peering:    "AS201054",
+						RouterUs:   "94.246.185.175",
+						RouterThem: "94.246.185.174",
+						Actions:    []string{},
+					},
+				},
+				Filter: "AS-BGPWTF",
+			},
+		},
+		{
+			// Invalid - unterminated action.
+			ut: `
+				to AS201054 94.246.185.174 at 94.246.185.175 action foo = bar
+				accept ANY AND NOT AS-ACCELERATION-CUST
+			`,
+			want: nil,
+		},
+	}
+
+	for _, test := range tests {
+		res := parseImportExport(test.ut)
+		if diff := deep.Equal(test.want, res); diff != nil {
+			t.Error(diff)
+		}
+	}
+}
diff --git a/bgpwtf/cccampix/irr/whois/BUILD.bazel b/bgpwtf/cccampix/irr/whois/BUILD.bazel
new file mode 100644
index 0000000..d30237d
--- /dev/null
+++ b/bgpwtf/cccampix/irr/whois/BUILD.bazel
@@ -0,0 +1,8 @@
+load("@io_bazel_rules_go//go:def.bzl", "go_library")
+
+go_library(
+    name = "go_default_library",
+    srcs = ["whois.go"],
+    importpath = "code.hackerspace.pl/hscloud/bgpwtf/cccampix/irr/whois",
+    visibility = ["//visibility:public"],
+)
diff --git a/bgpwtf/cccampix/irr/whois/whois.go b/bgpwtf/cccampix/irr/whois/whois.go
new file mode 100644
index 0000000..0e313b1
--- /dev/null
+++ b/bgpwtf/cccampix/irr/whois/whois.go
@@ -0,0 +1,32 @@
+package whois
+
+// Support for the WHOIS protocol.
+
+import (
+	"context"
+	"fmt"
+	"io/ioutil"
+	"net"
+	"strings"
+)
+
+// Query returns a semi-raw response from a WHOIS server for a given query.
+// We only convert \r\n to \n, and then do no other transformation to the data.
+func Query(ctx context.Context, server string, query string) (string, error) {
+	var d net.Dialer
+	conn, err := d.DialContext(ctx, "tcp", server)
+	if err != nil {
+		return "", fmt.Errorf("while dialing %q: %v", server, err)
+	}
+
+	defer conn.Close()
+
+	fmt.Fprintf(conn, "%s\r\n", query)
+
+	data, err := ioutil.ReadAll(conn)
+	if err != nil {
+		return "", fmt.Errorf("while receiving data from %q: %v", server, err)
+	}
+
+	return strings.ReplaceAll(string(data), "\r", ""), nil
+}
diff --git a/bgpwtf/cccampix/proto/ix.proto b/bgpwtf/cccampix/proto/ix.proto
index c938386..01ddc4f 100644
--- a/bgpwtf/cccampix/proto/ix.proto
+++ b/bgpwtf/cccampix/proto/ix.proto
@@ -39,3 +39,45 @@
     // GetIXMembers returns information about membership of a given PeeringDB IX.
     rpc GetIXMembers(GetIXMembersRequest) returns (GetIXMembersResponse);
 }
+
+message IRRQueryRequest {
+    // AS to query for. This needs be the AS number of the AS, possibly
+    // prefixed with 'as'/'AS'.
+    string as = 1;
+}
+
+message IRRAttribute {
+    message ImportExport {
+        message Expression {
+            string peering = 1;
+            string router_us = 2;
+            string router_them = 3;
+            repeated string actions = 4;
+        }
+        string protocol_from = 1;
+        string protocol_into = 2;
+        repeated Expression expressions = 3;
+        string filter = 4;
+    }
+
+    oneof value {
+        string remarks = 1;
+        ImportExport import = 2;
+        ImportExport export = 3;
+    }
+}
+
+message IRRQueryResponse {
+    enum Source {
+        SOURCE_INVALID = 0;
+        SOURCE_RIPE = 1;
+        SOURCE_ARIN = 2;
+    }
+    Source source = 1;
+    repeated IRRAttribute attributes = 2;
+}
+
+service IRR {
+    // Query returns parsed RPSL data from supported IRRs for a given aut-num.
+    rpc Query(IRRQueryRequest) returns (IRRQueryResponse);
+}