Skip to content

Commit

Permalink
Implement standard client Store interface
Browse files Browse the repository at this point in the history
Define and use a standard Store interface for the client library.  This
will allow us to extend the client to greater uses later.

As an example, a RAM-based caching store has been implemented.

Fixes palner#11

Signed-off-by: Seán C McCord <[email protected]>
  • Loading branch information
Ulexus committed Mar 28, 2020
1 parent 0ab04b0 commit 0cb4bae
Show file tree
Hide file tree
Showing 3 changed files with 499 additions and 166 deletions.
190 changes: 24 additions & 166 deletions clients/go/apiban/apiban.go
Original file line number Diff line number Diff line change
Expand Up @@ -22,183 +22,41 @@
package apiban

import (
"bytes"
"encoding/json"
"errors"
"fmt"
"io"
"net/http"
"net"
"time"
)

const (
// RootURL is the base URI of the APIBAN.org API server
RootURL = "https://apiban.org/api/"
)

// ErrBadRequest indicates a 400 response was received;
//
// NOTE: this is used by the server to indicate both that an IP address is not
// blocked (when calling Check) and that the list is complete (when calling
// Banned)
var ErrBadRequest = errors.New("Bad Request")

// Entry describes a set of blocked IP addresses from APIBAN.org
type Entry struct {

// ID is the timestamp of the next Entry
ID string `json:"ID"`

// IPs is the list of blocked IP addresses in this entry
IPs []string `json:"ipaddress"`
}

// Banned returns a set of banned addresses, optionally limited to the
// specified startFrom ID. If no startFrom is supplied, the entire current list will
// be pulled.
func Banned(key string, startFrom string) (*Entry, error) {
if key == "" {
return nil, errors.New("API Key is required")
}

if startFrom == "" {
startFrom = "100" // NOTE: arbitrary ID copied from reference source
}

out := &Entry{
ID: startFrom,
}

for {
e, err := queryServer(http.DefaultClient, fmt.Sprintf("%s%s/banned/%s", RootURL, key, out.ID))
if err != nil {
return nil, err
}

if e.ID == "none" {
// List complete
return out, nil
}
if e.ID == "" {
return nil, errors.New("empty ID received")
}
// Store defines and interface for storing and retrieving entries in the APIBan database, local or remote
type Store interface {

// Set the next ID
out.ID = e.ID
// Add inserts the given IP into the store
Add(ip *IP) error

// Aggregate the received IPs
out.IPs = append(out.IPs, e.IPs...)
}
}

// Check queries APIBAN.org to see if the provided IP address is blocked.
func Check(key string, ip string) (bool, error) {
if key == "" {
return false, errors.New("API Key is required")
}
if ip == "" {
return false, errors.New("IP address is required")
}

entry, err := queryServer(http.DefaultClient, fmt.Sprintf("%s%s/check/%s", RootURL, key, ip))
if err == ErrBadRequest {
// Not blocked
return false, nil
}
if err != nil {
return false, err
}
if entry == nil {
return false, errors.New("empty entry received")
}
// Exists checks to see whether the given IP is in the store
Exists(ip net.IP) (bool, error)

// IP address is blocked
return true, nil
}

func queryServer(c *http.Client, u string) (*Entry, error) {
resp, err := http.Get(u)
if err != nil {
return nil, err
}
defer resp.Body.Close()
// List retrieves the contents of the store
List() ([]*IP, error)

// StatusBadRequest (400) has a number of special cases to handle
if resp.StatusCode == http.StatusBadRequest {
return processBadRequest(resp)
}
if resp.StatusCode > 400 && resp.StatusCode < 500 {
return nil, fmt.Errorf("client error (%d) from apiban.org: %s from %q", resp.StatusCode, resp.Status, u)
}
if resp.StatusCode >= 500 {
return nil, fmt.Errorf("server error (%d) from apiban.org: %s from %q", resp.StatusCode, resp.Status, u)
}
if resp.StatusCode > 299 {
return nil, fmt.Errorf("unhandled error (%d) from apiban.org: %s from %q", resp.StatusCode, resp.Status, u)
}
// ListFromTime retrieves the contents of the store from the given timestamp
ListFromTime(t time.Time) ([]*IP, error)

entry := new(Entry)
if err = json.NewDecoder(resp.Body).Decode(entry); err != nil {
return nil, fmt.Errorf("failed to decode server response: %w", err)
}
// Remove deletes the given IP from the store
Remove(ip *IP) error

return entry, nil
// Reset empties the store
Reset() error
}

func processBadRequest(resp *http.Response) (*Entry, error) {
var buf bytes.Buffer
if _, err := buf.ReadFrom(resp.Body); err != nil {
return nil, fmt.Errorf("failed to read response body: %w", err)
}

// Read the bytes buffer into a new bytes.Reader
r := bytes.NewReader(buf.Bytes())

// First, try decoding as a normal entry
e := new(Entry)
if err := json.NewDecoder(r).Decode(e); err == nil {
// Successfully decoded normal entry

switch e.ID {
case "none":
// non-error case
case "unauthorized":
return nil, errors.New("unauthorized")
default:
// unhandled case
return nil, ErrBadRequest
}

if len(e.IPs) > 0 {
switch e.IPs[0] {
case "no new bans":
return e, nil
}
}

// Unhandled case
return nil, ErrBadRequest
}

// Next, try decoding as an errorEntry
if _, err := r.Seek(0, io.SeekStart); err != nil {
return nil, fmt.Errorf("failed to re-seek to beginning of response buffer: %w", err)
}
// IP is an individually-listed IP address or subnet
type IP struct {

type errorEntry struct {
AddressCode string `json:"ipaddress"`
IDCode string `json:"ID"`
}
// ID is the unique identifier for this IP
ID string

ee := new(errorEntry)
if err := json.NewDecoder(r).Decode(ee); err != nil {
return nil, fmt.Errorf("failed to decode Bad Request response: %w", err)
}
// Timestamp is the time at which this IP address was added to the apiban.org database
Timestamp time.Time

switch ee.AddressCode {
case "rate limit exceeded":
return nil, errors.New("rate limit exceeded")
default:
// unhandled case
return nil, ErrBadRequest
}
// IP is the IP address or IP network which is in the apiban.org database
IP net.IPNet
}
Loading

0 comments on commit 0cb4bae

Please sign in to comment.