├── .github ├── dependabot.yml └── workflows │ ├── cd.yaml │ ├── ci.yaml │ ├── issue.yaml │ ├── nightly.yaml │ └── release.yml ├── .gitignore ├── .golangci.yml ├── .goreleaser.yml ├── .prettierrc ├── LICENSE ├── Makefile ├── README.md ├── build └── Dockerfile ├── cf-workers └── ws │ ├── .editorconfig │ ├── .gitignore │ ├── package-lock.json │ ├── package.json │ ├── src │ └── index.ts │ ├── tsconfig.json │ ├── vitest.config.ts │ ├── worker-configuration.d.ts │ └── wrangler.toml ├── cmd └── ehco │ └── main.go ├── examples ├── config.json ├── xray_ss.json └── xray_trojan.json ├── go.mod ├── go.sum ├── hack └── get-ehco.sh ├── internal ├── cli │ ├── app.go │ ├── cmd.go │ ├── config.go │ └── flags.go ├── cmgr │ ├── cmgr.go │ ├── config.go │ ├── ms │ │ ├── handler.go │ │ └── ms.go │ └── syncer.go ├── config │ └── config.go ├── conn │ ├── limit_reader.go │ ├── relay_conn.go │ ├── relay_conn_test.go │ ├── udp_listener.go │ ├── ws_conn.go │ └── ws_conn_test.go ├── constant │ └── constant.go ├── glue │ └── interface.go ├── lb │ ├── round_robin.go │ └── round_robin_test.go ├── metrics │ ├── metrics.go │ ├── node.go │ └── ping.go ├── relay │ ├── conf │ │ └── cfg.go │ ├── health_check.go │ ├── relay.go │ ├── server.go │ ├── server_reloader.go │ └── utils.go ├── tls │ └── tls.go ├── transporter │ ├── base.go │ ├── interface.go │ ├── raw.go │ ├── ws.go │ └── wss.go └── web │ ├── handler_api.go │ ├── handler_page.go │ ├── handlers_ws.go │ ├── js │ ├── node_metrics.js │ └── rule_metrics.js │ ├── mw.go │ ├── server.go │ ├── templates │ ├── _head.html │ ├── _navbar.html │ ├── _node_metrics_dash.html │ ├── _rule_metrics_dash.html │ ├── connection.html │ ├── index.html │ ├── logs.html │ ├── rule_list.html │ └── rule_metrics.html │ ├── types.go │ └── utils.go ├── monitor ├── dashboard.json ├── ping.png ├── prometheus.yaml ├── proxy-traffic.png ├── traffic.png └── web.png ├── pkg ├── buffer │ └── buffer.go ├── bytes │ └── utils.go ├── http │ └── http.go ├── limiter │ ├── limiter.go │ └── limtier_test.go ├── log │ ├── leveld.go │ ├── log.go │ └── ws.go ├── metric_reader │ ├── node.go │ ├── reader.go │ ├── rule.go │ └── utils.go └── xray │ ├── bandwidth_recorder.go │ ├── const.go │ ├── server.go │ ├── services.go │ ├── user.go │ └── utils.go ├── test ├── bench │ ├── ehco_config.json │ ├── readme.md │ └── start_ss_client.sh ├── cmd │ ├── echo │ │ └── main.go │ └── tcp_client │ │ └── main.go ├── echo │ ├── echo.go │ └── ws.json └── relay_test.go └── tools ├── .gitignore ├── Makefile ├── go.mod ├── go.sum └── tools.go /.github/dependabot.yml: -------------------------------------------------------------------------------- 1 | # To get started with Dependabot version updates, you'll need to specify which 2 | # package ecosystems to update and where the package manifests are located. 3 | # Please see the documentation for all configuration options: 4 | # https://help.github.com/github/administering-a-repository/configuration-options-for-dependency-updates 5 | 6 | version: 2 7 | updates: 8 | - package-ecosystem: "gomod" # See documentation for possible values 9 | directory: "/" # Location of package manifests 10 | schedule: 11 | interval: "weekly" 12 | ignore: 13 | - dependency-name: "github.com/xtaci/smux" 14 | groups: 15 | # Specify a name for the group, which will be used in pull request titles 16 | # and branch names 17 | dependencies: 18 | # Define patterns to include dependencies in the group (based on 19 | # dependency name) 20 | patterns: 21 | - "*" # A wildcard that matches all dependencies in the package 22 | 23 | - package-ecosystem: "github-actions" 24 | directory: "/" 25 | schedule: 26 | interval: "weekly" 27 | # Create a group of dependencies to be updated together in one pull request 28 | groups: 29 | # Specify a name for the group, which will be used in pull request titles 30 | # and branch names 31 | dependencies: 32 | # Define patterns to include dependencies in the group (based on 33 | # dependency name) 34 | patterns: 35 | - "*" # A wildcard that matches all dependencies in the package 36 | -------------------------------------------------------------------------------- /.github/workflows/cd.yaml: -------------------------------------------------------------------------------- 1 | name: build-image 2 | 3 | on: 4 | schedule: 5 | - cron: "0 0 * * *" 6 | push: 7 | tags: 8 | - "*" 9 | workflow_run: 10 | workflows: ["check-code"] 11 | types: 12 | - completed 13 | workflow_dispatch: 14 | 15 | env: 16 | REGISTRY_IMAGE: ehco1996/ehco 17 | 18 | jobs: 19 | prepare-matrix: 20 | if: ${{ github.event.workflow_run.conclusion == 'success' || github.event_name == 'workflow_dispatch' || github.event_name == 'schedule' || github.event_name == 'push' }} 21 | runs-on: ubuntu-latest 22 | outputs: 23 | matrix: ${{ steps.set-matrix.outputs.matrix }} 24 | steps: 25 | - id: set-matrix 26 | run: | 27 | echo '{"platform": ["amd64", "arm64"]}' > matrix.json 28 | echo "matrix=$(cat matrix.json)" >> $GITHUB_OUTPUT 29 | 30 | build-bin: 31 | if: ${{ github.event.workflow_run.conclusion == 'success' || github.event_name == 'workflow_dispatch' || github.event_name == 'schedule' || github.event_name == 'push' }} 32 | runs-on: ubuntu-latest 33 | steps: 34 | - name: Check out code into the Go module directory 35 | uses: actions/checkout@v4 36 | 37 | - name: Set up Go 38 | uses: actions/setup-go@v5 39 | with: 40 | go-version: "1.22" 41 | id: go 42 | 43 | # Updated cache step 44 | - uses: actions/cache@v4 45 | with: 46 | path: | 47 | ~/.cache/go-build 48 | ~/go/pkg/mod 49 | key: ${{ runner.os }}-go-${{ hashFiles('**/go.sum') }} 50 | restore-keys: | 51 | ${{ runner.os }}-go- 52 | 53 | - name: build 54 | run: | 55 | rm -rf /tmp/ehco 56 | mkdir -p /tmp/ehco 57 | make build 58 | mv ./dist/ehco /tmp/ehco/ehco-amd64 59 | make build-arm 60 | mv ./dist/ehco /tmp/ehco/ehco-arm64 61 | cp ./build/Dockerfile /tmp/ehco/Dockerfile 62 | # debug 63 | ls -la /tmp/ehco 64 | 65 | - name: Upload binaries 66 | uses: actions/upload-artifact@v4 67 | with: 68 | name: binaries 69 | path: /tmp/ehco 70 | if-no-files-found: error 71 | retention-days: 1 72 | 73 | build-image: 74 | if: ${{ github.event.workflow_run.conclusion == 'success' || github.event_name == 'workflow_dispatch' || github.event_name == 'schedule' || github.event_name == 'push' }} 75 | runs-on: ubuntu-latest 76 | needs: 77 | - prepare-matrix 78 | - build-bin 79 | strategy: 80 | fail-fast: true 81 | matrix: ${{fromJson(needs.prepare-matrix.outputs.matrix)}} 82 | steps: 83 | - name: Download binaries 84 | uses: actions/download-artifact@v4 85 | with: 86 | name: binaries 87 | path: /tmp/ehco 88 | 89 | - name: Rename the binary 90 | run: | 91 | # debug 92 | ls -la /tmp/ehco 93 | cp /tmp/ehco/ehco-${{ matrix.platform }} /tmp/ehco/ehco 94 | # debug 95 | ls -la /tmp/ehco 96 | 97 | - name: Set up qemu 98 | uses: docker/setup-qemu-action@v3 99 | 100 | - name: Set up Docker Buildx 101 | uses: docker/setup-buildx-action@v3 102 | 103 | - name: Login to Docker Hub 104 | uses: docker/login-action@v3 105 | with: 106 | username: ${{ secrets.DOCKER_USERNAME }} 107 | password: ${{ secrets.DOCKER_PASSWORD }} 108 | 109 | - name: Build multi-platform image 110 | uses: docker/build-push-action@v5 111 | id: build 112 | with: 113 | context: /tmp/ehco 114 | file: /tmp/ehco/Dockerfile 115 | platforms: linux/${{ matrix.platform }} 116 | cache-from: type=gha 117 | cache-to: type=gha,mode=max 118 | outputs: type=image,name=${{ env.REGISTRY_IMAGE }},push-by-digest=true,name-canonical=true,push=true 119 | 120 | - name: Export digest 121 | run: | 122 | mkdir -p /tmp/digests 123 | digest="${{ steps.build.outputs.digest }}" 124 | touch "/tmp/digests/${digest#sha256:}" 125 | 126 | - name: Upload digest 127 | uses: actions/upload-artifact@v4 128 | with: 129 | name: digests-${{ matrix.platform }} 130 | path: /tmp/digests/* 131 | if-no-files-found: error 132 | retention-days: 1 133 | 134 | merge: 135 | if: ${{ github.event.workflow_run.conclusion == 'success' || github.event_name == 'workflow_dispatch' || github.event_name == 'schedule' || github.event_name == 'push' }} 136 | runs-on: ubuntu-latest 137 | needs: 138 | - prepare-matrix 139 | - build-image 140 | steps: 141 | - name: Set up Docker Buildx 142 | uses: docker/setup-buildx-action@v3 143 | 144 | - name: Docker meta 145 | id: meta 146 | uses: docker/metadata-action@v5 147 | with: 148 | images: ${{ env.REGISTRY_IMAGE }} 149 | tags: | 150 | type=sha 151 | type=raw,value=latest 152 | type=ref,event=tag 153 | 154 | - name: Login to Docker Hub 155 | uses: docker/login-action@v3 156 | with: 157 | username: ${{ secrets.DOCKER_USERNAME }} 158 | password: ${{ secrets.DOCKER_PASSWORD }} 159 | 160 | # need add more platform when change the matrix 161 | - name: Download digests for amd64 162 | uses: actions/download-artifact@v4 163 | with: 164 | name: digests-amd64 165 | path: /tmp/digests/amd64 166 | 167 | - name: Download digests for arm64 168 | uses: actions/download-artifact@v4 169 | with: 170 | name: digests-arm64 171 | path: /tmp/digests/arm64 172 | 173 | - name: Create manifest list and push 174 | working-directory: /tmp/digests 175 | run: | 176 | echo "Creating manifest list..." 177 | # debug 178 | ls -la /tmp/digests 179 | ls -la /tmp/digests/amd64 || echo "amd64 digest not found" 180 | ls -la /tmp/digests/arm64 || echo "arm64 digest not found" 181 | 182 | AMD64_DIGEST=$(ls amd64/ 2>/dev/null || echo "") 183 | ARM64_DIGEST=$(ls arm64/ 2>/dev/null || echo "") 184 | 185 | echo "AMD64_DIGEST: $AMD64_DIGEST" 186 | echo "ARM64_DIGEST: $ARM64_DIGEST" 187 | 188 | if [ -z "$AMD64_DIGEST" ] || [ -z "$ARM64_DIGEST" ]; then 189 | echo "Error: One or more digests are missing" 190 | exit 1 191 | fi 192 | 193 | docker buildx imagetools create \ 194 | --tag ${{ env.REGISTRY_IMAGE }}:latest \ 195 | ${{ env.REGISTRY_IMAGE }}@sha256:$AMD64_DIGEST \ 196 | ${{ env.REGISTRY_IMAGE }}@sha256:$ARM64_DIGEST 197 | 198 | - name: Inspect image 199 | run: | 200 | docker buildx imagetools inspect ${{ env.REGISTRY_IMAGE }}:${{ steps.meta.outputs.version || 'latest' }} 201 | -------------------------------------------------------------------------------- /.github/workflows/ci.yaml: -------------------------------------------------------------------------------- 1 | name: check-code 2 | 3 | on: 4 | push: 5 | branches: 6 | - master 7 | paths: 8 | - "cmd/**" 9 | - "internal/**" 10 | - "pkg/**" 11 | pull_request: 12 | branches: 13 | - master 14 | paths: 15 | - "cmd/**" 16 | - "internal/**" 17 | - "pkg/**" 18 | - "go.mod" 19 | jobs: 20 | test: 21 | runs-on: ubuntu-latest 22 | steps: 23 | - name: Set up Go 24 | uses: actions/setup-go@v5 25 | with: 26 | go-version: "1.21" 27 | id: go 28 | 29 | - name: Check out code into the Go module directory 30 | uses: actions/checkout@v4 31 | 32 | - uses: actions/cache@v4 33 | with: 34 | path: | 35 | ~/.cache/go-build 36 | ~/go/pkg/mod 37 | key: ${{ runner.os }}-go-${{ hashFiles('go.sum') }} 38 | restore-keys: | 39 | ${{ runner.os }}-go- 40 | 41 | - name: tidy 42 | run: make tidy 43 | 44 | - name: lint 45 | run: make lint 46 | 47 | - name: test 48 | run: make test 49 | 50 | - name: build 51 | run: make build 52 | -------------------------------------------------------------------------------- /.github/workflows/issue.yaml: -------------------------------------------------------------------------------- 1 | name: "Close stale issues and PRs" 2 | on: 3 | # schedule: 4 | # - cron: "30 1 * * *" 5 | workflow_dispatch: 6 | jobs: 7 | stale: 8 | runs-on: ubuntu-latest 9 | steps: 10 | - uses: actions/stale@v9 11 | with: 12 | exempt-all-pr-assignees: true 13 | -------------------------------------------------------------------------------- /.github/workflows/nightly.yaml: -------------------------------------------------------------------------------- 1 | name: nightly-release 2 | 3 | on: 4 | schedule: 5 | # 每天 UTC 时间 00:00 自动触发构建 6 | - cron: "0 0 * * *" 7 | workflow_dispatch: 8 | 9 | jobs: 10 | create-nightly-release: 11 | runs-on: ubuntu-latest 12 | steps: 13 | - name: Check out code 14 | uses: actions/checkout@v4 15 | with: 16 | fetch-depth: 0 # 确保获取所有历史和标签 17 | 18 | - name: Configure Git 19 | run: | 20 | git config user.name "GitHub Actions" 21 | git config user.email "actions@github.com" 22 | 23 | - name: Get latest version and create nightly tag 24 | id: get_version 25 | run: | 26 | # 获取最新的非 nightly 版本标签 27 | latest_version=$(git describe --tags --abbrev=0 --exclude="*-next") 28 | 29 | # 增加补丁版本号 30 | nightly_version=$(echo $latest_version | awk -F. '{$NF = $NF + 1;} 1' | sed 's/ /./g') 31 | 32 | # 创建 nightly 标签 33 | nightly_tag="${nightly_version}-next" 34 | echo "NIGHTLY_TAG=${nightly_tag}" >> $GITHUB_OUTPUT 35 | 36 | # 删除远程的旧 nightly tag(如果存在) 37 | git push origin :refs/tags/*-next || true 38 | 39 | # 删除本地的旧 nightly tag(如果存在) 40 | git tag -d $(git tag -l '*-next') || true 41 | 42 | # 创建新的 nightly tag 43 | git tag $nightly_tag 44 | 45 | # 强制推送新的 nightly tag 46 | git push origin $nightly_tag --force 47 | 48 | - name: Delete Old GitHub Release 49 | env: 50 | GH_TOKEN: ${{ secrets.GITHUB_TOKEN }} 51 | NIGHTLY_TAG: ${{ steps.get_version.outputs.NIGHTLY_TAG }} 52 | run: | 53 | gh release delete $NIGHTLY_TAG --yes || true 54 | 55 | - name: Set up Go 56 | uses: actions/setup-go@v5 57 | with: 58 | go-version: "1.21" 59 | 60 | - name: Get dependencies 61 | run: go mod download 62 | 63 | - name: GoReleaser Action 64 | uses: goreleaser/goreleaser-action@v5 65 | with: 66 | version: v1.26.2 67 | args: release --clean 68 | env: 69 | GITHUB_TOKEN: ${{ secrets.GORELEASER_GITHUB_TOKEN }} 70 | GORELEASER_CURRENT_TAG: ${{ steps.get_version.outputs.NIGHTLY_TAG }} 71 | 72 | -------------------------------------------------------------------------------- /.github/workflows/release.yml: -------------------------------------------------------------------------------- 1 | name: release-go-bin-on-tag 2 | 3 | on: 4 | push: 5 | tags: 6 | - "*" 7 | workflow_dispatch: 8 | 9 | jobs: 10 | build-go-bin: 11 | runs-on: ubuntu-latest 12 | steps: 13 | - name: Set up Go 14 | uses: actions/setup-go@v5 15 | with: 16 | go-version: "1.21" 17 | id: go 18 | 19 | - name: Check out code into the Go module directory 20 | uses: actions/checkout@v4 21 | 22 | - name: Get dependencies 23 | run: go mod download 24 | 25 | - name: GoReleaser Action 26 | uses: goreleaser/goreleaser-action@v5 27 | with: 28 | version: latest 29 | args: release --clean 30 | env: 31 | GITHUB_TOKEN: ${{ secrets.GORELEASER_GITHUB_TOKEN }} 32 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | # Binaries for programs and plugins 2 | *.exe 3 | *.exe~ 4 | *.dll 5 | *.so 6 | *.dylib 7 | 8 | # Test binary, build with `go test -c` 9 | *.test 10 | 11 | # Output of the go coverage tool, specifically when used with LiteIDE 12 | *.out 13 | 14 | dst/ 15 | dist/ 16 | .dist 17 | .idea/ 18 | 19 | # tls 20 | cert.pem 21 | key.pem 22 | .DS_Store 23 | monitor/data/ 24 | main 25 | ehco 26 | 27 | cmd/test/ 28 | localdev/ 29 | 30 | .vscode/settings.json 31 | .zed/ 32 | -------------------------------------------------------------------------------- /.goreleaser.yml: -------------------------------------------------------------------------------- 1 | # Make sure to check the documentation at http://goreleaser.com 2 | before: 3 | hooks: 4 | - go mod download 5 | builds: 6 | - id: ehco 7 | main: ./cmd/ehco/main.go 8 | flags: 9 | - -trimpath 10 | tags: 11 | - nofibrechannel 12 | - nomountstats 13 | ldflags: 14 | - -w -s 15 | - -X github.com/Ehco1996/ehco/internal/constant.GitBranch={{.Branch}} 16 | - -X github.com/Ehco1996/ehco/internal/constant.GitRevision={{.ShortCommit}} 17 | - -X github.com/Ehco1996/ehco/internal/constant.BuildTime={{.Date}} 18 | - -X github.com/Ehco1996/ehco/internal/constant.Version={{.Version}} 19 | goarch: 20 | - amd64 21 | - arm64 22 | goarm: 23 | - 7 24 | goos: 25 | - linux 26 | binary: "ehco_{{ .Os }}_{{ .Arch }}" 27 | archives: 28 | - format: binary 29 | name_template: "{{ .Binary }}" 30 | changelog: 31 | sort: asc 32 | filters: 33 | exclude: 34 | - "^docs:" 35 | - "^test:" 36 | upx: 37 | - enabled: true 38 | compress: 9 39 | 40 | release: 41 | prerelease: auto 42 | -------------------------------------------------------------------------------- /.prettierrc: -------------------------------------------------------------------------------- 1 | { 2 | "printWidth": 140, 3 | "singleQuote": true, 4 | "semi": true, 5 | "useTabs": false 6 | } -------------------------------------------------------------------------------- /Makefile: -------------------------------------------------------------------------------- 1 | .PHONY: tools lint fmt test build tidy release 2 | 3 | NAME=ehco 4 | BINDIR=dist 5 | 6 | PACKAGE=github.com/Ehco1996/ehco/internal/constant 7 | BUILDTIME=$(shell date +"%Y-%m-%d-%T") 8 | BRANCH=$(shell git rev-parse --abbrev-ref HEAD | tr -d '\040\011\012\015\n') 9 | REVISION=$(shell git rev-parse HEAD) 10 | 11 | 12 | PACKAGE_LIST := go list ./... 13 | FILES := $(shell find . -name "*.go" -type f) 14 | FAIL_ON_STDOUT := awk '{ print } END { if (NR > 0) { exit 1 } }' 15 | 16 | BUILD_TAG_FOR_NODE_EXPORTER="nofibrechannel,nomountstats" 17 | # -w -s 参数的解释:You will get the smallest binaries if you compile with -ldflags '-w -s'. The -w turns off DWARF debugging information 18 | # for more information, please refer to https://stackoverflow.com/questions/22267189/what-does-the-w-flag-mean-when-passed-in-via-the-ldflags-option-to-the-go-comman 19 | GOBUILD=CGO_ENABLED=0 go build -tags ${BUILD_TAG_FOR_NODE_EXPORTER} -trimpath -ldflags="-w -s -X ${PACKAGE}.GitBranch=${BRANCH} -X ${PACKAGE}.GitRevision=${REVISION} -X ${PACKAGE}.BuildTime=${BUILDTIME}" 20 | 21 | 22 | tools: 23 | @echo "run setup tools" 24 | make -C tools setup-tools 25 | 26 | lint: tools 27 | @echo "run lint" 28 | tools/bin/golangci-lint run 29 | 30 | fmt: tools 31 | @echo "golangci-lint run --fix" 32 | @tools/bin/golangci-lint run --fix 33 | @echo "gofmt (simplify)" 34 | @tools/bin/gofumpt -l -w $(FILES) 2>&1 | $(FAIL_ON_STDOUT) 35 | 36 | test: 37 | go test -tags ${BUILD_TAG_FOR_NODE_EXPORTER} -v -count=1 -timeout=1m ./... 38 | 39 | build: 40 | ${GOBUILD} -o $(BINDIR)/$(NAME) cmd/ehco/main.go 41 | 42 | build-arm: 43 | GOARCH=arm GOOS=linux ${GOBUILD} -o $(BINDIR)/$(NAME) cmd/ehco/main.go 44 | 45 | build-linux-amd64: 46 | GOARCH=amd64 GOOS=linux ${GOBUILD} -o $(BINDIR)/$(NAME)_amd64 cmd/ehco/main.go 47 | 48 | tidy: 49 | go mod tidy 50 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # ehco is a network relay tool and a typo :) 2 | 3 | [![Go Report Card](https://goreportcard.com/badge/github.com/Ehco1996/ehco)](https://goreportcard.com/report/github.com/Ehco1996/ehco) 4 | [![go.dev reference](https://img.shields.io/badge/go.dev-reference-007d9c?logo=go&logoColor=white&style=flat-square)](https://pkg.go.dev/github.com/Ehco1996/ehco) 5 | [![Docker Pulls](https://img.shields.io/docker/pulls/ehco1996/ehco)](https://hub.docker.com/r/ehco1996/ehco) 6 | 7 | ## Ehco Relay - 让流量转发更简单 8 | 9 | ehco 现在提供 SaaS(软件即服务)版本!这是一个全托管的解决方案,旨在为那些希望在不搭建和管理自己的服务器的情况下享受 ehco 强大流量转发能力的用户提供服务。 10 | 11 | - [Ehco Relay 官方网站](https://ehco-relay.cc) 12 | - [Ehco Relay 文档](https://docs.ehco-relay.cc/) 13 | 14 | ## 主要功能 15 | 16 | - tcp/udp relay 17 | - tunnel relay (ws/wss/) 18 | - [更多功能请探索文档](https://docs.ehco-relay.cc/) 19 | -------------------------------------------------------------------------------- /build/Dockerfile: -------------------------------------------------------------------------------- 1 | FROM debian:12-slim 2 | 3 | RUN apt update && apt install -y --no-install-recommends ca-certificates curl glibc-source libc6 4 | 5 | WORKDIR /bin/ 6 | 7 | # Copy the pre-built binary file from the previous stage 8 | COPY ehco . 9 | RUN chmod +x ehco 10 | 11 | ENTRYPOINT ["ehco"] 12 | -------------------------------------------------------------------------------- /cf-workers/ws/.editorconfig: -------------------------------------------------------------------------------- 1 | # http://editorconfig.org 2 | root = true 3 | 4 | [*] 5 | indent_style = tab 6 | end_of_line = lf 7 | charset = utf-8 8 | trim_trailing_whitespace = true 9 | insert_final_newline = true 10 | 11 | [*.yml] 12 | indent_style = space 13 | -------------------------------------------------------------------------------- /cf-workers/ws/.gitignore: -------------------------------------------------------------------------------- 1 | # Logs 2 | 3 | logs 4 | _.log 5 | npm-debug.log_ 6 | yarn-debug.log* 7 | yarn-error.log* 8 | lerna-debug.log* 9 | .pnpm-debug.log* 10 | 11 | # Diagnostic reports (https://nodejs.org/api/report.html) 12 | 13 | report.[0-9]_.[0-9]_.[0-9]_.[0-9]_.json 14 | 15 | # Runtime data 16 | 17 | pids 18 | _.pid 19 | _.seed 20 | \*.pid.lock 21 | 22 | # Directory for instrumented libs generated by jscoverage/JSCover 23 | 24 | lib-cov 25 | 26 | # Coverage directory used by tools like istanbul 27 | 28 | coverage 29 | \*.lcov 30 | 31 | # nyc test coverage 32 | 33 | .nyc_output 34 | 35 | # Grunt intermediate storage (https://gruntjs.com/creating-plugins#storing-task-files) 36 | 37 | .grunt 38 | 39 | # Bower dependency directory (https://bower.io/) 40 | 41 | bower_components 42 | 43 | # node-waf configuration 44 | 45 | .lock-wscript 46 | 47 | # Compiled binary addons (https://nodejs.org/api/addons.html) 48 | 49 | build/Release 50 | 51 | # Dependency directories 52 | 53 | node_modules/ 54 | jspm_packages/ 55 | 56 | # Snowpack dependency directory (https://snowpack.dev/) 57 | 58 | web_modules/ 59 | 60 | # TypeScript cache 61 | 62 | \*.tsbuildinfo 63 | 64 | # Optional npm cache directory 65 | 66 | .npm 67 | 68 | # Optional eslint cache 69 | 70 | .eslintcache 71 | 72 | # Optional stylelint cache 73 | 74 | .stylelintcache 75 | 76 | # Microbundle cache 77 | 78 | .rpt2_cache/ 79 | .rts2_cache_cjs/ 80 | .rts2_cache_es/ 81 | .rts2_cache_umd/ 82 | 83 | # Optional REPL history 84 | 85 | .node_repl_history 86 | 87 | # Output of 'npm pack' 88 | 89 | \*.tgz 90 | 91 | # Yarn Integrity file 92 | 93 | .yarn-integrity 94 | 95 | # dotenv environment variable files 96 | 97 | .env 98 | .env.development.local 99 | .env.test.local 100 | .env.production.local 101 | .env.local 102 | 103 | # parcel-bundler cache (https://parceljs.org/) 104 | 105 | .cache 106 | .parcel-cache 107 | 108 | # Next.js build output 109 | 110 | .next 111 | out 112 | 113 | # Nuxt.js build / generate output 114 | 115 | .nuxt 116 | dist 117 | 118 | # Gatsby files 119 | 120 | .cache/ 121 | 122 | # Comment in the public line in if your project uses Gatsby and not Next.js 123 | 124 | # https://nextjs.org/blog/next-9-1#public-directory-support 125 | 126 | # public 127 | 128 | # vuepress build output 129 | 130 | .vuepress/dist 131 | 132 | # vuepress v2.x temp and cache directory 133 | 134 | .temp 135 | .cache 136 | 137 | # Docusaurus cache and generated files 138 | 139 | .docusaurus 140 | 141 | # Serverless directories 142 | 143 | .serverless/ 144 | 145 | # FuseBox cache 146 | 147 | .fusebox/ 148 | 149 | # DynamoDB Local files 150 | 151 | .dynamodb/ 152 | 153 | # TernJS port file 154 | 155 | .tern-port 156 | 157 | # Stores VSCode versions used for testing VSCode extensions 158 | 159 | .vscode-test 160 | 161 | # yarn v2 162 | 163 | .yarn/cache 164 | .yarn/unplugged 165 | .yarn/build-state.yml 166 | .yarn/install-state.gz 167 | .pnp.\* 168 | 169 | # wrangler project 170 | 171 | .dev.vars 172 | .wrangler/ 173 | -------------------------------------------------------------------------------- /cf-workers/ws/package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "ws", 3 | "version": "0.0.0", 4 | "private": true, 5 | "scripts": { 6 | "deploy": "wrangler deploy", 7 | "dev": "wrangler dev", 8 | "start": "wrangler dev", 9 | "test": "vitest", 10 | "cf-typegen": "wrangler types" 11 | }, 12 | "devDependencies": { 13 | "@cloudflare/vitest-pool-workers": "^0.1.0", 14 | "@cloudflare/workers-types": "^4.20240603.0", 15 | "typescript": "^5.0.4", 16 | "vitest": "1.3.0", 17 | "wrangler": "^3.0.0" 18 | } 19 | } 20 | -------------------------------------------------------------------------------- /cf-workers/ws/src/index.ts: -------------------------------------------------------------------------------- 1 | /** 2 | * Welcome to Cloudflare Workers! This is your first worker. 3 | * 4 | * - Run `npm run dev` in your terminal to start a development server 5 | * - Open a browser tab at http://localhost:8787/ to see your worker in action 6 | * - Run `npm run deploy` to publish your worker 7 | * 8 | * Bind resources to your worker in `wrangler.toml`. After adding bindings, a type definition for the 9 | * `Env` object can be regenerated with `npm run cf-typegen`. 10 | * 11 | * Learn more at https://developers.cloudflare.com/workers/ 12 | */ 13 | import { connect } from 'cloudflare:sockets'; 14 | 15 | async function handleRequest(request) { 16 | const upgradeHeader = request.headers.get('Upgrade'); 17 | if (!upgradeHeader || upgradeHeader !== 'websocket') { 18 | return new Response('Expected Upgrade: websocket', { status: 426 }); 19 | } 20 | 21 | const url = new URL(request.url); 22 | const queryParams = url.searchParams; 23 | const remoteAddr = queryParams.get('remote_addr'); 24 | 25 | const webSocketPair = new WebSocketPair(); 26 | const [client, server] = Object.values(webSocketPair); 27 | server.accept(); 28 | 29 | const tcpSocket = connect(remoteAddr); 30 | 31 | function closeAll() { 32 | client.close(); 33 | server.close(); 34 | tcpSocket.close(); 35 | } 36 | 37 | const readableStream = new ReadableStream({ 38 | start(controller) { 39 | server.addEventListener('message', (event) => { 40 | controller.enqueue(event.data); 41 | }); 42 | server.addEventListener('close', () => { 43 | controller.close(); 44 | closeAll(); 45 | }); 46 | server.addEventListener('error', (err) => { 47 | controller.error(err); 48 | closeAll(); 49 | }); 50 | }, 51 | }); 52 | 53 | const writableStream = new WritableStream({ 54 | write(chunk) { 55 | server.send(chunk); 56 | }, 57 | close() { 58 | closeAll(); 59 | }, 60 | abort(err) { 61 | console.error('Stream error:', err); 62 | closeAll(); 63 | }, 64 | }); 65 | 66 | readableStream 67 | .pipeTo(tcpSocket.writable) 68 | .then(() => console.log('All data successfully written!')) 69 | .catch((e) => { 70 | console.error('Something went wrong on read!', e.message); 71 | closeAll(); 72 | }); 73 | 74 | tcpSocket.readable 75 | .pipeTo(writableStream) 76 | .then(() => console.log('All data successfully written!')) 77 | .catch((e) => { 78 | console.error('Something went wrong on write!', e.message); 79 | closeAll(); 80 | }); 81 | 82 | return new Response(null, { 83 | status: 101, 84 | webSocket: client, 85 | }); 86 | } 87 | 88 | export default { 89 | async fetch(request: Request, env: Env, ctx: ExecutionContext): Promise { 90 | return handleRequest(request); 91 | }, 92 | }; 93 | -------------------------------------------------------------------------------- /cf-workers/ws/vitest.config.ts: -------------------------------------------------------------------------------- 1 | import { defineWorkersConfig } from "@cloudflare/vitest-pool-workers/config"; 2 | 3 | export default defineWorkersConfig({ 4 | test: { 5 | poolOptions: { 6 | workers: { 7 | wrangler: { configPath: "./wrangler.toml" }, 8 | }, 9 | }, 10 | }, 11 | }); 12 | -------------------------------------------------------------------------------- /cf-workers/ws/worker-configuration.d.ts: -------------------------------------------------------------------------------- 1 | // Generated by Wrangler 2 | // After adding bindings to `wrangler.toml`, regenerate this interface via `npm run cf-typegen` 3 | interface Env { 4 | } 5 | -------------------------------------------------------------------------------- /cf-workers/ws/wrangler.toml: -------------------------------------------------------------------------------- 1 | #:schema node_modules/wrangler/config-schema.json 2 | name = "ws" 3 | main = "src/index.ts" 4 | compatibility_date = "2024-06-03" 5 | compatibility_flags = ["nodejs_compat", "fetch_legacy_url"] 6 | 7 | # Automatically place your workloads in an optimal location to minimize latency. 8 | # If you are running back-end logic in a Worker, running it closer to your back-end infrastructure 9 | # rather than the end user may result in better performance. 10 | # Docs: https://developers.cloudflare.com/workers/configuration/smart-placement/#smart-placement 11 | # [placement] 12 | # mode = "smart" 13 | 14 | # Variable bindings. These are arbitrary, plaintext strings (similar to environment variables) 15 | # Docs: 16 | # - https://developers.cloudflare.com/workers/wrangler/configuration/#environment-variables 17 | # Note: Use secrets to store sensitive data. 18 | # - https://developers.cloudflare.com/workers/configuration/secrets/ 19 | # [vars] 20 | # MY_VARIABLE = "production_value" 21 | 22 | # Bind the Workers AI model catalog. Run machine learning models, powered by serverless GPUs, on Cloudflare’s global network 23 | # Docs: https://developers.cloudflare.com/workers/wrangler/configuration/#workers-ai 24 | # [ai] 25 | # binding = "AI" 26 | 27 | # Bind an Analytics Engine dataset. Use Analytics Engine to write analytics within your Pages Function. 28 | # Docs: https://developers.cloudflare.com/workers/wrangler/configuration/#analytics-engine-datasets 29 | # [[analytics_engine_datasets]] 30 | # binding = "MY_DATASET" 31 | 32 | # Bind a headless browser instance running on Cloudflare's global network. 33 | # Docs: https://developers.cloudflare.com/workers/wrangler/configuration/#browser-rendering 34 | # [browser] 35 | # binding = "MY_BROWSER" 36 | 37 | # Bind a D1 database. D1 is Cloudflare’s native serverless SQL database. 38 | # Docs: https://developers.cloudflare.com/workers/wrangler/configuration/#d1-databases 39 | # [[d1_databases]] 40 | # binding = "MY_DB" 41 | # database_name = "my-database" 42 | # database_id = "xxxxxxxx-xxxx-xxxx-xxxx-xxxxxxxxxxxx" 43 | 44 | # Bind a dispatch namespace. Use Workers for Platforms to deploy serverless functions programmatically on behalf of your customers. 45 | # Docs: https://developers.cloudflare.com/workers/wrangler/configuration/#dispatch-namespace-bindings-workers-for-platforms 46 | # [[dispatch_namespaces]] 47 | # binding = "MY_DISPATCHER" 48 | # namespace = "my-namespace" 49 | 50 | # Bind a Durable Object. Durable objects are a scale-to-zero compute primitive based on the actor model. 51 | # Durable Objects can live for as long as needed. Use these when you need a long-running "server", such as in realtime apps. 52 | # Docs: https://developers.cloudflare.com/workers/wrangler/configuration/#durable-objects 53 | # [[durable_objects.bindings]] 54 | # name = "MY_DURABLE_OBJECT" 55 | # class_name = "MyDurableObject" 56 | 57 | # Durable Object migrations. 58 | # Docs: https://developers.cloudflare.com/workers/wrangler/configuration/#migrations 59 | # [[migrations]] 60 | # tag = "v1" 61 | # new_classes = ["MyDurableObject"] 62 | 63 | # Bind a Hyperdrive configuration. Use to accelerate access to your existing databases from Cloudflare Workers. 64 | # Docs: https://developers.cloudflare.com/workers/wrangler/configuration/#hyperdrive 65 | # [[hyperdrive]] 66 | # binding = "MY_HYPERDRIVE" 67 | # id = "xxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx" 68 | 69 | # Bind a KV Namespace. Use KV as persistent storage for small key-value pairs. 70 | # Docs: https://developers.cloudflare.com/workers/wrangler/configuration/#kv-namespaces 71 | # [[kv_namespaces]] 72 | # binding = "MY_KV_NAMESPACE" 73 | # id = "xxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx" 74 | 75 | # Bind an mTLS certificate. Use to present a client certificate when communicating with another service. 76 | # Docs: https://developers.cloudflare.com/workers/wrangler/configuration/#mtls-certificates 77 | # [[mtls_certificates]] 78 | # binding = "MY_CERTIFICATE" 79 | # certificate_id = "xxxxxxxx-xxxx-xxxx-xxxx-xxxxxxxxxxxx" 80 | 81 | # Bind a Queue producer. Use this binding to schedule an arbitrary task that may be processed later by a Queue consumer. 82 | # Docs: https://developers.cloudflare.com/workers/wrangler/configuration/#queues 83 | # [[queues.producers]] 84 | # binding = "MY_QUEUE" 85 | # queue = "my-queue" 86 | 87 | # Bind a Queue consumer. Queue Consumers can retrieve tasks scheduled by Producers to act on them. 88 | # Docs: https://developers.cloudflare.com/workers/wrangler/configuration/#queues 89 | # [[queues.consumers]] 90 | # queue = "my-queue" 91 | 92 | # Bind an R2 Bucket. Use R2 to store arbitrarily large blobs of data, such as files. 93 | # Docs: https://developers.cloudflare.com/workers/wrangler/configuration/#r2-buckets 94 | # [[r2_buckets]] 95 | # binding = "MY_BUCKET" 96 | # bucket_name = "my-bucket" 97 | 98 | # Bind another Worker service. Use this binding to call another Worker without network overhead. 99 | # Docs: https://developers.cloudflare.com/workers/wrangler/configuration/#service-bindings 100 | # [[services]] 101 | # binding = "MY_SERVICE" 102 | # service = "my-service" 103 | 104 | # Bind a Vectorize index. Use to store and query vector embeddings for semantic search, classification and other vector search use-cases. 105 | # Docs: https://developers.cloudflare.com/workers/wrangler/configuration/#vectorize-indexes 106 | # [[vectorize]] 107 | # binding = "MY_INDEX" 108 | # index_name = "my-index" 109 | -------------------------------------------------------------------------------- /cmd/ehco/main.go: -------------------------------------------------------------------------------- 1 | package main 2 | 3 | import ( 4 | "os" 5 | "time" 6 | 7 | "github.com/Ehco1996/ehco/internal/cli" 8 | sentry "github.com/getsentry/sentry-go" 9 | ) 10 | 11 | func main() { 12 | defer func() { 13 | err := recover() 14 | if err != nil { 15 | sentry.CurrentHub().Recover(err) 16 | sentry.Flush(time.Second * 5) 17 | } 18 | }() 19 | app := cli.CreateCliAPP() 20 | if err := app.Run(os.Args); err != nil { 21 | println("Run app meet err=", err.Error()) 22 | } 23 | } 24 | -------------------------------------------------------------------------------- /examples/config.json: -------------------------------------------------------------------------------- 1 | { 2 | "web_port": 9000, 3 | "enable_ping": false, 4 | "log_level": "info", 5 | "reload_interval": 60, 6 | "relay_configs": [ 7 | { 8 | "listen": "127.0.0.1:1234", 9 | "listen_type": "raw", 10 | "transport_type": "raw", 11 | "label": "relay1", 12 | "remotes": [ 13 | "0.0.0.0:5201" 14 | ], 15 | "options": { 16 | "enable_udp": true 17 | } 18 | }, 19 | { 20 | "listen": "127.0.0.1:1235", 21 | "listen_type": "raw", 22 | "transport_type": "ws", 23 | "remotes": [ 24 | "ws://0.0.0.0:2443" 25 | ] 26 | }, 27 | { 28 | "listen": "127.0.0.1:1236", 29 | "listen_type": "raw", 30 | "transport_type": "wss", 31 | "remotes": [ 32 | "wss://0.0.0.0:3443" 33 | ] 34 | }, 35 | { 36 | "listen": "127.0.0.1:2443", 37 | "listen_type": "ws", 38 | "transport_type": "raw", 39 | "remotes": [ 40 | "0.0.0.0:5201" 41 | ] 42 | }, 43 | { 44 | "listen": "127.0.0.1:3443", 45 | "listen_type": "wss", 46 | "transport_type": "raw", 47 | "remotes": [ 48 | "0.0.0.0:5201" 49 | ] 50 | } 51 | ] 52 | } -------------------------------------------------------------------------------- /examples/xray_ss.json: -------------------------------------------------------------------------------- 1 | { 2 | "web_port": 9000, 3 | "web_token": "", 4 | "enable_ping": false, 5 | "reload_interval": 10, 6 | "log_level": "debug", 7 | "relay_configs": [ 8 | { 9 | "listen": "127.0.0.1:1234", 10 | "listen_type": "raw", 11 | "transport_type": "raw", 12 | "tcp_remotes": [ 13 | "0.0.0.0:5201" 14 | ], 15 | "udp_remotes": [ 16 | "0.0.0.0:5201" 17 | ] 18 | } 19 | ], 20 | "sync_traffic_endpoint": "http://127.0.0.1:8000/api/proxy_configs/1/?token=youowntoken", 21 | "xray_config": { 22 | "stats": {}, 23 | "api": { 24 | "tag": "api", 25 | "services": [ 26 | "StatsService", 27 | "HandlerService" 28 | ] 29 | }, 30 | "log": { 31 | "loglevel": "error" 32 | }, 33 | "policy": { 34 | "levels": { 35 | "0": { 36 | "statsUserUplink": true, 37 | "statsUserDownlink": true 38 | } 39 | }, 40 | "system": { 41 | "statsInboundUplink": true, 42 | "statsInboundDownlink": true, 43 | "statsOutboundUplink": true, 44 | "statsOutboundDownlink": true 45 | } 46 | }, 47 | "inbounds": [ 48 | { 49 | "listen": "127.0.0.1", 50 | "port": 8080, 51 | "protocol": "dokodemo-door", 52 | "settings": { 53 | "address": "127.0.0.1" 54 | }, 55 | "tag": "api" 56 | }, 57 | { 58 | "listen": "127.0.0.1", 59 | "port": 12345, 60 | "protocol": "shadowsocks", 61 | "tag": "ss_proxy", 62 | "settings": { 63 | "clients": [], 64 | "network": "tcp,udp" 65 | } 66 | }, 67 | { 68 | "port": 10801, 69 | "protocol": "socks", 70 | "settings": { 71 | "udp": true 72 | } 73 | }, 74 | { 75 | "port": 10802, 76 | "protocol": "http" 77 | } 78 | ], 79 | "outbounds": [ 80 | { 81 | "protocol": "freedom", 82 | "settings": {} 83 | } 84 | ], 85 | "routing": { 86 | "settings": { 87 | "rules": [ 88 | { 89 | "type": "field", 90 | "inboundTag": [ 91 | "api" 92 | ], 93 | "outboundTag": "api" 94 | } 95 | ] 96 | } 97 | } 98 | } 99 | } -------------------------------------------------------------------------------- /examples/xray_trojan.json: -------------------------------------------------------------------------------- 1 | { 2 | "reload_interval": 1, 3 | "xray_config": { 4 | "stats": {}, 5 | "api": { 6 | "tag": "api", 7 | "services": [ 8 | "StatsService", 9 | "HandlerService" 10 | ] 11 | }, 12 | "log": { 13 | "loglevel": "debug" 14 | }, 15 | "policy": { 16 | "levels": { 17 | "0": { 18 | "statsUserUplink": true, 19 | "statsUserDownlink": true 20 | } 21 | }, 22 | "system": { 23 | "statsInboundUplink": true, 24 | "statsInboundDownlink": true, 25 | "statsOutboundUplink": true, 26 | "statsOutboundDownlink": true 27 | } 28 | }, 29 | "inbounds": [ 30 | { 31 | "listen": "127.0.0.1", 32 | "port": 4443, 33 | "protocol": "trojan", 34 | "tag": "trojan_proxy", 35 | "settings": { 36 | "clients": [ 37 | { 38 | "password": "123456xx", 39 | "email": "1.com" 40 | } 41 | ], 42 | "network": "tcp,udp", 43 | "fallbacks": [ 44 | { 45 | "dest": "127.0.0.1:1234" 46 | } 47 | ] 48 | }, 49 | "streamSettings": { 50 | "network": "tcp", 51 | "security": "tls", 52 | "tlsSettings": { 53 | "alpn": [ 54 | "h2", 55 | "http/1.1" 56 | ] 57 | } 58 | } 59 | } 60 | ], 61 | "outbounds": [ 62 | { 63 | "protocol": "freedom", 64 | "settings": {} 65 | } 66 | ] 67 | } 68 | } -------------------------------------------------------------------------------- /internal/cli/app.go: -------------------------------------------------------------------------------- 1 | package cli 2 | 3 | import ( 4 | "fmt" 5 | "os/signal" 6 | "syscall" 7 | 8 | "github.com/Ehco1996/ehco/internal/constant" 9 | "github.com/Ehco1996/ehco/pkg/log" 10 | cli "github.com/urfave/cli/v2" 11 | ) 12 | 13 | var cliLogger = log.MustNewLogger("info").Sugar().Named("cli") 14 | 15 | func startAction(ctx *cli.Context) error { 16 | cfg, err := InitConfigAndComponents() 17 | if err != nil { 18 | cliLogger.Fatalf("InitConfigAndComponents meet err=%s", err.Error()) 19 | } 20 | 21 | mainCtx, stop := signal.NotifyContext(ctx.Context, syscall.SIGINT, syscall.SIGTERM) 22 | defer stop() 23 | 24 | MustStartComponents(mainCtx, cfg) 25 | 26 | <-mainCtx.Done() 27 | 28 | cliLogger.Info("ehco exit now...") 29 | return nil 30 | } 31 | 32 | func CreateCliAPP() *cli.App { 33 | cli.VersionPrinter = func(c *cli.Context) { 34 | println("Welcome to ehco (ehco is a network relay tool and a typo)") 35 | println(fmt.Sprintf("Version=%s", constant.Version)) 36 | println(fmt.Sprintf("GitBranch=%s", constant.GitBranch)) 37 | println(fmt.Sprintf("GitRevision=%s", constant.GitRevision)) 38 | println(fmt.Sprintf("BuildTime=%s", constant.BuildTime)) 39 | } 40 | app := cli.NewApp() 41 | app.Name = "ehco" 42 | app.Flags = RootFlags 43 | app.Version = constant.Version 44 | app.Commands = []*cli.Command{InstallCMD} 45 | app.Usage = "ehco is a network relay tool and a typo :)" 46 | app.Action = startAction 47 | return app 48 | } 49 | -------------------------------------------------------------------------------- /internal/cli/cmd.go: -------------------------------------------------------------------------------- 1 | package cli 2 | 3 | import ( 4 | "fmt" 5 | "os" 6 | "os/exec" 7 | 8 | cli "github.com/urfave/cli/v2" 9 | ) 10 | 11 | const SystemDTMPL = `# Ehco service 12 | [Unit] 13 | Description=ehco 14 | After=network.target 15 | 16 | [Service] 17 | LimitNOFILE=65535 18 | ExecStart=ehco -c "" 19 | Restart=always 20 | 21 | [Install] 22 | WantedBy=multi-user.target 23 | ` 24 | 25 | var InstallCMD = &cli.Command{ 26 | Name: "install", 27 | Usage: "install ehco systemd service", 28 | Action: func(c *cli.Context) error { 29 | fmt.Printf("Install ehco systemd file to `%s`\n", SystemFilePath) 30 | if _, err := os.Stat(SystemFilePath); err != nil && os.IsNotExist(err) { 31 | f, _ := os.OpenFile(SystemFilePath, os.O_CREATE|os.O_WRONLY, 0o644) 32 | if _, err := f.WriteString(SystemDTMPL); err != nil { 33 | cliLogger.Fatal(err) 34 | } 35 | return f.Close() 36 | } 37 | command := exec.Command("vi", SystemFilePath) 38 | command.Stdin = os.Stdin 39 | command.Stdout = os.Stdout 40 | return command.Run() 41 | }, 42 | } 43 | -------------------------------------------------------------------------------- /internal/cli/config.go: -------------------------------------------------------------------------------- 1 | package cli 2 | 3 | import ( 4 | "context" 5 | "os" 6 | 7 | "github.com/Ehco1996/ehco/internal/config" 8 | "github.com/Ehco1996/ehco/internal/constant" 9 | "github.com/Ehco1996/ehco/internal/metrics" 10 | "github.com/Ehco1996/ehco/internal/relay" 11 | "github.com/Ehco1996/ehco/internal/relay/conf" 12 | "github.com/Ehco1996/ehco/internal/web" 13 | "github.com/Ehco1996/ehco/pkg/buffer" 14 | "github.com/Ehco1996/ehco/pkg/log" 15 | "github.com/Ehco1996/ehco/pkg/xray" 16 | "github.com/getsentry/sentry-go" 17 | ) 18 | 19 | func loadConfig() (cfg *config.Config, err error) { 20 | if ConfigPath != "" { 21 | cfg = config.NewConfig(ConfigPath) 22 | if err := cfg.LoadConfig(true); err != nil { 23 | return nil, err 24 | } 25 | } else { 26 | cfg = &config.Config{ 27 | WebPort: WebPort, 28 | WebToken: WebToken, 29 | EnablePing: EnablePing, 30 | PATH: ConfigPath, 31 | LogLeveL: LogLevel, 32 | ReloadInterval: ConfigReloadInterval, 33 | RelayConfigs: []*conf.Config{ 34 | { 35 | Listen: LocalAddr, 36 | ListenType: ListenType, 37 | TransportType: TransportType, 38 | }, 39 | }, 40 | } 41 | if RemoteAddr != "" { 42 | cfg.RelayConfigs[0].Remotes = []string{RemoteAddr} 43 | } 44 | if err := cfg.Adjust(); err != nil { 45 | return nil, err 46 | } 47 | } 48 | 49 | return cfg, nil 50 | } 51 | 52 | func initSentry() error { 53 | if dsn := os.Getenv("SENTRY_DSN"); dsn != "" { 54 | cliLogger.Infof("init sentry with dsn:%s", dsn) 55 | return sentry.Init(sentry.ClientOptions{Dsn: dsn}) 56 | } 57 | return nil 58 | } 59 | 60 | func initLogger(cfg *config.Config) error { 61 | if err := log.InitGlobalLogger(cfg.LogLeveL); err != nil { 62 | return err 63 | } 64 | return nil 65 | } 66 | 67 | func initGlobalBufferPool() { 68 | if BufferSize > 0 { 69 | buffer.ReplaceBufferPool(BufferSize) 70 | } 71 | } 72 | 73 | func InitConfigAndComponents() (*config.Config, error) { 74 | cfg, err := loadConfig() 75 | if err != nil { 76 | return nil, err 77 | } 78 | if err := initLogger(cfg); err != nil { 79 | return nil, err 80 | } 81 | if err := initSentry(); err != nil { 82 | return nil, err 83 | } 84 | initGlobalBufferPool() 85 | return cfg, nil 86 | } 87 | 88 | func MustStartComponents(mainCtx context.Context, cfg *config.Config) { 89 | cliLogger.Infof("Start ehco with version:%s", constant.Version) 90 | 91 | // start relay server 92 | rs, err := relay.NewServer(cfg) 93 | if err != nil { 94 | cliLogger.Fatalf("NewRelayServer meet err=%s", err.Error()) 95 | } 96 | go func() { 97 | metrics.EhcoAlive.Set(metrics.EhcoAliveStateRunning) 98 | sErr := rs.Start(mainCtx) 99 | if sErr != nil { 100 | cliLogger.Fatalf("StartRelayServer meet err=%s", sErr.Error()) 101 | } 102 | }() 103 | 104 | if cfg.NeedStartWebServer() { 105 | webS, err := web.NewServer(cfg, rs, rs, rs.Cmgr) 106 | if err != nil { 107 | cliLogger.Fatalf("NewWebServer meet err=%s", err.Error()) 108 | } 109 | go func() { 110 | cliLogger.Fatalf("StartWebServer meet err=%s", webS.Start()) 111 | }() 112 | } 113 | 114 | if cfg.NeedStartXrayServer() { 115 | xrayS := xray.NewXrayServer(cfg) 116 | if err := xrayS.Setup(); err != nil { 117 | cliLogger.Fatalf("Setup XrayServer meet err=%v", err) 118 | } 119 | if err := xrayS.Start(mainCtx); err != nil { 120 | cliLogger.Fatalf("Start XrayServer meet err=%v", err) 121 | } 122 | } 123 | } 124 | -------------------------------------------------------------------------------- /internal/cli/flags.go: -------------------------------------------------------------------------------- 1 | package cli 2 | 3 | import ( 4 | "github.com/Ehco1996/ehco/internal/constant" 5 | 6 | cli "github.com/urfave/cli/v2" 7 | ) 8 | 9 | var ( 10 | LocalAddr string 11 | ListenType constant.RelayType 12 | RemoteAddr string 13 | TransportType constant.RelayType 14 | ConfigPath string 15 | WebPort int 16 | WebToken string 17 | EnablePing bool 18 | SystemFilePath = "/etc/systemd/system/ehco.service" 19 | LogLevel string 20 | ConfigReloadInterval int 21 | BufferSize int 22 | ) 23 | 24 | var RootFlags = []cli.Flag{ 25 | &cli.StringFlag{ 26 | Name: "l,local", 27 | Usage: "监听地址,例如 0.0.0.0:1234", 28 | EnvVars: []string{"EHCO_LOCAL_ADDR"}, 29 | Destination: &LocalAddr, 30 | }, 31 | &cli.StringFlag{ 32 | Name: "lt,listen_type", 33 | Value: "raw", 34 | Usage: "监听类型,可选项有 raw,ws,wss", 35 | EnvVars: []string{"EHCO_LISTEN_TYPE"}, 36 | Destination: (*string)(&ListenType), 37 | Required: false, 38 | }, 39 | &cli.StringFlag{ 40 | Name: "r,remote", 41 | Usage: "转发地址,例如 0.0.0.0:5201,通过 ws 隧道转发时应为 ws://0.0.0.0:2443", 42 | EnvVars: []string{"EHCO_REMOTE_ADDR"}, 43 | Destination: &RemoteAddr, 44 | }, 45 | &cli.StringFlag{ 46 | Name: "tt,transport_type", 47 | Value: "raw", 48 | Usage: "传输类型,可选选有 raw,ws,wss", 49 | EnvVars: []string{"EHCO_TRANSPORT_TYPE"}, 50 | Destination: (*string)(&TransportType), 51 | }, 52 | &cli.StringFlag{ 53 | Name: "c,config", 54 | Usage: "配置文件地址,支持文件类型或 http api", 55 | EnvVars: []string{"EHCO_CONFIG_FILE"}, 56 | Destination: &ConfigPath, 57 | }, 58 | &cli.IntFlag{ 59 | Name: "web_port", 60 | Usage: "prometheus web exporter 的监听端口", 61 | EnvVars: []string{"EHCO_WEB_PORT"}, 62 | Value: 0, 63 | Destination: &WebPort, 64 | }, 65 | &cli.BoolFlag{ 66 | Name: "enable_ping", 67 | Usage: "是否打开 ping metrics", 68 | EnvVars: []string{"EHCO_ENABLE_PING"}, 69 | Value: true, 70 | Destination: &EnablePing, 71 | }, 72 | &cli.StringFlag{ 73 | Name: "web_token", 74 | Usage: "如果访问webui时不带着正确的token,会直接reset连接", 75 | EnvVars: []string{"EHCO_WEB_TOKEN"}, 76 | Destination: &WebToken, 77 | }, 78 | &cli.StringFlag{ 79 | Name: "log_level", 80 | Usage: "log level", 81 | EnvVars: []string{"EHCO_LOG_LEVEL"}, 82 | Destination: &LogLevel, 83 | DefaultText: "info", 84 | }, 85 | &cli.IntFlag{ 86 | Name: "config_reload_interval", 87 | Usage: "config reload interval", 88 | EnvVars: []string{"EHCO_CONFIG_RELOAD_INTERVAL"}, 89 | Destination: &ConfigReloadInterval, 90 | DefaultText: "60", 91 | }, 92 | &cli.IntFlag{ 93 | Name: "buffer_size", 94 | Usage: "set buffer size to when transport data default 20 * 1024(20KB)", 95 | EnvVars: []string{"EHCO_BUFFER_SIZE"}, 96 | Destination: &BufferSize, 97 | }, 98 | } 99 | -------------------------------------------------------------------------------- /internal/cmgr/cmgr.go: -------------------------------------------------------------------------------- 1 | package cmgr 2 | 3 | import ( 4 | "context" 5 | "os" 6 | "path/filepath" 7 | "sort" 8 | "sync" 9 | "time" 10 | 11 | "github.com/Ehco1996/ehco/internal/cmgr/ms" 12 | "github.com/Ehco1996/ehco/internal/conn" 13 | "github.com/Ehco1996/ehco/pkg/metric_reader" 14 | "go.uber.org/zap" 15 | ) 16 | 17 | const ( 18 | ConnectionTypeActive = "active" 19 | ConnectionTypeClosed = "closed" 20 | ) 21 | 22 | // connection manager interface/ 23 | // TODO support closed connection 24 | type Cmgr interface { 25 | ListConnections(connType string, page, pageSize int) []conn.RelayConn 26 | 27 | // AddConnection adds a connection to the connection manager. 28 | AddConnection(conn conn.RelayConn) 29 | 30 | // RemoveConnection removes a connection from the connection manager. 31 | RemoveConnection(conn conn.RelayConn) 32 | 33 | // CountConnection returns the number of active connections. 34 | CountConnection(connType string) int 35 | 36 | GetActiveConnectCntByRelayLabel(label string) int 37 | 38 | // Start starts the connection manager. 39 | Start(ctx context.Context, errCH chan error) 40 | 41 | // Metrics related 42 | QueryNodeMetrics(ctx context.Context, req *ms.QueryNodeMetricsReq, refresh bool) (*ms.QueryNodeMetricsResp, error) 43 | QueryRuleMetrics(ctx context.Context, req *ms.QueryRuleMetricsReq, refresh bool) (*ms.QueryRuleMetricsResp, error) 44 | } 45 | 46 | type cmgrImpl struct { 47 | lock sync.RWMutex 48 | cfg *Config 49 | l *zap.SugaredLogger 50 | 51 | // k: relay label, v: connection list 52 | activeConnectionsMap map[string][]conn.RelayConn 53 | closedConnectionsMap map[string][]conn.RelayConn 54 | 55 | ms *ms.MetricsStore 56 | mr metric_reader.Reader 57 | } 58 | 59 | func NewCmgr(cfg *Config) (Cmgr, error) { 60 | cmgr := &cmgrImpl{ 61 | cfg: cfg, 62 | l: zap.S().Named("cmgr"), 63 | activeConnectionsMap: make(map[string][]conn.RelayConn), 64 | closedConnectionsMap: make(map[string][]conn.RelayConn), 65 | } 66 | if cfg.NeedMetrics() { 67 | cmgr.mr = metric_reader.NewReader(cfg.MetricsURL) 68 | 69 | homeDir, _ := os.UserHomeDir() 70 | dbPath := filepath.Join(homeDir, ".ehco", "metrics.db") 71 | ms, err := ms.NewMetricsStore(dbPath) 72 | if err != nil { 73 | return nil, err 74 | } 75 | cmgr.ms = ms 76 | } 77 | return cmgr, nil 78 | } 79 | 80 | func (cm *cmgrImpl) ListConnections(connType string, page, pageSize int) []conn.RelayConn { 81 | cm.lock.RLock() 82 | defer cm.lock.RUnlock() 83 | 84 | var total int 85 | var m map[string][]conn.RelayConn 86 | 87 | if connType == ConnectionTypeActive { 88 | total = cm.countActiveConnection() 89 | m = cm.activeConnectionsMap 90 | } else { 91 | total = cm.countClosedConnection() 92 | m = cm.closedConnectionsMap 93 | 94 | } 95 | 96 | start := (page - 1) * pageSize 97 | if start > total { 98 | return []conn.RelayConn{} // Return empty slice if start index is more than length 99 | } 100 | end := start + pageSize 101 | if end > total { 102 | end = total 103 | } 104 | relayLabelList := make([]string, 0, len(m)) 105 | for k := range m { 106 | relayLabelList = append(relayLabelList, k) 107 | } 108 | // Sort the relay label list to make the result more predictable 109 | sort.Strings(relayLabelList) 110 | 111 | var conns []conn.RelayConn 112 | for _, label := range relayLabelList { 113 | conns = append(conns, m[label]...) 114 | } 115 | if end > len(conns) { 116 | end = len(conns) // Don't let the end index be more than slice length 117 | } 118 | return conns[start:end] 119 | } 120 | 121 | func (cm *cmgrImpl) AddConnection(c conn.RelayConn) { 122 | cm.lock.Lock() 123 | defer cm.lock.Unlock() 124 | label := c.GetRelayLabel() 125 | 126 | if _, ok := cm.activeConnectionsMap[label]; !ok { 127 | cm.activeConnectionsMap[label] = []conn.RelayConn{} 128 | } 129 | cm.activeConnectionsMap[label] = append(cm.activeConnectionsMap[label], c) 130 | } 131 | 132 | func (cm *cmgrImpl) RemoveConnection(c conn.RelayConn) { 133 | cm.lock.Lock() 134 | defer cm.lock.Unlock() 135 | 136 | label := c.GetRelayLabel() 137 | connections, ok := cm.activeConnectionsMap[label] 138 | if !ok { 139 | return // If the label doesn't exist, nothing to remove 140 | } 141 | 142 | // Find and remove the connection from activeConnectionsMap 143 | for i, activeConn := range connections { 144 | if activeConn == c { 145 | cm.activeConnectionsMap[label] = append(connections[:i], connections[i+1:]...) 146 | break 147 | } 148 | } 149 | // Add to closedConnectionsMap 150 | cm.closedConnectionsMap[label] = append(cm.closedConnectionsMap[label], c) 151 | } 152 | 153 | func (cm *cmgrImpl) CountConnection(connType string) int { 154 | if connType == ConnectionTypeActive { 155 | return cm.countActiveConnection() 156 | } else { 157 | return cm.countClosedConnection() 158 | } 159 | } 160 | 161 | func (cm *cmgrImpl) countActiveConnection() int { 162 | cm.lock.RLock() 163 | defer cm.lock.RUnlock() 164 | cnt := 0 165 | for _, v := range cm.activeConnectionsMap { 166 | cnt += len(v) 167 | } 168 | return cnt 169 | } 170 | 171 | func (cm *cmgrImpl) countClosedConnection() int { 172 | cm.lock.RLock() 173 | defer cm.lock.RUnlock() 174 | cnt := 0 175 | for _, v := range cm.closedConnectionsMap { 176 | cnt += len(v) 177 | } 178 | return cnt 179 | } 180 | 181 | func (cm *cmgrImpl) GetActiveConnectCntByRelayLabel(label string) int { 182 | cm.lock.RLock() 183 | defer cm.lock.RUnlock() 184 | return len(cm.activeConnectionsMap[label]) 185 | } 186 | 187 | func (cm *cmgrImpl) Start(ctx context.Context, errCH chan error) { 188 | cm.l.Infof("Start Cmgr sync interval=%d", cm.cfg.SyncInterval) 189 | ticker := time.NewTicker(time.Second * time.Duration(cm.cfg.SyncInterval)) 190 | defer ticker.Stop() 191 | for { 192 | select { 193 | case <-ctx.Done(): 194 | cm.l.Info("sync stop") 195 | return 196 | case <-ticker.C: 197 | if err := cm.syncOnce(ctx); err != nil { 198 | cm.l.Errorf("meet non retry error: %s ,exit now", err) 199 | errCH <- err 200 | } 201 | } 202 | } 203 | } 204 | 205 | func (cm *cmgrImpl) QueryNodeMetrics(ctx context.Context, req *ms.QueryNodeMetricsReq, refresh bool) (*ms.QueryNodeMetricsResp, error) { 206 | if refresh { 207 | nm, _, err := cm.mr.ReadOnce(ctx) 208 | if err != nil { 209 | return nil, err 210 | } 211 | if err := cm.ms.AddNodeMetric(ctx, nm); err != nil { 212 | return nil, err 213 | } 214 | } 215 | return cm.ms.QueryNodeMetric(ctx, req) 216 | } 217 | 218 | func (cm *cmgrImpl) QueryRuleMetrics(ctx context.Context, req *ms.QueryRuleMetricsReq, refresh bool) (*ms.QueryRuleMetricsResp, error) { 219 | if refresh { 220 | _, rm, err := cm.mr.ReadOnce(ctx) 221 | if err != nil { 222 | return nil, err 223 | } 224 | for _, m := range rm { 225 | if err := cm.ms.AddRuleMetric(ctx, m); err != nil { 226 | return nil, err 227 | } 228 | } 229 | } 230 | return cm.ms.QueryRuleMetric(ctx, req) 231 | } 232 | -------------------------------------------------------------------------------- /internal/cmgr/config.go: -------------------------------------------------------------------------------- 1 | package cmgr 2 | 3 | type Config struct { 4 | SyncURL string 5 | MetricsURL string 6 | SyncInterval int // in seconds 7 | } 8 | 9 | func (c *Config) NeedSync() bool { 10 | return c.SyncURL != "" && c.SyncInterval > 0 11 | } 12 | 13 | func (c *Config) NeedMetrics() bool { 14 | return c.MetricsURL != "" && c.SyncInterval > 0 15 | } 16 | 17 | func (c *Config) Adjust() { 18 | if c.SyncInterval <= 0 { 19 | c.SyncInterval = 60 20 | } 21 | } 22 | -------------------------------------------------------------------------------- /internal/cmgr/ms/handler.go: -------------------------------------------------------------------------------- 1 | package ms 2 | 3 | import ( 4 | "context" 5 | 6 | "github.com/Ehco1996/ehco/pkg/metric_reader" 7 | ) 8 | 9 | type NodeMetrics struct { 10 | Timestamp int64 `json:"timestamp"` 11 | 12 | CPUUsage float64 `json:"cpu_usage"` 13 | MemoryUsage float64 `json:"memory_usage"` 14 | DiskUsage float64 `json:"disk_usage"` 15 | NetworkIn float64 `json:"network_in"` // bytes per second 16 | NetworkOut float64 `json:"network_out"` // bytes per second 17 | } 18 | 19 | type QueryNodeMetricsReq struct { 20 | StartTimestamp int64 21 | EndTimestamp int64 22 | Num int64 23 | } 24 | 25 | type QueryNodeMetricsResp struct { 26 | TOTAL int `json:"total"` 27 | Data []NodeMetrics `json:"data"` 28 | } 29 | 30 | type RuleMetricsData struct { 31 | Timestamp int64 `json:"timestamp"` 32 | Label string `json:"label"` 33 | Remote string `json:"remote"` 34 | PingLatency int64 `json:"ping_latency"` 35 | TCPConnectionCount int64 `json:"tcp_connection_count"` 36 | TCPHandshakeDuration int64 `json:"tcp_handshake_duration"` 37 | TCPNetworkTransmitBytes int64 `json:"tcp_network_transmit_bytes"` 38 | UDPConnectionCount int64 `json:"udp_connection_count"` 39 | UDPHandshakeDuration int64 `json:"udp_handshake_duration"` 40 | UDPNetworkTransmitBytes int64 `json:"udp_network_transmit_bytes"` 41 | } 42 | 43 | type QueryRuleMetricsReq struct { 44 | RuleLabel string 45 | Remote string 46 | 47 | StartTimestamp int64 48 | EndTimestamp int64 49 | Num int64 50 | } 51 | 52 | type QueryRuleMetricsResp struct { 53 | TOTAL int `json:"total"` 54 | Data []RuleMetricsData `json:"data"` 55 | } 56 | 57 | func (ms *MetricsStore) AddNodeMetric(ctx context.Context, m *metric_reader.NodeMetrics) error { 58 | _, err := ms.db.ExecContext(ctx, ` 59 | INSERT OR REPLACE INTO node_metrics (timestamp, cpu_usage, memory_usage, disk_usage, network_in, network_out) 60 | VALUES (?, ?, ?, ?, ?, ?) 61 | `, m.SyncTime.Unix(), m.CpuUsagePercent, m.MemoryUsagePercent, m.DiskUsagePercent, m.NetworkReceiveBytesRate, m.NetworkTransmitBytesRate) 62 | return err 63 | } 64 | 65 | func (ms *MetricsStore) AddRuleMetric(ctx context.Context, rm *metric_reader.RuleMetrics) error { 66 | tx, err := ms.db.BeginTx(ctx, nil) 67 | if err != nil { 68 | return err 69 | } 70 | defer tx.Rollback() //nolint:errcheck 71 | 72 | stmt, err := tx.PrepareContext(ctx, ` 73 | INSERT OR REPLACE INTO rule_metrics 74 | (timestamp, label, remote, ping_latency, 75 | tcp_connection_count, tcp_handshake_duration, tcp_network_transmit_bytes, 76 | udp_connection_count, udp_handshake_duration, udp_network_transmit_bytes) 77 | VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?) 78 | `) 79 | if err != nil { 80 | return err 81 | } 82 | defer stmt.Close() //nolint:errcheck 83 | 84 | for remote, pingMetric := range rm.PingMetrics { 85 | _, err := stmt.ExecContext(ctx, rm.SyncTime.Unix(), rm.Label, remote, pingMetric.Latency, 86 | rm.TCPConnectionCount[remote], rm.TCPHandShakeDuration[remote], rm.TCPNetworkTransmitBytes[remote], 87 | rm.UDPConnectionCount[remote], rm.UDPHandShakeDuration[remote], rm.UDPNetworkTransmitBytes[remote]) 88 | if err != nil { 89 | return err 90 | } 91 | } 92 | 93 | return tx.Commit() 94 | } 95 | 96 | func (ms *MetricsStore) QueryNodeMetric(ctx context.Context, req *QueryNodeMetricsReq) (*QueryNodeMetricsResp, error) { 97 | rows, err := ms.db.QueryContext(ctx, ` 98 | SELECT timestamp, cpu_usage, memory_usage, disk_usage, network_in, network_out 99 | FROM node_metrics 100 | WHERE timestamp >= ? AND timestamp <= ? 101 | ORDER BY timestamp DESC 102 | LIMIT ? 103 | `, req.StartTimestamp, req.EndTimestamp, req.Num) 104 | if err != nil { 105 | return nil, err 106 | } 107 | defer rows.Close() //nolint:errcheck 108 | 109 | var resp QueryNodeMetricsResp 110 | for rows.Next() { 111 | var m NodeMetrics 112 | if err := rows.Scan(&m.Timestamp, &m.CPUUsage, &m.MemoryUsage, &m.DiskUsage, &m.NetworkIn, &m.NetworkOut); err != nil { 113 | return nil, err 114 | } 115 | resp.Data = append(resp.Data, m) 116 | } 117 | resp.TOTAL = len(resp.Data) 118 | return &resp, nil 119 | } 120 | 121 | func (ms *MetricsStore) QueryRuleMetric(ctx context.Context, req *QueryRuleMetricsReq) (*QueryRuleMetricsResp, error) { 122 | query := ` 123 | SELECT timestamp, label, remote, ping_latency, 124 | tcp_connection_count, tcp_handshake_duration, tcp_network_transmit_bytes, 125 | udp_connection_count, udp_handshake_duration, udp_network_transmit_bytes 126 | FROM rule_metrics 127 | WHERE timestamp >= ? AND timestamp <= ? 128 | ` 129 | args := []interface{}{req.StartTimestamp, req.EndTimestamp} 130 | 131 | if req.RuleLabel != "" { 132 | query += " AND label = ?" 133 | args = append(args, req.RuleLabel) 134 | } 135 | if req.Remote != "" { 136 | query += " AND remote = ?" 137 | args = append(args, req.Remote) 138 | } 139 | 140 | query += ` 141 | ORDER BY timestamp DESC 142 | LIMIT ? 143 | ` 144 | args = append(args, req.Num) 145 | 146 | rows, err := ms.db.Query(query, args...) 147 | if err != nil { 148 | return nil, err 149 | } 150 | defer rows.Close() //nolint:errcheck 151 | var resp QueryRuleMetricsResp 152 | for rows.Next() { 153 | var m RuleMetricsData 154 | if err := rows.Scan(&m.Timestamp, &m.Label, &m.Remote, &m.PingLatency, 155 | &m.TCPConnectionCount, &m.TCPHandshakeDuration, &m.TCPNetworkTransmitBytes, 156 | &m.UDPConnectionCount, &m.UDPHandshakeDuration, &m.UDPNetworkTransmitBytes); err != nil { 157 | return nil, err 158 | } 159 | resp.Data = append(resp.Data, m) 160 | } 161 | resp.TOTAL = len(resp.Data) 162 | return &resp, nil 163 | } 164 | -------------------------------------------------------------------------------- /internal/cmgr/ms/ms.go: -------------------------------------------------------------------------------- 1 | package ms 2 | 3 | import ( 4 | "database/sql" 5 | "os" 6 | "path/filepath" 7 | "time" 8 | 9 | "go.uber.org/zap" 10 | _ "modernc.org/sqlite" 11 | ) 12 | 13 | type MetricsStore struct { 14 | db *sql.DB 15 | dbPath string 16 | 17 | l *zap.SugaredLogger 18 | } 19 | 20 | func NewMetricsStore(dbPath string) (*MetricsStore, error) { 21 | // ensure the directory exists 22 | dirPath := filepath.Dir(dbPath) 23 | if err := os.MkdirAll(dirPath, 0o755); err != nil { 24 | return nil, err 25 | } 26 | // create db file if not exists 27 | if _, err := os.Stat(dbPath); os.IsNotExist(err) { 28 | f, err := os.Create(dbPath) 29 | if err != nil { 30 | return nil, err 31 | } 32 | if err := f.Close(); err != nil { 33 | return nil, err 34 | } 35 | } 36 | 37 | db, err := sql.Open("sqlite", dbPath) 38 | if err != nil { 39 | return nil, err 40 | } 41 | ms := &MetricsStore{dbPath: dbPath, db: db, l: zap.S().Named("ms")} 42 | if err := ms.initDB(); err != nil { 43 | return nil, err 44 | } 45 | if err := ms.cleanOldData(); err != nil { 46 | return nil, err 47 | } 48 | return ms, nil 49 | } 50 | 51 | func (ms *MetricsStore) cleanOldData() error { 52 | thirtyDaysAgo := time.Now().AddDate(0, 0, -30).Unix() 53 | 54 | // 清理 node_metrics 表 55 | _, err := ms.db.Exec("DELETE FROM node_metrics WHERE timestamp < ?", thirtyDaysAgo) 56 | if err != nil { 57 | return err 58 | } 59 | 60 | // 清理 rule_metrics 表 61 | _, err = ms.db.Exec("DELETE FROM rule_metrics WHERE timestamp < ?", thirtyDaysAgo) 62 | if err != nil { 63 | return err 64 | } 65 | 66 | ms.l.Infof("Cleaned data older than 30 days") 67 | return nil 68 | } 69 | 70 | func (ms *MetricsStore) initDB() error { 71 | // init NodeMetrics table 72 | if _, err := ms.db.Exec(` 73 | CREATE TABLE IF NOT EXISTS node_metrics ( 74 | timestamp INTEGER, 75 | cpu_usage REAL, 76 | memory_usage REAL, 77 | disk_usage REAL, 78 | network_in REAL, 79 | network_out REAL, 80 | PRIMARY KEY (timestamp) 81 | ) 82 | `); err != nil { 83 | return err 84 | } 85 | 86 | // init rule_metrics 87 | if _, err := ms.db.Exec(` 88 | CREATE TABLE IF NOT EXISTS rule_metrics ( 89 | timestamp INTEGER, 90 | label TEXT, 91 | remote TEXT, 92 | ping_latency INTEGER, 93 | tcp_connection_count INTEGER, 94 | tcp_handshake_duration BIGINT, 95 | tcp_network_transmit_bytes BIGINT, 96 | udp_connection_count INTEGER, 97 | udp_handshake_duration BIGINT, 98 | udp_network_transmit_bytes BIGINT, 99 | PRIMARY KEY (timestamp, label, remote) 100 | ) 101 | `); err != nil { 102 | return err 103 | } 104 | return nil 105 | } 106 | -------------------------------------------------------------------------------- /internal/cmgr/syncer.go: -------------------------------------------------------------------------------- 1 | package cmgr 2 | 3 | import ( 4 | "context" 5 | 6 | "github.com/Ehco1996/ehco/internal/conn" 7 | "github.com/Ehco1996/ehco/internal/constant" 8 | myhttp "github.com/Ehco1996/ehco/pkg/http" 9 | "github.com/Ehco1996/ehco/pkg/metric_reader" 10 | "go.uber.org/zap" 11 | ) 12 | 13 | type StatsPerRule struct { 14 | RelayLabel string `json:"relay_label"` 15 | 16 | Up int64 `json:"up_bytes"` 17 | Down int64 `json:"down_bytes"` 18 | ConnectionCnt int `json:"connection_count"` 19 | HandShakeLatency int64 `json:"latency_in_ms"` 20 | } 21 | 22 | type VersionInfo struct { 23 | Version string `json:"version"` 24 | ShortCommit string `json:"short_commit"` 25 | } 26 | 27 | type syncReq struct { 28 | Version VersionInfo `json:"version"` 29 | Node metric_reader.NodeMetrics `json:"node"` 30 | Stats []StatsPerRule `json:"stats"` 31 | } 32 | 33 | func (cm *cmgrImpl) syncOnce(ctx context.Context) error { 34 | cm.l.Infof("sync once total closed connections: %d", cm.countClosedConnection()) 35 | // todo: opt lock 36 | cm.lock.Lock() 37 | 38 | shortCommit := constant.GitRevision 39 | if len(constant.GitRevision) > 7 { 40 | shortCommit = constant.GitRevision[:7] 41 | } 42 | req := syncReq{ 43 | Stats: []StatsPerRule{}, 44 | Version: VersionInfo{Version: constant.Version, ShortCommit: shortCommit}, 45 | } 46 | 47 | if cm.cfg.NeedMetrics() { 48 | nm, rmm, err := cm.mr.ReadOnce(ctx) 49 | if err != nil { 50 | cm.l.Errorf("read metrics failed: %v", err) 51 | } else { 52 | req.Node = *nm 53 | if err := cm.ms.AddNodeMetric(ctx, nm); err != nil { 54 | cm.l.Errorf("add metrics to store failed: %v", err) 55 | } 56 | for _, rm := range rmm { 57 | if err := cm.ms.AddRuleMetric(ctx, rm); err != nil { 58 | cm.l.Errorf("add rule metrics to store failed: %v", err) 59 | } 60 | } 61 | } 62 | } 63 | 64 | for label, conns := range cm.closedConnectionsMap { 65 | s := StatsPerRule{ 66 | RelayLabel: label, 67 | } 68 | var totalLatency int64 69 | for _, c := range conns { 70 | s.ConnectionCnt++ 71 | s.Up += c.GetStats().Up 72 | s.Down += c.GetStats().Down 73 | totalLatency += c.GetStats().HandShakeLatency.Milliseconds() 74 | } 75 | if s.ConnectionCnt > 0 { 76 | s.HandShakeLatency = totalLatency / int64(s.ConnectionCnt) 77 | } 78 | req.Stats = append(req.Stats, s) 79 | } 80 | cm.closedConnectionsMap = make(map[string][]conn.RelayConn) 81 | cm.lock.Unlock() 82 | 83 | if cm.cfg.NeedSync() { 84 | cm.l.Debug("syncing data to server", zap.Any("data", req)) 85 | return myhttp.PostJSONWithRetry(cm.cfg.SyncURL, &req) 86 | } else { 87 | cm.l.Debugf("remove %d closed connections", len(req.Stats)) 88 | } 89 | return nil 90 | } 91 | -------------------------------------------------------------------------------- /internal/config/config.go: -------------------------------------------------------------------------------- 1 | package config 2 | 3 | import ( 4 | "encoding/json" 5 | "fmt" 6 | "os" 7 | "strings" 8 | "time" 9 | 10 | "github.com/Ehco1996/ehco/internal/constant" 11 | "github.com/Ehco1996/ehco/internal/relay/conf" 12 | "github.com/Ehco1996/ehco/internal/tls" 13 | myhttp "github.com/Ehco1996/ehco/pkg/http" 14 | xConf "github.com/xtls/xray-core/infra/conf" 15 | "go.uber.org/zap" 16 | ) 17 | 18 | type Config struct { 19 | PATH string `json:"-"` 20 | 21 | NodeLabel string `json:"node_label,omitempty"` 22 | WebHost string `json:"web_host,omitempty"` 23 | WebPort int `json:"web_port,omitempty"` 24 | WebToken string `json:"web_token,omitempty"` 25 | WebAuthUser string `json:"web_auth_user,omitempty"` 26 | WebAuthPass string `json:"web_auth_pass,omitempty"` 27 | 28 | LogLeveL string `json:"log_level,omitempty"` 29 | EnablePing bool `json:"enable_ping,omitempty"` 30 | ReloadInterval int `json:"reload_interval,omitempty"` 31 | 32 | RelayConfigs []*conf.Config `json:"relay_configs"` 33 | RelaySyncURL string `json:"relay_sync_url,omitempty"` 34 | RelaySyncInterval int `json:"relay_sync_interval,omitempty"` 35 | 36 | XRayConfig *xConf.Config `json:"xray_config,omitempty"` 37 | SyncTrafficEndPoint string `json:"sync_traffic_endpoint,omitempty"` 38 | 39 | lastLoadTime time.Time 40 | l *zap.SugaredLogger 41 | } 42 | 43 | func NewConfig(path string) *Config { 44 | return &Config{PATH: path, l: zap.S().Named("cfg")} 45 | } 46 | 47 | func (c *Config) NeedSyncFromServer() bool { 48 | return strings.Contains(c.PATH, "http") 49 | } 50 | 51 | func (c *Config) LoadConfig(force bool) error { 52 | if c.ReloadInterval > 0 && time.Since(c.lastLoadTime).Seconds() < float64(c.ReloadInterval) && !force { 53 | c.l.Warnf("Skip Load Config, last load time: %s", c.lastLoadTime) 54 | return nil 55 | } 56 | // reset 57 | c.RelayConfigs = nil 58 | c.lastLoadTime = time.Now() 59 | if c.NeedSyncFromServer() { 60 | if err := c.readFromHttp(); err != nil { 61 | return err 62 | } 63 | } else { 64 | if err := c.readFromFile(); err != nil { 65 | return err 66 | } 67 | } 68 | return c.Adjust() 69 | } 70 | 71 | func (c *Config) readFromFile() error { 72 | file, err := os.ReadFile(c.PATH) 73 | if err != nil { 74 | return err 75 | } 76 | c.l.Infof("Load Config From File: %s", c.PATH) 77 | return json.Unmarshal([]byte(file), &c) 78 | } 79 | 80 | func (c *Config) readFromHttp() error { 81 | c.l.Infof("Load Config From HTTP: %s", c.PATH) 82 | return myhttp.GetJSONWithRetry(c.PATH, &c) 83 | } 84 | 85 | func (c *Config) Adjust() error { 86 | if c.LogLeveL == "" { 87 | c.LogLeveL = "info" 88 | } 89 | if c.WebHost == "" { 90 | c.WebHost = "0.0.0.0" 91 | } 92 | 93 | for _, r := range c.RelayConfigs { 94 | if err := r.Validate(); err != nil { 95 | return err 96 | } 97 | } 98 | 99 | // check relay config label is unique 100 | labelMap := make(map[string]struct{}) 101 | for _, r := range c.RelayConfigs { 102 | if _, ok := labelMap[r.Label]; ok { 103 | return fmt.Errorf("relay label %s is not unique", r.Label) 104 | } 105 | labelMap[r.Label] = struct{}{} 106 | } 107 | // init tls when need 108 | for _, r := range c.RelayConfigs { 109 | if r.ListenType == constant.RelayTypeWSS || r.TransportType == constant.RelayTypeWSS { 110 | if err := tls.InitTlsCfg(); err != nil { 111 | return err 112 | } 113 | break 114 | } 115 | } 116 | return nil 117 | } 118 | 119 | func (c *Config) NeedStartWebServer() bool { 120 | return c.WebPort != 0 121 | } 122 | 123 | func (c *Config) NeedStartXrayServer() bool { 124 | return c.XRayConfig != nil 125 | } 126 | 127 | func (c *Config) NeedStartRelayServer() bool { 128 | return len(c.RelayConfigs) > 0 129 | } 130 | 131 | func (c *Config) NeedStartCmgr() bool { 132 | return c.RelaySyncURL != "" && c.RelaySyncInterval > 0 133 | } 134 | 135 | func (c *Config) GetMetricURL() string { 136 | if !c.NeedStartWebServer() { 137 | return "" 138 | } 139 | url := fmt.Sprintf("http://%s:%d/metrics/", c.WebHost, c.WebPort) 140 | if c.WebToken != "" { 141 | url += fmt.Sprintf("?token=%s", c.WebToken) 142 | } 143 | // for basic auth 144 | if c.WebAuthUser != "" && c.WebAuthPass != "" { 145 | url = fmt.Sprintf("http://%s:%s@%s:%d/metrics/", c.WebAuthUser, c.WebAuthPass, c.WebHost, c.WebPort) 146 | } 147 | return url 148 | } 149 | -------------------------------------------------------------------------------- /internal/conn/limit_reader.go: -------------------------------------------------------------------------------- 1 | package conn 2 | 3 | import ( 4 | "io" 5 | "net" 6 | 7 | "github.com/juju/ratelimit" 8 | ) 9 | 10 | type RateLimitedConn struct { 11 | net.Conn 12 | bucket *ratelimit.Bucket 13 | reader io.Reader 14 | } 15 | 16 | func NewRateLimitedConn(conn net.Conn, kbps int64) *RateLimitedConn { 17 | bps := float64(kbps) * 1000 // Convert kbps to bps (1 kbps = 1000 bps) 18 | rateBytesPerSec := bps / 8 // 1KB = 1024B, 1B = 8b 19 | bucket := ratelimit.NewBucketWithRate(rateBytesPerSec, int64(rateBytesPerSec)) 20 | return &RateLimitedConn{ 21 | Conn: conn, 22 | bucket: bucket, 23 | reader: ratelimit.Reader(conn, bucket), 24 | } 25 | } 26 | 27 | func (r *RateLimitedConn) Read(p []byte) (int, error) { 28 | return r.reader.Read(p) 29 | } 30 | -------------------------------------------------------------------------------- /internal/conn/relay_conn_test.go: -------------------------------------------------------------------------------- 1 | package conn 2 | 3 | import ( 4 | "errors" 5 | "io" 6 | "net" 7 | "testing" 8 | "time" 9 | 10 | "github.com/Ehco1996/ehco/internal/lb" 11 | "github.com/Ehco1996/ehco/internal/relay/conf" 12 | "github.com/stretchr/testify/assert" 13 | "go.uber.org/zap" 14 | ) 15 | 16 | func TestInnerConn_ReadWrite(t *testing.T) { 17 | testData := []byte("hello") 18 | testOptions := conf.Options{ 19 | IdleTimeout: time.Second, 20 | ReadTimeout: time.Second, 21 | } 22 | 23 | clientConn, serverConn := net.Pipe() 24 | clientConn.SetDeadline(time.Now().Add(1 * time.Second)) 25 | serverConn.SetDeadline(time.Now().Add(1 * time.Second)) 26 | defer clientConn.Close() 27 | defer serverConn.Close() 28 | rc := relayConnImpl{Stats: &Stats{}, remote: &lb.Node{}, Options: &testOptions} 29 | innerC := newInnerConn(clientConn, &rc) 30 | errChan := make(chan error, 1) 31 | go func() { 32 | _, err := innerC.Write(testData) 33 | errChan <- err 34 | }() 35 | 36 | buf := make([]byte, len(testData)) 37 | n, err := serverConn.Read(buf) 38 | if err != nil { 39 | t.Fatalf("read error: %v", err) 40 | } 41 | assert.Equal(t, n, len(testData)) 42 | assert.Equal(t, testData, buf) 43 | 44 | if err := <-errChan; err != nil { 45 | t.Fatalf("write err: %v", err) 46 | } 47 | assert.Equal(t, int64(len(testData)), rc.Stats.Up) 48 | 49 | errChan = make(chan error, 1) 50 | clientConn.SetDeadline(time.Now().Add(1 * time.Second)) 51 | serverConn.SetDeadline(time.Now().Add(1 * time.Second)) 52 | 53 | go func() { 54 | _, err := serverConn.Write(testData) 55 | errChan <- err // 将错误发送回主流程 56 | }() 57 | 58 | n, err = innerC.Read(buf) 59 | if err != nil { 60 | if errors.Is(err, io.EOF) { 61 | t.Logf("read eof") 62 | } else { 63 | t.Fatalf("read error: %v", err) 64 | } 65 | } 66 | assert.Equal(t, n, len(testData)) 67 | assert.Equal(t, testData, buf) 68 | 69 | if err := <-errChan; err != nil { 70 | t.Fatalf("write error: %v", err) 71 | } 72 | assert.Equal(t, int64(len(testData)), rc.Stats.Down) 73 | } 74 | 75 | func TestCopyTCPConn(t *testing.T) { 76 | // 设置监听端口,模拟外部服务器 77 | echoServer, err := net.Listen("tcp", "127.0.0.1:0") // 0 表示自动选择端口 78 | assert.NoError(t, err) 79 | defer echoServer.Close() 80 | 81 | msg := "Hello, TCP!" 82 | 83 | go func() { 84 | for { 85 | conn, err := echoServer.Accept() 86 | if err != nil { 87 | return 88 | } 89 | go func(c net.Conn) { 90 | defer c.Close() 91 | io.Copy(c, c) 92 | }(conn) 93 | } 94 | }() 95 | 96 | clientConn, err := net.Dial("tcp", echoServer.Addr().String()) 97 | assert.NoError(t, err) 98 | defer clientConn.Close() 99 | 100 | remoteConn, err := net.Dial("tcp", echoServer.Addr().String()) 101 | assert.NoError(t, err) 102 | defer remoteConn.Close() 103 | testOptions := conf.Options{IdleTimeout: time.Second, ReadTimeout: time.Second} 104 | rc := relayConnImpl{Stats: &Stats{}, remote: &lb.Node{}, Options: &testOptions} 105 | c1 := newInnerConn(clientConn, &rc) 106 | c2 := newInnerConn(remoteConn, &rc) 107 | 108 | done := make(chan struct{}) 109 | go func() { 110 | if err := copyConn(c1, c2, zap.S()); err != nil { 111 | t.Log(err) 112 | } 113 | done <- struct{}{} 114 | close(done) 115 | }() 116 | 117 | _, err = clientConn.Write([]byte(msg)) 118 | assert.NoError(t, err) 119 | 120 | buffer := make([]byte, len(msg)) 121 | _, err = clientConn.Read(buffer) 122 | assert.NoError(t, err) 123 | assert.Equal(t, msg, string(buffer)) 124 | // close the connection 125 | _ = clientConn.Close() 126 | _ = remoteConn.Close() 127 | // wait for the copyConn to finish 128 | <-done 129 | } 130 | 131 | func TestCopyUDPConn(t *testing.T) { 132 | // 设置监听地址,模拟外部UDP服务器 133 | serverAddr, err := net.ResolveUDPAddr("udp", "127.0.0.1:0") 134 | assert.NoError(t, err) 135 | 136 | echoServer, err := net.ListenUDP("udp", serverAddr) 137 | assert.NoError(t, err) 138 | defer echoServer.Close() 139 | 140 | msg := "Hello, UDP!" 141 | 142 | go func() { 143 | buffer := make([]byte, 1024) 144 | for { 145 | n, remoteAddr, err := echoServer.ReadFromUDP(buffer) 146 | if err != nil { 147 | return 148 | } 149 | _, err = echoServer.WriteToUDP(buffer[:n], remoteAddr) 150 | if err != nil { 151 | return 152 | } 153 | } 154 | }() 155 | 156 | clientConn, err := net.DialUDP("udp", nil, echoServer.LocalAddr().(*net.UDPAddr)) 157 | assert.NoError(t, err) 158 | defer clientConn.Close() 159 | 160 | remoteConn, err := net.DialUDP("udp", nil, echoServer.LocalAddr().(*net.UDPAddr)) 161 | assert.NoError(t, err) 162 | defer remoteConn.Close() 163 | 164 | testOptions := conf.Options{IdleTimeout: time.Second, ReadTimeout: time.Second} 165 | rc := relayConnImpl{Stats: &Stats{}, remote: &lb.Node{}, Options: &testOptions} 166 | c1 := newInnerConn(clientConn, &rc) 167 | c2 := newInnerConn(remoteConn, &rc) 168 | 169 | done := make(chan struct{}) 170 | go func() { 171 | if err := copyConn(c1, c2, zap.S()); err != nil { 172 | t.Log(err) 173 | } 174 | done <- struct{}{} 175 | close(done) 176 | }() 177 | 178 | _, err = clientConn.Write([]byte(msg)) 179 | assert.NoError(t, err) 180 | 181 | buffer := make([]byte, len(msg)) 182 | n, _, err := clientConn.ReadFromUDP(buffer) 183 | assert.NoError(t, err) 184 | assert.Equal(t, msg, string(buffer[:n])) 185 | 186 | // 关闭连接 187 | _ = clientConn.Close() 188 | _ = remoteConn.Close() 189 | 190 | // 等待 copyConn 完成 191 | <-done 192 | } 193 | -------------------------------------------------------------------------------- /internal/conn/udp_listener.go: -------------------------------------------------------------------------------- 1 | //nolint:errcheck 2 | package conn 3 | 4 | import ( 5 | "context" 6 | "io" 7 | "net" 8 | "sync" 9 | "sync/atomic" 10 | "time" 11 | 12 | "github.com/Ehco1996/ehco/internal/relay/conf" 13 | "github.com/Ehco1996/ehco/pkg/buffer" 14 | ) 15 | 16 | var _ net.Conn = &uc{} 17 | 18 | type uc struct { 19 | conn *net.UDPConn 20 | addr *net.UDPAddr 21 | 22 | msgCh chan []byte 23 | 24 | lastActivity atomic.Value 25 | 26 | listener *UDPListener 27 | } 28 | 29 | func (c *uc) Read(b []byte) (int, error) { 30 | select { 31 | case msg := <-c.msgCh: 32 | n := copy(b, msg) 33 | c.lastActivity.Store(time.Now()) 34 | return n, nil 35 | default: 36 | if time.Since(c.lastActivity.Load().(time.Time)) > c.listener.cfg.Options.IdleTimeout { 37 | return 0, io.EOF 38 | } 39 | return 0, nil 40 | } 41 | } 42 | 43 | func (c *uc) Write(b []byte) (int, error) { 44 | n, err := c.conn.WriteToUDP(b, c.addr) 45 | c.lastActivity.Store(time.Now()) 46 | return n, err 47 | } 48 | 49 | func (c *uc) Close() error { 50 | c.listener.connsMu.Lock() 51 | delete(c.listener.conns, c.addr.String()) 52 | c.listener.connsMu.Unlock() 53 | close(c.msgCh) 54 | return nil 55 | } 56 | 57 | func (c *uc) LocalAddr() net.Addr { 58 | return c.conn.LocalAddr() 59 | } 60 | 61 | func (c *uc) RemoteAddr() net.Addr { 62 | return c.addr 63 | } 64 | 65 | func (c *uc) SetDeadline(t time.Time) error { 66 | return nil 67 | } 68 | 69 | func (c *uc) SetReadDeadline(t time.Time) error { 70 | return nil 71 | } 72 | 73 | func (c *uc) SetWriteDeadline(t time.Time) error { 74 | return nil 75 | } 76 | 77 | type UDPListener struct { 78 | cfg *conf.Config 79 | listenAddr *net.UDPAddr 80 | listenConn *net.UDPConn 81 | 82 | conns map[string]*uc 83 | connsMu sync.RWMutex 84 | connCh chan *uc 85 | msgCh chan []byte 86 | errCh chan error 87 | 88 | ctx context.Context 89 | cancel context.CancelFunc 90 | 91 | closed atomic.Bool 92 | } 93 | 94 | func NewUDPListener(ctx context.Context, cfg *conf.Config) (*UDPListener, error) { 95 | udpAddr, err := net.ResolveUDPAddr("udp", cfg.Listen) 96 | if err != nil { 97 | return nil, err 98 | } 99 | 100 | conn, err := net.ListenUDP("udp", udpAddr) 101 | if err != nil { 102 | return nil, err 103 | } 104 | 105 | ctx, cancel := context.WithCancel(ctx) 106 | 107 | l := &UDPListener{ 108 | cfg: cfg, 109 | listenConn: conn, 110 | listenAddr: udpAddr, 111 | 112 | conns: make(map[string]*uc), 113 | connCh: make(chan *uc), 114 | msgCh: make(chan []byte), 115 | errCh: make(chan error), 116 | ctx: ctx, 117 | cancel: cancel, 118 | } 119 | 120 | go l.listen() 121 | 122 | return l, nil 123 | } 124 | 125 | func (l *UDPListener) listen() { 126 | defer l.listenConn.Close() 127 | for { 128 | if l.closed.Load() { 129 | return 130 | } 131 | 132 | buf := buffer.UDPBufferPool.Get() 133 | n, addr, err := l.listenConn.ReadFromUDP(buf) 134 | if err != nil { 135 | if !l.closed.Load() { 136 | select { 137 | case l.errCh <- err: 138 | default: 139 | } 140 | } 141 | buffer.UDPBufferPool.Put(buf) 142 | continue 143 | } 144 | 145 | l.connsMu.RLock() 146 | udpConn, exists := l.conns[addr.String()] 147 | l.connsMu.RUnlock() 148 | if !exists { 149 | l.connsMu.Lock() 150 | udpConn = &uc{ 151 | conn: l.listenConn, 152 | addr: addr, 153 | listener: l, 154 | msgCh: make(chan []byte, 10), 155 | lastActivity: atomic.Value{}, 156 | } 157 | udpConn.lastActivity.Store(time.Now()) 158 | l.conns[addr.String()] = udpConn 159 | l.connCh <- udpConn 160 | l.connsMu.Unlock() 161 | } 162 | 163 | select { 164 | case udpConn.msgCh <- buf[:n]: 165 | default: 166 | buffer.UDPBufferPool.Put(buf) 167 | } 168 | } 169 | } 170 | 171 | func (l *UDPListener) Accept() (*uc, error) { 172 | select { 173 | case conn := <-l.connCh: 174 | return conn, nil 175 | case err := <-l.errCh: 176 | return nil, err 177 | case <-l.ctx.Done(): 178 | return nil, l.ctx.Err() 179 | } 180 | } 181 | 182 | func (l *UDPListener) Close() error { 183 | if !l.closed.CompareAndSwap(false, true) { 184 | return nil 185 | } 186 | l.cancel() 187 | l.closed.Store(true) 188 | return l.listenConn.Close() 189 | } 190 | -------------------------------------------------------------------------------- /internal/conn/ws_conn.go: -------------------------------------------------------------------------------- 1 | package conn 2 | 3 | import ( 4 | "fmt" 5 | "io" 6 | "net" 7 | "time" 8 | 9 | "github.com/Ehco1996/ehco/pkg/buffer" 10 | "github.com/gobwas/ws" 11 | "github.com/gobwas/ws/wsutil" 12 | "go.uber.org/zap" 13 | ) 14 | 15 | // wsConn represents a WebSocket connection to relay(io.Copy) 16 | type wsConn struct { 17 | conn net.Conn 18 | isServer bool 19 | buf []byte 20 | } 21 | 22 | func NewWSConn(conn net.Conn, isServer bool) *wsConn { 23 | return &wsConn{conn: conn, isServer: isServer, buf: buffer.BufferPool.Get()} 24 | } 25 | 26 | func (c *wsConn) Read(b []byte) (n int, err error) { 27 | header, err := ws.ReadHeader(c.conn) 28 | if err != nil { 29 | return 0, err 30 | } 31 | if header.Length > int64(cap(c.buf)) { 32 | zap.S().Warnf("ws payload size:%d is larger than buffer size:%d", header.Length, cap(c.buf)) 33 | return 0, fmt.Errorf("buffer size:%d too small to transport ws payload size:%d", len(b), header.Length) 34 | } 35 | payload := c.buf[:header.Length] 36 | _, err = io.ReadFull(c.conn, payload) 37 | if err != nil { 38 | return 0, err 39 | } 40 | if header.Masked { 41 | ws.Cipher(payload, header.Mask, 0) 42 | } 43 | if len(payload) > len(b) { 44 | return 0, fmt.Errorf("buffer size:%d too small to transport ws payload size:%d", len(b), len(payload)) 45 | } 46 | copy(b, payload) 47 | return len(payload), nil 48 | } 49 | 50 | func (c *wsConn) Write(b []byte) (n int, err error) { 51 | if c.isServer { 52 | err = wsutil.WriteServerBinary(c.conn, b) 53 | } else { 54 | err = wsutil.WriteClientBinary(c.conn, b) 55 | } 56 | if err != nil { 57 | return 0, err 58 | } 59 | return len(b), nil 60 | } 61 | 62 | func (c *wsConn) Close() error { 63 | defer buffer.BufferPool.Put(c.buf) 64 | return c.conn.Close() 65 | } 66 | 67 | func (c *wsConn) LocalAddr() net.Addr { 68 | return c.conn.LocalAddr() 69 | } 70 | 71 | func (c *wsConn) RemoteAddr() net.Addr { 72 | return c.conn.RemoteAddr() 73 | } 74 | 75 | func (c *wsConn) SetDeadline(t time.Time) error { 76 | return c.conn.SetDeadline(t) 77 | } 78 | 79 | func (c *wsConn) SetReadDeadline(t time.Time) error { 80 | return c.conn.SetReadDeadline(t) 81 | } 82 | 83 | func (c *wsConn) SetWriteDeadline(t time.Time) error { 84 | return c.conn.SetWriteDeadline(t) 85 | } 86 | -------------------------------------------------------------------------------- /internal/conn/ws_conn_test.go: -------------------------------------------------------------------------------- 1 | package conn 2 | 3 | import ( 4 | "context" 5 | "net/http" 6 | "net/http/httptest" 7 | "net/url" 8 | "testing" 9 | 10 | "github.com/gobwas/ws" 11 | "github.com/stretchr/testify/assert" 12 | ) 13 | 14 | func TestClientConn_ReadWrite(t *testing.T) { 15 | data := []byte("hello") 16 | 17 | // Create a WebSocket server 18 | server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { 19 | conn, _, _, err := ws.UpgradeHTTP(r, w) 20 | if err != nil { 21 | http.Error(w, err.Error(), http.StatusInternalServerError) 22 | return 23 | } 24 | go func() { 25 | defer conn.Close() 26 | wsc := NewWSConn(conn, true) 27 | 28 | buf := make([]byte, 1024) 29 | for { 30 | n, err := wsc.Read(buf) 31 | if err != nil { 32 | return 33 | } 34 | assert.Equal(t, len(data), n) 35 | assert.Equal(t, "hello", string(buf[:n])) 36 | _, err = wsc.Write(buf[:n]) 37 | if err != nil { 38 | return 39 | } 40 | } 41 | }() 42 | })) 43 | defer server.Close() 44 | 45 | // Create a WebSocket client 46 | addr, err := url.Parse(server.URL) 47 | if err != nil { 48 | t.Fatal(err) 49 | } 50 | conn, _, _, err := ws.DefaultDialer.Dial(context.TODO(), "ws://"+addr.Host) 51 | if err != nil { 52 | t.Fatal(err) 53 | } 54 | defer conn.Close() 55 | 56 | wsClientConn := NewWSConn(conn, false) 57 | for i := 0; i < 3; i++ { 58 | // test write 59 | n, err := wsClientConn.Write(data) 60 | assert.NoError(t, err, "test cnt %d", i) 61 | assert.Equal(t, len(data), n, "test cnt %d", i) 62 | 63 | // test read 64 | buf := make([]byte, 100) 65 | n, err = wsClientConn.Read(buf) 66 | assert.NoError(t, err, "test cnt %d", i) 67 | assert.Equal(t, len(data), n, "test cnt %d", i) 68 | assert.Equal(t, "hello", string(buf[:n]), "test cnt %d", i) 69 | } 70 | } 71 | -------------------------------------------------------------------------------- /internal/constant/constant.go: -------------------------------------------------------------------------------- 1 | package constant 2 | 3 | import "time" 4 | 5 | type RelayType string 6 | 7 | var ( 8 | Version = "1.1.5" 9 | GitBranch string 10 | GitRevision string 11 | BuildTime string 12 | StartTime = time.Now().Local() 13 | ) 14 | 15 | const ( 16 | DefaultDialTimeOut = 3 * time.Second 17 | DefaultReadTimeOut = 5 * time.Second 18 | DefaultIdleTimeOut = 30 * time.Second 19 | DefaultSniffTimeOut = 300 * time.Millisecond 20 | 21 | // todo,support config in relay config 22 | BUFFER_POOL_SIZE = 1024 // support 512 connections 23 | BUFFER_SIZE = 40 * 1024 // 40KB ,the maximum packet size of shadowsocks is about 16 KiB so this is enough 24 | UDPBufSize = 1500 // use default max mtu 1500 25 | ) 26 | 27 | // relay type 28 | const ( 29 | // direct relay 30 | RelayTypeRaw RelayType = "raw" 31 | // ws relay 32 | RelayTypeWS RelayType = "ws" 33 | RelayTypeWSS RelayType = "wss" 34 | ) 35 | -------------------------------------------------------------------------------- /internal/glue/interface.go: -------------------------------------------------------------------------------- 1 | package glue 2 | 3 | import ( 4 | "context" 5 | ) 6 | 7 | type Reloader interface { 8 | Reload(force bool) error 9 | } 10 | 11 | type HealthChecker interface { 12 | // get relay by ID and check the connection health 13 | HealthCheck(ctx context.Context, RelayID string) (int64, error) 14 | } 15 | -------------------------------------------------------------------------------- /internal/lb/round_robin.go: -------------------------------------------------------------------------------- 1 | package lb 2 | 3 | import ( 4 | "net/url" 5 | "strings" 6 | "time" 7 | 8 | "go.uber.org/atomic" 9 | ) 10 | 11 | type Node struct { 12 | Address string 13 | HandShakeDuration time.Duration 14 | } 15 | 16 | func (n *Node) Clone() *Node { 17 | return &Node{ 18 | Address: n.Address, 19 | HandShakeDuration: n.HandShakeDuration, 20 | } 21 | } 22 | 23 | func extractHost(input string) (string, error) { 24 | // Check if the input string has a scheme, if not, add "http://" 25 | if !strings.Contains(input, "://") { 26 | input = "http://" + input 27 | } 28 | // Parse the URL 29 | u, err := url.Parse(input) 30 | if err != nil { 31 | return "", err 32 | } 33 | return u.Hostname(), nil 34 | } 35 | 36 | // NOTE for (https/ws/wss)://xxx.com -> xxx.com 37 | func (n *Node) GetAddrHost() (string, error) { 38 | return extractHost(n.Address) 39 | } 40 | 41 | // RoundRobin is an interface for representing round-robin balancing. 42 | type RoundRobin interface { 43 | Next() *Node 44 | GetAll() []*Node 45 | } 46 | 47 | type roundrobin struct { 48 | nodeList []*Node 49 | next *atomic.Int64 50 | len int 51 | } 52 | 53 | func NewRoundRobin(nodeList []*Node) RoundRobin { 54 | len := len(nodeList) 55 | next := atomic.NewInt64(0) 56 | return &roundrobin{nodeList: nodeList, len: len, next: next} 57 | } 58 | 59 | func (r *roundrobin) Next() *Node { 60 | n := r.next.Add(1) 61 | next := r.nodeList[(int(n)-1)%r.len] 62 | return next 63 | } 64 | 65 | func (r *roundrobin) GetAll() []*Node { 66 | return r.nodeList 67 | } 68 | -------------------------------------------------------------------------------- /internal/lb/round_robin_test.go: -------------------------------------------------------------------------------- 1 | package lb 2 | 3 | import ( 4 | "testing" 5 | ) 6 | 7 | func Test_roundrobin_Next(t *testing.T) { 8 | remotes := []string{ 9 | "127.0.0.1", 10 | "127.0.0.2", 11 | } 12 | nodeList := make([]*Node, len(remotes)) 13 | for i := range remotes { 14 | nodeList[i] = &Node{Address: remotes[i]} 15 | } 16 | rb := NewRoundRobin(nodeList) 17 | 18 | // normal round robin, should return node one by one 19 | for i := 0; i < len(remotes); i++ { 20 | if node := rb.Next(); node.Address != remotes[i] { 21 | t.Fatalf("need %s got %s", remotes[i], node.Address) 22 | } 23 | } 24 | } 25 | -------------------------------------------------------------------------------- /internal/metrics/metrics.go: -------------------------------------------------------------------------------- 1 | package metrics 2 | 3 | import ( 4 | "os" 5 | "time" 6 | 7 | "github.com/Ehco1996/ehco/internal/config" 8 | "github.com/prometheus/client_golang/prometheus" 9 | ) 10 | 11 | const ( 12 | METRIC_NS = "ehco" 13 | METRIC_SUBSYSTEM_TRAFFIC = "traffic" 14 | METRIC_SUBSYSTEM_PING = "ping" 15 | 16 | METRIC_CONN_TYPE_TCP = "tcp" 17 | METRIC_CONN_TYPE_UDP = "udp" 18 | METRIC_FLOW_READ = "read" 19 | METRIC_FLOW_WRITE = "write" 20 | 21 | EhcoAliveStateInit = 0 22 | EhcoAliveStateRunning = 1 23 | ) 24 | 25 | var ( 26 | Hostname, _ = os.Hostname() 27 | ConstLabels = map[string]string{ 28 | "ehco_runner_hostname": Hostname, 29 | } 30 | 31 | // 1ms ~ 5s (1ms 到 437ms ) 32 | msBuckets = prometheus.ExponentialBuckets(1, 1.5, 16) 33 | ) 34 | 35 | // ping metrics 36 | var ( 37 | pingInterval = time.Second * 30 38 | PingResponseDurationMilliseconds = prometheus.NewHistogramVec( 39 | prometheus.HistogramOpts{ 40 | Namespace: METRIC_NS, 41 | Subsystem: METRIC_SUBSYSTEM_PING, 42 | Name: "response_duration_milliseconds", 43 | Help: "A histogram of latencies for ping responses.", 44 | Buckets: msBuckets, 45 | ConstLabels: ConstLabels, 46 | }, 47 | []string{"label", "remote", "ip"}, 48 | ) 49 | ) 50 | 51 | // traffic metrics 52 | var ( 53 | EhcoAlive = prometheus.NewGauge(prometheus.GaugeOpts{ 54 | Namespace: METRIC_NS, 55 | Subsystem: "", 56 | Name: "alive_state", 57 | Help: "ehco 存活状态", 58 | ConstLabels: ConstLabels, 59 | }) 60 | 61 | CurConnectionCount = prometheus.NewGaugeVec(prometheus.GaugeOpts{ 62 | Namespace: METRIC_NS, 63 | Subsystem: METRIC_SUBSYSTEM_TRAFFIC, 64 | Name: "current_connection_count", 65 | Help: "当前链接数", 66 | ConstLabels: ConstLabels, 67 | }, []string{"label", "conn_type", "remote"}) 68 | 69 | HandShakeDurationMilliseconds = prometheus.NewHistogramVec(prometheus.HistogramOpts{ 70 | Buckets: msBuckets, 71 | Subsystem: METRIC_SUBSYSTEM_TRAFFIC, 72 | Namespace: METRIC_NS, 73 | Name: "handshake_duration_milliseconds", 74 | Help: "握手时间ms", 75 | ConstLabels: ConstLabels, 76 | }, []string{"label", "conn_type", "remote"}) 77 | 78 | NetWorkTransmitBytes = prometheus.NewCounterVec(prometheus.CounterOpts{ 79 | Namespace: METRIC_NS, 80 | Subsystem: METRIC_SUBSYSTEM_TRAFFIC, 81 | Name: "network_transmit_bytes", 82 | Help: "传输流量总量bytes", 83 | ConstLabels: ConstLabels, 84 | }, []string{"label", "conn_type", "flow", "remote"}) 85 | ) 86 | 87 | func RegisterEhcoMetrics(cfg *config.Config) error { 88 | // traffic 89 | prometheus.MustRegister(EhcoAlive) 90 | prometheus.MustRegister(CurConnectionCount) 91 | prometheus.MustRegister(NetWorkTransmitBytes) 92 | prometheus.MustRegister(HandShakeDurationMilliseconds) 93 | 94 | EhcoAlive.Set(EhcoAliveStateInit) 95 | 96 | // ping 97 | if cfg.EnablePing { 98 | pg := NewPingGroup(cfg) 99 | prometheus.MustRegister(PingResponseDurationMilliseconds) 100 | go pg.Run() 101 | } 102 | return nil 103 | } 104 | -------------------------------------------------------------------------------- /internal/metrics/node.go: -------------------------------------------------------------------------------- 1 | package metrics 2 | 3 | import ( 4 | "fmt" 5 | 6 | "github.com/Ehco1996/ehco/internal/config" 7 | "github.com/alecthomas/kingpin/v2" 8 | "github.com/prometheus/client_golang/prometheus" 9 | "github.com/prometheus/common/promlog" 10 | "github.com/prometheus/node_exporter/collector" 11 | ) 12 | 13 | func RegisterNodeExporterMetrics(cfg *config.Config) error { 14 | level := &promlog.AllowedLevel{} 15 | // mute node_exporter logger 16 | if err := level.Set("error"); err != nil { 17 | return err 18 | } 19 | 20 | logger := promlog.New(&promlog.Config{Level: level}) 21 | // node_exporter relay on `kingpin` to enable default node collector 22 | // see https://github.com/prometheus/node_exporter/pull/2463 23 | if _, err := kingpin.CommandLine.Parse([]string{}); err != nil { 24 | return err 25 | } 26 | nc, err := collector.NewNodeCollector(logger) 27 | if err != nil { 28 | return fmt.Errorf("couldn't create collector: %s", err) 29 | } 30 | prometheus.MustRegister(nc) 31 | return nil 32 | } 33 | -------------------------------------------------------------------------------- /internal/metrics/ping.go: -------------------------------------------------------------------------------- 1 | package metrics 2 | 3 | import ( 4 | "math" 5 | "runtime" 6 | "time" 7 | 8 | "github.com/Ehco1996/ehco/internal/config" 9 | "github.com/go-ping/ping" 10 | "go.uber.org/zap" 11 | ) 12 | 13 | func (pg *PingGroup) newPinger(ruleLabel string, remote string, addr string) (*ping.Pinger, error) { 14 | pinger := ping.New(addr) 15 | if err := pinger.Resolve(); err != nil { 16 | pg.logger.Error("failed to resolve pinger", zap.String("addr", addr), zap.Error(err)) 17 | return nil, err 18 | } 19 | pinger.Interval = pingInterval 20 | pinger.Timeout = time.Duration(math.MaxInt64) 21 | pinger.RecordRtts = false 22 | 23 | switch runtime.GOOS { 24 | case "darwin": 25 | case "linux": 26 | pinger.SetPrivileged(true) 27 | default: 28 | pinger.SetPrivileged(true) 29 | pg.logger.Warn("Attempting to set privileged mode for unknown OS", zap.String("OS", runtime.GOOS)) 30 | } 31 | 32 | pinger.OnRecv = func(pkt *ping.Packet) { 33 | ip := pkt.IPAddr.String() 34 | PingResponseDurationMilliseconds.WithLabelValues( 35 | ruleLabel, remote, ip).Observe(float64(pkt.Rtt.Milliseconds())) 36 | pg.logger.Sugar().Infof("%d bytes from %s icmp_seq=%d time=%v ttl=%v", 37 | pkt.Nbytes, pkt.Addr, pkt.Seq, pkt.Rtt, pkt.Ttl) 38 | } 39 | return pinger, nil 40 | } 41 | 42 | type PingGroup struct { 43 | logger *zap.Logger 44 | 45 | Pingers []*ping.Pinger 46 | } 47 | 48 | func NewPingGroup(cfg *config.Config) *PingGroup { 49 | pg := &PingGroup{logger: zap.L().Named("pinger"), Pingers: make([]*ping.Pinger, 0)} 50 | 51 | for _, relayCfg := range cfg.RelayConfigs { 52 | for _, remote := range relayCfg.GetAllRemotes() { 53 | addr, err := remote.GetAddrHost() 54 | if err != nil { 55 | pg.logger.Error("try parse host error", zap.Error(err)) 56 | continue 57 | } 58 | if pinger, err := pg.newPinger(relayCfg.Label, remote.Address, addr); err != nil { 59 | pg.logger.Error("new pinger meet error", zap.Error(err)) 60 | } else { 61 | pg.Pingers = append(pg.Pingers, pinger) 62 | } 63 | } 64 | } 65 | return pg 66 | } 67 | 68 | func (pg *PingGroup) Run() { 69 | if len(pg.Pingers) <= 0 { 70 | return 71 | } 72 | pg.logger.Sugar().Infof("Start Ping Group now total pinger: %d", len(pg.Pingers)) 73 | splay := time.Duration(pingInterval.Nanoseconds() / int64(len(pg.Pingers))) 74 | for _, pinger := range pg.Pingers { 75 | go func() { 76 | if err := pinger.Run(); err != nil { 77 | pg.logger.Error("Starting pinger meet err", zap.Error(err), zap.String("addr", pinger.Addr())) 78 | } 79 | }() 80 | time.Sleep(splay) 81 | } 82 | } 83 | -------------------------------------------------------------------------------- /internal/relay/health_check.go: -------------------------------------------------------------------------------- 1 | package relay 2 | 3 | import ( 4 | "context" 5 | "fmt" 6 | 7 | "github.com/Ehco1996/ehco/internal/glue" 8 | ) 9 | 10 | var _ glue.HealthChecker = (*Server)(nil) 11 | 12 | func (r *Server) HealthCheck(ctx context.Context, relayID string) (int64, error) { 13 | rs, ok := r.relayM.Load(relayID) 14 | if !ok { 15 | return 0, fmt.Errorf("label for relay: %s not found,can not health check", relayID) 16 | } 17 | inner, _ := rs.(*Relay) 18 | return inner.relayServer.HealthCheck(ctx) 19 | } 20 | -------------------------------------------------------------------------------- /internal/relay/relay.go: -------------------------------------------------------------------------------- 1 | package relay 2 | 3 | import ( 4 | "context" 5 | 6 | "go.uber.org/zap" 7 | 8 | "github.com/Ehco1996/ehco/internal/cmgr" 9 | "github.com/Ehco1996/ehco/internal/relay/conf" 10 | "github.com/Ehco1996/ehco/internal/transporter" 11 | ) 12 | 13 | type Relay struct { 14 | cfg *conf.Config 15 | l *zap.SugaredLogger 16 | 17 | relayServer transporter.RelayServer 18 | } 19 | 20 | func (r *Relay) UniqueID() string { 21 | return r.cfg.Label 22 | } 23 | 24 | func NewRelay(cfg *conf.Config, cmgr cmgr.Cmgr) (*Relay, error) { 25 | s, err := transporter.NewRelayServer(cfg, cmgr) 26 | if err != nil { 27 | return nil, err 28 | } 29 | 30 | r := &Relay{ 31 | relayServer: s, 32 | cfg: cfg, 33 | l: zap.S().Named("relay"), 34 | } 35 | return r, nil 36 | } 37 | 38 | func (r *Relay) ListenAndServe(ctx context.Context) error { 39 | errCh := make(chan error) 40 | go func() { 41 | r.l.Infof("Start Relay Server: %s", r.cfg.DefaultLabel()) 42 | errCh <- r.relayServer.ListenAndServe(ctx) 43 | }() 44 | return <-errCh 45 | } 46 | 47 | func (r *Relay) Stop() error { 48 | return r.relayServer.Close() 49 | } 50 | -------------------------------------------------------------------------------- /internal/relay/server.go: -------------------------------------------------------------------------------- 1 | package relay 2 | 3 | import ( 4 | "context" 5 | "errors" 6 | "net" 7 | "net/http" 8 | "os" 9 | "os/signal" 10 | "sync" 11 | "syscall" 12 | "time" 13 | 14 | "github.com/Ehco1996/ehco/internal/cmgr" 15 | "github.com/Ehco1996/ehco/internal/config" 16 | "go.uber.org/zap" 17 | ) 18 | 19 | type Server struct { 20 | relayM *sync.Map 21 | cfg *config.Config 22 | l *zap.SugaredLogger 23 | 24 | errCH chan error // once error happen, server will exit 25 | reloadCH chan struct{} // reload config 26 | 27 | Cmgr cmgr.Cmgr 28 | } 29 | 30 | func NewServer(cfg *config.Config) (*Server, error) { 31 | l := zap.S().Named("relay-server") 32 | cmgrCfg := &cmgr.Config{ 33 | SyncURL: cfg.RelaySyncURL, 34 | SyncInterval: cfg.RelaySyncInterval, 35 | MetricsURL: cfg.GetMetricURL(), 36 | } 37 | cmgrCfg.Adjust() 38 | cmgr, err := cmgr.NewCmgr(cmgrCfg) 39 | if err != nil { 40 | return nil, err 41 | } 42 | s := &Server{ 43 | cfg: cfg, 44 | l: l, 45 | relayM: &sync.Map{}, 46 | errCH: make(chan error, 1), 47 | reloadCH: make(chan struct{}, 1), 48 | Cmgr: cmgr, 49 | } 50 | return s, nil 51 | } 52 | 53 | func (s *Server) startOneRelay(ctx context.Context, r *Relay) { 54 | s.relayM.Store(r.UniqueID(), r) 55 | // mute closed network error for tcp server and mute http.ErrServerClosed for http server when config reload 56 | if err := r.ListenAndServe(ctx); err != nil && 57 | !errors.Is(err, net.ErrClosed) && !errors.Is(err, http.ErrServerClosed) { 58 | s.l.Errorf("start relay %s meet error: %s", r.UniqueID(), err) 59 | s.errCH <- err 60 | } 61 | } 62 | 63 | func (s *Server) stopOneRelay(r *Relay) { 64 | _ = r.Stop() 65 | s.relayM.Delete(r.UniqueID()) 66 | } 67 | 68 | func (s *Server) Start(ctx context.Context) error { 69 | // init and relay servers 70 | for idx := range s.cfg.RelayConfigs { 71 | r, err := NewRelay(s.cfg.RelayConfigs[idx], s.Cmgr) 72 | if err != nil { 73 | return err 74 | } 75 | go s.startOneRelay(ctx, r) 76 | } 77 | 78 | if s.cfg.PATH != "" && (s.cfg.ReloadInterval > 0) { 79 | s.l.Infof("Start to watch relay config %s ", s.cfg.PATH) 80 | go s.WatchAndReload(ctx) 81 | } 82 | 83 | // start Cmgr when need sync from server 84 | if s.cfg.NeedStartCmgr() { 85 | go s.Cmgr.Start(ctx, s.errCH) 86 | } 87 | 88 | select { 89 | case err := <-s.errCH: 90 | s.l.Errorf("meet error: %s exit now.", err) 91 | return err 92 | case <-ctx.Done(): 93 | s.l.Info("ctx cancelled start to stop all relay servers") 94 | return s.Stop() 95 | } 96 | } 97 | 98 | func (s *Server) Stop() error { 99 | var err error 100 | s.relayM.Range(func(key, value interface{}) bool { 101 | r := value.(*Relay) 102 | if e := r.Stop(); e != nil { 103 | err = errors.Join(err, e) 104 | } 105 | return true 106 | }) 107 | return err 108 | } 109 | 110 | func (s *Server) TriggerReload() { 111 | s.reloadCH <- struct{}{} 112 | } 113 | 114 | func (s *Server) WatchAndReload(ctx context.Context) { 115 | go s.TriggerReloadBySignal(ctx) 116 | go s.triggerReloadByTicker(ctx) 117 | 118 | for { 119 | select { 120 | case <-ctx.Done(): 121 | return 122 | case <-s.reloadCH: 123 | if err := s.Reload(false); err != nil { 124 | s.l.Errorf("auto reloading relay conf meet error: %s will retry in next loop", err) 125 | } 126 | } 127 | } 128 | } 129 | 130 | func (s *Server) triggerReloadByTicker(ctx context.Context) { 131 | if s.cfg.ReloadInterval > 0 { 132 | ticker := time.NewTicker(time.Second * time.Duration(s.cfg.ReloadInterval)) 133 | defer ticker.Stop() 134 | for { 135 | select { 136 | case <-ctx.Done(): 137 | return 138 | case <-ticker.C: 139 | s.l.Warn("Trigger Reloading Relay Conf By ticker! ") 140 | s.TriggerReload() 141 | } 142 | } 143 | } 144 | } 145 | 146 | func (s *Server) TriggerReloadBySignal(ctx context.Context) { 147 | // listen syscall.SIGHUP to trigger reload 148 | sigHubCH := make(chan os.Signal, 1) 149 | signal.Notify(sigHubCH, syscall.SIGHUP) 150 | for { 151 | select { 152 | case <-ctx.Done(): 153 | return 154 | case <-sigHubCH: 155 | s.l.Warn("Trigger Reloading Relay Conf By HUP Signal! ") 156 | s.TriggerReload() 157 | } 158 | } 159 | } 160 | -------------------------------------------------------------------------------- /internal/relay/server_reloader.go: -------------------------------------------------------------------------------- 1 | package relay 2 | 3 | import ( 4 | "context" 5 | 6 | "github.com/Ehco1996/ehco/internal/glue" 7 | "github.com/Ehco1996/ehco/internal/relay/conf" 8 | "go.uber.org/zap" 9 | ) 10 | 11 | // make sure Server implements the reloader.Reloader interface 12 | var _ glue.Reloader = (*Server)(nil) 13 | 14 | func (s *Server) Reload(force bool) error { 15 | // k:name v: *Config 16 | oldRelayCfgM := make(map[string]*conf.Config) 17 | for _, v := range s.cfg.RelayConfigs { 18 | oldRelayCfgM[v.Label] = v.Clone() 19 | } 20 | allRelayLabelList := make([]string, 0) 21 | 22 | // NOTE: this is for reuse cached clash sub, because clash sub to relay config will change port every time when call 23 | if err := s.cfg.LoadConfig(force); err != nil { 24 | s.l.Error("load new cfg meet error", zap.Error(err)) 25 | return err 26 | } 27 | 28 | // find all new relay label 29 | for _, newCfg := range s.cfg.RelayConfigs { 30 | // start bread new relay that not in old relayM 31 | allRelayLabelList = append(allRelayLabelList, newCfg.Label) 32 | } 33 | // closed relay not in all relay list 34 | s.relayM.Range(func(key, value interface{}) bool { 35 | oldLabel := key.(string) 36 | if !inArray(oldLabel, allRelayLabelList) { 37 | v, _ := s.relayM.Load(oldLabel) 38 | oldR := v.(*Relay) 39 | s.stopOneRelay(oldR) 40 | } 41 | return true 42 | }) 43 | 44 | for _, newCfg := range s.cfg.RelayConfigs { 45 | // start bread new relay that not in old relayM 46 | if old, ok := s.relayM.Load(newCfg.Label); !ok { 47 | s.l.Infof("start new relay name=%s", newCfg.Label) 48 | r, err := NewRelay(newCfg, s.Cmgr) 49 | if err != nil { 50 | s.l.Error("new relay meet error", zap.Error(err)) 51 | continue 52 | } 53 | go s.startOneRelay(context.TODO(), r) 54 | } else { 55 | // when label not change, check if config changed 56 | oldCfg, ok := oldRelayCfgM[newCfg.Label] 57 | if !ok { 58 | continue 59 | /// should not happen 60 | } 61 | // stop old and start new relay when config changed 62 | if oldCfg.Different(newCfg) { 63 | oldR := old.(*Relay) 64 | s.l.Infof("relay config changed, stop old and start new relay name=%s", newCfg.Label) 65 | s.stopOneRelay(oldR) 66 | r, err := NewRelay(newCfg, s.Cmgr) 67 | if err != nil { 68 | s.l.Error("new relay meet error", zap.Error(err)) 69 | continue 70 | } 71 | go s.startOneRelay(context.TODO(), r) 72 | } 73 | } 74 | } 75 | 76 | return nil 77 | } 78 | -------------------------------------------------------------------------------- /internal/relay/utils.go: -------------------------------------------------------------------------------- 1 | package relay 2 | 3 | func inArray(ele string, array []string) bool { 4 | for _, v := range array { 5 | if v == ele { 6 | return true 7 | } 8 | } 9 | return false 10 | } 11 | -------------------------------------------------------------------------------- /internal/tls/tls.go: -------------------------------------------------------------------------------- 1 | package tls 2 | 3 | import ( 4 | "crypto/ecdsa" 5 | "crypto/rand" 6 | "crypto/rsa" 7 | "crypto/tls" 8 | "crypto/x509" 9 | "crypto/x509/pkix" 10 | "encoding/pem" 11 | "math/big" 12 | "os" 13 | "time" 14 | 15 | "go.uber.org/zap" 16 | ) 17 | 18 | // pre built in tls cert 19 | var ( 20 | CertFileName = os.Getenv("EHCO_CERT_FILE_NAME") 21 | KeyFileName = os.Getenv("EHCO_KEY_FILE_NAME") 22 | 23 | DefaultTLSConfig *tls.Config 24 | DefaultTLSConfigCertBytes []byte 25 | DefaultTLSConfigKeyBytes []byte 26 | ) 27 | 28 | func InitTlsCfg() error { 29 | if DefaultTLSConfig != nil { 30 | return nil 31 | } 32 | cert, err := genCertificate() 33 | if err != nil { 34 | return err 35 | } 36 | DefaultTLSConfig = &tls.Config{ 37 | Certificates: []tls.Certificate{cert}, 38 | InsecureSkipVerify: true, 39 | } 40 | return nil 41 | } 42 | 43 | func genCertificate() (cert tls.Certificate, err error) { 44 | rawCert, rawKey, err := generateKeyPair() 45 | if err != nil { 46 | return 47 | } 48 | cert, err = tls.X509KeyPair(rawCert, rawKey) 49 | return cert, err 50 | } 51 | 52 | func generateKeyPair() (rawCert, rawKey []byte, err error) { 53 | // Create private key and self-signed certificate 54 | // Adapted from https://golang.org/src/crypto/tls/generate_cert.go 55 | 56 | priv, err := rsa.GenerateKey(rand.Reader, 2048) 57 | if err != nil { 58 | return 59 | } 60 | validFor := time.Hour * 24 * 365 * 1 61 | notBefore := time.Now() 62 | notAfter := notBefore.Add(validFor) 63 | serialNumberLimit := new(big.Int).Lsh(big.NewInt(1), 128) 64 | serialNumber, _ := rand.Int(rand.Reader, serialNumberLimit) 65 | template := x509.Certificate{ 66 | SerialNumber: serialNumber, 67 | Subject: pkix.Name{ 68 | Organization: []string{"ehco"}, 69 | }, 70 | NotBefore: notBefore, 71 | NotAfter: notAfter, 72 | 73 | KeyUsage: x509.KeyUsageKeyEncipherment | x509.KeyUsageDigitalSignature, 74 | ExtKeyUsage: []x509.ExtKeyUsage{x509.ExtKeyUsageServerAuth}, 75 | BasicConstraintsValid: true, 76 | } 77 | derBytes, err := x509.CreateCertificate(rand.Reader, &template, &template, &priv.PublicKey, priv) 78 | if err != nil { 79 | return 80 | } 81 | 82 | rawCert = pem.EncodeToMemory(&pem.Block{Type: "CERTIFICATE", Bytes: derBytes}) 83 | rawKey = pem.EncodeToMemory(&pem.Block{Type: "RSA PRIVATE KEY", Bytes: x509.MarshalPKCS1PrivateKey(priv)}) 84 | DefaultTLSConfigCertBytes = rawCert 85 | DefaultTLSConfigKeyBytes = rawKey 86 | 87 | if CertFileName != "" { 88 | certOut, err := os.Create(CertFileName) 89 | if err != nil { 90 | // todo fix logger 91 | zap.S().Fatalf("failed to open cert.pem for writing: %s", err) 92 | } 93 | if err = pem.Encode(certOut, &pem.Block{Type: "CERTIFICATE", Bytes: derBytes}); err != nil { 94 | zap.S().Info("failed to pem encode:", err) 95 | } 96 | if err := certOut.Close(); err != nil { 97 | zap.S().Error("error closing cert.pem:", err) 98 | return nil, nil, err 99 | } 100 | zap.S().Infof("write cert to %s", CertFileName) 101 | } 102 | if KeyFileName != "" { 103 | keyOut, err := os.OpenFile(KeyFileName, os.O_WRONLY|os.O_CREATE|os.O_TRUNC, 0o600) 104 | if err != nil { 105 | zap.S().Info("failed to open key.pem for writing:", err) 106 | } 107 | if err = pem.Encode(keyOut, mustPemBlockForKey(priv)); err != nil { 108 | zap.S().Info("failed to pem encode:", err) 109 | } 110 | if err := keyOut.Close(); err != nil { 111 | zap.S().Error("error closing key.pem:", err) 112 | return nil, nil, err 113 | } 114 | zap.S().Infof("write key to %s", KeyFileName) 115 | } 116 | return 117 | } 118 | 119 | func mustPemBlockForKey(priv interface{}) *pem.Block { 120 | switch k := priv.(type) { 121 | case *rsa.PrivateKey: 122 | return &pem.Block{Type: "RSA PRIVATE KEY", Bytes: x509.MarshalPKCS1PrivateKey(k)} 123 | case *ecdsa.PrivateKey: 124 | b, err := x509.MarshalECPrivateKey(k) 125 | if err != nil { 126 | zap.S().Errorf("Unable to marshal ECDSA private key: %v", err) 127 | } 128 | return &pem.Block{Type: "EC PRIVATE KEY", Bytes: b} 129 | default: 130 | return nil 131 | } 132 | } 133 | -------------------------------------------------------------------------------- /internal/transporter/base.go: -------------------------------------------------------------------------------- 1 | package transporter 2 | 3 | import ( 4 | "context" 5 | "fmt" 6 | "net" 7 | 8 | "github.com/sagernet/sing-box/common/sniff" 9 | "github.com/sagernet/sing/common/buf" 10 | "github.com/sagernet/sing/common/bufio" 11 | "go.uber.org/zap" 12 | 13 | "github.com/Ehco1996/ehco/internal/cmgr" 14 | "github.com/Ehco1996/ehco/internal/conn" 15 | "github.com/Ehco1996/ehco/internal/constant" 16 | "github.com/Ehco1996/ehco/internal/lb" 17 | "github.com/Ehco1996/ehco/internal/metrics" 18 | "github.com/Ehco1996/ehco/internal/relay/conf" 19 | ) 20 | 21 | var _ RelayServer = &BaseRelayServer{} 22 | 23 | type BaseRelayServer struct { 24 | cmgr cmgr.Cmgr 25 | cfg *conf.Config 26 | l *zap.SugaredLogger 27 | 28 | remotes lb.RoundRobin 29 | relayer RelayClient 30 | } 31 | 32 | func newBaseRelayServer(cfg *conf.Config, cmgr cmgr.Cmgr) (*BaseRelayServer, error) { 33 | relayer, err := newRelayClient(cfg) 34 | if err != nil { 35 | return nil, err 36 | } 37 | return &BaseRelayServer{ 38 | relayer: relayer, 39 | cfg: cfg, 40 | cmgr: cmgr, 41 | remotes: cfg.ToRemotesLB(), 42 | l: zap.S().Named(cfg.GetLoggerName()), 43 | }, nil 44 | } 45 | 46 | func (b *BaseRelayServer) RelayTCPConn(ctx context.Context, c net.Conn, remote *lb.Node) error { 47 | labels := []string{b.cfg.Label, metrics.METRIC_CONN_TYPE_TCP, remote.Address} 48 | metrics.CurConnectionCount.WithLabelValues(labels...).Inc() 49 | defer metrics.CurConnectionCount.WithLabelValues(labels...).Dec() 50 | 51 | if err := b.checkConnectionLimit(); err != nil { 52 | return err 53 | } 54 | 55 | var err error 56 | c, err = b.sniffAndBlockProtocol(c) 57 | if err != nil { 58 | return err 59 | } 60 | c = b.applyRateLimit(c) 61 | 62 | rc, err := b.relayer.HandShake(ctx, remote, true) 63 | if err != nil { 64 | return fmt.Errorf("handshake error: %w", err) 65 | } 66 | defer rc.Close() 67 | b.l.Infof("RelayTCPConn from %s to %s", c.LocalAddr(), remote.Address) 68 | return b.handleRelayConn(c, rc, remote, metrics.METRIC_CONN_TYPE_TCP) 69 | } 70 | 71 | func (b *BaseRelayServer) RelayUDPConn(ctx context.Context, c net.Conn, remote *lb.Node) error { 72 | labels := []string{b.cfg.Label, metrics.METRIC_CONN_TYPE_UDP, remote.Address} 73 | metrics.CurConnectionCount.WithLabelValues(labels...).Inc() 74 | defer metrics.CurConnectionCount.WithLabelValues(labels...).Dec() 75 | 76 | rc, err := b.relayer.HandShake(ctx, remote, false) 77 | if err != nil { 78 | return fmt.Errorf("handshake error: %w", err) 79 | } 80 | defer rc.Close() 81 | 82 | b.l.Infof("RelayUDPConn from %s to %s", c.LocalAddr(), remote.Address) 83 | return b.handleRelayConn(c, rc, remote, metrics.METRIC_CONN_TYPE_UDP) 84 | } 85 | 86 | func (b *BaseRelayServer) checkConnectionLimit() error { 87 | if b.cmgr == nil { 88 | return nil 89 | } 90 | if b.cfg.Options.MaxConnection > 0 && b.cmgr.CountConnection(cmgr.ConnectionTypeActive) >= b.cfg.Options.MaxConnection { 91 | return fmt.Errorf("relay:%s active connection count exceed limit %d", b.cfg.Label, b.cfg.Options.MaxConnection) 92 | } 93 | return nil 94 | } 95 | 96 | func (b *BaseRelayServer) sniffAndBlockProtocol(c net.Conn) (net.Conn, error) { 97 | if len(b.cfg.Options.BlockedProtocols) == 0 { 98 | return c, nil 99 | } 100 | 101 | buffer := buf.NewPacket() 102 | 103 | ctx, cancel := context.WithTimeout(context.Background(), b.cfg.Options.SniffTimeout) 104 | defer cancel() 105 | 106 | sniffMetadata, err := sniff.PeekStream(ctx, c, buffer, b.cfg.Options.SniffTimeout, sniff.TLSClientHello, sniff.HTTPHost) 107 | if err != nil { 108 | b.l.Debugf("sniff error: %s", err) 109 | } 110 | 111 | if sniffMetadata != nil { 112 | b.l.Infof("sniffed protocol: %s", sniffMetadata.Protocol) 113 | for _, p := range b.cfg.Options.BlockedProtocols { 114 | if sniffMetadata.Protocol == p { 115 | return c, fmt.Errorf("relay:%s blocked protocol:%s", b.cfg.Label, sniffMetadata.Protocol) 116 | } 117 | } 118 | } 119 | 120 | if !buffer.IsEmpty() { 121 | return bufio.NewCachedConn(c, buffer), nil 122 | } else { 123 | buffer.Release() 124 | } 125 | return c, nil 126 | } 127 | 128 | func (b *BaseRelayServer) applyRateLimit(c net.Conn) net.Conn { 129 | if b.cfg.Options.MaxReadRateKbps > 0 { 130 | return conn.NewRateLimitedConn(c, b.cfg.Options.MaxReadRateKbps) 131 | } 132 | return c 133 | } 134 | 135 | func (b *BaseRelayServer) handleRelayConn(c, rc net.Conn, remote *lb.Node, connType string) error { 136 | opts := []conn.RelayConnOption{ 137 | conn.WithLogger(b.l), 138 | conn.WithRemote(remote), 139 | conn.WithConnType(connType), 140 | conn.WithRelayLabel(b.cfg.Label), 141 | conn.WithRelayOptions(b.cfg.Options), 142 | } 143 | relayConn := conn.NewRelayConn(c, rc, opts...) 144 | if b.cmgr != nil { 145 | b.cmgr.AddConnection(relayConn) 146 | defer b.cmgr.RemoveConnection(relayConn) 147 | } 148 | 149 | return relayConn.Transport() 150 | } 151 | 152 | func (b *BaseRelayServer) HealthCheck(ctx context.Context) (int64, error) { 153 | remote := b.remotes.Next().Clone() 154 | // us tcp handshake to check health 155 | _, err := b.relayer.HandShake(ctx, remote, true) 156 | return int64(remote.HandShakeDuration.Milliseconds()), err 157 | } 158 | 159 | func (b *BaseRelayServer) Close() error { 160 | return fmt.Errorf("not implemented") 161 | } 162 | 163 | func (b *BaseRelayServer) ListenAndServe(ctx context.Context) error { 164 | return fmt.Errorf("not implemented") 165 | } 166 | 167 | func NewNetDialer(cfg *conf.Config) *net.Dialer { 168 | dialer := &net.Dialer{Timeout: constant.DefaultDialTimeOut} 169 | dialer.SetMultipathTCP(cfg.Options.EnableMultipathTCP) 170 | return dialer 171 | } 172 | 173 | func NewTCPListener(ctx context.Context, cfg *conf.Config) (net.Listener, error) { 174 | addr, err := net.ResolveTCPAddr("tcp", cfg.Listen) 175 | if err != nil { 176 | return nil, err 177 | } 178 | lcfg := net.ListenConfig{} 179 | lcfg.SetMultipathTCP(cfg.Options.EnableMultipathTCP) 180 | return lcfg.Listen(ctx, "tcp", addr.String()) 181 | } 182 | -------------------------------------------------------------------------------- /internal/transporter/interface.go: -------------------------------------------------------------------------------- 1 | package transporter 2 | 3 | import ( 4 | "context" 5 | "fmt" 6 | "net" 7 | 8 | "github.com/Ehco1996/ehco/internal/cmgr" 9 | "github.com/Ehco1996/ehco/internal/constant" 10 | "github.com/Ehco1996/ehco/internal/lb" 11 | "github.com/Ehco1996/ehco/internal/relay/conf" 12 | ) 13 | 14 | // TODO opt this interface 15 | type RelayClient interface { 16 | HandShake(ctx context.Context, remote *lb.Node, isTCP bool) (net.Conn, error) 17 | } 18 | 19 | func newRelayClient(cfg *conf.Config) (RelayClient, error) { 20 | switch cfg.TransportType { 21 | case constant.RelayTypeRaw: 22 | return newRawClient(cfg) 23 | case constant.RelayTypeWS: 24 | return newWsClient(cfg) 25 | case constant.RelayTypeWSS: 26 | return newWssClient(cfg) 27 | default: 28 | return nil, fmt.Errorf("unsupported transport type: %s", cfg.TransportType) 29 | } 30 | } 31 | 32 | type RelayServer interface { 33 | ListenAndServe(ctx context.Context) error 34 | Close() error 35 | 36 | RelayTCPConn(ctx context.Context, c net.Conn, remote *lb.Node) error 37 | RelayUDPConn(ctx context.Context, c net.Conn, remote *lb.Node) error 38 | HealthCheck(ctx context.Context) (int64, error) // latency in ms 39 | } 40 | 41 | func NewRelayServer(cfg *conf.Config, cmgr cmgr.Cmgr) (RelayServer, error) { 42 | base, err := newBaseRelayServer(cfg, cmgr) 43 | if err != nil { 44 | return nil, err 45 | } 46 | switch cfg.ListenType { 47 | case constant.RelayTypeRaw: 48 | return newRawServer(base) 49 | case constant.RelayTypeWS: 50 | return newWsServer(base) 51 | case constant.RelayTypeWSS: 52 | return newWssServer(base) 53 | default: 54 | panic("unsupported transport type" + cfg.ListenType) 55 | } 56 | } 57 | -------------------------------------------------------------------------------- /internal/transporter/raw.go: -------------------------------------------------------------------------------- 1 | // nolint: errcheck 2 | package transporter 3 | 4 | import ( 5 | "context" 6 | "errors" 7 | "net" 8 | "time" 9 | 10 | "github.com/Ehco1996/ehco/internal/conn" 11 | "github.com/Ehco1996/ehco/internal/lb" 12 | "github.com/Ehco1996/ehco/internal/metrics" 13 | "github.com/Ehco1996/ehco/internal/relay/conf" 14 | "go.uber.org/zap" 15 | ) 16 | 17 | var ( 18 | _ RelayClient = &RawClient{} 19 | _ RelayServer = &RawServer{} 20 | ) 21 | 22 | type RawClient struct { 23 | dialer *net.Dialer 24 | cfg *conf.Config 25 | l *zap.SugaredLogger 26 | } 27 | 28 | func newRawClient(cfg *conf.Config) (*RawClient, error) { 29 | r := &RawClient{ 30 | cfg: cfg, 31 | dialer: NewNetDialer(cfg), 32 | l: zap.S().Named(string(cfg.TransportType)), 33 | } 34 | return r, nil 35 | } 36 | 37 | func (raw *RawClient) HandShake(ctx context.Context, remote *lb.Node, isTCP bool) (net.Conn, error) { 38 | t1 := time.Now() 39 | var rc net.Conn 40 | var err error 41 | if isTCP { 42 | rc, err = raw.dialer.DialContext(ctx, "tcp", remote.Address) 43 | } else { 44 | rc, err = raw.dialer.DialContext(ctx, "udp", remote.Address) 45 | } 46 | if err != nil { 47 | return nil, err 48 | } 49 | latency := time.Since(t1) 50 | connType := metrics.METRIC_CONN_TYPE_TCP 51 | if !isTCP { 52 | connType = metrics.METRIC_CONN_TYPE_UDP 53 | } 54 | labels := []string{raw.cfg.Label, connType, remote.Address} 55 | metrics.HandShakeDurationMilliseconds.WithLabelValues(labels...).Observe(float64(latency.Milliseconds())) 56 | remote.HandShakeDuration = latency 57 | return rc, nil 58 | } 59 | 60 | type RawServer struct { 61 | *BaseRelayServer 62 | 63 | tcpLis net.Listener 64 | udpLis *conn.UDPListener 65 | } 66 | 67 | func newRawServer(bs *BaseRelayServer) (*RawServer, error) { 68 | rs := &RawServer{BaseRelayServer: bs} 69 | 70 | return rs, nil 71 | } 72 | 73 | func (s *RawServer) Close() error { 74 | err := s.tcpLis.Close() 75 | if s.udpLis != nil { 76 | err2 := s.udpLis.Close() 77 | err = errors.Join(err, err2) 78 | } 79 | return err 80 | } 81 | 82 | func (s *RawServer) ListenAndServe(ctx context.Context) error { 83 | ts, err := NewTCPListener(ctx, s.cfg) 84 | if err != nil { 85 | return err 86 | } 87 | s.tcpLis = ts 88 | 89 | if s.cfg.Options != nil && s.cfg.Options.EnableUDP { 90 | udpLis, err := conn.NewUDPListener(ctx, s.cfg) 91 | if err != nil { 92 | return err 93 | } 94 | s.udpLis = udpLis 95 | } 96 | 97 | if s.udpLis != nil { 98 | go s.listenUDP(ctx) 99 | } 100 | for { 101 | c, err := s.tcpLis.Accept() 102 | if err != nil { 103 | return err 104 | } 105 | go func(c net.Conn) { 106 | defer c.Close() 107 | if err := s.RelayTCPConn(ctx, c, s.remotes.Next()); err != nil { 108 | s.l.Errorf("RelayTCPConn meet error: %s", err.Error()) 109 | } 110 | }(c) 111 | } 112 | } 113 | 114 | func (s *RawServer) listenUDP(ctx context.Context) error { 115 | for { 116 | c, err := s.udpLis.Accept() 117 | if err != nil { 118 | // Check if the error is due to context cancellation 119 | if errors.Is(err, context.Canceled) { 120 | return nil // Return without logging the error 121 | } 122 | s.l.Errorf("UDP accept error: %v", err) 123 | return err 124 | } 125 | go func() { 126 | if err := s.RelayUDPConn(ctx, c, s.remotes.Next()); err != nil { 127 | s.l.Errorf("RelayUDPConn meet error: %s", err.Error()) 128 | } 129 | }() 130 | } 131 | } 132 | -------------------------------------------------------------------------------- /internal/transporter/ws.go: -------------------------------------------------------------------------------- 1 | package transporter 2 | 3 | import ( 4 | "context" 5 | "net" 6 | "net/http" 7 | "net/url" 8 | "time" 9 | 10 | "github.com/gobwas/ws" 11 | "github.com/labstack/echo/v4" 12 | "go.uber.org/zap" 13 | 14 | "github.com/Ehco1996/ehco/internal/conn" 15 | "github.com/Ehco1996/ehco/internal/lb" 16 | "github.com/Ehco1996/ehco/internal/metrics" 17 | "github.com/Ehco1996/ehco/internal/relay/conf" 18 | "github.com/Ehco1996/ehco/internal/web" 19 | ) 20 | 21 | var ( 22 | _ RelayClient = &WsClient{} 23 | _ RelayServer = &WsServer{} 24 | ) 25 | 26 | type WsClient struct { 27 | dialer *ws.Dialer 28 | cfg *conf.Config 29 | l *zap.SugaredLogger 30 | } 31 | 32 | func newWsClient(cfg *conf.Config) (*WsClient, error) { 33 | s := &WsClient{ 34 | cfg: cfg, 35 | l: zap.S().Named(string(cfg.TransportType)), 36 | // todo config buffer size 37 | dialer: &ws.Dialer{ 38 | Timeout: cfg.Options.DialTimeout, 39 | }, 40 | } 41 | return s, nil 42 | } 43 | 44 | func (s *WsClient) addUDPQueryParam(addr string) string { 45 | u, err := url.Parse(addr) 46 | if err != nil { 47 | s.l.Errorf("Failed to parse URL: %v", err) 48 | return addr 49 | } 50 | q := u.Query() 51 | q.Set("type", "udp") 52 | u.RawQuery = q.Encode() 53 | return u.String() 54 | } 55 | 56 | func (s *WsClient) HandShake(ctx context.Context, remote *lb.Node, isTCP bool) (net.Conn, error) { 57 | t1 := time.Now() 58 | addr, err := s.cfg.GetWSRemoteAddr(remote.Address) 59 | if err != nil { 60 | return nil, err 61 | } 62 | if !isTCP { 63 | addr = s.addUDPQueryParam(addr) 64 | } 65 | wsc, _, _, err := s.dialer.Dial(ctx, addr) 66 | if err != nil { 67 | return nil, err 68 | } 69 | latency := time.Since(t1) 70 | connType := metrics.METRIC_CONN_TYPE_TCP 71 | if !isTCP { 72 | connType = metrics.METRIC_CONN_TYPE_UDP 73 | } 74 | labels := []string{s.cfg.Label, connType, remote.Address} 75 | metrics.HandShakeDurationMilliseconds.WithLabelValues(labels...).Observe(float64(latency.Milliseconds())) 76 | remote.HandShakeDuration = latency 77 | c := conn.NewWSConn(wsc, false) 78 | return c, nil 79 | } 80 | 81 | type WsServer struct { 82 | *BaseRelayServer 83 | httpServer *http.Server 84 | } 85 | 86 | func newWsServer(bs *BaseRelayServer) (*WsServer, error) { 87 | s := &WsServer{BaseRelayServer: bs} 88 | e := web.NewEchoServer() 89 | e.Use(web.NginxLogMiddleware(zap.S().Named("ws-server"))) 90 | e.GET("/", echo.WrapHandler(web.MakeIndexF())) 91 | e.GET(bs.cfg.GetWSHandShakePath(), echo.WrapHandler(http.HandlerFunc(s.handleRequest))) 92 | s.httpServer = &http.Server{Handler: e} 93 | return s, nil 94 | } 95 | 96 | func (s *WsServer) handleRequest(w http.ResponseWriter, req *http.Request) { 97 | // todo use bufio.ReadWriter 98 | wsc, _, _, err := ws.UpgradeHTTP(req, w) 99 | if err != nil { 100 | return 101 | } 102 | 103 | var remote *lb.Node 104 | if addr := req.URL.Query().Get(conf.WS_QUERY_REMOTE_ADDR); addr != "" { 105 | remote = &lb.Node{Address: addr} 106 | } else { 107 | remote = s.remotes.Next() 108 | } 109 | 110 | if req.URL.Query().Get("type") == "udp" { 111 | if !s.cfg.Options.EnableUDP { 112 | s.l.Error("udp not support but request with udp type") 113 | wsc.Close() 114 | return 115 | } 116 | err = s.RelayUDPConn(req.Context(), conn.NewWSConn(wsc, true), remote) 117 | } else { 118 | err = s.RelayTCPConn(req.Context(), conn.NewWSConn(wsc, true), remote) 119 | } 120 | if err != nil { 121 | s.l.Errorf("handleRequest meet error:%s", err) 122 | } 123 | } 124 | 125 | func (s *WsServer) ListenAndServe(ctx context.Context) error { 126 | listener, err := NewTCPListener(ctx, s.cfg) 127 | if err != nil { 128 | return err 129 | } 130 | return s.httpServer.Serve(listener) 131 | } 132 | 133 | func (s *WsServer) Close() error { 134 | return s.httpServer.Close() 135 | } 136 | -------------------------------------------------------------------------------- /internal/transporter/wss.go: -------------------------------------------------------------------------------- 1 | package transporter 2 | 3 | import ( 4 | "context" 5 | "crypto/tls" 6 | 7 | "github.com/Ehco1996/ehco/internal/relay/conf" 8 | mytls "github.com/Ehco1996/ehco/internal/tls" 9 | ) 10 | 11 | var ( 12 | _ RelayClient = &WssClient{} 13 | _ RelayServer = &WssServer{} 14 | ) 15 | 16 | type WssClient struct { 17 | *WsClient 18 | } 19 | 20 | func newWssClient(cfg *conf.Config) (*WssClient, error) { 21 | wc, err := newWsClient(cfg) 22 | if err != nil { 23 | return nil, err 24 | } 25 | // insert tls config 26 | wc.dialer.TLSConfig = mytls.DefaultTLSConfig 27 | wc.dialer.TLSConfig.InsecureSkipVerify = true 28 | return &WssClient{WsClient: wc}, nil 29 | } 30 | 31 | type WssServer struct { 32 | *WsServer 33 | } 34 | 35 | func newWssServer(bs *BaseRelayServer) (*WssServer, error) { 36 | wsServer, err := newWsServer(bs) 37 | if err != nil { 38 | return nil, err 39 | } 40 | return &WssServer{WsServer: wsServer}, nil 41 | } 42 | 43 | func (s *WssServer) ListenAndServe(ctx context.Context) error { 44 | listener, err := NewTCPListener(ctx, s.cfg) 45 | if err != nil { 46 | return err 47 | } 48 | tlsCfg := mytls.DefaultTLSConfig 49 | tlsCfg.InsecureSkipVerify = true 50 | tlsListener := tls.NewListener(listener, mytls.DefaultTLSConfig) 51 | return s.httpServer.Serve(tlsListener) 52 | } 53 | -------------------------------------------------------------------------------- /internal/web/handler_api.go: -------------------------------------------------------------------------------- 1 | package web 2 | 3 | import ( 4 | "encoding/json" 5 | "fmt" 6 | "net/http" 7 | "strconv" 8 | "time" 9 | 10 | "github.com/Ehco1996/ehco/internal/cmgr/ms" 11 | "github.com/labstack/echo/v4" 12 | ) 13 | 14 | const ( 15 | defaultTimeRange = 60 // seconds 16 | errInvalidParam = "invalid parameter: %s" 17 | ) 18 | 19 | type queryParams struct { 20 | startTS int64 21 | endTS int64 22 | refresh bool 23 | } 24 | 25 | func parseQueryParams(c echo.Context) (*queryParams, error) { 26 | now := time.Now().Unix() 27 | params := &queryParams{ 28 | startTS: now - defaultTimeRange, 29 | endTS: now, 30 | refresh: false, 31 | } 32 | 33 | if start, err := parseTimestamp(c.QueryParam("start_ts")); err == nil { 34 | params.startTS = start 35 | } 36 | 37 | if end, err := parseTimestamp(c.QueryParam("end_ts")); err == nil { 38 | params.endTS = end 39 | } 40 | 41 | if refresh, err := strconv.ParseBool(c.QueryParam("latest")); err == nil { 42 | params.refresh = refresh 43 | } 44 | 45 | if params.startTS >= params.endTS { 46 | return nil, fmt.Errorf(errInvalidParam, "time range") 47 | } 48 | 49 | return params, nil 50 | } 51 | 52 | func parseTimestamp(s string) (int64, error) { 53 | if s == "" { 54 | return 0, fmt.Errorf("empty timestamp") 55 | } 56 | return strconv.ParseInt(s, 10, 64) 57 | } 58 | 59 | func (s *Server) GetNodeMetrics(c echo.Context) error { 60 | params, err := parseQueryParams(c) 61 | if err != nil { 62 | return echo.NewHTTPError(http.StatusBadRequest, err.Error()) 63 | } 64 | req := &ms.QueryNodeMetricsReq{StartTimestamp: params.startTS, EndTimestamp: params.endTS, Num: -1} 65 | if params.refresh { 66 | req.Num = 1 67 | } 68 | metrics, err := s.connMgr.QueryNodeMetrics(c.Request().Context(), req, params.refresh) 69 | if err != nil { 70 | return echo.NewHTTPError(http.StatusInternalServerError, err.Error()) 71 | } 72 | return c.JSON(http.StatusOK, metrics) 73 | } 74 | 75 | func (s *Server) GetRuleMetrics(c echo.Context) error { 76 | params, err := parseQueryParams(c) 77 | if err != nil { 78 | return echo.NewHTTPError(http.StatusBadRequest, err.Error()) 79 | } 80 | req := &ms.QueryRuleMetricsReq{ 81 | StartTimestamp: params.startTS, 82 | EndTimestamp: params.endTS, 83 | Num: -1, 84 | RuleLabel: c.QueryParam("label"), 85 | Remote: c.QueryParam("remote"), 86 | } 87 | if params.refresh { 88 | req.Num = 1 89 | } 90 | 91 | metrics, err := s.connMgr.QueryRuleMetrics(c.Request().Context(), req, params.refresh) 92 | if err != nil { 93 | return echo.NewHTTPError(http.StatusInternalServerError, err.Error()) 94 | } 95 | return c.JSON(http.StatusOK, metrics) 96 | } 97 | 98 | func (s *Server) CurrentConfig(c echo.Context) error { 99 | ret, err := json.Marshal(s.cfg) 100 | if err != nil { 101 | return echo.NewHTTPError(http.StatusBadRequest, err.Error()) 102 | } 103 | 104 | return c.JSONBlob(http.StatusOK, ret) 105 | } 106 | 107 | func (s *Server) HandleReload(c echo.Context) error { 108 | if s.Reloader == nil { 109 | return echo.NewHTTPError(http.StatusBadRequest, "reload not support") 110 | } 111 | err := s.Reloader.Reload(true) 112 | if err != nil { 113 | return echo.NewHTTPError(http.StatusBadRequest, err.Error()) 114 | } 115 | 116 | if _, err := c.Response().Write([]byte("reload success")); err != nil { 117 | s.l.Errorf("write response meet err=%v", err) 118 | return echo.NewHTTPError(http.StatusBadRequest, err.Error()) 119 | } 120 | return nil 121 | } 122 | 123 | func (s *Server) HandleHealthCheck(c echo.Context) error { 124 | relayLabel := c.QueryParam("relay_label") 125 | if relayLabel == "" { 126 | return echo.NewHTTPError(http.StatusBadRequest, "relay_label is required") 127 | } 128 | latency, err := s.HealthCheck(c.Request().Context(), relayLabel) 129 | if err != nil { 130 | res := HealthCheckResp{Message: err.Error(), ErrorCode: -1} 131 | return c.JSON(http.StatusBadRequest, res) 132 | } 133 | return c.JSON(http.StatusOK, HealthCheckResp{Message: "connect success", Latency: latency}) 134 | } 135 | -------------------------------------------------------------------------------- /internal/web/handler_page.go: -------------------------------------------------------------------------------- 1 | package web 2 | 3 | import ( 4 | "fmt" 5 | "net/http" 6 | "strconv" 7 | 8 | "github.com/Ehco1996/ehco/internal/config" 9 | "github.com/Ehco1996/ehco/internal/constant" 10 | "github.com/labstack/echo/v4" 11 | "go.uber.org/zap" 12 | ) 13 | 14 | const defaultPageSize = 20 15 | 16 | func MakeIndexF() http.HandlerFunc { 17 | return func(w http.ResponseWriter, r *http.Request) { 18 | zap.S().Named("web").Infof("index call from %s", r.RemoteAddr) 19 | fmt.Fprintf(w, "access from remote ip: %s \n", r.RemoteAddr) 20 | } 21 | } 22 | 23 | func (s *Server) index(c echo.Context) error { 24 | data := struct { 25 | Version string 26 | GitBranch string 27 | GitRevision string 28 | BuildTime string 29 | StartTime string 30 | Cfg config.Config 31 | }{ 32 | Version: constant.Version, 33 | GitBranch: constant.GitBranch, 34 | GitRevision: constant.GitRevision, 35 | BuildTime: constant.BuildTime, 36 | StartTime: constant.StartTime.Format("2006-01-02 15:04:05"), 37 | Cfg: *s.cfg, 38 | } 39 | return c.Render(http.StatusOK, "index.html", data) 40 | } 41 | 42 | func (s *Server) ListConnections(c echo.Context) error { 43 | pageStr := c.QueryParam("page") 44 | page, err := strconv.Atoi(pageStr) 45 | if err != nil || page < 1 { 46 | page = 1 47 | } 48 | pageSizeStr := c.QueryParam("page_size") 49 | pageSize, err := strconv.Atoi(pageSizeStr) 50 | if err != nil || pageSize < 1 { 51 | pageSize = defaultPageSize 52 | } 53 | connType := c.QueryParam("conn_type") 54 | total := s.connMgr.CountConnection(connType) 55 | perv := 0 56 | if page > 1 { 57 | perv = page - 1 58 | } 59 | next := 0 60 | if page*pageSize < total && page*pageSize > 0 { 61 | next = page + 1 62 | } 63 | 64 | activeCount := s.connMgr.CountConnection("active") 65 | closedCount := s.connMgr.CountConnection("closed") 66 | 67 | return c.Render(http.StatusOK, "connection.html", map[string]interface{}{ 68 | "ConnType": connType, 69 | "ConnectionList": s.connMgr.ListConnections(connType, page, pageSize), 70 | "CurrentPage": page, 71 | "TotalPage": total / pageSize, 72 | "PageSize": pageSize, 73 | "Prev": perv, 74 | "Next": next, 75 | "Count": total, 76 | "ActiveCount": activeCount, 77 | "ClosedCount": closedCount, 78 | "AllCount": s.connMgr.CountConnection("active") + s.connMgr.CountConnection("closed"), 79 | }) 80 | } 81 | 82 | func (s *Server) ListRules(c echo.Context) error { 83 | return c.Render(http.StatusOK, "rule_list.html", map[string]interface{}{ 84 | "Configs": s.cfg.RelayConfigs, 85 | }) 86 | } 87 | 88 | func (s *Server) RuleMetrics(c echo.Context) error { 89 | return c.Render(http.StatusOK, "rule_metrics.html", map[string]interface{}{ 90 | "Configs": s.cfg.RelayConfigs, 91 | }) 92 | } 93 | 94 | func (s *Server) LogsPage(c echo.Context) error { 95 | return c.Render(http.StatusOK, "logs.html", nil) 96 | } 97 | -------------------------------------------------------------------------------- /internal/web/handlers_ws.go: -------------------------------------------------------------------------------- 1 | package web 2 | 3 | import ( 4 | "net" 5 | 6 | "github.com/Ehco1996/ehco/pkg/log" 7 | "github.com/gobwas/ws" 8 | "github.com/labstack/echo/v4" 9 | ) 10 | 11 | func (s *Server) handleWebSocketLogs(c echo.Context) error { 12 | conn, _, _, err := ws.UpgradeHTTP(c.Request(), c.Response()) 13 | if err != nil { 14 | return err 15 | } 16 | defer conn.Close() 17 | 18 | log.SetWebSocketConn(conn) 19 | 20 | // 保持连接打开并处理可能的入站消息 21 | for { 22 | _, err := ws.ReadFrame(conn) 23 | if err != nil { 24 | if _, ok := err.(net.Error); ok { 25 | // 处理网络错误 26 | s.l.Errorf("WebSocket read error: %v", err) 27 | } 28 | break 29 | } 30 | } 31 | log.SetWebSocketConn(nil) 32 | return nil 33 | } 34 | -------------------------------------------------------------------------------- /internal/web/mw.go: -------------------------------------------------------------------------------- 1 | package web 2 | 3 | import ( 4 | "time" 5 | 6 | "github.com/labstack/echo/v4" 7 | "go.uber.org/zap" 8 | ) 9 | 10 | func NginxLogMiddleware(logger *zap.SugaredLogger) echo.MiddlewareFunc { 11 | return func(next echo.HandlerFunc) echo.HandlerFunc { 12 | return func(c echo.Context) error { 13 | start := time.Now() 14 | 15 | // 继续处理请求 16 | err := next(c) 17 | 18 | stop := time.Now() 19 | latency := stop.Sub(start) 20 | clientIP := c.RealIP() 21 | 22 | // NGINX 风格的日志格式 23 | logger.Infof("%s - - \"%s %s %s\" %d %v", 24 | clientIP, 25 | c.Request().Method, 26 | c.Request().RequestURI, 27 | c.Request().Proto, 28 | c.Response().Status, 29 | latency, 30 | ) 31 | 32 | return err 33 | } 34 | } 35 | } 36 | -------------------------------------------------------------------------------- /internal/web/server.go: -------------------------------------------------------------------------------- 1 | package web 2 | 3 | import ( 4 | "crypto/subtle" 5 | "embed" 6 | "fmt" 7 | "html/template" 8 | "io" 9 | "net" 10 | "net/http" 11 | _ "net/http/pprof" 12 | 13 | "github.com/labstack/echo/v4" 14 | "github.com/labstack/echo/v4/middleware" 15 | "github.com/pkg/errors" 16 | "github.com/prometheus/client_golang/prometheus/promhttp" 17 | "go.uber.org/zap" 18 | 19 | "github.com/Ehco1996/ehco/internal/cmgr" 20 | "github.com/Ehco1996/ehco/internal/config" 21 | "github.com/Ehco1996/ehco/internal/glue" 22 | "github.com/Ehco1996/ehco/internal/metrics" 23 | ) 24 | 25 | //go:embed templates/*.html js/*.js 26 | var templatesFS embed.FS 27 | 28 | const ( 29 | metricsPath = "/metrics/" 30 | indexPath = "/" 31 | connectionsPath = "/connections/" 32 | rulesPath = "/rules/" 33 | apiPrefix = "/api/v1" 34 | ) 35 | 36 | type Server struct { 37 | glue.Reloader 38 | glue.HealthChecker 39 | 40 | e *echo.Echo 41 | addr string 42 | l *zap.SugaredLogger 43 | cfg *config.Config 44 | 45 | connMgr cmgr.Cmgr 46 | } 47 | 48 | type echoTemplate struct { 49 | templates *template.Template 50 | } 51 | 52 | func (t *echoTemplate) Render(w io.Writer, name string, data interface{}, c echo.Context) error { 53 | return t.templates.ExecuteTemplate(w, name, data) 54 | } 55 | 56 | func NewServer( 57 | cfg *config.Config, 58 | relayReloader glue.Reloader, 59 | healthChecker glue.HealthChecker, 60 | connMgr cmgr.Cmgr, 61 | ) (*Server, error) { 62 | if err := validateConfig(cfg); err != nil { 63 | return nil, errors.Wrap(err, "invalid configuration") 64 | } 65 | 66 | l := zap.S().Named("web") 67 | 68 | e := NewEchoServer() 69 | if err := setupMiddleware(e, cfg, l); err != nil { 70 | return nil, errors.Wrap(err, "failed to setup middleware") 71 | } 72 | 73 | if err := setupTemplates(e, l, cfg); err != nil { 74 | return nil, errors.Wrap(err, "failed to setup templates") 75 | } 76 | 77 | if err := setupMetrics(cfg); err != nil { 78 | return nil, errors.Wrap(err, "failed to setup metrics") 79 | } 80 | 81 | s := &Server{ 82 | Reloader: relayReloader, 83 | HealthChecker: healthChecker, 84 | 85 | e: e, 86 | l: l, 87 | cfg: cfg, 88 | connMgr: connMgr, 89 | addr: net.JoinHostPort(cfg.WebHost, fmt.Sprintf("%d", cfg.WebPort)), 90 | } 91 | 92 | setupRoutes(s) 93 | 94 | return s, nil 95 | } 96 | 97 | func validateConfig(cfg *config.Config) error { 98 | // Add validation logic here 99 | if cfg.WebPort <= 0 || cfg.WebPort > 65535 { 100 | return errors.New("invalid web port") 101 | } 102 | // Add more validations as needed 103 | return nil 104 | } 105 | 106 | func setupMiddleware(e *echo.Echo, cfg *config.Config, l *zap.SugaredLogger) error { 107 | e.Use(NginxLogMiddleware(l)) 108 | 109 | if cfg.WebToken != "" { 110 | e.Use(middleware.KeyAuthWithConfig(middleware.KeyAuthConfig{ 111 | KeyLookup: "query:token", 112 | Validator: func(key string, c echo.Context) (bool, error) { 113 | return key == cfg.WebToken, nil 114 | }, 115 | })) 116 | } 117 | 118 | if cfg.WebAuthUser != "" && cfg.WebAuthPass != "" { 119 | e.Use(middleware.BasicAuth(func(username, password string, c echo.Context) (bool, error) { 120 | if subtle.ConstantTimeCompare([]byte(username), []byte(cfg.WebAuthUser)) == 1 && 121 | subtle.ConstantTimeCompare([]byte(password), []byte(cfg.WebAuthPass)) == 1 { 122 | return true, nil 123 | } 124 | return false, nil 125 | })) 126 | } 127 | 128 | return nil 129 | } 130 | 131 | func setupTemplates(e *echo.Echo, l *zap.SugaredLogger, cfg *config.Config) error { 132 | funcMap := template.FuncMap{ 133 | "sub": func(a, b int) int { return a - b }, 134 | "add": func(a, b int) int { return a + b }, 135 | "CurrentCfg": func() *config.Config { 136 | return cfg 137 | }, 138 | } 139 | tmpl, err := template.New("").Funcs(funcMap).ParseFS(templatesFS, "templates/*.html") 140 | if err != nil { 141 | return errors.Wrap(err, "failed to parse templates") 142 | } 143 | templates := template.Must(tmpl, nil) 144 | for _, temp := range templates.Templates() { 145 | l.Debug("template name: ", temp.Name()) 146 | } 147 | e.Renderer = &echoTemplate{templates: templates} 148 | return nil 149 | } 150 | 151 | func setupMetrics(cfg *config.Config) error { 152 | if err := metrics.RegisterEhcoMetrics(cfg); err != nil { 153 | return errors.Wrap(err, "failed to register Ehco metrics") 154 | } 155 | if err := metrics.RegisterNodeExporterMetrics(cfg); err != nil { 156 | return errors.Wrap(err, "failed to register Node Exporter metrics") 157 | } 158 | return nil 159 | } 160 | 161 | func setupRoutes(s *Server) { 162 | e := s.e 163 | 164 | e.StaticFS("/js", echo.MustSubFS(templatesFS, "js")) 165 | e.GET(metricsPath, echo.WrapHandler(promhttp.Handler())) 166 | e.GET("/debug/pprof/*", echo.WrapHandler(http.DefaultServeMux)) 167 | 168 | // web pages 169 | e.GET(indexPath, s.index) 170 | e.GET(connectionsPath, s.ListConnections) 171 | e.GET(rulesPath, s.ListRules) 172 | e.GET("/rule_metrics/", s.RuleMetrics) 173 | e.GET("/logs/", s.LogsPage) 174 | 175 | api := e.Group(apiPrefix) 176 | api.GET("/config/", s.CurrentConfig) 177 | api.POST("/config/reload/", s.HandleReload) 178 | api.GET("/health_check/", s.HandleHealthCheck) 179 | api.GET("/node_metrics/", s.GetNodeMetrics) 180 | api.GET("/rule_metrics/", s.GetRuleMetrics) 181 | 182 | // ws 183 | e.GET("/ws/logs", s.handleWebSocketLogs) 184 | } 185 | 186 | func (s *Server) Start() error { 187 | s.l.Infof("Start Web Server at http://%s", s.addr) 188 | return s.e.Start(s.addr) 189 | } 190 | 191 | func (s *Server) Stop() error { 192 | return s.e.Close() 193 | } 194 | -------------------------------------------------------------------------------- /internal/web/templates/_head.html: -------------------------------------------------------------------------------- 1 | 2 | Ehco Web({{ (CurrentCfg).NodeLabel}}) 3 | 4 | 5 | 6 | 7 | 8 | 9 | 10 | 11 | 12 | 13 | 14 | 15 | 16 | -------------------------------------------------------------------------------- /internal/web/templates/_navbar.html: -------------------------------------------------------------------------------- 1 | 60 |
61 | 62 | 86 | -------------------------------------------------------------------------------- /internal/web/templates/_node_metrics_dash.html: -------------------------------------------------------------------------------- 1 |
2 |
3 |

Node Metrics

4 |
5 | 28 | 34 |
35 |
36 |
37 |
38 |
39 |
40 | 41 |
42 |
43 | 44 |
45 |
46 | 47 |
48 | 49 |
50 | 51 |
52 |
53 |
54 |
55 |
56 | 57 | 58 | 59 | -------------------------------------------------------------------------------- /internal/web/templates/_rule_metrics_dash.html: -------------------------------------------------------------------------------- 1 |
2 |
3 |

Rule Metrics

4 |
5 |
6 |
7 | 8 |
9 |
10 |
11 |
12 |
13 | 16 |
17 |
18 |
19 |
20 |
21 |
22 |
23 | 24 |
25 |
26 |
27 |
28 |
29 | 32 |
33 |
34 |
35 |
36 |
37 | 60 |
61 |
62 |
63 |
64 |
65 |
66 | 67 |
68 |
69 | 70 |
71 |
72 | 73 |
74 |
75 | 76 |
77 |
78 |
79 |
80 |
81 | 82 | 83 | -------------------------------------------------------------------------------- /internal/web/templates/connection.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | {{template "_head.html" .}} 4 | 5 | {{ template "_navbar.html" . }} 6 |
7 |
8 |

Connections

9 |

Total: {{.AllCount}}

10 | 11 |
12 | 20 |
21 | 22 | {{if gt (len .ConnectionList) 0}} 23 |
24 | 25 | 26 | 27 | 28 | 29 | 30 | 31 | 32 | 33 | 34 | 35 | {{range .ConnectionList}} 36 | 37 | 38 | 39 | 40 | 41 | 42 | 43 | {{end}} 44 | 45 |
LabelTypeFlowStatsTime
{{.RelayLabel}}{{.ConnType}}{{.GetFlow}}{{.Stats}}{{.GetTime}}
46 |
47 | {{else}} 48 |
49 |

No {{.ConnType}} connections available.

50 |
51 | {{end}} 52 | 53 | {{if gt .TotalPage 1}} 54 | 77 | {{end}} 78 |
79 |
80 | 81 | 87 | 88 | 102 | 103 | 104 | -------------------------------------------------------------------------------- /internal/web/templates/index.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | {{template "_head.html" .}} 4 | 5 | {{ template "_navbar.html" . }} 6 | 7 |
8 |
9 |
10 |
11 |
12 |

Build Info

13 |
14 |
15 |
16 |
    17 |
  • Version: {{.Version}}
  • 18 |
  • GitBranch: {{.GitBranch}}
  • 19 |
  • GitRevision: {{.GitRevision}}
  • 20 |
  • BuildTime: {{.BuildTime}}
  • 21 |
  • StartTime: {{.StartTime}}
  • 22 |
23 |
24 |
25 |

Latest Version: Checking...

26 | 27 |
28 |
29 |
30 | 34 | 38 |
39 |
40 |
41 |
42 | 43 | {{template "_node_metrics_dash.html" .}} 44 |
45 | 46 | 47 |
48 | 53 |
54 | 55 | 112 | 113 | 114 | -------------------------------------------------------------------------------- /internal/web/templates/logs.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | {{template "_head.html" .}} 4 | 22 | 23 | 24 | {{ template "_navbar.html" . }} 25 |
26 |
27 |

Real-time Logs

28 |
29 |
30 |
31 |
32 | 33 |
34 |
35 | 40 |
41 |
42 |
43 | 49 | 55 |
56 |
57 |
58 |
59 |
60 |
61 | 62 | 175 | 176 | 177 | -------------------------------------------------------------------------------- /internal/web/templates/rule_list.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | {{template "_head.html" .}} 4 | 5 | {{ template "_navbar.html" . }} 6 |
7 |
8 |

Rules

9 | 10 | 11 | 12 | 13 | 14 | 15 | 16 | 17 | 18 | 19 | 20 | 21 | {{range .Configs}} 22 | 23 | 24 | 25 | 26 | 27 | 28 | 33 | 34 | {{end}} 35 | 36 |
LabelListenListen TypeTransport TypeRemoteActions
{{.Label}}{{.Listen}}{{.ListenType}}{{.TransportType}}{{.Remotes}} 29 | 32 |
37 |
38 |
39 | 40 | 71 | 72 | 73 | -------------------------------------------------------------------------------- /internal/web/templates/rule_metrics.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | {{template "_head.html" .}} 4 | 5 | {{ template "_navbar.html" . }} 6 |
7 |
8 |

Rule Metrics

9 | 10 | {{template "_rule_metrics_dash.html" .}} 11 |
12 |
13 | 14 | 15 | -------------------------------------------------------------------------------- /internal/web/types.go: -------------------------------------------------------------------------------- 1 | package web 2 | 3 | type HealthCheckResp struct { 4 | ErrorCode int `json:"error_code"` // code = 0 means success 5 | Message string `json:"msg"` 6 | Latency int64 `json:"latency"` 7 | } 8 | -------------------------------------------------------------------------------- /internal/web/utils.go: -------------------------------------------------------------------------------- 1 | package web 2 | 3 | import "github.com/labstack/echo/v4" 4 | 5 | func NewEchoServer() *echo.Echo { 6 | e := echo.New() 7 | e.Debug = true 8 | e.HidePort = true 9 | e.HideBanner = true 10 | return e 11 | } 12 | -------------------------------------------------------------------------------- /monitor/ping.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/Ehco1996/ehco/0eb890694914484edb3726e643730fa4c3d39d49/monitor/ping.png -------------------------------------------------------------------------------- /monitor/prometheus.yaml: -------------------------------------------------------------------------------- 1 | global: 2 | scrape_interval: 15s 3 | scrape_timeout: 10s 4 | evaluation_interval: 1m 5 | scrape_configs: 6 | - job_name: "relay_nodes" 7 | scrape_interval: "30s" 8 | metrics_path: "/metrics/" 9 | static_configs: 10 | - targets: 11 | - 127.0.0.1:9000 12 | -------------------------------------------------------------------------------- /monitor/proxy-traffic.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/Ehco1996/ehco/0eb890694914484edb3726e643730fa4c3d39d49/monitor/proxy-traffic.png -------------------------------------------------------------------------------- /monitor/traffic.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/Ehco1996/ehco/0eb890694914484edb3726e643730fa4c3d39d49/monitor/traffic.png -------------------------------------------------------------------------------- /monitor/web.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/Ehco1996/ehco/0eb890694914484edb3726e643730fa4c3d39d49/monitor/web.png -------------------------------------------------------------------------------- /pkg/buffer/buffer.go: -------------------------------------------------------------------------------- 1 | package buffer 2 | 3 | import ( 4 | "github.com/Ehco1996/ehco/internal/constant" 5 | ) 6 | 7 | // 全局pool 8 | var ( 9 | BufferPool *BytePool 10 | UDPBufferPool *BytePool 11 | ) 12 | 13 | func init() { 14 | BufferPool = NewBytePool(constant.BUFFER_POOL_SIZE, constant.BUFFER_SIZE) 15 | UDPBufferPool = NewBytePool(constant.BUFFER_POOL_SIZE, constant.UDPBufSize) 16 | } 17 | 18 | // BytePool implements a leaky pool of []byte in the form of a bounded channel 19 | type BytePool struct { 20 | c chan []byte 21 | size int 22 | } 23 | 24 | // NewBytePool creates a new BytePool bounded to the given maxSize, with new 25 | // byte arrays sized based on width. 26 | func NewBytePool(maxSize int, size int) (bp *BytePool) { 27 | return &BytePool{ 28 | c: make(chan []byte, maxSize), 29 | size: size, 30 | } 31 | } 32 | 33 | // Get gets a []byte from the BytePool, or creates a new one if none are available in the pool. 34 | func (bp *BytePool) Get() (b []byte) { 35 | select { 36 | case b = <-bp.c: 37 | // reuse existing buffer 38 | default: 39 | // create new buffer 40 | b = make([]byte, bp.size) 41 | } 42 | return 43 | } 44 | 45 | // Put returns the given Buffer to the BytePool. 46 | func (bp *BytePool) Put(b []byte) { 47 | select { 48 | case bp.c <- b: 49 | // buffer went back into pool 50 | default: 51 | // buffer didn't go back into pool, just discard 52 | } 53 | } 54 | 55 | func ReplaceBufferPool(size int) { 56 | BufferPool = NewBytePool(constant.BUFFER_POOL_SIZE, size) 57 | } 58 | -------------------------------------------------------------------------------- /pkg/bytes/utils.go: -------------------------------------------------------------------------------- 1 | package bytes 2 | 3 | import ( 4 | "fmt" 5 | "math" 6 | ) 7 | 8 | func PrettyByteSize(bf float64) string { 9 | for _, unit := range []string{"", "Ki", "Mi", "Gi", "Ti", "Pi", "Ei", "Zi"} { 10 | if math.Abs(bf) < 1024.0 { 11 | return fmt.Sprintf(" %3.1f%sB ", bf, unit) 12 | } 13 | bf /= 1024.0 14 | } 15 | return fmt.Sprintf(" %.1fYiB ", bf) 16 | } 17 | 18 | func PrettyBitRate(bps float64) string { 19 | for _, unit := range []string{"bps", "Kbps", "Mbps", "Gbps", "Tbps", "Pbps", "Ebps", "Zbps"} { 20 | if math.Abs(bps) < 1000.0 { 21 | return fmt.Sprintf(" %3.1f %s ", bps, unit) 22 | } 23 | bps /= 1000.0 24 | } 25 | return fmt.Sprintf(" %.1f Ybps ", bps) 26 | } 27 | -------------------------------------------------------------------------------- /pkg/http/http.go: -------------------------------------------------------------------------------- 1 | package http 2 | 3 | import ( 4 | "bytes" 5 | "encoding/json" 6 | "io" 7 | 8 | "github.com/Ehco1996/ehco/pkg/log" 9 | "github.com/hashicorp/go-retryablehttp" 10 | ) 11 | 12 | func generateRetirableClient() *retryablehttp.Client { 13 | retryClient := retryablehttp.NewClient() 14 | retryClient.Logger = log.NewZapLeveledLogger("http") 15 | return retryClient 16 | } 17 | 18 | func PostJSONWithRetry(url string, dataStruct interface{}) error { 19 | retryClient := generateRetirableClient() 20 | 21 | buf := new(bytes.Buffer) 22 | if err := json.NewEncoder(buf).Encode(dataStruct); err != nil { 23 | return err 24 | } 25 | r, err := retryClient.Post(url, "application/json", buf) 26 | if err != nil { 27 | return err 28 | } 29 | defer r.Body.Close() 30 | _, err = io.ReadAll(r.Body) 31 | if err != nil { 32 | return err 33 | } 34 | return err 35 | } 36 | 37 | func GetJSONWithRetry(url string, dataStruct interface{}) error { 38 | retryClient := generateRetirableClient() 39 | resp, err := retryClient.Get(url) 40 | if err != nil { 41 | return err 42 | } 43 | defer resp.Body.Close() 44 | return json.NewDecoder(resp.Body).Decode(&dataStruct) 45 | } 46 | -------------------------------------------------------------------------------- /pkg/limiter/limiter.go: -------------------------------------------------------------------------------- 1 | package limiter 2 | 3 | import ( 4 | "sync" 5 | "time" 6 | 7 | "go.uber.org/zap" 8 | "golang.org/x/time/rate" 9 | ) 10 | 11 | const ( 12 | GCInterval = time.Minute 13 | ) 14 | 15 | type IPRateLimiter struct { 16 | sync.RWMutex 17 | 18 | // key: ip 19 | previousRateM map[string]*rate.Limiter 20 | currentRateM map[string]*rate.Limiter 21 | 22 | limit rate.Limit // 表示每秒可以放入多少个token到桶中 23 | burst int // 表示桶容量大小,即同一时刻能取到的最大token数量 24 | 25 | lastGcTime time.Time // 上次gc的时间 26 | 27 | logger *zap.Logger 28 | } 29 | 30 | // NewIPRateLimiter . 31 | func NewIPRateLimiter(limit rate.Limit, burst int, logger *zap.Logger) *IPRateLimiter { 32 | i := &IPRateLimiter{ 33 | previousRateM: make(map[string]*rate.Limiter), 34 | currentRateM: make(map[string]*rate.Limiter), 35 | limit: limit, 36 | burst: burst, 37 | lastGcTime: time.Now(), 38 | logger: logger, 39 | } 40 | return i 41 | } 42 | 43 | func (i *IPRateLimiter) GetOreCreateLimiter(ip string) *rate.Limiter { 44 | i.RLock() 45 | limiter, exists := i.currentRateM[ip] 46 | if exists { 47 | i.RUnlock() 48 | return limiter 49 | } 50 | i.RUnlock() 51 | 52 | i.Lock() 53 | defer i.Unlock() 54 | // check again maybe race by another thread 55 | if limiter, exists := i.currentRateM[ip]; exists { 56 | return limiter 57 | } 58 | 59 | // for gc 60 | if limiter, exists := i.previousRateM[ip]; exists { 61 | i.currentRateM[ip] = limiter 62 | delete(i.previousRateM, ip) 63 | return limiter 64 | } 65 | 66 | // init new one 67 | limiter = rate.NewLimiter(i.limit, i.burst) 68 | i.currentRateM[ip] = limiter 69 | return limiter 70 | } 71 | 72 | func (i *IPRateLimiter) gc() { 73 | i.Lock() 74 | defer i.Unlock() 75 | now := time.Now() 76 | i.logger.Info("[IPRateLimiter] gc start", zap.Int("alive count", len(i.currentRateM))) 77 | i.lastGcTime = now 78 | i.previousRateM = i.currentRateM 79 | i.currentRateM = make(map[string]*rate.Limiter) 80 | } 81 | 82 | func (i *IPRateLimiter) CanServe(ip string) bool { 83 | ipl := i.GetOreCreateLimiter(ip) 84 | if time.Since(i.lastGcTime) > GCInterval { 85 | i.gc() 86 | } 87 | return ipl.Allow() 88 | } 89 | -------------------------------------------------------------------------------- /pkg/limiter/limtier_test.go: -------------------------------------------------------------------------------- 1 | package limiter 2 | 3 | import ( 4 | "testing" 5 | "time" 6 | 7 | "go.uber.org/zap" 8 | "golang.org/x/time/rate" 9 | ) 10 | 11 | func TestIPRateLimiter_CanServe(t *testing.T) { 12 | ipr := NewIPRateLimiter(rate.Limit(1), 1, zap.NewExample()) // 1/s 处理一个请求 13 | 14 | ip1 := "1.1.1.1" 15 | ip2 := "1.2.2.2" 16 | 17 | if !ipr.CanServe(ip1) { 18 | t.Errorf("IPRateLimiter can't server ip=%s", ip1) 19 | } 20 | 21 | if ipr.CanServe(ip1) { 22 | t.Errorf("IPRateLimiter can server ip=%s in limit time", ip1) 23 | } 24 | 25 | if !ipr.CanServe(ip2) { 26 | t.Errorf("IPRateLimiter can't server ip=%s different ip should not affects each other", ip1) 27 | } 28 | 29 | time.Sleep(time.Second) 30 | if !ipr.CanServe(ip1) { 31 | t.Errorf("IPRateLimiter can't server ip=%s after sleep", ip1) 32 | } 33 | } 34 | -------------------------------------------------------------------------------- /pkg/log/leveld.go: -------------------------------------------------------------------------------- 1 | package log 2 | 3 | import ( 4 | "github.com/hashicorp/go-retryablehttp" 5 | "go.uber.org/zap" 6 | ) 7 | 8 | // ZapLeveledLogger wrapper for zap.logger for use with hashicorp's go-retryable LeveledLogger 9 | // port from https://github.com/hashicorp/go-retryablehttp/issues/182#issuecomment-1758011585 10 | type ZapLeveledLogger struct { 11 | Logger *zap.SugaredLogger 12 | } 13 | 14 | // New creates a ZapLeveledLogger with a zap.logger that satisfies standard library log.Logger interface. 15 | func NewZapLeveledLogger(name string) retryablehttp.LeveledLogger { 16 | if globalInitd { 17 | return &ZapLeveledLogger{Logger: zap.S().Named(name)} 18 | } 19 | logger, _ := initLogger("info", false) 20 | return &ZapLeveledLogger{Logger: logger.Sugar().Named(name)} 21 | } 22 | 23 | func (l *ZapLeveledLogger) Error(msg string, keysAndValues ...interface{}) { 24 | l.Logger.Errorw(msg, keysAndValues...) 25 | } 26 | 27 | func (l *ZapLeveledLogger) Info(msg string, keysAndValues ...interface{}) { 28 | l.Logger.Infow(msg, keysAndValues...) 29 | } 30 | 31 | func (l *ZapLeveledLogger) Debug(msg string, keysAndValues ...interface{}) { 32 | l.Logger.Debugw(msg, keysAndValues...) 33 | } 34 | 35 | func (l *ZapLeveledLogger) Warn(msg string, keysAndValues ...interface{}) { 36 | l.Logger.Warnw(msg, keysAndValues...) 37 | } 38 | -------------------------------------------------------------------------------- /pkg/log/log.go: -------------------------------------------------------------------------------- 1 | package log 2 | 3 | import ( 4 | "os" 5 | "sync" 6 | 7 | "go.uber.org/zap" 8 | "go.uber.org/zap/zapcore" 9 | ) 10 | 11 | var ( 12 | doOnce sync.Once 13 | globalInitd bool 14 | 15 | globalWebSocketSyncher *WebSocketLogSyncher 16 | ) 17 | 18 | func initLogger(logLevel string, replaceGlobal bool) (*zap.Logger, error) { 19 | level := zapcore.InfoLevel 20 | if err := level.UnmarshalText([]byte(logLevel)); err != nil { 21 | return nil, err 22 | } 23 | 24 | consoleEncoder := zapcore.NewConsoleEncoder(zapcore.EncoderConfig{ 25 | TimeKey: "ts", 26 | LevelKey: "level", 27 | MessageKey: "msg", 28 | NameKey: "name", 29 | EncodeLevel: zapcore.LowercaseColorLevelEncoder, 30 | EncodeTime: zapcore.RFC3339TimeEncoder, 31 | EncodeName: zapcore.FullNameEncoder, 32 | }) 33 | stdoutCore := zapcore.NewCore(consoleEncoder, zapcore.AddSync(os.Stdout), level) 34 | 35 | jsonEncoder := zapcore.NewJSONEncoder(zapcore.EncoderConfig{ 36 | TimeKey: "ts", 37 | LevelKey: "level", 38 | NameKey: "logger", 39 | CallerKey: "caller", 40 | MessageKey: "msg", 41 | StacktraceKey: "stacktrace", 42 | LineEnding: zapcore.DefaultLineEnding, 43 | EncodeLevel: zapcore.LowercaseLevelEncoder, 44 | EncodeTime: zapcore.ISO8601TimeEncoder, 45 | EncodeDuration: zapcore.SecondsDurationEncoder, 46 | EncodeCaller: zapcore.ShortCallerEncoder, 47 | }) 48 | 49 | globalWebSocketSyncher = NewWebSocketLogSyncher() 50 | wsCore := zapcore.NewCore(jsonEncoder, globalWebSocketSyncher, level) 51 | 52 | // 合并两个 core 53 | core := zapcore.NewTee(stdoutCore, wsCore) 54 | 55 | l := zap.New(core) 56 | if replaceGlobal { 57 | zap.ReplaceGlobals(l) 58 | } 59 | return l, nil 60 | } 61 | 62 | func InitGlobalLogger(logLevel string) error { 63 | var err error 64 | doOnce.Do(func() { 65 | _, err = initLogger(logLevel, true) 66 | globalInitd = true 67 | }) 68 | return err 69 | } 70 | 71 | func MustNewLogger(logLevel string) *zap.Logger { 72 | l, err := initLogger(logLevel, false) 73 | if err != nil { 74 | panic(err) 75 | } 76 | return l 77 | } 78 | -------------------------------------------------------------------------------- /pkg/log/ws.go: -------------------------------------------------------------------------------- 1 | package log 2 | 3 | import ( 4 | "encoding/json" 5 | "net" 6 | "sync" 7 | 8 | "github.com/gobwas/ws" 9 | ) 10 | 11 | type WebSocketLogSyncher struct { 12 | conn net.Conn 13 | mu sync.Mutex 14 | } 15 | 16 | func NewWebSocketLogSyncher() *WebSocketLogSyncher { 17 | return &WebSocketLogSyncher{} 18 | } 19 | 20 | func (wsSync *WebSocketLogSyncher) Write(p []byte) (n int, err error) { 21 | wsSync.mu.Lock() 22 | defer wsSync.mu.Unlock() 23 | 24 | if wsSync.conn != nil { 25 | var logEntry map[string]interface{} 26 | if err := json.Unmarshal(p, &logEntry); err == nil { 27 | jsonData, _ := json.Marshal(logEntry) 28 | _ = ws.WriteFrame(wsSync.conn, ws.NewTextFrame(jsonData)) 29 | } 30 | 31 | if err != nil { 32 | return 0, err 33 | } 34 | } 35 | return len(p), nil 36 | } 37 | 38 | func (wsSync *WebSocketLogSyncher) Sync() error { 39 | return nil 40 | } 41 | 42 | func (wsSync *WebSocketLogSyncher) SetWSConn(conn net.Conn) { 43 | wsSync.mu.Lock() 44 | defer wsSync.mu.Unlock() 45 | wsSync.conn = conn 46 | } 47 | 48 | func SetWebSocketConn(conn net.Conn) { 49 | if globalWebSocketSyncher != nil { 50 | globalWebSocketSyncher.SetWSConn(conn) 51 | } 52 | } 53 | -------------------------------------------------------------------------------- /pkg/metric_reader/reader.go: -------------------------------------------------------------------------------- 1 | package metric_reader 2 | 3 | import ( 4 | "context" 5 | "io" 6 | "net/http" 7 | "strings" 8 | "time" 9 | 10 | "github.com/pkg/errors" 11 | dto "github.com/prometheus/client_model/go" 12 | "github.com/prometheus/common/expfmt" 13 | "go.uber.org/zap" 14 | ) 15 | 16 | type Reader interface { 17 | ReadOnce(ctx context.Context) (*NodeMetrics, map[string]*RuleMetrics, error) 18 | } 19 | 20 | type readerImpl struct { 21 | metricsURL string 22 | httpClient *http.Client 23 | 24 | lastMetrics *NodeMetrics 25 | lastRuleMetrics map[string]*RuleMetrics // key: label value: RuleMetrics 26 | l *zap.SugaredLogger 27 | } 28 | 29 | func NewReader(metricsURL string) *readerImpl { 30 | c := &http.Client{Timeout: 30 * time.Second} 31 | return &readerImpl{ 32 | httpClient: c, 33 | metricsURL: metricsURL, 34 | l: zap.S().Named("metric_reader"), 35 | } 36 | } 37 | 38 | func (b *readerImpl) ReadOnce(ctx context.Context) (*NodeMetrics, map[string]*RuleMetrics, error) { 39 | metricMap, err := b.fetchMetrics(ctx) 40 | if err != nil { 41 | return nil, nil, errors.Wrap(err, "failed to fetch metrics") 42 | } 43 | nm := &NodeMetrics{SyncTime: time.Now()} 44 | if err := b.ParseNodeMetrics(metricMap, nm); err != nil { 45 | return nil, nil, err 46 | } 47 | 48 | rm := make(map[string]*RuleMetrics) 49 | if err := b.ParseRuleMetrics(metricMap, rm); err != nil { 50 | return nil, nil, err 51 | } 52 | 53 | b.lastMetrics = nm 54 | b.lastRuleMetrics = rm 55 | return nm, rm, nil 56 | } 57 | 58 | func (r *readerImpl) fetchMetrics(ctx context.Context) (map[string]*dto.MetricFamily, error) { 59 | req, err := http.NewRequestWithContext(ctx, "GET", r.metricsURL, nil) 60 | if err != nil { 61 | return nil, errors.Wrap(err, "failed to create request") 62 | } 63 | 64 | resp, err := r.httpClient.Do(req) 65 | if err != nil { 66 | return nil, errors.Wrap(err, "failed to send request") 67 | } 68 | defer resp.Body.Close() 69 | 70 | body, err := io.ReadAll(resp.Body) 71 | if err != nil { 72 | return nil, errors.Wrap(err, "failed to read response body") 73 | } 74 | 75 | var parser expfmt.TextParser 76 | return parser.TextToMetricFamilies(strings.NewReader(string(body))) 77 | } 78 | -------------------------------------------------------------------------------- /pkg/metric_reader/rule.go: -------------------------------------------------------------------------------- 1 | package metric_reader 2 | 3 | import ( 4 | "time" 5 | 6 | dto "github.com/prometheus/client_model/go" 7 | ) 8 | 9 | const ( 10 | metricConnectionCount = "ehco_traffic_current_connection_count" 11 | metricNetworkTransmit = "ehco_traffic_network_transmit_bytes" 12 | metricPingResponse = "ehco_ping_response_duration_milliseconds" 13 | metricHandshakeDuration = "ehco_traffic_handshake_duration_milliseconds" 14 | 15 | labelKey = "label" 16 | remoteKey = "remote" 17 | connTypeKey = "conn_type" 18 | flowKey = "flow" 19 | ipKey = "ip" 20 | ) 21 | 22 | type PingMetric struct { 23 | Latency int64 `json:"latency"` // in ms 24 | Target string `json:"target"` 25 | } 26 | 27 | type RuleMetrics struct { 28 | Label string // rule label 29 | 30 | PingMetrics map[string]*PingMetric // key: remote 31 | 32 | TCPConnectionCount map[string]int64 // key: remote 33 | TCPHandShakeDuration map[string]int64 // key: remote in ms 34 | TCPNetworkTransmitBytes map[string]int64 // key: remote 35 | 36 | UDPConnectionCount map[string]int64 // key: remote 37 | UDPHandShakeDuration map[string]int64 // key: remote in ms 38 | UDPNetworkTransmitBytes map[string]int64 // key: remote 39 | 40 | SyncTime time.Time 41 | } 42 | 43 | func (b *readerImpl) ParseRuleMetrics(metricMap map[string]*dto.MetricFamily, rm map[string]*RuleMetrics) error { 44 | requiredMetrics := []string{ 45 | metricConnectionCount, 46 | metricNetworkTransmit, 47 | metricPingResponse, 48 | metricHandshakeDuration, 49 | } 50 | 51 | for _, metricName := range requiredMetrics { 52 | metricFamily, ok := metricMap[metricName] 53 | if !ok { 54 | continue 55 | } 56 | 57 | for _, metric := range metricFamily.Metric { 58 | labels := getLabelMap(metric) 59 | value := int64(getMetricValue(metric, metricFamily.GetType())) 60 | label, ok := labels[labelKey] 61 | if !ok || label == "" { 62 | continue 63 | } 64 | 65 | ruleMetric := b.ensureRuleMetric(rm, label) 66 | 67 | switch metricName { 68 | case metricConnectionCount: 69 | b.updateConnectionCount(ruleMetric, labels, value) 70 | case metricNetworkTransmit: 71 | b.updateNetworkTransmit(ruleMetric, labels, value) 72 | case metricPingResponse: 73 | b.updatePingMetrics(ruleMetric, labels, value) 74 | case metricHandshakeDuration: 75 | b.updateHandshakeDuration(ruleMetric, labels, value) 76 | } 77 | } 78 | } 79 | return nil 80 | } 81 | 82 | func (b *readerImpl) ensureRuleMetric(rm map[string]*RuleMetrics, label string) *RuleMetrics { 83 | if _, ok := rm[label]; !ok { 84 | rm[label] = &RuleMetrics{ 85 | Label: label, 86 | PingMetrics: make(map[string]*PingMetric), 87 | TCPConnectionCount: make(map[string]int64), 88 | TCPHandShakeDuration: make(map[string]int64), 89 | TCPNetworkTransmitBytes: make(map[string]int64), 90 | UDPConnectionCount: make(map[string]int64), 91 | UDPHandShakeDuration: make(map[string]int64), 92 | UDPNetworkTransmitBytes: make(map[string]int64), 93 | 94 | SyncTime: time.Now(), 95 | } 96 | } 97 | return rm[label] 98 | } 99 | 100 | func (b *readerImpl) updateConnectionCount(rm *RuleMetrics, labels map[string]string, value int64) { 101 | key := labels[remoteKey] 102 | switch labels[connTypeKey] { 103 | case "tcp": 104 | rm.TCPConnectionCount[key] = value 105 | default: 106 | rm.UDPConnectionCount[key] = value 107 | } 108 | } 109 | 110 | func (b *readerImpl) updateNetworkTransmit(rm *RuleMetrics, labels map[string]string, value int64) { 111 | if labels[flowKey] == "read" { 112 | key := labels[remoteKey] 113 | switch labels[connTypeKey] { 114 | case "tcp": 115 | rm.TCPNetworkTransmitBytes[key] += value 116 | default: 117 | rm.UDPNetworkTransmitBytes[key] += value 118 | } 119 | } 120 | } 121 | 122 | func (b *readerImpl) updatePingMetrics(rm *RuleMetrics, labels map[string]string, value int64) { 123 | remote := labels[remoteKey] 124 | rm.PingMetrics[remote] = &PingMetric{ 125 | Latency: value, 126 | Target: labels[ipKey], 127 | } 128 | } 129 | 130 | func (b *readerImpl) updateHandshakeDuration(rm *RuleMetrics, labels map[string]string, value int64) { 131 | key := labels[remoteKey] 132 | switch labels[connTypeKey] { 133 | case "tcp": 134 | rm.TCPHandShakeDuration[key] = value 135 | default: 136 | rm.UDPHandShakeDuration[key] = value 137 | } 138 | } 139 | 140 | func getLabelMap(metric *dto.Metric) map[string]string { 141 | labels := make(map[string]string) 142 | for _, label := range metric.Label { 143 | labels[label.GetName()] = label.GetValue() 144 | } 145 | return labels 146 | } 147 | -------------------------------------------------------------------------------- /pkg/metric_reader/utils.go: -------------------------------------------------------------------------------- 1 | package metric_reader 2 | 3 | import ( 4 | "math" 5 | 6 | dto "github.com/prometheus/client_model/go" 7 | ) 8 | 9 | func calculatePercentile(histogram *dto.Histogram, percentile float64) float64 { 10 | if histogram == nil { 11 | return 0 12 | } 13 | totalSamples := histogram.GetSampleCount() 14 | targetSample := percentile * float64(totalSamples) 15 | cumulativeCount := uint64(0) 16 | var lastBucketBound float64 17 | 18 | for _, bucket := range histogram.Bucket { 19 | cumulativeCount += bucket.GetCumulativeCount() 20 | if float64(cumulativeCount) >= targetSample { 21 | // Linear interpolation between bucket boundaries 22 | if bucket.GetCumulativeCount() > 0 && lastBucketBound != bucket.GetUpperBound() { 23 | return lastBucketBound + (float64(targetSample-float64(cumulativeCount-bucket.GetCumulativeCount()))/float64(bucket.GetCumulativeCount()))*(bucket.GetUpperBound()-lastBucketBound) 24 | } else { 25 | return bucket.GetUpperBound() 26 | } 27 | } 28 | lastBucketBound = bucket.GetUpperBound() 29 | } 30 | return math.NaN() 31 | } 32 | 33 | func getMetricValue(metric *dto.Metric, metricType dto.MetricType) float64 { 34 | switch metricType { 35 | case dto.MetricType_COUNTER: 36 | return metric.Counter.GetValue() 37 | case dto.MetricType_GAUGE: 38 | return metric.Gauge.GetValue() 39 | case dto.MetricType_HISTOGRAM: 40 | histogram := metric.Histogram 41 | if histogram != nil { 42 | return calculatePercentile(histogram, 0.9) 43 | } 44 | } 45 | return 0 46 | } 47 | -------------------------------------------------------------------------------- /pkg/xray/bandwidth_recorder.go: -------------------------------------------------------------------------------- 1 | package xray 2 | 3 | import ( 4 | "context" 5 | "io" 6 | "net/http" 7 | "strconv" 8 | "strings" 9 | "time" 10 | ) 11 | 12 | const ( 13 | netWorkSendMetric = "node_network_transmit_bytes_total" 14 | netWorkRecvMetric = "node_network_receive_bytes_total" 15 | ) 16 | 17 | type bandwidthRecorder struct { 18 | currentSendBytes float64 19 | uploadBandwidthBytes float64 20 | 21 | currentRecvBytes float64 22 | downloadBandwidthBytes float64 23 | 24 | lastRecordTime time.Time 25 | 26 | httpClient *http.Client 27 | metricsURL string 28 | } 29 | 30 | func NewBandwidthRecorder(metricsURL string) *bandwidthRecorder { 31 | c := &http.Client{Timeout: 30 * time.Second} 32 | return &bandwidthRecorder{ 33 | httpClient: c, 34 | metricsURL: metricsURL, 35 | } 36 | } 37 | 38 | func (b *bandwidthRecorder) RecordOnce(ctx context.Context) (uploadIncr float64, downloadIncr float64, err error) { 39 | response, err := b.httpClient.Get(b.metricsURL) 40 | if err != nil { 41 | return 42 | } 43 | defer response.Body.Close() 44 | 45 | body, err := io.ReadAll(response.Body) 46 | if err != nil { 47 | return 48 | } 49 | lines := strings.Split(string(body), "\n") 50 | 51 | var send float64 52 | var recv float64 53 | 54 | for _, line := range lines { 55 | if strings.HasPrefix(line, netWorkSendMetric) { 56 | parts := strings.Split(line, " ") 57 | if len(parts) >= 2 { 58 | value := parts[1] 59 | send += parseFloat(value) 60 | } 61 | } 62 | 63 | if strings.HasPrefix(line, netWorkRecvMetric) { 64 | parts := strings.Split(line, " ") 65 | if len(parts) >= 2 { 66 | value := parts[1] 67 | recv += parseFloat(value) 68 | } 69 | } 70 | } 71 | 72 | now := time.Now() 73 | if !b.lastRecordTime.IsZero() { 74 | // calculate bandwidth 75 | elapsed := now.Sub(b.lastRecordTime).Seconds() 76 | uploadIncr = (send - b.currentSendBytes) 77 | downloadIncr = (recv - b.currentRecvBytes) 78 | b.uploadBandwidthBytes = uploadIncr / elapsed 79 | b.downloadBandwidthBytes = downloadIncr / elapsed 80 | } 81 | b.lastRecordTime = now 82 | b.currentRecvBytes = recv 83 | b.currentSendBytes = send 84 | return 85 | } 86 | 87 | func parseFloat(s string) float64 { 88 | value, _ := strconv.ParseFloat(s, 64) 89 | return value 90 | } 91 | 92 | func (b *bandwidthRecorder) GetDownloadBandwidth() float64 { 93 | return b.downloadBandwidthBytes 94 | } 95 | 96 | func (b *bandwidthRecorder) GetUploadBandwidth() float64 { 97 | return b.uploadBandwidthBytes 98 | } 99 | -------------------------------------------------------------------------------- /pkg/xray/const.go: -------------------------------------------------------------------------------- 1 | package xray 2 | 3 | const ( 4 | XrayAPITag = "api" 5 | XraySSProxyTag = "ss_proxy" 6 | XrayTrojanProxyTag = "trojan_proxy" 7 | XrayVmessProxyTag = "vmess_proxy" 8 | XrayVlessProxyTag = "vless_proxy" 9 | XraySSRProxyTag = "ssr_proxy" 10 | 11 | SyncTime = 60 12 | 13 | ProtocolSS = "ss" 14 | ProtocolTrojan = "trojan" 15 | ) 16 | 17 | func InProxyTags(tag string) bool { 18 | return tag == XraySSProxyTag || tag == XrayTrojanProxyTag || 19 | tag == XrayVmessProxyTag || tag == XrayVlessProxyTag || 20 | tag == XraySSRProxyTag 21 | } 22 | -------------------------------------------------------------------------------- /pkg/xray/services.go: -------------------------------------------------------------------------------- 1 | package xray 2 | 3 | import ( 4 | "context" 5 | "strings" 6 | 7 | proxy "github.com/xtls/xray-core/app/proxyman/command" 8 | "github.com/xtls/xray-core/common/serial" 9 | "github.com/xtls/xray-core/proxy/shadowsocks" 10 | "go.uber.org/zap" 11 | ) 12 | 13 | func getEmailAndTrafficType(input string) (string, string) { 14 | s := strings.Split(input, ">>>") 15 | return s[1], s[len(s)-1] 16 | } 17 | 18 | func mappingCipher(in string) shadowsocks.CipherType { 19 | switch in { 20 | case "aes-128-gcm": 21 | return shadowsocks.CipherType_AES_128_GCM 22 | case "aes-256-gcm": 23 | return shadowsocks.CipherType_AES_256_GCM 24 | case "chacha20-ietf-poly1305": 25 | return shadowsocks.CipherType_CHACHA20_POLY1305 26 | } 27 | return shadowsocks.CipherType_UNKNOWN 28 | } 29 | 30 | // AddInboundUser add user to inbound by tag 31 | func AddInboundUser(ctx context.Context, c proxy.HandlerServiceClient, tag string, user *User) error { 32 | _, err := c.AlterInbound(ctx, &proxy.AlterInboundRequest{ 33 | Tag: tag, 34 | Operation: serial.ToTypedMessage( 35 | &proxy.AddUserOperation{User: user.ToXrayUser()}), 36 | }) 37 | if err != nil { 38 | zap.S().Named("xray").Errorf("Failed to Add User: %s To Server Tag: %s", user.GetEmail(), tag) 39 | return err 40 | } 41 | user.running = true 42 | zap.S().Named("xray").Infof("Add User: %s To Server Tag: %s", user.GetEmail(), tag) 43 | return nil 44 | } 45 | 46 | // RemoveInboundUser remove user from inbound by tag 47 | func RemoveInboundUser(ctx context.Context, c proxy.HandlerServiceClient, tag string, user *User) error { 48 | _, err := c.AlterInbound(ctx, &proxy.AlterInboundRequest{ 49 | Tag: tag, 50 | Operation: serial.ToTypedMessage(&proxy.RemoveUserOperation{ 51 | Email: user.GetEmail(), 52 | }), 53 | }) 54 | 55 | // mute not found error 56 | if err != nil && strings.Contains(err.Error(), "not found") { 57 | zap.S().Named("xray").Warnf("User Not Found %s", user.GetEmail()) 58 | err = nil 59 | } 60 | 61 | if err != nil { 62 | zap.S().Named("xray").Error("Failed to Remove User: %s To Server", user.GetEmail()) 63 | return err 64 | } 65 | user.running = false 66 | zap.S().Named("xray").Infof("[xray] Remove User: %v From Server", user.ID) 67 | return nil 68 | } 69 | -------------------------------------------------------------------------------- /pkg/xray/utils.go: -------------------------------------------------------------------------------- 1 | package xray 2 | 3 | import ( 4 | "bytes" 5 | "encoding/json" 6 | "io" 7 | "net/http" 8 | ) 9 | 10 | func getJson(c *http.Client, url string, target interface{}) error { 11 | r, err := c.Get(url) 12 | if err != nil { 13 | return err 14 | } 15 | defer r.Body.Close() 16 | return json.NewDecoder(r.Body).Decode(target) 17 | } 18 | 19 | func postJson(c *http.Client, url string, dataStruct interface{}) error { 20 | buf := new(bytes.Buffer) 21 | if err := json.NewEncoder(buf).Encode(dataStruct); err != nil { 22 | return err 23 | } 24 | r, err := http.Post(url, "application/json", buf) 25 | if err != nil { 26 | return err 27 | } 28 | defer r.Body.Close() 29 | _, err = io.ReadAll(r.Body) 30 | return err 31 | } 32 | -------------------------------------------------------------------------------- /test/bench/ehco_config.json: -------------------------------------------------------------------------------- 1 | { 2 | "web_port": 9000, 3 | "web_token": "", 4 | "enable_ping": false, 5 | "relay_configs": [ 6 | { 7 | "listen": "127.0.0.1:1234", 8 | "listen_type": "raw", 9 | "transport_type": "raw", 10 | "tcp_remotes": [ 11 | "0.0.0.0:1090" 12 | ] 13 | }, 14 | { 15 | "listen": "127.0.0.1:1235", 16 | "listen_type": "raw", 17 | "transport_type": "ws", 18 | "tcp_remotes": [ 19 | "ws://0.0.0.0:2443" 20 | ] 21 | }, 22 | { 23 | "listen": "127.0.0.1:1236", 24 | "listen_type": "raw", 25 | "transport_type": "wss", 26 | "tcp_remotes": [ 27 | "wss://0.0.0.0:3443" 28 | ] 29 | }, 30 | { 31 | "listen": "127.0.0.1:1237", 32 | "listen_type": "raw", 33 | "transport_type": "mwss", 34 | "tcp_remotes": [ 35 | "wss://0.0.0.0:4443" 36 | ] 37 | }, 38 | { 39 | "listen": "127.0.0.1:2443", 40 | "listen_type": "ws", 41 | "transport_type": "raw", 42 | "tcp_remotes": [ 43 | "0.0.0.0:1090" 44 | ] 45 | }, 46 | { 47 | "listen": "127.0.0.1:3443", 48 | "listen_type": "wss", 49 | "transport_type": "raw", 50 | "tcp_remotes": [ 51 | "0.0.0.0:1090" 52 | ] 53 | }, 54 | { 55 | "listen": "127.0.0.1:4443", 56 | "listen_type": "mwss", 57 | "transport_type": "raw", 58 | "tcp_remotes": [ 59 | "0.0.0.0:1090" 60 | ] 61 | } 62 | ], 63 | "xray_config": { 64 | "log": { 65 | "loglevel": "info" 66 | }, 67 | "inbounds": [ 68 | { 69 | "listen": "127.0.0.1", 70 | "port": 8488, 71 | "protocol": "shadowsocks", 72 | "tag": "ss_proxy", 73 | "settings": { 74 | "clients": [ 75 | { 76 | "password": "your-password", 77 | "method": "chacha20-poly1305" 78 | } 79 | ], 80 | "network": "tcp,udp" 81 | } 82 | } 83 | ], 84 | "outbounds": [ 85 | { 86 | "protocol": "freedom", 87 | "settings": {} 88 | } 89 | ] 90 | } 91 | } -------------------------------------------------------------------------------- /test/bench/readme.md: -------------------------------------------------------------------------------- 1 | 2 | # setup env 3 | 4 | 1. run `iperf3` server by `iperf3 -s` 5 | 2. start ss client by `start_ss_client.sh` 6 | 3. start ehco and xray(ss) server by `go run cmd/ehco/main.go -c test/bench/ehco_config.json` 7 | 8 | # run test 9 | 10 | * raw ss `iperf3 -c 127.0.0.1 -p 1090` 11 | 12 | ```bash 13 | Connecting to host 127.0.0.1, port 1090 14 | [ 5] local 127.0.0.1 port 51860 connected to 127.0.0.1 port 1090 15 | [ ID] Interval Transfer Bitrate 16 | [ 5] 0.00-1.00 sec 605 MBytes 5.08 Gbits/sec 17 | [ 5] 1.00-2.00 sec 621 MBytes 5.21 Gbits/sec 18 | [ 5] 2.00-3.00 sec 621 MBytes 5.21 Gbits/sec 19 | [ 5] 3.00-4.00 sec 622 MBytes 5.22 Gbits/sec 20 | [ 5] 4.00-5.00 sec 616 MBytes 5.17 Gbits/sec 21 | [ 5] 5.00-6.00 sec 621 MBytes 5.21 Gbits/sec 22 | [ 5] 6.00-7.00 sec 618 MBytes 5.18 Gbits/sec 23 | [ 5] 7.00-8.00 sec 621 MBytes 5.21 Gbits/sec 24 | [ 5] 8.00-9.00 sec 619 MBytes 5.20 Gbits/sec 25 | [ 5] 9.00-10.00 sec 613 MBytes 5.14 Gbits/sec 26 | - - - - - - - - - - - - - - - - - - - - - - - - - 27 | [ ID] Interval Transfer Bitrate 28 | [ 5] 0.00-10.00 sec 6.03 GBytes 5.18 Gbits/sec sender 29 | [ 5] 0.00-10.00 sec 6.03 GBytes 5.18 Gbits/sec receiver 30 | ``` 31 | 32 | * ehco raw `iperf3 -c 127.0.0.1 -p 1234` 33 | 34 | ```bash 35 | Connecting to host 127.0.0.1, port 1234 36 | [ 5] local 127.0.0.1 port 53027 connected to 127.0.0.1 port 1234 37 | [ ID] Interval Transfer Bitrate 38 | [ 5] 0.00-1.00 sec 616 MBytes 5.17 Gbits/sec 39 | [ 5] 1.00-2.00 sec 603 MBytes 5.06 Gbits/sec 40 | [ 5] 2.00-3.00 sec 612 MBytes 5.14 Gbits/sec 41 | [ 5] 3.00-4.00 sec 616 MBytes 5.17 Gbits/sec 42 | [ 5] 4.00-5.00 sec 616 MBytes 5.17 Gbits/sec 43 | [ 5] 5.00-6.00 sec 614 MBytes 5.15 Gbits/sec 44 | [ 5] 6.00-7.00 sec 615 MBytes 5.15 Gbits/sec 45 | [ 5] 7.00-8.00 sec 616 MBytes 5.17 Gbits/sec 46 | [ 5] 8.00-9.00 sec 609 MBytes 5.11 Gbits/sec 47 | [ 5] 9.00-10.00 sec 618 MBytes 5.18 Gbits/sec 48 | - - - - - - - - - - - - - - - - - - - - - - - - - 49 | [ ID] Interval Transfer Bitrate 50 | [ 5] 0.00-10.00 sec 5.99 GBytes 5.15 Gbits/sec sender 51 | [ 5] 0.00-10.00 sec 5.98 GBytes 5.14 Gbits/sec receiver 52 | 53 | iperf Done. 54 | ``` 55 | 56 | * ehco over ws `iperf3 -c 127.0.0.1 -p 1235` 57 | 58 | ```bash 59 | Connecting to host 127.0.0.1, port 1235 60 | [ 5] local 127.0.0.1 port 53778 connected to 127.0.0.1 port 1235 61 | [ ID] Interval Transfer Bitrate 62 | [ 5] 0.00-1.00 sec 610 MBytes 5.11 Gbits/sec 63 | [ 5] 1.00-2.00 sec 588 MBytes 4.94 Gbits/sec 64 | [ 5] 2.00-3.00 sec 594 MBytes 4.98 Gbits/sec 65 | [ 5] 3.00-4.00 sec 594 MBytes 4.98 Gbits/sec 66 | [ 5] 4.00-5.00 sec 593 MBytes 4.97 Gbits/sec 67 | [ 5] 5.00-6.00 sec 588 MBytes 4.94 Gbits/sec 68 | [ 5] 6.00-7.00 sec 593 MBytes 4.97 Gbits/sec 69 | [ 5] 7.00-8.00 sec 593 MBytes 4.98 Gbits/sec 70 | [ 5] 8.00-9.00 sec 593 MBytes 4.98 Gbits/sec 71 | [ 5] 9.00-10.00 sec 595 MBytes 4.99 Gbits/sec 72 | - - - - - - - - - - - - - - - - - - - - - - - - - 73 | [ ID] Interval Transfer Bitrate 74 | [ 5] 0.00-10.00 sec 5.80 GBytes 4.98 Gbits/sec sender 75 | [ 5] 0.00-10.00 sec 5.78 GBytes 4.96 Gbits/sec receiver 76 | 77 | iperf Done. 78 | ``` 79 | 80 | * ehco over wss `iperf3 -c 127.0.0.1 -p 1236` 81 | 82 | ```bash 83 | Connecting to host 127.0.0.1, port 1236 84 | [ 5] local 127.0.0.1 port 53866 connected to 127.0.0.1 port 1236 85 | [ ID] Interval Transfer Bitrate 86 | [ 5] 0.00-1.00 sec 573 MBytes 4.81 Gbits/sec 87 | [ 5] 1.00-2.00 sec 569 MBytes 4.78 Gbits/sec 88 | [ 5] 2.00-3.00 sec 575 MBytes 4.82 Gbits/sec 89 | [ 5] 3.00-4.00 sec 585 MBytes 4.91 Gbits/sec 90 | [ 5] 4.00-5.00 sec 590 MBytes 4.95 Gbits/sec 91 | [ 5] 5.00-6.00 sec 586 MBytes 4.92 Gbits/sec 92 | [ 5] 6.00-7.00 sec 587 MBytes 4.93 Gbits/sec 93 | [ 5] 7.00-8.00 sec 590 MBytes 4.95 Gbits/sec 94 | [ 5] 8.00-9.00 sec 587 MBytes 4.93 Gbits/sec 95 | [ 5] 9.00-10.00 sec 580 MBytes 4.87 Gbits/sec 96 | - - - - - - - - - - - - - - - - - - - - - - - - - 97 | [ ID] Interval Transfer Bitrate 98 | [ 5] 0.00-10.00 sec 5.69 GBytes 4.88 Gbits/sec sender 99 | [ 5] 0.00-10.00 sec 5.68 GBytes 4.88 Gbits/sec receiver 100 | 101 | iperf Done. 102 | ``` 103 | 104 | * ehco over mwss `iperf3 -c 127.0.0.1 -p 1237` 105 | 106 | ```bash 107 | Connecting to host 127.0.0.1, port 1237 108 | [ 5] local 127.0.0.1 port 54878 connected to 127.0.0.1 port 1237 109 | [ ID] Interval Transfer Bitrate 110 | [ 5] 0.00-1.00 sec 555 MBytes 4.65 Gbits/sec 111 | [ 5] 1.00-2.00 sec 542 MBytes 4.55 Gbits/sec 112 | [ 5] 2.00-3.00 sec 555 MBytes 4.66 Gbits/sec 113 | [ 5] 3.00-4.00 sec 549 MBytes 4.61 Gbits/sec 114 | [ 5] 4.00-5.00 sec 485 MBytes 4.07 Gbits/sec 115 | [ 5] 5.00-6.00 sec 514 MBytes 4.31 Gbits/sec 116 | [ 5] 6.00-7.00 sec 555 MBytes 4.66 Gbits/sec 117 | [ 5] 7.00-8.00 sec 555 MBytes 4.65 Gbits/sec 118 | [ 5] 8.00-9.00 sec 541 MBytes 4.54 Gbits/sec 119 | [ 5] 9.00-10.00 sec 533 MBytes 4.47 Gbits/sec 120 | - - - - - - - - - - - - - - - - - - - - - - - - - 121 | [ ID] Interval Transfer Bitrate 122 | [ 5] 0.00-10.00 sec 5.26 GBytes 4.52 Gbits/sec sender 123 | [ 5] 0.00-10.02 sec 5.25 GBytes 4.50 Gbits/sec receiver 124 | 125 | iperf Done. 126 | ``` 127 | -------------------------------------------------------------------------------- /test/bench/start_ss_client.sh: -------------------------------------------------------------------------------- 1 | echo "start ss in tcp tun mode." 2 | 3 | go-shadowsocks2 -c 'ss://AEAD_CHACHA20_POLY1305:your-password@[0.0.0.0]:8488' -tcptun :1090=0.0.0.0:5201 -verbose 4 | -------------------------------------------------------------------------------- /test/cmd/echo/main.go: -------------------------------------------------------------------------------- 1 | package main 2 | 3 | import ( 4 | "log" 5 | 6 | "github.com/Ehco1996/ehco/test/echo" 7 | ) 8 | 9 | func main() { 10 | log.Println("start tcp.udp echo server at: 0.0.0.0:2333") 11 | es := echo.NewEchoServer("0.0.0.0", 2333) 12 | _ = es.Run() 13 | } 14 | -------------------------------------------------------------------------------- /test/cmd/tcp_client/main.go: -------------------------------------------------------------------------------- 1 | package main 2 | 3 | import ( 4 | "time" 5 | 6 | "github.com/Ehco1996/ehco/test/echo" 7 | ) 8 | 9 | func main() { 10 | msg := []byte("hello") 11 | 12 | echoServerAddr := "127.0.0.1:2333" 13 | relayAddr := "127.0.0.1:2234" 14 | println("real echo server at:", echoServerAddr, "relay addr:", relayAddr) 15 | 16 | ret := echo.SendTcpMsg(msg, relayAddr) 17 | if string(ret) != "hello" { 18 | panic("relay short failed") 19 | } 20 | println("test short conn success, hello sended and received") 21 | 22 | if err := echo.EchoTcpMsgLong(msg, time.Second, relayAddr); err != nil { 23 | panic("relay long failed:" + err.Error()) 24 | } 25 | println("test long conn success") 26 | } 27 | -------------------------------------------------------------------------------- /test/echo/echo.go: -------------------------------------------------------------------------------- 1 | //nolint:errcheck 2 | package echo 3 | 4 | import ( 5 | "errors" 6 | "fmt" 7 | "io" 8 | "log" 9 | "net" 10 | "strconv" 11 | "sync" 12 | "time" 13 | 14 | "go.uber.org/zap" 15 | ) 16 | 17 | type EchoServer struct { 18 | host string 19 | port int 20 | tcpListener net.Listener 21 | udpConn *net.UDPConn 22 | stopChan chan struct{} 23 | wg sync.WaitGroup 24 | logger *zap.SugaredLogger 25 | } 26 | 27 | func NewEchoServer(host string, port int) *EchoServer { 28 | return &EchoServer{ 29 | host: host, 30 | port: port, 31 | stopChan: make(chan struct{}), 32 | logger: zap.S().Named("echo-test-server"), 33 | } 34 | } 35 | 36 | func (s *EchoServer) Run() error { 37 | addr := s.host + ":" + strconv.Itoa(s.port) 38 | var err error 39 | 40 | // Start TCP server 41 | s.tcpListener, err = net.Listen("tcp", addr) 42 | if err != nil { 43 | return fmt.Errorf("failed to start TCP server: %w", err) 44 | } 45 | 46 | // Start UDP server 47 | udpAddr := net.UDPAddr{IP: net.ParseIP(s.host), Port: s.port} 48 | s.udpConn, err = net.ListenUDP("udp", &udpAddr) 49 | if err != nil { 50 | return fmt.Errorf("failed to start UDP server: %w", err) 51 | } 52 | 53 | s.logger.Infof("Echo server started at: %s", addr) 54 | 55 | s.wg.Add(2) 56 | go s.serveTCP() 57 | go s.serveUDP() 58 | 59 | return nil 60 | } 61 | 62 | func (s *EchoServer) Stop() { 63 | close(s.stopChan) 64 | if s.tcpListener != nil { 65 | s.tcpListener.Close() 66 | } 67 | if s.udpConn != nil { 68 | s.udpConn.Close() 69 | } 70 | s.wg.Wait() 71 | s.logger.Info("Echo server stopped") 72 | } 73 | 74 | func (s *EchoServer) serveTCP() { 75 | defer s.wg.Done() 76 | for { 77 | select { 78 | case <-s.stopChan: 79 | return 80 | default: 81 | conn, err := s.tcpListener.Accept() 82 | if err != nil { 83 | select { 84 | case <-s.stopChan: 85 | return 86 | default: 87 | s.logger.Errorf("Failed to accept TCP connection: %v", err) 88 | } 89 | continue 90 | } 91 | go s.handleTCPConn(conn) 92 | } 93 | } 94 | } 95 | 96 | func (s *EchoServer) handleTCPConn(conn net.Conn) { 97 | defer conn.Close() 98 | s.logger.Infof("New TCP connection from: %s", conn.RemoteAddr()) 99 | 100 | buf := make([]byte, 1024) 101 | for { 102 | n, err := conn.Read(buf) 103 | if err == io.EOF { 104 | s.logger.Infof("Connection closed by client: %s", conn.RemoteAddr()) 105 | return 106 | } 107 | if err != nil { 108 | s.logger.Errorf("Error reading from connection: %v", err) 109 | return 110 | } 111 | 112 | s.logger.Infof("Received from %s: %s", conn.RemoteAddr(), string(buf[:n])) 113 | 114 | _, err = conn.Write(buf[:n]) 115 | if err != nil { 116 | s.logger.Errorf("Error writing to connection: %v", err) 117 | return 118 | } 119 | } 120 | } 121 | 122 | func isClosedConnError(err error) bool { 123 | return errors.Is(err, net.ErrClosed) 124 | } 125 | 126 | func (s *EchoServer) serveUDP() { 127 | defer s.wg.Done() 128 | buf := make([]byte, 1024) 129 | for { 130 | select { 131 | case <-s.stopChan: 132 | return 133 | default: 134 | n, remoteAddr, err := s.udpConn.ReadFromUDP(buf) 135 | if err != nil { 136 | if isClosedConnError(err) { 137 | break 138 | } 139 | s.logger.Errorf("Error reading UDP: %v", err) 140 | continue 141 | } 142 | 143 | s.logger.Infof("Received UDP from %s: %s", remoteAddr, string(buf[:n])) 144 | 145 | _, err = s.udpConn.WriteToUDP(buf[:n], remoteAddr) 146 | if err != nil { 147 | s.logger.Errorf("Error writing UDP: %v", err) 148 | } 149 | } 150 | } 151 | } 152 | 153 | func SendTcpMsg(msg []byte, address string) []byte { 154 | conn, err := net.Dial("tcp", address) 155 | if err != nil { 156 | log.Fatal(err) 157 | } 158 | println("conn start", conn.RemoteAddr().String(), conn.LocalAddr().String()) 159 | if _, err := conn.Write(msg); err != nil { 160 | log.Fatal(err) 161 | } 162 | time.Sleep(time.Second * 1) 163 | buf := make([]byte, len(msg)) 164 | n, err := conn.Read(buf) 165 | if err != nil { 166 | log.Fatal(err) 167 | } 168 | conn.Close() 169 | println("conn closed", conn.RemoteAddr().String()) 170 | return buf[:n] 171 | } 172 | 173 | func EchoTcpMsgLong(msg []byte, sleepTime time.Duration, address string) error { 174 | logger := zap.S() 175 | buf := make([]byte, len(msg)) 176 | conn, err := net.Dial("tcp", address) 177 | if err != nil { 178 | return err 179 | } 180 | defer conn.Close() 181 | logger.Infof("conn start %s %s", conn.RemoteAddr().String(), conn.LocalAddr().String()) 182 | for i := 0; i < 10; i++ { 183 | if _, err := conn.Write(msg); err != nil { 184 | return err 185 | } 186 | n, err := conn.Read(buf) 187 | if err != nil { 188 | return err 189 | } 190 | if string(buf[:n]) != string(msg) { 191 | return fmt.Errorf("msg not equal at %d send:%s receive:%s n:%d", i, msg, buf[:n], n) 192 | } 193 | // to fake a long connection 194 | time.Sleep(sleepTime) 195 | } 196 | logger.Infof("conn closed %s %s", conn.RemoteAddr().String(), conn.LocalAddr().String()) 197 | return nil 198 | } 199 | 200 | func SendUdpMsg(msg []byte, address string) []byte { 201 | conn, err := net.Dial("udp", address) 202 | if err != nil { 203 | log.Fatal(err) 204 | } 205 | defer conn.Close() 206 | if _, err := conn.Write(msg); err != nil { 207 | log.Fatal(err) 208 | } 209 | buf := make([]byte, len(msg)) 210 | time.Sleep(time.Second * 1) 211 | n, _ := conn.Read(buf) 212 | return buf[:n] 213 | } 214 | -------------------------------------------------------------------------------- /test/echo/ws.json: -------------------------------------------------------------------------------- 1 | { 2 | "relay_configs": [ 3 | { 4 | "listen": "127.0.0.1:2234", 5 | "listen_type": "raw", 6 | "transport_type": "ws", 7 | "tcp_remotes": ["ws://0.0.0.0:2443"] 8 | }, 9 | { 10 | "listen": "127.0.0.1:2443", 11 | "listen_type": "ws", 12 | "transport_type": "raw", 13 | "tcp_remotes": ["127.0.0.1:2333"] 14 | } 15 | ] 16 | } 17 | -------------------------------------------------------------------------------- /test/relay_test.go: -------------------------------------------------------------------------------- 1 | package test 2 | 3 | import ( 4 | "bytes" 5 | "context" 6 | "fmt" 7 | "os" 8 | "testing" 9 | "time" 10 | 11 | "github.com/Ehco1996/ehco/internal/config" 12 | 13 | "github.com/Ehco1996/ehco/internal/constant" 14 | "github.com/Ehco1996/ehco/internal/relay" 15 | "github.com/Ehco1996/ehco/internal/relay/conf" 16 | "github.com/Ehco1996/ehco/internal/tls" 17 | "github.com/Ehco1996/ehco/pkg/log" 18 | "github.com/Ehco1996/ehco/test/echo" 19 | "github.com/stretchr/testify/require" 20 | "go.uber.org/zap" 21 | "golang.org/x/sync/errgroup" 22 | ) 23 | 24 | const ( 25 | ECHO_HOST = "0.0.0.0" 26 | ECHO_PORT = 9002 27 | ECHO_SERVER = "0.0.0.0:9002" 28 | 29 | RAW_LISTEN = "0.0.0.0:1234" 30 | 31 | WS_LISTEN = "0.0.0.0:1235" 32 | WS_REMOTE = "ws://0.0.0.0:2000" 33 | WS_SERVER = "0.0.0.0:2000" 34 | 35 | WSS_LISTEN = "0.0.0.0:1236" 36 | WSS_REMOTE = "wss://0.0.0.0:2001" 37 | WSS_SERVER = "0.0.0.0:2001" 38 | ) 39 | 40 | func TestMain(m *testing.M) { 41 | // Setup 42 | _ = log.InitGlobalLogger("debug") 43 | _ = tls.InitTlsCfg() 44 | 45 | // Start echo server 46 | echoServer := echo.NewEchoServer(ECHO_HOST, ECHO_PORT) 47 | go echoServer.Run() 48 | 49 | // Start relay servers 50 | relayServers := startRelayServers() 51 | 52 | // Run tests 53 | code := m.Run() 54 | 55 | // Cleanup 56 | echoServer.Stop() 57 | for _, server := range relayServers { 58 | server.Stop() 59 | } 60 | 61 | os.Exit(code) 62 | } 63 | 64 | func startRelayServers() []*relay.Relay { 65 | options := conf.Options{ 66 | EnableUDP: true, 67 | IdleTimeoutSec: 1, 68 | ReadTimeoutSec: 1, 69 | } 70 | cfg := config.Config{ 71 | RelayConfigs: []*conf.Config{ 72 | // raw 73 | { 74 | Label: "raw", 75 | Listen: RAW_LISTEN, 76 | ListenType: constant.RelayTypeRaw, 77 | Remotes: []string{ECHO_SERVER}, 78 | TransportType: constant.RelayTypeRaw, 79 | Options: &options, 80 | }, 81 | // ws 82 | { 83 | Label: "ws-in", 84 | Listen: WS_LISTEN, 85 | ListenType: constant.RelayTypeRaw, 86 | Remotes: []string{WS_REMOTE}, 87 | TransportType: constant.RelayTypeWS, 88 | Options: &options, 89 | }, 90 | { 91 | Label: "ws-out", 92 | Listen: WS_SERVER, 93 | ListenType: constant.RelayTypeWS, 94 | Remotes: []string{ECHO_SERVER}, 95 | TransportType: constant.RelayTypeRaw, 96 | Options: &options, 97 | }, 98 | 99 | // wss 100 | { 101 | Label: "wss-in", 102 | Listen: WSS_LISTEN, 103 | ListenType: constant.RelayTypeRaw, 104 | Remotes: []string{WSS_REMOTE}, 105 | TransportType: constant.RelayTypeWSS, 106 | Options: &options, 107 | }, 108 | { 109 | Label: "wss-out", 110 | Listen: WSS_SERVER, 111 | ListenType: constant.RelayTypeWSS, 112 | Remotes: []string{ECHO_SERVER}, 113 | TransportType: constant.RelayTypeRaw, 114 | Options: &options, 115 | }, 116 | }, 117 | } 118 | cfg.Adjust() 119 | 120 | var servers []*relay.Relay 121 | for _, c := range cfg.RelayConfigs { 122 | c.Adjust() 123 | r, err := relay.NewRelay(c, nil) 124 | if err != nil { 125 | zap.S().Fatal(err) 126 | } 127 | go r.ListenAndServe(context.TODO()) 128 | servers = append(servers, r) 129 | } 130 | 131 | // Wait for init 132 | time.Sleep(time.Second) 133 | return servers 134 | } 135 | 136 | func TestRelay(t *testing.T) { 137 | testCases := []struct { 138 | name string 139 | address string 140 | protocol string 141 | }{ 142 | {"Raw", RAW_LISTEN, "raw"}, 143 | {"WS", WS_LISTEN, "ws"}, 144 | {"WSS", WSS_LISTEN, "wss"}, 145 | } 146 | 147 | for _, tc := range testCases { 148 | tc := tc // capture range variable 149 | t.Run(tc.name, func(t *testing.T) { 150 | t.Parallel() 151 | testTCPRelay(t, tc.address, tc.protocol, false) 152 | testUDPRelay(t, tc.address, false) 153 | }) 154 | } 155 | } 156 | 157 | func TestRelayConcurrent(t *testing.T) { 158 | testCases := []struct { 159 | name string 160 | address string 161 | concurrency int 162 | }{ 163 | {"Raw", RAW_LISTEN, 10}, 164 | {"WS", WS_LISTEN, 10}, 165 | {"WSS", WSS_LISTEN, 10}, 166 | } 167 | 168 | for _, tc := range testCases { 169 | tc := tc // capture range variable 170 | t.Run(tc.name, func(t *testing.T) { 171 | t.Parallel() 172 | testTCPRelay(t, tc.address, tc.name, true, tc.concurrency) 173 | testUDPRelay(t, tc.address, true, tc.concurrency) 174 | }) 175 | } 176 | } 177 | 178 | func testTCPRelay(t *testing.T, address, protocol string, concurrent bool, concurrency ...int) { 179 | t.Helper() 180 | msg := []byte("hello") 181 | 182 | runTest := func() error { 183 | res := echo.SendTcpMsg(msg, address) 184 | if !bytes.Equal(msg, res) { 185 | return fmt.Errorf("response mismatch: got %s, want %s", res, msg) 186 | } 187 | return nil 188 | } 189 | 190 | if concurrent { 191 | n := 10 192 | if len(concurrency) > 0 { 193 | n = concurrency[0] 194 | } 195 | g, ctx := errgroup.WithContext(context.Background()) 196 | for i := 0; i < n; i++ { 197 | g.Go(func() error { 198 | select { 199 | case <-ctx.Done(): 200 | return ctx.Err() 201 | default: 202 | return runTest() 203 | } 204 | }) 205 | } 206 | require.NoError(t, g.Wait(), "Concurrent test failed") 207 | } else { 208 | require.NoError(t, runTest(), "Single test failed") 209 | } 210 | 211 | t.Logf("Test TCP over %s done!", protocol) 212 | } 213 | 214 | func testUDPRelay(t *testing.T, address string, concurrent bool, concurrency ...int) { 215 | t.Helper() 216 | msg := []byte("hello udp") 217 | 218 | runTest := func() error { 219 | res := echo.SendUdpMsg(msg, address) 220 | if !bytes.Equal(msg, res) { 221 | return fmt.Errorf("response mismatch: got %s, want %s", res, msg) 222 | } 223 | return nil 224 | } 225 | 226 | if concurrent { 227 | n := 10 228 | if len(concurrency) > 0 { 229 | n = concurrency[0] 230 | } 231 | g, ctx := errgroup.WithContext(context.Background()) 232 | for i := 0; i < n; i++ { 233 | g.Go(func() error { 234 | select { 235 | case <-ctx.Done(): 236 | return ctx.Err() 237 | default: 238 | return runTest() 239 | } 240 | }) 241 | } 242 | require.NoError(t, g.Wait(), "Concurrent test failed") 243 | } else { 244 | require.NoError(t, runTest(), "Single test failed") 245 | } 246 | t.Logf("Test UDP over %s done!", address) 247 | } 248 | 249 | func TestRelayIdleTimeout(t *testing.T) { 250 | err := echo.EchoTcpMsgLong([]byte("hello"), time.Second*2, RAW_LISTEN) 251 | require.Error(t, err, "Connection should be rejected") 252 | } 253 | -------------------------------------------------------------------------------- /tools/.gitignore: -------------------------------------------------------------------------------- 1 | bin/ 2 | -------------------------------------------------------------------------------- /tools/Makefile: -------------------------------------------------------------------------------- 1 | 2 | .PHONY: setup-tools 3 | 4 | setup-tools: golangci-lint gofumpt 5 | 6 | GOINSTALL := CGO_ENABLED=0 GOBIN="$(CURDIR)/bin" go install 7 | 8 | golangci-lint: go.mod 9 | @$(GOINSTALL) "github.com/golangci/golangci-lint/cmd/golangci-lint" 10 | 11 | gofumpt: go.mod 12 | @$(GOINSTALL) "mvdan.cc/gofumpt" 13 | -------------------------------------------------------------------------------- /tools/tools.go: -------------------------------------------------------------------------------- 1 | //go:build tools 2 | // +build tools 3 | 4 | package tools 5 | 6 | // pin for go.mod 7 | import ( 8 | _ "github.com/envoyproxy/protoc-gen-validate" 9 | _ "github.com/golangci/golangci-lint/cmd/golangci-lint" 10 | _ "github.com/golangci/golangci-lint/pkg/commands" 11 | _ "mvdan.cc/gofumpt" 12 | ) 13 | --------------------------------------------------------------------------------