diff --git a/.github/workflows/publish-container.yaml b/.github/workflows/publish-container.yaml new file mode 100644 index 0000000..7513dea --- /dev/null +++ b/.github/workflows/publish-container.yaml @@ -0,0 +1,57 @@ +name: publish-container + +on: + pull_request: + branches: + - main + push: + branches: + - main + tags: + - "v*.*.*" + +jobs: + docker: + runs-on: ubuntu-latest + permissions: + contents: read + packages: write + steps: + - name: Checkout + uses: actions/checkout@8ade135a41bc03ea155e62e844d188df1ea18608 # v4.1.0 + with: + ref: ${{ github.event.pull_request.head.sha }} + - name: Docker meta + id: meta + uses: docker/metadata-action@96383f45573cb7f253c731d3b3ab81c87ef81934 # v5.0.0 + with: + images: | + ghcr.io/tkhq/traefik-plugin-geoblock + tags: | + type=raw,value=latest,enable={{is_default_branch}} + type=ref,event=pr + type=semver,pattern={{version}} + type=sha,format=long + - name: Get committer date + run: | + echo "committer_date=$(git log -1 --pretty=%ct)" >> "$GITHUB_ENV" + - name: Set up Docker Buildx + uses: docker/setup-buildx-action@f95db51fddba0c2d1ec667646a06c2ce06100226 # v3.0.0 + - name: Login to GHCR + if: github.event_name != 'pull_request' + uses: docker/login-action@343f7c4344506bcbf9b4de18042ae17996df046d # v3.0.0 + with: + registry: ghcr.io + username: ${{ github.actor }} + password: ${{ secrets.GITHUB_TOKEN }} + - name: Build and push + uses: docker/build-push-action@0565240e2d4ab88bba5387d719585280857ece09 # v5.0.0 + with: + context: . + platforms: linux/amd64 + file: deploy/Dockerfile + build-args: | + SOURCE_DATE_EPOCH=${{ env.committer_date }} + provenance: "false" + push: ${{ github.event_name != 'pull_request' }} + tags: ${{ steps.meta.outputs.tags }} diff --git a/.traefik.yml b/.traefik.yml index 38a6fdf..dcd849d 100644 --- a/.traefik.yml +++ b/.traefik.yml @@ -1,7 +1,7 @@ displayName: geoblock type: middleware import: github.com/nscuro/traefik-plugin-geoblock -summary: traefik plugin to whitelist requests based on geolocation +summary: traefik plugin to block or allow requests based on geolocation testData: # It doesn't appear to be possible to get the pilot plugin analyzer # to load local files. To prevent errors, the plugin is disabled here. @@ -9,6 +9,9 @@ testData: enabled: false # databaseFilePath: IP2LOCATION-LITE-DB1.IPV6.BIN # allowedCountries: [ "CH", "DE" ] + # blockedCountries: [ "RU" ] + # defaultAllow: false # allowPrivate: true # disallowedStatusCode: 403 - # allowedIPBlocks: ["66.249.64.0/19"] \ No newline at end of file + # allowedIPBlocks: ["66.249.64.0/19"] + # blockedIPBlocks: ["66.249.64.0/24"] diff --git a/README.md b/README.md index a623de6..91f5ca7 100644 --- a/README.md +++ b/README.md @@ -5,7 +5,7 @@ [![Latest GitHub release](https://img.shields.io/github/v/release/nscuro/traefik-plugin-geoblock?sort=semver)](https://github.com/nscuro/traefik-plugin-geoblock/releases/latest) [![License](https://img.shields.io/badge/license-Apache%202.0-brightgreen.svg)](LICENSE) -*traefik-plugin-geoblock is a traefik plugin to whitelist requests based on geolocation* +*traefik-plugin-geoblock is a traefik plugin to allow or block requests based on geolocation* > This projects includes IP2Location LITE data available from [`lite.ip2location.com`](https://lite.ip2location.com/database/ip-country). @@ -49,10 +49,16 @@ http: databaseFilePath: /plugins-local/src/github.com/nscuro/traefik-plugin-geoblock/IP2LOCATION-LITE-DB1.IPV6.BIN # Whitelist of countries to allow (ISO 3166-1 alpha-2) allowedCountries: [ "AT", "CH", "DE" ] + # Blocklist of countries to block (ISO 3166-1 alpha-2) + blockedCountries: [ "RU" ] + # Default allow indicates that if an IP is in neither block list nor allow lists, it should be allowed. + defaultAllow: false # Allow requests from private / internal networks? allowPrivate: true # HTTP status code to return for disallowed requests (default: 403) disallowedStatusCode: 204 # Add CIDR to be whitelisted, even if in a non-allowed country allowedIPBlocks: ["66.249.64.0/19"] -``` \ No newline at end of file + # Add CIDR to be blacklisted, even if in an allowed country or IP block + blockedIPBlocks: ["66.249.64.5/32"] +``` diff --git a/deploy/Dockerfile b/deploy/Dockerfile new file mode 100644 index 0000000..aeccd5c --- /dev/null +++ b/deploy/Dockerfile @@ -0,0 +1,17 @@ +FROM busybox@sha256:023917ec6a886d0e8e15f28fb543515a5fcd8d938edb091e8147db4efed388ee + +LABEL org.opencontainers.image.source https://github.com/tkhq/traefik-plugin-geoblock + +RUN mkdir /dist + +COPY go.mod /dist/ +COPY go.sum /dist/ +COPY .traefik.yml /dist/ +COPY LICENSE /dist/ +COPY plugin.go /dist/ +COPY plugin.go /dist/ +COPY vendor /dist/ + +COPY deploy/install.sh / + +ENTRYPOINT ["/install.sh"] diff --git a/deploy/install.sh b/deploy/install.sh new file mode 100755 index 0000000..30922b8 --- /dev/null +++ b/deploy/install.sh @@ -0,0 +1,7 @@ +#!/bin/sh +set -e + +# Copy this plugin to where Traefik is expecting it +mkdir -p /plugins-local/src/github.com/nscuro/traefik-plugin-geoblock +cp -R /dist/* /plugins-local/src/github.com/nscuro/traefik-plugin-geoblock/ +cp /dist/.traefik.yml /plugins-local/src/github.com/nscuro/traefik-plugin-geoblock/ diff --git a/plugin.go b/plugin.go index d62d80d..0ece473 100644 --- a/plugin.go +++ b/plugin.go @@ -20,9 +20,12 @@ type Config struct { Enabled bool // Enable this plugin? DatabaseFilePath string // Path to ip2location database file AllowedCountries []string // Whitelist of countries to allow (ISO 3166-1 alpha-2) + BlockedCountries []string // Blocklist of countries to be blocked (ISO 3166-1 alpha-2) + DefaultAllow bool // If source matches neither blocklist nor whitelist, should it be allowed through? AllowPrivate bool // Allow requests from private / internal networks? DisallowedStatusCode int // HTTP status code to return for disallowed requests AllowedIPBlocks []string // List of whitelist CIDR + BlockedIPBlocks []string // List of blocklisted CIDRs } // CreateConfig creates the default plugin configuration. @@ -36,9 +39,12 @@ type Plugin struct { db *ip2location.DB enabled bool allowedCountries []string + blockedCountries []string + defaultAllow bool allowPrivate bool disallowedStatusCode int allowedIPBlocks []*net.IPNet + blockedIPBlocks []*net.IPNet } // New creates a new plugin instance. @@ -46,6 +52,7 @@ func New(_ context.Context, next http.Handler, cfg *Config, name string) (http.H if next == nil { return nil, fmt.Errorf("%s: no next handler provided", name) } + if cfg == nil { return nil, fmt.Errorf("%s: no config provided", name) } @@ -73,7 +80,12 @@ func New(_ context.Context, next http.Handler, cfg *Config, name string) (http.H return nil, fmt.Errorf("%s: failed to open database: %w", name, err) } - allowedIPBlocks, err := initAllowedIPBlocks(cfg.AllowedIPBlocks) + allowedIPBlocks, err := initIPBlocks(cfg.AllowedIPBlocks) + if err != nil { + return nil, fmt.Errorf("%s: failed loading allowed CIDR blocks: %w", name, err) + } + + blockedIPBlocks, err := initIPBlocks(cfg.BlockedIPBlocks) if err != nil { return nil, fmt.Errorf("%s: failed loading allowed CIDR blocks: %w", name, err) } @@ -84,9 +96,12 @@ func New(_ context.Context, next http.Handler, cfg *Config, name string) (http.H db: db, enabled: cfg.Enabled, allowedCountries: cfg.AllowedCountries, + blockedCountries: cfg.BlockedCountries, + defaultAllow: cfg.DefaultAllow, allowPrivate: cfg.AllowPrivate, disallowedStatusCode: cfg.DisallowedStatusCode, allowedIPBlocks: allowedIPBlocks, + blockedIPBlocks: blockedIPBlocks, }, nil } @@ -146,37 +161,92 @@ func (p Plugin) GetRemoteIPs(req *http.Request) []string { } // CheckAllowed checks whether a given IP address is allowed according to the configured allowed countries. -func (p Plugin) CheckAllowed(ip string) (bool, string, error) { - country, err := p.Lookup(ip) +func (p Plugin) CheckAllowed(ip string) (allow bool, country string, err error) { + var allowedCountry, allowedIP, blockedCountry, blockedIP bool + var allowedNetworkLength, blockedNetworkLength int + + country, err = p.Lookup(ip) if err != nil { - return false, "", fmt.Errorf("lookup of %s failed: %w", ip, err) + return false, ip, fmt.Errorf("lookup of %s failed: %w", ip, err) } - if country == "-" { // Private address - if p.allowPrivate { - return true, ip, nil + if country == "-" { + return p.allowPrivate, country, nil + } + + if country != "-" { + for _, item := range p.blockedCountries { + if item == country { + blockedCountry = true + + break + } } - return false, ip, nil + for _, item := range p.allowedCountries { + if item == country { + allowedCountry = true + } + } + } + + blocked, blockedNetworkLength, err := p.isBlockedIPBlocks(ip) + if err != nil { + return false, ip, fmt.Errorf("failed to check if IP %q is blocked by IP block: %w", ip, err) + } + + if blocked { + blockedIP = true } - var allowed bool for _, allowedCountry := range p.allowedCountries { if allowedCountry == country { - return true, country, nil + return true, ip, nil } } - allowed, err = p.isAllowedIPBlocks(ip) + allowed, allowedNetBits, err := p.isAllowedIPBlocks(ip) if err != nil { - return false, "", fmt.Errorf("checking if %s is part of an allowed range failed: %w", ip, err) + return false, ip, fmt.Errorf("failed to check if IP %q is allowed by IP block: %w", ip, err) } - if !allowed { + if allowed { + allowedIP = true + allowedNetworkLength = allowedNetBits + } + + // Handle final values + // + // NB: discrete IPs have higher priority than countries: more specific to less specific. + + // NB: whichever matched prefix is longer has higher priority: more specific to less specific. + if allowedNetworkLength < blockedNetworkLength { + if blockedIP { + return false, country, nil + } + + if allowedIP { + return true, country, nil + } + } else { + if allowedIP { + return true, country, nil + } + + if blockedIP { + return false, country, nil + } + } + + if allowedCountry { + return true, country, nil + } + + if blockedCountry { return false, country, nil } - return true, country, nil + return p.defaultAllow, country, nil } // Lookup queries the ip2location database for a given IP address. @@ -195,34 +265,46 @@ func (p Plugin) Lookup(ip string) (string, error) { } // Create IP Networks using CIDR block array -func initAllowedIPBlocks(allowedIPBlocks []string) ([]*net.IPNet, error) { +func initIPBlocks(ipBlocks []string) ([]*net.IPNet, error) { - var allowedIPBlocksNet []*net.IPNet + var ipBlocksNet []*net.IPNet - for _, cidr := range allowedIPBlocks { + for _, cidr := range ipBlocks { _, block, err := net.ParseCIDR(cidr) if err != nil { return nil, fmt.Errorf("parse error on %q: %v", cidr, err) } - allowedIPBlocksNet = append(allowedIPBlocksNet, block) + ipBlocksNet = append(ipBlocksNet, block) } - return allowedIPBlocksNet, nil + return ipBlocksNet, nil } -// isAllowedIPBlocks check if an IP is allowed base on the allowed CIDR blocks -func (p Plugin) isAllowedIPBlocks(ip string) (bool, error) { - var ipAddress net.IP = net.ParseIP(ip) +// isAllowedIPBlocks checks if an IP is allowed base on the allowed CIDR blocks +func (p Plugin) isAllowedIPBlocks(ip string) (bool, int, error) { + return p.isInIPBlocks(ip, p.allowedIPBlocks) +} + +// isBlockedIPBlocks checks if an IP is allowed base on the blocked CIDR blocks +func (p Plugin) isBlockedIPBlocks(ip string) (bool, int, error) { + return p.isInIPBlocks(ip, p.blockedIPBlocks) +} + +// isInIPBlocks indicates whether the given IP exists in any of the IP subnets contained within ipBlocks. +func (p Plugin) isInIPBlocks(ip string, ipBlocks []*net.IPNet) (bool, int, error) { + ipAddress := net.ParseIP(ip) if ipAddress == nil { - return false, fmt.Errorf("unable parse IP address from address [%s]", ip) + return false, 0, fmt.Errorf("unable parse IP address from address [%s]", ip) } - for _, block := range p.allowedIPBlocks { + for _, block := range ipBlocks { if block.Contains(ipAddress) { - return true, nil + ones, _ := block.Mask.Size() + + return true, ones, nil } } - return false, nil + return false, 0, nil } diff --git a/plugin_test.go b/plugin_test.go index 50eb635..f9359d0 100644 --- a/plugin_test.go +++ b/plugin_test.go @@ -45,7 +45,7 @@ func TestNew(t *testing.T) { } }) - t.Run("NoConfig", func(t *testing.T) { + t.Run("Nogeoblock.Config", func(t *testing.T) { plugin, err := New(context.TODO(), &noopHandler{}, nil, pluginName) if err == nil { t.Errorf("expected error, but got none") @@ -174,6 +174,54 @@ func TestPlugin_ServeHTTP(t *testing.T) { t.Errorf("expected status code %d, but got: %d", http.StatusForbidden, rr.Code) } }) + + t.Run("Blocklist", func(t *testing.T) { + cfg := &Config{ + Enabled: true, + DatabaseFilePath: dbFilePath, + BlockedCountries: []string{"US"}, + AllowPrivate: false, + DefaultAllow: true, + DisallowedStatusCode: http.StatusForbidden, + } + + testRequest(t, "US IP blocked", cfg, "8.8.8.8", http.StatusForbidden) + testRequest(t, "DE IP allowed", cfg, "185.5.82.105", 0) + + cfg.BlockedCountries = nil + cfg.BlockedIPBlocks = []string{"8.8.8.0/24"} + + testRequest(t, "Google DNS-A blocked", cfg, "8.8.8.8", http.StatusForbidden) + testRequest(t, "Google DNS-B allowed", cfg, "8.8.4.4", 0) + + cfg.AllowedIPBlocks = []string{"8.8.8.7/32"} + + testRequest(t, "Higher specificity IP CIDR allow trumps lower specificity IP CIDR block", cfg, "8.8.8.7", 0) + testRequest(t, "Higher specificity IP CIDR allow should not override encompassing CIDR block", cfg, "8.8.8.9", http.StatusForbidden) + + cfg.DefaultAllow = false + + testRequest(t, "Default allow false", cfg, "8.8.4.4", http.StatusForbidden) + }) +} + +func testRequest(t *testing.T, testName string, cfg *Config, ip string, expectedStatus int) { + t.Run(testName, func(t *testing.T) { + plugin, err := New(context.TODO(), &noopHandler{}, cfg, pluginName) + if err != nil { + t.Errorf("expected no error, but got: %v", err) + } + + req := httptest.NewRequest(http.MethodGet, "/foobar", nil) + req.Header.Set("X-Real-IP", ip) + + rr := httptest.NewRecorder() + plugin.ServeHTTP(rr, req) + + if expectedStatus > 0 && rr.Code != expectedStatus { + t.Errorf("expected status code %d, but got: %d", expectedStatus, rr.Code) + } + }) } func TestPlugin_Lookup(t *testing.T) {