Move cli to internal

This commit is contained in:
9seconds
2021-04-02 21:36:15 +03:00
parent 24fdec6694
commit ea7033d569
11 changed files with 2 additions and 2 deletions
+192
View File
@@ -0,0 +1,192 @@
package cli
import (
"context"
"encoding/json"
"fmt"
"io"
"net"
"net/http"
"net/url"
"os"
"strconv"
"strings"
"sync"
)
type accessResponse struct {
IPv4 *accessResponseURLs `json:"ipv4,omitempty"`
IPv6 *accessResponseURLs `json:"ipv6,omitempty"`
Secret struct {
Hex string `json:"hex"`
Base64 string `json:"base64"`
} `json:"secret"`
}
type accessResponseURLs struct {
IP net.IP `json:"ip"`
Port uint `json:"port"`
TgURL string `json:"tg_url"`
TgQrCode string `json:"tg_qrcode"`
TmeURL string `json:"tme_url"`
TmeQrCode string `json:"tme_qrcode"`
}
type Access struct {
base
PublicIPv4 net.IP `kong:"help='Public IPv4 address for proxy. By default it is resolved via remote website',name='ipv4',short='i'"` // nolint: lll
PublicIPv6 net.IP `kong:"help='Public IPv6 address for proxy. By default it is resolved via remote website',name='ipv6',short='I'"` // nolint: lll
Port uint `kong:"help='Port number. Default port is taken from configuration file, bind-to parameter',type:'uint',short='p'"` // nolint: lll
Hex bool `kong:"help='Print secret in hex encoding.',short='x'"`
}
func (c *Access) Run(cli *CLI, version string) error {
if err := c.ReadConfig(version); err != nil {
return fmt.Errorf("cannot init config: %w", err)
}
return c.Execute(cli)
}
func (c *Access) Execute(cli *CLI) error {
resp := &accessResponse{}
resp.Secret.Base64 = c.Config.Secret.Base64()
resp.Secret.Hex = c.Config.Secret.Hex()
wg := &sync.WaitGroup{}
wg.Add(2) // nolint: gomnd
go func() {
defer wg.Done()
ip := cli.Access.PublicIPv4
if ip == nil {
ip = c.getIP("tcp4")
}
if ip != nil {
ip = ip.To4()
}
resp.IPv4 = c.makeURLs(ip, cli)
}()
go func() {
defer wg.Done()
ip := cli.Access.PublicIPv6
if ip == nil {
ip = c.getIP("tcp6")
}
if ip != nil {
ip = ip.To16()
}
resp.IPv6 = c.makeURLs(ip, cli)
}()
wg.Wait()
encoder := json.NewEncoder(os.Stdout)
encoder.SetEscapeHTML(false)
encoder.SetIndent("", " ")
if err := encoder.Encode(resp); err != nil {
return fmt.Errorf("cannot dump access json: %w", err)
}
return nil
}
func (c *Access) getIP(protocol string) net.IP {
client := c.Network.MakeHTTPClient(func(ctx context.Context, network, address string) (net.Conn, error) {
return c.Network.DialContext(ctx, protocol, address)
})
req, err := http.NewRequest(http.MethodGet, "https://ifconfig.co", nil) // nolint: noctx
if err != nil {
panic(err)
}
req.Header.Add("Accept", "text/plain")
resp, err := client.Do(req)
if err != nil {
return nil
}
if resp.StatusCode != http.StatusOK {
return nil
}
defer func() {
io.Copy(io.Discard, resp.Body) // nolint: errcheck
resp.Body.Close()
}()
data, err := io.ReadAll(resp.Body)
if err != nil {
return nil
}
return net.ParseIP(strings.TrimSpace(string(data)))
}
func (c *Access) makeURLs(ip net.IP, cli *CLI) *accessResponseURLs {
if ip == nil {
return nil
}
portNo := cli.Access.Port
if portNo == 0 {
portNo = c.Config.BindTo.PortValue(0)
}
values := url.Values{}
values.Set("server", ip.String())
values.Set("port", strconv.Itoa(int(portNo)))
if cli.Access.Hex {
values.Set("secret", c.Config.Secret.Hex())
} else {
values.Set("secret", c.Config.Secret.Base64())
}
urlQuery := values.Encode()
rv := &accessResponseURLs{
IP: ip,
Port: portNo,
TgURL: (&url.URL{
Scheme: "tg",
Host: "proxy",
RawQuery: urlQuery,
}).String(),
TmeURL: (&url.URL{
Scheme: "https",
Host: "t.me",
Path: "proxy",
RawQuery: urlQuery,
}).String(),
}
rv.TgQrCode = c.makeQRCode(rv.TgURL)
rv.TmeQrCode = c.makeQRCode(rv.TmeURL)
return rv
}
func (c *Access) makeQRCode(data string) string {
values := url.Values{}
values.Set("qzone", "4")
values.Set("format", "svg")
values.Set("data", data)
return (&url.URL{
Scheme: "https",
Host: "api.qrserver.com",
Path: "v1/create-qr-code",
RawQuery: values.Encode(),
}).String()
}
+197
View File
@@ -0,0 +1,197 @@
package cli_test
import (
"net"
"net/http"
"testing"
"github.com/9seconds/mtg/v2/config"
"github.com/9seconds/mtg/v2/internal/testlib"
"github.com/9seconds/mtg/v2/mtglib"
"github.com/jarcoal/httpmock"
"github.com/stretchr/testify/suite"
"github.com/xeipuuv/gojsonschema"
)
var accressResponseJSONSchema = func() *gojsonschema.Schema {
schema, err := gojsonschema.NewSchema(gojsonschema.NewStringLoader(`
{
"type": "object",
"required": ["secret"],
"additionalProperties": true,
"properties": {
"secret": {
"type": "object",
"required": [
"hex",
"base64"
],
"additionalProperties": false,
"properties": {
"hex": {
"type": "string",
"minLength": 34
},
"base64": {
"type": "string",
"minLength": 10
}
}
},
"ipv4": {
"$ref": "#/definitions/ip"
},
"ipv6": {
"$ref": "#/definitions/ip"
}
},
"definitions": {
"ip": {
"type": "object",
"required": [
"ip",
"port",
"tg_url",
"tg_qrcode",
"tme_url",
"tme_qrcode"
],
"additionalProperties": false,
"properties": {
"ip": {
"type": "string",
"minLength": 1,
"anyOf": [
{
"format": "ipv4"
},
{
"format": "ipv6"
}
]
},
"port": {
"type": "integer",
"multipleOf": 1.0,
"exclusiveMinimum": 0,
"exclusiveMaximum": 65536
},
"tg_url": {
"type": "string",
"minLength": 1,
"format": "uri"
},
"tg_qrcode": {
"type": "string",
"minLength": 1,
"format": "uri"
},
"tme_url": {
"type": "string",
"minLength": 1,
"format": "uri"
},
"tme_qrcode": {
"type": "string",
"minLength": 1,
"format": "uri"
}
}
}
}
}
`))
if err != nil {
panic(err)
}
return schema
}()
type AccessTestSuite struct {
CommonTestSuite
}
func (suite *AccessTestSuite) SetupTest() {
suite.CommonTestSuite.SetupTest()
suite.cli.Access.Config = &config.Config{}
suite.cli.Access.Config.Secret = mtglib.GenerateSecret("google.com")
suite.cli.Access.Network = suite.networkMock
suite.NoError(
suite.cli.Access.Config.BindTo.UnmarshalText([]byte("0.0.0.0:80")))
}
func (suite *AccessTestSuite) TestGenerateNoCalls() {
suite.cli.Access.PublicIPv4 = net.ParseIP("10.0.0.10")
suite.cli.Access.PublicIPv6 = net.ParseIP("2001:0db8:85a3:0000:0000:8a2e:0370:7334")
output := testlib.CaptureStdout(func() {
suite.NoError(suite.cli.Access.Execute(suite.cli))
})
validated, err := accressResponseJSONSchema.Validate(
gojsonschema.NewStringLoader(output))
suite.NoError(err)
suite.Empty(validated.Errors())
suite.True(validated.Valid())
suite.Contains(output, "10.0.0.10")
suite.Contains(output, "2001:db8:85a3::8a2e:370:7334")
suite.Contains(output, "ipv4")
suite.Contains(output, "ipv6")
suite.Contains(output, suite.cli.Access.Config.Secret.Base64())
suite.Contains(output, suite.cli.Access.Config.Secret.Hex())
}
func (suite *AccessTestSuite) TestGenerateIPv4Call() {
suite.cli.Access.PublicIPv6 = net.ParseIP("2001:0db8:85a3:0000:0000:8a2e:0370:7334")
httpmock.RegisterResponder(http.MethodGet, "https://ifconfig.co",
httpmock.NewStringResponder(http.StatusOK, "10.11.12.13"))
output := testlib.CaptureStdout(func() {
suite.NoError(suite.cli.Access.Execute(suite.cli))
})
validated, err := accressResponseJSONSchema.Validate(
gojsonschema.NewStringLoader(output))
suite.NoError(err)
suite.Empty(validated.Errors())
suite.True(validated.Valid())
suite.Contains(output, "10.11.12.13")
suite.Contains(output, "2001:db8:85a3::8a2e:370:7334")
suite.Contains(output, "ipv4")
suite.Contains(output, "ipv6")
suite.Contains(output, suite.cli.Access.Config.Secret.Base64())
suite.Contains(output, suite.cli.Access.Config.Secret.Hex())
}
func (suite *AccessTestSuite) TestIPv4CallFail() {
suite.cli.Access.PublicIPv6 = net.ParseIP("2001:0db8:85a3:0000:0000:8a2e:0370:7334")
httpmock.RegisterResponder(http.MethodGet, "https://ifconfig.co",
httpmock.NewStringResponder(http.StatusForbidden, ""))
output := testlib.CaptureStdout(func() {
suite.NoError(suite.cli.Access.Execute(suite.cli))
})
validated, err := accressResponseJSONSchema.Validate(
gojsonschema.NewStringLoader(output))
suite.NoError(err)
suite.Empty(validated.Errors())
suite.True(validated.Valid())
suite.Contains(output, "2001:db8:85a3::8a2e:370:7334")
suite.NotContains(output, "ipv4")
suite.Contains(output, "ipv6")
suite.Contains(output, suite.cli.Access.Config.Secret.Base64())
suite.Contains(output, suite.cli.Access.Config.Secret.Hex())
}
func TestAccess(t *testing.T) { // nolint: paralleltest
suite.Run(t, &AccessTestSuite{})
}
+81
View File
@@ -0,0 +1,81 @@
package cli
import (
"fmt"
"net"
"net/url"
"os"
"github.com/9seconds/mtg/v2/config"
"github.com/9seconds/mtg/v2/mtglib"
"github.com/9seconds/mtg/v2/network"
)
type base struct {
ConfigPath string `kong:"arg,required,type='existingfile',help='Path to the configuration file.',name='config-path'"` // nolint: lll
Network mtglib.Network `kong:"-"`
Config *config.Config `kong:"-"`
}
func (b *base) ReadConfig(version string) error {
content, err := os.ReadFile(b.ConfigPath)
if err != nil {
return fmt.Errorf("cannot read config file: %w", err)
}
conf, err := config.Parse(content)
if err != nil {
return fmt.Errorf("cannot parse config: %w", err)
}
ntw, err := b.makeNetwork(conf, version)
if err != nil {
return fmt.Errorf("cannot build a network: %w", err)
}
b.Config = conf
b.Network = ntw
return nil
}
func (b *base) makeNetwork(conf *config.Config, version string) (mtglib.Network, error) {
tcpTimeout := conf.Network.Timeout.TCP.Value(network.DefaultTimeout)
httpTimeout := conf.Network.Timeout.HTTP.Value(network.DefaultHTTPTimeout)
dohIP := conf.Network.DOHIP.Value(net.ParseIP(network.DefaultDOHHostname)).String()
bufferSize := conf.TCPBuffer.Value(network.DefaultBufferSize)
userAgent := "mtg/" + version
baseDialer, err := network.NewDefaultDialer(tcpTimeout, int(bufferSize))
if err != nil {
return nil, fmt.Errorf("cannot build a default dialer: %w", err)
}
proxyURLs := make([]*url.URL, 0, len(conf.Network.Proxies))
for _, v := range conf.Network.Proxies {
if value := v.Value(nil); value != nil {
proxyURLs = append(proxyURLs, v.Value(nil))
}
}
switch len(proxyURLs) {
case 0:
return network.NewNetwork(baseDialer, userAgent, dohIP, httpTimeout)
case 1:
socksDialer, err := network.NewSocks5Dialer(baseDialer, proxyURLs[0])
if err != nil {
return nil, fmt.Errorf("cannot build socks5 dialer: %w", err)
}
return network.NewNetwork(socksDialer, userAgent, dohIP, httpTimeout)
}
socksDialer, err := network.NewLoadBalancedSocks5Dialer(baseDialer, proxyURLs)
if err != nil {
return nil, fmt.Errorf("cannot build socks5 dialer: %w", err)
}
return network.NewNetwork(socksDialer, userAgent, dohIP, httpTimeout)
}
+33
View File
@@ -0,0 +1,33 @@
package cli
import (
"path/filepath"
"testing"
"github.com/stretchr/testify/suite"
)
type BaseTestSuite struct {
suite.Suite
b base
}
func (suite *BaseTestSuite) SetupTest() {
suite.b = base{}
}
func (suite *BaseTestSuite) TestReadConfigNok() {
suite.b.ConfigPath = filepath.Join("testdata", "unknown")
suite.Error(suite.b.ReadConfig("dev"))
}
func (suite *BaseTestSuite) TestReadConfig() {
suite.b.ConfigPath = filepath.Join("testdata", "minimal.toml")
suite.NoError(suite.b.ReadConfig("dev"))
}
func TestBase(t *testing.T) {
t.Parallel()
suite.Run(t, &BaseTestSuite{})
}
+10
View File
@@ -0,0 +1,10 @@
package cli
import "github.com/alecthomas/kong"
type CLI struct {
GenerateSecret GenerateSecret `kong:"cmd,help='Generate new proxy secret'"`
Access Access `kong:"cmd,help='Print access information.'"`
Run Proxy `kong:"cmd,help='Run proxy.'"`
Version kong.VersionFlag `kong:"help='Print version.',short='v'"`
}
+24
View File
@@ -0,0 +1,24 @@
package cli
import (
"fmt"
"github.com/9seconds/mtg/v2/mtglib"
)
type GenerateSecret struct {
HostName string `kong:"arg,required,help='Hostname to use for domain fronting.',name='hostname'"`
Hex bool `kong:"help='Print secret in hex encoding.',short='x'"`
}
func (c *GenerateSecret) Run(cli *CLI, _ string) error {
secret := mtglib.GenerateSecret(cli.GenerateSecret.HostName)
if cli.GenerateSecret.Hex {
fmt.Println(secret.Hex()) // nolint: forbidigo
} else {
fmt.Println(secret.Base64()) // nolint: forbidigo
}
return nil
}
+51
View File
@@ -0,0 +1,51 @@
package cli_test
import (
"strings"
"testing"
"github.com/9seconds/mtg/v2/internal/testlib"
"github.com/9seconds/mtg/v2/mtglib"
"github.com/stretchr/testify/suite"
)
type GenerateSecretTestSuite struct {
CommonTestSuite
}
func (suite *GenerateSecretTestSuite) SetupTest() {
suite.CommonTestSuite.SetupTest()
suite.cli.GenerateSecret.HostName = "google.com"
}
func (suite *GenerateSecretTestSuite) TestDefault() {
output := testlib.CaptureStdout(func() {
suite.NoError(suite.cli.GenerateSecret.Run(suite.cli, "dev"))
})
suite.True(strings.HasPrefix(output, "7"))
secret, err := mtglib.ParseSecret(output)
suite.NoError(err)
suite.True(secret.Valid())
suite.Equal("google.com", secret.Host)
}
func (suite *GenerateSecretTestSuite) TestHex() {
suite.cli.GenerateSecret.Hex = true
output := testlib.CaptureStdout(func() {
suite.NoError(suite.cli.GenerateSecret.Run(suite.cli, "dev"))
})
suite.True(strings.HasPrefix(output, "ee"))
secret, err := mtglib.ParseSecret(output)
suite.NoError(err)
suite.True(secret.Valid())
suite.Equal("google.com", secret.Host)
}
func TestGenerateSecret(t *testing.T) {
t.Parallel()
suite.Run(t, &GenerateSecretTestSuite{})
}
+37
View File
@@ -0,0 +1,37 @@
package cli_test
import (
"net/http"
"github.com/9seconds/mtg/v2/internal/cli"
"github.com/9seconds/mtg/v2/internal/testlib"
"github.com/jarcoal/httpmock"
"github.com/stretchr/testify/mock"
"github.com/stretchr/testify/suite"
)
type CommonTestSuite struct {
suite.Suite
cli *cli.CLI
networkMock *testlib.MtglibNetworkMock
httpClient *http.Client
}
func (suite *CommonTestSuite) SetupTest() {
suite.networkMock = &testlib.MtglibNetworkMock{}
suite.httpClient = &http.Client{}
suite.cli = &cli.CLI{}
httpmock.ActivateNonDefault(suite.httpClient)
suite.networkMock.
On("MakeHTTPClient", mock.Anything).
Maybe().
Return(suite.httpClient)
}
func (suite *CommonTestSuite) TearDownTest() {
suite.networkMock.AssertExpectations(suite.T())
httpmock.DeactivateAndReset()
}
+180
View File
@@ -0,0 +1,180 @@
package cli
import (
"fmt"
"net"
"os"
"github.com/9seconds/mtg/v2/antireplay"
"github.com/9seconds/mtg/v2/events"
"github.com/9seconds/mtg/v2/internal/utils"
"github.com/9seconds/mtg/v2/ipblocklist"
"github.com/9seconds/mtg/v2/logger"
"github.com/9seconds/mtg/v2/mtglib"
"github.com/9seconds/mtg/v2/stats"
"github.com/9seconds/mtg/v2/timeattack"
"github.com/rs/zerolog"
)
type Proxy struct {
base
}
func (c *Proxy) Run(cli *CLI, version string) error {
if err := c.ReadConfig(version); err != nil {
return fmt.Errorf("cannot init config: %w", err)
}
return c.Execute()
}
func (c *Proxy) Execute() error {
zerolog.TimeFieldFormat = zerolog.TimeFormatUnixMs
zerolog.TimestampFieldName = "timestamp"
zerolog.LevelFieldName = "level"
if c.Config.Debug {
zerolog.SetGlobalLevel(zerolog.DebugLevel)
} else {
zerolog.SetGlobalLevel(zerolog.WarnLevel)
}
ctx := utils.RootContext()
opts := mtglib.ProxyOpts{
Logger: logger.NewZeroLogger(zerolog.New(os.Stdout).With().Timestamp().Logger()),
Network: c.Network,
AntiReplayCache: antireplay.NewNoop(),
IPBlocklist: ipblocklist.NewNoop(),
TimeAttackDetector: timeattack.NewNoop(),
EventStream: events.NewNoopStream(),
Secret: c.Config.Secret,
BufferSize: c.Config.TCPBuffer.Value(mtglib.DefaultBufferSize),
DomainFrontingPort: c.Config.DomainFrontingPort.Value(mtglib.DefaultDomainFrontingPort),
IdleTimeout: c.Config.Network.Timeout.Idle.Value(mtglib.DefaultIdleTimeout),
PreferIP: c.Config.PreferIP.Value(mtglib.DefaultPreferIP),
}
opts.Logger.BindStr("configuration", c.Config.String()).Debug("configuration")
c.setupAntiReplayCache(&opts)
c.setupTimeAttackDetector(&opts)
if err := c.setupIPBlocklist(&opts); err != nil {
return fmt.Errorf("cannot setup ipblocklist: %w", err)
}
if err := c.setupEventStream(&opts); err != nil {
return fmt.Errorf("cannot setup event stream: %w", err)
}
proxy, err := mtglib.NewProxy(opts)
if err != nil {
return fmt.Errorf("cannot create a proxy: %w", err)
}
listener, err := net.Listen("tcp", c.Config.BindTo.String())
if err != nil {
return fmt.Errorf("cannot start proxy: %w", err)
}
go proxy.Serve(listener) // nolint: errcheck
<-ctx.Done()
listener.Close()
proxy.Shutdown()
return nil
}
func (c *Proxy) setupAntiReplayCache(opts *mtglib.ProxyOpts) {
if !c.Config.Defense.AntiReplay.Enabled {
return
}
opts.AntiReplayCache = antireplay.NewStableBloomFilter(
c.Config.Defense.AntiReplay.MaxSize.Value(antireplay.DefaultMaxSize),
c.Config.Defense.AntiReplay.ErrorRate.Value(antireplay.DefaultErrorRate),
)
}
func (c *Proxy) setupTimeAttackDetector(opts *mtglib.ProxyOpts) {
if !c.Config.Defense.Time.Enabled {
return
}
opts.TimeAttackDetector = timeattack.NewDetector(
c.Config.Defense.Time.AllowSkewness.Value(timeattack.DefaultDuration),
)
}
func (c *Proxy) setupIPBlocklist(opts *mtglib.ProxyOpts) error {
if !c.Config.Defense.Blocklist.Enabled {
return nil
}
remoteURLs := []string{}
localFiles := []string{}
for _, v := range c.Config.Defense.Blocklist.URLs {
if v.IsRemote() {
remoteURLs = append(remoteURLs, v.String())
} else {
localFiles = append(localFiles, v.String())
}
}
firehol, err := ipblocklist.NewFirehol(opts.Logger.Named("ipblockist"),
c.Network,
c.Config.Defense.Blocklist.DownloadConcurrency,
remoteURLs,
localFiles)
if err != nil {
return err // nolint: wrapcheck
}
go firehol.Run(c.Config.Defense.Blocklist.UpdateEach.Value(ipblocklist.DefaultUpdateEach))
opts.IPBlocklist = firehol
return nil
}
func (c *Proxy) setupEventStream(opts *mtglib.ProxyOpts) error {
factories := make([]events.ObserverFactory, 0, 2)
if c.Config.Stats.StatsD.Enabled {
statsdFactory, err := stats.NewStatsd(
c.Config.Stats.StatsD.Address.String(),
opts.Logger.Named("statsd"),
c.Config.Stats.StatsD.MetricPrefix.Value(stats.DefaultStatsdMetricPrefix),
c.Config.Stats.StatsD.TagFormat.Value(stats.DefaultStatsdTagFormat))
if err != nil {
return fmt.Errorf("cannot build statsd observer: %w", err)
}
factories = append(factories, statsdFactory.Make)
}
if c.Config.Stats.Prometheus.Enabled {
prometheus := stats.NewPrometheus(
c.Config.Stats.Prometheus.MetricPrefix.Value(stats.DefaultMetricPrefix),
c.Config.Stats.Prometheus.HTTPPath.Value("/"),
)
listener, err := net.Listen("tcp", c.Config.Stats.Prometheus.BindTo.String())
if err != nil {
return fmt.Errorf("cannot start a listener for prometheus: %w", err)
}
go prometheus.Serve(listener) // nolint: errcheck
factories = append(factories, prometheus.Make)
}
if len(factories) > 0 {
opts.EventStream = events.NewEventStream(factories)
}
return nil
}
+2
View File
@@ -0,0 +1,2 @@
secret = "7mqFMMq3P2Tvvt_rPx5qhmFnb29nbGUuY29t"
bind-to = "0.0.0.0:80"