Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
13 changes: 13 additions & 0 deletions Corefile
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,19 @@ libp2p.direct {
errors
any # RFC 8482
prometheus localhost:9253
denylist {
# Spamhaus DROP: hijacked IP ranges used for spam and malware
# Spamhaus recommends once per day; 12h is a reasonable compromise
feed https://www.spamhaus.org/drop/drop.txt format=ip refresh=12h name=spamhaus-drop
feed https://www.spamhaus.org/drop/dropv6.txt format=ip refresh=12h name=spamhaus-dropv6
# URLhaus: malware distribution URLs (IPs extracted)
# URLhaus updates every 5 minutes; use their stated minimum
feed https://urlhaus.abuse.ch/downloads/text/ format=url refresh=5m name=urlhaus
# Local allowlist: bypasses all denylists (own infrastructure, feed false positives)
# file ip-allowlist.txt type=allow
# Local denylist: quick blocks without waiting for feed updates
# file ip-denylist.txt
}
ipparser libp2p.direct
file zones/libp2p.direct
acme libp2p.direct {
Expand Down
13 changes: 13 additions & 0 deletions Corefile.local-dev
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,19 @@ libp2p.direct {
errors
any # RFC 8482
prometheus localhost:9253
denylist {
# Spamhaus DROP: hijacked IP ranges used for spam and malware
# Spamhaus recommends once per day; 12h is a reasonable compromise
feed https://www.spamhaus.org/drop/drop.txt format=ip refresh=12h name=spamhaus-drop
feed https://www.spamhaus.org/drop/dropv6.txt format=ip refresh=12h name=spamhaus-dropv6
# URLhaus: malware distribution URLs (IPs extracted)
# URLhaus updates every 5 minutes; use their stated minimum
feed https://urlhaus.abuse.ch/downloads/text/ format=url refresh=5m name=urlhaus
# Local allowlist: bypasses all denylists (own infrastructure, feed false positives)
# file ip-allowlist.txt type=allow
# Local denylist: quick blocks without waiting for feed updates
# file ip-denylist.txt
}
ipparser libp2p.direct
file zones/libp2p.direct
acme libp2p.direct {
Expand Down
2 changes: 1 addition & 1 deletion Dockerfile
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
FROM --platform=${BUILDPLATFORM:-linux/amd64} golang:1.23-bookworm AS builder
FROM --platform=${BUILDPLATFORM:-linux/amd64} golang:1.24-bookworm AS builder

LABEL org.opencontainers.image.source=https://github.com/ipshipyard/p2p-forge
LABEL org.opencontainers.image.documentation=https://github.com/ipshipyard/p2p-forge#docker
Expand Down
8 changes: 8 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -171,6 +171,14 @@ acme FORGE_DOMAIN {
- `dynamo TABLE_NAME` for production-grade key-value store shared across multiple instances (where all credentials are set via AWS' standard environment variables: `AWS_REGION`, `AWS_ACCESS_KEY_ID`, `AWS_SECRET_ACCESS_KEY`)
- `badger DB_PATH` for local key-value store (good for local development and testing)

#### Denylists

Optional plugin for blocking IPs from requesting certificates and resolving `A`/`AAAA` records. See [docs/denylist.md](docs/denylist.md) for configuration details.

#### Metrics

Prometheus metrics are exposed via the standard CoreDNS metrics plugin. See [docs/METRICS.md](docs/METRICS.md) for p2p-forge specific metrics.

### Example

Below is a basic example of starting a DNS server that handles the IP based domain names as well as ACME challenges.
Expand Down
70 changes: 70 additions & 0 deletions acme/clientip.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,70 @@
package acme

import (
"net"
"net/http"
"net/netip"
"strings"

"github.com/multiformats/go-multiaddr"
)

// clientIPs extracts client IPs from request: both X-Forwarded-For and RemoteAddr.
// Returns all valid IPs found (may be 0, 1, or 2 IPs).
//
// X-Forwarded-For spoofing is not a security concern here because:
// 1. We also check all IPs from the multiaddrs in the request body
// 2. The actual A/AAAA record being requested must match a multiaddr IP
// 3. An attacker cannot spoof the multiaddr IPs they're connecting from
//
// The client IP check is defense-in-depth; the multiaddr check is authoritative.
func clientIPs(r *http.Request) []netip.Addr {
var ips []netip.Addr

// Check X-Forwarded-For (leftmost = original client)
if xff := r.Header.Get("X-Forwarded-For"); xff != "" {
if comma := strings.Index(xff, ","); comma != -1 {
xff = xff[:comma]
}
xff = strings.TrimSpace(xff)
if ip, err := netip.ParseAddr(xff); err == nil {
ips = append(ips, ip)
}
}

// Also check RemoteAddr (direct connection IP)
host, _, err := net.SplitHostPort(r.RemoteAddr)
if err != nil {
host = r.RemoteAddr
}
if ip, err := netip.ParseAddr(host); err == nil {
ips = append(ips, ip)
}

return ips
}

// multiaddrsToIPs extracts IP addresses from multiaddr strings.
func multiaddrsToIPs(addrs []string) []netip.Addr {
ips := make([]netip.Addr, 0, len(addrs))
for _, addr := range addrs {
ma, err := multiaddr.NewMultiaddr(addr)
if err != nil {
continue
}
// Try IPv4
if val, err := ma.ValueForProtocol(multiaddr.P_IP4); err == nil {
if ip, err := netip.ParseAddr(val); err == nil {
ips = append(ips, ip)
continue
}
}
// Try IPv6
if val, err := ma.ValueForProtocol(multiaddr.P_IP6); err == nil {
if ip, err := netip.ParseAddr(val); err == nil {
ips = append(ips, ip)
}
}
}
return ips
}
139 changes: 139 additions & 0 deletions acme/clientip_test.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,139 @@
package acme

import (
"net/http"
"net/netip"
"testing"

"github.com/stretchr/testify/assert"
)

func TestClientIPs(t *testing.T) {
tests := []struct {
name string
xff string
remoteAddr string
expected []netip.Addr
}{
{
name: "XFF single IP",
xff: "1.2.3.4",
remoteAddr: "",
expected: []netip.Addr{netip.MustParseAddr("1.2.3.4")},
},
{
name: "XFF multiple IPs uses leftmost",
xff: "1.2.3.4, 5.6.7.8, 9.10.11.12",
remoteAddr: "",
expected: []netip.Addr{netip.MustParseAddr("1.2.3.4")},
},
{
name: "RemoteAddr IPv4 with port",
xff: "",
remoteAddr: "1.2.3.4:8080",
expected: []netip.Addr{netip.MustParseAddr("1.2.3.4")},
},
{
name: "RemoteAddr IPv6 with port",
xff: "",
remoteAddr: "[::1]:8080",
expected: []netip.Addr{netip.MustParseAddr("::1")},
},
{
name: "both XFF and RemoteAddr",
xff: "1.2.3.4",
remoteAddr: "5.6.7.8:8080",
expected: []netip.Addr{netip.MustParseAddr("1.2.3.4"), netip.MustParseAddr("5.6.7.8")},
},
{
name: "empty headers",
xff: "",
remoteAddr: "",
expected: nil,
},
{
name: "XFF with spaces",
xff: " 1.2.3.4 ",
remoteAddr: "",
expected: []netip.Addr{netip.MustParseAddr("1.2.3.4")},
},
{
name: "invalid XFF skipped",
xff: "not-an-ip",
remoteAddr: "1.2.3.4:80",
expected: []netip.Addr{netip.MustParseAddr("1.2.3.4")},
},
{
name: "RemoteAddr without port",
xff: "",
remoteAddr: "1.2.3.4",
expected: []netip.Addr{netip.MustParseAddr("1.2.3.4")},
},
}

for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
r := &http.Request{
Header: make(http.Header),
RemoteAddr: tt.remoteAddr,
}
if tt.xff != "" {
r.Header.Set("X-Forwarded-For", tt.xff)
}

got := clientIPs(r)
assert.Equal(t, tt.expected, got)
})
}
}

func TestMultiaddrsToIPs(t *testing.T) {
tests := []struct {
name string
addrs []string
expected []netip.Addr
}{
{
name: "IPv4 multiaddr",
addrs: []string{"/ip4/1.2.3.4/tcp/4001"},
expected: []netip.Addr{netip.MustParseAddr("1.2.3.4")},
},
{
name: "IPv6 multiaddr",
addrs: []string{"/ip6/2001:db8::1/tcp/4001"},
expected: []netip.Addr{netip.MustParseAddr("2001:db8::1")},
},
{
name: "mixed IPv4 and IPv6",
addrs: []string{"/ip4/1.2.3.4/tcp/4001", "/ip6/::1/tcp/4001"},
expected: []netip.Addr{netip.MustParseAddr("1.2.3.4"), netip.MustParseAddr("::1")},
},
{
name: "invalid multiaddr skipped",
addrs: []string{"not-a-multiaddr", "/ip4/1.2.3.4/tcp/4001"},
expected: []netip.Addr{netip.MustParseAddr("1.2.3.4")},
},
{
name: "empty input",
addrs: []string{},
expected: []netip.Addr{},
},
{
name: "nil input",
addrs: nil,
expected: []netip.Addr{},
},
{
name: "multiaddr without IP",
addrs: []string{"/dns4/example.com/tcp/4001"},
expected: []netip.Addr{},
},
}

for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
got := multiaddrsToIPs(tt.addrs)
assert.Equal(t, tt.expected, got)
})
}
}
38 changes: 38 additions & 0 deletions acme/writer.go
Original file line number Diff line number Diff line change
Expand Up @@ -11,6 +11,7 @@ import (
"io"
"net"
"net/http"
"net/netip"
"os"
"strings"
"testing"
Expand All @@ -20,6 +21,7 @@ import (
"github.com/coredns/coredns/plugin/pkg/reuseport"
"github.com/felixge/httpsnoop"
"github.com/ipshipyard/p2p-forge/client"
"github.com/ipshipyard/p2p-forge/denylist"
"github.com/prometheus/client_golang/prometheus"

metrics "github.com/slok/go-http-metrics/metrics/prometheus"
Expand Down Expand Up @@ -151,6 +153,13 @@ func (c *acmeWriter) OnStartup() error {
return
}

// Check denylist before attempting to connect
if blocked, reason := checkDenylist(clientIPs(r), typedBody.Addresses); blocked {
w.WriteHeader(http.StatusForbidden)
_, _ = w.Write([]byte(fmt.Sprintf("403 Forbidden: %s", reason)))
return
}

httpUserAgent := r.Header.Get("User-Agent")
if err := testAddresses(r.Context(), peerID, typedBody.Addresses, httpUserAgent); err != nil {
w.WriteHeader(http.StatusBadRequest)
Expand Down Expand Up @@ -295,6 +304,35 @@ type requestBody struct {
Addresses []string `json:"addresses"`
}

// checkDenylist checks client IPs and multiaddr IPs against denylist.
// Returns (blocked, reason) where reason describes which IP was blocked.
// Blocks if ANY IP is denied.
func checkDenylist(clientIPs []netip.Addr, multiaddrs []string) (bool, string) {
mgr := denylist.GetManager()
if mgr == nil {
return false, ""
}

// Check all client IPs (XFF and RemoteAddr)
for _, client := range clientIPs {
if !client.IsValid() {
continue
}
if denied, result := mgr.Check(client); denied {
return true, fmt.Sprintf("client IP %s blocked by %s", client, result.Name)
}
}

// Check multiaddr IPs
for _, ip := range multiaddrsToIPs(multiaddrs) {
if denied, result := mgr.Check(ip); denied {
return true, fmt.Sprintf("multiaddr IP %s blocked by %s", ip, result.Name)
}
}

return false, ""
}

func (c *acmeWriter) OnFinalShutdown() error {
if !c.nlSetup {
return nil
Expand Down
Loading