Skip to content

Commit 7dad53a

Browse files
authored
Merge pull request #1 from HENNGE/initial-iteration
feat: Initial Implementation of SPF2IP-Go Resolver
2 parents b1c8528 + eeeed2b commit 7dad53a

File tree

13 files changed

+1396
-0
lines changed

13 files changed

+1396
-0
lines changed

.github/workflows/test_and_lint.yml

Lines changed: 44 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,44 @@
1+
name: Test and Lint
2+
3+
on:
4+
push:
5+
6+
permissions:
7+
contents: read
8+
9+
env:
10+
GO_VERSION: 1.24.2
11+
GOLANGCI_LINT_VERSION: v2.1.6
12+
13+
jobs:
14+
golangci-lint:
15+
name: golangci-lint
16+
runs-on: ubuntu-latest
17+
steps:
18+
- name: Check out code
19+
uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4
20+
21+
- name: Set up Go
22+
uses: actions/setup-go@d35c59abb061a4a6fb18e82ac0862c26744d6ab5 # v5
23+
with:
24+
go-version: ${{ env.GO_VERSION }}
25+
26+
- name: Run golangci-lint
27+
uses: golangci/golangci-lint-action@4afd733a84b1f43292c63897423277bb7f4313a9 # v8
28+
with:
29+
version: ${{ env.GOLANGCI_LINT_VERSION }}
30+
31+
go-test:
32+
name: Go test
33+
runs-on: ubuntu-latest
34+
steps:
35+
- name: Check out code
36+
uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4
37+
38+
- name: Set up Go
39+
uses: actions/setup-go@d35c59abb061a4a6fb18e82ac0862c26744d6ab5 # v5
40+
with:
41+
go-version: ${{ env.GO_VERSION }}
42+
43+
- name: Go test
44+
run: go test -v ./...

.gitignore

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,5 @@
1+
# Binary
2+
/spf2ip-go
3+
4+
# OS generated files
5+
.DS_Store

README.md

Lines changed: 71 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,71 @@
1+
# SPF2IP-Go
2+
3+
A Go implementation designed to resolve a domain's SPF (Sender Policy Framework) records into a list of IP addresses and CIDR blocks. This project is a remake of and inspired by the original Python-based [SPF2IP by Nathan Dines](https://github.com/nathandines/SPF2IP).
4+
5+
This tool recursively processes SPF records, handling `include` and `redirect` mechanisms, and extracts all authorized IP networks.
6+
7+
## Features
8+
9+
- Resolves SPF records for a given domain.
10+
- Supports both IPv4 and IPv6 resolution (`ip4`, `ip6`, `a`, `mx` mechanisms).
11+
- Handles `include` and `redirect` mechanisms recursively.
12+
- Implements strict redirect logic (a `redirect` overrides preceding mechanisms in the same record).
13+
- Detects and prevents resolution loops and excessive lookups (via include depth limit).
14+
- Outputs all IPs and networks in canonical CIDR notation (e.g., `1.2.3.4/32`, `10.0.0.0/24`), sorted and unique.
15+
16+
## Acknowledgements
17+
18+
This project is a Go remake of the original [SPF2IP (Python) by Nathan Dines](https://github.com/nathandines/SPF2IP). Full credit and thanks to Nathan Dines for the original concept and implementation.
19+
20+
## Installation
21+
22+
### Building from Source
23+
To build the `spf2ip-go` executable from source, follow these steps:
24+
25+
1. Clone the repository:
26+
```bash
27+
git clone https://github.com/HENNGE/spf2ip-go.git
28+
cd spf2ip-go
29+
```
30+
31+
2. Build the executable:
32+
```bash
33+
go build github.com/HENNGE/spf2ip-go/cmd/spf2ip-go
34+
```
35+
This will create an executable named `spf2ip-go` in the current directory.
36+
37+
### Using Go Modules
38+
If you prefer to use Go modules, you can run the following command to install the package:
39+
40+
```bash
41+
go install github.com/HENNGE/spf2ip-go/cmd/spf2ip-go@latest
42+
```
43+
44+
## CLI Usage
45+
46+
```bash
47+
spf2ip-go --domain <domain_name> [--ip-version <4|6>] [--debug]
48+
```
49+
50+
### Flags
51+
52+
* `--domain <string>`: (Required) The domain for which the SPF records should be resolved.
53+
* `--ip-version <int>`: (Optional) The IP version to extract. Accepts `4` for IPv4 or `6` for IPv6. Defaults to `4`.
54+
* `--debug`: (Optional) Enable debug logging output to stderr. Defaults to `false`.
55+
56+
### Examples
57+
58+
1. **Get IPv4 addresses for `example.com`:**
59+
```bash
60+
spf2ip-go --domain example.com
61+
```
62+
63+
1. **Get IPv6 addresses for `example.com`:**
64+
```bash
65+
spf2ip-go --domain example.com --ip-version 6
66+
```
67+
68+
1. **Get IPv4 addresses for `example.com` with debug output:**
69+
```bash
70+
spf2ip-go --domain example.com --debug
71+
```

cidr_sorter.go

Lines changed: 58 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,58 @@
1+
package spf2ip
2+
3+
import (
4+
"bytes"
5+
"net"
6+
)
7+
8+
type cidrSorter []string
9+
10+
func (s cidrSorter) Len() int { return len(s) }
11+
func (s cidrSorter) Swap(i, j int) { s[i], s[j] = s[j], s[i] }
12+
func (s cidrSorter) Less(i, j int) bool {
13+
ip1, p1, isV4_1 := parseCIDR(s[i])
14+
ip2, p2, isV4_2 := parseCIDR(s[j])
15+
16+
isValid1 := ip1 != nil
17+
isValid2 := ip2 != nil
18+
19+
if !isValid1 && !isValid2 {
20+
return s[i] < s[j]
21+
} // Both invalid, sort by string
22+
23+
if !isValid1 {
24+
return false
25+
} // Invalid sorts after valid
26+
27+
if !isValid2 {
28+
return true
29+
} // Valid sorts before invalid
30+
31+
// Both are valid CIDRs
32+
if isV4_1 != isV4_2 {
33+
return isV4_1 // IPv4 comes before IPv6
34+
}
35+
36+
if cmp := bytes.Compare(ip1, ip2); cmp != 0 {
37+
return cmp < 0
38+
}
39+
40+
return p1 < p2 // Smaller prefix (wider network) comes first
41+
}
42+
43+
// parseCIDR parses a CIDR notation string and returns the IP, prefix length, and whether it's IPv4.
44+
func parseCIDR(s string) (ip net.IP, prefix int, isV4 bool) {
45+
_, ipNet, err := net.ParseCIDR(s)
46+
if err != nil {
47+
return nil, 0, false
48+
}
49+
50+
ip = ipNet.IP
51+
prefix, _ = ipNet.Mask.Size()
52+
53+
if ip4 := ip.To4(); ip4 != nil {
54+
return ip4, prefix, true
55+
}
56+
57+
return ip, prefix, false
58+
}

cidr_sorter_test.go

Lines changed: 50 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,50 @@
1+
package spf2ip
2+
3+
import (
4+
"sort"
5+
"testing"
6+
7+
"github.com/stretchr/testify/assert"
8+
)
9+
10+
func TestCIDRSorter(t *testing.T) {
11+
t.Parallel()
12+
13+
for description, tc := range map[string]struct {
14+
cidrs []string
15+
expected []string
16+
}{
17+
"empty": {
18+
cidrs: []string{},
19+
expected: []string{},
20+
},
21+
"IPv4": {
22+
cidrs: []string{"5.6.7.8/32", "1.1.1.0/24", "1.2.3.4/32"},
23+
expected: []string{"1.1.1.0/24", "1.2.3.4/32", "5.6.7.8/32"},
24+
},
25+
"IPv6": {
26+
cidrs: []string{"2001:db8:abcd::/48", "2001:db8::/32", "2001:db8:1234::/64"},
27+
expected: []string{"2001:db8::/32", "2001:db8:1234::/64", "2001:db8:abcd::/48"},
28+
},
29+
"mixed": {
30+
cidrs: []string{"2001:db8::/32", "1.1.1.0/24", "4.5.6.7/32", "2001:db8:abcd::/48"},
31+
expected: []string{"1.1.1.0/24", "4.5.6.7/32", "2001:db8::/32", "2001:db8:abcd::/48"},
32+
},
33+
"invalid": {
34+
cidrs: []string{"invalid", "", "1.2.3.4/32"},
35+
expected: []string{"1.2.3.4/32", "", "invalid"},
36+
},
37+
} {
38+
t.Run(description, func(t *testing.T) {
39+
t.Parallel()
40+
41+
// Create a copy of the CIDRs to avoid modifying the original slice.
42+
cidrs := make([]string, len(tc.cidrs))
43+
copy(cidrs, tc.cidrs)
44+
45+
sort.Sort(cidrSorter(cidrs))
46+
47+
assert.Equal(t, tc.expected, cidrs, "Sorted CIDRs should match expected order")
48+
})
49+
}
50+
}

cmd/spf2ip-go/main.go

Lines changed: 43 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,43 @@
1+
package main
2+
3+
import (
4+
context "context"
5+
"flag"
6+
"fmt"
7+
"log"
8+
net "net"
9+
"os"
10+
11+
spf2ip "github.com/HENNGE/spf2ip-go"
12+
)
13+
14+
func main() {
15+
domain := flag.String("domain", "", "Domain for which the IP addresses should be extracted (required)")
16+
ipVersion := flag.Int("ip-version", 4, "Define version of IP list to extract (4 or 6; default is 4)")
17+
debugLogging := flag.Bool("debug", false, "Enable debug logging output to stderr")
18+
19+
flag.Parse()
20+
21+
if *domain == "" {
22+
log.Printf("Usage of spf2ip-go:")
23+
flag.PrintDefaults()
24+
os.Exit(1)
25+
}
26+
27+
if *ipVersion != 4 && *ipVersion != 6 {
28+
log.Fatal("Error: --ip-version must be '4' or '6'")
29+
}
30+
31+
resolver := spf2ip.NewSPF2IPResolver(net.DefaultResolver, *debugLogging)
32+
33+
ips, err := resolver.Resolve(context.Background(), *domain, *ipVersion)
34+
if err != nil {
35+
log.Fatalf("Error resolving SPF: %v", err)
36+
}
37+
38+
log.Printf("Resolved %d IPs for domain %s:\n", len(ips), *domain)
39+
40+
for _, ip := range ips {
41+
fmt.Println(ip)
42+
}
43+
}

go.mod

Lines changed: 14 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,14 @@
1+
module github.com/HENNGE/spf2ip-go
2+
3+
go 1.24.2
4+
5+
require (
6+
github.com/stretchr/testify v1.10.0
7+
go.uber.org/mock v0.5.2
8+
)
9+
10+
require (
11+
github.com/davecgh/go-spew v1.1.1 // indirect
12+
github.com/pmezard/go-difflib v1.0.0 // indirect
13+
gopkg.in/yaml.v3 v3.0.1 // indirect
14+
)

go.sum

Lines changed: 12 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,12 @@
1+
github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c=
2+
github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
3+
github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM=
4+
github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4=
5+
github.com/stretchr/testify v1.10.0 h1:Xv5erBjTwe/5IxqUQTdXv5kgmIvbHo3QQyRwhJsOfJA=
6+
github.com/stretchr/testify v1.10.0/go.mod h1:r2ic/lqez/lEtzL7wO/rwa5dbSLXVDPFyf8C91i36aY=
7+
go.uber.org/mock v0.5.2 h1:LbtPTcP8A5k9WPXj54PPPbjcI4Y6lhyOZXn+VS7wNko=
8+
go.uber.org/mock v0.5.2/go.mod h1:wLlUxC2vVTPTaE3UD51E0BGOAElKrILxhVSDYQLld5o=
9+
gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405 h1:yhCVgyC4o1eVCa2tZl7eS0r+SDo693bJlVdllGtEeKM=
10+
gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0=
11+
gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA=
12+
gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=

netresolver_mock.go

Lines changed: 85 additions & 0 deletions
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

0 commit comments

Comments
 (0)