externalip/consensus.go
Timmy Welch 9363450cdc v0.0.1
Add pre-commit config
Update default consensus with more sources
Move IP version spec to Source so that dual-stack sites can be used
Add tests for HTTPSource and consensus
2022-07-07 18:40:35 -07:00

187 lines
5.7 KiB
Go

package externalip
import (
"log"
"net"
"sync"
"time"
)
const IPv6 = 6
const IPv4 = 4
// DefaultConsensusConfig returns the ConsensusConfig,
// with the default values:
// + Timeout: 30 seconds;
func DefaultConsensusConfig() *ConsensusConfig {
return &ConsensusConfig{
Timeout: time.Second * 30,
}
}
// DefaultConsensus returns a consensus filled
// with default and recommended HTTPSources.
// TLS-Protected providers get more power,
// compared to plain-text providers.
func DefaultConsensus(cfg *ConsensusConfig, logger *log.Logger) *Consensus {
consensus := NewConsensus(cfg, logger)
// TLS-protected providers
_ = consensus.AddVoter(NewHTTPSource("https://myip-mnpz5ateaq-uw.a.run.app", IPv4), 3)
_ = consensus.AddVoter(NewHTTPSource("https://myip-mnpz5ateaq-uw.a.run.app", IPv6), 3)
_ = consensus.AddVoter(NewHTTPSource("https://api.my-ip.io/ip", IPv4), 1)
_ = consensus.AddVoter(NewHTTPSource("https://api.my-ip.io/ip", IPv6), 1)
_ = consensus.AddVoter(NewHTTPSource("https://wtfismyip.com/text", IPv4), 1)
_ = consensus.AddVoter(NewHTTPSource("https://wtfismyip.com/text", IPv6), 1)
_ = consensus.AddVoter(NewHTTPSource("https://icanhazip.com", IPv4), 1)
_ = consensus.AddVoter(NewHTTPSource("https://icanhazip.com", IPv6), 1)
_ = consensus.AddVoter(NewHTTPSource("https://api.ip.sb/ip", IPv4), 1)
_ = consensus.AddVoter(NewHTTPSource("https://api.ip.sb/ip", IPv6), 1)
_ = consensus.AddVoter(NewHTTPSource("https://api.myip.la", IPv4), 1)
_ = consensus.AddVoter(NewHTTPSource("https://api.myip.la", IPv6), 1)
_ = consensus.AddVoter(NewHTTPSource("https://ident.me", IPv4), 1)
_ = consensus.AddVoter(NewHTTPSource("https://ident.me", IPv6), 1)
_ = consensus.AddVoter(NewHTTPSource("https://api.ipify.org/?format=plain", IPv4), 1)
_ = consensus.AddVoter(NewHTTPSource("http://api.ipaddress.com/myip", IPv4), 1)
_ = consensus.AddVoter(NewHTTPSource("https://ipecho.net/plain", IPv4), 1)
// Plain-text providers
_ = consensus.AddVoter(NewHTTPSource("https://ifconfig.io/ip", IPv6), 1)
_ = consensus.AddVoter(NewHTTPSource("https://ifconfig.io/ip", IPv4), 1)
_ = consensus.AddVoter(NewHTTPSource("https://checkip.amazonaws.com/", IPv4), 1)
_ = consensus.AddVoter(NewHTTPSource("http://whatismyip.akamai.com/", IPv4), 1)
_ = consensus.AddVoter(NewHTTPSource("https://tnx.nl/", IPv6), 1)
_ = consensus.AddVoter(NewHTTPSource("https://tnx.nl/", IPv4), 1)
_ = consensus.AddVoter(NewHTTPSource("http://myip.dnsomatic.com/", IPv4), 1)
_ = consensus.AddVoter(NewHTTPSource("https://diagnostic.opendns.com/myip", IPv6), 1)
return consensus
}
// NewConsensus creates a new Consensus, with no sources.
// When the given cfg is <nil>, the `DefaultConsensusConfig` will be used.
func NewConsensus(cfg *ConsensusConfig, logger *log.Logger) *Consensus {
if cfg == nil {
cfg = DefaultConsensusConfig()
}
if logger == nil {
logger = NewLogger(nil)
}
return &Consensus{
timeout: cfg.Timeout,
logger: logger,
}
}
// ConsensusConfig is used to configure the Consensus, while creating it.
type ConsensusConfig struct {
Timeout time.Duration
}
// WithTimeout sets the voting timeout of this config,
// returning the config itself at the end, to allow for chaining
func (cfg *ConsensusConfig) WithTimeout(timeout time.Duration) *ConsensusConfig {
cfg.Timeout = timeout
return cfg
}
// Consensus the type at the center of this library,
// and is the main entry point for users.
// Its `ExternalIP` method allows you to ask for your ExternalIP,
// influenced by all its added voters.
type Consensus struct {
voters4 []voter
voters6 []voter
timeout time.Duration
logger *log.Logger
}
// AddVoter adds a voter to this consensus.
// The source cannot be <nil> and
// the weight has to be of a value of 1 or above.
func (c *Consensus) AddVoter(source Source, weight uint) error {
if source == nil {
c.logger.Println("[ERROR] could not add voter: no source given")
return ErrNoSource
}
if weight == 0 {
c.logger.Println("[ERROR] could not add voter: weight cannot be 0")
return ErrInsufficientWeight
}
if source.IPVersion() == 4 {
c.voters4 = append(c.voters4, voter{
source: source,
weight: weight,
})
}
if source.IPVersion() == 6 {
c.voters6 = append(c.voters6, voter{
source: source,
weight: weight,
})
}
return nil
}
// ExternalIP requests asynchronously the externalIP from all added voters,
// returning the IP which received the most votes.
// The returned IP will always be valid, in case the returned error is <nil>.
func (c *Consensus) ExternalIP(ipversion uint) (net.IP, error) {
voteCollection := make(map[string]uint)
var vlock sync.Mutex
var wg sync.WaitGroup
var thisVoters []voter
if ipversion == IPv4 {
thisVoters = c.voters4
}
if ipversion == IPv6 {
thisVoters = c.voters6
}
// start all source Requests on a seperate goroutine
for _, v := range thisVoters {
wg.Add(1)
go func(v voter) {
defer wg.Done()
ip, err := v.source.IP(c.timeout, c.logger)
if err == nil && ip != nil {
vlock.Lock()
defer vlock.Unlock()
voteCollection[ip.String()] += v.weight
}
}(v)
}
// wait for all votes to come in,
// or until their process times out
wg.Wait()
// if no votes were casted succesfully,
// return early with an error
if len(voteCollection) == 0 {
c.logger.Println("[ERROR] no votes were casted succesfully")
return nil, ErrNoIP
}
var max uint
var externalIP string
// find the IP which has received the most votes,
// influinced by the voter's weight.
vlock.Lock()
defer vlock.Unlock()
for ip, votes := range voteCollection {
if votes > max {
max, externalIP = votes, ip
}
}
// as the found IP was parsed previously,
// we know it cannot be nil and is valid
if ipversion == IPv4 {
return net.ParseIP(externalIP).To4(), nil
}
return net.ParseIP(externalIP).To16(), nil
}