├── .github ├── FUNDING.yml ├── dependabot.yml └── workflows │ ├── codeql-analysis.yml │ ├── go.yml │ ├── release.yml │ └── reviewdog.yml ├── .gitignore ├── Dockerfile ├── LICENSE ├── conn.go ├── counter.go ├── delayedwriter.go ├── display └── print.go ├── go.mod ├── go.sum ├── images └── logo.png ├── protocol ├── grpc.go ├── http2.go ├── interop.go ├── mongo.go ├── mqtt.go ├── mysql.go ├── redis.go └── text.go ├── readme-cn.md ├── readme-ja.md ├── readme.md ├── settings.go ├── stat+polyfill.go ├── stat.go ├── stat_linux.go ├── tcpstat_linux.go └── tproxy.go /.github/FUNDING.yml: -------------------------------------------------------------------------------- 1 | # These are supported funding model platforms 2 | 3 | github: [kevwan] # Replace with up to 4 GitHub Sponsors-enabled usernames e.g., [user1, user2] 4 | patreon: # kevwan 5 | open_collective: # Replace with a single Open Collective username 6 | ko_fi: # Replace with a single Ko-fi username 7 | tidelift: # Replace with a single Tidelift platform-name/package-name e.g., npm/babel 8 | community_bridge: # Replace with a single Community Bridge project-name e.g., cloud-foundry 9 | liberapay: # Replace with a single Liberapay username 10 | issuehunt: # Replace with a single IssueHunt username 11 | otechie: # Replace with a single Otechie username 12 | custom: # https://gitee.com/kevwan/static/raw/master/images/sponsor.jpg 13 | ethereum: # 0x5052b7f6B937B02563996D23feb69b38D06Ca150 | kevwan 14 | -------------------------------------------------------------------------------- /.github/dependabot.yml: -------------------------------------------------------------------------------- 1 | # To get started with Dependabot version updates, you'll need to specify which 2 | # package ecosystems to update and where the package manifests are located. 3 | # Please see the documentation for all configuration options: 4 | # https://docs.github.com/github/administering-a-repository/configuration-options-for-dependency-updates 5 | 6 | version: 2 7 | updates: 8 | - package-ecosystem: "gomod" # See documentation for possible values 9 | directory: "/" # Location of package manifests 10 | schedule: 11 | interval: "daily" 12 | -------------------------------------------------------------------------------- /.github/workflows/codeql-analysis.yml: -------------------------------------------------------------------------------- 1 | # For most projects, this workflow file will not need changing; you simply need 2 | # to commit it to your repository. 3 | # 4 | # You may wish to alter this file to override the set of languages analyzed, 5 | # or to provide custom queries or build logic. 6 | # 7 | # ******** NOTE ******** 8 | # We have attempted to detect the languages in your repository. Please check 9 | # the `language` matrix defined below to confirm you have the correct set of 10 | # supported CodeQL languages. 11 | # 12 | name: "CodeQL" 13 | 14 | on: 15 | push: 16 | branches: [ main ] 17 | pull_request: 18 | # The branches below must be a subset of the branches above 19 | branches: [ main ] 20 | schedule: 21 | - cron: '18 19 * * 6' 22 | 23 | jobs: 24 | analyze: 25 | name: Analyze 26 | runs-on: ubuntu-latest 27 | 28 | strategy: 29 | fail-fast: false 30 | matrix: 31 | language: [ 'go' ] 32 | # CodeQL supports [ 'cpp', 'csharp', 'go', 'java', 'javascript', 'python' ] 33 | # Learn more: 34 | # https://docs.github.com/en/free-pro-team@latest/github/finding-security-vulnerabilities-and-errors-in-your-code/configuring-code-scanning#changing-the-languages-that-are-analyzed 35 | 36 | steps: 37 | - name: Checkout repository 38 | uses: actions/checkout@v2 39 | 40 | # Initializes the CodeQL tools for scanning. 41 | - name: Initialize CodeQL 42 | uses: github/codeql-action/init@v1 43 | with: 44 | languages: ${{ matrix.language }} 45 | # If you wish to specify custom queries, you can do so here or in a config file. 46 | # By default, queries listed here will override any specified in a config file. 47 | # Prefix the list here with "+" to use these queries and those in the config file. 48 | # queries: ./path/to/local/query, your-org/your-repo/queries@main 49 | 50 | # Autobuild attempts to build any compiled languages (C/C++, C#, or Java). 51 | # If this step fails, then you should remove it and run the build manually (see below) 52 | - name: Autobuild 53 | uses: github/codeql-action/autobuild@v1 54 | 55 | # ℹ️ Command-line programs to run using the OS shell. 56 | # 📚 https://git.io/JvXDl 57 | 58 | # ✏️ If the Autobuild fails above, remove it and uncomment the following three lines 59 | # and modify them (or add more) to build your code if your project 60 | # uses a compiled language 61 | 62 | #- run: | 63 | # make bootstrap 64 | # make release 65 | 66 | - name: Perform CodeQL Analysis 67 | uses: github/codeql-action/analyze@v1 68 | -------------------------------------------------------------------------------- /.github/workflows/go.yml: -------------------------------------------------------------------------------- 1 | name: Go 2 | 3 | on: 4 | push: 5 | branches: [ main ] 6 | pull_request: 7 | branches: [ main ] 8 | 9 | jobs: 10 | build: 11 | name: Build 12 | runs-on: ubuntu-latest 13 | steps: 14 | 15 | - name: Set up Go 1.x 16 | uses: actions/setup-go@v2 17 | with: 18 | go-version: ^1.18 19 | id: go 20 | 21 | - name: Check out code into the Go module directory 22 | uses: actions/checkout@v2 23 | 24 | - name: Get dependencies 25 | run: | 26 | go get -v -t -d ./... 27 | 28 | - name: Lint 29 | run: | 30 | go vet -stdmethods=false $(go list ./...) 31 | go install mvdan.cc/gofumpt@latest 32 | test -z "$(gofumpt -s -l -extra .)" || echo "Please run 'gofumpt -l -w -extra .'" 33 | 34 | - name: Test 35 | run: go test -race -coverprofile=coverage.txt -covermode=atomic ./... 36 | 37 | - name: Codecov 38 | uses: codecov/codecov-action@v2 39 | -------------------------------------------------------------------------------- /.github/workflows/release.yml: -------------------------------------------------------------------------------- 1 | name: Release 2 | on: 3 | release: 4 | types: [created] 5 | jobs: 6 | releases-matrix: 7 | name: Release tproxy binary 8 | runs-on: ubuntu-latest 9 | strategy: 10 | matrix: 11 | # build and publish in parallel: linux/amd64, linux/arm64, 12 | # windows/amd64, windows/arm64, darwin/amd64, darwin/arm64 13 | goos: [ linux, windows, darwin ] 14 | goarch: [ amd64, arm64 ] 15 | steps: 16 | - uses: actions/checkout@v3 17 | - uses: wangyoucao577/go-release-action@v1.40 18 | with: 19 | github_token: ${{ secrets.GITHUB_TOKEN }} 20 | goos: ${{ matrix.goos }} 21 | goarch: ${{ matrix.goarch }} 22 | binary_name: "tproxy" 23 | extra_files: readme-cn.md readme.md 24 | -------------------------------------------------------------------------------- /.github/workflows/reviewdog.yml: -------------------------------------------------------------------------------- 1 | name: reviewdog 2 | on: [pull_request] 3 | jobs: 4 | staticcheck: 5 | name: runner / staticcheck 6 | runs-on: ubuntu-latest 7 | steps: 8 | - uses: actions/checkout@v2 9 | - uses: actions/setup-go@v3 10 | with: 11 | go-version: "1.18" 12 | - uses: reviewdog/action-staticcheck@v1 13 | with: 14 | github_token: ${{ secrets.github_token }} 15 | # Change reviewdog reporter if you need [github-pr-check,github-check,github-pr-review]. 16 | reporter: github-pr-review 17 | # Report all results. 18 | filter_mode: nofilter 19 | # Exit with 1 when it find at least one finding. 20 | fail_on_error: true 21 | # Set staticcheck flags 22 | staticcheck_flags: -checks=inherit,-SA1019,-SA1029,-SA5008 23 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | # Ignore all 2 | * 3 | 4 | # Unignore all with extensions 5 | !*.* 6 | !**/Dockerfile 7 | !**/Makefile 8 | 9 | # Unignore all dirs 10 | !*/ 11 | !api 12 | 13 | # ignore 14 | .idea 15 | **/.DS_Store 16 | **/logs 17 | modd.conf 18 | makefile 19 | 20 | # for test purpose 21 | **/adhoc 22 | app 23 | go.work 24 | go.work.sum 25 | 26 | # gitlab ci 27 | .cache 28 | 29 | # vim auto backup file 30 | *~ 31 | !OWNERS 32 | -------------------------------------------------------------------------------- /Dockerfile: -------------------------------------------------------------------------------- 1 | FROM golang:alpine AS builder 2 | 3 | LABEL stage=gobuilder 4 | 5 | ENV CGO_ENABLED 0 6 | ENV GOPROXY https://goproxy.cn,direct 7 | RUN sed -i 's/dl-cdn.alpinelinux.org/mirrors.aliyun.com/g' /etc/apk/repositories 8 | 9 | RUN apk update --no-cache && apk add --no-cache tzdata 10 | 11 | WORKDIR /build 12 | 13 | ADD go.mod . 14 | ADD go.sum . 15 | RUN go mod download 16 | COPY . . 17 | RUN go build -ldflags="-s -w" -o /app/tproxy . 18 | 19 | 20 | FROM scratch 21 | 22 | COPY --from=builder /etc/ssl/certs/ca-certificates.crt /etc/ssl/certs/ca-certificates.crt 23 | COPY --from=builder /usr/share/zoneinfo/Asia/Shanghai /usr/share/zoneinfo/Asia/Shanghai 24 | ENV TZ Asia/Shanghai 25 | 26 | WORKDIR /app 27 | COPY --from=builder /app/tproxy /usr/local/bin/tproxy 28 | 29 | CMD ["tproxy"] 30 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | MIT License 2 | 3 | Copyright (c) 2022 Kevin Wan 4 | 5 | Permission is hereby granted, free of charge, to any person obtaining a copy 6 | of this software and associated documentation files (the "Software"), to deal 7 | in the Software without restriction, including without limitation the rights 8 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 9 | copies of the Software, and to permit persons to whom the Software is 10 | furnished to do so, subject to the following conditions: 11 | 12 | The above copyright notice and this permission notice shall be included in all 13 | copies or substantial portions of the Software. 14 | 15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 17 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 18 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 19 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 20 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 21 | SOFTWARE. 22 | -------------------------------------------------------------------------------- /conn.go: -------------------------------------------------------------------------------- 1 | package main 2 | 3 | import ( 4 | "errors" 5 | "fmt" 6 | "io" 7 | "net" 8 | "strconv" 9 | "sync" 10 | "time" 11 | 12 | "github.com/fatih/color" 13 | "github.com/juju/ratelimit" 14 | "github.com/kevwan/tproxy/display" 15 | "github.com/kevwan/tproxy/protocol" 16 | ) 17 | 18 | const ( 19 | useOfClosedConn = "use of closed network connection" 20 | statInterval = time.Second * 5 21 | ) 22 | 23 | var ( 24 | errClientCanceled = errors.New("client canceled") 25 | stat Stater 26 | ) 27 | 28 | type PairedConnection struct { 29 | id int 30 | cliConn net.Conn 31 | svrConn net.Conn 32 | once sync.Once 33 | stopChan chan struct{} 34 | } 35 | 36 | func NewPairedConnection(id int, cliConn net.Conn) *PairedConnection { 37 | return &PairedConnection{ 38 | id: id, 39 | cliConn: cliConn, 40 | stopChan: make(chan struct{}), 41 | } 42 | } 43 | 44 | func (c *PairedConnection) copyData(dst io.Writer, src io.Reader, tag string) { 45 | _, e := io.Copy(dst, src) 46 | if e != nil { 47 | netOpError, ok := e.(*net.OpError) 48 | if ok && netOpError.Err.Error() != useOfClosedConn { 49 | reason := netOpError.Unwrap().Error() 50 | display.PrintlnWithTime(color.HiRedString("[%d] %s error, %s", c.id, tag, reason)) 51 | } 52 | } 53 | } 54 | 55 | func (c *PairedConnection) copyDataWithRateLimit(dst io.Writer, src io.Reader, tag string, limit int64) { 56 | if limit > 0 { 57 | bucket := ratelimit.NewBucket(time.Second, limit) 58 | src = ratelimit.Reader(src, bucket) 59 | } 60 | 61 | c.copyData(dst, src, tag) 62 | } 63 | 64 | func (c *PairedConnection) handleClientMessage() { 65 | // client closed also trigger server close. 66 | defer c.stop() 67 | 68 | r, w := io.Pipe() 69 | tee := io.MultiWriter(c.svrConn, w) 70 | go protocol.CreateInterop(settings.Protocol).Dump(r, protocol.ClientSide, c.id, settings.Quiet) 71 | c.copyDataWithRateLimit(tee, c.cliConn, protocol.ClientSide, settings.UpLimit) 72 | } 73 | 74 | func (c *PairedConnection) handleServerMessage() { 75 | // server closed also trigger client close. 76 | defer c.stop() 77 | 78 | r, w := io.Pipe() 79 | tee := io.MultiWriter(newDelayedWriter(c.cliConn, settings.Delay, c.stopChan), w) 80 | go protocol.CreateInterop(settings.Protocol).Dump(r, protocol.ServerSide, c.id, settings.Quiet) 81 | c.copyDataWithRateLimit(tee, c.svrConn, protocol.ServerSide, settings.DownLimit) 82 | } 83 | 84 | func (c *PairedConnection) process() { 85 | defer c.stop() 86 | 87 | conn, err := net.Dial("tcp", settings.Remote) 88 | if err != nil { 89 | display.PrintlnWithTime(color.HiRedString("[x][%d] Couldn't connect to server: %v", c.id, err)) 90 | return 91 | } 92 | 93 | display.PrintlnWithTime(color.HiGreenString("[%d] Connected to server: %s", c.id, conn.RemoteAddr())) 94 | 95 | stat.AddConn(strconv.Itoa(c.id), conn.(*net.TCPConn)) 96 | c.svrConn = conn 97 | go c.handleServerMessage() 98 | 99 | c.handleClientMessage() 100 | } 101 | 102 | func (c *PairedConnection) stop() { 103 | c.once.Do(func() { 104 | close(c.stopChan) 105 | stat.DelConn(strconv.Itoa(c.id)) 106 | 107 | if c.cliConn != nil { 108 | display.PrintlnWithTime(color.HiBlueString("[%d] Client connection closed", c.id)) 109 | c.cliConn.Close() 110 | } 111 | if c.svrConn != nil { 112 | display.PrintlnWithTime(color.HiBlueString("[%d] Server connection closed", c.id)) 113 | c.svrConn.Close() 114 | } 115 | }) 116 | } 117 | 118 | func startListener() error { 119 | stat = NewStater(NewConnCounter(), NewStatPrinter(statInterval)) 120 | go stat.Start() 121 | 122 | conn, err := net.Listen("tcp", fmt.Sprintf("%s:%d", settings.LocalHost, settings.LocalPort)) 123 | if err != nil { 124 | return fmt.Errorf("failed to start listener: %w", err) 125 | } 126 | defer conn.Close() 127 | 128 | display.PrintfWithTime("Listening on %s...\n", conn.Addr().String()) 129 | 130 | var connIndex int 131 | for { 132 | cliConn, err := conn.Accept() 133 | if err != nil { 134 | return fmt.Errorf("server: accept: %w", err) 135 | } 136 | 137 | connIndex++ 138 | display.PrintlnWithTime(color.HiGreenString("[%d] Accepted from: %s", 139 | connIndex, cliConn.RemoteAddr())) 140 | 141 | pconn := NewPairedConnection(connIndex, cliConn) 142 | go pconn.process() 143 | } 144 | } 145 | -------------------------------------------------------------------------------- /counter.go: -------------------------------------------------------------------------------- 1 | package main 2 | 3 | import ( 4 | "fmt" 5 | "net" 6 | "sync" 7 | "sync/atomic" 8 | "time" 9 | 10 | "github.com/fatih/color" 11 | ) 12 | 13 | type connCounter struct { 14 | total int64 15 | concurrent int64 16 | max int64 17 | conns map[string]time.Time 18 | maxLifetime time.Duration 19 | lock sync.Mutex 20 | } 21 | 22 | func NewConnCounter() Stater { 23 | return &connCounter{ 24 | conns: make(map[string]time.Time), 25 | } 26 | } 27 | 28 | func (c *connCounter) AddConn(key string, _ *net.TCPConn) { 29 | atomic.AddInt64(&c.total, 1) 30 | val := atomic.AddInt64(&c.concurrent, 1) 31 | max := atomic.LoadInt64(&c.max) 32 | if val > max { 33 | atomic.CompareAndSwapInt64(&c.max, max, val) 34 | } 35 | 36 | c.lock.Lock() 37 | defer c.lock.Unlock() 38 | c.conns[key] = time.Now() 39 | } 40 | 41 | func (c *connCounter) DelConn(key string) { 42 | atomic.AddInt64(&c.concurrent, -1) 43 | 44 | c.lock.Lock() 45 | defer c.lock.Unlock() 46 | start, ok := c.conns[key] 47 | delete(c.conns, key) 48 | if ok { 49 | lifetime := time.Since(start) 50 | if lifetime > c.maxLifetime { 51 | c.maxLifetime = lifetime 52 | } 53 | } 54 | } 55 | 56 | func (c *connCounter) Start() { 57 | } 58 | 59 | func (c *connCounter) Stop() { 60 | c.lock.Lock() 61 | for _, start := range c.conns { 62 | lifetime := time.Since(start) 63 | if lifetime > c.maxLifetime { 64 | c.maxLifetime = lifetime 65 | } 66 | } 67 | defer c.lock.Unlock() 68 | 69 | fmt.Println() 70 | color.HiWhite("Connection stats (client -> tproxy -> server):") 71 | color.HiWhite(" Total connections: %d", atomic.LoadInt64(&c.total)) 72 | color.HiWhite(" Max concurrent connections: %d", atomic.LoadInt64(&c.max)) 73 | color.HiWhite(" Max connection lifetime: %s", c.maxLifetime) 74 | } 75 | -------------------------------------------------------------------------------- /delayedwriter.go: -------------------------------------------------------------------------------- 1 | package main 2 | 3 | import ( 4 | "io" 5 | "time" 6 | ) 7 | 8 | type delayedWriter struct { 9 | writer io.Writer 10 | delay time.Duration 11 | stopChan <-chan struct{} 12 | } 13 | 14 | func newDelayedWriter(writer io.Writer, delay time.Duration, stopChan <-chan struct{}) delayedWriter { 15 | return delayedWriter{ 16 | writer: writer, 17 | delay: delay, 18 | stopChan: stopChan, 19 | } 20 | } 21 | 22 | func (w delayedWriter) Write(p []byte) (int, error) { 23 | if w.delay == 0 { 24 | return w.writer.Write(p) 25 | } 26 | 27 | timer := time.NewTimer(w.delay) 28 | defer timer.Stop() 29 | 30 | select { 31 | case <-timer.C: 32 | return w.writer.Write(p) 33 | case <-w.stopChan: 34 | return 0, errClientCanceled 35 | } 36 | } 37 | -------------------------------------------------------------------------------- /display/print.go: -------------------------------------------------------------------------------- 1 | package display 2 | 3 | import ( 4 | "fmt" 5 | "time" 6 | ) 7 | 8 | const TimeFormat = "15:04:05.000" 9 | 10 | func PrintfWithTime(format string, args ...any) { 11 | args = append([]interface{}{time.Now().Format(TimeFormat)}, args...) 12 | fmt.Printf("%s "+format, args...) 13 | } 14 | 15 | func PrintlnWithTime(args ...any) { 16 | args = append([]interface{}{time.Now().Format(TimeFormat)}, args...) 17 | fmt.Println(args...) 18 | } 19 | -------------------------------------------------------------------------------- /go.mod: -------------------------------------------------------------------------------- 1 | module github.com/kevwan/tproxy 2 | 3 | go 1.24 4 | 5 | require ( 6 | github.com/eclipse/paho.mqtt.golang v1.5.0 7 | github.com/fatih/color v1.18.0 8 | github.com/juju/ratelimit v1.0.2 9 | github.com/olekukonko/tablewriter v0.0.5 10 | go.mongodb.org/mongo-driver v1.17.4 11 | golang.org/x/net v0.41.0 12 | google.golang.org/protobuf v1.36.6 13 | ) 14 | 15 | require ( 16 | github.com/mattn/go-colorable v0.1.14 // indirect 17 | github.com/mattn/go-isatty v0.0.20 // indirect 18 | github.com/mattn/go-runewidth v0.0.16 // indirect 19 | github.com/rivo/uniseg v0.4.7 // indirect 20 | golang.org/x/sys v0.33.0 // indirect 21 | golang.org/x/text v0.26.0 // indirect 22 | gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c // indirect 23 | ) 24 | -------------------------------------------------------------------------------- /go.sum: -------------------------------------------------------------------------------- 1 | github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c= 2 | github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= 3 | github.com/eclipse/paho.mqtt.golang v1.5.0 h1:EH+bUVJNgttidWFkLLVKaQPGmkTUfQQqjOsyvMGvD6o= 4 | github.com/eclipse/paho.mqtt.golang v1.5.0/go.mod h1:du/2qNQVqJf/Sqs4MEL77kR8QTqANF7XU7Fk0aOTAgk= 5 | github.com/fatih/color v1.18.0 h1:S8gINlzdQ840/4pfAwic/ZE0djQEH3wM94VfqLTZcOM= 6 | github.com/fatih/color v1.18.0/go.mod h1:4FelSpRwEGDpQ12mAdzqdOukCy4u8WUtOY6lkT/6HfU= 7 | github.com/google/go-cmp v0.6.0 h1:ofyhxvXcZhMsU5ulbFiLKl/XBFqE1GSq7atu8tAmTRI= 8 | github.com/google/go-cmp v0.6.0/go.mod h1:17dUlkBOakJ0+DkrSSNjCkIjxS6bF9zb3elmeNGIjoY= 9 | github.com/juju/ratelimit v1.0.2 h1:sRxmtRiajbvrcLQT7S+JbqU0ntsb9W2yhSdNN8tWfaI= 10 | github.com/juju/ratelimit v1.0.2/go.mod h1:qapgC/Gy+xNh9UxzV13HGGl/6UXNN+ct+vwSgWNm/qk= 11 | github.com/kr/pretty v0.2.1 h1:Fmg33tUaq4/8ym9TJN1x7sLJnHVwhP33CNkpYV/7rwI= 12 | github.com/kr/pretty v0.2.1/go.mod h1:ipq/a2n7PKx3OHsz4KJII5eveXtPO4qwEXGdVfWzfnI= 13 | github.com/kr/pty v1.1.1/go.mod h1:pFQYn66WHrOpPYNljwOMqo10TkYh1fy3cYio2l3bCsQ= 14 | github.com/kr/text v0.1.0 h1:45sCR5RtlFHMR4UwH9sdQ5TC8v0qDQCHnXt+kaKSTVE= 15 | github.com/kr/text v0.1.0/go.mod h1:4Jbv+DJW3UT/LiOwJeYQe1efqtUx/iVham/4vfdArNI= 16 | github.com/mattn/go-colorable v0.1.14 h1:9A9LHSqF/7dyVVX6g0U9cwm9pG3kP9gSzcuIPHPsaIE= 17 | github.com/mattn/go-colorable v0.1.14/go.mod h1:6LmQG8QLFO4G5z1gPvYEzlUgJ2wF+stgPZH1UqBm1s8= 18 | github.com/mattn/go-isatty v0.0.20 h1:xfD0iDuEKnDkl03q4limB+vH+GxLEtL/jb4xVJSWWEY= 19 | github.com/mattn/go-isatty v0.0.20/go.mod h1:W+V8PltTTMOvKvAeJH7IuucS94S2C6jfK/D7dTCTo3Y= 20 | github.com/mattn/go-runewidth v0.0.9/go.mod h1:H031xJmbD/WCDINGzjvQ9THkh0rPKHF+m2gUSrubnMI= 21 | github.com/mattn/go-runewidth v0.0.16 h1:E5ScNMtiwvlvB5paMFdw9p4kSQzbXFikJ5SQO6TULQc= 22 | github.com/mattn/go-runewidth v0.0.16/go.mod h1:Jdepj2loyihRzMpdS35Xk/zdY8IAYHsh153qUoGf23w= 23 | github.com/olekukonko/tablewriter v0.0.5 h1:P2Ga83D34wi1o9J6Wh1mRuqd4mF/x/lgBS7N7AbDhec= 24 | github.com/olekukonko/tablewriter v0.0.5/go.mod h1:hPp6KlRPjbx+hW8ykQs1w3UBbZlj6HuIJcUGPhkA7kY= 25 | github.com/rivo/uniseg v0.2.0/go.mod h1:J6wj4VEh+S6ZtnVlnTBMWIodfgj8LQOQFoIToxlJtxc= 26 | github.com/rivo/uniseg v0.4.7 h1:WUdvkW8uEhrYfLC4ZzdpI2ztxP1I582+49Oc5Mq64VQ= 27 | github.com/rivo/uniseg v0.4.7/go.mod h1:FN3SvrM+Zdj16jyLfmOkMNblXMcoc8DfTHruCPUcx88= 28 | go.mongodb.org/mongo-driver v1.17.4 h1:jUorfmVzljjr0FLzYQsGP8cgN/qzzxlY9Vh0C9KFXVw= 29 | go.mongodb.org/mongo-driver v1.17.4/go.mod h1:Hy04i7O2kC4RS06ZrhPRqj/u4DTYkFDAAccj+rVKqgQ= 30 | golang.org/x/net v0.41.0 h1:vBTly1HeNPEn3wtREYfy4GZ/NECgw2Cnl+nK6Nz3uvw= 31 | golang.org/x/net v0.41.0/go.mod h1:B/K4NNqkfmg07DQYrbwvSluqCJOOXwUjeb/5lOisjbA= 32 | golang.org/x/sys v0.6.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= 33 | golang.org/x/sys v0.33.0 h1:q3i8TbbEz+JRD9ywIRlyRAQbM0qF7hu24q3teo2hbuw= 34 | golang.org/x/sys v0.33.0/go.mod h1:BJP2sWEmIv4KK5OTEluFJCKSidICx8ciO85XgH3Ak8k= 35 | golang.org/x/text v0.26.0 h1:P42AVeLghgTYr4+xUnTRKDMqpar+PtX7KWuNQL21L8M= 36 | golang.org/x/text v0.26.0/go.mod h1:QK15LZJUUQVJxhz7wXgxSy/CJaTFjd0G+YLonydOVQA= 37 | google.golang.org/protobuf v1.36.6 h1:z1NpPI8ku2WgiWnf+t9wTPsn6eP1L7ksHUlkfLvd9xY= 38 | google.golang.org/protobuf v1.36.6/go.mod h1:jduwjTPXsFjZGTmRluh+L6NjiWu7pchiJ2/5YcXBHnY= 39 | gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c h1:Hei/4ADfdWqJk1ZMxUNpqntNwaWcugrBjAiHlqqRiVk= 40 | gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c/go.mod h1:JHkPIbrfpd72SG/EVd6muEfDQjcINNoR0C8j2r3qZ4Q= 41 | -------------------------------------------------------------------------------- /images/logo.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/kevwan/tproxy/7f4bce0c81ab1d5db52e5a40da42e2023fb2ae97/images/logo.png -------------------------------------------------------------------------------- /protocol/grpc.go: -------------------------------------------------------------------------------- 1 | package protocol 2 | 3 | import ( 4 | "encoding/binary" 5 | "fmt" 6 | "strings" 7 | 8 | "google.golang.org/protobuf/encoding/protowire" 9 | ) 10 | 11 | type grpcExplainer struct{} 12 | 13 | func (g *grpcExplainer) explain(b []byte) string { 14 | if len(b) < grpcHeaderLen { 15 | return "" 16 | } 17 | 18 | if int(b[0]) == 1 { 19 | return "" 20 | } 21 | 22 | b = b[1:] 23 | // 4 bytes as the pb message length 24 | size := binary.BigEndian.Uint32(b) 25 | b = b[4:] 26 | if len(b) < int(size) { 27 | return "" 28 | } 29 | 30 | var builder strings.Builder 31 | g.explainFields(b[:size], &builder, 0) 32 | return builder.String() 33 | } 34 | 35 | func (g *grpcExplainer) explainFields(b []byte, builder *strings.Builder, depth int) bool { 36 | for len(b) > 0 { 37 | num, tp, n := protowire.ConsumeTag(b) 38 | if n < 0 { 39 | return false 40 | } 41 | b = b[n:] 42 | 43 | switch tp { 44 | case protowire.VarintType: 45 | _, n = protowire.ConsumeVarint(b) 46 | if n < 0 { 47 | return false 48 | } 49 | b = b[n:] 50 | write(builder, fmt.Sprintf("#%d: (varint)\n", num), depth) 51 | case protowire.Fixed32Type: 52 | _, n = protowire.ConsumeFixed32(b) 53 | if n < 0 { 54 | return false 55 | } 56 | b = b[n:] 57 | write(builder, fmt.Sprintf("#%d: (fixed32)\n", num), depth) 58 | case protowire.Fixed64Type: 59 | _, n = protowire.ConsumeFixed64(b) 60 | if n < 0 { 61 | return false 62 | } 63 | b = b[n:] 64 | write(builder, fmt.Sprintf("#%d: (fixed64)\n", num), depth) 65 | case protowire.BytesType: 66 | v, n := protowire.ConsumeBytes(b) 67 | if n < 0 { 68 | return false 69 | } 70 | var buf strings.Builder 71 | if g.explainFields(b[1:n], &buf, depth+1) { 72 | write(builder, fmt.Sprintf("#%d:\n", num), depth) 73 | builder.WriteString(buf.String()) 74 | } else { 75 | write(builder, fmt.Sprintf("#%d: %s\n", num, v), depth) 76 | } 77 | b = b[n:] 78 | default: 79 | _, _, n = protowire.ConsumeField(b) 80 | if n < 0 { 81 | return false 82 | } 83 | b = b[n:] 84 | } 85 | } 86 | 87 | return true 88 | } 89 | 90 | func write(builder *strings.Builder, val string, depth int) { 91 | for i := 0; i < depth; i++ { 92 | builder.WriteString(" ") 93 | } 94 | builder.WriteString(val) 95 | } 96 | -------------------------------------------------------------------------------- /protocol/http2.go: -------------------------------------------------------------------------------- 1 | package protocol 2 | 3 | import ( 4 | "bytes" 5 | "encoding/binary" 6 | "encoding/hex" 7 | "fmt" 8 | "io" 9 | "strings" 10 | 11 | "github.com/fatih/color" 12 | "github.com/kevwan/tproxy/display" 13 | "golang.org/x/net/http2" 14 | "golang.org/x/net/http2/hpack" 15 | ) 16 | 17 | const ( 18 | http2HeaderLen = 9 19 | http2Preface = "PRI * HTTP/2.0\r\n\r\nSM\r\n\r\n" 20 | http2SettingsPayloadLen = 6 21 | grpcHeaderLen = 5 22 | ) 23 | 24 | type ( 25 | dataExplainer interface { 26 | explain(b []byte) string 27 | } 28 | 29 | http2Interop struct { 30 | explainer dataExplainer 31 | } 32 | ) 33 | 34 | func (i *http2Interop) Dump(r io.Reader, source string, id int, quiet bool) { 35 | i.readPreface(r, source, id) 36 | 37 | data := make([]byte, bufferSize) 38 | for { 39 | n, err := r.Read(data) 40 | if n > 0 && !quiet { 41 | var buf strings.Builder 42 | buf.WriteString(color.HiGreenString("from %s [%d]\n", source, id)) 43 | 44 | var index int 45 | for index < n { 46 | frameInfo, moreInfo, offset := i.explain(data[index:n]) 47 | buf.WriteString(fmt.Sprintf("%s%s%s\n", 48 | color.HiBlueString("%s:(", grpcProtocol), 49 | color.HiYellowString(frameInfo), 50 | color.HiBlueString(")"))) 51 | end := index + offset 52 | if end > n { 53 | end = n 54 | } 55 | buf.WriteString(fmt.Sprint(hex.Dump(data[index:end]))) 56 | if len(moreInfo) > 0 { 57 | buf.WriteString(fmt.Sprintf("\n%s\n\n", strings.TrimSpace(moreInfo))) 58 | } 59 | index += offset 60 | } 61 | 62 | display.PrintfWithTime("%s\n\n", strings.TrimSpace(buf.String())) 63 | } 64 | if err != nil && err != io.EOF { 65 | fmt.Printf("unable to read data %v", err) 66 | break 67 | } 68 | if n == 0 { 69 | break 70 | } 71 | } 72 | } 73 | 74 | func (i *http2Interop) explain(b []byte) (string, string, int) { 75 | if len(b) < http2HeaderLen { 76 | return "", "", len(b) 77 | } 78 | 79 | frame, err := http2.ReadFrameHeader(bytes.NewReader(b[:http2HeaderLen])) 80 | if err != nil { 81 | return "", "", len(b) 82 | } 83 | 84 | frameLen := http2HeaderLen + int(frame.Length) 85 | maxOffset := frameLen 86 | if maxOffset > len(b) { 87 | maxOffset = len(b) 88 | } 89 | 90 | switch frame.Type { 91 | case http2.FrameSettings: 92 | switch frame.Flags { 93 | case http2.FlagSettingsAck: 94 | return "http2:settings:ack", "", frameLen 95 | default: 96 | return i.explainSettings(b[http2HeaderLen:maxOffset]), "", frameLen 97 | } 98 | case http2.FramePing: 99 | id := hex.EncodeToString(b[http2HeaderLen:maxOffset]) 100 | switch frame.Flags { 101 | case http2.FlagPingAck: 102 | return fmt.Sprintf("http2:ping:ack %s", id), "", frameLen 103 | default: 104 | return fmt.Sprintf("http2:ping %s", id), "", frameLen 105 | } 106 | case http2.FrameWindowUpdate: 107 | increment := binary.BigEndian.Uint32(b[http2HeaderLen : http2HeaderLen+4]) 108 | return fmt.Sprintf("http2:window_update window_size_increment:%d", increment), "", frameLen 109 | case http2.FrameHeaders: 110 | info, headers := i.explainHeaders(frame, b[http2HeaderLen:maxOffset]) 111 | var builder strings.Builder 112 | for _, header := range headers { 113 | builder.WriteString(fmt.Sprintf("%s: %s\n", header.Name, header.Value)) 114 | } 115 | return info, builder.String(), frameLen 116 | case http2.FrameData: 117 | if frame.Flags == http2.FlagDataEndStream { 118 | var info string 119 | if i.explainer != nil { 120 | info = i.explainer.explain(b[http2HeaderLen:maxOffset]) 121 | } 122 | return fmt.Sprintf("http2:%s stream:%d len:%d end_stream", 123 | strings.ToLower(frame.Type.String()), frame.StreamID, frame.Length), info, frameLen 124 | } else { 125 | // TODO: handle data frame without end_stream flag 126 | return fmt.Sprintf("http2:%s stream:%d len:%d", 127 | strings.ToLower(frame.Type.String()), frame.StreamID, frame.Length), "", frameLen 128 | } 129 | } 130 | 131 | if frame.StreamID > 0 { 132 | desc := fmt.Sprintf("http2:%s stream:%d len:%d", 133 | strings.ToLower(frame.Type.String()), frame.StreamID, frame.Length) 134 | return desc, "", frameLen 135 | } 136 | 137 | return "http2:" + strings.ToLower(frame.Type.String()), "", frameLen 138 | } 139 | 140 | func (i *http2Interop) explainHeaders(frame http2.FrameHeader, b []byte) (string, []hpack.HeaderField) { 141 | var padded int 142 | var weight int 143 | if frame.Flags&http2.FlagHeadersPadded != 0 { 144 | padded = int(b[0]) 145 | b = b[1 : len(b)-padded] 146 | } 147 | if frame.Flags&http2.FlagHeadersPriority != 0 { 148 | b = b[4:] 149 | weight = int(b[0]) 150 | b = b[1:] 151 | } 152 | 153 | var buf strings.Builder 154 | buf.WriteString(fmt.Sprintf("http2:headers stream:%d", frame.StreamID)) 155 | 156 | switch { 157 | case frame.Flags&http2.FlagHeadersEndStream != 0: 158 | buf.WriteString(" end_stream") 159 | case frame.Flags&http2.FlagHeadersEndHeaders != 0: 160 | buf.WriteString(" end_headers") 161 | case frame.Flags&http2.FlagHeadersPadded != 0: 162 | buf.WriteString(" padded") 163 | case frame.Flags&http2.FlagHeadersPriority != 0: 164 | buf.WriteString(" priority") 165 | } 166 | 167 | if weight > 0 { 168 | buf.WriteString(fmt.Sprintf(" weight:%d", weight)) 169 | } 170 | 171 | if frame.Flags&http2.FlagHeadersEndStream != 0 || frame.Flags&http2.FlagHeadersEndHeaders != 0 { 172 | headers, err := hpack.NewDecoder(0, nil).DecodeFull(b) 173 | if err != nil { 174 | return buf.String(), nil 175 | } 176 | 177 | return buf.String(), headers 178 | } 179 | 180 | return buf.String(), nil 181 | } 182 | 183 | func (i *http2Interop) explainSettings(b []byte) string { 184 | var builder strings.Builder 185 | 186 | builder.WriteString("http2:settings") 187 | for i := 0; i < len(b)/http2SettingsPayloadLen; i++ { 188 | start := i * http2SettingsPayloadLen 189 | flag := binary.BigEndian.Uint16(b[start : start+2]) 190 | value := binary.BigEndian.Uint32(b[start+2 : start+http2SettingsPayloadLen]) 191 | 192 | switch http2.SettingID(flag) { 193 | case http2.SettingHeaderTableSize: 194 | builder.WriteString(fmt.Sprintf(" header_table_size:%d", value)) 195 | case http2.SettingEnablePush: 196 | builder.WriteString(fmt.Sprintf(" enable_push:%d", value)) 197 | case http2.SettingMaxConcurrentStreams: 198 | builder.WriteString(fmt.Sprintf(" max_concurrent_streams:%d", value)) 199 | case http2.SettingInitialWindowSize: 200 | builder.WriteString(fmt.Sprintf(" initial_window_size:%d", value)) 201 | case http2.SettingMaxFrameSize: 202 | builder.WriteString(fmt.Sprintf(" max_frame_size:%d", value)) 203 | case http2.SettingMaxHeaderListSize: 204 | builder.WriteString(fmt.Sprintf(" max_header_list_size:%d", value)) 205 | } 206 | } 207 | 208 | return builder.String() 209 | } 210 | 211 | func (i *http2Interop) readPreface(r io.Reader, source string, id int) { 212 | if source != ClientSide { 213 | return 214 | } 215 | 216 | preface := make([]byte, len(http2Preface)) 217 | n, err := r.Read(preface) 218 | if err != nil || n < len(http2Preface) { 219 | return 220 | } 221 | 222 | fmt.Println() 223 | var builder strings.Builder 224 | builder.WriteString(color.HiGreenString("from %s [%d]\n", source, id)) 225 | builder.WriteString(fmt.Sprintf("%s%s%s\n", 226 | color.HiBlueString("%s:(", grpcProtocol), 227 | color.YellowString("http2:preface"), 228 | color.HiBlueString(")"))) 229 | builder.WriteString(fmt.Sprint(hex.Dump(preface))) 230 | display.PrintlnWithTime(builder.String()) 231 | } 232 | -------------------------------------------------------------------------------- /protocol/interop.go: -------------------------------------------------------------------------------- 1 | package protocol 2 | 3 | import ( 4 | "encoding/hex" 5 | "fmt" 6 | "io" 7 | 8 | "github.com/kevwan/tproxy/display" 9 | ) 10 | 11 | const ( 12 | ServerSide = "SERVER" 13 | ClientSide = "CLIENT" 14 | 15 | bufferSize = 1 << 20 16 | grpcProtocol = "grpc" 17 | http2Protocol = "http2" 18 | redisProtocol = "redis" 19 | mongoProtocol = "mongo" 20 | mqttProtocol = "mqtt" 21 | mysqlProtocol = "mysql" 22 | textProtocol = "text" 23 | ) 24 | 25 | var interop defaultInterop 26 | 27 | type Interop interface { 28 | Dump(r io.Reader, source string, id int, quiet bool) 29 | } 30 | 31 | func CreateInterop(protocol string) Interop { 32 | switch protocol { 33 | case textProtocol: 34 | return new(textInterop) 35 | case grpcProtocol: 36 | return &http2Interop{ 37 | explainer: new(grpcExplainer), 38 | } 39 | case http2Protocol: 40 | return new(http2Interop) 41 | case redisProtocol: 42 | return new(redisInterop) 43 | case mongoProtocol: 44 | return new(mongoInterop) 45 | case mqttProtocol: 46 | return new(mqttInterop) 47 | case mysqlProtocol: 48 | return new(mysqlInterop) 49 | default: 50 | return interop 51 | } 52 | } 53 | 54 | type defaultInterop struct{} 55 | 56 | func (d defaultInterop) Dump(r io.Reader, source string, id int, quiet bool) { 57 | data := make([]byte, bufferSize) 58 | for { 59 | n, err := r.Read(data) 60 | if n > 0 && !quiet { 61 | display.PrintfWithTime("from %s [%d]:\n", source, id) 62 | fmt.Println(hex.Dump(data[:n])) 63 | } 64 | if err != nil && err != io.EOF { 65 | fmt.Printf("unable to read data %v", err) 66 | break 67 | } 68 | if n == 0 { 69 | break 70 | } 71 | } 72 | } 73 | -------------------------------------------------------------------------------- /protocol/mongo.go: -------------------------------------------------------------------------------- 1 | package protocol 2 | 3 | import ( 4 | "bytes" 5 | "encoding/binary" 6 | "encoding/json" 7 | "fmt" 8 | "io" 9 | 10 | "github.com/fatih/color" 11 | "github.com/kevwan/tproxy/display" 12 | "go.mongodb.org/mongo-driver/bson" 13 | ) 14 | 15 | const ( 16 | OpReply = 1 // Reply to a client request. responseTo is set. 17 | OpUpdate = 2001 // Update document. 18 | OpInsert = 2002 // Insert new document. 19 | Reserved = 2003 // Formerly used for OP_GET_BY_OID. 20 | OpQuery = 2004 // Query a collection. 21 | OpGetMore = 2005 // Get more data from a query. See Cursors. 22 | OpDelete = 2006 // Delete documents. 23 | OpKillCursors = 2007 // Notify database that the client has finished with the cursor. 24 | OpCommand = 2010 // Cluster internal protocol representing a command request. 25 | OpCommandreply = 2011 // Cluster internal protocol representing a reply to an OP_COMMAND. 26 | OpMsg = 2013 // Send a message using the format introduced in MongoDB 3.6. 27 | ) 28 | 29 | type mongoInterop struct { 30 | } 31 | 32 | type packet struct { 33 | IsClientFlow bool // client->server 34 | MessageLength int 35 | OpCode int // request type 36 | Payload io.Reader 37 | } 38 | 39 | func (mongo *mongoInterop) Dump(r io.Reader, source string, id int, quiet bool) { 40 | var pk *packet 41 | for { 42 | pk = newPacket(source, r) 43 | if pk == nil { 44 | return 45 | } 46 | if pk.IsClientFlow { 47 | resolveClientPacket(pk) 48 | } 49 | } 50 | } 51 | 52 | func resolveClientPacket(pk *packet) { 53 | var msg string 54 | switch pk.OpCode { 55 | case OpUpdate: 56 | _ = readInt32(pk.Payload) 57 | fullCollectionName := readString(pk.Payload) 58 | _ = readInt32(pk.Payload) 59 | selector := readBson2Json(pk.Payload) 60 | update := readBson2Json(pk.Payload) 61 | 62 | msg = fmt.Sprintf(" [Update] [coll:%s] %v %v", 63 | fullCollectionName, 64 | selector, 65 | update, 66 | ) 67 | 68 | case OpInsert: 69 | _ = readInt32(pk.Payload) 70 | fullCollectionName := readString(pk.Payload) 71 | command := readBson2Json(pk.Payload) 72 | 73 | msg = fmt.Sprintf(" [Insert] [coll:%s] %v", 74 | fullCollectionName, 75 | command, 76 | ) 77 | 78 | case OpQuery: 79 | _ = readInt32(pk.Payload) 80 | fullCollectionName := readString(pk.Payload) 81 | _ = readInt32(pk.Payload) 82 | _ = readInt32(pk.Payload) 83 | 84 | command := readBson2Json(pk.Payload) 85 | selector := readBson2Json(pk.Payload) 86 | 87 | msg = fmt.Sprintf(" [Query] [coll:%s] %v %v", 88 | fullCollectionName, 89 | command, 90 | selector, 91 | ) 92 | 93 | case OpCommand: 94 | database := readString(pk.Payload) 95 | commandName := readString(pk.Payload) 96 | metaData := readBson2Json(pk.Payload) 97 | commandArgs := readBson2Json(pk.Payload) 98 | inputDocs := readBson2Json(pk.Payload) 99 | 100 | msg = fmt.Sprintf(" [Commend] [DB:%s] [Cmd:%s] %v %v %v", 101 | database, 102 | commandName, 103 | metaData, 104 | commandArgs, 105 | inputDocs, 106 | ) 107 | 108 | case OpGetMore: 109 | _ = readInt32(pk.Payload) 110 | fullCollectionName := readString(pk.Payload) 111 | numberToReturn := readInt32(pk.Payload) 112 | cursorId := readInt64(pk.Payload) 113 | 114 | msg = fmt.Sprintf(" [Query more] [coll:%s] [num of reply:%v] [cursor:%v]", 115 | fullCollectionName, 116 | numberToReturn, 117 | cursorId, 118 | ) 119 | 120 | case OpDelete: 121 | _ = readInt32(pk.Payload) 122 | fullCollectionName := readString(pk.Payload) 123 | _ = readInt32(pk.Payload) 124 | selector := readBson2Json(pk.Payload) 125 | 126 | msg = fmt.Sprintf(" [Delete] [coll:%s] %v", 127 | fullCollectionName, 128 | selector, 129 | ) 130 | 131 | case OpMsg: 132 | return 133 | default: 134 | return 135 | } 136 | 137 | display.PrintlnWithTime(getDirectionStr(true) + msg) 138 | } 139 | 140 | func newPacket(source string, r io.Reader) *packet { 141 | // read pk 142 | var pk *packet 143 | var err error 144 | pk, err = parsePacket(r) 145 | 146 | // stream close 147 | if err == io.EOF { 148 | display.PrintlnWithTime(" close") 149 | return nil 150 | } else if err != nil { 151 | display.PrintlnWithTime("ERR : Unknown stream", ":", err) 152 | return nil 153 | } 154 | 155 | // set flow direction 156 | if source == "SERVER" { 157 | pk.IsClientFlow = false 158 | } else { 159 | pk.IsClientFlow = true 160 | } 161 | 162 | return pk 163 | } 164 | 165 | func parsePacket(r io.Reader) (*packet, error) { 166 | var buf bytes.Buffer 167 | p := &packet{} 168 | 169 | // header 170 | header := make([]byte, 16) 171 | if _, err := io.ReadFull(r, header); err != nil { 172 | return nil, err 173 | } 174 | 175 | // message length 176 | payloadLen := binary.LittleEndian.Uint32(header[0:4]) - 16 177 | p.MessageLength = int(payloadLen) 178 | 179 | // OpCode 180 | p.OpCode = int(binary.LittleEndian.Uint32(header[12:])) 181 | 182 | if p.MessageLength != 0 { 183 | io.CopyN(&buf, r, int64(payloadLen)) 184 | } 185 | 186 | p.Payload = bytes.NewReader(buf.Bytes()) 187 | 188 | return p, nil 189 | } 190 | 191 | func getDirectionStr(isClient bool) string { 192 | var msg string 193 | if isClient { 194 | msg += "| cli -> ser |" 195 | } else { 196 | msg += "| ser -> cli |" 197 | } 198 | return color.HiBlueString("%s", msg) 199 | } 200 | 201 | func readInt32(r io.Reader) (n int32) { 202 | binary.Read(r, binary.LittleEndian, &n) 203 | return 204 | } 205 | 206 | func readInt64(r io.Reader) int64 { 207 | var n int64 208 | binary.Read(r, binary.LittleEndian, &n) 209 | return n 210 | } 211 | 212 | func readString(r io.Reader) string { 213 | var result []byte 214 | var b = make([]byte, 1) 215 | for { 216 | 217 | _, err := r.Read(b) 218 | 219 | if err != nil { 220 | panic(err) 221 | } 222 | 223 | if b[0] == '\x00' { 224 | break 225 | } 226 | 227 | result = append(result, b[0]) 228 | } 229 | 230 | return string(result) 231 | } 232 | 233 | func readBson2Json(r io.Reader) string { 234 | // read len 235 | docLen := readInt32(r) 236 | if docLen == 0 { 237 | return "" 238 | } 239 | 240 | // document []byte 241 | docBytes := make([]byte, int(docLen)) 242 | binary.LittleEndian.PutUint32(docBytes, uint32(docLen)) 243 | if _, err := io.ReadFull(r, docBytes[4:]); err != nil { 244 | panic(err) 245 | } 246 | 247 | // resolve document 248 | var bsn bson.M 249 | err := bson.Unmarshal(docBytes, &bsn) 250 | if err != nil { 251 | panic(err) 252 | } 253 | 254 | // format to Json 255 | b, err := json.Marshal(bsn) 256 | if err != nil { 257 | return fmt.Sprintf("{\"error\":%q}", err.Error()) 258 | } 259 | 260 | return string(b) 261 | } 262 | -------------------------------------------------------------------------------- /protocol/mqtt.go: -------------------------------------------------------------------------------- 1 | package protocol 2 | 3 | import ( 4 | "io" 5 | 6 | "github.com/eclipse/paho.mqtt.golang/packets" 7 | "github.com/kevwan/tproxy/display" 8 | ) 9 | 10 | type mqttInterop struct { 11 | } 12 | 13 | func (red *mqttInterop) Dump(r io.Reader, source string, id int, quiet bool) { 14 | for { 15 | readPacket, err := packets.ReadPacket(r) 16 | if err != nil && err == io.EOF { 17 | continue 18 | } 19 | if err != nil { 20 | display.PrintfWithTime("[%s-%d] read packet has err: %+v, stop!!!\n", source, id, err) 21 | return 22 | } 23 | if !quiet { 24 | display.PrintfWithTime("[%s-%d] %s\n", source, id, readPacket.String()) 25 | continue 26 | } 27 | } 28 | } 29 | -------------------------------------------------------------------------------- /protocol/mysql.go: -------------------------------------------------------------------------------- 1 | package protocol 2 | 3 | import ( 4 | "bytes" 5 | "encoding/binary" 6 | "encoding/hex" 7 | "errors" 8 | "fmt" 9 | "github.com/fatih/color" 10 | "github.com/kevwan/tproxy/display" 11 | "io" 12 | "strings" 13 | "unicode" 14 | "unicode/utf8" 15 | ) 16 | 17 | type mysqlInterop struct{} 18 | 19 | const maxDecodeResponseBodySize = 32 * 1 << 10 // Limit 32KB (only result set may reach this limitation.) 20 | 21 | var comTypeMap = map[byte]string{ 22 | 0x00: "SLEEP", 23 | 0x01: "QUIT", 24 | 0x02: "INIT_DB", 25 | 0x03: "QUERY", 26 | 0x04: "FIELD_LIST", 27 | 0x05: "CREATE_DB", 28 | 0x06: "DROP_DB", 29 | 0x07: "REFRESH", 30 | 0x08: "SHUTDOWN", 31 | 0x09: "STATISTICS", 32 | 0x0a: "PROCESS_INFO", 33 | 0x0b: "CONNECT", 34 | 0x0c: "KILL", 35 | 0x0d: "DEBUG", 36 | 0x0e: "PING", 37 | 0x0f: "TIME", 38 | 0x10: "DELAYED_INSERT", 39 | 0x11: "CHANGE_USER", 40 | 0x12: "BINLOG_DUMP", 41 | 0x13: "TABLE_DUMP", 42 | 0x14: "CONNECT_OUT", 43 | 0x15: "REGISTER_SLAVE", 44 | 0x16: "PREPARE", 45 | 0x17: "EXECUTE", 46 | 0x18: "SEND_LONG_DATA", 47 | 0x19: "CLOSE_STMT", 48 | 0x1a: "RESET_STMT", 49 | 0x1b: "SET_OPTION", 50 | 0x1c: "FETCH", 51 | } 52 | 53 | var statusFlagMap = map[uint16]string{ 54 | 0x01: "SERVER_STATUS_AUTOCOMMIT", 55 | 0x02: "SERVER_STATUS_COMMAND", 56 | 0x04: "SERVER_STATUS_CONNECTED", 57 | 0x08: "SERVER_STATUS_MORE_RESULTS", 58 | 0x10: "SERVER_STATUS_SESSION_STATE ", 59 | } 60 | 61 | type ServerResponse struct { 62 | PacketLength int 63 | SequenceID byte 64 | Type byte 65 | Data []byte 66 | } 67 | 68 | type ResponsePkgType string 69 | 70 | const ( 71 | MySQLResponseTypeOK ResponsePkgType = "OK" 72 | MySQLResponseTypeError ResponsePkgType = "Error" 73 | MySQLResponseTypeEOF ResponsePkgType = "EOF" 74 | MySQLResponseTypeResultSet ResponsePkgType = "Result Set" 75 | MySQLResponseTypeUnknown ResponsePkgType = "Unknown" 76 | ) 77 | 78 | func getPkgType(flag byte) ResponsePkgType { 79 | if flag == 0x00 || flag == 0xfe { 80 | return MySQLResponseTypeOK 81 | } else if flag == 0xff { 82 | return MySQLResponseTypeError 83 | } else if flag > 0x01 && flag < 0xfa { 84 | return MySQLResponseTypeResultSet 85 | } else { 86 | return MySQLResponseTypeUnknown 87 | } 88 | } 89 | 90 | func readLCInt(buf []byte) ([]byte, uint64, error) { 91 | if len(buf) == 0 { 92 | return nil, 0, errors.New("empty buffer") 93 | } 94 | 95 | lcbyte := buf[0] 96 | 97 | switch { 98 | case lcbyte == 0xFB: // 0xFB 99 | return buf[1:], 0, nil 100 | case lcbyte < 0xFB: 101 | return buf[1:], uint64(lcbyte), nil 102 | case lcbyte == 0xFC: // 0xFC 103 | return buf[3:], uint64(binary.LittleEndian.Uint16(buf[1:3])), nil 104 | case lcbyte == 0xFD: // 0xFD 105 | return buf[4:], uint64(binary.LittleEndian.Uint32(append(buf[1:4], 0))), nil 106 | case lcbyte == 0xFE: // 0xFE 107 | return buf[9:], binary.LittleEndian.Uint64(buf[1:9]), nil 108 | default: 109 | return nil, 0, errors.New("failed reading length encoded integer") 110 | } 111 | } 112 | 113 | func processOkResponse(sequenceId byte, payload []byte) { 114 | var ( 115 | affectedRows, lastInsertID uint64 116 | statusFlag string 117 | ok bool 118 | err error 119 | remaining []byte 120 | ) 121 | remaining, affectedRows, err = readLCInt(payload[1:]) 122 | if err != nil { 123 | display.PrintlnWithTime(color.HiRedString("Failed reading length encoded integer: " + err.Error())) 124 | return 125 | } 126 | remaining, lastInsertID, err = readLCInt(remaining) 127 | if err != nil { 128 | display.PrintlnWithTime(color.HiRedString("Failed reading length encoded integer: " + err.Error())) 129 | return 130 | } 131 | 132 | if err != nil { 133 | display.PrintlnWithTime(color.HiRedString("Failed reading length encoded integer: " + err.Error())) 134 | return 135 | } 136 | 137 | statusFlag, ok = statusFlagMap[binary.LittleEndian.Uint16(remaining[:2])] 138 | if !ok { 139 | statusFlag = "unknown" 140 | } 141 | 142 | remaining = remaining[2:] 143 | 144 | warningsCount := binary.LittleEndian.Uint16(remaining[:2]) 145 | 146 | remaining = remaining[2:] 147 | 148 | display.PrintlnWithTime( 149 | fmt.Sprintf("[Server -> Client] %d-%s: affectRows: %d, lastInsertID: %d, warningsCount: %d, status: %s, data: %s", 150 | sequenceId, MySQLResponseTypeOK, affectedRows, lastInsertID, warningsCount, statusFlag, remaining)) 151 | } 152 | 153 | var sqlStateDescriptions = map[string]string{ 154 | "42000": "Syntax error or access rule violation.", 155 | "23000": "Integrity constraint violation.", 156 | "08000": "Connection exception.", 157 | "28000": "Invalid authorization specification.", 158 | "42001": "Syntax error in SQL statement.", 159 | } 160 | 161 | func processErrorResponse(sequenceId byte, payload []byte) { 162 | errCode := binary.LittleEndian.Uint16(payload[1:3]) 163 | sqlStateMarker := payload[3] 164 | sqlState := string(payload[5:9]) 165 | sqlStateDescription, ok := sqlStateDescriptions[sqlState[1:]] 166 | if !ok { 167 | sqlStateDescription = "Unknown SQLSTATE" 168 | } 169 | errorMessage := string(payload[9:]) 170 | 171 | display.PrintfWithTime( 172 | color.HiYellowString(fmt.Sprintf("[Server -> Client] %d-%s: ErrCode: %d, ErrMsg: %s, SqlState: %s, sqlStateMaker: %v", 173 | sequenceId, MySQLResponseTypeError, errCode, errorMessage, sqlStateDescription, sqlStateMarker)), 174 | ) 175 | } 176 | 177 | func processResultSetResponse(sequenceId byte, payload []byte) { 178 | display.PrintfWithTime(fmt.Sprintf("[Server -> Client] %d-%s: \n %s", sequenceId, MySQLResponseTypeResultSet, hexDump(payload))) 179 | 180 | } 181 | 182 | func insertSpace(hexStr string) string { 183 | var spaced strings.Builder 184 | for i := 0; i < len(hexStr); i += 2 { 185 | spaced.WriteString(hexStr[i:i+2] + " ") 186 | } 187 | return spaced.String() 188 | } 189 | 190 | func toPrintableASCII(data []byte) string { 191 | var result strings.Builder 192 | for i := 0; i < len(data); { 193 | r, size := utf8.DecodeRune(data[i:]) 194 | if r == utf8.RuneError && size == 1 { 195 | result.WriteByte('.') 196 | i++ 197 | } else { 198 | if unicode.IsPrint(r) { 199 | result.WriteRune(r) 200 | } else { 201 | result.WriteByte('.') 202 | } 203 | i += size 204 | } 205 | } 206 | return result.String() 207 | } 208 | 209 | func hexDump(data []byte) string { 210 | var result strings.Builder 211 | const chunkSize = 16 212 | 213 | for i := 0; i < len(data); i += chunkSize { 214 | end := i + chunkSize 215 | if end > len(data) { 216 | end = len(data) 217 | } 218 | chunk := data[i:end] 219 | 220 | hexStr := hex.EncodeToString(chunk) 221 | hexStr = insertSpace(hexStr) 222 | asciiStr := toPrintableASCII(chunk) 223 | result.WriteString(fmt.Sprintf("%04x %-48s |%s|\n", i, hexStr, asciiStr)) 224 | } 225 | 226 | return result.String() 227 | } 228 | 229 | func processUnknownResponse(sequenceId byte, payload []byte) { 230 | display.PrintlnWithTime(fmt.Sprintf("[Server -> Client] %d-%s:\n%s", sequenceId, MySQLResponseTypeUnknown, hexDump(payload))) 231 | } 232 | 233 | func (mysql *mysqlInterop) dumpServer(r io.Reader, id int, quiet bool, data []byte) { 234 | if len(data) < 4 { 235 | display.PrintlnWithTime("Invalid packet: insufficient data for header") 236 | return 237 | } 238 | 239 | sequenceId := data[3] 240 | payload := data[4:] 241 | 242 | if len(payload) > maxDecodeResponseBodySize { 243 | display.PrintlnWithTime(color.HiRedString(fmt.Sprintf("Packet too large to, just decode %d MB", maxDecodeResponseBodySize/1024/1024))) 244 | payload = payload[:maxDecodeResponseBodySize] 245 | } 246 | 247 | switch getPkgType(payload[0]) { 248 | case MySQLResponseTypeOK: 249 | processOkResponse(sequenceId, payload) 250 | case MySQLResponseTypeError: 251 | processErrorResponse(sequenceId, payload) 252 | case MySQLResponseTypeResultSet: 253 | processResultSetResponse(sequenceId, payload) 254 | case MySQLResponseTypeEOF: 255 | default: 256 | processUnknownResponse(sequenceId, payload) 257 | } 258 | 259 | } 260 | 261 | func (mysql *mysqlInterop) dumpClient(r io.Reader, id int, quiet bool, data []byte) { 262 | // parse packet length 263 | var ( 264 | packetLength uint32 265 | sequenceId uint32 266 | ) 267 | reader := bytes.NewReader(data[:4]) 268 | err := binary.Read(reader, binary.BigEndian, &packetLength) 269 | if err != nil { 270 | display.PrintfWithTime("Error reading packet length: %v\n", err) 271 | return 272 | } 273 | 274 | // parse command type 275 | commandType := data[4] 276 | commandName := comTypeMap[commandType] 277 | 278 | // parse sequence id 279 | if len(data) < 6 { 280 | sequenceId = 0 281 | } else { 282 | sequenceId = uint32(data[5]) 283 | } 284 | 285 | // parse query 286 | var query []byte 287 | for i := 6; i < len(data); i++ { 288 | if data[i] == 0 { 289 | break 290 | } 291 | query = append(query, data[i]) 292 | } 293 | 294 | if utf8.Valid(query) { 295 | display.PrintlnWithTime(fmt.Sprintf("[Client -> Server] %d-%s: %s", sequenceId, commandName, string(query))) 296 | } else { 297 | display.PrintlnWithTime(color.HiRedString("Invalid Query %v", query)) 298 | } 299 | } 300 | 301 | func (mysql *mysqlInterop) Dump(r io.Reader, source string, id int, quiet bool) { 302 | buffer := make([]byte, bufferSize) 303 | for { 304 | n, err := r.Read(buffer) 305 | if err != nil && err != io.EOF { 306 | display.PrintlnWithTime("Unable to read data: %v", err) 307 | break 308 | } 309 | 310 | if n > 0 && !quiet { 311 | data := buffer[:n] 312 | if source == "CLIENT" { 313 | mysql.dumpClient(r, id, quiet, data) 314 | } else { 315 | mysql.dumpServer(r, id, quiet, data) 316 | } 317 | } 318 | } 319 | } 320 | -------------------------------------------------------------------------------- /protocol/redis.go: -------------------------------------------------------------------------------- 1 | package protocol 2 | 3 | import ( 4 | "bufio" 5 | "io" 6 | "strconv" 7 | "strings" 8 | 9 | "github.com/kevwan/tproxy/display" 10 | ) 11 | 12 | type redisInterop struct { 13 | } 14 | 15 | func (red *redisInterop) Dump(r io.Reader, source string, id int, quiet bool) { 16 | // only parse client send command 17 | buf := bufio.NewReader(r) 18 | for { 19 | // read raw data 20 | line, _, _ := buf.ReadLine() 21 | lineStr := string(line) 22 | if source != "SERVER" && strings.HasPrefix(lineStr, "*") { 23 | cmdCount, _ := strconv.Atoi(strings.TrimLeft(lineStr, "*")) 24 | var sb strings.Builder 25 | for j := 0; j < cmdCount*2; j++ { 26 | c, _, _ := buf.ReadLine() 27 | if j&1 == 0 { // skip param length 28 | continue 29 | } 30 | sb.WriteString(" " + string(c)) 31 | } 32 | display.PrintlnWithTime(strings.TrimSpace(sb.String())) 33 | } 34 | } 35 | } 36 | -------------------------------------------------------------------------------- /protocol/text.go: -------------------------------------------------------------------------------- 1 | package protocol 2 | 3 | import ( 4 | "fmt" 5 | "io" 6 | 7 | "github.com/fatih/color" 8 | "github.com/kevwan/tproxy/display" 9 | ) 10 | 11 | type textInterop struct { 12 | } 13 | 14 | func (op *textInterop) Dump(r io.Reader, source string, id int, quiet bool) { 15 | data := make([]byte, bufferSize) 16 | for { 17 | n, err := r.Read(data) 18 | if n > 0 && !quiet { 19 | display.PrintfWithTime(color.HiYellowString("from %s [%d]:\n", source, id)) 20 | fmt.Println(string(data[:n])) 21 | } 22 | if err != nil && err != io.EOF { 23 | fmt.Printf("unable to read data %v", err) 24 | break 25 | } 26 | if n == 0 { 27 | break 28 | } 29 | } 30 | } 31 | -------------------------------------------------------------------------------- /readme-cn.md: -------------------------------------------------------------------------------- 1 | # tproxy 2 | 3 | [English](readme.md) | 简体中文 | [日本語](readme-ja.md) 4 | 5 | [![Go](https://github.com/kevwan/tproxy/workflows/Go/badge.svg?branch=main)](https://github.com/kevwan/tproxy/actions) 6 | [![Go Report Card](https://goreportcard.com/badge/github.com/kevwan/tproxy)](https://goreportcard.com/report/github.com/kevwan/tproxy) 7 | [![Release](https://img.shields.io/github/v/release/kevwan/tproxy.svg?style=flat-square)](https://github.com/kevwan/tproxy) 8 | [![License: MIT](https://img.shields.io/badge/License-MIT-yellow.svg)](https://opensource.org/licenses/MIT) 9 | 10 | Buy Me A Coffee 11 | 12 | ## 为啥写这个工具 13 | 14 | 当我在做后端开发或者写 [go-zero](https://github.com/zeromicro/go-zero) 的时候,经常会需要监控网络连接,分析请求内容。比如: 15 | 1. 分析 gRPC 连接何时连接、何时重连 16 | 2. 分析 MySQL 连接池,当前多少连接,连接的生命周期是什么策略 17 | 3. 也可以用来观察和分析任何 TCP 连接 18 | 19 | ## 安装 20 | 21 | ```shell 22 | $ GOPROXY=https://goproxy.cn/,direct go install github.com/kevwan/tproxy@latest 23 | ``` 24 | 25 | 或者使用 docker 镜像: 26 | 27 | ```shell 28 | $ docker run --rm -it -p : -p : kevinwan/tproxy:v1 tproxy -l 0.0.0.0 -p -r host.docker.internal: 29 | ``` 30 | 31 | arm64 系统: 32 | 33 | ```shell 34 | $ docker run --rm -it -p : -p : kevinwan/tproxy:v1-arm64 tproxy -l 0.0.0.0 -p -r host.docker.internal: 35 | ``` 36 | 37 | Windows: 38 | 39 | ```shell 40 | $ scoop install tproxy 41 | ``` 42 | 43 | ## 用法 44 | 45 | ```shell 46 | $ tproxy --help 47 | Usage of tproxy: 48 | -d duration 49 | the delay to relay packets 50 | -down int 51 | Downward speed limit(bytes/second) 52 | -l string 53 | Local address to listen on (default "localhost") 54 | -p int 55 | Local port to listen on, default to pick a random port 56 | -q Quiet mode, only prints connection open/close and stats, default false 57 | -r string 58 | Remote address (host:port) to connect 59 | -s Enable statistics 60 | -t string 61 | The type of protocol, currently support http2, grpc, redis and mongodb 62 | -up int 63 | Upward speed limit(bytes/second) 64 | ``` 65 | 66 | ## 示例 67 | 68 | ### 分析 gRPC 连接 69 | 70 | ```shell 71 | $ tproxy -p 8088 -r localhost:8081 -t grpc -d 100ms 72 | ``` 73 | 74 | - 侦听在 localhost 和 8088 端口 75 | - 重定向请求到 `localhost:8081` 76 | - 识别数据包格式为 gRPC 77 | - 数据包延迟100毫秒 78 | 79 | image 80 | 81 | ### 分析 MySQL 连接 82 | 83 | ```shell 84 | $ tproxy -p 3307 -r localhost:3306 85 | ``` 86 | 87 | image 88 | 89 | ### 查看网络状况(重传率和RTT) 90 | 91 | ```shell 92 | $ tproxy -p 3307 -r remotehost:3306 -s -q 93 | ``` 94 | 95 | image 96 | 97 | ### 查看连接池(总连接数、最大并发连接数、最长生命周期等) 98 | 99 | ```shell 100 | $ tproxy -p 3307 -r :3306 -s -q 101 | ``` 102 | 103 | image 104 | 105 | ## 欢迎 star!⭐ 106 | 107 | 如果你正在使用或者觉得这个项目对你有帮助,请 **star** 支持,感谢! 108 | -------------------------------------------------------------------------------- /readme-ja.md: -------------------------------------------------------------------------------- 1 | # tproxy 2 | 3 | [English](readme.md) | [简体中文](readme-cn.md) | 日本語 4 | 5 | [![Go](https://github.com/kevwan/tproxy/workflows/Go/badge.svg?branch=main)](https://github.com/kevwan/tproxy/actions) 6 | [![Go Report Card](https://goreportcard.com/badge/github.com/kevwan/tproxy)](https://goreportcard.com/report/github.com/kevwan/tproxy) 7 | [![Release](https://img.shields.io/github/v/release/kevwan/tproxy.svg?style=flat-square)](https://github.com/kevwan/tproxy) 8 | [![License: MIT](https://img.shields.io/badge/License-MIT-yellow.svg)](https://opensource.org/licenses/MIT) 9 | 10 | Buy Me A Coffee 11 | 12 | ## なぜこのツールを書いたのか 13 | 14 | バックエンドサービスを開発し、[go-zero](https://github.com/zeromicro/go-zero)を書くとき、ネットワークトラフィックを監視する必要がよくあります。例えば: 15 | 1. gRPC接続の監視、接続のタイミングと再接続のタイミング 16 | 2. MySQL接続プールの監視、接続数とライフタイムポリシーの把握 17 | 3. 任意のTCP接続のリアルタイム監視 18 | 19 | ## インストール 20 | 21 | ```shell 22 | $ go install github.com/kevwan/tproxy@latest 23 | ``` 24 | 25 | または、dockerイメージを使用します: 26 | 27 | ```shell 28 | $ docker run --rm -it -p : -p : kevinwan/tproxy:v1 tproxy -l 0.0.0.0 -p -r host.docker.internal: 29 | ``` 30 | 31 | arm64の場合: 32 | 33 | ```shell 34 | $ docker run --rm -it -p : -p : kevinwan/tproxy:v1-arm64 tproxy -l 0.0.0.0 -p -r host.docker.internal: 35 | ``` 36 | 37 | Windowsの場合、[scoop](https://scoop.sh/)を使用できます: 38 | 39 | ```shell 40 | $ scoop install tproxy 41 | ``` 42 | 43 | ## 使用方法 44 | 45 | ```shell 46 | $ tproxy --help 47 | Usage of tproxy: 48 | -d duration 49 | パケットを中継する遅延時間 50 | -down int 51 | 下り速度制限(バイト/秒) 52 | -l string 53 | リッスンするローカルアドレス(デフォルトは "localhost") 54 | -p int 55 | リッスンするローカルポート、デフォルトはランダムポート 56 | -q 静音モード、接続の開閉と統計のみを表示、デフォルトはfalse 57 | -r string 58 | 接続するリモートアドレス(ホスト:ポート) 59 | -s 統計を有効にする 60 | -t string 61 | プロトコルの種類、現在サポートされているのはhttp2、grpc、redis、mongodb 62 | -up int 63 | 上り速度制限(バイト/秒) 64 | ``` 65 | 66 | ## 例 67 | 68 | ### gRPC接続の監視 69 | 70 | ```shell 71 | $ tproxy -p 8088 -r localhost:8081 -t grpc -d 100ms 72 | ``` 73 | 74 | - localhostとポート8088でリッスン 75 | - トラフィックを`localhost:8081`にリダイレクト 76 | - プロトコルタイプをgRPCに設定 77 | - 各パケットの遅延時間を100msに設定 78 | 79 | image 80 | 81 | ### MySQL接続の監視 82 | 83 | ```shell 84 | $ tproxy -p 3307 -r localhost:3306 85 | ``` 86 | 87 | image 88 | 89 | ### 接続の信頼性の確認(再送率とRTT) 90 | 91 | ```shell 92 | $ tproxy -p 3307 -r remotehost:3306 -s -q 93 | ``` 94 | 95 | image 96 | 97 | ### 接続プールの動作を学ぶ 98 | 99 | ```shell 100 | $ tproxy -p 3307 -r localhost:3306 -q -s 101 | ``` 102 | 103 | image 104 | 105 | ## スターを付けてください! ⭐ 106 | 107 | このプロジェクトが気に入ったり、使用している場合は、**スター**を付けてください。ありがとうございます! 108 | -------------------------------------------------------------------------------- /readme.md: -------------------------------------------------------------------------------- 1 | # tproxy 2 | 3 | English | [简体中文](readme-cn.md) | [日本語](readme-ja.md) 4 | 5 | [![Go](https://github.com/kevwan/tproxy/workflows/Go/badge.svg?branch=main)](https://github.com/kevwan/tproxy/actions) 6 | [![Go Report Card](https://goreportcard.com/badge/github.com/kevwan/tproxy)](https://goreportcard.com/report/github.com/kevwan/tproxy) 7 | [![Release](https://img.shields.io/github/v/release/kevwan/tproxy.svg?style=flat-square)](https://github.com/kevwan/tproxy) 8 | [![License: MIT](https://img.shields.io/badge/License-MIT-yellow.svg)](https://opensource.org/licenses/MIT) 9 | 10 | Buy Me A Coffee 11 | 12 | ## Why I wrote this tool 13 | 14 | When I develop backend services and write [go-zero](https://github.com/zeromicro/go-zero), I often need to monitor the network traffic. For example: 15 | 1. monitoring gRPC connections, when to connect and when to reconnect 16 | 2. monitoring MySQL connection pools, how many connections and figure out the lifetime policy 17 | 3. monitoring any TCP connections on the fly 18 | 19 | ## Installation 20 | 21 | ```shell 22 | $ go install github.com/kevwan/tproxy@latest 23 | ``` 24 | 25 | Or use docker images: 26 | 27 | ```shell 28 | $ docker run --rm -it -p : -p : kevinwan/tproxy:v1 tproxy -l 0.0.0.0 -p -r host.docker.internal: 29 | ``` 30 | 31 | For arm64: 32 | 33 | ```shell 34 | $ docker run --rm -it -p : -p : kevinwan/tproxy:v1-arm64 tproxy -l 0.0.0.0 -p -r host.docker.internal: 35 | ``` 36 | 37 | On Windows, you can use [scoop](https://scoop.sh/): 38 | 39 | ```shell 40 | $ scoop install tproxy 41 | ``` 42 | 43 | ## Usages 44 | 45 | ```shell 46 | $ tproxy --help 47 | Usage of tproxy: 48 | -d duration 49 | the delay to relay packets 50 | -down int 51 | Downward speed limit(bytes/second) 52 | -l string 53 | Local address to listen on (default "localhost") 54 | -p int 55 | Local port to listen on, default to pick a random port 56 | -q Quiet mode, only prints connection open/close and stats, default false 57 | -r string 58 | Remote address (host:port) to connect 59 | -s Enable statistics 60 | -t string 61 | The type of protocol, currently support http2, grpc, redis and mongodb 62 | -up int 63 | Upward speed limit(bytes/second) 64 | ``` 65 | 66 | ## Examples 67 | 68 | ### Monitor gRPC connections 69 | 70 | ```shell 71 | $ tproxy -p 8088 -r localhost:8081 -t grpc -d 100ms 72 | ``` 73 | 74 | - listen on localhost and port 8088 75 | - redirect the traffic to `localhost:8081` 76 | - protocol type to be gRPC 77 | - delay 100ms for each packets 78 | 79 | image 80 | 81 | ### Monitor MySQL connections 82 | 83 | ```shell 84 | $ tproxy -p 3307 -r localhost:3306 85 | ``` 86 | 87 | image 88 | 89 | ### Check the connection reliability (Retrans rate and RTT) 90 | 91 | ```shell 92 | $ tproxy -p 3307 -r remotehost:3306 -s -q 93 | ``` 94 | 95 | image 96 | 97 | ### Learn the connection pool behaviors 98 | 99 | ```shell 100 | $ tproxy -p 3307 -r localhost:3306 -q -s 101 | ``` 102 | 103 | image 104 | 105 | ## Give a Star! ⭐ 106 | 107 | If you like or are using this project, please give it a **star**. Thanks! 108 | -------------------------------------------------------------------------------- /settings.go: -------------------------------------------------------------------------------- 1 | package main 2 | 3 | import "time" 4 | 5 | type Settings struct { 6 | Remote string 7 | LocalHost string 8 | LocalPort int 9 | Delay time.Duration 10 | Protocol string 11 | Stat bool 12 | Quiet bool 13 | UpLimit int64 14 | DownLimit int64 15 | } 16 | 17 | func saveSettings(localHost string, localPort int, remote string, delay time.Duration, 18 | protocol string, stat, quiet bool, upLimit, downLimit int64) { 19 | if localHost != "" { 20 | settings.LocalHost = localHost 21 | } 22 | if localPort != 0 { 23 | settings.LocalPort = localPort 24 | } 25 | if remote != "" { 26 | settings.Remote = remote 27 | } 28 | settings.Delay = delay 29 | settings.Protocol = protocol 30 | settings.Stat = stat 31 | settings.Quiet = quiet 32 | settings.UpLimit = upLimit 33 | settings.DownLimit = downLimit 34 | } 35 | -------------------------------------------------------------------------------- /stat+polyfill.go: -------------------------------------------------------------------------------- 1 | //go:build !linux 2 | 3 | package main 4 | 5 | import ( 6 | "net" 7 | "time" 8 | ) 9 | 10 | type StatPrinter struct{} 11 | 12 | func NewStatPrinter(_ time.Duration) Stater { 13 | return StatPrinter{} 14 | } 15 | 16 | func (p StatPrinter) AddConn(_ string, _ *net.TCPConn) { 17 | } 18 | 19 | func (p StatPrinter) DelConn(_ string) { 20 | } 21 | 22 | func (p StatPrinter) Start() { 23 | } 24 | 25 | func (p StatPrinter) Stop() { 26 | } 27 | -------------------------------------------------------------------------------- /stat.go: -------------------------------------------------------------------------------- 1 | package main 2 | 3 | import ( 4 | "fmt" 5 | "net" 6 | "os" 7 | "os/signal" 8 | "syscall" 9 | "time" 10 | ) 11 | 12 | type ( 13 | Stater interface { 14 | AddConn(key string, conn *net.TCPConn) 15 | DelConn(key string) 16 | Start() 17 | Stop() 18 | } 19 | 20 | compositeStater struct { 21 | staters []Stater 22 | } 23 | ) 24 | 25 | func NewStater(staters ...Stater) Stater { 26 | stat := compositeStater{ 27 | staters: append([]Stater(nil), staters...), 28 | } 29 | 30 | go func() { 31 | c := make(chan os.Signal, 1) 32 | signal.Notify(c, os.Interrupt, syscall.SIGTERM) 33 | 34 | for sig := range c { 35 | signal.Stop(c) 36 | stat.Stop() 37 | 38 | p, err := os.FindProcess(syscall.Getpid()) 39 | if err != nil { 40 | fmt.Println(err) 41 | os.Exit(0) 42 | } 43 | 44 | if err := p.Signal(sig); err != nil { 45 | fmt.Println(err) 46 | } 47 | } 48 | }() 49 | 50 | return stat 51 | } 52 | 53 | func (c compositeStater) AddConn(key string, conn *net.TCPConn) { 54 | for _, s := range c.staters { 55 | s.AddConn(key, conn) 56 | } 57 | } 58 | 59 | func (c compositeStater) DelConn(key string) { 60 | for _, s := range c.staters { 61 | s.DelConn(key) 62 | } 63 | } 64 | 65 | func (c compositeStater) Start() { 66 | for _, s := range c.staters { 67 | s.Start() 68 | } 69 | } 70 | 71 | func (c compositeStater) Stop() { 72 | for _, s := range c.staters { 73 | s.Stop() 74 | } 75 | } 76 | 77 | type NilPrinter struct{} 78 | 79 | func NewNilPrinter(_ time.Duration) Stater { 80 | return NilPrinter{} 81 | } 82 | 83 | func (p NilPrinter) AddConn(_ string, _ *net.TCPConn) { 84 | } 85 | 86 | func (p NilPrinter) DelConn(_ string) { 87 | } 88 | 89 | func (p NilPrinter) Start() { 90 | } 91 | 92 | func (p NilPrinter) Stop() { 93 | } 94 | -------------------------------------------------------------------------------- /stat_linux.go: -------------------------------------------------------------------------------- 1 | package main 2 | 3 | import ( 4 | "fmt" 5 | "net" 6 | "os" 7 | "sort" 8 | "sync" 9 | "time" 10 | 11 | "github.com/kevwan/tproxy/display" 12 | "github.com/olekukonko/tablewriter" 13 | ) 14 | 15 | type StatPrinter struct { 16 | duration time.Duration 17 | conns map[string]*net.TCPConn 18 | prev map[string]*TcpInfo 19 | lock sync.RWMutex 20 | } 21 | 22 | func NewStatPrinter(duration time.Duration) Stater { 23 | if !settings.Stat { 24 | return NilPrinter{} 25 | } 26 | 27 | return &StatPrinter{ 28 | duration: duration, 29 | conns: make(map[string]*net.TCPConn), 30 | } 31 | } 32 | 33 | func (p *StatPrinter) AddConn(key string, conn *net.TCPConn) { 34 | p.lock.Lock() 35 | defer p.lock.Unlock() 36 | p.conns[key] = conn 37 | } 38 | 39 | func (p *StatPrinter) DelConn(key string) { 40 | p.lock.Lock() 41 | defer p.lock.Unlock() 42 | delete(p.conns, key) 43 | } 44 | 45 | func (p *StatPrinter) Start() { 46 | ticker := time.NewTicker(p.duration) 47 | defer ticker.Stop() 48 | 49 | for range ticker.C { 50 | p.print() 51 | } 52 | } 53 | 54 | func (p *StatPrinter) Stop() { 55 | p.print() 56 | } 57 | 58 | func (p *StatPrinter) buildRows() [][]string { 59 | var keys []string 60 | infos := make(map[string]*TcpInfo) 61 | p.lock.RLock() 62 | prev := p.prev 63 | for k, v := range p.conns { 64 | info, err := GetTcpInfo(v) 65 | if err != nil { 66 | display.PrintfWithTime("GetTcpInfo: %v\n", err) 67 | continue 68 | } 69 | 70 | keys = append(keys, k) 71 | infos[k] = info 72 | } 73 | p.prev = infos 74 | p.lock.RUnlock() 75 | 76 | var rows [][]string 77 | now := time.Now().Format(display.TimeFormat) 78 | sort.Strings(keys) 79 | for _, k := range keys { 80 | v, ok := infos[k] 81 | if !ok { 82 | continue 83 | } 84 | 85 | var rate string 86 | pinfo, ok := prev[k] 87 | if ok { 88 | rate = fmt.Sprintf("%.2f", GetRetransRate(pinfo, v)) 89 | } else { 90 | rate = "-" 91 | } 92 | rtt, rttv := v.GetRTT() 93 | rows = append(rows, []string{now, k, rate, fmt.Sprint(rtt), fmt.Sprint(rttv)}) 94 | } 95 | 96 | return rows 97 | } 98 | 99 | func (p *StatPrinter) print() { 100 | rows := p.buildRows() 101 | if len(rows) == 0 { 102 | return 103 | } 104 | 105 | table := tablewriter.NewWriter(os.Stdout) 106 | table.SetHeader([]string{"Timestamp", "Connection", "RetransRate(%)", "RTT(ms)", "RTT/Variance(ms)"}) 107 | table.SetBorder(false) 108 | table.AppendBulk(rows) 109 | table.Render() 110 | fmt.Println() 111 | } 112 | -------------------------------------------------------------------------------- /tcpstat_linux.go: -------------------------------------------------------------------------------- 1 | package main 2 | 3 | import ( 4 | "fmt" 5 | "net" 6 | "syscall" 7 | "unsafe" 8 | ) 9 | 10 | const lostRateThreshold = 1e-6 11 | 12 | type TcpInfo struct { 13 | State uint8 `json:"state"` 14 | CAState uint8 `json:"ca_state"` 15 | Retransmits uint8 `json:"retransmits"` 16 | Probes uint8 `json:"probes"` 17 | Backoff uint8 `json:"backoff"` 18 | Options uint8 `json:"options"` 19 | WScale uint8 `json:"w_scale"` 20 | AppLimited uint8 `json:"app_limited"` 21 | RTO uint32 `json:"rto"` 22 | ATO uint32 `json:"ato"` 23 | SndMSS uint32 `json:"snd_mss"` 24 | RcvMSS uint32 `json:"rcv_mss"` 25 | Unacked uint32 `json:"unacked"` 26 | Sacked uint32 `json:"sacked"` 27 | Lost uint32 `json:"lost"` 28 | Retrans uint32 `json:"retrans"` 29 | Fackets uint32 `json:"f_ackets"` 30 | LastDataSent uint32 `json:"last_data_sent"` 31 | LastAckSent uint32 `json:"last_ack_sent"` 32 | LastDataRecv uint32 `json:"last_data_recv"` 33 | LastAckRecv uint32 `json:"last_ack_recv"` 34 | PathMTU uint32 `json:"p_mtu"` 35 | RcvSsThresh uint32 `json:"rcv_ss_thresh"` 36 | RTT uint32 `json:"rtt"` 37 | RTTVar uint32 `json:"rtt_var"` 38 | SndSsThresh uint32 `json:"snd_ss_thresh"` 39 | SndCwnd uint32 `json:"snd_cwnd"` 40 | AdvMSS uint32 `json:"adv_mss"` 41 | Reordering uint32 `json:"reordering"` 42 | RcvRTT uint32 `json:"rcv_rtt"` 43 | RcvSpace uint32 `json:"rcv_space"` 44 | TotalRetrans uint32 `json:"total_retrans"` 45 | PacingRate int64 `json:"pacing_rate"` 46 | MaxPacingRate int64 `json:"max_pacing_rate"` 47 | BytesAcked int64 `json:"bytes_acked"` 48 | BytesReceived int64 `json:"bytes_received"` 49 | SegsOut int32 `json:"segs_out"` 50 | SegsIn int32 `json:"segs_in"` // RFC4898 tcpEStatsPerfSegsIn 51 | NotSentBytes uint32 `json:"notsent_bytes"` 52 | MinRTT uint32 `json:"min_rtt"` 53 | DataSegsIn uint32 `json:"data_segs_in"` // RFC4898 tcpEStatsDataSegsIn 54 | DataSegsOut uint32 `json:"data_segs_out"` // RFC4898 tcpEStatsDataSegsOut 55 | DeliveryRate int64 `json:"delivery_rate"` 56 | BusyTime int64 `json:"busy_time"` // Time (usec) busy sending data 57 | RWndLimited int64 `json:"r_wnd_limited"` // Time (usec) limited by receive window 58 | SndBufLimited int64 `json:"snd_buf_limited"` // Time (usec) limited by send buffer 59 | Delivered uint32 `json:"delivered"` 60 | DeliveredCE uint32 `json:"delivered_ce"` 61 | BytesSent int64 `json:"bytes_sent"` // RFC4898 tcpEStatsPerfHCDataOctetsOut 62 | BytesRetrans int64 `json:"bytes_retrans"` // RFC4898 tcpEStatsPerfOctetsRetrans 63 | DSackDups uint32 `json:"d_sack_dups"` // RFC4898 tcpEStatsStackDSACKDups 64 | ReordSeen uint32 `json:"reord_seen"` // reordering events seen 65 | } 66 | 67 | func GetTcpInfo(tcpConn *net.TCPConn) (*TcpInfo, error) { 68 | rawConn, err := tcpConn.SyscallConn() 69 | if err != nil { 70 | return nil, fmt.Errorf("error getting raw connection. error: %v", err) 71 | } 72 | 73 | tcpInfo := TcpInfo{} 74 | size := unsafe.Sizeof(tcpInfo) 75 | 76 | var errno syscall.Errno 77 | err = rawConn.Control(func(fd uintptr) { 78 | _, _, errno = syscall.Syscall6(syscall.SYS_GETSOCKOPT, fd, syscall.SOL_TCP, syscall.TCP_INFO, 79 | uintptr(unsafe.Pointer(&tcpInfo)), uintptr(unsafe.Pointer(&size)), 0) 80 | }) 81 | if err != nil { 82 | return nil, fmt.Errorf("conn control failed, error: %v", err) 83 | } 84 | if errno != 0 { 85 | return nil, fmt.Errorf("syscall failed, errno: %d", errno) 86 | } 87 | 88 | return &tcpInfo, nil 89 | } 90 | 91 | // GetRetransRate returns the percent of lost packets. 92 | func GetRetransRate(preTi, ti *TcpInfo) float64 { 93 | if preTi == nil { 94 | return 0 95 | } 96 | 97 | bytesDelta := ti.BytesSent - preTi.BytesSent 98 | var lostRate float64 99 | if bytesDelta != 0 { 100 | lostRate = 100 * float64(ti.BytesRetrans-preTi.BytesRetrans) / float64(bytesDelta) 101 | if lostRate < lostRateThreshold { 102 | lostRate = 0 103 | } 104 | } 105 | if lostRate < 0 { 106 | return 0 107 | } else if lostRate > 1 { 108 | return 1 109 | } 110 | 111 | return lostRate 112 | } 113 | 114 | // GetRTT returns Round Trip Time in milliseconds. 115 | func (ti *TcpInfo) GetRTT() (uint32, uint32) { 116 | return ti.RTT / 1000, ti.RTTVar / 1000 117 | } 118 | -------------------------------------------------------------------------------- /tproxy.go: -------------------------------------------------------------------------------- 1 | package main 2 | 3 | import ( 4 | "flag" 5 | "fmt" 6 | "os" 7 | 8 | "github.com/fatih/color" 9 | ) 10 | 11 | var settings Settings 12 | 13 | func main() { 14 | var ( 15 | localPort = flag.Int("p", 0, "Local port to listen on, default to pick a random port") 16 | localHost = flag.String("l", "localhost", "Local address to listen on") 17 | remote = flag.String("r", "", "Remote address (host:port) to connect") 18 | delay = flag.Duration("d", 0, "the delay to relay packets") 19 | protocol = flag.String("t", "", "The type of protocol, currently support text, http2, grpc, mysql, redis, mongodb and mqtt") 20 | stat = flag.Bool("s", false, "Enable statistics") 21 | quiet = flag.Bool("q", false, "Quiet mode, only prints connection open/close and stats, default false") 22 | upLimit = flag.Int64("up", 0, "Upward speed limit(bytes/second)") 23 | downLimit = flag.Int64("down", 0, "Downward speed limit(bytes/second)") 24 | ) 25 | 26 | if len(os.Args) <= 1 { 27 | flag.Usage() 28 | return 29 | } 30 | 31 | flag.Parse() 32 | saveSettings(*localHost, *localPort, *remote, *delay, *protocol, *stat, *quiet, *upLimit, *downLimit) 33 | 34 | if len(settings.Remote) == 0 { 35 | fmt.Fprintln(os.Stderr, color.HiRedString("[x] Remote target required")) 36 | flag.PrintDefaults() 37 | os.Exit(1) 38 | } 39 | 40 | if err := startListener(); err != nil { 41 | fmt.Fprintln(os.Stderr, color.HiRedString("[x] Failed to start listener: %v", err)) 42 | os.Exit(1) 43 | } 44 | } 45 | --------------------------------------------------------------------------------