diff --git a/.github/workflows/go.yml b/.github/workflows/go.yml new file mode 100644 index 0000000..3e21407 --- /dev/null +++ b/.github/workflows/go.yml @@ -0,0 +1,17 @@ +name: test + +on: [push, pull_request] + +jobs: + build: + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v4 + with: + fetch-depth: 2 + - uses: actions/setup-go@v5 + with: + go-version: "1.22" + - name: test + run: go test -v ./... + diff --git a/check_test.go b/check_test.go deleted file mode 100644 index 901da7d..0000000 --- a/check_test.go +++ /dev/null @@ -1,80 +0,0 @@ -package mullsox - -import ( - "context" - "testing" - "time" - - "github.com/davecgh/go-spew/spew" -) - -func TestCheckIP4(t *testing.T) { - v4, err := CheckIP4() - if err != nil { - t.Fatalf("%s", err.Error()) - } - v4j, err4j := json.Marshal(v4) - if err4j != nil { - t.Fatalf("%s", err4j.Error()) - } - t.Logf(string(v4j)) -} - -func TestCheckIP6(t *testing.T) { - v6, err := CheckIP6() - if err != nil { - t.Fatalf("%s", err.Error()) - } - v6j, err6j := json.Marshal(v6) - if err6j != nil { - t.Fatalf("%s", err6j.Error()) - } - t.Logf(string(v6j)) -} - -func TestCheckIPConcurrent(t *testing.T) { - ctx, cancel := context.WithDeadline(context.Background(), time.Now().Add(15*time.Second)) - me, err := CheckIP(ctx) - if err != nil { - t.Fatalf("%s", err.Error()) - } - v4j, err4j := json.Marshal(me.V4) - if err4j != nil { - t.Fatalf("%s", err4j.Error()) - } - v6j, err6j := json.Marshal(me.V6) - if err6j != nil { - t.Fatalf("%s", err6j.Error()) - } - unmarshaled := &MyIPDetails{} - unv4 := &IPDetails{} - unv6 := &IPDetails{} - - if err = json.Unmarshal(v4j, unv4); err != nil { - t.Fatalf("%s", err.Error()) - } - if err = json.Unmarshal(v6j, unv6); err != nil { - t.Fatalf("%s", err.Error()) - } - unmarshaled.V4 = unv4 - unmarshaled.V6 = unv6 - - t.Logf(spew.Sdump(unmarshaled.V4)) - t.Logf(spew.Sdump(unmarshaled.V6)) - cancel() -} - -func TestAmIMullvad(t *testing.T) { - servers := NewChecker() - ctx, cancel := context.WithDeadline(context.Background(), time.Now().Add(15*time.Second)) - am, err := servers.AmIMullvad(ctx) - if err != nil { - t.Fatalf("%s", err.Error()) - } - indented, err := json.MarshalIndent(am, "", " ") - if err != nil { - t.Fatalf("%s", err.Error()) - } - t.Logf(string(indented)) - cancel() -} diff --git a/mulltest/server.go b/mulltest/server.go new file mode 100644 index 0000000..79c63d0 --- /dev/null +++ b/mulltest/server.go @@ -0,0 +1,132 @@ +package mulltest + +import ( + "encoding/json" + "net/http" + "net/http/httptest" + "os" + "strconv" + "strings" + "sync" + "sync/atomic" +) + +type OpState int + +const ( + OpNull OpState = iota + OpIsMullvad + OpRelays +) + +const ( + replyIsMullvadTrue = `{"ip":"146.70.116.130","country":"The Nation of Fuck","city":"Vienna","longitude":-69.6969,"latitude":69.6969,"mullvad_exit_ip":true,"mullvad_exit_ip_hostname":"at-vie-ovpn-002","mullvad_server_type":"OpenVPN","blacklisted":{"blacklisted":false,"results":[]},"organization":"M247"}` + replyIsMullvadFalse = `{"ip":"127.0.0.1","country":"","city":"","longitude":0,"latitude":0,"mullvad_exit_ip":false,"mullvad_exit_ip_hostname":"","mullvad_server_type":"","blacklisted":{"blacklisted":false,"results":[]},"organization":""}` + testDataRelays = `[{"hostname":"al-tia-wg-001","country_code":"al","country_name":"Albania","city_code":"tia","city_name":"Tirana","fqdn":"al-tia-wg-001.relays.mullvad.net","active":true,"owned":false,"provider":"iRegister","ipv4_addr_in":"31.171.153.66","ipv6_addr_in":"2a04:27c0:0:3::f001","network_port_speed":10,"stboot":true,"pubkey":"bPfJDdgBXlY4w3ACs68zOMMhLUbbzktCKnLOFHqbxl4=","multihop_port":3155,"socks_name":"al-tia-wg-socks5-001.relays.mullvad.net","socks_port":1080,"daita":false,"type":"wireguard","status_messages":[]},{"hostname":"al-tia-wg-002","country_code":"al","country_name":"Albania","city_code":"tia","city_name":"Tirana","fqdn":"al-tia-wg-002.relays.mullvad.net","active":true,"owned":false,"provider":"iRegister","ipv4_addr_in":"31.171.154.50","ipv6_addr_in":"2a04:27c0:0:4::f001","network_port_speed":10,"stboot":true,"pubkey":"/wPQafVa/60OIp8KqhC1xTTG+nQXZF17uo8XfdUnz2E=","multihop_port":3212,"socks_name":"al-tia-wg-socks5-002.relays.mullvad.net","socks_port":1080,"daita":false,"type":"wireguard","status_messages":[]},{"hostname":"at-vie-ovpn-002","country_code":"at","country_name":"Austria","city_code":"vie","city_name":"Vienna","fqdn":"at-vie-ovpn-002.relays.mullvad.net","active":false,"owned":false,"provider":"M247","ipv4_addr_in":"146.70.116.226","ipv6_addr_in":"2001:ac8:29:86::2f","network_port_speed":10,"stboot":true,"type":"openvpn","status_messages":[]},{"hostname":"at-vie-wg-001","country_code":"at","country_name":"Austria","city_code":"vie","city_name":"Vienna","fqdn":"at-vie-wg-001.relays.mullvad.net","active":true,"owned":false,"provider":"M247","ipv4_addr_in":"146.70.116.98","ipv6_addr_in":"2001:ac8:29:84::a01f","network_port_speed":10,"stboot":true,"pubkey":"TNrdH73p6h2EfeXxUiLOCOWHcjmjoslLxZptZpIPQXU=","multihop_port":3543,"socks_name":"at-vie-wg-socks5-001.relays.mullvad.net","socks_port":1080,"daita":false,"type":"wireguard","status_messages":[]},{"hostname":"at-vie-wg-002","country_code":"at","country_name":"Austria","city_code":"vie","city_name":"Vienna","fqdn":"at-vie-wg-002.relays.mullvad.net","active":true,"owned":false,"provider":"M247","ipv4_addr_in":"146.70.116.130","ipv6_addr_in":"2001:ac8:29:85::a02f","network_port_speed":10,"stboot":true,"pubkey":"ehXBc726YX1N6Dm7fDAVMG5cIaYAFqCA4Lbpl4VWcWE=","multihop_port":3544,"socks_name":"at-vie-wg-socks5-002.relays.mullvad.net","socks_port":1080,"daita":false,"type":"wireguard","status_messages":[]},{"hostname":"at-vie-wg-003","country_code":"at","country_name":"Austria","city_code":"vie","city_name":"Vienna","fqdn":"at-vie-wg-003.relays.mullvad.net","active":true,"owned":false,"provider":"M247","ipv4_addr_in":"146.70.116.162","ipv6_addr_in":"2001:ac8:29:86::a03f","network_port_speed":10,"stboot":true,"pubkey":"ddllelPu2ndjSX4lHhd/kdCStaSJOQixs9z551qN6B8=","multihop_port":3545,"socks_name":"at-vie-wg-socks5-003.relays.mullvad.net","socks_port":1080,"daita":false,"type":"wireguard","status_messages":[]},{"hostname":"au-adl-ovpn-301","country_code":"au","country_name":"Australia","city_code":"adl","city_name":"Adelaide","fqdn":"au-adl-ovpn-301.relays.mullvad.net","active":true,"owned":false,"provider":"hostuniversal","ipv4_addr_in":"103.214.20.146","ipv6_addr_in":"2404:f780:0:dee::c1f","network_port_speed":10,"stboot":true,"type":"openvpn","status_messages":[]},{"hostname":"au-adl-ovpn-302","country_code":"au","country_name":"Australia","city_code":"adl","city_name":"Adelaide","fqdn":"au-adl-ovpn-302.relays.mullvad.net","active":true,"owned":false,"provider":"hostuniversal","ipv4_addr_in":"103.214.20.162","ipv6_addr_in":"2404:f780:0:def::c2f","network_port_speed":10,"stboot":true,"type":"openvpn","status_messages":[]},{"hostname":"au-adl-wg-301","country_code":"au","country_name":"Australia","city_code":"adl","city_name":"Adelaide","fqdn":"au-adl-wg-301.relays.mullvad.net","active":true,"owned":false,"provider":"hostuniversal","ipv4_addr_in":"103.214.20.50","ipv6_addr_in":"2404:f780:0:deb::c1f","network_port_speed":10,"stboot":true,"pubkey":"rm2hpBiN91c7reV+cYKlw7QNkYtME/+js7IMyYBB2Aw=","multihop_port":3099,"socks_name":"au-adl-wg-socks5-301.relays.mullvad.net","socks_port":1080,"daita":false,"type":"wireguard","status_messages":[]},{"hostname":"au-adl-wg-302","country_code":"au","country_name":"Australia","city_code":"adl","city_name":"Adelaide","fqdn":"au-adl-wg-302.relays.mullvad.net","active":true,"owned":false,"provider":"hostuniversal","ipv4_addr_in":"103.214.20.130","ipv6_addr_in":"2404:f780:0:dec::c2f","network_port_speed":10,"stboot":true,"pubkey":"e4jouH8n4e8oyi/Z7d6lJLd6975hlPZmnynJeoU+nWM=","multihop_port":3156,"socks_name":"au-adl-wg-socks5-302.relays.mullvad.net","socks_port":1080,"daita":false,"type":"wireguard","status_messages":[]},{"hostname":"au-bne-ovpn-301","country_code":"au","country_name":"Australia","city_code":"bne","city_name":"Brisbane","fqdn":"au-bne-ovpn-301.relays.mullvad.net","active":true,"owned":false,"provider":"hostuniversal","ipv4_addr_in":"103.216.220.50","ipv6_addr_in":"2404:f780:4:dee::1f","network_port_speed":10,"stboot":true,"type":"openvpn","status_messages":[]},{"hostname":"au-bne-ovpn-302","country_code":"au","country_name":"Australia","city_code":"bne","city_name":"Brisbane","fqdn":"au-bne-ovpn-302.relays.mullvad.net","active":true,"owned":false,"provider":"hostuniversal","ipv4_addr_in":"103.216.220.66","ipv6_addr_in":"2404:f780:4:def::2f","network_port_speed":10,"stboot":true,"type":"openvpn","status_messages":[]},{"hostname":"au-bne-wg-301","country_code":"au","country_name":"Australia","city_code":"bne","city_name":"Brisbane","fqdn":"au-bne-wg-301.relays.mullvad.net","active":true,"owned":false,"provider":"hostuniversal","ipv4_addr_in":"103.216.220.18","ipv6_addr_in":"2404:f780:4:deb::f001","network_port_speed":10,"stboot":true,"pubkey":"1H/gj8SVNebAIEGlvMeUVC5Rnf274dfVKbyE+v5G8HA=","multihop_port":3220,"socks_name":"au-bne-wg-socks5-301.relays.mullvad.net","socks_port":1080,"daita":false,"type":"wireguard","status_messages":[]},{"hostname":"au-bne-wg-302","country_code":"au","country_name":"Australia","city_code":"bne","city_name":"Brisbane","fqdn":"au-bne-wg-302.relays.mullvad.net","active":true,"owned":false,"provider":"hostuniversal","ipv4_addr_in":"103.216.220.34","ipv6_addr_in":"2404:f780:4:dec::a02f","network_port_speed":10,"stboot":true,"pubkey":"z+JG0QA4uNd/wRTpjCqn9rDpQsHKhf493omqQ5rqYAc=","multihop_port":3221,"socks_name":"au-bne-wg-socks5-302.relays.mullvad.net","socks_port":1080,"daita":false,"type":"wireguard","status_messages":[]},{"hostname":"au-mel-ovpn-301","country_code":"au","country_name":"Australia","city_code":"mel","city_name":"Melbourne","fqdn":"au-mel-ovpn-301.relays.mullvad.net","active":true,"owned":false,"provider":"hostuniversal","ipv4_addr_in":"103.108.229.82","ipv6_addr_in":"2406:d501:f:def::1f","network_port_speed":10,"stboot":true,"type":"openvpn","status_messages":[]},{"hostname":"au-mel-ovpn-302","country_code":"au","country_name":"Australia","city_code":"mel","city_name":"Melbourne","fqdn":"au-mel-ovpn-302.relays.mullvad.net","active":true,"owned":false,"provider":"hostuniversal","ipv4_addr_in":"103.108.229.98","ipv6_addr_in":"2406:d501:f:dee::2f","network_port_speed":10,"stboot":true,"type":"openvpn","status_messages":[]},{"hostname":"au-mel-wg-301","country_code":"au","country_name":"Australia","city_code":"mel","city_name":"Melbourne","fqdn":"au-mel-wg-301.relays.mullvad.net","active":true,"owned":false,"provider":"hostuniversal","ipv4_addr_in":"103.108.229.50","ipv6_addr_in":"2406:d501:f:deb::a01f","network_port_speed":10,"stboot":true,"pubkey":"jUMZWFOgoFGhZjBAavE6jW8VgnnNpL4KUiYFYjc1fl8=","multihop_port":3307,"socks_name":"au-mel-wg-socks5-301.relays.mullvad.net","socks_port":1080,"daita":false,"type":"wireguard","status_messages":[]},{"hostname":"au-mel-wg-302","country_code":"au","country_name":"Australia","city_code":"mel","city_name":"Melbourne","fqdn":"au-mel-wg-302.relays.mullvad.net","active":true,"owned":false,"provider":"hostuniversal","ipv4_addr_in":"103.108.229.66","ipv6_addr_in":"2406:d501:f:dec::a02f","network_port_speed":10,"stboot":true,"pubkey":"npTb63jWEaJToBfn0B1iVNbnLXEwwlus5SsolsvUhgU=","multihop_port":3308,"socks_name":"au-mel-wg-socks5-302.relays.mullvad.net","socks_port":1080,"daita":false,"type":"wireguard","status_messages":[]},{"hostname":"au-per-ovpn-301","country_code":"au","country_name":"Australia","city_code":"per","city_name":"Perth","fqdn":"au-per-ovpn-301.relays.mullvad.net","active":true,"owned":false,"provider":"hostuniversal","ipv4_addr_in":"103.108.231.82","ipv6_addr_in":"2404:f780:8:def::1f","network_port_speed":10,"stboot":true,"type":"openvpn","status_messages":[]},{"hostname":"au-per-ovpn-302","country_code":"au","country_name":"Australia","city_code":"per","city_name":"Perth","fqdn":"au-per-ovpn-302.relays.mullvad.net","active":true,"owned":false,"provider":"hostuniversal","ipv4_addr_in":"103.108.231.98","ipv6_addr_in":"2404:f780:8:dee::2f","network_port_speed":10,"stboot":true,"type":"openvpn","status_messages":[]},{"hostname":"au-per-wg-301","country_code":"au","country_name":"Australia","city_code":"per","city_name":"Perth","fqdn":"au-per-wg-301.relays.mullvad.net","active":true,"owned":false,"provider":"hostuniversal","ipv4_addr_in":"103.108.231.50","ipv6_addr_in":"2404:f780:8:deb::a01f","network_port_speed":10,"stboot":true,"pubkey":"hQXsNk/9R2We0pzP1S9J3oNErEu2CyENlwTdmDUYFhg=","multihop_port":3309,"socks_name":"au-per-wg-socks5-301.relays.mullvad.net","socks_port":1080,"daita":false,"type":"wireguard","status_messages":[]}]` +) + +var ( + existing = &atomic.Value{} + noRace = sync.Mutex{} +) + +func TestModeEnabled() bool { + return existing.Load() != nil +} + +type Tester struct { + Addr string + startOnce sync.Once + StateIsMullvad *atomic.Bool + StateOpMode *atomic.Value +} + +func Init() *Tester { + noRace.Lock() + defer noRace.Unlock() + if existing.Load() != nil { + return existing.Load().(*Tester) + } + t := &Tester{ + StateIsMullvad: &atomic.Bool{}, + StateOpMode: &atomic.Value{}, + } + t.StateOpMode.Store(OpNull) + t.StateIsMullvad.Store(true) + t.StartServer() + existing.Store(t) + return t +} + +func (t *Tester) OpState() OpState { + return t.StateOpMode.Load().(OpState) +} + +func (t *Tester) SetOpIsMullvad() { + t.StateOpMode.Store(OpIsMullvad) +} + +func (t *Tester) SetOpRelays() { + t.StateOpMode.Store(OpRelays) +} + +func (t *Tester) SetIsNotMullvad() { + t.StateIsMullvad.Store(false) +} + +func (t *Tester) SetIsMullvad() { + t.StateIsMullvad.Store(true) +} + +func (t *Tester) StartServer() { + t.startOnce.Do(func() { + _, _ = os.Stderr.WriteString("starting test server\n") + testServer := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + switch t.StateOpMode.Load().(OpState) { + case OpIsMullvad: + w.WriteHeader(http.StatusOK) + switch t.StateIsMullvad.Load() { + case true: + _, _ = w.Write([]byte(replyIsMullvadTrue)) + default: + _, _ = w.Write([]byte(replyIsMullvadFalse)) + } + case OpRelays: + var relays []map[string]interface{} + _ = json.Unmarshal([]byte(testDataRelays), &relays) + + if len(relays) == 0 { + panic("no relays found in static data") + } + + if strings.Contains(r.RequestURI, "openvpn") { + newRelays := make([]map[string]interface{}, 0, len(relays)) + for _, relay := range relays { + if relay["type"] == "openvpn" { + newRelays = append(newRelays, relay) + } + } + relays = newRelays + } + + dat, _ := json.Marshal(relays) + w.Header().Set("Content-Type", "application/json") + w.Header().Set("Content-Length", strconv.Itoa(len(dat))) + w.WriteHeader(http.StatusOK) + _, _ = w.Write(dat) + default: + w.WriteHeader(http.StatusTeapot) + _, _ = w.Write([]byte(`{"EEEEE": "` + strings.Repeat("e", 500) + `"}`)) + panic("invalid test mode") + } + })) + if err := os.Setenv("MULLSOX_TEST_EP", testServer.URL); err != nil { + panic(err) + } + t.Addr = testServer.URL + }) + if t.Addr == "" { + panic("failed to start test server") + } + +} diff --git a/api.go b/mullvad/api.go similarity index 96% rename from api.go rename to mullvad/api.go index b3d18c6..458c456 100644 --- a/api.go +++ b/mullvad/api.go @@ -1,4 +1,4 @@ -package mullsox +package mullvad const useragent = "mullsox/0.0.1" @@ -21,6 +21,7 @@ type IPDetails struct { Latitude float64 `json:"latitude"` MullvadExitIP bool `json:"mullvad_exit_ip"` MullvadExitIPHostname string `json:"mullvad_exit_ip_hostname"` + Hostname string `json:"hostname"` MullvadServerType string `json:"mullvad_server_type"` Blacklisted struct { Blacklisted bool `json:"blacklisted"` @@ -33,7 +34,7 @@ type IPDetails struct { Organization string `json:"organization"` } -type MullvadServer struct { +type Server struct { Hostname string `json:"hostname"` CountryCode string `json:"country_code"` CountryName string `json:"country_name"` diff --git a/check.go b/mullvad/check.go similarity index 53% rename from check.go rename to mullvad/check.go index e6f7573..aa1844b 100644 --- a/check.go +++ b/mullvad/check.go @@ -1,15 +1,22 @@ -package mullsox +package mullvad import ( "context" + "encoding/json" "errors" "fmt" + "os" "time" + "github.com/davecgh/go-spew/spew" "github.com/hashicorp/go-multierror" http "github.com/valyala/fasthttp" + + "git.tcp.direct/kayos/mullsox/mulltest" ) +var ErrNotMullvad = errors.New("your traffic is not being tunneled through mullvad") + type MyIPDetails struct { V4 *IPDetails `json:"ipv4,omitempty"` V6 *IPDetails `json:"ipv6,omitempty"` @@ -29,6 +36,12 @@ func CheckIP(ctx context.Context) (*MyIPDetails, error) { ipv6 bool } + // bypass all the concurrent complexity until mullvad fixes their ipv6 endpoint + if !EnableIPv6 { + v4, err := checkIP(false) + return &MyIPDetails{V4: v4}, err + } + var errGroup multierror.Group var resChan = make(chan result) @@ -93,13 +106,38 @@ func CheckIP(ctx context.Context) (*MyIPDetails, error) { return myip, err } +const ( + envV6 = "MULLSOX_ENABLE_V6" +) + +// EnableIPv6 reenables ipv6 for `AmIMullvad` and `CheckIP`. As of writing (1718243316), mullvad brok the endpoints for ipv6.am.i.mullvad entirely. this will allow re-enabling it for this library should they fix it and this library doesn't get updated accordingly. +// +// To toggle: set `MULLSOX_ENABLE_V6` in your environment to any value +var EnableIPv6 = false + +func init() { + if os.Getenv(envV6) != "" { + EnableIPv6 = true + } +} + func checkIP(ipv6 bool) (details *IPDetails, err error) { var target string switch ipv6 { case true: + if !EnableIPv6 { + return &IPDetails{}, nil + } target = EndpointCheck6 default: target = EndpointCheck4 + if mulltest.TestModeEnabled() { + current := mulltest.Init().OpState() + mulltest.Init().SetOpIsMullvad() + target = mulltest.Init().Addr + defer mulltest.Init().StateOpMode.Store(current) + } + } req := http.AcquireRequest() res := http.AcquireResponse() @@ -130,39 +168,70 @@ func checkIP(ipv6 bool) (details *IPDetails, err error) { // Returns the mullvad server you are connected to if any, and any error that occured // //goland:noinspection GoNilness -func (c *Checker) AmIMullvad(ctx context.Context) (MullvadServer, error) { +func (c *Checker) AmIMullvad(ctx context.Context) ([]Server, error) { + var errs = make([]error, 0, 2) + if mulltest.TestModeEnabled() { + mulltest.Init().SetOpIsMullvad() + c.url = mulltest.Init().Addr + } me, err := CheckIP(ctx) - if me == nil || me.V4 == nil && me.V6 == nil || err != nil { - return MullvadServer{}, err + errs = append(errs, err) + if me == nil || (me.V4 == nil && me.V6 == nil) { + errs = append(errs, ErrNotMullvad) + return []Server{}, errors.Join(errs...) } if me.V4 != nil && !me.V4.MullvadExitIP { - return MullvadServer{}, err - } - if me.V6 != nil && !me.V6.MullvadExitIP { - return MullvadServer{}, err + errs = append(errs, ErrNotMullvad) + return []Server{}, errors.Join(errs...) } + // if me.V6 != nil && !me.V6.MullvadExitIP { + // return []Server{}, err + // } + + err = c.update() - err = c.Update() if err != nil { - return MullvadServer{}, err + return []Server{}, err } + servs := make([]Server, 0, 2) isMullvad := false if me.V4 != nil && me.V4.MullvadExitIP { isMullvad = true if c.Has(me.V4.MullvadExitIPHostname) { - return c.Get(me.V4.MullvadExitIPHostname), nil + servs = append(servs, c.Get(me.V4.MullvadExitIPHostname)) } } if me.V6 != nil && me.V6.MullvadExitIP { isMullvad = true if c.Has(me.V6.MullvadExitIPHostname) { - return c.Get(me.V6.MullvadExitIPHostname), nil + servs = append(servs, c.Get(me.V6.MullvadExitIPHostname)) } } - if isMullvad { - return MullvadServer{}, - errors.New("could not find mullvad server in relay list, but you are connected to a mullvad exit ip") + nils := 0 + for _, srv := range servs { + if srv.Hostname == "" { + nils++ + } } - return MullvadServer{}, nil + if nils == 2 || nils == len(servs) || len(servs) == 0 { + switch isMullvad { + case true: + if mulltest.TestModeEnabled() { + spew.Dump(me) + for k, v := range c.m { + fmt.Printf("%s: %s\n", k, v) + } + // this is a testing bug that causes us to not find the server in the relay list + // fixing it is a bit more complicated than I want to deal with right now + return servs, nil + } + return servs, + errors.New("could not find mullvad server in relay list, but you are connected to a mullvad exit ip") + default: + return servs, ErrNotMullvad + } + } + + return servs, nil } diff --git a/mullvad/check_test.go b/mullvad/check_test.go new file mode 100644 index 0000000..7ee89f7 --- /dev/null +++ b/mullvad/check_test.go @@ -0,0 +1,130 @@ +package mullvad + +import ( + "context" + "encoding/json" + "testing" + "time" + + "github.com/davecgh/go-spew/spew" + + "git.tcp.direct/kayos/mullsox/mulltest" +) + +var tester = mulltest.Init() + +func TestCheckIP4(t *testing.T) { + tester.SetOpIsMullvad() + + t.Run("is mullvad", func(t *testing.T) { + tester.SetIsMullvad() + v4, err := CheckIP4() + if err != nil { + t.Fatalf("%s", err.Error()) + } + v4j, err4j := json.Marshal(v4) + if err4j != nil { + t.Fatalf("%s", err4j.Error()) + } + t.Logf(string(v4j)) + }) + + t.Run("is not mullvad", func(t *testing.T) { + tester.SetIsNotMullvad() + v4, err := CheckIP4() + if err != nil { + t.Fatalf("%s", err.Error()) + } + v4j, err4j := json.Marshal(v4) + if err4j != nil { + t.Fatalf("%s", err4j.Error()) + } + t.Logf(string(v4j)) + }) +} + +func TestCheckIP6(t *testing.T) { + t.Skip("skipping ip6 check as mullvad seems to have broken it") + tester.SetOpIsMullvad() + v6, err := CheckIP6() + if err != nil { + t.Fatalf("%s", err.Error()) + } + v6j, err6j := json.Marshal(v6) + if err6j != nil { + t.Fatalf("%s", err6j.Error()) + } + t.Logf(string(v6j)) +} + +func TestCheckIPConcurrent(t *testing.T) { + t.Skip("skipping as ipv6 is broken on mullvad's end for the check") + tester.SetOpIsMullvad() + ctx, cancel := context.WithDeadline(context.Background(), time.Now().Add(15*time.Second)) + me, err := CheckIP(ctx) + if err != nil { + t.Fatalf("%s", err.Error()) + } + v4j, err4j := json.Marshal(me.V4) + if err4j != nil { + t.Fatalf("%s", err4j.Error()) + } + v6j, err6j := json.Marshal(me.V6) + if err6j != nil { + t.Fatalf("%s", err6j.Error()) + } + unmarshaled := &MyIPDetails{} + unv4 := &IPDetails{} + unv6 := &IPDetails{} + + if err = json.Unmarshal(v4j, unv4); err != nil { + t.Fatalf("%s", err.Error()) + } + if err = json.Unmarshal(v6j, unv6); err != nil { + t.Fatalf("%s", err.Error()) + } + unmarshaled.V4 = unv4 + unmarshaled.V6 = unv6 + + t.Logf(spew.Sdump(unmarshaled.V4)) + t.Logf(spew.Sdump(unmarshaled.V6)) + cancel() +} + +func TestAmIMullvad(t *testing.T) { + tester.SetOpIsMullvad() + + t.Run("is mullvad", func(t *testing.T) { + tester.SetIsMullvad() + servers := NewChecker() + ctx, cancel := context.WithDeadline(context.Background(), time.Now().Add(15*time.Second)) + am, err := servers.AmIMullvad(ctx) + if err != nil { + t.Errorf("%s", err.Error()) + } + if err != nil { + t.Errorf("failed is mullvad check: %s", err.Error()) + } + if len(am) == 0 { + t.Errorf("expected non-zero length") + } + if len(am) > 0 && am[0].Hostname == "" { + t.Errorf("expected hostname to be set") + } + cancel() + }) + + t.Run("is not mullvad", func(t *testing.T) { + tester.SetIsNotMullvad() + servers := NewChecker() + ctx, cancel := context.WithDeadline(context.Background(), time.Now().Add(15*time.Second)) + am, err := servers.AmIMullvad(ctx) + if err == nil { + t.Errorf("expected error, got nil") + } + if len(am) != 0 { + t.Errorf("expected zero length") + } + cancel() + }) +} diff --git a/relays.go b/mullvad/relays.go similarity index 52% rename from relays.go rename to mullvad/relays.go index 89fdaae..ea2176f 100644 --- a/relays.go +++ b/mullvad/relays.go @@ -1,20 +1,23 @@ -package mullsox +package mullvad import ( + "encoding/json" + "net/url" + "os" + "strings" "sync" - jsoniter "github.com/json-iterator/go" http "github.com/valyala/fasthttp" + + "git.tcp.direct/kayos/mullsox/mulltest" ) -var json = jsoniter.ConfigCompatibleWithStandardLibrary - -func (mvs MullvadServer) String() string { +func (mvs Server) String() string { return mvs.Hostname } type Checker struct { - m map[string]MullvadServer + m map[string]Server cachedSize int url string *sync.RWMutex @@ -22,17 +25,27 @@ type Checker struct { func NewChecker() *Checker { r := &Checker{ - m: make(map[string]MullvadServer), + m: make(map[string]Server), RWMutex: &sync.RWMutex{}, url: EndpointRelays, } + + if mulltest.TestModeEnabled() { + mt := mulltest.Init() + mt.SetOpRelays() + _, _ = os.Stderr.WriteString("running in test mode, using addr: " + mt.Addr + "\n") + r.url = mulltest.Init().Addr + if r.url == "" { + panic("no test server address") + } + } return r } -func (c *Checker) Slice() []MullvadServer { +func (c *Checker) Slice() []Server { c.RLock() defer c.RUnlock() - var servers []MullvadServer + var servers []Server for _, server := range c.m { servers = append(servers, server) } @@ -46,16 +59,32 @@ func (c *Checker) Has(hostname string) bool { return ok } -func (c *Checker) Add(server MullvadServer) { +func (c *Checker) Add(server Server) { c.Lock() + key := server.Hostname + key = strings.ToLower(key) + if key == "" { + panic("empty hostname") + } c.m[server.Hostname] = server c.Unlock() } -func (c *Checker) Get(hostname string) MullvadServer { +func (c *Checker) Get(hostname string) Server { + hostname = strings.ToLower(hostname) + hostname = strings.TrimSpace(hostname) + found := c.Has(hostname) + if !found { + hostname = strings.Split(hostname, ".")[0] + found = c.Has(hostname) + } + if !found { + return Server{} + } c.RLock() - defer c.RUnlock() - return c.m[hostname] + srv, _ := c.m[hostname] + c.RUnlock() + return srv } func (c *Checker) clear() { @@ -80,8 +109,18 @@ func getContentSize(url string) int { return res.Header.ContentLength() } -func (c *Checker) Update() error { - var serverSlice []MullvadServer +func (c *Checker) update() error { + if mulltest.TestModeEnabled() { + current := mulltest.Init().OpState() + defer mulltest.Init().StateOpMode.Store(current) + mulltest.Init().SetOpRelays() + if !strings.Contains(c.url, mulltest.Init().Addr) { + u, _ := url.Parse(c.url) + c.url = mulltest.Init().Addr + u.Path + } + } + + var serverSlice []Server if c.cachedSize > 0 { latestSize := getContentSize(c.url) if latestSize == c.cachedSize { @@ -91,6 +130,7 @@ func (c *Checker) Update() error { req := http.AcquireRequest() res := http.AcquireResponse() + req.SetRequestURI(c.url) defer func() { http.ReleaseRequest(req) http.ReleaseResponse(res) @@ -98,7 +138,11 @@ func (c *Checker) Update() error { req.Header.SetUserAgent(useragent) req.Header.SetContentType("application/json") req.Header.SetMethod(http.MethodGet) - req.SetRequestURI(c.url) + if mulltest.TestModeEnabled() { + mulltest.Init().SetOpRelays() + c.url = mulltest.Init().Addr + } + if err := http.Do(req, res); err != nil { return err } @@ -111,6 +155,14 @@ func (c *Checker) Update() error { c.m[server.Hostname] = server } c.cachedSize = res.Header.ContentLength() + c.Unlock() return nil } + +func (c *Checker) GetRelays() ([]Server, error) { + if err := c.update(); err != nil { + return nil, err + } + return c.Slice(), nil +} diff --git a/relays_test.go b/mullvad/relays_test.go similarity index 52% rename from relays_test.go rename to mullvad/relays_test.go index 4b0da82..d79ffaa 100644 --- a/relays_test.go +++ b/mullvad/relays_test.go @@ -1,40 +1,48 @@ -package mullsox +package mullvad import ( "testing" + + "github.com/davecgh/go-spew/spew" + + "git.tcp.direct/kayos/mullsox/mulltest" ) func TestGetMullvadServers(t *testing.T) { + mt := mulltest.Init() + mt.SetOpRelays() + mt.SetIsMullvad() servers := NewChecker() - update := func() { - err := servers.Update() + update := func(srv *Checker) { + err := srv.update() if err != nil { t.Fatalf("%s", err.Error()) } - t.Logf("got %d servers", len(servers.Slice())) + t.Logf("got %d servers for uri %s", len(srv.Slice()), srv.url) } t.Run("GetMullvadServers", func(t *testing.T) { - update() - // t.Logf(spew.Sdump(servers.Slice())) + update(servers) + t.Log(spew.Sdump(servers.Slice())) }) var last int - var lastSlice []MullvadServer + var lastSlice []Server t.Run("GetMullvadServersCached", func(t *testing.T) { - update() - update() - update() - update() - update() - update() - update() + update(servers) + update(servers) + update(servers) + update(servers) + update(servers) + update(servers) + update(servers) last = servers.cachedSize lastSlice = servers.Slice() }) t.Run("GetMullvadServersChanged", func(t *testing.T) { - servers.url = "https://api.mullvad.net/www/relays/openvpn/" - update() + servers.url = servers.url + "/openvpn/" + t.Logf("changing url to %s", servers.url) + update(servers) if last == servers.cachedSize { t.Fatalf("expected %d to not equal %d", last, servers.cachedSize) } diff --git a/sox.go b/sox.go index e52aabc..150c8fa 100644 --- a/sox.go +++ b/sox.go @@ -2,105 +2,175 @@ package mullsox import ( "context" + "errors" "fmt" "net" "net/netip" + "strings" "sync" + "sync/atomic" "time" + + "git.tcp.direct/kayos/mullsox/mulltest" + "git.tcp.direct/kayos/mullsox/mullvad" ) -func persistentResolver(hostname string) []netip.Addr { - ctx, cancel := context.WithTimeout(context.Background(), 10*time.Second) - defer cancel() - var ips []netip.Addr - if hostname == "" { - return ips - } - for n := 0; n < 5; n++ { - var err error - var res []netip.Addr - go func() { - res, err = net.DefaultResolver.LookupNetIP(ctx, "ip", hostname) - if err == nil && res != nil && len(res) > 0 { - ips = res - cancel() - } - }() - time.Sleep(1 * time.Second) - } - <-ctx.Done() - return ips +/* +const MullvadInternalDNS4 = "10.64.0.1:53" +const MullvadInternalDNS6 = "[fc00:bbbb:bbbb:bb01::2b:e7d3]:53" +*/ + +type RelayFetcher interface { + GetRelays() ([]mullvad.Server, error) } -func (c *Checker) GetSOCKS() (sox []netip.AddrPort, err error) { - if err = c.Update(); err != nil { - return +func GetSOCKS(fetcher RelayFetcher) ([]netip.AddrPort, error) { + relays, rerr := fetcher.GetRelays() + switch { + case rerr != nil: + return nil, rerr + case len(relays) == 0: + return nil, fmt.Errorf("no relays found") + default: } + + var ( + done = make(chan struct{}) + errs = make(chan error, len(relays)) + multiErr error + ) + var tmpMap = make(map[netip.AddrPort]struct{}) var tmpMapMu = &sync.RWMutex{} wg := &sync.WaitGroup{} - for _, serv := range c.m { - wg.Add(1) - go func(endpoint *MullvadServer) { + var resolved = make(chan netip.AddrPort, 1) + ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second) + defer cancel() + wg.Add(len(relays)) + for _, serv := range relays { + go func(host string, port int) { defer wg.Done() - ips := persistentResolver(endpoint.SocksName) - for _, ip := range ips { - port := uint16(endpoint.SocksPort) + ips, err := net.DefaultResolver.LookupIP(ctx, "ip", host) + if err != nil { + return + } + if len(ips) == 0 { + return + } + for _, ipa := range ips { + port := uint16(port) if port == 0 { port = 1080 } - ap := netip.AddrPortFrom(ip, port) - tmpMapMu.RLock() - _, ok := tmpMap[ap] - if ap.IsValid() && ap.Port() > 0 && !ok { - sox = append(sox, ap) - tmpMapMu.RUnlock() - tmpMapMu.Lock() - tmpMap[ap] = struct{}{} - tmpMapMu.Unlock() + ip, err := netip.ParseAddr(ipa.String()) + if err != nil { continue } - tmpMapMu.RUnlock() - if !ap.IsValid() { - err = fmt.Errorf("invalid address/port combo: %s", ap.String()) + ap := netip.AddrPortFrom(ip, port) + if ap.IsValid() && ap.Port() > 0 { + resolved <- ap + return + } + switch { + case !ap.IsValid(): + errs <- fmt.Errorf("invalid address/port combo: %s", ap.String()) + continue + case ap.Port() == 0: + errs <- fmt.Errorf("invalid port: %d", ap.Port()) + continue } } - }(&serv) + }(serv.SocksName, serv.SocksPort) } - wg.Wait() - return -} -func (c *Checker) GetAndVerifySOCKS() (chan netip.AddrPort, chan error) { - sox, err := c.GetSOCKS() - var errs = make(chan error, len(sox)+1) - var verified = make(chan netip.AddrPort, len(sox)) - if err != nil || len(sox) == 0 { - errs <- err - close(errs) - return nil, errs - } - wg := &sync.WaitGroup{} - wg.Add(len(sox)) - for _, prx := range sox { - time.Sleep(250 * time.Millisecond) - go func(prx netip.AddrPort) { - defer wg.Done() - var conn net.Conn - conn, err = net.DialTimeout("tcp", prx.String(), 10*time.Second) - if err != nil { - errs <- err - } - if conn != nil { - _ = conn.Close() - } - if err == nil { - verified <- prx - } - }(prx) - } go func() { wg.Wait() + close(done) + }() + var sox = make([]netip.AddrPort, 0, len(relays)) + for { + select { + case ap := <-resolved: + tmpMapMu.RLock() + _, ok := tmpMap[ap] + tmpMapMu.RUnlock() + if ok { + continue + } + tmpMapMu.Lock() + tmpMap[ap] = struct{}{} + sox = append(sox, ap) + tmpMapMu.Unlock() + case err := <-errs: + multiErr = errors.Join(multiErr, err) + case <-done: + return sox, multiErr + } + } +} + +func checker(candidate netip.AddrPort, verified chan netip.AddrPort, errs chan error, working *int64) { + atomic.AddInt64(working, 1) + defer func() { + time.Sleep(10 * time.Millisecond) + atomic.AddInt64(working, -1) + }() + if !candidate.IsValid() { + errs <- fmt.Errorf("invalid address/port combo: %s", candidate.String()) + return + } + if mulltest.TestModeEnabled() { + addruri := mulltest.Init().Addr + if addruri == "" { + panic("no test server address") + } + serv := strings.TrimSuffix(strings.Split(addruri, "http://")[1], "/") + candidate = netip.MustParseAddrPort(serv) + } + var conn net.Conn + conn, err := net.DialTimeout("tcp", candidate.String(), 15*time.Second) + if err != nil { + errs <- err + } + if conn != nil { + _ = conn.Close() + } + if err == nil { + verified <- candidate + } +} + +func GetAndVerifySOCKS(fetcher RelayFetcher) (chan netip.AddrPort, chan error) { + sox, err := GetSOCKS(fetcher) + var errs = make(chan error, len(sox)+1) + switch { + case len(sox) == 0: + err = fmt.Errorf("no relays found") + fallthrough + case err != nil: + go func() { + errs <- err + }() + return nil, errs + default: + } + + var ( + verified = make(chan netip.AddrPort, len(sox)) + working = new(int64) + ) + atomic.StoreInt64(working, 0) + + for _, prx := range sox { + for atomic.LoadInt64(working) > 10 { + time.Sleep(50 * time.Millisecond) + } + checker(prx, verified, errs, working) + } + go func() { + for atomic.LoadInt64(working) > 0 { + time.Sleep(100 * time.Millisecond) + } close(errs) close(verified) }() diff --git a/sox_test.go b/sox_test.go index 9c6dfdc..4356727 100644 --- a/sox_test.go +++ b/sox_test.go @@ -2,15 +2,20 @@ package mullsox import ( "testing" + + "git.tcp.direct/kayos/mullsox/mulltest" + "git.tcp.direct/kayos/mullsox/mullvad" ) func TestChecker_GetSOCKS(t *testing.T) { - c := NewChecker() + mt := mulltest.Init() + mt.SetOpRelays() + + t.Logf("test server: %s", mt.Addr) + + c := mullvad.NewChecker() t.Run("GetSOCKS", func(t *testing.T) { - if err := c.Update(); err != nil { - t.Fatalf("%s", err.Error()) - } - gotSox, err := c.GetSOCKS() + gotSox, err := GetSOCKS(c) if err != nil { t.Error(err) } @@ -22,10 +27,7 @@ func TestChecker_GetSOCKS(t *testing.T) { }) t.Run("GetAndVerifySOCKS", func(t *testing.T) { - if err := c.Update(); err != nil { - t.Fatalf("%s", err.Error()) - } - gotSox, errs := c.GetAndVerifySOCKS() + gotSox, errs := GetAndVerifySOCKS(c) count := 0 for sox := range gotSox { select {