├── .gitignore ├── example ├── dummy-iso8583-server │ ├── Makefile │ └── main.go ├── echo-server │ ├── Makefile │ └── main.go ├── dummy-iso8583-client │ ├── Makefile │ └── main.go ├── http-server │ ├── Makefile │ └── main.go └── README.md ├── .vscode └── settings.json ├── Dockerfile ├── .github ├── FUNDING.yml ├── dependabot.yml └── workflows │ └── release.yml ├── go.mod ├── compose.yml ├── pkg ├── message │ ├── echo.go │ ├── if.go │ ├── mpu_test.go │ ├── mpu.go │ ├── iso8583.go │ ├── http_test.go │ ├── modbus.go │ └── http.go └── multiplexer │ ├── multiplexer_test.go │ └── multiplexer.go ├── Makefile ├── go.sum ├── LICENSE ├── cmd ├── version.go ├── list.go ├── root.go └── server.go ├── main.go ├── .goreleaser.yml └── README.md /.gitignore: -------------------------------------------------------------------------------- 1 | .idea 2 | tcp-multiplexer 3 | dist/ 4 | github.env 5 | -------------------------------------------------------------------------------- /example/dummy-iso8583-server/Makefile: -------------------------------------------------------------------------------- 1 | run: 2 | go run main.go 3 | -------------------------------------------------------------------------------- /.vscode/settings.json: -------------------------------------------------------------------------------- 1 | { 2 | "makefile.configureOnOpen": false 3 | } -------------------------------------------------------------------------------- /Dockerfile: -------------------------------------------------------------------------------- 1 | FROM scratch 2 | ENTRYPOINT ["/tcp-multiplexer"] 3 | COPY tcp-multiplexer / 4 | -------------------------------------------------------------------------------- /example/echo-server/Makefile: -------------------------------------------------------------------------------- 1 | run: 2 | go run main.go 3 | nc-client: 4 | nc 127.0.0.1 1234 -------------------------------------------------------------------------------- /.github/FUNDING.yml: -------------------------------------------------------------------------------- 1 | # These are supported funding model platforms 2 | 3 | github: ingmarstein 4 | -------------------------------------------------------------------------------- /example/dummy-iso8583-client/Makefile: -------------------------------------------------------------------------------- 1 | run: 2 | go run main.go 3 | run-mux: 4 | go run main.go 127.0.0.1:8000 5 | -------------------------------------------------------------------------------- /example/http-server/Makefile: -------------------------------------------------------------------------------- 1 | run: 2 | go run main.go 3 | nc-client: 4 | nc 127.0.0.1 1234 5 | curl: 6 | curl http://127.0.0.1:1234 7 | -------------------------------------------------------------------------------- /go.mod: -------------------------------------------------------------------------------- 1 | module github.com/ingmarstein/tcp-multiplexer 2 | 3 | go 1.24.1 4 | 5 | require github.com/spf13/cobra v1.10.2 6 | 7 | require ( 8 | github.com/inconshreveable/mousetrap v1.1.0 // indirect 9 | github.com/spf13/pflag v1.0.9 // indirect 10 | ) 11 | -------------------------------------------------------------------------------- /compose.yml: -------------------------------------------------------------------------------- 1 | services: 2 | modbus-proxy: 3 | image: ghcr.io/ingmarstein/tcp-multiplexer 4 | container_name: modbus_proxy 5 | ports: 6 | - "5020:5020" 7 | command: [ "server", "-t", "192.168.1.22:1502", "-l", "5020", "-p", "modbus", "-v" ] 8 | restart: unless-stopped 9 | -------------------------------------------------------------------------------- /.github/dependabot.yml: -------------------------------------------------------------------------------- 1 | version: 2 2 | updates: 3 | - package-ecosystem: "gomod" 4 | directory: "/" 5 | schedule: 6 | interval: "daily" 7 | time: "11:00" 8 | - package-ecosystem: "github-actions" 9 | directory: "/" 10 | schedule: 11 | interval: "weekly" 12 | -------------------------------------------------------------------------------- /pkg/message/echo.go: -------------------------------------------------------------------------------- 1 | package message 2 | 3 | import ( 4 | "bufio" 5 | "io" 6 | ) 7 | 8 | // https://tools.ietf.org/html/rfc862 9 | 10 | type EchoMessageReader struct { 11 | } 12 | 13 | func (e EchoMessageReader) Name() string { 14 | return "echo" 15 | } 16 | 17 | // ReadMessage message is expected \n terminated 18 | func (e EchoMessageReader) ReadMessage(conn io.Reader) ([]byte, error) { 19 | return bufio.NewReader(conn).ReadBytes('\n') 20 | } 21 | -------------------------------------------------------------------------------- /pkg/message/if.go: -------------------------------------------------------------------------------- 1 | package message 2 | 3 | import "io" 4 | 5 | // Reader read message for specified application protocol from client and target server 6 | type Reader interface { 7 | ReadMessage(conn io.Reader) ([]byte, error) 8 | Name() string 9 | } 10 | 11 | var Readers map[string]Reader 12 | 13 | func init() { 14 | Readers = make(map[string]Reader) 15 | for _, msgReader := range []Reader{ 16 | &EchoMessageReader{}, 17 | &HTTPMessageReader{}, 18 | &ISO8583MessageReader{}, 19 | &MPUMessageReader{}, 20 | &ModbusMessageReader{}, 21 | } { 22 | Readers[msgReader.Name()] = msgReader 23 | } 24 | } 25 | -------------------------------------------------------------------------------- /pkg/message/mpu_test.go: -------------------------------------------------------------------------------- 1 | package message 2 | 3 | import ( 4 | "bytes" 5 | "fmt" 6 | "testing" 7 | ) 8 | 9 | func TestMPUMessageReader_ReadMessage(t *testing.T) { 10 | buf := &bytes.Buffer{} 11 | msgLen := 124 12 | header := []byte(fmt.Sprintf("%04d", msgLen)) 13 | buf.Write(header) 14 | for i := 0; i < msgLen; i++ { 15 | buf.WriteByte('a') 16 | } 17 | buf.WriteString("another message") 18 | fmt.Printf("%x\n", buf) 19 | 20 | iso, err := MPUMessageReader{}.ReadMessage(bytes.NewReader(buf.Bytes())) 21 | if err != nil { 22 | t.Fatal("Expected no error, but got:", err) 23 | } 24 | fmt.Printf("%x\n", iso) 25 | } 26 | -------------------------------------------------------------------------------- /pkg/message/mpu.go: -------------------------------------------------------------------------------- 1 | package message 2 | 3 | import ( 4 | "io" 5 | "strconv" 6 | ) 7 | 8 | // MPUMessageReader for reading MPU Switch format iso8583 9 | type MPUMessageReader struct { 10 | } 11 | 12 | func (M MPUMessageReader) ReadMessage(conn io.Reader) ([]byte, error) { 13 | // message header is 4-byte ASCII 14 | header := make([]byte, 4) 15 | _, err := conn.Read(header) 16 | if err != nil { 17 | return nil, err 18 | } 19 | 20 | headerStr := string(header) 21 | length, err := strconv.Atoi(headerStr) 22 | if err != nil { 23 | return nil, err 24 | } 25 | 26 | isoMsg := make([]byte, length) 27 | _, err = conn.Read(isoMsg) 28 | if err != nil { 29 | return nil, err 30 | } 31 | 32 | return append(header, isoMsg...), nil 33 | } 34 | 35 | func (M MPUMessageReader) Name() string { 36 | return "mpu" 37 | } 38 | -------------------------------------------------------------------------------- /Makefile: -------------------------------------------------------------------------------- 1 | .phony: all clean fmt run run-8583 run-modbus echo-client http-client-nobody http-client-body http-client-form build test vet 2 | 3 | all: build 4 | 5 | fmt: 6 | go fmt ./... 7 | run: fmt 8 | go run main.go server -v -p http 9 | run-8583: fmt 10 | go run main.go server -v -p iso8583 11 | run-modbus: fmt 12 | go run main.go server -v -p modbus 13 | echo-client: 14 | nc 127.0.0.1 8000 15 | http-client-nobody: 16 | curl -v http://127.0.0.1:8000 17 | http-client-body: 18 | curl -v -X POST -d '{"name":"bob"}' -H 'Content-Type: application/json' http://127.0.0.1:8000 19 | # TODO: to support 20 | http-client-form: 21 | curl -v -X POST -F key1=value1 http://127.0.0.1:8000 22 | test: 23 | go test ./... 24 | vet: 25 | go vet ./... 26 | 27 | build: fmt 28 | CGO_ENABLED=0 go build 29 | 30 | clean: 31 | rm -f tcp-multiplexer 32 | -------------------------------------------------------------------------------- /example/http-server/main.go: -------------------------------------------------------------------------------- 1 | package main 2 | 3 | import ( 4 | "fmt" 5 | "html" 6 | "net/http" 7 | "net/http/httputil" 8 | "os" 9 | ) 10 | 11 | func getPort() string { 12 | port := os.Getenv("PORT") 13 | if port == "" { 14 | port = "1234" 15 | } 16 | return port 17 | } 18 | 19 | func headers(w http.ResponseWriter, req *http.Request) { 20 | dump, err := httputil.DumpRequest(req, true) 21 | if err != nil { 22 | fmt.Println(err) 23 | } 24 | fmt.Println(dump) 25 | 26 | w.Header().Set("Content-Type", "text/html; charset=utf-8") 27 | w.Write([]byte(html.EscapeString(string(dump)))) 28 | } 29 | 30 | func main() { 31 | http.HandleFunc("/", headers) 32 | 33 | port := getPort() 34 | err := http.ListenAndServe(":"+port, nil) 35 | if err != nil { 36 | fmt.Printf("Error listening on port %s: %v\n", port, err) 37 | } 38 | } 39 | -------------------------------------------------------------------------------- /go.sum: -------------------------------------------------------------------------------- 1 | github.com/cpuguy83/go-md2man/v2 v2.0.6/go.mod h1:oOW0eioCTA6cOiMLiUPZOpcVxMig6NIQQ7OS05n1F4g= 2 | github.com/inconshreveable/mousetrap v1.1.0 h1:wN+x4NVGpMsO7ErUn/mUI3vEoE6Jt13X2s0bqwp9tc8= 3 | github.com/inconshreveable/mousetrap v1.1.0/go.mod h1:vpF70FUmC8bwa3OWnCshd2FqLfsEA9PFc4w1p2J65bw= 4 | github.com/russross/blackfriday/v2 v2.1.0/go.mod h1:+Rmxgy9KzJVeS9/2gXHxylqXiyQDYRxCVz55jmeOWTM= 5 | github.com/spf13/cobra v1.10.2 h1:DMTTonx5m65Ic0GOoRY2c16WCbHxOOw6xxezuLaBpcU= 6 | github.com/spf13/cobra v1.10.2/go.mod h1:7C1pvHqHw5A4vrJfjNwvOdzYu0Gml16OCs2GRiTUUS4= 7 | github.com/spf13/pflag v1.0.9 h1:9exaQaMOCwffKiiiYk6/BndUBv+iRViNW+4lEMi0PvY= 8 | github.com/spf13/pflag v1.0.9/go.mod h1:McXfInJRrz4CZXVZOBLb0bTZqETkiAhM9Iw0y3An2Bg= 9 | go.yaml.in/yaml/v3 v3.0.4/go.mod h1:DhzuOOF2ATzADvBadXxruRBLzYTpT36CKvDb3+aBEFg= 10 | gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= 11 | -------------------------------------------------------------------------------- /pkg/message/iso8583.go: -------------------------------------------------------------------------------- 1 | package message 2 | 3 | import ( 4 | "bytes" 5 | "encoding/binary" 6 | "io" 7 | ) 8 | 9 | type ISO8583MessageReader struct { 10 | } 11 | 12 | func (I ISO8583MessageReader) Name() string { 13 | return "iso8583" 14 | } 15 | 16 | // ReadMessage assume including a header with the length of the 8583 message 17 | // http://j8583.sourceforge.net/desc8583en.html 18 | // otherwise, we have to parse iso8583 message 19 | func (I ISO8583MessageReader) ReadMessage(conn io.Reader) ([]byte, error) { 20 | header := make([]byte, 2) 21 | _, err := conn.Read(header) 22 | if err != nil { 23 | return nil, err 24 | } 25 | 26 | var length uint16 27 | err = binary.Read(bytes.NewReader(header), binary.BigEndian, &length) 28 | if err != nil { 29 | return nil, err 30 | } 31 | 32 | isoMsg := make([]byte, length) 33 | _, err = conn.Read(isoMsg) 34 | if err != nil { 35 | return nil, err 36 | } 37 | 38 | return append(header, isoMsg...), nil 39 | } 40 | -------------------------------------------------------------------------------- /example/dummy-iso8583-client/main.go: -------------------------------------------------------------------------------- 1 | package main 2 | 3 | import ( 4 | "bufio" 5 | "bytes" 6 | "encoding/binary" 7 | "fmt" 8 | "net" 9 | "os" 10 | 11 | "github.com/ingmarstein/tcp-multiplexer/pkg/message" 12 | ) 13 | 14 | func main() { 15 | targetServer := "127.0.0.1:1234" 16 | if len(os.Args) > 1 { 17 | targetServer = os.Args[1] 18 | } 19 | 20 | conn, err := net.Dial("tcp", targetServer) 21 | if err != nil { 22 | fmt.Println(err) 23 | os.Exit(2) 24 | } 25 | 26 | for { 27 | reader := bufio.NewReader(os.Stdin) 28 | fmt.Print(">> ") 29 | inputData, _ := reader.ReadBytes('\n') 30 | buf := new(bytes.Buffer) 31 | err := binary.Write(buf, binary.BigEndian, uint16(len(inputData))) 32 | handleErr(err) 33 | 34 | _, err = conn.Write(append(buf.Bytes(), inputData...)) 35 | handleErr(err) 36 | 37 | msg, err := message.ISO8583MessageReader{}.ReadMessage(conn) 38 | handleErr(err) 39 | 40 | fmt.Printf("%x\n", msg) 41 | } 42 | } 43 | 44 | func handleErr(err error) { 45 | if err != nil { 46 | fmt.Println(err) 47 | os.Exit(2) 48 | } 49 | } 50 | -------------------------------------------------------------------------------- /example/README.md: -------------------------------------------------------------------------------- 1 | 2 | Below is the list of test target servers for each supported application protocol. 3 | 4 | ## echo 5 | 6 | Message pattern: \n terminated. 7 | 8 | ### echo-server 9 | 10 | ``` 11 | go run main.go 12 | ``` 13 | 14 | ### client 15 | 16 | ``` 17 | nc 127.0.0.1 1234 18 | ``` 19 | 20 | ## http 21 | 22 | Message pattern: HTTP 1.1 PlainText, refer RFC. 23 | ![](https://developer.mozilla.org/en-US/docs/Web/HTTP/Messages/httpmsgstructure2.png) 24 | 25 | ### http-server 26 | 27 | ``` 28 | go run main.go 29 | ``` 30 | 31 | ### client 32 | 33 | ``` 34 | curl http://127.0.0.1:1234 35 | ``` 36 | ## iso8583 37 | 38 | Message pattern: 2 bytes header with the length of iso8583 message. 39 | 40 | | 2 bytes | M bytes | 41 | | ------------------ | ------------------ | 42 | | Message Length = M | ISO–8583 Message | 43 | 44 | reference: 45 | https://github.com/kpavlov/jreactive-8583/blob/master/README.md 46 | 47 | ### dummy-iso8583-server 48 | 49 | ``` 50 | go run main.go 51 | ``` 52 | 53 | ### dummy-iso8583-client 54 | 55 | ``` 56 | go run main.go 57 | ``` 58 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | The MIT License (MIT) 2 | 3 | Copyright © 2021 xujiahua 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 13 | all 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 21 | THE SOFTWARE. 22 | -------------------------------------------------------------------------------- /pkg/message/http_test.go: -------------------------------------------------------------------------------- 1 | package message 2 | 3 | import ( 4 | "bytes" 5 | "fmt" 6 | "log" 7 | "log/slog" 8 | "net/http" 9 | "net/http/httptest" 10 | "net/http/httputil" 11 | "strings" 12 | "testing" 13 | ) 14 | 15 | func TestHTTPMessageReader_ReadMessage(t *testing.T) { 16 | slog.SetLogLoggerLevel(slog.LevelDebug) 17 | ts := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { 18 | dump, err := httputil.DumpRequest(r, true) 19 | if err != nil { 20 | t.Fatal("Expected no error, but got:", err) 21 | } 22 | fmt.Println(string(dump)) 23 | 24 | dump2, err := HTTPMessageReader{}.ReadMessage(bytes.NewReader(dump)) 25 | if err != nil { 26 | t.Fatal("Expected no error, but got:", err) 27 | } 28 | // headers may not in same orders 29 | fmt.Println(string(dump2)) 30 | })) 31 | defer ts.Close() 32 | 33 | const body = "Go is a general-purpose language designed with systems programming in mind." 34 | req, err := http.NewRequest("POST", ts.URL, strings.NewReader(body)) 35 | if err != nil { 36 | log.Fatal(err) 37 | } 38 | req.Host = "www.example.org" 39 | 40 | _, err = http.DefaultClient.Do(req) 41 | if err != nil { 42 | log.Fatal(err) 43 | } 44 | } 45 | -------------------------------------------------------------------------------- /cmd/version.go: -------------------------------------------------------------------------------- 1 | /* 2 | Copyright © 2021 NAME HERE 3 | 4 | Licensed under the Apache License, Version 2.0 (the "License"); 5 | you may not use this file except in compliance with the License. 6 | You may obtain a copy of the License at 7 | 8 | http://www.apache.org/licenses/LICENSE-2.0 9 | 10 | Unless required by applicable law or agreed to in writing, software 11 | distributed under the License is distributed on an "AS IS" BASIS, 12 | WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 13 | See the License for the specific language governing permissions and 14 | limitations under the License. 15 | */ 16 | package cmd 17 | 18 | import ( 19 | "fmt" 20 | 21 | "github.com/spf13/cobra" 22 | ) 23 | 24 | var ( 25 | version = "dev" 26 | commit = "none" 27 | date = "unknown" 28 | builtBy = "unknown" 29 | ) 30 | 31 | // versionCmd represents the version command 32 | var versionCmd = &cobra.Command{ 33 | Use: "version", 34 | Short: "binary version", 35 | Run: func(cmd *cobra.Command, args []string) { 36 | fmt.Printf("version %s, commit %s, built at %s by %s\n", version, commit, date, builtBy) 37 | }, 38 | } 39 | 40 | func init() { 41 | rootCmd.AddCommand(versionCmd) 42 | } 43 | -------------------------------------------------------------------------------- /.github/workflows/release.yml: -------------------------------------------------------------------------------- 1 | name: goreleaser 2 | 3 | on: 4 | push: 5 | tags: 6 | - '*' 7 | 8 | permissions: 9 | contents: write 10 | packages: write 11 | attestations: write 12 | 13 | jobs: 14 | goreleaser: 15 | runs-on: ubuntu-latest 16 | steps: 17 | - name: Checkout 18 | uses: actions/checkout@v6 19 | with: 20 | fetch-depth: 0 21 | - name: Set up Go 22 | uses: actions/setup-go@v6 23 | with: 24 | go-version: 'stable' 25 | - name: Log in to Docker Hub 26 | uses: docker/login-action@v3 27 | with: 28 | username: ${{ secrets.DOCKERHUB_USERNAME }} 29 | password: ${{ secrets.DOCKERHUB_PASSWORD }} 30 | - name: Log in to GitHub Container Registry 31 | uses: docker/login-action@v3 32 | with: 33 | registry: ghcr.io 34 | username: ${{ github.actor }} 35 | password: ${{ secrets.GITHUB_TOKEN }} 36 | - name: Run GoReleaser 37 | uses: goreleaser/goreleaser-action@v6 38 | with: 39 | distribution: goreleaser 40 | version: latest 41 | args: release --clean 42 | env: 43 | GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} 44 | -------------------------------------------------------------------------------- /example/echo-server/main.go: -------------------------------------------------------------------------------- 1 | package main 2 | 3 | import ( 4 | "bufio" 5 | "fmt" 6 | "io" 7 | "net" 8 | "os" 9 | ) 10 | 11 | func handleConnection(conn net.Conn) { 12 | defer func(c net.Conn) { 13 | err := c.Close() 14 | if err != nil { 15 | fmt.Println(err) 16 | } 17 | }(conn) 18 | 19 | for { 20 | data, err := bufio.NewReader(conn).ReadBytes('\n') 21 | if err == io.EOF { 22 | fmt.Println("connection is closed") 23 | break 24 | } 25 | if err != nil { 26 | fmt.Println(err) 27 | break 28 | } 29 | 30 | _, err = conn.Write(data) 31 | if err != nil { 32 | fmt.Println(err) 33 | break 34 | } 35 | } 36 | } 37 | 38 | func getPort() string { 39 | port := os.Getenv("PORT") 40 | if port == "" { 41 | port = "1234" 42 | } 43 | return port 44 | } 45 | 46 | func main() { 47 | PORT := ":" + getPort() 48 | l, err := net.Listen("tcp4", PORT) 49 | if err != nil { 50 | fmt.Println(err) 51 | return 52 | } 53 | defer l.Close() 54 | 55 | count := 0 56 | for { 57 | conn, err := l.Accept() 58 | if err != nil { 59 | fmt.Println(err) 60 | return 61 | } 62 | count++ 63 | fmt.Printf("%d: %v <-> %v\n", count, conn.LocalAddr(), conn.RemoteAddr()) 64 | go handleConnection(conn) 65 | } 66 | } 67 | -------------------------------------------------------------------------------- /cmd/list.go: -------------------------------------------------------------------------------- 1 | /* 2 | Copyright © 2021 NAME HERE 3 | 4 | Licensed under the Apache License, Version 2.0 (the "License"); 5 | you may not use this file except in compliance with the License. 6 | You may obtain a copy of the License at 7 | 8 | http://www.apache.org/licenses/LICENSE-2.0 9 | 10 | Unless required by applicable law or agreed to in writing, software 11 | distributed under the License is distributed on an "AS IS" BASIS, 12 | WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 13 | See the License for the specific language governing permissions and 14 | limitations under the License. 15 | */ 16 | package cmd 17 | 18 | import ( 19 | "fmt" 20 | 21 | "github.com/ingmarstein/tcp-multiplexer/pkg/message" 22 | "github.com/spf13/cobra" 23 | ) 24 | 25 | // listCmd represents the list command 26 | var listCmd = &cobra.Command{ 27 | Use: "list", 28 | Short: "list application protocols that multiplexer supports", 29 | Run: func(cmd *cobra.Command, args []string) { 30 | for name := range message.Readers { 31 | fmt.Print("* ") 32 | fmt.Println(name) 33 | } 34 | 35 | fmt.Println("\nusage for example: ./tcp-multiplexer server -p echo") 36 | }, 37 | } 38 | 39 | func init() { 40 | rootCmd.AddCommand(listCmd) 41 | } 42 | -------------------------------------------------------------------------------- /main.go: -------------------------------------------------------------------------------- 1 | /* 2 | Copyright © 2021 xujiahua 3 | 4 | Permission is hereby granted, free of charge, to any person obtaining a copy 5 | of this software and associated documentation files (the "Software"), to deal 6 | in the Software without restriction, including without limitation the rights 7 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 8 | copies of the Software, and to permit persons to whom the Software is 9 | furnished to do so, subject to the following conditions: 10 | 11 | The above copyright notice and this permission notice shall be included in 12 | all copies or substantial portions of the Software. 13 | 14 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 15 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 16 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 17 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 18 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 19 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN 20 | THE SOFTWARE. 21 | */ 22 | package main 23 | 24 | import "github.com/ingmarstein/tcp-multiplexer/cmd" 25 | 26 | func main() { 27 | cmd.Execute() 28 | } 29 | -------------------------------------------------------------------------------- /pkg/message/modbus.go: -------------------------------------------------------------------------------- 1 | package message 2 | 3 | import ( 4 | "encoding/binary" 5 | "fmt" 6 | "io" 7 | ) 8 | 9 | const ( 10 | maxTCPFrameLength = 260 11 | mbapHeaderLength = 6 12 | ) 13 | 14 | type ModbusMessageReader struct { 15 | } 16 | 17 | func (m ModbusMessageReader) Name() string { 18 | return "modbus" 19 | } 20 | 21 | func (m ModbusMessageReader) ReadMessage(conn io.Reader) ([]byte, error) { 22 | header := make([]byte, mbapHeaderLength) 23 | _, err := io.ReadFull(conn, header) 24 | if err != nil { 25 | return nil, err 26 | } 27 | 28 | // determine how many more bytes we need to read 29 | bytesNeeded := int(binary.BigEndian.Uint16(header[4:6])) 30 | // never read more than the max allowed frame length 31 | if bytesNeeded+mbapHeaderLength > maxTCPFrameLength { 32 | return nil, fmt.Errorf("protocol error: %d larger than max allowed frame length (%d)", bytesNeeded+mbapHeaderLength, maxTCPFrameLength) 33 | } 34 | 35 | // an MBAP length of 0 is illegal 36 | if bytesNeeded <= 0 { 37 | return nil, fmt.Errorf("protocol error: illegal MBAP length (%d)", bytesNeeded) 38 | } 39 | 40 | // read the PDU 41 | rxbuf := make([]byte, bytesNeeded) 42 | _, err = io.ReadFull(conn, rxbuf) 43 | if err != nil { 44 | return nil, err 45 | } 46 | 47 | return append(header, rxbuf...), nil 48 | } 49 | -------------------------------------------------------------------------------- /example/dummy-iso8583-server/main.go: -------------------------------------------------------------------------------- 1 | package main 2 | 3 | import ( 4 | "fmt" 5 | "io" 6 | "net" 7 | "os" 8 | 9 | "github.com/ingmarstein/tcp-multiplexer/pkg/message" 10 | ) 11 | 12 | func handleConnection(conn net.Conn) { 13 | defer func(c net.Conn) { 14 | err := c.Close() 15 | if err != nil { 16 | fmt.Println(err) 17 | } 18 | }(conn) 19 | 20 | for { 21 | data, err := message.ISO8583MessageReader{}.ReadMessage(conn) 22 | if err == io.EOF { 23 | fmt.Println("connection is closed") 24 | break 25 | } 26 | if err != nil { 27 | fmt.Println(err) 28 | break 29 | } 30 | 31 | _, err = conn.Write(data) 32 | if err != nil { 33 | fmt.Println(err) 34 | break 35 | } 36 | } 37 | } 38 | 39 | func getPort() string { 40 | port := os.Getenv("PORT") 41 | if port == "" { 42 | port = "1234" 43 | } 44 | return port 45 | } 46 | 47 | func main() { 48 | PORT := ":" + getPort() 49 | l, err := net.Listen("tcp4", PORT) 50 | if err != nil { 51 | fmt.Println(err) 52 | return 53 | } 54 | defer l.Close() 55 | 56 | count := 0 57 | for { 58 | conn, err := l.Accept() 59 | if err != nil { 60 | fmt.Println(err) 61 | return 62 | } 63 | count++ 64 | fmt.Printf("%d: %v <-> %v\n", count, conn.LocalAddr(), conn.RemoteAddr()) 65 | go handleConnection(conn) 66 | } 67 | } 68 | -------------------------------------------------------------------------------- /cmd/root.go: -------------------------------------------------------------------------------- 1 | /* 2 | Copyright © 2021 xujiahua 3 | 4 | Permission is hereby granted, free of charge, to any person obtaining a copy 5 | of this software and associated documentation files (the "Software"), to deal 6 | in the Software without restriction, including without limitation the rights 7 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 8 | copies of the Software, and to permit persons to whom the Software is 9 | furnished to do so, subject to the following conditions: 10 | 11 | The above copyright notice and this permission notice shall be included in 12 | all copies or substantial portions of the Software. 13 | 14 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 15 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 16 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 17 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 18 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 19 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN 20 | THE SOFTWARE. 21 | */ 22 | package cmd 23 | 24 | import ( 25 | "fmt" 26 | "os" 27 | 28 | "github.com/spf13/cobra" 29 | ) 30 | 31 | var verbose bool 32 | var debug bool 33 | 34 | // rootCmd represents the base command when called without any subcommands 35 | var rootCmd = &cobra.Command{ 36 | Use: "tcp-multiplexer", 37 | Short: "Multiplex multiple connections into a single TCP connection.", 38 | } 39 | 40 | // Execute adds all child commands to the root command and sets flags appropriately. 41 | // This is called by main.main(). It only needs to happen once to the rootCmd. 42 | func Execute() { 43 | if err := rootCmd.Execute(); err != nil { 44 | fmt.Println(err) 45 | os.Exit(1) 46 | } 47 | } 48 | 49 | func init() { 50 | rootCmd.PersistentFlags().BoolVarP(&verbose, "verbose", "v", false, "verbose log") 51 | rootCmd.PersistentFlags().BoolVarP(&verbose, "debug", "d", false, "debug log") 52 | } 53 | -------------------------------------------------------------------------------- /pkg/multiplexer/multiplexer_test.go: -------------------------------------------------------------------------------- 1 | package multiplexer 2 | 3 | import ( 4 | "bufio" 5 | "bytes" 6 | "fmt" 7 | "io" 8 | "log/slog" 9 | "net" 10 | "os" 11 | "sync" 12 | "testing" 13 | "time" 14 | 15 | "github.com/ingmarstein/tcp-multiplexer/pkg/message" 16 | ) 17 | 18 | func init() { 19 | slog.SetLogLoggerLevel(slog.LevelInfo) 20 | } 21 | 22 | func handleErr(err error) { 23 | if err != nil { 24 | fmt.Println(err) 25 | os.Exit(2) 26 | } 27 | } 28 | 29 | func client(t *testing.T, server string, clientIndex int) { 30 | conn, err := net.Dial("tcp", server) 31 | handleErr(err) 32 | defer conn.Close() 33 | 34 | for i := 0; i < 10; i++ { 35 | echo := []byte(fmt.Sprintf("client %d counter %d\n", clientIndex, i)) 36 | _, err = conn.Write(echo) 37 | handleErr(err) 38 | 39 | echoReply, err := message.EchoMessageReader{}.ReadMessage(conn) 40 | handleErr(err) 41 | 42 | if bytes.Compare(echo, echoReply) != 0 { 43 | t.Fatalf("Expected %s, but got %s", echo, echoReply) 44 | } 45 | } 46 | 47 | fmt.Println("client connection closed") 48 | } 49 | 50 | func handleConnection(conn net.Conn) { 51 | defer func(c net.Conn) { 52 | err := c.Close() 53 | if err != nil { 54 | fmt.Println(err) 55 | } 56 | }(conn) 57 | 58 | for { 59 | data, err := bufio.NewReader(conn).ReadBytes('\n') 60 | if err == io.EOF { 61 | fmt.Println("connection is closed") 62 | break 63 | } 64 | if err != nil { 65 | fmt.Println(err) 66 | break 67 | } 68 | 69 | _, err = conn.Write(data) 70 | if err != nil { 71 | fmt.Println(err) 72 | break 73 | } 74 | } 75 | } 76 | 77 | func TestMultiplexer_Start(t *testing.T) { 78 | l, err := net.Listen("tcp", ":0") 79 | go func() { 80 | defer l.Close() 81 | for { 82 | conn, err := l.Accept() 83 | if err != nil { 84 | fmt.Println(err) 85 | break 86 | } 87 | go handleConnection(conn) 88 | } 89 | }() 90 | const muxServer = "127.0.0.1:1235" 91 | 92 | mux := New(l.Addr().String(), "1235", message.EchoMessageReader{}, 0, 5*time.Second) 93 | 94 | errChan := make(chan error, 1) 95 | go func() { 96 | errChan <- mux.Start() 97 | }() 98 | 99 | time.Sleep(time.Second) 100 | 101 | var wg sync.WaitGroup 102 | wg.Add(2) 103 | go func() { 104 | client(t, muxServer, 1) 105 | wg.Done() 106 | }() 107 | go func() { 108 | client(t, muxServer, 2) 109 | wg.Done() 110 | }() 111 | 112 | wg.Wait() 113 | time.Sleep(time.Second) 114 | 115 | select { 116 | case err := <-errChan: 117 | if err != nil { 118 | t.Fatal("Expected no error, but got:", err) 119 | } 120 | default: 121 | } 122 | err = mux.Close() 123 | if err != nil { 124 | t.Fatal("Expected no error, but got:", err) 125 | } 126 | } 127 | -------------------------------------------------------------------------------- /cmd/server.go: -------------------------------------------------------------------------------- 1 | /* 2 | Copyright © 2021 xujiahua 3 | 4 | Permission is hereby granted, free of charge, to any person obtaining a copy 5 | of this software and associated documentation files (the "Software"), to deal 6 | in the Software without restriction, including without limitation the rights 7 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 8 | copies of the Software, and to permit persons to whom the Software is 9 | furnished to do so, subject to the following conditions: 10 | 11 | The above copyright notice and this permission notice shall be included in 12 | all copies or substantial portions of the Software. 13 | 14 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 15 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 16 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 17 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 18 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 19 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN 20 | THE SOFTWARE. 21 | */ 22 | package cmd 23 | 24 | import ( 25 | "fmt" 26 | "log/slog" 27 | "os" 28 | "os/signal" 29 | "syscall" 30 | "time" 31 | 32 | "github.com/ingmarstein/tcp-multiplexer/pkg/message" 33 | "github.com/ingmarstein/tcp-multiplexer/pkg/multiplexer" 34 | "github.com/spf13/cobra" 35 | ) 36 | 37 | var ( 38 | port string 39 | targetServer string 40 | applicationProtocol string 41 | timeout int 42 | delay time.Duration 43 | ) 44 | 45 | // serverCmd represents the server command 46 | var serverCmd = &cobra.Command{ 47 | Use: "server", 48 | Short: "start multiplexer proxy server", 49 | Run: func(cmd *cobra.Command, args []string) { 50 | slog.SetLogLoggerLevel(slog.LevelWarn) 51 | if verbose { 52 | slog.SetLogLoggerLevel(slog.LevelInfo) 53 | } 54 | if debug { 55 | slog.SetLogLoggerLevel(slog.LevelDebug) 56 | } 57 | 58 | slog.Info(fmt.Sprintf("starting multiplexer version %q on port %s, forwarding to %s, application protocol is %q", 59 | version, 60 | port, 61 | targetServer, 62 | applicationProtocol)) 63 | 64 | msgReader, ok := message.Readers[applicationProtocol] 65 | if !ok { 66 | slog.Error(fmt.Sprintf("%s application protocol is not supported", applicationProtocol)) 67 | os.Exit(2) 68 | } 69 | 70 | mux := multiplexer.New(targetServer, port, msgReader, delay, time.Duration(timeout)*time.Second) 71 | go func() { 72 | err := mux.Start() 73 | if err != nil { 74 | slog.Error(err.Error()) 75 | os.Exit(2) 76 | } 77 | }() 78 | 79 | signalChan := make(chan os.Signal, 1) 80 | signal.Notify( 81 | signalChan, 82 | syscall.SIGHUP, // kill -SIGHUP XXXX 83 | syscall.SIGINT, // kill -SIGINT XXXX or Ctrl+c 84 | syscall.SIGQUIT, // kill -SIGQUIT XXXX 85 | ) 86 | <-signalChan 87 | 88 | err := mux.Close() 89 | if err != nil { 90 | slog.Error(err.Error()) 91 | } 92 | }, 93 | } 94 | 95 | func init() { 96 | rootCmd.AddCommand(serverCmd) 97 | 98 | serverCmd.Flags().StringVarP(&port, "listen", "l", "8000", "multiplexer will listen on") 99 | serverCmd.Flags().StringVarP(&targetServer, "targetServer", "t", "127.0.0.1:1234", "multiplexer will forward message to") 100 | serverCmd.Flags().StringVarP(&applicationProtocol, "applicationProtocol", "p", "echo", "multiplexer will parse to message echo/http/iso8583/modbus") 101 | serverCmd.Flags().IntVar(&timeout, "timeout", 60, "timeout in seconds") 102 | serverCmd.Flags().DurationVar(&delay, "delay", 0, "delay after connect") 103 | } 104 | -------------------------------------------------------------------------------- /pkg/message/http.go: -------------------------------------------------------------------------------- 1 | package message 2 | 3 | import ( 4 | "bufio" 5 | "bytes" 6 | "errors" 7 | "fmt" 8 | "io" 9 | "log/slog" 10 | "net/textproto" 11 | "strconv" 12 | "strings" 13 | ) 14 | 15 | // https://tools.ietf.org/html/rfc2616 16 | // refer /usr/local/Cellar/go/1.16.3/libexec/src/net/http/request.go:1021 readRequest 17 | 18 | type HTTPMessageReader struct { 19 | } 20 | 21 | func (H HTTPMessageReader) Name() string { 22 | return "http" 23 | } 24 | 25 | const ( 26 | headerKeyContentLength = "Content-Length" 27 | headerKeyContentType = "Content-Type" 28 | headerFormContentType = "multipart/form-data" 29 | CRLF = "\r\n" 30 | boundaryPrefix = "boundary=" 31 | ) 32 | 33 | // support HTTP1 plaintext 34 | // DO NOT Support: 35 | // 1. https 36 | // 2. websocket 37 | 38 | func (H HTTPMessageReader) ReadMessage(conn io.Reader) ([]byte, error) { 39 | tp := textproto.NewReader(bufio.NewReader(conn)) 40 | startLine, err := tp.ReadLine() 41 | if err != nil { 42 | return nil, err 43 | } 44 | slog.Debug(startLine) 45 | 46 | headers, err := tp.ReadMIMEHeader() 47 | if err != nil { 48 | return nil, err 49 | } 50 | slog.Debug(fmt.Sprintf("%v", headers)) 51 | 52 | // https://developer.mozilla.org/en-US/docs/Web/HTTP/Messages#body 53 | // https://developer.mozilla.org/en-US/docs/Web/HTTP/Messages#body_2 54 | // 1. without body 55 | var body []byte 56 | 57 | // first check form type 58 | isFormContentType := false 59 | if vv, ok := headers[headerKeyContentType]; ok { 60 | slog.Debug(vv[0]) 61 | parts := strings.Split(vv[0], ";") 62 | contentType := strings.TrimSpace(parts[0]) 63 | // 3. Multiple-resource bodies 64 | if contentType == headerFormContentType { 65 | isFormContentType = true 66 | if len(parts) < 2 { 67 | return nil, errors.New("expect boundary= part in " + headerKeyContentType) 68 | } 69 | boundaryPart := strings.TrimSpace(parts[1]) 70 | if !strings.HasPrefix(boundaryPart, boundaryPrefix) { 71 | return nil, errors.New("expect boundary= part in " + headerKeyContentType) 72 | } 73 | 74 | lastBoundary := "--" + strings.TrimPrefix(boundaryPart, boundaryPrefix) + "--" 75 | scanner := bufio.NewScanner(tp.R) 76 | for scanner.Scan() { 77 | line := scanner.Bytes() 78 | body = append(body, line...) 79 | body = append(body, []byte(CRLF)...) 80 | if string(line) == lastBoundary { 81 | break 82 | } 83 | } 84 | if err := scanner.Err(); err != nil { 85 | return nil, err 86 | } 87 | } 88 | } 89 | 90 | // 2. Single-resource bodies: use Content-Length as size 91 | if !isFormContentType { 92 | if vv, ok := headers[headerKeyContentLength]; ok { 93 | size, err := strconv.Atoi(vv[0]) 94 | if err != nil { 95 | return nil, err 96 | } 97 | 98 | body = make([]byte, size) 99 | _, err = tp.R.Read(body) 100 | if err != nil { 101 | return nil, err 102 | } 103 | } 104 | } 105 | 106 | // TODO: 4. Transfer-Encoding 107 | // https://developer.mozilla.org/en-US/docs/Web/HTTP/Headers/Transfer-Encoding 108 | 109 | msg := dumpHTTPMessage(startLine, headers, body) 110 | 111 | slog.Debug(string(msg)) 112 | 113 | return msg, err 114 | } 115 | 116 | func dumpHTTPMessage(startLine string, headers textproto.MIMEHeader, body []byte) []byte { 117 | var b bytes.Buffer 118 | b.WriteString(startLine) 119 | b.WriteString(CRLF) 120 | for k, vv := range headers { 121 | for _, v := range vv { 122 | b.WriteString(k) 123 | b.WriteString(": ") 124 | b.WriteString(v) 125 | b.WriteString(CRLF) 126 | } 127 | } 128 | b.WriteString(CRLF) 129 | if len(body) != 0 { 130 | b.Write(body) 131 | } 132 | return b.Bytes() 133 | } 134 | -------------------------------------------------------------------------------- /.goreleaser.yml: -------------------------------------------------------------------------------- 1 | version: 2 2 | before: 3 | hooks: 4 | - go mod tidy 5 | builds: 6 | - env: 7 | - CGO_ENABLED=0 8 | ldflags: 9 | - -s -w -X github.com/ingmarstein/tcp-multiplexer/cmd.version={{.Version}} -X github.com/ingmarstein/tcp-multiplexer/cmd.commit={{.Commit}} -X github.com/ingmarstein/tcp-multiplexer/cmd.date={{.Date}} -X github.com/ingmarstein/tcp-multiplexer/cmd.builtBy=goreleaser 10 | goos: 11 | - linux 12 | - darwin 13 | goarch: 14 | - amd64 15 | - arm64 16 | - arm 17 | goarm: 18 | - "6" 19 | - "7" 20 | archives: 21 | - name_template: '{{ .ProjectName }}_{{ .Os }}_{{ .Arch }}{{ if .Arm }}v{{ .Arm }}{{ end }}' 22 | checksum: 23 | name_template: 'checksums.txt' 24 | snapshot: 25 | version_template: "{{ .Tag }}-next" 26 | changelog: 27 | sort: asc 28 | filters: 29 | exclude: 30 | - '^docs:' 31 | - '^test:' 32 | dockers: 33 | - image_templates: 34 | - "docker.io/ingmarstein/tcp-multiplexer:{{ .Version }}-amd64" 35 | - "docker.io/ingmarstein/tcp-multiplexer:latest-amd64" 36 | - "ghcr.io/ingmarstein/tcp-multiplexer:{{ .Version }}-amd64" 37 | - "ghcr.io/ingmarstein/tcp-multiplexer:latest-amd64" 38 | use: buildx 39 | goarch: amd64 40 | dockerfile: Dockerfile 41 | build_flag_templates: 42 | - "--platform=linux/amd64" 43 | - image_templates: 44 | - "docker.io/ingmarstein/tcp-multiplexer:{{ .Version }}-arm64" 45 | - "docker.io/ingmarstein/tcp-multiplexer:latest-arm64" 46 | - "ghcr.io/ingmarstein/tcp-multiplexer:{{ .Version }}-arm64" 47 | - "ghcr.io/ingmarstein/tcp-multiplexer:latest-arm64" 48 | use: buildx 49 | goarch: arm64 50 | dockerfile: Dockerfile 51 | build_flag_templates: 52 | - "--platform=linux/arm64" 53 | - image_templates: 54 | - "docker.io/ingmarstein/tcp-multiplexer:{{ .Version }}-arm-v6" 55 | - "docker.io/ingmarstein/tcp-multiplexer:latest-arm-v6" 56 | - "ghcr.io/ingmarstein/tcp-multiplexer:{{ .Version }}-arm-v6" 57 | - "ghcr.io/ingmarstein/tcp-multiplexer:latest-arm-v6" 58 | use: buildx 59 | goarch: arm 60 | goarm: "6" 61 | dockerfile: Dockerfile 62 | build_flag_templates: 63 | - "--platform=linux/arm/v6" 64 | - image_templates: 65 | - "docker.io/ingmarstein/tcp-multiplexer:{{ .Version }}-arm-v7" 66 | - "docker.io/ingmarstein/tcp-multiplexer:latest-arm-v7" 67 | - "ghcr.io/ingmarstein/tcp-multiplexer:{{ .Version }}-arm-v7" 68 | - "ghcr.io/ingmarstein/tcp-multiplexer:latest-arm-v7" 69 | use: buildx 70 | goarch: arm 71 | goarm: "7" 72 | dockerfile: Dockerfile 73 | build_flag_templates: 74 | - "--platform=linux/arm/v7" 75 | docker_manifests: 76 | - name_template: "docker.io/ingmarstein/tcp-multiplexer:{{ .Version }}" 77 | image_templates: 78 | - "docker.io/ingmarstein/tcp-multiplexer:{{ .Version }}-amd64" 79 | - "docker.io/ingmarstein/tcp-multiplexer:{{ .Version }}-arm64" 80 | - "docker.io/ingmarstein/tcp-multiplexer:{{ .Version }}-arm-v6" 81 | - "docker.io/ingmarstein/tcp-multiplexer:{{ .Version }}-arm-v7" 82 | - name_template: "docker.io/ingmarstein/tcp-multiplexer:latest" 83 | image_templates: 84 | - "docker.io/ingmarstein/tcp-multiplexer:latest-amd64" 85 | - "docker.io/ingmarstein/tcp-multiplexer:latest-arm64" 86 | - "docker.io/ingmarstein/tcp-multiplexer:latest-arm-v6" 87 | - "docker.io/ingmarstein/tcp-multiplexer:latest-arm-v7" 88 | - name_template: "ghcr.io/ingmarstein/tcp-multiplexer:{{ .Version }}" 89 | image_templates: 90 | - "ghcr.io/ingmarstein/tcp-multiplexer:{{ .Version }}-amd64" 91 | - "ghcr.io/ingmarstein/tcp-multiplexer:{{ .Version }}-arm64" 92 | - "ghcr.io/ingmarstein/tcp-multiplexer:{{ .Version }}-arm-v6" 93 | - "ghcr.io/ingmarstein/tcp-multiplexer:{{ .Version }}-arm-v7" 94 | - name_template: "ghcr.io/ingmarstein/tcp-multiplexer:latest" 95 | image_templates: 96 | - "ghcr.io/ingmarstein/tcp-multiplexer:latest-amd64" 97 | - "ghcr.io/ingmarstein/tcp-multiplexer:latest-arm64" 98 | - "ghcr.io/ingmarstein/tcp-multiplexer:latest-arm-v6" 99 | - "ghcr.io/ingmarstein/tcp-multiplexer:latest-arm-v7" 100 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # tcp-multiplexer 2 | 3 | Use it in front of a target server and let your client programs connect to it, if target server **only allows you to 4 | create a limited number of TCP connections concurrently**. While it has its limitation: increased latency as incoming 5 | request will block each other. 6 | 7 | A common use case for tcp-multiplexer is to allow multiple modbus/TCP clients connect to solar inverters which often 8 | only support a single TCP connection. 9 | 10 | ## Architecture 11 | 12 | ``` 13 | ┌──────────┐ 14 | │ ┌────────┴──┐ ┌─────────────────┐ ┌───────────────┐ 15 | │ │ ├─────►│ │ │ │ 16 | │ │ client(s) │ │ tcp-multiplexer ├─────►│ target server │ 17 | └─┤ ├─────►│ │ │ │ 18 | └───────────┘ └─────────────────┘ └───────────────┘ 19 | 20 | 21 | ─────► TCP connection 22 | 23 | drawn by https://asciiflow.com/ 24 | ``` 25 | 26 | Unlike with a reverse proxy, the TCP connection between `tcp-multiplexer` and the target server will be reused for all 27 | clients' TCP connections. 28 | 29 | Multiplexer is simple. For every TCP connection from clients, the handling logic: 30 | 31 | ``` 32 | for { 33 | get lock... 34 | data pipe: 35 | 1. get request message from client 36 | 2. forward request message to target server 37 | 3. get response message from target server 38 | 4. forward response message to client 39 | release lock... 40 | } 41 | ``` 42 | 43 | The lock makes sure that at any time, the TCP connection to the target server will be used in exactly one 44 | request-response loop. 45 | This way, all connections from clients share one TCP connection to the target server. 46 | 47 | Next key point is how to detect message (e.g., HTTP) from the TCP data stream. 48 | 49 | ## Supported application protocols 50 | 51 | Every application protocol (request–response message exchange pattern) has its own message format. The following formats 52 | are supported currently: 53 | 54 | 1. echo: \n terminated 55 | 2. http1 (not including https, websocket): not fully supported 56 | 3. iso8583: with 2 bytes header of the length of iso8583 message 57 | 4. modbus-tcp 58 | 59 | ``` 60 | $ ./tcp-multiplexer list 61 | * iso8583 62 | * echo 63 | * http 64 | * modbus 65 | 66 | usage for example: ./tcp-multiplexer server -p echo 67 | ``` 68 | 69 | See detailed: https://github.com/ingmarstein/tcp-multiplexer/tree/master/example 70 | 71 | ## Usage 72 | 73 | ``` 74 | $ ./tcp-multiplexer server -h 75 | start multiplexer proxy server 76 | 77 | Usage: 78 | tcp-multiplexer server [flags] 79 | 80 | Flags: 81 | -p, --applicationProtocol string multiplexer will parse to message echo/http/iso8583 (default "echo") 82 | --delay duration delay after connect 83 | -h, --help help for server 84 | -l, --listen string multiplexer will listen on (default "8000") 85 | -t, --targetServer string multiplexer will forward message to (default "127.0.0.1:1234") 86 | --timeout int timeout in seconds (default 60) 87 | 88 | Global Flags: 89 | -v, --verbose verbose log 90 | ``` 91 | 92 | #### In a container 93 | 94 | ``` 95 | docker run ghcr.io/ingmarstein/tcp-multiplexer server -t 127.0.0.1:1234 -l 8000 -p modbus 96 | ``` 97 | 98 | Alternatively, use the included `compose.yml` file as a template if you prefer to use Docker Compose. 99 | 100 | ## Testing 101 | 102 | Start echo server (listen on port 1234) 103 | 104 | ``` 105 | $ go run example/echo-server/main.go 106 | 1: 127.0.0.1:1234 <-> 127.0.0.1:58088 107 | ``` 108 | 109 | Start TCP multiplexing (listen on port 8000) 110 | 111 | ``` 112 | $ ./tcp-multiplexer server -p echo -t 127.0.0.1:1234 -l 8000 113 | INFO[2021-05-09T02:06:40+08:00] creating target connection 114 | INFO[2021-05-09T02:06:40+08:00] new target connection: 127.0.0.1:58088 <-> 127.0.0.1:1234 115 | INFO[2021-05-09T02:07:57+08:00] #1: 127.0.0.1:58342 <-> 127.0.0.1:8000 116 | INFO[2021-05-09T02:08:16+08:00] closed: 127.0.0.1:58342 <-> 127.0.0.1:8000 117 | INFO[2021-05-09T02:08:19+08:00] #2: 127.0.0.1:58402 <-> 127.0.0.1:8000 118 | ``` 119 | 120 | client test 121 | 122 | ``` 123 | $ nc 127.0.0.1 8000 124 | kkk 125 | kkk 126 | ^C 127 | $ nc 127.0.0.1 8000 128 | mmm 129 | mmm 130 | ``` 131 | -------------------------------------------------------------------------------- /pkg/multiplexer/multiplexer.go: -------------------------------------------------------------------------------- 1 | package multiplexer 2 | 3 | import ( 4 | "fmt" 5 | "io" 6 | "log/slog" 7 | "net" 8 | "sync" 9 | "time" 10 | 11 | "github.com/ingmarstein/tcp-multiplexer/pkg/message" 12 | ) 13 | 14 | type ( 15 | messageType int 16 | 17 | reqContainer struct { 18 | typ messageType 19 | message []byte 20 | sender chan<- *respContainer 21 | } 22 | 23 | respContainer struct { 24 | message []byte 25 | err error 26 | } 27 | 28 | Multiplexer struct { 29 | targetServer string 30 | port string 31 | messageReader message.Reader 32 | timeout time.Duration 33 | delay time.Duration 34 | l net.Listener 35 | quit chan struct{} 36 | wg *sync.WaitGroup 37 | requestQueue chan *reqContainer 38 | } 39 | ) 40 | 41 | const ( 42 | Connection messageType = iota 43 | Disconnection 44 | Packet 45 | ) 46 | 47 | func New(targetServer, port string, messageReader message.Reader, delay time.Duration, timeout time.Duration) Multiplexer { 48 | return Multiplexer{ 49 | targetServer: targetServer, 50 | port: port, 51 | messageReader: messageReader, 52 | quit: make(chan struct{}), 53 | delay: delay, 54 | timeout: timeout, 55 | } 56 | } 57 | 58 | func (mux *Multiplexer) deadline() time.Time { 59 | return time.Now().Add(mux.timeout) 60 | } 61 | 62 | func (mux *Multiplexer) Start() error { 63 | var err error 64 | mux.l, err = net.Listen("tcp", ":"+mux.port) 65 | if err != nil { 66 | return err 67 | } 68 | 69 | var wg sync.WaitGroup 70 | mux.wg = &wg 71 | 72 | requestQueue := make(chan *reqContainer, 32) 73 | mux.requestQueue = requestQueue 74 | 75 | // target connection loop 76 | go func() { 77 | mux.targetConnLoop(requestQueue) 78 | }() 79 | 80 | count := 0 81 | L: 82 | for { 83 | conn, err := mux.l.Accept() 84 | if err != nil { 85 | select { 86 | case <-mux.quit: 87 | slog.Info("no more connections will be accepted") 88 | return nil 89 | default: 90 | slog.Error(err.Error()) 91 | goto L 92 | } 93 | } 94 | count++ 95 | slog.Info(fmt.Sprintf("#%d: %v <-> %v", count, conn.RemoteAddr(), conn.LocalAddr())) 96 | 97 | wg.Add(1) 98 | go func() { 99 | mux.handleConnection(conn, requestQueue) 100 | wg.Done() 101 | }() 102 | } 103 | } 104 | 105 | func (mux *Multiplexer) handleConnection(conn net.Conn, sender chan<- *reqContainer) { 106 | defer func(c net.Conn) { 107 | slog.Debug(fmt.Sprintf("Closing client connection: %v", c.RemoteAddr())) 108 | err := c.Close() 109 | sender <- &reqContainer{typ: Disconnection} 110 | if err != nil { 111 | slog.Error(err.Error()) 112 | } 113 | }(conn) 114 | 115 | sender <- &reqContainer{typ: Connection} 116 | callback := make(chan *respContainer) 117 | 118 | for { 119 | err := conn.SetReadDeadline(mux.deadline()) 120 | if err != nil { 121 | slog.Error(fmt.Sprintf("error setting read deadline: %v", err)) 122 | } 123 | msg, err := mux.messageReader.ReadMessage(conn) 124 | if err == io.EOF { 125 | slog.Info(fmt.Sprintf("closed: %v <-> %v", conn.RemoteAddr(), conn.LocalAddr())) 126 | break 127 | } 128 | if err != nil { 129 | slog.Error(err.Error()) 130 | break 131 | } 132 | 133 | slog.Debug(fmt.Sprintf("Message from client...\n%x\n", msg)) 134 | 135 | // enqueue request msg to target conn loop 136 | sender <- &reqContainer{ 137 | typ: Packet, 138 | message: msg, 139 | sender: callback, 140 | } 141 | 142 | // get response from target conn loop 143 | resp := <-callback 144 | if resp.err != nil { 145 | slog.Error(fmt.Sprintf("failed to forward message, %v", resp.err)) 146 | break 147 | } 148 | 149 | // write back 150 | err = conn.SetWriteDeadline(mux.deadline()) 151 | if err != nil { 152 | slog.Error(fmt.Sprintf("error setting write deadline: %v", err)) 153 | } 154 | _, err = conn.Write(resp.message) 155 | if err != nil { 156 | slog.Error(err.Error()) 157 | break 158 | } 159 | } 160 | } 161 | 162 | func (mux *Multiplexer) createTargetConn() net.Conn { 163 | for { 164 | slog.Info("creating target connection") 165 | conn, err := net.DialTimeout("tcp", mux.targetServer, 30*time.Second) 166 | if err != nil { 167 | slog.Error(fmt.Sprintf("failed to connect to target server %s, %v", mux.targetServer, err)) 168 | // TODO: make sleep time configurable 169 | time.Sleep(1 * time.Second) 170 | continue 171 | } 172 | 173 | slog.Info(fmt.Sprintf("new target connection: %v <-> %v", conn.LocalAddr(), conn.RemoteAddr())) 174 | 175 | if mux.delay > 0 { 176 | slog.Info(fmt.Sprintf("waiting %s, before using new target connection", mux.delay)) 177 | time.Sleep(mux.delay) 178 | } 179 | 180 | return conn 181 | } 182 | } 183 | 184 | func (mux *Multiplexer) targetConnLoop(requestQueue <-chan *reqContainer) { 185 | var conn net.Conn 186 | clients := 0 187 | 188 | for container := range requestQueue { 189 | switch container.typ { 190 | case Connection: 191 | clients++ 192 | slog.Info(fmt.Sprintf("Connected clients: %d", clients)) 193 | continue 194 | case Disconnection: 195 | clients-- 196 | slog.Info(fmt.Sprintf("Connected clients: %d", clients)) 197 | if clients == 0 && conn != nil { 198 | slog.Info("closing target connection") 199 | err := conn.Close() 200 | if err != nil { 201 | slog.Error(err.Error()) 202 | } 203 | conn = nil 204 | } 205 | continue 206 | case Packet: 207 | break 208 | } 209 | 210 | if conn == nil { 211 | conn = mux.createTargetConn() 212 | } 213 | 214 | err := conn.SetWriteDeadline(mux.deadline()) 215 | if err != nil { 216 | slog.Error(fmt.Sprintf("error setting write deadline: %v", err)) 217 | } 218 | 219 | _, err = conn.Write(container.message) 220 | if err != nil { 221 | container.sender <- &respContainer{ 222 | err: err, 223 | } 224 | 225 | slog.Error(fmt.Sprintf("target connection: %v", err)) 226 | // renew conn 227 | err = conn.Close() 228 | if err != nil { 229 | slog.Error(fmt.Sprintf("error while closing connection: %v", err)) 230 | } 231 | conn = nil 232 | continue 233 | } 234 | 235 | err = conn.SetReadDeadline(mux.deadline()) 236 | if err != nil { 237 | slog.Error(fmt.Sprintf("error setting read deadline: %v", err)) 238 | } 239 | 240 | msg, err := mux.messageReader.ReadMessage(conn) 241 | container.sender <- &respContainer{ 242 | message: msg, 243 | err: err, 244 | } 245 | 246 | slog.Debug(fmt.Sprintf("Message from target server...\n%x\n", msg)) 247 | 248 | if err != nil { 249 | slog.Error(fmt.Sprintf("target connection: %v", err)) 250 | // renew conn 251 | err = conn.Close() 252 | if err != nil { 253 | slog.Error(fmt.Sprintf("error while closing connection: %v", err)) 254 | } 255 | conn = nil 256 | continue 257 | } 258 | } 259 | 260 | slog.Info("target connection write/read loop stopped gracefully") 261 | } 262 | 263 | // Close graceful shutdown 264 | func (mux *Multiplexer) Close() error { 265 | close(mux.quit) 266 | slog.Info("closing server...") 267 | err := mux.l.Close() 268 | if err != nil { 269 | return err 270 | } 271 | 272 | slog.Debug("wait all incoming connections closed") 273 | mux.wg.Wait() 274 | slog.Info("incoming connections closed") 275 | 276 | // stop target conn loop 277 | close(mux.requestQueue) 278 | 279 | slog.Info("multiplexer server stopped gracefully") 280 | slog.Info("server is closed gracefully") 281 | return nil 282 | } 283 | --------------------------------------------------------------------------------