├── .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 | [](https://github.com/kevwan/tproxy/actions)
6 | [](https://goreportcard.com/report/github.com/kevwan/tproxy)
7 | [](https://github.com/kevwan/tproxy)
8 | [](https://opensource.org/licenses/MIT)
9 |
10 |
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 |
80 |
81 | ### 分析 MySQL 连接
82 |
83 | ```shell
84 | $ tproxy -p 3307 -r localhost:3306
85 | ```
86 |
87 |
88 |
89 | ### 查看网络状况(重传率和RTT)
90 |
91 | ```shell
92 | $ tproxy -p 3307 -r remotehost:3306 -s -q
93 | ```
94 |
95 |
96 |
97 | ### 查看连接池(总连接数、最大并发连接数、最长生命周期等)
98 |
99 | ```shell
100 | $ tproxy -p 3307 -r :3306 -s -q
101 | ```
102 |
103 |
104 |
105 | ## 欢迎 star!⭐
106 |
107 | 如果你正在使用或者觉得这个项目对你有帮助,请 **star** 支持,感谢!
108 |
--------------------------------------------------------------------------------
/readme-ja.md:
--------------------------------------------------------------------------------
1 | # tproxy
2 |
3 | [English](readme.md) | [简体中文](readme-cn.md) | 日本語
4 |
5 | [](https://github.com/kevwan/tproxy/actions)
6 | [](https://goreportcard.com/report/github.com/kevwan/tproxy)
7 | [](https://github.com/kevwan/tproxy)
8 | [](https://opensource.org/licenses/MIT)
9 |
10 |
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 |
80 |
81 | ### MySQL接続の監視
82 |
83 | ```shell
84 | $ tproxy -p 3307 -r localhost:3306
85 | ```
86 |
87 |
88 |
89 | ### 接続の信頼性の確認(再送率とRTT)
90 |
91 | ```shell
92 | $ tproxy -p 3307 -r remotehost:3306 -s -q
93 | ```
94 |
95 |
96 |
97 | ### 接続プールの動作を学ぶ
98 |
99 | ```shell
100 | $ tproxy -p 3307 -r localhost:3306 -q -s
101 | ```
102 |
103 |
104 |
105 | ## スターを付けてください! ⭐
106 |
107 | このプロジェクトが気に入ったり、使用している場合は、**スター**を付けてください。ありがとうございます!
108 |
--------------------------------------------------------------------------------
/readme.md:
--------------------------------------------------------------------------------
1 | # tproxy
2 |
3 | English | [简体中文](readme-cn.md) | [日本語](readme-ja.md)
4 |
5 | [](https://github.com/kevwan/tproxy/actions)
6 | [](https://goreportcard.com/report/github.com/kevwan/tproxy)
7 | [](https://github.com/kevwan/tproxy)
8 | [](https://opensource.org/licenses/MIT)
9 |
10 |
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 |
80 |
81 | ### Monitor MySQL connections
82 |
83 | ```shell
84 | $ tproxy -p 3307 -r localhost:3306
85 | ```
86 |
87 |
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 |
96 |
97 | ### Learn the connection pool behaviors
98 |
99 | ```shell
100 | $ tproxy -p 3307 -r localhost:3306 -q -s
101 | ```
102 |
103 |
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 |
--------------------------------------------------------------------------------