diff --git a/ext/dnsbl/dnsbl.go b/ext/dnsbl/dnsbl.go
new file mode 100644
index 0000000000000000000000000000000000000000..eed1904e05e3cb7a324dfb997a8ff4a5e97ccfb5
--- /dev/null
+++ b/ext/dnsbl/dnsbl.go
@@ -0,0 +1,61 @@
+package dnsbl
+
+import (
+	"fmt"
+	"net"
+	"regexp"
+	"strings"
+
+	"github.com/d5/tengo/objects"
+)
+
+// The DNSBL external source looks up IP addresses in a DNS-based
+// blacklist, and expects the result to match a specific pattern.
+type DNSBL struct {
+	domain  string
+	matchRx *regexp.Regexp
+}
+
+func New(domain, match string) (*DNSBL, error) {
+	if match == "" {
+		match = `^127\.0\.0\.[0-9]*$`
+	}
+	rx, err := regexp.Compile(match)
+	if err != nil {
+		return nil, err
+	}
+	return &DNSBL{
+		domain:  domain,
+		matchRx: rx,
+	}, nil
+}
+
+func reverseIP(ip net.IP) string {
+	if ip.To4() != nil {
+		parts := strings.Split(ip.String(), ".")
+		rev := make([]string, len(parts))
+		for i := 0; i < len(parts); i++ {
+			rev[len(parts)-i-1] = parts[i]
+		}
+		return strings.Join(rev, ".")
+	}
+	return ""
+}
+
+// LookupIP implements the ExternalSource interface. We'd like to
+// return a boolean but can't figure out how to build one with Tengo,
+// so we return a 0/1 int result instead.
+func (d *DNSBL) LookupIP(ip string) (objects.Object, error) {
+	rev := reverseIP(net.ParseIP(ip))
+	if rev == "" {
+		return objects.UndefinedValue, nil
+	}
+	query := fmt.Sprintf("%s.%s", rev, d.domain)
+	ips, err := net.LookupIP(query)
+
+	var retval int64 = 0
+	if err == nil && len(ips) > 0 && d.matchRx.MatchString(ips[0].String()) {
+		retval = 1
+	}
+	return &objects.Int{Value: retval}, nil
+}
diff --git a/ext/ext.go b/ext/ext.go
new file mode 100644
index 0000000000000000000000000000000000000000..69c7a52be49b0064ad5ab78dd9b95031cca525d6
--- /dev/null
+++ b/ext/ext.go
@@ -0,0 +1,11 @@
+package ext
+
+import "github.com/d5/tengo/objects"
+
+// An ExternalSource provides per-IP information from third-party
+// sources. The lookup can return any Tengo object, we don't want to
+// force a specific return type yet (int or string can both be
+// useful, we'll see).
+type ExternalSource interface {
+	LookupIP(string) (objects.Object, error)
+}
diff --git a/ext/geoip/geoip.go b/ext/geoip/geoip.go
new file mode 100644
index 0000000000000000000000000000000000000000..9eac8a24fd8fe3fedd27812155a9c854c637f30c
--- /dev/null
+++ b/ext/geoip/geoip.go
@@ -0,0 +1,47 @@
+package geoip
+
+import (
+	"net"
+
+	"github.com/d5/tengo/objects"
+	"github.com/oschwald/maxminddb-golang"
+)
+
+var defaultGeoIPPaths = []string{
+	"/var/lib/GeoIP/GeoLite2-Country.mmdb",
+}
+
+type GeoIP struct {
+	readers []*maxminddb.Reader
+}
+
+func NewGeoIP(paths []string) (*GeoIP, error) {
+	if len(paths) == 0 {
+		paths = defaultGeoIPPaths
+	}
+
+	g := new(GeoIP)
+	for _, path := range paths {
+		r, err := maxminddb.Open(path)
+		if err != nil {
+			return nil, err
+		}
+		g.readers = append(g.readers, r)
+	}
+	return g, nil
+}
+
+func (g *GeoIP) LookupIP(ipStr string) (objects.Object, error) {
+	ip := net.ParseIP(ipStr)
+	var record struct {
+		Country struct {
+			ISOCode string `maxminddb:"iso_code"`
+		} `maxminddb:"country"`
+	}
+	for _, r := range g.readers {
+		if err := r.Lookup(ip, &record); err == nil {
+			return &objects.String{Value: record.Country.ISOCode}, nil
+		}
+	}
+	return objects.UndefinedValue, nil
+}