├── .devcontainer └── devcontainer.json ├── .github └── workflows │ └── docker-image.yml ├── .gitignore ├── Dockerfile ├── LICENSE ├── Makefile ├── README.md ├── client └── main.go ├── go.mod ├── go.sum ├── server ├── config.sample.yaml ├── main.go └── main_test.go └── utils ├── helpers.go └── logger.go /.devcontainer/devcontainer.json: -------------------------------------------------------------------------------- 1 | // For format details, see https://aka.ms/devcontainer.json. For config options, see the 2 | // README at: https://github.com/devcontainers/templates/tree/main/src/go 3 | { 4 | "name": "Go", 5 | // Or use a Dockerfile or Docker Compose file. More info: https://containers.dev/guide/dockerfile 6 | "image": "mcr.microsoft.com/devcontainers/go:1-1.21-bullseye", 7 | "features": { 8 | "ghcr.io/devcontainers/features/node:1": {} 9 | }, 10 | "customizations": { 11 | "vscode": { 12 | "extensions": [ 13 | "golang.go" 14 | ] 15 | } 16 | } 17 | 18 | // Features to add to the dev container. More info: https://containers.dev/features. 19 | // "features": {}, 20 | 21 | // Use 'forwardPorts' to make a list of ports inside the container available locally. 22 | // "forwardPorts": [], 23 | 24 | // Use 'postCreateCommand' to run commands after the container is created. 25 | // "postCreateCommand": "go version", 26 | 27 | // Configure tool-specific properties. 28 | // "customizations": {}, 29 | 30 | // Uncomment to connect as root instead. More info: https://aka.ms/dev-containers-non-root. 31 | // "remoteUser": "root" 32 | } 33 | -------------------------------------------------------------------------------- /.github/workflows/docker-image.yml: -------------------------------------------------------------------------------- 1 | # 2 | name: Create and publish a Docker image 3 | 4 | # Configures this workflow to run every time a change is pushed to the branch called `release`. 5 | on: 6 | push: 7 | tags: 8 | - v* 9 | 10 | # Defines two custom environment variables for the workflow. These are used for the Container registry domain, and a name for the Docker image that this workflow builds. 11 | env: 12 | REGISTRY: ghcr.io 13 | IMAGE_NAME: ${{ github.repository }} 14 | 15 | # There is a single job in this workflow. It's configured to run on the latest available version of Ubuntu. 16 | jobs: 17 | build-and-push-image: 18 | runs-on: ubuntu-latest 19 | # Sets the permissions granted to the `GITHUB_TOKEN` for the actions in this job. 20 | permissions: 21 | contents: read 22 | packages: write 23 | # 24 | steps: 25 | - name: Checkout repository 26 | uses: actions/checkout@v4 27 | - name: Set up QEMU 28 | uses: docker/setup-qemu-action@v3 29 | - name: Set up Docker Buildx 30 | uses: docker/setup-buildx-action@v3 31 | # Uses the `docker/login-action` action to log in to the Container registry registry using the account and password that will publish the packages. Once published, the packages are scoped to the account defined here. 32 | - name: Log in to the Container registry 33 | uses: docker/login-action@v3 34 | with: 35 | registry: ${{ env.REGISTRY }} 36 | username: ${{ github.actor }} 37 | password: ${{ secrets.GITHUB_TOKEN }} 38 | # This step uses [docker/metadata-action](https://github.com/docker/metadata-action#about) to extract tags and labels that will be applied to the specified image. The `id` "meta" allows the output of this step to be referenced in a subsequent step. The `images` value provides the base name for the tags and labels. 39 | - name: Extract metadata (tags, labels) for Docker 40 | id: meta 41 | uses: docker/metadata-action@v5 42 | with: 43 | images: ${{ env.REGISTRY }}/${{ env.IMAGE_NAME }} 44 | # This step uses the `docker/build-push-action` action to build the image, based on your repository's `Dockerfile`. If the build succeeds, it pushes the image to GitHub Packages. 45 | # It uses the `context` parameter to define the build's context as the set of files located in the specified path. For more information, see "[Usage](https://github.com/docker/build-push-action#usage)" in the README of the `docker/build-push-action` repository. 46 | # It uses the `tags` and `labels` parameters to tag and label the image with the output from the "meta" step. 47 | - name: Build and push Docker image 48 | uses: docker/build-push-action@v5 49 | with: 50 | context: . 51 | push: true 52 | platforms: linux/amd64,linux/arm64 53 | tags: ${{ steps.meta.outputs.tags }} 54 | labels: ${{ steps.meta.outputs.labels }} 55 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | bin/ 2 | 3 | # If you prefer the allow list template instead of the deny list, see community template: 4 | # https://github.com/github/gitignore/blob/main/community/Golang/Go.AllowList.gitignore 5 | # 6 | # Binaries for programs and plugins 7 | *.exe 8 | *.exe~ 9 | *.dll 10 | *.so 11 | *.dylib 12 | 13 | # Test binary, built with `go test -c` 14 | *.test 15 | 16 | # Output of the go coverage tool, specifically when used with LiteIDE 17 | *.out 18 | 19 | # Dependency directories (remove the comment below to include it) 20 | # vendor/ 21 | 22 | # Go workspace file 23 | go.work 24 | -------------------------------------------------------------------------------- /Dockerfile: -------------------------------------------------------------------------------- 1 | FROM golang:1.21 as builder 2 | 3 | 4 | WORKDIR /app 5 | 6 | COPY . ./ 7 | RUN go mod download 8 | 9 | 10 | RUN CGO_ENABLED=0 GOOS=linux go build -o stupid-proxy-server ./server/main.go 11 | RUN CGO_ENABLED=0 GOOS=linux go build -o stupid-proxy-client ./client/main.go 12 | 13 | 14 | FROM debian:buster-slim 15 | RUN set -x && apt-get update && DEBIAN_FRONTEND=noninteractive apt-get install -y \ 16 | ca-certificates && \ 17 | rm -rf /var/lib/apt/lists/* 18 | 19 | 20 | COPY --from=builder /app/stupid-proxy-* /app/ 21 | 22 | CMD ["/app/stupid-proxy-server"] 23 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | This is free and unencumbered software released into the public domain. 2 | 3 | Anyone is free to copy, modify, publish, use, compile, sell, or 4 | distribute this software, either in source code form or as a compiled 5 | binary, for any purpose, commercial or non-commercial, and by any 6 | means. 7 | 8 | In jurisdictions that recognize copyright laws, the author or authors 9 | of this software dedicate any and all copyright interest in the 10 | software to the public domain. We make this dedication for the benefit 11 | of the public at large and to the detriment of our heirs and 12 | successors. We intend this dedication to be an overt act of 13 | relinquishment in perpetuity of all present and future rights to this 14 | software under copyright law. 15 | 16 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, 17 | EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF 18 | MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. 19 | IN NO EVENT SHALL THE AUTHORS BE LIABLE FOR ANY CLAIM, DAMAGES OR 20 | OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, 21 | ARISING FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR 22 | OTHER DEALINGS IN THE SOFTWARE. 23 | 24 | For more information, please refer to 25 | -------------------------------------------------------------------------------- /Makefile: -------------------------------------------------------------------------------- 1 | GH_USER=hired-varied 2 | PROJ_NAME=stupid-proxy 3 | BINDIR=bin 4 | VERSION=$(shell git describe --tags || echo "unknown version") 5 | BUILDTIME=$(shell date -u) 6 | GOBUILD=CGO_ENABLED=0 go build -ldflags '-X "github.com/$(GH_USER)/$(PROJ_NAME)/constant.Version=$(VERSION)" \ 7 | -X "github.com/$(GH_USER)/$(PROJ_NAME)/constant.BuildTime=$(BUILDTIME)" \ 8 | -w -s' 9 | 10 | PLATFORM_LIST = \ 11 | macos-amd64 \ 12 | linux-386 \ 13 | linux-amd64 \ 14 | linux-armv5 \ 15 | linux-armv6 \ 16 | linux-armv7 \ 17 | linux-armv8 \ 18 | linux-mips-softfloat \ 19 | linux-mips-hardfloat \ 20 | linux-mipsle \ 21 | linux-mipsle-softfloat \ 22 | linux-mips64 \ 23 | linux-mips64le \ 24 | freebsd-386 \ 25 | freebsd-amd64 26 | 27 | WINDOWS_ARCH_LIST = \ 28 | windows-386 \ 29 | windows-amd64 30 | 31 | define compile_both 32 | $(GOBUILD) -o $(BINDIR)/$(PROJ_NAME)-server-$(1) server/main.go 33 | $(GOBUILD) -o $(BINDIR)/$(PROJ_NAME)-client-$(1) client/main.go 34 | endef 35 | 36 | all: linux-amd64 # Most used 37 | 38 | macos-amd64: 39 | GOARCH=amd64 GOOS=darwin $(call compile_both,$@) 40 | 41 | linux-386: 42 | GOARCH=386 GOOS=linux $(call compile_both,$@) 43 | 44 | linux-amd64: 45 | GOARCH=amd64 GOOS=linux $(call compile_both,$@) 46 | 47 | linux-armv5: 48 | GOARCH=arm GOOS=linux GOARM=5 $(call compile_both,$@) 49 | 50 | linux-armv6: 51 | GOARCH=arm GOOS=linux GOARM=6 $(call compile_both,$@) 52 | 53 | linux-armv7: 54 | GOARCH=arm GOOS=linux GOARM=7 $(call compile_both,$@) 55 | 56 | linux-armv8: 57 | GOARCH=arm64 GOOS=linux $(call compile_both,$@) 58 | 59 | linux-mips-softfloat: 60 | GOARCH=mips GOMIPS=softfloat GOOS=linux $(call compile_both,$@) 61 | 62 | linux-mips-hardfloat: 63 | GOARCH=mips GOMIPS=hardfloat GOOS=linux $(call compile_both,$@) 64 | 65 | linux-mipsle: 66 | GOARCH=mipsle GOOS=linux $(call compile_both,$@) 67 | 68 | linux-mipsle-softfloat: 69 | GOARCH=mipsle GOMIPS=softfloat GOOS=linux $(call compile_both,$@) 70 | 71 | linux-mips64: 72 | GOARCH=mips64 GOOS=linux $(call compile_both,$@) 73 | 74 | linux-mips64le: 75 | GOARCH=mips64le GOOS=linux $(call compile_both,$@) 76 | 77 | freebsd-386: 78 | GOARCH=386 GOOS=freebsd $(call compile_both,$@) 79 | 80 | freebsd-amd64: 81 | GOARCH=amd64 GOOS=freebsd $(call compile_both,$@) 82 | 83 | windows-386: 84 | GOARCH=386 GOOS=windows $(call compile_both,$@.exe) 85 | 86 | windows-amd64: 87 | GOARCH=amd64 GOOS=windows $(call compile_both,$@.exe) 88 | 89 | gz_releases=$(addsuffix .gz, $(PLATFORM_LIST)) 90 | zip_releases=$(addsuffix .zip, $(WINDOWS_ARCH_LIST)) 91 | 92 | $(gz_releases): %.gz : % 93 | chmod +x $(BINDIR)/$(PROJ_NAME)-server-$(basename $@) 94 | chmod +x $(BINDIR)/$(PROJ_NAME)-client-$(basename $@) 95 | gzip -f -S -$(VERSION).gz $(BINDIR)/$(PROJ_NAME)-server-$(basename $@) 96 | gzip -f -S -$(VERSION).gz $(BINDIR)/$(PROJ_NAME)-client-$(basename $@) 97 | 98 | $(zip_releases): %.zip : % 99 | zip -m -j $(BINDIR)/$(PROJ_NAME)-server-$(basename $@)-$(VERSION).zip $(BINDIR)/$(PROJ_NAME)-server-$(basename $@).exe 100 | zip -m -j $(BINDIR)/$(PROJ_NAME)-client-$(basename $@)-$(VERSION).zip $(BINDIR)/$(PROJ_NAME)-client-$(basename $@).exe 101 | 102 | all-arch: $(PLATFORM_LIST) $(WINDOWS_ARCH_LIST) 103 | 104 | releases: $(gz_releases) $(zip_releases) 105 | 106 | clean: 107 | rm -f $(BINDIR)/* 108 | 109 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # A stupid proxy 2 | 3 | A proxy that's stupid enough to allow you audiut it with a cup of coffee. 4 | 5 | 这是一个笨拙的代理程序,代码没几行,所以你可以很容易审计所有代码,目前服务端已经完成。 6 | 7 | 这个仓库的代码只有核心的代理逻辑,并且设计之初就打算使用 Treafik 作为接入边界。 8 | 核心的代理逻辑就是一个非常简单的 HTTP/HTTPS 代理,支持最朴素的用户名密码验证,代理验证默认不会显式返回给客户端(这样可以避免检测)。 9 | 但是考虑到如果直接作为 Chrome 的代理使用,还是需要一个触发器,所以这里留下了一个 URL,当客户端访问这个 URL 的时候会触发代理请求(后话)。 10 | 目前这种代理的方式是有 TLS over TLS 特征的(可以被检测),这个项目本身倒是可以支持 HTTP2 连接复用同一个 TCP 连接,可能某种程度上可能可以解决。 11 | 12 | # 部署指南 13 | 14 | 0. 你需要有一台 Linux 服务器,一个域名,并把域名指向你的服务器,开放 `80` 和 `443` 端口。 15 | 16 | 1. 安装 docker: 17 | 18 | ```shell 19 | sudo apt update 20 | sudo apt install docker.io docker-compose -y 21 | ``` 22 | 23 | 2. 前往任意目录,当然最好新开一个目录,保存以下内容到 `docker-compose.yaml`: 24 | 25 | ```yaml 26 | version: "3" 27 | 28 | services: 29 | traefik: 30 | image: traefik:latest 31 | restart: always 32 | command: 33 | - "--global.sendAnonymousUsage=false" 34 | - "--providers.docker" 35 | - "--providers.docker.exposedByDefault=false" 36 | - "--entrypoints.web.address=:80" 37 | - "--entrypoints.web.http.redirections.entrypoint.to=websecure" 38 | - "--entrypoints.web.http.redirections.entrypoint.scheme=https" 39 | - "--entrypoints.websecure.address=:443" 40 | - "--certificatesresolvers.tmhttpchallenge.acme.httpchallenge=true" 41 | - "--certificatesresolvers.tmhttpchallenge.acme.httpchallenge.entrypoint=web" 42 | - "--certificatesresolvers.tmhttpchallenge.acme.email=${LETSENCRYPT_EMAIL}" 43 | - "--certificatesresolvers.tmhttpchallenge.acme.storage=/etc/acme/acme.json" 44 | ports: 45 | - 80:80 46 | - 443:443 47 | volumes: 48 | - ./acme/:/etc/acme/ 49 | - /var/run/docker.sock:/var/run/docker.sock:ro 50 | 51 | stupid-proxy: 52 | image: ghcr.io/hired-varied/stupid-proxy:latest 53 | restart: always 54 | volumes: 55 | - ./config.yaml:/config.yaml 56 | expose: 57 | - "3000" 58 | labels: 59 | - "traefik.enable=true" 60 | - "traefik.tcp.services.stupid-proxy.loadbalancer.proxyprotocol.version=2" 61 | - "traefik.tcp.routers.stupid-proxy.rule=HostSNI(`${PROXY_FQDN}`)" 62 | - "traefik.tcp.routers.stupid-proxy.entrypoints=websecure" 63 | - "traefik.tcp.routers.stupid-proxy.tls" 64 | - "traefik.tcp.routers.stupid-proxy.tls.certresolver=tmhttpchallenge" 65 | cap_drop: 66 | - all 67 | ``` 68 | 69 | 3. 保存以下内容到 `.env`(请根据实际情况需要修改): 70 | 71 | ```env 72 | LETSENCRYPT_EMAIL=your-email@domain.com 73 | PROXY_FQDN=your.domain.com 74 | ``` 75 | 76 | 4. 保存以下内容到 `config.yaml` (注意修改用户名密码,触发 URL 到不为人知的值): 77 | 78 | ```yaml 79 | upstream_addr: "http://example.com" # 想伪装的目标 80 | listen_addr: 0.0.0.0:3000 81 | auth_trigger_path: /trigger-is-a-secret-path 82 | auth: 83 | username1: password1 84 | username2: password2 85 | ``` 86 | 87 | 5. 启动: 88 | 89 | ```shell 90 | sodo docker-compose up -d 91 | ``` 92 | 93 | 检查日志: 94 | 95 | ```shell 96 | sodo docker-compose logs -f 97 | ``` 98 | 99 | 备份:备份这个目录下的所有文件即可。 100 | 101 | 6. 使用支持的客户端进行连接,例如 iOS 的 Shadowrocket,代理类型选 HTTPS / HTTP2 都可以;Chrome / FireFox 也是可以的,但是需要写一个插件出发代理认证请求(这里还未提供)。 102 | 103 | -------------------------------------------------------------------------------- /client/main.go: -------------------------------------------------------------------------------- 1 | package main 2 | 3 | import "github.com/hired-varied/stupid-proxy/utils" 4 | 5 | var logger = utils.NewLogger(utils.InfoLevel) 6 | 7 | func main() { 8 | logger.Info("Work in progress") 9 | } 10 | -------------------------------------------------------------------------------- /go.mod: -------------------------------------------------------------------------------- 1 | module github.com/hired-varied/stupid-proxy 2 | 3 | go 1.21.3 4 | 5 | require ( 6 | github.com/pires/go-proxyproto v0.7.0 7 | golang.org/x/net v0.17.0 8 | golang.org/x/text v0.13.0 9 | gopkg.in/yaml.v3 v3.0.1 10 | ) 11 | -------------------------------------------------------------------------------- /go.sum: -------------------------------------------------------------------------------- 1 | github.com/pires/go-proxyproto v0.7.0 h1:IukmRewDQFWC7kfnb66CSomk2q/seBuilHBYFwyq0Hs= 2 | github.com/pires/go-proxyproto v0.7.0/go.mod h1:Vz/1JPY/OACxWGQNIRY2BeyDmpoaWmEP40O9LbuiFR4= 3 | golang.org/x/net v0.17.0 h1:pVaXccu2ozPjCXewfr1S7xza/zcXTity9cCdXQYSjIM= 4 | golang.org/x/net v0.17.0/go.mod h1:NxSsAGuq816PNPmqtQdLE42eU2Fs7NoRIZrHJAlaCOE= 5 | golang.org/x/text v0.13.0 h1:ablQoSUd0tRdKxZewP80B+BaqeKJuVhuRxj/dkrun3k= 6 | golang.org/x/text v0.13.0/go.mod h1:TvPlkZtksWOMsz7fbANvkp4WM8x/WCo/om8BMLbz+aE= 7 | gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= 8 | gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA= 9 | gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= 10 | -------------------------------------------------------------------------------- /server/config.sample.yaml: -------------------------------------------------------------------------------- 1 | upstream_addr: "http://example.com" 2 | listen_addr: 0.0.0.0:3000 3 | auth_trigger_path: /auth 4 | auth: 5 | username: password 6 | -------------------------------------------------------------------------------- /server/main.go: -------------------------------------------------------------------------------- 1 | package main 2 | 3 | import ( 4 | "crypto/tls" 5 | "encoding/base64" 6 | "errors" 7 | "flag" 8 | "io" 9 | "log" 10 | "net" 11 | "net/http" 12 | "net/http/httputil" 13 | "net/url" 14 | "strings" 15 | "time" 16 | 17 | "golang.org/x/net/http2" 18 | "golang.org/x/net/http2/h2c" 19 | 20 | "github.com/hired-varied/stupid-proxy/utils" 21 | "github.com/pires/go-proxyproto" 22 | ) 23 | 24 | var ( 25 | configFile = flag.String("config-file", "./config.yaml", "Config file") 26 | logger = utils.NewLogger(utils.InfoLevel) 27 | ) 28 | 29 | // Config represents the server configuration. 30 | type Config struct { 31 | UpstreamAddr string `yaml:"upstream_addr"` 32 | ListenAddr string `yaml:"listen_addr"` 33 | AuthTriggerPath string `yaml:"auth_trigger_path"` 34 | Auth map[string]string `yaml:"auth"` 35 | } 36 | 37 | type defaultHandler struct { 38 | reverseProxyUpstreamHost string 39 | reverseProxy *httputil.ReverseProxy 40 | config Config 41 | } 42 | 43 | type flushWriter struct { 44 | w io.Writer 45 | } 46 | 47 | func (f *flushWriter) Write(p []byte) (n int, err error) { 48 | defer func() { 49 | if r := recover(); r != nil { 50 | if s, ok := r.(string); ok { 51 | err = errors.New(s) 52 | logger.Error("Flush writer error in recover: %s\n", err) 53 | return 54 | } 55 | err = r.(error) 56 | } 57 | }() 58 | 59 | n, err = f.w.Write(p) 60 | if err != nil { 61 | logger.Error("Flush writer error in write response: %s\n", err) 62 | return 63 | } 64 | if f, ok := f.w.(http.Flusher); ok { 65 | f.Flush() 66 | } 67 | return 68 | } 69 | 70 | var headerBlackList = map[string]bool{} 71 | 72 | func (h *defaultHandler) ServeHTTP(w http.ResponseWriter, r *http.Request) { 73 | isAuthTriggerURL := r.Method == http.MethodGet && strings.HasSuffix(r.URL.Path, h.config.AuthTriggerPath) 74 | authorized, username := h.isAuthenticated(r.Header.Get("Proxy-Authorization")) 75 | if isAuthTriggerURL { 76 | if authorized { 77 | w.WriteHeader(http.StatusOK) 78 | } else { 79 | w.Header().Add("Proxy-Authenticate", "Basic realm=\"Hi, please show me your token!\"") 80 | w.WriteHeader(http.StatusProxyAuthRequired) 81 | } 82 | w.Write([]byte("")) 83 | w.(http.Flusher).Flush() 84 | } else { 85 | if authorized { 86 | logger.Debug("[%s] %s %s\n", username, r.Method, r.URL) 87 | for k := range r.Header { 88 | if headerBlackList[strings.ToLower(k)] { 89 | r.Header.Del(k) 90 | } 91 | } 92 | proxy(w, r, username) 93 | } else { // silent makes big fortune 94 | if username == "" { 95 | logger.Debug("[normal] %s %s\n", r.Method, r.URL) 96 | } else { 97 | logger.Debug("{%s} %s %s\n", username, r.Method, r.URL) 98 | } 99 | h.handleReverseProxy(w, r) 100 | } 101 | } 102 | } 103 | 104 | func (h *defaultHandler) isAuthenticated(authHeader string) (bool, string) { 105 | s := strings.SplitN(authHeader, " ", 2) 106 | if len(s) != 2 { 107 | return false, "" 108 | } 109 | 110 | b, err := base64.StdEncoding.DecodeString(s[1]) 111 | if err != nil { 112 | return false, "AuthBase64Invalid" 113 | } 114 | 115 | pair := strings.SplitN(string(b), ":", 2) 116 | if len(pair) != 2 { 117 | return false, "AuthUsernamePasswordInvalid" 118 | } 119 | 120 | email := pair[0] 121 | token := pair[1] 122 | 123 | // Check if it matches the static result 124 | if h.config.Auth[email] == token { 125 | return true, email 126 | } 127 | 128 | return false, "InvalidEmail " + email 129 | } 130 | 131 | func proxy(w http.ResponseWriter, r *http.Request, username string) { 132 | if r.Method == http.MethodConnect { 133 | handleTunneling(w, r, username) 134 | } else { 135 | handleHTTP(w, r, username) 136 | } 137 | } 138 | 139 | func (h *defaultHandler) handleReverseProxy(w http.ResponseWriter, r *http.Request) { 140 | for k := range r.Header { 141 | lk := strings.ToLower(k) 142 | if lk == "host" || lk == "authority" { 143 | r.Header.Del(k) 144 | } 145 | } 146 | r.Header.Add("Host", h.reverseProxyUpstreamHost) 147 | h.reverseProxy.ServeHTTP(w, r) 148 | } 149 | 150 | func createTCPConn(host string) (*net.TCPConn, error) { 151 | destConn, err := net.DialTimeout("tcp", host, 10*time.Second) 152 | if err != nil { 153 | return nil, err 154 | } 155 | if tcpConn, ok := destConn.(*net.TCPConn); ok { 156 | return tcpConn, nil 157 | } 158 | return nil, errors.New("failed to cast net.Conn to net.TCPConn") 159 | } 160 | 161 | func hijack(w http.ResponseWriter) (net.Conn, error) { 162 | hijacker, ok := w.(http.Hijacker) 163 | if !ok { 164 | return nil, errors.New("hijacking not supported") 165 | } 166 | clientConn, _, err := hijacker.Hijack() 167 | return clientConn, err 168 | } 169 | 170 | func handleTunneling(w http.ResponseWriter, r *http.Request, username string) { 171 | remoteTCPConn, err := createTCPConn(r.Host) 172 | if err != nil { 173 | http.Error(w, err.Error(), http.StatusBadGateway) 174 | return 175 | } 176 | defer remoteTCPConn.Close() 177 | w.WriteHeader(http.StatusOK) 178 | if r.ProtoMajor == 2 { 179 | w.(http.Flusher).Flush() // Must flush, or the client won't start the connection 180 | go func() { 181 | // Client -> Remote 182 | defer remoteTCPConn.CloseWrite() 183 | utils.CopyAndPrintError(remoteTCPConn, r.Body, logger) 184 | }() 185 | // Remote -> Client 186 | defer remoteTCPConn.CloseRead() 187 | utils.CopyAndPrintError(&flushWriter{w}, remoteTCPConn, logger) 188 | } else { 189 | clientConn, err := hijack(w) 190 | if err != nil { 191 | logger.Error("Hijack failed: %s", err) 192 | return 193 | } 194 | defer clientConn.Close() 195 | go func() { 196 | // Client -> Remote 197 | defer remoteTCPConn.CloseWrite() 198 | utils.CopyAndPrintError(remoteTCPConn, clientConn, logger) 199 | }() 200 | // Remote -> Client 201 | defer remoteTCPConn.CloseRead() 202 | utils.CopyAndPrintError(clientConn, remoteTCPConn, logger) 203 | } 204 | } 205 | 206 | func handleHTTP(w http.ResponseWriter, req *http.Request, username string) { 207 | if req.ProtoMajor == 2 { 208 | req.URL.Scheme = "http" 209 | req.URL.Host = req.Host 210 | } 211 | pipeRead, pipeWrite := io.Pipe() 212 | fromBody := req.Body 213 | req.Body = pipeRead 214 | go func() { 215 | defer pipeWrite.Close() 216 | defer fromBody.Close() 217 | utils.CopyAndPrintError(pipeWrite, fromBody, logger) 218 | }() 219 | resp, err := http.DefaultTransport.RoundTrip(req) 220 | if err != nil { 221 | http.Error(w, err.Error(), http.StatusBadGateway) 222 | return 223 | } 224 | copyHeader(w.Header(), resp.Header) 225 | w.WriteHeader(resp.StatusCode) 226 | utils.CopyAndPrintError(w, resp.Body, logger) 227 | } 228 | 229 | func copyHeader(dst, src http.Header) { 230 | for k, vv := range src { 231 | for _, v := range vv { 232 | dst.Add(k, v) 233 | } 234 | } 235 | } 236 | 237 | func main() { 238 | flag.Parse() 239 | hopByHopHeaders := []string{ 240 | "Connection", 241 | "Keep-Alive", 242 | "Proxy-Authenticate", 243 | "Proxy-Authorization", 244 | "Trailer", 245 | "TE", 246 | "Transfer-Encoding", 247 | "Upgrade", 248 | } 249 | for _, header := range hopByHopHeaders { 250 | headerBlackList[strings.ToLower(header)] = true 251 | } 252 | config := &Config{} 253 | utils.LoadConfigFile(*configFile, config) 254 | reverseProxyURL, err := url.Parse(config.UpstreamAddr) 255 | if err != nil { 256 | log.Fatal("Failed to parse reverse proxy URL", err) 257 | } 258 | reverseProxyUpstreamHost := reverseProxyURL.Host 259 | reverseProxy := httputil.NewSingleHostReverseProxy(reverseProxyURL) 260 | logger.Info("Listening on %s, upstream to %s.\n", config.ListenAddr, config.UpstreamAddr) 261 | 262 | h2s := &http2.Server{} 263 | server := &http.Server{ 264 | Addr: config.ListenAddr, 265 | Handler: h2c.NewHandler(&defaultHandler{ 266 | reverseProxyUpstreamHost, 267 | reverseProxy, 268 | *config, 269 | }, h2s), 270 | TLSConfig: &tls.Config{ 271 | MinVersion: tls.VersionTLS12, 272 | }, 273 | } 274 | ln, err := net.Listen("tcp", server.Addr) 275 | if err != nil { 276 | panic(err) 277 | } 278 | 279 | proxyListener := &proxyproto.Listener{ 280 | Listener: ln, 281 | } 282 | ln = proxyListener 283 | defer ln.Close() 284 | 285 | err = server.Serve(ln) 286 | if err != nil { 287 | log.Fatal("Failed to serve: ", err) 288 | } 289 | } 290 | -------------------------------------------------------------------------------- /server/main_test.go: -------------------------------------------------------------------------------- 1 | package main 2 | 3 | import ( 4 | "bytes" 5 | "encoding/base64" 6 | "net/http" 7 | "net/http/httptest" 8 | "net/http/httputil" 9 | "net/url" 10 | "testing" 11 | 12 | "github.com/hired-varied/stupid-proxy/utils" 13 | ) 14 | 15 | func TestIsAuthenticated(t *testing.T) { 16 | // Initialize a defaultHandler with a sample config 17 | handler := &defaultHandler{ 18 | config: Config{ 19 | Auth: map[string]string{ 20 | "testuser": "testpass", 21 | }, 22 | }, 23 | } 24 | 25 | // Test an authenticated request 26 | authHeader := "Basic " + base64.StdEncoding.EncodeToString([]byte("testuser:testpass")) 27 | authorized, username := handler.isAuthenticated(authHeader) 28 | if !authorized { 29 | t.Errorf("Expected request to be authorized") 30 | } 31 | if username != "testuser" { 32 | t.Errorf("Expected username to be 'testuser', got %s", username) 33 | } 34 | 35 | // Test an invalid request 36 | authHeader = "InvalidHeader" 37 | authorized, _ = handler.isAuthenticated(authHeader) 38 | if authorized { 39 | t.Errorf("Expected request to be unauthorized") 40 | } 41 | } 42 | 43 | func TestCopyHeader(t *testing.T) { 44 | src := make(http.Header) 45 | src.Add("Key1", "Value1") 46 | src.Add("Key2", "Value2") 47 | 48 | dst := make(http.Header) 49 | copyHeader(dst, src) 50 | 51 | if len(dst) != 2 { 52 | t.Errorf("Expected destination header to have 2 entries") 53 | } 54 | if dst.Get("Key1") != "Value1" { 55 | t.Errorf("Expected Key1 to have Value1") 56 | } 57 | if dst.Get("Key2") != "Value2" { 58 | t.Errorf("Expected Key2 to have Value2") 59 | } 60 | } 61 | 62 | func TestHandleTunneling(t *testing.T) { 63 | // Create a test server for handling the CONNECT request 64 | server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { 65 | // Simulate the behavior of the upstream server 66 | // In this example, we just send a success response 67 | w.WriteHeader(http.StatusOK) 68 | })) 69 | defer server.Close() 70 | 71 | // Create a fake client request to test the handleTunneling function 72 | clientRequest, err := http.NewRequest(http.MethodConnect, server.URL, nil) 73 | if err != nil { 74 | t.Fatalf("Failed to create client request: %v", err) 75 | } 76 | 77 | // Create a fake ResponseWriter for the test 78 | responseRecorder := httptest.NewRecorder() 79 | 80 | // Call the handleTunneling function 81 | handleTunneling(responseRecorder, clientRequest, "testuser") 82 | 83 | // Check if the response has the expected status code 84 | if responseRecorder.Code != http.StatusOK { 85 | t.Errorf("Expected status code %d, got %d", http.StatusOK, responseRecorder.Code) 86 | } 87 | } 88 | 89 | func TestHandleHTTP(t *testing.T) { 90 | // Create a test server for handling HTTP requests 91 | server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { 92 | // Simulate the behavior of the upstream server 93 | // In this example, we just send a simple response 94 | w.WriteHeader(http.StatusOK) 95 | w.Write([]byte("Response Body")) 96 | })) 97 | defer server.Close() 98 | 99 | // Create a fake client request to test the handleHTTP function 100 | clientRequest, err := http.NewRequest(http.MethodGet, server.URL, bytes.NewReader(nil)) 101 | if err != nil { 102 | t.Fatalf("Failed to create client request: %v", err) 103 | } 104 | 105 | // Create a fake ResponseWriter for the test 106 | responseRecorder := httptest.NewRecorder() 107 | 108 | // Call the handleHTTP function 109 | handleHTTP(responseRecorder, clientRequest, "testuser") 110 | 111 | // Check if the response has the expected status code and body 112 | if responseRecorder.Code != http.StatusOK { 113 | t.Errorf("Expected status code %d, got %d", http.StatusOK, responseRecorder.Code) 114 | } 115 | 116 | expectedBody := "Response Body" 117 | if responseRecorder.Body.String() != expectedBody { 118 | t.Errorf("Expected response body %q, got %q", expectedBody, responseRecorder.Body.String()) 119 | } 120 | } 121 | 122 | func TestLoadConfigFile(t *testing.T) { 123 | // Initialize a Config struct and load the test config 124 | config := &Config{} 125 | utils.LoadConfigFile("./config.sample.yaml", config) 126 | 127 | if config.UpstreamAddr != "http://example.com" || config.ListenAddr != "0.0.0.0:3000" || config.AuthTriggerPath != "/auth" || config.Auth["username"] != "password" { 128 | t.Errorf("Loaded config does not match the expected config") 129 | } 130 | } 131 | 132 | func TestServeHTTPWithAuthenticatedUser(t *testing.T) { 133 | // Create a test server for handling HTTP requests 134 | server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { 135 | // Simulate the behavior of the upstream server 136 | // In this example, we just send a simple response 137 | w.WriteHeader(http.StatusOK) 138 | w.Write([]byte("proxixed request")) 139 | })) 140 | defer server.Close() 141 | 142 | // Create a sample config for the defaultHandler 143 | config := Config{ 144 | UpstreamAddr: "https://www.google.com", 145 | ListenAddr: "127.0.0.1:8080", 146 | AuthTriggerPath: "/auth", 147 | Auth: map[string]string{ 148 | "testuser": "testpass", 149 | }, 150 | } 151 | 152 | // Create a fake client request with an authenticated user 153 | clientRequest, err := http.NewRequest(http.MethodGet, server.URL, bytes.NewReader(nil)) 154 | if err != nil { 155 | t.Fatalf("Failed to create client request: %v", err) 156 | } 157 | clientRequest.Header.Set("Proxy-Authorization", "Basic dGVzdHVzZXI6dGVzdHBhc3M=") // Base64("testuser:testpass") 158 | 159 | // Create a fake ResponseWriter for the test 160 | responseRecorder := httptest.NewRecorder() 161 | 162 | // Initialize the defaultHandler with the sample config 163 | reverseProxyURL, _ := url.Parse(config.UpstreamAddr) 164 | handler := &defaultHandler{ 165 | config: config, 166 | reverseProxy: httputil.NewSingleHostReverseProxy(reverseProxyURL), 167 | } 168 | 169 | // Call the ServeHTTP method with the client request and response writer 170 | handler.ServeHTTP(responseRecorder, clientRequest) 171 | 172 | // Check if the response has the expected status code and body 173 | if responseRecorder.Code != http.StatusOK { 174 | t.Errorf("Expected status code %d, got %d", http.StatusOK, responseRecorder.Code) 175 | } 176 | 177 | expectedBody := "proxixed request" 178 | if responseRecorder.Body.String() != expectedBody { 179 | t.Errorf("Expected response body %q, got %q", expectedBody, responseRecorder.Body.String()) 180 | } 181 | } 182 | -------------------------------------------------------------------------------- /utils/helpers.go: -------------------------------------------------------------------------------- 1 | package utils 2 | 3 | import ( 4 | "io" 5 | "log" 6 | "os" 7 | "sync" 8 | 9 | "gopkg.in/yaml.v3" 10 | ) 11 | 12 | // BuffPool buffer pool 13 | var BuffPool = sync.Pool{ 14 | New: func() interface{} { 15 | return make([]byte, 64*1024) 16 | }, 17 | } 18 | 19 | // LoadConfigFile from yaml file 20 | func LoadConfigFile(configFilePath string, config interface{}) { 21 | configFile, err := os.ReadFile(configFilePath) 22 | if err != nil { 23 | log.Fatal(err) 24 | } 25 | 26 | err = yaml.Unmarshal(configFile, config) 27 | if err != nil { 28 | log.Fatal(err) 29 | } 30 | } 31 | 32 | // CopyAndPrintError ditto 33 | func CopyAndPrintError(dst io.Writer, src io.Reader, logger *Logger) int64 { 34 | buf := BuffPool.Get().([]byte) 35 | defer BuffPool.Put(buf) 36 | size, err := io.CopyBuffer(dst, src, buf) 37 | if err != nil && err != io.EOF { 38 | logger.Error("Error while copy %s", err) 39 | } 40 | return size 41 | } 42 | -------------------------------------------------------------------------------- /utils/logger.go: -------------------------------------------------------------------------------- 1 | package utils 2 | 3 | import ( 4 | "fmt" 5 | "log" 6 | "os" 7 | ) 8 | 9 | const ( 10 | // DebugLevel log level 11 | DebugLevel = 0 12 | // InfoLevel log level 13 | InfoLevel = 1 14 | // WarningLevel log level 15 | WarningLevel = 2 16 | // ErrorLevel log level 17 | ErrorLevel = 3 18 | ) 19 | 20 | // Logger is a custom logger 21 | type Logger struct { 22 | level int 23 | } 24 | 25 | // NewLogger a new Logger 26 | func NewLogger(level int) *Logger { 27 | return &Logger{level} 28 | } 29 | 30 | var std = log.New(os.Stderr, "", log.LstdFlags) 31 | 32 | // Debug log 33 | func (l *Logger) Debug(format string, v ...interface{}) { 34 | if l.level > DebugLevel { 35 | return 36 | } 37 | std.Output(2, fmt.Sprintf(format, v...)) 38 | } 39 | 40 | // Info log 41 | func (l *Logger) Info(format string, v ...interface{}) { 42 | if l.level > InfoLevel { 43 | return 44 | } 45 | std.Output(2, fmt.Sprintf(format, v...)) 46 | } 47 | 48 | // Warning log 49 | func (l *Logger) Warning(format string, v ...interface{}) { 50 | if l.level > WarningLevel { 51 | return 52 | } 53 | std.Output(2, fmt.Sprintf(format, v...)) 54 | } 55 | 56 | // Error log 57 | func (l *Logger) Error(format string, v ...interface{}) { 58 | if l.level > ErrorLevel { 59 | return 60 | } 61 | std.Output(2, fmt.Sprintf(format, v...)) 62 | } 63 | --------------------------------------------------------------------------------