dn42regsrv/roaapi.go
Simon Marsh c6a608061e
All checks were successful
continuous-integration/drone/push Build is passing
Localise all external resources so that application can be used without requiring clearnet connectivity
Add gzip handler so that assets can be compressed if clients request it
Add cache-control headers so that content can be effectively cached locally
2020-10-24 14:47:18 +01:00

493 lines
12 KiB
Go

//////////////////////////////////////////////////////////////////////////
// DN42 Registry API Server
//////////////////////////////////////////////////////////////////////////
package main
//////////////////////////////////////////////////////////////////////////
import (
"fmt"
"github.com/gorilla/mux"
log "github.com/sirupsen/logrus"
// "math/big"
"bufio"
"net"
"net/http"
"os"
"sort"
"strconv"
"strings"
"time"
)
//////////////////////////////////////////////////////////////////////////
// register the api
func init() {
EventBus.Listen("APIEndpoint", InitROAAPI)
EventBus.Listen("RegistryUpdate", ROAUpdate)
}
//////////////////////////////////////////////////////////////////////////
// data model
type PrefixROA struct {
Prefix string `json:"prefix"`
MaxLen uint8 `json:"maxLength"`
ASN string `json:"asn"`
}
type ROAFilter struct {
Number uint `json:"nr"`
Action string `json:"action"`
Prefix string `json:"prefix"`
MinLen uint8 `json:"minlen"`
MaxLen uint8 `json:"maxlen"`
Network *net.IPNet `json:"-"`
IPType uint8 `json:"-"`
}
type ROA struct {
CTime time.Time
Commit string
Filters []*ROAFilter
IPv4 []*PrefixROA
IPv6 []*PrefixROA
}
var ROAData *ROA
// set validity period for one week
// this might appear to be a long time, but is intended to provide
// enough time to prevent expiry of the data between real registry
// updates (which may only happen infrequently)
const ROA_JSON_VALIDITY_PERIOD = (7 * 24)
type ROAMetaData struct {
Counts uint `json:"counts"`
Generated uint32 `json:"generated"`
Valid uint32 `json:"valid"`
Signature string `json:"signature,omitempty"`
SignatureDate string `json:"signatureDate,omitempty"`
}
type ROAJSON struct {
MetaData ROAMetaData `json:"metadata"`
Roas []*PrefixROA `json:"roas"`
}
var ROAJSONResponse *ROAJSON
//////////////////////////////////////////////////////////////////////////
// called from main to initialise the API routing
func InitROAAPI(params ...interface{}) {
router := params[0].(*mux.Router)
s := router.
Methods("GET").
PathPrefix("/roa").
Subrouter()
s.HandleFunc("/filter/{ipv}", roaFilterHandler)
s.HandleFunc("/json", roaJSONHandler)
s.HandleFunc("/bird/{birdv}/{ipv}", roaBirdHandler)
log.Info("ROA API installed")
}
//////////////////////////////////////////////////////////////////////////
// api handlers
// return JSON formatted version of filter{,6}.txt
func roaFilterHandler(w http.ResponseWriter, r *http.Request) {
vars := mux.Vars(r)
ipv := vars["ipv"]
// pre-create an array to hold the result
filters := make([]*ROAFilter, 0, len(ROAData.Filters))
// helper closure to select from the filter array
fselect := func(a []*ROAFilter, t uint8) []*ROAFilter {
for _, f := range ROAData.Filters {
if f.IPType == t {
a = append(a, f)
}
}
return a
}
// add ipv4 filters if required
if strings.ContainsRune(ipv, '4') {
filters = fselect(filters, 4)
}
// add ipv6 filters if required
if strings.ContainsRune(ipv, '6') {
filters = fselect(filters, 6)
}
// cache for up to a week, but set etag to commit to catch changes
w.Header().Set("Cache-Control", "public, max-age=604800, stale-if-error=86400")
w.Header().Set("ETag", ROAData.Commit)
ResponseJSON(w, filters)
}
// return JSON formatted ROA data suitable for use with GoRTR
func roaJSONHandler(w http.ResponseWriter, r *http.Request) {
// check validity period of returned data
tnow := uint32(time.Now().Unix())
valid := ROAJSONResponse.MetaData.Valid
// check if validity period is close to expiry
if (tnow > valid) ||
((valid - tnow) < (ROA_JSON_VALIDITY_PERIOD / 4)) {
// if so extend the validity period
ROAJSONResponse.MetaData.Valid += (ROA_JSON_VALIDITY_PERIOD * 3600)
}
// cache for up to a week, but set etag to commit to catch changes
w.Header().Set("Cache-Control", "public, max-age=604800, stale-if-error=86400")
w.Header().Set("ETag", ROAData.Commit)
ResponseJSON(w, ROAJSONResponse)
}
// return the roa in bird format
func roaBirdHandler(w http.ResponseWriter, r *http.Request) {
vars := mux.Vars(r)
birdv := vars["birdv"]
ipv := vars["ipv"]
// bird 1 or bird 2 format
birdf := "roa %s max %d as %s;\n"
if birdv == "2" {
birdf = "route %s max %d as %s;\n"
}
var roa []*PrefixROA
if strings.ContainsRune(ipv, '4') {
roa = append(roa, ROAData.IPv4...)
}
if strings.ContainsRune(ipv, '6') {
roa = append(roa, ROAData.IPv6...)
}
w.Header().Set("Content-Type", "text/plain")
w.Header().Set("Access-Control-Allow-Origin", "*")
// cache for up to a week, but set etag to commit to catch changes
w.Header().Set("Cache-Control", "public, max-age=604800, stale-if-error=86400")
w.Header().Set("ETag", ROAData.Commit)
fmt.Fprintf(w, "#\n# dn42regsrv ROA Generator\n# Last Updated: %s\n"+
"# Commit: %s\n#\n", ROAData.CTime.String(), ROAData.Commit)
for _, r := range roa {
fmt.Fprintf(w, birdf, r.Prefix, r.MaxLen, r.ASN[2:])
}
}
//////////////////////////////////////////////////////////////////////////
// called whenever the registry is updated
func ROAUpdate(params ...interface{}) {
registry := params[0].(*Registry)
path := params[1].(string)
// initiate new ROA data
roa := &ROA{
CTime: time.Now(),
Commit: registry.Commit,
}
// load filter{,6}.txt files
if roa.loadFilter(path+"/filter.txt", 4) != nil {
// error loading IPv4 filter, don't update
return
}
if roa.loadFilter(path+"/filter6.txt", 6) != nil {
// error loading IPv6 filter, don't update
return
}
// compile ROA prefixes
roa.IPv4 = roa.CompileROA(registry, "route")
roa.IPv6 = roa.CompileROA(registry, "route6")
// swap in the new data
ROAData = roa
log.WithFields(log.Fields{
"ipv4": len(roa.IPv4),
"ipv6": len(roa.IPv6),
}).Debug("ROA data updated")
// pre-compute the JSON return struct
utime := uint32(roa.CTime.Unix())
response := &ROAJSON{
MetaData: ROAMetaData{
Generated: utime,
Valid: utime + (ROA_JSON_VALIDITY_PERIOD * 3600),
},
}
response.Roas = append(roa.IPv4, roa.IPv6...)
response.MetaData.Counts = uint(len(response.Roas))
ROAJSONResponse = response
}
//////////////////////////////////////////////////////////////////////////
// load network filter definitions from a filter file
func (roa *ROA) loadFilter(path string, iptype uint8) error {
// open the file for reading
file, err := os.Open(path)
if err != nil {
log.WithFields(log.Fields{
"path": path,
"error": err,
}).Error("Unable to open filter file")
return err
}
defer file.Close()
// helper closure to convert strings to numbers
var cerr error
convert := func(s string) int {
if cerr != nil {
return 0
}
val, cerr := strconv.Atoi(s)
if cerr != nil {
log.WithFields(log.Fields{
"number": s,
"error": err,
}).Error("Unable to parse number in filter file")
return 0
}
return val
}
filters := make([]*ROAFilter, 0)
// read the file line by line
scanner := bufio.NewScanner(file)
for scanner.Scan() {
line := strings.TrimSpace(scanner.Text())
// remove any comments
if ix := strings.IndexRune(line, '#'); ix != -1 {
line = line[:ix]
}
fields := strings.Fields(line)
if len(fields) >= 5 {
// parse the prefix in to a NetIP structure
prefix := fields[2]
_, network, err := net.ParseCIDR(prefix)
if err != nil {
log.WithFields(log.Fields{
"path": path,
"prefix": prefix,
"error": err,
}).Error("Unable to parse CIDR in filter file")
} else {
// construct the filter object
roaf := &ROAFilter{
Number: uint(convert(fields[0])),
Action: fields[1],
Prefix: prefix,
MinLen: uint8(convert(fields[3])),
MaxLen: uint8(convert(fields[4])),
Network: network,
IPType: iptype,
}
// add to list if no strconv error
if cerr == nil {
filters = append(filters, roaf)
}
}
}
}
// did something go wrong ?
if err := scanner.Err(); err != nil {
log.WithFields(log.Fields{
"path": path,
"error": err,
}).Error("Scanner error reading filter file")
return err
}
// filter.txt should be in order,
// but still sort by number just in case
sort.Slice(filters, func(i, j int) bool {
return filters[i].Number < filters[j].Number
})
// add to the roa object
roa.Filters = append(roa.Filters, filters...)
return nil
}
//////////////////////////////////////////////////////////////////////////
// return the filter object that matches an IP address
func (roa *ROA) MatchFilter(ip net.IP) *ROAFilter {
for _, filter := range roa.Filters {
if filter.Network.Contains(ip) {
return filter
}
}
log.WithFields(log.Fields{
"IP": ip,
}).Error("Couldn't match address to filter !")
return nil
}
//////////////////////////////////////////////////////////////////////////
// compile ROA data
func (roa *ROA) CompileROA(registry *Registry,
tname string) []*PrefixROA {
// prepare indices to the route object keys
stype := registry.Schema[tname]
routeIX := stype.KeyIndex[tname]
originIX := stype.KeyIndex["origin"]
mlenIX := stype.KeyIndex["max-length"]
roalist := make([]*PrefixROA, 0, len(routeIX.Objects))
// for each object that has a route key
for object, rattribs := range routeIX.Objects {
if len(rattribs) > 1 {
log.WithFields(log.Fields{
"object": object.Ref,
}).Warn("Found object with multiple route attributes")
}
// extract the prefix
prefix := rattribs[0].RawValue
prefIP, prefNet, err := net.ParseCIDR(prefix)
if err != nil {
log.WithFields(log.Fields{
"object": object.Ref,
"prefix": prefix,
"error": err,
}).Error("Unable to parse CIDR in ROA")
continue
}
// check for CIDR errors
if !prefIP.Equal(prefNet.IP) {
log.WithFields(log.Fields{
"prefix": prefix,
}).Warn("Denied ROA: invalid CIDR")
continue
}
// match the prefix to the prefix filters
filter := roa.MatchFilter(prefNet.IP)
if filter == nil {
continue
}
// don't allow routes that are denied in the filter rules
if filter.Action == "deny" {
log.WithFields(log.Fields{
"object": object.Ref,
"prefix": prefix,
"filter": filter.Prefix,
}).Warn("Denied ROA: through filter rule")
continue
}
mlen := filter.MaxLen
prefLen, _ := prefNet.Mask.Size()
// calculate the max-length for this object
// check if the attribute has max-length defined
mattrib := mlenIX.Objects[object]
if mattrib != nil {
// use the local max-length value
tmp, err := strconv.ParseUint(mattrib[0].RawValue, 10, 8)
if err != nil {
log.WithFields(log.Fields{
"object": object.Ref,
"max-length": mattrib[0].RawValue,
"error": err,
}).Warn("Unable to convert max-length attribute")
} else {
// filter rules still have precedence over local values
if (uint8(tmp) < mlen) && (uint8(tmp) > filter.MinLen) {
mlen = uint8(tmp)
}
}
}
// if the prefix is greater than the max length
// then don't emit an ROA route (making the route invalid)
if prefLen > int(mlen) {
log.WithFields(log.Fields{
"object": object.Ref,
"prefix": prefix,
"maxlen": mlen,
}).Warn("Denied ROA: Prefix > filter MaxLen")
continue
}
// look up the origin key for this object
oattribs := originIX.Objects[object]
if oattribs == nil {
log.WithFields(log.Fields{
"object": object.Ref,
}).Warn("Route Object without Origin")
} else {
// then for origin that can announce this prefix
for _, oattrib := range oattribs {
// add the ROA
roalist = append(roalist, &PrefixROA{
Prefix: prefNet.String(),
MaxLen: mlen,
ASN: oattrib.RawValue,
})
}
}
}
return roalist
}
//////////////////////////////////////////////////////////////////////////
// end of code