diff --git a/.editorconfig b/.editorconfig new file mode 100644 index 0000000..81ff645 --- /dev/null +++ b/.editorconfig @@ -0,0 +1,13 @@ +[*] +end_of_line = lf +insert_final_newline = true +indent_style = space +indent_size = 2 + +[*.yml] +indent_style = space +indent_size = 2 + +[{Makefile,*.go}] +indent_style = tab +indent_size = 4 diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..3ef556e --- /dev/null +++ b/.gitignore @@ -0,0 +1,36 @@ +*.sublime-workspace + +dist/ +coverage.html + +# Compiled Object files, Static and Dynamic libs (Shared Objects) +*.o +*.a +*.so + +# Folders +_obj +_test + +# Architecture specific extensions/prefixes +*.[568vq] +[568vq].out + +*.cgo1.go +*.cgo2.c +_cgo_defun.c +_cgo_gotypes.go +_cgo_export.* + +_testmain.go + +*.exe +*.test +*.prof + +# Output of the go coverage tool, specifically when used with LiteIDE +*.out + +# External packages folder +vendor/ +0 diff --git a/.travis.yml b/.travis.yml new file mode 100644 index 0000000..cad9cfa --- /dev/null +++ b/.travis.yml @@ -0,0 +1,17 @@ +language: go +sudo: false +go: + - tip +install: + - go get -v github.com/golang/lint/golint + - go get -v github.com/Masterminds/glide + - go get -v github.com/mattn/goveralls + - go get -v github.com/mitchellh/gox + - go get -v github.com/tcnksm/ghr +script: + - glide install + - make test + - $HOME/gopath/bin/goveralls -service=travis-ci +after_success: + - make build + - ghr --username darvid --token $GITHUB_TOKEN --replace $TRAVIS_TAG dist/ diff --git a/LICENSE b/LICENSE new file mode 100644 index 0000000..330bcda --- /dev/null +++ b/LICENSE @@ -0,0 +1,21 @@ +MIT License + +Copyright (c) 2017 David Gidwani + +Permission is hereby granted, free of charge, to any person obtaining a copy +of this software and associated documentation files (the "Software"), to deal +in the Software without restriction, including without limitation the rights +to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +copies of the Software, and to permit persons to whom the Software is +furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in all +copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +SOFTWARE. diff --git a/Makefile b/Makefile new file mode 100644 index 0000000..7693620 --- /dev/null +++ b/Makefile @@ -0,0 +1,21 @@ +COVERPROFILE=coverage.out +TAG_NAME=$(shell git describe --tags --long --dirty 2>/dev/null || echo "unknown") +GOX_LDFLAGS="-X main.version=$(TAG_NAME) -X main.buildTime=$(shell date -u +%Y-%m-%dT%H:%M:%S%z)" + +.PHONY: clean install test + +default: build + +build: test + gox -output "dist/{{.OS}}_{{.Arch}}_{{.Dir}}" -ldflags $(GOX_LDFLAGS) + +install: + go install -ldflags $(GOX_LDFLAGS) + +clean: + go clean + +test: + go test -v -coverprofile=$(COVERPROFILE) + go tool cover -html=$(COVERPROFILE) + rm $(COVERPROFILE) diff --git a/README.md b/README.md new file mode 100644 index 0000000..d23c81d --- /dev/null +++ b/README.md @@ -0,0 +1,59 @@ +# emissary: A TCP service multiplexer + +[![Coveralls](https://img.shields.io/coveralls/darvid/emissary.svg)](https://coveralls.io/github/darvid/emissary) [![Go Report Card](https://goreportcard.com/badge/github.com/darvid/emissary)](https://goreportcard.com/report/github.com/darvid/emissary) [![Travis](https://img.shields.io/travis/darvid/emissary.svg)](https://travis-ci.org/darvid/emissary) + +[![asciicast](https://asciinema.org/a/99252.png)](https://asciinema.org/a/99252) + +**emissary** provides a command to multiplex TCP services on the same port, +and route connections to different upstream addresses based on their starting +bytes. + +Upstreams are configured through *upstream rules*, which are a simple +regexp/remote address pair. + +## Examples + +```shell +# Forward all HTTP GET requests to httpbin.org +$ emissary -bind localhost:1080 -upstream '/^GET/:httpbin.org:80' + +# Forward SOCKS5 traffic to a local SOCKS5 server +$ emissary -bind localhost:1080 -upstream '/^\x05/:localhost:1081' +``` + +Any number of upstreams may be chained together. + +## Usage + + Usage of emissary: + -alsologtostderr + log to standard error as well as files + -bind string + bind address (default "localhost:1080") + -buffersize int + buffer size for first read (default 4096) + -log_backtrace_at value + when logging hits line file:N, emit a stack trace (default :0) + -log_dir string + If non-empty, write log files in this directory + -logtostderr + log to standard error instead of files + -stderrthreshold value + logs at or above this threshold go to stderr + -upstream value + list of upstream rules (default []) + -v value + log level for V logs + -version + show version + -vmodule value + comma-separated list of pattern=N settings for file-filtered logging + +# Similar projects + +A few projects exist that provide TCP service muxing. The ones mentioned below +are libraries which require writing custom applications or scripts, which may be +preferential to some, depending on the use case. + +* [node-port-mux](https://github.com/robertklep/node-port-mux) +* [cmux](https://github.com/soheilhy/cmux) diff --git a/emissary.sublime-project b/emissary.sublime-project new file mode 100644 index 0000000..24db303 --- /dev/null +++ b/emissary.sublime-project @@ -0,0 +1,8 @@ +{ + "folders": + [ + { + "path": "." + } + ] +} diff --git a/glide.lock b/glide.lock new file mode 100644 index 0000000..1f77287 --- /dev/null +++ b/glide.lock @@ -0,0 +1,6 @@ +hash: e5ee9155f31209fb0cbb1c3f2ae926eea54d31bc63f236efee9673c3ad862f47 +updated: 2017-01-13T14:51:11.141523-05:00 +imports: +- name: github.com/golang/glog + version: 23def4e6c14b4da8ac2ed8007337bc5eb5007998 +testImports: [] diff --git a/glide.yaml b/glide.yaml new file mode 100644 index 0000000..3722ef8 --- /dev/null +++ b/glide.yaml @@ -0,0 +1,3 @@ +package: github.com/darvid/emissary +import: +- package: github.com/golang/glog diff --git a/main.go b/main.go new file mode 100644 index 0000000..3bebbe2 --- /dev/null +++ b/main.go @@ -0,0 +1,55 @@ +package main + +import ( + "flag" + "fmt" + "net" + + "github.com/golang/glog" +) + +var ( + version = "unknown" + buildTime = "unknown" + + bindAddr string + bufferSize int + upstreamRules UpstreamRuleList + showVersion bool +) + +func init() { + flag.IntVar(&bufferSize, "buffersize", 4096, "buffer size for first read") + flag.StringVar(&bindAddr, "bind", "localhost:1080", "bind address") + flag.Var(&upstreamRules, "upstream", "list of upstream rules") + flag.BoolVar(&showVersion, "version", false, "show version") +} + +func main() { + flag.Parse() + if showVersion { + fmt.Printf("emissary version: %s\n", version) + fmt.Printf("build time: %s\n", buildTime) + } else { + if len(upstreamRules) == 0 { + flag.PrintDefaults() + } else { + listener, err := net.Listen("tcp", bindAddr) + if err != nil { + glog.Fatalln(err) + } + defer listener.Close() + glog.Infof("listening on %s", listener.Addr().String()) + + for { + conn, err := listener.Accept() + defer conn.Close() + if err != nil { + panic(err) + } + go upstreamRules.HandleConn(conn, bufferSize) + } + } + } + return +} diff --git a/main_test.go b/main_test.go new file mode 100644 index 0000000..6cba6f5 --- /dev/null +++ b/main_test.go @@ -0,0 +1,104 @@ +package main + +import ( + "bufio" + "bytes" + "fmt" + "net" + "os" + "regexp" + "testing" +) + +var listenAddrPattern = regexp.MustCompile(`listening on (.+?:\d+)`) + +func TestMain(t *testing.T) { + oldArgs := os.Args + defer func() { os.Args = oldArgs }() + os.Args = []string{"emissary"} + + _, stderrWriter, _ := os.Pipe() + oldStderr := os.Stderr + os.Stderr = stderrWriter + defer func() { os.Stderr = oldStderr }() + + t.Log("Testing main without args...") + main() + + t.Log("Testing main with single upstream...") + listener, err := net.Listen("tcp", "localhost:0") + if err != nil { + t.Errorf("Failed to start listener: %s", err) + } + + stderrReader, stderrWriter, _ := os.Pipe() + go func(f *os.File) { + oldStderr := os.Stderr + os.Stderr = f + defer func() { os.Stderr = oldStderr }() + + os.Args = []string{ + "emissary", + "-alsologtostderr", + "-bind", + "localhost:0", + "-upstream", + fmt.Sprintf("/^GET/:%s", listener.Addr().String()), + } + main() + }(stderrWriter) + reader := bufio.NewReader(stderrReader) + line, _, err := reader.ReadLine() + if err != nil { + t.Errorf("Failed to read output from cli: %s", err) + } + matchAddr := listenAddrPattern.FindStringSubmatch(string(line)) + if len(matchAddr) != 2 { + t.Errorf("Unexpected output line from cli: %s", line) + } + server, err := net.Dial("tcp", matchAddr[1]) + defer server.Close() + if err != nil { + t.Errorf("Failed to connect to emissary: %s", err) + } + server.Write([]byte("GET /\n")) + client, err := listener.Accept() + defer client.Close() + if err != nil { + t.Errorf("Failed to accept connection: %s", err) + } + // buf := make([]byte, 6) + // client.Read(buf) + response := []byte("HTTP/1.0 740 Computer says no") + client.Write(response) + buf := make([]byte, len(response)) + server.Read(buf) + if bytes.Compare(buf, response) != 0 { + t.Error("Expected responses to match") + t.Errorf("`%s' != `%s'", response, buf) + } +} + +func TestMainVersion(t *testing.T) { + oldArgs := os.Args + defer func() { os.Args = oldArgs }() + os.Args = []string{"emissary", "-version"} + + stdoutReader, stdoutWriter, _ := os.Pipe() + oldStdout := os.Stdout + os.Stdout = stdoutWriter + defer func() { os.Stdout = oldStdout }() + + t.Log("Testing main version flag...") + main() + + buf := make([]byte, 46) + stdoutReader.Read(buf) + + expectedOutput := []byte(`emissary version: unknown +build time: unknown +`) + if bytes.Compare(expectedOutput, buf) != 0 { + t.Errorf("Unexpected output from `emissary -version'") + } +} diff --git a/upstream.go b/upstream.go new file mode 100644 index 0000000..9b3c5fa --- /dev/null +++ b/upstream.go @@ -0,0 +1,104 @@ +package main + +import ( + "errors" + "fmt" + "io" + "net" + "regexp" + + "github.com/golang/glog" +) + +// An UpstreamRule is a regex pattern associated with a remote address. +type UpstreamRule struct { + pattern *regexp.Regexp + addr *net.TCPAddr +} + +// NewUpstreamRule creates a new UpstreamRule containing a compiled regular +// expression, and resolved remote address. +func NewUpstreamRule(upstream string) (*UpstreamRule, error) { + upstreamPattern, _ := regexp.Compile(`^/(.+?)/:(.+?:\d+)`) + result := upstreamPattern.FindStringSubmatch(upstream) + if len(result) != 3 { + return nil, errors.New("invalid upstream specifier") + } + rulePattern, err := regexp.Compile(result[1]) + if err != nil { + return nil, err + } + addr, err := net.ResolveTCPAddr("tcp", result[2]) + if err != nil { + return nil, err + } + return &UpstreamRule{rulePattern, addr}, nil +} + +// UpstreamRuleList represents a list of UpstreamRules. +type UpstreamRuleList []*UpstreamRule + +// FindMatch attempts to find a matching UpstreamRule given a byte array. +func (rules *UpstreamRuleList) FindMatch(buf *[]byte) *UpstreamRule { + for _, rule := range *rules { + if !rule.pattern.Match(*buf) { + continue + } + return rule + } + return nil +} + +// HandleConn performs a Read on the given net.Conn, and serves as a +// reverse proxy to the matching remote upstream, if one was found that +// matched on the Read. +func (rules *UpstreamRuleList) HandleConn( + conn net.Conn, + bufSize int) (*UpstreamRule, error) { + glog.Infof("handling connection from %s", conn.RemoteAddr().String()) + buf := make([]byte, bufSize) + n, err := conn.Read(buf) + if err != nil { + return nil, err + } + rule := rules.FindMatch(&buf) + if rule == nil { + glog.Warningf("no upstream found for %s", conn.RemoteAddr().String()) + conn.Close() + glog.Infoln("closed connection from %s", conn.RemoteAddr().String()) + return nil, nil + } + glog.Infof("found matching upstream: %s", rule.addr.String()) + dest, err := net.Dial("tcp", rule.addr.String()) + if err != nil { + return nil, err + } + go func(source net.Conn, dest net.Conn) { + go dest.Write(buf[:n]) + go io.Copy(dest, conn) + go io.Copy(conn, dest) + }(conn, dest) + return rule, nil +} + +// Set parses a UpstreamRule specification from the given string and +// appends it to the UpstreamRuleList. +// +// UpstreamRule specifiers must be provided in the following format: +// +// '/pattern/:addr:port' +// +// Pattern is a regular expression, and is matched from the start of any +// incoming data stream. Address may be an IPv4 address or a hostname. +func (rules *UpstreamRuleList) Set(value string) error { + upstreamRule, err := NewUpstreamRule(value) + if err != nil { + return err + } + *rules = append(*rules, upstreamRule) + return nil +} + +func (rules *UpstreamRuleList) String() string { + return fmt.Sprint(*rules) +} diff --git a/upstream_test.go b/upstream_test.go new file mode 100644 index 0000000..b1500f4 --- /dev/null +++ b/upstream_test.go @@ -0,0 +1,119 @@ +package main + +import ( + "bytes" + "fmt" + "net" + "testing" +) + +func TestNewUpstreamRule(t *testing.T) { + t.Log("Creating an upstream rule with invalid regexp...") + rule, err := NewUpstreamRule("/^(whoops/:localhost:8000") + if err == nil { + t.Error("Expected a regexp error to be thrown") + } + + t.Log("Creating an upstream rule with invalid spec...") + rule, err = NewUpstreamRule("what is this even") + if err == nil { + t.Error("Expected a regexp error to be thrown") + } + + t.Log("Creating an upstream rule with invalid addr...") + rule, err = NewUpstreamRule("/^GET/:256.256.256.256:80") + if err == nil { + t.Error("Expected an error to be thrown from ResolveTCPAddr") + } + + t.Log("Creating a valid upstream rule...") + rule, err = NewUpstreamRule("/^GET/:localhost:8000") + if rule == nil || err != nil { + t.Error(err) + } + + if rule.pattern.String() != "^GET" { + t.Errorf("Invalid pattern captured: %s (expected '^GET')", rule.pattern) + } + + if rule.addr.String() != "127.0.0.1:8000" { + t.Errorf("Invalid addr captured: %s (expected '127.0.0.1:8000')", rule.addr) + } +} + +func TestUpstreamRuleListSet(t *testing.T) { + var rules UpstreamRuleList + + t.Log("Appending valid upstream to the rules list...") + err := rules.Set("/^GET/:localhost:8000") + if len(rules) != 1 || err != nil { + t.Errorf("Upstream rules list empty after calling Set: %s", err) + } + + t.Log("Appending invalid upstream to the rules list...") + err = rules.Set("what is this even") + if err == nil { + t.Error("Expected error to be thrown for invalid upstream") + } +} + +func TestUpstreamRuleListFindMatch(t *testing.T) { + var rules UpstreamRuleList + rules.Set("/^GET/:localhost:8000") + rules.Set("/^POST/:localhost:9000") + b := []byte{'\x05', '\x01'} + t.Log("Trying to find an upstream for non-matching data...") + chosenRule := rules.FindMatch(&b) + if chosenRule != nil { + t.Error("Expected no rule to match as the rule list is empty") + } + + rules.Set("/^\x05/:localhost:1080") + t.Log("Trying to find an upstream for matching data...") + chosenRule = rules.FindMatch(&b) + if chosenRule == nil || chosenRule.addr.String() != "127.0.0.1:1080" { + t.Errorf("Expected an upstream rule to match %v", b) + } +} + +func TestUpstreamHandleConn(t *testing.T) { + listener, err := net.Listen("tcp", "localhost:0") + if err != nil { + t.Error(err) + } + + var ( + rules UpstreamRuleList + server net.Conn + ) + + client, err := net.Dial("tcp", listener.Addr().String()) + if err != nil { + t.Error(err) + } + + t.Log("Creating rules list with SOCKS upstream rule...") + rules.Set(fmt.Sprintf("/^\x05/:%s", listener.Addr().String())) + + t.Log("Sending SOCKS greeting...") + client.Write([]byte{'\x05', '\x02', '\x00', '\x01'}) + + defer listener.Close() + server, err = listener.Accept() + if err != nil { + t.Error(err) + } + rule, err := rules.HandleConn(server, 4096) + if rule == nil || err != nil { + t.Errorf("Expected upstream rule to match: %s", err) + } + + t.Log("Sending some data...") + deadbeef := []byte{'\xFE', '\xEB', '\xDA', '\xED'} + client.Write(deadbeef) + b := make([]byte, 4) + server.Read(b) + if !bytes.Equal(deadbeef, b) { + t.Errorf("Expected %v, got %v\n", deadbeef, b) + } +}