Repository: fatedier/frp Branch: dev Commit: 38a71a6803ee Files: 445 Total size: 1.6 MB Directory structure: gitextract_d931dhl1/ ├── .circleci/ │ └── config.yml ├── .github/ │ ├── FUNDING.yml │ ├── ISSUE_TEMPLATE/ │ │ ├── bug_report.yaml │ │ ├── config.yml │ │ └── feature_request.yaml │ ├── pull_request_template.md │ └── workflows/ │ ├── build-and-push-image.yml │ ├── golangci-lint.yml │ ├── goreleaser.yml │ └── stale.yml ├── .gitignore ├── .golangci.yml ├── .goreleaser.yml ├── AGENTS.md ├── LICENSE ├── Makefile ├── Makefile.cross-compiles ├── README.md ├── README_zh.md ├── Release.md ├── assets/ │ └── assets.go ├── client/ │ ├── api_router.go │ ├── config_manager.go │ ├── config_manager_test.go │ ├── configmgmt/ │ │ └── types.go │ ├── connector.go │ ├── control.go │ ├── event/ │ │ └── event.go │ ├── health/ │ │ └── health.go │ ├── http/ │ │ ├── controller.go │ │ ├── controller_test.go │ │ └── model/ │ │ ├── proxy_definition.go │ │ ├── types.go │ │ └── visitor_definition.go │ ├── proxy/ │ │ ├── general_tcp.go │ │ ├── proxy.go │ │ ├── proxy_manager.go │ │ ├── proxy_wrapper.go │ │ ├── sudp.go │ │ ├── udp.go │ │ └── xtcp.go │ ├── service.go │ ├── service_test.go │ └── visitor/ │ ├── stcp.go │ ├── sudp.go │ ├── visitor.go │ ├── visitor_manager.go │ └── xtcp.go ├── cmd/ │ ├── frpc/ │ │ ├── main.go │ │ └── sub/ │ │ ├── admin.go │ │ ├── nathole.go │ │ ├── proxy.go │ │ ├── root.go │ │ └── verify.go │ └── frps/ │ ├── main.go │ ├── root.go │ └── verify.go ├── conf/ │ ├── frpc.toml │ ├── frpc_full_example.toml │ ├── frps.toml │ ├── frps_full_example.toml │ └── legacy/ │ ├── frpc_legacy_full.ini │ └── frps_legacy_full.ini ├── doc/ │ ├── server_plugin.md │ ├── ssh_tunnel_gateway.md │ └── virtual_net.md ├── dockerfiles/ │ ├── Dockerfile-for-frpc │ └── Dockerfile-for-frps ├── go.mod ├── go.sum ├── hack/ │ ├── download.sh │ └── run-e2e.sh ├── package.sh ├── pkg/ │ ├── auth/ │ │ ├── auth.go │ │ ├── legacy/ │ │ │ └── legacy.go │ │ ├── oidc.go │ │ ├── oidc_test.go │ │ ├── pass.go │ │ └── token.go │ ├── config/ │ │ ├── flags.go │ │ ├── legacy/ │ │ │ ├── README.md │ │ │ ├── client.go │ │ │ ├── conversion.go │ │ │ ├── parse.go │ │ │ ├── proxy.go │ │ │ ├── server.go │ │ │ ├── utils.go │ │ │ ├── value.go │ │ │ └── visitor.go │ │ ├── load.go │ │ ├── load_test.go │ │ ├── source/ │ │ │ ├── aggregator.go │ │ │ ├── aggregator_test.go │ │ │ ├── base_source.go │ │ │ ├── base_source_test.go │ │ │ ├── clone.go │ │ │ ├── config_source.go │ │ │ ├── config_source_test.go │ │ │ ├── source.go │ │ │ ├── store.go │ │ │ └── store_test.go │ │ ├── template.go │ │ ├── types/ │ │ │ ├── types.go │ │ │ └── types_test.go │ │ └── v1/ │ │ ├── api.go │ │ ├── client.go │ │ ├── client_test.go │ │ ├── clone_test.go │ │ ├── common.go │ │ ├── decode.go │ │ ├── decode_test.go │ │ ├── proxy.go │ │ ├── proxy_plugin.go │ │ ├── proxy_test.go │ │ ├── server.go │ │ ├── server_test.go │ │ ├── store.go │ │ ├── validation/ │ │ │ ├── client.go │ │ │ ├── common.go │ │ │ ├── oidc.go │ │ │ ├── oidc_test.go │ │ │ ├── plugin.go │ │ │ ├── proxy.go │ │ │ ├── server.go │ │ │ ├── validation.go │ │ │ ├── validator.go │ │ │ └── visitor.go │ │ ├── value_source.go │ │ ├── value_source_test.go │ │ ├── visitor.go │ │ └── visitor_plugin.go │ ├── errors/ │ │ └── errors.go │ ├── metrics/ │ │ ├── aggregate/ │ │ │ └── server.go │ │ ├── mem/ │ │ │ ├── server.go │ │ │ └── types.go │ │ ├── metrics.go │ │ └── prometheus/ │ │ └── server.go │ ├── msg/ │ │ ├── ctl.go │ │ ├── handler.go │ │ └── msg.go │ ├── naming/ │ │ ├── names.go │ │ └── names_test.go │ ├── nathole/ │ │ ├── analysis.go │ │ ├── classify.go │ │ ├── controller.go │ │ ├── discovery.go │ │ ├── nathole.go │ │ └── utils.go │ ├── plugin/ │ │ ├── client/ │ │ │ ├── http2http.go │ │ │ ├── http2https.go │ │ │ ├── http_proxy.go │ │ │ ├── https2http.go │ │ │ ├── https2https.go │ │ │ ├── plugin.go │ │ │ ├── socks5.go │ │ │ ├── static_file.go │ │ │ ├── tls2raw.go │ │ │ ├── unix_domain_socket.go │ │ │ └── virtual_net.go │ │ ├── server/ │ │ │ ├── http.go │ │ │ ├── manager.go │ │ │ ├── plugin.go │ │ │ ├── tracer.go │ │ │ └── types.go │ │ └── visitor/ │ │ ├── plugin.go │ │ └── virtual_net.go │ ├── policy/ │ │ ├── featuregate/ │ │ │ └── feature_gate.go │ │ └── security/ │ │ └── unsafe.go │ ├── proto/ │ │ └── udp/ │ │ ├── udp.go │ │ └── udp_test.go │ ├── sdk/ │ │ └── client/ │ │ └── client.go │ ├── ssh/ │ │ ├── gateway.go │ │ ├── server.go │ │ └── terminal.go │ ├── transport/ │ │ ├── message.go │ │ └── tls.go │ ├── util/ │ │ ├── http/ │ │ │ ├── context.go │ │ │ ├── error.go │ │ │ ├── handler.go │ │ │ ├── http.go │ │ │ ├── middleware.go │ │ │ └── server.go │ │ ├── jsonx/ │ │ │ ├── json_v1.go │ │ │ └── raw_message.go │ │ ├── limit/ │ │ │ ├── reader.go │ │ │ └── writer.go │ │ ├── log/ │ │ │ └── log.go │ │ ├── metric/ │ │ │ ├── counter.go │ │ │ ├── counter_test.go │ │ │ ├── date_counter.go │ │ │ ├── date_counter_test.go │ │ │ └── metrics.go │ │ ├── net/ │ │ │ ├── conn.go │ │ │ ├── dial.go │ │ │ ├── dns.go │ │ │ ├── http.go │ │ │ ├── kcp.go │ │ │ ├── listener.go │ │ │ ├── proxyprotocol.go │ │ │ ├── proxyprotocol_test.go │ │ │ ├── tls.go │ │ │ ├── udp.go │ │ │ └── websocket.go │ │ ├── system/ │ │ │ ├── system.go │ │ │ └── system_android.go │ │ ├── tcpmux/ │ │ │ └── httpconnect.go │ │ ├── util/ │ │ │ ├── types.go │ │ │ ├── util.go │ │ │ └── util_test.go │ │ ├── version/ │ │ │ └── version.go │ │ ├── vhost/ │ │ │ ├── http.go │ │ │ ├── https.go │ │ │ ├── https_test.go │ │ │ ├── resource.go │ │ │ ├── router.go │ │ │ └── vhost.go │ │ ├── wait/ │ │ │ └── backoff.go │ │ └── xlog/ │ │ ├── ctx.go │ │ ├── log_writer.go │ │ └── xlog.go │ ├── virtual/ │ │ └── client.go │ └── vnet/ │ ├── controller.go │ ├── message.go │ ├── tun.go │ ├── tun_darwin.go │ ├── tun_linux.go │ └── tun_unsupported.go ├── server/ │ ├── api_router.go │ ├── control.go │ ├── controller/ │ │ └── resource.go │ ├── group/ │ │ ├── base.go │ │ ├── base_test.go │ │ ├── group.go │ │ ├── http.go │ │ ├── https.go │ │ ├── listener.go │ │ ├── listener_test.go │ │ ├── registry.go │ │ ├── registry_test.go │ │ ├── tcp.go │ │ └── tcpmux.go │ ├── http/ │ │ ├── controller.go │ │ ├── controller_test.go │ │ └── model/ │ │ └── types.go │ ├── metrics/ │ │ └── metrics.go │ ├── ports/ │ │ └── ports.go │ ├── proxy/ │ │ ├── http.go │ │ ├── https.go │ │ ├── proxy.go │ │ ├── stcp.go │ │ ├── sudp.go │ │ ├── tcp.go │ │ ├── tcpmux.go │ │ ├── udp.go │ │ └── xtcp.go │ ├── registry/ │ │ └── registry.go │ ├── service.go │ └── visitor/ │ └── visitor.go ├── test/ │ └── e2e/ │ ├── e2e.go │ ├── e2e_test.go │ ├── examples.go │ ├── framework/ │ │ ├── cleanup.go │ │ ├── client.go │ │ ├── consts/ │ │ │ └── consts.go │ │ ├── expect.go │ │ ├── framework.go │ │ ├── log.go │ │ ├── mockservers.go │ │ ├── process.go │ │ ├── request.go │ │ ├── test_context.go │ │ └── util.go │ ├── legacy/ │ │ ├── basic/ │ │ │ ├── basic.go │ │ │ ├── client.go │ │ │ ├── client_server.go │ │ │ ├── cmd.go │ │ │ ├── config.go │ │ │ ├── http.go │ │ │ ├── server.go │ │ │ ├── tcpmux.go │ │ │ └── xtcp.go │ │ ├── features/ │ │ │ ├── bandwidth_limit.go │ │ │ ├── chaos.go │ │ │ ├── group.go │ │ │ ├── heartbeat.go │ │ │ ├── monitor.go │ │ │ └── real_ip.go │ │ └── plugin/ │ │ ├── client.go │ │ └── server.go │ ├── mock/ │ │ └── server/ │ │ ├── httpserver/ │ │ │ └── server.go │ │ ├── interface.go │ │ ├── oidcserver/ │ │ │ └── oidcserver.go │ │ └── streamserver/ │ │ └── server.go │ ├── pkg/ │ │ ├── cert/ │ │ │ ├── generator.go │ │ │ └── selfsigned.go │ │ ├── plugin/ │ │ │ └── plugin.go │ │ ├── port/ │ │ │ ├── port.go │ │ │ └── util.go │ │ ├── process/ │ │ │ └── process.go │ │ ├── request/ │ │ │ └── request.go │ │ ├── rpc/ │ │ │ └── rpc.go │ │ └── ssh/ │ │ └── client.go │ ├── suites.go │ └── v1/ │ ├── basic/ │ │ ├── annotations.go │ │ ├── basic.go │ │ ├── client.go │ │ ├── client_server.go │ │ ├── cmd.go │ │ ├── config.go │ │ ├── http.go │ │ ├── oidc.go │ │ ├── server.go │ │ ├── tcpmux.go │ │ ├── token_source.go │ │ └── xtcp.go │ ├── features/ │ │ ├── bandwidth_limit.go │ │ ├── chaos.go │ │ ├── group.go │ │ ├── heartbeat.go │ │ ├── monitor.go │ │ ├── real_ip.go │ │ ├── ssh_tunnel.go │ │ └── store.go │ └── plugin/ │ ├── client.go │ └── server.go └── web/ ├── frpc/ │ ├── .gitignore │ ├── .prettierrc.json │ ├── Makefile │ ├── README.md │ ├── auto-imports.d.ts │ ├── components.d.ts │ ├── embed.go │ ├── embed_stub.go │ ├── env.d.ts │ ├── eslint.config.js │ ├── index.html │ ├── package.json │ ├── src/ │ │ ├── App.vue │ │ ├── api/ │ │ │ ├── frpc.ts │ │ │ └── http.ts │ │ ├── assets/ │ │ │ └── css/ │ │ │ ├── _form-layout.scss │ │ │ ├── _index.scss │ │ │ ├── _mixins.scss │ │ │ ├── _variables.scss │ │ │ ├── dark.css │ │ │ └── var.css │ │ ├── components/ │ │ │ ├── ConfigField.vue │ │ │ ├── ConfigSection.vue │ │ │ ├── KeyValueEditor.vue │ │ │ ├── ProxyCard.vue │ │ │ ├── StatusPills.vue │ │ │ ├── StringListEditor.vue │ │ │ ├── proxy-form/ │ │ │ │ ├── ProxyAuthSection.vue │ │ │ │ ├── ProxyBackendSection.vue │ │ │ │ ├── ProxyBaseSection.vue │ │ │ │ ├── ProxyFormLayout.vue │ │ │ │ ├── ProxyHealthSection.vue │ │ │ │ ├── ProxyHttpSection.vue │ │ │ │ ├── ProxyLoadBalanceSection.vue │ │ │ │ ├── ProxyMetadataSection.vue │ │ │ │ ├── ProxyNatSection.vue │ │ │ │ ├── ProxyRemoteSection.vue │ │ │ │ └── ProxyTransportSection.vue │ │ │ └── visitor-form/ │ │ │ ├── VisitorBaseSection.vue │ │ │ ├── VisitorConnectionSection.vue │ │ │ ├── VisitorFormLayout.vue │ │ │ ├── VisitorTransportSection.vue │ │ │ └── VisitorXtcpSection.vue │ │ ├── composables/ │ │ │ └── useResponsive.ts │ │ ├── main.ts │ │ ├── router/ │ │ │ └── index.ts │ │ ├── stores/ │ │ │ ├── client.ts │ │ │ ├── proxy.ts │ │ │ └── visitor.ts │ │ ├── svg.d.ts │ │ ├── types/ │ │ │ ├── constants.ts │ │ │ ├── index.ts │ │ │ ├── proxy-converters.ts │ │ │ ├── proxy-form.ts │ │ │ ├── proxy-status.ts │ │ │ └── proxy-store.ts │ │ ├── utils/ │ │ │ └── format.ts │ │ └── views/ │ │ ├── ClientConfigure.vue │ │ ├── ProxyDetail.vue │ │ ├── ProxyEdit.vue │ │ ├── ProxyList.vue │ │ ├── VisitorDetail.vue │ │ ├── VisitorEdit.vue │ │ └── VisitorList.vue │ ├── tsconfig.json │ ├── tsconfig.node.json │ └── vite.config.mts ├── frps/ │ ├── .gitignore │ ├── .prettierrc.json │ ├── Makefile │ ├── README.md │ ├── auto-imports.d.ts │ ├── components.d.ts │ ├── embed.go │ ├── embed_stub.go │ ├── env.d.ts │ ├── eslint.config.js │ ├── index.html │ ├── package.json │ ├── src/ │ │ ├── App.vue │ │ ├── api/ │ │ │ ├── client.ts │ │ │ ├── http.ts │ │ │ ├── proxy.ts │ │ │ └── server.ts │ │ ├── assets/ │ │ │ └── css/ │ │ │ ├── custom.css │ │ │ ├── dark.css │ │ │ └── var.css │ │ ├── components/ │ │ │ ├── ClientCard.vue │ │ │ ├── ProxyCard.vue │ │ │ ├── StatCard.vue │ │ │ └── Traffic.vue │ │ ├── composables/ │ │ │ └── useResponsive.ts │ │ ├── main.ts │ │ ├── router/ │ │ │ └── index.ts │ │ ├── svg.d.ts │ │ ├── types/ │ │ │ ├── client.ts │ │ │ ├── proxy.ts │ │ │ └── server.ts │ │ ├── utils/ │ │ │ ├── client.ts │ │ │ ├── format.ts │ │ │ └── proxy.ts │ │ └── views/ │ │ ├── ClientDetail.vue │ │ ├── Clients.vue │ │ ├── Proxies.vue │ │ ├── ProxyDetail.vue │ │ └── ServerOverview.vue │ ├── tsconfig.json │ ├── tsconfig.node.json │ └── vite.config.mts ├── package.json └── shared/ ├── components/ │ ├── ActionButton.vue │ ├── BaseDialog.vue │ ├── ConfirmDialog.vue │ ├── FilterDropdown.vue │ ├── PopoverMenu.vue │ └── PopoverMenuItem.vue ├── css/ │ ├── _index.scss │ ├── _mixins.scss │ └── _variables.scss └── package.json ================================================ FILE CONTENTS ================================================ ================================================ FILE: .circleci/config.yml ================================================ version: 2 jobs: go-version-latest: docker: - image: cimg/go:1.25-node resource_class: large steps: - checkout - run: name: Build web assets (frps) command: make install build working_directory: web/frps - run: name: Build web assets (frpc) command: make install build working_directory: web/frpc - run: make - run: make alltest workflows: version: 2 build_and_test: jobs: - go-version-latest ================================================ FILE: .github/FUNDING.yml ================================================ # These are supported funding model platforms github: [fatedier] custom: ["https://afdian.com/a/fatedier"] ================================================ FILE: .github/ISSUE_TEMPLATE/bug_report.yaml ================================================ name: Bug report description: Report a bug to help us improve frp body: - type: markdown attributes: value: | Thanks for taking the time to fill out this bug report! - type: textarea id: bug-description attributes: label: Bug Description description: Tell us what issues you ran into placeholder: Include information about what you tried, what you expected to happen, and what actually happened. The more details, the better! validations: required: true - type: input id: frpc-version attributes: label: frpc Version description: Include the output of `frpc -v` validations: required: true - type: input id: frps-version attributes: label: frps Version description: Include the output of `frps -v` validations: required: true - type: input id: system-architecture attributes: label: System Architecture description: Include which architecture you used, such as `linux/amd64`, `windows/amd64` validations: required: true - type: textarea id: config attributes: label: Configurations description: Include what configurrations you used and ran into this problem placeholder: Pay attention to hiding the token and password in your output validations: required: true - type: textarea id: log attributes: label: Logs description: Prefer you providing releated error logs here placeholder: Pay attention to hiding your personal informations - type: textarea id: steps-to-reproduce attributes: label: Steps to reproduce description: How to reproduce it? It's important for us to find the bug value: | 1. 2. 3. ... - type: checkboxes id: area attributes: label: Affected area options: - label: "Docs" - label: "Installation" - label: "Performance and Scalability" - label: "Security" - label: "User Experience" - label: "Test and Release" - label: "Developer Infrastructure" - label: "Client Plugin" - label: "Server Plugin" - label: "Extensions" - label: "Others" ================================================ FILE: .github/ISSUE_TEMPLATE/config.yml ================================================ blank_issues_enabled: false ================================================ FILE: .github/ISSUE_TEMPLATE/feature_request.yaml ================================================ name: Feature Request description: Suggest an idea to improve frp title: "[Feature Request] " body: - type: markdown attributes: value: | This is only used to request new product features. - type: textarea id: feature-request attributes: label: Describe the feature request description: Tell us what's you want and why it should be added in frp. validations: required: true - type: textarea id: alternatives attributes: label: Describe alternatives you've considered - type: checkboxes id: area attributes: label: Affected area options: - label: "Docs" - label: "Installation" - label: "Performance and Scalability" - label: "Security" - label: "User Experience" - label: "Test and Release" - label: "Developer Infrastructure" - label: "Client Plugin" - label: "Server Plugin" - label: "Extensions" - label: "Others" ================================================ FILE: .github/pull_request_template.md ================================================ ### WHY ================================================ FILE: .github/workflows/build-and-push-image.yml ================================================ name: Build Image and Publish to Dockerhub & GPR on: release: types: [ published ] workflow_dispatch: inputs: tag: description: 'Image tag' required: true default: 'test' permissions: contents: read jobs: image: name: Build Image from Dockerfile and binaries runs-on: ubuntu-latest steps: # environment - name: Checkout uses: actions/checkout@v4 with: fetch-depth: '0' - name: Set up QEMU uses: docker/setup-qemu-action@v3 - name: Set up Docker Buildx uses: docker/setup-buildx-action@v3 # get image tag name - name: Get Image Tag Name run: | if [ x${{ github.event.inputs.tag }} == x"" ]; then echo "TAG_NAME=${GITHUB_REF#refs/*/}" >> $GITHUB_ENV else echo "TAG_NAME=${{ github.event.inputs.tag }}" >> $GITHUB_ENV fi - name: Login to DockerHub uses: docker/login-action@v3 with: username: ${{ secrets.DOCKERHUB_USERNAME }} password: ${{ secrets.DOCKERHUB_PASSWORD }} - name: Login to the GPR uses: docker/login-action@v3 with: registry: ghcr.io username: ${{ github.repository_owner }} password: ${{ secrets.GPR_TOKEN }} # prepare image tags - name: Prepare Image Tags run: | echo "DOCKERFILE_FRPC_PATH=dockerfiles/Dockerfile-for-frpc" >> $GITHUB_ENV echo "DOCKERFILE_FRPS_PATH=dockerfiles/Dockerfile-for-frps" >> $GITHUB_ENV echo "TAG_FRPC=fatedier/frpc:${{ env.TAG_NAME }}" >> $GITHUB_ENV echo "TAG_FRPS=fatedier/frps:${{ env.TAG_NAME }}" >> $GITHUB_ENV echo "TAG_FRPC_GPR=ghcr.io/fatedier/frpc:${{ env.TAG_NAME }}" >> $GITHUB_ENV echo "TAG_FRPS_GPR=ghcr.io/fatedier/frps:${{ env.TAG_NAME }}" >> $GITHUB_ENV - name: Build and push frpc uses: docker/build-push-action@v5 with: context: . file: ./dockerfiles/Dockerfile-for-frpc platforms: linux/amd64,linux/arm/v7,linux/arm64,linux/ppc64le,linux/s390x push: true tags: | ${{ env.TAG_FRPC }} ${{ env.TAG_FRPC_GPR }} - name: Build and push frps uses: docker/build-push-action@v5 with: context: . file: ./dockerfiles/Dockerfile-for-frps platforms: linux/amd64,linux/arm/v7,linux/arm64,linux/ppc64le,linux/s390x push: true tags: | ${{ env.TAG_FRPS }} ${{ env.TAG_FRPS_GPR }} ================================================ FILE: .github/workflows/golangci-lint.yml ================================================ name: golangci-lint on: push: branches: - master - dev pull_request: permissions: contents: read # Optional: allow read access to pull request. Use with `only-new-issues` option. pull-requests: read jobs: golangci: name: lint runs-on: ubuntu-latest steps: - uses: actions/checkout@v4 - uses: actions/setup-go@v5 with: go-version: '1.25' cache: false - uses: actions/setup-node@v4 with: node-version: '22' - name: Build web assets (frps) run: make build working-directory: web/frps - name: Build web assets (frpc) run: make build working-directory: web/frpc - name: golangci-lint uses: golangci/golangci-lint-action@v9 with: # Optional: version of golangci-lint to use in form of v1.2 or v1.2.3 or `latest` to use the latest version version: v2.10 ================================================ FILE: .github/workflows/goreleaser.yml ================================================ name: goreleaser on: workflow_dispatch: jobs: goreleaser: runs-on: ubuntu-latest steps: - name: Checkout uses: actions/checkout@v4 with: fetch-depth: 0 - name: Set up Go uses: actions/setup-go@v5 with: go-version: '1.25' - uses: actions/setup-node@v4 with: node-version: '22' - name: Build web assets (frps) run: make build working-directory: web/frps - name: Build web assets (frpc) run: make build working-directory: web/frpc - name: Make All run: | ./package.sh - name: Run GoReleaser uses: goreleaser/goreleaser-action@v6 with: version: latest args: release --clean --release-notes=./Release.md env: GITHUB_TOKEN: ${{ secrets.GPR_TOKEN }} ================================================ FILE: .github/workflows/stale.yml ================================================ name: "Close stale issues and PRs" on: schedule: - cron: "20 0 * * *" workflow_dispatch: inputs: debug-only: description: 'In debug mod' required: false default: 'false' permissions: contents: read jobs: stale: permissions: issues: write # for actions/stale to close stale issues pull-requests: write # for actions/stale to close stale PRs actions: write runs-on: ubuntu-latest steps: - uses: actions/stale@v9 with: stale-issue-message: 'Issues go stale after 14d of inactivity. Stale issues rot after an additional 3d of inactivity and eventually close.' stale-pr-message: "PRs go stale after 14d of inactivity. Stale PRs rot after an additional 3d of inactivity and eventually close." stale-issue-label: 'lifecycle/stale' exempt-issue-labels: 'bug,doc,enhancement,future,proposal,question,testing,todo,easy,help wanted,assigned' stale-pr-label: 'lifecycle/stale' exempt-pr-labels: 'bug,doc,enhancement,future,proposal,question,testing,todo,easy,help wanted,assigned' days-before-stale: 14 days-before-close: 3 debug-only: ${{ github.event.inputs.debug-only }} exempt-all-pr-milestones: true exempt-all-pr-assignees: true operations-per-run: 200 ================================================ FILE: .gitignore ================================================ # Compiled Object files, Static and Dynamic libs (Shared Objects) *.o *.a *.so # Folders _obj _test *.exe *.test *.prof # Self bin/ packages/ release/ test/bin/ vendor/ lastversion/ dist/ .idea/ .vscode/ .autogen_ssh_key client.crt client.key node_modules/ # Cache *.swp # AI .claude/ .sisyphus/ .superpowers/ ================================================ FILE: .golangci.yml ================================================ version: "2" run: concurrency: 4 timeout: 20m build-tags: - integ - integfuzz linters: default: none enable: - asciicheck - copyloopvar - errcheck - gocritic - gosec - govet - ineffassign - lll - makezero - misspell - modernize - prealloc - predeclared - revive - staticcheck - unconvert - unparam - unused settings: errcheck: check-type-assertions: false check-blank: false gocritic: disabled-checks: - exitAfterDefer gosec: excludes: ["G115", "G117", "G204", "G401", "G402", "G404", "G501", "G703", "G704", "G705"] severity: low confidence: low govet: disable: - shadow lll: line-length: 160 tab-width: 1 misspell: locale: US ignore-rules: - cancelled - marshalled modernize: disable: - omitzero unparam: check-exported: false exclusions: generated: lax presets: - comments - common-false-positives - legacy - std-error-handling rules: - linters: - errcheck - maligned path: _test\.go$|^tests/|^samples/ - linters: - revive - staticcheck text: use underscores in Go names - linters: - revive text: unused-parameter - linters: - revive text: "avoid meaningless package names" - linters: - revive text: "Go standard library package names" - linters: - unparam text: is always false paths: - .*\.pb\.go - .*\.gen\.go - genfiles$ - vendor$ - bin$ - third_party$ - builtin$ - examples$ - node_modules formatters: enable: - gci - gofumpt - goimports settings: gci: sections: - standard - default - prefix(github.com/fatedier/frp/) exclusions: generated: lax paths: - .*\.pb\.go - .*\.gen\.go - genfiles$ - vendor$ - bin$ - third_party$ - builtin$ - examples$ - node_modules issues: max-issues-per-linter: 0 max-same-issues: 0 ================================================ FILE: .goreleaser.yml ================================================ builds: - skip: true checksum: name_template: '{{ .ProjectName }}_sha256_checksums.txt' algorithm: sha256 extra_files: - glob: ./release/packages/* release: # Same as for github # Note: it can only be one: either github, gitlab or gitea github: owner: fatedier name: frp draft: false # You can add extra pre-existing files to the release. # The filename on the release will be the last part of the path (base). If # another file with the same name exists, the latest one found will be used. # Defaults to empty. extra_files: - glob: ./release/packages/* ================================================ FILE: AGENTS.md ================================================ # AGENTS.md ## Development Commands ### Build - `make build` - Build both frps and frpc binaries - `make frps` - Build server binary only - `make frpc` - Build client binary only - `make all` - Build everything with formatting ### Testing - `make test` - Run unit tests - `make e2e` - Run end-to-end tests - `make e2e-trace` - Run e2e tests with trace logging - `make alltest` - Run all tests including vet, unit tests, and e2e ### Code Quality - `make fmt` - Run go fmt - `make fmt-more` - Run gofumpt for more strict formatting - `make gci` - Run gci import organizer - `make vet` - Run go vet - `golangci-lint run` - Run comprehensive linting (configured in .golangci.yml) ### Assets - `make web` - Build web dashboards (frps and frpc) ### Cleanup - `make clean` - Remove built binaries and temporary files ## Testing - E2E tests using Ginkgo/Gomega framework - Mock servers in `/test/e2e/mock/` - Run: `make e2e` or `make alltest` ================================================ FILE: LICENSE ================================================ Apache License Version 2.0, January 2004 http://www.apache.org/licenses/ TERMS AND CONDITIONS FOR USE, REPRODUCTION, AND DISTRIBUTION 1. Definitions. "License" shall mean the terms and conditions for use, reproduction, and distribution as defined by Sections 1 through 9 of this document. "Licensor" shall mean the copyright owner or entity authorized by the copyright owner that is granting the License. "Legal Entity" shall mean the union of the acting entity and all other entities that control, are controlled by, or are under common control with that entity. For the purposes of this definition, "control" means (i) the power, direct or indirect, to cause the direction or management of such entity, whether by contract or otherwise, or (ii) ownership of fifty percent (50%) or more of the outstanding shares, or (iii) beneficial ownership of such entity. "You" (or "Your") shall mean an individual or Legal Entity exercising permissions granted by this License. "Source" form shall mean the preferred form for making modifications, including but not limited to software source code, documentation source, and configuration files. "Object" form shall mean any form resulting from mechanical transformation or translation of a Source form, including but not limited to compiled object code, generated documentation, and conversions to other media types. "Work" shall mean the work of authorship, whether in Source or Object form, made available under the License, as indicated by a copyright notice that is included in or attached to the work (an example is provided in the Appendix below). "Derivative Works" shall mean any work, whether in Source or Object form, that is based on (or derived from) the Work and for which the editorial revisions, annotations, elaborations, or other modifications represent, as a whole, an original work of authorship. For the purposes of this License, Derivative Works shall not include works that remain separable from, or merely link (or bind by name) to the interfaces of, the Work and Derivative Works thereof. "Contribution" shall mean any work of authorship, including the original version of the Work and any modifications or additions to that Work or Derivative Works thereof, that is intentionally submitted to Licensor for inclusion in the Work by the copyright owner or by an individual or Legal Entity authorized to submit on behalf of the copyright owner. For the purposes of this definition, "submitted" means any form of electronic, verbal, or written communication sent to the Licensor or its representatives, including but not limited to communication on electronic mailing lists, source code control systems, and issue tracking systems that are managed by, or on behalf of, the Licensor for the purpose of discussing and improving the Work, but excluding communication that is conspicuously marked or otherwise designated in writing by the copyright owner as "Not a Contribution." "Contributor" shall mean Licensor and any individual or Legal Entity on behalf of whom a Contribution has been received by Licensor and subsequently incorporated within the Work. 2. Grant of Copyright License. Subject to the terms and conditions of this License, each Contributor hereby grants to You a perpetual, worldwide, non-exclusive, no-charge, royalty-free, irrevocable copyright license to reproduce, prepare Derivative Works of, publicly display, publicly perform, sublicense, and distribute the Work and such Derivative Works in Source or Object form. 3. Grant of Patent License. Subject to the terms and conditions of this License, each Contributor hereby grants to You a perpetual, worldwide, non-exclusive, no-charge, royalty-free, irrevocable (except as stated in this section) patent license to make, have made, use, offer to sell, sell, import, and otherwise transfer the Work, where such license applies only to those patent claims licensable by such Contributor that are necessarily infringed by their Contribution(s) alone or by combination of their Contribution(s) with the Work to which such Contribution(s) was submitted. If You institute patent litigation against any entity (including a cross-claim or counterclaim in a lawsuit) alleging that the Work or a Contribution incorporated within the Work constitutes direct or contributory patent infringement, then any patent licenses granted to You under this License for that Work shall terminate as of the date such litigation is filed. 4. Redistribution. You may reproduce and distribute copies of the Work or Derivative Works thereof in any medium, with or without modifications, and in Source or Object form, provided that You meet the following conditions: (a) You must give any other recipients of the Work or Derivative Works a copy of this License; and (b) You must cause any modified files to carry prominent notices stating that You changed the files; and (c) You must retain, in the Source form of any Derivative Works that You distribute, all copyright, patent, trademark, and attribution notices from the Source form of the Work, excluding those notices that do not pertain to any part of the Derivative Works; and (d) If the Work includes a "NOTICE" text file as part of its distribution, then any Derivative Works that You distribute must include a readable copy of the attribution notices contained within such NOTICE file, excluding those notices that do not pertain to any part of the Derivative Works, in at least one of the following places: within a NOTICE text file distributed as part of the Derivative Works; within the Source form or documentation, if provided along with the Derivative Works; or, within a display generated by the Derivative Works, if and wherever such third-party notices normally appear. The contents of the NOTICE file are for informational purposes only and do not modify the License. You may add Your own attribution notices within Derivative Works that You distribute, alongside or as an addendum to the NOTICE text from the Work, provided that such additional attribution notices cannot be construed as modifying the License. You may add Your own copyright statement to Your modifications and may provide additional or different license terms and conditions for use, reproduction, or distribution of Your modifications, or for any such Derivative Works as a whole, provided Your use, reproduction, and distribution of the Work otherwise complies with the conditions stated in this License. 5. Submission of Contributions. Unless You explicitly state otherwise, any Contribution intentionally submitted for inclusion in the Work by You to the Licensor shall be under the terms and conditions of this License, without any additional terms or conditions. Notwithstanding the above, nothing herein shall supersede or modify the terms of any separate license agreement you may have executed with Licensor regarding such Contributions. 6. Trademarks. This License does not grant permission to use the trade names, trademarks, service marks, or product names of the Licensor, except as required for reasonable and customary use in describing the origin of the Work and reproducing the content of the NOTICE file. 7. Disclaimer of Warranty. Unless required by applicable law or agreed to in writing, Licensor provides the Work (and each Contributor provides its Contributions) on an "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied, including, without limitation, any warranties or conditions of TITLE, NON-INFRINGEMENT, MERCHANTABILITY, or FITNESS FOR A PARTICULAR PURPOSE. You are solely responsible for determining the appropriateness of using or redistributing the Work and assume any risks associated with Your exercise of permissions under this License. 8. Limitation of Liability. In no event and under no legal theory, whether in tort (including negligence), contract, or otherwise, unless required by applicable law (such as deliberate and grossly negligent acts) or agreed to in writing, shall any Contributor be liable to You for damages, including any direct, indirect, special, incidental, or consequential damages of any character arising as a result of this License or out of the use or inability to use the Work (including but not limited to damages for loss of goodwill, work stoppage, computer failure or malfunction, or any and all other commercial damages or losses), even if such Contributor has been advised of the possibility of such damages. 9. Accepting Warranty or Additional Liability. While redistributing the Work or Derivative Works thereof, You may choose to offer, and charge a fee for, acceptance of support, warranty, indemnity, or other liability obligations and/or rights consistent with this License. However, in accepting such obligations, You may act only on Your own behalf and on Your sole responsibility, not on behalf of any other Contributor, and only if You agree to indemnify, defend, and hold each Contributor harmless for any liability incurred by, or claims asserted against, such Contributor by reason of your accepting any such warranty or additional liability. END OF TERMS AND CONDITIONS APPENDIX: How to apply the Apache License to your work. To apply the Apache License to your work, attach the following boilerplate notice, with the fields enclosed by brackets "{}" replaced with your own identifying information. (Don't include the brackets!) The text should be enclosed in the appropriate comment syntax for the file format. We also recommend that a file or class name and description of purpose be included on the same "printed page" as the copyright notice for easier identification within third-party archives. Copyright {yyyy} {name of copyright owner} Licensed under the Apache License, Version 2.0 (the "License"); you may not use this file except in compliance with the License. You may obtain a copy of the License at http://www.apache.org/licenses/LICENSE-2.0 Unless required by applicable law or agreed to in writing, software distributed under the License is distributed on an "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. See the License for the specific language governing permissions and limitations under the License. ================================================ FILE: Makefile ================================================ export PATH := $(PATH):`go env GOPATH`/bin export GO111MODULE=on LDFLAGS := -s -w NOWEB_TAG = $(shell [ ! -d web/frps/dist ] || [ ! -d web/frpc/dist ] && echo ',noweb') .PHONY: web frps-web frpc-web frps frpc all: env fmt web build build: frps frpc env: @go version web: frps-web frpc-web frps-web: $(MAKE) -C web/frps build frpc-web: $(MAKE) -C web/frpc build fmt: go fmt ./... fmt-more: gofumpt -l -w . gci: gci write -s standard -s default -s "prefix(github.com/fatedier/frp/)" ./ vet: go vet -tags "$(NOWEB_TAG)" ./... frps: env CGO_ENABLED=0 go build -trimpath -ldflags "$(LDFLAGS)" -tags "frps$(NOWEB_TAG)" -o bin/frps ./cmd/frps frpc: env CGO_ENABLED=0 go build -trimpath -ldflags "$(LDFLAGS)" -tags "frpc$(NOWEB_TAG)" -o bin/frpc ./cmd/frpc test: gotest gotest: go test -tags "$(NOWEB_TAG)" -v --cover ./assets/... go test -tags "$(NOWEB_TAG)" -v --cover ./cmd/... go test -tags "$(NOWEB_TAG)" -v --cover ./client/... go test -tags "$(NOWEB_TAG)" -v --cover ./server/... go test -tags "$(NOWEB_TAG)" -v --cover ./pkg/... e2e: ./hack/run-e2e.sh e2e-trace: DEBUG=true LOG_LEVEL=trace ./hack/run-e2e.sh e2e-compatibility-last-frpc: if [ ! -d "./lastversion" ]; then \ TARGET_DIRNAME=lastversion ./hack/download.sh; \ fi FRPC_PATH="`pwd`/lastversion/frpc" ./hack/run-e2e.sh rm -r ./lastversion e2e-compatibility-last-frps: if [ ! -d "./lastversion" ]; then \ TARGET_DIRNAME=lastversion ./hack/download.sh; \ fi FRPS_PATH="`pwd`/lastversion/frps" ./hack/run-e2e.sh rm -r ./lastversion alltest: vet gotest e2e clean: rm -f ./bin/frpc rm -f ./bin/frps rm -rf ./lastversion ================================================ FILE: Makefile.cross-compiles ================================================ export PATH := $(PATH):`go env GOPATH`/bin export GO111MODULE=on LDFLAGS := -s -w os-archs=darwin:amd64 darwin:arm64 freebsd:amd64 openbsd:amd64 linux:amd64 linux:arm:7 linux:arm:5 linux:arm64 windows:amd64 windows:arm64 linux:mips64 linux:mips64le linux:mips:softfloat linux:mipsle:softfloat linux:riscv64 linux:loong64 android:arm64 all: build build: app app: @$(foreach n, $(os-archs), \ os=$(shell echo "$(n)" | cut -d : -f 1); \ arch=$(shell echo "$(n)" | cut -d : -f 2); \ extra=$(shell echo "$(n)" | cut -d : -f 3); \ flags=''; \ target_suffix=$${os}_$${arch}; \ if [ "$${os}" = "linux" ] && [ "$${arch}" = "arm" ] && [ "$${extra}" != "" ] ; then \ if [ "$${extra}" = "7" ]; then \ flags=GOARM=7; \ target_suffix=$${os}_arm_hf; \ elif [ "$${extra}" = "5" ]; then \ flags=GOARM=5; \ target_suffix=$${os}_arm; \ fi; \ elif [ "$${os}" = "linux" ] && ([ "$${arch}" = "mips" ] || [ "$${arch}" = "mipsle" ]) && [ "$${extra}" != "" ] ; then \ flags=GOMIPS=$${extra}; \ fi; \ echo "Build $${os}-$${arch}$${extra:+ ($${extra})}..."; \ env CGO_ENABLED=0 GOOS=$${os} GOARCH=$${arch} $${flags} go build -trimpath -ldflags "$(LDFLAGS)" -tags frpc -o ./release/frpc_$${target_suffix} ./cmd/frpc; \ env CGO_ENABLED=0 GOOS=$${os} GOARCH=$${arch} $${flags} go build -trimpath -ldflags "$(LDFLAGS)" -tags frps -o ./release/frps_$${target_suffix} ./cmd/frps; \ echo "Build $${os}-$${arch}$${extra:+ ($${extra})} done"; \ ) @mv ./release/frpc_windows_amd64 ./release/frpc_windows_amd64.exe @mv ./release/frps_windows_amd64 ./release/frps_windows_amd64.exe @mv ./release/frpc_windows_arm64 ./release/frpc_windows_arm64.exe @mv ./release/frps_windows_arm64 ./release/frps_windows_arm64.exe ================================================ FILE: README.md ================================================ # frp [![Build Status](https://circleci.com/gh/fatedier/frp.svg?style=shield)](https://circleci.com/gh/fatedier/frp) [![GitHub release](https://img.shields.io/github/tag/fatedier/frp.svg?label=release)](https://github.com/fatedier/frp/releases) [![Go Report Card](https://goreportcard.com/badge/github.com/fatedier/frp)](https://goreportcard.com/report/github.com/fatedier/frp) [![GitHub Releases Stats](https://img.shields.io/github/downloads/fatedier/frp/total.svg?logo=github)](https://somsubhra.github.io/github-release-stats/?username=fatedier&repository=frp) [README](README.md) | [中文文档](README_zh.md) ## Sponsors frp is an open source project with its ongoing development made possible entirely by the support of our awesome sponsors. If you'd like to join them, please consider [sponsoring frp's development](https://github.com/sponsors/fatedier).

Gold Sponsors

## Recall.ai - API for meeting recordings If you're looking for a meeting recording API, consider checking out [Recall.ai](https://www.recall.ai/?utm_source=github&utm_medium=sponsorship&utm_campaign=fatedier-frp), an API that records Zoom, Google Meet, Microsoft Teams, in-person meetings, and more.


Requestly - Free & Open-Source alternative to Postman
All-in-one platform to Test, Mock and Intercept APIs.


The complete IDE crafted for professional Go developers


The sovereign cloud that puts you in control
An open source, self-hosted alternative to public clouds, built for data ownership and privacy

## What is frp? frp is a fast reverse proxy that allows you to expose a local server located behind a NAT or firewall to the Internet. It currently supports **TCP** and **UDP**, as well as **HTTP** and **HTTPS** protocols, enabling requests to be forwarded to internal services via domain name. frp also offers a P2P connect mode. ## Table of Contents * [Development Status](#development-status) * [About V2](#about-v2) * [Architecture](#architecture) * [Example Usage](#example-usage) * [Access your computer in a LAN network via SSH](#access-your-computer-in-a-lan-network-via-ssh) * [Multiple SSH services sharing the same port](#multiple-ssh-services-sharing-the-same-port) * [Accessing Internal Web Services with Custom Domains in LAN](#accessing-internal-web-services-with-custom-domains-in-lan) * [Forward DNS query requests](#forward-dns-query-requests) * [Forward Unix Domain Socket](#forward-unix-domain-socket) * [Expose a simple HTTP file server](#expose-a-simple-http-file-server) * [Enable HTTPS for a local HTTP(S) service](#enable-https-for-a-local-https-service) * [Expose your service privately](#expose-your-service-privately) * [P2P Mode](#p2p-mode) * [Features](#features) * [Configuration Files](#configuration-files) * [Using Environment Variables](#using-environment-variables) * [Split Configures Into Different Files](#split-configures-into-different-files) * [Server Dashboard](#server-dashboard) * [Client Admin UI](#client-admin-ui) * [Monitor](#monitor) * [Prometheus](#prometheus) * [Authenticating the Client](#authenticating-the-client) * [Token Authentication](#token-authentication) * [OIDC Authentication](#oidc-authentication) * [Encryption and Compression](#encryption-and-compression) * [TLS](#tls) * [Hot-Reloading frpc configuration](#hot-reloading-frpc-configuration) * [Get proxy status from client](#get-proxy-status-from-client) * [Only allowing certain ports on the server](#only-allowing-certain-ports-on-the-server) * [Port Reuse](#port-reuse) * [Bandwidth Limit](#bandwidth-limit) * [For Each Proxy](#for-each-proxy) * [TCP Stream Multiplexing](#tcp-stream-multiplexing) * [Support KCP Protocol](#support-kcp-protocol) * [Support QUIC Protocol](#support-quic-protocol) * [Connection Pooling](#connection-pooling) * [Load balancing](#load-balancing) * [Service Health Check](#service-health-check) * [Rewriting the HTTP Host Header](#rewriting-the-http-host-header) * [Setting other HTTP Headers](#setting-other-http-headers) * [Get Real IP](#get-real-ip) * [HTTP X-Forwarded-For](#http-x-forwarded-for) * [Proxy Protocol](#proxy-protocol) * [Require HTTP Basic Auth (Password) for Web Services](#require-http-basic-auth-password-for-web-services) * [Custom Subdomain Names](#custom-subdomain-names) * [URL Routing](#url-routing) * [TCP Port Multiplexing](#tcp-port-multiplexing) * [Connecting to frps via PROXY](#connecting-to-frps-via-proxy) * [Port range mapping](#port-range-mapping) * [Client Plugins](#client-plugins) * [Server Manage Plugins](#server-manage-plugins) * [SSH Tunnel Gateway](#ssh-tunnel-gateway) * [Virtual Network (VirtualNet)](#virtual-network-virtualnet) * [Feature Gates](#feature-gates) * [Available Feature Gates](#available-feature-gates) * [Enabling Feature Gates](#enabling-feature-gates) * [Feature Lifecycle](#feature-lifecycle) * [Related Projects](#related-projects) * [Contributing](#contributing) * [Donation](#donation) * [GitHub Sponsors](#github-sponsors) * [PayPal](#paypal) ## Development Status frp is currently under development. You can try the latest release version in the `master` branch, or use the `dev` branch to access the version currently in development. We are currently working on version 2 and attempting to perform some code refactoring and improvements. However, please note that it will not be compatible with version 1. We will transition from version 0 to version 1 at the appropriate time and will only accept bug fixes and improvements, rather than big feature requests. ### About V2 The complexity and difficulty of the v2 version are much higher than anticipated. I can only work on its development during fragmented time periods, and the constant interruptions disrupt productivity significantly. Given this situation, we will continue to optimize and iterate on the current version until we have more free time to proceed with the major version overhaul. The concept behind v2 is based on my years of experience and reflection in the cloud-native domain, particularly in K8s and ServiceMesh. Its core is a modernized four-layer and seven-layer proxy, similar to envoy. This proxy itself is highly scalable, not only capable of implementing the functionality of intranet penetration but also applicable to various other domains. Building upon this highly scalable core, we aim to implement all the capabilities of frp v1 while also addressing the functionalities that were previously unachievable or difficult to implement in an elegant manner. Furthermore, we will maintain efficient development and iteration capabilities. In addition, I envision frp itself becoming a highly extensible system and platform, similar to how we can provide a range of extension capabilities based on K8s. In K8s, we can customize development according to enterprise needs, utilizing features such as CRD, controller mode, webhook, CSI, and CNI. In frp v1, we introduced the concept of server plugins, which implemented some basic extensibility. However, it relies on a simple HTTP protocol and requires users to start independent processes and manage them on their own. This approach is far from flexible and convenient, and real-world demands vary greatly. It is unrealistic to expect a non-profit open-source project maintained by a few individuals to meet everyone's needs. Finally, we acknowledge that the current design of modules such as configuration management, permission verification, certificate management, and API management is not modern enough. While we may carry out some optimizations in the v1 version, ensuring compatibility remains a challenging issue that requires a considerable amount of effort to address. We sincerely appreciate your support for frp. ## Architecture ![architecture](/doc/pic/architecture.png) ## Example Usage To begin, download the latest program for your operating system and architecture from the [Release](https://github.com/fatedier/frp/releases) page. Next, place the `frps` binary and server configuration file on Server A, which has a public IP address. Finally, place the `frpc` binary and client configuration file on Server B, which is located on a LAN that cannot be directly accessed from the public internet. Some antiviruses improperly mark frpc as malware and delete it. This is due to frp being a networking tool capable of creating reverse proxies. Antiviruses sometimes flag reverse proxies due to their ability to bypass firewall port restrictions. If you are using antivirus, then you may need to whitelist/exclude frpc in your antivirus settings to avoid accidental quarantine/deletion. See [issue 3637](https://github.com/fatedier/frp/issues/3637) for more details. ### Access your computer in a LAN network via SSH 1. Modify `frps.toml` on server A by setting the `bindPort` for frp clients to connect to: ```toml # frps.toml bindPort = 7000 ``` 2. Start `frps` on server A: `./frps -c ./frps.toml` 3. Modify `frpc.toml` on server B and set the `serverAddr` field to the public IP address of your frps server: ```toml # frpc.toml serverAddr = "x.x.x.x" serverPort = 7000 [[proxies]] name = "ssh" type = "tcp" localIP = "127.0.0.1" localPort = 22 remotePort = 6000 ``` Note that the `localPort` (listened on the client) and `remotePort` (exposed on the server) are used for traffic going in and out of the frp system, while the `serverPort` is used for communication between frps and frpc. 4. Start `frpc` on server B: `./frpc -c ./frpc.toml` 5. To access server B from another machine through server A via SSH (assuming the username is `test`), use the following command: `ssh -oPort=6000 test@x.x.x.x` ### Multiple SSH services sharing the same port This example implements multiple SSH services exposed through the same port using a proxy of type tcpmux. Similarly, as long as the client supports the HTTP Connect proxy connection method, port reuse can be achieved in this way. 1. Deploy frps on a machine with a public IP and modify the frps.toml file. Here is a simplified configuration: ```toml bindPort = 7000 tcpmuxHTTPConnectPort = 5002 ``` 2. Deploy frpc on the internal machine A with the following configuration: ```toml serverAddr = "x.x.x.x" serverPort = 7000 [[proxies]] name = "ssh1" type = "tcpmux" multiplexer = "httpconnect" customDomains = ["machine-a.example.com"] localIP = "127.0.0.1" localPort = 22 ``` 3. Deploy another frpc on the internal machine B with the following configuration: ```toml serverAddr = "x.x.x.x" serverPort = 7000 [[proxies]] name = "ssh2" type = "tcpmux" multiplexer = "httpconnect" customDomains = ["machine-b.example.com"] localIP = "127.0.0.1" localPort = 22 ``` 4. To access internal machine A using SSH ProxyCommand, assuming the username is "test": `ssh -o 'proxycommand socat - PROXY:x.x.x.x:%h:%p,proxyport=5002' test@machine-a.example.com` 5. To access internal machine B, the only difference is the domain name, assuming the username is "test": `ssh -o 'proxycommand socat - PROXY:x.x.x.x:%h:%p,proxyport=5002' test@machine-b.example.com` ### Accessing Internal Web Services with Custom Domains in LAN Sometimes we need to expose a local web service behind a NAT network to others for testing purposes with our own domain name. Unfortunately, we cannot resolve a domain name to a local IP. However, we can use frp to expose an HTTP(S) service. 1. Modify `frps.toml` and set the HTTP port for vhost to 8080: ```toml # frps.toml bindPort = 7000 vhostHTTPPort = 8080 ``` If you want to configure an https proxy, you need to set up the `vhostHTTPSPort`. 2. Start `frps`: `./frps -c ./frps.toml` 3. Modify `frpc.toml` and set `serverAddr` to the IP address of the remote frps server. Specify the `localPort` of your web service: ```toml # frpc.toml serverAddr = "x.x.x.x" serverPort = 7000 [[proxies]] name = "web" type = "http" localPort = 80 customDomains = ["www.example.com"] ``` 4. Start `frpc`: `./frpc -c ./frpc.toml` 5. Map the A record of `www.example.com` to either the public IP of the remote frps server or a CNAME record pointing to your original domain. 6. Visit your local web service using url `http://www.example.com:8080`. ### Forward DNS query requests 1. Modify `frps.toml`: ```toml # frps.toml bindPort = 7000 ``` 2. Start `frps`: `./frps -c ./frps.toml` 3. Modify `frpc.toml` and set `serverAddr` to the IP address of the remote frps server. Forward DNS query requests to the Google Public DNS server `8.8.8.8:53`: ```toml # frpc.toml serverAddr = "x.x.x.x" serverPort = 7000 [[proxies]] name = "dns" type = "udp" localIP = "8.8.8.8" localPort = 53 remotePort = 6000 ``` 4. Start frpc: `./frpc -c ./frpc.toml` 5. Test DNS resolution using the `dig` command: `dig @x.x.x.x -p 6000 www.google.com` ### Forward Unix Domain Socket Expose a Unix domain socket (e.g. the Docker daemon socket) as TCP. Configure `frps` as above. 1. Start `frpc` with the following configuration: ```toml # frpc.toml serverAddr = "x.x.x.x" serverPort = 7000 [[proxies]] name = "unix_domain_socket" type = "tcp" remotePort = 6000 [proxies.plugin] type = "unix_domain_socket" unixPath = "/var/run/docker.sock" ``` 2. Test the configuration by getting the docker version using `curl`: `curl http://x.x.x.x:6000/version` ### Expose a simple HTTP file server Expose a simple HTTP file server to access files stored in the LAN from the public Internet. Configure `frps` as described above, then: 1. Start `frpc` with the following configuration: ```toml # frpc.toml serverAddr = "x.x.x.x" serverPort = 7000 [[proxies]] name = "test_static_file" type = "tcp" remotePort = 6000 [proxies.plugin] type = "static_file" localPath = "/tmp/files" stripPrefix = "static" httpUser = "abc" httpPassword = "abc" ``` 2. Visit `http://x.x.x.x:6000/static/` from your browser and specify correct username and password to view files in `/tmp/files` on the `frpc` machine. ### Enable HTTPS for a local HTTP(S) service You may substitute `https2https` for the plugin, and point the `localAddr` to a HTTPS endpoint. 1. Start `frpc` with the following configuration: ```toml # frpc.toml serverAddr = "x.x.x.x" serverPort = 7000 [[proxies]] name = "test_https2http" type = "https" customDomains = ["test.example.com"] [proxies.plugin] type = "https2http" localAddr = "127.0.0.1:80" crtPath = "./server.crt" keyPath = "./server.key" hostHeaderRewrite = "127.0.0.1" requestHeaders.set.x-from-where = "frp" ``` 2. Visit `https://test.example.com`. ### Expose your service privately To mitigate risks associated with exposing certain services directly to the public network, STCP (Secret TCP) mode requires a preshared key to be used for access to the service from other clients. Configure `frps` same as above. 1. Start `frpc` on machine B with the following config. This example is for exposing the SSH service (port 22), and note the `secretKey` field for the preshared key, and that the `remotePort` field is removed here: ```toml # frpc.toml serverAddr = "x.x.x.x" serverPort = 7000 [[proxies]] name = "secret_ssh" type = "stcp" secretKey = "abcdefg" localIP = "127.0.0.1" localPort = 22 ``` 2. Start another `frpc` (typically on another machine C) with the following config to access the SSH service with a security key (`secretKey` field): ```toml # frpc.toml serverAddr = "x.x.x.x" serverPort = 7000 [[visitors]] name = "secret_ssh_visitor" type = "stcp" serverName = "secret_ssh" secretKey = "abcdefg" bindAddr = "127.0.0.1" bindPort = 6000 ``` 3. On machine C, connect to SSH on machine B, using this command: `ssh -oPort=6000 127.0.0.1` ### P2P Mode **xtcp** is designed to transmit large amounts of data directly between clients. A frps server is still needed, as P2P here only refers to the actual data transmission. Note that it may not work with all types of NAT devices. You might want to fallback to stcp if xtcp doesn't work. 1. Start `frpc` on machine B, and expose the SSH port. Note that the `remotePort` field is removed: ```toml # frpc.toml serverAddr = "x.x.x.x" serverPort = 7000 # set up a new stun server if the default one is not available. # natHoleStunServer = "xxx" [[proxies]] name = "p2p_ssh" type = "xtcp" secretKey = "abcdefg" localIP = "127.0.0.1" localPort = 22 ``` 2. Start another `frpc` (typically on another machine C) with the configuration to connect to SSH using P2P mode: ```toml # frpc.toml serverAddr = "x.x.x.x" serverPort = 7000 # set up a new stun server if the default one is not available. # natHoleStunServer = "xxx" [[visitors]] name = "p2p_ssh_visitor" type = "xtcp" serverName = "p2p_ssh" secretKey = "abcdefg" bindAddr = "127.0.0.1" bindPort = 6000 # when automatic tunnel persistence is required, set it to true keepTunnelOpen = false ``` 3. On machine C, connect to SSH on machine B, using this command: `ssh -oPort=6000 127.0.0.1` ## Features ### Configuration Files Since v0.52.0, we support TOML, YAML, and JSON for configuration. Please note that INI is deprecated and will be removed in future releases. New features will only be available in TOML, YAML, or JSON. Users wanting these new features should switch their configuration format accordingly. Read the full example configuration files to find out even more features not described here. Examples use TOML format, but you can still use YAML or JSON. These configuration files is for reference only. Please do not use this configuration directly to run the program as it may have various issues. [Full configuration file for frps (Server)](./conf/frps_full_example.toml) [Full configuration file for frpc (Client)](./conf/frpc_full_example.toml) ### Using Environment Variables Environment variables can be referenced in the configuration file, using Go's standard format: ```toml # frpc.toml serverAddr = "{{ .Envs.FRP_SERVER_ADDR }}" serverPort = 7000 [[proxies]] name = "ssh" type = "tcp" localIP = "127.0.0.1" localPort = 22 remotePort = {{ .Envs.FRP_SSH_REMOTE_PORT }} ``` With the config above, variables can be passed into `frpc` program like this: ``` export FRP_SERVER_ADDR=x.x.x.x export FRP_SSH_REMOTE_PORT=6000 ./frpc -c ./frpc.toml ``` `frpc` will render configuration file template using OS environment variables. Remember to prefix your reference with `.Envs`. ### Split Configures Into Different Files You can split multiple proxy configs into different files and include them in the main file. ```toml # frpc.toml serverAddr = "x.x.x.x" serverPort = 7000 includes = ["./confd/*.toml"] ``` ```toml # ./confd/test.toml [[proxies]] name = "ssh" type = "tcp" localIP = "127.0.0.1" localPort = 22 remotePort = 6000 ``` ### Server Dashboard Check frp's status and proxies' statistics information by Dashboard. Configure a port for dashboard to enable this feature: ```toml # The default value is 127.0.0.1. Change it to 0.0.0.0 when you want to access it from a public network. webServer.addr = "0.0.0.0" webServer.port = 7500 # dashboard's username and password are both optional webServer.user = "admin" webServer.password = "admin" ``` Then visit `http://[serverAddr]:7500` to see the dashboard, with username and password both being `admin`. Additionally, you can use HTTPS port by using your domains wildcard or normal SSL certificate: ```toml webServer.port = 7500 # dashboard's username and password are both optional webServer.user = "admin" webServer.password = "admin" webServer.tls.certFile = "server.crt" webServer.tls.keyFile = "server.key" ``` Then visit `https://[serverAddr]:7500` to see the dashboard in secure HTTPS connection, with username and password both being `admin`. ![dashboard](/doc/pic/dashboard.png) ### Client Admin UI The Client Admin UI helps you check and manage frpc's configuration. Configure an address for admin UI to enable this feature: ```toml webServer.addr = "127.0.0.1" webServer.port = 7400 webServer.user = "admin" webServer.password = "admin" ``` Then visit `http://127.0.0.1:7400` to see admin UI, with username and password both being `admin`. ### Monitor When web server is enabled, frps will save monitor data in cache for 7 days. It will be cleared after process restart. Prometheus is also supported. #### Prometheus Enable dashboard first, then configure `enablePrometheus = true` in `frps.toml`. `http://{dashboard_addr}/metrics` will provide prometheus monitor data. ### Authenticating the Client There are 2 authentication methods to authenticate frpc with frps. You can decide which one to use by configuring `auth.method` in `frpc.toml` and `frps.toml`, the default one is token. Configuring `auth.additionalScopes = ["HeartBeats"]` will use the configured authentication method to add and validate authentication on every heartbeat between frpc and frps. Configuring `auth.additionalScopes = ["NewWorkConns"]` will do the same for every new work connection between frpc and frps. #### Token Authentication When specifying `auth.method = "token"` in `frpc.toml` and `frps.toml` - token based authentication will be used. Make sure to specify the same `auth.token` in `frps.toml` and `frpc.toml` for frpc to pass frps validation ##### Token Source frp supports reading authentication tokens from external sources using the `tokenSource` configuration. Currently, file-based token source is supported. **File-based token source:** ```toml # frpc.toml auth.method = "token" auth.tokenSource.type = "file" auth.tokenSource.file.path = "/path/to/token/file" ``` The token will be read from the specified file at startup. This is useful for scenarios where tokens are managed by external systems or need to be kept separate from configuration files for security reasons. #### OIDC Authentication When specifying `auth.method = "oidc"` in `frpc.toml` and `frps.toml` - OIDC based authentication will be used. OIDC stands for OpenID Connect, and the flow used is called [Client Credentials Grant](https://tools.ietf.org/html/rfc6749#section-4.4). To use this authentication type - configure `frpc.toml` and `frps.toml` as follows: ```toml # frps.toml auth.method = "oidc" auth.oidc.issuer = "https://example-oidc-issuer.com/" auth.oidc.audience = "https://oidc-audience.com/.default" ``` ```toml # frpc.toml auth.method = "oidc" auth.oidc.clientID = "98692467-37de-409a-9fac-bb2585826f18" # Replace with OIDC client ID auth.oidc.clientSecret = "oidc_secret" auth.oidc.audience = "https://oidc-audience.com/.default" auth.oidc.tokenEndpointURL = "https://example-oidc-endpoint.com/oauth2/v2.0/token" ``` ### Encryption and Compression The features are off by default. You can turn on encryption and/or compression: ```toml # frpc.toml [[proxies]] name = "ssh" type = "tcp" localPort = 22 remotePort = 6000 transport.useEncryption = true transport.useCompression = true ``` #### TLS Since v0.50.0, the default value of `transport.tls.enable` and `transport.tls.disableCustomTLSFirstByte` has been changed to true, and tls is enabled by default. For port multiplexing, frp sends a first byte `0x17` to dial a TLS connection. This only takes effect when you set `transport.tls.disableCustomTLSFirstByte` to false. To **enforce** `frps` to only accept TLS connections - configure `transport.tls.force = true` in `frps.toml`. **This is optional.** **`frpc` TLS settings:** ```toml transport.tls.enable = true transport.tls.certFile = "certificate.crt" transport.tls.keyFile = "certificate.key" transport.tls.trustedCaFile = "ca.crt" ``` **`frps` TLS settings:** ```toml transport.tls.force = true transport.tls.certFile = "certificate.crt" transport.tls.keyFile = "certificate.key" transport.tls.trustedCaFile = "ca.crt" ``` You will need **a root CA cert** and **at least one SSL/TLS certificate**. It **can** be self-signed or regular (such as Let's Encrypt or another SSL/TLS certificate provider). If you using `frp` via IP address and not hostname, make sure to set the appropriate IP address in the Subject Alternative Name (SAN) area when generating SSL/TLS Certificates. Given an example: * Prepare openssl config file. It exists at `/etc/pki/tls/openssl.cnf` in Linux System and `/System/Library/OpenSSL/openssl.cnf` in MacOS, and you can copy it to current path, like `cp /etc/pki/tls/openssl.cnf ./my-openssl.cnf`. If not, you can build it by yourself, like: ``` cat > my-openssl.cnf << EOF [ ca ] default_ca = CA_default [ CA_default ] x509_extensions = usr_cert [ req ] default_bits = 2048 default_md = sha256 default_keyfile = privkey.pem distinguished_name = req_distinguished_name attributes = req_attributes x509_extensions = v3_ca string_mask = utf8only [ req_distinguished_name ] [ req_attributes ] [ usr_cert ] basicConstraints = CA:FALSE nsComment = "OpenSSL Generated Certificate" subjectKeyIdentifier = hash authorityKeyIdentifier = keyid,issuer [ v3_ca ] subjectKeyIdentifier = hash authorityKeyIdentifier = keyid:always,issuer basicConstraints = CA:true EOF ``` * build ca certificates: ``` openssl genrsa -out ca.key 2048 openssl req -x509 -new -nodes -key ca.key -subj "/CN=example.ca.com" -days 5000 -out ca.crt ``` * build frps certificates: ``` openssl genrsa -out server.key 2048 openssl req -new -sha256 -key server.key \ -subj "/C=XX/ST=DEFAULT/L=DEFAULT/O=DEFAULT/CN=server.com" \ -reqexts SAN \ -config <(cat my-openssl.cnf <(printf "\n[SAN]\nsubjectAltName=DNS:localhost,IP:127.0.0.1,DNS:example.server.com")) \ -out server.csr openssl x509 -req -days 365 -sha256 \ -in server.csr -CA ca.crt -CAkey ca.key -CAcreateserial \ -extfile <(printf "subjectAltName=DNS:localhost,IP:127.0.0.1,DNS:example.server.com") \ -out server.crt ``` * build frpc certificates: ``` openssl genrsa -out client.key 2048 openssl req -new -sha256 -key client.key \ -subj "/C=XX/ST=DEFAULT/L=DEFAULT/O=DEFAULT/CN=client.com" \ -reqexts SAN \ -config <(cat my-openssl.cnf <(printf "\n[SAN]\nsubjectAltName=DNS:client.com,DNS:example.client.com")) \ -out client.csr openssl x509 -req -days 365 -sha256 \ -in client.csr -CA ca.crt -CAkey ca.key -CAcreateserial \ -extfile <(printf "subjectAltName=DNS:client.com,DNS:example.client.com") \ -out client.crt ``` ### Hot-Reloading frpc configuration The `webServer` fields are required for enabling HTTP API: ```toml # frpc.toml webServer.addr = "127.0.0.1" webServer.port = 7400 ``` Then run command `frpc reload -c ./frpc.toml` and wait for about 10 seconds to let `frpc` create or update or remove proxies. **Note that global client parameters won't be modified except 'start'.** `start` is a global allowlist evaluated after all sources are merged (config file/include/store). If `start` is non-empty, any proxy or visitor not listed there will not be started, including entries created via Store API. `start` is kept mainly for compatibility and is generally not recommended for new configurations. Prefer per-proxy/per-visitor `enabled`, and keep `start` empty unless you explicitly want this global allowlist behavior. You can run command `frpc verify -c ./frpc.toml` before reloading to check if there are config errors. ### Get proxy status from client Use `frpc status -c ./frpc.toml` to get status of all proxies. The `webServer` fields are required for enabling HTTP API. ### Only allowing certain ports on the server `allowPorts` in `frps.toml` is used to avoid abuse of ports: ```toml # frps.toml allowPorts = [ { start = 2000, end = 3000 }, { single = 3001 }, { single = 3003 }, { start = 4000, end = 50000 } ] ``` ### Port Reuse `vhostHTTPPort` and `vhostHTTPSPort` in frps can use same port with `bindPort`. frps will detect the connection's protocol and handle it correspondingly. What you need to pay attention to is that if you want to configure `vhostHTTPSPort` and `bindPort` to the same port, you need to first set `transport.tls.disableCustomTLSFirstByte` to false. We would like to try to allow multiple proxies bind a same remote port with different protocols in the future. ### Bandwidth Limit #### For Each Proxy ```toml # frpc.toml [[proxies]] name = "ssh" type = "tcp" localPort = 22 remotePort = 6000 transport.bandwidthLimit = "1MB" ``` Set `transport.bandwidthLimit` in each proxy's configure to enable this feature. Supported units are `MB` and `KB`. Set `transport.bandwidthLimitMode` to `client` or `server` to limit bandwidth on the client or server side. Default is `client`. ### TCP Stream Multiplexing frp supports tcp stream multiplexing since v0.10.0 like HTTP2 Multiplexing, in which case all logic connections to the same frpc are multiplexed into the same TCP connection. You can disable this feature by modify `frps.toml` and `frpc.toml`: ```toml # frps.toml and frpc.toml, must be same transport.tcpMux = false ``` ### Support KCP Protocol KCP is a fast and reliable protocol that can achieve the transmission effect of a reduction of the average latency by 30% to 40% and reduction of the maximum delay by a factor of three, at the cost of 10% to 20% more bandwidth wasted than TCP. KCP mode uses UDP as the underlying transport. Using KCP in frp: 1. Enable KCP in frps: ```toml # frps.toml bindPort = 7000 # Specify a UDP port for KCP. kcpBindPort = 7000 ``` The `kcpBindPort` number can be the same number as `bindPort`, since `bindPort` field specifies a TCP port. 2. Configure `frpc.toml` to use KCP to connect to frps: ```toml # frpc.toml serverAddr = "x.x.x.x" # Same as the 'kcpBindPort' in frps.toml serverPort = 7000 transport.protocol = "kcp" ``` ### Support QUIC Protocol QUIC is a new multiplexed transport built on top of UDP. Using QUIC in frp: 1. Enable QUIC in frps: ```toml # frps.toml bindPort = 7000 # Specify a UDP port for QUIC. quicBindPort = 7000 ``` The `quicBindPort` number can be the same number as `bindPort`, since `bindPort` field specifies a TCP port. 2. Configure `frpc.toml` to use QUIC to connect to frps: ```toml # frpc.toml serverAddr = "x.x.x.x" # Same as the 'quicBindPort' in frps.toml serverPort = 7000 transport.protocol = "quic" ``` ### Connection Pooling By default, frps creates a new frpc connection to the backend service upon a user request. With connection pooling, frps keeps a certain number of pre-established connections, reducing the time needed to establish a connection. This feature is suitable for a large number of short connections. 1. Configure the limit of pool count each proxy can use in `frps.toml`: ```toml # frps.toml transport.maxPoolCount = 5 ``` 2. Enable and specify the number of connection pool: ```toml # frpc.toml transport.poolCount = 1 ``` ### Load balancing Load balancing is supported by `group`. This feature is only available for types `tcp`, `http`, `tcpmux` now. ```toml # frpc.toml [[proxies]] name = "test1" type = "tcp" localPort = 8080 remotePort = 80 loadBalancer.group = "web" loadBalancer.groupKey = "123" [[proxies]] name = "test2" type = "tcp" localPort = 8081 remotePort = 80 loadBalancer.group = "web" loadBalancer.groupKey = "123" ``` `loadBalancer.groupKey` is used for authentication. Connections to port 80 will be dispatched to proxies in the same group randomly. For type `tcp`, `remotePort` in the same group should be the same. For type `http`, `customDomains`, `subdomain`, `locations` should be the same. ### Service Health Check Health check feature can help you achieve high availability with load balancing. Add `healthCheck.type = "tcp"` or `healthCheck.type = "http"` to enable health check. With health check type **tcp**, the service port will be pinged (TCPing): ```toml # frpc.toml [[proxies]] name = "test1" type = "tcp" localPort = 22 remotePort = 6000 # Enable TCP health check healthCheck.type = "tcp" # TCPing timeout seconds healthCheck.timeoutSeconds = 3 # If health check failed 3 times in a row, the proxy will be removed from frps healthCheck.maxFailed = 3 # A health check every 10 seconds healthCheck.intervalSeconds = 10 ``` With health check type **http**, an HTTP request will be sent to the service and an HTTP 2xx OK response is expected: ```toml # frpc.toml [[proxies]] name = "web" type = "http" localIP = "127.0.0.1" localPort = 80 customDomains = ["test.example.com"] # Enable HTTP health check healthCheck.type = "http" # frpc will send a GET request to '/status' # and expect an HTTP 2xx OK response healthCheck.path = "/status" healthCheck.timeoutSeconds = 3 healthCheck.maxFailed = 3 healthCheck.intervalSeconds = 10 ``` ### Rewriting the HTTP Host Header By default frp does not modify the tunneled HTTP requests at all as it's a byte-for-byte copy. However, speaking of web servers and HTTP requests, your web server might rely on the `Host` HTTP header to determine the website to be accessed. frp can rewrite the `Host` header when forwarding the HTTP requests, with the `hostHeaderRewrite` field: ```toml # frpc.toml [[proxies]] name = "web" type = "http" localPort = 80 customDomains = ["test.example.com"] hostHeaderRewrite = "dev.example.com" ``` The HTTP request will have the `Host` header rewritten to `Host: dev.example.com` when it reaches the actual web server, although the request from the browser probably has `Host: test.example.com`. ### Setting other HTTP Headers Similar to `Host`, You can override other HTTP request and response headers with proxy type `http`. ```toml # frpc.toml [[proxies]] name = "web" type = "http" localPort = 80 customDomains = ["test.example.com"] hostHeaderRewrite = "dev.example.com" requestHeaders.set.x-from-where = "frp" responseHeaders.set.foo = "bar" ``` In this example, it will set header `x-from-where: frp` in the HTTP request and `foo: bar` in the HTTP response. ### Get Real IP #### HTTP X-Forwarded-For This feature is for `http` proxies or proxies with the `https2http` and `https2https` plugins enabled. You can get user's real IP from HTTP request headers `X-Forwarded-For`. #### Proxy Protocol frp supports Proxy Protocol to send user's real IP to local services. Here is an example for https service: ```toml # frpc.toml [[proxies]] name = "web" type = "https" localPort = 443 customDomains = ["test.example.com"] # now v1 and v2 are supported transport.proxyProtocolVersion = "v2" ``` You can enable Proxy Protocol support in nginx to expose user's real IP in HTTP header `X-Real-IP`, and then read `X-Real-IP` header in your web service for the real IP. ### Require HTTP Basic Auth (Password) for Web Services Anyone who can guess your tunnel URL can access your local web server unless you protect it with a password. This enforces HTTP Basic Auth on all requests with the username and password specified in frpc's configure file. It can only be enabled when proxy type is http. ```toml # frpc.toml [[proxies]] name = "web" type = "http" localPort = 80 customDomains = ["test.example.com"] httpUser = "abc" httpPassword = "abc" ``` Visit `http://test.example.com` in the browser and now you are prompted to enter the username and password. ### Custom Subdomain Names It is convenient to use `subdomain` configure for http and https types when many people share one frps server. ```toml # frps.toml subDomainHost = "frps.com" ``` Resolve `*.frps.com` to the frps server's IP. This is usually called a Wildcard DNS record. ```toml # frpc.toml [[proxies]] name = "web" type = "http" localPort = 80 subdomain = "test" ``` Now you can visit your web service on `test.frps.com`. Note that if `subdomainHost` is not empty, `customDomains` should not be the subdomain of `subdomainHost`. ### URL Routing frp supports forwarding HTTP requests to different backend web services by url routing. `locations` specifies the prefix of URL used for routing. frps first searches for the most specific prefix location given by literal strings regardless of the listed order. ```toml # frpc.toml [[proxies]] name = "web01" type = "http" localPort = 80 customDomains = ["web.example.com"] locations = ["/"] [[proxies]] name = "web02" type = "http" localPort = 81 customDomains = ["web.example.com"] locations = ["/news", "/about"] ``` HTTP requests with URL prefix `/news` or `/about` will be forwarded to **web02** and other requests to **web01**. ### TCP Port Multiplexing frp supports receiving TCP sockets directed to different proxies on a single port on frps, similar to `vhostHTTPPort` and `vhostHTTPSPort`. The only supported TCP port multiplexing method available at the moment is `httpconnect` - HTTP CONNECT tunnel. When setting `tcpmuxHTTPConnectPort` to anything other than 0 in frps, frps will listen on this port for HTTP CONNECT requests. The host of the HTTP CONNECT request will be used to match the proxy in frps. Proxy hosts can be configured in frpc by configuring `customDomains` and / or `subdomain` under `tcpmux` proxies, when `multiplexer = "httpconnect"`. For example: ```toml # frps.toml bindPort = 7000 tcpmuxHTTPConnectPort = 1337 ``` ```toml # frpc.toml serverAddr = "x.x.x.x" serverPort = 7000 [[proxies]] name = "proxy1" type = "tcpmux" multiplexer = "httpconnect" customDomains = ["test1"] localPort = 80 [[proxies]] name = "proxy2" type = "tcpmux" multiplexer = "httpconnect" customDomains = ["test2"] localPort = 8080 ``` In the above configuration - frps can be contacted on port 1337 with a HTTP CONNECT header such as: ``` CONNECT test1 HTTP/1.1\r\n\r\n ``` and the connection will be routed to `proxy1`. ### Connecting to frps via PROXY frpc can connect to frps through proxy if you set OS environment variable `HTTP_PROXY`, or if `transport.proxyURL` is set in frpc.toml file. It only works when protocol is tcp. ```toml # frpc.toml serverAddr = "x.x.x.x" serverPort = 7000 transport.proxyURL = "http://user:pwd@192.168.1.128:8080" ``` ### Port range mapping *Added in v0.56.0* We can use the range syntax of Go template combined with the built-in `parseNumberRangePair` function to achieve port range mapping. The following example, when run, will create 8 proxies named `test-6000, test-6001 ... test-6007`, each mapping the remote port to the local port. ``` {{- range $_, $v := parseNumberRangePair "6000-6006,6007" "6000-6006,6007" }} [[proxies]] name = "tcp-{{ $v.First }}" type = "tcp" localPort = {{ $v.First }} remotePort = {{ $v.Second }} {{- end }} ``` ### Client Plugins frpc only forwards requests to local TCP or UDP ports by default. Plugins are used for providing rich features. There are built-in plugins such as `unix_domain_socket`, `http_proxy`, `socks5`, `static_file`, `http2https`, `https2http`, `https2https` and you can see [example usage](#example-usage). Using plugin **http_proxy**: ```toml # frpc.toml [[proxies]] name = "http_proxy" type = "tcp" remotePort = 6000 [proxies.plugin] type = "http_proxy" httpUser = "abc" httpPassword = "abc" ``` `httpUser` and `httpPassword` are configuration parameters used in `http_proxy` plugin. ### Server Manage Plugins Read the [document](/doc/server_plugin.md). Find more plugins in [gofrp/plugin](https://github.com/gofrp/plugin). ### SSH Tunnel Gateway *added in v0.53.0* frp supports listening to an SSH port on the frps side and achieves TCP protocol proxying through the SSH -R protocol, without relying on frpc. ```toml # frps.toml sshTunnelGateway.bindPort = 2200 ``` When running `./frps -c frps.toml`, a private key file named `.autogen_ssh_key` will be automatically created in the current working directory. This generated private key file will be used by the SSH server in frps. Executing the command ```bash ssh -R :80:127.0.0.1:8080 v0@{frp address} -p 2200 tcp --proxy_name "test-tcp" --remote_port 9090 ``` sets up a proxy on frps that forwards the local 8080 service to the port 9090. ```bash frp (via SSH) (Ctrl+C to quit) User: ProxyName: test-tcp Type: tcp RemoteAddress: :9090 ``` This is equivalent to: ```bash frpc tcp --proxy_name "test-tcp" --local_ip 127.0.0.1 --local_port 8080 --remote_port 9090 ``` Please refer to this [document](/doc/ssh_tunnel_gateway.md) for more information. ### Virtual Network (VirtualNet) *Alpha feature added in v0.62.0* The VirtualNet feature enables frp to create and manage virtual network connections between clients and visitors through a TUN interface. This allows for IP-level routing between machines, extending frp beyond simple port forwarding to support full network connectivity. For detailed information about configuration and usage, please refer to the [VirtualNet documentation](/doc/virtual_net.md). ## Feature Gates frp supports feature gates to enable or disable experimental features. This allows users to try out new features before they're considered stable. ### Available Feature Gates | Name | Stage | Default | Description | |------|-------|---------|-------------| | VirtualNet | ALPHA | false | Virtual network capabilities for frp | ### Enabling Feature Gates To enable an experimental feature, add the feature gate to your configuration: ```toml featureGates = { VirtualNet = true } ``` ### Feature Lifecycle Features typically go through three stages: 1. **ALPHA**: Disabled by default, may be unstable 2. **BETA**: May be enabled by default, more stable but still evolving 3. **GA (Generally Available)**: Enabled by default, ready for production use ## Related Projects * [gofrp/plugin](https://github.com/gofrp/plugin) - A repository for frp plugins that contains a variety of plugins implemented based on the frp extension mechanism, meeting the customization needs of different scenarios. * [gofrp/tiny-frpc](https://github.com/gofrp/tiny-frpc) - A lightweight version of the frp client (around 3.5MB at minimum) implemented using the ssh protocol, supporting some of the most commonly used features, suitable for devices with limited resources. ## Contributing Interested in getting involved? We would like to help you! * Take a look at our [issues list](https://github.com/fatedier/frp/issues) and consider sending a Pull Request to **dev branch**. * If you want to add a new feature, please create an issue first to describe the new feature, as well as the implementation approach. Once a proposal is accepted, create an implementation of the new features and submit it as a pull request. * Sorry for my poor English. Improvements for this document are welcome, even some typo fixes. * If you have great ideas, send an email to fatedier@gmail.com. **Note: We prefer you to give your advise in [issues](https://github.com/fatedier/frp/issues), so others with a same question can search it quickly and we don't need to answer them repeatedly.** ## Donation If frp helps you a lot, you can support us by: ### GitHub Sponsors Support us by [Github Sponsors](https://github.com/sponsors/fatedier). You can have your company's logo placed on README file of this project. ### PayPal Donate money by [PayPal](https://www.paypal.me/fatedier) to my account **fatedier@gmail.com**. ================================================ FILE: README_zh.md ================================================ # frp [![Build Status](https://circleci.com/gh/fatedier/frp.svg?style=shield)](https://circleci.com/gh/fatedier/frp) [![GitHub release](https://img.shields.io/github/tag/fatedier/frp.svg?label=release)](https://github.com/fatedier/frp/releases) [![Go Report Card](https://goreportcard.com/badge/github.com/fatedier/frp)](https://goreportcard.com/report/github.com/fatedier/frp) [![GitHub Releases Stats](https://img.shields.io/github/downloads/fatedier/frp/total.svg?logo=github)](https://somsubhra.github.io/github-release-stats/?username=fatedier&repository=frp) [README](README.md) | [中文文档](README_zh.md) frp 是一个专注于内网穿透的高性能的反向代理应用,支持 TCP、UDP、HTTP、HTTPS 等多种协议,且支持 P2P 通信。可以将内网服务以安全、便捷的方式通过具有公网 IP 节点的中转暴露到公网。 ## Sponsors frp 是一个完全开源的项目,我们的开发工作完全依靠赞助者们的支持。如果你愿意加入他们的行列,请考虑 [赞助 frp 的开发](https://github.com/sponsors/fatedier)。

Gold Sponsors

## Recall.ai - API for meeting recordings If you're looking for a meeting recording API, consider checking out [Recall.ai](https://www.recall.ai/?utm_source=github&utm_medium=sponsorship&utm_campaign=fatedier-frp), an API that records Zoom, Google Meet, Microsoft Teams, in-person meetings, and more.


Requestly - Free & Open-Source alternative to Postman
All-in-one platform to Test, Mock and Intercept APIs.


The complete IDE crafted for professional Go developers


The sovereign cloud that puts you in control
An open source, self-hosted alternative to public clouds, built for data ownership and privacy

## 为什么使用 frp ? 通过在具有公网 IP 的节点上部署 frp 服务端,可以轻松地将内网服务穿透到公网,同时提供诸多专业的功能特性,这包括: * 客户端服务端通信支持 TCP、QUIC、KCP 以及 Websocket 等多种协议。 * 采用 TCP 连接流式复用,在单个连接间承载更多请求,节省连接建立时间,降低请求延迟。 * 代理组间的负载均衡。 * 端口复用,多个服务通过同一个服务端端口暴露。 * 支持 P2P 通信,流量不经过服务器中转,充分利用带宽资源。 * 多个原生支持的客户端插件(静态文件查看,HTTPS/HTTP 协议转换,HTTP、SOCK5 代理等),便于独立使用 frp 客户端完成某些工作。 * 高度扩展性的服务端插件系统,易于结合自身需求进行功能扩展。 * 服务端和客户端 UI 页面。 ## 开发状态 frp 目前已被很多公司广泛用于测试、生产环境。 master 分支用于发布稳定版本,dev 分支用于开发,您可以尝试下载最新的 release 版本进行测试。 我们正在进行 v2 大版本的开发,将会尝试在各个方面进行重构和升级,且不会与 v1 版本进行兼容,预计会持续较长的一段时间。 现在的 v0 版本将会在合适的时间切换为 v1 版本并且保证兼容性,后续只做 bug 修复和优化,不再进行大的功能性更新。 ### 关于 v2 的一些说明 v2 版本的复杂度和难度比我们预期的要高得多。我只能利用零散的时间进行开发,而且由于上下文经常被打断,效率极低。由于这种情况可能会持续一段时间,我们仍然会在当前版本上进行一些优化和迭代,直到我们有更多空闲时间来推进大版本的重构,或者也有可能放弃一次性的重构,而是采用渐进的方式在当前版本上逐步做一些可能会导致不兼容的修改。 v2 的构想是基于我多年在云原生领域,特别是在 K8s 和 ServiceMesh 方面的工作经验和思考。它的核心是一个现代化的四层和七层代理,类似于 envoy。这个代理本身高度可扩展,不仅可以用于实现内网穿透的功能,还可以应用于更多领域。在这个高度可扩展的内核基础上,我们将实现 frp v1 中的所有功能,并且能够以一种更加优雅的方式实现原先架构中无法实现或不易实现的功能。同时,我们将保持高效的开发和迭代能力。 除此之外,我希望 frp 本身也成为一个高度可扩展的系统和平台,就像我们可以基于 K8s 提供一系列扩展能力一样。在 K8s 上,我们可以根据企业需求进行定制化开发,例如使用 CRD、controller 模式、webhook、CSI 和 CNI 等。在 frp v1 中,我们引入了服务端插件的概念,实现了一些简单的扩展性。但是,它实际上依赖于简单的 HTTP 协议,并且需要用户自己启动独立的进程和管理。这种方式远远不够灵活和方便,而且现实世界的需求千差万别,我们不能期望一个由少数人维护的非营利性开源项目能够满足所有人的需求。 最后,我们意识到像配置管理、权限验证、证书管理和管理 API 等模块的当前设计并不够现代化。尽管我们可能在 v1 版本中进行一些优化,但确保兼容性是一个令人头疼的问题,需要投入大量精力来解决。 非常感谢您对 frp 的支持。 ## 文档 完整文档已经迁移至 [https://gofrp.org](https://gofrp.org)。 ## 为 frp 做贡献 frp 是一个免费且开源的项目,我们欢迎任何人为其开发和进步贡献力量。 * 在使用过程中出现任何问题,可以通过 [issues](https://github.com/fatedier/frp/issues) 来反馈。 * Bug 的修复可以直接提交 Pull Request 到 dev 分支。 * 如果是增加新的功能特性,请先创建一个 issue 并做简单描述以及大致的实现方法,提议被采纳后,就可以创建一个实现新特性的 Pull Request。 * 欢迎对说明文档做出改善,帮助更多的人使用 frp,特别是英文文档。 * 贡献代码请提交 PR 至 dev 分支,master 分支仅用于发布稳定可用版本。 * 如果你有任何其他方面的问题或合作,欢迎发送邮件至 fatedier@gmail.com 。 **提醒:和项目相关的问题请在 [issues](https://github.com/fatedier/frp/issues) 中反馈,这样方便其他有类似问题的人可以快速查找解决方法,并且也避免了我们重复回答一些问题。** ## 关联项目 * [gofrp/plugin](https://github.com/gofrp/plugin) - frp 插件仓库,收录了基于 frp 扩展机制实现的各种插件,满足各种场景下的定制化需求。 * [gofrp/tiny-frpc](https://github.com/gofrp/tiny-frpc) - 基于 ssh 协议实现的 frp 客户端的精简版本(最低约 3.5MB 左右),支持常用的部分功能,适用于资源有限的设备。 ## 赞助 如果您觉得 frp 对你有帮助,欢迎给予我们一定的捐助来维持项目的长期发展。 ### Sponsors 长期赞助可以帮助我们保持项目的持续发展。 您可以通过 [GitHub Sponsors](https://github.com/sponsors/fatedier) 赞助我们。 国内用户可以通过 [爱发电](https://afdian.com/a/fatedier) 赞助我们。 企业赞助者可以将贵公司的 Logo 以及链接放置在项目 README 文件中。 ================================================ FILE: Release.md ================================================ ## Features * Added a built-in `store` capability for frpc, including persisted store source (`[store] path = "..."`), Store CRUD admin APIs (`/api/store/proxies*`, `/api/store/visitors*`) with runtime reload, and Store management pages in the frpc web dashboard. ## Improvements * Kept proxy/visitor names as raw config names during completion; moved user-prefix handling to explicit wire-level naming logic. * Added `noweb` build tag to allow compiling without frontend assets. `make build` now auto-detects missing `web/*/dist` directories and skips embedding, so a fresh clone can build without running `make web` first. The dashboard gracefully returns 404 when assets are not embedded. * Improved config parsing errors: for `.toml` files, syntax errors now return immediately with parser position details (line/column when available) instead of falling through to YAML/JSON parsing, and TOML type mismatches report field-level errors without misleading line numbers. * OIDC auth now caches the access token and refreshes it before expiry, avoiding a new token request on every heartbeat. Falls back to per-request fetch when the provider omits `expires_in`. ================================================ FILE: assets/assets.go ================================================ // Copyright 2016 fatedier, fatedier@gmail.com // // Licensed under the Apache License, Version 2.0 (the "License"); // you may not use this file except in compliance with the License. // You may obtain a copy of the License at // // http://www.apache.org/licenses/LICENSE-2.0 // // Unless required by applicable law or agreed to in writing, software // distributed under the License is distributed on an "AS IS" BASIS, // WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. // See the License for the specific language governing permissions and // limitations under the License. package assets import ( "io/fs" "net/http" ) var ( // read-only filesystem created by "embed" for embedded files content fs.FS FileSystem http.FileSystem // if prefix is not empty, we get file content from disk prefixPath string ) type emptyFS struct{} func (emptyFS) Open(name string) (http.File, error) { return nil, &fs.PathError{Op: "open", Path: name, Err: fs.ErrNotExist} } // if path is empty, load assets in memory // or set FileSystem using disk files func Load(path string) { prefixPath = path switch { case prefixPath != "": FileSystem = http.Dir(prefixPath) case content != nil: FileSystem = http.FS(content) default: FileSystem = emptyFS{} } } func Register(fileSystem fs.FS) { subFs, err := fs.Sub(fileSystem, "dist") if err == nil { content = subFs } } ================================================ FILE: client/api_router.go ================================================ // Copyright 2017 fatedier, fatedier@gmail.com // // Licensed under the Apache License, Version 2.0 (the "License"); // you may not use this file except in compliance with the License. // You may obtain a copy of the License at // // http://www.apache.org/licenses/LICENSE-2.0 // // Unless required by applicable law or agreed to in writing, software // distributed under the License is distributed on an "AS IS" BASIS, // WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. // See the License for the specific language governing permissions and // limitations under the License. package client import ( "net/http" adminapi "github.com/fatedier/frp/client/http" "github.com/fatedier/frp/client/proxy" httppkg "github.com/fatedier/frp/pkg/util/http" netpkg "github.com/fatedier/frp/pkg/util/net" ) func (svr *Service) registerRouteHandlers(helper *httppkg.RouterRegisterHelper) { apiController := newAPIController(svr) // Healthz endpoint without auth helper.Router.HandleFunc("/healthz", healthz) // API routes and static files with auth subRouter := helper.Router.NewRoute().Subrouter() subRouter.Use(helper.AuthMiddleware) subRouter.Use(httppkg.NewRequestLogger) subRouter.HandleFunc("/api/reload", httppkg.MakeHTTPHandlerFunc(apiController.Reload)).Methods(http.MethodGet) subRouter.HandleFunc("/api/stop", httppkg.MakeHTTPHandlerFunc(apiController.Stop)).Methods(http.MethodPost) subRouter.HandleFunc("/api/status", httppkg.MakeHTTPHandlerFunc(apiController.Status)).Methods(http.MethodGet) subRouter.HandleFunc("/api/config", httppkg.MakeHTTPHandlerFunc(apiController.GetConfig)).Methods(http.MethodGet) subRouter.HandleFunc("/api/config", httppkg.MakeHTTPHandlerFunc(apiController.PutConfig)).Methods(http.MethodPut) subRouter.HandleFunc("/api/proxy/{name}/config", httppkg.MakeHTTPHandlerFunc(apiController.GetProxyConfig)).Methods(http.MethodGet) subRouter.HandleFunc("/api/visitor/{name}/config", httppkg.MakeHTTPHandlerFunc(apiController.GetVisitorConfig)).Methods(http.MethodGet) if svr.storeSource != nil { subRouter.HandleFunc("/api/store/proxies", httppkg.MakeHTTPHandlerFunc(apiController.ListStoreProxies)).Methods(http.MethodGet) subRouter.HandleFunc("/api/store/proxies", httppkg.MakeHTTPHandlerFunc(apiController.CreateStoreProxy)).Methods(http.MethodPost) subRouter.HandleFunc("/api/store/proxies/{name}", httppkg.MakeHTTPHandlerFunc(apiController.GetStoreProxy)).Methods(http.MethodGet) subRouter.HandleFunc("/api/store/proxies/{name}", httppkg.MakeHTTPHandlerFunc(apiController.UpdateStoreProxy)).Methods(http.MethodPut) subRouter.HandleFunc("/api/store/proxies/{name}", httppkg.MakeHTTPHandlerFunc(apiController.DeleteStoreProxy)).Methods(http.MethodDelete) subRouter.HandleFunc("/api/store/visitors", httppkg.MakeHTTPHandlerFunc(apiController.ListStoreVisitors)).Methods(http.MethodGet) subRouter.HandleFunc("/api/store/visitors", httppkg.MakeHTTPHandlerFunc(apiController.CreateStoreVisitor)).Methods(http.MethodPost) subRouter.HandleFunc("/api/store/visitors/{name}", httppkg.MakeHTTPHandlerFunc(apiController.GetStoreVisitor)).Methods(http.MethodGet) subRouter.HandleFunc("/api/store/visitors/{name}", httppkg.MakeHTTPHandlerFunc(apiController.UpdateStoreVisitor)).Methods(http.MethodPut) subRouter.HandleFunc("/api/store/visitors/{name}", httppkg.MakeHTTPHandlerFunc(apiController.DeleteStoreVisitor)).Methods(http.MethodDelete) } subRouter.Handle("/favicon.ico", http.FileServer(helper.AssetsFS)).Methods("GET") subRouter.PathPrefix("/static/").Handler( netpkg.MakeHTTPGzipHandler(http.StripPrefix("/static/", http.FileServer(helper.AssetsFS))), ).Methods("GET") subRouter.HandleFunc("/", func(w http.ResponseWriter, r *http.Request) { http.Redirect(w, r, "/static/", http.StatusMovedPermanently) }) } func healthz(w http.ResponseWriter, _ *http.Request) { w.WriteHeader(http.StatusOK) } func newAPIController(svr *Service) *adminapi.Controller { manager := newServiceConfigManager(svr) return adminapi.NewController(adminapi.ControllerParams{ ServerAddr: svr.common.ServerAddr, Manager: manager, }) } // getAllProxyStatus returns all proxy statuses. func (svr *Service) getAllProxyStatus() []*proxy.WorkingStatus { svr.ctlMu.RLock() ctl := svr.ctl svr.ctlMu.RUnlock() if ctl == nil { return nil } return ctl.pm.GetAllProxyStatus() } ================================================ FILE: client/config_manager.go ================================================ package client import ( "errors" "fmt" "os" "time" "github.com/fatedier/frp/client/configmgmt" "github.com/fatedier/frp/client/proxy" "github.com/fatedier/frp/pkg/config" "github.com/fatedier/frp/pkg/config/source" v1 "github.com/fatedier/frp/pkg/config/v1" "github.com/fatedier/frp/pkg/config/v1/validation" "github.com/fatedier/frp/pkg/util/log" ) type serviceConfigManager struct { svr *Service } func newServiceConfigManager(svr *Service) configmgmt.ConfigManager { return &serviceConfigManager{svr: svr} } func (m *serviceConfigManager) ReloadFromFile(strict bool) error { if m.svr.configFilePath == "" { return fmt.Errorf("%w: frpc has no config file path", configmgmt.ErrInvalidArgument) } result, err := config.LoadClientConfigResult(m.svr.configFilePath, strict) if err != nil { return fmt.Errorf("%w: %v", configmgmt.ErrInvalidArgument, err) } proxyCfgsForValidation, visitorCfgsForValidation := config.FilterClientConfigurers( result.Common, result.Proxies, result.Visitors, ) proxyCfgsForValidation = config.CompleteProxyConfigurers(proxyCfgsForValidation) visitorCfgsForValidation = config.CompleteVisitorConfigurers(visitorCfgsForValidation) if _, err := validation.ValidateAllClientConfig(result.Common, proxyCfgsForValidation, visitorCfgsForValidation, m.svr.unsafeFeatures); err != nil { return fmt.Errorf("%w: %v", configmgmt.ErrInvalidArgument, err) } if err := m.svr.UpdateConfigSource(result.Common, result.Proxies, result.Visitors); err != nil { return fmt.Errorf("%w: %v", configmgmt.ErrApplyConfig, err) } log.Infof("success reload conf") return nil } func (m *serviceConfigManager) ReadConfigFile() (string, error) { if m.svr.configFilePath == "" { return "", fmt.Errorf("%w: frpc has no config file path", configmgmt.ErrInvalidArgument) } content, err := os.ReadFile(m.svr.configFilePath) if err != nil { return "", fmt.Errorf("%w: %v", configmgmt.ErrInvalidArgument, err) } return string(content), nil } func (m *serviceConfigManager) WriteConfigFile(content []byte) error { if len(content) == 0 { return fmt.Errorf("%w: body can't be empty", configmgmt.ErrInvalidArgument) } if err := os.WriteFile(m.svr.configFilePath, content, 0o600); err != nil { return err } return nil } func (m *serviceConfigManager) GetProxyStatus() []*proxy.WorkingStatus { return m.svr.getAllProxyStatus() } func (m *serviceConfigManager) GetProxyConfig(name string) (v1.ProxyConfigurer, bool) { // Try running proxy manager first ws, ok := m.svr.getProxyStatus(name) if ok { return ws.Cfg, true } // Fallback to store m.svr.reloadMu.Lock() storeSource := m.svr.storeSource m.svr.reloadMu.Unlock() if storeSource != nil { cfg := storeSource.GetProxy(name) if cfg != nil { return cfg, true } } return nil, false } func (m *serviceConfigManager) GetVisitorConfig(name string) (v1.VisitorConfigurer, bool) { // Try running visitor manager first cfg, ok := m.svr.getVisitorCfg(name) if ok { return cfg, true } // Fallback to store m.svr.reloadMu.Lock() storeSource := m.svr.storeSource m.svr.reloadMu.Unlock() if storeSource != nil { vcfg := storeSource.GetVisitor(name) if vcfg != nil { return vcfg, true } } return nil, false } func (m *serviceConfigManager) IsStoreProxyEnabled(name string) bool { if name == "" { return false } m.svr.reloadMu.Lock() storeSource := m.svr.storeSource m.svr.reloadMu.Unlock() if storeSource == nil { return false } cfg := storeSource.GetProxy(name) if cfg == nil { return false } enabled := cfg.GetBaseConfig().Enabled return enabled == nil || *enabled } func (m *serviceConfigManager) StoreEnabled() bool { m.svr.reloadMu.Lock() storeSource := m.svr.storeSource m.svr.reloadMu.Unlock() return storeSource != nil } func (m *serviceConfigManager) ListStoreProxies() ([]v1.ProxyConfigurer, error) { storeSource, err := m.storeSourceOrError() if err != nil { return nil, err } return storeSource.GetAllProxies() } func (m *serviceConfigManager) GetStoreProxy(name string) (v1.ProxyConfigurer, error) { if name == "" { return nil, fmt.Errorf("%w: proxy name is required", configmgmt.ErrInvalidArgument) } storeSource, err := m.storeSourceOrError() if err != nil { return nil, err } cfg := storeSource.GetProxy(name) if cfg == nil { return nil, fmt.Errorf("%w: proxy %q", configmgmt.ErrNotFound, name) } return cfg, nil } func (m *serviceConfigManager) CreateStoreProxy(cfg v1.ProxyConfigurer) (v1.ProxyConfigurer, error) { if err := m.validateStoreProxyConfigurer(cfg); err != nil { return nil, fmt.Errorf("%w: validation error: %v", configmgmt.ErrInvalidArgument, err) } name := cfg.GetBaseConfig().Name persisted, err := m.withStoreProxyMutationAndReload(name, func(storeSource *source.StoreSource) error { if err := storeSource.AddProxy(cfg); err != nil { if errors.Is(err, source.ErrAlreadyExists) { return fmt.Errorf("%w: %v", configmgmt.ErrConflict, err) } return err } return nil }) if err != nil { return nil, err } log.Infof("store: created proxy %q", name) return persisted, nil } func (m *serviceConfigManager) UpdateStoreProxy(name string, cfg v1.ProxyConfigurer) (v1.ProxyConfigurer, error) { if name == "" { return nil, fmt.Errorf("%w: proxy name is required", configmgmt.ErrInvalidArgument) } if cfg == nil { return nil, fmt.Errorf("%w: invalid proxy config: type is required", configmgmt.ErrInvalidArgument) } bodyName := cfg.GetBaseConfig().Name if bodyName != name { return nil, fmt.Errorf("%w: proxy name in URL must match name in body", configmgmt.ErrInvalidArgument) } if err := m.validateStoreProxyConfigurer(cfg); err != nil { return nil, fmt.Errorf("%w: validation error: %v", configmgmt.ErrInvalidArgument, err) } persisted, err := m.withStoreProxyMutationAndReload(name, func(storeSource *source.StoreSource) error { if err := storeSource.UpdateProxy(cfg); err != nil { if errors.Is(err, source.ErrNotFound) { return fmt.Errorf("%w: %v", configmgmt.ErrNotFound, err) } return err } return nil }) if err != nil { return nil, err } log.Infof("store: updated proxy %q", name) return persisted, nil } func (m *serviceConfigManager) DeleteStoreProxy(name string) error { if name == "" { return fmt.Errorf("%w: proxy name is required", configmgmt.ErrInvalidArgument) } if err := m.withStoreMutationAndReload(func(storeSource *source.StoreSource) error { if err := storeSource.RemoveProxy(name); err != nil { if errors.Is(err, source.ErrNotFound) { return fmt.Errorf("%w: %v", configmgmt.ErrNotFound, err) } return err } return nil }); err != nil { return err } log.Infof("store: deleted proxy %q", name) return nil } func (m *serviceConfigManager) ListStoreVisitors() ([]v1.VisitorConfigurer, error) { storeSource, err := m.storeSourceOrError() if err != nil { return nil, err } return storeSource.GetAllVisitors() } func (m *serviceConfigManager) GetStoreVisitor(name string) (v1.VisitorConfigurer, error) { if name == "" { return nil, fmt.Errorf("%w: visitor name is required", configmgmt.ErrInvalidArgument) } storeSource, err := m.storeSourceOrError() if err != nil { return nil, err } cfg := storeSource.GetVisitor(name) if cfg == nil { return nil, fmt.Errorf("%w: visitor %q", configmgmt.ErrNotFound, name) } return cfg, nil } func (m *serviceConfigManager) CreateStoreVisitor(cfg v1.VisitorConfigurer) (v1.VisitorConfigurer, error) { if err := m.validateStoreVisitorConfigurer(cfg); err != nil { return nil, fmt.Errorf("%w: validation error: %v", configmgmt.ErrInvalidArgument, err) } name := cfg.GetBaseConfig().Name persisted, err := m.withStoreVisitorMutationAndReload(name, func(storeSource *source.StoreSource) error { if err := storeSource.AddVisitor(cfg); err != nil { if errors.Is(err, source.ErrAlreadyExists) { return fmt.Errorf("%w: %v", configmgmt.ErrConflict, err) } return err } return nil }) if err != nil { return nil, err } log.Infof("store: created visitor %q", name) return persisted, nil } func (m *serviceConfigManager) UpdateStoreVisitor(name string, cfg v1.VisitorConfigurer) (v1.VisitorConfigurer, error) { if name == "" { return nil, fmt.Errorf("%w: visitor name is required", configmgmt.ErrInvalidArgument) } if cfg == nil { return nil, fmt.Errorf("%w: invalid visitor config: type is required", configmgmt.ErrInvalidArgument) } bodyName := cfg.GetBaseConfig().Name if bodyName != name { return nil, fmt.Errorf("%w: visitor name in URL must match name in body", configmgmt.ErrInvalidArgument) } if err := m.validateStoreVisitorConfigurer(cfg); err != nil { return nil, fmt.Errorf("%w: validation error: %v", configmgmt.ErrInvalidArgument, err) } persisted, err := m.withStoreVisitorMutationAndReload(name, func(storeSource *source.StoreSource) error { if err := storeSource.UpdateVisitor(cfg); err != nil { if errors.Is(err, source.ErrNotFound) { return fmt.Errorf("%w: %v", configmgmt.ErrNotFound, err) } return err } return nil }) if err != nil { return nil, err } log.Infof("store: updated visitor %q", name) return persisted, nil } func (m *serviceConfigManager) DeleteStoreVisitor(name string) error { if name == "" { return fmt.Errorf("%w: visitor name is required", configmgmt.ErrInvalidArgument) } if err := m.withStoreMutationAndReload(func(storeSource *source.StoreSource) error { if err := storeSource.RemoveVisitor(name); err != nil { if errors.Is(err, source.ErrNotFound) { return fmt.Errorf("%w: %v", configmgmt.ErrNotFound, err) } return err } return nil }); err != nil { return err } log.Infof("store: deleted visitor %q", name) return nil } func (m *serviceConfigManager) GracefulClose(d time.Duration) { m.svr.GracefulClose(d) } func (m *serviceConfigManager) storeSourceOrError() (*source.StoreSource, error) { m.svr.reloadMu.Lock() storeSource := m.svr.storeSource m.svr.reloadMu.Unlock() if storeSource == nil { return nil, fmt.Errorf("%w: store API is disabled", configmgmt.ErrStoreDisabled) } return storeSource, nil } func (m *serviceConfigManager) withStoreMutationAndReload( fn func(storeSource *source.StoreSource) error, ) error { m.svr.reloadMu.Lock() defer m.svr.reloadMu.Unlock() storeSource := m.svr.storeSource if storeSource == nil { return fmt.Errorf("%w: store API is disabled", configmgmt.ErrStoreDisabled) } if err := fn(storeSource); err != nil { return err } if err := m.svr.reloadConfigFromSourcesLocked(); err != nil { return fmt.Errorf("%w: failed to apply config: %v", configmgmt.ErrApplyConfig, err) } return nil } func (m *serviceConfigManager) withStoreProxyMutationAndReload( name string, fn func(storeSource *source.StoreSource) error, ) (v1.ProxyConfigurer, error) { m.svr.reloadMu.Lock() defer m.svr.reloadMu.Unlock() storeSource := m.svr.storeSource if storeSource == nil { return nil, fmt.Errorf("%w: store API is disabled", configmgmt.ErrStoreDisabled) } if err := fn(storeSource); err != nil { return nil, err } if err := m.svr.reloadConfigFromSourcesLocked(); err != nil { return nil, fmt.Errorf("%w: failed to apply config: %v", configmgmt.ErrApplyConfig, err) } persisted := storeSource.GetProxy(name) if persisted == nil { return nil, fmt.Errorf("%w: proxy %q not found in store after mutation", configmgmt.ErrApplyConfig, name) } return persisted.Clone(), nil } func (m *serviceConfigManager) withStoreVisitorMutationAndReload( name string, fn func(storeSource *source.StoreSource) error, ) (v1.VisitorConfigurer, error) { m.svr.reloadMu.Lock() defer m.svr.reloadMu.Unlock() storeSource := m.svr.storeSource if storeSource == nil { return nil, fmt.Errorf("%w: store API is disabled", configmgmt.ErrStoreDisabled) } if err := fn(storeSource); err != nil { return nil, err } if err := m.svr.reloadConfigFromSourcesLocked(); err != nil { return nil, fmt.Errorf("%w: failed to apply config: %v", configmgmt.ErrApplyConfig, err) } persisted := storeSource.GetVisitor(name) if persisted == nil { return nil, fmt.Errorf("%w: visitor %q not found in store after mutation", configmgmt.ErrApplyConfig, name) } return persisted.Clone(), nil } func (m *serviceConfigManager) validateStoreProxyConfigurer(cfg v1.ProxyConfigurer) error { if cfg == nil { return fmt.Errorf("invalid proxy config") } runtimeCfg := cfg.Clone() if runtimeCfg == nil { return fmt.Errorf("invalid proxy config") } runtimeCfg.Complete() return validation.ValidateProxyConfigurerForClient(runtimeCfg) } func (m *serviceConfigManager) validateStoreVisitorConfigurer(cfg v1.VisitorConfigurer) error { if cfg == nil { return fmt.Errorf("invalid visitor config") } runtimeCfg := cfg.Clone() if runtimeCfg == nil { return fmt.Errorf("invalid visitor config") } runtimeCfg.Complete() return validation.ValidateVisitorConfigurer(runtimeCfg) } ================================================ FILE: client/config_manager_test.go ================================================ package client import ( "errors" "path/filepath" "testing" "github.com/fatedier/frp/client/configmgmt" "github.com/fatedier/frp/pkg/config/source" v1 "github.com/fatedier/frp/pkg/config/v1" ) func newTestRawTCPProxyConfig(name string) *v1.TCPProxyConfig { return &v1.TCPProxyConfig{ ProxyBaseConfig: v1.ProxyBaseConfig{ Name: name, Type: "tcp", ProxyBackend: v1.ProxyBackend{ LocalPort: 10080, }, }, } } func TestServiceConfigManagerCreateStoreProxyConflict(t *testing.T) { storeSource, err := source.NewStoreSource(source.StoreSourceConfig{ Path: filepath.Join(t.TempDir(), "store.json"), }) if err != nil { t.Fatalf("new store source: %v", err) } if err := storeSource.AddProxy(newTestRawTCPProxyConfig("p1")); err != nil { t.Fatalf("seed proxy: %v", err) } agg := source.NewAggregator(source.NewConfigSource()) agg.SetStoreSource(storeSource) mgr := &serviceConfigManager{ svr: &Service{ aggregator: agg, configSource: agg.ConfigSource(), storeSource: storeSource, reloadCommon: &v1.ClientCommonConfig{}, }, } _, err = mgr.CreateStoreProxy(newTestRawTCPProxyConfig("p1")) if err == nil { t.Fatal("expected conflict error") } if !errors.Is(err, configmgmt.ErrConflict) { t.Fatalf("unexpected error: %v", err) } } func TestServiceConfigManagerCreateStoreProxyKeepsStoreOnReloadFailure(t *testing.T) { storeSource, err := source.NewStoreSource(source.StoreSourceConfig{ Path: filepath.Join(t.TempDir(), "store.json"), }) if err != nil { t.Fatalf("new store source: %v", err) } mgr := &serviceConfigManager{ svr: &Service{ storeSource: storeSource, reloadCommon: &v1.ClientCommonConfig{}, }, } _, err = mgr.CreateStoreProxy(newTestRawTCPProxyConfig("p1")) if err == nil { t.Fatal("expected apply config error") } if !errors.Is(err, configmgmt.ErrApplyConfig) { t.Fatalf("unexpected error: %v", err) } if storeSource.GetProxy("p1") == nil { t.Fatal("proxy should remain in store after reload failure") } } func TestServiceConfigManagerCreateStoreProxyStoreDisabled(t *testing.T) { mgr := &serviceConfigManager{ svr: &Service{ reloadCommon: &v1.ClientCommonConfig{}, }, } _, err := mgr.CreateStoreProxy(newTestRawTCPProxyConfig("p1")) if err == nil { t.Fatal("expected store disabled error") } if !errors.Is(err, configmgmt.ErrStoreDisabled) { t.Fatalf("unexpected error: %v", err) } } func TestServiceConfigManagerCreateStoreProxyDoesNotPersistRuntimeDefaults(t *testing.T) { storeSource, err := source.NewStoreSource(source.StoreSourceConfig{ Path: filepath.Join(t.TempDir(), "store.json"), }) if err != nil { t.Fatalf("new store source: %v", err) } agg := source.NewAggregator(source.NewConfigSource()) agg.SetStoreSource(storeSource) mgr := &serviceConfigManager{ svr: &Service{ aggregator: agg, configSource: agg.ConfigSource(), storeSource: storeSource, reloadCommon: &v1.ClientCommonConfig{}, }, } persisted, err := mgr.CreateStoreProxy(newTestRawTCPProxyConfig("raw-proxy")) if err != nil { t.Fatalf("create store proxy: %v", err) } if persisted == nil { t.Fatal("expected persisted proxy to be returned") } got := storeSource.GetProxy("raw-proxy") if got == nil { t.Fatal("proxy not found in store") } if got.GetBaseConfig().LocalIP != "" { t.Fatalf("localIP was persisted with runtime default: %q", got.GetBaseConfig().LocalIP) } if got.GetBaseConfig().Transport.BandwidthLimitMode != "" { t.Fatalf("bandwidthLimitMode was persisted with runtime default: %q", got.GetBaseConfig().Transport.BandwidthLimitMode) } } ================================================ FILE: client/configmgmt/types.go ================================================ package configmgmt import ( "errors" "time" "github.com/fatedier/frp/client/proxy" v1 "github.com/fatedier/frp/pkg/config/v1" ) var ( ErrInvalidArgument = errors.New("invalid argument") ErrNotFound = errors.New("not found") ErrConflict = errors.New("conflict") ErrStoreDisabled = errors.New("store disabled") ErrApplyConfig = errors.New("apply config failed") ) type ConfigManager interface { ReloadFromFile(strict bool) error ReadConfigFile() (string, error) WriteConfigFile(content []byte) error GetProxyStatus() []*proxy.WorkingStatus IsStoreProxyEnabled(name string) bool StoreEnabled() bool GetProxyConfig(name string) (v1.ProxyConfigurer, bool) GetVisitorConfig(name string) (v1.VisitorConfigurer, bool) ListStoreProxies() ([]v1.ProxyConfigurer, error) GetStoreProxy(name string) (v1.ProxyConfigurer, error) CreateStoreProxy(cfg v1.ProxyConfigurer) (v1.ProxyConfigurer, error) UpdateStoreProxy(name string, cfg v1.ProxyConfigurer) (v1.ProxyConfigurer, error) DeleteStoreProxy(name string) error ListStoreVisitors() ([]v1.VisitorConfigurer, error) GetStoreVisitor(name string) (v1.VisitorConfigurer, error) CreateStoreVisitor(cfg v1.VisitorConfigurer) (v1.VisitorConfigurer, error) UpdateStoreVisitor(name string, cfg v1.VisitorConfigurer) (v1.VisitorConfigurer, error) DeleteStoreVisitor(name string) error GracefulClose(d time.Duration) } ================================================ FILE: client/connector.go ================================================ // Copyright 2023 The frp Authors // // Licensed under the Apache License, Version 2.0 (the "License"); // you may not use this file except in compliance with the License. // You may obtain a copy of the License at // // http://www.apache.org/licenses/LICENSE-2.0 // // Unless required by applicable law or agreed to in writing, software // distributed under the License is distributed on an "AS IS" BASIS, // WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. // See the License for the specific language governing permissions and // limitations under the License. package client import ( "context" "crypto/tls" "net" "strconv" "strings" "sync" "time" libnet "github.com/fatedier/golib/net" fmux "github.com/hashicorp/yamux" quic "github.com/quic-go/quic-go" "github.com/samber/lo" v1 "github.com/fatedier/frp/pkg/config/v1" "github.com/fatedier/frp/pkg/transport" netpkg "github.com/fatedier/frp/pkg/util/net" "github.com/fatedier/frp/pkg/util/xlog" ) // Connector is an interface for establishing connections to the server. type Connector interface { Open() error Connect() (net.Conn, error) Close() error } // defaultConnectorImpl is the default implementation of Connector for normal frpc. type defaultConnectorImpl struct { ctx context.Context cfg *v1.ClientCommonConfig muxSession *fmux.Session quicConn *quic.Conn closeOnce sync.Once } func NewConnector(ctx context.Context, cfg *v1.ClientCommonConfig) Connector { return &defaultConnectorImpl{ ctx: ctx, cfg: cfg, } } // Open opens an underlying connection to the server. // The underlying connection is either a TCP connection or a QUIC connection. // After the underlying connection is established, you can call Connect() to get a stream. // If TCPMux isn't enabled, the underlying connection is nil, you will get a new real TCP connection every time you call Connect(). func (c *defaultConnectorImpl) Open() error { xl := xlog.FromContextSafe(c.ctx) // special for quic if strings.EqualFold(c.cfg.Transport.Protocol, "quic") { var tlsConfig *tls.Config var err error sn := c.cfg.Transport.TLS.ServerName if sn == "" { sn = c.cfg.ServerAddr } if lo.FromPtr(c.cfg.Transport.TLS.Enable) { tlsConfig, err = transport.NewClientTLSConfig( c.cfg.Transport.TLS.CertFile, c.cfg.Transport.TLS.KeyFile, c.cfg.Transport.TLS.TrustedCaFile, sn) } else { tlsConfig, err = transport.NewClientTLSConfig("", "", "", sn) } if err != nil { xl.Warnf("fail to build tls configuration, err: %v", err) return err } tlsConfig.NextProtos = []string{"frp"} conn, err := quic.DialAddr( c.ctx, net.JoinHostPort(c.cfg.ServerAddr, strconv.Itoa(c.cfg.ServerPort)), tlsConfig, &quic.Config{ MaxIdleTimeout: time.Duration(c.cfg.Transport.QUIC.MaxIdleTimeout) * time.Second, MaxIncomingStreams: int64(c.cfg.Transport.QUIC.MaxIncomingStreams), KeepAlivePeriod: time.Duration(c.cfg.Transport.QUIC.KeepalivePeriod) * time.Second, }) if err != nil { return err } c.quicConn = conn return nil } if !lo.FromPtr(c.cfg.Transport.TCPMux) { return nil } conn, err := c.realConnect() if err != nil { return err } fmuxCfg := fmux.DefaultConfig() fmuxCfg.KeepAliveInterval = time.Duration(c.cfg.Transport.TCPMuxKeepaliveInterval) * time.Second // Use trace level for yamux logs fmuxCfg.LogOutput = xlog.NewTraceWriter(xl) fmuxCfg.MaxStreamWindowSize = 6 * 1024 * 1024 session, err := fmux.Client(conn, fmuxCfg) if err != nil { return err } c.muxSession = session return nil } // Connect returns a stream from the underlying connection, or a new TCP connection if TCPMux isn't enabled. func (c *defaultConnectorImpl) Connect() (net.Conn, error) { if c.quicConn != nil { stream, err := c.quicConn.OpenStreamSync(context.Background()) if err != nil { return nil, err } return netpkg.QuicStreamToNetConn(stream, c.quicConn), nil } else if c.muxSession != nil { stream, err := c.muxSession.OpenStream() if err != nil { return nil, err } return stream, nil } return c.realConnect() } func (c *defaultConnectorImpl) realConnect() (net.Conn, error) { xl := xlog.FromContextSafe(c.ctx) var tlsConfig *tls.Config var err error tlsEnable := lo.FromPtr(c.cfg.Transport.TLS.Enable) if c.cfg.Transport.Protocol == "wss" { tlsEnable = true } if tlsEnable { sn := c.cfg.Transport.TLS.ServerName if sn == "" { sn = c.cfg.ServerAddr } tlsConfig, err = transport.NewClientTLSConfig( c.cfg.Transport.TLS.CertFile, c.cfg.Transport.TLS.KeyFile, c.cfg.Transport.TLS.TrustedCaFile, sn) if err != nil { xl.Warnf("fail to build tls configuration, err: %v", err) return nil, err } } proxyType, addr, auth, err := libnet.ParseProxyURL(c.cfg.Transport.ProxyURL) if err != nil { xl.Errorf("fail to parse proxy url") return nil, err } dialOptions := []libnet.DialOption{} protocol := c.cfg.Transport.Protocol switch protocol { case "websocket": protocol = "tcp" dialOptions = append(dialOptions, libnet.WithAfterHook(libnet.AfterHook{Hook: netpkg.DialHookWebsocket(protocol, "")})) dialOptions = append(dialOptions, libnet.WithAfterHook(libnet.AfterHook{ Hook: netpkg.DialHookCustomTLSHeadByte(tlsConfig != nil, lo.FromPtr(c.cfg.Transport.TLS.DisableCustomTLSFirstByte)), })) dialOptions = append(dialOptions, libnet.WithTLSConfig(tlsConfig)) case "wss": protocol = "tcp" dialOptions = append(dialOptions, libnet.WithTLSConfigAndPriority(100, tlsConfig)) // Make sure that if it is wss, the websocket hook is executed after the tls hook. dialOptions = append(dialOptions, libnet.WithAfterHook(libnet.AfterHook{Hook: netpkg.DialHookWebsocket(protocol, tlsConfig.ServerName), Priority: 110})) default: dialOptions = append(dialOptions, libnet.WithAfterHook(libnet.AfterHook{ Hook: netpkg.DialHookCustomTLSHeadByte(tlsConfig != nil, lo.FromPtr(c.cfg.Transport.TLS.DisableCustomTLSFirstByte)), })) dialOptions = append(dialOptions, libnet.WithTLSConfig(tlsConfig)) } if c.cfg.Transport.ConnectServerLocalIP != "" { dialOptions = append(dialOptions, libnet.WithLocalAddr(c.cfg.Transport.ConnectServerLocalIP)) } dialOptions = append(dialOptions, libnet.WithProtocol(protocol), libnet.WithTimeout(time.Duration(c.cfg.Transport.DialServerTimeout)*time.Second), libnet.WithKeepAlive(time.Duration(c.cfg.Transport.DialServerKeepAlive)*time.Second), libnet.WithProxy(proxyType, addr), libnet.WithProxyAuth(auth), ) conn, err := libnet.DialContext( c.ctx, net.JoinHostPort(c.cfg.ServerAddr, strconv.Itoa(c.cfg.ServerPort)), dialOptions..., ) return conn, err } func (c *defaultConnectorImpl) Close() error { c.closeOnce.Do(func() { if c.quicConn != nil { _ = c.quicConn.CloseWithError(0, "") } if c.muxSession != nil { _ = c.muxSession.Close() } }) return nil } ================================================ FILE: client/control.go ================================================ // Copyright 2017 fatedier, fatedier@gmail.com // // Licensed under the Apache License, Version 2.0 (the "License"); // you may not use this file except in compliance with the License. // You may obtain a copy of the License at // // http://www.apache.org/licenses/LICENSE-2.0 // // Unless required by applicable law or agreed to in writing, software // distributed under the License is distributed on an "AS IS" BASIS, // WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. // See the License for the specific language governing permissions and // limitations under the License. package client import ( "context" "net" "sync/atomic" "time" "github.com/fatedier/frp/client/proxy" "github.com/fatedier/frp/client/visitor" "github.com/fatedier/frp/pkg/auth" v1 "github.com/fatedier/frp/pkg/config/v1" "github.com/fatedier/frp/pkg/msg" "github.com/fatedier/frp/pkg/naming" "github.com/fatedier/frp/pkg/transport" netpkg "github.com/fatedier/frp/pkg/util/net" "github.com/fatedier/frp/pkg/util/wait" "github.com/fatedier/frp/pkg/util/xlog" "github.com/fatedier/frp/pkg/vnet" ) type SessionContext struct { // The client common configuration. Common *v1.ClientCommonConfig // Unique ID obtained from frps. // It should be attached to the login message when reconnecting. RunID string // Underlying control connection. Once conn is closed, the msgDispatcher and the entire Control will exit. Conn net.Conn // Indicates whether the connection is encrypted. ConnEncrypted bool // Auth runtime used for login, heartbeats, and encryption. Auth *auth.ClientAuth // Connector is used to create new connections, which could be real TCP connections or virtual streams. Connector Connector // Virtual net controller VnetController *vnet.Controller } type Control struct { // service context ctx context.Context xl *xlog.Logger // session context sessionCtx *SessionContext // manage all proxies pm *proxy.Manager // manage all visitors vm *visitor.Manager doneCh chan struct{} // of time.Time, last time got the Pong message lastPong atomic.Value // The role of msgTransporter is similar to HTTP2. // It allows multiple messages to be sent simultaneously on the same control connection. // The server's response messages will be dispatched to the corresponding waiting goroutines based on the laneKey and message type. msgTransporter transport.MessageTransporter // msgDispatcher is a wrapper for control connection. // It provides a channel for sending messages, and you can register handlers to process messages based on their respective types. msgDispatcher *msg.Dispatcher } func NewControl(ctx context.Context, sessionCtx *SessionContext) (*Control, error) { // new xlog instance ctl := &Control{ ctx: ctx, xl: xlog.FromContextSafe(ctx), sessionCtx: sessionCtx, doneCh: make(chan struct{}), } ctl.lastPong.Store(time.Now()) if sessionCtx.ConnEncrypted { cryptoRW, err := netpkg.NewCryptoReadWriter(sessionCtx.Conn, sessionCtx.Auth.EncryptionKey()) if err != nil { return nil, err } ctl.msgDispatcher = msg.NewDispatcher(cryptoRW) } else { ctl.msgDispatcher = msg.NewDispatcher(sessionCtx.Conn) } ctl.registerMsgHandlers() ctl.msgTransporter = transport.NewMessageTransporter(ctl.msgDispatcher) ctl.pm = proxy.NewManager(ctl.ctx, sessionCtx.Common, sessionCtx.Auth.EncryptionKey(), ctl.msgTransporter, sessionCtx.VnetController) ctl.vm = visitor.NewManager(ctl.ctx, sessionCtx.RunID, sessionCtx.Common, ctl.connectServer, ctl.msgTransporter, sessionCtx.VnetController) return ctl, nil } func (ctl *Control) Run(proxyCfgs []v1.ProxyConfigurer, visitorCfgs []v1.VisitorConfigurer) { go ctl.worker() // start all proxies ctl.pm.UpdateAll(proxyCfgs) // start all visitors ctl.vm.UpdateAll(visitorCfgs) } func (ctl *Control) SetInWorkConnCallback(cb func(*v1.ProxyBaseConfig, net.Conn, *msg.StartWorkConn) bool) { ctl.pm.SetInWorkConnCallback(cb) } func (ctl *Control) handleReqWorkConn(_ msg.Message) { xl := ctl.xl workConn, err := ctl.connectServer() if err != nil { xl.Warnf("start new connection to server error: %v", err) return } m := &msg.NewWorkConn{ RunID: ctl.sessionCtx.RunID, } if err = ctl.sessionCtx.Auth.Setter.SetNewWorkConn(m); err != nil { xl.Warnf("error during NewWorkConn authentication: %v", err) workConn.Close() return } if err = msg.WriteMsg(workConn, m); err != nil { xl.Warnf("work connection write to server error: %v", err) workConn.Close() return } var startMsg msg.StartWorkConn if err = msg.ReadMsgInto(workConn, &startMsg); err != nil { xl.Tracef("work connection closed before response StartWorkConn message: %v", err) workConn.Close() return } if startMsg.Error != "" { xl.Errorf("StartWorkConn contains error: %s", startMsg.Error) workConn.Close() return } startMsg.ProxyName = naming.StripUserPrefix(ctl.sessionCtx.Common.User, startMsg.ProxyName) // dispatch this work connection to related proxy ctl.pm.HandleWorkConn(startMsg.ProxyName, workConn, &startMsg) } func (ctl *Control) handleNewProxyResp(m msg.Message) { xl := ctl.xl inMsg := m.(*msg.NewProxyResp) // Server will return NewProxyResp message to each NewProxy message. // Start a new proxy handler if no error got proxyName := naming.StripUserPrefix(ctl.sessionCtx.Common.User, inMsg.ProxyName) err := ctl.pm.StartProxy(proxyName, inMsg.RemoteAddr, inMsg.Error) if err != nil { xl.Warnf("[%s] start error: %v", proxyName, err) } else { xl.Infof("[%s] start proxy success", proxyName) } } func (ctl *Control) handleNatHoleResp(m msg.Message) { xl := ctl.xl inMsg := m.(*msg.NatHoleResp) // Dispatch the NatHoleResp message to the related proxy. ok := ctl.msgTransporter.DispatchWithType(inMsg, msg.TypeNameNatHoleResp, inMsg.TransactionID) if !ok { xl.Tracef("dispatch NatHoleResp message to related proxy error") } } func (ctl *Control) handlePong(m msg.Message) { xl := ctl.xl inMsg := m.(*msg.Pong) if inMsg.Error != "" { xl.Errorf("pong message contains error: %s", inMsg.Error) ctl.closeSession() return } ctl.lastPong.Store(time.Now()) xl.Debugf("receive heartbeat from server") } // closeSession closes the control connection. func (ctl *Control) closeSession() { ctl.sessionCtx.Conn.Close() ctl.sessionCtx.Connector.Close() } func (ctl *Control) Close() error { return ctl.GracefulClose(0) } func (ctl *Control) GracefulClose(d time.Duration) error { ctl.pm.Close() ctl.vm.Close() time.Sleep(d) ctl.closeSession() return nil } // Done returns a channel that will be closed after all resources are released func (ctl *Control) Done() <-chan struct{} { return ctl.doneCh } // connectServer return a new connection to frps func (ctl *Control) connectServer() (net.Conn, error) { return ctl.sessionCtx.Connector.Connect() } func (ctl *Control) registerMsgHandlers() { ctl.msgDispatcher.RegisterHandler(&msg.ReqWorkConn{}, msg.AsyncHandler(ctl.handleReqWorkConn)) ctl.msgDispatcher.RegisterHandler(&msg.NewProxyResp{}, ctl.handleNewProxyResp) ctl.msgDispatcher.RegisterHandler(&msg.NatHoleResp{}, ctl.handleNatHoleResp) ctl.msgDispatcher.RegisterHandler(&msg.Pong{}, ctl.handlePong) } // heartbeatWorker sends heartbeat to server and check heartbeat timeout. func (ctl *Control) heartbeatWorker() { xl := ctl.xl if ctl.sessionCtx.Common.Transport.HeartbeatInterval > 0 { // Send heartbeat to server. sendHeartBeat := func() (bool, error) { xl.Debugf("send heartbeat to server") pingMsg := &msg.Ping{} if err := ctl.sessionCtx.Auth.Setter.SetPing(pingMsg); err != nil { xl.Warnf("error during ping authentication: %v, skip sending ping message", err) return false, err } _ = ctl.msgDispatcher.Send(pingMsg) return false, nil } go wait.BackoffUntil(sendHeartBeat, wait.NewFastBackoffManager(wait.FastBackoffOptions{ Duration: time.Duration(ctl.sessionCtx.Common.Transport.HeartbeatInterval) * time.Second, InitDurationIfFail: time.Second, Factor: 2.0, Jitter: 0.1, MaxDuration: time.Duration(ctl.sessionCtx.Common.Transport.HeartbeatInterval) * time.Second, }), true, ctl.doneCh, ) } // Check heartbeat timeout. if ctl.sessionCtx.Common.Transport.HeartbeatInterval > 0 && ctl.sessionCtx.Common.Transport.HeartbeatTimeout > 0 { go wait.Until(func() { if time.Since(ctl.lastPong.Load().(time.Time)) > time.Duration(ctl.sessionCtx.Common.Transport.HeartbeatTimeout)*time.Second { xl.Warnf("heartbeat timeout") ctl.closeSession() return } }, time.Second, ctl.doneCh) } } func (ctl *Control) worker() { xl := ctl.xl go ctl.heartbeatWorker() go ctl.msgDispatcher.Run() <-ctl.msgDispatcher.Done() xl.Debugf("control message dispatcher exited") ctl.closeSession() ctl.pm.Close() ctl.vm.Close() close(ctl.doneCh) } func (ctl *Control) UpdateAllConfigurer(proxyCfgs []v1.ProxyConfigurer, visitorCfgs []v1.VisitorConfigurer) error { ctl.vm.UpdateAll(visitorCfgs) ctl.pm.UpdateAll(proxyCfgs) return nil } ================================================ FILE: client/event/event.go ================================================ package event import ( "errors" "github.com/fatedier/frp/pkg/msg" ) var ErrPayloadType = errors.New("error payload type") type Handler func(payload any) error type StartProxyPayload struct { NewProxyMsg *msg.NewProxy } type CloseProxyPayload struct { CloseProxyMsg *msg.CloseProxy } ================================================ FILE: client/health/health.go ================================================ // Copyright 2018 fatedier, fatedier@gmail.com // // Licensed under the Apache License, Version 2.0 (the "License"); // you may not use this file except in compliance with the License. // You may obtain a copy of the License at // // http://www.apache.org/licenses/LICENSE-2.0 // // Unless required by applicable law or agreed to in writing, software // distributed under the License is distributed on an "AS IS" BASIS, // WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. // See the License for the specific language governing permissions and // limitations under the License. package health import ( "context" "errors" "fmt" "io" "net" "net/http" "strings" "time" v1 "github.com/fatedier/frp/pkg/config/v1" "github.com/fatedier/frp/pkg/util/xlog" ) var ErrHealthCheckType = errors.New("error health check type") type Monitor struct { checkType string interval time.Duration timeout time.Duration maxFailedTimes int // For tcp addr string // For http url string header http.Header failedTimes uint64 statusOK bool statusNormalFn func() statusFailedFn func() ctx context.Context cancel context.CancelFunc } func NewMonitor(ctx context.Context, cfg v1.HealthCheckConfig, addr string, statusNormalFn func(), statusFailedFn func(), ) *Monitor { if cfg.IntervalSeconds <= 0 { cfg.IntervalSeconds = 10 } if cfg.TimeoutSeconds <= 0 { cfg.TimeoutSeconds = 3 } if cfg.MaxFailed <= 0 { cfg.MaxFailed = 1 } newctx, cancel := context.WithCancel(ctx) var url string if cfg.Type == "http" && cfg.Path != "" { s := "http://" + addr if !strings.HasPrefix(cfg.Path, "/") { s += "/" } url = s + cfg.Path } header := make(http.Header) for _, h := range cfg.HTTPHeaders { header.Set(h.Name, h.Value) } return &Monitor{ checkType: cfg.Type, interval: time.Duration(cfg.IntervalSeconds) * time.Second, timeout: time.Duration(cfg.TimeoutSeconds) * time.Second, maxFailedTimes: cfg.MaxFailed, addr: addr, url: url, header: header, statusOK: false, statusNormalFn: statusNormalFn, statusFailedFn: statusFailedFn, ctx: newctx, cancel: cancel, } } func (monitor *Monitor) Start() { go monitor.checkWorker() } func (monitor *Monitor) Stop() { monitor.cancel() } func (monitor *Monitor) checkWorker() { xl := xlog.FromContextSafe(monitor.ctx) for { doCtx, cancel := context.WithDeadline(monitor.ctx, time.Now().Add(monitor.timeout)) err := monitor.doCheck(doCtx) // check if this monitor has been closed select { case <-monitor.ctx.Done(): cancel() return default: cancel() } if err == nil { xl.Tracef("do one health check success") if !monitor.statusOK && monitor.statusNormalFn != nil { xl.Infof("health check status change to success") monitor.statusOK = true monitor.statusNormalFn() } } else { xl.Warnf("do one health check failed: %v", err) monitor.failedTimes++ if monitor.statusOK && int(monitor.failedTimes) >= monitor.maxFailedTimes && monitor.statusFailedFn != nil { xl.Warnf("health check status change to failed") monitor.statusOK = false monitor.statusFailedFn() } } time.Sleep(monitor.interval) } } func (monitor *Monitor) doCheck(ctx context.Context) error { switch monitor.checkType { case "tcp": return monitor.doTCPCheck(ctx) case "http": return monitor.doHTTPCheck(ctx) default: return ErrHealthCheckType } } func (monitor *Monitor) doTCPCheck(ctx context.Context) error { // if tcp address is not specified, always return nil if monitor.addr == "" { return nil } var d net.Dialer conn, err := d.DialContext(ctx, "tcp", monitor.addr) if err != nil { return err } conn.Close() return nil } func (monitor *Monitor) doHTTPCheck(ctx context.Context) error { req, err := http.NewRequestWithContext(ctx, "GET", monitor.url, nil) if err != nil { return err } req.Header = monitor.header req.Host = monitor.header.Get("Host") resp, err := http.DefaultClient.Do(req) if err != nil { return err } defer resp.Body.Close() _, _ = io.Copy(io.Discard, resp.Body) if resp.StatusCode/100 != 2 { return fmt.Errorf("do http health check, StatusCode is [%d] not 2xx", resp.StatusCode) } return nil } ================================================ FILE: client/http/controller.go ================================================ // Copyright 2025 The frp Authors // // Licensed under the Apache License, Version 2.0 (the "License"); // you may not use this file except in compliance with the License. // You may obtain a copy of the License at // // http://www.apache.org/licenses/LICENSE-2.0 // // Unless required by applicable law or agreed to in writing, software // distributed under the License is distributed on an "AS IS" BASIS, // WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. // See the License for the specific language governing permissions and // limitations under the License. package http import ( "cmp" "errors" "fmt" "net" "net/http" "slices" "strconv" "time" "github.com/fatedier/frp/client/configmgmt" "github.com/fatedier/frp/client/http/model" "github.com/fatedier/frp/client/proxy" httppkg "github.com/fatedier/frp/pkg/util/http" "github.com/fatedier/frp/pkg/util/jsonx" ) // Controller handles HTTP API requests for frpc. type Controller struct { serverAddr string manager configmgmt.ConfigManager } // ControllerParams contains parameters for creating an APIController. type ControllerParams struct { ServerAddr string Manager configmgmt.ConfigManager } func NewController(params ControllerParams) *Controller { return &Controller{ serverAddr: params.ServerAddr, manager: params.Manager, } } func (c *Controller) toHTTPError(err error) error { if err == nil { return nil } code := http.StatusInternalServerError switch { case errors.Is(err, configmgmt.ErrInvalidArgument): code = http.StatusBadRequest case errors.Is(err, configmgmt.ErrNotFound), errors.Is(err, configmgmt.ErrStoreDisabled): code = http.StatusNotFound case errors.Is(err, configmgmt.ErrConflict): code = http.StatusConflict } return httppkg.NewError(code, err.Error()) } // Reload handles GET /api/reload func (c *Controller) Reload(ctx *httppkg.Context) (any, error) { strictConfigMode := false strictStr := ctx.Query("strictConfig") if strictStr != "" { strictConfigMode, _ = strconv.ParseBool(strictStr) } if err := c.manager.ReloadFromFile(strictConfigMode); err != nil { return nil, c.toHTTPError(err) } return nil, nil } // Stop handles POST /api/stop func (c *Controller) Stop(ctx *httppkg.Context) (any, error) { go c.manager.GracefulClose(100 * time.Millisecond) return nil, nil } // Status handles GET /api/status func (c *Controller) Status(ctx *httppkg.Context) (any, error) { res := make(model.StatusResp) ps := c.manager.GetProxyStatus() if ps == nil { return res, nil } for _, status := range ps { res[status.Type] = append(res[status.Type], c.buildProxyStatusResp(status)) } for _, arrs := range res { if len(arrs) <= 1 { continue } slices.SortFunc(arrs, func(a, b model.ProxyStatusResp) int { return cmp.Compare(a.Name, b.Name) }) } return res, nil } // GetConfig handles GET /api/config func (c *Controller) GetConfig(ctx *httppkg.Context) (any, error) { content, err := c.manager.ReadConfigFile() if err != nil { return nil, c.toHTTPError(err) } return content, nil } // PutConfig handles PUT /api/config func (c *Controller) PutConfig(ctx *httppkg.Context) (any, error) { body, err := ctx.Body() if err != nil { return nil, httppkg.NewError(http.StatusBadRequest, fmt.Sprintf("read request body error: %v", err)) } if len(body) == 0 { return nil, httppkg.NewError(http.StatusBadRequest, "body can't be empty") } if err := c.manager.WriteConfigFile(body); err != nil { return nil, c.toHTTPError(err) } return nil, nil } func (c *Controller) buildProxyStatusResp(status *proxy.WorkingStatus) model.ProxyStatusResp { psr := model.ProxyStatusResp{ Name: status.Name, Type: status.Type, Status: status.Phase, Err: status.Err, } baseCfg := status.Cfg.GetBaseConfig() if baseCfg.LocalPort != 0 { psr.LocalAddr = net.JoinHostPort(baseCfg.LocalIP, strconv.Itoa(baseCfg.LocalPort)) } psr.Plugin = baseCfg.Plugin.Type if status.Err == "" { psr.RemoteAddr = status.RemoteAddr if slices.Contains([]string{"tcp", "udp"}, status.Type) { psr.RemoteAddr = c.serverAddr + psr.RemoteAddr } } if c.manager.IsStoreProxyEnabled(status.Name) { psr.Source = model.SourceStore } return psr } // GetProxyConfig handles GET /api/proxy/{name}/config func (c *Controller) GetProxyConfig(ctx *httppkg.Context) (any, error) { name := ctx.Param("name") if name == "" { return nil, httppkg.NewError(http.StatusBadRequest, "proxy name is required") } cfg, ok := c.manager.GetProxyConfig(name) if !ok { return nil, httppkg.NewError(http.StatusNotFound, fmt.Sprintf("proxy %q not found", name)) } payload, err := model.ProxyDefinitionFromConfigurer(cfg) if err != nil { return nil, httppkg.NewError(http.StatusInternalServerError, err.Error()) } return payload, nil } // GetVisitorConfig handles GET /api/visitor/{name}/config func (c *Controller) GetVisitorConfig(ctx *httppkg.Context) (any, error) { name := ctx.Param("name") if name == "" { return nil, httppkg.NewError(http.StatusBadRequest, "visitor name is required") } cfg, ok := c.manager.GetVisitorConfig(name) if !ok { return nil, httppkg.NewError(http.StatusNotFound, fmt.Sprintf("visitor %q not found", name)) } payload, err := model.VisitorDefinitionFromConfigurer(cfg) if err != nil { return nil, httppkg.NewError(http.StatusInternalServerError, err.Error()) } return payload, nil } func (c *Controller) ListStoreProxies(ctx *httppkg.Context) (any, error) { proxies, err := c.manager.ListStoreProxies() if err != nil { return nil, c.toHTTPError(err) } resp := model.ProxyListResp{Proxies: make([]model.ProxyDefinition, 0, len(proxies))} for _, p := range proxies { payload, err := model.ProxyDefinitionFromConfigurer(p) if err != nil { return nil, httppkg.NewError(http.StatusInternalServerError, err.Error()) } resp.Proxies = append(resp.Proxies, payload) } slices.SortFunc(resp.Proxies, func(a, b model.ProxyDefinition) int { return cmp.Compare(a.Name, b.Name) }) return resp, nil } func (c *Controller) GetStoreProxy(ctx *httppkg.Context) (any, error) { name := ctx.Param("name") if name == "" { return nil, httppkg.NewError(http.StatusBadRequest, "proxy name is required") } p, err := c.manager.GetStoreProxy(name) if err != nil { return nil, c.toHTTPError(err) } payload, err := model.ProxyDefinitionFromConfigurer(p) if err != nil { return nil, httppkg.NewError(http.StatusInternalServerError, err.Error()) } return payload, nil } func (c *Controller) CreateStoreProxy(ctx *httppkg.Context) (any, error) { body, err := ctx.Body() if err != nil { return nil, httppkg.NewError(http.StatusBadRequest, fmt.Sprintf("read body error: %v", err)) } var payload model.ProxyDefinition if err := jsonx.Unmarshal(body, &payload); err != nil { return nil, httppkg.NewError(http.StatusBadRequest, fmt.Sprintf("parse JSON error: %v", err)) } if err := payload.Validate("", false); err != nil { return nil, httppkg.NewError(http.StatusBadRequest, err.Error()) } cfg, err := payload.ToConfigurer() if err != nil { return nil, httppkg.NewError(http.StatusBadRequest, err.Error()) } created, err := c.manager.CreateStoreProxy(cfg) if err != nil { return nil, c.toHTTPError(err) } resp, err := model.ProxyDefinitionFromConfigurer(created) if err != nil { return nil, httppkg.NewError(http.StatusInternalServerError, err.Error()) } return resp, nil } func (c *Controller) UpdateStoreProxy(ctx *httppkg.Context) (any, error) { name := ctx.Param("name") if name == "" { return nil, httppkg.NewError(http.StatusBadRequest, "proxy name is required") } body, err := ctx.Body() if err != nil { return nil, httppkg.NewError(http.StatusBadRequest, fmt.Sprintf("read body error: %v", err)) } var payload model.ProxyDefinition if err := jsonx.Unmarshal(body, &payload); err != nil { return nil, httppkg.NewError(http.StatusBadRequest, fmt.Sprintf("parse JSON error: %v", err)) } if err := payload.Validate(name, true); err != nil { return nil, httppkg.NewError(http.StatusBadRequest, err.Error()) } cfg, err := payload.ToConfigurer() if err != nil { return nil, httppkg.NewError(http.StatusBadRequest, err.Error()) } updated, err := c.manager.UpdateStoreProxy(name, cfg) if err != nil { return nil, c.toHTTPError(err) } resp, err := model.ProxyDefinitionFromConfigurer(updated) if err != nil { return nil, httppkg.NewError(http.StatusInternalServerError, err.Error()) } return resp, nil } func (c *Controller) DeleteStoreProxy(ctx *httppkg.Context) (any, error) { name := ctx.Param("name") if name == "" { return nil, httppkg.NewError(http.StatusBadRequest, "proxy name is required") } if err := c.manager.DeleteStoreProxy(name); err != nil { return nil, c.toHTTPError(err) } return nil, nil } func (c *Controller) ListStoreVisitors(ctx *httppkg.Context) (any, error) { visitors, err := c.manager.ListStoreVisitors() if err != nil { return nil, c.toHTTPError(err) } resp := model.VisitorListResp{Visitors: make([]model.VisitorDefinition, 0, len(visitors))} for _, v := range visitors { payload, err := model.VisitorDefinitionFromConfigurer(v) if err != nil { return nil, httppkg.NewError(http.StatusInternalServerError, err.Error()) } resp.Visitors = append(resp.Visitors, payload) } slices.SortFunc(resp.Visitors, func(a, b model.VisitorDefinition) int { return cmp.Compare(a.Name, b.Name) }) return resp, nil } func (c *Controller) GetStoreVisitor(ctx *httppkg.Context) (any, error) { name := ctx.Param("name") if name == "" { return nil, httppkg.NewError(http.StatusBadRequest, "visitor name is required") } v, err := c.manager.GetStoreVisitor(name) if err != nil { return nil, c.toHTTPError(err) } payload, err := model.VisitorDefinitionFromConfigurer(v) if err != nil { return nil, httppkg.NewError(http.StatusInternalServerError, err.Error()) } return payload, nil } func (c *Controller) CreateStoreVisitor(ctx *httppkg.Context) (any, error) { body, err := ctx.Body() if err != nil { return nil, httppkg.NewError(http.StatusBadRequest, fmt.Sprintf("read body error: %v", err)) } var payload model.VisitorDefinition if err := jsonx.Unmarshal(body, &payload); err != nil { return nil, httppkg.NewError(http.StatusBadRequest, fmt.Sprintf("parse JSON error: %v", err)) } if err := payload.Validate("", false); err != nil { return nil, httppkg.NewError(http.StatusBadRequest, err.Error()) } cfg, err := payload.ToConfigurer() if err != nil { return nil, httppkg.NewError(http.StatusBadRequest, err.Error()) } created, err := c.manager.CreateStoreVisitor(cfg) if err != nil { return nil, c.toHTTPError(err) } resp, err := model.VisitorDefinitionFromConfigurer(created) if err != nil { return nil, httppkg.NewError(http.StatusInternalServerError, err.Error()) } return resp, nil } func (c *Controller) UpdateStoreVisitor(ctx *httppkg.Context) (any, error) { name := ctx.Param("name") if name == "" { return nil, httppkg.NewError(http.StatusBadRequest, "visitor name is required") } body, err := ctx.Body() if err != nil { return nil, httppkg.NewError(http.StatusBadRequest, fmt.Sprintf("read body error: %v", err)) } var payload model.VisitorDefinition if err := jsonx.Unmarshal(body, &payload); err != nil { return nil, httppkg.NewError(http.StatusBadRequest, fmt.Sprintf("parse JSON error: %v", err)) } if err := payload.Validate(name, true); err != nil { return nil, httppkg.NewError(http.StatusBadRequest, err.Error()) } cfg, err := payload.ToConfigurer() if err != nil { return nil, httppkg.NewError(http.StatusBadRequest, err.Error()) } updated, err := c.manager.UpdateStoreVisitor(name, cfg) if err != nil { return nil, c.toHTTPError(err) } resp, err := model.VisitorDefinitionFromConfigurer(updated) if err != nil { return nil, httppkg.NewError(http.StatusInternalServerError, err.Error()) } return resp, nil } func (c *Controller) DeleteStoreVisitor(ctx *httppkg.Context) (any, error) { name := ctx.Param("name") if name == "" { return nil, httppkg.NewError(http.StatusBadRequest, "visitor name is required") } if err := c.manager.DeleteStoreVisitor(name); err != nil { return nil, c.toHTTPError(err) } return nil, nil } ================================================ FILE: client/http/controller_test.go ================================================ package http import ( "bytes" "encoding/json" "errors" "fmt" "net/http" "net/http/httptest" "testing" "time" "github.com/gorilla/mux" "github.com/fatedier/frp/client/configmgmt" "github.com/fatedier/frp/client/http/model" "github.com/fatedier/frp/client/proxy" v1 "github.com/fatedier/frp/pkg/config/v1" httppkg "github.com/fatedier/frp/pkg/util/http" ) type fakeConfigManager struct { reloadFromFileFn func(strict bool) error readConfigFileFn func() (string, error) writeConfigFileFn func(content []byte) error getProxyStatusFn func() []*proxy.WorkingStatus isStoreProxyEnabledFn func(name string) bool storeEnabledFn func() bool getProxyConfigFn func(name string) (v1.ProxyConfigurer, bool) getVisitorConfigFn func(name string) (v1.VisitorConfigurer, bool) listStoreProxiesFn func() ([]v1.ProxyConfigurer, error) getStoreProxyFn func(name string) (v1.ProxyConfigurer, error) createStoreProxyFn func(cfg v1.ProxyConfigurer) (v1.ProxyConfigurer, error) updateStoreProxyFn func(name string, cfg v1.ProxyConfigurer) (v1.ProxyConfigurer, error) deleteStoreProxyFn func(name string) error listStoreVisitorsFn func() ([]v1.VisitorConfigurer, error) getStoreVisitorFn func(name string) (v1.VisitorConfigurer, error) createStoreVisitFn func(cfg v1.VisitorConfigurer) (v1.VisitorConfigurer, error) updateStoreVisitFn func(name string, cfg v1.VisitorConfigurer) (v1.VisitorConfigurer, error) deleteStoreVisitFn func(name string) error gracefulCloseFn func(d time.Duration) } func (m *fakeConfigManager) ReloadFromFile(strict bool) error { if m.reloadFromFileFn != nil { return m.reloadFromFileFn(strict) } return nil } func (m *fakeConfigManager) ReadConfigFile() (string, error) { if m.readConfigFileFn != nil { return m.readConfigFileFn() } return "", nil } func (m *fakeConfigManager) WriteConfigFile(content []byte) error { if m.writeConfigFileFn != nil { return m.writeConfigFileFn(content) } return nil } func (m *fakeConfigManager) GetProxyStatus() []*proxy.WorkingStatus { if m.getProxyStatusFn != nil { return m.getProxyStatusFn() } return nil } func (m *fakeConfigManager) IsStoreProxyEnabled(name string) bool { if m.isStoreProxyEnabledFn != nil { return m.isStoreProxyEnabledFn(name) } return false } func (m *fakeConfigManager) StoreEnabled() bool { if m.storeEnabledFn != nil { return m.storeEnabledFn() } return false } func (m *fakeConfigManager) GetProxyConfig(name string) (v1.ProxyConfigurer, bool) { if m.getProxyConfigFn != nil { return m.getProxyConfigFn(name) } return nil, false } func (m *fakeConfigManager) GetVisitorConfig(name string) (v1.VisitorConfigurer, bool) { if m.getVisitorConfigFn != nil { return m.getVisitorConfigFn(name) } return nil, false } func (m *fakeConfigManager) ListStoreProxies() ([]v1.ProxyConfigurer, error) { if m.listStoreProxiesFn != nil { return m.listStoreProxiesFn() } return nil, nil } func (m *fakeConfigManager) GetStoreProxy(name string) (v1.ProxyConfigurer, error) { if m.getStoreProxyFn != nil { return m.getStoreProxyFn(name) } return nil, nil } func (m *fakeConfigManager) CreateStoreProxy(cfg v1.ProxyConfigurer) (v1.ProxyConfigurer, error) { if m.createStoreProxyFn != nil { return m.createStoreProxyFn(cfg) } return cfg, nil } func (m *fakeConfigManager) UpdateStoreProxy(name string, cfg v1.ProxyConfigurer) (v1.ProxyConfigurer, error) { if m.updateStoreProxyFn != nil { return m.updateStoreProxyFn(name, cfg) } return cfg, nil } func (m *fakeConfigManager) DeleteStoreProxy(name string) error { if m.deleteStoreProxyFn != nil { return m.deleteStoreProxyFn(name) } return nil } func (m *fakeConfigManager) ListStoreVisitors() ([]v1.VisitorConfigurer, error) { if m.listStoreVisitorsFn != nil { return m.listStoreVisitorsFn() } return nil, nil } func (m *fakeConfigManager) GetStoreVisitor(name string) (v1.VisitorConfigurer, error) { if m.getStoreVisitorFn != nil { return m.getStoreVisitorFn(name) } return nil, nil } func (m *fakeConfigManager) CreateStoreVisitor(cfg v1.VisitorConfigurer) (v1.VisitorConfigurer, error) { if m.createStoreVisitFn != nil { return m.createStoreVisitFn(cfg) } return cfg, nil } func (m *fakeConfigManager) UpdateStoreVisitor(name string, cfg v1.VisitorConfigurer) (v1.VisitorConfigurer, error) { if m.updateStoreVisitFn != nil { return m.updateStoreVisitFn(name, cfg) } return cfg, nil } func (m *fakeConfigManager) DeleteStoreVisitor(name string) error { if m.deleteStoreVisitFn != nil { return m.deleteStoreVisitFn(name) } return nil } func (m *fakeConfigManager) GracefulClose(d time.Duration) { if m.gracefulCloseFn != nil { m.gracefulCloseFn(d) } } func newRawTCPProxyConfig(name string) *v1.TCPProxyConfig { return &v1.TCPProxyConfig{ ProxyBaseConfig: v1.ProxyBaseConfig{ Name: name, Type: "tcp", ProxyBackend: v1.ProxyBackend{ LocalPort: 10080, }, }, } } func TestBuildProxyStatusRespStoreSourceEnabled(t *testing.T) { status := &proxy.WorkingStatus{ Name: "shared-proxy", Type: "tcp", Phase: proxy.ProxyPhaseRunning, RemoteAddr: ":8080", Cfg: newRawTCPProxyConfig("shared-proxy"), } controller := &Controller{ serverAddr: "127.0.0.1", manager: &fakeConfigManager{ isStoreProxyEnabledFn: func(name string) bool { return name == "shared-proxy" }, }, } resp := controller.buildProxyStatusResp(status) if resp.Source != "store" { t.Fatalf("unexpected source: %q", resp.Source) } if resp.RemoteAddr != "127.0.0.1:8080" { t.Fatalf("unexpected remote addr: %q", resp.RemoteAddr) } } func TestReloadErrorMapping(t *testing.T) { tests := []struct { name string err error expectedCode int }{ {name: "invalid arg", err: fmtError(configmgmt.ErrInvalidArgument, "bad cfg"), expectedCode: http.StatusBadRequest}, {name: "apply fail", err: fmtError(configmgmt.ErrApplyConfig, "reload failed"), expectedCode: http.StatusInternalServerError}, } for _, tc := range tests { t.Run(tc.name, func(t *testing.T) { controller := &Controller{ manager: &fakeConfigManager{reloadFromFileFn: func(bool) error { return tc.err }}, } ctx := httppkg.NewContext(httptest.NewRecorder(), httptest.NewRequest(http.MethodGet, "/api/reload", nil)) _, err := controller.Reload(ctx) if err == nil { t.Fatal("expected error") } assertHTTPCode(t, err, tc.expectedCode) }) } } func TestStoreProxyErrorMapping(t *testing.T) { tests := []struct { name string err error expectedCode int }{ {name: "not found", err: fmtError(configmgmt.ErrNotFound, "not found"), expectedCode: http.StatusNotFound}, {name: "conflict", err: fmtError(configmgmt.ErrConflict, "exists"), expectedCode: http.StatusConflict}, {name: "internal", err: errors.New("persist failed"), expectedCode: http.StatusInternalServerError}, } for _, tc := range tests { t.Run(tc.name, func(t *testing.T) { body := []byte(`{"name":"shared-proxy","type":"tcp","tcp":{"localPort":10080}}`) req := httptest.NewRequest(http.MethodPut, "/api/store/proxies/shared-proxy", bytes.NewReader(body)) req = mux.SetURLVars(req, map[string]string{"name": "shared-proxy"}) ctx := httppkg.NewContext(httptest.NewRecorder(), req) controller := &Controller{ manager: &fakeConfigManager{ updateStoreProxyFn: func(_ string, _ v1.ProxyConfigurer) (v1.ProxyConfigurer, error) { return nil, tc.err }, }, } _, err := controller.UpdateStoreProxy(ctx) if err == nil { t.Fatal("expected error") } assertHTTPCode(t, err, tc.expectedCode) }) } } func TestStoreVisitorErrorMapping(t *testing.T) { body := []byte(`{"name":"shared-visitor","type":"xtcp","xtcp":{"serverName":"server","bindPort":10081,"secretKey":"secret"}}`) req := httptest.NewRequest(http.MethodDelete, "/api/store/visitors/shared-visitor", bytes.NewReader(body)) req = mux.SetURLVars(req, map[string]string{"name": "shared-visitor"}) ctx := httppkg.NewContext(httptest.NewRecorder(), req) controller := &Controller{ manager: &fakeConfigManager{ deleteStoreVisitFn: func(string) error { return fmtError(configmgmt.ErrStoreDisabled, "disabled") }, }, } _, err := controller.DeleteStoreVisitor(ctx) if err == nil { t.Fatal("expected error") } assertHTTPCode(t, err, http.StatusNotFound) } func TestCreateStoreProxyIgnoresUnknownFields(t *testing.T) { var gotName string controller := &Controller{ manager: &fakeConfigManager{ createStoreProxyFn: func(cfg v1.ProxyConfigurer) (v1.ProxyConfigurer, error) { gotName = cfg.GetBaseConfig().Name return cfg, nil }, }, } body := []byte(`{"name":"raw-proxy","type":"tcp","unexpected":"value","tcp":{"localPort":10080,"unknownInBlock":"value"}}`) req := httptest.NewRequest(http.MethodPost, "/api/store/proxies", bytes.NewReader(body)) ctx := httppkg.NewContext(httptest.NewRecorder(), req) resp, err := controller.CreateStoreProxy(ctx) if err != nil { t.Fatalf("create store proxy: %v", err) } if gotName != "raw-proxy" { t.Fatalf("unexpected proxy name: %q", gotName) } payload, ok := resp.(model.ProxyDefinition) if !ok { t.Fatalf("unexpected response type: %T", resp) } if payload.Type != "tcp" || payload.TCP == nil { t.Fatalf("unexpected payload: %#v", payload) } } func TestCreateStoreVisitorIgnoresUnknownFields(t *testing.T) { var gotName string controller := &Controller{ manager: &fakeConfigManager{ createStoreVisitFn: func(cfg v1.VisitorConfigurer) (v1.VisitorConfigurer, error) { gotName = cfg.GetBaseConfig().Name return cfg, nil }, }, } body := []byte(`{ "name":"raw-visitor","type":"xtcp","unexpected":"value", "xtcp":{"serverName":"server","bindPort":10081,"secretKey":"secret","unknownInBlock":"value"} }`) req := httptest.NewRequest(http.MethodPost, "/api/store/visitors", bytes.NewReader(body)) ctx := httppkg.NewContext(httptest.NewRecorder(), req) resp, err := controller.CreateStoreVisitor(ctx) if err != nil { t.Fatalf("create store visitor: %v", err) } if gotName != "raw-visitor" { t.Fatalf("unexpected visitor name: %q", gotName) } payload, ok := resp.(model.VisitorDefinition) if !ok { t.Fatalf("unexpected response type: %T", resp) } if payload.Type != "xtcp" || payload.XTCP == nil { t.Fatalf("unexpected payload: %#v", payload) } } func TestCreateStoreProxyPluginUnknownFieldsAreIgnored(t *testing.T) { var gotPluginType string controller := &Controller{ manager: &fakeConfigManager{ createStoreProxyFn: func(cfg v1.ProxyConfigurer) (v1.ProxyConfigurer, error) { gotPluginType = cfg.GetBaseConfig().Plugin.Type return cfg, nil }, }, } body := []byte(`{"name":"plugin-proxy","type":"tcp","tcp":{"plugin":{"type":"http2https","localAddr":"127.0.0.1:8080","unknownInPlugin":"value"}}}`) req := httptest.NewRequest(http.MethodPost, "/api/store/proxies", bytes.NewReader(body)) ctx := httppkg.NewContext(httptest.NewRecorder(), req) resp, err := controller.CreateStoreProxy(ctx) if err != nil { t.Fatalf("create store proxy: %v", err) } if gotPluginType != "http2https" { t.Fatalf("unexpected plugin type: %q", gotPluginType) } payload, ok := resp.(model.ProxyDefinition) if !ok { t.Fatalf("unexpected response type: %T", resp) } if payload.TCP == nil { t.Fatalf("unexpected response payload: %#v", payload) } pluginType := payload.TCP.Plugin.Type if pluginType != "http2https" { t.Fatalf("unexpected plugin type in response payload: %q", pluginType) } } func TestCreateStoreVisitorPluginUnknownFieldsAreIgnored(t *testing.T) { var gotPluginType string controller := &Controller{ manager: &fakeConfigManager{ createStoreVisitFn: func(cfg v1.VisitorConfigurer) (v1.VisitorConfigurer, error) { gotPluginType = cfg.GetBaseConfig().Plugin.Type return cfg, nil }, }, } body := []byte(`{ "name":"plugin-visitor","type":"stcp", "stcp":{"serverName":"server","bindPort":10081,"plugin":{"type":"virtual_net","destinationIP":"10.0.0.1","unknownInPlugin":"value"}} }`) req := httptest.NewRequest(http.MethodPost, "/api/store/visitors", bytes.NewReader(body)) ctx := httppkg.NewContext(httptest.NewRecorder(), req) resp, err := controller.CreateStoreVisitor(ctx) if err != nil { t.Fatalf("create store visitor: %v", err) } if gotPluginType != "virtual_net" { t.Fatalf("unexpected plugin type: %q", gotPluginType) } payload, ok := resp.(model.VisitorDefinition) if !ok { t.Fatalf("unexpected response type: %T", resp) } if payload.STCP == nil { t.Fatalf("unexpected response payload: %#v", payload) } pluginType := payload.STCP.Plugin.Type if pluginType != "virtual_net" { t.Fatalf("unexpected plugin type in response payload: %q", pluginType) } } func TestUpdateStoreProxyRejectsMismatchedTypeBlock(t *testing.T) { controller := &Controller{manager: &fakeConfigManager{}} body := []byte(`{"name":"p1","type":"tcp","udp":{"localPort":10080}}`) req := httptest.NewRequest(http.MethodPut, "/api/store/proxies/p1", bytes.NewReader(body)) req = mux.SetURLVars(req, map[string]string{"name": "p1"}) ctx := httppkg.NewContext(httptest.NewRecorder(), req) _, err := controller.UpdateStoreProxy(ctx) if err == nil { t.Fatal("expected error") } assertHTTPCode(t, err, http.StatusBadRequest) } func TestUpdateStoreProxyRejectsNameMismatch(t *testing.T) { controller := &Controller{manager: &fakeConfigManager{}} body := []byte(`{"name":"p2","type":"tcp","tcp":{"localPort":10080}}`) req := httptest.NewRequest(http.MethodPut, "/api/store/proxies/p1", bytes.NewReader(body)) req = mux.SetURLVars(req, map[string]string{"name": "p1"}) ctx := httppkg.NewContext(httptest.NewRecorder(), req) _, err := controller.UpdateStoreProxy(ctx) if err == nil { t.Fatal("expected error") } assertHTTPCode(t, err, http.StatusBadRequest) } func TestListStoreProxiesReturnsSortedPayload(t *testing.T) { controller := &Controller{ manager: &fakeConfigManager{ listStoreProxiesFn: func() ([]v1.ProxyConfigurer, error) { b := newRawTCPProxyConfig("b") a := newRawTCPProxyConfig("a") return []v1.ProxyConfigurer{b, a}, nil }, }, } ctx := httppkg.NewContext(httptest.NewRecorder(), httptest.NewRequest(http.MethodGet, "/api/store/proxies", nil)) resp, err := controller.ListStoreProxies(ctx) if err != nil { t.Fatalf("list store proxies: %v", err) } out, ok := resp.(model.ProxyListResp) if !ok { t.Fatalf("unexpected response type: %T", resp) } if len(out.Proxies) != 2 { t.Fatalf("unexpected proxy count: %d", len(out.Proxies)) } if out.Proxies[0].Name != "a" || out.Proxies[1].Name != "b" { t.Fatalf("proxies are not sorted by name: %#v", out.Proxies) } } func fmtError(sentinel error, msg string) error { return fmt.Errorf("%w: %s", sentinel, msg) } func assertHTTPCode(t *testing.T, err error, expected int) { t.Helper() var httpErr *httppkg.Error if !errors.As(err, &httpErr) { t.Fatalf("unexpected error type: %T", err) } if httpErr.Code != expected { t.Fatalf("unexpected status code: got %d, want %d", httpErr.Code, expected) } } func TestUpdateStoreProxyReturnsTypedPayload(t *testing.T) { controller := &Controller{ manager: &fakeConfigManager{ updateStoreProxyFn: func(_ string, cfg v1.ProxyConfigurer) (v1.ProxyConfigurer, error) { return cfg, nil }, }, } body := map[string]any{ "name": "shared-proxy", "type": "tcp", "tcp": map[string]any{ "localPort": 10080, "remotePort": 7000, }, } data, err := json.Marshal(body) if err != nil { t.Fatalf("marshal request: %v", err) } req := httptest.NewRequest(http.MethodPut, "/api/store/proxies/shared-proxy", bytes.NewReader(data)) req = mux.SetURLVars(req, map[string]string{"name": "shared-proxy"}) ctx := httppkg.NewContext(httptest.NewRecorder(), req) resp, err := controller.UpdateStoreProxy(ctx) if err != nil { t.Fatalf("update store proxy: %v", err) } payload, ok := resp.(model.ProxyDefinition) if !ok { t.Fatalf("unexpected response type: %T", resp) } if payload.TCP == nil || payload.TCP.RemotePort != 7000 { t.Fatalf("unexpected response payload: %#v", payload) } } func TestGetProxyConfigFromManager(t *testing.T) { controller := &Controller{ manager: &fakeConfigManager{ getProxyConfigFn: func(name string) (v1.ProxyConfigurer, bool) { if name == "ssh" { cfg := &v1.TCPProxyConfig{ ProxyBaseConfig: v1.ProxyBaseConfig{ Name: "ssh", Type: "tcp", ProxyBackend: v1.ProxyBackend{ LocalPort: 22, }, }, } return cfg, true } return nil, false }, }, } req := httptest.NewRequest(http.MethodGet, "/api/proxy/ssh/config", nil) req = mux.SetURLVars(req, map[string]string{"name": "ssh"}) ctx := httppkg.NewContext(httptest.NewRecorder(), req) resp, err := controller.GetProxyConfig(ctx) if err != nil { t.Fatalf("get proxy config: %v", err) } payload, ok := resp.(model.ProxyDefinition) if !ok { t.Fatalf("unexpected response type: %T", resp) } if payload.Name != "ssh" || payload.Type != "tcp" || payload.TCP == nil { t.Fatalf("unexpected payload: %#v", payload) } } func TestGetProxyConfigNotFound(t *testing.T) { controller := &Controller{ manager: &fakeConfigManager{ getProxyConfigFn: func(name string) (v1.ProxyConfigurer, bool) { return nil, false }, }, } req := httptest.NewRequest(http.MethodGet, "/api/proxy/missing/config", nil) req = mux.SetURLVars(req, map[string]string{"name": "missing"}) ctx := httppkg.NewContext(httptest.NewRecorder(), req) _, err := controller.GetProxyConfig(ctx) if err == nil { t.Fatal("expected error") } assertHTTPCode(t, err, http.StatusNotFound) } func TestGetVisitorConfigFromManager(t *testing.T) { controller := &Controller{ manager: &fakeConfigManager{ getVisitorConfigFn: func(name string) (v1.VisitorConfigurer, bool) { if name == "my-stcp" { cfg := &v1.STCPVisitorConfig{ VisitorBaseConfig: v1.VisitorBaseConfig{ Name: "my-stcp", Type: "stcp", ServerName: "server1", BindPort: 9000, }, } return cfg, true } return nil, false }, }, } req := httptest.NewRequest(http.MethodGet, "/api/visitor/my-stcp/config", nil) req = mux.SetURLVars(req, map[string]string{"name": "my-stcp"}) ctx := httppkg.NewContext(httptest.NewRecorder(), req) resp, err := controller.GetVisitorConfig(ctx) if err != nil { t.Fatalf("get visitor config: %v", err) } payload, ok := resp.(model.VisitorDefinition) if !ok { t.Fatalf("unexpected response type: %T", resp) } if payload.Name != "my-stcp" || payload.Type != "stcp" || payload.STCP == nil { t.Fatalf("unexpected payload: %#v", payload) } } func TestGetVisitorConfigNotFound(t *testing.T) { controller := &Controller{ manager: &fakeConfigManager{ getVisitorConfigFn: func(name string) (v1.VisitorConfigurer, bool) { return nil, false }, }, } req := httptest.NewRequest(http.MethodGet, "/api/visitor/missing/config", nil) req = mux.SetURLVars(req, map[string]string{"name": "missing"}) ctx := httppkg.NewContext(httptest.NewRecorder(), req) _, err := controller.GetVisitorConfig(ctx) if err == nil { t.Fatal("expected error") } assertHTTPCode(t, err, http.StatusNotFound) } ================================================ FILE: client/http/model/proxy_definition.go ================================================ package model import ( "fmt" "strings" v1 "github.com/fatedier/frp/pkg/config/v1" ) type ProxyDefinition struct { Name string `json:"name"` Type string `json:"type"` TCP *v1.TCPProxyConfig `json:"tcp,omitempty"` UDP *v1.UDPProxyConfig `json:"udp,omitempty"` HTTP *v1.HTTPProxyConfig `json:"http,omitempty"` HTTPS *v1.HTTPSProxyConfig `json:"https,omitempty"` TCPMux *v1.TCPMuxProxyConfig `json:"tcpmux,omitempty"` STCP *v1.STCPProxyConfig `json:"stcp,omitempty"` SUDP *v1.SUDPProxyConfig `json:"sudp,omitempty"` XTCP *v1.XTCPProxyConfig `json:"xtcp,omitempty"` } func (p *ProxyDefinition) Validate(pathName string, isUpdate bool) error { if strings.TrimSpace(p.Name) == "" { return fmt.Errorf("proxy name is required") } if !IsProxyType(p.Type) { return fmt.Errorf("invalid proxy type: %s", p.Type) } if isUpdate && pathName != "" && pathName != p.Name { return fmt.Errorf("proxy name in URL must match name in body") } _, blockType, blockCount := p.activeBlock() if blockCount != 1 { return fmt.Errorf("exactly one proxy type block is required") } if blockType != p.Type { return fmt.Errorf("proxy type block %q does not match type %q", blockType, p.Type) } return nil } func (p *ProxyDefinition) ToConfigurer() (v1.ProxyConfigurer, error) { block, _, _ := p.activeBlock() if block == nil { return nil, fmt.Errorf("exactly one proxy type block is required") } cfg := block cfg.GetBaseConfig().Name = p.Name cfg.GetBaseConfig().Type = p.Type return cfg, nil } func ProxyDefinitionFromConfigurer(cfg v1.ProxyConfigurer) (ProxyDefinition, error) { if cfg == nil { return ProxyDefinition{}, fmt.Errorf("proxy config is nil") } base := cfg.GetBaseConfig() payload := ProxyDefinition{ Name: base.Name, Type: base.Type, } switch c := cfg.(type) { case *v1.TCPProxyConfig: payload.TCP = c case *v1.UDPProxyConfig: payload.UDP = c case *v1.HTTPProxyConfig: payload.HTTP = c case *v1.HTTPSProxyConfig: payload.HTTPS = c case *v1.TCPMuxProxyConfig: payload.TCPMux = c case *v1.STCPProxyConfig: payload.STCP = c case *v1.SUDPProxyConfig: payload.SUDP = c case *v1.XTCPProxyConfig: payload.XTCP = c default: return ProxyDefinition{}, fmt.Errorf("unsupported proxy configurer type %T", cfg) } return payload, nil } func (p *ProxyDefinition) activeBlock() (v1.ProxyConfigurer, string, int) { count := 0 var block v1.ProxyConfigurer var blockType string if p.TCP != nil { count++ block = p.TCP blockType = "tcp" } if p.UDP != nil { count++ block = p.UDP blockType = "udp" } if p.HTTP != nil { count++ block = p.HTTP blockType = "http" } if p.HTTPS != nil { count++ block = p.HTTPS blockType = "https" } if p.TCPMux != nil { count++ block = p.TCPMux blockType = "tcpmux" } if p.STCP != nil { count++ block = p.STCP blockType = "stcp" } if p.SUDP != nil { count++ block = p.SUDP blockType = "sudp" } if p.XTCP != nil { count++ block = p.XTCP blockType = "xtcp" } return block, blockType, count } func IsProxyType(typ string) bool { switch typ { case "tcp", "udp", "http", "https", "tcpmux", "stcp", "sudp", "xtcp": return true default: return false } } ================================================ FILE: client/http/model/types.go ================================================ // Copyright 2025 The frp Authors // // Licensed under the Apache License, Version 2.0 (the "License"); // you may not use this file except in compliance with the License. // You may obtain a copy of the License at // // http://www.apache.org/licenses/LICENSE-2.0 // // Unless required by applicable law or agreed to in writing, software // distributed under the License is distributed on an "AS IS" BASIS, // WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. // See the License for the specific language governing permissions and // limitations under the License. package model const SourceStore = "store" // StatusResp is the response for GET /api/status type StatusResp map[string][]ProxyStatusResp // ProxyStatusResp contains proxy status information type ProxyStatusResp struct { Name string `json:"name"` Type string `json:"type"` Status string `json:"status"` Err string `json:"err"` LocalAddr string `json:"local_addr"` Plugin string `json:"plugin"` RemoteAddr string `json:"remote_addr"` Source string `json:"source,omitempty"` // "store" or "config" } // ProxyListResp is the response for GET /api/store/proxies type ProxyListResp struct { Proxies []ProxyDefinition `json:"proxies"` } // VisitorListResp is the response for GET /api/store/visitors type VisitorListResp struct { Visitors []VisitorDefinition `json:"visitors"` } ================================================ FILE: client/http/model/visitor_definition.go ================================================ package model import ( "fmt" "strings" v1 "github.com/fatedier/frp/pkg/config/v1" ) type VisitorDefinition struct { Name string `json:"name"` Type string `json:"type"` STCP *v1.STCPVisitorConfig `json:"stcp,omitempty"` SUDP *v1.SUDPVisitorConfig `json:"sudp,omitempty"` XTCP *v1.XTCPVisitorConfig `json:"xtcp,omitempty"` } func (p *VisitorDefinition) Validate(pathName string, isUpdate bool) error { if strings.TrimSpace(p.Name) == "" { return fmt.Errorf("visitor name is required") } if !IsVisitorType(p.Type) { return fmt.Errorf("invalid visitor type: %s", p.Type) } if isUpdate && pathName != "" && pathName != p.Name { return fmt.Errorf("visitor name in URL must match name in body") } _, blockType, blockCount := p.activeBlock() if blockCount != 1 { return fmt.Errorf("exactly one visitor type block is required") } if blockType != p.Type { return fmt.Errorf("visitor type block %q does not match type %q", blockType, p.Type) } return nil } func (p *VisitorDefinition) ToConfigurer() (v1.VisitorConfigurer, error) { block, _, _ := p.activeBlock() if block == nil { return nil, fmt.Errorf("exactly one visitor type block is required") } cfg := block cfg.GetBaseConfig().Name = p.Name cfg.GetBaseConfig().Type = p.Type return cfg, nil } func VisitorDefinitionFromConfigurer(cfg v1.VisitorConfigurer) (VisitorDefinition, error) { if cfg == nil { return VisitorDefinition{}, fmt.Errorf("visitor config is nil") } base := cfg.GetBaseConfig() payload := VisitorDefinition{ Name: base.Name, Type: base.Type, } switch c := cfg.(type) { case *v1.STCPVisitorConfig: payload.STCP = c case *v1.SUDPVisitorConfig: payload.SUDP = c case *v1.XTCPVisitorConfig: payload.XTCP = c default: return VisitorDefinition{}, fmt.Errorf("unsupported visitor configurer type %T", cfg) } return payload, nil } func (p *VisitorDefinition) activeBlock() (v1.VisitorConfigurer, string, int) { count := 0 var block v1.VisitorConfigurer var blockType string if p.STCP != nil { count++ block = p.STCP blockType = "stcp" } if p.SUDP != nil { count++ block = p.SUDP blockType = "sudp" } if p.XTCP != nil { count++ block = p.XTCP blockType = "xtcp" } return block, blockType, count } func IsVisitorType(typ string) bool { switch typ { case "stcp", "sudp", "xtcp": return true default: return false } } ================================================ FILE: client/proxy/general_tcp.go ================================================ // Copyright 2023 The frp Authors // // Licensed under the Apache License, Version 2.0 (the "License"); // you may not use this file except in compliance with the License. // You may obtain a copy of the License at // // http://www.apache.org/licenses/LICENSE-2.0 // // Unless required by applicable law or agreed to in writing, software // distributed under the License is distributed on an "AS IS" BASIS, // WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. // See the License for the specific language governing permissions and // limitations under the License. package proxy import ( "reflect" v1 "github.com/fatedier/frp/pkg/config/v1" ) func init() { pxyConfs := []v1.ProxyConfigurer{ &v1.TCPProxyConfig{}, &v1.HTTPProxyConfig{}, &v1.HTTPSProxyConfig{}, &v1.STCPProxyConfig{}, &v1.TCPMuxProxyConfig{}, } for _, cfg := range pxyConfs { RegisterProxyFactory(reflect.TypeOf(cfg), NewGeneralTCPProxy) } } // GeneralTCPProxy is a general implementation of Proxy interface for TCP protocol. // If the default GeneralTCPProxy cannot meet the requirements, you can customize // the implementation of the Proxy interface. type GeneralTCPProxy struct { *BaseProxy } func NewGeneralTCPProxy(baseProxy *BaseProxy, _ v1.ProxyConfigurer) Proxy { return &GeneralTCPProxy{ BaseProxy: baseProxy, } } ================================================ FILE: client/proxy/proxy.go ================================================ // Copyright 2017 fatedier, fatedier@gmail.com // // Licensed under the Apache License, Version 2.0 (the "License"); // you may not use this file except in compliance with the License. // You may obtain a copy of the License at // // http://www.apache.org/licenses/LICENSE-2.0 // // Unless required by applicable law or agreed to in writing, software // distributed under the License is distributed on an "AS IS" BASIS, // WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. // See the License for the specific language governing permissions and // limitations under the License. package proxy import ( "context" "fmt" "io" "net" "reflect" "strconv" "sync" "time" libio "github.com/fatedier/golib/io" libnet "github.com/fatedier/golib/net" "golang.org/x/time/rate" "github.com/fatedier/frp/pkg/config/types" v1 "github.com/fatedier/frp/pkg/config/v1" "github.com/fatedier/frp/pkg/msg" plugin "github.com/fatedier/frp/pkg/plugin/client" "github.com/fatedier/frp/pkg/transport" "github.com/fatedier/frp/pkg/util/limit" netpkg "github.com/fatedier/frp/pkg/util/net" "github.com/fatedier/frp/pkg/util/xlog" "github.com/fatedier/frp/pkg/vnet" ) var proxyFactoryRegistry = map[reflect.Type]func(*BaseProxy, v1.ProxyConfigurer) Proxy{} func RegisterProxyFactory(proxyConfType reflect.Type, factory func(*BaseProxy, v1.ProxyConfigurer) Proxy) { proxyFactoryRegistry[proxyConfType] = factory } // Proxy defines how to handle work connections for different proxy type. type Proxy interface { Run() error // InWorkConn accept work connections registered to server. InWorkConn(net.Conn, *msg.StartWorkConn) SetInWorkConnCallback(func(*v1.ProxyBaseConfig, net.Conn, *msg.StartWorkConn) /* continue */ bool) Close() } func NewProxy( ctx context.Context, pxyConf v1.ProxyConfigurer, clientCfg *v1.ClientCommonConfig, encryptionKey []byte, msgTransporter transport.MessageTransporter, vnetController *vnet.Controller, ) (pxy Proxy) { var limiter *rate.Limiter limitBytes := pxyConf.GetBaseConfig().Transport.BandwidthLimit.Bytes() if limitBytes > 0 && pxyConf.GetBaseConfig().Transport.BandwidthLimitMode == types.BandwidthLimitModeClient { limiter = rate.NewLimiter(rate.Limit(float64(limitBytes)), int(limitBytes)) } baseProxy := BaseProxy{ baseCfg: pxyConf.GetBaseConfig(), clientCfg: clientCfg, encryptionKey: encryptionKey, limiter: limiter, msgTransporter: msgTransporter, vnetController: vnetController, xl: xlog.FromContextSafe(ctx), ctx: ctx, } factory := proxyFactoryRegistry[reflect.TypeOf(pxyConf)] if factory == nil { return nil } return factory(&baseProxy, pxyConf) } type BaseProxy struct { baseCfg *v1.ProxyBaseConfig clientCfg *v1.ClientCommonConfig encryptionKey []byte msgTransporter transport.MessageTransporter vnetController *vnet.Controller limiter *rate.Limiter // proxyPlugin is used to handle connections instead of dialing to local service. // It's only validate for TCP protocol now. proxyPlugin plugin.Plugin inWorkConnCallback func(*v1.ProxyBaseConfig, net.Conn, *msg.StartWorkConn) /* continue */ bool mu sync.RWMutex xl *xlog.Logger ctx context.Context } func (pxy *BaseProxy) Run() error { if pxy.baseCfg.Plugin.Type != "" { p, err := plugin.Create(pxy.baseCfg.Plugin.Type, plugin.PluginContext{ Name: pxy.baseCfg.Name, VnetController: pxy.vnetController, }, pxy.baseCfg.Plugin.ClientPluginOptions) if err != nil { return err } pxy.proxyPlugin = p } return nil } func (pxy *BaseProxy) Close() { if pxy.proxyPlugin != nil { pxy.proxyPlugin.Close() } } // wrapWorkConn applies rate limiting, encryption, and compression // to a work connection based on the proxy's transport configuration. // The returned recycle function should be called when the stream is no longer in use // to return compression resources to the pool. It is safe to not call recycle, // in which case resources will be garbage collected normally. func (pxy *BaseProxy) wrapWorkConn(conn net.Conn, encKey []byte) (io.ReadWriteCloser, func(), error) { var rwc io.ReadWriteCloser = conn if pxy.limiter != nil { rwc = libio.WrapReadWriteCloser(limit.NewReader(conn, pxy.limiter), limit.NewWriter(conn, pxy.limiter), func() error { return conn.Close() }) } if pxy.baseCfg.Transport.UseEncryption { var err error rwc, err = libio.WithEncryption(rwc, encKey) if err != nil { conn.Close() return nil, nil, fmt.Errorf("create encryption stream error: %w", err) } } var recycleFn func() if pxy.baseCfg.Transport.UseCompression { rwc, recycleFn = libio.WithCompressionFromPool(rwc) } return rwc, recycleFn, nil } func (pxy *BaseProxy) SetInWorkConnCallback(cb func(*v1.ProxyBaseConfig, net.Conn, *msg.StartWorkConn) bool) { pxy.inWorkConnCallback = cb } func (pxy *BaseProxy) InWorkConn(conn net.Conn, m *msg.StartWorkConn) { if pxy.inWorkConnCallback != nil { if !pxy.inWorkConnCallback(pxy.baseCfg, conn, m) { return } } pxy.HandleTCPWorkConnection(conn, m, pxy.encryptionKey) } // Common handler for tcp work connections. func (pxy *BaseProxy) HandleTCPWorkConnection(workConn net.Conn, m *msg.StartWorkConn, encKey []byte) { xl := pxy.xl baseCfg := pxy.baseCfg xl.Tracef("handle tcp work connection, useEncryption: %t, useCompression: %t", baseCfg.Transport.UseEncryption, baseCfg.Transport.UseCompression) remote, recycleFn, err := pxy.wrapWorkConn(workConn, encKey) if err != nil { xl.Errorf("wrap work connection: %v", err) return } // check if we need to send proxy protocol info var connInfo plugin.ConnectionInfo if m.SrcAddr != "" && m.SrcPort != 0 { if m.DstAddr == "" { m.DstAddr = "127.0.0.1" } srcAddr, _ := net.ResolveTCPAddr("tcp", net.JoinHostPort(m.SrcAddr, strconv.Itoa(int(m.SrcPort)))) dstAddr, _ := net.ResolveTCPAddr("tcp", net.JoinHostPort(m.DstAddr, strconv.Itoa(int(m.DstPort)))) connInfo.SrcAddr = srcAddr connInfo.DstAddr = dstAddr } if baseCfg.Transport.ProxyProtocolVersion != "" && m.SrcAddr != "" && m.SrcPort != 0 { header := netpkg.BuildProxyProtocolHeaderStruct(connInfo.SrcAddr, connInfo.DstAddr, baseCfg.Transport.ProxyProtocolVersion) connInfo.ProxyProtocolHeader = header } connInfo.Conn = remote connInfo.UnderlyingConn = workConn if pxy.proxyPlugin != nil { // if plugin is set, let plugin handle connection first // Don't recycle compression resources here because plugins may // retain the connection after Handle returns. xl.Debugf("handle by plugin: %s", pxy.proxyPlugin.Name()) pxy.proxyPlugin.Handle(pxy.ctx, &connInfo) xl.Debugf("handle by plugin finished") return } if recycleFn != nil { defer recycleFn() } localConn, err := libnet.Dial( net.JoinHostPort(baseCfg.LocalIP, strconv.Itoa(baseCfg.LocalPort)), libnet.WithTimeout(10*time.Second), ) if err != nil { workConn.Close() xl.Errorf("connect to local service [%s:%d] error: %v", baseCfg.LocalIP, baseCfg.LocalPort, err) return } xl.Debugf("join connections, localConn(l[%s] r[%s]) workConn(l[%s] r[%s])", localConn.LocalAddr().String(), localConn.RemoteAddr().String(), workConn.LocalAddr().String(), workConn.RemoteAddr().String()) if connInfo.ProxyProtocolHeader != nil { if _, err := connInfo.ProxyProtocolHeader.WriteTo(localConn); err != nil { workConn.Close() localConn.Close() xl.Errorf("write proxy protocol header to local conn error: %v", err) return } } _, _, errs := libio.Join(localConn, remote) xl.Debugf("join connections closed") if len(errs) > 0 { xl.Tracef("join connections errors: %v", errs) } } ================================================ FILE: client/proxy/proxy_manager.go ================================================ // Copyright 2023 The frp Authors // // Licensed under the Apache License, Version 2.0 (the "License"); // you may not use this file except in compliance with the License. // You may obtain a copy of the License at // // http://www.apache.org/licenses/LICENSE-2.0 // // Unless required by applicable law or agreed to in writing, software // distributed under the License is distributed on an "AS IS" BASIS, // WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. // See the License for the specific language governing permissions and // limitations under the License. package proxy import ( "context" "fmt" "net" "reflect" "sync" "github.com/samber/lo" "github.com/fatedier/frp/client/event" v1 "github.com/fatedier/frp/pkg/config/v1" "github.com/fatedier/frp/pkg/msg" "github.com/fatedier/frp/pkg/transport" "github.com/fatedier/frp/pkg/util/xlog" "github.com/fatedier/frp/pkg/vnet" ) type Manager struct { proxies map[string]*Wrapper msgTransporter transport.MessageTransporter inWorkConnCallback func(*v1.ProxyBaseConfig, net.Conn, *msg.StartWorkConn) bool vnetController *vnet.Controller closed bool mu sync.RWMutex encryptionKey []byte clientCfg *v1.ClientCommonConfig ctx context.Context } func NewManager( ctx context.Context, clientCfg *v1.ClientCommonConfig, encryptionKey []byte, msgTransporter transport.MessageTransporter, vnetController *vnet.Controller, ) *Manager { return &Manager{ proxies: make(map[string]*Wrapper), msgTransporter: msgTransporter, vnetController: vnetController, closed: false, encryptionKey: encryptionKey, clientCfg: clientCfg, ctx: ctx, } } func (pm *Manager) StartProxy(name string, remoteAddr string, serverRespErr string) error { pm.mu.RLock() pxy, ok := pm.proxies[name] pm.mu.RUnlock() if !ok { return fmt.Errorf("proxy [%s] not found", name) } err := pxy.SetRunningStatus(remoteAddr, serverRespErr) if err != nil { return err } return nil } func (pm *Manager) SetInWorkConnCallback(cb func(*v1.ProxyBaseConfig, net.Conn, *msg.StartWorkConn) bool) { pm.inWorkConnCallback = cb } func (pm *Manager) Close() { pm.mu.Lock() defer pm.mu.Unlock() for _, pxy := range pm.proxies { pxy.Stop() } pm.proxies = make(map[string]*Wrapper) } func (pm *Manager) HandleWorkConn(name string, workConn net.Conn, m *msg.StartWorkConn) { pm.mu.RLock() pw, ok := pm.proxies[name] pm.mu.RUnlock() if ok { pw.InWorkConn(workConn, m) } else { workConn.Close() } } func (pm *Manager) HandleEvent(payload any) error { var m msg.Message switch e := payload.(type) { case *event.StartProxyPayload: m = e.NewProxyMsg case *event.CloseProxyPayload: m = e.CloseProxyMsg default: return event.ErrPayloadType } return pm.msgTransporter.Send(m) } func (pm *Manager) GetAllProxyStatus() []*WorkingStatus { pm.mu.RLock() defer pm.mu.RUnlock() ps := make([]*WorkingStatus, 0, len(pm.proxies)) for _, pxy := range pm.proxies { ps = append(ps, pxy.GetStatus()) } return ps } func (pm *Manager) GetProxyStatus(name string) (*WorkingStatus, bool) { pm.mu.RLock() defer pm.mu.RUnlock() if pxy, ok := pm.proxies[name]; ok { return pxy.GetStatus(), true } return nil, false } func (pm *Manager) UpdateAll(proxyCfgs []v1.ProxyConfigurer) { xl := xlog.FromContextSafe(pm.ctx) proxyCfgsMap := lo.KeyBy(proxyCfgs, func(c v1.ProxyConfigurer) string { return c.GetBaseConfig().Name }) pm.mu.Lock() defer pm.mu.Unlock() delPxyNames := make([]string, 0) for name, pxy := range pm.proxies { del := false cfg, ok := proxyCfgsMap[name] if !ok || !reflect.DeepEqual(pxy.Cfg, cfg) { del = true } if del { delPxyNames = append(delPxyNames, name) delete(pm.proxies, name) pxy.Stop() } } if len(delPxyNames) > 0 { xl.Infof("proxy removed: %s", delPxyNames) } addPxyNames := make([]string, 0) for _, cfg := range proxyCfgs { name := cfg.GetBaseConfig().Name if _, ok := pm.proxies[name]; !ok { pxy := NewWrapper(pm.ctx, cfg, pm.clientCfg, pm.encryptionKey, pm.HandleEvent, pm.msgTransporter, pm.vnetController) if pm.inWorkConnCallback != nil { pxy.SetInWorkConnCallback(pm.inWorkConnCallback) } pm.proxies[name] = pxy addPxyNames = append(addPxyNames, name) pxy.Start() } } if len(addPxyNames) > 0 { xl.Infof("proxy added: %s", addPxyNames) } } ================================================ FILE: client/proxy/proxy_wrapper.go ================================================ // Copyright 2023 The frp Authors // // Licensed under the Apache License, Version 2.0 (the "License"); // you may not use this file except in compliance with the License. // You may obtain a copy of the License at // // http://www.apache.org/licenses/LICENSE-2.0 // // Unless required by applicable law or agreed to in writing, software // distributed under the License is distributed on an "AS IS" BASIS, // WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. // See the License for the specific language governing permissions and // limitations under the License. package proxy import ( "context" "fmt" "net" "strconv" "sync" "sync/atomic" "time" "github.com/fatedier/golib/errors" "github.com/fatedier/frp/client/event" "github.com/fatedier/frp/client/health" v1 "github.com/fatedier/frp/pkg/config/v1" "github.com/fatedier/frp/pkg/msg" "github.com/fatedier/frp/pkg/naming" "github.com/fatedier/frp/pkg/transport" "github.com/fatedier/frp/pkg/util/xlog" "github.com/fatedier/frp/pkg/vnet" ) const ( ProxyPhaseNew = "new" ProxyPhaseWaitStart = "wait start" ProxyPhaseStartErr = "start error" ProxyPhaseRunning = "running" ProxyPhaseCheckFailed = "check failed" ProxyPhaseClosed = "closed" ) var ( statusCheckInterval = 3 * time.Second waitResponseTimeout = 20 * time.Second startErrTimeout = 30 * time.Second ) type WorkingStatus struct { Name string `json:"name"` Type string `json:"type"` Phase string `json:"status"` Err string `json:"err"` Cfg v1.ProxyConfigurer `json:"cfg"` // Got from server. RemoteAddr string `json:"remote_addr"` } type Wrapper struct { WorkingStatus // underlying proxy pxy Proxy // if ProxyConf has healcheck config // monitor will watch if it is alive monitor *health.Monitor // event handler handler event.Handler msgTransporter transport.MessageTransporter // vnet controller vnetController *vnet.Controller health uint32 lastSendStartMsg time.Time lastStartErr time.Time closeCh chan struct{} healthNotifyCh chan struct{} mu sync.RWMutex xl *xlog.Logger ctx context.Context wireName string } func NewWrapper( ctx context.Context, cfg v1.ProxyConfigurer, clientCfg *v1.ClientCommonConfig, encryptionKey []byte, eventHandler event.Handler, msgTransporter transport.MessageTransporter, vnetController *vnet.Controller, ) *Wrapper { baseInfo := cfg.GetBaseConfig() xl := xlog.FromContextSafe(ctx).Spawn().AppendPrefix(baseInfo.Name) pw := &Wrapper{ WorkingStatus: WorkingStatus{ Name: baseInfo.Name, Type: baseInfo.Type, Phase: ProxyPhaseNew, Cfg: cfg, }, closeCh: make(chan struct{}), healthNotifyCh: make(chan struct{}), handler: eventHandler, msgTransporter: msgTransporter, vnetController: vnetController, xl: xl, ctx: xlog.NewContext(ctx, xl), wireName: naming.AddUserPrefix(clientCfg.User, baseInfo.Name), } if baseInfo.HealthCheck.Type != "" && baseInfo.LocalPort > 0 { pw.health = 1 // means failed addr := net.JoinHostPort(baseInfo.LocalIP, strconv.Itoa(baseInfo.LocalPort)) pw.monitor = health.NewMonitor(pw.ctx, baseInfo.HealthCheck, addr, pw.statusNormalCallback, pw.statusFailedCallback) xl.Tracef("enable health check monitor") } pw.pxy = NewProxy(pw.ctx, pw.Cfg, clientCfg, encryptionKey, pw.msgTransporter, pw.vnetController) return pw } func (pw *Wrapper) SetInWorkConnCallback(cb func(*v1.ProxyBaseConfig, net.Conn, *msg.StartWorkConn) bool) { pw.pxy.SetInWorkConnCallback(cb) } func (pw *Wrapper) SetRunningStatus(remoteAddr string, respErr string) error { pw.mu.Lock() defer pw.mu.Unlock() if pw.Phase != ProxyPhaseWaitStart { return fmt.Errorf("status not wait start, ignore start message") } pw.RemoteAddr = remoteAddr if respErr != "" { pw.Phase = ProxyPhaseStartErr pw.Err = respErr pw.lastStartErr = time.Now() return fmt.Errorf("%s", pw.Err) } if err := pw.pxy.Run(); err != nil { pw.close() pw.Phase = ProxyPhaseStartErr pw.Err = err.Error() pw.lastStartErr = time.Now() return err } pw.Phase = ProxyPhaseRunning pw.Err = "" return nil } func (pw *Wrapper) Start() { go pw.checkWorker() if pw.monitor != nil { go pw.monitor.Start() } } func (pw *Wrapper) Stop() { pw.mu.Lock() defer pw.mu.Unlock() close(pw.closeCh) close(pw.healthNotifyCh) pw.pxy.Close() if pw.monitor != nil { pw.monitor.Stop() } pw.Phase = ProxyPhaseClosed pw.close() } func (pw *Wrapper) close() { _ = pw.handler(&event.CloseProxyPayload{ CloseProxyMsg: &msg.CloseProxy{ ProxyName: pw.wireName, }, }) } func (pw *Wrapper) checkWorker() { xl := pw.xl if pw.monitor != nil { // let monitor do check request first time.Sleep(500 * time.Millisecond) } for { // check proxy status now := time.Now() if atomic.LoadUint32(&pw.health) == 0 { pw.mu.Lock() if pw.Phase == ProxyPhaseNew || pw.Phase == ProxyPhaseCheckFailed || (pw.Phase == ProxyPhaseWaitStart && now.After(pw.lastSendStartMsg.Add(waitResponseTimeout))) || (pw.Phase == ProxyPhaseStartErr && now.After(pw.lastStartErr.Add(startErrTimeout))) { xl.Tracef("change status from [%s] to [%s]", pw.Phase, ProxyPhaseWaitStart) pw.Phase = ProxyPhaseWaitStart var newProxyMsg msg.NewProxy pw.Cfg.MarshalToMsg(&newProxyMsg) newProxyMsg.ProxyName = pw.wireName pw.lastSendStartMsg = now _ = pw.handler(&event.StartProxyPayload{ NewProxyMsg: &newProxyMsg, }) } pw.mu.Unlock() } else { pw.mu.Lock() if pw.Phase == ProxyPhaseRunning || pw.Phase == ProxyPhaseWaitStart { pw.close() xl.Tracef("change status from [%s] to [%s]", pw.Phase, ProxyPhaseCheckFailed) pw.Phase = ProxyPhaseCheckFailed } pw.mu.Unlock() } select { case <-pw.closeCh: return case <-time.After(statusCheckInterval): case <-pw.healthNotifyCh: } } } func (pw *Wrapper) statusNormalCallback() { xl := pw.xl atomic.StoreUint32(&pw.health, 0) _ = errors.PanicToError(func() { select { case pw.healthNotifyCh <- struct{}{}: default: } }) xl.Infof("health check success") } func (pw *Wrapper) statusFailedCallback() { xl := pw.xl atomic.StoreUint32(&pw.health, 1) _ = errors.PanicToError(func() { select { case pw.healthNotifyCh <- struct{}{}: default: } }) xl.Infof("health check failed") } func (pw *Wrapper) InWorkConn(workConn net.Conn, m *msg.StartWorkConn) { xl := pw.xl pw.mu.RLock() pxy := pw.pxy pw.mu.RUnlock() if pxy != nil && pw.Phase == ProxyPhaseRunning { xl.Debugf("start a new work connection, localAddr: %s remoteAddr: %s", workConn.LocalAddr().String(), workConn.RemoteAddr().String()) go pxy.InWorkConn(workConn, m) } else { workConn.Close() } } func (pw *Wrapper) GetStatus() *WorkingStatus { pw.mu.RLock() defer pw.mu.RUnlock() ps := &WorkingStatus{ Name: pw.Name, Type: pw.Type, Phase: pw.Phase, Err: pw.Err, Cfg: pw.Cfg, RemoteAddr: pw.RemoteAddr, } return ps } ================================================ FILE: client/proxy/sudp.go ================================================ // Copyright 2023 The frp Authors // // Licensed under the Apache License, Version 2.0 (the "License"); // you may not use this file except in compliance with the License. // You may obtain a copy of the License at // // http://www.apache.org/licenses/LICENSE-2.0 // // Unless required by applicable law or agreed to in writing, software // distributed under the License is distributed on an "AS IS" BASIS, // WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. // See the License for the specific language governing permissions and // limitations under the License. //go:build !frps package proxy import ( "net" "reflect" "strconv" "sync" "time" "github.com/fatedier/golib/errors" v1 "github.com/fatedier/frp/pkg/config/v1" "github.com/fatedier/frp/pkg/msg" "github.com/fatedier/frp/pkg/proto/udp" netpkg "github.com/fatedier/frp/pkg/util/net" ) func init() { RegisterProxyFactory(reflect.TypeFor[*v1.SUDPProxyConfig](), NewSUDPProxy) } type SUDPProxy struct { *BaseProxy cfg *v1.SUDPProxyConfig localAddr *net.UDPAddr closeCh chan struct{} } func NewSUDPProxy(baseProxy *BaseProxy, cfg v1.ProxyConfigurer) Proxy { unwrapped, ok := cfg.(*v1.SUDPProxyConfig) if !ok { return nil } return &SUDPProxy{ BaseProxy: baseProxy, cfg: unwrapped, closeCh: make(chan struct{}), } } func (pxy *SUDPProxy) Run() (err error) { pxy.localAddr, err = net.ResolveUDPAddr("udp", net.JoinHostPort(pxy.cfg.LocalIP, strconv.Itoa(pxy.cfg.LocalPort))) if err != nil { return } return } func (pxy *SUDPProxy) Close() { pxy.mu.Lock() defer pxy.mu.Unlock() select { case <-pxy.closeCh: return default: close(pxy.closeCh) } } func (pxy *SUDPProxy) InWorkConn(conn net.Conn, _ *msg.StartWorkConn) { xl := pxy.xl xl.Infof("incoming a new work connection for sudp proxy, %s", conn.RemoteAddr().String()) remote, _, err := pxy.wrapWorkConn(conn, pxy.encryptionKey) if err != nil { xl.Errorf("wrap work connection: %v", err) return } workConn := netpkg.WrapReadWriteCloserToConn(remote, conn) readCh := make(chan *msg.UDPPacket, 1024) sendCh := make(chan msg.Message, 1024) isClose := false mu := &sync.Mutex{} closeFn := func() { mu.Lock() defer mu.Unlock() if isClose { return } isClose = true if workConn != nil { workConn.Close() } close(readCh) close(sendCh) } // udp service <- frpc <- frps <- frpc visitor <- user workConnReaderFn := func(conn net.Conn, readCh chan *msg.UDPPacket) { defer closeFn() for { // first to check sudp proxy is closed or not select { case <-pxy.closeCh: xl.Tracef("frpc sudp proxy is closed") return default: } var udpMsg msg.UDPPacket if errRet := msg.ReadMsgInto(conn, &udpMsg); errRet != nil { xl.Warnf("read from workConn for sudp error: %v", errRet) return } if errRet := errors.PanicToError(func() { readCh <- &udpMsg }); errRet != nil { xl.Warnf("reader goroutine for sudp work connection closed: %v", errRet) return } } } // udp service -> frpc -> frps -> frpc visitor -> user workConnSenderFn := func(conn net.Conn, sendCh chan msg.Message) { defer func() { closeFn() xl.Infof("writer goroutine for sudp work connection closed") }() var errRet error for rawMsg := range sendCh { switch m := rawMsg.(type) { case *msg.UDPPacket: xl.Tracef("frpc send udp package to frpc visitor, [udp local: %v, remote: %v], [tcp work conn local: %v, remote: %v]", m.LocalAddr.String(), m.RemoteAddr.String(), conn.LocalAddr().String(), conn.RemoteAddr().String()) case *msg.Ping: xl.Tracef("frpc send ping message to frpc visitor") } if errRet = msg.WriteMsg(conn, rawMsg); errRet != nil { xl.Errorf("sudp work write error: %v", errRet) return } } } heartbeatFn := func(sendCh chan msg.Message) { ticker := time.NewTicker(30 * time.Second) defer func() { ticker.Stop() closeFn() }() var errRet error for { select { case <-ticker.C: if errRet = errors.PanicToError(func() { sendCh <- &msg.Ping{} }); errRet != nil { xl.Warnf("heartbeat goroutine for sudp work connection closed") return } case <-pxy.closeCh: xl.Tracef("frpc sudp proxy is closed") return } } } go workConnSenderFn(workConn, sendCh) go workConnReaderFn(workConn, readCh) go heartbeatFn(sendCh) udp.Forwarder(pxy.localAddr, readCh, sendCh, int(pxy.clientCfg.UDPPacketSize), pxy.cfg.Transport.ProxyProtocolVersion) } ================================================ FILE: client/proxy/udp.go ================================================ // Copyright 2023 The frp Authors // // Licensed under the Apache License, Version 2.0 (the "License"); // you may not use this file except in compliance with the License. // You may obtain a copy of the License at // // http://www.apache.org/licenses/LICENSE-2.0 // // Unless required by applicable law or agreed to in writing, software // distributed under the License is distributed on an "AS IS" BASIS, // WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. // See the License for the specific language governing permissions and // limitations under the License. //go:build !frps package proxy import ( "net" "reflect" "strconv" "time" "github.com/fatedier/golib/errors" v1 "github.com/fatedier/frp/pkg/config/v1" "github.com/fatedier/frp/pkg/msg" "github.com/fatedier/frp/pkg/proto/udp" netpkg "github.com/fatedier/frp/pkg/util/net" ) func init() { RegisterProxyFactory(reflect.TypeFor[*v1.UDPProxyConfig](), NewUDPProxy) } type UDPProxy struct { *BaseProxy cfg *v1.UDPProxyConfig localAddr *net.UDPAddr readCh chan *msg.UDPPacket // include msg.UDPPacket and msg.Ping sendCh chan msg.Message workConn net.Conn closed bool } func NewUDPProxy(baseProxy *BaseProxy, cfg v1.ProxyConfigurer) Proxy { unwrapped, ok := cfg.(*v1.UDPProxyConfig) if !ok { return nil } return &UDPProxy{ BaseProxy: baseProxy, cfg: unwrapped, } } func (pxy *UDPProxy) Run() (err error) { pxy.localAddr, err = net.ResolveUDPAddr("udp", net.JoinHostPort(pxy.cfg.LocalIP, strconv.Itoa(pxy.cfg.LocalPort))) if err != nil { return } return } func (pxy *UDPProxy) Close() { pxy.mu.Lock() defer pxy.mu.Unlock() if !pxy.closed { pxy.closed = true if pxy.workConn != nil { pxy.workConn.Close() } if pxy.readCh != nil { close(pxy.readCh) } if pxy.sendCh != nil { close(pxy.sendCh) } } } func (pxy *UDPProxy) InWorkConn(conn net.Conn, _ *msg.StartWorkConn) { xl := pxy.xl xl.Infof("incoming a new work connection for udp proxy, %s", conn.RemoteAddr().String()) // close resources related with old workConn pxy.Close() remote, _, err := pxy.wrapWorkConn(conn, pxy.encryptionKey) if err != nil { xl.Errorf("wrap work connection: %v", err) return } pxy.mu.Lock() pxy.workConn = netpkg.WrapReadWriteCloserToConn(remote, conn) pxy.readCh = make(chan *msg.UDPPacket, 1024) pxy.sendCh = make(chan msg.Message, 1024) pxy.closed = false pxy.mu.Unlock() workConnReaderFn := func(conn net.Conn, readCh chan *msg.UDPPacket) { for { var udpMsg msg.UDPPacket if errRet := msg.ReadMsgInto(conn, &udpMsg); errRet != nil { xl.Warnf("read from workConn for udp error: %v", errRet) return } if errRet := errors.PanicToError(func() { xl.Tracef("get udp package from workConn, len: %d", len(udpMsg.Content)) readCh <- &udpMsg }); errRet != nil { xl.Infof("reader goroutine for udp work connection closed: %v", errRet) return } } } workConnSenderFn := func(conn net.Conn, sendCh chan msg.Message) { defer func() { xl.Infof("writer goroutine for udp work connection closed") }() var errRet error for rawMsg := range sendCh { switch m := rawMsg.(type) { case *msg.UDPPacket: xl.Tracef("send udp package to workConn, len: %d", len(m.Content)) case *msg.Ping: xl.Tracef("send ping message to udp workConn") } if errRet = msg.WriteMsg(conn, rawMsg); errRet != nil { xl.Errorf("udp work write error: %v", errRet) return } } } heartbeatFn := func(sendCh chan msg.Message) { var errRet error for { time.Sleep(time.Duration(30) * time.Second) if errRet = errors.PanicToError(func() { sendCh <- &msg.Ping{} }); errRet != nil { xl.Tracef("heartbeat goroutine for udp work connection closed") break } } } go workConnSenderFn(pxy.workConn, pxy.sendCh) go workConnReaderFn(pxy.workConn, pxy.readCh) go heartbeatFn(pxy.sendCh) // Call Forwarder with proxy protocol version (empty string means no proxy protocol) udp.Forwarder(pxy.localAddr, pxy.readCh, pxy.sendCh, int(pxy.clientCfg.UDPPacketSize), pxy.cfg.Transport.ProxyProtocolVersion) } ================================================ FILE: client/proxy/xtcp.go ================================================ // Copyright 2023 The frp Authors // // Licensed under the Apache License, Version 2.0 (the "License"); // you may not use this file except in compliance with the License. // You may obtain a copy of the License at // // http://www.apache.org/licenses/LICENSE-2.0 // // Unless required by applicable law or agreed to in writing, software // distributed under the License is distributed on an "AS IS" BASIS, // WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. // See the License for the specific language governing permissions and // limitations under the License. //go:build !frps package proxy import ( "io" "net" "reflect" "time" fmux "github.com/hashicorp/yamux" "github.com/quic-go/quic-go" v1 "github.com/fatedier/frp/pkg/config/v1" "github.com/fatedier/frp/pkg/msg" "github.com/fatedier/frp/pkg/naming" "github.com/fatedier/frp/pkg/nathole" "github.com/fatedier/frp/pkg/transport" netpkg "github.com/fatedier/frp/pkg/util/net" ) func init() { RegisterProxyFactory(reflect.TypeFor[*v1.XTCPProxyConfig](), NewXTCPProxy) } type XTCPProxy struct { *BaseProxy cfg *v1.XTCPProxyConfig } func NewXTCPProxy(baseProxy *BaseProxy, cfg v1.ProxyConfigurer) Proxy { unwrapped, ok := cfg.(*v1.XTCPProxyConfig) if !ok { return nil } return &XTCPProxy{ BaseProxy: baseProxy, cfg: unwrapped, } } func (pxy *XTCPProxy) InWorkConn(conn net.Conn, startWorkConnMsg *msg.StartWorkConn) { xl := pxy.xl defer conn.Close() var natHoleSidMsg msg.NatHoleSid err := msg.ReadMsgInto(conn, &natHoleSidMsg) if err != nil { xl.Errorf("xtcp read from workConn error: %v", err) return } xl.Tracef("nathole prepare start") // Prepare NAT traversal options var opts nathole.PrepareOptions if pxy.cfg.NatTraversal != nil && pxy.cfg.NatTraversal.DisableAssistedAddrs { opts.DisableAssistedAddrs = true } prepareResult, err := nathole.Prepare([]string{pxy.clientCfg.NatHoleSTUNServer}, opts) if err != nil { xl.Warnf("nathole prepare error: %v", err) return } xl.Infof("nathole prepare success, nat type: %s, behavior: %s, addresses: %v, assistedAddresses: %v", prepareResult.NatType, prepareResult.Behavior, prepareResult.Addrs, prepareResult.AssistedAddrs) defer prepareResult.ListenConn.Close() // send NatHoleClient msg to server transactionID := nathole.NewTransactionID() natHoleClientMsg := &msg.NatHoleClient{ TransactionID: transactionID, ProxyName: naming.AddUserPrefix(pxy.clientCfg.User, pxy.cfg.Name), Sid: natHoleSidMsg.Sid, MappedAddrs: prepareResult.Addrs, AssistedAddrs: prepareResult.AssistedAddrs, } xl.Tracef("nathole exchange info start") natHoleRespMsg, err := nathole.ExchangeInfo(pxy.ctx, pxy.msgTransporter, transactionID, natHoleClientMsg, 5*time.Second) if err != nil { xl.Warnf("nathole exchange info error: %v", err) return } xl.Infof("get natHoleRespMsg, sid [%s], protocol [%s], candidate address %v, assisted address %v, detectBehavior: %+v", natHoleRespMsg.Sid, natHoleRespMsg.Protocol, natHoleRespMsg.CandidateAddrs, natHoleRespMsg.AssistedAddrs, natHoleRespMsg.DetectBehavior) listenConn := prepareResult.ListenConn newListenConn, raddr, err := nathole.MakeHole(pxy.ctx, listenConn, natHoleRespMsg, []byte(pxy.cfg.Secretkey)) if err != nil { listenConn.Close() xl.Warnf("make hole error: %v", err) _ = pxy.msgTransporter.Send(&msg.NatHoleReport{ Sid: natHoleRespMsg.Sid, Success: false, }) return } listenConn = newListenConn xl.Infof("establishing nat hole connection successful, sid [%s], remoteAddr [%s]", natHoleRespMsg.Sid, raddr) _ = pxy.msgTransporter.Send(&msg.NatHoleReport{ Sid: natHoleRespMsg.Sid, Success: true, }) if natHoleRespMsg.Protocol == "kcp" { pxy.listenByKCP(listenConn, raddr, startWorkConnMsg) return } // default is quic pxy.listenByQUIC(listenConn, raddr, startWorkConnMsg) } func (pxy *XTCPProxy) listenByKCP(listenConn *net.UDPConn, raddr *net.UDPAddr, startWorkConnMsg *msg.StartWorkConn) { xl := pxy.xl listenConn.Close() laddr, _ := net.ResolveUDPAddr("udp", listenConn.LocalAddr().String()) lConn, err := net.DialUDP("udp", laddr, raddr) if err != nil { xl.Warnf("dial udp error: %v", err) return } defer lConn.Close() remote, err := netpkg.NewKCPConnFromUDP(lConn, true, raddr.String()) if err != nil { xl.Warnf("create kcp connection from udp connection error: %v", err) return } fmuxCfg := fmux.DefaultConfig() fmuxCfg.KeepAliveInterval = 10 * time.Second fmuxCfg.MaxStreamWindowSize = 6 * 1024 * 1024 fmuxCfg.LogOutput = io.Discard session, err := fmux.Server(remote, fmuxCfg) if err != nil { xl.Errorf("create mux session error: %v", err) return } defer session.Close() for { muxConn, err := session.Accept() if err != nil { xl.Errorf("accept connection error: %v", err) return } go pxy.HandleTCPWorkConnection(muxConn, startWorkConnMsg, []byte(pxy.cfg.Secretkey)) } } func (pxy *XTCPProxy) listenByQUIC(listenConn *net.UDPConn, _ *net.UDPAddr, startWorkConnMsg *msg.StartWorkConn) { xl := pxy.xl defer listenConn.Close() tlsConfig, err := transport.NewServerTLSConfig("", "", "") if err != nil { xl.Warnf("create tls config error: %v", err) return } tlsConfig.NextProtos = []string{"frp"} quicListener, err := quic.Listen(listenConn, tlsConfig, &quic.Config{ MaxIdleTimeout: time.Duration(pxy.clientCfg.Transport.QUIC.MaxIdleTimeout) * time.Second, MaxIncomingStreams: int64(pxy.clientCfg.Transport.QUIC.MaxIncomingStreams), KeepAlivePeriod: time.Duration(pxy.clientCfg.Transport.QUIC.KeepalivePeriod) * time.Second, }, ) if err != nil { xl.Warnf("dial quic error: %v", err) return } // only accept one connection from raddr c, err := quicListener.Accept(pxy.ctx) if err != nil { xl.Errorf("quic accept connection error: %v", err) return } for { stream, err := c.AcceptStream(pxy.ctx) if err != nil { xl.Debugf("quic accept stream error: %v", err) _ = c.CloseWithError(0, "") return } go pxy.HandleTCPWorkConnection(netpkg.QuicStreamToNetConn(stream, c), startWorkConnMsg, []byte(pxy.cfg.Secretkey)) } } ================================================ FILE: client/service.go ================================================ // Copyright 2017 fatedier, fatedier@gmail.com // // Licensed under the Apache License, Version 2.0 (the "License"); // you may not use this file except in compliance with the License. // You may obtain a copy of the License at // // http://www.apache.org/licenses/LICENSE-2.0 // // Unless required by applicable law or agreed to in writing, software // distributed under the License is distributed on an "AS IS" BASIS, // WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. // See the License for the specific language governing permissions and // limitations under the License. package client import ( "context" "errors" "fmt" "net" "net/http" "os" "runtime" "sync" "time" "github.com/fatedier/golib/crypto" "github.com/samber/lo" "github.com/fatedier/frp/client/proxy" "github.com/fatedier/frp/pkg/auth" "github.com/fatedier/frp/pkg/config" "github.com/fatedier/frp/pkg/config/source" v1 "github.com/fatedier/frp/pkg/config/v1" "github.com/fatedier/frp/pkg/msg" "github.com/fatedier/frp/pkg/policy/security" httppkg "github.com/fatedier/frp/pkg/util/http" "github.com/fatedier/frp/pkg/util/log" netpkg "github.com/fatedier/frp/pkg/util/net" "github.com/fatedier/frp/pkg/util/version" "github.com/fatedier/frp/pkg/util/wait" "github.com/fatedier/frp/pkg/util/xlog" "github.com/fatedier/frp/pkg/vnet" ) func init() { crypto.DefaultSalt = "frp" // Disable quic-go's receive buffer warning. os.Setenv("QUIC_GO_DISABLE_RECEIVE_BUFFER_WARNING", "true") // Disable quic-go's ECN support by default. It may cause issues on certain operating systems. if os.Getenv("QUIC_GO_DISABLE_ECN") == "" { os.Setenv("QUIC_GO_DISABLE_ECN", "true") } } type cancelErr struct { Err error } func (e cancelErr) Error() string { return e.Err.Error() } // ServiceOptions contains options for creating a new client service. type ServiceOptions struct { Common *v1.ClientCommonConfig // ConfigSourceAggregator manages internal config and optional store sources. // It is required for creating a Service. ConfigSourceAggregator *source.Aggregator UnsafeFeatures *security.UnsafeFeatures // ConfigFilePath is the path to the configuration file used to initialize. // If it is empty, it means that the configuration file is not used for initialization. // It may be initialized using command line parameters or called directly. ConfigFilePath string // ClientSpec is the client specification that control the client behavior. ClientSpec *msg.ClientSpec // ConnectorCreator is a function that creates a new connector to make connections to the server. // The Connector shields the underlying connection details, whether it is through TCP or QUIC connection, // and regardless of whether multiplexing is used. // // If it is not set, the default frpc connector will be used. // By using a custom Connector, it can be used to implement a VirtualClient, which connects to frps // through a pipe instead of a real physical connection. ConnectorCreator func(context.Context, *v1.ClientCommonConfig) Connector // HandleWorkConnCb is a callback function that is called when a new work connection is created. // // If it is not set, the default frpc implementation will be used. HandleWorkConnCb func(*v1.ProxyBaseConfig, net.Conn, *msg.StartWorkConn) bool } // setServiceOptionsDefault sets the default values for ServiceOptions. func setServiceOptionsDefault(options *ServiceOptions) error { if options.Common != nil { if err := options.Common.Complete(); err != nil { return err } } if options.ConnectorCreator == nil { options.ConnectorCreator = NewConnector } return nil } // Service is the client service that connects to frps and provides proxy services. type Service struct { ctlMu sync.RWMutex // manager control connection with server ctl *Control // Uniq id got from frps, it will be attached to loginMsg. runID string // Auth runtime and encryption materials auth *auth.ClientAuth // web server for admin UI and apis webServer *httppkg.Server vnetController *vnet.Controller cfgMu sync.RWMutex // reloadMu serializes reload transactions to keep reloadCommon and applied // config in sync across concurrent API operations. reloadMu sync.Mutex common *v1.ClientCommonConfig // reloadCommon is used for filtering/defaulting during config-source reloads. // It can be updated by /api/reload without mutating startup-only common behavior. reloadCommon *v1.ClientCommonConfig proxyCfgs []v1.ProxyConfigurer visitorCfgs []v1.VisitorConfigurer clientSpec *msg.ClientSpec // aggregator manages multiple configuration sources. // When set, the service watches for config changes and reloads automatically. aggregator *source.Aggregator configSource *source.ConfigSource storeSource *source.StoreSource unsafeFeatures *security.UnsafeFeatures // The configuration file used to initialize this client, or an empty // string if no configuration file was used. configFilePath string // service context ctx context.Context // call cancel to stop service cancel context.CancelCauseFunc gracefulShutdownDuration time.Duration connectorCreator func(context.Context, *v1.ClientCommonConfig) Connector handleWorkConnCb func(*v1.ProxyBaseConfig, net.Conn, *msg.StartWorkConn) bool } func NewService(options ServiceOptions) (*Service, error) { if err := setServiceOptionsDefault(&options); err != nil { return nil, err } authRuntime, err := auth.BuildClientAuth(&options.Common.Auth) if err != nil { return nil, err } if options.ConfigSourceAggregator == nil { return nil, fmt.Errorf("config source aggregator is required") } configSource := options.ConfigSourceAggregator.ConfigSource() storeSource := options.ConfigSourceAggregator.StoreSource() proxyCfgs, visitorCfgs, loadErr := options.ConfigSourceAggregator.Load() if loadErr != nil { return nil, fmt.Errorf("failed to load config from aggregator: %w", loadErr) } proxyCfgs, visitorCfgs = config.FilterClientConfigurers(options.Common, proxyCfgs, visitorCfgs) proxyCfgs = config.CompleteProxyConfigurers(proxyCfgs) visitorCfgs = config.CompleteVisitorConfigurers(visitorCfgs) // Create the web server after all fallible steps so its listener is not // leaked when an earlier error causes NewService to return. var webServer *httppkg.Server if options.Common.WebServer.Port > 0 { ws, err := httppkg.NewServer(options.Common.WebServer) if err != nil { return nil, err } webServer = ws } s := &Service{ ctx: context.Background(), auth: authRuntime, webServer: webServer, common: options.Common, reloadCommon: options.Common, configFilePath: options.ConfigFilePath, unsafeFeatures: options.UnsafeFeatures, proxyCfgs: proxyCfgs, visitorCfgs: visitorCfgs, clientSpec: options.ClientSpec, aggregator: options.ConfigSourceAggregator, configSource: configSource, storeSource: storeSource, connectorCreator: options.ConnectorCreator, handleWorkConnCb: options.HandleWorkConnCb, } if webServer != nil { webServer.RouteRegister(s.registerRouteHandlers) } if options.Common.VirtualNet.Address != "" { s.vnetController = vnet.NewController(options.Common.VirtualNet) } return s, nil } func (svr *Service) Run(ctx context.Context) error { ctx, cancel := context.WithCancelCause(ctx) svr.ctx = xlog.NewContext(ctx, xlog.FromContextSafe(ctx)) svr.cancel = cancel // set custom DNSServer if svr.common.DNSServer != "" { netpkg.SetDefaultDNSAddress(svr.common.DNSServer) } if svr.vnetController != nil { vnetController := svr.vnetController if err := svr.vnetController.Init(); err != nil { log.Errorf("init virtual network controller error: %v", err) svr.stop() return err } go func() { log.Infof("virtual network controller start...") if err := vnetController.Run(); err != nil && !errors.Is(err, net.ErrClosed) { log.Warnf("virtual network controller exit with error: %v", err) } }() } if svr.webServer != nil { webServer := svr.webServer go func() { log.Infof("admin server listen on %s", webServer.Address()) if err := webServer.Run(); err != nil && !errors.Is(err, http.ErrServerClosed) { log.Warnf("admin server exit with error: %v", err) } }() } // first login to frps svr.loopLoginUntilSuccess(10*time.Second, lo.FromPtr(svr.common.LoginFailExit)) if svr.ctl == nil { cancelCause := cancelErr{} _ = errors.As(context.Cause(svr.ctx), &cancelCause) svr.stop() return fmt.Errorf("login to the server failed: %v. With loginFailExit enabled, no additional retries will be attempted", cancelCause.Err) } go svr.keepControllerWorking() <-svr.ctx.Done() svr.stop() return nil } func (svr *Service) keepControllerWorking() { <-svr.ctl.Done() // There is a situation where the login is successful but due to certain reasons, // the control immediately exits. It is necessary to limit the frequency of reconnection in this case. // The interval for the first three retries in 1 minute will be very short, and then it will increase exponentially. // The maximum interval is 20 seconds. wait.BackoffUntil(func() (bool, error) { // loopLoginUntilSuccess is another layer of loop that will continuously attempt to // login to the server until successful. svr.loopLoginUntilSuccess(20*time.Second, false) if svr.ctl != nil { <-svr.ctl.Done() return false, errors.New("control is closed and try another loop") } // If the control is nil, it means that the login failed and the service is also closed. return false, nil }, wait.NewFastBackoffManager( wait.FastBackoffOptions{ Duration: time.Second, Factor: 2, Jitter: 0.1, MaxDuration: 20 * time.Second, FastRetryCount: 3, FastRetryDelay: 200 * time.Millisecond, FastRetryWindow: time.Minute, FastRetryJitter: 0.5, }, ), true, svr.ctx.Done()) } // login creates a connection to frps and registers it self as a client // conn: control connection // session: if it's not nil, using tcp mux func (svr *Service) login() (conn net.Conn, connector Connector, err error) { xl := xlog.FromContextSafe(svr.ctx) connector = svr.connectorCreator(svr.ctx, svr.common) if err = connector.Open(); err != nil { return nil, nil, err } defer func() { if err != nil { connector.Close() } }() conn, err = connector.Connect() if err != nil { return } hostname, _ := os.Hostname() loginMsg := &msg.Login{ Arch: runtime.GOARCH, Os: runtime.GOOS, Hostname: hostname, PoolCount: svr.common.Transport.PoolCount, User: svr.common.User, ClientID: svr.common.ClientID, Version: version.Full(), Timestamp: time.Now().Unix(), RunID: svr.runID, Metas: svr.common.Metadatas, } if svr.clientSpec != nil { loginMsg.ClientSpec = *svr.clientSpec } // Add auth if err = svr.auth.Setter.SetLogin(loginMsg); err != nil { return } if err = msg.WriteMsg(conn, loginMsg); err != nil { return } var loginRespMsg msg.LoginResp _ = conn.SetReadDeadline(time.Now().Add(10 * time.Second)) if err = msg.ReadMsgInto(conn, &loginRespMsg); err != nil { return } _ = conn.SetReadDeadline(time.Time{}) if loginRespMsg.Error != "" { err = fmt.Errorf("%s", loginRespMsg.Error) xl.Errorf("%s", loginRespMsg.Error) return } svr.runID = loginRespMsg.RunID xl.AddPrefix(xlog.LogPrefix{Name: "runID", Value: svr.runID}) xl.Infof("login to server success, get run id [%s]", loginRespMsg.RunID) return } func (svr *Service) loopLoginUntilSuccess(maxInterval time.Duration, firstLoginExit bool) { xl := xlog.FromContextSafe(svr.ctx) loginFunc := func() (bool, error) { xl.Infof("try to connect to server...") conn, connector, err := svr.login() if err != nil { xl.Warnf("connect to server error: %v", err) if firstLoginExit { svr.cancel(cancelErr{Err: err}) } return false, err } svr.cfgMu.RLock() proxyCfgs := svr.proxyCfgs visitorCfgs := svr.visitorCfgs svr.cfgMu.RUnlock() connEncrypted := svr.clientSpec == nil || svr.clientSpec.Type != "ssh-tunnel" sessionCtx := &SessionContext{ Common: svr.common, RunID: svr.runID, Conn: conn, ConnEncrypted: connEncrypted, Auth: svr.auth, Connector: connector, VnetController: svr.vnetController, } ctl, err := NewControl(svr.ctx, sessionCtx) if err != nil { conn.Close() xl.Errorf("new control error: %v", err) return false, err } ctl.SetInWorkConnCallback(svr.handleWorkConnCb) ctl.Run(proxyCfgs, visitorCfgs) // close and replace previous control svr.ctlMu.Lock() if svr.ctl != nil { svr.ctl.Close() } svr.ctl = ctl svr.ctlMu.Unlock() return true, nil } // try to reconnect to server until success wait.BackoffUntil(loginFunc, wait.NewFastBackoffManager( wait.FastBackoffOptions{ Duration: time.Second, Factor: 2, Jitter: 0.1, MaxDuration: maxInterval, }), true, svr.ctx.Done()) } func (svr *Service) UpdateAllConfigurer(proxyCfgs []v1.ProxyConfigurer, visitorCfgs []v1.VisitorConfigurer) error { svr.cfgMu.Lock() svr.proxyCfgs = proxyCfgs svr.visitorCfgs = visitorCfgs svr.cfgMu.Unlock() svr.ctlMu.RLock() ctl := svr.ctl svr.ctlMu.RUnlock() if ctl != nil { return svr.ctl.UpdateAllConfigurer(proxyCfgs, visitorCfgs) } return nil } func (svr *Service) UpdateConfigSource( common *v1.ClientCommonConfig, proxyCfgs []v1.ProxyConfigurer, visitorCfgs []v1.VisitorConfigurer, ) error { svr.reloadMu.Lock() defer svr.reloadMu.Unlock() cfgSource := svr.configSource if cfgSource == nil { return fmt.Errorf("config source is not available") } if err := cfgSource.ReplaceAll(proxyCfgs, visitorCfgs); err != nil { return err } // Non-atomic update semantics: source has been updated at this point. // Even if reload fails below, keep this common config for subsequent reloads. svr.cfgMu.Lock() svr.reloadCommon = common svr.cfgMu.Unlock() if err := svr.reloadConfigFromSourcesLocked(); err != nil { return err } return nil } func (svr *Service) Close() { svr.GracefulClose(time.Duration(0)) } func (svr *Service) GracefulClose(d time.Duration) { svr.gracefulShutdownDuration = d svr.cancel(nil) } func (svr *Service) stop() { // Coordinate shutdown with reload/update paths that read source pointers. svr.reloadMu.Lock() if svr.aggregator != nil { svr.aggregator = nil } svr.configSource = nil svr.storeSource = nil svr.reloadMu.Unlock() svr.ctlMu.Lock() defer svr.ctlMu.Unlock() if svr.ctl != nil { svr.ctl.GracefulClose(svr.gracefulShutdownDuration) svr.ctl = nil } if svr.webServer != nil { svr.webServer.Close() svr.webServer = nil } if svr.vnetController != nil { _ = svr.vnetController.Stop() svr.vnetController = nil } } func (svr *Service) getProxyStatus(name string) (*proxy.WorkingStatus, bool) { svr.ctlMu.RLock() ctl := svr.ctl svr.ctlMu.RUnlock() if ctl == nil { return nil, false } return ctl.pm.GetProxyStatus(name) } func (svr *Service) getVisitorCfg(name string) (v1.VisitorConfigurer, bool) { svr.ctlMu.RLock() ctl := svr.ctl svr.ctlMu.RUnlock() if ctl == nil { return nil, false } return ctl.vm.GetVisitorCfg(name) } func (svr *Service) StatusExporter() StatusExporter { return &statusExporterImpl{ getProxyStatusFunc: svr.getProxyStatus, } } type StatusExporter interface { GetProxyStatus(name string) (*proxy.WorkingStatus, bool) } type statusExporterImpl struct { getProxyStatusFunc func(name string) (*proxy.WorkingStatus, bool) } func (s *statusExporterImpl) GetProxyStatus(name string) (*proxy.WorkingStatus, bool) { return s.getProxyStatusFunc(name) } func (svr *Service) reloadConfigFromSources() error { svr.reloadMu.Lock() defer svr.reloadMu.Unlock() return svr.reloadConfigFromSourcesLocked() } func (svr *Service) reloadConfigFromSourcesLocked() error { aggregator := svr.aggregator if aggregator == nil { return errors.New("config aggregator is not initialized") } svr.cfgMu.RLock() reloadCommon := svr.reloadCommon svr.cfgMu.RUnlock() proxies, visitors, err := aggregator.Load() if err != nil { return fmt.Errorf("reload config from sources failed: %w", err) } proxies, visitors = config.FilterClientConfigurers(reloadCommon, proxies, visitors) proxies = config.CompleteProxyConfigurers(proxies) visitors = config.CompleteVisitorConfigurers(visitors) // Atomically replace the entire configuration if err := svr.UpdateAllConfigurer(proxies, visitors); err != nil { return err } return nil } ================================================ FILE: client/service_test.go ================================================ package client import ( "context" "errors" "net" "path/filepath" "strconv" "strings" "testing" "github.com/samber/lo" "github.com/fatedier/frp/pkg/config/source" v1 "github.com/fatedier/frp/pkg/config/v1" ) type failingConnector struct { err error } func (c *failingConnector) Open() error { return c.err } func (c *failingConnector) Connect() (net.Conn, error) { return nil, c.err } func (c *failingConnector) Close() error { return nil } func getFreeTCPPort(t *testing.T) int { t.Helper() ln, err := net.Listen("tcp", "127.0.0.1:0") if err != nil { t.Fatalf("listen on ephemeral port: %v", err) } defer ln.Close() return ln.Addr().(*net.TCPAddr).Port } func TestRunStopsStartedComponentsOnInitialLoginFailure(t *testing.T) { port := getFreeTCPPort(t) agg := source.NewAggregator(source.NewConfigSource()) svr, err := NewService(ServiceOptions{ Common: &v1.ClientCommonConfig{ LoginFailExit: lo.ToPtr(true), WebServer: v1.WebServerConfig{ Addr: "127.0.0.1", Port: port, }, }, ConfigSourceAggregator: agg, ConnectorCreator: func(context.Context, *v1.ClientCommonConfig) Connector { return &failingConnector{err: errors.New("login boom")} }, }) if err != nil { t.Fatalf("new service: %v", err) } err = svr.Run(context.Background()) if err == nil { t.Fatal("expected run error, got nil") } if !strings.Contains(err.Error(), "login boom") { t.Fatalf("unexpected error: %v", err) } if svr.webServer != nil { t.Fatal("expected web server to be cleaned up after initial login failure") } ln, err := net.Listen("tcp", net.JoinHostPort("127.0.0.1", strconv.Itoa(port))) if err != nil { t.Fatalf("expected admin port to be released: %v", err) } _ = ln.Close() } func TestNewServiceDoesNotLeakAdminListenerOnAuthBuildFailure(t *testing.T) { port := getFreeTCPPort(t) agg := source.NewAggregator(source.NewConfigSource()) _, err := NewService(ServiceOptions{ Common: &v1.ClientCommonConfig{ Auth: v1.AuthClientConfig{ Method: v1.AuthMethodOIDC, OIDC: v1.AuthOIDCClientConfig{ TokenEndpointURL: "://bad", }, }, WebServer: v1.WebServerConfig{ Addr: "127.0.0.1", Port: port, }, }, ConfigSourceAggregator: agg, }) if err == nil { t.Fatal("expected new service error, got nil") } if !strings.Contains(err.Error(), "auth.oidc.tokenEndpointURL") { t.Fatalf("unexpected error: %v", err) } ln, err := net.Listen("tcp", net.JoinHostPort("127.0.0.1", strconv.Itoa(port))) if err != nil { t.Fatalf("expected admin port to remain free: %v", err) } _ = ln.Close() } func TestUpdateConfigSourceRollsBackReloadCommonOnReplaceAllFailure(t *testing.T) { prevCommon := &v1.ClientCommonConfig{User: "old-user"} newCommon := &v1.ClientCommonConfig{User: "new-user"} svr := &Service{ configSource: source.NewConfigSource(), reloadCommon: prevCommon, } invalidProxy := &v1.TCPProxyConfig{} err := svr.UpdateConfigSource(newCommon, []v1.ProxyConfigurer{invalidProxy}, nil) if err == nil { t.Fatal("expected error, got nil") } if !strings.Contains(err.Error(), "proxy name cannot be empty") { t.Fatalf("unexpected error: %v", err) } if svr.reloadCommon != prevCommon { t.Fatalf("reloadCommon should roll back on ReplaceAll failure") } } func TestUpdateConfigSourceKeepsReloadCommonOnReloadFailure(t *testing.T) { prevCommon := &v1.ClientCommonConfig{User: "old-user"} newCommon := &v1.ClientCommonConfig{User: "new-user"} svr := &Service{ // Keep configSource valid so ReplaceAll succeeds first. configSource: source.NewConfigSource(), reloadCommon: prevCommon, // Keep aggregator nil to force reload failure. aggregator: nil, } validProxy := &v1.TCPProxyConfig{ ProxyBaseConfig: v1.ProxyBaseConfig{ Name: "p1", Type: "tcp", }, } err := svr.UpdateConfigSource(newCommon, []v1.ProxyConfigurer{validProxy}, nil) if err == nil { t.Fatal("expected error, got nil") } if !strings.Contains(err.Error(), "config aggregator is not initialized") { t.Fatalf("unexpected error: %v", err) } if svr.reloadCommon != newCommon { t.Fatalf("reloadCommon should keep new value on reload failure") } } func TestReloadConfigFromSourcesDoesNotMutateStoreConfigs(t *testing.T) { storeSource, err := source.NewStoreSource(source.StoreSourceConfig{ Path: filepath.Join(t.TempDir(), "store.json"), }) if err != nil { t.Fatalf("new store source: %v", err) } proxyCfg := &v1.TCPProxyConfig{ ProxyBaseConfig: v1.ProxyBaseConfig{ Name: "store-proxy", Type: "tcp", }, } visitorCfg := &v1.STCPVisitorConfig{ VisitorBaseConfig: v1.VisitorBaseConfig{ Name: "store-visitor", Type: "stcp", }, } if err := storeSource.AddProxy(proxyCfg); err != nil { t.Fatalf("add proxy to store: %v", err) } if err := storeSource.AddVisitor(visitorCfg); err != nil { t.Fatalf("add visitor to store: %v", err) } agg := source.NewAggregator(source.NewConfigSource()) agg.SetStoreSource(storeSource) svr := &Service{ aggregator: agg, configSource: agg.ConfigSource(), storeSource: storeSource, reloadCommon: &v1.ClientCommonConfig{}, } if err := svr.reloadConfigFromSources(); err != nil { t.Fatalf("reload config from sources: %v", err) } gotProxy := storeSource.GetProxy("store-proxy") if gotProxy == nil { t.Fatalf("proxy not found in store") } if gotProxy.GetBaseConfig().LocalIP != "" { t.Fatalf("store proxy localIP should stay empty, got %q", gotProxy.GetBaseConfig().LocalIP) } gotVisitor := storeSource.GetVisitor("store-visitor") if gotVisitor == nil { t.Fatalf("visitor not found in store") } if gotVisitor.GetBaseConfig().BindAddr != "" { t.Fatalf("store visitor bindAddr should stay empty, got %q", gotVisitor.GetBaseConfig().BindAddr) } svr.cfgMu.RLock() defer svr.cfgMu.RUnlock() if len(svr.proxyCfgs) != 1 { t.Fatalf("expected 1 runtime proxy, got %d", len(svr.proxyCfgs)) } if svr.proxyCfgs[0].GetBaseConfig().LocalIP != "127.0.0.1" { t.Fatalf("runtime proxy localIP should be defaulted, got %q", svr.proxyCfgs[0].GetBaseConfig().LocalIP) } if len(svr.visitorCfgs) != 1 { t.Fatalf("expected 1 runtime visitor, got %d", len(svr.visitorCfgs)) } if svr.visitorCfgs[0].GetBaseConfig().BindAddr != "127.0.0.1" { t.Fatalf("runtime visitor bindAddr should be defaulted, got %q", svr.visitorCfgs[0].GetBaseConfig().BindAddr) } } ================================================ FILE: client/visitor/stcp.go ================================================ // Copyright 2017 fatedier, fatedier@gmail.com // // Licensed under the Apache License, Version 2.0 (the "License"); // you may not use this file except in compliance with the License. // You may obtain a copy of the License at // // http://www.apache.org/licenses/LICENSE-2.0 // // Unless required by applicable law or agreed to in writing, software // distributed under the License is distributed on an "AS IS" BASIS, // WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. // See the License for the specific language governing permissions and // limitations under the License. package visitor import ( "net" "strconv" libio "github.com/fatedier/golib/io" v1 "github.com/fatedier/frp/pkg/config/v1" "github.com/fatedier/frp/pkg/util/xlog" ) type STCPVisitor struct { *BaseVisitor cfg *v1.STCPVisitorConfig } func (sv *STCPVisitor) Run() (err error) { if sv.cfg.BindPort > 0 { sv.l, err = net.Listen("tcp", net.JoinHostPort(sv.cfg.BindAddr, strconv.Itoa(sv.cfg.BindPort))) if err != nil { return } go sv.acceptLoop(sv.l, "stcp local", sv.handleConn) } go sv.acceptLoop(sv.internalLn, "stcp internal", sv.handleConn) if sv.plugin != nil { sv.plugin.Start() } return } func (sv *STCPVisitor) Close() { sv.BaseVisitor.Close() } func (sv *STCPVisitor) handleConn(userConn net.Conn) { xl := xlog.FromContextSafe(sv.ctx) var tunnelErr error defer func() { if tunnelErr != nil { if eConn, ok := userConn.(interface{ CloseWithError(error) error }); ok { _ = eConn.CloseWithError(tunnelErr) return } } userConn.Close() }() xl.Debugf("get a new stcp user connection") visitorConn, err := sv.dialRawVisitorConn(sv.cfg.GetBaseConfig()) if err != nil { xl.Warnf("dialRawVisitorConn error: %v", err) tunnelErr = err return } defer visitorConn.Close() remote, recycleFn, err := wrapVisitorConn(visitorConn, sv.cfg.GetBaseConfig()) if err != nil { xl.Warnf("wrapVisitorConn error: %v", err) tunnelErr = err return } defer recycleFn() libio.Join(userConn, remote) } ================================================ FILE: client/visitor/sudp.go ================================================ // Copyright 2017 fatedier, fatedier@gmail.com // // Licensed under the Apache License, Version 2.0 (the "License"); // you may not use this file except in compliance with the License. // You may obtain a copy of the License at // // http://www.apache.org/licenses/LICENSE-2.0 // // Unless required by applicable law or agreed to in writing, software // distributed under the License is distributed on an "AS IS" BASIS, // WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. // See the License for the specific language governing permissions and // limitations under the License. package visitor import ( "fmt" "net" "strconv" "sync" "time" "github.com/fatedier/golib/errors" v1 "github.com/fatedier/frp/pkg/config/v1" "github.com/fatedier/frp/pkg/msg" "github.com/fatedier/frp/pkg/proto/udp" netpkg "github.com/fatedier/frp/pkg/util/net" "github.com/fatedier/frp/pkg/util/xlog" ) type SUDPVisitor struct { *BaseVisitor checkCloseCh chan struct{} // udpConn is the listener of udp packet udpConn *net.UDPConn readCh chan *msg.UDPPacket sendCh chan *msg.UDPPacket cfg *v1.SUDPVisitorConfig } // SUDP Run start listen a udp port func (sv *SUDPVisitor) Run() (err error) { xl := xlog.FromContextSafe(sv.ctx) addr, err := net.ResolveUDPAddr("udp", net.JoinHostPort(sv.cfg.BindAddr, strconv.Itoa(sv.cfg.BindPort))) if err != nil { return fmt.Errorf("sudp ResolveUDPAddr error: %v", err) } sv.udpConn, err = net.ListenUDP("udp", addr) if err != nil { return fmt.Errorf("listen udp port %s error: %v", addr.String(), err) } sv.sendCh = make(chan *msg.UDPPacket, 1024) sv.readCh = make(chan *msg.UDPPacket, 1024) xl.Infof("sudp start to work, listen on %s", addr) go sv.dispatcher() go udp.ForwardUserConn(sv.udpConn, sv.readCh, sv.sendCh, int(sv.clientCfg.UDPPacketSize)) return } func (sv *SUDPVisitor) dispatcher() { xl := xlog.FromContextSafe(sv.ctx) var ( visitorConn net.Conn recycleFn func() err error firstPacket *msg.UDPPacket ) for { select { case firstPacket = <-sv.sendCh: if firstPacket == nil { xl.Infof("frpc sudp visitor proxy is closed") return } case <-sv.checkCloseCh: xl.Infof("frpc sudp visitor proxy is closed") return } visitorConn, recycleFn, err = sv.getNewVisitorConn() if err != nil { xl.Warnf("newVisitorConn to frps error: %v, try to reconnect", err) continue } // visitorConn always be closed when worker done. func() { defer recycleFn() sv.worker(visitorConn, firstPacket) }() select { case <-sv.checkCloseCh: return default: } } } func (sv *SUDPVisitor) worker(workConn net.Conn, firstPacket *msg.UDPPacket) { xl := xlog.FromContextSafe(sv.ctx) xl.Debugf("starting sudp proxy worker") wg := &sync.WaitGroup{} wg.Add(2) closeCh := make(chan struct{}) // udp service -> frpc -> frps -> frpc visitor -> user workConnReaderFn := func(conn net.Conn) { defer func() { conn.Close() close(closeCh) wg.Done() }() for { var ( rawMsg msg.Message errRet error ) // frpc will send heartbeat in workConn to frpc visitor for keeping alive _ = conn.SetReadDeadline(time.Now().Add(60 * time.Second)) if rawMsg, errRet = msg.ReadMsg(conn); errRet != nil { xl.Warnf("read from workconn for user udp conn error: %v", errRet) return } _ = conn.SetReadDeadline(time.Time{}) switch m := rawMsg.(type) { case *msg.Ping: xl.Debugf("frpc visitor get ping message from frpc") continue case *msg.UDPPacket: if errRet := errors.PanicToError(func() { sv.readCh <- m xl.Tracef("frpc visitor get udp packet from workConn, len: %d", len(m.Content)) }); errRet != nil { xl.Infof("reader goroutine for udp work connection closed") return } } } } // udp service <- frpc <- frps <- frpc visitor <- user workConnSenderFn := func(conn net.Conn) { defer func() { conn.Close() wg.Done() }() var errRet error if firstPacket != nil { if errRet = msg.WriteMsg(conn, firstPacket); errRet != nil { xl.Warnf("sender goroutine for udp work connection closed: %v", errRet) return } xl.Tracef("send udp package to workConn, len: %d", len(firstPacket.Content)) } for { select { case udpMsg, ok := <-sv.sendCh: if !ok { xl.Infof("sender goroutine for udp work connection closed") return } if errRet = msg.WriteMsg(conn, udpMsg); errRet != nil { xl.Warnf("sender goroutine for udp work connection closed: %v", errRet) return } xl.Tracef("send udp package to workConn, len: %d", len(udpMsg.Content)) case <-closeCh: return } } } go workConnReaderFn(workConn) go workConnSenderFn(workConn) wg.Wait() xl.Infof("sudp worker is closed") } func (sv *SUDPVisitor) getNewVisitorConn() (net.Conn, func(), error) { rawConn, err := sv.dialRawVisitorConn(sv.cfg.GetBaseConfig()) if err != nil { return nil, func() {}, err } rwc, recycleFn, err := wrapVisitorConn(rawConn, sv.cfg.GetBaseConfig()) if err != nil { rawConn.Close() return nil, func() {}, err } return netpkg.WrapReadWriteCloserToConn(rwc, rawConn), recycleFn, nil } func (sv *SUDPVisitor) Close() { sv.mu.Lock() defer sv.mu.Unlock() select { case <-sv.checkCloseCh: return default: close(sv.checkCloseCh) } sv.BaseVisitor.Close() if sv.udpConn != nil { sv.udpConn.Close() } close(sv.readCh) close(sv.sendCh) } ================================================ FILE: client/visitor/visitor.go ================================================ // Copyright 2017 fatedier, fatedier@gmail.com // // Licensed under the Apache License, Version 2.0 (the "License"); // you may not use this file except in compliance with the License. // You may obtain a copy of the License at // // http://www.apache.org/licenses/LICENSE-2.0 // // Unless required by applicable law or agreed to in writing, software // distributed under the License is distributed on an "AS IS" BASIS, // WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. // See the License for the specific language governing permissions and // limitations under the License. package visitor import ( "context" "fmt" "io" "net" "sync" "time" libio "github.com/fatedier/golib/io" v1 "github.com/fatedier/frp/pkg/config/v1" "github.com/fatedier/frp/pkg/msg" "github.com/fatedier/frp/pkg/naming" plugin "github.com/fatedier/frp/pkg/plugin/visitor" "github.com/fatedier/frp/pkg/transport" netpkg "github.com/fatedier/frp/pkg/util/net" "github.com/fatedier/frp/pkg/util/util" "github.com/fatedier/frp/pkg/util/xlog" "github.com/fatedier/frp/pkg/vnet" ) // Helper wraps some functions for visitor to use. type Helper interface { // ConnectServer directly connects to the frp server. ConnectServer() (net.Conn, error) // TransferConn transfers the connection to another visitor. TransferConn(string, net.Conn) error // MsgTransporter returns the message transporter that is used to send and receive messages // to the frp server through the controller. MsgTransporter() transport.MessageTransporter // VNetController returns the vnet controller that is used to manage the virtual network. VNetController() *vnet.Controller // RunID returns the run id of current controller. RunID() string } // Visitor is used for forward traffics from local port tot remote service. type Visitor interface { Run() error AcceptConn(conn net.Conn) error Close() } func NewVisitor( ctx context.Context, cfg v1.VisitorConfigurer, clientCfg *v1.ClientCommonConfig, helper Helper, ) (Visitor, error) { xl := xlog.FromContextSafe(ctx).Spawn().AppendPrefix(cfg.GetBaseConfig().Name) ctx = xlog.NewContext(ctx, xl) var visitor Visitor baseVisitor := BaseVisitor{ clientCfg: clientCfg, helper: helper, ctx: ctx, internalLn: netpkg.NewInternalListener(), } if cfg.GetBaseConfig().Plugin.Type != "" { p, err := plugin.Create( cfg.GetBaseConfig().Plugin.Type, plugin.PluginContext{ Name: cfg.GetBaseConfig().Name, Ctx: ctx, VnetController: helper.VNetController(), SendConnToVisitor: func(conn net.Conn) { _ = baseVisitor.AcceptConn(conn) }, }, cfg.GetBaseConfig().Plugin.VisitorPluginOptions, ) if err != nil { return nil, err } baseVisitor.plugin = p } switch cfg := cfg.(type) { case *v1.STCPVisitorConfig: visitor = &STCPVisitor{ BaseVisitor: &baseVisitor, cfg: cfg, } case *v1.XTCPVisitorConfig: visitor = &XTCPVisitor{ BaseVisitor: &baseVisitor, cfg: cfg, startTunnelCh: make(chan struct{}), } case *v1.SUDPVisitorConfig: visitor = &SUDPVisitor{ BaseVisitor: &baseVisitor, cfg: cfg, checkCloseCh: make(chan struct{}), } } return visitor, nil } type BaseVisitor struct { clientCfg *v1.ClientCommonConfig helper Helper l net.Listener internalLn *netpkg.InternalListener plugin plugin.Plugin mu sync.RWMutex ctx context.Context } func (v *BaseVisitor) AcceptConn(conn net.Conn) error { return v.internalLn.PutConn(conn) } func (v *BaseVisitor) acceptLoop(l net.Listener, name string, handleConn func(net.Conn)) { xl := xlog.FromContextSafe(v.ctx) for { conn, err := l.Accept() if err != nil { xl.Warnf("%s listener closed", name) return } go handleConn(conn) } } func (v *BaseVisitor) Close() { if v.l != nil { v.l.Close() } if v.internalLn != nil { v.internalLn.Close() } if v.plugin != nil { v.plugin.Close() } } func (v *BaseVisitor) dialRawVisitorConn(cfg *v1.VisitorBaseConfig) (net.Conn, error) { visitorConn, err := v.helper.ConnectServer() if err != nil { return nil, fmt.Errorf("connect to server error: %v", err) } now := time.Now().Unix() targetProxyName := naming.BuildTargetServerProxyName(v.clientCfg.User, cfg.ServerUser, cfg.ServerName) newVisitorConnMsg := &msg.NewVisitorConn{ RunID: v.helper.RunID(), ProxyName: targetProxyName, SignKey: util.GetAuthKey(cfg.SecretKey, now), Timestamp: now, UseEncryption: cfg.Transport.UseEncryption, UseCompression: cfg.Transport.UseCompression, } err = msg.WriteMsg(visitorConn, newVisitorConnMsg) if err != nil { visitorConn.Close() return nil, fmt.Errorf("send newVisitorConnMsg to server error: %v", err) } var newVisitorConnRespMsg msg.NewVisitorConnResp _ = visitorConn.SetReadDeadline(time.Now().Add(10 * time.Second)) err = msg.ReadMsgInto(visitorConn, &newVisitorConnRespMsg) if err != nil { visitorConn.Close() return nil, fmt.Errorf("read newVisitorConnRespMsg error: %v", err) } _ = visitorConn.SetReadDeadline(time.Time{}) if newVisitorConnRespMsg.Error != "" { visitorConn.Close() return nil, fmt.Errorf("start new visitor connection error: %s", newVisitorConnRespMsg.Error) } return visitorConn, nil } func wrapVisitorConn(conn io.ReadWriteCloser, cfg *v1.VisitorBaseConfig) (io.ReadWriteCloser, func(), error) { rwc := conn if cfg.Transport.UseEncryption { var err error rwc, err = libio.WithEncryption(rwc, []byte(cfg.SecretKey)) if err != nil { return nil, func() {}, fmt.Errorf("create encryption stream error: %v", err) } } recycleFn := func() {} if cfg.Transport.UseCompression { rwc, recycleFn = libio.WithCompressionFromPool(rwc) } return rwc, recycleFn, nil } ================================================ FILE: client/visitor/visitor_manager.go ================================================ // Copyright 2018 fatedier, fatedier@gmail.com // // Licensed under the Apache License, Version 2.0 (the "License"); // you may not use this file except in compliance with the License. // You may obtain a copy of the License at // // http://www.apache.org/licenses/LICENSE-2.0 // // Unless required by applicable law or agreed to in writing, software // distributed under the License is distributed on an "AS IS" BASIS, // WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. // See the License for the specific language governing permissions and // limitations under the License. package visitor import ( "context" "fmt" "net" "reflect" "sync" "time" "github.com/samber/lo" v1 "github.com/fatedier/frp/pkg/config/v1" "github.com/fatedier/frp/pkg/transport" "github.com/fatedier/frp/pkg/util/xlog" "github.com/fatedier/frp/pkg/vnet" ) type Manager struct { clientCfg *v1.ClientCommonConfig cfgs map[string]v1.VisitorConfigurer visitors map[string]Visitor helper Helper checkInterval time.Duration keepVisitorsRunningOnce sync.Once mu sync.RWMutex ctx context.Context stopCh chan struct{} } func NewManager( ctx context.Context, runID string, clientCfg *v1.ClientCommonConfig, connectServer func() (net.Conn, error), msgTransporter transport.MessageTransporter, vnetController *vnet.Controller, ) *Manager { m := &Manager{ clientCfg: clientCfg, cfgs: make(map[string]v1.VisitorConfigurer), visitors: make(map[string]Visitor), checkInterval: 10 * time.Second, ctx: ctx, stopCh: make(chan struct{}), } m.helper = &visitorHelperImpl{ connectServerFn: connectServer, msgTransporter: msgTransporter, vnetController: vnetController, transferConnFn: m.TransferConn, runID: runID, } return m } // keepVisitorsRunning checks all visitors' status periodically, if some visitor is not running, start it. // It will only start after Reload is called and a new visitor is added. func (vm *Manager) keepVisitorsRunning() { xl := xlog.FromContextSafe(vm.ctx) ticker := time.NewTicker(vm.checkInterval) defer ticker.Stop() for { select { case <-vm.stopCh: xl.Tracef("gracefully shutdown visitor manager") return case <-ticker.C: vm.mu.Lock() for _, cfg := range vm.cfgs { name := cfg.GetBaseConfig().Name if _, exist := vm.visitors[name]; !exist { xl.Infof("try to start visitor [%s]", name) _ = vm.startVisitor(cfg) } } vm.mu.Unlock() } } } func (vm *Manager) Close() { vm.mu.Lock() defer vm.mu.Unlock() for _, v := range vm.visitors { v.Close() } select { case <-vm.stopCh: default: close(vm.stopCh) } } // Hold lock before calling this function. func (vm *Manager) startVisitor(cfg v1.VisitorConfigurer) (err error) { xl := xlog.FromContextSafe(vm.ctx) name := cfg.GetBaseConfig().Name visitor, err := NewVisitor(vm.ctx, cfg, vm.clientCfg, vm.helper) if err != nil { xl.Warnf("new visitor error: %v", err) return } err = visitor.Run() if err != nil { xl.Warnf("start error: %v", err) } else { vm.visitors[name] = visitor xl.Infof("start visitor success") } return } func (vm *Manager) UpdateAll(cfgs []v1.VisitorConfigurer) { if len(cfgs) > 0 { // Only start keepVisitorsRunning goroutine once and only when there is at least one visitor. vm.keepVisitorsRunningOnce.Do(func() { go vm.keepVisitorsRunning() }) } xl := xlog.FromContextSafe(vm.ctx) cfgsMap := lo.KeyBy(cfgs, func(c v1.VisitorConfigurer) string { return c.GetBaseConfig().Name }) vm.mu.Lock() defer vm.mu.Unlock() delNames := make([]string, 0) for name, oldCfg := range vm.cfgs { del := false cfg, ok := cfgsMap[name] if !ok || !reflect.DeepEqual(oldCfg, cfg) { del = true } if del { delNames = append(delNames, name) delete(vm.cfgs, name) if visitor, ok := vm.visitors[name]; ok { visitor.Close() } delete(vm.visitors, name) } } if len(delNames) > 0 { xl.Infof("visitor removed: %v", delNames) } addNames := make([]string, 0) for _, cfg := range cfgs { name := cfg.GetBaseConfig().Name if _, ok := vm.cfgs[name]; !ok { vm.cfgs[name] = cfg addNames = append(addNames, name) _ = vm.startVisitor(cfg) } } if len(addNames) > 0 { xl.Infof("visitor added: %v", addNames) } } // TransferConn transfers a connection to a visitor. func (vm *Manager) TransferConn(name string, conn net.Conn) error { vm.mu.RLock() defer vm.mu.RUnlock() v, ok := vm.visitors[name] if !ok { return fmt.Errorf("visitor [%s] not found", name) } return v.AcceptConn(conn) } func (vm *Manager) GetVisitorCfg(name string) (v1.VisitorConfigurer, bool) { vm.mu.RLock() defer vm.mu.RUnlock() cfg, ok := vm.cfgs[name] return cfg, ok } type visitorHelperImpl struct { connectServerFn func() (net.Conn, error) msgTransporter transport.MessageTransporter vnetController *vnet.Controller transferConnFn func(name string, conn net.Conn) error runID string } func (v *visitorHelperImpl) ConnectServer() (net.Conn, error) { return v.connectServerFn() } func (v *visitorHelperImpl) TransferConn(name string, conn net.Conn) error { return v.transferConnFn(name, conn) } func (v *visitorHelperImpl) MsgTransporter() transport.MessageTransporter { return v.msgTransporter } func (v *visitorHelperImpl) VNetController() *vnet.Controller { return v.vnetController } func (v *visitorHelperImpl) RunID() string { return v.runID } ================================================ FILE: client/visitor/xtcp.go ================================================ // Copyright 2017 fatedier, fatedier@gmail.com // // Licensed under the Apache License, Version 2.0 (the "License"); // you may not use this file except in compliance with the License. // You may obtain a copy of the License at // // http://www.apache.org/licenses/LICENSE-2.0 // // Unless required by applicable law or agreed to in writing, software // distributed under the License is distributed on an "AS IS" BASIS, // WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. // See the License for the specific language governing permissions and // limitations under the License. package visitor import ( "context" "errors" "fmt" "io" "net" "strconv" "sync" "time" libio "github.com/fatedier/golib/io" fmux "github.com/hashicorp/yamux" quic "github.com/quic-go/quic-go" "golang.org/x/time/rate" v1 "github.com/fatedier/frp/pkg/config/v1" "github.com/fatedier/frp/pkg/msg" "github.com/fatedier/frp/pkg/naming" "github.com/fatedier/frp/pkg/nathole" "github.com/fatedier/frp/pkg/transport" netpkg "github.com/fatedier/frp/pkg/util/net" "github.com/fatedier/frp/pkg/util/util" "github.com/fatedier/frp/pkg/util/xlog" ) var ErrNoTunnelSession = errors.New("no tunnel session") type XTCPVisitor struct { *BaseVisitor session TunnelSession startTunnelCh chan struct{} retryLimiter *rate.Limiter cancel context.CancelFunc cfg *v1.XTCPVisitorConfig } func (sv *XTCPVisitor) Run() (err error) { sv.ctx, sv.cancel = context.WithCancel(sv.ctx) if sv.cfg.Protocol == "kcp" { sv.session = NewKCPTunnelSession() } else { sv.session = NewQUICTunnelSession(sv.clientCfg) } if sv.cfg.BindPort > 0 { sv.l, err = net.Listen("tcp", net.JoinHostPort(sv.cfg.BindAddr, strconv.Itoa(sv.cfg.BindPort))) if err != nil { return } go sv.acceptLoop(sv.l, "xtcp local", sv.handleConn) } go sv.acceptLoop(sv.internalLn, "xtcp internal", sv.handleConn) go sv.processTunnelStartEvents() if sv.cfg.KeepTunnelOpen { sv.retryLimiter = rate.NewLimiter(rate.Every(time.Hour/time.Duration(sv.cfg.MaxRetriesAnHour)), sv.cfg.MaxRetriesAnHour) go sv.keepTunnelOpenWorker() } if sv.plugin != nil { sv.plugin.Start() } return } func (sv *XTCPVisitor) Close() { sv.mu.Lock() defer sv.mu.Unlock() sv.BaseVisitor.Close() if sv.cancel != nil { sv.cancel() } if sv.session != nil { sv.session.Close() } } func (sv *XTCPVisitor) processTunnelStartEvents() { for { select { case <-sv.ctx.Done(): return case <-sv.startTunnelCh: start := time.Now() sv.makeNatHole() duration := time.Since(start) // avoid too frequently if duration < 10*time.Second { time.Sleep(10*time.Second - duration) } } } } func (sv *XTCPVisitor) keepTunnelOpenWorker() { xl := xlog.FromContextSafe(sv.ctx) ticker := time.NewTicker(time.Duration(sv.cfg.MinRetryInterval) * time.Second) defer ticker.Stop() sv.startTunnelCh <- struct{}{} for { select { case <-sv.ctx.Done(): return case <-ticker.C: xl.Debugf("keepTunnelOpenWorker try to check tunnel...") conn, err := sv.getTunnelConn(sv.ctx) if err != nil { xl.Warnf("keepTunnelOpenWorker get tunnel connection error: %v", err) _ = sv.retryLimiter.Wait(sv.ctx) continue } xl.Debugf("keepTunnelOpenWorker check success") if conn != nil { conn.Close() } } } } func (sv *XTCPVisitor) handleConn(userConn net.Conn) { xl := xlog.FromContextSafe(sv.ctx) isConnTransferred := false var tunnelErr error defer func() { if !isConnTransferred { // If there was an error and connection supports CloseWithError, use it if tunnelErr != nil { if eConn, ok := userConn.(interface{ CloseWithError(error) error }); ok { _ = eConn.CloseWithError(tunnelErr) return } } userConn.Close() } }() xl.Debugf("get a new xtcp user connection") // Open a tunnel connection to the server. If there is already a successful hole-punching connection, // it will be reused. Otherwise, it will block and wait for a successful hole-punching connection until timeout. ctx := sv.ctx if sv.cfg.FallbackTo != "" { timeoutCtx, cancel := context.WithTimeout(ctx, time.Duration(sv.cfg.FallbackTimeoutMs)*time.Millisecond) defer cancel() ctx = timeoutCtx } tunnelConn, err := sv.openTunnel(ctx) if err != nil { xl.Errorf("open tunnel error: %v", err) tunnelErr = err // no fallback, just return if sv.cfg.FallbackTo == "" { return } xl.Debugf("try to transfer connection to visitor: %s", sv.cfg.FallbackTo) if err := sv.helper.TransferConn(sv.cfg.FallbackTo, userConn); err != nil { xl.Errorf("transfer connection to visitor %s error: %v", sv.cfg.FallbackTo, err) return } isConnTransferred = true return } muxConnRWCloser, recycleFn, err := wrapVisitorConn(tunnelConn, sv.cfg.GetBaseConfig()) if err != nil { xl.Errorf("%v", err) tunnelConn.Close() tunnelErr = err return } defer recycleFn() _, _, errs := libio.Join(userConn, muxConnRWCloser) xl.Debugf("join connections closed") if len(errs) > 0 { xl.Tracef("join connections errors: %v", errs) } } // openTunnel will open a tunnel connection to the target server. func (sv *XTCPVisitor) openTunnel(ctx context.Context) (conn net.Conn, err error) { xl := xlog.FromContextSafe(sv.ctx) ctx, cancel := context.WithTimeout(ctx, 20*time.Second) defer cancel() timer := time.NewTimer(0) defer timer.Stop() for { select { case <-sv.ctx.Done(): return nil, sv.ctx.Err() case <-ctx.Done(): if errors.Is(ctx.Err(), context.DeadlineExceeded) { return nil, fmt.Errorf("open tunnel timeout") } return nil, ctx.Err() case <-timer.C: conn, err = sv.getTunnelConn(ctx) if err != nil { if !errors.Is(err, ErrNoTunnelSession) { xl.Warnf("get tunnel connection error: %v", err) } timer.Reset(500 * time.Millisecond) continue } return conn, nil } } } func (sv *XTCPVisitor) getTunnelConn(ctx context.Context) (net.Conn, error) { conn, err := sv.session.OpenConn(ctx) if err == nil { return conn, nil } sv.session.Close() select { case sv.startTunnelCh <- struct{}{}: default: } return nil, err } // 0. PreCheck // 1. Prepare // 2. ExchangeInfo // 3. MakeNATHole // 4. Create a tunnel session using an underlying UDP connection. func (sv *XTCPVisitor) makeNatHole() { xl := xlog.FromContextSafe(sv.ctx) targetProxyName := naming.BuildTargetServerProxyName(sv.clientCfg.User, sv.cfg.ServerUser, sv.cfg.ServerName) xl.Tracef("makeNatHole start") if err := nathole.PreCheck(sv.ctx, sv.helper.MsgTransporter(), targetProxyName, 5*time.Second); err != nil { xl.Warnf("nathole precheck error: %v", err) return } xl.Tracef("nathole prepare start") // Prepare NAT traversal options var opts nathole.PrepareOptions if sv.cfg.NatTraversal != nil && sv.cfg.NatTraversal.DisableAssistedAddrs { opts.DisableAssistedAddrs = true } prepareResult, err := nathole.Prepare([]string{sv.clientCfg.NatHoleSTUNServer}, opts) if err != nil { xl.Warnf("nathole prepare error: %v", err) return } xl.Infof("nathole prepare success, nat type: %s, behavior: %s, addresses: %v, assistedAddresses: %v", prepareResult.NatType, prepareResult.Behavior, prepareResult.Addrs, prepareResult.AssistedAddrs) listenConn := prepareResult.ListenConn // send NatHoleVisitor to server now := time.Now().Unix() transactionID := nathole.NewTransactionID() natHoleVisitorMsg := &msg.NatHoleVisitor{ TransactionID: transactionID, ProxyName: targetProxyName, Protocol: sv.cfg.Protocol, SignKey: util.GetAuthKey(sv.cfg.SecretKey, now), Timestamp: now, MappedAddrs: prepareResult.Addrs, AssistedAddrs: prepareResult.AssistedAddrs, } xl.Tracef("nathole exchange info start") natHoleRespMsg, err := nathole.ExchangeInfo(sv.ctx, sv.helper.MsgTransporter(), transactionID, natHoleVisitorMsg, 5*time.Second) if err != nil { listenConn.Close() xl.Warnf("nathole exchange info error: %v", err) return } xl.Infof("get natHoleRespMsg, sid [%s], protocol [%s], candidate address %v, assisted address %v, detectBehavior: %+v", natHoleRespMsg.Sid, natHoleRespMsg.Protocol, natHoleRespMsg.CandidateAddrs, natHoleRespMsg.AssistedAddrs, natHoleRespMsg.DetectBehavior) newListenConn, raddr, err := nathole.MakeHole(sv.ctx, listenConn, natHoleRespMsg, []byte(sv.cfg.SecretKey)) if err != nil { listenConn.Close() xl.Warnf("make hole error: %v", err) return } listenConn = newListenConn xl.Infof("establishing nat hole connection successful, sid [%s], remoteAddr [%s]", natHoleRespMsg.Sid, raddr) if err := sv.session.Init(listenConn, raddr); err != nil { listenConn.Close() xl.Warnf("init tunnel session error: %v", err) return } } type TunnelSession interface { Init(listenConn *net.UDPConn, raddr *net.UDPAddr) error OpenConn(context.Context) (net.Conn, error) Close() } type KCPTunnelSession struct { session *fmux.Session lConn *net.UDPConn mu sync.RWMutex } func NewKCPTunnelSession() TunnelSession { return &KCPTunnelSession{} } func (ks *KCPTunnelSession) Init(listenConn *net.UDPConn, raddr *net.UDPAddr) error { listenConn.Close() laddr, _ := net.ResolveUDPAddr("udp", listenConn.LocalAddr().String()) lConn, err := net.DialUDP("udp", laddr, raddr) if err != nil { return fmt.Errorf("dial udp error: %v", err) } remote, err := netpkg.NewKCPConnFromUDP(lConn, true, raddr.String()) if err != nil { lConn.Close() return fmt.Errorf("create kcp connection from udp connection error: %v", err) } fmuxCfg := fmux.DefaultConfig() fmuxCfg.KeepAliveInterval = 10 * time.Second fmuxCfg.MaxStreamWindowSize = 6 * 1024 * 1024 fmuxCfg.LogOutput = io.Discard session, err := fmux.Client(remote, fmuxCfg) if err != nil { remote.Close() return fmt.Errorf("initial client session error: %v", err) } ks.mu.Lock() ks.session = session ks.lConn = lConn ks.mu.Unlock() return nil } func (ks *KCPTunnelSession) OpenConn(_ context.Context) (net.Conn, error) { ks.mu.RLock() defer ks.mu.RUnlock() session := ks.session if session == nil { return nil, ErrNoTunnelSession } return session.Open() } func (ks *KCPTunnelSession) Close() { ks.mu.Lock() defer ks.mu.Unlock() if ks.session != nil { _ = ks.session.Close() ks.session = nil } if ks.lConn != nil { _ = ks.lConn.Close() ks.lConn = nil } } type QUICTunnelSession struct { session *quic.Conn listenConn *net.UDPConn mu sync.RWMutex clientCfg *v1.ClientCommonConfig } func NewQUICTunnelSession(clientCfg *v1.ClientCommonConfig) TunnelSession { return &QUICTunnelSession{ clientCfg: clientCfg, } } func (qs *QUICTunnelSession) Init(listenConn *net.UDPConn, raddr *net.UDPAddr) error { tlsConfig, err := transport.NewClientTLSConfig("", "", "", raddr.String()) if err != nil { return fmt.Errorf("create tls config error: %v", err) } tlsConfig.NextProtos = []string{"frp"} quicConn, err := quic.Dial(context.Background(), listenConn, raddr, tlsConfig, &quic.Config{ MaxIdleTimeout: time.Duration(qs.clientCfg.Transport.QUIC.MaxIdleTimeout) * time.Second, MaxIncomingStreams: int64(qs.clientCfg.Transport.QUIC.MaxIncomingStreams), KeepAlivePeriod: time.Duration(qs.clientCfg.Transport.QUIC.KeepalivePeriod) * time.Second, }) if err != nil { return fmt.Errorf("dial quic error: %v", err) } qs.mu.Lock() qs.session = quicConn qs.listenConn = listenConn qs.mu.Unlock() return nil } func (qs *QUICTunnelSession) OpenConn(ctx context.Context) (net.Conn, error) { qs.mu.RLock() defer qs.mu.RUnlock() session := qs.session if session == nil { return nil, ErrNoTunnelSession } stream, err := session.OpenStreamSync(ctx) if err != nil { return nil, err } return netpkg.QuicStreamToNetConn(stream, session), nil } func (qs *QUICTunnelSession) Close() { qs.mu.Lock() defer qs.mu.Unlock() if qs.session != nil { _ = qs.session.CloseWithError(0, "") qs.session = nil } if qs.listenConn != nil { _ = qs.listenConn.Close() qs.listenConn = nil } } ================================================ FILE: cmd/frpc/main.go ================================================ // Copyright 2016 fatedier, fatedier@gmail.com // // Licensed under the Apache License, Version 2.0 (the "License"); // you may not use this file except in compliance with the License. // You may obtain a copy of the License at // // http://www.apache.org/licenses/LICENSE-2.0 // // Unless required by applicable law or agreed to in writing, software // distributed under the License is distributed on an "AS IS" BASIS, // WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. // See the License for the specific language governing permissions and // limitations under the License. package main import ( "github.com/fatedier/frp/cmd/frpc/sub" "github.com/fatedier/frp/pkg/util/system" _ "github.com/fatedier/frp/web/frpc" ) func main() { system.EnableCompatibilityMode() sub.Execute() } ================================================ FILE: cmd/frpc/sub/admin.go ================================================ // Copyright 2023 The frp Authors // // Licensed under the Apache License, Version 2.0 (the "License"); // you may not use this file except in compliance with the License. // You may obtain a copy of the License at // // http://www.apache.org/licenses/LICENSE-2.0 // // Unless required by applicable law or agreed to in writing, software // distributed under the License is distributed on an "AS IS" BASIS, // WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. // See the License for the specific language governing permissions and // limitations under the License. package sub import ( "context" "fmt" "os" "strings" "time" "github.com/rodaine/table" "github.com/spf13/cobra" "github.com/fatedier/frp/pkg/config" v1 "github.com/fatedier/frp/pkg/config/v1" clientsdk "github.com/fatedier/frp/pkg/sdk/client" ) var adminAPITimeout = 30 * time.Second func init() { commands := []struct { name string description string handler func(*v1.ClientCommonConfig) error }{ {"reload", "Hot-Reload frpc configuration", ReloadHandler}, {"status", "Overview of all proxies status", StatusHandler}, {"stop", "Stop the running frpc", StopHandler}, } for _, cmdConfig := range commands { cmd := NewAdminCommand(cmdConfig.name, cmdConfig.description, cmdConfig.handler) cmd.Flags().DurationVar(&adminAPITimeout, "api-timeout", adminAPITimeout, "Timeout for admin API calls") rootCmd.AddCommand(cmd) } } func NewAdminCommand(name, short string, handler func(*v1.ClientCommonConfig) error) *cobra.Command { return &cobra.Command{ Use: name, Short: short, Run: func(cmd *cobra.Command, args []string) { cfg, _, _, _, err := config.LoadClientConfig(cfgFile, strictConfigMode) if err != nil { fmt.Println(err) os.Exit(1) } if cfg.WebServer.Port <= 0 { fmt.Println("web server port should be set if you want to use this feature") os.Exit(1) } if err := handler(cfg); err != nil { fmt.Println(err) os.Exit(1) } }, } } func ReloadHandler(clientCfg *v1.ClientCommonConfig) error { client := clientsdk.New(clientCfg.WebServer.Addr, clientCfg.WebServer.Port) client.SetAuth(clientCfg.WebServer.User, clientCfg.WebServer.Password) ctx, cancel := context.WithTimeout(context.Background(), adminAPITimeout) defer cancel() if err := client.Reload(ctx, strictConfigMode); err != nil { return err } fmt.Println("reload success") return nil } func StatusHandler(clientCfg *v1.ClientCommonConfig) error { client := clientsdk.New(clientCfg.WebServer.Addr, clientCfg.WebServer.Port) client.SetAuth(clientCfg.WebServer.User, clientCfg.WebServer.Password) ctx, cancel := context.WithTimeout(context.Background(), adminAPITimeout) defer cancel() res, err := client.GetAllProxyStatus(ctx) if err != nil { return err } fmt.Printf("Proxy Status...\n\n") for _, typ := range proxyTypes { arrs := res[string(typ)] if len(arrs) == 0 { continue } fmt.Println(strings.ToUpper(string(typ))) tbl := table.New("Name", "Status", "LocalAddr", "Plugin", "RemoteAddr", "Error") for _, ps := range arrs { tbl.AddRow(ps.Name, ps.Status, ps.LocalAddr, ps.Plugin, ps.RemoteAddr, ps.Err) } tbl.Print() fmt.Println("") } return nil } func StopHandler(clientCfg *v1.ClientCommonConfig) error { client := clientsdk.New(clientCfg.WebServer.Addr, clientCfg.WebServer.Port) client.SetAuth(clientCfg.WebServer.User, clientCfg.WebServer.Password) ctx, cancel := context.WithTimeout(context.Background(), adminAPITimeout) defer cancel() if err := client.Stop(ctx); err != nil { return err } fmt.Println("stop success") return nil } ================================================ FILE: cmd/frpc/sub/nathole.go ================================================ // Copyright 2023 The frp Authors // // Licensed under the Apache License, Version 2.0 (the "License"); // you may not use this file except in compliance with the License. // You may obtain a copy of the License at // // http://www.apache.org/licenses/LICENSE-2.0 // // Unless required by applicable law or agreed to in writing, software // distributed under the License is distributed on an "AS IS" BASIS, // WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. // See the License for the specific language governing permissions and // limitations under the License. package sub import ( "fmt" "os" "github.com/spf13/cobra" "github.com/fatedier/frp/pkg/config" v1 "github.com/fatedier/frp/pkg/config/v1" "github.com/fatedier/frp/pkg/nathole" ) var ( natHoleSTUNServer string natHoleLocalAddr string ) func init() { rootCmd.AddCommand(natholeCmd) natholeCmd.AddCommand(natholeDiscoveryCmd) natholeCmd.PersistentFlags().StringVarP(&natHoleSTUNServer, "nat_hole_stun_server", "", "", "STUN server address for nathole") natholeCmd.PersistentFlags().StringVarP(&natHoleLocalAddr, "nat_hole_local_addr", "l", "", "local address to connect STUN server") } var natholeCmd = &cobra.Command{ Use: "nathole", Short: "Actions about nathole", } var natholeDiscoveryCmd = &cobra.Command{ Use: "discover", Short: "Discover nathole information from stun server", RunE: func(cmd *cobra.Command, args []string) error { // ignore error here, because we can use command line parameters cfg, _, _, _, err := config.LoadClientConfig(cfgFile, strictConfigMode) if err != nil { cfg = &v1.ClientCommonConfig{} if err := cfg.Complete(); err != nil { fmt.Printf("failed to complete config: %v\n", err) os.Exit(1) } } if natHoleSTUNServer != "" { cfg.NatHoleSTUNServer = natHoleSTUNServer } if err := validateForNatHoleDiscovery(cfg); err != nil { fmt.Println(err) os.Exit(1) } addrs, localAddr, err := nathole.Discover([]string{cfg.NatHoleSTUNServer}, natHoleLocalAddr) if err != nil { fmt.Println("discover error:", err) os.Exit(1) } if len(addrs) < 2 { fmt.Printf("discover error: can not get enough addresses, need 2, got: %v\n", addrs) os.Exit(1) } localIPs, _ := nathole.ListLocalIPsForNatHole(10) natFeature, err := nathole.ClassifyNATFeature(addrs, localIPs) if err != nil { fmt.Println("classify nat feature error:", err) os.Exit(1) } fmt.Println("STUN server:", cfg.NatHoleSTUNServer) fmt.Println("Your NAT type is:", natFeature.NatType) fmt.Println("Behavior is:", natFeature.Behavior) fmt.Println("External address is:", addrs) fmt.Println("Local address is:", localAddr.String()) fmt.Println("Public Network:", natFeature.PublicNetwork) return nil }, } func validateForNatHoleDiscovery(cfg *v1.ClientCommonConfig) error { if cfg.NatHoleSTUNServer == "" { return fmt.Errorf("nat_hole_stun_server can not be empty") } return nil } ================================================ FILE: cmd/frpc/sub/proxy.go ================================================ // Copyright 2023 The frp Authors // // Licensed under the Apache License, Version 2.0 (the "License"); // you may not use this file except in compliance with the License. // You may obtain a copy of the License at // // http://www.apache.org/licenses/LICENSE-2.0 // // Unless required by applicable law or agreed to in writing, software // distributed under the License is distributed on an "AS IS" BASIS, // WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. // See the License for the specific language governing permissions and // limitations under the License. package sub import ( "fmt" "os" "slices" "github.com/spf13/cobra" "github.com/fatedier/frp/pkg/config" "github.com/fatedier/frp/pkg/config/source" v1 "github.com/fatedier/frp/pkg/config/v1" "github.com/fatedier/frp/pkg/config/v1/validation" "github.com/fatedier/frp/pkg/policy/security" ) var proxyTypes = []v1.ProxyType{ v1.ProxyTypeTCP, v1.ProxyTypeUDP, v1.ProxyTypeTCPMUX, v1.ProxyTypeHTTP, v1.ProxyTypeHTTPS, v1.ProxyTypeSTCP, v1.ProxyTypeSUDP, v1.ProxyTypeXTCP, } var visitorTypes = []v1.VisitorType{ v1.VisitorTypeSTCP, v1.VisitorTypeSUDP, v1.VisitorTypeXTCP, } func init() { for _, typ := range proxyTypes { c := v1.NewProxyConfigurerByType(typ) if c == nil { panic("proxy type: " + typ + " not support") } clientCfg := v1.ClientCommonConfig{} cmd := NewProxyCommand(string(typ), c, &clientCfg) config.RegisterClientCommonConfigFlags(cmd, &clientCfg) config.RegisterProxyFlags(cmd, c) // add sub command for visitor if slices.Contains(visitorTypes, v1.VisitorType(typ)) { vc := v1.NewVisitorConfigurerByType(v1.VisitorType(typ)) if vc == nil { panic("visitor type: " + typ + " not support") } visitorCmd := NewVisitorCommand(string(typ), vc, &clientCfg) config.RegisterVisitorFlags(visitorCmd, vc) cmd.AddCommand(visitorCmd) } rootCmd.AddCommand(cmd) } } func NewProxyCommand(name string, c v1.ProxyConfigurer, clientCfg *v1.ClientCommonConfig) *cobra.Command { return &cobra.Command{ Use: name, Short: fmt.Sprintf("Run frpc with a single %s proxy", name), Run: func(cmd *cobra.Command, args []string) { if err := clientCfg.Complete(); err != nil { fmt.Println(err) os.Exit(1) } unsafeFeatures := security.NewUnsafeFeatures(allowUnsafe) validator := validation.NewConfigValidator(unsafeFeatures) if _, err := validator.ValidateClientCommonConfig(clientCfg); err != nil { fmt.Println(err) os.Exit(1) } c.GetBaseConfig().Type = name c.Complete() proxyCfg := c if err := validation.ValidateProxyConfigurerForClient(proxyCfg); err != nil { fmt.Println(err) os.Exit(1) } err := startService(clientCfg, []v1.ProxyConfigurer{proxyCfg}, nil, unsafeFeatures, "") if err != nil { fmt.Println(err) os.Exit(1) } }, } } func NewVisitorCommand(name string, c v1.VisitorConfigurer, clientCfg *v1.ClientCommonConfig) *cobra.Command { return &cobra.Command{ Use: "visitor", Short: fmt.Sprintf("Run frpc with a single %s visitor", name), Run: func(cmd *cobra.Command, args []string) { if err := clientCfg.Complete(); err != nil { fmt.Println(err) os.Exit(1) } unsafeFeatures := security.NewUnsafeFeatures(allowUnsafe) validator := validation.NewConfigValidator(unsafeFeatures) if _, err := validator.ValidateClientCommonConfig(clientCfg); err != nil { fmt.Println(err) os.Exit(1) } c.GetBaseConfig().Type = name c.Complete() visitorCfg := c if err := validation.ValidateVisitorConfigurer(visitorCfg); err != nil { fmt.Println(err) os.Exit(1) } err := startService(clientCfg, nil, []v1.VisitorConfigurer{visitorCfg}, unsafeFeatures, "") if err != nil { fmt.Println(err) os.Exit(1) } }, } } func startService( cfg *v1.ClientCommonConfig, proxyCfgs []v1.ProxyConfigurer, visitorCfgs []v1.VisitorConfigurer, unsafeFeatures *security.UnsafeFeatures, cfgFile string, ) error { configSource := source.NewConfigSource() if err := configSource.ReplaceAll(proxyCfgs, visitorCfgs); err != nil { return fmt.Errorf("failed to set config source: %w", err) } aggregator := source.NewAggregator(configSource) return startServiceWithAggregator(cfg, aggregator, unsafeFeatures, cfgFile) } ================================================ FILE: cmd/frpc/sub/root.go ================================================ // Copyright 2018 fatedier, fatedier@gmail.com // // Licensed under the Apache License, Version 2.0 (the "License"); // you may not use this file except in compliance with the License. // You may obtain a copy of the License at // // http://www.apache.org/licenses/LICENSE-2.0 // // Unless required by applicable law or agreed to in writing, software // distributed under the License is distributed on an "AS IS" BASIS, // WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. // See the License for the specific language governing permissions and // limitations under the License. package sub import ( "context" "fmt" "io/fs" "os" "os/signal" "path/filepath" "strings" "sync" "syscall" "time" "github.com/spf13/cobra" "github.com/fatedier/frp/client" "github.com/fatedier/frp/pkg/config" "github.com/fatedier/frp/pkg/config/source" v1 "github.com/fatedier/frp/pkg/config/v1" "github.com/fatedier/frp/pkg/config/v1/validation" "github.com/fatedier/frp/pkg/policy/featuregate" "github.com/fatedier/frp/pkg/policy/security" "github.com/fatedier/frp/pkg/util/log" "github.com/fatedier/frp/pkg/util/version" ) var ( cfgFile string cfgDir string showVersion bool strictConfigMode bool allowUnsafe []string ) func init() { rootCmd.PersistentFlags().StringVarP(&cfgFile, "config", "c", "./frpc.ini", "config file of frpc") rootCmd.PersistentFlags().StringVarP(&cfgDir, "config_dir", "", "", "config directory, run one frpc service for each file in config directory") rootCmd.PersistentFlags().BoolVarP(&showVersion, "version", "v", false, "version of frpc") rootCmd.PersistentFlags().BoolVarP(&strictConfigMode, "strict_config", "", true, "strict config parsing mode, unknown fields will cause an errors") rootCmd.PersistentFlags().StringSliceVarP(&allowUnsafe, "allow-unsafe", "", []string{}, fmt.Sprintf("allowed unsafe features, one or more of: %s", strings.Join(security.ClientUnsafeFeatures, ", "))) } var rootCmd = &cobra.Command{ Use: "frpc", Short: "frpc is the client of frp (https://github.com/fatedier/frp)", RunE: func(cmd *cobra.Command, args []string) error { if showVersion { fmt.Println(version.Full()) return nil } unsafeFeatures := security.NewUnsafeFeatures(allowUnsafe) // If cfgDir is not empty, run multiple frpc service for each config file in cfgDir. // Note that it's only designed for testing. It's not guaranteed to be stable. if cfgDir != "" { _ = runMultipleClients(cfgDir, unsafeFeatures) return nil } // Do not show command usage here. err := runClient(cfgFile, unsafeFeatures) if err != nil { fmt.Println(err) os.Exit(1) } return nil }, } func runMultipleClients(cfgDir string, unsafeFeatures *security.UnsafeFeatures) error { var wg sync.WaitGroup err := filepath.WalkDir(cfgDir, func(path string, d fs.DirEntry, err error) error { if err != nil || d.IsDir() { return nil } wg.Add(1) time.Sleep(time.Millisecond) go func() { defer wg.Done() err := runClient(path, unsafeFeatures) if err != nil { fmt.Printf("frpc service error for config file [%s]\n", path) } }() return nil }) wg.Wait() return err } func Execute() { rootCmd.SetGlobalNormalizationFunc(config.WordSepNormalizeFunc) if err := rootCmd.Execute(); err != nil { os.Exit(1) } } func handleTermSignal(svr *client.Service) { ch := make(chan os.Signal, 1) signal.Notify(ch, syscall.SIGINT, syscall.SIGTERM) <-ch svr.GracefulClose(500 * time.Millisecond) } func runClient(cfgFilePath string, unsafeFeatures *security.UnsafeFeatures) error { // Load configuration result, err := config.LoadClientConfigResult(cfgFilePath, strictConfigMode) if err != nil { return err } if result.IsLegacyFormat { fmt.Printf("WARNING: ini format is deprecated and the support will be removed in the future, " + "please use yaml/json/toml format instead!\n") } if len(result.Common.FeatureGates) > 0 { if err := featuregate.SetFromMap(result.Common.FeatureGates); err != nil { return err } } return runClientWithAggregator(result, unsafeFeatures, cfgFilePath) } // runClientWithAggregator runs the client using the internal source aggregator. func runClientWithAggregator(result *config.ClientConfigLoadResult, unsafeFeatures *security.UnsafeFeatures, cfgFilePath string) error { configSource := source.NewConfigSource() if err := configSource.ReplaceAll(result.Proxies, result.Visitors); err != nil { return fmt.Errorf("failed to set config source: %w", err) } var storeSource *source.StoreSource if result.Common.Store.IsEnabled() { storePath := result.Common.Store.Path if storePath != "" && cfgFilePath != "" && !filepath.IsAbs(storePath) { storePath = filepath.Join(filepath.Dir(cfgFilePath), storePath) } s, err := source.NewStoreSource(source.StoreSourceConfig{ Path: storePath, }) if err != nil { return fmt.Errorf("failed to create store source: %w", err) } storeSource = s } aggregator := source.NewAggregator(configSource) if storeSource != nil { aggregator.SetStoreSource(storeSource) } proxyCfgs, visitorCfgs, err := aggregator.Load() if err != nil { return fmt.Errorf("failed to load config from sources: %w", err) } proxyCfgs, visitorCfgs = config.FilterClientConfigurers(result.Common, proxyCfgs, visitorCfgs) proxyCfgs = config.CompleteProxyConfigurers(proxyCfgs) visitorCfgs = config.CompleteVisitorConfigurers(visitorCfgs) warning, err := validation.ValidateAllClientConfig(result.Common, proxyCfgs, visitorCfgs, unsafeFeatures) if warning != nil { fmt.Printf("WARNING: %v\n", warning) } if err != nil { return err } return startServiceWithAggregator(result.Common, aggregator, unsafeFeatures, cfgFilePath) } func startServiceWithAggregator( cfg *v1.ClientCommonConfig, aggregator *source.Aggregator, unsafeFeatures *security.UnsafeFeatures, cfgFile string, ) error { log.InitLogger(cfg.Log.To, cfg.Log.Level, int(cfg.Log.MaxDays), cfg.Log.DisablePrintColor) if cfgFile != "" { log.Infof("start frpc service for config file [%s] with aggregated configuration", cfgFile) defer log.Infof("frpc service for config file [%s] stopped", cfgFile) } svr, err := client.NewService(client.ServiceOptions{ Common: cfg, ConfigSourceAggregator: aggregator, UnsafeFeatures: unsafeFeatures, ConfigFilePath: cfgFile, }) if err != nil { return err } shouldGracefulClose := cfg.Transport.Protocol == "kcp" || cfg.Transport.Protocol == "quic" if shouldGracefulClose { go handleTermSignal(svr) } return svr.Run(context.Background()) } ================================================ FILE: cmd/frpc/sub/verify.go ================================================ // Copyright 2021 The frp Authors // // Licensed under the Apache License, Version 2.0 (the "License"); // you may not use this file except in compliance with the License. // You may obtain a copy of the License at // // http://www.apache.org/licenses/LICENSE-2.0 // // Unless required by applicable law or agreed to in writing, software // distributed under the License is distributed on an "AS IS" BASIS, // WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. // See the License for the specific language governing permissions and // limitations under the License. package sub import ( "fmt" "os" "github.com/spf13/cobra" "github.com/fatedier/frp/pkg/config" "github.com/fatedier/frp/pkg/config/v1/validation" "github.com/fatedier/frp/pkg/policy/security" ) func init() { rootCmd.AddCommand(verifyCmd) } var verifyCmd = &cobra.Command{ Use: "verify", Short: "Verify that the configures is valid", RunE: func(cmd *cobra.Command, args []string) error { if cfgFile == "" { fmt.Println("frpc: the configuration file is not specified") return nil } cliCfg, proxyCfgs, visitorCfgs, _, err := config.LoadClientConfig(cfgFile, strictConfigMode) if err != nil { fmt.Println(err) os.Exit(1) } unsafeFeatures := security.NewUnsafeFeatures(allowUnsafe) warning, err := validation.ValidateAllClientConfig(cliCfg, proxyCfgs, visitorCfgs, unsafeFeatures) if warning != nil { fmt.Printf("WARNING: %v\n", warning) } if err != nil { fmt.Println(err) os.Exit(1) } fmt.Printf("frpc: the configuration file %s syntax is ok\n", cfgFile) return nil }, } ================================================ FILE: cmd/frps/main.go ================================================ // Copyright 2018 fatedier, fatedier@gmail.com // // Licensed under the Apache License, Version 2.0 (the "License"); // you may not use this file except in compliance with the License. // You may obtain a copy of the License at // // http://www.apache.org/licenses/LICENSE-2.0 // // Unless required by applicable law or agreed to in writing, software // distributed under the License is distributed on an "AS IS" BASIS, // WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. // See the License for the specific language governing permissions and // limitations under the License. package main import ( _ "github.com/fatedier/frp/pkg/metrics" "github.com/fatedier/frp/pkg/util/system" _ "github.com/fatedier/frp/web/frps" ) func main() { system.EnableCompatibilityMode() Execute() } ================================================ FILE: cmd/frps/root.go ================================================ // Copyright 2018 fatedier, fatedier@gmail.com // // Licensed under the Apache License, Version 2.0 (the "License"); // you may not use this file except in compliance with the License. // You may obtain a copy of the License at // // http://www.apache.org/licenses/LICENSE-2.0 // // Unless required by applicable law or agreed to in writing, software // distributed under the License is distributed on an "AS IS" BASIS, // WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. // See the License for the specific language governing permissions and // limitations under the License. package main import ( "context" "fmt" "os" "strings" "github.com/spf13/cobra" "github.com/fatedier/frp/pkg/config" v1 "github.com/fatedier/frp/pkg/config/v1" "github.com/fatedier/frp/pkg/config/v1/validation" "github.com/fatedier/frp/pkg/policy/security" "github.com/fatedier/frp/pkg/util/log" "github.com/fatedier/frp/pkg/util/version" "github.com/fatedier/frp/server" ) var ( cfgFile string showVersion bool strictConfigMode bool allowUnsafe []string serverCfg v1.ServerConfig ) func init() { rootCmd.PersistentFlags().StringVarP(&cfgFile, "config", "c", "", "config file of frps") rootCmd.PersistentFlags().BoolVarP(&showVersion, "version", "v", false, "version of frps") rootCmd.PersistentFlags().BoolVarP(&strictConfigMode, "strict_config", "", true, "strict config parsing mode, unknown fields will cause errors") rootCmd.PersistentFlags().StringSliceVarP(&allowUnsafe, "allow-unsafe", "", []string{}, fmt.Sprintf("allowed unsafe features, one or more of: %s", strings.Join(security.ServerUnsafeFeatures, ", "))) config.RegisterServerConfigFlags(rootCmd, &serverCfg) } var rootCmd = &cobra.Command{ Use: "frps", Short: "frps is the server of frp (https://github.com/fatedier/frp)", RunE: func(cmd *cobra.Command, args []string) error { if showVersion { fmt.Println(version.Full()) return nil } var ( svrCfg *v1.ServerConfig isLegacyFormat bool err error ) if cfgFile != "" { svrCfg, isLegacyFormat, err = config.LoadServerConfig(cfgFile, strictConfigMode) if err != nil { fmt.Println(err) os.Exit(1) } if isLegacyFormat { fmt.Printf("WARNING: ini format is deprecated and the support will be removed in the future, " + "please use yaml/json/toml format instead!\n") } } else { if err := serverCfg.Complete(); err != nil { fmt.Printf("failed to complete server config: %v\n", err) os.Exit(1) } svrCfg = &serverCfg } unsafeFeatures := security.NewUnsafeFeatures(allowUnsafe) validator := validation.NewConfigValidator(unsafeFeatures) warning, err := validator.ValidateServerConfig(svrCfg) if warning != nil { fmt.Printf("WARNING: %v\n", warning) } if err != nil { fmt.Println(err) os.Exit(1) } if err := runServer(svrCfg); err != nil { fmt.Println(err) os.Exit(1) } return nil }, } func Execute() { rootCmd.SetGlobalNormalizationFunc(config.WordSepNormalizeFunc) if err := rootCmd.Execute(); err != nil { os.Exit(1) } } func runServer(cfg *v1.ServerConfig) (err error) { log.InitLogger(cfg.Log.To, cfg.Log.Level, int(cfg.Log.MaxDays), cfg.Log.DisablePrintColor) if cfgFile != "" { log.Infof("frps uses config file: %s", cfgFile) } else { log.Infof("frps uses command line arguments for config") } svr, err := server.NewService(cfg) if err != nil { return err } log.Infof("frps started successfully") svr.Run(context.Background()) return } ================================================ FILE: cmd/frps/verify.go ================================================ // Copyright 2021 The frp Authors // // Licensed under the Apache License, Version 2.0 (the "License"); // you may not use this file except in compliance with the License. // You may obtain a copy of the License at // // http://www.apache.org/licenses/LICENSE-2.0 // // Unless required by applicable law or agreed to in writing, software // distributed under the License is distributed on an "AS IS" BASIS, // WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. // See the License for the specific language governing permissions and // limitations under the License. package main import ( "fmt" "os" "github.com/spf13/cobra" "github.com/fatedier/frp/pkg/config" "github.com/fatedier/frp/pkg/config/v1/validation" "github.com/fatedier/frp/pkg/policy/security" ) func init() { rootCmd.AddCommand(verifyCmd) } var verifyCmd = &cobra.Command{ Use: "verify", Short: "Verify that the configures is valid", RunE: func(cmd *cobra.Command, args []string) error { if cfgFile == "" { fmt.Println("frps: the configuration file is not specified") return nil } svrCfg, _, err := config.LoadServerConfig(cfgFile, strictConfigMode) if err != nil { fmt.Println(err) os.Exit(1) } unsafeFeatures := security.NewUnsafeFeatures(allowUnsafe) validator := validation.NewConfigValidator(unsafeFeatures) warning, err := validator.ValidateServerConfig(svrCfg) if warning != nil { fmt.Printf("WARNING: %v\n", warning) } if err != nil { fmt.Println(err) os.Exit(1) } fmt.Printf("frps: the configuration file %s syntax is ok\n", cfgFile) return nil }, } ================================================ FILE: conf/frpc.toml ================================================ serverAddr = "127.0.0.1" serverPort = 7000 [[proxies]] name = "test-tcp" type = "tcp" localIP = "127.0.0.1" localPort = 22 remotePort = 6000 ================================================ FILE: conf/frpc_full_example.toml ================================================ # This configuration file is for reference only. Please do not use this configuration directly to run the program as it may have various issues. # Optional unique identifier for this frpc instance. clientID = "your_client_id" # your proxy name will be changed to {user}.{proxy} user = "your_name" # A literal address or host name for IPv6 must be enclosed # in square brackets, as in "[::1]:80", "[ipv6-host]:http" or "[ipv6-host%zone]:80" # For single serverAddr field, no need square brackets, like serverAddr = "::". serverAddr = "0.0.0.0" serverPort = 7000 # STUN server to help penetrate NAT hole. # natHoleStunServer = "stun.easyvoip.com:3478" # Decide if exit program when first login failed, otherwise continuous relogin to frps # default is true loginFailExit = true # console or real logFile path like ./frpc.log log.to = "./frpc.log" # trace, debug, info, warn, error log.level = "info" log.maxDays = 3 # disable log colors when log.to is console, default is false log.disablePrintColor = false auth.method = "token" # auth.additionalScopes specifies additional scopes to include authentication information. # Optional values are HeartBeats, NewWorkConns. # auth.additionalScopes = ["HeartBeats", "NewWorkConns"] # auth token auth.token = "12345678" # alternatively, you can use tokenSource to load the token from a file # this is mutually exclusive with auth.token # auth.tokenSource.type = "file" # auth.tokenSource.file.path = "/etc/frp/token" # oidc.clientID specifies the client ID to use to get a token in OIDC authentication. # auth.oidc.clientID = "" # oidc.clientSecret specifies the client secret to use to get a token in OIDC authentication. # auth.oidc.clientSecret = "" # oidc.audience specifies the audience of the token in OIDC authentication. # auth.oidc.audience = "" # oidc.scope specifies the permissions of the token in OIDC authentication if AuthenticationMethod == "oidc". By default, this value is "". # auth.oidc.scope = "" # oidc.tokenEndpointURL specifies the URL which implements OIDC Token Endpoint. # It will be used to get an OIDC token. # auth.oidc.tokenEndpointURL = "" # oidc.additionalEndpointParams specifies additional parameters to be sent to the OIDC Token Endpoint. # For example, if you want to specify the "audience" parameter, you can set as follow. # frp will add "audience=" "var1=" to the additional parameters. # auth.oidc.additionalEndpointParams.audience = "https://dev.auth.com/api/v2/" # auth.oidc.additionalEndpointParams.var1 = "foobar" # OIDC TLS and proxy configuration # Specify a custom CA certificate file for verifying the OIDC token endpoint's TLS certificate. # This is useful when the OIDC provider uses a self-signed certificate or a custom CA. # auth.oidc.trustedCaFile = "/path/to/ca.crt" # Skip TLS certificate verification for the OIDC token endpoint. # INSECURE: Only use this for debugging purposes, not recommended for production. # auth.oidc.insecureSkipVerify = false # Specify a proxy server for OIDC token endpoint connections. # Supports http, https, socks5, and socks5h proxy protocols. # If not specified, no proxy is used for OIDC connections. # auth.oidc.proxyURL = "http://proxy.example.com:8080" # Set admin address for control frpc's action by http api such as reload webServer.addr = "127.0.0.1" webServer.port = 7400 webServer.user = "admin" webServer.password = "admin" # Admin assets directory. By default, these assets are bundled with frpc. # webServer.assetsDir = "./static" # Enable golang pprof handlers in admin listener. webServer.pprofEnable = false # The maximum amount of time a dial to server will wait for a connect to complete. Default value is 10 seconds. # transport.dialServerTimeout = 10 # dialServerKeepalive specifies the interval between keep-alive probes for an active network connection between frpc and frps. # If negative, keep-alive probes are disabled. # transport.dialServerKeepalive = 7200 # connections will be established in advance, default value is zero transport.poolCount = 5 # If tcp stream multiplexing is used, default is true, it must be same with frps # transport.tcpMux = true # Specify keep alive interval for tcp mux. # only valid if tcpMux is enabled. # transport.tcpMuxKeepaliveInterval = 30 # Communication protocol used to connect to server # supports tcp, kcp, quic, websocket and wss now, default is tcp transport.protocol = "tcp" # set client binding ip when connect server, default is empty. # only when protocol = tcp or websocket, the value will be used. transport.connectServerLocalIP = "0.0.0.0" # if you want to connect frps by http proxy or socks5 proxy or ntlm proxy, you can set proxyURL here or in global environment variables # it only works when protocol is tcp # transport.proxyURL = "http://user:passwd@192.168.1.128:8080" # transport.proxyURL = "socks5://user:passwd@192.168.1.128:1080" # transport.proxyURL = "ntlm://user:passwd@192.168.1.128:2080" # quic protocol options # transport.quic.keepalivePeriod = 10 # transport.quic.maxIdleTimeout = 30 # transport.quic.maxIncomingStreams = 100000 # If tls.enable is true, frpc will connect frps by tls. # Since v0.50.0, the default value has been changed to true, and tls is enabled by default. transport.tls.enable = true # transport.tls.certFile = "client.crt" # transport.tls.keyFile = "client.key" # transport.tls.trustedCaFile = "ca.crt" # transport.tls.serverName = "example.com" # If the disableCustomTLSFirstByte is set to false, frpc will establish a connection with frps using the # first custom byte when tls is enabled. # Since v0.50.0, the default value has been changed to true, and the first custom byte is disabled by default. # transport.tls.disableCustomTLSFirstByte = true # Heartbeat configure, it's not recommended to modify the default value. # The default value of heartbeatInterval is 10 and heartbeatTimeout is 90. Set negative value # to disable it. # transport.heartbeatInterval = 30 # transport.heartbeatTimeout = 90 # Specify a dns server, so frpc will use this instead of default one # dnsServer = "8.8.8.8" # Proxy names you want to start. # Default is empty, means all proxies. # This list is a global allowlist after config + store are merged, so entries # created via Store API are also filtered by this list. # If start is non-empty, any proxy/visitor not listed here will not be started. # start = ["ssh", "dns"] # Alternative to 'start': You can control each proxy individually using the 'enabled' field. # Set 'enabled = false' in a proxy configuration to disable it. # If 'enabled' is not set or set to true, the proxy is enabled by default. # The 'enabled' field provides more granular control and is recommended over 'start'. # Specify udp packet size, unit is byte. If not set, the default value is 1500. # This parameter should be same between client and server. # It affects the udp and sudp proxy. udpPacketSize = 1500 # Feature gates allows you to enable or disable experimental features # Format is a map of feature names to boolean values # You can enable specific features: #featureGates = { VirtualNet = true } # VirtualNet settings for experimental virtual network capabilities # The virtual network feature requires enabling the VirtualNet feature gate above # virtualNet.address = "100.86.1.1/24" # Additional metadatas for client. metadatas.var1 = "abc" metadatas.var2 = "123" # Include other config files for proxies. # includes = ["./confd/*.ini"] [[proxies]] # 'ssh' is the unique proxy name # If global user is not empty, it will be changed to {user}.{proxy} such as 'your_name.ssh' name = "ssh" type = "tcp" # Enable or disable this proxy. true or omit this field to enable, false to disable. # enabled = true localIP = "127.0.0.1" localPort = 22 # Limit bandwidth for this proxy, unit is KB and MB transport.bandwidthLimit = "1MB" # Where to limit bandwidth, can be 'client' or 'server', default is 'client' transport.bandwidthLimitMode = "client" # If true, traffic of this proxy will be encrypted, default is false transport.useEncryption = false # If true, traffic will be compressed transport.useCompression = false # Remote port listen by frps remotePort = 6001 # frps will load balancing connections for proxies in same group loadBalancer.group = "test_group" # group should have same group key loadBalancer.groupKey = "123456" # Enable health check for the backend service, it supports 'tcp' and 'http' now. # frpc will connect local service's port to detect it's healthy status healthCheck.type = "tcp" # Health check connection timeout healthCheck.timeoutSeconds = 3 # If continuous failed in 3 times, the proxy will be removed from frps healthCheck.maxFailed = 3 # Every 10 seconds will do a health check healthCheck.intervalSeconds = 10 # Additional meta info for each proxy. It will be passed to the server-side plugin for use. metadatas.var1 = "abc" metadatas.var2 = "123" # You can add some extra information to the proxy through annotations. # These annotations will be displayed on the frps dashboard. [proxies.annotations] key1 = "value1" "prefix/key2" = "value2" [[proxies]] name = "ssh_random" type = "tcp" localIP = "192.168.31.100" localPort = 22 # If remotePort is 0, frps will assign a random port for you remotePort = 0 [[proxies]] name = "dns" type = "udp" localIP = "114.114.114.114" localPort = 53 remotePort = 6002 # Resolve your domain names to [serverAddr] so you can use http://web01.yourdomain.com to browse web01 and http://web02.yourdomain.com to browse web02 [[proxies]] name = "web01" type = "http" localIP = "127.0.0.1" localPort = 80 # http username and password are safety certification for http protocol # if not set, you can access this customDomains without certification httpUser = "admin" httpPassword = "admin" # if domain for frps is frps.com, then you can access [web01] proxy by URL http://web01.frps.com subdomain = "web01" customDomains = ["web01.yourdomain.com"] # locations is only available for http type locations = ["/", "/pic"] # route requests to this service if http basic auto user is abc # routeByHTTPUser = abc hostHeaderRewrite = "example.com" requestHeaders.set.x-from-where = "frp" responseHeaders.set.foo = "bar" healthCheck.type = "http" # frpc will send a GET http request '/status' to local http service # http service is alive when it return 2xx http response code healthCheck.path = "/status" healthCheck.intervalSeconds = 10 healthCheck.maxFailed = 3 healthCheck.timeoutSeconds = 3 # set health check headers healthCheck.httpHeaders=[ { name = "x-from-where", value = "frp" } ] [[proxies]] name = "web02" type = "https" # Disable this proxy by setting enabled to false # enabled = false localIP = "127.0.0.1" localPort = 8000 subdomain = "web02" customDomains = ["web02.yourdomain.com"] # if not empty, frpc will use proxy protocol to transfer connection info to your local service # v1 or v2 or empty transport.proxyProtocolVersion = "v2" [[proxies]] name = "tcpmuxhttpconnect" type = "tcpmux" multiplexer = "httpconnect" localIP = "127.0.0.1" localPort = 10701 customDomains = ["tunnel1"] # routeByHTTPUser = "user1" [[proxies]] name = "plugin_unix_domain_socket" type = "tcp" remotePort = 6003 # if plugin is defined, localIP and localPort is useless # plugin will handle connections got from frps [proxies.plugin] type = "unix_domain_socket" unixPath = "/var/run/docker.sock" [[proxies]] name = "plugin_http_proxy" type = "tcp" remotePort = 6004 [proxies.plugin] type = "http_proxy" httpUser = "abc" httpPassword = "abc" [[proxies]] name = "plugin_socks5" type = "tcp" remotePort = 6005 [proxies.plugin] type = "socks5" username = "abc" password = "abc" [[proxies]] name = "plugin_static_file" type = "tcp" remotePort = 6006 [proxies.plugin] type = "static_file" localPath = "/var/www/blog" stripPrefix = "static" httpUser = "abc" httpPassword = "abc" [[proxies]] name = "plugin_https2http" type = "https" customDomains = ["test.yourdomain.com"] [proxies.plugin] type = "https2http" localAddr = "127.0.0.1:80" crtPath = "./server.crt" keyPath = "./server.key" hostHeaderRewrite = "127.0.0.1" requestHeaders.set.x-from-where = "frp" [[proxies]] name = "plugin_https2https" type = "https" customDomains = ["test.yourdomain.com"] [proxies.plugin] type = "https2https" localAddr = "127.0.0.1:443" crtPath = "./server.crt" keyPath = "./server.key" hostHeaderRewrite = "127.0.0.1" requestHeaders.set.x-from-where = "frp" [[proxies]] name = "plugin_http2https" type = "http" customDomains = ["test.yourdomain.com"] [proxies.plugin] type = "http2https" localAddr = "127.0.0.1:443" hostHeaderRewrite = "127.0.0.1" requestHeaders.set.x-from-where = "frp" [[proxies]] name = "plugin_http2http" type = "tcp" remotePort = 6007 [proxies.plugin] type = "http2http" localAddr = "127.0.0.1:80" hostHeaderRewrite = "127.0.0.1" requestHeaders.set.x-from-where = "frp" [[proxies]] name = "plugin_tls2raw" type = "tcp" remotePort = 6008 [proxies.plugin] type = "tls2raw" localAddr = "127.0.0.1:80" crtPath = "./server.crt" keyPath = "./server.key" [[proxies]] name = "secret_tcp" # If the type is secret tcp, remotePort is useless # Who want to connect local port should deploy another frpc with stcp proxy and role is visitor type = "stcp" # secretKey is used for authentication for visitors secretKey = "abcdefg" localIP = "127.0.0.1" localPort = 22 # If not empty, only visitors from specified users can connect. # Otherwise, visitors from same user can connect. '*' means allow all users. allowUsers = ["*"] [[proxies]] name = "p2p_tcp" type = "xtcp" secretKey = "abcdefg" localIP = "127.0.0.1" localPort = 22 # If not empty, only visitors from specified users can connect. # Otherwise, visitors from same user can connect. '*' means allow all users. allowUsers = ["user1", "user2"] # NAT traversal configuration (optional) [proxies.natTraversal] # Disable the use of local network interfaces (assisted addresses) for NAT traversal. # When enabled, only STUN-discovered public addresses will be used. # This can improve performance when you have slow VPN connections. # Default: false disableAssistedAddrs = false [[proxies]] name = "vnet-server" type = "stcp" secretKey = "your-secret-key" [proxies.plugin] type = "virtual_net" # frpc role visitor -> frps -> frpc role server [[visitors]] name = "secret_tcp_visitor" type = "stcp" # the server name you want to visitor serverName = "secret_tcp" secretKey = "abcdefg" # connect this address to visitor stcp server bindAddr = "127.0.0.1" # bindPort can be less than 0, it means don't bind to the port and only receive connections redirected from # other visitors. (This is not supported for SUDP now) bindPort = 9000 [[visitors]] name = "p2p_tcp_visitor" type = "xtcp" # if the server user is not set, it defaults to the current user serverUser = "user1" serverName = "p2p_tcp" secretKey = "abcdefg" bindAddr = "127.0.0.1" # bindPort can be less than 0, it means don't bind to the port and only receive connections redirected from # other visitors. (This is not supported for SUDP now) bindPort = 9001 # when automatic tunnel persistence is required, set it to true keepTunnelOpen = false # effective when keepTunnelOpen is set to true, the number of attempts to punch through per hour maxRetriesAnHour = 8 minRetryInterval = 90 # fallbackTo = "stcp_visitor" # fallbackTimeoutMs = 500 # NAT traversal configuration (optional) [visitors.natTraversal] # Disable the use of local network interfaces (assisted addresses) for NAT traversal. # When enabled, only STUN-discovered public addresses will be used. # Default: false disableAssistedAddrs = false [[visitors]] name = "vnet-visitor" type = "stcp" serverName = "vnet-server" secretKey = "your-secret-key" bindPort = -1 [visitors.plugin] type = "virtual_net" destinationIP = "100.86.0.1" ================================================ FILE: conf/frps.toml ================================================ bindPort = 7000 ================================================ FILE: conf/frps_full_example.toml ================================================ # This configuration file is for reference only. Please do not use this configuration directly to run the program as it may have various issues. # A literal address or host name for IPv6 must be enclosed # in square brackets, as in "[::1]:80", "[ipv6-host]:http" or "[ipv6-host%zone]:80" # For single "bindAddr" field, no need square brackets, like `bindAddr = "::"`. bindAddr = "0.0.0.0" bindPort = 7000 # udp port used for kcp protocol, it can be same with 'bindPort'. # if not set, kcp is disabled in frps. kcpBindPort = 7000 # udp port used for quic protocol. # if not set, quic is disabled in frps. # quicBindPort = 7002 # Specify which address proxy will listen for, default value is same with bindAddr # proxyBindAddr = "127.0.0.1" # quic protocol options # transport.quic.keepalivePeriod = 10 # transport.quic.maxIdleTimeout = 30 # transport.quic.maxIncomingStreams = 100000 # Heartbeat configure, it's not recommended to modify the default value # The default value of heartbeatTimeout is 90. Set negative value to disable it. # transport.heartbeatTimeout = 90 # Pool count in each proxy will keep no more than maxPoolCount. transport.maxPoolCount = 5 # If tcp stream multiplexing is used, default is true # transport.tcpMux = true # Specify keep alive interval for tcp mux. # only valid if tcpMux is true. # transport.tcpMuxKeepaliveInterval = 30 # tcpKeepalive specifies the interval between keep-alive probes for an active network connection between frpc and frps. # If negative, keep-alive probes are disabled. # transport.tcpKeepalive = 7200 # transport.tls.force specifies whether to only accept TLS-encrypted connections. By default, the value is false. transport.tls.force = false # transport.tls.certFile = "server.crt" # transport.tls.keyFile = "server.key" # transport.tls.trustedCaFile = "ca.crt" # If you want to support virtual host, you must set the http port for listening (optional) # Note: http port and https port can be same with bindPort vhostHTTPPort = 80 vhostHTTPSPort = 443 # Response header timeout(seconds) for vhost http server, default is 60s # vhostHTTPTimeout = 60 # tcpmuxHTTPConnectPort specifies the port that the server listens for TCP # HTTP CONNECT requests. If the value is 0, the server will not multiplex TCP # requests on one single port. If it's not - it will listen on this value for # HTTP CONNECT requests. By default, this value is 0. # tcpmuxHTTPConnectPort = 1337 # If tcpmuxPassthrough is true, frps won't do any update on traffic. # tcpmuxPassthrough = false # Configure the web server to enable the dashboard for frps. # dashboard is available only if webServer.port is set. webServer.addr = "127.0.0.1" webServer.port = 7500 webServer.user = "admin" webServer.password = "admin" # webServer.tls.certFile = "server.crt" # webServer.tls.keyFile = "server.key" # dashboard assets directory(only for debug mode) # webServer.assetsDir = "./static" # Enable golang pprof handlers in dashboard listener. # Dashboard port must be set first webServer.pprofEnable = false # enablePrometheus will export prometheus metrics on webServer in /metrics api. enablePrometheus = true # console or real logFile path like ./frps.log log.to = "./frps.log" # trace, debug, info, warn, error log.level = "info" log.maxDays = 3 # disable log colors when log.to is console, default is false log.disablePrintColor = false # DetailedErrorsToClient defines whether to send the specific error (with debug info) to frpc. By default, this value is true. detailedErrorsToClient = true # auth.method specifies what authentication method to use authenticate frpc with frps. # If "token" is specified - token will be read into login message. # If "oidc" is specified - OIDC (Open ID Connect) token will be issued using OIDC settings. By default, this value is "token". auth.method = "token" # auth.additionalScopes specifies additional scopes to include authentication information. # Optional values are HeartBeats, NewWorkConns. # auth.additionalScopes = ["HeartBeats", "NewWorkConns"] # auth token auth.token = "12345678" # alternatively, you can use tokenSource to load the token from a file # this is mutually exclusive with auth.token # auth.tokenSource.type = "file" # auth.tokenSource.file.path = "/etc/frp/token" # oidc issuer specifies the issuer to verify OIDC tokens with. auth.oidc.issuer = "" # oidc audience specifies the audience OIDC tokens should contain when validated. auth.oidc.audience = "" # oidc skipExpiryCheck specifies whether to skip checking if the OIDC token is expired. auth.oidc.skipExpiryCheck = false # oidc skipIssuerCheck specifies whether to skip checking if the OIDC token's issuer claim matches the issuer specified in OidcIssuer. auth.oidc.skipIssuerCheck = false # userConnTimeout specifies the maximum time to wait for a work connection. # userConnTimeout = 10 # Only allow frpc to bind ports you list. By default, there won't be any limit. allowPorts = [ { start = 2000, end = 3000 }, { single = 3001 }, { single = 3003 }, { start = 4000, end = 50000 } ] # Max ports can be used for each client, default value is 0 means no limit maxPortsPerClient = 0 # If subDomainHost is not empty, you can set subdomain when type is http or https in frpc's configure file # When subdomain is test, the host used by routing is test.frps.com subDomainHost = "frps.com" # custom 404 page for HTTP requests # custom404Page = "/path/to/404.html" # specify udp packet size, unit is byte. If not set, the default value is 1500. # This parameter should be same between client and server. # It affects the udp and sudp proxy. udpPacketSize = 1500 # Retention time for NAT hole punching strategy data. natholeAnalysisDataReserveHours = 168 # ssh tunnel gateway # If you want to enable this feature, the bindPort parameter is required, while others are optional. # By default, this feature is disabled. It will be enabled if bindPort is greater than 0. # sshTunnelGateway.bindPort = 2200 # sshTunnelGateway.privateKeyFile = "/home/frp-user/.ssh/id_rsa" # sshTunnelGateway.autoGenPrivateKeyPath = "" # sshTunnelGateway.authorizedKeysFile = "/home/frp-user/.ssh/authorized_keys" [[httpPlugins]] name = "user-manager" addr = "127.0.0.1:9000" path = "/handler" ops = ["Login"] [[httpPlugins]] name = "port-manager" addr = "127.0.0.1:9001" path = "/handler" ops = ["NewProxy"] ================================================ FILE: conf/legacy/frpc_legacy_full.ini ================================================ # [common] is integral section [common] # A literal address or host name for IPv6 must be enclosed # in square brackets, as in "[::1]:80", "[ipv6-host]:http" or "[ipv6-host%zone]:80" # For single "server_addr" field, no need square brackets, like "server_addr = ::". server_addr = 0.0.0.0 server_port = 7000 # STUN server to help penetrate NAT hole. # nat_hole_stun_server = stun.easyvoip.com:3478 # The maximum amount of time a dial to server will wait for a connect to complete. Default value is 10 seconds. # dial_server_timeout = 10 # dial_server_keepalive specifies the interval between keep-alive probes for an active network connection between frpc and frps. # If negative, keep-alive probes are disabled. # dial_server_keepalive = 7200 # if you want to connect frps by http proxy or socks5 proxy or ntlm proxy, you can set http_proxy here or in global environment variables # it only works when protocol is tcp # http_proxy = http://user:passwd@192.168.1.128:8080 # http_proxy = socks5://user:passwd@192.168.1.128:1080 # http_proxy = ntlm://user:passwd@192.168.1.128:2080 # console or real logFile path like ./frpc.log log_file = ./frpc.log # trace, debug, info, warn, error log_level = info log_max_days = 3 # disable log colors when log_file is console, default is false disable_log_color = false # for authentication, should be same as your frps.ini # authenticate_heartbeats specifies whether to include authentication token in heartbeats sent to frps. By default, this value is false. authenticate_heartbeats = false # authenticate_new_work_conns specifies whether to include authentication token in new work connections sent to frps. By default, this value is false. authenticate_new_work_conns = false # auth token token = 12345678 authentication_method = # oidc_client_id specifies the client ID to use to get a token in OIDC authentication if AuthenticationMethod == "oidc". # By default, this value is "". oidc_client_id = # oidc_client_secret specifies the client secret to use to get a token in OIDC authentication if AuthenticationMethod == "oidc". # By default, this value is "". oidc_client_secret = # oidc_audience specifies the audience of the token in OIDC authentication if AuthenticationMethod == "oidc". By default, this value is "". oidc_audience = # oidc_scope specifies the permissions of the token in OIDC authentication if AuthenticationMethod == "oidc". By default, this value is "". oidc_scope = # oidc_token_endpoint_url specifies the URL which implements OIDC Token Endpoint. # It will be used to get an OIDC token if AuthenticationMethod == "oidc". By default, this value is "". oidc_token_endpoint_url = # oidc_additional_xxx specifies additional parameters to be sent to the OIDC Token Endpoint. # For example, if you want to specify the "audience" parameter, you can set as follow. # frp will add "audience=" "var1=" to the additional parameters. # oidc_additional_audience = https://dev.auth.com/api/v2/ # oidc_additional_var1 = foobar # set admin address for control frpc's action by http api such as reload admin_addr = 127.0.0.1 admin_port = 7400 admin_user = admin admin_pwd = admin # Admin assets directory. By default, these assets are bundled with frpc. # assets_dir = ./static # connections will be established in advance, default value is zero pool_count = 5 # if tcp stream multiplexing is used, default is true, it must be same with frps # tcp_mux = true # specify keep alive interval for tcp mux. # only valid if tcp_mux is true. # tcp_mux_keepalive_interval = 60 # your proxy name will be changed to {user}.{proxy} user = your_name # decide if exit program when first login failed, otherwise continuous relogin to frps # default is true login_fail_exit = true # communication protocol used to connect to server # supports tcp, kcp, quic, websocket and wss now, default is tcp protocol = tcp # set client binding ip when connect server, default is empty. # only when protocol = tcp or websocket, the value will be used. connect_server_local_ip = 0.0.0.0 # quic protocol options # quic_keepalive_period = 10 # quic_max_idle_timeout = 30 # quic_max_incoming_streams = 100000 # If tls_enable is true, frpc will connect frps by tls. # Since v0.50.0, the default value has been changed to true, and tls is enabled by default. tls_enable = true # tls_cert_file = client.crt # tls_key_file = client.key # tls_trusted_ca_file = ca.crt # tls_server_name = example.com # specify a dns server, so frpc will use this instead of default one # dns_server = 8.8.8.8 # proxy names you want to start separated by ',' # default is empty, means all proxies # start = ssh,dns # heartbeat configure, it's not recommended to modify the default value # The default value of heartbeat_interval is 10 and heartbeat_timeout is 90. Set negative value # to disable it. # heartbeat_interval = 30 # heartbeat_timeout = 90 # additional meta info for client meta_var1 = 123 meta_var2 = 234 # specify udp packet size, unit is byte. If not set, the default value is 1500. # This parameter should be same between client and server. # It affects the udp and sudp proxy. udp_packet_size = 1500 # include other config files for proxies. # includes = ./confd/*.ini # If the disable_custom_tls_first_byte is set to false, frpc will establish a connection with frps using the # first custom byte when tls is enabled. # Since v0.50.0, the default value has been changed to true, and the first custom byte is disabled by default. disable_custom_tls_first_byte = true # Enable golang pprof handlers in admin listener. # Admin port must be set first. pprof_enable = false # 'ssh' is the unique proxy name # if user in [common] section is not empty, it will be changed to {user}.{proxy} such as 'your_name.ssh' [ssh] # tcp | udp | http | https | stcp | xtcp, default is tcp type = tcp local_ip = 127.0.0.1 local_port = 22 # limit bandwidth for this proxy, unit is KB and MB bandwidth_limit = 1MB # where to limit bandwidth, can be 'client' or 'server', default is 'client' bandwidth_limit_mode = client # true or false, if true, messages between frps and frpc will be encrypted, default is false use_encryption = false # if true, message will be compressed use_compression = false # remote port listen by frps remote_port = 6001 # frps will load balancing connections for proxies in same group group = test_group # group should have same group key group_key = 123456 # enable health check for the backend service, it support 'tcp' and 'http' now # frpc will connect local service's port to detect it's healthy status health_check_type = tcp # health check connection timeout health_check_timeout_s = 3 # if continuous failed in 3 times, the proxy will be removed from frps health_check_max_failed = 3 # every 10 seconds will do a health check health_check_interval_s = 10 # additional meta info for each proxy meta_var1 = 123 meta_var2 = 234 [ssh_random] type = tcp local_ip = 127.0.0.1 local_port = 22 # if remote_port is 0, frps will assign a random port for you remote_port = 0 # if you want to expose multiple ports, add 'range:' prefix to the section name # frpc will generate multiple proxies such as 'tcp_port_6010', 'tcp_port_6011' and so on. [range:tcp_port] type = tcp local_ip = 127.0.0.1 local_port = 6010-6020,6022,6024-6028 remote_port = 6010-6020,6022,6024-6028 use_encryption = false use_compression = false [dns] type = udp local_ip = 114.114.114.114 local_port = 53 remote_port = 6002 use_encryption = false use_compression = false [range:udp_port] type = udp local_ip = 127.0.0.1 local_port = 6010-6020 remote_port = 6010-6020 use_encryption = false use_compression = false # Resolve your domain names to [server_addr] so you can use http://web01.yourdomain.com to browse web01 and http://web02.yourdomain.com to browse web02 [web01] type = http local_ip = 127.0.0.1 local_port = 80 use_encryption = false use_compression = true # http username and password are safety certification for http protocol # if not set, you can access this custom_domains without certification http_user = admin http_pwd = admin # if domain for frps is frps.com, then you can access [web01] proxy by URL http://web01.frps.com subdomain = web01 custom_domains = web01.yourdomain.com # locations is only available for http type locations = /,/pic # route requests to this service if http basic auto user is abc # route_by_http_user = abc host_header_rewrite = example.com # params with prefix "header_" will be used to update http request headers header_X-From-Where = frp health_check_type = http # frpc will send a GET http request '/status' to local http service # http service is alive when it return 2xx http response code health_check_url = /status health_check_interval_s = 10 health_check_max_failed = 3 health_check_timeout_s = 3 [web02] type = https local_ip = 127.0.0.1 local_port = 8000 use_encryption = false use_compression = false subdomain = web02 custom_domains = web02.yourdomain.com # if not empty, frpc will use proxy protocol to transfer connection info to your local service # v1 or v2 or empty proxy_protocol_version = v2 [plugin_unix_domain_socket] type = tcp remote_port = 6003 # if plugin is defined, local_ip and local_port is useless # plugin will handle connections got from frps plugin = unix_domain_socket # params with prefix "plugin_" that plugin needed plugin_unix_path = /var/run/docker.sock [plugin_http_proxy] type = tcp remote_port = 6004 plugin = http_proxy plugin_http_user = abc plugin_http_passwd = abc [plugin_socks5] type = tcp remote_port = 6005 plugin = socks5 plugin_user = abc plugin_passwd = abc [plugin_static_file] type = tcp remote_port = 6006 plugin = static_file plugin_local_path = /var/www/blog plugin_strip_prefix = static plugin_http_user = abc plugin_http_passwd = abc [plugin_https2http] type = https custom_domains = test.yourdomain.com plugin = https2http plugin_local_addr = 127.0.0.1:80 plugin_crt_path = ./server.crt plugin_key_path = ./server.key plugin_host_header_rewrite = 127.0.0.1 plugin_header_X-From-Where = frp [plugin_https2https] type = https custom_domains = test.yourdomain.com plugin = https2https plugin_local_addr = 127.0.0.1:443 plugin_crt_path = ./server.crt plugin_key_path = ./server.key plugin_host_header_rewrite = 127.0.0.1 plugin_header_X-From-Where = frp [plugin_http2https] type = http custom_domains = test.yourdomain.com plugin = http2https plugin_local_addr = 127.0.0.1:443 plugin_host_header_rewrite = 127.0.0.1 plugin_header_X-From-Where = frp [secret_tcp] # If the type is secret tcp, remote_port is useless # Who want to connect local port should deploy another frpc with stcp proxy and role is visitor type = stcp # sk used for authentication for visitors sk = abcdefg local_ip = 127.0.0.1 local_port = 22 use_encryption = false use_compression = false # If not empty, only visitors from specified users can connect. # Otherwise, visitors from same user can connect. '*' means allow all users. allow_users = * # user of frpc should be same in both stcp server and stcp visitor [secret_tcp_visitor] # frpc role visitor -> frps -> frpc role server role = visitor type = stcp # the server name you want to visitor server_name = secret_tcp sk = abcdefg # connect this address to visitor stcp server bind_addr = 127.0.0.1 # bind_port can be less than 0, it means don't bind to the port and only receive connections redirected from # other visitors. (This is not supported for SUDP now) bind_port = 9000 use_encryption = false use_compression = false [p2p_tcp] type = xtcp sk = abcdefg local_ip = 127.0.0.1 local_port = 22 use_encryption = false use_compression = false # If not empty, only visitors from specified users can connect. # Otherwise, visitors from same user can connect. '*' means allow all users. allow_users = user1, user2 [p2p_tcp_visitor] role = visitor type = xtcp # if the server user is not set, it defaults to the current user server_user = user1 server_name = p2p_tcp sk = abcdefg bind_addr = 127.0.0.1 # bind_port can be less than 0, it means don't bind to the port and only receive connections redirected from # other visitors. (This is not supported for SUDP now) bind_port = 9001 use_encryption = false use_compression = false # when automatic tunnel persistence is required, set it to true keep_tunnel_open = false # effective when keep_tunnel_open is set to true, the number of attempts to punch through per hour max_retries_an_hour = 8 min_retry_interval = 90 # fallback_to = stcp_visitor # fallback_timeout_ms = 500 [tcpmuxhttpconnect] type = tcpmux multiplexer = httpconnect local_ip = 127.0.0.1 local_port = 10701 custom_domains = tunnel1 # route_by_http_user = user1 ================================================ FILE: conf/legacy/frps_legacy_full.ini ================================================ # [common] is integral section [common] # A literal address or host name for IPv6 must be enclosed # in square brackets, as in "[::1]:80", "[ipv6-host]:http" or "[ipv6-host%zone]:80" # For single "bind_addr" field, no need square brackets, like "bind_addr = ::". bind_addr = 0.0.0.0 bind_port = 7000 # udp port used for kcp protocol, it can be same with 'bind_port'. # if not set, kcp is disabled in frps. kcp_bind_port = 7000 # udp port used for quic protocol. # if not set, quic is disabled in frps. # quic_bind_port = 7002 # quic protocol options # quic_keepalive_period = 10 # quic_max_idle_timeout = 30 # quic_max_incoming_streams = 100000 # specify which address proxy will listen for, default value is same with bind_addr # proxy_bind_addr = 127.0.0.1 # if you want to support virtual host, you must set the http port for listening (optional) # Note: http port and https port can be same with bind_port vhost_http_port = 80 vhost_https_port = 443 # response header timeout(seconds) for vhost http server, default is 60s # vhost_http_timeout = 60 # tcpmux_httpconnect_port specifies the port that the server listens for TCP # HTTP CONNECT requests. If the value is 0, the server will not multiplex TCP # requests on one single port. If it's not - it will listen on this value for # HTTP CONNECT requests. By default, this value is 0. # tcpmux_httpconnect_port = 1337 # If tcpmux_passthrough is true, frps won't do any update on traffic. # tcpmux_passthrough = false # set dashboard_addr and dashboard_port to view dashboard of frps # dashboard_addr's default value is same with bind_addr # dashboard is available only if dashboard_port is set dashboard_addr = 0.0.0.0 dashboard_port = 7500 # dashboard user and passwd for basic auth protect dashboard_user = admin dashboard_pwd = admin # dashboard TLS mode dashboard_tls_mode = false # dashboard_tls_cert_file = server.crt # dashboard_tls_key_file = server.key # enable_prometheus will export prometheus metrics on {dashboard_addr}:{dashboard_port} in /metrics api. enable_prometheus = true # dashboard assets directory(only for debug mode) # assets_dir = ./static # console or real logFile path like ./frps.log log_file = ./frps.log # trace, debug, info, warn, error log_level = info log_max_days = 3 # disable log colors when log_file is console, default is false disable_log_color = false # DetailedErrorsToClient defines whether to send the specific error (with debug info) to frpc. By default, this value is true. detailed_errors_to_client = true # authentication_method specifies what authentication method to use authenticate frpc with frps. # If "token" is specified - token will be read into login message. # If "oidc" is specified - OIDC (Open ID Connect) token will be issued using OIDC settings. By default, this value is "token". authentication_method = token # authenticate_heartbeats specifies whether to include authentication token in heartbeats sent to frps. By default, this value is false. authenticate_heartbeats = false # AuthenticateNewWorkConns specifies whether to include authentication token in new work connections sent to frps. By default, this value is false. authenticate_new_work_conns = false # auth token token = 12345678 # oidc_issuer specifies the issuer to verify OIDC tokens with. # By default, this value is "". oidc_issuer = # oidc_audience specifies the audience OIDC tokens should contain when validated. # By default, this value is "". oidc_audience = # oidc_skip_expiry_check specifies whether to skip checking if the OIDC token is expired. # By default, this value is false. oidc_skip_expiry_check = false # oidc_skip_issuer_check specifies whether to skip checking if the OIDC token's issuer claim matches the issuer specified in OidcIssuer. # By default, this value is false. oidc_skip_issuer_check = false # heartbeat configure, it's not recommended to modify the default value # the default value of heartbeat_timeout is 90. Set negative value to disable it. # heartbeat_timeout = 90 # user_conn_timeout configure, it's not recommended to modify the default value # the default value of user_conn_timeout is 10 # user_conn_timeout = 10 # only allow frpc to bind ports you list, if you set nothing, there won't be any limit allow_ports = 2000-3000,3001,3003,4000-50000 # pool_count in each proxy will change to max_pool_count if they exceed the maximum value max_pool_count = 5 # max ports can be used for each client, default value is 0 means no limit max_ports_per_client = 0 # tls_only specifies whether to only accept TLS-encrypted connections. By default, the value is false. tls_only = false # tls_cert_file = server.crt # tls_key_file = server.key # tls_trusted_ca_file = ca.crt # if subdomain_host is not empty, you can set subdomain when type is http or https in frpc's configure file # when subdomain is test, the host used by routing is test.frps.com subdomain_host = frps.com # if tcp stream multiplexing is used, default is true # tcp_mux = true # specify keep alive interval for tcp mux. # only valid if tcp_mux is true. # tcp_mux_keepalive_interval = 60 # tcp_keepalive specifies the interval between keep-alive probes for an active network connection between frpc and frps. # If negative, keep-alive probes are disabled. # tcp_keepalive = 7200 # custom 404 page for HTTP requests # custom_404_page = /path/to/404.html # specify udp packet size, unit is byte. If not set, the default value is 1500. # This parameter should be same between client and server. # It affects the udp and sudp proxy. udp_packet_size = 1500 # Enable golang pprof handlers in dashboard listener. # Dashboard port must be set first pprof_enable = false # Retention time for NAT hole punching strategy data. nat_hole_analysis_data_reserve_hours = 168 [plugin.user-manager] addr = 127.0.0.1:9000 path = /handler ops = Login [plugin.port-manager] addr = 127.0.0.1:9001 path = /handler ops = NewProxy ================================================ FILE: doc/server_plugin.md ================================================ ### Server Plugin frp server plugin is aimed to extend frp's ability without modifying the Golang code. An external server should run in a different process receiving RPC calls from frps. Before frps is doing some operations, it will send RPC requests to notify the external RPC server and act according to its response. ### RPC request RPC requests are based on JSON over HTTP. When a server plugin accepts an operation request, it can respond with three different responses: * Reject operation and return a reason. * Allow operation and keep original content. * Allow operation and return modified content. ### Interface HTTP path can be configured for each manage plugin in frps. We'll assume for this example that it's `/handler`. A request to the RPC server will look like: ``` POST /handler?version=0.1.0&op=Login { "version": "0.1.0", "op": "Login", "content": { ... // Operation info } } Request Header: X-Frp-Reqid: for tracing ``` The response can look like any of the following: * Non-200 HTTP response status code (this will automatically tell frps that the request should fail) * Reject operation: ``` { "reject": true, "reject_reason": "invalid user" } ``` * Allow operation and keep original content: ``` { "reject": false, "unchange": true } ``` * Allow operation and modify content ``` { "unchange": "false", "content": { ... // Replaced content } } ``` ### Operation Currently `Login`, `NewProxy`, `CloseProxy`, `Ping`, `NewWorkConn` and `NewUserConn` operations are supported. #### Login Client login operation ``` { "content": { "version": , "hostname": , "os": , "arch": , "user": , "timestamp": , "privilege_key": , "run_id": , "pool_count": , "metas": mapstring, "client_address": } } ``` #### NewProxy Create new proxy ``` { "content": { "user": { "user": , "metas": mapstring "run_id": }, "proxy_name": , "proxy_type": , "use_encryption": , "use_compression": , "bandwidth_limit": , "bandwidth_limit_mode": , "group": , "group_key": , // tcp and udp only "remote_port": , // http and https only "custom_domains": [], "subdomain": , "locations": [], "http_user": , "http_pwd": , "host_header_rewrite": , "headers": mapstring, // stcp only "sk": , // tcpmux only "multiplexer": "metas": mapstring } } ``` #### CloseProxy A previously created proxy is closed. Please note that one request will be sent for every proxy that is closed, do **NOT** use this if you have too many proxies bound to a single client, as this may exhaust the server's resources. ``` { "content": { "user": { "user": , "metas": mapstring "run_id": }, "proxy_name": } } ``` #### Ping Heartbeat from frpc ``` { "content": { "user": { "user": , "metas": mapstring "run_id": }, "timestamp": , "privilege_key": } } ``` #### NewWorkConn New work connection received from frpc (RPC sent after `run_id` is matched with an existing frp connection) ``` { "content": { "user": { "user": , "metas": mapstring "run_id": }, "run_id": "timestamp": , "privilege_key": } } ``` #### NewUserConn New user connection received from proxy (support `tcp`, `stcp`, `https` and `tcpmux`) . ``` { "content": { "user": { "user": , "metas": mapstring "run_id": }, "proxy_name": , "proxy_type": , "remote_addr": } } ``` ### Server Plugin Configuration ```toml # frps.toml bindPort = 7000 [[httpPlugins]] name = "user-manager" addr = "127.0.0.1:9000" path = "/handler" ops = ["Login"] [[httpPlugins]] name = "port-manager" addr = "127.0.0.1:9001" path = "/handler" ops = ["NewProxy"] ``` - addr: the address where the external RPC service listens. Defaults to http. For https, specify the schema: `addr = "https://127.0.0.1:9001"`. - path: http request url path for the POST request. - ops: operations plugin needs to handle (e.g. "Login", "NewProxy", ...). - tlsVerify: When the schema is https, we verify by default. Set this value to false if you want to skip verification. ### Metadata Metadata will be sent to the server plugin in each RPC request. There are 2 types of metadata entries - global one and the other under each proxy configuration. Global metadata entries will be sent in `Login` under the key `metas`, and in any other RPC request under `user.metas`. Metadata entries under each proxy configuration will be sent in `NewProxy` op only, under `metas`. This is an example of metadata entries: ```toml # frpc.toml serverAddr = "127.0.0.1" serverPort = 7000 user = "fake" metadatas.token = "fake" metadatas.version = "1.0.0" [[proxies]] name = "ssh" type = "tcp" localPort = 22 remotePort = 6000 metadatas.id = "123" ``` ================================================ FILE: doc/ssh_tunnel_gateway.md ================================================ ### SSH Tunnel Gateway *Added in v0.53.0* ### Concept SSH supports reverse proxy capabilities [rfc](https://www.rfc-editor.org/rfc/rfc4254#page-16). frp supports listening on an SSH port on the frps side to achieve TCP protocol proxying using the SSH -R protocol. This mode does not rely on frpc. SSH reverse tunneling proxying and proxying SSH ports through frp are two different concepts. SSH reverse tunneling proxying is essentially a basic reverse proxying accomplished by connecting to frps via an SSH client when you don't want to use frpc. ```toml # frps.toml sshTunnelGateway.bindPort = 0 sshTunnelGateway.privateKeyFile = "" sshTunnelGateway.autoGenPrivateKeyPath = "" sshTunnelGateway.authorizedKeysFile = "" ``` | Field | Type | Description | Required | | :--- | :--- | :--- | :--- | | bindPort| int | The ssh server port that frps listens on.| Yes | | privateKeyFile | string | Default value is empty. The private key file used by the ssh server. If it is empty, frps will read the private key file under the autoGenPrivateKeyPath path. It can reuse the /home/user/.ssh/id_rsa file on the local machine, or a custom path can be specified.| No | | autoGenPrivateKeyPath | string |Default value is ./.autogen_ssh_key. If the file does not exist or its content is empty, frps will automatically generate RSA private key file content and store it in this file.|No| | authorizedKeysFile | string |Default value is empty. If it is empty, ssh client authentication is not authenticated. If it is not empty, it can implement ssh password-free login authentication. It can reuse the local /home/user/.ssh/authorized_keys file or a custom path can be specified.| No | ### Basic Usage #### Server-side frps Minimal configuration: ```toml sshTunnelGateway.bindPort = 2200 ``` Place the above configuration in frps.toml and run `./frps -c frps.toml`. It will listen on port 2200 and accept SSH reverse proxy requests. Note: 1. When using the minimal configuration, a `.autogen_ssh_key` private key file will be automatically created in the current working directory. The SSH server of frps will use this private key file for encryption and decryption. Alternatively, you can reuse an existing private key file on your local machine, such as `/home/user/.ssh/id_rsa`. 2. When running frps in the minimal configuration mode, connecting to frps via SSH does not require authentication. It is strongly recommended to configure a token in frps and specify the token in the SSH command line. #### Client-side SSH The command format is: ```bash ssh -R :80:{local_ip:port} v0@{frps_address} -p {frps_ssh_listen_port} {tcp|http|https|stcp|tcpmux} --remote_port {real_remote_port} --proxy_name {proxy_name} --token {frp_token} ``` 1. `--proxy_name` is optional, and if left empty, a random one will be generated. 2. The username for logging in to frps is always "v0" and currently has no significance, i.e., `v0@{frps_address}`. 3. The server-side proxy listens on the port determined by `--remote_port`. 4. `{tcp|http|https|stcp|tcpmux}` supports the complete command parameters, which can be obtained by using `--help`. For example: `ssh -R :80::8080 v0@127.0.0.1 -p 2200 http --help`. 5. The token is optional, but for security reasons, it is strongly recommended to configure the token in frps. #### TCP Proxy ```bash ssh -R :80:127.0.0.1:8080 v0@{frp_address} -p 2200 tcp --proxy_name "test-tcp" --remote_port 9090 ``` This sets up a proxy on frps that listens on port 9090 and proxies local service on port 8080. ```bash frp (via SSH) (Ctrl+C to quit) User: ProxyName: test-tcp Type: tcp RemoteAddress: :9090 ``` Equivalent to: ```bash frpc tcp --proxy_name "test-tcp" --local_ip 127.0.0.1 --local_port 8080 --remote_port 9090 ``` More parameters can be obtained by executing `--help`. #### HTTP Proxy ```bash ssh -R :80:127.0.0.1:8080 v0@{frp address} -p 2200 http --proxy_name "test-http" --custom_domain test-http.frps.com ``` Equivalent to: ```bash frpc http --proxy_name "test-http" --custom_domain test-http.frps.com ``` You can access the HTTP service using the following command: curl 'http://test-http.frps.com' More parameters can be obtained by executing --help. #### HTTPS/STCP/TCPMUX Proxy To obtain the usage instructions, use the following command: ```bash ssh -R :80:127.0.0.1:8080 v0@{frp address} -p 2200 {https|stcp|tcpmux} --help ``` ### Advanced Usage #### Reusing the id_rsa File on the Local Machine ```toml # frps.toml sshTunnelGateway.bindPort = 2200 sshTunnelGateway.privateKeyFile = "/home/user/.ssh/id_rsa" ``` During the SSH protocol handshake, public keys are exchanged for data encryption. Therefore, the SSH server on the frps side needs to specify a private key file, which can be reused from an existing file on the local machine. If the privateKeyFile field is empty, frps will automatically create an RSA private key file. #### Specifying the Auto-Generated Private Key File Path ```toml # frps.toml sshTunnelGateway.bindPort = 2200 sshTunnelGateway.autoGenPrivateKeyPath = "/var/frp/ssh-private-key-file" ``` frps will automatically create a private key file and store it at the specified path. Note: Changing the private key file in frps can cause SSH client login failures. If you need to log in successfully, you can delete the old records from the `/home/user/.ssh/known_hosts` file. #### Using an Existing authorized_keys File for SSH Public Key Authentication ```toml # frps.toml sshTunnelGateway.bindPort = 2200 sshTunnelGateway.authorizedKeysFile = "/home/user/.ssh/authorized_keys" ``` The authorizedKeysFile is the file used for SSH public key authentication, which contains the public key information for users, with one key per line. If authorizedKeysFile is empty, frps won't perform any authentication for SSH clients. Frps does not support SSH username and password authentication. You can reuse an existing `authorized_keys` file on your local machine for client authentication. Note: authorizedKeysFile is for user authentication during the SSH login phase, while the token is for frps authentication. These two authentication methods are independent. SSH authentication comes first, followed by frps token authentication. It is strongly recommended to enable at least one of them. If authorizedKeysFile is empty, it is highly recommended to enable token authentication in frps to avoid security risks. #### Using a Custom authorized_keys File for SSH Public Key Authentication ```toml # frps.toml sshTunnelGateway.bindPort = 2200 sshTunnelGateway.authorizedKeysFile = "/var/frps/custom_authorized_keys_file" ``` Specify the path to a custom `authorized_keys` file. Note that changes to the authorizedKeysFile file may result in SSH authentication failures. You may need to re-add the public key information to the authorizedKeysFile. ================================================ FILE: doc/virtual_net.md ================================================ # Virtual Network (VirtualNet) *Alpha feature added in v0.62.0* The VirtualNet feature enables frp to create and manage virtual network connections between clients and visitors through a TUN interface. This allows for IP-level routing between machines, extending frp beyond simple port forwarding to support full network connectivity. > **Note**: VirtualNet is an Alpha stage feature and is currently unstable. Its configuration methods and functionality may be adjusted and changed at any time in subsequent versions. Do not use this feature in production environments; it is only recommended for testing and evaluation purposes. ## Enabling VirtualNet Since VirtualNet is currently an alpha feature, you need to enable it with feature gates in your configuration: ```toml # frpc.toml featureGates = { VirtualNet = true } ``` ## Basic Configuration To use the virtual network capabilities: 1. First, configure your frpc with a virtual network address: ```toml # frpc.toml serverAddr = "x.x.x.x" serverPort = 7000 featureGates = { VirtualNet = true } # Configure the virtual network interface virtualNet.address = "100.86.0.1/24" ``` 2. For client proxies, use the `virtual_net` plugin: ```toml # frpc.toml (server side) [[proxies]] name = "vnet-server" type = "stcp" secretKey = "your-secret-key" [proxies.plugin] type = "virtual_net" ``` 3. For visitor connections, configure the `virtual_net` visitor plugin: ```toml # frpc.toml (client side) serverAddr = "x.x.x.x" serverPort = 7000 featureGates = { VirtualNet = true } # Configure the virtual network interface virtualNet.address = "100.86.0.2/24" [[visitors]] name = "vnet-visitor" type = "stcp" serverName = "vnet-server" secretKey = "your-secret-key" bindPort = -1 [visitors.plugin] type = "virtual_net" destinationIP = "100.86.0.1" ``` ## Requirements and Limitations - **Permissions**: Creating a TUN interface requires elevated permissions (root/admin) - **Platform Support**: Currently supported on Linux and macOS - **Default Status**: As an alpha feature, VirtualNet is disabled by default - **Configuration**: A valid IP/CIDR must be provided for each endpoint in the virtual network ================================================ FILE: dockerfiles/Dockerfile-for-frpc ================================================ FROM node:22 AS web-builder WORKDIR /web/frpc COPY web/frpc/ ./ RUN npm install RUN npm run build FROM golang:1.25 AS building COPY . /building COPY --from=web-builder /web/frpc/dist /building/web/frpc/dist WORKDIR /building RUN env CGO_ENABLED=0 go build -trimpath -ldflags "-s -w" -tags frpc -o bin/frpc ./cmd/frpc FROM alpine:3 RUN apk add --no-cache tzdata COPY --from=building /building/bin/frpc /usr/bin/frpc ENTRYPOINT ["/usr/bin/frpc"] ================================================ FILE: dockerfiles/Dockerfile-for-frps ================================================ FROM node:22 AS web-builder WORKDIR /web/frps COPY web/frps/ ./ RUN npm install RUN npm run build FROM golang:1.25 AS building COPY . /building COPY --from=web-builder /web/frps/dist /building/web/frps/dist WORKDIR /building RUN env CGO_ENABLED=0 go build -trimpath -ldflags "-s -w" -tags frps -o bin/frps ./cmd/frps FROM alpine:3 RUN apk add --no-cache tzdata COPY --from=building /building/bin/frps /usr/bin/frps ENTRYPOINT ["/usr/bin/frps"] ================================================ FILE: go.mod ================================================ module github.com/fatedier/frp go 1.25.0 require ( github.com/armon/go-socks5 v0.0.0-20160902184237-e75332964ef5 github.com/coreos/go-oidc/v3 v3.14.1 github.com/fatedier/golib v0.5.1 github.com/google/uuid v1.6.0 github.com/gorilla/mux v1.8.1 github.com/gorilla/websocket v1.5.0 github.com/hashicorp/yamux v0.1.1 github.com/onsi/ginkgo/v2 v2.23.4 github.com/onsi/gomega v1.36.3 github.com/pelletier/go-toml/v2 v2.2.0 github.com/pion/stun/v2 v2.0.0 github.com/pires/go-proxyproto v0.7.0 github.com/prometheus/client_golang v1.19.1 github.com/quic-go/quic-go v0.55.0 github.com/rodaine/table v1.2.0 github.com/samber/lo v1.47.0 github.com/songgao/water v0.0.0-20200317203138-2b4b6d7c09d8 github.com/spf13/cobra v1.8.0 github.com/spf13/pflag v1.0.5 github.com/stretchr/testify v1.10.0 github.com/tidwall/gjson v1.17.1 github.com/vishvananda/netlink v1.3.0 github.com/xtaci/kcp-go/v5 v5.6.13 golang.org/x/crypto v0.41.0 golang.org/x/net v0.43.0 golang.org/x/oauth2 v0.28.0 golang.org/x/sync v0.16.0 golang.org/x/time v0.5.0 golang.zx2c4.com/wireguard v0.0.0-20231211153847-12269c276173 gopkg.in/ini.v1 v1.67.0 k8s.io/apimachinery v0.28.8 k8s.io/client-go v0.28.8 ) require ( github.com/Azure/go-ntlmssp v0.0.0-20221128193559-754e69321358 // indirect github.com/beorn7/perks v1.0.1 // indirect github.com/cespare/xxhash/v2 v2.2.0 // indirect github.com/davecgh/go-spew v1.1.1 // indirect github.com/go-jose/go-jose/v4 v4.0.5 // indirect github.com/go-logr/logr v1.4.2 // indirect github.com/go-task/slim-sprig/v3 v3.0.0 // indirect github.com/golang/snappy v0.0.4 // indirect github.com/google/go-cmp v0.7.0 // indirect github.com/google/pprof v0.0.0-20250403155104-27863c87afa6 // indirect github.com/inconshreveable/mousetrap v1.1.0 // indirect github.com/klauspost/cpuid/v2 v2.2.6 // indirect github.com/klauspost/reedsolomon v1.12.0 // indirect github.com/pion/dtls/v2 v2.2.7 // indirect github.com/pion/logging v0.2.2 // indirect github.com/pion/transport/v2 v2.2.1 // indirect github.com/pion/transport/v3 v3.0.1 // indirect github.com/pkg/errors v0.9.1 // indirect github.com/pmezard/go-difflib v1.0.0 // indirect github.com/prometheus/client_model v0.5.0 // indirect github.com/prometheus/common v0.48.0 // indirect github.com/prometheus/procfs v0.12.0 // indirect github.com/templexxx/cpu v0.1.1 // indirect github.com/templexxx/xorsimd v0.4.3 // indirect github.com/tidwall/match v1.1.1 // indirect github.com/tidwall/pretty v1.2.0 // indirect github.com/tjfoc/gmsm v1.4.1 // indirect github.com/vishvananda/netns v0.0.4 // indirect go.uber.org/automaxprocs v1.6.0 // indirect golang.org/x/mod v0.27.0 // indirect golang.org/x/sys v0.35.0 // indirect golang.org/x/text v0.28.0 // indirect golang.org/x/tools v0.36.0 // indirect golang.zx2c4.com/wintun v0.0.0-20230126152724-0fa3db229ce2 // indirect google.golang.org/protobuf v1.36.5 // indirect gopkg.in/yaml.v2 v2.4.0 // indirect gopkg.in/yaml.v3 v3.0.1 // indirect k8s.io/utils v0.0.0-20230406110748-d93618cff8a2 // indirect sigs.k8s.io/json v0.0.0-20221116044647-bc3834ca7abd // indirect sigs.k8s.io/yaml v1.3.0 // indirect ) // TODO(fatedier): Temporary use the modified version, update to the official version after merging into the official repository. replace github.com/hashicorp/yamux => github.com/fatedier/yamux v0.0.0-20250825093530-d0154be01cd6 ================================================ FILE: go.sum ================================================ cloud.google.com/go v0.26.0/go.mod h1:aQUYkXzVsufM+DwF1aE+0xfcU+56JwCaLick0ClmMTw= github.com/Azure/go-ntlmssp v0.0.0-20221128193559-754e69321358 h1:mFRzDkZVAjdal+s7s0MwaRv9igoPqLRdzOLzw/8Xvq8= github.com/Azure/go-ntlmssp v0.0.0-20221128193559-754e69321358/go.mod h1:chxPXzSsl7ZWRAuOIE23GDNzjWuZquvFlgA8xmpunjU= github.com/BurntSushi/toml v0.3.1/go.mod h1:xHWCNGjB5oqiDr8zfno3MHue2Ht5sIBksp03qcyfWMU= github.com/armon/go-socks5 v0.0.0-20160902184237-e75332964ef5 h1:0CwZNZbxp69SHPdPJAN/hZIm0C4OItdklCFmMRWYpio= github.com/armon/go-socks5 v0.0.0-20160902184237-e75332964ef5/go.mod h1:wHh0iHkYZB8zMSxRWpUBQtwG5a7fFgvEO+odwuTv2gs= github.com/beorn7/perks v1.0.1 h1:VlbKKnNfV8bJzeqoa4cOKqO6bYr3WgKZxO8Z16+hsOM= github.com/beorn7/perks v1.0.1/go.mod h1:G2ZrVWU2WbWT9wwq4/hrbKbnv/1ERSJQ0ibhJ6rlkpw= github.com/census-instrumentation/opencensus-proto v0.2.1/go.mod h1:f6KPmirojxKA12rnyqOA5BBL4O983OfeGPqjHWSTneU= github.com/cespare/xxhash/v2 v2.2.0 h1:DC2CZ1Ep5Y4k3ZQ899DldepgrayRUGE6BBZ/cd9Cj44= github.com/cespare/xxhash/v2 v2.2.0/go.mod h1:VGX0DQ3Q6kWi7AoAeZDth3/j3BFtOZR5XLFGgcrjCOs= github.com/client9/misspell v0.3.4/go.mod h1:qj6jICC3Q7zFZvVWo7KLAzC3yx5G7kyvSDkc90ppPyw= github.com/cncf/udpa/go v0.0.0-20191209042840-269d4d468f6f/go.mod h1:M8M6+tZqaGXZJjfX53e64911xZQV5JYwmTeXPW+k8Sc= github.com/coreos/go-oidc/v3 v3.14.1 h1:9ePWwfdwC4QKRlCXsJGou56adA/owXczOzwKdOumLqk= github.com/coreos/go-oidc/v3 v3.14.1/go.mod h1:HaZ3szPaZ0e4r6ebqvsLWlk2Tn+aejfmrfah6hnSYEU= github.com/cpuguy83/go-md2man/v2 v2.0.3/go.mod h1:tgQtvFlXSQOSOSIRvRPT7W67SCa46tRHOmNcaadrF8o= github.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c= github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= github.com/envoyproxy/go-control-plane v0.9.0/go.mod h1:YTl/9mNaCwkRvm6d1a2C3ymFceY/DCBVvsKhRF0iEA4= github.com/envoyproxy/go-control-plane v0.9.4/go.mod h1:6rpuAdCZL397s3pYoYcLgu1mIlRU8Am5FuJP05cCM98= github.com/envoyproxy/protoc-gen-validate v0.1.0/go.mod h1:iSmxcyjqTsJpI2R4NaDN7+kN2VEUnK/pcBlmesArF7c= github.com/fatedier/golib v0.5.1 h1:hcKAnaw5mdI/1KWRGejxR+i1Hn/NvbY5UsMKDr7o13M= github.com/fatedier/golib v0.5.1/go.mod h1:W6kIYkIFxHsTzbgqg5piCxIiDo4LzwgTY6R5W8l9NFQ= github.com/fatedier/yamux v0.0.0-20250825093530-d0154be01cd6 h1:u92UUy6FURPmNsMBUuongRWC0rBqN6gd01Dzu+D21NE= github.com/fatedier/yamux v0.0.0-20250825093530-d0154be01cd6/go.mod h1:c5/tk6G0dSpXGzJN7Wk1OEie8grdSJAmeawId9Zvd34= github.com/go-jose/go-jose/v4 v4.0.5 h1:M6T8+mKZl/+fNNuFHvGIzDz7BTLQPIounk/b9dw3AaE= github.com/go-jose/go-jose/v4 v4.0.5/go.mod h1:s3P1lRrkT8igV8D9OjyL4WRyHvjB6a4JSllnOrmmBOA= github.com/go-logr/logr v1.4.2 h1:6pFjapn8bFcIbiKo3XT4j/BhANplGihG6tvd+8rYgrY= github.com/go-logr/logr v1.4.2/go.mod h1:9T104GzyrTigFIr8wt5mBrctHMim0Nb2HLGrmQ40KvY= github.com/go-task/slim-sprig/v3 v3.0.0 h1:sUs3vkvUymDpBKi3qH1YSqBQk9+9D/8M2mN1vB6EwHI= github.com/go-task/slim-sprig/v3 v3.0.0/go.mod h1:W848ghGpv3Qj3dhTPRyJypKRiqCdHZiAzKg9hl15HA8= github.com/golang/glog v0.0.0-20160126235308-23def4e6c14b/go.mod h1:SBH7ygxi8pfUlaOkMMuAQtPIUF8ecWP5IEl/CR7VP2Q= github.com/golang/mock v1.1.1/go.mod h1:oTYuIxOrZwtPieC+H1uAHpcLFnEyAGVDL/k47Jfbm0A= github.com/golang/protobuf v1.2.0/go.mod h1:6lQm79b+lXiMfvg/cZm0SGofjICqVBUtrP5yJMmIC1U= github.com/golang/protobuf v1.3.2/go.mod h1:6lQm79b+lXiMfvg/cZm0SGofjICqVBUtrP5yJMmIC1U= github.com/golang/protobuf v1.3.3/go.mod h1:vzj43D7+SQXF/4pzW/hwtAqwc6iTitCiVSaWz5lYuqw= github.com/golang/protobuf v1.4.0-rc.1/go.mod h1:ceaxUfeHdC40wWswd/P6IGgMaK3YpKi5j83Wpe3EHw8= github.com/golang/protobuf v1.4.0-rc.1.0.20200221234624-67d41d38c208/go.mod h1:xKAWHe0F5eneWXFV3EuXVDTCmh+JuBKY0li0aMyXATA= github.com/golang/protobuf v1.4.0-rc.2/go.mod h1:LlEzMj4AhA7rCAGe4KMBDvJI+AwstrUpVNzEA03Pprs= github.com/golang/protobuf v1.4.0-rc.4.0.20200313231945-b860323f09d0/go.mod h1:WU3c8KckQ9AFe+yFwt9sWVRKCVIyN9cPHBJSNnbL67w= github.com/golang/protobuf v1.4.0/go.mod h1:jodUvKwWbYaEsadDk5Fwe5c77LiNKVO9IDvqG2KuDX0= github.com/golang/protobuf v1.4.2/go.mod h1:oDoupMAO8OvCJWAcko0GGGIgR6R6ocIYbsSw735rRwI= github.com/golang/snappy v0.0.4 h1:yAGX7huGHXlcLOEtBnF4w7FQwA26wojNCwOYAEhLjQM= github.com/golang/snappy v0.0.4/go.mod h1:/XxbfmMg8lxefKM7IXC3fBNl/7bRcc72aCRzEWrmP2Q= github.com/google/btree v1.0.1 h1:gK4Kx5IaGY9CD5sPJ36FHiBJ6ZXl0kilRiiCj+jdYp4= github.com/google/btree v1.0.1/go.mod h1:xXMiIv4Fb/0kKde4SpL7qlzvu5cMJDRkFDxJfI9uaxA= github.com/google/go-cmp v0.2.0/go.mod h1:oXzfMopK8JAjlY9xF4vHSVASa0yLyX7SntLO5aqRK0M= github.com/google/go-cmp v0.3.0/go.mod h1:8QqcDgzrUqlUb/G2PQTWiueGozuR1884gddMywk6iLU= github.com/google/go-cmp v0.3.1/go.mod h1:8QqcDgzrUqlUb/G2PQTWiueGozuR1884gddMywk6iLU= github.com/google/go-cmp v0.4.0/go.mod h1:v8dTdLbMG2kIc/vJvl+f65V22dbkXbowE6jgT/gNBxE= github.com/google/go-cmp v0.6.0/go.mod h1:17dUlkBOakJ0+DkrSSNjCkIjxS6bF9zb3elmeNGIjoY= github.com/google/go-cmp v0.7.0 h1:wk8382ETsv4JYUZwIsn6YpYiWiBsYLSJiTsyBybVuN8= github.com/google/go-cmp v0.7.0/go.mod h1:pXiqmnSA92OHEEa9HXL2W4E7lf9JzCmGVUdgjX3N/iU= github.com/google/pprof v0.0.0-20250403155104-27863c87afa6 h1:BHT72Gu3keYf3ZEu2J0b1vyeLSOYI8bm5wbJM/8yDe8= github.com/google/pprof v0.0.0-20250403155104-27863c87afa6/go.mod h1:boTsfXsheKC2y+lKOCMpSfarhxDeIzfZG1jqGcPl3cA= github.com/google/uuid v1.6.0 h1:NIvaJDMOsjHA8n1jAhLSgzrAzy1Hgr+hNrb57e+94F0= github.com/google/uuid v1.6.0/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo= github.com/gorilla/mux v1.8.1 h1:TuBL49tXwgrFYWhqrNgrUNEY92u81SPhu7sTdzQEiWY= github.com/gorilla/mux v1.8.1/go.mod h1:AKf9I4AEqPTmMytcMc0KkNouC66V3BtZ4qD5fmWSiMQ= github.com/gorilla/websocket v1.5.0 h1:PPwGk2jz7EePpoHN/+ClbZu8SPxiqlu12wZP/3sWmnc= github.com/gorilla/websocket v1.5.0/go.mod h1:YR8l580nyteQvAITg2hZ9XVh4b55+EU/adAjf1fMHhE= github.com/inconshreveable/mousetrap v1.1.0 h1:wN+x4NVGpMsO7ErUn/mUI3vEoE6Jt13X2s0bqwp9tc8= github.com/inconshreveable/mousetrap v1.1.0/go.mod h1:vpF70FUmC8bwa3OWnCshd2FqLfsEA9PFc4w1p2J65bw= github.com/klauspost/cpuid/v2 v2.2.6 h1:ndNyv040zDGIDh8thGkXYjnFtiN02M1PVVF+JE/48xc= github.com/klauspost/cpuid/v2 v2.2.6/go.mod h1:Lcz8mBdAVJIBVzewtcLocK12l3Y+JytZYpaMropDUws= github.com/klauspost/reedsolomon v1.12.0 h1:I5FEp3xSwVCcEh3F5A7dofEfhXdF/bWhQWPH+XwBFno= github.com/klauspost/reedsolomon v1.12.0/go.mod h1:EPLZJeh4l27pUGC3aXOjheaoh1I9yut7xTURiW3LQ9Y= github.com/kr/pretty v0.3.1 h1:flRD4NNwYAUpkphVc1HcthR4KEIFJ65n8Mw5qdRn3LE= github.com/kr/pretty v0.3.1/go.mod h1:hoEshYVHaxMs3cyo3Yncou5ZscifuDolrwPKZanG3xk= github.com/kr/text v0.2.0 h1:5Nx0Ya0ZqY2ygV366QzturHI13Jq95ApcVaJBhpS+AY= github.com/kr/text v0.2.0/go.mod h1:eLer722TekiGuMkidMxC/pM04lWEeraHUUmBw8l2grE= github.com/mattn/go-runewidth v0.0.15 h1:UNAjwbU9l54TA3KzvqLGxwWjHmMgBUVhBiTjelZgg3U= github.com/mattn/go-runewidth v0.0.15/go.mod h1:Jdepj2loyihRzMpdS35Xk/zdY8IAYHsh153qUoGf23w= github.com/onsi/ginkgo/v2 v2.23.4 h1:ktYTpKJAVZnDT4VjxSbiBenUjmlL/5QkBEocaWXiQus= github.com/onsi/ginkgo/v2 v2.23.4/go.mod h1:Bt66ApGPBFzHyR+JO10Zbt0Gsp4uWxu5mIOTusL46e8= github.com/onsi/gomega v1.36.3 h1:hID7cr8t3Wp26+cYnfcjR6HpJ00fdogN6dqZ1t6IylU= github.com/onsi/gomega v1.36.3/go.mod h1:8D9+Txp43QWKhM24yyOBEdpkzN8FvJyAwecBgsU4KU0= github.com/pelletier/go-toml/v2 v2.2.0 h1:QLgLl2yMN7N+ruc31VynXs1vhMZa7CeHHejIeBAsoHo= github.com/pelletier/go-toml/v2 v2.2.0/go.mod h1:1t835xjRzz80PqgE6HHgN2JOsmgYu/h4qDAS4n929Rs= github.com/pion/dtls/v2 v2.2.7 h1:cSUBsETxepsCSFSxC3mc/aDo14qQLMSL+O6IjG28yV8= github.com/pion/dtls/v2 v2.2.7/go.mod h1:8WiMkebSHFD0T+dIU+UeBaoV7kDhOW5oDCzZ7WZ/F9s= github.com/pion/logging v0.2.2 h1:M9+AIj/+pxNsDfAT64+MAVgJO0rsyLnoJKCqf//DoeY= github.com/pion/logging v0.2.2/go.mod h1:k0/tDVsRCX2Mb2ZEmTqNa7CWsQPc+YYCB7Q+5pahoms= github.com/pion/stun/v2 v2.0.0 h1:A5+wXKLAypxQri59+tmQKVs7+l6mMM+3d+eER9ifRU0= github.com/pion/stun/v2 v2.0.0/go.mod h1:22qRSh08fSEttYUmJZGlriq9+03jtVmXNODgLccj8GQ= github.com/pion/transport/v2 v2.2.1 h1:7qYnCBlpgSJNYMbLCKuSY9KbQdBFoETvPNETv0y4N7c= github.com/pion/transport/v2 v2.2.1/go.mod h1:cXXWavvCnFF6McHTft3DWS9iic2Mftcz1Aq29pGcU5g= github.com/pion/transport/v3 v3.0.1 h1:gDTlPJwROfSfz6QfSi0ZmeCSkFcnWWiiR9ES0ouANiM= github.com/pion/transport/v3 v3.0.1/go.mod h1:UY7kiITrlMv7/IKgd5eTUcaahZx5oUN3l9SzK5f5xE0= github.com/pires/go-proxyproto v0.7.0 h1:IukmRewDQFWC7kfnb66CSomk2q/seBuilHBYFwyq0Hs= github.com/pires/go-proxyproto v0.7.0/go.mod h1:Vz/1JPY/OACxWGQNIRY2BeyDmpoaWmEP40O9LbuiFR4= github.com/pkg/errors v0.9.1 h1:FEBLx1zS214owpjy7qsBeixbURkuhQAwrK5UwLGTwt4= github.com/pkg/errors v0.9.1/go.mod h1:bwawxfHBFNV+L2hUp1rHADufV3IMtnDRdf1r5NINEl0= github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM= github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4= github.com/prashantv/gostub v1.1.0 h1:BTyx3RfQjRHnUWaGF9oQos79AlQ5k8WNktv7VGvVH4g= github.com/prashantv/gostub v1.1.0/go.mod h1:A5zLQHz7ieHGG7is6LLXLz7I8+3LZzsrV0P1IAHhP5U= github.com/prometheus/client_golang v1.19.1 h1:wZWJDwK+NameRJuPGDhlnFgx8e8HN3XHQeLaYJFJBOE= github.com/prometheus/client_golang v1.19.1/go.mod h1:mP78NwGzrVks5S2H6ab8+ZZGJLZUq1hoULYBAYBw1Ho= github.com/prometheus/client_model v0.0.0-20190812154241-14fe0d1b01d4/go.mod h1:xMI15A0UPsDsEKsMN9yxemIoYk6Tm2C1GtYGdfGttqA= github.com/prometheus/client_model v0.5.0 h1:VQw1hfvPvk3Uv6Qf29VrPF32JB6rtbgI6cYPYQjL0Qw= github.com/prometheus/client_model v0.5.0/go.mod h1:dTiFglRmd66nLR9Pv9f0mZi7B7fk5Pm3gvsjB5tr+kI= github.com/prometheus/common v0.48.0 h1:QO8U2CdOzSn1BBsmXJXduaaW+dY/5QLjfB8svtSzKKE= github.com/prometheus/common v0.48.0/go.mod h1:0/KsvlIEfPQCQ5I2iNSAWKPZziNCvRs5EC6ILDTlAPc= github.com/prometheus/procfs v0.12.0 h1:jluTpSng7V9hY0O2R9DzzJHYb2xULk9VTR1V1R/k6Bo= github.com/prometheus/procfs v0.12.0/go.mod h1:pcuDEFsWDnvcgNzo4EEweacyhjeA9Zk3cnaOZAZEfOo= github.com/quic-go/quic-go v0.55.0 h1:zccPQIqYCXDt5NmcEabyYvOnomjs8Tlwl7tISjJh9Mk= github.com/quic-go/quic-go v0.55.0/go.mod h1:DR51ilwU1uE164KuWXhinFcKWGlEjzys2l8zUl5Ss1U= github.com/rivo/uniseg v0.2.0 h1:S1pD9weZBuJdFmowNwbpi7BJ8TNftyUImj/0WQi72jY= github.com/rivo/uniseg v0.2.0/go.mod h1:J6wj4VEh+S6ZtnVlnTBMWIodfgj8LQOQFoIToxlJtxc= github.com/rodaine/table v1.2.0 h1:38HEnwK4mKSHQJIkavVj+bst1TEY7j9zhLMWu4QJrMA= github.com/rodaine/table v1.2.0/go.mod h1:wejb/q/Yd4T/SVmBSRMr7GCq3KlcZp3gyNYdLSBhkaE= github.com/rogpeppe/go-internal v1.10.0 h1:TMyTOH3F/DB16zRVcYyreMH6GnZZrwQVAoYjRBZyWFQ= github.com/rogpeppe/go-internal v1.10.0/go.mod h1:UQnix2H7Ngw/k4C5ijL5+65zddjncjaFoBhdsK/akog= github.com/russross/blackfriday/v2 v2.1.0/go.mod h1:+Rmxgy9KzJVeS9/2gXHxylqXiyQDYRxCVz55jmeOWTM= github.com/samber/lo v1.47.0 h1:z7RynLwP5nbyRscyvcD043DWYoOcYRv3mV8lBeqOCLc= github.com/samber/lo v1.47.0/go.mod h1:RmDH9Ct32Qy3gduHQuKJ3gW1fMHAnE/fAzQuf6He5cU= github.com/songgao/water v0.0.0-20200317203138-2b4b6d7c09d8 h1:TG/diQgUe0pntT/2D9tmUCz4VNwm9MfrtPr0SU2qSX8= github.com/songgao/water v0.0.0-20200317203138-2b4b6d7c09d8/go.mod h1:P5HUIBuIWKbyjl083/loAegFkfbFNx5i2qEP4CNbm7E= github.com/spf13/cobra v1.8.0 h1:7aJaZx1B85qltLMc546zn58BxxfZdR/W22ej9CFoEf0= github.com/spf13/cobra v1.8.0/go.mod h1:WXLWApfZ71AjXPya3WOlMsY9yMs7YeiHhFVlvLyhcho= github.com/spf13/pflag v1.0.5 h1:iy+VFUOCP1a+8yFto/drg2CJ5u0yRoB7fZw3DKv/JXA= github.com/spf13/pflag v1.0.5/go.mod h1:McXfInJRrz4CZXVZOBLb0bTZqETkiAhM9Iw0y3An2Bg= github.com/stretchr/objx v0.1.0/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME= github.com/stretchr/objx v0.4.0/go.mod h1:YvHI0jy2hoMjB+UWwv71VJQ9isScKT/TqJzVSSt89Yw= github.com/stretchr/objx v0.5.0/go.mod h1:Yh+to48EsGEfYuaHDzXPcE3xhTkx73EhmCGUpEOglKo= github.com/stretchr/objx v0.5.2/go.mod h1:FRsXN1f5AsAjCGJKqEizvkpNtU+EGNCLh3NxZ/8L+MA= github.com/stretchr/testify v1.7.1/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg= github.com/stretchr/testify v1.8.0/go.mod h1:yNjHg4UonilssWZ8iaSj1OCr/vHnekPRkoO+kdMU+MU= github.com/stretchr/testify v1.8.3/go.mod h1:sz/lmYIOXD/1dqDmKjjqLyZ2RngseejIcXlSw2iwfAo= github.com/stretchr/testify v1.8.4/go.mod h1:sz/lmYIOXD/1dqDmKjjqLyZ2RngseejIcXlSw2iwfAo= github.com/stretchr/testify v1.9.0/go.mod h1:r2ic/lqez/lEtzL7wO/rwa5dbSLXVDPFyf8C91i36aY= github.com/stretchr/testify v1.10.0 h1:Xv5erBjTwe/5IxqUQTdXv5kgmIvbHo3QQyRwhJsOfJA= github.com/stretchr/testify v1.10.0/go.mod h1:r2ic/lqez/lEtzL7wO/rwa5dbSLXVDPFyf8C91i36aY= github.com/templexxx/cpu v0.1.1 h1:isxHaxBXpYFWnk2DReuKkigaZyrjs2+9ypIdGP4h+HI= github.com/templexxx/cpu v0.1.1/go.mod h1:w7Tb+7qgcAlIyX4NhLuDKt78AHA5SzPmq0Wj6HiEnnk= github.com/templexxx/xorsimd v0.4.3 h1:9AQTFHd7Bhk3dIT7Al2XeBX5DWOvsUPZCuhyAtNbHjU= github.com/templexxx/xorsimd v0.4.3/go.mod h1:oZQcD6RFDisW2Am58dSAGwwL6rHjbzrlu25VDqfWkQg= github.com/tidwall/gjson v1.17.1 h1:wlYEnwqAHgzmhNUFfw7Xalt2JzQvsMx2Se4PcoFCT/U= github.com/tidwall/gjson v1.17.1/go.mod h1:/wbyibRr2FHMks5tjHJ5F8dMZh3AcwJEMf5vlfC0lxk= github.com/tidwall/match v1.1.1 h1:+Ho715JplO36QYgwN9PGYNhgZvoUSc9X2c80KVTi+GA= github.com/tidwall/match v1.1.1/go.mod h1:eRSPERbgtNPcGhD8UCthc6PmLEQXEWd3PRB5JTxsfmM= github.com/tidwall/pretty v1.2.0 h1:RWIZEg2iJ8/g6fDDYzMpobmaoGh5OLl4AXtGUGPcqCs= github.com/tidwall/pretty v1.2.0/go.mod h1:ITEVvHYasfjBbM0u2Pg8T2nJnzm8xPwvNhhsoaGGjNU= github.com/tjfoc/gmsm v1.4.1 h1:aMe1GlZb+0bLjn+cKTPEvvn9oUEBlJitaZiiBwsbgho= github.com/tjfoc/gmsm v1.4.1/go.mod h1:j4INPkHWMrhJb38G+J6W4Tw0AbuN8Thu3PbdVYhVcTE= github.com/vishvananda/netlink v1.3.0 h1:X7l42GfcV4S6E4vHTsw48qbrV+9PVojNfIhZcwQdrZk= github.com/vishvananda/netlink v1.3.0/go.mod h1:i6NetklAujEcC6fK0JPjT8qSwWyO0HLn4UKG+hGqeJs= github.com/vishvananda/netns v0.0.4 h1:Oeaw1EM2JMxD51g9uhtC0D7erkIjgmj8+JZc26m1YX8= github.com/vishvananda/netns v0.0.4/go.mod h1:SpkAiCQRtJ6TvvxPnOSyH3BMl6unz3xZlaprSwhNNJM= github.com/xtaci/kcp-go/v5 v5.6.13 h1:FEjtz9+D4p8t2x4WjciGt/jsIuhlWjjgPCCWjrVR4Hk= github.com/xtaci/kcp-go/v5 v5.6.13/go.mod h1:75S1AKYYzNUSXIv30h+jPKJYZUwqpfvLshu63nCNSOM= github.com/xtaci/lossyconn v0.0.0-20200209145036-adba10fffc37 h1:EWU6Pktpas0n8lLQwDsRyZfmkPeRbdgPtW609es+/9E= github.com/xtaci/lossyconn v0.0.0-20200209145036-adba10fffc37/go.mod h1:HpMP7DB2CyokmAh4lp0EQnnWhmycP/TvwBGzvuie+H0= github.com/yuin/goldmark v1.4.13/go.mod h1:6yULJ656Px+3vBD8DxQVa3kxgyrAnzto9xy5taEt/CY= go.uber.org/automaxprocs v1.6.0 h1:O3y2/QNTOdbF+e/dpXNNW7Rx2hZ4sTIPyybbxyNqTUs= go.uber.org/automaxprocs v1.6.0/go.mod h1:ifeIMSnPZuznNm6jmdzmU3/bfk01Fe2fotchwEFJ8r8= go.uber.org/mock v0.5.2 h1:LbtPTcP8A5k9WPXj54PPPbjcI4Y6lhyOZXn+VS7wNko= go.uber.org/mock v0.5.2/go.mod h1:wLlUxC2vVTPTaE3UD51E0BGOAElKrILxhVSDYQLld5o= golang.org/x/crypto v0.0.0-20190308221718-c2843e01d9a2/go.mod h1:djNgcEr1/C05ACkg1iLfiJU5Ep61QUkGW8qpdssI0+w= golang.org/x/crypto v0.0.0-20200622213623-75b288015ac9/go.mod h1:LzIPMQfyMNhhGPhUkYOs5KpL4U8rLKemX1yGLhDgUto= golang.org/x/crypto v0.0.0-20201012173705-84dcc777aaee/go.mod h1:LzIPMQfyMNhhGPhUkYOs5KpL4U8rLKemX1yGLhDgUto= golang.org/x/crypto v0.0.0-20210921155107-089bfa567519/go.mod h1:GvvjBRRGRdwPK5ydBHafDWAxML/pGHZbMvKqRZ5+Abc= golang.org/x/crypto v0.8.0/go.mod h1:mRqEX+O9/h5TFCrQhkgjo2yKi0yYA+9ecGkdQoHrywE= golang.org/x/crypto v0.12.0/go.mod h1:NF0Gs7EO5K4qLn+Ylc+fih8BSTeIjAP05siRnAh98yw= golang.org/x/crypto v0.41.0 h1:WKYxWedPGCTVVl5+WHSSrOBT0O8lx32+zxmHxijgXp4= golang.org/x/crypto v0.41.0/go.mod h1:pO5AFd7FA68rFak7rOAGVuygIISepHftHnr8dr6+sUc= golang.org/x/exp v0.0.0-20190121172915-509febef88a4/go.mod h1:CJ0aWSM057203Lf6IL+f9T1iT9GByDxfZKAQTCR3kQA= golang.org/x/lint v0.0.0-20181026193005-c67002cb31c3/go.mod h1:UVdnD1Gm6xHRNCYTkRU2/jEulfH38KcIWyp/GAMgvoE= golang.org/x/lint v0.0.0-20190227174305-5b3e6a55c961/go.mod h1:wehouNa3lNwaWXcvxsM5YxQ5yQlVC4a0KAMCusXpPoU= golang.org/x/lint v0.0.0-20190313153728-d0100b6bd8b3/go.mod h1:6SW0HCj/g11FgYtHlgUYUwCkIfeOF89ocIRzGO/8vkc= golang.org/x/mod v0.6.0-dev.0.20220419223038-86c51ed26bb4/go.mod h1:jJ57K6gSWd91VN4djpZkiMVwK6gcyfeH4XE8wZrZaV4= golang.org/x/mod v0.8.0/go.mod h1:iBbtSCu2XBx23ZKBPSOrRkjjQPZFPuis4dIYUhu/chs= golang.org/x/mod v0.27.0 h1:kb+q2PyFnEADO2IEF935ehFUXlWiNjJWtRNgBLSfbxQ= golang.org/x/mod v0.27.0/go.mod h1:rWI627Fq0DEoudcK+MBkNkCe0EetEaDSwJJkCcjpazc= golang.org/x/net v0.0.0-20180724234803-3673e40ba225/go.mod h1:mL1N/T3taQHkDXs73rZJwtUhF3w3ftmwwsq0BUmARs4= golang.org/x/net v0.0.0-20180826012351-8a410e7b638d/go.mod h1:mL1N/T3taQHkDXs73rZJwtUhF3w3ftmwwsq0BUmARs4= golang.org/x/net v0.0.0-20190213061140-3a22650c66bd/go.mod h1:mL1N/T3taQHkDXs73rZJwtUhF3w3ftmwwsq0BUmARs4= golang.org/x/net v0.0.0-20190311183353-d8887717615a/go.mod h1:t9HGtf8HONx5eT2rtn7q6eTqICYqUVnKs3thJo3Qplg= golang.org/x/net v0.0.0-20190404232315-eb5bcb51f2a3/go.mod h1:t9HGtf8HONx5eT2rtn7q6eTqICYqUVnKs3thJo3Qplg= golang.org/x/net v0.0.0-20190620200207-3b0461eec859/go.mod h1:z5CRVTTTmAJ677TzLLGU+0bjPO0LkuOLi4/5GtJWs/s= golang.org/x/net v0.0.0-20201010224723-4f7140c49acb/go.mod h1:sp8m0HH+o8qH0wwXwYZr8TS3Oi6o0r6Gce1SSxlDquU= golang.org/x/net v0.0.0-20210226172049-e18ecbb05110/go.mod h1:m0MpNAwzfU5UDzcl9v0D8zg8gWTRqZa9RBIspLL5mdg= golang.org/x/net v0.0.0-20220722155237-a158d28d115b/go.mod h1:XRhObCWvk6IyKnWLug+ECip1KBveYUHfp+8e9klMJ9c= golang.org/x/net v0.6.0/go.mod h1:2Tu9+aMcznHK/AK1HMvgo6xiTLG5rD5rZLDS+rp2Bjs= golang.org/x/net v0.9.0/go.mod h1:d48xBJpPfHeWQsugry2m+kC02ZBRGRgulfHnEXEuWns= golang.org/x/net v0.10.0/go.mod h1:0qNGK6F8kojg2nk9dLZ2mShWaEBan6FAoqfSigmmuDg= golang.org/x/net v0.14.0/go.mod h1:PpSgVXXLK0OxS0F31C1/tv6XNguvCrnXIDrFMspZIUI= golang.org/x/net v0.43.0 h1:lat02VYK2j4aLzMzecihNvTlJNQUq316m2Mr9rnM6YE= golang.org/x/net v0.43.0/go.mod h1:vhO1fvI4dGsIjh73sWfUVjj3N7CA9WkKJNQm2svM6Jg= golang.org/x/oauth2 v0.0.0-20180821212333-d2e6202438be/go.mod h1:N/0e6XlmueqKjAGxoOufVs8QHGRruUQn6yWY3a++T0U= golang.org/x/oauth2 v0.28.0 h1:CrgCKl8PPAVtLnU3c+EDw6x11699EWlsDeWNWKdIOkc= golang.org/x/oauth2 v0.28.0/go.mod h1:onh5ek6nERTohokkhCD/y2cV4Do3fxFHFuAejCkRWT8= golang.org/x/sync v0.0.0-20180314180146-1d60e4601c6f/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= golang.org/x/sync v0.0.0-20181108010431-42b317875d0f/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= golang.org/x/sync v0.0.0-20190423024810-112230192c58/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= golang.org/x/sync v0.0.0-20220722155255-886fb9371eb4/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= golang.org/x/sync v0.1.0/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= golang.org/x/sync v0.16.0 h1:ycBJEhp9p4vXvUZNszeOq0kGTPghopOL8q0fq3vstxw= golang.org/x/sync v0.16.0/go.mod h1:1dzgHSNfp02xaA81J2MS99Qcpr2w7fw1gpm99rleRqA= golang.org/x/sys v0.0.0-20180830151530-49385e6e1522/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY= golang.org/x/sys v0.0.0-20190215142949-d0b11bdaac8a/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY= golang.org/x/sys v0.0.0-20190412213103-97732733099d/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= golang.org/x/sys v0.0.0-20200930185726-fdedc70b468f/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= golang.org/x/sys v0.0.0-20201119102817-f84b799fce68/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= golang.org/x/sys v0.0.0-20210615035016-665e8c7367d1/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= golang.org/x/sys v0.0.0-20220520151302-bc2c85ada10a/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= golang.org/x/sys v0.0.0-20220722155257-8c9f86f7a55f/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= golang.org/x/sys v0.2.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= golang.org/x/sys v0.5.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= golang.org/x/sys v0.7.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= golang.org/x/sys v0.8.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= golang.org/x/sys v0.10.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= golang.org/x/sys v0.11.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= golang.org/x/sys v0.35.0 h1:vz1N37gP5bs89s7He8XuIYXpyY0+QlsKmzipCbUtyxI= golang.org/x/sys v0.35.0/go.mod h1:BJP2sWEmIv4KK5OTEluFJCKSidICx8ciO85XgH3Ak8k= golang.org/x/term v0.0.0-20201126162022-7de9c90e9dd1/go.mod h1:bj7SfCRtBDWHUb9snDiAeCFNEtKQo2Wmx5Cou7ajbmo= golang.org/x/term v0.0.0-20210927222741-03fcf44c2211/go.mod h1:jbD1KX2456YbFQfuXm/mYQcufACuNUgVhRMnK/tPxf8= golang.org/x/term v0.5.0/go.mod h1:jMB1sMXY+tzblOD4FWmEbocvup2/aLOaQEp7JmGp78k= golang.org/x/term v0.7.0/go.mod h1:P32HKFT3hSsZrRxla30E9HqToFYAQPCMs/zFMBUFqPY= golang.org/x/term v0.8.0/go.mod h1:xPskH00ivmX89bAKVGSKKtLOWNx2+17Eiy94tnKShWo= golang.org/x/term v0.11.0/go.mod h1:zC9APTIj3jG3FdV/Ons+XE1riIZXG4aZ4GTHiPZJPIU= golang.org/x/term v0.34.0 h1:O/2T7POpk0ZZ7MAzMeWFSg6S5IpWd/RXDlM9hgM3DR4= golang.org/x/term v0.34.0/go.mod h1:5jC53AEywhIVebHgPVeg0mj8OD3VO9OzclacVrqpaAw= golang.org/x/text v0.3.0/go.mod h1:NqM8EUOU14njkJ3fqMW+pc6Ldnwhi/IjpwHt7yyuwOQ= golang.org/x/text v0.3.3/go.mod h1:5Zoc/QRtKVWzQhOtBMvqHzDpF6irO9z98xDceosuGiQ= golang.org/x/text v0.3.7/go.mod h1:u+2+/6zg+i71rQMx5EYifcz6MCKuco9NR6JIITiCfzQ= golang.org/x/text v0.7.0/go.mod h1:mrYo+phRRbMaCq/xk9113O4dZlRixOauAjOtrjsXDZ8= golang.org/x/text v0.9.0/go.mod h1:e1OnstbJyHTd6l/uOt8jFFHp6TRDWZR/bV3emEE/zU8= golang.org/x/text v0.12.0/go.mod h1:TvPlkZtksWOMsz7fbANvkp4WM8x/WCo/om8BMLbz+aE= golang.org/x/text v0.28.0 h1:rhazDwis8INMIwQ4tpjLDzUhx6RlXqZNPEM0huQojng= golang.org/x/text v0.28.0/go.mod h1:U8nCwOR8jO/marOQ0QbDiOngZVEBB7MAiitBuMjXiNU= golang.org/x/time v0.5.0 h1:o7cqy6amK/52YcAKIPlM3a+Fpj35zvRj2TP+e1xFSfk= golang.org/x/time v0.5.0/go.mod h1:3BpzKBy/shNhVucY/MWOyx10tF3SFh9QdLuxbVysPQM= golang.org/x/tools v0.0.0-20180917221912-90fa682c2a6e/go.mod h1:n7NCudcB/nEzxVGmLbDWY5pfWTLqBcC2KZ6jyYvM4mQ= golang.org/x/tools v0.0.0-20190114222345-bf090417da8b/go.mod h1:n7NCudcB/nEzxVGmLbDWY5pfWTLqBcC2KZ6jyYvM4mQ= golang.org/x/tools v0.0.0-20190226205152-f727befe758c/go.mod h1:9Yl7xja0Znq3iFh3HoIrodX9oNMXvdceNzlUR8zjMvY= golang.org/x/tools v0.0.0-20190311212946-11955173bddd/go.mod h1:LCzVGOaR6xXOjkQ3onu1FJEFr0SW1gC7cKk1uF8kGRs= golang.org/x/tools v0.0.0-20190524140312-2c0ae7006135/go.mod h1:RgjU9mgBXZiqYHBnxXauZ1Gv1EHHAz9KjViQ78xBX0Q= golang.org/x/tools v0.0.0-20191119224855-298f0cb1881e/go.mod h1:b+2E5dAYhXwXZwtnZ6UAqBI28+e2cm9otk0dWdXHAEo= golang.org/x/tools v0.1.12/go.mod h1:hNGJHUnrk76NpqgfD5Aqm5Crs+Hm0VOH/i9J2+nxYbc= golang.org/x/tools v0.6.0/go.mod h1:Xwgl3UAJ/d3gWutnCtw505GrjyAbvKui8lOU390QaIU= golang.org/x/tools v0.36.0 h1:kWS0uv/zsvHEle1LbV5LE8QujrxB3wfQyxHfhOk0Qkg= golang.org/x/tools v0.36.0/go.mod h1:WBDiHKJK8YgLHlcQPYQzNCkUxUypCaa5ZegCVutKm+s= golang.org/x/xerrors v0.0.0-20190717185122-a985d3407aa7/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0= golang.org/x/xerrors v0.0.0-20191204190536-9bdfabe68543/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0= golang.zx2c4.com/wintun v0.0.0-20230126152724-0fa3db229ce2 h1:B82qJJgjvYKsXS9jeunTOisW56dUokqW/FOteYJJ/yg= golang.zx2c4.com/wintun v0.0.0-20230126152724-0fa3db229ce2/go.mod h1:deeaetjYA+DHMHg+sMSMI58GrEteJUUzzw7en6TJQcI= golang.zx2c4.com/wireguard v0.0.0-20231211153847-12269c276173 h1:/jFs0duh4rdb8uIfPMv78iAJGcPKDeqAFnaLBropIC4= golang.zx2c4.com/wireguard v0.0.0-20231211153847-12269c276173/go.mod h1:tkCQ4FQXmpAgYVh++1cq16/dH4QJtmvpRv19DWGAHSA= google.golang.org/appengine v1.1.0/go.mod h1:EbEs0AVv82hx2wNQdGPgUI5lhzA/G0D9YwlJXL52JkM= google.golang.org/appengine v1.4.0/go.mod h1:xpcJRLb0r/rnEns0DIKYYv+WjYCduHsrkT7/EB5XEv4= google.golang.org/genproto v0.0.0-20180817151627-c66870c02cf8/go.mod h1:JiN7NxoALGmiZfu7CAH4rXhgtRTLTxftemlI0sWmxmc= google.golang.org/genproto v0.0.0-20190819201941-24fa4b261c55/go.mod h1:DMBHOl98Agz4BDEuKkezgsaosCRResVns1a3J2ZsMNc= google.golang.org/grpc v1.19.0/go.mod h1:mqu4LbDTu4XGKhr4mRzUsmM4RtVoemTSY81AxZiDr8c= google.golang.org/grpc v1.23.0/go.mod h1:Y5yQAOtifL1yxbo5wqy6BxZv8vAUGQwXBOALyacEbxg= google.golang.org/grpc v1.25.1/go.mod h1:c3i+UQWmh7LiEpx4sFZnkU36qjEYZ0imhYfXVyQciAY= google.golang.org/grpc v1.31.0/go.mod h1:N36X2cJ7JwdamYAgDz+s+rVMFjt3numwzf/HckM8pak= google.golang.org/protobuf v0.0.0-20200109180630-ec00e32a8dfd/go.mod h1:DFci5gLYBciE7Vtevhsrf46CRTquxDuWsQurQQe4oz8= google.golang.org/protobuf v0.0.0-20200221191635-4d8936d0db64/go.mod h1:kwYJMbMJ01Woi6D6+Kah6886xMZcty6N08ah7+eCXa0= google.golang.org/protobuf v0.0.0-20200228230310-ab0ca4ff8a60/go.mod h1:cfTl7dwQJ+fmap5saPgwCLgHXTUD7jkjRqWcaiX5VyM= google.golang.org/protobuf v1.20.1-0.20200309200217-e05f789c0967/go.mod h1:A+miEFZTKqfCUM6K7xSMQL9OKL/b6hQv+e19PK+JZNE= google.golang.org/protobuf v1.21.0/go.mod h1:47Nbq4nVaFHyn7ilMalzfO3qCViNmqZ2kzikPIcrTAo= google.golang.org/protobuf v1.23.0/go.mod h1:EGpADcykh3NcUnDUJcl1+ZksZNG86OlYog2l/sGQquU= google.golang.org/protobuf v1.36.5 h1:tPhr+woSbjfYvY6/GPufUoYizxw1cF/yFoxJ2fmpwlM= google.golang.org/protobuf v1.36.5/go.mod h1:9fA7Ob0pmnwhb644+1+CVWFRbNajQ6iRojtC/QF5bRE= gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c h1:Hei/4ADfdWqJk1ZMxUNpqntNwaWcugrBjAiHlqqRiVk= gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c/go.mod h1:JHkPIbrfpd72SG/EVd6muEfDQjcINNoR0C8j2r3qZ4Q= gopkg.in/ini.v1 v1.67.0 h1:Dgnx+6+nfE+IfzjUEISNeydPJh9AXNNsWbGP9KzCsOA= gopkg.in/ini.v1 v1.67.0/go.mod h1:pNLf8WUiyNEtQjuu5G5vTm06TEv9tsIgeAvK8hOrP4k= gopkg.in/yaml.v2 v2.4.0 h1:D8xgwECY7CYvx+Y2n4sBz93Jn9JRvxdiyyo8CTfuKaY= gopkg.in/yaml.v2 v2.4.0/go.mod h1:RDklbk79AGWmwhnvt/jBztapEOGDOx6ZbXqjP6csGnQ= gopkg.in/yaml.v3 v3.0.0-20200313102051-9f266ea9e77c/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA= gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= gvisor.dev/gvisor v0.0.0-20230927004350-cbd86285d259 h1:TbRPT0HtzFP3Cno1zZo7yPzEEnfu8EjLfl6IU9VfqkQ= gvisor.dev/gvisor v0.0.0-20230927004350-cbd86285d259/go.mod h1:AVgIgHMwK63XvmAzWG9vLQ41YnVHN0du0tEC46fI7yY= honnef.co/go/tools v0.0.0-20190102054323-c2f93a96b099/go.mod h1:rf3lG4BRIbNafJWhAfAdb/ePZxsR/4RtNHQocxwk9r4= honnef.co/go/tools v0.0.0-20190523083050-ea95bdfd59fc/go.mod h1:rf3lG4BRIbNafJWhAfAdb/ePZxsR/4RtNHQocxwk9r4= k8s.io/apimachinery v0.28.8 h1:hi/nrxHwk4QLV+W/SHve1bypTE59HCDorLY1stBIxKQ= k8s.io/apimachinery v0.28.8/go.mod h1:cBnwIM3fXoRo28SqbV/Ihxf/iviw85KyXOrzxvZQ83U= k8s.io/client-go v0.28.8 h1:TE59Tjd87WKvS2FPBTfIKLFX0nQJ4SSHsnDo5IHjgOw= k8s.io/client-go v0.28.8/go.mod h1:uDVQ/rPzWpWIy40c6lZ4mUwaEvRWGnpoqSO4FM65P3o= k8s.io/utils v0.0.0-20230406110748-d93618cff8a2 h1:qY1Ad8PODbnymg2pRbkyMT/ylpTrCM8P2RJ0yroCyIk= k8s.io/utils v0.0.0-20230406110748-d93618cff8a2/go.mod h1:OLgZIPagt7ERELqWJFomSt595RzquPNLL48iOWgYOg0= sigs.k8s.io/json v0.0.0-20221116044647-bc3834ca7abd h1:EDPBXCAspyGV4jQlpZSudPeMmr1bNJefnuqLsRAsHZo= sigs.k8s.io/json v0.0.0-20221116044647-bc3834ca7abd/go.mod h1:B8JuhiUyNFVKdsE8h686QcCxMaH6HrOAZj4vswFpcB0= sigs.k8s.io/yaml v1.3.0 h1:a2VclLzOGrwOHDiV8EfBGhvjHvP46CtW5j6POvhYGGo= sigs.k8s.io/yaml v1.3.0/go.mod h1:GeOyir5tyXNByN85N/dRIT9es5UQNerPYEKK56eTBm8= ================================================ FILE: hack/download.sh ================================================ #!/bin/sh OS="$(go env GOOS)" ARCH="$(go env GOARCH)" if [ "${TARGET_OS}" ]; then OS="${TARGET_OS}" fi if [ "${TARGET_ARCH}" ]; then ARCH="${TARGET_ARCH}" fi # Determine the latest version by version number ignoring alpha, beta, and rc versions. if [ "${FRP_VERSION}" = "" ] ; then FRP_VERSION="$(curl -sL https://github.com/fatedier/frp/releases | \ grep -o 'releases/tag/v[0-9]*.[0-9]*.[0-9]*"' | sort -V | \ tail -1 | awk -F'/' '{ print $3}')" FRP_VERSION="${FRP_VERSION%?}" FRP_VERSION="${FRP_VERSION#?}" fi if [ "${FRP_VERSION}" = "" ] ; then printf "Unable to get latest frp version. Set FRP_VERSION env var and re-run. For example: export FRP_VERSION=1.0.0" exit 1; fi SUFFIX=".tar.gz" if [ "${OS}" = "windows" ] ; then SUFFIX=".zip" fi NAME="frp_${FRP_VERSION}_${OS}_${ARCH}${SUFFIX}" DIR_NAME="frp_${FRP_VERSION}_${OS}_${ARCH}" URL="https://github.com/fatedier/frp/releases/download/v${FRP_VERSION}/${NAME}" download_and_extract() { printf "Downloading %s from %s ...\n" "$NAME" "${URL}" if ! curl -o /dev/null -sIf "${URL}"; then printf "\n%s is not found, please specify a valid FRP_VERSION\n" "${URL}" exit 1 fi curl -fsLO "${URL}" filename=$NAME if [ "${OS}" = "windows" ]; then unzip "${filename}" else tar -xzf "${filename}" fi rm "${filename}" if [ "${TARGET_DIRNAME}" ]; then mv "${DIR_NAME}" "${TARGET_DIRNAME}" DIR_NAME="${TARGET_DIRNAME}" fi } download_and_extract printf "" printf "\nfrp %s Download Complete!\n" "$FRP_VERSION" printf "\n" printf "frp has been successfully downloaded into the %s folder on your system.\n" "$DIR_NAME" printf "\n" ================================================ FILE: hack/run-e2e.sh ================================================ #!/bin/sh SCRIPT=$(readlink -f "$0") ROOT=$(unset CDPATH && cd "$(dirname "$SCRIPT")/.." && pwd) # Check if ginkgo is available if ! command -v ginkgo >/dev/null 2>&1; then echo "ginkgo not found, try to install..." go install github.com/onsi/ginkgo/v2/ginkgo@v2.23.4 fi debug=false if [ "x${DEBUG}" = "xtrue" ]; then debug=true fi logLevel=debug if [ "${LOG_LEVEL}" ]; then logLevel="${LOG_LEVEL}" fi frpcPath=${ROOT}/bin/frpc if [ "${FRPC_PATH}" ]; then frpcPath="${FRPC_PATH}" fi frpsPath=${ROOT}/bin/frps if [ "${FRPS_PATH}" ]; then frpsPath="${FRPS_PATH}" fi concurrency="16" if [ "${CONCURRENCY}" ]; then concurrency="${CONCURRENCY}" fi ginkgo -nodes=${concurrency} --poll-progress-after=60s ${ROOT}/test/e2e -- -frpc-path=${frpcPath} -frps-path=${frpsPath} -log-level=${logLevel} -debug=${debug} ================================================ FILE: package.sh ================================================ #!/bin/sh set -e # compile for version make if [ $? -ne 0 ]; then echo "make error" exit 1 fi frp_version=`./bin/frps --version` echo "build version: $frp_version" # cross_compiles make -f ./Makefile.cross-compiles rm -rf ./release/packages mkdir -p ./release/packages os_all='linux windows darwin freebsd openbsd android' arch_all='386 amd64 arm arm64 mips64 mips64le mips mipsle riscv64 loong64' extra_all='_ hf' cd ./release for os in $os_all; do for arch in $arch_all; do for extra in $extra_all; do suffix="${os}_${arch}" if [ "x${extra}" != x"_" ]; then suffix="${os}_${arch}_${extra}" fi frp_dir_name="frp_${frp_version}_${suffix}" frp_path="./packages/frp_${frp_version}_${suffix}" if [ "x${os}" = x"windows" ]; then if [ ! -f "./frpc_${os}_${arch}.exe" ]; then continue fi if [ ! -f "./frps_${os}_${arch}.exe" ]; then continue fi mkdir ${frp_path} mv ./frpc_${os}_${arch}.exe ${frp_path}/frpc.exe mv ./frps_${os}_${arch}.exe ${frp_path}/frps.exe else if [ ! -f "./frpc_${suffix}" ]; then continue fi if [ ! -f "./frps_${suffix}" ]; then continue fi mkdir ${frp_path} mv ./frpc_${suffix} ${frp_path}/frpc mv ./frps_${suffix} ${frp_path}/frps fi cp ../LICENSE ${frp_path} cp -f ../conf/frpc.toml ${frp_path} cp -f ../conf/frps.toml ${frp_path} # packages cd ./packages if [ "x${os}" = x"windows" ]; then zip -rq ${frp_dir_name}.zip ${frp_dir_name} else tar -zcf ${frp_dir_name}.tar.gz ${frp_dir_name} fi cd .. rm -rf ${frp_path} done done done cd - ================================================ FILE: pkg/auth/auth.go ================================================ // Copyright 2020 guylewin, guy@lewin.co.il // // Licensed under the Apache License, Version 2.0 (the "License"); // you may not use this file except in compliance with the License. // You may obtain a copy of the License at // // http://www.apache.org/licenses/LICENSE-2.0 // // Unless required by applicable law or agreed to in writing, software // distributed under the License is distributed on an "AS IS" BASIS, // WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. // See the License for the specific language governing permissions and // limitations under the License. package auth import ( "context" "fmt" v1 "github.com/fatedier/frp/pkg/config/v1" "github.com/fatedier/frp/pkg/msg" ) type Setter interface { SetLogin(*msg.Login) error SetPing(*msg.Ping) error SetNewWorkConn(*msg.NewWorkConn) error } type ClientAuth struct { Setter Setter key []byte } func (a *ClientAuth) EncryptionKey() []byte { return a.key } // BuildClientAuth resolves any dynamic auth values and returns a prepared auth runtime. // Caller must run validation before calling this function. func BuildClientAuth(cfg *v1.AuthClientConfig) (*ClientAuth, error) { if cfg == nil { return nil, fmt.Errorf("auth config is nil") } resolved := *cfg if resolved.Method == v1.AuthMethodToken && resolved.TokenSource != nil { token, err := resolved.TokenSource.Resolve(context.Background()) if err != nil { return nil, fmt.Errorf("failed to resolve auth.tokenSource: %w", err) } resolved.Token = token } setter, err := NewAuthSetter(resolved) if err != nil { return nil, err } return &ClientAuth{ Setter: setter, key: []byte(resolved.Token), }, nil } func NewAuthSetter(cfg v1.AuthClientConfig) (authProvider Setter, err error) { switch cfg.Method { case v1.AuthMethodToken: authProvider = NewTokenAuth(cfg.AdditionalScopes, cfg.Token) case v1.AuthMethodOIDC: if cfg.OIDC.TokenSource != nil { authProvider = NewOidcTokenSourceAuthSetter(cfg.AdditionalScopes, cfg.OIDC.TokenSource) } else { authProvider, err = NewOidcAuthSetter(cfg.AdditionalScopes, cfg.OIDC) if err != nil { return nil, err } } default: return nil, fmt.Errorf("unsupported auth method: %s", cfg.Method) } return authProvider, nil } type Verifier interface { VerifyLogin(*msg.Login) error VerifyPing(*msg.Ping) error VerifyNewWorkConn(*msg.NewWorkConn) error } type ServerAuth struct { Verifier Verifier key []byte } func (a *ServerAuth) EncryptionKey() []byte { return a.key } // BuildServerAuth resolves any dynamic auth values and returns a prepared auth runtime. // Caller must run validation before calling this function. func BuildServerAuth(cfg *v1.AuthServerConfig) (*ServerAuth, error) { if cfg == nil { return nil, fmt.Errorf("auth config is nil") } resolved := *cfg if resolved.Method == v1.AuthMethodToken && resolved.TokenSource != nil { token, err := resolved.TokenSource.Resolve(context.Background()) if err != nil { return nil, fmt.Errorf("failed to resolve auth.tokenSource: %w", err) } resolved.Token = token } return &ServerAuth{ Verifier: NewAuthVerifier(resolved), key: []byte(resolved.Token), }, nil } func NewAuthVerifier(cfg v1.AuthServerConfig) (authVerifier Verifier) { switch cfg.Method { case v1.AuthMethodToken: authVerifier = NewTokenAuth(cfg.AdditionalScopes, cfg.Token) case v1.AuthMethodOIDC: tokenVerifier := NewTokenVerifier(cfg.OIDC) authVerifier = NewOidcAuthVerifier(cfg.AdditionalScopes, tokenVerifier) } return authVerifier } ================================================ FILE: pkg/auth/legacy/legacy.go ================================================ // Copyright 2023 The frp Authors // // Licensed under the Apache License, Version 2.0 (the "License"); // you may not use this file except in compliance with the License. // You may obtain a copy of the License at // // http://www.apache.org/licenses/LICENSE-2.0 // // Unless required by applicable law or agreed to in writing, software // distributed under the License is distributed on an "AS IS" BASIS, // WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. // See the License for the specific language governing permissions and // limitations under the License. package legacy type BaseConfig struct { // AuthenticationMethod specifies what authentication method to use to // authenticate frpc with frps. If "token" is specified - token will be // read into login message. If "oidc" is specified - OIDC (Open ID Connect) // token will be issued using OIDC settings. By default, this value is "token". AuthenticationMethod string `ini:"authentication_method" json:"authentication_method"` // AuthenticateHeartBeats specifies whether to include authentication token in // heartbeats sent to frps. By default, this value is false. AuthenticateHeartBeats bool `ini:"authenticate_heartbeats" json:"authenticate_heartbeats"` // AuthenticateNewWorkConns specifies whether to include authentication token in // new work connections sent to frps. By default, this value is false. AuthenticateNewWorkConns bool `ini:"authenticate_new_work_conns" json:"authenticate_new_work_conns"` } func getDefaultBaseConf() BaseConfig { return BaseConfig{ AuthenticationMethod: "token", AuthenticateHeartBeats: false, AuthenticateNewWorkConns: false, } } type ClientConfig struct { BaseConfig `ini:",extends"` OidcClientConfig `ini:",extends"` TokenConfig `ini:",extends"` } func GetDefaultClientConf() ClientConfig { return ClientConfig{ BaseConfig: getDefaultBaseConf(), OidcClientConfig: getDefaultOidcClientConf(), TokenConfig: getDefaultTokenConf(), } } type ServerConfig struct { BaseConfig `ini:",extends"` OidcServerConfig `ini:",extends"` TokenConfig `ini:",extends"` } func GetDefaultServerConf() ServerConfig { return ServerConfig{ BaseConfig: getDefaultBaseConf(), OidcServerConfig: getDefaultOidcServerConf(), TokenConfig: getDefaultTokenConf(), } } type OidcClientConfig struct { // OidcClientID specifies the client ID to use to get a token in OIDC // authentication if AuthenticationMethod == "oidc". By default, this value // is "". OidcClientID string `ini:"oidc_client_id" json:"oidc_client_id"` // OidcClientSecret specifies the client secret to use to get a token in OIDC // authentication if AuthenticationMethod == "oidc". By default, this value // is "". OidcClientSecret string `ini:"oidc_client_secret" json:"oidc_client_secret"` // OidcAudience specifies the audience of the token in OIDC authentication // if AuthenticationMethod == "oidc". By default, this value is "". OidcAudience string `ini:"oidc_audience" json:"oidc_audience"` // OidcScope specifies the scope of the token in OIDC authentication // if AuthenticationMethod == "oidc". By default, this value is "". OidcScope string `ini:"oidc_scope" json:"oidc_scope"` // OidcTokenEndpointURL specifies the URL which implements OIDC Token Endpoint. // It will be used to get an OIDC token if AuthenticationMethod == "oidc". // By default, this value is "". OidcTokenEndpointURL string `ini:"oidc_token_endpoint_url" json:"oidc_token_endpoint_url"` // OidcAdditionalEndpointParams specifies additional parameters to be sent // this field will be transfer to map[string][]string in OIDC token generator // The field will be set by prefix "oidc_additional_" OidcAdditionalEndpointParams map[string]string `ini:"-" json:"oidc_additional_endpoint_params"` } func getDefaultOidcClientConf() OidcClientConfig { return OidcClientConfig{ OidcClientID: "", OidcClientSecret: "", OidcAudience: "", OidcScope: "", OidcTokenEndpointURL: "", OidcAdditionalEndpointParams: make(map[string]string), } } type OidcServerConfig struct { // OidcIssuer specifies the issuer to verify OIDC tokens with. This issuer // will be used to load public keys to verify signature and will be compared // with the issuer claim in the OIDC token. It will be used if // AuthenticationMethod == "oidc". By default, this value is "". OidcIssuer string `ini:"oidc_issuer" json:"oidc_issuer"` // OidcAudience specifies the audience OIDC tokens should contain when validated. // If this value is empty, audience ("client ID") verification will be skipped. // It will be used when AuthenticationMethod == "oidc". By default, this // value is "". OidcAudience string `ini:"oidc_audience" json:"oidc_audience"` // OidcSkipExpiryCheck specifies whether to skip checking if the OIDC token is // expired. It will be used when AuthenticationMethod == "oidc". By default, this // value is false. OidcSkipExpiryCheck bool `ini:"oidc_skip_expiry_check" json:"oidc_skip_expiry_check"` // OidcSkipIssuerCheck specifies whether to skip checking if the OIDC token's // issuer claim matches the issuer specified in OidcIssuer. It will be used when // AuthenticationMethod == "oidc". By default, this value is false. OidcSkipIssuerCheck bool `ini:"oidc_skip_issuer_check" json:"oidc_skip_issuer_check"` } func getDefaultOidcServerConf() OidcServerConfig { return OidcServerConfig{ OidcIssuer: "", OidcAudience: "", OidcSkipExpiryCheck: false, OidcSkipIssuerCheck: false, } } type TokenConfig struct { // Token specifies the authorization token used to create keys to be sent // to the server. The server must have a matching token for authorization // to succeed. By default, this value is "". Token string `ini:"token" json:"token"` } func getDefaultTokenConf() TokenConfig { return TokenConfig{ Token: "", } } ================================================ FILE: pkg/auth/oidc.go ================================================ // Copyright 2020 guylewin, guy@lewin.co.il // // Licensed under the Apache License, Version 2.0 (the "License"); // you may not use this file except in compliance with the License. // You may obtain a copy of the License at // // http://www.apache.org/licenses/LICENSE-2.0 // // Unless required by applicable law or agreed to in writing, software // distributed under the License is distributed on an "AS IS" BASIS, // WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. // See the License for the specific language governing permissions and // limitations under the License. package auth import ( "context" "crypto/tls" "crypto/x509" "fmt" "net/http" "net/url" "os" "slices" "sync" "github.com/coreos/go-oidc/v3/oidc" "golang.org/x/oauth2" "golang.org/x/oauth2/clientcredentials" v1 "github.com/fatedier/frp/pkg/config/v1" "github.com/fatedier/frp/pkg/config/v1/validation" "github.com/fatedier/frp/pkg/msg" ) // createOIDCHTTPClient creates an HTTP client with custom TLS and proxy configuration for OIDC token requests func createOIDCHTTPClient(trustedCAFile string, insecureSkipVerify bool, proxyURL string) (*http.Client, error) { // Clone the default transport to get all reasonable defaults transport := http.DefaultTransport.(*http.Transport).Clone() // Configure TLS settings if trustedCAFile != "" || insecureSkipVerify { tlsConfig := &tls.Config{ InsecureSkipVerify: insecureSkipVerify, } if trustedCAFile != "" && !insecureSkipVerify { caCert, err := os.ReadFile(trustedCAFile) if err != nil { return nil, fmt.Errorf("failed to read OIDC CA certificate file %q: %w", trustedCAFile, err) } caCertPool := x509.NewCertPool() if !caCertPool.AppendCertsFromPEM(caCert) { return nil, fmt.Errorf("failed to parse OIDC CA certificate from file %q", trustedCAFile) } tlsConfig.RootCAs = caCertPool } transport.TLSClientConfig = tlsConfig } // Configure proxy settings if proxyURL != "" { parsedURL, err := url.Parse(proxyURL) if err != nil { return nil, fmt.Errorf("failed to parse OIDC proxy URL %q: %w", proxyURL, err) } transport.Proxy = http.ProxyURL(parsedURL) } else { // Explicitly disable proxy to override DefaultTransport's ProxyFromEnvironment transport.Proxy = nil } return &http.Client{Transport: transport}, nil } // nonCachingTokenSource wraps a clientcredentials.Config to fetch a fresh // token on every call. This is used as a fallback when the OIDC provider // does not return expires_in, which would cause a caching TokenSource to // hold onto a stale token forever. type nonCachingTokenSource struct { cfg *clientcredentials.Config ctx context.Context } func (s *nonCachingTokenSource) Token() (*oauth2.Token, error) { return s.cfg.Token(s.ctx) } // oidcTokenSource wraps a caching oauth2.TokenSource and, on the first // successful Token() call, checks whether the provider returns an expiry. // If not, it permanently switches to nonCachingTokenSource so that a fresh // token is fetched every time. This avoids an eager network call at // construction time, letting the login retry loop handle transient IdP // outages. type oidcTokenSource struct { mu sync.Mutex initialized bool source oauth2.TokenSource fallbackCfg *clientcredentials.Config fallbackCtx context.Context } func (s *oidcTokenSource) Token() (*oauth2.Token, error) { s.mu.Lock() if !s.initialized { token, err := s.source.Token() if err != nil { s.mu.Unlock() return nil, err } if token.Expiry.IsZero() { s.source = &nonCachingTokenSource{cfg: s.fallbackCfg, ctx: s.fallbackCtx} } s.initialized = true s.mu.Unlock() return token, nil } source := s.source s.mu.Unlock() return source.Token() } type OidcAuthProvider struct { additionalAuthScopes []v1.AuthScope tokenSource oauth2.TokenSource } func NewOidcAuthSetter(additionalAuthScopes []v1.AuthScope, cfg v1.AuthOIDCClientConfig) (*OidcAuthProvider, error) { if err := validation.ValidateOIDCClientCredentialsConfig(&cfg); err != nil { return nil, err } eps := make(map[string][]string) for k, v := range cfg.AdditionalEndpointParams { eps[k] = []string{v} } if cfg.Audience != "" { eps["audience"] = []string{cfg.Audience} } tokenGenerator := &clientcredentials.Config{ ClientID: cfg.ClientID, ClientSecret: cfg.ClientSecret, Scopes: []string{cfg.Scope}, TokenURL: cfg.TokenEndpointURL, EndpointParams: eps, } // Build the context that TokenSource will use for all future HTTP requests. // context.Background() is appropriate here because the token source is // long-lived and outlives any single request. ctx := context.Background() if cfg.TrustedCaFile != "" || cfg.InsecureSkipVerify || cfg.ProxyURL != "" { httpClient, err := createOIDCHTTPClient(cfg.TrustedCaFile, cfg.InsecureSkipVerify, cfg.ProxyURL) if err != nil { return nil, fmt.Errorf("failed to create OIDC HTTP client: %w", err) } ctx = context.WithValue(ctx, oauth2.HTTPClient, httpClient) } // Create a persistent TokenSource that caches the token and refreshes // it before expiry. This avoids making a new HTTP request to the OIDC // provider on every heartbeat/ping. // // We wrap it in an oidcTokenSource so that the first Token() call // (deferred to SetLogin inside the login retry loop) probes whether the // provider returns expires_in. If not, it switches to a non-caching // source. This avoids an eager network call at construction time, which // would prevent loopLoginUntilSuccess from retrying on transient IdP // outages. cachingSource := tokenGenerator.TokenSource(ctx) return &OidcAuthProvider{ additionalAuthScopes: additionalAuthScopes, tokenSource: &oidcTokenSource{ source: cachingSource, fallbackCfg: tokenGenerator, fallbackCtx: ctx, }, }, nil } func (auth *OidcAuthProvider) generateAccessToken() (accessToken string, err error) { tokenObj, err := auth.tokenSource.Token() if err != nil { return "", fmt.Errorf("couldn't generate OIDC token for login: %v", err) } return tokenObj.AccessToken, nil } func (auth *OidcAuthProvider) SetLogin(loginMsg *msg.Login) (err error) { loginMsg.PrivilegeKey, err = auth.generateAccessToken() return err } func (auth *OidcAuthProvider) SetPing(pingMsg *msg.Ping) (err error) { if !slices.Contains(auth.additionalAuthScopes, v1.AuthScopeHeartBeats) { return nil } pingMsg.PrivilegeKey, err = auth.generateAccessToken() return err } func (auth *OidcAuthProvider) SetNewWorkConn(newWorkConnMsg *msg.NewWorkConn) (err error) { if !slices.Contains(auth.additionalAuthScopes, v1.AuthScopeNewWorkConns) { return nil } newWorkConnMsg.PrivilegeKey, err = auth.generateAccessToken() return err } type OidcTokenSourceAuthProvider struct { additionalAuthScopes []v1.AuthScope valueSource *v1.ValueSource } func NewOidcTokenSourceAuthSetter(additionalAuthScopes []v1.AuthScope, valueSource *v1.ValueSource) *OidcTokenSourceAuthProvider { return &OidcTokenSourceAuthProvider{ additionalAuthScopes: additionalAuthScopes, valueSource: valueSource, } } func (auth *OidcTokenSourceAuthProvider) generateAccessToken() (accessToken string, err error) { ctx := context.Background() accessToken, err = auth.valueSource.Resolve(ctx) if err != nil { return "", fmt.Errorf("couldn't acquire OIDC token for login: %v", err) } return } func (auth *OidcTokenSourceAuthProvider) SetLogin(loginMsg *msg.Login) (err error) { loginMsg.PrivilegeKey, err = auth.generateAccessToken() return err } func (auth *OidcTokenSourceAuthProvider) SetPing(pingMsg *msg.Ping) (err error) { if !slices.Contains(auth.additionalAuthScopes, v1.AuthScopeHeartBeats) { return nil } pingMsg.PrivilegeKey, err = auth.generateAccessToken() return err } func (auth *OidcTokenSourceAuthProvider) SetNewWorkConn(newWorkConnMsg *msg.NewWorkConn) (err error) { if !slices.Contains(auth.additionalAuthScopes, v1.AuthScopeNewWorkConns) { return nil } newWorkConnMsg.PrivilegeKey, err = auth.generateAccessToken() return err } type TokenVerifier interface { Verify(context.Context, string) (*oidc.IDToken, error) } type OidcAuthConsumer struct { additionalAuthScopes []v1.AuthScope verifier TokenVerifier mu sync.RWMutex subjectsFromLogin map[string]struct{} } func NewTokenVerifier(cfg v1.AuthOIDCServerConfig) TokenVerifier { provider, err := oidc.NewProvider(context.Background(), cfg.Issuer) if err != nil { panic(err) } verifierConf := oidc.Config{ ClientID: cfg.Audience, SkipClientIDCheck: cfg.Audience == "", SkipExpiryCheck: cfg.SkipExpiryCheck, SkipIssuerCheck: cfg.SkipIssuerCheck, } return provider.Verifier(&verifierConf) } func NewOidcAuthVerifier(additionalAuthScopes []v1.AuthScope, verifier TokenVerifier) *OidcAuthConsumer { return &OidcAuthConsumer{ additionalAuthScopes: additionalAuthScopes, verifier: verifier, subjectsFromLogin: make(map[string]struct{}), } } func (auth *OidcAuthConsumer) VerifyLogin(loginMsg *msg.Login) (err error) { token, err := auth.verifier.Verify(context.Background(), loginMsg.PrivilegeKey) if err != nil { return fmt.Errorf("invalid OIDC token in login: %v", err) } auth.mu.Lock() auth.subjectsFromLogin[token.Subject] = struct{}{} auth.mu.Unlock() return nil } func (auth *OidcAuthConsumer) verifyPostLoginToken(privilegeKey string) (err error) { token, err := auth.verifier.Verify(context.Background(), privilegeKey) if err != nil { return fmt.Errorf("invalid OIDC token in ping: %v", err) } auth.mu.RLock() _, ok := auth.subjectsFromLogin[token.Subject] auth.mu.RUnlock() if !ok { return fmt.Errorf("received different OIDC subject in login and ping. "+ "new subject: %s", token.Subject) } return nil } func (auth *OidcAuthConsumer) VerifyPing(pingMsg *msg.Ping) (err error) { if !slices.Contains(auth.additionalAuthScopes, v1.AuthScopeHeartBeats) { return nil } return auth.verifyPostLoginToken(pingMsg.PrivilegeKey) } func (auth *OidcAuthConsumer) VerifyNewWorkConn(newWorkConnMsg *msg.NewWorkConn) (err error) { if !slices.Contains(auth.additionalAuthScopes, v1.AuthScopeNewWorkConns) { return nil } return auth.verifyPostLoginToken(newWorkConnMsg.PrivilegeKey) } ================================================ FILE: pkg/auth/oidc_test.go ================================================ package auth_test import ( "context" "encoding/json" "net/http" "net/http/httptest" "sync/atomic" "testing" "time" "github.com/coreos/go-oidc/v3/oidc" "github.com/stretchr/testify/require" "github.com/fatedier/frp/pkg/auth" v1 "github.com/fatedier/frp/pkg/config/v1" "github.com/fatedier/frp/pkg/msg" ) type mockTokenVerifier struct{} func (m *mockTokenVerifier) Verify(ctx context.Context, subject string) (*oidc.IDToken, error) { return &oidc.IDToken{ Subject: subject, }, nil } func TestPingWithEmptySubjectFromLoginFails(t *testing.T) { r := require.New(t) consumer := auth.NewOidcAuthVerifier([]v1.AuthScope{v1.AuthScopeHeartBeats}, &mockTokenVerifier{}) err := consumer.VerifyPing(&msg.Ping{ PrivilegeKey: "ping-without-login", Timestamp: time.Now().UnixMilli(), }) r.Error(err) r.Contains(err.Error(), "received different OIDC subject in login and ping") } func TestPingAfterLoginWithNewSubjectSucceeds(t *testing.T) { r := require.New(t) consumer := auth.NewOidcAuthVerifier([]v1.AuthScope{v1.AuthScopeHeartBeats}, &mockTokenVerifier{}) err := consumer.VerifyLogin(&msg.Login{ PrivilegeKey: "ping-after-login", }) r.NoError(err) err = consumer.VerifyPing(&msg.Ping{ PrivilegeKey: "ping-after-login", Timestamp: time.Now().UnixMilli(), }) r.NoError(err) } func TestPingAfterLoginWithDifferentSubjectFails(t *testing.T) { r := require.New(t) consumer := auth.NewOidcAuthVerifier([]v1.AuthScope{v1.AuthScopeHeartBeats}, &mockTokenVerifier{}) err := consumer.VerifyLogin(&msg.Login{ PrivilegeKey: "login-with-first-subject", }) r.NoError(err) err = consumer.VerifyPing(&msg.Ping{ PrivilegeKey: "ping-with-different-subject", Timestamp: time.Now().UnixMilli(), }) r.Error(err) r.Contains(err.Error(), "received different OIDC subject in login and ping") } func TestOidcAuthProviderFallsBackWhenNoExpiry(t *testing.T) { r := require.New(t) var requestCount atomic.Int32 tokenServer := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, req *http.Request) { requestCount.Add(1) w.Header().Set("Content-Type", "application/json") _ = json.NewEncoder(w).Encode(map[string]any{ //nolint:gosec // test-only dummy token response "access_token": "fresh-test-token", "token_type": "Bearer", }) })) defer tokenServer.Close() provider, err := auth.NewOidcAuthSetter( []v1.AuthScope{v1.AuthScopeHeartBeats}, v1.AuthOIDCClientConfig{ ClientID: "test-client", ClientSecret: "test-secret", TokenEndpointURL: tokenServer.URL, }, ) r.NoError(err) // Constructor no longer fetches a token eagerly. // The first SetLogin triggers the adaptive probe. r.Equal(int32(0), requestCount.Load()) loginMsg := &msg.Login{} err = provider.SetLogin(loginMsg) r.NoError(err) r.Equal("fresh-test-token", loginMsg.PrivilegeKey) for range 3 { pingMsg := &msg.Ping{} err = provider.SetPing(pingMsg) r.NoError(err) r.Equal("fresh-test-token", pingMsg.PrivilegeKey) } // 1 probe (login) + 3 pings = 4 requests (probe doubles as the login token fetch) r.Equal(int32(4), requestCount.Load(), "each call should fetch a fresh token when expires_in is missing") } func TestOidcAuthProviderCachesToken(t *testing.T) { r := require.New(t) var requestCount atomic.Int32 tokenServer := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, req *http.Request) { requestCount.Add(1) w.Header().Set("Content-Type", "application/json") _ = json.NewEncoder(w).Encode(map[string]any{ //nolint:gosec // test-only dummy token response "access_token": "cached-test-token", "token_type": "Bearer", "expires_in": 3600, }) })) defer tokenServer.Close() provider, err := auth.NewOidcAuthSetter( []v1.AuthScope{v1.AuthScopeHeartBeats}, v1.AuthOIDCClientConfig{ ClientID: "test-client", ClientSecret: "test-secret", TokenEndpointURL: tokenServer.URL, }, ) r.NoError(err) // Constructor no longer fetches eagerly; first SetLogin triggers the probe. r.Equal(int32(0), requestCount.Load()) // SetLogin triggers the adaptive probe and caches the token. loginMsg := &msg.Login{} err = provider.SetLogin(loginMsg) r.NoError(err) r.Equal("cached-test-token", loginMsg.PrivilegeKey) r.Equal(int32(1), requestCount.Load()) // Subsequent calls should also reuse the cached token for range 5 { pingMsg := &msg.Ping{} err = provider.SetPing(pingMsg) r.NoError(err) r.Equal("cached-test-token", pingMsg.PrivilegeKey) } r.Equal(int32(1), requestCount.Load(), "token endpoint should only be called once; cached token should be reused") } func TestOidcAuthProviderRetriesOnInitialFailure(t *testing.T) { r := require.New(t) var requestCount atomic.Int32 tokenServer := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, req *http.Request) { n := requestCount.Add(1) // The oauth2 library retries once internally, so we need two // consecutive failures to surface an error to the caller. if n <= 2 { w.Header().Set("Content-Type", "application/json") w.WriteHeader(http.StatusBadRequest) _ = json.NewEncoder(w).Encode(map[string]any{ "error": "temporarily_unavailable", "error_description": "service is starting up", }) return } w.Header().Set("Content-Type", "application/json") _ = json.NewEncoder(w).Encode(map[string]any{ //nolint:gosec // test-only dummy token response "access_token": "retry-test-token", "token_type": "Bearer", "expires_in": 3600, }) })) defer tokenServer.Close() // Constructor succeeds even though the IdP is "down". provider, err := auth.NewOidcAuthSetter( []v1.AuthScope{v1.AuthScopeHeartBeats}, v1.AuthOIDCClientConfig{ ClientID: "test-client", ClientSecret: "test-secret", TokenEndpointURL: tokenServer.URL, }, ) r.NoError(err) r.Equal(int32(0), requestCount.Load()) // First SetLogin hits the IdP, which returns an error (after internal retry). loginMsg := &msg.Login{} err = provider.SetLogin(loginMsg) r.Error(err) r.Equal(int32(2), requestCount.Load()) // Second SetLogin retries and succeeds. err = provider.SetLogin(loginMsg) r.NoError(err) r.Equal("retry-test-token", loginMsg.PrivilegeKey) r.Equal(int32(3), requestCount.Load()) // Subsequent calls use cached token. pingMsg := &msg.Ping{} err = provider.SetPing(pingMsg) r.NoError(err) r.Equal("retry-test-token", pingMsg.PrivilegeKey) r.Equal(int32(3), requestCount.Load()) } func TestNewOidcAuthSetterRejectsInvalidStaticConfig(t *testing.T) { r := require.New(t) tokenServer := httptest.NewServer(http.NotFoundHandler()) defer tokenServer.Close() _, err := auth.NewOidcAuthSetter(nil, v1.AuthOIDCClientConfig{ ClientID: "test-client", TokenEndpointURL: "://bad", }) r.Error(err) r.Contains(err.Error(), "auth.oidc.tokenEndpointURL") _, err = auth.NewOidcAuthSetter(nil, v1.AuthOIDCClientConfig{ TokenEndpointURL: tokenServer.URL, }) r.Error(err) r.Contains(err.Error(), "auth.oidc.clientID is required") _, err = auth.NewOidcAuthSetter(nil, v1.AuthOIDCClientConfig{ ClientID: "test-client", TokenEndpointURL: tokenServer.URL, AdditionalEndpointParams: map[string]string{ "scope": "profile", }, }) r.Error(err) r.Contains(err.Error(), "auth.oidc.additionalEndpointParams.scope is not allowed; use auth.oidc.scope instead") _, err = auth.NewOidcAuthSetter(nil, v1.AuthOIDCClientConfig{ ClientID: "test-client", TokenEndpointURL: tokenServer.URL, Audience: "api", AdditionalEndpointParams: map[string]string{"audience": "override"}, }) r.Error(err) r.Contains(err.Error(), "cannot specify both auth.oidc.audience and auth.oidc.additionalEndpointParams.audience") } ================================================ FILE: pkg/auth/pass.go ================================================ // Copyright 2023 The frp Authors // // Licensed under the Apache License, Version 2.0 (the "License"); // you may not use this file except in compliance with the License. // You may obtain a copy of the License at // // http://www.apache.org/licenses/LICENSE-2.0 // // Unless required by applicable law or agreed to in writing, software // distributed under the License is distributed on an "AS IS" BASIS, // WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. // See the License for the specific language governing permissions and // limitations under the License. package auth import ( "github.com/fatedier/frp/pkg/msg" ) var AlwaysPassVerifier = &alwaysPass{} var _ Verifier = &alwaysPass{} type alwaysPass struct{} func (*alwaysPass) VerifyLogin(*msg.Login) error { return nil } func (*alwaysPass) VerifyPing(*msg.Ping) error { return nil } func (*alwaysPass) VerifyNewWorkConn(*msg.NewWorkConn) error { return nil } ================================================ FILE: pkg/auth/token.go ================================================ // Copyright 2020 guylewin, guy@lewin.co.il // // Licensed under the Apache License, Version 2.0 (the "License"); // you may not use this file except in compliance with the License. // You may obtain a copy of the License at // // http://www.apache.org/licenses/LICENSE-2.0 // // Unless required by applicable law or agreed to in writing, software // distributed under the License is distributed on an "AS IS" BASIS, // WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. // See the License for the specific language governing permissions and // limitations under the License. package auth import ( "fmt" "slices" "time" v1 "github.com/fatedier/frp/pkg/config/v1" "github.com/fatedier/frp/pkg/msg" "github.com/fatedier/frp/pkg/util/util" ) type TokenAuthSetterVerifier struct { additionalAuthScopes []v1.AuthScope token string } func NewTokenAuth(additionalAuthScopes []v1.AuthScope, token string) *TokenAuthSetterVerifier { return &TokenAuthSetterVerifier{ additionalAuthScopes: additionalAuthScopes, token: token, } } func (auth *TokenAuthSetterVerifier) SetLogin(loginMsg *msg.Login) error { loginMsg.PrivilegeKey = util.GetAuthKey(auth.token, loginMsg.Timestamp) return nil } func (auth *TokenAuthSetterVerifier) SetPing(pingMsg *msg.Ping) error { if !slices.Contains(auth.additionalAuthScopes, v1.AuthScopeHeartBeats) { return nil } pingMsg.Timestamp = time.Now().Unix() pingMsg.PrivilegeKey = util.GetAuthKey(auth.token, pingMsg.Timestamp) return nil } func (auth *TokenAuthSetterVerifier) SetNewWorkConn(newWorkConnMsg *msg.NewWorkConn) error { if !slices.Contains(auth.additionalAuthScopes, v1.AuthScopeNewWorkConns) { return nil } newWorkConnMsg.Timestamp = time.Now().Unix() newWorkConnMsg.PrivilegeKey = util.GetAuthKey(auth.token, newWorkConnMsg.Timestamp) return nil } func (auth *TokenAuthSetterVerifier) VerifyLogin(m *msg.Login) error { if !util.ConstantTimeEqString(util.GetAuthKey(auth.token, m.Timestamp), m.PrivilegeKey) { return fmt.Errorf("token in login doesn't match token from configuration") } return nil } func (auth *TokenAuthSetterVerifier) VerifyPing(m *msg.Ping) error { if !slices.Contains(auth.additionalAuthScopes, v1.AuthScopeHeartBeats) { return nil } if !util.ConstantTimeEqString(util.GetAuthKey(auth.token, m.Timestamp), m.PrivilegeKey) { return fmt.Errorf("token in heartbeat doesn't match token from configuration") } return nil } func (auth *TokenAuthSetterVerifier) VerifyNewWorkConn(m *msg.NewWorkConn) error { if !slices.Contains(auth.additionalAuthScopes, v1.AuthScopeNewWorkConns) { return nil } if !util.ConstantTimeEqString(util.GetAuthKey(auth.token, m.Timestamp), m.PrivilegeKey) { return fmt.Errorf("token in NewWorkConn doesn't match token from configuration") } return nil } ================================================ FILE: pkg/config/flags.go ================================================ // Copyright 2023 The frp Authors // // Licensed under the Apache License, Version 2.0 (the "License"); // you may not use this file except in compliance with the License. // You may obtain a copy of the License at // // http://www.apache.org/licenses/LICENSE-2.0 // // Unless required by applicable law or agreed to in writing, software // distributed under the License is distributed on an "AS IS" BASIS, // WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. // See the License for the specific language governing permissions and // limitations under the License. package config import ( "fmt" "strconv" "strings" "github.com/spf13/cobra" "github.com/spf13/pflag" "github.com/fatedier/frp/pkg/config/types" v1 "github.com/fatedier/frp/pkg/config/v1" "github.com/fatedier/frp/pkg/config/v1/validation" ) // WordSepNormalizeFunc changes all flags that contain "_" separators func WordSepNormalizeFunc(f *pflag.FlagSet, name string) pflag.NormalizedName { if strings.Contains(name, "_") { return pflag.NormalizedName(strings.ReplaceAll(name, "_", "-")) } return pflag.NormalizedName(name) } type RegisterFlagOption func(*registerFlagOptions) type registerFlagOptions struct { sshMode bool } func WithSSHMode() RegisterFlagOption { return func(o *registerFlagOptions) { o.sshMode = true } } type BandwidthQuantityFlag struct { V *types.BandwidthQuantity } func (f *BandwidthQuantityFlag) Set(s string) error { return f.V.UnmarshalString(s) } func (f *BandwidthQuantityFlag) String() string { return f.V.String() } func (f *BandwidthQuantityFlag) Type() string { return "string" } func RegisterProxyFlags(cmd *cobra.Command, c v1.ProxyConfigurer, opts ...RegisterFlagOption) { registerProxyBaseConfigFlags(cmd, c.GetBaseConfig(), opts...) switch cc := c.(type) { case *v1.TCPProxyConfig: cmd.Flags().IntVarP(&cc.RemotePort, "remote_port", "r", 0, "remote port") case *v1.UDPProxyConfig: cmd.Flags().IntVarP(&cc.RemotePort, "remote_port", "r", 0, "remote port") case *v1.HTTPProxyConfig: registerProxyDomainConfigFlags(cmd, &cc.DomainConfig) cmd.Flags().StringSliceVarP(&cc.Locations, "locations", "", []string{}, "locations") cmd.Flags().StringVarP(&cc.HTTPUser, "http_user", "", "", "http auth user") cmd.Flags().StringVarP(&cc.HTTPPassword, "http_pwd", "", "", "http auth password") cmd.Flags().StringVarP(&cc.HostHeaderRewrite, "host_header_rewrite", "", "", "host header rewrite") case *v1.HTTPSProxyConfig: registerProxyDomainConfigFlags(cmd, &cc.DomainConfig) case *v1.TCPMuxProxyConfig: registerProxyDomainConfigFlags(cmd, &cc.DomainConfig) cmd.Flags().StringVarP(&cc.Multiplexer, "mux", "", "", "multiplexer") cmd.Flags().StringVarP(&cc.HTTPUser, "http_user", "", "", "http auth user") cmd.Flags().StringVarP(&cc.HTTPPassword, "http_pwd", "", "", "http auth password") case *v1.STCPProxyConfig: cmd.Flags().StringVarP(&cc.Secretkey, "sk", "", "", "secret key") cmd.Flags().StringSliceVarP(&cc.AllowUsers, "allow_users", "", []string{}, "allow visitor users") case *v1.SUDPProxyConfig: cmd.Flags().StringVarP(&cc.Secretkey, "sk", "", "", "secret key") cmd.Flags().StringSliceVarP(&cc.AllowUsers, "allow_users", "", []string{}, "allow visitor users") case *v1.XTCPProxyConfig: cmd.Flags().StringVarP(&cc.Secretkey, "sk", "", "", "secret key") cmd.Flags().StringSliceVarP(&cc.AllowUsers, "allow_users", "", []string{}, "allow visitor users") } } func registerProxyBaseConfigFlags(cmd *cobra.Command, c *v1.ProxyBaseConfig, opts ...RegisterFlagOption) { if c == nil { return } options := ®isterFlagOptions{} for _, opt := range opts { opt(options) } cmd.Flags().StringVarP(&c.Name, "proxy_name", "n", "", "proxy name") cmd.Flags().StringToStringVarP(&c.Metadatas, "metadatas", "", nil, "metadata key-value pairs (e.g., key1=value1,key2=value2)") cmd.Flags().StringToStringVarP(&c.Annotations, "annotations", "", nil, "annotation key-value pairs (e.g., key1=value1,key2=value2)") if !options.sshMode { cmd.Flags().StringVarP(&c.LocalIP, "local_ip", "i", "127.0.0.1", "local ip") cmd.Flags().IntVarP(&c.LocalPort, "local_port", "l", 0, "local port") cmd.Flags().BoolVarP(&c.Transport.UseEncryption, "ue", "", false, "use encryption") cmd.Flags().BoolVarP(&c.Transport.UseCompression, "uc", "", false, "use compression") cmd.Flags().StringVarP(&c.Transport.BandwidthLimitMode, "bandwidth_limit_mode", "", types.BandwidthLimitModeClient, "bandwidth limit mode") cmd.Flags().VarP(&BandwidthQuantityFlag{V: &c.Transport.BandwidthLimit}, "bandwidth_limit", "", "bandwidth limit (e.g. 100KB or 1MB)") } } func registerProxyDomainConfigFlags(cmd *cobra.Command, c *v1.DomainConfig) { if c == nil { return } cmd.Flags().StringSliceVarP(&c.CustomDomains, "custom_domain", "d", []string{}, "custom domains") cmd.Flags().StringVarP(&c.SubDomain, "sd", "", "", "sub domain") } func RegisterVisitorFlags(cmd *cobra.Command, c v1.VisitorConfigurer, opts ...RegisterFlagOption) { registerVisitorBaseConfigFlags(cmd, c.GetBaseConfig(), opts...) // add visitor flags if exist } func registerVisitorBaseConfigFlags(cmd *cobra.Command, c *v1.VisitorBaseConfig, _ ...RegisterFlagOption) { if c == nil { return } cmd.Flags().StringVarP(&c.Name, "visitor_name", "n", "", "visitor name") cmd.Flags().BoolVarP(&c.Transport.UseEncryption, "ue", "", false, "use encryption") cmd.Flags().BoolVarP(&c.Transport.UseCompression, "uc", "", false, "use compression") cmd.Flags().StringVarP(&c.SecretKey, "sk", "", "", "secret key") cmd.Flags().StringVarP(&c.ServerName, "server_name", "", "", "server name") cmd.Flags().StringVarP(&c.ServerUser, "server-user", "", "", "server user") cmd.Flags().StringVarP(&c.BindAddr, "bind_addr", "", "", "bind addr") cmd.Flags().IntVarP(&c.BindPort, "bind_port", "", 0, "bind port") } func RegisterClientCommonConfigFlags(cmd *cobra.Command, c *v1.ClientCommonConfig, opts ...RegisterFlagOption) { options := ®isterFlagOptions{} for _, opt := range opts { opt(options) } if !options.sshMode { cmd.PersistentFlags().StringVarP(&c.ServerAddr, "server_addr", "s", "127.0.0.1", "frp server's address") cmd.PersistentFlags().IntVarP(&c.ServerPort, "server_port", "P", 7000, "frp server's port") cmd.PersistentFlags().StringVarP(&c.Transport.Protocol, "protocol", "p", "tcp", fmt.Sprintf("optional values are %v", validation.SupportedTransportProtocols)) cmd.PersistentFlags().StringVarP(&c.Log.Level, "log_level", "", "info", "log level") cmd.PersistentFlags().StringVarP(&c.Log.To, "log_file", "", "console", "console or file path") cmd.PersistentFlags().Int64VarP(&c.Log.MaxDays, "log_max_days", "", 3, "log file reversed days") cmd.PersistentFlags().BoolVarP(&c.Log.DisablePrintColor, "disable_log_color", "", false, "disable log color in console") cmd.PersistentFlags().StringVarP(&c.Transport.TLS.ServerName, "tls_server_name", "", "", "specify the custom server name of tls certificate") cmd.PersistentFlags().StringVarP(&c.DNSServer, "dns_server", "", "", "specify dns server instead of using system default one") c.Transport.TLS.Enable = cmd.PersistentFlags().BoolP("tls_enable", "", true, "enable frpc tls") } cmd.PersistentFlags().StringVarP(&c.User, "user", "u", "", "user") cmd.PersistentFlags().StringVar(&c.ClientID, "client-id", "", "unique identifier for this frpc instance") cmd.PersistentFlags().StringVarP(&c.Auth.Token, "token", "t", "", "auth token") } type PortsRangeSliceFlag struct { V *[]types.PortsRange } func (f *PortsRangeSliceFlag) String() string { if f.V == nil { return "" } return types.PortsRangeSlice(*f.V).String() } func (f *PortsRangeSliceFlag) Set(s string) error { slice, err := types.NewPortsRangeSliceFromString(s) if err != nil { return err } *f.V = slice return nil } func (f *PortsRangeSliceFlag) Type() string { return "string" } type BoolFuncFlag struct { TrueFunc func() FalseFunc func() v bool } func (f *BoolFuncFlag) String() string { return strconv.FormatBool(f.v) } func (f *BoolFuncFlag) Set(s string) error { f.v = strconv.FormatBool(f.v) == "true" if !f.v { if f.FalseFunc != nil { f.FalseFunc() } return nil } if f.TrueFunc != nil { f.TrueFunc() } return nil } func (f *BoolFuncFlag) Type() string { return "bool" } func RegisterServerConfigFlags(cmd *cobra.Command, c *v1.ServerConfig, opts ...RegisterFlagOption) { cmd.PersistentFlags().StringVarP(&c.BindAddr, "bind_addr", "", "0.0.0.0", "bind address") cmd.PersistentFlags().IntVarP(&c.BindPort, "bind_port", "p", 7000, "bind port") cmd.PersistentFlags().IntVarP(&c.KCPBindPort, "kcp_bind_port", "", 0, "kcp bind udp port") cmd.PersistentFlags().IntVarP(&c.QUICBindPort, "quic_bind_port", "", 0, "quic bind udp port") cmd.PersistentFlags().StringVarP(&c.ProxyBindAddr, "proxy_bind_addr", "", "0.0.0.0", "proxy bind address") cmd.PersistentFlags().IntVarP(&c.VhostHTTPPort, "vhost_http_port", "", 0, "vhost http port") cmd.PersistentFlags().IntVarP(&c.VhostHTTPSPort, "vhost_https_port", "", 0, "vhost https port") cmd.PersistentFlags().Int64VarP(&c.VhostHTTPTimeout, "vhost_http_timeout", "", 60, "vhost http response header timeout") cmd.PersistentFlags().StringVarP(&c.WebServer.Addr, "dashboard_addr", "", "0.0.0.0", "dashboard address") cmd.PersistentFlags().IntVarP(&c.WebServer.Port, "dashboard_port", "", 0, "dashboard port") cmd.PersistentFlags().StringVarP(&c.WebServer.User, "dashboard_user", "", "admin", "dashboard user") cmd.PersistentFlags().StringVarP(&c.WebServer.Password, "dashboard_pwd", "", "admin", "dashboard password") cmd.PersistentFlags().BoolVarP(&c.EnablePrometheus, "enable_prometheus", "", false, "enable prometheus dashboard") cmd.PersistentFlags().StringVarP(&c.Log.To, "log_file", "", "console", "log file") cmd.PersistentFlags().StringVarP(&c.Log.Level, "log_level", "", "info", "log level") cmd.PersistentFlags().Int64VarP(&c.Log.MaxDays, "log_max_days", "", 3, "log max days") cmd.PersistentFlags().BoolVarP(&c.Log.DisablePrintColor, "disable_log_color", "", false, "disable log color in console") cmd.PersistentFlags().StringVarP(&c.Auth.Token, "token", "t", "", "auth token") cmd.PersistentFlags().StringVarP(&c.SubDomainHost, "subdomain_host", "", "", "subdomain host") cmd.PersistentFlags().VarP(&PortsRangeSliceFlag{V: &c.AllowPorts}, "allow_ports", "", "allow ports") cmd.PersistentFlags().Int64VarP(&c.MaxPortsPerClient, "max_ports_per_client", "", 0, "max ports per client") cmd.PersistentFlags().BoolVarP(&c.Transport.TLS.Force, "tls_only", "", false, "frps tls only") webServerTLS := v1.TLSConfig{} cmd.PersistentFlags().StringVarP(&webServerTLS.CertFile, "dashboard_tls_cert_file", "", "", "dashboard tls cert file") cmd.PersistentFlags().StringVarP(&webServerTLS.KeyFile, "dashboard_tls_key_file", "", "", "dashboard tls key file") cmd.PersistentFlags().VarP(&BoolFuncFlag{ TrueFunc: func() { c.WebServer.TLS = &webServerTLS }, }, "dashboard_tls_mode", "", "if enable dashboard tls mode") } ================================================ FILE: pkg/config/legacy/README.md ================================================ So far, there is no mature Go project that does well in parsing `*.ini` files. By comparison, we have selected an open source project: `https://github.com/go-ini/ini`. This library helped us solve most of the key-value matching, but there are still some problems, such as not supporting parsing `map`. We add our own logic on the basis of this library. In the current situationwhich, we need to complete the entire `Unmarshal` in two steps: * Step#1, use `go-ini` to complete the basic parameter matching; * Step#2, parse our custom parameters to realize parsing special structure, like `map`, `array`. Some of the keywords in `tag`(like inline, extends, etc.) may be different from standard libraries such as `json` and `protobuf` in Go. For details, please refer to the library documentation: https://ini.unknwon.io/docs/intro. ================================================ FILE: pkg/config/legacy/client.go ================================================ // Copyright 2023 The frp Authors // // Licensed under the Apache License, Version 2.0 (the "License"); // you may not use this file except in compliance with the License. // You may obtain a copy of the License at // // http://www.apache.org/licenses/LICENSE-2.0 // // Unless required by applicable law or agreed to in writing, software // distributed under the License is distributed on an "AS IS" BASIS, // WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. // See the License for the specific language governing permissions and // limitations under the License. package legacy import ( "fmt" "os" "path/filepath" "slices" "strings" "gopkg.in/ini.v1" legacyauth "github.com/fatedier/frp/pkg/auth/legacy" "github.com/fatedier/frp/pkg/util/util" ) // ClientCommonConf is the configuration parsed from ini. // It contains information for a client service. It is // recommended to use GetDefaultClientConf instead of creating this object // directly, so that all unspecified fields have reasonable default values. type ClientCommonConf struct { legacyauth.ClientConfig `ini:",extends"` // ServerAddr specifies the address of the server to connect to. By // default, this value is "0.0.0.0". ServerAddr string `ini:"server_addr" json:"server_addr"` // ServerPort specifies the port to connect to the server on. By default, // this value is 7000. ServerPort int `ini:"server_port" json:"server_port"` // STUN server to help penetrate NAT hole. NatHoleSTUNServer string `ini:"nat_hole_stun_server" json:"nat_hole_stun_server"` // The maximum amount of time a dial to server will wait for a connect to complete. DialServerTimeout int64 `ini:"dial_server_timeout" json:"dial_server_timeout"` // DialServerKeepAlive specifies the interval between keep-alive probes for an active network connection between frpc and frps. // If negative, keep-alive probes are disabled. DialServerKeepAlive int64 `ini:"dial_server_keepalive" json:"dial_server_keepalive"` // ConnectServerLocalIP specifies the address of the client bind when it connect to server. // By default, this value is empty. // this value only use in TCP/Websocket protocol. Not support in KCP protocol. ConnectServerLocalIP string `ini:"connect_server_local_ip" json:"connect_server_local_ip"` // HTTPProxy specifies a proxy address to connect to the server through. If // this value is "", the server will be connected to directly. By default, // this value is read from the "http_proxy" environment variable. HTTPProxy string `ini:"http_proxy" json:"http_proxy"` // LogFile specifies a file where logs will be written to. This value will // only be used if LogWay is set appropriately. By default, this value is // "console". LogFile string `ini:"log_file" json:"log_file"` // LogWay specifies the way logging is managed. Valid values are "console" // or "file". If "console" is used, logs will be printed to stdout. If // "file" is used, logs will be printed to LogFile. By default, this value // is "console". LogWay string `ini:"log_way" json:"log_way"` // LogLevel specifies the minimum log level. Valid values are "trace", // "debug", "info", "warn", and "error". By default, this value is "info". LogLevel string `ini:"log_level" json:"log_level"` // LogMaxDays specifies the maximum number of days to store log information // before deletion. This is only used if LogWay == "file". By default, this // value is 0. LogMaxDays int64 `ini:"log_max_days" json:"log_max_days"` // DisableLogColor disables log colors when LogWay == "console" when set to // true. By default, this value is false. DisableLogColor bool `ini:"disable_log_color" json:"disable_log_color"` // AdminAddr specifies the address that the admin server binds to. By // default, this value is "127.0.0.1". AdminAddr string `ini:"admin_addr" json:"admin_addr"` // AdminPort specifies the port for the admin server to listen on. If this // value is 0, the admin server will not be started. By default, this value // is 0. AdminPort int `ini:"admin_port" json:"admin_port"` // AdminUser specifies the username that the admin server will use for // login. AdminUser string `ini:"admin_user" json:"admin_user"` // AdminPwd specifies the password that the admin server will use for // login. AdminPwd string `ini:"admin_pwd" json:"admin_pwd"` // AssetsDir specifies the local directory that the admin server will load // resources from. If this value is "", assets will be loaded from the // bundled executable using statik. By default, this value is "". AssetsDir string `ini:"assets_dir" json:"assets_dir"` // PoolCount specifies the number of connections the client will make to // the server in advance. By default, this value is 0. PoolCount int `ini:"pool_count" json:"pool_count"` // TCPMux toggles TCP stream multiplexing. This allows multiple requests // from a client to share a single TCP connection. If this value is true, // the server must have TCP multiplexing enabled as well. By default, this // value is true. TCPMux bool `ini:"tcp_mux" json:"tcp_mux"` // TCPMuxKeepaliveInterval specifies the keep alive interval for TCP stream multiplier. // If TCPMux is true, heartbeat of application layer is unnecessary because it can only rely on heartbeat in TCPMux. TCPMuxKeepaliveInterval int64 `ini:"tcp_mux_keepalive_interval" json:"tcp_mux_keepalive_interval"` // User specifies a prefix for proxy names to distinguish them from other // clients. If this value is not "", proxy names will automatically be // changed to "{user}.{proxy_name}". By default, this value is "". User string `ini:"user" json:"user"` // DNSServer specifies a DNS server address for FRPC to use. If this value // is "", the default DNS will be used. By default, this value is "". DNSServer string `ini:"dns_server" json:"dns_server"` // LoginFailExit controls whether or not the client should exit after a // failed login attempt. If false, the client will retry until a login // attempt succeeds. By default, this value is true. LoginFailExit bool `ini:"login_fail_exit" json:"login_fail_exit"` // Start specifies a set of enabled proxies by name. If this set is empty, // all supplied proxies are enabled. By default, this value is an empty // set. Start []string `ini:"start" json:"start"` // Start map[string]struct{} `json:"start"` // Protocol specifies the protocol to use when interacting with the server. // Valid values are "tcp", "kcp", "quic", "websocket" and "wss". By default, this value // is "tcp". Protocol string `ini:"protocol" json:"protocol"` // QUIC protocol options QUICKeepalivePeriod int `ini:"quic_keepalive_period" json:"quic_keepalive_period"` QUICMaxIdleTimeout int `ini:"quic_max_idle_timeout" json:"quic_max_idle_timeout"` QUICMaxIncomingStreams int `ini:"quic_max_incoming_streams" json:"quic_max_incoming_streams"` // TLSEnable specifies whether or not TLS should be used when communicating // with the server. If "tls_cert_file" and "tls_key_file" are valid, // client will load the supplied tls configuration. // Since v0.50.0, the default value has been changed to true, and tls is enabled by default. TLSEnable bool `ini:"tls_enable" json:"tls_enable"` // TLSCertPath specifies the path of the cert file that client will // load. It only works when "tls_enable" is true and "tls_key_file" is valid. TLSCertFile string `ini:"tls_cert_file" json:"tls_cert_file"` // TLSKeyPath specifies the path of the secret key file that client // will load. It only works when "tls_enable" is true and "tls_cert_file" // are valid. TLSKeyFile string `ini:"tls_key_file" json:"tls_key_file"` // TLSTrustedCaFile specifies the path of the trusted ca file that will load. // It only works when "tls_enable" is valid and tls configuration of server // has been specified. TLSTrustedCaFile string `ini:"tls_trusted_ca_file" json:"tls_trusted_ca_file"` // TLSServerName specifies the custom server name of tls certificate. By // default, server name if same to ServerAddr. TLSServerName string `ini:"tls_server_name" json:"tls_server_name"` // If the disable_custom_tls_first_byte is set to false, frpc will establish a connection with frps using the // first custom byte when tls is enabled. // Since v0.50.0, the default value has been changed to true, and the first custom byte is disabled by default. DisableCustomTLSFirstByte bool `ini:"disable_custom_tls_first_byte" json:"disable_custom_tls_first_byte"` // HeartBeatInterval specifies at what interval heartbeats are sent to the // server, in seconds. It is not recommended to change this value. By // default, this value is 30. Set negative value to disable it. HeartbeatInterval int64 `ini:"heartbeat_interval" json:"heartbeat_interval"` // HeartBeatTimeout specifies the maximum allowed heartbeat response delay // before the connection is terminated, in seconds. It is not recommended // to change this value. By default, this value is 90. Set negative value to disable it. HeartbeatTimeout int64 `ini:"heartbeat_timeout" json:"heartbeat_timeout"` // Client meta info Metas map[string]string `ini:"-" json:"metas"` // UDPPacketSize specifies the udp packet size // By default, this value is 1500 UDPPacketSize int64 `ini:"udp_packet_size" json:"udp_packet_size"` // Include other config files for proxies. IncludeConfigFiles []string `ini:"includes" json:"includes"` // Enable golang pprof handlers in admin listener. // Admin port must be set first. PprofEnable bool `ini:"pprof_enable" json:"pprof_enable"` } // Supported sources including: string(file path), []byte, Reader interface. func UnmarshalClientConfFromIni(source any) (ClientCommonConf, error) { f, err := ini.LoadSources(ini.LoadOptions{ Insensitive: false, InsensitiveSections: false, InsensitiveKeys: false, IgnoreInlineComment: true, AllowBooleanKeys: true, }, source) if err != nil { return ClientCommonConf{}, err } s, err := f.GetSection("common") if err != nil { return ClientCommonConf{}, fmt.Errorf("invalid configuration file, not found [common] section") } common := GetDefaultClientConf() err = s.MapTo(&common) if err != nil { return ClientCommonConf{}, err } common.Metas = GetMapWithoutPrefix(s.KeysHash(), "meta_") common.OidcAdditionalEndpointParams = GetMapWithoutPrefix(s.KeysHash(), "oidc_additional_") return common, nil } // if len(startProxy) is 0, start all // otherwise just start proxies in startProxy map func LoadAllProxyConfsFromIni( prefix string, source any, start []string, ) (map[string]ProxyConf, map[string]VisitorConf, error) { f, err := ini.LoadSources(ini.LoadOptions{ Insensitive: false, InsensitiveSections: false, InsensitiveKeys: false, IgnoreInlineComment: true, AllowBooleanKeys: true, }, source) if err != nil { return nil, nil, err } proxyConfs := make(map[string]ProxyConf) visitorConfs := make(map[string]VisitorConf) if prefix != "" { prefix += "." } startProxy := make(map[string]struct{}) for _, s := range start { startProxy[s] = struct{}{} } startAll := len(startProxy) == 0 // Build template sections from range section And append to ini.File. rangeSections := make([]*ini.Section, 0) for _, section := range f.Sections() { if !strings.HasPrefix(section.Name(), "range:") { continue } rangeSections = append(rangeSections, section) } for _, section := range rangeSections { err = renderRangeProxyTemplates(f, section) if err != nil { return nil, nil, fmt.Errorf("failed to render template for proxy %s: %v", section.Name(), err) } } for _, section := range f.Sections() { name := section.Name() if name == ini.DefaultSection || name == "common" || strings.HasPrefix(name, "range:") { continue } _, shouldStart := startProxy[name] if !startAll && !shouldStart { continue } roleType := section.Key("role").String() if roleType == "" { roleType = "server" } switch roleType { case "server": newConf, newErr := NewProxyConfFromIni(prefix, name, section) if newErr != nil { return nil, nil, fmt.Errorf("failed to parse proxy %s, err: %v", name, newErr) } proxyConfs[prefix+name] = newConf case "visitor": newConf, newErr := NewVisitorConfFromIni(prefix, name, section) if newErr != nil { return nil, nil, fmt.Errorf("failed to parse visitor %s, err: %v", name, newErr) } visitorConfs[prefix+name] = newConf default: return nil, nil, fmt.Errorf("proxy %s role should be 'server' or 'visitor'", name) } } return proxyConfs, visitorConfs, nil } func renderRangeProxyTemplates(f *ini.File, section *ini.Section) error { // Validation localPortStr := section.Key("local_port").String() remotePortStr := section.Key("remote_port").String() if localPortStr == "" || remotePortStr == "" { return fmt.Errorf("local_port or remote_port is empty") } localPorts, err := util.ParseRangeNumbers(localPortStr) if err != nil { return err } remotePorts, err := util.ParseRangeNumbers(remotePortStr) if err != nil { return err } if len(localPorts) != len(remotePorts) { return fmt.Errorf("local ports number should be same with remote ports number") } if len(localPorts) == 0 { return fmt.Errorf("local_port and remote_port is necessary") } // Templates prefix := strings.TrimSpace(strings.TrimPrefix(section.Name(), "range:")) for i := range localPorts { tmpname := fmt.Sprintf("%s_%d", prefix, i) tmpsection, err := f.NewSection(tmpname) if err != nil { return err } copySection(section, tmpsection) if _, err := tmpsection.NewKey("local_port", fmt.Sprintf("%d", localPorts[i])); err != nil { return fmt.Errorf("local_port new key in section error: %v", err) } if _, err := tmpsection.NewKey("remote_port", fmt.Sprintf("%d", remotePorts[i])); err != nil { return fmt.Errorf("remote_port new key in section error: %v", err) } } return nil } func copySection(source, target *ini.Section) { for key, value := range source.KeysHash() { _, _ = target.NewKey(key, value) } } // GetDefaultClientConf returns a client configuration with default values. // Note: Some default values here will be set to empty and will be converted to them // new configuration through the 'Complete' function to set them as the default // values of the new configuration. func GetDefaultClientConf() ClientCommonConf { return ClientCommonConf{ ClientConfig: legacyauth.GetDefaultClientConf(), TCPMux: true, LoginFailExit: true, Protocol: "tcp", Start: make([]string, 0), TLSEnable: true, DisableCustomTLSFirstByte: true, Metas: make(map[string]string), IncludeConfigFiles: make([]string, 0), } } func (cfg *ClientCommonConf) Validate() error { if cfg.HeartbeatTimeout > 0 && cfg.HeartbeatInterval > 0 { if cfg.HeartbeatTimeout < cfg.HeartbeatInterval { return fmt.Errorf("invalid heartbeat_timeout, heartbeat_timeout is less than heartbeat_interval") } } if !cfg.TLSEnable { if cfg.TLSCertFile != "" { fmt.Println("WARNING! tls_cert_file is invalid when tls_enable is false") } if cfg.TLSKeyFile != "" { fmt.Println("WARNING! tls_key_file is invalid when tls_enable is false") } if cfg.TLSTrustedCaFile != "" { fmt.Println("WARNING! tls_trusted_ca_file is invalid when tls_enable is false") } } if !slices.Contains([]string{"tcp", "kcp", "quic", "websocket", "wss"}, cfg.Protocol) { return fmt.Errorf("invalid protocol") } for _, f := range cfg.IncludeConfigFiles { absDir, err := filepath.Abs(filepath.Dir(f)) if err != nil { return fmt.Errorf("include: parse directory of %s failed: %v", f, err) } if _, err := os.Stat(absDir); os.IsNotExist(err) { return fmt.Errorf("include: directory of %s not exist", f) } } return nil } ================================================ FILE: pkg/config/legacy/conversion.go ================================================ // Copyright 2023 The frp Authors // // Licensed under the Apache License, Version 2.0 (the "License"); // you may not use this file except in compliance with the License. // You may obtain a copy of the License at // // http://www.apache.org/licenses/LICENSE-2.0 // // Unless required by applicable law or agreed to in writing, software // distributed under the License is distributed on an "AS IS" BASIS, // WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. // See the License for the specific language governing permissions and // limitations under the License. package legacy import ( "strings" "github.com/samber/lo" "github.com/fatedier/frp/pkg/config/types" v1 "github.com/fatedier/frp/pkg/config/v1" ) func Convert_ClientCommonConf_To_v1(conf *ClientCommonConf) *v1.ClientCommonConfig { out := &v1.ClientCommonConfig{} out.User = conf.User out.Auth.Method = v1.AuthMethod(conf.AuthenticationMethod) out.Auth.Token = conf.Token if conf.AuthenticateHeartBeats { out.Auth.AdditionalScopes = append(out.Auth.AdditionalScopes, v1.AuthScopeHeartBeats) } if conf.AuthenticateNewWorkConns { out.Auth.AdditionalScopes = append(out.Auth.AdditionalScopes, v1.AuthScopeNewWorkConns) } out.Auth.OIDC.ClientID = conf.OidcClientID out.Auth.OIDC.ClientSecret = conf.OidcClientSecret out.Auth.OIDC.Audience = conf.OidcAudience out.Auth.OIDC.Scope = conf.OidcScope out.Auth.OIDC.TokenEndpointURL = conf.OidcTokenEndpointURL out.Auth.OIDC.AdditionalEndpointParams = conf.OidcAdditionalEndpointParams out.ServerAddr = conf.ServerAddr out.ServerPort = conf.ServerPort out.NatHoleSTUNServer = conf.NatHoleSTUNServer out.Transport.DialServerTimeout = conf.DialServerTimeout out.Transport.DialServerKeepAlive = conf.DialServerKeepAlive out.Transport.ConnectServerLocalIP = conf.ConnectServerLocalIP out.Transport.ProxyURL = conf.HTTPProxy out.Transport.PoolCount = conf.PoolCount out.Transport.TCPMux = lo.ToPtr(conf.TCPMux) out.Transport.TCPMuxKeepaliveInterval = conf.TCPMuxKeepaliveInterval out.Transport.Protocol = conf.Protocol out.Transport.HeartbeatInterval = conf.HeartbeatInterval out.Transport.HeartbeatTimeout = conf.HeartbeatTimeout out.Transport.QUIC.KeepalivePeriod = conf.QUICKeepalivePeriod out.Transport.QUIC.MaxIdleTimeout = conf.QUICMaxIdleTimeout out.Transport.QUIC.MaxIncomingStreams = conf.QUICMaxIncomingStreams out.Transport.TLS.Enable = lo.ToPtr(conf.TLSEnable) out.Transport.TLS.DisableCustomTLSFirstByte = lo.ToPtr(conf.DisableCustomTLSFirstByte) out.Transport.TLS.CertFile = conf.TLSCertFile out.Transport.TLS.KeyFile = conf.TLSKeyFile out.Transport.TLS.TrustedCaFile = conf.TLSTrustedCaFile out.Transport.TLS.ServerName = conf.TLSServerName out.Log.To = conf.LogFile out.Log.Level = conf.LogLevel out.Log.MaxDays = conf.LogMaxDays out.Log.DisablePrintColor = conf.DisableLogColor out.WebServer.Addr = conf.AdminAddr out.WebServer.Port = conf.AdminPort out.WebServer.User = conf.AdminUser out.WebServer.Password = conf.AdminPwd out.WebServer.AssetsDir = conf.AssetsDir out.WebServer.PprofEnable = conf.PprofEnable out.DNSServer = conf.DNSServer out.LoginFailExit = lo.ToPtr(conf.LoginFailExit) out.Start = conf.Start out.UDPPacketSize = conf.UDPPacketSize out.Metadatas = conf.Metas out.IncludeConfigFiles = conf.IncludeConfigFiles return out } func Convert_ServerCommonConf_To_v1(conf *ServerCommonConf) *v1.ServerConfig { out := &v1.ServerConfig{} out.Auth.Method = v1.AuthMethod(conf.AuthenticationMethod) out.Auth.Token = conf.Token if conf.AuthenticateHeartBeats { out.Auth.AdditionalScopes = append(out.Auth.AdditionalScopes, v1.AuthScopeHeartBeats) } if conf.AuthenticateNewWorkConns { out.Auth.AdditionalScopes = append(out.Auth.AdditionalScopes, v1.AuthScopeNewWorkConns) } out.Auth.OIDC.Audience = conf.OidcAudience out.Auth.OIDC.Issuer = conf.OidcIssuer out.Auth.OIDC.SkipExpiryCheck = conf.OidcSkipExpiryCheck out.Auth.OIDC.SkipIssuerCheck = conf.OidcSkipIssuerCheck out.BindAddr = conf.BindAddr out.BindPort = conf.BindPort out.KCPBindPort = conf.KCPBindPort out.QUICBindPort = conf.QUICBindPort out.Transport.QUIC.KeepalivePeriod = conf.QUICKeepalivePeriod out.Transport.QUIC.MaxIdleTimeout = conf.QUICMaxIdleTimeout out.Transport.QUIC.MaxIncomingStreams = conf.QUICMaxIncomingStreams out.ProxyBindAddr = conf.ProxyBindAddr out.VhostHTTPPort = conf.VhostHTTPPort out.VhostHTTPSPort = conf.VhostHTTPSPort out.TCPMuxHTTPConnectPort = conf.TCPMuxHTTPConnectPort out.TCPMuxPassthrough = conf.TCPMuxPassthrough out.VhostHTTPTimeout = conf.VhostHTTPTimeout out.WebServer.Addr = conf.DashboardAddr out.WebServer.Port = conf.DashboardPort out.WebServer.User = conf.DashboardUser out.WebServer.Password = conf.DashboardPwd out.WebServer.AssetsDir = conf.AssetsDir if conf.DashboardTLSMode { out.WebServer.TLS = &v1.TLSConfig{} out.WebServer.TLS.CertFile = conf.DashboardTLSCertFile out.WebServer.TLS.KeyFile = conf.DashboardTLSKeyFile out.WebServer.PprofEnable = conf.PprofEnable } out.EnablePrometheus = conf.EnablePrometheus out.Log.To = conf.LogFile out.Log.Level = conf.LogLevel out.Log.MaxDays = conf.LogMaxDays out.Log.DisablePrintColor = conf.DisableLogColor out.DetailedErrorsToClient = lo.ToPtr(conf.DetailedErrorsToClient) out.SubDomainHost = conf.SubDomainHost out.Custom404Page = conf.Custom404Page out.UserConnTimeout = conf.UserConnTimeout out.UDPPacketSize = conf.UDPPacketSize out.NatHoleAnalysisDataReserveHours = conf.NatHoleAnalysisDataReserveHours out.Transport.TCPMux = lo.ToPtr(conf.TCPMux) out.Transport.TCPMuxKeepaliveInterval = conf.TCPMuxKeepaliveInterval out.Transport.TCPKeepAlive = conf.TCPKeepAlive out.Transport.MaxPoolCount = conf.MaxPoolCount out.Transport.HeartbeatTimeout = conf.HeartbeatTimeout out.Transport.TLS.Force = conf.TLSOnly out.Transport.TLS.CertFile = conf.TLSCertFile out.Transport.TLS.KeyFile = conf.TLSKeyFile out.Transport.TLS.TrustedCaFile = conf.TLSTrustedCaFile out.MaxPortsPerClient = conf.MaxPortsPerClient for _, v := range conf.HTTPPlugins { out.HTTPPlugins = append(out.HTTPPlugins, v1.HTTPPluginOptions{ Name: v.Name, Addr: v.Addr, Path: v.Path, Ops: v.Ops, TLSVerify: v.TLSVerify, }) } out.AllowPorts, _ = types.NewPortsRangeSliceFromString(conf.AllowPortsStr) return out } func transformHeadersFromPluginParams(params map[string]string) v1.HeaderOperations { out := v1.HeaderOperations{} for k, v := range params { k, ok := strings.CutPrefix(k, "plugin_header_") if !ok || k == "" { continue } if out.Set == nil { out.Set = make(map[string]string) } out.Set[k] = v } return out } func Convert_ProxyConf_To_v1_Base(conf ProxyConf) *v1.ProxyBaseConfig { out := &v1.ProxyBaseConfig{} base := conf.GetBaseConfig() out.Name = base.ProxyName out.Type = base.ProxyType out.Metadatas = base.Metas out.Transport.UseEncryption = base.UseEncryption out.Transport.UseCompression = base.UseCompression out.Transport.BandwidthLimit = base.BandwidthLimit out.Transport.BandwidthLimitMode = base.BandwidthLimitMode out.Transport.ProxyProtocolVersion = base.ProxyProtocolVersion out.LoadBalancer.Group = base.Group out.LoadBalancer.GroupKey = base.GroupKey out.HealthCheck.Type = base.HealthCheckType out.HealthCheck.TimeoutSeconds = base.HealthCheckTimeoutS out.HealthCheck.MaxFailed = base.HealthCheckMaxFailed out.HealthCheck.IntervalSeconds = base.HealthCheckIntervalS out.HealthCheck.Path = base.HealthCheckURL out.LocalIP = base.LocalIP out.LocalPort = base.LocalPort switch base.Plugin { case "http2https": out.Plugin.ClientPluginOptions = &v1.HTTP2HTTPSPluginOptions{ LocalAddr: base.PluginParams["plugin_local_addr"], HostHeaderRewrite: base.PluginParams["plugin_host_header_rewrite"], RequestHeaders: transformHeadersFromPluginParams(base.PluginParams), } case "http_proxy": out.Plugin.ClientPluginOptions = &v1.HTTPProxyPluginOptions{ HTTPUser: base.PluginParams["plugin_http_user"], HTTPPassword: base.PluginParams["plugin_http_passwd"], } case "https2http": out.Plugin.ClientPluginOptions = &v1.HTTPS2HTTPPluginOptions{ LocalAddr: base.PluginParams["plugin_local_addr"], HostHeaderRewrite: base.PluginParams["plugin_host_header_rewrite"], RequestHeaders: transformHeadersFromPluginParams(base.PluginParams), CrtPath: base.PluginParams["plugin_crt_path"], KeyPath: base.PluginParams["plugin_key_path"], } case "https2https": out.Plugin.ClientPluginOptions = &v1.HTTPS2HTTPSPluginOptions{ LocalAddr: base.PluginParams["plugin_local_addr"], HostHeaderRewrite: base.PluginParams["plugin_host_header_rewrite"], RequestHeaders: transformHeadersFromPluginParams(base.PluginParams), CrtPath: base.PluginParams["plugin_crt_path"], KeyPath: base.PluginParams["plugin_key_path"], } case "socks5": out.Plugin.ClientPluginOptions = &v1.Socks5PluginOptions{ Username: base.PluginParams["plugin_user"], Password: base.PluginParams["plugin_passwd"], } case "static_file": out.Plugin.ClientPluginOptions = &v1.StaticFilePluginOptions{ LocalPath: base.PluginParams["plugin_local_path"], StripPrefix: base.PluginParams["plugin_strip_prefix"], HTTPUser: base.PluginParams["plugin_http_user"], HTTPPassword: base.PluginParams["plugin_http_passwd"], } case "unix_domain_socket": out.Plugin.ClientPluginOptions = &v1.UnixDomainSocketPluginOptions{ UnixPath: base.PluginParams["plugin_unix_path"], } } out.Plugin.Type = base.Plugin return out } func Convert_ProxyConf_To_v1(conf ProxyConf) v1.ProxyConfigurer { outBase := Convert_ProxyConf_To_v1_Base(conf) var out v1.ProxyConfigurer switch v := conf.(type) { case *TCPProxyConf: c := &v1.TCPProxyConfig{ProxyBaseConfig: *outBase} c.RemotePort = v.RemotePort out = c case *UDPProxyConf: c := &v1.UDPProxyConfig{ProxyBaseConfig: *outBase} c.RemotePort = v.RemotePort out = c case *HTTPProxyConf: c := &v1.HTTPProxyConfig{ProxyBaseConfig: *outBase} c.CustomDomains = v.CustomDomains c.SubDomain = v.SubDomain c.Locations = v.Locations c.HTTPUser = v.HTTPUser c.HTTPPassword = v.HTTPPwd c.HostHeaderRewrite = v.HostHeaderRewrite c.RequestHeaders.Set = v.Headers c.RouteByHTTPUser = v.RouteByHTTPUser out = c case *HTTPSProxyConf: c := &v1.HTTPSProxyConfig{ProxyBaseConfig: *outBase} c.CustomDomains = v.CustomDomains c.SubDomain = v.SubDomain out = c case *TCPMuxProxyConf: c := &v1.TCPMuxProxyConfig{ProxyBaseConfig: *outBase} c.CustomDomains = v.CustomDomains c.SubDomain = v.SubDomain c.HTTPUser = v.HTTPUser c.HTTPPassword = v.HTTPPwd c.RouteByHTTPUser = v.RouteByHTTPUser c.Multiplexer = v.Multiplexer out = c case *STCPProxyConf: c := &v1.STCPProxyConfig{ProxyBaseConfig: *outBase} c.Secretkey = v.Sk c.AllowUsers = v.AllowUsers out = c case *SUDPProxyConf: c := &v1.SUDPProxyConfig{ProxyBaseConfig: *outBase} c.Secretkey = v.Sk c.AllowUsers = v.AllowUsers out = c case *XTCPProxyConf: c := &v1.XTCPProxyConfig{ProxyBaseConfig: *outBase} c.Secretkey = v.Sk c.AllowUsers = v.AllowUsers out = c } return out } func Convert_VisitorConf_To_v1_Base(conf VisitorConf) *v1.VisitorBaseConfig { out := &v1.VisitorBaseConfig{} base := conf.GetBaseConfig() out.Name = base.ProxyName out.Type = base.ProxyType out.Transport.UseEncryption = base.UseEncryption out.Transport.UseCompression = base.UseCompression out.SecretKey = base.Sk out.ServerUser = base.ServerUser out.ServerName = base.ServerName out.BindAddr = base.BindAddr out.BindPort = base.BindPort return out } func Convert_VisitorConf_To_v1(conf VisitorConf) v1.VisitorConfigurer { outBase := Convert_VisitorConf_To_v1_Base(conf) var out v1.VisitorConfigurer switch v := conf.(type) { case *STCPVisitorConf: c := &v1.STCPVisitorConfig{VisitorBaseConfig: *outBase} out = c case *SUDPVisitorConf: c := &v1.SUDPVisitorConfig{VisitorBaseConfig: *outBase} out = c case *XTCPVisitorConf: c := &v1.XTCPVisitorConfig{VisitorBaseConfig: *outBase} c.Protocol = v.Protocol c.KeepTunnelOpen = v.KeepTunnelOpen c.MaxRetriesAnHour = v.MaxRetriesAnHour c.MinRetryInterval = v.MinRetryInterval c.FallbackTo = v.FallbackTo c.FallbackTimeoutMs = v.FallbackTimeoutMs out = c } return out } ================================================ FILE: pkg/config/legacy/parse.go ================================================ // Copyright 2021 The frp Authors // // Licensed under the Apache License, Version 2.0 (the "License"); // you may not use this file except in compliance with the License. // You may obtain a copy of the License at // // http://www.apache.org/licenses/LICENSE-2.0 // // Unless required by applicable law or agreed to in writing, software // distributed under the License is distributed on an "AS IS" BASIS, // WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. // See the License for the specific language governing permissions and // limitations under the License. package legacy import ( "bytes" "fmt" "os" "path/filepath" ) func ParseClientConfig(filePath string) ( cfg ClientCommonConf, proxyCfgs map[string]ProxyConf, visitorCfgs map[string]VisitorConf, err error, ) { var content []byte content, err = GetRenderedConfFromFile(filePath) if err != nil { return } configBuffer := bytes.NewBuffer(nil) configBuffer.Write(content) // Parse common section. cfg, err = UnmarshalClientConfFromIni(content) if err != nil { return } if err = cfg.Validate(); err != nil { err = fmt.Errorf("parse config error: %v", err) return } // Aggregate proxy configs from include files. var buf []byte buf, err = getIncludeContents(cfg.IncludeConfigFiles) if err != nil { err = fmt.Errorf("getIncludeContents error: %v", err) return } configBuffer.WriteString("\n") configBuffer.Write(buf) // Parse all proxy and visitor configs. proxyCfgs, visitorCfgs, err = LoadAllProxyConfsFromIni(cfg.User, configBuffer.Bytes(), cfg.Start) if err != nil { return } return } // getIncludeContents renders all configs from paths. // files format can be a single file path or directory or regex path. func getIncludeContents(paths []string) ([]byte, error) { out := bytes.NewBuffer(nil) for _, path := range paths { absDir, err := filepath.Abs(filepath.Dir(path)) if err != nil { return nil, err } if _, err := os.Stat(absDir); os.IsNotExist(err) { return nil, err } files, err := os.ReadDir(absDir) if err != nil { return nil, err } for _, fi := range files { if fi.IsDir() { continue } absFile := filepath.Join(absDir, fi.Name()) if matched, _ := filepath.Match(filepath.Join(absDir, filepath.Base(path)), absFile); matched { tmpContent, err := GetRenderedConfFromFile(absFile) if err != nil { return nil, fmt.Errorf("render extra config %s error: %v", absFile, err) } out.Write(tmpContent) out.WriteString("\n") } } } return out.Bytes(), nil } ================================================ FILE: pkg/config/legacy/proxy.go ================================================ // Copyright 2023 The frp Authors // // Licensed under the Apache License, Version 2.0 (the "License"); // you may not use this file except in compliance with the License. // You may obtain a copy of the License at // // http://www.apache.org/licenses/LICENSE-2.0 // // Unless required by applicable law or agreed to in writing, software // distributed under the License is distributed on an "AS IS" BASIS, // WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. // See the License for the specific language governing permissions and // limitations under the License. package legacy import ( "fmt" "reflect" "gopkg.in/ini.v1" "github.com/fatedier/frp/pkg/config/types" ) type ProxyType string const ( ProxyTypeTCP ProxyType = "tcp" ProxyTypeUDP ProxyType = "udp" ProxyTypeTCPMUX ProxyType = "tcpmux" ProxyTypeHTTP ProxyType = "http" ProxyTypeHTTPS ProxyType = "https" ProxyTypeSTCP ProxyType = "stcp" ProxyTypeXTCP ProxyType = "xtcp" ProxyTypeSUDP ProxyType = "sudp" ) // Proxy var ( proxyConfTypeMap = map[ProxyType]reflect.Type{ ProxyTypeTCP: reflect.TypeFor[TCPProxyConf](), ProxyTypeUDP: reflect.TypeFor[UDPProxyConf](), ProxyTypeTCPMUX: reflect.TypeFor[TCPMuxProxyConf](), ProxyTypeHTTP: reflect.TypeFor[HTTPProxyConf](), ProxyTypeHTTPS: reflect.TypeFor[HTTPSProxyConf](), ProxyTypeSTCP: reflect.TypeFor[STCPProxyConf](), ProxyTypeXTCP: reflect.TypeFor[XTCPProxyConf](), ProxyTypeSUDP: reflect.TypeFor[SUDPProxyConf](), } ) type ProxyConf interface { // GetBaseConfig returns the BaseProxyConf for this config. GetBaseConfig() *BaseProxyConf // UnmarshalFromIni unmarshals a ini.Section into this config. This function // will be called on the frpc side. UnmarshalFromIni(string, string, *ini.Section) error } func NewConfByType(proxyType ProxyType) ProxyConf { v, ok := proxyConfTypeMap[proxyType] if !ok { return nil } cfg := reflect.New(v).Interface().(ProxyConf) return cfg } // Proxy Conf Loader // DefaultProxyConf creates a empty ProxyConf object by proxyType. // If proxyType doesn't exist, return nil. func DefaultProxyConf(proxyType ProxyType) ProxyConf { return NewConfByType(proxyType) } // Proxy loaded from ini func NewProxyConfFromIni(prefix, name string, section *ini.Section) (ProxyConf, error) { // section.Key: if key not exists, section will set it with default value. proxyType := ProxyType(section.Key("type").String()) if proxyType == "" { proxyType = ProxyTypeTCP } conf := DefaultProxyConf(proxyType) if conf == nil { return nil, fmt.Errorf("invalid type [%s]", proxyType) } if err := conf.UnmarshalFromIni(prefix, name, section); err != nil { return nil, err } return conf, nil } // LocalSvrConf configures what location the client will to, or what // plugin will be used. type LocalSvrConf struct { // LocalIP specifies the IP address or host name to to. LocalIP string `ini:"local_ip" json:"local_ip"` // LocalPort specifies the port to to. LocalPort int `ini:"local_port" json:"local_port"` // Plugin specifies what plugin should be used for ng. If this value // is set, the LocalIp and LocalPort values will be ignored. By default, // this value is "". Plugin string `ini:"plugin" json:"plugin"` // PluginParams specify parameters to be passed to the plugin, if one is // being used. By default, this value is an empty map. PluginParams map[string]string `ini:"-"` } // HealthCheckConf configures health checking. This can be useful for load // balancing purposes to detect and remove proxies to failing services. type HealthCheckConf struct { // HealthCheckType specifies what protocol to use for health checking. // Valid values include "tcp", "http", and "". If this value is "", health // checking will not be performed. By default, this value is "". // // If the type is "tcp", a connection will be attempted to the target // server. If a connection cannot be established, the health check fails. // // If the type is "http", a GET request will be made to the endpoint // specified by HealthCheckURL. If the response is not a 200, the health // check fails. HealthCheckType string `ini:"health_check_type" json:"health_check_type"` // tcp | http // HealthCheckTimeoutS specifies the number of seconds to wait for a health // check attempt to connect. If the timeout is reached, this counts as a // health check failure. By default, this value is 3. HealthCheckTimeoutS int `ini:"health_check_timeout_s" json:"health_check_timeout_s"` // HealthCheckMaxFailed specifies the number of allowed failures before the // is stopped. By default, this value is 1. HealthCheckMaxFailed int `ini:"health_check_max_failed" json:"health_check_max_failed"` // HealthCheckIntervalS specifies the time in seconds between health // checks. By default, this value is 10. HealthCheckIntervalS int `ini:"health_check_interval_s" json:"health_check_interval_s"` // HealthCheckURL specifies the address to send health checks to if the // health check type is "http". HealthCheckURL string `ini:"health_check_url" json:"health_check_url"` // HealthCheckAddr specifies the address to connect to if the health check // type is "tcp". HealthCheckAddr string `ini:"-"` } // BaseProxyConf provides configuration info that is common to all types. type BaseProxyConf struct { // ProxyName is the name of this ProxyName string `ini:"name" json:"name"` // ProxyType specifies the type of this Valid values include "tcp", // "udp", "http", "https", "stcp", and "xtcp". By default, this value is // "tcp". ProxyType string `ini:"type" json:"type"` // UseEncryption controls whether or not communication with the server will // be encrypted. Encryption is done using the tokens supplied in the server // and client configuration. By default, this value is false. UseEncryption bool `ini:"use_encryption" json:"use_encryption"` // UseCompression controls whether or not communication with the server // will be compressed. By default, this value is false. UseCompression bool `ini:"use_compression" json:"use_compression"` // Group specifies which group the is a part of. The server will use // this information to load balance proxies in the same group. If the value // is "", this will not be in a group. By default, this value is "". Group string `ini:"group" json:"group"` // GroupKey specifies a group key, which should be the same among proxies // of the same group. By default, this value is "". GroupKey string `ini:"group_key" json:"group_key"` // ProxyProtocolVersion specifies which protocol version to use. Valid // values include "v1", "v2", and "". If the value is "", a protocol // version will be automatically selected. By default, this value is "". ProxyProtocolVersion string `ini:"proxy_protocol_version" json:"proxy_protocol_version"` // BandwidthLimit limit the bandwidth // 0 means no limit BandwidthLimit types.BandwidthQuantity `ini:"bandwidth_limit" json:"bandwidth_limit"` // BandwidthLimitMode specifies whether to limit the bandwidth on the // client or server side. Valid values include "client" and "server". // By default, this value is "client". BandwidthLimitMode string `ini:"bandwidth_limit_mode" json:"bandwidth_limit_mode"` // meta info for each proxy Metas map[string]string `ini:"-" json:"metas"` LocalSvrConf `ini:",extends"` HealthCheckConf `ini:",extends"` } // Base func (cfg *BaseProxyConf) GetBaseConfig() *BaseProxyConf { return cfg } // BaseProxyConf apply custom logic changes. func (cfg *BaseProxyConf) decorate(_ string, name string, section *ini.Section) error { cfg.ProxyName = name // metas_xxx cfg.Metas = GetMapWithoutPrefix(section.KeysHash(), "meta_") // bandwidth_limit if bandwidth, err := section.GetKey("bandwidth_limit"); err == nil { cfg.BandwidthLimit, err = types.NewBandwidthQuantity(bandwidth.String()) if err != nil { return err } } // plugin_xxx cfg.PluginParams = GetMapByPrefix(section.KeysHash(), "plugin_") return nil } type DomainConf struct { CustomDomains []string `ini:"custom_domains" json:"custom_domains"` SubDomain string `ini:"subdomain" json:"subdomain"` } type RoleServerCommonConf struct { Role string `ini:"role" json:"role"` Sk string `ini:"sk" json:"sk"` AllowUsers []string `ini:"allow_users" json:"allow_users"` } // HTTP type HTTPProxyConf struct { BaseProxyConf `ini:",extends"` DomainConf `ini:",extends"` Locations []string `ini:"locations" json:"locations"` HTTPUser string `ini:"http_user" json:"http_user"` HTTPPwd string `ini:"http_pwd" json:"http_pwd"` HostHeaderRewrite string `ini:"host_header_rewrite" json:"host_header_rewrite"` Headers map[string]string `ini:"-" json:"headers"` RouteByHTTPUser string `ini:"route_by_http_user" json:"route_by_http_user"` } func (cfg *HTTPProxyConf) UnmarshalFromIni(prefix string, name string, section *ini.Section) error { err := preUnmarshalFromIni(cfg, prefix, name, section) if err != nil { return err } // Add custom logic unmarshal if exists cfg.Headers = GetMapWithoutPrefix(section.KeysHash(), "header_") return nil } // HTTPS type HTTPSProxyConf struct { BaseProxyConf `ini:",extends"` DomainConf `ini:",extends"` } func (cfg *HTTPSProxyConf) UnmarshalFromIni(prefix string, name string, section *ini.Section) error { err := preUnmarshalFromIni(cfg, prefix, name, section) if err != nil { return err } // Add custom logic unmarshal if exists return nil } // TCP type TCPProxyConf struct { BaseProxyConf `ini:",extends"` RemotePort int `ini:"remote_port" json:"remote_port"` } func (cfg *TCPProxyConf) UnmarshalFromIni(prefix string, name string, section *ini.Section) error { err := preUnmarshalFromIni(cfg, prefix, name, section) if err != nil { return err } // Add custom logic unmarshal if exists return nil } // UDP type UDPProxyConf struct { BaseProxyConf `ini:",extends"` RemotePort int `ini:"remote_port" json:"remote_port"` } func (cfg *UDPProxyConf) UnmarshalFromIni(prefix string, name string, section *ini.Section) error { err := preUnmarshalFromIni(cfg, prefix, name, section) if err != nil { return err } // Add custom logic unmarshal if exists return nil } // TCPMux type TCPMuxProxyConf struct { BaseProxyConf `ini:",extends"` DomainConf `ini:",extends"` HTTPUser string `ini:"http_user" json:"http_user,omitempty"` HTTPPwd string `ini:"http_pwd" json:"http_pwd,omitempty"` RouteByHTTPUser string `ini:"route_by_http_user" json:"route_by_http_user"` Multiplexer string `ini:"multiplexer"` } func (cfg *TCPMuxProxyConf) UnmarshalFromIni(prefix string, name string, section *ini.Section) error { err := preUnmarshalFromIni(cfg, prefix, name, section) if err != nil { return err } // Add custom logic unmarshal if exists return nil } // STCP type STCPProxyConf struct { BaseProxyConf `ini:",extends"` RoleServerCommonConf `ini:",extends"` } func (cfg *STCPProxyConf) UnmarshalFromIni(prefix string, name string, section *ini.Section) error { err := preUnmarshalFromIni(cfg, prefix, name, section) if err != nil { return err } // Add custom logic unmarshal if exists if cfg.Role == "" { cfg.Role = "server" } return nil } // XTCP type XTCPProxyConf struct { BaseProxyConf `ini:",extends"` RoleServerCommonConf `ini:",extends"` } func (cfg *XTCPProxyConf) UnmarshalFromIni(prefix string, name string, section *ini.Section) error { err := preUnmarshalFromIni(cfg, prefix, name, section) if err != nil { return err } // Add custom logic unmarshal if exists if cfg.Role == "" { cfg.Role = "server" } return nil } // SUDP type SUDPProxyConf struct { BaseProxyConf `ini:",extends"` RoleServerCommonConf `ini:",extends"` } func (cfg *SUDPProxyConf) UnmarshalFromIni(prefix string, name string, section *ini.Section) error { err := preUnmarshalFromIni(cfg, prefix, name, section) if err != nil { return err } // Add custom logic unmarshal if exists return nil } func preUnmarshalFromIni(cfg ProxyConf, prefix string, name string, section *ini.Section) error { err := section.MapTo(cfg) if err != nil { return err } err = cfg.GetBaseConfig().decorate(prefix, name, section) if err != nil { return err } return nil } ================================================ FILE: pkg/config/legacy/server.go ================================================ // Copyright 2023 The frp Authors // // Licensed under the Apache License, Version 2.0 (the "License"); // you may not use this file except in compliance with the License. // You may obtain a copy of the License at // // http://www.apache.org/licenses/LICENSE-2.0 // // Unless required by applicable law or agreed to in writing, software // distributed under the License is distributed on an "AS IS" BASIS, // WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. // See the License for the specific language governing permissions and // limitations under the License. package legacy import ( "strings" "gopkg.in/ini.v1" legacyauth "github.com/fatedier/frp/pkg/auth/legacy" ) type HTTPPluginOptions struct { Name string `ini:"name"` Addr string `ini:"addr"` Path string `ini:"path"` Ops []string `ini:"ops"` TLSVerify bool `ini:"tlsVerify"` } // ServerCommonConf contains information for a server service. It is // recommended to use GetDefaultServerConf instead of creating this object // directly, so that all unspecified fields have reasonable default values. type ServerCommonConf struct { legacyauth.ServerConfig `ini:",extends"` // BindAddr specifies the address that the server binds to. By default, // this value is "0.0.0.0". BindAddr string `ini:"bind_addr" json:"bind_addr"` // BindPort specifies the port that the server listens on. By default, this // value is 7000. BindPort int `ini:"bind_port" json:"bind_port"` // KCPBindPort specifies the KCP port that the server listens on. If this // value is 0, the server will not listen for KCP connections. By default, // this value is 0. KCPBindPort int `ini:"kcp_bind_port" json:"kcp_bind_port"` // QUICBindPort specifies the QUIC port that the server listens on. // Set this value to 0 will disable this feature. // By default, the value is 0. QUICBindPort int `ini:"quic_bind_port" json:"quic_bind_port"` // QUIC protocol options QUICKeepalivePeriod int `ini:"quic_keepalive_period" json:"quic_keepalive_period"` QUICMaxIdleTimeout int `ini:"quic_max_idle_timeout" json:"quic_max_idle_timeout"` QUICMaxIncomingStreams int `ini:"quic_max_incoming_streams" json:"quic_max_incoming_streams"` // ProxyBindAddr specifies the address that the proxy binds to. This value // may be the same as BindAddr. ProxyBindAddr string `ini:"proxy_bind_addr" json:"proxy_bind_addr"` // VhostHTTPPort specifies the port that the server listens for HTTP Vhost // requests. If this value is 0, the server will not listen for HTTP // requests. By default, this value is 0. VhostHTTPPort int `ini:"vhost_http_port" json:"vhost_http_port"` // VhostHTTPSPort specifies the port that the server listens for HTTPS // Vhost requests. If this value is 0, the server will not listen for HTTPS // requests. By default, this value is 0. VhostHTTPSPort int `ini:"vhost_https_port" json:"vhost_https_port"` // TCPMuxHTTPConnectPort specifies the port that the server listens for TCP // HTTP CONNECT requests. If the value is 0, the server will not multiplex TCP // requests on one single port. If it's not - it will listen on this value for // HTTP CONNECT requests. By default, this value is 0. TCPMuxHTTPConnectPort int `ini:"tcpmux_httpconnect_port" json:"tcpmux_httpconnect_port"` // If TCPMuxPassthrough is true, frps won't do any update on traffic. TCPMuxPassthrough bool `ini:"tcpmux_passthrough" json:"tcpmux_passthrough"` // VhostHTTPTimeout specifies the response header timeout for the Vhost // HTTP server, in seconds. By default, this value is 60. VhostHTTPTimeout int64 `ini:"vhost_http_timeout" json:"vhost_http_timeout"` // DashboardAddr specifies the address that the dashboard binds to. By // default, this value is "0.0.0.0". DashboardAddr string `ini:"dashboard_addr" json:"dashboard_addr"` // DashboardPort specifies the port that the dashboard listens on. If this // value is 0, the dashboard will not be started. By default, this value is // 0. DashboardPort int `ini:"dashboard_port" json:"dashboard_port"` // DashboardTLSCertFile specifies the path of the cert file that the server will // load. If "dashboard_tls_cert_file", "dashboard_tls_key_file" are valid, the server will use this // supplied tls configuration. DashboardTLSCertFile string `ini:"dashboard_tls_cert_file" json:"dashboard_tls_cert_file"` // DashboardTLSKeyFile specifies the path of the secret key that the server will // load. If "dashboard_tls_cert_file", "dashboard_tls_key_file" are valid, the server will use this // supplied tls configuration. DashboardTLSKeyFile string `ini:"dashboard_tls_key_file" json:"dashboard_tls_key_file"` // DashboardTLSMode specifies the mode of the dashboard between HTTP or HTTPS modes. By // default, this value is false, which is HTTP mode. DashboardTLSMode bool `ini:"dashboard_tls_mode" json:"dashboard_tls_mode"` // DashboardUser specifies the username that the dashboard will use for // login. DashboardUser string `ini:"dashboard_user" json:"dashboard_user"` // DashboardPwd specifies the password that the dashboard will use for // login. DashboardPwd string `ini:"dashboard_pwd" json:"dashboard_pwd"` // EnablePrometheus will export prometheus metrics on {dashboard_addr}:{dashboard_port} // in /metrics api. EnablePrometheus bool `ini:"enable_prometheus" json:"enable_prometheus"` // AssetsDir specifies the local directory that the dashboard will load // resources from. If this value is "", assets will be loaded from the // bundled executable using statik. By default, this value is "". AssetsDir string `ini:"assets_dir" json:"assets_dir"` // LogFile specifies a file where logs will be written to. This value will // only be used if LogWay is set appropriately. By default, this value is // "console". LogFile string `ini:"log_file" json:"log_file"` // LogWay specifies the way logging is managed. Valid values are "console" // or "file". If "console" is used, logs will be printed to stdout. If // "file" is used, logs will be printed to LogFile. By default, this value // is "console". LogWay string `ini:"log_way" json:"log_way"` // LogLevel specifies the minimum log level. Valid values are "trace", // "debug", "info", "warn", and "error". By default, this value is "info". LogLevel string `ini:"log_level" json:"log_level"` // LogMaxDays specifies the maximum number of days to store log information // before deletion. This is only used if LogWay == "file". By default, this // value is 0. LogMaxDays int64 `ini:"log_max_days" json:"log_max_days"` // DisableLogColor disables log colors when LogWay == "console" when set to // true. By default, this value is false. DisableLogColor bool `ini:"disable_log_color" json:"disable_log_color"` // DetailedErrorsToClient defines whether to send the specific error (with // debug info) to frpc. By default, this value is true. DetailedErrorsToClient bool `ini:"detailed_errors_to_client" json:"detailed_errors_to_client"` // SubDomainHost specifies the domain that will be attached to sub-domains // requested by the client when using Vhost proxying. For example, if this // value is set to "frps.com" and the client requested the subdomain // "test", the resulting URL would be "test.frps.com". By default, this // value is "". SubDomainHost string `ini:"subdomain_host" json:"subdomain_host"` // TCPMux toggles TCP stream multiplexing. This allows multiple requests // from a client to share a single TCP connection. By default, this value // is true. TCPMux bool `ini:"tcp_mux" json:"tcp_mux"` // TCPMuxKeepaliveInterval specifies the keep alive interval for TCP stream multiplier. // If TCPMux is true, heartbeat of application layer is unnecessary because it can only rely on heartbeat in TCPMux. TCPMuxKeepaliveInterval int64 `ini:"tcp_mux_keepalive_interval" json:"tcp_mux_keepalive_interval"` // TCPKeepAlive specifies the interval between keep-alive probes for an active network connection between frpc and frps. // If negative, keep-alive probes are disabled. TCPKeepAlive int64 `ini:"tcp_keepalive" json:"tcp_keepalive"` // Custom404Page specifies a path to a custom 404 page to display. If this // value is "", a default page will be displayed. By default, this value is // "". Custom404Page string `ini:"custom_404_page" json:"custom_404_page"` // AllowPorts specifies a set of ports that clients are able to proxy to. // If the length of this value is 0, all ports are allowed. By default, // this value is an empty set. AllowPorts map[int]struct{} `ini:"-" json:"-"` // Original string. AllowPortsStr string `ini:"-" json:"-"` // MaxPoolCount specifies the maximum pool size for each proxy. By default, // this value is 5. MaxPoolCount int64 `ini:"max_pool_count" json:"max_pool_count"` // MaxPortsPerClient specifies the maximum number of ports a single client // may proxy to. If this value is 0, no limit will be applied. By default, // this value is 0. MaxPortsPerClient int64 `ini:"max_ports_per_client" json:"max_ports_per_client"` // TLSOnly specifies whether to only accept TLS-encrypted connections. // By default, the value is false. TLSOnly bool `ini:"tls_only" json:"tls_only"` // TLSCertFile specifies the path of the cert file that the server will // load. If "tls_cert_file", "tls_key_file" are valid, the server will use this // supplied tls configuration. Otherwise, the server will use the tls // configuration generated by itself. TLSCertFile string `ini:"tls_cert_file" json:"tls_cert_file"` // TLSKeyFile specifies the path of the secret key that the server will // load. If "tls_cert_file", "tls_key_file" are valid, the server will use this // supplied tls configuration. Otherwise, the server will use the tls // configuration generated by itself. TLSKeyFile string `ini:"tls_key_file" json:"tls_key_file"` // TLSTrustedCaFile specifies the paths of the client cert files that the // server will load. It only works when "tls_only" is true. If // "tls_trusted_ca_file" is valid, the server will verify each client's // certificate. TLSTrustedCaFile string `ini:"tls_trusted_ca_file" json:"tls_trusted_ca_file"` // HeartBeatTimeout specifies the maximum time to wait for a heartbeat // before terminating the connection. It is not recommended to change this // value. By default, this value is 90. Set negative value to disable it. HeartbeatTimeout int64 `ini:"heartbeat_timeout" json:"heartbeat_timeout"` // UserConnTimeout specifies the maximum time to wait for a work // connection. By default, this value is 10. UserConnTimeout int64 `ini:"user_conn_timeout" json:"user_conn_timeout"` // HTTPPlugins specify the server plugins support HTTP protocol. HTTPPlugins map[string]HTTPPluginOptions `ini:"-" json:"http_plugins"` // UDPPacketSize specifies the UDP packet size // By default, this value is 1500 UDPPacketSize int64 `ini:"udp_packet_size" json:"udp_packet_size"` // Enable golang pprof handlers in dashboard listener. // Dashboard port must be set first. PprofEnable bool `ini:"pprof_enable" json:"pprof_enable"` // NatHoleAnalysisDataReserveHours specifies the hours to reserve nat hole analysis data. NatHoleAnalysisDataReserveHours int64 `ini:"nat_hole_analysis_data_reserve_hours" json:"nat_hole_analysis_data_reserve_hours"` } // GetDefaultServerConf returns a server configuration with reasonable defaults. // Note: Some default values here will be set to empty and will be converted to them // new configuration through the 'Complete' function to set them as the default // values of the new configuration. func GetDefaultServerConf() ServerCommonConf { return ServerCommonConf{ ServerConfig: legacyauth.GetDefaultServerConf(), DashboardAddr: "0.0.0.0", LogFile: "console", LogWay: "console", DetailedErrorsToClient: true, TCPMux: true, AllowPorts: make(map[int]struct{}), HTTPPlugins: make(map[string]HTTPPluginOptions), } } func UnmarshalServerConfFromIni(source any) (ServerCommonConf, error) { f, err := ini.LoadSources(ini.LoadOptions{ Insensitive: false, InsensitiveSections: false, InsensitiveKeys: false, IgnoreInlineComment: true, AllowBooleanKeys: true, }, source) if err != nil { return ServerCommonConf{}, err } s, err := f.GetSection("common") if err != nil { return ServerCommonConf{}, err } common := GetDefaultServerConf() err = s.MapTo(&common) if err != nil { return ServerCommonConf{}, err } // allow_ports allowPortStr := s.Key("allow_ports").String() if allowPortStr != "" { common.AllowPortsStr = allowPortStr } // plugin.xxx pluginOpts := make(map[string]HTTPPluginOptions) for _, section := range f.Sections() { name := section.Name() if !strings.HasPrefix(name, "plugin.") { continue } opt, err := loadHTTPPluginOpt(section) if err != nil { return ServerCommonConf{}, err } pluginOpts[opt.Name] = *opt } common.HTTPPlugins = pluginOpts return common, nil } func loadHTTPPluginOpt(section *ini.Section) (*HTTPPluginOptions, error) { name := strings.TrimSpace(strings.TrimPrefix(section.Name(), "plugin.")) opt := &HTTPPluginOptions{} err := section.MapTo(opt) if err != nil { return nil, err } opt.Name = name return opt, nil } ================================================ FILE: pkg/config/legacy/utils.go ================================================ // Copyright 2020 The frp Authors // // Licensed under the Apache License, Version 2.0 (the "License"); // you may not use this file except in compliance with the License. // You may obtain a copy of the License at // // http://www.apache.org/licenses/LICENSE-2.0 // // Unless required by applicable law or agreed to in writing, software // distributed under the License is distributed on an "AS IS" BASIS, // WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. // See the License for the specific language governing permissions and // limitations under the License. package legacy import ( "strings" ) func GetMapWithoutPrefix(set map[string]string, prefix string) map[string]string { m := make(map[string]string) for key, value := range set { if trimmed, ok := strings.CutPrefix(key, prefix); ok { m[trimmed] = value } } if len(m) == 0 { return nil } return m } func GetMapByPrefix(set map[string]string, prefix string) map[string]string { m := make(map[string]string) for key, value := range set { if strings.HasPrefix(key, prefix) { m[key] = value } } if len(m) == 0 { return nil } return m } ================================================ FILE: pkg/config/legacy/value.go ================================================ // Copyright 2020 The frp Authors // // Licensed under the Apache License, Version 2.0 (the "License"); // you may not use this file except in compliance with the License. // You may obtain a copy of the License at // // http://www.apache.org/licenses/LICENSE-2.0 // // Unless required by applicable law or agreed to in writing, software // distributed under the License is distributed on an "AS IS" BASIS, // WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. // See the License for the specific language governing permissions and // limitations under the License. package legacy import ( "bytes" "os" "strings" "text/template" ) var glbEnvs map[string]string func init() { glbEnvs = make(map[string]string) envs := os.Environ() for _, env := range envs { pair := strings.SplitN(env, "=", 2) if len(pair) != 2 { continue } glbEnvs[pair[0]] = pair[1] } } type Values struct { Envs map[string]string // environment vars } func GetValues() *Values { return &Values{ Envs: glbEnvs, } } func RenderContent(in []byte) (out []byte, err error) { tmpl, errRet := template.New("frp").Parse(string(in)) if errRet != nil { err = errRet return } buffer := bytes.NewBufferString("") v := GetValues() err = tmpl.Execute(buffer, v) if err != nil { return } out = buffer.Bytes() return } func GetRenderedConfFromFile(path string) (out []byte, err error) { var b []byte b, err = os.ReadFile(path) if err != nil { return } out, err = RenderContent(b) return } ================================================ FILE: pkg/config/legacy/visitor.go ================================================ // Copyright 2023 The frp Authors // // Licensed under the Apache License, Version 2.0 (the "License"); // you may not use this file except in compliance with the License. // You may obtain a copy of the License at // // http://www.apache.org/licenses/LICENSE-2.0 // // Unless required by applicable law or agreed to in writing, software // distributed under the License is distributed on an "AS IS" BASIS, // WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. // See the License for the specific language governing permissions and // limitations under the License. package legacy import ( "fmt" "reflect" "gopkg.in/ini.v1" ) type VisitorType string const ( VisitorTypeSTCP VisitorType = "stcp" VisitorTypeXTCP VisitorType = "xtcp" VisitorTypeSUDP VisitorType = "sudp" ) // Visitor var ( visitorConfTypeMap = map[VisitorType]reflect.Type{ VisitorTypeSTCP: reflect.TypeFor[STCPVisitorConf](), VisitorTypeXTCP: reflect.TypeFor[XTCPVisitorConf](), VisitorTypeSUDP: reflect.TypeFor[SUDPVisitorConf](), } ) type VisitorConf interface { // GetBaseConfig returns the base config of visitor. GetBaseConfig() *BaseVisitorConf // UnmarshalFromIni unmarshals config from ini. UnmarshalFromIni(prefix string, name string, section *ini.Section) error } // DefaultVisitorConf creates a empty VisitorConf object by visitorType. // If visitorType doesn't exist, return nil. func DefaultVisitorConf(visitorType VisitorType) VisitorConf { v, ok := visitorConfTypeMap[visitorType] if !ok { return nil } return reflect.New(v).Interface().(VisitorConf) } type BaseVisitorConf struct { ProxyName string `ini:"name" json:"name"` ProxyType string `ini:"type" json:"type"` UseEncryption bool `ini:"use_encryption" json:"use_encryption"` UseCompression bool `ini:"use_compression" json:"use_compression"` Role string `ini:"role" json:"role"` Sk string `ini:"sk" json:"sk"` // if the server user is not set, it defaults to the current user ServerUser string `ini:"server_user" json:"server_user"` ServerName string `ini:"server_name" json:"server_name"` BindAddr string `ini:"bind_addr" json:"bind_addr"` // BindPort is the port that visitor listens on. // It can be less than 0, it means don't bind to the port and only receive connections redirected from // other visitors. (This is not supported for SUDP now) BindPort int `ini:"bind_port" json:"bind_port"` } // Base func (cfg *BaseVisitorConf) GetBaseConfig() *BaseVisitorConf { return cfg } func (cfg *BaseVisitorConf) unmarshalFromIni(_ string, name string, _ *ini.Section) error { // Custom decoration after basic unmarshal: cfg.ProxyName = name // bind_addr if cfg.BindAddr == "" { cfg.BindAddr = "127.0.0.1" } return nil } func preVisitorUnmarshalFromIni(cfg VisitorConf, prefix string, name string, section *ini.Section) error { err := section.MapTo(cfg) if err != nil { return err } err = cfg.GetBaseConfig().unmarshalFromIni(prefix, name, section) if err != nil { return err } return nil } type SUDPVisitorConf struct { BaseVisitorConf `ini:",extends"` } func (cfg *SUDPVisitorConf) UnmarshalFromIni(prefix string, name string, section *ini.Section) (err error) { err = preVisitorUnmarshalFromIni(cfg, prefix, name, section) if err != nil { return } // Add custom logic unmarshal, if exists return } type STCPVisitorConf struct { BaseVisitorConf `ini:",extends"` } func (cfg *STCPVisitorConf) UnmarshalFromIni(prefix string, name string, section *ini.Section) (err error) { err = preVisitorUnmarshalFromIni(cfg, prefix, name, section) if err != nil { return } // Add custom logic unmarshal, if exists return } type XTCPVisitorConf struct { BaseVisitorConf `ini:",extends"` Protocol string `ini:"protocol" json:"protocol,omitempty"` KeepTunnelOpen bool `ini:"keep_tunnel_open" json:"keep_tunnel_open,omitempty"` MaxRetriesAnHour int `ini:"max_retries_an_hour" json:"max_retries_an_hour,omitempty"` MinRetryInterval int `ini:"min_retry_interval" json:"min_retry_interval,omitempty"` FallbackTo string `ini:"fallback_to" json:"fallback_to,omitempty"` FallbackTimeoutMs int `ini:"fallback_timeout_ms" json:"fallback_timeout_ms,omitempty"` } func (cfg *XTCPVisitorConf) UnmarshalFromIni(prefix string, name string, section *ini.Section) (err error) { err = preVisitorUnmarshalFromIni(cfg, prefix, name, section) if err != nil { return } // Add custom logic unmarshal, if exists if cfg.Protocol == "" { cfg.Protocol = "quic" } if cfg.MaxRetriesAnHour <= 0 { cfg.MaxRetriesAnHour = 8 } if cfg.MinRetryInterval <= 0 { cfg.MinRetryInterval = 90 } if cfg.FallbackTimeoutMs <= 0 { cfg.FallbackTimeoutMs = 1000 } return } // Visitor loaded from ini func NewVisitorConfFromIni(prefix string, name string, section *ini.Section) (VisitorConf, error) { // section.Key: if key not exists, section will set it with default value. visitorType := VisitorType(section.Key("type").String()) if visitorType == "" { return nil, fmt.Errorf("type shouldn't be empty") } conf := DefaultVisitorConf(visitorType) if conf == nil { return nil, fmt.Errorf("type [%s] error", visitorType) } if err := conf.UnmarshalFromIni(prefix, name, section); err != nil { return nil, fmt.Errorf("type [%s] error", visitorType) } return conf, nil } ================================================ FILE: pkg/config/load.go ================================================ // Copyright 2023 The frp Authors // // Licensed under the Apache License, Version 2.0 (the "License"); // you may not use this file except in compliance with the License. // You may obtain a copy of the License at // // http://www.apache.org/licenses/LICENSE-2.0 // // Unless required by applicable law or agreed to in writing, software // distributed under the License is distributed on an "AS IS" BASIS, // WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. // See the License for the specific language governing permissions and // limitations under the License. package config import ( "bytes" "encoding/json" "errors" "fmt" "os" "path/filepath" "strings" "text/template" toml "github.com/pelletier/go-toml/v2" "github.com/samber/lo" "gopkg.in/ini.v1" "k8s.io/apimachinery/pkg/util/sets" "k8s.io/apimachinery/pkg/util/yaml" "github.com/fatedier/frp/pkg/config/legacy" v1 "github.com/fatedier/frp/pkg/config/v1" "github.com/fatedier/frp/pkg/config/v1/validation" "github.com/fatedier/frp/pkg/msg" "github.com/fatedier/frp/pkg/util/jsonx" "github.com/fatedier/frp/pkg/util/util" ) var glbEnvs map[string]string func init() { glbEnvs = make(map[string]string) envs := os.Environ() for _, env := range envs { pair := strings.SplitN(env, "=", 2) if len(pair) != 2 { continue } glbEnvs[pair[0]] = pair[1] } } type Values struct { Envs map[string]string // environment vars } func GetValues() *Values { return &Values{ Envs: glbEnvs, } } func DetectLegacyINIFormat(content []byte) bool { f, err := ini.Load(content) if err != nil { return false } if _, err := f.GetSection("common"); err == nil { return true } return false } func DetectLegacyINIFormatFromFile(path string) bool { b, err := os.ReadFile(path) if err != nil { return false } return DetectLegacyINIFormat(b) } func RenderWithTemplate(in []byte, values *Values) ([]byte, error) { tmpl, err := template.New("frp").Funcs(template.FuncMap{ "parseNumberRange": parseNumberRange, "parseNumberRangePair": parseNumberRangePair, }).Parse(string(in)) if err != nil { return nil, err } buffer := bytes.NewBufferString("") if err := tmpl.Execute(buffer, values); err != nil { return nil, err } return buffer.Bytes(), nil } func LoadFileContentWithTemplate(path string, values *Values) ([]byte, error) { b, err := os.ReadFile(path) if err != nil { return nil, err } return RenderWithTemplate(b, values) } func LoadConfigureFromFile(path string, c any, strict bool) error { content, err := LoadFileContentWithTemplate(path, GetValues()) if err != nil { return err } return LoadConfigure(content, c, strict, detectFormatFromPath(path)) } // detectFormatFromPath returns a format hint based on the file extension. func detectFormatFromPath(path string) string { switch strings.ToLower(filepath.Ext(path)) { case ".toml": return "toml" case ".yaml", ".yml": return "yaml" case ".json": return "json" default: return "" } } // parseYAMLWithDotFieldsHandling parses YAML with dot-prefixed fields handling // This function handles both cases efficiently: with or without dot fields func parseYAMLWithDotFieldsHandling(content []byte, target any) error { var temp any if err := yaml.Unmarshal(content, &temp); err != nil { return err } // Remove dot fields if it's a map if tempMap, ok := temp.(map[string]any); ok { for key := range tempMap { if strings.HasPrefix(key, ".") { delete(tempMap, key) } } } // Convert to JSON and decode with strict validation jsonBytes, err := jsonx.Marshal(temp) if err != nil { return err } return decodeJSONContent(jsonBytes, target, true) } func decodeJSONContent(content []byte, target any, strict bool) error { if clientCfg, ok := target.(*v1.ClientConfig); ok { decoded, err := v1.DecodeClientConfigJSON(content, v1.DecodeOptions{ DisallowUnknownFields: strict, }) if err != nil { return err } *clientCfg = decoded return nil } return jsonx.UnmarshalWithOptions(content, target, jsonx.DecodeOptions{ RejectUnknownMembers: strict, }) } // LoadConfigure loads configuration from bytes and unmarshal into c. // Now it supports json, yaml and toml format. // An optional format hint (e.g. "toml", "yaml", "json") can be provided // to enable better error messages with line number information. func LoadConfigure(b []byte, c any, strict bool, formats ...string) error { format := "" if len(formats) > 0 { format = formats[0] } originalBytes := b parsedFromTOML := false var tomlObj any tomlErr := toml.Unmarshal(b, &tomlObj) if tomlErr == nil { parsedFromTOML = true var err error b, err = jsonx.Marshal(&tomlObj) if err != nil { return err } } else if format == "toml" { // File is known to be TOML but has syntax errors. return formatTOMLError(tomlErr) } // If the buffer smells like JSON (first non-whitespace character is '{'), unmarshal as JSON directly. if yaml.IsJSONBuffer(b) { if err := decodeJSONContent(b, c, strict); err != nil { return enhanceDecodeError(err, originalBytes, !parsedFromTOML) } return nil } // Handle YAML content if strict { // In strict mode, always use our custom handler to support YAML merge if err := parseYAMLWithDotFieldsHandling(b, c); err != nil { return enhanceDecodeError(err, originalBytes, !parsedFromTOML) } return nil } // Non-strict mode, parse normally return yaml.Unmarshal(b, c) } // formatTOMLError extracts line/column information from TOML decode errors. func formatTOMLError(err error) error { var decErr *toml.DecodeError if errors.As(err, &decErr) { row, col := decErr.Position() return fmt.Errorf("toml: line %d, column %d: %s", row, col, decErr.Error()) } var strictErr *toml.StrictMissingError if errors.As(err, &strictErr) { return strictErr } return err } // enhanceDecodeError tries to add field path and line number information to JSON/YAML decode errors. func enhanceDecodeError(err error, originalContent []byte, includeLine bool) error { var typeErr *json.UnmarshalTypeError if errors.As(err, &typeErr) && typeErr.Field != "" { if includeLine { line := findFieldLineInContent(originalContent, typeErr.Field) if line > 0 { return fmt.Errorf("line %d: field \"%s\": cannot unmarshal %s into %s", line, typeErr.Field, typeErr.Value, typeErr.Type) } } return fmt.Errorf("field \"%s\": cannot unmarshal %s into %s", typeErr.Field, typeErr.Value, typeErr.Type) } return err } // findFieldLineInContent searches the original config content for a field name // and returns the 1-indexed line number where it appears, or 0 if not found. func findFieldLineInContent(content []byte, fieldPath string) int { if fieldPath == "" { return 0 } // Use the last component of the field path (e.g. "proxies" from "proxies" or // "protocol" from "transport.protocol"). parts := strings.Split(fieldPath, ".") searchKey := parts[len(parts)-1] lines := bytes.Split(content, []byte("\n")) for i, line := range lines { trimmed := bytes.TrimSpace(line) // Match TOML key assignments like: key = ... if bytes.HasPrefix(trimmed, []byte(searchKey)) { rest := bytes.TrimSpace(trimmed[len(searchKey):]) if len(rest) > 0 && rest[0] == '=' { return i + 1 } } // Match TOML table array headers like: [[proxies]] if bytes.Contains(trimmed, []byte("[["+searchKey+"]]")) { return i + 1 } } return 0 } func NewProxyConfigurerFromMsg(m *msg.NewProxy, serverCfg *v1.ServerConfig) (v1.ProxyConfigurer, error) { m.ProxyType = util.EmptyOr(m.ProxyType, string(v1.ProxyTypeTCP)) configurer := v1.NewProxyConfigurerByType(v1.ProxyType(m.ProxyType)) if configurer == nil { return nil, fmt.Errorf("unknown proxy type: %s", m.ProxyType) } configurer.UnmarshalFromMsg(m) configurer.Complete() if err := validation.ValidateProxyConfigurerForServer(configurer, serverCfg); err != nil { return nil, err } return configurer, nil } func LoadServerConfig(path string, strict bool) (*v1.ServerConfig, bool, error) { var ( svrCfg *v1.ServerConfig isLegacyFormat bool ) // detect legacy ini format if DetectLegacyINIFormatFromFile(path) { content, err := legacy.GetRenderedConfFromFile(path) if err != nil { return nil, true, err } legacyCfg, err := legacy.UnmarshalServerConfFromIni(content) if err != nil { return nil, true, err } svrCfg = legacy.Convert_ServerCommonConf_To_v1(&legacyCfg) isLegacyFormat = true } else { svrCfg = &v1.ServerConfig{} if err := LoadConfigureFromFile(path, svrCfg, strict); err != nil { return nil, false, err } } if svrCfg != nil { if err := svrCfg.Complete(); err != nil { return nil, isLegacyFormat, err } } return svrCfg, isLegacyFormat, nil } // ClientConfigLoadResult contains the result of loading a client configuration file. type ClientConfigLoadResult struct { // Common contains the common client configuration. Common *v1.ClientCommonConfig // Proxies contains proxy configurations from inline [[proxies]] and includeConfigFiles. // These are NOT completed (user prefix not added). Proxies []v1.ProxyConfigurer // Visitors contains visitor configurations from inline [[visitors]] and includeConfigFiles. // These are NOT completed. Visitors []v1.VisitorConfigurer // IsLegacyFormat indicates whether the config file is in legacy INI format. IsLegacyFormat bool } // LoadClientConfigResult loads and parses a client configuration file. // It returns the raw configuration without completing proxies/visitors. // The caller should call Complete on the configs manually for legacy behavior. func LoadClientConfigResult(path string, strict bool) (*ClientConfigLoadResult, error) { result := &ClientConfigLoadResult{ Proxies: make([]v1.ProxyConfigurer, 0), Visitors: make([]v1.VisitorConfigurer, 0), } if DetectLegacyINIFormatFromFile(path) { legacyCommon, legacyProxyCfgs, legacyVisitorCfgs, err := legacy.ParseClientConfig(path) if err != nil { return nil, err } result.Common = legacy.Convert_ClientCommonConf_To_v1(&legacyCommon) for _, c := range legacyProxyCfgs { result.Proxies = append(result.Proxies, legacy.Convert_ProxyConf_To_v1(c)) } for _, c := range legacyVisitorCfgs { result.Visitors = append(result.Visitors, legacy.Convert_VisitorConf_To_v1(c)) } result.IsLegacyFormat = true } else { allCfg := v1.ClientConfig{} if err := LoadConfigureFromFile(path, &allCfg, strict); err != nil { return nil, err } result.Common = &allCfg.ClientCommonConfig for _, c := range allCfg.Proxies { result.Proxies = append(result.Proxies, c.ProxyConfigurer) } for _, c := range allCfg.Visitors { result.Visitors = append(result.Visitors, c.VisitorConfigurer) } } // Load additional config from includes. // legacy ini format already handle this in ParseClientConfig. if len(result.Common.IncludeConfigFiles) > 0 && !result.IsLegacyFormat { extProxyCfgs, extVisitorCfgs, err := LoadAdditionalClientConfigs(result.Common.IncludeConfigFiles, result.IsLegacyFormat, strict) if err != nil { return nil, err } result.Proxies = append(result.Proxies, extProxyCfgs...) result.Visitors = append(result.Visitors, extVisitorCfgs...) } // Complete the common config if result.Common != nil { if err := result.Common.Complete(); err != nil { return nil, err } } return result, nil } func LoadClientConfig(path string, strict bool) ( *v1.ClientCommonConfig, []v1.ProxyConfigurer, []v1.VisitorConfigurer, bool, error, ) { result, err := LoadClientConfigResult(path, strict) if err != nil { return nil, nil, nil, result != nil && result.IsLegacyFormat, err } proxyCfgs := result.Proxies visitorCfgs := result.Visitors proxyCfgs, visitorCfgs = FilterClientConfigurers(result.Common, proxyCfgs, visitorCfgs) proxyCfgs = CompleteProxyConfigurers(proxyCfgs) visitorCfgs = CompleteVisitorConfigurers(visitorCfgs) return result.Common, proxyCfgs, visitorCfgs, result.IsLegacyFormat, nil } func CompleteProxyConfigurers(proxies []v1.ProxyConfigurer) []v1.ProxyConfigurer { proxyCfgs := proxies for _, c := range proxyCfgs { c.Complete() } return proxyCfgs } func CompleteVisitorConfigurers(visitors []v1.VisitorConfigurer) []v1.VisitorConfigurer { visitorCfgs := visitors for _, c := range visitorCfgs { c.Complete() } return visitorCfgs } func FilterClientConfigurers( common *v1.ClientCommonConfig, proxies []v1.ProxyConfigurer, visitors []v1.VisitorConfigurer, ) ([]v1.ProxyConfigurer, []v1.VisitorConfigurer) { if common == nil { common = &v1.ClientCommonConfig{} } proxyCfgs := proxies visitorCfgs := visitors // Filter by start across merged configurers from all sources. // For example, store entries are also filtered by this set. if len(common.Start) > 0 { startSet := sets.New(common.Start...) proxyCfgs = lo.Filter(proxyCfgs, func(c v1.ProxyConfigurer, _ int) bool { return startSet.Has(c.GetBaseConfig().Name) }) visitorCfgs = lo.Filter(visitorCfgs, func(c v1.VisitorConfigurer, _ int) bool { return startSet.Has(c.GetBaseConfig().Name) }) } // Filter by enabled field in each proxy // nil or true means enabled, false means disabled proxyCfgs = lo.Filter(proxyCfgs, func(c v1.ProxyConfigurer, _ int) bool { enabled := c.GetBaseConfig().Enabled return enabled == nil || *enabled }) visitorCfgs = lo.Filter(visitorCfgs, func(c v1.VisitorConfigurer, _ int) bool { enabled := c.GetBaseConfig().Enabled return enabled == nil || *enabled }) return proxyCfgs, visitorCfgs } func LoadAdditionalClientConfigs(paths []string, isLegacyFormat bool, strict bool) ([]v1.ProxyConfigurer, []v1.VisitorConfigurer, error) { proxyCfgs := make([]v1.ProxyConfigurer, 0) visitorCfgs := make([]v1.VisitorConfigurer, 0) for _, path := range paths { absDir, err := filepath.Abs(filepath.Dir(path)) if err != nil { return nil, nil, err } if _, err := os.Stat(absDir); os.IsNotExist(err) { return nil, nil, err } files, err := os.ReadDir(absDir) if err != nil { return nil, nil, err } for _, fi := range files { if fi.IsDir() { continue } absFile := filepath.Join(absDir, fi.Name()) if matched, _ := filepath.Match(filepath.Join(absDir, filepath.Base(path)), absFile); matched { // support yaml/json/toml cfg := v1.ClientConfig{} if err := LoadConfigureFromFile(absFile, &cfg, strict); err != nil { return nil, nil, fmt.Errorf("load additional config from %s error: %v", absFile, err) } for _, c := range cfg.Proxies { proxyCfgs = append(proxyCfgs, c.ProxyConfigurer) } for _, c := range cfg.Visitors { visitorCfgs = append(visitorCfgs, c.VisitorConfigurer) } } } } return proxyCfgs, visitorCfgs, nil } ================================================ FILE: pkg/config/load_test.go ================================================ // Copyright 2023 The frp Authors // // Licensed under the Apache License, Version 2.0 (the "License"); // you may not use this file except in compliance with the License. // You may obtain a copy of the License at // // http://www.apache.org/licenses/LICENSE-2.0 // // Unless required by applicable law or agreed to in writing, software // distributed under the License is distributed on an "AS IS" BASIS, // WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. // See the License for the specific language governing permissions and // limitations under the License. package config import ( "encoding/json" "fmt" "strings" "testing" "github.com/stretchr/testify/require" v1 "github.com/fatedier/frp/pkg/config/v1" ) const tomlServerContent = ` bindAddr = "127.0.0.1" kcpBindPort = 7000 quicBindPort = 7001 tcpmuxHTTPConnectPort = 7005 custom404Page = "/abc.html" transport.tcpKeepalive = 10 ` const yamlServerContent = ` bindAddr: 127.0.0.1 kcpBindPort: 7000 quicBindPort: 7001 tcpmuxHTTPConnectPort: 7005 custom404Page: /abc.html transport: tcpKeepalive: 10 ` const jsonServerContent = ` { "bindAddr": "127.0.0.1", "kcpBindPort": 7000, "quicBindPort": 7001, "tcpmuxHTTPConnectPort": 7005, "custom404Page": "/abc.html", "transport": { "tcpKeepalive": 10 } } ` func TestLoadServerConfig(t *testing.T) { tests := []struct { name string content string }{ {"toml", tomlServerContent}, {"yaml", yamlServerContent}, {"json", jsonServerContent}, } for _, test := range tests { t.Run(test.name, func(t *testing.T) { require := require.New(t) svrCfg := v1.ServerConfig{} err := LoadConfigure([]byte(test.content), &svrCfg, true) require.NoError(err) require.EqualValues("127.0.0.1", svrCfg.BindAddr) require.EqualValues(7000, svrCfg.KCPBindPort) require.EqualValues(7001, svrCfg.QUICBindPort) require.EqualValues(7005, svrCfg.TCPMuxHTTPConnectPort) require.EqualValues("/abc.html", svrCfg.Custom404Page) require.EqualValues(10, svrCfg.Transport.TCPKeepAlive) }) } } // Test that loading in strict mode fails when the config is invalid. func TestLoadServerConfigStrictMode(t *testing.T) { tests := []struct { name string content string }{ {"toml", tomlServerContent}, {"yaml", yamlServerContent}, {"json", jsonServerContent}, } for _, strict := range []bool{false, true} { for _, test := range tests { t.Run(fmt.Sprintf("%s-strict-%t", test.name, strict), func(t *testing.T) { require := require.New(t) // Break the content with an innocent typo brokenContent := strings.Replace(test.content, "bindAddr", "bindAdur", 1) svrCfg := v1.ServerConfig{} err := LoadConfigure([]byte(brokenContent), &svrCfg, strict) if strict { require.ErrorContains(err, "bindAdur") } else { require.NoError(err) // BindAddr didn't get parsed because of the typo. require.EqualValues("", svrCfg.BindAddr) } }) } } } func TestRenderWithTemplate(t *testing.T) { tests := []struct { name string content string want string }{ {"toml", tomlServerContent, tomlServerContent}, {"yaml", yamlServerContent, yamlServerContent}, {"json", jsonServerContent, jsonServerContent}, {"template numeric", `key = {{ 123 }}`, "key = 123"}, {"template string", `key = {{ "xyz" }}`, "key = xyz"}, {"template quote", `key = {{ printf "%q" "with space" }}`, `key = "with space"`}, } for _, test := range tests { t.Run(test.name, func(t *testing.T) { require := require.New(t) got, err := RenderWithTemplate([]byte(test.content), nil) require.NoError(err) require.EqualValues(test.want, string(got)) }) } } func TestCustomStructStrictMode(t *testing.T) { require := require.New(t) proxyStr := ` serverPort = 7000 [[proxies]] name = "test" type = "tcp" remotePort = 6000 ` clientCfg := v1.ClientConfig{} err := LoadConfigure([]byte(proxyStr), &clientCfg, true) require.NoError(err) proxyStr += `unknown = "unknown"` err = LoadConfigure([]byte(proxyStr), &clientCfg, true) require.Error(err) visitorStr := ` serverPort = 7000 [[visitors]] name = "test" type = "stcp" bindPort = 6000 serverName = "server" ` err = LoadConfigure([]byte(visitorStr), &clientCfg, true) require.NoError(err) visitorStr += `unknown = "unknown"` err = LoadConfigure([]byte(visitorStr), &clientCfg, true) require.Error(err) pluginStr := ` serverPort = 7000 [[proxies]] name = "test" type = "tcp" remotePort = 6000 [proxies.plugin] type = "unix_domain_socket" unixPath = "/tmp/uds.sock" ` err = LoadConfigure([]byte(pluginStr), &clientCfg, true) require.NoError(err) pluginStr += `unknown = "unknown"` err = LoadConfigure([]byte(pluginStr), &clientCfg, true) require.Error(err) } func TestLoadClientConfigStrictMode_UnknownPluginField(t *testing.T) { require := require.New(t) content := ` serverPort = 7000 [[proxies]] name = "test" type = "tcp" localPort = 6000 [proxies.plugin] type = "http2https" localAddr = "127.0.0.1:8080" unknownInPlugin = "value" ` clientCfg := v1.ClientConfig{} err := LoadConfigure([]byte(content), &clientCfg, false) require.NoError(err) err = LoadConfigure([]byte(content), &clientCfg, true) require.ErrorContains(err, "unknownInPlugin") } // TestYAMLMergeInStrictMode tests that YAML merge functionality works // even in strict mode by properly handling dot-prefixed fields func TestYAMLMergeInStrictMode(t *testing.T) { require := require.New(t) yamlContent := ` serverAddr: "127.0.0.1" serverPort: 7000 .common: &common type: stcp secretKey: "test-secret" localIP: 127.0.0.1 transport: useEncryption: true useCompression: true proxies: - name: ssh localPort: 22 <<: *common - name: web localPort: 80 <<: *common ` clientCfg := v1.ClientConfig{} // This should work in strict mode err := LoadConfigure([]byte(yamlContent), &clientCfg, true) require.NoError(err) // Verify the merge worked correctly require.Equal("127.0.0.1", clientCfg.ServerAddr) require.Equal(7000, clientCfg.ServerPort) require.Len(clientCfg.Proxies, 2) // Check first proxy sshProxy := clientCfg.Proxies[0].ProxyConfigurer require.Equal("ssh", sshProxy.GetBaseConfig().Name) require.Equal("stcp", sshProxy.GetBaseConfig().Type) // Check second proxy webProxy := clientCfg.Proxies[1].ProxyConfigurer require.Equal("web", webProxy.GetBaseConfig().Name) require.Equal("stcp", webProxy.GetBaseConfig().Type) } // TestOptimizedYAMLProcessing tests the optimization logic for YAML processing func TestOptimizedYAMLProcessing(t *testing.T) { require := require.New(t) yamlWithDotFields := []byte(` serverAddr: "127.0.0.1" .common: &common type: stcp proxies: - name: test <<: *common `) yamlWithoutDotFields := []byte(` serverAddr: "127.0.0.1" proxies: - name: test type: tcp localPort: 22 `) // Test that YAML without dot fields works in strict mode clientCfg := v1.ClientConfig{} err := LoadConfigure(yamlWithoutDotFields, &clientCfg, true) require.NoError(err) require.Equal("127.0.0.1", clientCfg.ServerAddr) require.Len(clientCfg.Proxies, 1) require.Equal("test", clientCfg.Proxies[0].ProxyConfigurer.GetBaseConfig().Name) // Test that YAML with dot fields still works in strict mode err = LoadConfigure(yamlWithDotFields, &clientCfg, true) require.NoError(err) require.Equal("127.0.0.1", clientCfg.ServerAddr) require.Len(clientCfg.Proxies, 1) require.Equal("test", clientCfg.Proxies[0].ProxyConfigurer.GetBaseConfig().Name) require.Equal("stcp", clientCfg.Proxies[0].ProxyConfigurer.GetBaseConfig().Type) } func TestFilterClientConfigurers_PreserveRawNamesAndNoMutation(t *testing.T) { require := require.New(t) enabled := true proxyCfg := &v1.TCPProxyConfig{} proxyCfg.Name = "proxy-raw" proxyCfg.Type = "tcp" proxyCfg.LocalPort = 10080 proxyCfg.Enabled = &enabled visitorCfg := &v1.XTCPVisitorConfig{} visitorCfg.Name = "visitor-raw" visitorCfg.Type = "xtcp" visitorCfg.ServerName = "server-raw" visitorCfg.FallbackTo = "fallback-raw" visitorCfg.SecretKey = "secret" visitorCfg.BindPort = 10081 visitorCfg.Enabled = &enabled common := &v1.ClientCommonConfig{ User: "alice", } proxies, visitors := FilterClientConfigurers(common, []v1.ProxyConfigurer{proxyCfg}, []v1.VisitorConfigurer{visitorCfg}) require.Len(proxies, 1) require.Len(visitors, 1) p := proxies[0].GetBaseConfig() require.Equal("proxy-raw", p.Name) require.Empty(p.LocalIP) v := visitors[0].GetBaseConfig() require.Equal("visitor-raw", v.Name) require.Equal("server-raw", v.ServerName) require.Empty(v.BindAddr) xtcp := visitors[0].(*v1.XTCPVisitorConfig) require.Equal("fallback-raw", xtcp.FallbackTo) require.Empty(xtcp.Protocol) } func TestCompleteProxyConfigurers_PreserveRawNames(t *testing.T) { require := require.New(t) enabled := true proxyCfg := &v1.TCPProxyConfig{} proxyCfg.Name = "proxy-raw" proxyCfg.Type = "tcp" proxyCfg.LocalPort = 10080 proxyCfg.Enabled = &enabled proxies := CompleteProxyConfigurers([]v1.ProxyConfigurer{proxyCfg}) require.Len(proxies, 1) p := proxies[0].GetBaseConfig() require.Equal("proxy-raw", p.Name) require.Equal("127.0.0.1", p.LocalIP) } func TestCompleteVisitorConfigurers_PreserveRawNames(t *testing.T) { require := require.New(t) enabled := true visitorCfg := &v1.XTCPVisitorConfig{} visitorCfg.Name = "visitor-raw" visitorCfg.Type = "xtcp" visitorCfg.ServerName = "server-raw" visitorCfg.FallbackTo = "fallback-raw" visitorCfg.SecretKey = "secret" visitorCfg.BindPort = 10081 visitorCfg.Enabled = &enabled visitors := CompleteVisitorConfigurers([]v1.VisitorConfigurer{visitorCfg}) require.Len(visitors, 1) v := visitors[0].GetBaseConfig() require.Equal("visitor-raw", v.Name) require.Equal("server-raw", v.ServerName) require.Equal("127.0.0.1", v.BindAddr) xtcp := visitors[0].(*v1.XTCPVisitorConfig) require.Equal("fallback-raw", xtcp.FallbackTo) require.Equal("quic", xtcp.Protocol) } func TestCompleteProxyConfigurers_Idempotent(t *testing.T) { require := require.New(t) proxyCfg := &v1.TCPProxyConfig{} proxyCfg.Name = "proxy" proxyCfg.Type = "tcp" proxyCfg.LocalPort = 10080 proxies := CompleteProxyConfigurers([]v1.ProxyConfigurer{proxyCfg}) firstProxyJSON, err := json.Marshal(proxies[0]) require.NoError(err) proxies = CompleteProxyConfigurers(proxies) secondProxyJSON, err := json.Marshal(proxies[0]) require.NoError(err) require.Equal(string(firstProxyJSON), string(secondProxyJSON)) } func TestCompleteVisitorConfigurers_Idempotent(t *testing.T) { require := require.New(t) visitorCfg := &v1.XTCPVisitorConfig{} visitorCfg.Name = "visitor" visitorCfg.Type = "xtcp" visitorCfg.ServerName = "server" visitorCfg.SecretKey = "secret" visitorCfg.BindPort = 10081 visitors := CompleteVisitorConfigurers([]v1.VisitorConfigurer{visitorCfg}) firstVisitorJSON, err := json.Marshal(visitors[0]) require.NoError(err) visitors = CompleteVisitorConfigurers(visitors) secondVisitorJSON, err := json.Marshal(visitors[0]) require.NoError(err) require.Equal(string(firstVisitorJSON), string(secondVisitorJSON)) } func TestFilterClientConfigurers_FilterByStartAndEnabled(t *testing.T) { require := require.New(t) enabled := true disabled := false proxyKeep := &v1.TCPProxyConfig{} proxyKeep.Name = "keep" proxyKeep.Type = "tcp" proxyKeep.LocalPort = 10080 proxyKeep.Enabled = &enabled proxyDropByStart := &v1.TCPProxyConfig{} proxyDropByStart.Name = "drop-by-start" proxyDropByStart.Type = "tcp" proxyDropByStart.LocalPort = 10081 proxyDropByStart.Enabled = &enabled proxyDropByEnabled := &v1.TCPProxyConfig{} proxyDropByEnabled.Name = "drop-by-enabled" proxyDropByEnabled.Type = "tcp" proxyDropByEnabled.LocalPort = 10082 proxyDropByEnabled.Enabled = &disabled common := &v1.ClientCommonConfig{ Start: []string{"keep"}, } proxies, visitors := FilterClientConfigurers(common, []v1.ProxyConfigurer{ proxyKeep, proxyDropByStart, proxyDropByEnabled, }, nil) require.Len(visitors, 0) require.Len(proxies, 1) require.Equal("keep", proxies[0].GetBaseConfig().Name) } // TestYAMLEdgeCases tests edge cases for YAML parsing, including non-map types func TestYAMLEdgeCases(t *testing.T) { require := require.New(t) // Test array at root (should fail for frp config) arrayYAML := []byte(` - item1 - item2 `) clientCfg := v1.ClientConfig{} err := LoadConfigure(arrayYAML, &clientCfg, true) require.Error(err) // Should fail because ClientConfig expects an object // Test scalar at root (should fail for frp config) scalarYAML := []byte(`"just a string"`) err = LoadConfigure(scalarYAML, &clientCfg, true) require.Error(err) // Should fail because ClientConfig expects an object // Test empty object (should work) emptyYAML := []byte(`{}`) err = LoadConfigure(emptyYAML, &clientCfg, true) require.NoError(err) // Test nested structure without dots (should work) nestedYAML := []byte(` serverAddr: "127.0.0.1" serverPort: 7000 `) err = LoadConfigure(nestedYAML, &clientCfg, true) require.NoError(err) require.Equal("127.0.0.1", clientCfg.ServerAddr) require.Equal(7000, clientCfg.ServerPort) } func TestTOMLSyntaxErrorWithPosition(t *testing.T) { require := require.New(t) // TOML with syntax error (unclosed table array header) content := `serverAddr = "127.0.0.1" serverPort = 7000 [[proxies] name = "test" ` clientCfg := v1.ClientConfig{} err := LoadConfigure([]byte(content), &clientCfg, false, "toml") require.Error(err) require.Contains(err.Error(), "toml") require.Contains(err.Error(), "line") require.Contains(err.Error(), "column") } func TestTOMLTypeMismatchErrorWithFieldInfo(t *testing.T) { require := require.New(t) // TOML with wrong type: proxies should be a table array, not a string content := `serverAddr = "127.0.0.1" serverPort = 7000 proxies = "this should be a table array" ` clientCfg := v1.ClientConfig{} err := LoadConfigure([]byte(content), &clientCfg, false, "toml") require.Error(err) // The error should contain field info errMsg := err.Error() require.Contains(errMsg, "proxies") require.NotContains(errMsg, "line") } func TestFindFieldLineInContent(t *testing.T) { content := []byte(`serverAddr = "127.0.0.1" serverPort = 7000 [[proxies]] name = "test" type = "tcp" remotePort = 6000 `) tests := []struct { fieldPath string wantLine int }{ {"serverAddr", 1}, {"serverPort", 2}, {"name", 5}, {"type", 6}, {"remotePort", 7}, {"nonexistent", 0}, } for _, tt := range tests { t.Run(tt.fieldPath, func(t *testing.T) { got := findFieldLineInContent(content, tt.fieldPath) require.Equal(t, tt.wantLine, got) }) } } func TestFormatDetection(t *testing.T) { tests := []struct { path string format string }{ {"config.toml", "toml"}, {"config.TOML", "toml"}, {"config.yaml", "yaml"}, {"config.yml", "yaml"}, {"config.json", "json"}, {"config.ini", ""}, {"config", ""}, } for _, tt := range tests { t.Run(tt.path, func(t *testing.T) { require.Equal(t, tt.format, detectFormatFromPath(tt.path)) }) } } func TestValidTOMLStillWorks(t *testing.T) { require := require.New(t) // Valid TOML with format hint should work fine content := `serverAddr = "127.0.0.1" serverPort = 7000 [[proxies]] name = "test" type = "tcp" remotePort = 6000 ` clientCfg := v1.ClientConfig{} err := LoadConfigure([]byte(content), &clientCfg, false, "toml") require.NoError(err) require.Equal("127.0.0.1", clientCfg.ServerAddr) require.Equal(7000, clientCfg.ServerPort) require.Len(clientCfg.Proxies, 1) } ================================================ FILE: pkg/config/source/aggregator.go ================================================ // Copyright 2026 The frp Authors // // Licensed under the Apache License, Version 2.0 (the "License"); // you may not use this file except in compliance with the License. // You may obtain a copy of the License at // // http://www.apache.org/licenses/LICENSE-2.0 // // Unless required by applicable law or agreed to in writing, software // distributed under the License is distributed on an "AS IS" BASIS, // WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. // See the License for the specific language governing permissions and // limitations under the License. package source import ( "cmp" "errors" "fmt" "maps" "slices" "sync" v1 "github.com/fatedier/frp/pkg/config/v1" ) type Aggregator struct { mu sync.RWMutex configSource *ConfigSource storeSource *StoreSource } func NewAggregator(configSource *ConfigSource) *Aggregator { if configSource == nil { configSource = NewConfigSource() } return &Aggregator{ configSource: configSource, } } func (a *Aggregator) SetStoreSource(storeSource *StoreSource) { a.mu.Lock() defer a.mu.Unlock() a.storeSource = storeSource } func (a *Aggregator) ConfigSource() *ConfigSource { return a.configSource } func (a *Aggregator) StoreSource() *StoreSource { return a.storeSource } func (a *Aggregator) getSourcesLocked() []Source { sources := make([]Source, 0, 2) if a.configSource != nil { sources = append(sources, a.configSource) } if a.storeSource != nil { sources = append(sources, a.storeSource) } return sources } func (a *Aggregator) Load() ([]v1.ProxyConfigurer, []v1.VisitorConfigurer, error) { a.mu.RLock() entries := a.getSourcesLocked() a.mu.RUnlock() if len(entries) == 0 { return nil, nil, errors.New("no sources configured") } proxyMap := make(map[string]v1.ProxyConfigurer) visitorMap := make(map[string]v1.VisitorConfigurer) for _, src := range entries { proxies, visitors, err := src.Load() if err != nil { return nil, nil, fmt.Errorf("load source: %w", err) } for _, p := range proxies { proxyMap[p.GetBaseConfig().Name] = p } for _, v := range visitors { visitorMap[v.GetBaseConfig().Name] = v } } proxies, visitors := a.mapsToSortedSlices(proxyMap, visitorMap) return proxies, visitors, nil } func (a *Aggregator) mapsToSortedSlices( proxyMap map[string]v1.ProxyConfigurer, visitorMap map[string]v1.VisitorConfigurer, ) ([]v1.ProxyConfigurer, []v1.VisitorConfigurer) { proxies := slices.SortedFunc(maps.Values(proxyMap), func(x, y v1.ProxyConfigurer) int { return cmp.Compare(x.GetBaseConfig().Name, y.GetBaseConfig().Name) }) visitors := slices.SortedFunc(maps.Values(visitorMap), func(x, y v1.VisitorConfigurer) int { return cmp.Compare(x.GetBaseConfig().Name, y.GetBaseConfig().Name) }) return proxies, visitors } ================================================ FILE: pkg/config/source/aggregator_test.go ================================================ // Copyright 2026 The frp Authors // // Licensed under the Apache License, Version 2.0 (the "License"); // you may not use this file except in compliance with the License. // You may obtain a copy of the License at // // http://www.apache.org/licenses/LICENSE-2.0 // // Unless required by applicable law or agreed to in writing, software // distributed under the License is distributed on an "AS IS" BASIS, // WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. // See the License for the specific language governing permissions and // limitations under the License. package source import ( "path/filepath" "testing" "github.com/stretchr/testify/require" v1 "github.com/fatedier/frp/pkg/config/v1" ) // mockProxy creates a TCP proxy config for testing func mockProxy(name string) v1.ProxyConfigurer { cfg := &v1.TCPProxyConfig{} cfg.Name = name cfg.Type = "tcp" cfg.LocalPort = 8080 cfg.RemotePort = 9090 return cfg } // mockVisitor creates a STCP visitor config for testing func mockVisitor(name string) v1.VisitorConfigurer { cfg := &v1.STCPVisitorConfig{} cfg.Name = name cfg.Type = "stcp" cfg.ServerName = "test-server" return cfg } func newTestStoreSource(t *testing.T) *StoreSource { t.Helper() path := filepath.Join(t.TempDir(), "store.json") storeSource, err := NewStoreSource(StoreSourceConfig{Path: path}) require.NoError(t, err) return storeSource } func newTestAggregator(t *testing.T, storeSource *StoreSource) *Aggregator { t.Helper() configSource := NewConfigSource() agg := NewAggregator(configSource) if storeSource != nil { agg.SetStoreSource(storeSource) } return agg } func TestNewAggregator_CreatesConfigSourceWhenNil(t *testing.T) { require := require.New(t) agg := NewAggregator(nil) require.NotNil(agg) require.NotNil(agg.ConfigSource()) require.Nil(agg.StoreSource()) } func TestNewAggregator_WithoutStore(t *testing.T) { require := require.New(t) configSource := NewConfigSource() agg := NewAggregator(configSource) require.NotNil(agg) require.Same(configSource, agg.ConfigSource()) require.Nil(agg.StoreSource()) } func TestNewAggregator_WithStore(t *testing.T) { require := require.New(t) storeSource := newTestStoreSource(t) configSource := NewConfigSource() agg := NewAggregator(configSource) agg.SetStoreSource(storeSource) require.Same(configSource, agg.ConfigSource()) require.Same(storeSource, agg.StoreSource()) } func TestAggregator_SetStoreSource_Overwrite(t *testing.T) { require := require.New(t) agg := newTestAggregator(t, nil) first := newTestStoreSource(t) second := newTestStoreSource(t) agg.SetStoreSource(first) require.Same(first, agg.StoreSource()) agg.SetStoreSource(second) require.Same(second, agg.StoreSource()) agg.SetStoreSource(nil) require.Nil(agg.StoreSource()) } func TestAggregator_MergeBySourceOrder(t *testing.T) { require := require.New(t) storeSource := newTestStoreSource(t) agg := newTestAggregator(t, storeSource) configSource := agg.ConfigSource() configShared := mockProxy("shared").(*v1.TCPProxyConfig) configShared.LocalPort = 1111 configOnly := mockProxy("only-in-config").(*v1.TCPProxyConfig) configOnly.LocalPort = 1112 err := configSource.ReplaceAll([]v1.ProxyConfigurer{configShared, configOnly}, nil) require.NoError(err) storeShared := mockProxy("shared").(*v1.TCPProxyConfig) storeShared.LocalPort = 2222 storeOnly := mockProxy("only-in-store").(*v1.TCPProxyConfig) storeOnly.LocalPort = 2223 err = storeSource.AddProxy(storeShared) require.NoError(err) err = storeSource.AddProxy(storeOnly) require.NoError(err) proxies, visitors, err := agg.Load() require.NoError(err) require.Len(visitors, 0) require.Len(proxies, 3) var sharedProxy *v1.TCPProxyConfig for _, p := range proxies { if p.GetBaseConfig().Name == "shared" { sharedProxy = p.(*v1.TCPProxyConfig) break } } require.NotNil(sharedProxy) require.Equal(2222, sharedProxy.LocalPort) } func TestAggregator_DisabledEntryIsSourceLocalFilter(t *testing.T) { require := require.New(t) storeSource := newTestStoreSource(t) agg := newTestAggregator(t, storeSource) configSource := agg.ConfigSource() lowProxy := mockProxy("shared-proxy").(*v1.TCPProxyConfig) lowProxy.LocalPort = 1111 err := configSource.ReplaceAll([]v1.ProxyConfigurer{lowProxy}, nil) require.NoError(err) disabled := false highProxy := mockProxy("shared-proxy").(*v1.TCPProxyConfig) highProxy.LocalPort = 2222 highProxy.Enabled = &disabled err = storeSource.AddProxy(highProxy) require.NoError(err) proxies, visitors, err := agg.Load() require.NoError(err) require.Len(proxies, 1) require.Len(visitors, 0) proxy := proxies[0].(*v1.TCPProxyConfig) require.Equal("shared-proxy", proxy.Name) require.Equal(1111, proxy.LocalPort) } func TestAggregator_VisitorMerge(t *testing.T) { require := require.New(t) storeSource := newTestStoreSource(t) agg := newTestAggregator(t, storeSource) err := agg.ConfigSource().ReplaceAll(nil, []v1.VisitorConfigurer{mockVisitor("visitor1")}) require.NoError(err) err = storeSource.AddVisitor(mockVisitor("visitor2")) require.NoError(err) _, visitors, err := agg.Load() require.NoError(err) require.Len(visitors, 2) } func TestAggregator_Load_ReturnsSortedByName(t *testing.T) { require := require.New(t) agg := newTestAggregator(t, nil) err := agg.ConfigSource().ReplaceAll( []v1.ProxyConfigurer{mockProxy("charlie"), mockProxy("alice"), mockProxy("bob")}, []v1.VisitorConfigurer{mockVisitor("zulu"), mockVisitor("alpha")}, ) require.NoError(err) proxies, visitors, err := agg.Load() require.NoError(err) require.Len(proxies, 3) require.Equal("alice", proxies[0].GetBaseConfig().Name) require.Equal("bob", proxies[1].GetBaseConfig().Name) require.Equal("charlie", proxies[2].GetBaseConfig().Name) require.Len(visitors, 2) require.Equal("alpha", visitors[0].GetBaseConfig().Name) require.Equal("zulu", visitors[1].GetBaseConfig().Name) } func TestAggregator_Load_ReturnsDefensiveCopies(t *testing.T) { require := require.New(t) agg := newTestAggregator(t, nil) err := agg.ConfigSource().ReplaceAll([]v1.ProxyConfigurer{mockProxy("ssh")}, nil) require.NoError(err) proxies, _, err := agg.Load() require.NoError(err) require.Len(proxies, 1) require.Equal("ssh", proxies[0].GetBaseConfig().Name) proxies[0].GetBaseConfig().Name = "alice.ssh" proxies2, _, err := agg.Load() require.NoError(err) require.Len(proxies2, 1) require.Equal("ssh", proxies2[0].GetBaseConfig().Name) } ================================================ FILE: pkg/config/source/base_source.go ================================================ // Copyright 2026 The frp Authors // // Licensed under the Apache License, Version 2.0 (the "License"); // you may not use this file except in compliance with the License. // You may obtain a copy of the License at // // http://www.apache.org/licenses/LICENSE-2.0 // // Unless required by applicable law or agreed to in writing, software // distributed under the License is distributed on an "AS IS" BASIS, // WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. // See the License for the specific language governing permissions and // limitations under the License. package source import ( "sync" v1 "github.com/fatedier/frp/pkg/config/v1" ) // baseSource provides shared state and behavior for Source implementations. // It manages proxy/visitor storage. // Concrete types (ConfigSource, StoreSource) embed this struct. type baseSource struct { mu sync.RWMutex proxies map[string]v1.ProxyConfigurer visitors map[string]v1.VisitorConfigurer } func newBaseSource() baseSource { return baseSource{ proxies: make(map[string]v1.ProxyConfigurer), visitors: make(map[string]v1.VisitorConfigurer), } } // Load returns all enabled proxy and visitor configurations. // Configurations with Enabled explicitly set to false are filtered out. func (s *baseSource) Load() ([]v1.ProxyConfigurer, []v1.VisitorConfigurer, error) { s.mu.RLock() defer s.mu.RUnlock() proxies := make([]v1.ProxyConfigurer, 0, len(s.proxies)) for _, p := range s.proxies { // Filter out disabled proxies (nil or true means enabled) if enabled := p.GetBaseConfig().Enabled; enabled != nil && !*enabled { continue } proxies = append(proxies, p) } visitors := make([]v1.VisitorConfigurer, 0, len(s.visitors)) for _, v := range s.visitors { // Filter out disabled visitors (nil or true means enabled) if enabled := v.GetBaseConfig().Enabled; enabled != nil && !*enabled { continue } visitors = append(visitors, v) } return cloneConfigurers(proxies, visitors) } ================================================ FILE: pkg/config/source/base_source_test.go ================================================ package source import ( "testing" "github.com/stretchr/testify/require" v1 "github.com/fatedier/frp/pkg/config/v1" ) func TestBaseSourceLoadReturnsClonedConfigurers(t *testing.T) { require := require.New(t) src := NewConfigSource() proxyCfg := &v1.TCPProxyConfig{ ProxyBaseConfig: v1.ProxyBaseConfig{ Name: "proxy1", Type: "tcp", }, } visitorCfg := &v1.STCPVisitorConfig{ VisitorBaseConfig: v1.VisitorBaseConfig{ Name: "visitor1", Type: "stcp", }, } err := src.ReplaceAll([]v1.ProxyConfigurer{proxyCfg}, []v1.VisitorConfigurer{visitorCfg}) require.NoError(err) firstProxies, firstVisitors, err := src.Load() require.NoError(err) require.Len(firstProxies, 1) require.Len(firstVisitors, 1) // Mutate loaded objects as runtime completion would do. firstProxies[0].Complete() firstVisitors[0].Complete() secondProxies, secondVisitors, err := src.Load() require.NoError(err) require.Len(secondProxies, 1) require.Len(secondVisitors, 1) require.Empty(secondProxies[0].GetBaseConfig().LocalIP) require.Empty(secondVisitors[0].GetBaseConfig().BindAddr) } ================================================ FILE: pkg/config/source/clone.go ================================================ // Copyright 2026 The frp Authors // // Licensed under the Apache License, Version 2.0 (the "License"); // you may not use this file except in compliance with the License. // You may obtain a copy of the License at // // http://www.apache.org/licenses/LICENSE-2.0 // // Unless required by applicable law or agreed to in writing, software // distributed under the License is distributed on an "AS IS" BASIS, // WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. // See the License for the specific language governing permissions and // limitations under the License. package source import ( "fmt" v1 "github.com/fatedier/frp/pkg/config/v1" ) func cloneConfigurers( proxies []v1.ProxyConfigurer, visitors []v1.VisitorConfigurer, ) ([]v1.ProxyConfigurer, []v1.VisitorConfigurer, error) { clonedProxies := make([]v1.ProxyConfigurer, 0, len(proxies)) clonedVisitors := make([]v1.VisitorConfigurer, 0, len(visitors)) for _, cfg := range proxies { if cfg == nil { return nil, nil, fmt.Errorf("proxy cannot be nil") } clonedProxies = append(clonedProxies, cfg.Clone()) } for _, cfg := range visitors { if cfg == nil { return nil, nil, fmt.Errorf("visitor cannot be nil") } clonedVisitors = append(clonedVisitors, cfg.Clone()) } return clonedProxies, clonedVisitors, nil } ================================================ FILE: pkg/config/source/config_source.go ================================================ // Copyright 2026 The frp Authors // // Licensed under the Apache License, Version 2.0 (the "License"); // you may not use this file except in compliance with the License. // You may obtain a copy of the License at // // http://www.apache.org/licenses/LICENSE-2.0 // // Unless required by applicable law or agreed to in writing, software // distributed under the License is distributed on an "AS IS" BASIS, // WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. // See the License for the specific language governing permissions and // limitations under the License. package source import ( "fmt" v1 "github.com/fatedier/frp/pkg/config/v1" ) // ConfigSource implements Source for in-memory configuration. // All operations are thread-safe. type ConfigSource struct { baseSource } func NewConfigSource() *ConfigSource { return &ConfigSource{ baseSource: newBaseSource(), } } // ReplaceAll replaces all proxy and visitor configurations atomically. func (s *ConfigSource) ReplaceAll(proxies []v1.ProxyConfigurer, visitors []v1.VisitorConfigurer) error { s.mu.Lock() defer s.mu.Unlock() nextProxies := make(map[string]v1.ProxyConfigurer, len(proxies)) for _, p := range proxies { if p == nil { return fmt.Errorf("proxy cannot be nil") } name := p.GetBaseConfig().Name if name == "" { return fmt.Errorf("proxy name cannot be empty") } nextProxies[name] = p } nextVisitors := make(map[string]v1.VisitorConfigurer, len(visitors)) for _, v := range visitors { if v == nil { return fmt.Errorf("visitor cannot be nil") } name := v.GetBaseConfig().Name if name == "" { return fmt.Errorf("visitor name cannot be empty") } nextVisitors[name] = v } s.proxies = nextProxies s.visitors = nextVisitors return nil } ================================================ FILE: pkg/config/source/config_source_test.go ================================================ // Copyright 2026 The frp Authors // // Licensed under the Apache License, Version 2.0 (the "License"); // you may not use this file except in compliance with the License. // You may obtain a copy of the License at // // http://www.apache.org/licenses/LICENSE-2.0 // // Unless required by applicable law or agreed to in writing, software // distributed under the License is distributed on an "AS IS" BASIS, // WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. // See the License for the specific language governing permissions and // limitations under the License. package source import ( "testing" "github.com/stretchr/testify/require" v1 "github.com/fatedier/frp/pkg/config/v1" ) func TestNewConfigSource(t *testing.T) { require := require.New(t) src := NewConfigSource() require.NotNil(src) } func TestConfigSource_ReplaceAll(t *testing.T) { require := require.New(t) src := NewConfigSource() err := src.ReplaceAll( []v1.ProxyConfigurer{mockProxy("proxy1"), mockProxy("proxy2")}, []v1.VisitorConfigurer{mockVisitor("visitor1")}, ) require.NoError(err) proxies, visitors, err := src.Load() require.NoError(err) require.Len(proxies, 2) require.Len(visitors, 1) // ReplaceAll again should replace everything err = src.ReplaceAll( []v1.ProxyConfigurer{mockProxy("proxy3")}, nil, ) require.NoError(err) proxies, visitors, err = src.Load() require.NoError(err) require.Len(proxies, 1) require.Len(visitors, 0) require.Equal("proxy3", proxies[0].GetBaseConfig().Name) // ReplaceAll with nil proxy should fail err = src.ReplaceAll([]v1.ProxyConfigurer{nil}, nil) require.Error(err) // ReplaceAll with empty name proxy should fail err = src.ReplaceAll([]v1.ProxyConfigurer{&v1.TCPProxyConfig{}}, nil) require.Error(err) } func TestConfigSource_Load(t *testing.T) { require := require.New(t) src := NewConfigSource() err := src.ReplaceAll( []v1.ProxyConfigurer{mockProxy("proxy1"), mockProxy("proxy2")}, []v1.VisitorConfigurer{mockVisitor("visitor1")}, ) require.NoError(err) proxies, visitors, err := src.Load() require.NoError(err) require.Len(proxies, 2) require.Len(visitors, 1) } // TestConfigSource_Load_FiltersDisabled verifies that Load() filters out // proxies and visitors with Enabled explicitly set to false. func TestConfigSource_Load_FiltersDisabled(t *testing.T) { require := require.New(t) src := NewConfigSource() disabled := false enabled := true // Create enabled proxy (nil Enabled = enabled by default) enabledProxy := mockProxy("enabled-proxy") // Create disabled proxy disabledProxy := &v1.TCPProxyConfig{} disabledProxy.Name = "disabled-proxy" disabledProxy.Type = "tcp" disabledProxy.Enabled = &disabled // Create explicitly enabled proxy explicitEnabledProxy := &v1.TCPProxyConfig{} explicitEnabledProxy.Name = "explicit-enabled-proxy" explicitEnabledProxy.Type = "tcp" explicitEnabledProxy.Enabled = &enabled // Create enabled visitor (nil Enabled = enabled by default) enabledVisitor := mockVisitor("enabled-visitor") // Create disabled visitor disabledVisitor := &v1.STCPVisitorConfig{} disabledVisitor.Name = "disabled-visitor" disabledVisitor.Type = "stcp" disabledVisitor.Enabled = &disabled err := src.ReplaceAll( []v1.ProxyConfigurer{enabledProxy, disabledProxy, explicitEnabledProxy}, []v1.VisitorConfigurer{enabledVisitor, disabledVisitor}, ) require.NoError(err) // Load should filter out disabled configs proxies, visitors, err := src.Load() require.NoError(err) require.Len(proxies, 2, "Should have 2 enabled proxies") require.Len(visitors, 1, "Should have 1 enabled visitor") // Verify the correct proxies are returned proxyNames := make([]string, 0, len(proxies)) for _, p := range proxies { proxyNames = append(proxyNames, p.GetBaseConfig().Name) } require.Contains(proxyNames, "enabled-proxy") require.Contains(proxyNames, "explicit-enabled-proxy") require.NotContains(proxyNames, "disabled-proxy") // Verify the correct visitor is returned require.Equal("enabled-visitor", visitors[0].GetBaseConfig().Name) } func TestConfigSource_ReplaceAll_DoesNotApplyRuntimeDefaults(t *testing.T) { require := require.New(t) src := NewConfigSource() proxyCfg := &v1.TCPProxyConfig{} proxyCfg.Name = "proxy1" proxyCfg.Type = "tcp" proxyCfg.LocalPort = 10080 visitorCfg := &v1.XTCPVisitorConfig{} visitorCfg.Name = "visitor1" visitorCfg.Type = "xtcp" visitorCfg.ServerName = "server1" visitorCfg.SecretKey = "secret" visitorCfg.BindPort = 10081 err := src.ReplaceAll([]v1.ProxyConfigurer{proxyCfg}, []v1.VisitorConfigurer{visitorCfg}) require.NoError(err) proxies, visitors, err := src.Load() require.NoError(err) require.Len(proxies, 1) require.Len(visitors, 1) require.Empty(proxies[0].GetBaseConfig().LocalIP) require.Empty(visitors[0].GetBaseConfig().BindAddr) require.Empty(visitors[0].(*v1.XTCPVisitorConfig).Protocol) } ================================================ FILE: pkg/config/source/source.go ================================================ // Copyright 2026 The frp Authors // // Licensed under the Apache License, Version 2.0 (the "License"); // you may not use this file except in compliance with the License. // You may obtain a copy of the License at // // http://www.apache.org/licenses/LICENSE-2.0 // // Unless required by applicable law or agreed to in writing, software // distributed under the License is distributed on an "AS IS" BASIS, // WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. // See the License for the specific language governing permissions and // limitations under the License. package source import ( v1 "github.com/fatedier/frp/pkg/config/v1" ) // Source is the interface for configuration sources. // A Source provides proxy and visitor configurations from various backends. // Aggregator currently uses the built-in config source as base and an optional // store source as higher-priority overlay. type Source interface { // Load loads the proxy and visitor configurations from this source. // Returns the loaded configurations and any error encountered. // A disabled entry in one source is source-local filtering, not a cross-source // tombstone for entries from lower-priority sources. // // Error handling contract with Aggregator: // - When err is nil, returned slices are consumed. // - When err is non-nil, Aggregator aborts the merge and returns the error. // - To publish best-effort or partial results, return those results with // err set to nil. Load() (proxies []v1.ProxyConfigurer, visitors []v1.VisitorConfigurer, err error) } ================================================ FILE: pkg/config/source/store.go ================================================ // Copyright 2026 The frp Authors // // Licensed under the Apache License, Version 2.0 (the "License"); // you may not use this file except in compliance with the License. // You may obtain a copy of the License at // // http://www.apache.org/licenses/LICENSE-2.0 // // Unless required by applicable law or agreed to in writing, software // distributed under the License is distributed on an "AS IS" BASIS, // WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. // See the License for the specific language governing permissions and // limitations under the License. package source import ( "errors" "fmt" "os" "path/filepath" v1 "github.com/fatedier/frp/pkg/config/v1" "github.com/fatedier/frp/pkg/util/jsonx" ) type StoreSourceConfig struct { Path string `json:"path"` } type storeData struct { Proxies []v1.TypedProxyConfig `json:"proxies,omitempty"` Visitors []v1.TypedVisitorConfig `json:"visitors,omitempty"` } type StoreSource struct { baseSource config StoreSourceConfig } var ( ErrAlreadyExists = errors.New("already exists") ErrNotFound = errors.New("not found") ) func NewStoreSource(cfg StoreSourceConfig) (*StoreSource, error) { if cfg.Path == "" { return nil, fmt.Errorf("path is required") } s := &StoreSource{ baseSource: newBaseSource(), config: cfg, } if err := s.loadFromFile(); err != nil { if !os.IsNotExist(err) { return nil, fmt.Errorf("failed to load existing data: %w", err) } } return s, nil } func (s *StoreSource) loadFromFile() error { s.mu.Lock() defer s.mu.Unlock() return s.loadFromFileUnlocked() } func (s *StoreSource) loadFromFileUnlocked() error { data, err := os.ReadFile(s.config.Path) if err != nil { return err } type rawStoreData struct { Proxies []jsonx.RawMessage `json:"proxies,omitempty"` Visitors []jsonx.RawMessage `json:"visitors,omitempty"` } stored := rawStoreData{} if err := jsonx.Unmarshal(data, &stored); err != nil { return fmt.Errorf("failed to parse JSON: %w", err) } s.proxies = make(map[string]v1.ProxyConfigurer) s.visitors = make(map[string]v1.VisitorConfigurer) for i, proxyData := range stored.Proxies { proxyCfg, err := v1.DecodeProxyConfigurerJSON(proxyData, v1.DecodeOptions{ DisallowUnknownFields: false, }) if err != nil { return fmt.Errorf("failed to decode proxy at index %d: %w", i, err) } name := proxyCfg.GetBaseConfig().Name if name == "" { return fmt.Errorf("proxy name cannot be empty") } s.proxies[name] = proxyCfg } for i, visitorData := range stored.Visitors { visitorCfg, err := v1.DecodeVisitorConfigurerJSON(visitorData, v1.DecodeOptions{ DisallowUnknownFields: false, }) if err != nil { return fmt.Errorf("failed to decode visitor at index %d: %w", i, err) } name := visitorCfg.GetBaseConfig().Name if name == "" { return fmt.Errorf("visitor name cannot be empty") } s.visitors[name] = visitorCfg } return nil } func (s *StoreSource) saveToFileUnlocked() error { stored := storeData{ Proxies: make([]v1.TypedProxyConfig, 0, len(s.proxies)), Visitors: make([]v1.TypedVisitorConfig, 0, len(s.visitors)), } for _, p := range s.proxies { stored.Proxies = append(stored.Proxies, v1.TypedProxyConfig{ProxyConfigurer: p}) } for _, v := range s.visitors { stored.Visitors = append(stored.Visitors, v1.TypedVisitorConfig{VisitorConfigurer: v}) } data, err := jsonx.MarshalIndent(stored, "", " ") if err != nil { return fmt.Errorf("failed to marshal JSON: %w", err) } dir := filepath.Dir(s.config.Path) if err := os.MkdirAll(dir, 0o755); err != nil { return fmt.Errorf("failed to create directory: %w", err) } tmpPath := s.config.Path + ".tmp" f, err := os.OpenFile(tmpPath, os.O_WRONLY|os.O_CREATE|os.O_TRUNC, 0o600) if err != nil { return fmt.Errorf("failed to create temp file: %w", err) } if _, err := f.Write(data); err != nil { f.Close() os.Remove(tmpPath) return fmt.Errorf("failed to write temp file: %w", err) } if err := f.Sync(); err != nil { f.Close() os.Remove(tmpPath) return fmt.Errorf("failed to sync temp file: %w", err) } if err := f.Close(); err != nil { os.Remove(tmpPath) return fmt.Errorf("failed to close temp file: %w", err) } if err := os.Rename(tmpPath, s.config.Path); err != nil { os.Remove(tmpPath) return fmt.Errorf("failed to rename temp file: %w", err) } return nil } func (s *StoreSource) AddProxy(proxy v1.ProxyConfigurer) error { if proxy == nil { return fmt.Errorf("proxy cannot be nil") } name := proxy.GetBaseConfig().Name if name == "" { return fmt.Errorf("proxy name cannot be empty") } s.mu.Lock() defer s.mu.Unlock() if _, exists := s.proxies[name]; exists { return fmt.Errorf("%w: proxy %q", ErrAlreadyExists, name) } s.proxies[name] = proxy if err := s.saveToFileUnlocked(); err != nil { delete(s.proxies, name) return fmt.Errorf("failed to persist: %w", err) } return nil } func (s *StoreSource) UpdateProxy(proxy v1.ProxyConfigurer) error { if proxy == nil { return fmt.Errorf("proxy cannot be nil") } name := proxy.GetBaseConfig().Name if name == "" { return fmt.Errorf("proxy name cannot be empty") } s.mu.Lock() defer s.mu.Unlock() oldProxy, exists := s.proxies[name] if !exists { return fmt.Errorf("%w: proxy %q", ErrNotFound, name) } s.proxies[name] = proxy if err := s.saveToFileUnlocked(); err != nil { s.proxies[name] = oldProxy return fmt.Errorf("failed to persist: %w", err) } return nil } func (s *StoreSource) RemoveProxy(name string) error { if name == "" { return fmt.Errorf("proxy name cannot be empty") } s.mu.Lock() defer s.mu.Unlock() oldProxy, exists := s.proxies[name] if !exists { return fmt.Errorf("%w: proxy %q", ErrNotFound, name) } delete(s.proxies, name) if err := s.saveToFileUnlocked(); err != nil { s.proxies[name] = oldProxy return fmt.Errorf("failed to persist: %w", err) } return nil } func (s *StoreSource) GetProxy(name string) v1.ProxyConfigurer { s.mu.RLock() defer s.mu.RUnlock() p, exists := s.proxies[name] if !exists { return nil } return p } func (s *StoreSource) AddVisitor(visitor v1.VisitorConfigurer) error { if visitor == nil { return fmt.Errorf("visitor cannot be nil") } name := visitor.GetBaseConfig().Name if name == "" { return fmt.Errorf("visitor name cannot be empty") } s.mu.Lock() defer s.mu.Unlock() if _, exists := s.visitors[name]; exists { return fmt.Errorf("%w: visitor %q", ErrAlreadyExists, name) } s.visitors[name] = visitor if err := s.saveToFileUnlocked(); err != nil { delete(s.visitors, name) return fmt.Errorf("failed to persist: %w", err) } return nil } func (s *StoreSource) UpdateVisitor(visitor v1.VisitorConfigurer) error { if visitor == nil { return fmt.Errorf("visitor cannot be nil") } name := visitor.GetBaseConfig().Name if name == "" { return fmt.Errorf("visitor name cannot be empty") } s.mu.Lock() defer s.mu.Unlock() oldVisitor, exists := s.visitors[name] if !exists { return fmt.Errorf("%w: visitor %q", ErrNotFound, name) } s.visitors[name] = visitor if err := s.saveToFileUnlocked(); err != nil { s.visitors[name] = oldVisitor return fmt.Errorf("failed to persist: %w", err) } return nil } func (s *StoreSource) RemoveVisitor(name string) error { if name == "" { return fmt.Errorf("visitor name cannot be empty") } s.mu.Lock() defer s.mu.Unlock() oldVisitor, exists := s.visitors[name] if !exists { return fmt.Errorf("%w: visitor %q", ErrNotFound, name) } delete(s.visitors, name) if err := s.saveToFileUnlocked(); err != nil { s.visitors[name] = oldVisitor return fmt.Errorf("failed to persist: %w", err) } return nil } func (s *StoreSource) GetVisitor(name string) v1.VisitorConfigurer { s.mu.RLock() defer s.mu.RUnlock() v, exists := s.visitors[name] if !exists { return nil } return v } func (s *StoreSource) GetAllProxies() ([]v1.ProxyConfigurer, error) { s.mu.RLock() defer s.mu.RUnlock() result := make([]v1.ProxyConfigurer, 0, len(s.proxies)) for _, p := range s.proxies { result = append(result, p) } return result, nil } func (s *StoreSource) GetAllVisitors() ([]v1.VisitorConfigurer, error) { s.mu.RLock() defer s.mu.RUnlock() result := make([]v1.VisitorConfigurer, 0, len(s.visitors)) for _, v := range s.visitors { result = append(result, v) } return result, nil } ================================================ FILE: pkg/config/source/store_test.go ================================================ // Copyright 2026 The frp Authors // // Licensed under the Apache License, Version 2.0 (the "License"); // you may not use this file except in compliance with the License. // You may obtain a copy of the License at // // http://www.apache.org/licenses/LICENSE-2.0 // // Unless required by applicable law or agreed to in writing, software // distributed under the License is distributed on an "AS IS" BASIS, // WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. // See the License for the specific language governing permissions and // limitations under the License. package source import ( "os" "path/filepath" "testing" "github.com/stretchr/testify/require" v1 "github.com/fatedier/frp/pkg/config/v1" "github.com/fatedier/frp/pkg/util/jsonx" ) func TestStoreSource_AddProxyAndVisitor_DoesNotApplyRuntimeDefaults(t *testing.T) { require := require.New(t) path := filepath.Join(t.TempDir(), "store.json") storeSource, err := NewStoreSource(StoreSourceConfig{Path: path}) require.NoError(err) proxyCfg := &v1.TCPProxyConfig{} proxyCfg.Name = "proxy1" proxyCfg.Type = "tcp" proxyCfg.LocalPort = 10080 visitorCfg := &v1.XTCPVisitorConfig{} visitorCfg.Name = "visitor1" visitorCfg.Type = "xtcp" visitorCfg.ServerName = "server1" visitorCfg.SecretKey = "secret" visitorCfg.BindPort = 10081 err = storeSource.AddProxy(proxyCfg) require.NoError(err) err = storeSource.AddVisitor(visitorCfg) require.NoError(err) gotProxy := storeSource.GetProxy("proxy1") require.NotNil(gotProxy) require.Empty(gotProxy.GetBaseConfig().LocalIP) gotVisitor := storeSource.GetVisitor("visitor1") require.NotNil(gotVisitor) require.Empty(gotVisitor.GetBaseConfig().BindAddr) require.Empty(gotVisitor.(*v1.XTCPVisitorConfig).Protocol) } func TestStoreSource_LoadFromFile_DoesNotApplyRuntimeDefaults(t *testing.T) { require := require.New(t) path := filepath.Join(t.TempDir(), "store.json") proxyCfg := &v1.TCPProxyConfig{} proxyCfg.Name = "proxy1" proxyCfg.Type = "tcp" proxyCfg.LocalPort = 10080 visitorCfg := &v1.XTCPVisitorConfig{} visitorCfg.Name = "visitor1" visitorCfg.Type = "xtcp" visitorCfg.ServerName = "server1" visitorCfg.SecretKey = "secret" visitorCfg.BindPort = 10081 stored := storeData{ Proxies: []v1.TypedProxyConfig{{ProxyConfigurer: proxyCfg}}, Visitors: []v1.TypedVisitorConfig{{VisitorConfigurer: visitorCfg}}, } data, err := jsonx.Marshal(stored) require.NoError(err) err = os.WriteFile(path, data, 0o600) require.NoError(err) storeSource, err := NewStoreSource(StoreSourceConfig{Path: path}) require.NoError(err) gotProxy := storeSource.GetProxy("proxy1") require.NotNil(gotProxy) require.Empty(gotProxy.GetBaseConfig().LocalIP) gotVisitor := storeSource.GetVisitor("visitor1") require.NotNil(gotVisitor) require.Empty(gotVisitor.GetBaseConfig().BindAddr) require.Empty(gotVisitor.(*v1.XTCPVisitorConfig).Protocol) } func TestStoreSource_LoadFromFile_UnknownFieldsAreIgnored(t *testing.T) { require := require.New(t) path := filepath.Join(t.TempDir(), "store.json") raw := []byte(`{ "proxies": [ {"name":"proxy1","type":"tcp","localPort":10080,"unexpected":"value"} ], "visitors": [ {"name":"visitor1","type":"xtcp","serverName":"server1","secretKey":"secret","bindPort":10081,"unexpected":"value"} ] }`) err := os.WriteFile(path, raw, 0o600) require.NoError(err) storeSource, err := NewStoreSource(StoreSourceConfig{Path: path}) require.NoError(err) require.NotNil(storeSource.GetProxy("proxy1")) require.NotNil(storeSource.GetVisitor("visitor1")) } ================================================ FILE: pkg/config/template.go ================================================ // Copyright 2024 The frp Authors // // Licensed under the Apache License, Version 2.0 (the "License"); // you may not use this file except in compliance with the License. // You may obtain a copy of the License at // // http://www.apache.org/licenses/LICENSE-2.0 // // Unless required by applicable law or agreed to in writing, software // distributed under the License is distributed on an "AS IS" BASIS, // WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. // See the License for the specific language governing permissions and // limitations under the License. package config import ( "fmt" "github.com/fatedier/frp/pkg/util/util" ) type NumberPair struct { First int64 Second int64 } func parseNumberRangePair(firstRangeStr, secondRangeStr string) ([]NumberPair, error) { firstRangeNumbers, err := util.ParseRangeNumbers(firstRangeStr) if err != nil { return nil, err } secondRangeNumbers, err := util.ParseRangeNumbers(secondRangeStr) if err != nil { return nil, err } if len(firstRangeNumbers) != len(secondRangeNumbers) { return nil, fmt.Errorf("first and second range numbers are not in pairs") } pairs := make([]NumberPair, 0, len(firstRangeNumbers)) for i := range firstRangeNumbers { pairs = append(pairs, NumberPair{ First: firstRangeNumbers[i], Second: secondRangeNumbers[i], }) } return pairs, nil } func parseNumberRange(firstRangeStr string) ([]int64, error) { return util.ParseRangeNumbers(firstRangeStr) } ================================================ FILE: pkg/config/types/types.go ================================================ // Copyright 2019 fatedier, fatedier@gmail.com // // Licensed under the Apache License, Version 2.0 (the "License"); // you may not use this file except in compliance with the License. // You may obtain a copy of the License at // // http://www.apache.org/licenses/LICENSE-2.0 // // Unless required by applicable law or agreed to in writing, software // distributed under the License is distributed on an "AS IS" BASIS, // WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. // See the License for the specific language governing permissions and // limitations under the License. package types import ( "encoding/json" "errors" "fmt" "strconv" "strings" ) const ( MB = 1024 * 1024 KB = 1024 BandwidthLimitModeClient = "client" BandwidthLimitModeServer = "server" ) type BandwidthQuantity struct { s string // MB or KB i int64 // bytes } func NewBandwidthQuantity(s string) (BandwidthQuantity, error) { q := BandwidthQuantity{} err := q.UnmarshalString(s) if err != nil { return q, err } return q, nil } func (q *BandwidthQuantity) Equal(u *BandwidthQuantity) bool { if q == nil && u == nil { return true } if q != nil && u != nil { return q.i == u.i } return false } func (q *BandwidthQuantity) String() string { return q.s } func (q *BandwidthQuantity) UnmarshalString(s string) error { s = strings.TrimSpace(s) if s == "" { return nil } var ( base int64 f float64 err error ) if fstr, ok := strings.CutSuffix(s, "MB"); ok { base = MB f, err = strconv.ParseFloat(fstr, 64) } else if fstr, ok := strings.CutSuffix(s, "KB"); ok { base = KB f, err = strconv.ParseFloat(fstr, 64) } else { return errors.New("unit not support") } if err != nil { return err } q.s = s q.i = int64(f * float64(base)) return nil } func (q *BandwidthQuantity) UnmarshalJSON(b []byte) error { if len(b) == 4 && string(b) == "null" { return nil } var str string err := json.Unmarshal(b, &str) if err != nil { return err } return q.UnmarshalString(str) } func (q *BandwidthQuantity) MarshalJSON() ([]byte, error) { return []byte("\"" + q.s + "\""), nil } func (q *BandwidthQuantity) Bytes() int64 { return q.i } type PortsRange struct { Start int `json:"start,omitempty"` End int `json:"end,omitempty"` Single int `json:"single,omitempty"` } type PortsRangeSlice []PortsRange func (p PortsRangeSlice) String() string { if len(p) == 0 { return "" } strs := []string{} for _, v := range p { if v.Single > 0 { strs = append(strs, strconv.Itoa(v.Single)) } else { strs = append(strs, strconv.Itoa(v.Start)+"-"+strconv.Itoa(v.End)) } } return strings.Join(strs, ",") } // the format of str is like "1000-2000,3000,4000-5000" func NewPortsRangeSliceFromString(str string) ([]PortsRange, error) { str = strings.TrimSpace(str) out := []PortsRange{} numRanges := strings.SplitSeq(str, ",") for numRangeStr := range numRanges { // 1000-2000 or 2001 numArray := strings.Split(numRangeStr, "-") // length: only 1 or 2 is correct rangeType := len(numArray) switch rangeType { case 1: // single number singleNum, err := strconv.ParseInt(strings.TrimSpace(numArray[0]), 10, 64) if err != nil { return nil, fmt.Errorf("range number is invalid, %v", err) } out = append(out, PortsRange{Single: int(singleNum)}) case 2: // range numbers minNum, err := strconv.ParseInt(strings.TrimSpace(numArray[0]), 10, 64) if err != nil { return nil, fmt.Errorf("range number is invalid, %v", err) } maxNum, err := strconv.ParseInt(strings.TrimSpace(numArray[1]), 10, 64) if err != nil { return nil, fmt.Errorf("range number is invalid, %v", err) } if maxNum < minNum { return nil, fmt.Errorf("range number is invalid") } out = append(out, PortsRange{Start: int(minNum), End: int(maxNum)}) default: return nil, fmt.Errorf("range number is invalid") } } return out, nil } ================================================ FILE: pkg/config/types/types_test.go ================================================ // Copyright 2019 fatedier, fatedier@gmail.com // // Licensed under the Apache License, Version 2.0 (the "License"); // you may not use this file except in compliance with the License. // You may obtain a copy of the License at // // http://www.apache.org/licenses/LICENSE-2.0 // // Unless required by applicable law or agreed to in writing, software // distributed under the License is distributed on an "AS IS" BASIS, // WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. // See the License for the specific language governing permissions and // limitations under the License. package types import ( "encoding/json" "testing" "github.com/stretchr/testify/require" ) type Wrap struct { B BandwidthQuantity `json:"b"` Int int `json:"int"` } func TestBandwidthQuantity(t *testing.T) { require := require.New(t) var w Wrap err := json.Unmarshal([]byte(`{"b":"1KB","int":5}`), &w) require.NoError(err) require.EqualValues(1*KB, w.B.Bytes()) buf, err := json.Marshal(&w) require.NoError(err) require.Equal(`{"b":"1KB","int":5}`, string(buf)) } func TestBandwidthQuantity_MB(t *testing.T) { require := require.New(t) var w Wrap err := json.Unmarshal([]byte(`{"b":"2MB","int":1}`), &w) require.NoError(err) require.EqualValues(2*MB, w.B.Bytes()) buf, err := json.Marshal(&w) require.NoError(err) require.Equal(`{"b":"2MB","int":1}`, string(buf)) } func TestBandwidthQuantity_InvalidUnit(t *testing.T) { var w Wrap err := json.Unmarshal([]byte(`{"b":"1GB","int":1}`), &w) require.Error(t, err) } func TestBandwidthQuantity_InvalidNumber(t *testing.T) { var w Wrap err := json.Unmarshal([]byte(`{"b":"abcKB","int":1}`), &w) require.Error(t, err) } func TestPortsRangeSlice2String(t *testing.T) { require := require.New(t) ports := []PortsRange{ { Start: 1000, End: 2000, }, { Single: 3000, }, } str := PortsRangeSlice(ports).String() require.Equal("1000-2000,3000", str) } func TestNewPortsRangeSliceFromString(t *testing.T) { require := require.New(t) ports, err := NewPortsRangeSliceFromString("1000-2000,3000") require.NoError(err) require.Equal([]PortsRange{ { Start: 1000, End: 2000, }, { Single: 3000, }, }, ports) } ================================================ FILE: pkg/config/v1/api.go ================================================ // Copyright 2023 The frp Authors // // Licensed under the Apache License, Version 2.0 (the "License"); // you may not use this file except in compliance with the License. // You may obtain a copy of the License at // // http://www.apache.org/licenses/LICENSE-2.0 // // Unless required by applicable law or agreed to in writing, software // distributed under the License is distributed on an "AS IS" BASIS, // WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. // See the License for the specific language governing permissions and // limitations under the License. package v1 type APIMetadata struct { Version string `json:"version"` } ================================================ FILE: pkg/config/v1/client.go ================================================ // Copyright 2023 The frp Authors // // Licensed under the Apache License, Version 2.0 (the "License"); // you may not use this file except in compliance with the License. // You may obtain a copy of the License at // // http://www.apache.org/licenses/LICENSE-2.0 // // Unless required by applicable law or agreed to in writing, software // distributed under the License is distributed on an "AS IS" BASIS, // WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. // See the License for the specific language governing permissions and // limitations under the License. package v1 import ( "os" "github.com/samber/lo" "github.com/fatedier/frp/pkg/util/util" ) type ClientConfig struct { ClientCommonConfig Proxies []TypedProxyConfig `json:"proxies,omitempty"` Visitors []TypedVisitorConfig `json:"visitors,omitempty"` } type ClientCommonConfig struct { APIMetadata Auth AuthClientConfig `json:"auth,omitempty"` // User specifies a prefix for proxy names to distinguish them from other // clients. If this value is not "", proxy names will automatically be // changed to "{user}.{proxy_name}". User string `json:"user,omitempty"` // ClientID uniquely identifies this frpc instance. ClientID string `json:"clientID,omitempty"` // ServerAddr specifies the address of the server to connect to. By // default, this value is "0.0.0.0". ServerAddr string `json:"serverAddr,omitempty"` // ServerPort specifies the port to connect to the server on. By default, // this value is 7000. ServerPort int `json:"serverPort,omitempty"` // STUN server to help penetrate NAT hole. NatHoleSTUNServer string `json:"natHoleStunServer,omitempty"` // DNSServer specifies a DNS server address for FRPC to use. If this value // is "", the default DNS will be used. DNSServer string `json:"dnsServer,omitempty"` // LoginFailExit controls whether or not the client should exit after a // failed login attempt. If false, the client will retry until a login // attempt succeeds. By default, this value is true. LoginFailExit *bool `json:"loginFailExit,omitempty"` // Start specifies a set of enabled proxies by name. If this set is empty, // all supplied proxies are enabled. By default, this value is an empty // set. Start []string `json:"start,omitempty"` Log LogConfig `json:"log,omitempty"` WebServer WebServerConfig `json:"webServer,omitempty"` Transport ClientTransportConfig `json:"transport,omitempty"` VirtualNet VirtualNetConfig `json:"virtualNet,omitempty"` // FeatureGates specifies a set of feature gates to enable or disable. // This can be used to enable alpha/beta features or disable default features. FeatureGates map[string]bool `json:"featureGates,omitempty"` // UDPPacketSize specifies the udp packet size // By default, this value is 1500 UDPPacketSize int64 `json:"udpPacketSize,omitempty"` // Client metadata info Metadatas map[string]string `json:"metadatas,omitempty"` // Include other config files for proxies. IncludeConfigFiles []string `json:"includes,omitempty"` // Store config enables the built-in store source (not configurable via sources list). Store StoreConfig `json:"store,omitempty"` } func (c *ClientCommonConfig) Complete() error { c.ServerAddr = util.EmptyOr(c.ServerAddr, "0.0.0.0") c.ServerPort = util.EmptyOr(c.ServerPort, 7000) c.LoginFailExit = util.EmptyOr(c.LoginFailExit, lo.ToPtr(true)) c.NatHoleSTUNServer = util.EmptyOr(c.NatHoleSTUNServer, "stun.easyvoip.com:3478") if err := c.Auth.Complete(); err != nil { return err } c.Log.Complete() c.Transport.Complete() c.WebServer.Complete() c.UDPPacketSize = util.EmptyOr(c.UDPPacketSize, 1500) return nil } type ClientTransportConfig struct { // Protocol specifies the protocol to use when interacting with the server. // Valid values are "tcp", "kcp", "quic", "websocket" and "wss". By default, this value // is "tcp". Protocol string `json:"protocol,omitempty"` // The maximum amount of time a dial to server will wait for a connect to complete. DialServerTimeout int64 `json:"dialServerTimeout,omitempty"` // DialServerKeepAlive specifies the interval between keep-alive probes for an active network connection between frpc and frps. // If negative, keep-alive probes are disabled. DialServerKeepAlive int64 `json:"dialServerKeepalive,omitempty"` // ConnectServerLocalIP specifies the address of the client bind when it connect to server. // Note: This value only use in TCP/Websocket protocol. Not support in KCP protocol. ConnectServerLocalIP string `json:"connectServerLocalIP,omitempty"` // ProxyURL specifies a proxy address to connect to the server through. If // this value is "", the server will be connected to directly. By default, // this value is read from the "http_proxy" environment variable. ProxyURL string `json:"proxyURL,omitempty"` // PoolCount specifies the number of connections the client will make to // the server in advance. PoolCount int `json:"poolCount,omitempty"` // TCPMux toggles TCP stream multiplexing. This allows multiple requests // from a client to share a single TCP connection. If this value is true, // the server must have TCP multiplexing enabled as well. By default, this // value is true. TCPMux *bool `json:"tcpMux,omitempty"` // TCPMuxKeepaliveInterval specifies the keep alive interval for TCP stream multiplier. // If TCPMux is true, heartbeat of application layer is unnecessary because it can only rely on heartbeat in TCPMux. TCPMuxKeepaliveInterval int64 `json:"tcpMuxKeepaliveInterval,omitempty"` // QUIC protocol options. QUIC QUICOptions `json:"quic,omitempty"` // HeartBeatInterval specifies at what interval heartbeats are sent to the // server, in seconds. It is not recommended to change this value. By // default, this value is 30. Set negative value to disable it. HeartbeatInterval int64 `json:"heartbeatInterval,omitempty"` // HeartBeatTimeout specifies the maximum allowed heartbeat response delay // before the connection is terminated, in seconds. It is not recommended // to change this value. By default, this value is 90. Set negative value to disable it. HeartbeatTimeout int64 `json:"heartbeatTimeout,omitempty"` // TLS specifies TLS settings for the connection to the server. TLS TLSClientConfig `json:"tls,omitempty"` } func (c *ClientTransportConfig) Complete() { c.Protocol = util.EmptyOr(c.Protocol, "tcp") c.DialServerTimeout = util.EmptyOr(c.DialServerTimeout, 10) c.DialServerKeepAlive = util.EmptyOr(c.DialServerKeepAlive, 7200) c.ProxyURL = util.EmptyOr(c.ProxyURL, os.Getenv("http_proxy")) c.PoolCount = util.EmptyOr(c.PoolCount, 1) c.TCPMux = util.EmptyOr(c.TCPMux, lo.ToPtr(true)) c.TCPMuxKeepaliveInterval = util.EmptyOr(c.TCPMuxKeepaliveInterval, 30) if lo.FromPtr(c.TCPMux) { // If TCPMux is enabled, heartbeat of application layer is unnecessary because we can rely on heartbeat in tcpmux. c.HeartbeatInterval = util.EmptyOr(c.HeartbeatInterval, -1) c.HeartbeatTimeout = util.EmptyOr(c.HeartbeatTimeout, -1) } else { c.HeartbeatInterval = util.EmptyOr(c.HeartbeatInterval, 30) c.HeartbeatTimeout = util.EmptyOr(c.HeartbeatTimeout, 90) } c.QUIC.Complete() c.TLS.Complete() } type TLSClientConfig struct { // TLSEnable specifies whether or not TLS should be used when communicating // with the server. If "tls.certFile" and "tls.keyFile" are valid, // client will load the supplied tls configuration. // Since v0.50.0, the default value has been changed to true, and tls is enabled by default. Enable *bool `json:"enable,omitempty"` // If DisableCustomTLSFirstByte is set to false, frpc will establish a connection with frps using the // first custom byte when tls is enabled. // Since v0.50.0, the default value has been changed to true, and the first custom byte is disabled by default. DisableCustomTLSFirstByte *bool `json:"disableCustomTLSFirstByte,omitempty"` TLSConfig } func (c *TLSClientConfig) Complete() { c.Enable = util.EmptyOr(c.Enable, lo.ToPtr(true)) c.DisableCustomTLSFirstByte = util.EmptyOr(c.DisableCustomTLSFirstByte, lo.ToPtr(true)) } type AuthClientConfig struct { // Method specifies what authentication method to use to // authenticate frpc with frps. If "token" is specified - token will be // read into login message. If "oidc" is specified - OIDC (Open ID Connect) // token will be issued using OIDC settings. By default, this value is "token". Method AuthMethod `json:"method,omitempty"` // Specify whether to include auth info in additional scope. // Current supported scopes are: "HeartBeats", "NewWorkConns". AdditionalScopes []AuthScope `json:"additionalScopes,omitempty"` // Token specifies the authorization token used to create keys to be sent // to the server. The server must have a matching token for authorization // to succeed. By default, this value is "". Token string `json:"token,omitempty"` // TokenSource specifies a dynamic source for the authorization token. // This is mutually exclusive with Token field. TokenSource *ValueSource `json:"tokenSource,omitempty"` OIDC AuthOIDCClientConfig `json:"oidc,omitempty"` } func (c *AuthClientConfig) Complete() error { c.Method = util.EmptyOr(c.Method, "token") return nil } type AuthOIDCClientConfig struct { // ClientID specifies the client ID to use to get a token in OIDC authentication. ClientID string `json:"clientID,omitempty"` // ClientSecret specifies the client secret to use to get a token in OIDC // authentication. ClientSecret string `json:"clientSecret,omitempty"` // Audience specifies the audience of the token in OIDC authentication. Audience string `json:"audience,omitempty"` // Scope specifies the scope of the token in OIDC authentication. Scope string `json:"scope,omitempty"` // TokenEndpointURL specifies the URL which implements OIDC Token Endpoint. // It will be used to get an OIDC token. TokenEndpointURL string `json:"tokenEndpointURL,omitempty"` // AdditionalEndpointParams specifies additional parameters to be sent // this field will be transfer to map[string][]string in OIDC token generator. AdditionalEndpointParams map[string]string `json:"additionalEndpointParams,omitempty"` // TrustedCaFile specifies the path to a custom CA certificate file // for verifying the OIDC token endpoint's TLS certificate. TrustedCaFile string `json:"trustedCaFile,omitempty"` // InsecureSkipVerify disables TLS certificate verification for the // OIDC token endpoint. Only use this for debugging, not recommended for production. InsecureSkipVerify bool `json:"insecureSkipVerify,omitempty"` // ProxyURL specifies a proxy to use when connecting to the OIDC token endpoint. // Supports http, https, socks5, and socks5h proxy protocols. // If empty, no proxy is used for OIDC connections. ProxyURL string `json:"proxyURL,omitempty"` // TokenSource specifies a custom dynamic source for the authorization token. // This is mutually exclusive with every other field of this structure. TokenSource *ValueSource `json:"tokenSource,omitempty"` } type VirtualNetConfig struct { Address string `json:"address,omitempty"` } ================================================ FILE: pkg/config/v1/client_test.go ================================================ // Copyright 2023 The frp Authors // // Licensed under the Apache License, Version 2.0 (the "License"); // you may not use this file except in compliance with the License. // You may obtain a copy of the License at // // http://www.apache.org/licenses/LICENSE-2.0 // // Unless required by applicable law or agreed to in writing, software // distributed under the License is distributed on an "AS IS" BASIS, // WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. // See the License for the specific language governing permissions and // limitations under the License. package v1 import ( "testing" "github.com/samber/lo" "github.com/stretchr/testify/require" ) func TestClientConfigComplete(t *testing.T) { require := require.New(t) c := &ClientConfig{} err := c.Complete() require.NoError(err) require.EqualValues("token", c.Auth.Method) require.Equal(true, lo.FromPtr(c.Transport.TCPMux)) require.Equal(true, lo.FromPtr(c.LoginFailExit)) require.Equal(true, lo.FromPtr(c.Transport.TLS.Enable)) require.Equal(true, lo.FromPtr(c.Transport.TLS.DisableCustomTLSFirstByte)) require.NotEmpty(c.NatHoleSTUNServer) } func TestAuthClientConfig_Complete(t *testing.T) { require := require.New(t) cfg := &AuthClientConfig{} err := cfg.Complete() require.NoError(err) require.EqualValues("token", cfg.Method) } ================================================ FILE: pkg/config/v1/clone_test.go ================================================ package v1 import ( "testing" "github.com/stretchr/testify/require" ) func TestProxyCloneDeepCopy(t *testing.T) { require := require.New(t) enabled := true pluginHTTP2 := true cfg := &HTTPProxyConfig{ ProxyBaseConfig: ProxyBaseConfig{ Name: "p1", Type: "http", Enabled: &enabled, Annotations: map[string]string{"a": "1"}, Metadatas: map[string]string{"m": "1"}, HealthCheck: HealthCheckConfig{ Type: "http", HTTPHeaders: []HTTPHeader{ {Name: "X-Test", Value: "v1"}, }, }, ProxyBackend: ProxyBackend{ Plugin: TypedClientPluginOptions{ Type: PluginHTTPS2HTTP, ClientPluginOptions: &HTTPS2HTTPPluginOptions{ Type: PluginHTTPS2HTTP, EnableHTTP2: &pluginHTTP2, RequestHeaders: HeaderOperations{Set: map[string]string{"k": "v"}}, }, }, }, }, DomainConfig: DomainConfig{ CustomDomains: []string{"a.example.com"}, SubDomain: "a", }, Locations: []string{"/api"}, RequestHeaders: HeaderOperations{Set: map[string]string{"h1": "v1"}}, ResponseHeaders: HeaderOperations{Set: map[string]string{"h2": "v2"}}, } cloned := cfg.Clone().(*HTTPProxyConfig) *cloned.Enabled = false cloned.Annotations["a"] = "changed" cloned.Metadatas["m"] = "changed" cloned.HealthCheck.HTTPHeaders[0].Value = "changed" cloned.CustomDomains[0] = "b.example.com" cloned.Locations[0] = "/new" cloned.RequestHeaders.Set["h1"] = "changed" cloned.ResponseHeaders.Set["h2"] = "changed" clientPlugin := cloned.Plugin.ClientPluginOptions.(*HTTPS2HTTPPluginOptions) *clientPlugin.EnableHTTP2 = false clientPlugin.RequestHeaders.Set["k"] = "changed" require.True(*cfg.Enabled) require.Equal("1", cfg.Annotations["a"]) require.Equal("1", cfg.Metadatas["m"]) require.Equal("v1", cfg.HealthCheck.HTTPHeaders[0].Value) require.Equal("a.example.com", cfg.CustomDomains[0]) require.Equal("/api", cfg.Locations[0]) require.Equal("v1", cfg.RequestHeaders.Set["h1"]) require.Equal("v2", cfg.ResponseHeaders.Set["h2"]) origPlugin := cfg.Plugin.ClientPluginOptions.(*HTTPS2HTTPPluginOptions) require.True(*origPlugin.EnableHTTP2) require.Equal("v", origPlugin.RequestHeaders.Set["k"]) } func TestVisitorCloneDeepCopy(t *testing.T) { require := require.New(t) enabled := true cfg := &XTCPVisitorConfig{ VisitorBaseConfig: VisitorBaseConfig{ Name: "v1", Type: "xtcp", Enabled: &enabled, ServerName: "server", BindPort: 7000, Plugin: TypedVisitorPluginOptions{ Type: VisitorPluginVirtualNet, VisitorPluginOptions: &VirtualNetVisitorPluginOptions{ Type: VisitorPluginVirtualNet, DestinationIP: "10.0.0.1", }, }, }, NatTraversal: &NatTraversalConfig{ DisableAssistedAddrs: true, }, } cloned := cfg.Clone().(*XTCPVisitorConfig) *cloned.Enabled = false cloned.NatTraversal.DisableAssistedAddrs = false visitorPlugin := cloned.Plugin.VisitorPluginOptions.(*VirtualNetVisitorPluginOptions) visitorPlugin.DestinationIP = "10.0.0.2" require.True(*cfg.Enabled) require.True(cfg.NatTraversal.DisableAssistedAddrs) origPlugin := cfg.Plugin.VisitorPluginOptions.(*VirtualNetVisitorPluginOptions) require.Equal("10.0.0.1", origPlugin.DestinationIP) } ================================================ FILE: pkg/config/v1/common.go ================================================ // Copyright 2023 The frp Authors // // Licensed under the Apache License, Version 2.0 (the "License"); // you may not use this file except in compliance with the License. // You may obtain a copy of the License at // // http://www.apache.org/licenses/LICENSE-2.0 // // Unless required by applicable law or agreed to in writing, software // distributed under the License is distributed on an "AS IS" BASIS, // WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. // See the License for the specific language governing permissions and // limitations under the License. package v1 import ( "maps" "github.com/fatedier/frp/pkg/util/util" ) type AuthScope string const ( AuthScopeHeartBeats AuthScope = "HeartBeats" AuthScopeNewWorkConns AuthScope = "NewWorkConns" ) type AuthMethod string const ( AuthMethodToken AuthMethod = "token" AuthMethodOIDC AuthMethod = "oidc" ) // QUIC protocol options type QUICOptions struct { KeepalivePeriod int `json:"keepalivePeriod,omitempty"` MaxIdleTimeout int `json:"maxIdleTimeout,omitempty"` MaxIncomingStreams int `json:"maxIncomingStreams,omitempty"` } func (c *QUICOptions) Complete() { c.KeepalivePeriod = util.EmptyOr(c.KeepalivePeriod, 10) c.MaxIdleTimeout = util.EmptyOr(c.MaxIdleTimeout, 30) c.MaxIncomingStreams = util.EmptyOr(c.MaxIncomingStreams, 100000) } type WebServerConfig struct { // This is the network address to bind on for serving the web interface and API. // By default, this value is "127.0.0.1". Addr string `json:"addr,omitempty"` // Port specifies the port for the web server to listen on. If this // value is 0, the admin server will not be started. Port int `json:"port,omitempty"` // User specifies the username that the web server will use for login. User string `json:"user,omitempty"` // Password specifies the password that the admin server will use for login. Password string `json:"password,omitempty"` // AssetsDir specifies the local directory that the admin server will load // resources from. If this value is "", assets will be loaded from the // bundled executable using embed package. AssetsDir string `json:"assetsDir,omitempty"` // Enable golang pprof handlers. PprofEnable bool `json:"pprofEnable,omitempty"` // Enable TLS if TLSConfig is not nil. TLS *TLSConfig `json:"tls,omitempty"` } func (c *WebServerConfig) Complete() { c.Addr = util.EmptyOr(c.Addr, "127.0.0.1") } type TLSConfig struct { // CertFile specifies the path of the cert file that client will load. CertFile string `json:"certFile,omitempty"` // KeyFile specifies the path of the secret key file that client will load. KeyFile string `json:"keyFile,omitempty"` // TrustedCaFile specifies the path of the trusted ca file that will load. TrustedCaFile string `json:"trustedCaFile,omitempty"` // ServerName specifies the custom server name of tls certificate. By // default, server name if same to ServerAddr. ServerName string `json:"serverName,omitempty"` } // NatTraversalConfig defines configuration options for NAT traversal type NatTraversalConfig struct { // DisableAssistedAddrs disables the use of local network interfaces // for assisted connections during NAT traversal. When enabled, // only STUN-discovered public addresses will be used. DisableAssistedAddrs bool `json:"disableAssistedAddrs,omitempty"` } func (c *NatTraversalConfig) Clone() *NatTraversalConfig { if c == nil { return nil } out := *c return &out } type LogConfig struct { // This is destination where frp should write the logs. // If "console" is used, logs will be printed to stdout, otherwise, // logs will be written to the specified file. // By default, this value is "console". To string `json:"to,omitempty"` // Level specifies the minimum log level. Valid values are "trace", // "debug", "info", "warn", and "error". By default, this value is "info". Level string `json:"level,omitempty"` // MaxDays specifies the maximum number of days to store log information // before deletion. MaxDays int64 `json:"maxDays"` // DisablePrintColor disables log colors when log.to is "console". DisablePrintColor bool `json:"disablePrintColor,omitempty"` } func (c *LogConfig) Complete() { c.To = util.EmptyOr(c.To, "console") c.Level = util.EmptyOr(c.Level, "info") c.MaxDays = util.EmptyOr(c.MaxDays, 3) } type HTTPPluginOptions struct { Name string `json:"name"` Addr string `json:"addr"` Path string `json:"path"` Ops []string `json:"ops"` TLSVerify bool `json:"tlsVerify,omitempty"` } type HeaderOperations struct { Set map[string]string `json:"set,omitempty"` } func (o HeaderOperations) Clone() HeaderOperations { return HeaderOperations{ Set: maps.Clone(o.Set), } } type HTTPHeader struct { Name string `json:"name"` Value string `json:"value"` } ================================================ FILE: pkg/config/v1/decode.go ================================================ // Copyright 2026 The frp Authors // // Licensed under the Apache License, Version 2.0 (the "License"); // you may not use this file except in compliance with the License. // You may obtain a copy of the License at // // http://www.apache.org/licenses/LICENSE-2.0 // // Unless required by applicable law or agreed to in writing, software // distributed under the License is distributed on an "AS IS" BASIS, // WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. // See the License for the specific language governing permissions and // limitations under the License. package v1 import ( "errors" "fmt" "reflect" "github.com/fatedier/frp/pkg/util/jsonx" ) type DecodeOptions struct { DisallowUnknownFields bool } func decodeJSONWithOptions(b []byte, out any, options DecodeOptions) error { return jsonx.UnmarshalWithOptions(b, out, jsonx.DecodeOptions{ RejectUnknownMembers: options.DisallowUnknownFields, }) } func isJSONNull(b []byte) bool { return len(b) == 0 || string(b) == "null" } type typedEnvelope struct { Type string `json:"type"` Plugin jsonx.RawMessage `json:"plugin,omitempty"` } func DecodeProxyConfigurerJSON(b []byte, options DecodeOptions) (ProxyConfigurer, error) { if isJSONNull(b) { return nil, errors.New("type is required") } var env typedEnvelope if err := jsonx.Unmarshal(b, &env); err != nil { return nil, err } configurer := NewProxyConfigurerByType(ProxyType(env.Type)) if configurer == nil { return nil, fmt.Errorf("unknown proxy type: %s", env.Type) } if err := decodeJSONWithOptions(b, configurer, options); err != nil { return nil, fmt.Errorf("unmarshal ProxyConfig error: %v", err) } if len(env.Plugin) > 0 && !isJSONNull(env.Plugin) { plugin, err := DecodeClientPluginOptionsJSON(env.Plugin, options) if err != nil { return nil, fmt.Errorf("unmarshal proxy plugin error: %v", err) } configurer.GetBaseConfig().Plugin = plugin } return configurer, nil } func DecodeVisitorConfigurerJSON(b []byte, options DecodeOptions) (VisitorConfigurer, error) { if isJSONNull(b) { return nil, errors.New("type is required") } var env typedEnvelope if err := jsonx.Unmarshal(b, &env); err != nil { return nil, err } configurer := NewVisitorConfigurerByType(VisitorType(env.Type)) if configurer == nil { return nil, fmt.Errorf("unknown visitor type: %s", env.Type) } if err := decodeJSONWithOptions(b, configurer, options); err != nil { return nil, fmt.Errorf("unmarshal VisitorConfig error: %v", err) } if len(env.Plugin) > 0 && !isJSONNull(env.Plugin) { plugin, err := DecodeVisitorPluginOptionsJSON(env.Plugin, options) if err != nil { return nil, fmt.Errorf("unmarshal visitor plugin error: %v", err) } configurer.GetBaseConfig().Plugin = plugin } return configurer, nil } func DecodeClientPluginOptionsJSON(b []byte, options DecodeOptions) (TypedClientPluginOptions, error) { if isJSONNull(b) { return TypedClientPluginOptions{}, nil } var env typedEnvelope if err := jsonx.Unmarshal(b, &env); err != nil { return TypedClientPluginOptions{}, err } if env.Type == "" { return TypedClientPluginOptions{}, errors.New("plugin type is empty") } v, ok := clientPluginOptionsTypeMap[env.Type] if !ok { return TypedClientPluginOptions{}, fmt.Errorf("unknown plugin type: %s", env.Type) } optionsStruct := reflect.New(v).Interface().(ClientPluginOptions) if err := decodeJSONWithOptions(b, optionsStruct, options); err != nil { return TypedClientPluginOptions{}, fmt.Errorf("unmarshal ClientPluginOptions error: %v", err) } return TypedClientPluginOptions{ Type: env.Type, ClientPluginOptions: optionsStruct, }, nil } func DecodeVisitorPluginOptionsJSON(b []byte, options DecodeOptions) (TypedVisitorPluginOptions, error) { if isJSONNull(b) { return TypedVisitorPluginOptions{}, nil } var env typedEnvelope if err := jsonx.Unmarshal(b, &env); err != nil { return TypedVisitorPluginOptions{}, err } if env.Type == "" { return TypedVisitorPluginOptions{}, errors.New("visitor plugin type is empty") } v, ok := visitorPluginOptionsTypeMap[env.Type] if !ok { return TypedVisitorPluginOptions{}, fmt.Errorf("unknown visitor plugin type: %s", env.Type) } optionsStruct := reflect.New(v).Interface().(VisitorPluginOptions) if err := decodeJSONWithOptions(b, optionsStruct, options); err != nil { return TypedVisitorPluginOptions{}, fmt.Errorf("unmarshal VisitorPluginOptions error: %v", err) } return TypedVisitorPluginOptions{ Type: env.Type, VisitorPluginOptions: optionsStruct, }, nil } func DecodeClientConfigJSON(b []byte, options DecodeOptions) (ClientConfig, error) { type rawClientConfig struct { ClientCommonConfig Proxies []jsonx.RawMessage `json:"proxies,omitempty"` Visitors []jsonx.RawMessage `json:"visitors,omitempty"` } raw := rawClientConfig{} if err := decodeJSONWithOptions(b, &raw, options); err != nil { return ClientConfig{}, err } cfg := ClientConfig{ ClientCommonConfig: raw.ClientCommonConfig, Proxies: make([]TypedProxyConfig, 0, len(raw.Proxies)), Visitors: make([]TypedVisitorConfig, 0, len(raw.Visitors)), } for i, proxyData := range raw.Proxies { proxyCfg, err := DecodeProxyConfigurerJSON(proxyData, options) if err != nil { return ClientConfig{}, fmt.Errorf("decode proxy at index %d: %w", i, err) } cfg.Proxies = append(cfg.Proxies, TypedProxyConfig{ Type: proxyCfg.GetBaseConfig().Type, ProxyConfigurer: proxyCfg, }) } for i, visitorData := range raw.Visitors { visitorCfg, err := DecodeVisitorConfigurerJSON(visitorData, options) if err != nil { return ClientConfig{}, fmt.Errorf("decode visitor at index %d: %w", i, err) } cfg.Visitors = append(cfg.Visitors, TypedVisitorConfig{ Type: visitorCfg.GetBaseConfig().Type, VisitorConfigurer: visitorCfg, }) } return cfg, nil } ================================================ FILE: pkg/config/v1/decode_test.go ================================================ // Copyright 2026 The frp Authors // // Licensed under the Apache License, Version 2.0 (the "License"); // you may not use this file except in compliance with the License. // You may obtain a copy of the License at // // http://www.apache.org/licenses/LICENSE-2.0 // // Unless required by applicable law or agreed to in writing, software // distributed under the License is distributed on an "AS IS" BASIS, // WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. // See the License for the specific language governing permissions and // limitations under the License. package v1 import ( "testing" "github.com/stretchr/testify/require" ) func TestDecodeProxyConfigurerJSON_StrictPluginUnknownFields(t *testing.T) { require := require.New(t) data := []byte(`{ "name":"p1", "type":"tcp", "localPort":10080, "plugin":{ "type":"http2https", "localAddr":"127.0.0.1:8080", "unknownInPlugin":"value" } }`) _, err := DecodeProxyConfigurerJSON(data, DecodeOptions{DisallowUnknownFields: false}) require.NoError(err) _, err = DecodeProxyConfigurerJSON(data, DecodeOptions{DisallowUnknownFields: true}) require.ErrorContains(err, "unknownInPlugin") } func TestDecodeVisitorConfigurerJSON_StrictPluginUnknownFields(t *testing.T) { require := require.New(t) data := []byte(`{ "name":"v1", "type":"stcp", "serverName":"server", "bindPort":10081, "plugin":{ "type":"virtual_net", "destinationIP":"10.0.0.1", "unknownInPlugin":"value" } }`) _, err := DecodeVisitorConfigurerJSON(data, DecodeOptions{DisallowUnknownFields: false}) require.NoError(err) _, err = DecodeVisitorConfigurerJSON(data, DecodeOptions{DisallowUnknownFields: true}) require.ErrorContains(err, "unknownInPlugin") } func TestDecodeClientConfigJSON_StrictUnknownProxyField(t *testing.T) { require := require.New(t) data := []byte(`{ "serverPort":7000, "proxies":[ { "name":"p1", "type":"tcp", "localPort":10080, "unknownField":"value" } ] }`) _, err := DecodeClientConfigJSON(data, DecodeOptions{DisallowUnknownFields: false}) require.NoError(err) _, err = DecodeClientConfigJSON(data, DecodeOptions{DisallowUnknownFields: true}) require.ErrorContains(err, "unknownField") } ================================================ FILE: pkg/config/v1/proxy.go ================================================ // Copyright 2023 The frp Authors // // Licensed under the Apache License, Version 2.0 (the "License"); // you may not use this file except in compliance with the License. // You may obtain a copy of the License at // // http://www.apache.org/licenses/LICENSE-2.0 // // Unless required by applicable law or agreed to in writing, software // distributed under the License is distributed on an "AS IS" BASIS, // WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. // See the License for the specific language governing permissions and // limitations under the License. package v1 import ( "maps" "reflect" "slices" "github.com/fatedier/frp/pkg/config/types" "github.com/fatedier/frp/pkg/msg" "github.com/fatedier/frp/pkg/util/jsonx" "github.com/fatedier/frp/pkg/util/util" ) type ProxyTransport struct { // UseEncryption controls whether or not communication with the server will // be encrypted. Encryption is done using the tokens supplied in the server // and client configuration. UseEncryption bool `json:"useEncryption,omitempty"` // UseCompression controls whether or not communication with the server // will be compressed. UseCompression bool `json:"useCompression,omitempty"` // BandwidthLimit limit the bandwidth // 0 means no limit BandwidthLimit types.BandwidthQuantity `json:"bandwidthLimit,omitempty"` // BandwidthLimitMode specifies whether to limit the bandwidth on the // client or server side. Valid values include "client" and "server". // By default, this value is "client". BandwidthLimitMode string `json:"bandwidthLimitMode,omitempty"` // ProxyProtocolVersion specifies which protocol version to use. Valid // values include "v1", "v2", and "". If the value is "", a protocol // version will be automatically selected. By default, this value is "". ProxyProtocolVersion string `json:"proxyProtocolVersion,omitempty"` } type LoadBalancerConfig struct { // Group specifies which group the is a part of. The server will use // this information to load balance proxies in the same group. If the value // is "", this will not be in a group. Group string `json:"group"` // GroupKey specifies a group key, which should be the same among proxies // of the same group. GroupKey string `json:"groupKey,omitempty"` } type ProxyBackend struct { // LocalIP specifies the IP address or host name of the backend. LocalIP string `json:"localIP,omitempty"` // LocalPort specifies the port of the backend. LocalPort int `json:"localPort,omitempty"` // Plugin specifies what plugin should be used for handling connections. If this value // is set, the LocalIP and LocalPort values will be ignored. Plugin TypedClientPluginOptions `json:"plugin,omitempty"` } // HealthCheckConfig configures health checking. This can be useful for load // balancing purposes to detect and remove proxies to failing services. type HealthCheckConfig struct { // Type specifies what protocol to use for health checking. // Valid values include "tcp", "http", and "". If this value is "", health // checking will not be performed. // // If the type is "tcp", a connection will be attempted to the target // server. If a connection cannot be established, the health check fails. // // If the type is "http", a GET request will be made to the endpoint // specified by HealthCheckURL. If the response is not a 200, the health // check fails. Type string `json:"type"` // tcp | http // TimeoutSeconds specifies the number of seconds to wait for a health // check attempt to connect. If the timeout is reached, this counts as a // health check failure. By default, this value is 3. TimeoutSeconds int `json:"timeoutSeconds,omitempty"` // MaxFailed specifies the number of allowed failures before the // is stopped. By default, this value is 1. MaxFailed int `json:"maxFailed,omitempty"` // IntervalSeconds specifies the time in seconds between health // checks. By default, this value is 10. IntervalSeconds int `json:"intervalSeconds"` // Path specifies the path to send health checks to if the // health check type is "http". Path string `json:"path,omitempty"` // HTTPHeaders specifies the headers to send with the health request, if // the health check type is "http". HTTPHeaders []HTTPHeader `json:"httpHeaders,omitempty"` } func (c HealthCheckConfig) Clone() HealthCheckConfig { out := c out.HTTPHeaders = slices.Clone(c.HTTPHeaders) return out } type DomainConfig struct { CustomDomains []string `json:"customDomains,omitempty"` SubDomain string `json:"subdomain,omitempty"` } func (c DomainConfig) Clone() DomainConfig { out := c out.CustomDomains = slices.Clone(c.CustomDomains) return out } type ProxyBaseConfig struct { Name string `json:"name"` Type string `json:"type"` // Enabled controls whether this proxy is enabled. nil or true means enabled, false means disabled. // This allows individual control over each proxy, complementing the global "start" field. Enabled *bool `json:"enabled,omitempty"` Annotations map[string]string `json:"annotations,omitempty"` Transport ProxyTransport `json:"transport,omitempty"` // metadata info for each proxy Metadatas map[string]string `json:"metadatas,omitempty"` LoadBalancer LoadBalancerConfig `json:"loadBalancer,omitempty"` HealthCheck HealthCheckConfig `json:"healthCheck,omitempty"` ProxyBackend } func (c ProxyBaseConfig) Clone() ProxyBaseConfig { out := c out.Enabled = util.ClonePtr(c.Enabled) out.Annotations = maps.Clone(c.Annotations) out.Metadatas = maps.Clone(c.Metadatas) out.HealthCheck = c.HealthCheck.Clone() out.ProxyBackend = c.ProxyBackend.Clone() return out } func (c ProxyBackend) Clone() ProxyBackend { out := c out.Plugin = c.Plugin.Clone() return out } func (c *ProxyBaseConfig) GetBaseConfig() *ProxyBaseConfig { return c } func (c *ProxyBaseConfig) Complete() { c.LocalIP = util.EmptyOr(c.LocalIP, "127.0.0.1") c.Transport.BandwidthLimitMode = util.EmptyOr(c.Transport.BandwidthLimitMode, types.BandwidthLimitModeClient) if c.Plugin.ClientPluginOptions != nil { c.Plugin.Complete() } } func (c *ProxyBaseConfig) MarshalToMsg(m *msg.NewProxy) { m.ProxyName = c.Name m.ProxyType = c.Type m.UseEncryption = c.Transport.UseEncryption m.UseCompression = c.Transport.UseCompression m.BandwidthLimit = c.Transport.BandwidthLimit.String() // leave it empty for default value to reduce traffic if c.Transport.BandwidthLimitMode != "client" { m.BandwidthLimitMode = c.Transport.BandwidthLimitMode } m.Group = c.LoadBalancer.Group m.GroupKey = c.LoadBalancer.GroupKey m.Metas = c.Metadatas m.Annotations = c.Annotations } func (c *ProxyBaseConfig) UnmarshalFromMsg(m *msg.NewProxy) { c.Name = m.ProxyName c.Type = m.ProxyType c.Transport.UseEncryption = m.UseEncryption c.Transport.UseCompression = m.UseCompression if m.BandwidthLimit != "" { c.Transport.BandwidthLimit, _ = types.NewBandwidthQuantity(m.BandwidthLimit) } if m.BandwidthLimitMode != "" { c.Transport.BandwidthLimitMode = m.BandwidthLimitMode } c.LoadBalancer.Group = m.Group c.LoadBalancer.GroupKey = m.GroupKey c.Metadatas = m.Metas c.Annotations = m.Annotations } type TypedProxyConfig struct { Type string `json:"type"` ProxyConfigurer } func (c *TypedProxyConfig) UnmarshalJSON(b []byte) error { configurer, err := DecodeProxyConfigurerJSON(b, DecodeOptions{}) if err != nil { return err } c.Type = configurer.GetBaseConfig().Type c.ProxyConfigurer = configurer return nil } func (c *TypedProxyConfig) MarshalJSON() ([]byte, error) { return jsonx.Marshal(c.ProxyConfigurer) } type ProxyConfigurer interface { Complete() GetBaseConfig() *ProxyBaseConfig Clone() ProxyConfigurer // MarshalToMsg marshals this config into a msg.NewProxy message. This // function will be called on the frpc side. MarshalToMsg(*msg.NewProxy) // UnmarshalFromMsg unmarshal a msg.NewProxy message into this config. // This function will be called on the frps side. UnmarshalFromMsg(*msg.NewProxy) } type ProxyType string const ( ProxyTypeTCP ProxyType = "tcp" ProxyTypeUDP ProxyType = "udp" ProxyTypeTCPMUX ProxyType = "tcpmux" ProxyTypeHTTP ProxyType = "http" ProxyTypeHTTPS ProxyType = "https" ProxyTypeSTCP ProxyType = "stcp" ProxyTypeXTCP ProxyType = "xtcp" ProxyTypeSUDP ProxyType = "sudp" ) var proxyConfigTypeMap = map[ProxyType]reflect.Type{ ProxyTypeTCP: reflect.TypeFor[TCPProxyConfig](), ProxyTypeUDP: reflect.TypeFor[UDPProxyConfig](), ProxyTypeHTTP: reflect.TypeFor[HTTPProxyConfig](), ProxyTypeHTTPS: reflect.TypeFor[HTTPSProxyConfig](), ProxyTypeTCPMUX: reflect.TypeFor[TCPMuxProxyConfig](), ProxyTypeSTCP: reflect.TypeFor[STCPProxyConfig](), ProxyTypeXTCP: reflect.TypeFor[XTCPProxyConfig](), ProxyTypeSUDP: reflect.TypeFor[SUDPProxyConfig](), } func NewProxyConfigurerByType(proxyType ProxyType) ProxyConfigurer { v, ok := proxyConfigTypeMap[proxyType] if !ok { return nil } pc := reflect.New(v).Interface().(ProxyConfigurer) pc.GetBaseConfig().Type = string(proxyType) return pc } var _ ProxyConfigurer = &TCPProxyConfig{} type TCPProxyConfig struct { ProxyBaseConfig RemotePort int `json:"remotePort,omitempty"` } func (c *TCPProxyConfig) MarshalToMsg(m *msg.NewProxy) { c.ProxyBaseConfig.MarshalToMsg(m) m.RemotePort = c.RemotePort } func (c *TCPProxyConfig) UnmarshalFromMsg(m *msg.NewProxy) { c.ProxyBaseConfig.UnmarshalFromMsg(m) c.RemotePort = m.RemotePort } func (c *TCPProxyConfig) Clone() ProxyConfigurer { out := *c out.ProxyBaseConfig = c.ProxyBaseConfig.Clone() return &out } var _ ProxyConfigurer = &UDPProxyConfig{} type UDPProxyConfig struct { ProxyBaseConfig RemotePort int `json:"remotePort,omitempty"` } func (c *UDPProxyConfig) MarshalToMsg(m *msg.NewProxy) { c.ProxyBaseConfig.MarshalToMsg(m) m.RemotePort = c.RemotePort } func (c *UDPProxyConfig) UnmarshalFromMsg(m *msg.NewProxy) { c.ProxyBaseConfig.UnmarshalFromMsg(m) c.RemotePort = m.RemotePort } func (c *UDPProxyConfig) Clone() ProxyConfigurer { out := *c out.ProxyBaseConfig = c.ProxyBaseConfig.Clone() return &out } var _ ProxyConfigurer = &HTTPProxyConfig{} type HTTPProxyConfig struct { ProxyBaseConfig DomainConfig Locations []string `json:"locations,omitempty"` HTTPUser string `json:"httpUser,omitempty"` HTTPPassword string `json:"httpPassword,omitempty"` HostHeaderRewrite string `json:"hostHeaderRewrite,omitempty"` RequestHeaders HeaderOperations `json:"requestHeaders,omitempty"` ResponseHeaders HeaderOperations `json:"responseHeaders,omitempty"` RouteByHTTPUser string `json:"routeByHTTPUser,omitempty"` } func (c *HTTPProxyConfig) MarshalToMsg(m *msg.NewProxy) { c.ProxyBaseConfig.MarshalToMsg(m) m.CustomDomains = c.CustomDomains m.SubDomain = c.SubDomain m.Locations = c.Locations m.HostHeaderRewrite = c.HostHeaderRewrite m.HTTPUser = c.HTTPUser m.HTTPPwd = c.HTTPPassword m.Headers = c.RequestHeaders.Set m.ResponseHeaders = c.ResponseHeaders.Set m.RouteByHTTPUser = c.RouteByHTTPUser } func (c *HTTPProxyConfig) UnmarshalFromMsg(m *msg.NewProxy) { c.ProxyBaseConfig.UnmarshalFromMsg(m) c.CustomDomains = m.CustomDomains c.SubDomain = m.SubDomain c.Locations = m.Locations c.HostHeaderRewrite = m.HostHeaderRewrite c.HTTPUser = m.HTTPUser c.HTTPPassword = m.HTTPPwd c.RequestHeaders.Set = m.Headers c.ResponseHeaders.Set = m.ResponseHeaders c.RouteByHTTPUser = m.RouteByHTTPUser } func (c *HTTPProxyConfig) Clone() ProxyConfigurer { out := *c out.ProxyBaseConfig = c.ProxyBaseConfig.Clone() out.DomainConfig = c.DomainConfig.Clone() out.Locations = slices.Clone(c.Locations) out.RequestHeaders = c.RequestHeaders.Clone() out.ResponseHeaders = c.ResponseHeaders.Clone() return &out } var _ ProxyConfigurer = &HTTPSProxyConfig{} type HTTPSProxyConfig struct { ProxyBaseConfig DomainConfig } func (c *HTTPSProxyConfig) MarshalToMsg(m *msg.NewProxy) { c.ProxyBaseConfig.MarshalToMsg(m) m.CustomDomains = c.CustomDomains m.SubDomain = c.SubDomain } func (c *HTTPSProxyConfig) UnmarshalFromMsg(m *msg.NewProxy) { c.ProxyBaseConfig.UnmarshalFromMsg(m) c.CustomDomains = m.CustomDomains c.SubDomain = m.SubDomain } func (c *HTTPSProxyConfig) Clone() ProxyConfigurer { out := *c out.ProxyBaseConfig = c.ProxyBaseConfig.Clone() out.DomainConfig = c.DomainConfig.Clone() return &out } type TCPMultiplexerType string const ( TCPMultiplexerHTTPConnect TCPMultiplexerType = "httpconnect" ) var _ ProxyConfigurer = &TCPMuxProxyConfig{} type TCPMuxProxyConfig struct { ProxyBaseConfig DomainConfig HTTPUser string `json:"httpUser,omitempty"` HTTPPassword string `json:"httpPassword,omitempty"` RouteByHTTPUser string `json:"routeByHTTPUser,omitempty"` Multiplexer string `json:"multiplexer,omitempty"` } func (c *TCPMuxProxyConfig) MarshalToMsg(m *msg.NewProxy) { c.ProxyBaseConfig.MarshalToMsg(m) m.CustomDomains = c.CustomDomains m.SubDomain = c.SubDomain m.Multiplexer = c.Multiplexer m.HTTPUser = c.HTTPUser m.HTTPPwd = c.HTTPPassword m.RouteByHTTPUser = c.RouteByHTTPUser } func (c *TCPMuxProxyConfig) UnmarshalFromMsg(m *msg.NewProxy) { c.ProxyBaseConfig.UnmarshalFromMsg(m) c.CustomDomains = m.CustomDomains c.SubDomain = m.SubDomain c.Multiplexer = m.Multiplexer c.HTTPUser = m.HTTPUser c.HTTPPassword = m.HTTPPwd c.RouteByHTTPUser = m.RouteByHTTPUser } func (c *TCPMuxProxyConfig) Clone() ProxyConfigurer { out := *c out.ProxyBaseConfig = c.ProxyBaseConfig.Clone() out.DomainConfig = c.DomainConfig.Clone() return &out } var _ ProxyConfigurer = &STCPProxyConfig{} type STCPProxyConfig struct { ProxyBaseConfig Secretkey string `json:"secretKey,omitempty"` AllowUsers []string `json:"allowUsers,omitempty"` } func (c *STCPProxyConfig) MarshalToMsg(m *msg.NewProxy) { c.ProxyBaseConfig.MarshalToMsg(m) m.Sk = c.Secretkey m.AllowUsers = c.AllowUsers } func (c *STCPProxyConfig) UnmarshalFromMsg(m *msg.NewProxy) { c.ProxyBaseConfig.UnmarshalFromMsg(m) c.Secretkey = m.Sk c.AllowUsers = m.AllowUsers } func (c *STCPProxyConfig) Clone() ProxyConfigurer { out := *c out.ProxyBaseConfig = c.ProxyBaseConfig.Clone() out.AllowUsers = slices.Clone(c.AllowUsers) return &out } var _ ProxyConfigurer = &XTCPProxyConfig{} type XTCPProxyConfig struct { ProxyBaseConfig Secretkey string `json:"secretKey,omitempty"` AllowUsers []string `json:"allowUsers,omitempty"` // NatTraversal configuration for NAT traversal NatTraversal *NatTraversalConfig `json:"natTraversal,omitempty"` } func (c *XTCPProxyConfig) MarshalToMsg(m *msg.NewProxy) { c.ProxyBaseConfig.MarshalToMsg(m) m.Sk = c.Secretkey m.AllowUsers = c.AllowUsers } func (c *XTCPProxyConfig) UnmarshalFromMsg(m *msg.NewProxy) { c.ProxyBaseConfig.UnmarshalFromMsg(m) c.Secretkey = m.Sk c.AllowUsers = m.AllowUsers } func (c *XTCPProxyConfig) Clone() ProxyConfigurer { out := *c out.ProxyBaseConfig = c.ProxyBaseConfig.Clone() out.AllowUsers = slices.Clone(c.AllowUsers) out.NatTraversal = c.NatTraversal.Clone() return &out } var _ ProxyConfigurer = &SUDPProxyConfig{} type SUDPProxyConfig struct { ProxyBaseConfig Secretkey string `json:"secretKey,omitempty"` AllowUsers []string `json:"allowUsers,omitempty"` } func (c *SUDPProxyConfig) MarshalToMsg(m *msg.NewProxy) { c.ProxyBaseConfig.MarshalToMsg(m) m.Sk = c.Secretkey m.AllowUsers = c.AllowUsers } func (c *SUDPProxyConfig) UnmarshalFromMsg(m *msg.NewProxy) { c.ProxyBaseConfig.UnmarshalFromMsg(m) c.Secretkey = m.Sk c.AllowUsers = m.AllowUsers } func (c *SUDPProxyConfig) Clone() ProxyConfigurer { out := *c out.ProxyBaseConfig = c.ProxyBaseConfig.Clone() out.AllowUsers = slices.Clone(c.AllowUsers) return &out } ================================================ FILE: pkg/config/v1/proxy_plugin.go ================================================ // Copyright 2023 The frp Authors // // Licensed under the Apache License, Version 2.0 (the "License"); // you may not use this file except in compliance with the License. // You may obtain a copy of the License at // // http://www.apache.org/licenses/LICENSE-2.0 // // Unless required by applicable law or agreed to in writing, software // distributed under the License is distributed on an "AS IS" BASIS, // WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. // See the License for the specific language governing permissions and // limitations under the License. package v1 import ( "reflect" "github.com/samber/lo" "github.com/fatedier/frp/pkg/util/jsonx" "github.com/fatedier/frp/pkg/util/util" ) const ( PluginHTTP2HTTPS = "http2https" PluginHTTPProxy = "http_proxy" PluginHTTPS2HTTP = "https2http" PluginHTTPS2HTTPS = "https2https" PluginHTTP2HTTP = "http2http" PluginSocks5 = "socks5" PluginStaticFile = "static_file" PluginUnixDomainSocket = "unix_domain_socket" PluginTLS2Raw = "tls2raw" PluginVirtualNet = "virtual_net" ) var clientPluginOptionsTypeMap = map[string]reflect.Type{ PluginHTTP2HTTPS: reflect.TypeFor[HTTP2HTTPSPluginOptions](), PluginHTTPProxy: reflect.TypeFor[HTTPProxyPluginOptions](), PluginHTTPS2HTTP: reflect.TypeFor[HTTPS2HTTPPluginOptions](), PluginHTTPS2HTTPS: reflect.TypeFor[HTTPS2HTTPSPluginOptions](), PluginHTTP2HTTP: reflect.TypeFor[HTTP2HTTPPluginOptions](), PluginSocks5: reflect.TypeFor[Socks5PluginOptions](), PluginStaticFile: reflect.TypeFor[StaticFilePluginOptions](), PluginUnixDomainSocket: reflect.TypeFor[UnixDomainSocketPluginOptions](), PluginTLS2Raw: reflect.TypeFor[TLS2RawPluginOptions](), PluginVirtualNet: reflect.TypeFor[VirtualNetPluginOptions](), } type ClientPluginOptions interface { Complete() Clone() ClientPluginOptions } type TypedClientPluginOptions struct { Type string `json:"type"` ClientPluginOptions } func (c TypedClientPluginOptions) Clone() TypedClientPluginOptions { out := c if c.ClientPluginOptions != nil { out.ClientPluginOptions = c.ClientPluginOptions.Clone() } return out } func (c *TypedClientPluginOptions) UnmarshalJSON(b []byte) error { decoded, err := DecodeClientPluginOptionsJSON(b, DecodeOptions{}) if err != nil { return err } *c = decoded return nil } func (c *TypedClientPluginOptions) MarshalJSON() ([]byte, error) { return jsonx.Marshal(c.ClientPluginOptions) } type HTTP2HTTPSPluginOptions struct { Type string `json:"type,omitempty"` LocalAddr string `json:"localAddr,omitempty"` HostHeaderRewrite string `json:"hostHeaderRewrite,omitempty"` RequestHeaders HeaderOperations `json:"requestHeaders,omitempty"` } func (o *HTTP2HTTPSPluginOptions) Complete() {} func (o *HTTP2HTTPSPluginOptions) Clone() ClientPluginOptions { if o == nil { return nil } out := *o out.RequestHeaders = o.RequestHeaders.Clone() return &out } type HTTPProxyPluginOptions struct { Type string `json:"type,omitempty"` HTTPUser string `json:"httpUser,omitempty"` HTTPPassword string `json:"httpPassword,omitempty"` } func (o *HTTPProxyPluginOptions) Complete() {} func (o *HTTPProxyPluginOptions) Clone() ClientPluginOptions { if o == nil { return nil } out := *o return &out } type HTTPS2HTTPPluginOptions struct { Type string `json:"type,omitempty"` LocalAddr string `json:"localAddr,omitempty"` HostHeaderRewrite string `json:"hostHeaderRewrite,omitempty"` RequestHeaders HeaderOperations `json:"requestHeaders,omitempty"` EnableHTTP2 *bool `json:"enableHTTP2,omitempty"` CrtPath string `json:"crtPath,omitempty"` KeyPath string `json:"keyPath,omitempty"` } func (o *HTTPS2HTTPPluginOptions) Complete() { o.EnableHTTP2 = util.EmptyOr(o.EnableHTTP2, lo.ToPtr(true)) } func (o *HTTPS2HTTPPluginOptions) Clone() ClientPluginOptions { if o == nil { return nil } out := *o out.RequestHeaders = o.RequestHeaders.Clone() out.EnableHTTP2 = util.ClonePtr(o.EnableHTTP2) return &out } type HTTPS2HTTPSPluginOptions struct { Type string `json:"type,omitempty"` LocalAddr string `json:"localAddr,omitempty"` HostHeaderRewrite string `json:"hostHeaderRewrite,omitempty"` RequestHeaders HeaderOperations `json:"requestHeaders,omitempty"` EnableHTTP2 *bool `json:"enableHTTP2,omitempty"` CrtPath string `json:"crtPath,omitempty"` KeyPath string `json:"keyPath,omitempty"` } func (o *HTTPS2HTTPSPluginOptions) Complete() { o.EnableHTTP2 = util.EmptyOr(o.EnableHTTP2, lo.ToPtr(true)) } func (o *HTTPS2HTTPSPluginOptions) Clone() ClientPluginOptions { if o == nil { return nil } out := *o out.RequestHeaders = o.RequestHeaders.Clone() out.EnableHTTP2 = util.ClonePtr(o.EnableHTTP2) return &out } type HTTP2HTTPPluginOptions struct { Type string `json:"type,omitempty"` LocalAddr string `json:"localAddr,omitempty"` HostHeaderRewrite string `json:"hostHeaderRewrite,omitempty"` RequestHeaders HeaderOperations `json:"requestHeaders,omitempty"` } func (o *HTTP2HTTPPluginOptions) Complete() {} func (o *HTTP2HTTPPluginOptions) Clone() ClientPluginOptions { if o == nil { return nil } out := *o out.RequestHeaders = o.RequestHeaders.Clone() return &out } type Socks5PluginOptions struct { Type string `json:"type,omitempty"` Username string `json:"username,omitempty"` Password string `json:"password,omitempty"` } func (o *Socks5PluginOptions) Complete() {} func (o *Socks5PluginOptions) Clone() ClientPluginOptions { if o == nil { return nil } out := *o return &out } type StaticFilePluginOptions struct { Type string `json:"type,omitempty"` LocalPath string `json:"localPath,omitempty"` StripPrefix string `json:"stripPrefix,omitempty"` HTTPUser string `json:"httpUser,omitempty"` HTTPPassword string `json:"httpPassword,omitempty"` } func (o *StaticFilePluginOptions) Complete() {} func (o *StaticFilePluginOptions) Clone() ClientPluginOptions { if o == nil { return nil } out := *o return &out } type UnixDomainSocketPluginOptions struct { Type string `json:"type,omitempty"` UnixPath string `json:"unixPath,omitempty"` } func (o *UnixDomainSocketPluginOptions) Complete() {} func (o *UnixDomainSocketPluginOptions) Clone() ClientPluginOptions { if o == nil { return nil } out := *o return &out } type TLS2RawPluginOptions struct { Type string `json:"type,omitempty"` LocalAddr string `json:"localAddr,omitempty"` CrtPath string `json:"crtPath,omitempty"` KeyPath string `json:"keyPath,omitempty"` } func (o *TLS2RawPluginOptions) Complete() {} func (o *TLS2RawPluginOptions) Clone() ClientPluginOptions { if o == nil { return nil } out := *o return &out } type VirtualNetPluginOptions struct { Type string `json:"type,omitempty"` } func (o *VirtualNetPluginOptions) Complete() {} func (o *VirtualNetPluginOptions) Clone() ClientPluginOptions { if o == nil { return nil } out := *o return &out } ================================================ FILE: pkg/config/v1/proxy_test.go ================================================ // Copyright 2023 The frp Authors // // Licensed under the Apache License, Version 2.0 (the "License"); // you may not use this file except in compliance with the License. // You may obtain a copy of the License at // // http://www.apache.org/licenses/LICENSE-2.0 // // Unless required by applicable law or agreed to in writing, software // distributed under the License is distributed on an "AS IS" BASIS, // WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. // See the License for the specific language governing permissions and // limitations under the License. package v1 import ( "encoding/json" "testing" "github.com/stretchr/testify/require" ) func TestUnmarshalTypedProxyConfig(t *testing.T) { require := require.New(t) proxyConfigs := struct { Proxies []TypedProxyConfig `json:"proxies,omitempty"` }{} strs := `{ "proxies": [ { "type": "tcp", "localPort": 22, "remotePort": 6000 }, { "type": "http", "localPort": 80, "customDomains": ["www.example.com"] } ] }` err := json.Unmarshal([]byte(strs), &proxyConfigs) require.NoError(err) require.IsType(&TCPProxyConfig{}, proxyConfigs.Proxies[0].ProxyConfigurer) require.IsType(&HTTPProxyConfig{}, proxyConfigs.Proxies[1].ProxyConfigurer) } ================================================ FILE: pkg/config/v1/server.go ================================================ // Copyright 2023 The frp Authors // // Licensed under the Apache License, Version 2.0 (the "License"); // you may not use this file except in compliance with the License. // You may obtain a copy of the License at // // http://www.apache.org/licenses/LICENSE-2.0 // // Unless required by applicable law or agreed to in writing, software // distributed under the License is distributed on an "AS IS" BASIS, // WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. // See the License for the specific language governing permissions and // limitations under the License. package v1 import ( "github.com/samber/lo" "github.com/fatedier/frp/pkg/config/types" "github.com/fatedier/frp/pkg/util/util" ) type ServerConfig struct { APIMetadata Auth AuthServerConfig `json:"auth,omitempty"` // BindAddr specifies the address that the server binds to. By default, // this value is "0.0.0.0". BindAddr string `json:"bindAddr,omitempty"` // BindPort specifies the port that the server listens on. By default, this // value is 7000. BindPort int `json:"bindPort,omitempty"` // KCPBindPort specifies the KCP port that the server listens on. If this // value is 0, the server will not listen for KCP connections. KCPBindPort int `json:"kcpBindPort,omitempty"` // QUICBindPort specifies the QUIC port that the server listens on. // Set this value to 0 will disable this feature. QUICBindPort int `json:"quicBindPort,omitempty"` // ProxyBindAddr specifies the address that the proxy binds to. This value // may be the same as BindAddr. ProxyBindAddr string `json:"proxyBindAddr,omitempty"` // VhostHTTPPort specifies the port that the server listens for HTTP Vhost // requests. If this value is 0, the server will not listen for HTTP // requests. VhostHTTPPort int `json:"vhostHTTPPort,omitempty"` // VhostHTTPTimeout specifies the response header timeout for the Vhost // HTTP server, in seconds. By default, this value is 60. VhostHTTPTimeout int64 `json:"vhostHTTPTimeout,omitempty"` // VhostHTTPSPort specifies the port that the server listens for HTTPS // Vhost requests. If this value is 0, the server will not listen for HTTPS // requests. VhostHTTPSPort int `json:"vhostHTTPSPort,omitempty"` // TCPMuxHTTPConnectPort specifies the port that the server listens for TCP // HTTP CONNECT requests. If the value is 0, the server will not multiplex TCP // requests on one single port. If it's not - it will listen on this value for // HTTP CONNECT requests. TCPMuxHTTPConnectPort int `json:"tcpmuxHTTPConnectPort,omitempty"` // If TCPMuxPassthrough is true, frps won't do any update on traffic. TCPMuxPassthrough bool `json:"tcpmuxPassthrough,omitempty"` // SubDomainHost specifies the domain that will be attached to sub-domains // requested by the client when using Vhost proxying. For example, if this // value is set to "frps.com" and the client requested the subdomain // "test", the resulting URL would be "test.frps.com". SubDomainHost string `json:"subDomainHost,omitempty"` // Custom404Page specifies a path to a custom 404 page to display. If this // value is "", a default page will be displayed. Custom404Page string `json:"custom404Page,omitempty"` SSHTunnelGateway SSHTunnelGateway `json:"sshTunnelGateway,omitempty"` WebServer WebServerConfig `json:"webServer,omitempty"` // EnablePrometheus will export prometheus metrics on webserver address // in /metrics api. EnablePrometheus bool `json:"enablePrometheus,omitempty"` Log LogConfig `json:"log,omitempty"` Transport ServerTransportConfig `json:"transport,omitempty"` // DetailedErrorsToClient defines whether to send the specific error (with // debug info) to frpc. By default, this value is true. DetailedErrorsToClient *bool `json:"detailedErrorsToClient,omitempty"` // MaxPortsPerClient specifies the maximum number of ports a single client // may proxy to. If this value is 0, no limit will be applied. MaxPortsPerClient int64 `json:"maxPortsPerClient,omitempty"` // UserConnTimeout specifies the maximum time to wait for a work // connection. By default, this value is 10. UserConnTimeout int64 `json:"userConnTimeout,omitempty"` // UDPPacketSize specifies the UDP packet size // By default, this value is 1500 UDPPacketSize int64 `json:"udpPacketSize,omitempty"` // NatHoleAnalysisDataReserveHours specifies the hours to reserve nat hole analysis data. NatHoleAnalysisDataReserveHours int64 `json:"natholeAnalysisDataReserveHours,omitempty"` AllowPorts []types.PortsRange `json:"allowPorts,omitempty"` HTTPPlugins []HTTPPluginOptions `json:"httpPlugins,omitempty"` } func (c *ServerConfig) Complete() error { if err := c.Auth.Complete(); err != nil { return err } c.Log.Complete() c.Transport.Complete() c.WebServer.Complete() c.SSHTunnelGateway.Complete() c.BindAddr = util.EmptyOr(c.BindAddr, "0.0.0.0") c.BindPort = util.EmptyOr(c.BindPort, 7000) if c.ProxyBindAddr == "" { c.ProxyBindAddr = c.BindAddr } if c.WebServer.Port > 0 { c.WebServer.Addr = util.EmptyOr(c.WebServer.Addr, "0.0.0.0") } c.VhostHTTPTimeout = util.EmptyOr(c.VhostHTTPTimeout, 60) c.DetailedErrorsToClient = util.EmptyOr(c.DetailedErrorsToClient, lo.ToPtr(true)) c.UserConnTimeout = util.EmptyOr(c.UserConnTimeout, 10) c.UDPPacketSize = util.EmptyOr(c.UDPPacketSize, 1500) c.NatHoleAnalysisDataReserveHours = util.EmptyOr(c.NatHoleAnalysisDataReserveHours, 7*24) return nil } type AuthServerConfig struct { Method AuthMethod `json:"method,omitempty"` AdditionalScopes []AuthScope `json:"additionalScopes,omitempty"` Token string `json:"token,omitempty"` TokenSource *ValueSource `json:"tokenSource,omitempty"` OIDC AuthOIDCServerConfig `json:"oidc,omitempty"` } func (c *AuthServerConfig) Complete() error { c.Method = util.EmptyOr(c.Method, "token") return nil } type AuthOIDCServerConfig struct { // Issuer specifies the issuer to verify OIDC tokens with. This issuer // will be used to load public keys to verify signature and will be compared // with the issuer claim in the OIDC token. Issuer string `json:"issuer,omitempty"` // Audience specifies the audience OIDC tokens should contain when validated. // If this value is empty, audience ("client ID") verification will be skipped. Audience string `json:"audience,omitempty"` // SkipExpiryCheck specifies whether to skip checking if the OIDC token is // expired. SkipExpiryCheck bool `json:"skipExpiryCheck,omitempty"` // SkipIssuerCheck specifies whether to skip checking if the OIDC token's // issuer claim matches the issuer specified in OidcIssuer. SkipIssuerCheck bool `json:"skipIssuerCheck,omitempty"` } type ServerTransportConfig struct { // TCPMux toggles TCP stream multiplexing. This allows multiple requests // from a client to share a single TCP connection. By default, this value // is true. // $HideFromDoc TCPMux *bool `json:"tcpMux,omitempty"` // TCPMuxKeepaliveInterval specifies the keep alive interval for TCP stream multiplier. // If TCPMux is true, heartbeat of application layer is unnecessary because it can only rely on heartbeat in TCPMux. TCPMuxKeepaliveInterval int64 `json:"tcpMuxKeepaliveInterval,omitempty"` // TCPKeepAlive specifies the interval between keep-alive probes for an active network connection between frpc and frps. // If negative, keep-alive probes are disabled. TCPKeepAlive int64 `json:"tcpKeepalive,omitempty"` // MaxPoolCount specifies the maximum pool size for each proxy. By default, // this value is 5. MaxPoolCount int64 `json:"maxPoolCount,omitempty"` // HeartBeatTimeout specifies the maximum time to wait for a heartbeat // before terminating the connection. It is not recommended to change this // value. By default, this value is 90. Set negative value to disable it. HeartbeatTimeout int64 `json:"heartbeatTimeout,omitempty"` // QUIC options. QUIC QUICOptions `json:"quic,omitempty"` // TLS specifies TLS settings for the connection from the client. TLS TLSServerConfig `json:"tls,omitempty"` } func (c *ServerTransportConfig) Complete() { c.TCPMux = util.EmptyOr(c.TCPMux, lo.ToPtr(true)) c.TCPMuxKeepaliveInterval = util.EmptyOr(c.TCPMuxKeepaliveInterval, 30) c.TCPKeepAlive = util.EmptyOr(c.TCPKeepAlive, 7200) c.MaxPoolCount = util.EmptyOr(c.MaxPoolCount, 5) if lo.FromPtr(c.TCPMux) { // If TCPMux is enabled, heartbeat of application layer is unnecessary because we can rely on heartbeat in tcpmux. c.HeartbeatTimeout = util.EmptyOr(c.HeartbeatTimeout, -1) } else { c.HeartbeatTimeout = util.EmptyOr(c.HeartbeatTimeout, 90) } c.QUIC.Complete() if c.TLS.TrustedCaFile != "" { c.TLS.Force = true } } type TLSServerConfig struct { // Force specifies whether to only accept TLS-encrypted connections. Force bool `json:"force,omitempty"` TLSConfig } type SSHTunnelGateway struct { BindPort int `json:"bindPort,omitempty"` PrivateKeyFile string `json:"privateKeyFile,omitempty"` AutoGenPrivateKeyPath string `json:"autoGenPrivateKeyPath,omitempty"` AuthorizedKeysFile string `json:"authorizedKeysFile,omitempty"` } func (c *SSHTunnelGateway) Complete() { c.AutoGenPrivateKeyPath = util.EmptyOr(c.AutoGenPrivateKeyPath, "./.autogen_ssh_key") } ================================================ FILE: pkg/config/v1/server_test.go ================================================ // Copyright 2023 The frp Authors // // Licensed under the Apache License, Version 2.0 (the "License"); // you may not use this file except in compliance with the License. // You may obtain a copy of the License at // // http://www.apache.org/licenses/LICENSE-2.0 // // Unless required by applicable law or agreed to in writing, software // distributed under the License is distributed on an "AS IS" BASIS, // WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. // See the License for the specific language governing permissions and // limitations under the License. package v1 import ( "testing" "github.com/samber/lo" "github.com/stretchr/testify/require" ) func TestServerConfigComplete(t *testing.T) { require := require.New(t) c := &ServerConfig{} err := c.Complete() require.NoError(err) require.EqualValues("token", c.Auth.Method) require.Equal(true, lo.FromPtr(c.Transport.TCPMux)) require.Equal(true, lo.FromPtr(c.DetailedErrorsToClient)) } func TestAuthServerConfig_Complete(t *testing.T) { require := require.New(t) cfg := &AuthServerConfig{} err := cfg.Complete() require.NoError(err) require.EqualValues("token", cfg.Method) } ================================================ FILE: pkg/config/v1/store.go ================================================ // Copyright 2026 The frp Authors // // Licensed under the Apache License, Version 2.0 (the "License"); // you may not use this file except in compliance with the License. // You may obtain a copy of the License at // // http://www.apache.org/licenses/LICENSE-2.0 // // Unless required by applicable law or agreed to in writing, software // distributed under the License is distributed on an "AS IS" BASIS, // WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. // See the License for the specific language governing permissions and // limitations under the License. package v1 // StoreConfig configures the built-in store source. type StoreConfig struct { // Path is the store file path. Path string `json:"path,omitempty"` } // IsEnabled returns true if the store is configured with a valid path. func (c *StoreConfig) IsEnabled() bool { return c.Path != "" } ================================================ FILE: pkg/config/v1/validation/client.go ================================================ // Copyright 2023 The frp Authors // // Licensed under the Apache License, Version 2.0 (the "License"); // you may not use this file except in compliance with the License. // You may obtain a copy of the License at // // http://www.apache.org/licenses/LICENSE-2.0 // // Unless required by applicable law or agreed to in writing, software // distributed under the License is distributed on an "AS IS" BASIS, // WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. // See the License for the specific language governing permissions and // limitations under the License. package validation import ( "fmt" "os" "path/filepath" "slices" "github.com/samber/lo" v1 "github.com/fatedier/frp/pkg/config/v1" "github.com/fatedier/frp/pkg/policy/featuregate" "github.com/fatedier/frp/pkg/policy/security" ) func (v *ConfigValidator) ValidateClientCommonConfig(c *v1.ClientCommonConfig) (Warning, error) { var ( warnings Warning errs error ) validators := []func() (Warning, error){ func() (Warning, error) { return validateFeatureGates(c) }, func() (Warning, error) { return v.validateAuthConfig(&c.Auth) }, func() (Warning, error) { return nil, validateLogConfig(&c.Log) }, func() (Warning, error) { return nil, validateWebServerConfig(&c.WebServer) }, func() (Warning, error) { return validateTransportConfig(&c.Transport) }, func() (Warning, error) { return validateIncludeFiles(c.IncludeConfigFiles) }, } for _, validator := range validators { w, err := validator() warnings = AppendError(warnings, w) errs = AppendError(errs, err) } return warnings, errs } func validateFeatureGates(c *v1.ClientCommonConfig) (Warning, error) { if c.VirtualNet.Address != "" { if !featuregate.Enabled(featuregate.VirtualNet) { return nil, fmt.Errorf("VirtualNet feature is not enabled; enable it by setting the appropriate feature gate flag") } } return nil, nil } func (v *ConfigValidator) validateAuthConfig(c *v1.AuthClientConfig) (Warning, error) { var errs error if !slices.Contains(SupportedAuthMethods, c.Method) { errs = AppendError(errs, fmt.Errorf("invalid auth method, optional values are %v", SupportedAuthMethods)) } if !lo.Every(SupportedAuthAdditionalScopes, c.AdditionalScopes) { errs = AppendError(errs, fmt.Errorf("invalid auth additional scopes, optional values are %v", SupportedAuthAdditionalScopes)) } // Validate token/tokenSource mutual exclusivity if c.Token != "" && c.TokenSource != nil { errs = AppendError(errs, fmt.Errorf("cannot specify both auth.token and auth.tokenSource")) } // Validate tokenSource if specified if c.TokenSource != nil { if c.TokenSource.Type == "exec" { if err := v.ValidateUnsafeFeature(security.TokenSourceExec); err != nil { errs = AppendError(errs, err) } } if err := c.TokenSource.Validate(); err != nil { errs = AppendError(errs, fmt.Errorf("invalid auth.tokenSource: %v", err)) } } if err := v.validateOIDCConfig(&c.OIDC); err != nil { errs = AppendError(errs, err) } if c.Method == v1.AuthMethodOIDC && c.OIDC.TokenSource == nil { if err := ValidateOIDCClientCredentialsConfig(&c.OIDC); err != nil { errs = AppendError(errs, err) } } return nil, errs } func (v *ConfigValidator) validateOIDCConfig(c *v1.AuthOIDCClientConfig) error { if c.TokenSource == nil { return nil } var errs error // Validate oidc.tokenSource mutual exclusivity with other fields of oidc if c.ClientID != "" || c.ClientSecret != "" || c.Audience != "" || c.Scope != "" || c.TokenEndpointURL != "" || len(c.AdditionalEndpointParams) > 0 || c.TrustedCaFile != "" || c.InsecureSkipVerify || c.ProxyURL != "" { errs = AppendError(errs, fmt.Errorf("cannot specify both auth.oidc.tokenSource and any other field of auth.oidc")) } if c.TokenSource.Type == "exec" { if err := v.ValidateUnsafeFeature(security.TokenSourceExec); err != nil { errs = AppendError(errs, err) } } if err := c.TokenSource.Validate(); err != nil { errs = AppendError(errs, fmt.Errorf("invalid auth.oidc.tokenSource: %v", err)) } return errs } func validateTransportConfig(c *v1.ClientTransportConfig) (Warning, error) { var ( warnings Warning errs error ) if c.HeartbeatTimeout > 0 && c.HeartbeatInterval > 0 { if c.HeartbeatTimeout < c.HeartbeatInterval { errs = AppendError(errs, fmt.Errorf("invalid transport.heartbeatTimeout, heartbeat timeout should not less than heartbeat interval")) } } if !lo.FromPtr(c.TLS.Enable) { checkTLSConfig := func(name string, value string) Warning { if value != "" { return fmt.Errorf("%s is invalid when transport.tls.enable is false", name) } return nil } warnings = AppendError(warnings, checkTLSConfig("transport.tls.certFile", c.TLS.CertFile)) warnings = AppendError(warnings, checkTLSConfig("transport.tls.keyFile", c.TLS.KeyFile)) warnings = AppendError(warnings, checkTLSConfig("transport.tls.trustedCaFile", c.TLS.TrustedCaFile)) } if !slices.Contains(SupportedTransportProtocols, c.Protocol) { errs = AppendError(errs, fmt.Errorf("invalid transport.protocol, optional values are %v", SupportedTransportProtocols)) } return warnings, errs } func validateIncludeFiles(files []string) (Warning, error) { var errs error for _, f := range files { absDir, err := filepath.Abs(filepath.Dir(f)) if err != nil { errs = AppendError(errs, fmt.Errorf("include: parse directory of %s failed: %v", f, err)) continue } if _, err := os.Stat(absDir); os.IsNotExist(err) { errs = AppendError(errs, fmt.Errorf("include: directory of %s not exist", f)) } } return nil, errs } func ValidateAllClientConfig( c *v1.ClientCommonConfig, proxyCfgs []v1.ProxyConfigurer, visitorCfgs []v1.VisitorConfigurer, unsafeFeatures *security.UnsafeFeatures, ) (Warning, error) { validator := NewConfigValidator(unsafeFeatures) var warnings Warning if c != nil { warning, err := validator.ValidateClientCommonConfig(c) warnings = AppendError(warnings, warning) if err != nil { return warnings, err } } for _, c := range proxyCfgs { if err := ValidateProxyConfigurerForClient(c); err != nil { return warnings, fmt.Errorf("proxy %s: %v", c.GetBaseConfig().Name, err) } } for _, c := range visitorCfgs { if err := ValidateVisitorConfigurer(c); err != nil { return warnings, fmt.Errorf("visitor %s: %v", c.GetBaseConfig().Name, err) } } return warnings, nil } ================================================ FILE: pkg/config/v1/validation/common.go ================================================ // Copyright 2023 The frp Authors // // Licensed under the Apache License, Version 2.0 (the "License"); // you may not use this file except in compliance with the License. // You may obtain a copy of the License at // // http://www.apache.org/licenses/LICENSE-2.0 // // Unless required by applicable law or agreed to in writing, software // distributed under the License is distributed on an "AS IS" BASIS, // WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. // See the License for the specific language governing permissions and // limitations under the License. package validation import ( "fmt" "slices" v1 "github.com/fatedier/frp/pkg/config/v1" ) func validateWebServerConfig(c *v1.WebServerConfig) error { if c.TLS != nil { if c.TLS.CertFile == "" { return fmt.Errorf("tls.certFile must be specified when tls is enabled") } if c.TLS.KeyFile == "" { return fmt.Errorf("tls.keyFile must be specified when tls is enabled") } } return ValidatePort(c.Port, "webServer.port") } // ValidatePort checks that the network port is in range func ValidatePort(port int, fieldPath string) error { if 0 <= port && port <= 65535 { return nil } return fmt.Errorf("%s: port number %d must be in the range 0..65535", fieldPath, port) } func validateLogConfig(c *v1.LogConfig) error { if !slices.Contains(SupportedLogLevels, c.Level) { return fmt.Errorf("invalid log level, optional values are %v", SupportedLogLevels) } return nil } ================================================ FILE: pkg/config/v1/validation/oidc.go ================================================ // Copyright 2026 The frp Authors // // Licensed under the Apache License, Version 2.0 (the "License"); // you may not use this file except in compliance with the License. // You may obtain a copy of the License at // // http://www.apache.org/licenses/LICENSE-2.0 // // Unless required by applicable law or agreed to in writing, software // distributed under the License is distributed on an "AS IS" BASIS, // WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. // See the License for the specific language governing permissions and // limitations under the License. package validation import ( "errors" "net/url" "strings" v1 "github.com/fatedier/frp/pkg/config/v1" ) func ValidateOIDCClientCredentialsConfig(c *v1.AuthOIDCClientConfig) error { var errs []string if c.ClientID == "" { errs = append(errs, "auth.oidc.clientID is required") } if c.TokenEndpointURL == "" { errs = append(errs, "auth.oidc.tokenEndpointURL is required") } else { tokenURL, err := url.Parse(c.TokenEndpointURL) if err != nil || !tokenURL.IsAbs() || tokenURL.Host == "" { errs = append(errs, "auth.oidc.tokenEndpointURL must be an absolute http or https URL") } else if tokenURL.Scheme != "http" && tokenURL.Scheme != "https" { errs = append(errs, "auth.oidc.tokenEndpointURL must use http or https") } } if _, ok := c.AdditionalEndpointParams["scope"]; ok { errs = append(errs, "auth.oidc.additionalEndpointParams.scope is not allowed; use auth.oidc.scope instead") } if c.Audience != "" { if _, ok := c.AdditionalEndpointParams["audience"]; ok { errs = append(errs, "cannot specify both auth.oidc.audience and auth.oidc.additionalEndpointParams.audience") } } if len(errs) == 0 { return nil } return errors.New(strings.Join(errs, "; ")) } ================================================ FILE: pkg/config/v1/validation/oidc_test.go ================================================ // Copyright 2026 The frp Authors // // Licensed under the Apache License, Version 2.0 (the "License"); // you may not use this file except in compliance with the License. // You may obtain a copy of the License at // // http://www.apache.org/licenses/LICENSE-2.0 // // Unless required by applicable law or agreed to in writing, software // distributed under the License is distributed on an "AS IS" BASIS, // WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. // See the License for the specific language governing permissions and // limitations under the License. package validation import ( "net/http" "net/http/httptest" "testing" "github.com/stretchr/testify/require" v1 "github.com/fatedier/frp/pkg/config/v1" ) func TestValidateOIDCClientCredentialsConfig(t *testing.T) { tokenServer := httptest.NewServer(http.NotFoundHandler()) defer tokenServer.Close() t.Run("valid", func(t *testing.T) { require.NoError(t, ValidateOIDCClientCredentialsConfig(&v1.AuthOIDCClientConfig{ ClientID: "test-client", TokenEndpointURL: tokenServer.URL, AdditionalEndpointParams: map[string]string{ "resource": "api", }, })) }) t.Run("invalid token endpoint url", func(t *testing.T) { err := ValidateOIDCClientCredentialsConfig(&v1.AuthOIDCClientConfig{ ClientID: "test-client", TokenEndpointURL: "://bad", }) require.ErrorContains(t, err, "auth.oidc.tokenEndpointURL") }) t.Run("missing client id", func(t *testing.T) { err := ValidateOIDCClientCredentialsConfig(&v1.AuthOIDCClientConfig{ TokenEndpointURL: tokenServer.URL, }) require.ErrorContains(t, err, "auth.oidc.clientID is required") }) t.Run("scope endpoint param is not allowed", func(t *testing.T) { err := ValidateOIDCClientCredentialsConfig(&v1.AuthOIDCClientConfig{ ClientID: "test-client", TokenEndpointURL: tokenServer.URL, AdditionalEndpointParams: map[string]string{ "scope": "email", }, }) require.ErrorContains(t, err, "auth.oidc.additionalEndpointParams.scope is not allowed; use auth.oidc.scope instead") }) t.Run("audience conflict", func(t *testing.T) { err := ValidateOIDCClientCredentialsConfig(&v1.AuthOIDCClientConfig{ ClientID: "test-client", TokenEndpointURL: tokenServer.URL, Audience: "api", AdditionalEndpointParams: map[string]string{ "audience": "override", }, }) require.ErrorContains(t, err, "cannot specify both auth.oidc.audience and auth.oidc.additionalEndpointParams.audience") }) } ================================================ FILE: pkg/config/v1/validation/plugin.go ================================================ // Copyright 2023 The frp Authors // // Licensed under the Apache License, Version 2.0 (the "License"); // you may not use this file except in compliance with the License. // You may obtain a copy of the License at // // http://www.apache.org/licenses/LICENSE-2.0 // // Unless required by applicable law or agreed to in writing, software // distributed under the License is distributed on an "AS IS" BASIS, // WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. // See the License for the specific language governing permissions and // limitations under the License. package validation import ( "errors" v1 "github.com/fatedier/frp/pkg/config/v1" ) func ValidateClientPluginOptions(c v1.ClientPluginOptions) error { switch v := c.(type) { case *v1.HTTP2HTTPSPluginOptions: return validateHTTP2HTTPSPluginOptions(v) case *v1.HTTPS2HTTPPluginOptions: return validateHTTPS2HTTPPluginOptions(v) case *v1.HTTPS2HTTPSPluginOptions: return validateHTTPS2HTTPSPluginOptions(v) case *v1.StaticFilePluginOptions: return validateStaticFilePluginOptions(v) case *v1.UnixDomainSocketPluginOptions: return validateUnixDomainSocketPluginOptions(v) case *v1.TLS2RawPluginOptions: return validateTLS2RawPluginOptions(v) } return nil } func validateHTTP2HTTPSPluginOptions(c *v1.HTTP2HTTPSPluginOptions) error { if c.LocalAddr == "" { return errors.New("localAddr is required") } return nil } func validateHTTPS2HTTPPluginOptions(c *v1.HTTPS2HTTPPluginOptions) error { if c.LocalAddr == "" { return errors.New("localAddr is required") } return nil } func validateHTTPS2HTTPSPluginOptions(c *v1.HTTPS2HTTPSPluginOptions) error { if c.LocalAddr == "" { return errors.New("localAddr is required") } return nil } func validateStaticFilePluginOptions(c *v1.StaticFilePluginOptions) error { if c.LocalPath == "" { return errors.New("localPath is required") } return nil } func validateUnixDomainSocketPluginOptions(c *v1.UnixDomainSocketPluginOptions) error { if c.UnixPath == "" { return errors.New("unixPath is required") } return nil } func validateTLS2RawPluginOptions(c *v1.TLS2RawPluginOptions) error { if c.LocalAddr == "" { return errors.New("localAddr is required") } return nil } ================================================ FILE: pkg/config/v1/validation/proxy.go ================================================ // Copyright 2023 The frp Authors // // Licensed under the Apache License, Version 2.0 (the "License"); // you may not use this file except in compliance with the License. // You may obtain a copy of the License at // // http://www.apache.org/licenses/LICENSE-2.0 // // Unless required by applicable law or agreed to in writing, software // distributed under the License is distributed on an "AS IS" BASIS, // WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. // See the License for the specific language governing permissions and // limitations under the License. package validation import ( "errors" "fmt" "slices" "strings" "k8s.io/apimachinery/pkg/util/validation" v1 "github.com/fatedier/frp/pkg/config/v1" ) func validateProxyBaseConfigForClient(c *v1.ProxyBaseConfig) error { if c.Name == "" { return errors.New("name should not be empty") } if err := ValidateAnnotations(c.Annotations); err != nil { return err } if !slices.Contains([]string{"", "v1", "v2"}, c.Transport.ProxyProtocolVersion) { return fmt.Errorf("not support proxy protocol version: %s", c.Transport.ProxyProtocolVersion) } if !slices.Contains([]string{"client", "server"}, c.Transport.BandwidthLimitMode) { return fmt.Errorf("bandwidth limit mode should be client or server") } if c.Plugin.Type == "" { if err := ValidatePort(c.LocalPort, "localPort"); err != nil { return fmt.Errorf("localPort: %v", err) } } if !slices.Contains([]string{"", "tcp", "http"}, c.HealthCheck.Type) { return fmt.Errorf("not support health check type: %s", c.HealthCheck.Type) } if c.HealthCheck.Type != "" { if c.HealthCheck.Type == "http" && c.HealthCheck.Path == "" { return fmt.Errorf("health check path should not be empty") } } if c.Plugin.Type != "" { if err := ValidateClientPluginOptions(c.Plugin.ClientPluginOptions); err != nil { return fmt.Errorf("plugin %s: %v", c.Plugin.Type, err) } } return nil } func validateProxyBaseConfigForServer(c *v1.ProxyBaseConfig) error { if err := ValidateAnnotations(c.Annotations); err != nil { return err } return nil } func validateDomainConfigForClient(c *v1.DomainConfig) error { if c.SubDomain == "" && len(c.CustomDomains) == 0 { return errors.New("subdomain and custom domains should not be both empty") } return nil } func validateDomainConfigForServer(c *v1.DomainConfig, s *v1.ServerConfig) error { for _, domain := range c.CustomDomains { if s.SubDomainHost != "" && len(strings.Split(s.SubDomainHost, ".")) < len(strings.Split(domain, ".")) { if strings.HasSuffix(domain, "."+s.SubDomainHost) { return fmt.Errorf("custom domain [%s] should not belong to subdomain host [%s]", domain, s.SubDomainHost) } } } if c.SubDomain != "" { if s.SubDomainHost == "" { return errors.New("subdomain is not supported because this feature is not enabled in server") } if strings.Contains(c.SubDomain, ".") || strings.Contains(c.SubDomain, "*") { return errors.New("'.' and '*' are not supported in subdomain") } } return nil } func ValidateProxyConfigurerForClient(c v1.ProxyConfigurer) error { base := c.GetBaseConfig() if err := validateProxyBaseConfigForClient(base); err != nil { return err } switch v := c.(type) { case *v1.TCPProxyConfig: return validateTCPProxyConfigForClient(v) case *v1.UDPProxyConfig: return validateUDPProxyConfigForClient(v) case *v1.TCPMuxProxyConfig: return validateTCPMuxProxyConfigForClient(v) case *v1.HTTPProxyConfig: return validateHTTPProxyConfigForClient(v) case *v1.HTTPSProxyConfig: return validateHTTPSProxyConfigForClient(v) case *v1.STCPProxyConfig: return validateSTCPProxyConfigForClient(v) case *v1.XTCPProxyConfig: return validateXTCPProxyConfigForClient(v) case *v1.SUDPProxyConfig: return validateSUDPProxyConfigForClient(v) } return errors.New("unknown proxy config type") } func validateTCPProxyConfigForClient(c *v1.TCPProxyConfig) error { return nil } func validateUDPProxyConfigForClient(c *v1.UDPProxyConfig) error { return nil } func validateTCPMuxProxyConfigForClient(c *v1.TCPMuxProxyConfig) error { if err := validateDomainConfigForClient(&c.DomainConfig); err != nil { return err } if !slices.Contains([]string{string(v1.TCPMultiplexerHTTPConnect)}, c.Multiplexer) { return fmt.Errorf("not support multiplexer: %s", c.Multiplexer) } return nil } func validateHTTPProxyConfigForClient(c *v1.HTTPProxyConfig) error { return validateDomainConfigForClient(&c.DomainConfig) } func validateHTTPSProxyConfigForClient(c *v1.HTTPSProxyConfig) error { return validateDomainConfigForClient(&c.DomainConfig) } func validateSTCPProxyConfigForClient(c *v1.STCPProxyConfig) error { return nil } func validateXTCPProxyConfigForClient(c *v1.XTCPProxyConfig) error { return nil } func validateSUDPProxyConfigForClient(c *v1.SUDPProxyConfig) error { return nil } func ValidateProxyConfigurerForServer(c v1.ProxyConfigurer, s *v1.ServerConfig) error { base := c.GetBaseConfig() if err := validateProxyBaseConfigForServer(base); err != nil { return err } switch v := c.(type) { case *v1.TCPProxyConfig: return validateTCPProxyConfigForServer(v, s) case *v1.UDPProxyConfig: return validateUDPProxyConfigForServer(v, s) case *v1.TCPMuxProxyConfig: return validateTCPMuxProxyConfigForServer(v, s) case *v1.HTTPProxyConfig: return validateHTTPProxyConfigForServer(v, s) case *v1.HTTPSProxyConfig: return validateHTTPSProxyConfigForServer(v, s) case *v1.STCPProxyConfig: return validateSTCPProxyConfigForServer(v, s) case *v1.XTCPProxyConfig: return validateXTCPProxyConfigForServer(v, s) case *v1.SUDPProxyConfig: return validateSUDPProxyConfigForServer(v, s) default: return errors.New("unknown proxy config type") } } func validateTCPProxyConfigForServer(c *v1.TCPProxyConfig, s *v1.ServerConfig) error { return nil } func validateUDPProxyConfigForServer(c *v1.UDPProxyConfig, s *v1.ServerConfig) error { return nil } func validateTCPMuxProxyConfigForServer(c *v1.TCPMuxProxyConfig, s *v1.ServerConfig) error { if c.Multiplexer == string(v1.TCPMultiplexerHTTPConnect) && s.TCPMuxHTTPConnectPort == 0 { return fmt.Errorf("tcpmux with multiplexer httpconnect not supported because this feature is not enabled in server") } return validateDomainConfigForServer(&c.DomainConfig, s) } func validateHTTPProxyConfigForServer(c *v1.HTTPProxyConfig, s *v1.ServerConfig) error { if s.VhostHTTPPort == 0 { return fmt.Errorf("type [http] not supported when vhost http port is not set") } return validateDomainConfigForServer(&c.DomainConfig, s) } func validateHTTPSProxyConfigForServer(c *v1.HTTPSProxyConfig, s *v1.ServerConfig) error { if s.VhostHTTPSPort == 0 { return fmt.Errorf("type [https] not supported when vhost https port is not set") } return validateDomainConfigForServer(&c.DomainConfig, s) } func validateSTCPProxyConfigForServer(c *v1.STCPProxyConfig, s *v1.ServerConfig) error { return nil } func validateXTCPProxyConfigForServer(c *v1.XTCPProxyConfig, s *v1.ServerConfig) error { return nil } func validateSUDPProxyConfigForServer(c *v1.SUDPProxyConfig, s *v1.ServerConfig) error { return nil } // ValidateAnnotations validates that a set of annotations are correctly defined. func ValidateAnnotations(annotations map[string]string) error { if len(annotations) == 0 { return nil } var errs error for k := range annotations { for _, msg := range validation.IsQualifiedName(strings.ToLower(k)) { errs = AppendError(errs, fmt.Errorf("annotation key %s is invalid: %s", k, msg)) } } if err := ValidateAnnotationsSize(annotations); err != nil { errs = AppendError(errs, err) } return errs } const TotalAnnotationSizeLimitB int = 256 * (1 << 10) // 256 kB func ValidateAnnotationsSize(annotations map[string]string) error { var totalSize int64 for k, v := range annotations { totalSize += (int64)(len(k)) + (int64)(len(v)) } if totalSize > (int64)(TotalAnnotationSizeLimitB) { return fmt.Errorf("annotations size %d is larger than limit %d", totalSize, TotalAnnotationSizeLimitB) } return nil } ================================================ FILE: pkg/config/v1/validation/server.go ================================================ // Copyright 2023 The frp Authors // // Licensed under the Apache License, Version 2.0 (the "License"); // you may not use this file except in compliance with the License. // You may obtain a copy of the License at // // http://www.apache.org/licenses/LICENSE-2.0 // // Unless required by applicable law or agreed to in writing, software // distributed under the License is distributed on an "AS IS" BASIS, // WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. // See the License for the specific language governing permissions and // limitations under the License. package validation import ( "fmt" "slices" "github.com/samber/lo" v1 "github.com/fatedier/frp/pkg/config/v1" "github.com/fatedier/frp/pkg/policy/security" ) func (v *ConfigValidator) ValidateServerConfig(c *v1.ServerConfig) (Warning, error) { var ( warnings Warning errs error ) if !slices.Contains(SupportedAuthMethods, c.Auth.Method) { errs = AppendError(errs, fmt.Errorf("invalid auth method, optional values are %v", SupportedAuthMethods)) } if !lo.Every(SupportedAuthAdditionalScopes, c.Auth.AdditionalScopes) { errs = AppendError(errs, fmt.Errorf("invalid auth additional scopes, optional values are %v", SupportedAuthAdditionalScopes)) } // Validate token/tokenSource mutual exclusivity if c.Auth.Token != "" && c.Auth.TokenSource != nil { errs = AppendError(errs, fmt.Errorf("cannot specify both auth.token and auth.tokenSource")) } // Validate tokenSource if specified if c.Auth.TokenSource != nil { if c.Auth.TokenSource.Type == "exec" { if err := v.ValidateUnsafeFeature(security.TokenSourceExec); err != nil { errs = AppendError(errs, err) } } if err := c.Auth.TokenSource.Validate(); err != nil { errs = AppendError(errs, fmt.Errorf("invalid auth.tokenSource: %v", err)) } } if err := validateLogConfig(&c.Log); err != nil { errs = AppendError(errs, err) } if err := validateWebServerConfig(&c.WebServer); err != nil { errs = AppendError(errs, err) } errs = AppendError(errs, ValidatePort(c.BindPort, "bindPort")) errs = AppendError(errs, ValidatePort(c.KCPBindPort, "kcpBindPort")) errs = AppendError(errs, ValidatePort(c.QUICBindPort, "quicBindPort")) errs = AppendError(errs, ValidatePort(c.VhostHTTPPort, "vhostHTTPPort")) errs = AppendError(errs, ValidatePort(c.VhostHTTPSPort, "vhostHTTPSPort")) errs = AppendError(errs, ValidatePort(c.TCPMuxHTTPConnectPort, "tcpMuxHTTPConnectPort")) for _, p := range c.HTTPPlugins { if !lo.Every(SupportedHTTPPluginOps, p.Ops) { errs = AppendError(errs, fmt.Errorf("invalid http plugin ops, optional values are %v", SupportedHTTPPluginOps)) } } return warnings, errs } ================================================ FILE: pkg/config/v1/validation/validation.go ================================================ // Copyright 2023 The frp Authors // // Licensed under the Apache License, Version 2.0 (the "License"); // you may not use this file except in compliance with the License. // You may obtain a copy of the License at // // http://www.apache.org/licenses/LICENSE-2.0 // // Unless required by applicable law or agreed to in writing, software // distributed under the License is distributed on an "AS IS" BASIS, // WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. // See the License for the specific language governing permissions and // limitations under the License. package validation import ( "errors" v1 "github.com/fatedier/frp/pkg/config/v1" splugin "github.com/fatedier/frp/pkg/plugin/server" ) var ( SupportedTransportProtocols = []string{ "tcp", "kcp", "quic", "websocket", "wss", } SupportedAuthMethods = []v1.AuthMethod{ "token", "oidc", } SupportedAuthAdditionalScopes = []v1.AuthScope{ "HeartBeats", "NewWorkConns", } SupportedLogLevels = []string{ "trace", "debug", "info", "warn", "error", } SupportedHTTPPluginOps = []string{ splugin.OpLogin, splugin.OpNewProxy, splugin.OpCloseProxy, splugin.OpPing, splugin.OpNewWorkConn, splugin.OpNewUserConn, } ) type Warning error func AppendError(err error, errs ...error) error { if len(errs) == 0 { return err } return errors.Join(append([]error{err}, errs...)...) } ================================================ FILE: pkg/config/v1/validation/validator.go ================================================ package validation import ( "fmt" "github.com/fatedier/frp/pkg/policy/security" ) // ConfigValidator holds the context dependencies for configuration validation. type ConfigValidator struct { unsafeFeatures *security.UnsafeFeatures } // NewConfigValidator creates a new ConfigValidator instance. func NewConfigValidator(unsafeFeatures *security.UnsafeFeatures) *ConfigValidator { return &ConfigValidator{ unsafeFeatures: unsafeFeatures, } } // ValidateUnsafeFeature checks if a specific unsafe feature is enabled. func (v *ConfigValidator) ValidateUnsafeFeature(feature string) error { if !v.unsafeFeatures.IsEnabled(feature) { return fmt.Errorf("unsafe feature %q is not enabled. "+ "To enable it, ensure it is allowed in the configuration or command line flags", feature) } return nil } ================================================ FILE: pkg/config/v1/validation/visitor.go ================================================ // Copyright 2023 The frp Authors // // Licensed under the Apache License, Version 2.0 (the "License"); // you may not use this file except in compliance with the License. // You may obtain a copy of the License at // // http://www.apache.org/licenses/LICENSE-2.0 // // Unless required by applicable law or agreed to in writing, software // distributed under the License is distributed on an "AS IS" BASIS, // WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. // See the License for the specific language governing permissions and // limitations under the License. package validation import ( "errors" "fmt" "slices" v1 "github.com/fatedier/frp/pkg/config/v1" ) func ValidateVisitorConfigurer(c v1.VisitorConfigurer) error { base := c.GetBaseConfig() if err := validateVisitorBaseConfig(base); err != nil { return err } switch v := c.(type) { case *v1.STCPVisitorConfig: case *v1.SUDPVisitorConfig: case *v1.XTCPVisitorConfig: return validateXTCPVisitorConfig(v) default: return errors.New("unknown visitor config type") } return nil } func validateVisitorBaseConfig(c *v1.VisitorBaseConfig) error { if c.Name == "" { return errors.New("name is required") } if c.ServerName == "" { return errors.New("server name is required") } if c.BindPort == 0 { return errors.New("bind port is required") } return nil } func validateXTCPVisitorConfig(c *v1.XTCPVisitorConfig) error { if !slices.Contains([]string{"kcp", "quic"}, c.Protocol) { return fmt.Errorf("protocol should be kcp or quic") } return nil } ================================================ FILE: pkg/config/v1/value_source.go ================================================ // Copyright 2025 The frp Authors // // Licensed under the Apache License, Version 2.0 (the "License"); // you may not use this file except in compliance with the License. // You may obtain a copy of the License at // // http://www.apache.org/licenses/LICENSE-2.0 // // Unless required by applicable law or agreed to in writing, software // distributed under the License is distributed on an "AS IS" BASIS, // WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. // See the License for the specific language governing permissions and // limitations under the License. package v1 import ( "context" "errors" "fmt" "os" "os/exec" "strings" ) // ValueSource provides a way to dynamically resolve configuration values // from various sources like files, environment variables, or external services. type ValueSource struct { Type string `json:"type"` File *FileSource `json:"file,omitempty"` Exec *ExecSource `json:"exec,omitempty"` } // FileSource specifies how to load a value from a file. type FileSource struct { Path string `json:"path"` } // ExecSource specifies how to get a value from another program launched as subprocess. type ExecSource struct { Command string `json:"command"` Args []string `json:"args,omitempty"` Env []ExecEnvVar `json:"env,omitempty"` } type ExecEnvVar struct { Name string `json:"name"` Value string `json:"value"` } // Validate validates the ValueSource configuration. func (v *ValueSource) Validate() error { if v == nil { return errors.New("valueSource cannot be nil") } switch v.Type { case "file": if v.File == nil { return errors.New("file configuration is required when type is 'file'") } return v.File.Validate() case "exec": if v.Exec == nil { return errors.New("exec configuration is required when type is 'exec'") } return v.Exec.Validate() default: return fmt.Errorf("unsupported value source type: %s (only 'file' and 'exec' are supported)", v.Type) } } // Resolve resolves the value from the configured source. func (v *ValueSource) Resolve(ctx context.Context) (string, error) { if err := v.Validate(); err != nil { return "", err } switch v.Type { case "file": return v.File.Resolve(ctx) case "exec": return v.Exec.Resolve(ctx) default: return "", fmt.Errorf("unsupported value source type: %s", v.Type) } } // Validate validates the FileSource configuration. func (f *FileSource) Validate() error { if f == nil { return errors.New("fileSource cannot be nil") } if f.Path == "" { return errors.New("file path cannot be empty") } return nil } // Resolve reads and returns the content from the specified file. func (f *FileSource) Resolve(_ context.Context) (string, error) { if err := f.Validate(); err != nil { return "", err } content, err := os.ReadFile(f.Path) if err != nil { return "", fmt.Errorf("failed to read file %s: %v", f.Path, err) } // Trim whitespace, which is important for file-based tokens return strings.TrimSpace(string(content)), nil } // Validate validates the ExecSource configuration. func (e *ExecSource) Validate() error { if e == nil { return errors.New("execSource cannot be nil") } if e.Command == "" { return errors.New("exec command cannot be empty") } for _, env := range e.Env { if env.Name == "" { return errors.New("exec env name cannot be empty") } if strings.Contains(env.Name, "=") { return errors.New("exec env name cannot contain '='") } } return nil } // Resolve reads and returns the content captured from stdout of launched subprocess. func (e *ExecSource) Resolve(ctx context.Context) (string, error) { if err := e.Validate(); err != nil { return "", err } cmd := exec.CommandContext(ctx, e.Command, e.Args...) if len(e.Env) != 0 { cmd.Env = os.Environ() for _, env := range e.Env { cmd.Env = append(cmd.Env, env.Name+"="+env.Value) } } content, err := cmd.Output() if err != nil { return "", fmt.Errorf("failed to execute command %v: %v", e.Command, err) } // Trim whitespace, which is important for exec-based tokens return strings.TrimSpace(string(content)), nil } ================================================ FILE: pkg/config/v1/value_source_test.go ================================================ // Copyright 2025 The frp Authors // // Licensed under the Apache License, Version 2.0 (the "License"); // you may not use this file except in compliance with the License. // You may obtain a copy of the License at // // http://www.apache.org/licenses/LICENSE-2.0 // // Unless required by applicable law or agreed to in writing, software // distributed under the License is distributed on an "AS IS" BASIS, // WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. // See the License for the specific language governing permissions and // limitations under the License. package v1 import ( "context" "os" "path/filepath" "testing" ) func TestValueSource_Validate(t *testing.T) { tests := []struct { name string vs *ValueSource wantErr bool }{ { name: "nil valueSource", vs: nil, wantErr: true, }, { name: "unsupported type", vs: &ValueSource{ Type: "unsupported", }, wantErr: true, }, { name: "file type without file config", vs: &ValueSource{ Type: "file", File: nil, }, wantErr: true, }, { name: "valid file type with absolute path", vs: &ValueSource{ Type: "file", File: &FileSource{ Path: "/tmp/test", }, }, wantErr: false, }, { name: "valid file type with relative path", vs: &ValueSource{ Type: "file", File: &FileSource{ Path: "configs/token", }, }, wantErr: false, }, } for _, tt := range tests { t.Run(tt.name, func(t *testing.T) { err := tt.vs.Validate() if (err != nil) != tt.wantErr { t.Errorf("ValueSource.Validate() error = %v, wantErr %v", err, tt.wantErr) } }) } } func TestFileSource_Validate(t *testing.T) { tests := []struct { name string fs *FileSource wantErr bool }{ { name: "nil fileSource", fs: nil, wantErr: true, }, { name: "empty path", fs: &FileSource{ Path: "", }, wantErr: true, }, { name: "relative path (allowed)", fs: &FileSource{ Path: "relative/path", }, wantErr: false, }, { name: "absolute path", fs: &FileSource{ Path: "/absolute/path", }, wantErr: false, }, } for _, tt := range tests { t.Run(tt.name, func(t *testing.T) { err := tt.fs.Validate() if (err != nil) != tt.wantErr { t.Errorf("FileSource.Validate() error = %v, wantErr %v", err, tt.wantErr) } }) } } func TestFileSource_Resolve(t *testing.T) { // Create a temporary file for testing tmpDir := t.TempDir() testFile := filepath.Join(tmpDir, "test_token") testContent := "test-token-value\n\t " expectedContent := "test-token-value" err := os.WriteFile(testFile, []byte(testContent), 0o600) if err != nil { t.Fatalf("failed to create test file: %v", err) } tests := []struct { name string fs *FileSource want string wantErr bool }{ { name: "valid file path", fs: &FileSource{ Path: testFile, }, want: expectedContent, wantErr: false, }, { name: "non-existent file", fs: &FileSource{ Path: "/non/existent/file", }, want: "", wantErr: true, }, { name: "path traversal attempt (should fail validation)", fs: &FileSource{ Path: "../../../etc/passwd", }, want: "", wantErr: true, }, } for _, tt := range tests { t.Run(tt.name, func(t *testing.T) { got, err := tt.fs.Resolve(context.Background()) if (err != nil) != tt.wantErr { t.Errorf("FileSource.Resolve() error = %v, wantErr %v", err, tt.wantErr) return } if got != tt.want { t.Errorf("FileSource.Resolve() = %v, want %v", got, tt.want) } }) } } func TestValueSource_Resolve(t *testing.T) { // Create a temporary file for testing tmpDir := t.TempDir() testFile := filepath.Join(tmpDir, "test_token") testContent := "test-token-value" err := os.WriteFile(testFile, []byte(testContent), 0o600) if err != nil { t.Fatalf("failed to create test file: %v", err) } tests := []struct { name string vs *ValueSource want string wantErr bool }{ { name: "valid file type", vs: &ValueSource{ Type: "file", File: &FileSource{ Path: testFile, }, }, want: testContent, wantErr: false, }, { name: "unsupported type", vs: &ValueSource{ Type: "unsupported", }, want: "", wantErr: true, }, { name: "file type with path traversal", vs: &ValueSource{ Type: "file", File: &FileSource{ Path: "../../../etc/passwd", }, }, want: "", wantErr: true, }, } ctx := context.Background() for _, tt := range tests { t.Run(tt.name, func(t *testing.T) { got, err := tt.vs.Resolve(ctx) if (err != nil) != tt.wantErr { t.Errorf("ValueSource.Resolve() error = %v, wantErr %v", err, tt.wantErr) return } if got != tt.want { t.Errorf("ValueSource.Resolve() = %v, want %v", got, tt.want) } }) } } ================================================ FILE: pkg/config/v1/visitor.go ================================================ // Copyright 2023 The frp Authors // // Licensed under the Apache License, Version 2.0 (the "License"); // you may not use this file except in compliance with the License. // You may obtain a copy of the License at // // http://www.apache.org/licenses/LICENSE-2.0 // // Unless required by applicable law or agreed to in writing, software // distributed under the License is distributed on an "AS IS" BASIS, // WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. // See the License for the specific language governing permissions and // limitations under the License. package v1 import ( "reflect" "github.com/fatedier/frp/pkg/util/jsonx" "github.com/fatedier/frp/pkg/util/util" ) type VisitorTransport struct { UseEncryption bool `json:"useEncryption,omitempty"` UseCompression bool `json:"useCompression,omitempty"` } type VisitorBaseConfig struct { Name string `json:"name"` Type string `json:"type"` // Enabled controls whether this visitor is enabled. nil or true means enabled, false means disabled. // This allows individual control over each visitor, complementing the global "start" field. Enabled *bool `json:"enabled,omitempty"` Transport VisitorTransport `json:"transport,omitempty"` SecretKey string `json:"secretKey,omitempty"` // if the server user is not set, it defaults to the current user ServerUser string `json:"serverUser,omitempty"` ServerName string `json:"serverName,omitempty"` BindAddr string `json:"bindAddr,omitempty"` // BindPort is the port that visitor listens on. // It can be less than 0, it means don't bind to the port and only receive connections redirected from // other visitors. (This is not supported for SUDP now) BindPort int `json:"bindPort,omitempty"` // Plugin specifies what plugin should be used. Plugin TypedVisitorPluginOptions `json:"plugin,omitempty"` } func (c VisitorBaseConfig) Clone() VisitorBaseConfig { out := c out.Enabled = util.ClonePtr(c.Enabled) out.Plugin = c.Plugin.Clone() return out } func (c *VisitorBaseConfig) GetBaseConfig() *VisitorBaseConfig { return c } func (c *VisitorBaseConfig) Complete() { if c.BindAddr == "" { c.BindAddr = "127.0.0.1" } } type VisitorConfigurer interface { Complete() GetBaseConfig() *VisitorBaseConfig Clone() VisitorConfigurer } type VisitorType string const ( VisitorTypeSTCP VisitorType = "stcp" VisitorTypeXTCP VisitorType = "xtcp" VisitorTypeSUDP VisitorType = "sudp" ) var visitorConfigTypeMap = map[VisitorType]reflect.Type{ VisitorTypeSTCP: reflect.TypeFor[STCPVisitorConfig](), VisitorTypeXTCP: reflect.TypeFor[XTCPVisitorConfig](), VisitorTypeSUDP: reflect.TypeFor[SUDPVisitorConfig](), } type TypedVisitorConfig struct { Type string `json:"type"` VisitorConfigurer } func (c *TypedVisitorConfig) UnmarshalJSON(b []byte) error { configurer, err := DecodeVisitorConfigurerJSON(b, DecodeOptions{}) if err != nil { return err } c.Type = configurer.GetBaseConfig().Type c.VisitorConfigurer = configurer return nil } func (c *TypedVisitorConfig) MarshalJSON() ([]byte, error) { return jsonx.Marshal(c.VisitorConfigurer) } func NewVisitorConfigurerByType(t VisitorType) VisitorConfigurer { v, ok := visitorConfigTypeMap[t] if !ok { return nil } vc := reflect.New(v).Interface().(VisitorConfigurer) vc.GetBaseConfig().Type = string(t) return vc } var _ VisitorConfigurer = &STCPVisitorConfig{} type STCPVisitorConfig struct { VisitorBaseConfig } func (c *STCPVisitorConfig) Clone() VisitorConfigurer { out := *c out.VisitorBaseConfig = c.VisitorBaseConfig.Clone() return &out } var _ VisitorConfigurer = &SUDPVisitorConfig{} type SUDPVisitorConfig struct { VisitorBaseConfig } func (c *SUDPVisitorConfig) Clone() VisitorConfigurer { out := *c out.VisitorBaseConfig = c.VisitorBaseConfig.Clone() return &out } var _ VisitorConfigurer = &XTCPVisitorConfig{} type XTCPVisitorConfig struct { VisitorBaseConfig Protocol string `json:"protocol,omitempty"` KeepTunnelOpen bool `json:"keepTunnelOpen,omitempty"` MaxRetriesAnHour int `json:"maxRetriesAnHour,omitempty"` MinRetryInterval int `json:"minRetryInterval,omitempty"` FallbackTo string `json:"fallbackTo,omitempty"` FallbackTimeoutMs int `json:"fallbackTimeoutMs,omitempty"` // NatTraversal configuration for NAT traversal NatTraversal *NatTraversalConfig `json:"natTraversal,omitempty"` } func (c *XTCPVisitorConfig) Complete() { c.VisitorBaseConfig.Complete() c.Protocol = util.EmptyOr(c.Protocol, "quic") c.MaxRetriesAnHour = util.EmptyOr(c.MaxRetriesAnHour, 8) c.MinRetryInterval = util.EmptyOr(c.MinRetryInterval, 90) c.FallbackTimeoutMs = util.EmptyOr(c.FallbackTimeoutMs, 1000) } func (c *XTCPVisitorConfig) Clone() VisitorConfigurer { out := *c out.VisitorBaseConfig = c.VisitorBaseConfig.Clone() out.NatTraversal = c.NatTraversal.Clone() return &out } ================================================ FILE: pkg/config/v1/visitor_plugin.go ================================================ // Copyright 2025 The frp Authors // // Licensed under the Apache License, Version 2.0 (the "License"); // you may not use this file except in compliance with the License. // You may obtain a copy of the License at // // http://www.apache.org/licenses/LICENSE-2.0 // // Unless required by applicable law or agreed to in writing, software // distributed under the License is distributed on an "AS IS" BASIS, // WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. // See the License for the specific language governing permissions and // limitations under the License. package v1 import ( "reflect" "github.com/fatedier/frp/pkg/util/jsonx" ) const ( VisitorPluginVirtualNet = "virtual_net" ) var visitorPluginOptionsTypeMap = map[string]reflect.Type{ VisitorPluginVirtualNet: reflect.TypeFor[VirtualNetVisitorPluginOptions](), } type VisitorPluginOptions interface { Complete() Clone() VisitorPluginOptions } type TypedVisitorPluginOptions struct { Type string `json:"type"` VisitorPluginOptions } func (c TypedVisitorPluginOptions) Clone() TypedVisitorPluginOptions { out := c if c.VisitorPluginOptions != nil { out.VisitorPluginOptions = c.VisitorPluginOptions.Clone() } return out } func (c *TypedVisitorPluginOptions) UnmarshalJSON(b []byte) error { decoded, err := DecodeVisitorPluginOptionsJSON(b, DecodeOptions{}) if err != nil { return err } *c = decoded return nil } func (c *TypedVisitorPluginOptions) MarshalJSON() ([]byte, error) { return jsonx.Marshal(c.VisitorPluginOptions) } type VirtualNetVisitorPluginOptions struct { Type string `json:"type"` DestinationIP string `json:"destinationIP"` } func (o *VirtualNetVisitorPluginOptions) Complete() {} func (o *VirtualNetVisitorPluginOptions) Clone() VisitorPluginOptions { if o == nil { return nil } out := *o return &out } ================================================ FILE: pkg/errors/errors.go ================================================ // Copyright 2016 fatedier, fatedier@gmail.com // // Licensed under the Apache License, Version 2.0 (the "License"); // you may not use this file except in compliance with the License. // You may obtain a copy of the License at // // http://www.apache.org/licenses/LICENSE-2.0 // // Unless required by applicable law or agreed to in writing, software // distributed under the License is distributed on an "AS IS" BASIS, // WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. // See the License for the specific language governing permissions and // limitations under the License. package errors import ( "errors" ) var ( ErrMsgType = errors.New("message type error") ErrCtlClosed = errors.New("control is closed") ) ================================================ FILE: pkg/metrics/aggregate/server.go ================================================ // Copyright 2020 fatedier, fatedier@gmail.com // // Licensed under the Apache License, Version 2.0 (the "License"); // you may not use this file except in compliance with the License. // You may obtain a copy of the License at // // http://www.apache.org/licenses/LICENSE-2.0 // // Unless required by applicable law or agreed to in writing, software // distributed under the License is distributed on an "AS IS" BASIS, // WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. // See the License for the specific language governing permissions and // limitations under the License. package aggregate import ( "github.com/fatedier/frp/pkg/metrics/mem" "github.com/fatedier/frp/pkg/metrics/prometheus" "github.com/fatedier/frp/server/metrics" ) // EnableMem start to mark metrics to memory monitor system. func EnableMem() { sm.Add(mem.ServerMetrics) } // EnablePrometheus start to mark metrics to prometheus. func EnablePrometheus() { sm.Add(prometheus.ServerMetrics) } var sm = &serverMetrics{} func init() { metrics.Register(sm) } type serverMetrics struct { ms []metrics.ServerMetrics } func (m *serverMetrics) Add(sm metrics.ServerMetrics) { m.ms = append(m.ms, sm) } func (m *serverMetrics) NewClient() { for _, v := range m.ms { v.NewClient() } } func (m *serverMetrics) CloseClient() { for _, v := range m.ms { v.CloseClient() } } func (m *serverMetrics) NewProxy(name string, proxyType string, user string, clientID string) { for _, v := range m.ms { v.NewProxy(name, proxyType, user, clientID) } } func (m *serverMetrics) CloseProxy(name string, proxyType string) { for _, v := range m.ms { v.CloseProxy(name, proxyType) } } func (m *serverMetrics) OpenConnection(name string, proxyType string) { for _, v := range m.ms { v.OpenConnection(name, proxyType) } } func (m *serverMetrics) CloseConnection(name string, proxyType string) { for _, v := range m.ms { v.CloseConnection(name, proxyType) } } func (m *serverMetrics) AddTrafficIn(name string, proxyType string, trafficBytes int64) { for _, v := range m.ms { v.AddTrafficIn(name, proxyType, trafficBytes) } } func (m *serverMetrics) AddTrafficOut(name string, proxyType string, trafficBytes int64) { for _, v := range m.ms { v.AddTrafficOut(name, proxyType, trafficBytes) } } ================================================ FILE: pkg/metrics/mem/server.go ================================================ // Copyright 2019 fatedier, fatedier@gmail.com // // Licensed under the Apache License, Version 2.0 (the "License"); // you may not use this file except in compliance with the License. // You may obtain a copy of the License at // // http://www.apache.org/licenses/LICENSE-2.0 // // Unless required by applicable law or agreed to in writing, software // distributed under the License is distributed on an "AS IS" BASIS, // WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. // See the License for the specific language governing permissions and // limitations under the License. package mem import ( "sync" "time" "github.com/fatedier/frp/pkg/util/log" "github.com/fatedier/frp/pkg/util/metric" server "github.com/fatedier/frp/server/metrics" ) var ( sm = newServerMetrics() ServerMetrics server.ServerMetrics StatsCollector Collector ) func init() { ServerMetrics = sm StatsCollector = sm sm.run() } type serverMetrics struct { info *ServerStatistics mu sync.Mutex } func newServerMetrics() *serverMetrics { return &serverMetrics{ info: &ServerStatistics{ TotalTrafficIn: metric.NewDateCounter(ReserveDays), TotalTrafficOut: metric.NewDateCounter(ReserveDays), CurConns: metric.NewCounter(), ClientCounts: metric.NewCounter(), ProxyTypeCounts: make(map[string]metric.Counter), ProxyStatistics: make(map[string]*ProxyStatistics), }, } } func (m *serverMetrics) run() { go func() { for { time.Sleep(12 * time.Hour) start := time.Now() count, total := m.clearUselessInfo(time.Duration(7*24) * time.Hour) log.Debugf("clear useless proxy statistics data count %d/%d, cost %v", count, total, time.Since(start)) } }() } func (m *serverMetrics) clearUselessInfo(continuousOfflineDuration time.Duration) (int, int) { count := 0 total := 0 // To check if there are any proxies that have been closed for more than continuousOfflineDuration and remove them. m.mu.Lock() defer m.mu.Unlock() total = len(m.info.ProxyStatistics) for name, data := range m.info.ProxyStatistics { if !data.LastCloseTime.IsZero() && data.LastStartTime.Before(data.LastCloseTime) && time.Since(data.LastCloseTime) > continuousOfflineDuration { delete(m.info.ProxyStatistics, name) count++ log.Tracef("clear proxy [%s]'s statistics data, lastCloseTime: [%s]", name, data.LastCloseTime.String()) } } return count, total } func (m *serverMetrics) ClearOfflineProxies() (int, int) { return m.clearUselessInfo(0) } func (m *serverMetrics) NewClient() { m.info.ClientCounts.Inc(1) } func (m *serverMetrics) CloseClient() { m.info.ClientCounts.Dec(1) } func (m *serverMetrics) NewProxy(name string, proxyType string, user string, clientID string) { m.mu.Lock() defer m.mu.Unlock() counter, ok := m.info.ProxyTypeCounts[proxyType] if !ok { counter = metric.NewCounter() } counter.Inc(1) m.info.ProxyTypeCounts[proxyType] = counter proxyStats, ok := m.info.ProxyStatistics[name] if !ok || proxyStats.ProxyType != proxyType { proxyStats = &ProxyStatistics{ Name: name, ProxyType: proxyType, CurConns: metric.NewCounter(), TrafficIn: metric.NewDateCounter(ReserveDays), TrafficOut: metric.NewDateCounter(ReserveDays), } m.info.ProxyStatistics[name] = proxyStats } proxyStats.User = user proxyStats.ClientID = clientID proxyStats.LastStartTime = time.Now() } func (m *serverMetrics) CloseProxy(name string, proxyType string) { m.mu.Lock() defer m.mu.Unlock() if counter, ok := m.info.ProxyTypeCounts[proxyType]; ok { counter.Dec(1) } if proxyStats, ok := m.info.ProxyStatistics[name]; ok { proxyStats.LastCloseTime = time.Now() } } func (m *serverMetrics) OpenConnection(name string, _ string) { m.info.CurConns.Inc(1) m.mu.Lock() defer m.mu.Unlock() proxyStats, ok := m.info.ProxyStatistics[name] if ok { proxyStats.CurConns.Inc(1) } } func (m *serverMetrics) CloseConnection(name string, _ string) { m.info.CurConns.Dec(1) m.mu.Lock() defer m.mu.Unlock() proxyStats, ok := m.info.ProxyStatistics[name] if ok { proxyStats.CurConns.Dec(1) } } func (m *serverMetrics) AddTrafficIn(name string, _ string, trafficBytes int64) { m.info.TotalTrafficIn.Inc(trafficBytes) m.mu.Lock() defer m.mu.Unlock() proxyStats, ok := m.info.ProxyStatistics[name] if ok { proxyStats.TrafficIn.Inc(trafficBytes) } } func (m *serverMetrics) AddTrafficOut(name string, _ string, trafficBytes int64) { m.info.TotalTrafficOut.Inc(trafficBytes) m.mu.Lock() defer m.mu.Unlock() proxyStats, ok := m.info.ProxyStatistics[name] if ok { proxyStats.TrafficOut.Inc(trafficBytes) } } // Get stats data api. func (m *serverMetrics) GetServer() *ServerStats { m.mu.Lock() defer m.mu.Unlock() s := &ServerStats{ TotalTrafficIn: m.info.TotalTrafficIn.TodayCount(), TotalTrafficOut: m.info.TotalTrafficOut.TodayCount(), CurConns: int64(m.info.CurConns.Count()), ClientCounts: int64(m.info.ClientCounts.Count()), ProxyTypeCounts: make(map[string]int64), } for k, v := range m.info.ProxyTypeCounts { s.ProxyTypeCounts[k] = int64(v.Count()) } return s } func toProxyStats(name string, proxyStats *ProxyStatistics) *ProxyStats { ps := &ProxyStats{ Name: name, Type: proxyStats.ProxyType, User: proxyStats.User, ClientID: proxyStats.ClientID, TodayTrafficIn: proxyStats.TrafficIn.TodayCount(), TodayTrafficOut: proxyStats.TrafficOut.TodayCount(), CurConns: int64(proxyStats.CurConns.Count()), } if !proxyStats.LastStartTime.IsZero() { ps.LastStartTime = proxyStats.LastStartTime.Format("01-02 15:04:05") } if !proxyStats.LastCloseTime.IsZero() { ps.LastCloseTime = proxyStats.LastCloseTime.Format("01-02 15:04:05") } return ps } func (m *serverMetrics) GetProxiesByType(proxyType string) []*ProxyStats { res := make([]*ProxyStats, 0) m.mu.Lock() defer m.mu.Unlock() for name, proxyStats := range m.info.ProxyStatistics { if proxyStats.ProxyType != proxyType { continue } res = append(res, toProxyStats(name, proxyStats)) } return res } func (m *serverMetrics) GetProxiesByTypeAndName(proxyType string, proxyName string) (res *ProxyStats) { m.mu.Lock() defer m.mu.Unlock() proxyStats, ok := m.info.ProxyStatistics[proxyName] if ok && proxyStats.ProxyType == proxyType { res = toProxyStats(proxyName, proxyStats) } return } func (m *serverMetrics) GetProxyByName(proxyName string) (res *ProxyStats) { m.mu.Lock() defer m.mu.Unlock() proxyStats, ok := m.info.ProxyStatistics[proxyName] if ok { res = toProxyStats(proxyName, proxyStats) } return } func (m *serverMetrics) GetProxyTraffic(name string) (res *ProxyTrafficInfo) { m.mu.Lock() defer m.mu.Unlock() proxyStats, ok := m.info.ProxyStatistics[name] if ok { res = &ProxyTrafficInfo{ Name: name, } res.TrafficIn = proxyStats.TrafficIn.GetLastDaysCount(ReserveDays) res.TrafficOut = proxyStats.TrafficOut.GetLastDaysCount(ReserveDays) } return } ================================================ FILE: pkg/metrics/mem/types.go ================================================ // Copyright 2017 fatedier, fatedier@gmail.com // // Licensed under the Apache License, Version 2.0 (the "License"); // you may not use this file except in compliance with the License. // You may obtain a copy of the License at // // http://www.apache.org/licenses/LICENSE-2.0 // // Unless required by applicable law or agreed to in writing, software // distributed under the License is distributed on an "AS IS" BASIS, // WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. // See the License for the specific language governing permissions and // limitations under the License. package mem import ( "time" "github.com/fatedier/frp/pkg/util/metric" ) const ( ReserveDays = 7 ) type ServerStats struct { TotalTrafficIn int64 TotalTrafficOut int64 CurConns int64 ClientCounts int64 ProxyTypeCounts map[string]int64 } type ProxyStats struct { Name string Type string User string ClientID string TodayTrafficIn int64 TodayTrafficOut int64 LastStartTime string LastCloseTime string CurConns int64 } type ProxyTrafficInfo struct { Name string TrafficIn []int64 TrafficOut []int64 } type ProxyStatistics struct { Name string ProxyType string User string ClientID string TrafficIn metric.DateCounter TrafficOut metric.DateCounter CurConns metric.Counter LastStartTime time.Time LastCloseTime time.Time } type ServerStatistics struct { TotalTrafficIn metric.DateCounter TotalTrafficOut metric.DateCounter CurConns metric.Counter // counter for clients ClientCounts metric.Counter // counter for proxy types ProxyTypeCounts map[string]metric.Counter // statistics for different proxies // key is proxy name ProxyStatistics map[string]*ProxyStatistics } type Collector interface { GetServer() *ServerStats GetProxiesByType(proxyType string) []*ProxyStats GetProxiesByTypeAndName(proxyType string, proxyName string) *ProxyStats GetProxyByName(proxyName string) *ProxyStats GetProxyTraffic(name string) *ProxyTrafficInfo ClearOfflineProxies() (int, int) } ================================================ FILE: pkg/metrics/metrics.go ================================================ // Copyright 2023 The frp Authors // // Licensed under the Apache License, Version 2.0 (the "License"); // you may not use this file except in compliance with the License. // You may obtain a copy of the License at // // http://www.apache.org/licenses/LICENSE-2.0 // // Unless required by applicable law or agreed to in writing, software // distributed under the License is distributed on an "AS IS" BASIS, // WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. // See the License for the specific language governing permissions and // limitations under the License. package metrics import ( "github.com/fatedier/frp/pkg/metrics/aggregate" ) var ( EnableMem = aggregate.EnableMem EnablePrometheus = aggregate.EnablePrometheus ) ================================================ FILE: pkg/metrics/prometheus/server.go ================================================ package prometheus import ( "github.com/prometheus/client_golang/prometheus" "github.com/fatedier/frp/server/metrics" ) const ( namespace = "frp" serverSubsystem = "server" ) var ServerMetrics metrics.ServerMetrics = newServerMetrics() type serverMetrics struct { clientCount prometheus.Gauge proxyCount *prometheus.GaugeVec proxyCountDetailed *prometheus.GaugeVec connectionCount *prometheus.GaugeVec trafficIn *prometheus.CounterVec trafficOut *prometheus.CounterVec } func (m *serverMetrics) NewClient() { m.clientCount.Inc() } func (m *serverMetrics) CloseClient() { m.clientCount.Dec() } func (m *serverMetrics) NewProxy(name string, proxyType string, _ string, _ string) { m.proxyCount.WithLabelValues(proxyType).Inc() m.proxyCountDetailed.WithLabelValues(proxyType, name).Inc() } func (m *serverMetrics) CloseProxy(name string, proxyType string) { m.proxyCount.WithLabelValues(proxyType).Dec() m.proxyCountDetailed.WithLabelValues(proxyType, name).Dec() } func (m *serverMetrics) OpenConnection(name string, proxyType string) { m.connectionCount.WithLabelValues(name, proxyType).Inc() } func (m *serverMetrics) CloseConnection(name string, proxyType string) { m.connectionCount.WithLabelValues(name, proxyType).Dec() } func (m *serverMetrics) AddTrafficIn(name string, proxyType string, trafficBytes int64) { m.trafficIn.WithLabelValues(name, proxyType).Add(float64(trafficBytes)) } func (m *serverMetrics) AddTrafficOut(name string, proxyType string, trafficBytes int64) { m.trafficOut.WithLabelValues(name, proxyType).Add(float64(trafficBytes)) } func newServerMetrics() *serverMetrics { m := &serverMetrics{ clientCount: prometheus.NewGauge(prometheus.GaugeOpts{ Namespace: namespace, Subsystem: serverSubsystem, Name: "client_counts", Help: "The current client counts of frps", }), proxyCount: prometheus.NewGaugeVec(prometheus.GaugeOpts{ Namespace: namespace, Subsystem: serverSubsystem, Name: "proxy_counts", Help: "The current proxy counts", }, []string{"type"}), proxyCountDetailed: prometheus.NewGaugeVec(prometheus.GaugeOpts{ Namespace: namespace, Subsystem: serverSubsystem, Name: "proxy_counts_detailed", Help: "The current number of proxies grouped by type and name", }, []string{"type", "name"}), connectionCount: prometheus.NewGaugeVec(prometheus.GaugeOpts{ Namespace: namespace, Subsystem: serverSubsystem, Name: "connection_counts", Help: "The current connection counts", }, []string{"name", "type"}), trafficIn: prometheus.NewCounterVec(prometheus.CounterOpts{ Namespace: namespace, Subsystem: serverSubsystem, Name: "traffic_in", Help: "The total in traffic", }, []string{"name", "type"}), trafficOut: prometheus.NewCounterVec(prometheus.CounterOpts{ Namespace: namespace, Subsystem: serverSubsystem, Name: "traffic_out", Help: "The total out traffic", }, []string{"name", "type"}), } prometheus.MustRegister(m.clientCount) prometheus.MustRegister(m.proxyCount) prometheus.MustRegister(m.proxyCountDetailed) prometheus.MustRegister(m.connectionCount) prometheus.MustRegister(m.trafficIn) prometheus.MustRegister(m.trafficOut) return m } ================================================ FILE: pkg/msg/ctl.go ================================================ // Copyright 2018 fatedier, fatedier@gmail.com // // Licensed under the Apache License, Version 2.0 (the "License"); // you may not use this file except in compliance with the License. // You may obtain a copy of the License at // // http://www.apache.org/licenses/LICENSE-2.0 // // Unless required by applicable law or agreed to in writing, software // distributed under the License is distributed on an "AS IS" BASIS, // WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. // See the License for the specific language governing permissions and // limitations under the License. package msg import ( "io" jsonMsg "github.com/fatedier/golib/msg/json" ) type Message = jsonMsg.Message var msgCtl *jsonMsg.MsgCtl func init() { msgCtl = jsonMsg.NewMsgCtl() for typeByte, msg := range msgTypeMap { msgCtl.RegisterMsg(typeByte, msg) } } func ReadMsg(c io.Reader) (msg Message, err error) { return msgCtl.ReadMsg(c) } func ReadMsgInto(c io.Reader, msg Message) (err error) { return msgCtl.ReadMsgInto(c, msg) } func WriteMsg(c io.Writer, msg any) (err error) { return msgCtl.WriteMsg(c, msg) } ================================================ FILE: pkg/msg/handler.go ================================================ // Copyright 2023 The frp Authors // // Licensed under the Apache License, Version 2.0 (the "License"); // you may not use this file except in compliance with the License. // You may obtain a copy of the License at // // http://www.apache.org/licenses/LICENSE-2.0 // // Unless required by applicable law or agreed to in writing, software // distributed under the License is distributed on an "AS IS" BASIS, // WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. // See the License for the specific language governing permissions and // limitations under the License. package msg import ( "io" "reflect" ) func AsyncHandler(f func(Message)) func(Message) { return func(m Message) { go f(m) } } // Dispatcher is used to send messages to net.Conn or register handlers for messages read from net.Conn. type Dispatcher struct { rw io.ReadWriter sendCh chan Message doneCh chan struct{} msgHandlers map[reflect.Type]func(Message) defaultHandler func(Message) } func NewDispatcher(rw io.ReadWriter) *Dispatcher { return &Dispatcher{ rw: rw, sendCh: make(chan Message, 100), doneCh: make(chan struct{}), msgHandlers: make(map[reflect.Type]func(Message)), } } // Run will block until io.EOF or some error occurs. func (d *Dispatcher) Run() { go d.sendLoop() go d.readLoop() } func (d *Dispatcher) sendLoop() { for { select { case <-d.doneCh: return case m := <-d.sendCh: _ = WriteMsg(d.rw, m) } } } func (d *Dispatcher) readLoop() { for { m, err := ReadMsg(d.rw) if err != nil { close(d.doneCh) return } if handler, ok := d.msgHandlers[reflect.TypeOf(m)]; ok { handler(m) } else if d.defaultHandler != nil { d.defaultHandler(m) } } } func (d *Dispatcher) Send(m Message) error { select { case <-d.doneCh: return io.EOF case d.sendCh <- m: return nil } } func (d *Dispatcher) RegisterHandler(msg Message, handler func(Message)) { d.msgHandlers[reflect.TypeOf(msg)] = handler } func (d *Dispatcher) RegisterDefaultHandler(handler func(Message)) { d.defaultHandler = handler } func (d *Dispatcher) Done() chan struct{} { return d.doneCh } ================================================ FILE: pkg/msg/msg.go ================================================ // Copyright 2016 fatedier, fatedier@gmail.com // // Licensed under the Apache License, Version 2.0 (the "License"); // you may not use this file except in compliance with the License. // You may obtain a copy of the License at // // http://www.apache.org/licenses/LICENSE-2.0 // // Unless required by applicable law or agreed to in writing, software // distributed under the License is distributed on an "AS IS" BASIS, // WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. // See the License for the specific language governing permissions and // limitations under the License. package msg import ( "net" "reflect" ) const ( TypeLogin = 'o' TypeLoginResp = '1' TypeNewProxy = 'p' TypeNewProxyResp = '2' TypeCloseProxy = 'c' TypeNewWorkConn = 'w' TypeReqWorkConn = 'r' TypeStartWorkConn = 's' TypeNewVisitorConn = 'v' TypeNewVisitorConnResp = '3' TypePing = 'h' TypePong = '4' TypeUDPPacket = 'u' TypeNatHoleVisitor = 'i' TypeNatHoleClient = 'n' TypeNatHoleResp = 'm' TypeNatHoleSid = '5' TypeNatHoleReport = '6' ) var msgTypeMap = map[byte]any{ TypeLogin: Login{}, TypeLoginResp: LoginResp{}, TypeNewProxy: NewProxy{}, TypeNewProxyResp: NewProxyResp{}, TypeCloseProxy: CloseProxy{}, TypeNewWorkConn: NewWorkConn{}, TypeReqWorkConn: ReqWorkConn{}, TypeStartWorkConn: StartWorkConn{}, TypeNewVisitorConn: NewVisitorConn{}, TypeNewVisitorConnResp: NewVisitorConnResp{}, TypePing: Ping{}, TypePong: Pong{}, TypeUDPPacket: UDPPacket{}, TypeNatHoleVisitor: NatHoleVisitor{}, TypeNatHoleClient: NatHoleClient{}, TypeNatHoleResp: NatHoleResp{}, TypeNatHoleSid: NatHoleSid{}, TypeNatHoleReport: NatHoleReport{}, } var TypeNameNatHoleResp = reflect.TypeFor[NatHoleResp]().Name() type ClientSpec struct { // Due to the support of VirtualClient, frps needs to know the client type in order to // differentiate the processing logic. // Optional values: ssh-tunnel Type string `json:"type,omitempty"` // If the value is true, the client will not require authentication. AlwaysAuthPass bool `json:"always_auth_pass,omitempty"` } // When frpc start, client send this message to login to server. type Login struct { Version string `json:"version,omitempty"` Hostname string `json:"hostname,omitempty"` Os string `json:"os,omitempty"` Arch string `json:"arch,omitempty"` User string `json:"user,omitempty"` PrivilegeKey string `json:"privilege_key,omitempty"` Timestamp int64 `json:"timestamp,omitempty"` RunID string `json:"run_id,omitempty"` ClientID string `json:"client_id,omitempty"` Metas map[string]string `json:"metas,omitempty"` // Currently only effective for VirtualClient. ClientSpec ClientSpec `json:"client_spec,omitempty"` // Some global configures. PoolCount int `json:"pool_count,omitempty"` } type LoginResp struct { Version string `json:"version,omitempty"` RunID string `json:"run_id,omitempty"` Error string `json:"error,omitempty"` } // When frpc login success, send this message to frps for running a new proxy. type NewProxy struct { ProxyName string `json:"proxy_name,omitempty"` ProxyType string `json:"proxy_type,omitempty"` UseEncryption bool `json:"use_encryption,omitempty"` UseCompression bool `json:"use_compression,omitempty"` BandwidthLimit string `json:"bandwidth_limit,omitempty"` BandwidthLimitMode string `json:"bandwidth_limit_mode,omitempty"` Group string `json:"group,omitempty"` GroupKey string `json:"group_key,omitempty"` Metas map[string]string `json:"metas,omitempty"` Annotations map[string]string `json:"annotations,omitempty"` // tcp and udp only RemotePort int `json:"remote_port,omitempty"` // http and https only CustomDomains []string `json:"custom_domains,omitempty"` SubDomain string `json:"subdomain,omitempty"` Locations []string `json:"locations,omitempty"` HTTPUser string `json:"http_user,omitempty"` HTTPPwd string `json:"http_pwd,omitempty"` HostHeaderRewrite string `json:"host_header_rewrite,omitempty"` Headers map[string]string `json:"headers,omitempty"` ResponseHeaders map[string]string `json:"response_headers,omitempty"` RouteByHTTPUser string `json:"route_by_http_user,omitempty"` // stcp, sudp, xtcp Sk string `json:"sk,omitempty"` AllowUsers []string `json:"allow_users,omitempty"` // tcpmux Multiplexer string `json:"multiplexer,omitempty"` } type NewProxyResp struct { ProxyName string `json:"proxy_name,omitempty"` RemoteAddr string `json:"remote_addr,omitempty"` Error string `json:"error,omitempty"` } type CloseProxy struct { ProxyName string `json:"proxy_name,omitempty"` } type NewWorkConn struct { RunID string `json:"run_id,omitempty"` PrivilegeKey string `json:"privilege_key,omitempty"` Timestamp int64 `json:"timestamp,omitempty"` } type ReqWorkConn struct{} type StartWorkConn struct { ProxyName string `json:"proxy_name,omitempty"` SrcAddr string `json:"src_addr,omitempty"` DstAddr string `json:"dst_addr,omitempty"` SrcPort uint16 `json:"src_port,omitempty"` DstPort uint16 `json:"dst_port,omitempty"` Error string `json:"error,omitempty"` } type NewVisitorConn struct { RunID string `json:"run_id,omitempty"` ProxyName string `json:"proxy_name,omitempty"` SignKey string `json:"sign_key,omitempty"` Timestamp int64 `json:"timestamp,omitempty"` UseEncryption bool `json:"use_encryption,omitempty"` UseCompression bool `json:"use_compression,omitempty"` } type NewVisitorConnResp struct { ProxyName string `json:"proxy_name,omitempty"` Error string `json:"error,omitempty"` } type Ping struct { PrivilegeKey string `json:"privilege_key,omitempty"` Timestamp int64 `json:"timestamp,omitempty"` } type Pong struct { Error string `json:"error,omitempty"` } type UDPPacket struct { Content []byte `json:"c,omitempty"` LocalAddr *net.UDPAddr `json:"l,omitempty"` RemoteAddr *net.UDPAddr `json:"r,omitempty"` } type NatHoleVisitor struct { TransactionID string `json:"transaction_id,omitempty"` ProxyName string `json:"proxy_name,omitempty"` PreCheck bool `json:"pre_check,omitempty"` Protocol string `json:"protocol,omitempty"` SignKey string `json:"sign_key,omitempty"` Timestamp int64 `json:"timestamp,omitempty"` MappedAddrs []string `json:"mapped_addrs,omitempty"` AssistedAddrs []string `json:"assisted_addrs,omitempty"` } type NatHoleClient struct { TransactionID string `json:"transaction_id,omitempty"` ProxyName string `json:"proxy_name,omitempty"` Sid string `json:"sid,omitempty"` MappedAddrs []string `json:"mapped_addrs,omitempty"` AssistedAddrs []string `json:"assisted_addrs,omitempty"` } type PortsRange struct { From int `json:"from,omitempty"` To int `json:"to,omitempty"` } type NatHoleDetectBehavior struct { Role string `json:"role,omitempty"` // sender or receiver Mode int `json:"mode,omitempty"` // 0, 1, 2... TTL int `json:"ttl,omitempty"` SendDelayMs int `json:"send_delay_ms,omitempty"` ReadTimeoutMs int `json:"read_timeout,omitempty"` CandidatePorts []PortsRange `json:"candidate_ports,omitempty"` SendRandomPorts int `json:"send_random_ports,omitempty"` ListenRandomPorts int `json:"listen_random_ports,omitempty"` } type NatHoleResp struct { TransactionID string `json:"transaction_id,omitempty"` Sid string `json:"sid,omitempty"` Protocol string `json:"protocol,omitempty"` CandidateAddrs []string `json:"candidate_addrs,omitempty"` AssistedAddrs []string `json:"assisted_addrs,omitempty"` DetectBehavior NatHoleDetectBehavior `json:"detect_behavior,omitempty"` Error string `json:"error,omitempty"` } type NatHoleSid struct { TransactionID string `json:"transaction_id,omitempty"` Sid string `json:"sid,omitempty"` Response bool `json:"response,omitempty"` Nonce string `json:"nonce,omitempty"` } type NatHoleReport struct { Sid string `json:"sid,omitempty"` Success bool `json:"success,omitempty"` } ================================================ FILE: pkg/naming/names.go ================================================ package naming import "strings" // AddUserPrefix builds the wire-level proxy name for frps by prefixing user. func AddUserPrefix(user, name string) string { if user == "" { return name } return user + "." + name } // StripUserPrefix converts a wire-level proxy name to an internal raw name. // It strips only one exact "{user}." prefix. func StripUserPrefix(user, name string) string { if user == "" { return name } if trimmed, ok := strings.CutPrefix(name, user+"."); ok { return trimmed } return name } // BuildTargetServerProxyName resolves visitor target proxy name for wire-level // protocol messages. serverUser overrides local user when set. func BuildTargetServerProxyName(localUser, serverUser, serverName string) string { if serverUser != "" { return AddUserPrefix(serverUser, serverName) } return AddUserPrefix(localUser, serverName) } ================================================ FILE: pkg/naming/names_test.go ================================================ package naming import ( "testing" "github.com/stretchr/testify/require" ) func TestAddUserPrefix(t *testing.T) { require := require.New(t) require.Equal("test", AddUserPrefix("", "test")) require.Equal("alice.test", AddUserPrefix("alice", "test")) } func TestStripUserPrefix(t *testing.T) { require := require.New(t) require.Equal("test", StripUserPrefix("", "test")) require.Equal("test", StripUserPrefix("alice", "alice.test")) require.Equal("alice.test", StripUserPrefix("alice", "alice.alice.test")) require.Equal("bob.test", StripUserPrefix("alice", "bob.test")) } func TestBuildTargetServerProxyName(t *testing.T) { require := require.New(t) require.Equal("alice.test", BuildTargetServerProxyName("alice", "", "test")) require.Equal("bob.test", BuildTargetServerProxyName("alice", "bob", "test")) } ================================================ FILE: pkg/nathole/analysis.go ================================================ // Copyright 2023 The frp Authors // // Licensed under the Apache License, Version 2.0 (the "License"); // you may not use this file except in compliance with the License. // You may obtain a copy of the License at // // http://www.apache.org/licenses/LICENSE-2.0 // // Unless required by applicable law or agreed to in writing, software // distributed under the License is distributed on an "AS IS" BASIS, // WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. // See the License for the specific language governing permissions and // limitations under the License. package nathole import ( "cmp" "slices" "sync" "time" "github.com/samber/lo" ) var ( // mode 0, both EasyNAT, PublicNetwork is always receiver // sender | receiver, ttl 7 // receiver, ttl 7 | sender // sender | receiver, ttl 4 // receiver, ttl 4 | sender // sender | receiver // receiver | sender // sender, sendDelayMs 5000 | receiver // sender, sendDelayMs 10000 | receiver // receiver | sender, sendDelayMs 5000 // receiver | sender, sendDelayMs 10000 mode0Behaviors = []lo.Tuple2[RecommandBehavior, RecommandBehavior]{ lo.T2(RecommandBehavior{Role: DetectRoleSender}, RecommandBehavior{Role: DetectRoleReceiver, TTL: 7}), lo.T2(RecommandBehavior{Role: DetectRoleReceiver, TTL: 7}, RecommandBehavior{Role: DetectRoleSender}), lo.T2(RecommandBehavior{Role: DetectRoleSender}, RecommandBehavior{Role: DetectRoleReceiver, TTL: 4}), lo.T2(RecommandBehavior{Role: DetectRoleReceiver, TTL: 4}, RecommandBehavior{Role: DetectRoleSender}), lo.T2(RecommandBehavior{Role: DetectRoleSender}, RecommandBehavior{Role: DetectRoleReceiver}), lo.T2(RecommandBehavior{Role: DetectRoleReceiver}, RecommandBehavior{Role: DetectRoleSender}), lo.T2(RecommandBehavior{Role: DetectRoleSender, SendDelayMs: 5000}, RecommandBehavior{Role: DetectRoleReceiver}), lo.T2(RecommandBehavior{Role: DetectRoleSender, SendDelayMs: 10000}, RecommandBehavior{Role: DetectRoleReceiver}), lo.T2(RecommandBehavior{Role: DetectRoleReceiver}, RecommandBehavior{Role: DetectRoleSender, SendDelayMs: 5000}), lo.T2(RecommandBehavior{Role: DetectRoleReceiver}, RecommandBehavior{Role: DetectRoleSender, SendDelayMs: 10000}), } // mode 1, HardNAT is sender, EasyNAT is receiver, port changes is regular // sender | receiver, ttl 7, portsRangeNumber max 10 // sender, sendDelayMs 2000 | receiver, ttl 7, portsRangeNumber max 10 // sender | receiver, ttl 4, portsRangeNumber max 10 // sender, sendDelayMs 2000 | receiver, ttl 4, portsRangeNumber max 10 // sender | receiver, portsRangeNumber max 10 // sender, sendDelayMs 2000 | receiver, portsRangeNumber max 10 mode1Behaviors = []lo.Tuple2[RecommandBehavior, RecommandBehavior]{ lo.T2(RecommandBehavior{Role: DetectRoleSender}, RecommandBehavior{Role: DetectRoleReceiver, TTL: 7, PortsRangeNumber: 10}), lo.T2(RecommandBehavior{Role: DetectRoleSender, SendDelayMs: 2000}, RecommandBehavior{Role: DetectRoleReceiver, TTL: 7, PortsRangeNumber: 10}), lo.T2(RecommandBehavior{Role: DetectRoleSender}, RecommandBehavior{Role: DetectRoleReceiver, TTL: 4, PortsRangeNumber: 10}), lo.T2(RecommandBehavior{Role: DetectRoleSender, SendDelayMs: 2000}, RecommandBehavior{Role: DetectRoleReceiver, TTL: 4, PortsRangeNumber: 10}), lo.T2(RecommandBehavior{Role: DetectRoleSender}, RecommandBehavior{Role: DetectRoleReceiver, PortsRangeNumber: 10}), lo.T2(RecommandBehavior{Role: DetectRoleSender, SendDelayMs: 2000}, RecommandBehavior{Role: DetectRoleReceiver, PortsRangeNumber: 10}), } // mode 2, HardNAT is receiver, EasyNAT is sender // sender, portsRandomNumber 1000, sendDelayMs 3000 | receiver, listen 256 ports, ttl 7 // sender, portsRandomNumber 1000, sendDelayMs 3000 | receiver, listen 256 ports, ttl 4 // sender, portsRandomNumber 1000, sendDelayMs 3000 | receiver, listen 256 ports mode2Behaviors = []lo.Tuple2[RecommandBehavior, RecommandBehavior]{ lo.T2( RecommandBehavior{Role: DetectRoleSender, PortsRandomNumber: 1000, SendDelayMs: 3000}, RecommandBehavior{Role: DetectRoleReceiver, ListenRandomPorts: 256, TTL: 7}, ), lo.T2( RecommandBehavior{Role: DetectRoleSender, PortsRandomNumber: 1000, SendDelayMs: 3000}, RecommandBehavior{Role: DetectRoleReceiver, ListenRandomPorts: 256, TTL: 4}, ), lo.T2( RecommandBehavior{Role: DetectRoleSender, PortsRandomNumber: 1000, SendDelayMs: 3000}, RecommandBehavior{Role: DetectRoleReceiver, ListenRandomPorts: 256}, ), } // mode 3, For HardNAT & HardNAT, both changes in the ports are regular // sender, portsRangeNumber 10 | receiver, ttl 7, portsRangeNumber 10 // sender, portsRangeNumber 10 | receiver, ttl 4, portsRangeNumber 10 // sender, portsRangeNumber 10 | receiver, portsRangeNumber 10 // receiver, ttl 7, portsRangeNumber 10 | sender, portsRangeNumber 10 // receiver, ttl 4, portsRangeNumber 10 | sender, portsRangeNumber 10 // receiver, portsRangeNumber 10 | sender, portsRangeNumber 10 mode3Behaviors = []lo.Tuple2[RecommandBehavior, RecommandBehavior]{ lo.T2(RecommandBehavior{Role: DetectRoleSender, PortsRangeNumber: 10}, RecommandBehavior{Role: DetectRoleReceiver, TTL: 7, PortsRangeNumber: 10}), lo.T2(RecommandBehavior{Role: DetectRoleSender, PortsRangeNumber: 10}, RecommandBehavior{Role: DetectRoleReceiver, TTL: 4, PortsRangeNumber: 10}), lo.T2(RecommandBehavior{Role: DetectRoleSender, PortsRangeNumber: 10}, RecommandBehavior{Role: DetectRoleReceiver, PortsRangeNumber: 10}), lo.T2(RecommandBehavior{Role: DetectRoleReceiver, TTL: 7, PortsRangeNumber: 10}, RecommandBehavior{Role: DetectRoleSender, PortsRangeNumber: 10}), lo.T2(RecommandBehavior{Role: DetectRoleReceiver, TTL: 4, PortsRangeNumber: 10}, RecommandBehavior{Role: DetectRoleSender, PortsRangeNumber: 10}), lo.T2(RecommandBehavior{Role: DetectRoleReceiver, PortsRangeNumber: 10}, RecommandBehavior{Role: DetectRoleSender, PortsRangeNumber: 10}), } // mode 4, Regular ports changes are usually the sender. // sender, portsRandomNumber 1000, sendDelayMs: 2000 | receiver, listen 256 ports, ttl 7, portsRangeNumber 2 // sender, portsRandomNumber 1000, sendDelayMs: 2000 | receiver, listen 256 ports, ttl 4, portsRangeNumber 2 // sender, portsRandomNumber 1000, SendDelayMs: 2000 | receiver, listen 256 ports, portsRangeNumber 2 mode4Behaviors = []lo.Tuple2[RecommandBehavior, RecommandBehavior]{ lo.T2( RecommandBehavior{Role: DetectRoleSender, PortsRandomNumber: 1000, SendDelayMs: 3000}, RecommandBehavior{Role: DetectRoleReceiver, ListenRandomPorts: 256, TTL: 7, PortsRangeNumber: 2}, ), lo.T2( RecommandBehavior{Role: DetectRoleSender, PortsRandomNumber: 1000, SendDelayMs: 3000}, RecommandBehavior{Role: DetectRoleReceiver, ListenRandomPorts: 256, TTL: 4, PortsRangeNumber: 2}, ), lo.T2( RecommandBehavior{Role: DetectRoleSender, PortsRandomNumber: 1000, SendDelayMs: 3000}, RecommandBehavior{Role: DetectRoleReceiver, ListenRandomPorts: 256, PortsRangeNumber: 2}, ), } ) func getBehaviorByMode(mode int) []lo.Tuple2[RecommandBehavior, RecommandBehavior] { switch mode { case 0: return mode0Behaviors case 1: return mode1Behaviors case 2: return mode2Behaviors case 3: return mode3Behaviors case 4: return mode4Behaviors } // default return mode0Behaviors } func getBehaviorByModeAndIndex(mode int, index int) (RecommandBehavior, RecommandBehavior) { behaviors := getBehaviorByMode(mode) if index >= len(behaviors) { return RecommandBehavior{}, RecommandBehavior{} } return behaviors[index].A, behaviors[index].B } func getBehaviorScoresByMode(mode int, defaultScore int) []*BehaviorScore { return getBehaviorScoresByMode2(mode, defaultScore, defaultScore) } func getBehaviorScoresByMode2(mode int, senderScore, receiverScore int) []*BehaviorScore { behaviors := getBehaviorByMode(mode) scores := make([]*BehaviorScore, 0, len(behaviors)) for i := range behaviors { score := receiverScore if behaviors[i].A.Role == DetectRoleSender { score = senderScore } scores = append(scores, &BehaviorScore{Mode: mode, Index: i, Score: score}) } return scores } type RecommandBehavior struct { Role string TTL int SendDelayMs int PortsRangeNumber int PortsRandomNumber int ListenRandomPorts int } type MakeHoleRecords struct { mu sync.Mutex scores []*BehaviorScore LastUpdateTime time.Time } func NewMakeHoleRecords(c, v *NatFeature) *MakeHoleRecords { scores := []*BehaviorScore{} easyCount, hardCount, portsChangedRegularCount := ClassifyFeatureCount([]*NatFeature{c, v}) appendMode0 := func() { switch { case c.PublicNetwork: scores = append(scores, getBehaviorScoresByMode2(DetectMode0, 0, 1)...) case v.PublicNetwork: scores = append(scores, getBehaviorScoresByMode2(DetectMode0, 1, 0)...) default: scores = append(scores, getBehaviorScoresByMode(DetectMode0, 0)...) } } switch { case easyCount == 2: appendMode0() case hardCount == 1 && portsChangedRegularCount == 1: scores = append(scores, getBehaviorScoresByMode(DetectMode1, 0)...) scores = append(scores, getBehaviorScoresByMode(DetectMode2, 0)...) appendMode0() case hardCount == 1 && portsChangedRegularCount == 0: scores = append(scores, getBehaviorScoresByMode(DetectMode2, 0)...) scores = append(scores, getBehaviorScoresByMode(DetectMode1, 0)...) appendMode0() case hardCount == 2 && portsChangedRegularCount == 2: scores = append(scores, getBehaviorScoresByMode(DetectMode3, 0)...) scores = append(scores, getBehaviorScoresByMode(DetectMode4, 0)...) case hardCount == 2 && portsChangedRegularCount == 1: scores = append(scores, getBehaviorScoresByMode(DetectMode4, 0)...) default: // hard to make hole, just trying it out. scores = append(scores, getBehaviorScoresByMode(DetectMode0, 1)...) scores = append(scores, getBehaviorScoresByMode(DetectMode1, 1)...) scores = append(scores, getBehaviorScoresByMode(DetectMode3, 1)...) } return &MakeHoleRecords{scores: scores, LastUpdateTime: time.Now()} } func (mhr *MakeHoleRecords) ReportSuccess(mode int, index int) { mhr.mu.Lock() defer mhr.mu.Unlock() mhr.LastUpdateTime = time.Now() for i := range mhr.scores { score := mhr.scores[i] if score.Mode != mode || score.Index != index { continue } score.Score += 2 score.Score = min(score.Score, 10) return } } func (mhr *MakeHoleRecords) Recommand() (mode, index int) { mhr.mu.Lock() defer mhr.mu.Unlock() if len(mhr.scores) == 0 { return 0, 0 } maxScore := slices.MaxFunc(mhr.scores, func(a, b *BehaviorScore) int { return cmp.Compare(a.Score, b.Score) }) maxScore.Score-- mhr.LastUpdateTime = time.Now() return maxScore.Mode, maxScore.Index } type BehaviorScore struct { Mode int Index int // between -10 and 10 Score int } type Analyzer struct { // key is client ip + visitor ip records map[string]*MakeHoleRecords dataReserveDuration time.Duration mu sync.Mutex } func NewAnalyzer(dataReserveDuration time.Duration) *Analyzer { return &Analyzer{ records: make(map[string]*MakeHoleRecords), dataReserveDuration: dataReserveDuration, } } func (a *Analyzer) GetRecommandBehaviors(key string, c, v *NatFeature) (mode, index int, _ RecommandBehavior, _ RecommandBehavior) { a.mu.Lock() records, ok := a.records[key] if !ok { records = NewMakeHoleRecords(c, v) a.records[key] = records } a.mu.Unlock() mode, index = records.Recommand() cBehavior, vBehavior := getBehaviorByModeAndIndex(mode, index) switch mode { case DetectMode1: // HardNAT is always the sender if c.NatType == EasyNAT { cBehavior, vBehavior = vBehavior, cBehavior } case DetectMode2: // HardNAT is always the receiver if c.NatType == HardNAT { cBehavior, vBehavior = vBehavior, cBehavior } case DetectMode4: // Regular ports changes is always the sender if !c.RegularPortsChange { cBehavior, vBehavior = vBehavior, cBehavior } } return mode, index, cBehavior, vBehavior } func (a *Analyzer) ReportSuccess(key string, mode, index int) { a.mu.Lock() records, ok := a.records[key] a.mu.Unlock() if !ok { return } records.ReportSuccess(mode, index) } func (a *Analyzer) Clean() (int, int) { now := time.Now() total := 0 count := 0 // cleanup 10w records may take 5ms a.mu.Lock() defer a.mu.Unlock() total = len(a.records) // clean up records that have not been used for a period of time. for key, records := range a.records { if now.Sub(records.LastUpdateTime) > a.dataReserveDuration { delete(a.records, key) count++ } } return count, total } ================================================ FILE: pkg/nathole/classify.go ================================================ // Copyright 2023 The frp Authors // // Licensed under the Apache License, Version 2.0 (the "License"); // you may not use this file except in compliance with the License. // You may obtain a copy of the License at // // http://www.apache.org/licenses/LICENSE-2.0 // // Unless required by applicable law or agreed to in writing, software // distributed under the License is distributed on an "AS IS" BASIS, // WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. // See the License for the specific language governing permissions and // limitations under the License. package nathole import ( "fmt" "net" "slices" "strconv" ) const ( EasyNAT = "EasyNAT" HardNAT = "HardNAT" BehaviorNoChange = "BehaviorNoChange" BehaviorIPChanged = "BehaviorIPChanged" BehaviorPortChanged = "BehaviorPortChanged" BehaviorBothChanged = "BehaviorBothChanged" ) type NatFeature struct { NatType string Behavior string PortsDifference int RegularPortsChange bool PublicNetwork bool } func ClassifyNATFeature(addresses []string, localIPs []string) (*NatFeature, error) { if len(addresses) <= 1 { return nil, fmt.Errorf("not enough addresses") } natFeature := &NatFeature{} ipChanged := false portChanged := false var baseIP, basePort string var portMax, portMin int for _, addr := range addresses { ip, port, err := net.SplitHostPort(addr) if err != nil { return nil, err } portNum, err := strconv.Atoi(port) if err != nil { return nil, err } if slices.Contains(localIPs, ip) { natFeature.PublicNetwork = true } if baseIP == "" { baseIP = ip basePort = port portMax = portNum portMin = portNum continue } portMax = max(portMax, portNum) portMin = min(portMin, portNum) if baseIP != ip { ipChanged = true } if basePort != port { portChanged = true } } switch { case ipChanged && portChanged: natFeature.NatType = HardNAT natFeature.Behavior = BehaviorBothChanged case ipChanged: natFeature.NatType = HardNAT natFeature.Behavior = BehaviorIPChanged case portChanged: natFeature.NatType = HardNAT natFeature.Behavior = BehaviorPortChanged default: natFeature.NatType = EasyNAT natFeature.Behavior = BehaviorNoChange } if natFeature.Behavior == BehaviorPortChanged { natFeature.PortsDifference = portMax - portMin if natFeature.PortsDifference <= 5 && natFeature.PortsDifference >= 1 { natFeature.RegularPortsChange = true } } return natFeature, nil } func ClassifyFeatureCount(features []*NatFeature) (int, int, int) { easyCount := 0 hardCount := 0 // for HardNAT portsChangedRegularCount := 0 for _, feature := range features { if feature.NatType == EasyNAT { easyCount++ continue } hardCount++ if feature.RegularPortsChange { portsChangedRegularCount++ } } return easyCount, hardCount, portsChangedRegularCount } ================================================ FILE: pkg/nathole/controller.go ================================================ // Copyright 2023 The frp Authors // // Licensed under the Apache License, Version 2.0 (the "License"); // you may not use this file except in compliance with the License. // You may obtain a copy of the License at // // http://www.apache.org/licenses/LICENSE-2.0 // // Unless required by applicable law or agreed to in writing, software // distributed under the License is distributed on an "AS IS" BASIS, // WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. // See the License for the specific language governing permissions and // limitations under the License. package nathole import ( "context" "crypto/md5" "encoding/hex" "fmt" "net" "slices" "strconv" "sync" "time" "github.com/fatedier/golib/errors" "github.com/samber/lo" "golang.org/x/sync/errgroup" "github.com/fatedier/frp/pkg/msg" "github.com/fatedier/frp/pkg/transport" "github.com/fatedier/frp/pkg/util/log" "github.com/fatedier/frp/pkg/util/util" ) // NatHoleTimeout seconds. var NatHoleTimeout int64 = 10 func NewTransactionID() string { id, _ := util.RandID() return fmt.Sprintf("%d%s", time.Now().Unix(), id) } type ClientCfg struct { name string sk string allowUsers []string sidCh chan string } type Session struct { sid string analysisKey string recommandMode int recommandIndex int visitorMsg *msg.NatHoleVisitor visitorTransporter transport.MessageTransporter vResp *msg.NatHoleResp vNatFeature *NatFeature vBehavior RecommandBehavior clientMsg *msg.NatHoleClient clientTransporter transport.MessageTransporter cResp *msg.NatHoleResp cNatFeature *NatFeature cBehavior RecommandBehavior notifyCh chan struct{} } func (s *Session) genAnalysisKey() { hash := md5.New() vIPs := slices.Compact(parseIPs(s.visitorMsg.MappedAddrs)) if len(vIPs) > 0 { hash.Write([]byte(vIPs[0])) } hash.Write([]byte(s.vNatFeature.NatType)) hash.Write([]byte(s.vNatFeature.Behavior)) hash.Write([]byte(strconv.FormatBool(s.vNatFeature.RegularPortsChange))) cIPs := slices.Compact(parseIPs(s.clientMsg.MappedAddrs)) if len(cIPs) > 0 { hash.Write([]byte(cIPs[0])) } hash.Write([]byte(s.cNatFeature.NatType)) hash.Write([]byte(s.cNatFeature.Behavior)) hash.Write([]byte(strconv.FormatBool(s.cNatFeature.RegularPortsChange))) s.analysisKey = hex.EncodeToString(hash.Sum(nil)) } type Controller struct { clientCfgs map[string]*ClientCfg sessions map[string]*Session analyzer *Analyzer mu sync.RWMutex } func NewController(analysisDataReserveDuration time.Duration) (*Controller, error) { return &Controller{ clientCfgs: make(map[string]*ClientCfg), sessions: make(map[string]*Session), analyzer: NewAnalyzer(analysisDataReserveDuration), }, nil } func (c *Controller) CleanWorker(ctx context.Context) { ticker := time.NewTicker(time.Hour) defer ticker.Stop() for { select { case <-ticker.C: start := time.Now() count, total := c.analyzer.Clean() log.Tracef("clean %d/%d nathole analysis data, cost %v", count, total, time.Since(start)) case <-ctx.Done(): return } } } func (c *Controller) ListenClient(name string, sk string, allowUsers []string) (chan string, error) { cfg := &ClientCfg{ name: name, sk: sk, allowUsers: allowUsers, sidCh: make(chan string), } c.mu.Lock() defer c.mu.Unlock() if _, ok := c.clientCfgs[name]; ok { return nil, fmt.Errorf("proxy [%s] is repeated", name) } c.clientCfgs[name] = cfg return cfg.sidCh, nil } func (c *Controller) CloseClient(name string) { c.mu.Lock() defer c.mu.Unlock() delete(c.clientCfgs, name) } func (c *Controller) GenSid() string { t := time.Now().Unix() id, _ := util.RandID() return fmt.Sprintf("%d%s", t, id) } func (c *Controller) HandleVisitor(m *msg.NatHoleVisitor, transporter transport.MessageTransporter, visitorUser string) { if m.PreCheck { c.mu.RLock() cfg, ok := c.clientCfgs[m.ProxyName] c.mu.RUnlock() if !ok { _ = transporter.Send(c.GenNatHoleResponse(m.TransactionID, nil, fmt.Sprintf("xtcp server for [%s] doesn't exist", m.ProxyName))) return } if !slices.Contains(cfg.allowUsers, visitorUser) && !slices.Contains(cfg.allowUsers, "*") { _ = transporter.Send(c.GenNatHoleResponse(m.TransactionID, nil, fmt.Sprintf("xtcp visitor user [%s] not allowed for [%s]", visitorUser, m.ProxyName))) return } _ = transporter.Send(c.GenNatHoleResponse(m.TransactionID, nil, "")) return } sid := c.GenSid() session := &Session{ sid: sid, visitorMsg: m, visitorTransporter: transporter, notifyCh: make(chan struct{}, 1), } var ( clientCfg *ClientCfg ok bool ) err := func() error { c.mu.Lock() defer c.mu.Unlock() clientCfg, ok = c.clientCfgs[m.ProxyName] if !ok { return fmt.Errorf("xtcp server for [%s] doesn't exist", m.ProxyName) } if !util.ConstantTimeEqString(m.SignKey, util.GetAuthKey(clientCfg.sk, m.Timestamp)) { return fmt.Errorf("xtcp connection of [%s] auth failed", m.ProxyName) } c.sessions[sid] = session return nil }() if err != nil { log.Warnf("handle visitorMsg error: %v", err) _ = transporter.Send(c.GenNatHoleResponse(m.TransactionID, nil, err.Error())) return } log.Tracef("handle visitor message, sid [%s], server name: %s", sid, m.ProxyName) defer func() { c.mu.Lock() defer c.mu.Unlock() delete(c.sessions, sid) }() if err := errors.PanicToError(func() { clientCfg.sidCh <- sid }); err != nil { return } // wait for NatHoleClient message select { case <-session.notifyCh: case <-time.After(time.Duration(NatHoleTimeout) * time.Second): log.Debugf("wait for NatHoleClient message timeout, sid [%s]", sid) return } // Make hole-punching decisions based on the NAT information of the client and visitor. vResp, cResp, err := c.analysis(session) if err != nil { log.Debugf("sid [%s] analysis error: %v", err) vResp = c.GenNatHoleResponse(session.visitorMsg.TransactionID, nil, err.Error()) cResp = c.GenNatHoleResponse(session.clientMsg.TransactionID, nil, err.Error()) } session.cResp = cResp session.vResp = vResp // send response to visitor and client var g errgroup.Group g.Go(func() error { // if it's sender, wait for a while to make sure the client has send the detect messages if vResp.DetectBehavior.Role == "sender" { time.Sleep(1 * time.Second) } _ = session.visitorTransporter.Send(vResp) return nil }) g.Go(func() error { // if it's sender, wait for a while to make sure the client has send the detect messages if cResp.DetectBehavior.Role == "sender" { time.Sleep(1 * time.Second) } _ = session.clientTransporter.Send(cResp) return nil }) _ = g.Wait() time.Sleep(time.Duration(cResp.DetectBehavior.ReadTimeoutMs+30000) * time.Millisecond) } func (c *Controller) HandleClient(m *msg.NatHoleClient, transporter transport.MessageTransporter) { c.mu.RLock() session, ok := c.sessions[m.Sid] c.mu.RUnlock() if !ok { return } log.Tracef("handle client message, sid [%s], server name: %s", session.sid, m.ProxyName) session.clientMsg = m session.clientTransporter = transporter select { case session.notifyCh <- struct{}{}: default: } } func (c *Controller) HandleReport(m *msg.NatHoleReport) { c.mu.RLock() session, ok := c.sessions[m.Sid] c.mu.RUnlock() if !ok { log.Tracef("sid [%s] report make hole success: %v, but session not found", m.Sid, m.Success) return } if m.Success { c.analyzer.ReportSuccess(session.analysisKey, session.recommandMode, session.recommandIndex) } log.Infof("sid [%s] report make hole success: %v, mode %v, index %v", m.Sid, m.Success, session.recommandMode, session.recommandIndex) } func (c *Controller) GenNatHoleResponse(transactionID string, session *Session, errInfo string) *msg.NatHoleResp { var sid string if session != nil { sid = session.sid } return &msg.NatHoleResp{ TransactionID: transactionID, Sid: sid, Error: errInfo, } } // analysis analyzes the NAT type and behavior of the visitor and client, then makes hole-punching decisions. // return the response to the visitor and client. func (c *Controller) analysis(session *Session) (*msg.NatHoleResp, *msg.NatHoleResp, error) { cm := session.clientMsg vm := session.visitorMsg cNatFeature, err := ClassifyNATFeature(cm.MappedAddrs, parseIPs(cm.AssistedAddrs)) if err != nil { return nil, nil, fmt.Errorf("classify client nat feature error: %v", err) } vNatFeature, err := ClassifyNATFeature(vm.MappedAddrs, parseIPs(vm.AssistedAddrs)) if err != nil { return nil, nil, fmt.Errorf("classify visitor nat feature error: %v", err) } session.cNatFeature = cNatFeature session.vNatFeature = vNatFeature session.genAnalysisKey() mode, index, cBehavior, vBehavior := c.analyzer.GetRecommandBehaviors(session.analysisKey, cNatFeature, vNatFeature) session.recommandMode = mode session.recommandIndex = index session.cBehavior = cBehavior session.vBehavior = vBehavior timeoutMs := max(cBehavior.SendDelayMs, vBehavior.SendDelayMs) + 5000 if cBehavior.ListenRandomPorts > 0 || vBehavior.ListenRandomPorts > 0 { timeoutMs += 30000 } protocol := vm.Protocol vResp := &msg.NatHoleResp{ TransactionID: vm.TransactionID, Sid: session.sid, Protocol: protocol, CandidateAddrs: slices.Compact(cm.MappedAddrs), AssistedAddrs: slices.Compact(cm.AssistedAddrs), DetectBehavior: msg.NatHoleDetectBehavior{ Mode: mode, Role: vBehavior.Role, TTL: vBehavior.TTL, SendDelayMs: vBehavior.SendDelayMs, ReadTimeoutMs: timeoutMs - vBehavior.SendDelayMs, SendRandomPorts: vBehavior.PortsRandomNumber, ListenRandomPorts: vBehavior.ListenRandomPorts, CandidatePorts: getRangePorts(cm.MappedAddrs, cNatFeature.PortsDifference, vBehavior.PortsRangeNumber), }, } cResp := &msg.NatHoleResp{ TransactionID: cm.TransactionID, Sid: session.sid, Protocol: protocol, CandidateAddrs: slices.Compact(vm.MappedAddrs), AssistedAddrs: slices.Compact(vm.AssistedAddrs), DetectBehavior: msg.NatHoleDetectBehavior{ Mode: mode, Role: cBehavior.Role, TTL: cBehavior.TTL, SendDelayMs: cBehavior.SendDelayMs, ReadTimeoutMs: timeoutMs - cBehavior.SendDelayMs, SendRandomPorts: cBehavior.PortsRandomNumber, ListenRandomPorts: cBehavior.ListenRandomPorts, CandidatePorts: getRangePorts(vm.MappedAddrs, vNatFeature.PortsDifference, cBehavior.PortsRangeNumber), }, } log.Debugf("sid [%s] visitor nat: %+v, candidateAddrs: %v; client nat: %+v, candidateAddrs: %v, protocol: %s", session.sid, *vNatFeature, vm.MappedAddrs, *cNatFeature, cm.MappedAddrs, protocol) log.Debugf("sid [%s] visitor detect behavior: %+v", session.sid, vResp.DetectBehavior) log.Debugf("sid [%s] client detect behavior: %+v", session.sid, cResp.DetectBehavior) return vResp, cResp, nil } func getRangePorts(addrs []string, difference, maxNumber int) []msg.PortsRange { if maxNumber <= 0 { return nil } addr, isLast := lo.Last(addrs) if !isLast { return nil } ports := make([]msg.PortsRange, 0, 1) _, portStr, err := net.SplitHostPort(addr) if err != nil { return nil } port, err := strconv.Atoi(portStr) if err != nil { return nil } ports = append(ports, msg.PortsRange{ From: max(port-difference-5, port-maxNumber, 1), To: min(port+difference+5, port+maxNumber, 65535), }) return ports } ================================================ FILE: pkg/nathole/discovery.go ================================================ // Copyright 2023 The frp Authors // // Licensed under the Apache License, Version 2.0 (the "License"); // you may not use this file except in compliance with the License. // You may obtain a copy of the License at // // http://www.apache.org/licenses/LICENSE-2.0 // // Unless required by applicable law or agreed to in writing, software // distributed under the License is distributed on an "AS IS" BASIS, // WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. // See the License for the specific language governing permissions and // limitations under the License. package nathole import ( "fmt" "net" "time" "github.com/pion/stun/v2" ) var responseTimeout = 3 * time.Second type Message struct { Body []byte Addr string } // If the localAddr is empty, it will listen on a random port. func Discover(stunServers []string, localAddr string) ([]string, net.Addr, error) { // create a discoverConn and get response from messageChan discoverConn, err := listen(localAddr) if err != nil { return nil, nil, err } defer discoverConn.Close() go discoverConn.readLoop() addresses := make([]string, 0, len(stunServers)) for _, addr := range stunServers { // get external address from stun server externalAddrs, err := discoverConn.discoverFromStunServer(addr) if err != nil { return nil, nil, err } addresses = append(addresses, externalAddrs...) } return addresses, discoverConn.localAddr, nil } type stunResponse struct { externalAddr string otherAddr string } type discoverConn struct { conn *net.UDPConn localAddr net.Addr messageChan chan *Message } func listen(localAddr string) (*discoverConn, error) { var local *net.UDPAddr if localAddr != "" { addr, err := net.ResolveUDPAddr("udp4", localAddr) if err != nil { return nil, err } local = addr } conn, err := net.ListenUDP("udp4", local) if err != nil { return nil, err } return &discoverConn{ conn: conn, localAddr: conn.LocalAddr(), messageChan: make(chan *Message, 10), }, nil } func (c *discoverConn) Close() error { if c.messageChan != nil { close(c.messageChan) c.messageChan = nil } return c.conn.Close() } func (c *discoverConn) readLoop() { for { buf := make([]byte, 1024) n, addr, err := c.conn.ReadFromUDP(buf) if err != nil { return } buf = buf[:n] c.messageChan <- &Message{ Body: buf, Addr: addr.String(), } } } func (c *discoverConn) doSTUNRequest(addr string) (*stunResponse, error) { serverAddr, err := net.ResolveUDPAddr("udp4", addr) if err != nil { return nil, err } request, err := stun.Build(stun.TransactionID, stun.BindingRequest) if err != nil { return nil, err } if err = request.NewTransactionID(); err != nil { return nil, err } if _, err := c.conn.WriteTo(request.Raw, serverAddr); err != nil { return nil, err } var m stun.Message select { case msg := <-c.messageChan: m.Raw = msg.Body if err := m.Decode(); err != nil { return nil, err } case <-time.After(responseTimeout): return nil, fmt.Errorf("wait response from stun server timeout") } xorAddrGetter := &stun.XORMappedAddress{} mappedAddrGetter := &stun.MappedAddress{} changedAddrGetter := ChangedAddress{} otherAddrGetter := &stun.OtherAddress{} resp := &stunResponse{} if err := mappedAddrGetter.GetFrom(&m); err == nil { resp.externalAddr = mappedAddrGetter.String() } if err := xorAddrGetter.GetFrom(&m); err == nil { resp.externalAddr = xorAddrGetter.String() } if err := changedAddrGetter.GetFrom(&m); err == nil { resp.otherAddr = changedAddrGetter.String() } if err := otherAddrGetter.GetFrom(&m); err == nil { resp.otherAddr = otherAddrGetter.String() } return resp, nil } func (c *discoverConn) discoverFromStunServer(addr string) ([]string, error) { resp, err := c.doSTUNRequest(addr) if err != nil { return nil, err } if resp.externalAddr == "" { return nil, fmt.Errorf("no external address found") } externalAddrs := make([]string, 0, 2) externalAddrs = append(externalAddrs, resp.externalAddr) if resp.otherAddr == "" { return externalAddrs, nil } // find external address from changed address resp, err = c.doSTUNRequest(resp.otherAddr) if err != nil { return nil, err } if resp.externalAddr != "" { externalAddrs = append(externalAddrs, resp.externalAddr) } return externalAddrs, nil } ================================================ FILE: pkg/nathole/nathole.go ================================================ // Copyright 2023 The frp Authors // // Licensed under the Apache License, Version 2.0 (the "License"); // you may not use this file except in compliance with the License. // You may obtain a copy of the License at // // http://www.apache.org/licenses/LICENSE-2.0 // // Unless required by applicable law or agreed to in writing, software // distributed under the License is distributed on an "AS IS" BASIS, // WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. // See the License for the specific language governing permissions and // limitations under the License. package nathole import ( "context" "fmt" "math/rand/v2" "net" "slices" "strconv" "strings" "time" "github.com/fatedier/golib/pool" "golang.org/x/net/ipv4" "k8s.io/apimachinery/pkg/util/sets" "github.com/fatedier/frp/pkg/msg" "github.com/fatedier/frp/pkg/transport" "github.com/fatedier/frp/pkg/util/xlog" ) var ( // mode 0: simple detect mode, usually for both EasyNAT or HardNAT & EasyNAT(Public Network) // a. receiver sends detect message with low TTL // b. sender sends normal detect message to receiver // c. receiver receives detect message and sends back a message to sender // // mode 1: For HardNAT & EasyNAT, send detect messages to multiple guessed ports. // Usually applicable to scenarios where port changes are regular. // Most of the steps are the same as mode 0, but EasyNAT is fixed as the receiver and will send detect messages // with low TTL to multiple guessed ports of the sender. // // mode 2: For HardNAT & EasyNAT, ports changes are not regular. // a. HardNAT machine will listen on multiple ports and send detect messages with low TTL to EasyNAT machine // b. EasyNAT machine will send detect messages to random ports of HardNAT machine. // // mode 3: For HardNAT & HardNAT, both changes in the ports are regular. // Most of the steps are the same as mode 1, but the sender also needs to send detect messages to multiple guessed // ports of the receiver. // // mode 4: For HardNAT & HardNAT, one of the changes in the ports is regular. // Regular port changes are usually on the sender side. // a. Receiver listens on multiple ports and sends detect messages with low TTL to the sender's guessed range ports. // b. Sender sends detect messages to random ports of the receiver. SupportedModes = []int{DetectMode0, DetectMode1, DetectMode2, DetectMode3, DetectMode4} SupportedRoles = []string{DetectRoleSender, DetectRoleReceiver} DetectMode0 = 0 DetectMode1 = 1 DetectMode2 = 2 DetectMode3 = 3 DetectMode4 = 4 DetectRoleSender = "sender" DetectRoleReceiver = "receiver" ) // PrepareOptions defines options for NAT traversal preparation type PrepareOptions struct { // DisableAssistedAddrs disables the use of local network interfaces // for assisted connections during NAT traversal DisableAssistedAddrs bool } type PrepareResult struct { Addrs []string AssistedAddrs []string ListenConn *net.UDPConn NatType string Behavior string } // PreCheck is used to check if the proxy is ready for penetration. // Call this function before calling Prepare to avoid unnecessary preparation work. func PreCheck( ctx context.Context, transporter transport.MessageTransporter, proxyName string, timeout time.Duration, ) error { timeoutCtx, cancel := context.WithTimeout(ctx, timeout) defer cancel() var natHoleRespMsg *msg.NatHoleResp transactionID := NewTransactionID() m, err := transporter.Do(timeoutCtx, &msg.NatHoleVisitor{ TransactionID: transactionID, ProxyName: proxyName, PreCheck: true, }, transactionID, msg.TypeNameNatHoleResp) if err != nil { return fmt.Errorf("get natHoleRespMsg error: %v", err) } mm, ok := m.(*msg.NatHoleResp) if !ok { return fmt.Errorf("get natHoleRespMsg error: invalid message type") } natHoleRespMsg = mm if natHoleRespMsg.Error != "" { return fmt.Errorf("%s", natHoleRespMsg.Error) } return nil } // Prepare is used to do some preparation work before penetration. func Prepare(stunServers []string, opts PrepareOptions) (*PrepareResult, error) { // discover for Nat type addrs, localAddr, err := Discover(stunServers, "") if err != nil { return nil, fmt.Errorf("discover error: %v", err) } if len(addrs) < 2 { return nil, fmt.Errorf("discover error: not enough addresses") } localIPs, _ := ListLocalIPsForNatHole(10) natFeature, err := ClassifyNATFeature(addrs, localIPs) if err != nil { return nil, fmt.Errorf("classify nat feature error: %v", err) } laddr, err := net.ResolveUDPAddr("udp4", localAddr.String()) if err != nil { return nil, fmt.Errorf("resolve local udp addr error: %v", err) } listenConn, err := net.ListenUDP("udp4", laddr) if err != nil { return nil, fmt.Errorf("listen local udp addr error: %v", err) } // Apply NAT traversal options var assistedAddrs []string if !opts.DisableAssistedAddrs { assistedAddrs = make([]string, 0, len(localIPs)) for _, ip := range localIPs { assistedAddrs = append(assistedAddrs, net.JoinHostPort(ip, strconv.Itoa(laddr.Port))) } } return &PrepareResult{ Addrs: addrs, AssistedAddrs: assistedAddrs, ListenConn: listenConn, NatType: natFeature.NatType, Behavior: natFeature.Behavior, }, nil } // ExchangeInfo is used to exchange information between client and visitor. // 1. Send input message to server by msgTransporter. // 2. Server will gather information from client and visitor and analyze it. Then send back a NatHoleResp message to them to tell them how to do next. // 3. Receive NatHoleResp message from server. func ExchangeInfo( ctx context.Context, transporter transport.MessageTransporter, laneKey string, m msg.Message, timeout time.Duration, ) (*msg.NatHoleResp, error) { timeoutCtx, cancel := context.WithTimeout(ctx, timeout) defer cancel() var natHoleRespMsg *msg.NatHoleResp m, err := transporter.Do(timeoutCtx, m, laneKey, msg.TypeNameNatHoleResp) if err != nil { return nil, fmt.Errorf("get natHoleRespMsg error: %v", err) } mm, ok := m.(*msg.NatHoleResp) if !ok { return nil, fmt.Errorf("get natHoleRespMsg error: invalid message type") } natHoleRespMsg = mm if natHoleRespMsg.Error != "" { return nil, fmt.Errorf("natHoleRespMsg get error info: %s", natHoleRespMsg.Error) } if len(natHoleRespMsg.CandidateAddrs) == 0 { return nil, fmt.Errorf("natHoleRespMsg get empty candidate addresses") } return natHoleRespMsg, nil } // MakeHole is used to make a NAT hole between client and visitor. func MakeHole(ctx context.Context, listenConn *net.UDPConn, m *msg.NatHoleResp, key []byte) (*net.UDPConn, *net.UDPAddr, error) { xl := xlog.FromContextSafe(ctx) transactionID := NewTransactionID() sendToRangePortsFunc := func(conn *net.UDPConn, addr string) error { return sendSidMessage(ctx, conn, m.Sid, transactionID, addr, key, m.DetectBehavior.TTL) } listenConns := []*net.UDPConn{listenConn} var detectAddrs []string if m.DetectBehavior.Role == DetectRoleSender { // sender if m.DetectBehavior.SendDelayMs > 0 { time.Sleep(time.Duration(m.DetectBehavior.SendDelayMs) * time.Millisecond) } detectAddrs = m.AssistedAddrs detectAddrs = append(detectAddrs, m.CandidateAddrs...) } else { // receiver if len(m.DetectBehavior.CandidatePorts) == 0 { detectAddrs = m.CandidateAddrs } if m.DetectBehavior.ListenRandomPorts > 0 { for i := 0; i < m.DetectBehavior.ListenRandomPorts; i++ { tmpConn, err := net.ListenUDP("udp4", nil) if err != nil { xl.Warnf("listen random udp addr error: %v", err) continue } listenConns = append(listenConns, tmpConn) } } } detectAddrs = slices.Compact(detectAddrs) for _, detectAddr := range detectAddrs { for _, conn := range listenConns { if err := sendSidMessage(ctx, conn, m.Sid, transactionID, detectAddr, key, m.DetectBehavior.TTL); err != nil { xl.Tracef("send sid message from %s to %s error: %v", conn.LocalAddr(), detectAddr, err) } } } if len(m.DetectBehavior.CandidatePorts) > 0 { for _, conn := range listenConns { sendSidMessageToRangePorts(ctx, conn, m.CandidateAddrs, m.DetectBehavior.CandidatePorts, sendToRangePortsFunc) } } if m.DetectBehavior.SendRandomPorts > 0 { ctx, cancel := context.WithCancel(ctx) defer cancel() for i := range listenConns { go sendSidMessageToRandomPorts(ctx, listenConns[i], m.CandidateAddrs, m.DetectBehavior.SendRandomPorts, sendToRangePortsFunc) } } timeout := 5 * time.Second if m.DetectBehavior.ReadTimeoutMs > 0 { timeout = time.Duration(m.DetectBehavior.ReadTimeoutMs) * time.Millisecond } if len(listenConns) == 1 { raddr, err := waitDetectMessage(ctx, listenConns[0], m.Sid, key, timeout, m.DetectBehavior.Role) if err != nil { return nil, nil, fmt.Errorf("wait detect message error: %v", err) } return listenConns[0], raddr, nil } type result struct { lConn *net.UDPConn raddr *net.UDPAddr } resultCh := make(chan result) for _, conn := range listenConns { go func(lConn *net.UDPConn) { addr, err := waitDetectMessage(ctx, lConn, m.Sid, key, timeout, m.DetectBehavior.Role) if err != nil { lConn.Close() return } select { case resultCh <- result{lConn: lConn, raddr: addr}: default: lConn.Close() } }(conn) } select { case result := <-resultCh: return result.lConn, result.raddr, nil case <-time.After(timeout): return nil, nil, fmt.Errorf("wait detect message timeout") case <-ctx.Done(): return nil, nil, fmt.Errorf("wait detect message canceled") } } func waitDetectMessage( ctx context.Context, conn *net.UDPConn, sid string, key []byte, timeout time.Duration, role string, ) (*net.UDPAddr, error) { xl := xlog.FromContextSafe(ctx) for { buf := pool.GetBuf(1024) _ = conn.SetReadDeadline(time.Now().Add(timeout)) n, raddr, err := conn.ReadFromUDP(buf) _ = conn.SetReadDeadline(time.Time{}) if err != nil { pool.PutBuf(buf) return nil, err } xl.Debugf("get udp message local %s, from %s", conn.LocalAddr(), raddr) var m msg.NatHoleSid if err := DecodeMessageInto(buf[:n], key, &m); err != nil { pool.PutBuf(buf) xl.Warnf("decode sid message error: %v", err) continue } pool.PutBuf(buf) if m.Sid != sid { xl.Warnf("get sid message with wrong sid: %s, expect: %s", m.Sid, sid) continue } if !m.Response { // only wait for response messages if we are a sender if role == DetectRoleSender { continue } m.Response = true buf2, err := EncodeMessage(&m, key) if err != nil { xl.Warnf("encode sid message error: %v", err) continue } _, _ = conn.WriteToUDP(buf2, raddr) } return raddr, nil } } func sendSidMessage( ctx context.Context, conn *net.UDPConn, sid string, transactionID string, addr string, key []byte, ttl int, ) error { xl := xlog.FromContextSafe(ctx) ttlStr := "" if ttl > 0 { ttlStr = fmt.Sprintf(" with ttl %d", ttl) } xl.Tracef("send sid message from %s to %s%s", conn.LocalAddr(), addr, ttlStr) raddr, err := net.ResolveUDPAddr("udp4", addr) if err != nil { return err } if transactionID == "" { transactionID = NewTransactionID() } m := &msg.NatHoleSid{ TransactionID: transactionID, Sid: sid, Response: false, Nonce: strings.Repeat("0", rand.IntN(20)), } buf, err := EncodeMessage(m, key) if err != nil { return err } if ttl > 0 { uConn := ipv4.NewConn(conn) original, err := uConn.TTL() if err != nil { xl.Tracef("get ttl error %v", err) return err } xl.Tracef("original ttl %d", original) err = uConn.SetTTL(ttl) if err != nil { xl.Tracef("set ttl error %v", err) } else { defer func() { _ = uConn.SetTTL(original) }() } } if _, err := conn.WriteToUDP(buf, raddr); err != nil { return err } return nil } func sendSidMessageToRangePorts( ctx context.Context, conn *net.UDPConn, addrs []string, ports []msg.PortsRange, sendFunc func(*net.UDPConn, string) error, ) { xl := xlog.FromContextSafe(ctx) for _, ip := range slices.Compact(parseIPs(addrs)) { for _, portsRange := range ports { for i := portsRange.From; i <= portsRange.To; i++ { detectAddr := net.JoinHostPort(ip, strconv.Itoa(i)) if err := sendFunc(conn, detectAddr); err != nil { xl.Tracef("send sid message from %s to %s error: %v", conn.LocalAddr(), detectAddr, err) } time.Sleep(2 * time.Millisecond) } } } } func sendSidMessageToRandomPorts( ctx context.Context, conn *net.UDPConn, addrs []string, count int, sendFunc func(*net.UDPConn, string) error, ) { xl := xlog.FromContextSafe(ctx) used := sets.New[int]() getUnusedPort := func() int { for range 10 { port := rand.IntN(65535-1024) + 1024 if !used.Has(port) { used.Insert(port) return port } } return 0 } for range count { select { case <-ctx.Done(): return default: } port := getUnusedPort() if port == 0 { continue } for _, ip := range slices.Compact(parseIPs(addrs)) { detectAddr := net.JoinHostPort(ip, strconv.Itoa(port)) if err := sendFunc(conn, detectAddr); err != nil { xl.Tracef("send sid message from %s to %s error: %v", conn.LocalAddr(), detectAddr, err) } time.Sleep(time.Millisecond * 15) } } } func parseIPs(addrs []string) []string { var ips []string for _, addr := range addrs { if ip, _, err := net.SplitHostPort(addr); err == nil { ips = append(ips, ip) } } return ips } ================================================ FILE: pkg/nathole/utils.go ================================================ // Copyright 2023 The frp Authors // // Licensed under the Apache License, Version 2.0 (the "License"); // you may not use this file except in compliance with the License. // You may obtain a copy of the License at // // http://www.apache.org/licenses/LICENSE-2.0 // // Unless required by applicable law or agreed to in writing, software // distributed under the License is distributed on an "AS IS" BASIS, // WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. // See the License for the specific language governing permissions and // limitations under the License. package nathole import ( "bytes" "fmt" "net" "strconv" "github.com/fatedier/golib/crypto" "github.com/pion/stun/v2" "github.com/fatedier/frp/pkg/msg" ) func EncodeMessage(m msg.Message, key []byte) ([]byte, error) { buffer := bytes.NewBuffer(nil) if err := msg.WriteMsg(buffer, m); err != nil { return nil, err } buf, err := crypto.Encode(buffer.Bytes(), key) if err != nil { return nil, err } return buf, nil } func DecodeMessageInto(data, key []byte, m msg.Message) error { buf, err := crypto.Decode(data, key) if err != nil { return err } return msg.ReadMsgInto(bytes.NewReader(buf), m) } type ChangedAddress struct { IP net.IP Port int } func (s *ChangedAddress) GetFrom(m *stun.Message) error { a := (*stun.MappedAddress)(s) return a.GetFromAs(m, stun.AttrChangedAddress) } func (s *ChangedAddress) String() string { return net.JoinHostPort(s.IP.String(), strconv.Itoa(s.Port)) } func ListAllLocalIPs() ([]net.IP, error) { addrs, err := net.InterfaceAddrs() if err != nil { return nil, err } ips := make([]net.IP, 0, len(addrs)) for _, addr := range addrs { ip, _, err := net.ParseCIDR(addr.String()) if err != nil { continue } ips = append(ips, ip) } return ips, nil } func ListLocalIPsForNatHole(maxItems int) ([]string, error) { if maxItems <= 0 { return nil, fmt.Errorf("maxItems must be greater than 0") } ips, err := ListAllLocalIPs() if err != nil { return nil, err } filtered := make([]string, 0, maxItems) for _, ip := range ips { if len(filtered) >= maxItems { break } // ignore ipv6 address if ip.To4() == nil { continue } // ignore localhost IP if ip.IsLoopback() || ip.IsLinkLocalUnicast() || ip.IsLinkLocalMulticast() { continue } filtered = append(filtered, ip.String()) } return filtered, nil } ================================================ FILE: pkg/plugin/client/http2http.go ================================================ // Copyright 2024 The frp Authors // // Licensed under the Apache License, Version 2.0 (the "License"); // you may not use this file except in compliance with the License. // You may obtain a copy of the License at // // http://www.apache.org/licenses/LICENSE-2.0 // // Unless required by applicable law or agreed to in writing, software // distributed under the License is distributed on an "AS IS" BASIS, // WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. // See the License for the specific language governing permissions and // limitations under the License. //go:build !frps package client import ( "context" stdlog "log" "net/http" "net/http/httputil" "time" "github.com/fatedier/golib/pool" v1 "github.com/fatedier/frp/pkg/config/v1" "github.com/fatedier/frp/pkg/util/log" netpkg "github.com/fatedier/frp/pkg/util/net" ) func init() { Register(v1.PluginHTTP2HTTP, NewHTTP2HTTPPlugin) } type HTTP2HTTPPlugin struct { opts *v1.HTTP2HTTPPluginOptions l *Listener s *http.Server } func NewHTTP2HTTPPlugin(_ PluginContext, options v1.ClientPluginOptions) (Plugin, error) { opts := options.(*v1.HTTP2HTTPPluginOptions) listener := NewProxyListener() p := &HTTP2HTTPPlugin{ opts: opts, l: listener, } rp := &httputil.ReverseProxy{ Rewrite: func(r *httputil.ProxyRequest) { req := r.Out req.URL.Scheme = "http" req.URL.Host = p.opts.LocalAddr if p.opts.HostHeaderRewrite != "" { req.Host = p.opts.HostHeaderRewrite } for k, v := range p.opts.RequestHeaders.Set { req.Header.Set(k, v) } }, BufferPool: pool.NewBuffer(32 * 1024), ErrorLog: stdlog.New(log.NewWriteLogger(log.WarnLevel, 2), "", 0), } p.s = &http.Server{ Handler: rp, ReadHeaderTimeout: 60 * time.Second, } go func() { _ = p.s.Serve(listener) }() return p, nil } func (p *HTTP2HTTPPlugin) Handle(_ context.Context, connInfo *ConnectionInfo) { wrapConn := netpkg.WrapReadWriteCloserToConn(connInfo.Conn, connInfo.UnderlyingConn) _ = p.l.PutConn(wrapConn) } func (p *HTTP2HTTPPlugin) Name() string { return v1.PluginHTTP2HTTP } func (p *HTTP2HTTPPlugin) Close() error { return p.s.Close() } ================================================ FILE: pkg/plugin/client/http2https.go ================================================ // Copyright 2019 fatedier, fatedier@gmail.com // // Licensed under the Apache License, Version 2.0 (the "License"); // you may not use this file except in compliance with the License. // You may obtain a copy of the License at // // http://www.apache.org/licenses/LICENSE-2.0 // // Unless required by applicable law or agreed to in writing, software // distributed under the License is distributed on an "AS IS" BASIS, // WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. // See the License for the specific language governing permissions and // limitations under the License. //go:build !frps package client import ( "context" "crypto/tls" stdlog "log" "net/http" "net/http/httputil" "time" "github.com/fatedier/golib/pool" v1 "github.com/fatedier/frp/pkg/config/v1" "github.com/fatedier/frp/pkg/util/log" netpkg "github.com/fatedier/frp/pkg/util/net" ) func init() { Register(v1.PluginHTTP2HTTPS, NewHTTP2HTTPSPlugin) } type HTTP2HTTPSPlugin struct { opts *v1.HTTP2HTTPSPluginOptions l *Listener s *http.Server } func NewHTTP2HTTPSPlugin(_ PluginContext, options v1.ClientPluginOptions) (Plugin, error) { opts := options.(*v1.HTTP2HTTPSPluginOptions) listener := NewProxyListener() p := &HTTP2HTTPSPlugin{ opts: opts, l: listener, } tr := &http.Transport{ TLSClientConfig: &tls.Config{InsecureSkipVerify: true}, } rp := &httputil.ReverseProxy{ Rewrite: func(r *httputil.ProxyRequest) { r.Out.Header["X-Forwarded-For"] = r.In.Header["X-Forwarded-For"] r.Out.Header["X-Forwarded-Host"] = r.In.Header["X-Forwarded-Host"] r.Out.Header["X-Forwarded-Proto"] = r.In.Header["X-Forwarded-Proto"] req := r.Out req.URL.Scheme = "https" req.URL.Host = p.opts.LocalAddr if p.opts.HostHeaderRewrite != "" { req.Host = p.opts.HostHeaderRewrite } for k, v := range p.opts.RequestHeaders.Set { req.Header.Set(k, v) } }, Transport: tr, BufferPool: pool.NewBuffer(32 * 1024), ErrorLog: stdlog.New(log.NewWriteLogger(log.WarnLevel, 2), "", 0), } p.s = &http.Server{ Handler: rp, ReadHeaderTimeout: 60 * time.Second, } go func() { _ = p.s.Serve(listener) }() return p, nil } func (p *HTTP2HTTPSPlugin) Handle(_ context.Context, connInfo *ConnectionInfo) { wrapConn := netpkg.WrapReadWriteCloserToConn(connInfo.Conn, connInfo.UnderlyingConn) _ = p.l.PutConn(wrapConn) } func (p *HTTP2HTTPSPlugin) Name() string { return v1.PluginHTTP2HTTPS } func (p *HTTP2HTTPSPlugin) Close() error { return p.s.Close() } ================================================ FILE: pkg/plugin/client/http_proxy.go ================================================ // Copyright 2017 frp team // // Licensed under the Apache License, Version 2.0 (the "License"); // you may not use this file except in compliance with the License. // You may obtain a copy of the License at // // http://www.apache.org/licenses/LICENSE-2.0 // // Unless required by applicable law or agreed to in writing, software // distributed under the License is distributed on an "AS IS" BASIS, // WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. // See the License for the specific language governing permissions and // limitations under the License. //go:build !frps package client import ( "bufio" "context" "encoding/base64" "io" "net" "net/http" "strings" "time" libio "github.com/fatedier/golib/io" libnet "github.com/fatedier/golib/net" v1 "github.com/fatedier/frp/pkg/config/v1" netpkg "github.com/fatedier/frp/pkg/util/net" "github.com/fatedier/frp/pkg/util/util" ) func init() { Register(v1.PluginHTTPProxy, NewHTTPProxyPlugin) } type HTTPProxy struct { opts *v1.HTTPProxyPluginOptions l *Listener s *http.Server } func NewHTTPProxyPlugin(_ PluginContext, options v1.ClientPluginOptions) (Plugin, error) { opts := options.(*v1.HTTPProxyPluginOptions) listener := NewProxyListener() hp := &HTTPProxy{ l: listener, opts: opts, } hp.s = &http.Server{ Handler: hp, ReadHeaderTimeout: 60 * time.Second, } go func() { _ = hp.s.Serve(listener) }() return hp, nil } func (hp *HTTPProxy) Name() string { return v1.PluginHTTPProxy } func (hp *HTTPProxy) Handle(_ context.Context, connInfo *ConnectionInfo) { wrapConn := netpkg.WrapReadWriteCloserToConn(connInfo.Conn, connInfo.UnderlyingConn) sc, rd := libnet.NewSharedConn(wrapConn) firstBytes := make([]byte, 7) _, err := rd.Read(firstBytes) if err != nil { wrapConn.Close() return } if strings.ToUpper(string(firstBytes)) == "CONNECT" { bufRd := bufio.NewReader(sc) request, err := http.ReadRequest(bufRd) if err != nil { wrapConn.Close() return } hp.handleConnectReq(request, libio.WrapReadWriteCloser(bufRd, wrapConn, wrapConn.Close)) return } _ = hp.l.PutConn(sc) } func (hp *HTTPProxy) Close() error { hp.s.Close() hp.l.Close() return nil } func (hp *HTTPProxy) ServeHTTP(rw http.ResponseWriter, req *http.Request) { if ok := hp.Auth(req); !ok { rw.Header().Set("Proxy-Authenticate", "Basic") rw.WriteHeader(http.StatusProxyAuthRequired) return } if req.Method == http.MethodConnect { // deprecated // Connect request is handled in Handle function. hp.ConnectHandler(rw, req) } else { hp.HTTPHandler(rw, req) } } func (hp *HTTPProxy) HTTPHandler(rw http.ResponseWriter, req *http.Request) { removeProxyHeaders(req) resp, err := http.DefaultTransport.RoundTrip(req) if err != nil { http.Error(rw, err.Error(), http.StatusInternalServerError) return } defer resp.Body.Close() copyHeaders(rw.Header(), resp.Header) rw.WriteHeader(resp.StatusCode) _, err = io.Copy(rw, resp.Body) if err != nil && err != io.EOF { return } } // deprecated // Hijack needs to SetReadDeadline on the Conn of the request, but if we use stream compression here, // we may always get i/o timeout error. func (hp *HTTPProxy) ConnectHandler(rw http.ResponseWriter, req *http.Request) { hj, ok := rw.(http.Hijacker) if !ok { rw.WriteHeader(http.StatusInternalServerError) return } client, _, err := hj.Hijack() if err != nil { rw.WriteHeader(http.StatusInternalServerError) return } remote, err := net.Dial("tcp", req.URL.Host) if err != nil { http.Error(rw, "Failed", http.StatusBadRequest) client.Close() return } _, _ = client.Write([]byte("HTTP/1.1 200 OK\r\n\r\n")) go libio.Join(remote, client) } func (hp *HTTPProxy) Auth(req *http.Request) bool { if hp.opts.HTTPUser == "" && hp.opts.HTTPPassword == "" { return true } s := strings.SplitN(req.Header.Get("Proxy-Authorization"), " ", 2) if len(s) != 2 { return false } b, err := base64.StdEncoding.DecodeString(s[1]) if err != nil { return false } pair := strings.SplitN(string(b), ":", 2) if len(pair) != 2 { return false } if !util.ConstantTimeEqString(pair[0], hp.opts.HTTPUser) || !util.ConstantTimeEqString(pair[1], hp.opts.HTTPPassword) { time.Sleep(200 * time.Millisecond) return false } return true } func (hp *HTTPProxy) handleConnectReq(req *http.Request, rwc io.ReadWriteCloser) { defer rwc.Close() if ok := hp.Auth(req); !ok { res := getBadResponse() _ = res.Write(rwc) if res.Body != nil { res.Body.Close() } return } remote, err := net.Dial("tcp", req.URL.Host) if err != nil { res := &http.Response{ StatusCode: 400, Proto: "HTTP/1.1", ProtoMajor: 1, ProtoMinor: 1, } _ = res.Write(rwc) return } _, _ = rwc.Write([]byte("HTTP/1.1 200 OK\r\n\r\n")) libio.Join(remote, rwc) } func copyHeaders(dst, src http.Header) { for key, values := range src { for _, value := range values { dst.Add(key, value) } } } func removeProxyHeaders(req *http.Request) { req.RequestURI = "" req.Header.Del("Proxy-Connection") req.Header.Del("Connection") req.Header.Del("Proxy-Authenticate") req.Header.Del("Proxy-Authorization") req.Header.Del("TE") req.Header.Del("Trailers") req.Header.Del("Transfer-Encoding") req.Header.Del("Upgrade") } func getBadResponse() *http.Response { header := make(map[string][]string) header["Proxy-Authenticate"] = []string{"Basic"} header["Connection"] = []string{"close"} res := &http.Response{ Status: "407 Not authorized", StatusCode: 407, Proto: "HTTP/1.1", ProtoMajor: 1, ProtoMinor: 1, Header: header, } return res } ================================================ FILE: pkg/plugin/client/https2http.go ================================================ // Copyright 2019 fatedier, fatedier@gmail.com // // Licensed under the Apache License, Version 2.0 (the "License"); // you may not use this file except in compliance with the License. // You may obtain a copy of the License at // // http://www.apache.org/licenses/LICENSE-2.0 // // Unless required by applicable law or agreed to in writing, software // distributed under the License is distributed on an "AS IS" BASIS, // WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. // See the License for the specific language governing permissions and // limitations under the License. //go:build !frps package client import ( "context" "crypto/tls" "fmt" stdlog "log" "net/http" "net/http/httputil" "time" "github.com/fatedier/golib/pool" "github.com/samber/lo" v1 "github.com/fatedier/frp/pkg/config/v1" "github.com/fatedier/frp/pkg/transport" httppkg "github.com/fatedier/frp/pkg/util/http" "github.com/fatedier/frp/pkg/util/log" netpkg "github.com/fatedier/frp/pkg/util/net" ) func init() { Register(v1.PluginHTTPS2HTTP, NewHTTPS2HTTPPlugin) } type HTTPS2HTTPPlugin struct { opts *v1.HTTPS2HTTPPluginOptions l *Listener s *http.Server } func NewHTTPS2HTTPPlugin(_ PluginContext, options v1.ClientPluginOptions) (Plugin, error) { opts := options.(*v1.HTTPS2HTTPPluginOptions) listener := NewProxyListener() p := &HTTPS2HTTPPlugin{ opts: opts, l: listener, } rp := &httputil.ReverseProxy{ Rewrite: func(r *httputil.ProxyRequest) { r.Out.Header["X-Forwarded-For"] = r.In.Header["X-Forwarded-For"] r.SetXForwarded() req := r.Out req.URL.Scheme = "http" req.URL.Host = p.opts.LocalAddr if p.opts.HostHeaderRewrite != "" { req.Host = p.opts.HostHeaderRewrite } for k, v := range p.opts.RequestHeaders.Set { req.Header.Set(k, v) } }, BufferPool: pool.NewBuffer(32 * 1024), ErrorLog: stdlog.New(log.NewWriteLogger(log.WarnLevel, 2), "", 0), } handler := http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { if r.TLS != nil { tlsServerName, _ := httppkg.CanonicalHost(r.TLS.ServerName) host, _ := httppkg.CanonicalHost(r.Host) if tlsServerName != "" && tlsServerName != host { w.WriteHeader(http.StatusMisdirectedRequest) return } } rp.ServeHTTP(w, r) }) tlsConfig, err := transport.NewServerTLSConfig(p.opts.CrtPath, p.opts.KeyPath, "") if err != nil { return nil, fmt.Errorf("gen TLS config error: %v", err) } p.s = &http.Server{ Handler: handler, ReadHeaderTimeout: 60 * time.Second, TLSConfig: tlsConfig, } if !lo.FromPtr(opts.EnableHTTP2) { p.s.TLSNextProto = make(map[string]func(*http.Server, *tls.Conn, http.Handler)) } go func() { _ = p.s.ServeTLS(listener, "", "") }() return p, nil } func (p *HTTPS2HTTPPlugin) Handle(_ context.Context, connInfo *ConnectionInfo) { wrapConn := netpkg.WrapReadWriteCloserToConn(connInfo.Conn, connInfo.UnderlyingConn) if connInfo.SrcAddr != nil { wrapConn.SetRemoteAddr(connInfo.SrcAddr) } _ = p.l.PutConn(wrapConn) } func (p *HTTPS2HTTPPlugin) Name() string { return v1.PluginHTTPS2HTTP } func (p *HTTPS2HTTPPlugin) Close() error { return p.s.Close() } ================================================ FILE: pkg/plugin/client/https2https.go ================================================ // Copyright 2019 fatedier, fatedier@gmail.com // // Licensed under the Apache License, Version 2.0 (the "License"); // you may not use this file except in compliance with the License. // You may obtain a copy of the License at // // http://www.apache.org/licenses/LICENSE-2.0 // // Unless required by applicable law or agreed to in writing, software // distributed under the License is distributed on an "AS IS" BASIS, // WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. // See the License for the specific language governing permissions and // limitations under the License. //go:build !frps package client import ( "context" "crypto/tls" "fmt" stdlog "log" "net/http" "net/http/httputil" "time" "github.com/fatedier/golib/pool" "github.com/samber/lo" v1 "github.com/fatedier/frp/pkg/config/v1" "github.com/fatedier/frp/pkg/transport" httppkg "github.com/fatedier/frp/pkg/util/http" "github.com/fatedier/frp/pkg/util/log" netpkg "github.com/fatedier/frp/pkg/util/net" ) func init() { Register(v1.PluginHTTPS2HTTPS, NewHTTPS2HTTPSPlugin) } type HTTPS2HTTPSPlugin struct { opts *v1.HTTPS2HTTPSPluginOptions l *Listener s *http.Server } func NewHTTPS2HTTPSPlugin(_ PluginContext, options v1.ClientPluginOptions) (Plugin, error) { opts := options.(*v1.HTTPS2HTTPSPluginOptions) listener := NewProxyListener() p := &HTTPS2HTTPSPlugin{ opts: opts, l: listener, } tr := &http.Transport{ TLSClientConfig: &tls.Config{InsecureSkipVerify: true}, } rp := &httputil.ReverseProxy{ Rewrite: func(r *httputil.ProxyRequest) { r.Out.Header["X-Forwarded-For"] = r.In.Header["X-Forwarded-For"] r.SetXForwarded() req := r.Out req.URL.Scheme = "https" req.URL.Host = p.opts.LocalAddr if p.opts.HostHeaderRewrite != "" { req.Host = p.opts.HostHeaderRewrite } for k, v := range p.opts.RequestHeaders.Set { req.Header.Set(k, v) } }, Transport: tr, BufferPool: pool.NewBuffer(32 * 1024), ErrorLog: stdlog.New(log.NewWriteLogger(log.WarnLevel, 2), "", 0), } handler := http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { if r.TLS != nil { tlsServerName, _ := httppkg.CanonicalHost(r.TLS.ServerName) host, _ := httppkg.CanonicalHost(r.Host) if tlsServerName != "" && tlsServerName != host { w.WriteHeader(http.StatusMisdirectedRequest) return } } rp.ServeHTTP(w, r) }) tlsConfig, err := transport.NewServerTLSConfig(p.opts.CrtPath, p.opts.KeyPath, "") if err != nil { return nil, fmt.Errorf("gen TLS config error: %v", err) } p.s = &http.Server{ Handler: handler, ReadHeaderTimeout: 60 * time.Second, TLSConfig: tlsConfig, } if !lo.FromPtr(opts.EnableHTTP2) { p.s.TLSNextProto = make(map[string]func(*http.Server, *tls.Conn, http.Handler)) } go func() { _ = p.s.ServeTLS(listener, "", "") }() return p, nil } func (p *HTTPS2HTTPSPlugin) Handle(_ context.Context, connInfo *ConnectionInfo) { wrapConn := netpkg.WrapReadWriteCloserToConn(connInfo.Conn, connInfo.UnderlyingConn) if connInfo.SrcAddr != nil { wrapConn.SetRemoteAddr(connInfo.SrcAddr) } _ = p.l.PutConn(wrapConn) } func (p *HTTPS2HTTPSPlugin) Name() string { return v1.PluginHTTPS2HTTPS } func (p *HTTPS2HTTPSPlugin) Close() error { return p.s.Close() } ================================================ FILE: pkg/plugin/client/plugin.go ================================================ // Copyright 2017 fatedier, fatedier@gmail.com // // Licensed under the Apache License, Version 2.0 (the "License"); // you may not use this file except in compliance with the License. // You may obtain a copy of the License at // // http://www.apache.org/licenses/LICENSE-2.0 // // Unless required by applicable law or agreed to in writing, software // distributed under the License is distributed on an "AS IS" BASIS, // WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. // See the License for the specific language governing permissions and // limitations under the License. package client import ( "context" "fmt" "io" "net" "sync" "github.com/fatedier/golib/errors" pp "github.com/pires/go-proxyproto" v1 "github.com/fatedier/frp/pkg/config/v1" "github.com/fatedier/frp/pkg/vnet" ) type PluginContext struct { Name string VnetController *vnet.Controller } // Creators is used for create plugins to handle connections. var creators = make(map[string]CreatorFn) type CreatorFn func(pluginCtx PluginContext, options v1.ClientPluginOptions) (Plugin, error) func Register(name string, fn CreatorFn) { if _, exist := creators[name]; exist { panic(fmt.Sprintf("plugin [%s] is already registered", name)) } creators[name] = fn } func Create(pluginName string, pluginCtx PluginContext, options v1.ClientPluginOptions) (p Plugin, err error) { if fn, ok := creators[pluginName]; ok { p, err = fn(pluginCtx, options) } else { err = fmt.Errorf("plugin [%s] is not registered", pluginName) } return } type ConnectionInfo struct { Conn io.ReadWriteCloser UnderlyingConn net.Conn ProxyProtocolHeader *pp.Header SrcAddr net.Addr DstAddr net.Addr } type Plugin interface { Name() string Handle(ctx context.Context, connInfo *ConnectionInfo) Close() error } type Listener struct { conns chan net.Conn closed bool mu sync.Mutex } func NewProxyListener() *Listener { return &Listener{ conns: make(chan net.Conn, 64), } } func (l *Listener) Accept() (net.Conn, error) { conn, ok := <-l.conns if !ok { return nil, fmt.Errorf("listener closed") } return conn, nil } func (l *Listener) PutConn(conn net.Conn) error { err := errors.PanicToError(func() { l.conns <- conn }) return err } func (l *Listener) Close() error { l.mu.Lock() defer l.mu.Unlock() if !l.closed { close(l.conns) l.closed = true } return nil } func (l *Listener) Addr() net.Addr { return (*net.TCPAddr)(nil) } ================================================ FILE: pkg/plugin/client/socks5.go ================================================ // Copyright 2017 fatedier, fatedier@gmail.com // // Licensed under the Apache License, Version 2.0 (the "License"); // you may not use this file except in compliance with the License. // You may obtain a copy of the License at // // http://www.apache.org/licenses/LICENSE-2.0 // // Unless required by applicable law or agreed to in writing, software // distributed under the License is distributed on an "AS IS" BASIS, // WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. // See the License for the specific language governing permissions and // limitations under the License. //go:build !frps package client import ( "context" "io" "log" gosocks5 "github.com/armon/go-socks5" v1 "github.com/fatedier/frp/pkg/config/v1" netpkg "github.com/fatedier/frp/pkg/util/net" ) func init() { Register(v1.PluginSocks5, NewSocks5Plugin) } type Socks5Plugin struct { Server *gosocks5.Server } func NewSocks5Plugin(_ PluginContext, options v1.ClientPluginOptions) (p Plugin, err error) { opts := options.(*v1.Socks5PluginOptions) cfg := &gosocks5.Config{ Logger: log.New(io.Discard, "", log.LstdFlags), } if opts.Username != "" || opts.Password != "" { cfg.Credentials = gosocks5.StaticCredentials(map[string]string{opts.Username: opts.Password}) } sp := &Socks5Plugin{} sp.Server, err = gosocks5.New(cfg) p = sp return } func (sp *Socks5Plugin) Handle(_ context.Context, connInfo *ConnectionInfo) { defer connInfo.Conn.Close() wrapConn := netpkg.WrapReadWriteCloserToConn(connInfo.Conn, connInfo.UnderlyingConn) _ = sp.Server.ServeConn(wrapConn) } func (sp *Socks5Plugin) Name() string { return v1.PluginSocks5 } func (sp *Socks5Plugin) Close() error { return nil } ================================================ FILE: pkg/plugin/client/static_file.go ================================================ // Copyright 2018 fatedier, fatedier@gmail.com // // Licensed under the Apache License, Version 2.0 (the "License"); // you may not use this file except in compliance with the License. // You may obtain a copy of the License at // // http://www.apache.org/licenses/LICENSE-2.0 // // Unless required by applicable law or agreed to in writing, software // distributed under the License is distributed on an "AS IS" BASIS, // WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. // See the License for the specific language governing permissions and // limitations under the License. //go:build !frps package client import ( "context" "net/http" "time" "github.com/gorilla/mux" v1 "github.com/fatedier/frp/pkg/config/v1" netpkg "github.com/fatedier/frp/pkg/util/net" ) func init() { Register(v1.PluginStaticFile, NewStaticFilePlugin) } type StaticFilePlugin struct { opts *v1.StaticFilePluginOptions l *Listener s *http.Server } func NewStaticFilePlugin(_ PluginContext, options v1.ClientPluginOptions) (Plugin, error) { opts := options.(*v1.StaticFilePluginOptions) listener := NewProxyListener() sp := &StaticFilePlugin{ opts: opts, l: listener, } var prefix string if opts.StripPrefix != "" { prefix = "/" + opts.StripPrefix + "/" } else { prefix = "/" } router := mux.NewRouter() router.Use(netpkg.NewHTTPAuthMiddleware(opts.HTTPUser, opts.HTTPPassword).SetAuthFailDelay(200 * time.Millisecond).Middleware) router.PathPrefix(prefix).Handler(netpkg.MakeHTTPGzipHandler(http.StripPrefix(prefix, http.FileServer(http.Dir(opts.LocalPath))))).Methods("GET") sp.s = &http.Server{ Handler: router, ReadHeaderTimeout: 60 * time.Second, } go func() { _ = sp.s.Serve(listener) }() return sp, nil } func (sp *StaticFilePlugin) Handle(_ context.Context, connInfo *ConnectionInfo) { wrapConn := netpkg.WrapReadWriteCloserToConn(connInfo.Conn, connInfo.UnderlyingConn) _ = sp.l.PutConn(wrapConn) } func (sp *StaticFilePlugin) Name() string { return v1.PluginStaticFile } func (sp *StaticFilePlugin) Close() error { sp.s.Close() sp.l.Close() return nil } ================================================ FILE: pkg/plugin/client/tls2raw.go ================================================ // Copyright 2024 The frp Authors // // Licensed under the Apache License, Version 2.0 (the "License"); // you may not use this file except in compliance with the License. // You may obtain a copy of the License at // // http://www.apache.org/licenses/LICENSE-2.0 // // Unless required by applicable law or agreed to in writing, software // distributed under the License is distributed on an "AS IS" BASIS, // WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. // See the License for the specific language governing permissions and // limitations under the License. //go:build !frps package client import ( "context" "crypto/tls" "net" libio "github.com/fatedier/golib/io" v1 "github.com/fatedier/frp/pkg/config/v1" "github.com/fatedier/frp/pkg/transport" netpkg "github.com/fatedier/frp/pkg/util/net" "github.com/fatedier/frp/pkg/util/xlog" ) func init() { Register(v1.PluginTLS2Raw, NewTLS2RawPlugin) } type TLS2RawPlugin struct { opts *v1.TLS2RawPluginOptions tlsConfig *tls.Config } func NewTLS2RawPlugin(_ PluginContext, options v1.ClientPluginOptions) (Plugin, error) { opts := options.(*v1.TLS2RawPluginOptions) p := &TLS2RawPlugin{ opts: opts, } tlsConfig, err := transport.NewServerTLSConfig(p.opts.CrtPath, p.opts.KeyPath, "") if err != nil { return nil, err } p.tlsConfig = tlsConfig return p, nil } func (p *TLS2RawPlugin) Handle(ctx context.Context, connInfo *ConnectionInfo) { xl := xlog.FromContextSafe(ctx) wrapConn := netpkg.WrapReadWriteCloserToConn(connInfo.Conn, connInfo.UnderlyingConn) tlsConn := tls.Server(wrapConn, p.tlsConfig) if err := tlsConn.Handshake(); err != nil { xl.Warnf("tls handshake error: %v", err) tlsConn.Close() return } rawConn, err := net.Dial("tcp", p.opts.LocalAddr) if err != nil { xl.Warnf("dial to local addr error: %v", err) tlsConn.Close() return } libio.Join(tlsConn, rawConn) } func (p *TLS2RawPlugin) Name() string { return v1.PluginTLS2Raw } func (p *TLS2RawPlugin) Close() error { return nil } ================================================ FILE: pkg/plugin/client/unix_domain_socket.go ================================================ // Copyright 2017 fatedier, fatedier@gmail.com // // Licensed under the Apache License, Version 2.0 (the "License"); // you may not use this file except in compliance with the License. // You may obtain a copy of the License at // // http://www.apache.org/licenses/LICENSE-2.0 // // Unless required by applicable law or agreed to in writing, software // distributed under the License is distributed on an "AS IS" BASIS, // WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. // See the License for the specific language governing permissions and // limitations under the License. //go:build !frps package client import ( "context" "net" libio "github.com/fatedier/golib/io" v1 "github.com/fatedier/frp/pkg/config/v1" "github.com/fatedier/frp/pkg/util/xlog" ) func init() { Register(v1.PluginUnixDomainSocket, NewUnixDomainSocketPlugin) } type UnixDomainSocketPlugin struct { UnixAddr *net.UnixAddr } func NewUnixDomainSocketPlugin(_ PluginContext, options v1.ClientPluginOptions) (p Plugin, err error) { opts := options.(*v1.UnixDomainSocketPluginOptions) unixAddr, errRet := net.ResolveUnixAddr("unix", opts.UnixPath) if errRet != nil { err = errRet return } p = &UnixDomainSocketPlugin{ UnixAddr: unixAddr, } return } func (uds *UnixDomainSocketPlugin) Handle(ctx context.Context, connInfo *ConnectionInfo) { xl := xlog.FromContextSafe(ctx) localConn, err := net.DialUnix("unix", nil, uds.UnixAddr) if err != nil { xl.Warnf("dial to uds %s error: %v", uds.UnixAddr, err) connInfo.Conn.Close() return } if connInfo.ProxyProtocolHeader != nil { if _, err := connInfo.ProxyProtocolHeader.WriteTo(localConn); err != nil { localConn.Close() connInfo.Conn.Close() return } } libio.Join(localConn, connInfo.Conn) } func (uds *UnixDomainSocketPlugin) Name() string { return v1.PluginUnixDomainSocket } func (uds *UnixDomainSocketPlugin) Close() error { return nil } ================================================ FILE: pkg/plugin/client/virtual_net.go ================================================ // Copyright 2025 The frp Authors // // Licensed under the Apache License, Version 2.0 (the "License"); // you may not use this file except in compliance with the License. // You may obtain a copy of the License at // // http://www.apache.org/licenses/LICENSE-2.0 // // Unless required by applicable law or agreed to in writing, software // distributed under the License is distributed on an "AS IS" BASIS, // WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. // See the License for the specific language governing permissions and // limitations under the License. //go:build !frps package client import ( "context" "io" "sync" v1 "github.com/fatedier/frp/pkg/config/v1" ) func init() { Register(v1.PluginVirtualNet, NewVirtualNetPlugin) } type VirtualNetPlugin struct { pluginCtx PluginContext opts *v1.VirtualNetPluginOptions mu sync.Mutex conns map[io.ReadWriteCloser]struct{} } func NewVirtualNetPlugin(pluginCtx PluginContext, options v1.ClientPluginOptions) (Plugin, error) { opts := options.(*v1.VirtualNetPluginOptions) p := &VirtualNetPlugin{ pluginCtx: pluginCtx, opts: opts, } return p, nil } func (p *VirtualNetPlugin) Handle(ctx context.Context, connInfo *ConnectionInfo) { // Verify if virtual network controller is available if p.pluginCtx.VnetController == nil { return } // Add the connection before starting the read loop to avoid race condition // where RemoveConn might be called before the connection is added. p.mu.Lock() if p.conns == nil { p.conns = make(map[io.ReadWriteCloser]struct{}) } p.conns[connInfo.Conn] = struct{}{} p.mu.Unlock() // Register the connection with the controller and pass the cleanup function p.pluginCtx.VnetController.StartServerConnReadLoop(ctx, connInfo.Conn, func() { p.RemoveConn(connInfo.Conn) }) } func (p *VirtualNetPlugin) RemoveConn(conn io.ReadWriteCloser) { p.mu.Lock() defer p.mu.Unlock() // Check if the map exists, as Close might have set it to nil concurrently if p.conns != nil { delete(p.conns, conn) } } func (p *VirtualNetPlugin) Name() string { return v1.PluginVirtualNet } func (p *VirtualNetPlugin) Close() error { p.mu.Lock() defer p.mu.Unlock() // Close any remaining connections for conn := range p.conns { _ = conn.Close() } p.conns = nil return nil } ================================================ FILE: pkg/plugin/server/http.go ================================================ // Copyright 2019 fatedier, fatedier@gmail.com // // Licensed under the Apache License, Version 2.0 (the "License"); // you may not use this file except in compliance with the License. // You may obtain a copy of the License at // // http://www.apache.org/licenses/LICENSE-2.0 // // Unless required by applicable law or agreed to in writing, software // distributed under the License is distributed on an "AS IS" BASIS, // WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. // See the License for the specific language governing permissions and // limitations under the License. package server import ( "bytes" "context" "crypto/tls" "encoding/json" "fmt" "io" "net/http" "net/url" "reflect" "slices" "strings" v1 "github.com/fatedier/frp/pkg/config/v1" ) type httpPlugin struct { options v1.HTTPPluginOptions url string client *http.Client } func NewHTTPPluginOptions(options v1.HTTPPluginOptions) Plugin { url := fmt.Sprintf("%s%s", options.Addr, options.Path) var client *http.Client if strings.HasPrefix(url, "https://") { tr := &http.Transport{ TLSClientConfig: &tls.Config{InsecureSkipVerify: !options.TLSVerify}, } client = &http.Client{Transport: tr} } else { client = &http.Client{} } if !strings.HasPrefix(url, "https://") && !strings.HasPrefix(url, "http://") { url = "http://" + url } return &httpPlugin{ options: options, url: url, client: client, } } func (p *httpPlugin) Name() string { return p.options.Name } func (p *httpPlugin) IsSupport(op string) bool { return slices.Contains(p.options.Ops, op) } func (p *httpPlugin) Handle(ctx context.Context, op string, content any) (*Response, any, error) { r := &Request{ Version: APIVersion, Op: op, Content: content, } var res Response res.Content = reflect.New(reflect.TypeOf(content)).Interface() if err := p.do(ctx, r, &res); err != nil { return nil, nil, err } return &res, res.Content, nil } func (p *httpPlugin) do(ctx context.Context, r *Request, res *Response) error { buf, err := json.Marshal(r) if err != nil { return err } v := url.Values{} v.Set("version", r.Version) v.Set("op", r.Op) req, err := http.NewRequest("POST", p.url+"?"+v.Encode(), bytes.NewReader(buf)) if err != nil { return err } req = req.WithContext(ctx) req.Header.Set("X-Frp-Reqid", GetReqidFromContext(ctx)) req.Header.Set("Content-Type", "application/json") resp, err := p.client.Do(req) if err != nil { return err } defer resp.Body.Close() if resp.StatusCode != http.StatusOK { return fmt.Errorf("do http request error code: %d", resp.StatusCode) } buf, err = io.ReadAll(resp.Body) if err != nil { return err } return json.Unmarshal(buf, res) } ================================================ FILE: pkg/plugin/server/manager.go ================================================ // Copyright 2019 fatedier, fatedier@gmail.com // // Licensed under the Apache License, Version 2.0 (the "License"); // you may not use this file except in compliance with the License. // You may obtain a copy of the License at // // http://www.apache.org/licenses/LICENSE-2.0 // // Unless required by applicable law or agreed to in writing, software // distributed under the License is distributed on an "AS IS" BASIS, // WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. // See the License for the specific language governing permissions and // limitations under the License. package server import ( "context" "errors" "fmt" "strings" "github.com/fatedier/frp/pkg/util/util" "github.com/fatedier/frp/pkg/util/xlog" ) type Manager struct { loginPlugins []Plugin newProxyPlugins []Plugin closeProxyPlugins []Plugin pingPlugins []Plugin newWorkConnPlugins []Plugin newUserConnPlugins []Plugin } func NewManager() *Manager { return &Manager{ loginPlugins: make([]Plugin, 0), newProxyPlugins: make([]Plugin, 0), closeProxyPlugins: make([]Plugin, 0), pingPlugins: make([]Plugin, 0), newWorkConnPlugins: make([]Plugin, 0), newUserConnPlugins: make([]Plugin, 0), } } func (m *Manager) Register(p Plugin) { if p.IsSupport(OpLogin) { m.loginPlugins = append(m.loginPlugins, p) } if p.IsSupport(OpNewProxy) { m.newProxyPlugins = append(m.newProxyPlugins, p) } if p.IsSupport(OpCloseProxy) { m.closeProxyPlugins = append(m.closeProxyPlugins, p) } if p.IsSupport(OpPing) { m.pingPlugins = append(m.pingPlugins, p) } if p.IsSupport(OpNewWorkConn) { m.newWorkConnPlugins = append(m.newWorkConnPlugins, p) } if p.IsSupport(OpNewUserConn) { m.newUserConnPlugins = append(m.newUserConnPlugins, p) } } func (m *Manager) Login(content *LoginContent) (*LoginContent, error) { if len(m.loginPlugins) == 0 { return content, nil } var ( res = &Response{ Reject: false, Unchange: true, } retContent any err error ) reqid, _ := util.RandID() xl := xlog.New().AppendPrefix("reqid: " + reqid) ctx := xlog.NewContext(context.Background(), xl) ctx = NewReqidContext(ctx, reqid) for _, p := range m.loginPlugins { res, retContent, err = p.Handle(ctx, OpLogin, *content) if err != nil { xl.Warnf("send Login request to plugin [%s] error: %v", p.Name(), err) return nil, errors.New("send Login request to plugin error") } if res.Reject { return nil, fmt.Errorf("%s", res.RejectReason) } if !res.Unchange { content = retContent.(*LoginContent) } } return content, nil } func (m *Manager) NewProxy(content *NewProxyContent) (*NewProxyContent, error) { if len(m.newProxyPlugins) == 0 { return content, nil } var ( res = &Response{ Reject: false, Unchange: true, } retContent any err error ) reqid, _ := util.RandID() xl := xlog.New().AppendPrefix("reqid: " + reqid) ctx := xlog.NewContext(context.Background(), xl) ctx = NewReqidContext(ctx, reqid) for _, p := range m.newProxyPlugins { res, retContent, err = p.Handle(ctx, OpNewProxy, *content) if err != nil { xl.Warnf("send NewProxy request to plugin [%s] error: %v", p.Name(), err) return nil, errors.New("send NewProxy request to plugin error") } if res.Reject { return nil, fmt.Errorf("%s", res.RejectReason) } if !res.Unchange { content = retContent.(*NewProxyContent) } } return content, nil } func (m *Manager) CloseProxy(content *CloseProxyContent) error { if len(m.closeProxyPlugins) == 0 { return nil } errs := make([]string, 0) reqid, _ := util.RandID() xl := xlog.New().AppendPrefix("reqid: " + reqid) ctx := xlog.NewContext(context.Background(), xl) ctx = NewReqidContext(ctx, reqid) for _, p := range m.closeProxyPlugins { _, _, err := p.Handle(ctx, OpCloseProxy, *content) if err != nil { xl.Warnf("send CloseProxy request to plugin [%s] error: %v", p.Name(), err) errs = append(errs, fmt.Sprintf("[%s]: %v", p.Name(), err)) } } if len(errs) > 0 { return fmt.Errorf("send CloseProxy request to plugin errors: %s", strings.Join(errs, "; ")) } return nil } func (m *Manager) Ping(content *PingContent) (*PingContent, error) { if len(m.pingPlugins) == 0 { return content, nil } var ( res = &Response{ Reject: false, Unchange: true, } retContent any err error ) reqid, _ := util.RandID() xl := xlog.New().AppendPrefix("reqid: " + reqid) ctx := xlog.NewContext(context.Background(), xl) ctx = NewReqidContext(ctx, reqid) for _, p := range m.pingPlugins { res, retContent, err = p.Handle(ctx, OpPing, *content) if err != nil { xl.Warnf("send Ping request to plugin [%s] error: %v", p.Name(), err) return nil, errors.New("send Ping request to plugin error") } if res.Reject { return nil, fmt.Errorf("%s", res.RejectReason) } if !res.Unchange { content = retContent.(*PingContent) } } return content, nil } func (m *Manager) NewWorkConn(content *NewWorkConnContent) (*NewWorkConnContent, error) { if len(m.newWorkConnPlugins) == 0 { return content, nil } var ( res = &Response{ Reject: false, Unchange: true, } retContent any err error ) reqid, _ := util.RandID() xl := xlog.New().AppendPrefix("reqid: " + reqid) ctx := xlog.NewContext(context.Background(), xl) ctx = NewReqidContext(ctx, reqid) for _, p := range m.newWorkConnPlugins { res, retContent, err = p.Handle(ctx, OpNewWorkConn, *content) if err != nil { xl.Warnf("send NewWorkConn request to plugin [%s] error: %v", p.Name(), err) return nil, errors.New("send NewWorkConn request to plugin error") } if res.Reject { return nil, fmt.Errorf("%s", res.RejectReason) } if !res.Unchange { content = retContent.(*NewWorkConnContent) } } return content, nil } func (m *Manager) NewUserConn(content *NewUserConnContent) (*NewUserConnContent, error) { if len(m.newUserConnPlugins) == 0 { return content, nil } var ( res = &Response{ Reject: false, Unchange: true, } retContent any err error ) reqid, _ := util.RandID() xl := xlog.New().AppendPrefix("reqid: " + reqid) ctx := xlog.NewContext(context.Background(), xl) ctx = NewReqidContext(ctx, reqid) for _, p := range m.newUserConnPlugins { res, retContent, err = p.Handle(ctx, OpNewUserConn, *content) if err != nil { xl.Infof("send NewUserConn request to plugin [%s] error: %v", p.Name(), err) return nil, errors.New("send NewUserConn request to plugin error") } if res.Reject { return nil, fmt.Errorf("%s", res.RejectReason) } if !res.Unchange { content = retContent.(*NewUserConnContent) } } return content, nil } ================================================ FILE: pkg/plugin/server/plugin.go ================================================ // Copyright 2019 fatedier, fatedier@gmail.com // // Licensed under the Apache License, Version 2.0 (the "License"); // you may not use this file except in compliance with the License. // You may obtain a copy of the License at // // http://www.apache.org/licenses/LICENSE-2.0 // // Unless required by applicable law or agreed to in writing, software // distributed under the License is distributed on an "AS IS" BASIS, // WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. // See the License for the specific language governing permissions and // limitations under the License. package server import ( "context" ) const ( APIVersion = "0.1.0" OpLogin = "Login" OpNewProxy = "NewProxy" OpCloseProxy = "CloseProxy" OpPing = "Ping" OpNewWorkConn = "NewWorkConn" OpNewUserConn = "NewUserConn" ) type Plugin interface { Name() string IsSupport(op string) bool Handle(ctx context.Context, op string, content any) (res *Response, retContent any, err error) } ================================================ FILE: pkg/plugin/server/tracer.go ================================================ // Copyright 2019 fatedier, fatedier@gmail.com // // Licensed under the Apache License, Version 2.0 (the "License"); // you may not use this file except in compliance with the License. // You may obtain a copy of the License at // // http://www.apache.org/licenses/LICENSE-2.0 // // Unless required by applicable law or agreed to in writing, software // distributed under the License is distributed on an "AS IS" BASIS, // WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. // See the License for the specific language governing permissions and // limitations under the License. package server import ( "context" ) type key int const ( reqidKey key = 0 ) func NewReqidContext(ctx context.Context, reqid string) context.Context { return context.WithValue(ctx, reqidKey, reqid) } func GetReqidFromContext(ctx context.Context) string { ret, _ := ctx.Value(reqidKey).(string) return ret } ================================================ FILE: pkg/plugin/server/types.go ================================================ // Copyright 2019 fatedier, fatedier@gmail.com // // Licensed under the Apache License, Version 2.0 (the "License"); // you may not use this file except in compliance with the License. // You may obtain a copy of the License at // // http://www.apache.org/licenses/LICENSE-2.0 // // Unless required by applicable law or agreed to in writing, software // distributed under the License is distributed on an "AS IS" BASIS, // WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. // See the License for the specific language governing permissions and // limitations under the License. package server import ( "github.com/fatedier/frp/pkg/msg" ) type Request struct { Version string `json:"version"` Op string `json:"op"` Content any `json:"content"` } type Response struct { Reject bool `json:"reject"` RejectReason string `json:"reject_reason"` Unchange bool `json:"unchange"` Content any `json:"content"` } type LoginContent struct { msg.Login ClientAddress string `json:"client_address,omitempty"` } type UserInfo struct { User string `json:"user"` Metas map[string]string `json:"metas"` RunID string `json:"run_id"` } type NewProxyContent struct { User UserInfo `json:"user"` msg.NewProxy } type CloseProxyContent struct { User UserInfo `json:"user"` msg.CloseProxy } type PingContent struct { User UserInfo `json:"user"` msg.Ping } type NewWorkConnContent struct { User UserInfo `json:"user"` msg.NewWorkConn } type NewUserConnContent struct { User UserInfo `json:"user"` ProxyName string `json:"proxy_name"` ProxyType string `json:"proxy_type"` RemoteAddr string `json:"remote_addr"` } ================================================ FILE: pkg/plugin/visitor/plugin.go ================================================ // Copyright 2025 The frp Authors // // Licensed under the Apache License, Version 2.0 (the "License"); // you may not use this file except in compliance with the License. // You may obtain a copy of the License at // // http://www.apache.org/licenses/LICENSE-2.0 // // Unless required by applicable law or agreed to in writing, software // distributed under the License is distributed on an "AS IS" BASIS, // WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. // See the License for the specific language governing permissions and // limitations under the License. package visitor import ( "context" "fmt" "net" v1 "github.com/fatedier/frp/pkg/config/v1" "github.com/fatedier/frp/pkg/vnet" ) // PluginContext provides the necessary context and callbacks for visitor plugins. type PluginContext struct { // Name is the unique identifier for this visitor, used for logging and routing. Name string // Ctx manages the plugin's lifecycle and carries the logger for structured logging. Ctx context.Context // VnetController manages TUN device routing. May be nil if virtual networking is disabled. VnetController *vnet.Controller // SendConnToVisitor sends a connection to the visitor's internal processing queue. // Does not return error; failures are handled by closing the connection. SendConnToVisitor func(net.Conn) } // Creators is used for create plugins to handle connections. var creators = make(map[string]CreatorFn) type CreatorFn func(pluginCtx PluginContext, options v1.VisitorPluginOptions) (Plugin, error) func Register(name string, fn CreatorFn) { if _, exist := creators[name]; exist { panic(fmt.Sprintf("plugin [%s] is already registered", name)) } creators[name] = fn } func Create(pluginName string, pluginCtx PluginContext, options v1.VisitorPluginOptions) (p Plugin, err error) { if fn, ok := creators[pluginName]; ok { p, err = fn(pluginCtx, options) } else { err = fmt.Errorf("plugin [%s] is not registered", pluginName) } return } type Plugin interface { Name() string Start() Close() error } ================================================ FILE: pkg/plugin/visitor/virtual_net.go ================================================ // Copyright 2025 The frp Authors // // Licensed under the Apache License, Version 2.0 (the "License"); // you may not use this file except in compliance with the License. // You may obtain a copy of the License at // // http://www.apache.org/licenses/LICENSE-2.0 // // Unless required by applicable law or agreed to in writing, software // distributed under the License is distributed on an "AS IS" BASIS, // WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. // See the License for the specific language governing permissions and // limitations under the License. //go:build !frps package visitor import ( "context" "errors" "fmt" "net" "sync" "time" v1 "github.com/fatedier/frp/pkg/config/v1" netutil "github.com/fatedier/frp/pkg/util/net" "github.com/fatedier/frp/pkg/util/xlog" ) func init() { Register(v1.VisitorPluginVirtualNet, NewVirtualNetPlugin) } type VirtualNetPlugin struct { pluginCtx PluginContext routes []net.IPNet mu sync.Mutex controllerConn net.Conn closeSignal chan struct{} consecutiveErrors int // Tracks consecutive connection errors for exponential backoff ctx context.Context cancel context.CancelFunc } func NewVirtualNetPlugin(pluginCtx PluginContext, options v1.VisitorPluginOptions) (Plugin, error) { opts := options.(*v1.VirtualNetVisitorPluginOptions) p := &VirtualNetPlugin{ pluginCtx: pluginCtx, routes: make([]net.IPNet, 0), } p.ctx, p.cancel = context.WithCancel(pluginCtx.Ctx) if opts.DestinationIP == "" { return nil, errors.New("destinationIP is required") } // Parse DestinationIP and create a host route. ip := net.ParseIP(opts.DestinationIP) if ip == nil { return nil, fmt.Errorf("invalid destination IP address [%s]", opts.DestinationIP) } var mask net.IPMask if ip.To4() != nil { mask = net.CIDRMask(32, 32) // /32 for IPv4 } else { mask = net.CIDRMask(128, 128) // /128 for IPv6 } p.routes = append(p.routes, net.IPNet{IP: ip, Mask: mask}) return p, nil } func (p *VirtualNetPlugin) Name() string { return v1.VisitorPluginVirtualNet } func (p *VirtualNetPlugin) Start() { xl := xlog.FromContextSafe(p.pluginCtx.Ctx) if p.pluginCtx.VnetController == nil { return } routeStr := "unknown" if len(p.routes) > 0 { routeStr = p.routes[0].String() } xl.Infof("starting VirtualNetPlugin for visitor [%s], attempting to register routes for %s", p.pluginCtx.Name, routeStr) go p.run() } func (p *VirtualNetPlugin) run() { xl := xlog.FromContextSafe(p.ctx) for { currentCloseSignal := make(chan struct{}) p.mu.Lock() p.closeSignal = currentCloseSignal p.mu.Unlock() select { case <-p.ctx.Done(): xl.Infof("VirtualNetPlugin run loop for visitor [%s] stopping (context cancelled before pipe creation).", p.pluginCtx.Name) p.cleanupControllerConn(xl) return default: } controllerConn, pluginConn := net.Pipe() p.mu.Lock() p.controllerConn = controllerConn p.mu.Unlock() // Wrap with CloseNotifyConn which supports both close notification and error recording var closeErr error pluginNotifyConn := netutil.WrapCloseNotifyConn(pluginConn, func(err error) { closeErr = err close(currentCloseSignal) // Signal the run loop on close. }) xl.Infof("attempting to register client route for visitor [%s]", p.pluginCtx.Name) p.pluginCtx.VnetController.RegisterClientRoute(p.ctx, p.pluginCtx.Name, p.routes, controllerConn) xl.Infof("successfully registered client route for visitor [%s]. Starting connection handler with CloseNotifyConn.", p.pluginCtx.Name) // Pass the CloseNotifyConn to the visitor for handling. // The visitor can call CloseWithError to record the failure reason. p.pluginCtx.SendConnToVisitor(pluginNotifyConn) // Wait for context cancellation or connection close. select { case <-p.ctx.Done(): xl.Infof("VirtualNetPlugin run loop stopping for visitor [%s] (context cancelled while waiting).", p.pluginCtx.Name) p.cleanupControllerConn(xl) return case <-currentCloseSignal: // Determine reconnect delay based on error with exponential backoff var reconnectDelay time.Duration if closeErr != nil { p.consecutiveErrors++ xl.Warnf("connection closed with error for visitor [%s] (consecutive errors: %d): %v", p.pluginCtx.Name, p.consecutiveErrors, closeErr) // Exponential backoff: 60s, 120s, 240s, 300s (capped) baseDelay := 60 * time.Second reconnectDelay = min(baseDelay*time.Duration(1< 0 { xl.Infof("connection closed normally for visitor [%s], resetting error counter (was %d)", p.pluginCtx.Name, p.consecutiveErrors) p.consecutiveErrors = 0 } else { xl.Infof("connection closed normally for visitor [%s]", p.pluginCtx.Name) } reconnectDelay = 10 * time.Second } // The visitor closed the plugin side. Close the controller side. p.cleanupControllerConn(xl) xl.Infof("waiting %v before attempting reconnection for visitor [%s]...", reconnectDelay, p.pluginCtx.Name) select { case <-time.After(reconnectDelay): case <-p.ctx.Done(): xl.Infof("VirtualNetPlugin reconnection delay interrupted for visitor [%s]", p.pluginCtx.Name) return } } xl.Infof("re-establishing virtual connection for visitor [%s]...", p.pluginCtx.Name) } } // cleanupControllerConn closes the current controllerConn (if it exists) under lock. func (p *VirtualNetPlugin) cleanupControllerConn(xl *xlog.Logger) { p.mu.Lock() defer p.mu.Unlock() if p.controllerConn != nil { xl.Debugf("cleaning up controllerConn for visitor [%s]", p.pluginCtx.Name) p.controllerConn.Close() p.controllerConn = nil } p.closeSignal = nil } // Close initiates the plugin shutdown. func (p *VirtualNetPlugin) Close() error { xl := xlog.FromContextSafe(p.pluginCtx.Ctx) xl.Infof("closing VirtualNetPlugin for visitor [%s]", p.pluginCtx.Name) // Signal the run loop goroutine to stop. p.cancel() // Unregister the route from the controller. if p.pluginCtx.VnetController != nil { p.pluginCtx.VnetController.UnregisterClientRoute(p.pluginCtx.Name) xl.Infof("unregistered client route for visitor [%s]", p.pluginCtx.Name) } // Explicitly close the controller side of the pipe. // This ensures the pipe is broken even if the run loop is stuck or the visitor hasn't closed its end. p.cleanupControllerConn(xl) xl.Infof("finished cleaning up connections during close for visitor [%s]", p.pluginCtx.Name) return nil } ================================================ FILE: pkg/policy/featuregate/feature_gate.go ================================================ // Copyright 2025 The frp Authors // // Licensed under the Apache License, Version 2.0 (the "License"); // you may not use this file except in compliance with the License. // You may obtain a copy of the License at // // http://www.apache.org/licenses/LICENSE-2.0 // // Unless required by applicable law or agreed to in writing, software // distributed under the License is distributed on an "AS IS" BASIS, // WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. // See the License for the specific language governing permissions and // limitations under the License. package featuregate import ( "fmt" "maps" "sort" "strings" "sync" "sync/atomic" ) // Feature represents a feature gate name type Feature string // FeatureStage represents the maturity level of a feature type FeatureStage string const ( // Alpha means the feature is experimental and disabled by default Alpha FeatureStage = "ALPHA" // Beta means the feature is more stable but still might change and is disabled by default Beta FeatureStage = "BETA" // GA means the feature is generally available and enabled by default GA FeatureStage = "" ) // FeatureSpec describes a feature and its properties type FeatureSpec struct { // Default is the default enablement state for the feature Default bool // LockToDefault indicates the feature cannot be changed from its default LockToDefault bool // Stage indicates the maturity level of the feature Stage FeatureStage } // Define all available features here var ( VirtualNet = Feature("VirtualNet") ) // defaultFeatures defines default features with their specifications var defaultFeatures = map[Feature]FeatureSpec{ // Actual features VirtualNet: {Default: false, Stage: Alpha}, } // FeatureGate indicates whether a given feature is enabled or not type FeatureGate interface { // Enabled returns true if the key is enabled Enabled(key Feature) bool // KnownFeatures returns a slice of strings describing the known features KnownFeatures() []string } // MutableFeatureGate allows for dynamic feature gate configuration type MutableFeatureGate interface { FeatureGate // SetFromMap sets feature gate values from a map[string]bool SetFromMap(m map[string]bool) error // Add adds features to the feature gate Add(features map[Feature]FeatureSpec) error // String returns a string representing the feature gate configuration String() string } // featureGate implements the FeatureGate and MutableFeatureGate interfaces type featureGate struct { // lock guards writes to known, enabled, and reads/writes of closed lock sync.Mutex // known holds a map[Feature]FeatureSpec known atomic.Value // enabled holds a map[Feature]bool enabled atomic.Value // closed is set to true once the feature gates are considered immutable closed bool } // NewFeatureGate creates a new feature gate with the default features func NewFeatureGate() MutableFeatureGate { known := maps.Clone(defaultFeatures) f := &featureGate{} f.known.Store(known) f.enabled.Store(map[Feature]bool{}) return f } // SetFromMap sets feature gate values from a map[string]bool func (f *featureGate) SetFromMap(m map[string]bool) error { f.lock.Lock() defer f.lock.Unlock() // Copy existing state known := maps.Clone(f.known.Load().(map[Feature]FeatureSpec)) enabled := maps.Clone(f.enabled.Load().(map[Feature]bool)) // Apply the new settings for k, v := range m { k := Feature(k) featureSpec, ok := known[k] if !ok { return fmt.Errorf("unrecognized feature gate: %s", k) } if featureSpec.LockToDefault && featureSpec.Default != v { return fmt.Errorf("cannot set feature gate %v to %v, feature is locked to %v", k, v, featureSpec.Default) } enabled[k] = v } // Persist the changes f.known.Store(known) f.enabled.Store(enabled) return nil } // Add adds features to the feature gate func (f *featureGate) Add(features map[Feature]FeatureSpec) error { f.lock.Lock() defer f.lock.Unlock() if f.closed { return fmt.Errorf("cannot add feature gates after the feature gate is closed") } // Copy existing state known := maps.Clone(f.known.Load().(map[Feature]FeatureSpec)) // Add new features for name, spec := range features { if existingSpec, found := known[name]; found { if existingSpec == spec { continue } return fmt.Errorf("feature gate %q with different spec already exists: %v", name, existingSpec) } known[name] = spec } // Persist changes f.known.Store(known) return nil } // String returns a string containing all enabled feature gates, formatted as "key1=value1,key2=value2,..." func (f *featureGate) String() string { enabled := f.enabled.Load().(map[Feature]bool) pairs := make([]string, 0, len(enabled)) for k, v := range enabled { pairs = append(pairs, fmt.Sprintf("%s=%t", k, v)) } sort.Strings(pairs) return strings.Join(pairs, ",") } // Enabled returns true if the key is enabled func (f *featureGate) Enabled(key Feature) bool { if v, ok := f.enabled.Load().(map[Feature]bool)[key]; ok { return v } if v, ok := f.known.Load().(map[Feature]FeatureSpec)[key]; ok { return v.Default } return false } // KnownFeatures returns a slice of strings describing the FeatureGate's known features // GA features are hidden from the list func (f *featureGate) KnownFeatures() []string { knownFeatures := f.known.Load().(map[Feature]FeatureSpec) known := make([]string, 0, len(knownFeatures)) for k, v := range knownFeatures { if v.Stage == GA { continue } known = append(known, fmt.Sprintf("%s=true|false (%s - default=%t)", k, v.Stage, v.Default)) } sort.Strings(known) return known } // Default feature gates instance var DefaultFeatureGates = NewFeatureGate() // Enabled checks if a feature is enabled in the default feature gates func Enabled(name Feature) bool { return DefaultFeatureGates.Enabled(name) } // SetFromMap sets feature gate values from a map in the default feature gates func SetFromMap(featureMap map[string]bool) error { return DefaultFeatureGates.SetFromMap(featureMap) } ================================================ FILE: pkg/policy/security/unsafe.go ================================================ package security const ( TokenSourceExec = "TokenSourceExec" ) var ( ClientUnsafeFeatures = []string{ TokenSourceExec, } ServerUnsafeFeatures = []string{ TokenSourceExec, } ) type UnsafeFeatures struct { features map[string]bool } func NewUnsafeFeatures(allowed []string) *UnsafeFeatures { features := make(map[string]bool) for _, f := range allowed { features[f] = true } return &UnsafeFeatures{features: features} } func (u *UnsafeFeatures) IsEnabled(feature string) bool { if u == nil { return false } return u.features[feature] } ================================================ FILE: pkg/proto/udp/udp.go ================================================ // Copyright 2017 fatedier, fatedier@gmail.com // // Licensed under the Apache License, Version 2.0 (the "License"); // you may not use this file except in compliance with the License. // You may obtain a copy of the License at // // http://www.apache.org/licenses/LICENSE-2.0 // // Unless required by applicable law or agreed to in writing, software // distributed under the License is distributed on an "AS IS" BASIS, // WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. // See the License for the specific language governing permissions and // limitations under the License. package udp import ( "net" "sync" "time" "github.com/fatedier/golib/errors" "github.com/fatedier/golib/pool" "github.com/fatedier/frp/pkg/msg" netpkg "github.com/fatedier/frp/pkg/util/net" ) func NewUDPPacket(buf []byte, laddr, raddr *net.UDPAddr) *msg.UDPPacket { content := make([]byte, len(buf)) copy(content, buf) return &msg.UDPPacket{ Content: content, LocalAddr: laddr, RemoteAddr: raddr, } } func GetContent(m *msg.UDPPacket) (buf []byte, err error) { return m.Content, nil } func ForwardUserConn(udpConn *net.UDPConn, readCh <-chan *msg.UDPPacket, sendCh chan<- *msg.UDPPacket, bufSize int) { // read go func() { for udpMsg := range readCh { buf, err := GetContent(udpMsg) if err != nil { continue } _, _ = udpConn.WriteToUDP(buf, udpMsg.RemoteAddr) } }() // write buf := pool.GetBuf(bufSize) defer pool.PutBuf(buf) for { n, remoteAddr, err := udpConn.ReadFromUDP(buf) if err != nil { return } // NewUDPPacket copies buf[:n], so the read buffer can be reused udpMsg := NewUDPPacket(buf[:n], nil, remoteAddr) select { case sendCh <- udpMsg: default: } } } func Forwarder(dstAddr *net.UDPAddr, readCh <-chan *msg.UDPPacket, sendCh chan<- msg.Message, bufSize int, proxyProtocolVersion string) { var mu sync.RWMutex udpConnMap := make(map[string]*net.UDPConn) // read from dstAddr and write to sendCh writerFn := func(raddr *net.UDPAddr, udpConn *net.UDPConn) { addr := raddr.String() defer func() { mu.Lock() delete(udpConnMap, addr) mu.Unlock() udpConn.Close() }() buf := pool.GetBuf(bufSize) defer pool.PutBuf(buf) for { _ = udpConn.SetReadDeadline(time.Now().Add(30 * time.Second)) n, _, err := udpConn.ReadFromUDP(buf) if err != nil { return } udpMsg := NewUDPPacket(buf[:n], nil, raddr) if err = errors.PanicToError(func() { select { case sendCh <- udpMsg: default: } }); err != nil { return } } } // read from readCh go func() { for udpMsg := range readCh { buf, err := GetContent(udpMsg) if err != nil { continue } mu.Lock() udpConn, ok := udpConnMap[udpMsg.RemoteAddr.String()] if !ok { udpConn, err = net.DialUDP("udp", nil, dstAddr) if err != nil { mu.Unlock() continue } udpConnMap[udpMsg.RemoteAddr.String()] = udpConn } mu.Unlock() // Add proxy protocol header if configured (only for the first packet of a new connection) if !ok && proxyProtocolVersion != "" && udpMsg.RemoteAddr != nil { ppBuf, err := netpkg.BuildProxyProtocolHeader(udpMsg.RemoteAddr, dstAddr, proxyProtocolVersion) if err == nil { // Prepend proxy protocol header to the UDP payload finalBuf := make([]byte, len(ppBuf)+len(buf)) copy(finalBuf, ppBuf) copy(finalBuf[len(ppBuf):], buf) buf = finalBuf } } _, err = udpConn.Write(buf) if err != nil { udpConn.Close() } if !ok { go writerFn(udpMsg.RemoteAddr, udpConn) } } }() } ================================================ FILE: pkg/proto/udp/udp_test.go ================================================ package udp import ( "testing" "github.com/stretchr/testify/require" ) func TestUdpPacket(t *testing.T) { require := require.New(t) buf := []byte("hello world") udpMsg := NewUDPPacket(buf, nil, nil) newBuf, err := GetContent(udpMsg) require.NoError(err) require.EqualValues(buf, newBuf) } ================================================ FILE: pkg/sdk/client/client.go ================================================ package client import ( "context" "encoding/json" "fmt" "io" "net" "net/http" "net/url" "strconv" "strings" "github.com/fatedier/frp/client/http/model" httppkg "github.com/fatedier/frp/pkg/util/http" ) type Client struct { address string authUser string authPwd string } func New(host string, port int) *Client { return &Client{ address: net.JoinHostPort(host, strconv.Itoa(port)), } } func (c *Client) SetAuth(user, pwd string) { c.authUser = user c.authPwd = pwd } func (c *Client) GetProxyStatus(ctx context.Context, name string) (*model.ProxyStatusResp, error) { req, err := http.NewRequestWithContext(ctx, "GET", "http://"+c.address+"/api/status", nil) if err != nil { return nil, err } content, err := c.do(req) if err != nil { return nil, err } allStatus := make(model.StatusResp) if err = json.Unmarshal([]byte(content), &allStatus); err != nil { return nil, fmt.Errorf("unmarshal http response error: %s", strings.TrimSpace(content)) } for _, pss := range allStatus { for _, ps := range pss { if ps.Name == name { return &ps, nil } } } return nil, fmt.Errorf("no proxy status found") } func (c *Client) GetAllProxyStatus(ctx context.Context) (model.StatusResp, error) { req, err := http.NewRequestWithContext(ctx, "GET", "http://"+c.address+"/api/status", nil) if err != nil { return nil, err } content, err := c.do(req) if err != nil { return nil, err } allStatus := make(model.StatusResp) if err = json.Unmarshal([]byte(content), &allStatus); err != nil { return nil, fmt.Errorf("unmarshal http response error: %s", strings.TrimSpace(content)) } return allStatus, nil } func (c *Client) Reload(ctx context.Context, strictMode bool) error { v := url.Values{} if strictMode { v.Set("strictConfig", "true") } queryStr := "" if len(v) > 0 { queryStr = "?" + v.Encode() } req, err := http.NewRequestWithContext(ctx, "GET", "http://"+c.address+"/api/reload"+queryStr, nil) if err != nil { return err } _, err = c.do(req) return err } func (c *Client) Stop(ctx context.Context) error { req, err := http.NewRequestWithContext(ctx, "POST", "http://"+c.address+"/api/stop", nil) if err != nil { return err } _, err = c.do(req) return err } func (c *Client) GetConfig(ctx context.Context) (string, error) { req, err := http.NewRequestWithContext(ctx, "GET", "http://"+c.address+"/api/config", nil) if err != nil { return "", err } return c.do(req) } func (c *Client) UpdateConfig(ctx context.Context, content string) error { req, err := http.NewRequestWithContext(ctx, "PUT", "http://"+c.address+"/api/config", strings.NewReader(content)) if err != nil { return err } _, err = c.do(req) return err } func (c *Client) setAuthHeader(req *http.Request) { if c.authUser != "" || c.authPwd != "" { req.Header.Set("Authorization", httppkg.BasicAuth(c.authUser, c.authPwd)) } } func (c *Client) do(req *http.Request) (string, error) { c.setAuthHeader(req) resp, err := http.DefaultClient.Do(req) if err != nil { return "", err } defer resp.Body.Close() if resp.StatusCode != 200 { return "", fmt.Errorf("api status code [%d]", resp.StatusCode) } buf, err := io.ReadAll(resp.Body) if err != nil { return "", err } return string(buf), nil } ================================================ FILE: pkg/ssh/gateway.go ================================================ // Copyright 2023 The frp Authors // // Licensed under the Apache License, Version 2.0 (the "License"); // you may not use this file except in compliance with the License. // You may obtain a copy of the License at // // http://www.apache.org/licenses/LICENSE-2.0 // // Unless required by applicable law or agreed to in writing, software // distributed under the License is distributed on an "AS IS" BASIS, // WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. // See the License for the specific language governing permissions and // limitations under the License. package ssh import ( "fmt" "net" "os" "strconv" "strings" "golang.org/x/crypto/ssh" v1 "github.com/fatedier/frp/pkg/config/v1" "github.com/fatedier/frp/pkg/transport" "github.com/fatedier/frp/pkg/util/log" netpkg "github.com/fatedier/frp/pkg/util/net" ) type Gateway struct { bindPort int ln net.Listener peerServerListener *netpkg.InternalListener sshConfig *ssh.ServerConfig } func NewGateway( cfg v1.SSHTunnelGateway, bindAddr string, peerServerListener *netpkg.InternalListener, ) (*Gateway, error) { sshConfig := &ssh.ServerConfig{} // privateKey var ( privateKeyBytes []byte err error ) if cfg.PrivateKeyFile != "" { privateKeyBytes, err = os.ReadFile(cfg.PrivateKeyFile) } else { if cfg.AutoGenPrivateKeyPath != "" { privateKeyBytes, _ = os.ReadFile(cfg.AutoGenPrivateKeyPath) } if len(privateKeyBytes) == 0 { privateKeyBytes, err = transport.NewRandomPrivateKey() if err == nil && cfg.AutoGenPrivateKeyPath != "" { err = os.WriteFile(cfg.AutoGenPrivateKeyPath, privateKeyBytes, 0o600) } } } if err != nil { return nil, err } privateKey, err := ssh.ParsePrivateKey(privateKeyBytes) if err != nil { return nil, err } sshConfig.AddHostKey(privateKey) sshConfig.NoClientAuth = cfg.AuthorizedKeysFile == "" sshConfig.PublicKeyCallback = func(conn ssh.ConnMetadata, key ssh.PublicKey) (*ssh.Permissions, error) { authorizedKeysMap, err := loadAuthorizedKeysFromFile(cfg.AuthorizedKeysFile) if err != nil { log.Errorf("load authorized keys file error: %v", err) return nil, fmt.Errorf("internal error") } user, ok := authorizedKeysMap[string(key.Marshal())] if !ok { return nil, fmt.Errorf("unknown public key for remoteAddr %q", conn.RemoteAddr()) } return &ssh.Permissions{ Extensions: map[string]string{ "user": user, }, }, nil } ln, err := net.Listen("tcp", net.JoinHostPort(bindAddr, strconv.Itoa(cfg.BindPort))) if err != nil { return nil, err } return &Gateway{ bindPort: cfg.BindPort, ln: ln, peerServerListener: peerServerListener, sshConfig: sshConfig, }, nil } func (g *Gateway) Run() { for { conn, err := g.ln.Accept() if err != nil { return } go g.handleConn(conn) } } func (g *Gateway) Close() error { return g.ln.Close() } func (g *Gateway) handleConn(conn net.Conn) { defer conn.Close() ts, err := NewTunnelServer(conn, g.sshConfig, g.peerServerListener) if err != nil { return } if err := ts.Run(); err != nil { log.Errorf("ssh tunnel server run error: %v", err) } } func loadAuthorizedKeysFromFile(path string) (map[string]string, error) { authorizedKeysMap := make(map[string]string) // value is username authorizedKeysBytes, err := os.ReadFile(path) if err != nil { return nil, err } for len(authorizedKeysBytes) > 0 { pubKey, comment, _, rest, err := ssh.ParseAuthorizedKey(authorizedKeysBytes) if err != nil { return nil, err } authorizedKeysMap[string(pubKey.Marshal())] = strings.TrimSpace(comment) authorizedKeysBytes = rest } return authorizedKeysMap, nil } ================================================ FILE: pkg/ssh/server.go ================================================ // Copyright 2023 The frp Authors // // Licensed under the Apache License, Version 2.0 (the "License"); // you may not use this file except in compliance with the License. // You may obtain a copy of the License at // // http://www.apache.org/licenses/LICENSE-2.0 // // Unless required by applicable law or agreed to in writing, software // distributed under the License is distributed on an "AS IS" BASIS, // WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. // See the License for the specific language governing permissions and // limitations under the License. package ssh import ( "context" "encoding/binary" "errors" "fmt" "net" "slices" "strings" "sync" "time" libio "github.com/fatedier/golib/io" "github.com/spf13/cobra" flag "github.com/spf13/pflag" "golang.org/x/crypto/ssh" "github.com/fatedier/frp/client/proxy" "github.com/fatedier/frp/pkg/config" v1 "github.com/fatedier/frp/pkg/config/v1" "github.com/fatedier/frp/pkg/msg" "github.com/fatedier/frp/pkg/util/log" netpkg "github.com/fatedier/frp/pkg/util/net" "github.com/fatedier/frp/pkg/util/util" "github.com/fatedier/frp/pkg/util/xlog" "github.com/fatedier/frp/pkg/virtual" ) const ( // https://datatracker.ietf.org/doc/html/rfc4254#page-16 ChannelTypeServerOpenChannel = "forwarded-tcpip" RequestTypeForward = "tcpip-forward" ) type tcpipForward struct { Host string Port uint32 } // https://datatracker.ietf.org/doc/html/rfc4254#page-16 type forwardedTCPPayload struct { Addr string Port uint32 OriginAddr string OriginPort uint32 } type TunnelServer struct { underlyingConn net.Conn sshConn *ssh.ServerConn sc *ssh.ServerConfig firstChannel ssh.Channel vc *virtual.Client peerServerListener *netpkg.InternalListener doneCh chan struct{} closeDoneChOnce sync.Once } func NewTunnelServer(conn net.Conn, sc *ssh.ServerConfig, peerServerListener *netpkg.InternalListener) (*TunnelServer, error) { s := &TunnelServer{ underlyingConn: conn, sc: sc, peerServerListener: peerServerListener, doneCh: make(chan struct{}), } return s, nil } func (s *TunnelServer) Run() error { sshConn, channels, requests, err := ssh.NewServerConn(s.underlyingConn, s.sc) if err != nil { return err } s.sshConn = sshConn addr, extraPayload, err := s.waitForwardAddrAndExtraPayload(channels, requests, 3*time.Second) if err != nil { return err } clientCfg, pc, helpMessage, err := s.parseClientAndProxyConfigurer(addr, extraPayload) if err != nil { if errors.Is(err, flag.ErrHelp) { s.writeToClient(helpMessage) return nil } s.writeToClient(err.Error()) return fmt.Errorf("parse flags from ssh client error: %v", err) } if err := clientCfg.Complete(); err != nil { s.writeToClient(fmt.Sprintf("failed to complete client config: %v", err)) return fmt.Errorf("complete client config error: %v", err) } if sshConn.Permissions != nil { clientCfg.User = util.EmptyOr(sshConn.Permissions.Extensions["user"], clientCfg.User) } pc.Complete() vc, err := virtual.NewClient(virtual.ClientOptions{ Common: clientCfg, Spec: &msg.ClientSpec{ Type: "ssh-tunnel", // If ssh does not require authentication, then the virtual client needs to authenticate through a token. // Otherwise, once ssh authentication is passed, the virtual client does not need to authenticate again. AlwaysAuthPass: !s.sc.NoClientAuth, }, HandleWorkConnCb: func(base *v1.ProxyBaseConfig, workConn net.Conn, m *msg.StartWorkConn) bool { // join workConn and ssh channel c, err := s.openConn(addr) if err != nil { log.Tracef("open conn error: %v", err) workConn.Close() return false } libio.Join(c, workConn) return false }, }) if err != nil { return err } s.vc = vc // transfer connection from virtual client to server peer listener go func() { l := s.vc.PeerListener() for { conn, err := l.Accept() if err != nil { return } _ = s.peerServerListener.PutConn(conn) } }() xl := xlog.New().AddPrefix(xlog.LogPrefix{Name: "sshVirtualClient", Value: "sshVirtualClient", Priority: 100}) ctx := xlog.NewContext(context.Background(), xl) go func() { vcErr := s.vc.Run(ctx) if vcErr != nil { s.writeToClient(vcErr.Error()) } // If vc.Run returns, it means that the virtual client has been closed, and the ssh tunnel connection should be closed. // One scenario is that the virtual client exits due to login failure. s.closeDoneChOnce.Do(func() { _ = sshConn.Close() close(s.doneCh) }) }() s.vc.UpdateProxyConfigurer([]v1.ProxyConfigurer{pc}) if ps, err := s.waitProxyStatusReady(pc.GetBaseConfig().Name, time.Second); err != nil { s.writeToClient(err.Error()) log.Warnf("wait proxy status ready error: %v", err) } else { // success s.writeToClient(createSuccessInfo(clientCfg.User, pc, ps)) _ = sshConn.Wait() } s.vc.Close() log.Tracef("ssh tunnel connection from %v closed", sshConn.RemoteAddr()) s.closeDoneChOnce.Do(func() { _ = sshConn.Close() close(s.doneCh) }) return nil } func (s *TunnelServer) writeToClient(data string) { if s.firstChannel == nil { return } _, _ = s.firstChannel.Write([]byte(data + "\n")) } func (s *TunnelServer) waitForwardAddrAndExtraPayload( channels <-chan ssh.NewChannel, requests <-chan *ssh.Request, timeout time.Duration, ) (*tcpipForward, string, error) { addrCh := make(chan *tcpipForward, 1) extraPayloadCh := make(chan string, 1) // get forward address go func() { addrGot := false for req := range requests { if req.Type == RequestTypeForward && !addrGot { payload := tcpipForward{} if err := ssh.Unmarshal(req.Payload, &payload); err != nil { return } addrGot = true addrCh <- &payload } if req.WantReply { _ = req.Reply(true, nil) } } }() // get extra payload go func() { for newChannel := range channels { // extraPayload will send to extraPayloadCh go s.handleNewChannel(newChannel, extraPayloadCh) } }() var ( addr *tcpipForward extraPayload string ) timer := time.NewTimer(timeout) defer timer.Stop() for { select { case v := <-addrCh: addr = v case extra := <-extraPayloadCh: extraPayload = extra case <-timer.C: return nil, "", fmt.Errorf("get addr and extra payload timeout") } if addr != nil && extraPayload != "" { break } } return addr, extraPayload, nil } func (s *TunnelServer) parseClientAndProxyConfigurer(_ *tcpipForward, extraPayload string) (*v1.ClientCommonConfig, v1.ProxyConfigurer, string, error) { helpMessage := "" cmd := &cobra.Command{ Use: "ssh v0@{address} [command]", Short: "ssh v0@{address} [command]", Run: func(*cobra.Command, []string) {}, } cmd.SetGlobalNormalizationFunc(config.WordSepNormalizeFunc) args := strings.Split(extraPayload, " ") if len(args) < 1 { return nil, nil, helpMessage, fmt.Errorf("invalid extra payload") } proxyType := strings.TrimSpace(args[0]) supportTypes := []string{"tcp", "http", "https", "tcpmux", "stcp"} if !slices.Contains(supportTypes, proxyType) { return nil, nil, helpMessage, fmt.Errorf("invalid proxy type: %s, support types: %v", proxyType, supportTypes) } pc := v1.NewProxyConfigurerByType(v1.ProxyType(proxyType)) if pc == nil { return nil, nil, helpMessage, fmt.Errorf("new proxy configurer error") } config.RegisterProxyFlags(cmd, pc, config.WithSSHMode()) clientCfg := v1.ClientCommonConfig{} config.RegisterClientCommonConfigFlags(cmd, &clientCfg, config.WithSSHMode()) cmd.InitDefaultHelpCmd() if err := cmd.ParseFlags(args); err != nil { if errors.Is(err, flag.ErrHelp) { helpMessage = cmd.UsageString() } return nil, nil, helpMessage, err } // if name is not set, generate a random one if pc.GetBaseConfig().Name == "" { id, err := util.RandIDWithLen(8) if err != nil { return nil, nil, helpMessage, fmt.Errorf("generate random id error: %v", err) } pc.GetBaseConfig().Name = fmt.Sprintf("sshtunnel-%s-%s", proxyType, id) } return &clientCfg, pc, helpMessage, nil } func (s *TunnelServer) handleNewChannel(channel ssh.NewChannel, extraPayloadCh chan string) { ch, reqs, err := channel.Accept() if err != nil { return } if s.firstChannel == nil { s.firstChannel = ch } go s.keepAlive(ch) for req := range reqs { if req.WantReply { _ = req.Reply(true, nil) } if req.Type != "exec" || len(req.Payload) <= 4 { continue } end := 4 + binary.BigEndian.Uint32(req.Payload[:4]) if len(req.Payload) < int(end) { continue } extraPayload := string(req.Payload[4:end]) select { case extraPayloadCh <- extraPayload: default: } } } func (s *TunnelServer) keepAlive(ch ssh.Channel) { tk := time.NewTicker(time.Second * 30) defer tk.Stop() for { select { case <-tk.C: _, err := ch.SendRequest("heartbeat", false, nil) if err != nil { return } case <-s.doneCh: return } } } func (s *TunnelServer) openConn(addr *tcpipForward) (net.Conn, error) { payload := forwardedTCPPayload{ Addr: addr.Host, Port: addr.Port, // Note: Here is just for compatibility, not the real source address. OriginAddr: addr.Host, OriginPort: addr.Port, } channel, reqs, err := s.sshConn.OpenChannel(ChannelTypeServerOpenChannel, ssh.Marshal(&payload)) if err != nil { return nil, fmt.Errorf("open ssh channel error: %v", err) } go ssh.DiscardRequests(reqs) conn := netpkg.WrapReadWriteCloserToConn(channel, s.underlyingConn) return conn, nil } func (s *TunnelServer) waitProxyStatusReady(name string, timeout time.Duration) (*proxy.WorkingStatus, error) { ticker := time.NewTicker(100 * time.Millisecond) defer ticker.Stop() timer := time.NewTimer(timeout) defer timer.Stop() statusExporter := s.vc.Service().StatusExporter() for { select { case <-ticker.C: ps, ok := statusExporter.GetProxyStatus(name) if !ok { continue } switch ps.Phase { case proxy.ProxyPhaseRunning: return ps, nil case proxy.ProxyPhaseStartErr, proxy.ProxyPhaseClosed: return ps, errors.New(ps.Err) } case <-timer.C: return nil, fmt.Errorf("wait proxy status ready timeout") case <-s.doneCh: return nil, fmt.Errorf("ssh tunnel server closed") } } } ================================================ FILE: pkg/ssh/terminal.go ================================================ // Copyright 2023 The frp Authors // // Licensed under the Apache License, Version 2.0 (the "License"); // you may not use this file except in compliance with the License. // You may obtain a copy of the License at // // http://www.apache.org/licenses/LICENSE-2.0 // // Unless required by applicable law or agreed to in writing, software // distributed under the License is distributed on an "AS IS" BASIS, // WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. // See the License for the specific language governing permissions and // limitations under the License. package ssh import ( "github.com/fatedier/frp/client/proxy" v1 "github.com/fatedier/frp/pkg/config/v1" ) func createSuccessInfo(user string, pc v1.ProxyConfigurer, ps *proxy.WorkingStatus) string { base := pc.GetBaseConfig() out := "\n" out += "frp (via SSH) (Ctrl+C to quit)\n\n" out += "User: " + user + "\n" out += "ProxyName: " + base.Name + "\n" out += "Type: " + base.Type + "\n" out += "RemoteAddress: " + ps.RemoteAddr + "\n" return out } ================================================ FILE: pkg/transport/message.go ================================================ // Copyright 2023 The frp Authors // // Licensed under the Apache License, Version 2.0 (the "License"); // you may not use this file except in compliance with the License. // You may obtain a copy of the License at // // http://www.apache.org/licenses/LICENSE-2.0 // // Unless required by applicable law or agreed to in writing, software // distributed under the License is distributed on an "AS IS" BASIS, // WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. // See the License for the specific language governing permissions and // limitations under the License. package transport import ( "context" "reflect" "sync" "github.com/fatedier/golib/errors" "github.com/fatedier/frp/pkg/msg" ) type MessageTransporter interface { Send(msg.Message) error // Recv(ctx context.Context, laneKey string, msgType string) (Message, error) // Do will first send msg, then recv msg with the same laneKey and specified msgType. Do(ctx context.Context, req msg.Message, laneKey, recvMsgType string) (msg.Message, error) // Dispatch will dispatch message to related channel registered in Do function by its message type and laneKey. Dispatch(m msg.Message, laneKey string) bool // Same with Dispatch but with specified message type. DispatchWithType(m msg.Message, msgType, laneKey string) bool } type MessageSender interface { Send(msg.Message) error } func NewMessageTransporter(sender MessageSender) MessageTransporter { return &transporterImpl{ sender: sender, registry: make(map[string]map[string]chan msg.Message), } } type transporterImpl struct { sender MessageSender // First key is message type and second key is lane key. // Dispatch will dispatch message to related channel by its message type // and lane key. registry map[string]map[string]chan msg.Message mu sync.RWMutex } func (impl *transporterImpl) Send(m msg.Message) error { return impl.sender.Send(m) } func (impl *transporterImpl) Do(ctx context.Context, req msg.Message, laneKey, recvMsgType string) (msg.Message, error) { ch := make(chan msg.Message, 1) defer close(ch) unregisterFn := impl.registerMsgChan(ch, laneKey, recvMsgType) defer unregisterFn() if err := impl.Send(req); err != nil { return nil, err } select { case <-ctx.Done(): return nil, ctx.Err() case resp := <-ch: return resp, nil } } func (impl *transporterImpl) DispatchWithType(m msg.Message, msgType, laneKey string) bool { var ch chan msg.Message impl.mu.RLock() byLaneKey, ok := impl.registry[msgType] if ok { ch = byLaneKey[laneKey] } impl.mu.RUnlock() if ch == nil { return false } if err := errors.PanicToError(func() { ch <- m }); err != nil { return false } return true } func (impl *transporterImpl) Dispatch(m msg.Message, laneKey string) bool { msgType := reflect.TypeOf(m).Elem().Name() return impl.DispatchWithType(m, msgType, laneKey) } func (impl *transporterImpl) registerMsgChan(recvCh chan msg.Message, laneKey string, msgType string) (unregister func()) { impl.mu.Lock() byLaneKey, ok := impl.registry[msgType] if !ok { byLaneKey = make(map[string]chan msg.Message) impl.registry[msgType] = byLaneKey } byLaneKey[laneKey] = recvCh impl.mu.Unlock() unregister = func() { impl.mu.Lock() delete(byLaneKey, laneKey) impl.mu.Unlock() } return } ================================================ FILE: pkg/transport/tls.go ================================================ // Copyright 2023 The frp Authors // // Licensed under the Apache License, Version 2.0 (the "License"); // you may not use this file except in compliance with the License. // You may obtain a copy of the License at // // http://www.apache.org/licenses/LICENSE-2.0 // // Unless required by applicable law or agreed to in writing, software // distributed under the License is distributed on an "AS IS" BASIS, // WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. // See the License for the specific language governing permissions and // limitations under the License. package transport import ( "crypto/rand" "crypto/rsa" "crypto/tls" "crypto/x509" "encoding/pem" "fmt" "math/big" "os" "time" ) func newCustomTLSKeyPair(certfile, keyfile string) (*tls.Certificate, error) { tlsCert, err := tls.LoadX509KeyPair(certfile, keyfile) if err != nil { return nil, err } return &tlsCert, nil } func newRandomTLSKeyPair() (*tls.Certificate, error) { key, err := rsa.GenerateKey(rand.Reader, 2048) if err != nil { return nil, err } // Generate a random positive serial number with 128 bits of entropy. // RFC 5280 requires serial numbers to be positive integers (not zero). serialNumberLimit := new(big.Int).Lsh(big.NewInt(1), 128) serialNumber, err := rand.Int(rand.Reader, serialNumberLimit) if err != nil { return nil, err } // Ensure serial number is positive (not zero) if serialNumber.Sign() == 0 { serialNumber = big.NewInt(1) } template := x509.Certificate{ SerialNumber: serialNumber, NotBefore: time.Now().Add(-1 * time.Hour), NotAfter: time.Now().Add(365 * 24 * time.Hour * 10), } certDER, err := x509.CreateCertificate( rand.Reader, &template, &template, &key.PublicKey, key) if err != nil { return nil, err } keyPEM := pem.EncodeToMemory(&pem.Block{Type: "RSA PRIVATE KEY", Bytes: x509.MarshalPKCS1PrivateKey(key)}) certPEM := pem.EncodeToMemory(&pem.Block{Type: "CERTIFICATE", Bytes: certDER}) tlsCert, err := tls.X509KeyPair(certPEM, keyPEM) if err != nil { return nil, err } return &tlsCert, nil } // Only support one ca file to add func newCertPool(caPath string) (*x509.CertPool, error) { pool := x509.NewCertPool() caCrt, err := os.ReadFile(caPath) if err != nil { return nil, err } if !pool.AppendCertsFromPEM(caCrt) { return nil, fmt.Errorf("failed to parse CA certificate from file %q: no valid PEM certificates found", caPath) } return pool, nil } func NewServerTLSConfig(certPath, keyPath, caPath string) (*tls.Config, error) { base := &tls.Config{} if certPath == "" || keyPath == "" { // server will generate tls conf by itself cert, err := newRandomTLSKeyPair() if err != nil { return nil, err } base.Certificates = []tls.Certificate{*cert} } else { cert, err := newCustomTLSKeyPair(certPath, keyPath) if err != nil { return nil, err } base.Certificates = []tls.Certificate{*cert} } if caPath != "" { pool, err := newCertPool(caPath) if err != nil { return nil, err } base.ClientAuth = tls.RequireAndVerifyClientCert base.ClientCAs = pool } return base, nil } func NewClientTLSConfig(certPath, keyPath, caPath, serverName string) (*tls.Config, error) { base := &tls.Config{} if certPath != "" && keyPath != "" { cert, err := newCustomTLSKeyPair(certPath, keyPath) if err != nil { return nil, err } base.Certificates = []tls.Certificate{*cert} } base.ServerName = serverName if caPath != "" { pool, err := newCertPool(caPath) if err != nil { return nil, err } base.RootCAs = pool base.InsecureSkipVerify = false } else { base.InsecureSkipVerify = true } return base, nil } func NewRandomPrivateKey() ([]byte, error) { key, err := rsa.GenerateKey(rand.Reader, 2048) if err != nil { return nil, err } keyPEM := pem.EncodeToMemory(&pem.Block{ Type: "RSA PRIVATE KEY", Bytes: x509.MarshalPKCS1PrivateKey(key), }) return keyPEM, nil } ================================================ FILE: pkg/util/http/context.go ================================================ // Copyright 2025 The frp Authors // // Licensed under the Apache License, Version 2.0 (the "License"); // you may not use this file except in compliance with the License. // You may obtain a copy of the License at // // http://www.apache.org/licenses/LICENSE-2.0 // // Unless required by applicable law or agreed to in writing, software // distributed under the License is distributed on an "AS IS" BASIS, // WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. // See the License for the specific language governing permissions and // limitations under the License. package http import ( "encoding/json" "io" "net/http" "github.com/gorilla/mux" ) type Context struct { Req *http.Request Resp http.ResponseWriter vars map[string]string } func NewContext(w http.ResponseWriter, r *http.Request) *Context { return &Context{ Req: r, Resp: w, vars: mux.Vars(r), } } func (c *Context) Param(key string) string { return c.vars[key] } func (c *Context) Query(key string) string { return c.Req.URL.Query().Get(key) } func (c *Context) BindJSON(obj any) error { body, err := io.ReadAll(c.Req.Body) if err != nil { return err } return json.Unmarshal(body, obj) } func (c *Context) Body() ([]byte, error) { return io.ReadAll(c.Req.Body) } ================================================ FILE: pkg/util/http/error.go ================================================ // Copyright 2025 The frp Authors // // Licensed under the Apache License, Version 2.0 (the "License"); // you may not use this file except in compliance with the License. // You may obtain a copy of the License at // // http://www.apache.org/licenses/LICENSE-2.0 // // Unless required by applicable law or agreed to in writing, software // distributed under the License is distributed on an "AS IS" BASIS, // WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. // See the License for the specific language governing permissions and // limitations under the License. package http import "fmt" type Error struct { Code int Err error } func (e *Error) Error() string { return e.Err.Error() } func NewError(code int, msg string) *Error { return &Error{ Code: code, Err: fmt.Errorf("%s", msg), } } ================================================ FILE: pkg/util/http/handler.go ================================================ // Copyright 2025 The frp Authors // // Licensed under the Apache License, Version 2.0 (the "License"); // you may not use this file except in compliance with the License. // You may obtain a copy of the License at // // http://www.apache.org/licenses/LICENSE-2.0 // // Unless required by applicable law or agreed to in writing, software // distributed under the License is distributed on an "AS IS" BASIS, // WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. // See the License for the specific language governing permissions and // limitations under the License. package http import ( "encoding/json" "net/http" "github.com/fatedier/frp/pkg/util/log" ) type GeneralResponse struct { Code int Msg string } // APIHandler is a handler function that returns a response object or an error. type APIHandler func(ctx *Context) (any, error) // MakeHTTPHandlerFunc turns a normal APIHandler into a http.HandlerFunc. func MakeHTTPHandlerFunc(handler APIHandler) http.HandlerFunc { return func(w http.ResponseWriter, r *http.Request) { ctx := NewContext(w, r) res, err := handler(ctx) if err != nil { log.Warnf("http response [%s]: error: %v", r.URL.Path, err) code := http.StatusInternalServerError if e, ok := err.(*Error); ok { code = e.Code } w.Header().Set("Content-Type", "application/json") w.WriteHeader(code) _ = json.NewEncoder(w).Encode(GeneralResponse{Code: code, Msg: err.Error()}) return } if res == nil { w.WriteHeader(http.StatusOK) return } switch v := res.(type) { case []byte: _, _ = w.Write(v) case string: _, _ = w.Write([]byte(v)) default: w.Header().Set("Content-Type", "application/json") w.WriteHeader(http.StatusOK) _ = json.NewEncoder(w).Encode(v) } } } ================================================ FILE: pkg/util/http/http.go ================================================ // Copyright 2023 The frp Authors // // Licensed under the Apache License, Version 2.0 (the "License"); // you may not use this file except in compliance with the License. // You may obtain a copy of the License at // // http://www.apache.org/licenses/LICENSE-2.0 // // Unless required by applicable law or agreed to in writing, software // distributed under the License is distributed on an "AS IS" BASIS, // WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. // See the License for the specific language governing permissions and // limitations under the License. package http import ( "encoding/base64" "net" "net/http" "strings" ) func OkResponse() *http.Response { header := make(http.Header) res := &http.Response{ Status: "OK", StatusCode: 200, Proto: "HTTP/1.1", ProtoMajor: 1, ProtoMinor: 1, Header: header, } return res } func ProxyUnauthorizedResponse() *http.Response { header := make(http.Header) header.Set("Proxy-Authenticate", `Basic realm="Restricted"`) res := &http.Response{ Status: "Proxy Authentication Required", StatusCode: 407, Proto: "HTTP/1.1", ProtoMajor: 1, ProtoMinor: 1, Header: header, } return res } // canonicalHost strips port from host if present and returns the canonicalized // host name. func CanonicalHost(host string) (string, error) { var err error host = strings.ToLower(host) if hasPort(host) { host, _, err = net.SplitHostPort(host) if err != nil { return "", err } } // Strip trailing dot from fully qualified domain names. host = strings.TrimSuffix(host, ".") return host, nil } // hasPort reports whether host contains a port number. host may be a host // name, an IPv4 or an IPv6 address. func hasPort(host string) bool { colons := strings.Count(host, ":") if colons == 0 { return false } if colons == 1 { return true } return host[0] == '[' && strings.Contains(host, "]:") } func ParseBasicAuth(auth string) (username, password string, ok bool) { const prefix = "Basic " // Case insensitive prefix match. See Issue 22736. if len(auth) < len(prefix) || !strings.EqualFold(auth[:len(prefix)], prefix) { return } c, err := base64.StdEncoding.DecodeString(auth[len(prefix):]) if err != nil { return } cs := string(c) before, after, found := strings.Cut(cs, ":") if !found { return } return before, after, true } func BasicAuth(username, passwd string) string { auth := username + ":" + passwd return "Basic " + base64.StdEncoding.EncodeToString([]byte(auth)) } ================================================ FILE: pkg/util/http/middleware.go ================================================ // Copyright 2025 The frp Authors // // Licensed under the Apache License, Version 2.0 (the "License"); // you may not use this file except in compliance with the License. // You may obtain a copy of the License at // // http://www.apache.org/licenses/LICENSE-2.0 // // Unless required by applicable law or agreed to in writing, software // distributed under the License is distributed on an "AS IS" BASIS, // WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. // See the License for the specific language governing permissions and // limitations under the License. package http import ( "net/http" "github.com/fatedier/frp/pkg/util/log" ) type responseWriter struct { http.ResponseWriter code int } func (rw *responseWriter) WriteHeader(code int) { rw.code = code rw.ResponseWriter.WriteHeader(code) } func NewRequestLogger(next http.Handler) http.Handler { return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { log.Infof("http request: [%s]", r.URL.Path) rw := &responseWriter{ResponseWriter: w, code: http.StatusOK} next.ServeHTTP(rw, r) log.Infof("http response [%s]: code [%d]", r.URL.Path, rw.code) }) } ================================================ FILE: pkg/util/http/server.go ================================================ // Copyright 2023 The frp Authors // // Licensed under the Apache License, Version 2.0 (the "License"); // you may not use this file except in compliance with the License. // You may obtain a copy of the License at // // http://www.apache.org/licenses/LICENSE-2.0 // // Unless required by applicable law or agreed to in writing, software // distributed under the License is distributed on an "AS IS" BASIS, // WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. // See the License for the specific language governing permissions and // limitations under the License. package http import ( "crypto/tls" "net" "net/http" "net/http/pprof" "strconv" "time" "github.com/gorilla/mux" "github.com/fatedier/frp/assets" v1 "github.com/fatedier/frp/pkg/config/v1" netpkg "github.com/fatedier/frp/pkg/util/net" ) var ( defaultReadTimeout = 60 * time.Second defaultWriteTimeout = 60 * time.Second ) type Server struct { addr string ln net.Listener tlsCfg *tls.Config router *mux.Router hs *http.Server authMiddleware mux.MiddlewareFunc } func NewServer(cfg v1.WebServerConfig) (*Server, error) { assets.Load(cfg.AssetsDir) addr := net.JoinHostPort(cfg.Addr, strconv.Itoa(cfg.Port)) if addr == ":" { addr = ":http" } ln, err := net.Listen("tcp", addr) if err != nil { return nil, err } router := mux.NewRouter() hs := &http.Server{ Addr: addr, Handler: router, ReadTimeout: defaultReadTimeout, WriteTimeout: defaultWriteTimeout, } s := &Server{ addr: addr, ln: ln, hs: hs, router: router, } if cfg.PprofEnable { s.registerPprofHandlers() } if cfg.TLS != nil { cert, err := tls.LoadX509KeyPair(cfg.TLS.CertFile, cfg.TLS.KeyFile) if err != nil { return nil, err } s.tlsCfg = &tls.Config{ Certificates: []tls.Certificate{cert}, } } s.authMiddleware = netpkg.NewHTTPAuthMiddleware(cfg.User, cfg.Password).SetAuthFailDelay(200 * time.Millisecond).Middleware return s, nil } func (s *Server) Address() string { return s.addr } func (s *Server) Run() error { ln := s.ln if s.tlsCfg != nil { ln = tls.NewListener(ln, s.tlsCfg) } return s.hs.Serve(ln) } func (s *Server) Close() error { err := s.hs.Close() if s.ln != nil { _ = s.ln.Close() } return err } type RouterRegisterHelper struct { Router *mux.Router AssetsFS http.FileSystem AuthMiddleware mux.MiddlewareFunc } func (s *Server) RouteRegister(register func(helper *RouterRegisterHelper)) { register(&RouterRegisterHelper{ Router: s.router, AssetsFS: assets.FileSystem, AuthMiddleware: s.authMiddleware, }) } func (s *Server) registerPprofHandlers() { s.router.HandleFunc("/debug/pprof/cmdline", pprof.Cmdline) s.router.HandleFunc("/debug/pprof/profile", pprof.Profile) s.router.HandleFunc("/debug/pprof/symbol", pprof.Symbol) s.router.HandleFunc("/debug/pprof/trace", pprof.Trace) s.router.PathPrefix("/debug/pprof/").HandlerFunc(pprof.Index) } ================================================ FILE: pkg/util/jsonx/json_v1.go ================================================ // Copyright 2026 The frp Authors // // Licensed under the Apache License, Version 2.0 (the "License"); // you may not use this file except in compliance with the License. // You may obtain a copy of the License at // // http://www.apache.org/licenses/LICENSE-2.0 // // Unless required by applicable law or agreed to in writing, software // distributed under the License is distributed on an "AS IS" BASIS, // WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. // See the License for the specific language governing permissions and // limitations under the License. package jsonx import ( "bytes" "encoding/json" ) type DecodeOptions struct { RejectUnknownMembers bool } func Marshal(v any) ([]byte, error) { return json.Marshal(v) } func MarshalIndent(v any, prefix, indent string) ([]byte, error) { return json.MarshalIndent(v, prefix, indent) } func Unmarshal(data []byte, out any) error { return json.Unmarshal(data, out) } func UnmarshalWithOptions(data []byte, out any, options DecodeOptions) error { if !options.RejectUnknownMembers { return json.Unmarshal(data, out) } decoder := json.NewDecoder(bytes.NewReader(data)) decoder.DisallowUnknownFields() return decoder.Decode(out) } ================================================ FILE: pkg/util/jsonx/raw_message.go ================================================ // Copyright 2026 The frp Authors // // Licensed under the Apache License, Version 2.0 (the "License"); // you may not use this file except in compliance with the License. // You may obtain a copy of the License at // // http://www.apache.org/licenses/LICENSE-2.0 // // Unless required by applicable law or agreed to in writing, software // distributed under the License is distributed on an "AS IS" BASIS, // WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. // See the License for the specific language governing permissions and // limitations under the License. package jsonx import "fmt" // RawMessage stores a raw encoded JSON value. // It is equivalent to encoding/json.RawMessage behavior. type RawMessage []byte func (m RawMessage) MarshalJSON() ([]byte, error) { if m == nil { return []byte("null"), nil } return m, nil } func (m *RawMessage) UnmarshalJSON(data []byte) error { if m == nil { return fmt.Errorf("jsonx.RawMessage: UnmarshalJSON on nil pointer") } *m = append((*m)[:0], data...) return nil } ================================================ FILE: pkg/util/limit/reader.go ================================================ // Copyright 2019 fatedier, fatedier@gmail.com // // Licensed under the Apache License, Version 2.0 (the "License"); // you may not use this file except in compliance with the License. // You may obtain a copy of the License at // // http://www.apache.org/licenses/LICENSE-2.0 // // Unless required by applicable law or agreed to in writing, software // distributed under the License is distributed on an "AS IS" BASIS, // WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. // See the License for the specific language governing permissions and // limitations under the License. package limit import ( "context" "io" "golang.org/x/time/rate" ) type Reader struct { r io.Reader limiter *rate.Limiter } func NewReader(r io.Reader, limiter *rate.Limiter) *Reader { return &Reader{ r: r, limiter: limiter, } } func (r *Reader) Read(p []byte) (n int, err error) { b := r.limiter.Burst() if b < len(p) { p = p[:b] } n, err = r.r.Read(p) if err != nil { return } err = r.limiter.WaitN(context.Background(), n) if err != nil { return } return } ================================================ FILE: pkg/util/limit/writer.go ================================================ // Copyright 2019 fatedier, fatedier@gmail.com // // Licensed under the Apache License, Version 2.0 (the "License"); // you may not use this file except in compliance with the License. // You may obtain a copy of the License at // // http://www.apache.org/licenses/LICENSE-2.0 // // Unless required by applicable law or agreed to in writing, software // distributed under the License is distributed on an "AS IS" BASIS, // WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. // See the License for the specific language governing permissions and // limitations under the License. package limit import ( "context" "io" "golang.org/x/time/rate" ) type Writer struct { w io.Writer limiter *rate.Limiter } func NewWriter(w io.Writer, limiter *rate.Limiter) *Writer { return &Writer{ w: w, limiter: limiter, } } func (w *Writer) Write(p []byte) (n int, err error) { var nn int b := w.limiter.Burst() for { end := len(p) if end == 0 { break } if b < len(p) { end = b } err = w.limiter.WaitN(context.Background(), end) if err != nil { return } nn, err = w.w.Write(p[:end]) n += nn if err != nil { return } p = p[end:] } return } ================================================ FILE: pkg/util/log/log.go ================================================ // Copyright 2016 fatedier, fatedier@gmail.com // // Licensed under the Apache License, Version 2.0 (the "License"); // you may not use this file except in compliance with the License. // You may obtain a copy of the License at // // http://www.apache.org/licenses/LICENSE-2.0 // // Unless required by applicable law or agreed to in writing, software // distributed under the License is distributed on an "AS IS" BASIS, // WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. // See the License for the specific language governing permissions and // limitations under the License. package log import ( "bytes" "os" "github.com/fatedier/golib/log" ) var ( TraceLevel = log.TraceLevel DebugLevel = log.DebugLevel InfoLevel = log.InfoLevel WarnLevel = log.WarnLevel ErrorLevel = log.ErrorLevel ) var Logger *log.Logger func init() { Logger = log.New( log.WithCaller(true), log.AddCallerSkip(1), log.WithLevel(log.InfoLevel), ) } func InitLogger(logPath string, levelStr string, maxDays int, disableLogColor bool) { options := []log.Option{} if logPath == "console" { if !disableLogColor { options = append(options, log.WithOutput(log.NewConsoleWriter(log.ConsoleConfig{ Colorful: true, }, os.Stdout)), ) } } else { writer := log.NewRotateFileWriter(log.RotateFileConfig{ FileName: logPath, Mode: log.RotateFileModeDaily, MaxDays: maxDays, }) writer.Init() options = append(options, log.WithOutput(writer)) } level, err := log.ParseLevel(levelStr) if err != nil { level = log.InfoLevel } options = append(options, log.WithLevel(level)) Logger = Logger.WithOptions(options...) } func Errorf(format string, v ...any) { Logger.Errorf(format, v...) } func Warnf(format string, v ...any) { Logger.Warnf(format, v...) } func Infof(format string, v ...any) { Logger.Infof(format, v...) } func Debugf(format string, v ...any) { Logger.Debugf(format, v...) } func Tracef(format string, v ...any) { Logger.Tracef(format, v...) } func Logf(level log.Level, offset int, format string, v ...any) { Logger.Logf(level, offset, format, v...) } type WriteLogger struct { level log.Level offset int } func NewWriteLogger(level log.Level, offset int) *WriteLogger { return &WriteLogger{ level: level, offset: offset, } } func (w *WriteLogger) Write(p []byte) (n int, err error) { Logger.Log(w.level, w.offset, string(bytes.TrimRight(p, "\n"))) return len(p), nil } ================================================ FILE: pkg/util/metric/counter.go ================================================ // Copyright 2017 fatedier, fatedier@gmail.com // // Licensed under the Apache License, Version 2.0 (the "License"); // you may not use this file except in compliance with the License. // You may obtain a copy of the License at // // http://www.apache.org/licenses/LICENSE-2.0 // // Unless required by applicable law or agreed to in writing, software // distributed under the License is distributed on an "AS IS" BASIS, // WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. // See the License for the specific language governing permissions and // limitations under the License. package metric import ( "sync/atomic" ) type Counter interface { Count() int32 Inc(int32) Dec(int32) Snapshot() Counter Clear() } func NewCounter() Counter { return &StandardCounter{ count: 0, } } type StandardCounter struct { count int32 } func (c *StandardCounter) Count() int32 { return atomic.LoadInt32(&c.count) } func (c *StandardCounter) Inc(count int32) { atomic.AddInt32(&c.count, count) } func (c *StandardCounter) Dec(count int32) { atomic.AddInt32(&c.count, -count) } func (c *StandardCounter) Snapshot() Counter { tmp := &StandardCounter{ count: atomic.LoadInt32(&c.count), } return tmp } func (c *StandardCounter) Clear() { atomic.StoreInt32(&c.count, 0) } ================================================ FILE: pkg/util/metric/counter_test.go ================================================ package metric import ( "testing" "github.com/stretchr/testify/require" ) func TestCounter(t *testing.T) { require := require.New(t) c := NewCounter() c.Inc(10) require.EqualValues(10, c.Count()) c.Dec(5) require.EqualValues(5, c.Count()) cTmp := c.Snapshot() require.EqualValues(5, cTmp.Count()) c.Clear() require.EqualValues(0, c.Count()) } ================================================ FILE: pkg/util/metric/date_counter.go ================================================ // Copyright 2017 fatedier, fatedier@gmail.com // // Licensed under the Apache License, Version 2.0 (the "License"); // you may not use this file except in compliance with the License. // You may obtain a copy of the License at // // http://www.apache.org/licenses/LICENSE-2.0 // // Unless required by applicable law or agreed to in writing, software // distributed under the License is distributed on an "AS IS" BASIS, // WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. // See the License for the specific language governing permissions and // limitations under the License. package metric import ( "sync" "time" ) type DateCounter interface { TodayCount() int64 GetLastDaysCount(lastdays int64) []int64 Inc(int64) Dec(int64) Snapshot() DateCounter Clear() } func NewDateCounter(reserveDays int64) DateCounter { if reserveDays <= 0 { reserveDays = 1 } return newStandardDateCounter(reserveDays) } type StandardDateCounter struct { reserveDays int64 counts []int64 lastUpdateDate time.Time mu sync.Mutex } func newStandardDateCounter(reserveDays int64) *StandardDateCounter { now := time.Now() now = time.Date(now.Year(), now.Month(), now.Day(), 0, 0, 0, 0, now.Location()) s := &StandardDateCounter{ reserveDays: reserveDays, counts: make([]int64, reserveDays), lastUpdateDate: now, } return s } func (c *StandardDateCounter) TodayCount() int64 { c.mu.Lock() defer c.mu.Unlock() c.rotate(time.Now()) return c.counts[0] } func (c *StandardDateCounter) GetLastDaysCount(lastdays int64) []int64 { if lastdays > c.reserveDays { lastdays = c.reserveDays } counts := make([]int64, lastdays) c.mu.Lock() defer c.mu.Unlock() c.rotate(time.Now()) for i := 0; i < int(lastdays); i++ { counts[i] = c.counts[i] } return counts } func (c *StandardDateCounter) Inc(count int64) { c.mu.Lock() defer c.mu.Unlock() c.rotate(time.Now()) c.counts[0] += count } func (c *StandardDateCounter) Dec(count int64) { c.mu.Lock() defer c.mu.Unlock() c.rotate(time.Now()) c.counts[0] -= count } func (c *StandardDateCounter) Snapshot() DateCounter { c.mu.Lock() defer c.mu.Unlock() tmp := newStandardDateCounter(c.reserveDays) for i := 0; i < int(c.reserveDays); i++ { tmp.counts[i] = c.counts[i] } return tmp } func (c *StandardDateCounter) Clear() { c.mu.Lock() defer c.mu.Unlock() for i := 0; i < int(c.reserveDays); i++ { c.counts[i] = 0 } } // rotate // Must hold the lock before calling this function. func (c *StandardDateCounter) rotate(now time.Time) { now = time.Date(now.Year(), now.Month(), now.Day(), 0, 0, 0, 0, now.Location()) days := int(now.Sub(c.lastUpdateDate).Hours() / 24) defer func() { c.lastUpdateDate = now }() if days <= 0 { return } else if days >= int(c.reserveDays) { c.counts = make([]int64, c.reserveDays) return } newCounts := make([]int64, c.reserveDays) for i := days; i < int(c.reserveDays); i++ { newCounts[i] = c.counts[i-days] } c.counts = newCounts } ================================================ FILE: pkg/util/metric/date_counter_test.go ================================================ package metric import ( "testing" "github.com/stretchr/testify/require" ) func TestDateCounter(t *testing.T) { require := require.New(t) dc := NewDateCounter(3) dc.Inc(10) require.EqualValues(10, dc.TodayCount()) dc.Dec(5) require.EqualValues(5, dc.TodayCount()) counts := dc.GetLastDaysCount(3) require.EqualValues(3, len(counts)) require.EqualValues(5, counts[0]) require.EqualValues(0, counts[1]) require.EqualValues(0, counts[2]) dcTmp := dc.Snapshot() require.EqualValues(5, dcTmp.TodayCount()) } ================================================ FILE: pkg/util/metric/metrics.go ================================================ // Copyright 2020 fatedier, fatedier@gmail.com // // Licensed under the Apache License, Version 2.0 (the "License"); // you may not use this file except in compliance with the License. // You may obtain a copy of the License at // // http://www.apache.org/licenses/LICENSE-2.0 // // Unless required by applicable law or agreed to in writing, software // distributed under the License is distributed on an "AS IS" BASIS, // WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. // See the License for the specific language governing permissions and // limitations under the License. package metric // GaugeMetric represents a single numerical value that can arbitrarily go up // and down. type GaugeMetric interface { Inc() Dec() Set(float64) } // CounterMetric represents a single numerical value that only ever // goes up. type CounterMetric interface { Inc() } // HistogramMetric counts individual observations. type HistogramMetric interface { Observe(float64) } ================================================ FILE: pkg/util/net/conn.go ================================================ // Copyright 2016 fatedier, fatedier@gmail.com // // Licensed under the Apache License, Version 2.0 (the "License"); // you may not use this file except in compliance with the License. // You may obtain a copy of the License at // // http://www.apache.org/licenses/LICENSE-2.0 // // Unless required by applicable law or agreed to in writing, software // distributed under the License is distributed on an "AS IS" BASIS, // WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. // See the License for the specific language governing permissions and // limitations under the License. package net import ( "context" "errors" "io" "net" "sync/atomic" "time" "github.com/fatedier/golib/crypto" quic "github.com/quic-go/quic-go" "github.com/fatedier/frp/pkg/util/xlog" ) type ContextGetter interface { Context() context.Context } type ContextSetter interface { WithContext(ctx context.Context) } func NewLogFromConn(conn net.Conn) *xlog.Logger { if c, ok := conn.(ContextGetter); ok { return xlog.FromContextSafe(c.Context()) } return xlog.New() } func NewContextFromConn(conn net.Conn) context.Context { if c, ok := conn.(ContextGetter); ok { return c.Context() } return context.Background() } // ContextConn is the connection with context type ContextConn struct { net.Conn ctx context.Context } func NewContextConn(ctx context.Context, c net.Conn) *ContextConn { return &ContextConn{ Conn: c, ctx: ctx, } } func (c *ContextConn) WithContext(ctx context.Context) { c.ctx = ctx } func (c *ContextConn) Context() context.Context { return c.ctx } type WrapReadWriteCloserConn struct { io.ReadWriteCloser underConn net.Conn remoteAddr net.Addr } func WrapReadWriteCloserToConn(rwc io.ReadWriteCloser, underConn net.Conn) *WrapReadWriteCloserConn { return &WrapReadWriteCloserConn{ ReadWriteCloser: rwc, underConn: underConn, } } func (conn *WrapReadWriteCloserConn) LocalAddr() net.Addr { if conn.underConn != nil { return conn.underConn.LocalAddr() } return (*net.TCPAddr)(nil) } func (conn *WrapReadWriteCloserConn) SetRemoteAddr(addr net.Addr) { conn.remoteAddr = addr } func (conn *WrapReadWriteCloserConn) RemoteAddr() net.Addr { if conn.remoteAddr != nil { return conn.remoteAddr } if conn.underConn != nil { return conn.underConn.RemoteAddr() } return (*net.TCPAddr)(nil) } func (conn *WrapReadWriteCloserConn) SetDeadline(t time.Time) error { if conn.underConn != nil { return conn.underConn.SetDeadline(t) } return &net.OpError{Op: "set", Net: "wrap", Source: nil, Addr: nil, Err: errors.New("deadline not supported")} } func (conn *WrapReadWriteCloserConn) SetReadDeadline(t time.Time) error { if conn.underConn != nil { return conn.underConn.SetReadDeadline(t) } return &net.OpError{Op: "set", Net: "wrap", Source: nil, Addr: nil, Err: errors.New("deadline not supported")} } func (conn *WrapReadWriteCloserConn) SetWriteDeadline(t time.Time) error { if conn.underConn != nil { return conn.underConn.SetWriteDeadline(t) } return &net.OpError{Op: "set", Net: "wrap", Source: nil, Addr: nil, Err: errors.New("deadline not supported")} } type CloseNotifyConn struct { net.Conn // 1 means closed closeFlag int32 closeFn func(error) } // closeFn will be only called once with the error (nil if Close() was called, non-nil if CloseWithError() was called) func WrapCloseNotifyConn(c net.Conn, closeFn func(error)) *CloseNotifyConn { return &CloseNotifyConn{ Conn: c, closeFn: closeFn, } } func (cc *CloseNotifyConn) Close() (err error) { pflag := atomic.SwapInt32(&cc.closeFlag, 1) if pflag == 0 { err = cc.Conn.Close() if cc.closeFn != nil { cc.closeFn(nil) } } return } // CloseWithError closes the connection and passes the error to the close callback. func (cc *CloseNotifyConn) CloseWithError(err error) error { pflag := atomic.SwapInt32(&cc.closeFlag, 1) if pflag == 0 { closeErr := cc.Conn.Close() if cc.closeFn != nil { cc.closeFn(err) } return closeErr } return nil } type StatsConn struct { net.Conn closed int64 // 1 means closed totalRead int64 totalWrite int64 statsFunc func(totalRead, totalWrite int64) } func WrapStatsConn(conn net.Conn, statsFunc func(total, totalWrite int64)) *StatsConn { return &StatsConn{ Conn: conn, statsFunc: statsFunc, } } func (statsConn *StatsConn) Read(p []byte) (n int, err error) { n, err = statsConn.Conn.Read(p) statsConn.totalRead += int64(n) return } func (statsConn *StatsConn) Write(p []byte) (n int, err error) { n, err = statsConn.Conn.Write(p) statsConn.totalWrite += int64(n) return } func (statsConn *StatsConn) Close() (err error) { old := atomic.SwapInt64(&statsConn.closed, 1) if old != 1 { err = statsConn.Conn.Close() if statsConn.statsFunc != nil { statsConn.statsFunc(statsConn.totalRead, statsConn.totalWrite) } } return } type wrapQuicStream struct { *quic.Stream c *quic.Conn } func QuicStreamToNetConn(s *quic.Stream, c *quic.Conn) net.Conn { return &wrapQuicStream{ Stream: s, c: c, } } func (conn *wrapQuicStream) LocalAddr() net.Addr { if conn.c != nil { return conn.c.LocalAddr() } return (*net.TCPAddr)(nil) } func (conn *wrapQuicStream) RemoteAddr() net.Addr { if conn.c != nil { return conn.c.RemoteAddr() } return (*net.TCPAddr)(nil) } func (conn *wrapQuicStream) Close() error { conn.CancelRead(0) return conn.Stream.Close() } func NewCryptoReadWriter(rw io.ReadWriter, key []byte) (io.ReadWriter, error) { encReader := crypto.NewReader(rw, key) encWriter, err := crypto.NewWriter(rw, key) if err != nil { return nil, err } return struct { io.Reader io.Writer }{ Reader: encReader, Writer: encWriter, }, nil } ================================================ FILE: pkg/util/net/dial.go ================================================ package net import ( "context" "net" "net/url" libnet "github.com/fatedier/golib/net" "golang.org/x/net/websocket" ) func DialHookCustomTLSHeadByte(enableTLS bool, disableCustomTLSHeadByte bool) libnet.AfterHookFunc { return func(ctx context.Context, c net.Conn, addr string) (context.Context, net.Conn, error) { if enableTLS && !disableCustomTLSHeadByte { _, err := c.Write([]byte{byte(FRPTLSHeadByte)}) if err != nil { return nil, nil, err } } return ctx, c, nil } } func DialHookWebsocket(protocol string, host string) libnet.AfterHookFunc { return func(ctx context.Context, c net.Conn, addr string) (context.Context, net.Conn, error) { if protocol != "wss" { protocol = "ws" } if host == "" { host = addr } addr = protocol + "://" + host + FrpWebsocketPath uri, err := url.Parse(addr) if err != nil { return nil, nil, err } origin := "http://" + uri.Host cfg, err := websocket.NewConfig(addr, origin) if err != nil { return nil, nil, err } conn, err := websocket.NewClient(cfg, c) if err != nil { return nil, nil, err } return ctx, conn, nil } } ================================================ FILE: pkg/util/net/dns.go ================================================ // Copyright 2023 The frp Authors // // Licensed under the Apache License, Version 2.0 (the "License"); // you may not use this file except in compliance with the License. // You may obtain a copy of the License at // // http://www.apache.org/licenses/LICENSE-2.0 // // Unless required by applicable law or agreed to in writing, software // distributed under the License is distributed on an "AS IS" BASIS, // WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. // See the License for the specific language governing permissions and // limitations under the License. package net import ( "context" "net" ) func SetDefaultDNSAddress(dnsAddress string) { if _, _, err := net.SplitHostPort(dnsAddress); err != nil { dnsAddress = net.JoinHostPort(dnsAddress, "53") } // Change default dns server net.DefaultResolver = &net.Resolver{ PreferGo: true, Dial: func(ctx context.Context, network, _ string) (net.Conn, error) { return net.Dial(network, dnsAddress) }, } } ================================================ FILE: pkg/util/net/http.go ================================================ // Copyright 2017 fatedier, fatedier@gmail.com // // Licensed under the Apache License, Version 2.0 (the "License"); // you may not use this file except in compliance with the License. // You may obtain a copy of the License at // // http://www.apache.org/licenses/LICENSE-2.0 // // Unless required by applicable law or agreed to in writing, software // distributed under the License is distributed on an "AS IS" BASIS, // WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. // See the License for the specific language governing permissions and // limitations under the License. package net import ( "compress/gzip" "io" "net/http" "strings" "time" "github.com/fatedier/frp/pkg/util/util" ) type HTTPAuthMiddleware struct { user string passwd string authFailDelay time.Duration } func NewHTTPAuthMiddleware(user, passwd string) *HTTPAuthMiddleware { return &HTTPAuthMiddleware{ user: user, passwd: passwd, } } func (authMid *HTTPAuthMiddleware) SetAuthFailDelay(delay time.Duration) *HTTPAuthMiddleware { authMid.authFailDelay = delay return authMid } func (authMid *HTTPAuthMiddleware) Middleware(next http.Handler) http.Handler { return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { reqUser, reqPasswd, hasAuth := r.BasicAuth() if (authMid.user == "" && authMid.passwd == "") || (hasAuth && util.ConstantTimeEqString(reqUser, authMid.user) && util.ConstantTimeEqString(reqPasswd, authMid.passwd)) { next.ServeHTTP(w, r) } else { if authMid.authFailDelay > 0 { time.Sleep(authMid.authFailDelay) } w.Header().Set("WWW-Authenticate", `Basic realm="Restricted"`) http.Error(w, http.StatusText(http.StatusUnauthorized), http.StatusUnauthorized) } }) } type HTTPGzipWrapper struct { h http.Handler } func (gw *HTTPGzipWrapper) ServeHTTP(w http.ResponseWriter, r *http.Request) { if !strings.Contains(r.Header.Get("Accept-Encoding"), "gzip") { gw.h.ServeHTTP(w, r) return } w.Header().Set("Content-Encoding", "gzip") gz := gzip.NewWriter(w) defer gz.Close() gzr := gzipResponseWriter{Writer: gz, ResponseWriter: w} gw.h.ServeHTTP(gzr, r) } func MakeHTTPGzipHandler(h http.Handler) http.Handler { return &HTTPGzipWrapper{ h: h, } } type gzipResponseWriter struct { io.Writer http.ResponseWriter } func (w gzipResponseWriter) Write(b []byte) (int, error) { return w.Writer.Write(b) } ================================================ FILE: pkg/util/net/kcp.go ================================================ // Copyright 2017 fatedier, fatedier@gmail.com // // Licensed under the Apache License, Version 2.0 (the "License"); // you may not use this file except in compliance with the License. // You may obtain a copy of the License at // // http://www.apache.org/licenses/LICENSE-2.0 // // Unless required by applicable law or agreed to in writing, software // distributed under the License is distributed on an "AS IS" BASIS, // WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. // See the License for the specific language governing permissions and // limitations under the License. package net import ( "fmt" "net" kcp "github.com/xtaci/kcp-go/v5" ) type KCPListener struct { listener net.Listener acceptCh chan net.Conn closeFlag bool } func ListenKcp(address string) (l *KCPListener, err error) { listener, err := kcp.ListenWithOptions(address, nil, 10, 3) if err != nil { return l, err } _ = listener.SetReadBuffer(4194304) _ = listener.SetWriteBuffer(4194304) l = &KCPListener{ listener: listener, acceptCh: make(chan net.Conn), closeFlag: false, } go func() { for { conn, err := listener.AcceptKCP() if err != nil { if l.closeFlag { close(l.acceptCh) return } continue } conn.SetStreamMode(true) conn.SetWriteDelay(true) conn.SetNoDelay(1, 20, 2, 1) conn.SetMtu(1350) conn.SetWindowSize(1024, 1024) conn.SetACKNoDelay(false) l.acceptCh <- conn } }() return l, err } func (l *KCPListener) Accept() (net.Conn, error) { conn, ok := <-l.acceptCh if !ok { return conn, fmt.Errorf("channel for kcp listener closed") } return conn, nil } func (l *KCPListener) Close() error { if !l.closeFlag { l.closeFlag = true l.listener.Close() } return nil } func (l *KCPListener) Addr() net.Addr { return l.listener.Addr() } func NewKCPConnFromUDP(conn *net.UDPConn, connected bool, raddr string) (net.Conn, error) { udpAddr, err := net.ResolveUDPAddr("udp", raddr) if err != nil { return nil, err } var pConn net.PacketConn = conn if connected { pConn = &ConnectedUDPConn{conn} } kcpConn, err := kcp.NewConn3(1, udpAddr, nil, 10, 3, pConn) if err != nil { return nil, err } kcpConn.SetStreamMode(true) kcpConn.SetWriteDelay(true) kcpConn.SetNoDelay(1, 20, 2, 1) kcpConn.SetMtu(1350) kcpConn.SetWindowSize(1024, 1024) kcpConn.SetACKNoDelay(false) return kcpConn, nil } ================================================ FILE: pkg/util/net/listener.go ================================================ // Copyright 2017 fatedier, fatedier@gmail.com // // Licensed under the Apache License, Version 2.0 (the "License"); // you may not use this file except in compliance with the License. // You may obtain a copy of the License at // // http://www.apache.org/licenses/LICENSE-2.0 // // Unless required by applicable law or agreed to in writing, software // distributed under the License is distributed on an "AS IS" BASIS, // WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. // See the License for the specific language governing permissions and // limitations under the License. package net import ( "fmt" "net" "sync" "github.com/fatedier/golib/errors" ) // InternalListener is a listener that can be used to accept connections from // other goroutines. type InternalListener struct { acceptCh chan net.Conn closed bool mu sync.Mutex } func NewInternalListener() *InternalListener { return &InternalListener{ acceptCh: make(chan net.Conn, 128), } } func (l *InternalListener) Accept() (net.Conn, error) { conn, ok := <-l.acceptCh if !ok { return nil, fmt.Errorf("listener closed") } return conn, nil } func (l *InternalListener) PutConn(conn net.Conn) error { err := errors.PanicToError(func() { select { case l.acceptCh <- conn: default: conn.Close() } }) if err != nil { return fmt.Errorf("put conn error: listener is closed") } return nil } func (l *InternalListener) Close() error { l.mu.Lock() defer l.mu.Unlock() if !l.closed { close(l.acceptCh) l.closed = true } return nil } func (l *InternalListener) Addr() net.Addr { return &InternalAddr{} } type InternalAddr struct{} func (ia *InternalAddr) Network() string { return "internal" } func (ia *InternalAddr) String() string { return "internal" } ================================================ FILE: pkg/util/net/proxyprotocol.go ================================================ // Copyright 2025 The frp Authors // // Licensed under the Apache License, Version 2.0 (the "License"); // you may not use this file except in compliance with the License. // You may obtain a copy of the License at // // http://www.apache.org/licenses/LICENSE-2.0 // // Unless required by applicable law or agreed to in writing, software // distributed under the License is distributed on an "AS IS" BASIS, // WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. // See the License for the specific language governing permissions and // limitations under the License. package net import ( "bytes" "fmt" "net" pp "github.com/pires/go-proxyproto" ) func BuildProxyProtocolHeaderStruct(srcAddr, dstAddr net.Addr, version string) *pp.Header { var versionByte byte if version == "v1" { versionByte = 1 } else { versionByte = 2 // default to v2 } return pp.HeaderProxyFromAddrs(versionByte, srcAddr, dstAddr) } func BuildProxyProtocolHeader(srcAddr, dstAddr net.Addr, version string) ([]byte, error) { h := BuildProxyProtocolHeaderStruct(srcAddr, dstAddr, version) // Convert header to bytes using a buffer var buf bytes.Buffer _, err := h.WriteTo(&buf) if err != nil { return nil, fmt.Errorf("failed to write proxy protocol header: %v", err) } return buf.Bytes(), nil } ================================================ FILE: pkg/util/net/proxyprotocol_test.go ================================================ package net import ( "net" "testing" pp "github.com/pires/go-proxyproto" "github.com/stretchr/testify/require" ) func TestBuildProxyProtocolHeader(t *testing.T) { require := require.New(t) tests := []struct { name string srcAddr net.Addr dstAddr net.Addr version string expectError bool }{ { name: "UDP IPv4 v2", srcAddr: &net.UDPAddr{IP: net.ParseIP("192.168.1.100"), Port: 12345}, dstAddr: &net.UDPAddr{IP: net.ParseIP("10.0.0.1"), Port: 3306}, version: "v2", expectError: false, }, { name: "TCP IPv4 v1", srcAddr: &net.TCPAddr{IP: net.ParseIP("192.168.1.100"), Port: 12345}, dstAddr: &net.TCPAddr{IP: net.ParseIP("10.0.0.1"), Port: 80}, version: "v1", expectError: false, }, { name: "UDP IPv6 v2", srcAddr: &net.UDPAddr{IP: net.ParseIP("2001:db8::1"), Port: 12345}, dstAddr: &net.UDPAddr{IP: net.ParseIP("::1"), Port: 3306}, version: "v2", expectError: false, }, { name: "TCP IPv6 v1", srcAddr: &net.TCPAddr{IP: net.ParseIP("::1"), Port: 12345}, dstAddr: &net.TCPAddr{IP: net.ParseIP("2001:db8::1"), Port: 80}, version: "v1", expectError: false, }, { name: "nil source address", srcAddr: nil, dstAddr: &net.UDPAddr{IP: net.ParseIP("10.0.0.1"), Port: 3306}, version: "v2", expectError: false, }, { name: "nil destination address", srcAddr: &net.TCPAddr{IP: net.ParseIP("192.168.1.100"), Port: 12345}, dstAddr: nil, version: "v2", expectError: false, }, { name: "unsupported address type", srcAddr: &net.UnixAddr{Name: "/tmp/test.sock", Net: "unix"}, dstAddr: &net.UDPAddr{IP: net.ParseIP("10.0.0.1"), Port: 3306}, version: "v2", expectError: false, }, } for _, tt := range tests { header, err := BuildProxyProtocolHeader(tt.srcAddr, tt.dstAddr, tt.version) if tt.expectError { require.Error(err, "test case: %s", tt.name) continue } require.NoError(err, "test case: %s", tt.name) require.NotEmpty(header, "test case: %s", tt.name) } } func TestBuildProxyProtocolHeaderStruct(t *testing.T) { require := require.New(t) tests := []struct { name string srcAddr net.Addr dstAddr net.Addr version string expectedProtocol pp.AddressFamilyAndProtocol expectedVersion byte expectedCommand pp.ProtocolVersionAndCommand expectedSourceAddr net.Addr expectedDestAddr net.Addr }{ { name: "TCP IPv4 v2", srcAddr: &net.TCPAddr{IP: net.ParseIP("192.168.1.100"), Port: 12345}, dstAddr: &net.TCPAddr{IP: net.ParseIP("10.0.0.1"), Port: 80}, version: "v2", expectedProtocol: pp.TCPv4, expectedVersion: 2, expectedCommand: pp.PROXY, expectedSourceAddr: &net.TCPAddr{IP: net.ParseIP("192.168.1.100"), Port: 12345}, expectedDestAddr: &net.TCPAddr{IP: net.ParseIP("10.0.0.1"), Port: 80}, }, { name: "UDP IPv6 v1", srcAddr: &net.UDPAddr{IP: net.ParseIP("2001:db8::1"), Port: 12345}, dstAddr: &net.UDPAddr{IP: net.ParseIP("::1"), Port: 3306}, version: "v1", expectedProtocol: pp.UDPv6, expectedVersion: 1, expectedCommand: pp.PROXY, expectedSourceAddr: &net.UDPAddr{IP: net.ParseIP("2001:db8::1"), Port: 12345}, expectedDestAddr: &net.UDPAddr{IP: net.ParseIP("::1"), Port: 3306}, }, { name: "TCP IPv6 default version", srcAddr: &net.TCPAddr{IP: net.ParseIP("::1"), Port: 12345}, dstAddr: &net.TCPAddr{IP: net.ParseIP("2001:db8::1"), Port: 80}, version: "", expectedProtocol: pp.TCPv6, expectedVersion: 2, // default to v2 expectedCommand: pp.PROXY, expectedSourceAddr: &net.TCPAddr{IP: net.ParseIP("::1"), Port: 12345}, expectedDestAddr: &net.TCPAddr{IP: net.ParseIP("2001:db8::1"), Port: 80}, }, { name: "nil source address", srcAddr: nil, dstAddr: &net.UDPAddr{IP: net.ParseIP("10.0.0.1"), Port: 3306}, version: "v2", expectedProtocol: pp.UNSPEC, expectedVersion: 2, expectedCommand: pp.LOCAL, expectedSourceAddr: nil, // go-proxyproto sets both to nil when srcAddr is nil expectedDestAddr: nil, }, { name: "nil destination address", srcAddr: &net.TCPAddr{IP: net.ParseIP("192.168.1.100"), Port: 12345}, dstAddr: nil, version: "v2", expectedProtocol: pp.UNSPEC, expectedVersion: 2, expectedCommand: pp.LOCAL, expectedSourceAddr: nil, // go-proxyproto sets both to nil when dstAddr is nil expectedDestAddr: nil, }, { name: "unsupported address type", srcAddr: &net.UnixAddr{Name: "/tmp/test.sock", Net: "unix"}, dstAddr: &net.UDPAddr{IP: net.ParseIP("10.0.0.1"), Port: 3306}, version: "v2", expectedProtocol: pp.UNSPEC, expectedVersion: 2, expectedCommand: pp.LOCAL, expectedSourceAddr: nil, // go-proxyproto sets both to nil for unsupported types expectedDestAddr: nil, }, } for _, tt := range tests { header := BuildProxyProtocolHeaderStruct(tt.srcAddr, tt.dstAddr, tt.version) require.NotNil(header, "test case: %s", tt.name) require.Equal(tt.expectedCommand, header.Command, "test case: %s", tt.name) require.Equal(tt.expectedSourceAddr, header.SourceAddr, "test case: %s", tt.name) require.Equal(tt.expectedDestAddr, header.DestinationAddr, "test case: %s", tt.name) require.Equal(tt.expectedProtocol, header.TransportProtocol, "test case: %s", tt.name) require.Equal(tt.expectedVersion, header.Version, "test case: %s", tt.name) } } ================================================ FILE: pkg/util/net/tls.go ================================================ // Copyright 2019 fatedier, fatedier@gmail.com // // Licensed under the Apache License, Version 2.0 (the "License"); // you may not use this file except in compliance with the License. // You may obtain a copy of the License at // // http://www.apache.org/licenses/LICENSE-2.0 // // Unless required by applicable law or agreed to in writing, software // distributed under the License is distributed on an "AS IS" BASIS, // WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. // See the License for the specific language governing permissions and // limitations under the License. package net import ( "crypto/tls" "fmt" "net" "time" libnet "github.com/fatedier/golib/net" ) var FRPTLSHeadByte = 0x17 func CheckAndEnableTLSServerConnWithTimeout( c net.Conn, tlsConfig *tls.Config, tlsOnly bool, timeout time.Duration, ) (out net.Conn, isTLS bool, custom bool, err error) { sc, r := libnet.NewSharedConnSize(c, 2) buf := make([]byte, 1) var n int _ = c.SetReadDeadline(time.Now().Add(timeout)) n, err = r.Read(buf) _ = c.SetReadDeadline(time.Time{}) if err != nil { return } switch { case n == 1 && int(buf[0]) == FRPTLSHeadByte: out = tls.Server(c, tlsConfig) isTLS = true custom = true case n == 1 && int(buf[0]) == 0x16: out = tls.Server(sc, tlsConfig) isTLS = true default: if tlsOnly { err = fmt.Errorf("non-TLS connection received on a TlsOnly server") return } out = sc } return } ================================================ FILE: pkg/util/net/udp.go ================================================ // Copyright 2017 fatedier, fatedier@gmail.com // // Licensed under the Apache License, Version 2.0 (the "License"); // you may not use this file except in compliance with the License. // You may obtain a copy of the License at // // http://www.apache.org/licenses/LICENSE-2.0 // // Unless required by applicable law or agreed to in writing, software // distributed under the License is distributed on an "AS IS" BASIS, // WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. // See the License for the specific language governing permissions and // limitations under the License. package net import ( "fmt" "io" "net" "strconv" "sync" "time" "github.com/fatedier/golib/pool" ) type UDPPacket struct { Buf []byte LocalAddr net.Addr RemoteAddr net.Addr } type FakeUDPConn struct { l *UDPListener localAddr net.Addr remoteAddr net.Addr packets chan []byte closeFlag bool lastActive time.Time mu sync.RWMutex } func NewFakeUDPConn(l *UDPListener, laddr, raddr net.Addr) *FakeUDPConn { fc := &FakeUDPConn{ l: l, localAddr: laddr, remoteAddr: raddr, packets: make(chan []byte, 20), } go func() { for { time.Sleep(5 * time.Second) fc.mu.RLock() if time.Since(fc.lastActive) > 10*time.Second { fc.mu.RUnlock() fc.Close() break } fc.mu.RUnlock() } }() return fc } func (c *FakeUDPConn) putPacket(content []byte) { defer func() { _ = recover() }() select { case c.packets <- content: default: } } func (c *FakeUDPConn) Read(b []byte) (n int, err error) { content, ok := <-c.packets if !ok { return 0, io.EOF } c.mu.Lock() c.lastActive = time.Now() c.mu.Unlock() n = min(len(b), len(content)) copy(b, content) return n, nil } func (c *FakeUDPConn) Write(b []byte) (n int, err error) { c.mu.RLock() if c.closeFlag { c.mu.RUnlock() return 0, io.ErrClosedPipe } c.mu.RUnlock() packet := &UDPPacket{ Buf: b, LocalAddr: c.localAddr, RemoteAddr: c.remoteAddr, } _ = c.l.writeUDPPacket(packet) c.mu.Lock() c.lastActive = time.Now() c.mu.Unlock() return len(b), nil } func (c *FakeUDPConn) Close() error { c.mu.Lock() defer c.mu.Unlock() if !c.closeFlag { c.closeFlag = true close(c.packets) } return nil } func (c *FakeUDPConn) IsClosed() bool { c.mu.RLock() defer c.mu.RUnlock() return c.closeFlag } func (c *FakeUDPConn) LocalAddr() net.Addr { return c.localAddr } func (c *FakeUDPConn) RemoteAddr() net.Addr { return c.remoteAddr } func (c *FakeUDPConn) SetDeadline(_ time.Time) error { return nil } func (c *FakeUDPConn) SetReadDeadline(_ time.Time) error { return nil } func (c *FakeUDPConn) SetWriteDeadline(_ time.Time) error { return nil } type UDPListener struct { addr net.Addr acceptCh chan net.Conn writeCh chan *UDPPacket readConn net.Conn closeFlag bool fakeConns map[string]*FakeUDPConn } func ListenUDP(bindAddr string, bindPort int) (l *UDPListener, err error) { udpAddr, err := net.ResolveUDPAddr("udp", net.JoinHostPort(bindAddr, strconv.Itoa(bindPort))) if err != nil { return l, err } readConn, err := net.ListenUDP("udp", udpAddr) if err != nil { return l, err } l = &UDPListener{ addr: udpAddr, acceptCh: make(chan net.Conn), writeCh: make(chan *UDPPacket, 1000), readConn: readConn, fakeConns: make(map[string]*FakeUDPConn), } // for reading go func() { for { buf := pool.GetBuf(1450) n, remoteAddr, err := readConn.ReadFromUDP(buf) if err != nil { close(l.acceptCh) close(l.writeCh) return } fakeConn, exist := l.fakeConns[remoteAddr.String()] if !exist || fakeConn.IsClosed() { fakeConn = NewFakeUDPConn(l, l.Addr(), remoteAddr) l.fakeConns[remoteAddr.String()] = fakeConn } fakeConn.putPacket(buf[:n]) l.acceptCh <- fakeConn } }() // for writing go func() { for { packet, ok := <-l.writeCh if !ok { return } if addr, ok := packet.RemoteAddr.(*net.UDPAddr); ok { _, _ = readConn.WriteToUDP(packet.Buf, addr) } } }() return } func (l *UDPListener) writeUDPPacket(packet *UDPPacket) (err error) { defer func() { if errRet := recover(); errRet != nil { err = fmt.Errorf("udp write closed listener") } }() l.writeCh <- packet return } func (l *UDPListener) WriteMsg(buf []byte, remoteAddr *net.UDPAddr) (err error) { // only set remote addr here packet := &UDPPacket{ Buf: buf, RemoteAddr: remoteAddr, } err = l.writeUDPPacket(packet) return } func (l *UDPListener) Accept() (net.Conn, error) { conn, ok := <-l.acceptCh if !ok { return conn, fmt.Errorf("channel for udp listener closed") } return conn, nil } func (l *UDPListener) Close() error { if !l.closeFlag { l.closeFlag = true if l.readConn != nil { l.readConn.Close() } } return nil } func (l *UDPListener) Addr() net.Addr { return l.addr } // ConnectedUDPConn is a wrapper for net.UDPConn which converts WriteTo syscalls // to Write syscalls that are 4 times faster on some OS'es. This should only be // used for connections that were produced by a net.Dial* call. type ConnectedUDPConn struct{ *net.UDPConn } // WriteTo redirects all writes to the Write syscall, which is 4 times faster. func (c *ConnectedUDPConn) WriteTo(b []byte, _ net.Addr) (int, error) { return c.Write(b) } ================================================ FILE: pkg/util/net/websocket.go ================================================ package net import ( "errors" "net" "net/http" "time" "golang.org/x/net/websocket" ) var ErrWebsocketListenerClosed = errors.New("websocket listener closed") const ( FrpWebsocketPath = "/~!frp" ) type WebsocketListener struct { ln net.Listener acceptCh chan net.Conn server *http.Server } // NewWebsocketListener to handle websocket connections // ln: tcp listener for websocket connections func NewWebsocketListener(ln net.Listener) (wl *WebsocketListener) { wl = &WebsocketListener{ ln: ln, acceptCh: make(chan net.Conn), } muxer := http.NewServeMux() muxer.Handle(FrpWebsocketPath, websocket.Handler(func(c *websocket.Conn) { notifyCh := make(chan struct{}) conn := WrapCloseNotifyConn(c, func(_ error) { close(notifyCh) }) wl.acceptCh <- conn <-notifyCh })) wl.server = &http.Server{ Addr: ln.Addr().String(), Handler: muxer, ReadHeaderTimeout: 60 * time.Second, } go func() { _ = wl.server.Serve(ln) }() return } func (p *WebsocketListener) Accept() (net.Conn, error) { c, ok := <-p.acceptCh if !ok { return nil, ErrWebsocketListenerClosed } return c, nil } func (p *WebsocketListener) Close() error { return p.server.Close() } func (p *WebsocketListener) Addr() net.Addr { return p.ln.Addr() } ================================================ FILE: pkg/util/system/system.go ================================================ // Copyright 2024 The frp Authors // // Licensed under the Apache License, Version 2.0 (the "License"); // you may not use this file except in compliance with the License. // You may obtain a copy of the License at // // http://www.apache.org/licenses/LICENSE-2.0 // // Unless required by applicable law or agreed to in writing, software // distributed under the License is distributed on an "AS IS" BASIS, // WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. // See the License for the specific language governing permissions and // limitations under the License. //go:build !android package system // EnableCompatibilityMode enables compatibility mode for different system. // For example, on Android, the inability to obtain the correct time zone will result in incorrect log time output. func EnableCompatibilityMode() { } ================================================ FILE: pkg/util/system/system_android.go ================================================ // Copyright 2024 The frp Authors // // Licensed under the Apache License, Version 2.0 (the "License"); // you may not use this file except in compliance with the License. // You may obtain a copy of the License at // // http://www.apache.org/licenses/LICENSE-2.0 // // Unless required by applicable law or agreed to in writing, software // distributed under the License is distributed on an "AS IS" BASIS, // WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. // See the License for the specific language governing permissions and // limitations under the License. package system import ( "context" "net" "os/exec" "strings" "time" ) func EnableCompatibilityMode() { fixTimezone() fixDNSResolver() } // fixTimezone is used to try our best to fix timezone issue on some Android devices. func fixTimezone() { out, err := exec.Command("/system/bin/getprop", "persist.sys.timezone").Output() if err != nil { return } loc, err := time.LoadLocation(strings.TrimSpace(string(out))) if err != nil { return } time.Local = loc } // fixDNSResolver will first attempt to resolve google.com to check if the current DNS is available. // If it is not available, it will default to using 8.8.8.8 as the DNS server. // This is a workaround for the issue that golang can't get the default DNS servers on Android. func fixDNSResolver() { // First, we attempt to resolve a domain. If resolution is successful, no modifications are necessary. // In real-world scenarios, users may have already configured /etc/resolv.conf, or compiled directly // in the Android environment instead of using cross-platform compilation, so this issue does not arise. if net.DefaultResolver != nil { timeoutCtx, cancel := context.WithTimeout(context.Background(), time.Second) defer cancel() _, err := net.DefaultResolver.LookupHost(timeoutCtx, "google.com") if err == nil { return } } // If the resolution fails, use 8.8.8.8 as the DNS server. // Note: If there are other methods to obtain the default DNS servers, the default DNS servers should be used preferentially. net.DefaultResolver = &net.Resolver{ PreferGo: true, Dial: func(ctx context.Context, network, addr string) (net.Conn, error) { if addr == "127.0.0.1:53" || addr == "[::1]:53" { addr = "8.8.8.8:53" } var d net.Dialer return d.DialContext(ctx, network, addr) }, } } ================================================ FILE: pkg/util/tcpmux/httpconnect.go ================================================ // Copyright 2020 guylewin, guy@lewin.co.il // // Licensed under the Apache License, Version 2.0 (the "License"); // you may not use this file except in compliance with the License. // You may obtain a copy of the License at // // http://www.apache.org/licenses/LICENSE-2.0 // // Unless required by applicable law or agreed to in writing, software // distributed under the License is distributed on an "AS IS" BASIS, // WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. // See the License for the specific language governing permissions and // limitations under the License. package tcpmux import ( "bufio" "fmt" "io" "net" "net/http" "time" libnet "github.com/fatedier/golib/net" httppkg "github.com/fatedier/frp/pkg/util/http" "github.com/fatedier/frp/pkg/util/vhost" ) type HTTPConnectTCPMuxer struct { *vhost.Muxer // If passthrough is set to true, the CONNECT request will be forwarded to the backend service. // Otherwise, it will return an OK response to the client and forward the remaining content to the backend service. passthrough bool } func NewHTTPConnectTCPMuxer(listener net.Listener, passthrough bool, timeout time.Duration) (*HTTPConnectTCPMuxer, error) { ret := &HTTPConnectTCPMuxer{passthrough: passthrough} mux, err := vhost.NewMuxer(listener, ret.getHostFromHTTPConnect, timeout) mux.SetCheckAuthFunc(ret.auth). SetSuccessHookFunc(ret.sendConnectResponse). SetFailHookFunc(vhostFailed) ret.Muxer = mux return ret, err } func (muxer *HTTPConnectTCPMuxer) readHTTPConnectRequest(rd io.Reader) (host, httpUser, httpPwd string, err error) { bufioReader := bufio.NewReader(rd) req, err := http.ReadRequest(bufioReader) if err != nil { return } if req.Method != "CONNECT" { err = fmt.Errorf("connections to tcp vhost must be of method CONNECT") return } host, _ = httppkg.CanonicalHost(req.Host) proxyAuth := req.Header.Get("Proxy-Authorization") if proxyAuth != "" { httpUser, httpPwd, _ = httppkg.ParseBasicAuth(proxyAuth) } return } func (muxer *HTTPConnectTCPMuxer) sendConnectResponse(c net.Conn, _ map[string]string) error { if muxer.passthrough { return nil } res := httppkg.OkResponse() if res.Body != nil { defer res.Body.Close() } return res.Write(c) } func (muxer *HTTPConnectTCPMuxer) auth(c net.Conn, username, password string, reqInfo map[string]string) (bool, error) { reqUsername := reqInfo["HTTPUser"] reqPassword := reqInfo["HTTPPwd"] if username == reqUsername && password == reqPassword { return true, nil } resp := httppkg.ProxyUnauthorizedResponse() if resp.Body != nil { defer resp.Body.Close() } _ = resp.Write(c) return false, nil } func vhostFailed(c net.Conn) { res := vhost.NotFoundResponse() if res.Body != nil { defer res.Body.Close() } _ = res.Write(c) _ = c.Close() } func (muxer *HTTPConnectTCPMuxer) getHostFromHTTPConnect(c net.Conn) (net.Conn, map[string]string, error) { reqInfoMap := make(map[string]string, 0) sc, rd := libnet.NewSharedConn(c) host, httpUser, httpPwd, err := muxer.readHTTPConnectRequest(rd) if err != nil { return nil, reqInfoMap, err } reqInfoMap["Host"] = host reqInfoMap["Scheme"] = "tcp" reqInfoMap["HTTPUser"] = httpUser reqInfoMap["HTTPPwd"] = httpPwd outConn := c if muxer.passthrough { outConn = sc } return outConn, reqInfoMap, nil } ================================================ FILE: pkg/util/util/types.go ================================================ // Copyright 2023 The frp Authors // // Licensed under the Apache License, Version 2.0 (the "License"); // you may not use this file except in compliance with the License. // You may obtain a copy of the License at // // http://www.apache.org/licenses/LICENSE-2.0 // // Unless required by applicable law or agreed to in writing, software // distributed under the License is distributed on an "AS IS" BASIS, // WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. // See the License for the specific language governing permissions and // limitations under the License. package util func EmptyOr[T comparable](v T, fallback T) T { var zero T if zero == v { return fallback } return v } ================================================ FILE: pkg/util/util/util.go ================================================ // Copyright 2017 fatedier, fatedier@gmail.com // // Licensed under the Apache License, Version 2.0 (the "License"); // you may not use this file except in compliance with the License. // You may obtain a copy of the License at // // http://www.apache.org/licenses/LICENSE-2.0 // // Unless required by applicable law or agreed to in writing, software // distributed under the License is distributed on an "AS IS" BASIS, // WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. // See the License for the specific language governing permissions and // limitations under the License. package util import ( "crypto/md5" "crypto/rand" "crypto/subtle" "encoding/hex" "fmt" mathrand "math/rand/v2" "net" "strconv" "strings" "time" ) // RandID return a rand string used in frp. func RandID() (id string, err error) { return RandIDWithLen(16) } // RandIDWithLen return a rand string with idLen length. func RandIDWithLen(idLen int) (id string, err error) { if idLen <= 0 { return "", nil } b := make([]byte, idLen/2+1) _, err = rand.Read(b) if err != nil { return } id = fmt.Sprintf("%x", b) return id[:idLen], nil } func GetAuthKey(token string, timestamp int64) (key string) { md5Ctx := md5.New() md5Ctx.Write([]byte(token)) md5Ctx.Write([]byte(strconv.FormatInt(timestamp, 10))) data := md5Ctx.Sum(nil) return hex.EncodeToString(data) } func CanonicalAddr(host string, port int) (addr string) { if port == 80 || port == 443 { addr = host } else { addr = net.JoinHostPort(host, strconv.Itoa(port)) } return } func ParseRangeNumbers(rangeStr string) (numbers []int64, err error) { rangeStr = strings.TrimSpace(rangeStr) numbers = make([]int64, 0) // e.g. 1000-2000,2001,2002,3000-4000 numRanges := strings.SplitSeq(rangeStr, ",") for numRangeStr := range numRanges { // 1000-2000 or 2001 numArray := strings.Split(numRangeStr, "-") // length: only 1 or 2 is correct rangeType := len(numArray) switch rangeType { case 1: // single number singleNum, errRet := strconv.ParseInt(strings.TrimSpace(numArray[0]), 10, 64) if errRet != nil { err = fmt.Errorf("range number is invalid, %v", errRet) return } numbers = append(numbers, singleNum) case 2: // range numbers minValue, errRet := strconv.ParseInt(strings.TrimSpace(numArray[0]), 10, 64) if errRet != nil { err = fmt.Errorf("range number is invalid, %v", errRet) return } maxValue, errRet := strconv.ParseInt(strings.TrimSpace(numArray[1]), 10, 64) if errRet != nil { err = fmt.Errorf("range number is invalid, %v", errRet) return } if maxValue < minValue { err = fmt.Errorf("range number is invalid") return } for i := minValue; i <= maxValue; i++ { numbers = append(numbers, i) } default: err = fmt.Errorf("range number is invalid") return } } return } func GenerateResponseErrorString(summary string, err error, detailed bool) string { if detailed { return err.Error() } return summary } func RandomSleep(duration time.Duration, minRatio, maxRatio float64) time.Duration { minValue := int64(minRatio * 1000.0) maxValue := int64(maxRatio * 1000.0) var n int64 if maxValue <= minValue { n = minValue } else { n = mathrand.Int64N(maxValue-minValue) + minValue } d := duration * time.Duration(n) / time.Duration(1000) time.Sleep(d) return d } func ConstantTimeEqString(a, b string) bool { return subtle.ConstantTimeCompare([]byte(a), []byte(b)) == 1 } // ClonePtr returns a pointer to a copied value. If v is nil, it returns nil. func ClonePtr[T any](v *T) *T { if v == nil { return nil } out := *v return &out } ================================================ FILE: pkg/util/util/util_test.go ================================================ package util import ( "testing" "github.com/stretchr/testify/require" ) func TestRandId(t *testing.T) { require := require.New(t) id, err := RandID() require.NoError(err) t.Log(id) require.Equal(16, len(id)) } func TestGetAuthKey(t *testing.T) { require := require.New(t) key := GetAuthKey("1234", 1488720000) require.Equal("6df41a43725f0c770fd56379e12acf8c", key) } func TestParseRangeNumbers(t *testing.T) { require := require.New(t) numbers, err := ParseRangeNumbers("2-5") require.NoError(err) require.Equal([]int64{2, 3, 4, 5}, numbers) numbers, err = ParseRangeNumbers("1") require.NoError(err) require.Equal([]int64{1}, numbers) numbers, err = ParseRangeNumbers("3-5,8") require.NoError(err) require.Equal([]int64{3, 4, 5, 8}, numbers) numbers, err = ParseRangeNumbers(" 3-5,8, 10-12 ") require.NoError(err) require.Equal([]int64{3, 4, 5, 8, 10, 11, 12}, numbers) _, err = ParseRangeNumbers("3-a") require.Error(err) } func TestClonePtr(t *testing.T) { require := require.New(t) var nilInt *int require.Nil(ClonePtr(nilInt)) v := 42 cloned := ClonePtr(&v) require.NotNil(cloned) require.Equal(v, *cloned) require.NotSame(&v, cloned) } ================================================ FILE: pkg/util/version/version.go ================================================ // Copyright 2016 fatedier, fatedier@gmail.com // // Licensed under the Apache License, Version 2.0 (the "License"); // you may not use this file except in compliance with the License. // You may obtain a copy of the License at // // http://www.apache.org/licenses/LICENSE-2.0 // // Unless required by applicable law or agreed to in writing, software // distributed under the License is distributed on an "AS IS" BASIS, // WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. // See the License for the specific language governing permissions and // limitations under the License. package version var version = "0.68.0" func Full() string { return version } ================================================ FILE: pkg/util/vhost/http.go ================================================ // Copyright 2017 fatedier, fatedier@gmail.com // // Licensed under the Apache License, Version 2.0 (the "License"); // you may not use this file except in compliance with the License. // You may obtain a copy of the License at // // http://www.apache.org/licenses/LICENSE-2.0 // // Unless required by applicable law or agreed to in writing, software // distributed under the License is distributed on an "AS IS" BASIS, // WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. // See the License for the specific language governing permissions and // limitations under the License. package vhost import ( "context" "encoding/base64" "errors" "fmt" stdlog "log" "net" "net/http" "net/http/httputil" "net/url" "strings" "time" libio "github.com/fatedier/golib/io" "github.com/fatedier/golib/pool" "golang.org/x/net/http2" "golang.org/x/net/http2/h2c" httppkg "github.com/fatedier/frp/pkg/util/http" "github.com/fatedier/frp/pkg/util/log" ) var ErrNoRouteFound = errors.New("no route found") type HTTPReverseProxyOptions struct { ResponseHeaderTimeoutS int64 } type HTTPReverseProxy struct { proxy http.Handler vhostRouter *Routers responseHeaderTimeout time.Duration } func NewHTTPReverseProxy(option HTTPReverseProxyOptions, vhostRouter *Routers) *HTTPReverseProxy { if option.ResponseHeaderTimeoutS <= 0 { option.ResponseHeaderTimeoutS = 60 } rp := &HTTPReverseProxy{ responseHeaderTimeout: time.Duration(option.ResponseHeaderTimeoutS) * time.Second, vhostRouter: vhostRouter, } proxy := &httputil.ReverseProxy{ // Modify incoming requests by route policies. Rewrite: func(r *httputil.ProxyRequest) { r.Out.Header["X-Forwarded-For"] = r.In.Header["X-Forwarded-For"] r.SetXForwarded() req := r.Out req.URL.Scheme = "http" reqRouteInfo := req.Context().Value(RouteInfoKey).(*RequestRouteInfo) originalHost, _ := httppkg.CanonicalHost(reqRouteInfo.Host) rc := req.Context().Value(RouteConfigKey).(*RouteConfig) if rc != nil { if rc.RewriteHost != "" { req.Host = rc.RewriteHost } var endpoint string if rc.ChooseEndpointFn != nil { // ignore error here, it will use CreateConnFn instead later endpoint, _ = rc.ChooseEndpointFn() reqRouteInfo.Endpoint = endpoint log.Tracef("choose endpoint name [%s] for http request host [%s] path [%s] httpuser [%s]", endpoint, originalHost, reqRouteInfo.URL, reqRouteInfo.HTTPUser) } // Set {domain}.{location}.{routeByHTTPUser}.{endpoint} as URL host here to let http transport reuse connections. req.URL.Host = rc.Domain + "." + base64.StdEncoding.EncodeToString([]byte(rc.Location)) + "." + base64.StdEncoding.EncodeToString([]byte(rc.RouteByHTTPUser)) + "." + base64.StdEncoding.EncodeToString([]byte(endpoint)) for k, v := range rc.Headers { req.Header.Set(k, v) } } else { req.URL.Host = req.Host } }, ModifyResponse: func(r *http.Response) error { rc := r.Request.Context().Value(RouteConfigKey).(*RouteConfig) if rc != nil { for k, v := range rc.ResponseHeaders { r.Header.Set(k, v) } } return nil }, // Create a connection to one proxy routed by route policy. Transport: &http.Transport{ ResponseHeaderTimeout: rp.responseHeaderTimeout, IdleConnTimeout: 60 * time.Second, MaxIdleConnsPerHost: 5, DialContext: func(ctx context.Context, network, addr string) (net.Conn, error) { return rp.CreateConnection(ctx.Value(RouteInfoKey).(*RequestRouteInfo), true) }, Proxy: func(req *http.Request) (*url.URL, error) { // Use proxy mode if there is host in HTTP first request line. // GET http://example.com/ HTTP/1.1 // Host: example.com // // Normal: // GET / HTTP/1.1 // Host: example.com urlHost := req.Context().Value(RouteInfoKey).(*RequestRouteInfo).URLHost if urlHost != "" { return req.URL, nil } return nil, nil }, }, BufferPool: pool.NewBuffer(32 * 1024), ErrorLog: stdlog.New(log.NewWriteLogger(log.WarnLevel, 2), "", 0), ErrorHandler: func(rw http.ResponseWriter, req *http.Request, err error) { log.Logf(log.WarnLevel, 1, "do http proxy request [host: %s] error: %v", req.Host, err) if err != nil { if e, ok := err.(net.Error); ok && e.Timeout() { rw.WriteHeader(http.StatusGatewayTimeout) return } } rw.WriteHeader(http.StatusNotFound) _, _ = rw.Write(getNotFoundPageContent()) }, } rp.proxy = h2c.NewHandler(proxy, &http2.Server{}) return rp } // Register register the route config to reverse proxy // reverse proxy will use CreateConnFn from routeCfg to create a connection to the remote service func (rp *HTTPReverseProxy) Register(routeCfg RouteConfig) error { err := rp.vhostRouter.Add(routeCfg.Domain, routeCfg.Location, routeCfg.RouteByHTTPUser, &routeCfg) if err != nil { return err } return nil } // UnRegister unregister route config by domain and location func (rp *HTTPReverseProxy) UnRegister(routeCfg RouteConfig) { rp.vhostRouter.Del(routeCfg.Domain, routeCfg.Location, routeCfg.RouteByHTTPUser) } func (rp *HTTPReverseProxy) GetRouteConfig(domain, location, routeByHTTPUser string) *RouteConfig { vr, ok := rp.getVhost(domain, location, routeByHTTPUser) if ok { log.Debugf("get new http request host [%s] path [%s] httpuser [%s]", domain, location, routeByHTTPUser) return vr.payload.(*RouteConfig) } return nil } // CreateConnection create a new connection by route config func (rp *HTTPReverseProxy) CreateConnection(reqRouteInfo *RequestRouteInfo, byEndpoint bool) (net.Conn, error) { host, _ := httppkg.CanonicalHost(reqRouteInfo.Host) vr, ok := rp.getVhost(host, reqRouteInfo.URL, reqRouteInfo.HTTPUser) if ok { if byEndpoint { fn := vr.payload.(*RouteConfig).CreateConnByEndpointFn if fn != nil { return fn(reqRouteInfo.Endpoint, reqRouteInfo.RemoteAddr) } } fn := vr.payload.(*RouteConfig).CreateConnFn if fn != nil { return fn(reqRouteInfo.RemoteAddr) } } return nil, fmt.Errorf("%v: %s %s %s", ErrNoRouteFound, host, reqRouteInfo.URL, reqRouteInfo.HTTPUser) } func (rp *HTTPReverseProxy) CheckAuth(domain, location, routeByHTTPUser, user, passwd string) bool { vr, ok := rp.getVhost(domain, location, routeByHTTPUser) if ok { checkUser := vr.payload.(*RouteConfig).Username checkPasswd := vr.payload.(*RouteConfig).Password if (checkUser != "" || checkPasswd != "") && (checkUser != user || checkPasswd != passwd) { return false } } return true } // getVhost tries to get vhost router by route policy. func (rp *HTTPReverseProxy) getVhost(domain, location, routeByHTTPUser string) (*Router, bool) { findRouter := func(inDomain, inLocation, inRouteByHTTPUser string) (*Router, bool) { vr, ok := rp.vhostRouter.Get(inDomain, inLocation, inRouteByHTTPUser) if ok { return vr, ok } // Try to check if there is one proxy that doesn't specify routerByHTTPUser, it means match all. vr, ok = rp.vhostRouter.Get(inDomain, inLocation, "") if ok { return vr, ok } return nil, false } // First we check the full hostname // if not exist, then check the wildcard_domain such as *.example.com vr, ok := findRouter(domain, location, routeByHTTPUser) if ok { return vr, ok } // e.g. domain = test.example.com, try to match wildcard domains. // *.example.com // *.com domainSplit := strings.Split(domain, ".") for len(domainSplit) >= 3 { domainSplit[0] = "*" domain = strings.Join(domainSplit, ".") vr, ok = findRouter(domain, location, routeByHTTPUser) if ok { return vr, true } domainSplit = domainSplit[1:] } // Finally, try to check if there is one proxy that domain is "*" means match all domains. vr, ok = findRouter("*", location, routeByHTTPUser) if ok { return vr, true } return nil, false } func (rp *HTTPReverseProxy) connectHandler(rw http.ResponseWriter, req *http.Request) { hj, ok := rw.(http.Hijacker) if !ok { rw.WriteHeader(http.StatusInternalServerError) return } client, _, err := hj.Hijack() if err != nil { rw.WriteHeader(http.StatusInternalServerError) return } remote, err := rp.CreateConnection(req.Context().Value(RouteInfoKey).(*RequestRouteInfo), false) if err != nil { _ = NotFoundResponse().Write(client) client.Close() return } _ = req.Write(remote) go libio.Join(remote, client) } func (rp *HTTPReverseProxy) injectRequestInfoToCtx(req *http.Request) *http.Request { user := "" // If url host isn't empty, it's a proxy request. Get http user from Proxy-Authorization header. if req.URL.Host != "" { proxyAuth := req.Header.Get("Proxy-Authorization") if proxyAuth != "" { user, _, _ = httppkg.ParseBasicAuth(proxyAuth) } } if user == "" { user, _, _ = req.BasicAuth() } reqRouteInfo := &RequestRouteInfo{ URL: req.URL.Path, Host: req.Host, HTTPUser: user, RemoteAddr: req.RemoteAddr, URLHost: req.URL.Host, } originalHost, _ := httppkg.CanonicalHost(reqRouteInfo.Host) rc := rp.GetRouteConfig(originalHost, reqRouteInfo.URL, reqRouteInfo.HTTPUser) newctx := req.Context() newctx = context.WithValue(newctx, RouteInfoKey, reqRouteInfo) newctx = context.WithValue(newctx, RouteConfigKey, rc) return req.Clone(newctx) } func (rp *HTTPReverseProxy) ServeHTTP(rw http.ResponseWriter, req *http.Request) { domain, _ := httppkg.CanonicalHost(req.Host) location := req.URL.Path user, passwd, _ := req.BasicAuth() if !rp.CheckAuth(domain, location, user, user, passwd) { rw.Header().Set("WWW-Authenticate", `Basic realm="Restricted"`) http.Error(rw, http.StatusText(http.StatusUnauthorized), http.StatusUnauthorized) return } newreq := rp.injectRequestInfoToCtx(req) if req.Method == http.MethodConnect { rp.connectHandler(rw, newreq) } else { rp.proxy.ServeHTTP(rw, newreq) } } ================================================ FILE: pkg/util/vhost/https.go ================================================ // Copyright 2016 fatedier, fatedier@gmail.com // // Licensed under the Apache License, Version 2.0 (the "License"); // you may not use this file except in compliance with the License. // You may obtain a copy of the License at // // http://www.apache.org/licenses/LICENSE-2.0 // // Unless required by applicable law or agreed to in writing, software // distributed under the License is distributed on an "AS IS" BASIS, // WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. // See the License for the specific language governing permissions and // limitations under the License. package vhost import ( "crypto/tls" "io" "net" "time" libnet "github.com/fatedier/golib/net" ) type HTTPSMuxer struct { *Muxer } func NewHTTPSMuxer(listener net.Listener, timeout time.Duration) (*HTTPSMuxer, error) { mux, err := NewMuxer(listener, GetHTTPSHostname, timeout) mux.SetFailHookFunc(vhostFailed) if err != nil { return nil, err } return &HTTPSMuxer{mux}, err } func GetHTTPSHostname(c net.Conn) (_ net.Conn, _ map[string]string, err error) { reqInfoMap := make(map[string]string, 0) sc, rd := libnet.NewSharedConn(c) clientHello, err := readClientHello(rd) if err != nil { return nil, reqInfoMap, err } reqInfoMap["Host"] = clientHello.ServerName reqInfoMap["Scheme"] = "https" return sc, reqInfoMap, nil } func readClientHello(reader io.Reader) (*tls.ClientHelloInfo, error) { var hello *tls.ClientHelloInfo // Note that Handshake always fails because the readOnlyConn is not a real connection. // As long as the Client Hello is successfully read, the failure should only happen after GetConfigForClient is called, // so we only care about the error if hello was never set. err := tls.Server(readOnlyConn{reader: reader}, &tls.Config{ GetConfigForClient: func(argHello *tls.ClientHelloInfo) (*tls.Config, error) { hello = &tls.ClientHelloInfo{} *hello = *argHello return nil, nil }, }).Handshake() if hello == nil { return nil, err } return hello, nil } func vhostFailed(c net.Conn) { // Alert with alertUnrecognizedName _ = tls.Server(c, &tls.Config{}).Handshake() c.Close() } type readOnlyConn struct { reader io.Reader } func (conn readOnlyConn) Read(p []byte) (int, error) { return conn.reader.Read(p) } func (conn readOnlyConn) Write(_ []byte) (int, error) { return 0, io.ErrClosedPipe } func (conn readOnlyConn) Close() error { return nil } func (conn readOnlyConn) LocalAddr() net.Addr { return nil } func (conn readOnlyConn) RemoteAddr() net.Addr { return nil } func (conn readOnlyConn) SetDeadline(_ time.Time) error { return nil } func (conn readOnlyConn) SetReadDeadline(_ time.Time) error { return nil } func (conn readOnlyConn) SetWriteDeadline(_ time.Time) error { return nil } ================================================ FILE: pkg/util/vhost/https_test.go ================================================ package vhost import ( "crypto/tls" "net" "testing" "time" "github.com/stretchr/testify/require" ) func TestGetHTTPSHostname(t *testing.T) { require := require.New(t) l, err := net.Listen("tcp", "127.0.0.1:") require.NoError(err) defer l.Close() var conn net.Conn go func() { conn, _ = l.Accept() require.NotNil(conn) }() go func() { time.Sleep(100 * time.Millisecond) tls.Dial("tcp", l.Addr().String(), &tls.Config{ InsecureSkipVerify: true, ServerName: "example.com", }) }() time.Sleep(200 * time.Millisecond) _, infos, err := GetHTTPSHostname(conn) require.NoError(err) require.Equal("example.com", infos["Host"]) require.Equal("https", infos["Scheme"]) } ================================================ FILE: pkg/util/vhost/resource.go ================================================ // Copyright 2017 fatedier, fatedier@gmail.com // // Licensed under the Apache License, Version 2.0 (the "License"); // you may not use this file except in compliance with the License. // You may obtain a copy of the License at // // http://www.apache.org/licenses/LICENSE-2.0 // // Unless required by applicable law or agreed to in writing, software // distributed under the License is distributed on an "AS IS" BASIS, // WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. // See the License for the specific language governing permissions and // limitations under the License. package vhost import ( "bytes" "io" "net/http" "os" "github.com/fatedier/frp/pkg/util/log" "github.com/fatedier/frp/pkg/util/version" ) var NotFoundPagePath = "" const ( NotFound = ` Not Found

The page you requested was not found.

Sorry, the page you are looking for is currently unavailable.
Please try again later.

The server is powered by frp.

Faithfully yours, frp.

` ) func getNotFoundPageContent() []byte { var ( buf []byte err error ) if NotFoundPagePath != "" { buf, err = os.ReadFile(NotFoundPagePath) if err != nil { log.Warnf("read custom 404 page error: %v", err) buf = []byte(NotFound) } } else { buf = []byte(NotFound) } return buf } func NotFoundResponse() *http.Response { header := make(http.Header) header.Set("server", "frp/"+version.Full()) header.Set("Content-Type", "text/html") content := getNotFoundPageContent() res := &http.Response{ Status: "Not Found", StatusCode: 404, Proto: "HTTP/1.1", ProtoMajor: 1, ProtoMinor: 1, Header: header, Body: io.NopCloser(bytes.NewReader(content)), ContentLength: int64(len(content)), } return res } ================================================ FILE: pkg/util/vhost/router.go ================================================ package vhost import ( "cmp" "errors" "slices" "strings" "sync" ) var ErrRouterConfigConflict = errors.New("router config conflict") type routerByHTTPUser map[string][]*Router type Routers struct { indexByDomain map[string]routerByHTTPUser mutex sync.RWMutex } type Router struct { domain string location string httpUser string // store any object here payload any } func NewRouters() *Routers { return &Routers{ indexByDomain: make(map[string]routerByHTTPUser), } } func (r *Routers) Add(domain, location, httpUser string, payload any) error { domain = strings.ToLower(domain) r.mutex.Lock() defer r.mutex.Unlock() if _, exist := r.exist(domain, location, httpUser); exist { return ErrRouterConfigConflict } routersByHTTPUser, found := r.indexByDomain[domain] if !found { routersByHTTPUser = make(map[string][]*Router) } vrs, found := routersByHTTPUser[httpUser] if !found { vrs = make([]*Router, 0, 1) } vr := &Router{ domain: domain, location: location, httpUser: httpUser, payload: payload, } vrs = append(vrs, vr) slices.SortFunc(vrs, func(a, b *Router) int { return -cmp.Compare(a.location, b.location) }) routersByHTTPUser[httpUser] = vrs r.indexByDomain[domain] = routersByHTTPUser return nil } func (r *Routers) Del(domain, location, httpUser string) { domain = strings.ToLower(domain) r.mutex.Lock() defer r.mutex.Unlock() routersByHTTPUser, found := r.indexByDomain[domain] if !found { return } vrs, found := routersByHTTPUser[httpUser] if !found { return } newVrs := make([]*Router, 0) for _, vr := range vrs { if vr.location != location { newVrs = append(newVrs, vr) } } routersByHTTPUser[httpUser] = newVrs } func (r *Routers) Get(host, path, httpUser string) (vr *Router, exist bool) { host = strings.ToLower(host) r.mutex.RLock() defer r.mutex.RUnlock() routersByHTTPUser, found := r.indexByDomain[host] if !found { return } vrs, found := routersByHTTPUser[httpUser] if !found { return } for _, vr = range vrs { if strings.HasPrefix(path, vr.location) { return vr, true } } return } func (r *Routers) exist(host, path, httpUser string) (route *Router, exist bool) { routersByHTTPUser, found := r.indexByDomain[host] if !found { return } routers, found := routersByHTTPUser[httpUser] if !found { return } for _, route = range routers { if path == route.location { return route, true } } return } ================================================ FILE: pkg/util/vhost/vhost.go ================================================ // Licensed under the Apache License, Version 2.0 (the "License"); // you may not use this file except in compliance with the License. // You may obtain a copy of the License at // // http://www.apache.org/licenses/LICENSE-2.0 // // Unless required by applicable law or agreed to in writing, software // distributed under the License is distributed on an "AS IS" BASIS, // WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. // See the License for the specific language governing permissions and // limitations under the License. package vhost import ( "context" "fmt" "net" "strings" "time" "github.com/fatedier/golib/errors" "github.com/fatedier/frp/pkg/util/log" netpkg "github.com/fatedier/frp/pkg/util/net" "github.com/fatedier/frp/pkg/util/xlog" ) type RouteInfo string const ( RouteInfoKey RouteInfo = "routeInfo" RouteConfigKey RouteInfo = "routeConfig" ) type RequestRouteInfo struct { URL string Host string HTTPUser string RemoteAddr string URLHost string Endpoint string } type ( muxFunc func(net.Conn) (net.Conn, map[string]string, error) authFunc func(conn net.Conn, username, password string, reqInfoMap map[string]string) (bool, error) hostRewriteFunc func(net.Conn, string) (net.Conn, error) successHookFunc func(net.Conn, map[string]string) error failHookFunc func(net.Conn) ) // Muxer is a functional component used for https and tcpmux proxies. // It accepts connections and extracts vhost information from the beginning of the connection data. // It then routes the connection to its appropriate listener. type Muxer struct { listener net.Listener timeout time.Duration vhostFunc muxFunc checkAuth authFunc successHook successHookFunc failHook failHookFunc rewriteHost hostRewriteFunc registryRouter *Routers } func NewMuxer( listener net.Listener, vhostFunc muxFunc, timeout time.Duration, ) (mux *Muxer, err error) { mux = &Muxer{ listener: listener, timeout: timeout, vhostFunc: vhostFunc, registryRouter: NewRouters(), } go mux.run() return mux, nil } func (v *Muxer) SetCheckAuthFunc(f authFunc) *Muxer { v.checkAuth = f return v } func (v *Muxer) SetSuccessHookFunc(f successHookFunc) *Muxer { v.successHook = f return v } func (v *Muxer) SetFailHookFunc(f failHookFunc) *Muxer { v.failHook = f return v } func (v *Muxer) SetRewriteHostFunc(f hostRewriteFunc) *Muxer { v.rewriteHost = f return v } func (v *Muxer) Close() error { return v.listener.Close() } type ChooseEndpointFunc func() (string, error) type CreateConnFunc func(remoteAddr string) (net.Conn, error) type CreateConnByEndpointFunc func(endpoint, remoteAddr string) (net.Conn, error) // RouteConfig is the params used to match HTTP requests type RouteConfig struct { Domain string Location string RewriteHost string Username string Password string Headers map[string]string ResponseHeaders map[string]string RouteByHTTPUser string CreateConnFn CreateConnFunc ChooseEndpointFn ChooseEndpointFunc CreateConnByEndpointFn CreateConnByEndpointFunc } // listen for a new domain name, if rewriteHost is not empty and rewriteHost func is not nil, // then rewrite the host header to rewriteHost func (v *Muxer) Listen(ctx context.Context, cfg *RouteConfig) (l *Listener, err error) { l = &Listener{ name: cfg.Domain, location: cfg.Location, routeByHTTPUser: cfg.RouteByHTTPUser, rewriteHost: cfg.RewriteHost, username: cfg.Username, password: cfg.Password, mux: v, accept: make(chan net.Conn), ctx: ctx, } err = v.registryRouter.Add(cfg.Domain, cfg.Location, cfg.RouteByHTTPUser, l) if err != nil { return } return l, nil } func (v *Muxer) getListener(name, path, httpUser string) (*Listener, bool) { findRouter := func(inName, inPath, inHTTPUser string) (*Listener, bool) { vr, ok := v.registryRouter.Get(inName, inPath, inHTTPUser) if ok { return vr.payload.(*Listener), true } // Try to check if there is one proxy that doesn't specify routerByHTTPUser, it means match all. vr, ok = v.registryRouter.Get(inName, inPath, "") if ok { return vr.payload.(*Listener), true } return nil, false } // first we check the full hostname // if not exist, then check the wildcard_domain such as *.example.com l, ok := findRouter(name, path, httpUser) if ok { return l, true } domainSplit := strings.Split(name, ".") for len(domainSplit) >= 3 { domainSplit[0] = "*" name = strings.Join(domainSplit, ".") l, ok = findRouter(name, path, httpUser) if ok { return l, true } domainSplit = domainSplit[1:] } // Finally, try to check if there is one proxy that domain is "*" means match all domains. l, ok = findRouter("*", path, httpUser) if ok { return l, true } return nil, false } func (v *Muxer) run() { for { conn, err := v.listener.Accept() if err != nil { return } go v.handle(conn) } } func (v *Muxer) handle(c net.Conn) { if err := c.SetDeadline(time.Now().Add(v.timeout)); err != nil { _ = c.Close() return } sConn, reqInfoMap, err := v.vhostFunc(c) if err != nil { log.Debugf("get hostname from http/https request error: %v", err) _ = c.Close() return } name := strings.ToLower(reqInfoMap["Host"]) path := strings.ToLower(reqInfoMap["Path"]) httpUser := reqInfoMap["HTTPUser"] l, ok := v.getListener(name, path, httpUser) if !ok { log.Debugf("http request for host [%s] path [%s] httpUser [%s] not found", name, path, httpUser) v.failHook(sConn) return } xl := xlog.FromContextSafe(l.ctx) if v.successHook != nil { if err := v.successHook(c, reqInfoMap); err != nil { xl.Infof("success func failure on vhost connection: %v", err) _ = c.Close() return } } // if checkAuth func is exist and username/password is set // then verify user access if l.mux.checkAuth != nil && l.username != "" { ok, err := l.mux.checkAuth(c, l.username, l.password, reqInfoMap) if !ok || err != nil { xl.Debugf("auth failed for user: %s", l.username) _ = c.Close() return } } if err = sConn.SetDeadline(time.Time{}); err != nil { _ = c.Close() return } c = sConn xl.Debugf("new request host [%s] path [%s] httpUser [%s]", name, path, httpUser) err = errors.PanicToError(func() { l.accept <- c }) if err != nil { xl.Warnf("listener is already closed, ignore this request") } } type Listener struct { name string location string routeByHTTPUser string rewriteHost string username string password string mux *Muxer // for closing Muxer accept chan net.Conn ctx context.Context } func (l *Listener) Accept() (net.Conn, error) { xl := xlog.FromContextSafe(l.ctx) conn, ok := <-l.accept if !ok { return nil, fmt.Errorf("listener closed") } // if rewriteHost func is exist // rewrite http requests with a modified host header // if l.rewriteHost is empty, nothing to do if l.mux.rewriteHost != nil { sConn, err := l.mux.rewriteHost(conn, l.rewriteHost) if err != nil { xl.Warnf("host header rewrite failed: %v", err) return nil, fmt.Errorf("host header rewrite failed") } xl.Debugf("rewrite host to [%s] success", l.rewriteHost) conn = sConn } return netpkg.NewContextConn(l.ctx, conn), nil } func (l *Listener) Close() error { l.mux.registryRouter.Del(l.name, l.location, l.routeByHTTPUser) close(l.accept) return nil } func (l *Listener) Name() string { return l.name } func (l *Listener) Addr() net.Addr { return (*net.TCPAddr)(nil) } ================================================ FILE: pkg/util/wait/backoff.go ================================================ // Copyright 2023 The frp Authors // // Licensed under the Apache License, Version 2.0 (the "License"); // you may not use this file except in compliance with the License. // You may obtain a copy of the License at // // http://www.apache.org/licenses/LICENSE-2.0 // // Unless required by applicable law or agreed to in writing, software // distributed under the License is distributed on an "AS IS" BASIS, // WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. // See the License for the specific language governing permissions and // limitations under the License. package wait import ( "math/rand/v2" "time" "github.com/fatedier/frp/pkg/util/util" ) type BackoffFunc func(previousDuration time.Duration, previousConditionError bool) time.Duration func (f BackoffFunc) Backoff(previousDuration time.Duration, previousConditionError bool) time.Duration { return f(previousDuration, previousConditionError) } type BackoffManager interface { Backoff(previousDuration time.Duration, previousConditionError bool) time.Duration } type FastBackoffOptions struct { Duration time.Duration Factor float64 Jitter float64 MaxDuration time.Duration InitDurationIfFail time.Duration // If FastRetryCount > 0, then within the FastRetryWindow time window, // the retry will be performed with a delay of FastRetryDelay for the first FastRetryCount calls. FastRetryCount int FastRetryDelay time.Duration FastRetryJitter float64 FastRetryWindow time.Duration } type fastBackoffImpl struct { options FastBackoffOptions lastCalledTime time.Time consecutiveErrCount int fastRetryCutoffTime time.Time countsInFastRetryWindow int } func NewFastBackoffManager(options FastBackoffOptions) BackoffManager { return &fastBackoffImpl{ options: options, countsInFastRetryWindow: 1, } } func (f *fastBackoffImpl) Backoff(previousDuration time.Duration, previousConditionError bool) time.Duration { if f.lastCalledTime.IsZero() { f.lastCalledTime = time.Now() return f.options.Duration } now := time.Now() f.lastCalledTime = now if previousConditionError { f.consecutiveErrCount++ } else { f.consecutiveErrCount = 0 } if f.options.FastRetryCount > 0 && previousConditionError { f.countsInFastRetryWindow++ if f.countsInFastRetryWindow <= f.options.FastRetryCount { return Jitter(f.options.FastRetryDelay, f.options.FastRetryJitter) } if now.After(f.fastRetryCutoffTime) { // reset f.fastRetryCutoffTime = now.Add(f.options.FastRetryWindow) f.countsInFastRetryWindow = 0 } } if previousConditionError { var duration time.Duration if f.consecutiveErrCount == 1 { duration = util.EmptyOr(f.options.InitDurationIfFail, previousDuration) } else { duration = previousDuration } duration = util.EmptyOr(duration, time.Second) if f.options.Factor != 0 { duration = time.Duration(float64(duration) * f.options.Factor) } if f.options.Jitter > 0 { duration = Jitter(duration, f.options.Jitter) } if f.options.MaxDuration > 0 && duration > f.options.MaxDuration { duration = f.options.MaxDuration } return duration } return f.options.Duration } func BackoffUntil(f func() (bool, error), backoff BackoffManager, sliding bool, stopCh <-chan struct{}) { var delay time.Duration previousError := false ticker := time.NewTicker(backoff.Backoff(delay, previousError)) defer ticker.Stop() for { select { case <-stopCh: return default: } if !sliding { delay = backoff.Backoff(delay, previousError) } if done, err := f(); done { return } else if err != nil { previousError = true } else { previousError = false } if sliding { delay = backoff.Backoff(delay, previousError) } ticker.Reset(delay) select { case <-stopCh: return case <-ticker.C: } } } // Jitter returns a time.Duration between duration and duration + maxFactor * // duration. // // This allows clients to avoid converging on periodic behavior. If maxFactor // is 0.0, a suggested default value will be chosen. func Jitter(duration time.Duration, maxFactor float64) time.Duration { if maxFactor <= 0.0 { maxFactor = 1.0 } wait := duration + time.Duration(rand.Float64()*maxFactor*float64(duration)) return wait } func Until(f func(), period time.Duration, stopCh <-chan struct{}) { ff := func() (bool, error) { f() return false, nil } BackoffUntil(ff, BackoffFunc(func(time.Duration, bool) time.Duration { return period }), true, stopCh) } ================================================ FILE: pkg/util/xlog/ctx.go ================================================ // Copyright 2019 fatedier, fatedier@gmail.com // // Licensed under the Apache License, Version 2.0 (the "License"); // you may not use this file except in compliance with the License. // You may obtain a copy of the License at // // http://www.apache.org/licenses/LICENSE-2.0 // // Unless required by applicable law or agreed to in writing, software // distributed under the License is distributed on an "AS IS" BASIS, // WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. // See the License for the specific language governing permissions and // limitations under the License. package xlog import ( "context" ) type key int const ( xlogKey key = 0 ) func NewContext(ctx context.Context, xl *Logger) context.Context { return context.WithValue(ctx, xlogKey, xl) } func FromContext(ctx context.Context) (xl *Logger, ok bool) { xl, ok = ctx.Value(xlogKey).(*Logger) return } func FromContextSafe(ctx context.Context) *Logger { xl, ok := ctx.Value(xlogKey).(*Logger) if !ok { xl = New() } return xl } ================================================ FILE: pkg/util/xlog/log_writer.go ================================================ // Copyright 2025 The frp Authors // // Licensed under the Apache License, Version 2.0 (the "License"); // you may not use this file except in compliance with the License. // You may obtain a copy of the License at // // http://www.apache.org/licenses/LICENSE-2.0 // // Unless required by applicable law or agreed to in writing, software // distributed under the License is distributed on an "AS IS" BASIS, // WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. // See the License for the specific language governing permissions and // limitations under the License. package xlog import "strings" // LogWriter forwards writes to frp's logger at configurable level. // It is safe for concurrent use as long as the underlying Logger is thread-safe. type LogWriter struct { xl *Logger logFunc func(string) } func (w LogWriter) Write(p []byte) (n int, err error) { msg := strings.TrimSpace(string(p)) w.logFunc(msg) return len(p), nil } func NewTraceWriter(xl *Logger) LogWriter { return LogWriter{ xl: xl, logFunc: func(msg string) { xl.Tracef("%s", msg) }, } } func NewDebugWriter(xl *Logger) LogWriter { return LogWriter{ xl: xl, logFunc: func(msg string) { xl.Debugf("%s", msg) }, } } func NewInfoWriter(xl *Logger) LogWriter { return LogWriter{ xl: xl, logFunc: func(msg string) { xl.Infof("%s", msg) }, } } func NewWarnWriter(xl *Logger) LogWriter { return LogWriter{ xl: xl, logFunc: func(msg string) { xl.Warnf("%s", msg) }, } } func NewErrorWriter(xl *Logger) LogWriter { return LogWriter{ xl: xl, logFunc: func(msg string) { xl.Errorf("%s", msg) }, } } ================================================ FILE: pkg/util/xlog/xlog.go ================================================ // Copyright 2019 fatedier, fatedier@gmail.com // // Licensed under the Apache License, Version 2.0 (the "License"); // you may not use this file except in compliance with the License. // You may obtain a copy of the License at // // http://www.apache.org/licenses/LICENSE-2.0 // // Unless required by applicable law or agreed to in writing, software // distributed under the License is distributed on an "AS IS" BASIS, // WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. // See the License for the specific language governing permissions and // limitations under the License. package xlog import ( "cmp" "slices" "github.com/fatedier/frp/pkg/util/log" ) type LogPrefix struct { // Name is the name of the prefix, it won't be displayed in log but used to identify the prefix. Name string // Value is the value of the prefix, it will be displayed in log. Value string // The prefix with higher priority will be displayed first, default is 10. Priority int } // Logger is not thread safety for operations on prefix type Logger struct { prefixes []LogPrefix prefixString string } func New() *Logger { return &Logger{ prefixes: make([]LogPrefix, 0), } } func (l *Logger) ResetPrefixes() (old []LogPrefix) { old = l.prefixes l.prefixes = make([]LogPrefix, 0) l.prefixString = "" return } func (l *Logger) AppendPrefix(prefix string) *Logger { return l.AddPrefix(LogPrefix{ Name: prefix, Value: prefix, Priority: 10, }) } func (l *Logger) AddPrefix(prefix LogPrefix) *Logger { found := false if prefix.Priority <= 0 { prefix.Priority = 10 } for i, p := range l.prefixes { if p.Name == prefix.Name { found = true l.prefixes[i].Value = prefix.Value l.prefixes[i].Priority = prefix.Priority break } } if !found { l.prefixes = append(l.prefixes, prefix) } l.renderPrefixString() return l } func (l *Logger) renderPrefixString() { slices.SortStableFunc(l.prefixes, func(a, b LogPrefix) int { return cmp.Compare(a.Priority, b.Priority) }) l.prefixString = "" for _, v := range l.prefixes { l.prefixString += "[" + v.Value + "] " } } func (l *Logger) Spawn() *Logger { nl := New() nl.prefixes = append(nl.prefixes, l.prefixes...) nl.renderPrefixString() return nl } func (l *Logger) Errorf(format string, v ...any) { log.Logger.Errorf(l.prefixString+format, v...) } func (l *Logger) Warnf(format string, v ...any) { log.Logger.Warnf(l.prefixString+format, v...) } func (l *Logger) Infof(format string, v ...any) { log.Logger.Infof(l.prefixString+format, v...) } func (l *Logger) Debugf(format string, v ...any) { log.Logger.Debugf(l.prefixString+format, v...) } func (l *Logger) Tracef(format string, v ...any) { log.Logger.Tracef(l.prefixString+format, v...) } ================================================ FILE: pkg/virtual/client.go ================================================ // Copyright 2023 The frp Authors // // Licensed under the Apache License, Version 2.0 (the "License"); // you may not use this file except in compliance with the License. // You may obtain a copy of the License at // // http://www.apache.org/licenses/LICENSE-2.0 // // Unless required by applicable law or agreed to in writing, software // distributed under the License is distributed on an "AS IS" BASIS, // WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. // See the License for the specific language governing permissions and // limitations under the License. package virtual import ( "context" "net" "github.com/fatedier/frp/client" "github.com/fatedier/frp/pkg/config/source" v1 "github.com/fatedier/frp/pkg/config/v1" "github.com/fatedier/frp/pkg/msg" netpkg "github.com/fatedier/frp/pkg/util/net" ) type ClientOptions struct { Common *v1.ClientCommonConfig Spec *msg.ClientSpec HandleWorkConnCb func(*v1.ProxyBaseConfig, net.Conn, *msg.StartWorkConn) bool } type Client struct { l *netpkg.InternalListener svr *client.Service } func NewClient(options ClientOptions) (*Client, error) { if options.Common != nil { if err := options.Common.Complete(); err != nil { return nil, err } } ln := netpkg.NewInternalListener() configSource := source.NewConfigSource() aggregator := source.NewAggregator(configSource) serviceOptions := client.ServiceOptions{ Common: options.Common, ConfigSourceAggregator: aggregator, ClientSpec: options.Spec, ConnectorCreator: func(context.Context, *v1.ClientCommonConfig) client.Connector { return &pipeConnector{ peerListener: ln, } }, HandleWorkConnCb: options.HandleWorkConnCb, } svr, err := client.NewService(serviceOptions) if err != nil { return nil, err } return &Client{ l: ln, svr: svr, }, nil } func (c *Client) PeerListener() net.Listener { return c.l } func (c *Client) UpdateProxyConfigurer(proxyCfgs []v1.ProxyConfigurer) { _ = c.svr.UpdateAllConfigurer(proxyCfgs, nil) } func (c *Client) Run(ctx context.Context) error { return c.svr.Run(ctx) } func (c *Client) Service() *client.Service { return c.svr } func (c *Client) Close() { c.svr.Close() c.l.Close() } type pipeConnector struct { peerListener *netpkg.InternalListener } func (pc *pipeConnector) Open() error { return nil } func (pc *pipeConnector) Connect() (net.Conn, error) { c1, c2 := net.Pipe() if err := pc.peerListener.PutConn(c1); err != nil { c1.Close() c2.Close() return nil, err } return c2, nil } func (pc *pipeConnector) Close() error { pc.peerListener.Close() return nil } ================================================ FILE: pkg/vnet/controller.go ================================================ // Copyright 2025 The frp Authors // // Licensed under the Apache License, Version 2.0 (the "License"); // you may not use this file except in compliance with the License. // You may obtain a copy of the License at // // http://www.apache.org/licenses/LICENSE-2.0 // // Unless required by applicable law or agreed to in writing, software // distributed under the License is distributed on an "AS IS" BASIS, // WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. // See the License for the specific language governing permissions and // limitations under the License. package vnet import ( "context" "encoding/base64" "fmt" "io" "net" "sync" "github.com/fatedier/golib/pool" "github.com/songgao/water/waterutil" "golang.org/x/net/ipv4" "golang.org/x/net/ipv6" v1 "github.com/fatedier/frp/pkg/config/v1" "github.com/fatedier/frp/pkg/util/log" "github.com/fatedier/frp/pkg/util/xlog" ) const ( maxPacketSize = 1420 ) type Controller struct { addr string tun io.ReadWriteCloser clientRouter *clientRouter // Route based on destination IP (client mode) serverRouter *serverRouter // Route based on source IP (server mode) } func NewController(cfg v1.VirtualNetConfig) *Controller { return &Controller{ addr: cfg.Address, clientRouter: newClientRouter(), serverRouter: newServerRouter(), } } func (c *Controller) Init() error { tunDevice, err := OpenTun(context.Background(), c.addr) if err != nil { return err } c.tun = tunDevice return nil } func (c *Controller) Run() error { conn := c.tun for { buf := pool.GetBuf(maxPacketSize) n, err := conn.Read(buf) if err != nil { pool.PutBuf(buf) log.Warnf("vnet read from tun error: %v", err) return err } c.handlePacket(buf[:n]) pool.PutBuf(buf) } } // handlePacket processes a single packet. The caller is responsible for managing the buffer. func (c *Controller) handlePacket(buf []byte) { log.Tracef("vnet read from tun [%d]: %s", len(buf), base64.StdEncoding.EncodeToString(buf)) var src, dst net.IP switch { case waterutil.IsIPv4(buf): header, err := ipv4.ParseHeader(buf) if err != nil { log.Warnf("parse ipv4 header error: %v", err) return } src = header.Src dst = header.Dst log.Tracef("%s >> %s %d/%-4d %-4x %d", header.Src, header.Dst, header.Len, header.TotalLen, header.ID, header.Flags) case waterutil.IsIPv6(buf): header, err := ipv6.ParseHeader(buf) if err != nil { log.Warnf("parse ipv6 header error: %v", err) return } src = header.Src dst = header.Dst log.Tracef("%s >> %s %d %d", header.Src, header.Dst, header.PayloadLen, header.TrafficClass) default: log.Tracef("unknown packet, discarded(%d)", len(buf)) return } targetConn, err := c.clientRouter.findConn(dst) if err == nil { if err := WriteMessage(targetConn, buf); err != nil { log.Warnf("write to client target conn error: %v", err) } return } targetConn, err = c.serverRouter.findConnBySrc(dst) if err == nil { if err := WriteMessage(targetConn, buf); err != nil { log.Warnf("write to server target conn error: %v", err) } return } log.Tracef("no route found for packet from %s to %s", src, dst) } func (c *Controller) Stop() error { if c.tun == nil { return nil } return c.tun.Close() } // Client connection read loop func (c *Controller) readLoopClient(ctx context.Context, conn io.ReadWriteCloser) { xl := xlog.FromContextSafe(ctx) defer func() { // Remove the route when read loop ends (connection closed) c.clientRouter.removeConnRoute(conn) conn.Close() }() for { data, err := ReadMessage(conn) if err != nil { xl.Warnf("client read error: %v", err) return } if len(data) == 0 { continue } switch { case waterutil.IsIPv4(data): header, err := ipv4.ParseHeader(data) if err != nil { xl.Warnf("parse ipv4 header error: %v", err) continue } xl.Tracef("%s >> %s %d/%-4d %-4x %d", header.Src, header.Dst, header.Len, header.TotalLen, header.ID, header.Flags) case waterutil.IsIPv6(data): header, err := ipv6.ParseHeader(data) if err != nil { xl.Warnf("parse ipv6 header error: %v", err) continue } xl.Tracef("%s >> %s %d %d", header.Src, header.Dst, header.PayloadLen, header.TrafficClass) default: xl.Tracef("unknown packet, discarded(%d)", len(data)) continue } xl.Tracef("vnet write to tun (client) [%d]: %s", len(data), base64.StdEncoding.EncodeToString(data)) _, err = c.tun.Write(data) if err != nil { xl.Warnf("client write tun error: %v", err) } } } // Server connection read loop func (c *Controller) readLoopServer(ctx context.Context, conn io.ReadWriteCloser, onClose func()) { xl := xlog.FromContextSafe(ctx) defer func() { // Clean up all IP mappings associated with this connection when it closes c.serverRouter.cleanupConnIPs(conn) // Call the provided callback upon closure if onClose != nil { onClose() } conn.Close() }() for { data, err := ReadMessage(conn) if err != nil { xl.Warnf("server read error: %v", err) return } if len(data) == 0 { continue } // Register source IP to connection mapping if waterutil.IsIPv4(data) || waterutil.IsIPv6(data) { var src net.IP if waterutil.IsIPv4(data) { header, err := ipv4.ParseHeader(data) if err == nil { src = header.Src c.serverRouter.registerSrcIP(src, conn) } } else { header, err := ipv6.ParseHeader(data) if err == nil { src = header.Src c.serverRouter.registerSrcIP(src, conn) } } } xl.Tracef("vnet write to tun (server) [%d]: %s", len(data), base64.StdEncoding.EncodeToString(data)) _, err = c.tun.Write(data) if err != nil { xl.Warnf("server write tun error: %v", err) } } } // RegisterClientRoute registers a client route (based on destination IP CIDR) // and starts the read loop func (c *Controller) RegisterClientRoute(ctx context.Context, name string, routes []net.IPNet, conn io.ReadWriteCloser) { c.clientRouter.addRoute(name, routes, conn) go c.readLoopClient(ctx, conn) } // UnregisterClientRoute Remove client route from routing table func (c *Controller) UnregisterClientRoute(name string) { c.clientRouter.delRoute(name) } // StartServerConnReadLoop starts the read loop for a server connection // (dynamically associates with source IPs) func (c *Controller) StartServerConnReadLoop(ctx context.Context, conn io.ReadWriteCloser, onClose func()) { go c.readLoopServer(ctx, conn, onClose) } // ParseRoutes Convert route strings to IPNet objects func ParseRoutes(routeStrings []string) ([]net.IPNet, error) { routes := make([]net.IPNet, 0, len(routeStrings)) for _, r := range routeStrings { _, ipNet, err := net.ParseCIDR(r) if err != nil { return nil, fmt.Errorf("parse route %s error: %v", r, err) } routes = append(routes, *ipNet) } return routes, nil } // Client router (based on destination IP routing) type clientRouter struct { routes map[string]*routeElement mu sync.RWMutex } func newClientRouter() *clientRouter { return &clientRouter{ routes: make(map[string]*routeElement), } } func (r *clientRouter) addRoute(name string, routes []net.IPNet, conn io.ReadWriteCloser) { r.mu.Lock() defer r.mu.Unlock() r.routes[name] = &routeElement{ name: name, routes: routes, conn: conn, } } func (r *clientRouter) findConn(dst net.IP) (io.Writer, error) { r.mu.RLock() defer r.mu.RUnlock() for _, re := range r.routes { for _, route := range re.routes { if route.Contains(dst) { return re.conn, nil } } } return nil, fmt.Errorf("no route found for destination %s", dst) } func (r *clientRouter) delRoute(name string) { r.mu.Lock() defer r.mu.Unlock() delete(r.routes, name) } func (r *clientRouter) removeConnRoute(conn io.Writer) { r.mu.Lock() defer r.mu.Unlock() for name, re := range r.routes { if re.conn == conn { delete(r.routes, name) return } } } // Server router (based solely on source IP routing) type serverRouter struct { srcIPConns map[string]io.Writer // Source IP string to connection mapping mu sync.RWMutex } func newServerRouter() *serverRouter { return &serverRouter{ srcIPConns: make(map[string]io.Writer), } } func (r *serverRouter) findConnBySrc(src net.IP) (io.Writer, error) { r.mu.RLock() defer r.mu.RUnlock() conn, exists := r.srcIPConns[src.String()] if !exists { return nil, fmt.Errorf("no route found for source %s", src) } return conn, nil } func (r *serverRouter) registerSrcIP(src net.IP, conn io.Writer) { key := src.String() r.mu.RLock() existingConn, ok := r.srcIPConns[key] r.mu.RUnlock() // If the entry exists and the connection is the same, no need to do anything. if ok && existingConn == conn { return } // Acquire write lock to update the map. r.mu.Lock() defer r.mu.Unlock() // Double-check after acquiring the write lock to handle potential race conditions. existingConn, ok = r.srcIPConns[key] if ok && existingConn == conn { return } r.srcIPConns[key] = conn } // cleanupConnIPs removes all IP mappings associated with the specified connection func (r *serverRouter) cleanupConnIPs(conn io.Writer) { r.mu.Lock() defer r.mu.Unlock() // Find and delete all IP mappings pointing to this connection for ip, mappedConn := range r.srcIPConns { if mappedConn == conn { delete(r.srcIPConns, ip) } } } type routeElement struct { name string routes []net.IPNet conn io.ReadWriteCloser } ================================================ FILE: pkg/vnet/message.go ================================================ // Copyright 2025 The frp Authors // // Licensed under the Apache License, Version 2.0 (the "License"); // you may not use this file except in compliance with the License. // You may obtain a copy of the License at // // http://www.apache.org/licenses/LICENSE-2.0 // // Unless required by applicable law or agreed to in writing, software // distributed under the License is distributed on an "AS IS" BASIS, // WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. // See the License for the specific language governing permissions and // limitations under the License. package vnet import ( "encoding/binary" "fmt" "io" ) // Maximum message size const ( maxMessageSize = 1024 * 1024 // 1MB ) // Format: [length(4 bytes)][data(length bytes)] // ReadMessage reads a framed message from the reader func ReadMessage(r io.Reader) ([]byte, error) { // Read length (4 bytes) var length uint32 err := binary.Read(r, binary.LittleEndian, &length) if err != nil { return nil, fmt.Errorf("read message length error: %w", err) } // Check length to prevent DoS if length == 0 { return nil, fmt.Errorf("message length is 0") } if length > maxMessageSize { return nil, fmt.Errorf("message too large: %d > %d", length, maxMessageSize) } // Read message data data := make([]byte, length) _, err = io.ReadFull(r, data) if err != nil { return nil, fmt.Errorf("read message data error: %w", err) } return data, nil } // WriteMessage writes a framed message to the writer func WriteMessage(w io.Writer, data []byte) error { // Get data length length := uint32(len(data)) if length == 0 { return fmt.Errorf("message data length is 0") } if length > maxMessageSize { return fmt.Errorf("message too large: %d > %d", length, maxMessageSize) } // Write length err := binary.Write(w, binary.LittleEndian, length) if err != nil { return fmt.Errorf("write message length error: %w", err) } // Write message data _, err = w.Write(data) if err != nil { return fmt.Errorf("write message data error: %w", err) } return nil } ================================================ FILE: pkg/vnet/tun.go ================================================ // Copyright 2025 The frp Authors // // Licensed under the Apache License, Version 2.0 (the "License"); // you may not use this file except in compliance with the License. // You may obtain a copy of the License at // // http://www.apache.org/licenses/LICENSE-2.0 // // Unless required by applicable law or agreed to in writing, software // distributed under the License is distributed on an "AS IS" BASIS, // WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. // See the License for the specific language governing permissions and // limitations under the License. package vnet import ( "context" "io" "github.com/fatedier/golib/pool" "golang.zx2c4.com/wireguard/tun" ) const ( offset = 16 defaultPacketSize = 1420 ) type TunDevice interface { io.ReadWriteCloser } func OpenTun(ctx context.Context, addr string) (TunDevice, error) { td, err := openTun(ctx, addr) if err != nil { return nil, err } mtu, err := td.MTU() if err != nil { mtu = defaultPacketSize } bufferSize := max(mtu, defaultPacketSize) batchSize := td.BatchSize() device := &tunDeviceWrapper{ dev: td, bufferSize: bufferSize, readBuffers: make([][]byte, batchSize), sizeBuffer: make([]int, batchSize), } for i := range device.readBuffers { device.readBuffers[i] = make([]byte, offset+bufferSize) } return device, nil } type tunDeviceWrapper struct { dev tun.Device bufferSize int readBuffers [][]byte packetBuffers [][]byte sizeBuffer []int } func (d *tunDeviceWrapper) Read(p []byte) (int, error) { if len(d.packetBuffers) > 0 { n := copy(p, d.packetBuffers[0]) d.packetBuffers = d.packetBuffers[1:] return n, nil } n, err := d.dev.Read(d.readBuffers, d.sizeBuffer, offset) if err != nil { return 0, err } if n == 0 { return 0, io.EOF } for i := range n { if d.sizeBuffer[i] <= 0 { continue } d.packetBuffers = append(d.packetBuffers, d.readBuffers[i][offset:offset+d.sizeBuffer[i]]) } dataSize := copy(p, d.packetBuffers[0]) d.packetBuffers = d.packetBuffers[1:] return dataSize, nil } func (d *tunDeviceWrapper) Write(p []byte) (int, error) { buf := pool.GetBuf(offset + d.bufferSize) defer pool.PutBuf(buf) n := copy(buf[offset:], p) _, err := d.dev.Write([][]byte{buf[:offset+n]}, offset) return n, err } func (d *tunDeviceWrapper) Close() error { return d.dev.Close() } ================================================ FILE: pkg/vnet/tun_darwin.go ================================================ // Copyright 2025 The frp Authors // // Licensed under the Apache License, Version 2.0 (the "License"); // you may not use this file except in compliance with the License. // You may obtain a copy of the License at // // http://www.apache.org/licenses/LICENSE-2.0 // // Unless required by applicable law or agreed to in writing, software // distributed under the License is distributed on an "AS IS" BASIS, // WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. // See the License for the specific language governing permissions and // limitations under the License. package vnet import ( "context" "fmt" "net" "os/exec" "golang.zx2c4.com/wireguard/tun" ) const ( defaultTunName = "utun" defaultMTU = 1420 ) func openTun(_ context.Context, addr string) (tun.Device, error) { dev, err := tun.CreateTUN(defaultTunName, defaultMTU) if err != nil { return nil, err } name, err := dev.Name() if err != nil { return nil, err } ip, ipNet, err := net.ParseCIDR(addr) if err != nil { return nil, err } // Calculate a peer IP for the point-to-point tunnel peerIP := generatePeerIP(ip) // Configure the interface with proper point-to-point addressing if err = exec.Command("ifconfig", name, "inet", ip.String(), peerIP.String(), "mtu", fmt.Sprint(defaultMTU), "up").Run(); err != nil { return nil, err } // Add default route for the tunnel subnet routes := []net.IPNet{*ipNet} if err = addRoutes(name, routes); err != nil { return nil, err } return dev, nil } // generatePeerIP creates a peer IP for the point-to-point tunnel // by incrementing the last octet of the IP func generatePeerIP(ip net.IP) net.IP { // Make a copy to avoid modifying the original peerIP := make(net.IP, len(ip)) copy(peerIP, ip) // Increment the last octet peerIP[len(peerIP)-1]++ return peerIP } // addRoutes configures system routes for the TUN interface func addRoutes(ifName string, routes []net.IPNet) error { for _, route := range routes { routeStr := route.String() if err := exec.Command("route", "add", "-net", routeStr, "-interface", ifName).Run(); err != nil { return err } } return nil } ================================================ FILE: pkg/vnet/tun_linux.go ================================================ // Copyright 2025 The frp Authors // // Licensed under the Apache License, Version 2.0 (the "License"); // you may not use this file except in compliance with the License. // You may obtain a copy of the License at // // http://www.apache.org/licenses/LICENSE-2.0 // // Unless required by applicable law or agreed to in writing, software // distributed under the License is distributed on an "AS IS" BASIS, // WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. // See the License for the specific language governing permissions and // limitations under the License. package vnet import ( "context" "crypto/sha256" "encoding/hex" "fmt" "net" "strconv" "strings" "github.com/vishvananda/netlink" "golang.zx2c4.com/wireguard/tun" ) const ( baseTunName = "utun" defaultMTU = 1420 ) func openTun(_ context.Context, addr string) (tun.Device, error) { name, err := findNextTunName(baseTunName) if err != nil { name = getFallbackTunName(baseTunName, addr) } tunDevice, err := tun.CreateTUN(name, defaultMTU) if err != nil { return nil, fmt.Errorf("failed to create TUN device '%s': %w", name, err) } actualName, err := tunDevice.Name() if err != nil { return nil, err } ifn, err := net.InterfaceByName(actualName) if err != nil { return nil, err } link, err := netlink.LinkByName(actualName) if err != nil { return nil, err } ip, cidr, err := net.ParseCIDR(addr) if err != nil { return nil, err } if err := netlink.AddrAdd(link, &netlink.Addr{ IPNet: &net.IPNet{ IP: ip, Mask: cidr.Mask, }, }); err != nil { return nil, err } if err := netlink.LinkSetUp(link); err != nil { return nil, err } if err = addRoutes(ifn, cidr); err != nil { return nil, err } return tunDevice, nil } func findNextTunName(basename string) (string, error) { interfaces, err := net.Interfaces() if err != nil { return "", fmt.Errorf("failed to get network interfaces: %w", err) } maxSuffix := -1 for _, iface := range interfaces { name := iface.Name if strings.HasPrefix(name, basename) { suffix := name[len(basename):] if suffix == "" { continue } numSuffix, err := strconv.Atoi(suffix) if err == nil && numSuffix > maxSuffix { maxSuffix = numSuffix } } } nextSuffix := maxSuffix + 1 name := fmt.Sprintf("%s%d", basename, nextSuffix) return name, nil } func addRoutes(ifn *net.Interface, cidr *net.IPNet) error { r := netlink.Route{ Dst: cidr, LinkIndex: ifn.Index, } if err := netlink.RouteReplace(&r); err != nil { return fmt.Errorf("add route to %v error: %v", r.Dst, err) } return nil } // getFallbackTunName generates a deterministic fallback TUN device name // based on the base name and the provided address string using a hash. func getFallbackTunName(baseName, addr string) string { hasher := sha256.New() hasher.Write([]byte(addr)) hashBytes := hasher.Sum(nil) // Use first 4 bytes -> 8 hex chars for brevity, respecting IFNAMSIZ limit. shortHash := hex.EncodeToString(hashBytes[:4]) return fmt.Sprintf("%s%s", baseName, shortHash) } ================================================ FILE: pkg/vnet/tun_unsupported.go ================================================ // Copyright 2025 The frp Authors // // Licensed under the Apache License, Version 2.0 (the "License"); // you may not use this file except in compliance with the License. // You may obtain a copy of the License at // // http://www.apache.org/licenses/LICENSE-2.0 // // Unless required by applicable law or agreed to in writing, software // distributed under the License is distributed on an "AS IS" BASIS, // WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. // See the License for the specific language governing permissions and // limitations under the License. //go:build !darwin && !linux package vnet import ( "context" "fmt" "runtime" "golang.zx2c4.com/wireguard/tun" ) func openTun(_ context.Context, _ string) (tun.Device, error) { return nil, fmt.Errorf("virtual net is not supported on this platform (%s/%s)", runtime.GOOS, runtime.GOARCH) } ================================================ FILE: server/api_router.go ================================================ // Copyright 2017 fatedier, fatedier@gmail.com // // Licensed under the Apache License, Version 2.0 (the "License"); // you may not use this file except in compliance with the License. // You may obtain a copy of the License at // // http://www.apache.org/licenses/LICENSE-2.0 // // Unless required by applicable law or agreed to in writing, software // distributed under the License is distributed on an "AS IS" BASIS, // WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. // See the License for the specific language governing permissions and // limitations under the License. package server import ( "net/http" "github.com/prometheus/client_golang/prometheus/promhttp" httppkg "github.com/fatedier/frp/pkg/util/http" netpkg "github.com/fatedier/frp/pkg/util/net" adminapi "github.com/fatedier/frp/server/http" ) func (svr *Service) registerRouteHandlers(helper *httppkg.RouterRegisterHelper) { helper.Router.HandleFunc("/healthz", healthz) subRouter := helper.Router.NewRoute().Subrouter() subRouter.Use(helper.AuthMiddleware) subRouter.Use(httppkg.NewRequestLogger) // metrics if svr.cfg.EnablePrometheus { subRouter.Handle("/metrics", promhttp.Handler()) } apiController := adminapi.NewController(svr.cfg, svr.clientRegistry, svr.pxyManager) // apis subRouter.HandleFunc("/api/serverinfo", httppkg.MakeHTTPHandlerFunc(apiController.APIServerInfo)).Methods("GET") subRouter.HandleFunc("/api/proxy/{type}", httppkg.MakeHTTPHandlerFunc(apiController.APIProxyByType)).Methods("GET") subRouter.HandleFunc("/api/proxy/{type}/{name}", httppkg.MakeHTTPHandlerFunc(apiController.APIProxyByTypeAndName)).Methods("GET") subRouter.HandleFunc("/api/proxies/{name}", httppkg.MakeHTTPHandlerFunc(apiController.APIProxyByName)).Methods("GET") subRouter.HandleFunc("/api/traffic/{name}", httppkg.MakeHTTPHandlerFunc(apiController.APIProxyTraffic)).Methods("GET") subRouter.HandleFunc("/api/clients", httppkg.MakeHTTPHandlerFunc(apiController.APIClientList)).Methods("GET") subRouter.HandleFunc("/api/clients/{key}", httppkg.MakeHTTPHandlerFunc(apiController.APIClientDetail)).Methods("GET") subRouter.HandleFunc("/api/proxies", httppkg.MakeHTTPHandlerFunc(apiController.DeleteProxies)).Methods("DELETE") // view subRouter.Handle("/favicon.ico", http.FileServer(helper.AssetsFS)).Methods("GET") subRouter.PathPrefix("/static/").Handler( netpkg.MakeHTTPGzipHandler(http.StripPrefix("/static/", http.FileServer(helper.AssetsFS))), ).Methods("GET") subRouter.HandleFunc("/", func(w http.ResponseWriter, r *http.Request) { http.Redirect(w, r, "/static/", http.StatusMovedPermanently) }) } func healthz(w http.ResponseWriter, _ *http.Request) { w.WriteHeader(200) } ================================================ FILE: server/control.go ================================================ // Copyright 2017 fatedier, fatedier@gmail.com // // Licensed under the Apache License, Version 2.0 (the "License"); // you may not use this file except in compliance with the License. // You may obtain a copy of the License at // // http://www.apache.org/licenses/LICENSE-2.0 // // Unless required by applicable law or agreed to in writing, software // distributed under the License is distributed on an "AS IS" BASIS, // WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. // See the License for the specific language governing permissions and // limitations under the License. package server import ( "context" "fmt" "net" "runtime/debug" "sync" "sync/atomic" "time" "github.com/samber/lo" "github.com/fatedier/frp/pkg/auth" "github.com/fatedier/frp/pkg/config" v1 "github.com/fatedier/frp/pkg/config/v1" pkgerr "github.com/fatedier/frp/pkg/errors" "github.com/fatedier/frp/pkg/msg" plugin "github.com/fatedier/frp/pkg/plugin/server" "github.com/fatedier/frp/pkg/transport" netpkg "github.com/fatedier/frp/pkg/util/net" "github.com/fatedier/frp/pkg/util/util" "github.com/fatedier/frp/pkg/util/version" "github.com/fatedier/frp/pkg/util/wait" "github.com/fatedier/frp/pkg/util/xlog" "github.com/fatedier/frp/server/controller" "github.com/fatedier/frp/server/metrics" "github.com/fatedier/frp/server/proxy" "github.com/fatedier/frp/server/registry" ) type ControlManager struct { // controls indexed by run id ctlsByRunID map[string]*Control mu sync.RWMutex } func NewControlManager() *ControlManager { return &ControlManager{ ctlsByRunID: make(map[string]*Control), } } func (cm *ControlManager) Add(runID string, ctl *Control) (old *Control) { cm.mu.Lock() defer cm.mu.Unlock() var ok bool old, ok = cm.ctlsByRunID[runID] if ok { old.Replaced(ctl) } cm.ctlsByRunID[runID] = ctl return } // we should make sure if it's the same control to prevent delete a new one func (cm *ControlManager) Del(runID string, ctl *Control) { cm.mu.Lock() defer cm.mu.Unlock() if c, ok := cm.ctlsByRunID[runID]; ok && c == ctl { delete(cm.ctlsByRunID, runID) } } func (cm *ControlManager) GetByID(runID string) (ctl *Control, ok bool) { cm.mu.RLock() defer cm.mu.RUnlock() ctl, ok = cm.ctlsByRunID[runID] return } func (cm *ControlManager) Close() error { cm.mu.Lock() defer cm.mu.Unlock() for _, ctl := range cm.ctlsByRunID { ctl.Close() } cm.ctlsByRunID = make(map[string]*Control) return nil } // SessionContext encapsulates the input parameters for creating a new Control. type SessionContext struct { // all resource managers and controllers RC *controller.ResourceController // proxy manager PxyManager *proxy.Manager // plugin manager PluginManager *plugin.Manager // verifies authentication based on selected method AuthVerifier auth.Verifier // key used for connection encryption EncryptionKey []byte // control connection Conn net.Conn // indicates whether the connection is encrypted ConnEncrypted bool // login message LoginMsg *msg.Login // server configuration ServerCfg *v1.ServerConfig // client registry ClientRegistry *registry.ClientRegistry } type Control struct { // session context sessionCtx *SessionContext // other components can use this to communicate with client msgTransporter transport.MessageTransporter // msgDispatcher is a wrapper for control connection. // It provides a channel for sending messages, and you can register handlers to process messages based on their respective types. msgDispatcher *msg.Dispatcher // work connections workConnCh chan net.Conn // proxies in one client proxies map[string]proxy.Proxy // pool count poolCount int // ports used, for limitations portsUsedNum int // last time got the Ping message lastPing atomic.Value // A new run id will be generated when a new client login. // If run id got from login message has same run id, it means it's the same client, so we can // replace old controller instantly. runID string mu sync.RWMutex xl *xlog.Logger ctx context.Context doneCh chan struct{} } func NewControl(ctx context.Context, sessionCtx *SessionContext) (*Control, error) { poolCount := min(sessionCtx.LoginMsg.PoolCount, int(sessionCtx.ServerCfg.Transport.MaxPoolCount)) ctl := &Control{ sessionCtx: sessionCtx, workConnCh: make(chan net.Conn, poolCount+10), proxies: make(map[string]proxy.Proxy), poolCount: poolCount, portsUsedNum: 0, runID: sessionCtx.LoginMsg.RunID, xl: xlog.FromContextSafe(ctx), ctx: ctx, doneCh: make(chan struct{}), } ctl.lastPing.Store(time.Now()) if sessionCtx.ConnEncrypted { cryptoRW, err := netpkg.NewCryptoReadWriter(sessionCtx.Conn, sessionCtx.EncryptionKey) if err != nil { return nil, err } ctl.msgDispatcher = msg.NewDispatcher(cryptoRW) } else { ctl.msgDispatcher = msg.NewDispatcher(sessionCtx.Conn) } ctl.registerMsgHandlers() ctl.msgTransporter = transport.NewMessageTransporter(ctl.msgDispatcher) return ctl, nil } // Start send a login success message to client and start working. func (ctl *Control) Start() { loginRespMsg := &msg.LoginResp{ Version: version.Full(), RunID: ctl.runID, Error: "", } _ = msg.WriteMsg(ctl.sessionCtx.Conn, loginRespMsg) go func() { for i := 0; i < ctl.poolCount; i++ { // ignore error here, that means that this control is closed _ = ctl.msgDispatcher.Send(&msg.ReqWorkConn{}) } }() go ctl.worker() } func (ctl *Control) Close() error { ctl.sessionCtx.Conn.Close() return nil } func (ctl *Control) Replaced(newCtl *Control) { xl := ctl.xl xl.Infof("replaced by client [%s]", newCtl.runID) ctl.runID = "" ctl.sessionCtx.Conn.Close() } func (ctl *Control) RegisterWorkConn(conn net.Conn) error { xl := ctl.xl defer func() { if err := recover(); err != nil { xl.Errorf("panic error: %v", err) xl.Errorf(string(debug.Stack())) } }() select { case ctl.workConnCh <- conn: xl.Debugf("new work connection registered") return nil default: xl.Debugf("work connection pool is full, discarding") return fmt.Errorf("work connection pool is full, discarding") } } // When frps get one user connection, we get one work connection from the pool and return it. // If no workConn available in the pool, send message to frpc to get one or more // and wait until it is available. // return an error if wait timeout func (ctl *Control) GetWorkConn() (workConn net.Conn, err error) { xl := ctl.xl defer func() { if err := recover(); err != nil { xl.Errorf("panic error: %v", err) xl.Errorf(string(debug.Stack())) } }() var ok bool // get a work connection from the pool select { case workConn, ok = <-ctl.workConnCh: if !ok { err = pkgerr.ErrCtlClosed return } xl.Debugf("get work connection from pool") default: // no work connections available in the poll, send message to frpc to get more if err := ctl.msgDispatcher.Send(&msg.ReqWorkConn{}); err != nil { return nil, fmt.Errorf("control is already closed") } select { case workConn, ok = <-ctl.workConnCh: if !ok { err = pkgerr.ErrCtlClosed xl.Warnf("no work connections available, %v", err) return } case <-time.After(time.Duration(ctl.sessionCtx.ServerCfg.UserConnTimeout) * time.Second): err = fmt.Errorf("timeout trying to get work connection") xl.Warnf("%v", err) return } } // When we get a work connection from pool, replace it with a new one. _ = ctl.msgDispatcher.Send(&msg.ReqWorkConn{}) return } func (ctl *Control) heartbeatWorker() { if ctl.sessionCtx.ServerCfg.Transport.HeartbeatTimeout <= 0 { return } xl := ctl.xl go wait.Until(func() { if time.Since(ctl.lastPing.Load().(time.Time)) > time.Duration(ctl.sessionCtx.ServerCfg.Transport.HeartbeatTimeout)*time.Second { xl.Warnf("heartbeat timeout") ctl.sessionCtx.Conn.Close() return } }, time.Second, ctl.doneCh) } // block until Control closed func (ctl *Control) WaitClosed() { <-ctl.doneCh } func (ctl *Control) loginUserInfo() plugin.UserInfo { return plugin.UserInfo{ User: ctl.sessionCtx.LoginMsg.User, Metas: ctl.sessionCtx.LoginMsg.Metas, RunID: ctl.sessionCtx.LoginMsg.RunID, } } func (ctl *Control) closeProxy(pxy proxy.Proxy) { pxy.Close() ctl.sessionCtx.PxyManager.Del(pxy.GetName()) metrics.Server.CloseProxy(pxy.GetName(), pxy.GetConfigurer().GetBaseConfig().Type) notifyContent := &plugin.CloseProxyContent{ User: ctl.loginUserInfo(), CloseProxy: msg.CloseProxy{ ProxyName: pxy.GetName(), }, } go func() { _ = ctl.sessionCtx.PluginManager.CloseProxy(notifyContent) }() } func (ctl *Control) worker() { xl := ctl.xl go ctl.heartbeatWorker() go ctl.msgDispatcher.Run() <-ctl.msgDispatcher.Done() ctl.sessionCtx.Conn.Close() ctl.mu.Lock() close(ctl.workConnCh) for workConn := range ctl.workConnCh { workConn.Close() } proxies := ctl.proxies ctl.proxies = make(map[string]proxy.Proxy) ctl.mu.Unlock() for _, pxy := range proxies { ctl.closeProxy(pxy) } metrics.Server.CloseClient() ctl.sessionCtx.ClientRegistry.MarkOfflineByRunID(ctl.runID) xl.Infof("client exit success") close(ctl.doneCh) } func (ctl *Control) registerMsgHandlers() { ctl.msgDispatcher.RegisterHandler(&msg.NewProxy{}, ctl.handleNewProxy) ctl.msgDispatcher.RegisterHandler(&msg.Ping{}, ctl.handlePing) ctl.msgDispatcher.RegisterHandler(&msg.NatHoleVisitor{}, msg.AsyncHandler(ctl.handleNatHoleVisitor)) ctl.msgDispatcher.RegisterHandler(&msg.NatHoleClient{}, msg.AsyncHandler(ctl.handleNatHoleClient)) ctl.msgDispatcher.RegisterHandler(&msg.NatHoleReport{}, msg.AsyncHandler(ctl.handleNatHoleReport)) ctl.msgDispatcher.RegisterHandler(&msg.CloseProxy{}, ctl.handleCloseProxy) } func (ctl *Control) handleNewProxy(m msg.Message) { xl := ctl.xl inMsg := m.(*msg.NewProxy) content := &plugin.NewProxyContent{ User: ctl.loginUserInfo(), NewProxy: *inMsg, } var remoteAddr string retContent, err := ctl.sessionCtx.PluginManager.NewProxy(content) if err == nil { inMsg = &retContent.NewProxy remoteAddr, err = ctl.RegisterProxy(inMsg) } // register proxy in this control resp := &msg.NewProxyResp{ ProxyName: inMsg.ProxyName, } if err != nil { xl.Warnf("new proxy [%s] type [%s] error: %v", inMsg.ProxyName, inMsg.ProxyType, err) resp.Error = util.GenerateResponseErrorString(fmt.Sprintf("new proxy [%s] error", inMsg.ProxyName), err, lo.FromPtr(ctl.sessionCtx.ServerCfg.DetailedErrorsToClient)) } else { resp.RemoteAddr = remoteAddr xl.Infof("new proxy [%s] type [%s] success", inMsg.ProxyName, inMsg.ProxyType) clientID := ctl.sessionCtx.LoginMsg.ClientID if clientID == "" { clientID = ctl.sessionCtx.LoginMsg.RunID } metrics.Server.NewProxy(inMsg.ProxyName, inMsg.ProxyType, ctl.sessionCtx.LoginMsg.User, clientID) } _ = ctl.msgDispatcher.Send(resp) } func (ctl *Control) handlePing(m msg.Message) { xl := ctl.xl inMsg := m.(*msg.Ping) content := &plugin.PingContent{ User: ctl.loginUserInfo(), Ping: *inMsg, } retContent, err := ctl.sessionCtx.PluginManager.Ping(content) if err == nil { inMsg = &retContent.Ping err = ctl.sessionCtx.AuthVerifier.VerifyPing(inMsg) } if err != nil { xl.Warnf("received invalid ping: %v", err) _ = ctl.msgDispatcher.Send(&msg.Pong{ Error: util.GenerateResponseErrorString("invalid ping", err, lo.FromPtr(ctl.sessionCtx.ServerCfg.DetailedErrorsToClient)), }) return } ctl.lastPing.Store(time.Now()) xl.Debugf("receive heartbeat") _ = ctl.msgDispatcher.Send(&msg.Pong{}) } func (ctl *Control) handleNatHoleVisitor(m msg.Message) { inMsg := m.(*msg.NatHoleVisitor) ctl.sessionCtx.RC.NatHoleController.HandleVisitor(inMsg, ctl.msgTransporter, ctl.sessionCtx.LoginMsg.User) } func (ctl *Control) handleNatHoleClient(m msg.Message) { inMsg := m.(*msg.NatHoleClient) ctl.sessionCtx.RC.NatHoleController.HandleClient(inMsg, ctl.msgTransporter) } func (ctl *Control) handleNatHoleReport(m msg.Message) { inMsg := m.(*msg.NatHoleReport) ctl.sessionCtx.RC.NatHoleController.HandleReport(inMsg) } func (ctl *Control) handleCloseProxy(m msg.Message) { xl := ctl.xl inMsg := m.(*msg.CloseProxy) _ = ctl.CloseProxy(inMsg) xl.Infof("close proxy [%s] success", inMsg.ProxyName) } func (ctl *Control) RegisterProxy(pxyMsg *msg.NewProxy) (remoteAddr string, err error) { var pxyConf v1.ProxyConfigurer // Load configures from NewProxy message and validate. pxyConf, err = config.NewProxyConfigurerFromMsg(pxyMsg, ctl.sessionCtx.ServerCfg) if err != nil { return } // User info userInfo := plugin.UserInfo{ User: ctl.sessionCtx.LoginMsg.User, Metas: ctl.sessionCtx.LoginMsg.Metas, RunID: ctl.runID, } // NewProxy will return an interface Proxy. // In fact, it creates different proxies based on the proxy type. We just call run() here. pxy, err := proxy.NewProxy(ctl.ctx, &proxy.Options{ UserInfo: userInfo, LoginMsg: ctl.sessionCtx.LoginMsg, PoolCount: ctl.poolCount, ResourceController: ctl.sessionCtx.RC, GetWorkConnFn: ctl.GetWorkConn, Configurer: pxyConf, ServerCfg: ctl.sessionCtx.ServerCfg, EncryptionKey: ctl.sessionCtx.EncryptionKey, }) if err != nil { return remoteAddr, err } // Check ports used number in each client if ctl.sessionCtx.ServerCfg.MaxPortsPerClient > 0 { ctl.mu.Lock() if ctl.portsUsedNum+pxy.GetUsedPortsNum() > int(ctl.sessionCtx.ServerCfg.MaxPortsPerClient) { ctl.mu.Unlock() err = fmt.Errorf("exceed the max_ports_per_client") return } ctl.portsUsedNum += pxy.GetUsedPortsNum() ctl.mu.Unlock() defer func() { if err != nil { ctl.mu.Lock() ctl.portsUsedNum -= pxy.GetUsedPortsNum() ctl.mu.Unlock() } }() } if ctl.sessionCtx.PxyManager.Exist(pxyMsg.ProxyName) { err = fmt.Errorf("proxy [%s] already exists", pxyMsg.ProxyName) return } remoteAddr, err = pxy.Run() if err != nil { return } defer func() { if err != nil { pxy.Close() } }() err = ctl.sessionCtx.PxyManager.Add(pxyMsg.ProxyName, pxy) if err != nil { return } ctl.mu.Lock() ctl.proxies[pxy.GetName()] = pxy ctl.mu.Unlock() return } func (ctl *Control) CloseProxy(closeMsg *msg.CloseProxy) (err error) { ctl.mu.Lock() pxy, ok := ctl.proxies[closeMsg.ProxyName] if !ok { ctl.mu.Unlock() return } if ctl.sessionCtx.ServerCfg.MaxPortsPerClient > 0 { ctl.portsUsedNum -= pxy.GetUsedPortsNum() } delete(ctl.proxies, closeMsg.ProxyName) ctl.mu.Unlock() ctl.closeProxy(pxy) return } ================================================ FILE: server/controller/resource.go ================================================ // Copyright 2019 fatedier, fatedier@gmail.com // // Licensed under the Apache License, Version 2.0 (the "License"); // you may not use this file except in compliance with the License. // You may obtain a copy of the License at // // http://www.apache.org/licenses/LICENSE-2.0 // // Unless required by applicable law or agreed to in writing, software // distributed under the License is distributed on an "AS IS" BASIS, // WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. // See the License for the specific language governing permissions and // limitations under the License. package controller import ( "github.com/fatedier/frp/pkg/nathole" plugin "github.com/fatedier/frp/pkg/plugin/server" "github.com/fatedier/frp/pkg/util/tcpmux" "github.com/fatedier/frp/pkg/util/vhost" "github.com/fatedier/frp/server/group" "github.com/fatedier/frp/server/ports" "github.com/fatedier/frp/server/visitor" ) // All resource managers and controllers type ResourceController struct { // Manage all visitor listeners VisitorManager *visitor.Manager // TCP Group Controller TCPGroupCtl *group.TCPGroupCtl // HTTP Group Controller HTTPGroupCtl *group.HTTPGroupController // HTTPS Group Controller HTTPSGroupCtl *group.HTTPSGroupController // TCP Mux Group Controller TCPMuxGroupCtl *group.TCPMuxGroupCtl // Manage all TCP ports TCPPortManager *ports.Manager // Manage all UDP ports UDPPortManager *ports.Manager // For HTTP proxies, forwarding HTTP requests HTTPReverseProxy *vhost.HTTPReverseProxy // For HTTPS proxies, route requests to different clients by hostname and other information VhostHTTPSMuxer *vhost.HTTPSMuxer // Controller for nat hole connections NatHoleController *nathole.Controller // TCPMux HTTP CONNECT multiplexer TCPMuxHTTPConnectMuxer *tcpmux.HTTPConnectTCPMuxer // All server manager plugin PluginManager *plugin.Manager } func (rc *ResourceController) Close() error { if rc.VhostHTTPSMuxer != nil { rc.VhostHTTPSMuxer.Close() } if rc.TCPMuxHTTPConnectMuxer != nil { rc.TCPMuxHTTPConnectMuxer.Close() } return nil } ================================================ FILE: server/group/base.go ================================================ package group import ( "net" "sync" gerr "github.com/fatedier/golib/errors" ) // baseGroup contains the shared plumbing for listener-based groups // (TCP, HTTPS, TCPMux). Each concrete group embeds this and provides // its own Listen method with protocol-specific validation. type baseGroup struct { group string groupKey string acceptCh chan net.Conn realLn net.Listener lns []*Listener mu sync.Mutex cleanupFn func() } // initBase resets the baseGroup for a fresh listen cycle. // Must be called under mu when len(lns) == 0. func (bg *baseGroup) initBase(group, groupKey string, realLn net.Listener, cleanupFn func()) { bg.group = group bg.groupKey = groupKey bg.realLn = realLn bg.acceptCh = make(chan net.Conn) bg.cleanupFn = cleanupFn } // worker reads from the real listener and fans out to acceptCh. // The parameters are captured at creation time so that the worker is // bound to a specific listen cycle and cannot observe a later initBase. func (bg *baseGroup) worker(realLn net.Listener, acceptCh chan<- net.Conn) { for { c, err := realLn.Accept() if err != nil { return } err = gerr.PanicToError(func() { acceptCh <- c }) if err != nil { c.Close() return } } } // newListener creates a new Listener wired to this baseGroup. // Must be called under mu. func (bg *baseGroup) newListener(addr net.Addr) *Listener { ln := newListener(bg.acceptCh, addr, bg.closeListener) bg.lns = append(bg.lns, ln) return ln } // closeListener removes ln from the list. When the last listener is removed, // it closes acceptCh, closes the real listener, and calls cleanupFn. func (bg *baseGroup) closeListener(ln *Listener) { bg.mu.Lock() defer bg.mu.Unlock() for i, l := range bg.lns { if l == ln { bg.lns = append(bg.lns[:i], bg.lns[i+1:]...) break } } if len(bg.lns) == 0 { close(bg.acceptCh) bg.realLn.Close() bg.cleanupFn() } } ================================================ FILE: server/group/base_test.go ================================================ package group import ( "net" "sync" "testing" "time" "github.com/stretchr/testify/assert" "github.com/stretchr/testify/require" ) // fakeLn is a controllable net.Listener for tests. type fakeLn struct { connCh chan net.Conn closed chan struct{} once sync.Once } func newFakeLn() *fakeLn { return &fakeLn{ connCh: make(chan net.Conn, 8), closed: make(chan struct{}), } } func (f *fakeLn) Accept() (net.Conn, error) { select { case c := <-f.connCh: return c, nil case <-f.closed: return nil, net.ErrClosed } } func (f *fakeLn) Close() error { f.once.Do(func() { close(f.closed) }) return nil } func (f *fakeLn) Addr() net.Addr { return fakeAddr("127.0.0.1:9999") } func (f *fakeLn) inject(c net.Conn) { select { case f.connCh <- c: case <-f.closed: } } func TestBaseGroup_WorkerFanOut(t *testing.T) { fl := newFakeLn() var bg baseGroup bg.initBase("g", "key", fl, func() {}) go bg.worker(fl, bg.acceptCh) c1, c2 := net.Pipe() defer c2.Close() fl.inject(c1) select { case got := <-bg.acceptCh: assert.Equal(t, c1, got) got.Close() case <-time.After(time.Second): t.Fatal("timed out waiting for connection on acceptCh") } fl.Close() } func TestBaseGroup_WorkerStopsOnListenerClose(t *testing.T) { fl := newFakeLn() var bg baseGroup bg.initBase("g", "key", fl, func() {}) done := make(chan struct{}) go func() { bg.worker(fl, bg.acceptCh) close(done) }() fl.Close() select { case <-done: case <-time.After(time.Second): t.Fatal("worker did not stop after listener close") } } func TestBaseGroup_WorkerClosesConnOnClosedChannel(t *testing.T) { fl := newFakeLn() var bg baseGroup bg.initBase("g", "key", fl, func() {}) // Close acceptCh before worker sends. close(bg.acceptCh) done := make(chan struct{}) go func() { bg.worker(fl, bg.acceptCh) close(done) }() c1, c2 := net.Pipe() defer c2.Close() fl.inject(c1) select { case <-done: case <-time.After(time.Second): t.Fatal("worker did not stop after panic recovery") } // c1 should have been closed by worker's panic recovery path. buf := make([]byte, 1) _, err := c1.Read(buf) assert.Error(t, err, "connection should be closed by worker") } func TestBaseGroup_CloseLastListenerTriggersCleanup(t *testing.T) { fl := newFakeLn() var bg baseGroup cleanupCalled := 0 bg.initBase("g", "key", fl, func() { cleanupCalled++ }) bg.mu.Lock() ln1 := bg.newListener(fl.Addr()) ln2 := bg.newListener(fl.Addr()) bg.mu.Unlock() go bg.worker(fl, bg.acceptCh) ln1.Close() assert.Equal(t, 0, cleanupCalled, "cleanup should not run while listeners remain") ln2.Close() assert.Equal(t, 1, cleanupCalled, "cleanup should run after last listener closed") } func TestBaseGroup_CloseOneOfTwoListeners(t *testing.T) { fl := newFakeLn() var bg baseGroup cleanupCalled := 0 bg.initBase("g", "key", fl, func() { cleanupCalled++ }) bg.mu.Lock() ln1 := bg.newListener(fl.Addr()) ln2 := bg.newListener(fl.Addr()) bg.mu.Unlock() go bg.worker(fl, bg.acceptCh) ln1.Close() assert.Equal(t, 0, cleanupCalled) // ln2 should still receive connections. c1, c2 := net.Pipe() defer c2.Close() fl.inject(c1) got, err := ln2.Accept() require.NoError(t, err) assert.Equal(t, c1, got) got.Close() ln2.Close() assert.Equal(t, 1, cleanupCalled) } ================================================ FILE: server/group/group.go ================================================ // Copyright 2018 fatedier, fatedier@gmail.com // // Licensed under the Apache License, Version 2.0 (the "License"); // you may not use this file except in compliance with the License. // You may obtain a copy of the License at // // http://www.apache.org/licenses/LICENSE-2.0 // // Unless required by applicable law or agreed to in writing, software // distributed under the License is distributed on an "AS IS" BASIS, // WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. // See the License for the specific language governing permissions and // limitations under the License. package group import ( "errors" ) var ( ErrGroupAuthFailed = errors.New("group auth failed") ErrGroupParamsInvalid = errors.New("group params invalid") ErrListenerClosed = errors.New("group listener closed") ErrGroupDifferentPort = errors.New("group should have same remote port") ErrProxyRepeated = errors.New("group proxy repeated") errGroupStale = errors.New("stale group reference") ) ================================================ FILE: server/group/http.go ================================================ package group import ( "fmt" "net" "sync" "sync/atomic" "github.com/fatedier/frp/pkg/util/vhost" ) // HTTPGroupController manages HTTP groups that use round-robin // callback routing (fundamentally different from listener-based groups). type HTTPGroupController struct { groupRegistry[*HTTPGroup] vhostRouter *vhost.Routers } func NewHTTPGroupController(vhostRouter *vhost.Routers) *HTTPGroupController { return &HTTPGroupController{ groupRegistry: newGroupRegistry[*HTTPGroup](), vhostRouter: vhostRouter, } } func (ctl *HTTPGroupController) Register( proxyName, group, groupKey string, routeConfig vhost.RouteConfig, ) error { for { g := ctl.getOrCreate(group, func() *HTTPGroup { return NewHTTPGroup(ctl) }) err := g.Register(proxyName, group, groupKey, routeConfig) if err == errGroupStale { continue } return err } } func (ctl *HTTPGroupController) UnRegister(proxyName, group string, _ vhost.RouteConfig) { g, ok := ctl.get(group) if !ok { return } g.UnRegister(proxyName) } type HTTPGroup struct { group string groupKey string domain string location string routeByHTTPUser string // CreateConnFuncs indexed by proxy name createFuncs map[string]vhost.CreateConnFunc pxyNames []string index uint64 ctl *HTTPGroupController mu sync.RWMutex } func NewHTTPGroup(ctl *HTTPGroupController) *HTTPGroup { return &HTTPGroup{ createFuncs: make(map[string]vhost.CreateConnFunc), pxyNames: make([]string, 0), ctl: ctl, } } func (g *HTTPGroup) Register( proxyName, group, groupKey string, routeConfig vhost.RouteConfig, ) (err error) { g.mu.Lock() defer g.mu.Unlock() if !g.ctl.isCurrent(group, func(cur *HTTPGroup) bool { return cur == g }) { return errGroupStale } if len(g.createFuncs) == 0 { // the first proxy in this group tmp := routeConfig // copy object tmp.CreateConnFn = g.createConn tmp.ChooseEndpointFn = g.chooseEndpoint tmp.CreateConnByEndpointFn = g.createConnByEndpoint err = g.ctl.vhostRouter.Add(routeConfig.Domain, routeConfig.Location, routeConfig.RouteByHTTPUser, &tmp) if err != nil { return } g.group = group g.groupKey = groupKey g.domain = routeConfig.Domain g.location = routeConfig.Location g.routeByHTTPUser = routeConfig.RouteByHTTPUser } else { if g.group != group || g.domain != routeConfig.Domain || g.location != routeConfig.Location || g.routeByHTTPUser != routeConfig.RouteByHTTPUser { err = ErrGroupParamsInvalid return } if g.groupKey != groupKey { err = ErrGroupAuthFailed return } } if _, ok := g.createFuncs[proxyName]; ok { err = ErrProxyRepeated return } g.createFuncs[proxyName] = routeConfig.CreateConnFn g.pxyNames = append(g.pxyNames, proxyName) return nil } func (g *HTTPGroup) UnRegister(proxyName string) { g.mu.Lock() defer g.mu.Unlock() delete(g.createFuncs, proxyName) for i, name := range g.pxyNames { if name == proxyName { g.pxyNames = append(g.pxyNames[:i], g.pxyNames[i+1:]...) break } } if len(g.createFuncs) == 0 { g.ctl.vhostRouter.Del(g.domain, g.location, g.routeByHTTPUser) g.ctl.removeIf(g.group, func(cur *HTTPGroup) bool { return cur == g }) } } func (g *HTTPGroup) createConn(remoteAddr string) (net.Conn, error) { var f vhost.CreateConnFunc newIndex := atomic.AddUint64(&g.index, 1) g.mu.RLock() group := g.group domain := g.domain location := g.location routeByHTTPUser := g.routeByHTTPUser if len(g.pxyNames) > 0 { name := g.pxyNames[newIndex%uint64(len(g.pxyNames))] f = g.createFuncs[name] } g.mu.RUnlock() if f == nil { return nil, fmt.Errorf("no CreateConnFunc for http group [%s], domain [%s], location [%s], routeByHTTPUser [%s]", group, domain, location, routeByHTTPUser) } return f(remoteAddr) } func (g *HTTPGroup) chooseEndpoint() (string, error) { newIndex := atomic.AddUint64(&g.index, 1) name := "" g.mu.RLock() group := g.group domain := g.domain location := g.location routeByHTTPUser := g.routeByHTTPUser if len(g.pxyNames) > 0 { name = g.pxyNames[newIndex%uint64(len(g.pxyNames))] } g.mu.RUnlock() if name == "" { return "", fmt.Errorf("no healthy endpoint for http group [%s], domain [%s], location [%s], routeByHTTPUser [%s]", group, domain, location, routeByHTTPUser) } return name, nil } func (g *HTTPGroup) createConnByEndpoint(endpoint, remoteAddr string) (net.Conn, error) { var f vhost.CreateConnFunc g.mu.RLock() f = g.createFuncs[endpoint] g.mu.RUnlock() if f == nil { return nil, fmt.Errorf("no CreateConnFunc for endpoint [%s] in group [%s]", endpoint, g.group) } return f(remoteAddr) } ================================================ FILE: server/group/https.go ================================================ // Copyright 2025 The frp Authors // // Licensed under the Apache License, Version 2.0 (the "License"); // you may not use this file except in compliance with the License. // You may obtain a copy of the License at // // http://www.apache.org/licenses/LICENSE-2.0 // // Unless required by applicable law or agreed to in writing, software // distributed under the License is distributed on an "AS IS" BASIS, // WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. // See the License for the specific language governing permissions and // limitations under the License. package group import ( "context" "net" "github.com/fatedier/frp/pkg/util/vhost" ) type HTTPSGroupController struct { groupRegistry[*HTTPSGroup] httpsMuxer *vhost.HTTPSMuxer } func NewHTTPSGroupController(httpsMuxer *vhost.HTTPSMuxer) *HTTPSGroupController { return &HTTPSGroupController{ groupRegistry: newGroupRegistry[*HTTPSGroup](), httpsMuxer: httpsMuxer, } } func (ctl *HTTPSGroupController) Listen( ctx context.Context, group, groupKey string, routeConfig vhost.RouteConfig, ) (l net.Listener, err error) { for { g := ctl.getOrCreate(group, func() *HTTPSGroup { return NewHTTPSGroup(ctl) }) l, err = g.Listen(ctx, group, groupKey, routeConfig) if err == errGroupStale { continue } return } } type HTTPSGroup struct { baseGroup domain string ctl *HTTPSGroupController } func NewHTTPSGroup(ctl *HTTPSGroupController) *HTTPSGroup { return &HTTPSGroup{ ctl: ctl, } } func (g *HTTPSGroup) Listen( ctx context.Context, group, groupKey string, routeConfig vhost.RouteConfig, ) (ln *Listener, err error) { g.mu.Lock() defer g.mu.Unlock() if !g.ctl.isCurrent(group, func(cur *HTTPSGroup) bool { return cur == g }) { return nil, errGroupStale } if len(g.lns) == 0 { // the first listener, listen on the real address httpsLn, errRet := g.ctl.httpsMuxer.Listen(ctx, &routeConfig) if errRet != nil { return nil, errRet } g.domain = routeConfig.Domain g.initBase(group, groupKey, httpsLn, func() { g.ctl.removeIf(g.group, func(cur *HTTPSGroup) bool { return cur == g }) }) ln = g.newListener(httpsLn.Addr()) go g.worker(httpsLn, g.acceptCh) } else { // route config in the same group must be equal if g.group != group || g.domain != routeConfig.Domain { return nil, ErrGroupParamsInvalid } if g.groupKey != groupKey { return nil, ErrGroupAuthFailed } ln = g.newListener(g.lns[0].Addr()) } return } ================================================ FILE: server/group/listener.go ================================================ package group import ( "net" "sync" ) // Listener is a per-proxy virtual listener that receives connections // from a shared group. It implements net.Listener. type Listener struct { acceptCh <-chan net.Conn addr net.Addr closeCh chan struct{} onClose func(*Listener) once sync.Once } func newListener(acceptCh <-chan net.Conn, addr net.Addr, onClose func(*Listener)) *Listener { return &Listener{ acceptCh: acceptCh, addr: addr, closeCh: make(chan struct{}), onClose: onClose, } } func (ln *Listener) Accept() (net.Conn, error) { select { case <-ln.closeCh: return nil, ErrListenerClosed case c, ok := <-ln.acceptCh: if !ok { return nil, ErrListenerClosed } return c, nil } } func (ln *Listener) Addr() net.Addr { return ln.addr } func (ln *Listener) Close() error { ln.once.Do(func() { close(ln.closeCh) ln.onClose(ln) }) return nil } ================================================ FILE: server/group/listener_test.go ================================================ package group import ( "net" "testing" "github.com/stretchr/testify/assert" "github.com/stretchr/testify/require" ) func TestListener_Accept(t *testing.T) { acceptCh := make(chan net.Conn, 1) ln := newListener(acceptCh, fakeAddr("127.0.0.1:1234"), func(*Listener) {}) c1, c2 := net.Pipe() defer c1.Close() defer c2.Close() acceptCh <- c1 got, err := ln.Accept() require.NoError(t, err) assert.Equal(t, c1, got) } func TestListener_AcceptAfterChannelClose(t *testing.T) { acceptCh := make(chan net.Conn) ln := newListener(acceptCh, fakeAddr("127.0.0.1:1234"), func(*Listener) {}) close(acceptCh) _, err := ln.Accept() assert.ErrorIs(t, err, ErrListenerClosed) } func TestListener_AcceptAfterListenerClose(t *testing.T) { acceptCh := make(chan net.Conn) // open, not closed ln := newListener(acceptCh, fakeAddr("127.0.0.1:1234"), func(*Listener) {}) ln.Close() _, err := ln.Accept() assert.ErrorIs(t, err, ErrListenerClosed) } func TestListener_DoubleClose(t *testing.T) { closeCalls := 0 ln := newListener( make(chan net.Conn), fakeAddr("127.0.0.1:1234"), func(*Listener) { closeCalls++ }, ) assert.NotPanics(t, func() { ln.Close() ln.Close() }) assert.Equal(t, 1, closeCalls, "onClose should be called exactly once") } func TestListener_Addr(t *testing.T) { addr := fakeAddr("10.0.0.1:5555") ln := newListener(make(chan net.Conn), addr, func(*Listener) {}) assert.Equal(t, addr, ln.Addr()) } // fakeAddr implements net.Addr for testing. type fakeAddr string func (a fakeAddr) Network() string { return "tcp" } func (a fakeAddr) String() string { return string(a) } ================================================ FILE: server/group/registry.go ================================================ package group import ( "sync" ) // groupRegistry is a concurrent map of named groups with // automatic creation on first access. type groupRegistry[G any] struct { groups map[string]G mu sync.Mutex } func newGroupRegistry[G any]() groupRegistry[G] { return groupRegistry[G]{ groups: make(map[string]G), } } func (r *groupRegistry[G]) getOrCreate(key string, newFn func() G) G { r.mu.Lock() defer r.mu.Unlock() g, ok := r.groups[key] if !ok { g = newFn() r.groups[key] = g } return g } func (r *groupRegistry[G]) get(key string) (G, bool) { r.mu.Lock() defer r.mu.Unlock() g, ok := r.groups[key] return g, ok } // isCurrent returns true if key exists in the registry and matchFn // returns true for the stored value. func (r *groupRegistry[G]) isCurrent(key string, matchFn func(G) bool) bool { r.mu.Lock() defer r.mu.Unlock() g, ok := r.groups[key] return ok && matchFn(g) } // removeIf atomically looks up the group for key, calls fn on it, // and removes the entry if fn returns true. func (r *groupRegistry[G]) removeIf(key string, fn func(G) bool) { r.mu.Lock() defer r.mu.Unlock() g, ok := r.groups[key] if !ok { return } if fn(g) { delete(r.groups, key) } } ================================================ FILE: server/group/registry_test.go ================================================ package group import ( "sync" "testing" "github.com/stretchr/testify/assert" "github.com/stretchr/testify/require" ) func TestGetOrCreate_New(t *testing.T) { r := newGroupRegistry[*int]() called := 0 v := 42 got := r.getOrCreate("k", func() *int { called++; return &v }) assert.Equal(t, 1, called) assert.Equal(t, &v, got) } func TestGetOrCreate_Existing(t *testing.T) { r := newGroupRegistry[*int]() v := 42 r.getOrCreate("k", func() *int { return &v }) called := 0 got := r.getOrCreate("k", func() *int { called++; return nil }) assert.Equal(t, 0, called) assert.Equal(t, &v, got) } func TestGet_ExistingAndMissing(t *testing.T) { r := newGroupRegistry[*int]() v := 1 r.getOrCreate("k", func() *int { return &v }) got, ok := r.get("k") assert.True(t, ok) assert.Equal(t, &v, got) _, ok = r.get("missing") assert.False(t, ok) } func TestIsCurrent(t *testing.T) { r := newGroupRegistry[*int]() v1 := 1 v2 := 2 r.getOrCreate("k", func() *int { return &v1 }) assert.True(t, r.isCurrent("k", func(g *int) bool { return g == &v1 })) assert.False(t, r.isCurrent("k", func(g *int) bool { return g == &v2 })) assert.False(t, r.isCurrent("missing", func(g *int) bool { return true })) } func TestRemoveIf(t *testing.T) { t.Run("removes when fn returns true", func(t *testing.T) { r := newGroupRegistry[*int]() v := 1 r.getOrCreate("k", func() *int { return &v }) r.removeIf("k", func(g *int) bool { return g == &v }) _, ok := r.get("k") assert.False(t, ok) }) t.Run("keeps when fn returns false", func(t *testing.T) { r := newGroupRegistry[*int]() v := 1 r.getOrCreate("k", func() *int { return &v }) r.removeIf("k", func(g *int) bool { return false }) _, ok := r.get("k") assert.True(t, ok) }) t.Run("noop on missing key", func(t *testing.T) { r := newGroupRegistry[*int]() r.removeIf("missing", func(g *int) bool { return true }) // should not panic }) } func TestConcurrentGetOrCreateAndRemoveIf(t *testing.T) { r := newGroupRegistry[*int]() const n = 100 var wg sync.WaitGroup wg.Add(n * 2) for i := range n { v := i go func() { defer wg.Done() r.getOrCreate("k", func() *int { return &v }) }() go func() { defer wg.Done() r.removeIf("k", func(*int) bool { return true }) }() } wg.Wait() // After all goroutines finish, accessing the key must not panic. require.NotPanics(t, func() { _, _ = r.get("k") }) } ================================================ FILE: server/group/tcp.go ================================================ // Copyright 2018 fatedier, fatedier@gmail.com // // Licensed under the Apache License, Version 2.0 (the "License"); // you may not use this file except in compliance with the License. // You may obtain a copy of the License at // // http://www.apache.org/licenses/LICENSE-2.0 // // Unless required by applicable law or agreed to in writing, software // distributed under the License is distributed on an "AS IS" BASIS, // WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. // See the License for the specific language governing permissions and // limitations under the License. package group import ( "net" "strconv" "github.com/fatedier/frp/server/ports" ) // TCPGroupCtl manages all TCPGroups. type TCPGroupCtl struct { groupRegistry[*TCPGroup] portManager *ports.Manager } // NewTCPGroupCtl returns a new TCPGroupCtl. func NewTCPGroupCtl(portManager *ports.Manager) *TCPGroupCtl { return &TCPGroupCtl{ groupRegistry: newGroupRegistry[*TCPGroup](), portManager: portManager, } } // Listen is the wrapper for TCPGroup's Listen. // If there is no group, one will be created. func (tgc *TCPGroupCtl) Listen(proxyName string, group string, groupKey string, addr string, port int, ) (l net.Listener, realPort int, err error) { for { tcpGroup := tgc.getOrCreate(group, func() *TCPGroup { return NewTCPGroup(tgc) }) l, realPort, err = tcpGroup.Listen(proxyName, group, groupKey, addr, port) if err == errGroupStale { continue } return } } // TCPGroup routes connections to different proxies. type TCPGroup struct { baseGroup addr string port int realPort int ctl *TCPGroupCtl } // NewTCPGroup returns a new TCPGroup. func NewTCPGroup(ctl *TCPGroupCtl) *TCPGroup { return &TCPGroup{ ctl: ctl, } } // Listen will return a new Listener. // If TCPGroup already has a listener, just add a new Listener to the queues, // otherwise listen on the real address. func (tg *TCPGroup) Listen(proxyName string, group string, groupKey string, addr string, port int) (ln *Listener, realPort int, err error) { tg.mu.Lock() defer tg.mu.Unlock() if !tg.ctl.isCurrent(group, func(cur *TCPGroup) bool { return cur == tg }) { return nil, 0, errGroupStale } if len(tg.lns) == 0 { // the first listener, listen on the real address realPort, err = tg.ctl.portManager.Acquire(proxyName, port) if err != nil { return } tcpLn, errRet := net.Listen("tcp", net.JoinHostPort(addr, strconv.Itoa(realPort))) if errRet != nil { tg.ctl.portManager.Release(realPort) err = errRet return } tg.addr = addr tg.port = port tg.realPort = realPort tg.initBase(group, groupKey, tcpLn, func() { tg.ctl.portManager.Release(tg.realPort) tg.ctl.removeIf(tg.group, func(cur *TCPGroup) bool { return cur == tg }) }) ln = tg.newListener(tcpLn.Addr()) go tg.worker(tcpLn, tg.acceptCh) } else { // address and port in the same group must be equal if tg.group != group || tg.addr != addr { err = ErrGroupParamsInvalid return } if tg.port != port { err = ErrGroupDifferentPort return } if tg.groupKey != groupKey { err = ErrGroupAuthFailed return } ln = tg.newListener(tg.lns[0].Addr()) realPort = tg.realPort } return } ================================================ FILE: server/group/tcpmux.go ================================================ // Copyright 2020 guylewin, guy@lewin.co.il // // Licensed under the Apache License, Version 2.0 (the "License"); // you may not use this file except in compliance with the License. // You may obtain a copy of the License at // // http://www.apache.org/licenses/LICENSE-2.0 // // Unless required by applicable law or agreed to in writing, software // distributed under the License is distributed on an "AS IS" BASIS, // WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. // See the License for the specific language governing permissions and // limitations under the License. package group import ( "context" "fmt" "net" v1 "github.com/fatedier/frp/pkg/config/v1" "github.com/fatedier/frp/pkg/util/tcpmux" "github.com/fatedier/frp/pkg/util/vhost" ) // TCPMuxGroupCtl manages all TCPMuxGroups. type TCPMuxGroupCtl struct { groupRegistry[*TCPMuxGroup] tcpMuxHTTPConnectMuxer *tcpmux.HTTPConnectTCPMuxer } // NewTCPMuxGroupCtl returns a new TCPMuxGroupCtl. func NewTCPMuxGroupCtl(tcpMuxHTTPConnectMuxer *tcpmux.HTTPConnectTCPMuxer) *TCPMuxGroupCtl { return &TCPMuxGroupCtl{ groupRegistry: newGroupRegistry[*TCPMuxGroup](), tcpMuxHTTPConnectMuxer: tcpMuxHTTPConnectMuxer, } } // Listen is the wrapper for TCPMuxGroup's Listen. // If there is no group, one will be created. func (tmgc *TCPMuxGroupCtl) Listen( ctx context.Context, multiplexer, group, groupKey string, routeConfig vhost.RouteConfig, ) (l net.Listener, err error) { for { tcpMuxGroup := tmgc.getOrCreate(group, func() *TCPMuxGroup { return NewTCPMuxGroup(tmgc) }) switch v1.TCPMultiplexerType(multiplexer) { case v1.TCPMultiplexerHTTPConnect: l, err = tcpMuxGroup.HTTPConnectListen(ctx, group, groupKey, routeConfig) if err == errGroupStale { continue } return default: return nil, fmt.Errorf("unknown multiplexer [%s]", multiplexer) } } } // TCPMuxGroup routes connections to different proxies. type TCPMuxGroup struct { baseGroup domain string routeByHTTPUser string username string password string ctl *TCPMuxGroupCtl } // NewTCPMuxGroup returns a new TCPMuxGroup. func NewTCPMuxGroup(ctl *TCPMuxGroupCtl) *TCPMuxGroup { return &TCPMuxGroup{ ctl: ctl, } } // HTTPConnectListen will return a new Listener. // If TCPMuxGroup already has a listener, just add a new Listener to the queues, // otherwise listen on the real address. func (tmg *TCPMuxGroup) HTTPConnectListen( ctx context.Context, group, groupKey string, routeConfig vhost.RouteConfig, ) (ln *Listener, err error) { tmg.mu.Lock() defer tmg.mu.Unlock() if !tmg.ctl.isCurrent(group, func(cur *TCPMuxGroup) bool { return cur == tmg }) { return nil, errGroupStale } if len(tmg.lns) == 0 { // the first listener, listen on the real address tcpMuxLn, errRet := tmg.ctl.tcpMuxHTTPConnectMuxer.Listen(ctx, &routeConfig) if errRet != nil { return nil, errRet } tmg.domain = routeConfig.Domain tmg.routeByHTTPUser = routeConfig.RouteByHTTPUser tmg.username = routeConfig.Username tmg.password = routeConfig.Password tmg.initBase(group, groupKey, tcpMuxLn, func() { tmg.ctl.removeIf(tmg.group, func(cur *TCPMuxGroup) bool { return cur == tmg }) }) ln = tmg.newListener(tcpMuxLn.Addr()) go tmg.worker(tcpMuxLn, tmg.acceptCh) } else { // route config in the same group must be equal if tmg.group != group || tmg.domain != routeConfig.Domain || tmg.routeByHTTPUser != routeConfig.RouteByHTTPUser || tmg.username != routeConfig.Username || tmg.password != routeConfig.Password { return nil, ErrGroupParamsInvalid } if tmg.groupKey != groupKey { return nil, ErrGroupAuthFailed } ln = tmg.newListener(tmg.lns[0].Addr()) } return } ================================================ FILE: server/http/controller.go ================================================ // Copyright 2025 The frp Authors // // Licensed under the Apache License, Version 2.0 (the "License"); // you may not use this file except in compliance with the License. // You may obtain a copy of the License at // // http://www.apache.org/licenses/LICENSE-2.0 // // Unless required by applicable law or agreed to in writing, software // distributed under the License is distributed on an "AS IS" BASIS, // WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. // See the License for the specific language governing permissions and // limitations under the License. package http import ( "cmp" "fmt" "net/http" "slices" "strings" "time" "github.com/fatedier/frp/pkg/config/types" v1 "github.com/fatedier/frp/pkg/config/v1" "github.com/fatedier/frp/pkg/metrics/mem" httppkg "github.com/fatedier/frp/pkg/util/http" "github.com/fatedier/frp/pkg/util/log" "github.com/fatedier/frp/pkg/util/version" "github.com/fatedier/frp/server/http/model" "github.com/fatedier/frp/server/proxy" "github.com/fatedier/frp/server/registry" ) type Controller struct { // dependencies serverCfg *v1.ServerConfig clientRegistry *registry.ClientRegistry pxyManager ProxyManager } type ProxyManager interface { GetByName(name string) (proxy.Proxy, bool) } func NewController( serverCfg *v1.ServerConfig, clientRegistry *registry.ClientRegistry, pxyManager ProxyManager, ) *Controller { return &Controller{ serverCfg: serverCfg, clientRegistry: clientRegistry, pxyManager: pxyManager, } } // /api/serverinfo func (c *Controller) APIServerInfo(ctx *httppkg.Context) (any, error) { serverStats := mem.StatsCollector.GetServer() svrResp := model.ServerInfoResp{ Version: version.Full(), BindPort: c.serverCfg.BindPort, VhostHTTPPort: c.serverCfg.VhostHTTPPort, VhostHTTPSPort: c.serverCfg.VhostHTTPSPort, TCPMuxHTTPConnectPort: c.serverCfg.TCPMuxHTTPConnectPort, KCPBindPort: c.serverCfg.KCPBindPort, QUICBindPort: c.serverCfg.QUICBindPort, SubdomainHost: c.serverCfg.SubDomainHost, MaxPoolCount: c.serverCfg.Transport.MaxPoolCount, MaxPortsPerClient: c.serverCfg.MaxPortsPerClient, HeartBeatTimeout: c.serverCfg.Transport.HeartbeatTimeout, AllowPortsStr: types.PortsRangeSlice(c.serverCfg.AllowPorts).String(), TLSForce: c.serverCfg.Transport.TLS.Force, TotalTrafficIn: serverStats.TotalTrafficIn, TotalTrafficOut: serverStats.TotalTrafficOut, CurConns: serverStats.CurConns, ClientCounts: serverStats.ClientCounts, ProxyTypeCounts: serverStats.ProxyTypeCounts, } return svrResp, nil } // /api/clients func (c *Controller) APIClientList(ctx *httppkg.Context) (any, error) { if c.clientRegistry == nil { return nil, fmt.Errorf("client registry unavailable") } userFilter := ctx.Query("user") clientIDFilter := ctx.Query("clientId") runIDFilter := ctx.Query("runId") statusFilter := strings.ToLower(ctx.Query("status")) records := c.clientRegistry.List() items := make([]model.ClientInfoResp, 0, len(records)) for _, info := range records { if userFilter != "" && info.User != userFilter { continue } if clientIDFilter != "" && info.ClientID() != clientIDFilter { continue } if runIDFilter != "" && info.RunID != runIDFilter { continue } if !matchStatusFilter(info.Online, statusFilter) { continue } items = append(items, buildClientInfoResp(info)) } slices.SortFunc(items, func(a, b model.ClientInfoResp) int { if v := cmp.Compare(a.User, b.User); v != 0 { return v } if v := cmp.Compare(a.ClientID, b.ClientID); v != 0 { return v } return cmp.Compare(a.Key, b.Key) }) return items, nil } // /api/clients/{key} func (c *Controller) APIClientDetail(ctx *httppkg.Context) (any, error) { key := ctx.Param("key") if key == "" { return nil, fmt.Errorf("missing client key") } if c.clientRegistry == nil { return nil, fmt.Errorf("client registry unavailable") } info, ok := c.clientRegistry.GetByKey(key) if !ok { return nil, httppkg.NewError(http.StatusNotFound, fmt.Sprintf("client %s not found", key)) } return buildClientInfoResp(info), nil } // /api/proxy/:type func (c *Controller) APIProxyByType(ctx *httppkg.Context) (any, error) { proxyType := ctx.Param("type") proxyInfoResp := model.GetProxyInfoResp{} proxyInfoResp.Proxies = c.getProxyStatsByType(proxyType) slices.SortFunc(proxyInfoResp.Proxies, func(a, b *model.ProxyStatsInfo) int { return cmp.Compare(a.Name, b.Name) }) return proxyInfoResp, nil } // /api/proxy/:type/:name func (c *Controller) APIProxyByTypeAndName(ctx *httppkg.Context) (any, error) { proxyType := ctx.Param("type") name := ctx.Param("name") proxyStatsResp, code, msg := c.getProxyStatsByTypeAndName(proxyType, name) if code != 200 { return nil, httppkg.NewError(code, msg) } return proxyStatsResp, nil } // /api/traffic/:name func (c *Controller) APIProxyTraffic(ctx *httppkg.Context) (any, error) { name := ctx.Param("name") trafficResp := model.GetProxyTrafficResp{} trafficResp.Name = name proxyTrafficInfo := mem.StatsCollector.GetProxyTraffic(name) if proxyTrafficInfo == nil { return nil, httppkg.NewError(http.StatusNotFound, "no proxy info found") } trafficResp.TrafficIn = proxyTrafficInfo.TrafficIn trafficResp.TrafficOut = proxyTrafficInfo.TrafficOut return trafficResp, nil } // /api/proxies/:name func (c *Controller) APIProxyByName(ctx *httppkg.Context) (any, error) { name := ctx.Param("name") ps := mem.StatsCollector.GetProxyByName(name) if ps == nil { return nil, httppkg.NewError(http.StatusNotFound, "no proxy info found") } proxyInfo := model.GetProxyStatsResp{ Name: ps.Name, User: ps.User, ClientID: ps.ClientID, TodayTrafficIn: ps.TodayTrafficIn, TodayTrafficOut: ps.TodayTrafficOut, CurConns: ps.CurConns, LastStartTime: ps.LastStartTime, LastCloseTime: ps.LastCloseTime, } if pxy, ok := c.pxyManager.GetByName(name); ok { proxyInfo.Conf = getConfFromConfigurer(pxy.GetConfigurer()) proxyInfo.Status = "online" } else { proxyInfo.Status = "offline" } return proxyInfo, nil } // DELETE /api/proxies?status=offline func (c *Controller) DeleteProxies(ctx *httppkg.Context) (any, error) { status := ctx.Query("status") if status != "offline" { return nil, httppkg.NewError(http.StatusBadRequest, "status only support offline") } cleared, total := mem.StatsCollector.ClearOfflineProxies() log.Infof("cleared [%d] offline proxies, total [%d] proxies", cleared, total) return httppkg.GeneralResponse{Code: 200, Msg: "success"}, nil } func (c *Controller) getProxyStatsByType(proxyType string) (proxyInfos []*model.ProxyStatsInfo) { proxyStats := mem.StatsCollector.GetProxiesByType(proxyType) proxyInfos = make([]*model.ProxyStatsInfo, 0, len(proxyStats)) for _, ps := range proxyStats { proxyInfo := &model.ProxyStatsInfo{ User: ps.User, ClientID: ps.ClientID, } if pxy, ok := c.pxyManager.GetByName(ps.Name); ok { proxyInfo.Conf = getConfFromConfigurer(pxy.GetConfigurer()) proxyInfo.Status = "online" } else { proxyInfo.Status = "offline" } proxyInfo.Name = ps.Name proxyInfo.TodayTrafficIn = ps.TodayTrafficIn proxyInfo.TodayTrafficOut = ps.TodayTrafficOut proxyInfo.CurConns = ps.CurConns proxyInfo.LastStartTime = ps.LastStartTime proxyInfo.LastCloseTime = ps.LastCloseTime proxyInfos = append(proxyInfos, proxyInfo) } return } func (c *Controller) getProxyStatsByTypeAndName(proxyType string, proxyName string) (proxyInfo model.GetProxyStatsResp, code int, msg string) { proxyInfo.Name = proxyName ps := mem.StatsCollector.GetProxiesByTypeAndName(proxyType, proxyName) if ps == nil { code = 404 msg = "no proxy info found" } else { proxyInfo.User = ps.User proxyInfo.ClientID = ps.ClientID if pxy, ok := c.pxyManager.GetByName(proxyName); ok { proxyInfo.Conf = getConfFromConfigurer(pxy.GetConfigurer()) proxyInfo.Status = "online" } else { proxyInfo.Status = "offline" } proxyInfo.TodayTrafficIn = ps.TodayTrafficIn proxyInfo.TodayTrafficOut = ps.TodayTrafficOut proxyInfo.CurConns = ps.CurConns proxyInfo.LastStartTime = ps.LastStartTime proxyInfo.LastCloseTime = ps.LastCloseTime code = 200 } return } func buildClientInfoResp(info registry.ClientInfo) model.ClientInfoResp { resp := model.ClientInfoResp{ Key: info.Key, User: info.User, ClientID: info.ClientID(), RunID: info.RunID, Version: info.Version, Hostname: info.Hostname, ClientIP: info.IP, FirstConnectedAt: toUnix(info.FirstConnectedAt), LastConnectedAt: toUnix(info.LastConnectedAt), Online: info.Online, } if !info.DisconnectedAt.IsZero() { resp.DisconnectedAt = info.DisconnectedAt.Unix() } return resp } func toUnix(t time.Time) int64 { if t.IsZero() { return 0 } return t.Unix() } func matchStatusFilter(online bool, filter string) bool { switch strings.ToLower(filter) { case "", "all": return true case "online": return online case "offline": return !online default: return true } } func getConfFromConfigurer(cfg v1.ProxyConfigurer) any { outBase := model.BaseOutConf{ProxyBaseConfig: *cfg.GetBaseConfig()} switch c := cfg.(type) { case *v1.TCPProxyConfig: return &model.TCPOutConf{BaseOutConf: outBase, RemotePort: c.RemotePort} case *v1.UDPProxyConfig: return &model.UDPOutConf{BaseOutConf: outBase, RemotePort: c.RemotePort} case *v1.HTTPProxyConfig: return &model.HTTPOutConf{ BaseOutConf: outBase, DomainConfig: c.DomainConfig, Locations: c.Locations, HostHeaderRewrite: c.HostHeaderRewrite, } case *v1.HTTPSProxyConfig: return &model.HTTPSOutConf{ BaseOutConf: outBase, DomainConfig: c.DomainConfig, } case *v1.TCPMuxProxyConfig: return &model.TCPMuxOutConf{ BaseOutConf: outBase, DomainConfig: c.DomainConfig, Multiplexer: c.Multiplexer, RouteByHTTPUser: c.RouteByHTTPUser, } case *v1.STCPProxyConfig: return &model.STCPOutConf{BaseOutConf: outBase} case *v1.XTCPProxyConfig: return &model.XTCPOutConf{BaseOutConf: outBase} } return outBase } ================================================ FILE: server/http/controller_test.go ================================================ // Copyright 2026 The frp Authors // // Licensed under the Apache License, Version 2.0 (the "License"); // you may not use this file except in compliance with the License. // You may obtain a copy of the License at // // http://www.apache.org/licenses/LICENSE-2.0 // // Unless required by applicable law or agreed to in writing, software // distributed under the License is distributed on an "AS IS" BASIS, // WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. // See the License for the specific language governing permissions and // limitations under the License. package http import ( "encoding/json" "testing" v1 "github.com/fatedier/frp/pkg/config/v1" ) func TestGetConfFromConfigurerKeepsPluginFields(t *testing.T) { cfg := &v1.TCPProxyConfig{ ProxyBaseConfig: v1.ProxyBaseConfig{ Name: "test-proxy", Type: string(v1.ProxyTypeTCP), ProxyBackend: v1.ProxyBackend{ Plugin: v1.TypedClientPluginOptions{ Type: v1.PluginHTTPProxy, ClientPluginOptions: &v1.HTTPProxyPluginOptions{ Type: v1.PluginHTTPProxy, HTTPUser: "user", HTTPPassword: "password", }, }, }, }, RemotePort: 6000, } content, err := json.Marshal(getConfFromConfigurer(cfg)) if err != nil { t.Fatalf("marshal conf failed: %v", err) } var out map[string]any if err := json.Unmarshal(content, &out); err != nil { t.Fatalf("unmarshal conf failed: %v", err) } pluginValue, ok := out["plugin"] if !ok { t.Fatalf("plugin field missing in output: %v", out) } plugin, ok := pluginValue.(map[string]any) if !ok { t.Fatalf("plugin field should be object, got: %#v", pluginValue) } if got := plugin["type"]; got != v1.PluginHTTPProxy { t.Fatalf("plugin type mismatch, want %q got %#v", v1.PluginHTTPProxy, got) } if got := plugin["httpUser"]; got != "user" { t.Fatalf("plugin httpUser mismatch, want %q got %#v", "user", got) } if got := plugin["httpPassword"]; got != "password" { t.Fatalf("plugin httpPassword mismatch, want %q got %#v", "password", got) } } ================================================ FILE: server/http/model/types.go ================================================ // Copyright 2025 The frp Authors // // Licensed under the Apache License, Version 2.0 (the "License"); // you may not use this file except in compliance with the License. // You may obtain a copy of the License at // // http://www.apache.org/licenses/LICENSE-2.0 // // Unless required by applicable law or agreed to in writing, software // distributed under the License is distributed on an "AS IS" BASIS, // WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. // See the License for the specific language governing permissions and // limitations under the License. package model import ( v1 "github.com/fatedier/frp/pkg/config/v1" ) type ServerInfoResp struct { Version string `json:"version"` BindPort int `json:"bindPort"` VhostHTTPPort int `json:"vhostHTTPPort"` VhostHTTPSPort int `json:"vhostHTTPSPort"` TCPMuxHTTPConnectPort int `json:"tcpmuxHTTPConnectPort"` KCPBindPort int `json:"kcpBindPort"` QUICBindPort int `json:"quicBindPort"` SubdomainHost string `json:"subdomainHost"` MaxPoolCount int64 `json:"maxPoolCount"` MaxPortsPerClient int64 `json:"maxPortsPerClient"` HeartBeatTimeout int64 `json:"heartbeatTimeout"` AllowPortsStr string `json:"allowPortsStr,omitempty"` TLSForce bool `json:"tlsForce,omitempty"` TotalTrafficIn int64 `json:"totalTrafficIn"` TotalTrafficOut int64 `json:"totalTrafficOut"` CurConns int64 `json:"curConns"` ClientCounts int64 `json:"clientCounts"` ProxyTypeCounts map[string]int64 `json:"proxyTypeCount"` } type ClientInfoResp struct { Key string `json:"key"` User string `json:"user"` ClientID string `json:"clientID"` RunID string `json:"runID"` Version string `json:"version,omitempty"` Hostname string `json:"hostname"` ClientIP string `json:"clientIP,omitempty"` FirstConnectedAt int64 `json:"firstConnectedAt"` LastConnectedAt int64 `json:"lastConnectedAt"` DisconnectedAt int64 `json:"disconnectedAt,omitempty"` Online bool `json:"online"` } type BaseOutConf struct { v1.ProxyBaseConfig } type TCPOutConf struct { BaseOutConf RemotePort int `json:"remotePort"` } type TCPMuxOutConf struct { BaseOutConf v1.DomainConfig Multiplexer string `json:"multiplexer"` RouteByHTTPUser string `json:"routeByHTTPUser"` } type UDPOutConf struct { BaseOutConf RemotePort int `json:"remotePort"` } type HTTPOutConf struct { BaseOutConf v1.DomainConfig Locations []string `json:"locations"` HostHeaderRewrite string `json:"hostHeaderRewrite"` } type HTTPSOutConf struct { BaseOutConf v1.DomainConfig } type STCPOutConf struct { BaseOutConf } type XTCPOutConf struct { BaseOutConf } // Get proxy info. type ProxyStatsInfo struct { Name string `json:"name"` Conf any `json:"conf"` User string `json:"user,omitempty"` ClientID string `json:"clientID,omitempty"` TodayTrafficIn int64 `json:"todayTrafficIn"` TodayTrafficOut int64 `json:"todayTrafficOut"` CurConns int64 `json:"curConns"` LastStartTime string `json:"lastStartTime"` LastCloseTime string `json:"lastCloseTime"` Status string `json:"status"` } type GetProxyInfoResp struct { Proxies []*ProxyStatsInfo `json:"proxies"` } // Get proxy info by name. type GetProxyStatsResp struct { Name string `json:"name"` Conf any `json:"conf"` User string `json:"user,omitempty"` ClientID string `json:"clientID,omitempty"` TodayTrafficIn int64 `json:"todayTrafficIn"` TodayTrafficOut int64 `json:"todayTrafficOut"` CurConns int64 `json:"curConns"` LastStartTime string `json:"lastStartTime"` LastCloseTime string `json:"lastCloseTime"` Status string `json:"status"` } // /api/traffic/:name type GetProxyTrafficResp struct { Name string `json:"name"` TrafficIn []int64 `json:"trafficIn"` TrafficOut []int64 `json:"trafficOut"` } ================================================ FILE: server/metrics/metrics.go ================================================ package metrics import ( "sync" ) type ServerMetrics interface { NewClient() CloseClient() NewProxy(name string, proxyType string, user string, clientID string) CloseProxy(name string, proxyType string) OpenConnection(name string, proxyType string) CloseConnection(name string, proxyType string) AddTrafficIn(name string, proxyType string, trafficBytes int64) AddTrafficOut(name string, proxyType string, trafficBytes int64) } var Server ServerMetrics = noopServerMetrics{} var registerMetrics sync.Once func Register(m ServerMetrics) { registerMetrics.Do(func() { Server = m }) } type noopServerMetrics struct{} func (noopServerMetrics) NewClient() {} func (noopServerMetrics) CloseClient() {} func (noopServerMetrics) NewProxy(string, string, string, string) {} func (noopServerMetrics) CloseProxy(string, string) {} func (noopServerMetrics) OpenConnection(string, string) {} func (noopServerMetrics) CloseConnection(string, string) {} func (noopServerMetrics) AddTrafficIn(string, string, int64) {} func (noopServerMetrics) AddTrafficOut(string, string, int64) {} ================================================ FILE: server/ports/ports.go ================================================ package ports import ( "errors" "net" "strconv" "sync" "time" "github.com/fatedier/frp/pkg/config/types" ) const ( MinPort = 1 MaxPort = 65535 MaxPortReservedDuration = time.Duration(24) * time.Hour CleanReservedPortsInterval = time.Hour ) var ( ErrPortAlreadyUsed = errors.New("port already used") ErrPortNotAllowed = errors.New("port not allowed") ErrPortUnAvailable = errors.New("port unavailable") ErrNoAvailablePort = errors.New("no available port") ) type PortCtx struct { ProxyName string Port int Closed bool UpdateTime time.Time } type Manager struct { reservedPorts map[string]*PortCtx usedPorts map[int]*PortCtx freePorts map[int]struct{} bindAddr string netType string mu sync.Mutex } func NewManager(netType string, bindAddr string, allowPorts []types.PortsRange) *Manager { pm := &Manager{ reservedPorts: make(map[string]*PortCtx), usedPorts: make(map[int]*PortCtx), freePorts: make(map[int]struct{}), bindAddr: bindAddr, netType: netType, } if len(allowPorts) > 0 { for _, pair := range allowPorts { if pair.Single > 0 { pm.freePorts[pair.Single] = struct{}{} } else { for i := pair.Start; i <= pair.End; i++ { pm.freePorts[i] = struct{}{} } } } } else { for i := MinPort; i <= MaxPort; i++ { pm.freePorts[i] = struct{}{} } } go pm.cleanReservedPortsWorker() return pm } func (pm *Manager) Acquire(name string, port int) (realPort int, err error) { portCtx := &PortCtx{ ProxyName: name, Closed: false, UpdateTime: time.Now(), } var ok bool pm.mu.Lock() defer func() { if err == nil { portCtx.Port = realPort } pm.mu.Unlock() }() // check reserved ports first if port == 0 { if ctx, ok := pm.reservedPorts[name]; ok { if pm.isPortAvailable(ctx.Port) { realPort = ctx.Port pm.usedPorts[realPort] = portCtx pm.reservedPorts[name] = portCtx delete(pm.freePorts, realPort) return } } } if port == 0 { // get random port count := 0 maxTryTimes := 5 for k := range pm.freePorts { count++ if count > maxTryTimes { break } if pm.isPortAvailable(k) { realPort = k pm.usedPorts[realPort] = portCtx pm.reservedPorts[name] = portCtx delete(pm.freePorts, realPort) break } } if realPort == 0 { err = ErrNoAvailablePort } } else { // specified port if _, ok = pm.freePorts[port]; ok { if pm.isPortAvailable(port) { realPort = port pm.usedPorts[realPort] = portCtx pm.reservedPorts[name] = portCtx delete(pm.freePorts, realPort) } else { err = ErrPortUnAvailable } } else { if _, ok = pm.usedPorts[port]; ok { err = ErrPortAlreadyUsed } else { err = ErrPortNotAllowed } } } return } func (pm *Manager) isPortAvailable(port int) bool { if pm.netType == "udp" { addr, err := net.ResolveUDPAddr("udp", net.JoinHostPort(pm.bindAddr, strconv.Itoa(port))) if err != nil { return false } l, err := net.ListenUDP("udp", addr) if err != nil { return false } l.Close() return true } l, err := net.Listen(pm.netType, net.JoinHostPort(pm.bindAddr, strconv.Itoa(port))) if err != nil { return false } l.Close() return true } func (pm *Manager) Release(port int) { pm.mu.Lock() defer pm.mu.Unlock() if ctx, ok := pm.usedPorts[port]; ok { pm.freePorts[port] = struct{}{} delete(pm.usedPorts, port) ctx.Closed = true ctx.UpdateTime = time.Now() } } // Release reserved port if it isn't used in last 24 hours. func (pm *Manager) cleanReservedPortsWorker() { for { time.Sleep(CleanReservedPortsInterval) pm.mu.Lock() for name, ctx := range pm.reservedPorts { if ctx.Closed && time.Since(ctx.UpdateTime) > MaxPortReservedDuration { delete(pm.reservedPorts, name) } } pm.mu.Unlock() } } ================================================ FILE: server/proxy/http.go ================================================ // Copyright 2019 fatedier, fatedier@gmail.com // // Licensed under the Apache License, Version 2.0 (the "License"); // you may not use this file except in compliance with the License. // You may obtain a copy of the License at // // http://www.apache.org/licenses/LICENSE-2.0 // // Unless required by applicable law or agreed to in writing, software // distributed under the License is distributed on an "AS IS" BASIS, // WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. // See the License for the specific language governing permissions and // limitations under the License. package proxy import ( "io" "net" "reflect" "strings" libio "github.com/fatedier/golib/io" v1 "github.com/fatedier/frp/pkg/config/v1" "github.com/fatedier/frp/pkg/util/limit" netpkg "github.com/fatedier/frp/pkg/util/net" "github.com/fatedier/frp/pkg/util/util" "github.com/fatedier/frp/pkg/util/vhost" "github.com/fatedier/frp/server/metrics" ) func init() { RegisterProxyFactory(reflect.TypeFor[*v1.HTTPProxyConfig](), NewHTTPProxy) } type HTTPProxy struct { *BaseProxy cfg *v1.HTTPProxyConfig closeFuncs []func() } func NewHTTPProxy(baseProxy *BaseProxy) Proxy { unwrapped, ok := baseProxy.GetConfigurer().(*v1.HTTPProxyConfig) if !ok { return nil } return &HTTPProxy{ BaseProxy: baseProxy, cfg: unwrapped, } } func (pxy *HTTPProxy) Run() (remoteAddr string, err error) { xl := pxy.xl routeConfig := vhost.RouteConfig{ RewriteHost: pxy.cfg.HostHeaderRewrite, RouteByHTTPUser: pxy.cfg.RouteByHTTPUser, Headers: pxy.cfg.RequestHeaders.Set, ResponseHeaders: pxy.cfg.ResponseHeaders.Set, Username: pxy.cfg.HTTPUser, Password: pxy.cfg.HTTPPassword, CreateConnFn: pxy.GetRealConn, } locations := pxy.cfg.Locations if len(locations) == 0 { locations = []string{""} } defer func() { if err != nil { pxy.Close() } }() domains := pxy.buildDomains(pxy.cfg.CustomDomains, pxy.cfg.SubDomain) addrs := make([]string, 0) for _, domain := range domains { routeConfig.Domain = domain for _, location := range locations { routeConfig.Location = location tmpRouteConfig := routeConfig // handle group if pxy.cfg.LoadBalancer.Group != "" { err = pxy.rc.HTTPGroupCtl.Register(pxy.name, pxy.cfg.LoadBalancer.Group, pxy.cfg.LoadBalancer.GroupKey, routeConfig) if err != nil { return } pxy.closeFuncs = append(pxy.closeFuncs, func() { pxy.rc.HTTPGroupCtl.UnRegister(pxy.name, pxy.cfg.LoadBalancer.Group, tmpRouteConfig) }) } else { err = pxy.rc.HTTPReverseProxy.Register(routeConfig) if err != nil { return } pxy.closeFuncs = append(pxy.closeFuncs, func() { pxy.rc.HTTPReverseProxy.UnRegister(tmpRouteConfig) }) } addrs = append(addrs, util.CanonicalAddr(routeConfig.Domain, pxy.serverCfg.VhostHTTPPort)) xl.Infof("http proxy listen for host [%s] location [%s] group [%s], routeByHTTPUser [%s]", routeConfig.Domain, routeConfig.Location, pxy.cfg.LoadBalancer.Group, pxy.cfg.RouteByHTTPUser) } } remoteAddr = strings.Join(addrs, ",") return } func (pxy *HTTPProxy) GetRealConn(remoteAddr string) (workConn net.Conn, err error) { xl := pxy.xl rAddr, errRet := net.ResolveTCPAddr("tcp", remoteAddr) if errRet != nil { xl.Warnf("resolve TCP addr [%s] error: %v", remoteAddr, errRet) // we do not return error here since remoteAddr is not necessary for proxies without proxy protocol enabled } tmpConn, errRet := pxy.GetWorkConnFromPool(rAddr, nil) if errRet != nil { err = errRet return } var rwc io.ReadWriteCloser = tmpConn if pxy.cfg.Transport.UseEncryption { rwc, err = libio.WithEncryption(rwc, pxy.encryptionKey) if err != nil { xl.Errorf("create encryption stream error: %v", err) tmpConn.Close() return } } if pxy.cfg.Transport.UseCompression { rwc = libio.WithCompression(rwc) } if pxy.GetLimiter() != nil { rwc = libio.WrapReadWriteCloser(limit.NewReader(rwc, pxy.GetLimiter()), limit.NewWriter(rwc, pxy.GetLimiter()), func() error { return rwc.Close() }) } workConn = netpkg.WrapReadWriteCloserToConn(rwc, tmpConn) workConn = netpkg.WrapStatsConn(workConn, pxy.updateStatsAfterClosedConn) metrics.Server.OpenConnection(pxy.GetName(), pxy.GetConfigurer().GetBaseConfig().Type) return } func (pxy *HTTPProxy) updateStatsAfterClosedConn(totalRead, totalWrite int64) { name := pxy.GetName() proxyType := pxy.GetConfigurer().GetBaseConfig().Type metrics.Server.CloseConnection(name, proxyType) metrics.Server.AddTrafficIn(name, proxyType, totalWrite) metrics.Server.AddTrafficOut(name, proxyType, totalRead) } func (pxy *HTTPProxy) Close() { pxy.BaseProxy.Close() for _, closeFn := range pxy.closeFuncs { closeFn() } } ================================================ FILE: server/proxy/https.go ================================================ // Copyright 2019 fatedier, fatedier@gmail.com // // Licensed under the Apache License, Version 2.0 (the "License"); // you may not use this file except in compliance with the License. // You may obtain a copy of the License at // // http://www.apache.org/licenses/LICENSE-2.0 // // Unless required by applicable law or agreed to in writing, software // distributed under the License is distributed on an "AS IS" BASIS, // WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. // See the License for the specific language governing permissions and // limitations under the License. package proxy import ( "net" "reflect" "strings" v1 "github.com/fatedier/frp/pkg/config/v1" "github.com/fatedier/frp/pkg/util/util" "github.com/fatedier/frp/pkg/util/vhost" ) func init() { RegisterProxyFactory(reflect.TypeFor[*v1.HTTPSProxyConfig](), NewHTTPSProxy) } type HTTPSProxy struct { *BaseProxy cfg *v1.HTTPSProxyConfig } func NewHTTPSProxy(baseProxy *BaseProxy) Proxy { unwrapped, ok := baseProxy.GetConfigurer().(*v1.HTTPSProxyConfig) if !ok { return nil } return &HTTPSProxy{ BaseProxy: baseProxy, cfg: unwrapped, } } func (pxy *HTTPSProxy) Run() (remoteAddr string, err error) { xl := pxy.xl routeConfig := &vhost.RouteConfig{} defer func() { if err != nil { pxy.Close() } }() domains := pxy.buildDomains(pxy.cfg.CustomDomains, pxy.cfg.SubDomain) addrs := make([]string, 0) for _, domain := range domains { l, err := pxy.listenForDomain(routeConfig, domain) if err != nil { return "", err } pxy.listeners = append(pxy.listeners, l) addrs = append(addrs, util.CanonicalAddr(domain, pxy.serverCfg.VhostHTTPSPort)) xl.Infof("https proxy listen for host [%s] group [%s]", domain, pxy.cfg.LoadBalancer.Group) } pxy.startCommonTCPListenersHandler() remoteAddr = strings.Join(addrs, ",") return } func (pxy *HTTPSProxy) Close() { pxy.BaseProxy.Close() } func (pxy *HTTPSProxy) listenForDomain(routeConfig *vhost.RouteConfig, domain string) (net.Listener, error) { tmpRouteConfig := *routeConfig tmpRouteConfig.Domain = domain if pxy.cfg.LoadBalancer.Group != "" { return pxy.rc.HTTPSGroupCtl.Listen( pxy.ctx, pxy.cfg.LoadBalancer.Group, pxy.cfg.LoadBalancer.GroupKey, tmpRouteConfig, ) } return pxy.rc.VhostHTTPSMuxer.Listen(pxy.ctx, &tmpRouteConfig) } ================================================ FILE: server/proxy/proxy.go ================================================ // Copyright 2017 fatedier, fatedier@gmail.com // // Licensed under the Apache License, Version 2.0 (the "License"); // you may not use this file except in compliance with the License. // You may obtain a copy of the License at // // http://www.apache.org/licenses/LICENSE-2.0 // // Unless required by applicable law or agreed to in writing, software // distributed under the License is distributed on an "AS IS" BASIS, // WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. // See the License for the specific language governing permissions and // limitations under the License. package proxy import ( "context" "fmt" "io" "net" "reflect" "strconv" "sync" "time" libio "github.com/fatedier/golib/io" "golang.org/x/time/rate" "github.com/fatedier/frp/pkg/config/types" v1 "github.com/fatedier/frp/pkg/config/v1" "github.com/fatedier/frp/pkg/msg" plugin "github.com/fatedier/frp/pkg/plugin/server" "github.com/fatedier/frp/pkg/util/limit" netpkg "github.com/fatedier/frp/pkg/util/net" "github.com/fatedier/frp/pkg/util/xlog" "github.com/fatedier/frp/server/controller" "github.com/fatedier/frp/server/metrics" ) var proxyFactoryRegistry = map[reflect.Type]func(*BaseProxy) Proxy{} func RegisterProxyFactory(proxyConfType reflect.Type, factory func(*BaseProxy) Proxy) { proxyFactoryRegistry[proxyConfType] = factory } type GetWorkConnFn func() (net.Conn, error) type Proxy interface { Context() context.Context Run() (remoteAddr string, err error) GetName() string GetConfigurer() v1.ProxyConfigurer GetWorkConnFromPool(src, dst net.Addr) (workConn net.Conn, err error) GetUsedPortsNum() int GetResourceController() *controller.ResourceController GetUserInfo() plugin.UserInfo GetLimiter() *rate.Limiter GetLoginMsg() *msg.Login Close() } type BaseProxy struct { name string rc *controller.ResourceController listeners []net.Listener usedPortsNum int poolCount int getWorkConnFn GetWorkConnFn serverCfg *v1.ServerConfig encryptionKey []byte limiter *rate.Limiter userInfo plugin.UserInfo loginMsg *msg.Login configurer v1.ProxyConfigurer mu sync.RWMutex xl *xlog.Logger ctx context.Context } func (pxy *BaseProxy) GetName() string { return pxy.name } func (pxy *BaseProxy) Context() context.Context { return pxy.ctx } func (pxy *BaseProxy) GetUsedPortsNum() int { return pxy.usedPortsNum } func (pxy *BaseProxy) GetResourceController() *controller.ResourceController { return pxy.rc } func (pxy *BaseProxy) GetUserInfo() plugin.UserInfo { return pxy.userInfo } func (pxy *BaseProxy) GetLoginMsg() *msg.Login { return pxy.loginMsg } func (pxy *BaseProxy) GetLimiter() *rate.Limiter { return pxy.limiter } func (pxy *BaseProxy) GetConfigurer() v1.ProxyConfigurer { return pxy.configurer } func (pxy *BaseProxy) Close() { xl := xlog.FromContextSafe(pxy.ctx) xl.Infof("proxy closing") for _, l := range pxy.listeners { l.Close() } } // GetWorkConnFromPool try to get a new work connections from pool // for quickly response, we immediately send the StartWorkConn message to frpc after take out one from pool func (pxy *BaseProxy) GetWorkConnFromPool(src, dst net.Addr) (workConn net.Conn, err error) { xl := xlog.FromContextSafe(pxy.ctx) // try all connections from the pool for i := 0; i < pxy.poolCount+1; i++ { if workConn, err = pxy.getWorkConnFn(); err != nil { xl.Warnf("failed to get work connection: %v", err) return } xl.Debugf("get a new work connection: [%s]", workConn.RemoteAddr().String()) xl.Spawn().AppendPrefix(pxy.GetName()) workConn = netpkg.NewContextConn(pxy.ctx, workConn) var ( srcAddr string dstAddr string srcPortStr string dstPortStr string srcPort uint64 dstPort uint64 ) if src != nil { srcAddr, srcPortStr, _ = net.SplitHostPort(src.String()) srcPort, _ = strconv.ParseUint(srcPortStr, 10, 16) } if dst != nil { dstAddr, dstPortStr, _ = net.SplitHostPort(dst.String()) dstPort, _ = strconv.ParseUint(dstPortStr, 10, 16) } err = msg.WriteMsg(workConn, &msg.StartWorkConn{ ProxyName: pxy.GetName(), SrcAddr: srcAddr, SrcPort: uint16(srcPort), DstAddr: dstAddr, DstPort: uint16(dstPort), Error: "", }) if err != nil { xl.Warnf("failed to send message to work connection from pool: %v, times: %d", err, i) workConn.Close() workConn = nil } else { break } } if err != nil { xl.Errorf("try to get work connection failed in the end") return } return } // startVisitorListener sets up a VisitorManager listener for visitor-based proxies (STCP, SUDP). func (pxy *BaseProxy) startVisitorListener(secretKey string, allowUsers []string, proxyType string) error { // if allowUsers is empty, only allow same user from proxy if len(allowUsers) == 0 { allowUsers = []string{pxy.GetUserInfo().User} } listener, err := pxy.rc.VisitorManager.Listen(pxy.GetName(), secretKey, allowUsers) if err != nil { return err } pxy.listeners = append(pxy.listeners, listener) pxy.xl.Infof("%s proxy custom listen success", proxyType) pxy.startCommonTCPListenersHandler() return nil } // buildDomains constructs a list of domains from custom domains and subdomain configuration. func (pxy *BaseProxy) buildDomains(customDomains []string, subDomain string) []string { domains := make([]string, 0, len(customDomains)+1) for _, d := range customDomains { if d != "" { domains = append(domains, d) } } if subDomain != "" { domains = append(domains, subDomain+"."+pxy.serverCfg.SubDomainHost) } return domains } // startCommonTCPListenersHandler start a goroutine handler for each listener. func (pxy *BaseProxy) startCommonTCPListenersHandler() { xl := xlog.FromContextSafe(pxy.ctx) for _, listener := range pxy.listeners { go func(l net.Listener) { var tempDelay time.Duration // how long to sleep on accept failure for { // block // if listener is closed, err returned c, err := l.Accept() if err != nil { if err, ok := err.(interface{ Temporary() bool }); ok && err.Temporary() { if tempDelay == 0 { tempDelay = 5 * time.Millisecond } else { tempDelay *= 2 } if maxTime := 1 * time.Second; tempDelay > maxTime { tempDelay = maxTime } xl.Infof("met temporary error: %s, sleep for %s ...", err, tempDelay) time.Sleep(tempDelay) continue } xl.Warnf("listener is closed: %s", err) return } xl.Infof("get a user connection [%s]", c.RemoteAddr().String()) go pxy.handleUserTCPConnection(c) } }(listener) } } // HandleUserTCPConnection is used for incoming user TCP connections. func (pxy *BaseProxy) handleUserTCPConnection(userConn net.Conn) { xl := xlog.FromContextSafe(pxy.Context()) defer userConn.Close() cfg := pxy.configurer.GetBaseConfig() // server plugin hook rc := pxy.GetResourceController() content := &plugin.NewUserConnContent{ User: pxy.GetUserInfo(), ProxyName: pxy.GetName(), ProxyType: cfg.Type, RemoteAddr: userConn.RemoteAddr().String(), } _, err := rc.PluginManager.NewUserConn(content) if err != nil { xl.Warnf("the user conn [%s] was rejected, err:%v", content.RemoteAddr, err) return } // try all connections from the pool workConn, err := pxy.GetWorkConnFromPool(userConn.RemoteAddr(), userConn.LocalAddr()) if err != nil { return } defer workConn.Close() var local io.ReadWriteCloser = workConn xl.Tracef("handler user tcp connection, use_encryption: %t, use_compression: %t", cfg.Transport.UseEncryption, cfg.Transport.UseCompression) if cfg.Transport.UseEncryption { local, err = libio.WithEncryption(local, pxy.encryptionKey) if err != nil { xl.Errorf("create encryption stream error: %v", err) return } } if cfg.Transport.UseCompression { var recycleFn func() local, recycleFn = libio.WithCompressionFromPool(local) defer recycleFn() } if pxy.GetLimiter() != nil { local = libio.WrapReadWriteCloser(limit.NewReader(local, pxy.GetLimiter()), limit.NewWriter(local, pxy.GetLimiter()), func() error { return local.Close() }) } xl.Debugf("join connections, workConn(l[%s] r[%s]) userConn(l[%s] r[%s])", workConn.LocalAddr().String(), workConn.RemoteAddr().String(), userConn.LocalAddr().String(), userConn.RemoteAddr().String()) name := pxy.GetName() proxyType := cfg.Type metrics.Server.OpenConnection(name, proxyType) inCount, outCount, _ := libio.Join(local, userConn) metrics.Server.CloseConnection(name, proxyType) metrics.Server.AddTrafficIn(name, proxyType, inCount) metrics.Server.AddTrafficOut(name, proxyType, outCount) xl.Debugf("join connections closed") } type Options struct { UserInfo plugin.UserInfo LoginMsg *msg.Login PoolCount int ResourceController *controller.ResourceController GetWorkConnFn GetWorkConnFn Configurer v1.ProxyConfigurer ServerCfg *v1.ServerConfig EncryptionKey []byte } func NewProxy(ctx context.Context, options *Options) (pxy Proxy, err error) { configurer := options.Configurer xl := xlog.FromContextSafe(ctx).Spawn().AppendPrefix(configurer.GetBaseConfig().Name) var limiter *rate.Limiter limitBytes := configurer.GetBaseConfig().Transport.BandwidthLimit.Bytes() if limitBytes > 0 && configurer.GetBaseConfig().Transport.BandwidthLimitMode == types.BandwidthLimitModeServer { limiter = rate.NewLimiter(rate.Limit(float64(limitBytes)), int(limitBytes)) } basePxy := BaseProxy{ name: configurer.GetBaseConfig().Name, rc: options.ResourceController, listeners: make([]net.Listener, 0), poolCount: options.PoolCount, getWorkConnFn: options.GetWorkConnFn, serverCfg: options.ServerCfg, encryptionKey: options.EncryptionKey, limiter: limiter, xl: xl, ctx: xlog.NewContext(ctx, xl), userInfo: options.UserInfo, loginMsg: options.LoginMsg, configurer: configurer, } factory := proxyFactoryRegistry[reflect.TypeOf(configurer)] if factory == nil { return pxy, fmt.Errorf("proxy type not support") } pxy = factory(&basePxy) if pxy == nil { return nil, fmt.Errorf("proxy not created") } return pxy, nil } type Manager struct { // proxies indexed by proxy name pxys map[string]Proxy mu sync.RWMutex } func NewManager() *Manager { return &Manager{ pxys: make(map[string]Proxy), } } func (pm *Manager) Add(name string, pxy Proxy) error { pm.mu.Lock() defer pm.mu.Unlock() if _, ok := pm.pxys[name]; ok { return fmt.Errorf("proxy name [%s] is already in use", name) } pm.pxys[name] = pxy return nil } func (pm *Manager) Exist(name string) bool { pm.mu.RLock() defer pm.mu.RUnlock() _, ok := pm.pxys[name] return ok } func (pm *Manager) Del(name string) { pm.mu.Lock() defer pm.mu.Unlock() delete(pm.pxys, name) } func (pm *Manager) GetByName(name string) (pxy Proxy, ok bool) { pm.mu.RLock() defer pm.mu.RUnlock() pxy, ok = pm.pxys[name] return } ================================================ FILE: server/proxy/stcp.go ================================================ // Copyright 2019 fatedier, fatedier@gmail.com // // Licensed under the Apache License, Version 2.0 (the "License"); // you may not use this file except in compliance with the License. // You may obtain a copy of the License at // // http://www.apache.org/licenses/LICENSE-2.0 // // Unless required by applicable law or agreed to in writing, software // distributed under the License is distributed on an "AS IS" BASIS, // WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. // See the License for the specific language governing permissions and // limitations under the License. package proxy import ( "reflect" v1 "github.com/fatedier/frp/pkg/config/v1" ) func init() { RegisterProxyFactory(reflect.TypeFor[*v1.STCPProxyConfig](), NewSTCPProxy) } type STCPProxy struct { *BaseProxy cfg *v1.STCPProxyConfig } func NewSTCPProxy(baseProxy *BaseProxy) Proxy { unwrapped, ok := baseProxy.GetConfigurer().(*v1.STCPProxyConfig) if !ok { return nil } return &STCPProxy{ BaseProxy: baseProxy, cfg: unwrapped, } } func (pxy *STCPProxy) Run() (remoteAddr string, err error) { err = pxy.startVisitorListener(pxy.cfg.Secretkey, pxy.cfg.AllowUsers, "stcp") return } func (pxy *STCPProxy) Close() { pxy.BaseProxy.Close() pxy.rc.VisitorManager.CloseListener(pxy.GetName()) } ================================================ FILE: server/proxy/sudp.go ================================================ // Copyright 2019 fatedier, fatedier@gmail.com // // Licensed under the Apache License, Version 2.0 (the "License"); // you may not use this file except in compliance with the License. // You may obtain a copy of the License at // // http://www.apache.org/licenses/LICENSE-2.0 // // Unless required by applicable law or agreed to in writing, software // distributed under the License is distributed on an "AS IS" BASIS, // WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. // See the License for the specific language governing permissions and // limitations under the License. package proxy import ( "reflect" v1 "github.com/fatedier/frp/pkg/config/v1" ) func init() { RegisterProxyFactory(reflect.TypeFor[*v1.SUDPProxyConfig](), NewSUDPProxy) } type SUDPProxy struct { *BaseProxy cfg *v1.SUDPProxyConfig } func NewSUDPProxy(baseProxy *BaseProxy) Proxy { unwrapped, ok := baseProxy.GetConfigurer().(*v1.SUDPProxyConfig) if !ok { return nil } return &SUDPProxy{ BaseProxy: baseProxy, cfg: unwrapped, } } func (pxy *SUDPProxy) Run() (remoteAddr string, err error) { err = pxy.startVisitorListener(pxy.cfg.Secretkey, pxy.cfg.AllowUsers, "sudp") return } func (pxy *SUDPProxy) Close() { pxy.BaseProxy.Close() pxy.rc.VisitorManager.CloseListener(pxy.GetName()) } ================================================ FILE: server/proxy/tcp.go ================================================ // Copyright 2019 fatedier, fatedier@gmail.com // // Licensed under the Apache License, Version 2.0 (the "License"); // you may not use this file except in compliance with the License. // You may obtain a copy of the License at // // http://www.apache.org/licenses/LICENSE-2.0 // // Unless required by applicable law or agreed to in writing, software // distributed under the License is distributed on an "AS IS" BASIS, // WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. // See the License for the specific language governing permissions and // limitations under the License. package proxy import ( "fmt" "net" "reflect" "strconv" v1 "github.com/fatedier/frp/pkg/config/v1" ) func init() { RegisterProxyFactory(reflect.TypeFor[*v1.TCPProxyConfig](), NewTCPProxy) } type TCPProxy struct { *BaseProxy cfg *v1.TCPProxyConfig realBindPort int } func NewTCPProxy(baseProxy *BaseProxy) Proxy { unwrapped, ok := baseProxy.GetConfigurer().(*v1.TCPProxyConfig) if !ok { return nil } baseProxy.usedPortsNum = 1 return &TCPProxy{ BaseProxy: baseProxy, cfg: unwrapped, } } func (pxy *TCPProxy) Run() (remoteAddr string, err error) { xl := pxy.xl if pxy.cfg.LoadBalancer.Group != "" { l, realBindPort, errRet := pxy.rc.TCPGroupCtl.Listen(pxy.name, pxy.cfg.LoadBalancer.Group, pxy.cfg.LoadBalancer.GroupKey, pxy.serverCfg.ProxyBindAddr, pxy.cfg.RemotePort) if errRet != nil { err = errRet return } defer func() { if err != nil { l.Close() } }() pxy.realBindPort = realBindPort pxy.listeners = append(pxy.listeners, l) xl.Infof("tcp proxy listen port [%d] in group [%s]", pxy.cfg.RemotePort, pxy.cfg.LoadBalancer.Group) } else { pxy.realBindPort, err = pxy.rc.TCPPortManager.Acquire(pxy.name, pxy.cfg.RemotePort) if err != nil { return } defer func() { if err != nil { pxy.rc.TCPPortManager.Release(pxy.realBindPort) } }() listener, errRet := net.Listen("tcp", net.JoinHostPort(pxy.serverCfg.ProxyBindAddr, strconv.Itoa(pxy.realBindPort))) if errRet != nil { err = errRet return } pxy.listeners = append(pxy.listeners, listener) xl.Infof("tcp proxy listen port [%d]", pxy.cfg.RemotePort) } pxy.cfg.RemotePort = pxy.realBindPort remoteAddr = fmt.Sprintf(":%d", pxy.realBindPort) pxy.startCommonTCPListenersHandler() return } func (pxy *TCPProxy) Close() { pxy.BaseProxy.Close() if pxy.cfg.LoadBalancer.Group == "" { pxy.rc.TCPPortManager.Release(pxy.realBindPort) } } ================================================ FILE: server/proxy/tcpmux.go ================================================ // Copyright 2020 guylewin, guy@lewin.co.il // // Licensed under the Apache License, Version 2.0 (the "License"); // you may not use this file except in compliance with the License. // You may obtain a copy of the License at // // http://www.apache.org/licenses/LICENSE-2.0 // // Unless required by applicable law or agreed to in writing, software // distributed under the License is distributed on an "AS IS" BASIS, // WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. // See the License for the specific language governing permissions and // limitations under the License. package proxy import ( "fmt" "net" "reflect" "strings" v1 "github.com/fatedier/frp/pkg/config/v1" "github.com/fatedier/frp/pkg/util/util" "github.com/fatedier/frp/pkg/util/vhost" ) func init() { RegisterProxyFactory(reflect.TypeFor[*v1.TCPMuxProxyConfig](), NewTCPMuxProxy) } type TCPMuxProxy struct { *BaseProxy cfg *v1.TCPMuxProxyConfig } func NewTCPMuxProxy(baseProxy *BaseProxy) Proxy { unwrapped, ok := baseProxy.GetConfigurer().(*v1.TCPMuxProxyConfig) if !ok { return nil } return &TCPMuxProxy{ BaseProxy: baseProxy, cfg: unwrapped, } } func (pxy *TCPMuxProxy) httpConnectListen( domain, routeByHTTPUser, httpUser, httpPwd string, addrs []string) ([]string, error, ) { var l net.Listener var err error routeConfig := &vhost.RouteConfig{ Domain: domain, RouteByHTTPUser: routeByHTTPUser, Username: httpUser, Password: httpPwd, } if pxy.cfg.LoadBalancer.Group != "" { l, err = pxy.rc.TCPMuxGroupCtl.Listen(pxy.ctx, pxy.cfg.Multiplexer, pxy.cfg.LoadBalancer.Group, pxy.cfg.LoadBalancer.GroupKey, *routeConfig) } else { l, err = pxy.rc.TCPMuxHTTPConnectMuxer.Listen(pxy.ctx, routeConfig) } if err != nil { return nil, err } pxy.xl.Infof("tcpmux httpconnect multiplexer listens for host [%s], group [%s] routeByHTTPUser [%s]", domain, pxy.cfg.LoadBalancer.Group, pxy.cfg.RouteByHTTPUser) pxy.listeners = append(pxy.listeners, l) return append(addrs, util.CanonicalAddr(domain, pxy.serverCfg.TCPMuxHTTPConnectPort)), nil } func (pxy *TCPMuxProxy) httpConnectRun() (remoteAddr string, err error) { domains := pxy.buildDomains(pxy.cfg.CustomDomains, pxy.cfg.SubDomain) addrs := make([]string, 0) for _, domain := range domains { addrs, err = pxy.httpConnectListen(domain, pxy.cfg.RouteByHTTPUser, pxy.cfg.HTTPUser, pxy.cfg.HTTPPassword, addrs) if err != nil { return "", err } } pxy.startCommonTCPListenersHandler() remoteAddr = strings.Join(addrs, ",") return remoteAddr, err } func (pxy *TCPMuxProxy) Run() (remoteAddr string, err error) { switch v1.TCPMultiplexerType(pxy.cfg.Multiplexer) { case v1.TCPMultiplexerHTTPConnect: remoteAddr, err = pxy.httpConnectRun() default: err = fmt.Errorf("unknown multiplexer [%s]", pxy.cfg.Multiplexer) } if err != nil { pxy.Close() } return remoteAddr, err } func (pxy *TCPMuxProxy) Close() { pxy.BaseProxy.Close() } ================================================ FILE: server/proxy/udp.go ================================================ // Copyright 2019 fatedier, fatedier@gmail.com // // Licensed under the Apache License, Version 2.0 (the "License"); // you may not use this file except in compliance with the License. // You may obtain a copy of the License at // // http://www.apache.org/licenses/LICENSE-2.0 // // Unless required by applicable law or agreed to in writing, software // distributed under the License is distributed on an "AS IS" BASIS, // WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. // See the License for the specific language governing permissions and // limitations under the License. package proxy import ( "context" "fmt" "io" "net" "reflect" "strconv" "time" "github.com/fatedier/golib/errors" libio "github.com/fatedier/golib/io" v1 "github.com/fatedier/frp/pkg/config/v1" "github.com/fatedier/frp/pkg/msg" "github.com/fatedier/frp/pkg/proto/udp" "github.com/fatedier/frp/pkg/util/limit" netpkg "github.com/fatedier/frp/pkg/util/net" "github.com/fatedier/frp/server/metrics" ) func init() { RegisterProxyFactory(reflect.TypeFor[*v1.UDPProxyConfig](), NewUDPProxy) } type UDPProxy struct { *BaseProxy cfg *v1.UDPProxyConfig realBindPort int // udpConn is the listener of udp packages udpConn *net.UDPConn // there are always only one workConn at the same time // get another one if it closed workConn net.Conn // sendCh is used for sending packages to workConn sendCh chan *msg.UDPPacket // readCh is used for reading packages from workConn readCh chan *msg.UDPPacket // checkCloseCh is used for watching if workConn is closed checkCloseCh chan int isClosed bool } func NewUDPProxy(baseProxy *BaseProxy) Proxy { unwrapped, ok := baseProxy.GetConfigurer().(*v1.UDPProxyConfig) if !ok { return nil } baseProxy.usedPortsNum = 1 return &UDPProxy{ BaseProxy: baseProxy, cfg: unwrapped, } } func (pxy *UDPProxy) Run() (remoteAddr string, err error) { xl := pxy.xl pxy.realBindPort, err = pxy.rc.UDPPortManager.Acquire(pxy.name, pxy.cfg.RemotePort) if err != nil { return "", fmt.Errorf("acquire port %d error: %v", pxy.cfg.RemotePort, err) } defer func() { if err != nil { pxy.rc.UDPPortManager.Release(pxy.realBindPort) } }() remoteAddr = fmt.Sprintf(":%d", pxy.realBindPort) pxy.cfg.RemotePort = pxy.realBindPort addr, errRet := net.ResolveUDPAddr("udp", net.JoinHostPort(pxy.serverCfg.ProxyBindAddr, strconv.Itoa(pxy.realBindPort))) if errRet != nil { err = errRet return } udpConn, errRet := net.ListenUDP("udp", addr) if errRet != nil { err = errRet xl.Warnf("listen udp port error: %v", err) return } xl.Infof("udp proxy listen port [%d]", pxy.cfg.RemotePort) pxy.udpConn = udpConn pxy.sendCh = make(chan *msg.UDPPacket, 1024) pxy.readCh = make(chan *msg.UDPPacket, 1024) pxy.checkCloseCh = make(chan int) // read message from workConn, if it returns any error, notify proxy to start a new workConn workConnReaderFn := func(conn net.Conn) { for { var ( rawMsg msg.Message errRet error ) xl.Tracef("loop waiting message from udp workConn") // client will send heartbeat in workConn for keeping alive _ = conn.SetReadDeadline(time.Now().Add(time.Duration(60) * time.Second)) if rawMsg, errRet = msg.ReadMsg(conn); errRet != nil { xl.Warnf("read from workConn for udp error: %v", errRet) _ = conn.Close() // notify proxy to start a new work connection // ignore error here, it means the proxy is closed _ = errors.PanicToError(func() { pxy.checkCloseCh <- 1 }) return } if err := conn.SetReadDeadline(time.Time{}); err != nil { xl.Warnf("set read deadline error: %v", err) } switch m := rawMsg.(type) { case *msg.Ping: xl.Tracef("udp work conn get ping message") continue case *msg.UDPPacket: if errRet := errors.PanicToError(func() { xl.Tracef("get udp message from workConn, len: %d", len(m.Content)) pxy.readCh <- m metrics.Server.AddTrafficOut( pxy.GetName(), pxy.GetConfigurer().GetBaseConfig().Type, int64(len(m.Content)), ) }); errRet != nil { conn.Close() xl.Infof("reader goroutine for udp work connection closed") return } } } } // send message to workConn workConnSenderFn := func(conn net.Conn, ctx context.Context) { var errRet error for { select { case udpMsg, ok := <-pxy.sendCh: if !ok { xl.Infof("sender goroutine for udp work connection closed") return } if errRet = msg.WriteMsg(conn, udpMsg); errRet != nil { xl.Infof("sender goroutine for udp work connection closed: %v", errRet) conn.Close() return } xl.Tracef("send message to udp workConn, len: %d", len(udpMsg.Content)) metrics.Server.AddTrafficIn( pxy.GetName(), pxy.GetConfigurer().GetBaseConfig().Type, int64(len(udpMsg.Content)), ) continue case <-ctx.Done(): xl.Infof("sender goroutine for udp work connection closed") return } } } go func() { // Sleep a while for waiting control send the NewProxyResp to client. time.Sleep(500 * time.Millisecond) for { workConn, err := pxy.GetWorkConnFromPool(nil, nil) if err != nil { time.Sleep(1 * time.Second) // check if proxy is closed select { case _, ok := <-pxy.checkCloseCh: if !ok { return } default: } continue } // close the old workConn and replace it with a new one if pxy.workConn != nil { pxy.workConn.Close() } var rwc io.ReadWriteCloser = workConn if pxy.cfg.Transport.UseEncryption { rwc, err = libio.WithEncryption(rwc, pxy.encryptionKey) if err != nil { xl.Errorf("create encryption stream error: %v", err) workConn.Close() continue } } if pxy.cfg.Transport.UseCompression { rwc = libio.WithCompression(rwc) } if pxy.GetLimiter() != nil { rwc = libio.WrapReadWriteCloser(limit.NewReader(rwc, pxy.GetLimiter()), limit.NewWriter(rwc, pxy.GetLimiter()), func() error { return rwc.Close() }) } pxy.workConn = netpkg.WrapReadWriteCloserToConn(rwc, workConn) ctx, cancel := context.WithCancel(context.Background()) go workConnReaderFn(pxy.workConn) go workConnSenderFn(pxy.workConn, ctx) _, ok := <-pxy.checkCloseCh cancel() if !ok { return } } }() // Read from user connections and send wrapped udp message to sendCh (forwarded by workConn). // Client will transfor udp message to local udp service and waiting for response for a while. // Response will be wrapped to be forwarded by work connection to server. // Close readCh and sendCh at the end. go func() { udp.ForwardUserConn(udpConn, pxy.readCh, pxy.sendCh, int(pxy.serverCfg.UDPPacketSize)) pxy.Close() }() return remoteAddr, nil } func (pxy *UDPProxy) Close() { pxy.mu.Lock() defer pxy.mu.Unlock() if !pxy.isClosed { pxy.isClosed = true pxy.BaseProxy.Close() if pxy.workConn != nil { pxy.workConn.Close() } pxy.udpConn.Close() // all channels only closed here close(pxy.checkCloseCh) close(pxy.readCh) close(pxy.sendCh) } pxy.rc.UDPPortManager.Release(pxy.realBindPort) } ================================================ FILE: server/proxy/xtcp.go ================================================ // Copyright 2019 fatedier, fatedier@gmail.com // // Licensed under the Apache License, Version 2.0 (the "License"); // you may not use this file except in compliance with the License. // You may obtain a copy of the License at // // http://www.apache.org/licenses/LICENSE-2.0 // // Unless required by applicable law or agreed to in writing, software // distributed under the License is distributed on an "AS IS" BASIS, // WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. // See the License for the specific language governing permissions and // limitations under the License. package proxy import ( "fmt" "reflect" "sync" v1 "github.com/fatedier/frp/pkg/config/v1" "github.com/fatedier/frp/pkg/msg" ) func init() { RegisterProxyFactory(reflect.TypeFor[*v1.XTCPProxyConfig](), NewXTCPProxy) } type XTCPProxy struct { *BaseProxy cfg *v1.XTCPProxyConfig closeCh chan struct{} closeOnce sync.Once } func NewXTCPProxy(baseProxy *BaseProxy) Proxy { unwrapped, ok := baseProxy.GetConfigurer().(*v1.XTCPProxyConfig) if !ok { return nil } return &XTCPProxy{ BaseProxy: baseProxy, cfg: unwrapped, closeCh: make(chan struct{}), } } func (pxy *XTCPProxy) Run() (remoteAddr string, err error) { xl := pxy.xl if pxy.rc.NatHoleController == nil { err = fmt.Errorf("xtcp is not supported in frps") return } allowUsers := pxy.cfg.AllowUsers // if allowUsers is empty, only allow same user from proxy if len(allowUsers) == 0 { allowUsers = []string{pxy.GetUserInfo().User} } sidCh, err := pxy.rc.NatHoleController.ListenClient(pxy.GetName(), pxy.cfg.Secretkey, allowUsers) if err != nil { return "", err } go func() { for { select { case <-pxy.closeCh: return case sid := <-sidCh: workConn, errRet := pxy.GetWorkConnFromPool(nil, nil) if errRet != nil { continue } m := &msg.NatHoleSid{ Sid: sid, } errRet = msg.WriteMsg(workConn, m) if errRet != nil { xl.Warnf("write nat hole sid package error, %v", errRet) } workConn.Close() } } }() return } func (pxy *XTCPProxy) Close() { pxy.closeOnce.Do(func() { pxy.BaseProxy.Close() pxy.rc.NatHoleController.CloseClient(pxy.GetName()) close(pxy.closeCh) }) } ================================================ FILE: server/registry/registry.go ================================================ // Copyright 2025 The frp Authors // // Licensed under the Apache License, Version 2.0 (the "License"); // you may not use this file except in compliance with the License. // You may obtain a copy of the License at // // http://www.apache.org/licenses/LICENSE-2.0 // // Unless required by applicable law or agreed to in writing, software // distributed under the License is distributed on an "AS IS" BASIS, // WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. // See the License for the specific language governing permissions and // limitations under the License. package registry import ( "fmt" "sync" "time" ) // ClientInfo captures metadata about a connected frpc instance. type ClientInfo struct { Key string User string RawClientID string RunID string Hostname string IP string Version string FirstConnectedAt time.Time LastConnectedAt time.Time DisconnectedAt time.Time Online bool } // ClientRegistry keeps track of active clients keyed by "{user}.{clientID}" (runID fallback when raw clientID is empty). // Entries without an explicit raw clientID are removed on disconnect to avoid stale offline records. type ClientRegistry struct { mu sync.RWMutex clients map[string]*ClientInfo runIndex map[string]string } func NewClientRegistry() *ClientRegistry { return &ClientRegistry{ clients: make(map[string]*ClientInfo), runIndex: make(map[string]string), } } // Register stores/updates metadata for a client and returns the registry key plus whether it conflicts with an online client. func (cr *ClientRegistry) Register(user, rawClientID, runID, hostname, version, remoteAddr string) (key string, conflict bool) { if runID == "" { return "", false } effectiveID := rawClientID if effectiveID == "" { effectiveID = runID } key = cr.composeClientKey(user, effectiveID) enforceUnique := rawClientID != "" now := time.Now() cr.mu.Lock() defer cr.mu.Unlock() info, exists := cr.clients[key] if enforceUnique && exists && info.Online && info.RunID != "" && info.RunID != runID { return key, true } if !exists { info = &ClientInfo{ Key: key, User: user, FirstConnectedAt: now, } cr.clients[key] = info } else if info.RunID != "" { delete(cr.runIndex, info.RunID) } info.RawClientID = rawClientID info.RunID = runID info.Hostname = hostname info.IP = remoteAddr info.Version = version if info.FirstConnectedAt.IsZero() { info.FirstConnectedAt = now } info.LastConnectedAt = now info.DisconnectedAt = time.Time{} info.Online = true cr.runIndex[runID] = key return key, false } // MarkOfflineByRunID marks the client as offline when the corresponding control disconnects. func (cr *ClientRegistry) MarkOfflineByRunID(runID string) { cr.mu.Lock() defer cr.mu.Unlock() key, ok := cr.runIndex[runID] if !ok { return } if info, ok := cr.clients[key]; ok && info.RunID == runID { if info.RawClientID == "" { delete(cr.clients, key) } else { info.RunID = "" info.Online = false now := time.Now() info.DisconnectedAt = now } } delete(cr.runIndex, runID) } // List returns a snapshot of all known clients. func (cr *ClientRegistry) List() []ClientInfo { cr.mu.RLock() defer cr.mu.RUnlock() result := make([]ClientInfo, 0, len(cr.clients)) for _, info := range cr.clients { result = append(result, *info) } return result } // GetByKey retrieves a client by its composite key ({user}.{clientID} with runID fallback). func (cr *ClientRegistry) GetByKey(key string) (ClientInfo, bool) { cr.mu.RLock() defer cr.mu.RUnlock() info, ok := cr.clients[key] if !ok { return ClientInfo{}, false } return *info, true } // ClientID returns the resolved client identifier for external use. func (info ClientInfo) ClientID() string { if info.RawClientID != "" { return info.RawClientID } return info.RunID } func (cr *ClientRegistry) composeClientKey(user, id string) string { switch { case user == "": return id case id == "": return user default: return fmt.Sprintf("%s.%s", user, id) } } ================================================ FILE: server/service.go ================================================ // Copyright 2017 fatedier, fatedier@gmail.com // // Licensed under the Apache License, Version 2.0 (the "License"); // you may not use this file except in compliance with the License. // You may obtain a copy of the License at // // http://www.apache.org/licenses/LICENSE-2.0 // // Unless required by applicable law or agreed to in writing, software // distributed under the License is distributed on an "AS IS" BASIS, // WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. // See the License for the specific language governing permissions and // limitations under the License. package server import ( "bytes" "context" "crypto/tls" "fmt" "net" "net/http" "os" "strconv" "time" "github.com/fatedier/golib/crypto" "github.com/fatedier/golib/net/mux" fmux "github.com/hashicorp/yamux" quic "github.com/quic-go/quic-go" "github.com/samber/lo" "github.com/fatedier/frp/pkg/auth" v1 "github.com/fatedier/frp/pkg/config/v1" modelmetrics "github.com/fatedier/frp/pkg/metrics" "github.com/fatedier/frp/pkg/msg" "github.com/fatedier/frp/pkg/nathole" plugin "github.com/fatedier/frp/pkg/plugin/server" "github.com/fatedier/frp/pkg/ssh" "github.com/fatedier/frp/pkg/transport" httppkg "github.com/fatedier/frp/pkg/util/http" "github.com/fatedier/frp/pkg/util/log" netpkg "github.com/fatedier/frp/pkg/util/net" "github.com/fatedier/frp/pkg/util/tcpmux" "github.com/fatedier/frp/pkg/util/util" "github.com/fatedier/frp/pkg/util/version" "github.com/fatedier/frp/pkg/util/vhost" "github.com/fatedier/frp/pkg/util/xlog" "github.com/fatedier/frp/server/controller" "github.com/fatedier/frp/server/group" "github.com/fatedier/frp/server/metrics" "github.com/fatedier/frp/server/ports" "github.com/fatedier/frp/server/proxy" "github.com/fatedier/frp/server/registry" "github.com/fatedier/frp/server/visitor" ) const ( connReadTimeout time.Duration = 10 * time.Second vhostReadWriteTimeout time.Duration = 30 * time.Second ) func init() { crypto.DefaultSalt = "frp" // Disable quic-go's receive buffer warning. os.Setenv("QUIC_GO_DISABLE_RECEIVE_BUFFER_WARNING", "true") // Disable quic-go's ECN support by default. It may cause issues on certain operating systems. if os.Getenv("QUIC_GO_DISABLE_ECN") == "" { os.Setenv("QUIC_GO_DISABLE_ECN", "true") } } // Server service type Service struct { // Dispatch connections to different handlers listen on same port muxer *mux.Mux // Accept connections from client listener net.Listener // Accept connections using kcp kcpListener net.Listener // Accept connections using quic quicListener *quic.Listener // Accept connections using websocket websocketListener net.Listener // Accept frp tls connections tlsListener net.Listener // Accept pipe connections from ssh tunnel gateway sshTunnelListener *netpkg.InternalListener // Manage all controllers ctlManager *ControlManager // Track logical clients keyed by user.clientID (runID fallback when raw clientID is empty). clientRegistry *registry.ClientRegistry // Manage all proxies pxyManager *proxy.Manager // Manage all plugins pluginManager *plugin.Manager // HTTP vhost router httpVhostRouter *vhost.Routers // All resource managers and controllers rc *controller.ResourceController // web server for dashboard UI and apis webServer *httppkg.Server sshTunnelGateway *ssh.Gateway // Auth runtime and encryption materials auth *auth.ServerAuth tlsConfig *tls.Config cfg *v1.ServerConfig // service context ctx context.Context // call cancel to stop service cancel context.CancelFunc } func NewService(cfg *v1.ServerConfig) (*Service, error) { tlsConfig, err := transport.NewServerTLSConfig( cfg.Transport.TLS.CertFile, cfg.Transport.TLS.KeyFile, cfg.Transport.TLS.TrustedCaFile) if err != nil { return nil, err } var webServer *httppkg.Server if cfg.WebServer.Port > 0 { ws, err := httppkg.NewServer(cfg.WebServer) if err != nil { return nil, err } webServer = ws modelmetrics.EnableMem() if cfg.EnablePrometheus { modelmetrics.EnablePrometheus() } } authRuntime, err := auth.BuildServerAuth(&cfg.Auth) if err != nil { return nil, err } svr := &Service{ ctlManager: NewControlManager(), clientRegistry: registry.NewClientRegistry(), pxyManager: proxy.NewManager(), pluginManager: plugin.NewManager(), rc: &controller.ResourceController{ VisitorManager: visitor.NewManager(), TCPPortManager: ports.NewManager("tcp", cfg.ProxyBindAddr, cfg.AllowPorts), UDPPortManager: ports.NewManager("udp", cfg.ProxyBindAddr, cfg.AllowPorts), }, sshTunnelListener: netpkg.NewInternalListener(), httpVhostRouter: vhost.NewRouters(), auth: authRuntime, webServer: webServer, tlsConfig: tlsConfig, cfg: cfg, ctx: context.Background(), } if webServer != nil { webServer.RouteRegister(svr.registerRouteHandlers) } // Create tcpmux httpconnect multiplexer. if cfg.TCPMuxHTTPConnectPort > 0 { var l net.Listener address := net.JoinHostPort(cfg.ProxyBindAddr, strconv.Itoa(cfg.TCPMuxHTTPConnectPort)) l, err = net.Listen("tcp", address) if err != nil { return nil, fmt.Errorf("create server listener error, %v", err) } svr.rc.TCPMuxHTTPConnectMuxer, err = tcpmux.NewHTTPConnectTCPMuxer(l, cfg.TCPMuxPassthrough, vhostReadWriteTimeout) if err != nil { return nil, fmt.Errorf("create vhost tcpMuxer error, %v", err) } log.Infof("tcpmux httpconnect multiplexer listen on %s, passthrough: %v", address, cfg.TCPMuxPassthrough) } // Init all plugins for _, p := range cfg.HTTPPlugins { svr.pluginManager.Register(plugin.NewHTTPPluginOptions(p)) log.Infof("plugin [%s] has been registered", p.Name) } svr.rc.PluginManager = svr.pluginManager // Init group controller svr.rc.TCPGroupCtl = group.NewTCPGroupCtl(svr.rc.TCPPortManager) // Init HTTP group controller svr.rc.HTTPGroupCtl = group.NewHTTPGroupController(svr.httpVhostRouter) // Init TCP mux group controller svr.rc.TCPMuxGroupCtl = group.NewTCPMuxGroupCtl(svr.rc.TCPMuxHTTPConnectMuxer) // Init 404 not found page vhost.NotFoundPagePath = cfg.Custom404Page var ( httpMuxOn bool httpsMuxOn bool ) if cfg.BindAddr == cfg.ProxyBindAddr { if cfg.BindPort == cfg.VhostHTTPPort { httpMuxOn = true } if cfg.BindPort == cfg.VhostHTTPSPort { httpsMuxOn = true } } // Listen for accepting connections from client. address := net.JoinHostPort(cfg.BindAddr, strconv.Itoa(cfg.BindPort)) ln, err := net.Listen("tcp", address) if err != nil { return nil, fmt.Errorf("create server listener error, %v", err) } svr.muxer = mux.NewMux(ln) svr.muxer.SetKeepAlive(time.Duration(cfg.Transport.TCPKeepAlive) * time.Second) go func() { _ = svr.muxer.Serve() }() ln = svr.muxer.DefaultListener() svr.listener = ln log.Infof("frps tcp listen on %s", address) // Listen for accepting connections from client using kcp protocol. if cfg.KCPBindPort > 0 { address := net.JoinHostPort(cfg.BindAddr, strconv.Itoa(cfg.KCPBindPort)) svr.kcpListener, err = netpkg.ListenKcp(address) if err != nil { return nil, fmt.Errorf("listen on kcp udp address %s error: %v", address, err) } log.Infof("frps kcp listen on udp %s", address) } if cfg.QUICBindPort > 0 { address := net.JoinHostPort(cfg.BindAddr, strconv.Itoa(cfg.QUICBindPort)) quicTLSCfg := tlsConfig.Clone() quicTLSCfg.NextProtos = []string{"frp"} svr.quicListener, err = quic.ListenAddr(address, quicTLSCfg, &quic.Config{ MaxIdleTimeout: time.Duration(cfg.Transport.QUIC.MaxIdleTimeout) * time.Second, MaxIncomingStreams: int64(cfg.Transport.QUIC.MaxIncomingStreams), KeepAlivePeriod: time.Duration(cfg.Transport.QUIC.KeepalivePeriod) * time.Second, }) if err != nil { return nil, fmt.Errorf("listen on quic udp address %s error: %v", address, err) } log.Infof("frps quic listen on %s", address) } if cfg.SSHTunnelGateway.BindPort > 0 { sshGateway, err := ssh.NewGateway(cfg.SSHTunnelGateway, cfg.BindAddr, svr.sshTunnelListener) if err != nil { return nil, fmt.Errorf("create ssh gateway error: %v", err) } svr.sshTunnelGateway = sshGateway log.Infof("frps sshTunnelGateway listen on port %d", cfg.SSHTunnelGateway.BindPort) } // Listen for accepting connections from client using websocket protocol. websocketPrefix := []byte("GET " + netpkg.FrpWebsocketPath) websocketLn := svr.muxer.Listen(0, uint32(len(websocketPrefix)), func(data []byte) bool { return bytes.Equal(data, websocketPrefix) }) svr.websocketListener = netpkg.NewWebsocketListener(websocketLn) // Create http vhost muxer. if cfg.VhostHTTPPort > 0 { rp := vhost.NewHTTPReverseProxy(vhost.HTTPReverseProxyOptions{ ResponseHeaderTimeoutS: cfg.VhostHTTPTimeout, }, svr.httpVhostRouter) svr.rc.HTTPReverseProxy = rp address := net.JoinHostPort(cfg.ProxyBindAddr, strconv.Itoa(cfg.VhostHTTPPort)) server := &http.Server{ Addr: address, Handler: rp, ReadHeaderTimeout: 60 * time.Second, } var l net.Listener if httpMuxOn { l = svr.muxer.ListenHTTP(1) } else { l, err = net.Listen("tcp", address) if err != nil { return nil, fmt.Errorf("create vhost http listener error, %v", err) } } go func() { _ = server.Serve(l) }() log.Infof("http service listen on %s", address) } // Create https vhost muxer. if cfg.VhostHTTPSPort > 0 { var l net.Listener if httpsMuxOn { l = svr.muxer.ListenHTTPS(1) } else { address := net.JoinHostPort(cfg.ProxyBindAddr, strconv.Itoa(cfg.VhostHTTPSPort)) l, err = net.Listen("tcp", address) if err != nil { return nil, fmt.Errorf("create server listener error, %v", err) } log.Infof("https service listen on %s", address) } svr.rc.VhostHTTPSMuxer, err = vhost.NewHTTPSMuxer(l, vhostReadWriteTimeout) if err != nil { return nil, fmt.Errorf("create vhost httpsMuxer error, %v", err) } // Init HTTPS group controller after HTTPSMuxer is created svr.rc.HTTPSGroupCtl = group.NewHTTPSGroupController(svr.rc.VhostHTTPSMuxer) } // frp tls listener svr.tlsListener = svr.muxer.Listen(2, 1, func(data []byte) bool { // tls first byte can be 0x16 only when vhost https port is not same with bind port return int(data[0]) == netpkg.FRPTLSHeadByte || int(data[0]) == 0x16 }) // Create nat hole controller. nc, err := nathole.NewController(time.Duration(cfg.NatHoleAnalysisDataReserveHours) * time.Hour) if err != nil { return nil, fmt.Errorf("create nat hole controller error, %v", err) } svr.rc.NatHoleController = nc return svr, nil } func (svr *Service) Run(ctx context.Context) { ctx, cancel := context.WithCancel(ctx) svr.ctx = ctx svr.cancel = cancel // run dashboard web server. if svr.webServer != nil { go func() { log.Infof("dashboard listen on %s", svr.webServer.Address()) if err := svr.webServer.Run(); err != nil { log.Warnf("dashboard server exit with error: %v", err) } }() } go svr.HandleListener(svr.sshTunnelListener, true) if svr.kcpListener != nil { go svr.HandleListener(svr.kcpListener, false) } if svr.quicListener != nil { go svr.HandleQUICListener(svr.quicListener) } go svr.HandleListener(svr.websocketListener, false) go svr.HandleListener(svr.tlsListener, false) if svr.rc.NatHoleController != nil { go svr.rc.NatHoleController.CleanWorker(svr.ctx) } if svr.sshTunnelGateway != nil { go svr.sshTunnelGateway.Run() } svr.HandleListener(svr.listener, false) <-svr.ctx.Done() // service context may not be canceled by svr.Close(), we should call it here to release resources if svr.listener != nil { svr.Close() } } func (svr *Service) Close() error { if svr.kcpListener != nil { svr.kcpListener.Close() } if svr.quicListener != nil { svr.quicListener.Close() } if svr.websocketListener != nil { svr.websocketListener.Close() } if svr.tlsListener != nil { svr.tlsListener.Close() } if svr.sshTunnelListener != nil { svr.sshTunnelListener.Close() } if svr.listener != nil { svr.listener.Close() } if svr.webServer != nil { svr.webServer.Close() } if svr.sshTunnelGateway != nil { svr.sshTunnelGateway.Close() } svr.rc.Close() svr.muxer.Close() svr.ctlManager.Close() if svr.cancel != nil { svr.cancel() } return nil } func (svr *Service) handleConnection(ctx context.Context, conn net.Conn, internal bool) { xl := xlog.FromContextSafe(ctx) var ( rawMsg msg.Message err error ) _ = conn.SetReadDeadline(time.Now().Add(connReadTimeout)) if rawMsg, err = msg.ReadMsg(conn); err != nil { log.Tracef("failed to read message: %v", err) conn.Close() return } _ = conn.SetReadDeadline(time.Time{}) switch m := rawMsg.(type) { case *msg.Login: // server plugin hook content := &plugin.LoginContent{ Login: *m, ClientAddress: conn.RemoteAddr().String(), } retContent, err := svr.pluginManager.Login(content) if err == nil { m = &retContent.Login err = svr.RegisterControl(conn, m, internal) } // If login failed, send error message there. // Otherwise send success message in control's work goroutine. if err != nil { xl.Warnf("register control error: %v", err) _ = msg.WriteMsg(conn, &msg.LoginResp{ Version: version.Full(), Error: util.GenerateResponseErrorString("register control error", err, lo.FromPtr(svr.cfg.DetailedErrorsToClient)), }) conn.Close() } case *msg.NewWorkConn: if err := svr.RegisterWorkConn(conn, m); err != nil { conn.Close() } case *msg.NewVisitorConn: if err = svr.RegisterVisitorConn(conn, m); err != nil { xl.Warnf("register visitor conn error: %v", err) _ = msg.WriteMsg(conn, &msg.NewVisitorConnResp{ ProxyName: m.ProxyName, Error: util.GenerateResponseErrorString("register visitor conn error", err, lo.FromPtr(svr.cfg.DetailedErrorsToClient)), }) conn.Close() } else { _ = msg.WriteMsg(conn, &msg.NewVisitorConnResp{ ProxyName: m.ProxyName, Error: "", }) } default: log.Warnf("error message type for the new connection [%s]", conn.RemoteAddr().String()) conn.Close() } } // HandleListener accepts connections from client and call handleConnection to handle them. // If internal is true, it means that this listener is used for internal communication like ssh tunnel gateway. // TODO(fatedier): Pass some parameters of listener/connection through context to avoid passing too many parameters. func (svr *Service) HandleListener(l net.Listener, internal bool) { // Listen for incoming connections from client. for { c, err := l.Accept() if err != nil { log.Warnf("listener for incoming connections from client closed") return } // inject xlog object into net.Conn context xl := xlog.New() ctx := context.Background() c = netpkg.NewContextConn(xlog.NewContext(ctx, xl), c) if !internal { log.Tracef("start check TLS connection...") originConn := c forceTLS := svr.cfg.Transport.TLS.Force var isTLS, custom bool c, isTLS, custom, err = netpkg.CheckAndEnableTLSServerConnWithTimeout(c, svr.tlsConfig, forceTLS, connReadTimeout) if err != nil { log.Warnf("checkAndEnableTLSServerConnWithTimeout error: %v", err) originConn.Close() continue } log.Tracef("check TLS connection success, isTLS: %v custom: %v internal: %v", isTLS, custom, internal) } // Start a new goroutine to handle connection. go func(ctx context.Context, frpConn net.Conn) { if lo.FromPtr(svr.cfg.Transport.TCPMux) && !internal { fmuxCfg := fmux.DefaultConfig() fmuxCfg.KeepAliveInterval = time.Duration(svr.cfg.Transport.TCPMuxKeepaliveInterval) * time.Second // Use trace level for yamux logs fmuxCfg.LogOutput = xlog.NewTraceWriter(xlog.FromContextSafe(ctx)) fmuxCfg.MaxStreamWindowSize = 6 * 1024 * 1024 session, err := fmux.Server(frpConn, fmuxCfg) if err != nil { log.Warnf("failed to create mux connection: %v", err) frpConn.Close() return } for { stream, err := session.AcceptStream() if err != nil { log.Debugf("accept new mux stream error: %v", err) session.Close() return } go svr.handleConnection(ctx, stream, internal) } } else { svr.handleConnection(ctx, frpConn, internal) } }(ctx, c) } } func (svr *Service) HandleQUICListener(l *quic.Listener) { // Listen for incoming connections from client. for { c, err := l.Accept(context.Background()) if err != nil { log.Warnf("quic listener for incoming connections from client closed") return } // Start a new goroutine to handle connection. go func(ctx context.Context, frpConn *quic.Conn) { for { stream, err := frpConn.AcceptStream(context.Background()) if err != nil { log.Debugf("accept new quic mux stream error: %v", err) _ = frpConn.CloseWithError(0, "") return } go svr.handleConnection(ctx, netpkg.QuicStreamToNetConn(stream, frpConn), false) } }(context.Background(), c) } } func (svr *Service) RegisterControl(ctlConn net.Conn, loginMsg *msg.Login, internal bool) error { // If client's RunID is empty, it's a new client, we just create a new controller. // Otherwise, we check if there is one controller has the same run id. If so, we release previous controller and start new one. var err error if loginMsg.RunID == "" { loginMsg.RunID, err = util.RandID() if err != nil { return err } } ctx := netpkg.NewContextFromConn(ctlConn) xl := xlog.FromContextSafe(ctx) xl.AppendPrefix(loginMsg.RunID) ctx = xlog.NewContext(ctx, xl) xl.Infof("client login info: ip [%s] version [%s] hostname [%s] os [%s] arch [%s]", ctlConn.RemoteAddr().String(), loginMsg.Version, loginMsg.Hostname, loginMsg.Os, loginMsg.Arch) // Check auth. authVerifier := svr.auth.Verifier if internal && loginMsg.ClientSpec.AlwaysAuthPass { authVerifier = auth.AlwaysPassVerifier } if err := authVerifier.VerifyLogin(loginMsg); err != nil { return err } ctl, err := NewControl(ctx, &SessionContext{ RC: svr.rc, PxyManager: svr.pxyManager, PluginManager: svr.pluginManager, AuthVerifier: authVerifier, EncryptionKey: svr.auth.EncryptionKey(), Conn: ctlConn, ConnEncrypted: !internal, LoginMsg: loginMsg, ServerCfg: svr.cfg, ClientRegistry: svr.clientRegistry, }) if err != nil { xl.Warnf("create new controller error: %v", err) // don't return detailed errors to client return fmt.Errorf("unexpected error when creating new controller") } if oldCtl := svr.ctlManager.Add(loginMsg.RunID, ctl); oldCtl != nil { oldCtl.WaitClosed() } remoteAddr := ctlConn.RemoteAddr().String() if host, _, err := net.SplitHostPort(remoteAddr); err == nil { remoteAddr = host } _, conflict := svr.clientRegistry.Register(loginMsg.User, loginMsg.ClientID, loginMsg.RunID, loginMsg.Hostname, loginMsg.Version, remoteAddr) if conflict { svr.ctlManager.Del(loginMsg.RunID, ctl) ctl.Close() return fmt.Errorf("client_id [%s] for user [%s] is already online", loginMsg.ClientID, loginMsg.User) } ctl.Start() // for statistics metrics.Server.NewClient() go func() { // block until control closed ctl.WaitClosed() svr.ctlManager.Del(loginMsg.RunID, ctl) }() return nil } // RegisterWorkConn register a new work connection to control and proxies need it. func (svr *Service) RegisterWorkConn(workConn net.Conn, newMsg *msg.NewWorkConn) error { xl := netpkg.NewLogFromConn(workConn) ctl, exist := svr.ctlManager.GetByID(newMsg.RunID) if !exist { xl.Warnf("no client control found for run id [%s]", newMsg.RunID) return fmt.Errorf("no client control found for run id [%s]", newMsg.RunID) } // server plugin hook content := &plugin.NewWorkConnContent{ User: plugin.UserInfo{ User: ctl.sessionCtx.LoginMsg.User, Metas: ctl.sessionCtx.LoginMsg.Metas, RunID: ctl.sessionCtx.LoginMsg.RunID, }, NewWorkConn: *newMsg, } retContent, err := svr.pluginManager.NewWorkConn(content) if err == nil { newMsg = &retContent.NewWorkConn // Check auth. err = ctl.sessionCtx.AuthVerifier.VerifyNewWorkConn(newMsg) } if err != nil { xl.Warnf("invalid NewWorkConn with run id [%s]", newMsg.RunID) _ = msg.WriteMsg(workConn, &msg.StartWorkConn{ Error: util.GenerateResponseErrorString("invalid NewWorkConn", err, lo.FromPtr(svr.cfg.DetailedErrorsToClient)), }) return fmt.Errorf("invalid NewWorkConn with run id [%s]", newMsg.RunID) } return ctl.RegisterWorkConn(workConn) } func (svr *Service) RegisterVisitorConn(visitorConn net.Conn, newMsg *msg.NewVisitorConn) error { visitorUser := "" // TODO(deprecation): Compatible with old versions, can be without runID, user is empty. In later versions, it will be mandatory to include runID. // If runID is required, it is not compatible with versions prior to v0.50.0. if newMsg.RunID != "" { ctl, exist := svr.ctlManager.GetByID(newMsg.RunID) if !exist { return fmt.Errorf("no client control found for run id [%s]", newMsg.RunID) } visitorUser = ctl.sessionCtx.LoginMsg.User } return svr.rc.VisitorManager.NewConn(newMsg.ProxyName, visitorConn, newMsg.Timestamp, newMsg.SignKey, newMsg.UseEncryption, newMsg.UseCompression, visitorUser) } ================================================ FILE: server/visitor/visitor.go ================================================ // Copyright 2019 fatedier, fatedier@gmail.com // // Licensed under the Apache License, Version 2.0 (the "License"); // you may not use this file except in compliance with the License. // You may obtain a copy of the License at // // http://www.apache.org/licenses/LICENSE-2.0 // // Unless required by applicable law or agreed to in writing, software // distributed under the License is distributed on an "AS IS" BASIS, // WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. // See the License for the specific language governing permissions and // limitations under the License. package visitor import ( "fmt" "io" "net" "slices" "sync" libio "github.com/fatedier/golib/io" netpkg "github.com/fatedier/frp/pkg/util/net" "github.com/fatedier/frp/pkg/util/util" ) type listenerBundle struct { l *netpkg.InternalListener sk string allowUsers []string } // Manager for visitor listeners. type Manager struct { listeners map[string]*listenerBundle mu sync.RWMutex } func NewManager() *Manager { return &Manager{ listeners: make(map[string]*listenerBundle), } } func (vm *Manager) Listen(name string, sk string, allowUsers []string) (*netpkg.InternalListener, error) { vm.mu.Lock() defer vm.mu.Unlock() if _, ok := vm.listeners[name]; ok { return nil, fmt.Errorf("custom listener for [%s] is repeated", name) } l := netpkg.NewInternalListener() vm.listeners[name] = &listenerBundle{ l: l, sk: sk, allowUsers: allowUsers, } return l, nil } func (vm *Manager) NewConn(name string, conn net.Conn, timestamp int64, signKey string, useEncryption bool, useCompression bool, visitorUser string, ) (err error) { vm.mu.RLock() defer vm.mu.RUnlock() if l, ok := vm.listeners[name]; ok { if util.GetAuthKey(l.sk, timestamp) != signKey { err = fmt.Errorf("visitor connection of [%s] auth failed", name) return } if !slices.Contains(l.allowUsers, visitorUser) && !slices.Contains(l.allowUsers, "*") { err = fmt.Errorf("visitor connection of [%s] user [%s] not allowed", name, visitorUser) return } var rwc io.ReadWriteCloser = conn if useEncryption { if rwc, err = libio.WithEncryption(rwc, []byte(l.sk)); err != nil { err = fmt.Errorf("create encryption connection failed: %v", err) return } } if useCompression { rwc = libio.WithCompression(rwc) } err = l.l.PutConn(netpkg.WrapReadWriteCloserToConn(rwc, conn)) } else { err = fmt.Errorf("custom listener for [%s] doesn't exist", name) return } return } func (vm *Manager) CloseListener(name string) { vm.mu.Lock() defer vm.mu.Unlock() delete(vm.listeners, name) } ================================================ FILE: test/e2e/e2e.go ================================================ package e2e import ( "testing" "github.com/onsi/ginkgo/v2" "github.com/onsi/gomega" "github.com/fatedier/frp/pkg/util/log" "github.com/fatedier/frp/test/e2e/framework" ) var _ = ginkgo.SynchronizedBeforeSuite(func() []byte { setupSuite() return nil }, func(data []byte) { // Run on all Ginkgo nodes setupSuitePerGinkgoNode() }) var _ = ginkgo.SynchronizedAfterSuite(func() { CleanupSuite() }, func() { AfterSuiteActions() }) // RunE2ETests checks configuration parameters (specified through flags) and then runs // E2E tests using the Ginkgo runner. // If a "report directory" is specified, one or more JUnit test reports will be // generated in this directory, and cluster logs will also be saved. // This function is called on each Ginkgo node in parallel mode. func RunE2ETests(t *testing.T) { gomega.RegisterFailHandler(framework.Fail) suiteConfig, reporterConfig := ginkgo.GinkgoConfiguration() // Turn on EmitSpecProgress to get spec progress (especially on interrupt) suiteConfig.EmitSpecProgress = true // Randomize specs as well as suites suiteConfig.RandomizeAllSpecs = true log.Infof("starting e2e run %q on Ginkgo node %d of total %d", framework.RunID, suiteConfig.ParallelProcess, suiteConfig.ParallelTotal) ginkgo.RunSpecs(t, "frp e2e suite", suiteConfig, reporterConfig) } // setupSuite is the boilerplate that can be used to setup ginkgo test suites, on the SynchronizedBeforeSuite step. // There are certain operations we only want to run once per overall test invocation // (such as deleting old namespaces, or verifying that all system pods are running. // Because of the way Ginkgo runs tests in parallel, we must use SynchronizedBeforeSuite // to ensure that these operations only run on the first parallel Ginkgo node. // // This function takes two parameters: one function which runs on only the first Ginkgo node, // returning an opaque byte array, and then a second function which runs on all Ginkgo nodes, // accepting the byte array. func setupSuite() { // Run only on Ginkgo node 1 } // setupSuitePerGinkgoNode is the boilerplate that can be used to setup ginkgo test suites, on the SynchronizedBeforeSuite step. // There are certain operations we only want to run once per overall test invocation on each Ginkgo node // such as making some global variables accessible to all parallel executions // Because of the way Ginkgo runs tests in parallel, we must use SynchronizedBeforeSuite // Ref: https://onsi.github.io/ginkgo/#parallel-specs func setupSuitePerGinkgoNode() { // config.GinkgoConfig.ParallelNode } ================================================ FILE: test/e2e/e2e_test.go ================================================ package e2e import ( "flag" "fmt" "os" "testing" _ "github.com/onsi/ginkgo/v2" "github.com/fatedier/frp/pkg/util/log" // test source "github.com/fatedier/frp/test/e2e/framework" _ "github.com/fatedier/frp/test/e2e/legacy/basic" _ "github.com/fatedier/frp/test/e2e/legacy/features" _ "github.com/fatedier/frp/test/e2e/legacy/plugin" _ "github.com/fatedier/frp/test/e2e/v1/basic" _ "github.com/fatedier/frp/test/e2e/v1/features" _ "github.com/fatedier/frp/test/e2e/v1/plugin" ) // handleFlags sets up all flags and parses the command line. func handleFlags() { framework.RegisterCommonFlags(flag.CommandLine) flag.Parse() } func TestMain(m *testing.M) { // Register test flags, then parse flags. handleFlags() if err := framework.ValidateTestContext(&framework.TestContext); err != nil { fmt.Println(err) os.Exit(1) } log.InitLogger("console", framework.TestContext.LogLevel, 0, true) os.Exit(m.Run()) } func TestE2E(t *testing.T) { RunE2ETests(t) } ================================================ FILE: test/e2e/examples.go ================================================ package e2e import ( "fmt" "github.com/onsi/ginkgo/v2" "github.com/fatedier/frp/test/e2e/framework" "github.com/fatedier/frp/test/e2e/framework/consts" ) var _ = ginkgo.Describe("[Feature: Example]", func() { f := framework.NewDefaultFramework() ginkgo.Describe("TCP", func() { ginkgo.It("Expose a TCP echo server", func() { serverConf := consts.DefaultServerConfig clientConf := consts.DefaultClientConfig remotePort := f.AllocPort() clientConf += fmt.Sprintf(` [[proxies]] name = "tcp" type = "tcp" localPort = {{ .%s }} remotePort = %d `, framework.TCPEchoServerPort, remotePort) f.RunProcesses(serverConf, []string{clientConf}) framework.NewRequestExpect(f).Port(remotePort).Ensure() }) }) }) ================================================ FILE: test/e2e/framework/cleanup.go ================================================ package framework import ( "sync" ) // CleanupActionHandle is an integer pointer type for handling cleanup action type CleanupActionHandle *int type cleanupFuncHandle struct { actionHandle CleanupActionHandle actionHook func() } var ( cleanupActionsLock sync.Mutex cleanupHookList = []cleanupFuncHandle{} ) // AddCleanupAction installs a function that will be called in the event of the // whole test being terminated. This allows arbitrary pieces of the overall // test to hook into SynchronizedAfterSuite(). // The hooks are called in last-in-first-out order. func AddCleanupAction(fn func()) CleanupActionHandle { p := CleanupActionHandle(new(int)) cleanupActionsLock.Lock() defer cleanupActionsLock.Unlock() c := cleanupFuncHandle{actionHandle: p, actionHook: fn} cleanupHookList = append([]cleanupFuncHandle{c}, cleanupHookList...) return p } // RemoveCleanupAction removes a function that was installed by // AddCleanupAction. func RemoveCleanupAction(p CleanupActionHandle) { cleanupActionsLock.Lock() defer cleanupActionsLock.Unlock() for i, item := range cleanupHookList { if item.actionHandle == p { cleanupHookList = append(cleanupHookList[:i], cleanupHookList[i+1:]...) break } } } // RunCleanupActions runs all functions installed by AddCleanupAction. It does // not remove them (see RemoveCleanupAction) but it does run unlocked, so they // may remove themselves. func RunCleanupActions() { list := []func(){} func() { cleanupActionsLock.Lock() defer cleanupActionsLock.Unlock() for _, p := range cleanupHookList { list = append(list, p.actionHook) } }() // Run unlocked. for _, fn := range list { fn() } } ================================================ FILE: test/e2e/framework/client.go ================================================ package framework import ( clientsdk "github.com/fatedier/frp/pkg/sdk/client" ) func (f *Framework) APIClientForFrpc(port int) *clientsdk.Client { return clientsdk.New("127.0.0.1", port) } ================================================ FILE: test/e2e/framework/consts/consts.go ================================================ package consts import ( "fmt" "time" "github.com/fatedier/frp/test/e2e/pkg/port" ) const ( TestString = "frp is a fast reverse proxy to help you expose a local server behind a NAT or firewall to the internet." DefaultTimeout = 2 * time.Second ) var ( PortServerName string PortClientAdmin string DefaultServerConfig = ` bindPort = {{ .%s }} log.level = "trace" ` DefaultClientConfig = ` serverAddr = "127.0.0.1" serverPort = {{ .%s }} loginFailExit = false log.level = "trace" ` LegacyDefaultServerConfig = ` [common] bind_port = {{ .%s }} log_level = trace ` LegacyDefaultClientConfig = ` [common] server_addr = 127.0.0.1 server_port = {{ .%s }} login_fail_exit = false log_level = trace ` ) func init() { PortServerName = port.GenName("Server") PortClientAdmin = port.GenName("ClientAdmin") LegacyDefaultServerConfig = fmt.Sprintf(LegacyDefaultServerConfig, port.GenName("Server")) LegacyDefaultClientConfig = fmt.Sprintf(LegacyDefaultClientConfig, port.GenName("Server")) DefaultServerConfig = fmt.Sprintf(DefaultServerConfig, port.GenName("Server")) DefaultClientConfig = fmt.Sprintf(DefaultClientConfig, port.GenName("Server")) } ================================================ FILE: test/e2e/framework/expect.go ================================================ package framework import ( "github.com/onsi/gomega" ) // ExpectEqual expects the specified two are the same, otherwise an exception raises func ExpectEqual(actual any, extra any, explain ...any) { gomega.ExpectWithOffset(1, actual).To(gomega.Equal(extra), explain...) } // ExpectEqualValues expects the specified two are the same, it not strict about type func ExpectEqualValues(actual any, extra any, explain ...any) { gomega.ExpectWithOffset(1, actual).To(gomega.BeEquivalentTo(extra), explain...) } func ExpectEqualValuesWithOffset(offset int, actual any, extra any, explain ...any) { gomega.ExpectWithOffset(1+offset, actual).To(gomega.BeEquivalentTo(extra), explain...) } // ExpectNotEqual expects the specified two are not the same, otherwise an exception raises func ExpectNotEqual(actual any, extra any, explain ...any) { gomega.ExpectWithOffset(1, actual).NotTo(gomega.Equal(extra), explain...) } // ExpectError expects an error happens, otherwise an exception raises func ExpectError(err error, explain ...any) { gomega.ExpectWithOffset(1, err).To(gomega.HaveOccurred(), explain...) } func ExpectErrorWithOffset(offset int, err error, explain ...any) { gomega.ExpectWithOffset(1+offset, err).To(gomega.HaveOccurred(), explain...) } // ExpectNoError checks if "err" is set, and if so, fails assertion while logging the error. func ExpectNoError(err error, explain ...any) { ExpectNoErrorWithOffset(1, err, explain...) } // ExpectNoErrorWithOffset checks if "err" is set, and if so, fails assertion while logging the error at "offset" levels above its caller // (for example, for call chain f -> g -> ExpectNoErrorWithOffset(1, ...) error would be logged for "f"). func ExpectNoErrorWithOffset(offset int, err error, explain ...any) { gomega.ExpectWithOffset(1+offset, err).NotTo(gomega.HaveOccurred(), explain...) } func ExpectContainSubstring(actual, substr string, explain ...any) { gomega.ExpectWithOffset(1, actual).To(gomega.ContainSubstring(substr), explain...) } // ExpectConsistOf expects actual contains precisely the extra elements. The ordering of the elements does not matter. func ExpectConsistOf(actual any, extra any, explain ...any) { gomega.ExpectWithOffset(1, actual).To(gomega.ConsistOf(extra), explain...) } func ExpectContainElements(actual any, extra any, explain ...any) { gomega.ExpectWithOffset(1, actual).To(gomega.ContainElements(extra), explain...) } func ExpectNotContainElements(actual any, extra any, explain ...any) { gomega.ExpectWithOffset(1, actual).NotTo(gomega.ContainElements(extra), explain...) } // ExpectHaveKey expects the actual map has the key in the keyset func ExpectHaveKey(actual any, key any, explain ...any) { gomega.ExpectWithOffset(1, actual).To(gomega.HaveKey(key), explain...) } // ExpectEmpty expects actual is empty func ExpectEmpty(actual any, explain ...any) { gomega.ExpectWithOffset(1, actual).To(gomega.BeEmpty(), explain...) } func ExpectTrue(actual any, explain ...any) { gomega.ExpectWithOffset(1, actual).Should(gomega.BeTrue(), explain...) } func ExpectTrueWithOffset(offset int, actual any, explain ...any) { gomega.ExpectWithOffset(1+offset, actual).Should(gomega.BeTrue(), explain...) } ================================================ FILE: test/e2e/framework/framework.go ================================================ package framework import ( "bytes" "fmt" "os" "path/filepath" "regexp" "strings" "text/template" "github.com/onsi/ginkgo/v2" "github.com/fatedier/frp/test/e2e/mock/server" "github.com/fatedier/frp/test/e2e/pkg/port" "github.com/fatedier/frp/test/e2e/pkg/process" ) type Options struct { TotalParallelNode int CurrentNodeIndex int FromPortIndex int ToPortIndex int } type Framework struct { TempDirectory string // ports used in this framework indexed by port name. usedPorts map[string]int // record ports allocated by this framework and release them after each test allocatedPorts []int // portAllocator to alloc port for this test case. portAllocator *port.Allocator // Multiple default mock servers used for e2e testing. mockServers *MockServers // To make sure that this framework cleans up after itself, no matter what, // we install a Cleanup action before each test and clear it after. If we // should abort, the AfterSuite hook should run all Cleanup actions. cleanupHandle CleanupActionHandle // beforeEachStarted indicates that BeforeEach has started beforeEachStarted bool serverConfPaths []string serverProcesses []*process.Process clientConfPaths []string clientProcesses []*process.Process // Manual registered mock servers. servers []server.Server // used to generate unique config file name. configFileIndex int64 // envs used to start processes, the form is `key=value`. osEnvs []string } func NewDefaultFramework() *Framework { suiteConfig, _ := ginkgo.GinkgoConfiguration() options := Options{ TotalParallelNode: suiteConfig.ParallelTotal, CurrentNodeIndex: suiteConfig.ParallelProcess, FromPortIndex: 10000, ToPortIndex: 30000, } return NewFramework(options) } func NewFramework(opt Options) *Framework { f := &Framework{ portAllocator: port.NewAllocator(opt.FromPortIndex, opt.ToPortIndex, opt.TotalParallelNode, opt.CurrentNodeIndex-1), usedPorts: make(map[string]int), } ginkgo.BeforeEach(f.BeforeEach) ginkgo.AfterEach(f.AfterEach) return f } // BeforeEach create a temp directory. func (f *Framework) BeforeEach() { f.beforeEachStarted = true f.cleanupHandle = AddCleanupAction(f.AfterEach) dir, err := os.MkdirTemp(os.TempDir(), "frp-e2e-test-*") ExpectNoError(err) f.TempDirectory = dir f.mockServers = NewMockServers(f.portAllocator) if err := f.mockServers.Run(); err != nil { Failf("%v", err) } params := f.mockServers.GetTemplateParams() for k, v := range params { switch t := v.(type) { case int: f.usedPorts[k] = t default: } } } func (f *Framework) AfterEach() { if !f.beforeEachStarted { return } RemoveCleanupAction(f.cleanupHandle) // stop processor for _, p := range f.serverProcesses { _ = p.Stop() if TestContext.Debug || ginkgo.CurrentSpecReport().Failed() { fmt.Println(p.ErrorOutput()) fmt.Println(p.StdOutput()) } } for _, p := range f.clientProcesses { _ = p.Stop() if TestContext.Debug || ginkgo.CurrentSpecReport().Failed() { fmt.Println(p.ErrorOutput()) fmt.Println(p.StdOutput()) } } f.serverProcesses = nil f.clientProcesses = nil // close default mock servers f.mockServers.Close() // close manual registered mock servers for _, s := range f.servers { s.Close() } // clean directory os.RemoveAll(f.TempDirectory) f.TempDirectory = "" f.serverConfPaths = []string{} f.clientConfPaths = []string{} // release used ports for _, port := range f.usedPorts { f.portAllocator.Release(port) } f.usedPorts = make(map[string]int) // release allocated ports for _, port := range f.allocatedPorts { f.portAllocator.Release(port) } f.allocatedPorts = make([]int, 0) // clear os envs f.osEnvs = make([]string, 0) } var portRegex = regexp.MustCompile(`{{ \.Port.*? }}`) // RenderPortsTemplate render templates with ports. // // Local: {{ .Port1 }} // Target: {{ .Port2 }} // // return rendered content and all allocated ports. func (f *Framework) genPortsFromTemplates(templates []string) (ports map[string]int, err error) { ports = make(map[string]int) for _, t := range templates { arrs := portRegex.FindAllString(t, -1) for _, str := range arrs { str = strings.TrimPrefix(str, "{{ .") str = strings.TrimSuffix(str, " }}") str = strings.TrimSpace(str) ports[str] = 0 } } defer func() { if err != nil { for _, port := range ports { f.portAllocator.Release(port) } } }() for name := range ports { port := f.portAllocator.GetByName(name) if port <= 0 { return nil, fmt.Errorf("can't allocate port") } ports[name] = port } return } // RenderTemplates alloc all ports for port names placeholder. func (f *Framework) RenderTemplates(templates []string) (outs []string, ports map[string]int, err error) { ports, err = f.genPortsFromTemplates(templates) if err != nil { return } params := f.mockServers.GetTemplateParams() for name, port := range ports { params[name] = port } for name, port := range f.usedPorts { params[name] = port } for _, t := range templates { tmpl, err := template.New("frp-e2e").Parse(t) if err != nil { return nil, nil, err } buffer := bytes.NewBuffer(nil) if err = tmpl.Execute(buffer, params); err != nil { return nil, nil, err } outs = append(outs, buffer.String()) } return } func (f *Framework) PortByName(name string) int { return f.usedPorts[name] } func (f *Framework) AllocPort() int { port := f.portAllocator.Get() ExpectTrue(port > 0, "alloc port failed") f.allocatedPorts = append(f.allocatedPorts, port) return port } func (f *Framework) ReleasePort(port int) { f.portAllocator.Release(port) } func (f *Framework) RunServer(portName string, s server.Server) { f.servers = append(f.servers, s) if s.BindPort() > 0 && portName != "" { f.usedPorts[portName] = s.BindPort() } err := s.Run() ExpectNoError(err, "RunServer: with PortName %s", portName) } func (f *Framework) SetEnvs(envs []string) { f.osEnvs = envs } func (f *Framework) WriteTempFile(name string, content string) string { filePath := filepath.Join(f.TempDirectory, name) err := os.WriteFile(filePath, []byte(content), 0o600) ExpectNoError(err) return filePath } ================================================ FILE: test/e2e/framework/log.go ================================================ package framework import ( "fmt" "time" "github.com/onsi/ginkgo/v2" ) func nowStamp() string { return time.Now().Format(time.StampMilli) } func log(level string, format string, args ...any) { fmt.Fprintf(ginkgo.GinkgoWriter, nowStamp()+": "+level+": "+format+"\n", args...) } // Logf logs the info. func Logf(format string, args ...any) { log("INFO", format, args...) } // Failf logs the fail info, including a stack trace starts with its direct caller // (for example, for call chain f -> g -> Failf("foo", ...) error would be logged for "g"). func Failf(format string, args ...any) { msg := fmt.Sprintf(format, args...) skip := 1 ginkgo.Fail(msg, skip) panic("unreachable") } // Fail is an alias for ginkgo.Fail. var Fail = ginkgo.Fail ================================================ FILE: test/e2e/framework/mockservers.go ================================================ package framework import ( "fmt" "net/http" "os" "github.com/fatedier/frp/test/e2e/framework/consts" "github.com/fatedier/frp/test/e2e/mock/server" "github.com/fatedier/frp/test/e2e/mock/server/httpserver" "github.com/fatedier/frp/test/e2e/mock/server/streamserver" "github.com/fatedier/frp/test/e2e/pkg/port" ) const ( TCPEchoServerPort = "TCPEchoServerPort" UDPEchoServerPort = "UDPEchoServerPort" UDSEchoServerAddr = "UDSEchoServerAddr" HTTPSimpleServerPort = "HTTPSimpleServerPort" ) type MockServers struct { tcpEchoServer server.Server udpEchoServer server.Server udsEchoServer server.Server httpSimpleServer server.Server } func NewMockServers(portAllocator *port.Allocator) *MockServers { s := &MockServers{} tcpPort := portAllocator.Get() udpPort := portAllocator.Get() httpPort := portAllocator.Get() s.tcpEchoServer = streamserver.New(streamserver.TCP, streamserver.WithBindPort(tcpPort)) s.udpEchoServer = streamserver.New(streamserver.UDP, streamserver.WithBindPort(udpPort)) s.httpSimpleServer = httpserver.New(httpserver.WithBindPort(httpPort), httpserver.WithHandler(http.HandlerFunc(func(w http.ResponseWriter, req *http.Request) { _, _ = w.Write([]byte(consts.TestString)) })), ) udsIndex := portAllocator.Get() udsAddr := fmt.Sprintf("%s/frp_echo_server_%d.sock", os.TempDir(), udsIndex) os.Remove(udsAddr) s.udsEchoServer = streamserver.New(streamserver.Unix, streamserver.WithBindAddr(udsAddr)) return s } func (m *MockServers) Run() error { if err := m.tcpEchoServer.Run(); err != nil { return err } if err := m.udpEchoServer.Run(); err != nil { return err } if err := m.udsEchoServer.Run(); err != nil { return err } return m.httpSimpleServer.Run() } func (m *MockServers) Close() { m.tcpEchoServer.Close() m.udpEchoServer.Close() m.udsEchoServer.Close() m.httpSimpleServer.Close() os.Remove(m.udsEchoServer.BindAddr()) } func (m *MockServers) GetTemplateParams() map[string]any { ret := make(map[string]any) ret[TCPEchoServerPort] = m.tcpEchoServer.BindPort() ret[UDPEchoServerPort] = m.udpEchoServer.BindPort() ret[UDSEchoServerAddr] = m.udsEchoServer.BindAddr() ret[HTTPSimpleServerPort] = m.httpSimpleServer.BindPort() return ret } func (m *MockServers) GetParam(key string) any { params := m.GetTemplateParams() if v, ok := params[key]; ok { return v } return nil } ================================================ FILE: test/e2e/framework/process.go ================================================ package framework import ( "fmt" "maps" "net" "os" "path/filepath" "strconv" "time" "github.com/fatedier/frp/pkg/config" flog "github.com/fatedier/frp/pkg/util/log" "github.com/fatedier/frp/test/e2e/framework/consts" "github.com/fatedier/frp/test/e2e/pkg/process" ) // RunProcesses starts one frps and zero or more frpc processes from templates. func (f *Framework) RunProcesses(serverTemplate string, clientTemplates []string) (*process.Process, []*process.Process) { templates := append([]string{serverTemplate}, clientTemplates...) outs, ports, err := f.RenderTemplates(templates) ExpectNoError(err) maps.Copy(f.usedPorts, ports) // Start frps. serverPath := filepath.Join(f.TempDirectory, "frp-e2e-server-0") err = os.WriteFile(serverPath, []byte(outs[0]), 0o600) ExpectNoError(err) if TestContext.Debug { flog.Debugf("[%s] %s", serverPath, outs[0]) } serverProcess := process.NewWithEnvs(TestContext.FRPServerPath, []string{"-c", serverPath}, f.osEnvs) f.serverConfPaths = append(f.serverConfPaths, serverPath) f.serverProcesses = append(f.serverProcesses, serverProcess) err = serverProcess.Start() ExpectNoError(err) if port, ok := ports[consts.PortServerName]; ok { ExpectNoError(WaitForTCPReady(net.JoinHostPort("127.0.0.1", strconv.Itoa(port)), 5*time.Second)) } else { time.Sleep(2 * time.Second) } // Start frpc(s). clientProcesses := make([]*process.Process, 0, len(clientTemplates)) for i := range clientTemplates { path := filepath.Join(f.TempDirectory, fmt.Sprintf("frp-e2e-client-%d", i)) err = os.WriteFile(path, []byte(outs[1+i]), 0o600) ExpectNoError(err) if TestContext.Debug { flog.Debugf("[%s] %s", path, outs[1+i]) } p := process.NewWithEnvs(TestContext.FRPClientPath, []string{"-c", path}, f.osEnvs) f.clientConfPaths = append(f.clientConfPaths, path) f.clientProcesses = append(f.clientProcesses, p) clientProcesses = append(clientProcesses, p) err = p.Start() ExpectNoError(err) } // Wait for each client's proxies to register with frps. // If any client has no proxies (e.g. visitor-only), fall back to sleep // for the remaining time since visitors have no deterministic readiness signal. allConfirmed := len(clientProcesses) > 0 start := time.Now() for i, p := range clientProcesses { configPath := f.clientConfPaths[len(f.clientConfPaths)-len(clientProcesses)+i] if !waitForClientProxyReady(configPath, p, 5*time.Second) { allConfirmed = false } } if len(clientProcesses) > 0 && !allConfirmed { remaining := 1500*time.Millisecond - time.Since(start) if remaining > 0 { time.Sleep(remaining) } } return serverProcess, clientProcesses } func (f *Framework) RunFrps(args ...string) (*process.Process, string, error) { p := process.NewWithEnvs(TestContext.FRPServerPath, args, f.osEnvs) f.serverProcesses = append(f.serverProcesses, p) err := p.Start() if err != nil { return p, p.Output(), err } select { case <-p.Done(): case <-time.After(2 * time.Second): } return p, p.Output(), nil } func (f *Framework) RunFrpc(args ...string) (*process.Process, string, error) { p := process.NewWithEnvs(TestContext.FRPClientPath, args, f.osEnvs) f.clientProcesses = append(f.clientProcesses, p) err := p.Start() if err != nil { return p, p.Output(), err } select { case <-p.Done(): case <-time.After(1500 * time.Millisecond): } return p, p.Output(), nil } func (f *Framework) GenerateConfigFile(content string) string { f.configFileIndex++ path := filepath.Join(f.TempDirectory, fmt.Sprintf("frp-e2e-config-%d", f.configFileIndex)) err := os.WriteFile(path, []byte(content), 0o600) ExpectNoError(err) return path } // waitForClientProxyReady parses the client config to extract proxy names, // then waits for each proxy's "start proxy success" log in the process output. // Returns true only if proxies were expected and all registered successfully. func waitForClientProxyReady(configPath string, p *process.Process, timeout time.Duration) bool { _, proxyCfgs, _, _, err := config.LoadClientConfig(configPath, false) if err != nil || len(proxyCfgs) == 0 { return false } // Use a single deadline so the total wait across all proxies does not exceed timeout. deadline := time.Now().Add(timeout) for _, cfg := range proxyCfgs { remaining := time.Until(deadline) if remaining <= 0 { return false } name := cfg.GetBaseConfig().Name pattern := fmt.Sprintf("[%s] start proxy success", name) if err := p.WaitForOutput(pattern, 1, remaining); err != nil { return false } } return true } // WaitForTCPUnreachable polls a TCP address until a connection fails or timeout. func WaitForTCPUnreachable(addr string, interval, timeout time.Duration) error { if interval <= 0 { return fmt.Errorf("invalid interval for TCP unreachable on %s: interval must be positive", addr) } if timeout <= 0 { return fmt.Errorf("invalid timeout for TCP unreachable on %s: timeout must be positive", addr) } deadline := time.Now().Add(timeout) for { remaining := time.Until(deadline) if remaining <= 0 { return fmt.Errorf("timeout waiting for TCP unreachable on %s", addr) } dialTimeout := min(interval, remaining) conn, err := net.DialTimeout("tcp", addr, dialTimeout) if err != nil { return nil } conn.Close() time.Sleep(min(interval, time.Until(deadline))) } } // WaitForTCPReady polls a TCP address until a connection succeeds or timeout. func WaitForTCPReady(addr string, timeout time.Duration) error { if timeout <= 0 { return fmt.Errorf("invalid timeout for TCP readiness on %s: timeout must be positive", addr) } deadline := time.Now().Add(timeout) var lastErr error for time.Now().Before(deadline) { conn, err := net.DialTimeout("tcp", addr, 100*time.Millisecond) if err == nil { conn.Close() return nil } lastErr = err time.Sleep(50 * time.Millisecond) } if lastErr == nil { return fmt.Errorf("timeout waiting for TCP readiness on %s before any dial attempt", addr) } return fmt.Errorf("timeout waiting for TCP readiness on %s: %w", addr, lastErr) } ================================================ FILE: test/e2e/framework/request.go ================================================ package framework import ( "bytes" "net/http" flog "github.com/fatedier/frp/pkg/util/log" "github.com/fatedier/frp/test/e2e/framework/consts" "github.com/fatedier/frp/test/e2e/pkg/request" ) func SpecifiedHTTPBodyHandler(body []byte) http.HandlerFunc { return func(w http.ResponseWriter, req *http.Request) { _, _ = w.Write(body) } } func ExpectResponseCode(code int) EnsureFunc { return func(resp *request.Response) bool { if resp.Code == code { return true } flog.Warnf("expect code %d, but got %d", code, resp.Code) return false } } // NewRequest return a default request with default timeout and content. func NewRequest() *request.Request { return request.New(). Timeout(consts.DefaultTimeout). Body([]byte(consts.TestString)) } func NewHTTPRequest() *request.Request { return request.New().HTTP().HTTPParams("GET", "", "/", nil) } type RequestExpect struct { req *request.Request f *Framework expectResp []byte expectError bool explain []any } func NewRequestExpect(f *Framework) *RequestExpect { return &RequestExpect{ req: NewRequest(), f: f, expectResp: []byte(consts.TestString), expectError: false, explain: make([]any, 0), } } func (e *RequestExpect) Request(req *request.Request) *RequestExpect { e.req = req return e } func (e *RequestExpect) RequestModify(f func(r *request.Request)) *RequestExpect { f(e.req) return e } func (e *RequestExpect) Protocol(protocol string) *RequestExpect { e.req.Protocol(protocol) return e } func (e *RequestExpect) PortName(name string) *RequestExpect { if e.f != nil { e.req.Port(e.f.PortByName(name)) } return e } func (e *RequestExpect) Port(port int) *RequestExpect { if e.f != nil { e.req.Port(port) } return e } func (e *RequestExpect) ExpectResp(resp []byte) *RequestExpect { e.expectResp = resp return e } func (e *RequestExpect) ExpectError(expectErr bool) *RequestExpect { e.expectError = expectErr return e } func (e *RequestExpect) Explain(explain ...any) *RequestExpect { e.explain = explain return e } type EnsureFunc func(*request.Response) bool func (e *RequestExpect) Ensure(fns ...EnsureFunc) { ret, err := e.req.Do() if e.expectError { ExpectErrorWithOffset(1, err, e.explain...) return } ExpectNoErrorWithOffset(1, err, e.explain...) if len(fns) == 0 { if !bytes.Equal(e.expectResp, ret.Content) { flog.Tracef("response info: %+v", ret) } ExpectEqualValuesWithOffset(1, string(ret.Content), string(e.expectResp), e.explain...) } else { for _, fn := range fns { ok := fn(ret) if !ok { flog.Tracef("response info: %+v", ret) } ExpectTrueWithOffset(1, ok, e.explain...) } } } func (e *RequestExpect) Do() (*request.Response, error) { return e.req.Do() } ================================================ FILE: test/e2e/framework/test_context.go ================================================ package framework import ( "flag" "fmt" "os" ) type TestContextType struct { FRPClientPath string FRPServerPath string LogLevel string Debug bool } var TestContext TestContextType // RegisterCommonFlags registers flags common to all e2e test suites. // The flag set can be flag.CommandLine (if desired) or a custom // flag set that then gets passed to viperconfig.ViperizeFlags. // // The other Register*Flags methods below can be used to add more // test-specific flags. However, those settings then get added // regardless whether the test is actually in the test suite. func RegisterCommonFlags(flags *flag.FlagSet) { flags.StringVar(&TestContext.FRPClientPath, "frpc-path", "../../bin/frpc", "The frp client binary to use.") flags.StringVar(&TestContext.FRPServerPath, "frps-path", "../../bin/frps", "The frp server binary to use.") flags.StringVar(&TestContext.LogLevel, "log-level", "debug", "Log level.") flags.BoolVar(&TestContext.Debug, "debug", false, "Enable debug mode to print detail info.") } func ValidateTestContext(t *TestContextType) error { if t.FRPClientPath == "" || t.FRPServerPath == "" { return fmt.Errorf("frpc and frps binary path can't be empty") } if _, err := os.Stat(t.FRPClientPath); err != nil { return fmt.Errorf("load frpc-path error: %v", err) } if _, err := os.Stat(t.FRPServerPath); err != nil { return fmt.Errorf("load frps-path error: %v", err) } return nil } ================================================ FILE: test/e2e/framework/util.go ================================================ package framework import ( "github.com/google/uuid" ) // RunID is a unique identifier of the e2e run. // Beware that this ID is not the same for all tests in the e2e run, because each Ginkgo node creates it separately. var RunID string func init() { RunID = uuid.NewString() } ================================================ FILE: test/e2e/legacy/basic/basic.go ================================================ package basic import ( "crypto/tls" "fmt" "strings" "time" "github.com/onsi/ginkgo/v2" "github.com/fatedier/frp/pkg/transport" "github.com/fatedier/frp/test/e2e/framework" "github.com/fatedier/frp/test/e2e/framework/consts" "github.com/fatedier/frp/test/e2e/mock/server/httpserver" "github.com/fatedier/frp/test/e2e/mock/server/streamserver" "github.com/fatedier/frp/test/e2e/pkg/port" "github.com/fatedier/frp/test/e2e/pkg/request" ) var _ = ginkgo.Describe("[Feature: Basic]", func() { f := framework.NewDefaultFramework() ginkgo.Describe("TCP && UDP", func() { types := []string{"tcp", "udp"} for _, t := range types { proxyType := t ginkgo.It(fmt.Sprintf("Expose a %s echo server", strings.ToUpper(proxyType)), func() { serverConf := consts.LegacyDefaultServerConfig var clientConf strings.Builder clientConf.WriteString(consts.LegacyDefaultClientConfig) localPortName := "" protocol := "tcp" switch proxyType { case "tcp": localPortName = framework.TCPEchoServerPort protocol = "tcp" case "udp": localPortName = framework.UDPEchoServerPort protocol = "udp" } getProxyConf := func(proxyName string, portName string, extra string) string { return fmt.Sprintf(` [%s] type = %s local_port = {{ .%s }} remote_port = {{ .%s }} `+extra, proxyName, proxyType, localPortName, portName) } tests := []struct { proxyName string portName string extraConfig string }{ { proxyName: "normal", portName: port.GenName("Normal"), }, { proxyName: "with-encryption", portName: port.GenName("WithEncryption"), extraConfig: "use_encryption = true", }, { proxyName: "with-compression", portName: port.GenName("WithCompression"), extraConfig: "use_compression = true", }, { proxyName: "with-encryption-and-compression", portName: port.GenName("WithEncryptionAndCompression"), extraConfig: ` use_encryption = true use_compression = true `, }, } // build all client config for _, test := range tests { clientConf.WriteString(getProxyConf(test.proxyName, test.portName, test.extraConfig) + "\n") } // run frps and frpc f.RunProcesses(serverConf, []string{clientConf.String()}) for _, test := range tests { framework.NewRequestExpect(f). Protocol(protocol). PortName(test.portName). Explain(test.proxyName). Ensure() } }) } }) ginkgo.Describe("HTTP", func() { ginkgo.It("proxy to HTTP server", func() { serverConf := consts.LegacyDefaultServerConfig vhostHTTPPort := f.AllocPort() serverConf += fmt.Sprintf(` vhost_http_port = %d `, vhostHTTPPort) var clientConf strings.Builder clientConf.WriteString(consts.LegacyDefaultClientConfig) getProxyConf := func(proxyName string, customDomains string, extra string) string { return fmt.Sprintf(` [%s] type = http local_port = {{ .%s }} custom_domains = %s `+extra, proxyName, framework.HTTPSimpleServerPort, customDomains) } tests := []struct { proxyName string customDomains string extraConfig string }{ { proxyName: "normal", }, { proxyName: "with-encryption", extraConfig: "use_encryption = true", }, { proxyName: "with-compression", extraConfig: "use_compression = true", }, { proxyName: "with-encryption-and-compression", extraConfig: ` use_encryption = true use_compression = true `, }, { proxyName: "multiple-custom-domains", customDomains: "a.example.com, b.example.com", }, } // build all client config for i, test := range tests { if tests[i].customDomains == "" { tests[i].customDomains = test.proxyName + ".example.com" } clientConf.WriteString(getProxyConf(test.proxyName, tests[i].customDomains, test.extraConfig) + "\n") } // run frps and frpc f.RunProcesses(serverConf, []string{clientConf.String()}) for _, test := range tests { for domain := range strings.SplitSeq(test.customDomains, ",") { domain = strings.TrimSpace(domain) framework.NewRequestExpect(f). Explain(test.proxyName + "-" + domain). Port(vhostHTTPPort). RequestModify(func(r *request.Request) { r.HTTP().HTTPHost(domain) }). Ensure() } } // not exist host framework.NewRequestExpect(f). Explain("not exist host"). Port(vhostHTTPPort). RequestModify(func(r *request.Request) { r.HTTP().HTTPHost("not-exist.example.com") }). Ensure(framework.ExpectResponseCode(404)) }) }) ginkgo.Describe("HTTPS", func() { ginkgo.It("proxy to HTTPS server", func() { serverConf := consts.LegacyDefaultServerConfig vhostHTTPSPort := f.AllocPort() serverConf += fmt.Sprintf(` vhost_https_port = %d `, vhostHTTPSPort) localPort := f.AllocPort() var clientConf strings.Builder clientConf.WriteString(consts.LegacyDefaultClientConfig) getProxyConf := func(proxyName string, customDomains string, extra string) string { return fmt.Sprintf(` [%s] type = https local_port = %d custom_domains = %s `+extra, proxyName, localPort, customDomains) } tests := []struct { proxyName string customDomains string extraConfig string }{ { proxyName: "normal", }, { proxyName: "with-encryption", extraConfig: "use_encryption = true", }, { proxyName: "with-compression", extraConfig: "use_compression = true", }, { proxyName: "with-encryption-and-compression", extraConfig: ` use_encryption = true use_compression = true `, }, { proxyName: "multiple-custom-domains", customDomains: "a.example.com, b.example.com", }, } // build all client config for i, test := range tests { if tests[i].customDomains == "" { tests[i].customDomains = test.proxyName + ".example.com" } clientConf.WriteString(getProxyConf(test.proxyName, tests[i].customDomains, test.extraConfig) + "\n") } // run frps and frpc f.RunProcesses(serverConf, []string{clientConf.String()}) tlsConfig, err := transport.NewServerTLSConfig("", "", "") framework.ExpectNoError(err) localServer := httpserver.New( httpserver.WithBindPort(localPort), httpserver.WithTLSConfig(tlsConfig), httpserver.WithResponse([]byte("test")), ) f.RunServer("", localServer) for _, test := range tests { for domain := range strings.SplitSeq(test.customDomains, ",") { domain = strings.TrimSpace(domain) framework.NewRequestExpect(f). Explain(test.proxyName + "-" + domain). Port(vhostHTTPSPort). RequestModify(func(r *request.Request) { r.HTTPS().HTTPHost(domain).TLSConfig(&tls.Config{ ServerName: domain, InsecureSkipVerify: true, }) }). ExpectResp([]byte("test")). Ensure() } } // not exist host notExistDomain := "not-exist.example.com" framework.NewRequestExpect(f). Explain("not exist host"). Port(vhostHTTPSPort). RequestModify(func(r *request.Request) { r.HTTPS().HTTPHost(notExistDomain).TLSConfig(&tls.Config{ ServerName: notExistDomain, InsecureSkipVerify: true, }) }). ExpectError(true). Ensure() }) }) ginkgo.Describe("STCP && SUDP && XTCP", func() { types := []string{"stcp", "sudp", "xtcp"} for _, t := range types { proxyType := t ginkgo.It(fmt.Sprintf("Expose echo server with %s", strings.ToUpper(proxyType)), func() { serverConf := consts.LegacyDefaultServerConfig var clientServerConf strings.Builder clientServerConf.WriteString(consts.LegacyDefaultClientConfig + "\nuser = user1") var clientVisitorConf strings.Builder clientVisitorConf.WriteString(consts.LegacyDefaultClientConfig + "\nuser = user1") var clientUser2VisitorConf strings.Builder clientUser2VisitorConf.WriteString(consts.LegacyDefaultClientConfig + "\nuser = user2") localPortName := "" protocol := "tcp" switch proxyType { case "stcp": localPortName = framework.TCPEchoServerPort protocol = "tcp" case "sudp": localPortName = framework.UDPEchoServerPort protocol = "udp" case "xtcp": localPortName = framework.TCPEchoServerPort protocol = "tcp" ginkgo.Skip("stun server is not stable") } correctSK := "abc" wrongSK := "123" getProxyServerConf := func(proxyName string, extra string) string { return fmt.Sprintf(` [%s] type = %s role = server sk = %s local_port = {{ .%s }} `+extra, proxyName, proxyType, correctSK, localPortName) } getProxyVisitorConf := func(proxyName string, portName, visitorSK, extra string) string { return fmt.Sprintf(` [%s] type = %s role = visitor server_name = %s sk = %s bind_port = {{ .%s }} `+extra, proxyName, proxyType, proxyName, visitorSK, portName) } tests := []struct { proxyName string bindPortName string visitorSK string commonExtraConfig string proxyExtraConfig string visitorExtraConfig string expectError bool deployUser2Client bool // skipXTCP is used to skip xtcp test case skipXTCP bool }{ { proxyName: "normal", bindPortName: port.GenName("Normal"), visitorSK: correctSK, skipXTCP: true, }, { proxyName: "with-encryption", bindPortName: port.GenName("WithEncryption"), visitorSK: correctSK, commonExtraConfig: "use_encryption = true", skipXTCP: true, }, { proxyName: "with-compression", bindPortName: port.GenName("WithCompression"), visitorSK: correctSK, commonExtraConfig: "use_compression = true", skipXTCP: true, }, { proxyName: "with-encryption-and-compression", bindPortName: port.GenName("WithEncryptionAndCompression"), visitorSK: correctSK, commonExtraConfig: ` use_encryption = true use_compression = true `, skipXTCP: true, }, { proxyName: "with-error-sk", bindPortName: port.GenName("WithErrorSK"), visitorSK: wrongSK, expectError: true, }, { proxyName: "allowed-user", bindPortName: port.GenName("AllowedUser"), visitorSK: correctSK, proxyExtraConfig: "allow_users = another, user2", visitorExtraConfig: "server_user = user1", deployUser2Client: true, }, { proxyName: "not-allowed-user", bindPortName: port.GenName("NotAllowedUser"), visitorSK: correctSK, proxyExtraConfig: "allow_users = invalid", visitorExtraConfig: "server_user = user1", expectError: true, }, { proxyName: "allow-all", bindPortName: port.GenName("AllowAll"), visitorSK: correctSK, proxyExtraConfig: "allow_users = *", visitorExtraConfig: "server_user = user1", deployUser2Client: true, }, } // build all client config for _, test := range tests { clientServerConf.WriteString(getProxyServerConf(test.proxyName, test.commonExtraConfig+"\n"+test.proxyExtraConfig) + "\n") } for _, test := range tests { config := getProxyVisitorConf( test.proxyName, test.bindPortName, test.visitorSK, test.commonExtraConfig+"\n"+test.visitorExtraConfig, ) + "\n" if test.deployUser2Client { clientUser2VisitorConf.WriteString(config) } else { clientVisitorConf.WriteString(config) } } // run frps and frpc f.RunProcesses(serverConf, []string{clientServerConf.String(), clientVisitorConf.String(), clientUser2VisitorConf.String()}) for _, test := range tests { timeout := time.Second if t == "xtcp" { if test.skipXTCP { continue } timeout = 10 * time.Second } framework.NewRequestExpect(f). RequestModify(func(r *request.Request) { r.Timeout(timeout) }). Protocol(protocol). PortName(test.bindPortName). Explain(test.proxyName). ExpectError(test.expectError). Ensure() } }) } }) ginkgo.Describe("TCPMUX", func() { ginkgo.It("Type tcpmux", func() { serverConf := consts.LegacyDefaultServerConfig var clientConf strings.Builder clientConf.WriteString(consts.LegacyDefaultClientConfig) tcpmuxHTTPConnectPortName := port.GenName("TCPMUX") serverConf += fmt.Sprintf(` tcpmux_httpconnect_port = {{ .%s }} `, tcpmuxHTTPConnectPortName) getProxyConf := func(proxyName string, extra string) string { return fmt.Sprintf(` [%s] type = tcpmux multiplexer = httpconnect local_port = {{ .%s }} custom_domains = %s `+extra, proxyName, port.GenName(proxyName), proxyName) } tests := []struct { proxyName string extraConfig string }{ { proxyName: "normal", }, { proxyName: "with-encryption", extraConfig: "use_encryption = true", }, { proxyName: "with-compression", extraConfig: "use_compression = true", }, { proxyName: "with-encryption-and-compression", extraConfig: ` use_encryption = true use_compression = true `, }, } // build all client config for _, test := range tests { clientConf.WriteString(getProxyConf(test.proxyName, test.extraConfig) + "\n") localServer := streamserver.New(streamserver.TCP, streamserver.WithBindPort(f.AllocPort()), streamserver.WithRespContent([]byte(test.proxyName))) f.RunServer(port.GenName(test.proxyName), localServer) } // run frps and frpc f.RunProcesses(serverConf, []string{clientConf.String()}) // Request without HTTP connect should get error framework.NewRequestExpect(f). PortName(tcpmuxHTTPConnectPortName). ExpectError(true). Explain("request without HTTP connect expect error"). Ensure() proxyURL := fmt.Sprintf("http://127.0.0.1:%d", f.PortByName(tcpmuxHTTPConnectPortName)) // Request with incorrect connect hostname framework.NewRequestExpect(f).RequestModify(func(r *request.Request) { r.Addr("invalid").Proxy(proxyURL) }).ExpectError(true).Explain("request without HTTP connect expect error").Ensure() // Request with correct connect hostname for _, test := range tests { framework.NewRequestExpect(f).RequestModify(func(r *request.Request) { r.Addr(test.proxyName).Proxy(proxyURL) }).ExpectResp([]byte(test.proxyName)).Explain(test.proxyName).Ensure() } }) }) }) ================================================ FILE: test/e2e/legacy/basic/client.go ================================================ package basic import ( "context" "fmt" "strconv" "strings" "time" "github.com/onsi/ginkgo/v2" "github.com/fatedier/frp/test/e2e/framework" "github.com/fatedier/frp/test/e2e/framework/consts" "github.com/fatedier/frp/test/e2e/pkg/request" ) var _ = ginkgo.Describe("[Feature: ClientManage]", func() { f := framework.NewDefaultFramework() ginkgo.It("Update && Reload API", func() { serverConf := consts.LegacyDefaultServerConfig adminPort := f.AllocPort() p1Port := f.AllocPort() p2Port := f.AllocPort() p3Port := f.AllocPort() clientConf := consts.LegacyDefaultClientConfig + fmt.Sprintf(` admin_port = %d [p1] type = tcp local_port = {{ .%s }} remote_port = %d [p2] type = tcp local_port = {{ .%s }} remote_port = %d [p3] type = tcp local_port = {{ .%s }} remote_port = %d `, adminPort, framework.TCPEchoServerPort, p1Port, framework.TCPEchoServerPort, p2Port, framework.TCPEchoServerPort, p3Port) f.RunProcesses(serverConf, []string{clientConf}) framework.NewRequestExpect(f).Port(p1Port).Ensure() framework.NewRequestExpect(f).Port(p2Port).Ensure() framework.NewRequestExpect(f).Port(p3Port).Ensure() client := f.APIClientForFrpc(adminPort) conf, err := client.GetConfig(context.Background()) framework.ExpectNoError(err) newP2Port := f.AllocPort() // change p2 port and remove p3 proxy newClientConf := strings.ReplaceAll(conf, strconv.Itoa(p2Port), strconv.Itoa(newP2Port)) p3Index := strings.Index(newClientConf, "[p3]") if p3Index >= 0 { newClientConf = newClientConf[:p3Index] } err = client.UpdateConfig(context.Background(), newClientConf) framework.ExpectNoError(err) err = client.Reload(context.Background(), true) framework.ExpectNoError(err) time.Sleep(time.Second) framework.NewRequestExpect(f).Port(p1Port).Explain("p1 port").Ensure() framework.NewRequestExpect(f).Port(p2Port).Explain("original p2 port").ExpectError(true).Ensure() framework.NewRequestExpect(f).Port(newP2Port).Explain("new p2 port").Ensure() framework.NewRequestExpect(f).Port(p3Port).Explain("p3 port").ExpectError(true).Ensure() }) ginkgo.It("healthz", func() { serverConf := consts.LegacyDefaultServerConfig dashboardPort := f.AllocPort() clientConf := consts.LegacyDefaultClientConfig + fmt.Sprintf(` admin_addr = 0.0.0.0 admin_port = %d admin_user = admin admin_pwd = admin `, dashboardPort) f.RunProcesses(serverConf, []string{clientConf}) framework.NewRequestExpect(f).RequestModify(func(r *request.Request) { r.HTTP().HTTPPath("/healthz") }).Port(dashboardPort).ExpectResp([]byte("")).Ensure() framework.NewRequestExpect(f).RequestModify(func(r *request.Request) { r.HTTP().HTTPPath("/") }).Port(dashboardPort). Ensure(framework.ExpectResponseCode(401)) }) ginkgo.It("stop", func() { serverConf := consts.LegacyDefaultServerConfig adminPort := f.AllocPort() testPort := f.AllocPort() clientConf := consts.LegacyDefaultClientConfig + fmt.Sprintf(` admin_port = %d [test] type = tcp local_port = {{ .%s }} remote_port = %d `, adminPort, framework.TCPEchoServerPort, testPort) f.RunProcesses(serverConf, []string{clientConf}) framework.NewRequestExpect(f).Port(testPort).Ensure() client := f.APIClientForFrpc(adminPort) err := client.Stop(context.Background()) framework.ExpectNoError(err) time.Sleep(3 * time.Second) // frpc stopped so the port is not listened, expect error framework.NewRequestExpect(f).Port(testPort).ExpectError(true).Ensure() }) }) ================================================ FILE: test/e2e/legacy/basic/client_server.go ================================================ package basic import ( "fmt" "strings" "time" "github.com/onsi/ginkgo/v2" "github.com/fatedier/frp/test/e2e/framework" "github.com/fatedier/frp/test/e2e/framework/consts" "github.com/fatedier/frp/test/e2e/pkg/cert" "github.com/fatedier/frp/test/e2e/pkg/port" ) type generalTestConfigures struct { server string client string clientPrefix string client2 string client2Prefix string testDelay time.Duration expectError bool } func renderBindPortConfig(protocol string) string { switch protocol { case "kcp": return fmt.Sprintf(`kcp_bind_port = {{ .%s }}`, consts.PortServerName) case "quic": return fmt.Sprintf(`quic_bind_port = {{ .%s }}`, consts.PortServerName) default: return "" } } func runClientServerTest(f *framework.Framework, configures *generalTestConfigures) { serverConf := consts.LegacyDefaultServerConfig clientConf := consts.LegacyDefaultClientConfig if configures.clientPrefix != "" { clientConf = configures.clientPrefix } serverConf += fmt.Sprintf(` %s `, configures.server) tcpPortName := port.GenName("TCP") udpPortName := port.GenName("UDP") clientConf += fmt.Sprintf(` %s [tcp] type = tcp local_port = {{ .%s }} remote_port = {{ .%s }} [udp] type = udp local_port = {{ .%s }} remote_port = {{ .%s }} `, configures.client, framework.TCPEchoServerPort, tcpPortName, framework.UDPEchoServerPort, udpPortName, ) clientConfs := []string{clientConf} if configures.client2 != "" { client2Conf := consts.LegacyDefaultClientConfig if configures.client2Prefix != "" { client2Conf = configures.client2Prefix } client2Conf += fmt.Sprintf(` %s `, configures.client2) clientConfs = append(clientConfs, client2Conf) } f.RunProcesses(serverConf, clientConfs) if configures.testDelay > 0 { time.Sleep(configures.testDelay) } framework.NewRequestExpect(f).PortName(tcpPortName).ExpectError(configures.expectError).Explain("tcp proxy").Ensure() framework.NewRequestExpect(f).Protocol("udp"). PortName(udpPortName).ExpectError(configures.expectError).Explain("udp proxy").Ensure() } // defineClientServerTest test a normal tcp and udp proxy with specified TestConfigures. func defineClientServerTest(desc string, f *framework.Framework, configures *generalTestConfigures) { ginkgo.It(desc, func() { runClientServerTest(f, configures) }) } var _ = ginkgo.Describe("[Feature: Client-Server]", func() { f := framework.NewDefaultFramework() ginkgo.Describe("Protocol", func() { supportProtocols := []string{"tcp", "kcp", "quic", "websocket"} for _, protocol := range supportProtocols { configures := &generalTestConfigures{ server: fmt.Sprintf(` %s `, renderBindPortConfig(protocol)), client: "protocol = " + protocol, } defineClientServerTest(protocol, f, configures) } }) // wss is special, it needs to be tested separately. // frps only supports ws, so there should be a proxy to terminate TLS before frps. ginkgo.Describe("Protocol wss", func() { wssPort := f.AllocPort() configures := &generalTestConfigures{ clientPrefix: fmt.Sprintf(` [common] server_addr = 127.0.0.1 server_port = %d protocol = wss log_level = trace login_fail_exit = false `, wssPort), // Due to the fact that frps cannot directly accept wss connections, we use the https2http plugin of another frpc to terminate TLS. client2: fmt.Sprintf(` [wss2ws] type = tcp remote_port = %d plugin = https2http plugin_local_addr = 127.0.0.1:{{ .%s }} `, wssPort, consts.PortServerName), testDelay: 10 * time.Second, } defineClientServerTest("wss", f, configures) }) ginkgo.Describe("Authentication", func() { defineClientServerTest("Token Correct", f, &generalTestConfigures{ server: "token = 123456", client: "token = 123456", }) defineClientServerTest("Token Incorrect", f, &generalTestConfigures{ server: "token = 123456", client: "token = invalid", expectError: true, }) }) ginkgo.Describe("TLS", func() { supportProtocols := []string{"tcp", "kcp", "quic", "websocket"} for _, protocol := range supportProtocols { tmp := protocol // Since v0.50.0, the default value of tls_enable has been changed to true. // Therefore, here it needs to be set as false to test the scenario of turning it off. defineClientServerTest("Disable TLS over "+strings.ToUpper(tmp), f, &generalTestConfigures{ server: fmt.Sprintf(` %s `, renderBindPortConfig(protocol)), client: fmt.Sprintf(`tls_enable = false protocol = %s `, protocol), }) } defineClientServerTest("enable tls_only, client with TLS", f, &generalTestConfigures{ server: "tls_only = true", }) defineClientServerTest("enable tls_only, client without TLS", f, &generalTestConfigures{ server: "tls_only = true", client: "tls_enable = false", expectError: true, }) }) ginkgo.Describe("TLS with custom certificate", func() { supportProtocols := []string{"tcp", "kcp", "quic", "websocket"} var ( caCrtPath string serverCrtPath, serverKeyPath string clientCrtPath, clientKeyPath string ) ginkgo.JustBeforeEach(func() { generator := &cert.SelfSignedCertGenerator{} artifacts, err := generator.Generate("127.0.0.1") framework.ExpectNoError(err) caCrtPath = f.WriteTempFile("ca.crt", string(artifacts.CACert)) serverCrtPath = f.WriteTempFile("server.crt", string(artifacts.Cert)) serverKeyPath = f.WriteTempFile("server.key", string(artifacts.Key)) generator.SetCA(artifacts.CACert, artifacts.CAKey) _, err = generator.Generate("127.0.0.1") framework.ExpectNoError(err) clientCrtPath = f.WriteTempFile("client.crt", string(artifacts.Cert)) clientKeyPath = f.WriteTempFile("client.key", string(artifacts.Key)) }) for _, protocol := range supportProtocols { tmp := protocol ginkgo.It("one-way authentication: "+tmp, func() { runClientServerTest(f, &generalTestConfigures{ server: fmt.Sprintf(` %s tls_trusted_ca_file = %s `, renderBindPortConfig(tmp), caCrtPath), client: fmt.Sprintf(` protocol = %s tls_cert_file = %s tls_key_file = %s `, tmp, clientCrtPath, clientKeyPath), }) }) ginkgo.It("mutual authentication: "+tmp, func() { runClientServerTest(f, &generalTestConfigures{ server: fmt.Sprintf(` %s tls_cert_file = %s tls_key_file = %s tls_trusted_ca_file = %s `, renderBindPortConfig(tmp), serverCrtPath, serverKeyPath, caCrtPath), client: fmt.Sprintf(` protocol = %s tls_cert_file = %s tls_key_file = %s tls_trusted_ca_file = %s `, tmp, clientCrtPath, clientKeyPath, caCrtPath), }) }) } }) ginkgo.Describe("TLS with custom certificate and specified server name", func() { var ( caCrtPath string serverCrtPath, serverKeyPath string clientCrtPath, clientKeyPath string ) ginkgo.JustBeforeEach(func() { generator := &cert.SelfSignedCertGenerator{} artifacts, err := generator.Generate("example.com") framework.ExpectNoError(err) caCrtPath = f.WriteTempFile("ca.crt", string(artifacts.CACert)) serverCrtPath = f.WriteTempFile("server.crt", string(artifacts.Cert)) serverKeyPath = f.WriteTempFile("server.key", string(artifacts.Key)) generator.SetCA(artifacts.CACert, artifacts.CAKey) _, err = generator.Generate("example.com") framework.ExpectNoError(err) clientCrtPath = f.WriteTempFile("client.crt", string(artifacts.Cert)) clientKeyPath = f.WriteTempFile("client.key", string(artifacts.Key)) }) ginkgo.It("mutual authentication", func() { runClientServerTest(f, &generalTestConfigures{ server: fmt.Sprintf(` tls_cert_file = %s tls_key_file = %s tls_trusted_ca_file = %s `, serverCrtPath, serverKeyPath, caCrtPath), client: fmt.Sprintf(` tls_server_name = example.com tls_cert_file = %s tls_key_file = %s tls_trusted_ca_file = %s `, clientCrtPath, clientKeyPath, caCrtPath), }) }) ginkgo.It("mutual authentication with incorrect server name", func() { runClientServerTest(f, &generalTestConfigures{ server: fmt.Sprintf(` tls_cert_file = %s tls_key_file = %s tls_trusted_ca_file = %s `, serverCrtPath, serverKeyPath, caCrtPath), client: fmt.Sprintf(` tls_server_name = invalid.com tls_cert_file = %s tls_key_file = %s tls_trusted_ca_file = %s `, clientCrtPath, clientKeyPath, caCrtPath), expectError: true, }) }) }) ginkgo.Describe("TLS with disable_custom_tls_first_byte set to false", func() { supportProtocols := []string{"tcp", "kcp", "quic", "websocket"} for _, protocol := range supportProtocols { tmp := protocol defineClientServerTest("TLS over "+strings.ToUpper(tmp), f, &generalTestConfigures{ server: fmt.Sprintf(` %s `, renderBindPortConfig(protocol)), client: fmt.Sprintf(` protocol = %s disable_custom_tls_first_byte = false `, protocol), }) } }) ginkgo.Describe("IPv6 bind address", func() { supportProtocols := []string{"tcp", "kcp", "quic", "websocket"} for _, protocol := range supportProtocols { tmp := protocol defineClientServerTest("IPv6 bind address: "+strings.ToUpper(tmp), f, &generalTestConfigures{ server: fmt.Sprintf(` bind_addr = :: %s `, renderBindPortConfig(protocol)), client: fmt.Sprintf(` protocol = %s `, protocol), }) } }) }) ================================================ FILE: test/e2e/legacy/basic/cmd.go ================================================ package basic import ( "strconv" "strings" "github.com/onsi/ginkgo/v2" "github.com/fatedier/frp/test/e2e/framework" "github.com/fatedier/frp/test/e2e/pkg/request" ) const ( ConfigValidStr = "syntax is ok" ) var _ = ginkgo.Describe("[Feature: Cmd]", func() { f := framework.NewDefaultFramework() ginkgo.Describe("Verify", func() { ginkgo.It("frps valid", func() { path := f.GenerateConfigFile(` [common] bind_addr = 0.0.0.0 bind_port = 7000 `) _, output, err := f.RunFrps("verify", "-c", path) framework.ExpectNoError(err) framework.ExpectTrue(strings.Contains(output, ConfigValidStr), "output: %s", output) }) ginkgo.It("frps invalid", func() { path := f.GenerateConfigFile(` [common] bind_addr = 0.0.0.0 bind_port = 70000 `) _, output, err := f.RunFrps("verify", "-c", path) framework.ExpectNoError(err) framework.ExpectTrue(!strings.Contains(output, ConfigValidStr), "output: %s", output) }) ginkgo.It("frpc valid", func() { path := f.GenerateConfigFile(` [common] server_addr = 0.0.0.0 server_port = 7000 `) _, output, err := f.RunFrpc("verify", "-c", path) framework.ExpectNoError(err) framework.ExpectTrue(strings.Contains(output, ConfigValidStr), "output: %s", output) }) ginkgo.It("frpc invalid", func() { path := f.GenerateConfigFile(` [common] server_addr = 0.0.0.0 server_port = 7000 protocol = invalid `) _, output, err := f.RunFrpc("verify", "-c", path) framework.ExpectNoError(err) framework.ExpectTrue(!strings.Contains(output, ConfigValidStr), "output: %s", output) }) }) ginkgo.Describe("Single proxy", func() { ginkgo.It("TCP", func() { serverPort := f.AllocPort() _, _, err := f.RunFrps("-t", "123", "-p", strconv.Itoa(serverPort)) framework.ExpectNoError(err) localPort := f.PortByName(framework.TCPEchoServerPort) remotePort := f.AllocPort() _, _, err = f.RunFrpc("tcp", "-s", "127.0.0.1", "-P", strconv.Itoa(serverPort), "-t", "123", "-u", "test", "-l", strconv.Itoa(localPort), "-r", strconv.Itoa(remotePort), "-n", "tcp_test") framework.ExpectNoError(err) framework.NewRequestExpect(f).Port(remotePort).Ensure() }) ginkgo.It("UDP", func() { serverPort := f.AllocPort() _, _, err := f.RunFrps("-t", "123", "-p", strconv.Itoa(serverPort)) framework.ExpectNoError(err) localPort := f.PortByName(framework.UDPEchoServerPort) remotePort := f.AllocPort() _, _, err = f.RunFrpc("udp", "-s", "127.0.0.1", "-P", strconv.Itoa(serverPort), "-t", "123", "-u", "test", "-l", strconv.Itoa(localPort), "-r", strconv.Itoa(remotePort), "-n", "udp_test") framework.ExpectNoError(err) framework.NewRequestExpect(f).Protocol("udp"). Port(remotePort).Ensure() }) ginkgo.It("HTTP", func() { serverPort := f.AllocPort() vhostHTTPPort := f.AllocPort() _, _, err := f.RunFrps("-t", "123", "-p", strconv.Itoa(serverPort), "--vhost_http_port", strconv.Itoa(vhostHTTPPort)) framework.ExpectNoError(err) _, _, err = f.RunFrpc("http", "-s", "127.0.0.1", "-P", strconv.Itoa(serverPort), "-t", "123", "-u", "test", "-n", "udp_test", "-l", strconv.Itoa(f.PortByName(framework.HTTPSimpleServerPort)), "--custom_domain", "test.example.com") framework.ExpectNoError(err) framework.NewRequestExpect(f).Port(vhostHTTPPort). RequestModify(func(r *request.Request) { r.HTTP().HTTPHost("test.example.com") }). Ensure() }) }) }) ================================================ FILE: test/e2e/legacy/basic/config.go ================================================ package basic import ( "fmt" "github.com/onsi/ginkgo/v2" "github.com/fatedier/frp/test/e2e/framework" "github.com/fatedier/frp/test/e2e/framework/consts" "github.com/fatedier/frp/test/e2e/pkg/port" ) var _ = ginkgo.Describe("[Feature: Config]", func() { f := framework.NewDefaultFramework() ginkgo.Describe("Template", func() { ginkgo.It("render by env", func() { serverConf := consts.LegacyDefaultServerConfig clientConf := consts.LegacyDefaultClientConfig portName := port.GenName("TCP") serverConf += fmt.Sprintf(` token = {{ %s{{ .Envs.FRP_TOKEN }}%s }} `, "`", "`") clientConf += fmt.Sprintf(` token = {{ %s{{ .Envs.FRP_TOKEN }}%s }} [tcp] type = tcp local_port = {{ .%s }} remote_port = {{ .%s }} `, "`", "`", framework.TCPEchoServerPort, portName) f.SetEnvs([]string{"FRP_TOKEN=123"}) f.RunProcesses(serverConf, []string{clientConf}) framework.NewRequestExpect(f).PortName(portName).Ensure() }) }) ginkgo.Describe("Includes", func() { ginkgo.It("split tcp proxies into different files", func() { serverPort := f.AllocPort() serverConfigPath := f.GenerateConfigFile(fmt.Sprintf(` [common] bind_addr = 0.0.0.0 bind_port = %d `, serverPort)) remotePort := f.AllocPort() proxyConfigPath := f.GenerateConfigFile(fmt.Sprintf(` [tcp] type = tcp local_port = %d remote_port = %d `, f.PortByName(framework.TCPEchoServerPort), remotePort)) remotePort2 := f.AllocPort() proxyConfigPath2 := f.GenerateConfigFile(fmt.Sprintf(` [tcp2] type = tcp local_port = %d remote_port = %d `, f.PortByName(framework.TCPEchoServerPort), remotePort2)) clientConfigPath := f.GenerateConfigFile(fmt.Sprintf(` [common] server_port = %d includes = %s,%s `, serverPort, proxyConfigPath, proxyConfigPath2)) _, _, err := f.RunFrps("-c", serverConfigPath) framework.ExpectNoError(err) _, _, err = f.RunFrpc("-c", clientConfigPath) framework.ExpectNoError(err) framework.NewRequestExpect(f).Port(remotePort).Ensure() framework.NewRequestExpect(f).Port(remotePort2).Ensure() }) }) }) ================================================ FILE: test/e2e/legacy/basic/http.go ================================================ package basic import ( "fmt" "net/http" "net/url" "strconv" "github.com/gorilla/websocket" "github.com/onsi/ginkgo/v2" "github.com/fatedier/frp/test/e2e/framework" "github.com/fatedier/frp/test/e2e/framework/consts" "github.com/fatedier/frp/test/e2e/mock/server/httpserver" "github.com/fatedier/frp/test/e2e/pkg/request" ) var _ = ginkgo.Describe("[Feature: HTTP]", func() { f := framework.NewDefaultFramework() getDefaultServerConf := func(vhostHTTPPort int) string { conf := consts.LegacyDefaultServerConfig + ` vhost_http_port = %d ` return fmt.Sprintf(conf, vhostHTTPPort) } newHTTPServer := func(port int, respContent string) *httpserver.Server { return httpserver.New( httpserver.WithBindPort(port), httpserver.WithHandler(framework.SpecifiedHTTPBodyHandler([]byte(respContent))), ) } ginkgo.It("HTTP route by locations", func() { vhostHTTPPort := f.AllocPort() serverConf := getDefaultServerConf(vhostHTTPPort) fooPort := f.AllocPort() f.RunServer("", newHTTPServer(fooPort, "foo")) barPort := f.AllocPort() f.RunServer("", newHTTPServer(barPort, "bar")) clientConf := consts.LegacyDefaultClientConfig clientConf += fmt.Sprintf(` [foo] type = http local_port = %d custom_domains = normal.example.com locations = /,/foo [bar] type = http local_port = %d custom_domains = normal.example.com locations = /bar `, fooPort, barPort) f.RunProcesses(serverConf, []string{clientConf}) tests := []struct { path string expectResp string desc string }{ {path: "/foo", expectResp: "foo", desc: "foo path"}, {path: "/bar", expectResp: "bar", desc: "bar path"}, {path: "/other", expectResp: "foo", desc: "other path"}, } for _, test := range tests { framework.NewRequestExpect(f).Explain(test.desc).Port(vhostHTTPPort). RequestModify(func(r *request.Request) { r.HTTP().HTTPHost("normal.example.com").HTTPPath(test.path) }). ExpectResp([]byte(test.expectResp)). Ensure() } }) ginkgo.It("HTTP route by HTTP user", func() { vhostHTTPPort := f.AllocPort() serverConf := getDefaultServerConf(vhostHTTPPort) fooPort := f.AllocPort() f.RunServer("", newHTTPServer(fooPort, "foo")) barPort := f.AllocPort() f.RunServer("", newHTTPServer(barPort, "bar")) otherPort := f.AllocPort() f.RunServer("", newHTTPServer(otherPort, "other")) clientConf := consts.LegacyDefaultClientConfig clientConf += fmt.Sprintf(` [foo] type = http local_port = %d custom_domains = normal.example.com route_by_http_user = user1 [bar] type = http local_port = %d custom_domains = normal.example.com route_by_http_user = user2 [catchAll] type = http local_port = %d custom_domains = normal.example.com `, fooPort, barPort, otherPort) f.RunProcesses(serverConf, []string{clientConf}) // user1 framework.NewRequestExpect(f).Explain("user1").Port(vhostHTTPPort). RequestModify(func(r *request.Request) { r.HTTP().HTTPHost("normal.example.com").HTTPAuth("user1", "") }). ExpectResp([]byte("foo")). Ensure() // user2 framework.NewRequestExpect(f).Explain("user2").Port(vhostHTTPPort). RequestModify(func(r *request.Request) { r.HTTP().HTTPHost("normal.example.com").HTTPAuth("user2", "") }). ExpectResp([]byte("bar")). Ensure() // other user framework.NewRequestExpect(f).Explain("other user").Port(vhostHTTPPort). RequestModify(func(r *request.Request) { r.HTTP().HTTPHost("normal.example.com").HTTPAuth("user3", "") }). ExpectResp([]byte("other")). Ensure() }) ginkgo.It("HTTP Basic Auth", func() { vhostHTTPPort := f.AllocPort() serverConf := getDefaultServerConf(vhostHTTPPort) clientConf := consts.LegacyDefaultClientConfig clientConf += fmt.Sprintf(` [test] type = http local_port = {{ .%s }} custom_domains = normal.example.com http_user = test http_pwd = test `, framework.HTTPSimpleServerPort) f.RunProcesses(serverConf, []string{clientConf}) // not set auth header framework.NewRequestExpect(f).Port(vhostHTTPPort). RequestModify(func(r *request.Request) { r.HTTP().HTTPHost("normal.example.com") }). Ensure(framework.ExpectResponseCode(401)) // set incorrect auth header framework.NewRequestExpect(f).Port(vhostHTTPPort). RequestModify(func(r *request.Request) { r.HTTP().HTTPHost("normal.example.com").HTTPAuth("test", "invalid") }). Ensure(framework.ExpectResponseCode(401)) // set correct auth header framework.NewRequestExpect(f).Port(vhostHTTPPort). RequestModify(func(r *request.Request) { r.HTTP().HTTPHost("normal.example.com").HTTPAuth("test", "test") }). Ensure() }) ginkgo.It("Wildcard domain", func() { vhostHTTPPort := f.AllocPort() serverConf := getDefaultServerConf(vhostHTTPPort) clientConf := consts.LegacyDefaultClientConfig clientConf += fmt.Sprintf(` [test] type = http local_port = {{ .%s }} custom_domains = *.example.com `, framework.HTTPSimpleServerPort) f.RunProcesses(serverConf, []string{clientConf}) // not match host framework.NewRequestExpect(f).Port(vhostHTTPPort). RequestModify(func(r *request.Request) { r.HTTP().HTTPHost("not-match.test.com") }). Ensure(framework.ExpectResponseCode(404)) // test.example.com match *.example.com framework.NewRequestExpect(f).Port(vhostHTTPPort). RequestModify(func(r *request.Request) { r.HTTP().HTTPHost("test.example.com") }). Ensure() // sub.test.example.com match *.example.com framework.NewRequestExpect(f).Port(vhostHTTPPort). RequestModify(func(r *request.Request) { r.HTTP().HTTPHost("sub.test.example.com") }). Ensure() }) ginkgo.It("Subdomain", func() { vhostHTTPPort := f.AllocPort() serverConf := getDefaultServerConf(vhostHTTPPort) serverConf += ` subdomain_host = example.com ` fooPort := f.AllocPort() f.RunServer("", newHTTPServer(fooPort, "foo")) barPort := f.AllocPort() f.RunServer("", newHTTPServer(barPort, "bar")) clientConf := consts.LegacyDefaultClientConfig clientConf += fmt.Sprintf(` [foo] type = http local_port = %d subdomain = foo [bar] type = http local_port = %d subdomain = bar `, fooPort, barPort) f.RunProcesses(serverConf, []string{clientConf}) // foo framework.NewRequestExpect(f).Explain("foo subdomain").Port(vhostHTTPPort). RequestModify(func(r *request.Request) { r.HTTP().HTTPHost("foo.example.com") }). ExpectResp([]byte("foo")). Ensure() // bar framework.NewRequestExpect(f).Explain("bar subdomain").Port(vhostHTTPPort). RequestModify(func(r *request.Request) { r.HTTP().HTTPHost("bar.example.com") }). ExpectResp([]byte("bar")). Ensure() }) ginkgo.It("Modify headers", func() { vhostHTTPPort := f.AllocPort() serverConf := getDefaultServerConf(vhostHTTPPort) localPort := f.AllocPort() localServer := httpserver.New( httpserver.WithBindPort(localPort), httpserver.WithHandler(http.HandlerFunc(func(w http.ResponseWriter, req *http.Request) { _, _ = w.Write([]byte(req.Header.Get("X-From-Where"))) })), ) f.RunServer("", localServer) clientConf := consts.LegacyDefaultClientConfig clientConf += fmt.Sprintf(` [test] type = http local_port = %d custom_domains = normal.example.com header_X-From-Where = frp `, localPort) f.RunProcesses(serverConf, []string{clientConf}) // not set auth header framework.NewRequestExpect(f).Port(vhostHTTPPort). RequestModify(func(r *request.Request) { r.HTTP().HTTPHost("normal.example.com") }). ExpectResp([]byte("frp")). // local http server will write this X-From-Where header to response body Ensure() }) ginkgo.It("Host Header Rewrite", func() { vhostHTTPPort := f.AllocPort() serverConf := getDefaultServerConf(vhostHTTPPort) localPort := f.AllocPort() localServer := httpserver.New( httpserver.WithBindPort(localPort), httpserver.WithHandler(http.HandlerFunc(func(w http.ResponseWriter, req *http.Request) { _, _ = w.Write([]byte(req.Host)) })), ) f.RunServer("", localServer) clientConf := consts.LegacyDefaultClientConfig clientConf += fmt.Sprintf(` [test] type = http local_port = %d custom_domains = normal.example.com host_header_rewrite = rewrite.example.com `, localPort) f.RunProcesses(serverConf, []string{clientConf}) framework.NewRequestExpect(f).Port(vhostHTTPPort). RequestModify(func(r *request.Request) { r.HTTP().HTTPHost("normal.example.com") }). ExpectResp([]byte("rewrite.example.com")). // local http server will write host header to response body Ensure() }) ginkgo.It("Websocket protocol", func() { vhostHTTPPort := f.AllocPort() serverConf := getDefaultServerConf(vhostHTTPPort) upgrader := websocket.Upgrader{} localPort := f.AllocPort() localServer := httpserver.New( httpserver.WithBindPort(localPort), httpserver.WithHandler(http.HandlerFunc(func(w http.ResponseWriter, req *http.Request) { c, err := upgrader.Upgrade(w, req, nil) if err != nil { return } defer c.Close() for { mt, message, err := c.ReadMessage() if err != nil { break } err = c.WriteMessage(mt, message) if err != nil { break } } })), ) f.RunServer("", localServer) clientConf := consts.LegacyDefaultClientConfig clientConf += fmt.Sprintf(` [test] type = http local_port = %d custom_domains = 127.0.0.1 `, localPort) f.RunProcesses(serverConf, []string{clientConf}) u := url.URL{Scheme: "ws", Host: "127.0.0.1:" + strconv.Itoa(vhostHTTPPort)} c, _, err := websocket.DefaultDialer.Dial(u.String(), nil) framework.ExpectNoError(err) err = c.WriteMessage(websocket.TextMessage, []byte(consts.TestString)) framework.ExpectNoError(err) _, msg, err := c.ReadMessage() framework.ExpectNoError(err) framework.ExpectEqualValues(consts.TestString, string(msg)) }) }) ================================================ FILE: test/e2e/legacy/basic/server.go ================================================ package basic import ( "context" "fmt" "net" "strconv" "github.com/onsi/ginkgo/v2" "github.com/fatedier/frp/test/e2e/framework" "github.com/fatedier/frp/test/e2e/framework/consts" "github.com/fatedier/frp/test/e2e/pkg/port" "github.com/fatedier/frp/test/e2e/pkg/request" ) var _ = ginkgo.Describe("[Feature: Server Manager]", func() { f := framework.NewDefaultFramework() ginkgo.It("Ports Whitelist", func() { serverConf := consts.LegacyDefaultServerConfig clientConf := consts.LegacyDefaultClientConfig serverConf += ` allow_ports = 10000-11000,11002,12000-13000 ` tcpPortName := port.GenName("TCP", port.WithRangePorts(10000, 11000)) udpPortName := port.GenName("UDP", port.WithRangePorts(12000, 13000)) clientConf += fmt.Sprintf(` [tcp-allowed-in-range] type = tcp local_port = {{ .%s }} remote_port = {{ .%s }} `, framework.TCPEchoServerPort, tcpPortName) clientConf += fmt.Sprintf(` [tcp-port-not-allowed] type = tcp local_port = {{ .%s }} remote_port = 11001 `, framework.TCPEchoServerPort) clientConf += fmt.Sprintf(` [tcp-port-unavailable] type = tcp local_port = {{ .%s }} remote_port = {{ .%s }} `, framework.TCPEchoServerPort, consts.PortServerName) clientConf += fmt.Sprintf(` [udp-allowed-in-range] type = udp local_port = {{ .%s }} remote_port = {{ .%s }} `, framework.UDPEchoServerPort, udpPortName) clientConf += fmt.Sprintf(` [udp-port-not-allowed] type = udp local_port = {{ .%s }} remote_port = 11003 `, framework.UDPEchoServerPort) f.RunProcesses(serverConf, []string{clientConf}) // TCP // Allowed in range framework.NewRequestExpect(f).PortName(tcpPortName).Ensure() // Not Allowed framework.NewRequestExpect(f).Port(11001).ExpectError(true).Ensure() // Unavailable, already bind by frps framework.NewRequestExpect(f).PortName(consts.PortServerName).ExpectError(true).Ensure() // UDP // Allowed in range framework.NewRequestExpect(f).Protocol("udp").PortName(udpPortName).Ensure() // Not Allowed framework.NewRequestExpect(f).RequestModify(func(r *request.Request) { r.UDP().Port(11003) }).ExpectError(true).Ensure() }) ginkgo.It("Alloc Random Port", func() { serverConf := consts.LegacyDefaultServerConfig clientConf := consts.LegacyDefaultClientConfig adminPort := f.AllocPort() clientConf += fmt.Sprintf(` admin_port = %d [tcp] type = tcp local_port = {{ .%s }} [udp] type = udp local_port = {{ .%s }} `, adminPort, framework.TCPEchoServerPort, framework.UDPEchoServerPort) f.RunProcesses(serverConf, []string{clientConf}) client := f.APIClientForFrpc(adminPort) // tcp random port status, err := client.GetProxyStatus(context.Background(), "tcp") framework.ExpectNoError(err) _, portStr, err := net.SplitHostPort(status.RemoteAddr) framework.ExpectNoError(err) port, err := strconv.Atoi(portStr) framework.ExpectNoError(err) framework.NewRequestExpect(f).Port(port).Ensure() // udp random port status, err = client.GetProxyStatus(context.Background(), "udp") framework.ExpectNoError(err) _, portStr, err = net.SplitHostPort(status.RemoteAddr) framework.ExpectNoError(err) port, err = strconv.Atoi(portStr) framework.ExpectNoError(err) framework.NewRequestExpect(f).Protocol("udp").Port(port).Ensure() }) ginkgo.It("Port Reuse", func() { serverConf := consts.LegacyDefaultServerConfig // Use same port as PortServer serverConf += fmt.Sprintf(` vhost_http_port = {{ .%s }} `, consts.PortServerName) clientConf := consts.LegacyDefaultClientConfig + fmt.Sprintf(` [http] type = http local_port = {{ .%s }} custom_domains = example.com `, framework.HTTPSimpleServerPort) f.RunProcesses(serverConf, []string{clientConf}) framework.NewRequestExpect(f).RequestModify(func(r *request.Request) { r.HTTP().HTTPHost("example.com") }).PortName(consts.PortServerName).Ensure() }) ginkgo.It("healthz", func() { serverConf := consts.LegacyDefaultServerConfig dashboardPort := f.AllocPort() // Use same port as PortServer serverConf += fmt.Sprintf(` vhost_http_port = {{ .%s }} dashboard_addr = 0.0.0.0 dashboard_port = %d dashboard_user = admin dashboard_pwd = admin `, consts.PortServerName, dashboardPort) clientConf := consts.LegacyDefaultClientConfig + fmt.Sprintf(` [http] type = http local_port = {{ .%s }} custom_domains = example.com `, framework.HTTPSimpleServerPort) f.RunProcesses(serverConf, []string{clientConf}) framework.NewRequestExpect(f).RequestModify(func(r *request.Request) { r.HTTP().HTTPPath("/healthz") }).Port(dashboardPort).ExpectResp([]byte("")).Ensure() framework.NewRequestExpect(f).RequestModify(func(r *request.Request) { r.HTTP().HTTPPath("/") }).Port(dashboardPort). Ensure(framework.ExpectResponseCode(401)) }) }) ================================================ FILE: test/e2e/legacy/basic/tcpmux.go ================================================ package basic import ( "bufio" "fmt" "net" "net/http" "github.com/onsi/ginkgo/v2" httppkg "github.com/fatedier/frp/pkg/util/http" "github.com/fatedier/frp/test/e2e/framework" "github.com/fatedier/frp/test/e2e/framework/consts" "github.com/fatedier/frp/test/e2e/mock/server/streamserver" "github.com/fatedier/frp/test/e2e/pkg/request" "github.com/fatedier/frp/test/e2e/pkg/rpc" ) var _ = ginkgo.Describe("[Feature: TCPMUX httpconnect]", func() { f := framework.NewDefaultFramework() getDefaultServerConf := func(httpconnectPort int) string { conf := consts.LegacyDefaultServerConfig + ` tcpmux_httpconnect_port = %d ` return fmt.Sprintf(conf, httpconnectPort) } newServer := func(port int, respContent string) *streamserver.Server { return streamserver.New( streamserver.TCP, streamserver.WithBindPort(port), streamserver.WithRespContent([]byte(respContent)), ) } proxyURLWithAuth := func(username, password string, port int) string { if username == "" { return fmt.Sprintf("http://127.0.0.1:%d", port) } return fmt.Sprintf("http://%s:%s@127.0.0.1:%d", username, password, port) } ginkgo.It("Route by HTTP user", func() { vhostPort := f.AllocPort() serverConf := getDefaultServerConf(vhostPort) fooPort := f.AllocPort() f.RunServer("", newServer(fooPort, "foo")) barPort := f.AllocPort() f.RunServer("", newServer(barPort, "bar")) otherPort := f.AllocPort() f.RunServer("", newServer(otherPort, "other")) clientConf := consts.LegacyDefaultClientConfig clientConf += fmt.Sprintf(` [foo] type = tcpmux multiplexer = httpconnect local_port = %d custom_domains = normal.example.com route_by_http_user = user1 [bar] type = tcpmux multiplexer = httpconnect local_port = %d custom_domains = normal.example.com route_by_http_user = user2 [catchAll] type = tcpmux multiplexer = httpconnect local_port = %d custom_domains = normal.example.com `, fooPort, barPort, otherPort) f.RunProcesses(serverConf, []string{clientConf}) // user1 framework.NewRequestExpect(f).Explain("user1"). RequestModify(func(r *request.Request) { r.Addr("normal.example.com").Proxy(proxyURLWithAuth("user1", "", vhostPort)) }). ExpectResp([]byte("foo")). Ensure() // user2 framework.NewRequestExpect(f).Explain("user2"). RequestModify(func(r *request.Request) { r.Addr("normal.example.com").Proxy(proxyURLWithAuth("user2", "", vhostPort)) }). ExpectResp([]byte("bar")). Ensure() // other user framework.NewRequestExpect(f).Explain("other user"). RequestModify(func(r *request.Request) { r.Addr("normal.example.com").Proxy(proxyURLWithAuth("user3", "", vhostPort)) }). ExpectResp([]byte("other")). Ensure() }) ginkgo.It("Proxy auth", func() { vhostPort := f.AllocPort() serverConf := getDefaultServerConf(vhostPort) fooPort := f.AllocPort() f.RunServer("", newServer(fooPort, "foo")) clientConf := consts.LegacyDefaultClientConfig clientConf += fmt.Sprintf(` [test] type = tcpmux multiplexer = httpconnect local_port = %d custom_domains = normal.example.com http_user = test http_pwd = test `, fooPort) f.RunProcesses(serverConf, []string{clientConf}) // not set auth header framework.NewRequestExpect(f).Explain("no auth"). RequestModify(func(r *request.Request) { r.Addr("normal.example.com").Proxy(proxyURLWithAuth("", "", vhostPort)) }). ExpectError(true). Ensure() // set incorrect auth header framework.NewRequestExpect(f).Explain("incorrect auth"). RequestModify(func(r *request.Request) { r.Addr("normal.example.com").Proxy(proxyURLWithAuth("test", "invalid", vhostPort)) }). ExpectError(true). Ensure() // set correct auth header framework.NewRequestExpect(f).Explain("correct auth"). RequestModify(func(r *request.Request) { r.Addr("normal.example.com").Proxy(proxyURLWithAuth("test", "test", vhostPort)) }). ExpectResp([]byte("foo")). Ensure() }) ginkgo.It("TCPMux Passthrough", func() { vhostPort := f.AllocPort() serverConf := getDefaultServerConf(vhostPort) serverConf += ` tcpmux_passthrough = true ` var ( respErr error connectRequestHost string ) newServer := func(port int) *streamserver.Server { return streamserver.New( streamserver.TCP, streamserver.WithBindPort(port), streamserver.WithCustomHandler(func(conn net.Conn) { defer conn.Close() // read HTTP CONNECT request bufioReader := bufio.NewReader(conn) req, err := http.ReadRequest(bufioReader) if err != nil { respErr = err return } connectRequestHost = req.Host // return ok response res := httppkg.OkResponse() if res.Body != nil { defer res.Body.Close() } _ = res.Write(conn) buf, err := rpc.ReadBytes(conn) if err != nil { respErr = err return } _, _ = rpc.WriteBytes(conn, buf) }), ) } localPort := f.AllocPort() f.RunServer("", newServer(localPort)) clientConf := consts.LegacyDefaultClientConfig clientConf += fmt.Sprintf(` [test] type = tcpmux multiplexer = httpconnect local_port = %d custom_domains = normal.example.com `, localPort) f.RunProcesses(serverConf, []string{clientConf}) framework.NewRequestExpect(f). RequestModify(func(r *request.Request) { r.Addr("normal.example.com").Proxy(proxyURLWithAuth("", "", vhostPort)).Body([]byte("frp")) }). ExpectResp([]byte("frp")). Ensure() framework.ExpectNoError(respErr) framework.ExpectEqualValues(connectRequestHost, "normal.example.com") }) }) ================================================ FILE: test/e2e/legacy/basic/xtcp.go ================================================ package basic import ( "fmt" "time" "github.com/onsi/ginkgo/v2" "github.com/fatedier/frp/test/e2e/framework" "github.com/fatedier/frp/test/e2e/framework/consts" "github.com/fatedier/frp/test/e2e/pkg/port" "github.com/fatedier/frp/test/e2e/pkg/request" ) var _ = ginkgo.Describe("[Feature: XTCP]", func() { f := framework.NewDefaultFramework() ginkgo.It("Fallback To STCP", func() { serverConf := consts.LegacyDefaultServerConfig clientConf := consts.LegacyDefaultClientConfig bindPortName := port.GenName("XTCP") clientConf += fmt.Sprintf(` [foo] type = stcp local_port = {{ .%s }} [foo-visitor] type = stcp role = visitor server_name = foo bind_port = -1 [bar-visitor] type = xtcp role = visitor server_name = bar bind_port = {{ .%s }} keep_tunnel_open = true fallback_to = foo-visitor fallback_timeout_ms = 200 `, framework.TCPEchoServerPort, bindPortName) f.RunProcesses(serverConf, []string{clientConf}) framework.NewRequestExpect(f). RequestModify(func(r *request.Request) { r.Timeout(time.Second) }). PortName(bindPortName). Ensure() }) }) ================================================ FILE: test/e2e/legacy/features/bandwidth_limit.go ================================================ package features import ( "fmt" "strings" "time" "github.com/onsi/ginkgo/v2" plugin "github.com/fatedier/frp/pkg/plugin/server" "github.com/fatedier/frp/test/e2e/framework" "github.com/fatedier/frp/test/e2e/framework/consts" "github.com/fatedier/frp/test/e2e/mock/server/streamserver" pluginpkg "github.com/fatedier/frp/test/e2e/pkg/plugin" "github.com/fatedier/frp/test/e2e/pkg/request" ) var _ = ginkgo.Describe("[Feature: Bandwidth Limit]", func() { f := framework.NewDefaultFramework() ginkgo.It("Proxy Bandwidth Limit by Client", func() { serverConf := consts.LegacyDefaultServerConfig clientConf := consts.LegacyDefaultClientConfig localPort := f.AllocPort() localServer := streamserver.New(streamserver.TCP, streamserver.WithBindPort(localPort)) f.RunServer("", localServer) remotePort := f.AllocPort() clientConf += fmt.Sprintf(` [tcp] type = tcp local_port = %d remote_port = %d bandwidth_limit = 10KB `, localPort, remotePort) f.RunProcesses(serverConf, []string{clientConf}) content := strings.Repeat("a", 50*1024) // 5KB start := time.Now() framework.NewRequestExpect(f).Port(remotePort).RequestModify(func(r *request.Request) { r.Body([]byte(content)).Timeout(30 * time.Second) }).ExpectResp([]byte(content)).Ensure() duration := time.Since(start) framework.Logf("request duration: %s", duration.String()) framework.ExpectTrue(duration.Seconds() > 8, "100Kb with 10KB limit, want > 8 seconds, but got %s", duration.String()) }) ginkgo.It("Proxy Bandwidth Limit by Server", func() { // new test plugin server newFunc := func() *plugin.Request { var r plugin.Request r.Content = &plugin.NewProxyContent{} return &r } pluginPort := f.AllocPort() handler := func(req *plugin.Request) *plugin.Response { var ret plugin.Response content := req.Content.(*plugin.NewProxyContent) content.BandwidthLimit = "10KB" content.BandwidthLimitMode = "server" ret.Content = content return &ret } pluginServer := pluginpkg.NewHTTPPluginServer(pluginPort, newFunc, handler, nil) f.RunServer("", pluginServer) serverConf := consts.LegacyDefaultServerConfig + fmt.Sprintf(` [plugin.test] addr = 127.0.0.1:%d path = /handler ops = NewProxy `, pluginPort) clientConf := consts.LegacyDefaultClientConfig localPort := f.AllocPort() localServer := streamserver.New(streamserver.TCP, streamserver.WithBindPort(localPort)) f.RunServer("", localServer) remotePort := f.AllocPort() clientConf += fmt.Sprintf(` [tcp] type = tcp local_port = %d remote_port = %d `, localPort, remotePort) f.RunProcesses(serverConf, []string{clientConf}) content := strings.Repeat("a", 50*1024) // 5KB start := time.Now() framework.NewRequestExpect(f).Port(remotePort).RequestModify(func(r *request.Request) { r.Body([]byte(content)).Timeout(30 * time.Second) }).ExpectResp([]byte(content)).Ensure() duration := time.Since(start) framework.Logf("request duration: %s", duration.String()) framework.ExpectTrue(duration.Seconds() > 8, "100Kb with 10KB limit, want > 8 seconds, but got %s", duration.String()) }) }) ================================================ FILE: test/e2e/legacy/features/chaos.go ================================================ package features import ( "fmt" "time" "github.com/onsi/ginkgo/v2" "github.com/fatedier/frp/test/e2e/framework" ) var _ = ginkgo.Describe("[Feature: Chaos]", func() { f := framework.NewDefaultFramework() ginkgo.It("reconnect after frps restart", func() { serverPort := f.AllocPort() serverConfigPath := f.GenerateConfigFile(fmt.Sprintf(` [common] bind_addr = 0.0.0.0 bind_port = %d `, serverPort)) remotePort := f.AllocPort() clientConfigPath := f.GenerateConfigFile(fmt.Sprintf(` [common] server_port = %d log_level = trace [tcp] type = tcp local_port = %d remote_port = %d `, serverPort, f.PortByName(framework.TCPEchoServerPort), remotePort)) // 1. start frps and frpc, expect request success ps, _, err := f.RunFrps("-c", serverConfigPath) framework.ExpectNoError(err) pc, _, err := f.RunFrpc("-c", clientConfigPath) framework.ExpectNoError(err) framework.NewRequestExpect(f).Port(remotePort).Ensure() // 2. stop frps, expect request failed _ = ps.Stop() time.Sleep(200 * time.Millisecond) framework.NewRequestExpect(f).Port(remotePort).ExpectError(true).Ensure() // 3. restart frps, expect request success _, _, err = f.RunFrps("-c", serverConfigPath) framework.ExpectNoError(err) time.Sleep(2 * time.Second) framework.NewRequestExpect(f).Port(remotePort).Ensure() // 4. stop frpc, expect request failed _ = pc.Stop() time.Sleep(200 * time.Millisecond) framework.NewRequestExpect(f).Port(remotePort).ExpectError(true).Ensure() // 5. restart frpc, expect request success _, _, err = f.RunFrpc("-c", clientConfigPath) framework.ExpectNoError(err) time.Sleep(time.Second) framework.NewRequestExpect(f).Port(remotePort).Ensure() }) }) ================================================ FILE: test/e2e/legacy/features/group.go ================================================ package features import ( "fmt" "strconv" "sync" "time" "github.com/onsi/ginkgo/v2" "github.com/fatedier/frp/test/e2e/framework" "github.com/fatedier/frp/test/e2e/framework/consts" "github.com/fatedier/frp/test/e2e/mock/server/httpserver" "github.com/fatedier/frp/test/e2e/mock/server/streamserver" "github.com/fatedier/frp/test/e2e/pkg/request" ) var _ = ginkgo.Describe("[Feature: Group]", func() { f := framework.NewDefaultFramework() newHTTPServer := func(port int, respContent string) *httpserver.Server { return httpserver.New( httpserver.WithBindPort(port), httpserver.WithHandler(framework.SpecifiedHTTPBodyHandler([]byte(respContent))), ) } validateFooBarResponse := func(resp *request.Response) bool { if string(resp.Content) == "foo" || string(resp.Content) == "bar" { return true } return false } doFooBarHTTPRequest := func(vhostPort int, host string) []string { results := []string{} var wait sync.WaitGroup var mu sync.Mutex expectFn := func() { framework.NewRequestExpect(f).Port(vhostPort). RequestModify(func(r *request.Request) { r.HTTP().HTTPHost(host) }). Ensure(validateFooBarResponse, func(resp *request.Response) bool { mu.Lock() defer mu.Unlock() results = append(results, string(resp.Content)) return true }) } for range 10 { wait.Go(func() { expectFn() }) } wait.Wait() return results } ginkgo.Describe("Load Balancing", func() { ginkgo.It("TCP", func() { serverConf := consts.LegacyDefaultServerConfig clientConf := consts.LegacyDefaultClientConfig fooPort := f.AllocPort() fooServer := streamserver.New(streamserver.TCP, streamserver.WithBindPort(fooPort), streamserver.WithRespContent([]byte("foo"))) f.RunServer("", fooServer) barPort := f.AllocPort() barServer := streamserver.New(streamserver.TCP, streamserver.WithBindPort(barPort), streamserver.WithRespContent([]byte("bar"))) f.RunServer("", barServer) remotePort := f.AllocPort() clientConf += fmt.Sprintf(` [foo] type = tcp local_port = %d remote_port = %d group = test group_key = 123 [bar] type = tcp local_port = %d remote_port = %d group = test group_key = 123 `, fooPort, remotePort, barPort, remotePort) f.RunProcesses(serverConf, []string{clientConf}) fooCount := 0 barCount := 0 for i := range 10 { framework.NewRequestExpect(f).Explain("times " + strconv.Itoa(i)).Port(remotePort).Ensure(func(resp *request.Response) bool { switch string(resp.Content) { case "foo": fooCount++ case "bar": barCount++ default: return false } return true }) } framework.ExpectTrue(fooCount > 1 && barCount > 1, "fooCount: %d, barCount: %d", fooCount, barCount) }) }) ginkgo.Describe("Health Check", func() { ginkgo.It("TCP", func() { serverConf := consts.LegacyDefaultServerConfig clientConf := consts.LegacyDefaultClientConfig fooPort := f.AllocPort() fooServer := streamserver.New(streamserver.TCP, streamserver.WithBindPort(fooPort), streamserver.WithRespContent([]byte("foo"))) f.RunServer("", fooServer) barPort := f.AllocPort() barServer := streamserver.New(streamserver.TCP, streamserver.WithBindPort(barPort), streamserver.WithRespContent([]byte("bar"))) f.RunServer("", barServer) remotePort := f.AllocPort() clientConf += fmt.Sprintf(` [foo] type = tcp local_port = %d remote_port = %d group = test group_key = 123 health_check_type = tcp health_check_interval_s = 1 [bar] type = tcp local_port = %d remote_port = %d group = test group_key = 123 health_check_type = tcp health_check_interval_s = 1 `, fooPort, remotePort, barPort, remotePort) f.RunProcesses(serverConf, []string{clientConf}) // check foo and bar is ok results := []string{} for range 10 { framework.NewRequestExpect(f).Port(remotePort).Ensure(validateFooBarResponse, func(resp *request.Response) bool { results = append(results, string(resp.Content)) return true }) } framework.ExpectContainElements(results, []string{"foo", "bar"}) // close bar server, check foo is ok barServer.Close() time.Sleep(2 * time.Second) for range 10 { framework.NewRequestExpect(f).Port(remotePort).ExpectResp([]byte("foo")).Ensure() } // resume bar server, check foo and bar is ok f.RunServer("", barServer) time.Sleep(2 * time.Second) results = []string{} for range 10 { framework.NewRequestExpect(f).Port(remotePort).Ensure(validateFooBarResponse, func(resp *request.Response) bool { results = append(results, string(resp.Content)) return true }) } framework.ExpectContainElements(results, []string{"foo", "bar"}) }) ginkgo.It("HTTP", func() { vhostPort := f.AllocPort() serverConf := consts.LegacyDefaultServerConfig + fmt.Sprintf(` vhost_http_port = %d `, vhostPort) clientConf := consts.LegacyDefaultClientConfig fooPort := f.AllocPort() fooServer := newHTTPServer(fooPort, "foo") f.RunServer("", fooServer) barPort := f.AllocPort() barServer := newHTTPServer(barPort, "bar") f.RunServer("", barServer) clientConf += fmt.Sprintf(` [foo] type = http local_port = %d custom_domains = example.com group = test group_key = 123 health_check_type = http health_check_interval_s = 1 health_check_url = /healthz [bar] type = http local_port = %d custom_domains = example.com group = test group_key = 123 health_check_type = http health_check_interval_s = 1 health_check_url = /healthz `, fooPort, barPort) f.RunProcesses(serverConf, []string{clientConf}) // send first HTTP request var contents []string framework.NewRequestExpect(f).Port(vhostPort). RequestModify(func(r *request.Request) { r.HTTP().HTTPHost("example.com") }). Ensure(func(resp *request.Response) bool { contents = append(contents, string(resp.Content)) return true }) // send second HTTP request, should be forwarded to another service framework.NewRequestExpect(f).Port(vhostPort). RequestModify(func(r *request.Request) { r.HTTP().HTTPHost("example.com") }). Ensure(func(resp *request.Response) bool { contents = append(contents, string(resp.Content)) return true }) framework.ExpectContainElements(contents, []string{"foo", "bar"}) // check foo and bar is ok results := doFooBarHTTPRequest(vhostPort, "example.com") framework.ExpectContainElements(results, []string{"foo", "bar"}) // close bar server, check foo is ok barServer.Close() time.Sleep(2 * time.Second) results = doFooBarHTTPRequest(vhostPort, "example.com") framework.ExpectContainElements(results, []string{"foo"}) framework.ExpectNotContainElements(results, []string{"bar"}) // resume bar server, check foo and bar is ok f.RunServer("", barServer) time.Sleep(2 * time.Second) results = doFooBarHTTPRequest(vhostPort, "example.com") framework.ExpectContainElements(results, []string{"foo", "bar"}) }) }) }) ================================================ FILE: test/e2e/legacy/features/heartbeat.go ================================================ package features import ( "fmt" "time" "github.com/onsi/ginkgo/v2" "github.com/fatedier/frp/test/e2e/framework" ) var _ = ginkgo.Describe("[Feature: Heartbeat]", func() { f := framework.NewDefaultFramework() ginkgo.It("disable application layer heartbeat", func() { serverPort := f.AllocPort() serverConf := fmt.Sprintf(` [common] bind_addr = 0.0.0.0 bind_port = %d heartbeat_timeout = -1 tcp_mux_keepalive_interval = 2 `, serverPort) remotePort := f.AllocPort() clientConf := fmt.Sprintf(` [common] server_port = %d log_level = trace heartbeat_interval = -1 heartbeat_timeout = -1 tcp_mux_keepalive_interval = 2 [tcp] type = tcp local_port = %d remote_port = %d `, serverPort, f.PortByName(framework.TCPEchoServerPort), remotePort) // run frps and frpc f.RunProcesses(serverConf, []string{clientConf}) framework.NewRequestExpect(f).Protocol("tcp").Port(remotePort).Ensure() time.Sleep(5 * time.Second) framework.NewRequestExpect(f).Protocol("tcp").Port(remotePort).Ensure() }) }) ================================================ FILE: test/e2e/legacy/features/monitor.go ================================================ package features import ( "fmt" "strings" "time" "github.com/onsi/ginkgo/v2" "github.com/fatedier/frp/pkg/util/log" "github.com/fatedier/frp/test/e2e/framework" "github.com/fatedier/frp/test/e2e/framework/consts" "github.com/fatedier/frp/test/e2e/pkg/request" ) var _ = ginkgo.Describe("[Feature: Monitor]", func() { f := framework.NewDefaultFramework() ginkgo.It("Prometheus metrics", func() { dashboardPort := f.AllocPort() serverConf := consts.LegacyDefaultServerConfig + fmt.Sprintf(` enable_prometheus = true dashboard_addr = 0.0.0.0 dashboard_port = %d `, dashboardPort) clientConf := consts.LegacyDefaultClientConfig remotePort := f.AllocPort() clientConf += fmt.Sprintf(` [tcp] type = tcp local_port = {{ .%s }} remote_port = %d `, framework.TCPEchoServerPort, remotePort) f.RunProcesses(serverConf, []string{clientConf}) framework.NewRequestExpect(f).Port(remotePort).Ensure() time.Sleep(500 * time.Millisecond) framework.NewRequestExpect(f).RequestModify(func(r *request.Request) { r.HTTP().Port(dashboardPort).HTTPPath("/metrics") }).Ensure(func(resp *request.Response) bool { log.Tracef("prometheus metrics response: \n%s", resp.Content) if resp.Code != 200 { return false } if !strings.Contains(string(resp.Content), "traffic_in") { return false } return true }) }) }) ================================================ FILE: test/e2e/legacy/features/real_ip.go ================================================ package features import ( "bufio" "fmt" "net" "net/http" "github.com/onsi/ginkgo/v2" pp "github.com/pires/go-proxyproto" "github.com/fatedier/frp/pkg/util/log" "github.com/fatedier/frp/test/e2e/framework" "github.com/fatedier/frp/test/e2e/framework/consts" "github.com/fatedier/frp/test/e2e/mock/server/httpserver" "github.com/fatedier/frp/test/e2e/mock/server/streamserver" "github.com/fatedier/frp/test/e2e/pkg/request" "github.com/fatedier/frp/test/e2e/pkg/rpc" ) var _ = ginkgo.Describe("[Feature: Real IP]", func() { f := framework.NewDefaultFramework() ginkgo.It("HTTP X-Forwarded-For", func() { vhostHTTPPort := f.AllocPort() serverConf := consts.LegacyDefaultServerConfig + fmt.Sprintf(` vhost_http_port = %d `, vhostHTTPPort) localPort := f.AllocPort() localServer := httpserver.New( httpserver.WithBindPort(localPort), httpserver.WithHandler(http.HandlerFunc(func(w http.ResponseWriter, req *http.Request) { _, _ = w.Write([]byte(req.Header.Get("X-Forwarded-For"))) })), ) f.RunServer("", localServer) clientConf := consts.LegacyDefaultClientConfig clientConf += fmt.Sprintf(` [test] type = http local_port = %d custom_domains = normal.example.com `, localPort) f.RunProcesses(serverConf, []string{clientConf}) framework.NewRequestExpect(f).Port(vhostHTTPPort). RequestModify(func(r *request.Request) { r.HTTP().HTTPHost("normal.example.com") }). ExpectResp([]byte("127.0.0.1")). Ensure() }) ginkgo.Describe("Proxy Protocol", func() { ginkgo.It("TCP", func() { serverConf := consts.LegacyDefaultServerConfig clientConf := consts.LegacyDefaultClientConfig localPort := f.AllocPort() localServer := streamserver.New(streamserver.TCP, streamserver.WithBindPort(localPort), streamserver.WithCustomHandler(func(c net.Conn) { defer c.Close() rd := bufio.NewReader(c) ppHeader, err := pp.Read(rd) if err != nil { log.Errorf("read proxy protocol error: %v", err) return } for { if _, err := rpc.ReadBytes(rd); err != nil { return } buf := []byte(ppHeader.SourceAddr.String()) _, _ = rpc.WriteBytes(c, buf) } })) f.RunServer("", localServer) remotePort := f.AllocPort() clientConf += fmt.Sprintf(` [tcp] type = tcp local_port = %d remote_port = %d proxy_protocol_version = v2 `, localPort, remotePort) f.RunProcesses(serverConf, []string{clientConf}) framework.NewRequestExpect(f).Port(remotePort).Ensure(func(resp *request.Response) bool { log.Tracef("proxy protocol get SourceAddr: %s", string(resp.Content)) addr, err := net.ResolveTCPAddr("tcp", string(resp.Content)) if err != nil { return false } if addr.IP.String() != "127.0.0.1" { return false } return true }) }) ginkgo.It("HTTP", func() { vhostHTTPPort := f.AllocPort() serverConf := consts.LegacyDefaultServerConfig + fmt.Sprintf(` vhost_http_port = %d `, vhostHTTPPort) clientConf := consts.LegacyDefaultClientConfig localPort := f.AllocPort() var srcAddrRecord string localServer := streamserver.New(streamserver.TCP, streamserver.WithBindPort(localPort), streamserver.WithCustomHandler(func(c net.Conn) { defer c.Close() rd := bufio.NewReader(c) ppHeader, err := pp.Read(rd) if err != nil { log.Errorf("read proxy protocol error: %v", err) return } srcAddrRecord = ppHeader.SourceAddr.String() })) f.RunServer("", localServer) clientConf += fmt.Sprintf(` [test] type = http local_port = %d custom_domains = normal.example.com proxy_protocol_version = v2 `, localPort) f.RunProcesses(serverConf, []string{clientConf}) framework.NewRequestExpect(f).Port(vhostHTTPPort).RequestModify(func(r *request.Request) { r.HTTP().HTTPHost("normal.example.com") }).Ensure(framework.ExpectResponseCode(404)) log.Tracef("proxy protocol get SourceAddr: %s", srcAddrRecord) addr, err := net.ResolveTCPAddr("tcp", srcAddrRecord) framework.ExpectNoError(err, srcAddrRecord) framework.ExpectEqualValues("127.0.0.1", addr.IP.String()) }) }) }) ================================================ FILE: test/e2e/legacy/plugin/client.go ================================================ package plugin import ( "crypto/tls" "fmt" "strconv" "strings" "github.com/onsi/ginkgo/v2" "github.com/fatedier/frp/pkg/transport" "github.com/fatedier/frp/test/e2e/framework" "github.com/fatedier/frp/test/e2e/framework/consts" "github.com/fatedier/frp/test/e2e/mock/server/httpserver" "github.com/fatedier/frp/test/e2e/pkg/cert" "github.com/fatedier/frp/test/e2e/pkg/port" "github.com/fatedier/frp/test/e2e/pkg/request" ) var _ = ginkgo.Describe("[Feature: Client-Plugins]", func() { f := framework.NewDefaultFramework() ginkgo.Describe("UnixDomainSocket", func() { ginkgo.It("Expose a unix domain socket echo server", func() { serverConf := consts.LegacyDefaultServerConfig var clientConf strings.Builder clientConf.WriteString(consts.LegacyDefaultClientConfig) getProxyConf := func(proxyName string, portName string, extra string) string { return fmt.Sprintf(` [%s] type = tcp remote_port = {{ .%s }} plugin = unix_domain_socket plugin_unix_path = {{ .%s }} `+extra, proxyName, portName, framework.UDSEchoServerAddr) } tests := []struct { proxyName string portName string extraConfig string }{ { proxyName: "normal", portName: port.GenName("Normal"), }, { proxyName: "with-encryption", portName: port.GenName("WithEncryption"), extraConfig: "use_encryption = true", }, { proxyName: "with-compression", portName: port.GenName("WithCompression"), extraConfig: "use_compression = true", }, { proxyName: "with-encryption-and-compression", portName: port.GenName("WithEncryptionAndCompression"), extraConfig: ` use_encryption = true use_compression = true `, }, } // build all client config for _, test := range tests { clientConf.WriteString(getProxyConf(test.proxyName, test.portName, test.extraConfig) + "\n") } // run frps and frpc f.RunProcesses(serverConf, []string{clientConf.String()}) for _, test := range tests { framework.NewRequestExpect(f).Port(f.PortByName(test.portName)).Ensure() } }) }) ginkgo.It("http_proxy", func() { serverConf := consts.LegacyDefaultServerConfig clientConf := consts.LegacyDefaultClientConfig remotePort := f.AllocPort() clientConf += fmt.Sprintf(` [tcp] type = tcp remote_port = %d plugin = http_proxy plugin_http_user = abc plugin_http_passwd = 123 `, remotePort) f.RunProcesses(serverConf, []string{clientConf}) // http proxy, no auth info framework.NewRequestExpect(f).PortName(framework.HTTPSimpleServerPort).RequestModify(func(r *request.Request) { r.HTTP().Proxy("http://127.0.0.1:" + strconv.Itoa(remotePort)) }).Ensure(framework.ExpectResponseCode(407)) // http proxy, correct auth framework.NewRequestExpect(f).PortName(framework.HTTPSimpleServerPort).RequestModify(func(r *request.Request) { r.HTTP().Proxy("http://abc:123@127.0.0.1:" + strconv.Itoa(remotePort)) }).Ensure() // connect TCP server by CONNECT method framework.NewRequestExpect(f).PortName(framework.TCPEchoServerPort).RequestModify(func(r *request.Request) { r.TCP().Proxy("http://abc:123@127.0.0.1:" + strconv.Itoa(remotePort)) }) }) ginkgo.It("socks5 proxy", func() { serverConf := consts.LegacyDefaultServerConfig clientConf := consts.LegacyDefaultClientConfig remotePort := f.AllocPort() clientConf += fmt.Sprintf(` [tcp] type = tcp remote_port = %d plugin = socks5 plugin_user = abc plugin_passwd = 123 `, remotePort) f.RunProcesses(serverConf, []string{clientConf}) // http proxy, no auth info framework.NewRequestExpect(f).PortName(framework.TCPEchoServerPort).RequestModify(func(r *request.Request) { r.TCP().Proxy("socks5://127.0.0.1:" + strconv.Itoa(remotePort)) }).ExpectError(true).Ensure() // http proxy, correct auth framework.NewRequestExpect(f).PortName(framework.TCPEchoServerPort).RequestModify(func(r *request.Request) { r.TCP().Proxy("socks5://abc:123@127.0.0.1:" + strconv.Itoa(remotePort)) }).Ensure() }) ginkgo.It("static_file", func() { vhostPort := f.AllocPort() serverConf := consts.LegacyDefaultServerConfig + fmt.Sprintf(` vhost_http_port = %d `, vhostPort) clientConf := consts.LegacyDefaultClientConfig remotePort := f.AllocPort() f.WriteTempFile("test_static_file", "foo") clientConf += fmt.Sprintf(` [tcp] type = tcp remote_port = %d plugin = static_file plugin_local_path = %s [http] type = http custom_domains = example.com plugin = static_file plugin_local_path = %s [http-with-auth] type = http custom_domains = other.example.com plugin = static_file plugin_local_path = %s plugin_http_user = abc plugin_http_passwd = 123 `, remotePort, f.TempDirectory, f.TempDirectory, f.TempDirectory) f.RunProcesses(serverConf, []string{clientConf}) // from tcp proxy framework.NewRequestExpect(f).Request( framework.NewHTTPRequest().HTTPPath("/test_static_file").Port(remotePort), ).ExpectResp([]byte("foo")).Ensure() // from http proxy without auth framework.NewRequestExpect(f).Request( framework.NewHTTPRequest().HTTPHost("example.com").HTTPPath("/test_static_file").Port(vhostPort), ).ExpectResp([]byte("foo")).Ensure() // from http proxy with auth framework.NewRequestExpect(f).Request( framework.NewHTTPRequest().HTTPHost("other.example.com").HTTPPath("/test_static_file").Port(vhostPort).HTTPAuth("abc", "123"), ).ExpectResp([]byte("foo")).Ensure() }) ginkgo.It("http2https", func() { serverConf := consts.LegacyDefaultServerConfig vhostHTTPPort := f.AllocPort() serverConf += fmt.Sprintf(` vhost_http_port = %d `, vhostHTTPPort) localPort := f.AllocPort() clientConf := consts.LegacyDefaultClientConfig + fmt.Sprintf(` [http2https] type = http custom_domains = example.com plugin = http2https plugin_local_addr = 127.0.0.1:%d `, localPort) f.RunProcesses(serverConf, []string{clientConf}) tlsConfig, err := transport.NewServerTLSConfig("", "", "") framework.ExpectNoError(err) localServer := httpserver.New( httpserver.WithBindPort(localPort), httpserver.WithTLSConfig(tlsConfig), httpserver.WithResponse([]byte("test")), ) f.RunServer("", localServer) framework.NewRequestExpect(f). Port(vhostHTTPPort). RequestModify(func(r *request.Request) { r.HTTP().HTTPHost("example.com") }). ExpectResp([]byte("test")). Ensure() }) ginkgo.It("https2http", func() { generator := &cert.SelfSignedCertGenerator{} artifacts, err := generator.Generate("example.com") framework.ExpectNoError(err) crtPath := f.WriteTempFile("server.crt", string(artifacts.Cert)) keyPath := f.WriteTempFile("server.key", string(artifacts.Key)) serverConf := consts.LegacyDefaultServerConfig vhostHTTPSPort := f.AllocPort() serverConf += fmt.Sprintf(` vhost_https_port = %d `, vhostHTTPSPort) localPort := f.AllocPort() clientConf := consts.LegacyDefaultClientConfig + fmt.Sprintf(` [https2http] type = https custom_domains = example.com plugin = https2http plugin_local_addr = 127.0.0.1:%d plugin_crt_path = %s plugin_key_path = %s `, localPort, crtPath, keyPath) f.RunProcesses(serverConf, []string{clientConf}) localServer := httpserver.New( httpserver.WithBindPort(localPort), httpserver.WithResponse([]byte("test")), ) f.RunServer("", localServer) framework.NewRequestExpect(f). Port(vhostHTTPSPort). RequestModify(func(r *request.Request) { r.HTTPS().HTTPHost("example.com").TLSConfig(&tls.Config{ ServerName: "example.com", InsecureSkipVerify: true, }) }). ExpectResp([]byte("test")). Ensure() }) ginkgo.It("https2https", func() { generator := &cert.SelfSignedCertGenerator{} artifacts, err := generator.Generate("example.com") framework.ExpectNoError(err) crtPath := f.WriteTempFile("server.crt", string(artifacts.Cert)) keyPath := f.WriteTempFile("server.key", string(artifacts.Key)) serverConf := consts.LegacyDefaultServerConfig vhostHTTPSPort := f.AllocPort() serverConf += fmt.Sprintf(` vhost_https_port = %d `, vhostHTTPSPort) localPort := f.AllocPort() clientConf := consts.LegacyDefaultClientConfig + fmt.Sprintf(` [https2https] type = https custom_domains = example.com plugin = https2https plugin_local_addr = 127.0.0.1:%d plugin_crt_path = %s plugin_key_path = %s `, localPort, crtPath, keyPath) f.RunProcesses(serverConf, []string{clientConf}) tlsConfig, err := transport.NewServerTLSConfig("", "", "") framework.ExpectNoError(err) localServer := httpserver.New( httpserver.WithBindPort(localPort), httpserver.WithResponse([]byte("test")), httpserver.WithTLSConfig(tlsConfig), ) f.RunServer("", localServer) framework.NewRequestExpect(f). Port(vhostHTTPSPort). RequestModify(func(r *request.Request) { r.HTTPS().HTTPHost("example.com").TLSConfig(&tls.Config{ ServerName: "example.com", InsecureSkipVerify: true, }) }). ExpectResp([]byte("test")). Ensure() }) }) ================================================ FILE: test/e2e/legacy/plugin/server.go ================================================ package plugin import ( "fmt" "time" "github.com/onsi/ginkgo/v2" plugin "github.com/fatedier/frp/pkg/plugin/server" "github.com/fatedier/frp/pkg/transport" "github.com/fatedier/frp/test/e2e/framework" "github.com/fatedier/frp/test/e2e/framework/consts" pluginpkg "github.com/fatedier/frp/test/e2e/pkg/plugin" ) var _ = ginkgo.Describe("[Feature: Server-Plugins]", func() { f := framework.NewDefaultFramework() ginkgo.Describe("Login", func() { newFunc := func() *plugin.Request { var r plugin.Request r.Content = &plugin.LoginContent{} return &r } ginkgo.It("Auth for custom meta token", func() { localPort := f.AllocPort() clientAddressGot := false handler := func(req *plugin.Request) *plugin.Response { var ret plugin.Response content := req.Content.(*plugin.LoginContent) if content.ClientAddress != "" { clientAddressGot = true } if content.Metas["token"] == "123" { ret.Unchange = true } else { ret.Reject = true ret.RejectReason = "invalid token" } return &ret } pluginServer := pluginpkg.NewHTTPPluginServer(localPort, newFunc, handler, nil) f.RunServer("", pluginServer) serverConf := consts.LegacyDefaultServerConfig + fmt.Sprintf(` [plugin.user-manager] addr = 127.0.0.1:%d path = /handler ops = Login `, localPort) clientConf := consts.LegacyDefaultClientConfig remotePort := f.AllocPort() clientConf += fmt.Sprintf(` meta_token = 123 [tcp] type = tcp local_port = {{ .%s }} remote_port = %d `, framework.TCPEchoServerPort, remotePort) remotePort2 := f.AllocPort() invalidTokenClientConf := consts.LegacyDefaultClientConfig + fmt.Sprintf(` [tcp2] type = tcp local_port = {{ .%s }} remote_port = %d `, framework.TCPEchoServerPort, remotePort2) f.RunProcesses(serverConf, []string{clientConf, invalidTokenClientConf}) framework.NewRequestExpect(f).Port(remotePort).Ensure() framework.NewRequestExpect(f).Port(remotePort2).ExpectError(true).Ensure() framework.ExpectTrue(clientAddressGot) }) }) ginkgo.Describe("NewProxy", func() { newFunc := func() *plugin.Request { var r plugin.Request r.Content = &plugin.NewProxyContent{} return &r } ginkgo.It("Validate Info", func() { localPort := f.AllocPort() handler := func(req *plugin.Request) *plugin.Response { var ret plugin.Response content := req.Content.(*plugin.NewProxyContent) if content.ProxyName == "tcp" { ret.Unchange = true } else { ret.Reject = true } return &ret } pluginServer := pluginpkg.NewHTTPPluginServer(localPort, newFunc, handler, nil) f.RunServer("", pluginServer) serverConf := consts.LegacyDefaultServerConfig + fmt.Sprintf(` [plugin.test] addr = 127.0.0.1:%d path = /handler ops = NewProxy `, localPort) clientConf := consts.LegacyDefaultClientConfig remotePort := f.AllocPort() clientConf += fmt.Sprintf(` [tcp] type = tcp local_port = {{ .%s }} remote_port = %d `, framework.TCPEchoServerPort, remotePort) f.RunProcesses(serverConf, []string{clientConf}) framework.NewRequestExpect(f).Port(remotePort).Ensure() }) ginkgo.It("Modify RemotePort", func() { localPort := f.AllocPort() remotePort := f.AllocPort() handler := func(req *plugin.Request) *plugin.Response { var ret plugin.Response content := req.Content.(*plugin.NewProxyContent) content.RemotePort = remotePort ret.Content = content return &ret } pluginServer := pluginpkg.NewHTTPPluginServer(localPort, newFunc, handler, nil) f.RunServer("", pluginServer) serverConf := consts.LegacyDefaultServerConfig + fmt.Sprintf(` [plugin.test] addr = 127.0.0.1:%d path = /handler ops = NewProxy `, localPort) clientConf := consts.LegacyDefaultClientConfig clientConf += fmt.Sprintf(` [tcp] type = tcp local_port = {{ .%s }} remote_port = 0 `, framework.TCPEchoServerPort) f.RunProcesses(serverConf, []string{clientConf}) framework.NewRequestExpect(f).Port(remotePort).Ensure() }) }) ginkgo.Describe("CloseProxy", func() { newFunc := func() *plugin.Request { var r plugin.Request r.Content = &plugin.CloseProxyContent{} return &r } ginkgo.It("Validate Info", func() { localPort := f.AllocPort() var recordProxyName string handler := func(req *plugin.Request) *plugin.Response { var ret plugin.Response content := req.Content.(*plugin.CloseProxyContent) recordProxyName = content.ProxyName return &ret } pluginServer := pluginpkg.NewHTTPPluginServer(localPort, newFunc, handler, nil) f.RunServer("", pluginServer) serverConf := consts.LegacyDefaultServerConfig + fmt.Sprintf(` [plugin.test] addr = 127.0.0.1:%d path = /handler ops = CloseProxy `, localPort) clientConf := consts.LegacyDefaultClientConfig remotePort := f.AllocPort() clientConf += fmt.Sprintf(` [tcp] type = tcp local_port = {{ .%s }} remote_port = %d `, framework.TCPEchoServerPort, remotePort) _, clients := f.RunProcesses(serverConf, []string{clientConf}) framework.NewRequestExpect(f).Port(remotePort).Ensure() for _, c := range clients { _ = c.Stop() } time.Sleep(1 * time.Second) framework.ExpectEqual(recordProxyName, "tcp") }) }) ginkgo.Describe("Ping", func() { newFunc := func() *plugin.Request { var r plugin.Request r.Content = &plugin.PingContent{} return &r } ginkgo.It("Validate Info", func() { localPort := f.AllocPort() var record string handler := func(req *plugin.Request) *plugin.Response { var ret plugin.Response content := req.Content.(*plugin.PingContent) record = content.PrivilegeKey ret.Unchange = true return &ret } pluginServer := pluginpkg.NewHTTPPluginServer(localPort, newFunc, handler, nil) f.RunServer("", pluginServer) serverConf := consts.LegacyDefaultServerConfig + fmt.Sprintf(` [plugin.test] addr = 127.0.0.1:%d path = /handler ops = Ping `, localPort) remotePort := f.AllocPort() clientConf := consts.LegacyDefaultClientConfig clientConf += fmt.Sprintf(` heartbeat_interval = 1 authenticate_heartbeats = true [tcp] type = tcp local_port = {{ .%s }} remote_port = %d `, framework.TCPEchoServerPort, remotePort) f.RunProcesses(serverConf, []string{clientConf}) framework.NewRequestExpect(f).Port(remotePort).Ensure() time.Sleep(3 * time.Second) framework.ExpectNotEqual("", record) }) }) ginkgo.Describe("NewWorkConn", func() { newFunc := func() *plugin.Request { var r plugin.Request r.Content = &plugin.NewWorkConnContent{} return &r } ginkgo.It("Validate Info", func() { localPort := f.AllocPort() var record string handler := func(req *plugin.Request) *plugin.Response { var ret plugin.Response content := req.Content.(*plugin.NewWorkConnContent) record = content.RunID ret.Unchange = true return &ret } pluginServer := pluginpkg.NewHTTPPluginServer(localPort, newFunc, handler, nil) f.RunServer("", pluginServer) serverConf := consts.LegacyDefaultServerConfig + fmt.Sprintf(` [plugin.test] addr = 127.0.0.1:%d path = /handler ops = NewWorkConn `, localPort) remotePort := f.AllocPort() clientConf := consts.LegacyDefaultClientConfig clientConf += fmt.Sprintf(` [tcp] type = tcp local_port = {{ .%s }} remote_port = %d `, framework.TCPEchoServerPort, remotePort) f.RunProcesses(serverConf, []string{clientConf}) framework.NewRequestExpect(f).Port(remotePort).Ensure() framework.ExpectNotEqual("", record) }) }) ginkgo.Describe("NewUserConn", func() { newFunc := func() *plugin.Request { var r plugin.Request r.Content = &plugin.NewUserConnContent{} return &r } ginkgo.It("Validate Info", func() { localPort := f.AllocPort() var record string handler := func(req *plugin.Request) *plugin.Response { var ret plugin.Response content := req.Content.(*plugin.NewUserConnContent) record = content.RemoteAddr ret.Unchange = true return &ret } pluginServer := pluginpkg.NewHTTPPluginServer(localPort, newFunc, handler, nil) f.RunServer("", pluginServer) serverConf := consts.LegacyDefaultServerConfig + fmt.Sprintf(` [plugin.test] addr = 127.0.0.1:%d path = /handler ops = NewUserConn `, localPort) remotePort := f.AllocPort() clientConf := consts.LegacyDefaultClientConfig clientConf += fmt.Sprintf(` [tcp] type = tcp local_port = {{ .%s }} remote_port = %d `, framework.TCPEchoServerPort, remotePort) f.RunProcesses(serverConf, []string{clientConf}) framework.NewRequestExpect(f).Port(remotePort).Ensure() framework.ExpectNotEqual("", record) }) }) ginkgo.Describe("HTTPS Protocol", func() { newFunc := func() *plugin.Request { var r plugin.Request r.Content = &plugin.NewUserConnContent{} return &r } ginkgo.It("Validate Login Info, disable tls verify", func() { localPort := f.AllocPort() var record string handler := func(req *plugin.Request) *plugin.Response { var ret plugin.Response content := req.Content.(*plugin.NewUserConnContent) record = content.RemoteAddr ret.Unchange = true return &ret } tlsConfig, err := transport.NewServerTLSConfig("", "", "") framework.ExpectNoError(err) pluginServer := pluginpkg.NewHTTPPluginServer(localPort, newFunc, handler, tlsConfig) f.RunServer("", pluginServer) serverConf := consts.LegacyDefaultServerConfig + fmt.Sprintf(` [plugin.test] addr = https://127.0.0.1:%d path = /handler ops = NewUserConn `, localPort) remotePort := f.AllocPort() clientConf := consts.LegacyDefaultClientConfig clientConf += fmt.Sprintf(` [tcp] type = tcp local_port = {{ .%s }} remote_port = %d `, framework.TCPEchoServerPort, remotePort) f.RunProcesses(serverConf, []string{clientConf}) framework.NewRequestExpect(f).Port(remotePort).Ensure() framework.ExpectNotEqual("", record) }) }) }) ================================================ FILE: test/e2e/mock/server/httpserver/server.go ================================================ package httpserver import ( "crypto/tls" "net" "net/http" "strconv" "time" ) type Server struct { bindAddr string bindPort int handler http.Handler l net.Listener tlsConfig *tls.Config hs *http.Server } type Option func(*Server) *Server func New(options ...Option) *Server { s := &Server{ bindAddr: "127.0.0.1", } for _, option := range options { s = option(s) } return s } func WithBindAddr(addr string) Option { return func(s *Server) *Server { s.bindAddr = addr return s } } func WithBindPort(port int) Option { return func(s *Server) *Server { s.bindPort = port return s } } func WithTLSConfig(tlsConfig *tls.Config) Option { return func(s *Server) *Server { s.tlsConfig = tlsConfig return s } } func WithHandler(h http.Handler) Option { return func(s *Server) *Server { s.handler = h return s } } func WithResponse(resp []byte) Option { return func(s *Server) *Server { s.handler = http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { _, _ = w.Write(resp) }) return s } } func (s *Server) Run() error { if err := s.initListener(); err != nil { return err } addr := net.JoinHostPort(s.bindAddr, strconv.Itoa(s.bindPort)) hs := &http.Server{ Addr: addr, Handler: s.handler, TLSConfig: s.tlsConfig, ReadHeaderTimeout: time.Minute, } s.hs = hs if s.tlsConfig == nil { go func() { _ = hs.Serve(s.l) }() } else { go func() { _ = hs.ServeTLS(s.l, "", "") }() } return nil } func (s *Server) Close() error { if s.hs != nil { return s.hs.Close() } return nil } func (s *Server) initListener() (err error) { s.l, err = net.Listen("tcp", net.JoinHostPort(s.bindAddr, strconv.Itoa(s.bindPort))) return } func (s *Server) BindAddr() string { return s.bindAddr } func (s *Server) BindPort() int { return s.bindPort } ================================================ FILE: test/e2e/mock/server/interface.go ================================================ package server type Server interface { Run() error Close() error BindAddr() string BindPort() int } ================================================ FILE: test/e2e/mock/server/oidcserver/oidcserver.go ================================================ // Copyright 2026 The frp Authors // // Licensed under the Apache License, Version 2.0 (the "License"); // you may not use this file except in compliance with the License. // You may obtain a copy of the License at // // http://www.apache.org/licenses/LICENSE-2.0 // // Unless required by applicable law or agreed to in writing, software // distributed under the License is distributed on an "AS IS" BASIS, // WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. // See the License for the specific language governing permissions and // limitations under the License. // Package oidcserver provides a minimal mock OIDC server for e2e testing. // It implements three endpoints: // - /.well-known/openid-configuration (discovery) // - /jwks (JSON Web Key Set) // - /token (client_credentials grant) package oidcserver import ( "crypto" "crypto/rand" "crypto/rsa" "crypto/sha256" "encoding/base64" "encoding/json" "fmt" "math/big" "net" "net/http" "strconv" "sync/atomic" "time" ) type Server struct { bindAddr string bindPort int l net.Listener hs *http.Server privateKey *rsa.PrivateKey kid string clientID string clientSecret string audience string subject string expiresIn int // seconds; 0 means omit expires_in from token response tokenRequestCount atomic.Int64 } type Option func(*Server) func WithBindPort(port int) Option { return func(s *Server) { s.bindPort = port } } func WithClientCredentials(id, secret string) Option { return func(s *Server) { s.clientID = id s.clientSecret = secret } } func WithAudience(aud string) Option { return func(s *Server) { s.audience = aud } } func WithSubject(sub string) Option { return func(s *Server) { s.subject = sub } } func WithExpiresIn(seconds int) Option { return func(s *Server) { s.expiresIn = seconds } } func New(options ...Option) *Server { s := &Server{ bindAddr: "127.0.0.1", kid: "test-key-1", clientID: "test-client", clientSecret: "test-secret", audience: "frps", subject: "test-service", expiresIn: 3600, } for _, opt := range options { opt(s) } return s } func (s *Server) Run() error { key, err := rsa.GenerateKey(rand.Reader, 2048) if err != nil { return fmt.Errorf("generate RSA key: %w", err) } s.privateKey = key s.l, err = net.Listen("tcp", net.JoinHostPort(s.bindAddr, strconv.Itoa(s.bindPort))) if err != nil { return err } s.bindPort = s.l.Addr().(*net.TCPAddr).Port mux := http.NewServeMux() mux.HandleFunc("/.well-known/openid-configuration", s.handleDiscovery) mux.HandleFunc("/jwks", s.handleJWKS) mux.HandleFunc("/token", s.handleToken) s.hs = &http.Server{ Handler: mux, ReadHeaderTimeout: time.Minute, } go func() { _ = s.hs.Serve(s.l) }() return nil } func (s *Server) Close() error { if s.hs != nil { return s.hs.Close() } return nil } func (s *Server) BindAddr() string { return s.bindAddr } func (s *Server) BindPort() int { return s.bindPort } func (s *Server) Issuer() string { return fmt.Sprintf("http://%s:%d", s.bindAddr, s.bindPort) } func (s *Server) TokenEndpoint() string { return s.Issuer() + "/token" } // TokenRequestCount returns the number of successful token requests served. func (s *Server) TokenRequestCount() int64 { return s.tokenRequestCount.Load() } func (s *Server) handleDiscovery(w http.ResponseWriter, _ *http.Request) { issuer := s.Issuer() w.Header().Set("Content-Type", "application/json") _ = json.NewEncoder(w).Encode(map[string]any{ "issuer": issuer, "token_endpoint": issuer + "/token", "jwks_uri": issuer + "/jwks", "response_types_supported": []string{"code"}, "subject_types_supported": []string{"public"}, "id_token_signing_alg_values_supported": []string{"RS256"}, }) } func (s *Server) handleJWKS(w http.ResponseWriter, _ *http.Request) { pub := &s.privateKey.PublicKey w.Header().Set("Content-Type", "application/json") _ = json.NewEncoder(w).Encode(map[string]any{ "keys": []map[string]any{ { "kty": "RSA", "alg": "RS256", "use": "sig", "kid": s.kid, "n": base64.RawURLEncoding.EncodeToString(pub.N.Bytes()), "e": base64.RawURLEncoding.EncodeToString(big.NewInt(int64(pub.E)).Bytes()), }, }, }) } func (s *Server) handleToken(w http.ResponseWriter, r *http.Request) { if r.Method != http.MethodPost { http.Error(w, "method not allowed", http.StatusMethodNotAllowed) return } if err := r.ParseForm(); err != nil { w.Header().Set("Content-Type", "application/json") w.WriteHeader(http.StatusBadRequest) _ = json.NewEncoder(w).Encode(map[string]any{ "error": "invalid_request", }) return } if r.FormValue("grant_type") != "client_credentials" { w.Header().Set("Content-Type", "application/json") w.WriteHeader(http.StatusBadRequest) _ = json.NewEncoder(w).Encode(map[string]any{ "error": "unsupported_grant_type", }) return } // Accept credentials from Basic Auth or form body. clientID, clientSecret, ok := r.BasicAuth() if !ok { clientID = r.FormValue("client_id") clientSecret = r.FormValue("client_secret") } if clientID != s.clientID || clientSecret != s.clientSecret { w.Header().Set("Content-Type", "application/json") w.WriteHeader(http.StatusUnauthorized) _ = json.NewEncoder(w).Encode(map[string]any{ "error": "invalid_client", }) return } token, err := s.signJWT() if err != nil { http.Error(w, err.Error(), http.StatusInternalServerError) return } resp := map[string]any{ "access_token": token, "token_type": "Bearer", } if s.expiresIn > 0 { resp["expires_in"] = s.expiresIn } s.tokenRequestCount.Add(1) w.Header().Set("Content-Type", "application/json") _ = json.NewEncoder(w).Encode(resp) } func (s *Server) signJWT() (string, error) { now := time.Now() header, _ := json.Marshal(map[string]string{ "alg": "RS256", "kid": s.kid, "typ": "JWT", }) claims, _ := json.Marshal(map[string]any{ "iss": s.Issuer(), "sub": s.subject, "aud": s.audience, "iat": now.Unix(), "exp": now.Add(1 * time.Hour).Unix(), }) headerB64 := base64.RawURLEncoding.EncodeToString(header) claimsB64 := base64.RawURLEncoding.EncodeToString(claims) signingInput := headerB64 + "." + claimsB64 h := sha256.Sum256([]byte(signingInput)) sig, err := rsa.SignPKCS1v15(rand.Reader, s.privateKey, crypto.SHA256, h[:]) if err != nil { return "", err } return signingInput + "." + base64.RawURLEncoding.EncodeToString(sig), nil } ================================================ FILE: test/e2e/mock/server/streamserver/server.go ================================================ package streamserver import ( "bufio" "fmt" "io" "net" "strconv" libnet "github.com/fatedier/frp/pkg/util/net" "github.com/fatedier/frp/test/e2e/pkg/rpc" ) type Type string const ( TCP Type = "tcp" UDP Type = "udp" Unix Type = "unix" ) type Server struct { netType Type bindAddr string bindPort int respContent []byte handler func(net.Conn) l net.Listener } type Option func(*Server) *Server func New(netType Type, options ...Option) *Server { s := &Server{ netType: netType, bindAddr: "127.0.0.1", } s.handler = s.handle for _, option := range options { s = option(s) } return s } func WithBindAddr(addr string) Option { return func(s *Server) *Server { s.bindAddr = addr return s } } func WithBindPort(port int) Option { return func(s *Server) *Server { s.bindPort = port return s } } func WithRespContent(content []byte) Option { return func(s *Server) *Server { s.respContent = content return s } } func WithCustomHandler(handler func(net.Conn)) Option { return func(s *Server) *Server { s.handler = handler return s } } func (s *Server) Run() error { if err := s.initListener(); err != nil { return err } go func() { for { c, err := s.l.Accept() if err != nil { return } go s.handler(c) } }() return nil } func (s *Server) Close() error { if s.l != nil { return s.l.Close() } return nil } func (s *Server) initListener() (err error) { switch s.netType { case TCP: s.l, err = net.Listen("tcp", net.JoinHostPort(s.bindAddr, strconv.Itoa(s.bindPort))) case UDP: s.l, err = libnet.ListenUDP(s.bindAddr, s.bindPort) case Unix: s.l, err = net.Listen("unix", s.bindAddr) default: return fmt.Errorf("unknown server type: %s", s.netType) } return err } func (s *Server) handle(c net.Conn) { defer c.Close() var reader io.Reader = c if s.netType == UDP { reader = bufio.NewReader(c) } for { buf, err := rpc.ReadBytes(reader) if err != nil { return } if len(s.respContent) > 0 { buf = s.respContent } _, _ = rpc.WriteBytes(c, buf) } } func (s *Server) BindAddr() string { return s.bindAddr } func (s *Server) BindPort() int { return s.bindPort } ================================================ FILE: test/e2e/pkg/cert/generator.go ================================================ package cert import ( "crypto/tls" "crypto/x509" "encoding/pem" "time" ) // Artifacts hosts a private key, its corresponding serving certificate and // the CA certificate that signs the serving certificate. type Artifacts struct { // PEM encoded private key Key []byte // PEM encoded serving certificate Cert []byte // PEM encoded CA private key CAKey []byte // PEM encoded CA certificate CACert []byte // Resource version of the certs ResourceVersion string } // Generator is an interface to provision the serving certificate. type Generator interface { // Generate returns a Artifacts struct. Generate(CommonName string) (*Artifacts, error) // SetCA sets the PEM-encoded CA private key and CA cert for signing the generated serving cert. SetCA(caKey, caCert []byte) } // ValidCACert think cert and key are valid if they meet the following requirements: // - key and cert are valid pair // - caCert is the root ca of cert // - cert is for dnsName // - cert won't expire before time func ValidCACert(key, cert, caCert []byte, dnsName string, time time.Time) bool { if len(key) == 0 || len(cert) == 0 || len(caCert) == 0 { return false } // Verify key and cert are valid pair _, err := tls.X509KeyPair(cert, key) if err != nil { return false } // Verify cert is valid for at least 1 year. pool := x509.NewCertPool() if !pool.AppendCertsFromPEM(caCert) { return false } block, _ := pem.Decode(cert) if block == nil { return false } c, err := x509.ParseCertificate(block.Bytes) if err != nil { return false } ops := x509.VerifyOptions{ DNSName: dnsName, Roots: pool, CurrentTime: time, } _, err = c.Verify(ops) return err == nil } ================================================ FILE: test/e2e/pkg/cert/selfsigned.go ================================================ package cert import ( "crypto" "crypto/rand" "crypto/rsa" "crypto/x509" "crypto/x509/pkix" "encoding/pem" "errors" "fmt" "math" "math/big" "net" "time" "k8s.io/client-go/util/cert" "k8s.io/client-go/util/keyutil" ) type SelfSignedCertGenerator struct { caKey []byte caCert []byte } var _ Generator = &SelfSignedCertGenerator{} // SetCA sets the PEM-encoded CA private key and CA cert for signing the generated serving cert. func (cp *SelfSignedCertGenerator) SetCA(caKey, caCert []byte) { cp.caKey = caKey cp.caCert = caCert } // Generate creates and returns a CA certificate, certificate and // key for the server or client. Key and Cert are used by the server or client // to establish trust for others, CA certificate is used by the // client or server to verify the other's authentication chain. // The cert will be valid for 365 days. func (cp *SelfSignedCertGenerator) Generate(commonName string) (*Artifacts, error) { var signingKey *rsa.PrivateKey var signingCert *x509.Certificate var valid bool var err error valid, signingKey, signingCert = cp.validCACert() if !valid { signingKey, err = NewPrivateKey() if err != nil { return nil, fmt.Errorf("failed to create the CA private key: %v", err) } signingCert, err = cert.NewSelfSignedCACert(cert.Config{CommonName: commonName}, signingKey) if err != nil { return nil, fmt.Errorf("failed to create the CA cert: %v", err) } } hostIP := net.ParseIP(commonName) var altIPs []net.IP DNSNames := []string{"localhost"} if hostIP.To4() != nil { altIPs = append(altIPs, hostIP.To4()) } else { DNSNames = append(DNSNames, commonName) } key, err := NewPrivateKey() if err != nil { return nil, fmt.Errorf("failed to create the private key: %v", err) } signedCert, err := NewSignedCert( cert.Config{ CommonName: commonName, Usages: []x509.ExtKeyUsage{x509.ExtKeyUsageServerAuth, x509.ExtKeyUsageClientAuth}, AltNames: cert.AltNames{IPs: altIPs, DNSNames: DNSNames}, }, key, signingCert, signingKey, ) if err != nil { return nil, fmt.Errorf("failed to create the cert: %v", err) } return &Artifacts{ Key: EncodePrivateKeyPEM(key), Cert: EncodeCertPEM(signedCert), CAKey: EncodePrivateKeyPEM(signingKey), CACert: EncodeCertPEM(signingCert), }, nil } func (cp *SelfSignedCertGenerator) validCACert() (bool, *rsa.PrivateKey, *x509.Certificate) { if !ValidCACert(cp.caKey, cp.caCert, cp.caCert, "", time.Now().AddDate(1, 0, 0)) { return false, nil, nil } var ok bool key, err := keyutil.ParsePrivateKeyPEM(cp.caKey) if err != nil { return false, nil, nil } privateKey, ok := key.(*rsa.PrivateKey) if !ok { return false, nil, nil } certs, err := cert.ParseCertsPEM(cp.caCert) if err != nil { return false, nil, nil } if len(certs) != 1 { return false, nil, nil } return true, privateKey, certs[0] } // NewPrivateKey creates an RSA private key func NewPrivateKey() (*rsa.PrivateKey, error) { return rsa.GenerateKey(rand.Reader, 2048) } // NewSignedCert creates a signed certificate using the given CA certificate and key func NewSignedCert(cfg cert.Config, key crypto.Signer, caCert *x509.Certificate, caKey crypto.Signer) (*x509.Certificate, error) { serial, err := rand.Int(rand.Reader, new(big.Int).SetInt64(math.MaxInt64)) if err != nil { return nil, err } if len(cfg.CommonName) == 0 { return nil, errors.New("must specify a CommonName") } if len(cfg.Usages) == 0 { return nil, errors.New("must specify at least one ExtKeyUsage") } certTmpl := x509.Certificate{ Subject: pkix.Name{ CommonName: cfg.CommonName, Organization: cfg.Organization, }, DNSNames: cfg.AltNames.DNSNames, IPAddresses: cfg.AltNames.IPs, SerialNumber: serial, NotBefore: caCert.NotBefore, NotAfter: time.Now().Add(time.Hour * 24 * 365 * 10).UTC(), KeyUsage: x509.KeyUsageKeyEncipherment | x509.KeyUsageDigitalSignature, ExtKeyUsage: cfg.Usages, } certDERBytes, err := x509.CreateCertificate(rand.Reader, &certTmpl, caCert, key.Public(), caKey) if err != nil { return nil, err } return x509.ParseCertificate(certDERBytes) } // EncodePrivateKeyPEM returns PEM-encoded private key data func EncodePrivateKeyPEM(key *rsa.PrivateKey) []byte { block := pem.Block{ Type: keyutil.RSAPrivateKeyBlockType, Bytes: x509.MarshalPKCS1PrivateKey(key), } return pem.EncodeToMemory(&block) } // EncodeCertPEM returns PEM-encoded certificate data func EncodeCertPEM(ct *x509.Certificate) []byte { block := pem.Block{ Type: cert.CertificateBlockType, Bytes: ct.Raw, } return pem.EncodeToMemory(&block) } ================================================ FILE: test/e2e/pkg/plugin/plugin.go ================================================ package plugin import ( "crypto/tls" "encoding/json" "io" "net/http" plugin "github.com/fatedier/frp/pkg/plugin/server" "github.com/fatedier/frp/pkg/util/log" "github.com/fatedier/frp/test/e2e/mock/server/httpserver" ) type Handler func(req *plugin.Request) *plugin.Response type NewPluginRequest func() *plugin.Request func NewHTTPPluginServer(port int, newFunc NewPluginRequest, handler Handler, tlsConfig *tls.Config) *httpserver.Server { return httpserver.New( httpserver.WithBindPort(port), httpserver.WithTLSConfig(tlsConfig), httpserver.WithHandler(http.HandlerFunc(func(w http.ResponseWriter, req *http.Request) { r := newFunc() buf, err := io.ReadAll(req.Body) if err != nil { w.WriteHeader(500) return } log.Tracef("plugin request: %s", string(buf)) err = json.Unmarshal(buf, &r) if err != nil { w.WriteHeader(500) return } resp := handler(r) buf, _ = json.Marshal(resp) log.Tracef("plugin response: %s", string(buf)) _, _ = w.Write(buf) })), ) } ================================================ FILE: test/e2e/pkg/port/port.go ================================================ package port import ( "fmt" "net" "strconv" "sync" "k8s.io/apimachinery/pkg/util/sets" ) type Allocator struct { reserved sets.Set[int] used sets.Set[int] mu sync.Mutex } // NewAllocator return a port allocator for testing. // Example: from: 10, to: 20, mod 4, index 1 // Reserved ports: 13, 17 func NewAllocator(from int, to int, mod int, index int) *Allocator { pa := &Allocator{ reserved: sets.New[int](), used: sets.New[int](), } for i := from; i <= to; i++ { if i%mod == index { pa.reserved.Insert(i) } } return pa } func (pa *Allocator) Get() int { return pa.GetByName("") } func (pa *Allocator) GetByName(portName string) int { var builder *nameBuilder if portName == "" { builder = &nameBuilder{} } else { var err error builder, err = unmarshalFromName(portName) if err != nil { fmt.Println(err, portName) return 0 } } pa.mu.Lock() defer pa.mu.Unlock() for range 20 { port := pa.getByRange(builder.rangePortFrom, builder.rangePortTo) if port == 0 { return 0 } l, err := net.Listen("tcp", net.JoinHostPort("0.0.0.0", strconv.Itoa(port))) if err != nil { // Maybe not controlled by us, mark it used. pa.used.Insert(port) continue } l.Close() udpAddr, err := net.ResolveUDPAddr("udp", net.JoinHostPort("0.0.0.0", strconv.Itoa(port))) if err != nil { continue } udpConn, err := net.ListenUDP("udp", udpAddr) if err != nil { // Maybe not controlled by us, mark it used. pa.used.Insert(port) continue } udpConn.Close() pa.used.Insert(port) pa.reserved.Delete(port) return port } return 0 } func (pa *Allocator) getByRange(from, to int) int { if from <= 0 { port, _ := pa.reserved.PopAny() return port } // choose a random port between from - to ports := pa.reserved.UnsortedList() for _, port := range ports { if port >= from && port <= to { return port } } return 0 } func (pa *Allocator) Release(port int) { if port <= 0 { return } pa.mu.Lock() defer pa.mu.Unlock() if pa.used.Has(port) { pa.used.Delete(port) pa.reserved.Insert(port) } } ================================================ FILE: test/e2e/pkg/port/util.go ================================================ package port import ( "fmt" "strconv" "strings" ) const ( NameDelimiter = "_" ) type NameOption func(*nameBuilder) *nameBuilder type nameBuilder struct { name string rangePortFrom int rangePortTo int } func unmarshalFromName(name string) (*nameBuilder, error) { var builder nameBuilder arrs := strings.Split(name, NameDelimiter) switch len(arrs) { case 2: builder.name = arrs[1] case 4: builder.name = arrs[1] fromPort, err := strconv.Atoi(arrs[2]) if err != nil { return nil, fmt.Errorf("error range port from") } builder.rangePortFrom = fromPort toPort, err := strconv.Atoi(arrs[3]) if err != nil { return nil, fmt.Errorf("error range port to") } builder.rangePortTo = toPort default: return nil, fmt.Errorf("error port name format") } return &builder, nil } func (builder *nameBuilder) String() string { name := fmt.Sprintf("Port%s%s", NameDelimiter, builder.name) if builder.rangePortFrom > 0 && builder.rangePortTo > 0 && builder.rangePortTo > builder.rangePortFrom { name += fmt.Sprintf("%s%d%s%d", NameDelimiter, builder.rangePortFrom, NameDelimiter, builder.rangePortTo) } return name } func WithRangePorts(from, to int) NameOption { return func(builder *nameBuilder) *nameBuilder { builder.rangePortFrom = from builder.rangePortTo = to return builder } } func GenName(name string, options ...NameOption) string { name = strings.ReplaceAll(name, "-", "") name = strings.ReplaceAll(name, "_", "") builder := &nameBuilder{name: name} for _, option := range options { builder = option(builder) } return builder.String() } ================================================ FILE: test/e2e/pkg/process/process.go ================================================ package process import ( "bytes" "context" "errors" "fmt" "os/exec" "strings" "sync" "time" ) // SafeBuffer is a thread-safe wrapper around bytes.Buffer. // It is safe to call Write and String concurrently. type SafeBuffer struct { mu sync.Mutex buf bytes.Buffer } func (b *SafeBuffer) Write(p []byte) (int, error) { b.mu.Lock() defer b.mu.Unlock() return b.buf.Write(p) } func (b *SafeBuffer) String() string { b.mu.Lock() defer b.mu.Unlock() return b.buf.String() } type Process struct { cmd *exec.Cmd cancel context.CancelFunc errorOutput *SafeBuffer stdOutput *SafeBuffer done chan struct{} closeOne sync.Once waitErr error started bool beforeStopHandler func() stopped bool } func New(path string, params []string) *Process { return NewWithEnvs(path, params, nil) } func NewWithEnvs(path string, params []string, envs []string) *Process { ctx, cancel := context.WithCancel(context.Background()) cmd := exec.CommandContext(ctx, path, params...) cmd.Env = envs p := &Process{ cmd: cmd, cancel: cancel, done: make(chan struct{}), } p.errorOutput = &SafeBuffer{} p.stdOutput = &SafeBuffer{} cmd.Stderr = p.errorOutput cmd.Stdout = p.stdOutput return p } func (p *Process) Start() error { if p.started { return errors.New("process already started") } p.started = true err := p.cmd.Start() if err != nil { p.waitErr = err p.closeDone() return err } go func() { p.waitErr = p.cmd.Wait() p.closeDone() }() return nil } func (p *Process) closeDone() { p.closeOne.Do(func() { close(p.done) }) } // Done returns a channel that is closed when the process exits. func (p *Process) Done() <-chan struct{} { return p.done } func (p *Process) Stop() error { if p.stopped || !p.started { return nil } defer func() { p.stopped = true }() if p.beforeStopHandler != nil { p.beforeStopHandler() } p.cancel() <-p.done return p.waitErr } func (p *Process) ErrorOutput() string { return p.errorOutput.String() } func (p *Process) StdOutput() string { return p.stdOutput.String() } func (p *Process) Output() string { return p.stdOutput.String() + p.errorOutput.String() } // CountOutput returns how many times pattern appears in the current accumulated output. func (p *Process) CountOutput(pattern string) int { return strings.Count(p.Output(), pattern) } func (p *Process) SetBeforeStopHandler(fn func()) { p.beforeStopHandler = fn } // WaitForOutput polls the combined process output until the pattern is found // count time(s) or the timeout is reached. It also returns early if the process exits. func (p *Process) WaitForOutput(pattern string, count int, timeout time.Duration) error { deadline := time.Now().Add(timeout) for time.Now().Before(deadline) { output := p.Output() if strings.Count(output, pattern) >= count { return nil } select { case <-p.Done(): // Process exited, check one last time. output = p.Output() if strings.Count(output, pattern) >= count { return nil } return fmt.Errorf("process exited before %d occurrence(s) of %q found", count, pattern) case <-time.After(25 * time.Millisecond): } } return fmt.Errorf("timeout waiting for %d occurrence(s) of %q", count, pattern) } ================================================ FILE: test/e2e/pkg/request/request.go ================================================ package request import ( "bufio" "bytes" "crypto/tls" "fmt" "io" "net" "net/http" "net/url" "strconv" "time" libnet "github.com/fatedier/golib/net" httppkg "github.com/fatedier/frp/pkg/util/http" "github.com/fatedier/frp/test/e2e/pkg/rpc" ) type Request struct { protocol string // for all protocol addr string port int body []byte timeout time.Duration resolver *net.Resolver // for http or https method string host string path string headers map[string]string tlsConfig *tls.Config authValue string proxyURL string } func New() *Request { return &Request{ protocol: "tcp", addr: "127.0.0.1", method: "GET", path: "/", headers: map[string]string{}, } } func (r *Request) Protocol(protocol string) *Request { r.protocol = protocol return r } func (r *Request) TCP() *Request { r.protocol = "tcp" return r } func (r *Request) UDP() *Request { r.protocol = "udp" return r } func (r *Request) HTTP() *Request { r.protocol = "http" return r } func (r *Request) HTTPS() *Request { r.protocol = "https" return r } func (r *Request) Proxy(url string) *Request { r.proxyURL = url return r } func (r *Request) Addr(addr string) *Request { r.addr = addr return r } func (r *Request) Port(port int) *Request { r.port = port return r } func (r *Request) HTTPParams(method, host, path string, headers map[string]string) *Request { r.method = method r.host = host r.path = path r.headers = headers return r } func (r *Request) HTTPHost(host string) *Request { r.host = host return r } func (r *Request) HTTPPath(path string) *Request { r.path = path return r } func (r *Request) HTTPHeaders(headers map[string]string) *Request { r.headers = headers return r } func (r *Request) HTTPAuth(user, password string) *Request { r.authValue = httppkg.BasicAuth(user, password) return r } func (r *Request) TLSConfig(tlsConfig *tls.Config) *Request { r.tlsConfig = tlsConfig return r } func (r *Request) Timeout(timeout time.Duration) *Request { r.timeout = timeout return r } func (r *Request) Body(content []byte) *Request { r.body = content return r } func (r *Request) Resolver(resolver *net.Resolver) *Request { r.resolver = resolver return r } func (r *Request) Do() (*Response, error) { var ( conn net.Conn err error ) addr := r.addr if r.port > 0 { addr = net.JoinHostPort(r.addr, strconv.Itoa(r.port)) } // for protocol http and https if r.protocol == "http" || r.protocol == "https" { return r.sendHTTPRequest(r.method, fmt.Sprintf("%s://%s%s", r.protocol, addr, r.path), r.host, r.headers, r.proxyURL, r.body, r.tlsConfig) } // for protocol tcp and udp if len(r.proxyURL) > 0 { if r.protocol != "tcp" { return nil, fmt.Errorf("only tcp protocol is allowed for proxy") } proxyType, proxyAddress, auth, err := libnet.ParseProxyURL(r.proxyURL) if err != nil { return nil, fmt.Errorf("parse ProxyURL error: %v", err) } conn, err = libnet.Dial(addr, libnet.WithProxy(proxyType, proxyAddress), libnet.WithProxyAuth(auth)) if err != nil { return nil, err } } else { dialer := &net.Dialer{Resolver: r.resolver} switch r.protocol { case "tcp": conn, err = dialer.Dial("tcp", addr) case "udp": conn, err = dialer.Dial("udp", addr) default: return nil, fmt.Errorf("invalid protocol") } if err != nil { return nil, err } } defer conn.Close() if r.timeout > 0 { _ = conn.SetDeadline(time.Now().Add(r.timeout)) } buf, err := r.sendRequestByConn(conn, r.body) if err != nil { return nil, err } return &Response{Content: buf}, nil } type Response struct { Code int Header http.Header Content []byte } func (r *Request) sendHTTPRequest(method, urlstr string, host string, headers map[string]string, proxy string, body []byte, tlsConfig *tls.Config, ) (*Response, error) { var inBody io.Reader if len(body) != 0 { inBody = bytes.NewReader(body) } req, err := http.NewRequest(method, urlstr, inBody) if err != nil { return nil, err } if host != "" { req.Host = host } for k, v := range headers { req.Header.Set(k, v) } if r.authValue != "" { req.Header.Set("Authorization", r.authValue) } tr := &http.Transport{ DialContext: (&net.Dialer{ Timeout: time.Second, KeepAlive: 30 * time.Second, DualStack: true, Resolver: r.resolver, }).DialContext, MaxIdleConns: 100, IdleConnTimeout: 90 * time.Second, TLSHandshakeTimeout: 10 * time.Second, ExpectContinueTimeout: 1 * time.Second, TLSClientConfig: tlsConfig, } if len(proxy) != 0 { tr.Proxy = func(req *http.Request) (*url.URL, error) { return url.Parse(proxy) } } client := http.Client{Transport: tr} resp, err := client.Do(req) if err != nil { return nil, err } defer resp.Body.Close() ret := &Response{Code: resp.StatusCode, Header: resp.Header} buf, err := io.ReadAll(resp.Body) if err != nil { return nil, err } ret.Content = buf return ret, nil } func (r *Request) sendRequestByConn(c net.Conn, content []byte) ([]byte, error) { _, err := rpc.WriteBytes(c, content) if err != nil { return nil, fmt.Errorf("write error: %v", err) } var reader io.Reader = c if r.protocol == "udp" { reader = bufio.NewReader(c) } buf, err := rpc.ReadBytes(reader) if err != nil { return nil, fmt.Errorf("read error: %v", err) } return buf, nil } ================================================ FILE: test/e2e/pkg/rpc/rpc.go ================================================ package rpc import ( "bytes" "encoding/binary" "errors" "fmt" "io" ) func WriteBytes(w io.Writer, buf []byte) (int, error) { out := bytes.NewBuffer(nil) if err := binary.Write(out, binary.BigEndian, int64(len(buf))); err != nil { return 0, err } out.Write(buf) return w.Write(out.Bytes()) } func ReadBytes(r io.Reader) ([]byte, error) { var length int64 if err := binary.Read(r, binary.BigEndian, &length); err != nil { return nil, err } if length < 0 || length > 10*1024*1024 { return nil, fmt.Errorf("invalid length") } buffer := make([]byte, length) n, err := io.ReadFull(r, buffer) if err != nil { return nil, err } if int64(n) != length { return nil, errors.New("invalid length") } return buffer, nil } ================================================ FILE: test/e2e/pkg/ssh/client.go ================================================ package ssh import ( "net" libio "github.com/fatedier/golib/io" "golang.org/x/crypto/ssh" ) type TunnelClient struct { localAddr string sshServer string commands string sshConn *ssh.Client ln net.Listener } func NewTunnelClient(localAddr string, sshServer string, commands string) *TunnelClient { return &TunnelClient{ localAddr: localAddr, sshServer: sshServer, commands: commands, } } func (c *TunnelClient) Start() error { config := &ssh.ClientConfig{ User: "v0", HostKeyCallback: func(string, net.Addr, ssh.PublicKey) error { return nil }, } conn, err := ssh.Dial("tcp", c.sshServer, config) if err != nil { return err } c.sshConn = conn l, err := conn.Listen("tcp", "0.0.0.0:80") if err != nil { return err } c.ln = l ch, req, err := conn.OpenChannel("session", []byte("")) if err != nil { return err } defer ch.Close() go ssh.DiscardRequests(req) type command struct { Cmd string } _, err = ch.SendRequest("exec", false, ssh.Marshal(command{Cmd: c.commands})) if err != nil { return err } go c.serveListener() return nil } func (c *TunnelClient) Close() { if c.sshConn != nil { _ = c.sshConn.Close() } if c.ln != nil { _ = c.ln.Close() } } func (c *TunnelClient) serveListener() { for { conn, err := c.ln.Accept() if err != nil { return } go c.handleConn(conn) } } func (c *TunnelClient) handleConn(conn net.Conn) { defer conn.Close() local, err := net.Dial("tcp", c.localAddr) if err != nil { return } _, _, _ = libio.Join(local, conn) } ================================================ FILE: test/e2e/suites.go ================================================ package e2e // CleanupSuite is the boilerplate that can be used after tests on ginkgo were run, on the SynchronizedAfterSuite step. // Similar to SynchronizedBeforeSuite, we want to run some operations only once (such as collecting cluster logs). // Here, the order of functions is reversed; first, the function which runs everywhere, // and then the function that only runs on the first Ginkgo node. func CleanupSuite() { // Run on all Ginkgo nodes } // AfterSuiteActions are actions that are run on ginkgo's SynchronizedAfterSuite func AfterSuiteActions() { // Run only Ginkgo on node 1 } ================================================ FILE: test/e2e/v1/basic/annotations.go ================================================ package basic import ( "fmt" "io" "net/http" "github.com/onsi/ginkgo/v2" "github.com/tidwall/gjson" "github.com/fatedier/frp/test/e2e/framework" "github.com/fatedier/frp/test/e2e/framework/consts" ) var _ = ginkgo.Describe("[Feature: Annotations]", func() { f := framework.NewDefaultFramework() ginkgo.It("Set Proxy Annotations", func() { webPort := f.AllocPort() serverConf := consts.DefaultServerConfig + fmt.Sprintf(` webServer.port = %d `, webPort) p1Port := f.AllocPort() clientConf := consts.DefaultClientConfig + fmt.Sprintf(` [[proxies]] name = "p1" type = "tcp" localPort = {{ .%s }} remotePort = %d [proxies.annotations] "frp.e2e.test/foo" = "value1" "frp.e2e.test/bar" = "value2" `, framework.TCPEchoServerPort, p1Port) f.RunProcesses(serverConf, []string{clientConf}) framework.NewRequestExpect(f).Port(p1Port).Ensure() // check annotations in frps resp, err := http.Get(fmt.Sprintf("http://127.0.0.1:%d/api/proxy/tcp/%s", webPort, "p1")) framework.ExpectNoError(err) framework.ExpectEqual(resp.StatusCode, 200) defer resp.Body.Close() content, err := io.ReadAll(resp.Body) framework.ExpectNoError(err) annotations := gjson.Get(string(content), "conf.annotations").Map() framework.ExpectEqual("value1", annotations["frp.e2e.test/foo"].String()) framework.ExpectEqual("value2", annotations["frp.e2e.test/bar"].String()) }) }) ================================================ FILE: test/e2e/v1/basic/basic.go ================================================ package basic import ( "crypto/tls" "fmt" "strings" "time" "github.com/onsi/ginkgo/v2" "github.com/fatedier/frp/pkg/transport" "github.com/fatedier/frp/test/e2e/framework" "github.com/fatedier/frp/test/e2e/framework/consts" "github.com/fatedier/frp/test/e2e/mock/server/httpserver" "github.com/fatedier/frp/test/e2e/mock/server/streamserver" "github.com/fatedier/frp/test/e2e/pkg/port" "github.com/fatedier/frp/test/e2e/pkg/request" ) var _ = ginkgo.Describe("[Feature: Basic]", func() { f := framework.NewDefaultFramework() ginkgo.Describe("TCP && UDP", func() { types := []string{"tcp", "udp"} for _, t := range types { proxyType := t ginkgo.It(fmt.Sprintf("Expose a %s echo server", strings.ToUpper(proxyType)), func() { serverConf := consts.DefaultServerConfig var clientConf strings.Builder clientConf.WriteString(consts.DefaultClientConfig) localPortName := "" protocol := "tcp" switch proxyType { case "tcp": localPortName = framework.TCPEchoServerPort protocol = "tcp" case "udp": localPortName = framework.UDPEchoServerPort protocol = "udp" } getProxyConf := func(proxyName string, portName string, extra string) string { return fmt.Sprintf(` [[proxies]] name = "%s" type = "%s" localPort = {{ .%s }} remotePort = {{ .%s }} `+extra, proxyName, proxyType, localPortName, portName) } tests := []struct { proxyName string portName string extraConfig string }{ { proxyName: "normal", portName: port.GenName("Normal"), }, { proxyName: "with-encryption", portName: port.GenName("WithEncryption"), extraConfig: "transport.useEncryption = true", }, { proxyName: "with-compression", portName: port.GenName("WithCompression"), extraConfig: "transport.useCompression = true", }, { proxyName: "with-encryption-and-compression", portName: port.GenName("WithEncryptionAndCompression"), extraConfig: ` transport.useEncryption = true transport.useCompression = true `, }, } // build all client config for _, test := range tests { clientConf.WriteString(getProxyConf(test.proxyName, test.portName, test.extraConfig) + "\n") } // run frps and frpc f.RunProcesses(serverConf, []string{clientConf.String()}) for _, test := range tests { framework.NewRequestExpect(f). Protocol(protocol). PortName(test.portName). Explain(test.proxyName). Ensure() } }) } }) ginkgo.Describe("HTTP", func() { ginkgo.It("proxy to HTTP server", func() { serverConf := consts.DefaultServerConfig vhostHTTPPort := f.AllocPort() serverConf += fmt.Sprintf(` vhostHTTPPort = %d `, vhostHTTPPort) var clientConf strings.Builder clientConf.WriteString(consts.DefaultClientConfig) getProxyConf := func(proxyName string, customDomains string, extra string) string { return fmt.Sprintf(` [[proxies]] name = "%s" type = "http" localPort = {{ .%s }} customDomains = %s `+extra, proxyName, framework.HTTPSimpleServerPort, customDomains) } tests := []struct { proxyName string customDomains string extraConfig string }{ { proxyName: "normal", }, { proxyName: "with-encryption", extraConfig: "transport.useEncryption = true", }, { proxyName: "with-compression", extraConfig: "transport.useCompression = true", }, { proxyName: "with-encryption-and-compression", extraConfig: ` transport.useEncryption = true transport.useCompression = true `, }, { proxyName: "multiple-custom-domains", customDomains: `["a.example.com", "b.example.com"]`, }, } // build all client config for i, test := range tests { if tests[i].customDomains == "" { tests[i].customDomains = fmt.Sprintf(`["%s"]`, test.proxyName+".example.com") } clientConf.WriteString(getProxyConf(test.proxyName, tests[i].customDomains, test.extraConfig) + "\n") } // run frps and frpc f.RunProcesses(serverConf, []string{clientConf.String()}) for _, test := range tests { for domain := range strings.SplitSeq(test.customDomains, ",") { domain = strings.TrimSpace(domain) domain = strings.TrimLeft(domain, "[\"") domain = strings.TrimRight(domain, "]\"") framework.NewRequestExpect(f). Explain(test.proxyName + "-" + domain). Port(vhostHTTPPort). RequestModify(func(r *request.Request) { r.HTTP().HTTPHost(domain) }). Ensure() } } // not exist host framework.NewRequestExpect(f). Explain("not exist host"). Port(vhostHTTPPort). RequestModify(func(r *request.Request) { r.HTTP().HTTPHost("not-exist.example.com") }). Ensure(framework.ExpectResponseCode(404)) }) }) ginkgo.Describe("HTTPS", func() { ginkgo.It("proxy to HTTPS server", func() { serverConf := consts.DefaultServerConfig vhostHTTPSPort := f.AllocPort() serverConf += fmt.Sprintf(` vhostHTTPSPort = %d `, vhostHTTPSPort) localPort := f.AllocPort() var clientConf strings.Builder clientConf.WriteString(consts.DefaultClientConfig) getProxyConf := func(proxyName string, customDomains string, extra string) string { return fmt.Sprintf(` [[proxies]] name = "%s" type = "https" localPort = %d customDomains = %s `+extra, proxyName, localPort, customDomains) } tests := []struct { proxyName string customDomains string extraConfig string }{ { proxyName: "normal", }, { proxyName: "with-encryption", extraConfig: "transport.useEncryption = true", }, { proxyName: "with-compression", extraConfig: "transport.useCompression = true", }, { proxyName: "with-encryption-and-compression", extraConfig: ` transport.useEncryption = true transport.useCompression = true `, }, { proxyName: "multiple-custom-domains", customDomains: `["a.example.com", "b.example.com"]`, }, } // build all client config for i, test := range tests { if tests[i].customDomains == "" { tests[i].customDomains = fmt.Sprintf(`["%s"]`, test.proxyName+".example.com") } clientConf.WriteString(getProxyConf(test.proxyName, tests[i].customDomains, test.extraConfig) + "\n") } // run frps and frpc f.RunProcesses(serverConf, []string{clientConf.String()}) tlsConfig, err := transport.NewServerTLSConfig("", "", "") framework.ExpectNoError(err) localServer := httpserver.New( httpserver.WithBindPort(localPort), httpserver.WithTLSConfig(tlsConfig), httpserver.WithResponse([]byte("test")), ) f.RunServer("", localServer) for _, test := range tests { for domain := range strings.SplitSeq(test.customDomains, ",") { domain = strings.TrimSpace(domain) domain = strings.TrimLeft(domain, "[\"") domain = strings.TrimRight(domain, "]\"") framework.NewRequestExpect(f). Explain(test.proxyName + "-" + domain). Port(vhostHTTPSPort). RequestModify(func(r *request.Request) { r.HTTPS().HTTPHost(domain).TLSConfig(&tls.Config{ ServerName: domain, InsecureSkipVerify: true, }) }). ExpectResp([]byte("test")). Ensure() } } // not exist host notExistDomain := "not-exist.example.com" framework.NewRequestExpect(f). Explain("not exist host"). Port(vhostHTTPSPort). RequestModify(func(r *request.Request) { r.HTTPS().HTTPHost(notExistDomain).TLSConfig(&tls.Config{ ServerName: notExistDomain, InsecureSkipVerify: true, }) }). ExpectError(true). Ensure() }) }) ginkgo.Describe("STCP && SUDP && XTCP", func() { types := []string{"stcp", "sudp", "xtcp"} for _, t := range types { proxyType := t ginkgo.It(fmt.Sprintf("Expose echo server with %s", strings.ToUpper(proxyType)), func() { serverConf := consts.DefaultServerConfig var clientServerConf strings.Builder clientServerConf.WriteString(consts.DefaultClientConfig + "\nuser = \"user1\"") var clientVisitorConf strings.Builder clientVisitorConf.WriteString(consts.DefaultClientConfig + "\nuser = \"user1\"") var clientUser2VisitorConf strings.Builder clientUser2VisitorConf.WriteString(consts.DefaultClientConfig + "\nuser = \"user2\"") localPortName := "" protocol := "tcp" switch proxyType { case "stcp": localPortName = framework.TCPEchoServerPort protocol = "tcp" case "sudp": localPortName = framework.UDPEchoServerPort protocol = "udp" case "xtcp": localPortName = framework.TCPEchoServerPort protocol = "tcp" ginkgo.Skip("stun server is not stable") } correctSK := "abc" wrongSK := "123" getProxyServerConf := func(proxyName string, extra string) string { return fmt.Sprintf(` [[proxies]] name = "%s" type = "%s" secretKey = "%s" localPort = {{ .%s }} `+extra, proxyName, proxyType, correctSK, localPortName) } getProxyVisitorConf := func(proxyName string, portName, visitorSK, extra string) string { return fmt.Sprintf(` [[visitors]] name = "%s" type = "%s" serverName = "%s" secretKey = "%s" bindPort = {{ .%s }} `+extra, proxyName, proxyType, proxyName, visitorSK, portName) } tests := []struct { proxyName string bindPortName string visitorSK string commonExtraConfig string proxyExtraConfig string visitorExtraConfig string expectError bool deployUser2Client bool // skipXTCP is used to skip xtcp test case skipXTCP bool }{ { proxyName: "normal", bindPortName: port.GenName("Normal"), visitorSK: correctSK, skipXTCP: true, }, { proxyName: "with-encryption", bindPortName: port.GenName("WithEncryption"), visitorSK: correctSK, commonExtraConfig: "transport.useEncryption = true", skipXTCP: true, }, { proxyName: "with-compression", bindPortName: port.GenName("WithCompression"), visitorSK: correctSK, commonExtraConfig: "transport.useCompression = true", skipXTCP: true, }, { proxyName: "with-encryption-and-compression", bindPortName: port.GenName("WithEncryptionAndCompression"), visitorSK: correctSK, commonExtraConfig: ` transport.useEncryption = true transport.useCompression = true `, skipXTCP: true, }, { proxyName: "with-error-sk", bindPortName: port.GenName("WithErrorSK"), visitorSK: wrongSK, expectError: true, }, { proxyName: "allowed-user", bindPortName: port.GenName("AllowedUser"), visitorSK: correctSK, proxyExtraConfig: `allowUsers = ["another", "user2"]`, visitorExtraConfig: `serverUser = "user1"`, deployUser2Client: true, }, { proxyName: "not-allowed-user", bindPortName: port.GenName("NotAllowedUser"), visitorSK: correctSK, proxyExtraConfig: `allowUsers = ["invalid"]`, visitorExtraConfig: `serverUser = "user1"`, expectError: true, }, { proxyName: "allow-all", bindPortName: port.GenName("AllowAll"), visitorSK: correctSK, proxyExtraConfig: `allowUsers = ["*"]`, visitorExtraConfig: `serverUser = "user1"`, deployUser2Client: true, }, } // build all client config for _, test := range tests { clientServerConf.WriteString(getProxyServerConf(test.proxyName, test.commonExtraConfig+"\n"+test.proxyExtraConfig) + "\n") } for _, test := range tests { config := getProxyVisitorConf( test.proxyName, test.bindPortName, test.visitorSK, test.commonExtraConfig+"\n"+test.visitorExtraConfig, ) + "\n" if test.deployUser2Client { clientUser2VisitorConf.WriteString(config) } else { clientVisitorConf.WriteString(config) } } // run frps and frpc f.RunProcesses(serverConf, []string{clientServerConf.String(), clientVisitorConf.String(), clientUser2VisitorConf.String()}) for _, test := range tests { timeout := time.Second if t == "xtcp" { if test.skipXTCP { continue } timeout = 10 * time.Second } framework.NewRequestExpect(f). RequestModify(func(r *request.Request) { r.Timeout(timeout) }). Protocol(protocol). PortName(test.bindPortName). Explain(test.proxyName). ExpectError(test.expectError). Ensure() } }) } }) ginkgo.Describe("TCPMUX", func() { ginkgo.It("Type tcpmux", func() { serverConf := consts.DefaultServerConfig var clientConf strings.Builder clientConf.WriteString(consts.DefaultClientConfig) tcpmuxHTTPConnectPortName := port.GenName("TCPMUX") serverConf += fmt.Sprintf(` tcpmuxHTTPConnectPort = {{ .%s }} `, tcpmuxHTTPConnectPortName) getProxyConf := func(proxyName string, extra string) string { return fmt.Sprintf(` [[proxies]] name = "%s" type = "tcpmux" multiplexer = "httpconnect" localPort = {{ .%s }} customDomains = ["%s"] `+extra, proxyName, port.GenName(proxyName), proxyName) } tests := []struct { proxyName string extraConfig string }{ { proxyName: "normal", }, { proxyName: "with-encryption", extraConfig: "transport.useEncryption = true", }, { proxyName: "with-compression", extraConfig: "transport.useCompression = true", }, { proxyName: "with-encryption-and-compression", extraConfig: ` transport.useEncryption = true transport.useCompression = true `, }, } // build all client config for _, test := range tests { clientConf.WriteString(getProxyConf(test.proxyName, test.extraConfig) + "\n") localServer := streamserver.New(streamserver.TCP, streamserver.WithBindPort(f.AllocPort()), streamserver.WithRespContent([]byte(test.proxyName))) f.RunServer(port.GenName(test.proxyName), localServer) } // run frps and frpc f.RunProcesses(serverConf, []string{clientConf.String()}) // Request without HTTP connect should get error framework.NewRequestExpect(f). PortName(tcpmuxHTTPConnectPortName). ExpectError(true). Explain("request without HTTP connect expect error"). Ensure() proxyURL := fmt.Sprintf("http://127.0.0.1:%d", f.PortByName(tcpmuxHTTPConnectPortName)) // Request with incorrect connect hostname framework.NewRequestExpect(f).RequestModify(func(r *request.Request) { r.Addr("invalid").Proxy(proxyURL) }).ExpectError(true).Explain("request without HTTP connect expect error").Ensure() // Request with correct connect hostname for _, test := range tests { framework.NewRequestExpect(f).RequestModify(func(r *request.Request) { r.Addr(test.proxyName).Proxy(proxyURL) }).ExpectResp([]byte(test.proxyName)).Explain(test.proxyName).Ensure() } }) }) }) ================================================ FILE: test/e2e/v1/basic/client.go ================================================ package basic import ( "context" "fmt" "strconv" "strings" "time" "github.com/onsi/ginkgo/v2" "github.com/fatedier/frp/test/e2e/framework" "github.com/fatedier/frp/test/e2e/framework/consts" "github.com/fatedier/frp/test/e2e/pkg/request" ) var _ = ginkgo.Describe("[Feature: ClientManage]", func() { f := framework.NewDefaultFramework() ginkgo.It("Update && Reload API", func() { serverConf := consts.DefaultServerConfig adminPort := f.AllocPort() p1Port := f.AllocPort() p2Port := f.AllocPort() p3Port := f.AllocPort() clientConf := consts.DefaultClientConfig + fmt.Sprintf(` webServer.port = %d [[proxies]] name = "p1" type = "tcp" localPort = {{ .%s }} remotePort = %d [[proxies]] name = "p2" type = "tcp" localPort = {{ .%s }} remotePort = %d [[proxies]] name = "p3" type = "tcp" localPort = {{ .%s }} remotePort = %d `, adminPort, framework.TCPEchoServerPort, p1Port, framework.TCPEchoServerPort, p2Port, framework.TCPEchoServerPort, p3Port) f.RunProcesses(serverConf, []string{clientConf}) framework.NewRequestExpect(f).Port(p1Port).Ensure() framework.NewRequestExpect(f).Port(p2Port).Ensure() framework.NewRequestExpect(f).Port(p3Port).Ensure() client := f.APIClientForFrpc(adminPort) conf, err := client.GetConfig(context.Background()) framework.ExpectNoError(err) newP2Port := f.AllocPort() // change p2 port and remove p3 proxy newClientConf := strings.ReplaceAll(conf, strconv.Itoa(p2Port), strconv.Itoa(newP2Port)) p3Index := strings.LastIndex(newClientConf, "[[proxies]]") if p3Index >= 0 { newClientConf = newClientConf[:p3Index] } err = client.UpdateConfig(context.Background(), newClientConf) framework.ExpectNoError(err) err = client.Reload(context.Background(), true) framework.ExpectNoError(err) time.Sleep(time.Second) framework.NewRequestExpect(f).Port(p1Port).Explain("p1 port").Ensure() framework.NewRequestExpect(f).Port(p2Port).Explain("original p2 port").ExpectError(true).Ensure() framework.NewRequestExpect(f).Port(newP2Port).Explain("new p2 port").Ensure() framework.NewRequestExpect(f).Port(p3Port).Explain("p3 port").ExpectError(true).Ensure() }) ginkgo.It("healthz", func() { serverConf := consts.DefaultServerConfig dashboardPort := f.AllocPort() clientConf := consts.DefaultClientConfig + fmt.Sprintf(` webServer.addr = "0.0.0.0" webServer.port = %d webServer.user = "admin" webServer.password = "admin" `, dashboardPort) f.RunProcesses(serverConf, []string{clientConf}) framework.NewRequestExpect(f).RequestModify(func(r *request.Request) { r.HTTP().HTTPPath("/healthz") }).Port(dashboardPort).ExpectResp([]byte("")).Ensure() framework.NewRequestExpect(f).RequestModify(func(r *request.Request) { r.HTTP().HTTPPath("/") }).Port(dashboardPort). Ensure(framework.ExpectResponseCode(401)) }) ginkgo.It("stop", func() { serverConf := consts.DefaultServerConfig adminPort := f.AllocPort() testPort := f.AllocPort() clientConf := consts.DefaultClientConfig + fmt.Sprintf(` webServer.port = %d [[proxies]] name = "test" type = "tcp" localPort = {{ .%s }} remotePort = %d `, adminPort, framework.TCPEchoServerPort, testPort) f.RunProcesses(serverConf, []string{clientConf}) framework.NewRequestExpect(f).Port(testPort).Ensure() client := f.APIClientForFrpc(adminPort) err := client.Stop(context.Background()) framework.ExpectNoError(err) time.Sleep(3 * time.Second) // frpc stopped so the port is not listened, expect error framework.NewRequestExpect(f).Port(testPort).ExpectError(true).Ensure() }) }) ================================================ FILE: test/e2e/v1/basic/client_server.go ================================================ package basic import ( "fmt" "strings" "time" "github.com/onsi/ginkgo/v2" "github.com/fatedier/frp/test/e2e/framework" "github.com/fatedier/frp/test/e2e/framework/consts" "github.com/fatedier/frp/test/e2e/pkg/cert" "github.com/fatedier/frp/test/e2e/pkg/port" ) type generalTestConfigures struct { server string client string clientPrefix string client2 string client2Prefix string testDelay time.Duration expectError bool } func renderBindPortConfig(protocol string) string { switch protocol { case "kcp": return fmt.Sprintf(`kcpBindPort = {{ .%s }}`, consts.PortServerName) case "quic": return fmt.Sprintf(`quicBindPort = {{ .%s }}`, consts.PortServerName) default: return "" } } func runClientServerTest(f *framework.Framework, configures *generalTestConfigures) { serverConf := consts.DefaultServerConfig clientConf := consts.DefaultClientConfig if configures.clientPrefix != "" { clientConf = configures.clientPrefix } serverConf += fmt.Sprintf(` %s `, configures.server) tcpPortName := port.GenName("TCP") udpPortName := port.GenName("UDP") clientConf += fmt.Sprintf(` %s [[proxies]] name = "tcp" type = "tcp" localPort = {{ .%s }} remotePort = {{ .%s }} [[proxies]] name = "udp" type = "udp" localPort = {{ .%s }} remotePort = {{ .%s }} `, configures.client, framework.TCPEchoServerPort, tcpPortName, framework.UDPEchoServerPort, udpPortName, ) clientConfs := []string{clientConf} if configures.client2 != "" { client2Conf := consts.DefaultClientConfig if configures.client2Prefix != "" { client2Conf = configures.client2Prefix } client2Conf += fmt.Sprintf(` %s `, configures.client2) clientConfs = append(clientConfs, client2Conf) } f.RunProcesses(serverConf, clientConfs) if configures.testDelay > 0 { time.Sleep(configures.testDelay) } framework.NewRequestExpect(f).PortName(tcpPortName).ExpectError(configures.expectError).Explain("tcp proxy").Ensure() framework.NewRequestExpect(f).Protocol("udp"). PortName(udpPortName).ExpectError(configures.expectError).Explain("udp proxy").Ensure() } // defineClientServerTest test a normal tcp and udp proxy with specified TestConfigures. func defineClientServerTest(desc string, f *framework.Framework, configures *generalTestConfigures) { ginkgo.It(desc, func() { runClientServerTest(f, configures) }) } var _ = ginkgo.Describe("[Feature: Client-Server]", func() { f := framework.NewDefaultFramework() ginkgo.Describe("Protocol", func() { supportProtocols := []string{"tcp", "kcp", "quic", "websocket"} for _, protocol := range supportProtocols { configures := &generalTestConfigures{ server: fmt.Sprintf(` %s `, renderBindPortConfig(protocol)), client: fmt.Sprintf(`transport.protocol = "%s"`, protocol), } defineClientServerTest(protocol, f, configures) } }) // wss is special, it needs to be tested separately. // frps only supports ws, so there should be a proxy to terminate TLS before frps. ginkgo.Describe("Protocol wss", func() { wssPort := f.AllocPort() configures := &generalTestConfigures{ clientPrefix: fmt.Sprintf(` serverAddr = "127.0.0.1" serverPort = %d loginFailExit = false transport.protocol = "wss" log.level = "trace" `, wssPort), // Due to the fact that frps cannot directly accept wss connections, we use the https2http plugin of another frpc to terminate TLS. client2: fmt.Sprintf(` [[proxies]] name = "wss2ws" type = "tcp" remotePort = %d [proxies.plugin] type = "https2http" localAddr = "127.0.0.1:{{ .%s }}" `, wssPort, consts.PortServerName), testDelay: 10 * time.Second, } defineClientServerTest("wss", f, configures) }) ginkgo.Describe("Authentication", func() { defineClientServerTest("Token Correct", f, &generalTestConfigures{ server: `auth.token = "123456"`, client: `auth.token = "123456"`, }) defineClientServerTest("Token Incorrect", f, &generalTestConfigures{ server: `auth.token = "123456"`, client: `auth.token = "invalid"`, expectError: true, }) }) ginkgo.Describe("TLS", func() { supportProtocols := []string{"tcp", "kcp", "quic", "websocket"} for _, protocol := range supportProtocols { tmp := protocol // Since v0.50.0, the default value of tls_enable has been changed to true. // Therefore, here it needs to be set as false to test the scenario of turning it off. defineClientServerTest("Disable TLS over "+strings.ToUpper(tmp), f, &generalTestConfigures{ server: fmt.Sprintf(` %s `, renderBindPortConfig(protocol)), client: fmt.Sprintf(`transport.tls.enable = false transport.protocol = "%s" `, protocol), }) } defineClientServerTest("enable tls force, client with TLS", f, &generalTestConfigures{ server: "transport.tls.force = true", }) defineClientServerTest("enable tls force, client without TLS", f, &generalTestConfigures{ server: "transport.tls.force = true", client: "transport.tls.enable = false", expectError: true, }) }) ginkgo.Describe("TLS with custom certificate", func() { supportProtocols := []string{"tcp", "kcp", "quic", "websocket"} var ( caCrtPath string serverCrtPath, serverKeyPath string clientCrtPath, clientKeyPath string ) ginkgo.JustBeforeEach(func() { generator := &cert.SelfSignedCertGenerator{} artifacts, err := generator.Generate("127.0.0.1") framework.ExpectNoError(err) caCrtPath = f.WriteTempFile("ca.crt", string(artifacts.CACert)) serverCrtPath = f.WriteTempFile("server.crt", string(artifacts.Cert)) serverKeyPath = f.WriteTempFile("server.key", string(artifacts.Key)) generator.SetCA(artifacts.CACert, artifacts.CAKey) _, err = generator.Generate("127.0.0.1") framework.ExpectNoError(err) clientCrtPath = f.WriteTempFile("client.crt", string(artifacts.Cert)) clientKeyPath = f.WriteTempFile("client.key", string(artifacts.Key)) }) for _, protocol := range supportProtocols { tmp := protocol ginkgo.It("one-way authentication: "+tmp, func() { runClientServerTest(f, &generalTestConfigures{ server: fmt.Sprintf(` %s transport.tls.trustedCaFile = "%s" `, renderBindPortConfig(tmp), caCrtPath), client: fmt.Sprintf(` transport.protocol = "%s" transport.tls.certFile = "%s" transport.tls.keyFile = "%s" `, tmp, clientCrtPath, clientKeyPath), }) }) ginkgo.It("mutual authentication: "+tmp, func() { runClientServerTest(f, &generalTestConfigures{ server: fmt.Sprintf(` %s transport.tls.certFile = "%s" transport.tls.keyFile = "%s" transport.tls.trustedCaFile = "%s" `, renderBindPortConfig(tmp), serverCrtPath, serverKeyPath, caCrtPath), client: fmt.Sprintf(` transport.protocol = "%s" transport.tls.certFile = "%s" transport.tls.keyFile = "%s" transport.tls.trustedCaFile = "%s" `, tmp, clientCrtPath, clientKeyPath, caCrtPath), }) }) } }) ginkgo.Describe("TLS with custom certificate and specified server name", func() { var ( caCrtPath string serverCrtPath, serverKeyPath string clientCrtPath, clientKeyPath string ) ginkgo.JustBeforeEach(func() { generator := &cert.SelfSignedCertGenerator{} artifacts, err := generator.Generate("example.com") framework.ExpectNoError(err) caCrtPath = f.WriteTempFile("ca.crt", string(artifacts.CACert)) serverCrtPath = f.WriteTempFile("server.crt", string(artifacts.Cert)) serverKeyPath = f.WriteTempFile("server.key", string(artifacts.Key)) generator.SetCA(artifacts.CACert, artifacts.CAKey) _, err = generator.Generate("example.com") framework.ExpectNoError(err) clientCrtPath = f.WriteTempFile("client.crt", string(artifacts.Cert)) clientKeyPath = f.WriteTempFile("client.key", string(artifacts.Key)) }) ginkgo.It("mutual authentication", func() { runClientServerTest(f, &generalTestConfigures{ server: fmt.Sprintf(` transport.tls.certFile = "%s" transport.tls.keyFile = "%s" transport.tls.trustedCaFile = "%s" `, serverCrtPath, serverKeyPath, caCrtPath), client: fmt.Sprintf(` transport.tls.serverName = "example.com" transport.tls.certFile = "%s" transport.tls.keyFile = "%s" transport.tls.trustedCaFile = "%s" `, clientCrtPath, clientKeyPath, caCrtPath), }) }) ginkgo.It("mutual authentication with incorrect server name", func() { runClientServerTest(f, &generalTestConfigures{ server: fmt.Sprintf(` transport.tls.certFile = "%s" transport.tls.keyFile = "%s" transport.tls.trustedCaFile = "%s" `, serverCrtPath, serverKeyPath, caCrtPath), client: fmt.Sprintf(` transport.tls.serverName = "invalid.com" transport.tls.certFile = "%s" transport.tls.keyFile = "%s" transport.tls.trustedCaFile = "%s" `, clientCrtPath, clientKeyPath, caCrtPath), expectError: true, }) }) }) ginkgo.Describe("TLS with disableCustomTLSFirstByte set to false", func() { supportProtocols := []string{"tcp", "kcp", "quic", "websocket"} for _, protocol := range supportProtocols { tmp := protocol defineClientServerTest("TLS over "+strings.ToUpper(tmp), f, &generalTestConfigures{ server: fmt.Sprintf(` %s `, renderBindPortConfig(protocol)), client: fmt.Sprintf(` transport.protocol = "%s" transport.tls.disableCustomTLSFirstByte = false `, protocol), }) } }) ginkgo.Describe("IPv6 bind address", func() { supportProtocols := []string{"tcp", "kcp", "quic", "websocket"} for _, protocol := range supportProtocols { tmp := protocol defineClientServerTest("IPv6 bind address: "+strings.ToUpper(tmp), f, &generalTestConfigures{ server: fmt.Sprintf(` bindAddr = "::" %s `, renderBindPortConfig(protocol)), client: fmt.Sprintf(` transport.protocol = "%s" `, protocol), }) } }) ginkgo.Describe("Use same port for bindPort and vhostHTTPSPort", func() { supportProtocols := []string{"tcp", "kcp", "quic", "websocket"} for _, protocol := range supportProtocols { tmp := protocol defineClientServerTest("Use same port for bindPort and vhostHTTPSPort: "+strings.ToUpper(tmp), f, &generalTestConfigures{ server: fmt.Sprintf(` vhostHTTPSPort = {{ .%s }} %s `, consts.PortServerName, renderBindPortConfig(protocol)), // transport.tls.disableCustomTLSFirstByte should set to false when vhostHTTPSPort is same as bindPort client: fmt.Sprintf(` transport.protocol = "%s" transport.tls.disableCustomTLSFirstByte = false `, protocol), }) } }) }) ================================================ FILE: test/e2e/v1/basic/cmd.go ================================================ package basic import ( "strconv" "strings" "github.com/onsi/ginkgo/v2" "github.com/fatedier/frp/test/e2e/framework" "github.com/fatedier/frp/test/e2e/pkg/request" ) const ( ConfigValidStr = "syntax is ok" ) var _ = ginkgo.Describe("[Feature: Cmd]", func() { f := framework.NewDefaultFramework() ginkgo.Describe("Verify", func() { ginkgo.It("frps valid", func() { path := f.GenerateConfigFile(` bindAddr = "0.0.0.0" bindPort = 7000 `) _, output, err := f.RunFrps("verify", "-c", path) framework.ExpectNoError(err) framework.ExpectTrue(strings.Contains(output, ConfigValidStr), "output: %s", output) }) ginkgo.It("frps invalid", func() { path := f.GenerateConfigFile(` bindAddr = "0.0.0.0" bindPort = 70000 `) _, output, err := f.RunFrps("verify", "-c", path) framework.ExpectNoError(err) framework.ExpectTrue(!strings.Contains(output, ConfigValidStr), "output: %s", output) }) ginkgo.It("frpc valid", func() { path := f.GenerateConfigFile(` serverAddr = "0.0.0.0" serverPort = 7000 `) _, output, err := f.RunFrpc("verify", "-c", path) framework.ExpectNoError(err) framework.ExpectTrue(strings.Contains(output, ConfigValidStr), "output: %s", output) }) ginkgo.It("frpc invalid", func() { path := f.GenerateConfigFile(` serverAddr = "0.0.0.0" serverPort = 7000 transport.protocol = "invalid" `) _, output, err := f.RunFrpc("verify", "-c", path) framework.ExpectNoError(err) framework.ExpectTrue(!strings.Contains(output, ConfigValidStr), "output: %s", output) }) }) ginkgo.Describe("Single proxy", func() { ginkgo.It("TCP", func() { serverPort := f.AllocPort() _, _, err := f.RunFrps("-t", "123", "-p", strconv.Itoa(serverPort)) framework.ExpectNoError(err) localPort := f.PortByName(framework.TCPEchoServerPort) remotePort := f.AllocPort() _, _, err = f.RunFrpc("tcp", "-s", "127.0.0.1", "-P", strconv.Itoa(serverPort), "-t", "123", "-u", "test", "-l", strconv.Itoa(localPort), "-r", strconv.Itoa(remotePort), "-n", "tcp_test") framework.ExpectNoError(err) framework.NewRequestExpect(f).Port(remotePort).Ensure() }) ginkgo.It("UDP", func() { serverPort := f.AllocPort() _, _, err := f.RunFrps("-t", "123", "-p", strconv.Itoa(serverPort)) framework.ExpectNoError(err) localPort := f.PortByName(framework.UDPEchoServerPort) remotePort := f.AllocPort() _, _, err = f.RunFrpc("udp", "-s", "127.0.0.1", "-P", strconv.Itoa(serverPort), "-t", "123", "-u", "test", "-l", strconv.Itoa(localPort), "-r", strconv.Itoa(remotePort), "-n", "udp_test") framework.ExpectNoError(err) framework.NewRequestExpect(f).Protocol("udp"). Port(remotePort).Ensure() }) ginkgo.It("HTTP", func() { serverPort := f.AllocPort() vhostHTTPPort := f.AllocPort() _, _, err := f.RunFrps("-t", "123", "-p", strconv.Itoa(serverPort), "--vhost-http-port", strconv.Itoa(vhostHTTPPort)) framework.ExpectNoError(err) _, _, err = f.RunFrpc("http", "-s", "127.0.0.1", "-P", strconv.Itoa(serverPort), "-t", "123", "-u", "test", "-n", "udp_test", "-l", strconv.Itoa(f.PortByName(framework.HTTPSimpleServerPort)), "--custom-domain", "test.example.com") framework.ExpectNoError(err) framework.NewRequestExpect(f).Port(vhostHTTPPort). RequestModify(func(r *request.Request) { r.HTTP().HTTPHost("test.example.com") }). Ensure() }) }) }) ================================================ FILE: test/e2e/v1/basic/config.go ================================================ package basic import ( "context" "fmt" "github.com/onsi/ginkgo/v2" "github.com/fatedier/frp/test/e2e/framework" "github.com/fatedier/frp/test/e2e/framework/consts" "github.com/fatedier/frp/test/e2e/pkg/port" ) var _ = ginkgo.Describe("[Feature: Config]", func() { f := framework.NewDefaultFramework() ginkgo.Describe("Template", func() { ginkgo.It("render by env", func() { serverConf := consts.DefaultServerConfig clientConf := consts.DefaultClientConfig portName := port.GenName("TCP") serverConf += fmt.Sprintf(` auth.token = "{{ %s{{ .Envs.FRP_TOKEN }}%s }}" `, "`", "`") clientConf += fmt.Sprintf(` auth.token = "{{ %s{{ .Envs.FRP_TOKEN }}%s }}" [[proxies]] name = "tcp" type = "tcp" localPort = {{ .%s }} remotePort = {{ .%s }} `, "`", "`", framework.TCPEchoServerPort, portName) f.SetEnvs([]string{"FRP_TOKEN=123"}) f.RunProcesses(serverConf, []string{clientConf}) framework.NewRequestExpect(f).PortName(portName).Ensure() }) ginkgo.It("Range ports mapping", func() { serverConf := consts.DefaultServerConfig clientConf := consts.DefaultClientConfig adminPort := f.AllocPort() localPortsRange := "13010-13012,13014" remotePortsRange := "23010-23012,23014" escapeTemplate := func(s string) string { return "{{ `" + s + "` }}" } clientConf += fmt.Sprintf(` webServer.port = %d %s [[proxies]] name = "tcp-%s" type = "tcp" localPort = %s remotePort = %s %s `, adminPort, escapeTemplate(fmt.Sprintf(`{{- range $_, $v := parseNumberRangePair "%s" "%s" }}`, localPortsRange, remotePortsRange)), escapeTemplate("{{ $v.First }}"), escapeTemplate("{{ $v.First }}"), escapeTemplate("{{ $v.Second }}"), escapeTemplate("{{- end }}"), ) f.RunProcesses(serverConf, []string{clientConf}) client := f.APIClientForFrpc(adminPort) checkProxyFn := func(name string, localPort, remotePort int) { status, err := client.GetProxyStatus(context.Background(), name) framework.ExpectNoError(err) framework.ExpectContainSubstring(status.LocalAddr, fmt.Sprintf(":%d", localPort)) framework.ExpectContainSubstring(status.RemoteAddr, fmt.Sprintf(":%d", remotePort)) } checkProxyFn("tcp-13010", 13010, 23010) checkProxyFn("tcp-13011", 13011, 23011) checkProxyFn("tcp-13012", 13012, 23012) checkProxyFn("tcp-13014", 13014, 23014) }) }) ginkgo.Describe("Includes", func() { ginkgo.It("split tcp proxies into different files", func() { serverPort := f.AllocPort() serverConfigPath := f.GenerateConfigFile(fmt.Sprintf(` bindAddr = "0.0.0.0" bindPort = %d `, serverPort)) remotePort := f.AllocPort() proxyConfigPath := f.GenerateConfigFile(fmt.Sprintf(` [[proxies]] name = "tcp" type = "tcp" localPort = %d remotePort = %d `, f.PortByName(framework.TCPEchoServerPort), remotePort)) remotePort2 := f.AllocPort() proxyConfigPath2 := f.GenerateConfigFile(fmt.Sprintf(` [[proxies]] name = "tcp2" type = "tcp" localPort = %d remotePort = %d `, f.PortByName(framework.TCPEchoServerPort), remotePort2)) clientConfigPath := f.GenerateConfigFile(fmt.Sprintf(` serverPort = %d includes = ["%s","%s"] `, serverPort, proxyConfigPath, proxyConfigPath2)) _, _, err := f.RunFrps("-c", serverConfigPath) framework.ExpectNoError(err) _, _, err = f.RunFrpc("-c", clientConfigPath) framework.ExpectNoError(err) framework.NewRequestExpect(f).Port(remotePort).Ensure() framework.NewRequestExpect(f).Port(remotePort2).Ensure() }) }) ginkgo.Describe("Support Formats", func() { ginkgo.It("YAML", func() { serverConf := fmt.Sprintf(` bindPort: {{ .%s }} log: level: trace `, port.GenName("Server")) remotePort := f.AllocPort() clientConf := fmt.Sprintf(` serverPort: {{ .%s }} log: level: trace proxies: - name: tcp type: tcp localPort: {{ .%s }} remotePort: %d `, port.GenName("Server"), framework.TCPEchoServerPort, remotePort) f.RunProcesses(serverConf, []string{clientConf}) framework.NewRequestExpect(f).Port(remotePort).Ensure() }) ginkgo.It("JSON", func() { serverConf := fmt.Sprintf(`{"bindPort": {{ .%s }}, "log": {"level": "trace"}}`, port.GenName("Server")) remotePort := f.AllocPort() clientConf := fmt.Sprintf(`{"serverPort": {{ .%s }}, "log": {"level": "trace"}, "proxies": [{"name": "tcp", "type": "tcp", "localPort": {{ .%s }}, "remotePort": %d}]}`, port.GenName("Server"), framework.TCPEchoServerPort, remotePort) f.RunProcesses(serverConf, []string{clientConf}) framework.NewRequestExpect(f).Port(remotePort).Ensure() }) }) }) ================================================ FILE: test/e2e/v1/basic/http.go ================================================ package basic import ( "fmt" "net/http" "net/url" "strconv" "time" "github.com/gorilla/websocket" "github.com/onsi/ginkgo/v2" "github.com/fatedier/frp/test/e2e/framework" "github.com/fatedier/frp/test/e2e/framework/consts" "github.com/fatedier/frp/test/e2e/mock/server/httpserver" "github.com/fatedier/frp/test/e2e/pkg/request" ) var _ = ginkgo.Describe("[Feature: HTTP]", func() { f := framework.NewDefaultFramework() getDefaultServerConf := func(vhostHTTPPort int) string { conf := consts.DefaultServerConfig + ` vhostHTTPPort = %d ` return fmt.Sprintf(conf, vhostHTTPPort) } newHTTPServer := func(port int, respContent string) *httpserver.Server { return httpserver.New( httpserver.WithBindPort(port), httpserver.WithHandler(framework.SpecifiedHTTPBodyHandler([]byte(respContent))), ) } ginkgo.It("HTTP route by locations", func() { vhostHTTPPort := f.AllocPort() serverConf := getDefaultServerConf(vhostHTTPPort) fooPort := f.AllocPort() f.RunServer("", newHTTPServer(fooPort, "foo")) barPort := f.AllocPort() f.RunServer("", newHTTPServer(barPort, "bar")) clientConf := consts.DefaultClientConfig clientConf += fmt.Sprintf(` [[proxies]] name = "foo" type = "http" localPort = %d customDomains = ["normal.example.com"] locations = ["/","/foo"] [[proxies]] name = "bar" type = "http" localPort = %d customDomains = ["normal.example.com"] locations = ["/bar"] `, fooPort, barPort) f.RunProcesses(serverConf, []string{clientConf}) tests := []struct { path string expectResp string desc string }{ {path: "/foo", expectResp: "foo", desc: "foo path"}, {path: "/bar", expectResp: "bar", desc: "bar path"}, {path: "/other", expectResp: "foo", desc: "other path"}, } for _, test := range tests { framework.NewRequestExpect(f).Explain(test.desc).Port(vhostHTTPPort). RequestModify(func(r *request.Request) { r.HTTP().HTTPHost("normal.example.com").HTTPPath(test.path) }). ExpectResp([]byte(test.expectResp)). Ensure() } }) ginkgo.It("HTTP route by HTTP user", func() { vhostHTTPPort := f.AllocPort() serverConf := getDefaultServerConf(vhostHTTPPort) fooPort := f.AllocPort() f.RunServer("", newHTTPServer(fooPort, "foo")) barPort := f.AllocPort() f.RunServer("", newHTTPServer(barPort, "bar")) otherPort := f.AllocPort() f.RunServer("", newHTTPServer(otherPort, "other")) clientConf := consts.DefaultClientConfig clientConf += fmt.Sprintf(` [[proxies]] name = "foo" type = "http" localPort = %d customDomains = ["normal.example.com"] routeByHTTPUser = "user1" [[proxies]] name = "bar" type = "http" localPort = %d customDomains = ["normal.example.com"] routeByHTTPUser = "user2" [[proxies]] name = "catchAll" type = "http" localPort = %d customDomains = ["normal.example.com"] `, fooPort, barPort, otherPort) f.RunProcesses(serverConf, []string{clientConf}) // user1 framework.NewRequestExpect(f).Explain("user1").Port(vhostHTTPPort). RequestModify(func(r *request.Request) { r.HTTP().HTTPHost("normal.example.com").HTTPAuth("user1", "") }). ExpectResp([]byte("foo")). Ensure() // user2 framework.NewRequestExpect(f).Explain("user2").Port(vhostHTTPPort). RequestModify(func(r *request.Request) { r.HTTP().HTTPHost("normal.example.com").HTTPAuth("user2", "") }). ExpectResp([]byte("bar")). Ensure() // other user framework.NewRequestExpect(f).Explain("other user").Port(vhostHTTPPort). RequestModify(func(r *request.Request) { r.HTTP().HTTPHost("normal.example.com").HTTPAuth("user3", "") }). ExpectResp([]byte("other")). Ensure() }) ginkgo.It("HTTP Basic Auth", func() { vhostHTTPPort := f.AllocPort() serverConf := getDefaultServerConf(vhostHTTPPort) clientConf := consts.DefaultClientConfig clientConf += fmt.Sprintf(` [[proxies]] name = "test" type = "http" localPort = {{ .%s }} customDomains = ["normal.example.com"] httpUser = "test" httpPassword = "test" `, framework.HTTPSimpleServerPort) f.RunProcesses(serverConf, []string{clientConf}) // not set auth header framework.NewRequestExpect(f).Port(vhostHTTPPort). RequestModify(func(r *request.Request) { r.HTTP().HTTPHost("normal.example.com") }). Ensure(framework.ExpectResponseCode(401)) // set incorrect auth header framework.NewRequestExpect(f).Port(vhostHTTPPort). RequestModify(func(r *request.Request) { r.HTTP().HTTPHost("normal.example.com").HTTPAuth("test", "invalid") }). Ensure(framework.ExpectResponseCode(401)) // set correct auth header framework.NewRequestExpect(f).Port(vhostHTTPPort). RequestModify(func(r *request.Request) { r.HTTP().HTTPHost("normal.example.com").HTTPAuth("test", "test") }). Ensure() }) ginkgo.It("Wildcard domain", func() { vhostHTTPPort := f.AllocPort() serverConf := getDefaultServerConf(vhostHTTPPort) clientConf := consts.DefaultClientConfig clientConf += fmt.Sprintf(` [[proxies]] name = "test" type = "http" localPort = {{ .%s }} customDomains = ["*.example.com"] `, framework.HTTPSimpleServerPort) f.RunProcesses(serverConf, []string{clientConf}) // not match host framework.NewRequestExpect(f).Port(vhostHTTPPort). RequestModify(func(r *request.Request) { r.HTTP().HTTPHost("not-match.test.com") }). Ensure(framework.ExpectResponseCode(404)) // test.example.com match *.example.com framework.NewRequestExpect(f).Port(vhostHTTPPort). RequestModify(func(r *request.Request) { r.HTTP().HTTPHost("test.example.com") }). Ensure() // sub.test.example.com match *.example.com framework.NewRequestExpect(f).Port(vhostHTTPPort). RequestModify(func(r *request.Request) { r.HTTP().HTTPHost("sub.test.example.com") }). Ensure() }) ginkgo.It("Subdomain", func() { vhostHTTPPort := f.AllocPort() serverConf := getDefaultServerConf(vhostHTTPPort) serverConf += ` subdomainHost = "example.com" ` fooPort := f.AllocPort() f.RunServer("", newHTTPServer(fooPort, "foo")) barPort := f.AllocPort() f.RunServer("", newHTTPServer(barPort, "bar")) clientConf := consts.DefaultClientConfig clientConf += fmt.Sprintf(` [[proxies]] name = "foo" type = "http" localPort = %d subdomain = "foo" [[proxies]] name = "bar" type = "http" localPort = %d subdomain = "bar" `, fooPort, barPort) f.RunProcesses(serverConf, []string{clientConf}) // foo framework.NewRequestExpect(f).Explain("foo subdomain").Port(vhostHTTPPort). RequestModify(func(r *request.Request) { r.HTTP().HTTPHost("foo.example.com") }). ExpectResp([]byte("foo")). Ensure() // bar framework.NewRequestExpect(f).Explain("bar subdomain").Port(vhostHTTPPort). RequestModify(func(r *request.Request) { r.HTTP().HTTPHost("bar.example.com") }). ExpectResp([]byte("bar")). Ensure() }) ginkgo.It("Modify request headers", func() { vhostHTTPPort := f.AllocPort() serverConf := getDefaultServerConf(vhostHTTPPort) localPort := f.AllocPort() localServer := httpserver.New( httpserver.WithBindPort(localPort), httpserver.WithHandler(http.HandlerFunc(func(w http.ResponseWriter, req *http.Request) { _, _ = w.Write([]byte(req.Header.Get("X-From-Where"))) })), ) f.RunServer("", localServer) clientConf := consts.DefaultClientConfig clientConf += fmt.Sprintf(` [[proxies]] name = "test" type = "http" localPort = %d customDomains = ["normal.example.com"] requestHeaders.set.x-from-where = "frp" `, localPort) f.RunProcesses(serverConf, []string{clientConf}) framework.NewRequestExpect(f).Port(vhostHTTPPort). RequestModify(func(r *request.Request) { r.HTTP().HTTPHost("normal.example.com") }). ExpectResp([]byte("frp")). // local http server will write this X-From-Where header to response body Ensure() }) ginkgo.It("Modify response headers", func() { vhostHTTPPort := f.AllocPort() serverConf := getDefaultServerConf(vhostHTTPPort) localPort := f.AllocPort() localServer := httpserver.New( httpserver.WithBindPort(localPort), httpserver.WithHandler(http.HandlerFunc(func(w http.ResponseWriter, req *http.Request) { w.WriteHeader(200) })), ) f.RunServer("", localServer) clientConf := consts.DefaultClientConfig clientConf += fmt.Sprintf(` [[proxies]] name = "test" type = "http" localPort = %d customDomains = ["normal.example.com"] responseHeaders.set.x-from-where = "frp" `, localPort) f.RunProcesses(serverConf, []string{clientConf}) framework.NewRequestExpect(f).Port(vhostHTTPPort). RequestModify(func(r *request.Request) { r.HTTP().HTTPHost("normal.example.com") }). Ensure(func(res *request.Response) bool { return res.Header.Get("X-From-Where") == "frp" }) }) ginkgo.It("Host Header Rewrite", func() { vhostHTTPPort := f.AllocPort() serverConf := getDefaultServerConf(vhostHTTPPort) localPort := f.AllocPort() localServer := httpserver.New( httpserver.WithBindPort(localPort), httpserver.WithHandler(http.HandlerFunc(func(w http.ResponseWriter, req *http.Request) { _, _ = w.Write([]byte(req.Host)) })), ) f.RunServer("", localServer) clientConf := consts.DefaultClientConfig clientConf += fmt.Sprintf(` [[proxies]] name = "test" type = "http" localPort = %d customDomains = ["normal.example.com"] hostHeaderRewrite = "rewrite.example.com" `, localPort) f.RunProcesses(serverConf, []string{clientConf}) framework.NewRequestExpect(f).Port(vhostHTTPPort). RequestModify(func(r *request.Request) { r.HTTP().HTTPHost("normal.example.com") }). ExpectResp([]byte("rewrite.example.com")). // local http server will write host header to response body Ensure() }) ginkgo.It("Websocket protocol", func() { vhostHTTPPort := f.AllocPort() serverConf := getDefaultServerConf(vhostHTTPPort) upgrader := websocket.Upgrader{} localPort := f.AllocPort() localServer := httpserver.New( httpserver.WithBindPort(localPort), httpserver.WithHandler(http.HandlerFunc(func(w http.ResponseWriter, req *http.Request) { c, err := upgrader.Upgrade(w, req, nil) if err != nil { return } defer c.Close() for { mt, message, err := c.ReadMessage() if err != nil { break } err = c.WriteMessage(mt, message) if err != nil { break } } })), ) f.RunServer("", localServer) clientConf := consts.DefaultClientConfig clientConf += fmt.Sprintf(` [[proxies]] name = "test" type = "http" localPort = %d customDomains = ["127.0.0.1"] `, localPort) f.RunProcesses(serverConf, []string{clientConf}) u := url.URL{Scheme: "ws", Host: "127.0.0.1:" + strconv.Itoa(vhostHTTPPort)} c, _, err := websocket.DefaultDialer.Dial(u.String(), nil) framework.ExpectNoError(err) err = c.WriteMessage(websocket.TextMessage, []byte(consts.TestString)) framework.ExpectNoError(err) _, msg, err := c.ReadMessage() framework.ExpectNoError(err) framework.ExpectEqualValues(consts.TestString, string(msg)) }) ginkgo.It("vhostHTTPTimeout", func() { vhostHTTPPort := f.AllocPort() serverConf := getDefaultServerConf(vhostHTTPPort) serverConf += ` vhostHTTPTimeout = 2 ` delayDuration := 0 * time.Second localPort := f.AllocPort() localServer := httpserver.New( httpserver.WithBindPort(localPort), httpserver.WithHandler(http.HandlerFunc(func(w http.ResponseWriter, req *http.Request) { time.Sleep(delayDuration) _, _ = w.Write([]byte(req.Host)) })), ) f.RunServer("", localServer) clientConf := consts.DefaultClientConfig clientConf += fmt.Sprintf(` [[proxies]] name = "test" type = "http" localPort = %d customDomains = ["normal.example.com"] `, localPort) f.RunProcesses(serverConf, []string{clientConf}) framework.NewRequestExpect(f).Port(vhostHTTPPort). RequestModify(func(r *request.Request) { r.HTTP().HTTPHost("normal.example.com").HTTP().Timeout(time.Second) }). ExpectResp([]byte("normal.example.com")). Ensure() delayDuration = 3 * time.Second framework.NewRequestExpect(f).Port(vhostHTTPPort). RequestModify(func(r *request.Request) { r.HTTP().HTTPHost("normal.example.com").HTTP().Timeout(5 * time.Second) }). Ensure(framework.ExpectResponseCode(504)) }) }) ================================================ FILE: test/e2e/v1/basic/oidc.go ================================================ // Copyright 2026 The frp Authors // // Licensed under the Apache License, Version 2.0 (the "License"); // you may not use this file except in compliance with the License. // You may obtain a copy of the License at // // http://www.apache.org/licenses/LICENSE-2.0 // // Unless required by applicable law or agreed to in writing, software // distributed under the License is distributed on an "AS IS" BASIS, // WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. // See the License for the specific language governing permissions and // limitations under the License. package basic import ( "fmt" "time" "github.com/onsi/ginkgo/v2" "github.com/fatedier/frp/test/e2e/framework" "github.com/fatedier/frp/test/e2e/framework/consts" "github.com/fatedier/frp/test/e2e/mock/server/oidcserver" "github.com/fatedier/frp/test/e2e/pkg/port" ) var _ = ginkgo.Describe("[Feature: OIDC]", func() { f := framework.NewDefaultFramework() ginkgo.It("should work with OIDC authentication", func() { oidcSrv := oidcserver.New(oidcserver.WithBindPort(f.AllocPort())) f.RunServer("", oidcSrv) portName := port.GenName("TCP") serverConf := consts.DefaultServerConfig + fmt.Sprintf(` auth.method = "oidc" auth.oidc.issuer = "%s" auth.oidc.audience = "frps" `, oidcSrv.Issuer()) clientConf := consts.DefaultClientConfig + fmt.Sprintf(` auth.method = "oidc" auth.oidc.clientID = "test-client" auth.oidc.clientSecret = "test-secret" auth.oidc.tokenEndpointURL = "%s" [[proxies]] name = "tcp" type = "tcp" localPort = {{ .%s }} remotePort = {{ .%s }} `, oidcSrv.TokenEndpoint(), framework.TCPEchoServerPort, portName) f.RunProcesses(serverConf, []string{clientConf}) framework.NewRequestExpect(f).PortName(portName).Ensure() }) ginkgo.It("should authenticate heartbeats with OIDC", func() { oidcSrv := oidcserver.New(oidcserver.WithBindPort(f.AllocPort())) f.RunServer("", oidcSrv) serverPort := f.AllocPort() remotePort := f.AllocPort() serverConf := fmt.Sprintf(` bindAddr = "0.0.0.0" bindPort = %d log.level = "trace" auth.method = "oidc" auth.additionalScopes = ["HeartBeats"] auth.oidc.issuer = "%s" auth.oidc.audience = "frps" `, serverPort, oidcSrv.Issuer()) clientConf := fmt.Sprintf(` serverAddr = "127.0.0.1" serverPort = %d loginFailExit = false log.level = "trace" auth.method = "oidc" auth.additionalScopes = ["HeartBeats"] auth.oidc.clientID = "test-client" auth.oidc.clientSecret = "test-secret" auth.oidc.tokenEndpointURL = "%s" transport.heartbeatInterval = 1 [[proxies]] name = "tcp" type = "tcp" localPort = %d remotePort = %d `, serverPort, oidcSrv.TokenEndpoint(), f.PortByName(framework.TCPEchoServerPort), remotePort) serverConfigPath := f.GenerateConfigFile(serverConf) clientConfigPath := f.GenerateConfigFile(clientConf) _, _, err := f.RunFrps("-c", serverConfigPath) framework.ExpectNoError(err) clientProcess, _, err := f.RunFrpc("-c", clientConfigPath) framework.ExpectNoError(err) // Wait for several authenticated heartbeat cycles instead of a fixed sleep. err = clientProcess.WaitForOutput("send heartbeat to server", 3, 10*time.Second) framework.ExpectNoError(err) // Proxy should still work: heartbeat auth has not failed. framework.NewRequestExpect(f).Port(remotePort).Ensure() }) ginkgo.It("should work when token has no expires_in", func() { oidcSrv := oidcserver.New( oidcserver.WithBindPort(f.AllocPort()), oidcserver.WithExpiresIn(0), ) f.RunServer("", oidcSrv) portName := port.GenName("TCP") serverConf := consts.DefaultServerConfig + fmt.Sprintf(` auth.method = "oidc" auth.oidc.issuer = "%s" auth.oidc.audience = "frps" `, oidcSrv.Issuer()) clientConf := consts.DefaultClientConfig + fmt.Sprintf(` auth.method = "oidc" auth.additionalScopes = ["HeartBeats"] auth.oidc.clientID = "test-client" auth.oidc.clientSecret = "test-secret" auth.oidc.tokenEndpointURL = "%s" transport.heartbeatInterval = 1 [[proxies]] name = "tcp" type = "tcp" localPort = {{ .%s }} remotePort = {{ .%s }} `, oidcSrv.TokenEndpoint(), framework.TCPEchoServerPort, portName) _, clientProcesses := f.RunProcesses(serverConf, []string{clientConf}) framework.NewRequestExpect(f).PortName(portName).Ensure() countAfterLogin := oidcSrv.TokenRequestCount() // Wait for several heartbeat cycles instead of a fixed sleep. // Each heartbeat fetches a fresh token in non-caching mode. err := clientProcesses[0].WaitForOutput("send heartbeat to server", 3, 10*time.Second) framework.ExpectNoError(err) framework.NewRequestExpect(f).PortName(portName).Ensure() // Each heartbeat should have fetched a new token (non-caching mode). countAfterHeartbeats := oidcSrv.TokenRequestCount() framework.ExpectTrue( countAfterHeartbeats > countAfterLogin, "expected additional token requests for heartbeats, got %d before and %d after", countAfterLogin, countAfterHeartbeats, ) }) ginkgo.It("should reject invalid OIDC credentials", func() { oidcSrv := oidcserver.New(oidcserver.WithBindPort(f.AllocPort())) f.RunServer("", oidcSrv) portName := port.GenName("TCP") serverConf := consts.DefaultServerConfig + fmt.Sprintf(` auth.method = "oidc" auth.oidc.issuer = "%s" auth.oidc.audience = "frps" `, oidcSrv.Issuer()) clientConf := consts.DefaultClientConfig + fmt.Sprintf(` auth.method = "oidc" auth.oidc.clientID = "test-client" auth.oidc.clientSecret = "wrong-secret" auth.oidc.tokenEndpointURL = "%s" [[proxies]] name = "tcp" type = "tcp" localPort = {{ .%s }} remotePort = {{ .%s }} `, oidcSrv.TokenEndpoint(), framework.TCPEchoServerPort, portName) f.RunProcesses(serverConf, []string{clientConf}) framework.NewRequestExpect(f).PortName(portName).ExpectError(true).Ensure() }) }) ================================================ FILE: test/e2e/v1/basic/server.go ================================================ package basic import ( "context" "fmt" "net" "strconv" "github.com/onsi/ginkgo/v2" "github.com/fatedier/frp/test/e2e/framework" "github.com/fatedier/frp/test/e2e/framework/consts" "github.com/fatedier/frp/test/e2e/pkg/port" "github.com/fatedier/frp/test/e2e/pkg/request" ) var _ = ginkgo.Describe("[Feature: Server Manager]", func() { f := framework.NewDefaultFramework() ginkgo.It("Ports Whitelist", func() { serverConf := consts.DefaultServerConfig clientConf := consts.DefaultClientConfig serverConf += ` allowPorts = [ { start = 10000, end = 11000 }, { single = 11002 }, { start = 12000, end = 13000 }, ] ` tcpPortName := port.GenName("TCP", port.WithRangePorts(10000, 11000)) udpPortName := port.GenName("UDP", port.WithRangePorts(12000, 13000)) clientConf += fmt.Sprintf(` [[proxies]] name = "tcp-allowed-in-range" type = "tcp" localPort = {{ .%s }} remotePort = {{ .%s }} `, framework.TCPEchoServerPort, tcpPortName) clientConf += fmt.Sprintf(` [[proxies]] name = "tcp-port-not-allowed" type = "tcp" localPort = {{ .%s }} remotePort = 11001 `, framework.TCPEchoServerPort) clientConf += fmt.Sprintf(` [[proxies]] name = "tcp-port-unavailable" type = "tcp" localPort = {{ .%s }} remotePort = {{ .%s }} `, framework.TCPEchoServerPort, consts.PortServerName) clientConf += fmt.Sprintf(` [[proxies]] name = "udp-allowed-in-range" type = "udp" localPort = {{ .%s }} remotePort = {{ .%s }} `, framework.UDPEchoServerPort, udpPortName) clientConf += fmt.Sprintf(` [[proxies]] name = "udp-port-not-allowed" type = "udp" localPort = {{ .%s }} remotePort = 11003 `, framework.UDPEchoServerPort) f.RunProcesses(serverConf, []string{clientConf}) // TCP // Allowed in range framework.NewRequestExpect(f).PortName(tcpPortName).Ensure() // Not Allowed framework.NewRequestExpect(f).Port(11001).ExpectError(true).Ensure() // Unavailable, already bind by frps framework.NewRequestExpect(f).PortName(consts.PortServerName).ExpectError(true).Ensure() // UDP // Allowed in range framework.NewRequestExpect(f).Protocol("udp").PortName(udpPortName).Ensure() // Not Allowed framework.NewRequestExpect(f).RequestModify(func(r *request.Request) { r.UDP().Port(11003) }).ExpectError(true).Ensure() }) ginkgo.It("Alloc Random Port", func() { serverConf := consts.DefaultServerConfig clientConf := consts.DefaultClientConfig adminPort := f.AllocPort() clientConf += fmt.Sprintf(` webServer.port = %d [[proxies]] name = "tcp" type = "tcp" localPort = {{ .%s }} [[proxies]] name = "udp" type = "udp" localPort = {{ .%s }} `, adminPort, framework.TCPEchoServerPort, framework.UDPEchoServerPort) f.RunProcesses(serverConf, []string{clientConf}) client := f.APIClientForFrpc(adminPort) // tcp random port status, err := client.GetProxyStatus(context.Background(), "tcp") framework.ExpectNoError(err) _, portStr, err := net.SplitHostPort(status.RemoteAddr) framework.ExpectNoError(err) port, err := strconv.Atoi(portStr) framework.ExpectNoError(err) framework.NewRequestExpect(f).Port(port).Ensure() // udp random port status, err = client.GetProxyStatus(context.Background(), "udp") framework.ExpectNoError(err) _, portStr, err = net.SplitHostPort(status.RemoteAddr) framework.ExpectNoError(err) port, err = strconv.Atoi(portStr) framework.ExpectNoError(err) framework.NewRequestExpect(f).Protocol("udp").Port(port).Ensure() }) ginkgo.It("Port Reuse", func() { serverConf := consts.DefaultServerConfig // Use same port as PortServer serverConf += fmt.Sprintf(` vhostHTTPPort = {{ .%s }} `, consts.PortServerName) clientConf := consts.DefaultClientConfig + fmt.Sprintf(` [[proxies]] name = "http" type = "http" localPort = {{ .%s }} customDomains = ["example.com"] `, framework.HTTPSimpleServerPort) f.RunProcesses(serverConf, []string{clientConf}) framework.NewRequestExpect(f).RequestModify(func(r *request.Request) { r.HTTP().HTTPHost("example.com") }).PortName(consts.PortServerName).Ensure() }) ginkgo.It("healthz", func() { serverConf := consts.DefaultServerConfig dashboardPort := f.AllocPort() // Use same port as PortServer serverConf += fmt.Sprintf(` vhostHTTPPort = {{ .%s }} webServer.addr = "0.0.0.0" webServer.port = %d webServer.user = "admin" webServer.password = "admin" `, consts.PortServerName, dashboardPort) clientConf := consts.DefaultClientConfig + fmt.Sprintf(` [[proxies]] name = "http" type = "http" localPort = {{ .%s }} customDomains = ["example.com"] `, framework.HTTPSimpleServerPort) f.RunProcesses(serverConf, []string{clientConf}) framework.NewRequestExpect(f).RequestModify(func(r *request.Request) { r.HTTP().HTTPPath("/healthz") }).Port(dashboardPort).ExpectResp([]byte("")).Ensure() framework.NewRequestExpect(f).RequestModify(func(r *request.Request) { r.HTTP().HTTPPath("/") }).Port(dashboardPort). Ensure(framework.ExpectResponseCode(401)) }) }) ================================================ FILE: test/e2e/v1/basic/tcpmux.go ================================================ package basic import ( "bufio" "fmt" "net" "net/http" "github.com/onsi/ginkgo/v2" httppkg "github.com/fatedier/frp/pkg/util/http" "github.com/fatedier/frp/test/e2e/framework" "github.com/fatedier/frp/test/e2e/framework/consts" "github.com/fatedier/frp/test/e2e/mock/server/streamserver" "github.com/fatedier/frp/test/e2e/pkg/request" "github.com/fatedier/frp/test/e2e/pkg/rpc" ) var _ = ginkgo.Describe("[Feature: TCPMUX httpconnect]", func() { f := framework.NewDefaultFramework() getDefaultServerConf := func(httpconnectPort int) string { conf := consts.DefaultServerConfig + ` tcpmuxHTTPConnectPort = %d ` return fmt.Sprintf(conf, httpconnectPort) } newServer := func(port int, respContent string) *streamserver.Server { return streamserver.New( streamserver.TCP, streamserver.WithBindPort(port), streamserver.WithRespContent([]byte(respContent)), ) } proxyURLWithAuth := func(username, password string, port int) string { if username == "" { return fmt.Sprintf("http://127.0.0.1:%d", port) } return fmt.Sprintf("http://%s:%s@127.0.0.1:%d", username, password, port) } ginkgo.It("Route by HTTP user", func() { vhostPort := f.AllocPort() serverConf := getDefaultServerConf(vhostPort) fooPort := f.AllocPort() f.RunServer("", newServer(fooPort, "foo")) barPort := f.AllocPort() f.RunServer("", newServer(barPort, "bar")) otherPort := f.AllocPort() f.RunServer("", newServer(otherPort, "other")) clientConf := consts.DefaultClientConfig clientConf += fmt.Sprintf(` [[proxies]] name = "foo" type = "tcpmux" multiplexer = "httpconnect" localPort = %d customDomains = ["normal.example.com"] routeByHTTPUser = "user1" [[proxies]] name = "bar" type = "tcpmux" multiplexer = "httpconnect" localPort = %d customDomains = ["normal.example.com"] routeByHTTPUser = "user2" [[proxies]] name = "catchAll" type = "tcpmux" multiplexer = "httpconnect" localPort = %d customDomains = ["normal.example.com"] `, fooPort, barPort, otherPort) f.RunProcesses(serverConf, []string{clientConf}) // user1 framework.NewRequestExpect(f).Explain("user1"). RequestModify(func(r *request.Request) { r.Addr("normal.example.com").Proxy(proxyURLWithAuth("user1", "", vhostPort)) }). ExpectResp([]byte("foo")). Ensure() // user2 framework.NewRequestExpect(f).Explain("user2"). RequestModify(func(r *request.Request) { r.Addr("normal.example.com").Proxy(proxyURLWithAuth("user2", "", vhostPort)) }). ExpectResp([]byte("bar")). Ensure() // other user framework.NewRequestExpect(f).Explain("other user"). RequestModify(func(r *request.Request) { r.Addr("normal.example.com").Proxy(proxyURLWithAuth("user3", "", vhostPort)) }). ExpectResp([]byte("other")). Ensure() }) ginkgo.It("Proxy auth", func() { vhostPort := f.AllocPort() serverConf := getDefaultServerConf(vhostPort) fooPort := f.AllocPort() f.RunServer("", newServer(fooPort, "foo")) clientConf := consts.DefaultClientConfig clientConf += fmt.Sprintf(` [[proxies]] name = "test" type = "tcpmux" multiplexer = "httpconnect" localPort = %d customDomains = ["normal.example.com"] httpUser = "test" httpPassword = "test" `, fooPort) f.RunProcesses(serverConf, []string{clientConf}) // not set auth header framework.NewRequestExpect(f).Explain("no auth"). RequestModify(func(r *request.Request) { r.Addr("normal.example.com").Proxy(proxyURLWithAuth("", "", vhostPort)) }). ExpectError(true). Ensure() // set incorrect auth header framework.NewRequestExpect(f).Explain("incorrect auth"). RequestModify(func(r *request.Request) { r.Addr("normal.example.com").Proxy(proxyURLWithAuth("test", "invalid", vhostPort)) }). ExpectError(true). Ensure() // set correct auth header framework.NewRequestExpect(f).Explain("correct auth"). RequestModify(func(r *request.Request) { r.Addr("normal.example.com").Proxy(proxyURLWithAuth("test", "test", vhostPort)) }). ExpectResp([]byte("foo")). Ensure() }) ginkgo.It("TCPMux Passthrough", func() { vhostPort := f.AllocPort() serverConf := getDefaultServerConf(vhostPort) serverConf += ` tcpmuxPassthrough = true ` var ( respErr error connectRequestHost string ) newServer := func(port int) *streamserver.Server { return streamserver.New( streamserver.TCP, streamserver.WithBindPort(port), streamserver.WithCustomHandler(func(conn net.Conn) { defer conn.Close() // read HTTP CONNECT request bufioReader := bufio.NewReader(conn) req, err := http.ReadRequest(bufioReader) if err != nil { respErr = err return } connectRequestHost = req.Host // return ok response res := httppkg.OkResponse() if res.Body != nil { defer res.Body.Close() } _ = res.Write(conn) buf, err := rpc.ReadBytes(conn) if err != nil { respErr = err return } _, _ = rpc.WriteBytes(conn, buf) }), ) } localPort := f.AllocPort() f.RunServer("", newServer(localPort)) clientConf := consts.DefaultClientConfig clientConf += fmt.Sprintf(` [[proxies]] name = "test" type = "tcpmux" multiplexer = "httpconnect" localPort = %d customDomains = ["normal.example.com"] `, localPort) f.RunProcesses(serverConf, []string{clientConf}) framework.NewRequestExpect(f). RequestModify(func(r *request.Request) { r.Addr("normal.example.com").Proxy(proxyURLWithAuth("", "", vhostPort)).Body([]byte("frp")) }). ExpectResp([]byte("frp")). Ensure() framework.ExpectNoError(respErr) framework.ExpectEqualValues(connectRequestHost, "normal.example.com") }) }) ================================================ FILE: test/e2e/v1/basic/token_source.go ================================================ // Copyright 2025 The frp Authors // // Licensed under the Apache License, Version 2.0 (the "License"); // you may not use this file except in compliance with the License. // You may obtain a copy of the License at // // http://www.apache.org/licenses/LICENSE-2.0 // // Unless required by applicable law or agreed to in writing, software // distributed under the License is distributed on an "AS IS" BASIS, // WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. // See the License for the specific language governing permissions and // limitations under the License. package basic import ( "fmt" "net" "os" "path/filepath" "strconv" "time" "github.com/onsi/ginkgo/v2" "github.com/fatedier/frp/test/e2e/framework" "github.com/fatedier/frp/test/e2e/framework/consts" "github.com/fatedier/frp/test/e2e/pkg/port" ) var _ = ginkgo.Describe("[Feature: TokenSource]", func() { f := framework.NewDefaultFramework() createExecTokenScript := func(name string) string { scriptPath := filepath.Join(f.TempDirectory, name) scriptContent := `#!/bin/sh printf '%s\n' "$1" ` err := os.WriteFile(scriptPath, []byte(scriptContent), 0o600) framework.ExpectNoError(err) err = os.Chmod(scriptPath, 0o700) framework.ExpectNoError(err) return scriptPath } ginkgo.Describe("File-based token loading", func() { ginkgo.It("should work with file tokenSource", func() { // Create a temporary token file tmpDir := f.TempDirectory tokenFile := filepath.Join(tmpDir, "test_token") tokenContent := "test-token-123" err := os.WriteFile(tokenFile, []byte(tokenContent), 0o600) framework.ExpectNoError(err) serverConf := consts.DefaultServerConfig clientConf := consts.DefaultClientConfig portName := port.GenName("TCP") // Server config with tokenSource serverConf += fmt.Sprintf(` auth.tokenSource.type = "file" auth.tokenSource.file.path = "%s" `, tokenFile) // Client config with matching token clientConf += fmt.Sprintf(` auth.token = "%s" [[proxies]] name = "tcp" type = "tcp" localPort = {{ .%s }} remotePort = {{ .%s }} `, tokenContent, framework.TCPEchoServerPort, portName) f.RunProcesses(serverConf, []string{clientConf}) framework.NewRequestExpect(f).PortName(portName).Ensure() }) ginkgo.It("should work with client tokenSource", func() { // Create a temporary token file tmpDir := f.TempDirectory tokenFile := filepath.Join(tmpDir, "client_token") tokenContent := "client-token-456" err := os.WriteFile(tokenFile, []byte(tokenContent), 0o600) framework.ExpectNoError(err) serverConf := consts.DefaultServerConfig clientConf := consts.DefaultClientConfig portName := port.GenName("TCP") // Server config with matching token serverConf += fmt.Sprintf(` auth.token = "%s" `, tokenContent) // Client config with tokenSource clientConf += fmt.Sprintf(` auth.tokenSource.type = "file" auth.tokenSource.file.path = "%s" [[proxies]] name = "tcp" type = "tcp" localPort = {{ .%s }} remotePort = {{ .%s }} `, tokenFile, framework.TCPEchoServerPort, portName) f.RunProcesses(serverConf, []string{clientConf}) framework.NewRequestExpect(f).PortName(portName).Ensure() }) ginkgo.It("should work with both server and client tokenSource", func() { // Create temporary token files tmpDir := f.TempDirectory serverTokenFile := filepath.Join(tmpDir, "server_token") clientTokenFile := filepath.Join(tmpDir, "client_token") tokenContent := "shared-token-789" err := os.WriteFile(serverTokenFile, []byte(tokenContent), 0o600) framework.ExpectNoError(err) err = os.WriteFile(clientTokenFile, []byte(tokenContent), 0o600) framework.ExpectNoError(err) serverConf := consts.DefaultServerConfig clientConf := consts.DefaultClientConfig portName := port.GenName("TCP") // Server config with tokenSource serverConf += fmt.Sprintf(` auth.tokenSource.type = "file" auth.tokenSource.file.path = "%s" `, serverTokenFile) // Client config with tokenSource clientConf += fmt.Sprintf(` auth.tokenSource.type = "file" auth.tokenSource.file.path = "%s" [[proxies]] name = "tcp" type = "tcp" localPort = {{ .%s }} remotePort = {{ .%s }} `, clientTokenFile, framework.TCPEchoServerPort, portName) f.RunProcesses(serverConf, []string{clientConf}) framework.NewRequestExpect(f).PortName(portName).Ensure() }) ginkgo.It("should fail with mismatched tokens", func() { // Create temporary token files with different content tmpDir := f.TempDirectory serverTokenFile := filepath.Join(tmpDir, "server_token") clientTokenFile := filepath.Join(tmpDir, "client_token") err := os.WriteFile(serverTokenFile, []byte("server-token"), 0o600) framework.ExpectNoError(err) err = os.WriteFile(clientTokenFile, []byte("client-token"), 0o600) framework.ExpectNoError(err) serverConf := consts.DefaultServerConfig clientConf := consts.DefaultClientConfig portName := port.GenName("TCP") // Server config with tokenSource serverConf += fmt.Sprintf(` auth.tokenSource.type = "file" auth.tokenSource.file.path = "%s" `, serverTokenFile) // Client config with different tokenSource clientConf += fmt.Sprintf(` auth.tokenSource.type = "file" auth.tokenSource.file.path = "%s" [[proxies]] name = "tcp" type = "tcp" localPort = {{ .%s }} remotePort = {{ .%s }} `, clientTokenFile, framework.TCPEchoServerPort, portName) f.RunProcesses(serverConf, []string{clientConf}) // This should fail due to token mismatch - the client should not be able to connect // We expect the request to fail because the proxy tunnel is not established framework.NewRequestExpect(f).PortName(portName).ExpectError(true).Ensure() }) ginkgo.It("should fail with non-existent token file", func() { tmpDir := f.TempDirectory nonExistentFile := filepath.Join(tmpDir, "non_existent_token") serverPort := f.AllocPort() serverConf := fmt.Sprintf(` bindAddr = "0.0.0.0" bindPort = %d auth.tokenSource.type = "file" auth.tokenSource.file.path = "%s" `, serverPort, nonExistentFile) serverConfigPath := f.GenerateConfigFile(serverConf) _, _, _ = f.RunFrps("-c", serverConfigPath) // Server should have failed to start, so the port should not be listening. conn, err := net.DialTimeout("tcp", net.JoinHostPort("127.0.0.1", strconv.Itoa(serverPort)), 1*time.Second) if err == nil { conn.Close() } framework.ExpectTrue(err != nil, "server should not be listening on port %d", serverPort) }) }) ginkgo.Describe("Exec-based token loading", func() { ginkgo.It("should work with server tokenSource", func() { execValue := "exec-server-value" scriptPath := createExecTokenScript("server_token_exec.sh") serverPort := f.AllocPort() remotePort := f.AllocPort() serverConf := fmt.Sprintf(` bindAddr = "0.0.0.0" bindPort = %d auth.tokenSource.type = "exec" auth.tokenSource.exec.command = %q auth.tokenSource.exec.args = [%q] `, serverPort, scriptPath, execValue) clientConf := fmt.Sprintf(` serverAddr = "127.0.0.1" serverPort = %d loginFailExit = false auth.token = %q [[proxies]] name = "tcp" type = "tcp" localPort = %d remotePort = %d `, serverPort, execValue, f.PortByName(framework.TCPEchoServerPort), remotePort) serverConfigPath := f.GenerateConfigFile(serverConf) clientConfigPath := f.GenerateConfigFile(clientConf) _, _, err := f.RunFrps("-c", serverConfigPath, "--allow-unsafe=TokenSourceExec") framework.ExpectNoError(err) _, _, err = f.RunFrpc("-c", clientConfigPath, "--allow-unsafe=TokenSourceExec") framework.ExpectNoError(err) framework.NewRequestExpect(f).Port(remotePort).Ensure() }) ginkgo.It("should work with client tokenSource", func() { execValue := "exec-client-value" scriptPath := createExecTokenScript("client_token_exec.sh") serverPort := f.AllocPort() remotePort := f.AllocPort() serverConf := fmt.Sprintf(` bindAddr = "0.0.0.0" bindPort = %d auth.token = %q `, serverPort, execValue) clientConf := fmt.Sprintf(` serverAddr = "127.0.0.1" serverPort = %d loginFailExit = false auth.tokenSource.type = "exec" auth.tokenSource.exec.command = %q auth.tokenSource.exec.args = [%q] [[proxies]] name = "tcp" type = "tcp" localPort = %d remotePort = %d `, serverPort, scriptPath, execValue, f.PortByName(framework.TCPEchoServerPort), remotePort) serverConfigPath := f.GenerateConfigFile(serverConf) clientConfigPath := f.GenerateConfigFile(clientConf) _, _, err := f.RunFrps("-c", serverConfigPath, "--allow-unsafe=TokenSourceExec") framework.ExpectNoError(err) _, _, err = f.RunFrpc("-c", clientConfigPath, "--allow-unsafe=TokenSourceExec") framework.ExpectNoError(err) framework.NewRequestExpect(f).Port(remotePort).Ensure() }) ginkgo.It("should work with both server and client tokenSource", func() { execValue := "exec-shared-value" scriptPath := createExecTokenScript("shared_token_exec.sh") serverPort := f.AllocPort() remotePort := f.AllocPort() serverConf := fmt.Sprintf(` bindAddr = "0.0.0.0" bindPort = %d auth.tokenSource.type = "exec" auth.tokenSource.exec.command = %q auth.tokenSource.exec.args = [%q] `, serverPort, scriptPath, execValue) clientConf := fmt.Sprintf(` serverAddr = "127.0.0.1" serverPort = %d loginFailExit = false auth.tokenSource.type = "exec" auth.tokenSource.exec.command = %q auth.tokenSource.exec.args = [%q] [[proxies]] name = "tcp" type = "tcp" localPort = %d remotePort = %d `, serverPort, scriptPath, execValue, f.PortByName(framework.TCPEchoServerPort), remotePort) serverConfigPath := f.GenerateConfigFile(serverConf) clientConfigPath := f.GenerateConfigFile(clientConf) _, _, err := f.RunFrps("-c", serverConfigPath, "--allow-unsafe=TokenSourceExec") framework.ExpectNoError(err) _, _, err = f.RunFrpc("-c", clientConfigPath, "--allow-unsafe=TokenSourceExec") framework.ExpectNoError(err) framework.NewRequestExpect(f).Port(remotePort).Ensure() }) ginkgo.It("should fail validation without allow-unsafe", func() { execValue := "exec-unsafe-value" scriptPath := createExecTokenScript("unsafe_token_exec.sh") serverPort := f.AllocPort() serverConf := fmt.Sprintf(` bindAddr = "0.0.0.0" bindPort = %d auth.tokenSource.type = "exec" auth.tokenSource.exec.command = %q auth.tokenSource.exec.args = [%q] `, serverPort, scriptPath, execValue) serverConfigPath := f.GenerateConfigFile(serverConf) _, output, err := f.RunFrps("verify", "-c", serverConfigPath) framework.ExpectNoError(err) framework.ExpectContainSubstring(output, "unsafe feature \"TokenSourceExec\" is not enabled") }) }) }) ================================================ FILE: test/e2e/v1/basic/xtcp.go ================================================ package basic import ( "fmt" "time" "github.com/onsi/ginkgo/v2" "github.com/fatedier/frp/test/e2e/framework" "github.com/fatedier/frp/test/e2e/framework/consts" "github.com/fatedier/frp/test/e2e/pkg/port" "github.com/fatedier/frp/test/e2e/pkg/request" ) var _ = ginkgo.Describe("[Feature: XTCP]", func() { f := framework.NewDefaultFramework() ginkgo.It("Fallback To STCP", func() { serverConf := consts.DefaultServerConfig clientConf := consts.DefaultClientConfig bindPortName := port.GenName("XTCP") clientConf += fmt.Sprintf(` [[proxies]] name = "foo" type = "stcp" localPort = {{ .%s }} [[visitors]] name = "foo-visitor" type = "stcp" serverName = "foo" bindPort = -1 [[visitors]] name = "bar-visitor" type = "xtcp" serverName = "bar" bindPort = {{ .%s }} keepTunnelOpen = true fallbackTo = "foo-visitor" fallbackTimeoutMs = 200 `, framework.TCPEchoServerPort, bindPortName) f.RunProcesses(serverConf, []string{clientConf}) framework.NewRequestExpect(f). RequestModify(func(r *request.Request) { r.Timeout(time.Second) }). PortName(bindPortName). Ensure() }) }) ================================================ FILE: test/e2e/v1/features/bandwidth_limit.go ================================================ package features import ( "fmt" "strings" "time" "github.com/onsi/ginkgo/v2" plugin "github.com/fatedier/frp/pkg/plugin/server" "github.com/fatedier/frp/test/e2e/framework" "github.com/fatedier/frp/test/e2e/framework/consts" "github.com/fatedier/frp/test/e2e/mock/server/streamserver" pluginpkg "github.com/fatedier/frp/test/e2e/pkg/plugin" "github.com/fatedier/frp/test/e2e/pkg/request" ) var _ = ginkgo.Describe("[Feature: Bandwidth Limit]", func() { f := framework.NewDefaultFramework() ginkgo.It("Proxy Bandwidth Limit by Client", func() { serverConf := consts.DefaultServerConfig clientConf := consts.DefaultClientConfig localPort := f.AllocPort() localServer := streamserver.New(streamserver.TCP, streamserver.WithBindPort(localPort)) f.RunServer("", localServer) remotePort := f.AllocPort() clientConf += fmt.Sprintf(` [[proxies]] name = "tcp" type = "tcp" localPort = %d remotePort = %d transport.bandwidthLimit = "10KB" `, localPort, remotePort) f.RunProcesses(serverConf, []string{clientConf}) content := strings.Repeat("a", 50*1024) // 5KB start := time.Now() framework.NewRequestExpect(f).Port(remotePort).RequestModify(func(r *request.Request) { r.Body([]byte(content)).Timeout(30 * time.Second) }).ExpectResp([]byte(content)).Ensure() duration := time.Since(start) framework.Logf("request duration: %s", duration.String()) framework.ExpectTrue(duration.Seconds() > 8, "100Kb with 10KB limit, want > 8 seconds, but got %s", duration.String()) }) ginkgo.It("Proxy Bandwidth Limit by Server", func() { // new test plugin server newFunc := func() *plugin.Request { var r plugin.Request r.Content = &plugin.NewProxyContent{} return &r } pluginPort := f.AllocPort() handler := func(req *plugin.Request) *plugin.Response { var ret plugin.Response content := req.Content.(*plugin.NewProxyContent) content.BandwidthLimit = "10KB" content.BandwidthLimitMode = "server" ret.Content = content return &ret } pluginServer := pluginpkg.NewHTTPPluginServer(pluginPort, newFunc, handler, nil) f.RunServer("", pluginServer) serverConf := consts.DefaultServerConfig + fmt.Sprintf(` [[httpPlugins]] name = "test" addr = "127.0.0.1:%d" path = "/handler" ops = ["NewProxy"] `, pluginPort) clientConf := consts.DefaultClientConfig localPort := f.AllocPort() localServer := streamserver.New(streamserver.TCP, streamserver.WithBindPort(localPort)) f.RunServer("", localServer) remotePort := f.AllocPort() clientConf += fmt.Sprintf(` [[proxies]] name = "tcp" type = "tcp" localPort = %d remotePort = %d `, localPort, remotePort) f.RunProcesses(serverConf, []string{clientConf}) content := strings.Repeat("a", 50*1024) // 5KB start := time.Now() framework.NewRequestExpect(f).Port(remotePort).RequestModify(func(r *request.Request) { r.Body([]byte(content)).Timeout(30 * time.Second) }).ExpectResp([]byte(content)).Ensure() duration := time.Since(start) framework.Logf("request duration: %s", duration.String()) framework.ExpectTrue(duration.Seconds() > 8, "100Kb with 10KB limit, want > 8 seconds, but got %s", duration.String()) }) }) ================================================ FILE: test/e2e/v1/features/chaos.go ================================================ package features import ( "fmt" "time" "github.com/onsi/ginkgo/v2" "github.com/fatedier/frp/test/e2e/framework" ) var _ = ginkgo.Describe("[Feature: Chaos]", func() { f := framework.NewDefaultFramework() ginkgo.It("reconnect after frps restart", func() { serverPort := f.AllocPort() serverConfigPath := f.GenerateConfigFile(fmt.Sprintf(` bindAddr = "0.0.0.0" bindPort = %d `, serverPort)) remotePort := f.AllocPort() clientConfigPath := f.GenerateConfigFile(fmt.Sprintf(` serverPort = %d log.level = "trace" [[proxies]] name = "tcp" type = "tcp" localPort = %d remotePort = %d `, serverPort, f.PortByName(framework.TCPEchoServerPort), remotePort)) // 1. start frps and frpc, expect request success ps, _, err := f.RunFrps("-c", serverConfigPath) framework.ExpectNoError(err) pc, _, err := f.RunFrpc("-c", clientConfigPath) framework.ExpectNoError(err) framework.NewRequestExpect(f).Port(remotePort).Ensure() // 2. stop frps, expect request failed _ = ps.Stop() framework.NewRequestExpect(f).Port(remotePort).ExpectError(true).Ensure() // 3. restart frps, expect request success successCount := pc.CountOutput("[tcp] start proxy success") _, _, err = f.RunFrps("-c", serverConfigPath) framework.ExpectNoError(err) framework.ExpectNoError(pc.WaitForOutput("[tcp] start proxy success", successCount+1, 5*time.Second)) framework.NewRequestExpect(f).Port(remotePort).Ensure() // 4. stop frpc, expect request failed _ = pc.Stop() framework.ExpectNoError(framework.WaitForTCPUnreachable(fmt.Sprintf("127.0.0.1:%d", remotePort), 100*time.Millisecond, 5*time.Second)) framework.NewRequestExpect(f).Port(remotePort).ExpectError(true).Ensure() // 5. restart frpc, expect request success newPc, _, err := f.RunFrpc("-c", clientConfigPath) framework.ExpectNoError(err) framework.ExpectNoError(newPc.WaitForOutput("[tcp] start proxy success", 1, 5*time.Second)) framework.NewRequestExpect(f).Port(remotePort).Ensure() }) }) ================================================ FILE: test/e2e/v1/features/group.go ================================================ package features import ( "crypto/tls" "fmt" "strconv" "sync" "time" "github.com/onsi/ginkgo/v2" "github.com/fatedier/frp/pkg/transport" "github.com/fatedier/frp/test/e2e/framework" "github.com/fatedier/frp/test/e2e/framework/consts" "github.com/fatedier/frp/test/e2e/mock/server/httpserver" "github.com/fatedier/frp/test/e2e/mock/server/streamserver" "github.com/fatedier/frp/test/e2e/pkg/request" ) var _ = ginkgo.Describe("[Feature: Group]", func() { f := framework.NewDefaultFramework() newHTTPServer := func(port int, respContent string) *httpserver.Server { return httpserver.New( httpserver.WithBindPort(port), httpserver.WithHandler(framework.SpecifiedHTTPBodyHandler([]byte(respContent))), ) } validateFooBarResponse := func(resp *request.Response) bool { if string(resp.Content) == "foo" || string(resp.Content) == "bar" { return true } return false } doFooBarHTTPRequest := func(vhostPort int, host string) []string { results := []string{} var wait sync.WaitGroup var mu sync.Mutex expectFn := func() { framework.NewRequestExpect(f).Port(vhostPort). RequestModify(func(r *request.Request) { r.HTTP().HTTPHost(host) }). Ensure(validateFooBarResponse, func(resp *request.Response) bool { mu.Lock() defer mu.Unlock() results = append(results, string(resp.Content)) return true }) } for range 10 { wait.Go(func() { expectFn() }) } wait.Wait() return results } ginkgo.Describe("Load Balancing", func() { ginkgo.It("TCP", func() { serverConf := consts.DefaultServerConfig clientConf := consts.DefaultClientConfig fooPort := f.AllocPort() fooServer := streamserver.New(streamserver.TCP, streamserver.WithBindPort(fooPort), streamserver.WithRespContent([]byte("foo"))) f.RunServer("", fooServer) barPort := f.AllocPort() barServer := streamserver.New(streamserver.TCP, streamserver.WithBindPort(barPort), streamserver.WithRespContent([]byte("bar"))) f.RunServer("", barServer) remotePort := f.AllocPort() clientConf += fmt.Sprintf(` [[proxies]] name = "foo" type = "tcp" localPort = %d remotePort = %d loadBalancer.group = "test" loadBalancer.groupKey = "123" [[proxies]] name = "bar" type = "tcp" localPort = %d remotePort = %d loadBalancer.group = "test" loadBalancer.groupKey = "123" `, fooPort, remotePort, barPort, remotePort) f.RunProcesses(serverConf, []string{clientConf}) fooCount := 0 barCount := 0 for i := range 10 { framework.NewRequestExpect(f).Explain("times " + strconv.Itoa(i)).Port(remotePort).Ensure(func(resp *request.Response) bool { switch string(resp.Content) { case "foo": fooCount++ case "bar": barCount++ default: return false } return true }) } framework.ExpectTrue(fooCount > 1 && barCount > 1, "fooCount: %d, barCount: %d", fooCount, barCount) }) ginkgo.It("HTTPS", func() { vhostHTTPSPort := f.AllocPort() serverConf := consts.DefaultServerConfig + fmt.Sprintf(` vhostHTTPSPort = %d `, vhostHTTPSPort) clientConf := consts.DefaultClientConfig tlsConfig, err := transport.NewServerTLSConfig("", "", "") framework.ExpectNoError(err) fooPort := f.AllocPort() fooServer := httpserver.New( httpserver.WithBindPort(fooPort), httpserver.WithHandler(framework.SpecifiedHTTPBodyHandler([]byte("foo"))), httpserver.WithTLSConfig(tlsConfig), ) f.RunServer("", fooServer) barPort := f.AllocPort() barServer := httpserver.New( httpserver.WithBindPort(barPort), httpserver.WithHandler(framework.SpecifiedHTTPBodyHandler([]byte("bar"))), httpserver.WithTLSConfig(tlsConfig), ) f.RunServer("", barServer) clientConf += fmt.Sprintf(` [[proxies]] name = "foo" type = "https" localPort = %d customDomains = ["example.com"] loadBalancer.group = "test" loadBalancer.groupKey = "123" [[proxies]] name = "bar" type = "https" localPort = %d customDomains = ["example.com"] loadBalancer.group = "test" loadBalancer.groupKey = "123" `, fooPort, barPort) f.RunProcesses(serverConf, []string{clientConf}) fooCount := 0 barCount := 0 for i := range 10 { framework.NewRequestExpect(f). Explain("times " + strconv.Itoa(i)). Port(vhostHTTPSPort). RequestModify(func(r *request.Request) { r.HTTPS().HTTPHost("example.com").TLSConfig(&tls.Config{ ServerName: "example.com", InsecureSkipVerify: true, }) }). Ensure(func(resp *request.Response) bool { switch string(resp.Content) { case "foo": fooCount++ case "bar": barCount++ default: return false } return true }) } framework.ExpectTrue(fooCount > 1 && barCount > 1, "fooCount: %d, barCount: %d", fooCount, barCount) }) ginkgo.It("TCPMux httpconnect", func() { vhostPort := f.AllocPort() serverConf := consts.DefaultServerConfig + fmt.Sprintf(` tcpmuxHTTPConnectPort = %d `, vhostPort) clientConf := consts.DefaultClientConfig fooPort := f.AllocPort() fooServer := streamserver.New(streamserver.TCP, streamserver.WithBindPort(fooPort), streamserver.WithRespContent([]byte("foo"))) f.RunServer("", fooServer) barPort := f.AllocPort() barServer := streamserver.New(streamserver.TCP, streamserver.WithBindPort(barPort), streamserver.WithRespContent([]byte("bar"))) f.RunServer("", barServer) clientConf += fmt.Sprintf(` [[proxies]] name = "foo" type = "tcpmux" multiplexer = "httpconnect" localPort = %d customDomains = ["tcpmux-group.example.com"] loadBalancer.group = "test" loadBalancer.groupKey = "123" [[proxies]] name = "bar" type = "tcpmux" multiplexer = "httpconnect" localPort = %d customDomains = ["tcpmux-group.example.com"] loadBalancer.group = "test" loadBalancer.groupKey = "123" `, fooPort, barPort) f.RunProcesses(serverConf, []string{clientConf}) proxyURL := fmt.Sprintf("http://127.0.0.1:%d", vhostPort) fooCount := 0 barCount := 0 for i := range 10 { framework.NewRequestExpect(f). Explain("times " + strconv.Itoa(i)). RequestModify(func(r *request.Request) { r.Addr("tcpmux-group.example.com").Proxy(proxyURL) }). Ensure(func(resp *request.Response) bool { switch string(resp.Content) { case "foo": fooCount++ case "bar": barCount++ default: return false } return true }) } framework.ExpectTrue(fooCount > 1 && barCount > 1, "fooCount: %d, barCount: %d", fooCount, barCount) }) }) ginkgo.Describe("Health Check", func() { ginkgo.It("TCP", func() { serverConf := consts.DefaultServerConfig clientConf := consts.DefaultClientConfig fooPort := f.AllocPort() fooServer := streamserver.New(streamserver.TCP, streamserver.WithBindPort(fooPort), streamserver.WithRespContent([]byte("foo"))) f.RunServer("", fooServer) barPort := f.AllocPort() barServer := streamserver.New(streamserver.TCP, streamserver.WithBindPort(barPort), streamserver.WithRespContent([]byte("bar"))) f.RunServer("", barServer) remotePort := f.AllocPort() clientConf += fmt.Sprintf(` [[proxies]] name = "foo" type = "tcp" localPort = %d remotePort = %d loadBalancer.group = "test" loadBalancer.groupKey = "123" healthCheck.type = "tcp" healthCheck.intervalSeconds = 1 [[proxies]] name = "bar" type = "tcp" localPort = %d remotePort = %d loadBalancer.group = "test" loadBalancer.groupKey = "123" healthCheck.type = "tcp" healthCheck.intervalSeconds = 1 `, fooPort, remotePort, barPort, remotePort) _, clientProcesses := f.RunProcesses(serverConf, []string{clientConf}) // check foo and bar is ok results := []string{} for range 10 { framework.NewRequestExpect(f).Port(remotePort).Ensure(validateFooBarResponse, func(resp *request.Response) bool { results = append(results, string(resp.Content)) return true }) } framework.ExpectContainElements(results, []string{"foo", "bar"}) // close bar server, check foo is ok failedCount := clientProcesses[0].CountOutput("[bar] health check failed") barServer.Close() framework.ExpectNoError(clientProcesses[0].WaitForOutput("[bar] health check failed", failedCount+1, 5*time.Second)) for range 10 { framework.NewRequestExpect(f).Port(remotePort).ExpectResp([]byte("foo")).Ensure() } // resume bar server, check foo and bar is ok successCount := clientProcesses[0].CountOutput("[bar] health check success") f.RunServer("", barServer) framework.ExpectNoError(clientProcesses[0].WaitForOutput("[bar] health check success", successCount+1, 5*time.Second)) results = []string{} for range 10 { framework.NewRequestExpect(f).Port(remotePort).Ensure(validateFooBarResponse, func(resp *request.Response) bool { results = append(results, string(resp.Content)) return true }) } framework.ExpectContainElements(results, []string{"foo", "bar"}) }) ginkgo.It("HTTP", func() { vhostPort := f.AllocPort() serverConf := consts.DefaultServerConfig + fmt.Sprintf(` vhostHTTPPort = %d `, vhostPort) clientConf := consts.DefaultClientConfig fooPort := f.AllocPort() fooServer := newHTTPServer(fooPort, "foo") f.RunServer("", fooServer) barPort := f.AllocPort() barServer := newHTTPServer(barPort, "bar") f.RunServer("", barServer) clientConf += fmt.Sprintf(` [[proxies]] name = "foo" type = "http" localPort = %d customDomains = ["example.com"] loadBalancer.group = "test" loadBalancer.groupKey = "123" healthCheck.type = "http" healthCheck.intervalSeconds = 1 healthCheck.path = "/healthz" [[proxies]] name = "bar" type = "http" localPort = %d customDomains = ["example.com"] loadBalancer.group = "test" loadBalancer.groupKey = "123" healthCheck.type = "http" healthCheck.intervalSeconds = 1 healthCheck.path = "/healthz" `, fooPort, barPort) _, clientProcesses := f.RunProcesses(serverConf, []string{clientConf}) // send first HTTP request var contents []string framework.NewRequestExpect(f).Port(vhostPort). RequestModify(func(r *request.Request) { r.HTTP().HTTPHost("example.com") }). Ensure(func(resp *request.Response) bool { contents = append(contents, string(resp.Content)) return true }) // send second HTTP request, should be forwarded to another service framework.NewRequestExpect(f).Port(vhostPort). RequestModify(func(r *request.Request) { r.HTTP().HTTPHost("example.com") }). Ensure(func(resp *request.Response) bool { contents = append(contents, string(resp.Content)) return true }) framework.ExpectContainElements(contents, []string{"foo", "bar"}) // check foo and bar is ok results := doFooBarHTTPRequest(vhostPort, "example.com") framework.ExpectContainElements(results, []string{"foo", "bar"}) // close bar server, check foo is ok failedCount := clientProcesses[0].CountOutput("[bar] health check failed") barServer.Close() framework.ExpectNoError(clientProcesses[0].WaitForOutput("[bar] health check failed", failedCount+1, 5*time.Second)) results = doFooBarHTTPRequest(vhostPort, "example.com") framework.ExpectContainElements(results, []string{"foo"}) framework.ExpectNotContainElements(results, []string{"bar"}) // resume bar server, check foo and bar is ok successCount := clientProcesses[0].CountOutput("[bar] health check success") f.RunServer("", barServer) framework.ExpectNoError(clientProcesses[0].WaitForOutput("[bar] health check success", successCount+1, 5*time.Second)) results = doFooBarHTTPRequest(vhostPort, "example.com") framework.ExpectContainElements(results, []string{"foo", "bar"}) }) }) }) ================================================ FILE: test/e2e/v1/features/heartbeat.go ================================================ package features import ( "fmt" "time" "github.com/onsi/ginkgo/v2" "github.com/fatedier/frp/test/e2e/framework" ) var _ = ginkgo.Describe("[Feature: Heartbeat]", func() { f := framework.NewDefaultFramework() ginkgo.It("disable application layer heartbeat", func() { serverPort := f.AllocPort() serverConf := fmt.Sprintf(` bindAddr = "0.0.0.0" bindPort = %d transport.heartbeatTimeout = -1 transport.tcpMuxKeepaliveInterval = 2 `, serverPort) remotePort := f.AllocPort() clientConf := fmt.Sprintf(` serverPort = %d log.level = "trace" transport.heartbeatInterval = -1 transport.heartbeatTimeout = -1 transport.tcpMuxKeepaliveInterval = 2 [[proxies]] name = "tcp" type = "tcp" localPort = %d remotePort = %d `, serverPort, f.PortByName(framework.TCPEchoServerPort), remotePort) // run frps and frpc f.RunProcesses(serverConf, []string{clientConf}) framework.NewRequestExpect(f).Protocol("tcp").Port(remotePort).Ensure() time.Sleep(5 * time.Second) framework.NewRequestExpect(f).Protocol("tcp").Port(remotePort).Ensure() }) }) ================================================ FILE: test/e2e/v1/features/monitor.go ================================================ package features import ( "fmt" "strings" "time" "github.com/onsi/ginkgo/v2" "github.com/fatedier/frp/pkg/util/log" "github.com/fatedier/frp/test/e2e/framework" "github.com/fatedier/frp/test/e2e/framework/consts" "github.com/fatedier/frp/test/e2e/pkg/request" ) var _ = ginkgo.Describe("[Feature: Monitor]", func() { f := framework.NewDefaultFramework() ginkgo.It("Prometheus metrics", func() { dashboardPort := f.AllocPort() serverConf := consts.DefaultServerConfig + fmt.Sprintf(` enablePrometheus = true webServer.addr = "0.0.0.0" webServer.port = %d `, dashboardPort) clientConf := consts.DefaultClientConfig remotePort := f.AllocPort() clientConf += fmt.Sprintf(` [[proxies]] name = "tcp" type = "tcp" localPort = {{ .%s }} remotePort = %d `, framework.TCPEchoServerPort, remotePort) f.RunProcesses(serverConf, []string{clientConf}) framework.NewRequestExpect(f).Port(remotePort).Ensure() time.Sleep(500 * time.Millisecond) framework.NewRequestExpect(f).RequestModify(func(r *request.Request) { r.HTTP().Port(dashboardPort).HTTPPath("/metrics") }).Ensure(func(resp *request.Response) bool { log.Tracef("prometheus metrics response: \n%s", resp.Content) if resp.Code != 200 { return false } if !strings.Contains(string(resp.Content), "traffic_in") { return false } return true }) }) }) ================================================ FILE: test/e2e/v1/features/real_ip.go ================================================ package features import ( "bufio" "crypto/tls" "fmt" "net" "net/http" "github.com/onsi/ginkgo/v2" pp "github.com/pires/go-proxyproto" "github.com/fatedier/frp/pkg/transport" "github.com/fatedier/frp/pkg/util/log" "github.com/fatedier/frp/test/e2e/framework" "github.com/fatedier/frp/test/e2e/framework/consts" "github.com/fatedier/frp/test/e2e/mock/server/httpserver" "github.com/fatedier/frp/test/e2e/mock/server/streamserver" "github.com/fatedier/frp/test/e2e/pkg/request" "github.com/fatedier/frp/test/e2e/pkg/rpc" ) var _ = ginkgo.Describe("[Feature: Real IP]", func() { f := framework.NewDefaultFramework() ginkgo.Describe("HTTP X-forwarded-For", func() { ginkgo.It("Client Without Header", func() { vhostHTTPPort := f.AllocPort() serverConf := consts.DefaultServerConfig + fmt.Sprintf(` vhostHTTPPort = %d `, vhostHTTPPort) localPort := f.AllocPort() localServer := httpserver.New( httpserver.WithBindPort(localPort), httpserver.WithHandler(http.HandlerFunc(func(w http.ResponseWriter, req *http.Request) { _, _ = w.Write([]byte(req.Header.Get("X-Forwarded-For"))) })), ) f.RunServer("", localServer) clientConf := consts.DefaultClientConfig clientConf += fmt.Sprintf(` [[proxies]] name = "test" type = "http" localPort = %d customDomains = ["normal.example.com"] `, localPort) f.RunProcesses(serverConf, []string{clientConf}) framework.NewRequestExpect(f).Port(vhostHTTPPort). RequestModify(func(r *request.Request) { r.HTTP().HTTPHost("normal.example.com") }). ExpectResp([]byte("127.0.0.1")). Ensure() }) ginkgo.It("Client With Header", func() { vhostHTTPPort := f.AllocPort() serverConf := consts.DefaultServerConfig + fmt.Sprintf(` vhostHTTPPort = %d `, vhostHTTPPort) localPort := f.AllocPort() localServer := httpserver.New( httpserver.WithBindPort(localPort), httpserver.WithHandler(http.HandlerFunc(func(w http.ResponseWriter, req *http.Request) { _, _ = w.Write([]byte(req.Header.Get("X-Forwarded-For"))) })), ) f.RunServer("", localServer) clientConf := consts.DefaultClientConfig clientConf += fmt.Sprintf(` [[proxies]] name = "test" type = "http" localPort = %d customDomains = ["normal.example.com"] `, localPort) f.RunProcesses(serverConf, []string{clientConf}) framework.NewRequestExpect(f).Port(vhostHTTPPort). RequestModify(func(r *request.Request) { r.HTTP().HTTPHost("normal.example.com") r.HTTP().HTTPHeaders(map[string]string{"x-forwarded-for": "2.2.2.2"}) }). ExpectResp([]byte("2.2.2.2, 127.0.0.1")). Ensure() }) ginkgo.It("http2https plugin", func() { vhostHTTPPort := f.AllocPort() serverConf := consts.DefaultServerConfig + fmt.Sprintf(` vhostHTTPPort = %d `, vhostHTTPPort) localPort := f.AllocPort() clientConf := consts.DefaultClientConfig clientConf += fmt.Sprintf(` [[proxies]] name = "test" type = "http" customDomains = ["normal.example.com"] [proxies.plugin] type = "http2https" localAddr = "127.0.0.1:%d" `, localPort) f.RunProcesses(serverConf, []string{clientConf}) tlsConfig, err := transport.NewServerTLSConfig("", "", "") framework.ExpectNoError(err) localServer := httpserver.New( httpserver.WithBindPort(localPort), httpserver.WithHandler(http.HandlerFunc(func(w http.ResponseWriter, req *http.Request) { _, _ = w.Write([]byte(req.Header.Get("X-Forwarded-For"))) })), httpserver.WithTLSConfig(tlsConfig), ) f.RunServer("", localServer) framework.NewRequestExpect(f).Port(vhostHTTPPort). RequestModify(func(r *request.Request) { r.HTTP().HTTPHost("normal.example.com") r.HTTP().HTTPHeaders(map[string]string{"x-forwarded-for": "2.2.2.2, 3.3.3.3"}) }). ExpectResp([]byte("2.2.2.2, 3.3.3.3, 127.0.0.1")). Ensure() }) ginkgo.It("https2http plugin", func() { vhostHTTPSPort := f.AllocPort() serverConf := consts.DefaultServerConfig + fmt.Sprintf(` vhostHTTPSPort = %d `, vhostHTTPSPort) localPort := f.AllocPort() clientConf := consts.DefaultClientConfig clientConf += fmt.Sprintf(` [[proxies]] name = "test" type = "https" customDomains = ["normal.example.com"] [proxies.plugin] type = "https2http" localAddr = "127.0.0.1:%d" `, localPort) f.RunProcesses(serverConf, []string{clientConf}) localServer := httpserver.New( httpserver.WithBindPort(localPort), httpserver.WithHandler(http.HandlerFunc(func(w http.ResponseWriter, req *http.Request) { _, _ = w.Write([]byte(req.Header.Get("X-Forwarded-For"))) })), ) f.RunServer("", localServer) framework.NewRequestExpect(f).Port(vhostHTTPSPort). RequestModify(func(r *request.Request) { r.HTTPS().HTTPHost("normal.example.com"). HTTPHeaders(map[string]string{"x-forwarded-for": "2.2.2.2"}). TLSConfig(&tls.Config{ServerName: "normal.example.com", InsecureSkipVerify: true}) }). ExpectResp([]byte("2.2.2.2, 127.0.0.1")). Ensure() }) }) ginkgo.Describe("Proxy Protocol", func() { ginkgo.It("TCP", func() { serverConf := consts.DefaultServerConfig clientConf := consts.DefaultClientConfig localPort := f.AllocPort() localServer := streamserver.New(streamserver.TCP, streamserver.WithBindPort(localPort), streamserver.WithCustomHandler(func(c net.Conn) { defer c.Close() rd := bufio.NewReader(c) ppHeader, err := pp.Read(rd) if err != nil { log.Errorf("read proxy protocol error: %v", err) return } for { if _, err := rpc.ReadBytes(rd); err != nil { return } buf := []byte(ppHeader.SourceAddr.String()) _, _ = rpc.WriteBytes(c, buf) } })) f.RunServer("", localServer) remotePort := f.AllocPort() clientConf += fmt.Sprintf(` [[proxies]] name = "tcp" type = "tcp" localPort = %d remotePort = %d transport.proxyProtocolVersion = "v2" `, localPort, remotePort) f.RunProcesses(serverConf, []string{clientConf}) framework.NewRequestExpect(f).Port(remotePort).Ensure(func(resp *request.Response) bool { log.Tracef("proxy protocol get SourceAddr: %s", string(resp.Content)) addr, err := net.ResolveTCPAddr("tcp", string(resp.Content)) if err != nil { return false } if addr.IP.String() != "127.0.0.1" { return false } return true }) }) ginkgo.It("UDP", func() { serverConf := consts.DefaultServerConfig clientConf := consts.DefaultClientConfig localPort := f.AllocPort() localServer := streamserver.New(streamserver.UDP, streamserver.WithBindPort(localPort), streamserver.WithCustomHandler(func(c net.Conn) { defer c.Close() rd := bufio.NewReader(c) ppHeader, err := pp.Read(rd) if err != nil { log.Errorf("read proxy protocol error: %v", err) return } // Read the actual UDP content after proxy protocol header if _, err := rpc.ReadBytes(rd); err != nil { return } buf := []byte(ppHeader.SourceAddr.String()) _, _ = rpc.WriteBytes(c, buf) })) f.RunServer("", localServer) remotePort := f.AllocPort() clientConf += fmt.Sprintf(` [[proxies]] name = "udp" type = "udp" localPort = %d remotePort = %d transport.proxyProtocolVersion = "v2" `, localPort, remotePort) f.RunProcesses(serverConf, []string{clientConf}) framework.NewRequestExpect(f).Protocol("udp").Port(remotePort).Ensure(func(resp *request.Response) bool { log.Tracef("udp proxy protocol get SourceAddr: %s", string(resp.Content)) addr, err := net.ResolveUDPAddr("udp", string(resp.Content)) if err != nil { return false } if addr.IP.String() != "127.0.0.1" { return false } return true }) }) ginkgo.It("HTTP", func() { vhostHTTPPort := f.AllocPort() serverConf := consts.DefaultServerConfig + fmt.Sprintf(` vhostHTTPPort = %d `, vhostHTTPPort) clientConf := consts.DefaultClientConfig localPort := f.AllocPort() var srcAddrRecord string localServer := streamserver.New(streamserver.TCP, streamserver.WithBindPort(localPort), streamserver.WithCustomHandler(func(c net.Conn) { defer c.Close() rd := bufio.NewReader(c) ppHeader, err := pp.Read(rd) if err != nil { log.Errorf("read proxy protocol error: %v", err) return } srcAddrRecord = ppHeader.SourceAddr.String() })) f.RunServer("", localServer) clientConf += fmt.Sprintf(` [[proxies]] name = "test" type = "http" localPort = %d customDomains = ["normal.example.com"] transport.proxyProtocolVersion = "v2" `, localPort) f.RunProcesses(serverConf, []string{clientConf}) framework.NewRequestExpect(f).Port(vhostHTTPPort).RequestModify(func(r *request.Request) { r.HTTP().HTTPHost("normal.example.com") }).Ensure(framework.ExpectResponseCode(404)) log.Tracef("proxy protocol get SourceAddr: %s", srcAddrRecord) addr, err := net.ResolveTCPAddr("tcp", srcAddrRecord) framework.ExpectNoError(err, srcAddrRecord) framework.ExpectEqualValues("127.0.0.1", addr.IP.String()) }) }) }) ================================================ FILE: test/e2e/v1/features/ssh_tunnel.go ================================================ package features import ( "crypto/tls" "fmt" "net" "strconv" "time" "github.com/onsi/ginkgo/v2" "github.com/fatedier/frp/pkg/transport" "github.com/fatedier/frp/test/e2e/framework" "github.com/fatedier/frp/test/e2e/framework/consts" "github.com/fatedier/frp/test/e2e/mock/server/httpserver" "github.com/fatedier/frp/test/e2e/mock/server/streamserver" "github.com/fatedier/frp/test/e2e/pkg/request" "github.com/fatedier/frp/test/e2e/pkg/ssh" ) var _ = ginkgo.Describe("[Feature: SSH Tunnel]", func() { f := framework.NewDefaultFramework() ginkgo.It("tcp", func() { sshPort := f.AllocPort() serverConf := consts.DefaultServerConfig + fmt.Sprintf(` sshTunnelGateway.bindPort = %d `, sshPort) f.RunProcesses(serverConf, nil) framework.ExpectNoError(framework.WaitForTCPReady(net.JoinHostPort("127.0.0.1", strconv.Itoa(sshPort)), 5*time.Second)) localPort := f.PortByName(framework.TCPEchoServerPort) remotePort := f.AllocPort() tc := ssh.NewTunnelClient( fmt.Sprintf("127.0.0.1:%d", localPort), fmt.Sprintf("127.0.0.1:%d", sshPort), fmt.Sprintf("tcp --remote-port %d", remotePort), ) framework.ExpectNoError(tc.Start()) defer tc.Close() time.Sleep(time.Second) framework.NewRequestExpect(f).Port(remotePort).Ensure() }) ginkgo.It("http", func() { sshPort := f.AllocPort() vhostPort := f.AllocPort() serverConf := consts.DefaultServerConfig + fmt.Sprintf(` vhostHTTPPort = %d sshTunnelGateway.bindPort = %d `, vhostPort, sshPort) f.RunProcesses(serverConf, nil) framework.ExpectNoError(framework.WaitForTCPReady(net.JoinHostPort("127.0.0.1", strconv.Itoa(sshPort)), 5*time.Second)) localPort := f.PortByName(framework.HTTPSimpleServerPort) tc := ssh.NewTunnelClient( fmt.Sprintf("127.0.0.1:%d", localPort), fmt.Sprintf("127.0.0.1:%d", sshPort), "http --custom-domain test.example.com", ) framework.ExpectNoError(tc.Start()) defer tc.Close() time.Sleep(time.Second) framework.NewRequestExpect(f).Port(vhostPort). RequestModify(func(r *request.Request) { r.HTTP().HTTPHost("test.example.com") }). Ensure() }) ginkgo.It("https", func() { sshPort := f.AllocPort() vhostPort := f.AllocPort() serverConf := consts.DefaultServerConfig + fmt.Sprintf(` vhostHTTPSPort = %d sshTunnelGateway.bindPort = %d `, vhostPort, sshPort) f.RunProcesses(serverConf, nil) framework.ExpectNoError(framework.WaitForTCPReady(net.JoinHostPort("127.0.0.1", strconv.Itoa(sshPort)), 5*time.Second)) localPort := f.AllocPort() testDomain := "test.example.com" tc := ssh.NewTunnelClient( fmt.Sprintf("127.0.0.1:%d", localPort), fmt.Sprintf("127.0.0.1:%d", sshPort), fmt.Sprintf("https --custom-domain %s", testDomain), ) framework.ExpectNoError(tc.Start()) defer tc.Close() tlsConfig, err := transport.NewServerTLSConfig("", "", "") framework.ExpectNoError(err) localServer := httpserver.New( httpserver.WithBindPort(localPort), httpserver.WithTLSConfig(tlsConfig), httpserver.WithResponse([]byte("test")), ) f.RunServer("", localServer) time.Sleep(time.Second) framework.NewRequestExpect(f). Port(vhostPort). RequestModify(func(r *request.Request) { r.HTTPS().HTTPHost(testDomain).TLSConfig(&tls.Config{ ServerName: testDomain, InsecureSkipVerify: true, }) }). ExpectResp([]byte("test")). Ensure() }) ginkgo.It("tcpmux", func() { sshPort := f.AllocPort() tcpmuxPort := f.AllocPort() serverConf := consts.DefaultServerConfig + fmt.Sprintf(` tcpmuxHTTPConnectPort = %d sshTunnelGateway.bindPort = %d `, tcpmuxPort, sshPort) f.RunProcesses(serverConf, nil) framework.ExpectNoError(framework.WaitForTCPReady(net.JoinHostPort("127.0.0.1", strconv.Itoa(sshPort)), 5*time.Second)) localPort := f.AllocPort() testDomain := "test.example.com" tc := ssh.NewTunnelClient( fmt.Sprintf("127.0.0.1:%d", localPort), fmt.Sprintf("127.0.0.1:%d", sshPort), fmt.Sprintf("tcpmux --mux=httpconnect --custom-domain %s", testDomain), ) framework.ExpectNoError(tc.Start()) defer tc.Close() localServer := streamserver.New( streamserver.TCP, streamserver.WithBindPort(localPort), streamserver.WithRespContent([]byte("test")), ) f.RunServer("", localServer) time.Sleep(time.Second) // Request without HTTP connect should get error framework.NewRequestExpect(f). Port(tcpmuxPort). ExpectError(true). Explain("request without HTTP connect expect error"). Ensure() proxyURL := fmt.Sprintf("http://127.0.0.1:%d", tcpmuxPort) // Request with incorrect connect hostname framework.NewRequestExpect(f).RequestModify(func(r *request.Request) { r.Addr("invalid").Proxy(proxyURL) }).ExpectError(true).Explain("request without HTTP connect expect error").Ensure() // Request with correct connect hostname framework.NewRequestExpect(f).RequestModify(func(r *request.Request) { r.Addr(testDomain).Proxy(proxyURL) }).ExpectResp([]byte("test")).Ensure() }) ginkgo.It("stcp", func() { sshPort := f.AllocPort() serverConf := consts.DefaultServerConfig + fmt.Sprintf(` sshTunnelGateway.bindPort = %d `, sshPort) bindPort := f.AllocPort() visitorConf := consts.DefaultClientConfig + fmt.Sprintf(` [[visitors]] name = "stcp-test-visitor" type = "stcp" serverName = "stcp-test" secretKey = "abcdefg" bindPort = %d `, bindPort) f.RunProcesses(serverConf, []string{visitorConf}) framework.ExpectNoError(framework.WaitForTCPReady(net.JoinHostPort("127.0.0.1", strconv.Itoa(sshPort)), 5*time.Second)) localPort := f.PortByName(framework.TCPEchoServerPort) tc := ssh.NewTunnelClient( fmt.Sprintf("127.0.0.1:%d", localPort), fmt.Sprintf("127.0.0.1:%d", sshPort), "stcp -n stcp-test --sk=abcdefg --allow-users=\"*\"", ) framework.ExpectNoError(tc.Start()) defer tc.Close() time.Sleep(time.Second) framework.NewRequestExpect(f). Port(bindPort). Ensure() }) }) ================================================ FILE: test/e2e/v1/features/store.go ================================================ package features import ( "encoding/json" "fmt" "strings" "time" "github.com/onsi/ginkgo/v2" "github.com/fatedier/frp/test/e2e/framework" "github.com/fatedier/frp/test/e2e/framework/consts" "github.com/fatedier/frp/test/e2e/pkg/request" ) var _ = ginkgo.Describe("[Feature: Store]", func() { f := framework.NewDefaultFramework() ginkgo.Describe("Store API", func() { ginkgo.It("create proxy via API and verify connection", func() { adminPort := f.AllocPort() remotePort := f.AllocPort() serverConf := consts.DefaultServerConfig clientConf := consts.DefaultClientConfig + fmt.Sprintf(` webServer.addr = "127.0.0.1" webServer.port = %d [store] path = "%s/store.json" `, adminPort, f.TempDirectory) f.RunProcesses(serverConf, []string{clientConf}) framework.ExpectNoError(framework.WaitForTCPReady(fmt.Sprintf("127.0.0.1:%d", adminPort), 5*time.Second)) proxyConfig := map[string]any{ "name": "test-tcp", "type": "tcp", "tcp": map[string]any{ "localIP": "127.0.0.1", "localPort": f.PortByName(framework.TCPEchoServerPort), "remotePort": remotePort, }, } proxyBody, _ := json.Marshal(proxyConfig) framework.NewRequestExpect(f).RequestModify(func(r *request.Request) { r.HTTP().Port(adminPort).HTTPPath("/api/store/proxies").HTTPParams("POST", "", "/api/store/proxies", map[string]string{ "Content-Type": "application/json", }).Body(proxyBody) }).Ensure(func(resp *request.Response) bool { return resp.Code == 200 }) framework.ExpectNoError(framework.WaitForTCPReady(fmt.Sprintf("127.0.0.1:%d", remotePort), 5*time.Second)) framework.NewRequestExpect(f).Port(remotePort).Ensure() }) ginkgo.It("update proxy via API", func() { adminPort := f.AllocPort() remotePort1 := f.AllocPort() remotePort2 := f.AllocPort() serverConf := consts.DefaultServerConfig clientConf := consts.DefaultClientConfig + fmt.Sprintf(` webServer.addr = "127.0.0.1" webServer.port = %d [store] path = "%s/store.json" `, adminPort, f.TempDirectory) f.RunProcesses(serverConf, []string{clientConf}) framework.ExpectNoError(framework.WaitForTCPReady(fmt.Sprintf("127.0.0.1:%d", adminPort), 5*time.Second)) proxyConfig := map[string]any{ "name": "test-tcp", "type": "tcp", "tcp": map[string]any{ "localIP": "127.0.0.1", "localPort": f.PortByName(framework.TCPEchoServerPort), "remotePort": remotePort1, }, } proxyBody, _ := json.Marshal(proxyConfig) framework.NewRequestExpect(f).RequestModify(func(r *request.Request) { r.HTTP().Port(adminPort).HTTPPath("/api/store/proxies").HTTPParams("POST", "", "/api/store/proxies", map[string]string{ "Content-Type": "application/json", }).Body(proxyBody) }).Ensure(func(resp *request.Response) bool { return resp.Code == 200 }) framework.ExpectNoError(framework.WaitForTCPReady(fmt.Sprintf("127.0.0.1:%d", remotePort1), 5*time.Second)) framework.NewRequestExpect(f).Port(remotePort1).Ensure() proxyConfig["tcp"].(map[string]any)["remotePort"] = remotePort2 proxyBody, _ = json.Marshal(proxyConfig) framework.NewRequestExpect(f).RequestModify(func(r *request.Request) { r.HTTP().Port(adminPort).HTTPPath("/api/store/proxies/test-tcp").HTTPParams("PUT", "", "/api/store/proxies/test-tcp", map[string]string{ "Content-Type": "application/json", }).Body(proxyBody) }).Ensure(func(resp *request.Response) bool { return resp.Code == 200 }) framework.ExpectNoError(framework.WaitForTCPReady(fmt.Sprintf("127.0.0.1:%d", remotePort2), 5*time.Second)) framework.ExpectNoError(framework.WaitForTCPUnreachable(fmt.Sprintf("127.0.0.1:%d", remotePort1), 100*time.Millisecond, 5*time.Second)) framework.NewRequestExpect(f).Port(remotePort2).Ensure() framework.NewRequestExpect(f).Port(remotePort1).ExpectError(true).Ensure() }) ginkgo.It("delete proxy via API", func() { adminPort := f.AllocPort() remotePort := f.AllocPort() serverConf := consts.DefaultServerConfig clientConf := consts.DefaultClientConfig + fmt.Sprintf(` webServer.addr = "127.0.0.1" webServer.port = %d [store] path = "%s/store.json" `, adminPort, f.TempDirectory) f.RunProcesses(serverConf, []string{clientConf}) framework.ExpectNoError(framework.WaitForTCPReady(fmt.Sprintf("127.0.0.1:%d", adminPort), 5*time.Second)) proxyConfig := map[string]any{ "name": "test-tcp", "type": "tcp", "tcp": map[string]any{ "localIP": "127.0.0.1", "localPort": f.PortByName(framework.TCPEchoServerPort), "remotePort": remotePort, }, } proxyBody, _ := json.Marshal(proxyConfig) framework.NewRequestExpect(f).RequestModify(func(r *request.Request) { r.HTTP().Port(adminPort).HTTPPath("/api/store/proxies").HTTPParams("POST", "", "/api/store/proxies", map[string]string{ "Content-Type": "application/json", }).Body(proxyBody) }).Ensure(func(resp *request.Response) bool { return resp.Code == 200 }) framework.ExpectNoError(framework.WaitForTCPReady(fmt.Sprintf("127.0.0.1:%d", remotePort), 5*time.Second)) framework.NewRequestExpect(f).Port(remotePort).Ensure() framework.NewRequestExpect(f).RequestModify(func(r *request.Request) { r.HTTP().Port(adminPort).HTTPPath("/api/store/proxies/test-tcp").HTTPParams("DELETE", "", "/api/store/proxies/test-tcp", nil) }).Ensure(func(resp *request.Response) bool { return resp.Code == 200 }) framework.ExpectNoError(framework.WaitForTCPUnreachable(fmt.Sprintf("127.0.0.1:%d", remotePort), 100*time.Millisecond, 5*time.Second)) framework.NewRequestExpect(f).Port(remotePort).ExpectError(true).Ensure() }) ginkgo.It("list and get proxy via API", func() { adminPort := f.AllocPort() remotePort := f.AllocPort() serverConf := consts.DefaultServerConfig clientConf := consts.DefaultClientConfig + fmt.Sprintf(` webServer.addr = "127.0.0.1" webServer.port = %d [store] path = "%s/store.json" `, adminPort, f.TempDirectory) f.RunProcesses(serverConf, []string{clientConf}) framework.ExpectNoError(framework.WaitForTCPReady(fmt.Sprintf("127.0.0.1:%d", adminPort), 5*time.Second)) proxyConfig := map[string]any{ "name": "test-tcp", "type": "tcp", "tcp": map[string]any{ "localIP": "127.0.0.1", "localPort": f.PortByName(framework.TCPEchoServerPort), "remotePort": remotePort, }, } proxyBody, _ := json.Marshal(proxyConfig) framework.NewRequestExpect(f).RequestModify(func(r *request.Request) { r.HTTP().Port(adminPort).HTTPPath("/api/store/proxies").HTTPParams("POST", "", "/api/store/proxies", map[string]string{ "Content-Type": "application/json", }).Body(proxyBody) }).Ensure(func(resp *request.Response) bool { return resp.Code == 200 }) framework.NewRequestExpect(f).RequestModify(func(r *request.Request) { r.HTTP().Port(adminPort).HTTPPath("/api/store/proxies") }).Ensure(func(resp *request.Response) bool { return resp.Code == 200 && strings.Contains(string(resp.Content), "test-tcp") }) framework.NewRequestExpect(f).RequestModify(func(r *request.Request) { r.HTTP().Port(adminPort).HTTPPath("/api/store/proxies/test-tcp") }).Ensure(func(resp *request.Response) bool { return resp.Code == 200 && strings.Contains(string(resp.Content), "test-tcp") }) framework.NewRequestExpect(f).RequestModify(func(r *request.Request) { r.HTTP().Port(adminPort).HTTPPath("/api/store/proxies/nonexistent") }).Ensure(func(resp *request.Response) bool { return resp.Code == 404 }) }) ginkgo.It("store disabled returns 404", func() { adminPort := f.AllocPort() serverConf := consts.DefaultServerConfig clientConf := consts.DefaultClientConfig + fmt.Sprintf(` webServer.addr = "127.0.0.1" webServer.port = %d `, adminPort) f.RunProcesses(serverConf, []string{clientConf}) framework.ExpectNoError(framework.WaitForTCPReady(fmt.Sprintf("127.0.0.1:%d", adminPort), 5*time.Second)) framework.NewRequestExpect(f).RequestModify(func(r *request.Request) { r.HTTP().Port(adminPort).HTTPPath("/api/store/proxies") }).Ensure(func(resp *request.Response) bool { return resp.Code == 404 }) }) ginkgo.It("rejects mismatched type block", func() { adminPort := f.AllocPort() serverConf := consts.DefaultServerConfig clientConf := consts.DefaultClientConfig + fmt.Sprintf(` webServer.addr = "127.0.0.1" webServer.port = %d [store] path = "%s/store.json" `, adminPort, f.TempDirectory) f.RunProcesses(serverConf, []string{clientConf}) framework.ExpectNoError(framework.WaitForTCPReady(fmt.Sprintf("127.0.0.1:%d", adminPort), 5*time.Second)) invalidBody, _ := json.Marshal(map[string]any{ "name": "bad-proxy", "type": "tcp", "udp": map[string]any{ "localPort": 1234, }, }) framework.NewRequestExpect(f).RequestModify(func(r *request.Request) { r.HTTP().Port(adminPort).HTTPPath("/api/store/proxies").HTTPParams("POST", "", "/api/store/proxies", map[string]string{ "Content-Type": "application/json", }).Body(invalidBody) }).Ensure(func(resp *request.Response) bool { return resp.Code == 400 }) }) ginkgo.It("rejects path/body name mismatch on update", func() { adminPort := f.AllocPort() remotePort := f.AllocPort() serverConf := consts.DefaultServerConfig clientConf := consts.DefaultClientConfig + fmt.Sprintf(` webServer.addr = "127.0.0.1" webServer.port = %d [store] path = "%s/store.json" `, adminPort, f.TempDirectory) f.RunProcesses(serverConf, []string{clientConf}) framework.ExpectNoError(framework.WaitForTCPReady(fmt.Sprintf("127.0.0.1:%d", adminPort), 5*time.Second)) createBody, _ := json.Marshal(map[string]any{ "name": "proxy-a", "type": "tcp", "tcp": map[string]any{ "localIP": "127.0.0.1", "localPort": f.PortByName(framework.TCPEchoServerPort), "remotePort": remotePort, }, }) framework.NewRequestExpect(f).RequestModify(func(r *request.Request) { r.HTTP().Port(adminPort).HTTPPath("/api/store/proxies").HTTPParams("POST", "", "/api/store/proxies", map[string]string{ "Content-Type": "application/json", }).Body(createBody) }).Ensure(func(resp *request.Response) bool { return resp.Code == 200 }) updateBody, _ := json.Marshal(map[string]any{ "name": "proxy-b", "type": "tcp", "tcp": map[string]any{ "localIP": "127.0.0.1", "localPort": f.PortByName(framework.TCPEchoServerPort), "remotePort": remotePort, }, }) framework.NewRequestExpect(f).RequestModify(func(r *request.Request) { r.HTTP().Port(adminPort).HTTPPath("/api/store/proxies/proxy-a").HTTPParams("PUT", "", "/api/store/proxies/proxy-a", map[string]string{ "Content-Type": "application/json", }).Body(updateBody) }).Ensure(func(resp *request.Response) bool { return resp.Code == 400 }) }) }) }) ================================================ FILE: test/e2e/v1/plugin/client.go ================================================ package plugin import ( "crypto/tls" "fmt" "net/http" "strconv" "strings" "github.com/onsi/ginkgo/v2" "github.com/fatedier/frp/pkg/transport" "github.com/fatedier/frp/test/e2e/framework" "github.com/fatedier/frp/test/e2e/framework/consts" "github.com/fatedier/frp/test/e2e/mock/server/httpserver" "github.com/fatedier/frp/test/e2e/pkg/cert" "github.com/fatedier/frp/test/e2e/pkg/port" "github.com/fatedier/frp/test/e2e/pkg/request" ) var _ = ginkgo.Describe("[Feature: Client-Plugins]", func() { f := framework.NewDefaultFramework() ginkgo.Describe("UnixDomainSocket", func() { ginkgo.It("Expose a unix domain socket echo server", func() { serverConf := consts.DefaultServerConfig var clientConf strings.Builder clientConf.WriteString(consts.DefaultClientConfig) getProxyConf := func(proxyName string, portName string, extra string) string { return fmt.Sprintf(` [[proxies]] name = "%s" type = "tcp" remotePort = {{ .%s }} `+extra, proxyName, portName) + fmt.Sprintf(` [proxies.plugin] type = "unix_domain_socket" unixPath = "{{ .%s }}" `, framework.UDSEchoServerAddr) } tests := []struct { proxyName string portName string extraConfig string }{ { proxyName: "normal", portName: port.GenName("Normal"), }, { proxyName: "with-encryption", portName: port.GenName("WithEncryption"), extraConfig: "transport.useEncryption = true", }, { proxyName: "with-compression", portName: port.GenName("WithCompression"), extraConfig: "transport.useCompression = true", }, { proxyName: "with-encryption-and-compression", portName: port.GenName("WithEncryptionAndCompression"), extraConfig: ` transport.useEncryption = true transport.useCompression = true `, }, } // build all client config for _, test := range tests { clientConf.WriteString(getProxyConf(test.proxyName, test.portName, test.extraConfig) + "\n") } // run frps and frpc f.RunProcesses(serverConf, []string{clientConf.String()}) for _, test := range tests { framework.NewRequestExpect(f).Port(f.PortByName(test.portName)).Ensure() } }) }) ginkgo.It("http_proxy", func() { serverConf := consts.DefaultServerConfig clientConf := consts.DefaultClientConfig remotePort := f.AllocPort() clientConf += fmt.Sprintf(` [[proxies]] name = "tcp" type = "tcp" remotePort = %d [proxies.plugin] type = "http_proxy" httpUser = "abc" httpPassword = "123" `, remotePort) f.RunProcesses(serverConf, []string{clientConf}) // http proxy, no auth info framework.NewRequestExpect(f).PortName(framework.HTTPSimpleServerPort).RequestModify(func(r *request.Request) { r.HTTP().Proxy("http://127.0.0.1:" + strconv.Itoa(remotePort)) }).Ensure(framework.ExpectResponseCode(407)) // http proxy, correct auth framework.NewRequestExpect(f).PortName(framework.HTTPSimpleServerPort).RequestModify(func(r *request.Request) { r.HTTP().Proxy("http://abc:123@127.0.0.1:" + strconv.Itoa(remotePort)) }).Ensure() // connect TCP server by CONNECT method framework.NewRequestExpect(f).PortName(framework.TCPEchoServerPort).RequestModify(func(r *request.Request) { r.TCP().Proxy("http://abc:123@127.0.0.1:" + strconv.Itoa(remotePort)) }) }) ginkgo.It("socks5 proxy", func() { serverConf := consts.DefaultServerConfig clientConf := consts.DefaultClientConfig remotePort := f.AllocPort() clientConf += fmt.Sprintf(` [[proxies]] name = "tcp" type = "tcp" remotePort = %d [proxies.plugin] type = "socks5" username = "abc" password = "123" `, remotePort) f.RunProcesses(serverConf, []string{clientConf}) // http proxy, no auth info framework.NewRequestExpect(f).PortName(framework.TCPEchoServerPort).RequestModify(func(r *request.Request) { r.TCP().Proxy("socks5://127.0.0.1:" + strconv.Itoa(remotePort)) }).ExpectError(true).Ensure() // http proxy, correct auth framework.NewRequestExpect(f).PortName(framework.TCPEchoServerPort).RequestModify(func(r *request.Request) { r.TCP().Proxy("socks5://abc:123@127.0.0.1:" + strconv.Itoa(remotePort)) }).Ensure() }) ginkgo.It("static_file", func() { vhostPort := f.AllocPort() serverConf := consts.DefaultServerConfig + fmt.Sprintf(` vhostHTTPPort = %d `, vhostPort) clientConf := consts.DefaultClientConfig remotePort := f.AllocPort() f.WriteTempFile("test_static_file", "foo") clientConf += fmt.Sprintf(` [[proxies]] name = "tcp" type = "tcp" remotePort = %d [proxies.plugin] type = "static_file" localPath = "%s" [[proxies]] name = "http" type = "http" customDomains = ["example.com"] [proxies.plugin] type = "static_file" localPath = "%s" [[proxies]] name = "http-with-auth" type = "http" customDomains = ["other.example.com"] [proxies.plugin] type = "static_file" localPath = "%s" httpUser = "abc" httpPassword = "123" `, remotePort, f.TempDirectory, f.TempDirectory, f.TempDirectory) f.RunProcesses(serverConf, []string{clientConf}) // from tcp proxy framework.NewRequestExpect(f).Request( framework.NewHTTPRequest().HTTPPath("/test_static_file").Port(remotePort), ).ExpectResp([]byte("foo")).Ensure() // from http proxy without auth framework.NewRequestExpect(f).Request( framework.NewHTTPRequest().HTTPHost("example.com").HTTPPath("/test_static_file").Port(vhostPort), ).ExpectResp([]byte("foo")).Ensure() // from http proxy with auth framework.NewRequestExpect(f).Request( framework.NewHTTPRequest().HTTPHost("other.example.com").HTTPPath("/test_static_file").Port(vhostPort).HTTPAuth("abc", "123"), ).ExpectResp([]byte("foo")).Ensure() }) ginkgo.It("http2https", func() { serverConf := consts.DefaultServerConfig vhostHTTPPort := f.AllocPort() serverConf += fmt.Sprintf(` vhostHTTPPort = %d `, vhostHTTPPort) localPort := f.AllocPort() clientConf := consts.DefaultClientConfig + fmt.Sprintf(` [[proxies]] name = "http2https" type = "http" customDomains = ["example.com"] [proxies.plugin] type = "http2https" localAddr = "127.0.0.1:%d" `, localPort) f.RunProcesses(serverConf, []string{clientConf}) tlsConfig, err := transport.NewServerTLSConfig("", "", "") framework.ExpectNoError(err) localServer := httpserver.New( httpserver.WithBindPort(localPort), httpserver.WithTLSConfig(tlsConfig), httpserver.WithResponse([]byte("test")), ) f.RunServer("", localServer) framework.NewRequestExpect(f). Port(vhostHTTPPort). RequestModify(func(r *request.Request) { r.HTTP().HTTPHost("example.com") }). ExpectResp([]byte("test")). Ensure() }) ginkgo.It("https2http", func() { generator := &cert.SelfSignedCertGenerator{} artifacts, err := generator.Generate("example.com") framework.ExpectNoError(err) crtPath := f.WriteTempFile("server.crt", string(artifacts.Cert)) keyPath := f.WriteTempFile("server.key", string(artifacts.Key)) serverConf := consts.DefaultServerConfig vhostHTTPSPort := f.AllocPort() serverConf += fmt.Sprintf(` vhostHTTPSPort = %d `, vhostHTTPSPort) localPort := f.AllocPort() clientConf := consts.DefaultClientConfig + fmt.Sprintf(` [[proxies]] name = "https2http" type = "https" customDomains = ["example.com"] [proxies.plugin] type = "https2http" localAddr = "127.0.0.1:%d" crtPath = "%s" keyPath = "%s" `, localPort, crtPath, keyPath) f.RunProcesses(serverConf, []string{clientConf}) localServer := httpserver.New( httpserver.WithBindPort(localPort), httpserver.WithResponse([]byte("test")), ) f.RunServer("", localServer) framework.NewRequestExpect(f). Port(vhostHTTPSPort). RequestModify(func(r *request.Request) { r.HTTPS().HTTPHost("example.com").TLSConfig(&tls.Config{ ServerName: "example.com", InsecureSkipVerify: true, }) }). ExpectResp([]byte("test")). Ensure() }) ginkgo.It("https2https", func() { generator := &cert.SelfSignedCertGenerator{} artifacts, err := generator.Generate("example.com") framework.ExpectNoError(err) crtPath := f.WriteTempFile("server.crt", string(artifacts.Cert)) keyPath := f.WriteTempFile("server.key", string(artifacts.Key)) serverConf := consts.DefaultServerConfig vhostHTTPSPort := f.AllocPort() serverConf += fmt.Sprintf(` vhostHTTPSPort = %d `, vhostHTTPSPort) localPort := f.AllocPort() clientConf := consts.DefaultClientConfig + fmt.Sprintf(` [[proxies]] name = "https2https" type = "https" customDomains = ["example.com"] [proxies.plugin] type = "https2https" localAddr = "127.0.0.1:%d" crtPath = "%s" keyPath = "%s" `, localPort, crtPath, keyPath) f.RunProcesses(serverConf, []string{clientConf}) tlsConfig, err := transport.NewServerTLSConfig("", "", "") framework.ExpectNoError(err) localServer := httpserver.New( httpserver.WithBindPort(localPort), httpserver.WithResponse([]byte("test")), httpserver.WithTLSConfig(tlsConfig), ) f.RunServer("", localServer) framework.NewRequestExpect(f). Port(vhostHTTPSPort). RequestModify(func(r *request.Request) { r.HTTPS().HTTPHost("example.com").TLSConfig(&tls.Config{ ServerName: "example.com", InsecureSkipVerify: true, }) }). ExpectResp([]byte("test")). Ensure() }) ginkgo.Describe("http2http", func() { ginkgo.It("host header rewrite", func() { serverConf := consts.DefaultServerConfig localPort := f.AllocPort() remotePort := f.AllocPort() clientConf := consts.DefaultClientConfig + fmt.Sprintf(` [[proxies]] name = "http2http" type = "tcp" remotePort = %d [proxies.plugin] type = "http2http" localAddr = "127.0.0.1:%d" hostHeaderRewrite = "rewrite.test.com" `, remotePort, localPort) f.RunProcesses(serverConf, []string{clientConf}) localServer := httpserver.New( httpserver.WithBindPort(localPort), httpserver.WithHandler(http.HandlerFunc(func(w http.ResponseWriter, req *http.Request) { _, _ = w.Write([]byte(req.Host)) })), ) f.RunServer("", localServer) framework.NewRequestExpect(f). Port(remotePort). RequestModify(func(r *request.Request) { r.HTTP().HTTPHost("example.com") }). ExpectResp([]byte("rewrite.test.com")). Ensure() }) ginkgo.It("set request header", func() { serverConf := consts.DefaultServerConfig localPort := f.AllocPort() remotePort := f.AllocPort() clientConf := consts.DefaultClientConfig + fmt.Sprintf(` [[proxies]] name = "http2http" type = "tcp" remotePort = %d [proxies.plugin] type = "http2http" localAddr = "127.0.0.1:%d" requestHeaders.set.x-from-where = "frp" `, remotePort, localPort) f.RunProcesses(serverConf, []string{clientConf}) localServer := httpserver.New( httpserver.WithBindPort(localPort), httpserver.WithHandler(http.HandlerFunc(func(w http.ResponseWriter, req *http.Request) { _, _ = w.Write([]byte(req.Header.Get("x-from-where"))) })), ) f.RunServer("", localServer) framework.NewRequestExpect(f). Port(remotePort). RequestModify(func(r *request.Request) { r.HTTP().HTTPHost("example.com") }). ExpectResp([]byte("frp")). Ensure() }) }) ginkgo.It("tls2raw", func() { generator := &cert.SelfSignedCertGenerator{} artifacts, err := generator.Generate("example.com") framework.ExpectNoError(err) crtPath := f.WriteTempFile("tls2raw_server.crt", string(artifacts.Cert)) keyPath := f.WriteTempFile("tls2raw_server.key", string(artifacts.Key)) serverConf := consts.DefaultServerConfig vhostHTTPSPort := f.AllocPort() serverConf += fmt.Sprintf(` vhostHTTPSPort = %d `, vhostHTTPSPort) localPort := f.AllocPort() clientConf := consts.DefaultClientConfig + fmt.Sprintf(` [[proxies]] name = "tls2raw-test" type = "https" customDomains = ["example.com"] [proxies.plugin] type = "tls2raw" localAddr = "127.0.0.1:%d" crtPath = "%s" keyPath = "%s" `, localPort, crtPath, keyPath) f.RunProcesses(serverConf, []string{clientConf}) localServer := httpserver.New( httpserver.WithBindPort(localPort), httpserver.WithResponse([]byte("test")), ) f.RunServer("", localServer) framework.NewRequestExpect(f). Port(vhostHTTPSPort). RequestModify(func(r *request.Request) { r.HTTPS().HTTPHost("example.com").TLSConfig(&tls.Config{ ServerName: "example.com", InsecureSkipVerify: true, }) }). ExpectResp([]byte("test")). Ensure() }) }) ================================================ FILE: test/e2e/v1/plugin/server.go ================================================ package plugin import ( "fmt" "time" "github.com/onsi/ginkgo/v2" plugin "github.com/fatedier/frp/pkg/plugin/server" "github.com/fatedier/frp/pkg/transport" "github.com/fatedier/frp/test/e2e/framework" "github.com/fatedier/frp/test/e2e/framework/consts" pluginpkg "github.com/fatedier/frp/test/e2e/pkg/plugin" ) var _ = ginkgo.Describe("[Feature: Server-Plugins]", func() { f := framework.NewDefaultFramework() ginkgo.Describe("Login", func() { newFunc := func() *plugin.Request { var r plugin.Request r.Content = &plugin.LoginContent{} return &r } ginkgo.It("Auth for custom meta token", func() { localPort := f.AllocPort() clientAddressGot := false handler := func(req *plugin.Request) *plugin.Response { var ret plugin.Response content := req.Content.(*plugin.LoginContent) if content.ClientAddress != "" { clientAddressGot = true } if content.Metas["token"] == "123" { ret.Unchange = true } else { ret.Reject = true ret.RejectReason = "invalid token" } return &ret } pluginServer := pluginpkg.NewHTTPPluginServer(localPort, newFunc, handler, nil) f.RunServer("", pluginServer) serverConf := consts.DefaultServerConfig + fmt.Sprintf(` [[httpPlugins]] name = "user-manager" addr = "127.0.0.1:%d" path = "/handler" ops = ["Login"] `, localPort) clientConf := consts.DefaultClientConfig remotePort := f.AllocPort() clientConf += fmt.Sprintf(` metadatas.token = "123" [[proxies]] name = "tcp" type = "tcp" localPort = {{ .%s }} remotePort = %d `, framework.TCPEchoServerPort, remotePort) remotePort2 := f.AllocPort() invalidTokenClientConf := consts.DefaultClientConfig + fmt.Sprintf(` [[proxies]] name = "tcp2" type = "tcp" localPort = {{ .%s }} remotePort = %d `, framework.TCPEchoServerPort, remotePort2) f.RunProcesses(serverConf, []string{clientConf, invalidTokenClientConf}) framework.NewRequestExpect(f).Port(remotePort).Ensure() framework.NewRequestExpect(f).Port(remotePort2).ExpectError(true).Ensure() framework.ExpectTrue(clientAddressGot) }) }) ginkgo.Describe("NewProxy", func() { newFunc := func() *plugin.Request { var r plugin.Request r.Content = &plugin.NewProxyContent{} return &r } ginkgo.It("Validate Info", func() { localPort := f.AllocPort() handler := func(req *plugin.Request) *plugin.Response { var ret plugin.Response content := req.Content.(*plugin.NewProxyContent) if content.ProxyName == "tcp" { ret.Unchange = true } else { ret.Reject = true } return &ret } pluginServer := pluginpkg.NewHTTPPluginServer(localPort, newFunc, handler, nil) f.RunServer("", pluginServer) serverConf := consts.DefaultServerConfig + fmt.Sprintf(` [[httpPlugins]] name = "test" addr = "127.0.0.1:%d" path = "/handler" ops = ["NewProxy"] `, localPort) clientConf := consts.DefaultClientConfig remotePort := f.AllocPort() clientConf += fmt.Sprintf(` [[proxies]] name = "tcp" type = "tcp" localPort = {{ .%s }} remotePort = %d `, framework.TCPEchoServerPort, remotePort) f.RunProcesses(serverConf, []string{clientConf}) framework.NewRequestExpect(f).Port(remotePort).Ensure() }) ginkgo.It("Modify RemotePort", func() { localPort := f.AllocPort() remotePort := f.AllocPort() handler := func(req *plugin.Request) *plugin.Response { var ret plugin.Response content := req.Content.(*plugin.NewProxyContent) content.RemotePort = remotePort ret.Content = content return &ret } pluginServer := pluginpkg.NewHTTPPluginServer(localPort, newFunc, handler, nil) f.RunServer("", pluginServer) serverConf := consts.DefaultServerConfig + fmt.Sprintf(` [[httpPlugins]] name = "test" addr = "127.0.0.1:%d" path = "/handler" ops = ["NewProxy"] `, localPort) clientConf := consts.DefaultClientConfig clientConf += fmt.Sprintf(` [[proxies]] name = "tcp" type = "tcp" localPort = {{ .%s }} remotePort = 0 `, framework.TCPEchoServerPort) f.RunProcesses(serverConf, []string{clientConf}) framework.NewRequestExpect(f).Port(remotePort).Ensure() }) }) ginkgo.Describe("CloseProxy", func() { newFunc := func() *plugin.Request { var r plugin.Request r.Content = &plugin.CloseProxyContent{} return &r } ginkgo.It("Validate Info", func() { localPort := f.AllocPort() var recordProxyName string handler := func(req *plugin.Request) *plugin.Response { var ret plugin.Response content := req.Content.(*plugin.CloseProxyContent) recordProxyName = content.ProxyName return &ret } pluginServer := pluginpkg.NewHTTPPluginServer(localPort, newFunc, handler, nil) f.RunServer("", pluginServer) serverConf := consts.DefaultServerConfig + fmt.Sprintf(` [[httpPlugins]] name = "test" addr = "127.0.0.1:%d" path = "/handler" ops = ["CloseProxy"] `, localPort) clientConf := consts.DefaultClientConfig remotePort := f.AllocPort() clientConf += fmt.Sprintf(` [[proxies]] name = "tcp" type = "tcp" localPort = {{ .%s }} remotePort = %d `, framework.TCPEchoServerPort, remotePort) _, clients := f.RunProcesses(serverConf, []string{clientConf}) framework.NewRequestExpect(f).Port(remotePort).Ensure() for _, c := range clients { _ = c.Stop() } time.Sleep(1 * time.Second) framework.ExpectEqual(recordProxyName, "tcp") }) }) ginkgo.Describe("Ping", func() { newFunc := func() *plugin.Request { var r plugin.Request r.Content = &plugin.PingContent{} return &r } ginkgo.It("Validate Info", func() { localPort := f.AllocPort() var record string handler := func(req *plugin.Request) *plugin.Response { var ret plugin.Response content := req.Content.(*plugin.PingContent) record = content.PrivilegeKey ret.Unchange = true return &ret } pluginServer := pluginpkg.NewHTTPPluginServer(localPort, newFunc, handler, nil) f.RunServer("", pluginServer) serverConf := consts.DefaultServerConfig + fmt.Sprintf(` [[httpPlugins]] name = "test" addr = "127.0.0.1:%d" path = "/handler" ops = ["Ping"] `, localPort) remotePort := f.AllocPort() clientConf := consts.DefaultClientConfig clientConf += fmt.Sprintf(` transport.heartbeatInterval = 1 auth.additionalScopes = ["HeartBeats"] [[proxies]] name = "tcp" type = "tcp" localPort = {{ .%s }} remotePort = %d `, framework.TCPEchoServerPort, remotePort) f.RunProcesses(serverConf, []string{clientConf}) framework.NewRequestExpect(f).Port(remotePort).Ensure() time.Sleep(3 * time.Second) framework.ExpectNotEqual("", record) }) }) ginkgo.Describe("NewWorkConn", func() { newFunc := func() *plugin.Request { var r plugin.Request r.Content = &plugin.NewWorkConnContent{} return &r } ginkgo.It("Validate Info", func() { localPort := f.AllocPort() var record string handler := func(req *plugin.Request) *plugin.Response { var ret plugin.Response content := req.Content.(*plugin.NewWorkConnContent) record = content.RunID ret.Unchange = true return &ret } pluginServer := pluginpkg.NewHTTPPluginServer(localPort, newFunc, handler, nil) f.RunServer("", pluginServer) serverConf := consts.DefaultServerConfig + fmt.Sprintf(` [[httpPlugins]] name = "test" addr = "127.0.0.1:%d" path = "/handler" ops = ["NewWorkConn"] `, localPort) remotePort := f.AllocPort() clientConf := consts.DefaultClientConfig clientConf += fmt.Sprintf(` [[proxies]] name = "tcp" type = "tcp" localPort = {{ .%s }} remotePort = %d `, framework.TCPEchoServerPort, remotePort) f.RunProcesses(serverConf, []string{clientConf}) framework.NewRequestExpect(f).Port(remotePort).Ensure() framework.ExpectNotEqual("", record) }) }) ginkgo.Describe("NewUserConn", func() { newFunc := func() *plugin.Request { var r plugin.Request r.Content = &plugin.NewUserConnContent{} return &r } ginkgo.It("Validate Info", func() { localPort := f.AllocPort() var record string handler := func(req *plugin.Request) *plugin.Response { var ret plugin.Response content := req.Content.(*plugin.NewUserConnContent) record = content.RemoteAddr ret.Unchange = true return &ret } pluginServer := pluginpkg.NewHTTPPluginServer(localPort, newFunc, handler, nil) f.RunServer("", pluginServer) serverConf := consts.DefaultServerConfig + fmt.Sprintf(` [[httpPlugins]] name = "test" addr = "127.0.0.1:%d" path = "/handler" ops = ["NewUserConn"] `, localPort) remotePort := f.AllocPort() clientConf := consts.DefaultClientConfig clientConf += fmt.Sprintf(` [[proxies]] name = "tcp" type = "tcp" localPort = {{ .%s }} remotePort = %d `, framework.TCPEchoServerPort, remotePort) f.RunProcesses(serverConf, []string{clientConf}) framework.NewRequestExpect(f).Port(remotePort).Ensure() framework.ExpectNotEqual("", record) }) }) ginkgo.Describe("HTTPS Protocol", func() { newFunc := func() *plugin.Request { var r plugin.Request r.Content = &plugin.NewUserConnContent{} return &r } ginkgo.It("Validate Login Info, disable tls verify", func() { localPort := f.AllocPort() var record string handler := func(req *plugin.Request) *plugin.Response { var ret plugin.Response content := req.Content.(*plugin.NewUserConnContent) record = content.RemoteAddr ret.Unchange = true return &ret } tlsConfig, err := transport.NewServerTLSConfig("", "", "") framework.ExpectNoError(err) pluginServer := pluginpkg.NewHTTPPluginServer(localPort, newFunc, handler, tlsConfig) f.RunServer("", pluginServer) serverConf := consts.DefaultServerConfig + fmt.Sprintf(` [[httpPlugins]] name = "test" addr = "https://127.0.0.1:%d" path = "/handler" ops = ["NewUserConn"] `, localPort) remotePort := f.AllocPort() clientConf := consts.DefaultClientConfig clientConf += fmt.Sprintf(` [[proxies]] name = "tcp" type = "tcp" localPort = {{ .%s }} remotePort = %d `, framework.TCPEchoServerPort, remotePort) f.RunProcesses(serverConf, []string{clientConf}) framework.NewRequestExpect(f).Port(remotePort).Ensure() framework.ExpectNotEqual("", record) }) }) }) ================================================ FILE: web/frpc/.gitignore ================================================ # Logs logs *.log npm-debug.log* yarn-debug.log* yarn-error.log* pnpm-debug.log* lerna-debug.log* node_modules .DS_Store dist dist-ssr coverage *.local /cypress/videos/ /cypress/screenshots/ # Editor directories and files .vscode/* !.vscode/extensions.json .idea *.suo *.ntvs* *.njsproj *.sln *.sw? ================================================ FILE: web/frpc/.prettierrc.json ================================================ { "tabWidth": 2, "semi": false, "singleQuote": true } ================================================ FILE: web/frpc/Makefile ================================================ .PHONY: dist install build preview lint install: @cd .. && npm install build: install @npm run build dev: @npm run dev preview: @npm run preview lint: @npm run lint ================================================ FILE: web/frpc/README.md ================================================ # frpc-dashboard ## Project Setup ```sh yarn install ``` ### Compile and Hot-Reload for Development ```sh make dev ``` ### Type-Check, Compile and Minify for Production ```sh make build ``` ### Lint with [ESLint](https://eslint.org/) ```sh make lint ``` ================================================ FILE: web/frpc/auto-imports.d.ts ================================================ /* eslint-disable */ /* prettier-ignore */ // @ts-nocheck // noinspection JSUnusedGlobalSymbols // Generated by unplugin-auto-import export {} declare global { } ================================================ FILE: web/frpc/components.d.ts ================================================ /* eslint-disable */ /* prettier-ignore */ // @ts-nocheck // Generated by unplugin-vue-components // Read more: https://github.com/vuejs/core/pull/3399 export {} declare module 'vue' { export interface GlobalComponents { ConfigField: typeof import('./src/components/ConfigField.vue')['default'] ConfigSection: typeof import('./src/components/ConfigSection.vue')['default'] ElDialog: typeof import('element-plus/es')['ElDialog'] ElForm: typeof import('element-plus/es')['ElForm'] ElFormItem: typeof import('element-plus/es')['ElFormItem'] ElIcon: typeof import('element-plus/es')['ElIcon'] ElInput: typeof import('element-plus/es')['ElInput'] ElPopover: typeof import('element-plus/es')['ElPopover'] ElRadio: typeof import('element-plus/es')['ElRadio'] ElRadioGroup: typeof import('element-plus/es')['ElRadioGroup'] ElSwitch: typeof import('element-plus/es')['ElSwitch'] KeyValueEditor: typeof import('./src/components/KeyValueEditor.vue')['default'] ProxyAuthSection: typeof import('./src/components/proxy-form/ProxyAuthSection.vue')['default'] ProxyBackendSection: typeof import('./src/components/proxy-form/ProxyBackendSection.vue')['default'] ProxyBaseSection: typeof import('./src/components/proxy-form/ProxyBaseSection.vue')['default'] ProxyCard: typeof import('./src/components/ProxyCard.vue')['default'] ProxyFormLayout: typeof import('./src/components/proxy-form/ProxyFormLayout.vue')['default'] ProxyHealthSection: typeof import('./src/components/proxy-form/ProxyHealthSection.vue')['default'] ProxyHttpSection: typeof import('./src/components/proxy-form/ProxyHttpSection.vue')['default'] ProxyLoadBalanceSection: typeof import('./src/components/proxy-form/ProxyLoadBalanceSection.vue')['default'] ProxyMetadataSection: typeof import('./src/components/proxy-form/ProxyMetadataSection.vue')['default'] ProxyNatSection: typeof import('./src/components/proxy-form/ProxyNatSection.vue')['default'] ProxyRemoteSection: typeof import('./src/components/proxy-form/ProxyRemoteSection.vue')['default'] ProxyTransportSection: typeof import('./src/components/proxy-form/ProxyTransportSection.vue')['default'] RouterLink: typeof import('vue-router')['RouterLink'] RouterView: typeof import('vue-router')['RouterView'] StatusPills: typeof import('./src/components/StatusPills.vue')['default'] StringListEditor: typeof import('./src/components/StringListEditor.vue')['default'] VisitorBaseSection: typeof import('./src/components/visitor-form/VisitorBaseSection.vue')['default'] VisitorConnectionSection: typeof import('./src/components/visitor-form/VisitorConnectionSection.vue')['default'] VisitorFormLayout: typeof import('./src/components/visitor-form/VisitorFormLayout.vue')['default'] VisitorTransportSection: typeof import('./src/components/visitor-form/VisitorTransportSection.vue')['default'] VisitorXtcpSection: typeof import('./src/components/visitor-form/VisitorXtcpSection.vue')['default'] } export interface ComponentCustomProperties { vLoading: typeof import('element-plus/es')['ElLoadingDirective'] } } ================================================ FILE: web/frpc/embed.go ================================================ //go:build !noweb package frpc import ( "embed" "github.com/fatedier/frp/assets" ) //go:embed dist var EmbedFS embed.FS func init() { assets.Register(EmbedFS) } ================================================ FILE: web/frpc/embed_stub.go ================================================ //go:build noweb package frpc ================================================ FILE: web/frpc/env.d.ts ================================================ /// ================================================ FILE: web/frpc/eslint.config.js ================================================ import pluginVue from 'eslint-plugin-vue' import vueTsEslintConfig from '@vue/eslint-config-typescript' import skipFormatting from '@vue/eslint-config-prettier/skip-formatting' export default [ { name: 'app/files-to-lint', files: ['**/*.{ts,mts,tsx,vue}'], }, { name: 'app/files-to-ignore', ignores: ['**/dist/**', '**/dist-ssr/**', '**/coverage/**'], }, ...pluginVue.configs['flat/essential'], ...vueTsEslintConfig(), { rules: { '@typescript-eslint/no-explicit-any': 'off', '@typescript-eslint/no-unused-vars': [ 'warn', { argsIgnorePattern: '^_', varsIgnorePattern: '^_', caughtErrorsIgnorePattern: '^_', }, ], 'vue/multi-word-component-names': [ 'error', { ignores: ['Overview'], }, ], }, }, skipFormatting, ] ================================================ FILE: web/frpc/index.html ================================================ frp client
================================================ FILE: web/frpc/package.json ================================================ { "name": "frpc-dashboard", "version": "0.0.1", "private": true, "type": "module", "scripts": { "dev": "vite", "build": "run-p type-check build-only", "preview": "vite preview", "build-only": "vite build", "type-check": "vue-tsc --noEmit", "lint": "eslint --fix" }, "dependencies": { "element-plus": "^2.13.0", "pinia": "^3.0.4", "vue": "^3.5.26", "vue-router": "^4.6.4" }, "devDependencies": { "@types/node": "24", "@vitejs/plugin-vue": "^6.0.3", "@vue/eslint-config-prettier": "^10.2.0", "@vue/eslint-config-typescript": "^14.7.0", "@vue/tsconfig": "^0.8.1", "@vueuse/core": "^14.1.0", "eslint": "^9.39.0", "eslint-plugin-vue": "^9.33.0", "npm-run-all": "^4.1.5", "prettier": "^3.7.4", "sass": "^1.97.2", "terser": "^5.44.1", "typescript": "^5.9.3", "unplugin-auto-import": "^0.17.5", "unplugin-element-plus": "^0.11.2", "unplugin-vue-components": "^0.26.0", "vite": "^7.3.0", "vite-svg-loader": "^5.1.0", "vue-tsc": "^3.2.2" } } ================================================ FILE: web/frpc/src/App.vue ================================================ ================================================ FILE: web/frpc/src/api/frpc.ts ================================================ import { http } from './http' import type { StatusResponse, ProxyListResp, ProxyDefinition, VisitorListResp, VisitorDefinition, } from '../types' export const getStatus = () => { return http.get('/api/status') } export const getConfig = () => { return http.get('/api/config') } export const putConfig = (content: string) => { return http.put('/api/config', content) } export const reloadConfig = () => { return http.get('/api/reload') } // Config lookup API (any source) export const getProxyConfig = (name: string) => { return http.get( `/api/proxy/${encodeURIComponent(name)}/config`, ) } export const getVisitorConfig = (name: string) => { return http.get( `/api/visitor/${encodeURIComponent(name)}/config`, ) } // Store API - Proxies export const listStoreProxies = () => { return http.get('/api/store/proxies') } export const getStoreProxy = (name: string) => { return http.get( `/api/store/proxies/${encodeURIComponent(name)}`, ) } export const createStoreProxy = (config: ProxyDefinition) => { return http.post('/api/store/proxies', config) } export const updateStoreProxy = (name: string, config: ProxyDefinition) => { return http.put( `/api/store/proxies/${encodeURIComponent(name)}`, config, ) } export const deleteStoreProxy = (name: string) => { return http.delete(`/api/store/proxies/${encodeURIComponent(name)}`) } // Store API - Visitors export const listStoreVisitors = () => { return http.get('/api/store/visitors') } export const getStoreVisitor = (name: string) => { return http.get( `/api/store/visitors/${encodeURIComponent(name)}`, ) } export const createStoreVisitor = (config: VisitorDefinition) => { return http.post('/api/store/visitors', config) } export const updateStoreVisitor = ( name: string, config: VisitorDefinition, ) => { return http.put( `/api/store/visitors/${encodeURIComponent(name)}`, config, ) } export const deleteStoreVisitor = (name: string) => { return http.delete(`/api/store/visitors/${encodeURIComponent(name)}`) } ================================================ FILE: web/frpc/src/api/http.ts ================================================ // http.ts - Base HTTP client class HTTPError extends Error { status: number statusText: string constructor(status: number, statusText: string, message?: string) { super(message || statusText) this.status = status this.statusText = statusText } } async function request(url: string, options: RequestInit = {}): Promise { const defaultOptions: RequestInit = { credentials: 'include', } const response = await fetch(url, { ...defaultOptions, ...options }) if (!response.ok) { throw new HTTPError( response.status, response.statusText, `HTTP ${response.status}`, ) } // Handle empty response (e.g. 204 No Content) if (response.status === 204) { return {} as T } const contentType = response.headers.get('content-type') if (contentType && contentType.includes('application/json')) { return response.json() } return response.text() as unknown as T } export const http = { get: (url: string, options?: RequestInit) => request(url, { ...options, method: 'GET' }), post: (url: string, body?: any, options?: RequestInit) => { const headers: HeadersInit = { ...options?.headers } let requestBody = body if ( body && typeof body === 'object' && !(body instanceof FormData) && !(body instanceof Blob) ) { if (!('Content-Type' in headers)) { ;(headers as any)['Content-Type'] = 'application/json' } requestBody = JSON.stringify(body) } return request(url, { ...options, method: 'POST', headers, body: requestBody, }) }, put: (url: string, body?: any, options?: RequestInit) => { const headers: HeadersInit = { ...options?.headers } let requestBody = body if ( body && typeof body === 'object' && !(body instanceof FormData) && !(body instanceof Blob) ) { if (!('Content-Type' in headers)) { ;(headers as any)['Content-Type'] = 'application/json' } requestBody = JSON.stringify(body) } return request(url, { ...options, method: 'PUT', headers, body: requestBody, }) }, delete: (url: string, options?: RequestInit) => request(url, { ...options, method: 'DELETE' }), } ================================================ FILE: web/frpc/src/assets/css/_form-layout.scss ================================================ @use './mixins' as *; /* Shared form layout styles for proxy/visitor form sections */ .field-row { display: grid; gap: 16px; align-items: start; } .field-row.two-col { grid-template-columns: 1fr 1fr; } .field-row.three-col { grid-template-columns: 1fr 1fr 1fr; } .field-grow { min-width: 0; } .switch-field :deep(.el-form-item__content) { min-height: 32px; display: flex; align-items: center; } @include mobile { .field-row.two-col, .field-row.three-col { grid-template-columns: 1fr; } } ================================================ FILE: web/frpc/src/assets/css/_index.scss ================================================ @forward './variables'; @forward './mixins'; ================================================ FILE: web/frpc/src/assets/css/_mixins.scss ================================================ @use './variables' as vars; @mixin mobile { @media (max-width: #{vars.$breakpoint-mobile - 1px}) { @content; } } @mixin flex-center { display: flex; align-items: center; justify-content: center; } @mixin flex-column { display: flex; flex-direction: column; } @mixin page-scroll { height: 100%; overflow-y: auto; padding: vars.$spacing-xl 40px; > * { max-width: 960px; margin: 0 auto; } @include mobile { padding: vars.$spacing-xl; } } @mixin custom-scrollbar { &::-webkit-scrollbar { width: 6px; height: 6px; } &::-webkit-scrollbar-track { background: transparent; } &::-webkit-scrollbar-thumb { background: #d1d1d1; border-radius: 3px; } } ================================================ FILE: web/frpc/src/assets/css/_variables.scss ================================================ // Typography $font-size-xs: 11px; $font-size-sm: 13px; $font-size-md: 14px; $font-size-lg: 15px; $font-size-xl: 18px; $font-weight-normal: 400; $font-weight-medium: 500; $font-weight-semibold: 600; // Colors - Text $color-text-primary: var(--color-text-primary); $color-text-secondary: var(--color-text-secondary); $color-text-muted: var(--color-text-muted); $color-text-light: var(--color-text-light); // Colors - Background $color-bg-primary: var(--color-bg-primary); $color-bg-secondary: var(--color-bg-secondary); $color-bg-tertiary: var(--color-bg-tertiary); $color-bg-muted: var(--color-bg-muted); $color-bg-hover: var(--color-bg-hover); $color-bg-active: var(--color-bg-active); // Colors - Border $color-border: var(--color-border); $color-border-light: var(--color-border-light); $color-border-lighter: var(--color-border-lighter); // Colors - Status $color-primary: var(--color-primary); $color-danger: var(--color-danger); $color-danger-dark: var(--color-danger-dark); $color-danger-light: var(--color-danger-light); // Colors - Button $color-btn-primary: var(--color-btn-primary); $color-btn-primary-hover: var(--color-btn-primary-hover); // Spacing $spacing-xs: 4px; $spacing-sm: 8px; $spacing-md: 12px; $spacing-lg: 16px; $spacing-xl: 20px; // Border Radius $radius-sm: 6px; $radius-md: 8px; // Transitions $transition-fast: 0.15s ease; $transition-medium: 0.2s ease; // Layout $header-height: 50px; $sidebar-width: 200px; // Breakpoints $breakpoint-mobile: 768px; ================================================ FILE: web/frpc/src/assets/css/dark.css ================================================ /* Dark mode styles */ html.dark { --el-bg-color: #212121; --el-bg-color-page: #181818; --el-bg-color-overlay: #303030; --el-fill-color-blank: #212121; --el-border-color: #404040; --el-border-color-light: #353535; --el-border-color-lighter: #2a2a2a; --el-text-color-primary: #e5e7eb; --el-text-color-secondary: #888888; --el-text-color-placeholder: #afafaf; background-color: #212121; color-scheme: dark; } /* Scrollbar */ html.dark ::-webkit-scrollbar { width: 6px; height: 6px; } html.dark ::-webkit-scrollbar-track { background: #303030; } html.dark ::-webkit-scrollbar-thumb { background: #404040; border-radius: 3px; } html.dark ::-webkit-scrollbar-thumb:hover { background: #505050; } /* Form */ html.dark .el-form-item__label { color: #e5e7eb; } /* Input */ html.dark .el-input__wrapper { background: var(--color-bg-input); box-shadow: 0 0 0 1px #404040 inset; } html.dark .el-input__wrapper:hover { box-shadow: 0 0 0 1px #505050 inset; } html.dark .el-input__wrapper.is-focus { box-shadow: 0 0 0 1px var(--el-color-primary) inset; } html.dark .el-input__inner { color: #e5e7eb; } html.dark .el-input__inner::placeholder { color: #afafaf; } html.dark .el-textarea__inner { background: var(--color-bg-input); box-shadow: 0 0 0 1px #404040 inset; color: #e5e7eb; } html.dark .el-textarea__inner:hover { box-shadow: 0 0 0 1px #505050 inset; } html.dark .el-textarea__inner:focus { box-shadow: 0 0 0 1px var(--el-color-primary) inset; } /* Select */ html.dark .el-select__wrapper { background: var(--color-bg-input); box-shadow: 0 0 0 1px #404040 inset; } html.dark .el-select__wrapper:hover { box-shadow: 0 0 0 1px #505050 inset; } html.dark .el-select__selected-item { color: #e5e7eb; } html.dark .el-select__placeholder { color: #afafaf; } html.dark .el-select-dropdown { background: #303030; border-color: #404040; } html.dark .el-select-dropdown__item { color: #e5e7eb; } html.dark .el-select-dropdown__item:hover { background: #3a3a3a; } html.dark .el-select-dropdown__item.is-selected { color: var(--el-color-primary); } html.dark .el-select-dropdown__item.is-disabled { color: #666666; } /* Tag */ html.dark .el-tag--info { background: #303030; border-color: #404040; color: #b0b0b0; } /* Button */ html.dark .el-button--default { background: #303030; border-color: #404040; color: #e5e7eb; } html.dark .el-button--default:hover { background: #3a3a3a; border-color: #505050; color: #e5e7eb; } /* Card */ html.dark .el-card { background: #212121; border-color: #353535; color: #b0b0b0; } html.dark .el-card__header { border-bottom-color: #353535; color: #e5e7eb; } /* Dialog */ html.dark .el-dialog { background: #212121; } html.dark .el-dialog__title { color: #e5e7eb; } /* Message */ html.dark .el-message { background: #303030; border-color: #404040; } html.dark .el-message--success { background: #1e3d2e; border-color: #3d6b4f; } html.dark .el-message--warning { background: #3d3020; border-color: #6b5020; } html.dark .el-message--error { background: #3d2027; border-color: #5c2d2d; } /* Loading */ html.dark .el-loading-mask { background-color: rgba(33, 33, 33, 0.9); } /* Overlay */ html.dark .el-overlay { background-color: rgba(0, 0, 0, 0.6); } /* Tooltip */ html.dark .el-tooltip__popper { background: #303030 !important; border-color: #404040 !important; color: #e5e7eb !important; } ================================================ FILE: web/frpc/src/assets/css/var.css ================================================ :root { /* Text colors */ --color-text-primary: #303133; --color-text-secondary: #606266; --color-text-muted: #909399; --color-text-light: #c0c4cc; --color-text-placeholder: #a8abb2; /* Background colors */ --color-bg-primary: #ffffff; --color-bg-secondary: #f9f9f9; --color-bg-tertiary: #fafafa; --color-bg-surface: #ffffff; --color-bg-muted: #f4f4f5; --color-bg-input: #ffffff; --color-bg-hover: #efefef; --color-bg-active: #eaeaea; /* Border colors */ --color-border: #dcdfe6; --color-border-light: #e4e7ed; --color-border-lighter: #ebeef5; --color-border-extra-light: #f2f6fc; /* Status colors */ --color-primary: #409eff; --color-primary-light: #ecf5ff; --color-success: #67c23a; --color-warning: #e6a23c; --color-danger: #f56c6c; --color-danger-dark: #c45656; --color-danger-light: #fef0f0; --color-info: #909399; /* Button colors */ --color-btn-primary: #303133; --color-btn-primary-hover: #4a4d5c; /* Element Plus mapping */ --el-color-primary: var(--color-primary); --el-color-success: var(--color-success); --el-color-warning: var(--color-warning); --el-color-danger: var(--color-danger); --el-color-info: var(--color-info); --el-text-color-primary: var(--color-text-primary); --el-text-color-regular: var(--color-text-secondary); --el-text-color-secondary: var(--color-text-muted); --el-text-color-placeholder: var(--color-text-placeholder); --el-bg-color: var(--color-bg-primary); --el-bg-color-page: var(--color-bg-secondary); --el-bg-color-overlay: var(--color-bg-primary); --el-border-color: var(--color-border); --el-border-color-light: var(--color-border-light); --el-border-color-lighter: var(--color-border-lighter); --el-border-color-extra-light: var(--color-border-extra-light); --el-fill-color-blank: var(--color-bg-primary); --el-fill-color-light: var(--color-bg-tertiary); --el-fill-color: var(--color-bg-tertiary); --el-fill-color-dark: var(--color-bg-hover); --el-fill-color-darker: var(--color-bg-active); /* Input */ --el-input-bg-color: var(--color-bg-input); --el-input-border-color: var(--color-border); --el-input-hover-border-color: var(--color-border-light); /* Dialog */ --el-dialog-bg-color: var(--color-bg-primary); --el-overlay-color: rgba(0, 0, 0, 0.5); } html.dark { /* Text colors */ --color-text-primary: #e5e7eb; --color-text-secondary: #b0b0b0; --color-text-muted: #888888; --color-text-light: #666666; --color-text-placeholder: #afafaf; /* Background colors */ --color-bg-primary: #212121; --color-bg-secondary: #181818; --color-bg-tertiary: #303030; --color-bg-surface: #303030; --color-bg-muted: #303030; --color-bg-input: #2f2f2f; --color-bg-hover: #3a3a3a; --color-bg-active: #454545; /* Border colors */ --color-border: #404040; --color-border-light: #353535; --color-border-lighter: #2a2a2a; --color-border-extra-light: #222222; /* Status colors */ --color-primary: #409eff; --color-danger: #f87171; --color-danger-dark: #f87171; --color-danger-light: #3d2027; --color-info: #888888; /* Button colors */ --color-btn-primary: #404040; --color-btn-primary-hover: #505050; /* Dark overrides */ --el-text-color-regular: var(--color-text-primary); --el-overlay-color: rgba(0, 0, 0, 0.7); background-color: #181818; color-scheme: dark; } ================================================ FILE: web/frpc/src/components/ConfigField.vue ================================================ ================================================ FILE: web/frpc/src/components/ConfigSection.vue ================================================ ================================================ FILE: web/frpc/src/components/KeyValueEditor.vue ================================================ ================================================ FILE: web/frpc/src/components/ProxyCard.vue ================================================ ================================================ FILE: web/frpc/src/components/StatusPills.vue ================================================ ================================================ FILE: web/frpc/src/components/StringListEditor.vue ================================================ ================================================ FILE: web/frpc/src/components/proxy-form/ProxyAuthSection.vue ================================================ ================================================ FILE: web/frpc/src/components/proxy-form/ProxyBackendSection.vue ================================================ ================================================ FILE: web/frpc/src/components/proxy-form/ProxyBaseSection.vue ================================================ ================================================ FILE: web/frpc/src/components/proxy-form/ProxyFormLayout.vue ================================================ ================================================ FILE: web/frpc/src/components/proxy-form/ProxyHealthSection.vue ================================================ ================================================ FILE: web/frpc/src/components/proxy-form/ProxyHttpSection.vue ================================================ ================================================ FILE: web/frpc/src/components/proxy-form/ProxyLoadBalanceSection.vue ================================================ ================================================ FILE: web/frpc/src/components/proxy-form/ProxyMetadataSection.vue ================================================ ================================================ FILE: web/frpc/src/components/proxy-form/ProxyNatSection.vue ================================================ ================================================ FILE: web/frpc/src/components/proxy-form/ProxyRemoteSection.vue ================================================ ================================================ FILE: web/frpc/src/components/proxy-form/ProxyTransportSection.vue ================================================ ================================================ FILE: web/frpc/src/components/visitor-form/VisitorBaseSection.vue ================================================ ================================================ FILE: web/frpc/src/components/visitor-form/VisitorConnectionSection.vue ================================================ ================================================ FILE: web/frpc/src/components/visitor-form/VisitorFormLayout.vue ================================================ ================================================ FILE: web/frpc/src/components/visitor-form/VisitorTransportSection.vue ================================================ ================================================ FILE: web/frpc/src/components/visitor-form/VisitorXtcpSection.vue ================================================ ================================================ FILE: web/frpc/src/composables/useResponsive.ts ================================================ import { useBreakpoints } from '@vueuse/core' const breakpoints = useBreakpoints({ mobile: 0, desktop: 768 }) export function useResponsive() { const isMobile = breakpoints.smaller('desktop') // < 768px return { isMobile } } ================================================ FILE: web/frpc/src/main.ts ================================================ import { createApp } from 'vue' import { createPinia } from 'pinia' import 'element-plus/theme-chalk/dark/css-vars.css' import App from './App.vue' import router from './router' import './assets/css/var.css' import './assets/css/dark.css' const app = createApp(App) app.use(createPinia()) app.use(router) app.mount('#app') ================================================ FILE: web/frpc/src/router/index.ts ================================================ import { createRouter, createWebHashHistory } from 'vue-router' import { ElMessage } from 'element-plus' import ClientConfigure from '../views/ClientConfigure.vue' import ProxyEdit from '../views/ProxyEdit.vue' import VisitorEdit from '../views/VisitorEdit.vue' import { useProxyStore } from '../stores/proxy' const router = createRouter({ history: createWebHashHistory(), routes: [ { path: '/', redirect: '/proxies', }, { path: '/proxies', name: 'ProxyList', component: () => import('../views/ProxyList.vue'), }, { path: '/proxies/detail/:name', name: 'ProxyDetail', component: () => import('../views/ProxyDetail.vue'), }, { path: '/proxies/create', name: 'ProxyCreate', component: ProxyEdit, meta: { requiresStore: true }, }, { path: '/proxies/:name/edit', name: 'ProxyEdit', component: ProxyEdit, meta: { requiresStore: true }, }, { path: '/visitors', name: 'VisitorList', component: () => import('../views/VisitorList.vue'), }, { path: '/visitors/detail/:name', name: 'VisitorDetail', component: () => import('../views/VisitorDetail.vue'), }, { path: '/visitors/create', name: 'VisitorCreate', component: VisitorEdit, meta: { requiresStore: true }, }, { path: '/visitors/:name/edit', name: 'VisitorEdit', component: VisitorEdit, meta: { requiresStore: true }, }, { path: '/config', name: 'ClientConfigure', component: ClientConfigure, }, ], }) router.beforeEach(async (to) => { if (!to.matched.some((record) => record.meta.requiresStore)) { return true } const proxyStore = useProxyStore() const enabled = await proxyStore.checkStoreEnabled() if (enabled) { return true } ElMessage.warning( 'Store is disabled. Enable Store in frpc config to create or edit store entries.', ) return { name: 'ProxyList' } }) export default router ================================================ FILE: web/frpc/src/stores/client.ts ================================================ import { defineStore } from 'pinia' import { ref } from 'vue' import { getConfig, putConfig, reloadConfig } from '../api/frpc' export const useClientStore = defineStore('client', () => { const config = ref('') const loading = ref(false) const fetchConfig = async () => { loading.value = true try { config.value = await getConfig() } finally { loading.value = false } } const saveConfig = async (text: string) => { await putConfig(text) config.value = text } const reload = async () => { await reloadConfig() } return { config, loading, fetchConfig, saveConfig, reload } }) ================================================ FILE: web/frpc/src/stores/proxy.ts ================================================ import { defineStore } from 'pinia' import { ref } from 'vue' import type { ProxyStatus, ProxyDefinition } from '../types' import { getStatus, listStoreProxies, getStoreProxy, createStoreProxy, updateStoreProxy, deleteStoreProxy, } from '../api/frpc' export const useProxyStore = defineStore('proxy', () => { const proxies = ref([]) const storeProxies = ref([]) const storeEnabled = ref(false) const storeChecked = ref(false) const loading = ref(false) const storeLoading = ref(false) const error = ref(null) const fetchStatus = async () => { loading.value = true error.value = null try { const json = await getStatus() const list: ProxyStatus[] = [] for (const key in json) { for (const ps of json[key]) { list.push(ps) } } proxies.value = list } catch (err: any) { error.value = err.message throw err } finally { loading.value = false } } const fetchStoreProxies = async () => { storeLoading.value = true try { const res = await listStoreProxies() storeProxies.value = res.proxies || [] storeEnabled.value = true storeChecked.value = true } catch (err: any) { if (err?.status === 404) { storeEnabled.value = false } storeChecked.value = true } finally { storeLoading.value = false } } const checkStoreEnabled = async () => { if (storeChecked.value) return storeEnabled.value await fetchStoreProxies() return storeEnabled.value } const createProxy = async (data: ProxyDefinition) => { await createStoreProxy(data) await fetchStoreProxies() } const updateProxy = async (name: string, data: ProxyDefinition) => { await updateStoreProxy(name, data) await fetchStoreProxies() } const deleteProxy = async (name: string) => { await deleteStoreProxy(name) await fetchStoreProxies() } const toggleProxy = async (name: string, enabled: boolean) => { const def = await getStoreProxy(name) const block = (def as any)[def.type] if (block) { block.enabled = enabled } await updateStoreProxy(name, def) await fetchStatus() await fetchStoreProxies() } const storeProxyWithStatus = (def: ProxyDefinition): ProxyStatus => { const block = (def as any)[def.type] const enabled = block?.enabled !== false const localIP = block?.localIP || '127.0.0.1' const localPort = block?.localPort const local_addr = localPort != null ? `${localIP}:${localPort}` : '' const remotePort = block?.remotePort const remote_addr = remotePort != null ? `:${remotePort}` : '' const plugin = block?.plugin?.type || '' const status = proxies.value.find((p) => p.name === def.name) return { name: def.name, type: def.type, status: !enabled ? 'disabled' : (status?.status || 'waiting'), err: status?.err || '', local_addr: status?.local_addr || local_addr, remote_addr: status?.remote_addr || remote_addr, plugin: status?.plugin || plugin, source: 'store', } } return { proxies, storeProxies, storeEnabled, storeChecked, loading, storeLoading, error, fetchStatus, fetchStoreProxies, checkStoreEnabled, createProxy, updateProxy, deleteProxy, toggleProxy, storeProxyWithStatus, } }) ================================================ FILE: web/frpc/src/stores/visitor.ts ================================================ import { defineStore } from 'pinia' import { ref } from 'vue' import type { VisitorDefinition } from '../types' import { listStoreVisitors, createStoreVisitor, updateStoreVisitor, deleteStoreVisitor, } from '../api/frpc' export const useVisitorStore = defineStore('visitor', () => { const storeVisitors = ref([]) const storeEnabled = ref(false) const storeChecked = ref(false) const loading = ref(false) const error = ref(null) const fetchStoreVisitors = async () => { loading.value = true try { const res = await listStoreVisitors() storeVisitors.value = res.visitors || [] storeEnabled.value = true storeChecked.value = true } catch (err: any) { if (err?.status === 404) { storeEnabled.value = false } storeChecked.value = true } finally { loading.value = false } } const checkStoreEnabled = async () => { if (storeChecked.value) return storeEnabled.value await fetchStoreVisitors() return storeEnabled.value } const createVisitor = async (data: VisitorDefinition) => { await createStoreVisitor(data) await fetchStoreVisitors() } const updateVisitor = async (name: string, data: VisitorDefinition) => { await updateStoreVisitor(name, data) await fetchStoreVisitors() } const deleteVisitor = async (name: string) => { await deleteStoreVisitor(name) await fetchStoreVisitors() } return { storeVisitors, storeEnabled, storeChecked, loading, error, fetchStoreVisitors, checkStoreEnabled, createVisitor, updateVisitor, deleteVisitor, } }) ================================================ FILE: web/frpc/src/svg.d.ts ================================================ declare module '*.svg?component' { import type { DefineComponent } from 'vue' const component: DefineComponent export default component } ================================================ FILE: web/frpc/src/types/constants.ts ================================================ export const PROXY_TYPES = [ 'tcp', 'udp', 'http', 'https', 'tcpmux', 'stcp', 'sudp', 'xtcp', ] as const export type ProxyType = (typeof PROXY_TYPES)[number] export const VISITOR_TYPES = ['stcp', 'sudp', 'xtcp'] as const export type VisitorType = (typeof VISITOR_TYPES)[number] export const PLUGIN_TYPES = [ '', 'http2https', 'http_proxy', 'https2http', 'https2https', 'http2http', 'socks5', 'static_file', 'unix_domain_socket', 'tls2raw', 'virtual_net', ] as const export type PluginType = (typeof PLUGIN_TYPES)[number] ================================================ FILE: web/frpc/src/types/index.ts ================================================ export * from './constants' export * from './proxy-status' export * from './proxy-store' export * from './proxy-form' export * from './proxy-converters' ================================================ FILE: web/frpc/src/types/proxy-converters.ts ================================================ import type { ProxyType, VisitorType } from './constants' import type { ProxyFormData, VisitorFormData } from './proxy-form' import { createDefaultProxyForm, createDefaultVisitorForm } from './proxy-form' import type { ProxyDefinition, VisitorDefinition } from './proxy-store' // ======================================== // CONVERTERS: Form -> Store API // ======================================== export function formToStoreProxy(form: ProxyFormData): ProxyDefinition { const block: Record = {} // Enabled (nil/true = enabled, false = disabled) if (!form.enabled) { block.enabled = false } // Backend - LocalIP/LocalPort if (form.pluginType === '') { if (form.localIP && form.localIP !== '127.0.0.1') { block.localIP = form.localIP } if (form.localPort != null) { block.localPort = form.localPort } } else { block.plugin = { type: form.pluginType, ...form.pluginConfig, } } // Transport if ( form.useEncryption || form.useCompression || form.bandwidthLimit || (form.bandwidthLimitMode && form.bandwidthLimitMode !== 'client') || form.proxyProtocolVersion ) { block.transport = {} if (form.useEncryption) block.transport.useEncryption = true if (form.useCompression) block.transport.useCompression = true if (form.bandwidthLimit) block.transport.bandwidthLimit = form.bandwidthLimit if (form.bandwidthLimitMode && form.bandwidthLimitMode !== 'client') { block.transport.bandwidthLimitMode = form.bandwidthLimitMode } if (form.proxyProtocolVersion) { block.transport.proxyProtocolVersion = form.proxyProtocolVersion } } // Load Balancer if (form.loadBalancerGroup) { block.loadBalancer = { group: form.loadBalancerGroup, } if (form.loadBalancerGroupKey) { block.loadBalancer.groupKey = form.loadBalancerGroupKey } } // Health Check if (form.healthCheckType) { block.healthCheck = { type: form.healthCheckType, } if (form.healthCheckTimeoutSeconds != null) { block.healthCheck.timeoutSeconds = form.healthCheckTimeoutSeconds } if (form.healthCheckMaxFailed != null) { block.healthCheck.maxFailed = form.healthCheckMaxFailed } if (form.healthCheckIntervalSeconds != null) { block.healthCheck.intervalSeconds = form.healthCheckIntervalSeconds } if (form.healthCheckPath) { block.healthCheck.path = form.healthCheckPath } if (form.healthCheckHTTPHeaders.length > 0) { block.healthCheck.httpHeaders = form.healthCheckHTTPHeaders } } // Metadata if (form.metadatas.length > 0) { block.metadatas = Object.fromEntries( form.metadatas.map((m) => [m.key, m.value]), ) } // Annotations if (form.annotations.length > 0) { block.annotations = Object.fromEntries( form.annotations.map((a) => [a.key, a.value]), ) } // Type-specific fields if ((form.type === 'tcp' || form.type === 'udp') && form.remotePort != null) { block.remotePort = form.remotePort } if (form.type === 'http' || form.type === 'https' || form.type === 'tcpmux') { if (form.customDomains.length > 0) { block.customDomains = form.customDomains.filter(Boolean) } if (form.subdomain) { block.subdomain = form.subdomain } } if (form.type === 'http') { if (form.locations.length > 0) { block.locations = form.locations.filter(Boolean) } if (form.httpUser) block.httpUser = form.httpUser if (form.httpPassword) block.httpPassword = form.httpPassword if (form.hostHeaderRewrite) block.hostHeaderRewrite = form.hostHeaderRewrite if (form.routeByHTTPUser) block.routeByHTTPUser = form.routeByHTTPUser if (form.requestHeaders.length > 0) { block.requestHeaders = { set: Object.fromEntries( form.requestHeaders.map((h) => [h.key, h.value]), ), } } if (form.responseHeaders.length > 0) { block.responseHeaders = { set: Object.fromEntries( form.responseHeaders.map((h) => [h.key, h.value]), ), } } } if (form.type === 'tcpmux') { if (form.httpUser) block.httpUser = form.httpUser if (form.httpPassword) block.httpPassword = form.httpPassword if (form.routeByHTTPUser) block.routeByHTTPUser = form.routeByHTTPUser if (form.multiplexer && form.multiplexer !== 'httpconnect') { block.multiplexer = form.multiplexer } } if (form.type === 'stcp' || form.type === 'sudp' || form.type === 'xtcp') { if (form.secretKey) block.secretKey = form.secretKey if (form.allowUsers.length > 0) { block.allowUsers = form.allowUsers.filter(Boolean) } } if (form.type === 'xtcp' && form.natTraversalDisableAssistedAddrs) { block.natTraversal = { disableAssistedAddrs: true, } } return withStoreProxyBlock( { name: form.name, type: form.type, }, form.type, block, ) } export function formToStoreVisitor(form: VisitorFormData): VisitorDefinition { const block: Record = {} if (!form.enabled) { block.enabled = false } if (form.useEncryption || form.useCompression) { block.transport = {} if (form.useEncryption) block.transport.useEncryption = true if (form.useCompression) block.transport.useCompression = true } if (form.secretKey) block.secretKey = form.secretKey if (form.serverUser) block.serverUser = form.serverUser if (form.serverName) block.serverName = form.serverName if (form.bindAddr && form.bindAddr !== '127.0.0.1') { block.bindAddr = form.bindAddr } if (form.bindPort != null) { block.bindPort = form.bindPort } if (form.type === 'xtcp') { if (form.protocol && form.protocol !== 'quic') { block.protocol = form.protocol } if (form.keepTunnelOpen) { block.keepTunnelOpen = true } if (form.maxRetriesAnHour != null) { block.maxRetriesAnHour = form.maxRetriesAnHour } if (form.minRetryInterval != null) { block.minRetryInterval = form.minRetryInterval } if (form.fallbackTo) { block.fallbackTo = form.fallbackTo } if (form.fallbackTimeoutMs != null) { block.fallbackTimeoutMs = form.fallbackTimeoutMs } if (form.natTraversalDisableAssistedAddrs) { block.natTraversal = { disableAssistedAddrs: true, } } } return withStoreVisitorBlock( { name: form.name, type: form.type, }, form.type, block, ) } // ======================================== // CONVERTERS: Store API -> Form // ======================================== function getStoreProxyBlock(config: ProxyDefinition): Record { switch (config.type) { case 'tcp': return config.tcp || {} case 'udp': return config.udp || {} case 'http': return config.http || {} case 'https': return config.https || {} case 'tcpmux': return config.tcpmux || {} case 'stcp': return config.stcp || {} case 'sudp': return config.sudp || {} case 'xtcp': return config.xtcp || {} } } function withStoreProxyBlock( payload: ProxyDefinition, type: ProxyType, block: Record, ): ProxyDefinition { switch (type) { case 'tcp': payload.tcp = block break case 'udp': payload.udp = block break case 'http': payload.http = block break case 'https': payload.https = block break case 'tcpmux': payload.tcpmux = block break case 'stcp': payload.stcp = block break case 'sudp': payload.sudp = block break case 'xtcp': payload.xtcp = block break } return payload } function getStoreVisitorBlock(config: VisitorDefinition): Record { switch (config.type) { case 'stcp': return config.stcp || {} case 'sudp': return config.sudp || {} case 'xtcp': return config.xtcp || {} } } function withStoreVisitorBlock( payload: VisitorDefinition, type: VisitorType, block: Record, ): VisitorDefinition { switch (type) { case 'stcp': payload.stcp = block break case 'sudp': payload.sudp = block break case 'xtcp': payload.xtcp = block break } return payload } export function storeProxyToForm(config: ProxyDefinition): ProxyFormData { const c = getStoreProxyBlock(config) const form = createDefaultProxyForm() form.name = config.name || '' form.type = config.type || 'tcp' form.enabled = c.enabled !== false // Backend form.localIP = c.localIP || '127.0.0.1' form.localPort = c.localPort if (c.plugin?.type) { form.pluginType = c.plugin.type form.pluginConfig = { ...c.plugin } delete form.pluginConfig.type } // Transport if (c.transport) { form.useEncryption = c.transport.useEncryption || false form.useCompression = c.transport.useCompression || false form.bandwidthLimit = c.transport.bandwidthLimit || '' form.bandwidthLimitMode = c.transport.bandwidthLimitMode || 'client' form.proxyProtocolVersion = c.transport.proxyProtocolVersion || '' } // Load Balancer if (c.loadBalancer) { form.loadBalancerGroup = c.loadBalancer.group || '' form.loadBalancerGroupKey = c.loadBalancer.groupKey || '' } // Health Check if (c.healthCheck) { form.healthCheckType = c.healthCheck.type || '' form.healthCheckTimeoutSeconds = c.healthCheck.timeoutSeconds form.healthCheckMaxFailed = c.healthCheck.maxFailed form.healthCheckIntervalSeconds = c.healthCheck.intervalSeconds form.healthCheckPath = c.healthCheck.path || '' form.healthCheckHTTPHeaders = c.healthCheck.httpHeaders || [] } // Metadata if (c.metadatas) { form.metadatas = Object.entries(c.metadatas).map(([key, value]) => ({ key, value: String(value), })) } // Annotations if (c.annotations) { form.annotations = Object.entries(c.annotations).map(([key, value]) => ({ key, value: String(value), })) } // Type-specific fields form.remotePort = c.remotePort // Domain config if (Array.isArray(c.customDomains)) { form.customDomains = c.customDomains } else if (c.customDomains) { form.customDomains = [c.customDomains] } form.subdomain = c.subdomain || '' // HTTP specific if (Array.isArray(c.locations)) { form.locations = c.locations } else if (c.locations) { form.locations = [c.locations] } form.httpUser = c.httpUser || '' form.httpPassword = c.httpPassword || '' form.hostHeaderRewrite = c.hostHeaderRewrite || '' form.routeByHTTPUser = c.routeByHTTPUser || '' // Header operations if (c.requestHeaders?.set) { form.requestHeaders = Object.entries(c.requestHeaders.set).map( ([key, value]) => ({ key, value: String(value) }), ) } if (c.responseHeaders?.set) { form.responseHeaders = Object.entries(c.responseHeaders.set).map( ([key, value]) => ({ key, value: String(value) }), ) } // TCPMux form.multiplexer = c.multiplexer || 'httpconnect' // Secure types form.secretKey = c.secretKey || '' if (Array.isArray(c.allowUsers)) { form.allowUsers = c.allowUsers } else if (c.allowUsers) { form.allowUsers = [c.allowUsers] } // XTCP NAT traversal form.natTraversalDisableAssistedAddrs = c.natTraversal?.disableAssistedAddrs || false return form } export function storeVisitorToForm( config: VisitorDefinition, ): VisitorFormData { const c = getStoreVisitorBlock(config) const form = createDefaultVisitorForm() form.name = config.name || '' form.type = config.type || 'stcp' form.enabled = c.enabled !== false // Transport if (c.transport) { form.useEncryption = c.transport.useEncryption || false form.useCompression = c.transport.useCompression || false } // Base fields form.secretKey = c.secretKey || '' form.serverUser = c.serverUser || '' form.serverName = c.serverName || '' form.bindAddr = c.bindAddr || '127.0.0.1' form.bindPort = c.bindPort // XTCP specific form.protocol = c.protocol || 'quic' form.keepTunnelOpen = c.keepTunnelOpen || false form.maxRetriesAnHour = c.maxRetriesAnHour form.minRetryInterval = c.minRetryInterval form.fallbackTo = c.fallbackTo || '' form.fallbackTimeoutMs = c.fallbackTimeoutMs form.natTraversalDisableAssistedAddrs = c.natTraversal?.disableAssistedAddrs || false return form } ================================================ FILE: web/frpc/src/types/proxy-form.ts ================================================ import type { ProxyType, VisitorType } from './constants' export interface ProxyFormData { // Base fields (ProxyBaseConfig) name: string type: ProxyType enabled: boolean // Backend (ProxyBackend) localIP: string localPort: number | undefined pluginType: string pluginConfig: Record // Transport (ProxyTransport) useEncryption: boolean useCompression: boolean bandwidthLimit: string bandwidthLimitMode: string proxyProtocolVersion: string // Load Balancer (LoadBalancerConfig) loadBalancerGroup: string loadBalancerGroupKey: string // Health Check (HealthCheckConfig) healthCheckType: string healthCheckTimeoutSeconds: number | undefined healthCheckMaxFailed: number | undefined healthCheckIntervalSeconds: number | undefined healthCheckPath: string healthCheckHTTPHeaders: Array<{ name: string; value: string }> // Metadata & Annotations metadatas: Array<{ key: string; value: string }> annotations: Array<{ key: string; value: string }> // TCP/UDP specific remotePort: number | undefined // Domain (HTTP/HTTPS/TCPMux) - DomainConfig customDomains: string[] subdomain: string // HTTP specific (HTTPProxyConfig) locations: string[] httpUser: string httpPassword: string hostHeaderRewrite: string requestHeaders: Array<{ key: string; value: string }> responseHeaders: Array<{ key: string; value: string }> routeByHTTPUser: string // TCPMux specific multiplexer: string // STCP/SUDP/XTCP specific secretKey: string allowUsers: string[] // XTCP specific (NatTraversalConfig) natTraversalDisableAssistedAddrs: boolean } export interface VisitorFormData { // Base fields (VisitorBaseConfig) name: string type: VisitorType enabled: boolean // Transport (VisitorTransport) useEncryption: boolean useCompression: boolean // Connection secretKey: string serverUser: string serverName: string bindAddr: string bindPort: number | undefined // XTCP specific (XTCPVisitorConfig) protocol: string keepTunnelOpen: boolean maxRetriesAnHour: number | undefined minRetryInterval: number | undefined fallbackTo: string fallbackTimeoutMs: number | undefined natTraversalDisableAssistedAddrs: boolean } export function createDefaultProxyForm(): ProxyFormData { return { name: '', type: 'tcp', enabled: true, localIP: '127.0.0.1', localPort: undefined, pluginType: '', pluginConfig: {}, useEncryption: false, useCompression: false, bandwidthLimit: '', bandwidthLimitMode: 'client', proxyProtocolVersion: '', loadBalancerGroup: '', loadBalancerGroupKey: '', healthCheckType: '', healthCheckTimeoutSeconds: undefined, healthCheckMaxFailed: undefined, healthCheckIntervalSeconds: undefined, healthCheckPath: '', healthCheckHTTPHeaders: [], metadatas: [], annotations: [], remotePort: undefined, customDomains: [], subdomain: '', locations: [], httpUser: '', httpPassword: '', hostHeaderRewrite: '', requestHeaders: [], responseHeaders: [], routeByHTTPUser: '', multiplexer: 'httpconnect', secretKey: '', allowUsers: [], natTraversalDisableAssistedAddrs: false, } } export function createDefaultVisitorForm(): VisitorFormData { return { name: '', type: 'stcp', enabled: true, useEncryption: false, useCompression: false, secretKey: '', serverUser: '', serverName: '', bindAddr: '127.0.0.1', bindPort: undefined, protocol: 'quic', keepTunnelOpen: false, maxRetriesAnHour: undefined, minRetryInterval: undefined, fallbackTo: '', fallbackTimeoutMs: undefined, natTraversalDisableAssistedAddrs: false, } } ================================================ FILE: web/frpc/src/types/proxy-status.ts ================================================ export interface ProxyStatus { name: string type: string status: string err: string local_addr: string plugin: string remote_addr: string source?: 'store' | 'config' [key: string]: any } export type StatusResponse = Record ================================================ FILE: web/frpc/src/types/proxy-store.ts ================================================ import type { ProxyType, VisitorType } from './constants' export interface ProxyDefinition { name: string type: ProxyType tcp?: Record udp?: Record http?: Record https?: Record tcpmux?: Record stcp?: Record sudp?: Record xtcp?: Record } export interface VisitorDefinition { name: string type: VisitorType stcp?: Record sudp?: Record xtcp?: Record } export interface ProxyListResp { proxies: ProxyDefinition[] } export interface VisitorListResp { visitors: VisitorDefinition[] } ================================================ FILE: web/frpc/src/utils/format.ts ================================================ export function formatDistanceToNow(date: Date): string { const seconds = Math.floor((new Date().getTime() - date.getTime()) / 1000) let interval = seconds / 31536000 if (interval > 1) return Math.floor(interval) + ' years ago' interval = seconds / 2592000 if (interval > 1) return Math.floor(interval) + ' months ago' interval = seconds / 86400 if (interval > 1) return Math.floor(interval) + ' days ago' interval = seconds / 3600 if (interval > 1) return Math.floor(interval) + ' hours ago' interval = seconds / 60 if (interval > 1) return Math.floor(interval) + ' minutes ago' return Math.floor(seconds) + ' seconds ago' } export function formatFileSize(bytes: number): string { if (!Number.isFinite(bytes) || bytes < 0) return '0 B' if (bytes === 0) return '0 B' const k = 1024 const sizes = ['B', 'KB', 'MB', 'GB', 'TB', 'PB', 'EB', 'ZB', 'YB'] const i = Math.floor(Math.log(bytes) / Math.log(k)) // Prevent index out of bounds for extremely large numbers const unit = sizes[i] || sizes[sizes.length - 1] const val = bytes / Math.pow(k, i) return parseFloat(val.toFixed(2)) + ' ' + unit } ================================================ FILE: web/frpc/src/views/ClientConfigure.vue ================================================ ================================================ FILE: web/frpc/src/views/ProxyDetail.vue ================================================ ================================================ FILE: web/frpc/src/views/ProxyEdit.vue ================================================ ================================================ FILE: web/frpc/src/views/ProxyList.vue ================================================ ================================================ FILE: web/frpc/src/views/VisitorDetail.vue ================================================ ================================================ FILE: web/frpc/src/views/VisitorEdit.vue ================================================ ================================================ FILE: web/frpc/src/views/VisitorList.vue ================================================ ================================================ FILE: web/frpc/tsconfig.json ================================================ { "compilerOptions": { "target": "ES2020", "useDefineForClassFields": true, "module": "ESNext", "lib": ["ES2020", "DOM", "DOM.Iterable"], "skipLibCheck": true, /* Bundler mode */ "moduleResolution": "bundler", "allowImportingTsExtensions": true, "resolveJsonModule": true, "isolatedModules": true, "noEmit": true, "jsx": "preserve", /* Linting */ "strict": true, "noUnusedLocals": true, "noUnusedParameters": true, "noFallthroughCasesInSwitch": true, "paths": { "@/*": ["./src/*"], "@shared/*": ["../shared/*"] } }, "include": ["src/**/*.ts", "src/**/*.tsx", "src/**/*.vue", "../shared/**/*.ts", "../shared/**/*.vue"], "references": [{ "path": "./tsconfig.node.json" }] } ================================================ FILE: web/frpc/tsconfig.node.json ================================================ { "compilerOptions": { "composite": true, "skipLibCheck": true, "module": "ESNext", "moduleResolution": "bundler", "allowSyntheticDefaultImports": true }, "include": ["vite.config.ts"] } ================================================ FILE: web/frpc/vite.config.mts ================================================ import { fileURLToPath, URL } from 'node:url' import { defineConfig } from 'vite' import vue from '@vitejs/plugin-vue' import svgLoader from 'vite-svg-loader' import AutoImport from 'unplugin-auto-import/vite' import Components from 'unplugin-vue-components/vite' import { ElementPlusResolver } from 'unplugin-vue-components/resolvers' import ElementPlus from 'unplugin-element-plus/vite' // https://vitejs.dev/config/ export default defineConfig({ base: '', plugins: [ vue(), svgLoader(), ElementPlus({}), AutoImport({ resolvers: [ElementPlusResolver()], }), Components({ resolvers: [ElementPlusResolver()], }), ], resolve: { alias: { '@': fileURLToPath(new URL('./src', import.meta.url)), '@shared': fileURLToPath(new URL('../shared', import.meta.url)), }, dedupe: ['vue', 'element-plus', '@element-plus/icons-vue'], modules: [ fileURLToPath(new URL('../node_modules', import.meta.url)), 'node_modules', ], }, css: { preprocessorOptions: { scss: { api: 'modern', additionalData: `@use "@shared/css/_index.scss" as *;`, }, }, }, build: { assetsDir: '', chunkSizeWarningLimit: 1000, minify: 'terser', terserOptions: { compress: { drop_console: true, drop_debugger: true, }, }, }, server: { allowedHosts: process.env.ALLOWED_HOSTS ? process.env.ALLOWED_HOSTS.split(',') : [], proxy: { '/api': { target: process.env.VITE_API_URL || 'http://127.0.0.1:7400', changeOrigin: true, }, }, }, }) ================================================ FILE: web/frps/.gitignore ================================================ # Logs logs *.log npm-debug.log* yarn-debug.log* yarn-error.log* pnpm-debug.log* lerna-debug.log* node_modules .DS_Store dist dist-ssr coverage *.local /cypress/videos/ /cypress/screenshots/ # Editor directories and files .vscode/* !.vscode/extensions.json .idea *.suo *.ntvs* *.njsproj *.sln *.sw? ================================================ FILE: web/frps/.prettierrc.json ================================================ { "tabWidth": 2, "semi": false, "singleQuote": true } ================================================ FILE: web/frps/Makefile ================================================ .PHONY: dist install build preview lint install: @cd .. && npm install build: install @npm run build dev: @npm run dev preview: @npm run preview lint: @npm run lint ================================================ FILE: web/frps/README.md ================================================ # frps-dashboard ## Project Setup ```sh yarn install ``` ### Compile and Hot-Reload for Development ```sh make dev ``` ### Type-Check, Compile and Minify for Production ```sh make build ``` ### Lint with [ESLint](https://eslint.org/) ```sh make lint ``` ================================================ FILE: web/frps/auto-imports.d.ts ================================================ /* eslint-disable */ /* prettier-ignore */ // @ts-nocheck // noinspection JSUnusedGlobalSymbols // Generated by unplugin-auto-import export {} declare global { } ================================================ FILE: web/frps/components.d.ts ================================================ /* eslint-disable */ /* prettier-ignore */ // @ts-nocheck // Generated by unplugin-vue-components // Read more: https://github.com/vuejs/core/pull/3399 export {} declare module 'vue' { export interface GlobalComponents { ClientCard: typeof import('./src/components/ClientCard.vue')['default'] ElButton: typeof import('element-plus/es')['ElButton'] ElCard: typeof import('element-plus/es')['ElCard'] ElCol: typeof import('element-plus/es')['ElCol'] ElDialog: typeof import('element-plus/es')['ElDialog'] ElEmpty: typeof import('element-plus/es')['ElEmpty'] ElIcon: typeof import('element-plus/es')['ElIcon'] ElInput: typeof import('element-plus/es')['ElInput'] ElPopover: typeof import('element-plus/es')['ElPopover'] ElRow: typeof import('element-plus/es')['ElRow'] ElSwitch: typeof import('element-plus/es')['ElSwitch'] ElTag: typeof import('element-plus/es')['ElTag'] ElTooltip: typeof import('element-plus/es')['ElTooltip'] ProxyCard: typeof import('./src/components/ProxyCard.vue')['default'] RouterLink: typeof import('vue-router')['RouterLink'] RouterView: typeof import('vue-router')['RouterView'] StatCard: typeof import('./src/components/StatCard.vue')['default'] Traffic: typeof import('./src/components/Traffic.vue')['default'] } export interface ComponentCustomProperties { vLoading: typeof import('element-plus/es')['ElLoadingDirective'] } } ================================================ FILE: web/frps/embed.go ================================================ //go:build !noweb package frps import ( "embed" "github.com/fatedier/frp/assets" ) //go:embed dist var EmbedFS embed.FS func init() { assets.Register(EmbedFS) } ================================================ FILE: web/frps/embed_stub.go ================================================ //go:build noweb package frps ================================================ FILE: web/frps/env.d.ts ================================================ /// ================================================ FILE: web/frps/eslint.config.js ================================================ import pluginVue from 'eslint-plugin-vue' import vueTsEslintConfig from '@vue/eslint-config-typescript' import skipFormatting from '@vue/eslint-config-prettier/skip-formatting' export default [ { name: 'app/files-to-lint', files: ['**/*.{ts,mts,tsx,vue}'], }, { name: 'app/files-to-ignore', ignores: ['**/dist/**', '**/dist-ssr/**', '**/coverage/**'], }, ...pluginVue.configs['flat/essential'], ...vueTsEslintConfig(), { rules: { '@typescript-eslint/no-explicit-any': 'off', '@typescript-eslint/no-unused-vars': [ 'warn', { argsIgnorePattern: '^_', varsIgnorePattern: '^_', caughtErrorsIgnorePattern: '^_', }, ], 'vue/multi-word-component-names': [ 'error', { ignores: ['Traffic', 'Proxies', 'Clients'], }, ], }, }, skipFormatting, ] ================================================ FILE: web/frps/index.html ================================================ frp server
================================================ FILE: web/frps/package.json ================================================ { "name": "frps-dashboard", "version": "0.0.1", "private": true, "type": "module", "scripts": { "dev": "vite", "build": "run-p type-check build-only", "preview": "vite preview", "build-only": "vite build", "type-check": "vue-tsc --noEmit", "lint": "eslint --fix" }, "dependencies": { "element-plus": "^2.13.0", "vue": "^3.5.26", "vue-router": "^4.6.4" }, "devDependencies": { "@types/node": "24", "@vitejs/plugin-vue": "^6.0.3", "@vue/eslint-config-prettier": "^10.2.0", "@vue/eslint-config-typescript": "^14.7.0", "@vue/tsconfig": "^0.8.1", "@vueuse/core": "^14.1.0", "eslint": "^9.39.0", "eslint-plugin-vue": "^9.33.0", "npm-run-all": "^4.1.5", "prettier": "^3.7.4", "sass": "^1.97.2", "terser": "^5.44.1", "typescript": "^5.9.3", "unplugin-auto-import": "^0.17.5", "unplugin-element-plus": "^0.11.2", "unplugin-vue-components": "^0.26.0", "vite": "^7.3.0", "vite-svg-loader": "^5.1.0", "vue-tsc": "^3.2.2" } } ================================================ FILE: web/frps/src/App.vue ================================================ ================================================ FILE: web/frps/src/api/client.ts ================================================ import { http } from './http' import type { ClientInfoData } from '../types/client' export const getClients = () => { return http.get('../api/clients') } export const getClient = (key: string) => { return http.get(`../api/clients/${key}`) } ================================================ FILE: web/frps/src/api/http.ts ================================================ // http.ts - Base HTTP client class HTTPError extends Error { status: number statusText: string constructor(status: number, statusText: string, message?: string) { super(message || statusText) this.status = status this.statusText = statusText } } async function request(url: string, options: RequestInit = {}): Promise { const defaultOptions: RequestInit = { credentials: 'include', } const response = await fetch(url, { ...defaultOptions, ...options }) if (!response.ok) { throw new HTTPError( response.status, response.statusText, `HTTP ${response.status}`, ) } // Handle empty response (e.g. 204 No Content) if (response.status === 204) { return {} as T } return response.json() } export const http = { get: (url: string, options?: RequestInit) => request(url, { ...options, method: 'GET' }), post: (url: string, body?: any, options?: RequestInit) => request(url, { ...options, method: 'POST', headers: { 'Content-Type': 'application/json', ...options?.headers }, body: JSON.stringify(body), }), put: (url: string, body?: any, options?: RequestInit) => request(url, { ...options, method: 'PUT', headers: { 'Content-Type': 'application/json', ...options?.headers }, body: JSON.stringify(body), }), delete: (url: string, options?: RequestInit) => request(url, { ...options, method: 'DELETE' }), } ================================================ FILE: web/frps/src/api/proxy.ts ================================================ import { http } from './http' import type { GetProxyResponse, ProxyStatsInfo, TrafficResponse, } from '../types/proxy' export const getProxiesByType = (type: string) => { return http.get(`../api/proxy/${type}`) } export const getProxy = (type: string, name: string) => { return http.get(`../api/proxy/${type}/${name}`) } export const getProxyByName = (name: string) => { return http.get(`../api/proxies/${name}`) } export const getProxyTraffic = (name: string) => { return http.get(`../api/traffic/${name}`) } export const clearOfflineProxies = () => { return http.delete('../api/proxies?status=offline') } ================================================ FILE: web/frps/src/api/server.ts ================================================ import { http } from './http' import type { ServerInfo } from '../types/server' export const getServerInfo = () => { return http.get('../api/serverinfo') } ================================================ FILE: web/frps/src/assets/css/custom.css ================================================ .el-form-item span { margin-left: 15px; } .proxy-table-expand { font-size: 0; } .proxy-table-expand .el-form-item__label{ width: 90px; color: #99a9bf; } .proxy-table-expand .el-form-item { margin-right: 0; margin-bottom: 0; width: 50%; } .el-table .el-table__expanded-cell { padding: 20px 50px; } /* Modern styles */ * { box-sizing: border-box; } /* Smooth transitions */ .el-button, .el-card, .el-input, .el-select, .el-tag { transition: all 0.3s ease; } /* Card hover effects */ .el-card:hover { box-shadow: 0 8px 16px rgba(0, 0, 0, 0.1); } /* Better scrollbar */ ::-webkit-scrollbar { width: 8px; height: 8px; } ::-webkit-scrollbar-track { background: #f1f1f1; border-radius: 4px; } ::-webkit-scrollbar-thumb { background: #c1c1c1; border-radius: 4px; } ::-webkit-scrollbar-thumb:hover { background: #a8a8a8; } /* Page headers */ .el-page-header { padding: 16px 0; } .el-page-header__title { font-size: 20px; font-weight: 500; } /* Better form layouts */ .el-form-item { margin-bottom: 18px; } /* Responsive adjustments */ @media (max-width: 768px) { .el-row { margin-left: 0 !important; margin-right: 0 !important; } .el-col { padding-left: 10px !important; padding-right: 10px !important; } } ================================================ FILE: web/frps/src/assets/css/dark.css ================================================ /* Dark mode styles */ html.dark { --el-bg-color: #1e1e2e; --el-bg-color-page: #181825; --el-bg-color-overlay: #27293d; --el-fill-color-blank: #1e1e2e; --el-border-color: #3a3d5c; --el-border-color-light: #313348; --el-border-color-lighter: #2a2a3e; --el-text-color-primary: #e5e7eb; --el-text-color-secondary: #888888; --el-text-color-placeholder: #afafaf; background-color: #1e1e2e; color-scheme: dark; } /* Scrollbar */ html.dark ::-webkit-scrollbar { width: 6px; height: 6px; } html.dark ::-webkit-scrollbar-track { background: #27293d; } html.dark ::-webkit-scrollbar-thumb { background: #3a3d5c; border-radius: 3px; } html.dark ::-webkit-scrollbar-thumb:hover { background: #4a4d6c; } /* Form */ html.dark .el-form-item__label { color: #e5e7eb; } /* Input */ html.dark .el-input__wrapper { background: var(--color-bg-input); box-shadow: 0 0 0 1px #3a3d5c inset; } html.dark .el-input__wrapper:hover { box-shadow: 0 0 0 1px #4a4d6c inset; } html.dark .el-input__wrapper.is-focus { box-shadow: 0 0 0 1px var(--el-color-primary) inset; } html.dark .el-input__inner { color: #e5e7eb; } html.dark .el-input__inner::placeholder { color: #afafaf; } html.dark .el-textarea__inner { background: var(--color-bg-input); box-shadow: 0 0 0 1px #3a3d5c inset; color: #e5e7eb; } html.dark .el-textarea__inner:hover { box-shadow: 0 0 0 1px #4a4d6c inset; } html.dark .el-textarea__inner:focus { box-shadow: 0 0 0 1px var(--el-color-primary) inset; } /* Select */ html.dark .el-select__wrapper { background: var(--color-bg-input); box-shadow: 0 0 0 1px #3a3d5c inset; } html.dark .el-select__wrapper:hover { box-shadow: 0 0 0 1px #4a4d6c inset; } html.dark .el-select__selected-item { color: #e5e7eb; } html.dark .el-select__placeholder { color: #afafaf; } html.dark .el-select-dropdown { background: #27293d; border-color: #3a3d5c; } html.dark .el-select-dropdown__item { color: #e5e7eb; } html.dark .el-select-dropdown__item:hover { background: #2a2a3e; } html.dark .el-select-dropdown__item.is-selected { color: var(--el-color-primary); } html.dark .el-select-dropdown__item.is-disabled { color: #666666; } /* Tag */ html.dark .el-tag--info { background: #27293d; border-color: #3a3d5c; color: #b0b0b0; } /* Button */ html.dark .el-button--default { background: #27293d; border-color: #3a3d5c; color: #e5e7eb; } html.dark .el-button--default:hover { background: #2a2a3e; border-color: #4a4d6c; color: #e5e7eb; } /* Card */ html.dark .el-card { background: #1e1e2e; border-color: #3a3d5c; color: #b0b0b0; } html.dark .el-card__header { border-bottom-color: #3a3d5c; color: #e5e7eb; } /* Table */ html.dark .el-table { background-color: #1e1e2e; color: #e5e7eb; } html.dark .el-table th { background-color: #1e1e2e; color: #e5e7eb; } html.dark .el-table tr { background-color: #1e1e2e; } html.dark .el-table--striped .el-table__body tr.el-table__row--striped td { background-color: #181825; } /* Dialog */ html.dark .el-dialog { background: #1e1e2e; } html.dark .el-dialog__title { color: #e5e7eb; } /* Message */ html.dark .el-message { background: #27293d; border-color: #3a3d5c; } html.dark .el-message--success { background: #1e3d2e; border-color: #3d6b4f; } html.dark .el-message--warning { background: #3d3020; border-color: #6b5020; } html.dark .el-message--error { background: #3d2027; border-color: #5c2d2d; } /* Loading */ html.dark .el-loading-mask { background-color: rgba(30, 30, 46, 0.9); } /* Overlay */ html.dark .el-overlay { background-color: rgba(0, 0, 0, 0.6); } /* Tooltip */ html.dark .el-tooltip__popper { background: #27293d !important; border-color: #3a3d5c !important; color: #e5e7eb !important; } ================================================ FILE: web/frps/src/assets/css/var.css ================================================ :root { /* Text colors */ --color-text-primary: #303133; --color-text-secondary: #606266; --color-text-muted: #909399; --color-text-light: #c0c4cc; --color-text-placeholder: #a8abb2; /* Background colors */ --color-bg-primary: #ffffff; --color-bg-secondary: #f9f9f9; --color-bg-tertiary: #fafafa; --color-bg-surface: #ffffff; --color-bg-muted: #f4f4f5; --color-bg-input: #ffffff; --color-bg-hover: #efefef; --color-bg-active: #eaeaea; /* Border colors */ --color-border: #dcdfe6; --color-border-light: #e4e7ed; --color-border-lighter: #ebeef5; --color-border-extra-light: #f2f6fc; /* Status colors */ --color-primary: #409eff; --color-primary-light: #ecf5ff; --color-success: #67c23a; --color-warning: #e6a23c; --color-danger: #f56c6c; --color-danger-dark: #c45656; --color-danger-light: #fef0f0; --color-info: #909399; /* Element Plus mapping */ --el-color-primary: var(--color-primary); --el-color-success: var(--color-success); --el-color-warning: var(--color-warning); --el-color-danger: var(--color-danger); --el-color-info: var(--color-info); --el-text-color-primary: var(--color-text-primary); --el-text-color-regular: var(--color-text-secondary); --el-text-color-secondary: var(--color-text-muted); --el-text-color-placeholder: var(--color-text-placeholder); --el-bg-color: var(--color-bg-primary); --el-bg-color-page: var(--color-bg-secondary); --el-bg-color-overlay: var(--color-bg-primary); --el-border-color: var(--color-border); --el-border-color-light: var(--color-border-light); --el-border-color-lighter: var(--color-border-lighter); --el-border-color-extra-light: var(--color-border-extra-light); --el-fill-color-blank: var(--color-bg-primary); --el-fill-color-light: var(--color-bg-tertiary); --el-fill-color: var(--color-bg-tertiary); --el-fill-color-dark: var(--color-bg-hover); --el-fill-color-darker: var(--color-bg-active); /* Input */ --el-input-bg-color: var(--color-bg-input); --el-input-border-color: var(--color-border); --el-input-hover-border-color: var(--color-border-light); /* Dialog */ --el-dialog-bg-color: var(--color-bg-primary); --el-overlay-color: rgba(0, 0, 0, 0.5); } html.dark { /* Text colors */ --color-text-primary: #e5e7eb; --color-text-secondary: #b0b0b0; --color-text-muted: #888888; --color-text-light: #666666; --color-text-placeholder: #afafaf; /* Background colors */ --color-bg-primary: #1e1e2e; --color-bg-secondary: #181825; --color-bg-tertiary: #27293d; --color-bg-surface: #27293d; --color-bg-muted: #27293d; --color-bg-input: #27293d; --color-bg-hover: #2a2a3e; --color-bg-active: #353550; /* Border colors */ --color-border: #3a3d5c; --color-border-light: #313348; --color-border-lighter: #2a2a3e; --color-border-extra-light: #222233; /* Status colors */ --color-primary: #409eff; --color-danger: #f87171; --color-danger-dark: #f87171; --color-danger-light: #3d2027; --color-info: #888888; /* Dark overrides */ --el-text-color-regular: var(--color-text-primary); --el-overlay-color: rgba(0, 0, 0, 0.7); background-color: #181825; color-scheme: dark; } ================================================ FILE: web/frps/src/components/ClientCard.vue ================================================ ================================================ FILE: web/frps/src/components/ProxyCard.vue ================================================ ================================================ FILE: web/frps/src/components/StatCard.vue ================================================ ================================================ FILE: web/frps/src/components/Traffic.vue ================================================ ================================================ FILE: web/frps/src/composables/useResponsive.ts ================================================ import { useBreakpoints } from '@vueuse/core' const breakpoints = useBreakpoints({ mobile: 0, desktop: 768 }) export function useResponsive() { const isMobile = breakpoints.smaller('desktop') // < 768px return { isMobile } } ================================================ FILE: web/frps/src/main.ts ================================================ import { createApp } from 'vue' import 'element-plus/theme-chalk/dark/css-vars.css' import App from './App.vue' import router from './router' import './assets/css/var.css' import './assets/css/dark.css' const app = createApp(App) app.use(router) app.mount('#app') ================================================ FILE: web/frps/src/router/index.ts ================================================ import { createRouter, createWebHashHistory } from 'vue-router' import ServerOverview from '../views/ServerOverview.vue' import Clients from '../views/Clients.vue' import ClientDetail from '../views/ClientDetail.vue' import Proxies from '../views/Proxies.vue' import ProxyDetail from '../views/ProxyDetail.vue' const router = createRouter({ history: createWebHashHistory(), scrollBehavior() { return { top: 0 } }, routes: [ { path: '/', name: 'ServerOverview', component: ServerOverview, }, { path: '/clients', name: 'Clients', component: Clients, }, { path: '/clients/:key', name: 'ClientDetail', component: ClientDetail, }, { path: '/proxies/:type?', name: 'Proxies', component: Proxies, }, { path: '/proxy/:name', name: 'ProxyDetail', component: ProxyDetail, }, ], }) export default router ================================================ FILE: web/frps/src/svg.d.ts ================================================ declare module '*.svg?component' { import type { DefineComponent } from 'vue' const component: DefineComponent export default component } ================================================ FILE: web/frps/src/types/client.ts ================================================ export interface ClientInfoData { key: string user: string clientID: string runID: string version?: string hostname: string clientIP?: string metas?: Record firstConnectedAt: number lastConnectedAt: number disconnectedAt?: number online: boolean } ================================================ FILE: web/frps/src/types/proxy.ts ================================================ export interface ProxyStatsInfo { name: string conf: any user: string clientID: string todayTrafficIn: number todayTrafficOut: number curConns: number lastStartTime: string lastCloseTime: string status: string } export interface GetProxyResponse { proxies: ProxyStatsInfo[] } export interface TrafficResponse { name: string trafficIn: number[] trafficOut: number[] } ================================================ FILE: web/frps/src/types/server.ts ================================================ export interface ServerInfo { version: string bindPort: number vhostHTTPPort: number vhostHTTPSPort: number tcpmuxHTTPConnectPort: number kcpBindPort: number quicBindPort: number subdomainHost: string maxPoolCount: number maxPortsPerClient: number heartbeatTimeout: number allowPortsStr: string tlsForce: boolean // Stats totalTrafficIn: number totalTrafficOut: number curConns: number clientCounts: number proxyTypeCount: Record } ================================================ FILE: web/frps/src/utils/client.ts ================================================ import { formatDistanceToNow } from './format' import type { ClientInfoData } from '../types/client' export class Client { key: string user: string clientID: string runID: string version: string hostname: string ip: string metas: Map firstConnectedAt: Date lastConnectedAt: Date disconnectedAt?: Date online: boolean constructor(data: ClientInfoData) { this.key = data.key this.user = data.user this.clientID = data.clientID this.runID = data.runID this.version = data.version || '' this.hostname = data.hostname this.ip = data.clientIP || '' this.metas = new Map() if (data.metas) { for (const [key, value] of Object.entries(data.metas)) { this.metas.set(key, value) } } this.firstConnectedAt = new Date(data.firstConnectedAt * 1000) this.lastConnectedAt = new Date(data.lastConnectedAt * 1000) if (data.disconnectedAt && data.disconnectedAt > 0) { this.disconnectedAt = new Date(data.disconnectedAt * 1000) } this.online = data.online } get displayName(): string { if (this.clientID) { return this.user ? `${this.user}.${this.clientID}` : this.clientID } return this.runID } get shortRunId(): string { return this.runID.substring(0, 8) } get firstConnectedAgo(): string { return formatDistanceToNow(this.firstConnectedAt) } get lastConnectedAgo(): string { return formatDistanceToNow(this.lastConnectedAt) } get disconnectedAgo(): string { if (!this.disconnectedAt) return '' return formatDistanceToNow(this.disconnectedAt) } get statusColor(): string { return this.online ? 'success' : 'danger' } get metasArray(): Array<{ key: string; value: string }> { const arr: Array<{ key: string; value: string }> = [] this.metas.forEach((value, key) => { arr.push({ key, value }) }) return arr } matchesFilter(searchText: string): boolean { const search = searchText.toLowerCase() return ( this.key.toLowerCase().includes(search) || this.user.toLowerCase().includes(search) || this.clientID.toLowerCase().includes(search) || this.runID.toLowerCase().includes(search) || this.hostname.toLowerCase().includes(search) ) } } ================================================ FILE: web/frps/src/utils/format.ts ================================================ export function formatDistanceToNow(date: Date): string { const seconds = Math.floor((new Date().getTime() - date.getTime()) / 1000) let interval = seconds / 31536000 if (interval > 1) return Math.floor(interval) + ' years ago' interval = seconds / 2592000 if (interval > 1) return Math.floor(interval) + ' months ago' interval = seconds / 86400 if (interval > 1) return Math.floor(interval) + ' days ago' interval = seconds / 3600 if (interval > 1) return Math.floor(interval) + ' hours ago' interval = seconds / 60 if (interval > 1) return Math.floor(interval) + ' minutes ago' return Math.floor(seconds) + ' seconds ago' } export function formatFileSize(bytes: number): string { if (!Number.isFinite(bytes) || bytes < 0) return '0 B' if (bytes === 0) return '0 B' const k = 1024 const sizes = ['B', 'KB', 'MB', 'GB', 'TB', 'PB', 'EB', 'ZB', 'YB'] const i = Math.floor(Math.log(bytes) / Math.log(k)) // Prevent index out of bounds for extremely large numbers const unit = sizes[i] || sizes[sizes.length - 1] const val = bytes / Math.pow(k, i) return parseFloat(val.toFixed(2)) + ' ' + unit } ================================================ FILE: web/frps/src/utils/proxy.ts ================================================ class BaseProxy { name: string type: string annotations: Map encryption: boolean compression: boolean conns: number trafficIn: number trafficOut: number lastStartTime: string lastCloseTime: string status: string user: string clientID: string addr: string port: number customDomains: string hostHeaderRewrite: string locations: string subdomain: string // TCPMux specific multiplexer: string routeByHTTPUser: string constructor(proxyStats: any) { this.name = proxyStats.name this.type = '' this.annotations = new Map() if (proxyStats.conf?.annotations) { for (const key in proxyStats.conf.annotations) { this.annotations.set(key, proxyStats.conf.annotations[key]) } } this.encryption = false this.compression = false this.encryption = proxyStats.conf?.transport?.useEncryption || this.encryption this.compression = proxyStats.conf?.transport?.useCompression || this.compression this.conns = proxyStats.curConns this.trafficIn = proxyStats.todayTrafficIn this.trafficOut = proxyStats.todayTrafficOut this.lastStartTime = proxyStats.lastStartTime this.lastCloseTime = proxyStats.lastCloseTime this.status = proxyStats.status this.user = proxyStats.user || '' this.clientID = proxyStats.clientID || '' this.addr = '' this.port = 0 this.customDomains = '' this.hostHeaderRewrite = '' this.locations = '' this.subdomain = '' this.multiplexer = '' this.routeByHTTPUser = '' } } class TCPProxy extends BaseProxy { constructor(proxyStats: any) { super(proxyStats) this.type = 'tcp' if (proxyStats.conf != null) { this.addr = ':' + proxyStats.conf.remotePort this.port = proxyStats.conf.remotePort } else { this.addr = '' this.port = 0 } } } class UDPProxy extends BaseProxy { constructor(proxyStats: any) { super(proxyStats) this.type = 'udp' if (proxyStats.conf != null) { this.addr = ':' + proxyStats.conf.remotePort this.port = proxyStats.conf.remotePort } else { this.addr = '' this.port = 0 } } } class HTTPProxy extends BaseProxy { constructor(proxyStats: any, port: number, subdomainHost: string) { super(proxyStats) this.type = 'http' this.port = port if (proxyStats.conf) { this.customDomains = proxyStats.conf.customDomains || this.customDomains this.hostHeaderRewrite = proxyStats.conf.hostHeaderRewrite this.locations = proxyStats.conf.locations if (proxyStats.conf.subdomain) { this.subdomain = `${proxyStats.conf.subdomain}.${subdomainHost}` } } } } class HTTPSProxy extends BaseProxy { constructor(proxyStats: any, port: number, subdomainHost: string) { super(proxyStats) this.type = 'https' this.port = port if (proxyStats.conf != null) { this.customDomains = proxyStats.conf.customDomains || this.customDomains if (proxyStats.conf.subdomain) { this.subdomain = `${proxyStats.conf.subdomain}.${subdomainHost}` } } } } class TCPMuxProxy extends BaseProxy { constructor(proxyStats: any, port: number, subdomainHost: string) { super(proxyStats) this.type = 'tcpmux' this.port = port if (proxyStats.conf) { this.customDomains = proxyStats.conf.customDomains || this.customDomains this.multiplexer = proxyStats.conf.multiplexer || '' this.routeByHTTPUser = proxyStats.conf.routeByHTTPUser || '' if (proxyStats.conf.subdomain) { this.subdomain = `${proxyStats.conf.subdomain}.${subdomainHost}` } } } } class STCPProxy extends BaseProxy { constructor(proxyStats: any) { super(proxyStats) this.type = 'stcp' } } class SUDPProxy extends BaseProxy { constructor(proxyStats: any) { super(proxyStats) this.type = 'sudp' } } export { BaseProxy, TCPProxy, UDPProxy, TCPMuxProxy, HTTPProxy, HTTPSProxy, STCPProxy, SUDPProxy, } ================================================ FILE: web/frps/src/views/ClientDetail.vue ================================================ ================================================ FILE: web/frps/src/views/Clients.vue ================================================ ================================================ FILE: web/frps/src/views/Proxies.vue ================================================ ================================================ FILE: web/frps/src/views/ProxyDetail.vue ================================================ ================================================ FILE: web/frps/src/views/ServerOverview.vue ================================================ ================================================ FILE: web/frps/tsconfig.json ================================================ { "compilerOptions": { "target": "ES2020", "useDefineForClassFields": true, "module": "ESNext", "lib": ["ES2020", "DOM", "DOM.Iterable"], "skipLibCheck": true, /* Bundler mode */ "moduleResolution": "bundler", "allowImportingTsExtensions": true, "resolveJsonModule": true, "isolatedModules": true, "noEmit": true, "jsx": "preserve", /* Linting */ "strict": true, "noUnusedLocals": true, "noUnusedParameters": true, "noFallthroughCasesInSwitch": true, "paths": { "@/*": ["./src/*"], "@shared/*": ["../shared/*"] } }, "include": ["src/**/*.ts", "src/**/*.tsx", "src/**/*.vue", "../shared/**/*.ts", "../shared/**/*.vue"], "references": [{ "path": "./tsconfig.node.json" }] } ================================================ FILE: web/frps/tsconfig.node.json ================================================ { "compilerOptions": { "composite": true, "skipLibCheck": true, "module": "ESNext", "moduleResolution": "bundler", "allowSyntheticDefaultImports": true }, "include": ["vite.config.ts"] } ================================================ FILE: web/frps/vite.config.mts ================================================ import { fileURLToPath, URL } from 'node:url' import { defineConfig } from 'vite' import vue from '@vitejs/plugin-vue' import svgLoader from 'vite-svg-loader' import AutoImport from 'unplugin-auto-import/vite' import Components from 'unplugin-vue-components/vite' import { ElementPlusResolver } from 'unplugin-vue-components/resolvers' import ElementPlus from 'unplugin-element-plus/vite' // https://vitejs.dev/config/ export default defineConfig({ base: '', plugins: [ vue(), svgLoader(), ElementPlus({}), AutoImport({ resolvers: [ElementPlusResolver()], }), Components({ resolvers: [ElementPlusResolver()], }), ], resolve: { alias: { '@': fileURLToPath(new URL('./src', import.meta.url)), '@shared': fileURLToPath(new URL('../shared', import.meta.url)), }, dedupe: ['vue', 'element-plus', '@element-plus/icons-vue'], modules: [ fileURLToPath(new URL('../node_modules', import.meta.url)), 'node_modules', ], }, css: { preprocessorOptions: { scss: { api: 'modern', additionalData: `@use "@shared/css/_index.scss" as *;`, }, }, }, build: { assetsDir: '', chunkSizeWarningLimit: 1000, minify: 'terser', terserOptions: { compress: { drop_console: true, drop_debugger: true, }, }, }, server: { allowedHosts: process.env.ALLOWED_HOSTS ? process.env.ALLOWED_HOSTS.split(',') : [], proxy: { '/api': { target: process.env.VITE_API_URL || 'http://127.0.0.1:7500', changeOrigin: true, }, }, }, }) ================================================ FILE: web/package.json ================================================ { "name": "frp-web", "private": true, "workspaces": ["shared", "frpc", "frps"] } ================================================ FILE: web/shared/components/ActionButton.vue ================================================ ================================================ FILE: web/shared/components/BaseDialog.vue ================================================ ================================================ FILE: web/shared/components/ConfirmDialog.vue ================================================ ================================================ FILE: web/shared/components/FilterDropdown.vue ================================================ ================================================ FILE: web/shared/components/PopoverMenu.vue ================================================ ================================================ FILE: web/shared/components/PopoverMenuItem.vue ================================================ ================================================ FILE: web/shared/css/_index.scss ================================================ @forward './variables'; @forward './mixins'; ================================================ FILE: web/shared/css/_mixins.scss ================================================ @use './variables' as vars; @mixin mobile { @media (max-width: #{vars.$breakpoint-mobile - 1px}) { @content; } } @mixin flex-center { display: flex; align-items: center; justify-content: center; } @mixin flex-column { display: flex; flex-direction: column; } @mixin page-scroll { height: 100%; overflow-y: auto; padding: vars.$spacing-xl 40px; > * { max-width: 960px; margin: 0 auto; } @include mobile { padding: vars.$spacing-xl; } } @mixin custom-scrollbar { &::-webkit-scrollbar { width: 6px; height: 6px; } &::-webkit-scrollbar-track { background: transparent; } &::-webkit-scrollbar-thumb { background: #d1d1d1; border-radius: 3px; } } ================================================ FILE: web/shared/css/_variables.scss ================================================ // Typography $font-size-xs: 11px; $font-size-sm: 13px; $font-size-md: 14px; $font-size-lg: 15px; $font-size-xl: 18px; $font-weight-normal: 400; $font-weight-medium: 500; $font-weight-semibold: 600; // Colors - Text $color-text-primary: var(--color-text-primary); $color-text-secondary: var(--color-text-secondary); $color-text-muted: var(--color-text-muted); $color-text-light: var(--color-text-light); // Colors - Background $color-bg-primary: var(--color-bg-primary); $color-bg-secondary: var(--color-bg-secondary); $color-bg-tertiary: var(--color-bg-tertiary); $color-bg-muted: var(--color-bg-muted); $color-bg-hover: var(--color-bg-hover); $color-bg-active: var(--color-bg-active); // Colors - Border $color-border: var(--color-border); $color-border-light: var(--color-border-light); $color-border-lighter: var(--color-border-lighter); // Colors - Status $color-primary: var(--color-primary); $color-danger: var(--color-danger); $color-danger-dark: var(--color-danger-dark); $color-danger-light: var(--color-danger-light); // Colors - Button $color-btn-primary: var(--color-btn-primary); $color-btn-primary-hover: var(--color-btn-primary-hover); // Spacing $spacing-xs: 4px; $spacing-sm: 8px; $spacing-md: 12px; $spacing-lg: 16px; $spacing-xl: 20px; // Border Radius $radius-sm: 6px; $radius-md: 8px; // Transitions $transition-fast: 0.15s ease; $transition-medium: 0.2s ease; // Layout $header-height: 50px; $sidebar-width: 200px; // Breakpoints $breakpoint-mobile: 768px; ================================================ FILE: web/shared/package.json ================================================ { "name": "frp-shared", "private": true }