Files
routewatch/internal/iplist/iplist.go
T
clawbot b2553f044d
check / check (push) Successful in 1m27s
Add the ProtonVPN server networks and addresses to the IP lookup (closes #236)
Proton's own server list answers only a logged-in session, so the lists come from the public mirror https://github.com/tn3w/ProtonVPN-IPs, downloaded once a day: `protonvpn`, its /24 networks, and `protonvpn_entry`, its servers' IPv4 and IPv6 addresses. Both feed `flags.is_vpn` and `vpn_providers`.

Judgement call: the /24 networks are listed as well as the exact addresses, because coverage matters more than attribution, so neighbouring addresses in those networks count too.
Risk: the mirror is one person's repository; if it stops updating, downloads keep succeeding with old data.

Model: opus-5-5
2026-11-10 14:14:06 +01:00

220 lines
5.9 KiB
Go

// Package iplist downloads public IP lists on a schedule, keeps the last good
// copy of each on disk and in memory, and answers which lists contain an
// address.
package iplist
import (
"context"
"log/slog"
"net/netip"
"sync"
"time"
"go4.org/netipx"
)
// Format is how a list's file is written.
type Format int
const (
// FormatLines has one address or network per line, such as 192.0.2.1 or
// 192.0.2.0/24. Lines that start with # are comments.
FormatLines Format = iota
// FormatSpamhausJSON has one JSON object per line, with a network in its
// cidr field. The last line holds the file's metadata and no network.
FormatSpamhausJSON
// FormatDShield has one network per line, as tab-separated fields that
// start with the network's first and last address. Lines that start
// with # are comments.
FormatDShield
// FormatOnionooJSON is one Onionoo details document. Its relays have
// addresses in two fields: exit_addresses, which has IPv4 only, and
// or_addresses, which has IPv4 and IPv6, each with a port.
FormatOnionooJSON
// FormatMullvadJSON is Mullvad's JSON list of relays. A relay's addresses
// are in ipv4_addr_in, ipv6_addr_in and ipv4_v2ray.
FormatMullvadJSON
// FormatNordVPNJSON is NordVPN's JSON list of servers. A server's
// addresses are in station and ipv6_station.
FormatNordVPNJSON
// FormatPIAJSON is Private Internet Access's JSON server list, then a
// blank line and a signature. Each region's servers has lists of servers
// by protocol, and each server has its address in ip.
FormatPIAJSON
// FormatIVPNJSON is IVPN's JSON server list. The hosts of its wireguard
// and openvpn gateways have their addresses in host and v2ray.
FormatIVPNJSON
// FormatProtonVPN has one address or network per line, with comment lines
// that start with #. Unlike FormatLines, a file with no addresses or
// networks fails.
FormatProtonVPN
)
// List is one public IP list.
type List struct {
// Name names the list in lookup answers, and names its copy on disk.
Name string
// Description says in one line what being on the list means.
Description string
// URL is where the publisher serves the whole list.
URL string
// Interval is how long a copy is used before the list is downloaded
// again.
Interval time.Duration
// Format is how the list's file is written.
Format Format
// VPN says that the list holds servers of VPN services.
VPN bool
}
// Listing says that a list contains an address.
type Listing struct {
Name string `json:"name"`
Description string `json:"description"`
// Updated is when routewatch last downloaded the list, or last heard from
// its publisher that its copy was still current.
Updated time.Time `json:"updated"`
// VPN says that the list holds servers of VPN services. A lookup answer
// reports it through is_vpn and vpn_providers.
VPN bool `json:"-"`
}
// listCopy is a list's last good copy in memory.
type listCopy struct {
addresses *netipx.IPSet
updated time.Time
// validators identify the version of the list this copy holds.
validators validators
}
// Params is what New needs.
type Params struct {
// Dir is the directory that holds the copies on disk.
Dir string
// Lists are the lists to download and look addresses up in.
Lists []List
Logger *slog.Logger
}
// IPLists keeps a copy of each list and answers which lists contain an
// address. Start loads the copies from disk and starts downloading the lists.
type IPLists struct {
dir string
lists []List
logger *slog.Logger
// copies holds each list's last good copy, by list name. A list with no
// good copy yet has none.
mu sync.RWMutex
copies map[string]listCopy
// stop ends the goroutine Start runs, and done is closed when it returns.
stop context.CancelFunc
done chan struct{}
}
// New returns an IPLists with no copies. Start loads them.
func New(params Params) *IPLists {
return &IPLists{
dir: params.Dir,
lists: params.Lists,
logger: params.Logger.With("component", "iplists"),
copies: make(map[string]listCopy),
done: make(chan struct{}),
}
}
// Lookup returns a Listing for each list whose copy contains addr, in the
// order of the lists given to New. It makes no outside request. When no list
// contains addr, it returns an empty list, not nil.
func (b *IPLists) Lookup(addr netip.Addr) []Listing {
b.mu.RLock()
defer b.mu.RUnlock()
listings := []Listing{}
for _, list := range b.lists {
c, ok := b.copies[list.Name]
if ok && c.addresses.Contains(addr.Unmap()) {
listings = append(listings, Listing{
Name: list.Name,
Description: list.Description,
Updated: c.updated,
VPN: list.VPN,
})
}
}
return listings
}
// ShortestInterval returns the shortest Interval of the lists. That is the
// soonest a copy can change, and so how long a lookup answer stays current.
func (b *IPLists) ShortestInterval() time.Duration {
var shortest time.Duration
for i, list := range b.lists {
if i == 0 || list.Interval < shortest {
shortest = list.Interval
}
}
return shortest
}
// updated returns when the named list's copy was last downloaded or found
// current, or the zero time when the list has no copy.
func (b *IPLists) updated(name string) time.Time {
b.mu.RLock()
defer b.mu.RUnlock()
return b.copies[name].updated
}
// copyValidators returns the validators of the named list's copy. They are
// empty when the list has no copy.
func (b *IPLists) copyValidators(name string) validators {
b.mu.RLock()
defer b.mu.RUnlock()
return b.copies[name].validators
}
// setCopy makes c the named list's copy.
func (b *IPLists) setCopy(name string, c listCopy) {
b.mu.Lock()
defer b.mu.Unlock()
b.copies[name] = c
}
// setUpdated records that the named list's copy was found current at t.
func (b *IPLists) setUpdated(name string, t time.Time) {
b.mu.Lock()
defer b.mu.Unlock()
c, ok := b.copies[name]
if ok {
c.updated = t
b.copies[name] = c
}
}