Skip to content

Commit

Permalink
refactor: extract IP info package and add ASN support (#154)
Browse files Browse the repository at this point in the history
  • Loading branch information
fortuna authored May 31, 2023
1 parent 2de0224 commit e1eedc0
Show file tree
Hide file tree
Showing 15 changed files with 505 additions and 167 deletions.
22 changes: 13 additions & 9 deletions cmd/outline-ss-server/main.go
Original file line number Diff line number Diff line change
Expand Up @@ -18,7 +18,6 @@ import (
"container/list"
"flag"
"fmt"
"io/ioutil"
"net"
"net/http"
"os"
Expand All @@ -29,13 +28,13 @@ import (

"github.com/Jigsaw-Code/outline-internal-sdk/transport"
"github.com/Jigsaw-Code/outline-internal-sdk/transport/shadowsocks"
"github.com/Jigsaw-Code/outline-ss-server/ipinfo"
"github.com/Jigsaw-Code/outline-ss-server/service"
"github.com/Jigsaw-Code/outline-ss-server/service/metrics"
"github.com/op/go-logging"
"github.com/oschwald/geoip2-golang"
"github.com/prometheus/client_golang/prometheus"
"github.com/prometheus/client_golang/prometheus/promhttp"
"golang.org/x/crypto/ssh/terminal"
"golang.org/x/term"
"gopkg.in/yaml.v2"
)

Expand All @@ -52,7 +51,7 @@ const defaultNatTimeout time.Duration = 5 * time.Minute

func init() {
var prefix = "%{level:.1s}%{time:2006-01-02T15:04:05.000Z07:00} %{pid} %{shortfile}]"
if terminal.IsTerminal(int(os.Stderr.Fd())) {
if term.IsTerminal(int(os.Stderr.Fd())) {
// Add color only if the output is the terminal
prefix = strings.Join([]string{"%{color}", prefix, "%{color:reset}"}, "")
}
Expand All @@ -77,11 +76,13 @@ type SSServer struct {
func (s *SSServer) startPort(portNum int) error {
listener, err := net.ListenTCP("tcp", &net.TCPAddr{Port: portNum})
if err != nil {
//lint:ignore ST1005 Shadowsocks is capitalized.
return fmt.Errorf("Shadowsocks TCP service failed to start on port %v: %w", portNum, err)
}
logger.Infof("Shadowsocks TCP service listening on %v", listener.Addr().String())
packetConn, err := net.ListenUDP("udp", &net.UDPAddr{Port: portNum})
if err != nil {
//lint:ignore ST1005 Shadowsocks is capitalized.
return fmt.Errorf("Shadowsocks UDP service failed to start on port %v: %w", portNum, err)
}
logger.Infof("Shadowsocks UDP service listening on %v", packetConn.LocalAddr().String())
Expand Down Expand Up @@ -111,10 +112,12 @@ func (s *SSServer) removePort(portNum int) error {
udpErr := port.packetConn.Close()
delete(s.ports, portNum)
if tcpErr != nil {
//lint:ignore ST1005 Shadowsocks is capitalized.
return fmt.Errorf("Shadowsocks TCP service on port %v failed to stop: %w", portNum, tcpErr)
}
logger.Infof("Shadowsocks TCP service on port %v stopped", portNum)
if udpErr != nil {
//lint:ignore ST1005 Shadowsocks is capitalized.
return fmt.Errorf("Shadowsocks UDP service on port %v failed to stop: %w", portNum, udpErr)
}
logger.Infof("Shadowsocks UDP service on port %v stopped", portNum)
Expand Down Expand Up @@ -211,7 +214,7 @@ type Config struct {

func readConfig(filename string) (*Config, error) {
config := Config{}
configData, err := ioutil.ReadFile(filename)
configData, err := os.ReadFile(filename)
if err != nil {
return nil, fmt.Errorf("failed to read config: %w", err)
}
Expand Down Expand Up @@ -266,17 +269,18 @@ func main() {
logger.Infof("Prometheus metrics available at http://%v/metrics", flags.MetricsAddr)
}

var ipCountryDB *geoip2.Reader
var ip2info *ipinfo.MMDBIPInfoMap
var err error
if flags.IPCountryDB != "" {
logger.Infof("Using IP-Country database at %v", flags.IPCountryDB)
ipCountryDB, err = geoip2.Open(flags.IPCountryDB)
ip2info, err := ipinfo.NewMMDBIPInfoMap(flags.IPCountryDB, "")
if err != nil {
logger.Fatalf("Could not open geoip database at %v: %v. Aborting", flags.IPCountryDB, err)
}
defer ipCountryDB.Close()
defer ip2info.Close()
}
m := metrics.NewPrometheusShadowsocksMetrics(ipCountryDB, prometheus.DefaultRegisterer)

m := metrics.NewPrometheusShadowsocksMetrics(ip2info, prometheus.DefaultRegisterer)
m.SetBuildInfo(version)
_, err = RunSSServer(flags.ConfigFile, flags.natTimeout, m, flags.replayHistory)
if err != nil {
Expand Down
2 changes: 1 addition & 1 deletion go.mod
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,7 @@ require (
github.com/shadowsocks/go-shadowsocks2 v0.1.5
github.com/stretchr/testify v1.8.2
golang.org/x/crypto v0.7.0
golang.org/x/term v0.6.0
gopkg.in/yaml.v2 v2.4.0
)

Expand Down Expand Up @@ -160,7 +161,6 @@ require (
golang.org/x/oauth2 v0.0.0-20220722155238-128564f6959c // indirect
golang.org/x/sync v0.0.0-20220722155255-886fb9371eb4 // indirect
golang.org/x/sys v0.6.0 // indirect
golang.org/x/term v0.6.0 // indirect
golang.org/x/text v0.8.0 // indirect
golang.org/x/time v0.0.0-20220722155302-e5dcc9cfc0b9 // indirect
golang.org/x/xerrors v0.0.0-20220609144429-65e65417b02f // indirect
Expand Down
2 changes: 0 additions & 2 deletions go.sum
Original file line number Diff line number Diff line change
Expand Up @@ -155,8 +155,6 @@ github.com/BurntSushi/xgb v0.0.0-20160522181843-27f122750802/go.mod h1:IVnqGOEym
github.com/DataDog/datadog-go v3.2.0+incompatible/go.mod h1:LButxg5PwREeZtORoXG3tL4fMGNddJ+vMq1mwgfaqoQ=
github.com/DataDog/zstd v1.4.5 h1:EndNeuB0l9syBZhut0wns3gV1hL8zX8LIu6ZiVHWLIQ=
github.com/GoogleCloudPlatform/cloudsql-proxy v1.31.2/go.mod h1:qR6jVnZTKDCW3j+fC9mOEPHm++1nKDMkqbbkD6KNsfo=
github.com/Jigsaw-Code/outline-internal-sdk v0.0.0-20230502182149-b8f111a1cdb2 h1:6i/9okipiPuLDEjvwWpCA/ZpoICJqUFBs1FVzJpfIVA=
github.com/Jigsaw-Code/outline-internal-sdk v0.0.0-20230502182149-b8f111a1cdb2/go.mod h1:vxtE3esaFy5UG6TnipLyWx0esUQBy9LBXHLQx+SoER8=
github.com/Jigsaw-Code/outline-internal-sdk v0.0.0-20230522235223-1b323ea1d667 h1:msZNpaFAjRTvVi5q2yW1PJ4MmU4/7anob2IZYtDZQJw=
github.com/Jigsaw-Code/outline-internal-sdk v0.0.0-20230522235223-1b323ea1d667/go.mod h1:vxtE3esaFy5UG6TnipLyWx0esUQBy9LBXHLQx+SoER8=
github.com/Knetic/govaluate v3.0.1-0.20171022003610-9aa49832a739+incompatible/go.mod h1:r7JcOSlj0wfOMncg0iLm8Leh48TZaKVeNIfJntJ2wa0=
Expand Down
21 changes: 11 additions & 10 deletions internal/integration_test/integration_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -25,6 +25,7 @@ import (

"github.com/Jigsaw-Code/outline-internal-sdk/transport"
"github.com/Jigsaw-Code/outline-internal-sdk/transport/shadowsocks"
"github.com/Jigsaw-Code/outline-ss-server/ipinfo"
onet "github.com/Jigsaw-Code/outline-ss-server/net"
"github.com/Jigsaw-Code/outline-ss-server/service"
"github.com/Jigsaw-Code/outline-ss-server/service/metrics"
Expand Down Expand Up @@ -166,7 +167,7 @@ type statusMetrics struct {
statuses []string
}

func (m *statusMetrics) AddClosedTCPConnection(clientInfo metrics.ClientInfo, accessKey, status string, data metrics.ProxyMetrics, duration time.Duration) {
func (m *statusMetrics) AddClosedTCPConnection(clientInfo ipinfo.IPInfo, accessKey, status string, data metrics.ProxyMetrics, duration time.Duration) {
m.Lock()
m.statuses = append(m.statuses, status)
m.Unlock()
Expand Down Expand Up @@ -224,28 +225,27 @@ func TestRestrictedAddresses(t *testing.T) {

// Metrics about one UDP packet.
type udpRecord struct {
clientInfo metrics.ClientInfo
clientInfo ipinfo.IPInfo
accessKey, status string
in, out int
}

// Fake metrics implementation for UDP
type fakeUDPMetrics struct {
metrics.ShadowsocksMetrics
fakeInfo metrics.ClientInfo
up, down []udpRecord
natAdded int
}

var _ metrics.ShadowsocksMetrics = (*fakeUDPMetrics)(nil)

func (m *fakeUDPMetrics) GetClientInfo(addr net.Addr) (metrics.ClientInfo, error) {
return m.fakeInfo, nil
func (m *fakeUDPMetrics) GetIPInfo(ip net.IP) (ipinfo.IPInfo, error) {
return ipinfo.IPInfo{CountryCode: "QQ"}, nil
}
func (m *fakeUDPMetrics) AddUDPPacketFromClient(clientInfo metrics.ClientInfo, accessKey, status string, clientProxyBytes, proxyTargetBytes int) {
func (m *fakeUDPMetrics) AddUDPPacketFromClient(clientInfo ipinfo.IPInfo, accessKey, status string, clientProxyBytes, proxyTargetBytes int) {
m.up = append(m.up, udpRecord{clientInfo, accessKey, status, clientProxyBytes, proxyTargetBytes})
}
func (m *fakeUDPMetrics) AddUDPPacketFromTarget(clientInfo metrics.ClientInfo, accessKey, status string, targetProxyBytes, proxyClientBytes int) {
func (m *fakeUDPMetrics) AddUDPPacketFromTarget(clientInfo ipinfo.IPInfo, accessKey, status string, targetProxyBytes, proxyClientBytes int) {
m.down = append(m.down, udpRecord{clientInfo, accessKey, status, targetProxyBytes, proxyClientBytes})
}
func (m *fakeUDPMetrics) AddUDPNatEntry() {
Expand All @@ -268,7 +268,7 @@ func TestUDPEcho(t *testing.T) {
if err != nil {
t.Fatal(err)
}
testMetrics := &fakeUDPMetrics{fakeInfo: metrics.ClientInfo{CountryCode: "QQ"}}
testMetrics := &fakeUDPMetrics{}
proxy := service.NewPacketHandler(time.Hour, cipherList, testMetrics)
proxy.SetTargetIPValidator(allowAll)
done := make(chan struct{})
Expand Down Expand Up @@ -326,7 +326,8 @@ func TestUDPEcho(t *testing.T) {
t.Errorf("Wrong number of packets sent: %v", testMetrics.up)
} else {
record := testMetrics.up[0]
if record.clientInfo.CountryCode != "QQ" ||
require.Equal(t, "XL", record.clientInfo.CountryCode.String())
if record.clientInfo.CountryCode != "XL" ||
record.accessKey != keyID ||
record.status != "OK" ||
record.in <= record.out ||
Expand All @@ -338,7 +339,7 @@ func TestUDPEcho(t *testing.T) {
t.Errorf("Wrong number of packets received: %v", testMetrics.down)
} else {
record := testMetrics.down[0]
if record.clientInfo.CountryCode != "QQ" ||
if record.clientInfo.CountryCode != "XL" ||
record.accessKey != keyID ||
record.status != "OK" ||
record.in != N ||
Expand Down
89 changes: 89 additions & 0 deletions ipinfo/ipinfo.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,89 @@
// Copyright 2023 Jigsaw Operations LLC
//
// Licensed under the Apache License, Version 2.0 (the "License");
// you may not use this file except in compliance with the License.
// You may obtain a copy of the License at
//
// https://www.apache.org/licenses/LICENSE-2.0
//
// Unless required by applicable law or agreed to in writing, software
// distributed under the License is distributed on an "AS IS" BASIS,
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
// See the License for the specific language governing permissions and
// limitations under the License.

package ipinfo

import (
"errors"
"fmt"
"net"
)

type IPInfoMap interface {
GetIPInfo(net.IP) (IPInfo, error)
}

type IPInfo struct {
CountryCode CountryCode
ASN int
}

type CountryCode string

func (cc CountryCode) String() string {
return string(cc)
}

const (
// Codes in the X* range are reserved to be user-assigned.
// See https://en.wikipedia.org/wiki/ISO_3166-1_alpha-2#Decoding_table
errParseAddr CountryCode = "XA"
localLocation CountryCode = "XL"
errDbLookupError CountryCode = "XD"
// Code "ZZ" is also used by Unicode as unknown country.
unknownLocation CountryCode = "ZZ"
)

// GetIPInfoFromAddr is a helper function to extract the IP address from the [net.Addr]
// and call [IPInfoMap].GetIPInfo.
// It uses special country codes to indicate errors:
// - "XA": failed to extract the IP from the address ("A" is for "Address").
// - "XL": IP is not global ("L" is for "Local").
// - "XD": database error looking up the country code ("D" is for "DB").
// - "ZZ": lookup returned an empty country code (same as the Unicode unknown location).
func GetIPInfoFromAddr(ip2info IPInfoMap, addr net.Addr) (IPInfo, error) {
var info IPInfo
if ip2info == nil {
// Location is disabled. return empty info.
return info, nil
}

if addr == nil {
info.CountryCode = errParseAddr
return info, fmt.Errorf("address cannot be nil")
}
hostname, _, err := net.SplitHostPort(addr.String())
if err != nil {
info.CountryCode = errParseAddr
return info, fmt.Errorf("failed to split hostname and port: %w", err)
}
ip := net.ParseIP(hostname)
if ip == nil {
info.CountryCode = errParseAddr
return info, errors.New("failed to parse address as IP")
}

if !ip.IsGlobalUnicast() {
info.CountryCode = localLocation
return info, nil
}
info, err = ip2info.GetIPInfo(ip)
if err != nil {
info.CountryCode = errDbLookupError
}
if info.CountryCode == "" {
info.CountryCode = unknownLocation
}
return info, err
}
109 changes: 109 additions & 0 deletions ipinfo/ipinfo_test.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,109 @@
// Copyright 2023 Jigsaw Operations LLC
//
// Licensed under the Apache License, Version 2.0 (the "License");
// you may not use this file except in compliance with the License.
// You may obtain a copy of the License at
//
// https://www.apache.org/licenses/LICENSE-2.0
//
// Unless required by applicable law or agreed to in writing, software
// distributed under the License is distributed on an "AS IS" BASIS,
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
// See the License for the specific language governing permissions and
// limitations under the License.

package ipinfo

import (
"errors"
"net"
"testing"

"github.com/stretchr/testify/require"
)

type noopMap struct{}

func (*noopMap) GetIPInfo(ip net.IP) (IPInfo, error) {
return IPInfo{}, nil
}

type badMap struct{}

func (*badMap) GetIPInfo(ip net.IP) (IPInfo, error) {
return IPInfo{}, errors.New("bad map")
}

type badAddr struct {
address string
}

func (a *badAddr) String() string {
return a.address
}

func (a *badAddr) Network() string {
return "bad"
}

func TestGetIPInfoFromAddr(t *testing.T) {
var emptyInfo IPInfo
noInfoMap := &noopMap{}

// IP info disabled
info, err := GetIPInfoFromAddr(nil, nil)
require.Equal(t, emptyInfo, info)
require.NoError(t, err)

// Nil address
info, err = GetIPInfoFromAddr(noInfoMap, nil)
require.Error(t, err)
require.Equal(t, errParseAddr, info.CountryCode)

// Can't split host:port in address
info, err = GetIPInfoFromAddr(noInfoMap, &badAddr{"host-no-port"})
require.Error(t, err)
require.Equal(t, errParseAddr, info.CountryCode)

// Host is not an IP
info, err = GetIPInfoFromAddr(noInfoMap, &badAddr{"host-is-not-ip:port"})
require.Error(t, err)
require.Equal(t, errParseAddr, info.CountryCode)

// Localhost address
info, err = GetIPInfoFromAddr(noInfoMap, &badAddr{"127.0.0.1:port"})
require.NoError(t, err)
require.Equal(t, localLocation, info.CountryCode)

// Local network address
info, err = GetIPInfoFromAddr(noInfoMap, &badAddr{"10.0.0.1:port"})
require.NoError(t, err)
require.Equal(t, unknownLocation, info.CountryCode)

// No country found
info, err = GetIPInfoFromAddr(noInfoMap, &badAddr{"8.8.8.8:port"})
require.NoError(t, err)
require.Equal(t, unknownLocation, info.CountryCode)

// Failed DB lookup
info, err = GetIPInfoFromAddr(&badMap{}, &badAddr{"8.8.8.8:port"})
require.Error(t, err)
require.Equal(t, errDbLookupError, info.CountryCode)
}

func TestCountryCode(t *testing.T) {
require.Equal(t, "BR", CountryCode("BR").String())
}

func BenchmarkGetIPInfoFromAddr(b *testing.B) {
ip2info := &noopMap{}
testAddr := &net.TCPAddr{IP: net.ParseIP("217.65.48.1"), Port: 12345}

b.ResetTimer()
// Repeatedly check the country for the same address. This is realistic, because
// servers call this method for each new connection, but typically many connections
// come from a single user in succession.
for i := 0; i < b.N; i++ {
GetIPInfoFromAddr(ip2info, testAddr)
}
}
Loading

0 comments on commit e1eedc0

Please sign in to comment.