From e63708388bee99bb8348fce0cbbcc7bd2775e892 Mon Sep 17 00:00:00 2001 From: Reza Behzadan Date: Sun, 3 Mar 2024 06:31:46 +0330 Subject: [PATCH] Initial commit --- .gitignore | 33 +++++++ .goreleaser.yml | 29 ++++++ LICENSE | 22 +++++ Makefile | 59 +++++++++++ README.md | 18 ++++ VERSION | 1 + go.mod | 3 + main.go | 258 ++++++++++++++++++++++++++++++++++++++++++++++++ 8 files changed, 423 insertions(+) create mode 100644 .gitignore create mode 100644 .goreleaser.yml create mode 100644 LICENSE create mode 100644 Makefile create mode 100644 README.md create mode 100644 VERSION create mode 100644 go.mod create mode 100644 main.go diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..71b4967 --- /dev/null +++ b/.gitignore @@ -0,0 +1,33 @@ +# If you prefer the allow list template instead of the deny list, see community template: +# https://github.com/github/gitignore/blob/main/community/Golang/Go.AllowList.gitignore +# +# Binaries for programs and plugins +*.exe +*.exe~ +*.dll +*.so +*.dylib + +# Test binary, built with `go test -c` +*.test + +# Output of the go coverage tool, specifically when used with LiteIDE +*.out + +# Dependency directories (remove the comment below to include it) +# vendor/ + +# Go workspace file +go.work + +# By Reza +build/ +dist/ +.archive/ +.vagrant/ +.env +*_[0-9] +*_[0-9][0-9] +*_????-??-?? +*.zip + diff --git a/.goreleaser.yml b/.goreleaser.yml new file mode 100644 index 0000000..8291155 --- /dev/null +++ b/.goreleaser.yml @@ -0,0 +1,29 @@ +project_name: tcproxy + +builds: + - env: + - CGO_ENABLED=0 + goos: + - linux + goarch: + - amd64 + - arm64 + - arm + goarm: + - 7 # For ARMv7 + +gitea_urls: + api: https://git.behzadan.ir/api/v1/ + +# Need a Gitea token with repo access stored in your environment as GITEA_TOKEN. +release: + gitea: + owner: reza + name: tcproxy + +archives: + - format: tar.gz + files: + - LICENSE + - README.md + diff --git a/LICENSE b/LICENSE new file mode 100644 index 0000000..ba6bb4b --- /dev/null +++ b/LICENSE @@ -0,0 +1,22 @@ +Copyright (c) 2024, Reza Behzadan + +Redistribution and use in source and binary forms, with or without modification, are permitted provided that the following conditions are met: + +1. Redistributions of source code must retain the above copyright notice, this list of conditions, and the following disclaimer. + +2. Redistributions in binary form must reproduce the above copyright notice, this list of conditions, and the following disclaimer in the documentation and/or other materials provided with the distribution. + +3. The name of "Reza Behzadan" may not be used to endorse or promote products derived from this software without specific prior written permission. + +4. Modified versions of the software must be distributed under the same terms and conditions of this license, and the original name of "Reza Behzadan" must be credited as the original developer in any significant portions of the redistributed or modified code. + +5. The software, including any modified versions, must not be used, directly or indirectly, in any type of military project or in any project that may cause harm to any human being. + +6. The software, including any modified versions, must not be used, directly or indirectly, by any individual, organization, or entity involved in or supporting oppressive, totalitarian regimes, or in any project that supports such regimes or contributes to human rights violations. This includes, but is not limited to, entities known for systematic oppression, cruelty, and use of violence against defenseless individuals, or for supporting terrorism. + + +DISCLAIMER: + +THIS SOFTWARE IS PROVIDED BY REZA BEHZADAN "AS IS" AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE ARE DISCLAIMED. IN NO EVENT SHALL REZA BEHZADAN BE LIABLE FOR ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR CONSEQUENTIAL DAMAGES (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR SERVICES; LOSS OF USE, DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER CAUSED AND ON ANY THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE OF THIS SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE. + + diff --git a/Makefile b/Makefile new file mode 100644 index 0000000..3826969 --- /dev/null +++ b/Makefile @@ -0,0 +1,59 @@ +PROJECT_NAME := tcproxy +VERSION := $(shell cat VERSION) +BUILD_DIR := build +DIST_DIR := dist +LDFLAGS := "-w -s" +VVERSION := v$(VERSION) + +.PHONY: build +build: + @echo "Building for this platform ..." + @mkdir -p $(BUILD_DIR) + @CGO_ENABLED=0 go build -ldflags=$(LDFLAGS) -o $(BUILD_DIR)/$(PROJECT_NAME) main.go + @echo "Build complete!" + +.PHONY: install +install: build + @install build/$(PROJECT_NAME) /usr/local/bin + @echo "Binary installed at /usr/local/bin/$(PROJECT_NAME)" + +.PHONY: release +release: + @echo "Check if the git working directory is clean" + @if [ -n "$$(git status --porcelain)" ]; then \ + echo "Error: The working directory is not clean. Please commit or stash your changes."; \ + exit 1; \ + fi + + @echo "Check if the version tag already exists and does not point to HEAD" + @if git rev-parse $(VVERSION) >/dev/null 2>&1; then \ + if ! git describe --tags --exact-match HEAD >/dev/null 2>&1; then \ + echo "Error: Version $(VVERSION) is already tagged on a different commit."; \ + exit 1; \ + fi; \ + fi + + @echo "Tag the latest commit with the version from VERSION file if not already tagged" + @if ! git describe --tags --exact-match HEAD >/dev/null 2>&1; then \ + echo "Latest commit not tagged. Tagging with version from VERSION file..."; \ + git tag -a $(VVERSION) -m "Release $(VVERSION)"; \ + git push origin $(VVERSION); \ + fi + + @echo "Set GITEA_TOKEN variable and run goreleaser" + @echo "export GITEA_TOKEN=$(shell pass www/behzadan.ir/git/reza/tokens/dt06-goreleaser)" + @echo "goreleaser release" + #@export GITEA_TOKEN=$(shell pass www/behzadan.ir/git/reza/tokens/dt06-goreleaser) \ + #goreleaser release + + +.PHONY: run +run: + go run main.go $(filter-out $@,$(MAKECMDGOALS)) +%: + @: + +clean: + @echo "Cleaning up..." + @rm -rf ${BUILD_DIR} + @rm -rf ${DIST_DIR} diff --git a/README.md b/README.md new file mode 100644 index 0000000..ef2944a --- /dev/null +++ b/README.md @@ -0,0 +1,18 @@ + + +### Sample `iptables` rules + +``` +sudo iptables -t nat -A PREROUTING -i eth0 -p tcp --dport 80 -j REDIRECT --to-port 8443 +sudo iptables -t nat -A PREROUTING -i eth0 -p tcp --dport 443 -j REDIRECT --to-port 8443 +sudo ip6tables -t nat -A PREROUTING -i eth0 -p tcp --dport 80 -j REDIRECT --to-port 8443 +sudo ip6tables -t nat -A PREROUTING -i eth0 -p tcp --dport 443 -j REDIRECT --to-port 8443 +``` + +### Sample `dnsmasq` config +``` +no-dhcp-interface= +enable-tftp=false + +address=/#/185.218.139.254 +``` diff --git a/VERSION b/VERSION new file mode 100644 index 0000000..9df886c --- /dev/null +++ b/VERSION @@ -0,0 +1 @@ +1.4.2 diff --git a/go.mod b/go.mod new file mode 100644 index 0000000..cad92d6 --- /dev/null +++ b/go.mod @@ -0,0 +1,3 @@ +module tcproxy + +go 1.22.0 diff --git a/main.go b/main.go new file mode 100644 index 0000000..53d9b6f --- /dev/null +++ b/main.go @@ -0,0 +1,258 @@ +package main + +import ( + "bufio" + "bytes" + "crypto/tls" + "embed" + "flag" + "fmt" + "io" + "log" + "net" + "net/http" + "os" + "runtime" + "strings" + "sync" + "time" +) + +//go:embed VERSION +var versionFile embed.FS +var ( + NAME = "tcproxy" + VERSION string +) + +func printHelp() { + helpText := fmt.Sprintf(`Usage: %s [options] [path] +Options: + -b, --bind The address to bind the server (default "localhost:8443"). + -v, --version Display the version of the server. + -h, --help Display this help message. +`, NAME) + fmt.Print(helpText) +} + +func readVersion() (string, error) { + version, err := versionFile.ReadFile("VERSION") + if err != nil { + return "", err + } + VERSION = strings.TrimSpace(string(version)) + return VERSION, nil +} + +func printVersion() { + fmt.Printf( + "%s %s %s %s %s\n", + NAME, + VERSION, + runtime.Version(), + runtime.GOOS, + runtime.GOARCH, + ) +} + +type readOnlyConn struct { + reader io.Reader +} + +func (conn readOnlyConn) Read(p []byte) (int, error) { return conn.reader.Read(p) } +func (conn readOnlyConn) Write(p []byte) (int, error) { return 0, io.ErrClosedPipe } +func (conn readOnlyConn) Close() error { return nil } +func (conn readOnlyConn) LocalAddr() net.Addr { return nil } +func (conn readOnlyConn) RemoteAddr() net.Addr { return nil } +func (conn readOnlyConn) SetDeadline(t time.Time) error { return nil } +func (conn readOnlyConn) SetReadDeadline(t time.Time) error { return nil } +func (conn readOnlyConn) SetWriteDeadline(t time.Time) error { return nil } + +func main() { + versionFlag := flag.Bool("version", false, "Display the version of the server") + versionFlagShort := flag.Bool("v", false, "Display the version (short)") + helpFlag := flag.Bool("help", false, "Display help message") + helpFlagShort := flag.Bool("h", false, "Display help message (short)") + bindAddr := flag.String("bind", "localhost:8443", "The address to bind the HTTPS server") + bindAddrShort := flag.String("b", "localhost:8443", "The address to bind the HTTPS server (short)") + flag.Parse() + + _, err := readVersion() + if err != nil { + fmt.Fprintf(os.Stderr, "Error reading version: %v\n", err) + os.Exit(1) + } + + if *bindAddrShort != "localhost:8443" { + bindAddr = bindAddrShort + } + if *versionFlag || *versionFlagShort { + printVersion() + os.Exit(0) + } + if *helpFlag || *helpFlagShort { + printHelp() + os.Exit(0) + } + + l, err := net.Listen("tcp", *bindAddr) + if err != nil { + log.Fatal(err) + } + printVersion() + log.Printf("Listening on %s for incoming HTTPS (or HTTP) connections...", *bindAddr) + for { + conn, err := l.Accept() + if err != nil { + log.Print(err) + continue + } + go handleConnection(conn) + } +} + +func peekClientHello(reader io.Reader) (*tls.ClientHelloInfo, io.Reader, error) { + peekedBytes := new(bytes.Buffer) + hello, err := readClientHello(io.TeeReader(reader, peekedBytes)) + if err != nil { + return nil, nil, err + } + return hello, io.MultiReader(peekedBytes, reader), nil +} + +func readClientHello(reader io.Reader) (*tls.ClientHelloInfo, error) { + var hello *tls.ClientHelloInfo + err := tls.Server(readOnlyConn{reader: reader}, &tls.Config{ + GetConfigForClient: func(argHello *tls.ClientHelloInfo) (*tls.Config, error) { + hello = new(tls.ClientHelloInfo) + *hello = *argHello + return nil, nil + }, + }).Handshake() + if hello == nil { + return nil, err + } + return hello, nil +} + +func handleHTTPS(reader io.Reader, frontendConn net.Conn) { + clientHello, clientReader, err := peekClientHello(reader) + if err != nil { + log.Print("Failed to peek ClientHello:", err) + return + } + + serverName := clientHello.ServerName + log.Printf("Forwarding request for domain: %s", serverName) + + // Connect to the backend server as specified by the SNI + backendConn, err := net.DialTimeout("tcp", net.JoinHostPort(serverName, "443"), 10*time.Second) + if err != nil { + log.Printf("Failed to connect to backend %s: %v", serverName, err) + return + } + defer backendConn.Close() + + var wg sync.WaitGroup + wg.Add(2) + + // Forward traffic from client to backend + go func() { + io.Copy(backendConn, clientReader) + backendConn.(*net.TCPConn).CloseWrite() + wg.Done() + }() + + // Forward traffic from backend to client + go func() { + io.Copy(frontendConn, backendConn) + frontendConn.(*net.TCPConn).CloseWrite() + wg.Done() + }() + + wg.Wait() + log.Printf("Completed forwarding for domain: %s", serverName) +} + +func handleHTTP(reader io.Reader, frontendConn net.Conn) { + // Buffer the reader to peek into the HTTP request + bufferedReader := bufio.NewReader(reader) + req, err := http.ReadRequest(bufferedReader) + if err != nil { + log.Printf("Error reading HTTP request: %v", err) + return + } + + // Extract the Host from the HTTP request + backendServer := req.Host + if backendServer == "" { + log.Println("No Host header found or empty Host header") + return + } + + // Establish a connection to the backend server + backendConn, err := net.DialTimeout("tcp", net.JoinHostPort(backendServer, "80"), 10*time.Second) + if err != nil { + log.Printf("Failed to connect to backend %s: %v", backendServer, err) + return + } + defer backendConn.Close() + + // Write the original request to the backend + req.Write(backendConn) + + // Use a WaitGroup to wait for both copy operations to complete + var wg sync.WaitGroup + wg.Add(2) + + // Forward remaining traffic from client to backend + go func() { + io.Copy(backendConn, bufferedReader) // bufferedReader now points to the rest of the stream after the initial read + backendConn.(*net.TCPConn).CloseWrite() + wg.Done() + }() + + // Forward traffic from backend to client + go func() { + io.Copy(frontendConn, backendConn) + frontendConn.(*net.TCPConn).CloseWrite() + wg.Done() + }() + + // Wait for both forwarding operations to complete + wg.Wait() + log.Printf("Completed forwarding HTTP traffic to %s", backendServer) +} + +func handleConnection(clientConn net.Conn) { + defer clientConn.Close() + + tcpAddr, ok := clientConn.RemoteAddr().(*net.TCPAddr) + if !ok { + log.Printf("Failed to obtain TCP address from connection") + clientConn.Close() + return + } + log.Printf("Received connection from %s:%d", tcpAddr.IP, tcpAddr.Port) + + // Create a buffer to peek the first byte + buf := make([]byte, 1) + _, err := clientConn.Read(buf) + if err != nil { + log.Print("Failed to read from client connection:", err) + return + } + + // Use a MultiReader to put the peeked byte back in front of the clientConn stream + clientReader := io.MultiReader(bytes.NewReader(buf), clientConn) + + // Check if the first byte indicates a TLS handshake (0x16) + if buf[0] == 0x16 { + // Handle as HTTPS + handleHTTPS(clientReader, clientConn) + } else { + // Handle as HTTP or other non-TLS traffic + handleHTTP(clientReader, clientConn) + } + +}