Add the public VPN server lists, with is_vpn and vpn_providers (closes #235)
check / check (push) Successful in 1m36s

The IP lookup now checks Mullvad, NordVPN, Private Internet Access, IVPN and X4BNet's community list, each downloaded once a day. Each result has `flags.is_vpn` and `vpn_providers`.

Judgement call: X4BNet publishes IPv4 and IPv6 as two files, so they are two lists, `x4bnet` and `x4bnet_v6`.
Judgement call: Private Internet Access's signature is not read or checked.
Rule suppressed: `tagliatelle` on the structs holding the publishers' field names.

Model: opus-5-5
This commit is contained in:
clawbot committed 2026-11-10 10:56:24 +00:00
1 parent 3204b7ee4b
commit 601bc812b9
8 files changed
+584 -28

No files matched your search

+158 -7
View File
@@ -8,6 +8,7 @@ import (
"io"
"net"
"net/netip"
"slices"
"strings"
"go4.org/netipx"
@@ -33,7 +34,7 @@ var (
errUnknownFormat = errors.New("unknown list format")
errNoNetwork = errors.New("no network on the line")
errBadRange = errors.New("first and last address make no range")
errNoAddresses = errors.New("no relay addresses in the document")
errNoAddresses = errors.New("no addresses in the document")
)
// parse reads a list file written in format and returns the addresses it
@@ -49,6 +50,14 @@ func parse(r io.Reader, format Format) (*netipx.IPSet, error) {
return parseLines(r, addDShieldNetwork)
case FormatOnionooJSON:
return parseOnionoo(r)
case FormatMullvadJSON:
return parseMullvad(r)
case FormatNordVPNJSON:
return parseNordVPN(r)
case FormatPIAJSON:
return parsePIA(r)
case FormatIVPNJSON:
return parseIVPN(r)
default:
return nil, errUnknownFormat
}
@@ -168,11 +177,6 @@ func addDShieldNetwork(builder *netipx.IPSetBuilder, line string) error {
// fields of the relays in an Onionoo details document. A document with no
// addresses fails, because the Tor network always has relays.
func parseOnionoo(r io.Reader) (*netipx.IPSet, error) {
data, err := io.ReadAll(r)
if err != nil {
return nil, err
}
//nolint:tagliatelle // Onionoo's own field names
var details struct {
Relays []struct {
@@ -181,7 +185,7 @@ func parseOnionoo(r io.Reader) (*netipx.IPSet, error) {
} `json:"relays"`
}
err = json.Unmarshal(data, &details)
err := readJSON(r, &details)
if err != nil {
return nil, err
}
@@ -216,6 +220,153 @@ func parseOnionoo(r io.Reader) (*netipx.IPSet, error) {
return addresses, nil
}
// parseMullvad returns the addresses of the relays in a Mullvad document. A
// relay with no IPv6 address has null for it, and only bridges have
// ipv4_v2ray.
func parseMullvad(r io.Reader) (*netipx.IPSet, error) {
//nolint:tagliatelle // Mullvad's own field names
var relays []struct {
IPv4 string `json:"ipv4_addr_in"`
IPv6 string `json:"ipv6_addr_in"`
IPv4V2Ray string `json:"ipv4_v2ray"`
}
err := readJSON(r, &relays)
if err != nil {
return nil, err
}
var texts []string
for _, relay := range relays {
texts = append(texts, relay.IPv4, relay.IPv6, relay.IPv4V2Ray)
}
return addressSet(texts)
}
// parseNordVPN returns the addresses of the servers in a NordVPN document. A
// server with no IPv6 address has an empty ipv6_station.
func parseNordVPN(r io.Reader) (*netipx.IPSet, error) {
//nolint:tagliatelle // NordVPN's own field names
var servers []struct {
Station string `json:"station"`
IPv6Station string `json:"ipv6_station"`
}
err := readJSON(r, &servers)
if err != nil {
return nil, err
}
var texts []string
for _, server := range servers {
texts = append(texts, server.Station, server.IPv6Station)
}
return addressSet(texts)
}
// parsePIA returns the addresses of the servers in a Private Internet Access
// document. json.Decoder stops at the end of the JSON, so the signature after
// it is not read.
func parsePIA(r io.Reader) (*netipx.IPSet, error) {
var list struct {
Regions []struct {
Servers map[string][]struct {
IP string `json:"ip"`
} `json:"servers"`
} `json:"regions"`
}
err := json.NewDecoder(r).Decode(&list)
if err != nil {
return nil, err
}
var texts []string
for _, region := range list.Regions {
for _, servers := range region.Servers {
for _, server := range servers {
texts = append(texts, server.IP)
}
}
}
return addressSet(texts)
}
// parseIVPN returns the addresses of the hosts in an IVPN document. A host's
// ipv6 object holds only an address inside the VPN, so it is not read.
func parseIVPN(r io.Reader) (*netipx.IPSet, error) {
type gateway struct {
Hosts []struct {
Host string `json:"host"`
V2Ray string `json:"v2ray"`
} `json:"hosts"`
}
var list struct {
WireGuard []gateway `json:"wireguard"`
OpenVPN []gateway `json:"openvpn"`
}
err := readJSON(r, &list)
if err != nil {
return nil, err
}
var texts []string
for _, gw := range slices.Concat(list.WireGuard, list.OpenVPN) {
for _, host := range gw.Hosts {
texts = append(texts, host.Host, host.V2Ray)
}
}
return addressSet(texts)
}
// readJSON reads r to its end and decodes it, as one JSON document, into v.
func readJSON(r io.Reader, v any) error {
data, err := io.ReadAll(r)
if err != nil {
return err
}
return json.Unmarshal(data, v)
}
// addressSet returns the set of the addresses in texts, skipping empty ones.
// A list with no addresses fails, because every VPN service has servers.
func addressSet(texts []string) (*netipx.IPSet, error) {
var builder netipx.IPSetBuilder
for _, text := range texts {
if text == "" {
continue
}
err := addAddress(&builder, text)
if err != nil {
return nil, err
}
}
addresses, err := builder.IPSet()
if err != nil {
return nil, err
}
if len(addresses.Ranges()) == 0 {
return nil, errNoAddresses
}
return addresses, nil
}
// addAddress adds an address, such as 192.0.2.1, to builder.
func addAddress(builder *netipx.IPSetBuilder, text string) error {
addr, err := netip.ParseAddr(text)