[
  {
    "path": ".circleci/config.yml",
    "content": "version: 2\njobs:\n  go-version-latest:\n    docker:\n    - image: cimg/go:1.25-node\n    resource_class: large\n    steps:\n    - checkout\n    - run:\n        name: Build web assets (frps)\n        command: make install build\n        working_directory: web/frps\n    - run:\n        name: Build web assets (frpc)\n        command: make install build\n        working_directory: web/frpc\n    - run: make\n    - run: make alltest\n\nworkflows:\n  version: 2\n  build_and_test:\n    jobs:\n    - go-version-latest\n"
  },
  {
    "path": ".github/FUNDING.yml",
    "content": "# These are supported funding model platforms\n\ngithub: [fatedier]\ncustom: [\"https://afdian.com/a/fatedier\"]\n"
  },
  {
    "path": ".github/ISSUE_TEMPLATE/bug_report.yaml",
    "content": "name: Bug report\ndescription: Report a bug to help us improve frp\n\nbody:\n- type: markdown\n  attributes:\n    value: |\n      Thanks for taking the time to fill out this bug report!\n- type: textarea\n  id: bug-description\n  attributes:\n    label: Bug Description\n    description: Tell us what issues you ran into\n    placeholder: Include information about what you tried, what you expected to happen, and what actually happened. The more details, the better!\n  validations:\n    required: true\n- type: input\n  id: frpc-version\n  attributes:\n    label: frpc Version\n    description: Include the output of `frpc -v`\n  validations:\n    required: true\n- type: input\n  id: frps-version\n  attributes:\n    label: frps Version\n    description: Include the output of `frps -v`\n  validations:\n    required: true\n- type: input\n  id: system-architecture\n  attributes:\n    label: System Architecture\n    description: Include which architecture you used, such as `linux/amd64`, `windows/amd64`\n  validations:\n    required: true\n- type: textarea\n  id: config\n  attributes:\n    label: Configurations\n    description: Include what configurrations you used and ran into this problem\n    placeholder: Pay attention to hiding the token and password in your output\n  validations:\n    required: true\n- type: textarea\n  id: log\n  attributes:\n    label: Logs\n    description: Prefer you providing releated error logs here\n    placeholder: Pay attention to hiding your personal informations\n- type: textarea\n  id: steps-to-reproduce\n  attributes:\n    label: Steps to reproduce\n    description: How to reproduce it? It's important for us to find the bug\n    value: |\n      1. \n      2. \n      3. \n      ...\n- type: checkboxes\n  id: area\n  attributes:\n    label: Affected area\n    options:\n    - label: \"Docs\"\n    - label: \"Installation\"\n    - label: \"Performance and Scalability\"\n    - label: \"Security\"\n    - label: \"User Experience\"\n    - label: \"Test and Release\"\n    - label: \"Developer Infrastructure\"\n    - label: \"Client Plugin\"\n    - label: \"Server Plugin\"\n    - label: \"Extensions\"\n    - label: \"Others\"\n"
  },
  {
    "path": ".github/ISSUE_TEMPLATE/config.yml",
    "content": "blank_issues_enabled: false\n"
  },
  {
    "path": ".github/ISSUE_TEMPLATE/feature_request.yaml",
    "content": "name: Feature Request\ndescription: Suggest an idea to improve frp\ntitle: \"[Feature Request] \"\n\nbody:\n- type: markdown\n  attributes:\n    value: |\n      This is only used to request new product features.\n- type: textarea\n  id: feature-request\n  attributes:\n    label: Describe the feature request\n    description: Tell us what's you want and why it should be added in frp.\n  validations:\n    required: true\n- type: textarea\n  id: alternatives\n  attributes:\n    label: Describe alternatives you've considered\n- type: checkboxes\n  id: area\n  attributes:\n    label: Affected area\n    options:\n    - label: \"Docs\"\n    - label: \"Installation\"\n    - label: \"Performance and Scalability\"\n    - label: \"Security\"\n    - label: \"User Experience\"\n    - label: \"Test and Release\"\n    - label: \"Developer Infrastructure\"\n    - label: \"Client Plugin\"\n    - label: \"Server Plugin\"\n    - label: \"Extensions\"\n    - label: \"Others\"\n"
  },
  {
    "path": ".github/pull_request_template.md",
    "content": "### WHY\n\n<!-- author to complete -->\n"
  },
  {
    "path": ".github/workflows/build-and-push-image.yml",
    "content": "name: Build Image and Publish to Dockerhub & GPR\n\non:\n  release:\n    types: [ published ]\n  workflow_dispatch:\n    inputs:\n      tag:\n        description: 'Image tag'\n        required: true\n        default: 'test'\npermissions:\n  contents: read\n\njobs:\n  image:\n    name: Build Image from Dockerfile and binaries\n    runs-on: ubuntu-latest\n    steps:\n      # environment\n      - name: Checkout\n        uses: actions/checkout@v4\n        with:\n          fetch-depth: '0'\n\n      - name: Set up QEMU\n        uses: docker/setup-qemu-action@v3\n\n      - name: Set up Docker Buildx\n        uses: docker/setup-buildx-action@v3\n\n      # get image tag name\n      - name: Get Image Tag Name\n        run: |\n          if [ x${{ github.event.inputs.tag }} == x\"\" ]; then\n            echo \"TAG_NAME=${GITHUB_REF#refs/*/}\" >> $GITHUB_ENV\n          else\n            echo \"TAG_NAME=${{ github.event.inputs.tag }}\" >> $GITHUB_ENV\n          fi\n      - name: Login to DockerHub\n        uses: docker/login-action@v3\n        with:\n          username: ${{ secrets.DOCKERHUB_USERNAME }}\n          password: ${{ secrets.DOCKERHUB_PASSWORD }}\n\n      - name: Login to the GPR\n        uses: docker/login-action@v3\n        with:\n          registry: ghcr.io\n          username: ${{ github.repository_owner }}\n          password: ${{ secrets.GPR_TOKEN }}\n\n      # prepare image tags\n      - name: Prepare Image Tags\n        run: |\n          echo \"DOCKERFILE_FRPC_PATH=dockerfiles/Dockerfile-for-frpc\" >> $GITHUB_ENV\n          echo \"DOCKERFILE_FRPS_PATH=dockerfiles/Dockerfile-for-frps\" >> $GITHUB_ENV\n          echo \"TAG_FRPC=fatedier/frpc:${{ env.TAG_NAME }}\" >> $GITHUB_ENV\n          echo \"TAG_FRPS=fatedier/frps:${{ env.TAG_NAME }}\" >> $GITHUB_ENV\n          echo \"TAG_FRPC_GPR=ghcr.io/fatedier/frpc:${{ env.TAG_NAME }}\" >> $GITHUB_ENV\n          echo \"TAG_FRPS_GPR=ghcr.io/fatedier/frps:${{ env.TAG_NAME }}\" >> $GITHUB_ENV\n\n      - name: Build and push frpc\n        uses: docker/build-push-action@v5\n        with:\n          context: .\n          file: ./dockerfiles/Dockerfile-for-frpc\n          platforms: linux/amd64,linux/arm/v7,linux/arm64,linux/ppc64le,linux/s390x\n          push: true\n          tags: |\n            ${{ env.TAG_FRPC }}\n            ${{ env.TAG_FRPC_GPR }}\n\n      - name: Build and push frps\n        uses: docker/build-push-action@v5\n        with:\n          context: .\n          file: ./dockerfiles/Dockerfile-for-frps\n          platforms: linux/amd64,linux/arm/v7,linux/arm64,linux/ppc64le,linux/s390x\n          push: true\n          tags: |\n            ${{ env.TAG_FRPS }}\n            ${{ env.TAG_FRPS_GPR }}\n"
  },
  {
    "path": ".github/workflows/golangci-lint.yml",
    "content": "name: golangci-lint\non:\n  push:\n    branches:\n    - master\n    - dev\n  pull_request:\npermissions:\n  contents: read\n  # Optional: allow read access to pull request. Use with `only-new-issues` option.\n  pull-requests: read\njobs:\n  golangci:\n    name: lint\n    runs-on: ubuntu-latest\n    steps:\n    - uses: actions/checkout@v4\n    - uses: actions/setup-go@v5\n      with:\n        go-version: '1.25'\n        cache: false\n    - uses: actions/setup-node@v4\n      with:\n        node-version: '22'\n    - name: Build web assets (frps)\n      run: make build\n      working-directory: web/frps\n    - name: Build web assets (frpc)\n      run: make build\n      working-directory: web/frpc\n    - name: golangci-lint\n      uses: golangci/golangci-lint-action@v9\n      with:\n        # Optional: version of golangci-lint to use in form of v1.2 or v1.2.3 or `latest` to use the latest version\n        version: v2.10\n"
  },
  {
    "path": ".github/workflows/goreleaser.yml",
    "content": "name: goreleaser\n\non:\n  workflow_dispatch:\n\njobs:\n  goreleaser:\n    runs-on: ubuntu-latest\n    steps:\n      - name: Checkout\n        uses: actions/checkout@v4\n        with:\n          fetch-depth: 0\n\n      - name: Set up Go\n        uses: actions/setup-go@v5\n        with:\n          go-version: '1.25'\n      - uses: actions/setup-node@v4\n        with:\n          node-version: '22'\n      - name: Build web assets (frps)\n        run: make build\n        working-directory: web/frps\n      - name: Build web assets (frpc)\n        run: make build\n        working-directory: web/frpc\n      - name: Make All\n        run: |\n          ./package.sh\n\n      - name: Run GoReleaser\n        uses: goreleaser/goreleaser-action@v6\n        with:\n          version: latest\n          args: release --clean --release-notes=./Release.md\n        env:\n          GITHUB_TOKEN: ${{ secrets.GPR_TOKEN }}\n"
  },
  {
    "path": ".github/workflows/stale.yml",
    "content": "name: \"Close stale issues and PRs\"\non:\n  schedule:\n  - cron: \"20 0 * * *\"\n  workflow_dispatch:\n    inputs:\n      debug-only:\n        description: 'In debug mod'\n        required: false\n        default: 'false'\npermissions:\n  contents: read\n\njobs:\n  stale:\n    permissions:\n      issues: write  # for actions/stale to close stale issues\n      pull-requests: write  # for actions/stale to close stale PRs\n      actions: write\n    runs-on: ubuntu-latest\n    steps:\n    - uses: actions/stale@v9\n      with:\n        stale-issue-message: 'Issues go stale after 14d of inactivity. Stale issues rot after an additional 3d of inactivity and eventually close.'\n        stale-pr-message: \"PRs go stale after 14d of inactivity. Stale PRs rot after an additional 3d of inactivity and eventually close.\"\n        stale-issue-label: 'lifecycle/stale'\n        exempt-issue-labels: 'bug,doc,enhancement,future,proposal,question,testing,todo,easy,help wanted,assigned'\n        stale-pr-label: 'lifecycle/stale'\n        exempt-pr-labels: 'bug,doc,enhancement,future,proposal,question,testing,todo,easy,help wanted,assigned'\n        days-before-stale: 14\n        days-before-close: 3\n        debug-only: ${{ github.event.inputs.debug-only }}\n        exempt-all-pr-milestones: true\n        exempt-all-pr-assignees: true\n        operations-per-run: 200\n"
  },
  {
    "path": ".gitignore",
    "content": "# Compiled Object files, Static and Dynamic libs (Shared Objects)\n*.o\n*.a\n*.so\n\n# Folders\n_obj\n_test\n\n*.exe\n*.test\n*.prof\n\n# Self\nbin/\npackages/\nrelease/\ntest/bin/\nvendor/\nlastversion/\ndist/\n.idea/\n.vscode/\n.autogen_ssh_key\nclient.crt\nclient.key\n\nnode_modules/\n\n# Cache\n*.swp\n\n# AI\n.claude/\n.sisyphus/\n.superpowers/\n"
  },
  {
    "path": ".golangci.yml",
    "content": "version: \"2\"\nrun:\n  concurrency: 4\n  timeout: 20m\n  build-tags:\n  - integ\n  - integfuzz\nlinters:\n  default: none\n  enable:\n  - asciicheck\n  - copyloopvar\n  - errcheck\n  - gocritic\n  - gosec\n  - govet\n  - ineffassign\n  - lll\n  - makezero\n  - misspell\n  - modernize\n  - prealloc\n  - predeclared\n  - revive\n  - staticcheck\n  - unconvert\n  - unparam\n  - unused\n  settings:\n    errcheck:\n      check-type-assertions: false\n      check-blank: false\n    gocritic:\n      disabled-checks:\n      - exitAfterDefer\n    gosec:\n      excludes: [\"G115\", \"G117\", \"G204\", \"G401\", \"G402\", \"G404\", \"G501\", \"G703\", \"G704\", \"G705\"]\n      severity: low\n      confidence: low\n    govet:\n      disable:\n      - shadow\n    lll:\n      line-length: 160\n      tab-width: 1\n    misspell:\n      locale: US\n      ignore-rules:\n      - cancelled\n      - marshalled\n    modernize:\n      disable:\n      - omitzero\n    unparam:\n      check-exported: false\n  exclusions:\n    generated: lax\n    presets:\n    - comments\n    - common-false-positives\n    - legacy\n    - std-error-handling\n    rules:\n    - linters:\n      - errcheck\n      - maligned\n      path: _test\\.go$|^tests/|^samples/\n    - linters:\n      - revive\n      - staticcheck\n      text: use underscores in Go names\n    - linters:\n      - revive\n      text: unused-parameter\n    - linters:\n      - revive\n      text: \"avoid meaningless package names\"\n    - linters:\n      - revive\n      text: \"Go standard library package names\"\n    - linters:\n      - unparam\n      text: is always false\n    paths:\n    - .*\\.pb\\.go\n    - .*\\.gen\\.go\n    - genfiles$\n    - vendor$\n    - bin$\n    - third_party$\n    - builtin$\n    - examples$\n    - node_modules\nformatters:\n  enable:\n  - gci\n  - gofumpt\n  - goimports\n  settings:\n    gci:\n      sections:\n      - standard\n      - default\n      - prefix(github.com/fatedier/frp/)\n  exclusions:\n    generated: lax\n    paths:\n    - .*\\.pb\\.go\n    - .*\\.gen\\.go\n    - genfiles$\n    - vendor$\n    - bin$\n    - third_party$\n    - builtin$\n    - examples$\n    - node_modules\nissues:\n  max-issues-per-linter: 0\n  max-same-issues: 0\n"
  },
  {
    "path": ".goreleaser.yml",
    "content": "builds:\n  - skip: true\nchecksum:\n  name_template: '{{ .ProjectName }}_sha256_checksums.txt'\n  algorithm: sha256\n  extra_files:\n  - glob: ./release/packages/*\nrelease:\n  # Same as for github\n  # Note: it can only be one: either github, gitlab or gitea\n  github:\n    owner: fatedier\n    name: frp\n\n  draft: false\n\n  # You can add extra pre-existing files to the release.\n  # The filename on the release will be the last part of the path (base). If\n  # another file with the same name exists, the latest one found will be used.\n  # Defaults to empty.\n  extra_files:\n    - glob: ./release/packages/*\n"
  },
  {
    "path": "AGENTS.md",
    "content": "# AGENTS.md\n\n## Development Commands\n\n### Build\n- `make build` - Build both frps and frpc binaries\n- `make frps` - Build server binary only\n- `make frpc` - Build client binary only\n- `make all` - Build everything with formatting\n\n### Testing\n- `make test` - Run unit tests\n- `make e2e` - Run end-to-end tests\n- `make e2e-trace` - Run e2e tests with trace logging\n- `make alltest` - Run all tests including vet, unit tests, and e2e\n\n### Code Quality\n- `make fmt` - Run go fmt\n- `make fmt-more` - Run gofumpt for more strict formatting\n- `make gci` - Run gci import organizer\n- `make vet` - Run go vet\n- `golangci-lint run` - Run comprehensive linting (configured in .golangci.yml)\n\n### Assets\n- `make web` - Build web dashboards (frps and frpc)\n\n### Cleanup\n- `make clean` - Remove built binaries and temporary files\n\n## Testing\n\n- E2E tests using Ginkgo/Gomega framework\n- Mock servers in `/test/e2e/mock/`\n- Run: `make e2e` or `make alltest`\n"
  },
  {
    "path": "LICENSE",
    "content": "                                 Apache License\n                           Version 2.0, January 2004\n                        http://www.apache.org/licenses/\n\n   TERMS AND CONDITIONS FOR USE, REPRODUCTION, AND DISTRIBUTION\n\n   1. Definitions.\n\n      \"License\" shall mean the terms and conditions for use, reproduction,\n      and distribution as defined by Sections 1 through 9 of this document.\n\n      \"Licensor\" shall mean the copyright owner or entity authorized by\n      the copyright owner that is granting the License.\n\n      \"Legal Entity\" shall mean the union of the acting entity and all\n      other entities that control, are controlled by, or are under common\n      control with that entity. For the purposes of this definition,\n      \"control\" means (i) the power, direct or indirect, to cause the\n      direction or management of such entity, whether by contract or\n      otherwise, or (ii) ownership of fifty percent (50%) or more of the\n      outstanding shares, or (iii) beneficial ownership of such entity.\n\n      \"You\" (or \"Your\") shall mean an individual or Legal Entity\n      exercising permissions granted by this License.\n\n      \"Source\" form shall mean the preferred form for making modifications,\n      including but not limited to software source code, documentation\n      source, and configuration files.\n\n      \"Object\" form shall mean any form resulting from mechanical\n      transformation or translation of a Source form, including but\n      not limited to compiled object code, generated documentation,\n      and conversions to other media types.\n\n      \"Work\" shall mean the work of authorship, whether in Source or\n      Object form, made available under the License, as indicated by a\n      copyright notice that is included in or attached to the work\n      (an example is provided in the Appendix below).\n\n      \"Derivative Works\" shall mean any work, whether in Source or Object\n      form, that is based on (or derived from) the Work and for which the\n      editorial revisions, annotations, elaborations, or other modifications\n      represent, as a whole, an original work of authorship. For the purposes\n      of this License, Derivative Works shall not include works that remain\n      separable from, or merely link (or bind by name) to the interfaces of,\n      the Work and Derivative Works thereof.\n\n      \"Contribution\" shall mean any work of authorship, including\n      the original version of the Work and any modifications or additions\n      to that Work or Derivative Works thereof, that is intentionally\n      submitted to Licensor for inclusion in the Work by the copyright owner\n      or by an individual or Legal Entity authorized to submit on behalf of\n      the copyright owner. For the purposes of this definition, \"submitted\"\n      means any form of electronic, verbal, or written communication sent\n      to the Licensor or its representatives, including but not limited to\n      communication on electronic mailing lists, source code control systems,\n      and issue tracking systems that are managed by, or on behalf of, the\n      Licensor for the purpose of discussing and improving the Work, but\n      excluding communication that is conspicuously marked or otherwise\n      designated in writing by the copyright owner as \"Not a Contribution.\"\n\n      \"Contributor\" shall mean Licensor and any individual or Legal Entity\n      on behalf of whom a Contribution has been received by Licensor and\n      subsequently incorporated within the Work.\n\n   2. Grant of Copyright License. Subject to the terms and conditions of\n      this License, each Contributor hereby grants to You a perpetual,\n      worldwide, non-exclusive, no-charge, royalty-free, irrevocable\n      copyright license to reproduce, prepare Derivative Works of,\n      publicly display, publicly perform, sublicense, and distribute the\n      Work and such Derivative Works in Source or Object form.\n\n   3. Grant of Patent License. Subject to the terms and conditions of\n      this License, each Contributor hereby grants to You a perpetual,\n      worldwide, non-exclusive, no-charge, royalty-free, irrevocable\n      (except as stated in this section) patent license to make, have made,\n      use, offer to sell, sell, import, and otherwise transfer the Work,\n      where such license applies only to those patent claims licensable\n      by such Contributor that are necessarily infringed by their\n      Contribution(s) alone or by combination of their Contribution(s)\n      with the Work to which such Contribution(s) was submitted. If You\n      institute patent litigation against any entity (including a\n      cross-claim or counterclaim in a lawsuit) alleging that the Work\n      or a Contribution incorporated within the Work constitutes direct\n      or contributory patent infringement, then any patent licenses\n      granted to You under this License for that Work shall terminate\n      as of the date such litigation is filed.\n\n   4. Redistribution. You may reproduce and distribute copies of the\n      Work or Derivative Works thereof in any medium, with or without\n      modifications, and in Source or Object form, provided that You\n      meet the following conditions:\n\n      (a) You must give any other recipients of the Work or\n          Derivative Works a copy of this License; and\n\n      (b) You must cause any modified files to carry prominent notices\n          stating that You changed the files; and\n\n      (c) You must retain, in the Source form of any Derivative Works\n          that You distribute, all copyright, patent, trademark, and\n          attribution notices from the Source form of the Work,\n          excluding those notices that do not pertain to any part of\n          the Derivative Works; and\n\n      (d) If the Work includes a \"NOTICE\" text file as part of its\n          distribution, then any Derivative Works that You distribute must\n          include a readable copy of the attribution notices contained\n          within such NOTICE file, excluding those notices that do not\n          pertain to any part of the Derivative Works, in at least one\n          of the following places: within a NOTICE text file distributed\n          as part of the Derivative Works; within the Source form or\n          documentation, if provided along with the Derivative Works; or,\n          within a display generated by the Derivative Works, if and\n          wherever such third-party notices normally appear. The contents\n          of the NOTICE file are for informational purposes only and\n          do not modify the License. You may add Your own attribution\n          notices within Derivative Works that You distribute, alongside\n          or as an addendum to the NOTICE text from the Work, provided\n          that such additional attribution notices cannot be construed\n          as modifying the License.\n\n      You may add Your own copyright statement to Your modifications and\n      may provide additional or different license terms and conditions\n      for use, reproduction, or distribution of Your modifications, or\n      for any such Derivative Works as a whole, provided Your use,\n      reproduction, and distribution of the Work otherwise complies with\n      the conditions stated in this License.\n\n   5. Submission of Contributions. Unless You explicitly state otherwise,\n      any Contribution intentionally submitted for inclusion in the Work\n      by You to the Licensor shall be under the terms and conditions of\n      this License, without any additional terms or conditions.\n      Notwithstanding the above, nothing herein shall supersede or modify\n      the terms of any separate license agreement you may have executed\n      with Licensor regarding such Contributions.\n\n   6. Trademarks. This License does not grant permission to use the trade\n      names, trademarks, service marks, or product names of the Licensor,\n      except as required for reasonable and customary use in describing the\n      origin of the Work and reproducing the content of the NOTICE file.\n\n   7. Disclaimer of Warranty. Unless required by applicable law or\n      agreed to in writing, Licensor provides the Work (and each\n      Contributor provides its Contributions) on an \"AS IS\" BASIS,\n      WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or\n      implied, including, without limitation, any warranties or conditions\n      of TITLE, NON-INFRINGEMENT, MERCHANTABILITY, or FITNESS FOR A\n      PARTICULAR PURPOSE. You are solely responsible for determining the\n      appropriateness of using or redistributing the Work and assume any\n      risks associated with Your exercise of permissions under this License.\n\n   8. Limitation of Liability. In no event and under no legal theory,\n      whether in tort (including negligence), contract, or otherwise,\n      unless required by applicable law (such as deliberate and grossly\n      negligent acts) or agreed to in writing, shall any Contributor be\n      liable to You for damages, including any direct, indirect, special,\n      incidental, or consequential damages of any character arising as a\n      result of this License or out of the use or inability to use the\n      Work (including but not limited to damages for loss of goodwill,\n      work stoppage, computer failure or malfunction, or any and all\n      other commercial damages or losses), even if such Contributor\n      has been advised of the possibility of such damages.\n\n   9. Accepting Warranty or Additional Liability. While redistributing\n      the Work or Derivative Works thereof, You may choose to offer,\n      and charge a fee for, acceptance of support, warranty, indemnity,\n      or other liability obligations and/or rights consistent with this\n      License. However, in accepting such obligations, You may act only\n      on Your own behalf and on Your sole responsibility, not on behalf\n      of any other Contributor, and only if You agree to indemnify,\n      defend, and hold each Contributor harmless for any liability\n      incurred by, or claims asserted against, such Contributor by reason\n      of your accepting any such warranty or additional liability.\n\n   END OF TERMS AND CONDITIONS\n\n   APPENDIX: How to apply the Apache License to your work.\n\n      To apply the Apache License to your work, attach the following\n      boilerplate notice, with the fields enclosed by brackets \"{}\"\n      replaced with your own identifying information. (Don't include\n      the brackets!)  The text should be enclosed in the appropriate\n      comment syntax for the file format. We also recommend that a\n      file or class name and description of purpose be included on the\n      same \"printed page\" as the copyright notice for easier\n      identification within third-party archives.\n\n   Copyright {yyyy} {name of copyright owner}\n\n   Licensed under the Apache License, Version 2.0 (the \"License\");\n   you may not use this file except in compliance with the License.\n   You may obtain a copy of the License at\n\n       http://www.apache.org/licenses/LICENSE-2.0\n\n   Unless required by applicable law or agreed to in writing, software\n   distributed under the License is distributed on an \"AS IS\" BASIS,\n   WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.\n   See the License for the specific language governing permissions and\n   limitations under the License.\n\n"
  },
  {
    "path": "Makefile",
    "content": "export PATH := $(PATH):`go env GOPATH`/bin\nexport GO111MODULE=on\nLDFLAGS := -s -w\nNOWEB_TAG = $(shell [ ! -d web/frps/dist ] || [ ! -d web/frpc/dist ] && echo ',noweb')\n\n.PHONY: web frps-web frpc-web frps frpc\n\nall: env fmt web build\n\nbuild: frps frpc\n\nenv:\n\t@go version\n\nweb: frps-web frpc-web\n\nfrps-web:\n\t$(MAKE) -C web/frps build\n\nfrpc-web:\n\t$(MAKE) -C web/frpc build\n\nfmt:\n\tgo fmt ./...\n\nfmt-more:\n\tgofumpt -l -w .\n\ngci:\n\tgci write -s standard -s default -s \"prefix(github.com/fatedier/frp/)\" ./\n\nvet:\n\tgo vet -tags \"$(NOWEB_TAG)\" ./...\n\nfrps:\n\tenv CGO_ENABLED=0 go build -trimpath -ldflags \"$(LDFLAGS)\" -tags \"frps$(NOWEB_TAG)\" -o bin/frps ./cmd/frps\n\nfrpc:\n\tenv CGO_ENABLED=0 go build -trimpath -ldflags \"$(LDFLAGS)\" -tags \"frpc$(NOWEB_TAG)\" -o bin/frpc ./cmd/frpc\n\ntest: gotest\n\ngotest:\n\tgo test -tags \"$(NOWEB_TAG)\" -v --cover ./assets/...\n\tgo test -tags \"$(NOWEB_TAG)\" -v --cover ./cmd/...\n\tgo test -tags \"$(NOWEB_TAG)\" -v --cover ./client/...\n\tgo test -tags \"$(NOWEB_TAG)\" -v --cover ./server/...\n\tgo test -tags \"$(NOWEB_TAG)\" -v --cover ./pkg/...\n\ne2e:\n\t./hack/run-e2e.sh\n\ne2e-trace:\n\tDEBUG=true LOG_LEVEL=trace ./hack/run-e2e.sh\n\ne2e-compatibility-last-frpc:\n\tif [ ! -d \"./lastversion\" ]; then \\\n\t\tTARGET_DIRNAME=lastversion ./hack/download.sh; \\\n\tfi\n\tFRPC_PATH=\"`pwd`/lastversion/frpc\" ./hack/run-e2e.sh\n\trm -r ./lastversion\n\ne2e-compatibility-last-frps:\n\tif [ ! -d \"./lastversion\" ]; then \\\n\t\tTARGET_DIRNAME=lastversion ./hack/download.sh; \\\n\tfi\n\tFRPS_PATH=\"`pwd`/lastversion/frps\" ./hack/run-e2e.sh\n\trm -r ./lastversion\n\nalltest: vet gotest e2e\n\t\nclean:\n\trm -f ./bin/frpc\n\trm -f ./bin/frps\n\trm -rf ./lastversion\n"
  },
  {
    "path": "Makefile.cross-compiles",
    "content": "export PATH := $(PATH):`go env GOPATH`/bin\nexport GO111MODULE=on\nLDFLAGS := -s -w\n\nos-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\n\nall: build\n\nbuild: app\n\napp:\n\t@$(foreach n, $(os-archs), \\\n\t\tos=$(shell echo \"$(n)\" | cut -d : -f 1); \\\n\t\tarch=$(shell echo \"$(n)\" | cut -d : -f 2); \\\n\t\textra=$(shell echo \"$(n)\" | cut -d : -f 3); \\\n\t\tflags=''; \\\n\t\ttarget_suffix=$${os}_$${arch}; \\\n\t\tif [ \"$${os}\" = \"linux\" ] && [ \"$${arch}\" = \"arm\" ] && [ \"$${extra}\" != \"\" ] ; then \\\n\t\t\tif [ \"$${extra}\" = \"7\" ]; then \\\n\t\t\t\tflags=GOARM=7; \\\n\t\t\t\ttarget_suffix=$${os}_arm_hf; \\\n\t\t\telif [ \"$${extra}\" = \"5\" ]; then \\\n\t\t\t\tflags=GOARM=5; \\\n\t\t\t\ttarget_suffix=$${os}_arm; \\\n\t\t\tfi; \\\n\t\telif [ \"$${os}\" = \"linux\" ] && ([ \"$${arch}\" = \"mips\" ] || [ \"$${arch}\" = \"mipsle\" ]) && [ \"$${extra}\" != \"\" ] ; then \\\n\t\t    flags=GOMIPS=$${extra}; \\\n\t\tfi; \\\n\t\techo \"Build $${os}-$${arch}$${extra:+ ($${extra})}...\"; \\\n\t\tenv CGO_ENABLED=0 GOOS=$${os} GOARCH=$${arch} $${flags} go build -trimpath -ldflags \"$(LDFLAGS)\" -tags frpc -o ./release/frpc_$${target_suffix} ./cmd/frpc; \\\n\t\tenv CGO_ENABLED=0 GOOS=$${os} GOARCH=$${arch} $${flags} go build -trimpath -ldflags \"$(LDFLAGS)\" -tags frps -o ./release/frps_$${target_suffix} ./cmd/frps; \\\n\t\techo \"Build $${os}-$${arch}$${extra:+ ($${extra})} done\"; \\\n\t)\n\t@mv ./release/frpc_windows_amd64 ./release/frpc_windows_amd64.exe\n\t@mv ./release/frps_windows_amd64 ./release/frps_windows_amd64.exe\n\t@mv ./release/frpc_windows_arm64 ./release/frpc_windows_arm64.exe\n\t@mv ./release/frps_windows_arm64 ./release/frps_windows_arm64.exe\n"
  },
  {
    "path": "README.md",
    "content": "# frp\n\n[![Build Status](https://circleci.com/gh/fatedier/frp.svg?style=shield)](https://circleci.com/gh/fatedier/frp)\n[![GitHub release](https://img.shields.io/github/tag/fatedier/frp.svg?label=release)](https://github.com/fatedier/frp/releases)\n[![Go Report Card](https://goreportcard.com/badge/github.com/fatedier/frp)](https://goreportcard.com/report/github.com/fatedier/frp)\n[![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)\n\n[README](README.md) | [中文文档](README_zh.md)\n\n## Sponsors\n\nfrp 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).\n\n<h3 align=\"center\">Gold Sponsors</h3>\n<!--gold sponsors start-->\n<div align=\"center\">\n\n## Recall.ai - API for meeting recordings\n\nIf 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),\n\nan API that records Zoom, Google Meet, Microsoft Teams, in-person meetings, and more.\n\n</div>\n\n<p align=\"center\">\n  <a href=\"https://requestly.com/?utm_source=github&utm_medium=partnered&utm_campaign=frp\" target=\"_blank\">\n    <img width=\"480px\" src=\"https://github.com/user-attachments/assets/24670320-997d-4d62-9bca-955c59fe883d\">\n    <br>\n    <b>Requestly - Free & Open-Source alternative to Postman</b>\n    <br>\n    <sub>All-in-one platform to Test, Mock and Intercept APIs.</sub>\n  </a>\n</p>\n\n<p align=\"center\">\n  <a href=\"https://jb.gg/frp\" target=\"_blank\">\n    <img width=\"420px\" src=\"https://raw.githubusercontent.com/fatedier/frp/dev/doc/pic/sponsor_jetbrains.jpg\">\n\t<br>\n\t<b>The complete IDE crafted for professional Go developers</b>\n  </a>\n</p>\n\n<p align=\"center\">\n  <a href=\"https://github.com/beclab/Olares\" target=\"_blank\">\n    <img width=\"420px\" src=\"https://raw.githubusercontent.com/fatedier/frp/dev/doc/pic/sponsor_olares.jpeg\">\n\t<br>\n\t<b>The sovereign cloud that puts you in control</b>\n\t<br>\n\t<sub>An open source, self-hosted alternative to public clouds, built for data ownership and privacy</sub>\n  </a>\n</p>\n<!--gold sponsors end-->\n\n## What is frp?\n\nfrp 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.\n\nfrp also offers a P2P connect mode.\n\n## Table of Contents\n\n<!-- vim-markdown-toc GFM -->\n\n* [Development Status](#development-status)\n    * [About V2](#about-v2)\n* [Architecture](#architecture)\n* [Example Usage](#example-usage)\n    * [Access your computer in a LAN network via SSH](#access-your-computer-in-a-lan-network-via-ssh)\n    * [Multiple SSH services sharing the same port](#multiple-ssh-services-sharing-the-same-port)\n    * [Accessing Internal Web Services with Custom Domains in LAN](#accessing-internal-web-services-with-custom-domains-in-lan)\n    * [Forward DNS query requests](#forward-dns-query-requests)\n    * [Forward Unix Domain Socket](#forward-unix-domain-socket)\n    * [Expose a simple HTTP file server](#expose-a-simple-http-file-server)\n    * [Enable HTTPS for a local HTTP(S) service](#enable-https-for-a-local-https-service)\n    * [Expose your service privately](#expose-your-service-privately)\n    * [P2P Mode](#p2p-mode)\n* [Features](#features)\n    * [Configuration Files](#configuration-files)\n    * [Using Environment Variables](#using-environment-variables)\n    * [Split Configures Into Different Files](#split-configures-into-different-files)\n    * [Server Dashboard](#server-dashboard)\n    * [Client Admin UI](#client-admin-ui)\n    * [Monitor](#monitor)\n        * [Prometheus](#prometheus)\n    * [Authenticating the Client](#authenticating-the-client)\n        * [Token Authentication](#token-authentication)\n        * [OIDC Authentication](#oidc-authentication)\n    * [Encryption and Compression](#encryption-and-compression)\n        * [TLS](#tls)\n    * [Hot-Reloading frpc configuration](#hot-reloading-frpc-configuration)\n    * [Get proxy status from client](#get-proxy-status-from-client)\n    * [Only allowing certain ports on the server](#only-allowing-certain-ports-on-the-server)\n    * [Port Reuse](#port-reuse)\n    * [Bandwidth Limit](#bandwidth-limit)\n        * [For Each Proxy](#for-each-proxy)\n    * [TCP Stream Multiplexing](#tcp-stream-multiplexing)\n    * [Support KCP Protocol](#support-kcp-protocol)\n    * [Support QUIC Protocol](#support-quic-protocol)\n    * [Connection Pooling](#connection-pooling)\n    * [Load balancing](#load-balancing)\n    * [Service Health Check](#service-health-check)\n    * [Rewriting the HTTP Host Header](#rewriting-the-http-host-header)\n    * [Setting other HTTP Headers](#setting-other-http-headers)\n    * [Get Real IP](#get-real-ip)\n        * [HTTP X-Forwarded-For](#http-x-forwarded-for)\n        * [Proxy Protocol](#proxy-protocol)\n    * [Require HTTP Basic Auth (Password) for Web Services](#require-http-basic-auth-password-for-web-services)\n    * [Custom Subdomain Names](#custom-subdomain-names)\n    * [URL Routing](#url-routing)\n    * [TCP Port Multiplexing](#tcp-port-multiplexing)\n    * [Connecting to frps via PROXY](#connecting-to-frps-via-proxy)\n    * [Port range mapping](#port-range-mapping)\n    * [Client Plugins](#client-plugins)\n    * [Server Manage Plugins](#server-manage-plugins)\n    * [SSH Tunnel Gateway](#ssh-tunnel-gateway)\n    * [Virtual Network (VirtualNet)](#virtual-network-virtualnet)\n* [Feature Gates](#feature-gates)\n    * [Available Feature Gates](#available-feature-gates)\n    * [Enabling Feature Gates](#enabling-feature-gates)\n    * [Feature Lifecycle](#feature-lifecycle)\n* [Related Projects](#related-projects)\n* [Contributing](#contributing)\n* [Donation](#donation)\n    * [GitHub Sponsors](#github-sponsors)\n    * [PayPal](#paypal)\n\n<!-- vim-markdown-toc -->\n\n## Development Status\n\nfrp 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.\n\nWe 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.\n\nWe 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.\n\n### About V2\n\nThe 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.\n\nThe 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.\n\nIn 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.\n\nFinally, 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.\n\nWe sincerely appreciate your support for frp.\n\n## Architecture\n\n![architecture](/doc/pic/architecture.png)\n\n## Example Usage\n\nTo begin, download the latest program for your operating system and architecture from the [Release](https://github.com/fatedier/frp/releases) page.\n\nNext, place the `frps` binary and server configuration file on Server A, which has a public IP address.\n\nFinally, 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.\n\nSome 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.\n\n### Access your computer in a LAN network via SSH\n\n1. Modify `frps.toml` on server A by setting the `bindPort` for frp clients to connect to:\n\n  ```toml\n  # frps.toml\n  bindPort = 7000\n  ```\n\n2. Start `frps` on server A:\n\n  `./frps -c ./frps.toml`\n\n3. Modify `frpc.toml` on server B and set the `serverAddr` field to the public IP address of your frps server:\n\n  ```toml\n  # frpc.toml\n  serverAddr = \"x.x.x.x\"\n  serverPort = 7000\n\n  [[proxies]]\n  name = \"ssh\"\n  type = \"tcp\"\n  localIP = \"127.0.0.1\"\n  localPort = 22\n  remotePort = 6000\n  ```\n\nNote 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.\n\n4. Start `frpc` on server B:\n\n  `./frpc -c ./frpc.toml`\n\n5. To access server B from another machine through server A via SSH (assuming the username is `test`), use the following command:\n\n  `ssh -oPort=6000 test@x.x.x.x`\n\n### Multiple SSH services sharing the same port\n\nThis 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.\n\n1. Deploy frps on a machine with a public IP and modify the frps.toml file. Here is a simplified configuration:\n\n  ```toml\n  bindPort = 7000\n  tcpmuxHTTPConnectPort = 5002\n  ```\n\n2. Deploy frpc on the internal machine A with the following configuration:\n\n  ```toml\n  serverAddr = \"x.x.x.x\"\n  serverPort = 7000\n\n  [[proxies]]\n  name = \"ssh1\"\n  type = \"tcpmux\"\n  multiplexer = \"httpconnect\"\n  customDomains = [\"machine-a.example.com\"]\n  localIP = \"127.0.0.1\"\n  localPort = 22\n  ```\n\n3. Deploy another frpc on the internal machine B with the following configuration:\n\n  ```toml\n  serverAddr = \"x.x.x.x\"\n  serverPort = 7000\n\n  [[proxies]]\n  name = \"ssh2\"\n  type = \"tcpmux\"\n  multiplexer = \"httpconnect\"\n  customDomains = [\"machine-b.example.com\"]\n  localIP = \"127.0.0.1\"\n  localPort = 22\n  ```\n\n4. To access internal machine A using SSH ProxyCommand, assuming the username is \"test\":\n\n  `ssh -o 'proxycommand socat - PROXY:x.x.x.x:%h:%p,proxyport=5002' test@machine-a.example.com`\n\n5. To access internal machine B, the only difference is the domain name, assuming the username is \"test\":\n\n  `ssh -o 'proxycommand socat - PROXY:x.x.x.x:%h:%p,proxyport=5002' test@machine-b.example.com`\n\n### Accessing Internal Web Services with Custom Domains in LAN\n\nSometimes we need to expose a local web service behind a NAT network to others for testing purposes with our own domain name.\n\nUnfortunately, we cannot resolve a domain name to a local IP. However, we can use frp to expose an HTTP(S) service.\n\n1. Modify `frps.toml` and set the HTTP port for vhost to 8080:\n\n  ```toml\n  # frps.toml\n  bindPort = 7000\n  vhostHTTPPort = 8080\n  ```\n\n  If you want to configure an https proxy, you need to set up the `vhostHTTPSPort`.\n\n2. Start `frps`:\n\n  `./frps -c ./frps.toml`\n\n3. Modify `frpc.toml` and set `serverAddr` to the IP address of the remote frps server. Specify the `localPort` of your web service:\n\n  ```toml\n  # frpc.toml\n  serverAddr = \"x.x.x.x\"\n  serverPort = 7000\n\n  [[proxies]]\n  name = \"web\"\n  type = \"http\"\n  localPort = 80\n  customDomains = [\"www.example.com\"]\n  ```\n\n4. Start `frpc`:\n\n  `./frpc -c ./frpc.toml`\n\n5. 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.\n\n6. Visit your local web service using url `http://www.example.com:8080`.\n\n### Forward DNS query requests\n\n1. Modify `frps.toml`:\n\n  ```toml\n  # frps.toml\n  bindPort = 7000\n  ```\n\n2. Start `frps`:\n\n  `./frps -c ./frps.toml`\n\n3. 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`:\n\n  ```toml\n  # frpc.toml\n  serverAddr = \"x.x.x.x\"\n  serverPort = 7000\n\n  [[proxies]]\n  name = \"dns\"\n  type = \"udp\"\n  localIP = \"8.8.8.8\"\n  localPort = 53\n  remotePort = 6000\n  ```\n\n4. Start frpc:\n\n  `./frpc -c ./frpc.toml`\n\n5. Test DNS resolution using the `dig` command:\n\n  `dig @x.x.x.x -p 6000 www.google.com`\n\n### Forward Unix Domain Socket\n\nExpose a Unix domain socket (e.g. the Docker daemon socket) as TCP.\n\nConfigure `frps` as above.\n\n1. Start `frpc` with the following configuration:\n\n  ```toml\n  # frpc.toml\n  serverAddr = \"x.x.x.x\"\n  serverPort = 7000\n\n  [[proxies]]\n  name = \"unix_domain_socket\"\n  type = \"tcp\"\n  remotePort = 6000\n  [proxies.plugin]\n  type = \"unix_domain_socket\"\n  unixPath = \"/var/run/docker.sock\"\n  ```\n\n2. Test the configuration by getting the docker version using `curl`:\n\n  `curl http://x.x.x.x:6000/version`\n\n### Expose a simple HTTP file server\n\nExpose a simple HTTP file server to access files stored in the LAN from the public Internet.\n\nConfigure `frps` as described above, then:\n\n1. Start `frpc` with the following configuration:\n\n  ```toml\n  # frpc.toml\n  serverAddr = \"x.x.x.x\"\n  serverPort = 7000\n\n  [[proxies]]\n  name = \"test_static_file\"\n  type = \"tcp\"\n  remotePort = 6000\n  [proxies.plugin]\n  type = \"static_file\"\n  localPath = \"/tmp/files\"\n  stripPrefix = \"static\"\n  httpUser = \"abc\"\n  httpPassword = \"abc\"\n  ```\n\n2. 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.\n\n### Enable HTTPS for a local HTTP(S) service\n\nYou may substitute `https2https` for the plugin, and point the `localAddr` to a HTTPS endpoint.\n\n1. Start `frpc` with the following configuration:\n\n  ```toml\n  # frpc.toml\n  serverAddr = \"x.x.x.x\"\n  serverPort = 7000\n\n  [[proxies]]\n  name = \"test_https2http\"\n  type = \"https\"\n  customDomains = [\"test.example.com\"]\n\n  [proxies.plugin]\n  type = \"https2http\"\n  localAddr = \"127.0.0.1:80\"\n  crtPath = \"./server.crt\"\n  keyPath = \"./server.key\"\n  hostHeaderRewrite = \"127.0.0.1\"\n  requestHeaders.set.x-from-where = \"frp\"\n  ```\n\n2. Visit `https://test.example.com`.\n\n### Expose your service privately\n\nTo 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.\n\nConfigure `frps` same as above.\n\n1. 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:\n\n  ```toml\n  # frpc.toml\n  serverAddr = \"x.x.x.x\"\n  serverPort = 7000\n\n  [[proxies]]\n  name = \"secret_ssh\"\n  type = \"stcp\"\n  secretKey = \"abcdefg\"\n  localIP = \"127.0.0.1\"\n  localPort = 22\n  ```\n\n2. Start another `frpc` (typically on another machine C) with the following config to access the SSH service with a security key (`secretKey` field):\n\n  ```toml\n  # frpc.toml\n  serverAddr = \"x.x.x.x\"\n  serverPort = 7000\n\n  [[visitors]]\n  name = \"secret_ssh_visitor\"\n  type = \"stcp\"\n  serverName = \"secret_ssh\"\n  secretKey = \"abcdefg\"\n  bindAddr = \"127.0.0.1\"\n  bindPort = 6000\n  ```\n\n3. On machine C, connect to SSH on machine B, using this command:\n\n  `ssh -oPort=6000 127.0.0.1`\n\n### P2P Mode\n\n**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.\n\nNote that it may not work with all types of NAT devices. You might want to fallback to stcp if xtcp doesn't work.\n\n1. Start `frpc` on machine B, and expose the SSH port. Note that the `remotePort` field is removed:\n\n  ```toml\n  # frpc.toml\n  serverAddr = \"x.x.x.x\"\n  serverPort = 7000\n  # set up a new stun server if the default one is not available.\n  # natHoleStunServer = \"xxx\"\n\n  [[proxies]]\n  name = \"p2p_ssh\"\n  type = \"xtcp\"\n  secretKey = \"abcdefg\"\n  localIP = \"127.0.0.1\"\n  localPort = 22\n  ```\n\n2. Start another `frpc` (typically on another machine C) with the configuration to connect to SSH using P2P mode:\n\n  ```toml\n  # frpc.toml\n  serverAddr = \"x.x.x.x\"\n  serverPort = 7000\n  # set up a new stun server if the default one is not available.\n  # natHoleStunServer = \"xxx\"\n\n  [[visitors]]\n  name = \"p2p_ssh_visitor\"\n  type = \"xtcp\"\n  serverName = \"p2p_ssh\"\n  secretKey = \"abcdefg\"\n  bindAddr = \"127.0.0.1\"\n  bindPort = 6000\n  # when automatic tunnel persistence is required, set it to true\n  keepTunnelOpen = false\n  ```\n\n3. On machine C, connect to SSH on machine B, using this command:\n\n  `ssh -oPort=6000 127.0.0.1`\n\n## Features\n\n### Configuration Files\n\nSince 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.\n\nRead the full example configuration files to find out even more features not described here.\n\nExamples use TOML format, but you can still use YAML or JSON.\n\nThese configuration files is for reference only. Please do not use this configuration directly to run the program as it may have various issues.\n\n[Full configuration file for frps (Server)](./conf/frps_full_example.toml)\n\n[Full configuration file for frpc (Client)](./conf/frpc_full_example.toml)\n\n### Using Environment Variables\n\nEnvironment variables can be referenced in the configuration file, using Go's standard format:\n\n```toml\n# frpc.toml\nserverAddr = \"{{ .Envs.FRP_SERVER_ADDR }}\"\nserverPort = 7000\n\n[[proxies]]\nname = \"ssh\"\ntype = \"tcp\"\nlocalIP = \"127.0.0.1\"\nlocalPort = 22\nremotePort = {{ .Envs.FRP_SSH_REMOTE_PORT }}\n```\n\nWith the config above, variables can be passed into `frpc` program like this:\n\n```\nexport FRP_SERVER_ADDR=x.x.x.x\nexport FRP_SSH_REMOTE_PORT=6000\n./frpc -c ./frpc.toml\n```\n\n`frpc` will render configuration file template using OS environment variables. Remember to prefix your reference with `.Envs`.\n\n### Split Configures Into Different Files\n\nYou can split multiple proxy configs into different files and include them in the main file.\n\n```toml\n# frpc.toml\nserverAddr = \"x.x.x.x\"\nserverPort = 7000\nincludes = [\"./confd/*.toml\"]\n```\n\n```toml\n# ./confd/test.toml\n\n[[proxies]]\nname = \"ssh\"\ntype = \"tcp\"\nlocalIP = \"127.0.0.1\"\nlocalPort = 22\nremotePort = 6000\n```\n\n### Server Dashboard\n\nCheck frp's status and proxies' statistics information by Dashboard.\n\nConfigure a port for dashboard to enable this feature:\n\n```toml\n# 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.\nwebServer.addr = \"0.0.0.0\"\nwebServer.port = 7500\n# dashboard's username and password are both optional\nwebServer.user = \"admin\"\nwebServer.password = \"admin\"\n```\n\nThen visit `http://[serverAddr]:7500` to see the dashboard, with username and password both being `admin`.\n\nAdditionally, you can use HTTPS port by using your domains wildcard or normal SSL certificate:\n\n```toml\nwebServer.port = 7500\n# dashboard's username and password are both optional\nwebServer.user = \"admin\"\nwebServer.password = \"admin\"\nwebServer.tls.certFile = \"server.crt\"\nwebServer.tls.keyFile = \"server.key\"\n```\n\nThen visit `https://[serverAddr]:7500` to see the dashboard in secure HTTPS connection, with username and password both being `admin`.\n\n![dashboard](/doc/pic/dashboard.png)\n\n### Client Admin UI\n\nThe Client Admin UI helps you check and manage frpc's configuration.\n\nConfigure an address for admin UI to enable this feature:\n\n```toml\nwebServer.addr = \"127.0.0.1\"\nwebServer.port = 7400\nwebServer.user = \"admin\"\nwebServer.password = \"admin\"\n```\n\nThen visit `http://127.0.0.1:7400` to see admin UI, with username and password both being `admin`.\n\n### Monitor\n\nWhen web server is enabled, frps will save monitor data in cache for 7 days. It will be cleared after process restart.\n\nPrometheus is also supported.\n\n#### Prometheus\n\nEnable dashboard first, then configure `enablePrometheus = true` in `frps.toml`.\n\n`http://{dashboard_addr}/metrics` will provide prometheus monitor data.\n\n### Authenticating the Client\n\nThere are 2 authentication methods to authenticate frpc with frps. \n\nYou can decide which one to use by configuring `auth.method` in `frpc.toml` and `frps.toml`, the default one is token.\n\nConfiguring `auth.additionalScopes = [\"HeartBeats\"]` will use the configured authentication method to add and validate authentication on every heartbeat between frpc and frps.\n\nConfiguring `auth.additionalScopes = [\"NewWorkConns\"]` will do the same for every new work connection between frpc and frps.\n\n#### Token Authentication\n\nWhen specifying `auth.method = \"token\"` in `frpc.toml` and `frps.toml` - token based authentication will be used.\n\nMake sure to specify the same `auth.token` in `frps.toml` and `frpc.toml` for frpc to pass frps validation\n\n##### Token Source\n\nfrp supports reading authentication tokens from external sources using the `tokenSource` configuration. Currently, file-based token source is supported.\n\n**File-based token source:**\n\n```toml\n# frpc.toml\nauth.method = \"token\"\nauth.tokenSource.type = \"file\"\nauth.tokenSource.file.path = \"/path/to/token/file\"\n```\n\nThe 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.\n\n#### OIDC Authentication\n\nWhen specifying `auth.method = \"oidc\"` in `frpc.toml` and `frps.toml` - OIDC based authentication will be used.\n\nOIDC stands for OpenID Connect, and the flow used is called [Client Credentials Grant](https://tools.ietf.org/html/rfc6749#section-4.4).\n\nTo use this authentication type - configure `frpc.toml` and `frps.toml` as follows:\n\n```toml\n# frps.toml\nauth.method = \"oidc\"\nauth.oidc.issuer = \"https://example-oidc-issuer.com/\"\nauth.oidc.audience = \"https://oidc-audience.com/.default\"\n```\n\n```toml\n# frpc.toml\nauth.method = \"oidc\"\nauth.oidc.clientID = \"98692467-37de-409a-9fac-bb2585826f18\" # Replace with OIDC client ID\nauth.oidc.clientSecret = \"oidc_secret\"\nauth.oidc.audience = \"https://oidc-audience.com/.default\"\nauth.oidc.tokenEndpointURL = \"https://example-oidc-endpoint.com/oauth2/v2.0/token\"\n```\n\n### Encryption and Compression\n\nThe features are off by default. You can turn on encryption and/or compression:\n\n```toml\n# frpc.toml\n\n[[proxies]]\nname = \"ssh\"\ntype = \"tcp\"\nlocalPort = 22\nremotePort = 6000\ntransport.useEncryption = true\ntransport.useCompression = true\n```\n\n#### TLS\n\nSince 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.\n\nFor 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.\n\nTo **enforce** `frps` to only accept TLS connections - configure `transport.tls.force = true` in `frps.toml`. **This is optional.**\n\n**`frpc` TLS settings:**\n\n```toml\ntransport.tls.enable = true\ntransport.tls.certFile = \"certificate.crt\"\ntransport.tls.keyFile = \"certificate.key\"\ntransport.tls.trustedCaFile = \"ca.crt\"\n```\n\n**`frps` TLS settings:**\n\n```toml\ntransport.tls.force = true\ntransport.tls.certFile = \"certificate.crt\"\ntransport.tls.keyFile = \"certificate.key\"\ntransport.tls.trustedCaFile = \"ca.crt\"\n```\n\nYou 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).\n\nIf 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.\n\nGiven an example:\n\n* 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:\n```\ncat > my-openssl.cnf << EOF\n[ ca ]\ndefault_ca = CA_default\n[ CA_default ]\nx509_extensions = usr_cert\n[ req ]\ndefault_bits        = 2048\ndefault_md          = sha256\ndefault_keyfile     = privkey.pem\ndistinguished_name  = req_distinguished_name\nattributes          = req_attributes\nx509_extensions     = v3_ca\nstring_mask         = utf8only\n[ req_distinguished_name ]\n[ req_attributes ]\n[ usr_cert ]\nbasicConstraints       = CA:FALSE\nnsComment              = \"OpenSSL Generated Certificate\"\nsubjectKeyIdentifier   = hash\nauthorityKeyIdentifier = keyid,issuer\n[ v3_ca ]\nsubjectKeyIdentifier   = hash\nauthorityKeyIdentifier = keyid:always,issuer\nbasicConstraints       = CA:true\nEOF\n```\n\n* build ca certificates:\n```\nopenssl genrsa -out ca.key 2048\nopenssl req -x509 -new -nodes -key ca.key -subj \"/CN=example.ca.com\" -days 5000 -out ca.crt\n```\n\n* build frps certificates:\n```\nopenssl genrsa -out server.key 2048\n\nopenssl req -new -sha256 -key server.key \\\n    -subj \"/C=XX/ST=DEFAULT/L=DEFAULT/O=DEFAULT/CN=server.com\" \\\n    -reqexts SAN \\\n    -config <(cat my-openssl.cnf <(printf \"\\n[SAN]\\nsubjectAltName=DNS:localhost,IP:127.0.0.1,DNS:example.server.com\")) \\\n    -out server.csr\n\nopenssl x509 -req -days 365 -sha256 \\\n\t-in server.csr -CA ca.crt -CAkey ca.key -CAcreateserial \\\n\t-extfile <(printf \"subjectAltName=DNS:localhost,IP:127.0.0.1,DNS:example.server.com\") \\\n\t-out server.crt\n```\n\n* build frpc certificates：\n```\nopenssl genrsa -out client.key 2048\nopenssl req -new -sha256 -key client.key \\\n    -subj \"/C=XX/ST=DEFAULT/L=DEFAULT/O=DEFAULT/CN=client.com\" \\\n    -reqexts SAN \\\n    -config <(cat my-openssl.cnf <(printf \"\\n[SAN]\\nsubjectAltName=DNS:client.com,DNS:example.client.com\")) \\\n    -out client.csr\n\nopenssl x509 -req -days 365 -sha256 \\\n    -in client.csr -CA ca.crt -CAkey ca.key -CAcreateserial \\\n\t-extfile <(printf \"subjectAltName=DNS:client.com,DNS:example.client.com\") \\\n\t-out client.crt\n```\n\n### Hot-Reloading frpc configuration\n\nThe `webServer` fields are required for enabling HTTP API:\n\n```toml\n# frpc.toml\nwebServer.addr = \"127.0.0.1\"\nwebServer.port = 7400\n```\n\nThen run command `frpc reload -c ./frpc.toml` and wait for about 10 seconds to let `frpc` create or update or remove proxies.\n\n**Note that global client parameters won't be modified except 'start'.**\n\n`start` is a global allowlist evaluated after all sources are merged (config file/include/store).\nIf `start` is non-empty, any proxy or visitor not listed there will not be started, including\nentries created via Store API.\n\n`start` is kept mainly for compatibility and is generally not recommended for new configurations.\nPrefer per-proxy/per-visitor `enabled`, and keep `start` empty unless you explicitly want this\nglobal allowlist behavior.\n\nYou can run command `frpc verify -c ./frpc.toml` before reloading to check if there are config errors.\n\n### Get proxy status from client\n\nUse `frpc status -c ./frpc.toml` to get status of all proxies. The `webServer` fields are required for enabling HTTP API.\n\n### Only allowing certain ports on the server\n\n`allowPorts` in `frps.toml` is used to avoid abuse of ports:\n\n```toml\n# frps.toml\nallowPorts = [\n  { start = 2000, end = 3000 },\n  { single = 3001 },\n  { single = 3003 },\n  { start = 4000, end = 50000 }\n]\n```\n\n### Port Reuse\n\n`vhostHTTPPort` and `vhostHTTPSPort` in frps can use same port with `bindPort`. frps will detect the connection's protocol and handle it correspondingly.\n\nWhat 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.\n\nWe would like to try to allow multiple proxies bind a same remote port with different protocols in the future.\n\n### Bandwidth Limit\n\n#### For Each Proxy\n\n```toml\n# frpc.toml\n\n[[proxies]]\nname = \"ssh\"\ntype = \"tcp\"\nlocalPort = 22\nremotePort = 6000\ntransport.bandwidthLimit = \"1MB\"\n```\n\nSet `transport.bandwidthLimit` in each proxy's configure to enable this feature. Supported units are `MB` and `KB`.\n\nSet `transport.bandwidthLimitMode` to `client` or `server` to limit bandwidth on the client or server side. Default is `client`.\n\n### TCP Stream Multiplexing\n\nfrp 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.\n\nYou can disable this feature by modify `frps.toml` and `frpc.toml`:\n\n```toml\n# frps.toml and frpc.toml, must be same\ntransport.tcpMux = false\n```\n\n### Support KCP Protocol\n\nKCP 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.\n\nKCP mode uses UDP as the underlying transport. Using KCP in frp:\n\n1. Enable KCP in frps:\n\n  ```toml\n  # frps.toml\n  bindPort = 7000\n  # Specify a UDP port for KCP.\n  kcpBindPort = 7000\n  ```\n\n  The `kcpBindPort` number can be the same number as `bindPort`, since `bindPort` field specifies a TCP port.\n\n2. Configure `frpc.toml` to use KCP to connect to frps:\n\n  ```toml\n  # frpc.toml\n  serverAddr = \"x.x.x.x\"\n  # Same as the 'kcpBindPort' in frps.toml\n  serverPort = 7000\n  transport.protocol = \"kcp\"\n  ```\n\n### Support QUIC Protocol\n\nQUIC is a new multiplexed transport built on top of UDP.\n\nUsing QUIC in frp:\n\n1. Enable QUIC in frps:\n\n  ```toml\n  # frps.toml\n  bindPort = 7000\n  # Specify a UDP port for QUIC.\n  quicBindPort = 7000\n  ```\n\n  The `quicBindPort` number can be the same number as `bindPort`, since `bindPort` field specifies a TCP port.\n\n2. Configure `frpc.toml` to use QUIC to connect to frps:\n\n  ```toml\n  # frpc.toml\n  serverAddr = \"x.x.x.x\"\n  # Same as the 'quicBindPort' in frps.toml\n  serverPort = 7000\n  transport.protocol = \"quic\"\n  ```\n\n### Connection Pooling\n\nBy 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.\n\nThis feature is suitable for a large number of short connections.\n\n1. Configure the limit of pool count each proxy can use in `frps.toml`:\n\n  ```toml\n  # frps.toml\n  transport.maxPoolCount = 5\n  ```\n\n2. Enable and specify the number of connection pool:\n\n  ```toml\n  # frpc.toml\n  transport.poolCount = 1\n  ```\n\n### Load balancing\n\nLoad balancing is supported by `group`.\n\nThis feature is only available for types `tcp`, `http`, `tcpmux` now.\n\n```toml\n# frpc.toml\n\n[[proxies]]\nname = \"test1\"\ntype = \"tcp\"\nlocalPort = 8080\nremotePort = 80\nloadBalancer.group = \"web\"\nloadBalancer.groupKey = \"123\"\n\n[[proxies]]\nname = \"test2\"\ntype = \"tcp\"\nlocalPort = 8081\nremotePort = 80\nloadBalancer.group = \"web\"\nloadBalancer.groupKey = \"123\"\n```\n\n`loadBalancer.groupKey` is used for authentication.\n\nConnections to port 80 will be dispatched to proxies in the same group randomly.\n\nFor type `tcp`, `remotePort` in the same group should be the same.\n\nFor type `http`, `customDomains`, `subdomain`, `locations` should be the same.\n\n### Service Health Check\n\nHealth check feature can help you achieve high availability with load balancing.\n\nAdd `healthCheck.type = \"tcp\"` or `healthCheck.type = \"http\"` to enable health check.\n\nWith health check type **tcp**, the service port will be pinged (TCPing):\n\n```toml\n# frpc.toml\n\n[[proxies]]\nname = \"test1\"\ntype = \"tcp\"\nlocalPort = 22\nremotePort = 6000\n# Enable TCP health check\nhealthCheck.type = \"tcp\"\n# TCPing timeout seconds\nhealthCheck.timeoutSeconds = 3\n# If health check failed 3 times in a row, the proxy will be removed from frps\nhealthCheck.maxFailed = 3\n# A health check every 10 seconds\nhealthCheck.intervalSeconds = 10\n```\n\nWith health check type **http**, an HTTP request will be sent to the service and an HTTP 2xx OK response is expected:\n\n```toml\n# frpc.toml\n\n[[proxies]]\nname = \"web\"\ntype = \"http\"\nlocalIP = \"127.0.0.1\"\nlocalPort = 80\ncustomDomains = [\"test.example.com\"]\n# Enable HTTP health check\nhealthCheck.type = \"http\"\n# frpc will send a GET request to '/status'\n# and expect an HTTP 2xx OK response\nhealthCheck.path = \"/status\"\nhealthCheck.timeoutSeconds = 3\nhealthCheck.maxFailed = 3\nhealthCheck.intervalSeconds = 10\n```\n\n### Rewriting the HTTP Host Header\n\nBy default frp does not modify the tunneled HTTP requests at all as it's a byte-for-byte copy.\n\nHowever, 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:\n\n```toml\n# frpc.toml\n\n[[proxies]]\nname = \"web\"\ntype = \"http\"\nlocalPort = 80\ncustomDomains = [\"test.example.com\"]\nhostHeaderRewrite = \"dev.example.com\"\n```\n\nThe 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`.\n\n### Setting other HTTP Headers\n\nSimilar to `Host`, You can override other HTTP request and response headers with proxy type `http`.\n\n```toml\n# frpc.toml\n\n[[proxies]]\nname = \"web\"\ntype = \"http\"\nlocalPort = 80\ncustomDomains = [\"test.example.com\"]\nhostHeaderRewrite = \"dev.example.com\"\nrequestHeaders.set.x-from-where = \"frp\"\nresponseHeaders.set.foo = \"bar\"\n```\n\nIn this example, it will set header `x-from-where: frp` in the HTTP request and `foo: bar` in the HTTP response.\n\n### Get Real IP\n\n#### HTTP X-Forwarded-For\n\nThis feature is for `http` proxies or proxies with the `https2http` and `https2https` plugins enabled.\n\nYou can get user's real IP from HTTP request headers `X-Forwarded-For`.\n\n#### Proxy Protocol\n\nfrp supports Proxy Protocol to send user's real IP to local services.\n\nHere is an example for https service:\n\n```toml\n# frpc.toml\n\n[[proxies]]\nname = \"web\"\ntype = \"https\"\nlocalPort = 443\ncustomDomains = [\"test.example.com\"]\n\n# now v1 and v2 are supported\ntransport.proxyProtocolVersion = \"v2\"\n```\n\nYou 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.\n\n### Require HTTP Basic Auth (Password) for Web Services\n\nAnyone who can guess your tunnel URL can access your local web server unless you protect it with a password.\n\nThis enforces HTTP Basic Auth on all requests with the username and password specified in frpc's configure file.\n\nIt can only be enabled when proxy type is http.\n\n```toml\n# frpc.toml\n\n[[proxies]]\nname = \"web\"\ntype = \"http\"\nlocalPort = 80\ncustomDomains = [\"test.example.com\"]\nhttpUser = \"abc\"\nhttpPassword = \"abc\"\n```\n\nVisit `http://test.example.com` in the browser and now you are prompted to enter the username and password.\n\n### Custom Subdomain Names\n\nIt is convenient to use `subdomain` configure for http and https types when many people share one frps server.\n\n```toml\n# frps.toml\nsubDomainHost = \"frps.com\"\n```\n\nResolve `*.frps.com` to the frps server's IP. This is usually called a Wildcard DNS record.\n\n```toml\n# frpc.toml\n\n[[proxies]]\nname = \"web\"\ntype = \"http\"\nlocalPort = 80\nsubdomain = \"test\"\n```\n\nNow you can visit your web service on `test.frps.com`.\n\nNote that if `subdomainHost` is not empty, `customDomains` should not be the subdomain of `subdomainHost`.\n\n### URL Routing\n\nfrp supports forwarding HTTP requests to different backend web services by url routing.\n\n`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.\n\n```toml\n# frpc.toml\n\n[[proxies]]\nname = \"web01\"\ntype = \"http\"\nlocalPort = 80\ncustomDomains = [\"web.example.com\"]\nlocations = [\"/\"]\n\n[[proxies]]\nname = \"web02\"\ntype = \"http\"\nlocalPort = 81\ncustomDomains = [\"web.example.com\"]\nlocations = [\"/news\", \"/about\"]\n```\n\nHTTP requests with URL prefix `/news` or `/about` will be forwarded to **web02** and other requests to **web01**.\n\n### TCP Port Multiplexing\n\nfrp supports receiving TCP sockets directed to different proxies on a single port on frps, similar to `vhostHTTPPort` and `vhostHTTPSPort`.\n\nThe only supported TCP port multiplexing method available at the moment is `httpconnect` - HTTP CONNECT tunnel.\n\nWhen setting `tcpmuxHTTPConnectPort` to anything other than 0 in frps, frps will listen on this port for HTTP CONNECT requests.\n\nThe 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\"`.\n\nFor example:\n\n```toml\n# frps.toml\nbindPort = 7000\ntcpmuxHTTPConnectPort = 1337\n```\n\n```toml\n# frpc.toml\nserverAddr = \"x.x.x.x\"\nserverPort = 7000\n\n[[proxies]]\nname = \"proxy1\"\ntype = \"tcpmux\"\nmultiplexer = \"httpconnect\"\ncustomDomains = [\"test1\"]\nlocalPort = 80\n\n[[proxies]]\nname = \"proxy2\"\ntype = \"tcpmux\"\nmultiplexer = \"httpconnect\"\ncustomDomains = [\"test2\"]\nlocalPort = 8080\n```\n\nIn the above configuration - frps can be contacted on port 1337 with a HTTP CONNECT header such as:\n\n```\nCONNECT test1 HTTP/1.1\\r\\n\\r\\n\n```\nand the connection will be routed to `proxy1`.\n\n### Connecting to frps via PROXY\n\nfrpc can connect to frps through proxy if you set OS environment variable `HTTP_PROXY`, or if `transport.proxyURL` is set in frpc.toml file.\n\nIt only works when protocol is tcp.\n\n```toml\n# frpc.toml\nserverAddr = \"x.x.x.x\"\nserverPort = 7000\ntransport.proxyURL = \"http://user:pwd@192.168.1.128:8080\"\n```\n\n### Port range mapping\n\n*Added in v0.56.0*\n\nWe can use the range syntax of Go template combined with the built-in `parseNumberRangePair` function to achieve port range mapping.\n\nThe following example, when run, will create 8 proxies named `test-6000, test-6001 ... test-6007`, each mapping the remote port to the local port.\n\n```\n{{- range $_, $v := parseNumberRangePair \"6000-6006,6007\" \"6000-6006,6007\" }}\n[[proxies]]\nname = \"tcp-{{ $v.First }}\"\ntype = \"tcp\"\nlocalPort = {{ $v.First }}\nremotePort = {{ $v.Second }}\n{{- end }}\n```\n\n### Client Plugins\n\nfrpc only forwards requests to local TCP or UDP ports by default.\n\nPlugins 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).\n\nUsing plugin **http_proxy**:\n\n```toml\n# frpc.toml\n\n[[proxies]]\nname = \"http_proxy\"\ntype = \"tcp\"\nremotePort = 6000\n[proxies.plugin]\ntype = \"http_proxy\"\nhttpUser = \"abc\"\nhttpPassword = \"abc\"\n```\n\n`httpUser` and `httpPassword` are configuration parameters used in `http_proxy` plugin.\n\n### Server Manage Plugins\n\nRead the [document](/doc/server_plugin.md).\n\nFind more plugins in [gofrp/plugin](https://github.com/gofrp/plugin).\n\n### SSH Tunnel Gateway\n\n*added in v0.53.0*\n\nfrp supports listening to an SSH port on the frps side and achieves TCP protocol proxying through the SSH -R protocol, without relying on frpc.\n\n```toml\n# frps.toml\nsshTunnelGateway.bindPort = 2200\n```\n\nWhen 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.\n\nExecuting the command\n\n```bash\nssh -R :80:127.0.0.1:8080 v0@{frp address} -p 2200 tcp --proxy_name \"test-tcp\" --remote_port 9090\n```\n\nsets up a proxy on frps that forwards the local 8080 service to the port 9090.\n\n```bash\nfrp (via SSH) (Ctrl+C to quit)\n\nUser:\nProxyName: test-tcp\nType: tcp\nRemoteAddress: :9090\n```\n\nThis is equivalent to:\n\n```bash\nfrpc tcp --proxy_name \"test-tcp\" --local_ip 127.0.0.1 --local_port 8080 --remote_port 9090\n```\n\nPlease refer to this [document](/doc/ssh_tunnel_gateway.md) for more information.\n\n### Virtual Network (VirtualNet)\n\n*Alpha feature added in v0.62.0*\n\nThe 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.\n\nFor detailed information about configuration and usage, please refer to the [VirtualNet documentation](/doc/virtual_net.md).\n\n## Feature Gates\n\nfrp supports feature gates to enable or disable experimental features. This allows users to try out new features before they're considered stable.\n\n### Available Feature Gates\n\n| Name | Stage | Default | Description |\n|------|-------|---------|-------------|\n| VirtualNet | ALPHA | false | Virtual network capabilities for frp |\n\n### Enabling Feature Gates\n\nTo enable an experimental feature, add the feature gate to your configuration:\n\n```toml\nfeatureGates = { VirtualNet = true }\n```\n\n### Feature Lifecycle\n\nFeatures typically go through three stages:\n1. **ALPHA**: Disabled by default, may be unstable\n2. **BETA**: May be enabled by default, more stable but still evolving\n3. **GA (Generally Available)**: Enabled by default, ready for production use\n\n## Related Projects\n\n* [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.\n* [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.\n\n## Contributing\n\nInterested in getting involved? We would like to help you!\n\n* Take a look at our [issues list](https://github.com/fatedier/frp/issues) and consider sending a Pull Request to **dev branch**.\n* 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.\n* Sorry for my poor English. Improvements for this document are welcome, even some typo fixes.\n* If you have great ideas, send an email to fatedier@gmail.com.\n\n**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.**\n\n## Donation\n\nIf frp helps you a lot, you can support us by:\n\n### GitHub Sponsors\n\nSupport us by [Github Sponsors](https://github.com/sponsors/fatedier).\n\nYou can have your company's logo placed on README file of this project.\n\n### PayPal\n\nDonate money by [PayPal](https://www.paypal.me/fatedier) to my account **fatedier@gmail.com**.\n"
  },
  {
    "path": "README_zh.md",
    "content": "# frp\n\n[![Build Status](https://circleci.com/gh/fatedier/frp.svg?style=shield)](https://circleci.com/gh/fatedier/frp)\n[![GitHub release](https://img.shields.io/github/tag/fatedier/frp.svg?label=release)](https://github.com/fatedier/frp/releases)\n[![Go Report Card](https://goreportcard.com/badge/github.com/fatedier/frp)](https://goreportcard.com/report/github.com/fatedier/frp)\n[![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)\n\n[README](README.md) | [中文文档](README_zh.md)\n\nfrp 是一个专注于内网穿透的高性能的反向代理应用，支持 TCP、UDP、HTTP、HTTPS 等多种协议，且支持 P2P 通信。可以将内网服务以安全、便捷的方式通过具有公网 IP 节点的中转暴露到公网。\n\n## Sponsors\n\nfrp 是一个完全开源的项目，我们的开发工作完全依靠赞助者们的支持。如果你愿意加入他们的行列，请考虑 [赞助 frp 的开发](https://github.com/sponsors/fatedier)。\n\n<h3 align=\"center\">Gold Sponsors</h3>\n<!--gold sponsors start-->\n<div align=\"center\">\n\n## Recall.ai - API for meeting recordings\n\nIf 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),\n\nan API that records Zoom, Google Meet, Microsoft Teams, in-person meetings, and more.\n\n</div>\n\n<p align=\"center\">\n  <a href=\"https://requestly.com/?utm_source=github&utm_medium=partnered&utm_campaign=frp\" target=\"_blank\">\n    <img width=\"480px\" src=\"https://github.com/user-attachments/assets/24670320-997d-4d62-9bca-955c59fe883d\">\n    <br>\n    <b>Requestly - Free & Open-Source alternative to Postman</b>\n    <br>\n    <sub>All-in-one platform to Test, Mock and Intercept APIs.</sub>\n  </a>\n</p>\n\n<p align=\"center\">\n  <a href=\"https://jb.gg/frp\" target=\"_blank\">\n    <img width=\"420px\" src=\"https://raw.githubusercontent.com/fatedier/frp/dev/doc/pic/sponsor_jetbrains.jpg\">\n\t<br>\n\t<b>The complete IDE crafted for professional Go developers</b>\n  </a>\n</p>\n\n<p align=\"center\">\n  <a href=\"https://github.com/beclab/Olares\" target=\"_blank\">\n    <img width=\"420px\" src=\"https://raw.githubusercontent.com/fatedier/frp/dev/doc/pic/sponsor_olares.jpeg\">\n\t<br>\n\t<b>The sovereign cloud that puts you in control</b>\n\t<br>\n\t<sub>An open source, self-hosted alternative to public clouds, built for data ownership and privacy</sub>\n  </a>\n</p>\n<!--gold sponsors end-->\n\n## 为什么使用 frp ？\n\n通过在具有公网 IP 的节点上部署 frp 服务端，可以轻松地将内网服务穿透到公网，同时提供诸多专业的功能特性，这包括：\n\n* 客户端服务端通信支持 TCP、QUIC、KCP 以及 Websocket 等多种协议。\n* 采用 TCP 连接流式复用，在单个连接间承载更多请求，节省连接建立时间，降低请求延迟。\n* 代理组间的负载均衡。\n* 端口复用，多个服务通过同一个服务端端口暴露。\n* 支持 P2P 通信，流量不经过服务器中转，充分利用带宽资源。\n* 多个原生支持的客户端插件（静态文件查看，HTTPS/HTTP 协议转换，HTTP、SOCK5 代理等），便于独立使用 frp 客户端完成某些工作。\n* 高度扩展性的服务端插件系统，易于结合自身需求进行功能扩展。\n* 服务端和客户端 UI 页面。\n\n## 开发状态\n\nfrp 目前已被很多公司广泛用于测试、生产环境。\n\nmaster 分支用于发布稳定版本，dev 分支用于开发，您可以尝试下载最新的 release 版本进行测试。\n\n我们正在进行 v2 大版本的开发，将会尝试在各个方面进行重构和升级，且不会与 v1 版本进行兼容，预计会持续较长的一段时间。\n\n现在的 v0 版本将会在合适的时间切换为 v1 版本并且保证兼容性，后续只做 bug 修复和优化，不再进行大的功能性更新。\n\n### 关于 v2 的一些说明\n\nv2 版本的复杂度和难度比我们预期的要高得多。我只能利用零散的时间进行开发，而且由于上下文经常被打断，效率极低。由于这种情况可能会持续一段时间，我们仍然会在当前版本上进行一些优化和迭代，直到我们有更多空闲时间来推进大版本的重构，或者也有可能放弃一次性的重构，而是采用渐进的方式在当前版本上逐步做一些可能会导致不兼容的修改。\n\nv2 的构想是基于我多年在云原生领域，特别是在 K8s 和 ServiceMesh 方面的工作经验和思考。它的核心是一个现代化的四层和七层代理，类似于 envoy。这个代理本身高度可扩展，不仅可以用于实现内网穿透的功能，还可以应用于更多领域。在这个高度可扩展的内核基础上，我们将实现 frp v1 中的所有功能，并且能够以一种更加优雅的方式实现原先架构中无法实现或不易实现的功能。同时，我们将保持高效的开发和迭代能力。\n\n除此之外，我希望 frp 本身也成为一个高度可扩展的系统和平台，就像我们可以基于 K8s 提供一系列扩展能力一样。在 K8s 上，我们可以根据企业需求进行定制化开发，例如使用 CRD、controller 模式、webhook、CSI 和 CNI 等。在 frp v1 中，我们引入了服务端插件的概念，实现了一些简单的扩展性。但是，它实际上依赖于简单的 HTTP 协议，并且需要用户自己启动独立的进程和管理。这种方式远远不够灵活和方便，而且现实世界的需求千差万别，我们不能期望一个由少数人维护的非营利性开源项目能够满足所有人的需求。\n\n最后，我们意识到像配置管理、权限验证、证书管理和管理 API 等模块的当前设计并不够现代化。尽管我们可能在 v1 版本中进行一些优化，但确保兼容性是一个令人头疼的问题，需要投入大量精力来解决。\n\n非常感谢您对 frp 的支持。\n\n## 文档\n\n完整文档已经迁移至 [https://gofrp.org](https://gofrp.org)。\n\n## 为 frp 做贡献\n\nfrp 是一个免费且开源的项目，我们欢迎任何人为其开发和进步贡献力量。\n\n* 在使用过程中出现任何问题，可以通过 [issues](https://github.com/fatedier/frp/issues) 来反馈。\n* Bug 的修复可以直接提交 Pull Request 到 dev 分支。\n* 如果是增加新的功能特性，请先创建一个 issue 并做简单描述以及大致的实现方法，提议被采纳后，就可以创建一个实现新特性的 Pull Request。\n* 欢迎对说明文档做出改善，帮助更多的人使用 frp，特别是英文文档。\n* 贡献代码请提交 PR 至 dev 分支，master 分支仅用于发布稳定可用版本。\n* 如果你有任何其他方面的问题或合作，欢迎发送邮件至 fatedier@gmail.com 。\n\n**提醒：和项目相关的问题请在 [issues](https://github.com/fatedier/frp/issues) 中反馈，这样方便其他有类似问题的人可以快速查找解决方法，并且也避免了我们重复回答一些问题。**\n\n## 关联项目\n\n* [gofrp/plugin](https://github.com/gofrp/plugin) - frp 插件仓库，收录了基于 frp 扩展机制实现的各种插件，满足各种场景下的定制化需求。\n* [gofrp/tiny-frpc](https://github.com/gofrp/tiny-frpc) - 基于 ssh 协议实现的 frp 客户端的精简版本(最低约 3.5MB 左右)，支持常用的部分功能，适用于资源有限的设备。\n\n## 赞助\n\n如果您觉得 frp 对你有帮助，欢迎给予我们一定的捐助来维持项目的长期发展。\n\n### Sponsors\n\n长期赞助可以帮助我们保持项目的持续发展。\n\n您可以通过 [GitHub Sponsors](https://github.com/sponsors/fatedier) 赞助我们。\n\n国内用户可以通过 [爱发电](https://afdian.com/a/fatedier) 赞助我们。\n\n企业赞助者可以将贵公司的 Logo 以及链接放置在项目 README 文件中。\n"
  },
  {
    "path": "Release.md",
    "content": "## Features\n\n* 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.\n\n## Improvements\n\n* Kept proxy/visitor names as raw config names during completion; moved user-prefix handling to explicit wire-level naming logic.\n* 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.\n* 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.\n* 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`.\n"
  },
  {
    "path": "assets/assets.go",
    "content": "// Copyright 2016 fatedier, fatedier@gmail.com\n//\n// Licensed under the Apache License, Version 2.0 (the \"License\");\n// you may not use this file except in compliance with the License.\n// You may obtain a copy of the License at\n//\n//     http://www.apache.org/licenses/LICENSE-2.0\n//\n// Unless required by applicable law or agreed to in writing, software\n// distributed under the License is distributed on an \"AS IS\" BASIS,\n// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.\n// See the License for the specific language governing permissions and\n// limitations under the License.\n\npackage assets\n\nimport (\n\t\"io/fs\"\n\t\"net/http\"\n)\n\nvar (\n\t// read-only filesystem created by \"embed\" for embedded files\n\tcontent fs.FS\n\n\tFileSystem http.FileSystem\n\n\t// if prefix is not empty, we get file content from disk\n\tprefixPath string\n)\n\ntype emptyFS struct{}\n\nfunc (emptyFS) Open(name string) (http.File, error) {\n\treturn nil, &fs.PathError{Op: \"open\", Path: name, Err: fs.ErrNotExist}\n}\n\n// if path is empty, load assets in memory\n// or set FileSystem using disk files\nfunc Load(path string) {\n\tprefixPath = path\n\tswitch {\n\tcase prefixPath != \"\":\n\t\tFileSystem = http.Dir(prefixPath)\n\tcase content != nil:\n\t\tFileSystem = http.FS(content)\n\tdefault:\n\t\tFileSystem = emptyFS{}\n\t}\n}\n\nfunc Register(fileSystem fs.FS) {\n\tsubFs, err := fs.Sub(fileSystem, \"dist\")\n\tif err == nil {\n\t\tcontent = subFs\n\t}\n}\n"
  },
  {
    "path": "client/api_router.go",
    "content": "// Copyright 2017 fatedier, fatedier@gmail.com\n//\n// Licensed under the Apache License, Version 2.0 (the \"License\");\n// you may not use this file except in compliance with the License.\n// You may obtain a copy of the License at\n//\n//     http://www.apache.org/licenses/LICENSE-2.0\n//\n// Unless required by applicable law or agreed to in writing, software\n// distributed under the License is distributed on an \"AS IS\" BASIS,\n// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.\n// See the License for the specific language governing permissions and\n// limitations under the License.\n\npackage client\n\nimport (\n\t\"net/http\"\n\n\tadminapi \"github.com/fatedier/frp/client/http\"\n\t\"github.com/fatedier/frp/client/proxy\"\n\thttppkg \"github.com/fatedier/frp/pkg/util/http\"\n\tnetpkg \"github.com/fatedier/frp/pkg/util/net\"\n)\n\nfunc (svr *Service) registerRouteHandlers(helper *httppkg.RouterRegisterHelper) {\n\tapiController := newAPIController(svr)\n\n\t// Healthz endpoint without auth\n\thelper.Router.HandleFunc(\"/healthz\", healthz)\n\n\t// API routes and static files with auth\n\tsubRouter := helper.Router.NewRoute().Subrouter()\n\tsubRouter.Use(helper.AuthMiddleware)\n\tsubRouter.Use(httppkg.NewRequestLogger)\n\tsubRouter.HandleFunc(\"/api/reload\", httppkg.MakeHTTPHandlerFunc(apiController.Reload)).Methods(http.MethodGet)\n\tsubRouter.HandleFunc(\"/api/stop\", httppkg.MakeHTTPHandlerFunc(apiController.Stop)).Methods(http.MethodPost)\n\tsubRouter.HandleFunc(\"/api/status\", httppkg.MakeHTTPHandlerFunc(apiController.Status)).Methods(http.MethodGet)\n\tsubRouter.HandleFunc(\"/api/config\", httppkg.MakeHTTPHandlerFunc(apiController.GetConfig)).Methods(http.MethodGet)\n\tsubRouter.HandleFunc(\"/api/config\", httppkg.MakeHTTPHandlerFunc(apiController.PutConfig)).Methods(http.MethodPut)\n\tsubRouter.HandleFunc(\"/api/proxy/{name}/config\", httppkg.MakeHTTPHandlerFunc(apiController.GetProxyConfig)).Methods(http.MethodGet)\n\tsubRouter.HandleFunc(\"/api/visitor/{name}/config\", httppkg.MakeHTTPHandlerFunc(apiController.GetVisitorConfig)).Methods(http.MethodGet)\n\n\tif svr.storeSource != nil {\n\t\tsubRouter.HandleFunc(\"/api/store/proxies\", httppkg.MakeHTTPHandlerFunc(apiController.ListStoreProxies)).Methods(http.MethodGet)\n\t\tsubRouter.HandleFunc(\"/api/store/proxies\", httppkg.MakeHTTPHandlerFunc(apiController.CreateStoreProxy)).Methods(http.MethodPost)\n\t\tsubRouter.HandleFunc(\"/api/store/proxies/{name}\", httppkg.MakeHTTPHandlerFunc(apiController.GetStoreProxy)).Methods(http.MethodGet)\n\t\tsubRouter.HandleFunc(\"/api/store/proxies/{name}\", httppkg.MakeHTTPHandlerFunc(apiController.UpdateStoreProxy)).Methods(http.MethodPut)\n\t\tsubRouter.HandleFunc(\"/api/store/proxies/{name}\", httppkg.MakeHTTPHandlerFunc(apiController.DeleteStoreProxy)).Methods(http.MethodDelete)\n\t\tsubRouter.HandleFunc(\"/api/store/visitors\", httppkg.MakeHTTPHandlerFunc(apiController.ListStoreVisitors)).Methods(http.MethodGet)\n\t\tsubRouter.HandleFunc(\"/api/store/visitors\", httppkg.MakeHTTPHandlerFunc(apiController.CreateStoreVisitor)).Methods(http.MethodPost)\n\t\tsubRouter.HandleFunc(\"/api/store/visitors/{name}\", httppkg.MakeHTTPHandlerFunc(apiController.GetStoreVisitor)).Methods(http.MethodGet)\n\t\tsubRouter.HandleFunc(\"/api/store/visitors/{name}\", httppkg.MakeHTTPHandlerFunc(apiController.UpdateStoreVisitor)).Methods(http.MethodPut)\n\t\tsubRouter.HandleFunc(\"/api/store/visitors/{name}\", httppkg.MakeHTTPHandlerFunc(apiController.DeleteStoreVisitor)).Methods(http.MethodDelete)\n\t}\n\n\tsubRouter.Handle(\"/favicon.ico\", http.FileServer(helper.AssetsFS)).Methods(\"GET\")\n\tsubRouter.PathPrefix(\"/static/\").Handler(\n\t\tnetpkg.MakeHTTPGzipHandler(http.StripPrefix(\"/static/\", http.FileServer(helper.AssetsFS))),\n\t).Methods(\"GET\")\n\tsubRouter.HandleFunc(\"/\", func(w http.ResponseWriter, r *http.Request) {\n\t\thttp.Redirect(w, r, \"/static/\", http.StatusMovedPermanently)\n\t})\n}\n\nfunc healthz(w http.ResponseWriter, _ *http.Request) {\n\tw.WriteHeader(http.StatusOK)\n}\n\nfunc newAPIController(svr *Service) *adminapi.Controller {\n\tmanager := newServiceConfigManager(svr)\n\treturn adminapi.NewController(adminapi.ControllerParams{\n\t\tServerAddr: svr.common.ServerAddr,\n\t\tManager:    manager,\n\t})\n}\n\n// getAllProxyStatus returns all proxy statuses.\nfunc (svr *Service) getAllProxyStatus() []*proxy.WorkingStatus {\n\tsvr.ctlMu.RLock()\n\tctl := svr.ctl\n\tsvr.ctlMu.RUnlock()\n\tif ctl == nil {\n\t\treturn nil\n\t}\n\treturn ctl.pm.GetAllProxyStatus()\n}\n"
  },
  {
    "path": "client/config_manager.go",
    "content": "package client\n\nimport (\n\t\"errors\"\n\t\"fmt\"\n\t\"os\"\n\t\"time\"\n\n\t\"github.com/fatedier/frp/client/configmgmt\"\n\t\"github.com/fatedier/frp/client/proxy\"\n\t\"github.com/fatedier/frp/pkg/config\"\n\t\"github.com/fatedier/frp/pkg/config/source\"\n\tv1 \"github.com/fatedier/frp/pkg/config/v1\"\n\t\"github.com/fatedier/frp/pkg/config/v1/validation\"\n\t\"github.com/fatedier/frp/pkg/util/log\"\n)\n\ntype serviceConfigManager struct {\n\tsvr *Service\n}\n\nfunc newServiceConfigManager(svr *Service) configmgmt.ConfigManager {\n\treturn &serviceConfigManager{svr: svr}\n}\n\nfunc (m *serviceConfigManager) ReloadFromFile(strict bool) error {\n\tif m.svr.configFilePath == \"\" {\n\t\treturn fmt.Errorf(\"%w: frpc has no config file path\", configmgmt.ErrInvalidArgument)\n\t}\n\n\tresult, err := config.LoadClientConfigResult(m.svr.configFilePath, strict)\n\tif err != nil {\n\t\treturn fmt.Errorf(\"%w: %v\", configmgmt.ErrInvalidArgument, err)\n\t}\n\n\tproxyCfgsForValidation, visitorCfgsForValidation := config.FilterClientConfigurers(\n\t\tresult.Common,\n\t\tresult.Proxies,\n\t\tresult.Visitors,\n\t)\n\tproxyCfgsForValidation = config.CompleteProxyConfigurers(proxyCfgsForValidation)\n\tvisitorCfgsForValidation = config.CompleteVisitorConfigurers(visitorCfgsForValidation)\n\n\tif _, err := validation.ValidateAllClientConfig(result.Common, proxyCfgsForValidation, visitorCfgsForValidation, m.svr.unsafeFeatures); err != nil {\n\t\treturn fmt.Errorf(\"%w: %v\", configmgmt.ErrInvalidArgument, err)\n\t}\n\n\tif err := m.svr.UpdateConfigSource(result.Common, result.Proxies, result.Visitors); err != nil {\n\t\treturn fmt.Errorf(\"%w: %v\", configmgmt.ErrApplyConfig, err)\n\t}\n\n\tlog.Infof(\"success reload conf\")\n\treturn nil\n}\n\nfunc (m *serviceConfigManager) ReadConfigFile() (string, error) {\n\tif m.svr.configFilePath == \"\" {\n\t\treturn \"\", fmt.Errorf(\"%w: frpc has no config file path\", configmgmt.ErrInvalidArgument)\n\t}\n\n\tcontent, err := os.ReadFile(m.svr.configFilePath)\n\tif err != nil {\n\t\treturn \"\", fmt.Errorf(\"%w: %v\", configmgmt.ErrInvalidArgument, err)\n\t}\n\treturn string(content), nil\n}\n\nfunc (m *serviceConfigManager) WriteConfigFile(content []byte) error {\n\tif len(content) == 0 {\n\t\treturn fmt.Errorf(\"%w: body can't be empty\", configmgmt.ErrInvalidArgument)\n\t}\n\n\tif err := os.WriteFile(m.svr.configFilePath, content, 0o600); err != nil {\n\t\treturn err\n\t}\n\treturn nil\n}\n\nfunc (m *serviceConfigManager) GetProxyStatus() []*proxy.WorkingStatus {\n\treturn m.svr.getAllProxyStatus()\n}\n\nfunc (m *serviceConfigManager) GetProxyConfig(name string) (v1.ProxyConfigurer, bool) {\n\t// Try running proxy manager first\n\tws, ok := m.svr.getProxyStatus(name)\n\tif ok {\n\t\treturn ws.Cfg, true\n\t}\n\n\t// Fallback to store\n\tm.svr.reloadMu.Lock()\n\tstoreSource := m.svr.storeSource\n\tm.svr.reloadMu.Unlock()\n\n\tif storeSource != nil {\n\t\tcfg := storeSource.GetProxy(name)\n\t\tif cfg != nil {\n\t\t\treturn cfg, true\n\t\t}\n\t}\n\treturn nil, false\n}\n\nfunc (m *serviceConfigManager) GetVisitorConfig(name string) (v1.VisitorConfigurer, bool) {\n\t// Try running visitor manager first\n\tcfg, ok := m.svr.getVisitorCfg(name)\n\tif ok {\n\t\treturn cfg, true\n\t}\n\n\t// Fallback to store\n\tm.svr.reloadMu.Lock()\n\tstoreSource := m.svr.storeSource\n\tm.svr.reloadMu.Unlock()\n\n\tif storeSource != nil {\n\t\tvcfg := storeSource.GetVisitor(name)\n\t\tif vcfg != nil {\n\t\t\treturn vcfg, true\n\t\t}\n\t}\n\treturn nil, false\n}\n\nfunc (m *serviceConfigManager) IsStoreProxyEnabled(name string) bool {\n\tif name == \"\" {\n\t\treturn false\n\t}\n\n\tm.svr.reloadMu.Lock()\n\tstoreSource := m.svr.storeSource\n\tm.svr.reloadMu.Unlock()\n\n\tif storeSource == nil {\n\t\treturn false\n\t}\n\n\tcfg := storeSource.GetProxy(name)\n\tif cfg == nil {\n\t\treturn false\n\t}\n\tenabled := cfg.GetBaseConfig().Enabled\n\treturn enabled == nil || *enabled\n}\n\nfunc (m *serviceConfigManager) StoreEnabled() bool {\n\tm.svr.reloadMu.Lock()\n\tstoreSource := m.svr.storeSource\n\tm.svr.reloadMu.Unlock()\n\treturn storeSource != nil\n}\n\nfunc (m *serviceConfigManager) ListStoreProxies() ([]v1.ProxyConfigurer, error) {\n\tstoreSource, err := m.storeSourceOrError()\n\tif err != nil {\n\t\treturn nil, err\n\t}\n\treturn storeSource.GetAllProxies()\n}\n\nfunc (m *serviceConfigManager) GetStoreProxy(name string) (v1.ProxyConfigurer, error) {\n\tif name == \"\" {\n\t\treturn nil, fmt.Errorf(\"%w: proxy name is required\", configmgmt.ErrInvalidArgument)\n\t}\n\n\tstoreSource, err := m.storeSourceOrError()\n\tif err != nil {\n\t\treturn nil, err\n\t}\n\n\tcfg := storeSource.GetProxy(name)\n\tif cfg == nil {\n\t\treturn nil, fmt.Errorf(\"%w: proxy %q\", configmgmt.ErrNotFound, name)\n\t}\n\treturn cfg, nil\n}\n\nfunc (m *serviceConfigManager) CreateStoreProxy(cfg v1.ProxyConfigurer) (v1.ProxyConfigurer, error) {\n\tif err := m.validateStoreProxyConfigurer(cfg); err != nil {\n\t\treturn nil, fmt.Errorf(\"%w: validation error: %v\", configmgmt.ErrInvalidArgument, err)\n\t}\n\n\tname := cfg.GetBaseConfig().Name\n\tpersisted, err := m.withStoreProxyMutationAndReload(name, func(storeSource *source.StoreSource) error {\n\t\tif err := storeSource.AddProxy(cfg); err != nil {\n\t\t\tif errors.Is(err, source.ErrAlreadyExists) {\n\t\t\t\treturn fmt.Errorf(\"%w: %v\", configmgmt.ErrConflict, err)\n\t\t\t}\n\t\t\treturn err\n\t\t}\n\t\treturn nil\n\t})\n\tif err != nil {\n\t\treturn nil, err\n\t}\n\tlog.Infof(\"store: created proxy %q\", name)\n\treturn persisted, nil\n}\n\nfunc (m *serviceConfigManager) UpdateStoreProxy(name string, cfg v1.ProxyConfigurer) (v1.ProxyConfigurer, error) {\n\tif name == \"\" {\n\t\treturn nil, fmt.Errorf(\"%w: proxy name is required\", configmgmt.ErrInvalidArgument)\n\t}\n\tif cfg == nil {\n\t\treturn nil, fmt.Errorf(\"%w: invalid proxy config: type is required\", configmgmt.ErrInvalidArgument)\n\t}\n\tbodyName := cfg.GetBaseConfig().Name\n\tif bodyName != name {\n\t\treturn nil, fmt.Errorf(\"%w: proxy name in URL must match name in body\", configmgmt.ErrInvalidArgument)\n\t}\n\tif err := m.validateStoreProxyConfigurer(cfg); err != nil {\n\t\treturn nil, fmt.Errorf(\"%w: validation error: %v\", configmgmt.ErrInvalidArgument, err)\n\t}\n\n\tpersisted, err := m.withStoreProxyMutationAndReload(name, func(storeSource *source.StoreSource) error {\n\t\tif err := storeSource.UpdateProxy(cfg); err != nil {\n\t\t\tif errors.Is(err, source.ErrNotFound) {\n\t\t\t\treturn fmt.Errorf(\"%w: %v\", configmgmt.ErrNotFound, err)\n\t\t\t}\n\t\t\treturn err\n\t\t}\n\t\treturn nil\n\t})\n\tif err != nil {\n\t\treturn nil, err\n\t}\n\n\tlog.Infof(\"store: updated proxy %q\", name)\n\treturn persisted, nil\n}\n\nfunc (m *serviceConfigManager) DeleteStoreProxy(name string) error {\n\tif name == \"\" {\n\t\treturn fmt.Errorf(\"%w: proxy name is required\", configmgmt.ErrInvalidArgument)\n\t}\n\n\tif err := m.withStoreMutationAndReload(func(storeSource *source.StoreSource) error {\n\t\tif err := storeSource.RemoveProxy(name); err != nil {\n\t\t\tif errors.Is(err, source.ErrNotFound) {\n\t\t\t\treturn fmt.Errorf(\"%w: %v\", configmgmt.ErrNotFound, err)\n\t\t\t}\n\t\t\treturn err\n\t\t}\n\t\treturn nil\n\t}); err != nil {\n\t\treturn err\n\t}\n\n\tlog.Infof(\"store: deleted proxy %q\", name)\n\treturn nil\n}\n\nfunc (m *serviceConfigManager) ListStoreVisitors() ([]v1.VisitorConfigurer, error) {\n\tstoreSource, err := m.storeSourceOrError()\n\tif err != nil {\n\t\treturn nil, err\n\t}\n\treturn storeSource.GetAllVisitors()\n}\n\nfunc (m *serviceConfigManager) GetStoreVisitor(name string) (v1.VisitorConfigurer, error) {\n\tif name == \"\" {\n\t\treturn nil, fmt.Errorf(\"%w: visitor name is required\", configmgmt.ErrInvalidArgument)\n\t}\n\n\tstoreSource, err := m.storeSourceOrError()\n\tif err != nil {\n\t\treturn nil, err\n\t}\n\n\tcfg := storeSource.GetVisitor(name)\n\tif cfg == nil {\n\t\treturn nil, fmt.Errorf(\"%w: visitor %q\", configmgmt.ErrNotFound, name)\n\t}\n\treturn cfg, nil\n}\n\nfunc (m *serviceConfigManager) CreateStoreVisitor(cfg v1.VisitorConfigurer) (v1.VisitorConfigurer, error) {\n\tif err := m.validateStoreVisitorConfigurer(cfg); err != nil {\n\t\treturn nil, fmt.Errorf(\"%w: validation error: %v\", configmgmt.ErrInvalidArgument, err)\n\t}\n\n\tname := cfg.GetBaseConfig().Name\n\tpersisted, err := m.withStoreVisitorMutationAndReload(name, func(storeSource *source.StoreSource) error {\n\t\tif err := storeSource.AddVisitor(cfg); err != nil {\n\t\t\tif errors.Is(err, source.ErrAlreadyExists) {\n\t\t\t\treturn fmt.Errorf(\"%w: %v\", configmgmt.ErrConflict, err)\n\t\t\t}\n\t\t\treturn err\n\t\t}\n\t\treturn nil\n\t})\n\tif err != nil {\n\t\treturn nil, err\n\t}\n\n\tlog.Infof(\"store: created visitor %q\", name)\n\treturn persisted, nil\n}\n\nfunc (m *serviceConfigManager) UpdateStoreVisitor(name string, cfg v1.VisitorConfigurer) (v1.VisitorConfigurer, error) {\n\tif name == \"\" {\n\t\treturn nil, fmt.Errorf(\"%w: visitor name is required\", configmgmt.ErrInvalidArgument)\n\t}\n\tif cfg == nil {\n\t\treturn nil, fmt.Errorf(\"%w: invalid visitor config: type is required\", configmgmt.ErrInvalidArgument)\n\t}\n\tbodyName := cfg.GetBaseConfig().Name\n\tif bodyName != name {\n\t\treturn nil, fmt.Errorf(\"%w: visitor name in URL must match name in body\", configmgmt.ErrInvalidArgument)\n\t}\n\tif err := m.validateStoreVisitorConfigurer(cfg); err != nil {\n\t\treturn nil, fmt.Errorf(\"%w: validation error: %v\", configmgmt.ErrInvalidArgument, err)\n\t}\n\n\tpersisted, err := m.withStoreVisitorMutationAndReload(name, func(storeSource *source.StoreSource) error {\n\t\tif err := storeSource.UpdateVisitor(cfg); err != nil {\n\t\t\tif errors.Is(err, source.ErrNotFound) {\n\t\t\t\treturn fmt.Errorf(\"%w: %v\", configmgmt.ErrNotFound, err)\n\t\t\t}\n\t\t\treturn err\n\t\t}\n\t\treturn nil\n\t})\n\tif err != nil {\n\t\treturn nil, err\n\t}\n\n\tlog.Infof(\"store: updated visitor %q\", name)\n\treturn persisted, nil\n}\n\nfunc (m *serviceConfigManager) DeleteStoreVisitor(name string) error {\n\tif name == \"\" {\n\t\treturn fmt.Errorf(\"%w: visitor name is required\", configmgmt.ErrInvalidArgument)\n\t}\n\n\tif err := m.withStoreMutationAndReload(func(storeSource *source.StoreSource) error {\n\t\tif err := storeSource.RemoveVisitor(name); err != nil {\n\t\t\tif errors.Is(err, source.ErrNotFound) {\n\t\t\t\treturn fmt.Errorf(\"%w: %v\", configmgmt.ErrNotFound, err)\n\t\t\t}\n\t\t\treturn err\n\t\t}\n\t\treturn nil\n\t}); err != nil {\n\t\treturn err\n\t}\n\n\tlog.Infof(\"store: deleted visitor %q\", name)\n\treturn nil\n}\n\nfunc (m *serviceConfigManager) GracefulClose(d time.Duration) {\n\tm.svr.GracefulClose(d)\n}\n\nfunc (m *serviceConfigManager) storeSourceOrError() (*source.StoreSource, error) {\n\tm.svr.reloadMu.Lock()\n\tstoreSource := m.svr.storeSource\n\tm.svr.reloadMu.Unlock()\n\n\tif storeSource == nil {\n\t\treturn nil, fmt.Errorf(\"%w: store API is disabled\", configmgmt.ErrStoreDisabled)\n\t}\n\treturn storeSource, nil\n}\n\nfunc (m *serviceConfigManager) withStoreMutationAndReload(\n\tfn func(storeSource *source.StoreSource) error,\n) error {\n\tm.svr.reloadMu.Lock()\n\tdefer m.svr.reloadMu.Unlock()\n\n\tstoreSource := m.svr.storeSource\n\tif storeSource == nil {\n\t\treturn fmt.Errorf(\"%w: store API is disabled\", configmgmt.ErrStoreDisabled)\n\t}\n\n\tif err := fn(storeSource); err != nil {\n\t\treturn err\n\t}\n\n\tif err := m.svr.reloadConfigFromSourcesLocked(); err != nil {\n\t\treturn fmt.Errorf(\"%w: failed to apply config: %v\", configmgmt.ErrApplyConfig, err)\n\t}\n\treturn nil\n}\n\nfunc (m *serviceConfigManager) withStoreProxyMutationAndReload(\n\tname string,\n\tfn func(storeSource *source.StoreSource) error,\n) (v1.ProxyConfigurer, error) {\n\tm.svr.reloadMu.Lock()\n\tdefer m.svr.reloadMu.Unlock()\n\n\tstoreSource := m.svr.storeSource\n\tif storeSource == nil {\n\t\treturn nil, fmt.Errorf(\"%w: store API is disabled\", configmgmt.ErrStoreDisabled)\n\t}\n\n\tif err := fn(storeSource); err != nil {\n\t\treturn nil, err\n\t}\n\tif err := m.svr.reloadConfigFromSourcesLocked(); err != nil {\n\t\treturn nil, fmt.Errorf(\"%w: failed to apply config: %v\", configmgmt.ErrApplyConfig, err)\n\t}\n\n\tpersisted := storeSource.GetProxy(name)\n\tif persisted == nil {\n\t\treturn nil, fmt.Errorf(\"%w: proxy %q not found in store after mutation\", configmgmt.ErrApplyConfig, name)\n\t}\n\treturn persisted.Clone(), nil\n}\n\nfunc (m *serviceConfigManager) withStoreVisitorMutationAndReload(\n\tname string,\n\tfn func(storeSource *source.StoreSource) error,\n) (v1.VisitorConfigurer, error) {\n\tm.svr.reloadMu.Lock()\n\tdefer m.svr.reloadMu.Unlock()\n\n\tstoreSource := m.svr.storeSource\n\tif storeSource == nil {\n\t\treturn nil, fmt.Errorf(\"%w: store API is disabled\", configmgmt.ErrStoreDisabled)\n\t}\n\n\tif err := fn(storeSource); err != nil {\n\t\treturn nil, err\n\t}\n\tif err := m.svr.reloadConfigFromSourcesLocked(); err != nil {\n\t\treturn nil, fmt.Errorf(\"%w: failed to apply config: %v\", configmgmt.ErrApplyConfig, err)\n\t}\n\n\tpersisted := storeSource.GetVisitor(name)\n\tif persisted == nil {\n\t\treturn nil, fmt.Errorf(\"%w: visitor %q not found in store after mutation\", configmgmt.ErrApplyConfig, name)\n\t}\n\treturn persisted.Clone(), nil\n}\n\nfunc (m *serviceConfigManager) validateStoreProxyConfigurer(cfg v1.ProxyConfigurer) error {\n\tif cfg == nil {\n\t\treturn fmt.Errorf(\"invalid proxy config\")\n\t}\n\truntimeCfg := cfg.Clone()\n\tif runtimeCfg == nil {\n\t\treturn fmt.Errorf(\"invalid proxy config\")\n\t}\n\truntimeCfg.Complete()\n\treturn validation.ValidateProxyConfigurerForClient(runtimeCfg)\n}\n\nfunc (m *serviceConfigManager) validateStoreVisitorConfigurer(cfg v1.VisitorConfigurer) error {\n\tif cfg == nil {\n\t\treturn fmt.Errorf(\"invalid visitor config\")\n\t}\n\truntimeCfg := cfg.Clone()\n\tif runtimeCfg == nil {\n\t\treturn fmt.Errorf(\"invalid visitor config\")\n\t}\n\truntimeCfg.Complete()\n\treturn validation.ValidateVisitorConfigurer(runtimeCfg)\n}\n"
  },
  {
    "path": "client/config_manager_test.go",
    "content": "package client\n\nimport (\n\t\"errors\"\n\t\"path/filepath\"\n\t\"testing\"\n\n\t\"github.com/fatedier/frp/client/configmgmt\"\n\t\"github.com/fatedier/frp/pkg/config/source\"\n\tv1 \"github.com/fatedier/frp/pkg/config/v1\"\n)\n\nfunc newTestRawTCPProxyConfig(name string) *v1.TCPProxyConfig {\n\treturn &v1.TCPProxyConfig{\n\t\tProxyBaseConfig: v1.ProxyBaseConfig{\n\t\t\tName: name,\n\t\t\tType: \"tcp\",\n\t\t\tProxyBackend: v1.ProxyBackend{\n\t\t\t\tLocalPort: 10080,\n\t\t\t},\n\t\t},\n\t}\n}\n\nfunc TestServiceConfigManagerCreateStoreProxyConflict(t *testing.T) {\n\tstoreSource, err := source.NewStoreSource(source.StoreSourceConfig{\n\t\tPath: filepath.Join(t.TempDir(), \"store.json\"),\n\t})\n\tif err != nil {\n\t\tt.Fatalf(\"new store source: %v\", err)\n\t}\n\tif err := storeSource.AddProxy(newTestRawTCPProxyConfig(\"p1\")); err != nil {\n\t\tt.Fatalf(\"seed proxy: %v\", err)\n\t}\n\n\tagg := source.NewAggregator(source.NewConfigSource())\n\tagg.SetStoreSource(storeSource)\n\n\tmgr := &serviceConfigManager{\n\t\tsvr: &Service{\n\t\t\taggregator:   agg,\n\t\t\tconfigSource: agg.ConfigSource(),\n\t\t\tstoreSource:  storeSource,\n\t\t\treloadCommon: &v1.ClientCommonConfig{},\n\t\t},\n\t}\n\n\t_, err = mgr.CreateStoreProxy(newTestRawTCPProxyConfig(\"p1\"))\n\tif err == nil {\n\t\tt.Fatal(\"expected conflict error\")\n\t}\n\tif !errors.Is(err, configmgmt.ErrConflict) {\n\t\tt.Fatalf(\"unexpected error: %v\", err)\n\t}\n}\n\nfunc TestServiceConfigManagerCreateStoreProxyKeepsStoreOnReloadFailure(t *testing.T) {\n\tstoreSource, err := source.NewStoreSource(source.StoreSourceConfig{\n\t\tPath: filepath.Join(t.TempDir(), \"store.json\"),\n\t})\n\tif err != nil {\n\t\tt.Fatalf(\"new store source: %v\", err)\n\t}\n\n\tmgr := &serviceConfigManager{\n\t\tsvr: &Service{\n\t\t\tstoreSource:  storeSource,\n\t\t\treloadCommon: &v1.ClientCommonConfig{},\n\t\t},\n\t}\n\n\t_, err = mgr.CreateStoreProxy(newTestRawTCPProxyConfig(\"p1\"))\n\tif err == nil {\n\t\tt.Fatal(\"expected apply config error\")\n\t}\n\tif !errors.Is(err, configmgmt.ErrApplyConfig) {\n\t\tt.Fatalf(\"unexpected error: %v\", err)\n\t}\n\tif storeSource.GetProxy(\"p1\") == nil {\n\t\tt.Fatal(\"proxy should remain in store after reload failure\")\n\t}\n}\n\nfunc TestServiceConfigManagerCreateStoreProxyStoreDisabled(t *testing.T) {\n\tmgr := &serviceConfigManager{\n\t\tsvr: &Service{\n\t\t\treloadCommon: &v1.ClientCommonConfig{},\n\t\t},\n\t}\n\n\t_, err := mgr.CreateStoreProxy(newTestRawTCPProxyConfig(\"p1\"))\n\tif err == nil {\n\t\tt.Fatal(\"expected store disabled error\")\n\t}\n\tif !errors.Is(err, configmgmt.ErrStoreDisabled) {\n\t\tt.Fatalf(\"unexpected error: %v\", err)\n\t}\n}\n\nfunc TestServiceConfigManagerCreateStoreProxyDoesNotPersistRuntimeDefaults(t *testing.T) {\n\tstoreSource, err := source.NewStoreSource(source.StoreSourceConfig{\n\t\tPath: filepath.Join(t.TempDir(), \"store.json\"),\n\t})\n\tif err != nil {\n\t\tt.Fatalf(\"new store source: %v\", err)\n\t}\n\tagg := source.NewAggregator(source.NewConfigSource())\n\tagg.SetStoreSource(storeSource)\n\n\tmgr := &serviceConfigManager{\n\t\tsvr: &Service{\n\t\t\taggregator:   agg,\n\t\t\tconfigSource: agg.ConfigSource(),\n\t\t\tstoreSource:  storeSource,\n\t\t\treloadCommon: &v1.ClientCommonConfig{},\n\t\t},\n\t}\n\n\tpersisted, err := mgr.CreateStoreProxy(newTestRawTCPProxyConfig(\"raw-proxy\"))\n\tif err != nil {\n\t\tt.Fatalf(\"create store proxy: %v\", err)\n\t}\n\tif persisted == nil {\n\t\tt.Fatal(\"expected persisted proxy to be returned\")\n\t}\n\n\tgot := storeSource.GetProxy(\"raw-proxy\")\n\tif got == nil {\n\t\tt.Fatal(\"proxy not found in store\")\n\t}\n\tif got.GetBaseConfig().LocalIP != \"\" {\n\t\tt.Fatalf(\"localIP was persisted with runtime default: %q\", got.GetBaseConfig().LocalIP)\n\t}\n\tif got.GetBaseConfig().Transport.BandwidthLimitMode != \"\" {\n\t\tt.Fatalf(\"bandwidthLimitMode was persisted with runtime default: %q\", got.GetBaseConfig().Transport.BandwidthLimitMode)\n\t}\n}\n"
  },
  {
    "path": "client/configmgmt/types.go",
    "content": "package configmgmt\n\nimport (\n\t\"errors\"\n\t\"time\"\n\n\t\"github.com/fatedier/frp/client/proxy\"\n\tv1 \"github.com/fatedier/frp/pkg/config/v1\"\n)\n\nvar (\n\tErrInvalidArgument = errors.New(\"invalid argument\")\n\tErrNotFound        = errors.New(\"not found\")\n\tErrConflict        = errors.New(\"conflict\")\n\tErrStoreDisabled   = errors.New(\"store disabled\")\n\tErrApplyConfig     = errors.New(\"apply config failed\")\n)\n\ntype ConfigManager interface {\n\tReloadFromFile(strict bool) error\n\n\tReadConfigFile() (string, error)\n\tWriteConfigFile(content []byte) error\n\n\tGetProxyStatus() []*proxy.WorkingStatus\n\tIsStoreProxyEnabled(name string) bool\n\tStoreEnabled() bool\n\n\tGetProxyConfig(name string) (v1.ProxyConfigurer, bool)\n\tGetVisitorConfig(name string) (v1.VisitorConfigurer, bool)\n\n\tListStoreProxies() ([]v1.ProxyConfigurer, error)\n\tGetStoreProxy(name string) (v1.ProxyConfigurer, error)\n\tCreateStoreProxy(cfg v1.ProxyConfigurer) (v1.ProxyConfigurer, error)\n\tUpdateStoreProxy(name string, cfg v1.ProxyConfigurer) (v1.ProxyConfigurer, error)\n\tDeleteStoreProxy(name string) error\n\n\tListStoreVisitors() ([]v1.VisitorConfigurer, error)\n\tGetStoreVisitor(name string) (v1.VisitorConfigurer, error)\n\tCreateStoreVisitor(cfg v1.VisitorConfigurer) (v1.VisitorConfigurer, error)\n\tUpdateStoreVisitor(name string, cfg v1.VisitorConfigurer) (v1.VisitorConfigurer, error)\n\tDeleteStoreVisitor(name string) error\n\n\tGracefulClose(d time.Duration)\n}\n"
  },
  {
    "path": "client/connector.go",
    "content": "// Copyright 2023 The frp Authors\n//\n// Licensed under the Apache License, Version 2.0 (the \"License\");\n// you may not use this file except in compliance with the License.\n// You may obtain a copy of the License at\n//\n//     http://www.apache.org/licenses/LICENSE-2.0\n//\n// Unless required by applicable law or agreed to in writing, software\n// distributed under the License is distributed on an \"AS IS\" BASIS,\n// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.\n// See the License for the specific language governing permissions and\n// limitations under the License.\n\npackage client\n\nimport (\n\t\"context\"\n\t\"crypto/tls\"\n\t\"net\"\n\t\"strconv\"\n\t\"strings\"\n\t\"sync\"\n\t\"time\"\n\n\tlibnet \"github.com/fatedier/golib/net\"\n\tfmux \"github.com/hashicorp/yamux\"\n\tquic \"github.com/quic-go/quic-go\"\n\t\"github.com/samber/lo\"\n\n\tv1 \"github.com/fatedier/frp/pkg/config/v1\"\n\t\"github.com/fatedier/frp/pkg/transport\"\n\tnetpkg \"github.com/fatedier/frp/pkg/util/net\"\n\t\"github.com/fatedier/frp/pkg/util/xlog\"\n)\n\n// Connector is an interface for establishing connections to the server.\ntype Connector interface {\n\tOpen() error\n\tConnect() (net.Conn, error)\n\tClose() error\n}\n\n// defaultConnectorImpl is the default implementation of Connector for normal frpc.\ntype defaultConnectorImpl struct {\n\tctx context.Context\n\tcfg *v1.ClientCommonConfig\n\n\tmuxSession *fmux.Session\n\tquicConn   *quic.Conn\n\tcloseOnce  sync.Once\n}\n\nfunc NewConnector(ctx context.Context, cfg *v1.ClientCommonConfig) Connector {\n\treturn &defaultConnectorImpl{\n\t\tctx: ctx,\n\t\tcfg: cfg,\n\t}\n}\n\n// Open opens an underlying connection to the server.\n// The underlying connection is either a TCP connection or a QUIC connection.\n// After the underlying connection is established, you can call Connect() to get a stream.\n// If TCPMux isn't enabled, the underlying connection is nil, you will get a new real TCP connection every time you call Connect().\nfunc (c *defaultConnectorImpl) Open() error {\n\txl := xlog.FromContextSafe(c.ctx)\n\n\t// special for quic\n\tif strings.EqualFold(c.cfg.Transport.Protocol, \"quic\") {\n\t\tvar tlsConfig *tls.Config\n\t\tvar err error\n\t\tsn := c.cfg.Transport.TLS.ServerName\n\t\tif sn == \"\" {\n\t\t\tsn = c.cfg.ServerAddr\n\t\t}\n\t\tif lo.FromPtr(c.cfg.Transport.TLS.Enable) {\n\t\t\ttlsConfig, err = transport.NewClientTLSConfig(\n\t\t\t\tc.cfg.Transport.TLS.CertFile,\n\t\t\t\tc.cfg.Transport.TLS.KeyFile,\n\t\t\t\tc.cfg.Transport.TLS.TrustedCaFile,\n\t\t\t\tsn)\n\t\t} else {\n\t\t\ttlsConfig, err = transport.NewClientTLSConfig(\"\", \"\", \"\", sn)\n\t\t}\n\t\tif err != nil {\n\t\t\txl.Warnf(\"fail to build tls configuration, err: %v\", err)\n\t\t\treturn err\n\t\t}\n\t\ttlsConfig.NextProtos = []string{\"frp\"}\n\n\t\tconn, err := quic.DialAddr(\n\t\t\tc.ctx,\n\t\t\tnet.JoinHostPort(c.cfg.ServerAddr, strconv.Itoa(c.cfg.ServerPort)),\n\t\t\ttlsConfig, &quic.Config{\n\t\t\t\tMaxIdleTimeout:     time.Duration(c.cfg.Transport.QUIC.MaxIdleTimeout) * time.Second,\n\t\t\t\tMaxIncomingStreams: int64(c.cfg.Transport.QUIC.MaxIncomingStreams),\n\t\t\t\tKeepAlivePeriod:    time.Duration(c.cfg.Transport.QUIC.KeepalivePeriod) * time.Second,\n\t\t\t})\n\t\tif err != nil {\n\t\t\treturn err\n\t\t}\n\t\tc.quicConn = conn\n\t\treturn nil\n\t}\n\n\tif !lo.FromPtr(c.cfg.Transport.TCPMux) {\n\t\treturn nil\n\t}\n\n\tconn, err := c.realConnect()\n\tif err != nil {\n\t\treturn err\n\t}\n\n\tfmuxCfg := fmux.DefaultConfig()\n\tfmuxCfg.KeepAliveInterval = time.Duration(c.cfg.Transport.TCPMuxKeepaliveInterval) * time.Second\n\t// Use trace level for yamux logs\n\tfmuxCfg.LogOutput = xlog.NewTraceWriter(xl)\n\tfmuxCfg.MaxStreamWindowSize = 6 * 1024 * 1024\n\tsession, err := fmux.Client(conn, fmuxCfg)\n\tif err != nil {\n\t\treturn err\n\t}\n\tc.muxSession = session\n\treturn nil\n}\n\n// Connect returns a stream from the underlying connection, or a new TCP connection if TCPMux isn't enabled.\nfunc (c *defaultConnectorImpl) Connect() (net.Conn, error) {\n\tif c.quicConn != nil {\n\t\tstream, err := c.quicConn.OpenStreamSync(context.Background())\n\t\tif err != nil {\n\t\t\treturn nil, err\n\t\t}\n\t\treturn netpkg.QuicStreamToNetConn(stream, c.quicConn), nil\n\t} else if c.muxSession != nil {\n\t\tstream, err := c.muxSession.OpenStream()\n\t\tif err != nil {\n\t\t\treturn nil, err\n\t\t}\n\t\treturn stream, nil\n\t}\n\n\treturn c.realConnect()\n}\n\nfunc (c *defaultConnectorImpl) realConnect() (net.Conn, error) {\n\txl := xlog.FromContextSafe(c.ctx)\n\tvar tlsConfig *tls.Config\n\tvar err error\n\ttlsEnable := lo.FromPtr(c.cfg.Transport.TLS.Enable)\n\tif c.cfg.Transport.Protocol == \"wss\" {\n\t\ttlsEnable = true\n\t}\n\tif tlsEnable {\n\t\tsn := c.cfg.Transport.TLS.ServerName\n\t\tif sn == \"\" {\n\t\t\tsn = c.cfg.ServerAddr\n\t\t}\n\n\t\ttlsConfig, err = transport.NewClientTLSConfig(\n\t\t\tc.cfg.Transport.TLS.CertFile,\n\t\t\tc.cfg.Transport.TLS.KeyFile,\n\t\t\tc.cfg.Transport.TLS.TrustedCaFile,\n\t\t\tsn)\n\t\tif err != nil {\n\t\t\txl.Warnf(\"fail to build tls configuration, err: %v\", err)\n\t\t\treturn nil, err\n\t\t}\n\t}\n\n\tproxyType, addr, auth, err := libnet.ParseProxyURL(c.cfg.Transport.ProxyURL)\n\tif err != nil {\n\t\txl.Errorf(\"fail to parse proxy url\")\n\t\treturn nil, err\n\t}\n\tdialOptions := []libnet.DialOption{}\n\tprotocol := c.cfg.Transport.Protocol\n\tswitch protocol {\n\tcase \"websocket\":\n\t\tprotocol = \"tcp\"\n\t\tdialOptions = append(dialOptions, libnet.WithAfterHook(libnet.AfterHook{Hook: netpkg.DialHookWebsocket(protocol, \"\")}))\n\t\tdialOptions = append(dialOptions, libnet.WithAfterHook(libnet.AfterHook{\n\t\t\tHook: netpkg.DialHookCustomTLSHeadByte(tlsConfig != nil, lo.FromPtr(c.cfg.Transport.TLS.DisableCustomTLSFirstByte)),\n\t\t}))\n\t\tdialOptions = append(dialOptions, libnet.WithTLSConfig(tlsConfig))\n\tcase \"wss\":\n\t\tprotocol = \"tcp\"\n\t\tdialOptions = append(dialOptions, libnet.WithTLSConfigAndPriority(100, tlsConfig))\n\t\t// Make sure that if it is wss, the websocket hook is executed after the tls hook.\n\t\tdialOptions = append(dialOptions, libnet.WithAfterHook(libnet.AfterHook{Hook: netpkg.DialHookWebsocket(protocol, tlsConfig.ServerName), Priority: 110}))\n\tdefault:\n\t\tdialOptions = append(dialOptions, libnet.WithAfterHook(libnet.AfterHook{\n\t\t\tHook: netpkg.DialHookCustomTLSHeadByte(tlsConfig != nil, lo.FromPtr(c.cfg.Transport.TLS.DisableCustomTLSFirstByte)),\n\t\t}))\n\t\tdialOptions = append(dialOptions, libnet.WithTLSConfig(tlsConfig))\n\t}\n\n\tif c.cfg.Transport.ConnectServerLocalIP != \"\" {\n\t\tdialOptions = append(dialOptions, libnet.WithLocalAddr(c.cfg.Transport.ConnectServerLocalIP))\n\t}\n\tdialOptions = append(dialOptions,\n\t\tlibnet.WithProtocol(protocol),\n\t\tlibnet.WithTimeout(time.Duration(c.cfg.Transport.DialServerTimeout)*time.Second),\n\t\tlibnet.WithKeepAlive(time.Duration(c.cfg.Transport.DialServerKeepAlive)*time.Second),\n\t\tlibnet.WithProxy(proxyType, addr),\n\t\tlibnet.WithProxyAuth(auth),\n\t)\n\tconn, err := libnet.DialContext(\n\t\tc.ctx,\n\t\tnet.JoinHostPort(c.cfg.ServerAddr, strconv.Itoa(c.cfg.ServerPort)),\n\t\tdialOptions...,\n\t)\n\treturn conn, err\n}\n\nfunc (c *defaultConnectorImpl) Close() error {\n\tc.closeOnce.Do(func() {\n\t\tif c.quicConn != nil {\n\t\t\t_ = c.quicConn.CloseWithError(0, \"\")\n\t\t}\n\t\tif c.muxSession != nil {\n\t\t\t_ = c.muxSession.Close()\n\t\t}\n\t})\n\treturn nil\n}\n"
  },
  {
    "path": "client/control.go",
    "content": "// Copyright 2017 fatedier, fatedier@gmail.com\n//\n// Licensed under the Apache License, Version 2.0 (the \"License\");\n// you may not use this file except in compliance with the License.\n// You may obtain a copy of the License at\n//\n//     http://www.apache.org/licenses/LICENSE-2.0\n//\n// Unless required by applicable law or agreed to in writing, software\n// distributed under the License is distributed on an \"AS IS\" BASIS,\n// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.\n// See the License for the specific language governing permissions and\n// limitations under the License.\n\npackage client\n\nimport (\n\t\"context\"\n\t\"net\"\n\t\"sync/atomic\"\n\t\"time\"\n\n\t\"github.com/fatedier/frp/client/proxy\"\n\t\"github.com/fatedier/frp/client/visitor\"\n\t\"github.com/fatedier/frp/pkg/auth\"\n\tv1 \"github.com/fatedier/frp/pkg/config/v1\"\n\t\"github.com/fatedier/frp/pkg/msg\"\n\t\"github.com/fatedier/frp/pkg/naming\"\n\t\"github.com/fatedier/frp/pkg/transport\"\n\tnetpkg \"github.com/fatedier/frp/pkg/util/net\"\n\t\"github.com/fatedier/frp/pkg/util/wait\"\n\t\"github.com/fatedier/frp/pkg/util/xlog\"\n\t\"github.com/fatedier/frp/pkg/vnet\"\n)\n\ntype SessionContext struct {\n\t// The client common configuration.\n\tCommon *v1.ClientCommonConfig\n\n\t// Unique ID obtained from frps.\n\t// It should be attached to the login message when reconnecting.\n\tRunID string\n\t// Underlying control connection. Once conn is closed, the msgDispatcher and the entire Control will exit.\n\tConn net.Conn\n\t// Indicates whether the connection is encrypted.\n\tConnEncrypted bool\n\t// Auth runtime used for login, heartbeats, and encryption.\n\tAuth *auth.ClientAuth\n\t// Connector is used to create new connections, which could be real TCP connections or virtual streams.\n\tConnector Connector\n\t// Virtual net controller\n\tVnetController *vnet.Controller\n}\n\ntype Control struct {\n\t// service context\n\tctx context.Context\n\txl  *xlog.Logger\n\n\t// session context\n\tsessionCtx *SessionContext\n\n\t// manage all proxies\n\tpm *proxy.Manager\n\n\t// manage all visitors\n\tvm *visitor.Manager\n\n\tdoneCh chan struct{}\n\n\t// of time.Time, last time got the Pong message\n\tlastPong atomic.Value\n\n\t// The role of msgTransporter is similar to HTTP2.\n\t// It allows multiple messages to be sent simultaneously on the same control connection.\n\t// The server's response messages will be dispatched to the corresponding waiting goroutines based on the laneKey and message type.\n\tmsgTransporter transport.MessageTransporter\n\n\t// msgDispatcher is a wrapper for control connection.\n\t// It provides a channel for sending messages, and you can register handlers to process messages based on their respective types.\n\tmsgDispatcher *msg.Dispatcher\n}\n\nfunc NewControl(ctx context.Context, sessionCtx *SessionContext) (*Control, error) {\n\t// new xlog instance\n\tctl := &Control{\n\t\tctx:        ctx,\n\t\txl:         xlog.FromContextSafe(ctx),\n\t\tsessionCtx: sessionCtx,\n\t\tdoneCh:     make(chan struct{}),\n\t}\n\tctl.lastPong.Store(time.Now())\n\n\tif sessionCtx.ConnEncrypted {\n\t\tcryptoRW, err := netpkg.NewCryptoReadWriter(sessionCtx.Conn, sessionCtx.Auth.EncryptionKey())\n\t\tif err != nil {\n\t\t\treturn nil, err\n\t\t}\n\t\tctl.msgDispatcher = msg.NewDispatcher(cryptoRW)\n\t} else {\n\t\tctl.msgDispatcher = msg.NewDispatcher(sessionCtx.Conn)\n\t}\n\tctl.registerMsgHandlers()\n\tctl.msgTransporter = transport.NewMessageTransporter(ctl.msgDispatcher)\n\n\tctl.pm = proxy.NewManager(ctl.ctx, sessionCtx.Common, sessionCtx.Auth.EncryptionKey(), ctl.msgTransporter, sessionCtx.VnetController)\n\tctl.vm = visitor.NewManager(ctl.ctx, sessionCtx.RunID, sessionCtx.Common,\n\t\tctl.connectServer, ctl.msgTransporter, sessionCtx.VnetController)\n\treturn ctl, nil\n}\n\nfunc (ctl *Control) Run(proxyCfgs []v1.ProxyConfigurer, visitorCfgs []v1.VisitorConfigurer) {\n\tgo ctl.worker()\n\n\t// start all proxies\n\tctl.pm.UpdateAll(proxyCfgs)\n\n\t// start all visitors\n\tctl.vm.UpdateAll(visitorCfgs)\n}\n\nfunc (ctl *Control) SetInWorkConnCallback(cb func(*v1.ProxyBaseConfig, net.Conn, *msg.StartWorkConn) bool) {\n\tctl.pm.SetInWorkConnCallback(cb)\n}\n\nfunc (ctl *Control) handleReqWorkConn(_ msg.Message) {\n\txl := ctl.xl\n\tworkConn, err := ctl.connectServer()\n\tif err != nil {\n\t\txl.Warnf(\"start new connection to server error: %v\", err)\n\t\treturn\n\t}\n\n\tm := &msg.NewWorkConn{\n\t\tRunID: ctl.sessionCtx.RunID,\n\t}\n\tif err = ctl.sessionCtx.Auth.Setter.SetNewWorkConn(m); err != nil {\n\t\txl.Warnf(\"error during NewWorkConn authentication: %v\", err)\n\t\tworkConn.Close()\n\t\treturn\n\t}\n\tif err = msg.WriteMsg(workConn, m); err != nil {\n\t\txl.Warnf(\"work connection write to server error: %v\", err)\n\t\tworkConn.Close()\n\t\treturn\n\t}\n\n\tvar startMsg msg.StartWorkConn\n\tif err = msg.ReadMsgInto(workConn, &startMsg); err != nil {\n\t\txl.Tracef(\"work connection closed before response StartWorkConn message: %v\", err)\n\t\tworkConn.Close()\n\t\treturn\n\t}\n\tif startMsg.Error != \"\" {\n\t\txl.Errorf(\"StartWorkConn contains error: %s\", startMsg.Error)\n\t\tworkConn.Close()\n\t\treturn\n\t}\n\n\tstartMsg.ProxyName = naming.StripUserPrefix(ctl.sessionCtx.Common.User, startMsg.ProxyName)\n\n\t// dispatch this work connection to related proxy\n\tctl.pm.HandleWorkConn(startMsg.ProxyName, workConn, &startMsg)\n}\n\nfunc (ctl *Control) handleNewProxyResp(m msg.Message) {\n\txl := ctl.xl\n\tinMsg := m.(*msg.NewProxyResp)\n\t// Server will return NewProxyResp message to each NewProxy message.\n\t// Start a new proxy handler if no error got\n\tproxyName := naming.StripUserPrefix(ctl.sessionCtx.Common.User, inMsg.ProxyName)\n\terr := ctl.pm.StartProxy(proxyName, inMsg.RemoteAddr, inMsg.Error)\n\tif err != nil {\n\t\txl.Warnf(\"[%s] start error: %v\", proxyName, err)\n\t} else {\n\t\txl.Infof(\"[%s] start proxy success\", proxyName)\n\t}\n}\n\nfunc (ctl *Control) handleNatHoleResp(m msg.Message) {\n\txl := ctl.xl\n\tinMsg := m.(*msg.NatHoleResp)\n\n\t// Dispatch the NatHoleResp message to the related proxy.\n\tok := ctl.msgTransporter.DispatchWithType(inMsg, msg.TypeNameNatHoleResp, inMsg.TransactionID)\n\tif !ok {\n\t\txl.Tracef(\"dispatch NatHoleResp message to related proxy error\")\n\t}\n}\n\nfunc (ctl *Control) handlePong(m msg.Message) {\n\txl := ctl.xl\n\tinMsg := m.(*msg.Pong)\n\n\tif inMsg.Error != \"\" {\n\t\txl.Errorf(\"pong message contains error: %s\", inMsg.Error)\n\t\tctl.closeSession()\n\t\treturn\n\t}\n\tctl.lastPong.Store(time.Now())\n\txl.Debugf(\"receive heartbeat from server\")\n}\n\n// closeSession closes the control connection.\nfunc (ctl *Control) closeSession() {\n\tctl.sessionCtx.Conn.Close()\n\tctl.sessionCtx.Connector.Close()\n}\n\nfunc (ctl *Control) Close() error {\n\treturn ctl.GracefulClose(0)\n}\n\nfunc (ctl *Control) GracefulClose(d time.Duration) error {\n\tctl.pm.Close()\n\tctl.vm.Close()\n\n\ttime.Sleep(d)\n\n\tctl.closeSession()\n\treturn nil\n}\n\n// Done returns a channel that will be closed after all resources are released\nfunc (ctl *Control) Done() <-chan struct{} {\n\treturn ctl.doneCh\n}\n\n// connectServer return a new connection to frps\nfunc (ctl *Control) connectServer() (net.Conn, error) {\n\treturn ctl.sessionCtx.Connector.Connect()\n}\n\nfunc (ctl *Control) registerMsgHandlers() {\n\tctl.msgDispatcher.RegisterHandler(&msg.ReqWorkConn{}, msg.AsyncHandler(ctl.handleReqWorkConn))\n\tctl.msgDispatcher.RegisterHandler(&msg.NewProxyResp{}, ctl.handleNewProxyResp)\n\tctl.msgDispatcher.RegisterHandler(&msg.NatHoleResp{}, ctl.handleNatHoleResp)\n\tctl.msgDispatcher.RegisterHandler(&msg.Pong{}, ctl.handlePong)\n}\n\n// heartbeatWorker sends heartbeat to server and check heartbeat timeout.\nfunc (ctl *Control) heartbeatWorker() {\n\txl := ctl.xl\n\n\tif ctl.sessionCtx.Common.Transport.HeartbeatInterval > 0 {\n\t\t// Send heartbeat to server.\n\t\tsendHeartBeat := func() (bool, error) {\n\t\t\txl.Debugf(\"send heartbeat to server\")\n\t\t\tpingMsg := &msg.Ping{}\n\t\t\tif err := ctl.sessionCtx.Auth.Setter.SetPing(pingMsg); err != nil {\n\t\t\t\txl.Warnf(\"error during ping authentication: %v, skip sending ping message\", err)\n\t\t\t\treturn false, err\n\t\t\t}\n\t\t\t_ = ctl.msgDispatcher.Send(pingMsg)\n\t\t\treturn false, nil\n\t\t}\n\n\t\tgo wait.BackoffUntil(sendHeartBeat,\n\t\t\twait.NewFastBackoffManager(wait.FastBackoffOptions{\n\t\t\t\tDuration:           time.Duration(ctl.sessionCtx.Common.Transport.HeartbeatInterval) * time.Second,\n\t\t\t\tInitDurationIfFail: time.Second,\n\t\t\t\tFactor:             2.0,\n\t\t\t\tJitter:             0.1,\n\t\t\t\tMaxDuration:        time.Duration(ctl.sessionCtx.Common.Transport.HeartbeatInterval) * time.Second,\n\t\t\t}),\n\t\t\ttrue, ctl.doneCh,\n\t\t)\n\t}\n\n\t// Check heartbeat timeout.\n\tif ctl.sessionCtx.Common.Transport.HeartbeatInterval > 0 && ctl.sessionCtx.Common.Transport.HeartbeatTimeout > 0 {\n\t\tgo wait.Until(func() {\n\t\t\tif time.Since(ctl.lastPong.Load().(time.Time)) > time.Duration(ctl.sessionCtx.Common.Transport.HeartbeatTimeout)*time.Second {\n\t\t\t\txl.Warnf(\"heartbeat timeout\")\n\t\t\t\tctl.closeSession()\n\t\t\t\treturn\n\t\t\t}\n\t\t}, time.Second, ctl.doneCh)\n\t}\n}\n\nfunc (ctl *Control) worker() {\n\txl := ctl.xl\n\tgo ctl.heartbeatWorker()\n\tgo ctl.msgDispatcher.Run()\n\n\t<-ctl.msgDispatcher.Done()\n\txl.Debugf(\"control message dispatcher exited\")\n\tctl.closeSession()\n\n\tctl.pm.Close()\n\tctl.vm.Close()\n\tclose(ctl.doneCh)\n}\n\nfunc (ctl *Control) UpdateAllConfigurer(proxyCfgs []v1.ProxyConfigurer, visitorCfgs []v1.VisitorConfigurer) error {\n\tctl.vm.UpdateAll(visitorCfgs)\n\tctl.pm.UpdateAll(proxyCfgs)\n\treturn nil\n}\n"
  },
  {
    "path": "client/event/event.go",
    "content": "package event\n\nimport (\n\t\"errors\"\n\n\t\"github.com/fatedier/frp/pkg/msg\"\n)\n\nvar ErrPayloadType = errors.New(\"error payload type\")\n\ntype Handler func(payload any) error\n\ntype StartProxyPayload struct {\n\tNewProxyMsg *msg.NewProxy\n}\n\ntype CloseProxyPayload struct {\n\tCloseProxyMsg *msg.CloseProxy\n}\n"
  },
  {
    "path": "client/health/health.go",
    "content": "// Copyright 2018 fatedier, fatedier@gmail.com\n//\n// Licensed under the Apache License, Version 2.0 (the \"License\");\n// you may not use this file except in compliance with the License.\n// You may obtain a copy of the License at\n//\n//     http://www.apache.org/licenses/LICENSE-2.0\n//\n// Unless required by applicable law or agreed to in writing, software\n// distributed under the License is distributed on an \"AS IS\" BASIS,\n// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.\n// See the License for the specific language governing permissions and\n// limitations under the License.\n\npackage health\n\nimport (\n\t\"context\"\n\t\"errors\"\n\t\"fmt\"\n\t\"io\"\n\t\"net\"\n\t\"net/http\"\n\t\"strings\"\n\t\"time\"\n\n\tv1 \"github.com/fatedier/frp/pkg/config/v1\"\n\t\"github.com/fatedier/frp/pkg/util/xlog\"\n)\n\nvar ErrHealthCheckType = errors.New(\"error health check type\")\n\ntype Monitor struct {\n\tcheckType      string\n\tinterval       time.Duration\n\ttimeout        time.Duration\n\tmaxFailedTimes int\n\n\t// For tcp\n\taddr string\n\n\t// For http\n\turl            string\n\theader         http.Header\n\tfailedTimes    uint64\n\tstatusOK       bool\n\tstatusNormalFn func()\n\tstatusFailedFn func()\n\n\tctx    context.Context\n\tcancel context.CancelFunc\n}\n\nfunc NewMonitor(ctx context.Context, cfg v1.HealthCheckConfig, addr string,\n\tstatusNormalFn func(), statusFailedFn func(),\n) *Monitor {\n\tif cfg.IntervalSeconds <= 0 {\n\t\tcfg.IntervalSeconds = 10\n\t}\n\tif cfg.TimeoutSeconds <= 0 {\n\t\tcfg.TimeoutSeconds = 3\n\t}\n\tif cfg.MaxFailed <= 0 {\n\t\tcfg.MaxFailed = 1\n\t}\n\tnewctx, cancel := context.WithCancel(ctx)\n\n\tvar url string\n\tif cfg.Type == \"http\" && cfg.Path != \"\" {\n\t\ts := \"http://\" + addr\n\t\tif !strings.HasPrefix(cfg.Path, \"/\") {\n\t\t\ts += \"/\"\n\t\t}\n\t\turl = s + cfg.Path\n\t}\n\theader := make(http.Header)\n\tfor _, h := range cfg.HTTPHeaders {\n\t\theader.Set(h.Name, h.Value)\n\t}\n\n\treturn &Monitor{\n\t\tcheckType:      cfg.Type,\n\t\tinterval:       time.Duration(cfg.IntervalSeconds) * time.Second,\n\t\ttimeout:        time.Duration(cfg.TimeoutSeconds) * time.Second,\n\t\tmaxFailedTimes: cfg.MaxFailed,\n\t\taddr:           addr,\n\t\turl:            url,\n\t\theader:         header,\n\t\tstatusOK:       false,\n\t\tstatusNormalFn: statusNormalFn,\n\t\tstatusFailedFn: statusFailedFn,\n\t\tctx:            newctx,\n\t\tcancel:         cancel,\n\t}\n}\n\nfunc (monitor *Monitor) Start() {\n\tgo monitor.checkWorker()\n}\n\nfunc (monitor *Monitor) Stop() {\n\tmonitor.cancel()\n}\n\nfunc (monitor *Monitor) checkWorker() {\n\txl := xlog.FromContextSafe(monitor.ctx)\n\tfor {\n\t\tdoCtx, cancel := context.WithDeadline(monitor.ctx, time.Now().Add(monitor.timeout))\n\t\terr := monitor.doCheck(doCtx)\n\n\t\t// check if this monitor has been closed\n\t\tselect {\n\t\tcase <-monitor.ctx.Done():\n\t\t\tcancel()\n\t\t\treturn\n\t\tdefault:\n\t\t\tcancel()\n\t\t}\n\n\t\tif err == nil {\n\t\t\txl.Tracef(\"do one health check success\")\n\t\t\tif !monitor.statusOK && monitor.statusNormalFn != nil {\n\t\t\t\txl.Infof(\"health check status change to success\")\n\t\t\t\tmonitor.statusOK = true\n\t\t\t\tmonitor.statusNormalFn()\n\t\t\t}\n\t\t} else {\n\t\t\txl.Warnf(\"do one health check failed: %v\", err)\n\t\t\tmonitor.failedTimes++\n\t\t\tif monitor.statusOK && int(monitor.failedTimes) >= monitor.maxFailedTimes && monitor.statusFailedFn != nil {\n\t\t\t\txl.Warnf(\"health check status change to failed\")\n\t\t\t\tmonitor.statusOK = false\n\t\t\t\tmonitor.statusFailedFn()\n\t\t\t}\n\t\t}\n\n\t\ttime.Sleep(monitor.interval)\n\t}\n}\n\nfunc (monitor *Monitor) doCheck(ctx context.Context) error {\n\tswitch monitor.checkType {\n\tcase \"tcp\":\n\t\treturn monitor.doTCPCheck(ctx)\n\tcase \"http\":\n\t\treturn monitor.doHTTPCheck(ctx)\n\tdefault:\n\t\treturn ErrHealthCheckType\n\t}\n}\n\nfunc (monitor *Monitor) doTCPCheck(ctx context.Context) error {\n\t// if tcp address is not specified, always return nil\n\tif monitor.addr == \"\" {\n\t\treturn nil\n\t}\n\n\tvar d net.Dialer\n\tconn, err := d.DialContext(ctx, \"tcp\", monitor.addr)\n\tif err != nil {\n\t\treturn err\n\t}\n\tconn.Close()\n\treturn nil\n}\n\nfunc (monitor *Monitor) doHTTPCheck(ctx context.Context) error {\n\treq, err := http.NewRequestWithContext(ctx, \"GET\", monitor.url, nil)\n\tif err != nil {\n\t\treturn err\n\t}\n\treq.Header = monitor.header\n\treq.Host = monitor.header.Get(\"Host\")\n\tresp, err := http.DefaultClient.Do(req)\n\tif err != nil {\n\t\treturn err\n\t}\n\tdefer resp.Body.Close()\n\t_, _ = io.Copy(io.Discard, resp.Body)\n\n\tif resp.StatusCode/100 != 2 {\n\t\treturn fmt.Errorf(\"do http health check, StatusCode is [%d] not 2xx\", resp.StatusCode)\n\t}\n\treturn nil\n}\n"
  },
  {
    "path": "client/http/controller.go",
    "content": "// Copyright 2025 The frp Authors\n//\n// Licensed under the Apache License, Version 2.0 (the \"License\");\n// you may not use this file except in compliance with the License.\n// You may obtain a copy of the License at\n//\n//     http://www.apache.org/licenses/LICENSE-2.0\n//\n// Unless required by applicable law or agreed to in writing, software\n// distributed under the License is distributed on an \"AS IS\" BASIS,\n// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.\n// See the License for the specific language governing permissions and\n// limitations under the License.\n\npackage http\n\nimport (\n\t\"cmp\"\n\t\"errors\"\n\t\"fmt\"\n\t\"net\"\n\t\"net/http\"\n\t\"slices\"\n\t\"strconv\"\n\t\"time\"\n\n\t\"github.com/fatedier/frp/client/configmgmt\"\n\t\"github.com/fatedier/frp/client/http/model\"\n\t\"github.com/fatedier/frp/client/proxy\"\n\thttppkg \"github.com/fatedier/frp/pkg/util/http\"\n\t\"github.com/fatedier/frp/pkg/util/jsonx\"\n)\n\n// Controller handles HTTP API requests for frpc.\ntype Controller struct {\n\tserverAddr string\n\tmanager    configmgmt.ConfigManager\n}\n\n// ControllerParams contains parameters for creating an APIController.\ntype ControllerParams struct {\n\tServerAddr string\n\tManager    configmgmt.ConfigManager\n}\n\nfunc NewController(params ControllerParams) *Controller {\n\treturn &Controller{\n\t\tserverAddr: params.ServerAddr,\n\t\tmanager:    params.Manager,\n\t}\n}\n\nfunc (c *Controller) toHTTPError(err error) error {\n\tif err == nil {\n\t\treturn nil\n\t}\n\n\tcode := http.StatusInternalServerError\n\tswitch {\n\tcase errors.Is(err, configmgmt.ErrInvalidArgument):\n\t\tcode = http.StatusBadRequest\n\tcase errors.Is(err, configmgmt.ErrNotFound), errors.Is(err, configmgmt.ErrStoreDisabled):\n\t\tcode = http.StatusNotFound\n\tcase errors.Is(err, configmgmt.ErrConflict):\n\t\tcode = http.StatusConflict\n\t}\n\treturn httppkg.NewError(code, err.Error())\n}\n\n// Reload handles GET /api/reload\nfunc (c *Controller) Reload(ctx *httppkg.Context) (any, error) {\n\tstrictConfigMode := false\n\tstrictStr := ctx.Query(\"strictConfig\")\n\tif strictStr != \"\" {\n\t\tstrictConfigMode, _ = strconv.ParseBool(strictStr)\n\t}\n\n\tif err := c.manager.ReloadFromFile(strictConfigMode); err != nil {\n\t\treturn nil, c.toHTTPError(err)\n\t}\n\treturn nil, nil\n}\n\n// Stop handles POST /api/stop\nfunc (c *Controller) Stop(ctx *httppkg.Context) (any, error) {\n\tgo c.manager.GracefulClose(100 * time.Millisecond)\n\treturn nil, nil\n}\n\n// Status handles GET /api/status\nfunc (c *Controller) Status(ctx *httppkg.Context) (any, error) {\n\tres := make(model.StatusResp)\n\tps := c.manager.GetProxyStatus()\n\tif ps == nil {\n\t\treturn res, nil\n\t}\n\n\tfor _, status := range ps {\n\t\tres[status.Type] = append(res[status.Type], c.buildProxyStatusResp(status))\n\t}\n\n\tfor _, arrs := range res {\n\t\tif len(arrs) <= 1 {\n\t\t\tcontinue\n\t\t}\n\t\tslices.SortFunc(arrs, func(a, b model.ProxyStatusResp) int {\n\t\t\treturn cmp.Compare(a.Name, b.Name)\n\t\t})\n\t}\n\treturn res, nil\n}\n\n// GetConfig handles GET /api/config\nfunc (c *Controller) GetConfig(ctx *httppkg.Context) (any, error) {\n\tcontent, err := c.manager.ReadConfigFile()\n\tif err != nil {\n\t\treturn nil, c.toHTTPError(err)\n\t}\n\treturn content, nil\n}\n\n// PutConfig handles PUT /api/config\nfunc (c *Controller) PutConfig(ctx *httppkg.Context) (any, error) {\n\tbody, err := ctx.Body()\n\tif err != nil {\n\t\treturn nil, httppkg.NewError(http.StatusBadRequest, fmt.Sprintf(\"read request body error: %v\", err))\n\t}\n\n\tif len(body) == 0 {\n\t\treturn nil, httppkg.NewError(http.StatusBadRequest, \"body can't be empty\")\n\t}\n\n\tif err := c.manager.WriteConfigFile(body); err != nil {\n\t\treturn nil, c.toHTTPError(err)\n\t}\n\treturn nil, nil\n}\n\nfunc (c *Controller) buildProxyStatusResp(status *proxy.WorkingStatus) model.ProxyStatusResp {\n\tpsr := model.ProxyStatusResp{\n\t\tName:   status.Name,\n\t\tType:   status.Type,\n\t\tStatus: status.Phase,\n\t\tErr:    status.Err,\n\t}\n\tbaseCfg := status.Cfg.GetBaseConfig()\n\tif baseCfg.LocalPort != 0 {\n\t\tpsr.LocalAddr = net.JoinHostPort(baseCfg.LocalIP, strconv.Itoa(baseCfg.LocalPort))\n\t}\n\tpsr.Plugin = baseCfg.Plugin.Type\n\n\tif status.Err == \"\" {\n\t\tpsr.RemoteAddr = status.RemoteAddr\n\t\tif slices.Contains([]string{\"tcp\", \"udp\"}, status.Type) {\n\t\t\tpsr.RemoteAddr = c.serverAddr + psr.RemoteAddr\n\t\t}\n\t}\n\n\tif c.manager.IsStoreProxyEnabled(status.Name) {\n\t\tpsr.Source = model.SourceStore\n\t}\n\treturn psr\n}\n\n// GetProxyConfig handles GET /api/proxy/{name}/config\nfunc (c *Controller) GetProxyConfig(ctx *httppkg.Context) (any, error) {\n\tname := ctx.Param(\"name\")\n\tif name == \"\" {\n\t\treturn nil, httppkg.NewError(http.StatusBadRequest, \"proxy name is required\")\n\t}\n\n\tcfg, ok := c.manager.GetProxyConfig(name)\n\tif !ok {\n\t\treturn nil, httppkg.NewError(http.StatusNotFound, fmt.Sprintf(\"proxy %q not found\", name))\n\t}\n\n\tpayload, err := model.ProxyDefinitionFromConfigurer(cfg)\n\tif err != nil {\n\t\treturn nil, httppkg.NewError(http.StatusInternalServerError, err.Error())\n\t}\n\treturn payload, nil\n}\n\n// GetVisitorConfig handles GET /api/visitor/{name}/config\nfunc (c *Controller) GetVisitorConfig(ctx *httppkg.Context) (any, error) {\n\tname := ctx.Param(\"name\")\n\tif name == \"\" {\n\t\treturn nil, httppkg.NewError(http.StatusBadRequest, \"visitor name is required\")\n\t}\n\n\tcfg, ok := c.manager.GetVisitorConfig(name)\n\tif !ok {\n\t\treturn nil, httppkg.NewError(http.StatusNotFound, fmt.Sprintf(\"visitor %q not found\", name))\n\t}\n\n\tpayload, err := model.VisitorDefinitionFromConfigurer(cfg)\n\tif err != nil {\n\t\treturn nil, httppkg.NewError(http.StatusInternalServerError, err.Error())\n\t}\n\treturn payload, nil\n}\n\nfunc (c *Controller) ListStoreProxies(ctx *httppkg.Context) (any, error) {\n\tproxies, err := c.manager.ListStoreProxies()\n\tif err != nil {\n\t\treturn nil, c.toHTTPError(err)\n\t}\n\n\tresp := model.ProxyListResp{Proxies: make([]model.ProxyDefinition, 0, len(proxies))}\n\tfor _, p := range proxies {\n\t\tpayload, err := model.ProxyDefinitionFromConfigurer(p)\n\t\tif err != nil {\n\t\t\treturn nil, httppkg.NewError(http.StatusInternalServerError, err.Error())\n\t\t}\n\t\tresp.Proxies = append(resp.Proxies, payload)\n\t}\n\tslices.SortFunc(resp.Proxies, func(a, b model.ProxyDefinition) int {\n\t\treturn cmp.Compare(a.Name, b.Name)\n\t})\n\treturn resp, nil\n}\n\nfunc (c *Controller) GetStoreProxy(ctx *httppkg.Context) (any, error) {\n\tname := ctx.Param(\"name\")\n\tif name == \"\" {\n\t\treturn nil, httppkg.NewError(http.StatusBadRequest, \"proxy name is required\")\n\t}\n\n\tp, err := c.manager.GetStoreProxy(name)\n\tif err != nil {\n\t\treturn nil, c.toHTTPError(err)\n\t}\n\n\tpayload, err := model.ProxyDefinitionFromConfigurer(p)\n\tif err != nil {\n\t\treturn nil, httppkg.NewError(http.StatusInternalServerError, err.Error())\n\t}\n\n\treturn payload, nil\n}\n\nfunc (c *Controller) CreateStoreProxy(ctx *httppkg.Context) (any, error) {\n\tbody, err := ctx.Body()\n\tif err != nil {\n\t\treturn nil, httppkg.NewError(http.StatusBadRequest, fmt.Sprintf(\"read body error: %v\", err))\n\t}\n\n\tvar payload model.ProxyDefinition\n\tif err := jsonx.Unmarshal(body, &payload); err != nil {\n\t\treturn nil, httppkg.NewError(http.StatusBadRequest, fmt.Sprintf(\"parse JSON error: %v\", err))\n\t}\n\n\tif err := payload.Validate(\"\", false); err != nil {\n\t\treturn nil, httppkg.NewError(http.StatusBadRequest, err.Error())\n\t}\n\tcfg, err := payload.ToConfigurer()\n\tif err != nil {\n\t\treturn nil, httppkg.NewError(http.StatusBadRequest, err.Error())\n\t}\n\tcreated, err := c.manager.CreateStoreProxy(cfg)\n\tif err != nil {\n\t\treturn nil, c.toHTTPError(err)\n\t}\n\n\tresp, err := model.ProxyDefinitionFromConfigurer(created)\n\tif err != nil {\n\t\treturn nil, httppkg.NewError(http.StatusInternalServerError, err.Error())\n\t}\n\treturn resp, nil\n}\n\nfunc (c *Controller) UpdateStoreProxy(ctx *httppkg.Context) (any, error) {\n\tname := ctx.Param(\"name\")\n\tif name == \"\" {\n\t\treturn nil, httppkg.NewError(http.StatusBadRequest, \"proxy name is required\")\n\t}\n\n\tbody, err := ctx.Body()\n\tif err != nil {\n\t\treturn nil, httppkg.NewError(http.StatusBadRequest, fmt.Sprintf(\"read body error: %v\", err))\n\t}\n\n\tvar payload model.ProxyDefinition\n\tif err := jsonx.Unmarshal(body, &payload); err != nil {\n\t\treturn nil, httppkg.NewError(http.StatusBadRequest, fmt.Sprintf(\"parse JSON error: %v\", err))\n\t}\n\n\tif err := payload.Validate(name, true); err != nil {\n\t\treturn nil, httppkg.NewError(http.StatusBadRequest, err.Error())\n\t}\n\tcfg, err := payload.ToConfigurer()\n\tif err != nil {\n\t\treturn nil, httppkg.NewError(http.StatusBadRequest, err.Error())\n\t}\n\tupdated, err := c.manager.UpdateStoreProxy(name, cfg)\n\tif err != nil {\n\t\treturn nil, c.toHTTPError(err)\n\t}\n\n\tresp, err := model.ProxyDefinitionFromConfigurer(updated)\n\tif err != nil {\n\t\treturn nil, httppkg.NewError(http.StatusInternalServerError, err.Error())\n\t}\n\treturn resp, nil\n}\n\nfunc (c *Controller) DeleteStoreProxy(ctx *httppkg.Context) (any, error) {\n\tname := ctx.Param(\"name\")\n\tif name == \"\" {\n\t\treturn nil, httppkg.NewError(http.StatusBadRequest, \"proxy name is required\")\n\t}\n\n\tif err := c.manager.DeleteStoreProxy(name); err != nil {\n\t\treturn nil, c.toHTTPError(err)\n\t}\n\treturn nil, nil\n}\n\nfunc (c *Controller) ListStoreVisitors(ctx *httppkg.Context) (any, error) {\n\tvisitors, err := c.manager.ListStoreVisitors()\n\tif err != nil {\n\t\treturn nil, c.toHTTPError(err)\n\t}\n\n\tresp := model.VisitorListResp{Visitors: make([]model.VisitorDefinition, 0, len(visitors))}\n\tfor _, v := range visitors {\n\t\tpayload, err := model.VisitorDefinitionFromConfigurer(v)\n\t\tif err != nil {\n\t\t\treturn nil, httppkg.NewError(http.StatusInternalServerError, err.Error())\n\t\t}\n\t\tresp.Visitors = append(resp.Visitors, payload)\n\t}\n\tslices.SortFunc(resp.Visitors, func(a, b model.VisitorDefinition) int {\n\t\treturn cmp.Compare(a.Name, b.Name)\n\t})\n\treturn resp, nil\n}\n\nfunc (c *Controller) GetStoreVisitor(ctx *httppkg.Context) (any, error) {\n\tname := ctx.Param(\"name\")\n\tif name == \"\" {\n\t\treturn nil, httppkg.NewError(http.StatusBadRequest, \"visitor name is required\")\n\t}\n\n\tv, err := c.manager.GetStoreVisitor(name)\n\tif err != nil {\n\t\treturn nil, c.toHTTPError(err)\n\t}\n\n\tpayload, err := model.VisitorDefinitionFromConfigurer(v)\n\tif err != nil {\n\t\treturn nil, httppkg.NewError(http.StatusInternalServerError, err.Error())\n\t}\n\n\treturn payload, nil\n}\n\nfunc (c *Controller) CreateStoreVisitor(ctx *httppkg.Context) (any, error) {\n\tbody, err := ctx.Body()\n\tif err != nil {\n\t\treturn nil, httppkg.NewError(http.StatusBadRequest, fmt.Sprintf(\"read body error: %v\", err))\n\t}\n\n\tvar payload model.VisitorDefinition\n\tif err := jsonx.Unmarshal(body, &payload); err != nil {\n\t\treturn nil, httppkg.NewError(http.StatusBadRequest, fmt.Sprintf(\"parse JSON error: %v\", err))\n\t}\n\n\tif err := payload.Validate(\"\", false); err != nil {\n\t\treturn nil, httppkg.NewError(http.StatusBadRequest, err.Error())\n\t}\n\tcfg, err := payload.ToConfigurer()\n\tif err != nil {\n\t\treturn nil, httppkg.NewError(http.StatusBadRequest, err.Error())\n\t}\n\tcreated, err := c.manager.CreateStoreVisitor(cfg)\n\tif err != nil {\n\t\treturn nil, c.toHTTPError(err)\n\t}\n\n\tresp, err := model.VisitorDefinitionFromConfigurer(created)\n\tif err != nil {\n\t\treturn nil, httppkg.NewError(http.StatusInternalServerError, err.Error())\n\t}\n\treturn resp, nil\n}\n\nfunc (c *Controller) UpdateStoreVisitor(ctx *httppkg.Context) (any, error) {\n\tname := ctx.Param(\"name\")\n\tif name == \"\" {\n\t\treturn nil, httppkg.NewError(http.StatusBadRequest, \"visitor name is required\")\n\t}\n\n\tbody, err := ctx.Body()\n\tif err != nil {\n\t\treturn nil, httppkg.NewError(http.StatusBadRequest, fmt.Sprintf(\"read body error: %v\", err))\n\t}\n\n\tvar payload model.VisitorDefinition\n\tif err := jsonx.Unmarshal(body, &payload); err != nil {\n\t\treturn nil, httppkg.NewError(http.StatusBadRequest, fmt.Sprintf(\"parse JSON error: %v\", err))\n\t}\n\n\tif err := payload.Validate(name, true); err != nil {\n\t\treturn nil, httppkg.NewError(http.StatusBadRequest, err.Error())\n\t}\n\tcfg, err := payload.ToConfigurer()\n\tif err != nil {\n\t\treturn nil, httppkg.NewError(http.StatusBadRequest, err.Error())\n\t}\n\tupdated, err := c.manager.UpdateStoreVisitor(name, cfg)\n\tif err != nil {\n\t\treturn nil, c.toHTTPError(err)\n\t}\n\n\tresp, err := model.VisitorDefinitionFromConfigurer(updated)\n\tif err != nil {\n\t\treturn nil, httppkg.NewError(http.StatusInternalServerError, err.Error())\n\t}\n\treturn resp, nil\n}\n\nfunc (c *Controller) DeleteStoreVisitor(ctx *httppkg.Context) (any, error) {\n\tname := ctx.Param(\"name\")\n\tif name == \"\" {\n\t\treturn nil, httppkg.NewError(http.StatusBadRequest, \"visitor name is required\")\n\t}\n\n\tif err := c.manager.DeleteStoreVisitor(name); err != nil {\n\t\treturn nil, c.toHTTPError(err)\n\t}\n\treturn nil, nil\n}\n"
  },
  {
    "path": "client/http/controller_test.go",
    "content": "package http\n\nimport (\n\t\"bytes\"\n\t\"encoding/json\"\n\t\"errors\"\n\t\"fmt\"\n\t\"net/http\"\n\t\"net/http/httptest\"\n\t\"testing\"\n\t\"time\"\n\n\t\"github.com/gorilla/mux\"\n\n\t\"github.com/fatedier/frp/client/configmgmt\"\n\t\"github.com/fatedier/frp/client/http/model\"\n\t\"github.com/fatedier/frp/client/proxy\"\n\tv1 \"github.com/fatedier/frp/pkg/config/v1\"\n\thttppkg \"github.com/fatedier/frp/pkg/util/http\"\n)\n\ntype fakeConfigManager struct {\n\treloadFromFileFn      func(strict bool) error\n\treadConfigFileFn      func() (string, error)\n\twriteConfigFileFn     func(content []byte) error\n\tgetProxyStatusFn      func() []*proxy.WorkingStatus\n\tisStoreProxyEnabledFn func(name string) bool\n\tstoreEnabledFn        func() bool\n\tgetProxyConfigFn      func(name string) (v1.ProxyConfigurer, bool)\n\tgetVisitorConfigFn    func(name string) (v1.VisitorConfigurer, bool)\n\n\tlistStoreProxiesFn  func() ([]v1.ProxyConfigurer, error)\n\tgetStoreProxyFn     func(name string) (v1.ProxyConfigurer, error)\n\tcreateStoreProxyFn  func(cfg v1.ProxyConfigurer) (v1.ProxyConfigurer, error)\n\tupdateStoreProxyFn  func(name string, cfg v1.ProxyConfigurer) (v1.ProxyConfigurer, error)\n\tdeleteStoreProxyFn  func(name string) error\n\tlistStoreVisitorsFn func() ([]v1.VisitorConfigurer, error)\n\tgetStoreVisitorFn   func(name string) (v1.VisitorConfigurer, error)\n\tcreateStoreVisitFn  func(cfg v1.VisitorConfigurer) (v1.VisitorConfigurer, error)\n\tupdateStoreVisitFn  func(name string, cfg v1.VisitorConfigurer) (v1.VisitorConfigurer, error)\n\tdeleteStoreVisitFn  func(name string) error\n\tgracefulCloseFn     func(d time.Duration)\n}\n\nfunc (m *fakeConfigManager) ReloadFromFile(strict bool) error {\n\tif m.reloadFromFileFn != nil {\n\t\treturn m.reloadFromFileFn(strict)\n\t}\n\treturn nil\n}\n\nfunc (m *fakeConfigManager) ReadConfigFile() (string, error) {\n\tif m.readConfigFileFn != nil {\n\t\treturn m.readConfigFileFn()\n\t}\n\treturn \"\", nil\n}\n\nfunc (m *fakeConfigManager) WriteConfigFile(content []byte) error {\n\tif m.writeConfigFileFn != nil {\n\t\treturn m.writeConfigFileFn(content)\n\t}\n\treturn nil\n}\n\nfunc (m *fakeConfigManager) GetProxyStatus() []*proxy.WorkingStatus {\n\tif m.getProxyStatusFn != nil {\n\t\treturn m.getProxyStatusFn()\n\t}\n\treturn nil\n}\n\nfunc (m *fakeConfigManager) IsStoreProxyEnabled(name string) bool {\n\tif m.isStoreProxyEnabledFn != nil {\n\t\treturn m.isStoreProxyEnabledFn(name)\n\t}\n\treturn false\n}\n\nfunc (m *fakeConfigManager) StoreEnabled() bool {\n\tif m.storeEnabledFn != nil {\n\t\treturn m.storeEnabledFn()\n\t}\n\treturn false\n}\n\nfunc (m *fakeConfigManager) GetProxyConfig(name string) (v1.ProxyConfigurer, bool) {\n\tif m.getProxyConfigFn != nil {\n\t\treturn m.getProxyConfigFn(name)\n\t}\n\treturn nil, false\n}\n\nfunc (m *fakeConfigManager) GetVisitorConfig(name string) (v1.VisitorConfigurer, bool) {\n\tif m.getVisitorConfigFn != nil {\n\t\treturn m.getVisitorConfigFn(name)\n\t}\n\treturn nil, false\n}\n\nfunc (m *fakeConfigManager) ListStoreProxies() ([]v1.ProxyConfigurer, error) {\n\tif m.listStoreProxiesFn != nil {\n\t\treturn m.listStoreProxiesFn()\n\t}\n\treturn nil, nil\n}\n\nfunc (m *fakeConfigManager) GetStoreProxy(name string) (v1.ProxyConfigurer, error) {\n\tif m.getStoreProxyFn != nil {\n\t\treturn m.getStoreProxyFn(name)\n\t}\n\treturn nil, nil\n}\n\nfunc (m *fakeConfigManager) CreateStoreProxy(cfg v1.ProxyConfigurer) (v1.ProxyConfigurer, error) {\n\tif m.createStoreProxyFn != nil {\n\t\treturn m.createStoreProxyFn(cfg)\n\t}\n\treturn cfg, nil\n}\n\nfunc (m *fakeConfigManager) UpdateStoreProxy(name string, cfg v1.ProxyConfigurer) (v1.ProxyConfigurer, error) {\n\tif m.updateStoreProxyFn != nil {\n\t\treturn m.updateStoreProxyFn(name, cfg)\n\t}\n\treturn cfg, nil\n}\n\nfunc (m *fakeConfigManager) DeleteStoreProxy(name string) error {\n\tif m.deleteStoreProxyFn != nil {\n\t\treturn m.deleteStoreProxyFn(name)\n\t}\n\treturn nil\n}\n\nfunc (m *fakeConfigManager) ListStoreVisitors() ([]v1.VisitorConfigurer, error) {\n\tif m.listStoreVisitorsFn != nil {\n\t\treturn m.listStoreVisitorsFn()\n\t}\n\treturn nil, nil\n}\n\nfunc (m *fakeConfigManager) GetStoreVisitor(name string) (v1.VisitorConfigurer, error) {\n\tif m.getStoreVisitorFn != nil {\n\t\treturn m.getStoreVisitorFn(name)\n\t}\n\treturn nil, nil\n}\n\nfunc (m *fakeConfigManager) CreateStoreVisitor(cfg v1.VisitorConfigurer) (v1.VisitorConfigurer, error) {\n\tif m.createStoreVisitFn != nil {\n\t\treturn m.createStoreVisitFn(cfg)\n\t}\n\treturn cfg, nil\n}\n\nfunc (m *fakeConfigManager) UpdateStoreVisitor(name string, cfg v1.VisitorConfigurer) (v1.VisitorConfigurer, error) {\n\tif m.updateStoreVisitFn != nil {\n\t\treturn m.updateStoreVisitFn(name, cfg)\n\t}\n\treturn cfg, nil\n}\n\nfunc (m *fakeConfigManager) DeleteStoreVisitor(name string) error {\n\tif m.deleteStoreVisitFn != nil {\n\t\treturn m.deleteStoreVisitFn(name)\n\t}\n\treturn nil\n}\n\nfunc (m *fakeConfigManager) GracefulClose(d time.Duration) {\n\tif m.gracefulCloseFn != nil {\n\t\tm.gracefulCloseFn(d)\n\t}\n}\n\nfunc newRawTCPProxyConfig(name string) *v1.TCPProxyConfig {\n\treturn &v1.TCPProxyConfig{\n\t\tProxyBaseConfig: v1.ProxyBaseConfig{\n\t\t\tName: name,\n\t\t\tType: \"tcp\",\n\t\t\tProxyBackend: v1.ProxyBackend{\n\t\t\t\tLocalPort: 10080,\n\t\t\t},\n\t\t},\n\t}\n}\n\nfunc TestBuildProxyStatusRespStoreSourceEnabled(t *testing.T) {\n\tstatus := &proxy.WorkingStatus{\n\t\tName:       \"shared-proxy\",\n\t\tType:       \"tcp\",\n\t\tPhase:      proxy.ProxyPhaseRunning,\n\t\tRemoteAddr: \":8080\",\n\t\tCfg:        newRawTCPProxyConfig(\"shared-proxy\"),\n\t}\n\n\tcontroller := &Controller{\n\t\tserverAddr: \"127.0.0.1\",\n\t\tmanager: &fakeConfigManager{\n\t\t\tisStoreProxyEnabledFn: func(name string) bool {\n\t\t\t\treturn name == \"shared-proxy\"\n\t\t\t},\n\t\t},\n\t}\n\n\tresp := controller.buildProxyStatusResp(status)\n\tif resp.Source != \"store\" {\n\t\tt.Fatalf(\"unexpected source: %q\", resp.Source)\n\t}\n\tif resp.RemoteAddr != \"127.0.0.1:8080\" {\n\t\tt.Fatalf(\"unexpected remote addr: %q\", resp.RemoteAddr)\n\t}\n}\n\nfunc TestReloadErrorMapping(t *testing.T) {\n\ttests := []struct {\n\t\tname         string\n\t\terr          error\n\t\texpectedCode int\n\t}{\n\t\t{name: \"invalid arg\", err: fmtError(configmgmt.ErrInvalidArgument, \"bad cfg\"), expectedCode: http.StatusBadRequest},\n\t\t{name: \"apply fail\", err: fmtError(configmgmt.ErrApplyConfig, \"reload failed\"), expectedCode: http.StatusInternalServerError},\n\t}\n\n\tfor _, tc := range tests {\n\t\tt.Run(tc.name, func(t *testing.T) {\n\t\t\tcontroller := &Controller{\n\t\t\t\tmanager: &fakeConfigManager{reloadFromFileFn: func(bool) error { return tc.err }},\n\t\t\t}\n\t\t\tctx := httppkg.NewContext(httptest.NewRecorder(), httptest.NewRequest(http.MethodGet, \"/api/reload\", nil))\n\t\t\t_, err := controller.Reload(ctx)\n\t\t\tif err == nil {\n\t\t\t\tt.Fatal(\"expected error\")\n\t\t\t}\n\t\t\tassertHTTPCode(t, err, tc.expectedCode)\n\t\t})\n\t}\n}\n\nfunc TestStoreProxyErrorMapping(t *testing.T) {\n\ttests := []struct {\n\t\tname         string\n\t\terr          error\n\t\texpectedCode int\n\t}{\n\t\t{name: \"not found\", err: fmtError(configmgmt.ErrNotFound, \"not found\"), expectedCode: http.StatusNotFound},\n\t\t{name: \"conflict\", err: fmtError(configmgmt.ErrConflict, \"exists\"), expectedCode: http.StatusConflict},\n\t\t{name: \"internal\", err: errors.New(\"persist failed\"), expectedCode: http.StatusInternalServerError},\n\t}\n\n\tfor _, tc := range tests {\n\t\tt.Run(tc.name, func(t *testing.T) {\n\t\t\tbody := []byte(`{\"name\":\"shared-proxy\",\"type\":\"tcp\",\"tcp\":{\"localPort\":10080}}`)\n\t\t\treq := httptest.NewRequest(http.MethodPut, \"/api/store/proxies/shared-proxy\", bytes.NewReader(body))\n\t\t\treq = mux.SetURLVars(req, map[string]string{\"name\": \"shared-proxy\"})\n\t\t\tctx := httppkg.NewContext(httptest.NewRecorder(), req)\n\n\t\t\tcontroller := &Controller{\n\t\t\t\tmanager: &fakeConfigManager{\n\t\t\t\t\tupdateStoreProxyFn: func(_ string, _ v1.ProxyConfigurer) (v1.ProxyConfigurer, error) {\n\t\t\t\t\t\treturn nil, tc.err\n\t\t\t\t\t},\n\t\t\t\t},\n\t\t\t}\n\n\t\t\t_, err := controller.UpdateStoreProxy(ctx)\n\t\t\tif err == nil {\n\t\t\t\tt.Fatal(\"expected error\")\n\t\t\t}\n\t\t\tassertHTTPCode(t, err, tc.expectedCode)\n\t\t})\n\t}\n}\n\nfunc TestStoreVisitorErrorMapping(t *testing.T) {\n\tbody := []byte(`{\"name\":\"shared-visitor\",\"type\":\"xtcp\",\"xtcp\":{\"serverName\":\"server\",\"bindPort\":10081,\"secretKey\":\"secret\"}}`)\n\treq := httptest.NewRequest(http.MethodDelete, \"/api/store/visitors/shared-visitor\", bytes.NewReader(body))\n\treq = mux.SetURLVars(req, map[string]string{\"name\": \"shared-visitor\"})\n\tctx := httppkg.NewContext(httptest.NewRecorder(), req)\n\n\tcontroller := &Controller{\n\t\tmanager: &fakeConfigManager{\n\t\t\tdeleteStoreVisitFn: func(string) error {\n\t\t\t\treturn fmtError(configmgmt.ErrStoreDisabled, \"disabled\")\n\t\t\t},\n\t\t},\n\t}\n\n\t_, err := controller.DeleteStoreVisitor(ctx)\n\tif err == nil {\n\t\tt.Fatal(\"expected error\")\n\t}\n\tassertHTTPCode(t, err, http.StatusNotFound)\n}\n\nfunc TestCreateStoreProxyIgnoresUnknownFields(t *testing.T) {\n\tvar gotName string\n\tcontroller := &Controller{\n\t\tmanager: &fakeConfigManager{\n\t\t\tcreateStoreProxyFn: func(cfg v1.ProxyConfigurer) (v1.ProxyConfigurer, error) {\n\t\t\t\tgotName = cfg.GetBaseConfig().Name\n\t\t\t\treturn cfg, nil\n\t\t\t},\n\t\t},\n\t}\n\n\tbody := []byte(`{\"name\":\"raw-proxy\",\"type\":\"tcp\",\"unexpected\":\"value\",\"tcp\":{\"localPort\":10080,\"unknownInBlock\":\"value\"}}`)\n\treq := httptest.NewRequest(http.MethodPost, \"/api/store/proxies\", bytes.NewReader(body))\n\tctx := httppkg.NewContext(httptest.NewRecorder(), req)\n\n\tresp, err := controller.CreateStoreProxy(ctx)\n\tif err != nil {\n\t\tt.Fatalf(\"create store proxy: %v\", err)\n\t}\n\tif gotName != \"raw-proxy\" {\n\t\tt.Fatalf(\"unexpected proxy name: %q\", gotName)\n\t}\n\n\tpayload, ok := resp.(model.ProxyDefinition)\n\tif !ok {\n\t\tt.Fatalf(\"unexpected response type: %T\", resp)\n\t}\n\tif payload.Type != \"tcp\" || payload.TCP == nil {\n\t\tt.Fatalf(\"unexpected payload: %#v\", payload)\n\t}\n}\n\nfunc TestCreateStoreVisitorIgnoresUnknownFields(t *testing.T) {\n\tvar gotName string\n\tcontroller := &Controller{\n\t\tmanager: &fakeConfigManager{\n\t\t\tcreateStoreVisitFn: func(cfg v1.VisitorConfigurer) (v1.VisitorConfigurer, error) {\n\t\t\t\tgotName = cfg.GetBaseConfig().Name\n\t\t\t\treturn cfg, nil\n\t\t\t},\n\t\t},\n\t}\n\n\tbody := []byte(`{\n\t\t\t\"name\":\"raw-visitor\",\"type\":\"xtcp\",\"unexpected\":\"value\",\n\t\t\t\"xtcp\":{\"serverName\":\"server\",\"bindPort\":10081,\"secretKey\":\"secret\",\"unknownInBlock\":\"value\"}\n\t\t}`)\n\treq := httptest.NewRequest(http.MethodPost, \"/api/store/visitors\", bytes.NewReader(body))\n\tctx := httppkg.NewContext(httptest.NewRecorder(), req)\n\n\tresp, err := controller.CreateStoreVisitor(ctx)\n\tif err != nil {\n\t\tt.Fatalf(\"create store visitor: %v\", err)\n\t}\n\tif gotName != \"raw-visitor\" {\n\t\tt.Fatalf(\"unexpected visitor name: %q\", gotName)\n\t}\n\n\tpayload, ok := resp.(model.VisitorDefinition)\n\tif !ok {\n\t\tt.Fatalf(\"unexpected response type: %T\", resp)\n\t}\n\tif payload.Type != \"xtcp\" || payload.XTCP == nil {\n\t\tt.Fatalf(\"unexpected payload: %#v\", payload)\n\t}\n}\n\nfunc TestCreateStoreProxyPluginUnknownFieldsAreIgnored(t *testing.T) {\n\tvar gotPluginType string\n\tcontroller := &Controller{\n\t\tmanager: &fakeConfigManager{\n\t\t\tcreateStoreProxyFn: func(cfg v1.ProxyConfigurer) (v1.ProxyConfigurer, error) {\n\t\t\t\tgotPluginType = cfg.GetBaseConfig().Plugin.Type\n\t\t\t\treturn cfg, nil\n\t\t\t},\n\t\t},\n\t}\n\n\tbody := []byte(`{\"name\":\"plugin-proxy\",\"type\":\"tcp\",\"tcp\":{\"plugin\":{\"type\":\"http2https\",\"localAddr\":\"127.0.0.1:8080\",\"unknownInPlugin\":\"value\"}}}`)\n\treq := httptest.NewRequest(http.MethodPost, \"/api/store/proxies\", bytes.NewReader(body))\n\tctx := httppkg.NewContext(httptest.NewRecorder(), req)\n\n\tresp, err := controller.CreateStoreProxy(ctx)\n\tif err != nil {\n\t\tt.Fatalf(\"create store proxy: %v\", err)\n\t}\n\tif gotPluginType != \"http2https\" {\n\t\tt.Fatalf(\"unexpected plugin type: %q\", gotPluginType)\n\t}\n\tpayload, ok := resp.(model.ProxyDefinition)\n\tif !ok {\n\t\tt.Fatalf(\"unexpected response type: %T\", resp)\n\t}\n\tif payload.TCP == nil {\n\t\tt.Fatalf(\"unexpected response payload: %#v\", payload)\n\t}\n\tpluginType := payload.TCP.Plugin.Type\n\n\tif pluginType != \"http2https\" {\n\t\tt.Fatalf(\"unexpected plugin type in response payload: %q\", pluginType)\n\t}\n}\n\nfunc TestCreateStoreVisitorPluginUnknownFieldsAreIgnored(t *testing.T) {\n\tvar gotPluginType string\n\tcontroller := &Controller{\n\t\tmanager: &fakeConfigManager{\n\t\t\tcreateStoreVisitFn: func(cfg v1.VisitorConfigurer) (v1.VisitorConfigurer, error) {\n\t\t\t\tgotPluginType = cfg.GetBaseConfig().Plugin.Type\n\t\t\t\treturn cfg, nil\n\t\t\t},\n\t\t},\n\t}\n\n\tbody := []byte(`{\n\t\t\t\"name\":\"plugin-visitor\",\"type\":\"stcp\",\n\t\t\t\"stcp\":{\"serverName\":\"server\",\"bindPort\":10081,\"plugin\":{\"type\":\"virtual_net\",\"destinationIP\":\"10.0.0.1\",\"unknownInPlugin\":\"value\"}}\n\t\t}`)\n\treq := httptest.NewRequest(http.MethodPost, \"/api/store/visitors\", bytes.NewReader(body))\n\tctx := httppkg.NewContext(httptest.NewRecorder(), req)\n\n\tresp, err := controller.CreateStoreVisitor(ctx)\n\tif err != nil {\n\t\tt.Fatalf(\"create store visitor: %v\", err)\n\t}\n\tif gotPluginType != \"virtual_net\" {\n\t\tt.Fatalf(\"unexpected plugin type: %q\", gotPluginType)\n\t}\n\tpayload, ok := resp.(model.VisitorDefinition)\n\tif !ok {\n\t\tt.Fatalf(\"unexpected response type: %T\", resp)\n\t}\n\tif payload.STCP == nil {\n\t\tt.Fatalf(\"unexpected response payload: %#v\", payload)\n\t}\n\tpluginType := payload.STCP.Plugin.Type\n\n\tif pluginType != \"virtual_net\" {\n\t\tt.Fatalf(\"unexpected plugin type in response payload: %q\", pluginType)\n\t}\n}\n\nfunc TestUpdateStoreProxyRejectsMismatchedTypeBlock(t *testing.T) {\n\tcontroller := &Controller{manager: &fakeConfigManager{}}\n\tbody := []byte(`{\"name\":\"p1\",\"type\":\"tcp\",\"udp\":{\"localPort\":10080}}`)\n\treq := httptest.NewRequest(http.MethodPut, \"/api/store/proxies/p1\", bytes.NewReader(body))\n\treq = mux.SetURLVars(req, map[string]string{\"name\": \"p1\"})\n\tctx := httppkg.NewContext(httptest.NewRecorder(), req)\n\n\t_, err := controller.UpdateStoreProxy(ctx)\n\tif err == nil {\n\t\tt.Fatal(\"expected error\")\n\t}\n\tassertHTTPCode(t, err, http.StatusBadRequest)\n}\n\nfunc TestUpdateStoreProxyRejectsNameMismatch(t *testing.T) {\n\tcontroller := &Controller{manager: &fakeConfigManager{}}\n\tbody := []byte(`{\"name\":\"p2\",\"type\":\"tcp\",\"tcp\":{\"localPort\":10080}}`)\n\treq := httptest.NewRequest(http.MethodPut, \"/api/store/proxies/p1\", bytes.NewReader(body))\n\treq = mux.SetURLVars(req, map[string]string{\"name\": \"p1\"})\n\tctx := httppkg.NewContext(httptest.NewRecorder(), req)\n\n\t_, err := controller.UpdateStoreProxy(ctx)\n\tif err == nil {\n\t\tt.Fatal(\"expected error\")\n\t}\n\tassertHTTPCode(t, err, http.StatusBadRequest)\n}\n\nfunc TestListStoreProxiesReturnsSortedPayload(t *testing.T) {\n\tcontroller := &Controller{\n\t\tmanager: &fakeConfigManager{\n\t\t\tlistStoreProxiesFn: func() ([]v1.ProxyConfigurer, error) {\n\t\t\t\tb := newRawTCPProxyConfig(\"b\")\n\t\t\t\ta := newRawTCPProxyConfig(\"a\")\n\t\t\t\treturn []v1.ProxyConfigurer{b, a}, nil\n\t\t\t},\n\t\t},\n\t}\n\tctx := httppkg.NewContext(httptest.NewRecorder(), httptest.NewRequest(http.MethodGet, \"/api/store/proxies\", nil))\n\n\tresp, err := controller.ListStoreProxies(ctx)\n\tif err != nil {\n\t\tt.Fatalf(\"list store proxies: %v\", err)\n\t}\n\tout, ok := resp.(model.ProxyListResp)\n\tif !ok {\n\t\tt.Fatalf(\"unexpected response type: %T\", resp)\n\t}\n\tif len(out.Proxies) != 2 {\n\t\tt.Fatalf(\"unexpected proxy count: %d\", len(out.Proxies))\n\t}\n\tif out.Proxies[0].Name != \"a\" || out.Proxies[1].Name != \"b\" {\n\t\tt.Fatalf(\"proxies are not sorted by name: %#v\", out.Proxies)\n\t}\n}\n\nfunc fmtError(sentinel error, msg string) error {\n\treturn fmt.Errorf(\"%w: %s\", sentinel, msg)\n}\n\nfunc assertHTTPCode(t *testing.T, err error, expected int) {\n\tt.Helper()\n\tvar httpErr *httppkg.Error\n\tif !errors.As(err, &httpErr) {\n\t\tt.Fatalf(\"unexpected error type: %T\", err)\n\t}\n\tif httpErr.Code != expected {\n\t\tt.Fatalf(\"unexpected status code: got %d, want %d\", httpErr.Code, expected)\n\t}\n}\n\nfunc TestUpdateStoreProxyReturnsTypedPayload(t *testing.T) {\n\tcontroller := &Controller{\n\t\tmanager: &fakeConfigManager{\n\t\t\tupdateStoreProxyFn: func(_ string, cfg v1.ProxyConfigurer) (v1.ProxyConfigurer, error) {\n\t\t\t\treturn cfg, nil\n\t\t\t},\n\t\t},\n\t}\n\n\tbody := map[string]any{\n\t\t\"name\": \"shared-proxy\",\n\t\t\"type\": \"tcp\",\n\t\t\"tcp\": map[string]any{\n\t\t\t\"localPort\":  10080,\n\t\t\t\"remotePort\": 7000,\n\t\t},\n\t}\n\tdata, err := json.Marshal(body)\n\tif err != nil {\n\t\tt.Fatalf(\"marshal request: %v\", err)\n\t}\n\n\treq := httptest.NewRequest(http.MethodPut, \"/api/store/proxies/shared-proxy\", bytes.NewReader(data))\n\treq = mux.SetURLVars(req, map[string]string{\"name\": \"shared-proxy\"})\n\tctx := httppkg.NewContext(httptest.NewRecorder(), req)\n\n\tresp, err := controller.UpdateStoreProxy(ctx)\n\tif err != nil {\n\t\tt.Fatalf(\"update store proxy: %v\", err)\n\t}\n\tpayload, ok := resp.(model.ProxyDefinition)\n\tif !ok {\n\t\tt.Fatalf(\"unexpected response type: %T\", resp)\n\t}\n\tif payload.TCP == nil || payload.TCP.RemotePort != 7000 {\n\t\tt.Fatalf(\"unexpected response payload: %#v\", payload)\n\t}\n}\n\nfunc TestGetProxyConfigFromManager(t *testing.T) {\n\tcontroller := &Controller{\n\t\tmanager: &fakeConfigManager{\n\t\t\tgetProxyConfigFn: func(name string) (v1.ProxyConfigurer, bool) {\n\t\t\t\tif name == \"ssh\" {\n\t\t\t\t\tcfg := &v1.TCPProxyConfig{\n\t\t\t\t\t\tProxyBaseConfig: v1.ProxyBaseConfig{\n\t\t\t\t\t\t\tName: \"ssh\",\n\t\t\t\t\t\t\tType: \"tcp\",\n\t\t\t\t\t\t\tProxyBackend: v1.ProxyBackend{\n\t\t\t\t\t\t\t\tLocalPort: 22,\n\t\t\t\t\t\t\t},\n\t\t\t\t\t\t},\n\t\t\t\t\t}\n\t\t\t\t\treturn cfg, true\n\t\t\t\t}\n\t\t\t\treturn nil, false\n\t\t\t},\n\t\t},\n\t}\n\n\treq := httptest.NewRequest(http.MethodGet, \"/api/proxy/ssh/config\", nil)\n\treq = mux.SetURLVars(req, map[string]string{\"name\": \"ssh\"})\n\tctx := httppkg.NewContext(httptest.NewRecorder(), req)\n\n\tresp, err := controller.GetProxyConfig(ctx)\n\tif err != nil {\n\t\tt.Fatalf(\"get proxy config: %v\", err)\n\t}\n\tpayload, ok := resp.(model.ProxyDefinition)\n\tif !ok {\n\t\tt.Fatalf(\"unexpected response type: %T\", resp)\n\t}\n\tif payload.Name != \"ssh\" || payload.Type != \"tcp\" || payload.TCP == nil {\n\t\tt.Fatalf(\"unexpected payload: %#v\", payload)\n\t}\n}\n\nfunc TestGetProxyConfigNotFound(t *testing.T) {\n\tcontroller := &Controller{\n\t\tmanager: &fakeConfigManager{\n\t\t\tgetProxyConfigFn: func(name string) (v1.ProxyConfigurer, bool) {\n\t\t\t\treturn nil, false\n\t\t\t},\n\t\t},\n\t}\n\n\treq := httptest.NewRequest(http.MethodGet, \"/api/proxy/missing/config\", nil)\n\treq = mux.SetURLVars(req, map[string]string{\"name\": \"missing\"})\n\tctx := httppkg.NewContext(httptest.NewRecorder(), req)\n\n\t_, err := controller.GetProxyConfig(ctx)\n\tif err == nil {\n\t\tt.Fatal(\"expected error\")\n\t}\n\tassertHTTPCode(t, err, http.StatusNotFound)\n}\n\nfunc TestGetVisitorConfigFromManager(t *testing.T) {\n\tcontroller := &Controller{\n\t\tmanager: &fakeConfigManager{\n\t\t\tgetVisitorConfigFn: func(name string) (v1.VisitorConfigurer, bool) {\n\t\t\t\tif name == \"my-stcp\" {\n\t\t\t\t\tcfg := &v1.STCPVisitorConfig{\n\t\t\t\t\t\tVisitorBaseConfig: v1.VisitorBaseConfig{\n\t\t\t\t\t\t\tName:       \"my-stcp\",\n\t\t\t\t\t\t\tType:       \"stcp\",\n\t\t\t\t\t\t\tServerName: \"server1\",\n\t\t\t\t\t\t\tBindPort:   9000,\n\t\t\t\t\t\t},\n\t\t\t\t\t}\n\t\t\t\t\treturn cfg, true\n\t\t\t\t}\n\t\t\t\treturn nil, false\n\t\t\t},\n\t\t},\n\t}\n\n\treq := httptest.NewRequest(http.MethodGet, \"/api/visitor/my-stcp/config\", nil)\n\treq = mux.SetURLVars(req, map[string]string{\"name\": \"my-stcp\"})\n\tctx := httppkg.NewContext(httptest.NewRecorder(), req)\n\n\tresp, err := controller.GetVisitorConfig(ctx)\n\tif err != nil {\n\t\tt.Fatalf(\"get visitor config: %v\", err)\n\t}\n\tpayload, ok := resp.(model.VisitorDefinition)\n\tif !ok {\n\t\tt.Fatalf(\"unexpected response type: %T\", resp)\n\t}\n\tif payload.Name != \"my-stcp\" || payload.Type != \"stcp\" || payload.STCP == nil {\n\t\tt.Fatalf(\"unexpected payload: %#v\", payload)\n\t}\n}\n\nfunc TestGetVisitorConfigNotFound(t *testing.T) {\n\tcontroller := &Controller{\n\t\tmanager: &fakeConfigManager{\n\t\t\tgetVisitorConfigFn: func(name string) (v1.VisitorConfigurer, bool) {\n\t\t\t\treturn nil, false\n\t\t\t},\n\t\t},\n\t}\n\n\treq := httptest.NewRequest(http.MethodGet, \"/api/visitor/missing/config\", nil)\n\treq = mux.SetURLVars(req, map[string]string{\"name\": \"missing\"})\n\tctx := httppkg.NewContext(httptest.NewRecorder(), req)\n\n\t_, err := controller.GetVisitorConfig(ctx)\n\tif err == nil {\n\t\tt.Fatal(\"expected error\")\n\t}\n\tassertHTTPCode(t, err, http.StatusNotFound)\n}\n"
  },
  {
    "path": "client/http/model/proxy_definition.go",
    "content": "package model\n\nimport (\n\t\"fmt\"\n\t\"strings\"\n\n\tv1 \"github.com/fatedier/frp/pkg/config/v1\"\n)\n\ntype ProxyDefinition struct {\n\tName string `json:\"name\"`\n\tType string `json:\"type\"`\n\n\tTCP    *v1.TCPProxyConfig    `json:\"tcp,omitempty\"`\n\tUDP    *v1.UDPProxyConfig    `json:\"udp,omitempty\"`\n\tHTTP   *v1.HTTPProxyConfig   `json:\"http,omitempty\"`\n\tHTTPS  *v1.HTTPSProxyConfig  `json:\"https,omitempty\"`\n\tTCPMux *v1.TCPMuxProxyConfig `json:\"tcpmux,omitempty\"`\n\tSTCP   *v1.STCPProxyConfig   `json:\"stcp,omitempty\"`\n\tSUDP   *v1.SUDPProxyConfig   `json:\"sudp,omitempty\"`\n\tXTCP   *v1.XTCPProxyConfig   `json:\"xtcp,omitempty\"`\n}\n\nfunc (p *ProxyDefinition) Validate(pathName string, isUpdate bool) error {\n\tif strings.TrimSpace(p.Name) == \"\" {\n\t\treturn fmt.Errorf(\"proxy name is required\")\n\t}\n\tif !IsProxyType(p.Type) {\n\t\treturn fmt.Errorf(\"invalid proxy type: %s\", p.Type)\n\t}\n\tif isUpdate && pathName != \"\" && pathName != p.Name {\n\t\treturn fmt.Errorf(\"proxy name in URL must match name in body\")\n\t}\n\n\t_, blockType, blockCount := p.activeBlock()\n\tif blockCount != 1 {\n\t\treturn fmt.Errorf(\"exactly one proxy type block is required\")\n\t}\n\tif blockType != p.Type {\n\t\treturn fmt.Errorf(\"proxy type block %q does not match type %q\", blockType, p.Type)\n\t}\n\treturn nil\n}\n\nfunc (p *ProxyDefinition) ToConfigurer() (v1.ProxyConfigurer, error) {\n\tblock, _, _ := p.activeBlock()\n\tif block == nil {\n\t\treturn nil, fmt.Errorf(\"exactly one proxy type block is required\")\n\t}\n\n\tcfg := block\n\tcfg.GetBaseConfig().Name = p.Name\n\tcfg.GetBaseConfig().Type = p.Type\n\treturn cfg, nil\n}\n\nfunc ProxyDefinitionFromConfigurer(cfg v1.ProxyConfigurer) (ProxyDefinition, error) {\n\tif cfg == nil {\n\t\treturn ProxyDefinition{}, fmt.Errorf(\"proxy config is nil\")\n\t}\n\n\tbase := cfg.GetBaseConfig()\n\tpayload := ProxyDefinition{\n\t\tName: base.Name,\n\t\tType: base.Type,\n\t}\n\n\tswitch c := cfg.(type) {\n\tcase *v1.TCPProxyConfig:\n\t\tpayload.TCP = c\n\tcase *v1.UDPProxyConfig:\n\t\tpayload.UDP = c\n\tcase *v1.HTTPProxyConfig:\n\t\tpayload.HTTP = c\n\tcase *v1.HTTPSProxyConfig:\n\t\tpayload.HTTPS = c\n\tcase *v1.TCPMuxProxyConfig:\n\t\tpayload.TCPMux = c\n\tcase *v1.STCPProxyConfig:\n\t\tpayload.STCP = c\n\tcase *v1.SUDPProxyConfig:\n\t\tpayload.SUDP = c\n\tcase *v1.XTCPProxyConfig:\n\t\tpayload.XTCP = c\n\tdefault:\n\t\treturn ProxyDefinition{}, fmt.Errorf(\"unsupported proxy configurer type %T\", cfg)\n\t}\n\n\treturn payload, nil\n}\n\nfunc (p *ProxyDefinition) activeBlock() (v1.ProxyConfigurer, string, int) {\n\tcount := 0\n\tvar block v1.ProxyConfigurer\n\tvar blockType string\n\n\tif p.TCP != nil {\n\t\tcount++\n\t\tblock = p.TCP\n\t\tblockType = \"tcp\"\n\t}\n\tif p.UDP != nil {\n\t\tcount++\n\t\tblock = p.UDP\n\t\tblockType = \"udp\"\n\t}\n\tif p.HTTP != nil {\n\t\tcount++\n\t\tblock = p.HTTP\n\t\tblockType = \"http\"\n\t}\n\tif p.HTTPS != nil {\n\t\tcount++\n\t\tblock = p.HTTPS\n\t\tblockType = \"https\"\n\t}\n\tif p.TCPMux != nil {\n\t\tcount++\n\t\tblock = p.TCPMux\n\t\tblockType = \"tcpmux\"\n\t}\n\tif p.STCP != nil {\n\t\tcount++\n\t\tblock = p.STCP\n\t\tblockType = \"stcp\"\n\t}\n\tif p.SUDP != nil {\n\t\tcount++\n\t\tblock = p.SUDP\n\t\tblockType = \"sudp\"\n\t}\n\tif p.XTCP != nil {\n\t\tcount++\n\t\tblock = p.XTCP\n\t\tblockType = \"xtcp\"\n\t}\n\n\treturn block, blockType, count\n}\n\nfunc IsProxyType(typ string) bool {\n\tswitch typ {\n\tcase \"tcp\", \"udp\", \"http\", \"https\", \"tcpmux\", \"stcp\", \"sudp\", \"xtcp\":\n\t\treturn true\n\tdefault:\n\t\treturn false\n\t}\n}\n"
  },
  {
    "path": "client/http/model/types.go",
    "content": "// Copyright 2025 The frp Authors\n//\n// Licensed under the Apache License, Version 2.0 (the \"License\");\n// you may not use this file except in compliance with the License.\n// You may obtain a copy of the License at\n//\n//     http://www.apache.org/licenses/LICENSE-2.0\n//\n// Unless required by applicable law or agreed to in writing, software\n// distributed under the License is distributed on an \"AS IS\" BASIS,\n// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.\n// See the License for the specific language governing permissions and\n// limitations under the License.\n\npackage model\n\nconst SourceStore = \"store\"\n\n// StatusResp is the response for GET /api/status\ntype StatusResp map[string][]ProxyStatusResp\n\n// ProxyStatusResp contains proxy status information\ntype ProxyStatusResp struct {\n\tName       string `json:\"name\"`\n\tType       string `json:\"type\"`\n\tStatus     string `json:\"status\"`\n\tErr        string `json:\"err\"`\n\tLocalAddr  string `json:\"local_addr\"`\n\tPlugin     string `json:\"plugin\"`\n\tRemoteAddr string `json:\"remote_addr\"`\n\tSource     string `json:\"source,omitempty\"` // \"store\" or \"config\"\n}\n\n// ProxyListResp is the response for GET /api/store/proxies\ntype ProxyListResp struct {\n\tProxies []ProxyDefinition `json:\"proxies\"`\n}\n\n// VisitorListResp is the response for GET /api/store/visitors\ntype VisitorListResp struct {\n\tVisitors []VisitorDefinition `json:\"visitors\"`\n}\n"
  },
  {
    "path": "client/http/model/visitor_definition.go",
    "content": "package model\n\nimport (\n\t\"fmt\"\n\t\"strings\"\n\n\tv1 \"github.com/fatedier/frp/pkg/config/v1\"\n)\n\ntype VisitorDefinition struct {\n\tName string `json:\"name\"`\n\tType string `json:\"type\"`\n\n\tSTCP *v1.STCPVisitorConfig `json:\"stcp,omitempty\"`\n\tSUDP *v1.SUDPVisitorConfig `json:\"sudp,omitempty\"`\n\tXTCP *v1.XTCPVisitorConfig `json:\"xtcp,omitempty\"`\n}\n\nfunc (p *VisitorDefinition) Validate(pathName string, isUpdate bool) error {\n\tif strings.TrimSpace(p.Name) == \"\" {\n\t\treturn fmt.Errorf(\"visitor name is required\")\n\t}\n\tif !IsVisitorType(p.Type) {\n\t\treturn fmt.Errorf(\"invalid visitor type: %s\", p.Type)\n\t}\n\tif isUpdate && pathName != \"\" && pathName != p.Name {\n\t\treturn fmt.Errorf(\"visitor name in URL must match name in body\")\n\t}\n\n\t_, blockType, blockCount := p.activeBlock()\n\tif blockCount != 1 {\n\t\treturn fmt.Errorf(\"exactly one visitor type block is required\")\n\t}\n\tif blockType != p.Type {\n\t\treturn fmt.Errorf(\"visitor type block %q does not match type %q\", blockType, p.Type)\n\t}\n\treturn nil\n}\n\nfunc (p *VisitorDefinition) ToConfigurer() (v1.VisitorConfigurer, error) {\n\tblock, _, _ := p.activeBlock()\n\tif block == nil {\n\t\treturn nil, fmt.Errorf(\"exactly one visitor type block is required\")\n\t}\n\n\tcfg := block\n\tcfg.GetBaseConfig().Name = p.Name\n\tcfg.GetBaseConfig().Type = p.Type\n\treturn cfg, nil\n}\n\nfunc VisitorDefinitionFromConfigurer(cfg v1.VisitorConfigurer) (VisitorDefinition, error) {\n\tif cfg == nil {\n\t\treturn VisitorDefinition{}, fmt.Errorf(\"visitor config is nil\")\n\t}\n\n\tbase := cfg.GetBaseConfig()\n\tpayload := VisitorDefinition{\n\t\tName: base.Name,\n\t\tType: base.Type,\n\t}\n\n\tswitch c := cfg.(type) {\n\tcase *v1.STCPVisitorConfig:\n\t\tpayload.STCP = c\n\tcase *v1.SUDPVisitorConfig:\n\t\tpayload.SUDP = c\n\tcase *v1.XTCPVisitorConfig:\n\t\tpayload.XTCP = c\n\tdefault:\n\t\treturn VisitorDefinition{}, fmt.Errorf(\"unsupported visitor configurer type %T\", cfg)\n\t}\n\n\treturn payload, nil\n}\n\nfunc (p *VisitorDefinition) activeBlock() (v1.VisitorConfigurer, string, int) {\n\tcount := 0\n\tvar block v1.VisitorConfigurer\n\tvar blockType string\n\n\tif p.STCP != nil {\n\t\tcount++\n\t\tblock = p.STCP\n\t\tblockType = \"stcp\"\n\t}\n\tif p.SUDP != nil {\n\t\tcount++\n\t\tblock = p.SUDP\n\t\tblockType = \"sudp\"\n\t}\n\tif p.XTCP != nil {\n\t\tcount++\n\t\tblock = p.XTCP\n\t\tblockType = \"xtcp\"\n\t}\n\treturn block, blockType, count\n}\n\nfunc IsVisitorType(typ string) bool {\n\tswitch typ {\n\tcase \"stcp\", \"sudp\", \"xtcp\":\n\t\treturn true\n\tdefault:\n\t\treturn false\n\t}\n}\n"
  },
  {
    "path": "client/proxy/general_tcp.go",
    "content": "// Copyright 2023 The frp Authors\n//\n// Licensed under the Apache License, Version 2.0 (the \"License\");\n// you may not use this file except in compliance with the License.\n// You may obtain a copy of the License at\n//\n//     http://www.apache.org/licenses/LICENSE-2.0\n//\n// Unless required by applicable law or agreed to in writing, software\n// distributed under the License is distributed on an \"AS IS\" BASIS,\n// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.\n// See the License for the specific language governing permissions and\n// limitations under the License.\n\npackage proxy\n\nimport (\n\t\"reflect\"\n\n\tv1 \"github.com/fatedier/frp/pkg/config/v1\"\n)\n\nfunc init() {\n\tpxyConfs := []v1.ProxyConfigurer{\n\t\t&v1.TCPProxyConfig{},\n\t\t&v1.HTTPProxyConfig{},\n\t\t&v1.HTTPSProxyConfig{},\n\t\t&v1.STCPProxyConfig{},\n\t\t&v1.TCPMuxProxyConfig{},\n\t}\n\tfor _, cfg := range pxyConfs {\n\t\tRegisterProxyFactory(reflect.TypeOf(cfg), NewGeneralTCPProxy)\n\t}\n}\n\n// GeneralTCPProxy is a general implementation of Proxy interface for TCP protocol.\n// If the default GeneralTCPProxy cannot meet the requirements, you can customize\n// the implementation of the Proxy interface.\ntype GeneralTCPProxy struct {\n\t*BaseProxy\n}\n\nfunc NewGeneralTCPProxy(baseProxy *BaseProxy, _ v1.ProxyConfigurer) Proxy {\n\treturn &GeneralTCPProxy{\n\t\tBaseProxy: baseProxy,\n\t}\n}\n"
  },
  {
    "path": "client/proxy/proxy.go",
    "content": "// Copyright 2017 fatedier, fatedier@gmail.com\n//\n// Licensed under the Apache License, Version 2.0 (the \"License\");\n// you may not use this file except in compliance with the License.\n// You may obtain a copy of the License at\n//\n//     http://www.apache.org/licenses/LICENSE-2.0\n//\n// Unless required by applicable law or agreed to in writing, software\n// distributed under the License is distributed on an \"AS IS\" BASIS,\n// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.\n// See the License for the specific language governing permissions and\n// limitations under the License.\n\npackage proxy\n\nimport (\n\t\"context\"\n\t\"fmt\"\n\t\"io\"\n\t\"net\"\n\t\"reflect\"\n\t\"strconv\"\n\t\"sync\"\n\t\"time\"\n\n\tlibio \"github.com/fatedier/golib/io\"\n\tlibnet \"github.com/fatedier/golib/net\"\n\t\"golang.org/x/time/rate\"\n\n\t\"github.com/fatedier/frp/pkg/config/types\"\n\tv1 \"github.com/fatedier/frp/pkg/config/v1\"\n\t\"github.com/fatedier/frp/pkg/msg\"\n\tplugin \"github.com/fatedier/frp/pkg/plugin/client\"\n\t\"github.com/fatedier/frp/pkg/transport\"\n\t\"github.com/fatedier/frp/pkg/util/limit\"\n\tnetpkg \"github.com/fatedier/frp/pkg/util/net\"\n\t\"github.com/fatedier/frp/pkg/util/xlog\"\n\t\"github.com/fatedier/frp/pkg/vnet\"\n)\n\nvar proxyFactoryRegistry = map[reflect.Type]func(*BaseProxy, v1.ProxyConfigurer) Proxy{}\n\nfunc RegisterProxyFactory(proxyConfType reflect.Type, factory func(*BaseProxy, v1.ProxyConfigurer) Proxy) {\n\tproxyFactoryRegistry[proxyConfType] = factory\n}\n\n// Proxy defines how to handle work connections for different proxy type.\ntype Proxy interface {\n\tRun() error\n\t// InWorkConn accept work connections registered to server.\n\tInWorkConn(net.Conn, *msg.StartWorkConn)\n\tSetInWorkConnCallback(func(*v1.ProxyBaseConfig, net.Conn, *msg.StartWorkConn) /* continue */ bool)\n\tClose()\n}\n\nfunc NewProxy(\n\tctx context.Context,\n\tpxyConf v1.ProxyConfigurer,\n\tclientCfg *v1.ClientCommonConfig,\n\tencryptionKey []byte,\n\tmsgTransporter transport.MessageTransporter,\n\tvnetController *vnet.Controller,\n) (pxy Proxy) {\n\tvar limiter *rate.Limiter\n\tlimitBytes := pxyConf.GetBaseConfig().Transport.BandwidthLimit.Bytes()\n\tif limitBytes > 0 && pxyConf.GetBaseConfig().Transport.BandwidthLimitMode == types.BandwidthLimitModeClient {\n\t\tlimiter = rate.NewLimiter(rate.Limit(float64(limitBytes)), int(limitBytes))\n\t}\n\n\tbaseProxy := BaseProxy{\n\t\tbaseCfg:        pxyConf.GetBaseConfig(),\n\t\tclientCfg:      clientCfg,\n\t\tencryptionKey:  encryptionKey,\n\t\tlimiter:        limiter,\n\t\tmsgTransporter: msgTransporter,\n\t\tvnetController: vnetController,\n\t\txl:             xlog.FromContextSafe(ctx),\n\t\tctx:            ctx,\n\t}\n\n\tfactory := proxyFactoryRegistry[reflect.TypeOf(pxyConf)]\n\tif factory == nil {\n\t\treturn nil\n\t}\n\treturn factory(&baseProxy, pxyConf)\n}\n\ntype BaseProxy struct {\n\tbaseCfg        *v1.ProxyBaseConfig\n\tclientCfg      *v1.ClientCommonConfig\n\tencryptionKey  []byte\n\tmsgTransporter transport.MessageTransporter\n\tvnetController *vnet.Controller\n\tlimiter        *rate.Limiter\n\t// proxyPlugin is used to handle connections instead of dialing to local service.\n\t// It's only validate for TCP protocol now.\n\tproxyPlugin        plugin.Plugin\n\tinWorkConnCallback func(*v1.ProxyBaseConfig, net.Conn, *msg.StartWorkConn) /* continue */ bool\n\n\tmu  sync.RWMutex\n\txl  *xlog.Logger\n\tctx context.Context\n}\n\nfunc (pxy *BaseProxy) Run() error {\n\tif pxy.baseCfg.Plugin.Type != \"\" {\n\t\tp, err := plugin.Create(pxy.baseCfg.Plugin.Type, plugin.PluginContext{\n\t\t\tName:           pxy.baseCfg.Name,\n\t\t\tVnetController: pxy.vnetController,\n\t\t}, pxy.baseCfg.Plugin.ClientPluginOptions)\n\t\tif err != nil {\n\t\t\treturn err\n\t\t}\n\t\tpxy.proxyPlugin = p\n\t}\n\treturn nil\n}\n\nfunc (pxy *BaseProxy) Close() {\n\tif pxy.proxyPlugin != nil {\n\t\tpxy.proxyPlugin.Close()\n\t}\n}\n\n// wrapWorkConn applies rate limiting, encryption, and compression\n// to a work connection based on the proxy's transport configuration.\n// The returned recycle function should be called when the stream is no longer in use\n// to return compression resources to the pool. It is safe to not call recycle,\n// in which case resources will be garbage collected normally.\nfunc (pxy *BaseProxy) wrapWorkConn(conn net.Conn, encKey []byte) (io.ReadWriteCloser, func(), error) {\n\tvar rwc io.ReadWriteCloser = conn\n\tif pxy.limiter != nil {\n\t\trwc = libio.WrapReadWriteCloser(limit.NewReader(conn, pxy.limiter), limit.NewWriter(conn, pxy.limiter), func() error {\n\t\t\treturn conn.Close()\n\t\t})\n\t}\n\tif pxy.baseCfg.Transport.UseEncryption {\n\t\tvar err error\n\t\trwc, err = libio.WithEncryption(rwc, encKey)\n\t\tif err != nil {\n\t\t\tconn.Close()\n\t\t\treturn nil, nil, fmt.Errorf(\"create encryption stream error: %w\", err)\n\t\t}\n\t}\n\tvar recycleFn func()\n\tif pxy.baseCfg.Transport.UseCompression {\n\t\trwc, recycleFn = libio.WithCompressionFromPool(rwc)\n\t}\n\treturn rwc, recycleFn, nil\n}\n\nfunc (pxy *BaseProxy) SetInWorkConnCallback(cb func(*v1.ProxyBaseConfig, net.Conn, *msg.StartWorkConn) bool) {\n\tpxy.inWorkConnCallback = cb\n}\n\nfunc (pxy *BaseProxy) InWorkConn(conn net.Conn, m *msg.StartWorkConn) {\n\tif pxy.inWorkConnCallback != nil {\n\t\tif !pxy.inWorkConnCallback(pxy.baseCfg, conn, m) {\n\t\t\treturn\n\t\t}\n\t}\n\tpxy.HandleTCPWorkConnection(conn, m, pxy.encryptionKey)\n}\n\n// Common handler for tcp work connections.\nfunc (pxy *BaseProxy) HandleTCPWorkConnection(workConn net.Conn, m *msg.StartWorkConn, encKey []byte) {\n\txl := pxy.xl\n\tbaseCfg := pxy.baseCfg\n\n\txl.Tracef(\"handle tcp work connection, useEncryption: %t, useCompression: %t\",\n\t\tbaseCfg.Transport.UseEncryption, baseCfg.Transport.UseCompression)\n\n\tremote, recycleFn, err := pxy.wrapWorkConn(workConn, encKey)\n\tif err != nil {\n\t\txl.Errorf(\"wrap work connection: %v\", err)\n\t\treturn\n\t}\n\n\t// check if we need to send proxy protocol info\n\tvar connInfo plugin.ConnectionInfo\n\tif m.SrcAddr != \"\" && m.SrcPort != 0 {\n\t\tif m.DstAddr == \"\" {\n\t\t\tm.DstAddr = \"127.0.0.1\"\n\t\t}\n\t\tsrcAddr, _ := net.ResolveTCPAddr(\"tcp\", net.JoinHostPort(m.SrcAddr, strconv.Itoa(int(m.SrcPort))))\n\t\tdstAddr, _ := net.ResolveTCPAddr(\"tcp\", net.JoinHostPort(m.DstAddr, strconv.Itoa(int(m.DstPort))))\n\t\tconnInfo.SrcAddr = srcAddr\n\t\tconnInfo.DstAddr = dstAddr\n\t}\n\n\tif baseCfg.Transport.ProxyProtocolVersion != \"\" && m.SrcAddr != \"\" && m.SrcPort != 0 {\n\t\theader := netpkg.BuildProxyProtocolHeaderStruct(connInfo.SrcAddr, connInfo.DstAddr, baseCfg.Transport.ProxyProtocolVersion)\n\t\tconnInfo.ProxyProtocolHeader = header\n\t}\n\tconnInfo.Conn = remote\n\tconnInfo.UnderlyingConn = workConn\n\n\tif pxy.proxyPlugin != nil {\n\t\t// if plugin is set, let plugin handle connection first\n\t\t// Don't recycle compression resources here because plugins may\n\t\t// retain the connection after Handle returns.\n\t\txl.Debugf(\"handle by plugin: %s\", pxy.proxyPlugin.Name())\n\t\tpxy.proxyPlugin.Handle(pxy.ctx, &connInfo)\n\t\txl.Debugf(\"handle by plugin finished\")\n\t\treturn\n\t}\n\n\tif recycleFn != nil {\n\t\tdefer recycleFn()\n\t}\n\n\tlocalConn, err := libnet.Dial(\n\t\tnet.JoinHostPort(baseCfg.LocalIP, strconv.Itoa(baseCfg.LocalPort)),\n\t\tlibnet.WithTimeout(10*time.Second),\n\t)\n\tif err != nil {\n\t\tworkConn.Close()\n\t\txl.Errorf(\"connect to local service [%s:%d] error: %v\", baseCfg.LocalIP, baseCfg.LocalPort, err)\n\t\treturn\n\t}\n\n\txl.Debugf(\"join connections, localConn(l[%s] r[%s]) workConn(l[%s] r[%s])\", localConn.LocalAddr().String(),\n\t\tlocalConn.RemoteAddr().String(), workConn.LocalAddr().String(), workConn.RemoteAddr().String())\n\n\tif connInfo.ProxyProtocolHeader != nil {\n\t\tif _, err := connInfo.ProxyProtocolHeader.WriteTo(localConn); err != nil {\n\t\t\tworkConn.Close()\n\t\t\tlocalConn.Close()\n\t\t\txl.Errorf(\"write proxy protocol header to local conn error: %v\", err)\n\t\t\treturn\n\t\t}\n\t}\n\n\t_, _, errs := libio.Join(localConn, remote)\n\txl.Debugf(\"join connections closed\")\n\tif len(errs) > 0 {\n\t\txl.Tracef(\"join connections errors: %v\", errs)\n\t}\n}\n"
  },
  {
    "path": "client/proxy/proxy_manager.go",
    "content": "// Copyright 2023 The frp Authors\n//\n// Licensed under the Apache License, Version 2.0 (the \"License\");\n// you may not use this file except in compliance with the License.\n// You may obtain a copy of the License at\n//\n//     http://www.apache.org/licenses/LICENSE-2.0\n//\n// Unless required by applicable law or agreed to in writing, software\n// distributed under the License is distributed on an \"AS IS\" BASIS,\n// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.\n// See the License for the specific language governing permissions and\n// limitations under the License.\n\npackage proxy\n\nimport (\n\t\"context\"\n\t\"fmt\"\n\t\"net\"\n\t\"reflect\"\n\t\"sync\"\n\n\t\"github.com/samber/lo\"\n\n\t\"github.com/fatedier/frp/client/event\"\n\tv1 \"github.com/fatedier/frp/pkg/config/v1\"\n\t\"github.com/fatedier/frp/pkg/msg\"\n\t\"github.com/fatedier/frp/pkg/transport\"\n\t\"github.com/fatedier/frp/pkg/util/xlog\"\n\t\"github.com/fatedier/frp/pkg/vnet\"\n)\n\ntype Manager struct {\n\tproxies            map[string]*Wrapper\n\tmsgTransporter     transport.MessageTransporter\n\tinWorkConnCallback func(*v1.ProxyBaseConfig, net.Conn, *msg.StartWorkConn) bool\n\tvnetController     *vnet.Controller\n\n\tclosed bool\n\tmu     sync.RWMutex\n\n\tencryptionKey []byte\n\tclientCfg     *v1.ClientCommonConfig\n\n\tctx context.Context\n}\n\nfunc NewManager(\n\tctx context.Context,\n\tclientCfg *v1.ClientCommonConfig,\n\tencryptionKey []byte,\n\tmsgTransporter transport.MessageTransporter,\n\tvnetController *vnet.Controller,\n) *Manager {\n\treturn &Manager{\n\t\tproxies:        make(map[string]*Wrapper),\n\t\tmsgTransporter: msgTransporter,\n\t\tvnetController: vnetController,\n\t\tclosed:         false,\n\t\tencryptionKey:  encryptionKey,\n\t\tclientCfg:      clientCfg,\n\t\tctx:            ctx,\n\t}\n}\n\nfunc (pm *Manager) StartProxy(name string, remoteAddr string, serverRespErr string) error {\n\tpm.mu.RLock()\n\tpxy, ok := pm.proxies[name]\n\tpm.mu.RUnlock()\n\tif !ok {\n\t\treturn fmt.Errorf(\"proxy [%s] not found\", name)\n\t}\n\n\terr := pxy.SetRunningStatus(remoteAddr, serverRespErr)\n\tif err != nil {\n\t\treturn err\n\t}\n\treturn nil\n}\n\nfunc (pm *Manager) SetInWorkConnCallback(cb func(*v1.ProxyBaseConfig, net.Conn, *msg.StartWorkConn) bool) {\n\tpm.inWorkConnCallback = cb\n}\n\nfunc (pm *Manager) Close() {\n\tpm.mu.Lock()\n\tdefer pm.mu.Unlock()\n\tfor _, pxy := range pm.proxies {\n\t\tpxy.Stop()\n\t}\n\tpm.proxies = make(map[string]*Wrapper)\n}\n\nfunc (pm *Manager) HandleWorkConn(name string, workConn net.Conn, m *msg.StartWorkConn) {\n\tpm.mu.RLock()\n\tpw, ok := pm.proxies[name]\n\tpm.mu.RUnlock()\n\tif ok {\n\t\tpw.InWorkConn(workConn, m)\n\t} else {\n\t\tworkConn.Close()\n\t}\n}\n\nfunc (pm *Manager) HandleEvent(payload any) error {\n\tvar m msg.Message\n\tswitch e := payload.(type) {\n\tcase *event.StartProxyPayload:\n\t\tm = e.NewProxyMsg\n\tcase *event.CloseProxyPayload:\n\t\tm = e.CloseProxyMsg\n\tdefault:\n\t\treturn event.ErrPayloadType\n\t}\n\n\treturn pm.msgTransporter.Send(m)\n}\n\nfunc (pm *Manager) GetAllProxyStatus() []*WorkingStatus {\n\tpm.mu.RLock()\n\tdefer pm.mu.RUnlock()\n\tps := make([]*WorkingStatus, 0, len(pm.proxies))\n\tfor _, pxy := range pm.proxies {\n\t\tps = append(ps, pxy.GetStatus())\n\t}\n\treturn ps\n}\n\nfunc (pm *Manager) GetProxyStatus(name string) (*WorkingStatus, bool) {\n\tpm.mu.RLock()\n\tdefer pm.mu.RUnlock()\n\tif pxy, ok := pm.proxies[name]; ok {\n\t\treturn pxy.GetStatus(), true\n\t}\n\treturn nil, false\n}\n\nfunc (pm *Manager) UpdateAll(proxyCfgs []v1.ProxyConfigurer) {\n\txl := xlog.FromContextSafe(pm.ctx)\n\tproxyCfgsMap := lo.KeyBy(proxyCfgs, func(c v1.ProxyConfigurer) string {\n\t\treturn c.GetBaseConfig().Name\n\t})\n\tpm.mu.Lock()\n\tdefer pm.mu.Unlock()\n\n\tdelPxyNames := make([]string, 0)\n\tfor name, pxy := range pm.proxies {\n\t\tdel := false\n\t\tcfg, ok := proxyCfgsMap[name]\n\t\tif !ok || !reflect.DeepEqual(pxy.Cfg, cfg) {\n\t\t\tdel = true\n\t\t}\n\n\t\tif del {\n\t\t\tdelPxyNames = append(delPxyNames, name)\n\t\t\tdelete(pm.proxies, name)\n\t\t\tpxy.Stop()\n\t\t}\n\t}\n\tif len(delPxyNames) > 0 {\n\t\txl.Infof(\"proxy removed: %s\", delPxyNames)\n\t}\n\n\taddPxyNames := make([]string, 0)\n\tfor _, cfg := range proxyCfgs {\n\t\tname := cfg.GetBaseConfig().Name\n\t\tif _, ok := pm.proxies[name]; !ok {\n\t\t\tpxy := NewWrapper(pm.ctx, cfg, pm.clientCfg, pm.encryptionKey, pm.HandleEvent, pm.msgTransporter, pm.vnetController)\n\t\t\tif pm.inWorkConnCallback != nil {\n\t\t\t\tpxy.SetInWorkConnCallback(pm.inWorkConnCallback)\n\t\t\t}\n\t\t\tpm.proxies[name] = pxy\n\t\t\taddPxyNames = append(addPxyNames, name)\n\n\t\t\tpxy.Start()\n\t\t}\n\t}\n\tif len(addPxyNames) > 0 {\n\t\txl.Infof(\"proxy added: %s\", addPxyNames)\n\t}\n}\n"
  },
  {
    "path": "client/proxy/proxy_wrapper.go",
    "content": "// Copyright 2023 The frp Authors\n//\n// Licensed under the Apache License, Version 2.0 (the \"License\");\n// you may not use this file except in compliance with the License.\n// You may obtain a copy of the License at\n//\n//     http://www.apache.org/licenses/LICENSE-2.0\n//\n// Unless required by applicable law or agreed to in writing, software\n// distributed under the License is distributed on an \"AS IS\" BASIS,\n// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.\n// See the License for the specific language governing permissions and\n// limitations under the License.\n\npackage proxy\n\nimport (\n\t\"context\"\n\t\"fmt\"\n\t\"net\"\n\t\"strconv\"\n\t\"sync\"\n\t\"sync/atomic\"\n\t\"time\"\n\n\t\"github.com/fatedier/golib/errors\"\n\n\t\"github.com/fatedier/frp/client/event\"\n\t\"github.com/fatedier/frp/client/health\"\n\tv1 \"github.com/fatedier/frp/pkg/config/v1\"\n\t\"github.com/fatedier/frp/pkg/msg\"\n\t\"github.com/fatedier/frp/pkg/naming\"\n\t\"github.com/fatedier/frp/pkg/transport\"\n\t\"github.com/fatedier/frp/pkg/util/xlog\"\n\t\"github.com/fatedier/frp/pkg/vnet\"\n)\n\nconst (\n\tProxyPhaseNew         = \"new\"\n\tProxyPhaseWaitStart   = \"wait start\"\n\tProxyPhaseStartErr    = \"start error\"\n\tProxyPhaseRunning     = \"running\"\n\tProxyPhaseCheckFailed = \"check failed\"\n\tProxyPhaseClosed      = \"closed\"\n)\n\nvar (\n\tstatusCheckInterval = 3 * time.Second\n\twaitResponseTimeout = 20 * time.Second\n\tstartErrTimeout     = 30 * time.Second\n)\n\ntype WorkingStatus struct {\n\tName  string             `json:\"name\"`\n\tType  string             `json:\"type\"`\n\tPhase string             `json:\"status\"`\n\tErr   string             `json:\"err\"`\n\tCfg   v1.ProxyConfigurer `json:\"cfg\"`\n\n\t// Got from server.\n\tRemoteAddr string `json:\"remote_addr\"`\n}\n\ntype Wrapper struct {\n\tWorkingStatus\n\n\t// underlying proxy\n\tpxy Proxy\n\n\t// if ProxyConf has healcheck config\n\t// monitor will watch if it is alive\n\tmonitor *health.Monitor\n\n\t// event handler\n\thandler event.Handler\n\n\tmsgTransporter transport.MessageTransporter\n\t// vnet controller\n\tvnetController *vnet.Controller\n\n\thealth           uint32\n\tlastSendStartMsg time.Time\n\tlastStartErr     time.Time\n\tcloseCh          chan struct{}\n\thealthNotifyCh   chan struct{}\n\tmu               sync.RWMutex\n\n\txl  *xlog.Logger\n\tctx context.Context\n\n\twireName string\n}\n\nfunc NewWrapper(\n\tctx context.Context,\n\tcfg v1.ProxyConfigurer,\n\tclientCfg *v1.ClientCommonConfig,\n\tencryptionKey []byte,\n\teventHandler event.Handler,\n\tmsgTransporter transport.MessageTransporter,\n\tvnetController *vnet.Controller,\n) *Wrapper {\n\tbaseInfo := cfg.GetBaseConfig()\n\txl := xlog.FromContextSafe(ctx).Spawn().AppendPrefix(baseInfo.Name)\n\tpw := &Wrapper{\n\t\tWorkingStatus: WorkingStatus{\n\t\t\tName:  baseInfo.Name,\n\t\t\tType:  baseInfo.Type,\n\t\t\tPhase: ProxyPhaseNew,\n\t\t\tCfg:   cfg,\n\t\t},\n\t\tcloseCh:        make(chan struct{}),\n\t\thealthNotifyCh: make(chan struct{}),\n\t\thandler:        eventHandler,\n\t\tmsgTransporter: msgTransporter,\n\t\tvnetController: vnetController,\n\t\txl:             xl,\n\t\tctx:            xlog.NewContext(ctx, xl),\n\t\twireName:       naming.AddUserPrefix(clientCfg.User, baseInfo.Name),\n\t}\n\n\tif baseInfo.HealthCheck.Type != \"\" && baseInfo.LocalPort > 0 {\n\t\tpw.health = 1 // means failed\n\t\taddr := net.JoinHostPort(baseInfo.LocalIP, strconv.Itoa(baseInfo.LocalPort))\n\t\tpw.monitor = health.NewMonitor(pw.ctx, baseInfo.HealthCheck, addr,\n\t\t\tpw.statusNormalCallback, pw.statusFailedCallback)\n\t\txl.Tracef(\"enable health check monitor\")\n\t}\n\n\tpw.pxy = NewProxy(pw.ctx, pw.Cfg, clientCfg, encryptionKey, pw.msgTransporter, pw.vnetController)\n\treturn pw\n}\n\nfunc (pw *Wrapper) SetInWorkConnCallback(cb func(*v1.ProxyBaseConfig, net.Conn, *msg.StartWorkConn) bool) {\n\tpw.pxy.SetInWorkConnCallback(cb)\n}\n\nfunc (pw *Wrapper) SetRunningStatus(remoteAddr string, respErr string) error {\n\tpw.mu.Lock()\n\tdefer pw.mu.Unlock()\n\tif pw.Phase != ProxyPhaseWaitStart {\n\t\treturn fmt.Errorf(\"status not wait start, ignore start message\")\n\t}\n\n\tpw.RemoteAddr = remoteAddr\n\tif respErr != \"\" {\n\t\tpw.Phase = ProxyPhaseStartErr\n\t\tpw.Err = respErr\n\t\tpw.lastStartErr = time.Now()\n\t\treturn fmt.Errorf(\"%s\", pw.Err)\n\t}\n\n\tif err := pw.pxy.Run(); err != nil {\n\t\tpw.close()\n\t\tpw.Phase = ProxyPhaseStartErr\n\t\tpw.Err = err.Error()\n\t\tpw.lastStartErr = time.Now()\n\t\treturn err\n\t}\n\n\tpw.Phase = ProxyPhaseRunning\n\tpw.Err = \"\"\n\treturn nil\n}\n\nfunc (pw *Wrapper) Start() {\n\tgo pw.checkWorker()\n\tif pw.monitor != nil {\n\t\tgo pw.monitor.Start()\n\t}\n}\n\nfunc (pw *Wrapper) Stop() {\n\tpw.mu.Lock()\n\tdefer pw.mu.Unlock()\n\tclose(pw.closeCh)\n\tclose(pw.healthNotifyCh)\n\tpw.pxy.Close()\n\tif pw.monitor != nil {\n\t\tpw.monitor.Stop()\n\t}\n\tpw.Phase = ProxyPhaseClosed\n\tpw.close()\n}\n\nfunc (pw *Wrapper) close() {\n\t_ = pw.handler(&event.CloseProxyPayload{\n\t\tCloseProxyMsg: &msg.CloseProxy{\n\t\t\tProxyName: pw.wireName,\n\t\t},\n\t})\n}\n\nfunc (pw *Wrapper) checkWorker() {\n\txl := pw.xl\n\tif pw.monitor != nil {\n\t\t// let monitor do check request first\n\t\ttime.Sleep(500 * time.Millisecond)\n\t}\n\tfor {\n\t\t// check proxy status\n\t\tnow := time.Now()\n\t\tif atomic.LoadUint32(&pw.health) == 0 {\n\t\t\tpw.mu.Lock()\n\t\t\tif pw.Phase == ProxyPhaseNew ||\n\t\t\t\tpw.Phase == ProxyPhaseCheckFailed ||\n\t\t\t\t(pw.Phase == ProxyPhaseWaitStart && now.After(pw.lastSendStartMsg.Add(waitResponseTimeout))) ||\n\t\t\t\t(pw.Phase == ProxyPhaseStartErr && now.After(pw.lastStartErr.Add(startErrTimeout))) {\n\n\t\t\t\txl.Tracef(\"change status from [%s] to [%s]\", pw.Phase, ProxyPhaseWaitStart)\n\t\t\t\tpw.Phase = ProxyPhaseWaitStart\n\n\t\t\t\tvar newProxyMsg msg.NewProxy\n\t\t\t\tpw.Cfg.MarshalToMsg(&newProxyMsg)\n\t\t\t\tnewProxyMsg.ProxyName = pw.wireName\n\t\t\t\tpw.lastSendStartMsg = now\n\t\t\t\t_ = pw.handler(&event.StartProxyPayload{\n\t\t\t\t\tNewProxyMsg: &newProxyMsg,\n\t\t\t\t})\n\t\t\t}\n\t\t\tpw.mu.Unlock()\n\t\t} else {\n\t\t\tpw.mu.Lock()\n\t\t\tif pw.Phase == ProxyPhaseRunning || pw.Phase == ProxyPhaseWaitStart {\n\t\t\t\tpw.close()\n\t\t\t\txl.Tracef(\"change status from [%s] to [%s]\", pw.Phase, ProxyPhaseCheckFailed)\n\t\t\t\tpw.Phase = ProxyPhaseCheckFailed\n\t\t\t}\n\t\t\tpw.mu.Unlock()\n\t\t}\n\n\t\tselect {\n\t\tcase <-pw.closeCh:\n\t\t\treturn\n\t\tcase <-time.After(statusCheckInterval):\n\t\tcase <-pw.healthNotifyCh:\n\t\t}\n\t}\n}\n\nfunc (pw *Wrapper) statusNormalCallback() {\n\txl := pw.xl\n\tatomic.StoreUint32(&pw.health, 0)\n\t_ = errors.PanicToError(func() {\n\t\tselect {\n\t\tcase pw.healthNotifyCh <- struct{}{}:\n\t\tdefault:\n\t\t}\n\t})\n\txl.Infof(\"health check success\")\n}\n\nfunc (pw *Wrapper) statusFailedCallback() {\n\txl := pw.xl\n\tatomic.StoreUint32(&pw.health, 1)\n\t_ = errors.PanicToError(func() {\n\t\tselect {\n\t\tcase pw.healthNotifyCh <- struct{}{}:\n\t\tdefault:\n\t\t}\n\t})\n\txl.Infof(\"health check failed\")\n}\n\nfunc (pw *Wrapper) InWorkConn(workConn net.Conn, m *msg.StartWorkConn) {\n\txl := pw.xl\n\tpw.mu.RLock()\n\tpxy := pw.pxy\n\tpw.mu.RUnlock()\n\tif pxy != nil && pw.Phase == ProxyPhaseRunning {\n\t\txl.Debugf(\"start a new work connection, localAddr: %s remoteAddr: %s\", workConn.LocalAddr().String(), workConn.RemoteAddr().String())\n\t\tgo pxy.InWorkConn(workConn, m)\n\t} else {\n\t\tworkConn.Close()\n\t}\n}\n\nfunc (pw *Wrapper) GetStatus() *WorkingStatus {\n\tpw.mu.RLock()\n\tdefer pw.mu.RUnlock()\n\tps := &WorkingStatus{\n\t\tName:       pw.Name,\n\t\tType:       pw.Type,\n\t\tPhase:      pw.Phase,\n\t\tErr:        pw.Err,\n\t\tCfg:        pw.Cfg,\n\t\tRemoteAddr: pw.RemoteAddr,\n\t}\n\treturn ps\n}\n"
  },
  {
    "path": "client/proxy/sudp.go",
    "content": "// Copyright 2023 The frp Authors\n//\n// Licensed under the Apache License, Version 2.0 (the \"License\");\n// you may not use this file except in compliance with the License.\n// You may obtain a copy of the License at\n//\n//     http://www.apache.org/licenses/LICENSE-2.0\n//\n// Unless required by applicable law or agreed to in writing, software\n// distributed under the License is distributed on an \"AS IS\" BASIS,\n// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.\n// See the License for the specific language governing permissions and\n// limitations under the License.\n\n//go:build !frps\n\npackage proxy\n\nimport (\n\t\"net\"\n\t\"reflect\"\n\t\"strconv\"\n\t\"sync\"\n\t\"time\"\n\n\t\"github.com/fatedier/golib/errors\"\n\n\tv1 \"github.com/fatedier/frp/pkg/config/v1\"\n\t\"github.com/fatedier/frp/pkg/msg\"\n\t\"github.com/fatedier/frp/pkg/proto/udp\"\n\tnetpkg \"github.com/fatedier/frp/pkg/util/net\"\n)\n\nfunc init() {\n\tRegisterProxyFactory(reflect.TypeFor[*v1.SUDPProxyConfig](), NewSUDPProxy)\n}\n\ntype SUDPProxy struct {\n\t*BaseProxy\n\n\tcfg *v1.SUDPProxyConfig\n\n\tlocalAddr *net.UDPAddr\n\n\tcloseCh chan struct{}\n}\n\nfunc NewSUDPProxy(baseProxy *BaseProxy, cfg v1.ProxyConfigurer) Proxy {\n\tunwrapped, ok := cfg.(*v1.SUDPProxyConfig)\n\tif !ok {\n\t\treturn nil\n\t}\n\treturn &SUDPProxy{\n\t\tBaseProxy: baseProxy,\n\t\tcfg:       unwrapped,\n\t\tcloseCh:   make(chan struct{}),\n\t}\n}\n\nfunc (pxy *SUDPProxy) Run() (err error) {\n\tpxy.localAddr, err = net.ResolveUDPAddr(\"udp\", net.JoinHostPort(pxy.cfg.LocalIP, strconv.Itoa(pxy.cfg.LocalPort)))\n\tif err != nil {\n\t\treturn\n\t}\n\treturn\n}\n\nfunc (pxy *SUDPProxy) Close() {\n\tpxy.mu.Lock()\n\tdefer pxy.mu.Unlock()\n\tselect {\n\tcase <-pxy.closeCh:\n\t\treturn\n\tdefault:\n\t\tclose(pxy.closeCh)\n\t}\n}\n\nfunc (pxy *SUDPProxy) InWorkConn(conn net.Conn, _ *msg.StartWorkConn) {\n\txl := pxy.xl\n\txl.Infof(\"incoming a new work connection for sudp proxy, %s\", conn.RemoteAddr().String())\n\n\tremote, _, err := pxy.wrapWorkConn(conn, pxy.encryptionKey)\n\tif err != nil {\n\t\txl.Errorf(\"wrap work connection: %v\", err)\n\t\treturn\n\t}\n\n\tworkConn := netpkg.WrapReadWriteCloserToConn(remote, conn)\n\treadCh := make(chan *msg.UDPPacket, 1024)\n\tsendCh := make(chan msg.Message, 1024)\n\tisClose := false\n\n\tmu := &sync.Mutex{}\n\n\tcloseFn := func() {\n\t\tmu.Lock()\n\t\tdefer mu.Unlock()\n\t\tif isClose {\n\t\t\treturn\n\t\t}\n\n\t\tisClose = true\n\t\tif workConn != nil {\n\t\t\tworkConn.Close()\n\t\t}\n\t\tclose(readCh)\n\t\tclose(sendCh)\n\t}\n\n\t// udp service <- frpc <- frps <- frpc visitor <- user\n\tworkConnReaderFn := func(conn net.Conn, readCh chan *msg.UDPPacket) {\n\t\tdefer closeFn()\n\n\t\tfor {\n\t\t\t// first to check sudp proxy is closed or not\n\t\t\tselect {\n\t\t\tcase <-pxy.closeCh:\n\t\t\t\txl.Tracef(\"frpc sudp proxy is closed\")\n\t\t\t\treturn\n\t\t\tdefault:\n\t\t\t}\n\n\t\t\tvar udpMsg msg.UDPPacket\n\t\t\tif errRet := msg.ReadMsgInto(conn, &udpMsg); errRet != nil {\n\t\t\t\txl.Warnf(\"read from workConn for sudp error: %v\", errRet)\n\t\t\t\treturn\n\t\t\t}\n\n\t\t\tif errRet := errors.PanicToError(func() {\n\t\t\t\treadCh <- &udpMsg\n\t\t\t}); errRet != nil {\n\t\t\t\txl.Warnf(\"reader goroutine for sudp work connection closed: %v\", errRet)\n\t\t\t\treturn\n\t\t\t}\n\t\t}\n\t}\n\n\t// udp service -> frpc -> frps -> frpc visitor -> user\n\tworkConnSenderFn := func(conn net.Conn, sendCh chan msg.Message) {\n\t\tdefer func() {\n\t\t\tcloseFn()\n\t\t\txl.Infof(\"writer goroutine for sudp work connection closed\")\n\t\t}()\n\n\t\tvar errRet error\n\t\tfor rawMsg := range sendCh {\n\t\t\tswitch m := rawMsg.(type) {\n\t\t\tcase *msg.UDPPacket:\n\t\t\t\txl.Tracef(\"frpc send udp package to frpc visitor, [udp local: %v, remote: %v], [tcp work conn local: %v, remote: %v]\",\n\t\t\t\t\tm.LocalAddr.String(), m.RemoteAddr.String(), conn.LocalAddr().String(), conn.RemoteAddr().String())\n\t\t\tcase *msg.Ping:\n\t\t\t\txl.Tracef(\"frpc send ping message to frpc visitor\")\n\t\t\t}\n\n\t\t\tif errRet = msg.WriteMsg(conn, rawMsg); errRet != nil {\n\t\t\t\txl.Errorf(\"sudp work write error: %v\", errRet)\n\t\t\t\treturn\n\t\t\t}\n\t\t}\n\t}\n\n\theartbeatFn := func(sendCh chan msg.Message) {\n\t\tticker := time.NewTicker(30 * time.Second)\n\t\tdefer func() {\n\t\t\tticker.Stop()\n\t\t\tcloseFn()\n\t\t}()\n\n\t\tvar errRet error\n\t\tfor {\n\t\t\tselect {\n\t\t\tcase <-ticker.C:\n\t\t\t\tif errRet = errors.PanicToError(func() {\n\t\t\t\t\tsendCh <- &msg.Ping{}\n\t\t\t\t}); errRet != nil {\n\t\t\t\t\txl.Warnf(\"heartbeat goroutine for sudp work connection closed\")\n\t\t\t\t\treturn\n\t\t\t\t}\n\t\t\tcase <-pxy.closeCh:\n\t\t\t\txl.Tracef(\"frpc sudp proxy is closed\")\n\t\t\t\treturn\n\t\t\t}\n\t\t}\n\t}\n\n\tgo workConnSenderFn(workConn, sendCh)\n\tgo workConnReaderFn(workConn, readCh)\n\tgo heartbeatFn(sendCh)\n\n\tudp.Forwarder(pxy.localAddr, readCh, sendCh, int(pxy.clientCfg.UDPPacketSize), pxy.cfg.Transport.ProxyProtocolVersion)\n}\n"
  },
  {
    "path": "client/proxy/udp.go",
    "content": "// Copyright 2023 The frp Authors\n//\n// Licensed under the Apache License, Version 2.0 (the \"License\");\n// you may not use this file except in compliance with the License.\n// You may obtain a copy of the License at\n//\n//     http://www.apache.org/licenses/LICENSE-2.0\n//\n// Unless required by applicable law or agreed to in writing, software\n// distributed under the License is distributed on an \"AS IS\" BASIS,\n// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.\n// See the License for the specific language governing permissions and\n// limitations under the License.\n\n//go:build !frps\n\npackage proxy\n\nimport (\n\t\"net\"\n\t\"reflect\"\n\t\"strconv\"\n\t\"time\"\n\n\t\"github.com/fatedier/golib/errors\"\n\n\tv1 \"github.com/fatedier/frp/pkg/config/v1\"\n\t\"github.com/fatedier/frp/pkg/msg\"\n\t\"github.com/fatedier/frp/pkg/proto/udp\"\n\tnetpkg \"github.com/fatedier/frp/pkg/util/net\"\n)\n\nfunc init() {\n\tRegisterProxyFactory(reflect.TypeFor[*v1.UDPProxyConfig](), NewUDPProxy)\n}\n\ntype UDPProxy struct {\n\t*BaseProxy\n\n\tcfg *v1.UDPProxyConfig\n\n\tlocalAddr *net.UDPAddr\n\treadCh    chan *msg.UDPPacket\n\n\t// include msg.UDPPacket and msg.Ping\n\tsendCh   chan msg.Message\n\tworkConn net.Conn\n\tclosed   bool\n}\n\nfunc NewUDPProxy(baseProxy *BaseProxy, cfg v1.ProxyConfigurer) Proxy {\n\tunwrapped, ok := cfg.(*v1.UDPProxyConfig)\n\tif !ok {\n\t\treturn nil\n\t}\n\treturn &UDPProxy{\n\t\tBaseProxy: baseProxy,\n\t\tcfg:       unwrapped,\n\t}\n}\n\nfunc (pxy *UDPProxy) Run() (err error) {\n\tpxy.localAddr, err = net.ResolveUDPAddr(\"udp\", net.JoinHostPort(pxy.cfg.LocalIP, strconv.Itoa(pxy.cfg.LocalPort)))\n\tif err != nil {\n\t\treturn\n\t}\n\treturn\n}\n\nfunc (pxy *UDPProxy) Close() {\n\tpxy.mu.Lock()\n\tdefer pxy.mu.Unlock()\n\n\tif !pxy.closed {\n\t\tpxy.closed = true\n\t\tif pxy.workConn != nil {\n\t\t\tpxy.workConn.Close()\n\t\t}\n\t\tif pxy.readCh != nil {\n\t\t\tclose(pxy.readCh)\n\t\t}\n\t\tif pxy.sendCh != nil {\n\t\t\tclose(pxy.sendCh)\n\t\t}\n\t}\n}\n\nfunc (pxy *UDPProxy) InWorkConn(conn net.Conn, _ *msg.StartWorkConn) {\n\txl := pxy.xl\n\txl.Infof(\"incoming a new work connection for udp proxy, %s\", conn.RemoteAddr().String())\n\t// close resources related with old workConn\n\tpxy.Close()\n\n\tremote, _, err := pxy.wrapWorkConn(conn, pxy.encryptionKey)\n\tif err != nil {\n\t\txl.Errorf(\"wrap work connection: %v\", err)\n\t\treturn\n\t}\n\n\tpxy.mu.Lock()\n\tpxy.workConn = netpkg.WrapReadWriteCloserToConn(remote, conn)\n\tpxy.readCh = make(chan *msg.UDPPacket, 1024)\n\tpxy.sendCh = make(chan msg.Message, 1024)\n\tpxy.closed = false\n\tpxy.mu.Unlock()\n\n\tworkConnReaderFn := func(conn net.Conn, readCh chan *msg.UDPPacket) {\n\t\tfor {\n\t\t\tvar udpMsg msg.UDPPacket\n\t\t\tif errRet := msg.ReadMsgInto(conn, &udpMsg); errRet != nil {\n\t\t\t\txl.Warnf(\"read from workConn for udp error: %v\", errRet)\n\t\t\t\treturn\n\t\t\t}\n\t\t\tif errRet := errors.PanicToError(func() {\n\t\t\t\txl.Tracef(\"get udp package from workConn, len: %d\", len(udpMsg.Content))\n\t\t\t\treadCh <- &udpMsg\n\t\t\t}); errRet != nil {\n\t\t\t\txl.Infof(\"reader goroutine for udp work connection closed: %v\", errRet)\n\t\t\t\treturn\n\t\t\t}\n\t\t}\n\t}\n\tworkConnSenderFn := func(conn net.Conn, sendCh chan msg.Message) {\n\t\tdefer func() {\n\t\t\txl.Infof(\"writer goroutine for udp work connection closed\")\n\t\t}()\n\t\tvar errRet error\n\t\tfor rawMsg := range sendCh {\n\t\t\tswitch m := rawMsg.(type) {\n\t\t\tcase *msg.UDPPacket:\n\t\t\t\txl.Tracef(\"send udp package to workConn, len: %d\", len(m.Content))\n\t\t\tcase *msg.Ping:\n\t\t\t\txl.Tracef(\"send ping message to udp workConn\")\n\t\t\t}\n\t\t\tif errRet = msg.WriteMsg(conn, rawMsg); errRet != nil {\n\t\t\t\txl.Errorf(\"udp work write error: %v\", errRet)\n\t\t\t\treturn\n\t\t\t}\n\t\t}\n\t}\n\theartbeatFn := func(sendCh chan msg.Message) {\n\t\tvar errRet error\n\t\tfor {\n\t\t\ttime.Sleep(time.Duration(30) * time.Second)\n\t\t\tif errRet = errors.PanicToError(func() {\n\t\t\t\tsendCh <- &msg.Ping{}\n\t\t\t}); errRet != nil {\n\t\t\t\txl.Tracef(\"heartbeat goroutine for udp work connection closed\")\n\t\t\t\tbreak\n\t\t\t}\n\t\t}\n\t}\n\n\tgo workConnSenderFn(pxy.workConn, pxy.sendCh)\n\tgo workConnReaderFn(pxy.workConn, pxy.readCh)\n\tgo heartbeatFn(pxy.sendCh)\n\n\t// Call Forwarder with proxy protocol version (empty string means no proxy protocol)\n\tudp.Forwarder(pxy.localAddr, pxy.readCh, pxy.sendCh, int(pxy.clientCfg.UDPPacketSize), pxy.cfg.Transport.ProxyProtocolVersion)\n}\n"
  },
  {
    "path": "client/proxy/xtcp.go",
    "content": "// Copyright 2023 The frp Authors\n//\n// Licensed under the Apache License, Version 2.0 (the \"License\");\n// you may not use this file except in compliance with the License.\n// You may obtain a copy of the License at\n//\n//     http://www.apache.org/licenses/LICENSE-2.0\n//\n// Unless required by applicable law or agreed to in writing, software\n// distributed under the License is distributed on an \"AS IS\" BASIS,\n// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.\n// See the License for the specific language governing permissions and\n// limitations under the License.\n\n//go:build !frps\n\npackage proxy\n\nimport (\n\t\"io\"\n\t\"net\"\n\t\"reflect\"\n\t\"time\"\n\n\tfmux \"github.com/hashicorp/yamux\"\n\t\"github.com/quic-go/quic-go\"\n\n\tv1 \"github.com/fatedier/frp/pkg/config/v1\"\n\t\"github.com/fatedier/frp/pkg/msg\"\n\t\"github.com/fatedier/frp/pkg/naming\"\n\t\"github.com/fatedier/frp/pkg/nathole\"\n\t\"github.com/fatedier/frp/pkg/transport\"\n\tnetpkg \"github.com/fatedier/frp/pkg/util/net\"\n)\n\nfunc init() {\n\tRegisterProxyFactory(reflect.TypeFor[*v1.XTCPProxyConfig](), NewXTCPProxy)\n}\n\ntype XTCPProxy struct {\n\t*BaseProxy\n\n\tcfg *v1.XTCPProxyConfig\n}\n\nfunc NewXTCPProxy(baseProxy *BaseProxy, cfg v1.ProxyConfigurer) Proxy {\n\tunwrapped, ok := cfg.(*v1.XTCPProxyConfig)\n\tif !ok {\n\t\treturn nil\n\t}\n\treturn &XTCPProxy{\n\t\tBaseProxy: baseProxy,\n\t\tcfg:       unwrapped,\n\t}\n}\n\nfunc (pxy *XTCPProxy) InWorkConn(conn net.Conn, startWorkConnMsg *msg.StartWorkConn) {\n\txl := pxy.xl\n\tdefer conn.Close()\n\tvar natHoleSidMsg msg.NatHoleSid\n\terr := msg.ReadMsgInto(conn, &natHoleSidMsg)\n\tif err != nil {\n\t\txl.Errorf(\"xtcp read from workConn error: %v\", err)\n\t\treturn\n\t}\n\n\txl.Tracef(\"nathole prepare start\")\n\n\t// Prepare NAT traversal options\n\tvar opts nathole.PrepareOptions\n\tif pxy.cfg.NatTraversal != nil && pxy.cfg.NatTraversal.DisableAssistedAddrs {\n\t\topts.DisableAssistedAddrs = true\n\t}\n\n\tprepareResult, err := nathole.Prepare([]string{pxy.clientCfg.NatHoleSTUNServer}, opts)\n\tif err != nil {\n\t\txl.Warnf(\"nathole prepare error: %v\", err)\n\t\treturn\n\t}\n\n\txl.Infof(\"nathole prepare success, nat type: %s, behavior: %s, addresses: %v, assistedAddresses: %v\",\n\t\tprepareResult.NatType, prepareResult.Behavior, prepareResult.Addrs, prepareResult.AssistedAddrs)\n\tdefer prepareResult.ListenConn.Close()\n\n\t// send NatHoleClient msg to server\n\ttransactionID := nathole.NewTransactionID()\n\tnatHoleClientMsg := &msg.NatHoleClient{\n\t\tTransactionID: transactionID,\n\t\tProxyName:     naming.AddUserPrefix(pxy.clientCfg.User, pxy.cfg.Name),\n\t\tSid:           natHoleSidMsg.Sid,\n\t\tMappedAddrs:   prepareResult.Addrs,\n\t\tAssistedAddrs: prepareResult.AssistedAddrs,\n\t}\n\n\txl.Tracef(\"nathole exchange info start\")\n\tnatHoleRespMsg, err := nathole.ExchangeInfo(pxy.ctx, pxy.msgTransporter, transactionID, natHoleClientMsg, 5*time.Second)\n\tif err != nil {\n\t\txl.Warnf(\"nathole exchange info error: %v\", err)\n\t\treturn\n\t}\n\n\txl.Infof(\"get natHoleRespMsg, sid [%s], protocol [%s], candidate address %v, assisted address %v, detectBehavior: %+v\",\n\t\tnatHoleRespMsg.Sid, natHoleRespMsg.Protocol, natHoleRespMsg.CandidateAddrs,\n\t\tnatHoleRespMsg.AssistedAddrs, natHoleRespMsg.DetectBehavior)\n\n\tlistenConn := prepareResult.ListenConn\n\tnewListenConn, raddr, err := nathole.MakeHole(pxy.ctx, listenConn, natHoleRespMsg, []byte(pxy.cfg.Secretkey))\n\tif err != nil {\n\t\tlistenConn.Close()\n\t\txl.Warnf(\"make hole error: %v\", err)\n\t\t_ = pxy.msgTransporter.Send(&msg.NatHoleReport{\n\t\t\tSid:     natHoleRespMsg.Sid,\n\t\t\tSuccess: false,\n\t\t})\n\t\treturn\n\t}\n\tlistenConn = newListenConn\n\txl.Infof(\"establishing nat hole connection successful, sid [%s], remoteAddr [%s]\", natHoleRespMsg.Sid, raddr)\n\n\t_ = pxy.msgTransporter.Send(&msg.NatHoleReport{\n\t\tSid:     natHoleRespMsg.Sid,\n\t\tSuccess: true,\n\t})\n\n\tif natHoleRespMsg.Protocol == \"kcp\" {\n\t\tpxy.listenByKCP(listenConn, raddr, startWorkConnMsg)\n\t\treturn\n\t}\n\n\t// default is quic\n\tpxy.listenByQUIC(listenConn, raddr, startWorkConnMsg)\n}\n\nfunc (pxy *XTCPProxy) listenByKCP(listenConn *net.UDPConn, raddr *net.UDPAddr, startWorkConnMsg *msg.StartWorkConn) {\n\txl := pxy.xl\n\tlistenConn.Close()\n\tladdr, _ := net.ResolveUDPAddr(\"udp\", listenConn.LocalAddr().String())\n\tlConn, err := net.DialUDP(\"udp\", laddr, raddr)\n\tif err != nil {\n\t\txl.Warnf(\"dial udp error: %v\", err)\n\t\treturn\n\t}\n\tdefer lConn.Close()\n\n\tremote, err := netpkg.NewKCPConnFromUDP(lConn, true, raddr.String())\n\tif err != nil {\n\t\txl.Warnf(\"create kcp connection from udp connection error: %v\", err)\n\t\treturn\n\t}\n\n\tfmuxCfg := fmux.DefaultConfig()\n\tfmuxCfg.KeepAliveInterval = 10 * time.Second\n\tfmuxCfg.MaxStreamWindowSize = 6 * 1024 * 1024\n\tfmuxCfg.LogOutput = io.Discard\n\tsession, err := fmux.Server(remote, fmuxCfg)\n\tif err != nil {\n\t\txl.Errorf(\"create mux session error: %v\", err)\n\t\treturn\n\t}\n\tdefer session.Close()\n\n\tfor {\n\t\tmuxConn, err := session.Accept()\n\t\tif err != nil {\n\t\t\txl.Errorf(\"accept connection error: %v\", err)\n\t\t\treturn\n\t\t}\n\t\tgo pxy.HandleTCPWorkConnection(muxConn, startWorkConnMsg, []byte(pxy.cfg.Secretkey))\n\t}\n}\n\nfunc (pxy *XTCPProxy) listenByQUIC(listenConn *net.UDPConn, _ *net.UDPAddr, startWorkConnMsg *msg.StartWorkConn) {\n\txl := pxy.xl\n\tdefer listenConn.Close()\n\n\ttlsConfig, err := transport.NewServerTLSConfig(\"\", \"\", \"\")\n\tif err != nil {\n\t\txl.Warnf(\"create tls config error: %v\", err)\n\t\treturn\n\t}\n\ttlsConfig.NextProtos = []string{\"frp\"}\n\tquicListener, err := quic.Listen(listenConn, tlsConfig,\n\t\t&quic.Config{\n\t\t\tMaxIdleTimeout:     time.Duration(pxy.clientCfg.Transport.QUIC.MaxIdleTimeout) * time.Second,\n\t\t\tMaxIncomingStreams: int64(pxy.clientCfg.Transport.QUIC.MaxIncomingStreams),\n\t\t\tKeepAlivePeriod:    time.Duration(pxy.clientCfg.Transport.QUIC.KeepalivePeriod) * time.Second,\n\t\t},\n\t)\n\tif err != nil {\n\t\txl.Warnf(\"dial quic error: %v\", err)\n\t\treturn\n\t}\n\t// only accept one connection from raddr\n\tc, err := quicListener.Accept(pxy.ctx)\n\tif err != nil {\n\t\txl.Errorf(\"quic accept connection error: %v\", err)\n\t\treturn\n\t}\n\tfor {\n\t\tstream, err := c.AcceptStream(pxy.ctx)\n\t\tif err != nil {\n\t\t\txl.Debugf(\"quic accept stream error: %v\", err)\n\t\t\t_ = c.CloseWithError(0, \"\")\n\t\t\treturn\n\t\t}\n\t\tgo pxy.HandleTCPWorkConnection(netpkg.QuicStreamToNetConn(stream, c), startWorkConnMsg, []byte(pxy.cfg.Secretkey))\n\t}\n}\n"
  },
  {
    "path": "client/service.go",
    "content": "// Copyright 2017 fatedier, fatedier@gmail.com\n//\n// Licensed under the Apache License, Version 2.0 (the \"License\");\n// you may not use this file except in compliance with the License.\n// You may obtain a copy of the License at\n//\n//     http://www.apache.org/licenses/LICENSE-2.0\n//\n// Unless required by applicable law or agreed to in writing, software\n// distributed under the License is distributed on an \"AS IS\" BASIS,\n// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.\n// See the License for the specific language governing permissions and\n// limitations under the License.\n\npackage client\n\nimport (\n\t\"context\"\n\t\"errors\"\n\t\"fmt\"\n\t\"net\"\n\t\"net/http\"\n\t\"os\"\n\t\"runtime\"\n\t\"sync\"\n\t\"time\"\n\n\t\"github.com/fatedier/golib/crypto\"\n\t\"github.com/samber/lo\"\n\n\t\"github.com/fatedier/frp/client/proxy\"\n\t\"github.com/fatedier/frp/pkg/auth\"\n\t\"github.com/fatedier/frp/pkg/config\"\n\t\"github.com/fatedier/frp/pkg/config/source\"\n\tv1 \"github.com/fatedier/frp/pkg/config/v1\"\n\t\"github.com/fatedier/frp/pkg/msg\"\n\t\"github.com/fatedier/frp/pkg/policy/security\"\n\thttppkg \"github.com/fatedier/frp/pkg/util/http\"\n\t\"github.com/fatedier/frp/pkg/util/log\"\n\tnetpkg \"github.com/fatedier/frp/pkg/util/net\"\n\t\"github.com/fatedier/frp/pkg/util/version\"\n\t\"github.com/fatedier/frp/pkg/util/wait\"\n\t\"github.com/fatedier/frp/pkg/util/xlog\"\n\t\"github.com/fatedier/frp/pkg/vnet\"\n)\n\nfunc init() {\n\tcrypto.DefaultSalt = \"frp\"\n\t// Disable quic-go's receive buffer warning.\n\tos.Setenv(\"QUIC_GO_DISABLE_RECEIVE_BUFFER_WARNING\", \"true\")\n\t// Disable quic-go's ECN support by default. It may cause issues on certain operating systems.\n\tif os.Getenv(\"QUIC_GO_DISABLE_ECN\") == \"\" {\n\t\tos.Setenv(\"QUIC_GO_DISABLE_ECN\", \"true\")\n\t}\n}\n\ntype cancelErr struct {\n\tErr error\n}\n\nfunc (e cancelErr) Error() string {\n\treturn e.Err.Error()\n}\n\n// ServiceOptions contains options for creating a new client service.\ntype ServiceOptions struct {\n\tCommon *v1.ClientCommonConfig\n\n\t// ConfigSourceAggregator manages internal config and optional store sources.\n\t// It is required for creating a Service.\n\tConfigSourceAggregator *source.Aggregator\n\n\tUnsafeFeatures *security.UnsafeFeatures\n\n\t// ConfigFilePath is the path to the configuration file used to initialize.\n\t// If it is empty, it means that the configuration file is not used for initialization.\n\t// It may be initialized using command line parameters or called directly.\n\tConfigFilePath string\n\n\t// ClientSpec is the client specification that control the client behavior.\n\tClientSpec *msg.ClientSpec\n\n\t// ConnectorCreator is a function that creates a new connector to make connections to the server.\n\t// The Connector shields the underlying connection details, whether it is through TCP or QUIC connection,\n\t// and regardless of whether multiplexing is used.\n\t//\n\t// If it is not set, the default frpc connector will be used.\n\t// By using a custom Connector, it can be used to implement a VirtualClient, which connects to frps\n\t// through a pipe instead of a real physical connection.\n\tConnectorCreator func(context.Context, *v1.ClientCommonConfig) Connector\n\n\t// HandleWorkConnCb is a callback function that is called when a new work connection is created.\n\t//\n\t// If it is not set, the default frpc implementation will be used.\n\tHandleWorkConnCb func(*v1.ProxyBaseConfig, net.Conn, *msg.StartWorkConn) bool\n}\n\n// setServiceOptionsDefault sets the default values for ServiceOptions.\nfunc setServiceOptionsDefault(options *ServiceOptions) error {\n\tif options.Common != nil {\n\t\tif err := options.Common.Complete(); err != nil {\n\t\t\treturn err\n\t\t}\n\t}\n\tif options.ConnectorCreator == nil {\n\t\toptions.ConnectorCreator = NewConnector\n\t}\n\treturn nil\n}\n\n// Service is the client service that connects to frps and provides proxy services.\ntype Service struct {\n\tctlMu sync.RWMutex\n\t// manager control connection with server\n\tctl *Control\n\t// Uniq id got from frps, it will be attached to loginMsg.\n\trunID string\n\n\t// Auth runtime and encryption materials\n\tauth *auth.ClientAuth\n\n\t// web server for admin UI and apis\n\twebServer *httppkg.Server\n\n\tvnetController *vnet.Controller\n\n\tcfgMu sync.RWMutex\n\t// reloadMu serializes reload transactions to keep reloadCommon and applied\n\t// config in sync across concurrent API operations.\n\treloadMu sync.Mutex\n\tcommon   *v1.ClientCommonConfig\n\t// reloadCommon is used for filtering/defaulting during config-source reloads.\n\t// It can be updated by /api/reload without mutating startup-only common behavior.\n\treloadCommon *v1.ClientCommonConfig\n\tproxyCfgs    []v1.ProxyConfigurer\n\tvisitorCfgs  []v1.VisitorConfigurer\n\tclientSpec   *msg.ClientSpec\n\n\t// aggregator manages multiple configuration sources.\n\t// When set, the service watches for config changes and reloads automatically.\n\taggregator   *source.Aggregator\n\tconfigSource *source.ConfigSource\n\tstoreSource  *source.StoreSource\n\n\tunsafeFeatures *security.UnsafeFeatures\n\n\t// The configuration file used to initialize this client, or an empty\n\t// string if no configuration file was used.\n\tconfigFilePath string\n\n\t// service context\n\tctx context.Context\n\t// call cancel to stop service\n\tcancel                   context.CancelCauseFunc\n\tgracefulShutdownDuration time.Duration\n\n\tconnectorCreator func(context.Context, *v1.ClientCommonConfig) Connector\n\thandleWorkConnCb func(*v1.ProxyBaseConfig, net.Conn, *msg.StartWorkConn) bool\n}\n\nfunc NewService(options ServiceOptions) (*Service, error) {\n\tif err := setServiceOptionsDefault(&options); err != nil {\n\t\treturn nil, err\n\t}\n\n\tauthRuntime, err := auth.BuildClientAuth(&options.Common.Auth)\n\tif err != nil {\n\t\treturn nil, err\n\t}\n\n\tif options.ConfigSourceAggregator == nil {\n\t\treturn nil, fmt.Errorf(\"config source aggregator is required\")\n\t}\n\n\tconfigSource := options.ConfigSourceAggregator.ConfigSource()\n\tstoreSource := options.ConfigSourceAggregator.StoreSource()\n\n\tproxyCfgs, visitorCfgs, loadErr := options.ConfigSourceAggregator.Load()\n\tif loadErr != nil {\n\t\treturn nil, fmt.Errorf(\"failed to load config from aggregator: %w\", loadErr)\n\t}\n\tproxyCfgs, visitorCfgs = config.FilterClientConfigurers(options.Common, proxyCfgs, visitorCfgs)\n\tproxyCfgs = config.CompleteProxyConfigurers(proxyCfgs)\n\tvisitorCfgs = config.CompleteVisitorConfigurers(visitorCfgs)\n\n\t// Create the web server after all fallible steps so its listener is not\n\t// leaked when an earlier error causes NewService to return.\n\tvar webServer *httppkg.Server\n\tif options.Common.WebServer.Port > 0 {\n\t\tws, err := httppkg.NewServer(options.Common.WebServer)\n\t\tif err != nil {\n\t\t\treturn nil, err\n\t\t}\n\t\twebServer = ws\n\t}\n\n\ts := &Service{\n\t\tctx:              context.Background(),\n\t\tauth:             authRuntime,\n\t\twebServer:        webServer,\n\t\tcommon:           options.Common,\n\t\treloadCommon:     options.Common,\n\t\tconfigFilePath:   options.ConfigFilePath,\n\t\tunsafeFeatures:   options.UnsafeFeatures,\n\t\tproxyCfgs:        proxyCfgs,\n\t\tvisitorCfgs:      visitorCfgs,\n\t\tclientSpec:       options.ClientSpec,\n\t\taggregator:       options.ConfigSourceAggregator,\n\t\tconfigSource:     configSource,\n\t\tstoreSource:      storeSource,\n\t\tconnectorCreator: options.ConnectorCreator,\n\t\thandleWorkConnCb: options.HandleWorkConnCb,\n\t}\n\n\tif webServer != nil {\n\t\twebServer.RouteRegister(s.registerRouteHandlers)\n\t}\n\tif options.Common.VirtualNet.Address != \"\" {\n\t\ts.vnetController = vnet.NewController(options.Common.VirtualNet)\n\t}\n\treturn s, nil\n}\n\nfunc (svr *Service) Run(ctx context.Context) error {\n\tctx, cancel := context.WithCancelCause(ctx)\n\tsvr.ctx = xlog.NewContext(ctx, xlog.FromContextSafe(ctx))\n\tsvr.cancel = cancel\n\n\t// set custom DNSServer\n\tif svr.common.DNSServer != \"\" {\n\t\tnetpkg.SetDefaultDNSAddress(svr.common.DNSServer)\n\t}\n\n\tif svr.vnetController != nil {\n\t\tvnetController := svr.vnetController\n\t\tif err := svr.vnetController.Init(); err != nil {\n\t\t\tlog.Errorf(\"init virtual network controller error: %v\", err)\n\t\t\tsvr.stop()\n\t\t\treturn err\n\t\t}\n\t\tgo func() {\n\t\t\tlog.Infof(\"virtual network controller start...\")\n\t\t\tif err := vnetController.Run(); err != nil && !errors.Is(err, net.ErrClosed) {\n\t\t\t\tlog.Warnf(\"virtual network controller exit with error: %v\", err)\n\t\t\t}\n\t\t}()\n\t}\n\n\tif svr.webServer != nil {\n\t\twebServer := svr.webServer\n\t\tgo func() {\n\t\t\tlog.Infof(\"admin server listen on %s\", webServer.Address())\n\t\t\tif err := webServer.Run(); err != nil && !errors.Is(err, http.ErrServerClosed) {\n\t\t\t\tlog.Warnf(\"admin server exit with error: %v\", err)\n\t\t\t}\n\t\t}()\n\t}\n\n\t// first login to frps\n\tsvr.loopLoginUntilSuccess(10*time.Second, lo.FromPtr(svr.common.LoginFailExit))\n\tif svr.ctl == nil {\n\t\tcancelCause := cancelErr{}\n\t\t_ = errors.As(context.Cause(svr.ctx), &cancelCause)\n\t\tsvr.stop()\n\t\treturn fmt.Errorf(\"login to the server failed: %v. With loginFailExit enabled, no additional retries will be attempted\", cancelCause.Err)\n\t}\n\n\tgo svr.keepControllerWorking()\n\n\t<-svr.ctx.Done()\n\tsvr.stop()\n\treturn nil\n}\n\nfunc (svr *Service) keepControllerWorking() {\n\t<-svr.ctl.Done()\n\n\t// There is a situation where the login is successful but due to certain reasons,\n\t// the control immediately exits. It is necessary to limit the frequency of reconnection in this case.\n\t// The interval for the first three retries in 1 minute will be very short, and then it will increase exponentially.\n\t// The maximum interval is 20 seconds.\n\twait.BackoffUntil(func() (bool, error) {\n\t\t// loopLoginUntilSuccess is another layer of loop that will continuously attempt to\n\t\t// login to the server until successful.\n\t\tsvr.loopLoginUntilSuccess(20*time.Second, false)\n\t\tif svr.ctl != nil {\n\t\t\t<-svr.ctl.Done()\n\t\t\treturn false, errors.New(\"control is closed and try another loop\")\n\t\t}\n\t\t// If the control is nil, it means that the login failed and the service is also closed.\n\t\treturn false, nil\n\t}, wait.NewFastBackoffManager(\n\t\twait.FastBackoffOptions{\n\t\t\tDuration:        time.Second,\n\t\t\tFactor:          2,\n\t\t\tJitter:          0.1,\n\t\t\tMaxDuration:     20 * time.Second,\n\t\t\tFastRetryCount:  3,\n\t\t\tFastRetryDelay:  200 * time.Millisecond,\n\t\t\tFastRetryWindow: time.Minute,\n\t\t\tFastRetryJitter: 0.5,\n\t\t},\n\t), true, svr.ctx.Done())\n}\n\n// login creates a connection to frps and registers it self as a client\n// conn: control connection\n// session: if it's not nil, using tcp mux\nfunc (svr *Service) login() (conn net.Conn, connector Connector, err error) {\n\txl := xlog.FromContextSafe(svr.ctx)\n\tconnector = svr.connectorCreator(svr.ctx, svr.common)\n\tif err = connector.Open(); err != nil {\n\t\treturn nil, nil, err\n\t}\n\n\tdefer func() {\n\t\tif err != nil {\n\t\t\tconnector.Close()\n\t\t}\n\t}()\n\n\tconn, err = connector.Connect()\n\tif err != nil {\n\t\treturn\n\t}\n\n\thostname, _ := os.Hostname()\n\n\tloginMsg := &msg.Login{\n\t\tArch:      runtime.GOARCH,\n\t\tOs:        runtime.GOOS,\n\t\tHostname:  hostname,\n\t\tPoolCount: svr.common.Transport.PoolCount,\n\t\tUser:      svr.common.User,\n\t\tClientID:  svr.common.ClientID,\n\t\tVersion:   version.Full(),\n\t\tTimestamp: time.Now().Unix(),\n\t\tRunID:     svr.runID,\n\t\tMetas:     svr.common.Metadatas,\n\t}\n\tif svr.clientSpec != nil {\n\t\tloginMsg.ClientSpec = *svr.clientSpec\n\t}\n\n\t// Add auth\n\tif err = svr.auth.Setter.SetLogin(loginMsg); err != nil {\n\t\treturn\n\t}\n\n\tif err = msg.WriteMsg(conn, loginMsg); err != nil {\n\t\treturn\n\t}\n\n\tvar loginRespMsg msg.LoginResp\n\t_ = conn.SetReadDeadline(time.Now().Add(10 * time.Second))\n\tif err = msg.ReadMsgInto(conn, &loginRespMsg); err != nil {\n\t\treturn\n\t}\n\t_ = conn.SetReadDeadline(time.Time{})\n\n\tif loginRespMsg.Error != \"\" {\n\t\terr = fmt.Errorf(\"%s\", loginRespMsg.Error)\n\t\txl.Errorf(\"%s\", loginRespMsg.Error)\n\t\treturn\n\t}\n\n\tsvr.runID = loginRespMsg.RunID\n\txl.AddPrefix(xlog.LogPrefix{Name: \"runID\", Value: svr.runID})\n\n\txl.Infof(\"login to server success, get run id [%s]\", loginRespMsg.RunID)\n\treturn\n}\n\nfunc (svr *Service) loopLoginUntilSuccess(maxInterval time.Duration, firstLoginExit bool) {\n\txl := xlog.FromContextSafe(svr.ctx)\n\n\tloginFunc := func() (bool, error) {\n\t\txl.Infof(\"try to connect to server...\")\n\t\tconn, connector, err := svr.login()\n\t\tif err != nil {\n\t\t\txl.Warnf(\"connect to server error: %v\", err)\n\t\t\tif firstLoginExit {\n\t\t\t\tsvr.cancel(cancelErr{Err: err})\n\t\t\t}\n\t\t\treturn false, err\n\t\t}\n\n\t\tsvr.cfgMu.RLock()\n\t\tproxyCfgs := svr.proxyCfgs\n\t\tvisitorCfgs := svr.visitorCfgs\n\t\tsvr.cfgMu.RUnlock()\n\n\t\tconnEncrypted := svr.clientSpec == nil || svr.clientSpec.Type != \"ssh-tunnel\"\n\n\t\tsessionCtx := &SessionContext{\n\t\t\tCommon:         svr.common,\n\t\t\tRunID:          svr.runID,\n\t\t\tConn:           conn,\n\t\t\tConnEncrypted:  connEncrypted,\n\t\t\tAuth:           svr.auth,\n\t\t\tConnector:      connector,\n\t\t\tVnetController: svr.vnetController,\n\t\t}\n\t\tctl, err := NewControl(svr.ctx, sessionCtx)\n\t\tif err != nil {\n\t\t\tconn.Close()\n\t\t\txl.Errorf(\"new control error: %v\", err)\n\t\t\treturn false, err\n\t\t}\n\t\tctl.SetInWorkConnCallback(svr.handleWorkConnCb)\n\n\t\tctl.Run(proxyCfgs, visitorCfgs)\n\t\t// close and replace previous control\n\t\tsvr.ctlMu.Lock()\n\t\tif svr.ctl != nil {\n\t\t\tsvr.ctl.Close()\n\t\t}\n\t\tsvr.ctl = ctl\n\t\tsvr.ctlMu.Unlock()\n\t\treturn true, nil\n\t}\n\n\t// try to reconnect to server until success\n\twait.BackoffUntil(loginFunc, wait.NewFastBackoffManager(\n\t\twait.FastBackoffOptions{\n\t\t\tDuration:    time.Second,\n\t\t\tFactor:      2,\n\t\t\tJitter:      0.1,\n\t\t\tMaxDuration: maxInterval,\n\t\t}), true, svr.ctx.Done())\n}\n\nfunc (svr *Service) UpdateAllConfigurer(proxyCfgs []v1.ProxyConfigurer, visitorCfgs []v1.VisitorConfigurer) error {\n\tsvr.cfgMu.Lock()\n\tsvr.proxyCfgs = proxyCfgs\n\tsvr.visitorCfgs = visitorCfgs\n\tsvr.cfgMu.Unlock()\n\n\tsvr.ctlMu.RLock()\n\tctl := svr.ctl\n\tsvr.ctlMu.RUnlock()\n\n\tif ctl != nil {\n\t\treturn svr.ctl.UpdateAllConfigurer(proxyCfgs, visitorCfgs)\n\t}\n\treturn nil\n}\n\nfunc (svr *Service) UpdateConfigSource(\n\tcommon *v1.ClientCommonConfig,\n\tproxyCfgs []v1.ProxyConfigurer,\n\tvisitorCfgs []v1.VisitorConfigurer,\n) error {\n\tsvr.reloadMu.Lock()\n\tdefer svr.reloadMu.Unlock()\n\n\tcfgSource := svr.configSource\n\tif cfgSource == nil {\n\t\treturn fmt.Errorf(\"config source is not available\")\n\t}\n\n\tif err := cfgSource.ReplaceAll(proxyCfgs, visitorCfgs); err != nil {\n\t\treturn err\n\t}\n\n\t// Non-atomic update semantics: source has been updated at this point.\n\t// Even if reload fails below, keep this common config for subsequent reloads.\n\tsvr.cfgMu.Lock()\n\tsvr.reloadCommon = common\n\tsvr.cfgMu.Unlock()\n\n\tif err := svr.reloadConfigFromSourcesLocked(); err != nil {\n\t\treturn err\n\t}\n\treturn nil\n}\n\nfunc (svr *Service) Close() {\n\tsvr.GracefulClose(time.Duration(0))\n}\n\nfunc (svr *Service) GracefulClose(d time.Duration) {\n\tsvr.gracefulShutdownDuration = d\n\tsvr.cancel(nil)\n}\n\nfunc (svr *Service) stop() {\n\t// Coordinate shutdown with reload/update paths that read source pointers.\n\tsvr.reloadMu.Lock()\n\tif svr.aggregator != nil {\n\t\tsvr.aggregator = nil\n\t}\n\tsvr.configSource = nil\n\tsvr.storeSource = nil\n\tsvr.reloadMu.Unlock()\n\n\tsvr.ctlMu.Lock()\n\tdefer svr.ctlMu.Unlock()\n\tif svr.ctl != nil {\n\t\tsvr.ctl.GracefulClose(svr.gracefulShutdownDuration)\n\t\tsvr.ctl = nil\n\t}\n\tif svr.webServer != nil {\n\t\tsvr.webServer.Close()\n\t\tsvr.webServer = nil\n\t}\n\tif svr.vnetController != nil {\n\t\t_ = svr.vnetController.Stop()\n\t\tsvr.vnetController = nil\n\t}\n}\n\nfunc (svr *Service) getProxyStatus(name string) (*proxy.WorkingStatus, bool) {\n\tsvr.ctlMu.RLock()\n\tctl := svr.ctl\n\tsvr.ctlMu.RUnlock()\n\n\tif ctl == nil {\n\t\treturn nil, false\n\t}\n\treturn ctl.pm.GetProxyStatus(name)\n}\n\nfunc (svr *Service) getVisitorCfg(name string) (v1.VisitorConfigurer, bool) {\n\tsvr.ctlMu.RLock()\n\tctl := svr.ctl\n\tsvr.ctlMu.RUnlock()\n\n\tif ctl == nil {\n\t\treturn nil, false\n\t}\n\treturn ctl.vm.GetVisitorCfg(name)\n}\n\nfunc (svr *Service) StatusExporter() StatusExporter {\n\treturn &statusExporterImpl{\n\t\tgetProxyStatusFunc: svr.getProxyStatus,\n\t}\n}\n\ntype StatusExporter interface {\n\tGetProxyStatus(name string) (*proxy.WorkingStatus, bool)\n}\n\ntype statusExporterImpl struct {\n\tgetProxyStatusFunc func(name string) (*proxy.WorkingStatus, bool)\n}\n\nfunc (s *statusExporterImpl) GetProxyStatus(name string) (*proxy.WorkingStatus, bool) {\n\treturn s.getProxyStatusFunc(name)\n}\n\nfunc (svr *Service) reloadConfigFromSources() error {\n\tsvr.reloadMu.Lock()\n\tdefer svr.reloadMu.Unlock()\n\treturn svr.reloadConfigFromSourcesLocked()\n}\n\nfunc (svr *Service) reloadConfigFromSourcesLocked() error {\n\taggregator := svr.aggregator\n\tif aggregator == nil {\n\t\treturn errors.New(\"config aggregator is not initialized\")\n\t}\n\n\tsvr.cfgMu.RLock()\n\treloadCommon := svr.reloadCommon\n\tsvr.cfgMu.RUnlock()\n\n\tproxies, visitors, err := aggregator.Load()\n\tif err != nil {\n\t\treturn fmt.Errorf(\"reload config from sources failed: %w\", err)\n\t}\n\n\tproxies, visitors = config.FilterClientConfigurers(reloadCommon, proxies, visitors)\n\tproxies = config.CompleteProxyConfigurers(proxies)\n\tvisitors = config.CompleteVisitorConfigurers(visitors)\n\n\t// Atomically replace the entire configuration\n\tif err := svr.UpdateAllConfigurer(proxies, visitors); err != nil {\n\t\treturn err\n\t}\n\treturn nil\n}\n"
  },
  {
    "path": "client/service_test.go",
    "content": "package client\n\nimport (\n\t\"context\"\n\t\"errors\"\n\t\"net\"\n\t\"path/filepath\"\n\t\"strconv\"\n\t\"strings\"\n\t\"testing\"\n\n\t\"github.com/samber/lo\"\n\n\t\"github.com/fatedier/frp/pkg/config/source\"\n\tv1 \"github.com/fatedier/frp/pkg/config/v1\"\n)\n\ntype failingConnector struct {\n\terr error\n}\n\nfunc (c *failingConnector) Open() error {\n\treturn c.err\n}\n\nfunc (c *failingConnector) Connect() (net.Conn, error) {\n\treturn nil, c.err\n}\n\nfunc (c *failingConnector) Close() error {\n\treturn nil\n}\n\nfunc getFreeTCPPort(t *testing.T) int {\n\tt.Helper()\n\n\tln, err := net.Listen(\"tcp\", \"127.0.0.1:0\")\n\tif err != nil {\n\t\tt.Fatalf(\"listen on ephemeral port: %v\", err)\n\t}\n\tdefer ln.Close()\n\n\treturn ln.Addr().(*net.TCPAddr).Port\n}\n\nfunc TestRunStopsStartedComponentsOnInitialLoginFailure(t *testing.T) {\n\tport := getFreeTCPPort(t)\n\tagg := source.NewAggregator(source.NewConfigSource())\n\n\tsvr, err := NewService(ServiceOptions{\n\t\tCommon: &v1.ClientCommonConfig{\n\t\t\tLoginFailExit: lo.ToPtr(true),\n\t\t\tWebServer: v1.WebServerConfig{\n\t\t\t\tAddr: \"127.0.0.1\",\n\t\t\t\tPort: port,\n\t\t\t},\n\t\t},\n\t\tConfigSourceAggregator: agg,\n\t\tConnectorCreator: func(context.Context, *v1.ClientCommonConfig) Connector {\n\t\t\treturn &failingConnector{err: errors.New(\"login boom\")}\n\t\t},\n\t})\n\tif err != nil {\n\t\tt.Fatalf(\"new service: %v\", err)\n\t}\n\n\terr = svr.Run(context.Background())\n\tif err == nil {\n\t\tt.Fatal(\"expected run error, got nil\")\n\t}\n\tif !strings.Contains(err.Error(), \"login boom\") {\n\t\tt.Fatalf(\"unexpected error: %v\", err)\n\t}\n\tif svr.webServer != nil {\n\t\tt.Fatal(\"expected web server to be cleaned up after initial login failure\")\n\t}\n\n\tln, err := net.Listen(\"tcp\", net.JoinHostPort(\"127.0.0.1\", strconv.Itoa(port)))\n\tif err != nil {\n\t\tt.Fatalf(\"expected admin port to be released: %v\", err)\n\t}\n\t_ = ln.Close()\n}\n\nfunc TestNewServiceDoesNotLeakAdminListenerOnAuthBuildFailure(t *testing.T) {\n\tport := getFreeTCPPort(t)\n\tagg := source.NewAggregator(source.NewConfigSource())\n\n\t_, err := NewService(ServiceOptions{\n\t\tCommon: &v1.ClientCommonConfig{\n\t\t\tAuth: v1.AuthClientConfig{\n\t\t\t\tMethod: v1.AuthMethodOIDC,\n\t\t\t\tOIDC: v1.AuthOIDCClientConfig{\n\t\t\t\t\tTokenEndpointURL: \"://bad\",\n\t\t\t\t},\n\t\t\t},\n\t\t\tWebServer: v1.WebServerConfig{\n\t\t\t\tAddr: \"127.0.0.1\",\n\t\t\t\tPort: port,\n\t\t\t},\n\t\t},\n\t\tConfigSourceAggregator: agg,\n\t})\n\tif err == nil {\n\t\tt.Fatal(\"expected new service error, got nil\")\n\t}\n\tif !strings.Contains(err.Error(), \"auth.oidc.tokenEndpointURL\") {\n\t\tt.Fatalf(\"unexpected error: %v\", err)\n\t}\n\n\tln, err := net.Listen(\"tcp\", net.JoinHostPort(\"127.0.0.1\", strconv.Itoa(port)))\n\tif err != nil {\n\t\tt.Fatalf(\"expected admin port to remain free: %v\", err)\n\t}\n\t_ = ln.Close()\n}\n\nfunc TestUpdateConfigSourceRollsBackReloadCommonOnReplaceAllFailure(t *testing.T) {\n\tprevCommon := &v1.ClientCommonConfig{User: \"old-user\"}\n\tnewCommon := &v1.ClientCommonConfig{User: \"new-user\"}\n\n\tsvr := &Service{\n\t\tconfigSource: source.NewConfigSource(),\n\t\treloadCommon: prevCommon,\n\t}\n\n\tinvalidProxy := &v1.TCPProxyConfig{}\n\terr := svr.UpdateConfigSource(newCommon, []v1.ProxyConfigurer{invalidProxy}, nil)\n\tif err == nil {\n\t\tt.Fatal(\"expected error, got nil\")\n\t}\n\n\tif !strings.Contains(err.Error(), \"proxy name cannot be empty\") {\n\t\tt.Fatalf(\"unexpected error: %v\", err)\n\t}\n\n\tif svr.reloadCommon != prevCommon {\n\t\tt.Fatalf(\"reloadCommon should roll back on ReplaceAll failure\")\n\t}\n}\n\nfunc TestUpdateConfigSourceKeepsReloadCommonOnReloadFailure(t *testing.T) {\n\tprevCommon := &v1.ClientCommonConfig{User: \"old-user\"}\n\tnewCommon := &v1.ClientCommonConfig{User: \"new-user\"}\n\n\tsvr := &Service{\n\t\t// Keep configSource valid so ReplaceAll succeeds first.\n\t\tconfigSource: source.NewConfigSource(),\n\t\treloadCommon: prevCommon,\n\t\t// Keep aggregator nil to force reload failure.\n\t\taggregator: nil,\n\t}\n\n\tvalidProxy := &v1.TCPProxyConfig{\n\t\tProxyBaseConfig: v1.ProxyBaseConfig{\n\t\t\tName: \"p1\",\n\t\t\tType: \"tcp\",\n\t\t},\n\t}\n\terr := svr.UpdateConfigSource(newCommon, []v1.ProxyConfigurer{validProxy}, nil)\n\tif err == nil {\n\t\tt.Fatal(\"expected error, got nil\")\n\t}\n\n\tif !strings.Contains(err.Error(), \"config aggregator is not initialized\") {\n\t\tt.Fatalf(\"unexpected error: %v\", err)\n\t}\n\n\tif svr.reloadCommon != newCommon {\n\t\tt.Fatalf(\"reloadCommon should keep new value on reload failure\")\n\t}\n}\n\nfunc TestReloadConfigFromSourcesDoesNotMutateStoreConfigs(t *testing.T) {\n\tstoreSource, err := source.NewStoreSource(source.StoreSourceConfig{\n\t\tPath: filepath.Join(t.TempDir(), \"store.json\"),\n\t})\n\tif err != nil {\n\t\tt.Fatalf(\"new store source: %v\", err)\n\t}\n\n\tproxyCfg := &v1.TCPProxyConfig{\n\t\tProxyBaseConfig: v1.ProxyBaseConfig{\n\t\t\tName: \"store-proxy\",\n\t\t\tType: \"tcp\",\n\t\t},\n\t}\n\tvisitorCfg := &v1.STCPVisitorConfig{\n\t\tVisitorBaseConfig: v1.VisitorBaseConfig{\n\t\t\tName: \"store-visitor\",\n\t\t\tType: \"stcp\",\n\t\t},\n\t}\n\tif err := storeSource.AddProxy(proxyCfg); err != nil {\n\t\tt.Fatalf(\"add proxy to store: %v\", err)\n\t}\n\tif err := storeSource.AddVisitor(visitorCfg); err != nil {\n\t\tt.Fatalf(\"add visitor to store: %v\", err)\n\t}\n\n\tagg := source.NewAggregator(source.NewConfigSource())\n\tagg.SetStoreSource(storeSource)\n\tsvr := &Service{\n\t\taggregator:   agg,\n\t\tconfigSource: agg.ConfigSource(),\n\t\tstoreSource:  storeSource,\n\t\treloadCommon: &v1.ClientCommonConfig{},\n\t}\n\n\tif err := svr.reloadConfigFromSources(); err != nil {\n\t\tt.Fatalf(\"reload config from sources: %v\", err)\n\t}\n\n\tgotProxy := storeSource.GetProxy(\"store-proxy\")\n\tif gotProxy == nil {\n\t\tt.Fatalf(\"proxy not found in store\")\n\t}\n\tif gotProxy.GetBaseConfig().LocalIP != \"\" {\n\t\tt.Fatalf(\"store proxy localIP should stay empty, got %q\", gotProxy.GetBaseConfig().LocalIP)\n\t}\n\n\tgotVisitor := storeSource.GetVisitor(\"store-visitor\")\n\tif gotVisitor == nil {\n\t\tt.Fatalf(\"visitor not found in store\")\n\t}\n\tif gotVisitor.GetBaseConfig().BindAddr != \"\" {\n\t\tt.Fatalf(\"store visitor bindAddr should stay empty, got %q\", gotVisitor.GetBaseConfig().BindAddr)\n\t}\n\n\tsvr.cfgMu.RLock()\n\tdefer svr.cfgMu.RUnlock()\n\n\tif len(svr.proxyCfgs) != 1 {\n\t\tt.Fatalf(\"expected 1 runtime proxy, got %d\", len(svr.proxyCfgs))\n\t}\n\tif svr.proxyCfgs[0].GetBaseConfig().LocalIP != \"127.0.0.1\" {\n\t\tt.Fatalf(\"runtime proxy localIP should be defaulted, got %q\", svr.proxyCfgs[0].GetBaseConfig().LocalIP)\n\t}\n\n\tif len(svr.visitorCfgs) != 1 {\n\t\tt.Fatalf(\"expected 1 runtime visitor, got %d\", len(svr.visitorCfgs))\n\t}\n\tif svr.visitorCfgs[0].GetBaseConfig().BindAddr != \"127.0.0.1\" {\n\t\tt.Fatalf(\"runtime visitor bindAddr should be defaulted, got %q\", svr.visitorCfgs[0].GetBaseConfig().BindAddr)\n\t}\n}\n"
  },
  {
    "path": "client/visitor/stcp.go",
    "content": "// Copyright 2017 fatedier, fatedier@gmail.com\n//\n// Licensed under the Apache License, Version 2.0 (the \"License\");\n// you may not use this file except in compliance with the License.\n// You may obtain a copy of the License at\n//\n//     http://www.apache.org/licenses/LICENSE-2.0\n//\n// Unless required by applicable law or agreed to in writing, software\n// distributed under the License is distributed on an \"AS IS\" BASIS,\n// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.\n// See the License for the specific language governing permissions and\n// limitations under the License.\n\npackage visitor\n\nimport (\n\t\"net\"\n\t\"strconv\"\n\n\tlibio \"github.com/fatedier/golib/io\"\n\n\tv1 \"github.com/fatedier/frp/pkg/config/v1\"\n\t\"github.com/fatedier/frp/pkg/util/xlog\"\n)\n\ntype STCPVisitor struct {\n\t*BaseVisitor\n\n\tcfg *v1.STCPVisitorConfig\n}\n\nfunc (sv *STCPVisitor) Run() (err error) {\n\tif sv.cfg.BindPort > 0 {\n\t\tsv.l, err = net.Listen(\"tcp\", net.JoinHostPort(sv.cfg.BindAddr, strconv.Itoa(sv.cfg.BindPort)))\n\t\tif err != nil {\n\t\t\treturn\n\t\t}\n\t\tgo sv.acceptLoop(sv.l, \"stcp local\", sv.handleConn)\n\t}\n\n\tgo sv.acceptLoop(sv.internalLn, \"stcp internal\", sv.handleConn)\n\n\tif sv.plugin != nil {\n\t\tsv.plugin.Start()\n\t}\n\treturn\n}\n\nfunc (sv *STCPVisitor) Close() {\n\tsv.BaseVisitor.Close()\n}\n\nfunc (sv *STCPVisitor) handleConn(userConn net.Conn) {\n\txl := xlog.FromContextSafe(sv.ctx)\n\tvar tunnelErr error\n\tdefer func() {\n\t\tif tunnelErr != nil {\n\t\t\tif eConn, ok := userConn.(interface{ CloseWithError(error) error }); ok {\n\t\t\t\t_ = eConn.CloseWithError(tunnelErr)\n\t\t\t\treturn\n\t\t\t}\n\t\t}\n\t\tuserConn.Close()\n\t}()\n\n\txl.Debugf(\"get a new stcp user connection\")\n\tvisitorConn, err := sv.dialRawVisitorConn(sv.cfg.GetBaseConfig())\n\tif err != nil {\n\t\txl.Warnf(\"dialRawVisitorConn error: %v\", err)\n\t\ttunnelErr = err\n\t\treturn\n\t}\n\tdefer visitorConn.Close()\n\n\tremote, recycleFn, err := wrapVisitorConn(visitorConn, sv.cfg.GetBaseConfig())\n\tif err != nil {\n\t\txl.Warnf(\"wrapVisitorConn error: %v\", err)\n\t\ttunnelErr = err\n\t\treturn\n\t}\n\tdefer recycleFn()\n\n\tlibio.Join(userConn, remote)\n}\n"
  },
  {
    "path": "client/visitor/sudp.go",
    "content": "// Copyright 2017 fatedier, fatedier@gmail.com\n//\n// Licensed under the Apache License, Version 2.0 (the \"License\");\n// you may not use this file except in compliance with the License.\n// You may obtain a copy of the License at\n//\n//     http://www.apache.org/licenses/LICENSE-2.0\n//\n// Unless required by applicable law or agreed to in writing, software\n// distributed under the License is distributed on an \"AS IS\" BASIS,\n// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.\n// See the License for the specific language governing permissions and\n// limitations under the License.\n\npackage visitor\n\nimport (\n\t\"fmt\"\n\t\"net\"\n\t\"strconv\"\n\t\"sync\"\n\t\"time\"\n\n\t\"github.com/fatedier/golib/errors\"\n\n\tv1 \"github.com/fatedier/frp/pkg/config/v1\"\n\t\"github.com/fatedier/frp/pkg/msg\"\n\t\"github.com/fatedier/frp/pkg/proto/udp\"\n\tnetpkg \"github.com/fatedier/frp/pkg/util/net\"\n\t\"github.com/fatedier/frp/pkg/util/xlog\"\n)\n\ntype SUDPVisitor struct {\n\t*BaseVisitor\n\n\tcheckCloseCh chan struct{}\n\t// udpConn is the listener of udp packet\n\tudpConn *net.UDPConn\n\treadCh  chan *msg.UDPPacket\n\tsendCh  chan *msg.UDPPacket\n\n\tcfg *v1.SUDPVisitorConfig\n}\n\n// SUDP Run start listen a udp port\nfunc (sv *SUDPVisitor) Run() (err error) {\n\txl := xlog.FromContextSafe(sv.ctx)\n\n\taddr, err := net.ResolveUDPAddr(\"udp\", net.JoinHostPort(sv.cfg.BindAddr, strconv.Itoa(sv.cfg.BindPort)))\n\tif err != nil {\n\t\treturn fmt.Errorf(\"sudp ResolveUDPAddr error: %v\", err)\n\t}\n\n\tsv.udpConn, err = net.ListenUDP(\"udp\", addr)\n\tif err != nil {\n\t\treturn fmt.Errorf(\"listen udp port %s error: %v\", addr.String(), err)\n\t}\n\n\tsv.sendCh = make(chan *msg.UDPPacket, 1024)\n\tsv.readCh = make(chan *msg.UDPPacket, 1024)\n\n\txl.Infof(\"sudp start to work, listen on %s\", addr)\n\n\tgo sv.dispatcher()\n\tgo udp.ForwardUserConn(sv.udpConn, sv.readCh, sv.sendCh, int(sv.clientCfg.UDPPacketSize))\n\n\treturn\n}\n\nfunc (sv *SUDPVisitor) dispatcher() {\n\txl := xlog.FromContextSafe(sv.ctx)\n\n\tvar (\n\t\tvisitorConn net.Conn\n\t\trecycleFn   func()\n\t\terr         error\n\n\t\tfirstPacket *msg.UDPPacket\n\t)\n\n\tfor {\n\t\tselect {\n\t\tcase firstPacket = <-sv.sendCh:\n\t\t\tif firstPacket == nil {\n\t\t\t\txl.Infof(\"frpc sudp visitor proxy is closed\")\n\t\t\t\treturn\n\t\t\t}\n\t\tcase <-sv.checkCloseCh:\n\t\t\txl.Infof(\"frpc sudp visitor proxy is closed\")\n\t\t\treturn\n\t\t}\n\n\t\tvisitorConn, recycleFn, err = sv.getNewVisitorConn()\n\t\tif err != nil {\n\t\t\txl.Warnf(\"newVisitorConn to frps error: %v, try to reconnect\", err)\n\t\t\tcontinue\n\t\t}\n\n\t\t// visitorConn always be closed when worker done.\n\t\tfunc() {\n\t\t\tdefer recycleFn()\n\t\t\tsv.worker(visitorConn, firstPacket)\n\t\t}()\n\n\t\tselect {\n\t\tcase <-sv.checkCloseCh:\n\t\t\treturn\n\t\tdefault:\n\t\t}\n\t}\n}\n\nfunc (sv *SUDPVisitor) worker(workConn net.Conn, firstPacket *msg.UDPPacket) {\n\txl := xlog.FromContextSafe(sv.ctx)\n\txl.Debugf(\"starting sudp proxy worker\")\n\n\twg := &sync.WaitGroup{}\n\twg.Add(2)\n\tcloseCh := make(chan struct{})\n\n\t// udp service -> frpc -> frps -> frpc visitor -> user\n\tworkConnReaderFn := func(conn net.Conn) {\n\t\tdefer func() {\n\t\t\tconn.Close()\n\t\t\tclose(closeCh)\n\t\t\twg.Done()\n\t\t}()\n\n\t\tfor {\n\t\t\tvar (\n\t\t\t\trawMsg msg.Message\n\t\t\t\terrRet error\n\t\t\t)\n\n\t\t\t// frpc will send heartbeat in workConn to frpc visitor for keeping alive\n\t\t\t_ = conn.SetReadDeadline(time.Now().Add(60 * time.Second))\n\t\t\tif rawMsg, errRet = msg.ReadMsg(conn); errRet != nil {\n\t\t\t\txl.Warnf(\"read from workconn for user udp conn error: %v\", errRet)\n\t\t\t\treturn\n\t\t\t}\n\n\t\t\t_ = conn.SetReadDeadline(time.Time{})\n\t\t\tswitch m := rawMsg.(type) {\n\t\t\tcase *msg.Ping:\n\t\t\t\txl.Debugf(\"frpc visitor get ping message from frpc\")\n\t\t\t\tcontinue\n\t\t\tcase *msg.UDPPacket:\n\t\t\t\tif errRet := errors.PanicToError(func() {\n\t\t\t\t\tsv.readCh <- m\n\t\t\t\t\txl.Tracef(\"frpc visitor get udp packet from workConn, len: %d\", len(m.Content))\n\t\t\t\t}); errRet != nil {\n\t\t\t\t\txl.Infof(\"reader goroutine for udp work connection closed\")\n\t\t\t\t\treturn\n\t\t\t\t}\n\t\t\t}\n\t\t}\n\t}\n\n\t// udp service <- frpc <- frps <- frpc visitor <- user\n\tworkConnSenderFn := func(conn net.Conn) {\n\t\tdefer func() {\n\t\t\tconn.Close()\n\t\t\twg.Done()\n\t\t}()\n\n\t\tvar errRet error\n\t\tif firstPacket != nil {\n\t\t\tif errRet = msg.WriteMsg(conn, firstPacket); errRet != nil {\n\t\t\t\txl.Warnf(\"sender goroutine for udp work connection closed: %v\", errRet)\n\t\t\t\treturn\n\t\t\t}\n\t\t\txl.Tracef(\"send udp package to workConn, len: %d\", len(firstPacket.Content))\n\t\t}\n\n\t\tfor {\n\t\t\tselect {\n\t\t\tcase udpMsg, ok := <-sv.sendCh:\n\t\t\t\tif !ok {\n\t\t\t\t\txl.Infof(\"sender goroutine for udp work connection closed\")\n\t\t\t\t\treturn\n\t\t\t\t}\n\n\t\t\t\tif errRet = msg.WriteMsg(conn, udpMsg); errRet != nil {\n\t\t\t\t\txl.Warnf(\"sender goroutine for udp work connection closed: %v\", errRet)\n\t\t\t\t\treturn\n\t\t\t\t}\n\t\t\t\txl.Tracef(\"send udp package to workConn, len: %d\", len(udpMsg.Content))\n\t\t\tcase <-closeCh:\n\t\t\t\treturn\n\t\t\t}\n\t\t}\n\t}\n\n\tgo workConnReaderFn(workConn)\n\tgo workConnSenderFn(workConn)\n\n\twg.Wait()\n\txl.Infof(\"sudp worker is closed\")\n}\n\nfunc (sv *SUDPVisitor) getNewVisitorConn() (net.Conn, func(), error) {\n\trawConn, err := sv.dialRawVisitorConn(sv.cfg.GetBaseConfig())\n\tif err != nil {\n\t\treturn nil, func() {}, err\n\t}\n\trwc, recycleFn, err := wrapVisitorConn(rawConn, sv.cfg.GetBaseConfig())\n\tif err != nil {\n\t\trawConn.Close()\n\t\treturn nil, func() {}, err\n\t}\n\treturn netpkg.WrapReadWriteCloserToConn(rwc, rawConn), recycleFn, nil\n}\n\nfunc (sv *SUDPVisitor) Close() {\n\tsv.mu.Lock()\n\tdefer sv.mu.Unlock()\n\n\tselect {\n\tcase <-sv.checkCloseCh:\n\t\treturn\n\tdefault:\n\t\tclose(sv.checkCloseCh)\n\t}\n\tsv.BaseVisitor.Close()\n\tif sv.udpConn != nil {\n\t\tsv.udpConn.Close()\n\t}\n\tclose(sv.readCh)\n\tclose(sv.sendCh)\n}\n"
  },
  {
    "path": "client/visitor/visitor.go",
    "content": "// Copyright 2017 fatedier, fatedier@gmail.com\n//\n// Licensed under the Apache License, Version 2.0 (the \"License\");\n// you may not use this file except in compliance with the License.\n// You may obtain a copy of the License at\n//\n//     http://www.apache.org/licenses/LICENSE-2.0\n//\n// Unless required by applicable law or agreed to in writing, software\n// distributed under the License is distributed on an \"AS IS\" BASIS,\n// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.\n// See the License for the specific language governing permissions and\n// limitations under the License.\n\npackage visitor\n\nimport (\n\t\"context\"\n\t\"fmt\"\n\t\"io\"\n\t\"net\"\n\t\"sync\"\n\t\"time\"\n\n\tlibio \"github.com/fatedier/golib/io\"\n\n\tv1 \"github.com/fatedier/frp/pkg/config/v1\"\n\t\"github.com/fatedier/frp/pkg/msg\"\n\t\"github.com/fatedier/frp/pkg/naming\"\n\tplugin \"github.com/fatedier/frp/pkg/plugin/visitor\"\n\t\"github.com/fatedier/frp/pkg/transport\"\n\tnetpkg \"github.com/fatedier/frp/pkg/util/net\"\n\t\"github.com/fatedier/frp/pkg/util/util\"\n\t\"github.com/fatedier/frp/pkg/util/xlog\"\n\t\"github.com/fatedier/frp/pkg/vnet\"\n)\n\n// Helper wraps some functions for visitor to use.\ntype Helper interface {\n\t// ConnectServer directly connects to the frp server.\n\tConnectServer() (net.Conn, error)\n\t// TransferConn transfers the connection to another visitor.\n\tTransferConn(string, net.Conn) error\n\t// MsgTransporter returns the message transporter that is used to send and receive messages\n\t// to the frp server through the controller.\n\tMsgTransporter() transport.MessageTransporter\n\t// VNetController returns the vnet controller that is used to manage the virtual network.\n\tVNetController() *vnet.Controller\n\t// RunID returns the run id of current controller.\n\tRunID() string\n}\n\n// Visitor is used for forward traffics from local port tot remote service.\ntype Visitor interface {\n\tRun() error\n\tAcceptConn(conn net.Conn) error\n\tClose()\n}\n\nfunc NewVisitor(\n\tctx context.Context,\n\tcfg v1.VisitorConfigurer,\n\tclientCfg *v1.ClientCommonConfig,\n\thelper Helper,\n) (Visitor, error) {\n\txl := xlog.FromContextSafe(ctx).Spawn().AppendPrefix(cfg.GetBaseConfig().Name)\n\tctx = xlog.NewContext(ctx, xl)\n\tvar visitor Visitor\n\tbaseVisitor := BaseVisitor{\n\t\tclientCfg:  clientCfg,\n\t\thelper:     helper,\n\t\tctx:        ctx,\n\t\tinternalLn: netpkg.NewInternalListener(),\n\t}\n\tif cfg.GetBaseConfig().Plugin.Type != \"\" {\n\t\tp, err := plugin.Create(\n\t\t\tcfg.GetBaseConfig().Plugin.Type,\n\t\t\tplugin.PluginContext{\n\t\t\t\tName:           cfg.GetBaseConfig().Name,\n\t\t\t\tCtx:            ctx,\n\t\t\t\tVnetController: helper.VNetController(),\n\t\t\t\tSendConnToVisitor: func(conn net.Conn) {\n\t\t\t\t\t_ = baseVisitor.AcceptConn(conn)\n\t\t\t\t},\n\t\t\t},\n\t\t\tcfg.GetBaseConfig().Plugin.VisitorPluginOptions,\n\t\t)\n\t\tif err != nil {\n\t\t\treturn nil, err\n\t\t}\n\t\tbaseVisitor.plugin = p\n\t}\n\tswitch cfg := cfg.(type) {\n\tcase *v1.STCPVisitorConfig:\n\t\tvisitor = &STCPVisitor{\n\t\t\tBaseVisitor: &baseVisitor,\n\t\t\tcfg:         cfg,\n\t\t}\n\tcase *v1.XTCPVisitorConfig:\n\t\tvisitor = &XTCPVisitor{\n\t\t\tBaseVisitor:   &baseVisitor,\n\t\t\tcfg:           cfg,\n\t\t\tstartTunnelCh: make(chan struct{}),\n\t\t}\n\tcase *v1.SUDPVisitorConfig:\n\t\tvisitor = &SUDPVisitor{\n\t\t\tBaseVisitor:  &baseVisitor,\n\t\t\tcfg:          cfg,\n\t\t\tcheckCloseCh: make(chan struct{}),\n\t\t}\n\t}\n\treturn visitor, nil\n}\n\ntype BaseVisitor struct {\n\tclientCfg  *v1.ClientCommonConfig\n\thelper     Helper\n\tl          net.Listener\n\tinternalLn *netpkg.InternalListener\n\tplugin     plugin.Plugin\n\n\tmu  sync.RWMutex\n\tctx context.Context\n}\n\nfunc (v *BaseVisitor) AcceptConn(conn net.Conn) error {\n\treturn v.internalLn.PutConn(conn)\n}\n\nfunc (v *BaseVisitor) acceptLoop(l net.Listener, name string, handleConn func(net.Conn)) {\n\txl := xlog.FromContextSafe(v.ctx)\n\tfor {\n\t\tconn, err := l.Accept()\n\t\tif err != nil {\n\t\t\txl.Warnf(\"%s listener closed\", name)\n\t\t\treturn\n\t\t}\n\t\tgo handleConn(conn)\n\t}\n}\n\nfunc (v *BaseVisitor) Close() {\n\tif v.l != nil {\n\t\tv.l.Close()\n\t}\n\tif v.internalLn != nil {\n\t\tv.internalLn.Close()\n\t}\n\tif v.plugin != nil {\n\t\tv.plugin.Close()\n\t}\n}\n\nfunc (v *BaseVisitor) dialRawVisitorConn(cfg *v1.VisitorBaseConfig) (net.Conn, error) {\n\tvisitorConn, err := v.helper.ConnectServer()\n\tif err != nil {\n\t\treturn nil, fmt.Errorf(\"connect to server error: %v\", err)\n\t}\n\n\tnow := time.Now().Unix()\n\ttargetProxyName := naming.BuildTargetServerProxyName(v.clientCfg.User, cfg.ServerUser, cfg.ServerName)\n\tnewVisitorConnMsg := &msg.NewVisitorConn{\n\t\tRunID:          v.helper.RunID(),\n\t\tProxyName:      targetProxyName,\n\t\tSignKey:        util.GetAuthKey(cfg.SecretKey, now),\n\t\tTimestamp:      now,\n\t\tUseEncryption:  cfg.Transport.UseEncryption,\n\t\tUseCompression: cfg.Transport.UseCompression,\n\t}\n\terr = msg.WriteMsg(visitorConn, newVisitorConnMsg)\n\tif err != nil {\n\t\tvisitorConn.Close()\n\t\treturn nil, fmt.Errorf(\"send newVisitorConnMsg to server error: %v\", err)\n\t}\n\n\tvar newVisitorConnRespMsg msg.NewVisitorConnResp\n\t_ = visitorConn.SetReadDeadline(time.Now().Add(10 * time.Second))\n\terr = msg.ReadMsgInto(visitorConn, &newVisitorConnRespMsg)\n\tif err != nil {\n\t\tvisitorConn.Close()\n\t\treturn nil, fmt.Errorf(\"read newVisitorConnRespMsg error: %v\", err)\n\t}\n\t_ = visitorConn.SetReadDeadline(time.Time{})\n\n\tif newVisitorConnRespMsg.Error != \"\" {\n\t\tvisitorConn.Close()\n\t\treturn nil, fmt.Errorf(\"start new visitor connection error: %s\", newVisitorConnRespMsg.Error)\n\t}\n\treturn visitorConn, nil\n}\n\nfunc wrapVisitorConn(conn io.ReadWriteCloser, cfg *v1.VisitorBaseConfig) (io.ReadWriteCloser, func(), error) {\n\trwc := conn\n\tif cfg.Transport.UseEncryption {\n\t\tvar err error\n\t\trwc, err = libio.WithEncryption(rwc, []byte(cfg.SecretKey))\n\t\tif err != nil {\n\t\t\treturn nil, func() {}, fmt.Errorf(\"create encryption stream error: %v\", err)\n\t\t}\n\t}\n\trecycleFn := func() {}\n\tif cfg.Transport.UseCompression {\n\t\trwc, recycleFn = libio.WithCompressionFromPool(rwc)\n\t}\n\treturn rwc, recycleFn, nil\n}\n"
  },
  {
    "path": "client/visitor/visitor_manager.go",
    "content": "// Copyright 2018 fatedier, fatedier@gmail.com\n//\n// Licensed under the Apache License, Version 2.0 (the \"License\");\n// you may not use this file except in compliance with the License.\n// You may obtain a copy of the License at\n//\n//     http://www.apache.org/licenses/LICENSE-2.0\n//\n// Unless required by applicable law or agreed to in writing, software\n// distributed under the License is distributed on an \"AS IS\" BASIS,\n// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.\n// See the License for the specific language governing permissions and\n// limitations under the License.\n\npackage visitor\n\nimport (\n\t\"context\"\n\t\"fmt\"\n\t\"net\"\n\t\"reflect\"\n\t\"sync\"\n\t\"time\"\n\n\t\"github.com/samber/lo\"\n\n\tv1 \"github.com/fatedier/frp/pkg/config/v1\"\n\t\"github.com/fatedier/frp/pkg/transport\"\n\t\"github.com/fatedier/frp/pkg/util/xlog\"\n\t\"github.com/fatedier/frp/pkg/vnet\"\n)\n\ntype Manager struct {\n\tclientCfg *v1.ClientCommonConfig\n\tcfgs      map[string]v1.VisitorConfigurer\n\tvisitors  map[string]Visitor\n\thelper    Helper\n\n\tcheckInterval           time.Duration\n\tkeepVisitorsRunningOnce sync.Once\n\n\tmu  sync.RWMutex\n\tctx context.Context\n\n\tstopCh chan struct{}\n}\n\nfunc NewManager(\n\tctx context.Context,\n\trunID string,\n\tclientCfg *v1.ClientCommonConfig,\n\tconnectServer func() (net.Conn, error),\n\tmsgTransporter transport.MessageTransporter,\n\tvnetController *vnet.Controller,\n) *Manager {\n\tm := &Manager{\n\t\tclientCfg:     clientCfg,\n\t\tcfgs:          make(map[string]v1.VisitorConfigurer),\n\t\tvisitors:      make(map[string]Visitor),\n\t\tcheckInterval: 10 * time.Second,\n\t\tctx:           ctx,\n\t\tstopCh:        make(chan struct{}),\n\t}\n\tm.helper = &visitorHelperImpl{\n\t\tconnectServerFn: connectServer,\n\t\tmsgTransporter:  msgTransporter,\n\t\tvnetController:  vnetController,\n\t\ttransferConnFn:  m.TransferConn,\n\t\trunID:           runID,\n\t}\n\treturn m\n}\n\n// keepVisitorsRunning checks all visitors' status periodically, if some visitor is not running, start it.\n// It will only start after Reload is called and a new visitor is added.\nfunc (vm *Manager) keepVisitorsRunning() {\n\txl := xlog.FromContextSafe(vm.ctx)\n\n\tticker := time.NewTicker(vm.checkInterval)\n\tdefer ticker.Stop()\n\n\tfor {\n\t\tselect {\n\t\tcase <-vm.stopCh:\n\t\t\txl.Tracef(\"gracefully shutdown visitor manager\")\n\t\t\treturn\n\t\tcase <-ticker.C:\n\t\t\tvm.mu.Lock()\n\t\t\tfor _, cfg := range vm.cfgs {\n\t\t\t\tname := cfg.GetBaseConfig().Name\n\t\t\t\tif _, exist := vm.visitors[name]; !exist {\n\t\t\t\t\txl.Infof(\"try to start visitor [%s]\", name)\n\t\t\t\t\t_ = vm.startVisitor(cfg)\n\t\t\t\t}\n\t\t\t}\n\t\t\tvm.mu.Unlock()\n\t\t}\n\t}\n}\n\nfunc (vm *Manager) Close() {\n\tvm.mu.Lock()\n\tdefer vm.mu.Unlock()\n\tfor _, v := range vm.visitors {\n\t\tv.Close()\n\t}\n\tselect {\n\tcase <-vm.stopCh:\n\tdefault:\n\t\tclose(vm.stopCh)\n\t}\n}\n\n// Hold lock before calling this function.\nfunc (vm *Manager) startVisitor(cfg v1.VisitorConfigurer) (err error) {\n\txl := xlog.FromContextSafe(vm.ctx)\n\tname := cfg.GetBaseConfig().Name\n\tvisitor, err := NewVisitor(vm.ctx, cfg, vm.clientCfg, vm.helper)\n\tif err != nil {\n\t\txl.Warnf(\"new visitor error: %v\", err)\n\t\treturn\n\t}\n\terr = visitor.Run()\n\tif err != nil {\n\t\txl.Warnf(\"start error: %v\", err)\n\t} else {\n\t\tvm.visitors[name] = visitor\n\t\txl.Infof(\"start visitor success\")\n\t}\n\treturn\n}\n\nfunc (vm *Manager) UpdateAll(cfgs []v1.VisitorConfigurer) {\n\tif len(cfgs) > 0 {\n\t\t// Only start keepVisitorsRunning goroutine once and only when there is at least one visitor.\n\t\tvm.keepVisitorsRunningOnce.Do(func() {\n\t\t\tgo vm.keepVisitorsRunning()\n\t\t})\n\t}\n\n\txl := xlog.FromContextSafe(vm.ctx)\n\tcfgsMap := lo.KeyBy(cfgs, func(c v1.VisitorConfigurer) string {\n\t\treturn c.GetBaseConfig().Name\n\t})\n\tvm.mu.Lock()\n\tdefer vm.mu.Unlock()\n\n\tdelNames := make([]string, 0)\n\tfor name, oldCfg := range vm.cfgs {\n\t\tdel := false\n\t\tcfg, ok := cfgsMap[name]\n\t\tif !ok || !reflect.DeepEqual(oldCfg, cfg) {\n\t\t\tdel = true\n\t\t}\n\n\t\tif del {\n\t\t\tdelNames = append(delNames, name)\n\t\t\tdelete(vm.cfgs, name)\n\t\t\tif visitor, ok := vm.visitors[name]; ok {\n\t\t\t\tvisitor.Close()\n\t\t\t}\n\t\t\tdelete(vm.visitors, name)\n\t\t}\n\t}\n\tif len(delNames) > 0 {\n\t\txl.Infof(\"visitor removed: %v\", delNames)\n\t}\n\n\taddNames := make([]string, 0)\n\tfor _, cfg := range cfgs {\n\t\tname := cfg.GetBaseConfig().Name\n\t\tif _, ok := vm.cfgs[name]; !ok {\n\t\t\tvm.cfgs[name] = cfg\n\t\t\taddNames = append(addNames, name)\n\t\t\t_ = vm.startVisitor(cfg)\n\t\t}\n\t}\n\tif len(addNames) > 0 {\n\t\txl.Infof(\"visitor added: %v\", addNames)\n\t}\n}\n\n// TransferConn transfers a connection to a visitor.\nfunc (vm *Manager) TransferConn(name string, conn net.Conn) error {\n\tvm.mu.RLock()\n\tdefer vm.mu.RUnlock()\n\tv, ok := vm.visitors[name]\n\tif !ok {\n\t\treturn fmt.Errorf(\"visitor [%s] not found\", name)\n\t}\n\treturn v.AcceptConn(conn)\n}\n\nfunc (vm *Manager) GetVisitorCfg(name string) (v1.VisitorConfigurer, bool) {\n\tvm.mu.RLock()\n\tdefer vm.mu.RUnlock()\n\tcfg, ok := vm.cfgs[name]\n\treturn cfg, ok\n}\n\ntype visitorHelperImpl struct {\n\tconnectServerFn func() (net.Conn, error)\n\tmsgTransporter  transport.MessageTransporter\n\tvnetController  *vnet.Controller\n\ttransferConnFn  func(name string, conn net.Conn) error\n\trunID           string\n}\n\nfunc (v *visitorHelperImpl) ConnectServer() (net.Conn, error) {\n\treturn v.connectServerFn()\n}\n\nfunc (v *visitorHelperImpl) TransferConn(name string, conn net.Conn) error {\n\treturn v.transferConnFn(name, conn)\n}\n\nfunc (v *visitorHelperImpl) MsgTransporter() transport.MessageTransporter {\n\treturn v.msgTransporter\n}\n\nfunc (v *visitorHelperImpl) VNetController() *vnet.Controller {\n\treturn v.vnetController\n}\n\nfunc (v *visitorHelperImpl) RunID() string {\n\treturn v.runID\n}\n"
  },
  {
    "path": "client/visitor/xtcp.go",
    "content": "// Copyright 2017 fatedier, fatedier@gmail.com\n//\n// Licensed under the Apache License, Version 2.0 (the \"License\");\n// you may not use this file except in compliance with the License.\n// You may obtain a copy of the License at\n//\n//     http://www.apache.org/licenses/LICENSE-2.0\n//\n// Unless required by applicable law or agreed to in writing, software\n// distributed under the License is distributed on an \"AS IS\" BASIS,\n// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.\n// See the License for the specific language governing permissions and\n// limitations under the License.\n\npackage visitor\n\nimport (\n\t\"context\"\n\t\"errors\"\n\t\"fmt\"\n\t\"io\"\n\t\"net\"\n\t\"strconv\"\n\t\"sync\"\n\t\"time\"\n\n\tlibio \"github.com/fatedier/golib/io\"\n\tfmux \"github.com/hashicorp/yamux\"\n\tquic \"github.com/quic-go/quic-go\"\n\t\"golang.org/x/time/rate\"\n\n\tv1 \"github.com/fatedier/frp/pkg/config/v1\"\n\t\"github.com/fatedier/frp/pkg/msg\"\n\t\"github.com/fatedier/frp/pkg/naming\"\n\t\"github.com/fatedier/frp/pkg/nathole\"\n\t\"github.com/fatedier/frp/pkg/transport\"\n\tnetpkg \"github.com/fatedier/frp/pkg/util/net\"\n\t\"github.com/fatedier/frp/pkg/util/util\"\n\t\"github.com/fatedier/frp/pkg/util/xlog\"\n)\n\nvar ErrNoTunnelSession = errors.New(\"no tunnel session\")\n\ntype XTCPVisitor struct {\n\t*BaseVisitor\n\tsession       TunnelSession\n\tstartTunnelCh chan struct{}\n\tretryLimiter  *rate.Limiter\n\tcancel        context.CancelFunc\n\n\tcfg *v1.XTCPVisitorConfig\n}\n\nfunc (sv *XTCPVisitor) Run() (err error) {\n\tsv.ctx, sv.cancel = context.WithCancel(sv.ctx)\n\n\tif sv.cfg.Protocol == \"kcp\" {\n\t\tsv.session = NewKCPTunnelSession()\n\t} else {\n\t\tsv.session = NewQUICTunnelSession(sv.clientCfg)\n\t}\n\n\tif sv.cfg.BindPort > 0 {\n\t\tsv.l, err = net.Listen(\"tcp\", net.JoinHostPort(sv.cfg.BindAddr, strconv.Itoa(sv.cfg.BindPort)))\n\t\tif err != nil {\n\t\t\treturn\n\t\t}\n\t\tgo sv.acceptLoop(sv.l, \"xtcp local\", sv.handleConn)\n\t}\n\n\tgo sv.acceptLoop(sv.internalLn, \"xtcp internal\", sv.handleConn)\n\tgo sv.processTunnelStartEvents()\n\tif sv.cfg.KeepTunnelOpen {\n\t\tsv.retryLimiter = rate.NewLimiter(rate.Every(time.Hour/time.Duration(sv.cfg.MaxRetriesAnHour)), sv.cfg.MaxRetriesAnHour)\n\t\tgo sv.keepTunnelOpenWorker()\n\t}\n\n\tif sv.plugin != nil {\n\t\tsv.plugin.Start()\n\t}\n\treturn\n}\n\nfunc (sv *XTCPVisitor) Close() {\n\tsv.mu.Lock()\n\tdefer sv.mu.Unlock()\n\tsv.BaseVisitor.Close()\n\tif sv.cancel != nil {\n\t\tsv.cancel()\n\t}\n\tif sv.session != nil {\n\t\tsv.session.Close()\n\t}\n}\n\nfunc (sv *XTCPVisitor) processTunnelStartEvents() {\n\tfor {\n\t\tselect {\n\t\tcase <-sv.ctx.Done():\n\t\t\treturn\n\t\tcase <-sv.startTunnelCh:\n\t\t\tstart := time.Now()\n\t\t\tsv.makeNatHole()\n\t\t\tduration := time.Since(start)\n\t\t\t// avoid too frequently\n\t\t\tif duration < 10*time.Second {\n\t\t\t\ttime.Sleep(10*time.Second - duration)\n\t\t\t}\n\t\t}\n\t}\n}\n\nfunc (sv *XTCPVisitor) keepTunnelOpenWorker() {\n\txl := xlog.FromContextSafe(sv.ctx)\n\tticker := time.NewTicker(time.Duration(sv.cfg.MinRetryInterval) * time.Second)\n\tdefer ticker.Stop()\n\n\tsv.startTunnelCh <- struct{}{}\n\tfor {\n\t\tselect {\n\t\tcase <-sv.ctx.Done():\n\t\t\treturn\n\t\tcase <-ticker.C:\n\t\t\txl.Debugf(\"keepTunnelOpenWorker try to check tunnel...\")\n\t\t\tconn, err := sv.getTunnelConn(sv.ctx)\n\t\t\tif err != nil {\n\t\t\t\txl.Warnf(\"keepTunnelOpenWorker get tunnel connection error: %v\", err)\n\t\t\t\t_ = sv.retryLimiter.Wait(sv.ctx)\n\t\t\t\tcontinue\n\t\t\t}\n\t\t\txl.Debugf(\"keepTunnelOpenWorker check success\")\n\t\t\tif conn != nil {\n\t\t\t\tconn.Close()\n\t\t\t}\n\t\t}\n\t}\n}\n\nfunc (sv *XTCPVisitor) handleConn(userConn net.Conn) {\n\txl := xlog.FromContextSafe(sv.ctx)\n\tisConnTransferred := false\n\tvar tunnelErr error\n\tdefer func() {\n\t\tif !isConnTransferred {\n\t\t\t// If there was an error and connection supports CloseWithError, use it\n\t\t\tif tunnelErr != nil {\n\t\t\t\tif eConn, ok := userConn.(interface{ CloseWithError(error) error }); ok {\n\t\t\t\t\t_ = eConn.CloseWithError(tunnelErr)\n\t\t\t\t\treturn\n\t\t\t\t}\n\t\t\t}\n\t\t\tuserConn.Close()\n\t\t}\n\t}()\n\n\txl.Debugf(\"get a new xtcp user connection\")\n\n\t// Open a tunnel connection to the server. If there is already a successful hole-punching connection,\n\t// it will be reused. Otherwise, it will block and wait for a successful hole-punching connection until timeout.\n\tctx := sv.ctx\n\tif sv.cfg.FallbackTo != \"\" {\n\t\ttimeoutCtx, cancel := context.WithTimeout(ctx, time.Duration(sv.cfg.FallbackTimeoutMs)*time.Millisecond)\n\t\tdefer cancel()\n\t\tctx = timeoutCtx\n\t}\n\ttunnelConn, err := sv.openTunnel(ctx)\n\tif err != nil {\n\t\txl.Errorf(\"open tunnel error: %v\", err)\n\t\ttunnelErr = err\n\n\t\t// no fallback, just return\n\t\tif sv.cfg.FallbackTo == \"\" {\n\t\t\treturn\n\t\t}\n\n\t\txl.Debugf(\"try to transfer connection to visitor: %s\", sv.cfg.FallbackTo)\n\t\tif err := sv.helper.TransferConn(sv.cfg.FallbackTo, userConn); err != nil {\n\t\t\txl.Errorf(\"transfer connection to visitor %s error: %v\", sv.cfg.FallbackTo, err)\n\t\t\treturn\n\t\t}\n\t\tisConnTransferred = true\n\t\treturn\n\t}\n\n\tmuxConnRWCloser, recycleFn, err := wrapVisitorConn(tunnelConn, sv.cfg.GetBaseConfig())\n\tif err != nil {\n\t\txl.Errorf(\"%v\", err)\n\t\ttunnelConn.Close()\n\t\ttunnelErr = err\n\t\treturn\n\t}\n\tdefer recycleFn()\n\n\t_, _, errs := libio.Join(userConn, muxConnRWCloser)\n\txl.Debugf(\"join connections closed\")\n\tif len(errs) > 0 {\n\t\txl.Tracef(\"join connections errors: %v\", errs)\n\t}\n}\n\n// openTunnel will open a tunnel connection to the target server.\nfunc (sv *XTCPVisitor) openTunnel(ctx context.Context) (conn net.Conn, err error) {\n\txl := xlog.FromContextSafe(sv.ctx)\n\tctx, cancel := context.WithTimeout(ctx, 20*time.Second)\n\tdefer cancel()\n\n\ttimer := time.NewTimer(0)\n\tdefer timer.Stop()\n\n\tfor {\n\t\tselect {\n\t\tcase <-sv.ctx.Done():\n\t\t\treturn nil, sv.ctx.Err()\n\t\tcase <-ctx.Done():\n\t\t\tif errors.Is(ctx.Err(), context.DeadlineExceeded) {\n\t\t\t\treturn nil, fmt.Errorf(\"open tunnel timeout\")\n\t\t\t}\n\t\t\treturn nil, ctx.Err()\n\t\tcase <-timer.C:\n\t\t\tconn, err = sv.getTunnelConn(ctx)\n\t\t\tif err != nil {\n\t\t\t\tif !errors.Is(err, ErrNoTunnelSession) {\n\t\t\t\t\txl.Warnf(\"get tunnel connection error: %v\", err)\n\t\t\t\t}\n\t\t\t\ttimer.Reset(500 * time.Millisecond)\n\t\t\t\tcontinue\n\t\t\t}\n\t\t\treturn conn, nil\n\t\t}\n\t}\n}\n\nfunc (sv *XTCPVisitor) getTunnelConn(ctx context.Context) (net.Conn, error) {\n\tconn, err := sv.session.OpenConn(ctx)\n\tif err == nil {\n\t\treturn conn, nil\n\t}\n\tsv.session.Close()\n\n\tselect {\n\tcase sv.startTunnelCh <- struct{}{}:\n\tdefault:\n\t}\n\treturn nil, err\n}\n\n// 0. PreCheck\n// 1. Prepare\n// 2. ExchangeInfo\n// 3. MakeNATHole\n// 4. Create a tunnel session using an underlying UDP connection.\nfunc (sv *XTCPVisitor) makeNatHole() {\n\txl := xlog.FromContextSafe(sv.ctx)\n\ttargetProxyName := naming.BuildTargetServerProxyName(sv.clientCfg.User, sv.cfg.ServerUser, sv.cfg.ServerName)\n\txl.Tracef(\"makeNatHole start\")\n\tif err := nathole.PreCheck(sv.ctx, sv.helper.MsgTransporter(), targetProxyName, 5*time.Second); err != nil {\n\t\txl.Warnf(\"nathole precheck error: %v\", err)\n\t\treturn\n\t}\n\n\txl.Tracef(\"nathole prepare start\")\n\n\t// Prepare NAT traversal options\n\tvar opts nathole.PrepareOptions\n\tif sv.cfg.NatTraversal != nil && sv.cfg.NatTraversal.DisableAssistedAddrs {\n\t\topts.DisableAssistedAddrs = true\n\t}\n\n\tprepareResult, err := nathole.Prepare([]string{sv.clientCfg.NatHoleSTUNServer}, opts)\n\tif err != nil {\n\t\txl.Warnf(\"nathole prepare error: %v\", err)\n\t\treturn\n\t}\n\n\txl.Infof(\"nathole prepare success, nat type: %s, behavior: %s, addresses: %v, assistedAddresses: %v\",\n\t\tprepareResult.NatType, prepareResult.Behavior, prepareResult.Addrs, prepareResult.AssistedAddrs)\n\n\tlistenConn := prepareResult.ListenConn\n\n\t// send NatHoleVisitor to server\n\tnow := time.Now().Unix()\n\ttransactionID := nathole.NewTransactionID()\n\tnatHoleVisitorMsg := &msg.NatHoleVisitor{\n\t\tTransactionID: transactionID,\n\t\tProxyName:     targetProxyName,\n\t\tProtocol:      sv.cfg.Protocol,\n\t\tSignKey:       util.GetAuthKey(sv.cfg.SecretKey, now),\n\t\tTimestamp:     now,\n\t\tMappedAddrs:   prepareResult.Addrs,\n\t\tAssistedAddrs: prepareResult.AssistedAddrs,\n\t}\n\n\txl.Tracef(\"nathole exchange info start\")\n\tnatHoleRespMsg, err := nathole.ExchangeInfo(sv.ctx, sv.helper.MsgTransporter(), transactionID, natHoleVisitorMsg, 5*time.Second)\n\tif err != nil {\n\t\tlistenConn.Close()\n\t\txl.Warnf(\"nathole exchange info error: %v\", err)\n\t\treturn\n\t}\n\n\txl.Infof(\"get natHoleRespMsg, sid [%s], protocol [%s], candidate address %v, assisted address %v, detectBehavior: %+v\",\n\t\tnatHoleRespMsg.Sid, natHoleRespMsg.Protocol, natHoleRespMsg.CandidateAddrs,\n\t\tnatHoleRespMsg.AssistedAddrs, natHoleRespMsg.DetectBehavior)\n\n\tnewListenConn, raddr, err := nathole.MakeHole(sv.ctx, listenConn, natHoleRespMsg, []byte(sv.cfg.SecretKey))\n\tif err != nil {\n\t\tlistenConn.Close()\n\t\txl.Warnf(\"make hole error: %v\", err)\n\t\treturn\n\t}\n\tlistenConn = newListenConn\n\txl.Infof(\"establishing nat hole connection successful, sid [%s], remoteAddr [%s]\", natHoleRespMsg.Sid, raddr)\n\n\tif err := sv.session.Init(listenConn, raddr); err != nil {\n\t\tlistenConn.Close()\n\t\txl.Warnf(\"init tunnel session error: %v\", err)\n\t\treturn\n\t}\n}\n\ntype TunnelSession interface {\n\tInit(listenConn *net.UDPConn, raddr *net.UDPAddr) error\n\tOpenConn(context.Context) (net.Conn, error)\n\tClose()\n}\n\ntype KCPTunnelSession struct {\n\tsession *fmux.Session\n\tlConn   *net.UDPConn\n\tmu      sync.RWMutex\n}\n\nfunc NewKCPTunnelSession() TunnelSession {\n\treturn &KCPTunnelSession{}\n}\n\nfunc (ks *KCPTunnelSession) Init(listenConn *net.UDPConn, raddr *net.UDPAddr) error {\n\tlistenConn.Close()\n\tladdr, _ := net.ResolveUDPAddr(\"udp\", listenConn.LocalAddr().String())\n\tlConn, err := net.DialUDP(\"udp\", laddr, raddr)\n\tif err != nil {\n\t\treturn fmt.Errorf(\"dial udp error: %v\", err)\n\t}\n\tremote, err := netpkg.NewKCPConnFromUDP(lConn, true, raddr.String())\n\tif err != nil {\n\t\tlConn.Close()\n\t\treturn fmt.Errorf(\"create kcp connection from udp connection error: %v\", err)\n\t}\n\n\tfmuxCfg := fmux.DefaultConfig()\n\tfmuxCfg.KeepAliveInterval = 10 * time.Second\n\tfmuxCfg.MaxStreamWindowSize = 6 * 1024 * 1024\n\tfmuxCfg.LogOutput = io.Discard\n\tsession, err := fmux.Client(remote, fmuxCfg)\n\tif err != nil {\n\t\tremote.Close()\n\t\treturn fmt.Errorf(\"initial client session error: %v\", err)\n\t}\n\tks.mu.Lock()\n\tks.session = session\n\tks.lConn = lConn\n\tks.mu.Unlock()\n\treturn nil\n}\n\nfunc (ks *KCPTunnelSession) OpenConn(_ context.Context) (net.Conn, error) {\n\tks.mu.RLock()\n\tdefer ks.mu.RUnlock()\n\tsession := ks.session\n\tif session == nil {\n\t\treturn nil, ErrNoTunnelSession\n\t}\n\treturn session.Open()\n}\n\nfunc (ks *KCPTunnelSession) Close() {\n\tks.mu.Lock()\n\tdefer ks.mu.Unlock()\n\tif ks.session != nil {\n\t\t_ = ks.session.Close()\n\t\tks.session = nil\n\t}\n\tif ks.lConn != nil {\n\t\t_ = ks.lConn.Close()\n\t\tks.lConn = nil\n\t}\n}\n\ntype QUICTunnelSession struct {\n\tsession    *quic.Conn\n\tlistenConn *net.UDPConn\n\tmu         sync.RWMutex\n\n\tclientCfg *v1.ClientCommonConfig\n}\n\nfunc NewQUICTunnelSession(clientCfg *v1.ClientCommonConfig) TunnelSession {\n\treturn &QUICTunnelSession{\n\t\tclientCfg: clientCfg,\n\t}\n}\n\nfunc (qs *QUICTunnelSession) Init(listenConn *net.UDPConn, raddr *net.UDPAddr) error {\n\ttlsConfig, err := transport.NewClientTLSConfig(\"\", \"\", \"\", raddr.String())\n\tif err != nil {\n\t\treturn fmt.Errorf(\"create tls config error: %v\", err)\n\t}\n\ttlsConfig.NextProtos = []string{\"frp\"}\n\tquicConn, err := quic.Dial(context.Background(), listenConn, raddr, tlsConfig,\n\t\t&quic.Config{\n\t\t\tMaxIdleTimeout:     time.Duration(qs.clientCfg.Transport.QUIC.MaxIdleTimeout) * time.Second,\n\t\t\tMaxIncomingStreams: int64(qs.clientCfg.Transport.QUIC.MaxIncomingStreams),\n\t\t\tKeepAlivePeriod:    time.Duration(qs.clientCfg.Transport.QUIC.KeepalivePeriod) * time.Second,\n\t\t})\n\tif err != nil {\n\t\treturn fmt.Errorf(\"dial quic error: %v\", err)\n\t}\n\tqs.mu.Lock()\n\tqs.session = quicConn\n\tqs.listenConn = listenConn\n\tqs.mu.Unlock()\n\treturn nil\n}\n\nfunc (qs *QUICTunnelSession) OpenConn(ctx context.Context) (net.Conn, error) {\n\tqs.mu.RLock()\n\tdefer qs.mu.RUnlock()\n\tsession := qs.session\n\tif session == nil {\n\t\treturn nil, ErrNoTunnelSession\n\t}\n\tstream, err := session.OpenStreamSync(ctx)\n\tif err != nil {\n\t\treturn nil, err\n\t}\n\treturn netpkg.QuicStreamToNetConn(stream, session), nil\n}\n\nfunc (qs *QUICTunnelSession) Close() {\n\tqs.mu.Lock()\n\tdefer qs.mu.Unlock()\n\tif qs.session != nil {\n\t\t_ = qs.session.CloseWithError(0, \"\")\n\t\tqs.session = nil\n\t}\n\tif qs.listenConn != nil {\n\t\t_ = qs.listenConn.Close()\n\t\tqs.listenConn = nil\n\t}\n}\n"
  },
  {
    "path": "cmd/frpc/main.go",
    "content": "// Copyright 2016 fatedier, fatedier@gmail.com\n//\n// Licensed under the Apache License, Version 2.0 (the \"License\");\n// you may not use this file except in compliance with the License.\n// You may obtain a copy of the License at\n//\n//     http://www.apache.org/licenses/LICENSE-2.0\n//\n// Unless required by applicable law or agreed to in writing, software\n// distributed under the License is distributed on an \"AS IS\" BASIS,\n// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.\n// See the License for the specific language governing permissions and\n// limitations under the License.\n\npackage main\n\nimport (\n\t\"github.com/fatedier/frp/cmd/frpc/sub\"\n\t\"github.com/fatedier/frp/pkg/util/system\"\n\t_ \"github.com/fatedier/frp/web/frpc\"\n)\n\nfunc main() {\n\tsystem.EnableCompatibilityMode()\n\tsub.Execute()\n}\n"
  },
  {
    "path": "cmd/frpc/sub/admin.go",
    "content": "// Copyright 2023 The frp Authors\n//\n// Licensed under the Apache License, Version 2.0 (the \"License\");\n// you may not use this file except in compliance with the License.\n// You may obtain a copy of the License at\n//\n//     http://www.apache.org/licenses/LICENSE-2.0\n//\n// Unless required by applicable law or agreed to in writing, software\n// distributed under the License is distributed on an \"AS IS\" BASIS,\n// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.\n// See the License for the specific language governing permissions and\n// limitations under the License.\n\npackage sub\n\nimport (\n\t\"context\"\n\t\"fmt\"\n\t\"os\"\n\t\"strings\"\n\t\"time\"\n\n\t\"github.com/rodaine/table\"\n\t\"github.com/spf13/cobra\"\n\n\t\"github.com/fatedier/frp/pkg/config\"\n\tv1 \"github.com/fatedier/frp/pkg/config/v1\"\n\tclientsdk \"github.com/fatedier/frp/pkg/sdk/client\"\n)\n\nvar adminAPITimeout = 30 * time.Second\n\nfunc init() {\n\tcommands := []struct {\n\t\tname        string\n\t\tdescription string\n\t\thandler     func(*v1.ClientCommonConfig) error\n\t}{\n\t\t{\"reload\", \"Hot-Reload frpc configuration\", ReloadHandler},\n\t\t{\"status\", \"Overview of all proxies status\", StatusHandler},\n\t\t{\"stop\", \"Stop the running frpc\", StopHandler},\n\t}\n\n\tfor _, cmdConfig := range commands {\n\t\tcmd := NewAdminCommand(cmdConfig.name, cmdConfig.description, cmdConfig.handler)\n\t\tcmd.Flags().DurationVar(&adminAPITimeout, \"api-timeout\", adminAPITimeout, \"Timeout for admin API calls\")\n\t\trootCmd.AddCommand(cmd)\n\t}\n}\n\nfunc NewAdminCommand(name, short string, handler func(*v1.ClientCommonConfig) error) *cobra.Command {\n\treturn &cobra.Command{\n\t\tUse:   name,\n\t\tShort: short,\n\t\tRun: func(cmd *cobra.Command, args []string) {\n\t\t\tcfg, _, _, _, err := config.LoadClientConfig(cfgFile, strictConfigMode)\n\t\t\tif err != nil {\n\t\t\t\tfmt.Println(err)\n\t\t\t\tos.Exit(1)\n\t\t\t}\n\t\t\tif cfg.WebServer.Port <= 0 {\n\t\t\t\tfmt.Println(\"web server port should be set if you want to use this feature\")\n\t\t\t\tos.Exit(1)\n\t\t\t}\n\n\t\t\tif err := handler(cfg); err != nil {\n\t\t\t\tfmt.Println(err)\n\t\t\t\tos.Exit(1)\n\t\t\t}\n\t\t},\n\t}\n}\n\nfunc ReloadHandler(clientCfg *v1.ClientCommonConfig) error {\n\tclient := clientsdk.New(clientCfg.WebServer.Addr, clientCfg.WebServer.Port)\n\tclient.SetAuth(clientCfg.WebServer.User, clientCfg.WebServer.Password)\n\tctx, cancel := context.WithTimeout(context.Background(), adminAPITimeout)\n\tdefer cancel()\n\tif err := client.Reload(ctx, strictConfigMode); err != nil {\n\t\treturn err\n\t}\n\tfmt.Println(\"reload success\")\n\treturn nil\n}\n\nfunc StatusHandler(clientCfg *v1.ClientCommonConfig) error {\n\tclient := clientsdk.New(clientCfg.WebServer.Addr, clientCfg.WebServer.Port)\n\tclient.SetAuth(clientCfg.WebServer.User, clientCfg.WebServer.Password)\n\tctx, cancel := context.WithTimeout(context.Background(), adminAPITimeout)\n\tdefer cancel()\n\tres, err := client.GetAllProxyStatus(ctx)\n\tif err != nil {\n\t\treturn err\n\t}\n\n\tfmt.Printf(\"Proxy Status...\\n\\n\")\n\tfor _, typ := range proxyTypes {\n\t\tarrs := res[string(typ)]\n\t\tif len(arrs) == 0 {\n\t\t\tcontinue\n\t\t}\n\n\t\tfmt.Println(strings.ToUpper(string(typ)))\n\t\ttbl := table.New(\"Name\", \"Status\", \"LocalAddr\", \"Plugin\", \"RemoteAddr\", \"Error\")\n\t\tfor _, ps := range arrs {\n\t\t\ttbl.AddRow(ps.Name, ps.Status, ps.LocalAddr, ps.Plugin, ps.RemoteAddr, ps.Err)\n\t\t}\n\t\ttbl.Print()\n\t\tfmt.Println(\"\")\n\t}\n\treturn nil\n}\n\nfunc StopHandler(clientCfg *v1.ClientCommonConfig) error {\n\tclient := clientsdk.New(clientCfg.WebServer.Addr, clientCfg.WebServer.Port)\n\tclient.SetAuth(clientCfg.WebServer.User, clientCfg.WebServer.Password)\n\tctx, cancel := context.WithTimeout(context.Background(), adminAPITimeout)\n\tdefer cancel()\n\tif err := client.Stop(ctx); err != nil {\n\t\treturn err\n\t}\n\tfmt.Println(\"stop success\")\n\treturn nil\n}\n"
  },
  {
    "path": "cmd/frpc/sub/nathole.go",
    "content": "// Copyright 2023 The frp Authors\n//\n// Licensed under the Apache License, Version 2.0 (the \"License\");\n// you may not use this file except in compliance with the License.\n// You may obtain a copy of the License at\n//\n//     http://www.apache.org/licenses/LICENSE-2.0\n//\n// Unless required by applicable law or agreed to in writing, software\n// distributed under the License is distributed on an \"AS IS\" BASIS,\n// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.\n// See the License for the specific language governing permissions and\n// limitations under the License.\n\npackage sub\n\nimport (\n\t\"fmt\"\n\t\"os\"\n\n\t\"github.com/spf13/cobra\"\n\n\t\"github.com/fatedier/frp/pkg/config\"\n\tv1 \"github.com/fatedier/frp/pkg/config/v1\"\n\t\"github.com/fatedier/frp/pkg/nathole\"\n)\n\nvar (\n\tnatHoleSTUNServer string\n\tnatHoleLocalAddr  string\n)\n\nfunc init() {\n\trootCmd.AddCommand(natholeCmd)\n\tnatholeCmd.AddCommand(natholeDiscoveryCmd)\n\n\tnatholeCmd.PersistentFlags().StringVarP(&natHoleSTUNServer, \"nat_hole_stun_server\", \"\", \"\", \"STUN server address for nathole\")\n\tnatholeCmd.PersistentFlags().StringVarP(&natHoleLocalAddr, \"nat_hole_local_addr\", \"l\", \"\", \"local address to connect STUN server\")\n}\n\nvar natholeCmd = &cobra.Command{\n\tUse:   \"nathole\",\n\tShort: \"Actions about nathole\",\n}\n\nvar natholeDiscoveryCmd = &cobra.Command{\n\tUse:   \"discover\",\n\tShort: \"Discover nathole information from stun server\",\n\tRunE: func(cmd *cobra.Command, args []string) error {\n\t\t// ignore error here, because we can use command line parameters\n\t\tcfg, _, _, _, err := config.LoadClientConfig(cfgFile, strictConfigMode)\n\t\tif err != nil {\n\t\t\tcfg = &v1.ClientCommonConfig{}\n\t\t\tif err := cfg.Complete(); err != nil {\n\t\t\t\tfmt.Printf(\"failed to complete config: %v\\n\", err)\n\t\t\t\tos.Exit(1)\n\t\t\t}\n\t\t}\n\t\tif natHoleSTUNServer != \"\" {\n\t\t\tcfg.NatHoleSTUNServer = natHoleSTUNServer\n\t\t}\n\n\t\tif err := validateForNatHoleDiscovery(cfg); err != nil {\n\t\t\tfmt.Println(err)\n\t\t\tos.Exit(1)\n\t\t}\n\n\t\taddrs, localAddr, err := nathole.Discover([]string{cfg.NatHoleSTUNServer}, natHoleLocalAddr)\n\t\tif err != nil {\n\t\t\tfmt.Println(\"discover error:\", err)\n\t\t\tos.Exit(1)\n\t\t}\n\t\tif len(addrs) < 2 {\n\t\t\tfmt.Printf(\"discover error: can not get enough addresses, need 2, got: %v\\n\", addrs)\n\t\t\tos.Exit(1)\n\t\t}\n\n\t\tlocalIPs, _ := nathole.ListLocalIPsForNatHole(10)\n\n\t\tnatFeature, err := nathole.ClassifyNATFeature(addrs, localIPs)\n\t\tif err != nil {\n\t\t\tfmt.Println(\"classify nat feature error:\", err)\n\t\t\tos.Exit(1)\n\t\t}\n\t\tfmt.Println(\"STUN server:\", cfg.NatHoleSTUNServer)\n\t\tfmt.Println(\"Your NAT type is:\", natFeature.NatType)\n\t\tfmt.Println(\"Behavior is:\", natFeature.Behavior)\n\t\tfmt.Println(\"External address is:\", addrs)\n\t\tfmt.Println(\"Local address is:\", localAddr.String())\n\t\tfmt.Println(\"Public Network:\", natFeature.PublicNetwork)\n\t\treturn nil\n\t},\n}\n\nfunc validateForNatHoleDiscovery(cfg *v1.ClientCommonConfig) error {\n\tif cfg.NatHoleSTUNServer == \"\" {\n\t\treturn fmt.Errorf(\"nat_hole_stun_server can not be empty\")\n\t}\n\treturn nil\n}\n"
  },
  {
    "path": "cmd/frpc/sub/proxy.go",
    "content": "// Copyright 2023 The frp Authors\n//\n// Licensed under the Apache License, Version 2.0 (the \"License\");\n// you may not use this file except in compliance with the License.\n// You may obtain a copy of the License at\n//\n//     http://www.apache.org/licenses/LICENSE-2.0\n//\n// Unless required by applicable law or agreed to in writing, software\n// distributed under the License is distributed on an \"AS IS\" BASIS,\n// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.\n// See the License for the specific language governing permissions and\n// limitations under the License.\n\npackage sub\n\nimport (\n\t\"fmt\"\n\t\"os\"\n\t\"slices\"\n\n\t\"github.com/spf13/cobra\"\n\n\t\"github.com/fatedier/frp/pkg/config\"\n\t\"github.com/fatedier/frp/pkg/config/source\"\n\tv1 \"github.com/fatedier/frp/pkg/config/v1\"\n\t\"github.com/fatedier/frp/pkg/config/v1/validation\"\n\t\"github.com/fatedier/frp/pkg/policy/security\"\n)\n\nvar proxyTypes = []v1.ProxyType{\n\tv1.ProxyTypeTCP,\n\tv1.ProxyTypeUDP,\n\tv1.ProxyTypeTCPMUX,\n\tv1.ProxyTypeHTTP,\n\tv1.ProxyTypeHTTPS,\n\tv1.ProxyTypeSTCP,\n\tv1.ProxyTypeSUDP,\n\tv1.ProxyTypeXTCP,\n}\n\nvar visitorTypes = []v1.VisitorType{\n\tv1.VisitorTypeSTCP,\n\tv1.VisitorTypeSUDP,\n\tv1.VisitorTypeXTCP,\n}\n\nfunc init() {\n\tfor _, typ := range proxyTypes {\n\t\tc := v1.NewProxyConfigurerByType(typ)\n\t\tif c == nil {\n\t\t\tpanic(\"proxy type: \" + typ + \" not support\")\n\t\t}\n\t\tclientCfg := v1.ClientCommonConfig{}\n\t\tcmd := NewProxyCommand(string(typ), c, &clientCfg)\n\t\tconfig.RegisterClientCommonConfigFlags(cmd, &clientCfg)\n\t\tconfig.RegisterProxyFlags(cmd, c)\n\n\t\t// add sub command for visitor\n\t\tif slices.Contains(visitorTypes, v1.VisitorType(typ)) {\n\t\t\tvc := v1.NewVisitorConfigurerByType(v1.VisitorType(typ))\n\t\t\tif vc == nil {\n\t\t\t\tpanic(\"visitor type: \" + typ + \" not support\")\n\t\t\t}\n\t\t\tvisitorCmd := NewVisitorCommand(string(typ), vc, &clientCfg)\n\t\t\tconfig.RegisterVisitorFlags(visitorCmd, vc)\n\t\t\tcmd.AddCommand(visitorCmd)\n\t\t}\n\t\trootCmd.AddCommand(cmd)\n\t}\n}\n\nfunc NewProxyCommand(name string, c v1.ProxyConfigurer, clientCfg *v1.ClientCommonConfig) *cobra.Command {\n\treturn &cobra.Command{\n\t\tUse:   name,\n\t\tShort: fmt.Sprintf(\"Run frpc with a single %s proxy\", name),\n\t\tRun: func(cmd *cobra.Command, args []string) {\n\t\t\tif err := clientCfg.Complete(); err != nil {\n\t\t\t\tfmt.Println(err)\n\t\t\t\tos.Exit(1)\n\t\t\t}\n\n\t\t\tunsafeFeatures := security.NewUnsafeFeatures(allowUnsafe)\n\t\t\tvalidator := validation.NewConfigValidator(unsafeFeatures)\n\t\t\tif _, err := validator.ValidateClientCommonConfig(clientCfg); err != nil {\n\t\t\t\tfmt.Println(err)\n\t\t\t\tos.Exit(1)\n\t\t\t}\n\n\t\t\tc.GetBaseConfig().Type = name\n\t\t\tc.Complete()\n\t\t\tproxyCfg := c\n\t\t\tif err := validation.ValidateProxyConfigurerForClient(proxyCfg); err != nil {\n\t\t\t\tfmt.Println(err)\n\t\t\t\tos.Exit(1)\n\t\t\t}\n\t\t\terr := startService(clientCfg, []v1.ProxyConfigurer{proxyCfg}, nil, unsafeFeatures, \"\")\n\t\t\tif err != nil {\n\t\t\t\tfmt.Println(err)\n\t\t\t\tos.Exit(1)\n\t\t\t}\n\t\t},\n\t}\n}\n\nfunc NewVisitorCommand(name string, c v1.VisitorConfigurer, clientCfg *v1.ClientCommonConfig) *cobra.Command {\n\treturn &cobra.Command{\n\t\tUse:   \"visitor\",\n\t\tShort: fmt.Sprintf(\"Run frpc with a single %s visitor\", name),\n\t\tRun: func(cmd *cobra.Command, args []string) {\n\t\t\tif err := clientCfg.Complete(); err != nil {\n\t\t\t\tfmt.Println(err)\n\t\t\t\tos.Exit(1)\n\t\t\t}\n\t\t\tunsafeFeatures := security.NewUnsafeFeatures(allowUnsafe)\n\t\t\tvalidator := validation.NewConfigValidator(unsafeFeatures)\n\t\t\tif _, err := validator.ValidateClientCommonConfig(clientCfg); err != nil {\n\t\t\t\tfmt.Println(err)\n\t\t\t\tos.Exit(1)\n\t\t\t}\n\n\t\t\tc.GetBaseConfig().Type = name\n\t\t\tc.Complete()\n\t\t\tvisitorCfg := c\n\t\t\tif err := validation.ValidateVisitorConfigurer(visitorCfg); err != nil {\n\t\t\t\tfmt.Println(err)\n\t\t\t\tos.Exit(1)\n\t\t\t}\n\t\t\terr := startService(clientCfg, nil, []v1.VisitorConfigurer{visitorCfg}, unsafeFeatures, \"\")\n\t\t\tif err != nil {\n\t\t\t\tfmt.Println(err)\n\t\t\t\tos.Exit(1)\n\t\t\t}\n\t\t},\n\t}\n}\n\nfunc startService(\n\tcfg *v1.ClientCommonConfig,\n\tproxyCfgs []v1.ProxyConfigurer,\n\tvisitorCfgs []v1.VisitorConfigurer,\n\tunsafeFeatures *security.UnsafeFeatures,\n\tcfgFile string,\n) error {\n\tconfigSource := source.NewConfigSource()\n\tif err := configSource.ReplaceAll(proxyCfgs, visitorCfgs); err != nil {\n\t\treturn fmt.Errorf(\"failed to set config source: %w\", err)\n\t}\n\taggregator := source.NewAggregator(configSource)\n\treturn startServiceWithAggregator(cfg, aggregator, unsafeFeatures, cfgFile)\n}\n"
  },
  {
    "path": "cmd/frpc/sub/root.go",
    "content": "// Copyright 2018 fatedier, fatedier@gmail.com\n//\n// Licensed under the Apache License, Version 2.0 (the \"License\");\n// you may not use this file except in compliance with the License.\n// You may obtain a copy of the License at\n//\n//     http://www.apache.org/licenses/LICENSE-2.0\n//\n// Unless required by applicable law or agreed to in writing, software\n// distributed under the License is distributed on an \"AS IS\" BASIS,\n// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.\n// See the License for the specific language governing permissions and\n// limitations under the License.\n\npackage sub\n\nimport (\n\t\"context\"\n\t\"fmt\"\n\t\"io/fs\"\n\t\"os\"\n\t\"os/signal\"\n\t\"path/filepath\"\n\t\"strings\"\n\t\"sync\"\n\t\"syscall\"\n\t\"time\"\n\n\t\"github.com/spf13/cobra\"\n\n\t\"github.com/fatedier/frp/client\"\n\t\"github.com/fatedier/frp/pkg/config\"\n\t\"github.com/fatedier/frp/pkg/config/source\"\n\tv1 \"github.com/fatedier/frp/pkg/config/v1\"\n\t\"github.com/fatedier/frp/pkg/config/v1/validation\"\n\t\"github.com/fatedier/frp/pkg/policy/featuregate\"\n\t\"github.com/fatedier/frp/pkg/policy/security\"\n\t\"github.com/fatedier/frp/pkg/util/log\"\n\t\"github.com/fatedier/frp/pkg/util/version\"\n)\n\nvar (\n\tcfgFile          string\n\tcfgDir           string\n\tshowVersion      bool\n\tstrictConfigMode bool\n\tallowUnsafe      []string\n)\n\nfunc init() {\n\trootCmd.PersistentFlags().StringVarP(&cfgFile, \"config\", \"c\", \"./frpc.ini\", \"config file of frpc\")\n\trootCmd.PersistentFlags().StringVarP(&cfgDir, \"config_dir\", \"\", \"\", \"config directory, run one frpc service for each file in config directory\")\n\trootCmd.PersistentFlags().BoolVarP(&showVersion, \"version\", \"v\", false, \"version of frpc\")\n\trootCmd.PersistentFlags().BoolVarP(&strictConfigMode, \"strict_config\", \"\", true, \"strict config parsing mode, unknown fields will cause an errors\")\n\n\trootCmd.PersistentFlags().StringSliceVarP(&allowUnsafe, \"allow-unsafe\", \"\", []string{},\n\t\tfmt.Sprintf(\"allowed unsafe features, one or more of: %s\", strings.Join(security.ClientUnsafeFeatures, \", \")))\n}\n\nvar rootCmd = &cobra.Command{\n\tUse:   \"frpc\",\n\tShort: \"frpc is the client of frp (https://github.com/fatedier/frp)\",\n\tRunE: func(cmd *cobra.Command, args []string) error {\n\t\tif showVersion {\n\t\t\tfmt.Println(version.Full())\n\t\t\treturn nil\n\t\t}\n\n\t\tunsafeFeatures := security.NewUnsafeFeatures(allowUnsafe)\n\n\t\t// If cfgDir is not empty, run multiple frpc service for each config file in cfgDir.\n\t\t// Note that it's only designed for testing. It's not guaranteed to be stable.\n\t\tif cfgDir != \"\" {\n\t\t\t_ = runMultipleClients(cfgDir, unsafeFeatures)\n\t\t\treturn nil\n\t\t}\n\n\t\t// Do not show command usage here.\n\t\terr := runClient(cfgFile, unsafeFeatures)\n\t\tif err != nil {\n\t\t\tfmt.Println(err)\n\t\t\tos.Exit(1)\n\t\t}\n\t\treturn nil\n\t},\n}\n\nfunc runMultipleClients(cfgDir string, unsafeFeatures *security.UnsafeFeatures) error {\n\tvar wg sync.WaitGroup\n\terr := filepath.WalkDir(cfgDir, func(path string, d fs.DirEntry, err error) error {\n\t\tif err != nil || d.IsDir() {\n\t\t\treturn nil\n\t\t}\n\t\twg.Add(1)\n\t\ttime.Sleep(time.Millisecond)\n\t\tgo func() {\n\t\t\tdefer wg.Done()\n\t\t\terr := runClient(path, unsafeFeatures)\n\t\t\tif err != nil {\n\t\t\t\tfmt.Printf(\"frpc service error for config file [%s]\\n\", path)\n\t\t\t}\n\t\t}()\n\t\treturn nil\n\t})\n\twg.Wait()\n\treturn err\n}\n\nfunc Execute() {\n\trootCmd.SetGlobalNormalizationFunc(config.WordSepNormalizeFunc)\n\tif err := rootCmd.Execute(); err != nil {\n\t\tos.Exit(1)\n\t}\n}\n\nfunc handleTermSignal(svr *client.Service) {\n\tch := make(chan os.Signal, 1)\n\tsignal.Notify(ch, syscall.SIGINT, syscall.SIGTERM)\n\t<-ch\n\tsvr.GracefulClose(500 * time.Millisecond)\n}\n\nfunc runClient(cfgFilePath string, unsafeFeatures *security.UnsafeFeatures) error {\n\t// Load configuration\n\tresult, err := config.LoadClientConfigResult(cfgFilePath, strictConfigMode)\n\tif err != nil {\n\t\treturn err\n\t}\n\tif result.IsLegacyFormat {\n\t\tfmt.Printf(\"WARNING: ini format is deprecated and the support will be removed in the future, \" +\n\t\t\t\"please use yaml/json/toml format instead!\\n\")\n\t}\n\n\tif len(result.Common.FeatureGates) > 0 {\n\t\tif err := featuregate.SetFromMap(result.Common.FeatureGates); err != nil {\n\t\t\treturn err\n\t\t}\n\t}\n\n\treturn runClientWithAggregator(result, unsafeFeatures, cfgFilePath)\n}\n\n// runClientWithAggregator runs the client using the internal source aggregator.\nfunc runClientWithAggregator(result *config.ClientConfigLoadResult, unsafeFeatures *security.UnsafeFeatures, cfgFilePath string) error {\n\tconfigSource := source.NewConfigSource()\n\tif err := configSource.ReplaceAll(result.Proxies, result.Visitors); err != nil {\n\t\treturn fmt.Errorf(\"failed to set config source: %w\", err)\n\t}\n\n\tvar storeSource *source.StoreSource\n\n\tif result.Common.Store.IsEnabled() {\n\t\tstorePath := result.Common.Store.Path\n\t\tif storePath != \"\" && cfgFilePath != \"\" && !filepath.IsAbs(storePath) {\n\t\t\tstorePath = filepath.Join(filepath.Dir(cfgFilePath), storePath)\n\t\t}\n\n\t\ts, err := source.NewStoreSource(source.StoreSourceConfig{\n\t\t\tPath: storePath,\n\t\t})\n\t\tif err != nil {\n\t\t\treturn fmt.Errorf(\"failed to create store source: %w\", err)\n\t\t}\n\t\tstoreSource = s\n\t}\n\n\taggregator := source.NewAggregator(configSource)\n\tif storeSource != nil {\n\t\taggregator.SetStoreSource(storeSource)\n\t}\n\n\tproxyCfgs, visitorCfgs, err := aggregator.Load()\n\tif err != nil {\n\t\treturn fmt.Errorf(\"failed to load config from sources: %w\", err)\n\t}\n\n\tproxyCfgs, visitorCfgs = config.FilterClientConfigurers(result.Common, proxyCfgs, visitorCfgs)\n\tproxyCfgs = config.CompleteProxyConfigurers(proxyCfgs)\n\tvisitorCfgs = config.CompleteVisitorConfigurers(visitorCfgs)\n\n\twarning, err := validation.ValidateAllClientConfig(result.Common, proxyCfgs, visitorCfgs, unsafeFeatures)\n\tif warning != nil {\n\t\tfmt.Printf(\"WARNING: %v\\n\", warning)\n\t}\n\tif err != nil {\n\t\treturn err\n\t}\n\n\treturn startServiceWithAggregator(result.Common, aggregator, unsafeFeatures, cfgFilePath)\n}\n\nfunc startServiceWithAggregator(\n\tcfg *v1.ClientCommonConfig,\n\taggregator *source.Aggregator,\n\tunsafeFeatures *security.UnsafeFeatures,\n\tcfgFile string,\n) error {\n\tlog.InitLogger(cfg.Log.To, cfg.Log.Level, int(cfg.Log.MaxDays), cfg.Log.DisablePrintColor)\n\n\tif cfgFile != \"\" {\n\t\tlog.Infof(\"start frpc service for config file [%s] with aggregated configuration\", cfgFile)\n\t\tdefer log.Infof(\"frpc service for config file [%s] stopped\", cfgFile)\n\t}\n\tsvr, err := client.NewService(client.ServiceOptions{\n\t\tCommon:                 cfg,\n\t\tConfigSourceAggregator: aggregator,\n\t\tUnsafeFeatures:         unsafeFeatures,\n\t\tConfigFilePath:         cfgFile,\n\t})\n\tif err != nil {\n\t\treturn err\n\t}\n\n\tshouldGracefulClose := cfg.Transport.Protocol == \"kcp\" || cfg.Transport.Protocol == \"quic\"\n\tif shouldGracefulClose {\n\t\tgo handleTermSignal(svr)\n\t}\n\treturn svr.Run(context.Background())\n}\n"
  },
  {
    "path": "cmd/frpc/sub/verify.go",
    "content": "// Copyright 2021 The frp Authors\n//\n// Licensed under the Apache License, Version 2.0 (the \"License\");\n// you may not use this file except in compliance with the License.\n// You may obtain a copy of the License at\n//\n//     http://www.apache.org/licenses/LICENSE-2.0\n//\n// Unless required by applicable law or agreed to in writing, software\n// distributed under the License is distributed on an \"AS IS\" BASIS,\n// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.\n// See the License for the specific language governing permissions and\n// limitations under the License.\n\npackage sub\n\nimport (\n\t\"fmt\"\n\t\"os\"\n\n\t\"github.com/spf13/cobra\"\n\n\t\"github.com/fatedier/frp/pkg/config\"\n\t\"github.com/fatedier/frp/pkg/config/v1/validation\"\n\t\"github.com/fatedier/frp/pkg/policy/security\"\n)\n\nfunc init() {\n\trootCmd.AddCommand(verifyCmd)\n}\n\nvar verifyCmd = &cobra.Command{\n\tUse:   \"verify\",\n\tShort: \"Verify that the configures is valid\",\n\tRunE: func(cmd *cobra.Command, args []string) error {\n\t\tif cfgFile == \"\" {\n\t\t\tfmt.Println(\"frpc: the configuration file is not specified\")\n\t\t\treturn nil\n\t\t}\n\n\t\tcliCfg, proxyCfgs, visitorCfgs, _, err := config.LoadClientConfig(cfgFile, strictConfigMode)\n\t\tif err != nil {\n\t\t\tfmt.Println(err)\n\t\t\tos.Exit(1)\n\t\t}\n\t\tunsafeFeatures := security.NewUnsafeFeatures(allowUnsafe)\n\t\twarning, err := validation.ValidateAllClientConfig(cliCfg, proxyCfgs, visitorCfgs, unsafeFeatures)\n\t\tif warning != nil {\n\t\t\tfmt.Printf(\"WARNING: %v\\n\", warning)\n\t\t}\n\t\tif err != nil {\n\t\t\tfmt.Println(err)\n\t\t\tos.Exit(1)\n\t\t}\n\n\t\tfmt.Printf(\"frpc: the configuration file %s syntax is ok\\n\", cfgFile)\n\t\treturn nil\n\t},\n}\n"
  },
  {
    "path": "cmd/frps/main.go",
    "content": "// Copyright 2018 fatedier, fatedier@gmail.com\n//\n// Licensed under the Apache License, Version 2.0 (the \"License\");\n// you may not use this file except in compliance with the License.\n// You may obtain a copy of the License at\n//\n//     http://www.apache.org/licenses/LICENSE-2.0\n//\n// Unless required by applicable law or agreed to in writing, software\n// distributed under the License is distributed on an \"AS IS\" BASIS,\n// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.\n// See the License for the specific language governing permissions and\n// limitations under the License.\n\npackage main\n\nimport (\n\t_ \"github.com/fatedier/frp/pkg/metrics\"\n\t\"github.com/fatedier/frp/pkg/util/system\"\n\t_ \"github.com/fatedier/frp/web/frps\"\n)\n\nfunc main() {\n\tsystem.EnableCompatibilityMode()\n\tExecute()\n}\n"
  },
  {
    "path": "cmd/frps/root.go",
    "content": "// Copyright 2018 fatedier, fatedier@gmail.com\n//\n// Licensed under the Apache License, Version 2.0 (the \"License\");\n// you may not use this file except in compliance with the License.\n// You may obtain a copy of the License at\n//\n//     http://www.apache.org/licenses/LICENSE-2.0\n//\n// Unless required by applicable law or agreed to in writing, software\n// distributed under the License is distributed on an \"AS IS\" BASIS,\n// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.\n// See the License for the specific language governing permissions and\n// limitations under the License.\n\npackage main\n\nimport (\n\t\"context\"\n\t\"fmt\"\n\t\"os\"\n\t\"strings\"\n\n\t\"github.com/spf13/cobra\"\n\n\t\"github.com/fatedier/frp/pkg/config\"\n\tv1 \"github.com/fatedier/frp/pkg/config/v1\"\n\t\"github.com/fatedier/frp/pkg/config/v1/validation\"\n\t\"github.com/fatedier/frp/pkg/policy/security\"\n\t\"github.com/fatedier/frp/pkg/util/log\"\n\t\"github.com/fatedier/frp/pkg/util/version\"\n\t\"github.com/fatedier/frp/server\"\n)\n\nvar (\n\tcfgFile          string\n\tshowVersion      bool\n\tstrictConfigMode bool\n\tallowUnsafe      []string\n\n\tserverCfg v1.ServerConfig\n)\n\nfunc init() {\n\trootCmd.PersistentFlags().StringVarP(&cfgFile, \"config\", \"c\", \"\", \"config file of frps\")\n\trootCmd.PersistentFlags().BoolVarP(&showVersion, \"version\", \"v\", false, \"version of frps\")\n\trootCmd.PersistentFlags().BoolVarP(&strictConfigMode, \"strict_config\", \"\", true, \"strict config parsing mode, unknown fields will cause errors\")\n\trootCmd.PersistentFlags().StringSliceVarP(&allowUnsafe, \"allow-unsafe\", \"\", []string{},\n\t\tfmt.Sprintf(\"allowed unsafe features, one or more of: %s\", strings.Join(security.ServerUnsafeFeatures, \", \")))\n\n\tconfig.RegisterServerConfigFlags(rootCmd, &serverCfg)\n}\n\nvar rootCmd = &cobra.Command{\n\tUse:   \"frps\",\n\tShort: \"frps is the server of frp (https://github.com/fatedier/frp)\",\n\tRunE: func(cmd *cobra.Command, args []string) error {\n\t\tif showVersion {\n\t\t\tfmt.Println(version.Full())\n\t\t\treturn nil\n\t\t}\n\n\t\tvar (\n\t\t\tsvrCfg         *v1.ServerConfig\n\t\t\tisLegacyFormat bool\n\t\t\terr            error\n\t\t)\n\t\tif cfgFile != \"\" {\n\t\t\tsvrCfg, isLegacyFormat, err = config.LoadServerConfig(cfgFile, strictConfigMode)\n\t\t\tif err != nil {\n\t\t\t\tfmt.Println(err)\n\t\t\t\tos.Exit(1)\n\t\t\t}\n\t\t\tif isLegacyFormat {\n\t\t\t\tfmt.Printf(\"WARNING: ini format is deprecated and the support will be removed in the future, \" +\n\t\t\t\t\t\"please use yaml/json/toml format instead!\\n\")\n\t\t\t}\n\t\t} else {\n\t\t\tif err := serverCfg.Complete(); err != nil {\n\t\t\t\tfmt.Printf(\"failed to complete server config: %v\\n\", err)\n\t\t\t\tos.Exit(1)\n\t\t\t}\n\t\t\tsvrCfg = &serverCfg\n\t\t}\n\n\t\tunsafeFeatures := security.NewUnsafeFeatures(allowUnsafe)\n\t\tvalidator := validation.NewConfigValidator(unsafeFeatures)\n\t\twarning, err := validator.ValidateServerConfig(svrCfg)\n\t\tif warning != nil {\n\t\t\tfmt.Printf(\"WARNING: %v\\n\", warning)\n\t\t}\n\t\tif err != nil {\n\t\t\tfmt.Println(err)\n\t\t\tos.Exit(1)\n\t\t}\n\n\t\tif err := runServer(svrCfg); err != nil {\n\t\t\tfmt.Println(err)\n\t\t\tos.Exit(1)\n\t\t}\n\t\treturn nil\n\t},\n}\n\nfunc Execute() {\n\trootCmd.SetGlobalNormalizationFunc(config.WordSepNormalizeFunc)\n\tif err := rootCmd.Execute(); err != nil {\n\t\tos.Exit(1)\n\t}\n}\n\nfunc runServer(cfg *v1.ServerConfig) (err error) {\n\tlog.InitLogger(cfg.Log.To, cfg.Log.Level, int(cfg.Log.MaxDays), cfg.Log.DisablePrintColor)\n\n\tif cfgFile != \"\" {\n\t\tlog.Infof(\"frps uses config file: %s\", cfgFile)\n\t} else {\n\t\tlog.Infof(\"frps uses command line arguments for config\")\n\t}\n\n\tsvr, err := server.NewService(cfg)\n\tif err != nil {\n\t\treturn err\n\t}\n\tlog.Infof(\"frps started successfully\")\n\tsvr.Run(context.Background())\n\treturn\n}\n"
  },
  {
    "path": "cmd/frps/verify.go",
    "content": "// Copyright 2021 The frp Authors\n//\n// Licensed under the Apache License, Version 2.0 (the \"License\");\n// you may not use this file except in compliance with the License.\n// You may obtain a copy of the License at\n//\n//     http://www.apache.org/licenses/LICENSE-2.0\n//\n// Unless required by applicable law or agreed to in writing, software\n// distributed under the License is distributed on an \"AS IS\" BASIS,\n// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.\n// See the License for the specific language governing permissions and\n// limitations under the License.\n\npackage main\n\nimport (\n\t\"fmt\"\n\t\"os\"\n\n\t\"github.com/spf13/cobra\"\n\n\t\"github.com/fatedier/frp/pkg/config\"\n\t\"github.com/fatedier/frp/pkg/config/v1/validation\"\n\t\"github.com/fatedier/frp/pkg/policy/security\"\n)\n\nfunc init() {\n\trootCmd.AddCommand(verifyCmd)\n}\n\nvar verifyCmd = &cobra.Command{\n\tUse:   \"verify\",\n\tShort: \"Verify that the configures is valid\",\n\tRunE: func(cmd *cobra.Command, args []string) error {\n\t\tif cfgFile == \"\" {\n\t\t\tfmt.Println(\"frps: the configuration file is not specified\")\n\t\t\treturn nil\n\t\t}\n\t\tsvrCfg, _, err := config.LoadServerConfig(cfgFile, strictConfigMode)\n\t\tif err != nil {\n\t\t\tfmt.Println(err)\n\t\t\tos.Exit(1)\n\t\t}\n\n\t\tunsafeFeatures := security.NewUnsafeFeatures(allowUnsafe)\n\t\tvalidator := validation.NewConfigValidator(unsafeFeatures)\n\t\twarning, err := validator.ValidateServerConfig(svrCfg)\n\t\tif warning != nil {\n\t\t\tfmt.Printf(\"WARNING: %v\\n\", warning)\n\t\t}\n\t\tif err != nil {\n\t\t\tfmt.Println(err)\n\t\t\tos.Exit(1)\n\t\t}\n\t\tfmt.Printf(\"frps: the configuration file %s syntax is ok\\n\", cfgFile)\n\t\treturn nil\n\t},\n}\n"
  },
  {
    "path": "conf/frpc.toml",
    "content": "serverAddr = \"127.0.0.1\"\nserverPort = 7000\n\n[[proxies]]\nname = \"test-tcp\"\ntype = \"tcp\"\nlocalIP = \"127.0.0.1\"\nlocalPort = 22\nremotePort = 6000\n"
  },
  {
    "path": "conf/frpc_full_example.toml",
    "content": "# This configuration file is for reference only. Please do not use this configuration directly to run the program as it may have various issues.\n\n# Optional unique identifier for this frpc instance.\nclientID = \"your_client_id\"\n# your proxy name will be changed to {user}.{proxy}\nuser = \"your_name\"\n\n# A literal address or host name for IPv6 must be enclosed\n# in square brackets, as in \"[::1]:80\", \"[ipv6-host]:http\" or \"[ipv6-host%zone]:80\"\n# For single serverAddr field, no need square brackets, like serverAddr = \"::\".\nserverAddr = \"0.0.0.0\"\nserverPort = 7000\n\n# STUN server to help penetrate NAT hole.\n# natHoleStunServer = \"stun.easyvoip.com:3478\"\n\n# Decide if exit program when first login failed, otherwise continuous relogin to frps\n# default is true\nloginFailExit = true\n\n# console or real logFile path like ./frpc.log\nlog.to = \"./frpc.log\"\n# trace, debug, info, warn, error\nlog.level = \"info\"\nlog.maxDays = 3\n# disable log colors when log.to is console, default is false\nlog.disablePrintColor = false\n\nauth.method = \"token\"\n# auth.additionalScopes specifies additional scopes to include authentication information.\n# Optional values are HeartBeats, NewWorkConns.\n# auth.additionalScopes = [\"HeartBeats\", \"NewWorkConns\"]\n\n# auth token\nauth.token = \"12345678\"\n\n# alternatively, you can use tokenSource to load the token from a file\n# this is mutually exclusive with auth.token\n# auth.tokenSource.type = \"file\"\n# auth.tokenSource.file.path = \"/etc/frp/token\"\n\n# oidc.clientID specifies the client ID to use to get a token in OIDC authentication.\n# auth.oidc.clientID = \"\"\n# oidc.clientSecret specifies the client secret to use to get a token in OIDC authentication.\n# auth.oidc.clientSecret = \"\"\n# oidc.audience specifies the audience of the token in OIDC authentication.\n# auth.oidc.audience = \"\"\n# oidc.scope specifies the permissions of the token in OIDC authentication if AuthenticationMethod == \"oidc\". By default, this value is \"\".\n# auth.oidc.scope = \"\"\n# oidc.tokenEndpointURL specifies the URL which implements OIDC Token Endpoint.\n# It will be used to get an OIDC token.\n# auth.oidc.tokenEndpointURL = \"\"\n\n# oidc.additionalEndpointParams specifies additional parameters to be sent to the OIDC Token Endpoint.\n# For example, if you want to specify the \"audience\" parameter, you can set as follow.\n# frp will add \"audience=<value>\" \"var1=<value>\" to the additional parameters.\n# auth.oidc.additionalEndpointParams.audience = \"https://dev.auth.com/api/v2/\"\n# auth.oidc.additionalEndpointParams.var1 = \"foobar\"\n\n# OIDC TLS and proxy configuration\n# Specify a custom CA certificate file for verifying the OIDC token endpoint's TLS certificate.\n# This is useful when the OIDC provider uses a self-signed certificate or a custom CA.\n# auth.oidc.trustedCaFile = \"/path/to/ca.crt\"\n\n# Skip TLS certificate verification for the OIDC token endpoint.\n# INSECURE: Only use this for debugging purposes, not recommended for production.\n# auth.oidc.insecureSkipVerify = false\n\n# Specify a proxy server for OIDC token endpoint connections.\n# Supports http, https, socks5, and socks5h proxy protocols.\n# If not specified, no proxy is used for OIDC connections.\n# auth.oidc.proxyURL = \"http://proxy.example.com:8080\"\n\n# Set admin address for control frpc's action by http api such as reload\nwebServer.addr = \"127.0.0.1\"\nwebServer.port = 7400\nwebServer.user = \"admin\"\nwebServer.password = \"admin\"\n# Admin assets directory. By default, these assets are bundled with frpc.\n# webServer.assetsDir = \"./static\"\n\n# Enable golang pprof handlers in admin listener.\nwebServer.pprofEnable = false\n\n# The maximum amount of time a dial to server will wait for a connect to complete. Default value is 10 seconds.\n# transport.dialServerTimeout = 10\n\n# dialServerKeepalive specifies the interval between keep-alive probes for an active network connection between frpc and frps.\n# If negative, keep-alive probes are disabled.\n# transport.dialServerKeepalive = 7200\n\n# connections will be established in advance, default value is zero\ntransport.poolCount = 5\n\n# If tcp stream multiplexing is used, default is true, it must be same with frps\n# transport.tcpMux = true\n\n# Specify keep alive interval for tcp mux.\n# only valid if tcpMux is enabled.\n# transport.tcpMuxKeepaliveInterval = 30\n\n# Communication protocol used to connect to server\n# supports tcp, kcp, quic, websocket and wss now, default is tcp\ntransport.protocol = \"tcp\"\n\n# set client binding ip when connect server, default is empty.\n# only when protocol = tcp or websocket, the value will be used.\ntransport.connectServerLocalIP = \"0.0.0.0\"\n\n# 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\n# it only works when protocol is tcp\n# transport.proxyURL = \"http://user:passwd@192.168.1.128:8080\"\n# transport.proxyURL = \"socks5://user:passwd@192.168.1.128:1080\"\n# transport.proxyURL = \"ntlm://user:passwd@192.168.1.128:2080\"\n\n# quic protocol options\n# transport.quic.keepalivePeriod = 10\n# transport.quic.maxIdleTimeout = 30\n# transport.quic.maxIncomingStreams = 100000\n\n# If tls.enable is true, frpc will connect frps by tls.\n# Since v0.50.0, the default value has been changed to true, and tls is enabled by default.\ntransport.tls.enable = true\n\n# transport.tls.certFile = \"client.crt\"\n# transport.tls.keyFile = \"client.key\"\n# transport.tls.trustedCaFile = \"ca.crt\"\n# transport.tls.serverName = \"example.com\"\n\n# If the disableCustomTLSFirstByte is set to false, frpc will establish a connection with frps using the\n# first custom byte when tls is enabled.\n# Since v0.50.0, the default value has been changed to true, and the first custom byte is disabled by default.\n# transport.tls.disableCustomTLSFirstByte = true\n\n# Heartbeat configure, it's not recommended to modify the default value.\n# The default value of heartbeatInterval is 10 and heartbeatTimeout is 90. Set negative value\n# to disable it.\n# transport.heartbeatInterval = 30\n# transport.heartbeatTimeout = 90\n\n# Specify a dns server, so frpc will use this instead of default one\n# dnsServer = \"8.8.8.8\"\n\n# Proxy names you want to start.\n# Default is empty, means all proxies.\n# This list is a global allowlist after config + store are merged, so entries\n# created via Store API are also filtered by this list.\n# If start is non-empty, any proxy/visitor not listed here will not be started.\n# start = [\"ssh\", \"dns\"]\n\n# Alternative to 'start': You can control each proxy individually using the 'enabled' field.\n# Set 'enabled = false' in a proxy configuration to disable it.\n# If 'enabled' is not set or set to true, the proxy is enabled by default.\n# The 'enabled' field provides more granular control and is recommended over 'start'.\n\n# Specify udp packet size, unit is byte. If not set, the default value is 1500.\n# This parameter should be same between client and server.\n# It affects the udp and sudp proxy.\nudpPacketSize = 1500\n\n# Feature gates allows you to enable or disable experimental features\n# Format is a map of feature names to boolean values\n# You can enable specific features:\n#featureGates = { VirtualNet = true }\n\n# VirtualNet settings for experimental virtual network capabilities\n# The virtual network feature requires enabling the VirtualNet feature gate above\n# virtualNet.address = \"100.86.1.1/24\"\n\n# Additional metadatas for client.\nmetadatas.var1 = \"abc\"\nmetadatas.var2 = \"123\"\n\n# Include other config files for proxies.\n# includes = [\"./confd/*.ini\"]\n\n[[proxies]]\n# 'ssh' is the unique proxy name\n# If global user is not empty, it will be changed to {user}.{proxy} such as 'your_name.ssh'\nname = \"ssh\"\ntype = \"tcp\"\n# Enable or disable this proxy. true or omit this field to enable, false to disable.\n# enabled = true\nlocalIP = \"127.0.0.1\"\nlocalPort = 22\n# Limit bandwidth for this proxy, unit is KB and MB\ntransport.bandwidthLimit = \"1MB\"\n# Where to limit bandwidth, can be 'client' or 'server', default is 'client'\ntransport.bandwidthLimitMode = \"client\"\n# If true, traffic of this proxy will be encrypted, default is false\ntransport.useEncryption = false\n# If true, traffic will be compressed\ntransport.useCompression = false\n# Remote port listen by frps\nremotePort = 6001\n# frps will load balancing connections for proxies in same group\nloadBalancer.group = \"test_group\"\n# group should have same group key\nloadBalancer.groupKey = \"123456\"\n# Enable health check for the backend service, it supports 'tcp' and 'http' now.\n# frpc will connect local service's port to detect it's healthy status\nhealthCheck.type = \"tcp\"\n# Health check connection timeout\nhealthCheck.timeoutSeconds = 3\n# If continuous failed in 3 times, the proxy will be removed from frps\nhealthCheck.maxFailed = 3\n# Every 10 seconds will do a health check\nhealthCheck.intervalSeconds = 10\n# Additional meta info for each proxy. It will be passed to the server-side plugin for use.\nmetadatas.var1 = \"abc\"\nmetadatas.var2 = \"123\"\n# You can add some extra information to the proxy through annotations.\n# These annotations will be displayed on the frps dashboard.\n[proxies.annotations]\nkey1 = \"value1\"\n\"prefix/key2\" = \"value2\"\n\n[[proxies]]\nname = \"ssh_random\"\ntype = \"tcp\"\nlocalIP = \"192.168.31.100\"\nlocalPort = 22\n# If remotePort is 0, frps will assign a random port for you\nremotePort = 0\n\n[[proxies]]\nname = \"dns\"\ntype = \"udp\"\nlocalIP = \"114.114.114.114\"\nlocalPort = 53\nremotePort = 6002\n\n# 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\n[[proxies]]\nname = \"web01\"\ntype = \"http\"\nlocalIP = \"127.0.0.1\"\nlocalPort = 80\n# http username and password are safety certification for http protocol\n# if not set, you can access this customDomains without certification\nhttpUser = \"admin\"\nhttpPassword = \"admin\"\n# if domain for frps is frps.com, then you can access [web01] proxy by URL http://web01.frps.com\nsubdomain = \"web01\"\ncustomDomains = [\"web01.yourdomain.com\"]\n# locations is only available for http type\nlocations = [\"/\", \"/pic\"]\n# route requests to this service if http basic auto user is abc\n# routeByHTTPUser = abc\nhostHeaderRewrite = \"example.com\"\nrequestHeaders.set.x-from-where = \"frp\"\nresponseHeaders.set.foo = \"bar\"\nhealthCheck.type = \"http\"\n# frpc will send a GET http request '/status' to local http service\n# http service is alive when it return 2xx http response code\nhealthCheck.path = \"/status\"\nhealthCheck.intervalSeconds = 10\nhealthCheck.maxFailed = 3\nhealthCheck.timeoutSeconds = 3\n# set health check headers\nhealthCheck.httpHeaders=[\n    { name = \"x-from-where\", value = \"frp\" }\n]\n\n[[proxies]]\nname = \"web02\"\ntype = \"https\"\n# Disable this proxy by setting enabled to false\n# enabled = false\nlocalIP = \"127.0.0.1\"\nlocalPort = 8000\nsubdomain = \"web02\"\ncustomDomains = [\"web02.yourdomain.com\"]\n# if not empty, frpc will use proxy protocol to transfer connection info to your local service\n# v1 or v2 or empty\ntransport.proxyProtocolVersion = \"v2\"\n\n[[proxies]]\nname = \"tcpmuxhttpconnect\"\ntype = \"tcpmux\"\nmultiplexer = \"httpconnect\"\nlocalIP = \"127.0.0.1\"\nlocalPort = 10701\ncustomDomains = [\"tunnel1\"]\n# routeByHTTPUser = \"user1\"\n\n[[proxies]]\nname = \"plugin_unix_domain_socket\"\ntype = \"tcp\"\nremotePort = 6003\n# if plugin is defined, localIP and localPort is useless\n# plugin will handle connections got from frps\n[proxies.plugin]\ntype = \"unix_domain_socket\"\nunixPath = \"/var/run/docker.sock\"\n\n[[proxies]]\nname = \"plugin_http_proxy\"\ntype = \"tcp\"\nremotePort = 6004\n[proxies.plugin]\ntype = \"http_proxy\"\nhttpUser = \"abc\"\nhttpPassword = \"abc\"\n\n[[proxies]]\nname = \"plugin_socks5\"\ntype = \"tcp\"\nremotePort = 6005\n[proxies.plugin]\ntype = \"socks5\"\nusername = \"abc\"\npassword = \"abc\"\n\n[[proxies]]\nname = \"plugin_static_file\"\ntype = \"tcp\"\nremotePort = 6006\n[proxies.plugin]\ntype = \"static_file\"\nlocalPath = \"/var/www/blog\"\nstripPrefix = \"static\"\nhttpUser = \"abc\"\nhttpPassword = \"abc\"\n\n[[proxies]]\nname = \"plugin_https2http\"\ntype = \"https\"\ncustomDomains = [\"test.yourdomain.com\"]\n[proxies.plugin]\ntype = \"https2http\"\nlocalAddr = \"127.0.0.1:80\"\ncrtPath = \"./server.crt\"\nkeyPath = \"./server.key\"\nhostHeaderRewrite = \"127.0.0.1\"\nrequestHeaders.set.x-from-where = \"frp\"\n\n[[proxies]]\nname = \"plugin_https2https\"\ntype = \"https\"\ncustomDomains = [\"test.yourdomain.com\"]\n[proxies.plugin]\ntype = \"https2https\"\nlocalAddr = \"127.0.0.1:443\"\ncrtPath = \"./server.crt\"\nkeyPath = \"./server.key\"\nhostHeaderRewrite = \"127.0.0.1\"\nrequestHeaders.set.x-from-where = \"frp\"\n\n[[proxies]]\nname = \"plugin_http2https\"\ntype = \"http\"\ncustomDomains = [\"test.yourdomain.com\"]\n[proxies.plugin]\ntype = \"http2https\"\nlocalAddr = \"127.0.0.1:443\"\nhostHeaderRewrite = \"127.0.0.1\"\nrequestHeaders.set.x-from-where = \"frp\"\n\n[[proxies]]\nname = \"plugin_http2http\"\ntype = \"tcp\"\nremotePort = 6007\n[proxies.plugin]\ntype = \"http2http\"\nlocalAddr = \"127.0.0.1:80\"\nhostHeaderRewrite = \"127.0.0.1\"\nrequestHeaders.set.x-from-where = \"frp\"\n\n[[proxies]]\nname = \"plugin_tls2raw\"\ntype = \"tcp\"\nremotePort = 6008\n[proxies.plugin]\ntype = \"tls2raw\"\nlocalAddr = \"127.0.0.1:80\"\ncrtPath = \"./server.crt\"\nkeyPath = \"./server.key\"\n\n[[proxies]]\nname = \"secret_tcp\"\n# If the type is secret tcp, remotePort is useless\n# Who want to connect local port should deploy another frpc with stcp proxy and role is visitor\ntype = \"stcp\"\n# secretKey is used for authentication for visitors\nsecretKey = \"abcdefg\"\nlocalIP = \"127.0.0.1\"\nlocalPort = 22\n# If not empty, only visitors from specified users can connect.\n# Otherwise, visitors from same user can connect. '*' means allow all users.\nallowUsers = [\"*\"]\n\n[[proxies]]\nname = \"p2p_tcp\"\ntype = \"xtcp\"\nsecretKey = \"abcdefg\"\nlocalIP = \"127.0.0.1\"\nlocalPort = 22\n# If not empty, only visitors from specified users can connect.\n# Otherwise, visitors from same user can connect. '*' means allow all users.\nallowUsers = [\"user1\", \"user2\"]\n\n# NAT traversal configuration (optional)\n[proxies.natTraversal]\n# Disable the use of local network interfaces (assisted addresses) for NAT traversal.\n# When enabled, only STUN-discovered public addresses will be used.\n# This can improve performance when you have slow VPN connections.\n# Default: false\ndisableAssistedAddrs = false\n\n[[proxies]]\nname = \"vnet-server\"\ntype = \"stcp\"\nsecretKey = \"your-secret-key\"\n[proxies.plugin]\ntype = \"virtual_net\"\n\n# frpc role visitor -> frps -> frpc role server\n[[visitors]]\nname = \"secret_tcp_visitor\"\ntype = \"stcp\"\n# the server name you want to visitor\nserverName = \"secret_tcp\"\nsecretKey = \"abcdefg\"\n# connect this address to visitor stcp server\nbindAddr = \"127.0.0.1\"\n# bindPort can be less than 0, it means don't bind to the port and only receive connections redirected from\n# other visitors. (This is not supported for SUDP now)\nbindPort = 9000\n\n[[visitors]]\nname = \"p2p_tcp_visitor\"\ntype = \"xtcp\"\n# if the server user is not set, it defaults to the current user\nserverUser = \"user1\"\nserverName = \"p2p_tcp\"\nsecretKey = \"abcdefg\"\nbindAddr = \"127.0.0.1\"\n# bindPort can be less than 0, it means don't bind to the port and only receive connections redirected from\n# other visitors. (This is not supported for SUDP now)\nbindPort = 9001\n# when automatic tunnel persistence is required, set it to true\nkeepTunnelOpen = false\n# effective when keepTunnelOpen is set to true, the number of attempts to punch through per hour\nmaxRetriesAnHour = 8\nminRetryInterval = 90\n# fallbackTo = \"stcp_visitor\"\n# fallbackTimeoutMs = 500\n\n# NAT traversal configuration (optional)\n[visitors.natTraversal]\n# Disable the use of local network interfaces (assisted addresses) for NAT traversal.\n# When enabled, only STUN-discovered public addresses will be used.\n# Default: false\ndisableAssistedAddrs = false\n\n[[visitors]]\nname = \"vnet-visitor\"\ntype = \"stcp\"\nserverName = \"vnet-server\"\nsecretKey = \"your-secret-key\"\nbindPort = -1\n[visitors.plugin]\ntype = \"virtual_net\"\ndestinationIP = \"100.86.0.1\"\n"
  },
  {
    "path": "conf/frps.toml",
    "content": "bindPort = 7000\n"
  },
  {
    "path": "conf/frps_full_example.toml",
    "content": "# This configuration file is for reference only. Please do not use this configuration directly to run the program as it may have various issues.\n\n# A literal address or host name for IPv6 must be enclosed\n# in square brackets, as in \"[::1]:80\", \"[ipv6-host]:http\" or \"[ipv6-host%zone]:80\"\n# For single \"bindAddr\" field, no need square brackets, like `bindAddr = \"::\"`.\nbindAddr = \"0.0.0.0\"\nbindPort = 7000\n\n# udp port used for kcp protocol, it can be same with 'bindPort'.\n# if not set, kcp is disabled in frps.\nkcpBindPort = 7000\n\n# udp port used for quic protocol.\n# if not set, quic is disabled in frps.\n# quicBindPort = 7002\n\n# Specify which address proxy will listen for, default value is same with bindAddr\n# proxyBindAddr = \"127.0.0.1\"\n\n# quic protocol options\n# transport.quic.keepalivePeriod = 10\n# transport.quic.maxIdleTimeout = 30\n# transport.quic.maxIncomingStreams = 100000\n\n# Heartbeat configure, it's not recommended to modify the default value\n# The default value of heartbeatTimeout is 90. Set negative value to disable it.\n# transport.heartbeatTimeout = 90\n\n# Pool count in each proxy will keep no more than maxPoolCount.\ntransport.maxPoolCount = 5\n\n# If tcp stream multiplexing is used, default is true\n# transport.tcpMux = true\n\n# Specify keep alive interval for tcp mux.\n# only valid if tcpMux is true.\n# transport.tcpMuxKeepaliveInterval = 30\n\n# tcpKeepalive specifies the interval between keep-alive probes for an active network connection between frpc and frps.\n# If negative, keep-alive probes are disabled.\n# transport.tcpKeepalive = 7200\n\n# transport.tls.force specifies whether to only accept TLS-encrypted connections. By default, the value is false.\ntransport.tls.force = false\n\n# transport.tls.certFile = \"server.crt\"\n# transport.tls.keyFile = \"server.key\"\n# transport.tls.trustedCaFile = \"ca.crt\"\n\n# If you want to support virtual host, you must set the http port for listening (optional)\n# Note: http port and https port can be same with bindPort\nvhostHTTPPort = 80\nvhostHTTPSPort = 443\n\n# Response header timeout(seconds) for vhost http server, default is 60s\n# vhostHTTPTimeout = 60\n\n# tcpmuxHTTPConnectPort specifies the port that the server listens for TCP\n# HTTP CONNECT requests. If the value is 0, the server will not multiplex TCP\n# requests on one single port. If it's not - it will listen on this value for\n# HTTP CONNECT requests. By default, this value is 0.\n# tcpmuxHTTPConnectPort = 1337\n\n# If tcpmuxPassthrough is true, frps won't do any update on traffic.\n# tcpmuxPassthrough = false\n\n# Configure the web server to enable the dashboard for frps.\n# dashboard is available only if webServer.port is set.\nwebServer.addr = \"127.0.0.1\"\nwebServer.port = 7500\nwebServer.user = \"admin\"\nwebServer.password = \"admin\"\n# webServer.tls.certFile = \"server.crt\"\n# webServer.tls.keyFile = \"server.key\"\n# dashboard assets directory(only for debug mode)\n# webServer.assetsDir = \"./static\"\n\n# Enable golang pprof handlers in dashboard listener.\n# Dashboard port must be set first\nwebServer.pprofEnable = false\n\n# enablePrometheus will export prometheus metrics on webServer in /metrics api.\nenablePrometheus = true\n\n# console or real logFile path like ./frps.log\nlog.to = \"./frps.log\"\n# trace, debug, info, warn, error\nlog.level = \"info\"\nlog.maxDays = 3\n# disable log colors when log.to is console, default is false\nlog.disablePrintColor = false\n\n# DetailedErrorsToClient defines whether to send the specific error (with debug info) to frpc. By default, this value is true.\ndetailedErrorsToClient = true\n\n# auth.method specifies what authentication method to use authenticate frpc with frps.\n# If \"token\" is specified - token will be read into login message.\n# If \"oidc\" is specified - OIDC (Open ID Connect) token will be issued using OIDC settings. By default, this value is \"token\".\nauth.method = \"token\"\n\n# auth.additionalScopes specifies additional scopes to include authentication information.\n# Optional values are HeartBeats, NewWorkConns.\n# auth.additionalScopes = [\"HeartBeats\", \"NewWorkConns\"]\n\n# auth token\nauth.token = \"12345678\"\n\n# alternatively, you can use tokenSource to load the token from a file\n# this is mutually exclusive with auth.token\n# auth.tokenSource.type = \"file\"\n# auth.tokenSource.file.path = \"/etc/frp/token\"\n\n# oidc issuer specifies the issuer to verify OIDC tokens with.\nauth.oidc.issuer = \"\"\n# oidc audience specifies the audience OIDC tokens should contain when validated.\nauth.oidc.audience = \"\"\n# oidc skipExpiryCheck specifies whether to skip checking if the OIDC token is expired.\nauth.oidc.skipExpiryCheck = false\n# oidc skipIssuerCheck specifies whether to skip checking if the OIDC token's issuer claim matches the issuer specified in OidcIssuer.\nauth.oidc.skipIssuerCheck = false\n\n# userConnTimeout specifies the maximum time to wait for a work connection.\n# userConnTimeout = 10\n\n# Only allow frpc to bind ports you list. By default, there won't be any limit.\nallowPorts = [\n  { start = 2000, end = 3000 },\n  { single = 3001 },\n  { single = 3003 },\n  { start = 4000, end = 50000 }\n]\n\n# Max ports can be used for each client, default value is 0 means no limit\nmaxPortsPerClient = 0\n\n# If subDomainHost is not empty, you can set subdomain when type is http or https in frpc's configure file\n# When subdomain is test, the host used by routing is test.frps.com\nsubDomainHost = \"frps.com\"\n\n# custom 404 page for HTTP requests\n# custom404Page = \"/path/to/404.html\"\n\n# specify udp packet size, unit is byte. If not set, the default value is 1500.\n# This parameter should be same between client and server.\n# It affects the udp and sudp proxy.\nudpPacketSize = 1500\n\n# Retention time for NAT hole punching strategy data.\nnatholeAnalysisDataReserveHours = 168\n\n# ssh tunnel gateway\n# If you want to enable this feature, the bindPort parameter is required, while others are optional.\n# By default, this feature is disabled. It will be enabled if bindPort is greater than 0.\n# sshTunnelGateway.bindPort = 2200\n# sshTunnelGateway.privateKeyFile = \"/home/frp-user/.ssh/id_rsa\"\n# sshTunnelGateway.autoGenPrivateKeyPath = \"\"\n# sshTunnelGateway.authorizedKeysFile = \"/home/frp-user/.ssh/authorized_keys\"\n\n[[httpPlugins]]\nname = \"user-manager\"\naddr = \"127.0.0.1:9000\"\npath = \"/handler\"\nops = [\"Login\"]\n\n[[httpPlugins]]\nname = \"port-manager\"\naddr = \"127.0.0.1:9001\"\npath = \"/handler\"\nops = [\"NewProxy\"]\n"
  },
  {
    "path": "conf/legacy/frpc_legacy_full.ini",
    "content": "# [common] is integral section\n[common]\n# A literal address or host name for IPv6 must be enclosed\n# in square brackets, as in \"[::1]:80\", \"[ipv6-host]:http\" or \"[ipv6-host%zone]:80\"\n# For single \"server_addr\" field, no need square brackets, like \"server_addr = ::\".\nserver_addr = 0.0.0.0\nserver_port = 7000\n\n# STUN server to help penetrate NAT hole.\n# nat_hole_stun_server = stun.easyvoip.com:3478\n\n# The maximum amount of time a dial to server will wait for a connect to complete. Default value is 10 seconds.\n# dial_server_timeout = 10\n\n# dial_server_keepalive specifies the interval between keep-alive probes for an active network connection between frpc and frps.\n# If negative, keep-alive probes are disabled.\n# dial_server_keepalive = 7200\n\n# 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\n# it only works when protocol is tcp\n# http_proxy = http://user:passwd@192.168.1.128:8080\n# http_proxy = socks5://user:passwd@192.168.1.128:1080\n# http_proxy = ntlm://user:passwd@192.168.1.128:2080\n\n# console or real logFile path like ./frpc.log\nlog_file = ./frpc.log\n\n# trace, debug, info, warn, error\nlog_level = info\n\nlog_max_days = 3\n\n# disable log colors when log_file is console, default is false\ndisable_log_color = false\n\n# for authentication, should be same as your frps.ini\n# authenticate_heartbeats specifies whether to include authentication token in heartbeats sent to frps. By default, this value is false.\nauthenticate_heartbeats = false\n\n# authenticate_new_work_conns specifies whether to include authentication token in new work connections sent to frps. By default, this value is false.\nauthenticate_new_work_conns = false\n\n# auth token\ntoken = 12345678\n\nauthentication_method = \n\n# oidc_client_id specifies the client ID to use to get a token in OIDC authentication if AuthenticationMethod == \"oidc\".\n# By default, this value is \"\".\noidc_client_id =\n\n# oidc_client_secret specifies the client secret to use to get a token in OIDC authentication if AuthenticationMethod == \"oidc\".\n# By default, this value is \"\".\noidc_client_secret =\n\n# oidc_audience specifies the audience of the token in OIDC authentication if AuthenticationMethod == \"oidc\". By default, this value is \"\".\noidc_audience =\n\n# oidc_scope specifies the permissions of the token in OIDC authentication if AuthenticationMethod == \"oidc\". By default, this value is \"\".\noidc_scope =\n\n# oidc_token_endpoint_url specifies the URL which implements OIDC Token Endpoint.\n# It will be used to get an OIDC token if AuthenticationMethod == \"oidc\". By default, this value is \"\".\noidc_token_endpoint_url =\n\n# oidc_additional_xxx specifies additional parameters to be sent to the OIDC Token Endpoint.\n# For example, if you want to specify the \"audience\" parameter, you can set as follow.\n# frp will add \"audience=<value>\" \"var1=<value>\" to the additional parameters.\n# oidc_additional_audience = https://dev.auth.com/api/v2/\n# oidc_additional_var1 = foobar\n\n# set admin address for control frpc's action by http api such as reload\nadmin_addr = 127.0.0.1\nadmin_port = 7400\nadmin_user = admin\nadmin_pwd = admin\n# Admin assets directory. By default, these assets are bundled with frpc.\n# assets_dir = ./static\n\n# connections will be established in advance, default value is zero\npool_count = 5\n\n# if tcp stream multiplexing is used, default is true, it must be same with frps\n# tcp_mux = true\n\n# specify keep alive interval for tcp mux.\n# only valid if tcp_mux is true.\n# tcp_mux_keepalive_interval = 60\n\n# your proxy name will be changed to {user}.{proxy}\nuser = your_name\n\n# decide if exit program when first login failed, otherwise continuous relogin to frps\n# default is true\nlogin_fail_exit = true\n\n# communication protocol used to connect to server\n# supports tcp, kcp, quic, websocket and wss now, default is tcp\nprotocol = tcp\n\n# set client binding ip when connect server, default is empty.\n# only when protocol = tcp or websocket, the value will be used.\nconnect_server_local_ip = 0.0.0.0\n\n# quic protocol options\n# quic_keepalive_period = 10\n# quic_max_idle_timeout = 30\n# quic_max_incoming_streams = 100000\n\n# If tls_enable is true, frpc will connect frps by tls.\n# Since v0.50.0, the default value has been changed to true, and tls is enabled by default.\ntls_enable = true\n\n# tls_cert_file = client.crt\n# tls_key_file = client.key\n# tls_trusted_ca_file = ca.crt\n# tls_server_name = example.com\n\n# specify a dns server, so frpc will use this instead of default one\n# dns_server = 8.8.8.8\n\n# proxy names you want to start separated by ','\n# default is empty, means all proxies\n# start = ssh,dns\n\n# heartbeat configure, it's not recommended to modify the default value\n# The default value of heartbeat_interval is 10 and heartbeat_timeout is 90. Set negative value\n# to disable it.\n# heartbeat_interval = 30\n# heartbeat_timeout = 90\n\n# additional meta info for client\nmeta_var1 = 123\nmeta_var2 = 234\n\n# specify udp packet size, unit is byte. If not set, the default value is 1500.\n# This parameter should be same between client and server.\n# It affects the udp and sudp proxy.\nudp_packet_size = 1500\n\n# include other config files for proxies.\n# includes = ./confd/*.ini\n\n# If the disable_custom_tls_first_byte is set to false, frpc will establish a connection with frps using the\n# first custom byte when tls is enabled.\n# Since v0.50.0, the default value has been changed to true, and the first custom byte is disabled by default.\ndisable_custom_tls_first_byte = true\n\n# Enable golang pprof handlers in admin listener.\n# Admin port must be set first.\npprof_enable = false\n\n# 'ssh' is the unique proxy name\n# if user in [common] section is not empty, it will be changed to {user}.{proxy} such as 'your_name.ssh'\n[ssh]\n# tcp | udp | http | https | stcp | xtcp, default is tcp\ntype = tcp\nlocal_ip = 127.0.0.1\nlocal_port = 22\n# limit bandwidth for this proxy, unit is KB and MB\nbandwidth_limit = 1MB\n# where to limit bandwidth, can be 'client' or 'server', default is 'client'\nbandwidth_limit_mode = client\n# true or false, if true, messages between frps and frpc will be encrypted, default is false\nuse_encryption = false\n# if true, message will be compressed\nuse_compression = false\n# remote port listen by frps\nremote_port = 6001\n# frps will load balancing connections for proxies in same group\ngroup = test_group\n# group should have same group key\ngroup_key = 123456\n# enable health check for the backend service, it support 'tcp' and 'http' now\n# frpc will connect local service's port to detect it's healthy status\nhealth_check_type = tcp\n# health check connection timeout\nhealth_check_timeout_s = 3\n# if continuous failed in 3 times, the proxy will be removed from frps\nhealth_check_max_failed = 3\n# every 10 seconds will do a health check\nhealth_check_interval_s = 10\n# additional meta info for each proxy\nmeta_var1 = 123\nmeta_var2 = 234\n\n[ssh_random]\ntype = tcp\nlocal_ip = 127.0.0.1\nlocal_port = 22\n# if remote_port is 0, frps will assign a random port for you\nremote_port = 0\n\n# if you want to expose multiple ports, add 'range:' prefix to the section name\n# frpc will generate multiple proxies such as 'tcp_port_6010', 'tcp_port_6011' and so on.\n[range:tcp_port]\ntype = tcp\nlocal_ip = 127.0.0.1\nlocal_port = 6010-6020,6022,6024-6028\nremote_port = 6010-6020,6022,6024-6028\nuse_encryption = false\nuse_compression = false\n\n[dns]\ntype = udp\nlocal_ip = 114.114.114.114\nlocal_port = 53\nremote_port = 6002\nuse_encryption = false\nuse_compression = false\n\n[range:udp_port]\ntype = udp\nlocal_ip = 127.0.0.1\nlocal_port = 6010-6020\nremote_port = 6010-6020\nuse_encryption = false\nuse_compression = false\n\n# 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\n[web01]\ntype = http\nlocal_ip = 127.0.0.1\nlocal_port = 80\nuse_encryption = false\nuse_compression = true\n# http username and password are safety certification for http protocol\n# if not set, you can access this custom_domains without certification\nhttp_user = admin\nhttp_pwd = admin\n# if domain for frps is frps.com, then you can access [web01] proxy by URL http://web01.frps.com\nsubdomain = web01\ncustom_domains = web01.yourdomain.com\n# locations is only available for http type\nlocations = /,/pic\n# route requests to this service if http basic auto user is abc\n# route_by_http_user = abc\nhost_header_rewrite = example.com\n# params with prefix \"header_\" will be used to update http request headers\nheader_X-From-Where = frp\nhealth_check_type = http\n# frpc will send a GET http request '/status' to local http service\n# http service is alive when it return 2xx http response code\nhealth_check_url = /status\nhealth_check_interval_s = 10\nhealth_check_max_failed = 3\nhealth_check_timeout_s = 3\n\n[web02]\ntype = https\nlocal_ip = 127.0.0.1\nlocal_port = 8000\nuse_encryption = false\nuse_compression = false\nsubdomain = web02\ncustom_domains = web02.yourdomain.com\n# if not empty, frpc will use proxy protocol to transfer connection info to your local service\n# v1 or v2 or empty\nproxy_protocol_version = v2\n\n[plugin_unix_domain_socket]\ntype = tcp\nremote_port = 6003\n# if plugin is defined, local_ip and local_port is useless\n# plugin will handle connections got from frps\nplugin = unix_domain_socket\n# params with prefix \"plugin_\" that plugin needed\nplugin_unix_path = /var/run/docker.sock\n\n[plugin_http_proxy]\ntype = tcp\nremote_port = 6004\nplugin = http_proxy\nplugin_http_user = abc\nplugin_http_passwd = abc\n\n[plugin_socks5]\ntype = tcp\nremote_port = 6005\nplugin = socks5\nplugin_user = abc\nplugin_passwd = abc\n\n[plugin_static_file]\ntype = tcp\nremote_port = 6006\nplugin = static_file\nplugin_local_path = /var/www/blog\nplugin_strip_prefix = static\nplugin_http_user = abc\nplugin_http_passwd = abc\n\n[plugin_https2http]\ntype = https\ncustom_domains = test.yourdomain.com\nplugin = https2http\nplugin_local_addr = 127.0.0.1:80\nplugin_crt_path = ./server.crt\nplugin_key_path = ./server.key\nplugin_host_header_rewrite = 127.0.0.1\nplugin_header_X-From-Where = frp\n\n[plugin_https2https]\ntype = https\ncustom_domains = test.yourdomain.com\nplugin = https2https\nplugin_local_addr = 127.0.0.1:443\nplugin_crt_path = ./server.crt\nplugin_key_path = ./server.key\nplugin_host_header_rewrite = 127.0.0.1\nplugin_header_X-From-Where = frp\n\n[plugin_http2https]\ntype = http\ncustom_domains = test.yourdomain.com\nplugin = http2https\nplugin_local_addr = 127.0.0.1:443\nplugin_host_header_rewrite = 127.0.0.1\nplugin_header_X-From-Where = frp\n\n[secret_tcp]\n# If the type is secret tcp, remote_port is useless\n# Who want to connect local port should deploy another frpc with stcp proxy and role is visitor\ntype = stcp\n# sk used for authentication for visitors\nsk = abcdefg\nlocal_ip = 127.0.0.1\nlocal_port = 22\nuse_encryption = false\nuse_compression = false\n# If not empty, only visitors from specified users can connect.\n# Otherwise, visitors from same user can connect. '*' means allow all users.\nallow_users = *\n\n# user of frpc should be same in both stcp server and stcp visitor\n[secret_tcp_visitor]\n# frpc role visitor -> frps -> frpc role server\nrole = visitor\ntype = stcp\n# the server name you want to visitor\nserver_name = secret_tcp\nsk = abcdefg\n# connect this address to visitor stcp server\nbind_addr = 127.0.0.1\n# bind_port can be less than 0, it means don't bind to the port and only receive connections redirected from\n# other visitors. (This is not supported for SUDP now)\nbind_port = 9000\nuse_encryption = false\nuse_compression = false\n\n[p2p_tcp]\ntype = xtcp\nsk = abcdefg\nlocal_ip = 127.0.0.1\nlocal_port = 22\nuse_encryption = false\nuse_compression = false\n# If not empty, only visitors from specified users can connect.\n# Otherwise, visitors from same user can connect. '*' means allow all users.\nallow_users = user1, user2\n\n[p2p_tcp_visitor]\nrole = visitor\ntype = xtcp\n# if the server user is not set, it defaults to the current user\nserver_user = user1\nserver_name = p2p_tcp\nsk = abcdefg\nbind_addr = 127.0.0.1\n# bind_port can be less than 0, it means don't bind to the port and only receive connections redirected from\n# other visitors. (This is not supported for SUDP now)\nbind_port = 9001\nuse_encryption = false\nuse_compression = false\n# when automatic tunnel persistence is required, set it to true\nkeep_tunnel_open = false\n# effective when keep_tunnel_open is set to true, the number of attempts to punch through per hour\nmax_retries_an_hour = 8\nmin_retry_interval = 90\n# fallback_to = stcp_visitor\n# fallback_timeout_ms = 500\n\n[tcpmuxhttpconnect]\ntype = tcpmux\nmultiplexer = httpconnect\nlocal_ip = 127.0.0.1\nlocal_port = 10701\ncustom_domains = tunnel1\n# route_by_http_user = user1\n"
  },
  {
    "path": "conf/legacy/frps_legacy_full.ini",
    "content": "# [common] is integral section\n[common]\n# A literal address or host name for IPv6 must be enclosed\n# in square brackets, as in \"[::1]:80\", \"[ipv6-host]:http\" or \"[ipv6-host%zone]:80\"\n# For single \"bind_addr\" field, no need square brackets, like \"bind_addr = ::\".\nbind_addr = 0.0.0.0\nbind_port = 7000\n\n# udp port used for kcp protocol, it can be same with 'bind_port'.\n# if not set, kcp is disabled in frps.\nkcp_bind_port = 7000\n\n# udp port used for quic protocol.\n# if not set, quic is disabled in frps.\n# quic_bind_port = 7002\n# quic protocol options\n# quic_keepalive_period = 10\n# quic_max_idle_timeout = 30\n# quic_max_incoming_streams = 100000\n\n# specify which address proxy will listen for, default value is same with bind_addr\n# proxy_bind_addr = 127.0.0.1\n\n# if you want to support virtual host, you must set the http port for listening (optional)\n# Note: http port and https port can be same with bind_port\nvhost_http_port = 80\nvhost_https_port = 443\n\n# response header timeout(seconds) for vhost http server, default is 60s\n# vhost_http_timeout = 60\n\n# tcpmux_httpconnect_port specifies the port that the server listens for TCP\n# HTTP CONNECT requests. If the value is 0, the server will not multiplex TCP\n# requests on one single port. If it's not - it will listen on this value for\n# HTTP CONNECT requests. By default, this value is 0.\n# tcpmux_httpconnect_port = 1337\n\n# If tcpmux_passthrough is true, frps won't do any update on traffic.\n# tcpmux_passthrough = false\n\n# set dashboard_addr and dashboard_port to view dashboard of frps\n# dashboard_addr's default value is same with bind_addr\n# dashboard is available only if dashboard_port is set\ndashboard_addr = 0.0.0.0\ndashboard_port = 7500\n\n# dashboard user and passwd for basic auth protect\ndashboard_user = admin\ndashboard_pwd = admin\n\n# dashboard TLS mode\ndashboard_tls_mode = false\n# dashboard_tls_cert_file = server.crt\n# dashboard_tls_key_file = server.key\n\n# enable_prometheus will export prometheus metrics on {dashboard_addr}:{dashboard_port} in /metrics api.\nenable_prometheus = true\n\n# dashboard assets directory(only for debug mode)\n# assets_dir = ./static\n\n# console or real logFile path like ./frps.log\nlog_file = ./frps.log\n\n# trace, debug, info, warn, error\nlog_level = info\n\nlog_max_days = 3\n\n# disable log colors when log_file is console, default is false\ndisable_log_color = false\n\n# DetailedErrorsToClient defines whether to send the specific error (with debug info) to frpc. By default, this value is true.\ndetailed_errors_to_client = true\n\n# authentication_method specifies what authentication method to use authenticate frpc with frps.\n# If \"token\" is specified - token will be read into login message.\n# If \"oidc\" is specified - OIDC (Open ID Connect) token will be issued using OIDC settings. By default, this value is \"token\".\nauthentication_method = token\n\n# authenticate_heartbeats specifies whether to include authentication token in heartbeats sent to frps. By default, this value is false.\nauthenticate_heartbeats = false\n\n# AuthenticateNewWorkConns specifies whether to include authentication token in new work connections sent to frps. By default, this value is false.\nauthenticate_new_work_conns = false\n\n# auth token\ntoken = 12345678\n\n# oidc_issuer specifies the issuer to verify OIDC tokens with.\n# By default, this value is \"\".\noidc_issuer =\n\n# oidc_audience specifies the audience OIDC tokens should contain when validated.\n# By default, this value is \"\".\noidc_audience =\n\n# oidc_skip_expiry_check specifies whether to skip checking if the OIDC token is expired.\n# By default, this value is false.\noidc_skip_expiry_check = false\n\n# oidc_skip_issuer_check specifies whether to skip checking if the OIDC token's issuer claim matches the issuer specified in OidcIssuer.\n# By default, this value is false.\noidc_skip_issuer_check = false\n\n# heartbeat configure, it's not recommended to modify the default value\n# the default value of heartbeat_timeout is 90. Set negative value to disable it.\n# heartbeat_timeout = 90\n\n# user_conn_timeout configure, it's not recommended to modify the default value\n# the default value of user_conn_timeout is 10\n# user_conn_timeout = 10\n\n# only allow frpc to bind ports you list, if you set nothing, there won't be any limit\nallow_ports = 2000-3000,3001,3003,4000-50000\n\n# pool_count in each proxy will change to max_pool_count if they exceed the maximum value\nmax_pool_count = 5\n\n# max ports can be used for each client, default value is 0 means no limit\nmax_ports_per_client = 0\n\n# tls_only specifies whether to only accept TLS-encrypted connections. By default, the value is false.\ntls_only = false\n\n# tls_cert_file = server.crt\n# tls_key_file = server.key\n# tls_trusted_ca_file = ca.crt\n\n# if subdomain_host is not empty, you can set subdomain when type is http or https in frpc's configure file\n# when subdomain is test, the host used by routing is test.frps.com\nsubdomain_host = frps.com\n\n# if tcp stream multiplexing is used, default is true\n# tcp_mux = true\n\n# specify keep alive interval for tcp mux.\n# only valid if tcp_mux is true.\n# tcp_mux_keepalive_interval = 60\n\n# tcp_keepalive specifies the interval between keep-alive probes for an active network connection between frpc and frps.\n# If negative, keep-alive probes are disabled.\n# tcp_keepalive = 7200\n\n# custom 404 page for HTTP requests\n# custom_404_page = /path/to/404.html\n\n# specify udp packet size, unit is byte. If not set, the default value is 1500.\n# This parameter should be same between client and server.\n# It affects the udp and sudp proxy.\nudp_packet_size = 1500\n\n# Enable golang pprof handlers in dashboard listener.\n# Dashboard port must be set first\npprof_enable = false\n\n# Retention time for NAT hole punching strategy data.\nnat_hole_analysis_data_reserve_hours = 168\n\n[plugin.user-manager]\naddr = 127.0.0.1:9000\npath = /handler\nops = Login\n\n[plugin.port-manager]\naddr = 127.0.0.1:9001\npath = /handler\nops = NewProxy\n"
  },
  {
    "path": "doc/server_plugin.md",
    "content": "### Server Plugin\n\nfrp server plugin is aimed to extend frp's ability without modifying the Golang code.\n\nAn external server should run in a different process receiving RPC calls from frps.\nBefore frps is doing some operations, it will send RPC requests to notify the external RPC server and act according to its response.\n\n### RPC request\n\nRPC requests are based on JSON over HTTP.\n\nWhen a server plugin accepts an operation request, it can respond with three different responses:\n\n* Reject operation and return a reason.\n* Allow operation and keep original content.\n* Allow operation and return modified content.\n\n### Interface\n\nHTTP path can be configured for each manage plugin in frps. We'll assume for this example that it's `/handler`.\n\nA request to the RPC server will look like:\n\n```\nPOST /handler?version=0.1.0&op=Login\n{\n    \"version\": \"0.1.0\",\n    \"op\": \"Login\",\n    \"content\": {\n        ... // Operation info\n    }\n}\n\nRequest Header:\nX-Frp-Reqid: for tracing\n```\n\nThe response can look like any of the following:\n\n* Non-200 HTTP response status code (this will automatically tell frps that the request should fail)\n\n* Reject operation:\n\n```\n{\n    \"reject\": true,\n    \"reject_reason\": \"invalid user\"\n}\n```\n\n* Allow operation and keep original content:\n\n```\n{\n    \"reject\": false,\n    \"unchange\": true\n}\n```\n\n* Allow operation and modify content\n\n```\n{\n    \"unchange\": \"false\",\n    \"content\": {\n        ... // Replaced content\n    }\n}\n```\n\n### Operation\n\nCurrently `Login`, `NewProxy`, `CloseProxy`, `Ping`, `NewWorkConn` and `NewUserConn` operations are supported.\n\n#### Login\n\nClient login operation\n\n```\n{\n    \"content\": {\n        \"version\": <string>,\n        \"hostname\": <string>,\n        \"os\": <string>,\n        \"arch\": <string>,\n        \"user\": <string>,\n        \"timestamp\": <int64>,\n        \"privilege_key\": <string>,\n        \"run_id\": <string>,\n        \"pool_count\": <int>,\n        \"metas\": map<string>string,\n        \"client_address\": <string>\n    }\n}\n```\n\n#### NewProxy\n\nCreate new proxy\n\n```\n{\n    \"content\": {\n        \"user\": {\n            \"user\": <string>,\n            \"metas\": map<string>string\n            \"run_id\": <string>\n        },\n        \"proxy_name\": <string>,\n        \"proxy_type\": <string>,\n        \"use_encryption\": <bool>,\n        \"use_compression\": <bool>,\n        \"bandwidth_limit\": <string>,\n        \"bandwidth_limit_mode\": <string>,\n        \"group\": <string>,\n        \"group_key\": <string>,\n\n        // tcp and udp only\n        \"remote_port\": <int>,\n\n        // http and https only\n        \"custom_domains\": []<string>,\n        \"subdomain\": <string>,\n        \"locations\": []<string>,\n        \"http_user\": <string>,\n        \"http_pwd\": <string>,\n        \"host_header_rewrite\": <string>,\n        \"headers\": map<string>string,\n\n        // stcp only\n        \"sk\": <string>,\n\n        // tcpmux only\n        \"multiplexer\": <string>\n\n        \"metas\": map<string>string\n    }\n}\n```\n\n#### CloseProxy\n\nA previously created proxy is closed.\n\nPlease note that one request will be sent for every proxy that is closed, do **NOT** use this\nif you have too many proxies bound to a single client, as this may exhaust the server's resources.\n\n```\n{\n    \"content\": {\n        \"user\": {\n            \"user\": <string>,\n            \"metas\": map<string>string\n            \"run_id\": <string>\n        },\n        \"proxy_name\": <string>\n    }\n}\n```\n\n#### Ping\n\nHeartbeat from frpc\n\n```\n{\n    \"content\": {\n        \"user\": {\n            \"user\": <string>,\n            \"metas\": map<string>string\n            \"run_id\": <string>\n        },\n        \"timestamp\": <int64>,\n        \"privilege_key\": <string>\n    }\n}\n```\n\n#### NewWorkConn\n\nNew work connection received from frpc (RPC sent after `run_id` is matched with an existing frp connection)\n\n```\n{\n    \"content\": {\n        \"user\": {\n            \"user\": <string>,\n            \"metas\": map<string>string\n            \"run_id\": <string>\n        },\n        \"run_id\": <string>\n        \"timestamp\": <int64>,\n        \"privilege_key\": <string>\n    }\n}\n```\n\n#### NewUserConn\n\nNew user connection received from proxy (support `tcp`, `stcp`, `https` and `tcpmux`) .\n\n```\n{\n    \"content\": {\n        \"user\": {\n            \"user\": <string>,\n            \"metas\": map<string>string\n            \"run_id\": <string>\n        },\n        \"proxy_name\": <string>,\n        \"proxy_type\": <string>,\n        \"remote_addr\": <string>\n    }\n}\n```\n\n### Server Plugin Configuration\n\n```toml\n# frps.toml\nbindPort = 7000\n\n[[httpPlugins]]\nname = \"user-manager\"\naddr = \"127.0.0.1:9000\"\npath = \"/handler\"\nops = [\"Login\"]\n\n[[httpPlugins]]\nname = \"port-manager\"\naddr = \"127.0.0.1:9001\"\npath = \"/handler\"\nops = [\"NewProxy\"]\n```\n\n- addr: the address where the external RPC service listens. Defaults to http. For https, specify the schema: `addr = \"https://127.0.0.1:9001\"`.\n- path: http request url path for the POST request.\n- ops: operations plugin needs to handle (e.g. \"Login\", \"NewProxy\", ...).\n- tlsVerify: When the schema is https, we verify by default. Set this value to false if you want to skip verification.\n\n### Metadata\n\nMetadata will be sent to the server plugin in each RPC request.\n\nThere are 2 types of metadata entries - global one and the other under each proxy configuration.\nGlobal metadata entries will be sent in `Login` under the key `metas`, and in any other RPC request under `user.metas`.\nMetadata entries under each proxy configuration will be sent in `NewProxy` op only, under `metas`.\n\nThis is an example of metadata entries:\n\n```toml\n# frpc.toml\nserverAddr = \"127.0.0.1\"\nserverPort = 7000\nuser = \"fake\"\nmetadatas.token = \"fake\"\nmetadatas.version = \"1.0.0\"\n\n[[proxies]]\nname = \"ssh\"\ntype = \"tcp\"\nlocalPort = 22\nremotePort = 6000\nmetadatas.id = \"123\"\n```\n"
  },
  {
    "path": "doc/ssh_tunnel_gateway.md",
    "content": "### SSH Tunnel Gateway\n\n*Added in v0.53.0*\n\n### Concept\n\nSSH supports reverse proxy capabilities [rfc](https://www.rfc-editor.org/rfc/rfc4254#page-16).\n\nfrp 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.\n\nSSH 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.\n\n```toml\n# frps.toml\nsshTunnelGateway.bindPort = 0\nsshTunnelGateway.privateKeyFile = \"\"\nsshTunnelGateway.autoGenPrivateKeyPath = \"\"\nsshTunnelGateway.authorizedKeysFile = \"\"\n```\n\n| Field | Type | Description | Required |\n| :--- | :--- | :--- | :--- |\n| bindPort| int | The ssh server port that frps listens on.| Yes |\n| 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 |\n| 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|\n| 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 |\n\n### Basic Usage\n\n#### Server-side frps\n\nMinimal configuration:\n\n```toml\nsshTunnelGateway.bindPort = 2200\n```\n\nPlace the above configuration in frps.toml and run `./frps -c frps.toml`. It will listen on port 2200 and accept SSH reverse proxy requests.\n\nNote:\n\n1. 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`.\n\n2. 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.\n\n#### Client-side SSH\n\nThe command format is:\n\n```bash\nssh -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}\n```\n\n1. `--proxy_name` is optional, and if left empty, a random one will be generated.\n2. The username for logging in to frps is always \"v0\" and currently has no significance, i.e., `v0@{frps_address}`.\n3. The server-side proxy listens on the port determined by `--remote_port`.\n4. `{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`.\n5. The token is optional, but for security reasons, it is strongly recommended to configure the token in frps.\n\n#### TCP Proxy\n\n```bash\nssh -R :80:127.0.0.1:8080 v0@{frp_address} -p 2200 tcp --proxy_name \"test-tcp\" --remote_port 9090\n```\n\nThis sets up a proxy on frps that listens on port 9090 and proxies local service on port 8080.\n\n```bash\nfrp (via SSH) (Ctrl+C to quit)\n\nUser: \nProxyName: test-tcp\nType: tcp\nRemoteAddress: :9090\n```\n\nEquivalent to:\n\n```bash\nfrpc tcp --proxy_name \"test-tcp\" --local_ip 127.0.0.1 --local_port 8080 --remote_port 9090\n```\n\nMore parameters can be obtained by executing `--help`.\n\n#### HTTP Proxy\n\n```bash\nssh -R :80:127.0.0.1:8080 v0@{frp address} -p 2200 http --proxy_name \"test-http\"  --custom_domain test-http.frps.com\n```\n\nEquivalent to:\n```bash\nfrpc http --proxy_name \"test-http\" --custom_domain test-http.frps.com\n```\n\nYou can access the HTTP service using the following command:\n\ncurl 'http://test-http.frps.com'\n\nMore parameters can be obtained by executing --help.\n\n#### HTTPS/STCP/TCPMUX Proxy\n\nTo obtain the usage instructions, use the following command:\n\n```bash\nssh -R :80:127.0.0.1:8080 v0@{frp address} -p 2200 {https|stcp|tcpmux} --help\n```\n\n### Advanced Usage\n\n#### Reusing the id_rsa File on the Local Machine\n\n```toml\n# frps.toml\nsshTunnelGateway.bindPort = 2200\nsshTunnelGateway.privateKeyFile = \"/home/user/.ssh/id_rsa\"\n```\n\nDuring 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.\n\n#### Specifying the Auto-Generated Private Key File Path\n\n```toml\n# frps.toml\nsshTunnelGateway.bindPort = 2200\nsshTunnelGateway.autoGenPrivateKeyPath = \"/var/frp/ssh-private-key-file\"\n```\n\nfrps will automatically create a private key file and store it at the specified path.\n\nNote: 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.\n\n#### Using an Existing authorized_keys File for SSH Public Key Authentication\n\n```toml\n# frps.toml\nsshTunnelGateway.bindPort = 2200\nsshTunnelGateway.authorizedKeysFile = \"/home/user/.ssh/authorized_keys\"\n```\n\nThe authorizedKeysFile is the file used for SSH public key authentication, which contains the public key information for users, with one key per line.\n\nIf authorizedKeysFile is empty, frps won't perform any authentication for SSH clients. Frps does not support SSH username and password authentication.\n\nYou can reuse an existing `authorized_keys` file on your local machine for client authentication.\n\nNote: 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.\n\n#### Using a Custom authorized_keys File for SSH Public Key Authentication\n\n```toml\n# frps.toml\nsshTunnelGateway.bindPort = 2200\nsshTunnelGateway.authorizedKeysFile = \"/var/frps/custom_authorized_keys_file\"\n```\n\nSpecify the path to a custom `authorized_keys` file.\n\nNote 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.\n"
  },
  {
    "path": "doc/virtual_net.md",
    "content": "# Virtual Network (VirtualNet)\n\n*Alpha feature added in v0.62.0*\n\nThe 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.\n\n> **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.\n\n## Enabling VirtualNet\n\nSince VirtualNet is currently an alpha feature, you need to enable it with feature gates in your configuration:\n\n```toml\n# frpc.toml\nfeatureGates = { VirtualNet = true }\n```\n\n## Basic Configuration\n\nTo use the virtual network capabilities:\n\n1. First, configure your frpc with a virtual network address:\n\n```toml\n# frpc.toml\nserverAddr = \"x.x.x.x\"\nserverPort = 7000\nfeatureGates = { VirtualNet = true }\n\n# Configure the virtual network interface\nvirtualNet.address = \"100.86.0.1/24\"\n```\n\n2. For client proxies, use the `virtual_net` plugin:\n\n```toml\n# frpc.toml (server side)\n[[proxies]]\nname = \"vnet-server\"\ntype = \"stcp\"\nsecretKey = \"your-secret-key\"\n[proxies.plugin]\ntype = \"virtual_net\"\n```\n\n3. For visitor connections, configure the `virtual_net` visitor plugin:\n\n```toml\n# frpc.toml (client side)\nserverAddr = \"x.x.x.x\"\nserverPort = 7000\nfeatureGates = { VirtualNet = true }\n\n# Configure the virtual network interface\nvirtualNet.address = \"100.86.0.2/24\"\n\n[[visitors]]\nname = \"vnet-visitor\"\ntype = \"stcp\"\nserverName = \"vnet-server\"\nsecretKey = \"your-secret-key\"\nbindPort = -1\n[visitors.plugin]\ntype = \"virtual_net\"\ndestinationIP = \"100.86.0.1\"\n```\n\n## Requirements and Limitations\n\n- **Permissions**: Creating a TUN interface requires elevated permissions (root/admin)\n- **Platform Support**: Currently supported on Linux and macOS\n- **Default Status**: As an alpha feature, VirtualNet is disabled by default\n- **Configuration**: A valid IP/CIDR must be provided for each endpoint in the virtual network \n"
  },
  {
    "path": "dockerfiles/Dockerfile-for-frpc",
    "content": "FROM node:22 AS web-builder\n\nWORKDIR /web/frpc\nCOPY web/frpc/ ./\nRUN npm install\nRUN npm run build\n\nFROM golang:1.25 AS building\n\nCOPY . /building\nCOPY --from=web-builder /web/frpc/dist /building/web/frpc/dist\nWORKDIR /building\n\nRUN env CGO_ENABLED=0 go build -trimpath -ldflags \"-s -w\" -tags frpc -o bin/frpc ./cmd/frpc\n\nFROM alpine:3\n\nRUN apk add --no-cache tzdata\n\nCOPY --from=building /building/bin/frpc /usr/bin/frpc\n\nENTRYPOINT [\"/usr/bin/frpc\"]\n"
  },
  {
    "path": "dockerfiles/Dockerfile-for-frps",
    "content": "FROM node:22 AS web-builder\n\nWORKDIR /web/frps\nCOPY web/frps/ ./\nRUN npm install\nRUN npm run build\n\nFROM golang:1.25 AS building\n\nCOPY . /building\nCOPY --from=web-builder /web/frps/dist /building/web/frps/dist\nWORKDIR /building\n\nRUN env CGO_ENABLED=0 go build -trimpath -ldflags \"-s -w\" -tags frps -o bin/frps ./cmd/frps\n\nFROM alpine:3\n\nRUN apk add --no-cache tzdata\n\nCOPY --from=building /building/bin/frps /usr/bin/frps\n\nENTRYPOINT [\"/usr/bin/frps\"]\n"
  },
  {
    "path": "go.mod",
    "content": "module github.com/fatedier/frp\n\ngo 1.25.0\n\nrequire (\n\tgithub.com/armon/go-socks5 v0.0.0-20160902184237-e75332964ef5\n\tgithub.com/coreos/go-oidc/v3 v3.14.1\n\tgithub.com/fatedier/golib v0.5.1\n\tgithub.com/google/uuid v1.6.0\n\tgithub.com/gorilla/mux v1.8.1\n\tgithub.com/gorilla/websocket v1.5.0\n\tgithub.com/hashicorp/yamux v0.1.1\n\tgithub.com/onsi/ginkgo/v2 v2.23.4\n\tgithub.com/onsi/gomega v1.36.3\n\tgithub.com/pelletier/go-toml/v2 v2.2.0\n\tgithub.com/pion/stun/v2 v2.0.0\n\tgithub.com/pires/go-proxyproto v0.7.0\n\tgithub.com/prometheus/client_golang v1.19.1\n\tgithub.com/quic-go/quic-go v0.55.0\n\tgithub.com/rodaine/table v1.2.0\n\tgithub.com/samber/lo v1.47.0\n\tgithub.com/songgao/water v0.0.0-20200317203138-2b4b6d7c09d8\n\tgithub.com/spf13/cobra v1.8.0\n\tgithub.com/spf13/pflag v1.0.5\n\tgithub.com/stretchr/testify v1.10.0\n\tgithub.com/tidwall/gjson v1.17.1\n\tgithub.com/vishvananda/netlink v1.3.0\n\tgithub.com/xtaci/kcp-go/v5 v5.6.13\n\tgolang.org/x/crypto v0.41.0\n\tgolang.org/x/net v0.43.0\n\tgolang.org/x/oauth2 v0.28.0\n\tgolang.org/x/sync v0.16.0\n\tgolang.org/x/time v0.5.0\n\tgolang.zx2c4.com/wireguard v0.0.0-20231211153847-12269c276173\n\tgopkg.in/ini.v1 v1.67.0\n\tk8s.io/apimachinery v0.28.8\n\tk8s.io/client-go v0.28.8\n)\n\nrequire (\n\tgithub.com/Azure/go-ntlmssp v0.0.0-20221128193559-754e69321358 // indirect\n\tgithub.com/beorn7/perks v1.0.1 // indirect\n\tgithub.com/cespare/xxhash/v2 v2.2.0 // indirect\n\tgithub.com/davecgh/go-spew v1.1.1 // indirect\n\tgithub.com/go-jose/go-jose/v4 v4.0.5 // indirect\n\tgithub.com/go-logr/logr v1.4.2 // indirect\n\tgithub.com/go-task/slim-sprig/v3 v3.0.0 // indirect\n\tgithub.com/golang/snappy v0.0.4 // indirect\n\tgithub.com/google/go-cmp v0.7.0 // indirect\n\tgithub.com/google/pprof v0.0.0-20250403155104-27863c87afa6 // indirect\n\tgithub.com/inconshreveable/mousetrap v1.1.0 // indirect\n\tgithub.com/klauspost/cpuid/v2 v2.2.6 // indirect\n\tgithub.com/klauspost/reedsolomon v1.12.0 // indirect\n\tgithub.com/pion/dtls/v2 v2.2.7 // indirect\n\tgithub.com/pion/logging v0.2.2 // indirect\n\tgithub.com/pion/transport/v2 v2.2.1 // indirect\n\tgithub.com/pion/transport/v3 v3.0.1 // indirect\n\tgithub.com/pkg/errors v0.9.1 // indirect\n\tgithub.com/pmezard/go-difflib v1.0.0 // indirect\n\tgithub.com/prometheus/client_model v0.5.0 // indirect\n\tgithub.com/prometheus/common v0.48.0 // indirect\n\tgithub.com/prometheus/procfs v0.12.0 // indirect\n\tgithub.com/templexxx/cpu v0.1.1 // indirect\n\tgithub.com/templexxx/xorsimd v0.4.3 // indirect\n\tgithub.com/tidwall/match v1.1.1 // indirect\n\tgithub.com/tidwall/pretty v1.2.0 // indirect\n\tgithub.com/tjfoc/gmsm v1.4.1 // indirect\n\tgithub.com/vishvananda/netns v0.0.4 // indirect\n\tgo.uber.org/automaxprocs v1.6.0 // indirect\n\tgolang.org/x/mod v0.27.0 // indirect\n\tgolang.org/x/sys v0.35.0 // indirect\n\tgolang.org/x/text v0.28.0 // indirect\n\tgolang.org/x/tools v0.36.0 // indirect\n\tgolang.zx2c4.com/wintun v0.0.0-20230126152724-0fa3db229ce2 // indirect\n\tgoogle.golang.org/protobuf v1.36.5 // indirect\n\tgopkg.in/yaml.v2 v2.4.0 // indirect\n\tgopkg.in/yaml.v3 v3.0.1 // indirect\n\tk8s.io/utils v0.0.0-20230406110748-d93618cff8a2 // indirect\n\tsigs.k8s.io/json v0.0.0-20221116044647-bc3834ca7abd // indirect\n\tsigs.k8s.io/yaml v1.3.0 // indirect\n)\n\n// TODO(fatedier): Temporary use the modified version, update to the official version after merging into the official repository.\nreplace github.com/hashicorp/yamux => github.com/fatedier/yamux v0.0.0-20250825093530-d0154be01cd6\n"
  },
  {
    "path": "go.sum",
    "content": "cloud.google.com/go v0.26.0/go.mod h1:aQUYkXzVsufM+DwF1aE+0xfcU+56JwCaLick0ClmMTw=\ngithub.com/Azure/go-ntlmssp v0.0.0-20221128193559-754e69321358 h1:mFRzDkZVAjdal+s7s0MwaRv9igoPqLRdzOLzw/8Xvq8=\ngithub.com/Azure/go-ntlmssp v0.0.0-20221128193559-754e69321358/go.mod h1:chxPXzSsl7ZWRAuOIE23GDNzjWuZquvFlgA8xmpunjU=\ngithub.com/BurntSushi/toml v0.3.1/go.mod h1:xHWCNGjB5oqiDr8zfno3MHue2Ht5sIBksp03qcyfWMU=\ngithub.com/armon/go-socks5 v0.0.0-20160902184237-e75332964ef5 h1:0CwZNZbxp69SHPdPJAN/hZIm0C4OItdklCFmMRWYpio=\ngithub.com/armon/go-socks5 v0.0.0-20160902184237-e75332964ef5/go.mod h1:wHh0iHkYZB8zMSxRWpUBQtwG5a7fFgvEO+odwuTv2gs=\ngithub.com/beorn7/perks v1.0.1 h1:VlbKKnNfV8bJzeqoa4cOKqO6bYr3WgKZxO8Z16+hsOM=\ngithub.com/beorn7/perks v1.0.1/go.mod h1:G2ZrVWU2WbWT9wwq4/hrbKbnv/1ERSJQ0ibhJ6rlkpw=\ngithub.com/census-instrumentation/opencensus-proto v0.2.1/go.mod h1:f6KPmirojxKA12rnyqOA5BBL4O983OfeGPqjHWSTneU=\ngithub.com/cespare/xxhash/v2 v2.2.0 h1:DC2CZ1Ep5Y4k3ZQ899DldepgrayRUGE6BBZ/cd9Cj44=\ngithub.com/cespare/xxhash/v2 v2.2.0/go.mod h1:VGX0DQ3Q6kWi7AoAeZDth3/j3BFtOZR5XLFGgcrjCOs=\ngithub.com/client9/misspell v0.3.4/go.mod h1:qj6jICC3Q7zFZvVWo7KLAzC3yx5G7kyvSDkc90ppPyw=\ngithub.com/cncf/udpa/go v0.0.0-20191209042840-269d4d468f6f/go.mod h1:M8M6+tZqaGXZJjfX53e64911xZQV5JYwmTeXPW+k8Sc=\ngithub.com/coreos/go-oidc/v3 v3.14.1 h1:9ePWwfdwC4QKRlCXsJGou56adA/owXczOzwKdOumLqk=\ngithub.com/coreos/go-oidc/v3 v3.14.1/go.mod h1:HaZ3szPaZ0e4r6ebqvsLWlk2Tn+aejfmrfah6hnSYEU=\ngithub.com/cpuguy83/go-md2man/v2 v2.0.3/go.mod h1:tgQtvFlXSQOSOSIRvRPT7W67SCa46tRHOmNcaadrF8o=\ngithub.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=\ngithub.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c=\ngithub.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=\ngithub.com/envoyproxy/go-control-plane v0.9.0/go.mod h1:YTl/9mNaCwkRvm6d1a2C3ymFceY/DCBVvsKhRF0iEA4=\ngithub.com/envoyproxy/go-control-plane v0.9.4/go.mod h1:6rpuAdCZL397s3pYoYcLgu1mIlRU8Am5FuJP05cCM98=\ngithub.com/envoyproxy/protoc-gen-validate v0.1.0/go.mod h1:iSmxcyjqTsJpI2R4NaDN7+kN2VEUnK/pcBlmesArF7c=\ngithub.com/fatedier/golib v0.5.1 h1:hcKAnaw5mdI/1KWRGejxR+i1Hn/NvbY5UsMKDr7o13M=\ngithub.com/fatedier/golib v0.5.1/go.mod h1:W6kIYkIFxHsTzbgqg5piCxIiDo4LzwgTY6R5W8l9NFQ=\ngithub.com/fatedier/yamux v0.0.0-20250825093530-d0154be01cd6 h1:u92UUy6FURPmNsMBUuongRWC0rBqN6gd01Dzu+D21NE=\ngithub.com/fatedier/yamux v0.0.0-20250825093530-d0154be01cd6/go.mod h1:c5/tk6G0dSpXGzJN7Wk1OEie8grdSJAmeawId9Zvd34=\ngithub.com/go-jose/go-jose/v4 v4.0.5 h1:M6T8+mKZl/+fNNuFHvGIzDz7BTLQPIounk/b9dw3AaE=\ngithub.com/go-jose/go-jose/v4 v4.0.5/go.mod h1:s3P1lRrkT8igV8D9OjyL4WRyHvjB6a4JSllnOrmmBOA=\ngithub.com/go-logr/logr v1.4.2 h1:6pFjapn8bFcIbiKo3XT4j/BhANplGihG6tvd+8rYgrY=\ngithub.com/go-logr/logr v1.4.2/go.mod h1:9T104GzyrTigFIr8wt5mBrctHMim0Nb2HLGrmQ40KvY=\ngithub.com/go-task/slim-sprig/v3 v3.0.0 h1:sUs3vkvUymDpBKi3qH1YSqBQk9+9D/8M2mN1vB6EwHI=\ngithub.com/go-task/slim-sprig/v3 v3.0.0/go.mod h1:W848ghGpv3Qj3dhTPRyJypKRiqCdHZiAzKg9hl15HA8=\ngithub.com/golang/glog v0.0.0-20160126235308-23def4e6c14b/go.mod h1:SBH7ygxi8pfUlaOkMMuAQtPIUF8ecWP5IEl/CR7VP2Q=\ngithub.com/golang/mock v1.1.1/go.mod h1:oTYuIxOrZwtPieC+H1uAHpcLFnEyAGVDL/k47Jfbm0A=\ngithub.com/golang/protobuf v1.2.0/go.mod h1:6lQm79b+lXiMfvg/cZm0SGofjICqVBUtrP5yJMmIC1U=\ngithub.com/golang/protobuf v1.3.2/go.mod h1:6lQm79b+lXiMfvg/cZm0SGofjICqVBUtrP5yJMmIC1U=\ngithub.com/golang/protobuf v1.3.3/go.mod h1:vzj43D7+SQXF/4pzW/hwtAqwc6iTitCiVSaWz5lYuqw=\ngithub.com/golang/protobuf v1.4.0-rc.1/go.mod h1:ceaxUfeHdC40wWswd/P6IGgMaK3YpKi5j83Wpe3EHw8=\ngithub.com/golang/protobuf v1.4.0-rc.1.0.20200221234624-67d41d38c208/go.mod h1:xKAWHe0F5eneWXFV3EuXVDTCmh+JuBKY0li0aMyXATA=\ngithub.com/golang/protobuf v1.4.0-rc.2/go.mod h1:LlEzMj4AhA7rCAGe4KMBDvJI+AwstrUpVNzEA03Pprs=\ngithub.com/golang/protobuf v1.4.0-rc.4.0.20200313231945-b860323f09d0/go.mod h1:WU3c8KckQ9AFe+yFwt9sWVRKCVIyN9cPHBJSNnbL67w=\ngithub.com/golang/protobuf v1.4.0/go.mod h1:jodUvKwWbYaEsadDk5Fwe5c77LiNKVO9IDvqG2KuDX0=\ngithub.com/golang/protobuf v1.4.2/go.mod h1:oDoupMAO8OvCJWAcko0GGGIgR6R6ocIYbsSw735rRwI=\ngithub.com/golang/snappy v0.0.4 h1:yAGX7huGHXlcLOEtBnF4w7FQwA26wojNCwOYAEhLjQM=\ngithub.com/golang/snappy v0.0.4/go.mod h1:/XxbfmMg8lxefKM7IXC3fBNl/7bRcc72aCRzEWrmP2Q=\ngithub.com/google/btree v1.0.1 h1:gK4Kx5IaGY9CD5sPJ36FHiBJ6ZXl0kilRiiCj+jdYp4=\ngithub.com/google/btree v1.0.1/go.mod h1:xXMiIv4Fb/0kKde4SpL7qlzvu5cMJDRkFDxJfI9uaxA=\ngithub.com/google/go-cmp v0.2.0/go.mod h1:oXzfMopK8JAjlY9xF4vHSVASa0yLyX7SntLO5aqRK0M=\ngithub.com/google/go-cmp v0.3.0/go.mod h1:8QqcDgzrUqlUb/G2PQTWiueGozuR1884gddMywk6iLU=\ngithub.com/google/go-cmp v0.3.1/go.mod h1:8QqcDgzrUqlUb/G2PQTWiueGozuR1884gddMywk6iLU=\ngithub.com/google/go-cmp v0.4.0/go.mod h1:v8dTdLbMG2kIc/vJvl+f65V22dbkXbowE6jgT/gNBxE=\ngithub.com/google/go-cmp v0.6.0/go.mod h1:17dUlkBOakJ0+DkrSSNjCkIjxS6bF9zb3elmeNGIjoY=\ngithub.com/google/go-cmp v0.7.0 h1:wk8382ETsv4JYUZwIsn6YpYiWiBsYLSJiTsyBybVuN8=\ngithub.com/google/go-cmp v0.7.0/go.mod h1:pXiqmnSA92OHEEa9HXL2W4E7lf9JzCmGVUdgjX3N/iU=\ngithub.com/google/pprof v0.0.0-20250403155104-27863c87afa6 h1:BHT72Gu3keYf3ZEu2J0b1vyeLSOYI8bm5wbJM/8yDe8=\ngithub.com/google/pprof v0.0.0-20250403155104-27863c87afa6/go.mod h1:boTsfXsheKC2y+lKOCMpSfarhxDeIzfZG1jqGcPl3cA=\ngithub.com/google/uuid v1.6.0 h1:NIvaJDMOsjHA8n1jAhLSgzrAzy1Hgr+hNrb57e+94F0=\ngithub.com/google/uuid v1.6.0/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo=\ngithub.com/gorilla/mux v1.8.1 h1:TuBL49tXwgrFYWhqrNgrUNEY92u81SPhu7sTdzQEiWY=\ngithub.com/gorilla/mux v1.8.1/go.mod h1:AKf9I4AEqPTmMytcMc0KkNouC66V3BtZ4qD5fmWSiMQ=\ngithub.com/gorilla/websocket v1.5.0 h1:PPwGk2jz7EePpoHN/+ClbZu8SPxiqlu12wZP/3sWmnc=\ngithub.com/gorilla/websocket v1.5.0/go.mod h1:YR8l580nyteQvAITg2hZ9XVh4b55+EU/adAjf1fMHhE=\ngithub.com/inconshreveable/mousetrap v1.1.0 h1:wN+x4NVGpMsO7ErUn/mUI3vEoE6Jt13X2s0bqwp9tc8=\ngithub.com/inconshreveable/mousetrap v1.1.0/go.mod h1:vpF70FUmC8bwa3OWnCshd2FqLfsEA9PFc4w1p2J65bw=\ngithub.com/klauspost/cpuid/v2 v2.2.6 h1:ndNyv040zDGIDh8thGkXYjnFtiN02M1PVVF+JE/48xc=\ngithub.com/klauspost/cpuid/v2 v2.2.6/go.mod h1:Lcz8mBdAVJIBVzewtcLocK12l3Y+JytZYpaMropDUws=\ngithub.com/klauspost/reedsolomon v1.12.0 h1:I5FEp3xSwVCcEh3F5A7dofEfhXdF/bWhQWPH+XwBFno=\ngithub.com/klauspost/reedsolomon v1.12.0/go.mod h1:EPLZJeh4l27pUGC3aXOjheaoh1I9yut7xTURiW3LQ9Y=\ngithub.com/kr/pretty v0.3.1 h1:flRD4NNwYAUpkphVc1HcthR4KEIFJ65n8Mw5qdRn3LE=\ngithub.com/kr/pretty v0.3.1/go.mod h1:hoEshYVHaxMs3cyo3Yncou5ZscifuDolrwPKZanG3xk=\ngithub.com/kr/text v0.2.0 h1:5Nx0Ya0ZqY2ygV366QzturHI13Jq95ApcVaJBhpS+AY=\ngithub.com/kr/text v0.2.0/go.mod h1:eLer722TekiGuMkidMxC/pM04lWEeraHUUmBw8l2grE=\ngithub.com/mattn/go-runewidth v0.0.15 h1:UNAjwbU9l54TA3KzvqLGxwWjHmMgBUVhBiTjelZgg3U=\ngithub.com/mattn/go-runewidth v0.0.15/go.mod h1:Jdepj2loyihRzMpdS35Xk/zdY8IAYHsh153qUoGf23w=\ngithub.com/onsi/ginkgo/v2 v2.23.4 h1:ktYTpKJAVZnDT4VjxSbiBenUjmlL/5QkBEocaWXiQus=\ngithub.com/onsi/ginkgo/v2 v2.23.4/go.mod h1:Bt66ApGPBFzHyR+JO10Zbt0Gsp4uWxu5mIOTusL46e8=\ngithub.com/onsi/gomega v1.36.3 h1:hID7cr8t3Wp26+cYnfcjR6HpJ00fdogN6dqZ1t6IylU=\ngithub.com/onsi/gomega v1.36.3/go.mod h1:8D9+Txp43QWKhM24yyOBEdpkzN8FvJyAwecBgsU4KU0=\ngithub.com/pelletier/go-toml/v2 v2.2.0 h1:QLgLl2yMN7N+ruc31VynXs1vhMZa7CeHHejIeBAsoHo=\ngithub.com/pelletier/go-toml/v2 v2.2.0/go.mod h1:1t835xjRzz80PqgE6HHgN2JOsmgYu/h4qDAS4n929Rs=\ngithub.com/pion/dtls/v2 v2.2.7 h1:cSUBsETxepsCSFSxC3mc/aDo14qQLMSL+O6IjG28yV8=\ngithub.com/pion/dtls/v2 v2.2.7/go.mod h1:8WiMkebSHFD0T+dIU+UeBaoV7kDhOW5oDCzZ7WZ/F9s=\ngithub.com/pion/logging v0.2.2 h1:M9+AIj/+pxNsDfAT64+MAVgJO0rsyLnoJKCqf//DoeY=\ngithub.com/pion/logging v0.2.2/go.mod h1:k0/tDVsRCX2Mb2ZEmTqNa7CWsQPc+YYCB7Q+5pahoms=\ngithub.com/pion/stun/v2 v2.0.0 h1:A5+wXKLAypxQri59+tmQKVs7+l6mMM+3d+eER9ifRU0=\ngithub.com/pion/stun/v2 v2.0.0/go.mod h1:22qRSh08fSEttYUmJZGlriq9+03jtVmXNODgLccj8GQ=\ngithub.com/pion/transport/v2 v2.2.1 h1:7qYnCBlpgSJNYMbLCKuSY9KbQdBFoETvPNETv0y4N7c=\ngithub.com/pion/transport/v2 v2.2.1/go.mod h1:cXXWavvCnFF6McHTft3DWS9iic2Mftcz1Aq29pGcU5g=\ngithub.com/pion/transport/v3 v3.0.1 h1:gDTlPJwROfSfz6QfSi0ZmeCSkFcnWWiiR9ES0ouANiM=\ngithub.com/pion/transport/v3 v3.0.1/go.mod h1:UY7kiITrlMv7/IKgd5eTUcaahZx5oUN3l9SzK5f5xE0=\ngithub.com/pires/go-proxyproto v0.7.0 h1:IukmRewDQFWC7kfnb66CSomk2q/seBuilHBYFwyq0Hs=\ngithub.com/pires/go-proxyproto v0.7.0/go.mod h1:Vz/1JPY/OACxWGQNIRY2BeyDmpoaWmEP40O9LbuiFR4=\ngithub.com/pkg/errors v0.9.1 h1:FEBLx1zS214owpjy7qsBeixbURkuhQAwrK5UwLGTwt4=\ngithub.com/pkg/errors v0.9.1/go.mod h1:bwawxfHBFNV+L2hUp1rHADufV3IMtnDRdf1r5NINEl0=\ngithub.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM=\ngithub.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4=\ngithub.com/prashantv/gostub v1.1.0 h1:BTyx3RfQjRHnUWaGF9oQos79AlQ5k8WNktv7VGvVH4g=\ngithub.com/prashantv/gostub v1.1.0/go.mod h1:A5zLQHz7ieHGG7is6LLXLz7I8+3LZzsrV0P1IAHhP5U=\ngithub.com/prometheus/client_golang v1.19.1 h1:wZWJDwK+NameRJuPGDhlnFgx8e8HN3XHQeLaYJFJBOE=\ngithub.com/prometheus/client_golang v1.19.1/go.mod h1:mP78NwGzrVks5S2H6ab8+ZZGJLZUq1hoULYBAYBw1Ho=\ngithub.com/prometheus/client_model v0.0.0-20190812154241-14fe0d1b01d4/go.mod h1:xMI15A0UPsDsEKsMN9yxemIoYk6Tm2C1GtYGdfGttqA=\ngithub.com/prometheus/client_model v0.5.0 h1:VQw1hfvPvk3Uv6Qf29VrPF32JB6rtbgI6cYPYQjL0Qw=\ngithub.com/prometheus/client_model v0.5.0/go.mod h1:dTiFglRmd66nLR9Pv9f0mZi7B7fk5Pm3gvsjB5tr+kI=\ngithub.com/prometheus/common v0.48.0 h1:QO8U2CdOzSn1BBsmXJXduaaW+dY/5QLjfB8svtSzKKE=\ngithub.com/prometheus/common v0.48.0/go.mod h1:0/KsvlIEfPQCQ5I2iNSAWKPZziNCvRs5EC6ILDTlAPc=\ngithub.com/prometheus/procfs v0.12.0 h1:jluTpSng7V9hY0O2R9DzzJHYb2xULk9VTR1V1R/k6Bo=\ngithub.com/prometheus/procfs v0.12.0/go.mod h1:pcuDEFsWDnvcgNzo4EEweacyhjeA9Zk3cnaOZAZEfOo=\ngithub.com/quic-go/quic-go v0.55.0 h1:zccPQIqYCXDt5NmcEabyYvOnomjs8Tlwl7tISjJh9Mk=\ngithub.com/quic-go/quic-go v0.55.0/go.mod h1:DR51ilwU1uE164KuWXhinFcKWGlEjzys2l8zUl5Ss1U=\ngithub.com/rivo/uniseg v0.2.0 h1:S1pD9weZBuJdFmowNwbpi7BJ8TNftyUImj/0WQi72jY=\ngithub.com/rivo/uniseg v0.2.0/go.mod h1:J6wj4VEh+S6ZtnVlnTBMWIodfgj8LQOQFoIToxlJtxc=\ngithub.com/rodaine/table v1.2.0 h1:38HEnwK4mKSHQJIkavVj+bst1TEY7j9zhLMWu4QJrMA=\ngithub.com/rodaine/table v1.2.0/go.mod h1:wejb/q/Yd4T/SVmBSRMr7GCq3KlcZp3gyNYdLSBhkaE=\ngithub.com/rogpeppe/go-internal v1.10.0 h1:TMyTOH3F/DB16zRVcYyreMH6GnZZrwQVAoYjRBZyWFQ=\ngithub.com/rogpeppe/go-internal v1.10.0/go.mod h1:UQnix2H7Ngw/k4C5ijL5+65zddjncjaFoBhdsK/akog=\ngithub.com/russross/blackfriday/v2 v2.1.0/go.mod h1:+Rmxgy9KzJVeS9/2gXHxylqXiyQDYRxCVz55jmeOWTM=\ngithub.com/samber/lo v1.47.0 h1:z7RynLwP5nbyRscyvcD043DWYoOcYRv3mV8lBeqOCLc=\ngithub.com/samber/lo v1.47.0/go.mod h1:RmDH9Ct32Qy3gduHQuKJ3gW1fMHAnE/fAzQuf6He5cU=\ngithub.com/songgao/water v0.0.0-20200317203138-2b4b6d7c09d8 h1:TG/diQgUe0pntT/2D9tmUCz4VNwm9MfrtPr0SU2qSX8=\ngithub.com/songgao/water v0.0.0-20200317203138-2b4b6d7c09d8/go.mod h1:P5HUIBuIWKbyjl083/loAegFkfbFNx5i2qEP4CNbm7E=\ngithub.com/spf13/cobra v1.8.0 h1:7aJaZx1B85qltLMc546zn58BxxfZdR/W22ej9CFoEf0=\ngithub.com/spf13/cobra v1.8.0/go.mod h1:WXLWApfZ71AjXPya3WOlMsY9yMs7YeiHhFVlvLyhcho=\ngithub.com/spf13/pflag v1.0.5 h1:iy+VFUOCP1a+8yFto/drg2CJ5u0yRoB7fZw3DKv/JXA=\ngithub.com/spf13/pflag v1.0.5/go.mod h1:McXfInJRrz4CZXVZOBLb0bTZqETkiAhM9Iw0y3An2Bg=\ngithub.com/stretchr/objx v0.1.0/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME=\ngithub.com/stretchr/objx v0.4.0/go.mod h1:YvHI0jy2hoMjB+UWwv71VJQ9isScKT/TqJzVSSt89Yw=\ngithub.com/stretchr/objx v0.5.0/go.mod h1:Yh+to48EsGEfYuaHDzXPcE3xhTkx73EhmCGUpEOglKo=\ngithub.com/stretchr/objx v0.5.2/go.mod h1:FRsXN1f5AsAjCGJKqEizvkpNtU+EGNCLh3NxZ/8L+MA=\ngithub.com/stretchr/testify v1.7.1/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg=\ngithub.com/stretchr/testify v1.8.0/go.mod h1:yNjHg4UonilssWZ8iaSj1OCr/vHnekPRkoO+kdMU+MU=\ngithub.com/stretchr/testify v1.8.3/go.mod h1:sz/lmYIOXD/1dqDmKjjqLyZ2RngseejIcXlSw2iwfAo=\ngithub.com/stretchr/testify v1.8.4/go.mod h1:sz/lmYIOXD/1dqDmKjjqLyZ2RngseejIcXlSw2iwfAo=\ngithub.com/stretchr/testify v1.9.0/go.mod h1:r2ic/lqez/lEtzL7wO/rwa5dbSLXVDPFyf8C91i36aY=\ngithub.com/stretchr/testify v1.10.0 h1:Xv5erBjTwe/5IxqUQTdXv5kgmIvbHo3QQyRwhJsOfJA=\ngithub.com/stretchr/testify v1.10.0/go.mod h1:r2ic/lqez/lEtzL7wO/rwa5dbSLXVDPFyf8C91i36aY=\ngithub.com/templexxx/cpu v0.1.1 h1:isxHaxBXpYFWnk2DReuKkigaZyrjs2+9ypIdGP4h+HI=\ngithub.com/templexxx/cpu v0.1.1/go.mod h1:w7Tb+7qgcAlIyX4NhLuDKt78AHA5SzPmq0Wj6HiEnnk=\ngithub.com/templexxx/xorsimd v0.4.3 h1:9AQTFHd7Bhk3dIT7Al2XeBX5DWOvsUPZCuhyAtNbHjU=\ngithub.com/templexxx/xorsimd v0.4.3/go.mod h1:oZQcD6RFDisW2Am58dSAGwwL6rHjbzrlu25VDqfWkQg=\ngithub.com/tidwall/gjson v1.17.1 h1:wlYEnwqAHgzmhNUFfw7Xalt2JzQvsMx2Se4PcoFCT/U=\ngithub.com/tidwall/gjson v1.17.1/go.mod h1:/wbyibRr2FHMks5tjHJ5F8dMZh3AcwJEMf5vlfC0lxk=\ngithub.com/tidwall/match v1.1.1 h1:+Ho715JplO36QYgwN9PGYNhgZvoUSc9X2c80KVTi+GA=\ngithub.com/tidwall/match v1.1.1/go.mod h1:eRSPERbgtNPcGhD8UCthc6PmLEQXEWd3PRB5JTxsfmM=\ngithub.com/tidwall/pretty v1.2.0 h1:RWIZEg2iJ8/g6fDDYzMpobmaoGh5OLl4AXtGUGPcqCs=\ngithub.com/tidwall/pretty v1.2.0/go.mod h1:ITEVvHYasfjBbM0u2Pg8T2nJnzm8xPwvNhhsoaGGjNU=\ngithub.com/tjfoc/gmsm v1.4.1 h1:aMe1GlZb+0bLjn+cKTPEvvn9oUEBlJitaZiiBwsbgho=\ngithub.com/tjfoc/gmsm v1.4.1/go.mod h1:j4INPkHWMrhJb38G+J6W4Tw0AbuN8Thu3PbdVYhVcTE=\ngithub.com/vishvananda/netlink v1.3.0 h1:X7l42GfcV4S6E4vHTsw48qbrV+9PVojNfIhZcwQdrZk=\ngithub.com/vishvananda/netlink v1.3.0/go.mod h1:i6NetklAujEcC6fK0JPjT8qSwWyO0HLn4UKG+hGqeJs=\ngithub.com/vishvananda/netns v0.0.4 h1:Oeaw1EM2JMxD51g9uhtC0D7erkIjgmj8+JZc26m1YX8=\ngithub.com/vishvananda/netns v0.0.4/go.mod h1:SpkAiCQRtJ6TvvxPnOSyH3BMl6unz3xZlaprSwhNNJM=\ngithub.com/xtaci/kcp-go/v5 v5.6.13 h1:FEjtz9+D4p8t2x4WjciGt/jsIuhlWjjgPCCWjrVR4Hk=\ngithub.com/xtaci/kcp-go/v5 v5.6.13/go.mod h1:75S1AKYYzNUSXIv30h+jPKJYZUwqpfvLshu63nCNSOM=\ngithub.com/xtaci/lossyconn v0.0.0-20200209145036-adba10fffc37 h1:EWU6Pktpas0n8lLQwDsRyZfmkPeRbdgPtW609es+/9E=\ngithub.com/xtaci/lossyconn v0.0.0-20200209145036-adba10fffc37/go.mod h1:HpMP7DB2CyokmAh4lp0EQnnWhmycP/TvwBGzvuie+H0=\ngithub.com/yuin/goldmark v1.4.13/go.mod h1:6yULJ656Px+3vBD8DxQVa3kxgyrAnzto9xy5taEt/CY=\ngo.uber.org/automaxprocs v1.6.0 h1:O3y2/QNTOdbF+e/dpXNNW7Rx2hZ4sTIPyybbxyNqTUs=\ngo.uber.org/automaxprocs v1.6.0/go.mod h1:ifeIMSnPZuznNm6jmdzmU3/bfk01Fe2fotchwEFJ8r8=\ngo.uber.org/mock v0.5.2 h1:LbtPTcP8A5k9WPXj54PPPbjcI4Y6lhyOZXn+VS7wNko=\ngo.uber.org/mock v0.5.2/go.mod h1:wLlUxC2vVTPTaE3UD51E0BGOAElKrILxhVSDYQLld5o=\ngolang.org/x/crypto v0.0.0-20190308221718-c2843e01d9a2/go.mod h1:djNgcEr1/C05ACkg1iLfiJU5Ep61QUkGW8qpdssI0+w=\ngolang.org/x/crypto v0.0.0-20200622213623-75b288015ac9/go.mod h1:LzIPMQfyMNhhGPhUkYOs5KpL4U8rLKemX1yGLhDgUto=\ngolang.org/x/crypto v0.0.0-20201012173705-84dcc777aaee/go.mod h1:LzIPMQfyMNhhGPhUkYOs5KpL4U8rLKemX1yGLhDgUto=\ngolang.org/x/crypto v0.0.0-20210921155107-089bfa567519/go.mod h1:GvvjBRRGRdwPK5ydBHafDWAxML/pGHZbMvKqRZ5+Abc=\ngolang.org/x/crypto v0.8.0/go.mod h1:mRqEX+O9/h5TFCrQhkgjo2yKi0yYA+9ecGkdQoHrywE=\ngolang.org/x/crypto v0.12.0/go.mod h1:NF0Gs7EO5K4qLn+Ylc+fih8BSTeIjAP05siRnAh98yw=\ngolang.org/x/crypto v0.41.0 h1:WKYxWedPGCTVVl5+WHSSrOBT0O8lx32+zxmHxijgXp4=\ngolang.org/x/crypto v0.41.0/go.mod h1:pO5AFd7FA68rFak7rOAGVuygIISepHftHnr8dr6+sUc=\ngolang.org/x/exp v0.0.0-20190121172915-509febef88a4/go.mod h1:CJ0aWSM057203Lf6IL+f9T1iT9GByDxfZKAQTCR3kQA=\ngolang.org/x/lint v0.0.0-20181026193005-c67002cb31c3/go.mod h1:UVdnD1Gm6xHRNCYTkRU2/jEulfH38KcIWyp/GAMgvoE=\ngolang.org/x/lint v0.0.0-20190227174305-5b3e6a55c961/go.mod h1:wehouNa3lNwaWXcvxsM5YxQ5yQlVC4a0KAMCusXpPoU=\ngolang.org/x/lint v0.0.0-20190313153728-d0100b6bd8b3/go.mod h1:6SW0HCj/g11FgYtHlgUYUwCkIfeOF89ocIRzGO/8vkc=\ngolang.org/x/mod v0.6.0-dev.0.20220419223038-86c51ed26bb4/go.mod h1:jJ57K6gSWd91VN4djpZkiMVwK6gcyfeH4XE8wZrZaV4=\ngolang.org/x/mod v0.8.0/go.mod h1:iBbtSCu2XBx23ZKBPSOrRkjjQPZFPuis4dIYUhu/chs=\ngolang.org/x/mod v0.27.0 h1:kb+q2PyFnEADO2IEF935ehFUXlWiNjJWtRNgBLSfbxQ=\ngolang.org/x/mod v0.27.0/go.mod h1:rWI627Fq0DEoudcK+MBkNkCe0EetEaDSwJJkCcjpazc=\ngolang.org/x/net v0.0.0-20180724234803-3673e40ba225/go.mod h1:mL1N/T3taQHkDXs73rZJwtUhF3w3ftmwwsq0BUmARs4=\ngolang.org/x/net v0.0.0-20180826012351-8a410e7b638d/go.mod h1:mL1N/T3taQHkDXs73rZJwtUhF3w3ftmwwsq0BUmARs4=\ngolang.org/x/net v0.0.0-20190213061140-3a22650c66bd/go.mod h1:mL1N/T3taQHkDXs73rZJwtUhF3w3ftmwwsq0BUmARs4=\ngolang.org/x/net v0.0.0-20190311183353-d8887717615a/go.mod h1:t9HGtf8HONx5eT2rtn7q6eTqICYqUVnKs3thJo3Qplg=\ngolang.org/x/net v0.0.0-20190404232315-eb5bcb51f2a3/go.mod h1:t9HGtf8HONx5eT2rtn7q6eTqICYqUVnKs3thJo3Qplg=\ngolang.org/x/net v0.0.0-20190620200207-3b0461eec859/go.mod h1:z5CRVTTTmAJ677TzLLGU+0bjPO0LkuOLi4/5GtJWs/s=\ngolang.org/x/net v0.0.0-20201010224723-4f7140c49acb/go.mod h1:sp8m0HH+o8qH0wwXwYZr8TS3Oi6o0r6Gce1SSxlDquU=\ngolang.org/x/net v0.0.0-20210226172049-e18ecbb05110/go.mod h1:m0MpNAwzfU5UDzcl9v0D8zg8gWTRqZa9RBIspLL5mdg=\ngolang.org/x/net v0.0.0-20220722155237-a158d28d115b/go.mod h1:XRhObCWvk6IyKnWLug+ECip1KBveYUHfp+8e9klMJ9c=\ngolang.org/x/net v0.6.0/go.mod h1:2Tu9+aMcznHK/AK1HMvgo6xiTLG5rD5rZLDS+rp2Bjs=\ngolang.org/x/net v0.9.0/go.mod h1:d48xBJpPfHeWQsugry2m+kC02ZBRGRgulfHnEXEuWns=\ngolang.org/x/net v0.10.0/go.mod h1:0qNGK6F8kojg2nk9dLZ2mShWaEBan6FAoqfSigmmuDg=\ngolang.org/x/net v0.14.0/go.mod h1:PpSgVXXLK0OxS0F31C1/tv6XNguvCrnXIDrFMspZIUI=\ngolang.org/x/net v0.43.0 h1:lat02VYK2j4aLzMzecihNvTlJNQUq316m2Mr9rnM6YE=\ngolang.org/x/net v0.43.0/go.mod h1:vhO1fvI4dGsIjh73sWfUVjj3N7CA9WkKJNQm2svM6Jg=\ngolang.org/x/oauth2 v0.0.0-20180821212333-d2e6202438be/go.mod h1:N/0e6XlmueqKjAGxoOufVs8QHGRruUQn6yWY3a++T0U=\ngolang.org/x/oauth2 v0.28.0 h1:CrgCKl8PPAVtLnU3c+EDw6x11699EWlsDeWNWKdIOkc=\ngolang.org/x/oauth2 v0.28.0/go.mod h1:onh5ek6nERTohokkhCD/y2cV4Do3fxFHFuAejCkRWT8=\ngolang.org/x/sync v0.0.0-20180314180146-1d60e4601c6f/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=\ngolang.org/x/sync v0.0.0-20181108010431-42b317875d0f/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=\ngolang.org/x/sync v0.0.0-20190423024810-112230192c58/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=\ngolang.org/x/sync v0.0.0-20220722155255-886fb9371eb4/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=\ngolang.org/x/sync v0.1.0/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=\ngolang.org/x/sync v0.16.0 h1:ycBJEhp9p4vXvUZNszeOq0kGTPghopOL8q0fq3vstxw=\ngolang.org/x/sync v0.16.0/go.mod h1:1dzgHSNfp02xaA81J2MS99Qcpr2w7fw1gpm99rleRqA=\ngolang.org/x/sys v0.0.0-20180830151530-49385e6e1522/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY=\ngolang.org/x/sys v0.0.0-20190215142949-d0b11bdaac8a/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY=\ngolang.org/x/sys v0.0.0-20190412213103-97732733099d/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=\ngolang.org/x/sys v0.0.0-20200930185726-fdedc70b468f/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=\ngolang.org/x/sys v0.0.0-20201119102817-f84b799fce68/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=\ngolang.org/x/sys v0.0.0-20210615035016-665e8c7367d1/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=\ngolang.org/x/sys v0.0.0-20220520151302-bc2c85ada10a/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=\ngolang.org/x/sys v0.0.0-20220722155257-8c9f86f7a55f/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=\ngolang.org/x/sys v0.2.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=\ngolang.org/x/sys v0.5.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=\ngolang.org/x/sys v0.7.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=\ngolang.org/x/sys v0.8.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=\ngolang.org/x/sys v0.10.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=\ngolang.org/x/sys v0.11.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=\ngolang.org/x/sys v0.35.0 h1:vz1N37gP5bs89s7He8XuIYXpyY0+QlsKmzipCbUtyxI=\ngolang.org/x/sys v0.35.0/go.mod h1:BJP2sWEmIv4KK5OTEluFJCKSidICx8ciO85XgH3Ak8k=\ngolang.org/x/term v0.0.0-20201126162022-7de9c90e9dd1/go.mod h1:bj7SfCRtBDWHUb9snDiAeCFNEtKQo2Wmx5Cou7ajbmo=\ngolang.org/x/term v0.0.0-20210927222741-03fcf44c2211/go.mod h1:jbD1KX2456YbFQfuXm/mYQcufACuNUgVhRMnK/tPxf8=\ngolang.org/x/term v0.5.0/go.mod h1:jMB1sMXY+tzblOD4FWmEbocvup2/aLOaQEp7JmGp78k=\ngolang.org/x/term v0.7.0/go.mod h1:P32HKFT3hSsZrRxla30E9HqToFYAQPCMs/zFMBUFqPY=\ngolang.org/x/term v0.8.0/go.mod h1:xPskH00ivmX89bAKVGSKKtLOWNx2+17Eiy94tnKShWo=\ngolang.org/x/term v0.11.0/go.mod h1:zC9APTIj3jG3FdV/Ons+XE1riIZXG4aZ4GTHiPZJPIU=\ngolang.org/x/term v0.34.0 h1:O/2T7POpk0ZZ7MAzMeWFSg6S5IpWd/RXDlM9hgM3DR4=\ngolang.org/x/term v0.34.0/go.mod h1:5jC53AEywhIVebHgPVeg0mj8OD3VO9OzclacVrqpaAw=\ngolang.org/x/text v0.3.0/go.mod h1:NqM8EUOU14njkJ3fqMW+pc6Ldnwhi/IjpwHt7yyuwOQ=\ngolang.org/x/text v0.3.3/go.mod h1:5Zoc/QRtKVWzQhOtBMvqHzDpF6irO9z98xDceosuGiQ=\ngolang.org/x/text v0.3.7/go.mod h1:u+2+/6zg+i71rQMx5EYifcz6MCKuco9NR6JIITiCfzQ=\ngolang.org/x/text v0.7.0/go.mod h1:mrYo+phRRbMaCq/xk9113O4dZlRixOauAjOtrjsXDZ8=\ngolang.org/x/text v0.9.0/go.mod h1:e1OnstbJyHTd6l/uOt8jFFHp6TRDWZR/bV3emEE/zU8=\ngolang.org/x/text v0.12.0/go.mod h1:TvPlkZtksWOMsz7fbANvkp4WM8x/WCo/om8BMLbz+aE=\ngolang.org/x/text v0.28.0 h1:rhazDwis8INMIwQ4tpjLDzUhx6RlXqZNPEM0huQojng=\ngolang.org/x/text v0.28.0/go.mod h1:U8nCwOR8jO/marOQ0QbDiOngZVEBB7MAiitBuMjXiNU=\ngolang.org/x/time v0.5.0 h1:o7cqy6amK/52YcAKIPlM3a+Fpj35zvRj2TP+e1xFSfk=\ngolang.org/x/time v0.5.0/go.mod h1:3BpzKBy/shNhVucY/MWOyx10tF3SFh9QdLuxbVysPQM=\ngolang.org/x/tools v0.0.0-20180917221912-90fa682c2a6e/go.mod h1:n7NCudcB/nEzxVGmLbDWY5pfWTLqBcC2KZ6jyYvM4mQ=\ngolang.org/x/tools v0.0.0-20190114222345-bf090417da8b/go.mod h1:n7NCudcB/nEzxVGmLbDWY5pfWTLqBcC2KZ6jyYvM4mQ=\ngolang.org/x/tools v0.0.0-20190226205152-f727befe758c/go.mod h1:9Yl7xja0Znq3iFh3HoIrodX9oNMXvdceNzlUR8zjMvY=\ngolang.org/x/tools v0.0.0-20190311212946-11955173bddd/go.mod h1:LCzVGOaR6xXOjkQ3onu1FJEFr0SW1gC7cKk1uF8kGRs=\ngolang.org/x/tools v0.0.0-20190524140312-2c0ae7006135/go.mod h1:RgjU9mgBXZiqYHBnxXauZ1Gv1EHHAz9KjViQ78xBX0Q=\ngolang.org/x/tools v0.0.0-20191119224855-298f0cb1881e/go.mod h1:b+2E5dAYhXwXZwtnZ6UAqBI28+e2cm9otk0dWdXHAEo=\ngolang.org/x/tools v0.1.12/go.mod h1:hNGJHUnrk76NpqgfD5Aqm5Crs+Hm0VOH/i9J2+nxYbc=\ngolang.org/x/tools v0.6.0/go.mod h1:Xwgl3UAJ/d3gWutnCtw505GrjyAbvKui8lOU390QaIU=\ngolang.org/x/tools v0.36.0 h1:kWS0uv/zsvHEle1LbV5LE8QujrxB3wfQyxHfhOk0Qkg=\ngolang.org/x/tools v0.36.0/go.mod h1:WBDiHKJK8YgLHlcQPYQzNCkUxUypCaa5ZegCVutKm+s=\ngolang.org/x/xerrors v0.0.0-20190717185122-a985d3407aa7/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0=\ngolang.org/x/xerrors v0.0.0-20191204190536-9bdfabe68543/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0=\ngolang.zx2c4.com/wintun v0.0.0-20230126152724-0fa3db229ce2 h1:B82qJJgjvYKsXS9jeunTOisW56dUokqW/FOteYJJ/yg=\ngolang.zx2c4.com/wintun v0.0.0-20230126152724-0fa3db229ce2/go.mod h1:deeaetjYA+DHMHg+sMSMI58GrEteJUUzzw7en6TJQcI=\ngolang.zx2c4.com/wireguard v0.0.0-20231211153847-12269c276173 h1:/jFs0duh4rdb8uIfPMv78iAJGcPKDeqAFnaLBropIC4=\ngolang.zx2c4.com/wireguard v0.0.0-20231211153847-12269c276173/go.mod h1:tkCQ4FQXmpAgYVh++1cq16/dH4QJtmvpRv19DWGAHSA=\ngoogle.golang.org/appengine v1.1.0/go.mod h1:EbEs0AVv82hx2wNQdGPgUI5lhzA/G0D9YwlJXL52JkM=\ngoogle.golang.org/appengine v1.4.0/go.mod h1:xpcJRLb0r/rnEns0DIKYYv+WjYCduHsrkT7/EB5XEv4=\ngoogle.golang.org/genproto v0.0.0-20180817151627-c66870c02cf8/go.mod h1:JiN7NxoALGmiZfu7CAH4rXhgtRTLTxftemlI0sWmxmc=\ngoogle.golang.org/genproto v0.0.0-20190819201941-24fa4b261c55/go.mod h1:DMBHOl98Agz4BDEuKkezgsaosCRResVns1a3J2ZsMNc=\ngoogle.golang.org/grpc v1.19.0/go.mod h1:mqu4LbDTu4XGKhr4mRzUsmM4RtVoemTSY81AxZiDr8c=\ngoogle.golang.org/grpc v1.23.0/go.mod h1:Y5yQAOtifL1yxbo5wqy6BxZv8vAUGQwXBOALyacEbxg=\ngoogle.golang.org/grpc v1.25.1/go.mod h1:c3i+UQWmh7LiEpx4sFZnkU36qjEYZ0imhYfXVyQciAY=\ngoogle.golang.org/grpc v1.31.0/go.mod h1:N36X2cJ7JwdamYAgDz+s+rVMFjt3numwzf/HckM8pak=\ngoogle.golang.org/protobuf v0.0.0-20200109180630-ec00e32a8dfd/go.mod h1:DFci5gLYBciE7Vtevhsrf46CRTquxDuWsQurQQe4oz8=\ngoogle.golang.org/protobuf v0.0.0-20200221191635-4d8936d0db64/go.mod h1:kwYJMbMJ01Woi6D6+Kah6886xMZcty6N08ah7+eCXa0=\ngoogle.golang.org/protobuf v0.0.0-20200228230310-ab0ca4ff8a60/go.mod h1:cfTl7dwQJ+fmap5saPgwCLgHXTUD7jkjRqWcaiX5VyM=\ngoogle.golang.org/protobuf v1.20.1-0.20200309200217-e05f789c0967/go.mod h1:A+miEFZTKqfCUM6K7xSMQL9OKL/b6hQv+e19PK+JZNE=\ngoogle.golang.org/protobuf v1.21.0/go.mod h1:47Nbq4nVaFHyn7ilMalzfO3qCViNmqZ2kzikPIcrTAo=\ngoogle.golang.org/protobuf v1.23.0/go.mod h1:EGpADcykh3NcUnDUJcl1+ZksZNG86OlYog2l/sGQquU=\ngoogle.golang.org/protobuf v1.36.5 h1:tPhr+woSbjfYvY6/GPufUoYizxw1cF/yFoxJ2fmpwlM=\ngoogle.golang.org/protobuf v1.36.5/go.mod h1:9fA7Ob0pmnwhb644+1+CVWFRbNajQ6iRojtC/QF5bRE=\ngopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0=\ngopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c h1:Hei/4ADfdWqJk1ZMxUNpqntNwaWcugrBjAiHlqqRiVk=\ngopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c/go.mod h1:JHkPIbrfpd72SG/EVd6muEfDQjcINNoR0C8j2r3qZ4Q=\ngopkg.in/ini.v1 v1.67.0 h1:Dgnx+6+nfE+IfzjUEISNeydPJh9AXNNsWbGP9KzCsOA=\ngopkg.in/ini.v1 v1.67.0/go.mod h1:pNLf8WUiyNEtQjuu5G5vTm06TEv9tsIgeAvK8hOrP4k=\ngopkg.in/yaml.v2 v2.4.0 h1:D8xgwECY7CYvx+Y2n4sBz93Jn9JRvxdiyyo8CTfuKaY=\ngopkg.in/yaml.v2 v2.4.0/go.mod h1:RDklbk79AGWmwhnvt/jBztapEOGDOx6ZbXqjP6csGnQ=\ngopkg.in/yaml.v3 v3.0.0-20200313102051-9f266ea9e77c/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=\ngopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA=\ngopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=\ngvisor.dev/gvisor v0.0.0-20230927004350-cbd86285d259 h1:TbRPT0HtzFP3Cno1zZo7yPzEEnfu8EjLfl6IU9VfqkQ=\ngvisor.dev/gvisor v0.0.0-20230927004350-cbd86285d259/go.mod h1:AVgIgHMwK63XvmAzWG9vLQ41YnVHN0du0tEC46fI7yY=\nhonnef.co/go/tools v0.0.0-20190102054323-c2f93a96b099/go.mod h1:rf3lG4BRIbNafJWhAfAdb/ePZxsR/4RtNHQocxwk9r4=\nhonnef.co/go/tools v0.0.0-20190523083050-ea95bdfd59fc/go.mod h1:rf3lG4BRIbNafJWhAfAdb/ePZxsR/4RtNHQocxwk9r4=\nk8s.io/apimachinery v0.28.8 h1:hi/nrxHwk4QLV+W/SHve1bypTE59HCDorLY1stBIxKQ=\nk8s.io/apimachinery v0.28.8/go.mod h1:cBnwIM3fXoRo28SqbV/Ihxf/iviw85KyXOrzxvZQ83U=\nk8s.io/client-go v0.28.8 h1:TE59Tjd87WKvS2FPBTfIKLFX0nQJ4SSHsnDo5IHjgOw=\nk8s.io/client-go v0.28.8/go.mod h1:uDVQ/rPzWpWIy40c6lZ4mUwaEvRWGnpoqSO4FM65P3o=\nk8s.io/utils v0.0.0-20230406110748-d93618cff8a2 h1:qY1Ad8PODbnymg2pRbkyMT/ylpTrCM8P2RJ0yroCyIk=\nk8s.io/utils v0.0.0-20230406110748-d93618cff8a2/go.mod h1:OLgZIPagt7ERELqWJFomSt595RzquPNLL48iOWgYOg0=\nsigs.k8s.io/json v0.0.0-20221116044647-bc3834ca7abd h1:EDPBXCAspyGV4jQlpZSudPeMmr1bNJefnuqLsRAsHZo=\nsigs.k8s.io/json v0.0.0-20221116044647-bc3834ca7abd/go.mod h1:B8JuhiUyNFVKdsE8h686QcCxMaH6HrOAZj4vswFpcB0=\nsigs.k8s.io/yaml v1.3.0 h1:a2VclLzOGrwOHDiV8EfBGhvjHvP46CtW5j6POvhYGGo=\nsigs.k8s.io/yaml v1.3.0/go.mod h1:GeOyir5tyXNByN85N/dRIT9es5UQNerPYEKK56eTBm8=\n"
  },
  {
    "path": "hack/download.sh",
    "content": "#!/bin/sh\n\nOS=\"$(go env GOOS)\"\nARCH=\"$(go env GOARCH)\"\n\nif [ \"${TARGET_OS}\" ]; then\n  OS=\"${TARGET_OS}\"\nfi\nif [ \"${TARGET_ARCH}\" ]; then\n  ARCH=\"${TARGET_ARCH}\"\nfi\n\n# Determine the latest version by version number ignoring alpha, beta, and rc versions.\nif [ \"${FRP_VERSION}\" = \"\" ] ; then\n  FRP_VERSION=\"$(curl -sL https://github.com/fatedier/frp/releases | \\\n                  grep -o 'releases/tag/v[0-9]*.[0-9]*.[0-9]*\"' | sort -V | \\\n                  tail -1 | awk -F'/' '{ print $3}')\"\n  FRP_VERSION=\"${FRP_VERSION%?}\"\n  FRP_VERSION=\"${FRP_VERSION#?}\"\nfi\n\nif [ \"${FRP_VERSION}\" = \"\" ] ; then\n  printf \"Unable to get latest frp version. Set FRP_VERSION env var and re-run. For example: export FRP_VERSION=1.0.0\"\n  exit 1;\nfi\n\nSUFFIX=\".tar.gz\"\nif [ \"${OS}\" = \"windows\" ] ; then\n  SUFFIX=\".zip\"\nfi\nNAME=\"frp_${FRP_VERSION}_${OS}_${ARCH}${SUFFIX}\"\nDIR_NAME=\"frp_${FRP_VERSION}_${OS}_${ARCH}\"\nURL=\"https://github.com/fatedier/frp/releases/download/v${FRP_VERSION}/${NAME}\"\n\ndownload_and_extract() {\n  printf \"Downloading %s from %s ...\\n\" \"$NAME\" \"${URL}\"\n  if ! curl -o /dev/null -sIf \"${URL}\"; then\n    printf \"\\n%s is not found, please specify a valid FRP_VERSION\\n\" \"${URL}\"\n    exit 1\n  fi\n  curl -fsLO \"${URL}\"\n  filename=$NAME\n\n  if [ \"${OS}\" = \"windows\" ]; then\n    unzip \"${filename}\"\n  else\n    tar -xzf \"${filename}\"\n  fi\n  rm \"${filename}\"\n\n  if [ \"${TARGET_DIRNAME}\" ]; then\n    mv \"${DIR_NAME}\" \"${TARGET_DIRNAME}\"\n    DIR_NAME=\"${TARGET_DIRNAME}\"\n  fi\n}\n\ndownload_and_extract\n\nprintf \"\"\nprintf \"\\nfrp %s Download Complete!\\n\" \"$FRP_VERSION\"\nprintf \"\\n\"\nprintf \"frp has been successfully downloaded into the %s folder on your system.\\n\" \"$DIR_NAME\"\nprintf \"\\n\"\n"
  },
  {
    "path": "hack/run-e2e.sh",
    "content": "#!/bin/sh\n\nSCRIPT=$(readlink -f \"$0\")\nROOT=$(unset CDPATH && cd \"$(dirname \"$SCRIPT\")/..\" && pwd)\n\n# Check if ginkgo is available\nif ! command -v ginkgo >/dev/null 2>&1; then\n    echo \"ginkgo not found, try to install...\"\n    go install github.com/onsi/ginkgo/v2/ginkgo@v2.23.4\nfi\n\ndebug=false\nif [ \"x${DEBUG}\" = \"xtrue\" ]; then\n    debug=true\nfi\nlogLevel=debug\nif [ \"${LOG_LEVEL}\" ]; then\n    logLevel=\"${LOG_LEVEL}\"\nfi\n\nfrpcPath=${ROOT}/bin/frpc\nif [ \"${FRPC_PATH}\" ]; then\n    frpcPath=\"${FRPC_PATH}\"\nfi\nfrpsPath=${ROOT}/bin/frps\nif [ \"${FRPS_PATH}\" ]; then\n    frpsPath=\"${FRPS_PATH}\"\nfi\nconcurrency=\"16\"\nif [ \"${CONCURRENCY}\" ]; then\n    concurrency=\"${CONCURRENCY}\"\nfi\n\nginkgo -nodes=${concurrency} --poll-progress-after=60s ${ROOT}/test/e2e -- -frpc-path=${frpcPath} -frps-path=${frpsPath} -log-level=${logLevel} -debug=${debug}\n"
  },
  {
    "path": "package.sh",
    "content": "#!/bin/sh\nset -e\n\n# compile for version\nmake\nif [ $? -ne 0 ]; then\n    echo \"make error\"\n    exit 1\nfi\n\nfrp_version=`./bin/frps --version`\necho \"build version: $frp_version\"\n\n# cross_compiles\nmake -f ./Makefile.cross-compiles\n\nrm -rf ./release/packages\nmkdir -p ./release/packages\n\nos_all='linux windows darwin freebsd openbsd android'\narch_all='386 amd64 arm arm64 mips64 mips64le mips mipsle riscv64 loong64'\nextra_all='_ hf'\n\ncd ./release\n\nfor os in $os_all; do\n    for arch in $arch_all; do\n        for extra in $extra_all; do\n            suffix=\"${os}_${arch}\"\n            if [ \"x${extra}\" != x\"_\" ]; then\n                suffix=\"${os}_${arch}_${extra}\"\n            fi\n            frp_dir_name=\"frp_${frp_version}_${suffix}\"\n            frp_path=\"./packages/frp_${frp_version}_${suffix}\"\n\n            if [ \"x${os}\" = x\"windows\" ]; then\n                if [ ! -f \"./frpc_${os}_${arch}.exe\" ]; then\n                    continue\n                fi\n                if [ ! -f \"./frps_${os}_${arch}.exe\" ]; then\n                    continue\n                fi\n                mkdir ${frp_path}\n                mv ./frpc_${os}_${arch}.exe ${frp_path}/frpc.exe\n                mv ./frps_${os}_${arch}.exe ${frp_path}/frps.exe\n            else\n                if [ ! -f \"./frpc_${suffix}\" ]; then\n                    continue\n                fi\n                if [ ! -f \"./frps_${suffix}\" ]; then\n                    continue\n                fi\n                mkdir ${frp_path}\n                mv ./frpc_${suffix} ${frp_path}/frpc\n                mv ./frps_${suffix} ${frp_path}/frps\n            fi  \n            cp ../LICENSE ${frp_path}\n            cp -f ../conf/frpc.toml ${frp_path}\n            cp -f ../conf/frps.toml ${frp_path}\n\n            # packages\n            cd ./packages\n            if [ \"x${os}\" = x\"windows\" ]; then\n                zip -rq ${frp_dir_name}.zip ${frp_dir_name}\n            else\n                tar -zcf ${frp_dir_name}.tar.gz ${frp_dir_name}\n            fi  \n            cd ..\n            rm -rf ${frp_path}\n        done\n    done\ndone\n\ncd -\n"
  },
  {
    "path": "pkg/auth/auth.go",
    "content": "// Copyright 2020 guylewin, guy@lewin.co.il\n//\n// Licensed under the Apache License, Version 2.0 (the \"License\");\n// you may not use this file except in compliance with the License.\n// You may obtain a copy of the License at\n//\n//     http://www.apache.org/licenses/LICENSE-2.0\n//\n// Unless required by applicable law or agreed to in writing, software\n// distributed under the License is distributed on an \"AS IS\" BASIS,\n// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.\n// See the License for the specific language governing permissions and\n// limitations under the License.\n\npackage auth\n\nimport (\n\t\"context\"\n\t\"fmt\"\n\n\tv1 \"github.com/fatedier/frp/pkg/config/v1\"\n\t\"github.com/fatedier/frp/pkg/msg\"\n)\n\ntype Setter interface {\n\tSetLogin(*msg.Login) error\n\tSetPing(*msg.Ping) error\n\tSetNewWorkConn(*msg.NewWorkConn) error\n}\n\ntype ClientAuth struct {\n\tSetter Setter\n\tkey    []byte\n}\n\nfunc (a *ClientAuth) EncryptionKey() []byte {\n\treturn a.key\n}\n\n// BuildClientAuth resolves any dynamic auth values and returns a prepared auth runtime.\n// Caller must run validation before calling this function.\nfunc BuildClientAuth(cfg *v1.AuthClientConfig) (*ClientAuth, error) {\n\tif cfg == nil {\n\t\treturn nil, fmt.Errorf(\"auth config is nil\")\n\t}\n\tresolved := *cfg\n\tif resolved.Method == v1.AuthMethodToken && resolved.TokenSource != nil {\n\t\ttoken, err := resolved.TokenSource.Resolve(context.Background())\n\t\tif err != nil {\n\t\t\treturn nil, fmt.Errorf(\"failed to resolve auth.tokenSource: %w\", err)\n\t\t}\n\t\tresolved.Token = token\n\t}\n\tsetter, err := NewAuthSetter(resolved)\n\tif err != nil {\n\t\treturn nil, err\n\t}\n\treturn &ClientAuth{\n\t\tSetter: setter,\n\t\tkey:    []byte(resolved.Token),\n\t}, nil\n}\n\nfunc NewAuthSetter(cfg v1.AuthClientConfig) (authProvider Setter, err error) {\n\tswitch cfg.Method {\n\tcase v1.AuthMethodToken:\n\t\tauthProvider = NewTokenAuth(cfg.AdditionalScopes, cfg.Token)\n\tcase v1.AuthMethodOIDC:\n\t\tif cfg.OIDC.TokenSource != nil {\n\t\t\tauthProvider = NewOidcTokenSourceAuthSetter(cfg.AdditionalScopes, cfg.OIDC.TokenSource)\n\t\t} else {\n\t\t\tauthProvider, err = NewOidcAuthSetter(cfg.AdditionalScopes, cfg.OIDC)\n\t\t\tif err != nil {\n\t\t\t\treturn nil, err\n\t\t\t}\n\t\t}\n\tdefault:\n\t\treturn nil, fmt.Errorf(\"unsupported auth method: %s\", cfg.Method)\n\t}\n\treturn authProvider, nil\n}\n\ntype Verifier interface {\n\tVerifyLogin(*msg.Login) error\n\tVerifyPing(*msg.Ping) error\n\tVerifyNewWorkConn(*msg.NewWorkConn) error\n}\n\ntype ServerAuth struct {\n\tVerifier Verifier\n\tkey      []byte\n}\n\nfunc (a *ServerAuth) EncryptionKey() []byte {\n\treturn a.key\n}\n\n// BuildServerAuth resolves any dynamic auth values and returns a prepared auth runtime.\n// Caller must run validation before calling this function.\nfunc BuildServerAuth(cfg *v1.AuthServerConfig) (*ServerAuth, error) {\n\tif cfg == nil {\n\t\treturn nil, fmt.Errorf(\"auth config is nil\")\n\t}\n\tresolved := *cfg\n\tif resolved.Method == v1.AuthMethodToken && resolved.TokenSource != nil {\n\t\ttoken, err := resolved.TokenSource.Resolve(context.Background())\n\t\tif err != nil {\n\t\t\treturn nil, fmt.Errorf(\"failed to resolve auth.tokenSource: %w\", err)\n\t\t}\n\t\tresolved.Token = token\n\t}\n\treturn &ServerAuth{\n\t\tVerifier: NewAuthVerifier(resolved),\n\t\tkey:      []byte(resolved.Token),\n\t}, nil\n}\n\nfunc NewAuthVerifier(cfg v1.AuthServerConfig) (authVerifier Verifier) {\n\tswitch cfg.Method {\n\tcase v1.AuthMethodToken:\n\t\tauthVerifier = NewTokenAuth(cfg.AdditionalScopes, cfg.Token)\n\tcase v1.AuthMethodOIDC:\n\t\ttokenVerifier := NewTokenVerifier(cfg.OIDC)\n\t\tauthVerifier = NewOidcAuthVerifier(cfg.AdditionalScopes, tokenVerifier)\n\t}\n\treturn authVerifier\n}\n"
  },
  {
    "path": "pkg/auth/legacy/legacy.go",
    "content": "// Copyright 2023 The frp Authors\n//\n// Licensed under the Apache License, Version 2.0 (the \"License\");\n// you may not use this file except in compliance with the License.\n// You may obtain a copy of the License at\n//\n//     http://www.apache.org/licenses/LICENSE-2.0\n//\n// Unless required by applicable law or agreed to in writing, software\n// distributed under the License is distributed on an \"AS IS\" BASIS,\n// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.\n// See the License for the specific language governing permissions and\n// limitations under the License.\n\npackage legacy\n\ntype BaseConfig struct {\n\t// AuthenticationMethod specifies what authentication method to use to\n\t// authenticate frpc with frps. If \"token\" is specified - token will be\n\t// read into login message. If \"oidc\" is specified - OIDC (Open ID Connect)\n\t// token will be issued using OIDC settings. By default, this value is \"token\".\n\tAuthenticationMethod string `ini:\"authentication_method\" json:\"authentication_method\"`\n\t// AuthenticateHeartBeats specifies whether to include authentication token in\n\t// heartbeats sent to frps. By default, this value is false.\n\tAuthenticateHeartBeats bool `ini:\"authenticate_heartbeats\" json:\"authenticate_heartbeats\"`\n\t// AuthenticateNewWorkConns specifies whether to include authentication token in\n\t// new work connections sent to frps. By default, this value is false.\n\tAuthenticateNewWorkConns bool `ini:\"authenticate_new_work_conns\" json:\"authenticate_new_work_conns\"`\n}\n\nfunc getDefaultBaseConf() BaseConfig {\n\treturn BaseConfig{\n\t\tAuthenticationMethod:     \"token\",\n\t\tAuthenticateHeartBeats:   false,\n\t\tAuthenticateNewWorkConns: false,\n\t}\n}\n\ntype ClientConfig struct {\n\tBaseConfig       `ini:\",extends\"`\n\tOidcClientConfig `ini:\",extends\"`\n\tTokenConfig      `ini:\",extends\"`\n}\n\nfunc GetDefaultClientConf() ClientConfig {\n\treturn ClientConfig{\n\t\tBaseConfig:       getDefaultBaseConf(),\n\t\tOidcClientConfig: getDefaultOidcClientConf(),\n\t\tTokenConfig:      getDefaultTokenConf(),\n\t}\n}\n\ntype ServerConfig struct {\n\tBaseConfig       `ini:\",extends\"`\n\tOidcServerConfig `ini:\",extends\"`\n\tTokenConfig      `ini:\",extends\"`\n}\n\nfunc GetDefaultServerConf() ServerConfig {\n\treturn ServerConfig{\n\t\tBaseConfig:       getDefaultBaseConf(),\n\t\tOidcServerConfig: getDefaultOidcServerConf(),\n\t\tTokenConfig:      getDefaultTokenConf(),\n\t}\n}\n\ntype OidcClientConfig struct {\n\t// OidcClientID specifies the client ID to use to get a token in OIDC\n\t// authentication if AuthenticationMethod == \"oidc\". By default, this value\n\t// is \"\".\n\tOidcClientID string `ini:\"oidc_client_id\" json:\"oidc_client_id\"`\n\t// OidcClientSecret specifies the client secret to use to get a token in OIDC\n\t// authentication if AuthenticationMethod == \"oidc\". By default, this value\n\t// is \"\".\n\tOidcClientSecret string `ini:\"oidc_client_secret\" json:\"oidc_client_secret\"`\n\t// OidcAudience specifies the audience of the token in OIDC authentication\n\t// if AuthenticationMethod == \"oidc\". By default, this value is \"\".\n\tOidcAudience string `ini:\"oidc_audience\" json:\"oidc_audience\"`\n\t// OidcScope specifies the scope of the token in OIDC authentication\n\t// if AuthenticationMethod == \"oidc\". By default, this value is \"\".\n\tOidcScope string `ini:\"oidc_scope\" json:\"oidc_scope\"`\n\t// OidcTokenEndpointURL specifies the URL which implements OIDC Token Endpoint.\n\t// It will be used to get an OIDC token if AuthenticationMethod == \"oidc\".\n\t// By default, this value is \"\".\n\tOidcTokenEndpointURL string `ini:\"oidc_token_endpoint_url\" json:\"oidc_token_endpoint_url\"`\n\n\t// OidcAdditionalEndpointParams specifies additional parameters to be sent\n\t// this field will be transfer to map[string][]string in OIDC token generator\n\t// The field will be set by prefix \"oidc_additional_\"\n\tOidcAdditionalEndpointParams map[string]string `ini:\"-\" json:\"oidc_additional_endpoint_params\"`\n}\n\nfunc getDefaultOidcClientConf() OidcClientConfig {\n\treturn OidcClientConfig{\n\t\tOidcClientID:                 \"\",\n\t\tOidcClientSecret:             \"\",\n\t\tOidcAudience:                 \"\",\n\t\tOidcScope:                    \"\",\n\t\tOidcTokenEndpointURL:         \"\",\n\t\tOidcAdditionalEndpointParams: make(map[string]string),\n\t}\n}\n\ntype OidcServerConfig struct {\n\t// OidcIssuer specifies the issuer to verify OIDC tokens with. This issuer\n\t// will be used to load public keys to verify signature and will be compared\n\t// with the issuer claim in the OIDC token. It will be used if\n\t// AuthenticationMethod == \"oidc\". By default, this value is \"\".\n\tOidcIssuer string `ini:\"oidc_issuer\" json:\"oidc_issuer\"`\n\t// OidcAudience specifies the audience OIDC tokens should contain when validated.\n\t// If this value is empty, audience (\"client ID\") verification will be skipped.\n\t// It will be used when AuthenticationMethod == \"oidc\". By default, this\n\t// value is \"\".\n\tOidcAudience string `ini:\"oidc_audience\" json:\"oidc_audience\"`\n\t// OidcSkipExpiryCheck specifies whether to skip checking if the OIDC token is\n\t// expired. It will be used when AuthenticationMethod == \"oidc\". By default, this\n\t// value is false.\n\tOidcSkipExpiryCheck bool `ini:\"oidc_skip_expiry_check\" json:\"oidc_skip_expiry_check\"`\n\t// OidcSkipIssuerCheck specifies whether to skip checking if the OIDC token's\n\t// issuer claim matches the issuer specified in OidcIssuer. It will be used when\n\t// AuthenticationMethod == \"oidc\". By default, this value is false.\n\tOidcSkipIssuerCheck bool `ini:\"oidc_skip_issuer_check\" json:\"oidc_skip_issuer_check\"`\n}\n\nfunc getDefaultOidcServerConf() OidcServerConfig {\n\treturn OidcServerConfig{\n\t\tOidcIssuer:          \"\",\n\t\tOidcAudience:        \"\",\n\t\tOidcSkipExpiryCheck: false,\n\t\tOidcSkipIssuerCheck: false,\n\t}\n}\n\ntype TokenConfig struct {\n\t// Token specifies the authorization token used to create keys to be sent\n\t// to the server. The server must have a matching token for authorization\n\t// to succeed.  By default, this value is \"\".\n\tToken string `ini:\"token\" json:\"token\"`\n}\n\nfunc getDefaultTokenConf() TokenConfig {\n\treturn TokenConfig{\n\t\tToken: \"\",\n\t}\n}\n"
  },
  {
    "path": "pkg/auth/oidc.go",
    "content": "// Copyright 2020 guylewin, guy@lewin.co.il\n//\n// Licensed under the Apache License, Version 2.0 (the \"License\");\n// you may not use this file except in compliance with the License.\n// You may obtain a copy of the License at\n//\n//     http://www.apache.org/licenses/LICENSE-2.0\n//\n// Unless required by applicable law or agreed to in writing, software\n// distributed under the License is distributed on an \"AS IS\" BASIS,\n// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.\n// See the License for the specific language governing permissions and\n// limitations under the License.\n\npackage auth\n\nimport (\n\t\"context\"\n\t\"crypto/tls\"\n\t\"crypto/x509\"\n\t\"fmt\"\n\t\"net/http\"\n\t\"net/url\"\n\t\"os\"\n\t\"slices\"\n\t\"sync\"\n\n\t\"github.com/coreos/go-oidc/v3/oidc\"\n\t\"golang.org/x/oauth2\"\n\t\"golang.org/x/oauth2/clientcredentials\"\n\n\tv1 \"github.com/fatedier/frp/pkg/config/v1\"\n\t\"github.com/fatedier/frp/pkg/config/v1/validation\"\n\t\"github.com/fatedier/frp/pkg/msg\"\n)\n\n// createOIDCHTTPClient creates an HTTP client with custom TLS and proxy configuration for OIDC token requests\nfunc createOIDCHTTPClient(trustedCAFile string, insecureSkipVerify bool, proxyURL string) (*http.Client, error) {\n\t// Clone the default transport to get all reasonable defaults\n\ttransport := http.DefaultTransport.(*http.Transport).Clone()\n\n\t// Configure TLS settings\n\tif trustedCAFile != \"\" || insecureSkipVerify {\n\t\ttlsConfig := &tls.Config{\n\t\t\tInsecureSkipVerify: insecureSkipVerify,\n\t\t}\n\n\t\tif trustedCAFile != \"\" && !insecureSkipVerify {\n\t\t\tcaCert, err := os.ReadFile(trustedCAFile)\n\t\t\tif err != nil {\n\t\t\t\treturn nil, fmt.Errorf(\"failed to read OIDC CA certificate file %q: %w\", trustedCAFile, err)\n\t\t\t}\n\n\t\t\tcaCertPool := x509.NewCertPool()\n\t\t\tif !caCertPool.AppendCertsFromPEM(caCert) {\n\t\t\t\treturn nil, fmt.Errorf(\"failed to parse OIDC CA certificate from file %q\", trustedCAFile)\n\t\t\t}\n\n\t\t\ttlsConfig.RootCAs = caCertPool\n\t\t}\n\t\ttransport.TLSClientConfig = tlsConfig\n\t}\n\n\t// Configure proxy settings\n\tif proxyURL != \"\" {\n\t\tparsedURL, err := url.Parse(proxyURL)\n\t\tif err != nil {\n\t\t\treturn nil, fmt.Errorf(\"failed to parse OIDC proxy URL %q: %w\", proxyURL, err)\n\t\t}\n\t\ttransport.Proxy = http.ProxyURL(parsedURL)\n\t} else {\n\t\t// Explicitly disable proxy to override DefaultTransport's ProxyFromEnvironment\n\t\ttransport.Proxy = nil\n\t}\n\n\treturn &http.Client{Transport: transport}, nil\n}\n\n// nonCachingTokenSource wraps a clientcredentials.Config to fetch a fresh\n// token on every call. This is used as a fallback when the OIDC provider\n// does not return expires_in, which would cause a caching TokenSource to\n// hold onto a stale token forever.\ntype nonCachingTokenSource struct {\n\tcfg *clientcredentials.Config\n\tctx context.Context\n}\n\nfunc (s *nonCachingTokenSource) Token() (*oauth2.Token, error) {\n\treturn s.cfg.Token(s.ctx)\n}\n\n// oidcTokenSource wraps a caching oauth2.TokenSource and, on the first\n// successful Token() call, checks whether the provider returns an expiry.\n// If not, it permanently switches to nonCachingTokenSource so that a fresh\n// token is fetched every time.  This avoids an eager network call at\n// construction time, letting the login retry loop handle transient IdP\n// outages.\ntype oidcTokenSource struct {\n\tmu          sync.Mutex\n\tinitialized bool\n\tsource      oauth2.TokenSource\n\tfallbackCfg *clientcredentials.Config\n\tfallbackCtx context.Context\n}\n\nfunc (s *oidcTokenSource) Token() (*oauth2.Token, error) {\n\ts.mu.Lock()\n\tif !s.initialized {\n\t\ttoken, err := s.source.Token()\n\t\tif err != nil {\n\t\t\ts.mu.Unlock()\n\t\t\treturn nil, err\n\t\t}\n\t\tif token.Expiry.IsZero() {\n\t\t\ts.source = &nonCachingTokenSource{cfg: s.fallbackCfg, ctx: s.fallbackCtx}\n\t\t}\n\t\ts.initialized = true\n\t\ts.mu.Unlock()\n\t\treturn token, nil\n\t}\n\tsource := s.source\n\ts.mu.Unlock()\n\treturn source.Token()\n}\n\ntype OidcAuthProvider struct {\n\tadditionalAuthScopes []v1.AuthScope\n\n\ttokenSource oauth2.TokenSource\n}\n\nfunc NewOidcAuthSetter(additionalAuthScopes []v1.AuthScope, cfg v1.AuthOIDCClientConfig) (*OidcAuthProvider, error) {\n\tif err := validation.ValidateOIDCClientCredentialsConfig(&cfg); err != nil {\n\t\treturn nil, err\n\t}\n\n\teps := make(map[string][]string)\n\tfor k, v := range cfg.AdditionalEndpointParams {\n\t\teps[k] = []string{v}\n\t}\n\n\tif cfg.Audience != \"\" {\n\t\teps[\"audience\"] = []string{cfg.Audience}\n\t}\n\n\ttokenGenerator := &clientcredentials.Config{\n\t\tClientID:       cfg.ClientID,\n\t\tClientSecret:   cfg.ClientSecret,\n\t\tScopes:         []string{cfg.Scope},\n\t\tTokenURL:       cfg.TokenEndpointURL,\n\t\tEndpointParams: eps,\n\t}\n\n\t// Build the context that TokenSource will use for all future HTTP requests.\n\t// context.Background() is appropriate here because the token source is\n\t// long-lived and outlives any single request.\n\tctx := context.Background()\n\tif cfg.TrustedCaFile != \"\" || cfg.InsecureSkipVerify || cfg.ProxyURL != \"\" {\n\t\thttpClient, err := createOIDCHTTPClient(cfg.TrustedCaFile, cfg.InsecureSkipVerify, cfg.ProxyURL)\n\t\tif err != nil {\n\t\t\treturn nil, fmt.Errorf(\"failed to create OIDC HTTP client: %w\", err)\n\t\t}\n\t\tctx = context.WithValue(ctx, oauth2.HTTPClient, httpClient)\n\t}\n\n\t// Create a persistent TokenSource that caches the token and refreshes\n\t// it before expiry. This avoids making a new HTTP request to the OIDC\n\t// provider on every heartbeat/ping.\n\t//\n\t// We wrap it in an oidcTokenSource so that the first Token() call\n\t// (deferred to SetLogin inside the login retry loop) probes whether the\n\t// provider returns expires_in.  If not, it switches to a non-caching\n\t// source.  This avoids an eager network call at construction time, which\n\t// would prevent loopLoginUntilSuccess from retrying on transient IdP\n\t// outages.\n\tcachingSource := tokenGenerator.TokenSource(ctx)\n\n\treturn &OidcAuthProvider{\n\t\tadditionalAuthScopes: additionalAuthScopes,\n\t\ttokenSource: &oidcTokenSource{\n\t\t\tsource:      cachingSource,\n\t\t\tfallbackCfg: tokenGenerator,\n\t\t\tfallbackCtx: ctx,\n\t\t},\n\t}, nil\n}\n\nfunc (auth *OidcAuthProvider) generateAccessToken() (accessToken string, err error) {\n\ttokenObj, err := auth.tokenSource.Token()\n\tif err != nil {\n\t\treturn \"\", fmt.Errorf(\"couldn't generate OIDC token for login: %v\", err)\n\t}\n\treturn tokenObj.AccessToken, nil\n}\n\nfunc (auth *OidcAuthProvider) SetLogin(loginMsg *msg.Login) (err error) {\n\tloginMsg.PrivilegeKey, err = auth.generateAccessToken()\n\treturn err\n}\n\nfunc (auth *OidcAuthProvider) SetPing(pingMsg *msg.Ping) (err error) {\n\tif !slices.Contains(auth.additionalAuthScopes, v1.AuthScopeHeartBeats) {\n\t\treturn nil\n\t}\n\n\tpingMsg.PrivilegeKey, err = auth.generateAccessToken()\n\treturn err\n}\n\nfunc (auth *OidcAuthProvider) SetNewWorkConn(newWorkConnMsg *msg.NewWorkConn) (err error) {\n\tif !slices.Contains(auth.additionalAuthScopes, v1.AuthScopeNewWorkConns) {\n\t\treturn nil\n\t}\n\n\tnewWorkConnMsg.PrivilegeKey, err = auth.generateAccessToken()\n\treturn err\n}\n\ntype OidcTokenSourceAuthProvider struct {\n\tadditionalAuthScopes []v1.AuthScope\n\n\tvalueSource *v1.ValueSource\n}\n\nfunc NewOidcTokenSourceAuthSetter(additionalAuthScopes []v1.AuthScope, valueSource *v1.ValueSource) *OidcTokenSourceAuthProvider {\n\treturn &OidcTokenSourceAuthProvider{\n\t\tadditionalAuthScopes: additionalAuthScopes,\n\t\tvalueSource:          valueSource,\n\t}\n}\n\nfunc (auth *OidcTokenSourceAuthProvider) generateAccessToken() (accessToken string, err error) {\n\tctx := context.Background()\n\taccessToken, err = auth.valueSource.Resolve(ctx)\n\tif err != nil {\n\t\treturn \"\", fmt.Errorf(\"couldn't acquire OIDC token for login: %v\", err)\n\t}\n\treturn\n}\n\nfunc (auth *OidcTokenSourceAuthProvider) SetLogin(loginMsg *msg.Login) (err error) {\n\tloginMsg.PrivilegeKey, err = auth.generateAccessToken()\n\treturn err\n}\n\nfunc (auth *OidcTokenSourceAuthProvider) SetPing(pingMsg *msg.Ping) (err error) {\n\tif !slices.Contains(auth.additionalAuthScopes, v1.AuthScopeHeartBeats) {\n\t\treturn nil\n\t}\n\n\tpingMsg.PrivilegeKey, err = auth.generateAccessToken()\n\treturn err\n}\n\nfunc (auth *OidcTokenSourceAuthProvider) SetNewWorkConn(newWorkConnMsg *msg.NewWorkConn) (err error) {\n\tif !slices.Contains(auth.additionalAuthScopes, v1.AuthScopeNewWorkConns) {\n\t\treturn nil\n\t}\n\n\tnewWorkConnMsg.PrivilegeKey, err = auth.generateAccessToken()\n\treturn err\n}\n\ntype TokenVerifier interface {\n\tVerify(context.Context, string) (*oidc.IDToken, error)\n}\n\ntype OidcAuthConsumer struct {\n\tadditionalAuthScopes []v1.AuthScope\n\n\tverifier          TokenVerifier\n\tmu                sync.RWMutex\n\tsubjectsFromLogin map[string]struct{}\n}\n\nfunc NewTokenVerifier(cfg v1.AuthOIDCServerConfig) TokenVerifier {\n\tprovider, err := oidc.NewProvider(context.Background(), cfg.Issuer)\n\tif err != nil {\n\t\tpanic(err)\n\t}\n\tverifierConf := oidc.Config{\n\t\tClientID:          cfg.Audience,\n\t\tSkipClientIDCheck: cfg.Audience == \"\",\n\t\tSkipExpiryCheck:   cfg.SkipExpiryCheck,\n\t\tSkipIssuerCheck:   cfg.SkipIssuerCheck,\n\t}\n\treturn provider.Verifier(&verifierConf)\n}\n\nfunc NewOidcAuthVerifier(additionalAuthScopes []v1.AuthScope, verifier TokenVerifier) *OidcAuthConsumer {\n\treturn &OidcAuthConsumer{\n\t\tadditionalAuthScopes: additionalAuthScopes,\n\t\tverifier:             verifier,\n\t\tsubjectsFromLogin:    make(map[string]struct{}),\n\t}\n}\n\nfunc (auth *OidcAuthConsumer) VerifyLogin(loginMsg *msg.Login) (err error) {\n\ttoken, err := auth.verifier.Verify(context.Background(), loginMsg.PrivilegeKey)\n\tif err != nil {\n\t\treturn fmt.Errorf(\"invalid OIDC token in login: %v\", err)\n\t}\n\tauth.mu.Lock()\n\tauth.subjectsFromLogin[token.Subject] = struct{}{}\n\tauth.mu.Unlock()\n\treturn nil\n}\n\nfunc (auth *OidcAuthConsumer) verifyPostLoginToken(privilegeKey string) (err error) {\n\ttoken, err := auth.verifier.Verify(context.Background(), privilegeKey)\n\tif err != nil {\n\t\treturn fmt.Errorf(\"invalid OIDC token in ping: %v\", err)\n\t}\n\tauth.mu.RLock()\n\t_, ok := auth.subjectsFromLogin[token.Subject]\n\tauth.mu.RUnlock()\n\tif !ok {\n\t\treturn fmt.Errorf(\"received different OIDC subject in login and ping. \"+\n\t\t\t\"new subject: %s\",\n\t\t\ttoken.Subject)\n\t}\n\treturn nil\n}\n\nfunc (auth *OidcAuthConsumer) VerifyPing(pingMsg *msg.Ping) (err error) {\n\tif !slices.Contains(auth.additionalAuthScopes, v1.AuthScopeHeartBeats) {\n\t\treturn nil\n\t}\n\n\treturn auth.verifyPostLoginToken(pingMsg.PrivilegeKey)\n}\n\nfunc (auth *OidcAuthConsumer) VerifyNewWorkConn(newWorkConnMsg *msg.NewWorkConn) (err error) {\n\tif !slices.Contains(auth.additionalAuthScopes, v1.AuthScopeNewWorkConns) {\n\t\treturn nil\n\t}\n\n\treturn auth.verifyPostLoginToken(newWorkConnMsg.PrivilegeKey)\n}\n"
  },
  {
    "path": "pkg/auth/oidc_test.go",
    "content": "package auth_test\n\nimport (\n\t\"context\"\n\t\"encoding/json\"\n\t\"net/http\"\n\t\"net/http/httptest\"\n\t\"sync/atomic\"\n\t\"testing\"\n\t\"time\"\n\n\t\"github.com/coreos/go-oidc/v3/oidc\"\n\t\"github.com/stretchr/testify/require\"\n\n\t\"github.com/fatedier/frp/pkg/auth\"\n\tv1 \"github.com/fatedier/frp/pkg/config/v1\"\n\t\"github.com/fatedier/frp/pkg/msg\"\n)\n\ntype mockTokenVerifier struct{}\n\nfunc (m *mockTokenVerifier) Verify(ctx context.Context, subject string) (*oidc.IDToken, error) {\n\treturn &oidc.IDToken{\n\t\tSubject: subject,\n\t}, nil\n}\n\nfunc TestPingWithEmptySubjectFromLoginFails(t *testing.T) {\n\tr := require.New(t)\n\tconsumer := auth.NewOidcAuthVerifier([]v1.AuthScope{v1.AuthScopeHeartBeats}, &mockTokenVerifier{})\n\terr := consumer.VerifyPing(&msg.Ping{\n\t\tPrivilegeKey: \"ping-without-login\",\n\t\tTimestamp:    time.Now().UnixMilli(),\n\t})\n\tr.Error(err)\n\tr.Contains(err.Error(), \"received different OIDC subject in login and ping\")\n}\n\nfunc TestPingAfterLoginWithNewSubjectSucceeds(t *testing.T) {\n\tr := require.New(t)\n\tconsumer := auth.NewOidcAuthVerifier([]v1.AuthScope{v1.AuthScopeHeartBeats}, &mockTokenVerifier{})\n\terr := consumer.VerifyLogin(&msg.Login{\n\t\tPrivilegeKey: \"ping-after-login\",\n\t})\n\tr.NoError(err)\n\n\terr = consumer.VerifyPing(&msg.Ping{\n\t\tPrivilegeKey: \"ping-after-login\",\n\t\tTimestamp:    time.Now().UnixMilli(),\n\t})\n\tr.NoError(err)\n}\n\nfunc TestPingAfterLoginWithDifferentSubjectFails(t *testing.T) {\n\tr := require.New(t)\n\tconsumer := auth.NewOidcAuthVerifier([]v1.AuthScope{v1.AuthScopeHeartBeats}, &mockTokenVerifier{})\n\terr := consumer.VerifyLogin(&msg.Login{\n\t\tPrivilegeKey: \"login-with-first-subject\",\n\t})\n\tr.NoError(err)\n\n\terr = consumer.VerifyPing(&msg.Ping{\n\t\tPrivilegeKey: \"ping-with-different-subject\",\n\t\tTimestamp:    time.Now().UnixMilli(),\n\t})\n\tr.Error(err)\n\tr.Contains(err.Error(), \"received different OIDC subject in login and ping\")\n}\n\nfunc TestOidcAuthProviderFallsBackWhenNoExpiry(t *testing.T) {\n\tr := require.New(t)\n\n\tvar requestCount atomic.Int32\n\ttokenServer := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, req *http.Request) {\n\t\trequestCount.Add(1)\n\t\tw.Header().Set(\"Content-Type\", \"application/json\")\n\t\t_ = json.NewEncoder(w).Encode(map[string]any{ //nolint:gosec // test-only dummy token response\n\t\t\t\"access_token\": \"fresh-test-token\",\n\t\t\t\"token_type\":   \"Bearer\",\n\t\t})\n\t}))\n\tdefer tokenServer.Close()\n\n\tprovider, err := auth.NewOidcAuthSetter(\n\t\t[]v1.AuthScope{v1.AuthScopeHeartBeats},\n\t\tv1.AuthOIDCClientConfig{\n\t\t\tClientID:         \"test-client\",\n\t\t\tClientSecret:     \"test-secret\",\n\t\t\tTokenEndpointURL: tokenServer.URL,\n\t\t},\n\t)\n\tr.NoError(err)\n\n\t// Constructor no longer fetches a token eagerly.\n\t// The first SetLogin triggers the adaptive probe.\n\tr.Equal(int32(0), requestCount.Load())\n\n\tloginMsg := &msg.Login{}\n\terr = provider.SetLogin(loginMsg)\n\tr.NoError(err)\n\tr.Equal(\"fresh-test-token\", loginMsg.PrivilegeKey)\n\n\tfor range 3 {\n\t\tpingMsg := &msg.Ping{}\n\t\terr = provider.SetPing(pingMsg)\n\t\tr.NoError(err)\n\t\tr.Equal(\"fresh-test-token\", pingMsg.PrivilegeKey)\n\t}\n\n\t// 1 probe (login) + 3 pings = 4 requests (probe doubles as the login token fetch)\n\tr.Equal(int32(4), requestCount.Load(), \"each call should fetch a fresh token when expires_in is missing\")\n}\n\nfunc TestOidcAuthProviderCachesToken(t *testing.T) {\n\tr := require.New(t)\n\n\tvar requestCount atomic.Int32\n\ttokenServer := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, req *http.Request) {\n\t\trequestCount.Add(1)\n\t\tw.Header().Set(\"Content-Type\", \"application/json\")\n\t\t_ = json.NewEncoder(w).Encode(map[string]any{ //nolint:gosec // test-only dummy token response\n\t\t\t\"access_token\": \"cached-test-token\",\n\t\t\t\"token_type\":   \"Bearer\",\n\t\t\t\"expires_in\":   3600,\n\t\t})\n\t}))\n\tdefer tokenServer.Close()\n\n\tprovider, err := auth.NewOidcAuthSetter(\n\t\t[]v1.AuthScope{v1.AuthScopeHeartBeats},\n\t\tv1.AuthOIDCClientConfig{\n\t\t\tClientID:         \"test-client\",\n\t\t\tClientSecret:     \"test-secret\",\n\t\t\tTokenEndpointURL: tokenServer.URL,\n\t\t},\n\t)\n\tr.NoError(err)\n\n\t// Constructor no longer fetches eagerly; first SetLogin triggers the probe.\n\tr.Equal(int32(0), requestCount.Load())\n\n\t// SetLogin triggers the adaptive probe and caches the token.\n\tloginMsg := &msg.Login{}\n\terr = provider.SetLogin(loginMsg)\n\tr.NoError(err)\n\tr.Equal(\"cached-test-token\", loginMsg.PrivilegeKey)\n\tr.Equal(int32(1), requestCount.Load())\n\n\t// Subsequent calls should also reuse the cached token\n\tfor range 5 {\n\t\tpingMsg := &msg.Ping{}\n\t\terr = provider.SetPing(pingMsg)\n\t\tr.NoError(err)\n\t\tr.Equal(\"cached-test-token\", pingMsg.PrivilegeKey)\n\t}\n\tr.Equal(int32(1), requestCount.Load(), \"token endpoint should only be called once; cached token should be reused\")\n}\n\nfunc TestOidcAuthProviderRetriesOnInitialFailure(t *testing.T) {\n\tr := require.New(t)\n\n\tvar requestCount atomic.Int32\n\ttokenServer := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, req *http.Request) {\n\t\tn := requestCount.Add(1)\n\t\t// The oauth2 library retries once internally, so we need two\n\t\t// consecutive failures to surface an error to the caller.\n\t\tif n <= 2 {\n\t\t\tw.Header().Set(\"Content-Type\", \"application/json\")\n\t\t\tw.WriteHeader(http.StatusBadRequest)\n\t\t\t_ = json.NewEncoder(w).Encode(map[string]any{\n\t\t\t\t\"error\":             \"temporarily_unavailable\",\n\t\t\t\t\"error_description\": \"service is starting up\",\n\t\t\t})\n\t\t\treturn\n\t\t}\n\t\tw.Header().Set(\"Content-Type\", \"application/json\")\n\t\t_ = json.NewEncoder(w).Encode(map[string]any{ //nolint:gosec // test-only dummy token response\n\t\t\t\"access_token\": \"retry-test-token\",\n\t\t\t\"token_type\":   \"Bearer\",\n\t\t\t\"expires_in\":   3600,\n\t\t})\n\t}))\n\tdefer tokenServer.Close()\n\n\t// Constructor succeeds even though the IdP is \"down\".\n\tprovider, err := auth.NewOidcAuthSetter(\n\t\t[]v1.AuthScope{v1.AuthScopeHeartBeats},\n\t\tv1.AuthOIDCClientConfig{\n\t\t\tClientID:         \"test-client\",\n\t\t\tClientSecret:     \"test-secret\",\n\t\t\tTokenEndpointURL: tokenServer.URL,\n\t\t},\n\t)\n\tr.NoError(err)\n\tr.Equal(int32(0), requestCount.Load())\n\n\t// First SetLogin hits the IdP, which returns an error (after internal retry).\n\tloginMsg := &msg.Login{}\n\terr = provider.SetLogin(loginMsg)\n\tr.Error(err)\n\tr.Equal(int32(2), requestCount.Load())\n\n\t// Second SetLogin retries and succeeds.\n\terr = provider.SetLogin(loginMsg)\n\tr.NoError(err)\n\tr.Equal(\"retry-test-token\", loginMsg.PrivilegeKey)\n\tr.Equal(int32(3), requestCount.Load())\n\n\t// Subsequent calls use cached token.\n\tpingMsg := &msg.Ping{}\n\terr = provider.SetPing(pingMsg)\n\tr.NoError(err)\n\tr.Equal(\"retry-test-token\", pingMsg.PrivilegeKey)\n\tr.Equal(int32(3), requestCount.Load())\n}\n\nfunc TestNewOidcAuthSetterRejectsInvalidStaticConfig(t *testing.T) {\n\tr := require.New(t)\n\ttokenServer := httptest.NewServer(http.NotFoundHandler())\n\tdefer tokenServer.Close()\n\n\t_, err := auth.NewOidcAuthSetter(nil, v1.AuthOIDCClientConfig{\n\t\tClientID:         \"test-client\",\n\t\tTokenEndpointURL: \"://bad\",\n\t})\n\tr.Error(err)\n\tr.Contains(err.Error(), \"auth.oidc.tokenEndpointURL\")\n\n\t_, err = auth.NewOidcAuthSetter(nil, v1.AuthOIDCClientConfig{\n\t\tTokenEndpointURL: tokenServer.URL,\n\t})\n\tr.Error(err)\n\tr.Contains(err.Error(), \"auth.oidc.clientID is required\")\n\n\t_, err = auth.NewOidcAuthSetter(nil, v1.AuthOIDCClientConfig{\n\t\tClientID:         \"test-client\",\n\t\tTokenEndpointURL: tokenServer.URL,\n\t\tAdditionalEndpointParams: map[string]string{\n\t\t\t\"scope\": \"profile\",\n\t\t},\n\t})\n\tr.Error(err)\n\tr.Contains(err.Error(), \"auth.oidc.additionalEndpointParams.scope is not allowed; use auth.oidc.scope instead\")\n\n\t_, err = auth.NewOidcAuthSetter(nil, v1.AuthOIDCClientConfig{\n\t\tClientID:                 \"test-client\",\n\t\tTokenEndpointURL:         tokenServer.URL,\n\t\tAudience:                 \"api\",\n\t\tAdditionalEndpointParams: map[string]string{\"audience\": \"override\"},\n\t})\n\tr.Error(err)\n\tr.Contains(err.Error(), \"cannot specify both auth.oidc.audience and auth.oidc.additionalEndpointParams.audience\")\n}\n"
  },
  {
    "path": "pkg/auth/pass.go",
    "content": "// Copyright 2023 The frp Authors\n//\n// Licensed under the Apache License, Version 2.0 (the \"License\");\n// you may not use this file except in compliance with the License.\n// You may obtain a copy of the License at\n//\n//     http://www.apache.org/licenses/LICENSE-2.0\n//\n// Unless required by applicable law or agreed to in writing, software\n// distributed under the License is distributed on an \"AS IS\" BASIS,\n// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.\n// See the License for the specific language governing permissions and\n// limitations under the License.\n\npackage auth\n\nimport (\n\t\"github.com/fatedier/frp/pkg/msg\"\n)\n\nvar AlwaysPassVerifier = &alwaysPass{}\n\nvar _ Verifier = &alwaysPass{}\n\ntype alwaysPass struct{}\n\nfunc (*alwaysPass) VerifyLogin(*msg.Login) error { return nil }\n\nfunc (*alwaysPass) VerifyPing(*msg.Ping) error { return nil }\n\nfunc (*alwaysPass) VerifyNewWorkConn(*msg.NewWorkConn) error { return nil }\n"
  },
  {
    "path": "pkg/auth/token.go",
    "content": "// Copyright 2020 guylewin, guy@lewin.co.il\n//\n// Licensed under the Apache License, Version 2.0 (the \"License\");\n// you may not use this file except in compliance with the License.\n// You may obtain a copy of the License at\n//\n//     http://www.apache.org/licenses/LICENSE-2.0\n//\n// Unless required by applicable law or agreed to in writing, software\n// distributed under the License is distributed on an \"AS IS\" BASIS,\n// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.\n// See the License for the specific language governing permissions and\n// limitations under the License.\n\npackage auth\n\nimport (\n\t\"fmt\"\n\t\"slices\"\n\t\"time\"\n\n\tv1 \"github.com/fatedier/frp/pkg/config/v1\"\n\t\"github.com/fatedier/frp/pkg/msg\"\n\t\"github.com/fatedier/frp/pkg/util/util\"\n)\n\ntype TokenAuthSetterVerifier struct {\n\tadditionalAuthScopes []v1.AuthScope\n\ttoken                string\n}\n\nfunc NewTokenAuth(additionalAuthScopes []v1.AuthScope, token string) *TokenAuthSetterVerifier {\n\treturn &TokenAuthSetterVerifier{\n\t\tadditionalAuthScopes: additionalAuthScopes,\n\t\ttoken:                token,\n\t}\n}\n\nfunc (auth *TokenAuthSetterVerifier) SetLogin(loginMsg *msg.Login) error {\n\tloginMsg.PrivilegeKey = util.GetAuthKey(auth.token, loginMsg.Timestamp)\n\treturn nil\n}\n\nfunc (auth *TokenAuthSetterVerifier) SetPing(pingMsg *msg.Ping) error {\n\tif !slices.Contains(auth.additionalAuthScopes, v1.AuthScopeHeartBeats) {\n\t\treturn nil\n\t}\n\n\tpingMsg.Timestamp = time.Now().Unix()\n\tpingMsg.PrivilegeKey = util.GetAuthKey(auth.token, pingMsg.Timestamp)\n\treturn nil\n}\n\nfunc (auth *TokenAuthSetterVerifier) SetNewWorkConn(newWorkConnMsg *msg.NewWorkConn) error {\n\tif !slices.Contains(auth.additionalAuthScopes, v1.AuthScopeNewWorkConns) {\n\t\treturn nil\n\t}\n\n\tnewWorkConnMsg.Timestamp = time.Now().Unix()\n\tnewWorkConnMsg.PrivilegeKey = util.GetAuthKey(auth.token, newWorkConnMsg.Timestamp)\n\treturn nil\n}\n\nfunc (auth *TokenAuthSetterVerifier) VerifyLogin(m *msg.Login) error {\n\tif !util.ConstantTimeEqString(util.GetAuthKey(auth.token, m.Timestamp), m.PrivilegeKey) {\n\t\treturn fmt.Errorf(\"token in login doesn't match token from configuration\")\n\t}\n\treturn nil\n}\n\nfunc (auth *TokenAuthSetterVerifier) VerifyPing(m *msg.Ping) error {\n\tif !slices.Contains(auth.additionalAuthScopes, v1.AuthScopeHeartBeats) {\n\t\treturn nil\n\t}\n\n\tif !util.ConstantTimeEqString(util.GetAuthKey(auth.token, m.Timestamp), m.PrivilegeKey) {\n\t\treturn fmt.Errorf(\"token in heartbeat doesn't match token from configuration\")\n\t}\n\treturn nil\n}\n\nfunc (auth *TokenAuthSetterVerifier) VerifyNewWorkConn(m *msg.NewWorkConn) error {\n\tif !slices.Contains(auth.additionalAuthScopes, v1.AuthScopeNewWorkConns) {\n\t\treturn nil\n\t}\n\n\tif !util.ConstantTimeEqString(util.GetAuthKey(auth.token, m.Timestamp), m.PrivilegeKey) {\n\t\treturn fmt.Errorf(\"token in NewWorkConn doesn't match token from configuration\")\n\t}\n\treturn nil\n}\n"
  },
  {
    "path": "pkg/config/flags.go",
    "content": "// Copyright 2023 The frp Authors\n//\n// Licensed under the Apache License, Version 2.0 (the \"License\");\n// you may not use this file except in compliance with the License.\n// You may obtain a copy of the License at\n//\n//     http://www.apache.org/licenses/LICENSE-2.0\n//\n// Unless required by applicable law or agreed to in writing, software\n// distributed under the License is distributed on an \"AS IS\" BASIS,\n// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.\n// See the License for the specific language governing permissions and\n// limitations under the License.\n\npackage config\n\nimport (\n\t\"fmt\"\n\t\"strconv\"\n\t\"strings\"\n\n\t\"github.com/spf13/cobra\"\n\t\"github.com/spf13/pflag\"\n\n\t\"github.com/fatedier/frp/pkg/config/types\"\n\tv1 \"github.com/fatedier/frp/pkg/config/v1\"\n\t\"github.com/fatedier/frp/pkg/config/v1/validation\"\n)\n\n// WordSepNormalizeFunc changes all flags that contain \"_\" separators\nfunc WordSepNormalizeFunc(f *pflag.FlagSet, name string) pflag.NormalizedName {\n\tif strings.Contains(name, \"_\") {\n\t\treturn pflag.NormalizedName(strings.ReplaceAll(name, \"_\", \"-\"))\n\t}\n\treturn pflag.NormalizedName(name)\n}\n\ntype RegisterFlagOption func(*registerFlagOptions)\n\ntype registerFlagOptions struct {\n\tsshMode bool\n}\n\nfunc WithSSHMode() RegisterFlagOption {\n\treturn func(o *registerFlagOptions) {\n\t\to.sshMode = true\n\t}\n}\n\ntype BandwidthQuantityFlag struct {\n\tV *types.BandwidthQuantity\n}\n\nfunc (f *BandwidthQuantityFlag) Set(s string) error {\n\treturn f.V.UnmarshalString(s)\n}\n\nfunc (f *BandwidthQuantityFlag) String() string {\n\treturn f.V.String()\n}\n\nfunc (f *BandwidthQuantityFlag) Type() string {\n\treturn \"string\"\n}\n\nfunc RegisterProxyFlags(cmd *cobra.Command, c v1.ProxyConfigurer, opts ...RegisterFlagOption) {\n\tregisterProxyBaseConfigFlags(cmd, c.GetBaseConfig(), opts...)\n\n\tswitch cc := c.(type) {\n\tcase *v1.TCPProxyConfig:\n\t\tcmd.Flags().IntVarP(&cc.RemotePort, \"remote_port\", \"r\", 0, \"remote port\")\n\tcase *v1.UDPProxyConfig:\n\t\tcmd.Flags().IntVarP(&cc.RemotePort, \"remote_port\", \"r\", 0, \"remote port\")\n\tcase *v1.HTTPProxyConfig:\n\t\tregisterProxyDomainConfigFlags(cmd, &cc.DomainConfig)\n\t\tcmd.Flags().StringSliceVarP(&cc.Locations, \"locations\", \"\", []string{}, \"locations\")\n\t\tcmd.Flags().StringVarP(&cc.HTTPUser, \"http_user\", \"\", \"\", \"http auth user\")\n\t\tcmd.Flags().StringVarP(&cc.HTTPPassword, \"http_pwd\", \"\", \"\", \"http auth password\")\n\t\tcmd.Flags().StringVarP(&cc.HostHeaderRewrite, \"host_header_rewrite\", \"\", \"\", \"host header rewrite\")\n\tcase *v1.HTTPSProxyConfig:\n\t\tregisterProxyDomainConfigFlags(cmd, &cc.DomainConfig)\n\tcase *v1.TCPMuxProxyConfig:\n\t\tregisterProxyDomainConfigFlags(cmd, &cc.DomainConfig)\n\t\tcmd.Flags().StringVarP(&cc.Multiplexer, \"mux\", \"\", \"\", \"multiplexer\")\n\t\tcmd.Flags().StringVarP(&cc.HTTPUser, \"http_user\", \"\", \"\", \"http auth user\")\n\t\tcmd.Flags().StringVarP(&cc.HTTPPassword, \"http_pwd\", \"\", \"\", \"http auth password\")\n\tcase *v1.STCPProxyConfig:\n\t\tcmd.Flags().StringVarP(&cc.Secretkey, \"sk\", \"\", \"\", \"secret key\")\n\t\tcmd.Flags().StringSliceVarP(&cc.AllowUsers, \"allow_users\", \"\", []string{}, \"allow visitor users\")\n\tcase *v1.SUDPProxyConfig:\n\t\tcmd.Flags().StringVarP(&cc.Secretkey, \"sk\", \"\", \"\", \"secret key\")\n\t\tcmd.Flags().StringSliceVarP(&cc.AllowUsers, \"allow_users\", \"\", []string{}, \"allow visitor users\")\n\tcase *v1.XTCPProxyConfig:\n\t\tcmd.Flags().StringVarP(&cc.Secretkey, \"sk\", \"\", \"\", \"secret key\")\n\t\tcmd.Flags().StringSliceVarP(&cc.AllowUsers, \"allow_users\", \"\", []string{}, \"allow visitor users\")\n\t}\n}\n\nfunc registerProxyBaseConfigFlags(cmd *cobra.Command, c *v1.ProxyBaseConfig, opts ...RegisterFlagOption) {\n\tif c == nil {\n\t\treturn\n\t}\n\toptions := &registerFlagOptions{}\n\tfor _, opt := range opts {\n\t\topt(options)\n\t}\n\n\tcmd.Flags().StringVarP(&c.Name, \"proxy_name\", \"n\", \"\", \"proxy name\")\n\tcmd.Flags().StringToStringVarP(&c.Metadatas, \"metadatas\", \"\", nil, \"metadata key-value pairs (e.g., key1=value1,key2=value2)\")\n\tcmd.Flags().StringToStringVarP(&c.Annotations, \"annotations\", \"\", nil, \"annotation key-value pairs (e.g., key1=value1,key2=value2)\")\n\n\tif !options.sshMode {\n\t\tcmd.Flags().StringVarP(&c.LocalIP, \"local_ip\", \"i\", \"127.0.0.1\", \"local ip\")\n\t\tcmd.Flags().IntVarP(&c.LocalPort, \"local_port\", \"l\", 0, \"local port\")\n\t\tcmd.Flags().BoolVarP(&c.Transport.UseEncryption, \"ue\", \"\", false, \"use encryption\")\n\t\tcmd.Flags().BoolVarP(&c.Transport.UseCompression, \"uc\", \"\", false, \"use compression\")\n\t\tcmd.Flags().StringVarP(&c.Transport.BandwidthLimitMode, \"bandwidth_limit_mode\", \"\", types.BandwidthLimitModeClient, \"bandwidth limit mode\")\n\t\tcmd.Flags().VarP(&BandwidthQuantityFlag{V: &c.Transport.BandwidthLimit}, \"bandwidth_limit\", \"\", \"bandwidth limit (e.g. 100KB or 1MB)\")\n\t}\n}\n\nfunc registerProxyDomainConfigFlags(cmd *cobra.Command, c *v1.DomainConfig) {\n\tif c == nil {\n\t\treturn\n\t}\n\tcmd.Flags().StringSliceVarP(&c.CustomDomains, \"custom_domain\", \"d\", []string{}, \"custom domains\")\n\tcmd.Flags().StringVarP(&c.SubDomain, \"sd\", \"\", \"\", \"sub domain\")\n}\n\nfunc RegisterVisitorFlags(cmd *cobra.Command, c v1.VisitorConfigurer, opts ...RegisterFlagOption) {\n\tregisterVisitorBaseConfigFlags(cmd, c.GetBaseConfig(), opts...)\n\n\t// add visitor flags if exist\n}\n\nfunc registerVisitorBaseConfigFlags(cmd *cobra.Command, c *v1.VisitorBaseConfig, _ ...RegisterFlagOption) {\n\tif c == nil {\n\t\treturn\n\t}\n\tcmd.Flags().StringVarP(&c.Name, \"visitor_name\", \"n\", \"\", \"visitor name\")\n\tcmd.Flags().BoolVarP(&c.Transport.UseEncryption, \"ue\", \"\", false, \"use encryption\")\n\tcmd.Flags().BoolVarP(&c.Transport.UseCompression, \"uc\", \"\", false, \"use compression\")\n\tcmd.Flags().StringVarP(&c.SecretKey, \"sk\", \"\", \"\", \"secret key\")\n\tcmd.Flags().StringVarP(&c.ServerName, \"server_name\", \"\", \"\", \"server name\")\n\tcmd.Flags().StringVarP(&c.ServerUser, \"server-user\", \"\", \"\", \"server user\")\n\tcmd.Flags().StringVarP(&c.BindAddr, \"bind_addr\", \"\", \"\", \"bind addr\")\n\tcmd.Flags().IntVarP(&c.BindPort, \"bind_port\", \"\", 0, \"bind port\")\n}\n\nfunc RegisterClientCommonConfigFlags(cmd *cobra.Command, c *v1.ClientCommonConfig, opts ...RegisterFlagOption) {\n\toptions := &registerFlagOptions{}\n\tfor _, opt := range opts {\n\t\topt(options)\n\t}\n\n\tif !options.sshMode {\n\t\tcmd.PersistentFlags().StringVarP(&c.ServerAddr, \"server_addr\", \"s\", \"127.0.0.1\", \"frp server's address\")\n\t\tcmd.PersistentFlags().IntVarP(&c.ServerPort, \"server_port\", \"P\", 7000, \"frp server's port\")\n\t\tcmd.PersistentFlags().StringVarP(&c.Transport.Protocol, \"protocol\", \"p\", \"tcp\",\n\t\t\tfmt.Sprintf(\"optional values are %v\", validation.SupportedTransportProtocols))\n\t\tcmd.PersistentFlags().StringVarP(&c.Log.Level, \"log_level\", \"\", \"info\", \"log level\")\n\t\tcmd.PersistentFlags().StringVarP(&c.Log.To, \"log_file\", \"\", \"console\", \"console or file path\")\n\t\tcmd.PersistentFlags().Int64VarP(&c.Log.MaxDays, \"log_max_days\", \"\", 3, \"log file reversed days\")\n\t\tcmd.PersistentFlags().BoolVarP(&c.Log.DisablePrintColor, \"disable_log_color\", \"\", false, \"disable log color in console\")\n\t\tcmd.PersistentFlags().StringVarP(&c.Transport.TLS.ServerName, \"tls_server_name\", \"\", \"\", \"specify the custom server name of tls certificate\")\n\t\tcmd.PersistentFlags().StringVarP(&c.DNSServer, \"dns_server\", \"\", \"\", \"specify dns server instead of using system default one\")\n\t\tc.Transport.TLS.Enable = cmd.PersistentFlags().BoolP(\"tls_enable\", \"\", true, \"enable frpc tls\")\n\t}\n\tcmd.PersistentFlags().StringVarP(&c.User, \"user\", \"u\", \"\", \"user\")\n\tcmd.PersistentFlags().StringVar(&c.ClientID, \"client-id\", \"\", \"unique identifier for this frpc instance\")\n\tcmd.PersistentFlags().StringVarP(&c.Auth.Token, \"token\", \"t\", \"\", \"auth token\")\n}\n\ntype PortsRangeSliceFlag struct {\n\tV *[]types.PortsRange\n}\n\nfunc (f *PortsRangeSliceFlag) String() string {\n\tif f.V == nil {\n\t\treturn \"\"\n\t}\n\treturn types.PortsRangeSlice(*f.V).String()\n}\n\nfunc (f *PortsRangeSliceFlag) Set(s string) error {\n\tslice, err := types.NewPortsRangeSliceFromString(s)\n\tif err != nil {\n\t\treturn err\n\t}\n\t*f.V = slice\n\treturn nil\n}\n\nfunc (f *PortsRangeSliceFlag) Type() string {\n\treturn \"string\"\n}\n\ntype BoolFuncFlag struct {\n\tTrueFunc  func()\n\tFalseFunc func()\n\n\tv bool\n}\n\nfunc (f *BoolFuncFlag) String() string {\n\treturn strconv.FormatBool(f.v)\n}\n\nfunc (f *BoolFuncFlag) Set(s string) error {\n\tf.v = strconv.FormatBool(f.v) == \"true\"\n\n\tif !f.v {\n\t\tif f.FalseFunc != nil {\n\t\t\tf.FalseFunc()\n\t\t}\n\t\treturn nil\n\t}\n\n\tif f.TrueFunc != nil {\n\t\tf.TrueFunc()\n\t}\n\treturn nil\n}\n\nfunc (f *BoolFuncFlag) Type() string {\n\treturn \"bool\"\n}\n\nfunc RegisterServerConfigFlags(cmd *cobra.Command, c *v1.ServerConfig, opts ...RegisterFlagOption) {\n\tcmd.PersistentFlags().StringVarP(&c.BindAddr, \"bind_addr\", \"\", \"0.0.0.0\", \"bind address\")\n\tcmd.PersistentFlags().IntVarP(&c.BindPort, \"bind_port\", \"p\", 7000, \"bind port\")\n\tcmd.PersistentFlags().IntVarP(&c.KCPBindPort, \"kcp_bind_port\", \"\", 0, \"kcp bind udp port\")\n\tcmd.PersistentFlags().IntVarP(&c.QUICBindPort, \"quic_bind_port\", \"\", 0, \"quic bind udp port\")\n\tcmd.PersistentFlags().StringVarP(&c.ProxyBindAddr, \"proxy_bind_addr\", \"\", \"0.0.0.0\", \"proxy bind address\")\n\tcmd.PersistentFlags().IntVarP(&c.VhostHTTPPort, \"vhost_http_port\", \"\", 0, \"vhost http port\")\n\tcmd.PersistentFlags().IntVarP(&c.VhostHTTPSPort, \"vhost_https_port\", \"\", 0, \"vhost https port\")\n\tcmd.PersistentFlags().Int64VarP(&c.VhostHTTPTimeout, \"vhost_http_timeout\", \"\", 60, \"vhost http response header timeout\")\n\tcmd.PersistentFlags().StringVarP(&c.WebServer.Addr, \"dashboard_addr\", \"\", \"0.0.0.0\", \"dashboard address\")\n\tcmd.PersistentFlags().IntVarP(&c.WebServer.Port, \"dashboard_port\", \"\", 0, \"dashboard port\")\n\tcmd.PersistentFlags().StringVarP(&c.WebServer.User, \"dashboard_user\", \"\", \"admin\", \"dashboard user\")\n\tcmd.PersistentFlags().StringVarP(&c.WebServer.Password, \"dashboard_pwd\", \"\", \"admin\", \"dashboard password\")\n\tcmd.PersistentFlags().BoolVarP(&c.EnablePrometheus, \"enable_prometheus\", \"\", false, \"enable prometheus dashboard\")\n\tcmd.PersistentFlags().StringVarP(&c.Log.To, \"log_file\", \"\", \"console\", \"log file\")\n\tcmd.PersistentFlags().StringVarP(&c.Log.Level, \"log_level\", \"\", \"info\", \"log level\")\n\tcmd.PersistentFlags().Int64VarP(&c.Log.MaxDays, \"log_max_days\", \"\", 3, \"log max days\")\n\tcmd.PersistentFlags().BoolVarP(&c.Log.DisablePrintColor, \"disable_log_color\", \"\", false, \"disable log color in console\")\n\tcmd.PersistentFlags().StringVarP(&c.Auth.Token, \"token\", \"t\", \"\", \"auth token\")\n\tcmd.PersistentFlags().StringVarP(&c.SubDomainHost, \"subdomain_host\", \"\", \"\", \"subdomain host\")\n\tcmd.PersistentFlags().VarP(&PortsRangeSliceFlag{V: &c.AllowPorts}, \"allow_ports\", \"\", \"allow ports\")\n\tcmd.PersistentFlags().Int64VarP(&c.MaxPortsPerClient, \"max_ports_per_client\", \"\", 0, \"max ports per client\")\n\tcmd.PersistentFlags().BoolVarP(&c.Transport.TLS.Force, \"tls_only\", \"\", false, \"frps tls only\")\n\n\twebServerTLS := v1.TLSConfig{}\n\tcmd.PersistentFlags().StringVarP(&webServerTLS.CertFile, \"dashboard_tls_cert_file\", \"\", \"\", \"dashboard tls cert file\")\n\tcmd.PersistentFlags().StringVarP(&webServerTLS.KeyFile, \"dashboard_tls_key_file\", \"\", \"\", \"dashboard tls key file\")\n\tcmd.PersistentFlags().VarP(&BoolFuncFlag{\n\t\tTrueFunc: func() { c.WebServer.TLS = &webServerTLS },\n\t}, \"dashboard_tls_mode\", \"\", \"if enable dashboard tls mode\")\n}\n"
  },
  {
    "path": "pkg/config/legacy/README.md",
    "content": "So far, there is no mature Go project that does well in parsing `*.ini` files. \n\nBy comparison, we have selected an open source project: `https://github.com/go-ini/ini`.\n\nThis library helped us solve most of the key-value matching, but there are still some problems, such as not supporting parsing `map`.\n\nWe add our own logic on the basis of this library. In the current situationwhich, we need to complete the entire `Unmarshal` in two steps:\n\n* Step#1, use `go-ini` to complete the basic parameter matching;\n* Step#2, parse our custom parameters to realize parsing special structure, like `map`, `array`.\n\nSome 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."
  },
  {
    "path": "pkg/config/legacy/client.go",
    "content": "// Copyright 2023 The frp Authors\n//\n// Licensed under the Apache License, Version 2.0 (the \"License\");\n// you may not use this file except in compliance with the License.\n// You may obtain a copy of the License at\n//\n//     http://www.apache.org/licenses/LICENSE-2.0\n//\n// Unless required by applicable law or agreed to in writing, software\n// distributed under the License is distributed on an \"AS IS\" BASIS,\n// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.\n// See the License for the specific language governing permissions and\n// limitations under the License.\n\npackage legacy\n\nimport (\n\t\"fmt\"\n\t\"os\"\n\t\"path/filepath\"\n\t\"slices\"\n\t\"strings\"\n\n\t\"gopkg.in/ini.v1\"\n\n\tlegacyauth \"github.com/fatedier/frp/pkg/auth/legacy\"\n\t\"github.com/fatedier/frp/pkg/util/util\"\n)\n\n// ClientCommonConf is the configuration parsed from ini.\n// It contains information for a client service. It is\n// recommended to use GetDefaultClientConf instead of creating this object\n// directly, so that all unspecified fields have reasonable default values.\ntype ClientCommonConf struct {\n\tlegacyauth.ClientConfig `ini:\",extends\"`\n\n\t// ServerAddr specifies the address of the server to connect to. By\n\t// default, this value is \"0.0.0.0\".\n\tServerAddr string `ini:\"server_addr\" json:\"server_addr\"`\n\t// ServerPort specifies the port to connect to the server on. By default,\n\t// this value is 7000.\n\tServerPort int `ini:\"server_port\" json:\"server_port\"`\n\t// STUN server to help penetrate NAT hole.\n\tNatHoleSTUNServer string `ini:\"nat_hole_stun_server\" json:\"nat_hole_stun_server\"`\n\t// The maximum amount of time a dial to server will wait for a connect to complete.\n\tDialServerTimeout int64 `ini:\"dial_server_timeout\" json:\"dial_server_timeout\"`\n\t// DialServerKeepAlive specifies the interval between keep-alive probes for an active network connection between frpc and frps.\n\t// If negative, keep-alive probes are disabled.\n\tDialServerKeepAlive int64 `ini:\"dial_server_keepalive\" json:\"dial_server_keepalive\"`\n\t// ConnectServerLocalIP specifies the address of the client bind when it connect to server.\n\t// By default, this value is empty.\n\t// this value only use in TCP/Websocket protocol. Not support in KCP protocol.\n\tConnectServerLocalIP string `ini:\"connect_server_local_ip\" json:\"connect_server_local_ip\"`\n\t// HTTPProxy specifies a proxy address to connect to the server through. If\n\t// this value is \"\", the server will be connected to directly. By default,\n\t// this value is read from the \"http_proxy\" environment variable.\n\tHTTPProxy string `ini:\"http_proxy\" json:\"http_proxy\"`\n\t// LogFile specifies a file where logs will be written to. This value will\n\t// only be used if LogWay is set appropriately. By default, this value is\n\t// \"console\".\n\tLogFile string `ini:\"log_file\" json:\"log_file\"`\n\t// LogWay specifies the way logging is managed. Valid values are \"console\"\n\t// or \"file\". If \"console\" is used, logs will be printed to stdout. If\n\t// \"file\" is used, logs will be printed to LogFile. By default, this value\n\t// is \"console\".\n\tLogWay string `ini:\"log_way\" json:\"log_way\"`\n\t// LogLevel specifies the minimum log level. Valid values are \"trace\",\n\t// \"debug\", \"info\", \"warn\", and \"error\". By default, this value is \"info\".\n\tLogLevel string `ini:\"log_level\" json:\"log_level\"`\n\t// LogMaxDays specifies the maximum number of days to store log information\n\t// before deletion. This is only used if LogWay == \"file\". By default, this\n\t// value is 0.\n\tLogMaxDays int64 `ini:\"log_max_days\" json:\"log_max_days\"`\n\t// DisableLogColor disables log colors when LogWay == \"console\" when set to\n\t// true. By default, this value is false.\n\tDisableLogColor bool `ini:\"disable_log_color\" json:\"disable_log_color\"`\n\t// AdminAddr specifies the address that the admin server binds to. By\n\t// default, this value is \"127.0.0.1\".\n\tAdminAddr string `ini:\"admin_addr\" json:\"admin_addr\"`\n\t// AdminPort specifies the port for the admin server to listen on. If this\n\t// value is 0, the admin server will not be started. By default, this value\n\t// is 0.\n\tAdminPort int `ini:\"admin_port\" json:\"admin_port\"`\n\t// AdminUser specifies the username that the admin server will use for\n\t// login.\n\tAdminUser string `ini:\"admin_user\" json:\"admin_user\"`\n\t// AdminPwd specifies the password that the admin server will use for\n\t// login.\n\tAdminPwd string `ini:\"admin_pwd\" json:\"admin_pwd\"`\n\t// AssetsDir specifies the local directory that the admin server will load\n\t// resources from. If this value is \"\", assets will be loaded from the\n\t// bundled executable using statik. By default, this value is \"\".\n\tAssetsDir string `ini:\"assets_dir\" json:\"assets_dir\"`\n\t// PoolCount specifies the number of connections the client will make to\n\t// the server in advance. By default, this value is 0.\n\tPoolCount int `ini:\"pool_count\" json:\"pool_count\"`\n\t// TCPMux toggles TCP stream multiplexing. This allows multiple requests\n\t// from a client to share a single TCP connection. If this value is true,\n\t// the server must have TCP multiplexing enabled as well. By default, this\n\t// value is true.\n\tTCPMux bool `ini:\"tcp_mux\" json:\"tcp_mux\"`\n\t// TCPMuxKeepaliveInterval specifies the keep alive interval for TCP stream multiplier.\n\t// If TCPMux is true, heartbeat of application layer is unnecessary because it can only rely on heartbeat in TCPMux.\n\tTCPMuxKeepaliveInterval int64 `ini:\"tcp_mux_keepalive_interval\" json:\"tcp_mux_keepalive_interval\"`\n\t// User specifies a prefix for proxy names to distinguish them from other\n\t// clients. If this value is not \"\", proxy names will automatically be\n\t// changed to \"{user}.{proxy_name}\". By default, this value is \"\".\n\tUser string `ini:\"user\" json:\"user\"`\n\t// DNSServer specifies a DNS server address for FRPC to use. If this value\n\t// is \"\", the default DNS will be used. By default, this value is \"\".\n\tDNSServer string `ini:\"dns_server\" json:\"dns_server\"`\n\t// LoginFailExit controls whether or not the client should exit after a\n\t// failed login attempt. If false, the client will retry until a login\n\t// attempt succeeds. By default, this value is true.\n\tLoginFailExit bool `ini:\"login_fail_exit\" json:\"login_fail_exit\"`\n\t// Start specifies a set of enabled proxies by name. If this set is empty,\n\t// all supplied proxies are enabled. By default, this value is an empty\n\t// set.\n\tStart []string `ini:\"start\" json:\"start\"`\n\t// Start map[string]struct{} `json:\"start\"`\n\t// Protocol specifies the protocol to use when interacting with the server.\n\t// Valid values are \"tcp\", \"kcp\", \"quic\", \"websocket\" and \"wss\". By default, this value\n\t// is \"tcp\".\n\tProtocol string `ini:\"protocol\" json:\"protocol\"`\n\t// QUIC protocol options\n\tQUICKeepalivePeriod    int `ini:\"quic_keepalive_period\" json:\"quic_keepalive_period\"`\n\tQUICMaxIdleTimeout     int `ini:\"quic_max_idle_timeout\" json:\"quic_max_idle_timeout\"`\n\tQUICMaxIncomingStreams int `ini:\"quic_max_incoming_streams\" json:\"quic_max_incoming_streams\"`\n\t// TLSEnable specifies whether or not TLS should be used when communicating\n\t// with the server. If \"tls_cert_file\" and \"tls_key_file\" are valid,\n\t// client will load the supplied tls configuration.\n\t// Since v0.50.0, the default value has been changed to true, and tls is enabled by default.\n\tTLSEnable bool `ini:\"tls_enable\" json:\"tls_enable\"`\n\t// TLSCertPath specifies the path of the cert file that client will\n\t// load. It only works when \"tls_enable\" is true and \"tls_key_file\" is valid.\n\tTLSCertFile string `ini:\"tls_cert_file\" json:\"tls_cert_file\"`\n\t// TLSKeyPath specifies the path of the secret key file that client\n\t// will load. It only works when \"tls_enable\" is true and \"tls_cert_file\"\n\t// are valid.\n\tTLSKeyFile string `ini:\"tls_key_file\" json:\"tls_key_file\"`\n\t// TLSTrustedCaFile specifies the path of the trusted ca file that will load.\n\t// It only works when \"tls_enable\" is valid and tls configuration of server\n\t// has been specified.\n\tTLSTrustedCaFile string `ini:\"tls_trusted_ca_file\" json:\"tls_trusted_ca_file\"`\n\t// TLSServerName specifies the custom server name of tls certificate. By\n\t// default, server name if same to ServerAddr.\n\tTLSServerName string `ini:\"tls_server_name\" json:\"tls_server_name\"`\n\t// If the disable_custom_tls_first_byte is set to false, frpc will establish a connection with frps using the\n\t// first custom byte when tls is enabled.\n\t// Since v0.50.0, the default value has been changed to true, and the first custom byte is disabled by default.\n\tDisableCustomTLSFirstByte bool `ini:\"disable_custom_tls_first_byte\" json:\"disable_custom_tls_first_byte\"`\n\t// HeartBeatInterval specifies at what interval heartbeats are sent to the\n\t// server, in seconds. It is not recommended to change this value. By\n\t// default, this value is 30. Set negative value to disable it.\n\tHeartbeatInterval int64 `ini:\"heartbeat_interval\" json:\"heartbeat_interval\"`\n\t// HeartBeatTimeout specifies the maximum allowed heartbeat response delay\n\t// before the connection is terminated, in seconds. It is not recommended\n\t// to change this value. By default, this value is 90. Set negative value to disable it.\n\tHeartbeatTimeout int64 `ini:\"heartbeat_timeout\" json:\"heartbeat_timeout\"`\n\t// Client meta info\n\tMetas map[string]string `ini:\"-\" json:\"metas\"`\n\t// UDPPacketSize specifies the udp packet size\n\t// By default, this value is 1500\n\tUDPPacketSize int64 `ini:\"udp_packet_size\" json:\"udp_packet_size\"`\n\t// Include other config files for proxies.\n\tIncludeConfigFiles []string `ini:\"includes\" json:\"includes\"`\n\t// Enable golang pprof handlers in admin listener.\n\t// Admin port must be set first.\n\tPprofEnable bool `ini:\"pprof_enable\" json:\"pprof_enable\"`\n}\n\n// Supported sources including: string(file path), []byte, Reader interface.\nfunc UnmarshalClientConfFromIni(source any) (ClientCommonConf, error) {\n\tf, err := ini.LoadSources(ini.LoadOptions{\n\t\tInsensitive:         false,\n\t\tInsensitiveSections: false,\n\t\tInsensitiveKeys:     false,\n\t\tIgnoreInlineComment: true,\n\t\tAllowBooleanKeys:    true,\n\t}, source)\n\tif err != nil {\n\t\treturn ClientCommonConf{}, err\n\t}\n\n\ts, err := f.GetSection(\"common\")\n\tif err != nil {\n\t\treturn ClientCommonConf{}, fmt.Errorf(\"invalid configuration file, not found [common] section\")\n\t}\n\n\tcommon := GetDefaultClientConf()\n\terr = s.MapTo(&common)\n\tif err != nil {\n\t\treturn ClientCommonConf{}, err\n\t}\n\n\tcommon.Metas = GetMapWithoutPrefix(s.KeysHash(), \"meta_\")\n\tcommon.OidcAdditionalEndpointParams = GetMapWithoutPrefix(s.KeysHash(), \"oidc_additional_\")\n\n\treturn common, nil\n}\n\n// if len(startProxy) is 0, start all\n// otherwise just start proxies in startProxy map\nfunc LoadAllProxyConfsFromIni(\n\tprefix string,\n\tsource any,\n\tstart []string,\n) (map[string]ProxyConf, map[string]VisitorConf, error) {\n\tf, err := ini.LoadSources(ini.LoadOptions{\n\t\tInsensitive:         false,\n\t\tInsensitiveSections: false,\n\t\tInsensitiveKeys:     false,\n\t\tIgnoreInlineComment: true,\n\t\tAllowBooleanKeys:    true,\n\t}, source)\n\tif err != nil {\n\t\treturn nil, nil, err\n\t}\n\n\tproxyConfs := make(map[string]ProxyConf)\n\tvisitorConfs := make(map[string]VisitorConf)\n\n\tif prefix != \"\" {\n\t\tprefix += \".\"\n\t}\n\n\tstartProxy := make(map[string]struct{})\n\tfor _, s := range start {\n\t\tstartProxy[s] = struct{}{}\n\t}\n\n\tstartAll := len(startProxy) == 0\n\n\t// Build template sections from range section And append to ini.File.\n\trangeSections := make([]*ini.Section, 0)\n\tfor _, section := range f.Sections() {\n\n\t\tif !strings.HasPrefix(section.Name(), \"range:\") {\n\t\t\tcontinue\n\t\t}\n\n\t\trangeSections = append(rangeSections, section)\n\t}\n\n\tfor _, section := range rangeSections {\n\t\terr = renderRangeProxyTemplates(f, section)\n\t\tif err != nil {\n\t\t\treturn nil, nil, fmt.Errorf(\"failed to render template for proxy %s: %v\", section.Name(), err)\n\t\t}\n\t}\n\n\tfor _, section := range f.Sections() {\n\t\tname := section.Name()\n\n\t\tif name == ini.DefaultSection || name == \"common\" || strings.HasPrefix(name, \"range:\") {\n\t\t\tcontinue\n\t\t}\n\n\t\t_, shouldStart := startProxy[name]\n\t\tif !startAll && !shouldStart {\n\t\t\tcontinue\n\t\t}\n\n\t\troleType := section.Key(\"role\").String()\n\t\tif roleType == \"\" {\n\t\t\troleType = \"server\"\n\t\t}\n\n\t\tswitch roleType {\n\t\tcase \"server\":\n\t\t\tnewConf, newErr := NewProxyConfFromIni(prefix, name, section)\n\t\t\tif newErr != nil {\n\t\t\t\treturn nil, nil, fmt.Errorf(\"failed to parse proxy %s, err: %v\", name, newErr)\n\t\t\t}\n\t\t\tproxyConfs[prefix+name] = newConf\n\t\tcase \"visitor\":\n\t\t\tnewConf, newErr := NewVisitorConfFromIni(prefix, name, section)\n\t\t\tif newErr != nil {\n\t\t\t\treturn nil, nil, fmt.Errorf(\"failed to parse visitor %s, err: %v\", name, newErr)\n\t\t\t}\n\t\t\tvisitorConfs[prefix+name] = newConf\n\t\tdefault:\n\t\t\treturn nil, nil, fmt.Errorf(\"proxy %s role should be 'server' or 'visitor'\", name)\n\t\t}\n\t}\n\treturn proxyConfs, visitorConfs, nil\n}\n\nfunc renderRangeProxyTemplates(f *ini.File, section *ini.Section) error {\n\t// Validation\n\tlocalPortStr := section.Key(\"local_port\").String()\n\tremotePortStr := section.Key(\"remote_port\").String()\n\tif localPortStr == \"\" || remotePortStr == \"\" {\n\t\treturn fmt.Errorf(\"local_port or remote_port is empty\")\n\t}\n\n\tlocalPorts, err := util.ParseRangeNumbers(localPortStr)\n\tif err != nil {\n\t\treturn err\n\t}\n\n\tremotePorts, err := util.ParseRangeNumbers(remotePortStr)\n\tif err != nil {\n\t\treturn err\n\t}\n\n\tif len(localPorts) != len(remotePorts) {\n\t\treturn fmt.Errorf(\"local ports number should be same with remote ports number\")\n\t}\n\n\tif len(localPorts) == 0 {\n\t\treturn fmt.Errorf(\"local_port and remote_port is necessary\")\n\t}\n\n\t// Templates\n\tprefix := strings.TrimSpace(strings.TrimPrefix(section.Name(), \"range:\"))\n\n\tfor i := range localPorts {\n\t\ttmpname := fmt.Sprintf(\"%s_%d\", prefix, i)\n\n\t\ttmpsection, err := f.NewSection(tmpname)\n\t\tif err != nil {\n\t\t\treturn err\n\t\t}\n\n\t\tcopySection(section, tmpsection)\n\t\tif _, err := tmpsection.NewKey(\"local_port\", fmt.Sprintf(\"%d\", localPorts[i])); err != nil {\n\t\t\treturn fmt.Errorf(\"local_port new key in section error: %v\", err)\n\t\t}\n\t\tif _, err := tmpsection.NewKey(\"remote_port\", fmt.Sprintf(\"%d\", remotePorts[i])); err != nil {\n\t\t\treturn fmt.Errorf(\"remote_port new key in section error: %v\", err)\n\t\t}\n\t}\n\n\treturn nil\n}\n\nfunc copySection(source, target *ini.Section) {\n\tfor key, value := range source.KeysHash() {\n\t\t_, _ = target.NewKey(key, value)\n\t}\n}\n\n// GetDefaultClientConf returns a client configuration with default values.\n// Note: Some default values here will be set to empty and will be converted to them\n// new configuration through the 'Complete' function to set them as the default\n// values of the new configuration.\nfunc GetDefaultClientConf() ClientCommonConf {\n\treturn ClientCommonConf{\n\t\tClientConfig:              legacyauth.GetDefaultClientConf(),\n\t\tTCPMux:                    true,\n\t\tLoginFailExit:             true,\n\t\tProtocol:                  \"tcp\",\n\t\tStart:                     make([]string, 0),\n\t\tTLSEnable:                 true,\n\t\tDisableCustomTLSFirstByte: true,\n\t\tMetas:                     make(map[string]string),\n\t\tIncludeConfigFiles:        make([]string, 0),\n\t}\n}\n\nfunc (cfg *ClientCommonConf) Validate() error {\n\tif cfg.HeartbeatTimeout > 0 && cfg.HeartbeatInterval > 0 {\n\t\tif cfg.HeartbeatTimeout < cfg.HeartbeatInterval {\n\t\t\treturn fmt.Errorf(\"invalid heartbeat_timeout, heartbeat_timeout is less than heartbeat_interval\")\n\t\t}\n\t}\n\n\tif !cfg.TLSEnable {\n\t\tif cfg.TLSCertFile != \"\" {\n\t\t\tfmt.Println(\"WARNING! tls_cert_file is invalid when tls_enable is false\")\n\t\t}\n\n\t\tif cfg.TLSKeyFile != \"\" {\n\t\t\tfmt.Println(\"WARNING! tls_key_file is invalid when tls_enable is false\")\n\t\t}\n\n\t\tif cfg.TLSTrustedCaFile != \"\" {\n\t\t\tfmt.Println(\"WARNING! tls_trusted_ca_file is invalid when tls_enable is false\")\n\t\t}\n\t}\n\n\tif !slices.Contains([]string{\"tcp\", \"kcp\", \"quic\", \"websocket\", \"wss\"}, cfg.Protocol) {\n\t\treturn fmt.Errorf(\"invalid protocol\")\n\t}\n\n\tfor _, f := range cfg.IncludeConfigFiles {\n\t\tabsDir, err := filepath.Abs(filepath.Dir(f))\n\t\tif err != nil {\n\t\t\treturn fmt.Errorf(\"include: parse directory of %s failed: %v\", f, err)\n\t\t}\n\t\tif _, err := os.Stat(absDir); os.IsNotExist(err) {\n\t\t\treturn fmt.Errorf(\"include: directory of %s not exist\", f)\n\t\t}\n\t}\n\treturn nil\n}\n"
  },
  {
    "path": "pkg/config/legacy/conversion.go",
    "content": "// Copyright 2023 The frp Authors\n//\n// Licensed under the Apache License, Version 2.0 (the \"License\");\n// you may not use this file except in compliance with the License.\n// You may obtain a copy of the License at\n//\n//     http://www.apache.org/licenses/LICENSE-2.0\n//\n// Unless required by applicable law or agreed to in writing, software\n// distributed under the License is distributed on an \"AS IS\" BASIS,\n// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.\n// See the License for the specific language governing permissions and\n// limitations under the License.\n\npackage legacy\n\nimport (\n\t\"strings\"\n\n\t\"github.com/samber/lo\"\n\n\t\"github.com/fatedier/frp/pkg/config/types\"\n\tv1 \"github.com/fatedier/frp/pkg/config/v1\"\n)\n\nfunc Convert_ClientCommonConf_To_v1(conf *ClientCommonConf) *v1.ClientCommonConfig {\n\tout := &v1.ClientCommonConfig{}\n\tout.User = conf.User\n\tout.Auth.Method = v1.AuthMethod(conf.AuthenticationMethod)\n\tout.Auth.Token = conf.Token\n\tif conf.AuthenticateHeartBeats {\n\t\tout.Auth.AdditionalScopes = append(out.Auth.AdditionalScopes, v1.AuthScopeHeartBeats)\n\t}\n\tif conf.AuthenticateNewWorkConns {\n\t\tout.Auth.AdditionalScopes = append(out.Auth.AdditionalScopes, v1.AuthScopeNewWorkConns)\n\t}\n\tout.Auth.OIDC.ClientID = conf.OidcClientID\n\tout.Auth.OIDC.ClientSecret = conf.OidcClientSecret\n\tout.Auth.OIDC.Audience = conf.OidcAudience\n\tout.Auth.OIDC.Scope = conf.OidcScope\n\tout.Auth.OIDC.TokenEndpointURL = conf.OidcTokenEndpointURL\n\tout.Auth.OIDC.AdditionalEndpointParams = conf.OidcAdditionalEndpointParams\n\n\tout.ServerAddr = conf.ServerAddr\n\tout.ServerPort = conf.ServerPort\n\tout.NatHoleSTUNServer = conf.NatHoleSTUNServer\n\tout.Transport.DialServerTimeout = conf.DialServerTimeout\n\tout.Transport.DialServerKeepAlive = conf.DialServerKeepAlive\n\tout.Transport.ConnectServerLocalIP = conf.ConnectServerLocalIP\n\tout.Transport.ProxyURL = conf.HTTPProxy\n\tout.Transport.PoolCount = conf.PoolCount\n\tout.Transport.TCPMux = lo.ToPtr(conf.TCPMux)\n\tout.Transport.TCPMuxKeepaliveInterval = conf.TCPMuxKeepaliveInterval\n\tout.Transport.Protocol = conf.Protocol\n\tout.Transport.HeartbeatInterval = conf.HeartbeatInterval\n\tout.Transport.HeartbeatTimeout = conf.HeartbeatTimeout\n\tout.Transport.QUIC.KeepalivePeriod = conf.QUICKeepalivePeriod\n\tout.Transport.QUIC.MaxIdleTimeout = conf.QUICMaxIdleTimeout\n\tout.Transport.QUIC.MaxIncomingStreams = conf.QUICMaxIncomingStreams\n\tout.Transport.TLS.Enable = lo.ToPtr(conf.TLSEnable)\n\tout.Transport.TLS.DisableCustomTLSFirstByte = lo.ToPtr(conf.DisableCustomTLSFirstByte)\n\tout.Transport.TLS.CertFile = conf.TLSCertFile\n\tout.Transport.TLS.KeyFile = conf.TLSKeyFile\n\tout.Transport.TLS.TrustedCaFile = conf.TLSTrustedCaFile\n\tout.Transport.TLS.ServerName = conf.TLSServerName\n\n\tout.Log.To = conf.LogFile\n\tout.Log.Level = conf.LogLevel\n\tout.Log.MaxDays = conf.LogMaxDays\n\tout.Log.DisablePrintColor = conf.DisableLogColor\n\n\tout.WebServer.Addr = conf.AdminAddr\n\tout.WebServer.Port = conf.AdminPort\n\tout.WebServer.User = conf.AdminUser\n\tout.WebServer.Password = conf.AdminPwd\n\tout.WebServer.AssetsDir = conf.AssetsDir\n\tout.WebServer.PprofEnable = conf.PprofEnable\n\n\tout.DNSServer = conf.DNSServer\n\tout.LoginFailExit = lo.ToPtr(conf.LoginFailExit)\n\tout.Start = conf.Start\n\tout.UDPPacketSize = conf.UDPPacketSize\n\tout.Metadatas = conf.Metas\n\tout.IncludeConfigFiles = conf.IncludeConfigFiles\n\treturn out\n}\n\nfunc Convert_ServerCommonConf_To_v1(conf *ServerCommonConf) *v1.ServerConfig {\n\tout := &v1.ServerConfig{}\n\tout.Auth.Method = v1.AuthMethod(conf.AuthenticationMethod)\n\tout.Auth.Token = conf.Token\n\tif conf.AuthenticateHeartBeats {\n\t\tout.Auth.AdditionalScopes = append(out.Auth.AdditionalScopes, v1.AuthScopeHeartBeats)\n\t}\n\tif conf.AuthenticateNewWorkConns {\n\t\tout.Auth.AdditionalScopes = append(out.Auth.AdditionalScopes, v1.AuthScopeNewWorkConns)\n\t}\n\tout.Auth.OIDC.Audience = conf.OidcAudience\n\tout.Auth.OIDC.Issuer = conf.OidcIssuer\n\tout.Auth.OIDC.SkipExpiryCheck = conf.OidcSkipExpiryCheck\n\tout.Auth.OIDC.SkipIssuerCheck = conf.OidcSkipIssuerCheck\n\n\tout.BindAddr = conf.BindAddr\n\tout.BindPort = conf.BindPort\n\tout.KCPBindPort = conf.KCPBindPort\n\tout.QUICBindPort = conf.QUICBindPort\n\tout.Transport.QUIC.KeepalivePeriod = conf.QUICKeepalivePeriod\n\tout.Transport.QUIC.MaxIdleTimeout = conf.QUICMaxIdleTimeout\n\tout.Transport.QUIC.MaxIncomingStreams = conf.QUICMaxIncomingStreams\n\n\tout.ProxyBindAddr = conf.ProxyBindAddr\n\tout.VhostHTTPPort = conf.VhostHTTPPort\n\tout.VhostHTTPSPort = conf.VhostHTTPSPort\n\tout.TCPMuxHTTPConnectPort = conf.TCPMuxHTTPConnectPort\n\tout.TCPMuxPassthrough = conf.TCPMuxPassthrough\n\tout.VhostHTTPTimeout = conf.VhostHTTPTimeout\n\n\tout.WebServer.Addr = conf.DashboardAddr\n\tout.WebServer.Port = conf.DashboardPort\n\tout.WebServer.User = conf.DashboardUser\n\tout.WebServer.Password = conf.DashboardPwd\n\tout.WebServer.AssetsDir = conf.AssetsDir\n\tif conf.DashboardTLSMode {\n\t\tout.WebServer.TLS = &v1.TLSConfig{}\n\t\tout.WebServer.TLS.CertFile = conf.DashboardTLSCertFile\n\t\tout.WebServer.TLS.KeyFile = conf.DashboardTLSKeyFile\n\t\tout.WebServer.PprofEnable = conf.PprofEnable\n\t}\n\n\tout.EnablePrometheus = conf.EnablePrometheus\n\n\tout.Log.To = conf.LogFile\n\tout.Log.Level = conf.LogLevel\n\tout.Log.MaxDays = conf.LogMaxDays\n\tout.Log.DisablePrintColor = conf.DisableLogColor\n\n\tout.DetailedErrorsToClient = lo.ToPtr(conf.DetailedErrorsToClient)\n\tout.SubDomainHost = conf.SubDomainHost\n\tout.Custom404Page = conf.Custom404Page\n\tout.UserConnTimeout = conf.UserConnTimeout\n\tout.UDPPacketSize = conf.UDPPacketSize\n\tout.NatHoleAnalysisDataReserveHours = conf.NatHoleAnalysisDataReserveHours\n\n\tout.Transport.TCPMux = lo.ToPtr(conf.TCPMux)\n\tout.Transport.TCPMuxKeepaliveInterval = conf.TCPMuxKeepaliveInterval\n\tout.Transport.TCPKeepAlive = conf.TCPKeepAlive\n\tout.Transport.MaxPoolCount = conf.MaxPoolCount\n\tout.Transport.HeartbeatTimeout = conf.HeartbeatTimeout\n\n\tout.Transport.TLS.Force = conf.TLSOnly\n\tout.Transport.TLS.CertFile = conf.TLSCertFile\n\tout.Transport.TLS.KeyFile = conf.TLSKeyFile\n\tout.Transport.TLS.TrustedCaFile = conf.TLSTrustedCaFile\n\n\tout.MaxPortsPerClient = conf.MaxPortsPerClient\n\n\tfor _, v := range conf.HTTPPlugins {\n\t\tout.HTTPPlugins = append(out.HTTPPlugins, v1.HTTPPluginOptions{\n\t\t\tName:      v.Name,\n\t\t\tAddr:      v.Addr,\n\t\t\tPath:      v.Path,\n\t\t\tOps:       v.Ops,\n\t\t\tTLSVerify: v.TLSVerify,\n\t\t})\n\t}\n\n\tout.AllowPorts, _ = types.NewPortsRangeSliceFromString(conf.AllowPortsStr)\n\treturn out\n}\n\nfunc transformHeadersFromPluginParams(params map[string]string) v1.HeaderOperations {\n\tout := v1.HeaderOperations{}\n\tfor k, v := range params {\n\t\tk, ok := strings.CutPrefix(k, \"plugin_header_\")\n\t\tif !ok || k == \"\" {\n\t\t\tcontinue\n\t\t}\n\t\tif out.Set == nil {\n\t\t\tout.Set = make(map[string]string)\n\t\t}\n\t\tout.Set[k] = v\n\t}\n\treturn out\n}\n\nfunc Convert_ProxyConf_To_v1_Base(conf ProxyConf) *v1.ProxyBaseConfig {\n\tout := &v1.ProxyBaseConfig{}\n\tbase := conf.GetBaseConfig()\n\n\tout.Name = base.ProxyName\n\tout.Type = base.ProxyType\n\tout.Metadatas = base.Metas\n\n\tout.Transport.UseEncryption = base.UseEncryption\n\tout.Transport.UseCompression = base.UseCompression\n\tout.Transport.BandwidthLimit = base.BandwidthLimit\n\tout.Transport.BandwidthLimitMode = base.BandwidthLimitMode\n\tout.Transport.ProxyProtocolVersion = base.ProxyProtocolVersion\n\n\tout.LoadBalancer.Group = base.Group\n\tout.LoadBalancer.GroupKey = base.GroupKey\n\n\tout.HealthCheck.Type = base.HealthCheckType\n\tout.HealthCheck.TimeoutSeconds = base.HealthCheckTimeoutS\n\tout.HealthCheck.MaxFailed = base.HealthCheckMaxFailed\n\tout.HealthCheck.IntervalSeconds = base.HealthCheckIntervalS\n\tout.HealthCheck.Path = base.HealthCheckURL\n\n\tout.LocalIP = base.LocalIP\n\tout.LocalPort = base.LocalPort\n\n\tswitch base.Plugin {\n\tcase \"http2https\":\n\t\tout.Plugin.ClientPluginOptions = &v1.HTTP2HTTPSPluginOptions{\n\t\t\tLocalAddr:         base.PluginParams[\"plugin_local_addr\"],\n\t\t\tHostHeaderRewrite: base.PluginParams[\"plugin_host_header_rewrite\"],\n\t\t\tRequestHeaders:    transformHeadersFromPluginParams(base.PluginParams),\n\t\t}\n\tcase \"http_proxy\":\n\t\tout.Plugin.ClientPluginOptions = &v1.HTTPProxyPluginOptions{\n\t\t\tHTTPUser:     base.PluginParams[\"plugin_http_user\"],\n\t\t\tHTTPPassword: base.PluginParams[\"plugin_http_passwd\"],\n\t\t}\n\tcase \"https2http\":\n\t\tout.Plugin.ClientPluginOptions = &v1.HTTPS2HTTPPluginOptions{\n\t\t\tLocalAddr:         base.PluginParams[\"plugin_local_addr\"],\n\t\t\tHostHeaderRewrite: base.PluginParams[\"plugin_host_header_rewrite\"],\n\t\t\tRequestHeaders:    transformHeadersFromPluginParams(base.PluginParams),\n\t\t\tCrtPath:           base.PluginParams[\"plugin_crt_path\"],\n\t\t\tKeyPath:           base.PluginParams[\"plugin_key_path\"],\n\t\t}\n\tcase \"https2https\":\n\t\tout.Plugin.ClientPluginOptions = &v1.HTTPS2HTTPSPluginOptions{\n\t\t\tLocalAddr:         base.PluginParams[\"plugin_local_addr\"],\n\t\t\tHostHeaderRewrite: base.PluginParams[\"plugin_host_header_rewrite\"],\n\t\t\tRequestHeaders:    transformHeadersFromPluginParams(base.PluginParams),\n\t\t\tCrtPath:           base.PluginParams[\"plugin_crt_path\"],\n\t\t\tKeyPath:           base.PluginParams[\"plugin_key_path\"],\n\t\t}\n\tcase \"socks5\":\n\t\tout.Plugin.ClientPluginOptions = &v1.Socks5PluginOptions{\n\t\t\tUsername: base.PluginParams[\"plugin_user\"],\n\t\t\tPassword: base.PluginParams[\"plugin_passwd\"],\n\t\t}\n\tcase \"static_file\":\n\t\tout.Plugin.ClientPluginOptions = &v1.StaticFilePluginOptions{\n\t\t\tLocalPath:    base.PluginParams[\"plugin_local_path\"],\n\t\t\tStripPrefix:  base.PluginParams[\"plugin_strip_prefix\"],\n\t\t\tHTTPUser:     base.PluginParams[\"plugin_http_user\"],\n\t\t\tHTTPPassword: base.PluginParams[\"plugin_http_passwd\"],\n\t\t}\n\tcase \"unix_domain_socket\":\n\t\tout.Plugin.ClientPluginOptions = &v1.UnixDomainSocketPluginOptions{\n\t\t\tUnixPath: base.PluginParams[\"plugin_unix_path\"],\n\t\t}\n\t}\n\tout.Plugin.Type = base.Plugin\n\treturn out\n}\n\nfunc Convert_ProxyConf_To_v1(conf ProxyConf) v1.ProxyConfigurer {\n\toutBase := Convert_ProxyConf_To_v1_Base(conf)\n\tvar out v1.ProxyConfigurer\n\tswitch v := conf.(type) {\n\tcase *TCPProxyConf:\n\t\tc := &v1.TCPProxyConfig{ProxyBaseConfig: *outBase}\n\t\tc.RemotePort = v.RemotePort\n\t\tout = c\n\tcase *UDPProxyConf:\n\t\tc := &v1.UDPProxyConfig{ProxyBaseConfig: *outBase}\n\t\tc.RemotePort = v.RemotePort\n\t\tout = c\n\tcase *HTTPProxyConf:\n\t\tc := &v1.HTTPProxyConfig{ProxyBaseConfig: *outBase}\n\t\tc.CustomDomains = v.CustomDomains\n\t\tc.SubDomain = v.SubDomain\n\t\tc.Locations = v.Locations\n\t\tc.HTTPUser = v.HTTPUser\n\t\tc.HTTPPassword = v.HTTPPwd\n\t\tc.HostHeaderRewrite = v.HostHeaderRewrite\n\t\tc.RequestHeaders.Set = v.Headers\n\t\tc.RouteByHTTPUser = v.RouteByHTTPUser\n\t\tout = c\n\tcase *HTTPSProxyConf:\n\t\tc := &v1.HTTPSProxyConfig{ProxyBaseConfig: *outBase}\n\t\tc.CustomDomains = v.CustomDomains\n\t\tc.SubDomain = v.SubDomain\n\t\tout = c\n\tcase *TCPMuxProxyConf:\n\t\tc := &v1.TCPMuxProxyConfig{ProxyBaseConfig: *outBase}\n\t\tc.CustomDomains = v.CustomDomains\n\t\tc.SubDomain = v.SubDomain\n\t\tc.HTTPUser = v.HTTPUser\n\t\tc.HTTPPassword = v.HTTPPwd\n\t\tc.RouteByHTTPUser = v.RouteByHTTPUser\n\t\tc.Multiplexer = v.Multiplexer\n\t\tout = c\n\tcase *STCPProxyConf:\n\t\tc := &v1.STCPProxyConfig{ProxyBaseConfig: *outBase}\n\t\tc.Secretkey = v.Sk\n\t\tc.AllowUsers = v.AllowUsers\n\t\tout = c\n\tcase *SUDPProxyConf:\n\t\tc := &v1.SUDPProxyConfig{ProxyBaseConfig: *outBase}\n\t\tc.Secretkey = v.Sk\n\t\tc.AllowUsers = v.AllowUsers\n\t\tout = c\n\tcase *XTCPProxyConf:\n\t\tc := &v1.XTCPProxyConfig{ProxyBaseConfig: *outBase}\n\t\tc.Secretkey = v.Sk\n\t\tc.AllowUsers = v.AllowUsers\n\t\tout = c\n\t}\n\treturn out\n}\n\nfunc Convert_VisitorConf_To_v1_Base(conf VisitorConf) *v1.VisitorBaseConfig {\n\tout := &v1.VisitorBaseConfig{}\n\tbase := conf.GetBaseConfig()\n\n\tout.Name = base.ProxyName\n\tout.Type = base.ProxyType\n\tout.Transport.UseEncryption = base.UseEncryption\n\tout.Transport.UseCompression = base.UseCompression\n\tout.SecretKey = base.Sk\n\tout.ServerUser = base.ServerUser\n\tout.ServerName = base.ServerName\n\tout.BindAddr = base.BindAddr\n\tout.BindPort = base.BindPort\n\treturn out\n}\n\nfunc Convert_VisitorConf_To_v1(conf VisitorConf) v1.VisitorConfigurer {\n\toutBase := Convert_VisitorConf_To_v1_Base(conf)\n\tvar out v1.VisitorConfigurer\n\tswitch v := conf.(type) {\n\tcase *STCPVisitorConf:\n\t\tc := &v1.STCPVisitorConfig{VisitorBaseConfig: *outBase}\n\t\tout = c\n\tcase *SUDPVisitorConf:\n\t\tc := &v1.SUDPVisitorConfig{VisitorBaseConfig: *outBase}\n\t\tout = c\n\tcase *XTCPVisitorConf:\n\t\tc := &v1.XTCPVisitorConfig{VisitorBaseConfig: *outBase}\n\t\tc.Protocol = v.Protocol\n\t\tc.KeepTunnelOpen = v.KeepTunnelOpen\n\t\tc.MaxRetriesAnHour = v.MaxRetriesAnHour\n\t\tc.MinRetryInterval = v.MinRetryInterval\n\t\tc.FallbackTo = v.FallbackTo\n\t\tc.FallbackTimeoutMs = v.FallbackTimeoutMs\n\t\tout = c\n\t}\n\treturn out\n}\n"
  },
  {
    "path": "pkg/config/legacy/parse.go",
    "content": "// Copyright 2021 The frp Authors\n//\n// Licensed under the Apache License, Version 2.0 (the \"License\");\n// you may not use this file except in compliance with the License.\n// You may obtain a copy of the License at\n//\n//     http://www.apache.org/licenses/LICENSE-2.0\n//\n// Unless required by applicable law or agreed to in writing, software\n// distributed under the License is distributed on an \"AS IS\" BASIS,\n// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.\n// See the License for the specific language governing permissions and\n// limitations under the License.\n\npackage legacy\n\nimport (\n\t\"bytes\"\n\t\"fmt\"\n\t\"os\"\n\t\"path/filepath\"\n)\n\nfunc ParseClientConfig(filePath string) (\n\tcfg ClientCommonConf,\n\tproxyCfgs map[string]ProxyConf,\n\tvisitorCfgs map[string]VisitorConf,\n\terr error,\n) {\n\tvar content []byte\n\tcontent, err = GetRenderedConfFromFile(filePath)\n\tif err != nil {\n\t\treturn\n\t}\n\tconfigBuffer := bytes.NewBuffer(nil)\n\tconfigBuffer.Write(content)\n\n\t// Parse common section.\n\tcfg, err = UnmarshalClientConfFromIni(content)\n\tif err != nil {\n\t\treturn\n\t}\n\tif err = cfg.Validate(); err != nil {\n\t\terr = fmt.Errorf(\"parse config error: %v\", err)\n\t\treturn\n\t}\n\n\t// Aggregate proxy configs from include files.\n\tvar buf []byte\n\tbuf, err = getIncludeContents(cfg.IncludeConfigFiles)\n\tif err != nil {\n\t\terr = fmt.Errorf(\"getIncludeContents error: %v\", err)\n\t\treturn\n\t}\n\tconfigBuffer.WriteString(\"\\n\")\n\tconfigBuffer.Write(buf)\n\n\t// Parse all proxy and visitor configs.\n\tproxyCfgs, visitorCfgs, err = LoadAllProxyConfsFromIni(cfg.User, configBuffer.Bytes(), cfg.Start)\n\tif err != nil {\n\t\treturn\n\t}\n\treturn\n}\n\n// getIncludeContents renders all configs from paths.\n// files format can be a single file path or directory or regex path.\nfunc getIncludeContents(paths []string) ([]byte, error) {\n\tout := bytes.NewBuffer(nil)\n\tfor _, path := range paths {\n\t\tabsDir, err := filepath.Abs(filepath.Dir(path))\n\t\tif err != nil {\n\t\t\treturn nil, err\n\t\t}\n\t\tif _, err := os.Stat(absDir); os.IsNotExist(err) {\n\t\t\treturn nil, err\n\t\t}\n\t\tfiles, err := os.ReadDir(absDir)\n\t\tif err != nil {\n\t\t\treturn nil, err\n\t\t}\n\t\tfor _, fi := range files {\n\t\t\tif fi.IsDir() {\n\t\t\t\tcontinue\n\t\t\t}\n\t\t\tabsFile := filepath.Join(absDir, fi.Name())\n\t\t\tif matched, _ := filepath.Match(filepath.Join(absDir, filepath.Base(path)), absFile); matched {\n\t\t\t\ttmpContent, err := GetRenderedConfFromFile(absFile)\n\t\t\t\tif err != nil {\n\t\t\t\t\treturn nil, fmt.Errorf(\"render extra config %s error: %v\", absFile, err)\n\t\t\t\t}\n\t\t\t\tout.Write(tmpContent)\n\t\t\t\tout.WriteString(\"\\n\")\n\t\t\t}\n\t\t}\n\t}\n\treturn out.Bytes(), nil\n}\n"
  },
  {
    "path": "pkg/config/legacy/proxy.go",
    "content": "// Copyright 2023 The frp Authors\n//\n// Licensed under the Apache License, Version 2.0 (the \"License\");\n// you may not use this file except in compliance with the License.\n// You may obtain a copy of the License at\n//\n//     http://www.apache.org/licenses/LICENSE-2.0\n//\n// Unless required by applicable law or agreed to in writing, software\n// distributed under the License is distributed on an \"AS IS\" BASIS,\n// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.\n// See the License for the specific language governing permissions and\n// limitations under the License.\n\npackage legacy\n\nimport (\n\t\"fmt\"\n\t\"reflect\"\n\n\t\"gopkg.in/ini.v1\"\n\n\t\"github.com/fatedier/frp/pkg/config/types\"\n)\n\ntype ProxyType string\n\nconst (\n\tProxyTypeTCP    ProxyType = \"tcp\"\n\tProxyTypeUDP    ProxyType = \"udp\"\n\tProxyTypeTCPMUX ProxyType = \"tcpmux\"\n\tProxyTypeHTTP   ProxyType = \"http\"\n\tProxyTypeHTTPS  ProxyType = \"https\"\n\tProxyTypeSTCP   ProxyType = \"stcp\"\n\tProxyTypeXTCP   ProxyType = \"xtcp\"\n\tProxyTypeSUDP   ProxyType = \"sudp\"\n)\n\n// Proxy\nvar (\n\tproxyConfTypeMap = map[ProxyType]reflect.Type{\n\t\tProxyTypeTCP:    reflect.TypeFor[TCPProxyConf](),\n\t\tProxyTypeUDP:    reflect.TypeFor[UDPProxyConf](),\n\t\tProxyTypeTCPMUX: reflect.TypeFor[TCPMuxProxyConf](),\n\t\tProxyTypeHTTP:   reflect.TypeFor[HTTPProxyConf](),\n\t\tProxyTypeHTTPS:  reflect.TypeFor[HTTPSProxyConf](),\n\t\tProxyTypeSTCP:   reflect.TypeFor[STCPProxyConf](),\n\t\tProxyTypeXTCP:   reflect.TypeFor[XTCPProxyConf](),\n\t\tProxyTypeSUDP:   reflect.TypeFor[SUDPProxyConf](),\n\t}\n)\n\ntype ProxyConf interface {\n\t// GetBaseConfig returns the BaseProxyConf for this config.\n\tGetBaseConfig() *BaseProxyConf\n\t// UnmarshalFromIni unmarshals a ini.Section into this config. This function\n\t// will be called on the frpc side.\n\tUnmarshalFromIni(string, string, *ini.Section) error\n}\n\nfunc NewConfByType(proxyType ProxyType) ProxyConf {\n\tv, ok := proxyConfTypeMap[proxyType]\n\tif !ok {\n\t\treturn nil\n\t}\n\tcfg := reflect.New(v).Interface().(ProxyConf)\n\treturn cfg\n}\n\n// Proxy Conf Loader\n// DefaultProxyConf creates a empty ProxyConf object by proxyType.\n// If proxyType doesn't exist, return nil.\nfunc DefaultProxyConf(proxyType ProxyType) ProxyConf {\n\treturn NewConfByType(proxyType)\n}\n\n// Proxy loaded from ini\nfunc NewProxyConfFromIni(prefix, name string, section *ini.Section) (ProxyConf, error) {\n\t// section.Key: if key not exists, section will set it with default value.\n\tproxyType := ProxyType(section.Key(\"type\").String())\n\tif proxyType == \"\" {\n\t\tproxyType = ProxyTypeTCP\n\t}\n\n\tconf := DefaultProxyConf(proxyType)\n\tif conf == nil {\n\t\treturn nil, fmt.Errorf(\"invalid type [%s]\", proxyType)\n\t}\n\n\tif err := conf.UnmarshalFromIni(prefix, name, section); err != nil {\n\t\treturn nil, err\n\t}\n\treturn conf, nil\n}\n\n// LocalSvrConf configures what location the client will to, or what\n// plugin will be used.\ntype LocalSvrConf struct {\n\t// LocalIP specifies the IP address or host name to to.\n\tLocalIP string `ini:\"local_ip\" json:\"local_ip\"`\n\t// LocalPort specifies the port to to.\n\tLocalPort int `ini:\"local_port\" json:\"local_port\"`\n\n\t// Plugin specifies what plugin should be used for ng. If this value\n\t// is set, the LocalIp and LocalPort values will be ignored. By default,\n\t// this value is \"\".\n\tPlugin string `ini:\"plugin\" json:\"plugin\"`\n\t// PluginParams specify parameters to be passed to the plugin, if one is\n\t// being used. By default, this value is an empty map.\n\tPluginParams map[string]string `ini:\"-\"`\n}\n\n// HealthCheckConf configures health checking. This can be useful for load\n// balancing purposes to detect and remove proxies to failing services.\ntype HealthCheckConf struct {\n\t// HealthCheckType specifies what protocol to use for health checking.\n\t// Valid values include \"tcp\", \"http\", and \"\". If this value is \"\", health\n\t// checking will not be performed. By default, this value is \"\".\n\t//\n\t// If the type is \"tcp\", a connection will be attempted to the target\n\t// server. If a connection cannot be established, the health check fails.\n\t//\n\t// If the type is \"http\", a GET request will be made to the endpoint\n\t// specified by HealthCheckURL. If the response is not a 200, the health\n\t// check fails.\n\tHealthCheckType string `ini:\"health_check_type\" json:\"health_check_type\"` // tcp | http\n\t// HealthCheckTimeoutS specifies the number of seconds to wait for a health\n\t// check attempt to connect. If the timeout is reached, this counts as a\n\t// health check failure. By default, this value is 3.\n\tHealthCheckTimeoutS int `ini:\"health_check_timeout_s\" json:\"health_check_timeout_s\"`\n\t// HealthCheckMaxFailed specifies the number of allowed failures before the\n\t// is stopped. By default, this value is 1.\n\tHealthCheckMaxFailed int `ini:\"health_check_max_failed\" json:\"health_check_max_failed\"`\n\t// HealthCheckIntervalS specifies the time in seconds between health\n\t// checks. By default, this value is 10.\n\tHealthCheckIntervalS int `ini:\"health_check_interval_s\" json:\"health_check_interval_s\"`\n\t// HealthCheckURL specifies the address to send health checks to if the\n\t// health check type is \"http\".\n\tHealthCheckURL string `ini:\"health_check_url\" json:\"health_check_url\"`\n\t// HealthCheckAddr specifies the address to connect to if the health check\n\t// type is \"tcp\".\n\tHealthCheckAddr string `ini:\"-\"`\n}\n\n// BaseProxyConf provides configuration info that is common to all types.\ntype BaseProxyConf struct {\n\t// ProxyName is the name of this\n\tProxyName string `ini:\"name\" json:\"name\"`\n\t// ProxyType specifies the type of this  Valid values include \"tcp\",\n\t// \"udp\", \"http\", \"https\", \"stcp\", and \"xtcp\". By default, this value is\n\t// \"tcp\".\n\tProxyType string `ini:\"type\" json:\"type\"`\n\n\t// UseEncryption controls whether or not communication with the server will\n\t// be encrypted. Encryption is done using the tokens supplied in the server\n\t// and client configuration. By default, this value is false.\n\tUseEncryption bool `ini:\"use_encryption\" json:\"use_encryption\"`\n\t// UseCompression controls whether or not communication with the server\n\t// will be compressed. By default, this value is false.\n\tUseCompression bool `ini:\"use_compression\" json:\"use_compression\"`\n\t// Group specifies which group the is a part of. The server will use\n\t// this information to load balance proxies in the same group. If the value\n\t// is \"\", this will not be in a group. By default, this value is \"\".\n\tGroup string `ini:\"group\" json:\"group\"`\n\t// GroupKey specifies a group key, which should be the same among proxies\n\t// of the same group. By default, this value is \"\".\n\tGroupKey string `ini:\"group_key\" json:\"group_key\"`\n\n\t// ProxyProtocolVersion specifies which protocol version to use. Valid\n\t// values include \"v1\", \"v2\", and \"\". If the value is \"\", a protocol\n\t// version will be automatically selected. By default, this value is \"\".\n\tProxyProtocolVersion string `ini:\"proxy_protocol_version\" json:\"proxy_protocol_version\"`\n\n\t// BandwidthLimit limit the bandwidth\n\t// 0 means no limit\n\tBandwidthLimit types.BandwidthQuantity `ini:\"bandwidth_limit\" json:\"bandwidth_limit\"`\n\t// BandwidthLimitMode specifies whether to limit the bandwidth on the\n\t// client or server side. Valid values include \"client\" and \"server\".\n\t// By default, this value is \"client\".\n\tBandwidthLimitMode string `ini:\"bandwidth_limit_mode\" json:\"bandwidth_limit_mode\"`\n\n\t// meta info for each proxy\n\tMetas map[string]string `ini:\"-\" json:\"metas\"`\n\n\tLocalSvrConf    `ini:\",extends\"`\n\tHealthCheckConf `ini:\",extends\"`\n}\n\n// Base\nfunc (cfg *BaseProxyConf) GetBaseConfig() *BaseProxyConf {\n\treturn cfg\n}\n\n// BaseProxyConf apply custom logic changes.\nfunc (cfg *BaseProxyConf) decorate(_ string, name string, section *ini.Section) error {\n\tcfg.ProxyName = name\n\t// metas_xxx\n\tcfg.Metas = GetMapWithoutPrefix(section.KeysHash(), \"meta_\")\n\n\t// bandwidth_limit\n\tif bandwidth, err := section.GetKey(\"bandwidth_limit\"); err == nil {\n\t\tcfg.BandwidthLimit, err = types.NewBandwidthQuantity(bandwidth.String())\n\t\tif err != nil {\n\t\t\treturn err\n\t\t}\n\t}\n\n\t// plugin_xxx\n\tcfg.PluginParams = GetMapByPrefix(section.KeysHash(), \"plugin_\")\n\treturn nil\n}\n\ntype DomainConf struct {\n\tCustomDomains []string `ini:\"custom_domains\" json:\"custom_domains\"`\n\tSubDomain     string   `ini:\"subdomain\" json:\"subdomain\"`\n}\n\ntype RoleServerCommonConf struct {\n\tRole       string   `ini:\"role\" json:\"role\"`\n\tSk         string   `ini:\"sk\" json:\"sk\"`\n\tAllowUsers []string `ini:\"allow_users\" json:\"allow_users\"`\n}\n\n// HTTP\ntype HTTPProxyConf struct {\n\tBaseProxyConf `ini:\",extends\"`\n\tDomainConf    `ini:\",extends\"`\n\n\tLocations         []string          `ini:\"locations\" json:\"locations\"`\n\tHTTPUser          string            `ini:\"http_user\" json:\"http_user\"`\n\tHTTPPwd           string            `ini:\"http_pwd\" json:\"http_pwd\"`\n\tHostHeaderRewrite string            `ini:\"host_header_rewrite\" json:\"host_header_rewrite\"`\n\tHeaders           map[string]string `ini:\"-\" json:\"headers\"`\n\tRouteByHTTPUser   string            `ini:\"route_by_http_user\" json:\"route_by_http_user\"`\n}\n\nfunc (cfg *HTTPProxyConf) UnmarshalFromIni(prefix string, name string, section *ini.Section) error {\n\terr := preUnmarshalFromIni(cfg, prefix, name, section)\n\tif err != nil {\n\t\treturn err\n\t}\n\n\t// Add custom logic unmarshal if exists\n\tcfg.Headers = GetMapWithoutPrefix(section.KeysHash(), \"header_\")\n\treturn nil\n}\n\n// HTTPS\ntype HTTPSProxyConf struct {\n\tBaseProxyConf `ini:\",extends\"`\n\tDomainConf    `ini:\",extends\"`\n}\n\nfunc (cfg *HTTPSProxyConf) UnmarshalFromIni(prefix string, name string, section *ini.Section) error {\n\terr := preUnmarshalFromIni(cfg, prefix, name, section)\n\tif err != nil {\n\t\treturn err\n\t}\n\n\t// Add custom logic unmarshal if exists\n\treturn nil\n}\n\n// TCP\ntype TCPProxyConf struct {\n\tBaseProxyConf `ini:\",extends\"`\n\tRemotePort    int `ini:\"remote_port\" json:\"remote_port\"`\n}\n\nfunc (cfg *TCPProxyConf) UnmarshalFromIni(prefix string, name string, section *ini.Section) error {\n\terr := preUnmarshalFromIni(cfg, prefix, name, section)\n\tif err != nil {\n\t\treturn err\n\t}\n\n\t// Add custom logic unmarshal if exists\n\n\treturn nil\n}\n\n// UDP\ntype UDPProxyConf struct {\n\tBaseProxyConf `ini:\",extends\"`\n\n\tRemotePort int `ini:\"remote_port\" json:\"remote_port\"`\n}\n\nfunc (cfg *UDPProxyConf) UnmarshalFromIni(prefix string, name string, section *ini.Section) error {\n\terr := preUnmarshalFromIni(cfg, prefix, name, section)\n\tif err != nil {\n\t\treturn err\n\t}\n\n\t// Add custom logic unmarshal if exists\n\n\treturn nil\n}\n\n// TCPMux\ntype TCPMuxProxyConf struct {\n\tBaseProxyConf   `ini:\",extends\"`\n\tDomainConf      `ini:\",extends\"`\n\tHTTPUser        string `ini:\"http_user\" json:\"http_user,omitempty\"`\n\tHTTPPwd         string `ini:\"http_pwd\" json:\"http_pwd,omitempty\"`\n\tRouteByHTTPUser string `ini:\"route_by_http_user\" json:\"route_by_http_user\"`\n\n\tMultiplexer string `ini:\"multiplexer\"`\n}\n\nfunc (cfg *TCPMuxProxyConf) UnmarshalFromIni(prefix string, name string, section *ini.Section) error {\n\terr := preUnmarshalFromIni(cfg, prefix, name, section)\n\tif err != nil {\n\t\treturn err\n\t}\n\n\t// Add custom logic unmarshal if exists\n\n\treturn nil\n}\n\n// STCP\ntype STCPProxyConf struct {\n\tBaseProxyConf        `ini:\",extends\"`\n\tRoleServerCommonConf `ini:\",extends\"`\n}\n\nfunc (cfg *STCPProxyConf) UnmarshalFromIni(prefix string, name string, section *ini.Section) error {\n\terr := preUnmarshalFromIni(cfg, prefix, name, section)\n\tif err != nil {\n\t\treturn err\n\t}\n\n\t// Add custom logic unmarshal if exists\n\tif cfg.Role == \"\" {\n\t\tcfg.Role = \"server\"\n\t}\n\treturn nil\n}\n\n// XTCP\ntype XTCPProxyConf struct {\n\tBaseProxyConf        `ini:\",extends\"`\n\tRoleServerCommonConf `ini:\",extends\"`\n}\n\nfunc (cfg *XTCPProxyConf) UnmarshalFromIni(prefix string, name string, section *ini.Section) error {\n\terr := preUnmarshalFromIni(cfg, prefix, name, section)\n\tif err != nil {\n\t\treturn err\n\t}\n\n\t// Add custom logic unmarshal if exists\n\tif cfg.Role == \"\" {\n\t\tcfg.Role = \"server\"\n\t}\n\treturn nil\n}\n\n// SUDP\ntype SUDPProxyConf struct {\n\tBaseProxyConf        `ini:\",extends\"`\n\tRoleServerCommonConf `ini:\",extends\"`\n}\n\nfunc (cfg *SUDPProxyConf) UnmarshalFromIni(prefix string, name string, section *ini.Section) error {\n\terr := preUnmarshalFromIni(cfg, prefix, name, section)\n\tif err != nil {\n\t\treturn err\n\t}\n\n\t// Add custom logic unmarshal if exists\n\treturn nil\n}\n\nfunc preUnmarshalFromIni(cfg ProxyConf, prefix string, name string, section *ini.Section) error {\n\terr := section.MapTo(cfg)\n\tif err != nil {\n\t\treturn err\n\t}\n\n\terr = cfg.GetBaseConfig().decorate(prefix, name, section)\n\tif err != nil {\n\t\treturn err\n\t}\n\n\treturn nil\n}\n"
  },
  {
    "path": "pkg/config/legacy/server.go",
    "content": "// Copyright 2023 The frp Authors\n//\n// Licensed under the Apache License, Version 2.0 (the \"License\");\n// you may not use this file except in compliance with the License.\n// You may obtain a copy of the License at\n//\n//     http://www.apache.org/licenses/LICENSE-2.0\n//\n// Unless required by applicable law or agreed to in writing, software\n// distributed under the License is distributed on an \"AS IS\" BASIS,\n// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.\n// See the License for the specific language governing permissions and\n// limitations under the License.\n\npackage legacy\n\nimport (\n\t\"strings\"\n\n\t\"gopkg.in/ini.v1\"\n\n\tlegacyauth \"github.com/fatedier/frp/pkg/auth/legacy\"\n)\n\ntype HTTPPluginOptions struct {\n\tName      string   `ini:\"name\"`\n\tAddr      string   `ini:\"addr\"`\n\tPath      string   `ini:\"path\"`\n\tOps       []string `ini:\"ops\"`\n\tTLSVerify bool     `ini:\"tlsVerify\"`\n}\n\n// ServerCommonConf contains information for a server service. It is\n// recommended to use GetDefaultServerConf instead of creating this object\n// directly, so that all unspecified fields have reasonable default values.\ntype ServerCommonConf struct {\n\tlegacyauth.ServerConfig `ini:\",extends\"`\n\n\t// BindAddr specifies the address that the server binds to. By default,\n\t// this value is \"0.0.0.0\".\n\tBindAddr string `ini:\"bind_addr\" json:\"bind_addr\"`\n\t// BindPort specifies the port that the server listens on. By default, this\n\t// value is 7000.\n\tBindPort int `ini:\"bind_port\" json:\"bind_port\"`\n\t// KCPBindPort specifies the KCP port that the server listens on. If this\n\t// value is 0, the server will not listen for KCP connections. By default,\n\t// this value is 0.\n\tKCPBindPort int `ini:\"kcp_bind_port\" json:\"kcp_bind_port\"`\n\t// QUICBindPort specifies the QUIC port that the server listens on.\n\t// Set this value to 0 will disable this feature.\n\t// By default, the value is 0.\n\tQUICBindPort int `ini:\"quic_bind_port\" json:\"quic_bind_port\"`\n\t// QUIC protocol options\n\tQUICKeepalivePeriod    int `ini:\"quic_keepalive_period\" json:\"quic_keepalive_period\"`\n\tQUICMaxIdleTimeout     int `ini:\"quic_max_idle_timeout\" json:\"quic_max_idle_timeout\"`\n\tQUICMaxIncomingStreams int `ini:\"quic_max_incoming_streams\" json:\"quic_max_incoming_streams\"`\n\t// ProxyBindAddr specifies the address that the proxy binds to. This value\n\t// may be the same as BindAddr.\n\tProxyBindAddr string `ini:\"proxy_bind_addr\" json:\"proxy_bind_addr\"`\n\t// VhostHTTPPort specifies the port that the server listens for HTTP Vhost\n\t// requests. If this value is 0, the server will not listen for HTTP\n\t// requests. By default, this value is 0.\n\tVhostHTTPPort int `ini:\"vhost_http_port\" json:\"vhost_http_port\"`\n\t// VhostHTTPSPort specifies the port that the server listens for HTTPS\n\t// Vhost requests. If this value is 0, the server will not listen for HTTPS\n\t// requests. By default, this value is 0.\n\tVhostHTTPSPort int `ini:\"vhost_https_port\" json:\"vhost_https_port\"`\n\t// TCPMuxHTTPConnectPort specifies the port that the server listens for TCP\n\t// HTTP CONNECT requests. If the value is 0, the server will not multiplex TCP\n\t// requests on one single port. If it's not - it will listen on this value for\n\t// HTTP CONNECT requests. By default, this value is 0.\n\tTCPMuxHTTPConnectPort int `ini:\"tcpmux_httpconnect_port\" json:\"tcpmux_httpconnect_port\"`\n\t// If TCPMuxPassthrough is true, frps won't do any update on traffic.\n\tTCPMuxPassthrough bool `ini:\"tcpmux_passthrough\" json:\"tcpmux_passthrough\"`\n\t// VhostHTTPTimeout specifies the response header timeout for the Vhost\n\t// HTTP server, in seconds. By default, this value is 60.\n\tVhostHTTPTimeout int64 `ini:\"vhost_http_timeout\" json:\"vhost_http_timeout\"`\n\t// DashboardAddr specifies the address that the dashboard binds to. By\n\t// default, this value is \"0.0.0.0\".\n\tDashboardAddr string `ini:\"dashboard_addr\" json:\"dashboard_addr\"`\n\t// DashboardPort specifies the port that the dashboard listens on. If this\n\t// value is 0, the dashboard will not be started. By default, this value is\n\t// 0.\n\tDashboardPort int `ini:\"dashboard_port\" json:\"dashboard_port\"`\n\t// DashboardTLSCertFile specifies the path of the cert file that the server will\n\t// load. If \"dashboard_tls_cert_file\", \"dashboard_tls_key_file\" are valid, the server will use this\n\t// supplied tls configuration.\n\tDashboardTLSCertFile string `ini:\"dashboard_tls_cert_file\" json:\"dashboard_tls_cert_file\"`\n\t// DashboardTLSKeyFile specifies the path of the secret key that the server will\n\t// load. If \"dashboard_tls_cert_file\", \"dashboard_tls_key_file\" are valid, the server will use this\n\t// supplied tls configuration.\n\tDashboardTLSKeyFile string `ini:\"dashboard_tls_key_file\" json:\"dashboard_tls_key_file\"`\n\t// DashboardTLSMode specifies the mode of the dashboard between HTTP or HTTPS modes. By\n\t// default, this value is false, which is HTTP mode.\n\tDashboardTLSMode bool `ini:\"dashboard_tls_mode\" json:\"dashboard_tls_mode\"`\n\t// DashboardUser specifies the username that the dashboard will use for\n\t// login.\n\tDashboardUser string `ini:\"dashboard_user\" json:\"dashboard_user\"`\n\t// DashboardPwd specifies the password that the dashboard will use for\n\t// login.\n\tDashboardPwd string `ini:\"dashboard_pwd\" json:\"dashboard_pwd\"`\n\t// EnablePrometheus will export prometheus metrics on {dashboard_addr}:{dashboard_port}\n\t// in /metrics api.\n\tEnablePrometheus bool `ini:\"enable_prometheus\" json:\"enable_prometheus\"`\n\t// AssetsDir specifies the local directory that the dashboard will load\n\t// resources from. If this value is \"\", assets will be loaded from the\n\t// bundled executable using statik. By default, this value is \"\".\n\tAssetsDir string `ini:\"assets_dir\" json:\"assets_dir\"`\n\t// LogFile specifies a file where logs will be written to. This value will\n\t// only be used if LogWay is set appropriately. By default, this value is\n\t// \"console\".\n\tLogFile string `ini:\"log_file\" json:\"log_file\"`\n\t// LogWay specifies the way logging is managed. Valid values are \"console\"\n\t// or \"file\". If \"console\" is used, logs will be printed to stdout. If\n\t// \"file\" is used, logs will be printed to LogFile. By default, this value\n\t// is \"console\".\n\tLogWay string `ini:\"log_way\" json:\"log_way\"`\n\t// LogLevel specifies the minimum log level. Valid values are \"trace\",\n\t// \"debug\", \"info\", \"warn\", and \"error\". By default, this value is \"info\".\n\tLogLevel string `ini:\"log_level\" json:\"log_level\"`\n\t// LogMaxDays specifies the maximum number of days to store log information\n\t// before deletion. This is only used if LogWay == \"file\". By default, this\n\t// value is 0.\n\tLogMaxDays int64 `ini:\"log_max_days\" json:\"log_max_days\"`\n\t// DisableLogColor disables log colors when LogWay == \"console\" when set to\n\t// true. By default, this value is false.\n\tDisableLogColor bool `ini:\"disable_log_color\" json:\"disable_log_color\"`\n\t// DetailedErrorsToClient defines whether to send the specific error (with\n\t// debug info) to frpc. By default, this value is true.\n\tDetailedErrorsToClient bool `ini:\"detailed_errors_to_client\" json:\"detailed_errors_to_client\"`\n\n\t// SubDomainHost specifies the domain that will be attached to sub-domains\n\t// requested by the client when using Vhost proxying. For example, if this\n\t// value is set to \"frps.com\" and the client requested the subdomain\n\t// \"test\", the resulting URL would be \"test.frps.com\". By default, this\n\t// value is \"\".\n\tSubDomainHost string `ini:\"subdomain_host\" json:\"subdomain_host\"`\n\t// TCPMux toggles TCP stream multiplexing. This allows multiple requests\n\t// from a client to share a single TCP connection. By default, this value\n\t// is true.\n\tTCPMux bool `ini:\"tcp_mux\" json:\"tcp_mux\"`\n\t// TCPMuxKeepaliveInterval specifies the keep alive interval for TCP stream multiplier.\n\t// If TCPMux is true, heartbeat of application layer is unnecessary because it can only rely on heartbeat in TCPMux.\n\tTCPMuxKeepaliveInterval int64 `ini:\"tcp_mux_keepalive_interval\" json:\"tcp_mux_keepalive_interval\"`\n\t// TCPKeepAlive specifies the interval between keep-alive probes for an active network connection between frpc and frps.\n\t// If negative, keep-alive probes are disabled.\n\tTCPKeepAlive int64 `ini:\"tcp_keepalive\" json:\"tcp_keepalive\"`\n\t// Custom404Page specifies a path to a custom 404 page to display. If this\n\t// value is \"\", a default page will be displayed. By default, this value is\n\t// \"\".\n\tCustom404Page string `ini:\"custom_404_page\" json:\"custom_404_page\"`\n\n\t// AllowPorts specifies a set of ports that clients are able to proxy to.\n\t// If the length of this value is 0, all ports are allowed. By default,\n\t// this value is an empty set.\n\tAllowPorts map[int]struct{} `ini:\"-\" json:\"-\"`\n\t// Original string.\n\tAllowPortsStr string `ini:\"-\" json:\"-\"`\n\t// MaxPoolCount specifies the maximum pool size for each proxy. By default,\n\t// this value is 5.\n\tMaxPoolCount int64 `ini:\"max_pool_count\" json:\"max_pool_count\"`\n\t// MaxPortsPerClient specifies the maximum number of ports a single client\n\t// may proxy to. If this value is 0, no limit will be applied. By default,\n\t// this value is 0.\n\tMaxPortsPerClient int64 `ini:\"max_ports_per_client\" json:\"max_ports_per_client\"`\n\t// TLSOnly specifies whether to only accept TLS-encrypted connections.\n\t// By default, the value is false.\n\tTLSOnly bool `ini:\"tls_only\" json:\"tls_only\"`\n\t// TLSCertFile specifies the path of the cert file that the server will\n\t// load. If \"tls_cert_file\", \"tls_key_file\" are valid, the server will use this\n\t// supplied tls configuration. Otherwise, the server will use the tls\n\t// configuration generated by itself.\n\tTLSCertFile string `ini:\"tls_cert_file\" json:\"tls_cert_file\"`\n\t// TLSKeyFile specifies the path of the secret key that the server will\n\t// load. If \"tls_cert_file\", \"tls_key_file\" are valid, the server will use this\n\t// supplied tls configuration. Otherwise, the server will use the tls\n\t// configuration generated by itself.\n\tTLSKeyFile string `ini:\"tls_key_file\" json:\"tls_key_file\"`\n\t// TLSTrustedCaFile specifies the paths of the client cert files that the\n\t// server will load. It only works when \"tls_only\" is true. If\n\t// \"tls_trusted_ca_file\" is valid, the server will verify each client's\n\t// certificate.\n\tTLSTrustedCaFile string `ini:\"tls_trusted_ca_file\" json:\"tls_trusted_ca_file\"`\n\t// HeartBeatTimeout specifies the maximum time to wait for a heartbeat\n\t// before terminating the connection. It is not recommended to change this\n\t// value. By default, this value is 90. Set negative value to disable it.\n\tHeartbeatTimeout int64 `ini:\"heartbeat_timeout\" json:\"heartbeat_timeout\"`\n\t// UserConnTimeout specifies the maximum time to wait for a work\n\t// connection. By default, this value is 10.\n\tUserConnTimeout int64 `ini:\"user_conn_timeout\" json:\"user_conn_timeout\"`\n\t// HTTPPlugins specify the server plugins support HTTP protocol.\n\tHTTPPlugins map[string]HTTPPluginOptions `ini:\"-\" json:\"http_plugins\"`\n\t// UDPPacketSize specifies the UDP packet size\n\t// By default, this value is 1500\n\tUDPPacketSize int64 `ini:\"udp_packet_size\" json:\"udp_packet_size\"`\n\t// Enable golang pprof handlers in dashboard listener.\n\t// Dashboard port must be set first.\n\tPprofEnable bool `ini:\"pprof_enable\" json:\"pprof_enable\"`\n\t// NatHoleAnalysisDataReserveHours specifies the hours to reserve nat hole analysis data.\n\tNatHoleAnalysisDataReserveHours int64 `ini:\"nat_hole_analysis_data_reserve_hours\" json:\"nat_hole_analysis_data_reserve_hours\"`\n}\n\n// GetDefaultServerConf returns a server configuration with reasonable defaults.\n// Note: Some default values here will be set to empty and will be converted to them\n// new configuration through the 'Complete' function to set them as the default\n// values of the new configuration.\nfunc GetDefaultServerConf() ServerCommonConf {\n\treturn ServerCommonConf{\n\t\tServerConfig:           legacyauth.GetDefaultServerConf(),\n\t\tDashboardAddr:          \"0.0.0.0\",\n\t\tLogFile:                \"console\",\n\t\tLogWay:                 \"console\",\n\t\tDetailedErrorsToClient: true,\n\t\tTCPMux:                 true,\n\t\tAllowPorts:             make(map[int]struct{}),\n\t\tHTTPPlugins:            make(map[string]HTTPPluginOptions),\n\t}\n}\n\nfunc UnmarshalServerConfFromIni(source any) (ServerCommonConf, error) {\n\tf, err := ini.LoadSources(ini.LoadOptions{\n\t\tInsensitive:         false,\n\t\tInsensitiveSections: false,\n\t\tInsensitiveKeys:     false,\n\t\tIgnoreInlineComment: true,\n\t\tAllowBooleanKeys:    true,\n\t}, source)\n\tif err != nil {\n\t\treturn ServerCommonConf{}, err\n\t}\n\n\ts, err := f.GetSection(\"common\")\n\tif err != nil {\n\t\treturn ServerCommonConf{}, err\n\t}\n\n\tcommon := GetDefaultServerConf()\n\terr = s.MapTo(&common)\n\tif err != nil {\n\t\treturn ServerCommonConf{}, err\n\t}\n\n\t// allow_ports\n\tallowPortStr := s.Key(\"allow_ports\").String()\n\tif allowPortStr != \"\" {\n\t\tcommon.AllowPortsStr = allowPortStr\n\t}\n\n\t// plugin.xxx\n\tpluginOpts := make(map[string]HTTPPluginOptions)\n\tfor _, section := range f.Sections() {\n\t\tname := section.Name()\n\t\tif !strings.HasPrefix(name, \"plugin.\") {\n\t\t\tcontinue\n\t\t}\n\n\t\topt, err := loadHTTPPluginOpt(section)\n\t\tif err != nil {\n\t\t\treturn ServerCommonConf{}, err\n\t\t}\n\n\t\tpluginOpts[opt.Name] = *opt\n\t}\n\tcommon.HTTPPlugins = pluginOpts\n\n\treturn common, nil\n}\n\nfunc loadHTTPPluginOpt(section *ini.Section) (*HTTPPluginOptions, error) {\n\tname := strings.TrimSpace(strings.TrimPrefix(section.Name(), \"plugin.\"))\n\n\topt := &HTTPPluginOptions{}\n\terr := section.MapTo(opt)\n\tif err != nil {\n\t\treturn nil, err\n\t}\n\n\topt.Name = name\n\n\treturn opt, nil\n}\n"
  },
  {
    "path": "pkg/config/legacy/utils.go",
    "content": "// Copyright 2020 The frp Authors\n//\n// Licensed under the Apache License, Version 2.0 (the \"License\");\n// you may not use this file except in compliance with the License.\n// You may obtain a copy of the License at\n//\n//     http://www.apache.org/licenses/LICENSE-2.0\n//\n// Unless required by applicable law or agreed to in writing, software\n// distributed under the License is distributed on an \"AS IS\" BASIS,\n// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.\n// See the License for the specific language governing permissions and\n// limitations under the License.\n\npackage legacy\n\nimport (\n\t\"strings\"\n)\n\nfunc GetMapWithoutPrefix(set map[string]string, prefix string) map[string]string {\n\tm := make(map[string]string)\n\n\tfor key, value := range set {\n\t\tif trimmed, ok := strings.CutPrefix(key, prefix); ok {\n\t\t\tm[trimmed] = value\n\t\t}\n\t}\n\n\tif len(m) == 0 {\n\t\treturn nil\n\t}\n\n\treturn m\n}\n\nfunc GetMapByPrefix(set map[string]string, prefix string) map[string]string {\n\tm := make(map[string]string)\n\n\tfor key, value := range set {\n\t\tif strings.HasPrefix(key, prefix) {\n\t\t\tm[key] = value\n\t\t}\n\t}\n\n\tif len(m) == 0 {\n\t\treturn nil\n\t}\n\n\treturn m\n}\n"
  },
  {
    "path": "pkg/config/legacy/value.go",
    "content": "// Copyright 2020 The frp Authors\n//\n// Licensed under the Apache License, Version 2.0 (the \"License\");\n// you may not use this file except in compliance with the License.\n// You may obtain a copy of the License at\n//\n//     http://www.apache.org/licenses/LICENSE-2.0\n//\n// Unless required by applicable law or agreed to in writing, software\n// distributed under the License is distributed on an \"AS IS\" BASIS,\n// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.\n// See the License for the specific language governing permissions and\n// limitations under the License.\n\npackage legacy\n\nimport (\n\t\"bytes\"\n\t\"os\"\n\t\"strings\"\n\t\"text/template\"\n)\n\nvar glbEnvs map[string]string\n\nfunc init() {\n\tglbEnvs = make(map[string]string)\n\tenvs := os.Environ()\n\tfor _, env := range envs {\n\t\tpair := strings.SplitN(env, \"=\", 2)\n\t\tif len(pair) != 2 {\n\t\t\tcontinue\n\t\t}\n\t\tglbEnvs[pair[0]] = pair[1]\n\t}\n}\n\ntype Values struct {\n\tEnvs map[string]string // environment vars\n}\n\nfunc GetValues() *Values {\n\treturn &Values{\n\t\tEnvs: glbEnvs,\n\t}\n}\n\nfunc RenderContent(in []byte) (out []byte, err error) {\n\ttmpl, errRet := template.New(\"frp\").Parse(string(in))\n\tif errRet != nil {\n\t\terr = errRet\n\t\treturn\n\t}\n\n\tbuffer := bytes.NewBufferString(\"\")\n\tv := GetValues()\n\terr = tmpl.Execute(buffer, v)\n\tif err != nil {\n\t\treturn\n\t}\n\tout = buffer.Bytes()\n\treturn\n}\n\nfunc GetRenderedConfFromFile(path string) (out []byte, err error) {\n\tvar b []byte\n\tb, err = os.ReadFile(path)\n\tif err != nil {\n\t\treturn\n\t}\n\n\tout, err = RenderContent(b)\n\treturn\n}\n"
  },
  {
    "path": "pkg/config/legacy/visitor.go",
    "content": "// Copyright 2023 The frp Authors\n//\n// Licensed under the Apache License, Version 2.0 (the \"License\");\n// you may not use this file except in compliance with the License.\n// You may obtain a copy of the License at\n//\n//     http://www.apache.org/licenses/LICENSE-2.0\n//\n// Unless required by applicable law or agreed to in writing, software\n// distributed under the License is distributed on an \"AS IS\" BASIS,\n// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.\n// See the License for the specific language governing permissions and\n// limitations under the License.\n\npackage legacy\n\nimport (\n\t\"fmt\"\n\t\"reflect\"\n\n\t\"gopkg.in/ini.v1\"\n)\n\ntype VisitorType string\n\nconst (\n\tVisitorTypeSTCP VisitorType = \"stcp\"\n\tVisitorTypeXTCP VisitorType = \"xtcp\"\n\tVisitorTypeSUDP VisitorType = \"sudp\"\n)\n\n// Visitor\nvar (\n\tvisitorConfTypeMap = map[VisitorType]reflect.Type{\n\t\tVisitorTypeSTCP: reflect.TypeFor[STCPVisitorConf](),\n\t\tVisitorTypeXTCP: reflect.TypeFor[XTCPVisitorConf](),\n\t\tVisitorTypeSUDP: reflect.TypeFor[SUDPVisitorConf](),\n\t}\n)\n\ntype VisitorConf interface {\n\t// GetBaseConfig returns the base config of visitor.\n\tGetBaseConfig() *BaseVisitorConf\n\t// UnmarshalFromIni unmarshals config from ini.\n\tUnmarshalFromIni(prefix string, name string, section *ini.Section) error\n}\n\n// DefaultVisitorConf creates a empty VisitorConf object by visitorType.\n// If visitorType doesn't exist, return nil.\nfunc DefaultVisitorConf(visitorType VisitorType) VisitorConf {\n\tv, ok := visitorConfTypeMap[visitorType]\n\tif !ok {\n\t\treturn nil\n\t}\n\treturn reflect.New(v).Interface().(VisitorConf)\n}\n\ntype BaseVisitorConf struct {\n\tProxyName      string `ini:\"name\" json:\"name\"`\n\tProxyType      string `ini:\"type\" json:\"type\"`\n\tUseEncryption  bool   `ini:\"use_encryption\" json:\"use_encryption\"`\n\tUseCompression bool   `ini:\"use_compression\" json:\"use_compression\"`\n\tRole           string `ini:\"role\" json:\"role\"`\n\tSk             string `ini:\"sk\" json:\"sk\"`\n\t// if the server user is not set, it defaults to the current user\n\tServerUser string `ini:\"server_user\" json:\"server_user\"`\n\tServerName string `ini:\"server_name\" json:\"server_name\"`\n\tBindAddr   string `ini:\"bind_addr\" json:\"bind_addr\"`\n\t// BindPort is the port that visitor listens on.\n\t// It can be less than 0, it means don't bind to the port and only receive connections redirected from\n\t// other visitors. (This is not supported for SUDP now)\n\tBindPort int `ini:\"bind_port\" json:\"bind_port\"`\n}\n\n// Base\nfunc (cfg *BaseVisitorConf) GetBaseConfig() *BaseVisitorConf {\n\treturn cfg\n}\n\nfunc (cfg *BaseVisitorConf) unmarshalFromIni(_ string, name string, _ *ini.Section) error {\n\t// Custom decoration after basic unmarshal:\n\tcfg.ProxyName = name\n\n\t// bind_addr\n\tif cfg.BindAddr == \"\" {\n\t\tcfg.BindAddr = \"127.0.0.1\"\n\t}\n\treturn nil\n}\n\nfunc preVisitorUnmarshalFromIni(cfg VisitorConf, prefix string, name string, section *ini.Section) error {\n\terr := section.MapTo(cfg)\n\tif err != nil {\n\t\treturn err\n\t}\n\n\terr = cfg.GetBaseConfig().unmarshalFromIni(prefix, name, section)\n\tif err != nil {\n\t\treturn err\n\t}\n\treturn nil\n}\n\ntype SUDPVisitorConf struct {\n\tBaseVisitorConf `ini:\",extends\"`\n}\n\nfunc (cfg *SUDPVisitorConf) UnmarshalFromIni(prefix string, name string, section *ini.Section) (err error) {\n\terr = preVisitorUnmarshalFromIni(cfg, prefix, name, section)\n\tif err != nil {\n\t\treturn\n\t}\n\n\t// Add custom logic unmarshal, if exists\n\n\treturn\n}\n\ntype STCPVisitorConf struct {\n\tBaseVisitorConf `ini:\",extends\"`\n}\n\nfunc (cfg *STCPVisitorConf) UnmarshalFromIni(prefix string, name string, section *ini.Section) (err error) {\n\terr = preVisitorUnmarshalFromIni(cfg, prefix, name, section)\n\tif err != nil {\n\t\treturn\n\t}\n\n\t// Add custom logic unmarshal, if exists\n\n\treturn\n}\n\ntype XTCPVisitorConf struct {\n\tBaseVisitorConf `ini:\",extends\"`\n\n\tProtocol          string `ini:\"protocol\" json:\"protocol,omitempty\"`\n\tKeepTunnelOpen    bool   `ini:\"keep_tunnel_open\" json:\"keep_tunnel_open,omitempty\"`\n\tMaxRetriesAnHour  int    `ini:\"max_retries_an_hour\" json:\"max_retries_an_hour,omitempty\"`\n\tMinRetryInterval  int    `ini:\"min_retry_interval\" json:\"min_retry_interval,omitempty\"`\n\tFallbackTo        string `ini:\"fallback_to\" json:\"fallback_to,omitempty\"`\n\tFallbackTimeoutMs int    `ini:\"fallback_timeout_ms\" json:\"fallback_timeout_ms,omitempty\"`\n}\n\nfunc (cfg *XTCPVisitorConf) UnmarshalFromIni(prefix string, name string, section *ini.Section) (err error) {\n\terr = preVisitorUnmarshalFromIni(cfg, prefix, name, section)\n\tif err != nil {\n\t\treturn\n\t}\n\n\t// Add custom logic unmarshal, if exists\n\tif cfg.Protocol == \"\" {\n\t\tcfg.Protocol = \"quic\"\n\t}\n\tif cfg.MaxRetriesAnHour <= 0 {\n\t\tcfg.MaxRetriesAnHour = 8\n\t}\n\tif cfg.MinRetryInterval <= 0 {\n\t\tcfg.MinRetryInterval = 90\n\t}\n\tif cfg.FallbackTimeoutMs <= 0 {\n\t\tcfg.FallbackTimeoutMs = 1000\n\t}\n\treturn\n}\n\n// Visitor loaded from ini\nfunc NewVisitorConfFromIni(prefix string, name string, section *ini.Section) (VisitorConf, error) {\n\t// section.Key: if key not exists, section will set it with default value.\n\tvisitorType := VisitorType(section.Key(\"type\").String())\n\n\tif visitorType == \"\" {\n\t\treturn nil, fmt.Errorf(\"type shouldn't be empty\")\n\t}\n\n\tconf := DefaultVisitorConf(visitorType)\n\tif conf == nil {\n\t\treturn nil, fmt.Errorf(\"type [%s] error\", visitorType)\n\t}\n\n\tif err := conf.UnmarshalFromIni(prefix, name, section); err != nil {\n\t\treturn nil, fmt.Errorf(\"type [%s] error\", visitorType)\n\t}\n\treturn conf, nil\n}\n"
  },
  {
    "path": "pkg/config/load.go",
    "content": "// Copyright 2023 The frp Authors\n//\n// Licensed under the Apache License, Version 2.0 (the \"License\");\n// you may not use this file except in compliance with the License.\n// You may obtain a copy of the License at\n//\n//     http://www.apache.org/licenses/LICENSE-2.0\n//\n// Unless required by applicable law or agreed to in writing, software\n// distributed under the License is distributed on an \"AS IS\" BASIS,\n// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.\n// See the License for the specific language governing permissions and\n// limitations under the License.\n\npackage config\n\nimport (\n\t\"bytes\"\n\t\"encoding/json\"\n\t\"errors\"\n\t\"fmt\"\n\t\"os\"\n\t\"path/filepath\"\n\t\"strings\"\n\t\"text/template\"\n\n\ttoml \"github.com/pelletier/go-toml/v2\"\n\t\"github.com/samber/lo\"\n\t\"gopkg.in/ini.v1\"\n\t\"k8s.io/apimachinery/pkg/util/sets\"\n\t\"k8s.io/apimachinery/pkg/util/yaml\"\n\n\t\"github.com/fatedier/frp/pkg/config/legacy\"\n\tv1 \"github.com/fatedier/frp/pkg/config/v1\"\n\t\"github.com/fatedier/frp/pkg/config/v1/validation\"\n\t\"github.com/fatedier/frp/pkg/msg\"\n\t\"github.com/fatedier/frp/pkg/util/jsonx\"\n\t\"github.com/fatedier/frp/pkg/util/util\"\n)\n\nvar glbEnvs map[string]string\n\nfunc init() {\n\tglbEnvs = make(map[string]string)\n\tenvs := os.Environ()\n\tfor _, env := range envs {\n\t\tpair := strings.SplitN(env, \"=\", 2)\n\t\tif len(pair) != 2 {\n\t\t\tcontinue\n\t\t}\n\t\tglbEnvs[pair[0]] = pair[1]\n\t}\n}\n\ntype Values struct {\n\tEnvs map[string]string // environment vars\n}\n\nfunc GetValues() *Values {\n\treturn &Values{\n\t\tEnvs: glbEnvs,\n\t}\n}\n\nfunc DetectLegacyINIFormat(content []byte) bool {\n\tf, err := ini.Load(content)\n\tif err != nil {\n\t\treturn false\n\t}\n\tif _, err := f.GetSection(\"common\"); err == nil {\n\t\treturn true\n\t}\n\treturn false\n}\n\nfunc DetectLegacyINIFormatFromFile(path string) bool {\n\tb, err := os.ReadFile(path)\n\tif err != nil {\n\t\treturn false\n\t}\n\treturn DetectLegacyINIFormat(b)\n}\n\nfunc RenderWithTemplate(in []byte, values *Values) ([]byte, error) {\n\ttmpl, err := template.New(\"frp\").Funcs(template.FuncMap{\n\t\t\"parseNumberRange\":     parseNumberRange,\n\t\t\"parseNumberRangePair\": parseNumberRangePair,\n\t}).Parse(string(in))\n\tif err != nil {\n\t\treturn nil, err\n\t}\n\n\tbuffer := bytes.NewBufferString(\"\")\n\tif err := tmpl.Execute(buffer, values); err != nil {\n\t\treturn nil, err\n\t}\n\treturn buffer.Bytes(), nil\n}\n\nfunc LoadFileContentWithTemplate(path string, values *Values) ([]byte, error) {\n\tb, err := os.ReadFile(path)\n\tif err != nil {\n\t\treturn nil, err\n\t}\n\treturn RenderWithTemplate(b, values)\n}\n\nfunc LoadConfigureFromFile(path string, c any, strict bool) error {\n\tcontent, err := LoadFileContentWithTemplate(path, GetValues())\n\tif err != nil {\n\t\treturn err\n\t}\n\treturn LoadConfigure(content, c, strict, detectFormatFromPath(path))\n}\n\n// detectFormatFromPath returns a format hint based on the file extension.\nfunc detectFormatFromPath(path string) string {\n\tswitch strings.ToLower(filepath.Ext(path)) {\n\tcase \".toml\":\n\t\treturn \"toml\"\n\tcase \".yaml\", \".yml\":\n\t\treturn \"yaml\"\n\tcase \".json\":\n\t\treturn \"json\"\n\tdefault:\n\t\treturn \"\"\n\t}\n}\n\n// parseYAMLWithDotFieldsHandling parses YAML with dot-prefixed fields handling\n// This function handles both cases efficiently: with or without dot fields\nfunc parseYAMLWithDotFieldsHandling(content []byte, target any) error {\n\tvar temp any\n\tif err := yaml.Unmarshal(content, &temp); err != nil {\n\t\treturn err\n\t}\n\n\t// Remove dot fields if it's a map\n\tif tempMap, ok := temp.(map[string]any); ok {\n\t\tfor key := range tempMap {\n\t\t\tif strings.HasPrefix(key, \".\") {\n\t\t\t\tdelete(tempMap, key)\n\t\t\t}\n\t\t}\n\t}\n\n\t// Convert to JSON and decode with strict validation\n\tjsonBytes, err := jsonx.Marshal(temp)\n\tif err != nil {\n\t\treturn err\n\t}\n\treturn decodeJSONContent(jsonBytes, target, true)\n}\n\nfunc decodeJSONContent(content []byte, target any, strict bool) error {\n\tif clientCfg, ok := target.(*v1.ClientConfig); ok {\n\t\tdecoded, err := v1.DecodeClientConfigJSON(content, v1.DecodeOptions{\n\t\t\tDisallowUnknownFields: strict,\n\t\t})\n\t\tif err != nil {\n\t\t\treturn err\n\t\t}\n\t\t*clientCfg = decoded\n\t\treturn nil\n\t}\n\n\treturn jsonx.UnmarshalWithOptions(content, target, jsonx.DecodeOptions{\n\t\tRejectUnknownMembers: strict,\n\t})\n}\n\n// LoadConfigure loads configuration from bytes and unmarshal into c.\n// Now it supports json, yaml and toml format.\n// An optional format hint (e.g. \"toml\", \"yaml\", \"json\") can be provided\n// to enable better error messages with line number information.\nfunc LoadConfigure(b []byte, c any, strict bool, formats ...string) error {\n\tformat := \"\"\n\tif len(formats) > 0 {\n\t\tformat = formats[0]\n\t}\n\n\toriginalBytes := b\n\tparsedFromTOML := false\n\n\tvar tomlObj any\n\ttomlErr := toml.Unmarshal(b, &tomlObj)\n\tif tomlErr == nil {\n\t\tparsedFromTOML = true\n\t\tvar err error\n\t\tb, err = jsonx.Marshal(&tomlObj)\n\t\tif err != nil {\n\t\t\treturn err\n\t\t}\n\t} else if format == \"toml\" {\n\t\t// File is known to be TOML but has syntax errors.\n\t\treturn formatTOMLError(tomlErr)\n\t}\n\n\t// If the buffer smells like JSON (first non-whitespace character is '{'), unmarshal as JSON directly.\n\tif yaml.IsJSONBuffer(b) {\n\t\tif err := decodeJSONContent(b, c, strict); err != nil {\n\t\t\treturn enhanceDecodeError(err, originalBytes, !parsedFromTOML)\n\t\t}\n\t\treturn nil\n\t}\n\n\t// Handle YAML content\n\tif strict {\n\t\t// In strict mode, always use our custom handler to support YAML merge\n\t\tif err := parseYAMLWithDotFieldsHandling(b, c); err != nil {\n\t\t\treturn enhanceDecodeError(err, originalBytes, !parsedFromTOML)\n\t\t}\n\t\treturn nil\n\t}\n\t// Non-strict mode, parse normally\n\treturn yaml.Unmarshal(b, c)\n}\n\n// formatTOMLError extracts line/column information from TOML decode errors.\nfunc formatTOMLError(err error) error {\n\tvar decErr *toml.DecodeError\n\tif errors.As(err, &decErr) {\n\t\trow, col := decErr.Position()\n\t\treturn fmt.Errorf(\"toml: line %d, column %d: %s\", row, col, decErr.Error())\n\t}\n\tvar strictErr *toml.StrictMissingError\n\tif errors.As(err, &strictErr) {\n\t\treturn strictErr\n\t}\n\treturn err\n}\n\n// enhanceDecodeError tries to add field path and line number information to JSON/YAML decode errors.\nfunc enhanceDecodeError(err error, originalContent []byte, includeLine bool) error {\n\tvar typeErr *json.UnmarshalTypeError\n\tif errors.As(err, &typeErr) && typeErr.Field != \"\" {\n\t\tif includeLine {\n\t\t\tline := findFieldLineInContent(originalContent, typeErr.Field)\n\t\t\tif line > 0 {\n\t\t\t\treturn fmt.Errorf(\"line %d: field \\\"%s\\\": cannot unmarshal %s into %s\", line, typeErr.Field, typeErr.Value, typeErr.Type)\n\t\t\t}\n\t\t}\n\t\treturn fmt.Errorf(\"field \\\"%s\\\": cannot unmarshal %s into %s\", typeErr.Field, typeErr.Value, typeErr.Type)\n\t}\n\treturn err\n}\n\n// findFieldLineInContent searches the original config content for a field name\n// and returns the 1-indexed line number where it appears, or 0 if not found.\nfunc findFieldLineInContent(content []byte, fieldPath string) int {\n\tif fieldPath == \"\" {\n\t\treturn 0\n\t}\n\n\t// Use the last component of the field path (e.g. \"proxies\" from \"proxies\" or\n\t// \"protocol\" from \"transport.protocol\").\n\tparts := strings.Split(fieldPath, \".\")\n\tsearchKey := parts[len(parts)-1]\n\n\tlines := bytes.Split(content, []byte(\"\\n\"))\n\tfor i, line := range lines {\n\t\ttrimmed := bytes.TrimSpace(line)\n\t\t// Match TOML key assignments like: key = ...\n\t\tif bytes.HasPrefix(trimmed, []byte(searchKey)) {\n\t\t\trest := bytes.TrimSpace(trimmed[len(searchKey):])\n\t\t\tif len(rest) > 0 && rest[0] == '=' {\n\t\t\t\treturn i + 1\n\t\t\t}\n\t\t}\n\t\t// Match TOML table array headers like: [[proxies]]\n\t\tif bytes.Contains(trimmed, []byte(\"[[\"+searchKey+\"]]\")) {\n\t\t\treturn i + 1\n\t\t}\n\t}\n\treturn 0\n}\n\nfunc NewProxyConfigurerFromMsg(m *msg.NewProxy, serverCfg *v1.ServerConfig) (v1.ProxyConfigurer, error) {\n\tm.ProxyType = util.EmptyOr(m.ProxyType, string(v1.ProxyTypeTCP))\n\n\tconfigurer := v1.NewProxyConfigurerByType(v1.ProxyType(m.ProxyType))\n\tif configurer == nil {\n\t\treturn nil, fmt.Errorf(\"unknown proxy type: %s\", m.ProxyType)\n\t}\n\n\tconfigurer.UnmarshalFromMsg(m)\n\tconfigurer.Complete()\n\n\tif err := validation.ValidateProxyConfigurerForServer(configurer, serverCfg); err != nil {\n\t\treturn nil, err\n\t}\n\treturn configurer, nil\n}\n\nfunc LoadServerConfig(path string, strict bool) (*v1.ServerConfig, bool, error) {\n\tvar (\n\t\tsvrCfg         *v1.ServerConfig\n\t\tisLegacyFormat bool\n\t)\n\t// detect legacy ini format\n\tif DetectLegacyINIFormatFromFile(path) {\n\t\tcontent, err := legacy.GetRenderedConfFromFile(path)\n\t\tif err != nil {\n\t\t\treturn nil, true, err\n\t\t}\n\t\tlegacyCfg, err := legacy.UnmarshalServerConfFromIni(content)\n\t\tif err != nil {\n\t\t\treturn nil, true, err\n\t\t}\n\t\tsvrCfg = legacy.Convert_ServerCommonConf_To_v1(&legacyCfg)\n\t\tisLegacyFormat = true\n\t} else {\n\t\tsvrCfg = &v1.ServerConfig{}\n\t\tif err := LoadConfigureFromFile(path, svrCfg, strict); err != nil {\n\t\t\treturn nil, false, err\n\t\t}\n\t}\n\tif svrCfg != nil {\n\t\tif err := svrCfg.Complete(); err != nil {\n\t\t\treturn nil, isLegacyFormat, err\n\t\t}\n\t}\n\treturn svrCfg, isLegacyFormat, nil\n}\n\n// ClientConfigLoadResult contains the result of loading a client configuration file.\ntype ClientConfigLoadResult struct {\n\t// Common contains the common client configuration.\n\tCommon *v1.ClientCommonConfig\n\n\t// Proxies contains proxy configurations from inline [[proxies]] and includeConfigFiles.\n\t// These are NOT completed (user prefix not added).\n\tProxies []v1.ProxyConfigurer\n\n\t// Visitors contains visitor configurations from inline [[visitors]] and includeConfigFiles.\n\t// These are NOT completed.\n\tVisitors []v1.VisitorConfigurer\n\n\t// IsLegacyFormat indicates whether the config file is in legacy INI format.\n\tIsLegacyFormat bool\n}\n\n// LoadClientConfigResult loads and parses a client configuration file.\n// It returns the raw configuration without completing proxies/visitors.\n// The caller should call Complete on the configs manually for legacy behavior.\nfunc LoadClientConfigResult(path string, strict bool) (*ClientConfigLoadResult, error) {\n\tresult := &ClientConfigLoadResult{\n\t\tProxies:  make([]v1.ProxyConfigurer, 0),\n\t\tVisitors: make([]v1.VisitorConfigurer, 0),\n\t}\n\n\tif DetectLegacyINIFormatFromFile(path) {\n\t\tlegacyCommon, legacyProxyCfgs, legacyVisitorCfgs, err := legacy.ParseClientConfig(path)\n\t\tif err != nil {\n\t\t\treturn nil, err\n\t\t}\n\t\tresult.Common = legacy.Convert_ClientCommonConf_To_v1(&legacyCommon)\n\t\tfor _, c := range legacyProxyCfgs {\n\t\t\tresult.Proxies = append(result.Proxies, legacy.Convert_ProxyConf_To_v1(c))\n\t\t}\n\t\tfor _, c := range legacyVisitorCfgs {\n\t\t\tresult.Visitors = append(result.Visitors, legacy.Convert_VisitorConf_To_v1(c))\n\t\t}\n\t\tresult.IsLegacyFormat = true\n\t} else {\n\t\tallCfg := v1.ClientConfig{}\n\t\tif err := LoadConfigureFromFile(path, &allCfg, strict); err != nil {\n\t\t\treturn nil, err\n\t\t}\n\t\tresult.Common = &allCfg.ClientCommonConfig\n\t\tfor _, c := range allCfg.Proxies {\n\t\t\tresult.Proxies = append(result.Proxies, c.ProxyConfigurer)\n\t\t}\n\t\tfor _, c := range allCfg.Visitors {\n\t\t\tresult.Visitors = append(result.Visitors, c.VisitorConfigurer)\n\t\t}\n\t}\n\n\t// Load additional config from includes.\n\t// legacy ini format already handle this in ParseClientConfig.\n\tif len(result.Common.IncludeConfigFiles) > 0 && !result.IsLegacyFormat {\n\t\textProxyCfgs, extVisitorCfgs, err := LoadAdditionalClientConfigs(result.Common.IncludeConfigFiles, result.IsLegacyFormat, strict)\n\t\tif err != nil {\n\t\t\treturn nil, err\n\t\t}\n\t\tresult.Proxies = append(result.Proxies, extProxyCfgs...)\n\t\tresult.Visitors = append(result.Visitors, extVisitorCfgs...)\n\t}\n\n\t// Complete the common config\n\tif result.Common != nil {\n\t\tif err := result.Common.Complete(); err != nil {\n\t\t\treturn nil, err\n\t\t}\n\t}\n\n\treturn result, nil\n}\n\nfunc LoadClientConfig(path string, strict bool) (\n\t*v1.ClientCommonConfig,\n\t[]v1.ProxyConfigurer,\n\t[]v1.VisitorConfigurer,\n\tbool, error,\n) {\n\tresult, err := LoadClientConfigResult(path, strict)\n\tif err != nil {\n\t\treturn nil, nil, nil, result != nil && result.IsLegacyFormat, err\n\t}\n\n\tproxyCfgs := result.Proxies\n\tvisitorCfgs := result.Visitors\n\n\tproxyCfgs, visitorCfgs = FilterClientConfigurers(result.Common, proxyCfgs, visitorCfgs)\n\tproxyCfgs = CompleteProxyConfigurers(proxyCfgs)\n\tvisitorCfgs = CompleteVisitorConfigurers(visitorCfgs)\n\treturn result.Common, proxyCfgs, visitorCfgs, result.IsLegacyFormat, nil\n}\n\nfunc CompleteProxyConfigurers(proxies []v1.ProxyConfigurer) []v1.ProxyConfigurer {\n\tproxyCfgs := proxies\n\tfor _, c := range proxyCfgs {\n\t\tc.Complete()\n\t}\n\treturn proxyCfgs\n}\n\nfunc CompleteVisitorConfigurers(visitors []v1.VisitorConfigurer) []v1.VisitorConfigurer {\n\tvisitorCfgs := visitors\n\tfor _, c := range visitorCfgs {\n\t\tc.Complete()\n\t}\n\treturn visitorCfgs\n}\n\nfunc FilterClientConfigurers(\n\tcommon *v1.ClientCommonConfig,\n\tproxies []v1.ProxyConfigurer,\n\tvisitors []v1.VisitorConfigurer,\n) ([]v1.ProxyConfigurer, []v1.VisitorConfigurer) {\n\tif common == nil {\n\t\tcommon = &v1.ClientCommonConfig{}\n\t}\n\n\tproxyCfgs := proxies\n\tvisitorCfgs := visitors\n\n\t// Filter by start across merged configurers from all sources.\n\t// For example, store entries are also filtered by this set.\n\tif len(common.Start) > 0 {\n\t\tstartSet := sets.New(common.Start...)\n\t\tproxyCfgs = lo.Filter(proxyCfgs, func(c v1.ProxyConfigurer, _ int) bool {\n\t\t\treturn startSet.Has(c.GetBaseConfig().Name)\n\t\t})\n\t\tvisitorCfgs = lo.Filter(visitorCfgs, func(c v1.VisitorConfigurer, _ int) bool {\n\t\t\treturn startSet.Has(c.GetBaseConfig().Name)\n\t\t})\n\t}\n\n\t// Filter by enabled field in each proxy\n\t// nil or true means enabled, false means disabled\n\tproxyCfgs = lo.Filter(proxyCfgs, func(c v1.ProxyConfigurer, _ int) bool {\n\t\tenabled := c.GetBaseConfig().Enabled\n\t\treturn enabled == nil || *enabled\n\t})\n\tvisitorCfgs = lo.Filter(visitorCfgs, func(c v1.VisitorConfigurer, _ int) bool {\n\t\tenabled := c.GetBaseConfig().Enabled\n\t\treturn enabled == nil || *enabled\n\t})\n\treturn proxyCfgs, visitorCfgs\n}\n\nfunc LoadAdditionalClientConfigs(paths []string, isLegacyFormat bool, strict bool) ([]v1.ProxyConfigurer, []v1.VisitorConfigurer, error) {\n\tproxyCfgs := make([]v1.ProxyConfigurer, 0)\n\tvisitorCfgs := make([]v1.VisitorConfigurer, 0)\n\tfor _, path := range paths {\n\t\tabsDir, err := filepath.Abs(filepath.Dir(path))\n\t\tif err != nil {\n\t\t\treturn nil, nil, err\n\t\t}\n\t\tif _, err := os.Stat(absDir); os.IsNotExist(err) {\n\t\t\treturn nil, nil, err\n\t\t}\n\t\tfiles, err := os.ReadDir(absDir)\n\t\tif err != nil {\n\t\t\treturn nil, nil, err\n\t\t}\n\t\tfor _, fi := range files {\n\t\t\tif fi.IsDir() {\n\t\t\t\tcontinue\n\t\t\t}\n\t\t\tabsFile := filepath.Join(absDir, fi.Name())\n\t\t\tif matched, _ := filepath.Match(filepath.Join(absDir, filepath.Base(path)), absFile); matched {\n\t\t\t\t// support yaml/json/toml\n\t\t\t\tcfg := v1.ClientConfig{}\n\t\t\t\tif err := LoadConfigureFromFile(absFile, &cfg, strict); err != nil {\n\t\t\t\t\treturn nil, nil, fmt.Errorf(\"load additional config from %s error: %v\", absFile, err)\n\t\t\t\t}\n\t\t\t\tfor _, c := range cfg.Proxies {\n\t\t\t\t\tproxyCfgs = append(proxyCfgs, c.ProxyConfigurer)\n\t\t\t\t}\n\t\t\t\tfor _, c := range cfg.Visitors {\n\t\t\t\t\tvisitorCfgs = append(visitorCfgs, c.VisitorConfigurer)\n\t\t\t\t}\n\t\t\t}\n\t\t}\n\t}\n\treturn proxyCfgs, visitorCfgs, nil\n}\n"
  },
  {
    "path": "pkg/config/load_test.go",
    "content": "// Copyright 2023 The frp Authors\n//\n// Licensed under the Apache License, Version 2.0 (the \"License\");\n// you may not use this file except in compliance with the License.\n// You may obtain a copy of the License at\n//\n//     http://www.apache.org/licenses/LICENSE-2.0\n//\n// Unless required by applicable law or agreed to in writing, software\n// distributed under the License is distributed on an \"AS IS\" BASIS,\n// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.\n// See the License for the specific language governing permissions and\n// limitations under the License.\n\npackage config\n\nimport (\n\t\"encoding/json\"\n\t\"fmt\"\n\t\"strings\"\n\t\"testing\"\n\n\t\"github.com/stretchr/testify/require\"\n\n\tv1 \"github.com/fatedier/frp/pkg/config/v1\"\n)\n\nconst tomlServerContent = `\nbindAddr = \"127.0.0.1\"\nkcpBindPort = 7000\nquicBindPort = 7001\ntcpmuxHTTPConnectPort = 7005\ncustom404Page = \"/abc.html\"\ntransport.tcpKeepalive = 10\n`\n\nconst yamlServerContent = `\nbindAddr: 127.0.0.1\nkcpBindPort: 7000\nquicBindPort: 7001\ntcpmuxHTTPConnectPort: 7005\ncustom404Page: /abc.html\ntransport:\n  tcpKeepalive: 10\n`\n\nconst jsonServerContent = `\n{\n  \"bindAddr\": \"127.0.0.1\",\n  \"kcpBindPort\": 7000,\n  \"quicBindPort\": 7001,\n  \"tcpmuxHTTPConnectPort\": 7005,\n  \"custom404Page\": \"/abc.html\",\n  \"transport\": {\n    \"tcpKeepalive\": 10\n  }\n}\n`\n\nfunc TestLoadServerConfig(t *testing.T) {\n\ttests := []struct {\n\t\tname    string\n\t\tcontent string\n\t}{\n\t\t{\"toml\", tomlServerContent},\n\t\t{\"yaml\", yamlServerContent},\n\t\t{\"json\", jsonServerContent},\n\t}\n\tfor _, test := range tests {\n\t\tt.Run(test.name, func(t *testing.T) {\n\t\t\trequire := require.New(t)\n\t\t\tsvrCfg := v1.ServerConfig{}\n\t\t\terr := LoadConfigure([]byte(test.content), &svrCfg, true)\n\t\t\trequire.NoError(err)\n\t\t\trequire.EqualValues(\"127.0.0.1\", svrCfg.BindAddr)\n\t\t\trequire.EqualValues(7000, svrCfg.KCPBindPort)\n\t\t\trequire.EqualValues(7001, svrCfg.QUICBindPort)\n\t\t\trequire.EqualValues(7005, svrCfg.TCPMuxHTTPConnectPort)\n\t\t\trequire.EqualValues(\"/abc.html\", svrCfg.Custom404Page)\n\t\t\trequire.EqualValues(10, svrCfg.Transport.TCPKeepAlive)\n\t\t})\n\t}\n}\n\n// Test that loading in strict mode fails when the config is invalid.\nfunc TestLoadServerConfigStrictMode(t *testing.T) {\n\ttests := []struct {\n\t\tname    string\n\t\tcontent string\n\t}{\n\t\t{\"toml\", tomlServerContent},\n\t\t{\"yaml\", yamlServerContent},\n\t\t{\"json\", jsonServerContent},\n\t}\n\n\tfor _, strict := range []bool{false, true} {\n\t\tfor _, test := range tests {\n\t\t\tt.Run(fmt.Sprintf(\"%s-strict-%t\", test.name, strict), func(t *testing.T) {\n\t\t\t\trequire := require.New(t)\n\t\t\t\t// Break the content with an innocent typo\n\t\t\t\tbrokenContent := strings.Replace(test.content, \"bindAddr\", \"bindAdur\", 1)\n\t\t\t\tsvrCfg := v1.ServerConfig{}\n\t\t\t\terr := LoadConfigure([]byte(brokenContent), &svrCfg, strict)\n\t\t\t\tif strict {\n\t\t\t\t\trequire.ErrorContains(err, \"bindAdur\")\n\t\t\t\t} else {\n\t\t\t\t\trequire.NoError(err)\n\t\t\t\t\t// BindAddr didn't get parsed because of the typo.\n\t\t\t\t\trequire.EqualValues(\"\", svrCfg.BindAddr)\n\t\t\t\t}\n\t\t\t})\n\t\t}\n\t}\n}\n\nfunc TestRenderWithTemplate(t *testing.T) {\n\ttests := []struct {\n\t\tname    string\n\t\tcontent string\n\t\twant    string\n\t}{\n\t\t{\"toml\", tomlServerContent, tomlServerContent},\n\t\t{\"yaml\", yamlServerContent, yamlServerContent},\n\t\t{\"json\", jsonServerContent, jsonServerContent},\n\t\t{\"template numeric\", `key = {{ 123 }}`, \"key = 123\"},\n\t\t{\"template string\", `key = {{ \"xyz\" }}`, \"key = xyz\"},\n\t\t{\"template quote\", `key = {{ printf \"%q\" \"with space\" }}`, `key = \"with space\"`},\n\t}\n\tfor _, test := range tests {\n\t\tt.Run(test.name, func(t *testing.T) {\n\t\t\trequire := require.New(t)\n\t\t\tgot, err := RenderWithTemplate([]byte(test.content), nil)\n\t\t\trequire.NoError(err)\n\t\t\trequire.EqualValues(test.want, string(got))\n\t\t})\n\t}\n}\n\nfunc TestCustomStructStrictMode(t *testing.T) {\n\trequire := require.New(t)\n\n\tproxyStr := `\nserverPort = 7000\n\n[[proxies]]\nname = \"test\"\ntype = \"tcp\"\nremotePort = 6000\n`\n\tclientCfg := v1.ClientConfig{}\n\terr := LoadConfigure([]byte(proxyStr), &clientCfg, true)\n\trequire.NoError(err)\n\n\tproxyStr += `unknown = \"unknown\"`\n\terr = LoadConfigure([]byte(proxyStr), &clientCfg, true)\n\trequire.Error(err)\n\n\tvisitorStr := `\nserverPort = 7000\n\n[[visitors]]\nname = \"test\"\ntype = \"stcp\"\nbindPort = 6000\nserverName = \"server\"\n`\n\terr = LoadConfigure([]byte(visitorStr), &clientCfg, true)\n\trequire.NoError(err)\n\n\tvisitorStr += `unknown = \"unknown\"`\n\terr = LoadConfigure([]byte(visitorStr), &clientCfg, true)\n\trequire.Error(err)\n\n\tpluginStr := `\nserverPort = 7000\n\n[[proxies]]\nname = \"test\"\ntype = \"tcp\"\nremotePort = 6000\n[proxies.plugin]\ntype = \"unix_domain_socket\"\nunixPath = \"/tmp/uds.sock\"\n`\n\terr = LoadConfigure([]byte(pluginStr), &clientCfg, true)\n\trequire.NoError(err)\n\tpluginStr += `unknown = \"unknown\"`\n\terr = LoadConfigure([]byte(pluginStr), &clientCfg, true)\n\trequire.Error(err)\n}\n\nfunc TestLoadClientConfigStrictMode_UnknownPluginField(t *testing.T) {\n\trequire := require.New(t)\n\n\tcontent := `\nserverPort = 7000\n\n[[proxies]]\nname = \"test\"\ntype = \"tcp\"\nlocalPort = 6000\n[proxies.plugin]\ntype = \"http2https\"\nlocalAddr = \"127.0.0.1:8080\"\nunknownInPlugin = \"value\"\n`\n\n\tclientCfg := v1.ClientConfig{}\n\n\terr := LoadConfigure([]byte(content), &clientCfg, false)\n\trequire.NoError(err)\n\n\terr = LoadConfigure([]byte(content), &clientCfg, true)\n\trequire.ErrorContains(err, \"unknownInPlugin\")\n}\n\n// TestYAMLMergeInStrictMode tests that YAML merge functionality works\n// even in strict mode by properly handling dot-prefixed fields\nfunc TestYAMLMergeInStrictMode(t *testing.T) {\n\trequire := require.New(t)\n\n\tyamlContent := `\nserverAddr: \"127.0.0.1\"\nserverPort: 7000\n\n.common: &common\n  type: stcp\n  secretKey: \"test-secret\"\n  localIP: 127.0.0.1\n  transport:\n    useEncryption: true\n    useCompression: true\n\nproxies:\n- name: ssh\n  localPort: 22\n  <<: *common\n- name: web\n  localPort: 80\n  <<: *common\n`\n\n\tclientCfg := v1.ClientConfig{}\n\t// This should work in strict mode\n\terr := LoadConfigure([]byte(yamlContent), &clientCfg, true)\n\trequire.NoError(err)\n\n\t// Verify the merge worked correctly\n\trequire.Equal(\"127.0.0.1\", clientCfg.ServerAddr)\n\trequire.Equal(7000, clientCfg.ServerPort)\n\trequire.Len(clientCfg.Proxies, 2)\n\n\t// Check first proxy\n\tsshProxy := clientCfg.Proxies[0].ProxyConfigurer\n\trequire.Equal(\"ssh\", sshProxy.GetBaseConfig().Name)\n\trequire.Equal(\"stcp\", sshProxy.GetBaseConfig().Type)\n\n\t// Check second proxy\n\twebProxy := clientCfg.Proxies[1].ProxyConfigurer\n\trequire.Equal(\"web\", webProxy.GetBaseConfig().Name)\n\trequire.Equal(\"stcp\", webProxy.GetBaseConfig().Type)\n}\n\n// TestOptimizedYAMLProcessing tests the optimization logic for YAML processing\nfunc TestOptimizedYAMLProcessing(t *testing.T) {\n\trequire := require.New(t)\n\n\tyamlWithDotFields := []byte(`\nserverAddr: \"127.0.0.1\"\n.common: &common\n  type: stcp\nproxies:\n- name: test\n  <<: *common\n`)\n\n\tyamlWithoutDotFields := []byte(`\nserverAddr: \"127.0.0.1\"\nproxies:\n- name: test\n  type: tcp\n  localPort: 22\n`)\n\n\t// Test that YAML without dot fields works in strict mode\n\tclientCfg := v1.ClientConfig{}\n\terr := LoadConfigure(yamlWithoutDotFields, &clientCfg, true)\n\trequire.NoError(err)\n\trequire.Equal(\"127.0.0.1\", clientCfg.ServerAddr)\n\trequire.Len(clientCfg.Proxies, 1)\n\trequire.Equal(\"test\", clientCfg.Proxies[0].ProxyConfigurer.GetBaseConfig().Name)\n\n\t// Test that YAML with dot fields still works in strict mode\n\terr = LoadConfigure(yamlWithDotFields, &clientCfg, true)\n\trequire.NoError(err)\n\trequire.Equal(\"127.0.0.1\", clientCfg.ServerAddr)\n\trequire.Len(clientCfg.Proxies, 1)\n\trequire.Equal(\"test\", clientCfg.Proxies[0].ProxyConfigurer.GetBaseConfig().Name)\n\trequire.Equal(\"stcp\", clientCfg.Proxies[0].ProxyConfigurer.GetBaseConfig().Type)\n}\n\nfunc TestFilterClientConfigurers_PreserveRawNamesAndNoMutation(t *testing.T) {\n\trequire := require.New(t)\n\n\tenabled := true\n\tproxyCfg := &v1.TCPProxyConfig{}\n\tproxyCfg.Name = \"proxy-raw\"\n\tproxyCfg.Type = \"tcp\"\n\tproxyCfg.LocalPort = 10080\n\tproxyCfg.Enabled = &enabled\n\n\tvisitorCfg := &v1.XTCPVisitorConfig{}\n\tvisitorCfg.Name = \"visitor-raw\"\n\tvisitorCfg.Type = \"xtcp\"\n\tvisitorCfg.ServerName = \"server-raw\"\n\tvisitorCfg.FallbackTo = \"fallback-raw\"\n\tvisitorCfg.SecretKey = \"secret\"\n\tvisitorCfg.BindPort = 10081\n\tvisitorCfg.Enabled = &enabled\n\n\tcommon := &v1.ClientCommonConfig{\n\t\tUser: \"alice\",\n\t}\n\n\tproxies, visitors := FilterClientConfigurers(common, []v1.ProxyConfigurer{proxyCfg}, []v1.VisitorConfigurer{visitorCfg})\n\trequire.Len(proxies, 1)\n\trequire.Len(visitors, 1)\n\n\tp := proxies[0].GetBaseConfig()\n\trequire.Equal(\"proxy-raw\", p.Name)\n\trequire.Empty(p.LocalIP)\n\n\tv := visitors[0].GetBaseConfig()\n\trequire.Equal(\"visitor-raw\", v.Name)\n\trequire.Equal(\"server-raw\", v.ServerName)\n\trequire.Empty(v.BindAddr)\n\n\txtcp := visitors[0].(*v1.XTCPVisitorConfig)\n\trequire.Equal(\"fallback-raw\", xtcp.FallbackTo)\n\trequire.Empty(xtcp.Protocol)\n}\n\nfunc TestCompleteProxyConfigurers_PreserveRawNames(t *testing.T) {\n\trequire := require.New(t)\n\n\tenabled := true\n\tproxyCfg := &v1.TCPProxyConfig{}\n\tproxyCfg.Name = \"proxy-raw\"\n\tproxyCfg.Type = \"tcp\"\n\tproxyCfg.LocalPort = 10080\n\tproxyCfg.Enabled = &enabled\n\n\tproxies := CompleteProxyConfigurers([]v1.ProxyConfigurer{proxyCfg})\n\trequire.Len(proxies, 1)\n\n\tp := proxies[0].GetBaseConfig()\n\trequire.Equal(\"proxy-raw\", p.Name)\n\trequire.Equal(\"127.0.0.1\", p.LocalIP)\n}\n\nfunc TestCompleteVisitorConfigurers_PreserveRawNames(t *testing.T) {\n\trequire := require.New(t)\n\n\tenabled := true\n\tvisitorCfg := &v1.XTCPVisitorConfig{}\n\tvisitorCfg.Name = \"visitor-raw\"\n\tvisitorCfg.Type = \"xtcp\"\n\tvisitorCfg.ServerName = \"server-raw\"\n\tvisitorCfg.FallbackTo = \"fallback-raw\"\n\tvisitorCfg.SecretKey = \"secret\"\n\tvisitorCfg.BindPort = 10081\n\tvisitorCfg.Enabled = &enabled\n\n\tvisitors := CompleteVisitorConfigurers([]v1.VisitorConfigurer{visitorCfg})\n\trequire.Len(visitors, 1)\n\n\tv := visitors[0].GetBaseConfig()\n\trequire.Equal(\"visitor-raw\", v.Name)\n\trequire.Equal(\"server-raw\", v.ServerName)\n\trequire.Equal(\"127.0.0.1\", v.BindAddr)\n\n\txtcp := visitors[0].(*v1.XTCPVisitorConfig)\n\trequire.Equal(\"fallback-raw\", xtcp.FallbackTo)\n\trequire.Equal(\"quic\", xtcp.Protocol)\n}\n\nfunc TestCompleteProxyConfigurers_Idempotent(t *testing.T) {\n\trequire := require.New(t)\n\n\tproxyCfg := &v1.TCPProxyConfig{}\n\tproxyCfg.Name = \"proxy\"\n\tproxyCfg.Type = \"tcp\"\n\tproxyCfg.LocalPort = 10080\n\n\tproxies := CompleteProxyConfigurers([]v1.ProxyConfigurer{proxyCfg})\n\tfirstProxyJSON, err := json.Marshal(proxies[0])\n\trequire.NoError(err)\n\n\tproxies = CompleteProxyConfigurers(proxies)\n\tsecondProxyJSON, err := json.Marshal(proxies[0])\n\trequire.NoError(err)\n\n\trequire.Equal(string(firstProxyJSON), string(secondProxyJSON))\n}\n\nfunc TestCompleteVisitorConfigurers_Idempotent(t *testing.T) {\n\trequire := require.New(t)\n\n\tvisitorCfg := &v1.XTCPVisitorConfig{}\n\tvisitorCfg.Name = \"visitor\"\n\tvisitorCfg.Type = \"xtcp\"\n\tvisitorCfg.ServerName = \"server\"\n\tvisitorCfg.SecretKey = \"secret\"\n\tvisitorCfg.BindPort = 10081\n\n\tvisitors := CompleteVisitorConfigurers([]v1.VisitorConfigurer{visitorCfg})\n\tfirstVisitorJSON, err := json.Marshal(visitors[0])\n\trequire.NoError(err)\n\n\tvisitors = CompleteVisitorConfigurers(visitors)\n\tsecondVisitorJSON, err := json.Marshal(visitors[0])\n\trequire.NoError(err)\n\n\trequire.Equal(string(firstVisitorJSON), string(secondVisitorJSON))\n}\n\nfunc TestFilterClientConfigurers_FilterByStartAndEnabled(t *testing.T) {\n\trequire := require.New(t)\n\n\tenabled := true\n\tdisabled := false\n\n\tproxyKeep := &v1.TCPProxyConfig{}\n\tproxyKeep.Name = \"keep\"\n\tproxyKeep.Type = \"tcp\"\n\tproxyKeep.LocalPort = 10080\n\tproxyKeep.Enabled = &enabled\n\n\tproxyDropByStart := &v1.TCPProxyConfig{}\n\tproxyDropByStart.Name = \"drop-by-start\"\n\tproxyDropByStart.Type = \"tcp\"\n\tproxyDropByStart.LocalPort = 10081\n\tproxyDropByStart.Enabled = &enabled\n\n\tproxyDropByEnabled := &v1.TCPProxyConfig{}\n\tproxyDropByEnabled.Name = \"drop-by-enabled\"\n\tproxyDropByEnabled.Type = \"tcp\"\n\tproxyDropByEnabled.LocalPort = 10082\n\tproxyDropByEnabled.Enabled = &disabled\n\n\tcommon := &v1.ClientCommonConfig{\n\t\tStart: []string{\"keep\"},\n\t}\n\n\tproxies, visitors := FilterClientConfigurers(common, []v1.ProxyConfigurer{\n\t\tproxyKeep,\n\t\tproxyDropByStart,\n\t\tproxyDropByEnabled,\n\t}, nil)\n\trequire.Len(visitors, 0)\n\trequire.Len(proxies, 1)\n\trequire.Equal(\"keep\", proxies[0].GetBaseConfig().Name)\n}\n\n// TestYAMLEdgeCases tests edge cases for YAML parsing, including non-map types\nfunc TestYAMLEdgeCases(t *testing.T) {\n\trequire := require.New(t)\n\n\t// Test array at root (should fail for frp config)\n\tarrayYAML := []byte(`\n- item1\n- item2\n`)\n\tclientCfg := v1.ClientConfig{}\n\terr := LoadConfigure(arrayYAML, &clientCfg, true)\n\trequire.Error(err) // Should fail because ClientConfig expects an object\n\n\t// Test scalar at root (should fail for frp config)\n\tscalarYAML := []byte(`\"just a string\"`)\n\terr = LoadConfigure(scalarYAML, &clientCfg, true)\n\trequire.Error(err) // Should fail because ClientConfig expects an object\n\n\t// Test empty object (should work)\n\temptyYAML := []byte(`{}`)\n\terr = LoadConfigure(emptyYAML, &clientCfg, true)\n\trequire.NoError(err)\n\n\t// Test nested structure without dots (should work)\n\tnestedYAML := []byte(`\nserverAddr: \"127.0.0.1\"\nserverPort: 7000\n`)\n\terr = LoadConfigure(nestedYAML, &clientCfg, true)\n\trequire.NoError(err)\n\trequire.Equal(\"127.0.0.1\", clientCfg.ServerAddr)\n\trequire.Equal(7000, clientCfg.ServerPort)\n}\n\nfunc TestTOMLSyntaxErrorWithPosition(t *testing.T) {\n\trequire := require.New(t)\n\n\t// TOML with syntax error (unclosed table array header)\n\tcontent := `serverAddr = \"127.0.0.1\"\nserverPort = 7000\n\n[[proxies]\nname = \"test\"\n`\n\n\tclientCfg := v1.ClientConfig{}\n\terr := LoadConfigure([]byte(content), &clientCfg, false, \"toml\")\n\trequire.Error(err)\n\trequire.Contains(err.Error(), \"toml\")\n\trequire.Contains(err.Error(), \"line\")\n\trequire.Contains(err.Error(), \"column\")\n}\n\nfunc TestTOMLTypeMismatchErrorWithFieldInfo(t *testing.T) {\n\trequire := require.New(t)\n\n\t// TOML with wrong type: proxies should be a table array, not a string\n\tcontent := `serverAddr = \"127.0.0.1\"\nserverPort = 7000\nproxies = \"this should be a table array\"\n`\n\n\tclientCfg := v1.ClientConfig{}\n\terr := LoadConfigure([]byte(content), &clientCfg, false, \"toml\")\n\trequire.Error(err)\n\t// The error should contain field info\n\terrMsg := err.Error()\n\trequire.Contains(errMsg, \"proxies\")\n\trequire.NotContains(errMsg, \"line\")\n}\n\nfunc TestFindFieldLineInContent(t *testing.T) {\n\tcontent := []byte(`serverAddr = \"127.0.0.1\"\nserverPort = 7000\n\n[[proxies]]\nname = \"test\"\ntype = \"tcp\"\nremotePort = 6000\n`)\n\n\ttests := []struct {\n\t\tfieldPath string\n\t\twantLine  int\n\t}{\n\t\t{\"serverAddr\", 1},\n\t\t{\"serverPort\", 2},\n\t\t{\"name\", 5},\n\t\t{\"type\", 6},\n\t\t{\"remotePort\", 7},\n\t\t{\"nonexistent\", 0},\n\t}\n\n\tfor _, tt := range tests {\n\t\tt.Run(tt.fieldPath, func(t *testing.T) {\n\t\t\tgot := findFieldLineInContent(content, tt.fieldPath)\n\t\t\trequire.Equal(t, tt.wantLine, got)\n\t\t})\n\t}\n}\n\nfunc TestFormatDetection(t *testing.T) {\n\ttests := []struct {\n\t\tpath   string\n\t\tformat string\n\t}{\n\t\t{\"config.toml\", \"toml\"},\n\t\t{\"config.TOML\", \"toml\"},\n\t\t{\"config.yaml\", \"yaml\"},\n\t\t{\"config.yml\", \"yaml\"},\n\t\t{\"config.json\", \"json\"},\n\t\t{\"config.ini\", \"\"},\n\t\t{\"config\", \"\"},\n\t}\n\n\tfor _, tt := range tests {\n\t\tt.Run(tt.path, func(t *testing.T) {\n\t\t\trequire.Equal(t, tt.format, detectFormatFromPath(tt.path))\n\t\t})\n\t}\n}\n\nfunc TestValidTOMLStillWorks(t *testing.T) {\n\trequire := require.New(t)\n\n\t// Valid TOML with format hint should work fine\n\tcontent := `serverAddr = \"127.0.0.1\"\nserverPort = 7000\n\n[[proxies]]\nname = \"test\"\ntype = \"tcp\"\nremotePort = 6000\n`\n\tclientCfg := v1.ClientConfig{}\n\terr := LoadConfigure([]byte(content), &clientCfg, false, \"toml\")\n\trequire.NoError(err)\n\trequire.Equal(\"127.0.0.1\", clientCfg.ServerAddr)\n\trequire.Equal(7000, clientCfg.ServerPort)\n\trequire.Len(clientCfg.Proxies, 1)\n}\n"
  },
  {
    "path": "pkg/config/source/aggregator.go",
    "content": "// Copyright 2026 The frp Authors\n//\n// Licensed under the Apache License, Version 2.0 (the \"License\");\n// you may not use this file except in compliance with the License.\n// You may obtain a copy of the License at\n//\n//     http://www.apache.org/licenses/LICENSE-2.0\n//\n// Unless required by applicable law or agreed to in writing, software\n// distributed under the License is distributed on an \"AS IS\" BASIS,\n// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.\n// See the License for the specific language governing permissions and\n// limitations under the License.\n\npackage source\n\nimport (\n\t\"cmp\"\n\t\"errors\"\n\t\"fmt\"\n\t\"maps\"\n\t\"slices\"\n\t\"sync\"\n\n\tv1 \"github.com/fatedier/frp/pkg/config/v1\"\n)\n\ntype Aggregator struct {\n\tmu sync.RWMutex\n\n\tconfigSource *ConfigSource\n\tstoreSource  *StoreSource\n}\n\nfunc NewAggregator(configSource *ConfigSource) *Aggregator {\n\tif configSource == nil {\n\t\tconfigSource = NewConfigSource()\n\t}\n\treturn &Aggregator{\n\t\tconfigSource: configSource,\n\t}\n}\n\nfunc (a *Aggregator) SetStoreSource(storeSource *StoreSource) {\n\ta.mu.Lock()\n\tdefer a.mu.Unlock()\n\n\ta.storeSource = storeSource\n}\n\nfunc (a *Aggregator) ConfigSource() *ConfigSource {\n\treturn a.configSource\n}\n\nfunc (a *Aggregator) StoreSource() *StoreSource {\n\treturn a.storeSource\n}\n\nfunc (a *Aggregator) getSourcesLocked() []Source {\n\tsources := make([]Source, 0, 2)\n\tif a.configSource != nil {\n\t\tsources = append(sources, a.configSource)\n\t}\n\tif a.storeSource != nil {\n\t\tsources = append(sources, a.storeSource)\n\t}\n\treturn sources\n}\n\nfunc (a *Aggregator) Load() ([]v1.ProxyConfigurer, []v1.VisitorConfigurer, error) {\n\ta.mu.RLock()\n\tentries := a.getSourcesLocked()\n\ta.mu.RUnlock()\n\n\tif len(entries) == 0 {\n\t\treturn nil, nil, errors.New(\"no sources configured\")\n\t}\n\n\tproxyMap := make(map[string]v1.ProxyConfigurer)\n\tvisitorMap := make(map[string]v1.VisitorConfigurer)\n\n\tfor _, src := range entries {\n\t\tproxies, visitors, err := src.Load()\n\t\tif err != nil {\n\t\t\treturn nil, nil, fmt.Errorf(\"load source: %w\", err)\n\t\t}\n\t\tfor _, p := range proxies {\n\t\t\tproxyMap[p.GetBaseConfig().Name] = p\n\t\t}\n\t\tfor _, v := range visitors {\n\t\t\tvisitorMap[v.GetBaseConfig().Name] = v\n\t\t}\n\t}\n\tproxies, visitors := a.mapsToSortedSlices(proxyMap, visitorMap)\n\treturn proxies, visitors, nil\n}\n\nfunc (a *Aggregator) mapsToSortedSlices(\n\tproxyMap map[string]v1.ProxyConfigurer,\n\tvisitorMap map[string]v1.VisitorConfigurer,\n) ([]v1.ProxyConfigurer, []v1.VisitorConfigurer) {\n\tproxies := slices.SortedFunc(maps.Values(proxyMap), func(x, y v1.ProxyConfigurer) int {\n\t\treturn cmp.Compare(x.GetBaseConfig().Name, y.GetBaseConfig().Name)\n\t})\n\tvisitors := slices.SortedFunc(maps.Values(visitorMap), func(x, y v1.VisitorConfigurer) int {\n\t\treturn cmp.Compare(x.GetBaseConfig().Name, y.GetBaseConfig().Name)\n\t})\n\treturn proxies, visitors\n}\n"
  },
  {
    "path": "pkg/config/source/aggregator_test.go",
    "content": "// Copyright 2026 The frp Authors\n//\n// Licensed under the Apache License, Version 2.0 (the \"License\");\n// you may not use this file except in compliance with the License.\n// You may obtain a copy of the License at\n//\n//     http://www.apache.org/licenses/LICENSE-2.0\n//\n// Unless required by applicable law or agreed to in writing, software\n// distributed under the License is distributed on an \"AS IS\" BASIS,\n// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.\n// See the License for the specific language governing permissions and\n// limitations under the License.\n\npackage source\n\nimport (\n\t\"path/filepath\"\n\t\"testing\"\n\n\t\"github.com/stretchr/testify/require\"\n\n\tv1 \"github.com/fatedier/frp/pkg/config/v1\"\n)\n\n// mockProxy creates a TCP proxy config for testing\nfunc mockProxy(name string) v1.ProxyConfigurer {\n\tcfg := &v1.TCPProxyConfig{}\n\tcfg.Name = name\n\tcfg.Type = \"tcp\"\n\tcfg.LocalPort = 8080\n\tcfg.RemotePort = 9090\n\treturn cfg\n}\n\n// mockVisitor creates a STCP visitor config for testing\nfunc mockVisitor(name string) v1.VisitorConfigurer {\n\tcfg := &v1.STCPVisitorConfig{}\n\tcfg.Name = name\n\tcfg.Type = \"stcp\"\n\tcfg.ServerName = \"test-server\"\n\treturn cfg\n}\n\nfunc newTestStoreSource(t *testing.T) *StoreSource {\n\tt.Helper()\n\n\tpath := filepath.Join(t.TempDir(), \"store.json\")\n\tstoreSource, err := NewStoreSource(StoreSourceConfig{Path: path})\n\trequire.NoError(t, err)\n\treturn storeSource\n}\n\nfunc newTestAggregator(t *testing.T, storeSource *StoreSource) *Aggregator {\n\tt.Helper()\n\n\tconfigSource := NewConfigSource()\n\tagg := NewAggregator(configSource)\n\tif storeSource != nil {\n\t\tagg.SetStoreSource(storeSource)\n\t}\n\treturn agg\n}\n\nfunc TestNewAggregator_CreatesConfigSourceWhenNil(t *testing.T) {\n\trequire := require.New(t)\n\n\tagg := NewAggregator(nil)\n\trequire.NotNil(agg)\n\trequire.NotNil(agg.ConfigSource())\n\trequire.Nil(agg.StoreSource())\n}\n\nfunc TestNewAggregator_WithoutStore(t *testing.T) {\n\trequire := require.New(t)\n\n\tconfigSource := NewConfigSource()\n\tagg := NewAggregator(configSource)\n\trequire.NotNil(agg)\n\trequire.Same(configSource, agg.ConfigSource())\n\trequire.Nil(agg.StoreSource())\n}\n\nfunc TestNewAggregator_WithStore(t *testing.T) {\n\trequire := require.New(t)\n\n\tstoreSource := newTestStoreSource(t)\n\tconfigSource := NewConfigSource()\n\tagg := NewAggregator(configSource)\n\tagg.SetStoreSource(storeSource)\n\n\trequire.Same(configSource, agg.ConfigSource())\n\trequire.Same(storeSource, agg.StoreSource())\n}\n\nfunc TestAggregator_SetStoreSource_Overwrite(t *testing.T) {\n\trequire := require.New(t)\n\n\tagg := newTestAggregator(t, nil)\n\tfirst := newTestStoreSource(t)\n\tsecond := newTestStoreSource(t)\n\n\tagg.SetStoreSource(first)\n\trequire.Same(first, agg.StoreSource())\n\n\tagg.SetStoreSource(second)\n\trequire.Same(second, agg.StoreSource())\n\n\tagg.SetStoreSource(nil)\n\trequire.Nil(agg.StoreSource())\n}\n\nfunc TestAggregator_MergeBySourceOrder(t *testing.T) {\n\trequire := require.New(t)\n\n\tstoreSource := newTestStoreSource(t)\n\tagg := newTestAggregator(t, storeSource)\n\n\tconfigSource := agg.ConfigSource()\n\n\tconfigShared := mockProxy(\"shared\").(*v1.TCPProxyConfig)\n\tconfigShared.LocalPort = 1111\n\tconfigOnly := mockProxy(\"only-in-config\").(*v1.TCPProxyConfig)\n\tconfigOnly.LocalPort = 1112\n\n\terr := configSource.ReplaceAll([]v1.ProxyConfigurer{configShared, configOnly}, nil)\n\trequire.NoError(err)\n\n\tstoreShared := mockProxy(\"shared\").(*v1.TCPProxyConfig)\n\tstoreShared.LocalPort = 2222\n\tstoreOnly := mockProxy(\"only-in-store\").(*v1.TCPProxyConfig)\n\tstoreOnly.LocalPort = 2223\n\terr = storeSource.AddProxy(storeShared)\n\trequire.NoError(err)\n\terr = storeSource.AddProxy(storeOnly)\n\trequire.NoError(err)\n\n\tproxies, visitors, err := agg.Load()\n\trequire.NoError(err)\n\trequire.Len(visitors, 0)\n\trequire.Len(proxies, 3)\n\n\tvar sharedProxy *v1.TCPProxyConfig\n\tfor _, p := range proxies {\n\t\tif p.GetBaseConfig().Name == \"shared\" {\n\t\t\tsharedProxy = p.(*v1.TCPProxyConfig)\n\t\t\tbreak\n\t\t}\n\t}\n\trequire.NotNil(sharedProxy)\n\trequire.Equal(2222, sharedProxy.LocalPort)\n}\n\nfunc TestAggregator_DisabledEntryIsSourceLocalFilter(t *testing.T) {\n\trequire := require.New(t)\n\n\tstoreSource := newTestStoreSource(t)\n\tagg := newTestAggregator(t, storeSource)\n\tconfigSource := agg.ConfigSource()\n\n\tlowProxy := mockProxy(\"shared-proxy\").(*v1.TCPProxyConfig)\n\tlowProxy.LocalPort = 1111\n\terr := configSource.ReplaceAll([]v1.ProxyConfigurer{lowProxy}, nil)\n\trequire.NoError(err)\n\n\tdisabled := false\n\thighProxy := mockProxy(\"shared-proxy\").(*v1.TCPProxyConfig)\n\thighProxy.LocalPort = 2222\n\thighProxy.Enabled = &disabled\n\terr = storeSource.AddProxy(highProxy)\n\trequire.NoError(err)\n\n\tproxies, visitors, err := agg.Load()\n\trequire.NoError(err)\n\trequire.Len(proxies, 1)\n\trequire.Len(visitors, 0)\n\n\tproxy := proxies[0].(*v1.TCPProxyConfig)\n\trequire.Equal(\"shared-proxy\", proxy.Name)\n\trequire.Equal(1111, proxy.LocalPort)\n}\n\nfunc TestAggregator_VisitorMerge(t *testing.T) {\n\trequire := require.New(t)\n\n\tstoreSource := newTestStoreSource(t)\n\tagg := newTestAggregator(t, storeSource)\n\n\terr := agg.ConfigSource().ReplaceAll(nil, []v1.VisitorConfigurer{mockVisitor(\"visitor1\")})\n\trequire.NoError(err)\n\terr = storeSource.AddVisitor(mockVisitor(\"visitor2\"))\n\trequire.NoError(err)\n\n\t_, visitors, err := agg.Load()\n\trequire.NoError(err)\n\trequire.Len(visitors, 2)\n}\n\nfunc TestAggregator_Load_ReturnsSortedByName(t *testing.T) {\n\trequire := require.New(t)\n\n\tagg := newTestAggregator(t, nil)\n\terr := agg.ConfigSource().ReplaceAll(\n\t\t[]v1.ProxyConfigurer{mockProxy(\"charlie\"), mockProxy(\"alice\"), mockProxy(\"bob\")},\n\t\t[]v1.VisitorConfigurer{mockVisitor(\"zulu\"), mockVisitor(\"alpha\")},\n\t)\n\trequire.NoError(err)\n\n\tproxies, visitors, err := agg.Load()\n\trequire.NoError(err)\n\trequire.Len(proxies, 3)\n\trequire.Equal(\"alice\", proxies[0].GetBaseConfig().Name)\n\trequire.Equal(\"bob\", proxies[1].GetBaseConfig().Name)\n\trequire.Equal(\"charlie\", proxies[2].GetBaseConfig().Name)\n\trequire.Len(visitors, 2)\n\trequire.Equal(\"alpha\", visitors[0].GetBaseConfig().Name)\n\trequire.Equal(\"zulu\", visitors[1].GetBaseConfig().Name)\n}\n\nfunc TestAggregator_Load_ReturnsDefensiveCopies(t *testing.T) {\n\trequire := require.New(t)\n\n\tagg := newTestAggregator(t, nil)\n\terr := agg.ConfigSource().ReplaceAll([]v1.ProxyConfigurer{mockProxy(\"ssh\")}, nil)\n\trequire.NoError(err)\n\n\tproxies, _, err := agg.Load()\n\trequire.NoError(err)\n\trequire.Len(proxies, 1)\n\trequire.Equal(\"ssh\", proxies[0].GetBaseConfig().Name)\n\n\tproxies[0].GetBaseConfig().Name = \"alice.ssh\"\n\n\tproxies2, _, err := agg.Load()\n\trequire.NoError(err)\n\trequire.Len(proxies2, 1)\n\trequire.Equal(\"ssh\", proxies2[0].GetBaseConfig().Name)\n}\n"
  },
  {
    "path": "pkg/config/source/base_source.go",
    "content": "// Copyright 2026 The frp Authors\n//\n// Licensed under the Apache License, Version 2.0 (the \"License\");\n// you may not use this file except in compliance with the License.\n// You may obtain a copy of the License at\n//\n//     http://www.apache.org/licenses/LICENSE-2.0\n//\n// Unless required by applicable law or agreed to in writing, software\n// distributed under the License is distributed on an \"AS IS\" BASIS,\n// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.\n// See the License for the specific language governing permissions and\n// limitations under the License.\n\npackage source\n\nimport (\n\t\"sync\"\n\n\tv1 \"github.com/fatedier/frp/pkg/config/v1\"\n)\n\n// baseSource provides shared state and behavior for Source implementations.\n// It manages proxy/visitor storage.\n// Concrete types (ConfigSource, StoreSource) embed this struct.\ntype baseSource struct {\n\tmu sync.RWMutex\n\n\tproxies  map[string]v1.ProxyConfigurer\n\tvisitors map[string]v1.VisitorConfigurer\n}\n\nfunc newBaseSource() baseSource {\n\treturn baseSource{\n\t\tproxies:  make(map[string]v1.ProxyConfigurer),\n\t\tvisitors: make(map[string]v1.VisitorConfigurer),\n\t}\n}\n\n// Load returns all enabled proxy and visitor configurations.\n// Configurations with Enabled explicitly set to false are filtered out.\nfunc (s *baseSource) Load() ([]v1.ProxyConfigurer, []v1.VisitorConfigurer, error) {\n\ts.mu.RLock()\n\tdefer s.mu.RUnlock()\n\n\tproxies := make([]v1.ProxyConfigurer, 0, len(s.proxies))\n\tfor _, p := range s.proxies {\n\t\t// Filter out disabled proxies (nil or true means enabled)\n\t\tif enabled := p.GetBaseConfig().Enabled; enabled != nil && !*enabled {\n\t\t\tcontinue\n\t\t}\n\t\tproxies = append(proxies, p)\n\t}\n\n\tvisitors := make([]v1.VisitorConfigurer, 0, len(s.visitors))\n\tfor _, v := range s.visitors {\n\t\t// Filter out disabled visitors (nil or true means enabled)\n\t\tif enabled := v.GetBaseConfig().Enabled; enabled != nil && !*enabled {\n\t\t\tcontinue\n\t\t}\n\t\tvisitors = append(visitors, v)\n\t}\n\n\treturn cloneConfigurers(proxies, visitors)\n}\n"
  },
  {
    "path": "pkg/config/source/base_source_test.go",
    "content": "package source\n\nimport (\n\t\"testing\"\n\n\t\"github.com/stretchr/testify/require\"\n\n\tv1 \"github.com/fatedier/frp/pkg/config/v1\"\n)\n\nfunc TestBaseSourceLoadReturnsClonedConfigurers(t *testing.T) {\n\trequire := require.New(t)\n\n\tsrc := NewConfigSource()\n\n\tproxyCfg := &v1.TCPProxyConfig{\n\t\tProxyBaseConfig: v1.ProxyBaseConfig{\n\t\t\tName: \"proxy1\",\n\t\t\tType: \"tcp\",\n\t\t},\n\t}\n\tvisitorCfg := &v1.STCPVisitorConfig{\n\t\tVisitorBaseConfig: v1.VisitorBaseConfig{\n\t\t\tName: \"visitor1\",\n\t\t\tType: \"stcp\",\n\t\t},\n\t}\n\n\terr := src.ReplaceAll([]v1.ProxyConfigurer{proxyCfg}, []v1.VisitorConfigurer{visitorCfg})\n\trequire.NoError(err)\n\n\tfirstProxies, firstVisitors, err := src.Load()\n\trequire.NoError(err)\n\trequire.Len(firstProxies, 1)\n\trequire.Len(firstVisitors, 1)\n\n\t// Mutate loaded objects as runtime completion would do.\n\tfirstProxies[0].Complete()\n\tfirstVisitors[0].Complete()\n\n\tsecondProxies, secondVisitors, err := src.Load()\n\trequire.NoError(err)\n\trequire.Len(secondProxies, 1)\n\trequire.Len(secondVisitors, 1)\n\n\trequire.Empty(secondProxies[0].GetBaseConfig().LocalIP)\n\trequire.Empty(secondVisitors[0].GetBaseConfig().BindAddr)\n}\n"
  },
  {
    "path": "pkg/config/source/clone.go",
    "content": "// Copyright 2026 The frp Authors\n//\n// Licensed under the Apache License, Version 2.0 (the \"License\");\n// you may not use this file except in compliance with the License.\n// You may obtain a copy of the License at\n//\n//     http://www.apache.org/licenses/LICENSE-2.0\n//\n// Unless required by applicable law or agreed to in writing, software\n// distributed under the License is distributed on an \"AS IS\" BASIS,\n// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.\n// See the License for the specific language governing permissions and\n// limitations under the License.\n\npackage source\n\nimport (\n\t\"fmt\"\n\n\tv1 \"github.com/fatedier/frp/pkg/config/v1\"\n)\n\nfunc cloneConfigurers(\n\tproxies []v1.ProxyConfigurer,\n\tvisitors []v1.VisitorConfigurer,\n) ([]v1.ProxyConfigurer, []v1.VisitorConfigurer, error) {\n\tclonedProxies := make([]v1.ProxyConfigurer, 0, len(proxies))\n\tclonedVisitors := make([]v1.VisitorConfigurer, 0, len(visitors))\n\n\tfor _, cfg := range proxies {\n\t\tif cfg == nil {\n\t\t\treturn nil, nil, fmt.Errorf(\"proxy cannot be nil\")\n\t\t}\n\t\tclonedProxies = append(clonedProxies, cfg.Clone())\n\t}\n\tfor _, cfg := range visitors {\n\t\tif cfg == nil {\n\t\t\treturn nil, nil, fmt.Errorf(\"visitor cannot be nil\")\n\t\t}\n\t\tclonedVisitors = append(clonedVisitors, cfg.Clone())\n\t}\n\treturn clonedProxies, clonedVisitors, nil\n}\n"
  },
  {
    "path": "pkg/config/source/config_source.go",
    "content": "// Copyright 2026 The frp Authors\n//\n// Licensed under the Apache License, Version 2.0 (the \"License\");\n// you may not use this file except in compliance with the License.\n// You may obtain a copy of the License at\n//\n//     http://www.apache.org/licenses/LICENSE-2.0\n//\n// Unless required by applicable law or agreed to in writing, software\n// distributed under the License is distributed on an \"AS IS\" BASIS,\n// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.\n// See the License for the specific language governing permissions and\n// limitations under the License.\n\npackage source\n\nimport (\n\t\"fmt\"\n\n\tv1 \"github.com/fatedier/frp/pkg/config/v1\"\n)\n\n// ConfigSource implements Source for in-memory configuration.\n// All operations are thread-safe.\ntype ConfigSource struct {\n\tbaseSource\n}\n\nfunc NewConfigSource() *ConfigSource {\n\treturn &ConfigSource{\n\t\tbaseSource: newBaseSource(),\n\t}\n}\n\n// ReplaceAll replaces all proxy and visitor configurations atomically.\nfunc (s *ConfigSource) ReplaceAll(proxies []v1.ProxyConfigurer, visitors []v1.VisitorConfigurer) error {\n\ts.mu.Lock()\n\tdefer s.mu.Unlock()\n\n\tnextProxies := make(map[string]v1.ProxyConfigurer, len(proxies))\n\tfor _, p := range proxies {\n\t\tif p == nil {\n\t\t\treturn fmt.Errorf(\"proxy cannot be nil\")\n\t\t}\n\t\tname := p.GetBaseConfig().Name\n\t\tif name == \"\" {\n\t\t\treturn fmt.Errorf(\"proxy name cannot be empty\")\n\t\t}\n\t\tnextProxies[name] = p\n\t}\n\tnextVisitors := make(map[string]v1.VisitorConfigurer, len(visitors))\n\tfor _, v := range visitors {\n\t\tif v == nil {\n\t\t\treturn fmt.Errorf(\"visitor cannot be nil\")\n\t\t}\n\t\tname := v.GetBaseConfig().Name\n\t\tif name == \"\" {\n\t\t\treturn fmt.Errorf(\"visitor name cannot be empty\")\n\t\t}\n\t\tnextVisitors[name] = v\n\t}\n\ts.proxies = nextProxies\n\ts.visitors = nextVisitors\n\treturn nil\n}\n"
  },
  {
    "path": "pkg/config/source/config_source_test.go",
    "content": "// Copyright 2026 The frp Authors\n//\n// Licensed under the Apache License, Version 2.0 (the \"License\");\n// you may not use this file except in compliance with the License.\n// You may obtain a copy of the License at\n//\n//     http://www.apache.org/licenses/LICENSE-2.0\n//\n// Unless required by applicable law or agreed to in writing, software\n// distributed under the License is distributed on an \"AS IS\" BASIS,\n// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.\n// See the License for the specific language governing permissions and\n// limitations under the License.\n\npackage source\n\nimport (\n\t\"testing\"\n\n\t\"github.com/stretchr/testify/require\"\n\n\tv1 \"github.com/fatedier/frp/pkg/config/v1\"\n)\n\nfunc TestNewConfigSource(t *testing.T) {\n\trequire := require.New(t)\n\n\tsrc := NewConfigSource()\n\trequire.NotNil(src)\n}\n\nfunc TestConfigSource_ReplaceAll(t *testing.T) {\n\trequire := require.New(t)\n\n\tsrc := NewConfigSource()\n\n\terr := src.ReplaceAll(\n\t\t[]v1.ProxyConfigurer{mockProxy(\"proxy1\"), mockProxy(\"proxy2\")},\n\t\t[]v1.VisitorConfigurer{mockVisitor(\"visitor1\")},\n\t)\n\trequire.NoError(err)\n\n\tproxies, visitors, err := src.Load()\n\trequire.NoError(err)\n\trequire.Len(proxies, 2)\n\trequire.Len(visitors, 1)\n\n\t// ReplaceAll again should replace everything\n\terr = src.ReplaceAll(\n\t\t[]v1.ProxyConfigurer{mockProxy(\"proxy3\")},\n\t\tnil,\n\t)\n\trequire.NoError(err)\n\n\tproxies, visitors, err = src.Load()\n\trequire.NoError(err)\n\trequire.Len(proxies, 1)\n\trequire.Len(visitors, 0)\n\trequire.Equal(\"proxy3\", proxies[0].GetBaseConfig().Name)\n\n\t// ReplaceAll with nil proxy should fail\n\terr = src.ReplaceAll([]v1.ProxyConfigurer{nil}, nil)\n\trequire.Error(err)\n\n\t// ReplaceAll with empty name proxy should fail\n\terr = src.ReplaceAll([]v1.ProxyConfigurer{&v1.TCPProxyConfig{}}, nil)\n\trequire.Error(err)\n}\n\nfunc TestConfigSource_Load(t *testing.T) {\n\trequire := require.New(t)\n\n\tsrc := NewConfigSource()\n\n\terr := src.ReplaceAll(\n\t\t[]v1.ProxyConfigurer{mockProxy(\"proxy1\"), mockProxy(\"proxy2\")},\n\t\t[]v1.VisitorConfigurer{mockVisitor(\"visitor1\")},\n\t)\n\trequire.NoError(err)\n\n\tproxies, visitors, err := src.Load()\n\trequire.NoError(err)\n\trequire.Len(proxies, 2)\n\trequire.Len(visitors, 1)\n}\n\n// TestConfigSource_Load_FiltersDisabled verifies that Load() filters out\n// proxies and visitors with Enabled explicitly set to false.\nfunc TestConfigSource_Load_FiltersDisabled(t *testing.T) {\n\trequire := require.New(t)\n\n\tsrc := NewConfigSource()\n\n\tdisabled := false\n\tenabled := true\n\n\t// Create enabled proxy (nil Enabled = enabled by default)\n\tenabledProxy := mockProxy(\"enabled-proxy\")\n\n\t// Create disabled proxy\n\tdisabledProxy := &v1.TCPProxyConfig{}\n\tdisabledProxy.Name = \"disabled-proxy\"\n\tdisabledProxy.Type = \"tcp\"\n\tdisabledProxy.Enabled = &disabled\n\n\t// Create explicitly enabled proxy\n\texplicitEnabledProxy := &v1.TCPProxyConfig{}\n\texplicitEnabledProxy.Name = \"explicit-enabled-proxy\"\n\texplicitEnabledProxy.Type = \"tcp\"\n\texplicitEnabledProxy.Enabled = &enabled\n\n\t// Create enabled visitor (nil Enabled = enabled by default)\n\tenabledVisitor := mockVisitor(\"enabled-visitor\")\n\n\t// Create disabled visitor\n\tdisabledVisitor := &v1.STCPVisitorConfig{}\n\tdisabledVisitor.Name = \"disabled-visitor\"\n\tdisabledVisitor.Type = \"stcp\"\n\tdisabledVisitor.Enabled = &disabled\n\n\terr := src.ReplaceAll(\n\t\t[]v1.ProxyConfigurer{enabledProxy, disabledProxy, explicitEnabledProxy},\n\t\t[]v1.VisitorConfigurer{enabledVisitor, disabledVisitor},\n\t)\n\trequire.NoError(err)\n\n\t// Load should filter out disabled configs\n\tproxies, visitors, err := src.Load()\n\trequire.NoError(err)\n\trequire.Len(proxies, 2, \"Should have 2 enabled proxies\")\n\trequire.Len(visitors, 1, \"Should have 1 enabled visitor\")\n\n\t// Verify the correct proxies are returned\n\tproxyNames := make([]string, 0, len(proxies))\n\tfor _, p := range proxies {\n\t\tproxyNames = append(proxyNames, p.GetBaseConfig().Name)\n\t}\n\trequire.Contains(proxyNames, \"enabled-proxy\")\n\trequire.Contains(proxyNames, \"explicit-enabled-proxy\")\n\trequire.NotContains(proxyNames, \"disabled-proxy\")\n\n\t// Verify the correct visitor is returned\n\trequire.Equal(\"enabled-visitor\", visitors[0].GetBaseConfig().Name)\n}\n\nfunc TestConfigSource_ReplaceAll_DoesNotApplyRuntimeDefaults(t *testing.T) {\n\trequire := require.New(t)\n\n\tsrc := NewConfigSource()\n\n\tproxyCfg := &v1.TCPProxyConfig{}\n\tproxyCfg.Name = \"proxy1\"\n\tproxyCfg.Type = \"tcp\"\n\tproxyCfg.LocalPort = 10080\n\n\tvisitorCfg := &v1.XTCPVisitorConfig{}\n\tvisitorCfg.Name = \"visitor1\"\n\tvisitorCfg.Type = \"xtcp\"\n\tvisitorCfg.ServerName = \"server1\"\n\tvisitorCfg.SecretKey = \"secret\"\n\tvisitorCfg.BindPort = 10081\n\n\terr := src.ReplaceAll([]v1.ProxyConfigurer{proxyCfg}, []v1.VisitorConfigurer{visitorCfg})\n\trequire.NoError(err)\n\n\tproxies, visitors, err := src.Load()\n\trequire.NoError(err)\n\trequire.Len(proxies, 1)\n\trequire.Len(visitors, 1)\n\trequire.Empty(proxies[0].GetBaseConfig().LocalIP)\n\trequire.Empty(visitors[0].GetBaseConfig().BindAddr)\n\trequire.Empty(visitors[0].(*v1.XTCPVisitorConfig).Protocol)\n}\n"
  },
  {
    "path": "pkg/config/source/source.go",
    "content": "// Copyright 2026 The frp Authors\n//\n// Licensed under the Apache License, Version 2.0 (the \"License\");\n// you may not use this file except in compliance with the License.\n// You may obtain a copy of the License at\n//\n//     http://www.apache.org/licenses/LICENSE-2.0\n//\n// Unless required by applicable law or agreed to in writing, software\n// distributed under the License is distributed on an \"AS IS\" BASIS,\n// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.\n// See the License for the specific language governing permissions and\n// limitations under the License.\n\npackage source\n\nimport (\n\tv1 \"github.com/fatedier/frp/pkg/config/v1\"\n)\n\n// Source is the interface for configuration sources.\n// A Source provides proxy and visitor configurations from various backends.\n// Aggregator currently uses the built-in config source as base and an optional\n// store source as higher-priority overlay.\ntype Source interface {\n\t// Load loads the proxy and visitor configurations from this source.\n\t// Returns the loaded configurations and any error encountered.\n\t// A disabled entry in one source is source-local filtering, not a cross-source\n\t// tombstone for entries from lower-priority sources.\n\t//\n\t// Error handling contract with Aggregator:\n\t//   - When err is nil, returned slices are consumed.\n\t//   - When err is non-nil, Aggregator aborts the merge and returns the error.\n\t//   - To publish best-effort or partial results, return those results with\n\t//     err set to nil.\n\tLoad() (proxies []v1.ProxyConfigurer, visitors []v1.VisitorConfigurer, err error)\n}\n"
  },
  {
    "path": "pkg/config/source/store.go",
    "content": "// Copyright 2026 The frp Authors\n//\n// Licensed under the Apache License, Version 2.0 (the \"License\");\n// you may not use this file except in compliance with the License.\n// You may obtain a copy of the License at\n//\n//     http://www.apache.org/licenses/LICENSE-2.0\n//\n// Unless required by applicable law or agreed to in writing, software\n// distributed under the License is distributed on an \"AS IS\" BASIS,\n// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.\n// See the License for the specific language governing permissions and\n// limitations under the License.\n\npackage source\n\nimport (\n\t\"errors\"\n\t\"fmt\"\n\t\"os\"\n\t\"path/filepath\"\n\n\tv1 \"github.com/fatedier/frp/pkg/config/v1\"\n\t\"github.com/fatedier/frp/pkg/util/jsonx\"\n)\n\ntype StoreSourceConfig struct {\n\tPath string `json:\"path\"`\n}\n\ntype storeData struct {\n\tProxies  []v1.TypedProxyConfig   `json:\"proxies,omitempty\"`\n\tVisitors []v1.TypedVisitorConfig `json:\"visitors,omitempty\"`\n}\n\ntype StoreSource struct {\n\tbaseSource\n\tconfig StoreSourceConfig\n}\n\nvar (\n\tErrAlreadyExists = errors.New(\"already exists\")\n\tErrNotFound      = errors.New(\"not found\")\n)\n\nfunc NewStoreSource(cfg StoreSourceConfig) (*StoreSource, error) {\n\tif cfg.Path == \"\" {\n\t\treturn nil, fmt.Errorf(\"path is required\")\n\t}\n\n\ts := &StoreSource{\n\t\tbaseSource: newBaseSource(),\n\t\tconfig:     cfg,\n\t}\n\n\tif err := s.loadFromFile(); err != nil {\n\t\tif !os.IsNotExist(err) {\n\t\t\treturn nil, fmt.Errorf(\"failed to load existing data: %w\", err)\n\t\t}\n\t}\n\n\treturn s, nil\n}\n\nfunc (s *StoreSource) loadFromFile() error {\n\ts.mu.Lock()\n\tdefer s.mu.Unlock()\n\treturn s.loadFromFileUnlocked()\n}\n\nfunc (s *StoreSource) loadFromFileUnlocked() error {\n\tdata, err := os.ReadFile(s.config.Path)\n\tif err != nil {\n\t\treturn err\n\t}\n\n\ttype rawStoreData struct {\n\t\tProxies  []jsonx.RawMessage `json:\"proxies,omitempty\"`\n\t\tVisitors []jsonx.RawMessage `json:\"visitors,omitempty\"`\n\t}\n\tstored := rawStoreData{}\n\tif err := jsonx.Unmarshal(data, &stored); err != nil {\n\t\treturn fmt.Errorf(\"failed to parse JSON: %w\", err)\n\t}\n\n\ts.proxies = make(map[string]v1.ProxyConfigurer)\n\ts.visitors = make(map[string]v1.VisitorConfigurer)\n\n\tfor i, proxyData := range stored.Proxies {\n\t\tproxyCfg, err := v1.DecodeProxyConfigurerJSON(proxyData, v1.DecodeOptions{\n\t\t\tDisallowUnknownFields: false,\n\t\t})\n\t\tif err != nil {\n\t\t\treturn fmt.Errorf(\"failed to decode proxy at index %d: %w\", i, err)\n\t\t}\n\t\tname := proxyCfg.GetBaseConfig().Name\n\t\tif name == \"\" {\n\t\t\treturn fmt.Errorf(\"proxy name cannot be empty\")\n\t\t}\n\t\ts.proxies[name] = proxyCfg\n\t}\n\n\tfor i, visitorData := range stored.Visitors {\n\t\tvisitorCfg, err := v1.DecodeVisitorConfigurerJSON(visitorData, v1.DecodeOptions{\n\t\t\tDisallowUnknownFields: false,\n\t\t})\n\t\tif err != nil {\n\t\t\treturn fmt.Errorf(\"failed to decode visitor at index %d: %w\", i, err)\n\t\t}\n\t\tname := visitorCfg.GetBaseConfig().Name\n\t\tif name == \"\" {\n\t\t\treturn fmt.Errorf(\"visitor name cannot be empty\")\n\t\t}\n\t\ts.visitors[name] = visitorCfg\n\t}\n\n\treturn nil\n}\n\nfunc (s *StoreSource) saveToFileUnlocked() error {\n\tstored := storeData{\n\t\tProxies:  make([]v1.TypedProxyConfig, 0, len(s.proxies)),\n\t\tVisitors: make([]v1.TypedVisitorConfig, 0, len(s.visitors)),\n\t}\n\n\tfor _, p := range s.proxies {\n\t\tstored.Proxies = append(stored.Proxies, v1.TypedProxyConfig{ProxyConfigurer: p})\n\t}\n\tfor _, v := range s.visitors {\n\t\tstored.Visitors = append(stored.Visitors, v1.TypedVisitorConfig{VisitorConfigurer: v})\n\t}\n\n\tdata, err := jsonx.MarshalIndent(stored, \"\", \"  \")\n\tif err != nil {\n\t\treturn fmt.Errorf(\"failed to marshal JSON: %w\", err)\n\t}\n\n\tdir := filepath.Dir(s.config.Path)\n\tif err := os.MkdirAll(dir, 0o755); err != nil {\n\t\treturn fmt.Errorf(\"failed to create directory: %w\", err)\n\t}\n\n\ttmpPath := s.config.Path + \".tmp\"\n\n\tf, err := os.OpenFile(tmpPath, os.O_WRONLY|os.O_CREATE|os.O_TRUNC, 0o600)\n\tif err != nil {\n\t\treturn fmt.Errorf(\"failed to create temp file: %w\", err)\n\t}\n\n\tif _, err := f.Write(data); err != nil {\n\t\tf.Close()\n\t\tos.Remove(tmpPath)\n\t\treturn fmt.Errorf(\"failed to write temp file: %w\", err)\n\t}\n\n\tif err := f.Sync(); err != nil {\n\t\tf.Close()\n\t\tos.Remove(tmpPath)\n\t\treturn fmt.Errorf(\"failed to sync temp file: %w\", err)\n\t}\n\n\tif err := f.Close(); err != nil {\n\t\tos.Remove(tmpPath)\n\t\treturn fmt.Errorf(\"failed to close temp file: %w\", err)\n\t}\n\n\tif err := os.Rename(tmpPath, s.config.Path); err != nil {\n\t\tos.Remove(tmpPath)\n\t\treturn fmt.Errorf(\"failed to rename temp file: %w\", err)\n\t}\n\n\treturn nil\n}\n\nfunc (s *StoreSource) AddProxy(proxy v1.ProxyConfigurer) error {\n\tif proxy == nil {\n\t\treturn fmt.Errorf(\"proxy cannot be nil\")\n\t}\n\n\tname := proxy.GetBaseConfig().Name\n\tif name == \"\" {\n\t\treturn fmt.Errorf(\"proxy name cannot be empty\")\n\t}\n\n\ts.mu.Lock()\n\tdefer s.mu.Unlock()\n\n\tif _, exists := s.proxies[name]; exists {\n\t\treturn fmt.Errorf(\"%w: proxy %q\", ErrAlreadyExists, name)\n\t}\n\n\ts.proxies[name] = proxy\n\n\tif err := s.saveToFileUnlocked(); err != nil {\n\t\tdelete(s.proxies, name)\n\t\treturn fmt.Errorf(\"failed to persist: %w\", err)\n\t}\n\treturn nil\n}\n\nfunc (s *StoreSource) UpdateProxy(proxy v1.ProxyConfigurer) error {\n\tif proxy == nil {\n\t\treturn fmt.Errorf(\"proxy cannot be nil\")\n\t}\n\n\tname := proxy.GetBaseConfig().Name\n\tif name == \"\" {\n\t\treturn fmt.Errorf(\"proxy name cannot be empty\")\n\t}\n\n\ts.mu.Lock()\n\tdefer s.mu.Unlock()\n\n\toldProxy, exists := s.proxies[name]\n\tif !exists {\n\t\treturn fmt.Errorf(\"%w: proxy %q\", ErrNotFound, name)\n\t}\n\n\ts.proxies[name] = proxy\n\n\tif err := s.saveToFileUnlocked(); err != nil {\n\t\ts.proxies[name] = oldProxy\n\t\treturn fmt.Errorf(\"failed to persist: %w\", err)\n\t}\n\treturn nil\n}\n\nfunc (s *StoreSource) RemoveProxy(name string) error {\n\tif name == \"\" {\n\t\treturn fmt.Errorf(\"proxy name cannot be empty\")\n\t}\n\n\ts.mu.Lock()\n\tdefer s.mu.Unlock()\n\n\toldProxy, exists := s.proxies[name]\n\tif !exists {\n\t\treturn fmt.Errorf(\"%w: proxy %q\", ErrNotFound, name)\n\t}\n\n\tdelete(s.proxies, name)\n\n\tif err := s.saveToFileUnlocked(); err != nil {\n\t\ts.proxies[name] = oldProxy\n\t\treturn fmt.Errorf(\"failed to persist: %w\", err)\n\t}\n\treturn nil\n}\n\nfunc (s *StoreSource) GetProxy(name string) v1.ProxyConfigurer {\n\ts.mu.RLock()\n\tdefer s.mu.RUnlock()\n\n\tp, exists := s.proxies[name]\n\tif !exists {\n\t\treturn nil\n\t}\n\treturn p\n}\n\nfunc (s *StoreSource) AddVisitor(visitor v1.VisitorConfigurer) error {\n\tif visitor == nil {\n\t\treturn fmt.Errorf(\"visitor cannot be nil\")\n\t}\n\n\tname := visitor.GetBaseConfig().Name\n\tif name == \"\" {\n\t\treturn fmt.Errorf(\"visitor name cannot be empty\")\n\t}\n\n\ts.mu.Lock()\n\tdefer s.mu.Unlock()\n\n\tif _, exists := s.visitors[name]; exists {\n\t\treturn fmt.Errorf(\"%w: visitor %q\", ErrAlreadyExists, name)\n\t}\n\n\ts.visitors[name] = visitor\n\n\tif err := s.saveToFileUnlocked(); err != nil {\n\t\tdelete(s.visitors, name)\n\t\treturn fmt.Errorf(\"failed to persist: %w\", err)\n\t}\n\treturn nil\n}\n\nfunc (s *StoreSource) UpdateVisitor(visitor v1.VisitorConfigurer) error {\n\tif visitor == nil {\n\t\treturn fmt.Errorf(\"visitor cannot be nil\")\n\t}\n\n\tname := visitor.GetBaseConfig().Name\n\tif name == \"\" {\n\t\treturn fmt.Errorf(\"visitor name cannot be empty\")\n\t}\n\n\ts.mu.Lock()\n\tdefer s.mu.Unlock()\n\n\toldVisitor, exists := s.visitors[name]\n\tif !exists {\n\t\treturn fmt.Errorf(\"%w: visitor %q\", ErrNotFound, name)\n\t}\n\n\ts.visitors[name] = visitor\n\n\tif err := s.saveToFileUnlocked(); err != nil {\n\t\ts.visitors[name] = oldVisitor\n\t\treturn fmt.Errorf(\"failed to persist: %w\", err)\n\t}\n\treturn nil\n}\n\nfunc (s *StoreSource) RemoveVisitor(name string) error {\n\tif name == \"\" {\n\t\treturn fmt.Errorf(\"visitor name cannot be empty\")\n\t}\n\n\ts.mu.Lock()\n\tdefer s.mu.Unlock()\n\n\toldVisitor, exists := s.visitors[name]\n\tif !exists {\n\t\treturn fmt.Errorf(\"%w: visitor %q\", ErrNotFound, name)\n\t}\n\n\tdelete(s.visitors, name)\n\n\tif err := s.saveToFileUnlocked(); err != nil {\n\t\ts.visitors[name] = oldVisitor\n\t\treturn fmt.Errorf(\"failed to persist: %w\", err)\n\t}\n\treturn nil\n}\n\nfunc (s *StoreSource) GetVisitor(name string) v1.VisitorConfigurer {\n\ts.mu.RLock()\n\tdefer s.mu.RUnlock()\n\n\tv, exists := s.visitors[name]\n\tif !exists {\n\t\treturn nil\n\t}\n\treturn v\n}\n\nfunc (s *StoreSource) GetAllProxies() ([]v1.ProxyConfigurer, error) {\n\ts.mu.RLock()\n\tdefer s.mu.RUnlock()\n\n\tresult := make([]v1.ProxyConfigurer, 0, len(s.proxies))\n\tfor _, p := range s.proxies {\n\t\tresult = append(result, p)\n\t}\n\treturn result, nil\n}\n\nfunc (s *StoreSource) GetAllVisitors() ([]v1.VisitorConfigurer, error) {\n\ts.mu.RLock()\n\tdefer s.mu.RUnlock()\n\n\tresult := make([]v1.VisitorConfigurer, 0, len(s.visitors))\n\tfor _, v := range s.visitors {\n\t\tresult = append(result, v)\n\t}\n\treturn result, nil\n}\n"
  },
  {
    "path": "pkg/config/source/store_test.go",
    "content": "// Copyright 2026 The frp Authors\n//\n// Licensed under the Apache License, Version 2.0 (the \"License\");\n// you may not use this file except in compliance with the License.\n// You may obtain a copy of the License at\n//\n//     http://www.apache.org/licenses/LICENSE-2.0\n//\n// Unless required by applicable law or agreed to in writing, software\n// distributed under the License is distributed on an \"AS IS\" BASIS,\n// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.\n// See the License for the specific language governing permissions and\n// limitations under the License.\n\npackage source\n\nimport (\n\t\"os\"\n\t\"path/filepath\"\n\t\"testing\"\n\n\t\"github.com/stretchr/testify/require\"\n\n\tv1 \"github.com/fatedier/frp/pkg/config/v1\"\n\t\"github.com/fatedier/frp/pkg/util/jsonx\"\n)\n\nfunc TestStoreSource_AddProxyAndVisitor_DoesNotApplyRuntimeDefaults(t *testing.T) {\n\trequire := require.New(t)\n\n\tpath := filepath.Join(t.TempDir(), \"store.json\")\n\tstoreSource, err := NewStoreSource(StoreSourceConfig{Path: path})\n\trequire.NoError(err)\n\n\tproxyCfg := &v1.TCPProxyConfig{}\n\tproxyCfg.Name = \"proxy1\"\n\tproxyCfg.Type = \"tcp\"\n\tproxyCfg.LocalPort = 10080\n\n\tvisitorCfg := &v1.XTCPVisitorConfig{}\n\tvisitorCfg.Name = \"visitor1\"\n\tvisitorCfg.Type = \"xtcp\"\n\tvisitorCfg.ServerName = \"server1\"\n\tvisitorCfg.SecretKey = \"secret\"\n\tvisitorCfg.BindPort = 10081\n\n\terr = storeSource.AddProxy(proxyCfg)\n\trequire.NoError(err)\n\terr = storeSource.AddVisitor(visitorCfg)\n\trequire.NoError(err)\n\n\tgotProxy := storeSource.GetProxy(\"proxy1\")\n\trequire.NotNil(gotProxy)\n\trequire.Empty(gotProxy.GetBaseConfig().LocalIP)\n\n\tgotVisitor := storeSource.GetVisitor(\"visitor1\")\n\trequire.NotNil(gotVisitor)\n\trequire.Empty(gotVisitor.GetBaseConfig().BindAddr)\n\trequire.Empty(gotVisitor.(*v1.XTCPVisitorConfig).Protocol)\n}\n\nfunc TestStoreSource_LoadFromFile_DoesNotApplyRuntimeDefaults(t *testing.T) {\n\trequire := require.New(t)\n\n\tpath := filepath.Join(t.TempDir(), \"store.json\")\n\n\tproxyCfg := &v1.TCPProxyConfig{}\n\tproxyCfg.Name = \"proxy1\"\n\tproxyCfg.Type = \"tcp\"\n\tproxyCfg.LocalPort = 10080\n\n\tvisitorCfg := &v1.XTCPVisitorConfig{}\n\tvisitorCfg.Name = \"visitor1\"\n\tvisitorCfg.Type = \"xtcp\"\n\tvisitorCfg.ServerName = \"server1\"\n\tvisitorCfg.SecretKey = \"secret\"\n\tvisitorCfg.BindPort = 10081\n\n\tstored := storeData{\n\t\tProxies:  []v1.TypedProxyConfig{{ProxyConfigurer: proxyCfg}},\n\t\tVisitors: []v1.TypedVisitorConfig{{VisitorConfigurer: visitorCfg}},\n\t}\n\tdata, err := jsonx.Marshal(stored)\n\trequire.NoError(err)\n\terr = os.WriteFile(path, data, 0o600)\n\trequire.NoError(err)\n\n\tstoreSource, err := NewStoreSource(StoreSourceConfig{Path: path})\n\trequire.NoError(err)\n\n\tgotProxy := storeSource.GetProxy(\"proxy1\")\n\trequire.NotNil(gotProxy)\n\trequire.Empty(gotProxy.GetBaseConfig().LocalIP)\n\n\tgotVisitor := storeSource.GetVisitor(\"visitor1\")\n\trequire.NotNil(gotVisitor)\n\trequire.Empty(gotVisitor.GetBaseConfig().BindAddr)\n\trequire.Empty(gotVisitor.(*v1.XTCPVisitorConfig).Protocol)\n}\n\nfunc TestStoreSource_LoadFromFile_UnknownFieldsAreIgnored(t *testing.T) {\n\trequire := require.New(t)\n\n\tpath := filepath.Join(t.TempDir(), \"store.json\")\n\traw := []byte(`{\n\t\t\"proxies\": [\n\t\t\t{\"name\":\"proxy1\",\"type\":\"tcp\",\"localPort\":10080,\"unexpected\":\"value\"}\n\t\t],\n\t\t\"visitors\": [\n\t\t\t{\"name\":\"visitor1\",\"type\":\"xtcp\",\"serverName\":\"server1\",\"secretKey\":\"secret\",\"bindPort\":10081,\"unexpected\":\"value\"}\n\t\t]\n\t}`)\n\terr := os.WriteFile(path, raw, 0o600)\n\trequire.NoError(err)\n\n\tstoreSource, err := NewStoreSource(StoreSourceConfig{Path: path})\n\trequire.NoError(err)\n\n\trequire.NotNil(storeSource.GetProxy(\"proxy1\"))\n\trequire.NotNil(storeSource.GetVisitor(\"visitor1\"))\n}\n"
  },
  {
    "path": "pkg/config/template.go",
    "content": "// Copyright 2024 The frp Authors\n//\n// Licensed under the Apache License, Version 2.0 (the \"License\");\n// you may not use this file except in compliance with the License.\n// You may obtain a copy of the License at\n//\n//     http://www.apache.org/licenses/LICENSE-2.0\n//\n// Unless required by applicable law or agreed to in writing, software\n// distributed under the License is distributed on an \"AS IS\" BASIS,\n// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.\n// See the License for the specific language governing permissions and\n// limitations under the License.\n\npackage config\n\nimport (\n\t\"fmt\"\n\n\t\"github.com/fatedier/frp/pkg/util/util\"\n)\n\ntype NumberPair struct {\n\tFirst  int64\n\tSecond int64\n}\n\nfunc parseNumberRangePair(firstRangeStr, secondRangeStr string) ([]NumberPair, error) {\n\tfirstRangeNumbers, err := util.ParseRangeNumbers(firstRangeStr)\n\tif err != nil {\n\t\treturn nil, err\n\t}\n\tsecondRangeNumbers, err := util.ParseRangeNumbers(secondRangeStr)\n\tif err != nil {\n\t\treturn nil, err\n\t}\n\tif len(firstRangeNumbers) != len(secondRangeNumbers) {\n\t\treturn nil, fmt.Errorf(\"first and second range numbers are not in pairs\")\n\t}\n\tpairs := make([]NumberPair, 0, len(firstRangeNumbers))\n\tfor i := range firstRangeNumbers {\n\t\tpairs = append(pairs, NumberPair{\n\t\t\tFirst:  firstRangeNumbers[i],\n\t\t\tSecond: secondRangeNumbers[i],\n\t\t})\n\t}\n\treturn pairs, nil\n}\n\nfunc parseNumberRange(firstRangeStr string) ([]int64, error) {\n\treturn util.ParseRangeNumbers(firstRangeStr)\n}\n"
  },
  {
    "path": "pkg/config/types/types.go",
    "content": "// Copyright 2019 fatedier, fatedier@gmail.com\n//\n// Licensed under the Apache License, Version 2.0 (the \"License\");\n// you may not use this file except in compliance with the License.\n// You may obtain a copy of the License at\n//\n//     http://www.apache.org/licenses/LICENSE-2.0\n//\n// Unless required by applicable law or agreed to in writing, software\n// distributed under the License is distributed on an \"AS IS\" BASIS,\n// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.\n// See the License for the specific language governing permissions and\n// limitations under the License.\n\npackage types\n\nimport (\n\t\"encoding/json\"\n\t\"errors\"\n\t\"fmt\"\n\t\"strconv\"\n\t\"strings\"\n)\n\nconst (\n\tMB = 1024 * 1024\n\tKB = 1024\n\n\tBandwidthLimitModeClient = \"client\"\n\tBandwidthLimitModeServer = \"server\"\n)\n\ntype BandwidthQuantity struct {\n\ts string // MB or KB\n\n\ti int64 // bytes\n}\n\nfunc NewBandwidthQuantity(s string) (BandwidthQuantity, error) {\n\tq := BandwidthQuantity{}\n\terr := q.UnmarshalString(s)\n\tif err != nil {\n\t\treturn q, err\n\t}\n\treturn q, nil\n}\n\nfunc (q *BandwidthQuantity) Equal(u *BandwidthQuantity) bool {\n\tif q == nil && u == nil {\n\t\treturn true\n\t}\n\tif q != nil && u != nil {\n\t\treturn q.i == u.i\n\t}\n\treturn false\n}\n\nfunc (q *BandwidthQuantity) String() string {\n\treturn q.s\n}\n\nfunc (q *BandwidthQuantity) UnmarshalString(s string) error {\n\ts = strings.TrimSpace(s)\n\tif s == \"\" {\n\t\treturn nil\n\t}\n\n\tvar (\n\t\tbase int64\n\t\tf    float64\n\t\terr  error\n\t)\n\tif fstr, ok := strings.CutSuffix(s, \"MB\"); ok {\n\t\tbase = MB\n\t\tf, err = strconv.ParseFloat(fstr, 64)\n\t} else if fstr, ok := strings.CutSuffix(s, \"KB\"); ok {\n\t\tbase = KB\n\t\tf, err = strconv.ParseFloat(fstr, 64)\n\t} else {\n\t\treturn errors.New(\"unit not support\")\n\t}\n\tif err != nil {\n\t\treturn err\n\t}\n\n\tq.s = s\n\tq.i = int64(f * float64(base))\n\treturn nil\n}\n\nfunc (q *BandwidthQuantity) UnmarshalJSON(b []byte) error {\n\tif len(b) == 4 && string(b) == \"null\" {\n\t\treturn nil\n\t}\n\n\tvar str string\n\terr := json.Unmarshal(b, &str)\n\tif err != nil {\n\t\treturn err\n\t}\n\n\treturn q.UnmarshalString(str)\n}\n\nfunc (q *BandwidthQuantity) MarshalJSON() ([]byte, error) {\n\treturn []byte(\"\\\"\" + q.s + \"\\\"\"), nil\n}\n\nfunc (q *BandwidthQuantity) Bytes() int64 {\n\treturn q.i\n}\n\ntype PortsRange struct {\n\tStart  int `json:\"start,omitempty\"`\n\tEnd    int `json:\"end,omitempty\"`\n\tSingle int `json:\"single,omitempty\"`\n}\n\ntype PortsRangeSlice []PortsRange\n\nfunc (p PortsRangeSlice) String() string {\n\tif len(p) == 0 {\n\t\treturn \"\"\n\t}\n\tstrs := []string{}\n\tfor _, v := range p {\n\t\tif v.Single > 0 {\n\t\t\tstrs = append(strs, strconv.Itoa(v.Single))\n\t\t} else {\n\t\t\tstrs = append(strs, strconv.Itoa(v.Start)+\"-\"+strconv.Itoa(v.End))\n\t\t}\n\t}\n\treturn strings.Join(strs, \",\")\n}\n\n// the format of str is like \"1000-2000,3000,4000-5000\"\nfunc NewPortsRangeSliceFromString(str string) ([]PortsRange, error) {\n\tstr = strings.TrimSpace(str)\n\tout := []PortsRange{}\n\tnumRanges := strings.SplitSeq(str, \",\")\n\tfor numRangeStr := range numRanges {\n\t\t// 1000-2000 or 2001\n\t\tnumArray := strings.Split(numRangeStr, \"-\")\n\t\t// length: only 1 or 2 is correct\n\t\trangeType := len(numArray)\n\t\tswitch rangeType {\n\t\tcase 1:\n\t\t\t// single number\n\t\t\tsingleNum, err := strconv.ParseInt(strings.TrimSpace(numArray[0]), 10, 64)\n\t\t\tif err != nil {\n\t\t\t\treturn nil, fmt.Errorf(\"range number is invalid, %v\", err)\n\t\t\t}\n\t\t\tout = append(out, PortsRange{Single: int(singleNum)})\n\t\tcase 2:\n\t\t\t// range numbers\n\t\t\tminNum, err := strconv.ParseInt(strings.TrimSpace(numArray[0]), 10, 64)\n\t\t\tif err != nil {\n\t\t\t\treturn nil, fmt.Errorf(\"range number is invalid, %v\", err)\n\t\t\t}\n\t\t\tmaxNum, err := strconv.ParseInt(strings.TrimSpace(numArray[1]), 10, 64)\n\t\t\tif err != nil {\n\t\t\t\treturn nil, fmt.Errorf(\"range number is invalid, %v\", err)\n\t\t\t}\n\t\t\tif maxNum < minNum {\n\t\t\t\treturn nil, fmt.Errorf(\"range number is invalid\")\n\t\t\t}\n\t\t\tout = append(out, PortsRange{Start: int(minNum), End: int(maxNum)})\n\t\tdefault:\n\t\t\treturn nil, fmt.Errorf(\"range number is invalid\")\n\t\t}\n\t}\n\treturn out, nil\n}\n"
  },
  {
    "path": "pkg/config/types/types_test.go",
    "content": "// Copyright 2019 fatedier, fatedier@gmail.com\n//\n// Licensed under the Apache License, Version 2.0 (the \"License\");\n// you may not use this file except in compliance with the License.\n// You may obtain a copy of the License at\n//\n//     http://www.apache.org/licenses/LICENSE-2.0\n//\n// Unless required by applicable law or agreed to in writing, software\n// distributed under the License is distributed on an \"AS IS\" BASIS,\n// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.\n// See the License for the specific language governing permissions and\n// limitations under the License.\n\npackage types\n\nimport (\n\t\"encoding/json\"\n\t\"testing\"\n\n\t\"github.com/stretchr/testify/require\"\n)\n\ntype Wrap struct {\n\tB   BandwidthQuantity `json:\"b\"`\n\tInt int               `json:\"int\"`\n}\n\nfunc TestBandwidthQuantity(t *testing.T) {\n\trequire := require.New(t)\n\n\tvar w Wrap\n\terr := json.Unmarshal([]byte(`{\"b\":\"1KB\",\"int\":5}`), &w)\n\trequire.NoError(err)\n\trequire.EqualValues(1*KB, w.B.Bytes())\n\n\tbuf, err := json.Marshal(&w)\n\trequire.NoError(err)\n\trequire.Equal(`{\"b\":\"1KB\",\"int\":5}`, string(buf))\n}\n\nfunc TestBandwidthQuantity_MB(t *testing.T) {\n\trequire := require.New(t)\n\n\tvar w Wrap\n\terr := json.Unmarshal([]byte(`{\"b\":\"2MB\",\"int\":1}`), &w)\n\trequire.NoError(err)\n\trequire.EqualValues(2*MB, w.B.Bytes())\n\n\tbuf, err := json.Marshal(&w)\n\trequire.NoError(err)\n\trequire.Equal(`{\"b\":\"2MB\",\"int\":1}`, string(buf))\n}\n\nfunc TestBandwidthQuantity_InvalidUnit(t *testing.T) {\n\tvar w Wrap\n\terr := json.Unmarshal([]byte(`{\"b\":\"1GB\",\"int\":1}`), &w)\n\trequire.Error(t, err)\n}\n\nfunc TestBandwidthQuantity_InvalidNumber(t *testing.T) {\n\tvar w Wrap\n\terr := json.Unmarshal([]byte(`{\"b\":\"abcKB\",\"int\":1}`), &w)\n\trequire.Error(t, err)\n}\n\nfunc TestPortsRangeSlice2String(t *testing.T) {\n\trequire := require.New(t)\n\n\tports := []PortsRange{\n\t\t{\n\t\t\tStart: 1000,\n\t\t\tEnd:   2000,\n\t\t},\n\t\t{\n\t\t\tSingle: 3000,\n\t\t},\n\t}\n\tstr := PortsRangeSlice(ports).String()\n\trequire.Equal(\"1000-2000,3000\", str)\n}\n\nfunc TestNewPortsRangeSliceFromString(t *testing.T) {\n\trequire := require.New(t)\n\n\tports, err := NewPortsRangeSliceFromString(\"1000-2000,3000\")\n\trequire.NoError(err)\n\trequire.Equal([]PortsRange{\n\t\t{\n\t\t\tStart: 1000,\n\t\t\tEnd:   2000,\n\t\t},\n\t\t{\n\t\t\tSingle: 3000,\n\t\t},\n\t}, ports)\n}\n"
  },
  {
    "path": "pkg/config/v1/api.go",
    "content": "// Copyright 2023 The frp Authors\n//\n// Licensed under the Apache License, Version 2.0 (the \"License\");\n// you may not use this file except in compliance with the License.\n// You may obtain a copy of the License at\n//\n//     http://www.apache.org/licenses/LICENSE-2.0\n//\n// Unless required by applicable law or agreed to in writing, software\n// distributed under the License is distributed on an \"AS IS\" BASIS,\n// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.\n// See the License for the specific language governing permissions and\n// limitations under the License.\n\npackage v1\n\ntype APIMetadata struct {\n\tVersion string `json:\"version\"`\n}\n"
  },
  {
    "path": "pkg/config/v1/client.go",
    "content": "// Copyright 2023 The frp Authors\n//\n// Licensed under the Apache License, Version 2.0 (the \"License\");\n// you may not use this file except in compliance with the License.\n// You may obtain a copy of the License at\n//\n//     http://www.apache.org/licenses/LICENSE-2.0\n//\n// Unless required by applicable law or agreed to in writing, software\n// distributed under the License is distributed on an \"AS IS\" BASIS,\n// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.\n// See the License for the specific language governing permissions and\n// limitations under the License.\n\npackage v1\n\nimport (\n\t\"os\"\n\n\t\"github.com/samber/lo\"\n\n\t\"github.com/fatedier/frp/pkg/util/util\"\n)\n\ntype ClientConfig struct {\n\tClientCommonConfig\n\n\tProxies  []TypedProxyConfig   `json:\"proxies,omitempty\"`\n\tVisitors []TypedVisitorConfig `json:\"visitors,omitempty\"`\n}\n\ntype ClientCommonConfig struct {\n\tAPIMetadata\n\n\tAuth AuthClientConfig `json:\"auth,omitempty\"`\n\t// User specifies a prefix for proxy names to distinguish them from other\n\t// clients. If this value is not \"\", proxy names will automatically be\n\t// changed to \"{user}.{proxy_name}\".\n\tUser string `json:\"user,omitempty\"`\n\t// ClientID uniquely identifies this frpc instance.\n\tClientID string `json:\"clientID,omitempty\"`\n\n\t// ServerAddr specifies the address of the server to connect to. By\n\t// default, this value is \"0.0.0.0\".\n\tServerAddr string `json:\"serverAddr,omitempty\"`\n\t// ServerPort specifies the port to connect to the server on. By default,\n\t// this value is 7000.\n\tServerPort int `json:\"serverPort,omitempty\"`\n\t// STUN server to help penetrate NAT hole.\n\tNatHoleSTUNServer string `json:\"natHoleStunServer,omitempty\"`\n\t// DNSServer specifies a DNS server address for FRPC to use. If this value\n\t// is \"\", the default DNS will be used.\n\tDNSServer string `json:\"dnsServer,omitempty\"`\n\t// LoginFailExit controls whether or not the client should exit after a\n\t// failed login attempt. If false, the client will retry until a login\n\t// attempt succeeds. By default, this value is true.\n\tLoginFailExit *bool `json:\"loginFailExit,omitempty\"`\n\t// Start specifies a set of enabled proxies by name. If this set is empty,\n\t// all supplied proxies are enabled. By default, this value is an empty\n\t// set.\n\tStart []string `json:\"start,omitempty\"`\n\n\tLog        LogConfig             `json:\"log,omitempty\"`\n\tWebServer  WebServerConfig       `json:\"webServer,omitempty\"`\n\tTransport  ClientTransportConfig `json:\"transport,omitempty\"`\n\tVirtualNet VirtualNetConfig      `json:\"virtualNet,omitempty\"`\n\n\t// FeatureGates specifies a set of feature gates to enable or disable.\n\t// This can be used to enable alpha/beta features or disable default features.\n\tFeatureGates map[string]bool `json:\"featureGates,omitempty\"`\n\n\t// UDPPacketSize specifies the udp packet size\n\t// By default, this value is 1500\n\tUDPPacketSize int64 `json:\"udpPacketSize,omitempty\"`\n\t// Client metadata info\n\tMetadatas map[string]string `json:\"metadatas,omitempty\"`\n\n\t// Include other config files for proxies.\n\tIncludeConfigFiles []string `json:\"includes,omitempty\"`\n\n\t// Store config enables the built-in store source (not configurable via sources list).\n\tStore StoreConfig `json:\"store,omitempty\"`\n}\n\nfunc (c *ClientCommonConfig) Complete() error {\n\tc.ServerAddr = util.EmptyOr(c.ServerAddr, \"0.0.0.0\")\n\tc.ServerPort = util.EmptyOr(c.ServerPort, 7000)\n\tc.LoginFailExit = util.EmptyOr(c.LoginFailExit, lo.ToPtr(true))\n\tc.NatHoleSTUNServer = util.EmptyOr(c.NatHoleSTUNServer, \"stun.easyvoip.com:3478\")\n\n\tif err := c.Auth.Complete(); err != nil {\n\t\treturn err\n\t}\n\tc.Log.Complete()\n\tc.Transport.Complete()\n\tc.WebServer.Complete()\n\n\tc.UDPPacketSize = util.EmptyOr(c.UDPPacketSize, 1500)\n\treturn nil\n}\n\ntype ClientTransportConfig struct {\n\t// Protocol specifies the protocol to use when interacting with the server.\n\t// Valid values are \"tcp\", \"kcp\", \"quic\", \"websocket\" and \"wss\". By default, this value\n\t// is \"tcp\".\n\tProtocol string `json:\"protocol,omitempty\"`\n\t// The maximum amount of time a dial to server will wait for a connect to complete.\n\tDialServerTimeout int64 `json:\"dialServerTimeout,omitempty\"`\n\t// DialServerKeepAlive specifies the interval between keep-alive probes for an active network connection between frpc and frps.\n\t// If negative, keep-alive probes are disabled.\n\tDialServerKeepAlive int64 `json:\"dialServerKeepalive,omitempty\"`\n\t// ConnectServerLocalIP specifies the address of the client bind when it connect to server.\n\t// Note: This value only use in TCP/Websocket protocol. Not support in KCP protocol.\n\tConnectServerLocalIP string `json:\"connectServerLocalIP,omitempty\"`\n\t// ProxyURL specifies a proxy address to connect to the server through. If\n\t// this value is \"\", the server will be connected to directly. By default,\n\t// this value is read from the \"http_proxy\" environment variable.\n\tProxyURL string `json:\"proxyURL,omitempty\"`\n\t// PoolCount specifies the number of connections the client will make to\n\t// the server in advance.\n\tPoolCount int `json:\"poolCount,omitempty\"`\n\t// TCPMux toggles TCP stream multiplexing. This allows multiple requests\n\t// from a client to share a single TCP connection. If this value is true,\n\t// the server must have TCP multiplexing enabled as well. By default, this\n\t// value is true.\n\tTCPMux *bool `json:\"tcpMux,omitempty\"`\n\t// TCPMuxKeepaliveInterval specifies the keep alive interval for TCP stream multiplier.\n\t// If TCPMux is true, heartbeat of application layer is unnecessary because it can only rely on heartbeat in TCPMux.\n\tTCPMuxKeepaliveInterval int64 `json:\"tcpMuxKeepaliveInterval,omitempty\"`\n\t// QUIC protocol options.\n\tQUIC QUICOptions `json:\"quic,omitempty\"`\n\t// HeartBeatInterval specifies at what interval heartbeats are sent to the\n\t// server, in seconds. It is not recommended to change this value. By\n\t// default, this value is 30. Set negative value to disable it.\n\tHeartbeatInterval int64 `json:\"heartbeatInterval,omitempty\"`\n\t// HeartBeatTimeout specifies the maximum allowed heartbeat response delay\n\t// before the connection is terminated, in seconds. It is not recommended\n\t// to change this value. By default, this value is 90. Set negative value to disable it.\n\tHeartbeatTimeout int64 `json:\"heartbeatTimeout,omitempty\"`\n\t// TLS specifies TLS settings for the connection to the server.\n\tTLS TLSClientConfig `json:\"tls,omitempty\"`\n}\n\nfunc (c *ClientTransportConfig) Complete() {\n\tc.Protocol = util.EmptyOr(c.Protocol, \"tcp\")\n\tc.DialServerTimeout = util.EmptyOr(c.DialServerTimeout, 10)\n\tc.DialServerKeepAlive = util.EmptyOr(c.DialServerKeepAlive, 7200)\n\tc.ProxyURL = util.EmptyOr(c.ProxyURL, os.Getenv(\"http_proxy\"))\n\tc.PoolCount = util.EmptyOr(c.PoolCount, 1)\n\tc.TCPMux = util.EmptyOr(c.TCPMux, lo.ToPtr(true))\n\tc.TCPMuxKeepaliveInterval = util.EmptyOr(c.TCPMuxKeepaliveInterval, 30)\n\tif lo.FromPtr(c.TCPMux) {\n\t\t// If TCPMux is enabled, heartbeat of application layer is unnecessary because we can rely on heartbeat in tcpmux.\n\t\tc.HeartbeatInterval = util.EmptyOr(c.HeartbeatInterval, -1)\n\t\tc.HeartbeatTimeout = util.EmptyOr(c.HeartbeatTimeout, -1)\n\t} else {\n\t\tc.HeartbeatInterval = util.EmptyOr(c.HeartbeatInterval, 30)\n\t\tc.HeartbeatTimeout = util.EmptyOr(c.HeartbeatTimeout, 90)\n\t}\n\tc.QUIC.Complete()\n\tc.TLS.Complete()\n}\n\ntype TLSClientConfig struct {\n\t// TLSEnable specifies whether or not TLS should be used when communicating\n\t// with the server. If \"tls.certFile\" and \"tls.keyFile\" are valid,\n\t// client will load the supplied tls configuration.\n\t// Since v0.50.0, the default value has been changed to true, and tls is enabled by default.\n\tEnable *bool `json:\"enable,omitempty\"`\n\t// If DisableCustomTLSFirstByte is set to false, frpc will establish a connection with frps using the\n\t// first custom byte when tls is enabled.\n\t// Since v0.50.0, the default value has been changed to true, and the first custom byte is disabled by default.\n\tDisableCustomTLSFirstByte *bool `json:\"disableCustomTLSFirstByte,omitempty\"`\n\n\tTLSConfig\n}\n\nfunc (c *TLSClientConfig) Complete() {\n\tc.Enable = util.EmptyOr(c.Enable, lo.ToPtr(true))\n\tc.DisableCustomTLSFirstByte = util.EmptyOr(c.DisableCustomTLSFirstByte, lo.ToPtr(true))\n}\n\ntype AuthClientConfig struct {\n\t// Method specifies what authentication method to use to\n\t// authenticate frpc with frps. If \"token\" is specified - token will be\n\t// read into login message. If \"oidc\" is specified - OIDC (Open ID Connect)\n\t// token will be issued using OIDC settings. By default, this value is \"token\".\n\tMethod AuthMethod `json:\"method,omitempty\"`\n\t// Specify whether to include auth info in additional scope.\n\t// Current supported scopes are: \"HeartBeats\", \"NewWorkConns\".\n\tAdditionalScopes []AuthScope `json:\"additionalScopes,omitempty\"`\n\t// Token specifies the authorization token used to create keys to be sent\n\t// to the server. The server must have a matching token for authorization\n\t// to succeed.  By default, this value is \"\".\n\tToken string `json:\"token,omitempty\"`\n\t// TokenSource specifies a dynamic source for the authorization token.\n\t// This is mutually exclusive with Token field.\n\tTokenSource *ValueSource         `json:\"tokenSource,omitempty\"`\n\tOIDC        AuthOIDCClientConfig `json:\"oidc,omitempty\"`\n}\n\nfunc (c *AuthClientConfig) Complete() error {\n\tc.Method = util.EmptyOr(c.Method, \"token\")\n\treturn nil\n}\n\ntype AuthOIDCClientConfig struct {\n\t// ClientID specifies the client ID to use to get a token in OIDC authentication.\n\tClientID string `json:\"clientID,omitempty\"`\n\t// ClientSecret specifies the client secret to use to get a token in OIDC\n\t// authentication.\n\tClientSecret string `json:\"clientSecret,omitempty\"`\n\t// Audience specifies the audience of the token in OIDC authentication.\n\tAudience string `json:\"audience,omitempty\"`\n\t// Scope specifies the scope of the token in OIDC authentication.\n\tScope string `json:\"scope,omitempty\"`\n\t// TokenEndpointURL specifies the URL which implements OIDC Token Endpoint.\n\t// It will be used to get an OIDC token.\n\tTokenEndpointURL string `json:\"tokenEndpointURL,omitempty\"`\n\t// AdditionalEndpointParams specifies additional parameters to be sent\n\t// this field will be transfer to map[string][]string in OIDC token generator.\n\tAdditionalEndpointParams map[string]string `json:\"additionalEndpointParams,omitempty\"`\n\n\t// TrustedCaFile specifies the path to a custom CA certificate file\n\t// for verifying the OIDC token endpoint's TLS certificate.\n\tTrustedCaFile string `json:\"trustedCaFile,omitempty\"`\n\t// InsecureSkipVerify disables TLS certificate verification for the\n\t// OIDC token endpoint. Only use this for debugging, not recommended for production.\n\tInsecureSkipVerify bool `json:\"insecureSkipVerify,omitempty\"`\n\t// ProxyURL specifies a proxy to use when connecting to the OIDC token endpoint.\n\t// Supports http, https, socks5, and socks5h proxy protocols.\n\t// If empty, no proxy is used for OIDC connections.\n\tProxyURL string `json:\"proxyURL,omitempty\"`\n\n\t// TokenSource specifies a custom dynamic source for the authorization token.\n\t// This is mutually exclusive with every other field of this structure.\n\tTokenSource *ValueSource `json:\"tokenSource,omitempty\"`\n}\n\ntype VirtualNetConfig struct {\n\tAddress string `json:\"address,omitempty\"`\n}\n"
  },
  {
    "path": "pkg/config/v1/client_test.go",
    "content": "// Copyright 2023 The frp Authors\n//\n// Licensed under the Apache License, Version 2.0 (the \"License\");\n// you may not use this file except in compliance with the License.\n// You may obtain a copy of the License at\n//\n//     http://www.apache.org/licenses/LICENSE-2.0\n//\n// Unless required by applicable law or agreed to in writing, software\n// distributed under the License is distributed on an \"AS IS\" BASIS,\n// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.\n// See the License for the specific language governing permissions and\n// limitations under the License.\n\npackage v1\n\nimport (\n\t\"testing\"\n\n\t\"github.com/samber/lo\"\n\t\"github.com/stretchr/testify/require\"\n)\n\nfunc TestClientConfigComplete(t *testing.T) {\n\trequire := require.New(t)\n\tc := &ClientConfig{}\n\terr := c.Complete()\n\trequire.NoError(err)\n\n\trequire.EqualValues(\"token\", c.Auth.Method)\n\trequire.Equal(true, lo.FromPtr(c.Transport.TCPMux))\n\trequire.Equal(true, lo.FromPtr(c.LoginFailExit))\n\trequire.Equal(true, lo.FromPtr(c.Transport.TLS.Enable))\n\trequire.Equal(true, lo.FromPtr(c.Transport.TLS.DisableCustomTLSFirstByte))\n\trequire.NotEmpty(c.NatHoleSTUNServer)\n}\n\nfunc TestAuthClientConfig_Complete(t *testing.T) {\n\trequire := require.New(t)\n\tcfg := &AuthClientConfig{}\n\terr := cfg.Complete()\n\trequire.NoError(err)\n\trequire.EqualValues(\"token\", cfg.Method)\n}\n"
  },
  {
    "path": "pkg/config/v1/clone_test.go",
    "content": "package v1\n\nimport (\n\t\"testing\"\n\n\t\"github.com/stretchr/testify/require\"\n)\n\nfunc TestProxyCloneDeepCopy(t *testing.T) {\n\trequire := require.New(t)\n\n\tenabled := true\n\tpluginHTTP2 := true\n\tcfg := &HTTPProxyConfig{\n\t\tProxyBaseConfig: ProxyBaseConfig{\n\t\t\tName:        \"p1\",\n\t\t\tType:        \"http\",\n\t\t\tEnabled:     &enabled,\n\t\t\tAnnotations: map[string]string{\"a\": \"1\"},\n\t\t\tMetadatas:   map[string]string{\"m\": \"1\"},\n\t\t\tHealthCheck: HealthCheckConfig{\n\t\t\t\tType: \"http\",\n\t\t\t\tHTTPHeaders: []HTTPHeader{\n\t\t\t\t\t{Name: \"X-Test\", Value: \"v1\"},\n\t\t\t\t},\n\t\t\t},\n\t\t\tProxyBackend: ProxyBackend{\n\t\t\t\tPlugin: TypedClientPluginOptions{\n\t\t\t\t\tType: PluginHTTPS2HTTP,\n\t\t\t\t\tClientPluginOptions: &HTTPS2HTTPPluginOptions{\n\t\t\t\t\t\tType:           PluginHTTPS2HTTP,\n\t\t\t\t\t\tEnableHTTP2:    &pluginHTTP2,\n\t\t\t\t\t\tRequestHeaders: HeaderOperations{Set: map[string]string{\"k\": \"v\"}},\n\t\t\t\t\t},\n\t\t\t\t},\n\t\t\t},\n\t\t},\n\t\tDomainConfig: DomainConfig{\n\t\t\tCustomDomains: []string{\"a.example.com\"},\n\t\t\tSubDomain:     \"a\",\n\t\t},\n\t\tLocations:       []string{\"/api\"},\n\t\tRequestHeaders:  HeaderOperations{Set: map[string]string{\"h1\": \"v1\"}},\n\t\tResponseHeaders: HeaderOperations{Set: map[string]string{\"h2\": \"v2\"}},\n\t}\n\n\tcloned := cfg.Clone().(*HTTPProxyConfig)\n\n\t*cloned.Enabled = false\n\tcloned.Annotations[\"a\"] = \"changed\"\n\tcloned.Metadatas[\"m\"] = \"changed\"\n\tcloned.HealthCheck.HTTPHeaders[0].Value = \"changed\"\n\tcloned.CustomDomains[0] = \"b.example.com\"\n\tcloned.Locations[0] = \"/new\"\n\tcloned.RequestHeaders.Set[\"h1\"] = \"changed\"\n\tcloned.ResponseHeaders.Set[\"h2\"] = \"changed\"\n\tclientPlugin := cloned.Plugin.ClientPluginOptions.(*HTTPS2HTTPPluginOptions)\n\t*clientPlugin.EnableHTTP2 = false\n\tclientPlugin.RequestHeaders.Set[\"k\"] = \"changed\"\n\n\trequire.True(*cfg.Enabled)\n\trequire.Equal(\"1\", cfg.Annotations[\"a\"])\n\trequire.Equal(\"1\", cfg.Metadatas[\"m\"])\n\trequire.Equal(\"v1\", cfg.HealthCheck.HTTPHeaders[0].Value)\n\trequire.Equal(\"a.example.com\", cfg.CustomDomains[0])\n\trequire.Equal(\"/api\", cfg.Locations[0])\n\trequire.Equal(\"v1\", cfg.RequestHeaders.Set[\"h1\"])\n\trequire.Equal(\"v2\", cfg.ResponseHeaders.Set[\"h2\"])\n\n\torigPlugin := cfg.Plugin.ClientPluginOptions.(*HTTPS2HTTPPluginOptions)\n\trequire.True(*origPlugin.EnableHTTP2)\n\trequire.Equal(\"v\", origPlugin.RequestHeaders.Set[\"k\"])\n}\n\nfunc TestVisitorCloneDeepCopy(t *testing.T) {\n\trequire := require.New(t)\n\n\tenabled := true\n\tcfg := &XTCPVisitorConfig{\n\t\tVisitorBaseConfig: VisitorBaseConfig{\n\t\t\tName:       \"v1\",\n\t\t\tType:       \"xtcp\",\n\t\t\tEnabled:    &enabled,\n\t\t\tServerName: \"server\",\n\t\t\tBindPort:   7000,\n\t\t\tPlugin: TypedVisitorPluginOptions{\n\t\t\t\tType: VisitorPluginVirtualNet,\n\t\t\t\tVisitorPluginOptions: &VirtualNetVisitorPluginOptions{\n\t\t\t\t\tType:          VisitorPluginVirtualNet,\n\t\t\t\t\tDestinationIP: \"10.0.0.1\",\n\t\t\t\t},\n\t\t\t},\n\t\t},\n\t\tNatTraversal: &NatTraversalConfig{\n\t\t\tDisableAssistedAddrs: true,\n\t\t},\n\t}\n\n\tcloned := cfg.Clone().(*XTCPVisitorConfig)\n\t*cloned.Enabled = false\n\tcloned.NatTraversal.DisableAssistedAddrs = false\n\tvisitorPlugin := cloned.Plugin.VisitorPluginOptions.(*VirtualNetVisitorPluginOptions)\n\tvisitorPlugin.DestinationIP = \"10.0.0.2\"\n\n\trequire.True(*cfg.Enabled)\n\trequire.True(cfg.NatTraversal.DisableAssistedAddrs)\n\torigPlugin := cfg.Plugin.VisitorPluginOptions.(*VirtualNetVisitorPluginOptions)\n\trequire.Equal(\"10.0.0.1\", origPlugin.DestinationIP)\n}\n"
  },
  {
    "path": "pkg/config/v1/common.go",
    "content": "// Copyright 2023 The frp Authors\n//\n// Licensed under the Apache License, Version 2.0 (the \"License\");\n// you may not use this file except in compliance with the License.\n// You may obtain a copy of the License at\n//\n//     http://www.apache.org/licenses/LICENSE-2.0\n//\n// Unless required by applicable law or agreed to in writing, software\n// distributed under the License is distributed on an \"AS IS\" BASIS,\n// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.\n// See the License for the specific language governing permissions and\n// limitations under the License.\n\npackage v1\n\nimport (\n\t\"maps\"\n\n\t\"github.com/fatedier/frp/pkg/util/util\"\n)\n\ntype AuthScope string\n\nconst (\n\tAuthScopeHeartBeats   AuthScope = \"HeartBeats\"\n\tAuthScopeNewWorkConns AuthScope = \"NewWorkConns\"\n)\n\ntype AuthMethod string\n\nconst (\n\tAuthMethodToken AuthMethod = \"token\"\n\tAuthMethodOIDC  AuthMethod = \"oidc\"\n)\n\n// QUIC protocol options\ntype QUICOptions struct {\n\tKeepalivePeriod    int `json:\"keepalivePeriod,omitempty\"`\n\tMaxIdleTimeout     int `json:\"maxIdleTimeout,omitempty\"`\n\tMaxIncomingStreams int `json:\"maxIncomingStreams,omitempty\"`\n}\n\nfunc (c *QUICOptions) Complete() {\n\tc.KeepalivePeriod = util.EmptyOr(c.KeepalivePeriod, 10)\n\tc.MaxIdleTimeout = util.EmptyOr(c.MaxIdleTimeout, 30)\n\tc.MaxIncomingStreams = util.EmptyOr(c.MaxIncomingStreams, 100000)\n}\n\ntype WebServerConfig struct {\n\t// This is the network address to bind on for serving the web interface and API.\n\t// By default, this value is \"127.0.0.1\".\n\tAddr string `json:\"addr,omitempty\"`\n\t// Port specifies the port for the web server to listen on. If this\n\t// value is 0, the admin server will not be started.\n\tPort int `json:\"port,omitempty\"`\n\t// User specifies the username that the web server will use for login.\n\tUser string `json:\"user,omitempty\"`\n\t// Password specifies the password that the admin server will use for login.\n\tPassword string `json:\"password,omitempty\"`\n\t// AssetsDir specifies the local directory that the admin server will load\n\t// resources from. If this value is \"\", assets will be loaded from the\n\t// bundled executable using embed package.\n\tAssetsDir string `json:\"assetsDir,omitempty\"`\n\t// Enable golang pprof handlers.\n\tPprofEnable bool `json:\"pprofEnable,omitempty\"`\n\t// Enable TLS if TLSConfig is not nil.\n\tTLS *TLSConfig `json:\"tls,omitempty\"`\n}\n\nfunc (c *WebServerConfig) Complete() {\n\tc.Addr = util.EmptyOr(c.Addr, \"127.0.0.1\")\n}\n\ntype TLSConfig struct {\n\t// CertFile specifies the path of the cert file that client will load.\n\tCertFile string `json:\"certFile,omitempty\"`\n\t// KeyFile specifies the path of the secret key file that client will load.\n\tKeyFile string `json:\"keyFile,omitempty\"`\n\t// TrustedCaFile specifies the path of the trusted ca file that will load.\n\tTrustedCaFile string `json:\"trustedCaFile,omitempty\"`\n\t// ServerName specifies the custom server name of tls certificate. By\n\t// default, server name if same to ServerAddr.\n\tServerName string `json:\"serverName,omitempty\"`\n}\n\n// NatTraversalConfig defines configuration options for NAT traversal\ntype NatTraversalConfig struct {\n\t// DisableAssistedAddrs disables the use of local network interfaces\n\t// for assisted connections during NAT traversal. When enabled,\n\t// only STUN-discovered public addresses will be used.\n\tDisableAssistedAddrs bool `json:\"disableAssistedAddrs,omitempty\"`\n}\n\nfunc (c *NatTraversalConfig) Clone() *NatTraversalConfig {\n\tif c == nil {\n\t\treturn nil\n\t}\n\tout := *c\n\treturn &out\n}\n\ntype LogConfig struct {\n\t// This is destination where frp should write the logs.\n\t// If \"console\" is used, logs will be printed to stdout, otherwise,\n\t// logs will be written to the specified file.\n\t// By default, this value is \"console\".\n\tTo string `json:\"to,omitempty\"`\n\t// Level specifies the minimum log level. Valid values are \"trace\",\n\t// \"debug\", \"info\", \"warn\", and \"error\". By default, this value is \"info\".\n\tLevel string `json:\"level,omitempty\"`\n\t// MaxDays specifies the maximum number of days to store log information\n\t// before deletion.\n\tMaxDays int64 `json:\"maxDays\"`\n\t// DisablePrintColor disables log colors when log.to is \"console\".\n\tDisablePrintColor bool `json:\"disablePrintColor,omitempty\"`\n}\n\nfunc (c *LogConfig) Complete() {\n\tc.To = util.EmptyOr(c.To, \"console\")\n\tc.Level = util.EmptyOr(c.Level, \"info\")\n\tc.MaxDays = util.EmptyOr(c.MaxDays, 3)\n}\n\ntype HTTPPluginOptions struct {\n\tName      string   `json:\"name\"`\n\tAddr      string   `json:\"addr\"`\n\tPath      string   `json:\"path\"`\n\tOps       []string `json:\"ops\"`\n\tTLSVerify bool     `json:\"tlsVerify,omitempty\"`\n}\n\ntype HeaderOperations struct {\n\tSet map[string]string `json:\"set,omitempty\"`\n}\n\nfunc (o HeaderOperations) Clone() HeaderOperations {\n\treturn HeaderOperations{\n\t\tSet: maps.Clone(o.Set),\n\t}\n}\n\ntype HTTPHeader struct {\n\tName  string `json:\"name\"`\n\tValue string `json:\"value\"`\n}\n"
  },
  {
    "path": "pkg/config/v1/decode.go",
    "content": "// Copyright 2026 The frp Authors\n//\n// Licensed under the Apache License, Version 2.0 (the \"License\");\n// you may not use this file except in compliance with the License.\n// You may obtain a copy of the License at\n//\n//     http://www.apache.org/licenses/LICENSE-2.0\n//\n// Unless required by applicable law or agreed to in writing, software\n// distributed under the License is distributed on an \"AS IS\" BASIS,\n// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.\n// See the License for the specific language governing permissions and\n// limitations under the License.\n\npackage v1\n\nimport (\n\t\"errors\"\n\t\"fmt\"\n\t\"reflect\"\n\n\t\"github.com/fatedier/frp/pkg/util/jsonx\"\n)\n\ntype DecodeOptions struct {\n\tDisallowUnknownFields bool\n}\n\nfunc decodeJSONWithOptions(b []byte, out any, options DecodeOptions) error {\n\treturn jsonx.UnmarshalWithOptions(b, out, jsonx.DecodeOptions{\n\t\tRejectUnknownMembers: options.DisallowUnknownFields,\n\t})\n}\n\nfunc isJSONNull(b []byte) bool {\n\treturn len(b) == 0 || string(b) == \"null\"\n}\n\ntype typedEnvelope struct {\n\tType   string           `json:\"type\"`\n\tPlugin jsonx.RawMessage `json:\"plugin,omitempty\"`\n}\n\nfunc DecodeProxyConfigurerJSON(b []byte, options DecodeOptions) (ProxyConfigurer, error) {\n\tif isJSONNull(b) {\n\t\treturn nil, errors.New(\"type is required\")\n\t}\n\n\tvar env typedEnvelope\n\tif err := jsonx.Unmarshal(b, &env); err != nil {\n\t\treturn nil, err\n\t}\n\n\tconfigurer := NewProxyConfigurerByType(ProxyType(env.Type))\n\tif configurer == nil {\n\t\treturn nil, fmt.Errorf(\"unknown proxy type: %s\", env.Type)\n\t}\n\tif err := decodeJSONWithOptions(b, configurer, options); err != nil {\n\t\treturn nil, fmt.Errorf(\"unmarshal ProxyConfig error: %v\", err)\n\t}\n\n\tif len(env.Plugin) > 0 && !isJSONNull(env.Plugin) {\n\t\tplugin, err := DecodeClientPluginOptionsJSON(env.Plugin, options)\n\t\tif err != nil {\n\t\t\treturn nil, fmt.Errorf(\"unmarshal proxy plugin error: %v\", err)\n\t\t}\n\t\tconfigurer.GetBaseConfig().Plugin = plugin\n\t}\n\treturn configurer, nil\n}\n\nfunc DecodeVisitorConfigurerJSON(b []byte, options DecodeOptions) (VisitorConfigurer, error) {\n\tif isJSONNull(b) {\n\t\treturn nil, errors.New(\"type is required\")\n\t}\n\n\tvar env typedEnvelope\n\tif err := jsonx.Unmarshal(b, &env); err != nil {\n\t\treturn nil, err\n\t}\n\n\tconfigurer := NewVisitorConfigurerByType(VisitorType(env.Type))\n\tif configurer == nil {\n\t\treturn nil, fmt.Errorf(\"unknown visitor type: %s\", env.Type)\n\t}\n\tif err := decodeJSONWithOptions(b, configurer, options); err != nil {\n\t\treturn nil, fmt.Errorf(\"unmarshal VisitorConfig error: %v\", err)\n\t}\n\n\tif len(env.Plugin) > 0 && !isJSONNull(env.Plugin) {\n\t\tplugin, err := DecodeVisitorPluginOptionsJSON(env.Plugin, options)\n\t\tif err != nil {\n\t\t\treturn nil, fmt.Errorf(\"unmarshal visitor plugin error: %v\", err)\n\t\t}\n\t\tconfigurer.GetBaseConfig().Plugin = plugin\n\t}\n\treturn configurer, nil\n}\n\nfunc DecodeClientPluginOptionsJSON(b []byte, options DecodeOptions) (TypedClientPluginOptions, error) {\n\tif isJSONNull(b) {\n\t\treturn TypedClientPluginOptions{}, nil\n\t}\n\n\tvar env typedEnvelope\n\tif err := jsonx.Unmarshal(b, &env); err != nil {\n\t\treturn TypedClientPluginOptions{}, err\n\t}\n\tif env.Type == \"\" {\n\t\treturn TypedClientPluginOptions{}, errors.New(\"plugin type is empty\")\n\t}\n\n\tv, ok := clientPluginOptionsTypeMap[env.Type]\n\tif !ok {\n\t\treturn TypedClientPluginOptions{}, fmt.Errorf(\"unknown plugin type: %s\", env.Type)\n\t}\n\toptionsStruct := reflect.New(v).Interface().(ClientPluginOptions)\n\tif err := decodeJSONWithOptions(b, optionsStruct, options); err != nil {\n\t\treturn TypedClientPluginOptions{}, fmt.Errorf(\"unmarshal ClientPluginOptions error: %v\", err)\n\t}\n\treturn TypedClientPluginOptions{\n\t\tType:                env.Type,\n\t\tClientPluginOptions: optionsStruct,\n\t}, nil\n}\n\nfunc DecodeVisitorPluginOptionsJSON(b []byte, options DecodeOptions) (TypedVisitorPluginOptions, error) {\n\tif isJSONNull(b) {\n\t\treturn TypedVisitorPluginOptions{}, nil\n\t}\n\n\tvar env typedEnvelope\n\tif err := jsonx.Unmarshal(b, &env); err != nil {\n\t\treturn TypedVisitorPluginOptions{}, err\n\t}\n\tif env.Type == \"\" {\n\t\treturn TypedVisitorPluginOptions{}, errors.New(\"visitor plugin type is empty\")\n\t}\n\n\tv, ok := visitorPluginOptionsTypeMap[env.Type]\n\tif !ok {\n\t\treturn TypedVisitorPluginOptions{}, fmt.Errorf(\"unknown visitor plugin type: %s\", env.Type)\n\t}\n\toptionsStruct := reflect.New(v).Interface().(VisitorPluginOptions)\n\tif err := decodeJSONWithOptions(b, optionsStruct, options); err != nil {\n\t\treturn TypedVisitorPluginOptions{}, fmt.Errorf(\"unmarshal VisitorPluginOptions error: %v\", err)\n\t}\n\treturn TypedVisitorPluginOptions{\n\t\tType:                 env.Type,\n\t\tVisitorPluginOptions: optionsStruct,\n\t}, nil\n}\n\nfunc DecodeClientConfigJSON(b []byte, options DecodeOptions) (ClientConfig, error) {\n\ttype rawClientConfig struct {\n\t\tClientCommonConfig\n\t\tProxies  []jsonx.RawMessage `json:\"proxies,omitempty\"`\n\t\tVisitors []jsonx.RawMessage `json:\"visitors,omitempty\"`\n\t}\n\n\traw := rawClientConfig{}\n\tif err := decodeJSONWithOptions(b, &raw, options); err != nil {\n\t\treturn ClientConfig{}, err\n\t}\n\n\tcfg := ClientConfig{\n\t\tClientCommonConfig: raw.ClientCommonConfig,\n\t\tProxies:            make([]TypedProxyConfig, 0, len(raw.Proxies)),\n\t\tVisitors:           make([]TypedVisitorConfig, 0, len(raw.Visitors)),\n\t}\n\n\tfor i, proxyData := range raw.Proxies {\n\t\tproxyCfg, err := DecodeProxyConfigurerJSON(proxyData, options)\n\t\tif err != nil {\n\t\t\treturn ClientConfig{}, fmt.Errorf(\"decode proxy at index %d: %w\", i, err)\n\t\t}\n\t\tcfg.Proxies = append(cfg.Proxies, TypedProxyConfig{\n\t\t\tType:            proxyCfg.GetBaseConfig().Type,\n\t\t\tProxyConfigurer: proxyCfg,\n\t\t})\n\t}\n\n\tfor i, visitorData := range raw.Visitors {\n\t\tvisitorCfg, err := DecodeVisitorConfigurerJSON(visitorData, options)\n\t\tif err != nil {\n\t\t\treturn ClientConfig{}, fmt.Errorf(\"decode visitor at index %d: %w\", i, err)\n\t\t}\n\t\tcfg.Visitors = append(cfg.Visitors, TypedVisitorConfig{\n\t\t\tType:              visitorCfg.GetBaseConfig().Type,\n\t\t\tVisitorConfigurer: visitorCfg,\n\t\t})\n\t}\n\n\treturn cfg, nil\n}\n"
  },
  {
    "path": "pkg/config/v1/decode_test.go",
    "content": "// Copyright 2026 The frp Authors\n//\n// Licensed under the Apache License, Version 2.0 (the \"License\");\n// you may not use this file except in compliance with the License.\n// You may obtain a copy of the License at\n//\n//     http://www.apache.org/licenses/LICENSE-2.0\n//\n// Unless required by applicable law or agreed to in writing, software\n// distributed under the License is distributed on an \"AS IS\" BASIS,\n// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.\n// See the License for the specific language governing permissions and\n// limitations under the License.\n\npackage v1\n\nimport (\n\t\"testing\"\n\n\t\"github.com/stretchr/testify/require\"\n)\n\nfunc TestDecodeProxyConfigurerJSON_StrictPluginUnknownFields(t *testing.T) {\n\trequire := require.New(t)\n\n\tdata := []byte(`{\n\t\t\"name\":\"p1\",\n\t\t\"type\":\"tcp\",\n\t\t\"localPort\":10080,\n\t\t\"plugin\":{\n\t\t\t\"type\":\"http2https\",\n\t\t\t\"localAddr\":\"127.0.0.1:8080\",\n\t\t\t\"unknownInPlugin\":\"value\"\n\t\t}\n\t}`)\n\n\t_, err := DecodeProxyConfigurerJSON(data, DecodeOptions{DisallowUnknownFields: false})\n\trequire.NoError(err)\n\n\t_, err = DecodeProxyConfigurerJSON(data, DecodeOptions{DisallowUnknownFields: true})\n\trequire.ErrorContains(err, \"unknownInPlugin\")\n}\n\nfunc TestDecodeVisitorConfigurerJSON_StrictPluginUnknownFields(t *testing.T) {\n\trequire := require.New(t)\n\n\tdata := []byte(`{\n\t\t\"name\":\"v1\",\n\t\t\"type\":\"stcp\",\n\t\t\"serverName\":\"server\",\n\t\t\"bindPort\":10081,\n\t\t\"plugin\":{\n\t\t\t\"type\":\"virtual_net\",\n\t\t\t\"destinationIP\":\"10.0.0.1\",\n\t\t\t\"unknownInPlugin\":\"value\"\n\t\t}\n\t}`)\n\n\t_, err := DecodeVisitorConfigurerJSON(data, DecodeOptions{DisallowUnknownFields: false})\n\trequire.NoError(err)\n\n\t_, err = DecodeVisitorConfigurerJSON(data, DecodeOptions{DisallowUnknownFields: true})\n\trequire.ErrorContains(err, \"unknownInPlugin\")\n}\n\nfunc TestDecodeClientConfigJSON_StrictUnknownProxyField(t *testing.T) {\n\trequire := require.New(t)\n\n\tdata := []byte(`{\n\t\t\"serverPort\":7000,\n\t\t\"proxies\":[\n\t\t\t{\n\t\t\t\t\"name\":\"p1\",\n\t\t\t\t\"type\":\"tcp\",\n\t\t\t\t\"localPort\":10080,\n\t\t\t\t\"unknownField\":\"value\"\n\t\t\t}\n\t\t]\n\t}`)\n\n\t_, err := DecodeClientConfigJSON(data, DecodeOptions{DisallowUnknownFields: false})\n\trequire.NoError(err)\n\n\t_, err = DecodeClientConfigJSON(data, DecodeOptions{DisallowUnknownFields: true})\n\trequire.ErrorContains(err, \"unknownField\")\n}\n"
  },
  {
    "path": "pkg/config/v1/proxy.go",
    "content": "// Copyright 2023 The frp Authors\n//\n// Licensed under the Apache License, Version 2.0 (the \"License\");\n// you may not use this file except in compliance with the License.\n// You may obtain a copy of the License at\n//\n//     http://www.apache.org/licenses/LICENSE-2.0\n//\n// Unless required by applicable law or agreed to in writing, software\n// distributed under the License is distributed on an \"AS IS\" BASIS,\n// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.\n// See the License for the specific language governing permissions and\n// limitations under the License.\n\npackage v1\n\nimport (\n\t\"maps\"\n\t\"reflect\"\n\t\"slices\"\n\n\t\"github.com/fatedier/frp/pkg/config/types\"\n\t\"github.com/fatedier/frp/pkg/msg\"\n\t\"github.com/fatedier/frp/pkg/util/jsonx\"\n\t\"github.com/fatedier/frp/pkg/util/util\"\n)\n\ntype ProxyTransport struct {\n\t// UseEncryption controls whether or not communication with the server will\n\t// be encrypted. Encryption is done using the tokens supplied in the server\n\t// and client configuration.\n\tUseEncryption bool `json:\"useEncryption,omitempty\"`\n\t// UseCompression controls whether or not communication with the server\n\t// will be compressed.\n\tUseCompression bool `json:\"useCompression,omitempty\"`\n\t// BandwidthLimit limit the bandwidth\n\t// 0 means no limit\n\tBandwidthLimit types.BandwidthQuantity `json:\"bandwidthLimit,omitempty\"`\n\t// BandwidthLimitMode specifies whether to limit the bandwidth on the\n\t// client or server side. Valid values include \"client\" and \"server\".\n\t// By default, this value is \"client\".\n\tBandwidthLimitMode string `json:\"bandwidthLimitMode,omitempty\"`\n\t// ProxyProtocolVersion specifies which protocol version to use. Valid\n\t// values include \"v1\", \"v2\", and \"\". If the value is \"\", a protocol\n\t// version will be automatically selected. By default, this value is \"\".\n\tProxyProtocolVersion string `json:\"proxyProtocolVersion,omitempty\"`\n}\n\ntype LoadBalancerConfig struct {\n\t// Group specifies which group the is a part of. The server will use\n\t// this information to load balance proxies in the same group. If the value\n\t// is \"\", this will not be in a group.\n\tGroup string `json:\"group\"`\n\t// GroupKey specifies a group key, which should be the same among proxies\n\t// of the same group.\n\tGroupKey string `json:\"groupKey,omitempty\"`\n}\n\ntype ProxyBackend struct {\n\t// LocalIP specifies the IP address or host name of the backend.\n\tLocalIP string `json:\"localIP,omitempty\"`\n\t// LocalPort specifies the port of the backend.\n\tLocalPort int `json:\"localPort,omitempty\"`\n\n\t// Plugin specifies what plugin should be used for handling connections. If this value\n\t// is set, the LocalIP and LocalPort values will be ignored.\n\tPlugin TypedClientPluginOptions `json:\"plugin,omitempty\"`\n}\n\n// HealthCheckConfig configures health checking. This can be useful for load\n// balancing purposes to detect and remove proxies to failing services.\ntype HealthCheckConfig struct {\n\t// Type specifies what protocol to use for health checking.\n\t// Valid values include \"tcp\", \"http\", and \"\". If this value is \"\", health\n\t// checking will not be performed.\n\t//\n\t// If the type is \"tcp\", a connection will be attempted to the target\n\t// server. If a connection cannot be established, the health check fails.\n\t//\n\t// If the type is \"http\", a GET request will be made to the endpoint\n\t// specified by HealthCheckURL. If the response is not a 200, the health\n\t// check fails.\n\tType string `json:\"type\"` // tcp | http\n\t// TimeoutSeconds specifies the number of seconds to wait for a health\n\t// check attempt to connect. If the timeout is reached, this counts as a\n\t// health check failure. By default, this value is 3.\n\tTimeoutSeconds int `json:\"timeoutSeconds,omitempty\"`\n\t// MaxFailed specifies the number of allowed failures before the\n\t// is stopped. By default, this value is 1.\n\tMaxFailed int `json:\"maxFailed,omitempty\"`\n\t// IntervalSeconds specifies the time in seconds between health\n\t// checks. By default, this value is 10.\n\tIntervalSeconds int `json:\"intervalSeconds\"`\n\t// Path specifies the path to send health checks to if the\n\t// health check type is \"http\".\n\tPath string `json:\"path,omitempty\"`\n\t// HTTPHeaders specifies the headers to send with the health request, if\n\t// the health check type is \"http\".\n\tHTTPHeaders []HTTPHeader `json:\"httpHeaders,omitempty\"`\n}\n\nfunc (c HealthCheckConfig) Clone() HealthCheckConfig {\n\tout := c\n\tout.HTTPHeaders = slices.Clone(c.HTTPHeaders)\n\treturn out\n}\n\ntype DomainConfig struct {\n\tCustomDomains []string `json:\"customDomains,omitempty\"`\n\tSubDomain     string   `json:\"subdomain,omitempty\"`\n}\n\nfunc (c DomainConfig) Clone() DomainConfig {\n\tout := c\n\tout.CustomDomains = slices.Clone(c.CustomDomains)\n\treturn out\n}\n\ntype ProxyBaseConfig struct {\n\tName string `json:\"name\"`\n\tType string `json:\"type\"`\n\t// Enabled controls whether this proxy is enabled. nil or true means enabled, false means disabled.\n\t// This allows individual control over each proxy, complementing the global \"start\" field.\n\tEnabled     *bool             `json:\"enabled,omitempty\"`\n\tAnnotations map[string]string `json:\"annotations,omitempty\"`\n\tTransport   ProxyTransport    `json:\"transport,omitempty\"`\n\t// metadata info for each proxy\n\tMetadatas    map[string]string  `json:\"metadatas,omitempty\"`\n\tLoadBalancer LoadBalancerConfig `json:\"loadBalancer,omitempty\"`\n\tHealthCheck  HealthCheckConfig  `json:\"healthCheck,omitempty\"`\n\tProxyBackend\n}\n\nfunc (c ProxyBaseConfig) Clone() ProxyBaseConfig {\n\tout := c\n\tout.Enabled = util.ClonePtr(c.Enabled)\n\tout.Annotations = maps.Clone(c.Annotations)\n\tout.Metadatas = maps.Clone(c.Metadatas)\n\tout.HealthCheck = c.HealthCheck.Clone()\n\tout.ProxyBackend = c.ProxyBackend.Clone()\n\treturn out\n}\n\nfunc (c ProxyBackend) Clone() ProxyBackend {\n\tout := c\n\tout.Plugin = c.Plugin.Clone()\n\treturn out\n}\n\nfunc (c *ProxyBaseConfig) GetBaseConfig() *ProxyBaseConfig {\n\treturn c\n}\n\nfunc (c *ProxyBaseConfig) Complete() {\n\tc.LocalIP = util.EmptyOr(c.LocalIP, \"127.0.0.1\")\n\tc.Transport.BandwidthLimitMode = util.EmptyOr(c.Transport.BandwidthLimitMode, types.BandwidthLimitModeClient)\n\n\tif c.Plugin.ClientPluginOptions != nil {\n\t\tc.Plugin.Complete()\n\t}\n}\n\nfunc (c *ProxyBaseConfig) MarshalToMsg(m *msg.NewProxy) {\n\tm.ProxyName = c.Name\n\tm.ProxyType = c.Type\n\tm.UseEncryption = c.Transport.UseEncryption\n\tm.UseCompression = c.Transport.UseCompression\n\tm.BandwidthLimit = c.Transport.BandwidthLimit.String()\n\t// leave it empty for default value to reduce traffic\n\tif c.Transport.BandwidthLimitMode != \"client\" {\n\t\tm.BandwidthLimitMode = c.Transport.BandwidthLimitMode\n\t}\n\tm.Group = c.LoadBalancer.Group\n\tm.GroupKey = c.LoadBalancer.GroupKey\n\tm.Metas = c.Metadatas\n\tm.Annotations = c.Annotations\n}\n\nfunc (c *ProxyBaseConfig) UnmarshalFromMsg(m *msg.NewProxy) {\n\tc.Name = m.ProxyName\n\tc.Type = m.ProxyType\n\tc.Transport.UseEncryption = m.UseEncryption\n\tc.Transport.UseCompression = m.UseCompression\n\tif m.BandwidthLimit != \"\" {\n\t\tc.Transport.BandwidthLimit, _ = types.NewBandwidthQuantity(m.BandwidthLimit)\n\t}\n\tif m.BandwidthLimitMode != \"\" {\n\t\tc.Transport.BandwidthLimitMode = m.BandwidthLimitMode\n\t}\n\tc.LoadBalancer.Group = m.Group\n\tc.LoadBalancer.GroupKey = m.GroupKey\n\tc.Metadatas = m.Metas\n\tc.Annotations = m.Annotations\n}\n\ntype TypedProxyConfig struct {\n\tType string `json:\"type\"`\n\tProxyConfigurer\n}\n\nfunc (c *TypedProxyConfig) UnmarshalJSON(b []byte) error {\n\tconfigurer, err := DecodeProxyConfigurerJSON(b, DecodeOptions{})\n\tif err != nil {\n\t\treturn err\n\t}\n\n\tc.Type = configurer.GetBaseConfig().Type\n\tc.ProxyConfigurer = configurer\n\treturn nil\n}\n\nfunc (c *TypedProxyConfig) MarshalJSON() ([]byte, error) {\n\treturn jsonx.Marshal(c.ProxyConfigurer)\n}\n\ntype ProxyConfigurer interface {\n\tComplete()\n\tGetBaseConfig() *ProxyBaseConfig\n\tClone() ProxyConfigurer\n\t// MarshalToMsg marshals this config into a msg.NewProxy message. This\n\t// function will be called on the frpc side.\n\tMarshalToMsg(*msg.NewProxy)\n\t// UnmarshalFromMsg unmarshal a msg.NewProxy message into this config.\n\t// This function will be called on the frps side.\n\tUnmarshalFromMsg(*msg.NewProxy)\n}\n\ntype ProxyType string\n\nconst (\n\tProxyTypeTCP    ProxyType = \"tcp\"\n\tProxyTypeUDP    ProxyType = \"udp\"\n\tProxyTypeTCPMUX ProxyType = \"tcpmux\"\n\tProxyTypeHTTP   ProxyType = \"http\"\n\tProxyTypeHTTPS  ProxyType = \"https\"\n\tProxyTypeSTCP   ProxyType = \"stcp\"\n\tProxyTypeXTCP   ProxyType = \"xtcp\"\n\tProxyTypeSUDP   ProxyType = \"sudp\"\n)\n\nvar proxyConfigTypeMap = map[ProxyType]reflect.Type{\n\tProxyTypeTCP:    reflect.TypeFor[TCPProxyConfig](),\n\tProxyTypeUDP:    reflect.TypeFor[UDPProxyConfig](),\n\tProxyTypeHTTP:   reflect.TypeFor[HTTPProxyConfig](),\n\tProxyTypeHTTPS:  reflect.TypeFor[HTTPSProxyConfig](),\n\tProxyTypeTCPMUX: reflect.TypeFor[TCPMuxProxyConfig](),\n\tProxyTypeSTCP:   reflect.TypeFor[STCPProxyConfig](),\n\tProxyTypeXTCP:   reflect.TypeFor[XTCPProxyConfig](),\n\tProxyTypeSUDP:   reflect.TypeFor[SUDPProxyConfig](),\n}\n\nfunc NewProxyConfigurerByType(proxyType ProxyType) ProxyConfigurer {\n\tv, ok := proxyConfigTypeMap[proxyType]\n\tif !ok {\n\t\treturn nil\n\t}\n\tpc := reflect.New(v).Interface().(ProxyConfigurer)\n\tpc.GetBaseConfig().Type = string(proxyType)\n\treturn pc\n}\n\nvar _ ProxyConfigurer = &TCPProxyConfig{}\n\ntype TCPProxyConfig struct {\n\tProxyBaseConfig\n\n\tRemotePort int `json:\"remotePort,omitempty\"`\n}\n\nfunc (c *TCPProxyConfig) MarshalToMsg(m *msg.NewProxy) {\n\tc.ProxyBaseConfig.MarshalToMsg(m)\n\n\tm.RemotePort = c.RemotePort\n}\n\nfunc (c *TCPProxyConfig) UnmarshalFromMsg(m *msg.NewProxy) {\n\tc.ProxyBaseConfig.UnmarshalFromMsg(m)\n\n\tc.RemotePort = m.RemotePort\n}\n\nfunc (c *TCPProxyConfig) Clone() ProxyConfigurer {\n\tout := *c\n\tout.ProxyBaseConfig = c.ProxyBaseConfig.Clone()\n\treturn &out\n}\n\nvar _ ProxyConfigurer = &UDPProxyConfig{}\n\ntype UDPProxyConfig struct {\n\tProxyBaseConfig\n\n\tRemotePort int `json:\"remotePort,omitempty\"`\n}\n\nfunc (c *UDPProxyConfig) MarshalToMsg(m *msg.NewProxy) {\n\tc.ProxyBaseConfig.MarshalToMsg(m)\n\n\tm.RemotePort = c.RemotePort\n}\n\nfunc (c *UDPProxyConfig) UnmarshalFromMsg(m *msg.NewProxy) {\n\tc.ProxyBaseConfig.UnmarshalFromMsg(m)\n\n\tc.RemotePort = m.RemotePort\n}\n\nfunc (c *UDPProxyConfig) Clone() ProxyConfigurer {\n\tout := *c\n\tout.ProxyBaseConfig = c.ProxyBaseConfig.Clone()\n\treturn &out\n}\n\nvar _ ProxyConfigurer = &HTTPProxyConfig{}\n\ntype HTTPProxyConfig struct {\n\tProxyBaseConfig\n\tDomainConfig\n\n\tLocations         []string         `json:\"locations,omitempty\"`\n\tHTTPUser          string           `json:\"httpUser,omitempty\"`\n\tHTTPPassword      string           `json:\"httpPassword,omitempty\"`\n\tHostHeaderRewrite string           `json:\"hostHeaderRewrite,omitempty\"`\n\tRequestHeaders    HeaderOperations `json:\"requestHeaders,omitempty\"`\n\tResponseHeaders   HeaderOperations `json:\"responseHeaders,omitempty\"`\n\tRouteByHTTPUser   string           `json:\"routeByHTTPUser,omitempty\"`\n}\n\nfunc (c *HTTPProxyConfig) MarshalToMsg(m *msg.NewProxy) {\n\tc.ProxyBaseConfig.MarshalToMsg(m)\n\n\tm.CustomDomains = c.CustomDomains\n\tm.SubDomain = c.SubDomain\n\tm.Locations = c.Locations\n\tm.HostHeaderRewrite = c.HostHeaderRewrite\n\tm.HTTPUser = c.HTTPUser\n\tm.HTTPPwd = c.HTTPPassword\n\tm.Headers = c.RequestHeaders.Set\n\tm.ResponseHeaders = c.ResponseHeaders.Set\n\tm.RouteByHTTPUser = c.RouteByHTTPUser\n}\n\nfunc (c *HTTPProxyConfig) UnmarshalFromMsg(m *msg.NewProxy) {\n\tc.ProxyBaseConfig.UnmarshalFromMsg(m)\n\n\tc.CustomDomains = m.CustomDomains\n\tc.SubDomain = m.SubDomain\n\tc.Locations = m.Locations\n\tc.HostHeaderRewrite = m.HostHeaderRewrite\n\tc.HTTPUser = m.HTTPUser\n\tc.HTTPPassword = m.HTTPPwd\n\tc.RequestHeaders.Set = m.Headers\n\tc.ResponseHeaders.Set = m.ResponseHeaders\n\tc.RouteByHTTPUser = m.RouteByHTTPUser\n}\n\nfunc (c *HTTPProxyConfig) Clone() ProxyConfigurer {\n\tout := *c\n\tout.ProxyBaseConfig = c.ProxyBaseConfig.Clone()\n\tout.DomainConfig = c.DomainConfig.Clone()\n\tout.Locations = slices.Clone(c.Locations)\n\tout.RequestHeaders = c.RequestHeaders.Clone()\n\tout.ResponseHeaders = c.ResponseHeaders.Clone()\n\treturn &out\n}\n\nvar _ ProxyConfigurer = &HTTPSProxyConfig{}\n\ntype HTTPSProxyConfig struct {\n\tProxyBaseConfig\n\tDomainConfig\n}\n\nfunc (c *HTTPSProxyConfig) MarshalToMsg(m *msg.NewProxy) {\n\tc.ProxyBaseConfig.MarshalToMsg(m)\n\n\tm.CustomDomains = c.CustomDomains\n\tm.SubDomain = c.SubDomain\n}\n\nfunc (c *HTTPSProxyConfig) UnmarshalFromMsg(m *msg.NewProxy) {\n\tc.ProxyBaseConfig.UnmarshalFromMsg(m)\n\n\tc.CustomDomains = m.CustomDomains\n\tc.SubDomain = m.SubDomain\n}\n\nfunc (c *HTTPSProxyConfig) Clone() ProxyConfigurer {\n\tout := *c\n\tout.ProxyBaseConfig = c.ProxyBaseConfig.Clone()\n\tout.DomainConfig = c.DomainConfig.Clone()\n\treturn &out\n}\n\ntype TCPMultiplexerType string\n\nconst (\n\tTCPMultiplexerHTTPConnect TCPMultiplexerType = \"httpconnect\"\n)\n\nvar _ ProxyConfigurer = &TCPMuxProxyConfig{}\n\ntype TCPMuxProxyConfig struct {\n\tProxyBaseConfig\n\tDomainConfig\n\n\tHTTPUser        string `json:\"httpUser,omitempty\"`\n\tHTTPPassword    string `json:\"httpPassword,omitempty\"`\n\tRouteByHTTPUser string `json:\"routeByHTTPUser,omitempty\"`\n\tMultiplexer     string `json:\"multiplexer,omitempty\"`\n}\n\nfunc (c *TCPMuxProxyConfig) MarshalToMsg(m *msg.NewProxy) {\n\tc.ProxyBaseConfig.MarshalToMsg(m)\n\n\tm.CustomDomains = c.CustomDomains\n\tm.SubDomain = c.SubDomain\n\tm.Multiplexer = c.Multiplexer\n\tm.HTTPUser = c.HTTPUser\n\tm.HTTPPwd = c.HTTPPassword\n\tm.RouteByHTTPUser = c.RouteByHTTPUser\n}\n\nfunc (c *TCPMuxProxyConfig) UnmarshalFromMsg(m *msg.NewProxy) {\n\tc.ProxyBaseConfig.UnmarshalFromMsg(m)\n\n\tc.CustomDomains = m.CustomDomains\n\tc.SubDomain = m.SubDomain\n\tc.Multiplexer = m.Multiplexer\n\tc.HTTPUser = m.HTTPUser\n\tc.HTTPPassword = m.HTTPPwd\n\tc.RouteByHTTPUser = m.RouteByHTTPUser\n}\n\nfunc (c *TCPMuxProxyConfig) Clone() ProxyConfigurer {\n\tout := *c\n\tout.ProxyBaseConfig = c.ProxyBaseConfig.Clone()\n\tout.DomainConfig = c.DomainConfig.Clone()\n\treturn &out\n}\n\nvar _ ProxyConfigurer = &STCPProxyConfig{}\n\ntype STCPProxyConfig struct {\n\tProxyBaseConfig\n\n\tSecretkey  string   `json:\"secretKey,omitempty\"`\n\tAllowUsers []string `json:\"allowUsers,omitempty\"`\n}\n\nfunc (c *STCPProxyConfig) MarshalToMsg(m *msg.NewProxy) {\n\tc.ProxyBaseConfig.MarshalToMsg(m)\n\n\tm.Sk = c.Secretkey\n\tm.AllowUsers = c.AllowUsers\n}\n\nfunc (c *STCPProxyConfig) UnmarshalFromMsg(m *msg.NewProxy) {\n\tc.ProxyBaseConfig.UnmarshalFromMsg(m)\n\n\tc.Secretkey = m.Sk\n\tc.AllowUsers = m.AllowUsers\n}\n\nfunc (c *STCPProxyConfig) Clone() ProxyConfigurer {\n\tout := *c\n\tout.ProxyBaseConfig = c.ProxyBaseConfig.Clone()\n\tout.AllowUsers = slices.Clone(c.AllowUsers)\n\treturn &out\n}\n\nvar _ ProxyConfigurer = &XTCPProxyConfig{}\n\ntype XTCPProxyConfig struct {\n\tProxyBaseConfig\n\n\tSecretkey  string   `json:\"secretKey,omitempty\"`\n\tAllowUsers []string `json:\"allowUsers,omitempty\"`\n\n\t// NatTraversal configuration for NAT traversal\n\tNatTraversal *NatTraversalConfig `json:\"natTraversal,omitempty\"`\n}\n\nfunc (c *XTCPProxyConfig) MarshalToMsg(m *msg.NewProxy) {\n\tc.ProxyBaseConfig.MarshalToMsg(m)\n\n\tm.Sk = c.Secretkey\n\tm.AllowUsers = c.AllowUsers\n}\n\nfunc (c *XTCPProxyConfig) UnmarshalFromMsg(m *msg.NewProxy) {\n\tc.ProxyBaseConfig.UnmarshalFromMsg(m)\n\n\tc.Secretkey = m.Sk\n\tc.AllowUsers = m.AllowUsers\n}\n\nfunc (c *XTCPProxyConfig) Clone() ProxyConfigurer {\n\tout := *c\n\tout.ProxyBaseConfig = c.ProxyBaseConfig.Clone()\n\tout.AllowUsers = slices.Clone(c.AllowUsers)\n\tout.NatTraversal = c.NatTraversal.Clone()\n\treturn &out\n}\n\nvar _ ProxyConfigurer = &SUDPProxyConfig{}\n\ntype SUDPProxyConfig struct {\n\tProxyBaseConfig\n\n\tSecretkey  string   `json:\"secretKey,omitempty\"`\n\tAllowUsers []string `json:\"allowUsers,omitempty\"`\n}\n\nfunc (c *SUDPProxyConfig) MarshalToMsg(m *msg.NewProxy) {\n\tc.ProxyBaseConfig.MarshalToMsg(m)\n\n\tm.Sk = c.Secretkey\n\tm.AllowUsers = c.AllowUsers\n}\n\nfunc (c *SUDPProxyConfig) UnmarshalFromMsg(m *msg.NewProxy) {\n\tc.ProxyBaseConfig.UnmarshalFromMsg(m)\n\n\tc.Secretkey = m.Sk\n\tc.AllowUsers = m.AllowUsers\n}\n\nfunc (c *SUDPProxyConfig) Clone() ProxyConfigurer {\n\tout := *c\n\tout.ProxyBaseConfig = c.ProxyBaseConfig.Clone()\n\tout.AllowUsers = slices.Clone(c.AllowUsers)\n\treturn &out\n}\n"
  },
  {
    "path": "pkg/config/v1/proxy_plugin.go",
    "content": "// Copyright 2023 The frp Authors\n//\n// Licensed under the Apache License, Version 2.0 (the \"License\");\n// you may not use this file except in compliance with the License.\n// You may obtain a copy of the License at\n//\n//     http://www.apache.org/licenses/LICENSE-2.0\n//\n// Unless required by applicable law or agreed to in writing, software\n// distributed under the License is distributed on an \"AS IS\" BASIS,\n// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.\n// See the License for the specific language governing permissions and\n// limitations under the License.\n\npackage v1\n\nimport (\n\t\"reflect\"\n\n\t\"github.com/samber/lo\"\n\n\t\"github.com/fatedier/frp/pkg/util/jsonx\"\n\t\"github.com/fatedier/frp/pkg/util/util\"\n)\n\nconst (\n\tPluginHTTP2HTTPS       = \"http2https\"\n\tPluginHTTPProxy        = \"http_proxy\"\n\tPluginHTTPS2HTTP       = \"https2http\"\n\tPluginHTTPS2HTTPS      = \"https2https\"\n\tPluginHTTP2HTTP        = \"http2http\"\n\tPluginSocks5           = \"socks5\"\n\tPluginStaticFile       = \"static_file\"\n\tPluginUnixDomainSocket = \"unix_domain_socket\"\n\tPluginTLS2Raw          = \"tls2raw\"\n\tPluginVirtualNet       = \"virtual_net\"\n)\n\nvar clientPluginOptionsTypeMap = map[string]reflect.Type{\n\tPluginHTTP2HTTPS:       reflect.TypeFor[HTTP2HTTPSPluginOptions](),\n\tPluginHTTPProxy:        reflect.TypeFor[HTTPProxyPluginOptions](),\n\tPluginHTTPS2HTTP:       reflect.TypeFor[HTTPS2HTTPPluginOptions](),\n\tPluginHTTPS2HTTPS:      reflect.TypeFor[HTTPS2HTTPSPluginOptions](),\n\tPluginHTTP2HTTP:        reflect.TypeFor[HTTP2HTTPPluginOptions](),\n\tPluginSocks5:           reflect.TypeFor[Socks5PluginOptions](),\n\tPluginStaticFile:       reflect.TypeFor[StaticFilePluginOptions](),\n\tPluginUnixDomainSocket: reflect.TypeFor[UnixDomainSocketPluginOptions](),\n\tPluginTLS2Raw:          reflect.TypeFor[TLS2RawPluginOptions](),\n\tPluginVirtualNet:       reflect.TypeFor[VirtualNetPluginOptions](),\n}\n\ntype ClientPluginOptions interface {\n\tComplete()\n\tClone() ClientPluginOptions\n}\n\ntype TypedClientPluginOptions struct {\n\tType string `json:\"type\"`\n\tClientPluginOptions\n}\n\nfunc (c TypedClientPluginOptions) Clone() TypedClientPluginOptions {\n\tout := c\n\tif c.ClientPluginOptions != nil {\n\t\tout.ClientPluginOptions = c.ClientPluginOptions.Clone()\n\t}\n\treturn out\n}\n\nfunc (c *TypedClientPluginOptions) UnmarshalJSON(b []byte) error {\n\tdecoded, err := DecodeClientPluginOptionsJSON(b, DecodeOptions{})\n\tif err != nil {\n\t\treturn err\n\t}\n\t*c = decoded\n\treturn nil\n}\n\nfunc (c *TypedClientPluginOptions) MarshalJSON() ([]byte, error) {\n\treturn jsonx.Marshal(c.ClientPluginOptions)\n}\n\ntype HTTP2HTTPSPluginOptions struct {\n\tType              string           `json:\"type,omitempty\"`\n\tLocalAddr         string           `json:\"localAddr,omitempty\"`\n\tHostHeaderRewrite string           `json:\"hostHeaderRewrite,omitempty\"`\n\tRequestHeaders    HeaderOperations `json:\"requestHeaders,omitempty\"`\n}\n\nfunc (o *HTTP2HTTPSPluginOptions) Complete() {}\n\nfunc (o *HTTP2HTTPSPluginOptions) Clone() ClientPluginOptions {\n\tif o == nil {\n\t\treturn nil\n\t}\n\tout := *o\n\tout.RequestHeaders = o.RequestHeaders.Clone()\n\treturn &out\n}\n\ntype HTTPProxyPluginOptions struct {\n\tType         string `json:\"type,omitempty\"`\n\tHTTPUser     string `json:\"httpUser,omitempty\"`\n\tHTTPPassword string `json:\"httpPassword,omitempty\"`\n}\n\nfunc (o *HTTPProxyPluginOptions) Complete() {}\n\nfunc (o *HTTPProxyPluginOptions) Clone() ClientPluginOptions {\n\tif o == nil {\n\t\treturn nil\n\t}\n\tout := *o\n\treturn &out\n}\n\ntype HTTPS2HTTPPluginOptions struct {\n\tType              string           `json:\"type,omitempty\"`\n\tLocalAddr         string           `json:\"localAddr,omitempty\"`\n\tHostHeaderRewrite string           `json:\"hostHeaderRewrite,omitempty\"`\n\tRequestHeaders    HeaderOperations `json:\"requestHeaders,omitempty\"`\n\tEnableHTTP2       *bool            `json:\"enableHTTP2,omitempty\"`\n\tCrtPath           string           `json:\"crtPath,omitempty\"`\n\tKeyPath           string           `json:\"keyPath,omitempty\"`\n}\n\nfunc (o *HTTPS2HTTPPluginOptions) Complete() {\n\to.EnableHTTP2 = util.EmptyOr(o.EnableHTTP2, lo.ToPtr(true))\n}\n\nfunc (o *HTTPS2HTTPPluginOptions) Clone() ClientPluginOptions {\n\tif o == nil {\n\t\treturn nil\n\t}\n\tout := *o\n\tout.RequestHeaders = o.RequestHeaders.Clone()\n\tout.EnableHTTP2 = util.ClonePtr(o.EnableHTTP2)\n\treturn &out\n}\n\ntype HTTPS2HTTPSPluginOptions struct {\n\tType              string           `json:\"type,omitempty\"`\n\tLocalAddr         string           `json:\"localAddr,omitempty\"`\n\tHostHeaderRewrite string           `json:\"hostHeaderRewrite,omitempty\"`\n\tRequestHeaders    HeaderOperations `json:\"requestHeaders,omitempty\"`\n\tEnableHTTP2       *bool            `json:\"enableHTTP2,omitempty\"`\n\tCrtPath           string           `json:\"crtPath,omitempty\"`\n\tKeyPath           string           `json:\"keyPath,omitempty\"`\n}\n\nfunc (o *HTTPS2HTTPSPluginOptions) Complete() {\n\to.EnableHTTP2 = util.EmptyOr(o.EnableHTTP2, lo.ToPtr(true))\n}\n\nfunc (o *HTTPS2HTTPSPluginOptions) Clone() ClientPluginOptions {\n\tif o == nil {\n\t\treturn nil\n\t}\n\tout := *o\n\tout.RequestHeaders = o.RequestHeaders.Clone()\n\tout.EnableHTTP2 = util.ClonePtr(o.EnableHTTP2)\n\treturn &out\n}\n\ntype HTTP2HTTPPluginOptions struct {\n\tType              string           `json:\"type,omitempty\"`\n\tLocalAddr         string           `json:\"localAddr,omitempty\"`\n\tHostHeaderRewrite string           `json:\"hostHeaderRewrite,omitempty\"`\n\tRequestHeaders    HeaderOperations `json:\"requestHeaders,omitempty\"`\n}\n\nfunc (o *HTTP2HTTPPluginOptions) Complete() {}\n\nfunc (o *HTTP2HTTPPluginOptions) Clone() ClientPluginOptions {\n\tif o == nil {\n\t\treturn nil\n\t}\n\tout := *o\n\tout.RequestHeaders = o.RequestHeaders.Clone()\n\treturn &out\n}\n\ntype Socks5PluginOptions struct {\n\tType     string `json:\"type,omitempty\"`\n\tUsername string `json:\"username,omitempty\"`\n\tPassword string `json:\"password,omitempty\"`\n}\n\nfunc (o *Socks5PluginOptions) Complete() {}\n\nfunc (o *Socks5PluginOptions) Clone() ClientPluginOptions {\n\tif o == nil {\n\t\treturn nil\n\t}\n\tout := *o\n\treturn &out\n}\n\ntype StaticFilePluginOptions struct {\n\tType         string `json:\"type,omitempty\"`\n\tLocalPath    string `json:\"localPath,omitempty\"`\n\tStripPrefix  string `json:\"stripPrefix,omitempty\"`\n\tHTTPUser     string `json:\"httpUser,omitempty\"`\n\tHTTPPassword string `json:\"httpPassword,omitempty\"`\n}\n\nfunc (o *StaticFilePluginOptions) Complete() {}\n\nfunc (o *StaticFilePluginOptions) Clone() ClientPluginOptions {\n\tif o == nil {\n\t\treturn nil\n\t}\n\tout := *o\n\treturn &out\n}\n\ntype UnixDomainSocketPluginOptions struct {\n\tType     string `json:\"type,omitempty\"`\n\tUnixPath string `json:\"unixPath,omitempty\"`\n}\n\nfunc (o *UnixDomainSocketPluginOptions) Complete() {}\n\nfunc (o *UnixDomainSocketPluginOptions) Clone() ClientPluginOptions {\n\tif o == nil {\n\t\treturn nil\n\t}\n\tout := *o\n\treturn &out\n}\n\ntype TLS2RawPluginOptions struct {\n\tType      string `json:\"type,omitempty\"`\n\tLocalAddr string `json:\"localAddr,omitempty\"`\n\tCrtPath   string `json:\"crtPath,omitempty\"`\n\tKeyPath   string `json:\"keyPath,omitempty\"`\n}\n\nfunc (o *TLS2RawPluginOptions) Complete() {}\n\nfunc (o *TLS2RawPluginOptions) Clone() ClientPluginOptions {\n\tif o == nil {\n\t\treturn nil\n\t}\n\tout := *o\n\treturn &out\n}\n\ntype VirtualNetPluginOptions struct {\n\tType string `json:\"type,omitempty\"`\n}\n\nfunc (o *VirtualNetPluginOptions) Complete() {}\n\nfunc (o *VirtualNetPluginOptions) Clone() ClientPluginOptions {\n\tif o == nil {\n\t\treturn nil\n\t}\n\tout := *o\n\treturn &out\n}\n"
  },
  {
    "path": "pkg/config/v1/proxy_test.go",
    "content": "// Copyright 2023 The frp Authors\n//\n// Licensed under the Apache License, Version 2.0 (the \"License\");\n// you may not use this file except in compliance with the License.\n// You may obtain a copy of the License at\n//\n//     http://www.apache.org/licenses/LICENSE-2.0\n//\n// Unless required by applicable law or agreed to in writing, software\n// distributed under the License is distributed on an \"AS IS\" BASIS,\n// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.\n// See the License for the specific language governing permissions and\n// limitations under the License.\n\npackage v1\n\nimport (\n\t\"encoding/json\"\n\t\"testing\"\n\n\t\"github.com/stretchr/testify/require\"\n)\n\nfunc TestUnmarshalTypedProxyConfig(t *testing.T) {\n\trequire := require.New(t)\n\tproxyConfigs := struct {\n\t\tProxies []TypedProxyConfig `json:\"proxies,omitempty\"`\n\t}{}\n\n\tstrs := `{\n\t\t\"proxies\": [\n\t\t\t{\n\t\t\t\t\"type\": \"tcp\",\n\t\t\t\t\"localPort\": 22,\n\t\t\t\t\"remotePort\": 6000\n\t\t\t},\n\t\t\t{\n\t\t\t\t\"type\": \"http\",\n\t\t\t\t\"localPort\": 80,\n\t\t\t\t\"customDomains\": [\"www.example.com\"]\n\t\t\t}\n\t\t]\n\t}`\n\terr := json.Unmarshal([]byte(strs), &proxyConfigs)\n\trequire.NoError(err)\n\n\trequire.IsType(&TCPProxyConfig{}, proxyConfigs.Proxies[0].ProxyConfigurer)\n\trequire.IsType(&HTTPProxyConfig{}, proxyConfigs.Proxies[1].ProxyConfigurer)\n}\n"
  },
  {
    "path": "pkg/config/v1/server.go",
    "content": "// Copyright 2023 The frp Authors\n//\n// Licensed under the Apache License, Version 2.0 (the \"License\");\n// you may not use this file except in compliance with the License.\n// You may obtain a copy of the License at\n//\n//     http://www.apache.org/licenses/LICENSE-2.0\n//\n// Unless required by applicable law or agreed to in writing, software\n// distributed under the License is distributed on an \"AS IS\" BASIS,\n// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.\n// See the License for the specific language governing permissions and\n// limitations under the License.\n\npackage v1\n\nimport (\n\t\"github.com/samber/lo\"\n\n\t\"github.com/fatedier/frp/pkg/config/types\"\n\t\"github.com/fatedier/frp/pkg/util/util\"\n)\n\ntype ServerConfig struct {\n\tAPIMetadata\n\n\tAuth AuthServerConfig `json:\"auth,omitempty\"`\n\t// BindAddr specifies the address that the server binds to. By default,\n\t// this value is \"0.0.0.0\".\n\tBindAddr string `json:\"bindAddr,omitempty\"`\n\t// BindPort specifies the port that the server listens on. By default, this\n\t// value is 7000.\n\tBindPort int `json:\"bindPort,omitempty\"`\n\t// KCPBindPort specifies the KCP port that the server listens on. If this\n\t// value is 0, the server will not listen for KCP connections.\n\tKCPBindPort int `json:\"kcpBindPort,omitempty\"`\n\t// QUICBindPort specifies the QUIC port that the server listens on.\n\t// Set this value to 0 will disable this feature.\n\tQUICBindPort int `json:\"quicBindPort,omitempty\"`\n\t// ProxyBindAddr specifies the address that the proxy binds to. This value\n\t// may be the same as BindAddr.\n\tProxyBindAddr string `json:\"proxyBindAddr,omitempty\"`\n\t// VhostHTTPPort specifies the port that the server listens for HTTP Vhost\n\t// requests. If this value is 0, the server will not listen for HTTP\n\t// requests.\n\tVhostHTTPPort int `json:\"vhostHTTPPort,omitempty\"`\n\t// VhostHTTPTimeout specifies the response header timeout for the Vhost\n\t// HTTP server, in seconds. By default, this value is 60.\n\tVhostHTTPTimeout int64 `json:\"vhostHTTPTimeout,omitempty\"`\n\t// VhostHTTPSPort specifies the port that the server listens for HTTPS\n\t// Vhost requests. If this value is 0, the server will not listen for HTTPS\n\t// requests.\n\tVhostHTTPSPort int `json:\"vhostHTTPSPort,omitempty\"`\n\t// TCPMuxHTTPConnectPort specifies the port that the server listens for TCP\n\t// HTTP CONNECT requests. If the value is 0, the server will not multiplex TCP\n\t// requests on one single port. If it's not - it will listen on this value for\n\t// HTTP CONNECT requests.\n\tTCPMuxHTTPConnectPort int `json:\"tcpmuxHTTPConnectPort,omitempty\"`\n\t// If TCPMuxPassthrough is true, frps won't do any update on traffic.\n\tTCPMuxPassthrough bool `json:\"tcpmuxPassthrough,omitempty\"`\n\t// SubDomainHost specifies the domain that will be attached to sub-domains\n\t// requested by the client when using Vhost proxying. For example, if this\n\t// value is set to \"frps.com\" and the client requested the subdomain\n\t// \"test\", the resulting URL would be \"test.frps.com\".\n\tSubDomainHost string `json:\"subDomainHost,omitempty\"`\n\t// Custom404Page specifies a path to a custom 404 page to display. If this\n\t// value is \"\", a default page will be displayed.\n\tCustom404Page string `json:\"custom404Page,omitempty\"`\n\n\tSSHTunnelGateway SSHTunnelGateway `json:\"sshTunnelGateway,omitempty\"`\n\n\tWebServer WebServerConfig `json:\"webServer,omitempty\"`\n\t// EnablePrometheus will export prometheus metrics on webserver address\n\t// in /metrics api.\n\tEnablePrometheus bool `json:\"enablePrometheus,omitempty\"`\n\n\tLog LogConfig `json:\"log,omitempty\"`\n\n\tTransport ServerTransportConfig `json:\"transport,omitempty\"`\n\n\t// DetailedErrorsToClient defines whether to send the specific error (with\n\t// debug info) to frpc. By default, this value is true.\n\tDetailedErrorsToClient *bool `json:\"detailedErrorsToClient,omitempty\"`\n\t// MaxPortsPerClient specifies the maximum number of ports a single client\n\t// may proxy to. If this value is 0, no limit will be applied.\n\tMaxPortsPerClient int64 `json:\"maxPortsPerClient,omitempty\"`\n\t// UserConnTimeout specifies the maximum time to wait for a work\n\t// connection. By default, this value is 10.\n\tUserConnTimeout int64 `json:\"userConnTimeout,omitempty\"`\n\t// UDPPacketSize specifies the UDP packet size\n\t// By default, this value is 1500\n\tUDPPacketSize int64 `json:\"udpPacketSize,omitempty\"`\n\t// NatHoleAnalysisDataReserveHours specifies the hours to reserve nat hole analysis data.\n\tNatHoleAnalysisDataReserveHours int64 `json:\"natholeAnalysisDataReserveHours,omitempty\"`\n\n\tAllowPorts []types.PortsRange `json:\"allowPorts,omitempty\"`\n\n\tHTTPPlugins []HTTPPluginOptions `json:\"httpPlugins,omitempty\"`\n}\n\nfunc (c *ServerConfig) Complete() error {\n\tif err := c.Auth.Complete(); err != nil {\n\t\treturn err\n\t}\n\tc.Log.Complete()\n\tc.Transport.Complete()\n\tc.WebServer.Complete()\n\tc.SSHTunnelGateway.Complete()\n\n\tc.BindAddr = util.EmptyOr(c.BindAddr, \"0.0.0.0\")\n\tc.BindPort = util.EmptyOr(c.BindPort, 7000)\n\tif c.ProxyBindAddr == \"\" {\n\t\tc.ProxyBindAddr = c.BindAddr\n\t}\n\n\tif c.WebServer.Port > 0 {\n\t\tc.WebServer.Addr = util.EmptyOr(c.WebServer.Addr, \"0.0.0.0\")\n\t}\n\n\tc.VhostHTTPTimeout = util.EmptyOr(c.VhostHTTPTimeout, 60)\n\tc.DetailedErrorsToClient = util.EmptyOr(c.DetailedErrorsToClient, lo.ToPtr(true))\n\tc.UserConnTimeout = util.EmptyOr(c.UserConnTimeout, 10)\n\tc.UDPPacketSize = util.EmptyOr(c.UDPPacketSize, 1500)\n\tc.NatHoleAnalysisDataReserveHours = util.EmptyOr(c.NatHoleAnalysisDataReserveHours, 7*24)\n\treturn nil\n}\n\ntype AuthServerConfig struct {\n\tMethod           AuthMethod           `json:\"method,omitempty\"`\n\tAdditionalScopes []AuthScope          `json:\"additionalScopes,omitempty\"`\n\tToken            string               `json:\"token,omitempty\"`\n\tTokenSource      *ValueSource         `json:\"tokenSource,omitempty\"`\n\tOIDC             AuthOIDCServerConfig `json:\"oidc,omitempty\"`\n}\n\nfunc (c *AuthServerConfig) Complete() error {\n\tc.Method = util.EmptyOr(c.Method, \"token\")\n\treturn nil\n}\n\ntype AuthOIDCServerConfig struct {\n\t// Issuer specifies the issuer to verify OIDC tokens with. This issuer\n\t// will be used to load public keys to verify signature and will be compared\n\t// with the issuer claim in the OIDC token.\n\tIssuer string `json:\"issuer,omitempty\"`\n\t// Audience specifies the audience OIDC tokens should contain when validated.\n\t// If this value is empty, audience (\"client ID\") verification will be skipped.\n\tAudience string `json:\"audience,omitempty\"`\n\t// SkipExpiryCheck specifies whether to skip checking if the OIDC token is\n\t// expired.\n\tSkipExpiryCheck bool `json:\"skipExpiryCheck,omitempty\"`\n\t// SkipIssuerCheck specifies whether to skip checking if the OIDC token's\n\t// issuer claim matches the issuer specified in OidcIssuer.\n\tSkipIssuerCheck bool `json:\"skipIssuerCheck,omitempty\"`\n}\n\ntype ServerTransportConfig struct {\n\t// TCPMux toggles TCP stream multiplexing. This allows multiple requests\n\t// from a client to share a single TCP connection. By default, this value\n\t// is true.\n\t// $HideFromDoc\n\tTCPMux *bool `json:\"tcpMux,omitempty\"`\n\t// TCPMuxKeepaliveInterval specifies the keep alive interval for TCP stream multiplier.\n\t// If TCPMux is true, heartbeat of application layer is unnecessary because it can only rely on heartbeat in TCPMux.\n\tTCPMuxKeepaliveInterval int64 `json:\"tcpMuxKeepaliveInterval,omitempty\"`\n\t// TCPKeepAlive specifies the interval between keep-alive probes for an active network connection between frpc and frps.\n\t// If negative, keep-alive probes are disabled.\n\tTCPKeepAlive int64 `json:\"tcpKeepalive,omitempty\"`\n\t// MaxPoolCount specifies the maximum pool size for each proxy. By default,\n\t// this value is 5.\n\tMaxPoolCount int64 `json:\"maxPoolCount,omitempty\"`\n\t// HeartBeatTimeout specifies the maximum time to wait for a heartbeat\n\t// before terminating the connection. It is not recommended to change this\n\t// value. By default, this value is 90. Set negative value to disable it.\n\tHeartbeatTimeout int64 `json:\"heartbeatTimeout,omitempty\"`\n\t// QUIC options.\n\tQUIC QUICOptions `json:\"quic,omitempty\"`\n\t// TLS specifies TLS settings for the connection from the client.\n\tTLS TLSServerConfig `json:\"tls,omitempty\"`\n}\n\nfunc (c *ServerTransportConfig) Complete() {\n\tc.TCPMux = util.EmptyOr(c.TCPMux, lo.ToPtr(true))\n\tc.TCPMuxKeepaliveInterval = util.EmptyOr(c.TCPMuxKeepaliveInterval, 30)\n\tc.TCPKeepAlive = util.EmptyOr(c.TCPKeepAlive, 7200)\n\tc.MaxPoolCount = util.EmptyOr(c.MaxPoolCount, 5)\n\tif lo.FromPtr(c.TCPMux) {\n\t\t// If TCPMux is enabled, heartbeat of application layer is unnecessary because we can rely on heartbeat in tcpmux.\n\t\tc.HeartbeatTimeout = util.EmptyOr(c.HeartbeatTimeout, -1)\n\t} else {\n\t\tc.HeartbeatTimeout = util.EmptyOr(c.HeartbeatTimeout, 90)\n\t}\n\tc.QUIC.Complete()\n\tif c.TLS.TrustedCaFile != \"\" {\n\t\tc.TLS.Force = true\n\t}\n}\n\ntype TLSServerConfig struct {\n\t// Force specifies whether to only accept TLS-encrypted connections.\n\tForce bool `json:\"force,omitempty\"`\n\n\tTLSConfig\n}\n\ntype SSHTunnelGateway struct {\n\tBindPort              int    `json:\"bindPort,omitempty\"`\n\tPrivateKeyFile        string `json:\"privateKeyFile,omitempty\"`\n\tAutoGenPrivateKeyPath string `json:\"autoGenPrivateKeyPath,omitempty\"`\n\tAuthorizedKeysFile    string `json:\"authorizedKeysFile,omitempty\"`\n}\n\nfunc (c *SSHTunnelGateway) Complete() {\n\tc.AutoGenPrivateKeyPath = util.EmptyOr(c.AutoGenPrivateKeyPath, \"./.autogen_ssh_key\")\n}\n"
  },
  {
    "path": "pkg/config/v1/server_test.go",
    "content": "// Copyright 2023 The frp Authors\n//\n// Licensed under the Apache License, Version 2.0 (the \"License\");\n// you may not use this file except in compliance with the License.\n// You may obtain a copy of the License at\n//\n//     http://www.apache.org/licenses/LICENSE-2.0\n//\n// Unless required by applicable law or agreed to in writing, software\n// distributed under the License is distributed on an \"AS IS\" BASIS,\n// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.\n// See the License for the specific language governing permissions and\n// limitations under the License.\n\npackage v1\n\nimport (\n\t\"testing\"\n\n\t\"github.com/samber/lo\"\n\t\"github.com/stretchr/testify/require\"\n)\n\nfunc TestServerConfigComplete(t *testing.T) {\n\trequire := require.New(t)\n\tc := &ServerConfig{}\n\terr := c.Complete()\n\trequire.NoError(err)\n\n\trequire.EqualValues(\"token\", c.Auth.Method)\n\trequire.Equal(true, lo.FromPtr(c.Transport.TCPMux))\n\trequire.Equal(true, lo.FromPtr(c.DetailedErrorsToClient))\n}\n\nfunc TestAuthServerConfig_Complete(t *testing.T) {\n\trequire := require.New(t)\n\tcfg := &AuthServerConfig{}\n\terr := cfg.Complete()\n\trequire.NoError(err)\n\trequire.EqualValues(\"token\", cfg.Method)\n}\n"
  },
  {
    "path": "pkg/config/v1/store.go",
    "content": "// Copyright 2026 The frp Authors\n//\n// Licensed under the Apache License, Version 2.0 (the \"License\");\n// you may not use this file except in compliance with the License.\n// You may obtain a copy of the License at\n//\n//     http://www.apache.org/licenses/LICENSE-2.0\n//\n// Unless required by applicable law or agreed to in writing, software\n// distributed under the License is distributed on an \"AS IS\" BASIS,\n// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.\n// See the License for the specific language governing permissions and\n// limitations under the License.\n\npackage v1\n\n// StoreConfig configures the built-in store source.\ntype StoreConfig struct {\n\t// Path is the store file path.\n\tPath string `json:\"path,omitempty\"`\n}\n\n// IsEnabled returns true if the store is configured with a valid path.\nfunc (c *StoreConfig) IsEnabled() bool {\n\treturn c.Path != \"\"\n}\n"
  },
  {
    "path": "pkg/config/v1/validation/client.go",
    "content": "// Copyright 2023 The frp Authors\n//\n// Licensed under the Apache License, Version 2.0 (the \"License\");\n// you may not use this file except in compliance with the License.\n// You may obtain a copy of the License at\n//\n//     http://www.apache.org/licenses/LICENSE-2.0\n//\n// Unless required by applicable law or agreed to in writing, software\n// distributed under the License is distributed on an \"AS IS\" BASIS,\n// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.\n// See the License for the specific language governing permissions and\n// limitations under the License.\n\npackage validation\n\nimport (\n\t\"fmt\"\n\t\"os\"\n\t\"path/filepath\"\n\t\"slices\"\n\n\t\"github.com/samber/lo\"\n\n\tv1 \"github.com/fatedier/frp/pkg/config/v1\"\n\t\"github.com/fatedier/frp/pkg/policy/featuregate\"\n\t\"github.com/fatedier/frp/pkg/policy/security\"\n)\n\nfunc (v *ConfigValidator) ValidateClientCommonConfig(c *v1.ClientCommonConfig) (Warning, error) {\n\tvar (\n\t\twarnings Warning\n\t\terrs     error\n\t)\n\n\tvalidators := []func() (Warning, error){\n\t\tfunc() (Warning, error) { return validateFeatureGates(c) },\n\t\tfunc() (Warning, error) { return v.validateAuthConfig(&c.Auth) },\n\t\tfunc() (Warning, error) { return nil, validateLogConfig(&c.Log) },\n\t\tfunc() (Warning, error) { return nil, validateWebServerConfig(&c.WebServer) },\n\t\tfunc() (Warning, error) { return validateTransportConfig(&c.Transport) },\n\t\tfunc() (Warning, error) { return validateIncludeFiles(c.IncludeConfigFiles) },\n\t}\n\n\tfor _, validator := range validators {\n\t\tw, err := validator()\n\t\twarnings = AppendError(warnings, w)\n\t\terrs = AppendError(errs, err)\n\t}\n\treturn warnings, errs\n}\n\nfunc validateFeatureGates(c *v1.ClientCommonConfig) (Warning, error) {\n\tif c.VirtualNet.Address != \"\" {\n\t\tif !featuregate.Enabled(featuregate.VirtualNet) {\n\t\t\treturn nil, fmt.Errorf(\"VirtualNet feature is not enabled; enable it by setting the appropriate feature gate flag\")\n\t\t}\n\t}\n\treturn nil, nil\n}\n\nfunc (v *ConfigValidator) validateAuthConfig(c *v1.AuthClientConfig) (Warning, error) {\n\tvar errs error\n\tif !slices.Contains(SupportedAuthMethods, c.Method) {\n\t\terrs = AppendError(errs, fmt.Errorf(\"invalid auth method, optional values are %v\", SupportedAuthMethods))\n\t}\n\tif !lo.Every(SupportedAuthAdditionalScopes, c.AdditionalScopes) {\n\t\terrs = AppendError(errs, fmt.Errorf(\"invalid auth additional scopes, optional values are %v\", SupportedAuthAdditionalScopes))\n\t}\n\n\t// Validate token/tokenSource mutual exclusivity\n\tif c.Token != \"\" && c.TokenSource != nil {\n\t\terrs = AppendError(errs, fmt.Errorf(\"cannot specify both auth.token and auth.tokenSource\"))\n\t}\n\n\t// Validate tokenSource if specified\n\tif c.TokenSource != nil {\n\t\tif c.TokenSource.Type == \"exec\" {\n\t\t\tif err := v.ValidateUnsafeFeature(security.TokenSourceExec); err != nil {\n\t\t\t\terrs = AppendError(errs, err)\n\t\t\t}\n\t\t}\n\t\tif err := c.TokenSource.Validate(); err != nil {\n\t\t\terrs = AppendError(errs, fmt.Errorf(\"invalid auth.tokenSource: %v\", err))\n\t\t}\n\t}\n\n\tif err := v.validateOIDCConfig(&c.OIDC); err != nil {\n\t\terrs = AppendError(errs, err)\n\t}\n\tif c.Method == v1.AuthMethodOIDC && c.OIDC.TokenSource == nil {\n\t\tif err := ValidateOIDCClientCredentialsConfig(&c.OIDC); err != nil {\n\t\t\terrs = AppendError(errs, err)\n\t\t}\n\t}\n\treturn nil, errs\n}\n\nfunc (v *ConfigValidator) validateOIDCConfig(c *v1.AuthOIDCClientConfig) error {\n\tif c.TokenSource == nil {\n\t\treturn nil\n\t}\n\tvar errs error\n\t// Validate oidc.tokenSource mutual exclusivity with other fields of oidc\n\tif c.ClientID != \"\" || c.ClientSecret != \"\" || c.Audience != \"\" ||\n\t\tc.Scope != \"\" || c.TokenEndpointURL != \"\" || len(c.AdditionalEndpointParams) > 0 ||\n\t\tc.TrustedCaFile != \"\" || c.InsecureSkipVerify || c.ProxyURL != \"\" {\n\t\terrs = AppendError(errs, fmt.Errorf(\"cannot specify both auth.oidc.tokenSource and any other field of auth.oidc\"))\n\t}\n\tif c.TokenSource.Type == \"exec\" {\n\t\tif err := v.ValidateUnsafeFeature(security.TokenSourceExec); err != nil {\n\t\t\terrs = AppendError(errs, err)\n\t\t}\n\t}\n\tif err := c.TokenSource.Validate(); err != nil {\n\t\terrs = AppendError(errs, fmt.Errorf(\"invalid auth.oidc.tokenSource: %v\", err))\n\t}\n\treturn errs\n}\n\nfunc validateTransportConfig(c *v1.ClientTransportConfig) (Warning, error) {\n\tvar (\n\t\twarnings Warning\n\t\terrs     error\n\t)\n\n\tif c.HeartbeatTimeout > 0 && c.HeartbeatInterval > 0 {\n\t\tif c.HeartbeatTimeout < c.HeartbeatInterval {\n\t\t\terrs = AppendError(errs, fmt.Errorf(\"invalid transport.heartbeatTimeout, heartbeat timeout should not less than heartbeat interval\"))\n\t\t}\n\t}\n\n\tif !lo.FromPtr(c.TLS.Enable) {\n\t\tcheckTLSConfig := func(name string, value string) Warning {\n\t\t\tif value != \"\" {\n\t\t\t\treturn fmt.Errorf(\"%s is invalid when transport.tls.enable is false\", name)\n\t\t\t}\n\t\t\treturn nil\n\t\t}\n\n\t\twarnings = AppendError(warnings, checkTLSConfig(\"transport.tls.certFile\", c.TLS.CertFile))\n\t\twarnings = AppendError(warnings, checkTLSConfig(\"transport.tls.keyFile\", c.TLS.KeyFile))\n\t\twarnings = AppendError(warnings, checkTLSConfig(\"transport.tls.trustedCaFile\", c.TLS.TrustedCaFile))\n\t}\n\n\tif !slices.Contains(SupportedTransportProtocols, c.Protocol) {\n\t\terrs = AppendError(errs, fmt.Errorf(\"invalid transport.protocol, optional values are %v\", SupportedTransportProtocols))\n\t}\n\treturn warnings, errs\n}\n\nfunc validateIncludeFiles(files []string) (Warning, error) {\n\tvar errs error\n\tfor _, f := range files {\n\t\tabsDir, err := filepath.Abs(filepath.Dir(f))\n\t\tif err != nil {\n\t\t\terrs = AppendError(errs, fmt.Errorf(\"include: parse directory of %s failed: %v\", f, err))\n\t\t\tcontinue\n\t\t}\n\t\tif _, err := os.Stat(absDir); os.IsNotExist(err) {\n\t\t\terrs = AppendError(errs, fmt.Errorf(\"include: directory of %s not exist\", f))\n\t\t}\n\t}\n\treturn nil, errs\n}\n\nfunc ValidateAllClientConfig(\n\tc *v1.ClientCommonConfig,\n\tproxyCfgs []v1.ProxyConfigurer,\n\tvisitorCfgs []v1.VisitorConfigurer,\n\tunsafeFeatures *security.UnsafeFeatures,\n) (Warning, error) {\n\tvalidator := NewConfigValidator(unsafeFeatures)\n\tvar warnings Warning\n\tif c != nil {\n\t\twarning, err := validator.ValidateClientCommonConfig(c)\n\t\twarnings = AppendError(warnings, warning)\n\t\tif err != nil {\n\t\t\treturn warnings, err\n\t\t}\n\t}\n\n\tfor _, c := range proxyCfgs {\n\t\tif err := ValidateProxyConfigurerForClient(c); err != nil {\n\t\t\treturn warnings, fmt.Errorf(\"proxy %s: %v\", c.GetBaseConfig().Name, err)\n\t\t}\n\t}\n\n\tfor _, c := range visitorCfgs {\n\t\tif err := ValidateVisitorConfigurer(c); err != nil {\n\t\t\treturn warnings, fmt.Errorf(\"visitor %s: %v\", c.GetBaseConfig().Name, err)\n\t\t}\n\t}\n\treturn warnings, nil\n}\n"
  },
  {
    "path": "pkg/config/v1/validation/common.go",
    "content": "// Copyright 2023 The frp Authors\n//\n// Licensed under the Apache License, Version 2.0 (the \"License\");\n// you may not use this file except in compliance with the License.\n// You may obtain a copy of the License at\n//\n//     http://www.apache.org/licenses/LICENSE-2.0\n//\n// Unless required by applicable law or agreed to in writing, software\n// distributed under the License is distributed on an \"AS IS\" BASIS,\n// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.\n// See the License for the specific language governing permissions and\n// limitations under the License.\n\npackage validation\n\nimport (\n\t\"fmt\"\n\t\"slices\"\n\n\tv1 \"github.com/fatedier/frp/pkg/config/v1\"\n)\n\nfunc validateWebServerConfig(c *v1.WebServerConfig) error {\n\tif c.TLS != nil {\n\t\tif c.TLS.CertFile == \"\" {\n\t\t\treturn fmt.Errorf(\"tls.certFile must be specified when tls is enabled\")\n\t\t}\n\t\tif c.TLS.KeyFile == \"\" {\n\t\t\treturn fmt.Errorf(\"tls.keyFile must be specified when tls is enabled\")\n\t\t}\n\t}\n\n\treturn ValidatePort(c.Port, \"webServer.port\")\n}\n\n// ValidatePort checks that the network port is in range\nfunc ValidatePort(port int, fieldPath string) error {\n\tif 0 <= port && port <= 65535 {\n\t\treturn nil\n\t}\n\treturn fmt.Errorf(\"%s: port number %d must be in the range 0..65535\", fieldPath, port)\n}\n\nfunc validateLogConfig(c *v1.LogConfig) error {\n\tif !slices.Contains(SupportedLogLevels, c.Level) {\n\t\treturn fmt.Errorf(\"invalid log level, optional values are %v\", SupportedLogLevels)\n\t}\n\treturn nil\n}\n"
  },
  {
    "path": "pkg/config/v1/validation/oidc.go",
    "content": "// Copyright 2026 The frp Authors\n//\n// Licensed under the Apache License, Version 2.0 (the \"License\");\n// you may not use this file except in compliance with the License.\n// You may obtain a copy of the License at\n//\n//     http://www.apache.org/licenses/LICENSE-2.0\n//\n// Unless required by applicable law or agreed to in writing, software\n// distributed under the License is distributed on an \"AS IS\" BASIS,\n// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.\n// See the License for the specific language governing permissions and\n// limitations under the License.\n\npackage validation\n\nimport (\n\t\"errors\"\n\t\"net/url\"\n\t\"strings\"\n\n\tv1 \"github.com/fatedier/frp/pkg/config/v1\"\n)\n\nfunc ValidateOIDCClientCredentialsConfig(c *v1.AuthOIDCClientConfig) error {\n\tvar errs []string\n\n\tif c.ClientID == \"\" {\n\t\terrs = append(errs, \"auth.oidc.clientID is required\")\n\t}\n\n\tif c.TokenEndpointURL == \"\" {\n\t\terrs = append(errs, \"auth.oidc.tokenEndpointURL is required\")\n\t} else {\n\t\ttokenURL, err := url.Parse(c.TokenEndpointURL)\n\t\tif err != nil || !tokenURL.IsAbs() || tokenURL.Host == \"\" {\n\t\t\terrs = append(errs, \"auth.oidc.tokenEndpointURL must be an absolute http or https URL\")\n\t\t} else if tokenURL.Scheme != \"http\" && tokenURL.Scheme != \"https\" {\n\t\t\terrs = append(errs, \"auth.oidc.tokenEndpointURL must use http or https\")\n\t\t}\n\t}\n\n\tif _, ok := c.AdditionalEndpointParams[\"scope\"]; ok {\n\t\terrs = append(errs, \"auth.oidc.additionalEndpointParams.scope is not allowed; use auth.oidc.scope instead\")\n\t}\n\n\tif c.Audience != \"\" {\n\t\tif _, ok := c.AdditionalEndpointParams[\"audience\"]; ok {\n\t\t\terrs = append(errs, \"cannot specify both auth.oidc.audience and auth.oidc.additionalEndpointParams.audience\")\n\t\t}\n\t}\n\n\tif len(errs) == 0 {\n\t\treturn nil\n\t}\n\treturn errors.New(strings.Join(errs, \"; \"))\n}\n"
  },
  {
    "path": "pkg/config/v1/validation/oidc_test.go",
    "content": "// Copyright 2026 The frp Authors\n//\n// Licensed under the Apache License, Version 2.0 (the \"License\");\n// you may not use this file except in compliance with the License.\n// You may obtain a copy of the License at\n//\n//     http://www.apache.org/licenses/LICENSE-2.0\n//\n// Unless required by applicable law or agreed to in writing, software\n// distributed under the License is distributed on an \"AS IS\" BASIS,\n// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.\n// See the License for the specific language governing permissions and\n// limitations under the License.\n\npackage validation\n\nimport (\n\t\"net/http\"\n\t\"net/http/httptest\"\n\t\"testing\"\n\n\t\"github.com/stretchr/testify/require\"\n\n\tv1 \"github.com/fatedier/frp/pkg/config/v1\"\n)\n\nfunc TestValidateOIDCClientCredentialsConfig(t *testing.T) {\n\ttokenServer := httptest.NewServer(http.NotFoundHandler())\n\tdefer tokenServer.Close()\n\n\tt.Run(\"valid\", func(t *testing.T) {\n\t\trequire.NoError(t, ValidateOIDCClientCredentialsConfig(&v1.AuthOIDCClientConfig{\n\t\t\tClientID:         \"test-client\",\n\t\t\tTokenEndpointURL: tokenServer.URL,\n\t\t\tAdditionalEndpointParams: map[string]string{\n\t\t\t\t\"resource\": \"api\",\n\t\t\t},\n\t\t}))\n\t})\n\n\tt.Run(\"invalid token endpoint url\", func(t *testing.T) {\n\t\terr := ValidateOIDCClientCredentialsConfig(&v1.AuthOIDCClientConfig{\n\t\t\tClientID:         \"test-client\",\n\t\t\tTokenEndpointURL: \"://bad\",\n\t\t})\n\t\trequire.ErrorContains(t, err, \"auth.oidc.tokenEndpointURL\")\n\t})\n\n\tt.Run(\"missing client id\", func(t *testing.T) {\n\t\terr := ValidateOIDCClientCredentialsConfig(&v1.AuthOIDCClientConfig{\n\t\t\tTokenEndpointURL: tokenServer.URL,\n\t\t})\n\t\trequire.ErrorContains(t, err, \"auth.oidc.clientID is required\")\n\t})\n\n\tt.Run(\"scope endpoint param is not allowed\", func(t *testing.T) {\n\t\terr := ValidateOIDCClientCredentialsConfig(&v1.AuthOIDCClientConfig{\n\t\t\tClientID:         \"test-client\",\n\t\t\tTokenEndpointURL: tokenServer.URL,\n\t\t\tAdditionalEndpointParams: map[string]string{\n\t\t\t\t\"scope\": \"email\",\n\t\t\t},\n\t\t})\n\t\trequire.ErrorContains(t, err, \"auth.oidc.additionalEndpointParams.scope is not allowed; use auth.oidc.scope instead\")\n\t})\n\n\tt.Run(\"audience conflict\", func(t *testing.T) {\n\t\terr := ValidateOIDCClientCredentialsConfig(&v1.AuthOIDCClientConfig{\n\t\t\tClientID:         \"test-client\",\n\t\t\tTokenEndpointURL: tokenServer.URL,\n\t\t\tAudience:         \"api\",\n\t\t\tAdditionalEndpointParams: map[string]string{\n\t\t\t\t\"audience\": \"override\",\n\t\t\t},\n\t\t})\n\t\trequire.ErrorContains(t, err, \"cannot specify both auth.oidc.audience and auth.oidc.additionalEndpointParams.audience\")\n\t})\n}\n"
  },
  {
    "path": "pkg/config/v1/validation/plugin.go",
    "content": "// Copyright 2023 The frp Authors\n//\n// Licensed under the Apache License, Version 2.0 (the \"License\");\n// you may not use this file except in compliance with the License.\n// You may obtain a copy of the License at\n//\n//     http://www.apache.org/licenses/LICENSE-2.0\n//\n// Unless required by applicable law or agreed to in writing, software\n// distributed under the License is distributed on an \"AS IS\" BASIS,\n// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.\n// See the License for the specific language governing permissions and\n// limitations under the License.\n\npackage validation\n\nimport (\n\t\"errors\"\n\n\tv1 \"github.com/fatedier/frp/pkg/config/v1\"\n)\n\nfunc ValidateClientPluginOptions(c v1.ClientPluginOptions) error {\n\tswitch v := c.(type) {\n\tcase *v1.HTTP2HTTPSPluginOptions:\n\t\treturn validateHTTP2HTTPSPluginOptions(v)\n\tcase *v1.HTTPS2HTTPPluginOptions:\n\t\treturn validateHTTPS2HTTPPluginOptions(v)\n\tcase *v1.HTTPS2HTTPSPluginOptions:\n\t\treturn validateHTTPS2HTTPSPluginOptions(v)\n\tcase *v1.StaticFilePluginOptions:\n\t\treturn validateStaticFilePluginOptions(v)\n\tcase *v1.UnixDomainSocketPluginOptions:\n\t\treturn validateUnixDomainSocketPluginOptions(v)\n\tcase *v1.TLS2RawPluginOptions:\n\t\treturn validateTLS2RawPluginOptions(v)\n\t}\n\treturn nil\n}\n\nfunc validateHTTP2HTTPSPluginOptions(c *v1.HTTP2HTTPSPluginOptions) error {\n\tif c.LocalAddr == \"\" {\n\t\treturn errors.New(\"localAddr is required\")\n\t}\n\treturn nil\n}\n\nfunc validateHTTPS2HTTPPluginOptions(c *v1.HTTPS2HTTPPluginOptions) error {\n\tif c.LocalAddr == \"\" {\n\t\treturn errors.New(\"localAddr is required\")\n\t}\n\treturn nil\n}\n\nfunc validateHTTPS2HTTPSPluginOptions(c *v1.HTTPS2HTTPSPluginOptions) error {\n\tif c.LocalAddr == \"\" {\n\t\treturn errors.New(\"localAddr is required\")\n\t}\n\treturn nil\n}\n\nfunc validateStaticFilePluginOptions(c *v1.StaticFilePluginOptions) error {\n\tif c.LocalPath == \"\" {\n\t\treturn errors.New(\"localPath is required\")\n\t}\n\treturn nil\n}\n\nfunc validateUnixDomainSocketPluginOptions(c *v1.UnixDomainSocketPluginOptions) error {\n\tif c.UnixPath == \"\" {\n\t\treturn errors.New(\"unixPath is required\")\n\t}\n\treturn nil\n}\n\nfunc validateTLS2RawPluginOptions(c *v1.TLS2RawPluginOptions) error {\n\tif c.LocalAddr == \"\" {\n\t\treturn errors.New(\"localAddr is required\")\n\t}\n\treturn nil\n}\n"
  },
  {
    "path": "pkg/config/v1/validation/proxy.go",
    "content": "// Copyright 2023 The frp Authors\n//\n// Licensed under the Apache License, Version 2.0 (the \"License\");\n// you may not use this file except in compliance with the License.\n// You may obtain a copy of the License at\n//\n//     http://www.apache.org/licenses/LICENSE-2.0\n//\n// Unless required by applicable law or agreed to in writing, software\n// distributed under the License is distributed on an \"AS IS\" BASIS,\n// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.\n// See the License for the specific language governing permissions and\n// limitations under the License.\n\npackage validation\n\nimport (\n\t\"errors\"\n\t\"fmt\"\n\t\"slices\"\n\t\"strings\"\n\n\t\"k8s.io/apimachinery/pkg/util/validation\"\n\n\tv1 \"github.com/fatedier/frp/pkg/config/v1\"\n)\n\nfunc validateProxyBaseConfigForClient(c *v1.ProxyBaseConfig) error {\n\tif c.Name == \"\" {\n\t\treturn errors.New(\"name should not be empty\")\n\t}\n\n\tif err := ValidateAnnotations(c.Annotations); err != nil {\n\t\treturn err\n\t}\n\tif !slices.Contains([]string{\"\", \"v1\", \"v2\"}, c.Transport.ProxyProtocolVersion) {\n\t\treturn fmt.Errorf(\"not support proxy protocol version: %s\", c.Transport.ProxyProtocolVersion)\n\t}\n\tif !slices.Contains([]string{\"client\", \"server\"}, c.Transport.BandwidthLimitMode) {\n\t\treturn fmt.Errorf(\"bandwidth limit mode should be client or server\")\n\t}\n\n\tif c.Plugin.Type == \"\" {\n\t\tif err := ValidatePort(c.LocalPort, \"localPort\"); err != nil {\n\t\t\treturn fmt.Errorf(\"localPort: %v\", err)\n\t\t}\n\t}\n\n\tif !slices.Contains([]string{\"\", \"tcp\", \"http\"}, c.HealthCheck.Type) {\n\t\treturn fmt.Errorf(\"not support health check type: %s\", c.HealthCheck.Type)\n\t}\n\tif c.HealthCheck.Type != \"\" {\n\t\tif c.HealthCheck.Type == \"http\" &&\n\t\t\tc.HealthCheck.Path == \"\" {\n\t\t\treturn fmt.Errorf(\"health check path should not be empty\")\n\t\t}\n\t}\n\n\tif c.Plugin.Type != \"\" {\n\t\tif err := ValidateClientPluginOptions(c.Plugin.ClientPluginOptions); err != nil {\n\t\t\treturn fmt.Errorf(\"plugin %s: %v\", c.Plugin.Type, err)\n\t\t}\n\t}\n\treturn nil\n}\n\nfunc validateProxyBaseConfigForServer(c *v1.ProxyBaseConfig) error {\n\tif err := ValidateAnnotations(c.Annotations); err != nil {\n\t\treturn err\n\t}\n\treturn nil\n}\n\nfunc validateDomainConfigForClient(c *v1.DomainConfig) error {\n\tif c.SubDomain == \"\" && len(c.CustomDomains) == 0 {\n\t\treturn errors.New(\"subdomain and custom domains should not be both empty\")\n\t}\n\treturn nil\n}\n\nfunc validateDomainConfigForServer(c *v1.DomainConfig, s *v1.ServerConfig) error {\n\tfor _, domain := range c.CustomDomains {\n\t\tif s.SubDomainHost != \"\" && len(strings.Split(s.SubDomainHost, \".\")) < len(strings.Split(domain, \".\")) {\n\t\t\tif strings.HasSuffix(domain, \".\"+s.SubDomainHost) {\n\t\t\t\treturn fmt.Errorf(\"custom domain [%s] should not belong to subdomain host [%s]\", domain, s.SubDomainHost)\n\t\t\t}\n\t\t}\n\t}\n\n\tif c.SubDomain != \"\" {\n\t\tif s.SubDomainHost == \"\" {\n\t\t\treturn errors.New(\"subdomain is not supported because this feature is not enabled in server\")\n\t\t}\n\n\t\tif strings.Contains(c.SubDomain, \".\") || strings.Contains(c.SubDomain, \"*\") {\n\t\t\treturn errors.New(\"'.' and '*' are not supported in subdomain\")\n\t\t}\n\t}\n\treturn nil\n}\n\nfunc ValidateProxyConfigurerForClient(c v1.ProxyConfigurer) error {\n\tbase := c.GetBaseConfig()\n\tif err := validateProxyBaseConfigForClient(base); err != nil {\n\t\treturn err\n\t}\n\n\tswitch v := c.(type) {\n\tcase *v1.TCPProxyConfig:\n\t\treturn validateTCPProxyConfigForClient(v)\n\tcase *v1.UDPProxyConfig:\n\t\treturn validateUDPProxyConfigForClient(v)\n\tcase *v1.TCPMuxProxyConfig:\n\t\treturn validateTCPMuxProxyConfigForClient(v)\n\tcase *v1.HTTPProxyConfig:\n\t\treturn validateHTTPProxyConfigForClient(v)\n\tcase *v1.HTTPSProxyConfig:\n\t\treturn validateHTTPSProxyConfigForClient(v)\n\tcase *v1.STCPProxyConfig:\n\t\treturn validateSTCPProxyConfigForClient(v)\n\tcase *v1.XTCPProxyConfig:\n\t\treturn validateXTCPProxyConfigForClient(v)\n\tcase *v1.SUDPProxyConfig:\n\t\treturn validateSUDPProxyConfigForClient(v)\n\t}\n\treturn errors.New(\"unknown proxy config type\")\n}\n\nfunc validateTCPProxyConfigForClient(c *v1.TCPProxyConfig) error {\n\treturn nil\n}\n\nfunc validateUDPProxyConfigForClient(c *v1.UDPProxyConfig) error {\n\treturn nil\n}\n\nfunc validateTCPMuxProxyConfigForClient(c *v1.TCPMuxProxyConfig) error {\n\tif err := validateDomainConfigForClient(&c.DomainConfig); err != nil {\n\t\treturn err\n\t}\n\n\tif !slices.Contains([]string{string(v1.TCPMultiplexerHTTPConnect)}, c.Multiplexer) {\n\t\treturn fmt.Errorf(\"not support multiplexer: %s\", c.Multiplexer)\n\t}\n\treturn nil\n}\n\nfunc validateHTTPProxyConfigForClient(c *v1.HTTPProxyConfig) error {\n\treturn validateDomainConfigForClient(&c.DomainConfig)\n}\n\nfunc validateHTTPSProxyConfigForClient(c *v1.HTTPSProxyConfig) error {\n\treturn validateDomainConfigForClient(&c.DomainConfig)\n}\n\nfunc validateSTCPProxyConfigForClient(c *v1.STCPProxyConfig) error {\n\treturn nil\n}\n\nfunc validateXTCPProxyConfigForClient(c *v1.XTCPProxyConfig) error {\n\treturn nil\n}\n\nfunc validateSUDPProxyConfigForClient(c *v1.SUDPProxyConfig) error {\n\treturn nil\n}\n\nfunc ValidateProxyConfigurerForServer(c v1.ProxyConfigurer, s *v1.ServerConfig) error {\n\tbase := c.GetBaseConfig()\n\tif err := validateProxyBaseConfigForServer(base); err != nil {\n\t\treturn err\n\t}\n\n\tswitch v := c.(type) {\n\tcase *v1.TCPProxyConfig:\n\t\treturn validateTCPProxyConfigForServer(v, s)\n\tcase *v1.UDPProxyConfig:\n\t\treturn validateUDPProxyConfigForServer(v, s)\n\tcase *v1.TCPMuxProxyConfig:\n\t\treturn validateTCPMuxProxyConfigForServer(v, s)\n\tcase *v1.HTTPProxyConfig:\n\t\treturn validateHTTPProxyConfigForServer(v, s)\n\tcase *v1.HTTPSProxyConfig:\n\t\treturn validateHTTPSProxyConfigForServer(v, s)\n\tcase *v1.STCPProxyConfig:\n\t\treturn validateSTCPProxyConfigForServer(v, s)\n\tcase *v1.XTCPProxyConfig:\n\t\treturn validateXTCPProxyConfigForServer(v, s)\n\tcase *v1.SUDPProxyConfig:\n\t\treturn validateSUDPProxyConfigForServer(v, s)\n\tdefault:\n\t\treturn errors.New(\"unknown proxy config type\")\n\t}\n}\n\nfunc validateTCPProxyConfigForServer(c *v1.TCPProxyConfig, s *v1.ServerConfig) error {\n\treturn nil\n}\n\nfunc validateUDPProxyConfigForServer(c *v1.UDPProxyConfig, s *v1.ServerConfig) error {\n\treturn nil\n}\n\nfunc validateTCPMuxProxyConfigForServer(c *v1.TCPMuxProxyConfig, s *v1.ServerConfig) error {\n\tif c.Multiplexer == string(v1.TCPMultiplexerHTTPConnect) &&\n\t\ts.TCPMuxHTTPConnectPort == 0 {\n\t\treturn fmt.Errorf(\"tcpmux with multiplexer httpconnect not supported because this feature is not enabled in server\")\n\t}\n\n\treturn validateDomainConfigForServer(&c.DomainConfig, s)\n}\n\nfunc validateHTTPProxyConfigForServer(c *v1.HTTPProxyConfig, s *v1.ServerConfig) error {\n\tif s.VhostHTTPPort == 0 {\n\t\treturn fmt.Errorf(\"type [http] not supported when vhost http port is not set\")\n\t}\n\n\treturn validateDomainConfigForServer(&c.DomainConfig, s)\n}\n\nfunc validateHTTPSProxyConfigForServer(c *v1.HTTPSProxyConfig, s *v1.ServerConfig) error {\n\tif s.VhostHTTPSPort == 0 {\n\t\treturn fmt.Errorf(\"type [https] not supported when vhost https port is not set\")\n\t}\n\n\treturn validateDomainConfigForServer(&c.DomainConfig, s)\n}\n\nfunc validateSTCPProxyConfigForServer(c *v1.STCPProxyConfig, s *v1.ServerConfig) error {\n\treturn nil\n}\n\nfunc validateXTCPProxyConfigForServer(c *v1.XTCPProxyConfig, s *v1.ServerConfig) error {\n\treturn nil\n}\n\nfunc validateSUDPProxyConfigForServer(c *v1.SUDPProxyConfig, s *v1.ServerConfig) error {\n\treturn nil\n}\n\n// ValidateAnnotations validates that a set of annotations are correctly defined.\nfunc ValidateAnnotations(annotations map[string]string) error {\n\tif len(annotations) == 0 {\n\t\treturn nil\n\t}\n\n\tvar errs error\n\tfor k := range annotations {\n\t\tfor _, msg := range validation.IsQualifiedName(strings.ToLower(k)) {\n\t\t\terrs = AppendError(errs, fmt.Errorf(\"annotation key %s is invalid: %s\", k, msg))\n\t\t}\n\t}\n\tif err := ValidateAnnotationsSize(annotations); err != nil {\n\t\terrs = AppendError(errs, err)\n\t}\n\treturn errs\n}\n\nconst TotalAnnotationSizeLimitB int = 256 * (1 << 10) // 256 kB\n\nfunc ValidateAnnotationsSize(annotations map[string]string) error {\n\tvar totalSize int64\n\tfor k, v := range annotations {\n\t\ttotalSize += (int64)(len(k)) + (int64)(len(v))\n\t}\n\tif totalSize > (int64)(TotalAnnotationSizeLimitB) {\n\t\treturn fmt.Errorf(\"annotations size %d is larger than limit %d\", totalSize, TotalAnnotationSizeLimitB)\n\t}\n\treturn nil\n}\n"
  },
  {
    "path": "pkg/config/v1/validation/server.go",
    "content": "// Copyright 2023 The frp Authors\n//\n// Licensed under the Apache License, Version 2.0 (the \"License\");\n// you may not use this file except in compliance with the License.\n// You may obtain a copy of the License at\n//\n//     http://www.apache.org/licenses/LICENSE-2.0\n//\n// Unless required by applicable law or agreed to in writing, software\n// distributed under the License is distributed on an \"AS IS\" BASIS,\n// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.\n// See the License for the specific language governing permissions and\n// limitations under the License.\n\npackage validation\n\nimport (\n\t\"fmt\"\n\t\"slices\"\n\n\t\"github.com/samber/lo\"\n\n\tv1 \"github.com/fatedier/frp/pkg/config/v1\"\n\t\"github.com/fatedier/frp/pkg/policy/security\"\n)\n\nfunc (v *ConfigValidator) ValidateServerConfig(c *v1.ServerConfig) (Warning, error) {\n\tvar (\n\t\twarnings Warning\n\t\terrs     error\n\t)\n\tif !slices.Contains(SupportedAuthMethods, c.Auth.Method) {\n\t\terrs = AppendError(errs, fmt.Errorf(\"invalid auth method, optional values are %v\", SupportedAuthMethods))\n\t}\n\tif !lo.Every(SupportedAuthAdditionalScopes, c.Auth.AdditionalScopes) {\n\t\terrs = AppendError(errs, fmt.Errorf(\"invalid auth additional scopes, optional values are %v\", SupportedAuthAdditionalScopes))\n\t}\n\n\t// Validate token/tokenSource mutual exclusivity\n\tif c.Auth.Token != \"\" && c.Auth.TokenSource != nil {\n\t\terrs = AppendError(errs, fmt.Errorf(\"cannot specify both auth.token and auth.tokenSource\"))\n\t}\n\n\t// Validate tokenSource if specified\n\tif c.Auth.TokenSource != nil {\n\t\tif c.Auth.TokenSource.Type == \"exec\" {\n\t\t\tif err := v.ValidateUnsafeFeature(security.TokenSourceExec); err != nil {\n\t\t\t\terrs = AppendError(errs, err)\n\t\t\t}\n\t\t}\n\t\tif err := c.Auth.TokenSource.Validate(); err != nil {\n\t\t\terrs = AppendError(errs, fmt.Errorf(\"invalid auth.tokenSource: %v\", err))\n\t\t}\n\t}\n\n\tif err := validateLogConfig(&c.Log); err != nil {\n\t\terrs = AppendError(errs, err)\n\t}\n\n\tif err := validateWebServerConfig(&c.WebServer); err != nil {\n\t\terrs = AppendError(errs, err)\n\t}\n\n\terrs = AppendError(errs, ValidatePort(c.BindPort, \"bindPort\"))\n\terrs = AppendError(errs, ValidatePort(c.KCPBindPort, \"kcpBindPort\"))\n\terrs = AppendError(errs, ValidatePort(c.QUICBindPort, \"quicBindPort\"))\n\terrs = AppendError(errs, ValidatePort(c.VhostHTTPPort, \"vhostHTTPPort\"))\n\terrs = AppendError(errs, ValidatePort(c.VhostHTTPSPort, \"vhostHTTPSPort\"))\n\terrs = AppendError(errs, ValidatePort(c.TCPMuxHTTPConnectPort, \"tcpMuxHTTPConnectPort\"))\n\n\tfor _, p := range c.HTTPPlugins {\n\t\tif !lo.Every(SupportedHTTPPluginOps, p.Ops) {\n\t\t\terrs = AppendError(errs, fmt.Errorf(\"invalid http plugin ops, optional values are %v\", SupportedHTTPPluginOps))\n\t\t}\n\t}\n\treturn warnings, errs\n}\n"
  },
  {
    "path": "pkg/config/v1/validation/validation.go",
    "content": "// Copyright 2023 The frp Authors\n//\n// Licensed under the Apache License, Version 2.0 (the \"License\");\n// you may not use this file except in compliance with the License.\n// You may obtain a copy of the License at\n//\n//     http://www.apache.org/licenses/LICENSE-2.0\n//\n// Unless required by applicable law or agreed to in writing, software\n// distributed under the License is distributed on an \"AS IS\" BASIS,\n// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.\n// See the License for the specific language governing permissions and\n// limitations under the License.\n\npackage validation\n\nimport (\n\t\"errors\"\n\n\tv1 \"github.com/fatedier/frp/pkg/config/v1\"\n\tsplugin \"github.com/fatedier/frp/pkg/plugin/server\"\n)\n\nvar (\n\tSupportedTransportProtocols = []string{\n\t\t\"tcp\",\n\t\t\"kcp\",\n\t\t\"quic\",\n\t\t\"websocket\",\n\t\t\"wss\",\n\t}\n\n\tSupportedAuthMethods = []v1.AuthMethod{\n\t\t\"token\",\n\t\t\"oidc\",\n\t}\n\n\tSupportedAuthAdditionalScopes = []v1.AuthScope{\n\t\t\"HeartBeats\",\n\t\t\"NewWorkConns\",\n\t}\n\n\tSupportedLogLevels = []string{\n\t\t\"trace\",\n\t\t\"debug\",\n\t\t\"info\",\n\t\t\"warn\",\n\t\t\"error\",\n\t}\n\n\tSupportedHTTPPluginOps = []string{\n\t\tsplugin.OpLogin,\n\t\tsplugin.OpNewProxy,\n\t\tsplugin.OpCloseProxy,\n\t\tsplugin.OpPing,\n\t\tsplugin.OpNewWorkConn,\n\t\tsplugin.OpNewUserConn,\n\t}\n)\n\ntype Warning error\n\nfunc AppendError(err error, errs ...error) error {\n\tif len(errs) == 0 {\n\t\treturn err\n\t}\n\treturn errors.Join(append([]error{err}, errs...)...)\n}\n"
  },
  {
    "path": "pkg/config/v1/validation/validator.go",
    "content": "package validation\n\nimport (\n\t\"fmt\"\n\n\t\"github.com/fatedier/frp/pkg/policy/security\"\n)\n\n// ConfigValidator holds the context dependencies for configuration validation.\ntype ConfigValidator struct {\n\tunsafeFeatures *security.UnsafeFeatures\n}\n\n// NewConfigValidator creates a new ConfigValidator instance.\nfunc NewConfigValidator(unsafeFeatures *security.UnsafeFeatures) *ConfigValidator {\n\treturn &ConfigValidator{\n\t\tunsafeFeatures: unsafeFeatures,\n\t}\n}\n\n// ValidateUnsafeFeature checks if a specific unsafe feature is enabled.\nfunc (v *ConfigValidator) ValidateUnsafeFeature(feature string) error {\n\tif !v.unsafeFeatures.IsEnabled(feature) {\n\t\treturn fmt.Errorf(\"unsafe feature %q is not enabled. \"+\n\t\t\t\"To enable it, ensure it is allowed in the configuration or command line flags\", feature)\n\t}\n\treturn nil\n}\n"
  },
  {
    "path": "pkg/config/v1/validation/visitor.go",
    "content": "// Copyright 2023 The frp Authors\n//\n// Licensed under the Apache License, Version 2.0 (the \"License\");\n// you may not use this file except in compliance with the License.\n// You may obtain a copy of the License at\n//\n//     http://www.apache.org/licenses/LICENSE-2.0\n//\n// Unless required by applicable law or agreed to in writing, software\n// distributed under the License is distributed on an \"AS IS\" BASIS,\n// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.\n// See the License for the specific language governing permissions and\n// limitations under the License.\n\npackage validation\n\nimport (\n\t\"errors\"\n\t\"fmt\"\n\t\"slices\"\n\n\tv1 \"github.com/fatedier/frp/pkg/config/v1\"\n)\n\nfunc ValidateVisitorConfigurer(c v1.VisitorConfigurer) error {\n\tbase := c.GetBaseConfig()\n\tif err := validateVisitorBaseConfig(base); err != nil {\n\t\treturn err\n\t}\n\n\tswitch v := c.(type) {\n\tcase *v1.STCPVisitorConfig:\n\tcase *v1.SUDPVisitorConfig:\n\tcase *v1.XTCPVisitorConfig:\n\t\treturn validateXTCPVisitorConfig(v)\n\tdefault:\n\t\treturn errors.New(\"unknown visitor config type\")\n\t}\n\treturn nil\n}\n\nfunc validateVisitorBaseConfig(c *v1.VisitorBaseConfig) error {\n\tif c.Name == \"\" {\n\t\treturn errors.New(\"name is required\")\n\t}\n\n\tif c.ServerName == \"\" {\n\t\treturn errors.New(\"server name is required\")\n\t}\n\n\tif c.BindPort == 0 {\n\t\treturn errors.New(\"bind port is required\")\n\t}\n\treturn nil\n}\n\nfunc validateXTCPVisitorConfig(c *v1.XTCPVisitorConfig) error {\n\tif !slices.Contains([]string{\"kcp\", \"quic\"}, c.Protocol) {\n\t\treturn fmt.Errorf(\"protocol should be kcp or quic\")\n\t}\n\treturn nil\n}\n"
  },
  {
    "path": "pkg/config/v1/value_source.go",
    "content": "// Copyright 2025 The frp Authors\n//\n// Licensed under the Apache License, Version 2.0 (the \"License\");\n// you may not use this file except in compliance with the License.\n// You may obtain a copy of the License at\n//\n//     http://www.apache.org/licenses/LICENSE-2.0\n//\n// Unless required by applicable law or agreed to in writing, software\n// distributed under the License is distributed on an \"AS IS\" BASIS,\n// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.\n// See the License for the specific language governing permissions and\n// limitations under the License.\n\npackage v1\n\nimport (\n\t\"context\"\n\t\"errors\"\n\t\"fmt\"\n\t\"os\"\n\t\"os/exec\"\n\t\"strings\"\n)\n\n// ValueSource provides a way to dynamically resolve configuration values\n// from various sources like files, environment variables, or external services.\ntype ValueSource struct {\n\tType string      `json:\"type\"`\n\tFile *FileSource `json:\"file,omitempty\"`\n\tExec *ExecSource `json:\"exec,omitempty\"`\n}\n\n// FileSource specifies how to load a value from a file.\ntype FileSource struct {\n\tPath string `json:\"path\"`\n}\n\n// ExecSource specifies how to get a value from another program launched as subprocess.\ntype ExecSource struct {\n\tCommand string       `json:\"command\"`\n\tArgs    []string     `json:\"args,omitempty\"`\n\tEnv     []ExecEnvVar `json:\"env,omitempty\"`\n}\n\ntype ExecEnvVar struct {\n\tName  string `json:\"name\"`\n\tValue string `json:\"value\"`\n}\n\n// Validate validates the ValueSource configuration.\nfunc (v *ValueSource) Validate() error {\n\tif v == nil {\n\t\treturn errors.New(\"valueSource cannot be nil\")\n\t}\n\n\tswitch v.Type {\n\tcase \"file\":\n\t\tif v.File == nil {\n\t\t\treturn errors.New(\"file configuration is required when type is 'file'\")\n\t\t}\n\t\treturn v.File.Validate()\n\tcase \"exec\":\n\t\tif v.Exec == nil {\n\t\t\treturn errors.New(\"exec configuration is required when type is 'exec'\")\n\t\t}\n\t\treturn v.Exec.Validate()\n\tdefault:\n\t\treturn fmt.Errorf(\"unsupported value source type: %s (only 'file' and 'exec' are supported)\", v.Type)\n\t}\n}\n\n// Resolve resolves the value from the configured source.\nfunc (v *ValueSource) Resolve(ctx context.Context) (string, error) {\n\tif err := v.Validate(); err != nil {\n\t\treturn \"\", err\n\t}\n\n\tswitch v.Type {\n\tcase \"file\":\n\t\treturn v.File.Resolve(ctx)\n\tcase \"exec\":\n\t\treturn v.Exec.Resolve(ctx)\n\tdefault:\n\t\treturn \"\", fmt.Errorf(\"unsupported value source type: %s\", v.Type)\n\t}\n}\n\n// Validate validates the FileSource configuration.\nfunc (f *FileSource) Validate() error {\n\tif f == nil {\n\t\treturn errors.New(\"fileSource cannot be nil\")\n\t}\n\n\tif f.Path == \"\" {\n\t\treturn errors.New(\"file path cannot be empty\")\n\t}\n\treturn nil\n}\n\n// Resolve reads and returns the content from the specified file.\nfunc (f *FileSource) Resolve(_ context.Context) (string, error) {\n\tif err := f.Validate(); err != nil {\n\t\treturn \"\", err\n\t}\n\n\tcontent, err := os.ReadFile(f.Path)\n\tif err != nil {\n\t\treturn \"\", fmt.Errorf(\"failed to read file %s: %v\", f.Path, err)\n\t}\n\n\t// Trim whitespace, which is important for file-based tokens\n\treturn strings.TrimSpace(string(content)), nil\n}\n\n// Validate validates the ExecSource configuration.\nfunc (e *ExecSource) Validate() error {\n\tif e == nil {\n\t\treturn errors.New(\"execSource cannot be nil\")\n\t}\n\n\tif e.Command == \"\" {\n\t\treturn errors.New(\"exec command cannot be empty\")\n\t}\n\n\tfor _, env := range e.Env {\n\t\tif env.Name == \"\" {\n\t\t\treturn errors.New(\"exec env name cannot be empty\")\n\t\t}\n\t\tif strings.Contains(env.Name, \"=\") {\n\t\t\treturn errors.New(\"exec env name cannot contain '='\")\n\t\t}\n\t}\n\treturn nil\n}\n\n// Resolve reads and returns the content captured from stdout of launched subprocess.\nfunc (e *ExecSource) Resolve(ctx context.Context) (string, error) {\n\tif err := e.Validate(); err != nil {\n\t\treturn \"\", err\n\t}\n\n\tcmd := exec.CommandContext(ctx, e.Command, e.Args...)\n\tif len(e.Env) != 0 {\n\t\tcmd.Env = os.Environ()\n\t\tfor _, env := range e.Env {\n\t\t\tcmd.Env = append(cmd.Env, env.Name+\"=\"+env.Value)\n\t\t}\n\t}\n\n\tcontent, err := cmd.Output()\n\tif err != nil {\n\t\treturn \"\", fmt.Errorf(\"failed to execute command %v: %v\", e.Command, err)\n\t}\n\n\t// Trim whitespace, which is important for exec-based tokens\n\treturn strings.TrimSpace(string(content)), nil\n}\n"
  },
  {
    "path": "pkg/config/v1/value_source_test.go",
    "content": "// Copyright 2025 The frp Authors\n//\n// Licensed under the Apache License, Version 2.0 (the \"License\");\n// you may not use this file except in compliance with the License.\n// You may obtain a copy of the License at\n//\n//     http://www.apache.org/licenses/LICENSE-2.0\n//\n// Unless required by applicable law or agreed to in writing, software\n// distributed under the License is distributed on an \"AS IS\" BASIS,\n// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.\n// See the License for the specific language governing permissions and\n// limitations under the License.\n\npackage v1\n\nimport (\n\t\"context\"\n\t\"os\"\n\t\"path/filepath\"\n\t\"testing\"\n)\n\nfunc TestValueSource_Validate(t *testing.T) {\n\ttests := []struct {\n\t\tname    string\n\t\tvs      *ValueSource\n\t\twantErr bool\n\t}{\n\t\t{\n\t\t\tname:    \"nil valueSource\",\n\t\t\tvs:      nil,\n\t\t\twantErr: true,\n\t\t},\n\t\t{\n\t\t\tname: \"unsupported type\",\n\t\t\tvs: &ValueSource{\n\t\t\t\tType: \"unsupported\",\n\t\t\t},\n\t\t\twantErr: true,\n\t\t},\n\t\t{\n\t\t\tname: \"file type without file config\",\n\t\t\tvs: &ValueSource{\n\t\t\t\tType: \"file\",\n\t\t\t\tFile: nil,\n\t\t\t},\n\t\t\twantErr: true,\n\t\t},\n\t\t{\n\t\t\tname: \"valid file type with absolute path\",\n\t\t\tvs: &ValueSource{\n\t\t\t\tType: \"file\",\n\t\t\t\tFile: &FileSource{\n\t\t\t\t\tPath: \"/tmp/test\",\n\t\t\t\t},\n\t\t\t},\n\t\t\twantErr: false,\n\t\t},\n\t\t{\n\t\t\tname: \"valid file type with relative path\",\n\t\t\tvs: &ValueSource{\n\t\t\t\tType: \"file\",\n\t\t\t\tFile: &FileSource{\n\t\t\t\t\tPath: \"configs/token\",\n\t\t\t\t},\n\t\t\t},\n\t\t\twantErr: false,\n\t\t},\n\t}\n\n\tfor _, tt := range tests {\n\t\tt.Run(tt.name, func(t *testing.T) {\n\t\t\terr := tt.vs.Validate()\n\t\t\tif (err != nil) != tt.wantErr {\n\t\t\t\tt.Errorf(\"ValueSource.Validate() error = %v, wantErr %v\", err, tt.wantErr)\n\t\t\t}\n\t\t})\n\t}\n}\n\nfunc TestFileSource_Validate(t *testing.T) {\n\ttests := []struct {\n\t\tname    string\n\t\tfs      *FileSource\n\t\twantErr bool\n\t}{\n\t\t{\n\t\t\tname:    \"nil fileSource\",\n\t\t\tfs:      nil,\n\t\t\twantErr: true,\n\t\t},\n\t\t{\n\t\t\tname: \"empty path\",\n\t\t\tfs: &FileSource{\n\t\t\t\tPath: \"\",\n\t\t\t},\n\t\t\twantErr: true,\n\t\t},\n\t\t{\n\t\t\tname: \"relative path (allowed)\",\n\t\t\tfs: &FileSource{\n\t\t\t\tPath: \"relative/path\",\n\t\t\t},\n\t\t\twantErr: false,\n\t\t},\n\t\t{\n\t\t\tname: \"absolute path\",\n\t\t\tfs: &FileSource{\n\t\t\t\tPath: \"/absolute/path\",\n\t\t\t},\n\t\t\twantErr: false,\n\t\t},\n\t}\n\n\tfor _, tt := range tests {\n\t\tt.Run(tt.name, func(t *testing.T) {\n\t\t\terr := tt.fs.Validate()\n\t\t\tif (err != nil) != tt.wantErr {\n\t\t\t\tt.Errorf(\"FileSource.Validate() error = %v, wantErr %v\", err, tt.wantErr)\n\t\t\t}\n\t\t})\n\t}\n}\n\nfunc TestFileSource_Resolve(t *testing.T) {\n\t// Create a temporary file for testing\n\ttmpDir := t.TempDir()\n\ttestFile := filepath.Join(tmpDir, \"test_token\")\n\ttestContent := \"test-token-value\\n\\t \"\n\texpectedContent := \"test-token-value\"\n\n\terr := os.WriteFile(testFile, []byte(testContent), 0o600)\n\tif err != nil {\n\t\tt.Fatalf(\"failed to create test file: %v\", err)\n\t}\n\n\ttests := []struct {\n\t\tname    string\n\t\tfs      *FileSource\n\t\twant    string\n\t\twantErr bool\n\t}{\n\t\t{\n\t\t\tname: \"valid file path\",\n\t\t\tfs: &FileSource{\n\t\t\t\tPath: testFile,\n\t\t\t},\n\t\t\twant:    expectedContent,\n\t\t\twantErr: false,\n\t\t},\n\t\t{\n\t\t\tname: \"non-existent file\",\n\t\t\tfs: &FileSource{\n\t\t\t\tPath: \"/non/existent/file\",\n\t\t\t},\n\t\t\twant:    \"\",\n\t\t\twantErr: true,\n\t\t},\n\t\t{\n\t\t\tname: \"path traversal attempt (should fail validation)\",\n\t\t\tfs: &FileSource{\n\t\t\t\tPath: \"../../../etc/passwd\",\n\t\t\t},\n\t\t\twant:    \"\",\n\t\t\twantErr: true,\n\t\t},\n\t}\n\n\tfor _, tt := range tests {\n\t\tt.Run(tt.name, func(t *testing.T) {\n\t\t\tgot, err := tt.fs.Resolve(context.Background())\n\t\t\tif (err != nil) != tt.wantErr {\n\t\t\t\tt.Errorf(\"FileSource.Resolve() error = %v, wantErr %v\", err, tt.wantErr)\n\t\t\t\treturn\n\t\t\t}\n\t\t\tif got != tt.want {\n\t\t\t\tt.Errorf(\"FileSource.Resolve() = %v, want %v\", got, tt.want)\n\t\t\t}\n\t\t})\n\t}\n}\n\nfunc TestValueSource_Resolve(t *testing.T) {\n\t// Create a temporary file for testing\n\ttmpDir := t.TempDir()\n\ttestFile := filepath.Join(tmpDir, \"test_token\")\n\ttestContent := \"test-token-value\"\n\n\terr := os.WriteFile(testFile, []byte(testContent), 0o600)\n\tif err != nil {\n\t\tt.Fatalf(\"failed to create test file: %v\", err)\n\t}\n\n\ttests := []struct {\n\t\tname    string\n\t\tvs      *ValueSource\n\t\twant    string\n\t\twantErr bool\n\t}{\n\t\t{\n\t\t\tname: \"valid file type\",\n\t\t\tvs: &ValueSource{\n\t\t\t\tType: \"file\",\n\t\t\t\tFile: &FileSource{\n\t\t\t\t\tPath: testFile,\n\t\t\t\t},\n\t\t\t},\n\t\t\twant:    testContent,\n\t\t\twantErr: false,\n\t\t},\n\t\t{\n\t\t\tname: \"unsupported type\",\n\t\t\tvs: &ValueSource{\n\t\t\t\tType: \"unsupported\",\n\t\t\t},\n\t\t\twant:    \"\",\n\t\t\twantErr: true,\n\t\t},\n\t\t{\n\t\t\tname: \"file type with path traversal\",\n\t\t\tvs: &ValueSource{\n\t\t\t\tType: \"file\",\n\t\t\t\tFile: &FileSource{\n\t\t\t\t\tPath: \"../../../etc/passwd\",\n\t\t\t\t},\n\t\t\t},\n\t\t\twant:    \"\",\n\t\t\twantErr: true,\n\t\t},\n\t}\n\n\tctx := context.Background()\n\tfor _, tt := range tests {\n\t\tt.Run(tt.name, func(t *testing.T) {\n\t\t\tgot, err := tt.vs.Resolve(ctx)\n\t\t\tif (err != nil) != tt.wantErr {\n\t\t\t\tt.Errorf(\"ValueSource.Resolve() error = %v, wantErr %v\", err, tt.wantErr)\n\t\t\t\treturn\n\t\t\t}\n\t\t\tif got != tt.want {\n\t\t\t\tt.Errorf(\"ValueSource.Resolve() = %v, want %v\", got, tt.want)\n\t\t\t}\n\t\t})\n\t}\n}\n"
  },
  {
    "path": "pkg/config/v1/visitor.go",
    "content": "// Copyright 2023 The frp Authors\n//\n// Licensed under the Apache License, Version 2.0 (the \"License\");\n// you may not use this file except in compliance with the License.\n// You may obtain a copy of the License at\n//\n//     http://www.apache.org/licenses/LICENSE-2.0\n//\n// Unless required by applicable law or agreed to in writing, software\n// distributed under the License is distributed on an \"AS IS\" BASIS,\n// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.\n// See the License for the specific language governing permissions and\n// limitations under the License.\n\npackage v1\n\nimport (\n\t\"reflect\"\n\n\t\"github.com/fatedier/frp/pkg/util/jsonx\"\n\t\"github.com/fatedier/frp/pkg/util/util\"\n)\n\ntype VisitorTransport struct {\n\tUseEncryption  bool `json:\"useEncryption,omitempty\"`\n\tUseCompression bool `json:\"useCompression,omitempty\"`\n}\n\ntype VisitorBaseConfig struct {\n\tName string `json:\"name\"`\n\tType string `json:\"type\"`\n\t// Enabled controls whether this visitor is enabled. nil or true means enabled, false means disabled.\n\t// This allows individual control over each visitor, complementing the global \"start\" field.\n\tEnabled   *bool            `json:\"enabled,omitempty\"`\n\tTransport VisitorTransport `json:\"transport,omitempty\"`\n\tSecretKey string           `json:\"secretKey,omitempty\"`\n\t// if the server user is not set, it defaults to the current user\n\tServerUser string `json:\"serverUser,omitempty\"`\n\tServerName string `json:\"serverName,omitempty\"`\n\tBindAddr   string `json:\"bindAddr,omitempty\"`\n\t// BindPort is the port that visitor listens on.\n\t// It can be less than 0, it means don't bind to the port and only receive connections redirected from\n\t// other visitors. (This is not supported for SUDP now)\n\tBindPort int `json:\"bindPort,omitempty\"`\n\n\t// Plugin specifies what plugin should be used.\n\tPlugin TypedVisitorPluginOptions `json:\"plugin,omitempty\"`\n}\n\nfunc (c VisitorBaseConfig) Clone() VisitorBaseConfig {\n\tout := c\n\tout.Enabled = util.ClonePtr(c.Enabled)\n\tout.Plugin = c.Plugin.Clone()\n\treturn out\n}\n\nfunc (c *VisitorBaseConfig) GetBaseConfig() *VisitorBaseConfig {\n\treturn c\n}\n\nfunc (c *VisitorBaseConfig) Complete() {\n\tif c.BindAddr == \"\" {\n\t\tc.BindAddr = \"127.0.0.1\"\n\t}\n}\n\ntype VisitorConfigurer interface {\n\tComplete()\n\tGetBaseConfig() *VisitorBaseConfig\n\tClone() VisitorConfigurer\n}\n\ntype VisitorType string\n\nconst (\n\tVisitorTypeSTCP VisitorType = \"stcp\"\n\tVisitorTypeXTCP VisitorType = \"xtcp\"\n\tVisitorTypeSUDP VisitorType = \"sudp\"\n)\n\nvar visitorConfigTypeMap = map[VisitorType]reflect.Type{\n\tVisitorTypeSTCP: reflect.TypeFor[STCPVisitorConfig](),\n\tVisitorTypeXTCP: reflect.TypeFor[XTCPVisitorConfig](),\n\tVisitorTypeSUDP: reflect.TypeFor[SUDPVisitorConfig](),\n}\n\ntype TypedVisitorConfig struct {\n\tType string `json:\"type\"`\n\tVisitorConfigurer\n}\n\nfunc (c *TypedVisitorConfig) UnmarshalJSON(b []byte) error {\n\tconfigurer, err := DecodeVisitorConfigurerJSON(b, DecodeOptions{})\n\tif err != nil {\n\t\treturn err\n\t}\n\n\tc.Type = configurer.GetBaseConfig().Type\n\tc.VisitorConfigurer = configurer\n\treturn nil\n}\n\nfunc (c *TypedVisitorConfig) MarshalJSON() ([]byte, error) {\n\treturn jsonx.Marshal(c.VisitorConfigurer)\n}\n\nfunc NewVisitorConfigurerByType(t VisitorType) VisitorConfigurer {\n\tv, ok := visitorConfigTypeMap[t]\n\tif !ok {\n\t\treturn nil\n\t}\n\tvc := reflect.New(v).Interface().(VisitorConfigurer)\n\tvc.GetBaseConfig().Type = string(t)\n\treturn vc\n}\n\nvar _ VisitorConfigurer = &STCPVisitorConfig{}\n\ntype STCPVisitorConfig struct {\n\tVisitorBaseConfig\n}\n\nfunc (c *STCPVisitorConfig) Clone() VisitorConfigurer {\n\tout := *c\n\tout.VisitorBaseConfig = c.VisitorBaseConfig.Clone()\n\treturn &out\n}\n\nvar _ VisitorConfigurer = &SUDPVisitorConfig{}\n\ntype SUDPVisitorConfig struct {\n\tVisitorBaseConfig\n}\n\nfunc (c *SUDPVisitorConfig) Clone() VisitorConfigurer {\n\tout := *c\n\tout.VisitorBaseConfig = c.VisitorBaseConfig.Clone()\n\treturn &out\n}\n\nvar _ VisitorConfigurer = &XTCPVisitorConfig{}\n\ntype XTCPVisitorConfig struct {\n\tVisitorBaseConfig\n\n\tProtocol          string `json:\"protocol,omitempty\"`\n\tKeepTunnelOpen    bool   `json:\"keepTunnelOpen,omitempty\"`\n\tMaxRetriesAnHour  int    `json:\"maxRetriesAnHour,omitempty\"`\n\tMinRetryInterval  int    `json:\"minRetryInterval,omitempty\"`\n\tFallbackTo        string `json:\"fallbackTo,omitempty\"`\n\tFallbackTimeoutMs int    `json:\"fallbackTimeoutMs,omitempty\"`\n\n\t// NatTraversal configuration for NAT traversal\n\tNatTraversal *NatTraversalConfig `json:\"natTraversal,omitempty\"`\n}\n\nfunc (c *XTCPVisitorConfig) Complete() {\n\tc.VisitorBaseConfig.Complete()\n\n\tc.Protocol = util.EmptyOr(c.Protocol, \"quic\")\n\tc.MaxRetriesAnHour = util.EmptyOr(c.MaxRetriesAnHour, 8)\n\tc.MinRetryInterval = util.EmptyOr(c.MinRetryInterval, 90)\n\tc.FallbackTimeoutMs = util.EmptyOr(c.FallbackTimeoutMs, 1000)\n}\n\nfunc (c *XTCPVisitorConfig) Clone() VisitorConfigurer {\n\tout := *c\n\tout.VisitorBaseConfig = c.VisitorBaseConfig.Clone()\n\tout.NatTraversal = c.NatTraversal.Clone()\n\treturn &out\n}\n"
  },
  {
    "path": "pkg/config/v1/visitor_plugin.go",
    "content": "// Copyright 2025 The frp Authors\n//\n// Licensed under the Apache License, Version 2.0 (the \"License\");\n// you may not use this file except in compliance with the License.\n// You may obtain a copy of the License at\n//\n//     http://www.apache.org/licenses/LICENSE-2.0\n//\n// Unless required by applicable law or agreed to in writing, software\n// distributed under the License is distributed on an \"AS IS\" BASIS,\n// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.\n// See the License for the specific language governing permissions and\n// limitations under the License.\n\npackage v1\n\nimport (\n\t\"reflect\"\n\n\t\"github.com/fatedier/frp/pkg/util/jsonx\"\n)\n\nconst (\n\tVisitorPluginVirtualNet = \"virtual_net\"\n)\n\nvar visitorPluginOptionsTypeMap = map[string]reflect.Type{\n\tVisitorPluginVirtualNet: reflect.TypeFor[VirtualNetVisitorPluginOptions](),\n}\n\ntype VisitorPluginOptions interface {\n\tComplete()\n\tClone() VisitorPluginOptions\n}\n\ntype TypedVisitorPluginOptions struct {\n\tType string `json:\"type\"`\n\tVisitorPluginOptions\n}\n\nfunc (c TypedVisitorPluginOptions) Clone() TypedVisitorPluginOptions {\n\tout := c\n\tif c.VisitorPluginOptions != nil {\n\t\tout.VisitorPluginOptions = c.VisitorPluginOptions.Clone()\n\t}\n\treturn out\n}\n\nfunc (c *TypedVisitorPluginOptions) UnmarshalJSON(b []byte) error {\n\tdecoded, err := DecodeVisitorPluginOptionsJSON(b, DecodeOptions{})\n\tif err != nil {\n\t\treturn err\n\t}\n\t*c = decoded\n\treturn nil\n}\n\nfunc (c *TypedVisitorPluginOptions) MarshalJSON() ([]byte, error) {\n\treturn jsonx.Marshal(c.VisitorPluginOptions)\n}\n\ntype VirtualNetVisitorPluginOptions struct {\n\tType          string `json:\"type\"`\n\tDestinationIP string `json:\"destinationIP\"`\n}\n\nfunc (o *VirtualNetVisitorPluginOptions) Complete() {}\n\nfunc (o *VirtualNetVisitorPluginOptions) Clone() VisitorPluginOptions {\n\tif o == nil {\n\t\treturn nil\n\t}\n\tout := *o\n\treturn &out\n}\n"
  },
  {
    "path": "pkg/errors/errors.go",
    "content": "// Copyright 2016 fatedier, fatedier@gmail.com\n//\n// Licensed under the Apache License, Version 2.0 (the \"License\");\n// you may not use this file except in compliance with the License.\n// You may obtain a copy of the License at\n//\n//     http://www.apache.org/licenses/LICENSE-2.0\n//\n// Unless required by applicable law or agreed to in writing, software\n// distributed under the License is distributed on an \"AS IS\" BASIS,\n// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.\n// See the License for the specific language governing permissions and\n// limitations under the License.\n\npackage errors\n\nimport (\n\t\"errors\"\n)\n\nvar (\n\tErrMsgType   = errors.New(\"message type error\")\n\tErrCtlClosed = errors.New(\"control is closed\")\n)\n"
  },
  {
    "path": "pkg/metrics/aggregate/server.go",
    "content": "// Copyright 2020 fatedier, fatedier@gmail.com\n//\n// Licensed under the Apache License, Version 2.0 (the \"License\");\n// you may not use this file except in compliance with the License.\n// You may obtain a copy of the License at\n//\n//     http://www.apache.org/licenses/LICENSE-2.0\n//\n// Unless required by applicable law or agreed to in writing, software\n// distributed under the License is distributed on an \"AS IS\" BASIS,\n// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.\n// See the License for the specific language governing permissions and\n// limitations under the License.\n\npackage aggregate\n\nimport (\n\t\"github.com/fatedier/frp/pkg/metrics/mem\"\n\t\"github.com/fatedier/frp/pkg/metrics/prometheus\"\n\t\"github.com/fatedier/frp/server/metrics\"\n)\n\n// EnableMem start to mark metrics to memory monitor system.\nfunc EnableMem() {\n\tsm.Add(mem.ServerMetrics)\n}\n\n// EnablePrometheus start to mark metrics to prometheus.\nfunc EnablePrometheus() {\n\tsm.Add(prometheus.ServerMetrics)\n}\n\nvar sm = &serverMetrics{}\n\nfunc init() {\n\tmetrics.Register(sm)\n}\n\ntype serverMetrics struct {\n\tms []metrics.ServerMetrics\n}\n\nfunc (m *serverMetrics) Add(sm metrics.ServerMetrics) {\n\tm.ms = append(m.ms, sm)\n}\n\nfunc (m *serverMetrics) NewClient() {\n\tfor _, v := range m.ms {\n\t\tv.NewClient()\n\t}\n}\n\nfunc (m *serverMetrics) CloseClient() {\n\tfor _, v := range m.ms {\n\t\tv.CloseClient()\n\t}\n}\n\nfunc (m *serverMetrics) NewProxy(name string, proxyType string, user string, clientID string) {\n\tfor _, v := range m.ms {\n\t\tv.NewProxy(name, proxyType, user, clientID)\n\t}\n}\n\nfunc (m *serverMetrics) CloseProxy(name string, proxyType string) {\n\tfor _, v := range m.ms {\n\t\tv.CloseProxy(name, proxyType)\n\t}\n}\n\nfunc (m *serverMetrics) OpenConnection(name string, proxyType string) {\n\tfor _, v := range m.ms {\n\t\tv.OpenConnection(name, proxyType)\n\t}\n}\n\nfunc (m *serverMetrics) CloseConnection(name string, proxyType string) {\n\tfor _, v := range m.ms {\n\t\tv.CloseConnection(name, proxyType)\n\t}\n}\n\nfunc (m *serverMetrics) AddTrafficIn(name string, proxyType string, trafficBytes int64) {\n\tfor _, v := range m.ms {\n\t\tv.AddTrafficIn(name, proxyType, trafficBytes)\n\t}\n}\n\nfunc (m *serverMetrics) AddTrafficOut(name string, proxyType string, trafficBytes int64) {\n\tfor _, v := range m.ms {\n\t\tv.AddTrafficOut(name, proxyType, trafficBytes)\n\t}\n}\n"
  },
  {
    "path": "pkg/metrics/mem/server.go",
    "content": "// Copyright 2019 fatedier, fatedier@gmail.com\n//\n// Licensed under the Apache License, Version 2.0 (the \"License\");\n// you may not use this file except in compliance with the License.\n// You may obtain a copy of the License at\n//\n//     http://www.apache.org/licenses/LICENSE-2.0\n//\n// Unless required by applicable law or agreed to in writing, software\n// distributed under the License is distributed on an \"AS IS\" BASIS,\n// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.\n// See the License for the specific language governing permissions and\n// limitations under the License.\n\npackage mem\n\nimport (\n\t\"sync\"\n\t\"time\"\n\n\t\"github.com/fatedier/frp/pkg/util/log\"\n\t\"github.com/fatedier/frp/pkg/util/metric\"\n\tserver \"github.com/fatedier/frp/server/metrics\"\n)\n\nvar (\n\tsm = newServerMetrics()\n\n\tServerMetrics  server.ServerMetrics\n\tStatsCollector Collector\n)\n\nfunc init() {\n\tServerMetrics = sm\n\tStatsCollector = sm\n\tsm.run()\n}\n\ntype serverMetrics struct {\n\tinfo *ServerStatistics\n\tmu   sync.Mutex\n}\n\nfunc newServerMetrics() *serverMetrics {\n\treturn &serverMetrics{\n\t\tinfo: &ServerStatistics{\n\t\t\tTotalTrafficIn:  metric.NewDateCounter(ReserveDays),\n\t\t\tTotalTrafficOut: metric.NewDateCounter(ReserveDays),\n\t\t\tCurConns:        metric.NewCounter(),\n\n\t\t\tClientCounts:    metric.NewCounter(),\n\t\t\tProxyTypeCounts: make(map[string]metric.Counter),\n\n\t\t\tProxyStatistics: make(map[string]*ProxyStatistics),\n\t\t},\n\t}\n}\n\nfunc (m *serverMetrics) run() {\n\tgo func() {\n\t\tfor {\n\t\t\ttime.Sleep(12 * time.Hour)\n\t\t\tstart := time.Now()\n\t\t\tcount, total := m.clearUselessInfo(time.Duration(7*24) * time.Hour)\n\t\t\tlog.Debugf(\"clear useless proxy statistics data count %d/%d, cost %v\", count, total, time.Since(start))\n\t\t}\n\t}()\n}\n\nfunc (m *serverMetrics) clearUselessInfo(continuousOfflineDuration time.Duration) (int, int) {\n\tcount := 0\n\ttotal := 0\n\t// To check if there are any proxies that have been closed for more than continuousOfflineDuration and remove them.\n\tm.mu.Lock()\n\tdefer m.mu.Unlock()\n\ttotal = len(m.info.ProxyStatistics)\n\tfor name, data := range m.info.ProxyStatistics {\n\t\tif !data.LastCloseTime.IsZero() &&\n\t\t\tdata.LastStartTime.Before(data.LastCloseTime) &&\n\t\t\ttime.Since(data.LastCloseTime) > continuousOfflineDuration {\n\t\t\tdelete(m.info.ProxyStatistics, name)\n\t\t\tcount++\n\t\t\tlog.Tracef(\"clear proxy [%s]'s statistics data, lastCloseTime: [%s]\", name, data.LastCloseTime.String())\n\t\t}\n\t}\n\treturn count, total\n}\n\nfunc (m *serverMetrics) ClearOfflineProxies() (int, int) {\n\treturn m.clearUselessInfo(0)\n}\n\nfunc (m *serverMetrics) NewClient() {\n\tm.info.ClientCounts.Inc(1)\n}\n\nfunc (m *serverMetrics) CloseClient() {\n\tm.info.ClientCounts.Dec(1)\n}\n\nfunc (m *serverMetrics) NewProxy(name string, proxyType string, user string, clientID string) {\n\tm.mu.Lock()\n\tdefer m.mu.Unlock()\n\tcounter, ok := m.info.ProxyTypeCounts[proxyType]\n\tif !ok {\n\t\tcounter = metric.NewCounter()\n\t}\n\tcounter.Inc(1)\n\tm.info.ProxyTypeCounts[proxyType] = counter\n\n\tproxyStats, ok := m.info.ProxyStatistics[name]\n\tif !ok || proxyStats.ProxyType != proxyType {\n\t\tproxyStats = &ProxyStatistics{\n\t\t\tName:       name,\n\t\t\tProxyType:  proxyType,\n\t\t\tCurConns:   metric.NewCounter(),\n\t\t\tTrafficIn:  metric.NewDateCounter(ReserveDays),\n\t\t\tTrafficOut: metric.NewDateCounter(ReserveDays),\n\t\t}\n\t\tm.info.ProxyStatistics[name] = proxyStats\n\t}\n\tproxyStats.User = user\n\tproxyStats.ClientID = clientID\n\tproxyStats.LastStartTime = time.Now()\n}\n\nfunc (m *serverMetrics) CloseProxy(name string, proxyType string) {\n\tm.mu.Lock()\n\tdefer m.mu.Unlock()\n\tif counter, ok := m.info.ProxyTypeCounts[proxyType]; ok {\n\t\tcounter.Dec(1)\n\t}\n\tif proxyStats, ok := m.info.ProxyStatistics[name]; ok {\n\t\tproxyStats.LastCloseTime = time.Now()\n\t}\n}\n\nfunc (m *serverMetrics) OpenConnection(name string, _ string) {\n\tm.info.CurConns.Inc(1)\n\n\tm.mu.Lock()\n\tdefer m.mu.Unlock()\n\tproxyStats, ok := m.info.ProxyStatistics[name]\n\tif ok {\n\t\tproxyStats.CurConns.Inc(1)\n\t}\n}\n\nfunc (m *serverMetrics) CloseConnection(name string, _ string) {\n\tm.info.CurConns.Dec(1)\n\n\tm.mu.Lock()\n\tdefer m.mu.Unlock()\n\tproxyStats, ok := m.info.ProxyStatistics[name]\n\tif ok {\n\t\tproxyStats.CurConns.Dec(1)\n\t}\n}\n\nfunc (m *serverMetrics) AddTrafficIn(name string, _ string, trafficBytes int64) {\n\tm.info.TotalTrafficIn.Inc(trafficBytes)\n\n\tm.mu.Lock()\n\tdefer m.mu.Unlock()\n\n\tproxyStats, ok := m.info.ProxyStatistics[name]\n\tif ok {\n\t\tproxyStats.TrafficIn.Inc(trafficBytes)\n\t}\n}\n\nfunc (m *serverMetrics) AddTrafficOut(name string, _ string, trafficBytes int64) {\n\tm.info.TotalTrafficOut.Inc(trafficBytes)\n\n\tm.mu.Lock()\n\tdefer m.mu.Unlock()\n\n\tproxyStats, ok := m.info.ProxyStatistics[name]\n\tif ok {\n\t\tproxyStats.TrafficOut.Inc(trafficBytes)\n\t}\n}\n\n// Get stats data api.\n\nfunc (m *serverMetrics) GetServer() *ServerStats {\n\tm.mu.Lock()\n\tdefer m.mu.Unlock()\n\ts := &ServerStats{\n\t\tTotalTrafficIn:  m.info.TotalTrafficIn.TodayCount(),\n\t\tTotalTrafficOut: m.info.TotalTrafficOut.TodayCount(),\n\t\tCurConns:        int64(m.info.CurConns.Count()),\n\t\tClientCounts:    int64(m.info.ClientCounts.Count()),\n\t\tProxyTypeCounts: make(map[string]int64),\n\t}\n\tfor k, v := range m.info.ProxyTypeCounts {\n\t\ts.ProxyTypeCounts[k] = int64(v.Count())\n\t}\n\treturn s\n}\n\nfunc toProxyStats(name string, proxyStats *ProxyStatistics) *ProxyStats {\n\tps := &ProxyStats{\n\t\tName:            name,\n\t\tType:            proxyStats.ProxyType,\n\t\tUser:            proxyStats.User,\n\t\tClientID:        proxyStats.ClientID,\n\t\tTodayTrafficIn:  proxyStats.TrafficIn.TodayCount(),\n\t\tTodayTrafficOut: proxyStats.TrafficOut.TodayCount(),\n\t\tCurConns:        int64(proxyStats.CurConns.Count()),\n\t}\n\tif !proxyStats.LastStartTime.IsZero() {\n\t\tps.LastStartTime = proxyStats.LastStartTime.Format(\"01-02 15:04:05\")\n\t}\n\tif !proxyStats.LastCloseTime.IsZero() {\n\t\tps.LastCloseTime = proxyStats.LastCloseTime.Format(\"01-02 15:04:05\")\n\t}\n\treturn ps\n}\n\nfunc (m *serverMetrics) GetProxiesByType(proxyType string) []*ProxyStats {\n\tres := make([]*ProxyStats, 0)\n\tm.mu.Lock()\n\tdefer m.mu.Unlock()\n\n\tfor name, proxyStats := range m.info.ProxyStatistics {\n\t\tif proxyStats.ProxyType != proxyType {\n\t\t\tcontinue\n\t\t}\n\t\tres = append(res, toProxyStats(name, proxyStats))\n\t}\n\treturn res\n}\n\nfunc (m *serverMetrics) GetProxiesByTypeAndName(proxyType string, proxyName string) (res *ProxyStats) {\n\tm.mu.Lock()\n\tdefer m.mu.Unlock()\n\n\tproxyStats, ok := m.info.ProxyStatistics[proxyName]\n\tif ok && proxyStats.ProxyType == proxyType {\n\t\tres = toProxyStats(proxyName, proxyStats)\n\t}\n\treturn\n}\n\nfunc (m *serverMetrics) GetProxyByName(proxyName string) (res *ProxyStats) {\n\tm.mu.Lock()\n\tdefer m.mu.Unlock()\n\n\tproxyStats, ok := m.info.ProxyStatistics[proxyName]\n\tif ok {\n\t\tres = toProxyStats(proxyName, proxyStats)\n\t}\n\treturn\n}\n\nfunc (m *serverMetrics) GetProxyTraffic(name string) (res *ProxyTrafficInfo) {\n\tm.mu.Lock()\n\tdefer m.mu.Unlock()\n\n\tproxyStats, ok := m.info.ProxyStatistics[name]\n\tif ok {\n\t\tres = &ProxyTrafficInfo{\n\t\t\tName: name,\n\t\t}\n\t\tres.TrafficIn = proxyStats.TrafficIn.GetLastDaysCount(ReserveDays)\n\t\tres.TrafficOut = proxyStats.TrafficOut.GetLastDaysCount(ReserveDays)\n\t}\n\treturn\n}\n"
  },
  {
    "path": "pkg/metrics/mem/types.go",
    "content": "// Copyright 2017 fatedier, fatedier@gmail.com\n//\n// Licensed under the Apache License, Version 2.0 (the \"License\");\n// you may not use this file except in compliance with the License.\n// You may obtain a copy of the License at\n//\n//     http://www.apache.org/licenses/LICENSE-2.0\n//\n// Unless required by applicable law or agreed to in writing, software\n// distributed under the License is distributed on an \"AS IS\" BASIS,\n// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.\n// See the License for the specific language governing permissions and\n// limitations under the License.\n\npackage mem\n\nimport (\n\t\"time\"\n\n\t\"github.com/fatedier/frp/pkg/util/metric\"\n)\n\nconst (\n\tReserveDays = 7\n)\n\ntype ServerStats struct {\n\tTotalTrafficIn  int64\n\tTotalTrafficOut int64\n\tCurConns        int64\n\tClientCounts    int64\n\tProxyTypeCounts map[string]int64\n}\n\ntype ProxyStats struct {\n\tName            string\n\tType            string\n\tUser            string\n\tClientID        string\n\tTodayTrafficIn  int64\n\tTodayTrafficOut int64\n\tLastStartTime   string\n\tLastCloseTime   string\n\tCurConns        int64\n}\n\ntype ProxyTrafficInfo struct {\n\tName       string\n\tTrafficIn  []int64\n\tTrafficOut []int64\n}\n\ntype ProxyStatistics struct {\n\tName          string\n\tProxyType     string\n\tUser          string\n\tClientID      string\n\tTrafficIn     metric.DateCounter\n\tTrafficOut    metric.DateCounter\n\tCurConns      metric.Counter\n\tLastStartTime time.Time\n\tLastCloseTime time.Time\n}\n\ntype ServerStatistics struct {\n\tTotalTrafficIn  metric.DateCounter\n\tTotalTrafficOut metric.DateCounter\n\tCurConns        metric.Counter\n\n\t// counter for clients\n\tClientCounts metric.Counter\n\n\t// counter for proxy types\n\tProxyTypeCounts map[string]metric.Counter\n\n\t// statistics for different proxies\n\t// key is proxy name\n\tProxyStatistics map[string]*ProxyStatistics\n}\n\ntype Collector interface {\n\tGetServer() *ServerStats\n\tGetProxiesByType(proxyType string) []*ProxyStats\n\tGetProxiesByTypeAndName(proxyType string, proxyName string) *ProxyStats\n\tGetProxyByName(proxyName string) *ProxyStats\n\tGetProxyTraffic(name string) *ProxyTrafficInfo\n\tClearOfflineProxies() (int, int)\n}\n"
  },
  {
    "path": "pkg/metrics/metrics.go",
    "content": "// Copyright 2023 The frp Authors\n//\n// Licensed under the Apache License, Version 2.0 (the \"License\");\n// you may not use this file except in compliance with the License.\n// You may obtain a copy of the License at\n//\n//     http://www.apache.org/licenses/LICENSE-2.0\n//\n// Unless required by applicable law or agreed to in writing, software\n// distributed under the License is distributed on an \"AS IS\" BASIS,\n// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.\n// See the License for the specific language governing permissions and\n// limitations under the License.\n\npackage metrics\n\nimport (\n\t\"github.com/fatedier/frp/pkg/metrics/aggregate\"\n)\n\nvar (\n\tEnableMem        = aggregate.EnableMem\n\tEnablePrometheus = aggregate.EnablePrometheus\n)\n"
  },
  {
    "path": "pkg/metrics/prometheus/server.go",
    "content": "package prometheus\n\nimport (\n\t\"github.com/prometheus/client_golang/prometheus\"\n\n\t\"github.com/fatedier/frp/server/metrics\"\n)\n\nconst (\n\tnamespace       = \"frp\"\n\tserverSubsystem = \"server\"\n)\n\nvar ServerMetrics metrics.ServerMetrics = newServerMetrics()\n\ntype serverMetrics struct {\n\tclientCount        prometheus.Gauge\n\tproxyCount         *prometheus.GaugeVec\n\tproxyCountDetailed *prometheus.GaugeVec\n\tconnectionCount    *prometheus.GaugeVec\n\ttrafficIn          *prometheus.CounterVec\n\ttrafficOut         *prometheus.CounterVec\n}\n\nfunc (m *serverMetrics) NewClient() {\n\tm.clientCount.Inc()\n}\n\nfunc (m *serverMetrics) CloseClient() {\n\tm.clientCount.Dec()\n}\n\nfunc (m *serverMetrics) NewProxy(name string, proxyType string, _ string, _ string) {\n\tm.proxyCount.WithLabelValues(proxyType).Inc()\n\tm.proxyCountDetailed.WithLabelValues(proxyType, name).Inc()\n}\n\nfunc (m *serverMetrics) CloseProxy(name string, proxyType string) {\n\tm.proxyCount.WithLabelValues(proxyType).Dec()\n\tm.proxyCountDetailed.WithLabelValues(proxyType, name).Dec()\n}\n\nfunc (m *serverMetrics) OpenConnection(name string, proxyType string) {\n\tm.connectionCount.WithLabelValues(name, proxyType).Inc()\n}\n\nfunc (m *serverMetrics) CloseConnection(name string, proxyType string) {\n\tm.connectionCount.WithLabelValues(name, proxyType).Dec()\n}\n\nfunc (m *serverMetrics) AddTrafficIn(name string, proxyType string, trafficBytes int64) {\n\tm.trafficIn.WithLabelValues(name, proxyType).Add(float64(trafficBytes))\n}\n\nfunc (m *serverMetrics) AddTrafficOut(name string, proxyType string, trafficBytes int64) {\n\tm.trafficOut.WithLabelValues(name, proxyType).Add(float64(trafficBytes))\n}\n\nfunc newServerMetrics() *serverMetrics {\n\tm := &serverMetrics{\n\t\tclientCount: prometheus.NewGauge(prometheus.GaugeOpts{\n\t\t\tNamespace: namespace,\n\t\t\tSubsystem: serverSubsystem,\n\t\t\tName:      \"client_counts\",\n\t\t\tHelp:      \"The current client counts of frps\",\n\t\t}),\n\t\tproxyCount: prometheus.NewGaugeVec(prometheus.GaugeOpts{\n\t\t\tNamespace: namespace,\n\t\t\tSubsystem: serverSubsystem,\n\t\t\tName:      \"proxy_counts\",\n\t\t\tHelp:      \"The current proxy counts\",\n\t\t}, []string{\"type\"}),\n\t\tproxyCountDetailed: prometheus.NewGaugeVec(prometheus.GaugeOpts{\n\t\t\tNamespace: namespace,\n\t\t\tSubsystem: serverSubsystem,\n\t\t\tName:      \"proxy_counts_detailed\",\n\t\t\tHelp:      \"The current number of proxies grouped by type and name\",\n\t\t}, []string{\"type\", \"name\"}),\n\t\tconnectionCount: prometheus.NewGaugeVec(prometheus.GaugeOpts{\n\t\t\tNamespace: namespace,\n\t\t\tSubsystem: serverSubsystem,\n\t\t\tName:      \"connection_counts\",\n\t\t\tHelp:      \"The current connection counts\",\n\t\t}, []string{\"name\", \"type\"}),\n\t\ttrafficIn: prometheus.NewCounterVec(prometheus.CounterOpts{\n\t\t\tNamespace: namespace,\n\t\t\tSubsystem: serverSubsystem,\n\t\t\tName:      \"traffic_in\",\n\t\t\tHelp:      \"The total in traffic\",\n\t\t}, []string{\"name\", \"type\"}),\n\t\ttrafficOut: prometheus.NewCounterVec(prometheus.CounterOpts{\n\t\t\tNamespace: namespace,\n\t\t\tSubsystem: serverSubsystem,\n\t\t\tName:      \"traffic_out\",\n\t\t\tHelp:      \"The total out traffic\",\n\t\t}, []string{\"name\", \"type\"}),\n\t}\n\tprometheus.MustRegister(m.clientCount)\n\tprometheus.MustRegister(m.proxyCount)\n\tprometheus.MustRegister(m.proxyCountDetailed)\n\tprometheus.MustRegister(m.connectionCount)\n\tprometheus.MustRegister(m.trafficIn)\n\tprometheus.MustRegister(m.trafficOut)\n\treturn m\n}\n"
  },
  {
    "path": "pkg/msg/ctl.go",
    "content": "// Copyright 2018 fatedier, fatedier@gmail.com\n//\n// Licensed under the Apache License, Version 2.0 (the \"License\");\n// you may not use this file except in compliance with the License.\n// You may obtain a copy of the License at\n//\n//     http://www.apache.org/licenses/LICENSE-2.0\n//\n// Unless required by applicable law or agreed to in writing, software\n// distributed under the License is distributed on an \"AS IS\" BASIS,\n// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.\n// See the License for the specific language governing permissions and\n// limitations under the License.\n\npackage msg\n\nimport (\n\t\"io\"\n\n\tjsonMsg \"github.com/fatedier/golib/msg/json\"\n)\n\ntype Message = jsonMsg.Message\n\nvar msgCtl *jsonMsg.MsgCtl\n\nfunc init() {\n\tmsgCtl = jsonMsg.NewMsgCtl()\n\tfor typeByte, msg := range msgTypeMap {\n\t\tmsgCtl.RegisterMsg(typeByte, msg)\n\t}\n}\n\nfunc ReadMsg(c io.Reader) (msg Message, err error) {\n\treturn msgCtl.ReadMsg(c)\n}\n\nfunc ReadMsgInto(c io.Reader, msg Message) (err error) {\n\treturn msgCtl.ReadMsgInto(c, msg)\n}\n\nfunc WriteMsg(c io.Writer, msg any) (err error) {\n\treturn msgCtl.WriteMsg(c, msg)\n}\n"
  },
  {
    "path": "pkg/msg/handler.go",
    "content": "// Copyright 2023 The frp Authors\n//\n// Licensed under the Apache License, Version 2.0 (the \"License\");\n// you may not use this file except in compliance with the License.\n// You may obtain a copy of the License at\n//\n//     http://www.apache.org/licenses/LICENSE-2.0\n//\n// Unless required by applicable law or agreed to in writing, software\n// distributed under the License is distributed on an \"AS IS\" BASIS,\n// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.\n// See the License for the specific language governing permissions and\n// limitations under the License.\n\npackage msg\n\nimport (\n\t\"io\"\n\t\"reflect\"\n)\n\nfunc AsyncHandler(f func(Message)) func(Message) {\n\treturn func(m Message) {\n\t\tgo f(m)\n\t}\n}\n\n// Dispatcher is used to send messages to net.Conn or register handlers for messages read from net.Conn.\ntype Dispatcher struct {\n\trw io.ReadWriter\n\n\tsendCh         chan Message\n\tdoneCh         chan struct{}\n\tmsgHandlers    map[reflect.Type]func(Message)\n\tdefaultHandler func(Message)\n}\n\nfunc NewDispatcher(rw io.ReadWriter) *Dispatcher {\n\treturn &Dispatcher{\n\t\trw:          rw,\n\t\tsendCh:      make(chan Message, 100),\n\t\tdoneCh:      make(chan struct{}),\n\t\tmsgHandlers: make(map[reflect.Type]func(Message)),\n\t}\n}\n\n// Run will block until io.EOF or some error occurs.\nfunc (d *Dispatcher) Run() {\n\tgo d.sendLoop()\n\tgo d.readLoop()\n}\n\nfunc (d *Dispatcher) sendLoop() {\n\tfor {\n\t\tselect {\n\t\tcase <-d.doneCh:\n\t\t\treturn\n\t\tcase m := <-d.sendCh:\n\t\t\t_ = WriteMsg(d.rw, m)\n\t\t}\n\t}\n}\n\nfunc (d *Dispatcher) readLoop() {\n\tfor {\n\t\tm, err := ReadMsg(d.rw)\n\t\tif err != nil {\n\t\t\tclose(d.doneCh)\n\t\t\treturn\n\t\t}\n\n\t\tif handler, ok := d.msgHandlers[reflect.TypeOf(m)]; ok {\n\t\t\thandler(m)\n\t\t} else if d.defaultHandler != nil {\n\t\t\td.defaultHandler(m)\n\t\t}\n\t}\n}\n\nfunc (d *Dispatcher) Send(m Message) error {\n\tselect {\n\tcase <-d.doneCh:\n\t\treturn io.EOF\n\tcase d.sendCh <- m:\n\t\treturn nil\n\t}\n}\n\nfunc (d *Dispatcher) RegisterHandler(msg Message, handler func(Message)) {\n\td.msgHandlers[reflect.TypeOf(msg)] = handler\n}\n\nfunc (d *Dispatcher) RegisterDefaultHandler(handler func(Message)) {\n\td.defaultHandler = handler\n}\n\nfunc (d *Dispatcher) Done() chan struct{} {\n\treturn d.doneCh\n}\n"
  },
  {
    "path": "pkg/msg/msg.go",
    "content": "// Copyright 2016 fatedier, fatedier@gmail.com\n//\n// Licensed under the Apache License, Version 2.0 (the \"License\");\n// you may not use this file except in compliance with the License.\n// You may obtain a copy of the License at\n//\n//     http://www.apache.org/licenses/LICENSE-2.0\n//\n// Unless required by applicable law or agreed to in writing, software\n// distributed under the License is distributed on an \"AS IS\" BASIS,\n// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.\n// See the License for the specific language governing permissions and\n// limitations under the License.\n\npackage msg\n\nimport (\n\t\"net\"\n\t\"reflect\"\n)\n\nconst (\n\tTypeLogin              = 'o'\n\tTypeLoginResp          = '1'\n\tTypeNewProxy           = 'p'\n\tTypeNewProxyResp       = '2'\n\tTypeCloseProxy         = 'c'\n\tTypeNewWorkConn        = 'w'\n\tTypeReqWorkConn        = 'r'\n\tTypeStartWorkConn      = 's'\n\tTypeNewVisitorConn     = 'v'\n\tTypeNewVisitorConnResp = '3'\n\tTypePing               = 'h'\n\tTypePong               = '4'\n\tTypeUDPPacket          = 'u'\n\tTypeNatHoleVisitor     = 'i'\n\tTypeNatHoleClient      = 'n'\n\tTypeNatHoleResp        = 'm'\n\tTypeNatHoleSid         = '5'\n\tTypeNatHoleReport      = '6'\n)\n\nvar msgTypeMap = map[byte]any{\n\tTypeLogin:              Login{},\n\tTypeLoginResp:          LoginResp{},\n\tTypeNewProxy:           NewProxy{},\n\tTypeNewProxyResp:       NewProxyResp{},\n\tTypeCloseProxy:         CloseProxy{},\n\tTypeNewWorkConn:        NewWorkConn{},\n\tTypeReqWorkConn:        ReqWorkConn{},\n\tTypeStartWorkConn:      StartWorkConn{},\n\tTypeNewVisitorConn:     NewVisitorConn{},\n\tTypeNewVisitorConnResp: NewVisitorConnResp{},\n\tTypePing:               Ping{},\n\tTypePong:               Pong{},\n\tTypeUDPPacket:          UDPPacket{},\n\tTypeNatHoleVisitor:     NatHoleVisitor{},\n\tTypeNatHoleClient:      NatHoleClient{},\n\tTypeNatHoleResp:        NatHoleResp{},\n\tTypeNatHoleSid:         NatHoleSid{},\n\tTypeNatHoleReport:      NatHoleReport{},\n}\n\nvar TypeNameNatHoleResp = reflect.TypeFor[NatHoleResp]().Name()\n\ntype ClientSpec struct {\n\t// Due to the support of VirtualClient, frps needs to know the client type in order to\n\t// differentiate the processing logic.\n\t// Optional values: ssh-tunnel\n\tType string `json:\"type,omitempty\"`\n\t// If the value is true, the client will not require authentication.\n\tAlwaysAuthPass bool `json:\"always_auth_pass,omitempty\"`\n}\n\n// When frpc start, client send this message to login to server.\ntype Login struct {\n\tVersion      string            `json:\"version,omitempty\"`\n\tHostname     string            `json:\"hostname,omitempty\"`\n\tOs           string            `json:\"os,omitempty\"`\n\tArch         string            `json:\"arch,omitempty\"`\n\tUser         string            `json:\"user,omitempty\"`\n\tPrivilegeKey string            `json:\"privilege_key,omitempty\"`\n\tTimestamp    int64             `json:\"timestamp,omitempty\"`\n\tRunID        string            `json:\"run_id,omitempty\"`\n\tClientID     string            `json:\"client_id,omitempty\"`\n\tMetas        map[string]string `json:\"metas,omitempty\"`\n\n\t// Currently only effective for VirtualClient.\n\tClientSpec ClientSpec `json:\"client_spec,omitempty\"`\n\n\t// Some global configures.\n\tPoolCount int `json:\"pool_count,omitempty\"`\n}\n\ntype LoginResp struct {\n\tVersion string `json:\"version,omitempty\"`\n\tRunID   string `json:\"run_id,omitempty\"`\n\tError   string `json:\"error,omitempty\"`\n}\n\n// When frpc login success, send this message to frps for running a new proxy.\ntype NewProxy struct {\n\tProxyName          string            `json:\"proxy_name,omitempty\"`\n\tProxyType          string            `json:\"proxy_type,omitempty\"`\n\tUseEncryption      bool              `json:\"use_encryption,omitempty\"`\n\tUseCompression     bool              `json:\"use_compression,omitempty\"`\n\tBandwidthLimit     string            `json:\"bandwidth_limit,omitempty\"`\n\tBandwidthLimitMode string            `json:\"bandwidth_limit_mode,omitempty\"`\n\tGroup              string            `json:\"group,omitempty\"`\n\tGroupKey           string            `json:\"group_key,omitempty\"`\n\tMetas              map[string]string `json:\"metas,omitempty\"`\n\tAnnotations        map[string]string `json:\"annotations,omitempty\"`\n\n\t// tcp and udp only\n\tRemotePort int `json:\"remote_port,omitempty\"`\n\n\t// http and https only\n\tCustomDomains     []string          `json:\"custom_domains,omitempty\"`\n\tSubDomain         string            `json:\"subdomain,omitempty\"`\n\tLocations         []string          `json:\"locations,omitempty\"`\n\tHTTPUser          string            `json:\"http_user,omitempty\"`\n\tHTTPPwd           string            `json:\"http_pwd,omitempty\"`\n\tHostHeaderRewrite string            `json:\"host_header_rewrite,omitempty\"`\n\tHeaders           map[string]string `json:\"headers,omitempty\"`\n\tResponseHeaders   map[string]string `json:\"response_headers,omitempty\"`\n\tRouteByHTTPUser   string            `json:\"route_by_http_user,omitempty\"`\n\n\t// stcp, sudp, xtcp\n\tSk         string   `json:\"sk,omitempty\"`\n\tAllowUsers []string `json:\"allow_users,omitempty\"`\n\n\t// tcpmux\n\tMultiplexer string `json:\"multiplexer,omitempty\"`\n}\n\ntype NewProxyResp struct {\n\tProxyName  string `json:\"proxy_name,omitempty\"`\n\tRemoteAddr string `json:\"remote_addr,omitempty\"`\n\tError      string `json:\"error,omitempty\"`\n}\n\ntype CloseProxy struct {\n\tProxyName string `json:\"proxy_name,omitempty\"`\n}\n\ntype NewWorkConn struct {\n\tRunID        string `json:\"run_id,omitempty\"`\n\tPrivilegeKey string `json:\"privilege_key,omitempty\"`\n\tTimestamp    int64  `json:\"timestamp,omitempty\"`\n}\n\ntype ReqWorkConn struct{}\n\ntype StartWorkConn struct {\n\tProxyName string `json:\"proxy_name,omitempty\"`\n\tSrcAddr   string `json:\"src_addr,omitempty\"`\n\tDstAddr   string `json:\"dst_addr,omitempty\"`\n\tSrcPort   uint16 `json:\"src_port,omitempty\"`\n\tDstPort   uint16 `json:\"dst_port,omitempty\"`\n\tError     string `json:\"error,omitempty\"`\n}\n\ntype NewVisitorConn struct {\n\tRunID          string `json:\"run_id,omitempty\"`\n\tProxyName      string `json:\"proxy_name,omitempty\"`\n\tSignKey        string `json:\"sign_key,omitempty\"`\n\tTimestamp      int64  `json:\"timestamp,omitempty\"`\n\tUseEncryption  bool   `json:\"use_encryption,omitempty\"`\n\tUseCompression bool   `json:\"use_compression,omitempty\"`\n}\n\ntype NewVisitorConnResp struct {\n\tProxyName string `json:\"proxy_name,omitempty\"`\n\tError     string `json:\"error,omitempty\"`\n}\n\ntype Ping struct {\n\tPrivilegeKey string `json:\"privilege_key,omitempty\"`\n\tTimestamp    int64  `json:\"timestamp,omitempty\"`\n}\n\ntype Pong struct {\n\tError string `json:\"error,omitempty\"`\n}\n\ntype UDPPacket struct {\n\tContent    []byte       `json:\"c,omitempty\"`\n\tLocalAddr  *net.UDPAddr `json:\"l,omitempty\"`\n\tRemoteAddr *net.UDPAddr `json:\"r,omitempty\"`\n}\n\ntype NatHoleVisitor struct {\n\tTransactionID string   `json:\"transaction_id,omitempty\"`\n\tProxyName     string   `json:\"proxy_name,omitempty\"`\n\tPreCheck      bool     `json:\"pre_check,omitempty\"`\n\tProtocol      string   `json:\"protocol,omitempty\"`\n\tSignKey       string   `json:\"sign_key,omitempty\"`\n\tTimestamp     int64    `json:\"timestamp,omitempty\"`\n\tMappedAddrs   []string `json:\"mapped_addrs,omitempty\"`\n\tAssistedAddrs []string `json:\"assisted_addrs,omitempty\"`\n}\n\ntype NatHoleClient struct {\n\tTransactionID string   `json:\"transaction_id,omitempty\"`\n\tProxyName     string   `json:\"proxy_name,omitempty\"`\n\tSid           string   `json:\"sid,omitempty\"`\n\tMappedAddrs   []string `json:\"mapped_addrs,omitempty\"`\n\tAssistedAddrs []string `json:\"assisted_addrs,omitempty\"`\n}\n\ntype PortsRange struct {\n\tFrom int `json:\"from,omitempty\"`\n\tTo   int `json:\"to,omitempty\"`\n}\n\ntype NatHoleDetectBehavior struct {\n\tRole              string       `json:\"role,omitempty\"` // sender or receiver\n\tMode              int          `json:\"mode,omitempty\"` // 0, 1, 2...\n\tTTL               int          `json:\"ttl,omitempty\"`\n\tSendDelayMs       int          `json:\"send_delay_ms,omitempty\"`\n\tReadTimeoutMs     int          `json:\"read_timeout,omitempty\"`\n\tCandidatePorts    []PortsRange `json:\"candidate_ports,omitempty\"`\n\tSendRandomPorts   int          `json:\"send_random_ports,omitempty\"`\n\tListenRandomPorts int          `json:\"listen_random_ports,omitempty\"`\n}\n\ntype NatHoleResp struct {\n\tTransactionID  string                `json:\"transaction_id,omitempty\"`\n\tSid            string                `json:\"sid,omitempty\"`\n\tProtocol       string                `json:\"protocol,omitempty\"`\n\tCandidateAddrs []string              `json:\"candidate_addrs,omitempty\"`\n\tAssistedAddrs  []string              `json:\"assisted_addrs,omitempty\"`\n\tDetectBehavior NatHoleDetectBehavior `json:\"detect_behavior,omitempty\"`\n\tError          string                `json:\"error,omitempty\"`\n}\n\ntype NatHoleSid struct {\n\tTransactionID string `json:\"transaction_id,omitempty\"`\n\tSid           string `json:\"sid,omitempty\"`\n\tResponse      bool   `json:\"response,omitempty\"`\n\tNonce         string `json:\"nonce,omitempty\"`\n}\n\ntype NatHoleReport struct {\n\tSid     string `json:\"sid,omitempty\"`\n\tSuccess bool   `json:\"success,omitempty\"`\n}\n"
  },
  {
    "path": "pkg/naming/names.go",
    "content": "package naming\n\nimport \"strings\"\n\n// AddUserPrefix builds the wire-level proxy name for frps by prefixing user.\nfunc AddUserPrefix(user, name string) string {\n\tif user == \"\" {\n\t\treturn name\n\t}\n\treturn user + \".\" + name\n}\n\n// StripUserPrefix converts a wire-level proxy name to an internal raw name.\n// It strips only one exact \"{user}.\" prefix.\nfunc StripUserPrefix(user, name string) string {\n\tif user == \"\" {\n\t\treturn name\n\t}\n\tif trimmed, ok := strings.CutPrefix(name, user+\".\"); ok {\n\t\treturn trimmed\n\t}\n\treturn name\n}\n\n// BuildTargetServerProxyName resolves visitor target proxy name for wire-level\n// protocol messages. serverUser overrides local user when set.\nfunc BuildTargetServerProxyName(localUser, serverUser, serverName string) string {\n\tif serverUser != \"\" {\n\t\treturn AddUserPrefix(serverUser, serverName)\n\t}\n\treturn AddUserPrefix(localUser, serverName)\n}\n"
  },
  {
    "path": "pkg/naming/names_test.go",
    "content": "package naming\n\nimport (\n\t\"testing\"\n\n\t\"github.com/stretchr/testify/require\"\n)\n\nfunc TestAddUserPrefix(t *testing.T) {\n\trequire := require.New(t)\n\trequire.Equal(\"test\", AddUserPrefix(\"\", \"test\"))\n\trequire.Equal(\"alice.test\", AddUserPrefix(\"alice\", \"test\"))\n}\n\nfunc TestStripUserPrefix(t *testing.T) {\n\trequire := require.New(t)\n\trequire.Equal(\"test\", StripUserPrefix(\"\", \"test\"))\n\trequire.Equal(\"test\", StripUserPrefix(\"alice\", \"alice.test\"))\n\trequire.Equal(\"alice.test\", StripUserPrefix(\"alice\", \"alice.alice.test\"))\n\trequire.Equal(\"bob.test\", StripUserPrefix(\"alice\", \"bob.test\"))\n}\n\nfunc TestBuildTargetServerProxyName(t *testing.T) {\n\trequire := require.New(t)\n\trequire.Equal(\"alice.test\", BuildTargetServerProxyName(\"alice\", \"\", \"test\"))\n\trequire.Equal(\"bob.test\", BuildTargetServerProxyName(\"alice\", \"bob\", \"test\"))\n}\n"
  },
  {
    "path": "pkg/nathole/analysis.go",
    "content": "// Copyright 2023 The frp Authors\n//\n// Licensed under the Apache License, Version 2.0 (the \"License\");\n// you may not use this file except in compliance with the License.\n// You may obtain a copy of the License at\n//\n//     http://www.apache.org/licenses/LICENSE-2.0\n//\n// Unless required by applicable law or agreed to in writing, software\n// distributed under the License is distributed on an \"AS IS\" BASIS,\n// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.\n// See the License for the specific language governing permissions and\n// limitations under the License.\n\npackage nathole\n\nimport (\n\t\"cmp\"\n\t\"slices\"\n\t\"sync\"\n\t\"time\"\n\n\t\"github.com/samber/lo\"\n)\n\nvar (\n\t// mode 0, both EasyNAT, PublicNetwork is always receiver\n\t// sender | receiver, ttl 7\n\t// receiver, ttl 7 | sender\n\t// sender | receiver, ttl 4\n\t// receiver, ttl 4 | sender\n\t// sender | receiver\n\t// receiver | sender\n\t// sender, sendDelayMs 5000 | receiver\n\t// sender, sendDelayMs 10000 | receiver\n\t// receiver | sender, sendDelayMs 5000\n\t// receiver | sender, sendDelayMs 10000\n\tmode0Behaviors = []lo.Tuple2[RecommandBehavior, RecommandBehavior]{\n\t\tlo.T2(RecommandBehavior{Role: DetectRoleSender}, RecommandBehavior{Role: DetectRoleReceiver, TTL: 7}),\n\t\tlo.T2(RecommandBehavior{Role: DetectRoleReceiver, TTL: 7}, RecommandBehavior{Role: DetectRoleSender}),\n\t\tlo.T2(RecommandBehavior{Role: DetectRoleSender}, RecommandBehavior{Role: DetectRoleReceiver, TTL: 4}),\n\t\tlo.T2(RecommandBehavior{Role: DetectRoleReceiver, TTL: 4}, RecommandBehavior{Role: DetectRoleSender}),\n\t\tlo.T2(RecommandBehavior{Role: DetectRoleSender}, RecommandBehavior{Role: DetectRoleReceiver}),\n\t\tlo.T2(RecommandBehavior{Role: DetectRoleReceiver}, RecommandBehavior{Role: DetectRoleSender}),\n\t\tlo.T2(RecommandBehavior{Role: DetectRoleSender, SendDelayMs: 5000}, RecommandBehavior{Role: DetectRoleReceiver}),\n\t\tlo.T2(RecommandBehavior{Role: DetectRoleSender, SendDelayMs: 10000}, RecommandBehavior{Role: DetectRoleReceiver}),\n\t\tlo.T2(RecommandBehavior{Role: DetectRoleReceiver}, RecommandBehavior{Role: DetectRoleSender, SendDelayMs: 5000}),\n\t\tlo.T2(RecommandBehavior{Role: DetectRoleReceiver}, RecommandBehavior{Role: DetectRoleSender, SendDelayMs: 10000}),\n\t}\n\n\t// mode 1, HardNAT is sender, EasyNAT is receiver, port changes is regular\n\t// sender | receiver, ttl 7, portsRangeNumber max 10\n\t// sender, sendDelayMs 2000 | receiver, ttl 7, portsRangeNumber max 10\n\t// sender | receiver, ttl 4, portsRangeNumber max 10\n\t// sender, sendDelayMs 2000 | receiver, ttl 4, portsRangeNumber max 10\n\t// sender | receiver, portsRangeNumber max 10\n\t// sender, sendDelayMs 2000 | receiver, portsRangeNumber max 10\n\tmode1Behaviors = []lo.Tuple2[RecommandBehavior, RecommandBehavior]{\n\t\tlo.T2(RecommandBehavior{Role: DetectRoleSender}, RecommandBehavior{Role: DetectRoleReceiver, TTL: 7, PortsRangeNumber: 10}),\n\t\tlo.T2(RecommandBehavior{Role: DetectRoleSender, SendDelayMs: 2000}, RecommandBehavior{Role: DetectRoleReceiver, TTL: 7, PortsRangeNumber: 10}),\n\t\tlo.T2(RecommandBehavior{Role: DetectRoleSender}, RecommandBehavior{Role: DetectRoleReceiver, TTL: 4, PortsRangeNumber: 10}),\n\t\tlo.T2(RecommandBehavior{Role: DetectRoleSender, SendDelayMs: 2000}, RecommandBehavior{Role: DetectRoleReceiver, TTL: 4, PortsRangeNumber: 10}),\n\t\tlo.T2(RecommandBehavior{Role: DetectRoleSender}, RecommandBehavior{Role: DetectRoleReceiver, PortsRangeNumber: 10}),\n\t\tlo.T2(RecommandBehavior{Role: DetectRoleSender, SendDelayMs: 2000}, RecommandBehavior{Role: DetectRoleReceiver, PortsRangeNumber: 10}),\n\t}\n\n\t// mode 2, HardNAT is receiver, EasyNAT is sender\n\t// sender, portsRandomNumber 1000, sendDelayMs 3000 | receiver, listen 256 ports, ttl 7\n\t// sender, portsRandomNumber 1000, sendDelayMs 3000 | receiver, listen 256 ports, ttl 4\n\t// sender, portsRandomNumber 1000, sendDelayMs 3000 | receiver, listen 256 ports\n\tmode2Behaviors = []lo.Tuple2[RecommandBehavior, RecommandBehavior]{\n\t\tlo.T2(\n\t\t\tRecommandBehavior{Role: DetectRoleSender, PortsRandomNumber: 1000, SendDelayMs: 3000},\n\t\t\tRecommandBehavior{Role: DetectRoleReceiver, ListenRandomPorts: 256, TTL: 7},\n\t\t),\n\t\tlo.T2(\n\t\t\tRecommandBehavior{Role: DetectRoleSender, PortsRandomNumber: 1000, SendDelayMs: 3000},\n\t\t\tRecommandBehavior{Role: DetectRoleReceiver, ListenRandomPorts: 256, TTL: 4},\n\t\t),\n\t\tlo.T2(\n\t\t\tRecommandBehavior{Role: DetectRoleSender, PortsRandomNumber: 1000, SendDelayMs: 3000},\n\t\t\tRecommandBehavior{Role: DetectRoleReceiver, ListenRandomPorts: 256},\n\t\t),\n\t}\n\n\t// mode 3, For HardNAT & HardNAT, both changes in the ports are regular\n\t// sender, portsRangeNumber 10 | receiver, ttl 7, portsRangeNumber 10\n\t// sender, portsRangeNumber 10 | receiver, ttl 4, portsRangeNumber 10\n\t// sender, portsRangeNumber 10 | receiver, portsRangeNumber 10\n\t// receiver, ttl 7, portsRangeNumber 10 | sender, portsRangeNumber 10\n\t// receiver, ttl 4, portsRangeNumber 10 | sender, portsRangeNumber 10\n\t// receiver, portsRangeNumber 10 | sender, portsRangeNumber 10\n\tmode3Behaviors = []lo.Tuple2[RecommandBehavior, RecommandBehavior]{\n\t\tlo.T2(RecommandBehavior{Role: DetectRoleSender, PortsRangeNumber: 10}, RecommandBehavior{Role: DetectRoleReceiver, TTL: 7, PortsRangeNumber: 10}),\n\t\tlo.T2(RecommandBehavior{Role: DetectRoleSender, PortsRangeNumber: 10}, RecommandBehavior{Role: DetectRoleReceiver, TTL: 4, PortsRangeNumber: 10}),\n\t\tlo.T2(RecommandBehavior{Role: DetectRoleSender, PortsRangeNumber: 10}, RecommandBehavior{Role: DetectRoleReceiver, PortsRangeNumber: 10}),\n\t\tlo.T2(RecommandBehavior{Role: DetectRoleReceiver, TTL: 7, PortsRangeNumber: 10}, RecommandBehavior{Role: DetectRoleSender, PortsRangeNumber: 10}),\n\t\tlo.T2(RecommandBehavior{Role: DetectRoleReceiver, TTL: 4, PortsRangeNumber: 10}, RecommandBehavior{Role: DetectRoleSender, PortsRangeNumber: 10}),\n\t\tlo.T2(RecommandBehavior{Role: DetectRoleReceiver, PortsRangeNumber: 10}, RecommandBehavior{Role: DetectRoleSender, PortsRangeNumber: 10}),\n\t}\n\n\t// mode 4, Regular ports changes are usually the sender.\n\t// sender, portsRandomNumber 1000, sendDelayMs: 2000 | receiver, listen 256 ports, ttl 7, portsRangeNumber 2\n\t// sender, portsRandomNumber 1000, sendDelayMs: 2000 | receiver, listen 256 ports, ttl 4, portsRangeNumber 2\n\t// sender, portsRandomNumber 1000, SendDelayMs: 2000 | receiver, listen 256 ports, portsRangeNumber 2\n\tmode4Behaviors = []lo.Tuple2[RecommandBehavior, RecommandBehavior]{\n\t\tlo.T2(\n\t\t\tRecommandBehavior{Role: DetectRoleSender, PortsRandomNumber: 1000, SendDelayMs: 3000},\n\t\t\tRecommandBehavior{Role: DetectRoleReceiver, ListenRandomPorts: 256, TTL: 7, PortsRangeNumber: 2},\n\t\t),\n\t\tlo.T2(\n\t\t\tRecommandBehavior{Role: DetectRoleSender, PortsRandomNumber: 1000, SendDelayMs: 3000},\n\t\t\tRecommandBehavior{Role: DetectRoleReceiver, ListenRandomPorts: 256, TTL: 4, PortsRangeNumber: 2},\n\t\t),\n\t\tlo.T2(\n\t\t\tRecommandBehavior{Role: DetectRoleSender, PortsRandomNumber: 1000, SendDelayMs: 3000},\n\t\t\tRecommandBehavior{Role: DetectRoleReceiver, ListenRandomPorts: 256, PortsRangeNumber: 2},\n\t\t),\n\t}\n)\n\nfunc getBehaviorByMode(mode int) []lo.Tuple2[RecommandBehavior, RecommandBehavior] {\n\tswitch mode {\n\tcase 0:\n\t\treturn mode0Behaviors\n\tcase 1:\n\t\treturn mode1Behaviors\n\tcase 2:\n\t\treturn mode2Behaviors\n\tcase 3:\n\t\treturn mode3Behaviors\n\tcase 4:\n\t\treturn mode4Behaviors\n\t}\n\t// default\n\treturn mode0Behaviors\n}\n\nfunc getBehaviorByModeAndIndex(mode int, index int) (RecommandBehavior, RecommandBehavior) {\n\tbehaviors := getBehaviorByMode(mode)\n\tif index >= len(behaviors) {\n\t\treturn RecommandBehavior{}, RecommandBehavior{}\n\t}\n\treturn behaviors[index].A, behaviors[index].B\n}\n\nfunc getBehaviorScoresByMode(mode int, defaultScore int) []*BehaviorScore {\n\treturn getBehaviorScoresByMode2(mode, defaultScore, defaultScore)\n}\n\nfunc getBehaviorScoresByMode2(mode int, senderScore, receiverScore int) []*BehaviorScore {\n\tbehaviors := getBehaviorByMode(mode)\n\tscores := make([]*BehaviorScore, 0, len(behaviors))\n\tfor i := range behaviors {\n\t\tscore := receiverScore\n\t\tif behaviors[i].A.Role == DetectRoleSender {\n\t\t\tscore = senderScore\n\t\t}\n\t\tscores = append(scores, &BehaviorScore{Mode: mode, Index: i, Score: score})\n\t}\n\treturn scores\n}\n\ntype RecommandBehavior struct {\n\tRole              string\n\tTTL               int\n\tSendDelayMs       int\n\tPortsRangeNumber  int\n\tPortsRandomNumber int\n\tListenRandomPorts int\n}\n\ntype MakeHoleRecords struct {\n\tmu             sync.Mutex\n\tscores         []*BehaviorScore\n\tLastUpdateTime time.Time\n}\n\nfunc NewMakeHoleRecords(c, v *NatFeature) *MakeHoleRecords {\n\tscores := []*BehaviorScore{}\n\teasyCount, hardCount, portsChangedRegularCount := ClassifyFeatureCount([]*NatFeature{c, v})\n\tappendMode0 := func() {\n\t\tswitch {\n\t\tcase c.PublicNetwork:\n\t\t\tscores = append(scores, getBehaviorScoresByMode2(DetectMode0, 0, 1)...)\n\t\tcase v.PublicNetwork:\n\t\t\tscores = append(scores, getBehaviorScoresByMode2(DetectMode0, 1, 0)...)\n\t\tdefault:\n\t\t\tscores = append(scores, getBehaviorScoresByMode(DetectMode0, 0)...)\n\t\t}\n\t}\n\n\tswitch {\n\tcase easyCount == 2:\n\t\tappendMode0()\n\tcase hardCount == 1 && portsChangedRegularCount == 1:\n\t\tscores = append(scores, getBehaviorScoresByMode(DetectMode1, 0)...)\n\t\tscores = append(scores, getBehaviorScoresByMode(DetectMode2, 0)...)\n\t\tappendMode0()\n\tcase hardCount == 1 && portsChangedRegularCount == 0:\n\t\tscores = append(scores, getBehaviorScoresByMode(DetectMode2, 0)...)\n\t\tscores = append(scores, getBehaviorScoresByMode(DetectMode1, 0)...)\n\t\tappendMode0()\n\tcase hardCount == 2 && portsChangedRegularCount == 2:\n\t\tscores = append(scores, getBehaviorScoresByMode(DetectMode3, 0)...)\n\t\tscores = append(scores, getBehaviorScoresByMode(DetectMode4, 0)...)\n\tcase hardCount == 2 && portsChangedRegularCount == 1:\n\t\tscores = append(scores, getBehaviorScoresByMode(DetectMode4, 0)...)\n\tdefault:\n\t\t// hard to make hole, just trying it out.\n\t\tscores = append(scores, getBehaviorScoresByMode(DetectMode0, 1)...)\n\t\tscores = append(scores, getBehaviorScoresByMode(DetectMode1, 1)...)\n\t\tscores = append(scores, getBehaviorScoresByMode(DetectMode3, 1)...)\n\t}\n\treturn &MakeHoleRecords{scores: scores, LastUpdateTime: time.Now()}\n}\n\nfunc (mhr *MakeHoleRecords) ReportSuccess(mode int, index int) {\n\tmhr.mu.Lock()\n\tdefer mhr.mu.Unlock()\n\tmhr.LastUpdateTime = time.Now()\n\tfor i := range mhr.scores {\n\t\tscore := mhr.scores[i]\n\t\tif score.Mode != mode || score.Index != index {\n\t\t\tcontinue\n\t\t}\n\n\t\tscore.Score += 2\n\t\tscore.Score = min(score.Score, 10)\n\t\treturn\n\t}\n}\n\nfunc (mhr *MakeHoleRecords) Recommand() (mode, index int) {\n\tmhr.mu.Lock()\n\tdefer mhr.mu.Unlock()\n\n\tif len(mhr.scores) == 0 {\n\t\treturn 0, 0\n\t}\n\tmaxScore := slices.MaxFunc(mhr.scores, func(a, b *BehaviorScore) int {\n\t\treturn cmp.Compare(a.Score, b.Score)\n\t})\n\tmaxScore.Score--\n\tmhr.LastUpdateTime = time.Now()\n\treturn maxScore.Mode, maxScore.Index\n}\n\ntype BehaviorScore struct {\n\tMode  int\n\tIndex int\n\t// between -10 and 10\n\tScore int\n}\n\ntype Analyzer struct {\n\t// key is client ip + visitor ip\n\trecords             map[string]*MakeHoleRecords\n\tdataReserveDuration time.Duration\n\n\tmu sync.Mutex\n}\n\nfunc NewAnalyzer(dataReserveDuration time.Duration) *Analyzer {\n\treturn &Analyzer{\n\t\trecords:             make(map[string]*MakeHoleRecords),\n\t\tdataReserveDuration: dataReserveDuration,\n\t}\n}\n\nfunc (a *Analyzer) GetRecommandBehaviors(key string, c, v *NatFeature) (mode, index int, _ RecommandBehavior, _ RecommandBehavior) {\n\ta.mu.Lock()\n\trecords, ok := a.records[key]\n\tif !ok {\n\t\trecords = NewMakeHoleRecords(c, v)\n\t\ta.records[key] = records\n\t}\n\ta.mu.Unlock()\n\n\tmode, index = records.Recommand()\n\tcBehavior, vBehavior := getBehaviorByModeAndIndex(mode, index)\n\n\tswitch mode {\n\tcase DetectMode1:\n\t\t// HardNAT is always the sender\n\t\tif c.NatType == EasyNAT {\n\t\t\tcBehavior, vBehavior = vBehavior, cBehavior\n\t\t}\n\tcase DetectMode2:\n\t\t// HardNAT is always the receiver\n\t\tif c.NatType == HardNAT {\n\t\t\tcBehavior, vBehavior = vBehavior, cBehavior\n\t\t}\n\tcase DetectMode4:\n\t\t// Regular ports changes is always the sender\n\t\tif !c.RegularPortsChange {\n\t\t\tcBehavior, vBehavior = vBehavior, cBehavior\n\t\t}\n\t}\n\treturn mode, index, cBehavior, vBehavior\n}\n\nfunc (a *Analyzer) ReportSuccess(key string, mode, index int) {\n\ta.mu.Lock()\n\trecords, ok := a.records[key]\n\ta.mu.Unlock()\n\tif !ok {\n\t\treturn\n\t}\n\trecords.ReportSuccess(mode, index)\n}\n\nfunc (a *Analyzer) Clean() (int, int) {\n\tnow := time.Now()\n\ttotal := 0\n\tcount := 0\n\n\t// cleanup 10w records may take 5ms\n\ta.mu.Lock()\n\tdefer a.mu.Unlock()\n\ttotal = len(a.records)\n\t// clean up records that have not been used for a period of time.\n\tfor key, records := range a.records {\n\t\tif now.Sub(records.LastUpdateTime) > a.dataReserveDuration {\n\t\t\tdelete(a.records, key)\n\t\t\tcount++\n\t\t}\n\t}\n\treturn count, total\n}\n"
  },
  {
    "path": "pkg/nathole/classify.go",
    "content": "// Copyright 2023 The frp Authors\n//\n// Licensed under the Apache License, Version 2.0 (the \"License\");\n// you may not use this file except in compliance with the License.\n// You may obtain a copy of the License at\n//\n//     http://www.apache.org/licenses/LICENSE-2.0\n//\n// Unless required by applicable law or agreed to in writing, software\n// distributed under the License is distributed on an \"AS IS\" BASIS,\n// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.\n// See the License for the specific language governing permissions and\n// limitations under the License.\n\npackage nathole\n\nimport (\n\t\"fmt\"\n\t\"net\"\n\t\"slices\"\n\t\"strconv\"\n)\n\nconst (\n\tEasyNAT = \"EasyNAT\"\n\tHardNAT = \"HardNAT\"\n\n\tBehaviorNoChange    = \"BehaviorNoChange\"\n\tBehaviorIPChanged   = \"BehaviorIPChanged\"\n\tBehaviorPortChanged = \"BehaviorPortChanged\"\n\tBehaviorBothChanged = \"BehaviorBothChanged\"\n)\n\ntype NatFeature struct {\n\tNatType            string\n\tBehavior           string\n\tPortsDifference    int\n\tRegularPortsChange bool\n\tPublicNetwork      bool\n}\n\nfunc ClassifyNATFeature(addresses []string, localIPs []string) (*NatFeature, error) {\n\tif len(addresses) <= 1 {\n\t\treturn nil, fmt.Errorf(\"not enough addresses\")\n\t}\n\tnatFeature := &NatFeature{}\n\tipChanged := false\n\tportChanged := false\n\n\tvar baseIP, basePort string\n\tvar portMax, portMin int\n\tfor _, addr := range addresses {\n\t\tip, port, err := net.SplitHostPort(addr)\n\t\tif err != nil {\n\t\t\treturn nil, err\n\t\t}\n\t\tportNum, err := strconv.Atoi(port)\n\t\tif err != nil {\n\t\t\treturn nil, err\n\t\t}\n\t\tif slices.Contains(localIPs, ip) {\n\t\t\tnatFeature.PublicNetwork = true\n\t\t}\n\n\t\tif baseIP == \"\" {\n\t\t\tbaseIP = ip\n\t\t\tbasePort = port\n\t\t\tportMax = portNum\n\t\t\tportMin = portNum\n\t\t\tcontinue\n\t\t}\n\n\t\tportMax = max(portMax, portNum)\n\t\tportMin = min(portMin, portNum)\n\t\tif baseIP != ip {\n\t\t\tipChanged = true\n\t\t}\n\t\tif basePort != port {\n\t\t\tportChanged = true\n\t\t}\n\t}\n\n\tswitch {\n\tcase ipChanged && portChanged:\n\t\tnatFeature.NatType = HardNAT\n\t\tnatFeature.Behavior = BehaviorBothChanged\n\tcase ipChanged:\n\t\tnatFeature.NatType = HardNAT\n\t\tnatFeature.Behavior = BehaviorIPChanged\n\tcase portChanged:\n\t\tnatFeature.NatType = HardNAT\n\t\tnatFeature.Behavior = BehaviorPortChanged\n\tdefault:\n\t\tnatFeature.NatType = EasyNAT\n\t\tnatFeature.Behavior = BehaviorNoChange\n\t}\n\tif natFeature.Behavior == BehaviorPortChanged {\n\t\tnatFeature.PortsDifference = portMax - portMin\n\t\tif natFeature.PortsDifference <= 5 && natFeature.PortsDifference >= 1 {\n\t\t\tnatFeature.RegularPortsChange = true\n\t\t}\n\t}\n\treturn natFeature, nil\n}\n\nfunc ClassifyFeatureCount(features []*NatFeature) (int, int, int) {\n\teasyCount := 0\n\thardCount := 0\n\t// for HardNAT\n\tportsChangedRegularCount := 0\n\tfor _, feature := range features {\n\t\tif feature.NatType == EasyNAT {\n\t\t\teasyCount++\n\t\t\tcontinue\n\t\t}\n\n\t\thardCount++\n\t\tif feature.RegularPortsChange {\n\t\t\tportsChangedRegularCount++\n\t\t}\n\t}\n\treturn easyCount, hardCount, portsChangedRegularCount\n}\n"
  },
  {
    "path": "pkg/nathole/controller.go",
    "content": "// Copyright 2023 The frp Authors\n//\n// Licensed under the Apache License, Version 2.0 (the \"License\");\n// you may not use this file except in compliance with the License.\n// You may obtain a copy of the License at\n//\n//     http://www.apache.org/licenses/LICENSE-2.0\n//\n// Unless required by applicable law or agreed to in writing, software\n// distributed under the License is distributed on an \"AS IS\" BASIS,\n// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.\n// See the License for the specific language governing permissions and\n// limitations under the License.\n\npackage nathole\n\nimport (\n\t\"context\"\n\t\"crypto/md5\"\n\t\"encoding/hex\"\n\t\"fmt\"\n\t\"net\"\n\t\"slices\"\n\t\"strconv\"\n\t\"sync\"\n\t\"time\"\n\n\t\"github.com/fatedier/golib/errors\"\n\t\"github.com/samber/lo\"\n\t\"golang.org/x/sync/errgroup\"\n\n\t\"github.com/fatedier/frp/pkg/msg\"\n\t\"github.com/fatedier/frp/pkg/transport\"\n\t\"github.com/fatedier/frp/pkg/util/log\"\n\t\"github.com/fatedier/frp/pkg/util/util\"\n)\n\n// NatHoleTimeout seconds.\nvar NatHoleTimeout int64 = 10\n\nfunc NewTransactionID() string {\n\tid, _ := util.RandID()\n\treturn fmt.Sprintf(\"%d%s\", time.Now().Unix(), id)\n}\n\ntype ClientCfg struct {\n\tname       string\n\tsk         string\n\tallowUsers []string\n\tsidCh      chan string\n}\n\ntype Session struct {\n\tsid            string\n\tanalysisKey    string\n\trecommandMode  int\n\trecommandIndex int\n\n\tvisitorMsg         *msg.NatHoleVisitor\n\tvisitorTransporter transport.MessageTransporter\n\tvResp              *msg.NatHoleResp\n\tvNatFeature        *NatFeature\n\tvBehavior          RecommandBehavior\n\n\tclientMsg         *msg.NatHoleClient\n\tclientTransporter transport.MessageTransporter\n\tcResp             *msg.NatHoleResp\n\tcNatFeature       *NatFeature\n\tcBehavior         RecommandBehavior\n\n\tnotifyCh chan struct{}\n}\n\nfunc (s *Session) genAnalysisKey() {\n\thash := md5.New()\n\tvIPs := slices.Compact(parseIPs(s.visitorMsg.MappedAddrs))\n\tif len(vIPs) > 0 {\n\t\thash.Write([]byte(vIPs[0]))\n\t}\n\thash.Write([]byte(s.vNatFeature.NatType))\n\thash.Write([]byte(s.vNatFeature.Behavior))\n\thash.Write([]byte(strconv.FormatBool(s.vNatFeature.RegularPortsChange)))\n\n\tcIPs := slices.Compact(parseIPs(s.clientMsg.MappedAddrs))\n\tif len(cIPs) > 0 {\n\t\thash.Write([]byte(cIPs[0]))\n\t}\n\thash.Write([]byte(s.cNatFeature.NatType))\n\thash.Write([]byte(s.cNatFeature.Behavior))\n\thash.Write([]byte(strconv.FormatBool(s.cNatFeature.RegularPortsChange)))\n\ts.analysisKey = hex.EncodeToString(hash.Sum(nil))\n}\n\ntype Controller struct {\n\tclientCfgs map[string]*ClientCfg\n\tsessions   map[string]*Session\n\tanalyzer   *Analyzer\n\n\tmu sync.RWMutex\n}\n\nfunc NewController(analysisDataReserveDuration time.Duration) (*Controller, error) {\n\treturn &Controller{\n\t\tclientCfgs: make(map[string]*ClientCfg),\n\t\tsessions:   make(map[string]*Session),\n\t\tanalyzer:   NewAnalyzer(analysisDataReserveDuration),\n\t}, nil\n}\n\nfunc (c *Controller) CleanWorker(ctx context.Context) {\n\tticker := time.NewTicker(time.Hour)\n\tdefer ticker.Stop()\n\tfor {\n\t\tselect {\n\t\tcase <-ticker.C:\n\t\t\tstart := time.Now()\n\t\t\tcount, total := c.analyzer.Clean()\n\t\t\tlog.Tracef(\"clean %d/%d nathole analysis data, cost %v\", count, total, time.Since(start))\n\t\tcase <-ctx.Done():\n\t\t\treturn\n\t\t}\n\t}\n}\n\nfunc (c *Controller) ListenClient(name string, sk string, allowUsers []string) (chan string, error) {\n\tcfg := &ClientCfg{\n\t\tname:       name,\n\t\tsk:         sk,\n\t\tallowUsers: allowUsers,\n\t\tsidCh:      make(chan string),\n\t}\n\tc.mu.Lock()\n\tdefer c.mu.Unlock()\n\tif _, ok := c.clientCfgs[name]; ok {\n\t\treturn nil, fmt.Errorf(\"proxy [%s] is repeated\", name)\n\t}\n\tc.clientCfgs[name] = cfg\n\treturn cfg.sidCh, nil\n}\n\nfunc (c *Controller) CloseClient(name string) {\n\tc.mu.Lock()\n\tdefer c.mu.Unlock()\n\tdelete(c.clientCfgs, name)\n}\n\nfunc (c *Controller) GenSid() string {\n\tt := time.Now().Unix()\n\tid, _ := util.RandID()\n\treturn fmt.Sprintf(\"%d%s\", t, id)\n}\n\nfunc (c *Controller) HandleVisitor(m *msg.NatHoleVisitor, transporter transport.MessageTransporter, visitorUser string) {\n\tif m.PreCheck {\n\t\tc.mu.RLock()\n\t\tcfg, ok := c.clientCfgs[m.ProxyName]\n\t\tc.mu.RUnlock()\n\t\tif !ok {\n\t\t\t_ = transporter.Send(c.GenNatHoleResponse(m.TransactionID, nil, fmt.Sprintf(\"xtcp server for [%s] doesn't exist\", m.ProxyName)))\n\t\t\treturn\n\t\t}\n\t\tif !slices.Contains(cfg.allowUsers, visitorUser) && !slices.Contains(cfg.allowUsers, \"*\") {\n\t\t\t_ = transporter.Send(c.GenNatHoleResponse(m.TransactionID, nil, fmt.Sprintf(\"xtcp visitor user [%s] not allowed for [%s]\", visitorUser, m.ProxyName)))\n\t\t\treturn\n\t\t}\n\t\t_ = transporter.Send(c.GenNatHoleResponse(m.TransactionID, nil, \"\"))\n\t\treturn\n\t}\n\n\tsid := c.GenSid()\n\tsession := &Session{\n\t\tsid:                sid,\n\t\tvisitorMsg:         m,\n\t\tvisitorTransporter: transporter,\n\t\tnotifyCh:           make(chan struct{}, 1),\n\t}\n\tvar (\n\t\tclientCfg *ClientCfg\n\t\tok        bool\n\t)\n\terr := func() error {\n\t\tc.mu.Lock()\n\t\tdefer c.mu.Unlock()\n\n\t\tclientCfg, ok = c.clientCfgs[m.ProxyName]\n\t\tif !ok {\n\t\t\treturn fmt.Errorf(\"xtcp server for [%s] doesn't exist\", m.ProxyName)\n\t\t}\n\t\tif !util.ConstantTimeEqString(m.SignKey, util.GetAuthKey(clientCfg.sk, m.Timestamp)) {\n\t\t\treturn fmt.Errorf(\"xtcp connection of [%s] auth failed\", m.ProxyName)\n\t\t}\n\t\tc.sessions[sid] = session\n\t\treturn nil\n\t}()\n\tif err != nil {\n\t\tlog.Warnf(\"handle visitorMsg error: %v\", err)\n\t\t_ = transporter.Send(c.GenNatHoleResponse(m.TransactionID, nil, err.Error()))\n\t\treturn\n\t}\n\tlog.Tracef(\"handle visitor message, sid [%s], server name: %s\", sid, m.ProxyName)\n\n\tdefer func() {\n\t\tc.mu.Lock()\n\t\tdefer c.mu.Unlock()\n\t\tdelete(c.sessions, sid)\n\t}()\n\n\tif err := errors.PanicToError(func() {\n\t\tclientCfg.sidCh <- sid\n\t}); err != nil {\n\t\treturn\n\t}\n\n\t// wait for NatHoleClient message\n\tselect {\n\tcase <-session.notifyCh:\n\tcase <-time.After(time.Duration(NatHoleTimeout) * time.Second):\n\t\tlog.Debugf(\"wait for NatHoleClient message timeout, sid [%s]\", sid)\n\t\treturn\n\t}\n\n\t// Make hole-punching decisions based on the NAT information of the client and visitor.\n\tvResp, cResp, err := c.analysis(session)\n\tif err != nil {\n\t\tlog.Debugf(\"sid [%s] analysis error: %v\", err)\n\t\tvResp = c.GenNatHoleResponse(session.visitorMsg.TransactionID, nil, err.Error())\n\t\tcResp = c.GenNatHoleResponse(session.clientMsg.TransactionID, nil, err.Error())\n\t}\n\tsession.cResp = cResp\n\tsession.vResp = vResp\n\n\t// send response to visitor and client\n\tvar g errgroup.Group\n\tg.Go(func() error {\n\t\t// if it's sender, wait for a while to make sure the client has send the detect messages\n\t\tif vResp.DetectBehavior.Role == \"sender\" {\n\t\t\ttime.Sleep(1 * time.Second)\n\t\t}\n\t\t_ = session.visitorTransporter.Send(vResp)\n\t\treturn nil\n\t})\n\tg.Go(func() error {\n\t\t// if it's sender, wait for a while to make sure the client has send the detect messages\n\t\tif cResp.DetectBehavior.Role == \"sender\" {\n\t\t\ttime.Sleep(1 * time.Second)\n\t\t}\n\t\t_ = session.clientTransporter.Send(cResp)\n\t\treturn nil\n\t})\n\t_ = g.Wait()\n\n\ttime.Sleep(time.Duration(cResp.DetectBehavior.ReadTimeoutMs+30000) * time.Millisecond)\n}\n\nfunc (c *Controller) HandleClient(m *msg.NatHoleClient, transporter transport.MessageTransporter) {\n\tc.mu.RLock()\n\tsession, ok := c.sessions[m.Sid]\n\tc.mu.RUnlock()\n\tif !ok {\n\t\treturn\n\t}\n\tlog.Tracef(\"handle client message, sid [%s], server name: %s\", session.sid, m.ProxyName)\n\tsession.clientMsg = m\n\tsession.clientTransporter = transporter\n\tselect {\n\tcase session.notifyCh <- struct{}{}:\n\tdefault:\n\t}\n}\n\nfunc (c *Controller) HandleReport(m *msg.NatHoleReport) {\n\tc.mu.RLock()\n\tsession, ok := c.sessions[m.Sid]\n\tc.mu.RUnlock()\n\tif !ok {\n\t\tlog.Tracef(\"sid [%s] report make hole success: %v, but session not found\", m.Sid, m.Success)\n\t\treturn\n\t}\n\tif m.Success {\n\t\tc.analyzer.ReportSuccess(session.analysisKey, session.recommandMode, session.recommandIndex)\n\t}\n\tlog.Infof(\"sid [%s] report make hole success: %v, mode %v, index %v\",\n\t\tm.Sid, m.Success, session.recommandMode, session.recommandIndex)\n}\n\nfunc (c *Controller) GenNatHoleResponse(transactionID string, session *Session, errInfo string) *msg.NatHoleResp {\n\tvar sid string\n\tif session != nil {\n\t\tsid = session.sid\n\t}\n\treturn &msg.NatHoleResp{\n\t\tTransactionID: transactionID,\n\t\tSid:           sid,\n\t\tError:         errInfo,\n\t}\n}\n\n// analysis analyzes the NAT type and behavior of the visitor and client, then makes hole-punching decisions.\n// return the response to the visitor and client.\nfunc (c *Controller) analysis(session *Session) (*msg.NatHoleResp, *msg.NatHoleResp, error) {\n\tcm := session.clientMsg\n\tvm := session.visitorMsg\n\n\tcNatFeature, err := ClassifyNATFeature(cm.MappedAddrs, parseIPs(cm.AssistedAddrs))\n\tif err != nil {\n\t\treturn nil, nil, fmt.Errorf(\"classify client nat feature error: %v\", err)\n\t}\n\n\tvNatFeature, err := ClassifyNATFeature(vm.MappedAddrs, parseIPs(vm.AssistedAddrs))\n\tif err != nil {\n\t\treturn nil, nil, fmt.Errorf(\"classify visitor nat feature error: %v\", err)\n\t}\n\tsession.cNatFeature = cNatFeature\n\tsession.vNatFeature = vNatFeature\n\tsession.genAnalysisKey()\n\n\tmode, index, cBehavior, vBehavior := c.analyzer.GetRecommandBehaviors(session.analysisKey, cNatFeature, vNatFeature)\n\tsession.recommandMode = mode\n\tsession.recommandIndex = index\n\tsession.cBehavior = cBehavior\n\tsession.vBehavior = vBehavior\n\n\ttimeoutMs := max(cBehavior.SendDelayMs, vBehavior.SendDelayMs) + 5000\n\tif cBehavior.ListenRandomPorts > 0 || vBehavior.ListenRandomPorts > 0 {\n\t\ttimeoutMs += 30000\n\t}\n\n\tprotocol := vm.Protocol\n\tvResp := &msg.NatHoleResp{\n\t\tTransactionID:  vm.TransactionID,\n\t\tSid:            session.sid,\n\t\tProtocol:       protocol,\n\t\tCandidateAddrs: slices.Compact(cm.MappedAddrs),\n\t\tAssistedAddrs:  slices.Compact(cm.AssistedAddrs),\n\t\tDetectBehavior: msg.NatHoleDetectBehavior{\n\t\t\tMode:              mode,\n\t\t\tRole:              vBehavior.Role,\n\t\t\tTTL:               vBehavior.TTL,\n\t\t\tSendDelayMs:       vBehavior.SendDelayMs,\n\t\t\tReadTimeoutMs:     timeoutMs - vBehavior.SendDelayMs,\n\t\t\tSendRandomPorts:   vBehavior.PortsRandomNumber,\n\t\t\tListenRandomPorts: vBehavior.ListenRandomPorts,\n\t\t\tCandidatePorts:    getRangePorts(cm.MappedAddrs, cNatFeature.PortsDifference, vBehavior.PortsRangeNumber),\n\t\t},\n\t}\n\tcResp := &msg.NatHoleResp{\n\t\tTransactionID:  cm.TransactionID,\n\t\tSid:            session.sid,\n\t\tProtocol:       protocol,\n\t\tCandidateAddrs: slices.Compact(vm.MappedAddrs),\n\t\tAssistedAddrs:  slices.Compact(vm.AssistedAddrs),\n\t\tDetectBehavior: msg.NatHoleDetectBehavior{\n\t\t\tMode:              mode,\n\t\t\tRole:              cBehavior.Role,\n\t\t\tTTL:               cBehavior.TTL,\n\t\t\tSendDelayMs:       cBehavior.SendDelayMs,\n\t\t\tReadTimeoutMs:     timeoutMs - cBehavior.SendDelayMs,\n\t\t\tSendRandomPorts:   cBehavior.PortsRandomNumber,\n\t\t\tListenRandomPorts: cBehavior.ListenRandomPorts,\n\t\t\tCandidatePorts:    getRangePorts(vm.MappedAddrs, vNatFeature.PortsDifference, cBehavior.PortsRangeNumber),\n\t\t},\n\t}\n\n\tlog.Debugf(\"sid [%s] visitor nat: %+v, candidateAddrs: %v; client nat: %+v, candidateAddrs: %v, protocol: %s\",\n\t\tsession.sid, *vNatFeature, vm.MappedAddrs, *cNatFeature, cm.MappedAddrs, protocol)\n\tlog.Debugf(\"sid [%s] visitor detect behavior: %+v\", session.sid, vResp.DetectBehavior)\n\tlog.Debugf(\"sid [%s] client detect behavior: %+v\", session.sid, cResp.DetectBehavior)\n\treturn vResp, cResp, nil\n}\n\nfunc getRangePorts(addrs []string, difference, maxNumber int) []msg.PortsRange {\n\tif maxNumber <= 0 {\n\t\treturn nil\n\t}\n\n\taddr, isLast := lo.Last(addrs)\n\tif !isLast {\n\t\treturn nil\n\t}\n\tports := make([]msg.PortsRange, 0, 1)\n\t_, portStr, err := net.SplitHostPort(addr)\n\tif err != nil {\n\t\treturn nil\n\t}\n\tport, err := strconv.Atoi(portStr)\n\tif err != nil {\n\t\treturn nil\n\t}\n\tports = append(ports, msg.PortsRange{\n\t\tFrom: max(port-difference-5, port-maxNumber, 1),\n\t\tTo:   min(port+difference+5, port+maxNumber, 65535),\n\t})\n\treturn ports\n}\n"
  },
  {
    "path": "pkg/nathole/discovery.go",
    "content": "// Copyright 2023 The frp Authors\n//\n// Licensed under the Apache License, Version 2.0 (the \"License\");\n// you may not use this file except in compliance with the License.\n// You may obtain a copy of the License at\n//\n//     http://www.apache.org/licenses/LICENSE-2.0\n//\n// Unless required by applicable law or agreed to in writing, software\n// distributed under the License is distributed on an \"AS IS\" BASIS,\n// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.\n// See the License for the specific language governing permissions and\n// limitations under the License.\n\npackage nathole\n\nimport (\n\t\"fmt\"\n\t\"net\"\n\t\"time\"\n\n\t\"github.com/pion/stun/v2\"\n)\n\nvar responseTimeout = 3 * time.Second\n\ntype Message struct {\n\tBody []byte\n\tAddr string\n}\n\n// If the localAddr is empty, it will listen on a random port.\nfunc Discover(stunServers []string, localAddr string) ([]string, net.Addr, error) {\n\t// create a discoverConn and get response from messageChan\n\tdiscoverConn, err := listen(localAddr)\n\tif err != nil {\n\t\treturn nil, nil, err\n\t}\n\tdefer discoverConn.Close()\n\n\tgo discoverConn.readLoop()\n\n\taddresses := make([]string, 0, len(stunServers))\n\tfor _, addr := range stunServers {\n\t\t// get external address from stun server\n\t\texternalAddrs, err := discoverConn.discoverFromStunServer(addr)\n\t\tif err != nil {\n\t\t\treturn nil, nil, err\n\t\t}\n\t\taddresses = append(addresses, externalAddrs...)\n\t}\n\treturn addresses, discoverConn.localAddr, nil\n}\n\ntype stunResponse struct {\n\texternalAddr string\n\totherAddr    string\n}\n\ntype discoverConn struct {\n\tconn *net.UDPConn\n\n\tlocalAddr   net.Addr\n\tmessageChan chan *Message\n}\n\nfunc listen(localAddr string) (*discoverConn, error) {\n\tvar local *net.UDPAddr\n\tif localAddr != \"\" {\n\t\taddr, err := net.ResolveUDPAddr(\"udp4\", localAddr)\n\t\tif err != nil {\n\t\t\treturn nil, err\n\t\t}\n\t\tlocal = addr\n\t}\n\tconn, err := net.ListenUDP(\"udp4\", local)\n\tif err != nil {\n\t\treturn nil, err\n\t}\n\n\treturn &discoverConn{\n\t\tconn:        conn,\n\t\tlocalAddr:   conn.LocalAddr(),\n\t\tmessageChan: make(chan *Message, 10),\n\t}, nil\n}\n\nfunc (c *discoverConn) Close() error {\n\tif c.messageChan != nil {\n\t\tclose(c.messageChan)\n\t\tc.messageChan = nil\n\t}\n\treturn c.conn.Close()\n}\n\nfunc (c *discoverConn) readLoop() {\n\tfor {\n\t\tbuf := make([]byte, 1024)\n\t\tn, addr, err := c.conn.ReadFromUDP(buf)\n\t\tif err != nil {\n\t\t\treturn\n\t\t}\n\t\tbuf = buf[:n]\n\n\t\tc.messageChan <- &Message{\n\t\t\tBody: buf,\n\t\t\tAddr: addr.String(),\n\t\t}\n\t}\n}\n\nfunc (c *discoverConn) doSTUNRequest(addr string) (*stunResponse, error) {\n\tserverAddr, err := net.ResolveUDPAddr(\"udp4\", addr)\n\tif err != nil {\n\t\treturn nil, err\n\t}\n\trequest, err := stun.Build(stun.TransactionID, stun.BindingRequest)\n\tif err != nil {\n\t\treturn nil, err\n\t}\n\n\tif err = request.NewTransactionID(); err != nil {\n\t\treturn nil, err\n\t}\n\tif _, err := c.conn.WriteTo(request.Raw, serverAddr); err != nil {\n\t\treturn nil, err\n\t}\n\n\tvar m stun.Message\n\tselect {\n\tcase msg := <-c.messageChan:\n\t\tm.Raw = msg.Body\n\t\tif err := m.Decode(); err != nil {\n\t\t\treturn nil, err\n\t\t}\n\tcase <-time.After(responseTimeout):\n\t\treturn nil, fmt.Errorf(\"wait response from stun server timeout\")\n\t}\n\txorAddrGetter := &stun.XORMappedAddress{}\n\tmappedAddrGetter := &stun.MappedAddress{}\n\tchangedAddrGetter := ChangedAddress{}\n\totherAddrGetter := &stun.OtherAddress{}\n\n\tresp := &stunResponse{}\n\tif err := mappedAddrGetter.GetFrom(&m); err == nil {\n\t\tresp.externalAddr = mappedAddrGetter.String()\n\t}\n\tif err := xorAddrGetter.GetFrom(&m); err == nil {\n\t\tresp.externalAddr = xorAddrGetter.String()\n\t}\n\tif err := changedAddrGetter.GetFrom(&m); err == nil {\n\t\tresp.otherAddr = changedAddrGetter.String()\n\t}\n\tif err := otherAddrGetter.GetFrom(&m); err == nil {\n\t\tresp.otherAddr = otherAddrGetter.String()\n\t}\n\treturn resp, nil\n}\n\nfunc (c *discoverConn) discoverFromStunServer(addr string) ([]string, error) {\n\tresp, err := c.doSTUNRequest(addr)\n\tif err != nil {\n\t\treturn nil, err\n\t}\n\tif resp.externalAddr == \"\" {\n\t\treturn nil, fmt.Errorf(\"no external address found\")\n\t}\n\n\texternalAddrs := make([]string, 0, 2)\n\texternalAddrs = append(externalAddrs, resp.externalAddr)\n\n\tif resp.otherAddr == \"\" {\n\t\treturn externalAddrs, nil\n\t}\n\n\t// find external address from changed address\n\tresp, err = c.doSTUNRequest(resp.otherAddr)\n\tif err != nil {\n\t\treturn nil, err\n\t}\n\tif resp.externalAddr != \"\" {\n\t\texternalAddrs = append(externalAddrs, resp.externalAddr)\n\t}\n\treturn externalAddrs, nil\n}\n"
  },
  {
    "path": "pkg/nathole/nathole.go",
    "content": "// Copyright 2023 The frp Authors\n//\n// Licensed under the Apache License, Version 2.0 (the \"License\");\n// you may not use this file except in compliance with the License.\n// You may obtain a copy of the License at\n//\n//     http://www.apache.org/licenses/LICENSE-2.0\n//\n// Unless required by applicable law or agreed to in writing, software\n// distributed under the License is distributed on an \"AS IS\" BASIS,\n// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.\n// See the License for the specific language governing permissions and\n// limitations under the License.\n\npackage nathole\n\nimport (\n\t\"context\"\n\t\"fmt\"\n\t\"math/rand/v2\"\n\t\"net\"\n\t\"slices\"\n\t\"strconv\"\n\t\"strings\"\n\t\"time\"\n\n\t\"github.com/fatedier/golib/pool\"\n\t\"golang.org/x/net/ipv4\"\n\t\"k8s.io/apimachinery/pkg/util/sets\"\n\n\t\"github.com/fatedier/frp/pkg/msg\"\n\t\"github.com/fatedier/frp/pkg/transport\"\n\t\"github.com/fatedier/frp/pkg/util/xlog\"\n)\n\nvar (\n\t// mode 0: simple detect mode, usually for both EasyNAT or HardNAT & EasyNAT(Public Network)\n\t// a. receiver sends detect message with low TTL\n\t// b. sender sends normal detect message to receiver\n\t// c. receiver receives detect message and sends back a message to sender\n\t//\n\t// mode 1: For HardNAT & EasyNAT, send detect messages to multiple guessed ports.\n\t// Usually applicable to scenarios where port changes are regular.\n\t// Most of the steps are the same as mode 0, but EasyNAT is fixed as the receiver and will send detect messages\n\t// with low TTL to multiple guessed ports of the sender.\n\t//\n\t// mode 2: For HardNAT & EasyNAT, ports changes are not regular.\n\t// a. HardNAT machine will listen on multiple ports and send detect messages with low TTL to EasyNAT machine\n\t// b. EasyNAT machine will send detect messages to random ports of HardNAT machine.\n\t//\n\t// mode 3: For HardNAT & HardNAT, both changes in the ports are regular.\n\t// Most of the steps are the same as mode 1, but the sender also needs to send detect messages to multiple guessed\n\t// ports of the receiver.\n\t//\n\t// mode 4: For HardNAT & HardNAT, one of the changes in the ports is regular.\n\t// Regular port changes are usually on the sender side.\n\t// a. Receiver listens on multiple ports and sends detect messages with low TTL to the sender's guessed range ports.\n\t// b. Sender sends detect messages to random ports of the receiver.\n\tSupportedModes = []int{DetectMode0, DetectMode1, DetectMode2, DetectMode3, DetectMode4}\n\tSupportedRoles = []string{DetectRoleSender, DetectRoleReceiver}\n\n\tDetectMode0        = 0\n\tDetectMode1        = 1\n\tDetectMode2        = 2\n\tDetectMode3        = 3\n\tDetectMode4        = 4\n\tDetectRoleSender   = \"sender\"\n\tDetectRoleReceiver = \"receiver\"\n)\n\n// PrepareOptions defines options for NAT traversal preparation\ntype PrepareOptions struct {\n\t// DisableAssistedAddrs disables the use of local network interfaces\n\t// for assisted connections during NAT traversal\n\tDisableAssistedAddrs bool\n}\n\ntype PrepareResult struct {\n\tAddrs         []string\n\tAssistedAddrs []string\n\tListenConn    *net.UDPConn\n\tNatType       string\n\tBehavior      string\n}\n\n// PreCheck is used to check if the proxy is ready for penetration.\n// Call this function before calling Prepare to avoid unnecessary preparation work.\nfunc PreCheck(\n\tctx context.Context, transporter transport.MessageTransporter,\n\tproxyName string, timeout time.Duration,\n) error {\n\ttimeoutCtx, cancel := context.WithTimeout(ctx, timeout)\n\tdefer cancel()\n\n\tvar natHoleRespMsg *msg.NatHoleResp\n\ttransactionID := NewTransactionID()\n\tm, err := transporter.Do(timeoutCtx, &msg.NatHoleVisitor{\n\t\tTransactionID: transactionID,\n\t\tProxyName:     proxyName,\n\t\tPreCheck:      true,\n\t}, transactionID, msg.TypeNameNatHoleResp)\n\tif err != nil {\n\t\treturn fmt.Errorf(\"get natHoleRespMsg error: %v\", err)\n\t}\n\tmm, ok := m.(*msg.NatHoleResp)\n\tif !ok {\n\t\treturn fmt.Errorf(\"get natHoleRespMsg error: invalid message type\")\n\t}\n\tnatHoleRespMsg = mm\n\n\tif natHoleRespMsg.Error != \"\" {\n\t\treturn fmt.Errorf(\"%s\", natHoleRespMsg.Error)\n\t}\n\treturn nil\n}\n\n// Prepare is used to do some preparation work before penetration.\nfunc Prepare(stunServers []string, opts PrepareOptions) (*PrepareResult, error) {\n\t// discover for Nat type\n\taddrs, localAddr, err := Discover(stunServers, \"\")\n\tif err != nil {\n\t\treturn nil, fmt.Errorf(\"discover error: %v\", err)\n\t}\n\tif len(addrs) < 2 {\n\t\treturn nil, fmt.Errorf(\"discover error: not enough addresses\")\n\t}\n\n\tlocalIPs, _ := ListLocalIPsForNatHole(10)\n\tnatFeature, err := ClassifyNATFeature(addrs, localIPs)\n\tif err != nil {\n\t\treturn nil, fmt.Errorf(\"classify nat feature error: %v\", err)\n\t}\n\n\tladdr, err := net.ResolveUDPAddr(\"udp4\", localAddr.String())\n\tif err != nil {\n\t\treturn nil, fmt.Errorf(\"resolve local udp addr error: %v\", err)\n\t}\n\tlistenConn, err := net.ListenUDP(\"udp4\", laddr)\n\tif err != nil {\n\t\treturn nil, fmt.Errorf(\"listen local udp addr error: %v\", err)\n\t}\n\n\t// Apply NAT traversal options\n\tvar assistedAddrs []string\n\tif !opts.DisableAssistedAddrs {\n\t\tassistedAddrs = make([]string, 0, len(localIPs))\n\t\tfor _, ip := range localIPs {\n\t\t\tassistedAddrs = append(assistedAddrs, net.JoinHostPort(ip, strconv.Itoa(laddr.Port)))\n\t\t}\n\t}\n\treturn &PrepareResult{\n\t\tAddrs:         addrs,\n\t\tAssistedAddrs: assistedAddrs,\n\t\tListenConn:    listenConn,\n\t\tNatType:       natFeature.NatType,\n\t\tBehavior:      natFeature.Behavior,\n\t}, nil\n}\n\n// ExchangeInfo is used to exchange information between client and visitor.\n// 1. Send input message to server by msgTransporter.\n// 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.\n// 3. Receive NatHoleResp message from server.\nfunc ExchangeInfo(\n\tctx context.Context, transporter transport.MessageTransporter,\n\tlaneKey string, m msg.Message, timeout time.Duration,\n) (*msg.NatHoleResp, error) {\n\ttimeoutCtx, cancel := context.WithTimeout(ctx, timeout)\n\tdefer cancel()\n\n\tvar natHoleRespMsg *msg.NatHoleResp\n\tm, err := transporter.Do(timeoutCtx, m, laneKey, msg.TypeNameNatHoleResp)\n\tif err != nil {\n\t\treturn nil, fmt.Errorf(\"get natHoleRespMsg error: %v\", err)\n\t}\n\tmm, ok := m.(*msg.NatHoleResp)\n\tif !ok {\n\t\treturn nil, fmt.Errorf(\"get natHoleRespMsg error: invalid message type\")\n\t}\n\tnatHoleRespMsg = mm\n\n\tif natHoleRespMsg.Error != \"\" {\n\t\treturn nil, fmt.Errorf(\"natHoleRespMsg get error info: %s\", natHoleRespMsg.Error)\n\t}\n\tif len(natHoleRespMsg.CandidateAddrs) == 0 {\n\t\treturn nil, fmt.Errorf(\"natHoleRespMsg get empty candidate addresses\")\n\t}\n\treturn natHoleRespMsg, nil\n}\n\n// MakeHole is used to make a NAT hole between client and visitor.\nfunc MakeHole(ctx context.Context, listenConn *net.UDPConn, m *msg.NatHoleResp, key []byte) (*net.UDPConn, *net.UDPAddr, error) {\n\txl := xlog.FromContextSafe(ctx)\n\ttransactionID := NewTransactionID()\n\tsendToRangePortsFunc := func(conn *net.UDPConn, addr string) error {\n\t\treturn sendSidMessage(ctx, conn, m.Sid, transactionID, addr, key, m.DetectBehavior.TTL)\n\t}\n\n\tlistenConns := []*net.UDPConn{listenConn}\n\tvar detectAddrs []string\n\tif m.DetectBehavior.Role == DetectRoleSender {\n\t\t// sender\n\t\tif m.DetectBehavior.SendDelayMs > 0 {\n\t\t\ttime.Sleep(time.Duration(m.DetectBehavior.SendDelayMs) * time.Millisecond)\n\t\t}\n\t\tdetectAddrs = m.AssistedAddrs\n\t\tdetectAddrs = append(detectAddrs, m.CandidateAddrs...)\n\t} else {\n\t\t// receiver\n\t\tif len(m.DetectBehavior.CandidatePorts) == 0 {\n\t\t\tdetectAddrs = m.CandidateAddrs\n\t\t}\n\n\t\tif m.DetectBehavior.ListenRandomPorts > 0 {\n\t\t\tfor i := 0; i < m.DetectBehavior.ListenRandomPorts; i++ {\n\t\t\t\ttmpConn, err := net.ListenUDP(\"udp4\", nil)\n\t\t\t\tif err != nil {\n\t\t\t\t\txl.Warnf(\"listen random udp addr error: %v\", err)\n\t\t\t\t\tcontinue\n\t\t\t\t}\n\t\t\t\tlistenConns = append(listenConns, tmpConn)\n\t\t\t}\n\t\t}\n\t}\n\n\tdetectAddrs = slices.Compact(detectAddrs)\n\tfor _, detectAddr := range detectAddrs {\n\t\tfor _, conn := range listenConns {\n\t\t\tif err := sendSidMessage(ctx, conn, m.Sid, transactionID, detectAddr, key, m.DetectBehavior.TTL); err != nil {\n\t\t\t\txl.Tracef(\"send sid message from %s to %s error: %v\", conn.LocalAddr(), detectAddr, err)\n\t\t\t}\n\t\t}\n\t}\n\tif len(m.DetectBehavior.CandidatePorts) > 0 {\n\t\tfor _, conn := range listenConns {\n\t\t\tsendSidMessageToRangePorts(ctx, conn, m.CandidateAddrs, m.DetectBehavior.CandidatePorts, sendToRangePortsFunc)\n\t\t}\n\t}\n\tif m.DetectBehavior.SendRandomPorts > 0 {\n\t\tctx, cancel := context.WithCancel(ctx)\n\t\tdefer cancel()\n\t\tfor i := range listenConns {\n\t\t\tgo sendSidMessageToRandomPorts(ctx, listenConns[i], m.CandidateAddrs, m.DetectBehavior.SendRandomPorts, sendToRangePortsFunc)\n\t\t}\n\t}\n\n\ttimeout := 5 * time.Second\n\tif m.DetectBehavior.ReadTimeoutMs > 0 {\n\t\ttimeout = time.Duration(m.DetectBehavior.ReadTimeoutMs) * time.Millisecond\n\t}\n\n\tif len(listenConns) == 1 {\n\t\traddr, err := waitDetectMessage(ctx, listenConns[0], m.Sid, key, timeout, m.DetectBehavior.Role)\n\t\tif err != nil {\n\t\t\treturn nil, nil, fmt.Errorf(\"wait detect message error: %v\", err)\n\t\t}\n\t\treturn listenConns[0], raddr, nil\n\t}\n\n\ttype result struct {\n\t\tlConn *net.UDPConn\n\t\traddr *net.UDPAddr\n\t}\n\tresultCh := make(chan result)\n\tfor _, conn := range listenConns {\n\t\tgo func(lConn *net.UDPConn) {\n\t\t\taddr, err := waitDetectMessage(ctx, lConn, m.Sid, key, timeout, m.DetectBehavior.Role)\n\t\t\tif err != nil {\n\t\t\t\tlConn.Close()\n\t\t\t\treturn\n\t\t\t}\n\t\t\tselect {\n\t\t\tcase resultCh <- result{lConn: lConn, raddr: addr}:\n\t\t\tdefault:\n\t\t\t\tlConn.Close()\n\t\t\t}\n\t\t}(conn)\n\t}\n\n\tselect {\n\tcase result := <-resultCh:\n\t\treturn result.lConn, result.raddr, nil\n\tcase <-time.After(timeout):\n\t\treturn nil, nil, fmt.Errorf(\"wait detect message timeout\")\n\tcase <-ctx.Done():\n\t\treturn nil, nil, fmt.Errorf(\"wait detect message canceled\")\n\t}\n}\n\nfunc waitDetectMessage(\n\tctx context.Context, conn *net.UDPConn, sid string, key []byte,\n\ttimeout time.Duration, role string,\n) (*net.UDPAddr, error) {\n\txl := xlog.FromContextSafe(ctx)\n\tfor {\n\t\tbuf := pool.GetBuf(1024)\n\t\t_ = conn.SetReadDeadline(time.Now().Add(timeout))\n\t\tn, raddr, err := conn.ReadFromUDP(buf)\n\t\t_ = conn.SetReadDeadline(time.Time{})\n\t\tif err != nil {\n\t\t\tpool.PutBuf(buf)\n\t\t\treturn nil, err\n\t\t}\n\t\txl.Debugf(\"get udp message local %s, from %s\", conn.LocalAddr(), raddr)\n\t\tvar m msg.NatHoleSid\n\t\tif err := DecodeMessageInto(buf[:n], key, &m); err != nil {\n\t\t\tpool.PutBuf(buf)\n\t\t\txl.Warnf(\"decode sid message error: %v\", err)\n\t\t\tcontinue\n\t\t}\n\t\tpool.PutBuf(buf)\n\n\t\tif m.Sid != sid {\n\t\t\txl.Warnf(\"get sid message with wrong sid: %s, expect: %s\", m.Sid, sid)\n\t\t\tcontinue\n\t\t}\n\n\t\tif !m.Response {\n\t\t\t// only wait for response messages if we are a sender\n\t\t\tif role == DetectRoleSender {\n\t\t\t\tcontinue\n\t\t\t}\n\n\t\t\tm.Response = true\n\t\t\tbuf2, err := EncodeMessage(&m, key)\n\t\t\tif err != nil {\n\t\t\t\txl.Warnf(\"encode sid message error: %v\", err)\n\t\t\t\tcontinue\n\t\t\t}\n\t\t\t_, _ = conn.WriteToUDP(buf2, raddr)\n\t\t}\n\t\treturn raddr, nil\n\t}\n}\n\nfunc sendSidMessage(\n\tctx context.Context, conn *net.UDPConn,\n\tsid string, transactionID string, addr string, key []byte, ttl int,\n) error {\n\txl := xlog.FromContextSafe(ctx)\n\tttlStr := \"\"\n\tif ttl > 0 {\n\t\tttlStr = fmt.Sprintf(\" with ttl %d\", ttl)\n\t}\n\txl.Tracef(\"send sid message from %s to %s%s\", conn.LocalAddr(), addr, ttlStr)\n\traddr, err := net.ResolveUDPAddr(\"udp4\", addr)\n\tif err != nil {\n\t\treturn err\n\t}\n\tif transactionID == \"\" {\n\t\ttransactionID = NewTransactionID()\n\t}\n\tm := &msg.NatHoleSid{\n\t\tTransactionID: transactionID,\n\t\tSid:           sid,\n\t\tResponse:      false,\n\t\tNonce:         strings.Repeat(\"0\", rand.IntN(20)),\n\t}\n\tbuf, err := EncodeMessage(m, key)\n\tif err != nil {\n\t\treturn err\n\t}\n\tif ttl > 0 {\n\t\tuConn := ipv4.NewConn(conn)\n\t\toriginal, err := uConn.TTL()\n\t\tif err != nil {\n\t\t\txl.Tracef(\"get ttl error %v\", err)\n\t\t\treturn err\n\t\t}\n\t\txl.Tracef(\"original ttl %d\", original)\n\n\t\terr = uConn.SetTTL(ttl)\n\t\tif err != nil {\n\t\t\txl.Tracef(\"set ttl error %v\", err)\n\t\t} else {\n\t\t\tdefer func() {\n\t\t\t\t_ = uConn.SetTTL(original)\n\t\t\t}()\n\t\t}\n\t}\n\n\tif _, err := conn.WriteToUDP(buf, raddr); err != nil {\n\t\treturn err\n\t}\n\treturn nil\n}\n\nfunc sendSidMessageToRangePorts(\n\tctx context.Context, conn *net.UDPConn, addrs []string, ports []msg.PortsRange,\n\tsendFunc func(*net.UDPConn, string) error,\n) {\n\txl := xlog.FromContextSafe(ctx)\n\tfor _, ip := range slices.Compact(parseIPs(addrs)) {\n\t\tfor _, portsRange := range ports {\n\t\t\tfor i := portsRange.From; i <= portsRange.To; i++ {\n\t\t\t\tdetectAddr := net.JoinHostPort(ip, strconv.Itoa(i))\n\t\t\t\tif err := sendFunc(conn, detectAddr); err != nil {\n\t\t\t\t\txl.Tracef(\"send sid message from %s to %s error: %v\", conn.LocalAddr(), detectAddr, err)\n\t\t\t\t}\n\t\t\t\ttime.Sleep(2 * time.Millisecond)\n\t\t\t}\n\t\t}\n\t}\n}\n\nfunc sendSidMessageToRandomPorts(\n\tctx context.Context, conn *net.UDPConn, addrs []string, count int,\n\tsendFunc func(*net.UDPConn, string) error,\n) {\n\txl := xlog.FromContextSafe(ctx)\n\tused := sets.New[int]()\n\tgetUnusedPort := func() int {\n\t\tfor range 10 {\n\t\t\tport := rand.IntN(65535-1024) + 1024\n\t\t\tif !used.Has(port) {\n\t\t\t\tused.Insert(port)\n\t\t\t\treturn port\n\t\t\t}\n\t\t}\n\t\treturn 0\n\t}\n\n\tfor range count {\n\t\tselect {\n\t\tcase <-ctx.Done():\n\t\t\treturn\n\t\tdefault:\n\t\t}\n\n\t\tport := getUnusedPort()\n\t\tif port == 0 {\n\t\t\tcontinue\n\t\t}\n\n\t\tfor _, ip := range slices.Compact(parseIPs(addrs)) {\n\t\t\tdetectAddr := net.JoinHostPort(ip, strconv.Itoa(port))\n\t\t\tif err := sendFunc(conn, detectAddr); err != nil {\n\t\t\t\txl.Tracef(\"send sid message from %s to %s error: %v\", conn.LocalAddr(), detectAddr, err)\n\t\t\t}\n\t\t\ttime.Sleep(time.Millisecond * 15)\n\t\t}\n\t}\n}\n\nfunc parseIPs(addrs []string) []string {\n\tvar ips []string\n\tfor _, addr := range addrs {\n\t\tif ip, _, err := net.SplitHostPort(addr); err == nil {\n\t\t\tips = append(ips, ip)\n\t\t}\n\t}\n\treturn ips\n}\n"
  },
  {
    "path": "pkg/nathole/utils.go",
    "content": "// Copyright 2023 The frp Authors\n//\n// Licensed under the Apache License, Version 2.0 (the \"License\");\n// you may not use this file except in compliance with the License.\n// You may obtain a copy of the License at\n//\n//     http://www.apache.org/licenses/LICENSE-2.0\n//\n// Unless required by applicable law or agreed to in writing, software\n// distributed under the License is distributed on an \"AS IS\" BASIS,\n// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.\n// See the License for the specific language governing permissions and\n// limitations under the License.\n\npackage nathole\n\nimport (\n\t\"bytes\"\n\t\"fmt\"\n\t\"net\"\n\t\"strconv\"\n\n\t\"github.com/fatedier/golib/crypto\"\n\t\"github.com/pion/stun/v2\"\n\n\t\"github.com/fatedier/frp/pkg/msg\"\n)\n\nfunc EncodeMessage(m msg.Message, key []byte) ([]byte, error) {\n\tbuffer := bytes.NewBuffer(nil)\n\tif err := msg.WriteMsg(buffer, m); err != nil {\n\t\treturn nil, err\n\t}\n\n\tbuf, err := crypto.Encode(buffer.Bytes(), key)\n\tif err != nil {\n\t\treturn nil, err\n\t}\n\treturn buf, nil\n}\n\nfunc DecodeMessageInto(data, key []byte, m msg.Message) error {\n\tbuf, err := crypto.Decode(data, key)\n\tif err != nil {\n\t\treturn err\n\t}\n\n\treturn msg.ReadMsgInto(bytes.NewReader(buf), m)\n}\n\ntype ChangedAddress struct {\n\tIP   net.IP\n\tPort int\n}\n\nfunc (s *ChangedAddress) GetFrom(m *stun.Message) error {\n\ta := (*stun.MappedAddress)(s)\n\treturn a.GetFromAs(m, stun.AttrChangedAddress)\n}\n\nfunc (s *ChangedAddress) String() string {\n\treturn net.JoinHostPort(s.IP.String(), strconv.Itoa(s.Port))\n}\n\nfunc ListAllLocalIPs() ([]net.IP, error) {\n\taddrs, err := net.InterfaceAddrs()\n\tif err != nil {\n\t\treturn nil, err\n\t}\n\tips := make([]net.IP, 0, len(addrs))\n\tfor _, addr := range addrs {\n\t\tip, _, err := net.ParseCIDR(addr.String())\n\t\tif err != nil {\n\t\t\tcontinue\n\t\t}\n\t\tips = append(ips, ip)\n\t}\n\treturn ips, nil\n}\n\nfunc ListLocalIPsForNatHole(maxItems int) ([]string, error) {\n\tif maxItems <= 0 {\n\t\treturn nil, fmt.Errorf(\"maxItems must be greater than 0\")\n\t}\n\n\tips, err := ListAllLocalIPs()\n\tif err != nil {\n\t\treturn nil, err\n\t}\n\n\tfiltered := make([]string, 0, maxItems)\n\tfor _, ip := range ips {\n\t\tif len(filtered) >= maxItems {\n\t\t\tbreak\n\t\t}\n\n\t\t// ignore ipv6 address\n\t\tif ip.To4() == nil {\n\t\t\tcontinue\n\t\t}\n\t\t// ignore localhost IP\n\t\tif ip.IsLoopback() || ip.IsLinkLocalUnicast() || ip.IsLinkLocalMulticast() {\n\t\t\tcontinue\n\t\t}\n\n\t\tfiltered = append(filtered, ip.String())\n\t}\n\treturn filtered, nil\n}\n"
  },
  {
    "path": "pkg/plugin/client/http2http.go",
    "content": "// Copyright 2024 The frp Authors\n//\n// Licensed under the Apache License, Version 2.0 (the \"License\");\n// you may not use this file except in compliance with the License.\n// You may obtain a copy of the License at\n//\n//     http://www.apache.org/licenses/LICENSE-2.0\n//\n// Unless required by applicable law or agreed to in writing, software\n// distributed under the License is distributed on an \"AS IS\" BASIS,\n// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.\n// See the License for the specific language governing permissions and\n// limitations under the License.\n\n//go:build !frps\n\npackage client\n\nimport (\n\t\"context\"\n\tstdlog \"log\"\n\t\"net/http\"\n\t\"net/http/httputil\"\n\t\"time\"\n\n\t\"github.com/fatedier/golib/pool\"\n\n\tv1 \"github.com/fatedier/frp/pkg/config/v1\"\n\t\"github.com/fatedier/frp/pkg/util/log\"\n\tnetpkg \"github.com/fatedier/frp/pkg/util/net\"\n)\n\nfunc init() {\n\tRegister(v1.PluginHTTP2HTTP, NewHTTP2HTTPPlugin)\n}\n\ntype HTTP2HTTPPlugin struct {\n\topts *v1.HTTP2HTTPPluginOptions\n\n\tl *Listener\n\ts *http.Server\n}\n\nfunc NewHTTP2HTTPPlugin(_ PluginContext, options v1.ClientPluginOptions) (Plugin, error) {\n\topts := options.(*v1.HTTP2HTTPPluginOptions)\n\n\tlistener := NewProxyListener()\n\n\tp := &HTTP2HTTPPlugin{\n\t\topts: opts,\n\t\tl:    listener,\n\t}\n\n\trp := &httputil.ReverseProxy{\n\t\tRewrite: func(r *httputil.ProxyRequest) {\n\t\t\treq := r.Out\n\t\t\treq.URL.Scheme = \"http\"\n\t\t\treq.URL.Host = p.opts.LocalAddr\n\t\t\tif p.opts.HostHeaderRewrite != \"\" {\n\t\t\t\treq.Host = p.opts.HostHeaderRewrite\n\t\t\t}\n\t\t\tfor k, v := range p.opts.RequestHeaders.Set {\n\t\t\t\treq.Header.Set(k, v)\n\t\t\t}\n\t\t},\n\t\tBufferPool: pool.NewBuffer(32 * 1024),\n\t\tErrorLog:   stdlog.New(log.NewWriteLogger(log.WarnLevel, 2), \"\", 0),\n\t}\n\n\tp.s = &http.Server{\n\t\tHandler:           rp,\n\t\tReadHeaderTimeout: 60 * time.Second,\n\t}\n\n\tgo func() {\n\t\t_ = p.s.Serve(listener)\n\t}()\n\n\treturn p, nil\n}\n\nfunc (p *HTTP2HTTPPlugin) Handle(_ context.Context, connInfo *ConnectionInfo) {\n\twrapConn := netpkg.WrapReadWriteCloserToConn(connInfo.Conn, connInfo.UnderlyingConn)\n\t_ = p.l.PutConn(wrapConn)\n}\n\nfunc (p *HTTP2HTTPPlugin) Name() string {\n\treturn v1.PluginHTTP2HTTP\n}\n\nfunc (p *HTTP2HTTPPlugin) Close() error {\n\treturn p.s.Close()\n}\n"
  },
  {
    "path": "pkg/plugin/client/http2https.go",
    "content": "// Copyright 2019 fatedier, fatedier@gmail.com\n//\n// Licensed under the Apache License, Version 2.0 (the \"License\");\n// you may not use this file except in compliance with the License.\n// You may obtain a copy of the License at\n//\n//     http://www.apache.org/licenses/LICENSE-2.0\n//\n// Unless required by applicable law or agreed to in writing, software\n// distributed under the License is distributed on an \"AS IS\" BASIS,\n// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.\n// See the License for the specific language governing permissions and\n// limitations under the License.\n\n//go:build !frps\n\npackage client\n\nimport (\n\t\"context\"\n\t\"crypto/tls\"\n\tstdlog \"log\"\n\t\"net/http\"\n\t\"net/http/httputil\"\n\t\"time\"\n\n\t\"github.com/fatedier/golib/pool\"\n\n\tv1 \"github.com/fatedier/frp/pkg/config/v1\"\n\t\"github.com/fatedier/frp/pkg/util/log\"\n\tnetpkg \"github.com/fatedier/frp/pkg/util/net\"\n)\n\nfunc init() {\n\tRegister(v1.PluginHTTP2HTTPS, NewHTTP2HTTPSPlugin)\n}\n\ntype HTTP2HTTPSPlugin struct {\n\topts *v1.HTTP2HTTPSPluginOptions\n\n\tl *Listener\n\ts *http.Server\n}\n\nfunc NewHTTP2HTTPSPlugin(_ PluginContext, options v1.ClientPluginOptions) (Plugin, error) {\n\topts := options.(*v1.HTTP2HTTPSPluginOptions)\n\n\tlistener := NewProxyListener()\n\n\tp := &HTTP2HTTPSPlugin{\n\t\topts: opts,\n\t\tl:    listener,\n\t}\n\n\ttr := &http.Transport{\n\t\tTLSClientConfig: &tls.Config{InsecureSkipVerify: true},\n\t}\n\n\trp := &httputil.ReverseProxy{\n\t\tRewrite: func(r *httputil.ProxyRequest) {\n\t\t\tr.Out.Header[\"X-Forwarded-For\"] = r.In.Header[\"X-Forwarded-For\"]\n\t\t\tr.Out.Header[\"X-Forwarded-Host\"] = r.In.Header[\"X-Forwarded-Host\"]\n\t\t\tr.Out.Header[\"X-Forwarded-Proto\"] = r.In.Header[\"X-Forwarded-Proto\"]\n\t\t\treq := r.Out\n\t\t\treq.URL.Scheme = \"https\"\n\t\t\treq.URL.Host = p.opts.LocalAddr\n\t\t\tif p.opts.HostHeaderRewrite != \"\" {\n\t\t\t\treq.Host = p.opts.HostHeaderRewrite\n\t\t\t}\n\t\t\tfor k, v := range p.opts.RequestHeaders.Set {\n\t\t\t\treq.Header.Set(k, v)\n\t\t\t}\n\t\t},\n\t\tTransport:  tr,\n\t\tBufferPool: pool.NewBuffer(32 * 1024),\n\t\tErrorLog:   stdlog.New(log.NewWriteLogger(log.WarnLevel, 2), \"\", 0),\n\t}\n\n\tp.s = &http.Server{\n\t\tHandler:           rp,\n\t\tReadHeaderTimeout: 60 * time.Second,\n\t}\n\n\tgo func() {\n\t\t_ = p.s.Serve(listener)\n\t}()\n\n\treturn p, nil\n}\n\nfunc (p *HTTP2HTTPSPlugin) Handle(_ context.Context, connInfo *ConnectionInfo) {\n\twrapConn := netpkg.WrapReadWriteCloserToConn(connInfo.Conn, connInfo.UnderlyingConn)\n\t_ = p.l.PutConn(wrapConn)\n}\n\nfunc (p *HTTP2HTTPSPlugin) Name() string {\n\treturn v1.PluginHTTP2HTTPS\n}\n\nfunc (p *HTTP2HTTPSPlugin) Close() error {\n\treturn p.s.Close()\n}\n"
  },
  {
    "path": "pkg/plugin/client/http_proxy.go",
    "content": "// Copyright 2017 frp team\n//\n// Licensed under the Apache License, Version 2.0 (the \"License\");\n// you may not use this file except in compliance with the License.\n// You may obtain a copy of the License at\n//\n//     http://www.apache.org/licenses/LICENSE-2.0\n//\n// Unless required by applicable law or agreed to in writing, software\n// distributed under the License is distributed on an \"AS IS\" BASIS,\n// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.\n// See the License for the specific language governing permissions and\n// limitations under the License.\n\n//go:build !frps\n\npackage client\n\nimport (\n\t\"bufio\"\n\t\"context\"\n\t\"encoding/base64\"\n\t\"io\"\n\t\"net\"\n\t\"net/http\"\n\t\"strings\"\n\t\"time\"\n\n\tlibio \"github.com/fatedier/golib/io\"\n\tlibnet \"github.com/fatedier/golib/net\"\n\n\tv1 \"github.com/fatedier/frp/pkg/config/v1\"\n\tnetpkg \"github.com/fatedier/frp/pkg/util/net\"\n\t\"github.com/fatedier/frp/pkg/util/util\"\n)\n\nfunc init() {\n\tRegister(v1.PluginHTTPProxy, NewHTTPProxyPlugin)\n}\n\ntype HTTPProxy struct {\n\topts *v1.HTTPProxyPluginOptions\n\n\tl *Listener\n\ts *http.Server\n}\n\nfunc NewHTTPProxyPlugin(_ PluginContext, options v1.ClientPluginOptions) (Plugin, error) {\n\topts := options.(*v1.HTTPProxyPluginOptions)\n\tlistener := NewProxyListener()\n\n\thp := &HTTPProxy{\n\t\tl:    listener,\n\t\topts: opts,\n\t}\n\n\thp.s = &http.Server{\n\t\tHandler:           hp,\n\t\tReadHeaderTimeout: 60 * time.Second,\n\t}\n\n\tgo func() {\n\t\t_ = hp.s.Serve(listener)\n\t}()\n\treturn hp, nil\n}\n\nfunc (hp *HTTPProxy) Name() string {\n\treturn v1.PluginHTTPProxy\n}\n\nfunc (hp *HTTPProxy) Handle(_ context.Context, connInfo *ConnectionInfo) {\n\twrapConn := netpkg.WrapReadWriteCloserToConn(connInfo.Conn, connInfo.UnderlyingConn)\n\n\tsc, rd := libnet.NewSharedConn(wrapConn)\n\tfirstBytes := make([]byte, 7)\n\t_, err := rd.Read(firstBytes)\n\tif err != nil {\n\t\twrapConn.Close()\n\t\treturn\n\t}\n\n\tif strings.ToUpper(string(firstBytes)) == \"CONNECT\" {\n\t\tbufRd := bufio.NewReader(sc)\n\t\trequest, err := http.ReadRequest(bufRd)\n\t\tif err != nil {\n\t\t\twrapConn.Close()\n\t\t\treturn\n\t\t}\n\t\thp.handleConnectReq(request, libio.WrapReadWriteCloser(bufRd, wrapConn, wrapConn.Close))\n\t\treturn\n\t}\n\n\t_ = hp.l.PutConn(sc)\n}\n\nfunc (hp *HTTPProxy) Close() error {\n\thp.s.Close()\n\thp.l.Close()\n\treturn nil\n}\n\nfunc (hp *HTTPProxy) ServeHTTP(rw http.ResponseWriter, req *http.Request) {\n\tif ok := hp.Auth(req); !ok {\n\t\trw.Header().Set(\"Proxy-Authenticate\", \"Basic\")\n\t\trw.WriteHeader(http.StatusProxyAuthRequired)\n\t\treturn\n\t}\n\n\tif req.Method == http.MethodConnect {\n\t\t// deprecated\n\t\t// Connect request is handled in Handle function.\n\t\thp.ConnectHandler(rw, req)\n\t} else {\n\t\thp.HTTPHandler(rw, req)\n\t}\n}\n\nfunc (hp *HTTPProxy) HTTPHandler(rw http.ResponseWriter, req *http.Request) {\n\tremoveProxyHeaders(req)\n\n\tresp, err := http.DefaultTransport.RoundTrip(req)\n\tif err != nil {\n\t\thttp.Error(rw, err.Error(), http.StatusInternalServerError)\n\t\treturn\n\t}\n\tdefer resp.Body.Close()\n\n\tcopyHeaders(rw.Header(), resp.Header)\n\trw.WriteHeader(resp.StatusCode)\n\n\t_, err = io.Copy(rw, resp.Body)\n\tif err != nil && err != io.EOF {\n\t\treturn\n\t}\n}\n\n// deprecated\n// Hijack needs to SetReadDeadline on the Conn of the request, but if we use stream compression here,\n// we may always get i/o timeout error.\nfunc (hp *HTTPProxy) ConnectHandler(rw http.ResponseWriter, req *http.Request) {\n\thj, ok := rw.(http.Hijacker)\n\tif !ok {\n\t\trw.WriteHeader(http.StatusInternalServerError)\n\t\treturn\n\t}\n\n\tclient, _, err := hj.Hijack()\n\tif err != nil {\n\t\trw.WriteHeader(http.StatusInternalServerError)\n\t\treturn\n\t}\n\n\tremote, err := net.Dial(\"tcp\", req.URL.Host)\n\tif err != nil {\n\t\thttp.Error(rw, \"Failed\", http.StatusBadRequest)\n\t\tclient.Close()\n\t\treturn\n\t}\n\t_, _ = client.Write([]byte(\"HTTP/1.1 200 OK\\r\\n\\r\\n\"))\n\n\tgo libio.Join(remote, client)\n}\n\nfunc (hp *HTTPProxy) Auth(req *http.Request) bool {\n\tif hp.opts.HTTPUser == \"\" && hp.opts.HTTPPassword == \"\" {\n\t\treturn true\n\t}\n\n\ts := strings.SplitN(req.Header.Get(\"Proxy-Authorization\"), \" \", 2)\n\tif len(s) != 2 {\n\t\treturn false\n\t}\n\n\tb, err := base64.StdEncoding.DecodeString(s[1])\n\tif err != nil {\n\t\treturn false\n\t}\n\n\tpair := strings.SplitN(string(b), \":\", 2)\n\tif len(pair) != 2 {\n\t\treturn false\n\t}\n\n\tif !util.ConstantTimeEqString(pair[0], hp.opts.HTTPUser) ||\n\t\t!util.ConstantTimeEqString(pair[1], hp.opts.HTTPPassword) {\n\t\ttime.Sleep(200 * time.Millisecond)\n\t\treturn false\n\t}\n\treturn true\n}\n\nfunc (hp *HTTPProxy) handleConnectReq(req *http.Request, rwc io.ReadWriteCloser) {\n\tdefer rwc.Close()\n\tif ok := hp.Auth(req); !ok {\n\t\tres := getBadResponse()\n\t\t_ = res.Write(rwc)\n\t\tif res.Body != nil {\n\t\t\tres.Body.Close()\n\t\t}\n\t\treturn\n\t}\n\n\tremote, err := net.Dial(\"tcp\", req.URL.Host)\n\tif err != nil {\n\t\tres := &http.Response{\n\t\t\tStatusCode: 400,\n\t\t\tProto:      \"HTTP/1.1\",\n\t\t\tProtoMajor: 1,\n\t\t\tProtoMinor: 1,\n\t\t}\n\t\t_ = res.Write(rwc)\n\t\treturn\n\t}\n\t_, _ = rwc.Write([]byte(\"HTTP/1.1 200 OK\\r\\n\\r\\n\"))\n\n\tlibio.Join(remote, rwc)\n}\n\nfunc copyHeaders(dst, src http.Header) {\n\tfor key, values := range src {\n\t\tfor _, value := range values {\n\t\t\tdst.Add(key, value)\n\t\t}\n\t}\n}\n\nfunc removeProxyHeaders(req *http.Request) {\n\treq.RequestURI = \"\"\n\treq.Header.Del(\"Proxy-Connection\")\n\treq.Header.Del(\"Connection\")\n\treq.Header.Del(\"Proxy-Authenticate\")\n\treq.Header.Del(\"Proxy-Authorization\")\n\treq.Header.Del(\"TE\")\n\treq.Header.Del(\"Trailers\")\n\treq.Header.Del(\"Transfer-Encoding\")\n\treq.Header.Del(\"Upgrade\")\n}\n\nfunc getBadResponse() *http.Response {\n\theader := make(map[string][]string)\n\theader[\"Proxy-Authenticate\"] = []string{\"Basic\"}\n\theader[\"Connection\"] = []string{\"close\"}\n\tres := &http.Response{\n\t\tStatus:     \"407 Not authorized\",\n\t\tStatusCode: 407,\n\t\tProto:      \"HTTP/1.1\",\n\t\tProtoMajor: 1,\n\t\tProtoMinor: 1,\n\t\tHeader:     header,\n\t}\n\treturn res\n}\n"
  },
  {
    "path": "pkg/plugin/client/https2http.go",
    "content": "// Copyright 2019 fatedier, fatedier@gmail.com\n//\n// Licensed under the Apache License, Version 2.0 (the \"License\");\n// you may not use this file except in compliance with the License.\n// You may obtain a copy of the License at\n//\n//     http://www.apache.org/licenses/LICENSE-2.0\n//\n// Unless required by applicable law or agreed to in writing, software\n// distributed under the License is distributed on an \"AS IS\" BASIS,\n// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.\n// See the License for the specific language governing permissions and\n// limitations under the License.\n\n//go:build !frps\n\npackage client\n\nimport (\n\t\"context\"\n\t\"crypto/tls\"\n\t\"fmt\"\n\tstdlog \"log\"\n\t\"net/http\"\n\t\"net/http/httputil\"\n\t\"time\"\n\n\t\"github.com/fatedier/golib/pool\"\n\t\"github.com/samber/lo\"\n\n\tv1 \"github.com/fatedier/frp/pkg/config/v1\"\n\t\"github.com/fatedier/frp/pkg/transport\"\n\thttppkg \"github.com/fatedier/frp/pkg/util/http\"\n\t\"github.com/fatedier/frp/pkg/util/log\"\n\tnetpkg \"github.com/fatedier/frp/pkg/util/net\"\n)\n\nfunc init() {\n\tRegister(v1.PluginHTTPS2HTTP, NewHTTPS2HTTPPlugin)\n}\n\ntype HTTPS2HTTPPlugin struct {\n\topts *v1.HTTPS2HTTPPluginOptions\n\n\tl *Listener\n\ts *http.Server\n}\n\nfunc NewHTTPS2HTTPPlugin(_ PluginContext, options v1.ClientPluginOptions) (Plugin, error) {\n\topts := options.(*v1.HTTPS2HTTPPluginOptions)\n\tlistener := NewProxyListener()\n\n\tp := &HTTPS2HTTPPlugin{\n\t\topts: opts,\n\t\tl:    listener,\n\t}\n\n\trp := &httputil.ReverseProxy{\n\t\tRewrite: func(r *httputil.ProxyRequest) {\n\t\t\tr.Out.Header[\"X-Forwarded-For\"] = r.In.Header[\"X-Forwarded-For\"]\n\t\t\tr.SetXForwarded()\n\t\t\treq := r.Out\n\t\t\treq.URL.Scheme = \"http\"\n\t\t\treq.URL.Host = p.opts.LocalAddr\n\t\t\tif p.opts.HostHeaderRewrite != \"\" {\n\t\t\t\treq.Host = p.opts.HostHeaderRewrite\n\t\t\t}\n\t\t\tfor k, v := range p.opts.RequestHeaders.Set {\n\t\t\t\treq.Header.Set(k, v)\n\t\t\t}\n\t\t},\n\t\tBufferPool: pool.NewBuffer(32 * 1024),\n\t\tErrorLog:   stdlog.New(log.NewWriteLogger(log.WarnLevel, 2), \"\", 0),\n\t}\n\thandler := http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {\n\t\tif r.TLS != nil {\n\t\t\ttlsServerName, _ := httppkg.CanonicalHost(r.TLS.ServerName)\n\t\t\thost, _ := httppkg.CanonicalHost(r.Host)\n\t\t\tif tlsServerName != \"\" && tlsServerName != host {\n\t\t\t\tw.WriteHeader(http.StatusMisdirectedRequest)\n\t\t\t\treturn\n\t\t\t}\n\t\t}\n\t\trp.ServeHTTP(w, r)\n\t})\n\n\ttlsConfig, err := transport.NewServerTLSConfig(p.opts.CrtPath, p.opts.KeyPath, \"\")\n\tif err != nil {\n\t\treturn nil, fmt.Errorf(\"gen TLS config error: %v\", err)\n\t}\n\n\tp.s = &http.Server{\n\t\tHandler:           handler,\n\t\tReadHeaderTimeout: 60 * time.Second,\n\t\tTLSConfig:         tlsConfig,\n\t}\n\tif !lo.FromPtr(opts.EnableHTTP2) {\n\t\tp.s.TLSNextProto = make(map[string]func(*http.Server, *tls.Conn, http.Handler))\n\t}\n\n\tgo func() {\n\t\t_ = p.s.ServeTLS(listener, \"\", \"\")\n\t}()\n\treturn p, nil\n}\n\nfunc (p *HTTPS2HTTPPlugin) Handle(_ context.Context, connInfo *ConnectionInfo) {\n\twrapConn := netpkg.WrapReadWriteCloserToConn(connInfo.Conn, connInfo.UnderlyingConn)\n\tif connInfo.SrcAddr != nil {\n\t\twrapConn.SetRemoteAddr(connInfo.SrcAddr)\n\t}\n\t_ = p.l.PutConn(wrapConn)\n}\n\nfunc (p *HTTPS2HTTPPlugin) Name() string {\n\treturn v1.PluginHTTPS2HTTP\n}\n\nfunc (p *HTTPS2HTTPPlugin) Close() error {\n\treturn p.s.Close()\n}\n"
  },
  {
    "path": "pkg/plugin/client/https2https.go",
    "content": "// Copyright 2019 fatedier, fatedier@gmail.com\n//\n// Licensed under the Apache License, Version 2.0 (the \"License\");\n// you may not use this file except in compliance with the License.\n// You may obtain a copy of the License at\n//\n//     http://www.apache.org/licenses/LICENSE-2.0\n//\n// Unless required by applicable law or agreed to in writing, software\n// distributed under the License is distributed on an \"AS IS\" BASIS,\n// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.\n// See the License for the specific language governing permissions and\n// limitations under the License.\n\n//go:build !frps\n\npackage client\n\nimport (\n\t\"context\"\n\t\"crypto/tls\"\n\t\"fmt\"\n\tstdlog \"log\"\n\t\"net/http\"\n\t\"net/http/httputil\"\n\t\"time\"\n\n\t\"github.com/fatedier/golib/pool\"\n\t\"github.com/samber/lo\"\n\n\tv1 \"github.com/fatedier/frp/pkg/config/v1\"\n\t\"github.com/fatedier/frp/pkg/transport\"\n\thttppkg \"github.com/fatedier/frp/pkg/util/http\"\n\t\"github.com/fatedier/frp/pkg/util/log\"\n\tnetpkg \"github.com/fatedier/frp/pkg/util/net\"\n)\n\nfunc init() {\n\tRegister(v1.PluginHTTPS2HTTPS, NewHTTPS2HTTPSPlugin)\n}\n\ntype HTTPS2HTTPSPlugin struct {\n\topts *v1.HTTPS2HTTPSPluginOptions\n\n\tl *Listener\n\ts *http.Server\n}\n\nfunc NewHTTPS2HTTPSPlugin(_ PluginContext, options v1.ClientPluginOptions) (Plugin, error) {\n\topts := options.(*v1.HTTPS2HTTPSPluginOptions)\n\n\tlistener := NewProxyListener()\n\n\tp := &HTTPS2HTTPSPlugin{\n\t\topts: opts,\n\t\tl:    listener,\n\t}\n\n\ttr := &http.Transport{\n\t\tTLSClientConfig: &tls.Config{InsecureSkipVerify: true},\n\t}\n\n\trp := &httputil.ReverseProxy{\n\t\tRewrite: func(r *httputil.ProxyRequest) {\n\t\t\tr.Out.Header[\"X-Forwarded-For\"] = r.In.Header[\"X-Forwarded-For\"]\n\t\t\tr.SetXForwarded()\n\t\t\treq := r.Out\n\t\t\treq.URL.Scheme = \"https\"\n\t\t\treq.URL.Host = p.opts.LocalAddr\n\t\t\tif p.opts.HostHeaderRewrite != \"\" {\n\t\t\t\treq.Host = p.opts.HostHeaderRewrite\n\t\t\t}\n\t\t\tfor k, v := range p.opts.RequestHeaders.Set {\n\t\t\t\treq.Header.Set(k, v)\n\t\t\t}\n\t\t},\n\t\tTransport:  tr,\n\t\tBufferPool: pool.NewBuffer(32 * 1024),\n\t\tErrorLog:   stdlog.New(log.NewWriteLogger(log.WarnLevel, 2), \"\", 0),\n\t}\n\thandler := http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {\n\t\tif r.TLS != nil {\n\t\t\ttlsServerName, _ := httppkg.CanonicalHost(r.TLS.ServerName)\n\t\t\thost, _ := httppkg.CanonicalHost(r.Host)\n\t\t\tif tlsServerName != \"\" && tlsServerName != host {\n\t\t\t\tw.WriteHeader(http.StatusMisdirectedRequest)\n\t\t\t\treturn\n\t\t\t}\n\t\t}\n\t\trp.ServeHTTP(w, r)\n\t})\n\n\ttlsConfig, err := transport.NewServerTLSConfig(p.opts.CrtPath, p.opts.KeyPath, \"\")\n\tif err != nil {\n\t\treturn nil, fmt.Errorf(\"gen TLS config error: %v\", err)\n\t}\n\n\tp.s = &http.Server{\n\t\tHandler:           handler,\n\t\tReadHeaderTimeout: 60 * time.Second,\n\t\tTLSConfig:         tlsConfig,\n\t}\n\tif !lo.FromPtr(opts.EnableHTTP2) {\n\t\tp.s.TLSNextProto = make(map[string]func(*http.Server, *tls.Conn, http.Handler))\n\t}\n\n\tgo func() {\n\t\t_ = p.s.ServeTLS(listener, \"\", \"\")\n\t}()\n\treturn p, nil\n}\n\nfunc (p *HTTPS2HTTPSPlugin) Handle(_ context.Context, connInfo *ConnectionInfo) {\n\twrapConn := netpkg.WrapReadWriteCloserToConn(connInfo.Conn, connInfo.UnderlyingConn)\n\tif connInfo.SrcAddr != nil {\n\t\twrapConn.SetRemoteAddr(connInfo.SrcAddr)\n\t}\n\t_ = p.l.PutConn(wrapConn)\n}\n\nfunc (p *HTTPS2HTTPSPlugin) Name() string {\n\treturn v1.PluginHTTPS2HTTPS\n}\n\nfunc (p *HTTPS2HTTPSPlugin) Close() error {\n\treturn p.s.Close()\n}\n"
  },
  {
    "path": "pkg/plugin/client/plugin.go",
    "content": "// Copyright 2017 fatedier, fatedier@gmail.com\n//\n// Licensed under the Apache License, Version 2.0 (the \"License\");\n// you may not use this file except in compliance with the License.\n// You may obtain a copy of the License at\n//\n//     http://www.apache.org/licenses/LICENSE-2.0\n//\n// Unless required by applicable law or agreed to in writing, software\n// distributed under the License is distributed on an \"AS IS\" BASIS,\n// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.\n// See the License for the specific language governing permissions and\n// limitations under the License.\n\npackage client\n\nimport (\n\t\"context\"\n\t\"fmt\"\n\t\"io\"\n\t\"net\"\n\t\"sync\"\n\n\t\"github.com/fatedier/golib/errors\"\n\tpp \"github.com/pires/go-proxyproto\"\n\n\tv1 \"github.com/fatedier/frp/pkg/config/v1\"\n\t\"github.com/fatedier/frp/pkg/vnet\"\n)\n\ntype PluginContext struct {\n\tName           string\n\tVnetController *vnet.Controller\n}\n\n// Creators is used for create plugins to handle connections.\nvar creators = make(map[string]CreatorFn)\n\ntype CreatorFn func(pluginCtx PluginContext, options v1.ClientPluginOptions) (Plugin, error)\n\nfunc Register(name string, fn CreatorFn) {\n\tif _, exist := creators[name]; exist {\n\t\tpanic(fmt.Sprintf(\"plugin [%s] is already registered\", name))\n\t}\n\tcreators[name] = fn\n}\n\nfunc Create(pluginName string, pluginCtx PluginContext, options v1.ClientPluginOptions) (p Plugin, err error) {\n\tif fn, ok := creators[pluginName]; ok {\n\t\tp, err = fn(pluginCtx, options)\n\t} else {\n\t\terr = fmt.Errorf(\"plugin [%s] is not registered\", pluginName)\n\t}\n\treturn\n}\n\ntype ConnectionInfo struct {\n\tConn           io.ReadWriteCloser\n\tUnderlyingConn net.Conn\n\n\tProxyProtocolHeader *pp.Header\n\tSrcAddr             net.Addr\n\tDstAddr             net.Addr\n}\n\ntype Plugin interface {\n\tName() string\n\n\tHandle(ctx context.Context, connInfo *ConnectionInfo)\n\tClose() error\n}\n\ntype Listener struct {\n\tconns  chan net.Conn\n\tclosed bool\n\tmu     sync.Mutex\n}\n\nfunc NewProxyListener() *Listener {\n\treturn &Listener{\n\t\tconns: make(chan net.Conn, 64),\n\t}\n}\n\nfunc (l *Listener) Accept() (net.Conn, error) {\n\tconn, ok := <-l.conns\n\tif !ok {\n\t\treturn nil, fmt.Errorf(\"listener closed\")\n\t}\n\treturn conn, nil\n}\n\nfunc (l *Listener) PutConn(conn net.Conn) error {\n\terr := errors.PanicToError(func() {\n\t\tl.conns <- conn\n\t})\n\treturn err\n}\n\nfunc (l *Listener) Close() error {\n\tl.mu.Lock()\n\tdefer l.mu.Unlock()\n\tif !l.closed {\n\t\tclose(l.conns)\n\t\tl.closed = true\n\t}\n\treturn nil\n}\n\nfunc (l *Listener) Addr() net.Addr {\n\treturn (*net.TCPAddr)(nil)\n}\n"
  },
  {
    "path": "pkg/plugin/client/socks5.go",
    "content": "// Copyright 2017 fatedier, fatedier@gmail.com\n//\n// Licensed under the Apache License, Version 2.0 (the \"License\");\n// you may not use this file except in compliance with the License.\n// You may obtain a copy of the License at\n//\n//     http://www.apache.org/licenses/LICENSE-2.0\n//\n// Unless required by applicable law or agreed to in writing, software\n// distributed under the License is distributed on an \"AS IS\" BASIS,\n// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.\n// See the License for the specific language governing permissions and\n// limitations under the License.\n\n//go:build !frps\n\npackage client\n\nimport (\n\t\"context\"\n\t\"io\"\n\t\"log\"\n\n\tgosocks5 \"github.com/armon/go-socks5\"\n\n\tv1 \"github.com/fatedier/frp/pkg/config/v1\"\n\tnetpkg \"github.com/fatedier/frp/pkg/util/net\"\n)\n\nfunc init() {\n\tRegister(v1.PluginSocks5, NewSocks5Plugin)\n}\n\ntype Socks5Plugin struct {\n\tServer *gosocks5.Server\n}\n\nfunc NewSocks5Plugin(_ PluginContext, options v1.ClientPluginOptions) (p Plugin, err error) {\n\topts := options.(*v1.Socks5PluginOptions)\n\n\tcfg := &gosocks5.Config{\n\t\tLogger: log.New(io.Discard, \"\", log.LstdFlags),\n\t}\n\tif opts.Username != \"\" || opts.Password != \"\" {\n\t\tcfg.Credentials = gosocks5.StaticCredentials(map[string]string{opts.Username: opts.Password})\n\t}\n\tsp := &Socks5Plugin{}\n\tsp.Server, err = gosocks5.New(cfg)\n\tp = sp\n\treturn\n}\n\nfunc (sp *Socks5Plugin) Handle(_ context.Context, connInfo *ConnectionInfo) {\n\tdefer connInfo.Conn.Close()\n\twrapConn := netpkg.WrapReadWriteCloserToConn(connInfo.Conn, connInfo.UnderlyingConn)\n\t_ = sp.Server.ServeConn(wrapConn)\n}\n\nfunc (sp *Socks5Plugin) Name() string {\n\treturn v1.PluginSocks5\n}\n\nfunc (sp *Socks5Plugin) Close() error {\n\treturn nil\n}\n"
  },
  {
    "path": "pkg/plugin/client/static_file.go",
    "content": "// Copyright 2018 fatedier, fatedier@gmail.com\n//\n// Licensed under the Apache License, Version 2.0 (the \"License\");\n// you may not use this file except in compliance with the License.\n// You may obtain a copy of the License at\n//\n//     http://www.apache.org/licenses/LICENSE-2.0\n//\n// Unless required by applicable law or agreed to in writing, software\n// distributed under the License is distributed on an \"AS IS\" BASIS,\n// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.\n// See the License for the specific language governing permissions and\n// limitations under the License.\n\n//go:build !frps\n\npackage client\n\nimport (\n\t\"context\"\n\t\"net/http\"\n\t\"time\"\n\n\t\"github.com/gorilla/mux\"\n\n\tv1 \"github.com/fatedier/frp/pkg/config/v1\"\n\tnetpkg \"github.com/fatedier/frp/pkg/util/net\"\n)\n\nfunc init() {\n\tRegister(v1.PluginStaticFile, NewStaticFilePlugin)\n}\n\ntype StaticFilePlugin struct {\n\topts *v1.StaticFilePluginOptions\n\n\tl *Listener\n\ts *http.Server\n}\n\nfunc NewStaticFilePlugin(_ PluginContext, options v1.ClientPluginOptions) (Plugin, error) {\n\topts := options.(*v1.StaticFilePluginOptions)\n\n\tlistener := NewProxyListener()\n\n\tsp := &StaticFilePlugin{\n\t\topts: opts,\n\n\t\tl: listener,\n\t}\n\tvar prefix string\n\tif opts.StripPrefix != \"\" {\n\t\tprefix = \"/\" + opts.StripPrefix + \"/\"\n\t} else {\n\t\tprefix = \"/\"\n\t}\n\n\trouter := mux.NewRouter()\n\trouter.Use(netpkg.NewHTTPAuthMiddleware(opts.HTTPUser, opts.HTTPPassword).SetAuthFailDelay(200 * time.Millisecond).Middleware)\n\trouter.PathPrefix(prefix).Handler(netpkg.MakeHTTPGzipHandler(http.StripPrefix(prefix, http.FileServer(http.Dir(opts.LocalPath))))).Methods(\"GET\")\n\tsp.s = &http.Server{\n\t\tHandler:           router,\n\t\tReadHeaderTimeout: 60 * time.Second,\n\t}\n\tgo func() {\n\t\t_ = sp.s.Serve(listener)\n\t}()\n\treturn sp, nil\n}\n\nfunc (sp *StaticFilePlugin) Handle(_ context.Context, connInfo *ConnectionInfo) {\n\twrapConn := netpkg.WrapReadWriteCloserToConn(connInfo.Conn, connInfo.UnderlyingConn)\n\t_ = sp.l.PutConn(wrapConn)\n}\n\nfunc (sp *StaticFilePlugin) Name() string {\n\treturn v1.PluginStaticFile\n}\n\nfunc (sp *StaticFilePlugin) Close() error {\n\tsp.s.Close()\n\tsp.l.Close()\n\treturn nil\n}\n"
  },
  {
    "path": "pkg/plugin/client/tls2raw.go",
    "content": "// Copyright 2024 The frp Authors\n//\n// Licensed under the Apache License, Version 2.0 (the \"License\");\n// you may not use this file except in compliance with the License.\n// You may obtain a copy of the License at\n//\n//     http://www.apache.org/licenses/LICENSE-2.0\n//\n// Unless required by applicable law or agreed to in writing, software\n// distributed under the License is distributed on an \"AS IS\" BASIS,\n// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.\n// See the License for the specific language governing permissions and\n// limitations under the License.\n\n//go:build !frps\n\npackage client\n\nimport (\n\t\"context\"\n\t\"crypto/tls\"\n\t\"net\"\n\n\tlibio \"github.com/fatedier/golib/io\"\n\n\tv1 \"github.com/fatedier/frp/pkg/config/v1\"\n\t\"github.com/fatedier/frp/pkg/transport\"\n\tnetpkg \"github.com/fatedier/frp/pkg/util/net\"\n\t\"github.com/fatedier/frp/pkg/util/xlog\"\n)\n\nfunc init() {\n\tRegister(v1.PluginTLS2Raw, NewTLS2RawPlugin)\n}\n\ntype TLS2RawPlugin struct {\n\topts *v1.TLS2RawPluginOptions\n\n\ttlsConfig *tls.Config\n}\n\nfunc NewTLS2RawPlugin(_ PluginContext, options v1.ClientPluginOptions) (Plugin, error) {\n\topts := options.(*v1.TLS2RawPluginOptions)\n\n\tp := &TLS2RawPlugin{\n\t\topts: opts,\n\t}\n\n\ttlsConfig, err := transport.NewServerTLSConfig(p.opts.CrtPath, p.opts.KeyPath, \"\")\n\tif err != nil {\n\t\treturn nil, err\n\t}\n\tp.tlsConfig = tlsConfig\n\treturn p, nil\n}\n\nfunc (p *TLS2RawPlugin) Handle(ctx context.Context, connInfo *ConnectionInfo) {\n\txl := xlog.FromContextSafe(ctx)\n\n\twrapConn := netpkg.WrapReadWriteCloserToConn(connInfo.Conn, connInfo.UnderlyingConn)\n\ttlsConn := tls.Server(wrapConn, p.tlsConfig)\n\n\tif err := tlsConn.Handshake(); err != nil {\n\t\txl.Warnf(\"tls handshake error: %v\", err)\n\t\ttlsConn.Close()\n\t\treturn\n\t}\n\trawConn, err := net.Dial(\"tcp\", p.opts.LocalAddr)\n\tif err != nil {\n\t\txl.Warnf(\"dial to local addr error: %v\", err)\n\t\ttlsConn.Close()\n\t\treturn\n\t}\n\n\tlibio.Join(tlsConn, rawConn)\n}\n\nfunc (p *TLS2RawPlugin) Name() string {\n\treturn v1.PluginTLS2Raw\n}\n\nfunc (p *TLS2RawPlugin) Close() error {\n\treturn nil\n}\n"
  },
  {
    "path": "pkg/plugin/client/unix_domain_socket.go",
    "content": "// Copyright 2017 fatedier, fatedier@gmail.com\n//\n// Licensed under the Apache License, Version 2.0 (the \"License\");\n// you may not use this file except in compliance with the License.\n// You may obtain a copy of the License at\n//\n//     http://www.apache.org/licenses/LICENSE-2.0\n//\n// Unless required by applicable law or agreed to in writing, software\n// distributed under the License is distributed on an \"AS IS\" BASIS,\n// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.\n// See the License for the specific language governing permissions and\n// limitations under the License.\n\n//go:build !frps\n\npackage client\n\nimport (\n\t\"context\"\n\t\"net\"\n\n\tlibio \"github.com/fatedier/golib/io\"\n\n\tv1 \"github.com/fatedier/frp/pkg/config/v1\"\n\t\"github.com/fatedier/frp/pkg/util/xlog\"\n)\n\nfunc init() {\n\tRegister(v1.PluginUnixDomainSocket, NewUnixDomainSocketPlugin)\n}\n\ntype UnixDomainSocketPlugin struct {\n\tUnixAddr *net.UnixAddr\n}\n\nfunc NewUnixDomainSocketPlugin(_ PluginContext, options v1.ClientPluginOptions) (p Plugin, err error) {\n\topts := options.(*v1.UnixDomainSocketPluginOptions)\n\n\tunixAddr, errRet := net.ResolveUnixAddr(\"unix\", opts.UnixPath)\n\tif errRet != nil {\n\t\terr = errRet\n\t\treturn\n\t}\n\n\tp = &UnixDomainSocketPlugin{\n\t\tUnixAddr: unixAddr,\n\t}\n\treturn\n}\n\nfunc (uds *UnixDomainSocketPlugin) Handle(ctx context.Context, connInfo *ConnectionInfo) {\n\txl := xlog.FromContextSafe(ctx)\n\tlocalConn, err := net.DialUnix(\"unix\", nil, uds.UnixAddr)\n\tif err != nil {\n\t\txl.Warnf(\"dial to uds %s error: %v\", uds.UnixAddr, err)\n\t\tconnInfo.Conn.Close()\n\t\treturn\n\t}\n\tif connInfo.ProxyProtocolHeader != nil {\n\t\tif _, err := connInfo.ProxyProtocolHeader.WriteTo(localConn); err != nil {\n\t\t\tlocalConn.Close()\n\t\t\tconnInfo.Conn.Close()\n\t\t\treturn\n\t\t}\n\t}\n\n\tlibio.Join(localConn, connInfo.Conn)\n}\n\nfunc (uds *UnixDomainSocketPlugin) Name() string {\n\treturn v1.PluginUnixDomainSocket\n}\n\nfunc (uds *UnixDomainSocketPlugin) Close() error {\n\treturn nil\n}\n"
  },
  {
    "path": "pkg/plugin/client/virtual_net.go",
    "content": "// Copyright 2025 The frp Authors\n//\n// Licensed under the Apache License, Version 2.0 (the \"License\");\n// you may not use this file except in compliance with the License.\n// You may obtain a copy of the License at\n//\n//     http://www.apache.org/licenses/LICENSE-2.0\n//\n// Unless required by applicable law or agreed to in writing, software\n// distributed under the License is distributed on an \"AS IS\" BASIS,\n// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.\n// See the License for the specific language governing permissions and\n// limitations under the License.\n\n//go:build !frps\n\npackage client\n\nimport (\n\t\"context\"\n\t\"io\"\n\t\"sync\"\n\n\tv1 \"github.com/fatedier/frp/pkg/config/v1\"\n)\n\nfunc init() {\n\tRegister(v1.PluginVirtualNet, NewVirtualNetPlugin)\n}\n\ntype VirtualNetPlugin struct {\n\tpluginCtx PluginContext\n\topts      *v1.VirtualNetPluginOptions\n\tmu        sync.Mutex\n\tconns     map[io.ReadWriteCloser]struct{}\n}\n\nfunc NewVirtualNetPlugin(pluginCtx PluginContext, options v1.ClientPluginOptions) (Plugin, error) {\n\topts := options.(*v1.VirtualNetPluginOptions)\n\n\tp := &VirtualNetPlugin{\n\t\tpluginCtx: pluginCtx,\n\t\topts:      opts,\n\t}\n\treturn p, nil\n}\n\nfunc (p *VirtualNetPlugin) Handle(ctx context.Context, connInfo *ConnectionInfo) {\n\t// Verify if virtual network controller is available\n\tif p.pluginCtx.VnetController == nil {\n\t\treturn\n\t}\n\n\t// Add the connection before starting the read loop to avoid race condition\n\t// where RemoveConn might be called before the connection is added.\n\tp.mu.Lock()\n\tif p.conns == nil {\n\t\tp.conns = make(map[io.ReadWriteCloser]struct{})\n\t}\n\tp.conns[connInfo.Conn] = struct{}{}\n\tp.mu.Unlock()\n\n\t// Register the connection with the controller and pass the cleanup function\n\tp.pluginCtx.VnetController.StartServerConnReadLoop(ctx, connInfo.Conn, func() {\n\t\tp.RemoveConn(connInfo.Conn)\n\t})\n}\n\nfunc (p *VirtualNetPlugin) RemoveConn(conn io.ReadWriteCloser) {\n\tp.mu.Lock()\n\tdefer p.mu.Unlock()\n\t// Check if the map exists, as Close might have set it to nil concurrently\n\tif p.conns != nil {\n\t\tdelete(p.conns, conn)\n\t}\n}\n\nfunc (p *VirtualNetPlugin) Name() string {\n\treturn v1.PluginVirtualNet\n}\n\nfunc (p *VirtualNetPlugin) Close() error {\n\tp.mu.Lock()\n\tdefer p.mu.Unlock()\n\n\t// Close any remaining connections\n\tfor conn := range p.conns {\n\t\t_ = conn.Close()\n\t}\n\tp.conns = nil\n\treturn nil\n}\n"
  },
  {
    "path": "pkg/plugin/server/http.go",
    "content": "// Copyright 2019 fatedier, fatedier@gmail.com\n//\n// Licensed under the Apache License, Version 2.0 (the \"License\");\n// you may not use this file except in compliance with the License.\n// You may obtain a copy of the License at\n//\n//     http://www.apache.org/licenses/LICENSE-2.0\n//\n// Unless required by applicable law or agreed to in writing, software\n// distributed under the License is distributed on an \"AS IS\" BASIS,\n// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.\n// See the License for the specific language governing permissions and\n// limitations under the License.\n\npackage server\n\nimport (\n\t\"bytes\"\n\t\"context\"\n\t\"crypto/tls\"\n\t\"encoding/json\"\n\t\"fmt\"\n\t\"io\"\n\t\"net/http\"\n\t\"net/url\"\n\t\"reflect\"\n\t\"slices\"\n\t\"strings\"\n\n\tv1 \"github.com/fatedier/frp/pkg/config/v1\"\n)\n\ntype httpPlugin struct {\n\toptions v1.HTTPPluginOptions\n\n\turl    string\n\tclient *http.Client\n}\n\nfunc NewHTTPPluginOptions(options v1.HTTPPluginOptions) Plugin {\n\turl := fmt.Sprintf(\"%s%s\", options.Addr, options.Path)\n\n\tvar client *http.Client\n\tif strings.HasPrefix(url, \"https://\") {\n\t\ttr := &http.Transport{\n\t\t\tTLSClientConfig: &tls.Config{InsecureSkipVerify: !options.TLSVerify},\n\t\t}\n\t\tclient = &http.Client{Transport: tr}\n\t} else {\n\t\tclient = &http.Client{}\n\t}\n\n\tif !strings.HasPrefix(url, \"https://\") && !strings.HasPrefix(url, \"http://\") {\n\t\turl = \"http://\" + url\n\t}\n\treturn &httpPlugin{\n\t\toptions: options,\n\t\turl:     url,\n\t\tclient:  client,\n\t}\n}\n\nfunc (p *httpPlugin) Name() string {\n\treturn p.options.Name\n}\n\nfunc (p *httpPlugin) IsSupport(op string) bool {\n\treturn slices.Contains(p.options.Ops, op)\n}\n\nfunc (p *httpPlugin) Handle(ctx context.Context, op string, content any) (*Response, any, error) {\n\tr := &Request{\n\t\tVersion: APIVersion,\n\t\tOp:      op,\n\t\tContent: content,\n\t}\n\tvar res Response\n\tres.Content = reflect.New(reflect.TypeOf(content)).Interface()\n\tif err := p.do(ctx, r, &res); err != nil {\n\t\treturn nil, nil, err\n\t}\n\treturn &res, res.Content, nil\n}\n\nfunc (p *httpPlugin) do(ctx context.Context, r *Request, res *Response) error {\n\tbuf, err := json.Marshal(r)\n\tif err != nil {\n\t\treturn err\n\t}\n\tv := url.Values{}\n\tv.Set(\"version\", r.Version)\n\tv.Set(\"op\", r.Op)\n\treq, err := http.NewRequest(\"POST\", p.url+\"?\"+v.Encode(), bytes.NewReader(buf))\n\tif err != nil {\n\t\treturn err\n\t}\n\treq = req.WithContext(ctx)\n\treq.Header.Set(\"X-Frp-Reqid\", GetReqidFromContext(ctx))\n\treq.Header.Set(\"Content-Type\", \"application/json\")\n\tresp, err := p.client.Do(req)\n\tif err != nil {\n\t\treturn err\n\t}\n\tdefer resp.Body.Close()\n\n\tif resp.StatusCode != http.StatusOK {\n\t\treturn fmt.Errorf(\"do http request error code: %d\", resp.StatusCode)\n\t}\n\tbuf, err = io.ReadAll(resp.Body)\n\tif err != nil {\n\t\treturn err\n\t}\n\treturn json.Unmarshal(buf, res)\n}\n"
  },
  {
    "path": "pkg/plugin/server/manager.go",
    "content": "// Copyright 2019 fatedier, fatedier@gmail.com\n//\n// Licensed under the Apache License, Version 2.0 (the \"License\");\n// you may not use this file except in compliance with the License.\n// You may obtain a copy of the License at\n//\n//     http://www.apache.org/licenses/LICENSE-2.0\n//\n// Unless required by applicable law or agreed to in writing, software\n// distributed under the License is distributed on an \"AS IS\" BASIS,\n// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.\n// See the License for the specific language governing permissions and\n// limitations under the License.\n\npackage server\n\nimport (\n\t\"context\"\n\t\"errors\"\n\t\"fmt\"\n\t\"strings\"\n\n\t\"github.com/fatedier/frp/pkg/util/util\"\n\t\"github.com/fatedier/frp/pkg/util/xlog\"\n)\n\ntype Manager struct {\n\tloginPlugins       []Plugin\n\tnewProxyPlugins    []Plugin\n\tcloseProxyPlugins  []Plugin\n\tpingPlugins        []Plugin\n\tnewWorkConnPlugins []Plugin\n\tnewUserConnPlugins []Plugin\n}\n\nfunc NewManager() *Manager {\n\treturn &Manager{\n\t\tloginPlugins:       make([]Plugin, 0),\n\t\tnewProxyPlugins:    make([]Plugin, 0),\n\t\tcloseProxyPlugins:  make([]Plugin, 0),\n\t\tpingPlugins:        make([]Plugin, 0),\n\t\tnewWorkConnPlugins: make([]Plugin, 0),\n\t\tnewUserConnPlugins: make([]Plugin, 0),\n\t}\n}\n\nfunc (m *Manager) Register(p Plugin) {\n\tif p.IsSupport(OpLogin) {\n\t\tm.loginPlugins = append(m.loginPlugins, p)\n\t}\n\tif p.IsSupport(OpNewProxy) {\n\t\tm.newProxyPlugins = append(m.newProxyPlugins, p)\n\t}\n\tif p.IsSupport(OpCloseProxy) {\n\t\tm.closeProxyPlugins = append(m.closeProxyPlugins, p)\n\t}\n\tif p.IsSupport(OpPing) {\n\t\tm.pingPlugins = append(m.pingPlugins, p)\n\t}\n\tif p.IsSupport(OpNewWorkConn) {\n\t\tm.newWorkConnPlugins = append(m.newWorkConnPlugins, p)\n\t}\n\tif p.IsSupport(OpNewUserConn) {\n\t\tm.newUserConnPlugins = append(m.newUserConnPlugins, p)\n\t}\n}\n\nfunc (m *Manager) Login(content *LoginContent) (*LoginContent, error) {\n\tif len(m.loginPlugins) == 0 {\n\t\treturn content, nil\n\t}\n\n\tvar (\n\t\tres = &Response{\n\t\t\tReject:   false,\n\t\t\tUnchange: true,\n\t\t}\n\t\tretContent any\n\t\terr        error\n\t)\n\treqid, _ := util.RandID()\n\txl := xlog.New().AppendPrefix(\"reqid: \" + reqid)\n\tctx := xlog.NewContext(context.Background(), xl)\n\tctx = NewReqidContext(ctx, reqid)\n\n\tfor _, p := range m.loginPlugins {\n\t\tres, retContent, err = p.Handle(ctx, OpLogin, *content)\n\t\tif err != nil {\n\t\t\txl.Warnf(\"send Login request to plugin [%s] error: %v\", p.Name(), err)\n\t\t\treturn nil, errors.New(\"send Login request to plugin error\")\n\t\t}\n\t\tif res.Reject {\n\t\t\treturn nil, fmt.Errorf(\"%s\", res.RejectReason)\n\t\t}\n\t\tif !res.Unchange {\n\t\t\tcontent = retContent.(*LoginContent)\n\t\t}\n\t}\n\treturn content, nil\n}\n\nfunc (m *Manager) NewProxy(content *NewProxyContent) (*NewProxyContent, error) {\n\tif len(m.newProxyPlugins) == 0 {\n\t\treturn content, nil\n\t}\n\n\tvar (\n\t\tres = &Response{\n\t\t\tReject:   false,\n\t\t\tUnchange: true,\n\t\t}\n\t\tretContent any\n\t\terr        error\n\t)\n\treqid, _ := util.RandID()\n\txl := xlog.New().AppendPrefix(\"reqid: \" + reqid)\n\tctx := xlog.NewContext(context.Background(), xl)\n\tctx = NewReqidContext(ctx, reqid)\n\n\tfor _, p := range m.newProxyPlugins {\n\t\tres, retContent, err = p.Handle(ctx, OpNewProxy, *content)\n\t\tif err != nil {\n\t\t\txl.Warnf(\"send NewProxy request to plugin [%s] error: %v\", p.Name(), err)\n\t\t\treturn nil, errors.New(\"send NewProxy request to plugin error\")\n\t\t}\n\t\tif res.Reject {\n\t\t\treturn nil, fmt.Errorf(\"%s\", res.RejectReason)\n\t\t}\n\t\tif !res.Unchange {\n\t\t\tcontent = retContent.(*NewProxyContent)\n\t\t}\n\t}\n\treturn content, nil\n}\n\nfunc (m *Manager) CloseProxy(content *CloseProxyContent) error {\n\tif len(m.closeProxyPlugins) == 0 {\n\t\treturn nil\n\t}\n\n\terrs := make([]string, 0)\n\treqid, _ := util.RandID()\n\txl := xlog.New().AppendPrefix(\"reqid: \" + reqid)\n\tctx := xlog.NewContext(context.Background(), xl)\n\tctx = NewReqidContext(ctx, reqid)\n\n\tfor _, p := range m.closeProxyPlugins {\n\t\t_, _, err := p.Handle(ctx, OpCloseProxy, *content)\n\t\tif err != nil {\n\t\t\txl.Warnf(\"send CloseProxy request to plugin [%s] error: %v\", p.Name(), err)\n\t\t\terrs = append(errs, fmt.Sprintf(\"[%s]: %v\", p.Name(), err))\n\t\t}\n\t}\n\n\tif len(errs) > 0 {\n\t\treturn fmt.Errorf(\"send CloseProxy request to plugin errors: %s\", strings.Join(errs, \"; \"))\n\t}\n\treturn nil\n}\n\nfunc (m *Manager) Ping(content *PingContent) (*PingContent, error) {\n\tif len(m.pingPlugins) == 0 {\n\t\treturn content, nil\n\t}\n\n\tvar (\n\t\tres = &Response{\n\t\t\tReject:   false,\n\t\t\tUnchange: true,\n\t\t}\n\t\tretContent any\n\t\terr        error\n\t)\n\treqid, _ := util.RandID()\n\txl := xlog.New().AppendPrefix(\"reqid: \" + reqid)\n\tctx := xlog.NewContext(context.Background(), xl)\n\tctx = NewReqidContext(ctx, reqid)\n\n\tfor _, p := range m.pingPlugins {\n\t\tres, retContent, err = p.Handle(ctx, OpPing, *content)\n\t\tif err != nil {\n\t\t\txl.Warnf(\"send Ping request to plugin [%s] error: %v\", p.Name(), err)\n\t\t\treturn nil, errors.New(\"send Ping request to plugin error\")\n\t\t}\n\t\tif res.Reject {\n\t\t\treturn nil, fmt.Errorf(\"%s\", res.RejectReason)\n\t\t}\n\t\tif !res.Unchange {\n\t\t\tcontent = retContent.(*PingContent)\n\t\t}\n\t}\n\treturn content, nil\n}\n\nfunc (m *Manager) NewWorkConn(content *NewWorkConnContent) (*NewWorkConnContent, error) {\n\tif len(m.newWorkConnPlugins) == 0 {\n\t\treturn content, nil\n\t}\n\n\tvar (\n\t\tres = &Response{\n\t\t\tReject:   false,\n\t\t\tUnchange: true,\n\t\t}\n\t\tretContent any\n\t\terr        error\n\t)\n\treqid, _ := util.RandID()\n\txl := xlog.New().AppendPrefix(\"reqid: \" + reqid)\n\tctx := xlog.NewContext(context.Background(), xl)\n\tctx = NewReqidContext(ctx, reqid)\n\n\tfor _, p := range m.newWorkConnPlugins {\n\t\tres, retContent, err = p.Handle(ctx, OpNewWorkConn, *content)\n\t\tif err != nil {\n\t\t\txl.Warnf(\"send NewWorkConn request to plugin [%s] error: %v\", p.Name(), err)\n\t\t\treturn nil, errors.New(\"send NewWorkConn request to plugin error\")\n\t\t}\n\t\tif res.Reject {\n\t\t\treturn nil, fmt.Errorf(\"%s\", res.RejectReason)\n\t\t}\n\t\tif !res.Unchange {\n\t\t\tcontent = retContent.(*NewWorkConnContent)\n\t\t}\n\t}\n\treturn content, nil\n}\n\nfunc (m *Manager) NewUserConn(content *NewUserConnContent) (*NewUserConnContent, error) {\n\tif len(m.newUserConnPlugins) == 0 {\n\t\treturn content, nil\n\t}\n\n\tvar (\n\t\tres = &Response{\n\t\t\tReject:   false,\n\t\t\tUnchange: true,\n\t\t}\n\t\tretContent any\n\t\terr        error\n\t)\n\treqid, _ := util.RandID()\n\txl := xlog.New().AppendPrefix(\"reqid: \" + reqid)\n\tctx := xlog.NewContext(context.Background(), xl)\n\tctx = NewReqidContext(ctx, reqid)\n\n\tfor _, p := range m.newUserConnPlugins {\n\t\tres, retContent, err = p.Handle(ctx, OpNewUserConn, *content)\n\t\tif err != nil {\n\t\t\txl.Infof(\"send NewUserConn request to plugin [%s] error: %v\", p.Name(), err)\n\t\t\treturn nil, errors.New(\"send NewUserConn request to plugin error\")\n\t\t}\n\t\tif res.Reject {\n\t\t\treturn nil, fmt.Errorf(\"%s\", res.RejectReason)\n\t\t}\n\t\tif !res.Unchange {\n\t\t\tcontent = retContent.(*NewUserConnContent)\n\t\t}\n\t}\n\treturn content, nil\n}\n"
  },
  {
    "path": "pkg/plugin/server/plugin.go",
    "content": "// Copyright 2019 fatedier, fatedier@gmail.com\n//\n// Licensed under the Apache License, Version 2.0 (the \"License\");\n// you may not use this file except in compliance with the License.\n// You may obtain a copy of the License at\n//\n//     http://www.apache.org/licenses/LICENSE-2.0\n//\n// Unless required by applicable law or agreed to in writing, software\n// distributed under the License is distributed on an \"AS IS\" BASIS,\n// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.\n// See the License for the specific language governing permissions and\n// limitations under the License.\n\npackage server\n\nimport (\n\t\"context\"\n)\n\nconst (\n\tAPIVersion = \"0.1.0\"\n\n\tOpLogin       = \"Login\"\n\tOpNewProxy    = \"NewProxy\"\n\tOpCloseProxy  = \"CloseProxy\"\n\tOpPing        = \"Ping\"\n\tOpNewWorkConn = \"NewWorkConn\"\n\tOpNewUserConn = \"NewUserConn\"\n)\n\ntype Plugin interface {\n\tName() string\n\tIsSupport(op string) bool\n\tHandle(ctx context.Context, op string, content any) (res *Response, retContent any, err error)\n}\n"
  },
  {
    "path": "pkg/plugin/server/tracer.go",
    "content": "// Copyright 2019 fatedier, fatedier@gmail.com\n//\n// Licensed under the Apache License, Version 2.0 (the \"License\");\n// you may not use this file except in compliance with the License.\n// You may obtain a copy of the License at\n//\n//     http://www.apache.org/licenses/LICENSE-2.0\n//\n// Unless required by applicable law or agreed to in writing, software\n// distributed under the License is distributed on an \"AS IS\" BASIS,\n// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.\n// See the License for the specific language governing permissions and\n// limitations under the License.\n\npackage server\n\nimport (\n\t\"context\"\n)\n\ntype key int\n\nconst (\n\treqidKey key = 0\n)\n\nfunc NewReqidContext(ctx context.Context, reqid string) context.Context {\n\treturn context.WithValue(ctx, reqidKey, reqid)\n}\n\nfunc GetReqidFromContext(ctx context.Context) string {\n\tret, _ := ctx.Value(reqidKey).(string)\n\treturn ret\n}\n"
  },
  {
    "path": "pkg/plugin/server/types.go",
    "content": "// Copyright 2019 fatedier, fatedier@gmail.com\n//\n// Licensed under the Apache License, Version 2.0 (the \"License\");\n// you may not use this file except in compliance with the License.\n// You may obtain a copy of the License at\n//\n//     http://www.apache.org/licenses/LICENSE-2.0\n//\n// Unless required by applicable law or agreed to in writing, software\n// distributed under the License is distributed on an \"AS IS\" BASIS,\n// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.\n// See the License for the specific language governing permissions and\n// limitations under the License.\n\npackage server\n\nimport (\n\t\"github.com/fatedier/frp/pkg/msg\"\n)\n\ntype Request struct {\n\tVersion string `json:\"version\"`\n\tOp      string `json:\"op\"`\n\tContent any    `json:\"content\"`\n}\n\ntype Response struct {\n\tReject       bool   `json:\"reject\"`\n\tRejectReason string `json:\"reject_reason\"`\n\tUnchange     bool   `json:\"unchange\"`\n\tContent      any    `json:\"content\"`\n}\n\ntype LoginContent struct {\n\tmsg.Login\n\n\tClientAddress string `json:\"client_address,omitempty\"`\n}\n\ntype UserInfo struct {\n\tUser  string            `json:\"user\"`\n\tMetas map[string]string `json:\"metas\"`\n\tRunID string            `json:\"run_id\"`\n}\n\ntype NewProxyContent struct {\n\tUser UserInfo `json:\"user\"`\n\tmsg.NewProxy\n}\n\ntype CloseProxyContent struct {\n\tUser UserInfo `json:\"user\"`\n\tmsg.CloseProxy\n}\n\ntype PingContent struct {\n\tUser UserInfo `json:\"user\"`\n\tmsg.Ping\n}\n\ntype NewWorkConnContent struct {\n\tUser UserInfo `json:\"user\"`\n\tmsg.NewWorkConn\n}\n\ntype NewUserConnContent struct {\n\tUser       UserInfo `json:\"user\"`\n\tProxyName  string   `json:\"proxy_name\"`\n\tProxyType  string   `json:\"proxy_type\"`\n\tRemoteAddr string   `json:\"remote_addr\"`\n}\n"
  },
  {
    "path": "pkg/plugin/visitor/plugin.go",
    "content": "// Copyright 2025 The frp Authors\n//\n// Licensed under the Apache License, Version 2.0 (the \"License\");\n// you may not use this file except in compliance with the License.\n// You may obtain a copy of the License at\n//\n//     http://www.apache.org/licenses/LICENSE-2.0\n//\n// Unless required by applicable law or agreed to in writing, software\n// distributed under the License is distributed on an \"AS IS\" BASIS,\n// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.\n// See the License for the specific language governing permissions and\n// limitations under the License.\n\npackage visitor\n\nimport (\n\t\"context\"\n\t\"fmt\"\n\t\"net\"\n\n\tv1 \"github.com/fatedier/frp/pkg/config/v1\"\n\t\"github.com/fatedier/frp/pkg/vnet\"\n)\n\n// PluginContext provides the necessary context and callbacks for visitor plugins.\ntype PluginContext struct {\n\t// Name is the unique identifier for this visitor, used for logging and routing.\n\tName string\n\n\t// Ctx manages the plugin's lifecycle and carries the logger for structured logging.\n\tCtx context.Context\n\n\t// VnetController manages TUN device routing. May be nil if virtual networking is disabled.\n\tVnetController *vnet.Controller\n\n\t// SendConnToVisitor sends a connection to the visitor's internal processing queue.\n\t// Does not return error; failures are handled by closing the connection.\n\tSendConnToVisitor func(net.Conn)\n}\n\n// Creators is used for create plugins to handle connections.\nvar creators = make(map[string]CreatorFn)\n\ntype CreatorFn func(pluginCtx PluginContext, options v1.VisitorPluginOptions) (Plugin, error)\n\nfunc Register(name string, fn CreatorFn) {\n\tif _, exist := creators[name]; exist {\n\t\tpanic(fmt.Sprintf(\"plugin [%s] is already registered\", name))\n\t}\n\tcreators[name] = fn\n}\n\nfunc Create(pluginName string, pluginCtx PluginContext, options v1.VisitorPluginOptions) (p Plugin, err error) {\n\tif fn, ok := creators[pluginName]; ok {\n\t\tp, err = fn(pluginCtx, options)\n\t} else {\n\t\terr = fmt.Errorf(\"plugin [%s] is not registered\", pluginName)\n\t}\n\treturn\n}\n\ntype Plugin interface {\n\tName() string\n\tStart()\n\tClose() error\n}\n"
  },
  {
    "path": "pkg/plugin/visitor/virtual_net.go",
    "content": "// Copyright 2025 The frp Authors\n//\n// Licensed under the Apache License, Version 2.0 (the \"License\");\n// you may not use this file except in compliance with the License.\n// You may obtain a copy of the License at\n//\n//     http://www.apache.org/licenses/LICENSE-2.0\n//\n// Unless required by applicable law or agreed to in writing, software\n// distributed under the License is distributed on an \"AS IS\" BASIS,\n// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.\n// See the License for the specific language governing permissions and\n// limitations under the License.\n\n//go:build !frps\n\npackage visitor\n\nimport (\n\t\"context\"\n\t\"errors\"\n\t\"fmt\"\n\t\"net\"\n\t\"sync\"\n\t\"time\"\n\n\tv1 \"github.com/fatedier/frp/pkg/config/v1\"\n\tnetutil \"github.com/fatedier/frp/pkg/util/net\"\n\t\"github.com/fatedier/frp/pkg/util/xlog\"\n)\n\nfunc init() {\n\tRegister(v1.VisitorPluginVirtualNet, NewVirtualNetPlugin)\n}\n\ntype VirtualNetPlugin struct {\n\tpluginCtx PluginContext\n\n\troutes []net.IPNet\n\n\tmu             sync.Mutex\n\tcontrollerConn net.Conn\n\tcloseSignal    chan struct{}\n\n\tconsecutiveErrors int // Tracks consecutive connection errors for exponential backoff\n\n\tctx    context.Context\n\tcancel context.CancelFunc\n}\n\nfunc NewVirtualNetPlugin(pluginCtx PluginContext, options v1.VisitorPluginOptions) (Plugin, error) {\n\topts := options.(*v1.VirtualNetVisitorPluginOptions)\n\n\tp := &VirtualNetPlugin{\n\t\tpluginCtx: pluginCtx,\n\t\troutes:    make([]net.IPNet, 0),\n\t}\n\n\tp.ctx, p.cancel = context.WithCancel(pluginCtx.Ctx)\n\n\tif opts.DestinationIP == \"\" {\n\t\treturn nil, errors.New(\"destinationIP is required\")\n\t}\n\n\t// Parse DestinationIP and create a host route.\n\tip := net.ParseIP(opts.DestinationIP)\n\tif ip == nil {\n\t\treturn nil, fmt.Errorf(\"invalid destination IP address [%s]\", opts.DestinationIP)\n\t}\n\n\tvar mask net.IPMask\n\tif ip.To4() != nil {\n\t\tmask = net.CIDRMask(32, 32) // /32 for IPv4\n\t} else {\n\t\tmask = net.CIDRMask(128, 128) // /128 for IPv6\n\t}\n\tp.routes = append(p.routes, net.IPNet{IP: ip, Mask: mask})\n\n\treturn p, nil\n}\n\nfunc (p *VirtualNetPlugin) Name() string {\n\treturn v1.VisitorPluginVirtualNet\n}\n\nfunc (p *VirtualNetPlugin) Start() {\n\txl := xlog.FromContextSafe(p.pluginCtx.Ctx)\n\tif p.pluginCtx.VnetController == nil {\n\t\treturn\n\t}\n\n\trouteStr := \"unknown\"\n\tif len(p.routes) > 0 {\n\t\trouteStr = p.routes[0].String()\n\t}\n\txl.Infof(\"starting VirtualNetPlugin for visitor [%s], attempting to register routes for %s\", p.pluginCtx.Name, routeStr)\n\n\tgo p.run()\n}\n\nfunc (p *VirtualNetPlugin) run() {\n\txl := xlog.FromContextSafe(p.ctx)\n\n\tfor {\n\t\tcurrentCloseSignal := make(chan struct{})\n\n\t\tp.mu.Lock()\n\t\tp.closeSignal = currentCloseSignal\n\t\tp.mu.Unlock()\n\n\t\tselect {\n\t\tcase <-p.ctx.Done():\n\t\t\txl.Infof(\"VirtualNetPlugin run loop for visitor [%s] stopping (context cancelled before pipe creation).\", p.pluginCtx.Name)\n\t\t\tp.cleanupControllerConn(xl)\n\t\t\treturn\n\t\tdefault:\n\t\t}\n\n\t\tcontrollerConn, pluginConn := net.Pipe()\n\n\t\tp.mu.Lock()\n\t\tp.controllerConn = controllerConn\n\t\tp.mu.Unlock()\n\n\t\t// Wrap with CloseNotifyConn which supports both close notification and error recording\n\t\tvar closeErr error\n\t\tpluginNotifyConn := netutil.WrapCloseNotifyConn(pluginConn, func(err error) {\n\t\t\tcloseErr = err\n\t\t\tclose(currentCloseSignal) // Signal the run loop on close.\n\t\t})\n\n\t\txl.Infof(\"attempting to register client route for visitor [%s]\", p.pluginCtx.Name)\n\t\tp.pluginCtx.VnetController.RegisterClientRoute(p.ctx, p.pluginCtx.Name, p.routes, controllerConn)\n\t\txl.Infof(\"successfully registered client route for visitor [%s]. Starting connection handler with CloseNotifyConn.\", p.pluginCtx.Name)\n\n\t\t// Pass the CloseNotifyConn to the visitor for handling.\n\t\t// The visitor can call CloseWithError to record the failure reason.\n\t\tp.pluginCtx.SendConnToVisitor(pluginNotifyConn)\n\n\t\t// Wait for context cancellation or connection close.\n\t\tselect {\n\t\tcase <-p.ctx.Done():\n\t\t\txl.Infof(\"VirtualNetPlugin run loop stopping for visitor [%s] (context cancelled while waiting).\", p.pluginCtx.Name)\n\t\t\tp.cleanupControllerConn(xl)\n\t\t\treturn\n\t\tcase <-currentCloseSignal:\n\t\t\t// Determine reconnect delay based on error with exponential backoff\n\t\t\tvar reconnectDelay time.Duration\n\t\t\tif closeErr != nil {\n\t\t\t\tp.consecutiveErrors++\n\t\t\t\txl.Warnf(\"connection closed with error for visitor [%s] (consecutive errors: %d): %v\",\n\t\t\t\t\tp.pluginCtx.Name, p.consecutiveErrors, closeErr)\n\n\t\t\t\t// Exponential backoff: 60s, 120s, 240s, 300s (capped)\n\t\t\t\tbaseDelay := 60 * time.Second\n\t\t\t\treconnectDelay = min(baseDelay*time.Duration(1<<uint(p.consecutiveErrors-1)), 300*time.Second)\n\t\t\t} else {\n\t\t\t\t// Reset consecutive errors on successful connection\n\t\t\t\tif p.consecutiveErrors > 0 {\n\t\t\t\t\txl.Infof(\"connection closed normally for visitor [%s], resetting error counter (was %d)\",\n\t\t\t\t\t\tp.pluginCtx.Name, p.consecutiveErrors)\n\t\t\t\t\tp.consecutiveErrors = 0\n\t\t\t\t} else {\n\t\t\t\t\txl.Infof(\"connection closed normally for visitor [%s]\", p.pluginCtx.Name)\n\t\t\t\t}\n\t\t\t\treconnectDelay = 10 * time.Second\n\t\t\t}\n\n\t\t\t// The visitor closed the plugin side. Close the controller side.\n\t\t\tp.cleanupControllerConn(xl)\n\n\t\t\txl.Infof(\"waiting %v before attempting reconnection for visitor [%s]...\", reconnectDelay, p.pluginCtx.Name)\n\t\t\tselect {\n\t\t\tcase <-time.After(reconnectDelay):\n\t\t\tcase <-p.ctx.Done():\n\t\t\t\txl.Infof(\"VirtualNetPlugin reconnection delay interrupted for visitor [%s]\", p.pluginCtx.Name)\n\t\t\t\treturn\n\t\t\t}\n\t\t}\n\n\t\txl.Infof(\"re-establishing virtual connection for visitor [%s]...\", p.pluginCtx.Name)\n\t}\n}\n\n// cleanupControllerConn closes the current controllerConn (if it exists) under lock.\nfunc (p *VirtualNetPlugin) cleanupControllerConn(xl *xlog.Logger) {\n\tp.mu.Lock()\n\tdefer p.mu.Unlock()\n\tif p.controllerConn != nil {\n\t\txl.Debugf(\"cleaning up controllerConn for visitor [%s]\", p.pluginCtx.Name)\n\t\tp.controllerConn.Close()\n\t\tp.controllerConn = nil\n\t}\n\tp.closeSignal = nil\n}\n\n// Close initiates the plugin shutdown.\nfunc (p *VirtualNetPlugin) Close() error {\n\txl := xlog.FromContextSafe(p.pluginCtx.Ctx)\n\txl.Infof(\"closing VirtualNetPlugin for visitor [%s]\", p.pluginCtx.Name)\n\n\t// Signal the run loop goroutine to stop.\n\tp.cancel()\n\n\t// Unregister the route from the controller.\n\tif p.pluginCtx.VnetController != nil {\n\t\tp.pluginCtx.VnetController.UnregisterClientRoute(p.pluginCtx.Name)\n\t\txl.Infof(\"unregistered client route for visitor [%s]\", p.pluginCtx.Name)\n\t}\n\n\t// Explicitly close the controller side of the pipe.\n\t// This ensures the pipe is broken even if the run loop is stuck or the visitor hasn't closed its end.\n\tp.cleanupControllerConn(xl)\n\txl.Infof(\"finished cleaning up connections during close for visitor [%s]\", p.pluginCtx.Name)\n\n\treturn nil\n}\n"
  },
  {
    "path": "pkg/policy/featuregate/feature_gate.go",
    "content": "// Copyright 2025 The frp Authors\n//\n// Licensed under the Apache License, Version 2.0 (the \"License\");\n// you may not use this file except in compliance with the License.\n// You may obtain a copy of the License at\n//\n//     http://www.apache.org/licenses/LICENSE-2.0\n//\n// Unless required by applicable law or agreed to in writing, software\n// distributed under the License is distributed on an \"AS IS\" BASIS,\n// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.\n// See the License for the specific language governing permissions and\n// limitations under the License.\n\npackage featuregate\n\nimport (\n\t\"fmt\"\n\t\"maps\"\n\t\"sort\"\n\t\"strings\"\n\t\"sync\"\n\t\"sync/atomic\"\n)\n\n// Feature represents a feature gate name\ntype Feature string\n\n// FeatureStage represents the maturity level of a feature\ntype FeatureStage string\n\nconst (\n\t// Alpha means the feature is experimental and disabled by default\n\tAlpha FeatureStage = \"ALPHA\"\n\t// Beta means the feature is more stable but still might change and is disabled by default\n\tBeta FeatureStage = \"BETA\"\n\t// GA means the feature is generally available and enabled by default\n\tGA FeatureStage = \"\"\n)\n\n// FeatureSpec describes a feature and its properties\ntype FeatureSpec struct {\n\t// Default is the default enablement state for the feature\n\tDefault bool\n\t// LockToDefault indicates the feature cannot be changed from its default\n\tLockToDefault bool\n\t// Stage indicates the maturity level of the feature\n\tStage FeatureStage\n}\n\n// Define all available features here\nvar (\n\tVirtualNet = Feature(\"VirtualNet\")\n)\n\n// defaultFeatures defines default features with their specifications\nvar defaultFeatures = map[Feature]FeatureSpec{\n\t// Actual features\n\tVirtualNet: {Default: false, Stage: Alpha},\n}\n\n// FeatureGate indicates whether a given feature is enabled or not\ntype FeatureGate interface {\n\t// Enabled returns true if the key is enabled\n\tEnabled(key Feature) bool\n\t// KnownFeatures returns a slice of strings describing the known features\n\tKnownFeatures() []string\n}\n\n// MutableFeatureGate allows for dynamic feature gate configuration\ntype MutableFeatureGate interface {\n\tFeatureGate\n\n\t// SetFromMap sets feature gate values from a map[string]bool\n\tSetFromMap(m map[string]bool) error\n\t// Add adds features to the feature gate\n\tAdd(features map[Feature]FeatureSpec) error\n\t// String returns a string representing the feature gate configuration\n\tString() string\n}\n\n// featureGate implements the FeatureGate and MutableFeatureGate interfaces\ntype featureGate struct {\n\t// lock guards writes to known, enabled, and reads/writes of closed\n\tlock sync.Mutex\n\t// known holds a map[Feature]FeatureSpec\n\tknown atomic.Value\n\t// enabled holds a map[Feature]bool\n\tenabled atomic.Value\n\t// closed is set to true once the feature gates are considered immutable\n\tclosed bool\n}\n\n// NewFeatureGate creates a new feature gate with the default features\nfunc NewFeatureGate() MutableFeatureGate {\n\tknown := maps.Clone(defaultFeatures)\n\n\tf := &featureGate{}\n\tf.known.Store(known)\n\tf.enabled.Store(map[Feature]bool{})\n\treturn f\n}\n\n// SetFromMap sets feature gate values from a map[string]bool\nfunc (f *featureGate) SetFromMap(m map[string]bool) error {\n\tf.lock.Lock()\n\tdefer f.lock.Unlock()\n\n\t// Copy existing state\n\tknown := maps.Clone(f.known.Load().(map[Feature]FeatureSpec))\n\tenabled := maps.Clone(f.enabled.Load().(map[Feature]bool))\n\n\t// Apply the new settings\n\tfor k, v := range m {\n\t\tk := Feature(k)\n\t\tfeatureSpec, ok := known[k]\n\t\tif !ok {\n\t\t\treturn fmt.Errorf(\"unrecognized feature gate: %s\", k)\n\t\t}\n\t\tif featureSpec.LockToDefault && featureSpec.Default != v {\n\t\t\treturn fmt.Errorf(\"cannot set feature gate %v to %v, feature is locked to %v\", k, v, featureSpec.Default)\n\t\t}\n\t\tenabled[k] = v\n\t}\n\n\t// Persist the changes\n\tf.known.Store(known)\n\tf.enabled.Store(enabled)\n\treturn nil\n}\n\n// Add adds features to the feature gate\nfunc (f *featureGate) Add(features map[Feature]FeatureSpec) error {\n\tf.lock.Lock()\n\tdefer f.lock.Unlock()\n\n\tif f.closed {\n\t\treturn fmt.Errorf(\"cannot add feature gates after the feature gate is closed\")\n\t}\n\n\t// Copy existing state\n\tknown := maps.Clone(f.known.Load().(map[Feature]FeatureSpec))\n\n\t// Add new features\n\tfor name, spec := range features {\n\t\tif existingSpec, found := known[name]; found {\n\t\t\tif existingSpec == spec {\n\t\t\t\tcontinue\n\t\t\t}\n\t\t\treturn fmt.Errorf(\"feature gate %q with different spec already exists: %v\", name, existingSpec)\n\t\t}\n\t\tknown[name] = spec\n\t}\n\n\t// Persist changes\n\tf.known.Store(known)\n\n\treturn nil\n}\n\n// String returns a string containing all enabled feature gates, formatted as \"key1=value1,key2=value2,...\"\nfunc (f *featureGate) String() string {\n\tenabled := f.enabled.Load().(map[Feature]bool)\n\tpairs := make([]string, 0, len(enabled))\n\tfor k, v := range enabled {\n\t\tpairs = append(pairs, fmt.Sprintf(\"%s=%t\", k, v))\n\t}\n\tsort.Strings(pairs)\n\treturn strings.Join(pairs, \",\")\n}\n\n// Enabled returns true if the key is enabled\nfunc (f *featureGate) Enabled(key Feature) bool {\n\tif v, ok := f.enabled.Load().(map[Feature]bool)[key]; ok {\n\t\treturn v\n\t}\n\tif v, ok := f.known.Load().(map[Feature]FeatureSpec)[key]; ok {\n\t\treturn v.Default\n\t}\n\treturn false\n}\n\n// KnownFeatures returns a slice of strings describing the FeatureGate's known features\n// GA features are hidden from the list\nfunc (f *featureGate) KnownFeatures() []string {\n\tknownFeatures := f.known.Load().(map[Feature]FeatureSpec)\n\tknown := make([]string, 0, len(knownFeatures))\n\tfor k, v := range knownFeatures {\n\t\tif v.Stage == GA {\n\t\t\tcontinue\n\t\t}\n\t\tknown = append(known, fmt.Sprintf(\"%s=true|false (%s - default=%t)\", k, v.Stage, v.Default))\n\t}\n\tsort.Strings(known)\n\treturn known\n}\n\n// Default feature gates instance\nvar DefaultFeatureGates = NewFeatureGate()\n\n// Enabled checks if a feature is enabled in the default feature gates\nfunc Enabled(name Feature) bool {\n\treturn DefaultFeatureGates.Enabled(name)\n}\n\n// SetFromMap sets feature gate values from a map in the default feature gates\nfunc SetFromMap(featureMap map[string]bool) error {\n\treturn DefaultFeatureGates.SetFromMap(featureMap)\n}\n"
  },
  {
    "path": "pkg/policy/security/unsafe.go",
    "content": "package security\n\nconst (\n\tTokenSourceExec = \"TokenSourceExec\"\n)\n\nvar (\n\tClientUnsafeFeatures = []string{\n\t\tTokenSourceExec,\n\t}\n\n\tServerUnsafeFeatures = []string{\n\t\tTokenSourceExec,\n\t}\n)\n\ntype UnsafeFeatures struct {\n\tfeatures map[string]bool\n}\n\nfunc NewUnsafeFeatures(allowed []string) *UnsafeFeatures {\n\tfeatures := make(map[string]bool)\n\tfor _, f := range allowed {\n\t\tfeatures[f] = true\n\t}\n\treturn &UnsafeFeatures{features: features}\n}\n\nfunc (u *UnsafeFeatures) IsEnabled(feature string) bool {\n\tif u == nil {\n\t\treturn false\n\t}\n\treturn u.features[feature]\n}\n"
  },
  {
    "path": "pkg/proto/udp/udp.go",
    "content": "// Copyright 2017 fatedier, fatedier@gmail.com\n//\n// Licensed under the Apache License, Version 2.0 (the \"License\");\n// you may not use this file except in compliance with the License.\n// You may obtain a copy of the License at\n//\n//     http://www.apache.org/licenses/LICENSE-2.0\n//\n// Unless required by applicable law or agreed to in writing, software\n// distributed under the License is distributed on an \"AS IS\" BASIS,\n// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.\n// See the License for the specific language governing permissions and\n// limitations under the License.\n\npackage udp\n\nimport (\n\t\"net\"\n\t\"sync\"\n\t\"time\"\n\n\t\"github.com/fatedier/golib/errors\"\n\t\"github.com/fatedier/golib/pool\"\n\n\t\"github.com/fatedier/frp/pkg/msg\"\n\tnetpkg \"github.com/fatedier/frp/pkg/util/net\"\n)\n\nfunc NewUDPPacket(buf []byte, laddr, raddr *net.UDPAddr) *msg.UDPPacket {\n\tcontent := make([]byte, len(buf))\n\tcopy(content, buf)\n\treturn &msg.UDPPacket{\n\t\tContent:    content,\n\t\tLocalAddr:  laddr,\n\t\tRemoteAddr: raddr,\n\t}\n}\n\nfunc GetContent(m *msg.UDPPacket) (buf []byte, err error) {\n\treturn m.Content, nil\n}\n\nfunc ForwardUserConn(udpConn *net.UDPConn, readCh <-chan *msg.UDPPacket, sendCh chan<- *msg.UDPPacket, bufSize int) {\n\t// read\n\tgo func() {\n\t\tfor udpMsg := range readCh {\n\t\t\tbuf, err := GetContent(udpMsg)\n\t\t\tif err != nil {\n\t\t\t\tcontinue\n\t\t\t}\n\t\t\t_, _ = udpConn.WriteToUDP(buf, udpMsg.RemoteAddr)\n\t\t}\n\t}()\n\n\t// write\n\tbuf := pool.GetBuf(bufSize)\n\tdefer pool.PutBuf(buf)\n\tfor {\n\t\tn, remoteAddr, err := udpConn.ReadFromUDP(buf)\n\t\tif err != nil {\n\t\t\treturn\n\t\t}\n\t\t// NewUDPPacket copies buf[:n], so the read buffer can be reused\n\t\tudpMsg := NewUDPPacket(buf[:n], nil, remoteAddr)\n\n\t\tselect {\n\t\tcase sendCh <- udpMsg:\n\t\tdefault:\n\t\t}\n\t}\n}\n\nfunc Forwarder(dstAddr *net.UDPAddr, readCh <-chan *msg.UDPPacket, sendCh chan<- msg.Message, bufSize int, proxyProtocolVersion string) {\n\tvar mu sync.RWMutex\n\tudpConnMap := make(map[string]*net.UDPConn)\n\n\t// read from dstAddr and write to sendCh\n\twriterFn := func(raddr *net.UDPAddr, udpConn *net.UDPConn) {\n\t\taddr := raddr.String()\n\t\tdefer func() {\n\t\t\tmu.Lock()\n\t\t\tdelete(udpConnMap, addr)\n\t\t\tmu.Unlock()\n\t\t\tudpConn.Close()\n\t\t}()\n\n\t\tbuf := pool.GetBuf(bufSize)\n\t\tdefer pool.PutBuf(buf)\n\t\tfor {\n\t\t\t_ = udpConn.SetReadDeadline(time.Now().Add(30 * time.Second))\n\t\t\tn, _, err := udpConn.ReadFromUDP(buf)\n\t\t\tif err != nil {\n\t\t\t\treturn\n\t\t\t}\n\n\t\t\tudpMsg := NewUDPPacket(buf[:n], nil, raddr)\n\t\t\tif err = errors.PanicToError(func() {\n\t\t\t\tselect {\n\t\t\t\tcase sendCh <- udpMsg:\n\t\t\t\tdefault:\n\t\t\t\t}\n\t\t\t}); err != nil {\n\t\t\t\treturn\n\t\t\t}\n\t\t}\n\t}\n\n\t// read from readCh\n\tgo func() {\n\t\tfor udpMsg := range readCh {\n\t\t\tbuf, err := GetContent(udpMsg)\n\t\t\tif err != nil {\n\t\t\t\tcontinue\n\t\t\t}\n\n\t\t\tmu.Lock()\n\t\t\tudpConn, ok := udpConnMap[udpMsg.RemoteAddr.String()]\n\t\t\tif !ok {\n\t\t\t\tudpConn, err = net.DialUDP(\"udp\", nil, dstAddr)\n\t\t\t\tif err != nil {\n\t\t\t\t\tmu.Unlock()\n\t\t\t\t\tcontinue\n\t\t\t\t}\n\t\t\t\tudpConnMap[udpMsg.RemoteAddr.String()] = udpConn\n\t\t\t}\n\t\t\tmu.Unlock()\n\n\t\t\t// Add proxy protocol header if configured (only for the first packet of a new connection)\n\t\t\tif !ok && proxyProtocolVersion != \"\" && udpMsg.RemoteAddr != nil {\n\t\t\t\tppBuf, err := netpkg.BuildProxyProtocolHeader(udpMsg.RemoteAddr, dstAddr, proxyProtocolVersion)\n\t\t\t\tif err == nil {\n\t\t\t\t\t// Prepend proxy protocol header to the UDP payload\n\t\t\t\t\tfinalBuf := make([]byte, len(ppBuf)+len(buf))\n\t\t\t\t\tcopy(finalBuf, ppBuf)\n\t\t\t\t\tcopy(finalBuf[len(ppBuf):], buf)\n\t\t\t\t\tbuf = finalBuf\n\t\t\t\t}\n\t\t\t}\n\n\t\t\t_, err = udpConn.Write(buf)\n\t\t\tif err != nil {\n\t\t\t\tudpConn.Close()\n\t\t\t}\n\n\t\t\tif !ok {\n\t\t\t\tgo writerFn(udpMsg.RemoteAddr, udpConn)\n\t\t\t}\n\t\t}\n\t}()\n}\n"
  },
  {
    "path": "pkg/proto/udp/udp_test.go",
    "content": "package udp\n\nimport (\n\t\"testing\"\n\n\t\"github.com/stretchr/testify/require\"\n)\n\nfunc TestUdpPacket(t *testing.T) {\n\trequire := require.New(t)\n\n\tbuf := []byte(\"hello world\")\n\tudpMsg := NewUDPPacket(buf, nil, nil)\n\n\tnewBuf, err := GetContent(udpMsg)\n\trequire.NoError(err)\n\trequire.EqualValues(buf, newBuf)\n}\n"
  },
  {
    "path": "pkg/sdk/client/client.go",
    "content": "package client\n\nimport (\n\t\"context\"\n\t\"encoding/json\"\n\t\"fmt\"\n\t\"io\"\n\t\"net\"\n\t\"net/http\"\n\t\"net/url\"\n\t\"strconv\"\n\t\"strings\"\n\n\t\"github.com/fatedier/frp/client/http/model\"\n\thttppkg \"github.com/fatedier/frp/pkg/util/http\"\n)\n\ntype Client struct {\n\taddress  string\n\tauthUser string\n\tauthPwd  string\n}\n\nfunc New(host string, port int) *Client {\n\treturn &Client{\n\t\taddress: net.JoinHostPort(host, strconv.Itoa(port)),\n\t}\n}\n\nfunc (c *Client) SetAuth(user, pwd string) {\n\tc.authUser = user\n\tc.authPwd = pwd\n}\n\nfunc (c *Client) GetProxyStatus(ctx context.Context, name string) (*model.ProxyStatusResp, error) {\n\treq, err := http.NewRequestWithContext(ctx, \"GET\", \"http://\"+c.address+\"/api/status\", nil)\n\tif err != nil {\n\t\treturn nil, err\n\t}\n\tcontent, err := c.do(req)\n\tif err != nil {\n\t\treturn nil, err\n\t}\n\tallStatus := make(model.StatusResp)\n\tif err = json.Unmarshal([]byte(content), &allStatus); err != nil {\n\t\treturn nil, fmt.Errorf(\"unmarshal http response error: %s\", strings.TrimSpace(content))\n\t}\n\tfor _, pss := range allStatus {\n\t\tfor _, ps := range pss {\n\t\t\tif ps.Name == name {\n\t\t\t\treturn &ps, nil\n\t\t\t}\n\t\t}\n\t}\n\treturn nil, fmt.Errorf(\"no proxy status found\")\n}\n\nfunc (c *Client) GetAllProxyStatus(ctx context.Context) (model.StatusResp, error) {\n\treq, err := http.NewRequestWithContext(ctx, \"GET\", \"http://\"+c.address+\"/api/status\", nil)\n\tif err != nil {\n\t\treturn nil, err\n\t}\n\tcontent, err := c.do(req)\n\tif err != nil {\n\t\treturn nil, err\n\t}\n\tallStatus := make(model.StatusResp)\n\tif err = json.Unmarshal([]byte(content), &allStatus); err != nil {\n\t\treturn nil, fmt.Errorf(\"unmarshal http response error: %s\", strings.TrimSpace(content))\n\t}\n\treturn allStatus, nil\n}\n\nfunc (c *Client) Reload(ctx context.Context, strictMode bool) error {\n\tv := url.Values{}\n\tif strictMode {\n\t\tv.Set(\"strictConfig\", \"true\")\n\t}\n\tqueryStr := \"\"\n\tif len(v) > 0 {\n\t\tqueryStr = \"?\" + v.Encode()\n\t}\n\treq, err := http.NewRequestWithContext(ctx, \"GET\", \"http://\"+c.address+\"/api/reload\"+queryStr, nil)\n\tif err != nil {\n\t\treturn err\n\t}\n\t_, err = c.do(req)\n\treturn err\n}\n\nfunc (c *Client) Stop(ctx context.Context) error {\n\treq, err := http.NewRequestWithContext(ctx, \"POST\", \"http://\"+c.address+\"/api/stop\", nil)\n\tif err != nil {\n\t\treturn err\n\t}\n\t_, err = c.do(req)\n\treturn err\n}\n\nfunc (c *Client) GetConfig(ctx context.Context) (string, error) {\n\treq, err := http.NewRequestWithContext(ctx, \"GET\", \"http://\"+c.address+\"/api/config\", nil)\n\tif err != nil {\n\t\treturn \"\", err\n\t}\n\treturn c.do(req)\n}\n\nfunc (c *Client) UpdateConfig(ctx context.Context, content string) error {\n\treq, err := http.NewRequestWithContext(ctx, \"PUT\", \"http://\"+c.address+\"/api/config\", strings.NewReader(content))\n\tif err != nil {\n\t\treturn err\n\t}\n\t_, err = c.do(req)\n\treturn err\n}\n\nfunc (c *Client) setAuthHeader(req *http.Request) {\n\tif c.authUser != \"\" || c.authPwd != \"\" {\n\t\treq.Header.Set(\"Authorization\", httppkg.BasicAuth(c.authUser, c.authPwd))\n\t}\n}\n\nfunc (c *Client) do(req *http.Request) (string, error) {\n\tc.setAuthHeader(req)\n\n\tresp, err := http.DefaultClient.Do(req)\n\tif err != nil {\n\t\treturn \"\", err\n\t}\n\tdefer resp.Body.Close()\n\n\tif resp.StatusCode != 200 {\n\t\treturn \"\", fmt.Errorf(\"api status code [%d]\", resp.StatusCode)\n\t}\n\tbuf, err := io.ReadAll(resp.Body)\n\tif err != nil {\n\t\treturn \"\", err\n\t}\n\treturn string(buf), nil\n}\n"
  },
  {
    "path": "pkg/ssh/gateway.go",
    "content": "// Copyright 2023 The frp Authors\n//\n// Licensed under the Apache License, Version 2.0 (the \"License\");\n// you may not use this file except in compliance with the License.\n// You may obtain a copy of the License at\n//\n//     http://www.apache.org/licenses/LICENSE-2.0\n//\n// Unless required by applicable law or agreed to in writing, software\n// distributed under the License is distributed on an \"AS IS\" BASIS,\n// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.\n// See the License for the specific language governing permissions and\n// limitations under the License.\n\npackage ssh\n\nimport (\n\t\"fmt\"\n\t\"net\"\n\t\"os\"\n\t\"strconv\"\n\t\"strings\"\n\n\t\"golang.org/x/crypto/ssh\"\n\n\tv1 \"github.com/fatedier/frp/pkg/config/v1\"\n\t\"github.com/fatedier/frp/pkg/transport\"\n\t\"github.com/fatedier/frp/pkg/util/log\"\n\tnetpkg \"github.com/fatedier/frp/pkg/util/net\"\n)\n\ntype Gateway struct {\n\tbindPort int\n\tln       net.Listener\n\n\tpeerServerListener *netpkg.InternalListener\n\n\tsshConfig *ssh.ServerConfig\n}\n\nfunc NewGateway(\n\tcfg v1.SSHTunnelGateway, bindAddr string,\n\tpeerServerListener *netpkg.InternalListener,\n) (*Gateway, error) {\n\tsshConfig := &ssh.ServerConfig{}\n\n\t// privateKey\n\tvar (\n\t\tprivateKeyBytes []byte\n\t\terr             error\n\t)\n\tif cfg.PrivateKeyFile != \"\" {\n\t\tprivateKeyBytes, err = os.ReadFile(cfg.PrivateKeyFile)\n\t} else {\n\t\tif cfg.AutoGenPrivateKeyPath != \"\" {\n\t\t\tprivateKeyBytes, _ = os.ReadFile(cfg.AutoGenPrivateKeyPath)\n\t\t}\n\t\tif len(privateKeyBytes) == 0 {\n\t\t\tprivateKeyBytes, err = transport.NewRandomPrivateKey()\n\t\t\tif err == nil && cfg.AutoGenPrivateKeyPath != \"\" {\n\t\t\t\terr = os.WriteFile(cfg.AutoGenPrivateKeyPath, privateKeyBytes, 0o600)\n\t\t\t}\n\t\t}\n\t}\n\tif err != nil {\n\t\treturn nil, err\n\t}\n\tprivateKey, err := ssh.ParsePrivateKey(privateKeyBytes)\n\tif err != nil {\n\t\treturn nil, err\n\t}\n\tsshConfig.AddHostKey(privateKey)\n\n\tsshConfig.NoClientAuth = cfg.AuthorizedKeysFile == \"\"\n\tsshConfig.PublicKeyCallback = func(conn ssh.ConnMetadata, key ssh.PublicKey) (*ssh.Permissions, error) {\n\t\tauthorizedKeysMap, err := loadAuthorizedKeysFromFile(cfg.AuthorizedKeysFile)\n\t\tif err != nil {\n\t\t\tlog.Errorf(\"load authorized keys file error: %v\", err)\n\t\t\treturn nil, fmt.Errorf(\"internal error\")\n\t\t}\n\n\t\tuser, ok := authorizedKeysMap[string(key.Marshal())]\n\t\tif !ok {\n\t\t\treturn nil, fmt.Errorf(\"unknown public key for remoteAddr %q\", conn.RemoteAddr())\n\t\t}\n\t\treturn &ssh.Permissions{\n\t\t\tExtensions: map[string]string{\n\t\t\t\t\"user\": user,\n\t\t\t},\n\t\t}, nil\n\t}\n\n\tln, err := net.Listen(\"tcp\", net.JoinHostPort(bindAddr, strconv.Itoa(cfg.BindPort)))\n\tif err != nil {\n\t\treturn nil, err\n\t}\n\treturn &Gateway{\n\t\tbindPort:           cfg.BindPort,\n\t\tln:                 ln,\n\t\tpeerServerListener: peerServerListener,\n\t\tsshConfig:          sshConfig,\n\t}, nil\n}\n\nfunc (g *Gateway) Run() {\n\tfor {\n\t\tconn, err := g.ln.Accept()\n\t\tif err != nil {\n\t\t\treturn\n\t\t}\n\t\tgo g.handleConn(conn)\n\t}\n}\n\nfunc (g *Gateway) Close() error {\n\treturn g.ln.Close()\n}\n\nfunc (g *Gateway) handleConn(conn net.Conn) {\n\tdefer conn.Close()\n\n\tts, err := NewTunnelServer(conn, g.sshConfig, g.peerServerListener)\n\tif err != nil {\n\t\treturn\n\t}\n\tif err := ts.Run(); err != nil {\n\t\tlog.Errorf(\"ssh tunnel server run error: %v\", err)\n\t}\n}\n\nfunc loadAuthorizedKeysFromFile(path string) (map[string]string, error) {\n\tauthorizedKeysMap := make(map[string]string) // value is username\n\tauthorizedKeysBytes, err := os.ReadFile(path)\n\tif err != nil {\n\t\treturn nil, err\n\t}\n\tfor len(authorizedKeysBytes) > 0 {\n\t\tpubKey, comment, _, rest, err := ssh.ParseAuthorizedKey(authorizedKeysBytes)\n\t\tif err != nil {\n\t\t\treturn nil, err\n\t\t}\n\n\t\tauthorizedKeysMap[string(pubKey.Marshal())] = strings.TrimSpace(comment)\n\t\tauthorizedKeysBytes = rest\n\t}\n\treturn authorizedKeysMap, nil\n}\n"
  },
  {
    "path": "pkg/ssh/server.go",
    "content": "// Copyright 2023 The frp Authors\n//\n// Licensed under the Apache License, Version 2.0 (the \"License\");\n// you may not use this file except in compliance with the License.\n// You may obtain a copy of the License at\n//\n//     http://www.apache.org/licenses/LICENSE-2.0\n//\n// Unless required by applicable law or agreed to in writing, software\n// distributed under the License is distributed on an \"AS IS\" BASIS,\n// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.\n// See the License for the specific language governing permissions and\n// limitations under the License.\n\npackage ssh\n\nimport (\n\t\"context\"\n\t\"encoding/binary\"\n\t\"errors\"\n\t\"fmt\"\n\t\"net\"\n\t\"slices\"\n\t\"strings\"\n\t\"sync\"\n\t\"time\"\n\n\tlibio \"github.com/fatedier/golib/io\"\n\t\"github.com/spf13/cobra\"\n\tflag \"github.com/spf13/pflag\"\n\t\"golang.org/x/crypto/ssh\"\n\n\t\"github.com/fatedier/frp/client/proxy\"\n\t\"github.com/fatedier/frp/pkg/config\"\n\tv1 \"github.com/fatedier/frp/pkg/config/v1\"\n\t\"github.com/fatedier/frp/pkg/msg\"\n\t\"github.com/fatedier/frp/pkg/util/log\"\n\tnetpkg \"github.com/fatedier/frp/pkg/util/net\"\n\t\"github.com/fatedier/frp/pkg/util/util\"\n\t\"github.com/fatedier/frp/pkg/util/xlog\"\n\t\"github.com/fatedier/frp/pkg/virtual\"\n)\n\nconst (\n\t// https://datatracker.ietf.org/doc/html/rfc4254#page-16\n\tChannelTypeServerOpenChannel = \"forwarded-tcpip\"\n\tRequestTypeForward           = \"tcpip-forward\"\n)\n\ntype tcpipForward struct {\n\tHost string\n\tPort uint32\n}\n\n// https://datatracker.ietf.org/doc/html/rfc4254#page-16\ntype forwardedTCPPayload struct {\n\tAddr string\n\tPort uint32\n\n\tOriginAddr string\n\tOriginPort uint32\n}\n\ntype TunnelServer struct {\n\tunderlyingConn net.Conn\n\tsshConn        *ssh.ServerConn\n\tsc             *ssh.ServerConfig\n\tfirstChannel   ssh.Channel\n\n\tvc                 *virtual.Client\n\tpeerServerListener *netpkg.InternalListener\n\tdoneCh             chan struct{}\n\tcloseDoneChOnce    sync.Once\n}\n\nfunc NewTunnelServer(conn net.Conn, sc *ssh.ServerConfig, peerServerListener *netpkg.InternalListener) (*TunnelServer, error) {\n\ts := &TunnelServer{\n\t\tunderlyingConn:     conn,\n\t\tsc:                 sc,\n\t\tpeerServerListener: peerServerListener,\n\t\tdoneCh:             make(chan struct{}),\n\t}\n\treturn s, nil\n}\n\nfunc (s *TunnelServer) Run() error {\n\tsshConn, channels, requests, err := ssh.NewServerConn(s.underlyingConn, s.sc)\n\tif err != nil {\n\t\treturn err\n\t}\n\n\ts.sshConn = sshConn\n\n\taddr, extraPayload, err := s.waitForwardAddrAndExtraPayload(channels, requests, 3*time.Second)\n\tif err != nil {\n\t\treturn err\n\t}\n\n\tclientCfg, pc, helpMessage, err := s.parseClientAndProxyConfigurer(addr, extraPayload)\n\tif err != nil {\n\t\tif errors.Is(err, flag.ErrHelp) {\n\t\t\ts.writeToClient(helpMessage)\n\t\t\treturn nil\n\t\t}\n\t\ts.writeToClient(err.Error())\n\t\treturn fmt.Errorf(\"parse flags from ssh client error: %v\", err)\n\t}\n\tif err := clientCfg.Complete(); err != nil {\n\t\ts.writeToClient(fmt.Sprintf(\"failed to complete client config: %v\", err))\n\t\treturn fmt.Errorf(\"complete client config error: %v\", err)\n\t}\n\tif sshConn.Permissions != nil {\n\t\tclientCfg.User = util.EmptyOr(sshConn.Permissions.Extensions[\"user\"], clientCfg.User)\n\t}\n\tpc.Complete()\n\n\tvc, err := virtual.NewClient(virtual.ClientOptions{\n\t\tCommon: clientCfg,\n\t\tSpec: &msg.ClientSpec{\n\t\t\tType: \"ssh-tunnel\",\n\t\t\t// If ssh does not require authentication, then the virtual client needs to authenticate through a token.\n\t\t\t// Otherwise, once ssh authentication is passed, the virtual client does not need to authenticate again.\n\t\t\tAlwaysAuthPass: !s.sc.NoClientAuth,\n\t\t},\n\t\tHandleWorkConnCb: func(base *v1.ProxyBaseConfig, workConn net.Conn, m *msg.StartWorkConn) bool {\n\t\t\t// join workConn and ssh channel\n\t\t\tc, err := s.openConn(addr)\n\t\t\tif err != nil {\n\t\t\t\tlog.Tracef(\"open conn error: %v\", err)\n\t\t\t\tworkConn.Close()\n\t\t\t\treturn false\n\t\t\t}\n\t\t\tlibio.Join(c, workConn)\n\t\t\treturn false\n\t\t},\n\t})\n\tif err != nil {\n\t\treturn err\n\t}\n\ts.vc = vc\n\n\t// transfer connection from virtual client to server peer listener\n\tgo func() {\n\t\tl := s.vc.PeerListener()\n\t\tfor {\n\t\t\tconn, err := l.Accept()\n\t\t\tif err != nil {\n\t\t\t\treturn\n\t\t\t}\n\t\t\t_ = s.peerServerListener.PutConn(conn)\n\t\t}\n\t}()\n\txl := xlog.New().AddPrefix(xlog.LogPrefix{Name: \"sshVirtualClient\", Value: \"sshVirtualClient\", Priority: 100})\n\tctx := xlog.NewContext(context.Background(), xl)\n\tgo func() {\n\t\tvcErr := s.vc.Run(ctx)\n\t\tif vcErr != nil {\n\t\t\ts.writeToClient(vcErr.Error())\n\t\t}\n\n\t\t// If vc.Run returns, it means that the virtual client has been closed, and the ssh tunnel connection should be closed.\n\t\t// One scenario is that the virtual client exits due to login failure.\n\t\ts.closeDoneChOnce.Do(func() {\n\t\t\t_ = sshConn.Close()\n\t\t\tclose(s.doneCh)\n\t\t})\n\t}()\n\n\ts.vc.UpdateProxyConfigurer([]v1.ProxyConfigurer{pc})\n\n\tif ps, err := s.waitProxyStatusReady(pc.GetBaseConfig().Name, time.Second); err != nil {\n\t\ts.writeToClient(err.Error())\n\t\tlog.Warnf(\"wait proxy status ready error: %v\", err)\n\t} else {\n\t\t// success\n\t\ts.writeToClient(createSuccessInfo(clientCfg.User, pc, ps))\n\t\t_ = sshConn.Wait()\n\t}\n\n\ts.vc.Close()\n\tlog.Tracef(\"ssh tunnel connection from %v closed\", sshConn.RemoteAddr())\n\ts.closeDoneChOnce.Do(func() {\n\t\t_ = sshConn.Close()\n\t\tclose(s.doneCh)\n\t})\n\treturn nil\n}\n\nfunc (s *TunnelServer) writeToClient(data string) {\n\tif s.firstChannel == nil {\n\t\treturn\n\t}\n\t_, _ = s.firstChannel.Write([]byte(data + \"\\n\"))\n}\n\nfunc (s *TunnelServer) waitForwardAddrAndExtraPayload(\n\tchannels <-chan ssh.NewChannel,\n\trequests <-chan *ssh.Request,\n\ttimeout time.Duration,\n) (*tcpipForward, string, error) {\n\taddrCh := make(chan *tcpipForward, 1)\n\textraPayloadCh := make(chan string, 1)\n\n\t// get forward address\n\tgo func() {\n\t\taddrGot := false\n\t\tfor req := range requests {\n\t\t\tif req.Type == RequestTypeForward && !addrGot {\n\t\t\t\tpayload := tcpipForward{}\n\t\t\t\tif err := ssh.Unmarshal(req.Payload, &payload); err != nil {\n\t\t\t\t\treturn\n\t\t\t\t}\n\t\t\t\taddrGot = true\n\t\t\t\taddrCh <- &payload\n\t\t\t}\n\t\t\tif req.WantReply {\n\t\t\t\t_ = req.Reply(true, nil)\n\t\t\t}\n\t\t}\n\t}()\n\n\t// get extra payload\n\tgo func() {\n\t\tfor newChannel := range channels {\n\t\t\t// extraPayload will send to extraPayloadCh\n\t\t\tgo s.handleNewChannel(newChannel, extraPayloadCh)\n\t\t}\n\t}()\n\n\tvar (\n\t\taddr         *tcpipForward\n\t\textraPayload string\n\t)\n\n\ttimer := time.NewTimer(timeout)\n\tdefer timer.Stop()\n\tfor {\n\t\tselect {\n\t\tcase v := <-addrCh:\n\t\t\taddr = v\n\t\tcase extra := <-extraPayloadCh:\n\t\t\textraPayload = extra\n\t\tcase <-timer.C:\n\t\t\treturn nil, \"\", fmt.Errorf(\"get addr and extra payload timeout\")\n\t\t}\n\t\tif addr != nil && extraPayload != \"\" {\n\t\t\tbreak\n\t\t}\n\t}\n\treturn addr, extraPayload, nil\n}\n\nfunc (s *TunnelServer) parseClientAndProxyConfigurer(_ *tcpipForward, extraPayload string) (*v1.ClientCommonConfig, v1.ProxyConfigurer, string, error) {\n\thelpMessage := \"\"\n\tcmd := &cobra.Command{\n\t\tUse:   \"ssh v0@{address} [command]\",\n\t\tShort: \"ssh v0@{address} [command]\",\n\t\tRun:   func(*cobra.Command, []string) {},\n\t}\n\tcmd.SetGlobalNormalizationFunc(config.WordSepNormalizeFunc)\n\n\targs := strings.Split(extraPayload, \" \")\n\tif len(args) < 1 {\n\t\treturn nil, nil, helpMessage, fmt.Errorf(\"invalid extra payload\")\n\t}\n\tproxyType := strings.TrimSpace(args[0])\n\tsupportTypes := []string{\"tcp\", \"http\", \"https\", \"tcpmux\", \"stcp\"}\n\tif !slices.Contains(supportTypes, proxyType) {\n\t\treturn nil, nil, helpMessage, fmt.Errorf(\"invalid proxy type: %s, support types: %v\", proxyType, supportTypes)\n\t}\n\tpc := v1.NewProxyConfigurerByType(v1.ProxyType(proxyType))\n\tif pc == nil {\n\t\treturn nil, nil, helpMessage, fmt.Errorf(\"new proxy configurer error\")\n\t}\n\tconfig.RegisterProxyFlags(cmd, pc, config.WithSSHMode())\n\n\tclientCfg := v1.ClientCommonConfig{}\n\tconfig.RegisterClientCommonConfigFlags(cmd, &clientCfg, config.WithSSHMode())\n\n\tcmd.InitDefaultHelpCmd()\n\tif err := cmd.ParseFlags(args); err != nil {\n\t\tif errors.Is(err, flag.ErrHelp) {\n\t\t\thelpMessage = cmd.UsageString()\n\t\t}\n\t\treturn nil, nil, helpMessage, err\n\t}\n\t// if name is not set, generate a random one\n\tif pc.GetBaseConfig().Name == \"\" {\n\t\tid, err := util.RandIDWithLen(8)\n\t\tif err != nil {\n\t\t\treturn nil, nil, helpMessage, fmt.Errorf(\"generate random id error: %v\", err)\n\t\t}\n\t\tpc.GetBaseConfig().Name = fmt.Sprintf(\"sshtunnel-%s-%s\", proxyType, id)\n\t}\n\treturn &clientCfg, pc, helpMessage, nil\n}\n\nfunc (s *TunnelServer) handleNewChannel(channel ssh.NewChannel, extraPayloadCh chan string) {\n\tch, reqs, err := channel.Accept()\n\tif err != nil {\n\t\treturn\n\t}\n\tif s.firstChannel == nil {\n\t\ts.firstChannel = ch\n\t}\n\tgo s.keepAlive(ch)\n\n\tfor req := range reqs {\n\t\tif req.WantReply {\n\t\t\t_ = req.Reply(true, nil)\n\t\t}\n\t\tif req.Type != \"exec\" || len(req.Payload) <= 4 {\n\t\t\tcontinue\n\t\t}\n\t\tend := 4 + binary.BigEndian.Uint32(req.Payload[:4])\n\t\tif len(req.Payload) < int(end) {\n\t\t\tcontinue\n\t\t}\n\t\textraPayload := string(req.Payload[4:end])\n\t\tselect {\n\t\tcase extraPayloadCh <- extraPayload:\n\t\tdefault:\n\t\t}\n\t}\n}\n\nfunc (s *TunnelServer) keepAlive(ch ssh.Channel) {\n\ttk := time.NewTicker(time.Second * 30)\n\tdefer tk.Stop()\n\n\tfor {\n\t\tselect {\n\t\tcase <-tk.C:\n\t\t\t_, err := ch.SendRequest(\"heartbeat\", false, nil)\n\t\t\tif err != nil {\n\t\t\t\treturn\n\t\t\t}\n\t\tcase <-s.doneCh:\n\t\t\treturn\n\t\t}\n\t}\n}\n\nfunc (s *TunnelServer) openConn(addr *tcpipForward) (net.Conn, error) {\n\tpayload := forwardedTCPPayload{\n\t\tAddr: addr.Host,\n\t\tPort: addr.Port,\n\t\t// Note: Here is just for compatibility, not the real source address.\n\t\tOriginAddr: addr.Host,\n\t\tOriginPort: addr.Port,\n\t}\n\tchannel, reqs, err := s.sshConn.OpenChannel(ChannelTypeServerOpenChannel, ssh.Marshal(&payload))\n\tif err != nil {\n\t\treturn nil, fmt.Errorf(\"open ssh channel error: %v\", err)\n\t}\n\tgo ssh.DiscardRequests(reqs)\n\n\tconn := netpkg.WrapReadWriteCloserToConn(channel, s.underlyingConn)\n\treturn conn, nil\n}\n\nfunc (s *TunnelServer) waitProxyStatusReady(name string, timeout time.Duration) (*proxy.WorkingStatus, error) {\n\tticker := time.NewTicker(100 * time.Millisecond)\n\tdefer ticker.Stop()\n\n\ttimer := time.NewTimer(timeout)\n\tdefer timer.Stop()\n\n\tstatusExporter := s.vc.Service().StatusExporter()\n\n\tfor {\n\t\tselect {\n\t\tcase <-ticker.C:\n\t\t\tps, ok := statusExporter.GetProxyStatus(name)\n\t\t\tif !ok {\n\t\t\t\tcontinue\n\t\t\t}\n\t\t\tswitch ps.Phase {\n\t\t\tcase proxy.ProxyPhaseRunning:\n\t\t\t\treturn ps, nil\n\t\t\tcase proxy.ProxyPhaseStartErr, proxy.ProxyPhaseClosed:\n\t\t\t\treturn ps, errors.New(ps.Err)\n\t\t\t}\n\t\tcase <-timer.C:\n\t\t\treturn nil, fmt.Errorf(\"wait proxy status ready timeout\")\n\t\tcase <-s.doneCh:\n\t\t\treturn nil, fmt.Errorf(\"ssh tunnel server closed\")\n\t\t}\n\t}\n}\n"
  },
  {
    "path": "pkg/ssh/terminal.go",
    "content": "// Copyright 2023 The frp Authors\n//\n// Licensed under the Apache License, Version 2.0 (the \"License\");\n// you may not use this file except in compliance with the License.\n// You may obtain a copy of the License at\n//\n//     http://www.apache.org/licenses/LICENSE-2.0\n//\n// Unless required by applicable law or agreed to in writing, software\n// distributed under the License is distributed on an \"AS IS\" BASIS,\n// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.\n// See the License for the specific language governing permissions and\n// limitations under the License.\n\npackage ssh\n\nimport (\n\t\"github.com/fatedier/frp/client/proxy\"\n\tv1 \"github.com/fatedier/frp/pkg/config/v1\"\n)\n\nfunc createSuccessInfo(user string, pc v1.ProxyConfigurer, ps *proxy.WorkingStatus) string {\n\tbase := pc.GetBaseConfig()\n\tout := \"\\n\"\n\tout += \"frp (via SSH) (Ctrl+C to quit)\\n\\n\"\n\tout += \"User: \" + user + \"\\n\"\n\tout += \"ProxyName: \" + base.Name + \"\\n\"\n\tout += \"Type: \" + base.Type + \"\\n\"\n\tout += \"RemoteAddress: \" + ps.RemoteAddr + \"\\n\"\n\treturn out\n}\n"
  },
  {
    "path": "pkg/transport/message.go",
    "content": "// Copyright 2023 The frp Authors\n//\n// Licensed under the Apache License, Version 2.0 (the \"License\");\n// you may not use this file except in compliance with the License.\n// You may obtain a copy of the License at\n//\n//     http://www.apache.org/licenses/LICENSE-2.0\n//\n// Unless required by applicable law or agreed to in writing, software\n// distributed under the License is distributed on an \"AS IS\" BASIS,\n// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.\n// See the License for the specific language governing permissions and\n// limitations under the License.\n\npackage transport\n\nimport (\n\t\"context\"\n\t\"reflect\"\n\t\"sync\"\n\n\t\"github.com/fatedier/golib/errors\"\n\n\t\"github.com/fatedier/frp/pkg/msg\"\n)\n\ntype MessageTransporter interface {\n\tSend(msg.Message) error\n\t// Recv(ctx context.Context, laneKey string, msgType string) (Message, error)\n\t// Do will first send msg, then recv msg with the same laneKey and specified msgType.\n\tDo(ctx context.Context, req msg.Message, laneKey, recvMsgType string) (msg.Message, error)\n\t// Dispatch will dispatch message to related channel registered in Do function by its message type and laneKey.\n\tDispatch(m msg.Message, laneKey string) bool\n\t// Same with Dispatch but with specified message type.\n\tDispatchWithType(m msg.Message, msgType, laneKey string) bool\n}\n\ntype MessageSender interface {\n\tSend(msg.Message) error\n}\n\nfunc NewMessageTransporter(sender MessageSender) MessageTransporter {\n\treturn &transporterImpl{\n\t\tsender:   sender,\n\t\tregistry: make(map[string]map[string]chan msg.Message),\n\t}\n}\n\ntype transporterImpl struct {\n\tsender MessageSender\n\n\t// First key is message type and second key is lane key.\n\t// Dispatch will dispatch message to related channel by its message type\n\t// and lane key.\n\tregistry map[string]map[string]chan msg.Message\n\tmu       sync.RWMutex\n}\n\nfunc (impl *transporterImpl) Send(m msg.Message) error {\n\treturn impl.sender.Send(m)\n}\n\nfunc (impl *transporterImpl) Do(ctx context.Context, req msg.Message, laneKey, recvMsgType string) (msg.Message, error) {\n\tch := make(chan msg.Message, 1)\n\tdefer close(ch)\n\tunregisterFn := impl.registerMsgChan(ch, laneKey, recvMsgType)\n\tdefer unregisterFn()\n\n\tif err := impl.Send(req); err != nil {\n\t\treturn nil, err\n\t}\n\n\tselect {\n\tcase <-ctx.Done():\n\t\treturn nil, ctx.Err()\n\tcase resp := <-ch:\n\t\treturn resp, nil\n\t}\n}\n\nfunc (impl *transporterImpl) DispatchWithType(m msg.Message, msgType, laneKey string) bool {\n\tvar ch chan msg.Message\n\timpl.mu.RLock()\n\tbyLaneKey, ok := impl.registry[msgType]\n\tif ok {\n\t\tch = byLaneKey[laneKey]\n\t}\n\timpl.mu.RUnlock()\n\n\tif ch == nil {\n\t\treturn false\n\t}\n\n\tif err := errors.PanicToError(func() {\n\t\tch <- m\n\t}); err != nil {\n\t\treturn false\n\t}\n\treturn true\n}\n\nfunc (impl *transporterImpl) Dispatch(m msg.Message, laneKey string) bool {\n\tmsgType := reflect.TypeOf(m).Elem().Name()\n\treturn impl.DispatchWithType(m, msgType, laneKey)\n}\n\nfunc (impl *transporterImpl) registerMsgChan(recvCh chan msg.Message, laneKey string, msgType string) (unregister func()) {\n\timpl.mu.Lock()\n\tbyLaneKey, ok := impl.registry[msgType]\n\tif !ok {\n\t\tbyLaneKey = make(map[string]chan msg.Message)\n\t\timpl.registry[msgType] = byLaneKey\n\t}\n\tbyLaneKey[laneKey] = recvCh\n\timpl.mu.Unlock()\n\n\tunregister = func() {\n\t\timpl.mu.Lock()\n\t\tdelete(byLaneKey, laneKey)\n\t\timpl.mu.Unlock()\n\t}\n\treturn\n}\n"
  },
  {
    "path": "pkg/transport/tls.go",
    "content": "// Copyright 2023 The frp Authors\n//\n// Licensed under the Apache License, Version 2.0 (the \"License\");\n// you may not use this file except in compliance with the License.\n// You may obtain a copy of the License at\n//\n//     http://www.apache.org/licenses/LICENSE-2.0\n//\n// Unless required by applicable law or agreed to in writing, software\n// distributed under the License is distributed on an \"AS IS\" BASIS,\n// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.\n// See the License for the specific language governing permissions and\n// limitations under the License.\n\npackage transport\n\nimport (\n\t\"crypto/rand\"\n\t\"crypto/rsa\"\n\t\"crypto/tls\"\n\t\"crypto/x509\"\n\t\"encoding/pem\"\n\t\"fmt\"\n\t\"math/big\"\n\t\"os\"\n\t\"time\"\n)\n\nfunc newCustomTLSKeyPair(certfile, keyfile string) (*tls.Certificate, error) {\n\ttlsCert, err := tls.LoadX509KeyPair(certfile, keyfile)\n\tif err != nil {\n\t\treturn nil, err\n\t}\n\treturn &tlsCert, nil\n}\n\nfunc newRandomTLSKeyPair() (*tls.Certificate, error) {\n\tkey, err := rsa.GenerateKey(rand.Reader, 2048)\n\tif err != nil {\n\t\treturn nil, err\n\t}\n\n\t// Generate a random positive serial number with 128 bits of entropy.\n\t// RFC 5280 requires serial numbers to be positive integers (not zero).\n\tserialNumberLimit := new(big.Int).Lsh(big.NewInt(1), 128)\n\tserialNumber, err := rand.Int(rand.Reader, serialNumberLimit)\n\tif err != nil {\n\t\treturn nil, err\n\t}\n\t// Ensure serial number is positive (not zero)\n\tif serialNumber.Sign() == 0 {\n\t\tserialNumber = big.NewInt(1)\n\t}\n\n\ttemplate := x509.Certificate{\n\t\tSerialNumber: serialNumber,\n\t\tNotBefore:    time.Now().Add(-1 * time.Hour),\n\t\tNotAfter:     time.Now().Add(365 * 24 * time.Hour * 10),\n\t}\n\n\tcertDER, err := x509.CreateCertificate(\n\t\trand.Reader,\n\t\t&template,\n\t\t&template,\n\t\t&key.PublicKey,\n\t\tkey)\n\tif err != nil {\n\t\treturn nil, err\n\t}\n\tkeyPEM := pem.EncodeToMemory(&pem.Block{Type: \"RSA PRIVATE KEY\", Bytes: x509.MarshalPKCS1PrivateKey(key)})\n\tcertPEM := pem.EncodeToMemory(&pem.Block{Type: \"CERTIFICATE\", Bytes: certDER})\n\n\ttlsCert, err := tls.X509KeyPair(certPEM, keyPEM)\n\tif err != nil {\n\t\treturn nil, err\n\t}\n\treturn &tlsCert, nil\n}\n\n// Only support one ca file to add\nfunc newCertPool(caPath string) (*x509.CertPool, error) {\n\tpool := x509.NewCertPool()\n\n\tcaCrt, err := os.ReadFile(caPath)\n\tif err != nil {\n\t\treturn nil, err\n\t}\n\n\tif !pool.AppendCertsFromPEM(caCrt) {\n\t\treturn nil, fmt.Errorf(\"failed to parse CA certificate from file %q: no valid PEM certificates found\", caPath)\n\t}\n\n\treturn pool, nil\n}\n\nfunc NewServerTLSConfig(certPath, keyPath, caPath string) (*tls.Config, error) {\n\tbase := &tls.Config{}\n\n\tif certPath == \"\" || keyPath == \"\" {\n\t\t// server will generate tls conf by itself\n\t\tcert, err := newRandomTLSKeyPair()\n\t\tif err != nil {\n\t\t\treturn nil, err\n\t\t}\n\t\tbase.Certificates = []tls.Certificate{*cert}\n\t} else {\n\t\tcert, err := newCustomTLSKeyPair(certPath, keyPath)\n\t\tif err != nil {\n\t\t\treturn nil, err\n\t\t}\n\n\t\tbase.Certificates = []tls.Certificate{*cert}\n\t}\n\n\tif caPath != \"\" {\n\t\tpool, err := newCertPool(caPath)\n\t\tif err != nil {\n\t\t\treturn nil, err\n\t\t}\n\n\t\tbase.ClientAuth = tls.RequireAndVerifyClientCert\n\t\tbase.ClientCAs = pool\n\t}\n\n\treturn base, nil\n}\n\nfunc NewClientTLSConfig(certPath, keyPath, caPath, serverName string) (*tls.Config, error) {\n\tbase := &tls.Config{}\n\n\tif certPath != \"\" && keyPath != \"\" {\n\t\tcert, err := newCustomTLSKeyPair(certPath, keyPath)\n\t\tif err != nil {\n\t\t\treturn nil, err\n\t\t}\n\n\t\tbase.Certificates = []tls.Certificate{*cert}\n\t}\n\n\tbase.ServerName = serverName\n\n\tif caPath != \"\" {\n\t\tpool, err := newCertPool(caPath)\n\t\tif err != nil {\n\t\t\treturn nil, err\n\t\t}\n\n\t\tbase.RootCAs = pool\n\t\tbase.InsecureSkipVerify = false\n\t} else {\n\t\tbase.InsecureSkipVerify = true\n\t}\n\n\treturn base, nil\n}\n\nfunc NewRandomPrivateKey() ([]byte, error) {\n\tkey, err := rsa.GenerateKey(rand.Reader, 2048)\n\tif err != nil {\n\t\treturn nil, err\n\t}\n\tkeyPEM := pem.EncodeToMemory(&pem.Block{\n\t\tType:  \"RSA PRIVATE KEY\",\n\t\tBytes: x509.MarshalPKCS1PrivateKey(key),\n\t})\n\treturn keyPEM, nil\n}\n"
  },
  {
    "path": "pkg/util/http/context.go",
    "content": "// Copyright 2025 The frp Authors\n//\n// Licensed under the Apache License, Version 2.0 (the \"License\");\n// you may not use this file except in compliance with the License.\n// You may obtain a copy of the License at\n//\n//     http://www.apache.org/licenses/LICENSE-2.0\n//\n// Unless required by applicable law or agreed to in writing, software\n// distributed under the License is distributed on an \"AS IS\" BASIS,\n// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.\n// See the License for the specific language governing permissions and\n// limitations under the License.\n\npackage http\n\nimport (\n\t\"encoding/json\"\n\t\"io\"\n\t\"net/http\"\n\n\t\"github.com/gorilla/mux\"\n)\n\ntype Context struct {\n\tReq  *http.Request\n\tResp http.ResponseWriter\n\tvars map[string]string\n}\n\nfunc NewContext(w http.ResponseWriter, r *http.Request) *Context {\n\treturn &Context{\n\t\tReq:  r,\n\t\tResp: w,\n\t\tvars: mux.Vars(r),\n\t}\n}\n\nfunc (c *Context) Param(key string) string {\n\treturn c.vars[key]\n}\n\nfunc (c *Context) Query(key string) string {\n\treturn c.Req.URL.Query().Get(key)\n}\n\nfunc (c *Context) BindJSON(obj any) error {\n\tbody, err := io.ReadAll(c.Req.Body)\n\tif err != nil {\n\t\treturn err\n\t}\n\treturn json.Unmarshal(body, obj)\n}\n\nfunc (c *Context) Body() ([]byte, error) {\n\treturn io.ReadAll(c.Req.Body)\n}\n"
  },
  {
    "path": "pkg/util/http/error.go",
    "content": "// Copyright 2025 The frp Authors\n//\n// Licensed under the Apache License, Version 2.0 (the \"License\");\n// you may not use this file except in compliance with the License.\n// You may obtain a copy of the License at\n//\n//     http://www.apache.org/licenses/LICENSE-2.0\n//\n// Unless required by applicable law or agreed to in writing, software\n// distributed under the License is distributed on an \"AS IS\" BASIS,\n// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.\n// See the License for the specific language governing permissions and\n// limitations under the License.\n\npackage http\n\nimport \"fmt\"\n\ntype Error struct {\n\tCode int\n\tErr  error\n}\n\nfunc (e *Error) Error() string {\n\treturn e.Err.Error()\n}\n\nfunc NewError(code int, msg string) *Error {\n\treturn &Error{\n\t\tCode: code,\n\t\tErr:  fmt.Errorf(\"%s\", msg),\n\t}\n}\n"
  },
  {
    "path": "pkg/util/http/handler.go",
    "content": "// Copyright 2025 The frp Authors\n//\n// Licensed under the Apache License, Version 2.0 (the \"License\");\n// you may not use this file except in compliance with the License.\n// You may obtain a copy of the License at\n//\n//     http://www.apache.org/licenses/LICENSE-2.0\n//\n// Unless required by applicable law or agreed to in writing, software\n// distributed under the License is distributed on an \"AS IS\" BASIS,\n// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.\n// See the License for the specific language governing permissions and\n// limitations under the License.\n\npackage http\n\nimport (\n\t\"encoding/json\"\n\t\"net/http\"\n\n\t\"github.com/fatedier/frp/pkg/util/log\"\n)\n\ntype GeneralResponse struct {\n\tCode int\n\tMsg  string\n}\n\n// APIHandler is a handler function that returns a response object or an error.\ntype APIHandler func(ctx *Context) (any, error)\n\n// MakeHTTPHandlerFunc turns a normal APIHandler into a http.HandlerFunc.\nfunc MakeHTTPHandlerFunc(handler APIHandler) http.HandlerFunc {\n\treturn func(w http.ResponseWriter, r *http.Request) {\n\t\tctx := NewContext(w, r)\n\t\tres, err := handler(ctx)\n\t\tif err != nil {\n\t\t\tlog.Warnf(\"http response [%s]: error: %v\", r.URL.Path, err)\n\t\t\tcode := http.StatusInternalServerError\n\t\t\tif e, ok := err.(*Error); ok {\n\t\t\t\tcode = e.Code\n\t\t\t}\n\n\t\t\tw.Header().Set(\"Content-Type\", \"application/json\")\n\t\t\tw.WriteHeader(code)\n\t\t\t_ = json.NewEncoder(w).Encode(GeneralResponse{Code: code, Msg: err.Error()})\n\t\t\treturn\n\t\t}\n\n\t\tif res == nil {\n\t\t\tw.WriteHeader(http.StatusOK)\n\t\t\treturn\n\t\t}\n\n\t\tswitch v := res.(type) {\n\t\tcase []byte:\n\t\t\t_, _ = w.Write(v)\n\t\tcase string:\n\t\t\t_, _ = w.Write([]byte(v))\n\t\tdefault:\n\t\t\tw.Header().Set(\"Content-Type\", \"application/json\")\n\t\t\tw.WriteHeader(http.StatusOK)\n\t\t\t_ = json.NewEncoder(w).Encode(v)\n\t\t}\n\t}\n}\n"
  },
  {
    "path": "pkg/util/http/http.go",
    "content": "// Copyright 2023 The frp Authors\n//\n// Licensed under the Apache License, Version 2.0 (the \"License\");\n// you may not use this file except in compliance with the License.\n// You may obtain a copy of the License at\n//\n//     http://www.apache.org/licenses/LICENSE-2.0\n//\n// Unless required by applicable law or agreed to in writing, software\n// distributed under the License is distributed on an \"AS IS\" BASIS,\n// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.\n// See the License for the specific language governing permissions and\n// limitations under the License.\n\npackage http\n\nimport (\n\t\"encoding/base64\"\n\t\"net\"\n\t\"net/http\"\n\t\"strings\"\n)\n\nfunc OkResponse() *http.Response {\n\theader := make(http.Header)\n\n\tres := &http.Response{\n\t\tStatus:     \"OK\",\n\t\tStatusCode: 200,\n\t\tProto:      \"HTTP/1.1\",\n\t\tProtoMajor: 1,\n\t\tProtoMinor: 1,\n\t\tHeader:     header,\n\t}\n\treturn res\n}\n\nfunc ProxyUnauthorizedResponse() *http.Response {\n\theader := make(http.Header)\n\theader.Set(\"Proxy-Authenticate\", `Basic realm=\"Restricted\"`)\n\tres := &http.Response{\n\t\tStatus:     \"Proxy Authentication Required\",\n\t\tStatusCode: 407,\n\t\tProto:      \"HTTP/1.1\",\n\t\tProtoMajor: 1,\n\t\tProtoMinor: 1,\n\t\tHeader:     header,\n\t}\n\treturn res\n}\n\n// canonicalHost strips port from host if present and returns the canonicalized\n// host name.\nfunc CanonicalHost(host string) (string, error) {\n\tvar err error\n\thost = strings.ToLower(host)\n\tif hasPort(host) {\n\t\thost, _, err = net.SplitHostPort(host)\n\t\tif err != nil {\n\t\t\treturn \"\", err\n\t\t}\n\t}\n\t// Strip trailing dot from fully qualified domain names.\n\thost = strings.TrimSuffix(host, \".\")\n\treturn host, nil\n}\n\n// hasPort reports whether host contains a port number. host may be a host\n// name, an IPv4 or an IPv6 address.\nfunc hasPort(host string) bool {\n\tcolons := strings.Count(host, \":\")\n\tif colons == 0 {\n\t\treturn false\n\t}\n\tif colons == 1 {\n\t\treturn true\n\t}\n\treturn host[0] == '[' && strings.Contains(host, \"]:\")\n}\n\nfunc ParseBasicAuth(auth string) (username, password string, ok bool) {\n\tconst prefix = \"Basic \"\n\t// Case insensitive prefix match. See Issue 22736.\n\tif len(auth) < len(prefix) || !strings.EqualFold(auth[:len(prefix)], prefix) {\n\t\treturn\n\t}\n\tc, err := base64.StdEncoding.DecodeString(auth[len(prefix):])\n\tif err != nil {\n\t\treturn\n\t}\n\tcs := string(c)\n\tbefore, after, found := strings.Cut(cs, \":\")\n\tif !found {\n\t\treturn\n\t}\n\treturn before, after, true\n}\n\nfunc BasicAuth(username, passwd string) string {\n\tauth := username + \":\" + passwd\n\treturn \"Basic \" + base64.StdEncoding.EncodeToString([]byte(auth))\n}\n"
  },
  {
    "path": "pkg/util/http/middleware.go",
    "content": "// Copyright 2025 The frp Authors\n//\n// Licensed under the Apache License, Version 2.0 (the \"License\");\n// you may not use this file except in compliance with the License.\n// You may obtain a copy of the License at\n//\n//     http://www.apache.org/licenses/LICENSE-2.0\n//\n// Unless required by applicable law or agreed to in writing, software\n// distributed under the License is distributed on an \"AS IS\" BASIS,\n// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.\n// See the License for the specific language governing permissions and\n// limitations under the License.\n\npackage http\n\nimport (\n\t\"net/http\"\n\n\t\"github.com/fatedier/frp/pkg/util/log\"\n)\n\ntype responseWriter struct {\n\thttp.ResponseWriter\n\tcode int\n}\n\nfunc (rw *responseWriter) WriteHeader(code int) {\n\trw.code = code\n\trw.ResponseWriter.WriteHeader(code)\n}\n\nfunc NewRequestLogger(next http.Handler) http.Handler {\n\treturn http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {\n\t\tlog.Infof(\"http request: [%s]\", r.URL.Path)\n\t\trw := &responseWriter{ResponseWriter: w, code: http.StatusOK}\n\t\tnext.ServeHTTP(rw, r)\n\t\tlog.Infof(\"http response [%s]: code [%d]\", r.URL.Path, rw.code)\n\t})\n}\n"
  },
  {
    "path": "pkg/util/http/server.go",
    "content": "// Copyright 2023 The frp Authors\n//\n// Licensed under the Apache License, Version 2.0 (the \"License\");\n// you may not use this file except in compliance with the License.\n// You may obtain a copy of the License at\n//\n//     http://www.apache.org/licenses/LICENSE-2.0\n//\n// Unless required by applicable law or agreed to in writing, software\n// distributed under the License is distributed on an \"AS IS\" BASIS,\n// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.\n// See the License for the specific language governing permissions and\n// limitations under the License.\n\npackage http\n\nimport (\n\t\"crypto/tls\"\n\t\"net\"\n\t\"net/http\"\n\t\"net/http/pprof\"\n\t\"strconv\"\n\t\"time\"\n\n\t\"github.com/gorilla/mux\"\n\n\t\"github.com/fatedier/frp/assets\"\n\tv1 \"github.com/fatedier/frp/pkg/config/v1\"\n\tnetpkg \"github.com/fatedier/frp/pkg/util/net\"\n)\n\nvar (\n\tdefaultReadTimeout  = 60 * time.Second\n\tdefaultWriteTimeout = 60 * time.Second\n)\n\ntype Server struct {\n\taddr   string\n\tln     net.Listener\n\ttlsCfg *tls.Config\n\n\trouter *mux.Router\n\ths     *http.Server\n\n\tauthMiddleware mux.MiddlewareFunc\n}\n\nfunc NewServer(cfg v1.WebServerConfig) (*Server, error) {\n\tassets.Load(cfg.AssetsDir)\n\n\taddr := net.JoinHostPort(cfg.Addr, strconv.Itoa(cfg.Port))\n\tif addr == \":\" {\n\t\taddr = \":http\"\n\t}\n\n\tln, err := net.Listen(\"tcp\", addr)\n\tif err != nil {\n\t\treturn nil, err\n\t}\n\n\trouter := mux.NewRouter()\n\ths := &http.Server{\n\t\tAddr:         addr,\n\t\tHandler:      router,\n\t\tReadTimeout:  defaultReadTimeout,\n\t\tWriteTimeout: defaultWriteTimeout,\n\t}\n\ts := &Server{\n\t\taddr:   addr,\n\t\tln:     ln,\n\t\ths:     hs,\n\t\trouter: router,\n\t}\n\tif cfg.PprofEnable {\n\t\ts.registerPprofHandlers()\n\t}\n\tif cfg.TLS != nil {\n\t\tcert, err := tls.LoadX509KeyPair(cfg.TLS.CertFile, cfg.TLS.KeyFile)\n\t\tif err != nil {\n\t\t\treturn nil, err\n\t\t}\n\t\ts.tlsCfg = &tls.Config{\n\t\t\tCertificates: []tls.Certificate{cert},\n\t\t}\n\t}\n\ts.authMiddleware = netpkg.NewHTTPAuthMiddleware(cfg.User, cfg.Password).SetAuthFailDelay(200 * time.Millisecond).Middleware\n\treturn s, nil\n}\n\nfunc (s *Server) Address() string {\n\treturn s.addr\n}\n\nfunc (s *Server) Run() error {\n\tln := s.ln\n\tif s.tlsCfg != nil {\n\t\tln = tls.NewListener(ln, s.tlsCfg)\n\t}\n\treturn s.hs.Serve(ln)\n}\n\nfunc (s *Server) Close() error {\n\terr := s.hs.Close()\n\tif s.ln != nil {\n\t\t_ = s.ln.Close()\n\t}\n\treturn err\n}\n\ntype RouterRegisterHelper struct {\n\tRouter         *mux.Router\n\tAssetsFS       http.FileSystem\n\tAuthMiddleware mux.MiddlewareFunc\n}\n\nfunc (s *Server) RouteRegister(register func(helper *RouterRegisterHelper)) {\n\tregister(&RouterRegisterHelper{\n\t\tRouter:         s.router,\n\t\tAssetsFS:       assets.FileSystem,\n\t\tAuthMiddleware: s.authMiddleware,\n\t})\n}\n\nfunc (s *Server) registerPprofHandlers() {\n\ts.router.HandleFunc(\"/debug/pprof/cmdline\", pprof.Cmdline)\n\ts.router.HandleFunc(\"/debug/pprof/profile\", pprof.Profile)\n\ts.router.HandleFunc(\"/debug/pprof/symbol\", pprof.Symbol)\n\ts.router.HandleFunc(\"/debug/pprof/trace\", pprof.Trace)\n\ts.router.PathPrefix(\"/debug/pprof/\").HandlerFunc(pprof.Index)\n}\n"
  },
  {
    "path": "pkg/util/jsonx/json_v1.go",
    "content": "// Copyright 2026 The frp Authors\n//\n// Licensed under the Apache License, Version 2.0 (the \"License\");\n// you may not use this file except in compliance with the License.\n// You may obtain a copy of the License at\n//\n//     http://www.apache.org/licenses/LICENSE-2.0\n//\n// Unless required by applicable law or agreed to in writing, software\n// distributed under the License is distributed on an \"AS IS\" BASIS,\n// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.\n// See the License for the specific language governing permissions and\n// limitations under the License.\n\npackage jsonx\n\nimport (\n\t\"bytes\"\n\t\"encoding/json\"\n)\n\ntype DecodeOptions struct {\n\tRejectUnknownMembers bool\n}\n\nfunc Marshal(v any) ([]byte, error) {\n\treturn json.Marshal(v)\n}\n\nfunc MarshalIndent(v any, prefix, indent string) ([]byte, error) {\n\treturn json.MarshalIndent(v, prefix, indent)\n}\n\nfunc Unmarshal(data []byte, out any) error {\n\treturn json.Unmarshal(data, out)\n}\n\nfunc UnmarshalWithOptions(data []byte, out any, options DecodeOptions) error {\n\tif !options.RejectUnknownMembers {\n\t\treturn json.Unmarshal(data, out)\n\t}\n\tdecoder := json.NewDecoder(bytes.NewReader(data))\n\tdecoder.DisallowUnknownFields()\n\treturn decoder.Decode(out)\n}\n"
  },
  {
    "path": "pkg/util/jsonx/raw_message.go",
    "content": "// Copyright 2026 The frp Authors\n//\n// Licensed under the Apache License, Version 2.0 (the \"License\");\n// you may not use this file except in compliance with the License.\n// You may obtain a copy of the License at\n//\n//     http://www.apache.org/licenses/LICENSE-2.0\n//\n// Unless required by applicable law or agreed to in writing, software\n// distributed under the License is distributed on an \"AS IS\" BASIS,\n// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.\n// See the License for the specific language governing permissions and\n// limitations under the License.\n\npackage jsonx\n\nimport \"fmt\"\n\n// RawMessage stores a raw encoded JSON value.\n// It is equivalent to encoding/json.RawMessage behavior.\ntype RawMessage []byte\n\nfunc (m RawMessage) MarshalJSON() ([]byte, error) {\n\tif m == nil {\n\t\treturn []byte(\"null\"), nil\n\t}\n\treturn m, nil\n}\n\nfunc (m *RawMessage) UnmarshalJSON(data []byte) error {\n\tif m == nil {\n\t\treturn fmt.Errorf(\"jsonx.RawMessage: UnmarshalJSON on nil pointer\")\n\t}\n\t*m = append((*m)[:0], data...)\n\treturn nil\n}\n"
  },
  {
    "path": "pkg/util/limit/reader.go",
    "content": "// Copyright 2019 fatedier, fatedier@gmail.com\n//\n// Licensed under the Apache License, Version 2.0 (the \"License\");\n// you may not use this file except in compliance with the License.\n// You may obtain a copy of the License at\n//\n//     http://www.apache.org/licenses/LICENSE-2.0\n//\n// Unless required by applicable law or agreed to in writing, software\n// distributed under the License is distributed on an \"AS IS\" BASIS,\n// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.\n// See the License for the specific language governing permissions and\n// limitations under the License.\n\npackage limit\n\nimport (\n\t\"context\"\n\t\"io\"\n\n\t\"golang.org/x/time/rate\"\n)\n\ntype Reader struct {\n\tr       io.Reader\n\tlimiter *rate.Limiter\n}\n\nfunc NewReader(r io.Reader, limiter *rate.Limiter) *Reader {\n\treturn &Reader{\n\t\tr:       r,\n\t\tlimiter: limiter,\n\t}\n}\n\nfunc (r *Reader) Read(p []byte) (n int, err error) {\n\tb := r.limiter.Burst()\n\tif b < len(p) {\n\t\tp = p[:b]\n\t}\n\tn, err = r.r.Read(p)\n\tif err != nil {\n\t\treturn\n\t}\n\n\terr = r.limiter.WaitN(context.Background(), n)\n\tif err != nil {\n\t\treturn\n\t}\n\treturn\n}\n"
  },
  {
    "path": "pkg/util/limit/writer.go",
    "content": "// Copyright 2019 fatedier, fatedier@gmail.com\n//\n// Licensed under the Apache License, Version 2.0 (the \"License\");\n// you may not use this file except in compliance with the License.\n// You may obtain a copy of the License at\n//\n//     http://www.apache.org/licenses/LICENSE-2.0\n//\n// Unless required by applicable law or agreed to in writing, software\n// distributed under the License is distributed on an \"AS IS\" BASIS,\n// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.\n// See the License for the specific language governing permissions and\n// limitations under the License.\n\npackage limit\n\nimport (\n\t\"context\"\n\t\"io\"\n\n\t\"golang.org/x/time/rate\"\n)\n\ntype Writer struct {\n\tw       io.Writer\n\tlimiter *rate.Limiter\n}\n\nfunc NewWriter(w io.Writer, limiter *rate.Limiter) *Writer {\n\treturn &Writer{\n\t\tw:       w,\n\t\tlimiter: limiter,\n\t}\n}\n\nfunc (w *Writer) Write(p []byte) (n int, err error) {\n\tvar nn int\n\tb := w.limiter.Burst()\n\tfor {\n\t\tend := len(p)\n\t\tif end == 0 {\n\t\t\tbreak\n\t\t}\n\t\tif b < len(p) {\n\t\t\tend = b\n\t\t}\n\t\terr = w.limiter.WaitN(context.Background(), end)\n\t\tif err != nil {\n\t\t\treturn\n\t\t}\n\n\t\tnn, err = w.w.Write(p[:end])\n\t\tn += nn\n\t\tif err != nil {\n\t\t\treturn\n\t\t}\n\t\tp = p[end:]\n\t}\n\treturn\n}\n"
  },
  {
    "path": "pkg/util/log/log.go",
    "content": "// Copyright 2016 fatedier, fatedier@gmail.com\n//\n// Licensed under the Apache License, Version 2.0 (the \"License\");\n// you may not use this file except in compliance with the License.\n// You may obtain a copy of the License at\n//\n//     http://www.apache.org/licenses/LICENSE-2.0\n//\n// Unless required by applicable law or agreed to in writing, software\n// distributed under the License is distributed on an \"AS IS\" BASIS,\n// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.\n// See the License for the specific language governing permissions and\n// limitations under the License.\n\npackage log\n\nimport (\n\t\"bytes\"\n\t\"os\"\n\n\t\"github.com/fatedier/golib/log\"\n)\n\nvar (\n\tTraceLevel = log.TraceLevel\n\tDebugLevel = log.DebugLevel\n\tInfoLevel  = log.InfoLevel\n\tWarnLevel  = log.WarnLevel\n\tErrorLevel = log.ErrorLevel\n)\n\nvar Logger *log.Logger\n\nfunc init() {\n\tLogger = log.New(\n\t\tlog.WithCaller(true),\n\t\tlog.AddCallerSkip(1),\n\t\tlog.WithLevel(log.InfoLevel),\n\t)\n}\n\nfunc InitLogger(logPath string, levelStr string, maxDays int, disableLogColor bool) {\n\toptions := []log.Option{}\n\tif logPath == \"console\" {\n\t\tif !disableLogColor {\n\t\t\toptions = append(options,\n\t\t\t\tlog.WithOutput(log.NewConsoleWriter(log.ConsoleConfig{\n\t\t\t\t\tColorful: true,\n\t\t\t\t}, os.Stdout)),\n\t\t\t)\n\t\t}\n\t} else {\n\t\twriter := log.NewRotateFileWriter(log.RotateFileConfig{\n\t\t\tFileName: logPath,\n\t\t\tMode:     log.RotateFileModeDaily,\n\t\t\tMaxDays:  maxDays,\n\t\t})\n\t\twriter.Init()\n\t\toptions = append(options, log.WithOutput(writer))\n\t}\n\n\tlevel, err := log.ParseLevel(levelStr)\n\tif err != nil {\n\t\tlevel = log.InfoLevel\n\t}\n\toptions = append(options, log.WithLevel(level))\n\tLogger = Logger.WithOptions(options...)\n}\n\nfunc Errorf(format string, v ...any) {\n\tLogger.Errorf(format, v...)\n}\n\nfunc Warnf(format string, v ...any) {\n\tLogger.Warnf(format, v...)\n}\n\nfunc Infof(format string, v ...any) {\n\tLogger.Infof(format, v...)\n}\n\nfunc Debugf(format string, v ...any) {\n\tLogger.Debugf(format, v...)\n}\n\nfunc Tracef(format string, v ...any) {\n\tLogger.Tracef(format, v...)\n}\n\nfunc Logf(level log.Level, offset int, format string, v ...any) {\n\tLogger.Logf(level, offset, format, v...)\n}\n\ntype WriteLogger struct {\n\tlevel  log.Level\n\toffset int\n}\n\nfunc NewWriteLogger(level log.Level, offset int) *WriteLogger {\n\treturn &WriteLogger{\n\t\tlevel:  level,\n\t\toffset: offset,\n\t}\n}\n\nfunc (w *WriteLogger) Write(p []byte) (n int, err error) {\n\tLogger.Log(w.level, w.offset, string(bytes.TrimRight(p, \"\\n\")))\n\treturn len(p), nil\n}\n"
  },
  {
    "path": "pkg/util/metric/counter.go",
    "content": "// Copyright 2017 fatedier, fatedier@gmail.com\n//\n// Licensed under the Apache License, Version 2.0 (the \"License\");\n// you may not use this file except in compliance with the License.\n// You may obtain a copy of the License at\n//\n//     http://www.apache.org/licenses/LICENSE-2.0\n//\n// Unless required by applicable law or agreed to in writing, software\n// distributed under the License is distributed on an \"AS IS\" BASIS,\n// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.\n// See the License for the specific language governing permissions and\n// limitations under the License.\n\npackage metric\n\nimport (\n\t\"sync/atomic\"\n)\n\ntype Counter interface {\n\tCount() int32\n\tInc(int32)\n\tDec(int32)\n\tSnapshot() Counter\n\tClear()\n}\n\nfunc NewCounter() Counter {\n\treturn &StandardCounter{\n\t\tcount: 0,\n\t}\n}\n\ntype StandardCounter struct {\n\tcount int32\n}\n\nfunc (c *StandardCounter) Count() int32 {\n\treturn atomic.LoadInt32(&c.count)\n}\n\nfunc (c *StandardCounter) Inc(count int32) {\n\tatomic.AddInt32(&c.count, count)\n}\n\nfunc (c *StandardCounter) Dec(count int32) {\n\tatomic.AddInt32(&c.count, -count)\n}\n\nfunc (c *StandardCounter) Snapshot() Counter {\n\ttmp := &StandardCounter{\n\t\tcount: atomic.LoadInt32(&c.count),\n\t}\n\treturn tmp\n}\n\nfunc (c *StandardCounter) Clear() {\n\tatomic.StoreInt32(&c.count, 0)\n}\n"
  },
  {
    "path": "pkg/util/metric/counter_test.go",
    "content": "package metric\n\nimport (\n\t\"testing\"\n\n\t\"github.com/stretchr/testify/require\"\n)\n\nfunc TestCounter(t *testing.T) {\n\trequire := require.New(t)\n\tc := NewCounter()\n\tc.Inc(10)\n\trequire.EqualValues(10, c.Count())\n\n\tc.Dec(5)\n\trequire.EqualValues(5, c.Count())\n\n\tcTmp := c.Snapshot()\n\trequire.EqualValues(5, cTmp.Count())\n\n\tc.Clear()\n\trequire.EqualValues(0, c.Count())\n}\n"
  },
  {
    "path": "pkg/util/metric/date_counter.go",
    "content": "// Copyright 2017 fatedier, fatedier@gmail.com\n//\n// Licensed under the Apache License, Version 2.0 (the \"License\");\n// you may not use this file except in compliance with the License.\n// You may obtain a copy of the License at\n//\n//     http://www.apache.org/licenses/LICENSE-2.0\n//\n// Unless required by applicable law or agreed to in writing, software\n// distributed under the License is distributed on an \"AS IS\" BASIS,\n// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.\n// See the License for the specific language governing permissions and\n// limitations under the License.\n\npackage metric\n\nimport (\n\t\"sync\"\n\t\"time\"\n)\n\ntype DateCounter interface {\n\tTodayCount() int64\n\tGetLastDaysCount(lastdays int64) []int64\n\tInc(int64)\n\tDec(int64)\n\tSnapshot() DateCounter\n\tClear()\n}\n\nfunc NewDateCounter(reserveDays int64) DateCounter {\n\tif reserveDays <= 0 {\n\t\treserveDays = 1\n\t}\n\treturn newStandardDateCounter(reserveDays)\n}\n\ntype StandardDateCounter struct {\n\treserveDays int64\n\tcounts      []int64\n\n\tlastUpdateDate time.Time\n\tmu             sync.Mutex\n}\n\nfunc newStandardDateCounter(reserveDays int64) *StandardDateCounter {\n\tnow := time.Now()\n\tnow = time.Date(now.Year(), now.Month(), now.Day(), 0, 0, 0, 0, now.Location())\n\ts := &StandardDateCounter{\n\t\treserveDays:    reserveDays,\n\t\tcounts:         make([]int64, reserveDays),\n\t\tlastUpdateDate: now,\n\t}\n\treturn s\n}\n\nfunc (c *StandardDateCounter) TodayCount() int64 {\n\tc.mu.Lock()\n\tdefer c.mu.Unlock()\n\n\tc.rotate(time.Now())\n\treturn c.counts[0]\n}\n\nfunc (c *StandardDateCounter) GetLastDaysCount(lastdays int64) []int64 {\n\tif lastdays > c.reserveDays {\n\t\tlastdays = c.reserveDays\n\t}\n\tcounts := make([]int64, lastdays)\n\n\tc.mu.Lock()\n\tdefer c.mu.Unlock()\n\tc.rotate(time.Now())\n\tfor i := 0; i < int(lastdays); i++ {\n\t\tcounts[i] = c.counts[i]\n\t}\n\treturn counts\n}\n\nfunc (c *StandardDateCounter) Inc(count int64) {\n\tc.mu.Lock()\n\tdefer c.mu.Unlock()\n\tc.rotate(time.Now())\n\tc.counts[0] += count\n}\n\nfunc (c *StandardDateCounter) Dec(count int64) {\n\tc.mu.Lock()\n\tdefer c.mu.Unlock()\n\tc.rotate(time.Now())\n\tc.counts[0] -= count\n}\n\nfunc (c *StandardDateCounter) Snapshot() DateCounter {\n\tc.mu.Lock()\n\tdefer c.mu.Unlock()\n\ttmp := newStandardDateCounter(c.reserveDays)\n\tfor i := 0; i < int(c.reserveDays); i++ {\n\t\ttmp.counts[i] = c.counts[i]\n\t}\n\treturn tmp\n}\n\nfunc (c *StandardDateCounter) Clear() {\n\tc.mu.Lock()\n\tdefer c.mu.Unlock()\n\tfor i := 0; i < int(c.reserveDays); i++ {\n\t\tc.counts[i] = 0\n\t}\n}\n\n// rotate\n// Must hold the lock before calling this function.\nfunc (c *StandardDateCounter) rotate(now time.Time) {\n\tnow = time.Date(now.Year(), now.Month(), now.Day(), 0, 0, 0, 0, now.Location())\n\tdays := int(now.Sub(c.lastUpdateDate).Hours() / 24)\n\n\tdefer func() {\n\t\tc.lastUpdateDate = now\n\t}()\n\n\tif days <= 0 {\n\t\treturn\n\t} else if days >= int(c.reserveDays) {\n\t\tc.counts = make([]int64, c.reserveDays)\n\t\treturn\n\t}\n\tnewCounts := make([]int64, c.reserveDays)\n\n\tfor i := days; i < int(c.reserveDays); i++ {\n\t\tnewCounts[i] = c.counts[i-days]\n\t}\n\tc.counts = newCounts\n}\n"
  },
  {
    "path": "pkg/util/metric/date_counter_test.go",
    "content": "package metric\n\nimport (\n\t\"testing\"\n\n\t\"github.com/stretchr/testify/require\"\n)\n\nfunc TestDateCounter(t *testing.T) {\n\trequire := require.New(t)\n\n\tdc := NewDateCounter(3)\n\tdc.Inc(10)\n\trequire.EqualValues(10, dc.TodayCount())\n\n\tdc.Dec(5)\n\trequire.EqualValues(5, dc.TodayCount())\n\n\tcounts := dc.GetLastDaysCount(3)\n\trequire.EqualValues(3, len(counts))\n\trequire.EqualValues(5, counts[0])\n\trequire.EqualValues(0, counts[1])\n\trequire.EqualValues(0, counts[2])\n\n\tdcTmp := dc.Snapshot()\n\trequire.EqualValues(5, dcTmp.TodayCount())\n}\n"
  },
  {
    "path": "pkg/util/metric/metrics.go",
    "content": "// Copyright 2020 fatedier, fatedier@gmail.com\n//\n// Licensed under the Apache License, Version 2.0 (the \"License\");\n// you may not use this file except in compliance with the License.\n// You may obtain a copy of the License at\n//\n//     http://www.apache.org/licenses/LICENSE-2.0\n//\n// Unless required by applicable law or agreed to in writing, software\n// distributed under the License is distributed on an \"AS IS\" BASIS,\n// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.\n// See the License for the specific language governing permissions and\n// limitations under the License.\n\npackage metric\n\n// GaugeMetric represents a single numerical value that can arbitrarily go up\n// and down.\ntype GaugeMetric interface {\n\tInc()\n\tDec()\n\tSet(float64)\n}\n\n// CounterMetric represents a single numerical value that only ever\n// goes up.\ntype CounterMetric interface {\n\tInc()\n}\n\n// HistogramMetric counts individual observations.\ntype HistogramMetric interface {\n\tObserve(float64)\n}\n"
  },
  {
    "path": "pkg/util/net/conn.go",
    "content": "// Copyright 2016 fatedier, fatedier@gmail.com\n//\n// Licensed under the Apache License, Version 2.0 (the \"License\");\n// you may not use this file except in compliance with the License.\n// You may obtain a copy of the License at\n//\n//     http://www.apache.org/licenses/LICENSE-2.0\n//\n// Unless required by applicable law or agreed to in writing, software\n// distributed under the License is distributed on an \"AS IS\" BASIS,\n// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.\n// See the License for the specific language governing permissions and\n// limitations under the License.\n\npackage net\n\nimport (\n\t\"context\"\n\t\"errors\"\n\t\"io\"\n\t\"net\"\n\t\"sync/atomic\"\n\t\"time\"\n\n\t\"github.com/fatedier/golib/crypto\"\n\tquic \"github.com/quic-go/quic-go\"\n\n\t\"github.com/fatedier/frp/pkg/util/xlog\"\n)\n\ntype ContextGetter interface {\n\tContext() context.Context\n}\n\ntype ContextSetter interface {\n\tWithContext(ctx context.Context)\n}\n\nfunc NewLogFromConn(conn net.Conn) *xlog.Logger {\n\tif c, ok := conn.(ContextGetter); ok {\n\t\treturn xlog.FromContextSafe(c.Context())\n\t}\n\treturn xlog.New()\n}\n\nfunc NewContextFromConn(conn net.Conn) context.Context {\n\tif c, ok := conn.(ContextGetter); ok {\n\t\treturn c.Context()\n\t}\n\treturn context.Background()\n}\n\n// ContextConn is the connection with context\ntype ContextConn struct {\n\tnet.Conn\n\n\tctx context.Context\n}\n\nfunc NewContextConn(ctx context.Context, c net.Conn) *ContextConn {\n\treturn &ContextConn{\n\t\tConn: c,\n\t\tctx:  ctx,\n\t}\n}\n\nfunc (c *ContextConn) WithContext(ctx context.Context) {\n\tc.ctx = ctx\n}\n\nfunc (c *ContextConn) Context() context.Context {\n\treturn c.ctx\n}\n\ntype WrapReadWriteCloserConn struct {\n\tio.ReadWriteCloser\n\n\tunderConn net.Conn\n\n\tremoteAddr net.Addr\n}\n\nfunc WrapReadWriteCloserToConn(rwc io.ReadWriteCloser, underConn net.Conn) *WrapReadWriteCloserConn {\n\treturn &WrapReadWriteCloserConn{\n\t\tReadWriteCloser: rwc,\n\t\tunderConn:       underConn,\n\t}\n}\n\nfunc (conn *WrapReadWriteCloserConn) LocalAddr() net.Addr {\n\tif conn.underConn != nil {\n\t\treturn conn.underConn.LocalAddr()\n\t}\n\treturn (*net.TCPAddr)(nil)\n}\n\nfunc (conn *WrapReadWriteCloserConn) SetRemoteAddr(addr net.Addr) {\n\tconn.remoteAddr = addr\n}\n\nfunc (conn *WrapReadWriteCloserConn) RemoteAddr() net.Addr {\n\tif conn.remoteAddr != nil {\n\t\treturn conn.remoteAddr\n\t}\n\tif conn.underConn != nil {\n\t\treturn conn.underConn.RemoteAddr()\n\t}\n\treturn (*net.TCPAddr)(nil)\n}\n\nfunc (conn *WrapReadWriteCloserConn) SetDeadline(t time.Time) error {\n\tif conn.underConn != nil {\n\t\treturn conn.underConn.SetDeadline(t)\n\t}\n\treturn &net.OpError{Op: \"set\", Net: \"wrap\", Source: nil, Addr: nil, Err: errors.New(\"deadline not supported\")}\n}\n\nfunc (conn *WrapReadWriteCloserConn) SetReadDeadline(t time.Time) error {\n\tif conn.underConn != nil {\n\t\treturn conn.underConn.SetReadDeadline(t)\n\t}\n\treturn &net.OpError{Op: \"set\", Net: \"wrap\", Source: nil, Addr: nil, Err: errors.New(\"deadline not supported\")}\n}\n\nfunc (conn *WrapReadWriteCloserConn) SetWriteDeadline(t time.Time) error {\n\tif conn.underConn != nil {\n\t\treturn conn.underConn.SetWriteDeadline(t)\n\t}\n\treturn &net.OpError{Op: \"set\", Net: \"wrap\", Source: nil, Addr: nil, Err: errors.New(\"deadline not supported\")}\n}\n\ntype CloseNotifyConn struct {\n\tnet.Conn\n\n\t// 1 means closed\n\tcloseFlag int32\n\n\tcloseFn func(error)\n}\n\n// closeFn will be only called once with the error (nil if Close() was called, non-nil if CloseWithError() was called)\nfunc WrapCloseNotifyConn(c net.Conn, closeFn func(error)) *CloseNotifyConn {\n\treturn &CloseNotifyConn{\n\t\tConn:    c,\n\t\tcloseFn: closeFn,\n\t}\n}\n\nfunc (cc *CloseNotifyConn) Close() (err error) {\n\tpflag := atomic.SwapInt32(&cc.closeFlag, 1)\n\tif pflag == 0 {\n\t\terr = cc.Conn.Close()\n\t\tif cc.closeFn != nil {\n\t\t\tcc.closeFn(nil)\n\t\t}\n\t}\n\treturn\n}\n\n// CloseWithError closes the connection and passes the error to the close callback.\nfunc (cc *CloseNotifyConn) CloseWithError(err error) error {\n\tpflag := atomic.SwapInt32(&cc.closeFlag, 1)\n\tif pflag == 0 {\n\t\tcloseErr := cc.Conn.Close()\n\t\tif cc.closeFn != nil {\n\t\t\tcc.closeFn(err)\n\t\t}\n\t\treturn closeErr\n\t}\n\treturn nil\n}\n\ntype StatsConn struct {\n\tnet.Conn\n\n\tclosed     int64 // 1 means closed\n\ttotalRead  int64\n\ttotalWrite int64\n\tstatsFunc  func(totalRead, totalWrite int64)\n}\n\nfunc WrapStatsConn(conn net.Conn, statsFunc func(total, totalWrite int64)) *StatsConn {\n\treturn &StatsConn{\n\t\tConn:      conn,\n\t\tstatsFunc: statsFunc,\n\t}\n}\n\nfunc (statsConn *StatsConn) Read(p []byte) (n int, err error) {\n\tn, err = statsConn.Conn.Read(p)\n\tstatsConn.totalRead += int64(n)\n\treturn\n}\n\nfunc (statsConn *StatsConn) Write(p []byte) (n int, err error) {\n\tn, err = statsConn.Conn.Write(p)\n\tstatsConn.totalWrite += int64(n)\n\treturn\n}\n\nfunc (statsConn *StatsConn) Close() (err error) {\n\told := atomic.SwapInt64(&statsConn.closed, 1)\n\tif old != 1 {\n\t\terr = statsConn.Conn.Close()\n\t\tif statsConn.statsFunc != nil {\n\t\t\tstatsConn.statsFunc(statsConn.totalRead, statsConn.totalWrite)\n\t\t}\n\t}\n\treturn\n}\n\ntype wrapQuicStream struct {\n\t*quic.Stream\n\tc *quic.Conn\n}\n\nfunc QuicStreamToNetConn(s *quic.Stream, c *quic.Conn) net.Conn {\n\treturn &wrapQuicStream{\n\t\tStream: s,\n\t\tc:      c,\n\t}\n}\n\nfunc (conn *wrapQuicStream) LocalAddr() net.Addr {\n\tif conn.c != nil {\n\t\treturn conn.c.LocalAddr()\n\t}\n\treturn (*net.TCPAddr)(nil)\n}\n\nfunc (conn *wrapQuicStream) RemoteAddr() net.Addr {\n\tif conn.c != nil {\n\t\treturn conn.c.RemoteAddr()\n\t}\n\treturn (*net.TCPAddr)(nil)\n}\n\nfunc (conn *wrapQuicStream) Close() error {\n\tconn.CancelRead(0)\n\treturn conn.Stream.Close()\n}\n\nfunc NewCryptoReadWriter(rw io.ReadWriter, key []byte) (io.ReadWriter, error) {\n\tencReader := crypto.NewReader(rw, key)\n\tencWriter, err := crypto.NewWriter(rw, key)\n\tif err != nil {\n\t\treturn nil, err\n\t}\n\treturn struct {\n\t\tio.Reader\n\t\tio.Writer\n\t}{\n\t\tReader: encReader,\n\t\tWriter: encWriter,\n\t}, nil\n}\n"
  },
  {
    "path": "pkg/util/net/dial.go",
    "content": "package net\n\nimport (\n\t\"context\"\n\t\"net\"\n\t\"net/url\"\n\n\tlibnet \"github.com/fatedier/golib/net\"\n\t\"golang.org/x/net/websocket\"\n)\n\nfunc DialHookCustomTLSHeadByte(enableTLS bool, disableCustomTLSHeadByte bool) libnet.AfterHookFunc {\n\treturn func(ctx context.Context, c net.Conn, addr string) (context.Context, net.Conn, error) {\n\t\tif enableTLS && !disableCustomTLSHeadByte {\n\t\t\t_, err := c.Write([]byte{byte(FRPTLSHeadByte)})\n\t\t\tif err != nil {\n\t\t\t\treturn nil, nil, err\n\t\t\t}\n\t\t}\n\t\treturn ctx, c, nil\n\t}\n}\n\nfunc DialHookWebsocket(protocol string, host string) libnet.AfterHookFunc {\n\treturn func(ctx context.Context, c net.Conn, addr string) (context.Context, net.Conn, error) {\n\t\tif protocol != \"wss\" {\n\t\t\tprotocol = \"ws\"\n\t\t}\n\t\tif host == \"\" {\n\t\t\thost = addr\n\t\t}\n\t\taddr = protocol + \"://\" + host + FrpWebsocketPath\n\t\turi, err := url.Parse(addr)\n\t\tif err != nil {\n\t\t\treturn nil, nil, err\n\t\t}\n\n\t\torigin := \"http://\" + uri.Host\n\t\tcfg, err := websocket.NewConfig(addr, origin)\n\t\tif err != nil {\n\t\t\treturn nil, nil, err\n\t\t}\n\n\t\tconn, err := websocket.NewClient(cfg, c)\n\t\tif err != nil {\n\t\t\treturn nil, nil, err\n\t\t}\n\t\treturn ctx, conn, nil\n\t}\n}\n"
  },
  {
    "path": "pkg/util/net/dns.go",
    "content": "// Copyright 2023 The frp Authors\n//\n// Licensed under the Apache License, Version 2.0 (the \"License\");\n// you may not use this file except in compliance with the License.\n// You may obtain a copy of the License at\n//\n//     http://www.apache.org/licenses/LICENSE-2.0\n//\n// Unless required by applicable law or agreed to in writing, software\n// distributed under the License is distributed on an \"AS IS\" BASIS,\n// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.\n// See the License for the specific language governing permissions and\n// limitations under the License.\n\npackage net\n\nimport (\n\t\"context\"\n\t\"net\"\n)\n\nfunc SetDefaultDNSAddress(dnsAddress string) {\n\tif _, _, err := net.SplitHostPort(dnsAddress); err != nil {\n\t\tdnsAddress = net.JoinHostPort(dnsAddress, \"53\")\n\t}\n\t// Change default dns server\n\tnet.DefaultResolver = &net.Resolver{\n\t\tPreferGo: true,\n\t\tDial: func(ctx context.Context, network, _ string) (net.Conn, error) {\n\t\t\treturn net.Dial(network, dnsAddress)\n\t\t},\n\t}\n}\n"
  },
  {
    "path": "pkg/util/net/http.go",
    "content": "// Copyright 2017 fatedier, fatedier@gmail.com\n//\n// Licensed under the Apache License, Version 2.0 (the \"License\");\n// you may not use this file except in compliance with the License.\n// You may obtain a copy of the License at\n//\n//     http://www.apache.org/licenses/LICENSE-2.0\n//\n// Unless required by applicable law or agreed to in writing, software\n// distributed under the License is distributed on an \"AS IS\" BASIS,\n// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.\n// See the License for the specific language governing permissions and\n// limitations under the License.\n\npackage net\n\nimport (\n\t\"compress/gzip\"\n\t\"io\"\n\t\"net/http\"\n\t\"strings\"\n\t\"time\"\n\n\t\"github.com/fatedier/frp/pkg/util/util\"\n)\n\ntype HTTPAuthMiddleware struct {\n\tuser          string\n\tpasswd        string\n\tauthFailDelay time.Duration\n}\n\nfunc NewHTTPAuthMiddleware(user, passwd string) *HTTPAuthMiddleware {\n\treturn &HTTPAuthMiddleware{\n\t\tuser:   user,\n\t\tpasswd: passwd,\n\t}\n}\n\nfunc (authMid *HTTPAuthMiddleware) SetAuthFailDelay(delay time.Duration) *HTTPAuthMiddleware {\n\tauthMid.authFailDelay = delay\n\treturn authMid\n}\n\nfunc (authMid *HTTPAuthMiddleware) Middleware(next http.Handler) http.Handler {\n\treturn http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {\n\t\treqUser, reqPasswd, hasAuth := r.BasicAuth()\n\t\tif (authMid.user == \"\" && authMid.passwd == \"\") ||\n\t\t\t(hasAuth && util.ConstantTimeEqString(reqUser, authMid.user) &&\n\t\t\t\tutil.ConstantTimeEqString(reqPasswd, authMid.passwd)) {\n\t\t\tnext.ServeHTTP(w, r)\n\t\t} else {\n\t\t\tif authMid.authFailDelay > 0 {\n\t\t\t\ttime.Sleep(authMid.authFailDelay)\n\t\t\t}\n\t\t\tw.Header().Set(\"WWW-Authenticate\", `Basic realm=\"Restricted\"`)\n\t\t\thttp.Error(w, http.StatusText(http.StatusUnauthorized), http.StatusUnauthorized)\n\t\t}\n\t})\n}\n\ntype HTTPGzipWrapper struct {\n\th http.Handler\n}\n\nfunc (gw *HTTPGzipWrapper) ServeHTTP(w http.ResponseWriter, r *http.Request) {\n\tif !strings.Contains(r.Header.Get(\"Accept-Encoding\"), \"gzip\") {\n\t\tgw.h.ServeHTTP(w, r)\n\t\treturn\n\t}\n\tw.Header().Set(\"Content-Encoding\", \"gzip\")\n\tgz := gzip.NewWriter(w)\n\tdefer gz.Close()\n\tgzr := gzipResponseWriter{Writer: gz, ResponseWriter: w}\n\tgw.h.ServeHTTP(gzr, r)\n}\n\nfunc MakeHTTPGzipHandler(h http.Handler) http.Handler {\n\treturn &HTTPGzipWrapper{\n\t\th: h,\n\t}\n}\n\ntype gzipResponseWriter struct {\n\tio.Writer\n\thttp.ResponseWriter\n}\n\nfunc (w gzipResponseWriter) Write(b []byte) (int, error) {\n\treturn w.Writer.Write(b)\n}\n"
  },
  {
    "path": "pkg/util/net/kcp.go",
    "content": "// Copyright 2017 fatedier, fatedier@gmail.com\n//\n// Licensed under the Apache License, Version 2.0 (the \"License\");\n// you may not use this file except in compliance with the License.\n// You may obtain a copy of the License at\n//\n//     http://www.apache.org/licenses/LICENSE-2.0\n//\n// Unless required by applicable law or agreed to in writing, software\n// distributed under the License is distributed on an \"AS IS\" BASIS,\n// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.\n// See the License for the specific language governing permissions and\n// limitations under the License.\n\npackage net\n\nimport (\n\t\"fmt\"\n\t\"net\"\n\n\tkcp \"github.com/xtaci/kcp-go/v5\"\n)\n\ntype KCPListener struct {\n\tlistener  net.Listener\n\tacceptCh  chan net.Conn\n\tcloseFlag bool\n}\n\nfunc ListenKcp(address string) (l *KCPListener, err error) {\n\tlistener, err := kcp.ListenWithOptions(address, nil, 10, 3)\n\tif err != nil {\n\t\treturn l, err\n\t}\n\t_ = listener.SetReadBuffer(4194304)\n\t_ = listener.SetWriteBuffer(4194304)\n\n\tl = &KCPListener{\n\t\tlistener:  listener,\n\t\tacceptCh:  make(chan net.Conn),\n\t\tcloseFlag: false,\n\t}\n\n\tgo func() {\n\t\tfor {\n\t\t\tconn, err := listener.AcceptKCP()\n\t\t\tif err != nil {\n\t\t\t\tif l.closeFlag {\n\t\t\t\t\tclose(l.acceptCh)\n\t\t\t\t\treturn\n\t\t\t\t}\n\t\t\t\tcontinue\n\t\t\t}\n\t\t\tconn.SetStreamMode(true)\n\t\t\tconn.SetWriteDelay(true)\n\t\t\tconn.SetNoDelay(1, 20, 2, 1)\n\t\t\tconn.SetMtu(1350)\n\t\t\tconn.SetWindowSize(1024, 1024)\n\t\t\tconn.SetACKNoDelay(false)\n\n\t\t\tl.acceptCh <- conn\n\t\t}\n\t}()\n\treturn l, err\n}\n\nfunc (l *KCPListener) Accept() (net.Conn, error) {\n\tconn, ok := <-l.acceptCh\n\tif !ok {\n\t\treturn conn, fmt.Errorf(\"channel for kcp listener closed\")\n\t}\n\treturn conn, nil\n}\n\nfunc (l *KCPListener) Close() error {\n\tif !l.closeFlag {\n\t\tl.closeFlag = true\n\t\tl.listener.Close()\n\t}\n\treturn nil\n}\n\nfunc (l *KCPListener) Addr() net.Addr {\n\treturn l.listener.Addr()\n}\n\nfunc NewKCPConnFromUDP(conn *net.UDPConn, connected bool, raddr string) (net.Conn, error) {\n\tudpAddr, err := net.ResolveUDPAddr(\"udp\", raddr)\n\tif err != nil {\n\t\treturn nil, err\n\t}\n\tvar pConn net.PacketConn = conn\n\tif connected {\n\t\tpConn = &ConnectedUDPConn{conn}\n\t}\n\tkcpConn, err := kcp.NewConn3(1, udpAddr, nil, 10, 3, pConn)\n\tif err != nil {\n\t\treturn nil, err\n\t}\n\tkcpConn.SetStreamMode(true)\n\tkcpConn.SetWriteDelay(true)\n\tkcpConn.SetNoDelay(1, 20, 2, 1)\n\tkcpConn.SetMtu(1350)\n\tkcpConn.SetWindowSize(1024, 1024)\n\tkcpConn.SetACKNoDelay(false)\n\treturn kcpConn, nil\n}\n"
  },
  {
    "path": "pkg/util/net/listener.go",
    "content": "// Copyright 2017 fatedier, fatedier@gmail.com\n//\n// Licensed under the Apache License, Version 2.0 (the \"License\");\n// you may not use this file except in compliance with the License.\n// You may obtain a copy of the License at\n//\n//     http://www.apache.org/licenses/LICENSE-2.0\n//\n// Unless required by applicable law or agreed to in writing, software\n// distributed under the License is distributed on an \"AS IS\" BASIS,\n// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.\n// See the License for the specific language governing permissions and\n// limitations under the License.\n\npackage net\n\nimport (\n\t\"fmt\"\n\t\"net\"\n\t\"sync\"\n\n\t\"github.com/fatedier/golib/errors\"\n)\n\n// InternalListener is a listener that can be used to accept connections from\n// other goroutines.\ntype InternalListener struct {\n\tacceptCh chan net.Conn\n\tclosed   bool\n\tmu       sync.Mutex\n}\n\nfunc NewInternalListener() *InternalListener {\n\treturn &InternalListener{\n\t\tacceptCh: make(chan net.Conn, 128),\n\t}\n}\n\nfunc (l *InternalListener) Accept() (net.Conn, error) {\n\tconn, ok := <-l.acceptCh\n\tif !ok {\n\t\treturn nil, fmt.Errorf(\"listener closed\")\n\t}\n\treturn conn, nil\n}\n\nfunc (l *InternalListener) PutConn(conn net.Conn) error {\n\terr := errors.PanicToError(func() {\n\t\tselect {\n\t\tcase l.acceptCh <- conn:\n\t\tdefault:\n\t\t\tconn.Close()\n\t\t}\n\t})\n\tif err != nil {\n\t\treturn fmt.Errorf(\"put conn error: listener is closed\")\n\t}\n\treturn nil\n}\n\nfunc (l *InternalListener) Close() error {\n\tl.mu.Lock()\n\tdefer l.mu.Unlock()\n\tif !l.closed {\n\t\tclose(l.acceptCh)\n\t\tl.closed = true\n\t}\n\treturn nil\n}\n\nfunc (l *InternalListener) Addr() net.Addr {\n\treturn &InternalAddr{}\n}\n\ntype InternalAddr struct{}\n\nfunc (ia *InternalAddr) Network() string {\n\treturn \"internal\"\n}\n\nfunc (ia *InternalAddr) String() string {\n\treturn \"internal\"\n}\n"
  },
  {
    "path": "pkg/util/net/proxyprotocol.go",
    "content": "// Copyright 2025 The frp Authors\n//\n// Licensed under the Apache License, Version 2.0 (the \"License\");\n// you may not use this file except in compliance with the License.\n// You may obtain a copy of the License at\n//\n//     http://www.apache.org/licenses/LICENSE-2.0\n//\n// Unless required by applicable law or agreed to in writing, software\n// distributed under the License is distributed on an \"AS IS\" BASIS,\n// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.\n// See the License for the specific language governing permissions and\n// limitations under the License.\n\npackage net\n\nimport (\n\t\"bytes\"\n\t\"fmt\"\n\t\"net\"\n\n\tpp \"github.com/pires/go-proxyproto\"\n)\n\nfunc BuildProxyProtocolHeaderStruct(srcAddr, dstAddr net.Addr, version string) *pp.Header {\n\tvar versionByte byte\n\tif version == \"v1\" {\n\t\tversionByte = 1\n\t} else {\n\t\tversionByte = 2 // default to v2\n\t}\n\treturn pp.HeaderProxyFromAddrs(versionByte, srcAddr, dstAddr)\n}\n\nfunc BuildProxyProtocolHeader(srcAddr, dstAddr net.Addr, version string) ([]byte, error) {\n\th := BuildProxyProtocolHeaderStruct(srcAddr, dstAddr, version)\n\n\t// Convert header to bytes using a buffer\n\tvar buf bytes.Buffer\n\t_, err := h.WriteTo(&buf)\n\tif err != nil {\n\t\treturn nil, fmt.Errorf(\"failed to write proxy protocol header: %v\", err)\n\t}\n\treturn buf.Bytes(), nil\n}\n"
  },
  {
    "path": "pkg/util/net/proxyprotocol_test.go",
    "content": "package net\n\nimport (\n\t\"net\"\n\t\"testing\"\n\n\tpp \"github.com/pires/go-proxyproto\"\n\t\"github.com/stretchr/testify/require\"\n)\n\nfunc TestBuildProxyProtocolHeader(t *testing.T) {\n\trequire := require.New(t)\n\n\ttests := []struct {\n\t\tname        string\n\t\tsrcAddr     net.Addr\n\t\tdstAddr     net.Addr\n\t\tversion     string\n\t\texpectError bool\n\t}{\n\t\t{\n\t\t\tname:        \"UDP IPv4 v2\",\n\t\t\tsrcAddr:     &net.UDPAddr{IP: net.ParseIP(\"192.168.1.100\"), Port: 12345},\n\t\t\tdstAddr:     &net.UDPAddr{IP: net.ParseIP(\"10.0.0.1\"), Port: 3306},\n\t\t\tversion:     \"v2\",\n\t\t\texpectError: false,\n\t\t},\n\t\t{\n\t\t\tname:        \"TCP IPv4 v1\",\n\t\t\tsrcAddr:     &net.TCPAddr{IP: net.ParseIP(\"192.168.1.100\"), Port: 12345},\n\t\t\tdstAddr:     &net.TCPAddr{IP: net.ParseIP(\"10.0.0.1\"), Port: 80},\n\t\t\tversion:     \"v1\",\n\t\t\texpectError: false,\n\t\t},\n\t\t{\n\t\t\tname:        \"UDP IPv6 v2\",\n\t\t\tsrcAddr:     &net.UDPAddr{IP: net.ParseIP(\"2001:db8::1\"), Port: 12345},\n\t\t\tdstAddr:     &net.UDPAddr{IP: net.ParseIP(\"::1\"), Port: 3306},\n\t\t\tversion:     \"v2\",\n\t\t\texpectError: false,\n\t\t},\n\t\t{\n\t\t\tname:        \"TCP IPv6 v1\",\n\t\t\tsrcAddr:     &net.TCPAddr{IP: net.ParseIP(\"::1\"), Port: 12345},\n\t\t\tdstAddr:     &net.TCPAddr{IP: net.ParseIP(\"2001:db8::1\"), Port: 80},\n\t\t\tversion:     \"v1\",\n\t\t\texpectError: false,\n\t\t},\n\t\t{\n\t\t\tname:        \"nil source address\",\n\t\t\tsrcAddr:     nil,\n\t\t\tdstAddr:     &net.UDPAddr{IP: net.ParseIP(\"10.0.0.1\"), Port: 3306},\n\t\t\tversion:     \"v2\",\n\t\t\texpectError: false,\n\t\t},\n\t\t{\n\t\t\tname:        \"nil destination address\",\n\t\t\tsrcAddr:     &net.TCPAddr{IP: net.ParseIP(\"192.168.1.100\"), Port: 12345},\n\t\t\tdstAddr:     nil,\n\t\t\tversion:     \"v2\",\n\t\t\texpectError: false,\n\t\t},\n\t\t{\n\t\t\tname:        \"unsupported address type\",\n\t\t\tsrcAddr:     &net.UnixAddr{Name: \"/tmp/test.sock\", Net: \"unix\"},\n\t\t\tdstAddr:     &net.UDPAddr{IP: net.ParseIP(\"10.0.0.1\"), Port: 3306},\n\t\t\tversion:     \"v2\",\n\t\t\texpectError: false,\n\t\t},\n\t}\n\n\tfor _, tt := range tests {\n\t\theader, err := BuildProxyProtocolHeader(tt.srcAddr, tt.dstAddr, tt.version)\n\n\t\tif tt.expectError {\n\t\t\trequire.Error(err, \"test case: %s\", tt.name)\n\t\t\tcontinue\n\t\t}\n\n\t\trequire.NoError(err, \"test case: %s\", tt.name)\n\t\trequire.NotEmpty(header, \"test case: %s\", tt.name)\n\t}\n}\n\nfunc TestBuildProxyProtocolHeaderStruct(t *testing.T) {\n\trequire := require.New(t)\n\n\ttests := []struct {\n\t\tname               string\n\t\tsrcAddr            net.Addr\n\t\tdstAddr            net.Addr\n\t\tversion            string\n\t\texpectedProtocol   pp.AddressFamilyAndProtocol\n\t\texpectedVersion    byte\n\t\texpectedCommand    pp.ProtocolVersionAndCommand\n\t\texpectedSourceAddr net.Addr\n\t\texpectedDestAddr   net.Addr\n\t}{\n\t\t{\n\t\t\tname:               \"TCP IPv4 v2\",\n\t\t\tsrcAddr:            &net.TCPAddr{IP: net.ParseIP(\"192.168.1.100\"), Port: 12345},\n\t\t\tdstAddr:            &net.TCPAddr{IP: net.ParseIP(\"10.0.0.1\"), Port: 80},\n\t\t\tversion:            \"v2\",\n\t\t\texpectedProtocol:   pp.TCPv4,\n\t\t\texpectedVersion:    2,\n\t\t\texpectedCommand:    pp.PROXY,\n\t\t\texpectedSourceAddr: &net.TCPAddr{IP: net.ParseIP(\"192.168.1.100\"), Port: 12345},\n\t\t\texpectedDestAddr:   &net.TCPAddr{IP: net.ParseIP(\"10.0.0.1\"), Port: 80},\n\t\t},\n\t\t{\n\t\t\tname:               \"UDP IPv6 v1\",\n\t\t\tsrcAddr:            &net.UDPAddr{IP: net.ParseIP(\"2001:db8::1\"), Port: 12345},\n\t\t\tdstAddr:            &net.UDPAddr{IP: net.ParseIP(\"::1\"), Port: 3306},\n\t\t\tversion:            \"v1\",\n\t\t\texpectedProtocol:   pp.UDPv6,\n\t\t\texpectedVersion:    1,\n\t\t\texpectedCommand:    pp.PROXY,\n\t\t\texpectedSourceAddr: &net.UDPAddr{IP: net.ParseIP(\"2001:db8::1\"), Port: 12345},\n\t\t\texpectedDestAddr:   &net.UDPAddr{IP: net.ParseIP(\"::1\"), Port: 3306},\n\t\t},\n\t\t{\n\t\t\tname:               \"TCP IPv6 default version\",\n\t\t\tsrcAddr:            &net.TCPAddr{IP: net.ParseIP(\"::1\"), Port: 12345},\n\t\t\tdstAddr:            &net.TCPAddr{IP: net.ParseIP(\"2001:db8::1\"), Port: 80},\n\t\t\tversion:            \"\",\n\t\t\texpectedProtocol:   pp.TCPv6,\n\t\t\texpectedVersion:    2, // default to v2\n\t\t\texpectedCommand:    pp.PROXY,\n\t\t\texpectedSourceAddr: &net.TCPAddr{IP: net.ParseIP(\"::1\"), Port: 12345},\n\t\t\texpectedDestAddr:   &net.TCPAddr{IP: net.ParseIP(\"2001:db8::1\"), Port: 80},\n\t\t},\n\t\t{\n\t\t\tname:               \"nil source address\",\n\t\t\tsrcAddr:            nil,\n\t\t\tdstAddr:            &net.UDPAddr{IP: net.ParseIP(\"10.0.0.1\"), Port: 3306},\n\t\t\tversion:            \"v2\",\n\t\t\texpectedProtocol:   pp.UNSPEC,\n\t\t\texpectedVersion:    2,\n\t\t\texpectedCommand:    pp.LOCAL,\n\t\t\texpectedSourceAddr: nil, // go-proxyproto sets both to nil when srcAddr is nil\n\t\t\texpectedDestAddr:   nil,\n\t\t},\n\t\t{\n\t\t\tname:               \"nil destination address\",\n\t\t\tsrcAddr:            &net.TCPAddr{IP: net.ParseIP(\"192.168.1.100\"), Port: 12345},\n\t\t\tdstAddr:            nil,\n\t\t\tversion:            \"v2\",\n\t\t\texpectedProtocol:   pp.UNSPEC,\n\t\t\texpectedVersion:    2,\n\t\t\texpectedCommand:    pp.LOCAL,\n\t\t\texpectedSourceAddr: nil, // go-proxyproto sets both to nil when dstAddr is nil\n\t\t\texpectedDestAddr:   nil,\n\t\t},\n\t\t{\n\t\t\tname:               \"unsupported address type\",\n\t\t\tsrcAddr:            &net.UnixAddr{Name: \"/tmp/test.sock\", Net: \"unix\"},\n\t\t\tdstAddr:            &net.UDPAddr{IP: net.ParseIP(\"10.0.0.1\"), Port: 3306},\n\t\t\tversion:            \"v2\",\n\t\t\texpectedProtocol:   pp.UNSPEC,\n\t\t\texpectedVersion:    2,\n\t\t\texpectedCommand:    pp.LOCAL,\n\t\t\texpectedSourceAddr: nil, // go-proxyproto sets both to nil for unsupported types\n\t\t\texpectedDestAddr:   nil,\n\t\t},\n\t}\n\n\tfor _, tt := range tests {\n\t\theader := BuildProxyProtocolHeaderStruct(tt.srcAddr, tt.dstAddr, tt.version)\n\n\t\trequire.NotNil(header, \"test case: %s\", tt.name)\n\n\t\trequire.Equal(tt.expectedCommand, header.Command, \"test case: %s\", tt.name)\n\t\trequire.Equal(tt.expectedSourceAddr, header.SourceAddr, \"test case: %s\", tt.name)\n\t\trequire.Equal(tt.expectedDestAddr, header.DestinationAddr, \"test case: %s\", tt.name)\n\t\trequire.Equal(tt.expectedProtocol, header.TransportProtocol, \"test case: %s\", tt.name)\n\t\trequire.Equal(tt.expectedVersion, header.Version, \"test case: %s\", tt.name)\n\t}\n}\n"
  },
  {
    "path": "pkg/util/net/tls.go",
    "content": "// Copyright 2019 fatedier, fatedier@gmail.com\n//\n// Licensed under the Apache License, Version 2.0 (the \"License\");\n// you may not use this file except in compliance with the License.\n// You may obtain a copy of the License at\n//\n//     http://www.apache.org/licenses/LICENSE-2.0\n//\n// Unless required by applicable law or agreed to in writing, software\n// distributed under the License is distributed on an \"AS IS\" BASIS,\n// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.\n// See the License for the specific language governing permissions and\n// limitations under the License.\n\npackage net\n\nimport (\n\t\"crypto/tls\"\n\t\"fmt\"\n\t\"net\"\n\t\"time\"\n\n\tlibnet \"github.com/fatedier/golib/net\"\n)\n\nvar FRPTLSHeadByte = 0x17\n\nfunc CheckAndEnableTLSServerConnWithTimeout(\n\tc net.Conn, tlsConfig *tls.Config, tlsOnly bool, timeout time.Duration,\n) (out net.Conn, isTLS bool, custom bool, err error) {\n\tsc, r := libnet.NewSharedConnSize(c, 2)\n\tbuf := make([]byte, 1)\n\tvar n int\n\t_ = c.SetReadDeadline(time.Now().Add(timeout))\n\tn, err = r.Read(buf)\n\t_ = c.SetReadDeadline(time.Time{})\n\tif err != nil {\n\t\treturn\n\t}\n\n\tswitch {\n\tcase n == 1 && int(buf[0]) == FRPTLSHeadByte:\n\t\tout = tls.Server(c, tlsConfig)\n\t\tisTLS = true\n\t\tcustom = true\n\tcase n == 1 && int(buf[0]) == 0x16:\n\t\tout = tls.Server(sc, tlsConfig)\n\t\tisTLS = true\n\tdefault:\n\t\tif tlsOnly {\n\t\t\terr = fmt.Errorf(\"non-TLS connection received on a TlsOnly server\")\n\t\t\treturn\n\t\t}\n\t\tout = sc\n\t}\n\treturn\n}\n"
  },
  {
    "path": "pkg/util/net/udp.go",
    "content": "// Copyright 2017 fatedier, fatedier@gmail.com\n//\n// Licensed under the Apache License, Version 2.0 (the \"License\");\n// you may not use this file except in compliance with the License.\n// You may obtain a copy of the License at\n//\n//     http://www.apache.org/licenses/LICENSE-2.0\n//\n// Unless required by applicable law or agreed to in writing, software\n// distributed under the License is distributed on an \"AS IS\" BASIS,\n// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.\n// See the License for the specific language governing permissions and\n// limitations under the License.\n\npackage net\n\nimport (\n\t\"fmt\"\n\t\"io\"\n\t\"net\"\n\t\"strconv\"\n\t\"sync\"\n\t\"time\"\n\n\t\"github.com/fatedier/golib/pool\"\n)\n\ntype UDPPacket struct {\n\tBuf        []byte\n\tLocalAddr  net.Addr\n\tRemoteAddr net.Addr\n}\n\ntype FakeUDPConn struct {\n\tl *UDPListener\n\n\tlocalAddr  net.Addr\n\tremoteAddr net.Addr\n\tpackets    chan []byte\n\tcloseFlag  bool\n\n\tlastActive time.Time\n\tmu         sync.RWMutex\n}\n\nfunc NewFakeUDPConn(l *UDPListener, laddr, raddr net.Addr) *FakeUDPConn {\n\tfc := &FakeUDPConn{\n\t\tl:          l,\n\t\tlocalAddr:  laddr,\n\t\tremoteAddr: raddr,\n\t\tpackets:    make(chan []byte, 20),\n\t}\n\n\tgo func() {\n\t\tfor {\n\t\t\ttime.Sleep(5 * time.Second)\n\t\t\tfc.mu.RLock()\n\t\t\tif time.Since(fc.lastActive) > 10*time.Second {\n\t\t\t\tfc.mu.RUnlock()\n\t\t\t\tfc.Close()\n\t\t\t\tbreak\n\t\t\t}\n\t\t\tfc.mu.RUnlock()\n\t\t}\n\t}()\n\treturn fc\n}\n\nfunc (c *FakeUDPConn) putPacket(content []byte) {\n\tdefer func() {\n\t\t_ = recover()\n\t}()\n\n\tselect {\n\tcase c.packets <- content:\n\tdefault:\n\t}\n}\n\nfunc (c *FakeUDPConn) Read(b []byte) (n int, err error) {\n\tcontent, ok := <-c.packets\n\tif !ok {\n\t\treturn 0, io.EOF\n\t}\n\tc.mu.Lock()\n\tc.lastActive = time.Now()\n\tc.mu.Unlock()\n\n\tn = min(len(b), len(content))\n\tcopy(b, content)\n\treturn n, nil\n}\n\nfunc (c *FakeUDPConn) Write(b []byte) (n int, err error) {\n\tc.mu.RLock()\n\tif c.closeFlag {\n\t\tc.mu.RUnlock()\n\t\treturn 0, io.ErrClosedPipe\n\t}\n\tc.mu.RUnlock()\n\n\tpacket := &UDPPacket{\n\t\tBuf:        b,\n\t\tLocalAddr:  c.localAddr,\n\t\tRemoteAddr: c.remoteAddr,\n\t}\n\t_ = c.l.writeUDPPacket(packet)\n\n\tc.mu.Lock()\n\tc.lastActive = time.Now()\n\tc.mu.Unlock()\n\treturn len(b), nil\n}\n\nfunc (c *FakeUDPConn) Close() error {\n\tc.mu.Lock()\n\tdefer c.mu.Unlock()\n\tif !c.closeFlag {\n\t\tc.closeFlag = true\n\t\tclose(c.packets)\n\t}\n\treturn nil\n}\n\nfunc (c *FakeUDPConn) IsClosed() bool {\n\tc.mu.RLock()\n\tdefer c.mu.RUnlock()\n\treturn c.closeFlag\n}\n\nfunc (c *FakeUDPConn) LocalAddr() net.Addr {\n\treturn c.localAddr\n}\n\nfunc (c *FakeUDPConn) RemoteAddr() net.Addr {\n\treturn c.remoteAddr\n}\n\nfunc (c *FakeUDPConn) SetDeadline(_ time.Time) error {\n\treturn nil\n}\n\nfunc (c *FakeUDPConn) SetReadDeadline(_ time.Time) error {\n\treturn nil\n}\n\nfunc (c *FakeUDPConn) SetWriteDeadline(_ time.Time) error {\n\treturn nil\n}\n\ntype UDPListener struct {\n\taddr      net.Addr\n\tacceptCh  chan net.Conn\n\twriteCh   chan *UDPPacket\n\treadConn  net.Conn\n\tcloseFlag bool\n\n\tfakeConns map[string]*FakeUDPConn\n}\n\nfunc ListenUDP(bindAddr string, bindPort int) (l *UDPListener, err error) {\n\tudpAddr, err := net.ResolveUDPAddr(\"udp\", net.JoinHostPort(bindAddr, strconv.Itoa(bindPort)))\n\tif err != nil {\n\t\treturn l, err\n\t}\n\treadConn, err := net.ListenUDP(\"udp\", udpAddr)\n\tif err != nil {\n\t\treturn l, err\n\t}\n\n\tl = &UDPListener{\n\t\taddr:      udpAddr,\n\t\tacceptCh:  make(chan net.Conn),\n\t\twriteCh:   make(chan *UDPPacket, 1000),\n\t\treadConn:  readConn,\n\t\tfakeConns: make(map[string]*FakeUDPConn),\n\t}\n\n\t// for reading\n\tgo func() {\n\t\tfor {\n\t\t\tbuf := pool.GetBuf(1450)\n\t\t\tn, remoteAddr, err := readConn.ReadFromUDP(buf)\n\t\t\tif err != nil {\n\t\t\t\tclose(l.acceptCh)\n\t\t\t\tclose(l.writeCh)\n\t\t\t\treturn\n\t\t\t}\n\n\t\t\tfakeConn, exist := l.fakeConns[remoteAddr.String()]\n\t\t\tif !exist || fakeConn.IsClosed() {\n\t\t\t\tfakeConn = NewFakeUDPConn(l, l.Addr(), remoteAddr)\n\t\t\t\tl.fakeConns[remoteAddr.String()] = fakeConn\n\t\t\t}\n\t\t\tfakeConn.putPacket(buf[:n])\n\n\t\t\tl.acceptCh <- fakeConn\n\t\t}\n\t}()\n\n\t// for writing\n\tgo func() {\n\t\tfor {\n\t\t\tpacket, ok := <-l.writeCh\n\t\t\tif !ok {\n\t\t\t\treturn\n\t\t\t}\n\n\t\t\tif addr, ok := packet.RemoteAddr.(*net.UDPAddr); ok {\n\t\t\t\t_, _ = readConn.WriteToUDP(packet.Buf, addr)\n\t\t\t}\n\t\t}\n\t}()\n\n\treturn\n}\n\nfunc (l *UDPListener) writeUDPPacket(packet *UDPPacket) (err error) {\n\tdefer func() {\n\t\tif errRet := recover(); errRet != nil {\n\t\t\terr = fmt.Errorf(\"udp write closed listener\")\n\t\t}\n\t}()\n\tl.writeCh <- packet\n\treturn\n}\n\nfunc (l *UDPListener) WriteMsg(buf []byte, remoteAddr *net.UDPAddr) (err error) {\n\t// only set remote addr here\n\tpacket := &UDPPacket{\n\t\tBuf:        buf,\n\t\tRemoteAddr: remoteAddr,\n\t}\n\terr = l.writeUDPPacket(packet)\n\treturn\n}\n\nfunc (l *UDPListener) Accept() (net.Conn, error) {\n\tconn, ok := <-l.acceptCh\n\tif !ok {\n\t\treturn conn, fmt.Errorf(\"channel for udp listener closed\")\n\t}\n\treturn conn, nil\n}\n\nfunc (l *UDPListener) Close() error {\n\tif !l.closeFlag {\n\t\tl.closeFlag = true\n\t\tif l.readConn != nil {\n\t\t\tl.readConn.Close()\n\t\t}\n\t}\n\treturn nil\n}\n\nfunc (l *UDPListener) Addr() net.Addr {\n\treturn l.addr\n}\n\n// ConnectedUDPConn is a wrapper for net.UDPConn which converts WriteTo syscalls\n// to Write syscalls that are 4 times faster on some OS'es. This should only be\n// used for connections that were produced by a net.Dial* call.\ntype ConnectedUDPConn struct{ *net.UDPConn }\n\n// WriteTo redirects all writes to the Write syscall, which is 4 times faster.\nfunc (c *ConnectedUDPConn) WriteTo(b []byte, _ net.Addr) (int, error) { return c.Write(b) }\n"
  },
  {
    "path": "pkg/util/net/websocket.go",
    "content": "package net\n\nimport (\n\t\"errors\"\n\t\"net\"\n\t\"net/http\"\n\t\"time\"\n\n\t\"golang.org/x/net/websocket\"\n)\n\nvar ErrWebsocketListenerClosed = errors.New(\"websocket listener closed\")\n\nconst (\n\tFrpWebsocketPath = \"/~!frp\"\n)\n\ntype WebsocketListener struct {\n\tln       net.Listener\n\tacceptCh chan net.Conn\n\n\tserver *http.Server\n}\n\n// NewWebsocketListener to handle websocket connections\n// ln: tcp listener for websocket connections\nfunc NewWebsocketListener(ln net.Listener) (wl *WebsocketListener) {\n\twl = &WebsocketListener{\n\t\tln:       ln,\n\t\tacceptCh: make(chan net.Conn),\n\t}\n\n\tmuxer := http.NewServeMux()\n\tmuxer.Handle(FrpWebsocketPath, websocket.Handler(func(c *websocket.Conn) {\n\t\tnotifyCh := make(chan struct{})\n\t\tconn := WrapCloseNotifyConn(c, func(_ error) {\n\t\t\tclose(notifyCh)\n\t\t})\n\t\twl.acceptCh <- conn\n\t\t<-notifyCh\n\t}))\n\n\twl.server = &http.Server{\n\t\tAddr:              ln.Addr().String(),\n\t\tHandler:           muxer,\n\t\tReadHeaderTimeout: 60 * time.Second,\n\t}\n\n\tgo func() {\n\t\t_ = wl.server.Serve(ln)\n\t}()\n\treturn\n}\n\nfunc (p *WebsocketListener) Accept() (net.Conn, error) {\n\tc, ok := <-p.acceptCh\n\tif !ok {\n\t\treturn nil, ErrWebsocketListenerClosed\n\t}\n\treturn c, nil\n}\n\nfunc (p *WebsocketListener) Close() error {\n\treturn p.server.Close()\n}\n\nfunc (p *WebsocketListener) Addr() net.Addr {\n\treturn p.ln.Addr()\n}\n"
  },
  {
    "path": "pkg/util/system/system.go",
    "content": "// Copyright 2024 The frp Authors\n//\n// Licensed under the Apache License, Version 2.0 (the \"License\");\n// you may not use this file except in compliance with the License.\n// You may obtain a copy of the License at\n//\n//     http://www.apache.org/licenses/LICENSE-2.0\n//\n// Unless required by applicable law or agreed to in writing, software\n// distributed under the License is distributed on an \"AS IS\" BASIS,\n// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.\n// See the License for the specific language governing permissions and\n// limitations under the License.\n\n//go:build !android\n\npackage system\n\n// EnableCompatibilityMode enables compatibility mode for different system.\n// For example, on Android, the inability to obtain the correct time zone will result in incorrect log time output.\nfunc EnableCompatibilityMode() {\n}\n"
  },
  {
    "path": "pkg/util/system/system_android.go",
    "content": "// Copyright 2024 The frp Authors\n//\n// Licensed under the Apache License, Version 2.0 (the \"License\");\n// you may not use this file except in compliance with the License.\n// You may obtain a copy of the License at\n//\n//     http://www.apache.org/licenses/LICENSE-2.0\n//\n// Unless required by applicable law or agreed to in writing, software\n// distributed under the License is distributed on an \"AS IS\" BASIS,\n// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.\n// See the License for the specific language governing permissions and\n// limitations under the License.\n\npackage system\n\nimport (\n\t\"context\"\n\t\"net\"\n\t\"os/exec\"\n\t\"strings\"\n\t\"time\"\n)\n\nfunc EnableCompatibilityMode() {\n\tfixTimezone()\n\tfixDNSResolver()\n}\n\n// fixTimezone is used to try our best to fix timezone issue on some Android devices.\nfunc fixTimezone() {\n\tout, err := exec.Command(\"/system/bin/getprop\", \"persist.sys.timezone\").Output()\n\tif err != nil {\n\t\treturn\n\t}\n\tloc, err := time.LoadLocation(strings.TrimSpace(string(out)))\n\tif err != nil {\n\t\treturn\n\t}\n\ttime.Local = loc\n}\n\n// fixDNSResolver will first attempt to resolve google.com to check if the current DNS is available.\n// If it is not available, it will default to using 8.8.8.8 as the DNS server.\n// This is a workaround for the issue that golang can't get the default DNS servers on Android.\nfunc fixDNSResolver() {\n\t// First, we attempt to resolve a domain. If resolution is successful, no modifications are necessary.\n\t// In real-world scenarios, users may have already configured /etc/resolv.conf, or compiled directly\n\t// in the Android environment instead of using cross-platform compilation, so this issue does not arise.\n\tif net.DefaultResolver != nil {\n\t\ttimeoutCtx, cancel := context.WithTimeout(context.Background(), time.Second)\n\t\tdefer cancel()\n\t\t_, err := net.DefaultResolver.LookupHost(timeoutCtx, \"google.com\")\n\t\tif err == nil {\n\t\t\treturn\n\t\t}\n\t}\n\t// If the resolution fails, use 8.8.8.8 as the DNS server.\n\t// Note: If there are other methods to obtain the default DNS servers, the default DNS servers should be used preferentially.\n\tnet.DefaultResolver = &net.Resolver{\n\t\tPreferGo: true,\n\t\tDial: func(ctx context.Context, network, addr string) (net.Conn, error) {\n\t\t\tif addr == \"127.0.0.1:53\" || addr == \"[::1]:53\" {\n\t\t\t\taddr = \"8.8.8.8:53\"\n\t\t\t}\n\t\t\tvar d net.Dialer\n\t\t\treturn d.DialContext(ctx, network, addr)\n\t\t},\n\t}\n}\n"
  },
  {
    "path": "pkg/util/tcpmux/httpconnect.go",
    "content": "// Copyright 2020 guylewin, guy@lewin.co.il\n//\n// Licensed under the Apache License, Version 2.0 (the \"License\");\n// you may not use this file except in compliance with the License.\n// You may obtain a copy of the License at\n//\n//     http://www.apache.org/licenses/LICENSE-2.0\n//\n// Unless required by applicable law or agreed to in writing, software\n// distributed under the License is distributed on an \"AS IS\" BASIS,\n// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.\n// See the License for the specific language governing permissions and\n// limitations under the License.\n\npackage tcpmux\n\nimport (\n\t\"bufio\"\n\t\"fmt\"\n\t\"io\"\n\t\"net\"\n\t\"net/http\"\n\t\"time\"\n\n\tlibnet \"github.com/fatedier/golib/net\"\n\n\thttppkg \"github.com/fatedier/frp/pkg/util/http\"\n\t\"github.com/fatedier/frp/pkg/util/vhost\"\n)\n\ntype HTTPConnectTCPMuxer struct {\n\t*vhost.Muxer\n\n\t// If passthrough is set to true, the CONNECT request will be forwarded to the backend service.\n\t// Otherwise, it will return an OK response to the client and forward the remaining content to the backend service.\n\tpassthrough bool\n}\n\nfunc NewHTTPConnectTCPMuxer(listener net.Listener, passthrough bool, timeout time.Duration) (*HTTPConnectTCPMuxer, error) {\n\tret := &HTTPConnectTCPMuxer{passthrough: passthrough}\n\tmux, err := vhost.NewMuxer(listener, ret.getHostFromHTTPConnect, timeout)\n\tmux.SetCheckAuthFunc(ret.auth).\n\t\tSetSuccessHookFunc(ret.sendConnectResponse).\n\t\tSetFailHookFunc(vhostFailed)\n\tret.Muxer = mux\n\treturn ret, err\n}\n\nfunc (muxer *HTTPConnectTCPMuxer) readHTTPConnectRequest(rd io.Reader) (host, httpUser, httpPwd string, err error) {\n\tbufioReader := bufio.NewReader(rd)\n\n\treq, err := http.ReadRequest(bufioReader)\n\tif err != nil {\n\t\treturn\n\t}\n\n\tif req.Method != \"CONNECT\" {\n\t\terr = fmt.Errorf(\"connections to tcp vhost must be of method CONNECT\")\n\t\treturn\n\t}\n\n\thost, _ = httppkg.CanonicalHost(req.Host)\n\tproxyAuth := req.Header.Get(\"Proxy-Authorization\")\n\tif proxyAuth != \"\" {\n\t\thttpUser, httpPwd, _ = httppkg.ParseBasicAuth(proxyAuth)\n\t}\n\treturn\n}\n\nfunc (muxer *HTTPConnectTCPMuxer) sendConnectResponse(c net.Conn, _ map[string]string) error {\n\tif muxer.passthrough {\n\t\treturn nil\n\t}\n\tres := httppkg.OkResponse()\n\tif res.Body != nil {\n\t\tdefer res.Body.Close()\n\t}\n\treturn res.Write(c)\n}\n\nfunc (muxer *HTTPConnectTCPMuxer) auth(c net.Conn, username, password string, reqInfo map[string]string) (bool, error) {\n\treqUsername := reqInfo[\"HTTPUser\"]\n\treqPassword := reqInfo[\"HTTPPwd\"]\n\tif username == reqUsername && password == reqPassword {\n\t\treturn true, nil\n\t}\n\n\tresp := httppkg.ProxyUnauthorizedResponse()\n\tif resp.Body != nil {\n\t\tdefer resp.Body.Close()\n\t}\n\t_ = resp.Write(c)\n\treturn false, nil\n}\n\nfunc vhostFailed(c net.Conn) {\n\tres := vhost.NotFoundResponse()\n\tif res.Body != nil {\n\t\tdefer res.Body.Close()\n\t}\n\t_ = res.Write(c)\n\t_ = c.Close()\n}\n\nfunc (muxer *HTTPConnectTCPMuxer) getHostFromHTTPConnect(c net.Conn) (net.Conn, map[string]string, error) {\n\treqInfoMap := make(map[string]string, 0)\n\tsc, rd := libnet.NewSharedConn(c)\n\n\thost, httpUser, httpPwd, err := muxer.readHTTPConnectRequest(rd)\n\tif err != nil {\n\t\treturn nil, reqInfoMap, err\n\t}\n\n\treqInfoMap[\"Host\"] = host\n\treqInfoMap[\"Scheme\"] = \"tcp\"\n\treqInfoMap[\"HTTPUser\"] = httpUser\n\treqInfoMap[\"HTTPPwd\"] = httpPwd\n\n\toutConn := c\n\tif muxer.passthrough {\n\t\toutConn = sc\n\t}\n\treturn outConn, reqInfoMap, nil\n}\n"
  },
  {
    "path": "pkg/util/util/types.go",
    "content": "// Copyright 2023 The frp Authors\n//\n// Licensed under the Apache License, Version 2.0 (the \"License\");\n// you may not use this file except in compliance with the License.\n// You may obtain a copy of the License at\n//\n//     http://www.apache.org/licenses/LICENSE-2.0\n//\n// Unless required by applicable law or agreed to in writing, software\n// distributed under the License is distributed on an \"AS IS\" BASIS,\n// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.\n// See the License for the specific language governing permissions and\n// limitations under the License.\n\npackage util\n\nfunc EmptyOr[T comparable](v T, fallback T) T {\n\tvar zero T\n\tif zero == v {\n\t\treturn fallback\n\t}\n\treturn v\n}\n"
  },
  {
    "path": "pkg/util/util/util.go",
    "content": "// Copyright 2017 fatedier, fatedier@gmail.com\n//\n// Licensed under the Apache License, Version 2.0 (the \"License\");\n// you may not use this file except in compliance with the License.\n// You may obtain a copy of the License at\n//\n//     http://www.apache.org/licenses/LICENSE-2.0\n//\n// Unless required by applicable law or agreed to in writing, software\n// distributed under the License is distributed on an \"AS IS\" BASIS,\n// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.\n// See the License for the specific language governing permissions and\n// limitations under the License.\n\npackage util\n\nimport (\n\t\"crypto/md5\"\n\t\"crypto/rand\"\n\t\"crypto/subtle\"\n\t\"encoding/hex\"\n\t\"fmt\"\n\tmathrand \"math/rand/v2\"\n\t\"net\"\n\t\"strconv\"\n\t\"strings\"\n\t\"time\"\n)\n\n// RandID return a rand string used in frp.\nfunc RandID() (id string, err error) {\n\treturn RandIDWithLen(16)\n}\n\n// RandIDWithLen return a rand string with idLen length.\nfunc RandIDWithLen(idLen int) (id string, err error) {\n\tif idLen <= 0 {\n\t\treturn \"\", nil\n\t}\n\tb := make([]byte, idLen/2+1)\n\t_, err = rand.Read(b)\n\tif err != nil {\n\t\treturn\n\t}\n\n\tid = fmt.Sprintf(\"%x\", b)\n\treturn id[:idLen], nil\n}\n\nfunc GetAuthKey(token string, timestamp int64) (key string) {\n\tmd5Ctx := md5.New()\n\tmd5Ctx.Write([]byte(token))\n\tmd5Ctx.Write([]byte(strconv.FormatInt(timestamp, 10)))\n\tdata := md5Ctx.Sum(nil)\n\treturn hex.EncodeToString(data)\n}\n\nfunc CanonicalAddr(host string, port int) (addr string) {\n\tif port == 80 || port == 443 {\n\t\taddr = host\n\t} else {\n\t\taddr = net.JoinHostPort(host, strconv.Itoa(port))\n\t}\n\treturn\n}\n\nfunc ParseRangeNumbers(rangeStr string) (numbers []int64, err error) {\n\trangeStr = strings.TrimSpace(rangeStr)\n\tnumbers = make([]int64, 0)\n\t// e.g. 1000-2000,2001,2002,3000-4000\n\tnumRanges := strings.SplitSeq(rangeStr, \",\")\n\tfor numRangeStr := range numRanges {\n\t\t// 1000-2000 or 2001\n\t\tnumArray := strings.Split(numRangeStr, \"-\")\n\t\t// length: only 1 or 2 is correct\n\t\trangeType := len(numArray)\n\t\tswitch rangeType {\n\t\tcase 1:\n\t\t\t// single number\n\t\t\tsingleNum, errRet := strconv.ParseInt(strings.TrimSpace(numArray[0]), 10, 64)\n\t\t\tif errRet != nil {\n\t\t\t\terr = fmt.Errorf(\"range number is invalid, %v\", errRet)\n\t\t\t\treturn\n\t\t\t}\n\t\t\tnumbers = append(numbers, singleNum)\n\t\tcase 2:\n\t\t\t// range numbers\n\t\t\tminValue, errRet := strconv.ParseInt(strings.TrimSpace(numArray[0]), 10, 64)\n\t\t\tif errRet != nil {\n\t\t\t\terr = fmt.Errorf(\"range number is invalid, %v\", errRet)\n\t\t\t\treturn\n\t\t\t}\n\t\t\tmaxValue, errRet := strconv.ParseInt(strings.TrimSpace(numArray[1]), 10, 64)\n\t\t\tif errRet != nil {\n\t\t\t\terr = fmt.Errorf(\"range number is invalid, %v\", errRet)\n\t\t\t\treturn\n\t\t\t}\n\t\t\tif maxValue < minValue {\n\t\t\t\terr = fmt.Errorf(\"range number is invalid\")\n\t\t\t\treturn\n\t\t\t}\n\t\t\tfor i := minValue; i <= maxValue; i++ {\n\t\t\t\tnumbers = append(numbers, i)\n\t\t\t}\n\t\tdefault:\n\t\t\terr = fmt.Errorf(\"range number is invalid\")\n\t\t\treturn\n\t\t}\n\t}\n\treturn\n}\n\nfunc GenerateResponseErrorString(summary string, err error, detailed bool) string {\n\tif detailed {\n\t\treturn err.Error()\n\t}\n\treturn summary\n}\n\nfunc RandomSleep(duration time.Duration, minRatio, maxRatio float64) time.Duration {\n\tminValue := int64(minRatio * 1000.0)\n\tmaxValue := int64(maxRatio * 1000.0)\n\tvar n int64\n\tif maxValue <= minValue {\n\t\tn = minValue\n\t} else {\n\t\tn = mathrand.Int64N(maxValue-minValue) + minValue\n\t}\n\td := duration * time.Duration(n) / time.Duration(1000)\n\ttime.Sleep(d)\n\treturn d\n}\n\nfunc ConstantTimeEqString(a, b string) bool {\n\treturn subtle.ConstantTimeCompare([]byte(a), []byte(b)) == 1\n}\n\n// ClonePtr returns a pointer to a copied value. If v is nil, it returns nil.\nfunc ClonePtr[T any](v *T) *T {\n\tif v == nil {\n\t\treturn nil\n\t}\n\tout := *v\n\treturn &out\n}\n"
  },
  {
    "path": "pkg/util/util/util_test.go",
    "content": "package util\n\nimport (\n\t\"testing\"\n\n\t\"github.com/stretchr/testify/require\"\n)\n\nfunc TestRandId(t *testing.T) {\n\trequire := require.New(t)\n\tid, err := RandID()\n\trequire.NoError(err)\n\tt.Log(id)\n\trequire.Equal(16, len(id))\n}\n\nfunc TestGetAuthKey(t *testing.T) {\n\trequire := require.New(t)\n\tkey := GetAuthKey(\"1234\", 1488720000)\n\trequire.Equal(\"6df41a43725f0c770fd56379e12acf8c\", key)\n}\n\nfunc TestParseRangeNumbers(t *testing.T) {\n\trequire := require.New(t)\n\tnumbers, err := ParseRangeNumbers(\"2-5\")\n\trequire.NoError(err)\n\trequire.Equal([]int64{2, 3, 4, 5}, numbers)\n\n\tnumbers, err = ParseRangeNumbers(\"1\")\n\trequire.NoError(err)\n\trequire.Equal([]int64{1}, numbers)\n\n\tnumbers, err = ParseRangeNumbers(\"3-5,8\")\n\trequire.NoError(err)\n\trequire.Equal([]int64{3, 4, 5, 8}, numbers)\n\n\tnumbers, err = ParseRangeNumbers(\" 3-5,8, 10-12 \")\n\trequire.NoError(err)\n\trequire.Equal([]int64{3, 4, 5, 8, 10, 11, 12}, numbers)\n\n\t_, err = ParseRangeNumbers(\"3-a\")\n\trequire.Error(err)\n}\n\nfunc TestClonePtr(t *testing.T) {\n\trequire := require.New(t)\n\n\tvar nilInt *int\n\trequire.Nil(ClonePtr(nilInt))\n\n\tv := 42\n\tcloned := ClonePtr(&v)\n\trequire.NotNil(cloned)\n\trequire.Equal(v, *cloned)\n\trequire.NotSame(&v, cloned)\n}\n"
  },
  {
    "path": "pkg/util/version/version.go",
    "content": "// Copyright 2016 fatedier, fatedier@gmail.com\n//\n// Licensed under the Apache License, Version 2.0 (the \"License\");\n// you may not use this file except in compliance with the License.\n// You may obtain a copy of the License at\n//\n//     http://www.apache.org/licenses/LICENSE-2.0\n//\n// Unless required by applicable law or agreed to in writing, software\n// distributed under the License is distributed on an \"AS IS\" BASIS,\n// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.\n// See the License for the specific language governing permissions and\n// limitations under the License.\n\npackage version\n\nvar version = \"0.68.0\"\n\nfunc Full() string {\n\treturn version\n}\n"
  },
  {
    "path": "pkg/util/vhost/http.go",
    "content": "// Copyright 2017 fatedier, fatedier@gmail.com\n//\n// Licensed under the Apache License, Version 2.0 (the \"License\");\n// you may not use this file except in compliance with the License.\n// You may obtain a copy of the License at\n//\n//     http://www.apache.org/licenses/LICENSE-2.0\n//\n// Unless required by applicable law or agreed to in writing, software\n// distributed under the License is distributed on an \"AS IS\" BASIS,\n// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.\n// See the License for the specific language governing permissions and\n// limitations under the License.\n\npackage vhost\n\nimport (\n\t\"context\"\n\t\"encoding/base64\"\n\t\"errors\"\n\t\"fmt\"\n\tstdlog \"log\"\n\t\"net\"\n\t\"net/http\"\n\t\"net/http/httputil\"\n\t\"net/url\"\n\t\"strings\"\n\t\"time\"\n\n\tlibio \"github.com/fatedier/golib/io\"\n\t\"github.com/fatedier/golib/pool\"\n\t\"golang.org/x/net/http2\"\n\t\"golang.org/x/net/http2/h2c\"\n\n\thttppkg \"github.com/fatedier/frp/pkg/util/http\"\n\t\"github.com/fatedier/frp/pkg/util/log\"\n)\n\nvar ErrNoRouteFound = errors.New(\"no route found\")\n\ntype HTTPReverseProxyOptions struct {\n\tResponseHeaderTimeoutS int64\n}\n\ntype HTTPReverseProxy struct {\n\tproxy       http.Handler\n\tvhostRouter *Routers\n\n\tresponseHeaderTimeout time.Duration\n}\n\nfunc NewHTTPReverseProxy(option HTTPReverseProxyOptions, vhostRouter *Routers) *HTTPReverseProxy {\n\tif option.ResponseHeaderTimeoutS <= 0 {\n\t\toption.ResponseHeaderTimeoutS = 60\n\t}\n\trp := &HTTPReverseProxy{\n\t\tresponseHeaderTimeout: time.Duration(option.ResponseHeaderTimeoutS) * time.Second,\n\t\tvhostRouter:           vhostRouter,\n\t}\n\tproxy := &httputil.ReverseProxy{\n\t\t// Modify incoming requests by route policies.\n\t\tRewrite: func(r *httputil.ProxyRequest) {\n\t\t\tr.Out.Header[\"X-Forwarded-For\"] = r.In.Header[\"X-Forwarded-For\"]\n\t\t\tr.SetXForwarded()\n\t\t\treq := r.Out\n\t\t\treq.URL.Scheme = \"http\"\n\t\t\treqRouteInfo := req.Context().Value(RouteInfoKey).(*RequestRouteInfo)\n\t\t\toriginalHost, _ := httppkg.CanonicalHost(reqRouteInfo.Host)\n\n\t\t\trc := req.Context().Value(RouteConfigKey).(*RouteConfig)\n\t\t\tif rc != nil {\n\t\t\t\tif rc.RewriteHost != \"\" {\n\t\t\t\t\treq.Host = rc.RewriteHost\n\t\t\t\t}\n\n\t\t\t\tvar endpoint string\n\t\t\t\tif rc.ChooseEndpointFn != nil {\n\t\t\t\t\t// ignore error here, it will use CreateConnFn instead later\n\t\t\t\t\tendpoint, _ = rc.ChooseEndpointFn()\n\t\t\t\t\treqRouteInfo.Endpoint = endpoint\n\t\t\t\t\tlog.Tracef(\"choose endpoint name [%s] for http request host [%s] path [%s] httpuser [%s]\",\n\t\t\t\t\t\tendpoint, originalHost, reqRouteInfo.URL, reqRouteInfo.HTTPUser)\n\t\t\t\t}\n\t\t\t\t// Set {domain}.{location}.{routeByHTTPUser}.{endpoint} as URL host here to let http transport reuse connections.\n\t\t\t\treq.URL.Host = rc.Domain + \".\" +\n\t\t\t\t\tbase64.StdEncoding.EncodeToString([]byte(rc.Location)) + \".\" +\n\t\t\t\t\tbase64.StdEncoding.EncodeToString([]byte(rc.RouteByHTTPUser)) + \".\" +\n\t\t\t\t\tbase64.StdEncoding.EncodeToString([]byte(endpoint))\n\n\t\t\t\tfor k, v := range rc.Headers {\n\t\t\t\t\treq.Header.Set(k, v)\n\t\t\t\t}\n\t\t\t} else {\n\t\t\t\treq.URL.Host = req.Host\n\t\t\t}\n\t\t},\n\t\tModifyResponse: func(r *http.Response) error {\n\t\t\trc := r.Request.Context().Value(RouteConfigKey).(*RouteConfig)\n\t\t\tif rc != nil {\n\t\t\t\tfor k, v := range rc.ResponseHeaders {\n\t\t\t\t\tr.Header.Set(k, v)\n\t\t\t\t}\n\t\t\t}\n\t\t\treturn nil\n\t\t},\n\t\t// Create a connection to one proxy routed by route policy.\n\t\tTransport: &http.Transport{\n\t\t\tResponseHeaderTimeout: rp.responseHeaderTimeout,\n\t\t\tIdleConnTimeout:       60 * time.Second,\n\t\t\tMaxIdleConnsPerHost:   5,\n\t\t\tDialContext: func(ctx context.Context, network, addr string) (net.Conn, error) {\n\t\t\t\treturn rp.CreateConnection(ctx.Value(RouteInfoKey).(*RequestRouteInfo), true)\n\t\t\t},\n\t\t\tProxy: func(req *http.Request) (*url.URL, error) {\n\t\t\t\t// Use proxy mode if there is host in HTTP first request line.\n\t\t\t\t// GET http://example.com/ HTTP/1.1\n\t\t\t\t// Host: example.com\n\t\t\t\t//\n\t\t\t\t// Normal:\n\t\t\t\t// GET / HTTP/1.1\n\t\t\t\t// Host: example.com\n\t\t\t\turlHost := req.Context().Value(RouteInfoKey).(*RequestRouteInfo).URLHost\n\t\t\t\tif urlHost != \"\" {\n\t\t\t\t\treturn req.URL, nil\n\t\t\t\t}\n\t\t\t\treturn nil, nil\n\t\t\t},\n\t\t},\n\t\tBufferPool: pool.NewBuffer(32 * 1024),\n\t\tErrorLog:   stdlog.New(log.NewWriteLogger(log.WarnLevel, 2), \"\", 0),\n\t\tErrorHandler: func(rw http.ResponseWriter, req *http.Request, err error) {\n\t\t\tlog.Logf(log.WarnLevel, 1, \"do http proxy request [host: %s] error: %v\", req.Host, err)\n\t\t\tif err != nil {\n\t\t\t\tif e, ok := err.(net.Error); ok && e.Timeout() {\n\t\t\t\t\trw.WriteHeader(http.StatusGatewayTimeout)\n\t\t\t\t\treturn\n\t\t\t\t}\n\t\t\t}\n\t\t\trw.WriteHeader(http.StatusNotFound)\n\t\t\t_, _ = rw.Write(getNotFoundPageContent())\n\t\t},\n\t}\n\trp.proxy = h2c.NewHandler(proxy, &http2.Server{})\n\treturn rp\n}\n\n// Register register the route config to reverse proxy\n// reverse proxy will use CreateConnFn from routeCfg to create a connection to the remote service\nfunc (rp *HTTPReverseProxy) Register(routeCfg RouteConfig) error {\n\terr := rp.vhostRouter.Add(routeCfg.Domain, routeCfg.Location, routeCfg.RouteByHTTPUser, &routeCfg)\n\tif err != nil {\n\t\treturn err\n\t}\n\treturn nil\n}\n\n// UnRegister unregister route config by domain and location\nfunc (rp *HTTPReverseProxy) UnRegister(routeCfg RouteConfig) {\n\trp.vhostRouter.Del(routeCfg.Domain, routeCfg.Location, routeCfg.RouteByHTTPUser)\n}\n\nfunc (rp *HTTPReverseProxy) GetRouteConfig(domain, location, routeByHTTPUser string) *RouteConfig {\n\tvr, ok := rp.getVhost(domain, location, routeByHTTPUser)\n\tif ok {\n\t\tlog.Debugf(\"get new http request host [%s] path [%s] httpuser [%s]\", domain, location, routeByHTTPUser)\n\t\treturn vr.payload.(*RouteConfig)\n\t}\n\treturn nil\n}\n\n// CreateConnection create a new connection by route config\nfunc (rp *HTTPReverseProxy) CreateConnection(reqRouteInfo *RequestRouteInfo, byEndpoint bool) (net.Conn, error) {\n\thost, _ := httppkg.CanonicalHost(reqRouteInfo.Host)\n\tvr, ok := rp.getVhost(host, reqRouteInfo.URL, reqRouteInfo.HTTPUser)\n\tif ok {\n\t\tif byEndpoint {\n\t\t\tfn := vr.payload.(*RouteConfig).CreateConnByEndpointFn\n\t\t\tif fn != nil {\n\t\t\t\treturn fn(reqRouteInfo.Endpoint, reqRouteInfo.RemoteAddr)\n\t\t\t}\n\t\t}\n\t\tfn := vr.payload.(*RouteConfig).CreateConnFn\n\t\tif fn != nil {\n\t\t\treturn fn(reqRouteInfo.RemoteAddr)\n\t\t}\n\t}\n\treturn nil, fmt.Errorf(\"%v: %s %s %s\", ErrNoRouteFound, host, reqRouteInfo.URL, reqRouteInfo.HTTPUser)\n}\n\nfunc (rp *HTTPReverseProxy) CheckAuth(domain, location, routeByHTTPUser, user, passwd string) bool {\n\tvr, ok := rp.getVhost(domain, location, routeByHTTPUser)\n\tif ok {\n\t\tcheckUser := vr.payload.(*RouteConfig).Username\n\t\tcheckPasswd := vr.payload.(*RouteConfig).Password\n\t\tif (checkUser != \"\" || checkPasswd != \"\") && (checkUser != user || checkPasswd != passwd) {\n\t\t\treturn false\n\t\t}\n\t}\n\treturn true\n}\n\n// getVhost tries to get vhost router by route policy.\nfunc (rp *HTTPReverseProxy) getVhost(domain, location, routeByHTTPUser string) (*Router, bool) {\n\tfindRouter := func(inDomain, inLocation, inRouteByHTTPUser string) (*Router, bool) {\n\t\tvr, ok := rp.vhostRouter.Get(inDomain, inLocation, inRouteByHTTPUser)\n\t\tif ok {\n\t\t\treturn vr, ok\n\t\t}\n\t\t// Try to check if there is one proxy that doesn't specify routerByHTTPUser, it means match all.\n\t\tvr, ok = rp.vhostRouter.Get(inDomain, inLocation, \"\")\n\t\tif ok {\n\t\t\treturn vr, ok\n\t\t}\n\t\treturn nil, false\n\t}\n\n\t// First we check the full hostname\n\t// if not exist, then check the wildcard_domain such as *.example.com\n\tvr, ok := findRouter(domain, location, routeByHTTPUser)\n\tif ok {\n\t\treturn vr, ok\n\t}\n\n\t// e.g. domain = test.example.com, try to match wildcard domains.\n\t// *.example.com\n\t// *.com\n\tdomainSplit := strings.Split(domain, \".\")\n\tfor len(domainSplit) >= 3 {\n\t\tdomainSplit[0] = \"*\"\n\t\tdomain = strings.Join(domainSplit, \".\")\n\t\tvr, ok = findRouter(domain, location, routeByHTTPUser)\n\t\tif ok {\n\t\t\treturn vr, true\n\t\t}\n\t\tdomainSplit = domainSplit[1:]\n\t}\n\n\t// Finally, try to check if there is one proxy that domain is \"*\" means match all domains.\n\tvr, ok = findRouter(\"*\", location, routeByHTTPUser)\n\tif ok {\n\t\treturn vr, true\n\t}\n\treturn nil, false\n}\n\nfunc (rp *HTTPReverseProxy) connectHandler(rw http.ResponseWriter, req *http.Request) {\n\thj, ok := rw.(http.Hijacker)\n\tif !ok {\n\t\trw.WriteHeader(http.StatusInternalServerError)\n\t\treturn\n\t}\n\n\tclient, _, err := hj.Hijack()\n\tif err != nil {\n\t\trw.WriteHeader(http.StatusInternalServerError)\n\t\treturn\n\t}\n\n\tremote, err := rp.CreateConnection(req.Context().Value(RouteInfoKey).(*RequestRouteInfo), false)\n\tif err != nil {\n\t\t_ = NotFoundResponse().Write(client)\n\t\tclient.Close()\n\t\treturn\n\t}\n\t_ = req.Write(remote)\n\tgo libio.Join(remote, client)\n}\n\nfunc (rp *HTTPReverseProxy) injectRequestInfoToCtx(req *http.Request) *http.Request {\n\tuser := \"\"\n\t// If url host isn't empty, it's a proxy request. Get http user from Proxy-Authorization header.\n\tif req.URL.Host != \"\" {\n\t\tproxyAuth := req.Header.Get(\"Proxy-Authorization\")\n\t\tif proxyAuth != \"\" {\n\t\t\tuser, _, _ = httppkg.ParseBasicAuth(proxyAuth)\n\t\t}\n\t}\n\tif user == \"\" {\n\t\tuser, _, _ = req.BasicAuth()\n\t}\n\n\treqRouteInfo := &RequestRouteInfo{\n\t\tURL:        req.URL.Path,\n\t\tHost:       req.Host,\n\t\tHTTPUser:   user,\n\t\tRemoteAddr: req.RemoteAddr,\n\t\tURLHost:    req.URL.Host,\n\t}\n\n\toriginalHost, _ := httppkg.CanonicalHost(reqRouteInfo.Host)\n\trc := rp.GetRouteConfig(originalHost, reqRouteInfo.URL, reqRouteInfo.HTTPUser)\n\n\tnewctx := req.Context()\n\tnewctx = context.WithValue(newctx, RouteInfoKey, reqRouteInfo)\n\tnewctx = context.WithValue(newctx, RouteConfigKey, rc)\n\treturn req.Clone(newctx)\n}\n\nfunc (rp *HTTPReverseProxy) ServeHTTP(rw http.ResponseWriter, req *http.Request) {\n\tdomain, _ := httppkg.CanonicalHost(req.Host)\n\tlocation := req.URL.Path\n\tuser, passwd, _ := req.BasicAuth()\n\tif !rp.CheckAuth(domain, location, user, user, passwd) {\n\t\trw.Header().Set(\"WWW-Authenticate\", `Basic realm=\"Restricted\"`)\n\t\thttp.Error(rw, http.StatusText(http.StatusUnauthorized), http.StatusUnauthorized)\n\t\treturn\n\t}\n\n\tnewreq := rp.injectRequestInfoToCtx(req)\n\tif req.Method == http.MethodConnect {\n\t\trp.connectHandler(rw, newreq)\n\t} else {\n\t\trp.proxy.ServeHTTP(rw, newreq)\n\t}\n}\n"
  },
  {
    "path": "pkg/util/vhost/https.go",
    "content": "// Copyright 2016 fatedier, fatedier@gmail.com\n//\n// Licensed under the Apache License, Version 2.0 (the \"License\");\n// you may not use this file except in compliance with the License.\n// You may obtain a copy of the License at\n//\n//     http://www.apache.org/licenses/LICENSE-2.0\n//\n// Unless required by applicable law or agreed to in writing, software\n// distributed under the License is distributed on an \"AS IS\" BASIS,\n// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.\n// See the License for the specific language governing permissions and\n// limitations under the License.\n\npackage vhost\n\nimport (\n\t\"crypto/tls\"\n\t\"io\"\n\t\"net\"\n\t\"time\"\n\n\tlibnet \"github.com/fatedier/golib/net\"\n)\n\ntype HTTPSMuxer struct {\n\t*Muxer\n}\n\nfunc NewHTTPSMuxer(listener net.Listener, timeout time.Duration) (*HTTPSMuxer, error) {\n\tmux, err := NewMuxer(listener, GetHTTPSHostname, timeout)\n\tmux.SetFailHookFunc(vhostFailed)\n\tif err != nil {\n\t\treturn nil, err\n\t}\n\treturn &HTTPSMuxer{mux}, err\n}\n\nfunc GetHTTPSHostname(c net.Conn) (_ net.Conn, _ map[string]string, err error) {\n\treqInfoMap := make(map[string]string, 0)\n\tsc, rd := libnet.NewSharedConn(c)\n\n\tclientHello, err := readClientHello(rd)\n\tif err != nil {\n\t\treturn nil, reqInfoMap, err\n\t}\n\n\treqInfoMap[\"Host\"] = clientHello.ServerName\n\treqInfoMap[\"Scheme\"] = \"https\"\n\treturn sc, reqInfoMap, nil\n}\n\nfunc readClientHello(reader io.Reader) (*tls.ClientHelloInfo, error) {\n\tvar hello *tls.ClientHelloInfo\n\n\t// Note that Handshake always fails because the readOnlyConn is not a real connection.\n\t// As long as the Client Hello is successfully read, the failure should only happen after GetConfigForClient is called,\n\t// so we only care about the error if hello was never set.\n\terr := tls.Server(readOnlyConn{reader: reader}, &tls.Config{\n\t\tGetConfigForClient: func(argHello *tls.ClientHelloInfo) (*tls.Config, error) {\n\t\t\thello = &tls.ClientHelloInfo{}\n\t\t\t*hello = *argHello\n\t\t\treturn nil, nil\n\t\t},\n\t}).Handshake()\n\n\tif hello == nil {\n\t\treturn nil, err\n\t}\n\treturn hello, nil\n}\n\nfunc vhostFailed(c net.Conn) {\n\t// Alert with alertUnrecognizedName\n\t_ = tls.Server(c, &tls.Config{}).Handshake()\n\tc.Close()\n}\n\ntype readOnlyConn struct {\n\treader io.Reader\n}\n\nfunc (conn readOnlyConn) Read(p []byte) (int, error)         { return conn.reader.Read(p) }\nfunc (conn readOnlyConn) Write(_ []byte) (int, error)        { return 0, io.ErrClosedPipe }\nfunc (conn readOnlyConn) Close() error                       { return nil }\nfunc (conn readOnlyConn) LocalAddr() net.Addr                { return nil }\nfunc (conn readOnlyConn) RemoteAddr() net.Addr               { return nil }\nfunc (conn readOnlyConn) SetDeadline(_ time.Time) error      { return nil }\nfunc (conn readOnlyConn) SetReadDeadline(_ time.Time) error  { return nil }\nfunc (conn readOnlyConn) SetWriteDeadline(_ time.Time) error { return nil }\n"
  },
  {
    "path": "pkg/util/vhost/https_test.go",
    "content": "package vhost\n\nimport (\n\t\"crypto/tls\"\n\t\"net\"\n\t\"testing\"\n\t\"time\"\n\n\t\"github.com/stretchr/testify/require\"\n)\n\nfunc TestGetHTTPSHostname(t *testing.T) {\n\trequire := require.New(t)\n\n\tl, err := net.Listen(\"tcp\", \"127.0.0.1:\")\n\trequire.NoError(err)\n\tdefer l.Close()\n\n\tvar conn net.Conn\n\tgo func() {\n\t\tconn, _ = l.Accept()\n\t\trequire.NotNil(conn)\n\t}()\n\n\tgo func() {\n\t\ttime.Sleep(100 * time.Millisecond)\n\t\ttls.Dial(\"tcp\", l.Addr().String(), &tls.Config{\n\t\t\tInsecureSkipVerify: true,\n\t\t\tServerName:         \"example.com\",\n\t\t})\n\t}()\n\n\ttime.Sleep(200 * time.Millisecond)\n\t_, infos, err := GetHTTPSHostname(conn)\n\trequire.NoError(err)\n\trequire.Equal(\"example.com\", infos[\"Host\"])\n\trequire.Equal(\"https\", infos[\"Scheme\"])\n}\n"
  },
  {
    "path": "pkg/util/vhost/resource.go",
    "content": "// Copyright 2017 fatedier, fatedier@gmail.com\n//\n// Licensed under the Apache License, Version 2.0 (the \"License\");\n// you may not use this file except in compliance with the License.\n// You may obtain a copy of the License at\n//\n//     http://www.apache.org/licenses/LICENSE-2.0\n//\n// Unless required by applicable law or agreed to in writing, software\n// distributed under the License is distributed on an \"AS IS\" BASIS,\n// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.\n// See the License for the specific language governing permissions and\n// limitations under the License.\n\npackage vhost\n\nimport (\n\t\"bytes\"\n\t\"io\"\n\t\"net/http\"\n\t\"os\"\n\n\t\"github.com/fatedier/frp/pkg/util/log\"\n\t\"github.com/fatedier/frp/pkg/util/version\"\n)\n\nvar NotFoundPagePath = \"\"\n\nconst (\n\tNotFound = `<!DOCTYPE html>\n<html>\n<head>\n<title>Not Found</title>\n<style>\n    body {\n        width: 35em;\n        margin: 0 auto;\n        font-family: Tahoma, Verdana, Arial, sans-serif;\n    }\n</style>\n</head>\n<body>\n<h1>The page you requested was not found.</h1>\n<p>Sorry, the page you are looking for is currently unavailable.<br/>\nPlease try again later.</p>\n<p>The server is powered by <a href=\"https://github.com/fatedier/frp\">frp</a>.</p>\n<p><em>Faithfully yours, frp.</em></p>\n</body>\n</html>\n`\n)\n\nfunc getNotFoundPageContent() []byte {\n\tvar (\n\t\tbuf []byte\n\t\terr error\n\t)\n\tif NotFoundPagePath != \"\" {\n\t\tbuf, err = os.ReadFile(NotFoundPagePath)\n\t\tif err != nil {\n\t\t\tlog.Warnf(\"read custom 404 page error: %v\", err)\n\t\t\tbuf = []byte(NotFound)\n\t\t}\n\t} else {\n\t\tbuf = []byte(NotFound)\n\t}\n\treturn buf\n}\n\nfunc NotFoundResponse() *http.Response {\n\theader := make(http.Header)\n\theader.Set(\"server\", \"frp/\"+version.Full())\n\theader.Set(\"Content-Type\", \"text/html\")\n\n\tcontent := getNotFoundPageContent()\n\tres := &http.Response{\n\t\tStatus:        \"Not Found\",\n\t\tStatusCode:    404,\n\t\tProto:         \"HTTP/1.1\",\n\t\tProtoMajor:    1,\n\t\tProtoMinor:    1,\n\t\tHeader:        header,\n\t\tBody:          io.NopCloser(bytes.NewReader(content)),\n\t\tContentLength: int64(len(content)),\n\t}\n\treturn res\n}\n"
  },
  {
    "path": "pkg/util/vhost/router.go",
    "content": "package vhost\n\nimport (\n\t\"cmp\"\n\t\"errors\"\n\t\"slices\"\n\t\"strings\"\n\t\"sync\"\n)\n\nvar ErrRouterConfigConflict = errors.New(\"router config conflict\")\n\ntype routerByHTTPUser map[string][]*Router\n\ntype Routers struct {\n\tindexByDomain map[string]routerByHTTPUser\n\n\tmutex sync.RWMutex\n}\n\ntype Router struct {\n\tdomain   string\n\tlocation string\n\thttpUser string\n\n\t// store any object here\n\tpayload any\n}\n\nfunc NewRouters() *Routers {\n\treturn &Routers{\n\t\tindexByDomain: make(map[string]routerByHTTPUser),\n\t}\n}\n\nfunc (r *Routers) Add(domain, location, httpUser string, payload any) error {\n\tdomain = strings.ToLower(domain)\n\n\tr.mutex.Lock()\n\tdefer r.mutex.Unlock()\n\n\tif _, exist := r.exist(domain, location, httpUser); exist {\n\t\treturn ErrRouterConfigConflict\n\t}\n\n\troutersByHTTPUser, found := r.indexByDomain[domain]\n\tif !found {\n\t\troutersByHTTPUser = make(map[string][]*Router)\n\t}\n\tvrs, found := routersByHTTPUser[httpUser]\n\tif !found {\n\t\tvrs = make([]*Router, 0, 1)\n\t}\n\n\tvr := &Router{\n\t\tdomain:   domain,\n\t\tlocation: location,\n\t\thttpUser: httpUser,\n\t\tpayload:  payload,\n\t}\n\tvrs = append(vrs, vr)\n\n\tslices.SortFunc(vrs, func(a, b *Router) int {\n\t\treturn -cmp.Compare(a.location, b.location)\n\t})\n\n\troutersByHTTPUser[httpUser] = vrs\n\tr.indexByDomain[domain] = routersByHTTPUser\n\treturn nil\n}\n\nfunc (r *Routers) Del(domain, location, httpUser string) {\n\tdomain = strings.ToLower(domain)\n\n\tr.mutex.Lock()\n\tdefer r.mutex.Unlock()\n\n\troutersByHTTPUser, found := r.indexByDomain[domain]\n\tif !found {\n\t\treturn\n\t}\n\n\tvrs, found := routersByHTTPUser[httpUser]\n\tif !found {\n\t\treturn\n\t}\n\tnewVrs := make([]*Router, 0)\n\tfor _, vr := range vrs {\n\t\tif vr.location != location {\n\t\t\tnewVrs = append(newVrs, vr)\n\t\t}\n\t}\n\troutersByHTTPUser[httpUser] = newVrs\n}\n\nfunc (r *Routers) Get(host, path, httpUser string) (vr *Router, exist bool) {\n\thost = strings.ToLower(host)\n\n\tr.mutex.RLock()\n\tdefer r.mutex.RUnlock()\n\n\troutersByHTTPUser, found := r.indexByDomain[host]\n\tif !found {\n\t\treturn\n\t}\n\n\tvrs, found := routersByHTTPUser[httpUser]\n\tif !found {\n\t\treturn\n\t}\n\n\tfor _, vr = range vrs {\n\t\tif strings.HasPrefix(path, vr.location) {\n\t\t\treturn vr, true\n\t\t}\n\t}\n\treturn\n}\n\nfunc (r *Routers) exist(host, path, httpUser string) (route *Router, exist bool) {\n\troutersByHTTPUser, found := r.indexByDomain[host]\n\tif !found {\n\t\treturn\n\t}\n\trouters, found := routersByHTTPUser[httpUser]\n\tif !found {\n\t\treturn\n\t}\n\n\tfor _, route = range routers {\n\t\tif path == route.location {\n\t\t\treturn route, true\n\t\t}\n\t}\n\treturn\n}\n"
  },
  {
    "path": "pkg/util/vhost/vhost.go",
    "content": "// Licensed under the Apache License, Version 2.0 (the \"License\");\n// you may not use this file except in compliance with the License.\n// You may obtain a copy of the License at\n//\n//     http://www.apache.org/licenses/LICENSE-2.0\n//\n// Unless required by applicable law or agreed to in writing, software\n// distributed under the License is distributed on an \"AS IS\" BASIS,\n// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.\n// See the License for the specific language governing permissions and\n// limitations under the License.\n\npackage vhost\n\nimport (\n\t\"context\"\n\t\"fmt\"\n\t\"net\"\n\t\"strings\"\n\t\"time\"\n\n\t\"github.com/fatedier/golib/errors\"\n\n\t\"github.com/fatedier/frp/pkg/util/log\"\n\tnetpkg \"github.com/fatedier/frp/pkg/util/net\"\n\t\"github.com/fatedier/frp/pkg/util/xlog\"\n)\n\ntype RouteInfo string\n\nconst (\n\tRouteInfoKey   RouteInfo = \"routeInfo\"\n\tRouteConfigKey RouteInfo = \"routeConfig\"\n)\n\ntype RequestRouteInfo struct {\n\tURL        string\n\tHost       string\n\tHTTPUser   string\n\tRemoteAddr string\n\tURLHost    string\n\tEndpoint   string\n}\n\ntype (\n\tmuxFunc         func(net.Conn) (net.Conn, map[string]string, error)\n\tauthFunc        func(conn net.Conn, username, password string, reqInfoMap map[string]string) (bool, error)\n\thostRewriteFunc func(net.Conn, string) (net.Conn, error)\n\tsuccessHookFunc func(net.Conn, map[string]string) error\n\tfailHookFunc    func(net.Conn)\n)\n\n// Muxer is a functional component used for https and tcpmux proxies.\n// It accepts connections and extracts vhost information from the beginning of the connection data.\n// It then routes the connection to its appropriate listener.\ntype Muxer struct {\n\tlistener net.Listener\n\ttimeout  time.Duration\n\n\tvhostFunc      muxFunc\n\tcheckAuth      authFunc\n\tsuccessHook    successHookFunc\n\tfailHook       failHookFunc\n\trewriteHost    hostRewriteFunc\n\tregistryRouter *Routers\n}\n\nfunc NewMuxer(\n\tlistener net.Listener,\n\tvhostFunc muxFunc,\n\ttimeout time.Duration,\n) (mux *Muxer, err error) {\n\tmux = &Muxer{\n\t\tlistener:       listener,\n\t\ttimeout:        timeout,\n\t\tvhostFunc:      vhostFunc,\n\t\tregistryRouter: NewRouters(),\n\t}\n\tgo mux.run()\n\treturn mux, nil\n}\n\nfunc (v *Muxer) SetCheckAuthFunc(f authFunc) *Muxer {\n\tv.checkAuth = f\n\treturn v\n}\n\nfunc (v *Muxer) SetSuccessHookFunc(f successHookFunc) *Muxer {\n\tv.successHook = f\n\treturn v\n}\n\nfunc (v *Muxer) SetFailHookFunc(f failHookFunc) *Muxer {\n\tv.failHook = f\n\treturn v\n}\n\nfunc (v *Muxer) SetRewriteHostFunc(f hostRewriteFunc) *Muxer {\n\tv.rewriteHost = f\n\treturn v\n}\n\nfunc (v *Muxer) Close() error {\n\treturn v.listener.Close()\n}\n\ntype ChooseEndpointFunc func() (string, error)\n\ntype CreateConnFunc func(remoteAddr string) (net.Conn, error)\n\ntype CreateConnByEndpointFunc func(endpoint, remoteAddr string) (net.Conn, error)\n\n// RouteConfig is the params used to match HTTP requests\ntype RouteConfig struct {\n\tDomain          string\n\tLocation        string\n\tRewriteHost     string\n\tUsername        string\n\tPassword        string\n\tHeaders         map[string]string\n\tResponseHeaders map[string]string\n\tRouteByHTTPUser string\n\n\tCreateConnFn           CreateConnFunc\n\tChooseEndpointFn       ChooseEndpointFunc\n\tCreateConnByEndpointFn CreateConnByEndpointFunc\n}\n\n// listen for a new domain name, if rewriteHost is not empty and rewriteHost func is not nil,\n// then rewrite the host header to rewriteHost\nfunc (v *Muxer) Listen(ctx context.Context, cfg *RouteConfig) (l *Listener, err error) {\n\tl = &Listener{\n\t\tname:            cfg.Domain,\n\t\tlocation:        cfg.Location,\n\t\trouteByHTTPUser: cfg.RouteByHTTPUser,\n\t\trewriteHost:     cfg.RewriteHost,\n\t\tusername:        cfg.Username,\n\t\tpassword:        cfg.Password,\n\t\tmux:             v,\n\t\taccept:          make(chan net.Conn),\n\t\tctx:             ctx,\n\t}\n\terr = v.registryRouter.Add(cfg.Domain, cfg.Location, cfg.RouteByHTTPUser, l)\n\tif err != nil {\n\t\treturn\n\t}\n\treturn l, nil\n}\n\nfunc (v *Muxer) getListener(name, path, httpUser string) (*Listener, bool) {\n\tfindRouter := func(inName, inPath, inHTTPUser string) (*Listener, bool) {\n\t\tvr, ok := v.registryRouter.Get(inName, inPath, inHTTPUser)\n\t\tif ok {\n\t\t\treturn vr.payload.(*Listener), true\n\t\t}\n\t\t// Try to check if there is one proxy that doesn't specify routerByHTTPUser, it means match all.\n\t\tvr, ok = v.registryRouter.Get(inName, inPath, \"\")\n\t\tif ok {\n\t\t\treturn vr.payload.(*Listener), true\n\t\t}\n\t\treturn nil, false\n\t}\n\n\t// first we check the full hostname\n\t// if not exist, then check the wildcard_domain such as *.example.com\n\tl, ok := findRouter(name, path, httpUser)\n\tif ok {\n\t\treturn l, true\n\t}\n\n\tdomainSplit := strings.Split(name, \".\")\n\tfor len(domainSplit) >= 3 {\n\t\tdomainSplit[0] = \"*\"\n\t\tname = strings.Join(domainSplit, \".\")\n\n\t\tl, ok = findRouter(name, path, httpUser)\n\t\tif ok {\n\t\t\treturn l, true\n\t\t}\n\t\tdomainSplit = domainSplit[1:]\n\t}\n\t// Finally, try to check if there is one proxy that domain is \"*\" means match all domains.\n\tl, ok = findRouter(\"*\", path, httpUser)\n\tif ok {\n\t\treturn l, true\n\t}\n\treturn nil, false\n}\n\nfunc (v *Muxer) run() {\n\tfor {\n\t\tconn, err := v.listener.Accept()\n\t\tif err != nil {\n\t\t\treturn\n\t\t}\n\t\tgo v.handle(conn)\n\t}\n}\n\nfunc (v *Muxer) handle(c net.Conn) {\n\tif err := c.SetDeadline(time.Now().Add(v.timeout)); err != nil {\n\t\t_ = c.Close()\n\t\treturn\n\t}\n\n\tsConn, reqInfoMap, err := v.vhostFunc(c)\n\tif err != nil {\n\t\tlog.Debugf(\"get hostname from http/https request error: %v\", err)\n\t\t_ = c.Close()\n\t\treturn\n\t}\n\n\tname := strings.ToLower(reqInfoMap[\"Host\"])\n\tpath := strings.ToLower(reqInfoMap[\"Path\"])\n\thttpUser := reqInfoMap[\"HTTPUser\"]\n\tl, ok := v.getListener(name, path, httpUser)\n\tif !ok {\n\t\tlog.Debugf(\"http request for host [%s] path [%s] httpUser [%s] not found\", name, path, httpUser)\n\t\tv.failHook(sConn)\n\t\treturn\n\t}\n\n\txl := xlog.FromContextSafe(l.ctx)\n\tif v.successHook != nil {\n\t\tif err := v.successHook(c, reqInfoMap); err != nil {\n\t\t\txl.Infof(\"success func failure on vhost connection: %v\", err)\n\t\t\t_ = c.Close()\n\t\t\treturn\n\t\t}\n\t}\n\n\t// if checkAuth func is exist and username/password is set\n\t// then verify user access\n\tif l.mux.checkAuth != nil && l.username != \"\" {\n\t\tok, err := l.mux.checkAuth(c, l.username, l.password, reqInfoMap)\n\t\tif !ok || err != nil {\n\t\t\txl.Debugf(\"auth failed for user: %s\", l.username)\n\t\t\t_ = c.Close()\n\t\t\treturn\n\t\t}\n\t}\n\n\tif err = sConn.SetDeadline(time.Time{}); err != nil {\n\t\t_ = c.Close()\n\t\treturn\n\t}\n\tc = sConn\n\n\txl.Debugf(\"new request host [%s] path [%s] httpUser [%s]\", name, path, httpUser)\n\terr = errors.PanicToError(func() {\n\t\tl.accept <- c\n\t})\n\tif err != nil {\n\t\txl.Warnf(\"listener is already closed, ignore this request\")\n\t}\n}\n\ntype Listener struct {\n\tname            string\n\tlocation        string\n\trouteByHTTPUser string\n\trewriteHost     string\n\tusername        string\n\tpassword        string\n\tmux             *Muxer // for closing Muxer\n\taccept          chan net.Conn\n\tctx             context.Context\n}\n\nfunc (l *Listener) Accept() (net.Conn, error) {\n\txl := xlog.FromContextSafe(l.ctx)\n\tconn, ok := <-l.accept\n\tif !ok {\n\t\treturn nil, fmt.Errorf(\"listener closed\")\n\t}\n\n\t// if rewriteHost func is exist\n\t// rewrite http requests with a modified host header\n\t// if l.rewriteHost is empty, nothing to do\n\tif l.mux.rewriteHost != nil {\n\t\tsConn, err := l.mux.rewriteHost(conn, l.rewriteHost)\n\t\tif err != nil {\n\t\t\txl.Warnf(\"host header rewrite failed: %v\", err)\n\t\t\treturn nil, fmt.Errorf(\"host header rewrite failed\")\n\t\t}\n\t\txl.Debugf(\"rewrite host to [%s] success\", l.rewriteHost)\n\t\tconn = sConn\n\t}\n\treturn netpkg.NewContextConn(l.ctx, conn), nil\n}\n\nfunc (l *Listener) Close() error {\n\tl.mux.registryRouter.Del(l.name, l.location, l.routeByHTTPUser)\n\tclose(l.accept)\n\treturn nil\n}\n\nfunc (l *Listener) Name() string {\n\treturn l.name\n}\n\nfunc (l *Listener) Addr() net.Addr {\n\treturn (*net.TCPAddr)(nil)\n}\n"
  },
  {
    "path": "pkg/util/wait/backoff.go",
    "content": "// Copyright 2023 The frp Authors\n//\n// Licensed under the Apache License, Version 2.0 (the \"License\");\n// you may not use this file except in compliance with the License.\n// You may obtain a copy of the License at\n//\n//     http://www.apache.org/licenses/LICENSE-2.0\n//\n// Unless required by applicable law or agreed to in writing, software\n// distributed under the License is distributed on an \"AS IS\" BASIS,\n// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.\n// See the License for the specific language governing permissions and\n// limitations under the License.\n\npackage wait\n\nimport (\n\t\"math/rand/v2\"\n\t\"time\"\n\n\t\"github.com/fatedier/frp/pkg/util/util\"\n)\n\ntype BackoffFunc func(previousDuration time.Duration, previousConditionError bool) time.Duration\n\nfunc (f BackoffFunc) Backoff(previousDuration time.Duration, previousConditionError bool) time.Duration {\n\treturn f(previousDuration, previousConditionError)\n}\n\ntype BackoffManager interface {\n\tBackoff(previousDuration time.Duration, previousConditionError bool) time.Duration\n}\n\ntype FastBackoffOptions struct {\n\tDuration           time.Duration\n\tFactor             float64\n\tJitter             float64\n\tMaxDuration        time.Duration\n\tInitDurationIfFail time.Duration\n\n\t// If FastRetryCount > 0, then within the FastRetryWindow time window,\n\t// the retry will be performed with a delay of FastRetryDelay for the first FastRetryCount calls.\n\tFastRetryCount  int\n\tFastRetryDelay  time.Duration\n\tFastRetryJitter float64\n\tFastRetryWindow time.Duration\n}\n\ntype fastBackoffImpl struct {\n\toptions FastBackoffOptions\n\n\tlastCalledTime      time.Time\n\tconsecutiveErrCount int\n\n\tfastRetryCutoffTime     time.Time\n\tcountsInFastRetryWindow int\n}\n\nfunc NewFastBackoffManager(options FastBackoffOptions) BackoffManager {\n\treturn &fastBackoffImpl{\n\t\toptions:                 options,\n\t\tcountsInFastRetryWindow: 1,\n\t}\n}\n\nfunc (f *fastBackoffImpl) Backoff(previousDuration time.Duration, previousConditionError bool) time.Duration {\n\tif f.lastCalledTime.IsZero() {\n\t\tf.lastCalledTime = time.Now()\n\t\treturn f.options.Duration\n\t}\n\tnow := time.Now()\n\tf.lastCalledTime = now\n\n\tif previousConditionError {\n\t\tf.consecutiveErrCount++\n\t} else {\n\t\tf.consecutiveErrCount = 0\n\t}\n\n\tif f.options.FastRetryCount > 0 && previousConditionError {\n\t\tf.countsInFastRetryWindow++\n\t\tif f.countsInFastRetryWindow <= f.options.FastRetryCount {\n\t\t\treturn Jitter(f.options.FastRetryDelay, f.options.FastRetryJitter)\n\t\t}\n\t\tif now.After(f.fastRetryCutoffTime) {\n\t\t\t// reset\n\t\t\tf.fastRetryCutoffTime = now.Add(f.options.FastRetryWindow)\n\t\t\tf.countsInFastRetryWindow = 0\n\t\t}\n\t}\n\n\tif previousConditionError {\n\t\tvar duration time.Duration\n\t\tif f.consecutiveErrCount == 1 {\n\t\t\tduration = util.EmptyOr(f.options.InitDurationIfFail, previousDuration)\n\t\t} else {\n\t\t\tduration = previousDuration\n\t\t}\n\n\t\tduration = util.EmptyOr(duration, time.Second)\n\t\tif f.options.Factor != 0 {\n\t\t\tduration = time.Duration(float64(duration) * f.options.Factor)\n\t\t}\n\t\tif f.options.Jitter > 0 {\n\t\t\tduration = Jitter(duration, f.options.Jitter)\n\t\t}\n\t\tif f.options.MaxDuration > 0 && duration > f.options.MaxDuration {\n\t\t\tduration = f.options.MaxDuration\n\t\t}\n\t\treturn duration\n\t}\n\treturn f.options.Duration\n}\n\nfunc BackoffUntil(f func() (bool, error), backoff BackoffManager, sliding bool, stopCh <-chan struct{}) {\n\tvar delay time.Duration\n\tpreviousError := false\n\n\tticker := time.NewTicker(backoff.Backoff(delay, previousError))\n\tdefer ticker.Stop()\n\n\tfor {\n\t\tselect {\n\t\tcase <-stopCh:\n\t\t\treturn\n\t\tdefault:\n\t\t}\n\n\t\tif !sliding {\n\t\t\tdelay = backoff.Backoff(delay, previousError)\n\t\t}\n\n\t\tif done, err := f(); done {\n\t\t\treturn\n\t\t} else if err != nil {\n\t\t\tpreviousError = true\n\t\t} else {\n\t\t\tpreviousError = false\n\t\t}\n\n\t\tif sliding {\n\t\t\tdelay = backoff.Backoff(delay, previousError)\n\t\t}\n\n\t\tticker.Reset(delay)\n\t\tselect {\n\t\tcase <-stopCh:\n\t\t\treturn\n\t\tcase <-ticker.C:\n\t\t}\n\t}\n}\n\n// Jitter returns a time.Duration between duration and duration + maxFactor *\n// duration.\n//\n// This allows clients to avoid converging on periodic behavior. If maxFactor\n// is 0.0, a suggested default value will be chosen.\nfunc Jitter(duration time.Duration, maxFactor float64) time.Duration {\n\tif maxFactor <= 0.0 {\n\t\tmaxFactor = 1.0\n\t}\n\twait := duration + time.Duration(rand.Float64()*maxFactor*float64(duration))\n\treturn wait\n}\n\nfunc Until(f func(), period time.Duration, stopCh <-chan struct{}) {\n\tff := func() (bool, error) {\n\t\tf()\n\t\treturn false, nil\n\t}\n\tBackoffUntil(ff, BackoffFunc(func(time.Duration, bool) time.Duration {\n\t\treturn period\n\t}), true, stopCh)\n}\n"
  },
  {
    "path": "pkg/util/xlog/ctx.go",
    "content": "// Copyright 2019 fatedier, fatedier@gmail.com\n//\n// Licensed under the Apache License, Version 2.0 (the \"License\");\n// you may not use this file except in compliance with the License.\n// You may obtain a copy of the License at\n//\n//     http://www.apache.org/licenses/LICENSE-2.0\n//\n// Unless required by applicable law or agreed to in writing, software\n// distributed under the License is distributed on an \"AS IS\" BASIS,\n// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.\n// See the License for the specific language governing permissions and\n// limitations under the License.\n\npackage xlog\n\nimport (\n\t\"context\"\n)\n\ntype key int\n\nconst (\n\txlogKey key = 0\n)\n\nfunc NewContext(ctx context.Context, xl *Logger) context.Context {\n\treturn context.WithValue(ctx, xlogKey, xl)\n}\n\nfunc FromContext(ctx context.Context) (xl *Logger, ok bool) {\n\txl, ok = ctx.Value(xlogKey).(*Logger)\n\treturn\n}\n\nfunc FromContextSafe(ctx context.Context) *Logger {\n\txl, ok := ctx.Value(xlogKey).(*Logger)\n\tif !ok {\n\t\txl = New()\n\t}\n\treturn xl\n}\n"
  },
  {
    "path": "pkg/util/xlog/log_writer.go",
    "content": "// Copyright 2025 The frp Authors\n//\n// Licensed under the Apache License, Version 2.0 (the \"License\");\n// you may not use this file except in compliance with the License.\n// You may obtain a copy of the License at\n//\n//     http://www.apache.org/licenses/LICENSE-2.0\n//\n// Unless required by applicable law or agreed to in writing, software\n// distributed under the License is distributed on an \"AS IS\" BASIS,\n// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.\n// See the License for the specific language governing permissions and\n// limitations under the License.\n\npackage xlog\n\nimport \"strings\"\n\n// LogWriter forwards writes to frp's logger at configurable level.\n// It is safe for concurrent use as long as the underlying Logger is thread-safe.\ntype LogWriter struct {\n\txl      *Logger\n\tlogFunc func(string)\n}\n\nfunc (w LogWriter) Write(p []byte) (n int, err error) {\n\tmsg := strings.TrimSpace(string(p))\n\tw.logFunc(msg)\n\treturn len(p), nil\n}\n\nfunc NewTraceWriter(xl *Logger) LogWriter {\n\treturn LogWriter{\n\t\txl:      xl,\n\t\tlogFunc: func(msg string) { xl.Tracef(\"%s\", msg) },\n\t}\n}\n\nfunc NewDebugWriter(xl *Logger) LogWriter {\n\treturn LogWriter{\n\t\txl:      xl,\n\t\tlogFunc: func(msg string) { xl.Debugf(\"%s\", msg) },\n\t}\n}\n\nfunc NewInfoWriter(xl *Logger) LogWriter {\n\treturn LogWriter{\n\t\txl:      xl,\n\t\tlogFunc: func(msg string) { xl.Infof(\"%s\", msg) },\n\t}\n}\n\nfunc NewWarnWriter(xl *Logger) LogWriter {\n\treturn LogWriter{\n\t\txl:      xl,\n\t\tlogFunc: func(msg string) { xl.Warnf(\"%s\", msg) },\n\t}\n}\n\nfunc NewErrorWriter(xl *Logger) LogWriter {\n\treturn LogWriter{\n\t\txl:      xl,\n\t\tlogFunc: func(msg string) { xl.Errorf(\"%s\", msg) },\n\t}\n}\n"
  },
  {
    "path": "pkg/util/xlog/xlog.go",
    "content": "// Copyright 2019 fatedier, fatedier@gmail.com\n//\n// Licensed under the Apache License, Version 2.0 (the \"License\");\n// you may not use this file except in compliance with the License.\n// You may obtain a copy of the License at\n//\n//     http://www.apache.org/licenses/LICENSE-2.0\n//\n// Unless required by applicable law or agreed to in writing, software\n// distributed under the License is distributed on an \"AS IS\" BASIS,\n// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.\n// See the License for the specific language governing permissions and\n// limitations under the License.\n\npackage xlog\n\nimport (\n\t\"cmp\"\n\t\"slices\"\n\n\t\"github.com/fatedier/frp/pkg/util/log\"\n)\n\ntype LogPrefix struct {\n\t// Name is the name of the prefix, it won't be displayed in log but used to identify the prefix.\n\tName string\n\t// Value is the value of the prefix, it will be displayed in log.\n\tValue string\n\t// The prefix with higher priority will be displayed first, default is 10.\n\tPriority int\n}\n\n// Logger is not thread safety for operations on prefix\ntype Logger struct {\n\tprefixes []LogPrefix\n\n\tprefixString string\n}\n\nfunc New() *Logger {\n\treturn &Logger{\n\t\tprefixes: make([]LogPrefix, 0),\n\t}\n}\n\nfunc (l *Logger) ResetPrefixes() (old []LogPrefix) {\n\told = l.prefixes\n\tl.prefixes = make([]LogPrefix, 0)\n\tl.prefixString = \"\"\n\treturn\n}\n\nfunc (l *Logger) AppendPrefix(prefix string) *Logger {\n\treturn l.AddPrefix(LogPrefix{\n\t\tName:     prefix,\n\t\tValue:    prefix,\n\t\tPriority: 10,\n\t})\n}\n\nfunc (l *Logger) AddPrefix(prefix LogPrefix) *Logger {\n\tfound := false\n\tif prefix.Priority <= 0 {\n\t\tprefix.Priority = 10\n\t}\n\tfor i, p := range l.prefixes {\n\t\tif p.Name == prefix.Name {\n\t\t\tfound = true\n\t\t\tl.prefixes[i].Value = prefix.Value\n\t\t\tl.prefixes[i].Priority = prefix.Priority\n\t\t\tbreak\n\t\t}\n\t}\n\tif !found {\n\t\tl.prefixes = append(l.prefixes, prefix)\n\t}\n\tl.renderPrefixString()\n\treturn l\n}\n\nfunc (l *Logger) renderPrefixString() {\n\tslices.SortStableFunc(l.prefixes, func(a, b LogPrefix) int {\n\t\treturn cmp.Compare(a.Priority, b.Priority)\n\t})\n\tl.prefixString = \"\"\n\tfor _, v := range l.prefixes {\n\t\tl.prefixString += \"[\" + v.Value + \"] \"\n\t}\n}\n\nfunc (l *Logger) Spawn() *Logger {\n\tnl := New()\n\tnl.prefixes = append(nl.prefixes, l.prefixes...)\n\tnl.renderPrefixString()\n\treturn nl\n}\n\nfunc (l *Logger) Errorf(format string, v ...any) {\n\tlog.Logger.Errorf(l.prefixString+format, v...)\n}\n\nfunc (l *Logger) Warnf(format string, v ...any) {\n\tlog.Logger.Warnf(l.prefixString+format, v...)\n}\n\nfunc (l *Logger) Infof(format string, v ...any) {\n\tlog.Logger.Infof(l.prefixString+format, v...)\n}\n\nfunc (l *Logger) Debugf(format string, v ...any) {\n\tlog.Logger.Debugf(l.prefixString+format, v...)\n}\n\nfunc (l *Logger) Tracef(format string, v ...any) {\n\tlog.Logger.Tracef(l.prefixString+format, v...)\n}\n"
  },
  {
    "path": "pkg/virtual/client.go",
    "content": "// Copyright 2023 The frp Authors\n//\n// Licensed under the Apache License, Version 2.0 (the \"License\");\n// you may not use this file except in compliance with the License.\n// You may obtain a copy of the License at\n//\n//     http://www.apache.org/licenses/LICENSE-2.0\n//\n// Unless required by applicable law or agreed to in writing, software\n// distributed under the License is distributed on an \"AS IS\" BASIS,\n// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.\n// See the License for the specific language governing permissions and\n// limitations under the License.\n\npackage virtual\n\nimport (\n\t\"context\"\n\t\"net\"\n\n\t\"github.com/fatedier/frp/client\"\n\t\"github.com/fatedier/frp/pkg/config/source\"\n\tv1 \"github.com/fatedier/frp/pkg/config/v1\"\n\t\"github.com/fatedier/frp/pkg/msg\"\n\tnetpkg \"github.com/fatedier/frp/pkg/util/net\"\n)\n\ntype ClientOptions struct {\n\tCommon           *v1.ClientCommonConfig\n\tSpec             *msg.ClientSpec\n\tHandleWorkConnCb func(*v1.ProxyBaseConfig, net.Conn, *msg.StartWorkConn) bool\n}\n\ntype Client struct {\n\tl   *netpkg.InternalListener\n\tsvr *client.Service\n}\n\nfunc NewClient(options ClientOptions) (*Client, error) {\n\tif options.Common != nil {\n\t\tif err := options.Common.Complete(); err != nil {\n\t\t\treturn nil, err\n\t\t}\n\t}\n\n\tln := netpkg.NewInternalListener()\n\tconfigSource := source.NewConfigSource()\n\taggregator := source.NewAggregator(configSource)\n\n\tserviceOptions := client.ServiceOptions{\n\t\tCommon:                 options.Common,\n\t\tConfigSourceAggregator: aggregator,\n\t\tClientSpec:             options.Spec,\n\t\tConnectorCreator: func(context.Context, *v1.ClientCommonConfig) client.Connector {\n\t\t\treturn &pipeConnector{\n\t\t\t\tpeerListener: ln,\n\t\t\t}\n\t\t},\n\t\tHandleWorkConnCb: options.HandleWorkConnCb,\n\t}\n\tsvr, err := client.NewService(serviceOptions)\n\tif err != nil {\n\t\treturn nil, err\n\t}\n\treturn &Client{\n\t\tl:   ln,\n\t\tsvr: svr,\n\t}, nil\n}\n\nfunc (c *Client) PeerListener() net.Listener {\n\treturn c.l\n}\n\nfunc (c *Client) UpdateProxyConfigurer(proxyCfgs []v1.ProxyConfigurer) {\n\t_ = c.svr.UpdateAllConfigurer(proxyCfgs, nil)\n}\n\nfunc (c *Client) Run(ctx context.Context) error {\n\treturn c.svr.Run(ctx)\n}\n\nfunc (c *Client) Service() *client.Service {\n\treturn c.svr\n}\n\nfunc (c *Client) Close() {\n\tc.svr.Close()\n\tc.l.Close()\n}\n\ntype pipeConnector struct {\n\tpeerListener *netpkg.InternalListener\n}\n\nfunc (pc *pipeConnector) Open() error {\n\treturn nil\n}\n\nfunc (pc *pipeConnector) Connect() (net.Conn, error) {\n\tc1, c2 := net.Pipe()\n\tif err := pc.peerListener.PutConn(c1); err != nil {\n\t\tc1.Close()\n\t\tc2.Close()\n\t\treturn nil, err\n\t}\n\treturn c2, nil\n}\n\nfunc (pc *pipeConnector) Close() error {\n\tpc.peerListener.Close()\n\treturn nil\n}\n"
  },
  {
    "path": "pkg/vnet/controller.go",
    "content": "// Copyright 2025 The frp Authors\n//\n// Licensed under the Apache License, Version 2.0 (the \"License\");\n// you may not use this file except in compliance with the License.\n// You may obtain a copy of the License at\n//\n//     http://www.apache.org/licenses/LICENSE-2.0\n//\n// Unless required by applicable law or agreed to in writing, software\n// distributed under the License is distributed on an \"AS IS\" BASIS,\n// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.\n// See the License for the specific language governing permissions and\n// limitations under the License.\n\npackage vnet\n\nimport (\n\t\"context\"\n\t\"encoding/base64\"\n\t\"fmt\"\n\t\"io\"\n\t\"net\"\n\t\"sync\"\n\n\t\"github.com/fatedier/golib/pool\"\n\t\"github.com/songgao/water/waterutil\"\n\t\"golang.org/x/net/ipv4\"\n\t\"golang.org/x/net/ipv6\"\n\n\tv1 \"github.com/fatedier/frp/pkg/config/v1\"\n\t\"github.com/fatedier/frp/pkg/util/log\"\n\t\"github.com/fatedier/frp/pkg/util/xlog\"\n)\n\nconst (\n\tmaxPacketSize = 1420\n)\n\ntype Controller struct {\n\taddr string\n\n\ttun          io.ReadWriteCloser\n\tclientRouter *clientRouter // Route based on destination IP (client mode)\n\tserverRouter *serverRouter // Route based on source IP (server mode)\n}\n\nfunc NewController(cfg v1.VirtualNetConfig) *Controller {\n\treturn &Controller{\n\t\taddr:         cfg.Address,\n\t\tclientRouter: newClientRouter(),\n\t\tserverRouter: newServerRouter(),\n\t}\n}\n\nfunc (c *Controller) Init() error {\n\ttunDevice, err := OpenTun(context.Background(), c.addr)\n\tif err != nil {\n\t\treturn err\n\t}\n\tc.tun = tunDevice\n\treturn nil\n}\n\nfunc (c *Controller) Run() error {\n\tconn := c.tun\n\n\tfor {\n\t\tbuf := pool.GetBuf(maxPacketSize)\n\t\tn, err := conn.Read(buf)\n\t\tif err != nil {\n\t\t\tpool.PutBuf(buf)\n\t\t\tlog.Warnf(\"vnet read from tun error: %v\", err)\n\t\t\treturn err\n\t\t}\n\n\t\tc.handlePacket(buf[:n])\n\t\tpool.PutBuf(buf)\n\t}\n}\n\n// handlePacket processes a single packet. The caller is responsible for managing the buffer.\nfunc (c *Controller) handlePacket(buf []byte) {\n\tlog.Tracef(\"vnet read from tun [%d]: %s\", len(buf), base64.StdEncoding.EncodeToString(buf))\n\n\tvar src, dst net.IP\n\tswitch {\n\tcase waterutil.IsIPv4(buf):\n\t\theader, err := ipv4.ParseHeader(buf)\n\t\tif err != nil {\n\t\t\tlog.Warnf(\"parse ipv4 header error: %v\", err)\n\t\t\treturn\n\t\t}\n\t\tsrc = header.Src\n\t\tdst = header.Dst\n\t\tlog.Tracef(\"%s >> %s %d/%-4d %-4x %d\",\n\t\t\theader.Src, header.Dst,\n\t\t\theader.Len, header.TotalLen, header.ID, header.Flags)\n\tcase waterutil.IsIPv6(buf):\n\t\theader, err := ipv6.ParseHeader(buf)\n\t\tif err != nil {\n\t\t\tlog.Warnf(\"parse ipv6 header error: %v\", err)\n\t\t\treturn\n\t\t}\n\t\tsrc = header.Src\n\t\tdst = header.Dst\n\t\tlog.Tracef(\"%s >> %s %d %d\",\n\t\t\theader.Src, header.Dst,\n\t\t\theader.PayloadLen, header.TrafficClass)\n\tdefault:\n\t\tlog.Tracef(\"unknown packet, discarded(%d)\", len(buf))\n\t\treturn\n\t}\n\n\ttargetConn, err := c.clientRouter.findConn(dst)\n\tif err == nil {\n\t\tif err := WriteMessage(targetConn, buf); err != nil {\n\t\t\tlog.Warnf(\"write to client target conn error: %v\", err)\n\t\t}\n\t\treturn\n\t}\n\n\ttargetConn, err = c.serverRouter.findConnBySrc(dst)\n\tif err == nil {\n\t\tif err := WriteMessage(targetConn, buf); err != nil {\n\t\t\tlog.Warnf(\"write to server target conn error: %v\", err)\n\t\t}\n\t\treturn\n\t}\n\n\tlog.Tracef(\"no route found for packet from %s to %s\", src, dst)\n}\n\nfunc (c *Controller) Stop() error {\n\tif c.tun == nil {\n\t\treturn nil\n\t}\n\treturn c.tun.Close()\n}\n\n// Client connection read loop\nfunc (c *Controller) readLoopClient(ctx context.Context, conn io.ReadWriteCloser) {\n\txl := xlog.FromContextSafe(ctx)\n\tdefer func() {\n\t\t// Remove the route when read loop ends (connection closed)\n\t\tc.clientRouter.removeConnRoute(conn)\n\t\tconn.Close()\n\t}()\n\n\tfor {\n\t\tdata, err := ReadMessage(conn)\n\t\tif err != nil {\n\t\t\txl.Warnf(\"client read error: %v\", err)\n\t\t\treturn\n\t\t}\n\n\t\tif len(data) == 0 {\n\t\t\tcontinue\n\t\t}\n\n\t\tswitch {\n\t\tcase waterutil.IsIPv4(data):\n\t\t\theader, err := ipv4.ParseHeader(data)\n\t\t\tif err != nil {\n\t\t\t\txl.Warnf(\"parse ipv4 header error: %v\", err)\n\t\t\t\tcontinue\n\t\t\t}\n\t\t\txl.Tracef(\"%s >> %s %d/%-4d %-4x %d\",\n\t\t\t\theader.Src, header.Dst,\n\t\t\t\theader.Len, header.TotalLen, header.ID, header.Flags)\n\t\tcase waterutil.IsIPv6(data):\n\t\t\theader, err := ipv6.ParseHeader(data)\n\t\t\tif err != nil {\n\t\t\t\txl.Warnf(\"parse ipv6 header error: %v\", err)\n\t\t\t\tcontinue\n\t\t\t}\n\t\t\txl.Tracef(\"%s >> %s %d %d\",\n\t\t\t\theader.Src, header.Dst,\n\t\t\t\theader.PayloadLen, header.TrafficClass)\n\t\tdefault:\n\t\t\txl.Tracef(\"unknown packet, discarded(%d)\", len(data))\n\t\t\tcontinue\n\t\t}\n\n\t\txl.Tracef(\"vnet write to tun (client) [%d]: %s\", len(data), base64.StdEncoding.EncodeToString(data))\n\t\t_, err = c.tun.Write(data)\n\t\tif err != nil {\n\t\t\txl.Warnf(\"client write tun error: %v\", err)\n\t\t}\n\t}\n}\n\n// Server connection read loop\nfunc (c *Controller) readLoopServer(ctx context.Context, conn io.ReadWriteCloser, onClose func()) {\n\txl := xlog.FromContextSafe(ctx)\n\tdefer func() {\n\t\t// Clean up all IP mappings associated with this connection when it closes\n\t\tc.serverRouter.cleanupConnIPs(conn)\n\t\t// Call the provided callback upon closure\n\t\tif onClose != nil {\n\t\t\tonClose()\n\t\t}\n\t\tconn.Close()\n\t}()\n\n\tfor {\n\t\tdata, err := ReadMessage(conn)\n\t\tif err != nil {\n\t\t\txl.Warnf(\"server read error: %v\", err)\n\t\t\treturn\n\t\t}\n\n\t\tif len(data) == 0 {\n\t\t\tcontinue\n\t\t}\n\n\t\t// Register source IP to connection mapping\n\t\tif waterutil.IsIPv4(data) || waterutil.IsIPv6(data) {\n\t\t\tvar src net.IP\n\t\t\tif waterutil.IsIPv4(data) {\n\t\t\t\theader, err := ipv4.ParseHeader(data)\n\t\t\t\tif err == nil {\n\t\t\t\t\tsrc = header.Src\n\t\t\t\t\tc.serverRouter.registerSrcIP(src, conn)\n\t\t\t\t}\n\t\t\t} else {\n\t\t\t\theader, err := ipv6.ParseHeader(data)\n\t\t\t\tif err == nil {\n\t\t\t\t\tsrc = header.Src\n\t\t\t\t\tc.serverRouter.registerSrcIP(src, conn)\n\t\t\t\t}\n\t\t\t}\n\t\t}\n\n\t\txl.Tracef(\"vnet write to tun (server) [%d]: %s\", len(data), base64.StdEncoding.EncodeToString(data))\n\t\t_, err = c.tun.Write(data)\n\t\tif err != nil {\n\t\t\txl.Warnf(\"server write tun error: %v\", err)\n\t\t}\n\t}\n}\n\n// RegisterClientRoute registers a client route (based on destination IP CIDR)\n// and starts the read loop\nfunc (c *Controller) RegisterClientRoute(ctx context.Context, name string, routes []net.IPNet, conn io.ReadWriteCloser) {\n\tc.clientRouter.addRoute(name, routes, conn)\n\tgo c.readLoopClient(ctx, conn)\n}\n\n// UnregisterClientRoute Remove client route from routing table\nfunc (c *Controller) UnregisterClientRoute(name string) {\n\tc.clientRouter.delRoute(name)\n}\n\n// StartServerConnReadLoop starts the read loop for a server connection\n// (dynamically associates with source IPs)\nfunc (c *Controller) StartServerConnReadLoop(ctx context.Context, conn io.ReadWriteCloser, onClose func()) {\n\tgo c.readLoopServer(ctx, conn, onClose)\n}\n\n// ParseRoutes Convert route strings to IPNet objects\nfunc ParseRoutes(routeStrings []string) ([]net.IPNet, error) {\n\troutes := make([]net.IPNet, 0, len(routeStrings))\n\tfor _, r := range routeStrings {\n\t\t_, ipNet, err := net.ParseCIDR(r)\n\t\tif err != nil {\n\t\t\treturn nil, fmt.Errorf(\"parse route %s error: %v\", r, err)\n\t\t}\n\t\troutes = append(routes, *ipNet)\n\t}\n\treturn routes, nil\n}\n\n// Client router (based on destination IP routing)\ntype clientRouter struct {\n\troutes map[string]*routeElement\n\tmu     sync.RWMutex\n}\n\nfunc newClientRouter() *clientRouter {\n\treturn &clientRouter{\n\t\troutes: make(map[string]*routeElement),\n\t}\n}\n\nfunc (r *clientRouter) addRoute(name string, routes []net.IPNet, conn io.ReadWriteCloser) {\n\tr.mu.Lock()\n\tdefer r.mu.Unlock()\n\tr.routes[name] = &routeElement{\n\t\tname:   name,\n\t\troutes: routes,\n\t\tconn:   conn,\n\t}\n}\n\nfunc (r *clientRouter) findConn(dst net.IP) (io.Writer, error) {\n\tr.mu.RLock()\n\tdefer r.mu.RUnlock()\n\tfor _, re := range r.routes {\n\t\tfor _, route := range re.routes {\n\t\t\tif route.Contains(dst) {\n\t\t\t\treturn re.conn, nil\n\t\t\t}\n\t\t}\n\t}\n\treturn nil, fmt.Errorf(\"no route found for destination %s\", dst)\n}\n\nfunc (r *clientRouter) delRoute(name string) {\n\tr.mu.Lock()\n\tdefer r.mu.Unlock()\n\tdelete(r.routes, name)\n}\n\nfunc (r *clientRouter) removeConnRoute(conn io.Writer) {\n\tr.mu.Lock()\n\tdefer r.mu.Unlock()\n\tfor name, re := range r.routes {\n\t\tif re.conn == conn {\n\t\t\tdelete(r.routes, name)\n\t\t\treturn\n\t\t}\n\t}\n}\n\n// Server router (based solely on source IP routing)\ntype serverRouter struct {\n\tsrcIPConns map[string]io.Writer // Source IP string to connection mapping\n\tmu         sync.RWMutex\n}\n\nfunc newServerRouter() *serverRouter {\n\treturn &serverRouter{\n\t\tsrcIPConns: make(map[string]io.Writer),\n\t}\n}\n\nfunc (r *serverRouter) findConnBySrc(src net.IP) (io.Writer, error) {\n\tr.mu.RLock()\n\tdefer r.mu.RUnlock()\n\tconn, exists := r.srcIPConns[src.String()]\n\tif !exists {\n\t\treturn nil, fmt.Errorf(\"no route found for source %s\", src)\n\t}\n\treturn conn, nil\n}\n\nfunc (r *serverRouter) registerSrcIP(src net.IP, conn io.Writer) {\n\tkey := src.String()\n\n\tr.mu.RLock()\n\texistingConn, ok := r.srcIPConns[key]\n\tr.mu.RUnlock()\n\n\t// If the entry exists and the connection is the same, no need to do anything.\n\tif ok && existingConn == conn {\n\t\treturn\n\t}\n\n\t// Acquire write lock to update the map.\n\tr.mu.Lock()\n\tdefer r.mu.Unlock()\n\n\t// Double-check after acquiring the write lock to handle potential race conditions.\n\texistingConn, ok = r.srcIPConns[key]\n\tif ok && existingConn == conn {\n\t\treturn\n\t}\n\n\tr.srcIPConns[key] = conn\n}\n\n// cleanupConnIPs removes all IP mappings associated with the specified connection\nfunc (r *serverRouter) cleanupConnIPs(conn io.Writer) {\n\tr.mu.Lock()\n\tdefer r.mu.Unlock()\n\n\t// Find and delete all IP mappings pointing to this connection\n\tfor ip, mappedConn := range r.srcIPConns {\n\t\tif mappedConn == conn {\n\t\t\tdelete(r.srcIPConns, ip)\n\t\t}\n\t}\n}\n\ntype routeElement struct {\n\tname   string\n\troutes []net.IPNet\n\tconn   io.ReadWriteCloser\n}\n"
  },
  {
    "path": "pkg/vnet/message.go",
    "content": "// Copyright 2025 The frp Authors\n//\n// Licensed under the Apache License, Version 2.0 (the \"License\");\n// you may not use this file except in compliance with the License.\n// You may obtain a copy of the License at\n//\n//     http://www.apache.org/licenses/LICENSE-2.0\n//\n// Unless required by applicable law or agreed to in writing, software\n// distributed under the License is distributed on an \"AS IS\" BASIS,\n// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.\n// See the License for the specific language governing permissions and\n// limitations under the License.\n\npackage vnet\n\nimport (\n\t\"encoding/binary\"\n\t\"fmt\"\n\t\"io\"\n)\n\n// Maximum message size\nconst (\n\tmaxMessageSize = 1024 * 1024 // 1MB\n)\n\n// Format: [length(4 bytes)][data(length bytes)]\n\n// ReadMessage reads a framed message from the reader\nfunc ReadMessage(r io.Reader) ([]byte, error) {\n\t// Read length (4 bytes)\n\tvar length uint32\n\terr := binary.Read(r, binary.LittleEndian, &length)\n\tif err != nil {\n\t\treturn nil, fmt.Errorf(\"read message length error: %w\", err)\n\t}\n\n\t// Check length to prevent DoS\n\tif length == 0 {\n\t\treturn nil, fmt.Errorf(\"message length is 0\")\n\t}\n\tif length > maxMessageSize {\n\t\treturn nil, fmt.Errorf(\"message too large: %d > %d\", length, maxMessageSize)\n\t}\n\n\t// Read message data\n\tdata := make([]byte, length)\n\t_, err = io.ReadFull(r, data)\n\tif err != nil {\n\t\treturn nil, fmt.Errorf(\"read message data error: %w\", err)\n\t}\n\n\treturn data, nil\n}\n\n// WriteMessage writes a framed message to the writer\nfunc WriteMessage(w io.Writer, data []byte) error {\n\t// Get data length\n\tlength := uint32(len(data))\n\tif length == 0 {\n\t\treturn fmt.Errorf(\"message data length is 0\")\n\t}\n\tif length > maxMessageSize {\n\t\treturn fmt.Errorf(\"message too large: %d > %d\", length, maxMessageSize)\n\t}\n\n\t// Write length\n\terr := binary.Write(w, binary.LittleEndian, length)\n\tif err != nil {\n\t\treturn fmt.Errorf(\"write message length error: %w\", err)\n\t}\n\n\t// Write message data\n\t_, err = w.Write(data)\n\tif err != nil {\n\t\treturn fmt.Errorf(\"write message data error: %w\", err)\n\t}\n\n\treturn nil\n}\n"
  },
  {
    "path": "pkg/vnet/tun.go",
    "content": "// Copyright 2025 The frp Authors\n//\n// Licensed under the Apache License, Version 2.0 (the \"License\");\n// you may not use this file except in compliance with the License.\n// You may obtain a copy of the License at\n//\n//     http://www.apache.org/licenses/LICENSE-2.0\n//\n// Unless required by applicable law or agreed to in writing, software\n// distributed under the License is distributed on an \"AS IS\" BASIS,\n// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.\n// See the License for the specific language governing permissions and\n// limitations under the License.\n\npackage vnet\n\nimport (\n\t\"context\"\n\t\"io\"\n\n\t\"github.com/fatedier/golib/pool\"\n\t\"golang.zx2c4.com/wireguard/tun\"\n)\n\nconst (\n\toffset            = 16\n\tdefaultPacketSize = 1420\n)\n\ntype TunDevice interface {\n\tio.ReadWriteCloser\n}\n\nfunc OpenTun(ctx context.Context, addr string) (TunDevice, error) {\n\ttd, err := openTun(ctx, addr)\n\tif err != nil {\n\t\treturn nil, err\n\t}\n\n\tmtu, err := td.MTU()\n\tif err != nil {\n\t\tmtu = defaultPacketSize\n\t}\n\n\tbufferSize := max(mtu, defaultPacketSize)\n\tbatchSize := td.BatchSize()\n\n\tdevice := &tunDeviceWrapper{\n\t\tdev:         td,\n\t\tbufferSize:  bufferSize,\n\t\treadBuffers: make([][]byte, batchSize),\n\t\tsizeBuffer:  make([]int, batchSize),\n\t}\n\n\tfor i := range device.readBuffers {\n\t\tdevice.readBuffers[i] = make([]byte, offset+bufferSize)\n\t}\n\n\treturn device, nil\n}\n\ntype tunDeviceWrapper struct {\n\tdev           tun.Device\n\tbufferSize    int\n\treadBuffers   [][]byte\n\tpacketBuffers [][]byte\n\tsizeBuffer    []int\n}\n\nfunc (d *tunDeviceWrapper) Read(p []byte) (int, error) {\n\tif len(d.packetBuffers) > 0 {\n\t\tn := copy(p, d.packetBuffers[0])\n\t\td.packetBuffers = d.packetBuffers[1:]\n\t\treturn n, nil\n\t}\n\n\tn, err := d.dev.Read(d.readBuffers, d.sizeBuffer, offset)\n\tif err != nil {\n\t\treturn 0, err\n\t}\n\tif n == 0 {\n\t\treturn 0, io.EOF\n\t}\n\n\tfor i := range n {\n\t\tif d.sizeBuffer[i] <= 0 {\n\t\t\tcontinue\n\t\t}\n\t\td.packetBuffers = append(d.packetBuffers, d.readBuffers[i][offset:offset+d.sizeBuffer[i]])\n\t}\n\n\tdataSize := copy(p, d.packetBuffers[0])\n\td.packetBuffers = d.packetBuffers[1:]\n\n\treturn dataSize, nil\n}\n\nfunc (d *tunDeviceWrapper) Write(p []byte) (int, error) {\n\tbuf := pool.GetBuf(offset + d.bufferSize)\n\tdefer pool.PutBuf(buf)\n\n\tn := copy(buf[offset:], p)\n\t_, err := d.dev.Write([][]byte{buf[:offset+n]}, offset)\n\treturn n, err\n}\n\nfunc (d *tunDeviceWrapper) Close() error {\n\treturn d.dev.Close()\n}\n"
  },
  {
    "path": "pkg/vnet/tun_darwin.go",
    "content": "// Copyright 2025 The frp Authors\n//\n// Licensed under the Apache License, Version 2.0 (the \"License\");\n// you may not use this file except in compliance with the License.\n// You may obtain a copy of the License at\n//\n//     http://www.apache.org/licenses/LICENSE-2.0\n//\n// Unless required by applicable law or agreed to in writing, software\n// distributed under the License is distributed on an \"AS IS\" BASIS,\n// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.\n// See the License for the specific language governing permissions and\n// limitations under the License.\n\npackage vnet\n\nimport (\n\t\"context\"\n\t\"fmt\"\n\t\"net\"\n\t\"os/exec\"\n\n\t\"golang.zx2c4.com/wireguard/tun\"\n)\n\nconst (\n\tdefaultTunName = \"utun\"\n\tdefaultMTU     = 1420\n)\n\nfunc openTun(_ context.Context, addr string) (tun.Device, error) {\n\tdev, err := tun.CreateTUN(defaultTunName, defaultMTU)\n\tif err != nil {\n\t\treturn nil, err\n\t}\n\n\tname, err := dev.Name()\n\tif err != nil {\n\t\treturn nil, err\n\t}\n\n\tip, ipNet, err := net.ParseCIDR(addr)\n\tif err != nil {\n\t\treturn nil, err\n\t}\n\n\t// Calculate a peer IP for the point-to-point tunnel\n\tpeerIP := generatePeerIP(ip)\n\n\t// Configure the interface with proper point-to-point addressing\n\tif err = exec.Command(\"ifconfig\", name, \"inet\", ip.String(), peerIP.String(), \"mtu\", fmt.Sprint(defaultMTU), \"up\").Run(); err != nil {\n\t\treturn nil, err\n\t}\n\n\t// Add default route for the tunnel subnet\n\troutes := []net.IPNet{*ipNet}\n\tif err = addRoutes(name, routes); err != nil {\n\t\treturn nil, err\n\t}\n\treturn dev, nil\n}\n\n// generatePeerIP creates a peer IP for the point-to-point tunnel\n// by incrementing the last octet of the IP\nfunc generatePeerIP(ip net.IP) net.IP {\n\t// Make a copy to avoid modifying the original\n\tpeerIP := make(net.IP, len(ip))\n\tcopy(peerIP, ip)\n\n\t// Increment the last octet\n\tpeerIP[len(peerIP)-1]++\n\n\treturn peerIP\n}\n\n// addRoutes configures system routes for the TUN interface\nfunc addRoutes(ifName string, routes []net.IPNet) error {\n\tfor _, route := range routes {\n\t\trouteStr := route.String()\n\t\tif err := exec.Command(\"route\", \"add\", \"-net\", routeStr, \"-interface\", ifName).Run(); err != nil {\n\t\t\treturn err\n\t\t}\n\t}\n\treturn nil\n}\n"
  },
  {
    "path": "pkg/vnet/tun_linux.go",
    "content": "// Copyright 2025 The frp Authors\n//\n// Licensed under the Apache License, Version 2.0 (the \"License\");\n// you may not use this file except in compliance with the License.\n// You may obtain a copy of the License at\n//\n//     http://www.apache.org/licenses/LICENSE-2.0\n//\n// Unless required by applicable law or agreed to in writing, software\n// distributed under the License is distributed on an \"AS IS\" BASIS,\n// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.\n// See the License for the specific language governing permissions and\n// limitations under the License.\n\npackage vnet\n\nimport (\n\t\"context\"\n\t\"crypto/sha256\"\n\t\"encoding/hex\"\n\t\"fmt\"\n\t\"net\"\n\t\"strconv\"\n\t\"strings\"\n\n\t\"github.com/vishvananda/netlink\"\n\t\"golang.zx2c4.com/wireguard/tun\"\n)\n\nconst (\n\tbaseTunName = \"utun\"\n\tdefaultMTU  = 1420\n)\n\nfunc openTun(_ context.Context, addr string) (tun.Device, error) {\n\tname, err := findNextTunName(baseTunName)\n\tif err != nil {\n\t\tname = getFallbackTunName(baseTunName, addr)\n\t}\n\n\ttunDevice, err := tun.CreateTUN(name, defaultMTU)\n\tif err != nil {\n\t\treturn nil, fmt.Errorf(\"failed to create TUN device '%s': %w\", name, err)\n\t}\n\n\tactualName, err := tunDevice.Name()\n\tif err != nil {\n\t\treturn nil, err\n\t}\n\n\tifn, err := net.InterfaceByName(actualName)\n\tif err != nil {\n\t\treturn nil, err\n\t}\n\n\tlink, err := netlink.LinkByName(actualName)\n\tif err != nil {\n\t\treturn nil, err\n\t}\n\n\tip, cidr, err := net.ParseCIDR(addr)\n\tif err != nil {\n\t\treturn nil, err\n\t}\n\tif err := netlink.AddrAdd(link, &netlink.Addr{\n\t\tIPNet: &net.IPNet{\n\t\t\tIP:   ip,\n\t\t\tMask: cidr.Mask,\n\t\t},\n\t}); err != nil {\n\t\treturn nil, err\n\t}\n\n\tif err := netlink.LinkSetUp(link); err != nil {\n\t\treturn nil, err\n\t}\n\n\tif err = addRoutes(ifn, cidr); err != nil {\n\t\treturn nil, err\n\t}\n\treturn tunDevice, nil\n}\n\nfunc findNextTunName(basename string) (string, error) {\n\tinterfaces, err := net.Interfaces()\n\tif err != nil {\n\t\treturn \"\", fmt.Errorf(\"failed to get network interfaces: %w\", err)\n\t}\n\tmaxSuffix := -1\n\n\tfor _, iface := range interfaces {\n\t\tname := iface.Name\n\t\tif strings.HasPrefix(name, basename) {\n\t\t\tsuffix := name[len(basename):]\n\t\t\tif suffix == \"\" {\n\t\t\t\tcontinue\n\t\t\t}\n\n\t\t\tnumSuffix, err := strconv.Atoi(suffix)\n\t\t\tif err == nil && numSuffix > maxSuffix {\n\t\t\t\tmaxSuffix = numSuffix\n\t\t\t}\n\t\t}\n\t}\n\n\tnextSuffix := maxSuffix + 1\n\tname := fmt.Sprintf(\"%s%d\", basename, nextSuffix)\n\treturn name, nil\n}\n\nfunc addRoutes(ifn *net.Interface, cidr *net.IPNet) error {\n\tr := netlink.Route{\n\t\tDst:       cidr,\n\t\tLinkIndex: ifn.Index,\n\t}\n\tif err := netlink.RouteReplace(&r); err != nil {\n\t\treturn fmt.Errorf(\"add route to %v error: %v\", r.Dst, err)\n\t}\n\treturn nil\n}\n\n// getFallbackTunName generates a deterministic fallback TUN device name\n// based on the base name and the provided address string using a hash.\nfunc getFallbackTunName(baseName, addr string) string {\n\thasher := sha256.New()\n\thasher.Write([]byte(addr))\n\thashBytes := hasher.Sum(nil)\n\t// Use first 4 bytes -> 8 hex chars for brevity, respecting IFNAMSIZ limit.\n\tshortHash := hex.EncodeToString(hashBytes[:4])\n\treturn fmt.Sprintf(\"%s%s\", baseName, shortHash)\n}\n"
  },
  {
    "path": "pkg/vnet/tun_unsupported.go",
    "content": "// Copyright 2025 The frp Authors\n//\n// Licensed under the Apache License, Version 2.0 (the \"License\");\n// you may not use this file except in compliance with the License.\n// You may obtain a copy of the License at\n//\n//     http://www.apache.org/licenses/LICENSE-2.0\n//\n// Unless required by applicable law or agreed to in writing, software\n// distributed under the License is distributed on an \"AS IS\" BASIS,\n// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.\n// See the License for the specific language governing permissions and\n// limitations under the License.\n\n//go:build !darwin && !linux\n\npackage vnet\n\nimport (\n\t\"context\"\n\t\"fmt\"\n\t\"runtime\"\n\n\t\"golang.zx2c4.com/wireguard/tun\"\n)\n\nfunc openTun(_ context.Context, _ string) (tun.Device, error) {\n\treturn nil, fmt.Errorf(\"virtual net is not supported on this platform (%s/%s)\", runtime.GOOS, runtime.GOARCH)\n}\n"
  },
  {
    "path": "server/api_router.go",
    "content": "// Copyright 2017 fatedier, fatedier@gmail.com\n//\n// Licensed under the Apache License, Version 2.0 (the \"License\");\n// you may not use this file except in compliance with the License.\n// You may obtain a copy of the License at\n//\n//     http://www.apache.org/licenses/LICENSE-2.0\n//\n// Unless required by applicable law or agreed to in writing, software\n// distributed under the License is distributed on an \"AS IS\" BASIS,\n// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.\n// See the License for the specific language governing permissions and\n// limitations under the License.\n\npackage server\n\nimport (\n\t\"net/http\"\n\n\t\"github.com/prometheus/client_golang/prometheus/promhttp\"\n\n\thttppkg \"github.com/fatedier/frp/pkg/util/http\"\n\tnetpkg \"github.com/fatedier/frp/pkg/util/net\"\n\tadminapi \"github.com/fatedier/frp/server/http\"\n)\n\nfunc (svr *Service) registerRouteHandlers(helper *httppkg.RouterRegisterHelper) {\n\thelper.Router.HandleFunc(\"/healthz\", healthz)\n\tsubRouter := helper.Router.NewRoute().Subrouter()\n\n\tsubRouter.Use(helper.AuthMiddleware)\n\tsubRouter.Use(httppkg.NewRequestLogger)\n\n\t// metrics\n\tif svr.cfg.EnablePrometheus {\n\t\tsubRouter.Handle(\"/metrics\", promhttp.Handler())\n\t}\n\n\tapiController := adminapi.NewController(svr.cfg, svr.clientRegistry, svr.pxyManager)\n\n\t// apis\n\tsubRouter.HandleFunc(\"/api/serverinfo\", httppkg.MakeHTTPHandlerFunc(apiController.APIServerInfo)).Methods(\"GET\")\n\tsubRouter.HandleFunc(\"/api/proxy/{type}\", httppkg.MakeHTTPHandlerFunc(apiController.APIProxyByType)).Methods(\"GET\")\n\tsubRouter.HandleFunc(\"/api/proxy/{type}/{name}\", httppkg.MakeHTTPHandlerFunc(apiController.APIProxyByTypeAndName)).Methods(\"GET\")\n\tsubRouter.HandleFunc(\"/api/proxies/{name}\", httppkg.MakeHTTPHandlerFunc(apiController.APIProxyByName)).Methods(\"GET\")\n\tsubRouter.HandleFunc(\"/api/traffic/{name}\", httppkg.MakeHTTPHandlerFunc(apiController.APIProxyTraffic)).Methods(\"GET\")\n\tsubRouter.HandleFunc(\"/api/clients\", httppkg.MakeHTTPHandlerFunc(apiController.APIClientList)).Methods(\"GET\")\n\tsubRouter.HandleFunc(\"/api/clients/{key}\", httppkg.MakeHTTPHandlerFunc(apiController.APIClientDetail)).Methods(\"GET\")\n\tsubRouter.HandleFunc(\"/api/proxies\", httppkg.MakeHTTPHandlerFunc(apiController.DeleteProxies)).Methods(\"DELETE\")\n\n\t// view\n\tsubRouter.Handle(\"/favicon.ico\", http.FileServer(helper.AssetsFS)).Methods(\"GET\")\n\tsubRouter.PathPrefix(\"/static/\").Handler(\n\t\tnetpkg.MakeHTTPGzipHandler(http.StripPrefix(\"/static/\", http.FileServer(helper.AssetsFS))),\n\t).Methods(\"GET\")\n\n\tsubRouter.HandleFunc(\"/\", func(w http.ResponseWriter, r *http.Request) {\n\t\thttp.Redirect(w, r, \"/static/\", http.StatusMovedPermanently)\n\t})\n}\n\nfunc healthz(w http.ResponseWriter, _ *http.Request) {\n\tw.WriteHeader(200)\n}\n"
  },
  {
    "path": "server/control.go",
    "content": "// Copyright 2017 fatedier, fatedier@gmail.com\n//\n// Licensed under the Apache License, Version 2.0 (the \"License\");\n// you may not use this file except in compliance with the License.\n// You may obtain a copy of the License at\n//\n//     http://www.apache.org/licenses/LICENSE-2.0\n//\n// Unless required by applicable law or agreed to in writing, software\n// distributed under the License is distributed on an \"AS IS\" BASIS,\n// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.\n// See the License for the specific language governing permissions and\n// limitations under the License.\n\npackage server\n\nimport (\n\t\"context\"\n\t\"fmt\"\n\t\"net\"\n\t\"runtime/debug\"\n\t\"sync\"\n\t\"sync/atomic\"\n\t\"time\"\n\n\t\"github.com/samber/lo\"\n\n\t\"github.com/fatedier/frp/pkg/auth\"\n\t\"github.com/fatedier/frp/pkg/config\"\n\tv1 \"github.com/fatedier/frp/pkg/config/v1\"\n\tpkgerr \"github.com/fatedier/frp/pkg/errors\"\n\t\"github.com/fatedier/frp/pkg/msg\"\n\tplugin \"github.com/fatedier/frp/pkg/plugin/server\"\n\t\"github.com/fatedier/frp/pkg/transport\"\n\tnetpkg \"github.com/fatedier/frp/pkg/util/net\"\n\t\"github.com/fatedier/frp/pkg/util/util\"\n\t\"github.com/fatedier/frp/pkg/util/version\"\n\t\"github.com/fatedier/frp/pkg/util/wait\"\n\t\"github.com/fatedier/frp/pkg/util/xlog\"\n\t\"github.com/fatedier/frp/server/controller\"\n\t\"github.com/fatedier/frp/server/metrics\"\n\t\"github.com/fatedier/frp/server/proxy\"\n\t\"github.com/fatedier/frp/server/registry\"\n)\n\ntype ControlManager struct {\n\t// controls indexed by run id\n\tctlsByRunID map[string]*Control\n\n\tmu sync.RWMutex\n}\n\nfunc NewControlManager() *ControlManager {\n\treturn &ControlManager{\n\t\tctlsByRunID: make(map[string]*Control),\n\t}\n}\n\nfunc (cm *ControlManager) Add(runID string, ctl *Control) (old *Control) {\n\tcm.mu.Lock()\n\tdefer cm.mu.Unlock()\n\n\tvar ok bool\n\told, ok = cm.ctlsByRunID[runID]\n\tif ok {\n\t\told.Replaced(ctl)\n\t}\n\tcm.ctlsByRunID[runID] = ctl\n\treturn\n}\n\n// we should make sure if it's the same control to prevent delete a new one\nfunc (cm *ControlManager) Del(runID string, ctl *Control) {\n\tcm.mu.Lock()\n\tdefer cm.mu.Unlock()\n\tif c, ok := cm.ctlsByRunID[runID]; ok && c == ctl {\n\t\tdelete(cm.ctlsByRunID, runID)\n\t}\n}\n\nfunc (cm *ControlManager) GetByID(runID string) (ctl *Control, ok bool) {\n\tcm.mu.RLock()\n\tdefer cm.mu.RUnlock()\n\tctl, ok = cm.ctlsByRunID[runID]\n\treturn\n}\n\nfunc (cm *ControlManager) Close() error {\n\tcm.mu.Lock()\n\tdefer cm.mu.Unlock()\n\tfor _, ctl := range cm.ctlsByRunID {\n\t\tctl.Close()\n\t}\n\tcm.ctlsByRunID = make(map[string]*Control)\n\treturn nil\n}\n\n// SessionContext encapsulates the input parameters for creating a new Control.\ntype SessionContext struct {\n\t// all resource managers and controllers\n\tRC *controller.ResourceController\n\t// proxy manager\n\tPxyManager *proxy.Manager\n\t// plugin manager\n\tPluginManager *plugin.Manager\n\t// verifies authentication based on selected method\n\tAuthVerifier auth.Verifier\n\t// key used for connection encryption\n\tEncryptionKey []byte\n\t// control connection\n\tConn net.Conn\n\t// indicates whether the connection is encrypted\n\tConnEncrypted bool\n\t// login message\n\tLoginMsg *msg.Login\n\t// server configuration\n\tServerCfg *v1.ServerConfig\n\t// client registry\n\tClientRegistry *registry.ClientRegistry\n}\n\ntype Control struct {\n\t// session context\n\tsessionCtx *SessionContext\n\n\t// other components can use this to communicate with client\n\tmsgTransporter transport.MessageTransporter\n\n\t// msgDispatcher is a wrapper for control connection.\n\t// It provides a channel for sending messages, and you can register handlers to process messages based on their respective types.\n\tmsgDispatcher *msg.Dispatcher\n\n\t// work connections\n\tworkConnCh chan net.Conn\n\n\t// proxies in one client\n\tproxies map[string]proxy.Proxy\n\n\t// pool count\n\tpoolCount int\n\n\t// ports used, for limitations\n\tportsUsedNum int\n\n\t// last time got the Ping message\n\tlastPing atomic.Value\n\n\t// A new run id will be generated when a new client login.\n\t// If run id got from login message has same run id, it means it's the same client, so we can\n\t// replace old controller instantly.\n\trunID string\n\n\tmu sync.RWMutex\n\n\txl     *xlog.Logger\n\tctx    context.Context\n\tdoneCh chan struct{}\n}\n\nfunc NewControl(ctx context.Context, sessionCtx *SessionContext) (*Control, error) {\n\tpoolCount := min(sessionCtx.LoginMsg.PoolCount, int(sessionCtx.ServerCfg.Transport.MaxPoolCount))\n\tctl := &Control{\n\t\tsessionCtx:   sessionCtx,\n\t\tworkConnCh:   make(chan net.Conn, poolCount+10),\n\t\tproxies:      make(map[string]proxy.Proxy),\n\t\tpoolCount:    poolCount,\n\t\tportsUsedNum: 0,\n\t\trunID:        sessionCtx.LoginMsg.RunID,\n\t\txl:           xlog.FromContextSafe(ctx),\n\t\tctx:          ctx,\n\t\tdoneCh:       make(chan struct{}),\n\t}\n\tctl.lastPing.Store(time.Now())\n\n\tif sessionCtx.ConnEncrypted {\n\t\tcryptoRW, err := netpkg.NewCryptoReadWriter(sessionCtx.Conn, sessionCtx.EncryptionKey)\n\t\tif err != nil {\n\t\t\treturn nil, err\n\t\t}\n\t\tctl.msgDispatcher = msg.NewDispatcher(cryptoRW)\n\t} else {\n\t\tctl.msgDispatcher = msg.NewDispatcher(sessionCtx.Conn)\n\t}\n\tctl.registerMsgHandlers()\n\tctl.msgTransporter = transport.NewMessageTransporter(ctl.msgDispatcher)\n\treturn ctl, nil\n}\n\n// Start send a login success message to client and start working.\nfunc (ctl *Control) Start() {\n\tloginRespMsg := &msg.LoginResp{\n\t\tVersion: version.Full(),\n\t\tRunID:   ctl.runID,\n\t\tError:   \"\",\n\t}\n\t_ = msg.WriteMsg(ctl.sessionCtx.Conn, loginRespMsg)\n\n\tgo func() {\n\t\tfor i := 0; i < ctl.poolCount; i++ {\n\t\t\t// ignore error here, that means that this control is closed\n\t\t\t_ = ctl.msgDispatcher.Send(&msg.ReqWorkConn{})\n\t\t}\n\t}()\n\tgo ctl.worker()\n}\n\nfunc (ctl *Control) Close() error {\n\tctl.sessionCtx.Conn.Close()\n\treturn nil\n}\n\nfunc (ctl *Control) Replaced(newCtl *Control) {\n\txl := ctl.xl\n\txl.Infof(\"replaced by client [%s]\", newCtl.runID)\n\tctl.runID = \"\"\n\tctl.sessionCtx.Conn.Close()\n}\n\nfunc (ctl *Control) RegisterWorkConn(conn net.Conn) error {\n\txl := ctl.xl\n\tdefer func() {\n\t\tif err := recover(); err != nil {\n\t\t\txl.Errorf(\"panic error: %v\", err)\n\t\t\txl.Errorf(string(debug.Stack()))\n\t\t}\n\t}()\n\n\tselect {\n\tcase ctl.workConnCh <- conn:\n\t\txl.Debugf(\"new work connection registered\")\n\t\treturn nil\n\tdefault:\n\t\txl.Debugf(\"work connection pool is full, discarding\")\n\t\treturn fmt.Errorf(\"work connection pool is full, discarding\")\n\t}\n}\n\n// When frps get one user connection, we get one work connection from the pool and return it.\n// If no workConn available in the pool, send message to frpc to get one or more\n// and wait until it is available.\n// return an error if wait timeout\nfunc (ctl *Control) GetWorkConn() (workConn net.Conn, err error) {\n\txl := ctl.xl\n\tdefer func() {\n\t\tif err := recover(); err != nil {\n\t\t\txl.Errorf(\"panic error: %v\", err)\n\t\t\txl.Errorf(string(debug.Stack()))\n\t\t}\n\t}()\n\n\tvar ok bool\n\t// get a work connection from the pool\n\tselect {\n\tcase workConn, ok = <-ctl.workConnCh:\n\t\tif !ok {\n\t\t\terr = pkgerr.ErrCtlClosed\n\t\t\treturn\n\t\t}\n\t\txl.Debugf(\"get work connection from pool\")\n\tdefault:\n\t\t// no work connections available in the poll, send message to frpc to get more\n\t\tif err := ctl.msgDispatcher.Send(&msg.ReqWorkConn{}); err != nil {\n\t\t\treturn nil, fmt.Errorf(\"control is already closed\")\n\t\t}\n\n\t\tselect {\n\t\tcase workConn, ok = <-ctl.workConnCh:\n\t\t\tif !ok {\n\t\t\t\terr = pkgerr.ErrCtlClosed\n\t\t\t\txl.Warnf(\"no work connections available, %v\", err)\n\t\t\t\treturn\n\t\t\t}\n\n\t\tcase <-time.After(time.Duration(ctl.sessionCtx.ServerCfg.UserConnTimeout) * time.Second):\n\t\t\terr = fmt.Errorf(\"timeout trying to get work connection\")\n\t\t\txl.Warnf(\"%v\", err)\n\t\t\treturn\n\t\t}\n\t}\n\n\t// When we get a work connection from pool, replace it with a new one.\n\t_ = ctl.msgDispatcher.Send(&msg.ReqWorkConn{})\n\treturn\n}\n\nfunc (ctl *Control) heartbeatWorker() {\n\tif ctl.sessionCtx.ServerCfg.Transport.HeartbeatTimeout <= 0 {\n\t\treturn\n\t}\n\n\txl := ctl.xl\n\tgo wait.Until(func() {\n\t\tif time.Since(ctl.lastPing.Load().(time.Time)) > time.Duration(ctl.sessionCtx.ServerCfg.Transport.HeartbeatTimeout)*time.Second {\n\t\t\txl.Warnf(\"heartbeat timeout\")\n\t\t\tctl.sessionCtx.Conn.Close()\n\t\t\treturn\n\t\t}\n\t}, time.Second, ctl.doneCh)\n}\n\n// block until Control closed\nfunc (ctl *Control) WaitClosed() {\n\t<-ctl.doneCh\n}\n\nfunc (ctl *Control) loginUserInfo() plugin.UserInfo {\n\treturn plugin.UserInfo{\n\t\tUser:  ctl.sessionCtx.LoginMsg.User,\n\t\tMetas: ctl.sessionCtx.LoginMsg.Metas,\n\t\tRunID: ctl.sessionCtx.LoginMsg.RunID,\n\t}\n}\n\nfunc (ctl *Control) closeProxy(pxy proxy.Proxy) {\n\tpxy.Close()\n\tctl.sessionCtx.PxyManager.Del(pxy.GetName())\n\tmetrics.Server.CloseProxy(pxy.GetName(), pxy.GetConfigurer().GetBaseConfig().Type)\n\n\tnotifyContent := &plugin.CloseProxyContent{\n\t\tUser: ctl.loginUserInfo(),\n\t\tCloseProxy: msg.CloseProxy{\n\t\t\tProxyName: pxy.GetName(),\n\t\t},\n\t}\n\tgo func() {\n\t\t_ = ctl.sessionCtx.PluginManager.CloseProxy(notifyContent)\n\t}()\n}\n\nfunc (ctl *Control) worker() {\n\txl := ctl.xl\n\n\tgo ctl.heartbeatWorker()\n\tgo ctl.msgDispatcher.Run()\n\n\t<-ctl.msgDispatcher.Done()\n\tctl.sessionCtx.Conn.Close()\n\n\tctl.mu.Lock()\n\tclose(ctl.workConnCh)\n\tfor workConn := range ctl.workConnCh {\n\t\tworkConn.Close()\n\t}\n\tproxies := ctl.proxies\n\tctl.proxies = make(map[string]proxy.Proxy)\n\tctl.mu.Unlock()\n\n\tfor _, pxy := range proxies {\n\t\tctl.closeProxy(pxy)\n\t}\n\n\tmetrics.Server.CloseClient()\n\tctl.sessionCtx.ClientRegistry.MarkOfflineByRunID(ctl.runID)\n\txl.Infof(\"client exit success\")\n\tclose(ctl.doneCh)\n}\n\nfunc (ctl *Control) registerMsgHandlers() {\n\tctl.msgDispatcher.RegisterHandler(&msg.NewProxy{}, ctl.handleNewProxy)\n\tctl.msgDispatcher.RegisterHandler(&msg.Ping{}, ctl.handlePing)\n\tctl.msgDispatcher.RegisterHandler(&msg.NatHoleVisitor{}, msg.AsyncHandler(ctl.handleNatHoleVisitor))\n\tctl.msgDispatcher.RegisterHandler(&msg.NatHoleClient{}, msg.AsyncHandler(ctl.handleNatHoleClient))\n\tctl.msgDispatcher.RegisterHandler(&msg.NatHoleReport{}, msg.AsyncHandler(ctl.handleNatHoleReport))\n\tctl.msgDispatcher.RegisterHandler(&msg.CloseProxy{}, ctl.handleCloseProxy)\n}\n\nfunc (ctl *Control) handleNewProxy(m msg.Message) {\n\txl := ctl.xl\n\tinMsg := m.(*msg.NewProxy)\n\n\tcontent := &plugin.NewProxyContent{\n\t\tUser:     ctl.loginUserInfo(),\n\t\tNewProxy: *inMsg,\n\t}\n\tvar remoteAddr string\n\tretContent, err := ctl.sessionCtx.PluginManager.NewProxy(content)\n\tif err == nil {\n\t\tinMsg = &retContent.NewProxy\n\t\tremoteAddr, err = ctl.RegisterProxy(inMsg)\n\t}\n\n\t// register proxy in this control\n\tresp := &msg.NewProxyResp{\n\t\tProxyName: inMsg.ProxyName,\n\t}\n\tif err != nil {\n\t\txl.Warnf(\"new proxy [%s] type [%s] error: %v\", inMsg.ProxyName, inMsg.ProxyType, err)\n\t\tresp.Error = util.GenerateResponseErrorString(fmt.Sprintf(\"new proxy [%s] error\", inMsg.ProxyName),\n\t\t\terr, lo.FromPtr(ctl.sessionCtx.ServerCfg.DetailedErrorsToClient))\n\t} else {\n\t\tresp.RemoteAddr = remoteAddr\n\t\txl.Infof(\"new proxy [%s] type [%s] success\", inMsg.ProxyName, inMsg.ProxyType)\n\t\tclientID := ctl.sessionCtx.LoginMsg.ClientID\n\t\tif clientID == \"\" {\n\t\t\tclientID = ctl.sessionCtx.LoginMsg.RunID\n\t\t}\n\t\tmetrics.Server.NewProxy(inMsg.ProxyName, inMsg.ProxyType, ctl.sessionCtx.LoginMsg.User, clientID)\n\t}\n\t_ = ctl.msgDispatcher.Send(resp)\n}\n\nfunc (ctl *Control) handlePing(m msg.Message) {\n\txl := ctl.xl\n\tinMsg := m.(*msg.Ping)\n\n\tcontent := &plugin.PingContent{\n\t\tUser: ctl.loginUserInfo(),\n\t\tPing: *inMsg,\n\t}\n\tretContent, err := ctl.sessionCtx.PluginManager.Ping(content)\n\tif err == nil {\n\t\tinMsg = &retContent.Ping\n\t\terr = ctl.sessionCtx.AuthVerifier.VerifyPing(inMsg)\n\t}\n\tif err != nil {\n\t\txl.Warnf(\"received invalid ping: %v\", err)\n\t\t_ = ctl.msgDispatcher.Send(&msg.Pong{\n\t\t\tError: util.GenerateResponseErrorString(\"invalid ping\", err, lo.FromPtr(ctl.sessionCtx.ServerCfg.DetailedErrorsToClient)),\n\t\t})\n\t\treturn\n\t}\n\tctl.lastPing.Store(time.Now())\n\txl.Debugf(\"receive heartbeat\")\n\t_ = ctl.msgDispatcher.Send(&msg.Pong{})\n}\n\nfunc (ctl *Control) handleNatHoleVisitor(m msg.Message) {\n\tinMsg := m.(*msg.NatHoleVisitor)\n\tctl.sessionCtx.RC.NatHoleController.HandleVisitor(inMsg, ctl.msgTransporter, ctl.sessionCtx.LoginMsg.User)\n}\n\nfunc (ctl *Control) handleNatHoleClient(m msg.Message) {\n\tinMsg := m.(*msg.NatHoleClient)\n\tctl.sessionCtx.RC.NatHoleController.HandleClient(inMsg, ctl.msgTransporter)\n}\n\nfunc (ctl *Control) handleNatHoleReport(m msg.Message) {\n\tinMsg := m.(*msg.NatHoleReport)\n\tctl.sessionCtx.RC.NatHoleController.HandleReport(inMsg)\n}\n\nfunc (ctl *Control) handleCloseProxy(m msg.Message) {\n\txl := ctl.xl\n\tinMsg := m.(*msg.CloseProxy)\n\t_ = ctl.CloseProxy(inMsg)\n\txl.Infof(\"close proxy [%s] success\", inMsg.ProxyName)\n}\n\nfunc (ctl *Control) RegisterProxy(pxyMsg *msg.NewProxy) (remoteAddr string, err error) {\n\tvar pxyConf v1.ProxyConfigurer\n\t// Load configures from NewProxy message and validate.\n\tpxyConf, err = config.NewProxyConfigurerFromMsg(pxyMsg, ctl.sessionCtx.ServerCfg)\n\tif err != nil {\n\t\treturn\n\t}\n\n\t// User info\n\tuserInfo := plugin.UserInfo{\n\t\tUser:  ctl.sessionCtx.LoginMsg.User,\n\t\tMetas: ctl.sessionCtx.LoginMsg.Metas,\n\t\tRunID: ctl.runID,\n\t}\n\n\t// NewProxy will return an interface Proxy.\n\t// In fact, it creates different proxies based on the proxy type. We just call run() here.\n\tpxy, err := proxy.NewProxy(ctl.ctx, &proxy.Options{\n\t\tUserInfo:           userInfo,\n\t\tLoginMsg:           ctl.sessionCtx.LoginMsg,\n\t\tPoolCount:          ctl.poolCount,\n\t\tResourceController: ctl.sessionCtx.RC,\n\t\tGetWorkConnFn:      ctl.GetWorkConn,\n\t\tConfigurer:         pxyConf,\n\t\tServerCfg:          ctl.sessionCtx.ServerCfg,\n\t\tEncryptionKey:      ctl.sessionCtx.EncryptionKey,\n\t})\n\tif err != nil {\n\t\treturn remoteAddr, err\n\t}\n\n\t// Check ports used number in each client\n\tif ctl.sessionCtx.ServerCfg.MaxPortsPerClient > 0 {\n\t\tctl.mu.Lock()\n\t\tif ctl.portsUsedNum+pxy.GetUsedPortsNum() > int(ctl.sessionCtx.ServerCfg.MaxPortsPerClient) {\n\t\t\tctl.mu.Unlock()\n\t\t\terr = fmt.Errorf(\"exceed the max_ports_per_client\")\n\t\t\treturn\n\t\t}\n\t\tctl.portsUsedNum += pxy.GetUsedPortsNum()\n\t\tctl.mu.Unlock()\n\n\t\tdefer func() {\n\t\t\tif err != nil {\n\t\t\t\tctl.mu.Lock()\n\t\t\t\tctl.portsUsedNum -= pxy.GetUsedPortsNum()\n\t\t\t\tctl.mu.Unlock()\n\t\t\t}\n\t\t}()\n\t}\n\n\tif ctl.sessionCtx.PxyManager.Exist(pxyMsg.ProxyName) {\n\t\terr = fmt.Errorf(\"proxy [%s] already exists\", pxyMsg.ProxyName)\n\t\treturn\n\t}\n\n\tremoteAddr, err = pxy.Run()\n\tif err != nil {\n\t\treturn\n\t}\n\tdefer func() {\n\t\tif err != nil {\n\t\t\tpxy.Close()\n\t\t}\n\t}()\n\n\terr = ctl.sessionCtx.PxyManager.Add(pxyMsg.ProxyName, pxy)\n\tif err != nil {\n\t\treturn\n\t}\n\n\tctl.mu.Lock()\n\tctl.proxies[pxy.GetName()] = pxy\n\tctl.mu.Unlock()\n\treturn\n}\n\nfunc (ctl *Control) CloseProxy(closeMsg *msg.CloseProxy) (err error) {\n\tctl.mu.Lock()\n\tpxy, ok := ctl.proxies[closeMsg.ProxyName]\n\tif !ok {\n\t\tctl.mu.Unlock()\n\t\treturn\n\t}\n\n\tif ctl.sessionCtx.ServerCfg.MaxPortsPerClient > 0 {\n\t\tctl.portsUsedNum -= pxy.GetUsedPortsNum()\n\t}\n\tdelete(ctl.proxies, closeMsg.ProxyName)\n\tctl.mu.Unlock()\n\n\tctl.closeProxy(pxy)\n\treturn\n}\n"
  },
  {
    "path": "server/controller/resource.go",
    "content": "// Copyright 2019 fatedier, fatedier@gmail.com\n//\n// Licensed under the Apache License, Version 2.0 (the \"License\");\n// you may not use this file except in compliance with the License.\n// You may obtain a copy of the License at\n//\n//     http://www.apache.org/licenses/LICENSE-2.0\n//\n// Unless required by applicable law or agreed to in writing, software\n// distributed under the License is distributed on an \"AS IS\" BASIS,\n// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.\n// See the License for the specific language governing permissions and\n// limitations under the License.\n\npackage controller\n\nimport (\n\t\"github.com/fatedier/frp/pkg/nathole\"\n\tplugin \"github.com/fatedier/frp/pkg/plugin/server\"\n\t\"github.com/fatedier/frp/pkg/util/tcpmux\"\n\t\"github.com/fatedier/frp/pkg/util/vhost\"\n\t\"github.com/fatedier/frp/server/group\"\n\t\"github.com/fatedier/frp/server/ports\"\n\t\"github.com/fatedier/frp/server/visitor\"\n)\n\n// All resource managers and controllers\ntype ResourceController struct {\n\t// Manage all visitor listeners\n\tVisitorManager *visitor.Manager\n\n\t// TCP Group Controller\n\tTCPGroupCtl *group.TCPGroupCtl\n\n\t// HTTP Group Controller\n\tHTTPGroupCtl *group.HTTPGroupController\n\n\t// HTTPS Group Controller\n\tHTTPSGroupCtl *group.HTTPSGroupController\n\n\t// TCP Mux Group Controller\n\tTCPMuxGroupCtl *group.TCPMuxGroupCtl\n\n\t// Manage all TCP ports\n\tTCPPortManager *ports.Manager\n\n\t// Manage all UDP ports\n\tUDPPortManager *ports.Manager\n\n\t// For HTTP proxies, forwarding HTTP requests\n\tHTTPReverseProxy *vhost.HTTPReverseProxy\n\n\t// For HTTPS proxies, route requests to different clients by hostname and other information\n\tVhostHTTPSMuxer *vhost.HTTPSMuxer\n\n\t// Controller for nat hole connections\n\tNatHoleController *nathole.Controller\n\n\t// TCPMux HTTP CONNECT multiplexer\n\tTCPMuxHTTPConnectMuxer *tcpmux.HTTPConnectTCPMuxer\n\n\t// All server manager plugin\n\tPluginManager *plugin.Manager\n}\n\nfunc (rc *ResourceController) Close() error {\n\tif rc.VhostHTTPSMuxer != nil {\n\t\trc.VhostHTTPSMuxer.Close()\n\t}\n\tif rc.TCPMuxHTTPConnectMuxer != nil {\n\t\trc.TCPMuxHTTPConnectMuxer.Close()\n\t}\n\treturn nil\n}\n"
  },
  {
    "path": "server/group/base.go",
    "content": "package group\n\nimport (\n\t\"net\"\n\t\"sync\"\n\n\tgerr \"github.com/fatedier/golib/errors\"\n)\n\n// baseGroup contains the shared plumbing for listener-based groups\n// (TCP, HTTPS, TCPMux). Each concrete group embeds this and provides\n// its own Listen method with protocol-specific validation.\ntype baseGroup struct {\n\tgroup    string\n\tgroupKey string\n\n\tacceptCh  chan net.Conn\n\trealLn    net.Listener\n\tlns       []*Listener\n\tmu        sync.Mutex\n\tcleanupFn func()\n}\n\n// initBase resets the baseGroup for a fresh listen cycle.\n// Must be called under mu when len(lns) == 0.\nfunc (bg *baseGroup) initBase(group, groupKey string, realLn net.Listener, cleanupFn func()) {\n\tbg.group = group\n\tbg.groupKey = groupKey\n\tbg.realLn = realLn\n\tbg.acceptCh = make(chan net.Conn)\n\tbg.cleanupFn = cleanupFn\n}\n\n// worker reads from the real listener and fans out to acceptCh.\n// The parameters are captured at creation time so that the worker is\n// bound to a specific listen cycle and cannot observe a later initBase.\nfunc (bg *baseGroup) worker(realLn net.Listener, acceptCh chan<- net.Conn) {\n\tfor {\n\t\tc, err := realLn.Accept()\n\t\tif err != nil {\n\t\t\treturn\n\t\t}\n\t\terr = gerr.PanicToError(func() {\n\t\t\tacceptCh <- c\n\t\t})\n\t\tif err != nil {\n\t\t\tc.Close()\n\t\t\treturn\n\t\t}\n\t}\n}\n\n// newListener creates a new Listener wired to this baseGroup.\n// Must be called under mu.\nfunc (bg *baseGroup) newListener(addr net.Addr) *Listener {\n\tln := newListener(bg.acceptCh, addr, bg.closeListener)\n\tbg.lns = append(bg.lns, ln)\n\treturn ln\n}\n\n// closeListener removes ln from the list. When the last listener is removed,\n// it closes acceptCh, closes the real listener, and calls cleanupFn.\nfunc (bg *baseGroup) closeListener(ln *Listener) {\n\tbg.mu.Lock()\n\tdefer bg.mu.Unlock()\n\tfor i, l := range bg.lns {\n\t\tif l == ln {\n\t\t\tbg.lns = append(bg.lns[:i], bg.lns[i+1:]...)\n\t\t\tbreak\n\t\t}\n\t}\n\tif len(bg.lns) == 0 {\n\t\tclose(bg.acceptCh)\n\t\tbg.realLn.Close()\n\t\tbg.cleanupFn()\n\t}\n}\n"
  },
  {
    "path": "server/group/base_test.go",
    "content": "package group\n\nimport (\n\t\"net\"\n\t\"sync\"\n\t\"testing\"\n\t\"time\"\n\n\t\"github.com/stretchr/testify/assert\"\n\t\"github.com/stretchr/testify/require\"\n)\n\n// fakeLn is a controllable net.Listener for tests.\ntype fakeLn struct {\n\tconnCh chan net.Conn\n\tclosed chan struct{}\n\tonce   sync.Once\n}\n\nfunc newFakeLn() *fakeLn {\n\treturn &fakeLn{\n\t\tconnCh: make(chan net.Conn, 8),\n\t\tclosed: make(chan struct{}),\n\t}\n}\n\nfunc (f *fakeLn) Accept() (net.Conn, error) {\n\tselect {\n\tcase c := <-f.connCh:\n\t\treturn c, nil\n\tcase <-f.closed:\n\t\treturn nil, net.ErrClosed\n\t}\n}\n\nfunc (f *fakeLn) Close() error {\n\tf.once.Do(func() { close(f.closed) })\n\treturn nil\n}\n\nfunc (f *fakeLn) Addr() net.Addr { return fakeAddr(\"127.0.0.1:9999\") }\n\nfunc (f *fakeLn) inject(c net.Conn) {\n\tselect {\n\tcase f.connCh <- c:\n\tcase <-f.closed:\n\t}\n}\n\nfunc TestBaseGroup_WorkerFanOut(t *testing.T) {\n\tfl := newFakeLn()\n\tvar bg baseGroup\n\tbg.initBase(\"g\", \"key\", fl, func() {})\n\n\tgo bg.worker(fl, bg.acceptCh)\n\n\tc1, c2 := net.Pipe()\n\tdefer c2.Close()\n\tfl.inject(c1)\n\n\tselect {\n\tcase got := <-bg.acceptCh:\n\t\tassert.Equal(t, c1, got)\n\t\tgot.Close()\n\tcase <-time.After(time.Second):\n\t\tt.Fatal(\"timed out waiting for connection on acceptCh\")\n\t}\n\n\tfl.Close()\n}\n\nfunc TestBaseGroup_WorkerStopsOnListenerClose(t *testing.T) {\n\tfl := newFakeLn()\n\tvar bg baseGroup\n\tbg.initBase(\"g\", \"key\", fl, func() {})\n\n\tdone := make(chan struct{})\n\tgo func() {\n\t\tbg.worker(fl, bg.acceptCh)\n\t\tclose(done)\n\t}()\n\n\tfl.Close()\n\tselect {\n\tcase <-done:\n\tcase <-time.After(time.Second):\n\t\tt.Fatal(\"worker did not stop after listener close\")\n\t}\n}\n\nfunc TestBaseGroup_WorkerClosesConnOnClosedChannel(t *testing.T) {\n\tfl := newFakeLn()\n\tvar bg baseGroup\n\tbg.initBase(\"g\", \"key\", fl, func() {})\n\n\t// Close acceptCh before worker sends.\n\tclose(bg.acceptCh)\n\n\tdone := make(chan struct{})\n\tgo func() {\n\t\tbg.worker(fl, bg.acceptCh)\n\t\tclose(done)\n\t}()\n\n\tc1, c2 := net.Pipe()\n\tdefer c2.Close()\n\tfl.inject(c1)\n\n\tselect {\n\tcase <-done:\n\tcase <-time.After(time.Second):\n\t\tt.Fatal(\"worker did not stop after panic recovery\")\n\t}\n\n\t// c1 should have been closed by worker's panic recovery path.\n\tbuf := make([]byte, 1)\n\t_, err := c1.Read(buf)\n\tassert.Error(t, err, \"connection should be closed by worker\")\n}\n\nfunc TestBaseGroup_CloseLastListenerTriggersCleanup(t *testing.T) {\n\tfl := newFakeLn()\n\tvar bg baseGroup\n\tcleanupCalled := 0\n\tbg.initBase(\"g\", \"key\", fl, func() { cleanupCalled++ })\n\n\tbg.mu.Lock()\n\tln1 := bg.newListener(fl.Addr())\n\tln2 := bg.newListener(fl.Addr())\n\tbg.mu.Unlock()\n\n\tgo bg.worker(fl, bg.acceptCh)\n\n\tln1.Close()\n\tassert.Equal(t, 0, cleanupCalled, \"cleanup should not run while listeners remain\")\n\n\tln2.Close()\n\tassert.Equal(t, 1, cleanupCalled, \"cleanup should run after last listener closed\")\n}\n\nfunc TestBaseGroup_CloseOneOfTwoListeners(t *testing.T) {\n\tfl := newFakeLn()\n\tvar bg baseGroup\n\tcleanupCalled := 0\n\tbg.initBase(\"g\", \"key\", fl, func() { cleanupCalled++ })\n\n\tbg.mu.Lock()\n\tln1 := bg.newListener(fl.Addr())\n\tln2 := bg.newListener(fl.Addr())\n\tbg.mu.Unlock()\n\n\tgo bg.worker(fl, bg.acceptCh)\n\n\tln1.Close()\n\tassert.Equal(t, 0, cleanupCalled)\n\n\t// ln2 should still receive connections.\n\tc1, c2 := net.Pipe()\n\tdefer c2.Close()\n\tfl.inject(c1)\n\n\tgot, err := ln2.Accept()\n\trequire.NoError(t, err)\n\tassert.Equal(t, c1, got)\n\tgot.Close()\n\n\tln2.Close()\n\tassert.Equal(t, 1, cleanupCalled)\n}\n"
  },
  {
    "path": "server/group/group.go",
    "content": "// Copyright 2018 fatedier, fatedier@gmail.com\n//\n// Licensed under the Apache License, Version 2.0 (the \"License\");\n// you may not use this file except in compliance with the License.\n// You may obtain a copy of the License at\n//\n//     http://www.apache.org/licenses/LICENSE-2.0\n//\n// Unless required by applicable law or agreed to in writing, software\n// distributed under the License is distributed on an \"AS IS\" BASIS,\n// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.\n// See the License for the specific language governing permissions and\n// limitations under the License.\n\npackage group\n\nimport (\n\t\"errors\"\n)\n\nvar (\n\tErrGroupAuthFailed    = errors.New(\"group auth failed\")\n\tErrGroupParamsInvalid = errors.New(\"group params invalid\")\n\tErrListenerClosed     = errors.New(\"group listener closed\")\n\tErrGroupDifferentPort = errors.New(\"group should have same remote port\")\n\tErrProxyRepeated      = errors.New(\"group proxy repeated\")\n\n\terrGroupStale = errors.New(\"stale group reference\")\n)\n"
  },
  {
    "path": "server/group/http.go",
    "content": "package group\n\nimport (\n\t\"fmt\"\n\t\"net\"\n\t\"sync\"\n\t\"sync/atomic\"\n\n\t\"github.com/fatedier/frp/pkg/util/vhost\"\n)\n\n// HTTPGroupController manages HTTP groups that use round-robin\n// callback routing (fundamentally different from listener-based groups).\ntype HTTPGroupController struct {\n\tgroupRegistry[*HTTPGroup]\n\tvhostRouter *vhost.Routers\n}\n\nfunc NewHTTPGroupController(vhostRouter *vhost.Routers) *HTTPGroupController {\n\treturn &HTTPGroupController{\n\t\tgroupRegistry: newGroupRegistry[*HTTPGroup](),\n\t\tvhostRouter:   vhostRouter,\n\t}\n}\n\nfunc (ctl *HTTPGroupController) Register(\n\tproxyName, group, groupKey string,\n\trouteConfig vhost.RouteConfig,\n) error {\n\tfor {\n\t\tg := ctl.getOrCreate(group, func() *HTTPGroup {\n\t\t\treturn NewHTTPGroup(ctl)\n\t\t})\n\t\terr := g.Register(proxyName, group, groupKey, routeConfig)\n\t\tif err == errGroupStale {\n\t\t\tcontinue\n\t\t}\n\t\treturn err\n\t}\n}\n\nfunc (ctl *HTTPGroupController) UnRegister(proxyName, group string, _ vhost.RouteConfig) {\n\tg, ok := ctl.get(group)\n\tif !ok {\n\t\treturn\n\t}\n\tg.UnRegister(proxyName)\n}\n\ntype HTTPGroup struct {\n\tgroup           string\n\tgroupKey        string\n\tdomain          string\n\tlocation        string\n\trouteByHTTPUser string\n\n\t// CreateConnFuncs indexed by proxy name\n\tcreateFuncs map[string]vhost.CreateConnFunc\n\tpxyNames    []string\n\tindex       uint64\n\tctl         *HTTPGroupController\n\tmu          sync.RWMutex\n}\n\nfunc NewHTTPGroup(ctl *HTTPGroupController) *HTTPGroup {\n\treturn &HTTPGroup{\n\t\tcreateFuncs: make(map[string]vhost.CreateConnFunc),\n\t\tpxyNames:    make([]string, 0),\n\t\tctl:         ctl,\n\t}\n}\n\nfunc (g *HTTPGroup) Register(\n\tproxyName, group, groupKey string,\n\trouteConfig vhost.RouteConfig,\n) (err error) {\n\tg.mu.Lock()\n\tdefer g.mu.Unlock()\n\tif !g.ctl.isCurrent(group, func(cur *HTTPGroup) bool { return cur == g }) {\n\t\treturn errGroupStale\n\t}\n\tif len(g.createFuncs) == 0 {\n\t\t// the first proxy in this group\n\t\ttmp := routeConfig // copy object\n\t\ttmp.CreateConnFn = g.createConn\n\t\ttmp.ChooseEndpointFn = g.chooseEndpoint\n\t\ttmp.CreateConnByEndpointFn = g.createConnByEndpoint\n\t\terr = g.ctl.vhostRouter.Add(routeConfig.Domain, routeConfig.Location, routeConfig.RouteByHTTPUser, &tmp)\n\t\tif err != nil {\n\t\t\treturn\n\t\t}\n\n\t\tg.group = group\n\t\tg.groupKey = groupKey\n\t\tg.domain = routeConfig.Domain\n\t\tg.location = routeConfig.Location\n\t\tg.routeByHTTPUser = routeConfig.RouteByHTTPUser\n\t} else {\n\t\tif g.group != group || g.domain != routeConfig.Domain ||\n\t\t\tg.location != routeConfig.Location || g.routeByHTTPUser != routeConfig.RouteByHTTPUser {\n\t\t\terr = ErrGroupParamsInvalid\n\t\t\treturn\n\t\t}\n\t\tif g.groupKey != groupKey {\n\t\t\terr = ErrGroupAuthFailed\n\t\t\treturn\n\t\t}\n\t}\n\tif _, ok := g.createFuncs[proxyName]; ok {\n\t\terr = ErrProxyRepeated\n\t\treturn\n\t}\n\tg.createFuncs[proxyName] = routeConfig.CreateConnFn\n\tg.pxyNames = append(g.pxyNames, proxyName)\n\treturn nil\n}\n\nfunc (g *HTTPGroup) UnRegister(proxyName string) {\n\tg.mu.Lock()\n\tdefer g.mu.Unlock()\n\tdelete(g.createFuncs, proxyName)\n\tfor i, name := range g.pxyNames {\n\t\tif name == proxyName {\n\t\t\tg.pxyNames = append(g.pxyNames[:i], g.pxyNames[i+1:]...)\n\t\t\tbreak\n\t\t}\n\t}\n\n\tif len(g.createFuncs) == 0 {\n\t\tg.ctl.vhostRouter.Del(g.domain, g.location, g.routeByHTTPUser)\n\t\tg.ctl.removeIf(g.group, func(cur *HTTPGroup) bool {\n\t\t\treturn cur == g\n\t\t})\n\t}\n}\n\nfunc (g *HTTPGroup) createConn(remoteAddr string) (net.Conn, error) {\n\tvar f vhost.CreateConnFunc\n\tnewIndex := atomic.AddUint64(&g.index, 1)\n\n\tg.mu.RLock()\n\tgroup := g.group\n\tdomain := g.domain\n\tlocation := g.location\n\trouteByHTTPUser := g.routeByHTTPUser\n\tif len(g.pxyNames) > 0 {\n\t\tname := g.pxyNames[newIndex%uint64(len(g.pxyNames))]\n\t\tf = g.createFuncs[name]\n\t}\n\tg.mu.RUnlock()\n\n\tif f == nil {\n\t\treturn nil, fmt.Errorf(\"no CreateConnFunc for http group [%s], domain [%s], location [%s], routeByHTTPUser [%s]\",\n\t\t\tgroup, domain, location, routeByHTTPUser)\n\t}\n\n\treturn f(remoteAddr)\n}\n\nfunc (g *HTTPGroup) chooseEndpoint() (string, error) {\n\tnewIndex := atomic.AddUint64(&g.index, 1)\n\tname := \"\"\n\n\tg.mu.RLock()\n\tgroup := g.group\n\tdomain := g.domain\n\tlocation := g.location\n\trouteByHTTPUser := g.routeByHTTPUser\n\tif len(g.pxyNames) > 0 {\n\t\tname = g.pxyNames[newIndex%uint64(len(g.pxyNames))]\n\t}\n\tg.mu.RUnlock()\n\n\tif name == \"\" {\n\t\treturn \"\", fmt.Errorf(\"no healthy endpoint for http group [%s], domain [%s], location [%s], routeByHTTPUser [%s]\",\n\t\t\tgroup, domain, location, routeByHTTPUser)\n\t}\n\treturn name, nil\n}\n\nfunc (g *HTTPGroup) createConnByEndpoint(endpoint, remoteAddr string) (net.Conn, error) {\n\tvar f vhost.CreateConnFunc\n\tg.mu.RLock()\n\tf = g.createFuncs[endpoint]\n\tg.mu.RUnlock()\n\n\tif f == nil {\n\t\treturn nil, fmt.Errorf(\"no CreateConnFunc for endpoint [%s] in group [%s]\", endpoint, g.group)\n\t}\n\treturn f(remoteAddr)\n}\n"
  },
  {
    "path": "server/group/https.go",
    "content": "// Copyright 2025 The frp Authors\n//\n// Licensed under the Apache License, Version 2.0 (the \"License\");\n// you may not use this file except in compliance with the License.\n// You may obtain a copy of the License at\n//\n//     http://www.apache.org/licenses/LICENSE-2.0\n//\n// Unless required by applicable law or agreed to in writing, software\n// distributed under the License is distributed on an \"AS IS\" BASIS,\n// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.\n// See the License for the specific language governing permissions and\n// limitations under the License.\n\npackage group\n\nimport (\n\t\"context\"\n\t\"net\"\n\n\t\"github.com/fatedier/frp/pkg/util/vhost\"\n)\n\ntype HTTPSGroupController struct {\n\tgroupRegistry[*HTTPSGroup]\n\thttpsMuxer *vhost.HTTPSMuxer\n}\n\nfunc NewHTTPSGroupController(httpsMuxer *vhost.HTTPSMuxer) *HTTPSGroupController {\n\treturn &HTTPSGroupController{\n\t\tgroupRegistry: newGroupRegistry[*HTTPSGroup](),\n\t\thttpsMuxer:    httpsMuxer,\n\t}\n}\n\nfunc (ctl *HTTPSGroupController) Listen(\n\tctx context.Context,\n\tgroup, groupKey string,\n\trouteConfig vhost.RouteConfig,\n) (l net.Listener, err error) {\n\tfor {\n\t\tg := ctl.getOrCreate(group, func() *HTTPSGroup {\n\t\t\treturn NewHTTPSGroup(ctl)\n\t\t})\n\t\tl, err = g.Listen(ctx, group, groupKey, routeConfig)\n\t\tif err == errGroupStale {\n\t\t\tcontinue\n\t\t}\n\t\treturn\n\t}\n}\n\ntype HTTPSGroup struct {\n\tbaseGroup\n\n\tdomain string\n\tctl    *HTTPSGroupController\n}\n\nfunc NewHTTPSGroup(ctl *HTTPSGroupController) *HTTPSGroup {\n\treturn &HTTPSGroup{\n\t\tctl: ctl,\n\t}\n}\n\nfunc (g *HTTPSGroup) Listen(\n\tctx context.Context,\n\tgroup, groupKey string,\n\trouteConfig vhost.RouteConfig,\n) (ln *Listener, err error) {\n\tg.mu.Lock()\n\tdefer g.mu.Unlock()\n\tif !g.ctl.isCurrent(group, func(cur *HTTPSGroup) bool { return cur == g }) {\n\t\treturn nil, errGroupStale\n\t}\n\tif len(g.lns) == 0 {\n\t\t// the first listener, listen on the real address\n\t\thttpsLn, errRet := g.ctl.httpsMuxer.Listen(ctx, &routeConfig)\n\t\tif errRet != nil {\n\t\t\treturn nil, errRet\n\t\t}\n\n\t\tg.domain = routeConfig.Domain\n\t\tg.initBase(group, groupKey, httpsLn, func() {\n\t\t\tg.ctl.removeIf(g.group, func(cur *HTTPSGroup) bool {\n\t\t\t\treturn cur == g\n\t\t\t})\n\t\t})\n\t\tln = g.newListener(httpsLn.Addr())\n\t\tgo g.worker(httpsLn, g.acceptCh)\n\t} else {\n\t\t// route config in the same group must be equal\n\t\tif g.group != group || g.domain != routeConfig.Domain {\n\t\t\treturn nil, ErrGroupParamsInvalid\n\t\t}\n\t\tif g.groupKey != groupKey {\n\t\t\treturn nil, ErrGroupAuthFailed\n\t\t}\n\t\tln = g.newListener(g.lns[0].Addr())\n\t}\n\treturn\n}\n"
  },
  {
    "path": "server/group/listener.go",
    "content": "package group\n\nimport (\n\t\"net\"\n\t\"sync\"\n)\n\n// Listener is a per-proxy virtual listener that receives connections\n// from a shared group. It implements net.Listener.\ntype Listener struct {\n\tacceptCh <-chan net.Conn\n\taddr     net.Addr\n\tcloseCh  chan struct{}\n\tonClose  func(*Listener)\n\tonce     sync.Once\n}\n\nfunc newListener(acceptCh <-chan net.Conn, addr net.Addr, onClose func(*Listener)) *Listener {\n\treturn &Listener{\n\t\tacceptCh: acceptCh,\n\t\taddr:     addr,\n\t\tcloseCh:  make(chan struct{}),\n\t\tonClose:  onClose,\n\t}\n}\n\nfunc (ln *Listener) Accept() (net.Conn, error) {\n\tselect {\n\tcase <-ln.closeCh:\n\t\treturn nil, ErrListenerClosed\n\tcase c, ok := <-ln.acceptCh:\n\t\tif !ok {\n\t\t\treturn nil, ErrListenerClosed\n\t\t}\n\t\treturn c, nil\n\t}\n}\n\nfunc (ln *Listener) Addr() net.Addr {\n\treturn ln.addr\n}\n\nfunc (ln *Listener) Close() error {\n\tln.once.Do(func() {\n\t\tclose(ln.closeCh)\n\t\tln.onClose(ln)\n\t})\n\treturn nil\n}\n"
  },
  {
    "path": "server/group/listener_test.go",
    "content": "package group\n\nimport (\n\t\"net\"\n\t\"testing\"\n\n\t\"github.com/stretchr/testify/assert\"\n\t\"github.com/stretchr/testify/require\"\n)\n\nfunc TestListener_Accept(t *testing.T) {\n\tacceptCh := make(chan net.Conn, 1)\n\tln := newListener(acceptCh, fakeAddr(\"127.0.0.1:1234\"), func(*Listener) {})\n\n\tc1, c2 := net.Pipe()\n\tdefer c1.Close()\n\tdefer c2.Close()\n\n\tacceptCh <- c1\n\tgot, err := ln.Accept()\n\trequire.NoError(t, err)\n\tassert.Equal(t, c1, got)\n}\n\nfunc TestListener_AcceptAfterChannelClose(t *testing.T) {\n\tacceptCh := make(chan net.Conn)\n\tln := newListener(acceptCh, fakeAddr(\"127.0.0.1:1234\"), func(*Listener) {})\n\n\tclose(acceptCh)\n\t_, err := ln.Accept()\n\tassert.ErrorIs(t, err, ErrListenerClosed)\n}\n\nfunc TestListener_AcceptAfterListenerClose(t *testing.T) {\n\tacceptCh := make(chan net.Conn) // open, not closed\n\tln := newListener(acceptCh, fakeAddr(\"127.0.0.1:1234\"), func(*Listener) {})\n\n\tln.Close()\n\t_, err := ln.Accept()\n\tassert.ErrorIs(t, err, ErrListenerClosed)\n}\n\nfunc TestListener_DoubleClose(t *testing.T) {\n\tcloseCalls := 0\n\tln := newListener(\n\t\tmake(chan net.Conn),\n\t\tfakeAddr(\"127.0.0.1:1234\"),\n\t\tfunc(*Listener) { closeCalls++ },\n\t)\n\n\tassert.NotPanics(t, func() {\n\t\tln.Close()\n\t\tln.Close()\n\t})\n\tassert.Equal(t, 1, closeCalls, \"onClose should be called exactly once\")\n}\n\nfunc TestListener_Addr(t *testing.T) {\n\taddr := fakeAddr(\"10.0.0.1:5555\")\n\tln := newListener(make(chan net.Conn), addr, func(*Listener) {})\n\tassert.Equal(t, addr, ln.Addr())\n}\n\n// fakeAddr implements net.Addr for testing.\ntype fakeAddr string\n\nfunc (a fakeAddr) Network() string { return \"tcp\" }\nfunc (a fakeAddr) String() string  { return string(a) }\n"
  },
  {
    "path": "server/group/registry.go",
    "content": "package group\n\nimport (\n\t\"sync\"\n)\n\n// groupRegistry is a concurrent map of named groups with\n// automatic creation on first access.\ntype groupRegistry[G any] struct {\n\tgroups map[string]G\n\tmu     sync.Mutex\n}\n\nfunc newGroupRegistry[G any]() groupRegistry[G] {\n\treturn groupRegistry[G]{\n\t\tgroups: make(map[string]G),\n\t}\n}\n\nfunc (r *groupRegistry[G]) getOrCreate(key string, newFn func() G) G {\n\tr.mu.Lock()\n\tdefer r.mu.Unlock()\n\tg, ok := r.groups[key]\n\tif !ok {\n\t\tg = newFn()\n\t\tr.groups[key] = g\n\t}\n\treturn g\n}\n\nfunc (r *groupRegistry[G]) get(key string) (G, bool) {\n\tr.mu.Lock()\n\tdefer r.mu.Unlock()\n\tg, ok := r.groups[key]\n\treturn g, ok\n}\n\n// isCurrent returns true if key exists in the registry and matchFn\n// returns true for the stored value.\nfunc (r *groupRegistry[G]) isCurrent(key string, matchFn func(G) bool) bool {\n\tr.mu.Lock()\n\tdefer r.mu.Unlock()\n\tg, ok := r.groups[key]\n\treturn ok && matchFn(g)\n}\n\n// removeIf atomically looks up the group for key, calls fn on it,\n// and removes the entry if fn returns true.\nfunc (r *groupRegistry[G]) removeIf(key string, fn func(G) bool) {\n\tr.mu.Lock()\n\tdefer r.mu.Unlock()\n\tg, ok := r.groups[key]\n\tif !ok {\n\t\treturn\n\t}\n\tif fn(g) {\n\t\tdelete(r.groups, key)\n\t}\n}\n"
  },
  {
    "path": "server/group/registry_test.go",
    "content": "package group\n\nimport (\n\t\"sync\"\n\t\"testing\"\n\n\t\"github.com/stretchr/testify/assert\"\n\t\"github.com/stretchr/testify/require\"\n)\n\nfunc TestGetOrCreate_New(t *testing.T) {\n\tr := newGroupRegistry[*int]()\n\tcalled := 0\n\tv := 42\n\tgot := r.getOrCreate(\"k\", func() *int { called++; return &v })\n\tassert.Equal(t, 1, called)\n\tassert.Equal(t, &v, got)\n}\n\nfunc TestGetOrCreate_Existing(t *testing.T) {\n\tr := newGroupRegistry[*int]()\n\tv := 42\n\tr.getOrCreate(\"k\", func() *int { return &v })\n\n\tcalled := 0\n\tgot := r.getOrCreate(\"k\", func() *int { called++; return nil })\n\tassert.Equal(t, 0, called)\n\tassert.Equal(t, &v, got)\n}\n\nfunc TestGet_ExistingAndMissing(t *testing.T) {\n\tr := newGroupRegistry[*int]()\n\tv := 1\n\tr.getOrCreate(\"k\", func() *int { return &v })\n\n\tgot, ok := r.get(\"k\")\n\tassert.True(t, ok)\n\tassert.Equal(t, &v, got)\n\n\t_, ok = r.get(\"missing\")\n\tassert.False(t, ok)\n}\n\nfunc TestIsCurrent(t *testing.T) {\n\tr := newGroupRegistry[*int]()\n\tv1 := 1\n\tv2 := 2\n\tr.getOrCreate(\"k\", func() *int { return &v1 })\n\n\tassert.True(t, r.isCurrent(\"k\", func(g *int) bool { return g == &v1 }))\n\tassert.False(t, r.isCurrent(\"k\", func(g *int) bool { return g == &v2 }))\n\tassert.False(t, r.isCurrent(\"missing\", func(g *int) bool { return true }))\n}\n\nfunc TestRemoveIf(t *testing.T) {\n\tt.Run(\"removes when fn returns true\", func(t *testing.T) {\n\t\tr := newGroupRegistry[*int]()\n\t\tv := 1\n\t\tr.getOrCreate(\"k\", func() *int { return &v })\n\t\tr.removeIf(\"k\", func(g *int) bool { return g == &v })\n\t\t_, ok := r.get(\"k\")\n\t\tassert.False(t, ok)\n\t})\n\n\tt.Run(\"keeps when fn returns false\", func(t *testing.T) {\n\t\tr := newGroupRegistry[*int]()\n\t\tv := 1\n\t\tr.getOrCreate(\"k\", func() *int { return &v })\n\t\tr.removeIf(\"k\", func(g *int) bool { return false })\n\t\t_, ok := r.get(\"k\")\n\t\tassert.True(t, ok)\n\t})\n\n\tt.Run(\"noop on missing key\", func(t *testing.T) {\n\t\tr := newGroupRegistry[*int]()\n\t\tr.removeIf(\"missing\", func(g *int) bool { return true }) // should not panic\n\t})\n}\n\nfunc TestConcurrentGetOrCreateAndRemoveIf(t *testing.T) {\n\tr := newGroupRegistry[*int]()\n\tconst n = 100\n\tvar wg sync.WaitGroup\n\twg.Add(n * 2)\n\tfor i := range n {\n\t\tv := i\n\t\tgo func() {\n\t\t\tdefer wg.Done()\n\t\t\tr.getOrCreate(\"k\", func() *int { return &v })\n\t\t}()\n\t\tgo func() {\n\t\t\tdefer wg.Done()\n\t\t\tr.removeIf(\"k\", func(*int) bool { return true })\n\t\t}()\n\t}\n\twg.Wait()\n\n\t// After all goroutines finish, accessing the key must not panic.\n\trequire.NotPanics(t, func() {\n\t\t_, _ = r.get(\"k\")\n\t})\n}\n"
  },
  {
    "path": "server/group/tcp.go",
    "content": "// Copyright 2018 fatedier, fatedier@gmail.com\n//\n// Licensed under the Apache License, Version 2.0 (the \"License\");\n// you may not use this file except in compliance with the License.\n// You may obtain a copy of the License at\n//\n//     http://www.apache.org/licenses/LICENSE-2.0\n//\n// Unless required by applicable law or agreed to in writing, software\n// distributed under the License is distributed on an \"AS IS\" BASIS,\n// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.\n// See the License for the specific language governing permissions and\n// limitations under the License.\n\npackage group\n\nimport (\n\t\"net\"\n\t\"strconv\"\n\n\t\"github.com/fatedier/frp/server/ports\"\n)\n\n// TCPGroupCtl manages all TCPGroups.\ntype TCPGroupCtl struct {\n\tgroupRegistry[*TCPGroup]\n\tportManager *ports.Manager\n}\n\n// NewTCPGroupCtl returns a new TCPGroupCtl.\nfunc NewTCPGroupCtl(portManager *ports.Manager) *TCPGroupCtl {\n\treturn &TCPGroupCtl{\n\t\tgroupRegistry: newGroupRegistry[*TCPGroup](),\n\t\tportManager:   portManager,\n\t}\n}\n\n// Listen is the wrapper for TCPGroup's Listen.\n// If there is no group, one will be created.\nfunc (tgc *TCPGroupCtl) Listen(proxyName string, group string, groupKey string,\n\taddr string, port int,\n) (l net.Listener, realPort int, err error) {\n\tfor {\n\t\ttcpGroup := tgc.getOrCreate(group, func() *TCPGroup {\n\t\t\treturn NewTCPGroup(tgc)\n\t\t})\n\t\tl, realPort, err = tcpGroup.Listen(proxyName, group, groupKey, addr, port)\n\t\tif err == errGroupStale {\n\t\t\tcontinue\n\t\t}\n\t\treturn\n\t}\n}\n\n// TCPGroup routes connections to different proxies.\ntype TCPGroup struct {\n\tbaseGroup\n\n\taddr     string\n\tport     int\n\trealPort int\n\tctl      *TCPGroupCtl\n}\n\n// NewTCPGroup returns a new TCPGroup.\nfunc NewTCPGroup(ctl *TCPGroupCtl) *TCPGroup {\n\treturn &TCPGroup{\n\t\tctl: ctl,\n\t}\n}\n\n// Listen will return a new Listener.\n// If TCPGroup already has a listener, just add a new Listener to the queues,\n// otherwise listen on the real address.\nfunc (tg *TCPGroup) Listen(proxyName string, group string, groupKey string, addr string, port int) (ln *Listener, realPort int, err error) {\n\ttg.mu.Lock()\n\tdefer tg.mu.Unlock()\n\tif !tg.ctl.isCurrent(group, func(cur *TCPGroup) bool { return cur == tg }) {\n\t\treturn nil, 0, errGroupStale\n\t}\n\tif len(tg.lns) == 0 {\n\t\t// the first listener, listen on the real address\n\t\trealPort, err = tg.ctl.portManager.Acquire(proxyName, port)\n\t\tif err != nil {\n\t\t\treturn\n\t\t}\n\t\ttcpLn, errRet := net.Listen(\"tcp\", net.JoinHostPort(addr, strconv.Itoa(realPort)))\n\t\tif errRet != nil {\n\t\t\ttg.ctl.portManager.Release(realPort)\n\t\t\terr = errRet\n\t\t\treturn\n\t\t}\n\n\t\ttg.addr = addr\n\t\ttg.port = port\n\t\ttg.realPort = realPort\n\t\ttg.initBase(group, groupKey, tcpLn, func() {\n\t\t\ttg.ctl.portManager.Release(tg.realPort)\n\t\t\ttg.ctl.removeIf(tg.group, func(cur *TCPGroup) bool {\n\t\t\t\treturn cur == tg\n\t\t\t})\n\t\t})\n\t\tln = tg.newListener(tcpLn.Addr())\n\t\tgo tg.worker(tcpLn, tg.acceptCh)\n\t} else {\n\t\t// address and port in the same group must be equal\n\t\tif tg.group != group || tg.addr != addr {\n\t\t\terr = ErrGroupParamsInvalid\n\t\t\treturn\n\t\t}\n\t\tif tg.port != port {\n\t\t\terr = ErrGroupDifferentPort\n\t\t\treturn\n\t\t}\n\t\tif tg.groupKey != groupKey {\n\t\t\terr = ErrGroupAuthFailed\n\t\t\treturn\n\t\t}\n\t\tln = tg.newListener(tg.lns[0].Addr())\n\t\trealPort = tg.realPort\n\t}\n\treturn\n}\n"
  },
  {
    "path": "server/group/tcpmux.go",
    "content": "// Copyright 2020 guylewin, guy@lewin.co.il\n//\n// Licensed under the Apache License, Version 2.0 (the \"License\");\n// you may not use this file except in compliance with the License.\n// You may obtain a copy of the License at\n//\n//     http://www.apache.org/licenses/LICENSE-2.0\n//\n// Unless required by applicable law or agreed to in writing, software\n// distributed under the License is distributed on an \"AS IS\" BASIS,\n// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.\n// See the License for the specific language governing permissions and\n// limitations under the License.\n\npackage group\n\nimport (\n\t\"context\"\n\t\"fmt\"\n\t\"net\"\n\n\tv1 \"github.com/fatedier/frp/pkg/config/v1\"\n\t\"github.com/fatedier/frp/pkg/util/tcpmux\"\n\t\"github.com/fatedier/frp/pkg/util/vhost\"\n)\n\n// TCPMuxGroupCtl manages all TCPMuxGroups.\ntype TCPMuxGroupCtl struct {\n\tgroupRegistry[*TCPMuxGroup]\n\ttcpMuxHTTPConnectMuxer *tcpmux.HTTPConnectTCPMuxer\n}\n\n// NewTCPMuxGroupCtl returns a new TCPMuxGroupCtl.\nfunc NewTCPMuxGroupCtl(tcpMuxHTTPConnectMuxer *tcpmux.HTTPConnectTCPMuxer) *TCPMuxGroupCtl {\n\treturn &TCPMuxGroupCtl{\n\t\tgroupRegistry:          newGroupRegistry[*TCPMuxGroup](),\n\t\ttcpMuxHTTPConnectMuxer: tcpMuxHTTPConnectMuxer,\n\t}\n}\n\n// Listen is the wrapper for TCPMuxGroup's Listen.\n// If there is no group, one will be created.\nfunc (tmgc *TCPMuxGroupCtl) Listen(\n\tctx context.Context,\n\tmultiplexer, group, groupKey string,\n\trouteConfig vhost.RouteConfig,\n) (l net.Listener, err error) {\n\tfor {\n\t\ttcpMuxGroup := tmgc.getOrCreate(group, func() *TCPMuxGroup {\n\t\t\treturn NewTCPMuxGroup(tmgc)\n\t\t})\n\n\t\tswitch v1.TCPMultiplexerType(multiplexer) {\n\t\tcase v1.TCPMultiplexerHTTPConnect:\n\t\t\tl, err = tcpMuxGroup.HTTPConnectListen(ctx, group, groupKey, routeConfig)\n\t\t\tif err == errGroupStale {\n\t\t\t\tcontinue\n\t\t\t}\n\t\t\treturn\n\t\tdefault:\n\t\t\treturn nil, fmt.Errorf(\"unknown multiplexer [%s]\", multiplexer)\n\t\t}\n\t}\n}\n\n// TCPMuxGroup routes connections to different proxies.\ntype TCPMuxGroup struct {\n\tbaseGroup\n\n\tdomain          string\n\trouteByHTTPUser string\n\tusername        string\n\tpassword        string\n\tctl             *TCPMuxGroupCtl\n}\n\n// NewTCPMuxGroup returns a new TCPMuxGroup.\nfunc NewTCPMuxGroup(ctl *TCPMuxGroupCtl) *TCPMuxGroup {\n\treturn &TCPMuxGroup{\n\t\tctl: ctl,\n\t}\n}\n\n// HTTPConnectListen will return a new Listener.\n// If TCPMuxGroup already has a listener, just add a new Listener to the queues,\n// otherwise listen on the real address.\nfunc (tmg *TCPMuxGroup) HTTPConnectListen(\n\tctx context.Context,\n\tgroup, groupKey string,\n\trouteConfig vhost.RouteConfig,\n) (ln *Listener, err error) {\n\ttmg.mu.Lock()\n\tdefer tmg.mu.Unlock()\n\tif !tmg.ctl.isCurrent(group, func(cur *TCPMuxGroup) bool { return cur == tmg }) {\n\t\treturn nil, errGroupStale\n\t}\n\tif len(tmg.lns) == 0 {\n\t\t// the first listener, listen on the real address\n\t\ttcpMuxLn, errRet := tmg.ctl.tcpMuxHTTPConnectMuxer.Listen(ctx, &routeConfig)\n\t\tif errRet != nil {\n\t\t\treturn nil, errRet\n\t\t}\n\n\t\ttmg.domain = routeConfig.Domain\n\t\ttmg.routeByHTTPUser = routeConfig.RouteByHTTPUser\n\t\ttmg.username = routeConfig.Username\n\t\ttmg.password = routeConfig.Password\n\t\ttmg.initBase(group, groupKey, tcpMuxLn, func() {\n\t\t\ttmg.ctl.removeIf(tmg.group, func(cur *TCPMuxGroup) bool {\n\t\t\t\treturn cur == tmg\n\t\t\t})\n\t\t})\n\t\tln = tmg.newListener(tcpMuxLn.Addr())\n\t\tgo tmg.worker(tcpMuxLn, tmg.acceptCh)\n\t} else {\n\t\t// route config in the same group must be equal\n\t\tif tmg.group != group || tmg.domain != routeConfig.Domain ||\n\t\t\ttmg.routeByHTTPUser != routeConfig.RouteByHTTPUser ||\n\t\t\ttmg.username != routeConfig.Username ||\n\t\t\ttmg.password != routeConfig.Password {\n\t\t\treturn nil, ErrGroupParamsInvalid\n\t\t}\n\t\tif tmg.groupKey != groupKey {\n\t\t\treturn nil, ErrGroupAuthFailed\n\t\t}\n\t\tln = tmg.newListener(tmg.lns[0].Addr())\n\t}\n\treturn\n}\n"
  },
  {
    "path": "server/http/controller.go",
    "content": "// Copyright 2025 The frp Authors\n//\n// Licensed under the Apache License, Version 2.0 (the \"License\");\n// you may not use this file except in compliance with the License.\n// You may obtain a copy of the License at\n//\n//     http://www.apache.org/licenses/LICENSE-2.0\n//\n// Unless required by applicable law or agreed to in writing, software\n// distributed under the License is distributed on an \"AS IS\" BASIS,\n// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.\n// See the License for the specific language governing permissions and\n// limitations under the License.\n\npackage http\n\nimport (\n\t\"cmp\"\n\t\"fmt\"\n\t\"net/http\"\n\t\"slices\"\n\t\"strings\"\n\t\"time\"\n\n\t\"github.com/fatedier/frp/pkg/config/types\"\n\tv1 \"github.com/fatedier/frp/pkg/config/v1\"\n\t\"github.com/fatedier/frp/pkg/metrics/mem\"\n\thttppkg \"github.com/fatedier/frp/pkg/util/http\"\n\t\"github.com/fatedier/frp/pkg/util/log\"\n\t\"github.com/fatedier/frp/pkg/util/version\"\n\t\"github.com/fatedier/frp/server/http/model\"\n\t\"github.com/fatedier/frp/server/proxy\"\n\t\"github.com/fatedier/frp/server/registry\"\n)\n\ntype Controller struct {\n\t// dependencies\n\tserverCfg      *v1.ServerConfig\n\tclientRegistry *registry.ClientRegistry\n\tpxyManager     ProxyManager\n}\n\ntype ProxyManager interface {\n\tGetByName(name string) (proxy.Proxy, bool)\n}\n\nfunc NewController(\n\tserverCfg *v1.ServerConfig,\n\tclientRegistry *registry.ClientRegistry,\n\tpxyManager ProxyManager,\n) *Controller {\n\treturn &Controller{\n\t\tserverCfg:      serverCfg,\n\t\tclientRegistry: clientRegistry,\n\t\tpxyManager:     pxyManager,\n\t}\n}\n\n// /api/serverinfo\nfunc (c *Controller) APIServerInfo(ctx *httppkg.Context) (any, error) {\n\tserverStats := mem.StatsCollector.GetServer()\n\tsvrResp := model.ServerInfoResp{\n\t\tVersion:               version.Full(),\n\t\tBindPort:              c.serverCfg.BindPort,\n\t\tVhostHTTPPort:         c.serverCfg.VhostHTTPPort,\n\t\tVhostHTTPSPort:        c.serverCfg.VhostHTTPSPort,\n\t\tTCPMuxHTTPConnectPort: c.serverCfg.TCPMuxHTTPConnectPort,\n\t\tKCPBindPort:           c.serverCfg.KCPBindPort,\n\t\tQUICBindPort:          c.serverCfg.QUICBindPort,\n\t\tSubdomainHost:         c.serverCfg.SubDomainHost,\n\t\tMaxPoolCount:          c.serverCfg.Transport.MaxPoolCount,\n\t\tMaxPortsPerClient:     c.serverCfg.MaxPortsPerClient,\n\t\tHeartBeatTimeout:      c.serverCfg.Transport.HeartbeatTimeout,\n\t\tAllowPortsStr:         types.PortsRangeSlice(c.serverCfg.AllowPorts).String(),\n\t\tTLSForce:              c.serverCfg.Transport.TLS.Force,\n\n\t\tTotalTrafficIn:  serverStats.TotalTrafficIn,\n\t\tTotalTrafficOut: serverStats.TotalTrafficOut,\n\t\tCurConns:        serverStats.CurConns,\n\t\tClientCounts:    serverStats.ClientCounts,\n\t\tProxyTypeCounts: serverStats.ProxyTypeCounts,\n\t}\n\n\treturn svrResp, nil\n}\n\n// /api/clients\nfunc (c *Controller) APIClientList(ctx *httppkg.Context) (any, error) {\n\tif c.clientRegistry == nil {\n\t\treturn nil, fmt.Errorf(\"client registry unavailable\")\n\t}\n\n\tuserFilter := ctx.Query(\"user\")\n\tclientIDFilter := ctx.Query(\"clientId\")\n\trunIDFilter := ctx.Query(\"runId\")\n\tstatusFilter := strings.ToLower(ctx.Query(\"status\"))\n\n\trecords := c.clientRegistry.List()\n\titems := make([]model.ClientInfoResp, 0, len(records))\n\tfor _, info := range records {\n\t\tif userFilter != \"\" && info.User != userFilter {\n\t\t\tcontinue\n\t\t}\n\t\tif clientIDFilter != \"\" && info.ClientID() != clientIDFilter {\n\t\t\tcontinue\n\t\t}\n\t\tif runIDFilter != \"\" && info.RunID != runIDFilter {\n\t\t\tcontinue\n\t\t}\n\t\tif !matchStatusFilter(info.Online, statusFilter) {\n\t\t\tcontinue\n\t\t}\n\t\titems = append(items, buildClientInfoResp(info))\n\t}\n\n\tslices.SortFunc(items, func(a, b model.ClientInfoResp) int {\n\t\tif v := cmp.Compare(a.User, b.User); v != 0 {\n\t\t\treturn v\n\t\t}\n\t\tif v := cmp.Compare(a.ClientID, b.ClientID); v != 0 {\n\t\t\treturn v\n\t\t}\n\t\treturn cmp.Compare(a.Key, b.Key)\n\t})\n\n\treturn items, nil\n}\n\n// /api/clients/{key}\nfunc (c *Controller) APIClientDetail(ctx *httppkg.Context) (any, error) {\n\tkey := ctx.Param(\"key\")\n\tif key == \"\" {\n\t\treturn nil, fmt.Errorf(\"missing client key\")\n\t}\n\n\tif c.clientRegistry == nil {\n\t\treturn nil, fmt.Errorf(\"client registry unavailable\")\n\t}\n\n\tinfo, ok := c.clientRegistry.GetByKey(key)\n\tif !ok {\n\t\treturn nil, httppkg.NewError(http.StatusNotFound, fmt.Sprintf(\"client %s not found\", key))\n\t}\n\n\treturn buildClientInfoResp(info), nil\n}\n\n// /api/proxy/:type\nfunc (c *Controller) APIProxyByType(ctx *httppkg.Context) (any, error) {\n\tproxyType := ctx.Param(\"type\")\n\n\tproxyInfoResp := model.GetProxyInfoResp{}\n\tproxyInfoResp.Proxies = c.getProxyStatsByType(proxyType)\n\tslices.SortFunc(proxyInfoResp.Proxies, func(a, b *model.ProxyStatsInfo) int {\n\t\treturn cmp.Compare(a.Name, b.Name)\n\t})\n\n\treturn proxyInfoResp, nil\n}\n\n// /api/proxy/:type/:name\nfunc (c *Controller) APIProxyByTypeAndName(ctx *httppkg.Context) (any, error) {\n\tproxyType := ctx.Param(\"type\")\n\tname := ctx.Param(\"name\")\n\n\tproxyStatsResp, code, msg := c.getProxyStatsByTypeAndName(proxyType, name)\n\tif code != 200 {\n\t\treturn nil, httppkg.NewError(code, msg)\n\t}\n\n\treturn proxyStatsResp, nil\n}\n\n// /api/traffic/:name\nfunc (c *Controller) APIProxyTraffic(ctx *httppkg.Context) (any, error) {\n\tname := ctx.Param(\"name\")\n\n\ttrafficResp := model.GetProxyTrafficResp{}\n\ttrafficResp.Name = name\n\tproxyTrafficInfo := mem.StatsCollector.GetProxyTraffic(name)\n\n\tif proxyTrafficInfo == nil {\n\t\treturn nil, httppkg.NewError(http.StatusNotFound, \"no proxy info found\")\n\t}\n\ttrafficResp.TrafficIn = proxyTrafficInfo.TrafficIn\n\ttrafficResp.TrafficOut = proxyTrafficInfo.TrafficOut\n\n\treturn trafficResp, nil\n}\n\n// /api/proxies/:name\nfunc (c *Controller) APIProxyByName(ctx *httppkg.Context) (any, error) {\n\tname := ctx.Param(\"name\")\n\n\tps := mem.StatsCollector.GetProxyByName(name)\n\tif ps == nil {\n\t\treturn nil, httppkg.NewError(http.StatusNotFound, \"no proxy info found\")\n\t}\n\n\tproxyInfo := model.GetProxyStatsResp{\n\t\tName:            ps.Name,\n\t\tUser:            ps.User,\n\t\tClientID:        ps.ClientID,\n\t\tTodayTrafficIn:  ps.TodayTrafficIn,\n\t\tTodayTrafficOut: ps.TodayTrafficOut,\n\t\tCurConns:        ps.CurConns,\n\t\tLastStartTime:   ps.LastStartTime,\n\t\tLastCloseTime:   ps.LastCloseTime,\n\t}\n\n\tif pxy, ok := c.pxyManager.GetByName(name); ok {\n\t\tproxyInfo.Conf = getConfFromConfigurer(pxy.GetConfigurer())\n\t\tproxyInfo.Status = \"online\"\n\t} else {\n\t\tproxyInfo.Status = \"offline\"\n\t}\n\n\treturn proxyInfo, nil\n}\n\n// DELETE /api/proxies?status=offline\nfunc (c *Controller) DeleteProxies(ctx *httppkg.Context) (any, error) {\n\tstatus := ctx.Query(\"status\")\n\tif status != \"offline\" {\n\t\treturn nil, httppkg.NewError(http.StatusBadRequest, \"status only support offline\")\n\t}\n\tcleared, total := mem.StatsCollector.ClearOfflineProxies()\n\tlog.Infof(\"cleared [%d] offline proxies, total [%d] proxies\", cleared, total)\n\treturn httppkg.GeneralResponse{Code: 200, Msg: \"success\"}, nil\n}\n\nfunc (c *Controller) getProxyStatsByType(proxyType string) (proxyInfos []*model.ProxyStatsInfo) {\n\tproxyStats := mem.StatsCollector.GetProxiesByType(proxyType)\n\tproxyInfos = make([]*model.ProxyStatsInfo, 0, len(proxyStats))\n\tfor _, ps := range proxyStats {\n\t\tproxyInfo := &model.ProxyStatsInfo{\n\t\t\tUser:     ps.User,\n\t\t\tClientID: ps.ClientID,\n\t\t}\n\t\tif pxy, ok := c.pxyManager.GetByName(ps.Name); ok {\n\t\t\tproxyInfo.Conf = getConfFromConfigurer(pxy.GetConfigurer())\n\t\t\tproxyInfo.Status = \"online\"\n\t\t} else {\n\t\t\tproxyInfo.Status = \"offline\"\n\t\t}\n\t\tproxyInfo.Name = ps.Name\n\t\tproxyInfo.TodayTrafficIn = ps.TodayTrafficIn\n\t\tproxyInfo.TodayTrafficOut = ps.TodayTrafficOut\n\t\tproxyInfo.CurConns = ps.CurConns\n\t\tproxyInfo.LastStartTime = ps.LastStartTime\n\t\tproxyInfo.LastCloseTime = ps.LastCloseTime\n\t\tproxyInfos = append(proxyInfos, proxyInfo)\n\t}\n\treturn\n}\n\nfunc (c *Controller) getProxyStatsByTypeAndName(proxyType string, proxyName string) (proxyInfo model.GetProxyStatsResp, code int, msg string) {\n\tproxyInfo.Name = proxyName\n\tps := mem.StatsCollector.GetProxiesByTypeAndName(proxyType, proxyName)\n\tif ps == nil {\n\t\tcode = 404\n\t\tmsg = \"no proxy info found\"\n\t} else {\n\t\tproxyInfo.User = ps.User\n\t\tproxyInfo.ClientID = ps.ClientID\n\t\tif pxy, ok := c.pxyManager.GetByName(proxyName); ok {\n\t\t\tproxyInfo.Conf = getConfFromConfigurer(pxy.GetConfigurer())\n\t\t\tproxyInfo.Status = \"online\"\n\t\t} else {\n\t\t\tproxyInfo.Status = \"offline\"\n\t\t}\n\t\tproxyInfo.TodayTrafficIn = ps.TodayTrafficIn\n\t\tproxyInfo.TodayTrafficOut = ps.TodayTrafficOut\n\t\tproxyInfo.CurConns = ps.CurConns\n\t\tproxyInfo.LastStartTime = ps.LastStartTime\n\t\tproxyInfo.LastCloseTime = ps.LastCloseTime\n\t\tcode = 200\n\t}\n\n\treturn\n}\n\nfunc buildClientInfoResp(info registry.ClientInfo) model.ClientInfoResp {\n\tresp := model.ClientInfoResp{\n\t\tKey:              info.Key,\n\t\tUser:             info.User,\n\t\tClientID:         info.ClientID(),\n\t\tRunID:            info.RunID,\n\t\tVersion:          info.Version,\n\t\tHostname:         info.Hostname,\n\t\tClientIP:         info.IP,\n\t\tFirstConnectedAt: toUnix(info.FirstConnectedAt),\n\t\tLastConnectedAt:  toUnix(info.LastConnectedAt),\n\t\tOnline:           info.Online,\n\t}\n\tif !info.DisconnectedAt.IsZero() {\n\t\tresp.DisconnectedAt = info.DisconnectedAt.Unix()\n\t}\n\treturn resp\n}\n\nfunc toUnix(t time.Time) int64 {\n\tif t.IsZero() {\n\t\treturn 0\n\t}\n\treturn t.Unix()\n}\n\nfunc matchStatusFilter(online bool, filter string) bool {\n\tswitch strings.ToLower(filter) {\n\tcase \"\", \"all\":\n\t\treturn true\n\tcase \"online\":\n\t\treturn online\n\tcase \"offline\":\n\t\treturn !online\n\tdefault:\n\t\treturn true\n\t}\n}\n\nfunc getConfFromConfigurer(cfg v1.ProxyConfigurer) any {\n\toutBase := model.BaseOutConf{ProxyBaseConfig: *cfg.GetBaseConfig()}\n\n\tswitch c := cfg.(type) {\n\tcase *v1.TCPProxyConfig:\n\t\treturn &model.TCPOutConf{BaseOutConf: outBase, RemotePort: c.RemotePort}\n\tcase *v1.UDPProxyConfig:\n\t\treturn &model.UDPOutConf{BaseOutConf: outBase, RemotePort: c.RemotePort}\n\tcase *v1.HTTPProxyConfig:\n\t\treturn &model.HTTPOutConf{\n\t\t\tBaseOutConf:       outBase,\n\t\t\tDomainConfig:      c.DomainConfig,\n\t\t\tLocations:         c.Locations,\n\t\t\tHostHeaderRewrite: c.HostHeaderRewrite,\n\t\t}\n\tcase *v1.HTTPSProxyConfig:\n\t\treturn &model.HTTPSOutConf{\n\t\t\tBaseOutConf:  outBase,\n\t\t\tDomainConfig: c.DomainConfig,\n\t\t}\n\tcase *v1.TCPMuxProxyConfig:\n\t\treturn &model.TCPMuxOutConf{\n\t\t\tBaseOutConf:     outBase,\n\t\t\tDomainConfig:    c.DomainConfig,\n\t\t\tMultiplexer:     c.Multiplexer,\n\t\t\tRouteByHTTPUser: c.RouteByHTTPUser,\n\t\t}\n\tcase *v1.STCPProxyConfig:\n\t\treturn &model.STCPOutConf{BaseOutConf: outBase}\n\tcase *v1.XTCPProxyConfig:\n\t\treturn &model.XTCPOutConf{BaseOutConf: outBase}\n\t}\n\treturn outBase\n}\n"
  },
  {
    "path": "server/http/controller_test.go",
    "content": "// Copyright 2026 The frp Authors\n//\n// Licensed under the Apache License, Version 2.0 (the \"License\");\n// you may not use this file except in compliance with the License.\n// You may obtain a copy of the License at\n//\n//     http://www.apache.org/licenses/LICENSE-2.0\n//\n// Unless required by applicable law or agreed to in writing, software\n// distributed under the License is distributed on an \"AS IS\" BASIS,\n// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.\n// See the License for the specific language governing permissions and\n// limitations under the License.\n\npackage http\n\nimport (\n\t\"encoding/json\"\n\t\"testing\"\n\n\tv1 \"github.com/fatedier/frp/pkg/config/v1\"\n)\n\nfunc TestGetConfFromConfigurerKeepsPluginFields(t *testing.T) {\n\tcfg := &v1.TCPProxyConfig{\n\t\tProxyBaseConfig: v1.ProxyBaseConfig{\n\t\t\tName: \"test-proxy\",\n\t\t\tType: string(v1.ProxyTypeTCP),\n\t\t\tProxyBackend: v1.ProxyBackend{\n\t\t\t\tPlugin: v1.TypedClientPluginOptions{\n\t\t\t\t\tType: v1.PluginHTTPProxy,\n\t\t\t\t\tClientPluginOptions: &v1.HTTPProxyPluginOptions{\n\t\t\t\t\t\tType:         v1.PluginHTTPProxy,\n\t\t\t\t\t\tHTTPUser:     \"user\",\n\t\t\t\t\t\tHTTPPassword: \"password\",\n\t\t\t\t\t},\n\t\t\t\t},\n\t\t\t},\n\t\t},\n\t\tRemotePort: 6000,\n\t}\n\n\tcontent, err := json.Marshal(getConfFromConfigurer(cfg))\n\tif err != nil {\n\t\tt.Fatalf(\"marshal conf failed: %v\", err)\n\t}\n\n\tvar out map[string]any\n\tif err := json.Unmarshal(content, &out); err != nil {\n\t\tt.Fatalf(\"unmarshal conf failed: %v\", err)\n\t}\n\n\tpluginValue, ok := out[\"plugin\"]\n\tif !ok {\n\t\tt.Fatalf(\"plugin field missing in output: %v\", out)\n\t}\n\tplugin, ok := pluginValue.(map[string]any)\n\tif !ok {\n\t\tt.Fatalf(\"plugin field should be object, got: %#v\", pluginValue)\n\t}\n\n\tif got := plugin[\"type\"]; got != v1.PluginHTTPProxy {\n\t\tt.Fatalf(\"plugin type mismatch, want %q got %#v\", v1.PluginHTTPProxy, got)\n\t}\n\tif got := plugin[\"httpUser\"]; got != \"user\" {\n\t\tt.Fatalf(\"plugin httpUser mismatch, want %q got %#v\", \"user\", got)\n\t}\n\tif got := plugin[\"httpPassword\"]; got != \"password\" {\n\t\tt.Fatalf(\"plugin httpPassword mismatch, want %q got %#v\", \"password\", got)\n\t}\n}\n"
  },
  {
    "path": "server/http/model/types.go",
    "content": "// Copyright 2025 The frp Authors\n//\n// Licensed under the Apache License, Version 2.0 (the \"License\");\n// you may not use this file except in compliance with the License.\n// You may obtain a copy of the License at\n//\n//     http://www.apache.org/licenses/LICENSE-2.0\n//\n// Unless required by applicable law or agreed to in writing, software\n// distributed under the License is distributed on an \"AS IS\" BASIS,\n// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.\n// See the License for the specific language governing permissions and\n// limitations under the License.\n\npackage model\n\nimport (\n\tv1 \"github.com/fatedier/frp/pkg/config/v1\"\n)\n\ntype ServerInfoResp struct {\n\tVersion               string `json:\"version\"`\n\tBindPort              int    `json:\"bindPort\"`\n\tVhostHTTPPort         int    `json:\"vhostHTTPPort\"`\n\tVhostHTTPSPort        int    `json:\"vhostHTTPSPort\"`\n\tTCPMuxHTTPConnectPort int    `json:\"tcpmuxHTTPConnectPort\"`\n\tKCPBindPort           int    `json:\"kcpBindPort\"`\n\tQUICBindPort          int    `json:\"quicBindPort\"`\n\tSubdomainHost         string `json:\"subdomainHost\"`\n\tMaxPoolCount          int64  `json:\"maxPoolCount\"`\n\tMaxPortsPerClient     int64  `json:\"maxPortsPerClient\"`\n\tHeartBeatTimeout      int64  `json:\"heartbeatTimeout\"`\n\tAllowPortsStr         string `json:\"allowPortsStr,omitempty\"`\n\tTLSForce              bool   `json:\"tlsForce,omitempty\"`\n\n\tTotalTrafficIn  int64            `json:\"totalTrafficIn\"`\n\tTotalTrafficOut int64            `json:\"totalTrafficOut\"`\n\tCurConns        int64            `json:\"curConns\"`\n\tClientCounts    int64            `json:\"clientCounts\"`\n\tProxyTypeCounts map[string]int64 `json:\"proxyTypeCount\"`\n}\n\ntype ClientInfoResp struct {\n\tKey              string `json:\"key\"`\n\tUser             string `json:\"user\"`\n\tClientID         string `json:\"clientID\"`\n\tRunID            string `json:\"runID\"`\n\tVersion          string `json:\"version,omitempty\"`\n\tHostname         string `json:\"hostname\"`\n\tClientIP         string `json:\"clientIP,omitempty\"`\n\tFirstConnectedAt int64  `json:\"firstConnectedAt\"`\n\tLastConnectedAt  int64  `json:\"lastConnectedAt\"`\n\tDisconnectedAt   int64  `json:\"disconnectedAt,omitempty\"`\n\tOnline           bool   `json:\"online\"`\n}\n\ntype BaseOutConf struct {\n\tv1.ProxyBaseConfig\n}\n\ntype TCPOutConf struct {\n\tBaseOutConf\n\tRemotePort int `json:\"remotePort\"`\n}\n\ntype TCPMuxOutConf struct {\n\tBaseOutConf\n\tv1.DomainConfig\n\tMultiplexer     string `json:\"multiplexer\"`\n\tRouteByHTTPUser string `json:\"routeByHTTPUser\"`\n}\n\ntype UDPOutConf struct {\n\tBaseOutConf\n\tRemotePort int `json:\"remotePort\"`\n}\n\ntype HTTPOutConf struct {\n\tBaseOutConf\n\tv1.DomainConfig\n\tLocations         []string `json:\"locations\"`\n\tHostHeaderRewrite string   `json:\"hostHeaderRewrite\"`\n}\n\ntype HTTPSOutConf struct {\n\tBaseOutConf\n\tv1.DomainConfig\n}\n\ntype STCPOutConf struct {\n\tBaseOutConf\n}\n\ntype XTCPOutConf struct {\n\tBaseOutConf\n}\n\n// Get proxy info.\ntype ProxyStatsInfo struct {\n\tName            string `json:\"name\"`\n\tConf            any    `json:\"conf\"`\n\tUser            string `json:\"user,omitempty\"`\n\tClientID        string `json:\"clientID,omitempty\"`\n\tTodayTrafficIn  int64  `json:\"todayTrafficIn\"`\n\tTodayTrafficOut int64  `json:\"todayTrafficOut\"`\n\tCurConns        int64  `json:\"curConns\"`\n\tLastStartTime   string `json:\"lastStartTime\"`\n\tLastCloseTime   string `json:\"lastCloseTime\"`\n\tStatus          string `json:\"status\"`\n}\n\ntype GetProxyInfoResp struct {\n\tProxies []*ProxyStatsInfo `json:\"proxies\"`\n}\n\n// Get proxy info by name.\ntype GetProxyStatsResp struct {\n\tName            string `json:\"name\"`\n\tConf            any    `json:\"conf\"`\n\tUser            string `json:\"user,omitempty\"`\n\tClientID        string `json:\"clientID,omitempty\"`\n\tTodayTrafficIn  int64  `json:\"todayTrafficIn\"`\n\tTodayTrafficOut int64  `json:\"todayTrafficOut\"`\n\tCurConns        int64  `json:\"curConns\"`\n\tLastStartTime   string `json:\"lastStartTime\"`\n\tLastCloseTime   string `json:\"lastCloseTime\"`\n\tStatus          string `json:\"status\"`\n}\n\n// /api/traffic/:name\ntype GetProxyTrafficResp struct {\n\tName       string  `json:\"name\"`\n\tTrafficIn  []int64 `json:\"trafficIn\"`\n\tTrafficOut []int64 `json:\"trafficOut\"`\n}\n"
  },
  {
    "path": "server/metrics/metrics.go",
    "content": "package metrics\n\nimport (\n\t\"sync\"\n)\n\ntype ServerMetrics interface {\n\tNewClient()\n\tCloseClient()\n\tNewProxy(name string, proxyType string, user string, clientID string)\n\tCloseProxy(name string, proxyType string)\n\tOpenConnection(name string, proxyType string)\n\tCloseConnection(name string, proxyType string)\n\tAddTrafficIn(name string, proxyType string, trafficBytes int64)\n\tAddTrafficOut(name string, proxyType string, trafficBytes int64)\n}\n\nvar Server ServerMetrics = noopServerMetrics{}\n\nvar registerMetrics sync.Once\n\nfunc Register(m ServerMetrics) {\n\tregisterMetrics.Do(func() {\n\t\tServer = m\n\t})\n}\n\ntype noopServerMetrics struct{}\n\nfunc (noopServerMetrics) NewClient()                              {}\nfunc (noopServerMetrics) CloseClient()                            {}\nfunc (noopServerMetrics) NewProxy(string, string, string, string) {}\nfunc (noopServerMetrics) CloseProxy(string, string)               {}\nfunc (noopServerMetrics) OpenConnection(string, string)           {}\nfunc (noopServerMetrics) CloseConnection(string, string)          {}\nfunc (noopServerMetrics) AddTrafficIn(string, string, int64)      {}\nfunc (noopServerMetrics) AddTrafficOut(string, string, int64)     {}\n"
  },
  {
    "path": "server/ports/ports.go",
    "content": "package ports\n\nimport (\n\t\"errors\"\n\t\"net\"\n\t\"strconv\"\n\t\"sync\"\n\t\"time\"\n\n\t\"github.com/fatedier/frp/pkg/config/types\"\n)\n\nconst (\n\tMinPort                    = 1\n\tMaxPort                    = 65535\n\tMaxPortReservedDuration    = time.Duration(24) * time.Hour\n\tCleanReservedPortsInterval = time.Hour\n)\n\nvar (\n\tErrPortAlreadyUsed = errors.New(\"port already used\")\n\tErrPortNotAllowed  = errors.New(\"port not allowed\")\n\tErrPortUnAvailable = errors.New(\"port unavailable\")\n\tErrNoAvailablePort = errors.New(\"no available port\")\n)\n\ntype PortCtx struct {\n\tProxyName  string\n\tPort       int\n\tClosed     bool\n\tUpdateTime time.Time\n}\n\ntype Manager struct {\n\treservedPorts map[string]*PortCtx\n\tusedPorts     map[int]*PortCtx\n\tfreePorts     map[int]struct{}\n\n\tbindAddr string\n\tnetType  string\n\tmu       sync.Mutex\n}\n\nfunc NewManager(netType string, bindAddr string, allowPorts []types.PortsRange) *Manager {\n\tpm := &Manager{\n\t\treservedPorts: make(map[string]*PortCtx),\n\t\tusedPorts:     make(map[int]*PortCtx),\n\t\tfreePorts:     make(map[int]struct{}),\n\t\tbindAddr:      bindAddr,\n\t\tnetType:       netType,\n\t}\n\tif len(allowPorts) > 0 {\n\t\tfor _, pair := range allowPorts {\n\t\t\tif pair.Single > 0 {\n\t\t\t\tpm.freePorts[pair.Single] = struct{}{}\n\t\t\t} else {\n\t\t\t\tfor i := pair.Start; i <= pair.End; i++ {\n\t\t\t\t\tpm.freePorts[i] = struct{}{}\n\t\t\t\t}\n\t\t\t}\n\t\t}\n\t} else {\n\t\tfor i := MinPort; i <= MaxPort; i++ {\n\t\t\tpm.freePorts[i] = struct{}{}\n\t\t}\n\t}\n\tgo pm.cleanReservedPortsWorker()\n\treturn pm\n}\n\nfunc (pm *Manager) Acquire(name string, port int) (realPort int, err error) {\n\tportCtx := &PortCtx{\n\t\tProxyName:  name,\n\t\tClosed:     false,\n\t\tUpdateTime: time.Now(),\n\t}\n\n\tvar ok bool\n\n\tpm.mu.Lock()\n\tdefer func() {\n\t\tif err == nil {\n\t\t\tportCtx.Port = realPort\n\t\t}\n\t\tpm.mu.Unlock()\n\t}()\n\n\t// check reserved ports first\n\tif port == 0 {\n\t\tif ctx, ok := pm.reservedPorts[name]; ok {\n\t\t\tif pm.isPortAvailable(ctx.Port) {\n\t\t\t\trealPort = ctx.Port\n\t\t\t\tpm.usedPorts[realPort] = portCtx\n\t\t\t\tpm.reservedPorts[name] = portCtx\n\t\t\t\tdelete(pm.freePorts, realPort)\n\t\t\t\treturn\n\t\t\t}\n\t\t}\n\t}\n\n\tif port == 0 {\n\t\t// get random port\n\t\tcount := 0\n\t\tmaxTryTimes := 5\n\t\tfor k := range pm.freePorts {\n\t\t\tcount++\n\t\t\tif count > maxTryTimes {\n\t\t\t\tbreak\n\t\t\t}\n\t\t\tif pm.isPortAvailable(k) {\n\t\t\t\trealPort = k\n\t\t\t\tpm.usedPorts[realPort] = portCtx\n\t\t\t\tpm.reservedPorts[name] = portCtx\n\t\t\t\tdelete(pm.freePorts, realPort)\n\t\t\t\tbreak\n\t\t\t}\n\t\t}\n\t\tif realPort == 0 {\n\t\t\terr = ErrNoAvailablePort\n\t\t}\n\t} else {\n\t\t// specified port\n\t\tif _, ok = pm.freePorts[port]; ok {\n\t\t\tif pm.isPortAvailable(port) {\n\t\t\t\trealPort = port\n\t\t\t\tpm.usedPorts[realPort] = portCtx\n\t\t\t\tpm.reservedPorts[name] = portCtx\n\t\t\t\tdelete(pm.freePorts, realPort)\n\t\t\t} else {\n\t\t\t\terr = ErrPortUnAvailable\n\t\t\t}\n\t\t} else {\n\t\t\tif _, ok = pm.usedPorts[port]; ok {\n\t\t\t\terr = ErrPortAlreadyUsed\n\t\t\t} else {\n\t\t\t\terr = ErrPortNotAllowed\n\t\t\t}\n\t\t}\n\t}\n\treturn\n}\n\nfunc (pm *Manager) isPortAvailable(port int) bool {\n\tif pm.netType == \"udp\" {\n\t\taddr, err := net.ResolveUDPAddr(\"udp\", net.JoinHostPort(pm.bindAddr, strconv.Itoa(port)))\n\t\tif err != nil {\n\t\t\treturn false\n\t\t}\n\t\tl, err := net.ListenUDP(\"udp\", addr)\n\t\tif err != nil {\n\t\t\treturn false\n\t\t}\n\t\tl.Close()\n\t\treturn true\n\t}\n\n\tl, err := net.Listen(pm.netType, net.JoinHostPort(pm.bindAddr, strconv.Itoa(port)))\n\tif err != nil {\n\t\treturn false\n\t}\n\tl.Close()\n\treturn true\n}\n\nfunc (pm *Manager) Release(port int) {\n\tpm.mu.Lock()\n\tdefer pm.mu.Unlock()\n\tif ctx, ok := pm.usedPorts[port]; ok {\n\t\tpm.freePorts[port] = struct{}{}\n\t\tdelete(pm.usedPorts, port)\n\t\tctx.Closed = true\n\t\tctx.UpdateTime = time.Now()\n\t}\n}\n\n// Release reserved port if it isn't used in last 24 hours.\nfunc (pm *Manager) cleanReservedPortsWorker() {\n\tfor {\n\t\ttime.Sleep(CleanReservedPortsInterval)\n\t\tpm.mu.Lock()\n\t\tfor name, ctx := range pm.reservedPorts {\n\t\t\tif ctx.Closed && time.Since(ctx.UpdateTime) > MaxPortReservedDuration {\n\t\t\t\tdelete(pm.reservedPorts, name)\n\t\t\t}\n\t\t}\n\t\tpm.mu.Unlock()\n\t}\n}\n"
  },
  {
    "path": "server/proxy/http.go",
    "content": "// Copyright 2019 fatedier, fatedier@gmail.com\n//\n// Licensed under the Apache License, Version 2.0 (the \"License\");\n// you may not use this file except in compliance with the License.\n// You may obtain a copy of the License at\n//\n//     http://www.apache.org/licenses/LICENSE-2.0\n//\n// Unless required by applicable law or agreed to in writing, software\n// distributed under the License is distributed on an \"AS IS\" BASIS,\n// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.\n// See the License for the specific language governing permissions and\n// limitations under the License.\n\npackage proxy\n\nimport (\n\t\"io\"\n\t\"net\"\n\t\"reflect\"\n\t\"strings\"\n\n\tlibio \"github.com/fatedier/golib/io\"\n\n\tv1 \"github.com/fatedier/frp/pkg/config/v1\"\n\t\"github.com/fatedier/frp/pkg/util/limit\"\n\tnetpkg \"github.com/fatedier/frp/pkg/util/net\"\n\t\"github.com/fatedier/frp/pkg/util/util\"\n\t\"github.com/fatedier/frp/pkg/util/vhost\"\n\t\"github.com/fatedier/frp/server/metrics\"\n)\n\nfunc init() {\n\tRegisterProxyFactory(reflect.TypeFor[*v1.HTTPProxyConfig](), NewHTTPProxy)\n}\n\ntype HTTPProxy struct {\n\t*BaseProxy\n\tcfg *v1.HTTPProxyConfig\n\n\tcloseFuncs []func()\n}\n\nfunc NewHTTPProxy(baseProxy *BaseProxy) Proxy {\n\tunwrapped, ok := baseProxy.GetConfigurer().(*v1.HTTPProxyConfig)\n\tif !ok {\n\t\treturn nil\n\t}\n\treturn &HTTPProxy{\n\t\tBaseProxy: baseProxy,\n\t\tcfg:       unwrapped,\n\t}\n}\n\nfunc (pxy *HTTPProxy) Run() (remoteAddr string, err error) {\n\txl := pxy.xl\n\trouteConfig := vhost.RouteConfig{\n\t\tRewriteHost:     pxy.cfg.HostHeaderRewrite,\n\t\tRouteByHTTPUser: pxy.cfg.RouteByHTTPUser,\n\t\tHeaders:         pxy.cfg.RequestHeaders.Set,\n\t\tResponseHeaders: pxy.cfg.ResponseHeaders.Set,\n\t\tUsername:        pxy.cfg.HTTPUser,\n\t\tPassword:        pxy.cfg.HTTPPassword,\n\t\tCreateConnFn:    pxy.GetRealConn,\n\t}\n\n\tlocations := pxy.cfg.Locations\n\tif len(locations) == 0 {\n\t\tlocations = []string{\"\"}\n\t}\n\n\tdefer func() {\n\t\tif err != nil {\n\t\t\tpxy.Close()\n\t\t}\n\t}()\n\n\tdomains := pxy.buildDomains(pxy.cfg.CustomDomains, pxy.cfg.SubDomain)\n\n\taddrs := make([]string, 0)\n\tfor _, domain := range domains {\n\t\trouteConfig.Domain = domain\n\t\tfor _, location := range locations {\n\t\t\trouteConfig.Location = location\n\t\t\ttmpRouteConfig := routeConfig\n\n\t\t\t// handle group\n\t\t\tif pxy.cfg.LoadBalancer.Group != \"\" {\n\t\t\t\terr = pxy.rc.HTTPGroupCtl.Register(pxy.name, pxy.cfg.LoadBalancer.Group, pxy.cfg.LoadBalancer.GroupKey, routeConfig)\n\t\t\t\tif err != nil {\n\t\t\t\t\treturn\n\t\t\t\t}\n\t\t\t\tpxy.closeFuncs = append(pxy.closeFuncs, func() {\n\t\t\t\t\tpxy.rc.HTTPGroupCtl.UnRegister(pxy.name, pxy.cfg.LoadBalancer.Group, tmpRouteConfig)\n\t\t\t\t})\n\t\t\t} else {\n\t\t\t\terr = pxy.rc.HTTPReverseProxy.Register(routeConfig)\n\t\t\t\tif err != nil {\n\t\t\t\t\treturn\n\t\t\t\t}\n\t\t\t\tpxy.closeFuncs = append(pxy.closeFuncs, func() {\n\t\t\t\t\tpxy.rc.HTTPReverseProxy.UnRegister(tmpRouteConfig)\n\t\t\t\t})\n\t\t\t}\n\t\t\taddrs = append(addrs, util.CanonicalAddr(routeConfig.Domain, pxy.serverCfg.VhostHTTPPort))\n\t\t\txl.Infof(\"http proxy listen for host [%s] location [%s] group [%s], routeByHTTPUser [%s]\",\n\t\t\t\trouteConfig.Domain, routeConfig.Location, pxy.cfg.LoadBalancer.Group, pxy.cfg.RouteByHTTPUser)\n\t\t}\n\t}\n\tremoteAddr = strings.Join(addrs, \",\")\n\treturn\n}\n\nfunc (pxy *HTTPProxy) GetRealConn(remoteAddr string) (workConn net.Conn, err error) {\n\txl := pxy.xl\n\trAddr, errRet := net.ResolveTCPAddr(\"tcp\", remoteAddr)\n\tif errRet != nil {\n\t\txl.Warnf(\"resolve TCP addr [%s] error: %v\", remoteAddr, errRet)\n\t\t// we do not return error here since remoteAddr is not necessary for proxies without proxy protocol enabled\n\t}\n\n\ttmpConn, errRet := pxy.GetWorkConnFromPool(rAddr, nil)\n\tif errRet != nil {\n\t\terr = errRet\n\t\treturn\n\t}\n\n\tvar rwc io.ReadWriteCloser = tmpConn\n\tif pxy.cfg.Transport.UseEncryption {\n\t\trwc, err = libio.WithEncryption(rwc, pxy.encryptionKey)\n\t\tif err != nil {\n\t\t\txl.Errorf(\"create encryption stream error: %v\", err)\n\t\t\ttmpConn.Close()\n\t\t\treturn\n\t\t}\n\t}\n\tif pxy.cfg.Transport.UseCompression {\n\t\trwc = libio.WithCompression(rwc)\n\t}\n\n\tif pxy.GetLimiter() != nil {\n\t\trwc = libio.WrapReadWriteCloser(limit.NewReader(rwc, pxy.GetLimiter()), limit.NewWriter(rwc, pxy.GetLimiter()), func() error {\n\t\t\treturn rwc.Close()\n\t\t})\n\t}\n\n\tworkConn = netpkg.WrapReadWriteCloserToConn(rwc, tmpConn)\n\tworkConn = netpkg.WrapStatsConn(workConn, pxy.updateStatsAfterClosedConn)\n\tmetrics.Server.OpenConnection(pxy.GetName(), pxy.GetConfigurer().GetBaseConfig().Type)\n\treturn\n}\n\nfunc (pxy *HTTPProxy) updateStatsAfterClosedConn(totalRead, totalWrite int64) {\n\tname := pxy.GetName()\n\tproxyType := pxy.GetConfigurer().GetBaseConfig().Type\n\tmetrics.Server.CloseConnection(name, proxyType)\n\tmetrics.Server.AddTrafficIn(name, proxyType, totalWrite)\n\tmetrics.Server.AddTrafficOut(name, proxyType, totalRead)\n}\n\nfunc (pxy *HTTPProxy) Close() {\n\tpxy.BaseProxy.Close()\n\tfor _, closeFn := range pxy.closeFuncs {\n\t\tcloseFn()\n\t}\n}\n"
  },
  {
    "path": "server/proxy/https.go",
    "content": "// Copyright 2019 fatedier, fatedier@gmail.com\n//\n// Licensed under the Apache License, Version 2.0 (the \"License\");\n// you may not use this file except in compliance with the License.\n// You may obtain a copy of the License at\n//\n//     http://www.apache.org/licenses/LICENSE-2.0\n//\n// Unless required by applicable law or agreed to in writing, software\n// distributed under the License is distributed on an \"AS IS\" BASIS,\n// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.\n// See the License for the specific language governing permissions and\n// limitations under the License.\n\npackage proxy\n\nimport (\n\t\"net\"\n\t\"reflect\"\n\t\"strings\"\n\n\tv1 \"github.com/fatedier/frp/pkg/config/v1\"\n\t\"github.com/fatedier/frp/pkg/util/util\"\n\t\"github.com/fatedier/frp/pkg/util/vhost\"\n)\n\nfunc init() {\n\tRegisterProxyFactory(reflect.TypeFor[*v1.HTTPSProxyConfig](), NewHTTPSProxy)\n}\n\ntype HTTPSProxy struct {\n\t*BaseProxy\n\tcfg *v1.HTTPSProxyConfig\n}\n\nfunc NewHTTPSProxy(baseProxy *BaseProxy) Proxy {\n\tunwrapped, ok := baseProxy.GetConfigurer().(*v1.HTTPSProxyConfig)\n\tif !ok {\n\t\treturn nil\n\t}\n\treturn &HTTPSProxy{\n\t\tBaseProxy: baseProxy,\n\t\tcfg:       unwrapped,\n\t}\n}\n\nfunc (pxy *HTTPSProxy) Run() (remoteAddr string, err error) {\n\txl := pxy.xl\n\trouteConfig := &vhost.RouteConfig{}\n\n\tdefer func() {\n\t\tif err != nil {\n\t\t\tpxy.Close()\n\t\t}\n\t}()\n\tdomains := pxy.buildDomains(pxy.cfg.CustomDomains, pxy.cfg.SubDomain)\n\n\taddrs := make([]string, 0)\n\tfor _, domain := range domains {\n\t\tl, err := pxy.listenForDomain(routeConfig, domain)\n\t\tif err != nil {\n\t\t\treturn \"\", err\n\t\t}\n\t\tpxy.listeners = append(pxy.listeners, l)\n\t\taddrs = append(addrs, util.CanonicalAddr(domain, pxy.serverCfg.VhostHTTPSPort))\n\t\txl.Infof(\"https proxy listen for host [%s] group [%s]\", domain, pxy.cfg.LoadBalancer.Group)\n\t}\n\n\tpxy.startCommonTCPListenersHandler()\n\tremoteAddr = strings.Join(addrs, \",\")\n\treturn\n}\n\nfunc (pxy *HTTPSProxy) Close() {\n\tpxy.BaseProxy.Close()\n}\n\nfunc (pxy *HTTPSProxy) listenForDomain(routeConfig *vhost.RouteConfig, domain string) (net.Listener, error) {\n\ttmpRouteConfig := *routeConfig\n\ttmpRouteConfig.Domain = domain\n\n\tif pxy.cfg.LoadBalancer.Group != \"\" {\n\t\treturn pxy.rc.HTTPSGroupCtl.Listen(\n\t\t\tpxy.ctx,\n\t\t\tpxy.cfg.LoadBalancer.Group,\n\t\t\tpxy.cfg.LoadBalancer.GroupKey,\n\t\t\ttmpRouteConfig,\n\t\t)\n\t}\n\treturn pxy.rc.VhostHTTPSMuxer.Listen(pxy.ctx, &tmpRouteConfig)\n}\n"
  },
  {
    "path": "server/proxy/proxy.go",
    "content": "// Copyright 2017 fatedier, fatedier@gmail.com\n//\n// Licensed under the Apache License, Version 2.0 (the \"License\");\n// you may not use this file except in compliance with the License.\n// You may obtain a copy of the License at\n//\n//     http://www.apache.org/licenses/LICENSE-2.0\n//\n// Unless required by applicable law or agreed to in writing, software\n// distributed under the License is distributed on an \"AS IS\" BASIS,\n// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.\n// See the License for the specific language governing permissions and\n// limitations under the License.\n\npackage proxy\n\nimport (\n\t\"context\"\n\t\"fmt\"\n\t\"io\"\n\t\"net\"\n\t\"reflect\"\n\t\"strconv\"\n\t\"sync\"\n\t\"time\"\n\n\tlibio \"github.com/fatedier/golib/io\"\n\t\"golang.org/x/time/rate\"\n\n\t\"github.com/fatedier/frp/pkg/config/types\"\n\tv1 \"github.com/fatedier/frp/pkg/config/v1\"\n\t\"github.com/fatedier/frp/pkg/msg\"\n\tplugin \"github.com/fatedier/frp/pkg/plugin/server\"\n\t\"github.com/fatedier/frp/pkg/util/limit\"\n\tnetpkg \"github.com/fatedier/frp/pkg/util/net\"\n\t\"github.com/fatedier/frp/pkg/util/xlog\"\n\t\"github.com/fatedier/frp/server/controller\"\n\t\"github.com/fatedier/frp/server/metrics\"\n)\n\nvar proxyFactoryRegistry = map[reflect.Type]func(*BaseProxy) Proxy{}\n\nfunc RegisterProxyFactory(proxyConfType reflect.Type, factory func(*BaseProxy) Proxy) {\n\tproxyFactoryRegistry[proxyConfType] = factory\n}\n\ntype GetWorkConnFn func() (net.Conn, error)\n\ntype Proxy interface {\n\tContext() context.Context\n\tRun() (remoteAddr string, err error)\n\tGetName() string\n\tGetConfigurer() v1.ProxyConfigurer\n\tGetWorkConnFromPool(src, dst net.Addr) (workConn net.Conn, err error)\n\tGetUsedPortsNum() int\n\tGetResourceController() *controller.ResourceController\n\tGetUserInfo() plugin.UserInfo\n\tGetLimiter() *rate.Limiter\n\tGetLoginMsg() *msg.Login\n\tClose()\n}\n\ntype BaseProxy struct {\n\tname          string\n\trc            *controller.ResourceController\n\tlisteners     []net.Listener\n\tusedPortsNum  int\n\tpoolCount     int\n\tgetWorkConnFn GetWorkConnFn\n\tserverCfg     *v1.ServerConfig\n\tencryptionKey []byte\n\tlimiter       *rate.Limiter\n\tuserInfo      plugin.UserInfo\n\tloginMsg      *msg.Login\n\tconfigurer    v1.ProxyConfigurer\n\n\tmu  sync.RWMutex\n\txl  *xlog.Logger\n\tctx context.Context\n}\n\nfunc (pxy *BaseProxy) GetName() string {\n\treturn pxy.name\n}\n\nfunc (pxy *BaseProxy) Context() context.Context {\n\treturn pxy.ctx\n}\n\nfunc (pxy *BaseProxy) GetUsedPortsNum() int {\n\treturn pxy.usedPortsNum\n}\n\nfunc (pxy *BaseProxy) GetResourceController() *controller.ResourceController {\n\treturn pxy.rc\n}\n\nfunc (pxy *BaseProxy) GetUserInfo() plugin.UserInfo {\n\treturn pxy.userInfo\n}\n\nfunc (pxy *BaseProxy) GetLoginMsg() *msg.Login {\n\treturn pxy.loginMsg\n}\n\nfunc (pxy *BaseProxy) GetLimiter() *rate.Limiter {\n\treturn pxy.limiter\n}\n\nfunc (pxy *BaseProxy) GetConfigurer() v1.ProxyConfigurer {\n\treturn pxy.configurer\n}\n\nfunc (pxy *BaseProxy) Close() {\n\txl := xlog.FromContextSafe(pxy.ctx)\n\txl.Infof(\"proxy closing\")\n\tfor _, l := range pxy.listeners {\n\t\tl.Close()\n\t}\n}\n\n// GetWorkConnFromPool try to get a new work connections from pool\n// for quickly response, we immediately send the StartWorkConn message to frpc after take out one from pool\nfunc (pxy *BaseProxy) GetWorkConnFromPool(src, dst net.Addr) (workConn net.Conn, err error) {\n\txl := xlog.FromContextSafe(pxy.ctx)\n\t// try all connections from the pool\n\tfor i := 0; i < pxy.poolCount+1; i++ {\n\t\tif workConn, err = pxy.getWorkConnFn(); err != nil {\n\t\t\txl.Warnf(\"failed to get work connection: %v\", err)\n\t\t\treturn\n\t\t}\n\t\txl.Debugf(\"get a new work connection: [%s]\", workConn.RemoteAddr().String())\n\t\txl.Spawn().AppendPrefix(pxy.GetName())\n\t\tworkConn = netpkg.NewContextConn(pxy.ctx, workConn)\n\n\t\tvar (\n\t\t\tsrcAddr    string\n\t\t\tdstAddr    string\n\t\t\tsrcPortStr string\n\t\t\tdstPortStr string\n\t\t\tsrcPort    uint64\n\t\t\tdstPort    uint64\n\t\t)\n\n\t\tif src != nil {\n\t\t\tsrcAddr, srcPortStr, _ = net.SplitHostPort(src.String())\n\t\t\tsrcPort, _ = strconv.ParseUint(srcPortStr, 10, 16)\n\t\t}\n\t\tif dst != nil {\n\t\t\tdstAddr, dstPortStr, _ = net.SplitHostPort(dst.String())\n\t\t\tdstPort, _ = strconv.ParseUint(dstPortStr, 10, 16)\n\t\t}\n\t\terr = msg.WriteMsg(workConn, &msg.StartWorkConn{\n\t\t\tProxyName: pxy.GetName(),\n\t\t\tSrcAddr:   srcAddr,\n\t\t\tSrcPort:   uint16(srcPort),\n\t\t\tDstAddr:   dstAddr,\n\t\t\tDstPort:   uint16(dstPort),\n\t\t\tError:     \"\",\n\t\t})\n\t\tif err != nil {\n\t\t\txl.Warnf(\"failed to send message to work connection from pool: %v, times: %d\", err, i)\n\t\t\tworkConn.Close()\n\t\t\tworkConn = nil\n\t\t} else {\n\t\t\tbreak\n\t\t}\n\t}\n\n\tif err != nil {\n\t\txl.Errorf(\"try to get work connection failed in the end\")\n\t\treturn\n\t}\n\treturn\n}\n\n// startVisitorListener sets up a VisitorManager listener for visitor-based proxies (STCP, SUDP).\nfunc (pxy *BaseProxy) startVisitorListener(secretKey string, allowUsers []string, proxyType string) error {\n\t// if allowUsers is empty, only allow same user from proxy\n\tif len(allowUsers) == 0 {\n\t\tallowUsers = []string{pxy.GetUserInfo().User}\n\t}\n\tlistener, err := pxy.rc.VisitorManager.Listen(pxy.GetName(), secretKey, allowUsers)\n\tif err != nil {\n\t\treturn err\n\t}\n\tpxy.listeners = append(pxy.listeners, listener)\n\tpxy.xl.Infof(\"%s proxy custom listen success\", proxyType)\n\tpxy.startCommonTCPListenersHandler()\n\treturn nil\n}\n\n// buildDomains constructs a list of domains from custom domains and subdomain configuration.\nfunc (pxy *BaseProxy) buildDomains(customDomains []string, subDomain string) []string {\n\tdomains := make([]string, 0, len(customDomains)+1)\n\tfor _, d := range customDomains {\n\t\tif d != \"\" {\n\t\t\tdomains = append(domains, d)\n\t\t}\n\t}\n\tif subDomain != \"\" {\n\t\tdomains = append(domains, subDomain+\".\"+pxy.serverCfg.SubDomainHost)\n\t}\n\treturn domains\n}\n\n// startCommonTCPListenersHandler start a goroutine handler for each listener.\nfunc (pxy *BaseProxy) startCommonTCPListenersHandler() {\n\txl := xlog.FromContextSafe(pxy.ctx)\n\tfor _, listener := range pxy.listeners {\n\t\tgo func(l net.Listener) {\n\t\t\tvar tempDelay time.Duration // how long to sleep on accept failure\n\n\t\t\tfor {\n\t\t\t\t// block\n\t\t\t\t// if listener is closed, err returned\n\t\t\t\tc, err := l.Accept()\n\t\t\t\tif err != nil {\n\t\t\t\t\tif err, ok := err.(interface{ Temporary() bool }); ok && err.Temporary() {\n\t\t\t\t\t\tif tempDelay == 0 {\n\t\t\t\t\t\t\ttempDelay = 5 * time.Millisecond\n\t\t\t\t\t\t} else {\n\t\t\t\t\t\t\ttempDelay *= 2\n\t\t\t\t\t\t}\n\t\t\t\t\t\tif maxTime := 1 * time.Second; tempDelay > maxTime {\n\t\t\t\t\t\t\ttempDelay = maxTime\n\t\t\t\t\t\t}\n\t\t\t\t\t\txl.Infof(\"met temporary error: %s, sleep for %s ...\", err, tempDelay)\n\t\t\t\t\t\ttime.Sleep(tempDelay)\n\t\t\t\t\t\tcontinue\n\t\t\t\t\t}\n\n\t\t\t\t\txl.Warnf(\"listener is closed: %s\", err)\n\t\t\t\t\treturn\n\t\t\t\t}\n\t\t\t\txl.Infof(\"get a user connection [%s]\", c.RemoteAddr().String())\n\t\t\t\tgo pxy.handleUserTCPConnection(c)\n\t\t\t}\n\t\t}(listener)\n\t}\n}\n\n// HandleUserTCPConnection is used for incoming user TCP connections.\nfunc (pxy *BaseProxy) handleUserTCPConnection(userConn net.Conn) {\n\txl := xlog.FromContextSafe(pxy.Context())\n\tdefer userConn.Close()\n\n\tcfg := pxy.configurer.GetBaseConfig()\n\t// server plugin hook\n\trc := pxy.GetResourceController()\n\tcontent := &plugin.NewUserConnContent{\n\t\tUser:       pxy.GetUserInfo(),\n\t\tProxyName:  pxy.GetName(),\n\t\tProxyType:  cfg.Type,\n\t\tRemoteAddr: userConn.RemoteAddr().String(),\n\t}\n\t_, err := rc.PluginManager.NewUserConn(content)\n\tif err != nil {\n\t\txl.Warnf(\"the user conn [%s] was rejected, err:%v\", content.RemoteAddr, err)\n\t\treturn\n\t}\n\n\t// try all connections from the pool\n\tworkConn, err := pxy.GetWorkConnFromPool(userConn.RemoteAddr(), userConn.LocalAddr())\n\tif err != nil {\n\t\treturn\n\t}\n\tdefer workConn.Close()\n\n\tvar local io.ReadWriteCloser = workConn\n\txl.Tracef(\"handler user tcp connection, use_encryption: %t, use_compression: %t\",\n\t\tcfg.Transport.UseEncryption, cfg.Transport.UseCompression)\n\tif cfg.Transport.UseEncryption {\n\t\tlocal, err = libio.WithEncryption(local, pxy.encryptionKey)\n\t\tif err != nil {\n\t\t\txl.Errorf(\"create encryption stream error: %v\", err)\n\t\t\treturn\n\t\t}\n\t}\n\tif cfg.Transport.UseCompression {\n\t\tvar recycleFn func()\n\t\tlocal, recycleFn = libio.WithCompressionFromPool(local)\n\t\tdefer recycleFn()\n\t}\n\n\tif pxy.GetLimiter() != nil {\n\t\tlocal = libio.WrapReadWriteCloser(limit.NewReader(local, pxy.GetLimiter()), limit.NewWriter(local, pxy.GetLimiter()), func() error {\n\t\t\treturn local.Close()\n\t\t})\n\t}\n\n\txl.Debugf(\"join connections, workConn(l[%s] r[%s]) userConn(l[%s] r[%s])\", workConn.LocalAddr().String(),\n\t\tworkConn.RemoteAddr().String(), userConn.LocalAddr().String(), userConn.RemoteAddr().String())\n\n\tname := pxy.GetName()\n\tproxyType := cfg.Type\n\tmetrics.Server.OpenConnection(name, proxyType)\n\tinCount, outCount, _ := libio.Join(local, userConn)\n\tmetrics.Server.CloseConnection(name, proxyType)\n\tmetrics.Server.AddTrafficIn(name, proxyType, inCount)\n\tmetrics.Server.AddTrafficOut(name, proxyType, outCount)\n\txl.Debugf(\"join connections closed\")\n}\n\ntype Options struct {\n\tUserInfo           plugin.UserInfo\n\tLoginMsg           *msg.Login\n\tPoolCount          int\n\tResourceController *controller.ResourceController\n\tGetWorkConnFn      GetWorkConnFn\n\tConfigurer         v1.ProxyConfigurer\n\tServerCfg          *v1.ServerConfig\n\tEncryptionKey      []byte\n}\n\nfunc NewProxy(ctx context.Context, options *Options) (pxy Proxy, err error) {\n\tconfigurer := options.Configurer\n\txl := xlog.FromContextSafe(ctx).Spawn().AppendPrefix(configurer.GetBaseConfig().Name)\n\n\tvar limiter *rate.Limiter\n\tlimitBytes := configurer.GetBaseConfig().Transport.BandwidthLimit.Bytes()\n\tif limitBytes > 0 && configurer.GetBaseConfig().Transport.BandwidthLimitMode == types.BandwidthLimitModeServer {\n\t\tlimiter = rate.NewLimiter(rate.Limit(float64(limitBytes)), int(limitBytes))\n\t}\n\n\tbasePxy := BaseProxy{\n\t\tname:          configurer.GetBaseConfig().Name,\n\t\trc:            options.ResourceController,\n\t\tlisteners:     make([]net.Listener, 0),\n\t\tpoolCount:     options.PoolCount,\n\t\tgetWorkConnFn: options.GetWorkConnFn,\n\t\tserverCfg:     options.ServerCfg,\n\t\tencryptionKey: options.EncryptionKey,\n\t\tlimiter:       limiter,\n\t\txl:            xl,\n\t\tctx:           xlog.NewContext(ctx, xl),\n\t\tuserInfo:      options.UserInfo,\n\t\tloginMsg:      options.LoginMsg,\n\t\tconfigurer:    configurer,\n\t}\n\n\tfactory := proxyFactoryRegistry[reflect.TypeOf(configurer)]\n\tif factory == nil {\n\t\treturn pxy, fmt.Errorf(\"proxy type not support\")\n\t}\n\tpxy = factory(&basePxy)\n\tif pxy == nil {\n\t\treturn nil, fmt.Errorf(\"proxy not created\")\n\t}\n\treturn pxy, nil\n}\n\ntype Manager struct {\n\t// proxies indexed by proxy name\n\tpxys map[string]Proxy\n\n\tmu sync.RWMutex\n}\n\nfunc NewManager() *Manager {\n\treturn &Manager{\n\t\tpxys: make(map[string]Proxy),\n\t}\n}\n\nfunc (pm *Manager) Add(name string, pxy Proxy) error {\n\tpm.mu.Lock()\n\tdefer pm.mu.Unlock()\n\tif _, ok := pm.pxys[name]; ok {\n\t\treturn fmt.Errorf(\"proxy name [%s] is already in use\", name)\n\t}\n\n\tpm.pxys[name] = pxy\n\treturn nil\n}\n\nfunc (pm *Manager) Exist(name string) bool {\n\tpm.mu.RLock()\n\tdefer pm.mu.RUnlock()\n\t_, ok := pm.pxys[name]\n\treturn ok\n}\n\nfunc (pm *Manager) Del(name string) {\n\tpm.mu.Lock()\n\tdefer pm.mu.Unlock()\n\tdelete(pm.pxys, name)\n}\n\nfunc (pm *Manager) GetByName(name string) (pxy Proxy, ok bool) {\n\tpm.mu.RLock()\n\tdefer pm.mu.RUnlock()\n\tpxy, ok = pm.pxys[name]\n\treturn\n}\n"
  },
  {
    "path": "server/proxy/stcp.go",
    "content": "// Copyright 2019 fatedier, fatedier@gmail.com\n//\n// Licensed under the Apache License, Version 2.0 (the \"License\");\n// you may not use this file except in compliance with the License.\n// You may obtain a copy of the License at\n//\n//     http://www.apache.org/licenses/LICENSE-2.0\n//\n// Unless required by applicable law or agreed to in writing, software\n// distributed under the License is distributed on an \"AS IS\" BASIS,\n// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.\n// See the License for the specific language governing permissions and\n// limitations under the License.\n\npackage proxy\n\nimport (\n\t\"reflect\"\n\n\tv1 \"github.com/fatedier/frp/pkg/config/v1\"\n)\n\nfunc init() {\n\tRegisterProxyFactory(reflect.TypeFor[*v1.STCPProxyConfig](), NewSTCPProxy)\n}\n\ntype STCPProxy struct {\n\t*BaseProxy\n\tcfg *v1.STCPProxyConfig\n}\n\nfunc NewSTCPProxy(baseProxy *BaseProxy) Proxy {\n\tunwrapped, ok := baseProxy.GetConfigurer().(*v1.STCPProxyConfig)\n\tif !ok {\n\t\treturn nil\n\t}\n\treturn &STCPProxy{\n\t\tBaseProxy: baseProxy,\n\t\tcfg:       unwrapped,\n\t}\n}\n\nfunc (pxy *STCPProxy) Run() (remoteAddr string, err error) {\n\terr = pxy.startVisitorListener(pxy.cfg.Secretkey, pxy.cfg.AllowUsers, \"stcp\")\n\treturn\n}\n\nfunc (pxy *STCPProxy) Close() {\n\tpxy.BaseProxy.Close()\n\tpxy.rc.VisitorManager.CloseListener(pxy.GetName())\n}\n"
  },
  {
    "path": "server/proxy/sudp.go",
    "content": "// Copyright 2019 fatedier, fatedier@gmail.com\n//\n// Licensed under the Apache License, Version 2.0 (the \"License\");\n// you may not use this file except in compliance with the License.\n// You may obtain a copy of the License at\n//\n//     http://www.apache.org/licenses/LICENSE-2.0\n//\n// Unless required by applicable law or agreed to in writing, software\n// distributed under the License is distributed on an \"AS IS\" BASIS,\n// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.\n// See the License for the specific language governing permissions and\n// limitations under the License.\n\npackage proxy\n\nimport (\n\t\"reflect\"\n\n\tv1 \"github.com/fatedier/frp/pkg/config/v1\"\n)\n\nfunc init() {\n\tRegisterProxyFactory(reflect.TypeFor[*v1.SUDPProxyConfig](), NewSUDPProxy)\n}\n\ntype SUDPProxy struct {\n\t*BaseProxy\n\tcfg *v1.SUDPProxyConfig\n}\n\nfunc NewSUDPProxy(baseProxy *BaseProxy) Proxy {\n\tunwrapped, ok := baseProxy.GetConfigurer().(*v1.SUDPProxyConfig)\n\tif !ok {\n\t\treturn nil\n\t}\n\treturn &SUDPProxy{\n\t\tBaseProxy: baseProxy,\n\t\tcfg:       unwrapped,\n\t}\n}\n\nfunc (pxy *SUDPProxy) Run() (remoteAddr string, err error) {\n\terr = pxy.startVisitorListener(pxy.cfg.Secretkey, pxy.cfg.AllowUsers, \"sudp\")\n\treturn\n}\n\nfunc (pxy *SUDPProxy) Close() {\n\tpxy.BaseProxy.Close()\n\tpxy.rc.VisitorManager.CloseListener(pxy.GetName())\n}\n"
  },
  {
    "path": "server/proxy/tcp.go",
    "content": "// Copyright 2019 fatedier, fatedier@gmail.com\n//\n// Licensed under the Apache License, Version 2.0 (the \"License\");\n// you may not use this file except in compliance with the License.\n// You may obtain a copy of the License at\n//\n//     http://www.apache.org/licenses/LICENSE-2.0\n//\n// Unless required by applicable law or agreed to in writing, software\n// distributed under the License is distributed on an \"AS IS\" BASIS,\n// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.\n// See the License for the specific language governing permissions and\n// limitations under the License.\n\npackage proxy\n\nimport (\n\t\"fmt\"\n\t\"net\"\n\t\"reflect\"\n\t\"strconv\"\n\n\tv1 \"github.com/fatedier/frp/pkg/config/v1\"\n)\n\nfunc init() {\n\tRegisterProxyFactory(reflect.TypeFor[*v1.TCPProxyConfig](), NewTCPProxy)\n}\n\ntype TCPProxy struct {\n\t*BaseProxy\n\tcfg *v1.TCPProxyConfig\n\n\trealBindPort int\n}\n\nfunc NewTCPProxy(baseProxy *BaseProxy) Proxy {\n\tunwrapped, ok := baseProxy.GetConfigurer().(*v1.TCPProxyConfig)\n\tif !ok {\n\t\treturn nil\n\t}\n\tbaseProxy.usedPortsNum = 1\n\treturn &TCPProxy{\n\t\tBaseProxy: baseProxy,\n\t\tcfg:       unwrapped,\n\t}\n}\n\nfunc (pxy *TCPProxy) Run() (remoteAddr string, err error) {\n\txl := pxy.xl\n\tif pxy.cfg.LoadBalancer.Group != \"\" {\n\t\tl, realBindPort, errRet := pxy.rc.TCPGroupCtl.Listen(pxy.name, pxy.cfg.LoadBalancer.Group, pxy.cfg.LoadBalancer.GroupKey,\n\t\t\tpxy.serverCfg.ProxyBindAddr, pxy.cfg.RemotePort)\n\t\tif errRet != nil {\n\t\t\terr = errRet\n\t\t\treturn\n\t\t}\n\t\tdefer func() {\n\t\t\tif err != nil {\n\t\t\t\tl.Close()\n\t\t\t}\n\t\t}()\n\t\tpxy.realBindPort = realBindPort\n\t\tpxy.listeners = append(pxy.listeners, l)\n\t\txl.Infof(\"tcp proxy listen port [%d] in group [%s]\", pxy.cfg.RemotePort, pxy.cfg.LoadBalancer.Group)\n\t} else {\n\t\tpxy.realBindPort, err = pxy.rc.TCPPortManager.Acquire(pxy.name, pxy.cfg.RemotePort)\n\t\tif err != nil {\n\t\t\treturn\n\t\t}\n\t\tdefer func() {\n\t\t\tif err != nil {\n\t\t\t\tpxy.rc.TCPPortManager.Release(pxy.realBindPort)\n\t\t\t}\n\t\t}()\n\t\tlistener, errRet := net.Listen(\"tcp\", net.JoinHostPort(pxy.serverCfg.ProxyBindAddr, strconv.Itoa(pxy.realBindPort)))\n\t\tif errRet != nil {\n\t\t\terr = errRet\n\t\t\treturn\n\t\t}\n\t\tpxy.listeners = append(pxy.listeners, listener)\n\t\txl.Infof(\"tcp proxy listen port [%d]\", pxy.cfg.RemotePort)\n\t}\n\n\tpxy.cfg.RemotePort = pxy.realBindPort\n\tremoteAddr = fmt.Sprintf(\":%d\", pxy.realBindPort)\n\tpxy.startCommonTCPListenersHandler()\n\treturn\n}\n\nfunc (pxy *TCPProxy) Close() {\n\tpxy.BaseProxy.Close()\n\tif pxy.cfg.LoadBalancer.Group == \"\" {\n\t\tpxy.rc.TCPPortManager.Release(pxy.realBindPort)\n\t}\n}\n"
  },
  {
    "path": "server/proxy/tcpmux.go",
    "content": "// Copyright 2020 guylewin, guy@lewin.co.il\n//\n// Licensed under the Apache License, Version 2.0 (the \"License\");\n// you may not use this file except in compliance with the License.\n// You may obtain a copy of the License at\n//\n//     http://www.apache.org/licenses/LICENSE-2.0\n//\n// Unless required by applicable law or agreed to in writing, software\n// distributed under the License is distributed on an \"AS IS\" BASIS,\n// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.\n// See the License for the specific language governing permissions and\n// limitations under the License.\n\npackage proxy\n\nimport (\n\t\"fmt\"\n\t\"net\"\n\t\"reflect\"\n\t\"strings\"\n\n\tv1 \"github.com/fatedier/frp/pkg/config/v1\"\n\t\"github.com/fatedier/frp/pkg/util/util\"\n\t\"github.com/fatedier/frp/pkg/util/vhost\"\n)\n\nfunc init() {\n\tRegisterProxyFactory(reflect.TypeFor[*v1.TCPMuxProxyConfig](), NewTCPMuxProxy)\n}\n\ntype TCPMuxProxy struct {\n\t*BaseProxy\n\tcfg *v1.TCPMuxProxyConfig\n}\n\nfunc NewTCPMuxProxy(baseProxy *BaseProxy) Proxy {\n\tunwrapped, ok := baseProxy.GetConfigurer().(*v1.TCPMuxProxyConfig)\n\tif !ok {\n\t\treturn nil\n\t}\n\treturn &TCPMuxProxy{\n\t\tBaseProxy: baseProxy,\n\t\tcfg:       unwrapped,\n\t}\n}\n\nfunc (pxy *TCPMuxProxy) httpConnectListen(\n\tdomain, routeByHTTPUser, httpUser, httpPwd string, addrs []string) ([]string, error,\n) {\n\tvar l net.Listener\n\tvar err error\n\trouteConfig := &vhost.RouteConfig{\n\t\tDomain:          domain,\n\t\tRouteByHTTPUser: routeByHTTPUser,\n\t\tUsername:        httpUser,\n\t\tPassword:        httpPwd,\n\t}\n\tif pxy.cfg.LoadBalancer.Group != \"\" {\n\t\tl, err = pxy.rc.TCPMuxGroupCtl.Listen(pxy.ctx, pxy.cfg.Multiplexer,\n\t\t\tpxy.cfg.LoadBalancer.Group, pxy.cfg.LoadBalancer.GroupKey, *routeConfig)\n\t} else {\n\t\tl, err = pxy.rc.TCPMuxHTTPConnectMuxer.Listen(pxy.ctx, routeConfig)\n\t}\n\tif err != nil {\n\t\treturn nil, err\n\t}\n\tpxy.xl.Infof(\"tcpmux httpconnect multiplexer listens for host [%s], group [%s] routeByHTTPUser [%s]\",\n\t\tdomain, pxy.cfg.LoadBalancer.Group, pxy.cfg.RouteByHTTPUser)\n\tpxy.listeners = append(pxy.listeners, l)\n\treturn append(addrs, util.CanonicalAddr(domain, pxy.serverCfg.TCPMuxHTTPConnectPort)), nil\n}\n\nfunc (pxy *TCPMuxProxy) httpConnectRun() (remoteAddr string, err error) {\n\tdomains := pxy.buildDomains(pxy.cfg.CustomDomains, pxy.cfg.SubDomain)\n\n\taddrs := make([]string, 0)\n\tfor _, domain := range domains {\n\t\taddrs, err = pxy.httpConnectListen(domain, pxy.cfg.RouteByHTTPUser, pxy.cfg.HTTPUser, pxy.cfg.HTTPPassword, addrs)\n\t\tif err != nil {\n\t\t\treturn \"\", err\n\t\t}\n\t}\n\n\tpxy.startCommonTCPListenersHandler()\n\tremoteAddr = strings.Join(addrs, \",\")\n\treturn remoteAddr, err\n}\n\nfunc (pxy *TCPMuxProxy) Run() (remoteAddr string, err error) {\n\tswitch v1.TCPMultiplexerType(pxy.cfg.Multiplexer) {\n\tcase v1.TCPMultiplexerHTTPConnect:\n\t\tremoteAddr, err = pxy.httpConnectRun()\n\tdefault:\n\t\terr = fmt.Errorf(\"unknown multiplexer [%s]\", pxy.cfg.Multiplexer)\n\t}\n\n\tif err != nil {\n\t\tpxy.Close()\n\t}\n\treturn remoteAddr, err\n}\n\nfunc (pxy *TCPMuxProxy) Close() {\n\tpxy.BaseProxy.Close()\n}\n"
  },
  {
    "path": "server/proxy/udp.go",
    "content": "// Copyright 2019 fatedier, fatedier@gmail.com\n//\n// Licensed under the Apache License, Version 2.0 (the \"License\");\n// you may not use this file except in compliance with the License.\n// You may obtain a copy of the License at\n//\n//     http://www.apache.org/licenses/LICENSE-2.0\n//\n// Unless required by applicable law or agreed to in writing, software\n// distributed under the License is distributed on an \"AS IS\" BASIS,\n// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.\n// See the License for the specific language governing permissions and\n// limitations under the License.\n\npackage proxy\n\nimport (\n\t\"context\"\n\t\"fmt\"\n\t\"io\"\n\t\"net\"\n\t\"reflect\"\n\t\"strconv\"\n\t\"time\"\n\n\t\"github.com/fatedier/golib/errors\"\n\tlibio \"github.com/fatedier/golib/io\"\n\n\tv1 \"github.com/fatedier/frp/pkg/config/v1\"\n\t\"github.com/fatedier/frp/pkg/msg\"\n\t\"github.com/fatedier/frp/pkg/proto/udp\"\n\t\"github.com/fatedier/frp/pkg/util/limit\"\n\tnetpkg \"github.com/fatedier/frp/pkg/util/net\"\n\t\"github.com/fatedier/frp/server/metrics\"\n)\n\nfunc init() {\n\tRegisterProxyFactory(reflect.TypeFor[*v1.UDPProxyConfig](), NewUDPProxy)\n}\n\ntype UDPProxy struct {\n\t*BaseProxy\n\tcfg *v1.UDPProxyConfig\n\n\trealBindPort int\n\n\t// udpConn is the listener of udp packages\n\tudpConn *net.UDPConn\n\n\t// there are always only one workConn at the same time\n\t// get another one if it closed\n\tworkConn net.Conn\n\n\t// sendCh is used for sending packages to workConn\n\tsendCh chan *msg.UDPPacket\n\n\t// readCh is used for reading packages from workConn\n\treadCh chan *msg.UDPPacket\n\n\t// checkCloseCh is used for watching if workConn is closed\n\tcheckCloseCh chan int\n\n\tisClosed bool\n}\n\nfunc NewUDPProxy(baseProxy *BaseProxy) Proxy {\n\tunwrapped, ok := baseProxy.GetConfigurer().(*v1.UDPProxyConfig)\n\tif !ok {\n\t\treturn nil\n\t}\n\tbaseProxy.usedPortsNum = 1\n\treturn &UDPProxy{\n\t\tBaseProxy: baseProxy,\n\t\tcfg:       unwrapped,\n\t}\n}\n\nfunc (pxy *UDPProxy) Run() (remoteAddr string, err error) {\n\txl := pxy.xl\n\tpxy.realBindPort, err = pxy.rc.UDPPortManager.Acquire(pxy.name, pxy.cfg.RemotePort)\n\tif err != nil {\n\t\treturn \"\", fmt.Errorf(\"acquire port %d error: %v\", pxy.cfg.RemotePort, err)\n\t}\n\tdefer func() {\n\t\tif err != nil {\n\t\t\tpxy.rc.UDPPortManager.Release(pxy.realBindPort)\n\t\t}\n\t}()\n\n\tremoteAddr = fmt.Sprintf(\":%d\", pxy.realBindPort)\n\tpxy.cfg.RemotePort = pxy.realBindPort\n\taddr, errRet := net.ResolveUDPAddr(\"udp\", net.JoinHostPort(pxy.serverCfg.ProxyBindAddr, strconv.Itoa(pxy.realBindPort)))\n\tif errRet != nil {\n\t\terr = errRet\n\t\treturn\n\t}\n\tudpConn, errRet := net.ListenUDP(\"udp\", addr)\n\tif errRet != nil {\n\t\terr = errRet\n\t\txl.Warnf(\"listen udp port error: %v\", err)\n\t\treturn\n\t}\n\txl.Infof(\"udp proxy listen port [%d]\", pxy.cfg.RemotePort)\n\n\tpxy.udpConn = udpConn\n\tpxy.sendCh = make(chan *msg.UDPPacket, 1024)\n\tpxy.readCh = make(chan *msg.UDPPacket, 1024)\n\tpxy.checkCloseCh = make(chan int)\n\n\t// read message from workConn, if it returns any error, notify proxy to start a new workConn\n\tworkConnReaderFn := func(conn net.Conn) {\n\t\tfor {\n\t\t\tvar (\n\t\t\t\trawMsg msg.Message\n\t\t\t\terrRet error\n\t\t\t)\n\t\t\txl.Tracef(\"loop waiting message from udp workConn\")\n\t\t\t// client will send heartbeat in workConn for keeping alive\n\t\t\t_ = conn.SetReadDeadline(time.Now().Add(time.Duration(60) * time.Second))\n\t\t\tif rawMsg, errRet = msg.ReadMsg(conn); errRet != nil {\n\t\t\t\txl.Warnf(\"read from workConn for udp error: %v\", errRet)\n\t\t\t\t_ = conn.Close()\n\t\t\t\t// notify proxy to start a new work connection\n\t\t\t\t// ignore error here, it means the proxy is closed\n\t\t\t\t_ = errors.PanicToError(func() {\n\t\t\t\t\tpxy.checkCloseCh <- 1\n\t\t\t\t})\n\t\t\t\treturn\n\t\t\t}\n\t\t\tif err := conn.SetReadDeadline(time.Time{}); err != nil {\n\t\t\t\txl.Warnf(\"set read deadline error: %v\", err)\n\t\t\t}\n\t\t\tswitch m := rawMsg.(type) {\n\t\t\tcase *msg.Ping:\n\t\t\t\txl.Tracef(\"udp work conn get ping message\")\n\t\t\t\tcontinue\n\t\t\tcase *msg.UDPPacket:\n\t\t\t\tif errRet := errors.PanicToError(func() {\n\t\t\t\t\txl.Tracef(\"get udp message from workConn, len: %d\", len(m.Content))\n\t\t\t\t\tpxy.readCh <- m\n\t\t\t\t\tmetrics.Server.AddTrafficOut(\n\t\t\t\t\t\tpxy.GetName(),\n\t\t\t\t\t\tpxy.GetConfigurer().GetBaseConfig().Type,\n\t\t\t\t\t\tint64(len(m.Content)),\n\t\t\t\t\t)\n\t\t\t\t}); errRet != nil {\n\t\t\t\t\tconn.Close()\n\t\t\t\t\txl.Infof(\"reader goroutine for udp work connection closed\")\n\t\t\t\t\treturn\n\t\t\t\t}\n\t\t\t}\n\t\t}\n\t}\n\n\t// send message to workConn\n\tworkConnSenderFn := func(conn net.Conn, ctx context.Context) {\n\t\tvar errRet error\n\t\tfor {\n\t\t\tselect {\n\t\t\tcase udpMsg, ok := <-pxy.sendCh:\n\t\t\t\tif !ok {\n\t\t\t\t\txl.Infof(\"sender goroutine for udp work connection closed\")\n\t\t\t\t\treturn\n\t\t\t\t}\n\t\t\t\tif errRet = msg.WriteMsg(conn, udpMsg); errRet != nil {\n\t\t\t\t\txl.Infof(\"sender goroutine for udp work connection closed: %v\", errRet)\n\t\t\t\t\tconn.Close()\n\t\t\t\t\treturn\n\t\t\t\t}\n\t\t\t\txl.Tracef(\"send message to udp workConn, len: %d\", len(udpMsg.Content))\n\t\t\t\tmetrics.Server.AddTrafficIn(\n\t\t\t\t\tpxy.GetName(),\n\t\t\t\t\tpxy.GetConfigurer().GetBaseConfig().Type,\n\t\t\t\t\tint64(len(udpMsg.Content)),\n\t\t\t\t)\n\t\t\t\tcontinue\n\t\t\tcase <-ctx.Done():\n\t\t\t\txl.Infof(\"sender goroutine for udp work connection closed\")\n\t\t\t\treturn\n\t\t\t}\n\t\t}\n\t}\n\n\tgo func() {\n\t\t// Sleep a while for waiting control send the NewProxyResp to client.\n\t\ttime.Sleep(500 * time.Millisecond)\n\t\tfor {\n\t\t\tworkConn, err := pxy.GetWorkConnFromPool(nil, nil)\n\t\t\tif err != nil {\n\t\t\t\ttime.Sleep(1 * time.Second)\n\t\t\t\t// check if proxy is closed\n\t\t\t\tselect {\n\t\t\t\tcase _, ok := <-pxy.checkCloseCh:\n\t\t\t\t\tif !ok {\n\t\t\t\t\t\treturn\n\t\t\t\t\t}\n\t\t\t\tdefault:\n\t\t\t\t}\n\t\t\t\tcontinue\n\t\t\t}\n\t\t\t// close the old workConn and replace it with a new one\n\t\t\tif pxy.workConn != nil {\n\t\t\t\tpxy.workConn.Close()\n\t\t\t}\n\n\t\t\tvar rwc io.ReadWriteCloser = workConn\n\t\t\tif pxy.cfg.Transport.UseEncryption {\n\t\t\t\trwc, err = libio.WithEncryption(rwc, pxy.encryptionKey)\n\t\t\t\tif err != nil {\n\t\t\t\t\txl.Errorf(\"create encryption stream error: %v\", err)\n\t\t\t\t\tworkConn.Close()\n\t\t\t\t\tcontinue\n\t\t\t\t}\n\t\t\t}\n\t\t\tif pxy.cfg.Transport.UseCompression {\n\t\t\t\trwc = libio.WithCompression(rwc)\n\t\t\t}\n\n\t\t\tif pxy.GetLimiter() != nil {\n\t\t\t\trwc = libio.WrapReadWriteCloser(limit.NewReader(rwc, pxy.GetLimiter()), limit.NewWriter(rwc, pxy.GetLimiter()), func() error {\n\t\t\t\t\treturn rwc.Close()\n\t\t\t\t})\n\t\t\t}\n\n\t\t\tpxy.workConn = netpkg.WrapReadWriteCloserToConn(rwc, workConn)\n\t\t\tctx, cancel := context.WithCancel(context.Background())\n\t\t\tgo workConnReaderFn(pxy.workConn)\n\t\t\tgo workConnSenderFn(pxy.workConn, ctx)\n\t\t\t_, ok := <-pxy.checkCloseCh\n\t\t\tcancel()\n\t\t\tif !ok {\n\t\t\t\treturn\n\t\t\t}\n\t\t}\n\t}()\n\n\t// Read from user connections and send wrapped udp message to sendCh (forwarded by workConn).\n\t// Client will transfor udp message to local udp service and waiting for response for a while.\n\t// Response will be wrapped to be forwarded by work connection to server.\n\t// Close readCh and sendCh at the end.\n\tgo func() {\n\t\tudp.ForwardUserConn(udpConn, pxy.readCh, pxy.sendCh, int(pxy.serverCfg.UDPPacketSize))\n\t\tpxy.Close()\n\t}()\n\treturn remoteAddr, nil\n}\n\nfunc (pxy *UDPProxy) Close() {\n\tpxy.mu.Lock()\n\tdefer pxy.mu.Unlock()\n\tif !pxy.isClosed {\n\t\tpxy.isClosed = true\n\n\t\tpxy.BaseProxy.Close()\n\t\tif pxy.workConn != nil {\n\t\t\tpxy.workConn.Close()\n\t\t}\n\t\tpxy.udpConn.Close()\n\n\t\t// all channels only closed here\n\t\tclose(pxy.checkCloseCh)\n\t\tclose(pxy.readCh)\n\t\tclose(pxy.sendCh)\n\t}\n\tpxy.rc.UDPPortManager.Release(pxy.realBindPort)\n}\n"
  },
  {
    "path": "server/proxy/xtcp.go",
    "content": "// Copyright 2019 fatedier, fatedier@gmail.com\n//\n// Licensed under the Apache License, Version 2.0 (the \"License\");\n// you may not use this file except in compliance with the License.\n// You may obtain a copy of the License at\n//\n//     http://www.apache.org/licenses/LICENSE-2.0\n//\n// Unless required by applicable law or agreed to in writing, software\n// distributed under the License is distributed on an \"AS IS\" BASIS,\n// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.\n// See the License for the specific language governing permissions and\n// limitations under the License.\n\npackage proxy\n\nimport (\n\t\"fmt\"\n\t\"reflect\"\n\t\"sync\"\n\n\tv1 \"github.com/fatedier/frp/pkg/config/v1\"\n\t\"github.com/fatedier/frp/pkg/msg\"\n)\n\nfunc init() {\n\tRegisterProxyFactory(reflect.TypeFor[*v1.XTCPProxyConfig](), NewXTCPProxy)\n}\n\ntype XTCPProxy struct {\n\t*BaseProxy\n\tcfg *v1.XTCPProxyConfig\n\n\tcloseCh   chan struct{}\n\tcloseOnce sync.Once\n}\n\nfunc NewXTCPProxy(baseProxy *BaseProxy) Proxy {\n\tunwrapped, ok := baseProxy.GetConfigurer().(*v1.XTCPProxyConfig)\n\tif !ok {\n\t\treturn nil\n\t}\n\treturn &XTCPProxy{\n\t\tBaseProxy: baseProxy,\n\t\tcfg:       unwrapped,\n\t\tcloseCh:   make(chan struct{}),\n\t}\n}\n\nfunc (pxy *XTCPProxy) Run() (remoteAddr string, err error) {\n\txl := pxy.xl\n\n\tif pxy.rc.NatHoleController == nil {\n\t\terr = fmt.Errorf(\"xtcp is not supported in frps\")\n\t\treturn\n\t}\n\tallowUsers := pxy.cfg.AllowUsers\n\t// if allowUsers is empty, only allow same user from proxy\n\tif len(allowUsers) == 0 {\n\t\tallowUsers = []string{pxy.GetUserInfo().User}\n\t}\n\tsidCh, err := pxy.rc.NatHoleController.ListenClient(pxy.GetName(), pxy.cfg.Secretkey, allowUsers)\n\tif err != nil {\n\t\treturn \"\", err\n\t}\n\tgo func() {\n\t\tfor {\n\t\t\tselect {\n\t\t\tcase <-pxy.closeCh:\n\t\t\t\treturn\n\t\t\tcase sid := <-sidCh:\n\t\t\t\tworkConn, errRet := pxy.GetWorkConnFromPool(nil, nil)\n\t\t\t\tif errRet != nil {\n\t\t\t\t\tcontinue\n\t\t\t\t}\n\t\t\t\tm := &msg.NatHoleSid{\n\t\t\t\t\tSid: sid,\n\t\t\t\t}\n\t\t\t\terrRet = msg.WriteMsg(workConn, m)\n\t\t\t\tif errRet != nil {\n\t\t\t\t\txl.Warnf(\"write nat hole sid package error, %v\", errRet)\n\t\t\t\t}\n\t\t\t\tworkConn.Close()\n\t\t\t}\n\t\t}\n\t}()\n\treturn\n}\n\nfunc (pxy *XTCPProxy) Close() {\n\tpxy.closeOnce.Do(func() {\n\t\tpxy.BaseProxy.Close()\n\t\tpxy.rc.NatHoleController.CloseClient(pxy.GetName())\n\t\tclose(pxy.closeCh)\n\t})\n}\n"
  },
  {
    "path": "server/registry/registry.go",
    "content": "// Copyright 2025 The frp Authors\n//\n// Licensed under the Apache License, Version 2.0 (the \"License\");\n// you may not use this file except in compliance with the License.\n// You may obtain a copy of the License at\n//\n//     http://www.apache.org/licenses/LICENSE-2.0\n//\n// Unless required by applicable law or agreed to in writing, software\n// distributed under the License is distributed on an \"AS IS\" BASIS,\n// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.\n// See the License for the specific language governing permissions and\n// limitations under the License.\n\npackage registry\n\nimport (\n\t\"fmt\"\n\t\"sync\"\n\t\"time\"\n)\n\n// ClientInfo captures metadata about a connected frpc instance.\ntype ClientInfo struct {\n\tKey              string\n\tUser             string\n\tRawClientID      string\n\tRunID            string\n\tHostname         string\n\tIP               string\n\tVersion          string\n\tFirstConnectedAt time.Time\n\tLastConnectedAt  time.Time\n\tDisconnectedAt   time.Time\n\tOnline           bool\n}\n\n// ClientRegistry keeps track of active clients keyed by \"{user}.{clientID}\" (runID fallback when raw clientID is empty).\n// Entries without an explicit raw clientID are removed on disconnect to avoid stale offline records.\ntype ClientRegistry struct {\n\tmu       sync.RWMutex\n\tclients  map[string]*ClientInfo\n\trunIndex map[string]string\n}\n\nfunc NewClientRegistry() *ClientRegistry {\n\treturn &ClientRegistry{\n\t\tclients:  make(map[string]*ClientInfo),\n\t\trunIndex: make(map[string]string),\n\t}\n}\n\n// Register stores/updates metadata for a client and returns the registry key plus whether it conflicts with an online client.\nfunc (cr *ClientRegistry) Register(user, rawClientID, runID, hostname, version, remoteAddr string) (key string, conflict bool) {\n\tif runID == \"\" {\n\t\treturn \"\", false\n\t}\n\n\teffectiveID := rawClientID\n\tif effectiveID == \"\" {\n\t\teffectiveID = runID\n\t}\n\tkey = cr.composeClientKey(user, effectiveID)\n\tenforceUnique := rawClientID != \"\"\n\n\tnow := time.Now()\n\tcr.mu.Lock()\n\tdefer cr.mu.Unlock()\n\n\tinfo, exists := cr.clients[key]\n\tif enforceUnique && exists && info.Online && info.RunID != \"\" && info.RunID != runID {\n\t\treturn key, true\n\t}\n\n\tif !exists {\n\t\tinfo = &ClientInfo{\n\t\t\tKey:              key,\n\t\t\tUser:             user,\n\t\t\tFirstConnectedAt: now,\n\t\t}\n\t\tcr.clients[key] = info\n\t} else if info.RunID != \"\" {\n\t\tdelete(cr.runIndex, info.RunID)\n\t}\n\n\tinfo.RawClientID = rawClientID\n\tinfo.RunID = runID\n\tinfo.Hostname = hostname\n\tinfo.IP = remoteAddr\n\tinfo.Version = version\n\tif info.FirstConnectedAt.IsZero() {\n\t\tinfo.FirstConnectedAt = now\n\t}\n\tinfo.LastConnectedAt = now\n\tinfo.DisconnectedAt = time.Time{}\n\tinfo.Online = true\n\n\tcr.runIndex[runID] = key\n\treturn key, false\n}\n\n// MarkOfflineByRunID marks the client as offline when the corresponding control disconnects.\nfunc (cr *ClientRegistry) MarkOfflineByRunID(runID string) {\n\tcr.mu.Lock()\n\tdefer cr.mu.Unlock()\n\n\tkey, ok := cr.runIndex[runID]\n\tif !ok {\n\t\treturn\n\t}\n\tif info, ok := cr.clients[key]; ok && info.RunID == runID {\n\t\tif info.RawClientID == \"\" {\n\t\t\tdelete(cr.clients, key)\n\t\t} else {\n\t\t\tinfo.RunID = \"\"\n\t\t\tinfo.Online = false\n\t\t\tnow := time.Now()\n\t\t\tinfo.DisconnectedAt = now\n\t\t}\n\t}\n\tdelete(cr.runIndex, runID)\n}\n\n// List returns a snapshot of all known clients.\nfunc (cr *ClientRegistry) List() []ClientInfo {\n\tcr.mu.RLock()\n\tdefer cr.mu.RUnlock()\n\n\tresult := make([]ClientInfo, 0, len(cr.clients))\n\tfor _, info := range cr.clients {\n\t\tresult = append(result, *info)\n\t}\n\treturn result\n}\n\n// GetByKey retrieves a client by its composite key ({user}.{clientID} with runID fallback).\nfunc (cr *ClientRegistry) GetByKey(key string) (ClientInfo, bool) {\n\tcr.mu.RLock()\n\tdefer cr.mu.RUnlock()\n\n\tinfo, ok := cr.clients[key]\n\tif !ok {\n\t\treturn ClientInfo{}, false\n\t}\n\treturn *info, true\n}\n\n// ClientID returns the resolved client identifier for external use.\nfunc (info ClientInfo) ClientID() string {\n\tif info.RawClientID != \"\" {\n\t\treturn info.RawClientID\n\t}\n\treturn info.RunID\n}\n\nfunc (cr *ClientRegistry) composeClientKey(user, id string) string {\n\tswitch {\n\tcase user == \"\":\n\t\treturn id\n\tcase id == \"\":\n\t\treturn user\n\tdefault:\n\t\treturn fmt.Sprintf(\"%s.%s\", user, id)\n\t}\n}\n"
  },
  {
    "path": "server/service.go",
    "content": "// Copyright 2017 fatedier, fatedier@gmail.com\n//\n// Licensed under the Apache License, Version 2.0 (the \"License\");\n// you may not use this file except in compliance with the License.\n// You may obtain a copy of the License at\n//\n//     http://www.apache.org/licenses/LICENSE-2.0\n//\n// Unless required by applicable law or agreed to in writing, software\n// distributed under the License is distributed on an \"AS IS\" BASIS,\n// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.\n// See the License for the specific language governing permissions and\n// limitations under the License.\n\npackage server\n\nimport (\n\t\"bytes\"\n\t\"context\"\n\t\"crypto/tls\"\n\t\"fmt\"\n\t\"net\"\n\t\"net/http\"\n\t\"os\"\n\t\"strconv\"\n\t\"time\"\n\n\t\"github.com/fatedier/golib/crypto\"\n\t\"github.com/fatedier/golib/net/mux\"\n\tfmux \"github.com/hashicorp/yamux\"\n\tquic \"github.com/quic-go/quic-go\"\n\t\"github.com/samber/lo\"\n\n\t\"github.com/fatedier/frp/pkg/auth\"\n\tv1 \"github.com/fatedier/frp/pkg/config/v1\"\n\tmodelmetrics \"github.com/fatedier/frp/pkg/metrics\"\n\t\"github.com/fatedier/frp/pkg/msg\"\n\t\"github.com/fatedier/frp/pkg/nathole\"\n\tplugin \"github.com/fatedier/frp/pkg/plugin/server\"\n\t\"github.com/fatedier/frp/pkg/ssh\"\n\t\"github.com/fatedier/frp/pkg/transport\"\n\thttppkg \"github.com/fatedier/frp/pkg/util/http\"\n\t\"github.com/fatedier/frp/pkg/util/log\"\n\tnetpkg \"github.com/fatedier/frp/pkg/util/net\"\n\t\"github.com/fatedier/frp/pkg/util/tcpmux\"\n\t\"github.com/fatedier/frp/pkg/util/util\"\n\t\"github.com/fatedier/frp/pkg/util/version\"\n\t\"github.com/fatedier/frp/pkg/util/vhost\"\n\t\"github.com/fatedier/frp/pkg/util/xlog\"\n\t\"github.com/fatedier/frp/server/controller\"\n\t\"github.com/fatedier/frp/server/group\"\n\t\"github.com/fatedier/frp/server/metrics\"\n\t\"github.com/fatedier/frp/server/ports\"\n\t\"github.com/fatedier/frp/server/proxy\"\n\t\"github.com/fatedier/frp/server/registry\"\n\t\"github.com/fatedier/frp/server/visitor\"\n)\n\nconst (\n\tconnReadTimeout       time.Duration = 10 * time.Second\n\tvhostReadWriteTimeout time.Duration = 30 * time.Second\n)\n\nfunc init() {\n\tcrypto.DefaultSalt = \"frp\"\n\t// Disable quic-go's receive buffer warning.\n\tos.Setenv(\"QUIC_GO_DISABLE_RECEIVE_BUFFER_WARNING\", \"true\")\n\t// Disable quic-go's ECN support by default. It may cause issues on certain operating systems.\n\tif os.Getenv(\"QUIC_GO_DISABLE_ECN\") == \"\" {\n\t\tos.Setenv(\"QUIC_GO_DISABLE_ECN\", \"true\")\n\t}\n}\n\n// Server service\ntype Service struct {\n\t// Dispatch connections to different handlers listen on same port\n\tmuxer *mux.Mux\n\n\t// Accept connections from client\n\tlistener net.Listener\n\n\t// Accept connections using kcp\n\tkcpListener net.Listener\n\n\t// Accept connections using quic\n\tquicListener *quic.Listener\n\n\t// Accept connections using websocket\n\twebsocketListener net.Listener\n\n\t// Accept frp tls connections\n\ttlsListener net.Listener\n\n\t// Accept pipe connections from ssh tunnel gateway\n\tsshTunnelListener *netpkg.InternalListener\n\n\t// Manage all controllers\n\tctlManager *ControlManager\n\n\t// Track logical clients keyed by user.clientID (runID fallback when raw clientID is empty).\n\tclientRegistry *registry.ClientRegistry\n\n\t// Manage all proxies\n\tpxyManager *proxy.Manager\n\n\t// Manage all plugins\n\tpluginManager *plugin.Manager\n\n\t// HTTP vhost router\n\thttpVhostRouter *vhost.Routers\n\n\t// All resource managers and controllers\n\trc *controller.ResourceController\n\n\t// web server for dashboard UI and apis\n\twebServer *httppkg.Server\n\n\tsshTunnelGateway *ssh.Gateway\n\n\t// Auth runtime and encryption materials\n\tauth *auth.ServerAuth\n\n\ttlsConfig *tls.Config\n\n\tcfg *v1.ServerConfig\n\n\t// service context\n\tctx context.Context\n\t// call cancel to stop service\n\tcancel context.CancelFunc\n}\n\nfunc NewService(cfg *v1.ServerConfig) (*Service, error) {\n\ttlsConfig, err := transport.NewServerTLSConfig(\n\t\tcfg.Transport.TLS.CertFile,\n\t\tcfg.Transport.TLS.KeyFile,\n\t\tcfg.Transport.TLS.TrustedCaFile)\n\tif err != nil {\n\t\treturn nil, err\n\t}\n\n\tvar webServer *httppkg.Server\n\tif cfg.WebServer.Port > 0 {\n\t\tws, err := httppkg.NewServer(cfg.WebServer)\n\t\tif err != nil {\n\t\t\treturn nil, err\n\t\t}\n\t\twebServer = ws\n\n\t\tmodelmetrics.EnableMem()\n\t\tif cfg.EnablePrometheus {\n\t\t\tmodelmetrics.EnablePrometheus()\n\t\t}\n\t}\n\n\tauthRuntime, err := auth.BuildServerAuth(&cfg.Auth)\n\tif err != nil {\n\t\treturn nil, err\n\t}\n\n\tsvr := &Service{\n\t\tctlManager:     NewControlManager(),\n\t\tclientRegistry: registry.NewClientRegistry(),\n\t\tpxyManager:     proxy.NewManager(),\n\t\tpluginManager:  plugin.NewManager(),\n\t\trc: &controller.ResourceController{\n\t\t\tVisitorManager: visitor.NewManager(),\n\t\t\tTCPPortManager: ports.NewManager(\"tcp\", cfg.ProxyBindAddr, cfg.AllowPorts),\n\t\t\tUDPPortManager: ports.NewManager(\"udp\", cfg.ProxyBindAddr, cfg.AllowPorts),\n\t\t},\n\t\tsshTunnelListener: netpkg.NewInternalListener(),\n\t\thttpVhostRouter:   vhost.NewRouters(),\n\t\tauth:              authRuntime,\n\t\twebServer:         webServer,\n\t\ttlsConfig:         tlsConfig,\n\t\tcfg:               cfg,\n\t\tctx:               context.Background(),\n\t}\n\tif webServer != nil {\n\t\twebServer.RouteRegister(svr.registerRouteHandlers)\n\t}\n\n\t// Create tcpmux httpconnect multiplexer.\n\tif cfg.TCPMuxHTTPConnectPort > 0 {\n\t\tvar l net.Listener\n\t\taddress := net.JoinHostPort(cfg.ProxyBindAddr, strconv.Itoa(cfg.TCPMuxHTTPConnectPort))\n\t\tl, err = net.Listen(\"tcp\", address)\n\t\tif err != nil {\n\t\t\treturn nil, fmt.Errorf(\"create server listener error, %v\", err)\n\t\t}\n\n\t\tsvr.rc.TCPMuxHTTPConnectMuxer, err = tcpmux.NewHTTPConnectTCPMuxer(l, cfg.TCPMuxPassthrough, vhostReadWriteTimeout)\n\t\tif err != nil {\n\t\t\treturn nil, fmt.Errorf(\"create vhost tcpMuxer error, %v\", err)\n\t\t}\n\t\tlog.Infof(\"tcpmux httpconnect multiplexer listen on %s, passthrough: %v\", address, cfg.TCPMuxPassthrough)\n\t}\n\n\t// Init all plugins\n\tfor _, p := range cfg.HTTPPlugins {\n\t\tsvr.pluginManager.Register(plugin.NewHTTPPluginOptions(p))\n\t\tlog.Infof(\"plugin [%s] has been registered\", p.Name)\n\t}\n\tsvr.rc.PluginManager = svr.pluginManager\n\n\t// Init group controller\n\tsvr.rc.TCPGroupCtl = group.NewTCPGroupCtl(svr.rc.TCPPortManager)\n\n\t// Init HTTP group controller\n\tsvr.rc.HTTPGroupCtl = group.NewHTTPGroupController(svr.httpVhostRouter)\n\n\t// Init TCP mux group controller\n\tsvr.rc.TCPMuxGroupCtl = group.NewTCPMuxGroupCtl(svr.rc.TCPMuxHTTPConnectMuxer)\n\n\t// Init 404 not found page\n\tvhost.NotFoundPagePath = cfg.Custom404Page\n\n\tvar (\n\t\thttpMuxOn  bool\n\t\thttpsMuxOn bool\n\t)\n\tif cfg.BindAddr == cfg.ProxyBindAddr {\n\t\tif cfg.BindPort == cfg.VhostHTTPPort {\n\t\t\thttpMuxOn = true\n\t\t}\n\t\tif cfg.BindPort == cfg.VhostHTTPSPort {\n\t\t\thttpsMuxOn = true\n\t\t}\n\t}\n\n\t// Listen for accepting connections from client.\n\taddress := net.JoinHostPort(cfg.BindAddr, strconv.Itoa(cfg.BindPort))\n\tln, err := net.Listen(\"tcp\", address)\n\tif err != nil {\n\t\treturn nil, fmt.Errorf(\"create server listener error, %v\", err)\n\t}\n\n\tsvr.muxer = mux.NewMux(ln)\n\tsvr.muxer.SetKeepAlive(time.Duration(cfg.Transport.TCPKeepAlive) * time.Second)\n\tgo func() {\n\t\t_ = svr.muxer.Serve()\n\t}()\n\tln = svr.muxer.DefaultListener()\n\n\tsvr.listener = ln\n\tlog.Infof(\"frps tcp listen on %s\", address)\n\n\t// Listen for accepting connections from client using kcp protocol.\n\tif cfg.KCPBindPort > 0 {\n\t\taddress := net.JoinHostPort(cfg.BindAddr, strconv.Itoa(cfg.KCPBindPort))\n\t\tsvr.kcpListener, err = netpkg.ListenKcp(address)\n\t\tif err != nil {\n\t\t\treturn nil, fmt.Errorf(\"listen on kcp udp address %s error: %v\", address, err)\n\t\t}\n\t\tlog.Infof(\"frps kcp listen on udp %s\", address)\n\t}\n\n\tif cfg.QUICBindPort > 0 {\n\t\taddress := net.JoinHostPort(cfg.BindAddr, strconv.Itoa(cfg.QUICBindPort))\n\t\tquicTLSCfg := tlsConfig.Clone()\n\t\tquicTLSCfg.NextProtos = []string{\"frp\"}\n\t\tsvr.quicListener, err = quic.ListenAddr(address, quicTLSCfg, &quic.Config{\n\t\t\tMaxIdleTimeout:     time.Duration(cfg.Transport.QUIC.MaxIdleTimeout) * time.Second,\n\t\t\tMaxIncomingStreams: int64(cfg.Transport.QUIC.MaxIncomingStreams),\n\t\t\tKeepAlivePeriod:    time.Duration(cfg.Transport.QUIC.KeepalivePeriod) * time.Second,\n\t\t})\n\t\tif err != nil {\n\t\t\treturn nil, fmt.Errorf(\"listen on quic udp address %s error: %v\", address, err)\n\t\t}\n\t\tlog.Infof(\"frps quic listen on %s\", address)\n\t}\n\n\tif cfg.SSHTunnelGateway.BindPort > 0 {\n\t\tsshGateway, err := ssh.NewGateway(cfg.SSHTunnelGateway, cfg.BindAddr, svr.sshTunnelListener)\n\t\tif err != nil {\n\t\t\treturn nil, fmt.Errorf(\"create ssh gateway error: %v\", err)\n\t\t}\n\t\tsvr.sshTunnelGateway = sshGateway\n\t\tlog.Infof(\"frps sshTunnelGateway listen on port %d\", cfg.SSHTunnelGateway.BindPort)\n\t}\n\n\t// Listen for accepting connections from client using websocket protocol.\n\twebsocketPrefix := []byte(\"GET \" + netpkg.FrpWebsocketPath)\n\twebsocketLn := svr.muxer.Listen(0, uint32(len(websocketPrefix)), func(data []byte) bool {\n\t\treturn bytes.Equal(data, websocketPrefix)\n\t})\n\tsvr.websocketListener = netpkg.NewWebsocketListener(websocketLn)\n\n\t// Create http vhost muxer.\n\tif cfg.VhostHTTPPort > 0 {\n\t\trp := vhost.NewHTTPReverseProxy(vhost.HTTPReverseProxyOptions{\n\t\t\tResponseHeaderTimeoutS: cfg.VhostHTTPTimeout,\n\t\t}, svr.httpVhostRouter)\n\t\tsvr.rc.HTTPReverseProxy = rp\n\n\t\taddress := net.JoinHostPort(cfg.ProxyBindAddr, strconv.Itoa(cfg.VhostHTTPPort))\n\t\tserver := &http.Server{\n\t\t\tAddr:              address,\n\t\t\tHandler:           rp,\n\t\t\tReadHeaderTimeout: 60 * time.Second,\n\t\t}\n\t\tvar l net.Listener\n\t\tif httpMuxOn {\n\t\t\tl = svr.muxer.ListenHTTP(1)\n\t\t} else {\n\t\t\tl, err = net.Listen(\"tcp\", address)\n\t\t\tif err != nil {\n\t\t\t\treturn nil, fmt.Errorf(\"create vhost http listener error, %v\", err)\n\t\t\t}\n\t\t}\n\t\tgo func() {\n\t\t\t_ = server.Serve(l)\n\t\t}()\n\t\tlog.Infof(\"http service listen on %s\", address)\n\t}\n\n\t// Create https vhost muxer.\n\tif cfg.VhostHTTPSPort > 0 {\n\t\tvar l net.Listener\n\t\tif httpsMuxOn {\n\t\t\tl = svr.muxer.ListenHTTPS(1)\n\t\t} else {\n\t\t\taddress := net.JoinHostPort(cfg.ProxyBindAddr, strconv.Itoa(cfg.VhostHTTPSPort))\n\t\t\tl, err = net.Listen(\"tcp\", address)\n\t\t\tif err != nil {\n\t\t\t\treturn nil, fmt.Errorf(\"create server listener error, %v\", err)\n\t\t\t}\n\t\t\tlog.Infof(\"https service listen on %s\", address)\n\t\t}\n\n\t\tsvr.rc.VhostHTTPSMuxer, err = vhost.NewHTTPSMuxer(l, vhostReadWriteTimeout)\n\t\tif err != nil {\n\t\t\treturn nil, fmt.Errorf(\"create vhost httpsMuxer error, %v\", err)\n\t\t}\n\n\t\t// Init HTTPS group controller after HTTPSMuxer is created\n\t\tsvr.rc.HTTPSGroupCtl = group.NewHTTPSGroupController(svr.rc.VhostHTTPSMuxer)\n\t}\n\n\t// frp tls listener\n\tsvr.tlsListener = svr.muxer.Listen(2, 1, func(data []byte) bool {\n\t\t// tls first byte can be 0x16 only when vhost https port is not same with bind port\n\t\treturn int(data[0]) == netpkg.FRPTLSHeadByte || int(data[0]) == 0x16\n\t})\n\n\t// Create nat hole controller.\n\tnc, err := nathole.NewController(time.Duration(cfg.NatHoleAnalysisDataReserveHours) * time.Hour)\n\tif err != nil {\n\t\treturn nil, fmt.Errorf(\"create nat hole controller error, %v\", err)\n\t}\n\tsvr.rc.NatHoleController = nc\n\treturn svr, nil\n}\n\nfunc (svr *Service) Run(ctx context.Context) {\n\tctx, cancel := context.WithCancel(ctx)\n\tsvr.ctx = ctx\n\tsvr.cancel = cancel\n\n\t// run dashboard web server.\n\tif svr.webServer != nil {\n\t\tgo func() {\n\t\t\tlog.Infof(\"dashboard listen on %s\", svr.webServer.Address())\n\t\t\tif err := svr.webServer.Run(); err != nil {\n\t\t\t\tlog.Warnf(\"dashboard server exit with error: %v\", err)\n\t\t\t}\n\t\t}()\n\t}\n\n\tgo svr.HandleListener(svr.sshTunnelListener, true)\n\n\tif svr.kcpListener != nil {\n\t\tgo svr.HandleListener(svr.kcpListener, false)\n\t}\n\tif svr.quicListener != nil {\n\t\tgo svr.HandleQUICListener(svr.quicListener)\n\t}\n\tgo svr.HandleListener(svr.websocketListener, false)\n\tgo svr.HandleListener(svr.tlsListener, false)\n\n\tif svr.rc.NatHoleController != nil {\n\t\tgo svr.rc.NatHoleController.CleanWorker(svr.ctx)\n\t}\n\n\tif svr.sshTunnelGateway != nil {\n\t\tgo svr.sshTunnelGateway.Run()\n\t}\n\n\tsvr.HandleListener(svr.listener, false)\n\n\t<-svr.ctx.Done()\n\t// service context may not be canceled by svr.Close(), we should call it here to release resources\n\tif svr.listener != nil {\n\t\tsvr.Close()\n\t}\n}\n\nfunc (svr *Service) Close() error {\n\tif svr.kcpListener != nil {\n\t\tsvr.kcpListener.Close()\n\t}\n\tif svr.quicListener != nil {\n\t\tsvr.quicListener.Close()\n\t}\n\tif svr.websocketListener != nil {\n\t\tsvr.websocketListener.Close()\n\t}\n\tif svr.tlsListener != nil {\n\t\tsvr.tlsListener.Close()\n\t}\n\tif svr.sshTunnelListener != nil {\n\t\tsvr.sshTunnelListener.Close()\n\t}\n\tif svr.listener != nil {\n\t\tsvr.listener.Close()\n\t}\n\tif svr.webServer != nil {\n\t\tsvr.webServer.Close()\n\t}\n\tif svr.sshTunnelGateway != nil {\n\t\tsvr.sshTunnelGateway.Close()\n\t}\n\tsvr.rc.Close()\n\tsvr.muxer.Close()\n\tsvr.ctlManager.Close()\n\tif svr.cancel != nil {\n\t\tsvr.cancel()\n\t}\n\treturn nil\n}\n\nfunc (svr *Service) handleConnection(ctx context.Context, conn net.Conn, internal bool) {\n\txl := xlog.FromContextSafe(ctx)\n\n\tvar (\n\t\trawMsg msg.Message\n\t\terr    error\n\t)\n\n\t_ = conn.SetReadDeadline(time.Now().Add(connReadTimeout))\n\tif rawMsg, err = msg.ReadMsg(conn); err != nil {\n\t\tlog.Tracef(\"failed to read message: %v\", err)\n\t\tconn.Close()\n\t\treturn\n\t}\n\t_ = conn.SetReadDeadline(time.Time{})\n\n\tswitch m := rawMsg.(type) {\n\tcase *msg.Login:\n\t\t// server plugin hook\n\t\tcontent := &plugin.LoginContent{\n\t\t\tLogin:         *m,\n\t\t\tClientAddress: conn.RemoteAddr().String(),\n\t\t}\n\t\tretContent, err := svr.pluginManager.Login(content)\n\t\tif err == nil {\n\t\t\tm = &retContent.Login\n\t\t\terr = svr.RegisterControl(conn, m, internal)\n\t\t}\n\n\t\t// If login failed, send error message there.\n\t\t// Otherwise send success message in control's work goroutine.\n\t\tif err != nil {\n\t\t\txl.Warnf(\"register control error: %v\", err)\n\t\t\t_ = msg.WriteMsg(conn, &msg.LoginResp{\n\t\t\t\tVersion: version.Full(),\n\t\t\t\tError:   util.GenerateResponseErrorString(\"register control error\", err, lo.FromPtr(svr.cfg.DetailedErrorsToClient)),\n\t\t\t})\n\t\t\tconn.Close()\n\t\t}\n\tcase *msg.NewWorkConn:\n\t\tif err := svr.RegisterWorkConn(conn, m); err != nil {\n\t\t\tconn.Close()\n\t\t}\n\tcase *msg.NewVisitorConn:\n\t\tif err = svr.RegisterVisitorConn(conn, m); err != nil {\n\t\t\txl.Warnf(\"register visitor conn error: %v\", err)\n\t\t\t_ = msg.WriteMsg(conn, &msg.NewVisitorConnResp{\n\t\t\t\tProxyName: m.ProxyName,\n\t\t\t\tError:     util.GenerateResponseErrorString(\"register visitor conn error\", err, lo.FromPtr(svr.cfg.DetailedErrorsToClient)),\n\t\t\t})\n\t\t\tconn.Close()\n\t\t} else {\n\t\t\t_ = msg.WriteMsg(conn, &msg.NewVisitorConnResp{\n\t\t\t\tProxyName: m.ProxyName,\n\t\t\t\tError:     \"\",\n\t\t\t})\n\t\t}\n\tdefault:\n\t\tlog.Warnf(\"error message type for the new connection [%s]\", conn.RemoteAddr().String())\n\t\tconn.Close()\n\t}\n}\n\n// HandleListener accepts connections from client and call handleConnection to handle them.\n// If internal is true, it means that this listener is used for internal communication like ssh tunnel gateway.\n// TODO(fatedier): Pass some parameters of listener/connection through context to avoid passing too many parameters.\nfunc (svr *Service) HandleListener(l net.Listener, internal bool) {\n\t// Listen for incoming connections from client.\n\tfor {\n\t\tc, err := l.Accept()\n\t\tif err != nil {\n\t\t\tlog.Warnf(\"listener for incoming connections from client closed\")\n\t\t\treturn\n\t\t}\n\t\t// inject xlog object into net.Conn context\n\t\txl := xlog.New()\n\t\tctx := context.Background()\n\n\t\tc = netpkg.NewContextConn(xlog.NewContext(ctx, xl), c)\n\n\t\tif !internal {\n\t\t\tlog.Tracef(\"start check TLS connection...\")\n\t\t\toriginConn := c\n\t\t\tforceTLS := svr.cfg.Transport.TLS.Force\n\t\t\tvar isTLS, custom bool\n\t\t\tc, isTLS, custom, err = netpkg.CheckAndEnableTLSServerConnWithTimeout(c, svr.tlsConfig, forceTLS, connReadTimeout)\n\t\t\tif err != nil {\n\t\t\t\tlog.Warnf(\"checkAndEnableTLSServerConnWithTimeout error: %v\", err)\n\t\t\t\toriginConn.Close()\n\t\t\t\tcontinue\n\t\t\t}\n\t\t\tlog.Tracef(\"check TLS connection success, isTLS: %v custom: %v internal: %v\", isTLS, custom, internal)\n\t\t}\n\n\t\t// Start a new goroutine to handle connection.\n\t\tgo func(ctx context.Context, frpConn net.Conn) {\n\t\t\tif lo.FromPtr(svr.cfg.Transport.TCPMux) && !internal {\n\t\t\t\tfmuxCfg := fmux.DefaultConfig()\n\t\t\t\tfmuxCfg.KeepAliveInterval = time.Duration(svr.cfg.Transport.TCPMuxKeepaliveInterval) * time.Second\n\t\t\t\t// Use trace level for yamux logs\n\t\t\t\tfmuxCfg.LogOutput = xlog.NewTraceWriter(xlog.FromContextSafe(ctx))\n\t\t\t\tfmuxCfg.MaxStreamWindowSize = 6 * 1024 * 1024\n\t\t\t\tsession, err := fmux.Server(frpConn, fmuxCfg)\n\t\t\t\tif err != nil {\n\t\t\t\t\tlog.Warnf(\"failed to create mux connection: %v\", err)\n\t\t\t\t\tfrpConn.Close()\n\t\t\t\t\treturn\n\t\t\t\t}\n\n\t\t\t\tfor {\n\t\t\t\t\tstream, err := session.AcceptStream()\n\t\t\t\t\tif err != nil {\n\t\t\t\t\t\tlog.Debugf(\"accept new mux stream error: %v\", err)\n\t\t\t\t\t\tsession.Close()\n\t\t\t\t\t\treturn\n\t\t\t\t\t}\n\t\t\t\t\tgo svr.handleConnection(ctx, stream, internal)\n\t\t\t\t}\n\t\t\t} else {\n\t\t\t\tsvr.handleConnection(ctx, frpConn, internal)\n\t\t\t}\n\t\t}(ctx, c)\n\t}\n}\n\nfunc (svr *Service) HandleQUICListener(l *quic.Listener) {\n\t// Listen for incoming connections from client.\n\tfor {\n\t\tc, err := l.Accept(context.Background())\n\t\tif err != nil {\n\t\t\tlog.Warnf(\"quic listener for incoming connections from client closed\")\n\t\t\treturn\n\t\t}\n\t\t// Start a new goroutine to handle connection.\n\t\tgo func(ctx context.Context, frpConn *quic.Conn) {\n\t\t\tfor {\n\t\t\t\tstream, err := frpConn.AcceptStream(context.Background())\n\t\t\t\tif err != nil {\n\t\t\t\t\tlog.Debugf(\"accept new quic mux stream error: %v\", err)\n\t\t\t\t\t_ = frpConn.CloseWithError(0, \"\")\n\t\t\t\t\treturn\n\t\t\t\t}\n\t\t\t\tgo svr.handleConnection(ctx, netpkg.QuicStreamToNetConn(stream, frpConn), false)\n\t\t\t}\n\t\t}(context.Background(), c)\n\t}\n}\n\nfunc (svr *Service) RegisterControl(ctlConn net.Conn, loginMsg *msg.Login, internal bool) error {\n\t// If client's RunID is empty, it's a new client, we just create a new controller.\n\t// Otherwise, we check if there is one controller has the same run id. If so, we release previous controller and start new one.\n\tvar err error\n\tif loginMsg.RunID == \"\" {\n\t\tloginMsg.RunID, err = util.RandID()\n\t\tif err != nil {\n\t\t\treturn err\n\t\t}\n\t}\n\n\tctx := netpkg.NewContextFromConn(ctlConn)\n\txl := xlog.FromContextSafe(ctx)\n\txl.AppendPrefix(loginMsg.RunID)\n\tctx = xlog.NewContext(ctx, xl)\n\txl.Infof(\"client login info: ip [%s] version [%s] hostname [%s] os [%s] arch [%s]\",\n\t\tctlConn.RemoteAddr().String(), loginMsg.Version, loginMsg.Hostname, loginMsg.Os, loginMsg.Arch)\n\n\t// Check auth.\n\tauthVerifier := svr.auth.Verifier\n\tif internal && loginMsg.ClientSpec.AlwaysAuthPass {\n\t\tauthVerifier = auth.AlwaysPassVerifier\n\t}\n\tif err := authVerifier.VerifyLogin(loginMsg); err != nil {\n\t\treturn err\n\t}\n\n\tctl, err := NewControl(ctx, &SessionContext{\n\t\tRC:             svr.rc,\n\t\tPxyManager:     svr.pxyManager,\n\t\tPluginManager:  svr.pluginManager,\n\t\tAuthVerifier:   authVerifier,\n\t\tEncryptionKey:  svr.auth.EncryptionKey(),\n\t\tConn:           ctlConn,\n\t\tConnEncrypted:  !internal,\n\t\tLoginMsg:       loginMsg,\n\t\tServerCfg:      svr.cfg,\n\t\tClientRegistry: svr.clientRegistry,\n\t})\n\tif err != nil {\n\t\txl.Warnf(\"create new controller error: %v\", err)\n\t\t// don't return detailed errors to client\n\t\treturn fmt.Errorf(\"unexpected error when creating new controller\")\n\t}\n\n\tif oldCtl := svr.ctlManager.Add(loginMsg.RunID, ctl); oldCtl != nil {\n\t\toldCtl.WaitClosed()\n\t}\n\n\tremoteAddr := ctlConn.RemoteAddr().String()\n\tif host, _, err := net.SplitHostPort(remoteAddr); err == nil {\n\t\tremoteAddr = host\n\t}\n\t_, conflict := svr.clientRegistry.Register(loginMsg.User, loginMsg.ClientID, loginMsg.RunID, loginMsg.Hostname, loginMsg.Version, remoteAddr)\n\tif conflict {\n\t\tsvr.ctlManager.Del(loginMsg.RunID, ctl)\n\t\tctl.Close()\n\t\treturn fmt.Errorf(\"client_id [%s] for user [%s] is already online\", loginMsg.ClientID, loginMsg.User)\n\t}\n\n\tctl.Start()\n\n\t// for statistics\n\tmetrics.Server.NewClient()\n\n\tgo func() {\n\t\t// block until control closed\n\t\tctl.WaitClosed()\n\t\tsvr.ctlManager.Del(loginMsg.RunID, ctl)\n\t}()\n\treturn nil\n}\n\n// RegisterWorkConn register a new work connection to control and proxies need it.\nfunc (svr *Service) RegisterWorkConn(workConn net.Conn, newMsg *msg.NewWorkConn) error {\n\txl := netpkg.NewLogFromConn(workConn)\n\tctl, exist := svr.ctlManager.GetByID(newMsg.RunID)\n\tif !exist {\n\t\txl.Warnf(\"no client control found for run id [%s]\", newMsg.RunID)\n\t\treturn fmt.Errorf(\"no client control found for run id [%s]\", newMsg.RunID)\n\t}\n\t// server plugin hook\n\tcontent := &plugin.NewWorkConnContent{\n\t\tUser: plugin.UserInfo{\n\t\t\tUser:  ctl.sessionCtx.LoginMsg.User,\n\t\t\tMetas: ctl.sessionCtx.LoginMsg.Metas,\n\t\t\tRunID: ctl.sessionCtx.LoginMsg.RunID,\n\t\t},\n\t\tNewWorkConn: *newMsg,\n\t}\n\tretContent, err := svr.pluginManager.NewWorkConn(content)\n\tif err == nil {\n\t\tnewMsg = &retContent.NewWorkConn\n\t\t// Check auth.\n\t\terr = ctl.sessionCtx.AuthVerifier.VerifyNewWorkConn(newMsg)\n\t}\n\tif err != nil {\n\t\txl.Warnf(\"invalid NewWorkConn with run id [%s]\", newMsg.RunID)\n\t\t_ = msg.WriteMsg(workConn, &msg.StartWorkConn{\n\t\t\tError: util.GenerateResponseErrorString(\"invalid NewWorkConn\", err, lo.FromPtr(svr.cfg.DetailedErrorsToClient)),\n\t\t})\n\t\treturn fmt.Errorf(\"invalid NewWorkConn with run id [%s]\", newMsg.RunID)\n\t}\n\treturn ctl.RegisterWorkConn(workConn)\n}\n\nfunc (svr *Service) RegisterVisitorConn(visitorConn net.Conn, newMsg *msg.NewVisitorConn) error {\n\tvisitorUser := \"\"\n\t// TODO(deprecation): Compatible with old versions, can be without runID, user is empty. In later versions, it will be mandatory to include runID.\n\t// If runID is required, it is not compatible with versions prior to v0.50.0.\n\tif newMsg.RunID != \"\" {\n\t\tctl, exist := svr.ctlManager.GetByID(newMsg.RunID)\n\t\tif !exist {\n\t\t\treturn fmt.Errorf(\"no client control found for run id [%s]\", newMsg.RunID)\n\t\t}\n\t\tvisitorUser = ctl.sessionCtx.LoginMsg.User\n\t}\n\treturn svr.rc.VisitorManager.NewConn(newMsg.ProxyName, visitorConn, newMsg.Timestamp, newMsg.SignKey,\n\t\tnewMsg.UseEncryption, newMsg.UseCompression, visitorUser)\n}\n"
  },
  {
    "path": "server/visitor/visitor.go",
    "content": "// Copyright 2019 fatedier, fatedier@gmail.com\n//\n// Licensed under the Apache License, Version 2.0 (the \"License\");\n// you may not use this file except in compliance with the License.\n// You may obtain a copy of the License at\n//\n//     http://www.apache.org/licenses/LICENSE-2.0\n//\n// Unless required by applicable law or agreed to in writing, software\n// distributed under the License is distributed on an \"AS IS\" BASIS,\n// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.\n// See the License for the specific language governing permissions and\n// limitations under the License.\n\npackage visitor\n\nimport (\n\t\"fmt\"\n\t\"io\"\n\t\"net\"\n\t\"slices\"\n\t\"sync\"\n\n\tlibio \"github.com/fatedier/golib/io\"\n\n\tnetpkg \"github.com/fatedier/frp/pkg/util/net\"\n\t\"github.com/fatedier/frp/pkg/util/util\"\n)\n\ntype listenerBundle struct {\n\tl          *netpkg.InternalListener\n\tsk         string\n\tallowUsers []string\n}\n\n// Manager for visitor listeners.\ntype Manager struct {\n\tlisteners map[string]*listenerBundle\n\n\tmu sync.RWMutex\n}\n\nfunc NewManager() *Manager {\n\treturn &Manager{\n\t\tlisteners: make(map[string]*listenerBundle),\n\t}\n}\n\nfunc (vm *Manager) Listen(name string, sk string, allowUsers []string) (*netpkg.InternalListener, error) {\n\tvm.mu.Lock()\n\tdefer vm.mu.Unlock()\n\n\tif _, ok := vm.listeners[name]; ok {\n\t\treturn nil, fmt.Errorf(\"custom listener for [%s] is repeated\", name)\n\t}\n\n\tl := netpkg.NewInternalListener()\n\tvm.listeners[name] = &listenerBundle{\n\t\tl:          l,\n\t\tsk:         sk,\n\t\tallowUsers: allowUsers,\n\t}\n\treturn l, nil\n}\n\nfunc (vm *Manager) NewConn(name string, conn net.Conn, timestamp int64, signKey string,\n\tuseEncryption bool, useCompression bool, visitorUser string,\n) (err error) {\n\tvm.mu.RLock()\n\tdefer vm.mu.RUnlock()\n\n\tif l, ok := vm.listeners[name]; ok {\n\t\tif util.GetAuthKey(l.sk, timestamp) != signKey {\n\t\t\terr = fmt.Errorf(\"visitor connection of [%s] auth failed\", name)\n\t\t\treturn\n\t\t}\n\n\t\tif !slices.Contains(l.allowUsers, visitorUser) && !slices.Contains(l.allowUsers, \"*\") {\n\t\t\terr = fmt.Errorf(\"visitor connection of [%s] user [%s] not allowed\", name, visitorUser)\n\t\t\treturn\n\t\t}\n\n\t\tvar rwc io.ReadWriteCloser = conn\n\t\tif useEncryption {\n\t\t\tif rwc, err = libio.WithEncryption(rwc, []byte(l.sk)); err != nil {\n\t\t\t\terr = fmt.Errorf(\"create encryption connection failed: %v\", err)\n\t\t\t\treturn\n\t\t\t}\n\t\t}\n\t\tif useCompression {\n\t\t\trwc = libio.WithCompression(rwc)\n\t\t}\n\t\terr = l.l.PutConn(netpkg.WrapReadWriteCloserToConn(rwc, conn))\n\t} else {\n\t\terr = fmt.Errorf(\"custom listener for [%s] doesn't exist\", name)\n\t\treturn\n\t}\n\treturn\n}\n\nfunc (vm *Manager) CloseListener(name string) {\n\tvm.mu.Lock()\n\tdefer vm.mu.Unlock()\n\n\tdelete(vm.listeners, name)\n}\n"
  },
  {
    "path": "test/e2e/e2e.go",
    "content": "package e2e\n\nimport (\n\t\"testing\"\n\n\t\"github.com/onsi/ginkgo/v2\"\n\t\"github.com/onsi/gomega\"\n\n\t\"github.com/fatedier/frp/pkg/util/log\"\n\t\"github.com/fatedier/frp/test/e2e/framework\"\n)\n\nvar _ = ginkgo.SynchronizedBeforeSuite(func() []byte {\n\tsetupSuite()\n\treturn nil\n}, func(data []byte) {\n\t// Run on all Ginkgo nodes\n\tsetupSuitePerGinkgoNode()\n})\n\nvar _ = ginkgo.SynchronizedAfterSuite(func() {\n\tCleanupSuite()\n}, func() {\n\tAfterSuiteActions()\n})\n\n// RunE2ETests checks configuration parameters (specified through flags) and then runs\n// E2E tests using the Ginkgo runner.\n// If a \"report directory\" is specified, one or more JUnit test reports will be\n// generated in this directory, and cluster logs will also be saved.\n// This function is called on each Ginkgo node in parallel mode.\nfunc RunE2ETests(t *testing.T) {\n\tgomega.RegisterFailHandler(framework.Fail)\n\n\tsuiteConfig, reporterConfig := ginkgo.GinkgoConfiguration()\n\t// Turn on EmitSpecProgress to get spec progress (especially on interrupt)\n\tsuiteConfig.EmitSpecProgress = true\n\t// Randomize specs as well as suites\n\tsuiteConfig.RandomizeAllSpecs = true\n\n\tlog.Infof(\"starting e2e run %q on Ginkgo node %d of total %d\",\n\t\tframework.RunID, suiteConfig.ParallelProcess, suiteConfig.ParallelTotal)\n\tginkgo.RunSpecs(t, \"frp e2e suite\", suiteConfig, reporterConfig)\n}\n\n// setupSuite is the boilerplate that can be used to setup ginkgo test suites, on the SynchronizedBeforeSuite step.\n// There are certain operations we only want to run once per overall test invocation\n// (such as deleting old namespaces, or verifying that all system pods are running.\n// Because of the way Ginkgo runs tests in parallel, we must use SynchronizedBeforeSuite\n// to ensure that these operations only run on the first parallel Ginkgo node.\n//\n// This function takes two parameters: one function which runs on only the first Ginkgo node,\n// returning an opaque byte array, and then a second function which runs on all Ginkgo nodes,\n// accepting the byte array.\nfunc setupSuite() {\n\t// Run only on Ginkgo node 1\n}\n\n// setupSuitePerGinkgoNode is the boilerplate that can be used to setup ginkgo test suites, on the SynchronizedBeforeSuite step.\n// There are certain operations we only want to run once per overall test invocation on each Ginkgo node\n// such as making some global variables accessible to all parallel executions\n// Because of the way Ginkgo runs tests in parallel, we must use SynchronizedBeforeSuite\n// Ref: https://onsi.github.io/ginkgo/#parallel-specs\nfunc setupSuitePerGinkgoNode() {\n\t// config.GinkgoConfig.ParallelNode\n}\n"
  },
  {
    "path": "test/e2e/e2e_test.go",
    "content": "package e2e\n\nimport (\n\t\"flag\"\n\t\"fmt\"\n\t\"os\"\n\t\"testing\"\n\n\t_ \"github.com/onsi/ginkgo/v2\"\n\n\t\"github.com/fatedier/frp/pkg/util/log\"\n\t// test source\n\t\"github.com/fatedier/frp/test/e2e/framework\"\n\t_ \"github.com/fatedier/frp/test/e2e/legacy/basic\"\n\t_ \"github.com/fatedier/frp/test/e2e/legacy/features\"\n\t_ \"github.com/fatedier/frp/test/e2e/legacy/plugin\"\n\t_ \"github.com/fatedier/frp/test/e2e/v1/basic\"\n\t_ \"github.com/fatedier/frp/test/e2e/v1/features\"\n\t_ \"github.com/fatedier/frp/test/e2e/v1/plugin\"\n)\n\n// handleFlags sets up all flags and parses the command line.\nfunc handleFlags() {\n\tframework.RegisterCommonFlags(flag.CommandLine)\n\tflag.Parse()\n}\n\nfunc TestMain(m *testing.M) {\n\t// Register test flags, then parse flags.\n\thandleFlags()\n\n\tif err := framework.ValidateTestContext(&framework.TestContext); err != nil {\n\t\tfmt.Println(err)\n\t\tos.Exit(1)\n\t}\n\n\tlog.InitLogger(\"console\", framework.TestContext.LogLevel, 0, true)\n\tos.Exit(m.Run())\n}\n\nfunc TestE2E(t *testing.T) {\n\tRunE2ETests(t)\n}\n"
  },
  {
    "path": "test/e2e/examples.go",
    "content": "package e2e\n\nimport (\n\t\"fmt\"\n\n\t\"github.com/onsi/ginkgo/v2\"\n\n\t\"github.com/fatedier/frp/test/e2e/framework\"\n\t\"github.com/fatedier/frp/test/e2e/framework/consts\"\n)\n\nvar _ = ginkgo.Describe(\"[Feature: Example]\", func() {\n\tf := framework.NewDefaultFramework()\n\n\tginkgo.Describe(\"TCP\", func() {\n\t\tginkgo.It(\"Expose a TCP echo server\", func() {\n\t\t\tserverConf := consts.DefaultServerConfig\n\t\t\tclientConf := consts.DefaultClientConfig\n\n\t\t\tremotePort := f.AllocPort()\n\t\t\tclientConf += fmt.Sprintf(`\n\t\t\t[[proxies]]\n\t\t\tname = \"tcp\"\n\t\t\ttype = \"tcp\"\n\t\t\tlocalPort = {{ .%s }}\n\t\t\tremotePort = %d\n\t\t\t`, framework.TCPEchoServerPort, remotePort)\n\n\t\t\tf.RunProcesses(serverConf, []string{clientConf})\n\n\t\t\tframework.NewRequestExpect(f).Port(remotePort).Ensure()\n\t\t})\n\t})\n})\n"
  },
  {
    "path": "test/e2e/framework/cleanup.go",
    "content": "package framework\n\nimport (\n\t\"sync\"\n)\n\n// CleanupActionHandle is an integer pointer type for handling cleanup action\ntype CleanupActionHandle *int\n\ntype cleanupFuncHandle struct {\n\tactionHandle CleanupActionHandle\n\tactionHook   func()\n}\n\nvar (\n\tcleanupActionsLock sync.Mutex\n\tcleanupHookList    = []cleanupFuncHandle{}\n)\n\n// AddCleanupAction installs a function that will be called in the event of the\n// whole test being terminated.  This allows arbitrary pieces of the overall\n// test to hook into SynchronizedAfterSuite().\n// The hooks are called in last-in-first-out order.\nfunc AddCleanupAction(fn func()) CleanupActionHandle {\n\tp := CleanupActionHandle(new(int))\n\tcleanupActionsLock.Lock()\n\tdefer cleanupActionsLock.Unlock()\n\tc := cleanupFuncHandle{actionHandle: p, actionHook: fn}\n\tcleanupHookList = append([]cleanupFuncHandle{c}, cleanupHookList...)\n\treturn p\n}\n\n// RemoveCleanupAction removes a function that was installed by\n// AddCleanupAction.\nfunc RemoveCleanupAction(p CleanupActionHandle) {\n\tcleanupActionsLock.Lock()\n\tdefer cleanupActionsLock.Unlock()\n\tfor i, item := range cleanupHookList {\n\t\tif item.actionHandle == p {\n\t\t\tcleanupHookList = append(cleanupHookList[:i], cleanupHookList[i+1:]...)\n\t\t\tbreak\n\t\t}\n\t}\n}\n\n// RunCleanupActions runs all functions installed by AddCleanupAction.  It does\n// not remove them (see RemoveCleanupAction) but it does run unlocked, so they\n// may remove themselves.\nfunc RunCleanupActions() {\n\tlist := []func(){}\n\tfunc() {\n\t\tcleanupActionsLock.Lock()\n\t\tdefer cleanupActionsLock.Unlock()\n\t\tfor _, p := range cleanupHookList {\n\t\t\tlist = append(list, p.actionHook)\n\t\t}\n\t}()\n\t// Run unlocked.\n\tfor _, fn := range list {\n\t\tfn()\n\t}\n}\n"
  },
  {
    "path": "test/e2e/framework/client.go",
    "content": "package framework\n\nimport (\n\tclientsdk \"github.com/fatedier/frp/pkg/sdk/client\"\n)\n\nfunc (f *Framework) APIClientForFrpc(port int) *clientsdk.Client {\n\treturn clientsdk.New(\"127.0.0.1\", port)\n}\n"
  },
  {
    "path": "test/e2e/framework/consts/consts.go",
    "content": "package consts\n\nimport (\n\t\"fmt\"\n\t\"time\"\n\n\t\"github.com/fatedier/frp/test/e2e/pkg/port\"\n)\n\nconst (\n\tTestString = \"frp is a fast reverse proxy to help you expose a local server behind a NAT or firewall to the internet.\"\n\n\tDefaultTimeout = 2 * time.Second\n)\n\nvar (\n\tPortServerName  string\n\tPortClientAdmin string\n\n\tDefaultServerConfig = `\nbindPort = {{ .%s }}\nlog.level = \"trace\"\n`\n\n\tDefaultClientConfig = `\nserverAddr = \"127.0.0.1\"\nserverPort = {{ .%s }}\nloginFailExit = false\nlog.level = \"trace\"\n`\n\n\tLegacyDefaultServerConfig = `\n\t[common]\n\tbind_port = {{ .%s }}\n\tlog_level = trace\n\t`\n\n\tLegacyDefaultClientConfig = `\n\t[common]\n\tserver_addr = 127.0.0.1\n\tserver_port = {{ .%s }}\n\tlogin_fail_exit = false\n\tlog_level = trace\n\t`\n)\n\nfunc init() {\n\tPortServerName = port.GenName(\"Server\")\n\tPortClientAdmin = port.GenName(\"ClientAdmin\")\n\tLegacyDefaultServerConfig = fmt.Sprintf(LegacyDefaultServerConfig, port.GenName(\"Server\"))\n\tLegacyDefaultClientConfig = fmt.Sprintf(LegacyDefaultClientConfig, port.GenName(\"Server\"))\n\n\tDefaultServerConfig = fmt.Sprintf(DefaultServerConfig, port.GenName(\"Server\"))\n\tDefaultClientConfig = fmt.Sprintf(DefaultClientConfig, port.GenName(\"Server\"))\n}\n"
  },
  {
    "path": "test/e2e/framework/expect.go",
    "content": "package framework\n\nimport (\n\t\"github.com/onsi/gomega\"\n)\n\n// ExpectEqual expects the specified two are the same, otherwise an exception raises\nfunc ExpectEqual(actual any, extra any, explain ...any) {\n\tgomega.ExpectWithOffset(1, actual).To(gomega.Equal(extra), explain...)\n}\n\n// ExpectEqualValues expects the specified two are the same, it not strict about type\nfunc ExpectEqualValues(actual any, extra any, explain ...any) {\n\tgomega.ExpectWithOffset(1, actual).To(gomega.BeEquivalentTo(extra), explain...)\n}\n\nfunc ExpectEqualValuesWithOffset(offset int, actual any, extra any, explain ...any) {\n\tgomega.ExpectWithOffset(1+offset, actual).To(gomega.BeEquivalentTo(extra), explain...)\n}\n\n// ExpectNotEqual expects the specified two are not the same, otherwise an exception raises\nfunc ExpectNotEqual(actual any, extra any, explain ...any) {\n\tgomega.ExpectWithOffset(1, actual).NotTo(gomega.Equal(extra), explain...)\n}\n\n// ExpectError expects an error happens, otherwise an exception raises\nfunc ExpectError(err error, explain ...any) {\n\tgomega.ExpectWithOffset(1, err).To(gomega.HaveOccurred(), explain...)\n}\n\nfunc ExpectErrorWithOffset(offset int, err error, explain ...any) {\n\tgomega.ExpectWithOffset(1+offset, err).To(gomega.HaveOccurred(), explain...)\n}\n\n// ExpectNoError checks if \"err\" is set, and if so, fails assertion while logging the error.\nfunc ExpectNoError(err error, explain ...any) {\n\tExpectNoErrorWithOffset(1, err, explain...)\n}\n\n// ExpectNoErrorWithOffset checks if \"err\" is set, and if so, fails assertion while logging the error at \"offset\" levels above its caller\n// (for example, for call chain f -> g -> ExpectNoErrorWithOffset(1, ...) error would be logged for \"f\").\nfunc ExpectNoErrorWithOffset(offset int, err error, explain ...any) {\n\tgomega.ExpectWithOffset(1+offset, err).NotTo(gomega.HaveOccurred(), explain...)\n}\n\nfunc ExpectContainSubstring(actual, substr string, explain ...any) {\n\tgomega.ExpectWithOffset(1, actual).To(gomega.ContainSubstring(substr), explain...)\n}\n\n// ExpectConsistOf expects actual contains precisely the extra elements.  The ordering of the elements does not matter.\nfunc ExpectConsistOf(actual any, extra any, explain ...any) {\n\tgomega.ExpectWithOffset(1, actual).To(gomega.ConsistOf(extra), explain...)\n}\n\nfunc ExpectContainElements(actual any, extra any, explain ...any) {\n\tgomega.ExpectWithOffset(1, actual).To(gomega.ContainElements(extra), explain...)\n}\n\nfunc ExpectNotContainElements(actual any, extra any, explain ...any) {\n\tgomega.ExpectWithOffset(1, actual).NotTo(gomega.ContainElements(extra), explain...)\n}\n\n// ExpectHaveKey expects the actual map has the key in the keyset\nfunc ExpectHaveKey(actual any, key any, explain ...any) {\n\tgomega.ExpectWithOffset(1, actual).To(gomega.HaveKey(key), explain...)\n}\n\n// ExpectEmpty expects actual is empty\nfunc ExpectEmpty(actual any, explain ...any) {\n\tgomega.ExpectWithOffset(1, actual).To(gomega.BeEmpty(), explain...)\n}\n\nfunc ExpectTrue(actual any, explain ...any) {\n\tgomega.ExpectWithOffset(1, actual).Should(gomega.BeTrue(), explain...)\n}\n\nfunc ExpectTrueWithOffset(offset int, actual any, explain ...any) {\n\tgomega.ExpectWithOffset(1+offset, actual).Should(gomega.BeTrue(), explain...)\n}\n"
  },
  {
    "path": "test/e2e/framework/framework.go",
    "content": "package framework\n\nimport (\n\t\"bytes\"\n\t\"fmt\"\n\t\"os\"\n\t\"path/filepath\"\n\t\"regexp\"\n\t\"strings\"\n\t\"text/template\"\n\n\t\"github.com/onsi/ginkgo/v2\"\n\n\t\"github.com/fatedier/frp/test/e2e/mock/server\"\n\t\"github.com/fatedier/frp/test/e2e/pkg/port\"\n\t\"github.com/fatedier/frp/test/e2e/pkg/process\"\n)\n\ntype Options struct {\n\tTotalParallelNode int\n\tCurrentNodeIndex  int\n\tFromPortIndex     int\n\tToPortIndex       int\n}\n\ntype Framework struct {\n\tTempDirectory string\n\n\t// ports used in this framework indexed by port name.\n\tusedPorts map[string]int\n\n\t// record ports allocated by this framework and release them after each test\n\tallocatedPorts []int\n\n\t// portAllocator to alloc port for this test case.\n\tportAllocator *port.Allocator\n\n\t// Multiple default mock servers used for e2e testing.\n\tmockServers *MockServers\n\n\t// To make sure that this framework cleans up after itself, no matter what,\n\t// we install a Cleanup action before each test and clear it after.  If we\n\t// should abort, the AfterSuite hook should run all Cleanup actions.\n\tcleanupHandle CleanupActionHandle\n\n\t// beforeEachStarted indicates that BeforeEach has started\n\tbeforeEachStarted bool\n\n\tserverConfPaths []string\n\tserverProcesses []*process.Process\n\tclientConfPaths []string\n\tclientProcesses []*process.Process\n\n\t// Manual registered mock servers.\n\tservers []server.Server\n\n\t// used to generate unique config file name.\n\tconfigFileIndex int64\n\n\t// envs used to start processes, the form is `key=value`.\n\tosEnvs []string\n}\n\nfunc NewDefaultFramework() *Framework {\n\tsuiteConfig, _ := ginkgo.GinkgoConfiguration()\n\toptions := Options{\n\t\tTotalParallelNode: suiteConfig.ParallelTotal,\n\t\tCurrentNodeIndex:  suiteConfig.ParallelProcess,\n\t\tFromPortIndex:     10000,\n\t\tToPortIndex:       30000,\n\t}\n\treturn NewFramework(options)\n}\n\nfunc NewFramework(opt Options) *Framework {\n\tf := &Framework{\n\t\tportAllocator: port.NewAllocator(opt.FromPortIndex, opt.ToPortIndex, opt.TotalParallelNode, opt.CurrentNodeIndex-1),\n\t\tusedPorts:     make(map[string]int),\n\t}\n\n\tginkgo.BeforeEach(f.BeforeEach)\n\tginkgo.AfterEach(f.AfterEach)\n\treturn f\n}\n\n// BeforeEach create a temp directory.\nfunc (f *Framework) BeforeEach() {\n\tf.beforeEachStarted = true\n\n\tf.cleanupHandle = AddCleanupAction(f.AfterEach)\n\n\tdir, err := os.MkdirTemp(os.TempDir(), \"frp-e2e-test-*\")\n\tExpectNoError(err)\n\tf.TempDirectory = dir\n\n\tf.mockServers = NewMockServers(f.portAllocator)\n\tif err := f.mockServers.Run(); err != nil {\n\t\tFailf(\"%v\", err)\n\t}\n\n\tparams := f.mockServers.GetTemplateParams()\n\tfor k, v := range params {\n\t\tswitch t := v.(type) {\n\t\tcase int:\n\t\t\tf.usedPorts[k] = t\n\t\tdefault:\n\t\t}\n\t}\n}\n\nfunc (f *Framework) AfterEach() {\n\tif !f.beforeEachStarted {\n\t\treturn\n\t}\n\n\tRemoveCleanupAction(f.cleanupHandle)\n\n\t// stop processor\n\tfor _, p := range f.serverProcesses {\n\t\t_ = p.Stop()\n\t\tif TestContext.Debug || ginkgo.CurrentSpecReport().Failed() {\n\t\t\tfmt.Println(p.ErrorOutput())\n\t\t\tfmt.Println(p.StdOutput())\n\t\t}\n\t}\n\tfor _, p := range f.clientProcesses {\n\t\t_ = p.Stop()\n\t\tif TestContext.Debug || ginkgo.CurrentSpecReport().Failed() {\n\t\t\tfmt.Println(p.ErrorOutput())\n\t\t\tfmt.Println(p.StdOutput())\n\t\t}\n\t}\n\tf.serverProcesses = nil\n\tf.clientProcesses = nil\n\n\t// close default mock servers\n\tf.mockServers.Close()\n\n\t// close manual registered mock servers\n\tfor _, s := range f.servers {\n\t\ts.Close()\n\t}\n\n\t// clean directory\n\tos.RemoveAll(f.TempDirectory)\n\tf.TempDirectory = \"\"\n\tf.serverConfPaths = []string{}\n\tf.clientConfPaths = []string{}\n\n\t// release used ports\n\tfor _, port := range f.usedPorts {\n\t\tf.portAllocator.Release(port)\n\t}\n\tf.usedPorts = make(map[string]int)\n\n\t// release allocated ports\n\tfor _, port := range f.allocatedPorts {\n\t\tf.portAllocator.Release(port)\n\t}\n\tf.allocatedPorts = make([]int, 0)\n\n\t// clear os envs\n\tf.osEnvs = make([]string, 0)\n}\n\nvar portRegex = regexp.MustCompile(`{{ \\.Port.*? }}`)\n\n// RenderPortsTemplate render templates with ports.\n//\n// Local: {{ .Port1 }}\n// Target: {{ .Port2 }}\n//\n// return rendered content and all allocated ports.\nfunc (f *Framework) genPortsFromTemplates(templates []string) (ports map[string]int, err error) {\n\tports = make(map[string]int)\n\tfor _, t := range templates {\n\t\tarrs := portRegex.FindAllString(t, -1)\n\t\tfor _, str := range arrs {\n\t\t\tstr = strings.TrimPrefix(str, \"{{ .\")\n\t\t\tstr = strings.TrimSuffix(str, \" }}\")\n\t\t\tstr = strings.TrimSpace(str)\n\t\t\tports[str] = 0\n\t\t}\n\t}\n\tdefer func() {\n\t\tif err != nil {\n\t\t\tfor _, port := range ports {\n\t\t\t\tf.portAllocator.Release(port)\n\t\t\t}\n\t\t}\n\t}()\n\n\tfor name := range ports {\n\t\tport := f.portAllocator.GetByName(name)\n\t\tif port <= 0 {\n\t\t\treturn nil, fmt.Errorf(\"can't allocate port\")\n\t\t}\n\t\tports[name] = port\n\t}\n\treturn\n}\n\n// RenderTemplates alloc all ports for port names placeholder.\nfunc (f *Framework) RenderTemplates(templates []string) (outs []string, ports map[string]int, err error) {\n\tports, err = f.genPortsFromTemplates(templates)\n\tif err != nil {\n\t\treturn\n\t}\n\n\tparams := f.mockServers.GetTemplateParams()\n\tfor name, port := range ports {\n\t\tparams[name] = port\n\t}\n\n\tfor name, port := range f.usedPorts {\n\t\tparams[name] = port\n\t}\n\n\tfor _, t := range templates {\n\t\ttmpl, err := template.New(\"frp-e2e\").Parse(t)\n\t\tif err != nil {\n\t\t\treturn nil, nil, err\n\t\t}\n\t\tbuffer := bytes.NewBuffer(nil)\n\t\tif err = tmpl.Execute(buffer, params); err != nil {\n\t\t\treturn nil, nil, err\n\t\t}\n\t\touts = append(outs, buffer.String())\n\t}\n\treturn\n}\n\nfunc (f *Framework) PortByName(name string) int {\n\treturn f.usedPorts[name]\n}\n\nfunc (f *Framework) AllocPort() int {\n\tport := f.portAllocator.Get()\n\tExpectTrue(port > 0, \"alloc port failed\")\n\tf.allocatedPorts = append(f.allocatedPorts, port)\n\treturn port\n}\n\nfunc (f *Framework) ReleasePort(port int) {\n\tf.portAllocator.Release(port)\n}\n\nfunc (f *Framework) RunServer(portName string, s server.Server) {\n\tf.servers = append(f.servers, s)\n\tif s.BindPort() > 0 && portName != \"\" {\n\t\tf.usedPorts[portName] = s.BindPort()\n\t}\n\terr := s.Run()\n\tExpectNoError(err, \"RunServer: with PortName %s\", portName)\n}\n\nfunc (f *Framework) SetEnvs(envs []string) {\n\tf.osEnvs = envs\n}\n\nfunc (f *Framework) WriteTempFile(name string, content string) string {\n\tfilePath := filepath.Join(f.TempDirectory, name)\n\terr := os.WriteFile(filePath, []byte(content), 0o600)\n\tExpectNoError(err)\n\treturn filePath\n}\n"
  },
  {
    "path": "test/e2e/framework/log.go",
    "content": "package framework\n\nimport (\n\t\"fmt\"\n\t\"time\"\n\n\t\"github.com/onsi/ginkgo/v2\"\n)\n\nfunc nowStamp() string {\n\treturn time.Now().Format(time.StampMilli)\n}\n\nfunc log(level string, format string, args ...any) {\n\tfmt.Fprintf(ginkgo.GinkgoWriter, nowStamp()+\": \"+level+\": \"+format+\"\\n\", args...)\n}\n\n// Logf logs the info.\nfunc Logf(format string, args ...any) {\n\tlog(\"INFO\", format, args...)\n}\n\n// Failf logs the fail info, including a stack trace starts with its direct caller\n// (for example, for call chain f -> g -> Failf(\"foo\", ...) error would be logged for \"g\").\nfunc Failf(format string, args ...any) {\n\tmsg := fmt.Sprintf(format, args...)\n\tskip := 1\n\tginkgo.Fail(msg, skip)\n\tpanic(\"unreachable\")\n}\n\n// Fail is an alias for ginkgo.Fail.\nvar Fail = ginkgo.Fail\n"
  },
  {
    "path": "test/e2e/framework/mockservers.go",
    "content": "package framework\n\nimport (\n\t\"fmt\"\n\t\"net/http\"\n\t\"os\"\n\n\t\"github.com/fatedier/frp/test/e2e/framework/consts\"\n\t\"github.com/fatedier/frp/test/e2e/mock/server\"\n\t\"github.com/fatedier/frp/test/e2e/mock/server/httpserver\"\n\t\"github.com/fatedier/frp/test/e2e/mock/server/streamserver\"\n\t\"github.com/fatedier/frp/test/e2e/pkg/port\"\n)\n\nconst (\n\tTCPEchoServerPort    = \"TCPEchoServerPort\"\n\tUDPEchoServerPort    = \"UDPEchoServerPort\"\n\tUDSEchoServerAddr    = \"UDSEchoServerAddr\"\n\tHTTPSimpleServerPort = \"HTTPSimpleServerPort\"\n)\n\ntype MockServers struct {\n\ttcpEchoServer    server.Server\n\tudpEchoServer    server.Server\n\tudsEchoServer    server.Server\n\thttpSimpleServer server.Server\n}\n\nfunc NewMockServers(portAllocator *port.Allocator) *MockServers {\n\ts := &MockServers{}\n\ttcpPort := portAllocator.Get()\n\tudpPort := portAllocator.Get()\n\thttpPort := portAllocator.Get()\n\ts.tcpEchoServer = streamserver.New(streamserver.TCP, streamserver.WithBindPort(tcpPort))\n\ts.udpEchoServer = streamserver.New(streamserver.UDP, streamserver.WithBindPort(udpPort))\n\ts.httpSimpleServer = httpserver.New(httpserver.WithBindPort(httpPort),\n\t\thttpserver.WithHandler(http.HandlerFunc(func(w http.ResponseWriter, req *http.Request) {\n\t\t\t_, _ = w.Write([]byte(consts.TestString))\n\t\t})),\n\t)\n\n\tudsIndex := portAllocator.Get()\n\tudsAddr := fmt.Sprintf(\"%s/frp_echo_server_%d.sock\", os.TempDir(), udsIndex)\n\tos.Remove(udsAddr)\n\ts.udsEchoServer = streamserver.New(streamserver.Unix, streamserver.WithBindAddr(udsAddr))\n\treturn s\n}\n\nfunc (m *MockServers) Run() error {\n\tif err := m.tcpEchoServer.Run(); err != nil {\n\t\treturn err\n\t}\n\tif err := m.udpEchoServer.Run(); err != nil {\n\t\treturn err\n\t}\n\tif err := m.udsEchoServer.Run(); err != nil {\n\t\treturn err\n\t}\n\treturn m.httpSimpleServer.Run()\n}\n\nfunc (m *MockServers) Close() {\n\tm.tcpEchoServer.Close()\n\tm.udpEchoServer.Close()\n\tm.udsEchoServer.Close()\n\tm.httpSimpleServer.Close()\n\tos.Remove(m.udsEchoServer.BindAddr())\n}\n\nfunc (m *MockServers) GetTemplateParams() map[string]any {\n\tret := make(map[string]any)\n\tret[TCPEchoServerPort] = m.tcpEchoServer.BindPort()\n\tret[UDPEchoServerPort] = m.udpEchoServer.BindPort()\n\tret[UDSEchoServerAddr] = m.udsEchoServer.BindAddr()\n\tret[HTTPSimpleServerPort] = m.httpSimpleServer.BindPort()\n\treturn ret\n}\n\nfunc (m *MockServers) GetParam(key string) any {\n\tparams := m.GetTemplateParams()\n\tif v, ok := params[key]; ok {\n\t\treturn v\n\t}\n\treturn nil\n}\n"
  },
  {
    "path": "test/e2e/framework/process.go",
    "content": "package framework\n\nimport (\n\t\"fmt\"\n\t\"maps\"\n\t\"net\"\n\t\"os\"\n\t\"path/filepath\"\n\t\"strconv\"\n\t\"time\"\n\n\t\"github.com/fatedier/frp/pkg/config\"\n\tflog \"github.com/fatedier/frp/pkg/util/log\"\n\t\"github.com/fatedier/frp/test/e2e/framework/consts\"\n\t\"github.com/fatedier/frp/test/e2e/pkg/process\"\n)\n\n// RunProcesses starts one frps and zero or more frpc processes from templates.\nfunc (f *Framework) RunProcesses(serverTemplate string, clientTemplates []string) (*process.Process, []*process.Process) {\n\ttemplates := append([]string{serverTemplate}, clientTemplates...)\n\touts, ports, err := f.RenderTemplates(templates)\n\tExpectNoError(err)\n\n\tmaps.Copy(f.usedPorts, ports)\n\n\t// Start frps.\n\tserverPath := filepath.Join(f.TempDirectory, \"frp-e2e-server-0\")\n\terr = os.WriteFile(serverPath, []byte(outs[0]), 0o600)\n\tExpectNoError(err)\n\n\tif TestContext.Debug {\n\t\tflog.Debugf(\"[%s] %s\", serverPath, outs[0])\n\t}\n\n\tserverProcess := process.NewWithEnvs(TestContext.FRPServerPath, []string{\"-c\", serverPath}, f.osEnvs)\n\tf.serverConfPaths = append(f.serverConfPaths, serverPath)\n\tf.serverProcesses = append(f.serverProcesses, serverProcess)\n\terr = serverProcess.Start()\n\tExpectNoError(err)\n\n\tif port, ok := ports[consts.PortServerName]; ok {\n\t\tExpectNoError(WaitForTCPReady(net.JoinHostPort(\"127.0.0.1\", strconv.Itoa(port)), 5*time.Second))\n\t} else {\n\t\ttime.Sleep(2 * time.Second)\n\t}\n\n\t// Start frpc(s).\n\tclientProcesses := make([]*process.Process, 0, len(clientTemplates))\n\tfor i := range clientTemplates {\n\t\tpath := filepath.Join(f.TempDirectory, fmt.Sprintf(\"frp-e2e-client-%d\", i))\n\t\terr = os.WriteFile(path, []byte(outs[1+i]), 0o600)\n\t\tExpectNoError(err)\n\n\t\tif TestContext.Debug {\n\t\t\tflog.Debugf(\"[%s] %s\", path, outs[1+i])\n\t\t}\n\n\t\tp := process.NewWithEnvs(TestContext.FRPClientPath, []string{\"-c\", path}, f.osEnvs)\n\t\tf.clientConfPaths = append(f.clientConfPaths, path)\n\t\tf.clientProcesses = append(f.clientProcesses, p)\n\t\tclientProcesses = append(clientProcesses, p)\n\t\terr = p.Start()\n\t\tExpectNoError(err)\n\t}\n\t// Wait for each client's proxies to register with frps.\n\t// If any client has no proxies (e.g. visitor-only), fall back to sleep\n\t// for the remaining time since visitors have no deterministic readiness signal.\n\tallConfirmed := len(clientProcesses) > 0\n\tstart := time.Now()\n\tfor i, p := range clientProcesses {\n\t\tconfigPath := f.clientConfPaths[len(f.clientConfPaths)-len(clientProcesses)+i]\n\t\tif !waitForClientProxyReady(configPath, p, 5*time.Second) {\n\t\t\tallConfirmed = false\n\t\t}\n\t}\n\tif len(clientProcesses) > 0 && !allConfirmed {\n\t\tremaining := 1500*time.Millisecond - time.Since(start)\n\t\tif remaining > 0 {\n\t\t\ttime.Sleep(remaining)\n\t\t}\n\t}\n\n\treturn serverProcess, clientProcesses\n}\n\nfunc (f *Framework) RunFrps(args ...string) (*process.Process, string, error) {\n\tp := process.NewWithEnvs(TestContext.FRPServerPath, args, f.osEnvs)\n\tf.serverProcesses = append(f.serverProcesses, p)\n\terr := p.Start()\n\tif err != nil {\n\t\treturn p, p.Output(), err\n\t}\n\tselect {\n\tcase <-p.Done():\n\tcase <-time.After(2 * time.Second):\n\t}\n\treturn p, p.Output(), nil\n}\n\nfunc (f *Framework) RunFrpc(args ...string) (*process.Process, string, error) {\n\tp := process.NewWithEnvs(TestContext.FRPClientPath, args, f.osEnvs)\n\tf.clientProcesses = append(f.clientProcesses, p)\n\terr := p.Start()\n\tif err != nil {\n\t\treturn p, p.Output(), err\n\t}\n\tselect {\n\tcase <-p.Done():\n\tcase <-time.After(1500 * time.Millisecond):\n\t}\n\treturn p, p.Output(), nil\n}\n\nfunc (f *Framework) GenerateConfigFile(content string) string {\n\tf.configFileIndex++\n\tpath := filepath.Join(f.TempDirectory, fmt.Sprintf(\"frp-e2e-config-%d\", f.configFileIndex))\n\terr := os.WriteFile(path, []byte(content), 0o600)\n\tExpectNoError(err)\n\treturn path\n}\n\n// waitForClientProxyReady parses the client config to extract proxy names,\n// then waits for each proxy's \"start proxy success\" log in the process output.\n// Returns true only if proxies were expected and all registered successfully.\nfunc waitForClientProxyReady(configPath string, p *process.Process, timeout time.Duration) bool {\n\t_, proxyCfgs, _, _, err := config.LoadClientConfig(configPath, false)\n\tif err != nil || len(proxyCfgs) == 0 {\n\t\treturn false\n\t}\n\n\t// Use a single deadline so the total wait across all proxies does not exceed timeout.\n\tdeadline := time.Now().Add(timeout)\n\tfor _, cfg := range proxyCfgs {\n\t\tremaining := time.Until(deadline)\n\t\tif remaining <= 0 {\n\t\t\treturn false\n\t\t}\n\t\tname := cfg.GetBaseConfig().Name\n\t\tpattern := fmt.Sprintf(\"[%s] start proxy success\", name)\n\t\tif err := p.WaitForOutput(pattern, 1, remaining); err != nil {\n\t\t\treturn false\n\t\t}\n\t}\n\treturn true\n}\n\n// WaitForTCPUnreachable polls a TCP address until a connection fails or timeout.\nfunc WaitForTCPUnreachable(addr string, interval, timeout time.Duration) error {\n\tif interval <= 0 {\n\t\treturn fmt.Errorf(\"invalid interval for TCP unreachable on %s: interval must be positive\", addr)\n\t}\n\tif timeout <= 0 {\n\t\treturn fmt.Errorf(\"invalid timeout for TCP unreachable on %s: timeout must be positive\", addr)\n\t}\n\tdeadline := time.Now().Add(timeout)\n\tfor {\n\t\tremaining := time.Until(deadline)\n\t\tif remaining <= 0 {\n\t\t\treturn fmt.Errorf(\"timeout waiting for TCP unreachable on %s\", addr)\n\t\t}\n\t\tdialTimeout := min(interval, remaining)\n\t\tconn, err := net.DialTimeout(\"tcp\", addr, dialTimeout)\n\t\tif err != nil {\n\t\t\treturn nil\n\t\t}\n\t\tconn.Close()\n\t\ttime.Sleep(min(interval, time.Until(deadline)))\n\t}\n}\n\n// WaitForTCPReady polls a TCP address until a connection succeeds or timeout.\nfunc WaitForTCPReady(addr string, timeout time.Duration) error {\n\tif timeout <= 0 {\n\t\treturn fmt.Errorf(\"invalid timeout for TCP readiness on %s: timeout must be positive\", addr)\n\t}\n\tdeadline := time.Now().Add(timeout)\n\tvar lastErr error\n\tfor time.Now().Before(deadline) {\n\t\tconn, err := net.DialTimeout(\"tcp\", addr, 100*time.Millisecond)\n\t\tif err == nil {\n\t\t\tconn.Close()\n\t\t\treturn nil\n\t\t}\n\t\tlastErr = err\n\t\ttime.Sleep(50 * time.Millisecond)\n\t}\n\tif lastErr == nil {\n\t\treturn fmt.Errorf(\"timeout waiting for TCP readiness on %s before any dial attempt\", addr)\n\t}\n\treturn fmt.Errorf(\"timeout waiting for TCP readiness on %s: %w\", addr, lastErr)\n}\n"
  },
  {
    "path": "test/e2e/framework/request.go",
    "content": "package framework\n\nimport (\n\t\"bytes\"\n\t\"net/http\"\n\n\tflog \"github.com/fatedier/frp/pkg/util/log\"\n\t\"github.com/fatedier/frp/test/e2e/framework/consts\"\n\t\"github.com/fatedier/frp/test/e2e/pkg/request\"\n)\n\nfunc SpecifiedHTTPBodyHandler(body []byte) http.HandlerFunc {\n\treturn func(w http.ResponseWriter, req *http.Request) {\n\t\t_, _ = w.Write(body)\n\t}\n}\n\nfunc ExpectResponseCode(code int) EnsureFunc {\n\treturn func(resp *request.Response) bool {\n\t\tif resp.Code == code {\n\t\t\treturn true\n\t\t}\n\t\tflog.Warnf(\"expect code %d, but got %d\", code, resp.Code)\n\t\treturn false\n\t}\n}\n\n// NewRequest return a default request with default timeout and content.\nfunc NewRequest() *request.Request {\n\treturn request.New().\n\t\tTimeout(consts.DefaultTimeout).\n\t\tBody([]byte(consts.TestString))\n}\n\nfunc NewHTTPRequest() *request.Request {\n\treturn request.New().HTTP().HTTPParams(\"GET\", \"\", \"/\", nil)\n}\n\ntype RequestExpect struct {\n\treq *request.Request\n\n\tf           *Framework\n\texpectResp  []byte\n\texpectError bool\n\texplain     []any\n}\n\nfunc NewRequestExpect(f *Framework) *RequestExpect {\n\treturn &RequestExpect{\n\t\treq:         NewRequest(),\n\t\tf:           f,\n\t\texpectResp:  []byte(consts.TestString),\n\t\texpectError: false,\n\t\texplain:     make([]any, 0),\n\t}\n}\n\nfunc (e *RequestExpect) Request(req *request.Request) *RequestExpect {\n\te.req = req\n\treturn e\n}\n\nfunc (e *RequestExpect) RequestModify(f func(r *request.Request)) *RequestExpect {\n\tf(e.req)\n\treturn e\n}\n\nfunc (e *RequestExpect) Protocol(protocol string) *RequestExpect {\n\te.req.Protocol(protocol)\n\treturn e\n}\n\nfunc (e *RequestExpect) PortName(name string) *RequestExpect {\n\tif e.f != nil {\n\t\te.req.Port(e.f.PortByName(name))\n\t}\n\treturn e\n}\n\nfunc (e *RequestExpect) Port(port int) *RequestExpect {\n\tif e.f != nil {\n\t\te.req.Port(port)\n\t}\n\treturn e\n}\n\nfunc (e *RequestExpect) ExpectResp(resp []byte) *RequestExpect {\n\te.expectResp = resp\n\treturn e\n}\n\nfunc (e *RequestExpect) ExpectError(expectErr bool) *RequestExpect {\n\te.expectError = expectErr\n\treturn e\n}\n\nfunc (e *RequestExpect) Explain(explain ...any) *RequestExpect {\n\te.explain = explain\n\treturn e\n}\n\ntype EnsureFunc func(*request.Response) bool\n\nfunc (e *RequestExpect) Ensure(fns ...EnsureFunc) {\n\tret, err := e.req.Do()\n\tif e.expectError {\n\t\tExpectErrorWithOffset(1, err, e.explain...)\n\t\treturn\n\t}\n\tExpectNoErrorWithOffset(1, err, e.explain...)\n\n\tif len(fns) == 0 {\n\t\tif !bytes.Equal(e.expectResp, ret.Content) {\n\t\t\tflog.Tracef(\"response info: %+v\", ret)\n\t\t}\n\t\tExpectEqualValuesWithOffset(1, string(ret.Content), string(e.expectResp), e.explain...)\n\t} else {\n\t\tfor _, fn := range fns {\n\t\t\tok := fn(ret)\n\t\t\tif !ok {\n\t\t\t\tflog.Tracef(\"response info: %+v\", ret)\n\t\t\t}\n\t\t\tExpectTrueWithOffset(1, ok, e.explain...)\n\t\t}\n\t}\n}\n\nfunc (e *RequestExpect) Do() (*request.Response, error) {\n\treturn e.req.Do()\n}\n"
  },
  {
    "path": "test/e2e/framework/test_context.go",
    "content": "package framework\n\nimport (\n\t\"flag\"\n\t\"fmt\"\n\t\"os\"\n)\n\ntype TestContextType struct {\n\tFRPClientPath string\n\tFRPServerPath string\n\tLogLevel      string\n\tDebug         bool\n}\n\nvar TestContext TestContextType\n\n// RegisterCommonFlags registers flags common to all e2e test suites.\n// The flag set can be flag.CommandLine (if desired) or a custom\n// flag set that then gets passed to viperconfig.ViperizeFlags.\n//\n// The other Register*Flags methods below can be used to add more\n// test-specific flags. However, those settings then get added\n// regardless whether the test is actually in the test suite.\nfunc RegisterCommonFlags(flags *flag.FlagSet) {\n\tflags.StringVar(&TestContext.FRPClientPath, \"frpc-path\", \"../../bin/frpc\", \"The frp client binary to use.\")\n\tflags.StringVar(&TestContext.FRPServerPath, \"frps-path\", \"../../bin/frps\", \"The frp server binary to use.\")\n\tflags.StringVar(&TestContext.LogLevel, \"log-level\", \"debug\", \"Log level.\")\n\tflags.BoolVar(&TestContext.Debug, \"debug\", false, \"Enable debug mode to print detail info.\")\n}\n\nfunc ValidateTestContext(t *TestContextType) error {\n\tif t.FRPClientPath == \"\" || t.FRPServerPath == \"\" {\n\t\treturn fmt.Errorf(\"frpc and frps binary path can't be empty\")\n\t}\n\tif _, err := os.Stat(t.FRPClientPath); err != nil {\n\t\treturn fmt.Errorf(\"load frpc-path error: %v\", err)\n\t}\n\tif _, err := os.Stat(t.FRPServerPath); err != nil {\n\t\treturn fmt.Errorf(\"load frps-path error: %v\", err)\n\t}\n\treturn nil\n}\n"
  },
  {
    "path": "test/e2e/framework/util.go",
    "content": "package framework\n\nimport (\n\t\"github.com/google/uuid\"\n)\n\n// RunID is a unique identifier of the e2e run.\n// Beware that this ID is not the same for all tests in the e2e run, because each Ginkgo node creates it separately.\nvar RunID string\n\nfunc init() {\n\tRunID = uuid.NewString()\n}\n"
  },
  {
    "path": "test/e2e/legacy/basic/basic.go",
    "content": "package basic\n\nimport (\n\t\"crypto/tls\"\n\t\"fmt\"\n\t\"strings\"\n\t\"time\"\n\n\t\"github.com/onsi/ginkgo/v2\"\n\n\t\"github.com/fatedier/frp/pkg/transport\"\n\t\"github.com/fatedier/frp/test/e2e/framework\"\n\t\"github.com/fatedier/frp/test/e2e/framework/consts\"\n\t\"github.com/fatedier/frp/test/e2e/mock/server/httpserver\"\n\t\"github.com/fatedier/frp/test/e2e/mock/server/streamserver\"\n\t\"github.com/fatedier/frp/test/e2e/pkg/port\"\n\t\"github.com/fatedier/frp/test/e2e/pkg/request\"\n)\n\nvar _ = ginkgo.Describe(\"[Feature: Basic]\", func() {\n\tf := framework.NewDefaultFramework()\n\n\tginkgo.Describe(\"TCP && UDP\", func() {\n\t\ttypes := []string{\"tcp\", \"udp\"}\n\t\tfor _, t := range types {\n\t\t\tproxyType := t\n\t\t\tginkgo.It(fmt.Sprintf(\"Expose a %s echo server\", strings.ToUpper(proxyType)), func() {\n\t\t\t\tserverConf := consts.LegacyDefaultServerConfig\n\t\t\t\tvar clientConf strings.Builder\n\t\t\t\tclientConf.WriteString(consts.LegacyDefaultClientConfig)\n\n\t\t\t\tlocalPortName := \"\"\n\t\t\t\tprotocol := \"tcp\"\n\t\t\t\tswitch proxyType {\n\t\t\t\tcase \"tcp\":\n\t\t\t\t\tlocalPortName = framework.TCPEchoServerPort\n\t\t\t\t\tprotocol = \"tcp\"\n\t\t\t\tcase \"udp\":\n\t\t\t\t\tlocalPortName = framework.UDPEchoServerPort\n\t\t\t\t\tprotocol = \"udp\"\n\t\t\t\t}\n\t\t\t\tgetProxyConf := func(proxyName string, portName string, extra string) string {\n\t\t\t\t\treturn fmt.Sprintf(`\n\t\t\t\t[%s]\n\t\t\t\ttype = %s\n\t\t\t\tlocal_port = {{ .%s }}\n\t\t\t\tremote_port = {{ .%s }}\n\t\t\t\t`+extra, proxyName, proxyType, localPortName, portName)\n\t\t\t\t}\n\n\t\t\t\ttests := []struct {\n\t\t\t\t\tproxyName   string\n\t\t\t\t\tportName    string\n\t\t\t\t\textraConfig string\n\t\t\t\t}{\n\t\t\t\t\t{\n\t\t\t\t\t\tproxyName: \"normal\",\n\t\t\t\t\t\tportName:  port.GenName(\"Normal\"),\n\t\t\t\t\t},\n\t\t\t\t\t{\n\t\t\t\t\t\tproxyName:   \"with-encryption\",\n\t\t\t\t\t\tportName:    port.GenName(\"WithEncryption\"),\n\t\t\t\t\t\textraConfig: \"use_encryption = true\",\n\t\t\t\t\t},\n\t\t\t\t\t{\n\t\t\t\t\t\tproxyName:   \"with-compression\",\n\t\t\t\t\t\tportName:    port.GenName(\"WithCompression\"),\n\t\t\t\t\t\textraConfig: \"use_compression = true\",\n\t\t\t\t\t},\n\t\t\t\t\t{\n\t\t\t\t\t\tproxyName: \"with-encryption-and-compression\",\n\t\t\t\t\t\tportName:  port.GenName(\"WithEncryptionAndCompression\"),\n\t\t\t\t\t\textraConfig: `\n\t\t\t\t\t\tuse_encryption = true\n\t\t\t\t\t\tuse_compression = true\n\t\t\t\t\t\t`,\n\t\t\t\t\t},\n\t\t\t\t}\n\n\t\t\t\t// build all client config\n\t\t\t\tfor _, test := range tests {\n\t\t\t\t\tclientConf.WriteString(getProxyConf(test.proxyName, test.portName, test.extraConfig) + \"\\n\")\n\t\t\t\t}\n\t\t\t\t// run frps and frpc\n\t\t\t\tf.RunProcesses(serverConf, []string{clientConf.String()})\n\n\t\t\t\tfor _, test := range tests {\n\t\t\t\t\tframework.NewRequestExpect(f).\n\t\t\t\t\t\tProtocol(protocol).\n\t\t\t\t\t\tPortName(test.portName).\n\t\t\t\t\t\tExplain(test.proxyName).\n\t\t\t\t\t\tEnsure()\n\t\t\t\t}\n\t\t\t})\n\t\t}\n\t})\n\n\tginkgo.Describe(\"HTTP\", func() {\n\t\tginkgo.It(\"proxy to HTTP server\", func() {\n\t\t\tserverConf := consts.LegacyDefaultServerConfig\n\t\t\tvhostHTTPPort := f.AllocPort()\n\t\t\tserverConf += fmt.Sprintf(`\n\t\t\tvhost_http_port = %d\n\t\t\t`, vhostHTTPPort)\n\n\t\t\tvar clientConf strings.Builder\n\t\t\tclientConf.WriteString(consts.LegacyDefaultClientConfig)\n\n\t\t\tgetProxyConf := func(proxyName string, customDomains string, extra string) string {\n\t\t\t\treturn fmt.Sprintf(`\n\t\t\t\t[%s]\n\t\t\t\ttype = http\n\t\t\t\tlocal_port = {{ .%s }}\n\t\t\t\tcustom_domains = %s\n\t\t\t\t`+extra, proxyName, framework.HTTPSimpleServerPort, customDomains)\n\t\t\t}\n\n\t\t\ttests := []struct {\n\t\t\t\tproxyName     string\n\t\t\t\tcustomDomains string\n\t\t\t\textraConfig   string\n\t\t\t}{\n\t\t\t\t{\n\t\t\t\t\tproxyName: \"normal\",\n\t\t\t\t},\n\t\t\t\t{\n\t\t\t\t\tproxyName:   \"with-encryption\",\n\t\t\t\t\textraConfig: \"use_encryption = true\",\n\t\t\t\t},\n\t\t\t\t{\n\t\t\t\t\tproxyName:   \"with-compression\",\n\t\t\t\t\textraConfig: \"use_compression = true\",\n\t\t\t\t},\n\t\t\t\t{\n\t\t\t\t\tproxyName: \"with-encryption-and-compression\",\n\t\t\t\t\textraConfig: `\n\t\t\t\t\t\tuse_encryption = true\n\t\t\t\t\t\tuse_compression = true\n\t\t\t\t\t\t`,\n\t\t\t\t},\n\t\t\t\t{\n\t\t\t\t\tproxyName:     \"multiple-custom-domains\",\n\t\t\t\t\tcustomDomains: \"a.example.com, b.example.com\",\n\t\t\t\t},\n\t\t\t}\n\n\t\t\t// build all client config\n\t\t\tfor i, test := range tests {\n\t\t\t\tif tests[i].customDomains == \"\" {\n\t\t\t\t\ttests[i].customDomains = test.proxyName + \".example.com\"\n\t\t\t\t}\n\t\t\t\tclientConf.WriteString(getProxyConf(test.proxyName, tests[i].customDomains, test.extraConfig) + \"\\n\")\n\t\t\t}\n\t\t\t// run frps and frpc\n\t\t\tf.RunProcesses(serverConf, []string{clientConf.String()})\n\n\t\t\tfor _, test := range tests {\n\t\t\t\tfor domain := range strings.SplitSeq(test.customDomains, \",\") {\n\t\t\t\t\tdomain = strings.TrimSpace(domain)\n\t\t\t\t\tframework.NewRequestExpect(f).\n\t\t\t\t\t\tExplain(test.proxyName + \"-\" + domain).\n\t\t\t\t\t\tPort(vhostHTTPPort).\n\t\t\t\t\t\tRequestModify(func(r *request.Request) {\n\t\t\t\t\t\t\tr.HTTP().HTTPHost(domain)\n\t\t\t\t\t\t}).\n\t\t\t\t\t\tEnsure()\n\t\t\t\t}\n\t\t\t}\n\n\t\t\t// not exist host\n\t\t\tframework.NewRequestExpect(f).\n\t\t\t\tExplain(\"not exist host\").\n\t\t\t\tPort(vhostHTTPPort).\n\t\t\t\tRequestModify(func(r *request.Request) {\n\t\t\t\t\tr.HTTP().HTTPHost(\"not-exist.example.com\")\n\t\t\t\t}).\n\t\t\t\tEnsure(framework.ExpectResponseCode(404))\n\t\t})\n\t})\n\n\tginkgo.Describe(\"HTTPS\", func() {\n\t\tginkgo.It(\"proxy to HTTPS server\", func() {\n\t\t\tserverConf := consts.LegacyDefaultServerConfig\n\t\t\tvhostHTTPSPort := f.AllocPort()\n\t\t\tserverConf += fmt.Sprintf(`\n\t\t\tvhost_https_port = %d\n\t\t\t`, vhostHTTPSPort)\n\n\t\t\tlocalPort := f.AllocPort()\n\t\t\tvar clientConf strings.Builder\n\t\t\tclientConf.WriteString(consts.LegacyDefaultClientConfig)\n\t\t\tgetProxyConf := func(proxyName string, customDomains string, extra string) string {\n\t\t\t\treturn fmt.Sprintf(`\n\t\t\t\t[%s]\n\t\t\t\ttype = https\n\t\t\t\tlocal_port = %d\n\t\t\t\tcustom_domains = %s\n\t\t\t\t`+extra, proxyName, localPort, customDomains)\n\t\t\t}\n\n\t\t\ttests := []struct {\n\t\t\t\tproxyName     string\n\t\t\t\tcustomDomains string\n\t\t\t\textraConfig   string\n\t\t\t}{\n\t\t\t\t{\n\t\t\t\t\tproxyName: \"normal\",\n\t\t\t\t},\n\t\t\t\t{\n\t\t\t\t\tproxyName:   \"with-encryption\",\n\t\t\t\t\textraConfig: \"use_encryption = true\",\n\t\t\t\t},\n\t\t\t\t{\n\t\t\t\t\tproxyName:   \"with-compression\",\n\t\t\t\t\textraConfig: \"use_compression = true\",\n\t\t\t\t},\n\t\t\t\t{\n\t\t\t\t\tproxyName: \"with-encryption-and-compression\",\n\t\t\t\t\textraConfig: `\n\t\t\t\t\t\tuse_encryption = true\n\t\t\t\t\t\tuse_compression = true\n\t\t\t\t\t\t`,\n\t\t\t\t},\n\t\t\t\t{\n\t\t\t\t\tproxyName:     \"multiple-custom-domains\",\n\t\t\t\t\tcustomDomains: \"a.example.com, b.example.com\",\n\t\t\t\t},\n\t\t\t}\n\n\t\t\t// build all client config\n\t\t\tfor i, test := range tests {\n\t\t\t\tif tests[i].customDomains == \"\" {\n\t\t\t\t\ttests[i].customDomains = test.proxyName + \".example.com\"\n\t\t\t\t}\n\t\t\t\tclientConf.WriteString(getProxyConf(test.proxyName, tests[i].customDomains, test.extraConfig) + \"\\n\")\n\t\t\t}\n\t\t\t// run frps and frpc\n\t\t\tf.RunProcesses(serverConf, []string{clientConf.String()})\n\n\t\t\ttlsConfig, err := transport.NewServerTLSConfig(\"\", \"\", \"\")\n\t\t\tframework.ExpectNoError(err)\n\t\t\tlocalServer := httpserver.New(\n\t\t\t\thttpserver.WithBindPort(localPort),\n\t\t\t\thttpserver.WithTLSConfig(tlsConfig),\n\t\t\t\thttpserver.WithResponse([]byte(\"test\")),\n\t\t\t)\n\t\t\tf.RunServer(\"\", localServer)\n\n\t\t\tfor _, test := range tests {\n\t\t\t\tfor domain := range strings.SplitSeq(test.customDomains, \",\") {\n\t\t\t\t\tdomain = strings.TrimSpace(domain)\n\t\t\t\t\tframework.NewRequestExpect(f).\n\t\t\t\t\t\tExplain(test.proxyName + \"-\" + domain).\n\t\t\t\t\t\tPort(vhostHTTPSPort).\n\t\t\t\t\t\tRequestModify(func(r *request.Request) {\n\t\t\t\t\t\t\tr.HTTPS().HTTPHost(domain).TLSConfig(&tls.Config{\n\t\t\t\t\t\t\t\tServerName:         domain,\n\t\t\t\t\t\t\t\tInsecureSkipVerify: true,\n\t\t\t\t\t\t\t})\n\t\t\t\t\t\t}).\n\t\t\t\t\t\tExpectResp([]byte(\"test\")).\n\t\t\t\t\t\tEnsure()\n\t\t\t\t}\n\t\t\t}\n\n\t\t\t// not exist host\n\t\t\tnotExistDomain := \"not-exist.example.com\"\n\t\t\tframework.NewRequestExpect(f).\n\t\t\t\tExplain(\"not exist host\").\n\t\t\t\tPort(vhostHTTPSPort).\n\t\t\t\tRequestModify(func(r *request.Request) {\n\t\t\t\t\tr.HTTPS().HTTPHost(notExistDomain).TLSConfig(&tls.Config{\n\t\t\t\t\t\tServerName:         notExistDomain,\n\t\t\t\t\t\tInsecureSkipVerify: true,\n\t\t\t\t\t})\n\t\t\t\t}).\n\t\t\t\tExpectError(true).\n\t\t\t\tEnsure()\n\t\t})\n\t})\n\n\tginkgo.Describe(\"STCP && SUDP && XTCP\", func() {\n\t\ttypes := []string{\"stcp\", \"sudp\", \"xtcp\"}\n\t\tfor _, t := range types {\n\t\t\tproxyType := t\n\t\t\tginkgo.It(fmt.Sprintf(\"Expose echo server with %s\", strings.ToUpper(proxyType)), func() {\n\t\t\t\tserverConf := consts.LegacyDefaultServerConfig\n\t\t\t\tvar clientServerConf strings.Builder\n\t\t\t\tclientServerConf.WriteString(consts.LegacyDefaultClientConfig + \"\\nuser = user1\")\n\t\t\t\tvar clientVisitorConf strings.Builder\n\t\t\t\tclientVisitorConf.WriteString(consts.LegacyDefaultClientConfig + \"\\nuser = user1\")\n\t\t\t\tvar clientUser2VisitorConf strings.Builder\n\t\t\t\tclientUser2VisitorConf.WriteString(consts.LegacyDefaultClientConfig + \"\\nuser = user2\")\n\n\t\t\t\tlocalPortName := \"\"\n\t\t\t\tprotocol := \"tcp\"\n\t\t\t\tswitch proxyType {\n\t\t\t\tcase \"stcp\":\n\t\t\t\t\tlocalPortName = framework.TCPEchoServerPort\n\t\t\t\t\tprotocol = \"tcp\"\n\t\t\t\tcase \"sudp\":\n\t\t\t\t\tlocalPortName = framework.UDPEchoServerPort\n\t\t\t\t\tprotocol = \"udp\"\n\t\t\t\tcase \"xtcp\":\n\t\t\t\t\tlocalPortName = framework.TCPEchoServerPort\n\t\t\t\t\tprotocol = \"tcp\"\n\t\t\t\t\tginkgo.Skip(\"stun server is not stable\")\n\t\t\t\t}\n\n\t\t\t\tcorrectSK := \"abc\"\n\t\t\t\twrongSK := \"123\"\n\n\t\t\t\tgetProxyServerConf := func(proxyName string, extra string) string {\n\t\t\t\t\treturn fmt.Sprintf(`\n\t\t\t\t[%s]\n\t\t\t\ttype = %s\n\t\t\t\trole = server\n\t\t\t\tsk = %s\n\t\t\t\tlocal_port = {{ .%s }}\n\t\t\t\t`+extra, proxyName, proxyType, correctSK, localPortName)\n\t\t\t\t}\n\t\t\t\tgetProxyVisitorConf := func(proxyName string, portName, visitorSK, extra string) string {\n\t\t\t\t\treturn fmt.Sprintf(`\n\t\t\t\t[%s]\n\t\t\t\ttype = %s\n\t\t\t\trole = visitor\n\t\t\t\tserver_name = %s\n\t\t\t\tsk = %s\n\t\t\t\tbind_port = {{ .%s }}\n\t\t\t\t`+extra, proxyName, proxyType, proxyName, visitorSK, portName)\n\t\t\t\t}\n\n\t\t\t\ttests := []struct {\n\t\t\t\t\tproxyName          string\n\t\t\t\t\tbindPortName       string\n\t\t\t\t\tvisitorSK          string\n\t\t\t\t\tcommonExtraConfig  string\n\t\t\t\t\tproxyExtraConfig   string\n\t\t\t\t\tvisitorExtraConfig string\n\t\t\t\t\texpectError        bool\n\t\t\t\t\tdeployUser2Client  bool\n\t\t\t\t\t// skipXTCP is used to skip xtcp test case\n\t\t\t\t\tskipXTCP bool\n\t\t\t\t}{\n\t\t\t\t\t{\n\t\t\t\t\t\tproxyName:    \"normal\",\n\t\t\t\t\t\tbindPortName: port.GenName(\"Normal\"),\n\t\t\t\t\t\tvisitorSK:    correctSK,\n\t\t\t\t\t\tskipXTCP:     true,\n\t\t\t\t\t},\n\t\t\t\t\t{\n\t\t\t\t\t\tproxyName:         \"with-encryption\",\n\t\t\t\t\t\tbindPortName:      port.GenName(\"WithEncryption\"),\n\t\t\t\t\t\tvisitorSK:         correctSK,\n\t\t\t\t\t\tcommonExtraConfig: \"use_encryption = true\",\n\t\t\t\t\t\tskipXTCP:          true,\n\t\t\t\t\t},\n\t\t\t\t\t{\n\t\t\t\t\t\tproxyName:         \"with-compression\",\n\t\t\t\t\t\tbindPortName:      port.GenName(\"WithCompression\"),\n\t\t\t\t\t\tvisitorSK:         correctSK,\n\t\t\t\t\t\tcommonExtraConfig: \"use_compression = true\",\n\t\t\t\t\t\tskipXTCP:          true,\n\t\t\t\t\t},\n\t\t\t\t\t{\n\t\t\t\t\t\tproxyName:    \"with-encryption-and-compression\",\n\t\t\t\t\t\tbindPortName: port.GenName(\"WithEncryptionAndCompression\"),\n\t\t\t\t\t\tvisitorSK:    correctSK,\n\t\t\t\t\t\tcommonExtraConfig: `\n\t\t\t\t\t\tuse_encryption = true\n\t\t\t\t\t\tuse_compression = true\n\t\t\t\t\t\t`,\n\t\t\t\t\t\tskipXTCP: true,\n\t\t\t\t\t},\n\t\t\t\t\t{\n\t\t\t\t\t\tproxyName:    \"with-error-sk\",\n\t\t\t\t\t\tbindPortName: port.GenName(\"WithErrorSK\"),\n\t\t\t\t\t\tvisitorSK:    wrongSK,\n\t\t\t\t\t\texpectError:  true,\n\t\t\t\t\t},\n\t\t\t\t\t{\n\t\t\t\t\t\tproxyName:          \"allowed-user\",\n\t\t\t\t\t\tbindPortName:       port.GenName(\"AllowedUser\"),\n\t\t\t\t\t\tvisitorSK:          correctSK,\n\t\t\t\t\t\tproxyExtraConfig:   \"allow_users = another, user2\",\n\t\t\t\t\t\tvisitorExtraConfig: \"server_user = user1\",\n\t\t\t\t\t\tdeployUser2Client:  true,\n\t\t\t\t\t},\n\t\t\t\t\t{\n\t\t\t\t\t\tproxyName:          \"not-allowed-user\",\n\t\t\t\t\t\tbindPortName:       port.GenName(\"NotAllowedUser\"),\n\t\t\t\t\t\tvisitorSK:          correctSK,\n\t\t\t\t\t\tproxyExtraConfig:   \"allow_users = invalid\",\n\t\t\t\t\t\tvisitorExtraConfig: \"server_user = user1\",\n\t\t\t\t\t\texpectError:        true,\n\t\t\t\t\t},\n\t\t\t\t\t{\n\t\t\t\t\t\tproxyName:          \"allow-all\",\n\t\t\t\t\t\tbindPortName:       port.GenName(\"AllowAll\"),\n\t\t\t\t\t\tvisitorSK:          correctSK,\n\t\t\t\t\t\tproxyExtraConfig:   \"allow_users = *\",\n\t\t\t\t\t\tvisitorExtraConfig: \"server_user = user1\",\n\t\t\t\t\t\tdeployUser2Client:  true,\n\t\t\t\t\t},\n\t\t\t\t}\n\n\t\t\t\t// build all client config\n\t\t\t\tfor _, test := range tests {\n\t\t\t\t\tclientServerConf.WriteString(getProxyServerConf(test.proxyName, test.commonExtraConfig+\"\\n\"+test.proxyExtraConfig) + \"\\n\")\n\t\t\t\t}\n\t\t\t\tfor _, test := range tests {\n\t\t\t\t\tconfig := getProxyVisitorConf(\n\t\t\t\t\t\ttest.proxyName, test.bindPortName, test.visitorSK, test.commonExtraConfig+\"\\n\"+test.visitorExtraConfig,\n\t\t\t\t\t) + \"\\n\"\n\t\t\t\t\tif test.deployUser2Client {\n\t\t\t\t\t\tclientUser2VisitorConf.WriteString(config)\n\t\t\t\t\t} else {\n\t\t\t\t\t\tclientVisitorConf.WriteString(config)\n\t\t\t\t\t}\n\t\t\t\t}\n\t\t\t\t// run frps and frpc\n\t\t\t\tf.RunProcesses(serverConf, []string{clientServerConf.String(), clientVisitorConf.String(), clientUser2VisitorConf.String()})\n\n\t\t\t\tfor _, test := range tests {\n\t\t\t\t\ttimeout := time.Second\n\t\t\t\t\tif t == \"xtcp\" {\n\t\t\t\t\t\tif test.skipXTCP {\n\t\t\t\t\t\t\tcontinue\n\t\t\t\t\t\t}\n\t\t\t\t\t\ttimeout = 10 * time.Second\n\t\t\t\t\t}\n\t\t\t\t\tframework.NewRequestExpect(f).\n\t\t\t\t\t\tRequestModify(func(r *request.Request) {\n\t\t\t\t\t\t\tr.Timeout(timeout)\n\t\t\t\t\t\t}).\n\t\t\t\t\t\tProtocol(protocol).\n\t\t\t\t\t\tPortName(test.bindPortName).\n\t\t\t\t\t\tExplain(test.proxyName).\n\t\t\t\t\t\tExpectError(test.expectError).\n\t\t\t\t\t\tEnsure()\n\t\t\t\t}\n\t\t\t})\n\t\t}\n\t})\n\n\tginkgo.Describe(\"TCPMUX\", func() {\n\t\tginkgo.It(\"Type tcpmux\", func() {\n\t\t\tserverConf := consts.LegacyDefaultServerConfig\n\t\t\tvar clientConf strings.Builder\n\t\t\tclientConf.WriteString(consts.LegacyDefaultClientConfig)\n\n\t\t\ttcpmuxHTTPConnectPortName := port.GenName(\"TCPMUX\")\n\t\t\tserverConf += fmt.Sprintf(`\n\t\t\ttcpmux_httpconnect_port = {{ .%s }}\n\t\t\t`, tcpmuxHTTPConnectPortName)\n\n\t\t\tgetProxyConf := func(proxyName string, extra string) string {\n\t\t\t\treturn fmt.Sprintf(`\n\t\t\t\t[%s]\n\t\t\t\ttype = tcpmux\n\t\t\t\tmultiplexer = httpconnect\n\t\t\t\tlocal_port = {{ .%s }}\n\t\t\t\tcustom_domains = %s\n\t\t\t\t`+extra, proxyName, port.GenName(proxyName), proxyName)\n\t\t\t}\n\n\t\t\ttests := []struct {\n\t\t\t\tproxyName   string\n\t\t\t\textraConfig string\n\t\t\t}{\n\t\t\t\t{\n\t\t\t\t\tproxyName: \"normal\",\n\t\t\t\t},\n\t\t\t\t{\n\t\t\t\t\tproxyName:   \"with-encryption\",\n\t\t\t\t\textraConfig: \"use_encryption = true\",\n\t\t\t\t},\n\t\t\t\t{\n\t\t\t\t\tproxyName:   \"with-compression\",\n\t\t\t\t\textraConfig: \"use_compression = true\",\n\t\t\t\t},\n\t\t\t\t{\n\t\t\t\t\tproxyName: \"with-encryption-and-compression\",\n\t\t\t\t\textraConfig: `\n\t\t\t\t\t\tuse_encryption = true\n\t\t\t\t\t\tuse_compression = true\n\t\t\t\t\t`,\n\t\t\t\t},\n\t\t\t}\n\n\t\t\t// build all client config\n\t\t\tfor _, test := range tests {\n\t\t\t\tclientConf.WriteString(getProxyConf(test.proxyName, test.extraConfig) + \"\\n\")\n\n\t\t\t\tlocalServer := streamserver.New(streamserver.TCP, streamserver.WithBindPort(f.AllocPort()), streamserver.WithRespContent([]byte(test.proxyName)))\n\t\t\t\tf.RunServer(port.GenName(test.proxyName), localServer)\n\t\t\t}\n\n\t\t\t// run frps and frpc\n\t\t\tf.RunProcesses(serverConf, []string{clientConf.String()})\n\n\t\t\t// Request without HTTP connect should get error\n\t\t\tframework.NewRequestExpect(f).\n\t\t\t\tPortName(tcpmuxHTTPConnectPortName).\n\t\t\t\tExpectError(true).\n\t\t\t\tExplain(\"request without HTTP connect expect error\").\n\t\t\t\tEnsure()\n\n\t\t\tproxyURL := fmt.Sprintf(\"http://127.0.0.1:%d\", f.PortByName(tcpmuxHTTPConnectPortName))\n\t\t\t// Request with incorrect connect hostname\n\t\t\tframework.NewRequestExpect(f).RequestModify(func(r *request.Request) {\n\t\t\t\tr.Addr(\"invalid\").Proxy(proxyURL)\n\t\t\t}).ExpectError(true).Explain(\"request without HTTP connect expect error\").Ensure()\n\n\t\t\t// Request with correct connect hostname\n\t\t\tfor _, test := range tests {\n\t\t\t\tframework.NewRequestExpect(f).RequestModify(func(r *request.Request) {\n\t\t\t\t\tr.Addr(test.proxyName).Proxy(proxyURL)\n\t\t\t\t}).ExpectResp([]byte(test.proxyName)).Explain(test.proxyName).Ensure()\n\t\t\t}\n\t\t})\n\t})\n})\n"
  },
  {
    "path": "test/e2e/legacy/basic/client.go",
    "content": "package basic\n\nimport (\n\t\"context\"\n\t\"fmt\"\n\t\"strconv\"\n\t\"strings\"\n\t\"time\"\n\n\t\"github.com/onsi/ginkgo/v2\"\n\n\t\"github.com/fatedier/frp/test/e2e/framework\"\n\t\"github.com/fatedier/frp/test/e2e/framework/consts\"\n\t\"github.com/fatedier/frp/test/e2e/pkg/request\"\n)\n\nvar _ = ginkgo.Describe(\"[Feature: ClientManage]\", func() {\n\tf := framework.NewDefaultFramework()\n\n\tginkgo.It(\"Update && Reload API\", func() {\n\t\tserverConf := consts.LegacyDefaultServerConfig\n\n\t\tadminPort := f.AllocPort()\n\n\t\tp1Port := f.AllocPort()\n\t\tp2Port := f.AllocPort()\n\t\tp3Port := f.AllocPort()\n\n\t\tclientConf := consts.LegacyDefaultClientConfig + fmt.Sprintf(`\n\t\tadmin_port = %d\n\n\t\t[p1]\n\t\ttype = tcp\n\t\tlocal_port = {{ .%s }}\n\t\tremote_port = %d\n\n\t\t[p2]\n\t\ttype = tcp\n\t\tlocal_port = {{ .%s }}\n\t\tremote_port = %d\n\n\t\t[p3]\n\t\ttype = tcp\n\t\tlocal_port = {{ .%s }}\n\t\tremote_port = %d\n\t\t`, adminPort,\n\t\t\tframework.TCPEchoServerPort, p1Port,\n\t\t\tframework.TCPEchoServerPort, p2Port,\n\t\t\tframework.TCPEchoServerPort, p3Port)\n\n\t\tf.RunProcesses(serverConf, []string{clientConf})\n\n\t\tframework.NewRequestExpect(f).Port(p1Port).Ensure()\n\t\tframework.NewRequestExpect(f).Port(p2Port).Ensure()\n\t\tframework.NewRequestExpect(f).Port(p3Port).Ensure()\n\n\t\tclient := f.APIClientForFrpc(adminPort)\n\t\tconf, err := client.GetConfig(context.Background())\n\t\tframework.ExpectNoError(err)\n\n\t\tnewP2Port := f.AllocPort()\n\t\t// change p2 port and remove p3 proxy\n\t\tnewClientConf := strings.ReplaceAll(conf, strconv.Itoa(p2Port), strconv.Itoa(newP2Port))\n\t\tp3Index := strings.Index(newClientConf, \"[p3]\")\n\t\tif p3Index >= 0 {\n\t\t\tnewClientConf = newClientConf[:p3Index]\n\t\t}\n\n\t\terr = client.UpdateConfig(context.Background(), newClientConf)\n\t\tframework.ExpectNoError(err)\n\n\t\terr = client.Reload(context.Background(), true)\n\t\tframework.ExpectNoError(err)\n\t\ttime.Sleep(time.Second)\n\n\t\tframework.NewRequestExpect(f).Port(p1Port).Explain(\"p1 port\").Ensure()\n\t\tframework.NewRequestExpect(f).Port(p2Port).Explain(\"original p2 port\").ExpectError(true).Ensure()\n\t\tframework.NewRequestExpect(f).Port(newP2Port).Explain(\"new p2 port\").Ensure()\n\t\tframework.NewRequestExpect(f).Port(p3Port).Explain(\"p3 port\").ExpectError(true).Ensure()\n\t})\n\n\tginkgo.It(\"healthz\", func() {\n\t\tserverConf := consts.LegacyDefaultServerConfig\n\n\t\tdashboardPort := f.AllocPort()\n\t\tclientConf := consts.LegacyDefaultClientConfig + fmt.Sprintf(`\n\t\tadmin_addr = 0.0.0.0\n\t\tadmin_port = %d\n\t\tadmin_user = admin\n\t\tadmin_pwd = admin\n\t\t`, dashboardPort)\n\n\t\tf.RunProcesses(serverConf, []string{clientConf})\n\n\t\tframework.NewRequestExpect(f).RequestModify(func(r *request.Request) {\n\t\t\tr.HTTP().HTTPPath(\"/healthz\")\n\t\t}).Port(dashboardPort).ExpectResp([]byte(\"\")).Ensure()\n\n\t\tframework.NewRequestExpect(f).RequestModify(func(r *request.Request) {\n\t\t\tr.HTTP().HTTPPath(\"/\")\n\t\t}).Port(dashboardPort).\n\t\t\tEnsure(framework.ExpectResponseCode(401))\n\t})\n\n\tginkgo.It(\"stop\", func() {\n\t\tserverConf := consts.LegacyDefaultServerConfig\n\n\t\tadminPort := f.AllocPort()\n\t\ttestPort := f.AllocPort()\n\t\tclientConf := consts.LegacyDefaultClientConfig + fmt.Sprintf(`\n\t\tadmin_port = %d\n\n\t\t[test]\n\t\ttype = tcp\n\t\tlocal_port = {{ .%s }}\n\t\tremote_port = %d\n\t\t`, adminPort, framework.TCPEchoServerPort, testPort)\n\n\t\tf.RunProcesses(serverConf, []string{clientConf})\n\n\t\tframework.NewRequestExpect(f).Port(testPort).Ensure()\n\n\t\tclient := f.APIClientForFrpc(adminPort)\n\t\terr := client.Stop(context.Background())\n\t\tframework.ExpectNoError(err)\n\n\t\ttime.Sleep(3 * time.Second)\n\n\t\t// frpc stopped so the port is not listened, expect error\n\t\tframework.NewRequestExpect(f).Port(testPort).ExpectError(true).Ensure()\n\t})\n})\n"
  },
  {
    "path": "test/e2e/legacy/basic/client_server.go",
    "content": "package basic\n\nimport (\n\t\"fmt\"\n\t\"strings\"\n\t\"time\"\n\n\t\"github.com/onsi/ginkgo/v2\"\n\n\t\"github.com/fatedier/frp/test/e2e/framework\"\n\t\"github.com/fatedier/frp/test/e2e/framework/consts\"\n\t\"github.com/fatedier/frp/test/e2e/pkg/cert\"\n\t\"github.com/fatedier/frp/test/e2e/pkg/port\"\n)\n\ntype generalTestConfigures struct {\n\tserver        string\n\tclient        string\n\tclientPrefix  string\n\tclient2       string\n\tclient2Prefix string\n\ttestDelay     time.Duration\n\texpectError   bool\n}\n\nfunc renderBindPortConfig(protocol string) string {\n\tswitch protocol {\n\tcase \"kcp\":\n\t\treturn fmt.Sprintf(`kcp_bind_port = {{ .%s }}`, consts.PortServerName)\n\tcase \"quic\":\n\t\treturn fmt.Sprintf(`quic_bind_port = {{ .%s }}`, consts.PortServerName)\n\tdefault:\n\t\treturn \"\"\n\t}\n}\n\nfunc runClientServerTest(f *framework.Framework, configures *generalTestConfigures) {\n\tserverConf := consts.LegacyDefaultServerConfig\n\tclientConf := consts.LegacyDefaultClientConfig\n\tif configures.clientPrefix != \"\" {\n\t\tclientConf = configures.clientPrefix\n\t}\n\n\tserverConf += fmt.Sprintf(`\n\t\t\t\t%s\n\t\t\t\t`, configures.server)\n\n\ttcpPortName := port.GenName(\"TCP\")\n\tudpPortName := port.GenName(\"UDP\")\n\tclientConf += fmt.Sprintf(`\n\t\t\t\t%s\n\n\t\t\t\t[tcp]\n\t\t\t\ttype = tcp\n\t\t\t\tlocal_port = {{ .%s }}\n\t\t\t\tremote_port = {{ .%s }}\n\n\t\t\t\t[udp]\n\t\t\t\ttype = udp\n\t\t\t\tlocal_port = {{ .%s }}\n\t\t\t\tremote_port = {{ .%s }}\n\t\t\t\t`, configures.client,\n\t\tframework.TCPEchoServerPort, tcpPortName,\n\t\tframework.UDPEchoServerPort, udpPortName,\n\t)\n\n\tclientConfs := []string{clientConf}\n\tif configures.client2 != \"\" {\n\t\tclient2Conf := consts.LegacyDefaultClientConfig\n\t\tif configures.client2Prefix != \"\" {\n\t\t\tclient2Conf = configures.client2Prefix\n\t\t}\n\t\tclient2Conf += fmt.Sprintf(`\n\t\t\t%s\n\t\t`, configures.client2)\n\t\tclientConfs = append(clientConfs, client2Conf)\n\t}\n\n\tf.RunProcesses(serverConf, clientConfs)\n\n\tif configures.testDelay > 0 {\n\t\ttime.Sleep(configures.testDelay)\n\t}\n\n\tframework.NewRequestExpect(f).PortName(tcpPortName).ExpectError(configures.expectError).Explain(\"tcp proxy\").Ensure()\n\tframework.NewRequestExpect(f).Protocol(\"udp\").\n\t\tPortName(udpPortName).ExpectError(configures.expectError).Explain(\"udp proxy\").Ensure()\n}\n\n// defineClientServerTest test a normal tcp and udp proxy with specified TestConfigures.\nfunc defineClientServerTest(desc string, f *framework.Framework, configures *generalTestConfigures) {\n\tginkgo.It(desc, func() {\n\t\trunClientServerTest(f, configures)\n\t})\n}\n\nvar _ = ginkgo.Describe(\"[Feature: Client-Server]\", func() {\n\tf := framework.NewDefaultFramework()\n\n\tginkgo.Describe(\"Protocol\", func() {\n\t\tsupportProtocols := []string{\"tcp\", \"kcp\", \"quic\", \"websocket\"}\n\t\tfor _, protocol := range supportProtocols {\n\t\t\tconfigures := &generalTestConfigures{\n\t\t\t\tserver: fmt.Sprintf(`\n\t\t\t\t%s\n\t\t\t\t`, renderBindPortConfig(protocol)),\n\t\t\t\tclient: \"protocol = \" + protocol,\n\t\t\t}\n\t\t\tdefineClientServerTest(protocol, f, configures)\n\t\t}\n\t})\n\n\t// wss is special, it needs to be tested separately.\n\t// frps only supports ws, so there should be a proxy to terminate TLS before frps.\n\tginkgo.Describe(\"Protocol wss\", func() {\n\t\twssPort := f.AllocPort()\n\t\tconfigures := &generalTestConfigures{\n\t\t\tclientPrefix: fmt.Sprintf(`\n\t\t\t\t[common]\n\t\t\t\tserver_addr = 127.0.0.1\n\t\t\t\tserver_port = %d\n\t\t\t\tprotocol = wss\n\t\t\t\tlog_level = trace\n\t\t\t\tlogin_fail_exit = false\n\t\t\t`, wssPort),\n\t\t\t// Due to the fact that frps cannot directly accept wss connections, we use the https2http plugin of another frpc to terminate TLS.\n\t\t\tclient2: fmt.Sprintf(`\n\t\t\t\t[wss2ws]\n\t\t\t\ttype = tcp\n\t\t\t\tremote_port = %d\n\t\t\t\tplugin = https2http\n\t\t\t\tplugin_local_addr = 127.0.0.1:{{ .%s }}\n\t\t\t`, wssPort, consts.PortServerName),\n\t\t\ttestDelay: 10 * time.Second,\n\t\t}\n\n\t\tdefineClientServerTest(\"wss\", f, configures)\n\t})\n\n\tginkgo.Describe(\"Authentication\", func() {\n\t\tdefineClientServerTest(\"Token Correct\", f, &generalTestConfigures{\n\t\t\tserver: \"token = 123456\",\n\t\t\tclient: \"token = 123456\",\n\t\t})\n\n\t\tdefineClientServerTest(\"Token Incorrect\", f, &generalTestConfigures{\n\t\t\tserver:      \"token = 123456\",\n\t\t\tclient:      \"token = invalid\",\n\t\t\texpectError: true,\n\t\t})\n\t})\n\n\tginkgo.Describe(\"TLS\", func() {\n\t\tsupportProtocols := []string{\"tcp\", \"kcp\", \"quic\", \"websocket\"}\n\t\tfor _, protocol := range supportProtocols {\n\t\t\ttmp := protocol\n\t\t\t// Since v0.50.0, the default value of tls_enable has been changed to true.\n\t\t\t// Therefore, here it needs to be set as false to test the scenario of turning it off.\n\t\t\tdefineClientServerTest(\"Disable TLS over \"+strings.ToUpper(tmp), f, &generalTestConfigures{\n\t\t\t\tserver: fmt.Sprintf(`\n\t\t\t\t%s\n\t\t\t\t`, renderBindPortConfig(protocol)),\n\t\t\t\tclient: fmt.Sprintf(`tls_enable = false\n\t\t\t\tprotocol = %s\n\t\t\t\t`, protocol),\n\t\t\t})\n\t\t}\n\n\t\tdefineClientServerTest(\"enable tls_only, client with TLS\", f, &generalTestConfigures{\n\t\t\tserver: \"tls_only = true\",\n\t\t})\n\t\tdefineClientServerTest(\"enable tls_only, client without TLS\", f, &generalTestConfigures{\n\t\t\tserver:      \"tls_only = true\",\n\t\t\tclient:      \"tls_enable = false\",\n\t\t\texpectError: true,\n\t\t})\n\t})\n\n\tginkgo.Describe(\"TLS with custom certificate\", func() {\n\t\tsupportProtocols := []string{\"tcp\", \"kcp\", \"quic\", \"websocket\"}\n\n\t\tvar (\n\t\t\tcaCrtPath                    string\n\t\t\tserverCrtPath, serverKeyPath string\n\t\t\tclientCrtPath, clientKeyPath string\n\t\t)\n\t\tginkgo.JustBeforeEach(func() {\n\t\t\tgenerator := &cert.SelfSignedCertGenerator{}\n\t\t\tartifacts, err := generator.Generate(\"127.0.0.1\")\n\t\t\tframework.ExpectNoError(err)\n\n\t\t\tcaCrtPath = f.WriteTempFile(\"ca.crt\", string(artifacts.CACert))\n\t\t\tserverCrtPath = f.WriteTempFile(\"server.crt\", string(artifacts.Cert))\n\t\t\tserverKeyPath = f.WriteTempFile(\"server.key\", string(artifacts.Key))\n\t\t\tgenerator.SetCA(artifacts.CACert, artifacts.CAKey)\n\t\t\t_, err = generator.Generate(\"127.0.0.1\")\n\t\t\tframework.ExpectNoError(err)\n\t\t\tclientCrtPath = f.WriteTempFile(\"client.crt\", string(artifacts.Cert))\n\t\t\tclientKeyPath = f.WriteTempFile(\"client.key\", string(artifacts.Key))\n\t\t})\n\n\t\tfor _, protocol := range supportProtocols {\n\t\t\ttmp := protocol\n\n\t\t\tginkgo.It(\"one-way authentication: \"+tmp, func() {\n\t\t\t\trunClientServerTest(f, &generalTestConfigures{\n\t\t\t\t\tserver: fmt.Sprintf(`\n\t\t\t\t\t\t%s\n\t\t\t\t\t\ttls_trusted_ca_file = %s\n\t\t\t\t\t`, renderBindPortConfig(tmp), caCrtPath),\n\t\t\t\t\tclient: fmt.Sprintf(`\n\t\t\t\t\t\tprotocol = %s\n\t\t\t\t\t\ttls_cert_file = %s\n\t\t\t\t\t\ttls_key_file = %s\n\t\t\t\t\t`, tmp, clientCrtPath, clientKeyPath),\n\t\t\t\t})\n\t\t\t})\n\n\t\t\tginkgo.It(\"mutual authentication: \"+tmp, func() {\n\t\t\t\trunClientServerTest(f, &generalTestConfigures{\n\t\t\t\t\tserver: fmt.Sprintf(`\n\t\t\t\t\t\t%s\n\t\t\t\t\t\ttls_cert_file = %s\n\t\t\t\t\t\ttls_key_file = %s\n\t\t\t\t\t\ttls_trusted_ca_file = %s\n\t\t\t\t\t`, renderBindPortConfig(tmp), serverCrtPath, serverKeyPath, caCrtPath),\n\t\t\t\t\tclient: fmt.Sprintf(`\n\t\t\t\t\t\tprotocol = %s\n\t\t\t\t\t\ttls_cert_file = %s\n\t\t\t\t\t\ttls_key_file = %s\n\t\t\t\t\t\ttls_trusted_ca_file = %s\n\t\t\t\t\t`, tmp, clientCrtPath, clientKeyPath, caCrtPath),\n\t\t\t\t})\n\t\t\t})\n\t\t}\n\t})\n\n\tginkgo.Describe(\"TLS with custom certificate and specified server name\", func() {\n\t\tvar (\n\t\t\tcaCrtPath                    string\n\t\t\tserverCrtPath, serverKeyPath string\n\t\t\tclientCrtPath, clientKeyPath string\n\t\t)\n\t\tginkgo.JustBeforeEach(func() {\n\t\t\tgenerator := &cert.SelfSignedCertGenerator{}\n\t\t\tartifacts, err := generator.Generate(\"example.com\")\n\t\t\tframework.ExpectNoError(err)\n\n\t\t\tcaCrtPath = f.WriteTempFile(\"ca.crt\", string(artifacts.CACert))\n\t\t\tserverCrtPath = f.WriteTempFile(\"server.crt\", string(artifacts.Cert))\n\t\t\tserverKeyPath = f.WriteTempFile(\"server.key\", string(artifacts.Key))\n\t\t\tgenerator.SetCA(artifacts.CACert, artifacts.CAKey)\n\t\t\t_, err = generator.Generate(\"example.com\")\n\t\t\tframework.ExpectNoError(err)\n\t\t\tclientCrtPath = f.WriteTempFile(\"client.crt\", string(artifacts.Cert))\n\t\t\tclientKeyPath = f.WriteTempFile(\"client.key\", string(artifacts.Key))\n\t\t})\n\n\t\tginkgo.It(\"mutual authentication\", func() {\n\t\t\trunClientServerTest(f, &generalTestConfigures{\n\t\t\t\tserver: fmt.Sprintf(`\n\t\t\t\ttls_cert_file = %s\n\t\t\t\ttls_key_file = %s\n\t\t\t\ttls_trusted_ca_file = %s\n\t\t\t\t`, serverCrtPath, serverKeyPath, caCrtPath),\n\t\t\t\tclient: fmt.Sprintf(`\n\t\t\t\ttls_server_name = example.com\n\t\t\t\ttls_cert_file = %s\n\t\t\t\ttls_key_file = %s\n\t\t\t\ttls_trusted_ca_file = %s\n\t\t\t\t`, clientCrtPath, clientKeyPath, caCrtPath),\n\t\t\t})\n\t\t})\n\n\t\tginkgo.It(\"mutual authentication with incorrect server name\", func() {\n\t\t\trunClientServerTest(f, &generalTestConfigures{\n\t\t\t\tserver: fmt.Sprintf(`\n\t\t\t\ttls_cert_file = %s\n\t\t\t\ttls_key_file = %s\n\t\t\t\ttls_trusted_ca_file = %s\n\t\t\t\t`, serverCrtPath, serverKeyPath, caCrtPath),\n\t\t\t\tclient: fmt.Sprintf(`\n\t\t\t\ttls_server_name = invalid.com\n\t\t\t\ttls_cert_file = %s\n\t\t\t\ttls_key_file = %s\n\t\t\t\ttls_trusted_ca_file = %s\n\t\t\t\t`, clientCrtPath, clientKeyPath, caCrtPath),\n\t\t\t\texpectError: true,\n\t\t\t})\n\t\t})\n\t})\n\n\tginkgo.Describe(\"TLS with disable_custom_tls_first_byte set to false\", func() {\n\t\tsupportProtocols := []string{\"tcp\", \"kcp\", \"quic\", \"websocket\"}\n\t\tfor _, protocol := range supportProtocols {\n\t\t\ttmp := protocol\n\t\t\tdefineClientServerTest(\"TLS over \"+strings.ToUpper(tmp), f, &generalTestConfigures{\n\t\t\t\tserver: fmt.Sprintf(`\n\t\t\t\t\t%s\n\t\t\t\t\t`, renderBindPortConfig(protocol)),\n\t\t\t\tclient: fmt.Sprintf(`\n\t\t\t\t\tprotocol = %s\n\t\t\t\t\tdisable_custom_tls_first_byte = false\n\t\t\t\t\t`, protocol),\n\t\t\t})\n\t\t}\n\t})\n\n\tginkgo.Describe(\"IPv6 bind address\", func() {\n\t\tsupportProtocols := []string{\"tcp\", \"kcp\", \"quic\", \"websocket\"}\n\t\tfor _, protocol := range supportProtocols {\n\t\t\ttmp := protocol\n\t\t\tdefineClientServerTest(\"IPv6 bind address: \"+strings.ToUpper(tmp), f, &generalTestConfigures{\n\t\t\t\tserver: fmt.Sprintf(`\n\t\t\t\t\tbind_addr = ::\n\t\t\t\t\t%s\n\t\t\t\t\t`, renderBindPortConfig(protocol)),\n\t\t\t\tclient: fmt.Sprintf(`\n\t\t\t\t\tprotocol = %s\n\t\t\t\t\t`, protocol),\n\t\t\t})\n\t\t}\n\t})\n})\n"
  },
  {
    "path": "test/e2e/legacy/basic/cmd.go",
    "content": "package basic\n\nimport (\n\t\"strconv\"\n\t\"strings\"\n\n\t\"github.com/onsi/ginkgo/v2\"\n\n\t\"github.com/fatedier/frp/test/e2e/framework\"\n\t\"github.com/fatedier/frp/test/e2e/pkg/request\"\n)\n\nconst (\n\tConfigValidStr = \"syntax is ok\"\n)\n\nvar _ = ginkgo.Describe(\"[Feature: Cmd]\", func() {\n\tf := framework.NewDefaultFramework()\n\n\tginkgo.Describe(\"Verify\", func() {\n\t\tginkgo.It(\"frps valid\", func() {\n\t\t\tpath := f.GenerateConfigFile(`\n\t\t\t[common]\n\t\t\tbind_addr = 0.0.0.0\n\t\t\tbind_port = 7000\n\t\t\t`)\n\t\t\t_, output, err := f.RunFrps(\"verify\", \"-c\", path)\n\t\t\tframework.ExpectNoError(err)\n\t\t\tframework.ExpectTrue(strings.Contains(output, ConfigValidStr), \"output: %s\", output)\n\t\t})\n\t\tginkgo.It(\"frps invalid\", func() {\n\t\t\tpath := f.GenerateConfigFile(`\n\t\t\t[common]\n\t\t\tbind_addr = 0.0.0.0\n\t\t\tbind_port = 70000\n\t\t\t`)\n\t\t\t_, output, err := f.RunFrps(\"verify\", \"-c\", path)\n\t\t\tframework.ExpectNoError(err)\n\t\t\tframework.ExpectTrue(!strings.Contains(output, ConfigValidStr), \"output: %s\", output)\n\t\t})\n\t\tginkgo.It(\"frpc valid\", func() {\n\t\t\tpath := f.GenerateConfigFile(`\n\t\t\t[common]\n\t\t\tserver_addr = 0.0.0.0\n\t\t\tserver_port = 7000\n\t\t\t`)\n\t\t\t_, output, err := f.RunFrpc(\"verify\", \"-c\", path)\n\t\t\tframework.ExpectNoError(err)\n\t\t\tframework.ExpectTrue(strings.Contains(output, ConfigValidStr), \"output: %s\", output)\n\t\t})\n\t\tginkgo.It(\"frpc invalid\", func() {\n\t\t\tpath := f.GenerateConfigFile(`\n\t\t\t[common]\n\t\t\tserver_addr = 0.0.0.0\n\t\t\tserver_port = 7000\n\t\t\tprotocol = invalid\n\t\t\t`)\n\t\t\t_, output, err := f.RunFrpc(\"verify\", \"-c\", path)\n\t\t\tframework.ExpectNoError(err)\n\t\t\tframework.ExpectTrue(!strings.Contains(output, ConfigValidStr), \"output: %s\", output)\n\t\t})\n\t})\n\n\tginkgo.Describe(\"Single proxy\", func() {\n\t\tginkgo.It(\"TCP\", func() {\n\t\t\tserverPort := f.AllocPort()\n\t\t\t_, _, err := f.RunFrps(\"-t\", \"123\", \"-p\", strconv.Itoa(serverPort))\n\t\t\tframework.ExpectNoError(err)\n\n\t\t\tlocalPort := f.PortByName(framework.TCPEchoServerPort)\n\t\t\tremotePort := f.AllocPort()\n\t\t\t_, _, err = f.RunFrpc(\"tcp\", \"-s\", \"127.0.0.1\", \"-P\", strconv.Itoa(serverPort), \"-t\", \"123\", \"-u\", \"test\",\n\t\t\t\t\"-l\", strconv.Itoa(localPort), \"-r\", strconv.Itoa(remotePort), \"-n\", \"tcp_test\")\n\t\t\tframework.ExpectNoError(err)\n\n\t\t\tframework.NewRequestExpect(f).Port(remotePort).Ensure()\n\t\t})\n\n\t\tginkgo.It(\"UDP\", func() {\n\t\t\tserverPort := f.AllocPort()\n\t\t\t_, _, err := f.RunFrps(\"-t\", \"123\", \"-p\", strconv.Itoa(serverPort))\n\t\t\tframework.ExpectNoError(err)\n\n\t\t\tlocalPort := f.PortByName(framework.UDPEchoServerPort)\n\t\t\tremotePort := f.AllocPort()\n\t\t\t_, _, err = f.RunFrpc(\"udp\", \"-s\", \"127.0.0.1\", \"-P\", strconv.Itoa(serverPort), \"-t\", \"123\", \"-u\", \"test\",\n\t\t\t\t\"-l\", strconv.Itoa(localPort), \"-r\", strconv.Itoa(remotePort), \"-n\", \"udp_test\")\n\t\t\tframework.ExpectNoError(err)\n\n\t\t\tframework.NewRequestExpect(f).Protocol(\"udp\").\n\t\t\t\tPort(remotePort).Ensure()\n\t\t})\n\n\t\tginkgo.It(\"HTTP\", func() {\n\t\t\tserverPort := f.AllocPort()\n\t\t\tvhostHTTPPort := f.AllocPort()\n\t\t\t_, _, err := f.RunFrps(\"-t\", \"123\", \"-p\", strconv.Itoa(serverPort), \"--vhost_http_port\", strconv.Itoa(vhostHTTPPort))\n\t\t\tframework.ExpectNoError(err)\n\n\t\t\t_, _, err = f.RunFrpc(\"http\", \"-s\", \"127.0.0.1\", \"-P\", strconv.Itoa(serverPort), \"-t\", \"123\", \"-u\", \"test\",\n\t\t\t\t\"-n\", \"udp_test\", \"-l\", strconv.Itoa(f.PortByName(framework.HTTPSimpleServerPort)),\n\t\t\t\t\"--custom_domain\", \"test.example.com\")\n\t\t\tframework.ExpectNoError(err)\n\n\t\t\tframework.NewRequestExpect(f).Port(vhostHTTPPort).\n\t\t\t\tRequestModify(func(r *request.Request) {\n\t\t\t\t\tr.HTTP().HTTPHost(\"test.example.com\")\n\t\t\t\t}).\n\t\t\t\tEnsure()\n\t\t})\n\t})\n})\n"
  },
  {
    "path": "test/e2e/legacy/basic/config.go",
    "content": "package basic\n\nimport (\n\t\"fmt\"\n\n\t\"github.com/onsi/ginkgo/v2\"\n\n\t\"github.com/fatedier/frp/test/e2e/framework\"\n\t\"github.com/fatedier/frp/test/e2e/framework/consts\"\n\t\"github.com/fatedier/frp/test/e2e/pkg/port\"\n)\n\nvar _ = ginkgo.Describe(\"[Feature: Config]\", func() {\n\tf := framework.NewDefaultFramework()\n\n\tginkgo.Describe(\"Template\", func() {\n\t\tginkgo.It(\"render by env\", func() {\n\t\t\tserverConf := consts.LegacyDefaultServerConfig\n\t\t\tclientConf := consts.LegacyDefaultClientConfig\n\n\t\t\tportName := port.GenName(\"TCP\")\n\t\t\tserverConf += fmt.Sprintf(`\n\t\t\ttoken = {{ %s{{ .Envs.FRP_TOKEN }}%s }}\n\t\t\t`, \"`\", \"`\")\n\n\t\t\tclientConf += fmt.Sprintf(`\n\t\t\ttoken = {{ %s{{ .Envs.FRP_TOKEN }}%s }}\n\n\t\t\t[tcp]\n\t\t\ttype = tcp\n\t\t\tlocal_port = {{ .%s }}\n\t\t\tremote_port = {{ .%s }}\n\t\t\t`, \"`\", \"`\", framework.TCPEchoServerPort, portName)\n\n\t\t\tf.SetEnvs([]string{\"FRP_TOKEN=123\"})\n\t\t\tf.RunProcesses(serverConf, []string{clientConf})\n\n\t\t\tframework.NewRequestExpect(f).PortName(portName).Ensure()\n\t\t})\n\t})\n\n\tginkgo.Describe(\"Includes\", func() {\n\t\tginkgo.It(\"split tcp proxies into different files\", func() {\n\t\t\tserverPort := f.AllocPort()\n\t\t\tserverConfigPath := f.GenerateConfigFile(fmt.Sprintf(`\n\t\t\t[common]\n\t\t\tbind_addr = 0.0.0.0\n\t\t\tbind_port = %d\n\t\t\t`, serverPort))\n\n\t\t\tremotePort := f.AllocPort()\n\t\t\tproxyConfigPath := f.GenerateConfigFile(fmt.Sprintf(`\n\t\t\t[tcp]\n\t\t\ttype = tcp\n\t\t\tlocal_port = %d\n\t\t\tremote_port = %d\n\t\t\t`, f.PortByName(framework.TCPEchoServerPort), remotePort))\n\n\t\t\tremotePort2 := f.AllocPort()\n\t\t\tproxyConfigPath2 := f.GenerateConfigFile(fmt.Sprintf(`\n\t\t\t[tcp2]\n\t\t\ttype = tcp\n\t\t\tlocal_port = %d\n\t\t\tremote_port = %d\n\t\t\t`, f.PortByName(framework.TCPEchoServerPort), remotePort2))\n\n\t\t\tclientConfigPath := f.GenerateConfigFile(fmt.Sprintf(`\n\t\t\t[common]\n\t\t\tserver_port = %d\n\t\t\tincludes = %s,%s\n\t\t\t`, serverPort, proxyConfigPath, proxyConfigPath2))\n\n\t\t\t_, _, err := f.RunFrps(\"-c\", serverConfigPath)\n\t\t\tframework.ExpectNoError(err)\n\n\t\t\t_, _, err = f.RunFrpc(\"-c\", clientConfigPath)\n\t\t\tframework.ExpectNoError(err)\n\n\t\t\tframework.NewRequestExpect(f).Port(remotePort).Ensure()\n\t\t\tframework.NewRequestExpect(f).Port(remotePort2).Ensure()\n\t\t})\n\t})\n})\n"
  },
  {
    "path": "test/e2e/legacy/basic/http.go",
    "content": "package basic\n\nimport (\n\t\"fmt\"\n\t\"net/http\"\n\t\"net/url\"\n\t\"strconv\"\n\n\t\"github.com/gorilla/websocket\"\n\t\"github.com/onsi/ginkgo/v2\"\n\n\t\"github.com/fatedier/frp/test/e2e/framework\"\n\t\"github.com/fatedier/frp/test/e2e/framework/consts\"\n\t\"github.com/fatedier/frp/test/e2e/mock/server/httpserver\"\n\t\"github.com/fatedier/frp/test/e2e/pkg/request\"\n)\n\nvar _ = ginkgo.Describe(\"[Feature: HTTP]\", func() {\n\tf := framework.NewDefaultFramework()\n\n\tgetDefaultServerConf := func(vhostHTTPPort int) string {\n\t\tconf := consts.LegacyDefaultServerConfig + `\n\t\tvhost_http_port = %d\n\t\t`\n\t\treturn fmt.Sprintf(conf, vhostHTTPPort)\n\t}\n\tnewHTTPServer := func(port int, respContent string) *httpserver.Server {\n\t\treturn httpserver.New(\n\t\t\thttpserver.WithBindPort(port),\n\t\t\thttpserver.WithHandler(framework.SpecifiedHTTPBodyHandler([]byte(respContent))),\n\t\t)\n\t}\n\n\tginkgo.It(\"HTTP route by locations\", func() {\n\t\tvhostHTTPPort := f.AllocPort()\n\t\tserverConf := getDefaultServerConf(vhostHTTPPort)\n\n\t\tfooPort := f.AllocPort()\n\t\tf.RunServer(\"\", newHTTPServer(fooPort, \"foo\"))\n\n\t\tbarPort := f.AllocPort()\n\t\tf.RunServer(\"\", newHTTPServer(barPort, \"bar\"))\n\n\t\tclientConf := consts.LegacyDefaultClientConfig\n\t\tclientConf += fmt.Sprintf(`\n\t\t\t[foo]\n\t\t\ttype = http\n\t\t\tlocal_port = %d\n\t\t\tcustom_domains = normal.example.com\n\t\t\tlocations = /,/foo\n\n\t\t\t[bar]\n\t\t\ttype = http\n\t\t\tlocal_port = %d\n\t\t\tcustom_domains = normal.example.com\n\t\t\tlocations = /bar\n\t\t\t`, fooPort, barPort)\n\n\t\tf.RunProcesses(serverConf, []string{clientConf})\n\n\t\ttests := []struct {\n\t\t\tpath       string\n\t\t\texpectResp string\n\t\t\tdesc       string\n\t\t}{\n\t\t\t{path: \"/foo\", expectResp: \"foo\", desc: \"foo path\"},\n\t\t\t{path: \"/bar\", expectResp: \"bar\", desc: \"bar path\"},\n\t\t\t{path: \"/other\", expectResp: \"foo\", desc: \"other path\"},\n\t\t}\n\n\t\tfor _, test := range tests {\n\t\t\tframework.NewRequestExpect(f).Explain(test.desc).Port(vhostHTTPPort).\n\t\t\t\tRequestModify(func(r *request.Request) {\n\t\t\t\t\tr.HTTP().HTTPHost(\"normal.example.com\").HTTPPath(test.path)\n\t\t\t\t}).\n\t\t\t\tExpectResp([]byte(test.expectResp)).\n\t\t\t\tEnsure()\n\t\t}\n\t})\n\n\tginkgo.It(\"HTTP route by HTTP user\", func() {\n\t\tvhostHTTPPort := f.AllocPort()\n\t\tserverConf := getDefaultServerConf(vhostHTTPPort)\n\n\t\tfooPort := f.AllocPort()\n\t\tf.RunServer(\"\", newHTTPServer(fooPort, \"foo\"))\n\n\t\tbarPort := f.AllocPort()\n\t\tf.RunServer(\"\", newHTTPServer(barPort, \"bar\"))\n\n\t\totherPort := f.AllocPort()\n\t\tf.RunServer(\"\", newHTTPServer(otherPort, \"other\"))\n\n\t\tclientConf := consts.LegacyDefaultClientConfig\n\t\tclientConf += fmt.Sprintf(`\n\t\t\t[foo]\n\t\t\ttype = http\n\t\t\tlocal_port = %d\n\t\t\tcustom_domains = normal.example.com\n\t\t\troute_by_http_user = user1\n\n\t\t\t[bar]\n\t\t\ttype = http\n\t\t\tlocal_port = %d\n\t\t\tcustom_domains = normal.example.com\n\t\t\troute_by_http_user = user2\n\n\t\t\t[catchAll]\n\t\t\ttype = http\n\t\t\tlocal_port = %d\n\t\t\tcustom_domains = normal.example.com\n\t\t\t`, fooPort, barPort, otherPort)\n\n\t\tf.RunProcesses(serverConf, []string{clientConf})\n\n\t\t// user1\n\t\tframework.NewRequestExpect(f).Explain(\"user1\").Port(vhostHTTPPort).\n\t\t\tRequestModify(func(r *request.Request) {\n\t\t\t\tr.HTTP().HTTPHost(\"normal.example.com\").HTTPAuth(\"user1\", \"\")\n\t\t\t}).\n\t\t\tExpectResp([]byte(\"foo\")).\n\t\t\tEnsure()\n\n\t\t// user2\n\t\tframework.NewRequestExpect(f).Explain(\"user2\").Port(vhostHTTPPort).\n\t\t\tRequestModify(func(r *request.Request) {\n\t\t\t\tr.HTTP().HTTPHost(\"normal.example.com\").HTTPAuth(\"user2\", \"\")\n\t\t\t}).\n\t\t\tExpectResp([]byte(\"bar\")).\n\t\t\tEnsure()\n\n\t\t// other user\n\t\tframework.NewRequestExpect(f).Explain(\"other user\").Port(vhostHTTPPort).\n\t\t\tRequestModify(func(r *request.Request) {\n\t\t\t\tr.HTTP().HTTPHost(\"normal.example.com\").HTTPAuth(\"user3\", \"\")\n\t\t\t}).\n\t\t\tExpectResp([]byte(\"other\")).\n\t\t\tEnsure()\n\t})\n\n\tginkgo.It(\"HTTP Basic Auth\", func() {\n\t\tvhostHTTPPort := f.AllocPort()\n\t\tserverConf := getDefaultServerConf(vhostHTTPPort)\n\n\t\tclientConf := consts.LegacyDefaultClientConfig\n\t\tclientConf += fmt.Sprintf(`\n\t\t\t[test]\n\t\t\ttype = http\n\t\t\tlocal_port = {{ .%s }}\n\t\t\tcustom_domains = normal.example.com\n\t\t\thttp_user = test\n\t\t\thttp_pwd = test\n\t\t\t`, framework.HTTPSimpleServerPort)\n\n\t\tf.RunProcesses(serverConf, []string{clientConf})\n\n\t\t// not set auth header\n\t\tframework.NewRequestExpect(f).Port(vhostHTTPPort).\n\t\t\tRequestModify(func(r *request.Request) {\n\t\t\t\tr.HTTP().HTTPHost(\"normal.example.com\")\n\t\t\t}).\n\t\t\tEnsure(framework.ExpectResponseCode(401))\n\n\t\t// set incorrect auth header\n\t\tframework.NewRequestExpect(f).Port(vhostHTTPPort).\n\t\t\tRequestModify(func(r *request.Request) {\n\t\t\t\tr.HTTP().HTTPHost(\"normal.example.com\").HTTPAuth(\"test\", \"invalid\")\n\t\t\t}).\n\t\t\tEnsure(framework.ExpectResponseCode(401))\n\n\t\t// set correct auth header\n\t\tframework.NewRequestExpect(f).Port(vhostHTTPPort).\n\t\t\tRequestModify(func(r *request.Request) {\n\t\t\t\tr.HTTP().HTTPHost(\"normal.example.com\").HTTPAuth(\"test\", \"test\")\n\t\t\t}).\n\t\t\tEnsure()\n\t})\n\n\tginkgo.It(\"Wildcard domain\", func() {\n\t\tvhostHTTPPort := f.AllocPort()\n\t\tserverConf := getDefaultServerConf(vhostHTTPPort)\n\n\t\tclientConf := consts.LegacyDefaultClientConfig\n\t\tclientConf += fmt.Sprintf(`\n\t\t\t[test]\n\t\t\ttype = http\n\t\t\tlocal_port = {{ .%s }}\n\t\t\tcustom_domains = *.example.com\n\t\t\t`, framework.HTTPSimpleServerPort)\n\n\t\tf.RunProcesses(serverConf, []string{clientConf})\n\n\t\t// not match host\n\t\tframework.NewRequestExpect(f).Port(vhostHTTPPort).\n\t\t\tRequestModify(func(r *request.Request) {\n\t\t\t\tr.HTTP().HTTPHost(\"not-match.test.com\")\n\t\t\t}).\n\t\t\tEnsure(framework.ExpectResponseCode(404))\n\n\t\t// test.example.com match *.example.com\n\t\tframework.NewRequestExpect(f).Port(vhostHTTPPort).\n\t\t\tRequestModify(func(r *request.Request) {\n\t\t\t\tr.HTTP().HTTPHost(\"test.example.com\")\n\t\t\t}).\n\t\t\tEnsure()\n\n\t\t// sub.test.example.com match *.example.com\n\t\tframework.NewRequestExpect(f).Port(vhostHTTPPort).\n\t\t\tRequestModify(func(r *request.Request) {\n\t\t\t\tr.HTTP().HTTPHost(\"sub.test.example.com\")\n\t\t\t}).\n\t\t\tEnsure()\n\t})\n\n\tginkgo.It(\"Subdomain\", func() {\n\t\tvhostHTTPPort := f.AllocPort()\n\t\tserverConf := getDefaultServerConf(vhostHTTPPort)\n\t\tserverConf += `\n\t\tsubdomain_host = example.com\n\t\t`\n\n\t\tfooPort := f.AllocPort()\n\t\tf.RunServer(\"\", newHTTPServer(fooPort, \"foo\"))\n\n\t\tbarPort := f.AllocPort()\n\t\tf.RunServer(\"\", newHTTPServer(barPort, \"bar\"))\n\n\t\tclientConf := consts.LegacyDefaultClientConfig\n\t\tclientConf += fmt.Sprintf(`\n\t\t\t[foo]\n\t\t\ttype = http\n\t\t\tlocal_port = %d\n\t\t\tsubdomain = foo\n\n\t\t\t[bar]\n\t\t\ttype = http\n\t\t\tlocal_port = %d\n\t\t\tsubdomain = bar\n\t\t\t`, fooPort, barPort)\n\n\t\tf.RunProcesses(serverConf, []string{clientConf})\n\n\t\t// foo\n\t\tframework.NewRequestExpect(f).Explain(\"foo subdomain\").Port(vhostHTTPPort).\n\t\t\tRequestModify(func(r *request.Request) {\n\t\t\t\tr.HTTP().HTTPHost(\"foo.example.com\")\n\t\t\t}).\n\t\t\tExpectResp([]byte(\"foo\")).\n\t\t\tEnsure()\n\n\t\t// bar\n\t\tframework.NewRequestExpect(f).Explain(\"bar subdomain\").Port(vhostHTTPPort).\n\t\t\tRequestModify(func(r *request.Request) {\n\t\t\t\tr.HTTP().HTTPHost(\"bar.example.com\")\n\t\t\t}).\n\t\t\tExpectResp([]byte(\"bar\")).\n\t\t\tEnsure()\n\t})\n\n\tginkgo.It(\"Modify headers\", func() {\n\t\tvhostHTTPPort := f.AllocPort()\n\t\tserverConf := getDefaultServerConf(vhostHTTPPort)\n\n\t\tlocalPort := f.AllocPort()\n\t\tlocalServer := httpserver.New(\n\t\t\thttpserver.WithBindPort(localPort),\n\t\t\thttpserver.WithHandler(http.HandlerFunc(func(w http.ResponseWriter, req *http.Request) {\n\t\t\t\t_, _ = w.Write([]byte(req.Header.Get(\"X-From-Where\")))\n\t\t\t})),\n\t\t)\n\t\tf.RunServer(\"\", localServer)\n\n\t\tclientConf := consts.LegacyDefaultClientConfig\n\t\tclientConf += fmt.Sprintf(`\n\t\t\t[test]\n\t\t\ttype = http\n\t\t\tlocal_port = %d\n\t\t\tcustom_domains = normal.example.com\n\t\t\theader_X-From-Where = frp\n\t\t\t`, localPort)\n\n\t\tf.RunProcesses(serverConf, []string{clientConf})\n\n\t\t// not set auth header\n\t\tframework.NewRequestExpect(f).Port(vhostHTTPPort).\n\t\t\tRequestModify(func(r *request.Request) {\n\t\t\t\tr.HTTP().HTTPHost(\"normal.example.com\")\n\t\t\t}).\n\t\t\tExpectResp([]byte(\"frp\")). // local http server will write this X-From-Where header to response body\n\t\t\tEnsure()\n\t})\n\n\tginkgo.It(\"Host Header Rewrite\", func() {\n\t\tvhostHTTPPort := f.AllocPort()\n\t\tserverConf := getDefaultServerConf(vhostHTTPPort)\n\n\t\tlocalPort := f.AllocPort()\n\t\tlocalServer := httpserver.New(\n\t\t\thttpserver.WithBindPort(localPort),\n\t\t\thttpserver.WithHandler(http.HandlerFunc(func(w http.ResponseWriter, req *http.Request) {\n\t\t\t\t_, _ = w.Write([]byte(req.Host))\n\t\t\t})),\n\t\t)\n\t\tf.RunServer(\"\", localServer)\n\n\t\tclientConf := consts.LegacyDefaultClientConfig\n\t\tclientConf += fmt.Sprintf(`\n\t\t\t[test]\n\t\t\ttype = http\n\t\t\tlocal_port = %d\n\t\t\tcustom_domains = normal.example.com\n\t\t\thost_header_rewrite = rewrite.example.com\n\t\t\t`, localPort)\n\n\t\tf.RunProcesses(serverConf, []string{clientConf})\n\n\t\tframework.NewRequestExpect(f).Port(vhostHTTPPort).\n\t\t\tRequestModify(func(r *request.Request) {\n\t\t\t\tr.HTTP().HTTPHost(\"normal.example.com\")\n\t\t\t}).\n\t\t\tExpectResp([]byte(\"rewrite.example.com\")). // local http server will write host header to response body\n\t\t\tEnsure()\n\t})\n\n\tginkgo.It(\"Websocket protocol\", func() {\n\t\tvhostHTTPPort := f.AllocPort()\n\t\tserverConf := getDefaultServerConf(vhostHTTPPort)\n\n\t\tupgrader := websocket.Upgrader{}\n\n\t\tlocalPort := f.AllocPort()\n\t\tlocalServer := httpserver.New(\n\t\t\thttpserver.WithBindPort(localPort),\n\t\t\thttpserver.WithHandler(http.HandlerFunc(func(w http.ResponseWriter, req *http.Request) {\n\t\t\t\tc, err := upgrader.Upgrade(w, req, nil)\n\t\t\t\tif err != nil {\n\t\t\t\t\treturn\n\t\t\t\t}\n\t\t\t\tdefer c.Close()\n\t\t\t\tfor {\n\t\t\t\t\tmt, message, err := c.ReadMessage()\n\t\t\t\t\tif err != nil {\n\t\t\t\t\t\tbreak\n\t\t\t\t\t}\n\t\t\t\t\terr = c.WriteMessage(mt, message)\n\t\t\t\t\tif err != nil {\n\t\t\t\t\t\tbreak\n\t\t\t\t\t}\n\t\t\t\t}\n\t\t\t})),\n\t\t)\n\n\t\tf.RunServer(\"\", localServer)\n\n\t\tclientConf := consts.LegacyDefaultClientConfig\n\t\tclientConf += fmt.Sprintf(`\n\t\t\t[test]\n\t\t\ttype = http\n\t\t\tlocal_port = %d\n\t\t\tcustom_domains = 127.0.0.1\n\t\t\t`, localPort)\n\n\t\tf.RunProcesses(serverConf, []string{clientConf})\n\n\t\tu := url.URL{Scheme: \"ws\", Host: \"127.0.0.1:\" + strconv.Itoa(vhostHTTPPort)}\n\t\tc, _, err := websocket.DefaultDialer.Dial(u.String(), nil)\n\t\tframework.ExpectNoError(err)\n\n\t\terr = c.WriteMessage(websocket.TextMessage, []byte(consts.TestString))\n\t\tframework.ExpectNoError(err)\n\n\t\t_, msg, err := c.ReadMessage()\n\t\tframework.ExpectNoError(err)\n\t\tframework.ExpectEqualValues(consts.TestString, string(msg))\n\t})\n})\n"
  },
  {
    "path": "test/e2e/legacy/basic/server.go",
    "content": "package basic\n\nimport (\n\t\"context\"\n\t\"fmt\"\n\t\"net\"\n\t\"strconv\"\n\n\t\"github.com/onsi/ginkgo/v2\"\n\n\t\"github.com/fatedier/frp/test/e2e/framework\"\n\t\"github.com/fatedier/frp/test/e2e/framework/consts\"\n\t\"github.com/fatedier/frp/test/e2e/pkg/port\"\n\t\"github.com/fatedier/frp/test/e2e/pkg/request\"\n)\n\nvar _ = ginkgo.Describe(\"[Feature: Server Manager]\", func() {\n\tf := framework.NewDefaultFramework()\n\n\tginkgo.It(\"Ports Whitelist\", func() {\n\t\tserverConf := consts.LegacyDefaultServerConfig\n\t\tclientConf := consts.LegacyDefaultClientConfig\n\n\t\tserverConf += `\n\t\t\tallow_ports = 10000-11000,11002,12000-13000\n\t\t`\n\n\t\ttcpPortName := port.GenName(\"TCP\", port.WithRangePorts(10000, 11000))\n\t\tudpPortName := port.GenName(\"UDP\", port.WithRangePorts(12000, 13000))\n\t\tclientConf += fmt.Sprintf(`\n\t\t\t[tcp-allowed-in-range]\n\t\t\ttype = tcp\n\t\t\tlocal_port = {{ .%s }}\n\t\t\tremote_port = {{ .%s }}\n\t\t\t`, framework.TCPEchoServerPort, tcpPortName)\n\t\tclientConf += fmt.Sprintf(`\n\t\t\t[tcp-port-not-allowed]\n\t\t\ttype = tcp\n\t\t\tlocal_port = {{ .%s }}\n\t\t\tremote_port = 11001\n\t\t\t`, framework.TCPEchoServerPort)\n\t\tclientConf += fmt.Sprintf(`\n\t\t\t[tcp-port-unavailable]\n\t\t\ttype = tcp\n\t\t\tlocal_port = {{ .%s }}\n\t\t\tremote_port = {{ .%s }}\n\t\t\t`, framework.TCPEchoServerPort, consts.PortServerName)\n\t\tclientConf += fmt.Sprintf(`\n\t\t\t[udp-allowed-in-range]\n\t\t\ttype = udp\n\t\t\tlocal_port = {{ .%s }}\n\t\t\tremote_port = {{ .%s }}\n\t\t\t`, framework.UDPEchoServerPort, udpPortName)\n\t\tclientConf += fmt.Sprintf(`\n\t\t\t[udp-port-not-allowed]\n\t\t\ttype = udp\n\t\t\tlocal_port = {{ .%s }}\n\t\t\tremote_port = 11003\n\t\t\t`, framework.UDPEchoServerPort)\n\n\t\tf.RunProcesses(serverConf, []string{clientConf})\n\n\t\t// TCP\n\t\t// Allowed in range\n\t\tframework.NewRequestExpect(f).PortName(tcpPortName).Ensure()\n\n\t\t// Not Allowed\n\t\tframework.NewRequestExpect(f).Port(11001).ExpectError(true).Ensure()\n\n\t\t// Unavailable, already bind by frps\n\t\tframework.NewRequestExpect(f).PortName(consts.PortServerName).ExpectError(true).Ensure()\n\n\t\t// UDP\n\t\t// Allowed in range\n\t\tframework.NewRequestExpect(f).Protocol(\"udp\").PortName(udpPortName).Ensure()\n\n\t\t// Not Allowed\n\t\tframework.NewRequestExpect(f).RequestModify(func(r *request.Request) {\n\t\t\tr.UDP().Port(11003)\n\t\t}).ExpectError(true).Ensure()\n\t})\n\n\tginkgo.It(\"Alloc Random Port\", func() {\n\t\tserverConf := consts.LegacyDefaultServerConfig\n\t\tclientConf := consts.LegacyDefaultClientConfig\n\n\t\tadminPort := f.AllocPort()\n\t\tclientConf += fmt.Sprintf(`\n\t\tadmin_port = %d\n\n\t\t[tcp]\n\t\ttype = tcp\n\t\tlocal_port = {{ .%s }}\n\n\t\t[udp]\n\t\ttype = udp\n\t\tlocal_port = {{ .%s }}\n\t\t`, adminPort, framework.TCPEchoServerPort, framework.UDPEchoServerPort)\n\n\t\tf.RunProcesses(serverConf, []string{clientConf})\n\n\t\tclient := f.APIClientForFrpc(adminPort)\n\n\t\t// tcp random port\n\t\tstatus, err := client.GetProxyStatus(context.Background(), \"tcp\")\n\t\tframework.ExpectNoError(err)\n\n\t\t_, portStr, err := net.SplitHostPort(status.RemoteAddr)\n\t\tframework.ExpectNoError(err)\n\t\tport, err := strconv.Atoi(portStr)\n\t\tframework.ExpectNoError(err)\n\n\t\tframework.NewRequestExpect(f).Port(port).Ensure()\n\n\t\t// udp random port\n\t\tstatus, err = client.GetProxyStatus(context.Background(), \"udp\")\n\t\tframework.ExpectNoError(err)\n\n\t\t_, portStr, err = net.SplitHostPort(status.RemoteAddr)\n\t\tframework.ExpectNoError(err)\n\t\tport, err = strconv.Atoi(portStr)\n\t\tframework.ExpectNoError(err)\n\n\t\tframework.NewRequestExpect(f).Protocol(\"udp\").Port(port).Ensure()\n\t})\n\n\tginkgo.It(\"Port Reuse\", func() {\n\t\tserverConf := consts.LegacyDefaultServerConfig\n\t\t// Use same port as PortServer\n\t\tserverConf += fmt.Sprintf(`\n\t\tvhost_http_port = {{ .%s }}\n\t\t`, consts.PortServerName)\n\n\t\tclientConf := consts.LegacyDefaultClientConfig + fmt.Sprintf(`\n\t\t[http]\n\t\ttype = http\n\t\tlocal_port = {{ .%s }}\n\t\tcustom_domains = example.com\n\t\t`, framework.HTTPSimpleServerPort)\n\n\t\tf.RunProcesses(serverConf, []string{clientConf})\n\n\t\tframework.NewRequestExpect(f).RequestModify(func(r *request.Request) {\n\t\t\tr.HTTP().HTTPHost(\"example.com\")\n\t\t}).PortName(consts.PortServerName).Ensure()\n\t})\n\n\tginkgo.It(\"healthz\", func() {\n\t\tserverConf := consts.LegacyDefaultServerConfig\n\t\tdashboardPort := f.AllocPort()\n\n\t\t// Use same port as PortServer\n\t\tserverConf += fmt.Sprintf(`\n\t\tvhost_http_port = {{ .%s }}\n\t\tdashboard_addr = 0.0.0.0\n\t\tdashboard_port = %d\n\t\tdashboard_user = admin\n\t\tdashboard_pwd = admin\n\t\t`, consts.PortServerName, dashboardPort)\n\n\t\tclientConf := consts.LegacyDefaultClientConfig + fmt.Sprintf(`\n\t\t[http]\n\t\ttype = http\n\t\tlocal_port = {{ .%s }}\n\t\tcustom_domains = example.com\n\t\t`, framework.HTTPSimpleServerPort)\n\n\t\tf.RunProcesses(serverConf, []string{clientConf})\n\n\t\tframework.NewRequestExpect(f).RequestModify(func(r *request.Request) {\n\t\t\tr.HTTP().HTTPPath(\"/healthz\")\n\t\t}).Port(dashboardPort).ExpectResp([]byte(\"\")).Ensure()\n\n\t\tframework.NewRequestExpect(f).RequestModify(func(r *request.Request) {\n\t\t\tr.HTTP().HTTPPath(\"/\")\n\t\t}).Port(dashboardPort).\n\t\t\tEnsure(framework.ExpectResponseCode(401))\n\t})\n})\n"
  },
  {
    "path": "test/e2e/legacy/basic/tcpmux.go",
    "content": "package basic\n\nimport (\n\t\"bufio\"\n\t\"fmt\"\n\t\"net\"\n\t\"net/http\"\n\n\t\"github.com/onsi/ginkgo/v2\"\n\n\thttppkg \"github.com/fatedier/frp/pkg/util/http\"\n\t\"github.com/fatedier/frp/test/e2e/framework\"\n\t\"github.com/fatedier/frp/test/e2e/framework/consts\"\n\t\"github.com/fatedier/frp/test/e2e/mock/server/streamserver\"\n\t\"github.com/fatedier/frp/test/e2e/pkg/request\"\n\t\"github.com/fatedier/frp/test/e2e/pkg/rpc\"\n)\n\nvar _ = ginkgo.Describe(\"[Feature: TCPMUX httpconnect]\", func() {\n\tf := framework.NewDefaultFramework()\n\n\tgetDefaultServerConf := func(httpconnectPort int) string {\n\t\tconf := consts.LegacyDefaultServerConfig + `\n\t\ttcpmux_httpconnect_port = %d\n\t\t`\n\t\treturn fmt.Sprintf(conf, httpconnectPort)\n\t}\n\tnewServer := func(port int, respContent string) *streamserver.Server {\n\t\treturn streamserver.New(\n\t\t\tstreamserver.TCP,\n\t\t\tstreamserver.WithBindPort(port),\n\t\t\tstreamserver.WithRespContent([]byte(respContent)),\n\t\t)\n\t}\n\n\tproxyURLWithAuth := func(username, password string, port int) string {\n\t\tif username == \"\" {\n\t\t\treturn fmt.Sprintf(\"http://127.0.0.1:%d\", port)\n\t\t}\n\t\treturn fmt.Sprintf(\"http://%s:%s@127.0.0.1:%d\", username, password, port)\n\t}\n\n\tginkgo.It(\"Route by HTTP user\", func() {\n\t\tvhostPort := f.AllocPort()\n\t\tserverConf := getDefaultServerConf(vhostPort)\n\n\t\tfooPort := f.AllocPort()\n\t\tf.RunServer(\"\", newServer(fooPort, \"foo\"))\n\n\t\tbarPort := f.AllocPort()\n\t\tf.RunServer(\"\", newServer(barPort, \"bar\"))\n\n\t\totherPort := f.AllocPort()\n\t\tf.RunServer(\"\", newServer(otherPort, \"other\"))\n\n\t\tclientConf := consts.LegacyDefaultClientConfig\n\t\tclientConf += fmt.Sprintf(`\n\t\t\t[foo]\n\t\t\ttype = tcpmux\n\t\t\tmultiplexer = httpconnect\n\t\t\tlocal_port = %d\n\t\t\tcustom_domains = normal.example.com\n\t\t\troute_by_http_user = user1\n\n\t\t\t[bar]\n\t\t\ttype = tcpmux\n\t\t\tmultiplexer = httpconnect\n\t\t\tlocal_port = %d\n\t\t\tcustom_domains = normal.example.com\n\t\t\troute_by_http_user = user2\n\n\t\t\t[catchAll]\n\t\t\ttype = tcpmux\n\t\t\tmultiplexer = httpconnect\n\t\t\tlocal_port = %d\n\t\t\tcustom_domains = normal.example.com\n\t\t\t`, fooPort, barPort, otherPort)\n\n\t\tf.RunProcesses(serverConf, []string{clientConf})\n\n\t\t// user1\n\t\tframework.NewRequestExpect(f).Explain(\"user1\").\n\t\t\tRequestModify(func(r *request.Request) {\n\t\t\t\tr.Addr(\"normal.example.com\").Proxy(proxyURLWithAuth(\"user1\", \"\", vhostPort))\n\t\t\t}).\n\t\t\tExpectResp([]byte(\"foo\")).\n\t\t\tEnsure()\n\n\t\t// user2\n\t\tframework.NewRequestExpect(f).Explain(\"user2\").\n\t\t\tRequestModify(func(r *request.Request) {\n\t\t\t\tr.Addr(\"normal.example.com\").Proxy(proxyURLWithAuth(\"user2\", \"\", vhostPort))\n\t\t\t}).\n\t\t\tExpectResp([]byte(\"bar\")).\n\t\t\tEnsure()\n\n\t\t// other user\n\t\tframework.NewRequestExpect(f).Explain(\"other user\").\n\t\t\tRequestModify(func(r *request.Request) {\n\t\t\t\tr.Addr(\"normal.example.com\").Proxy(proxyURLWithAuth(\"user3\", \"\", vhostPort))\n\t\t\t}).\n\t\t\tExpectResp([]byte(\"other\")).\n\t\t\tEnsure()\n\t})\n\n\tginkgo.It(\"Proxy auth\", func() {\n\t\tvhostPort := f.AllocPort()\n\t\tserverConf := getDefaultServerConf(vhostPort)\n\n\t\tfooPort := f.AllocPort()\n\t\tf.RunServer(\"\", newServer(fooPort, \"foo\"))\n\n\t\tclientConf := consts.LegacyDefaultClientConfig\n\t\tclientConf += fmt.Sprintf(`\n\t\t\t[test]\n\t\t\ttype = tcpmux\n\t\t\tmultiplexer = httpconnect\n\t\t\tlocal_port = %d\n\t\t\tcustom_domains = normal.example.com\n\t\t\thttp_user = test\n\t\t\thttp_pwd = test\n\t\t`, fooPort)\n\n\t\tf.RunProcesses(serverConf, []string{clientConf})\n\n\t\t// not set auth header\n\t\tframework.NewRequestExpect(f).Explain(\"no auth\").\n\t\t\tRequestModify(func(r *request.Request) {\n\t\t\t\tr.Addr(\"normal.example.com\").Proxy(proxyURLWithAuth(\"\", \"\", vhostPort))\n\t\t\t}).\n\t\t\tExpectError(true).\n\t\t\tEnsure()\n\n\t\t// set incorrect auth header\n\t\tframework.NewRequestExpect(f).Explain(\"incorrect auth\").\n\t\t\tRequestModify(func(r *request.Request) {\n\t\t\t\tr.Addr(\"normal.example.com\").Proxy(proxyURLWithAuth(\"test\", \"invalid\", vhostPort))\n\t\t\t}).\n\t\t\tExpectError(true).\n\t\t\tEnsure()\n\n\t\t// set correct auth header\n\t\tframework.NewRequestExpect(f).Explain(\"correct auth\").\n\t\t\tRequestModify(func(r *request.Request) {\n\t\t\t\tr.Addr(\"normal.example.com\").Proxy(proxyURLWithAuth(\"test\", \"test\", vhostPort))\n\t\t\t}).\n\t\t\tExpectResp([]byte(\"foo\")).\n\t\t\tEnsure()\n\t})\n\n\tginkgo.It(\"TCPMux Passthrough\", func() {\n\t\tvhostPort := f.AllocPort()\n\t\tserverConf := getDefaultServerConf(vhostPort)\n\t\tserverConf += `\n\t\t\ttcpmux_passthrough = true\n\t\t`\n\n\t\tvar (\n\t\t\trespErr            error\n\t\t\tconnectRequestHost string\n\t\t)\n\t\tnewServer := func(port int) *streamserver.Server {\n\t\t\treturn streamserver.New(\n\t\t\t\tstreamserver.TCP,\n\t\t\t\tstreamserver.WithBindPort(port),\n\t\t\t\tstreamserver.WithCustomHandler(func(conn net.Conn) {\n\t\t\t\t\tdefer conn.Close()\n\n\t\t\t\t\t// read HTTP CONNECT request\n\t\t\t\t\tbufioReader := bufio.NewReader(conn)\n\t\t\t\t\treq, err := http.ReadRequest(bufioReader)\n\t\t\t\t\tif err != nil {\n\t\t\t\t\t\trespErr = err\n\t\t\t\t\t\treturn\n\t\t\t\t\t}\n\t\t\t\t\tconnectRequestHost = req.Host\n\n\t\t\t\t\t// return ok response\n\t\t\t\t\tres := httppkg.OkResponse()\n\t\t\t\t\tif res.Body != nil {\n\t\t\t\t\t\tdefer res.Body.Close()\n\t\t\t\t\t}\n\t\t\t\t\t_ = res.Write(conn)\n\n\t\t\t\t\tbuf, err := rpc.ReadBytes(conn)\n\t\t\t\t\tif err != nil {\n\t\t\t\t\t\trespErr = err\n\t\t\t\t\t\treturn\n\t\t\t\t\t}\n\t\t\t\t\t_, _ = rpc.WriteBytes(conn, buf)\n\t\t\t\t}),\n\t\t\t)\n\t\t}\n\n\t\tlocalPort := f.AllocPort()\n\t\tf.RunServer(\"\", newServer(localPort))\n\n\t\tclientConf := consts.LegacyDefaultClientConfig\n\t\tclientConf += fmt.Sprintf(`\n\t\t\t[test]\n\t\t\ttype = tcpmux\n\t\t\tmultiplexer = httpconnect\n\t\t\tlocal_port = %d\n\t\t\tcustom_domains = normal.example.com\n\t\t\t`, localPort)\n\n\t\tf.RunProcesses(serverConf, []string{clientConf})\n\n\t\tframework.NewRequestExpect(f).\n\t\t\tRequestModify(func(r *request.Request) {\n\t\t\t\tr.Addr(\"normal.example.com\").Proxy(proxyURLWithAuth(\"\", \"\", vhostPort)).Body([]byte(\"frp\"))\n\t\t\t}).\n\t\t\tExpectResp([]byte(\"frp\")).\n\t\t\tEnsure()\n\t\tframework.ExpectNoError(respErr)\n\t\tframework.ExpectEqualValues(connectRequestHost, \"normal.example.com\")\n\t})\n})\n"
  },
  {
    "path": "test/e2e/legacy/basic/xtcp.go",
    "content": "package basic\n\nimport (\n\t\"fmt\"\n\t\"time\"\n\n\t\"github.com/onsi/ginkgo/v2\"\n\n\t\"github.com/fatedier/frp/test/e2e/framework\"\n\t\"github.com/fatedier/frp/test/e2e/framework/consts\"\n\t\"github.com/fatedier/frp/test/e2e/pkg/port\"\n\t\"github.com/fatedier/frp/test/e2e/pkg/request\"\n)\n\nvar _ = ginkgo.Describe(\"[Feature: XTCP]\", func() {\n\tf := framework.NewDefaultFramework()\n\n\tginkgo.It(\"Fallback To STCP\", func() {\n\t\tserverConf := consts.LegacyDefaultServerConfig\n\t\tclientConf := consts.LegacyDefaultClientConfig\n\n\t\tbindPortName := port.GenName(\"XTCP\")\n\t\tclientConf += fmt.Sprintf(`\n\t\t\t[foo]\n\t\t\ttype = stcp\n\t\t\tlocal_port = {{ .%s }}\n\n\t\t\t[foo-visitor]\n\t\t\ttype = stcp\n\t\t\trole = visitor\n\t\t\tserver_name = foo\n\t\t\tbind_port = -1\n\n\t\t\t[bar-visitor]\n\t\t\ttype = xtcp\n\t\t\trole = visitor\n\t\t\tserver_name = bar\n\t\t\tbind_port = {{ .%s }}\n\t\t\tkeep_tunnel_open = true\n\t\t\tfallback_to = foo-visitor\n\t\t\tfallback_timeout_ms = 200\n\t\t\t`, framework.TCPEchoServerPort, bindPortName)\n\n\t\tf.RunProcesses(serverConf, []string{clientConf})\n\t\tframework.NewRequestExpect(f).\n\t\t\tRequestModify(func(r *request.Request) {\n\t\t\t\tr.Timeout(time.Second)\n\t\t\t}).\n\t\t\tPortName(bindPortName).\n\t\t\tEnsure()\n\t})\n})\n"
  },
  {
    "path": "test/e2e/legacy/features/bandwidth_limit.go",
    "content": "package features\n\nimport (\n\t\"fmt\"\n\t\"strings\"\n\t\"time\"\n\n\t\"github.com/onsi/ginkgo/v2\"\n\n\tplugin \"github.com/fatedier/frp/pkg/plugin/server\"\n\t\"github.com/fatedier/frp/test/e2e/framework\"\n\t\"github.com/fatedier/frp/test/e2e/framework/consts\"\n\t\"github.com/fatedier/frp/test/e2e/mock/server/streamserver\"\n\tpluginpkg \"github.com/fatedier/frp/test/e2e/pkg/plugin\"\n\t\"github.com/fatedier/frp/test/e2e/pkg/request\"\n)\n\nvar _ = ginkgo.Describe(\"[Feature: Bandwidth Limit]\", func() {\n\tf := framework.NewDefaultFramework()\n\n\tginkgo.It(\"Proxy Bandwidth Limit by Client\", func() {\n\t\tserverConf := consts.LegacyDefaultServerConfig\n\t\tclientConf := consts.LegacyDefaultClientConfig\n\n\t\tlocalPort := f.AllocPort()\n\t\tlocalServer := streamserver.New(streamserver.TCP, streamserver.WithBindPort(localPort))\n\t\tf.RunServer(\"\", localServer)\n\n\t\tremotePort := f.AllocPort()\n\t\tclientConf += fmt.Sprintf(`\n\t\t\t[tcp]\n\t\t\ttype = tcp\n\t\t\tlocal_port = %d\n\t\t\tremote_port = %d\n\t\t\tbandwidth_limit = 10KB\n\t\t\t`, localPort, remotePort)\n\n\t\tf.RunProcesses(serverConf, []string{clientConf})\n\n\t\tcontent := strings.Repeat(\"a\", 50*1024) // 5KB\n\t\tstart := time.Now()\n\t\tframework.NewRequestExpect(f).Port(remotePort).RequestModify(func(r *request.Request) {\n\t\t\tr.Body([]byte(content)).Timeout(30 * time.Second)\n\t\t}).ExpectResp([]byte(content)).Ensure()\n\n\t\tduration := time.Since(start)\n\t\tframework.Logf(\"request duration: %s\", duration.String())\n\n\t\tframework.ExpectTrue(duration.Seconds() > 8, \"100Kb with 10KB limit, want > 8 seconds, but got %s\", duration.String())\n\t})\n\n\tginkgo.It(\"Proxy Bandwidth Limit by Server\", func() {\n\t\t// new test plugin server\n\t\tnewFunc := func() *plugin.Request {\n\t\t\tvar r plugin.Request\n\t\t\tr.Content = &plugin.NewProxyContent{}\n\t\t\treturn &r\n\t\t}\n\t\tpluginPort := f.AllocPort()\n\t\thandler := func(req *plugin.Request) *plugin.Response {\n\t\t\tvar ret plugin.Response\n\t\t\tcontent := req.Content.(*plugin.NewProxyContent)\n\t\t\tcontent.BandwidthLimit = \"10KB\"\n\t\t\tcontent.BandwidthLimitMode = \"server\"\n\t\t\tret.Content = content\n\t\t\treturn &ret\n\t\t}\n\t\tpluginServer := pluginpkg.NewHTTPPluginServer(pluginPort, newFunc, handler, nil)\n\n\t\tf.RunServer(\"\", pluginServer)\n\n\t\tserverConf := consts.LegacyDefaultServerConfig + fmt.Sprintf(`\n\t\t[plugin.test]\n\t\taddr = 127.0.0.1:%d\n\t\tpath = /handler\n\t\tops = NewProxy\n\t\t`, pluginPort)\n\t\tclientConf := consts.LegacyDefaultClientConfig\n\n\t\tlocalPort := f.AllocPort()\n\t\tlocalServer := streamserver.New(streamserver.TCP, streamserver.WithBindPort(localPort))\n\t\tf.RunServer(\"\", localServer)\n\n\t\tremotePort := f.AllocPort()\n\t\tclientConf += fmt.Sprintf(`\n\t\t\t[tcp]\n\t\t\ttype = tcp\n\t\t\tlocal_port = %d\n\t\t\tremote_port = %d\n\t\t\t`, localPort, remotePort)\n\n\t\tf.RunProcesses(serverConf, []string{clientConf})\n\n\t\tcontent := strings.Repeat(\"a\", 50*1024) // 5KB\n\t\tstart := time.Now()\n\t\tframework.NewRequestExpect(f).Port(remotePort).RequestModify(func(r *request.Request) {\n\t\t\tr.Body([]byte(content)).Timeout(30 * time.Second)\n\t\t}).ExpectResp([]byte(content)).Ensure()\n\n\t\tduration := time.Since(start)\n\t\tframework.Logf(\"request duration: %s\", duration.String())\n\n\t\tframework.ExpectTrue(duration.Seconds() > 8, \"100Kb with 10KB limit, want > 8 seconds, but got %s\", duration.String())\n\t})\n})\n"
  },
  {
    "path": "test/e2e/legacy/features/chaos.go",
    "content": "package features\n\nimport (\n\t\"fmt\"\n\t\"time\"\n\n\t\"github.com/onsi/ginkgo/v2\"\n\n\t\"github.com/fatedier/frp/test/e2e/framework\"\n)\n\nvar _ = ginkgo.Describe(\"[Feature: Chaos]\", func() {\n\tf := framework.NewDefaultFramework()\n\n\tginkgo.It(\"reconnect after frps restart\", func() {\n\t\tserverPort := f.AllocPort()\n\t\tserverConfigPath := f.GenerateConfigFile(fmt.Sprintf(`\n\t\t[common]\n\t\tbind_addr = 0.0.0.0\n\t\tbind_port = %d\n\t\t`, serverPort))\n\n\t\tremotePort := f.AllocPort()\n\t\tclientConfigPath := f.GenerateConfigFile(fmt.Sprintf(`\n\t\t[common]\n\t\tserver_port = %d\n\t\tlog_level = trace\n\n\t\t[tcp]\n\t\ttype = tcp\n\t\tlocal_port = %d\n\t\tremote_port = %d\n\t\t`, serverPort, f.PortByName(framework.TCPEchoServerPort), remotePort))\n\n\t\t// 1. start frps and frpc, expect request success\n\t\tps, _, err := f.RunFrps(\"-c\", serverConfigPath)\n\t\tframework.ExpectNoError(err)\n\n\t\tpc, _, err := f.RunFrpc(\"-c\", clientConfigPath)\n\t\tframework.ExpectNoError(err)\n\t\tframework.NewRequestExpect(f).Port(remotePort).Ensure()\n\n\t\t// 2. stop frps, expect request failed\n\t\t_ = ps.Stop()\n\t\ttime.Sleep(200 * time.Millisecond)\n\t\tframework.NewRequestExpect(f).Port(remotePort).ExpectError(true).Ensure()\n\n\t\t// 3. restart frps, expect request success\n\t\t_, _, err = f.RunFrps(\"-c\", serverConfigPath)\n\t\tframework.ExpectNoError(err)\n\t\ttime.Sleep(2 * time.Second)\n\t\tframework.NewRequestExpect(f).Port(remotePort).Ensure()\n\n\t\t// 4. stop frpc, expect request failed\n\t\t_ = pc.Stop()\n\t\ttime.Sleep(200 * time.Millisecond)\n\t\tframework.NewRequestExpect(f).Port(remotePort).ExpectError(true).Ensure()\n\n\t\t// 5. restart frpc, expect request success\n\t\t_, _, err = f.RunFrpc(\"-c\", clientConfigPath)\n\t\tframework.ExpectNoError(err)\n\t\ttime.Sleep(time.Second)\n\t\tframework.NewRequestExpect(f).Port(remotePort).Ensure()\n\t})\n})\n"
  },
  {
    "path": "test/e2e/legacy/features/group.go",
    "content": "package features\n\nimport (\n\t\"fmt\"\n\t\"strconv\"\n\t\"sync\"\n\t\"time\"\n\n\t\"github.com/onsi/ginkgo/v2\"\n\n\t\"github.com/fatedier/frp/test/e2e/framework\"\n\t\"github.com/fatedier/frp/test/e2e/framework/consts\"\n\t\"github.com/fatedier/frp/test/e2e/mock/server/httpserver\"\n\t\"github.com/fatedier/frp/test/e2e/mock/server/streamserver\"\n\t\"github.com/fatedier/frp/test/e2e/pkg/request\"\n)\n\nvar _ = ginkgo.Describe(\"[Feature: Group]\", func() {\n\tf := framework.NewDefaultFramework()\n\n\tnewHTTPServer := func(port int, respContent string) *httpserver.Server {\n\t\treturn httpserver.New(\n\t\t\thttpserver.WithBindPort(port),\n\t\t\thttpserver.WithHandler(framework.SpecifiedHTTPBodyHandler([]byte(respContent))),\n\t\t)\n\t}\n\n\tvalidateFooBarResponse := func(resp *request.Response) bool {\n\t\tif string(resp.Content) == \"foo\" || string(resp.Content) == \"bar\" {\n\t\t\treturn true\n\t\t}\n\t\treturn false\n\t}\n\n\tdoFooBarHTTPRequest := func(vhostPort int, host string) []string {\n\t\tresults := []string{}\n\t\tvar wait sync.WaitGroup\n\t\tvar mu sync.Mutex\n\t\texpectFn := func() {\n\t\t\tframework.NewRequestExpect(f).Port(vhostPort).\n\t\t\t\tRequestModify(func(r *request.Request) {\n\t\t\t\t\tr.HTTP().HTTPHost(host)\n\t\t\t\t}).\n\t\t\t\tEnsure(validateFooBarResponse, func(resp *request.Response) bool {\n\t\t\t\t\tmu.Lock()\n\t\t\t\t\tdefer mu.Unlock()\n\t\t\t\t\tresults = append(results, string(resp.Content))\n\t\t\t\t\treturn true\n\t\t\t\t})\n\t\t}\n\t\tfor range 10 {\n\t\t\twait.Go(func() {\n\t\t\t\texpectFn()\n\t\t\t})\n\t\t}\n\n\t\twait.Wait()\n\t\treturn results\n\t}\n\n\tginkgo.Describe(\"Load Balancing\", func() {\n\t\tginkgo.It(\"TCP\", func() {\n\t\t\tserverConf := consts.LegacyDefaultServerConfig\n\t\t\tclientConf := consts.LegacyDefaultClientConfig\n\n\t\t\tfooPort := f.AllocPort()\n\t\t\tfooServer := streamserver.New(streamserver.TCP, streamserver.WithBindPort(fooPort), streamserver.WithRespContent([]byte(\"foo\")))\n\t\t\tf.RunServer(\"\", fooServer)\n\n\t\t\tbarPort := f.AllocPort()\n\t\t\tbarServer := streamserver.New(streamserver.TCP, streamserver.WithBindPort(barPort), streamserver.WithRespContent([]byte(\"bar\")))\n\t\t\tf.RunServer(\"\", barServer)\n\n\t\t\tremotePort := f.AllocPort()\n\t\t\tclientConf += fmt.Sprintf(`\n\t\t\t[foo]\n\t\t\ttype = tcp\n\t\t\tlocal_port = %d\n\t\t\tremote_port = %d\n\t\t\tgroup = test\n\t\t\tgroup_key = 123\n\n\t\t\t[bar]\n\t\t\ttype = tcp\n\t\t\tlocal_port = %d\n\t\t\tremote_port = %d\n\t\t\tgroup = test\n\t\t\tgroup_key = 123\n\t\t\t`, fooPort, remotePort, barPort, remotePort)\n\n\t\t\tf.RunProcesses(serverConf, []string{clientConf})\n\n\t\t\tfooCount := 0\n\t\t\tbarCount := 0\n\t\t\tfor i := range 10 {\n\t\t\t\tframework.NewRequestExpect(f).Explain(\"times \" + strconv.Itoa(i)).Port(remotePort).Ensure(func(resp *request.Response) bool {\n\t\t\t\t\tswitch string(resp.Content) {\n\t\t\t\t\tcase \"foo\":\n\t\t\t\t\t\tfooCount++\n\t\t\t\t\tcase \"bar\":\n\t\t\t\t\t\tbarCount++\n\t\t\t\t\tdefault:\n\t\t\t\t\t\treturn false\n\t\t\t\t\t}\n\t\t\t\t\treturn true\n\t\t\t\t})\n\t\t\t}\n\n\t\t\tframework.ExpectTrue(fooCount > 1 && barCount > 1, \"fooCount: %d, barCount: %d\", fooCount, barCount)\n\t\t})\n\t})\n\n\tginkgo.Describe(\"Health Check\", func() {\n\t\tginkgo.It(\"TCP\", func() {\n\t\t\tserverConf := consts.LegacyDefaultServerConfig\n\t\t\tclientConf := consts.LegacyDefaultClientConfig\n\n\t\t\tfooPort := f.AllocPort()\n\t\t\tfooServer := streamserver.New(streamserver.TCP, streamserver.WithBindPort(fooPort), streamserver.WithRespContent([]byte(\"foo\")))\n\t\t\tf.RunServer(\"\", fooServer)\n\n\t\t\tbarPort := f.AllocPort()\n\t\t\tbarServer := streamserver.New(streamserver.TCP, streamserver.WithBindPort(barPort), streamserver.WithRespContent([]byte(\"bar\")))\n\t\t\tf.RunServer(\"\", barServer)\n\n\t\t\tremotePort := f.AllocPort()\n\t\t\tclientConf += fmt.Sprintf(`\n\t\t\t[foo]\n\t\t\ttype = tcp\n\t\t\tlocal_port = %d\n\t\t\tremote_port = %d\n\t\t\tgroup = test\n\t\t\tgroup_key = 123\n\t\t\thealth_check_type = tcp\n\t\t\thealth_check_interval_s = 1\n\n\t\t\t[bar]\n\t\t\ttype = tcp\n\t\t\tlocal_port = %d\n\t\t\tremote_port = %d\n\t\t\tgroup = test\n\t\t\tgroup_key = 123\n\t\t\thealth_check_type = tcp\n\t\t\thealth_check_interval_s = 1\n\t\t\t`, fooPort, remotePort, barPort, remotePort)\n\n\t\t\tf.RunProcesses(serverConf, []string{clientConf})\n\n\t\t\t// check foo and bar is ok\n\t\t\tresults := []string{}\n\t\t\tfor range 10 {\n\t\t\t\tframework.NewRequestExpect(f).Port(remotePort).Ensure(validateFooBarResponse, func(resp *request.Response) bool {\n\t\t\t\t\tresults = append(results, string(resp.Content))\n\t\t\t\t\treturn true\n\t\t\t\t})\n\t\t\t}\n\t\t\tframework.ExpectContainElements(results, []string{\"foo\", \"bar\"})\n\n\t\t\t// close bar server, check foo is ok\n\t\t\tbarServer.Close()\n\t\t\ttime.Sleep(2 * time.Second)\n\t\t\tfor range 10 {\n\t\t\t\tframework.NewRequestExpect(f).Port(remotePort).ExpectResp([]byte(\"foo\")).Ensure()\n\t\t\t}\n\n\t\t\t// resume bar server, check foo and bar is ok\n\t\t\tf.RunServer(\"\", barServer)\n\t\t\ttime.Sleep(2 * time.Second)\n\t\t\tresults = []string{}\n\t\t\tfor range 10 {\n\t\t\t\tframework.NewRequestExpect(f).Port(remotePort).Ensure(validateFooBarResponse, func(resp *request.Response) bool {\n\t\t\t\t\tresults = append(results, string(resp.Content))\n\t\t\t\t\treturn true\n\t\t\t\t})\n\t\t\t}\n\t\t\tframework.ExpectContainElements(results, []string{\"foo\", \"bar\"})\n\t\t})\n\n\t\tginkgo.It(\"HTTP\", func() {\n\t\t\tvhostPort := f.AllocPort()\n\t\t\tserverConf := consts.LegacyDefaultServerConfig + fmt.Sprintf(`\n\t\t\tvhost_http_port = %d\n\t\t\t`, vhostPort)\n\t\t\tclientConf := consts.LegacyDefaultClientConfig\n\n\t\t\tfooPort := f.AllocPort()\n\t\t\tfooServer := newHTTPServer(fooPort, \"foo\")\n\t\t\tf.RunServer(\"\", fooServer)\n\n\t\t\tbarPort := f.AllocPort()\n\t\t\tbarServer := newHTTPServer(barPort, \"bar\")\n\t\t\tf.RunServer(\"\", barServer)\n\n\t\t\tclientConf += fmt.Sprintf(`\n\t\t\t[foo]\n\t\t\ttype = http\n\t\t\tlocal_port = %d\n\t\t\tcustom_domains = example.com\n\t\t\tgroup = test\n\t\t\tgroup_key = 123\n\t\t\thealth_check_type = http\n\t\t\thealth_check_interval_s = 1\n\t\t\thealth_check_url = /healthz\n\n\t\t\t[bar]\n\t\t\ttype = http\n\t\t\tlocal_port = %d\n\t\t\tcustom_domains = example.com\n\t\t\tgroup = test\n\t\t\tgroup_key = 123\n\t\t\thealth_check_type = http\n\t\t\thealth_check_interval_s = 1\n\t\t\thealth_check_url = /healthz\n\t\t\t`, fooPort, barPort)\n\n\t\t\tf.RunProcesses(serverConf, []string{clientConf})\n\n\t\t\t// send first HTTP request\n\t\t\tvar contents []string\n\t\t\tframework.NewRequestExpect(f).Port(vhostPort).\n\t\t\t\tRequestModify(func(r *request.Request) {\n\t\t\t\t\tr.HTTP().HTTPHost(\"example.com\")\n\t\t\t\t}).\n\t\t\t\tEnsure(func(resp *request.Response) bool {\n\t\t\t\t\tcontents = append(contents, string(resp.Content))\n\t\t\t\t\treturn true\n\t\t\t\t})\n\n\t\t\t// send second HTTP request, should be forwarded to another service\n\t\t\tframework.NewRequestExpect(f).Port(vhostPort).\n\t\t\t\tRequestModify(func(r *request.Request) {\n\t\t\t\t\tr.HTTP().HTTPHost(\"example.com\")\n\t\t\t\t}).\n\t\t\t\tEnsure(func(resp *request.Response) bool {\n\t\t\t\t\tcontents = append(contents, string(resp.Content))\n\t\t\t\t\treturn true\n\t\t\t\t})\n\n\t\t\tframework.ExpectContainElements(contents, []string{\"foo\", \"bar\"})\n\n\t\t\t// check foo and bar is ok\n\t\t\tresults := doFooBarHTTPRequest(vhostPort, \"example.com\")\n\t\t\tframework.ExpectContainElements(results, []string{\"foo\", \"bar\"})\n\n\t\t\t// close bar server, check foo is ok\n\t\t\tbarServer.Close()\n\t\t\ttime.Sleep(2 * time.Second)\n\t\t\tresults = doFooBarHTTPRequest(vhostPort, \"example.com\")\n\t\t\tframework.ExpectContainElements(results, []string{\"foo\"})\n\t\t\tframework.ExpectNotContainElements(results, []string{\"bar\"})\n\n\t\t\t// resume bar server, check foo and bar is ok\n\t\t\tf.RunServer(\"\", barServer)\n\t\t\ttime.Sleep(2 * time.Second)\n\t\t\tresults = doFooBarHTTPRequest(vhostPort, \"example.com\")\n\t\t\tframework.ExpectContainElements(results, []string{\"foo\", \"bar\"})\n\t\t})\n\t})\n})\n"
  },
  {
    "path": "test/e2e/legacy/features/heartbeat.go",
    "content": "package features\n\nimport (\n\t\"fmt\"\n\t\"time\"\n\n\t\"github.com/onsi/ginkgo/v2\"\n\n\t\"github.com/fatedier/frp/test/e2e/framework\"\n)\n\nvar _ = ginkgo.Describe(\"[Feature: Heartbeat]\", func() {\n\tf := framework.NewDefaultFramework()\n\n\tginkgo.It(\"disable application layer heartbeat\", func() {\n\t\tserverPort := f.AllocPort()\n\t\tserverConf := fmt.Sprintf(`\n\t\t[common]\n\t\tbind_addr = 0.0.0.0\n\t\tbind_port = %d\n\t\theartbeat_timeout = -1\n\t\ttcp_mux_keepalive_interval = 2\n\t\t`, serverPort)\n\n\t\tremotePort := f.AllocPort()\n\t\tclientConf := fmt.Sprintf(`\n\t\t[common]\n\t\tserver_port = %d\n\t\tlog_level = trace\n\t\theartbeat_interval = -1\n\t\theartbeat_timeout = -1\n\t\ttcp_mux_keepalive_interval = 2\n\n\t\t[tcp]\n\t\ttype = tcp\n\t\tlocal_port = %d\n\t\tremote_port = %d\n\t\t`, serverPort, f.PortByName(framework.TCPEchoServerPort), remotePort)\n\n\t\t// run frps and frpc\n\t\tf.RunProcesses(serverConf, []string{clientConf})\n\n\t\tframework.NewRequestExpect(f).Protocol(\"tcp\").Port(remotePort).Ensure()\n\n\t\ttime.Sleep(5 * time.Second)\n\t\tframework.NewRequestExpect(f).Protocol(\"tcp\").Port(remotePort).Ensure()\n\t})\n})\n"
  },
  {
    "path": "test/e2e/legacy/features/monitor.go",
    "content": "package features\n\nimport (\n\t\"fmt\"\n\t\"strings\"\n\t\"time\"\n\n\t\"github.com/onsi/ginkgo/v2\"\n\n\t\"github.com/fatedier/frp/pkg/util/log\"\n\t\"github.com/fatedier/frp/test/e2e/framework\"\n\t\"github.com/fatedier/frp/test/e2e/framework/consts\"\n\t\"github.com/fatedier/frp/test/e2e/pkg/request\"\n)\n\nvar _ = ginkgo.Describe(\"[Feature: Monitor]\", func() {\n\tf := framework.NewDefaultFramework()\n\n\tginkgo.It(\"Prometheus metrics\", func() {\n\t\tdashboardPort := f.AllocPort()\n\t\tserverConf := consts.LegacyDefaultServerConfig + fmt.Sprintf(`\n\t\tenable_prometheus = true\n\t\tdashboard_addr = 0.0.0.0\n\t\tdashboard_port = %d\n\t\t`, dashboardPort)\n\n\t\tclientConf := consts.LegacyDefaultClientConfig\n\t\tremotePort := f.AllocPort()\n\t\tclientConf += fmt.Sprintf(`\n\t\t[tcp]\n\t\ttype = tcp\n\t\tlocal_port = {{ .%s }}\n\t\tremote_port = %d\n\t\t`, framework.TCPEchoServerPort, remotePort)\n\n\t\tf.RunProcesses(serverConf, []string{clientConf})\n\n\t\tframework.NewRequestExpect(f).Port(remotePort).Ensure()\n\t\ttime.Sleep(500 * time.Millisecond)\n\n\t\tframework.NewRequestExpect(f).RequestModify(func(r *request.Request) {\n\t\t\tr.HTTP().Port(dashboardPort).HTTPPath(\"/metrics\")\n\t\t}).Ensure(func(resp *request.Response) bool {\n\t\t\tlog.Tracef(\"prometheus metrics response: \\n%s\", resp.Content)\n\t\t\tif resp.Code != 200 {\n\t\t\t\treturn false\n\t\t\t}\n\t\t\tif !strings.Contains(string(resp.Content), \"traffic_in\") {\n\t\t\t\treturn false\n\t\t\t}\n\t\t\treturn true\n\t\t})\n\t})\n})\n"
  },
  {
    "path": "test/e2e/legacy/features/real_ip.go",
    "content": "package features\n\nimport (\n\t\"bufio\"\n\t\"fmt\"\n\t\"net\"\n\t\"net/http\"\n\n\t\"github.com/onsi/ginkgo/v2\"\n\tpp \"github.com/pires/go-proxyproto\"\n\n\t\"github.com/fatedier/frp/pkg/util/log\"\n\t\"github.com/fatedier/frp/test/e2e/framework\"\n\t\"github.com/fatedier/frp/test/e2e/framework/consts\"\n\t\"github.com/fatedier/frp/test/e2e/mock/server/httpserver\"\n\t\"github.com/fatedier/frp/test/e2e/mock/server/streamserver\"\n\t\"github.com/fatedier/frp/test/e2e/pkg/request\"\n\t\"github.com/fatedier/frp/test/e2e/pkg/rpc\"\n)\n\nvar _ = ginkgo.Describe(\"[Feature: Real IP]\", func() {\n\tf := framework.NewDefaultFramework()\n\n\tginkgo.It(\"HTTP X-Forwarded-For\", func() {\n\t\tvhostHTTPPort := f.AllocPort()\n\t\tserverConf := consts.LegacyDefaultServerConfig + fmt.Sprintf(`\n\t\tvhost_http_port = %d\n\t\t`, vhostHTTPPort)\n\n\t\tlocalPort := f.AllocPort()\n\t\tlocalServer := httpserver.New(\n\t\t\thttpserver.WithBindPort(localPort),\n\t\t\thttpserver.WithHandler(http.HandlerFunc(func(w http.ResponseWriter, req *http.Request) {\n\t\t\t\t_, _ = w.Write([]byte(req.Header.Get(\"X-Forwarded-For\")))\n\t\t\t})),\n\t\t)\n\t\tf.RunServer(\"\", localServer)\n\n\t\tclientConf := consts.LegacyDefaultClientConfig\n\t\tclientConf += fmt.Sprintf(`\n\t\t[test]\n\t\ttype = http\n\t\tlocal_port = %d\n\t\tcustom_domains = normal.example.com\n\t\t`, localPort)\n\n\t\tf.RunProcesses(serverConf, []string{clientConf})\n\n\t\tframework.NewRequestExpect(f).Port(vhostHTTPPort).\n\t\t\tRequestModify(func(r *request.Request) {\n\t\t\t\tr.HTTP().HTTPHost(\"normal.example.com\")\n\t\t\t}).\n\t\t\tExpectResp([]byte(\"127.0.0.1\")).\n\t\t\tEnsure()\n\t})\n\n\tginkgo.Describe(\"Proxy Protocol\", func() {\n\t\tginkgo.It(\"TCP\", func() {\n\t\t\tserverConf := consts.LegacyDefaultServerConfig\n\t\t\tclientConf := consts.LegacyDefaultClientConfig\n\n\t\t\tlocalPort := f.AllocPort()\n\t\t\tlocalServer := streamserver.New(streamserver.TCP, streamserver.WithBindPort(localPort),\n\t\t\t\tstreamserver.WithCustomHandler(func(c net.Conn) {\n\t\t\t\t\tdefer c.Close()\n\t\t\t\t\trd := bufio.NewReader(c)\n\t\t\t\t\tppHeader, err := pp.Read(rd)\n\t\t\t\t\tif err != nil {\n\t\t\t\t\t\tlog.Errorf(\"read proxy protocol error: %v\", err)\n\t\t\t\t\t\treturn\n\t\t\t\t\t}\n\n\t\t\t\t\tfor {\n\t\t\t\t\t\tif _, err := rpc.ReadBytes(rd); err != nil {\n\t\t\t\t\t\t\treturn\n\t\t\t\t\t\t}\n\n\t\t\t\t\t\tbuf := []byte(ppHeader.SourceAddr.String())\n\t\t\t\t\t\t_, _ = rpc.WriteBytes(c, buf)\n\t\t\t\t\t}\n\t\t\t\t}))\n\t\t\tf.RunServer(\"\", localServer)\n\n\t\t\tremotePort := f.AllocPort()\n\t\t\tclientConf += fmt.Sprintf(`\n\t\t\t[tcp]\n\t\t\ttype = tcp\n\t\t\tlocal_port = %d\n\t\t\tremote_port = %d\n\t\t\tproxy_protocol_version = v2\n\t\t\t`, localPort, remotePort)\n\n\t\t\tf.RunProcesses(serverConf, []string{clientConf})\n\n\t\t\tframework.NewRequestExpect(f).Port(remotePort).Ensure(func(resp *request.Response) bool {\n\t\t\t\tlog.Tracef(\"proxy protocol get SourceAddr: %s\", string(resp.Content))\n\t\t\t\taddr, err := net.ResolveTCPAddr(\"tcp\", string(resp.Content))\n\t\t\t\tif err != nil {\n\t\t\t\t\treturn false\n\t\t\t\t}\n\t\t\t\tif addr.IP.String() != \"127.0.0.1\" {\n\t\t\t\t\treturn false\n\t\t\t\t}\n\t\t\t\treturn true\n\t\t\t})\n\t\t})\n\n\t\tginkgo.It(\"HTTP\", func() {\n\t\t\tvhostHTTPPort := f.AllocPort()\n\t\t\tserverConf := consts.LegacyDefaultServerConfig + fmt.Sprintf(`\n\t\tvhost_http_port = %d\n\t\t`, vhostHTTPPort)\n\n\t\t\tclientConf := consts.LegacyDefaultClientConfig\n\n\t\t\tlocalPort := f.AllocPort()\n\t\t\tvar srcAddrRecord string\n\t\t\tlocalServer := streamserver.New(streamserver.TCP, streamserver.WithBindPort(localPort),\n\t\t\t\tstreamserver.WithCustomHandler(func(c net.Conn) {\n\t\t\t\t\tdefer c.Close()\n\t\t\t\t\trd := bufio.NewReader(c)\n\t\t\t\t\tppHeader, err := pp.Read(rd)\n\t\t\t\t\tif err != nil {\n\t\t\t\t\t\tlog.Errorf(\"read proxy protocol error: %v\", err)\n\t\t\t\t\t\treturn\n\t\t\t\t\t}\n\t\t\t\t\tsrcAddrRecord = ppHeader.SourceAddr.String()\n\t\t\t\t}))\n\t\t\tf.RunServer(\"\", localServer)\n\n\t\t\tclientConf += fmt.Sprintf(`\n\t\t\t[test]\n\t\t\ttype = http\n\t\t\tlocal_port = %d\n\t\t\tcustom_domains = normal.example.com\n\t\t\tproxy_protocol_version = v2\n\t\t\t`, localPort)\n\n\t\t\tf.RunProcesses(serverConf, []string{clientConf})\n\n\t\t\tframework.NewRequestExpect(f).Port(vhostHTTPPort).RequestModify(func(r *request.Request) {\n\t\t\t\tr.HTTP().HTTPHost(\"normal.example.com\")\n\t\t\t}).Ensure(framework.ExpectResponseCode(404))\n\n\t\t\tlog.Tracef(\"proxy protocol get SourceAddr: %s\", srcAddrRecord)\n\t\t\taddr, err := net.ResolveTCPAddr(\"tcp\", srcAddrRecord)\n\t\t\tframework.ExpectNoError(err, srcAddrRecord)\n\t\t\tframework.ExpectEqualValues(\"127.0.0.1\", addr.IP.String())\n\t\t})\n\t})\n})\n"
  },
  {
    "path": "test/e2e/legacy/plugin/client.go",
    "content": "package plugin\n\nimport (\n\t\"crypto/tls\"\n\t\"fmt\"\n\t\"strconv\"\n\t\"strings\"\n\n\t\"github.com/onsi/ginkgo/v2\"\n\n\t\"github.com/fatedier/frp/pkg/transport\"\n\t\"github.com/fatedier/frp/test/e2e/framework\"\n\t\"github.com/fatedier/frp/test/e2e/framework/consts\"\n\t\"github.com/fatedier/frp/test/e2e/mock/server/httpserver\"\n\t\"github.com/fatedier/frp/test/e2e/pkg/cert\"\n\t\"github.com/fatedier/frp/test/e2e/pkg/port\"\n\t\"github.com/fatedier/frp/test/e2e/pkg/request\"\n)\n\nvar _ = ginkgo.Describe(\"[Feature: Client-Plugins]\", func() {\n\tf := framework.NewDefaultFramework()\n\n\tginkgo.Describe(\"UnixDomainSocket\", func() {\n\t\tginkgo.It(\"Expose a unix domain socket echo server\", func() {\n\t\t\tserverConf := consts.LegacyDefaultServerConfig\n\t\t\tvar clientConf strings.Builder\n\t\t\tclientConf.WriteString(consts.LegacyDefaultClientConfig)\n\n\t\t\tgetProxyConf := func(proxyName string, portName string, extra string) string {\n\t\t\t\treturn fmt.Sprintf(`\n\t\t\t\t[%s]\n\t\t\t\ttype = tcp\n\t\t\t\tremote_port = {{ .%s }}\n\t\t\t\tplugin = unix_domain_socket\n\t\t\t\tplugin_unix_path = {{ .%s }}\n\t\t\t\t`+extra, proxyName, portName, framework.UDSEchoServerAddr)\n\t\t\t}\n\n\t\t\ttests := []struct {\n\t\t\t\tproxyName   string\n\t\t\t\tportName    string\n\t\t\t\textraConfig string\n\t\t\t}{\n\t\t\t\t{\n\t\t\t\t\tproxyName: \"normal\",\n\t\t\t\t\tportName:  port.GenName(\"Normal\"),\n\t\t\t\t},\n\t\t\t\t{\n\t\t\t\t\tproxyName:   \"with-encryption\",\n\t\t\t\t\tportName:    port.GenName(\"WithEncryption\"),\n\t\t\t\t\textraConfig: \"use_encryption = true\",\n\t\t\t\t},\n\t\t\t\t{\n\t\t\t\t\tproxyName:   \"with-compression\",\n\t\t\t\t\tportName:    port.GenName(\"WithCompression\"),\n\t\t\t\t\textraConfig: \"use_compression = true\",\n\t\t\t\t},\n\t\t\t\t{\n\t\t\t\t\tproxyName: \"with-encryption-and-compression\",\n\t\t\t\t\tportName:  port.GenName(\"WithEncryptionAndCompression\"),\n\t\t\t\t\textraConfig: `\n\t\t\t\t\tuse_encryption = true\n\t\t\t\t\tuse_compression = true\n\t\t\t\t\t`,\n\t\t\t\t},\n\t\t\t}\n\n\t\t\t// build all client config\n\t\t\tfor _, test := range tests {\n\t\t\t\tclientConf.WriteString(getProxyConf(test.proxyName, test.portName, test.extraConfig) + \"\\n\")\n\t\t\t}\n\t\t\t// run frps and frpc\n\t\t\tf.RunProcesses(serverConf, []string{clientConf.String()})\n\n\t\t\tfor _, test := range tests {\n\t\t\t\tframework.NewRequestExpect(f).Port(f.PortByName(test.portName)).Ensure()\n\t\t\t}\n\t\t})\n\t})\n\n\tginkgo.It(\"http_proxy\", func() {\n\t\tserverConf := consts.LegacyDefaultServerConfig\n\t\tclientConf := consts.LegacyDefaultClientConfig\n\n\t\tremotePort := f.AllocPort()\n\t\tclientConf += fmt.Sprintf(`\n\t\t[tcp]\n\t\ttype = tcp\n\t\tremote_port = %d\n\t\tplugin = http_proxy\n\t\tplugin_http_user = abc\n\t\tplugin_http_passwd = 123\n\t\t`, remotePort)\n\n\t\tf.RunProcesses(serverConf, []string{clientConf})\n\n\t\t// http proxy, no auth info\n\t\tframework.NewRequestExpect(f).PortName(framework.HTTPSimpleServerPort).RequestModify(func(r *request.Request) {\n\t\t\tr.HTTP().Proxy(\"http://127.0.0.1:\" + strconv.Itoa(remotePort))\n\t\t}).Ensure(framework.ExpectResponseCode(407))\n\n\t\t// http proxy, correct auth\n\t\tframework.NewRequestExpect(f).PortName(framework.HTTPSimpleServerPort).RequestModify(func(r *request.Request) {\n\t\t\tr.HTTP().Proxy(\"http://abc:123@127.0.0.1:\" + strconv.Itoa(remotePort))\n\t\t}).Ensure()\n\n\t\t// connect TCP server by CONNECT method\n\t\tframework.NewRequestExpect(f).PortName(framework.TCPEchoServerPort).RequestModify(func(r *request.Request) {\n\t\t\tr.TCP().Proxy(\"http://abc:123@127.0.0.1:\" + strconv.Itoa(remotePort))\n\t\t})\n\t})\n\n\tginkgo.It(\"socks5 proxy\", func() {\n\t\tserverConf := consts.LegacyDefaultServerConfig\n\t\tclientConf := consts.LegacyDefaultClientConfig\n\n\t\tremotePort := f.AllocPort()\n\t\tclientConf += fmt.Sprintf(`\n\t\t[tcp]\n\t\ttype = tcp\n\t\tremote_port = %d\n\t\tplugin = socks5\n\t\tplugin_user = abc\n\t\tplugin_passwd = 123\n\t\t`, remotePort)\n\n\t\tf.RunProcesses(serverConf, []string{clientConf})\n\n\t\t// http proxy, no auth info\n\t\tframework.NewRequestExpect(f).PortName(framework.TCPEchoServerPort).RequestModify(func(r *request.Request) {\n\t\t\tr.TCP().Proxy(\"socks5://127.0.0.1:\" + strconv.Itoa(remotePort))\n\t\t}).ExpectError(true).Ensure()\n\n\t\t// http proxy, correct auth\n\t\tframework.NewRequestExpect(f).PortName(framework.TCPEchoServerPort).RequestModify(func(r *request.Request) {\n\t\t\tr.TCP().Proxy(\"socks5://abc:123@127.0.0.1:\" + strconv.Itoa(remotePort))\n\t\t}).Ensure()\n\t})\n\n\tginkgo.It(\"static_file\", func() {\n\t\tvhostPort := f.AllocPort()\n\t\tserverConf := consts.LegacyDefaultServerConfig + fmt.Sprintf(`\n\t\tvhost_http_port = %d\n\t\t`, vhostPort)\n\t\tclientConf := consts.LegacyDefaultClientConfig\n\n\t\tremotePort := f.AllocPort()\n\t\tf.WriteTempFile(\"test_static_file\", \"foo\")\n\t\tclientConf += fmt.Sprintf(`\n\t\t[tcp]\n\t\ttype = tcp\n\t\tremote_port = %d\n\t\tplugin = static_file\n\t\tplugin_local_path = %s\n\n\t\t[http]\n\t\ttype = http\n\t\tcustom_domains = example.com\n\t\tplugin = static_file\n\t\tplugin_local_path = %s\n\n\t\t[http-with-auth]\n\t\ttype = http\n\t\tcustom_domains = other.example.com\n\t\tplugin = static_file\n\t\tplugin_local_path = %s\n\t\tplugin_http_user = abc\n\t\tplugin_http_passwd = 123\n\t\t`, remotePort, f.TempDirectory, f.TempDirectory, f.TempDirectory)\n\n\t\tf.RunProcesses(serverConf, []string{clientConf})\n\n\t\t// from tcp proxy\n\t\tframework.NewRequestExpect(f).Request(\n\t\t\tframework.NewHTTPRequest().HTTPPath(\"/test_static_file\").Port(remotePort),\n\t\t).ExpectResp([]byte(\"foo\")).Ensure()\n\n\t\t// from http proxy without auth\n\t\tframework.NewRequestExpect(f).Request(\n\t\t\tframework.NewHTTPRequest().HTTPHost(\"example.com\").HTTPPath(\"/test_static_file\").Port(vhostPort),\n\t\t).ExpectResp([]byte(\"foo\")).Ensure()\n\n\t\t// from http proxy with auth\n\t\tframework.NewRequestExpect(f).Request(\n\t\t\tframework.NewHTTPRequest().HTTPHost(\"other.example.com\").HTTPPath(\"/test_static_file\").Port(vhostPort).HTTPAuth(\"abc\", \"123\"),\n\t\t).ExpectResp([]byte(\"foo\")).Ensure()\n\t})\n\n\tginkgo.It(\"http2https\", func() {\n\t\tserverConf := consts.LegacyDefaultServerConfig\n\t\tvhostHTTPPort := f.AllocPort()\n\t\tserverConf += fmt.Sprintf(`\n\t\tvhost_http_port = %d\n\t\t`, vhostHTTPPort)\n\n\t\tlocalPort := f.AllocPort()\n\t\tclientConf := consts.LegacyDefaultClientConfig + fmt.Sprintf(`\n\t\t[http2https]\n\t\ttype = http\n\t\tcustom_domains = example.com\n\t\tplugin = http2https\n\t\tplugin_local_addr = 127.0.0.1:%d\n\t\t`, localPort)\n\n\t\tf.RunProcesses(serverConf, []string{clientConf})\n\n\t\ttlsConfig, err := transport.NewServerTLSConfig(\"\", \"\", \"\")\n\t\tframework.ExpectNoError(err)\n\t\tlocalServer := httpserver.New(\n\t\t\thttpserver.WithBindPort(localPort),\n\t\t\thttpserver.WithTLSConfig(tlsConfig),\n\t\t\thttpserver.WithResponse([]byte(\"test\")),\n\t\t)\n\t\tf.RunServer(\"\", localServer)\n\n\t\tframework.NewRequestExpect(f).\n\t\t\tPort(vhostHTTPPort).\n\t\t\tRequestModify(func(r *request.Request) {\n\t\t\t\tr.HTTP().HTTPHost(\"example.com\")\n\t\t\t}).\n\t\t\tExpectResp([]byte(\"test\")).\n\t\t\tEnsure()\n\t})\n\n\tginkgo.It(\"https2http\", func() {\n\t\tgenerator := &cert.SelfSignedCertGenerator{}\n\t\tartifacts, err := generator.Generate(\"example.com\")\n\t\tframework.ExpectNoError(err)\n\t\tcrtPath := f.WriteTempFile(\"server.crt\", string(artifacts.Cert))\n\t\tkeyPath := f.WriteTempFile(\"server.key\", string(artifacts.Key))\n\n\t\tserverConf := consts.LegacyDefaultServerConfig\n\t\tvhostHTTPSPort := f.AllocPort()\n\t\tserverConf += fmt.Sprintf(`\n\t\tvhost_https_port = %d\n\t\t`, vhostHTTPSPort)\n\n\t\tlocalPort := f.AllocPort()\n\t\tclientConf := consts.LegacyDefaultClientConfig + fmt.Sprintf(`\n\t\t[https2http]\n\t\ttype = https\n\t\tcustom_domains = example.com\n\t\tplugin = https2http\n\t\tplugin_local_addr = 127.0.0.1:%d\n\t\tplugin_crt_path = %s\n\t\tplugin_key_path = %s\n\t\t`, localPort, crtPath, keyPath)\n\n\t\tf.RunProcesses(serverConf, []string{clientConf})\n\n\t\tlocalServer := httpserver.New(\n\t\t\thttpserver.WithBindPort(localPort),\n\t\t\thttpserver.WithResponse([]byte(\"test\")),\n\t\t)\n\t\tf.RunServer(\"\", localServer)\n\n\t\tframework.NewRequestExpect(f).\n\t\t\tPort(vhostHTTPSPort).\n\t\t\tRequestModify(func(r *request.Request) {\n\t\t\t\tr.HTTPS().HTTPHost(\"example.com\").TLSConfig(&tls.Config{\n\t\t\t\t\tServerName:         \"example.com\",\n\t\t\t\t\tInsecureSkipVerify: true,\n\t\t\t\t})\n\t\t\t}).\n\t\t\tExpectResp([]byte(\"test\")).\n\t\t\tEnsure()\n\t})\n\n\tginkgo.It(\"https2https\", func() {\n\t\tgenerator := &cert.SelfSignedCertGenerator{}\n\t\tartifacts, err := generator.Generate(\"example.com\")\n\t\tframework.ExpectNoError(err)\n\t\tcrtPath := f.WriteTempFile(\"server.crt\", string(artifacts.Cert))\n\t\tkeyPath := f.WriteTempFile(\"server.key\", string(artifacts.Key))\n\n\t\tserverConf := consts.LegacyDefaultServerConfig\n\t\tvhostHTTPSPort := f.AllocPort()\n\t\tserverConf += fmt.Sprintf(`\n\t\tvhost_https_port = %d\n\t\t`, vhostHTTPSPort)\n\n\t\tlocalPort := f.AllocPort()\n\t\tclientConf := consts.LegacyDefaultClientConfig + fmt.Sprintf(`\n\t\t[https2https]\n\t\ttype = https\n\t\tcustom_domains = example.com\n\t\tplugin = https2https\n\t\tplugin_local_addr = 127.0.0.1:%d\n\t\tplugin_crt_path = %s\n\t\tplugin_key_path = %s\n\t\t`, localPort, crtPath, keyPath)\n\n\t\tf.RunProcesses(serverConf, []string{clientConf})\n\n\t\ttlsConfig, err := transport.NewServerTLSConfig(\"\", \"\", \"\")\n\t\tframework.ExpectNoError(err)\n\t\tlocalServer := httpserver.New(\n\t\t\thttpserver.WithBindPort(localPort),\n\t\t\thttpserver.WithResponse([]byte(\"test\")),\n\t\t\thttpserver.WithTLSConfig(tlsConfig),\n\t\t)\n\t\tf.RunServer(\"\", localServer)\n\n\t\tframework.NewRequestExpect(f).\n\t\t\tPort(vhostHTTPSPort).\n\t\t\tRequestModify(func(r *request.Request) {\n\t\t\t\tr.HTTPS().HTTPHost(\"example.com\").TLSConfig(&tls.Config{\n\t\t\t\t\tServerName:         \"example.com\",\n\t\t\t\t\tInsecureSkipVerify: true,\n\t\t\t\t})\n\t\t\t}).\n\t\t\tExpectResp([]byte(\"test\")).\n\t\t\tEnsure()\n\t})\n})\n"
  },
  {
    "path": "test/e2e/legacy/plugin/server.go",
    "content": "package plugin\n\nimport (\n\t\"fmt\"\n\t\"time\"\n\n\t\"github.com/onsi/ginkgo/v2\"\n\n\tplugin \"github.com/fatedier/frp/pkg/plugin/server\"\n\t\"github.com/fatedier/frp/pkg/transport\"\n\t\"github.com/fatedier/frp/test/e2e/framework\"\n\t\"github.com/fatedier/frp/test/e2e/framework/consts\"\n\tpluginpkg \"github.com/fatedier/frp/test/e2e/pkg/plugin\"\n)\n\nvar _ = ginkgo.Describe(\"[Feature: Server-Plugins]\", func() {\n\tf := framework.NewDefaultFramework()\n\n\tginkgo.Describe(\"Login\", func() {\n\t\tnewFunc := func() *plugin.Request {\n\t\t\tvar r plugin.Request\n\t\t\tr.Content = &plugin.LoginContent{}\n\t\t\treturn &r\n\t\t}\n\n\t\tginkgo.It(\"Auth for custom meta token\", func() {\n\t\t\tlocalPort := f.AllocPort()\n\n\t\t\tclientAddressGot := false\n\t\t\thandler := func(req *plugin.Request) *plugin.Response {\n\t\t\t\tvar ret plugin.Response\n\t\t\t\tcontent := req.Content.(*plugin.LoginContent)\n\t\t\t\tif content.ClientAddress != \"\" {\n\t\t\t\t\tclientAddressGot = true\n\t\t\t\t}\n\t\t\t\tif content.Metas[\"token\"] == \"123\" {\n\t\t\t\t\tret.Unchange = true\n\t\t\t\t} else {\n\t\t\t\t\tret.Reject = true\n\t\t\t\t\tret.RejectReason = \"invalid token\"\n\t\t\t\t}\n\t\t\t\treturn &ret\n\t\t\t}\n\t\t\tpluginServer := pluginpkg.NewHTTPPluginServer(localPort, newFunc, handler, nil)\n\n\t\t\tf.RunServer(\"\", pluginServer)\n\n\t\t\tserverConf := consts.LegacyDefaultServerConfig + fmt.Sprintf(`\n\t\t\t[plugin.user-manager]\n\t\t\taddr = 127.0.0.1:%d\n\t\t\tpath = /handler\n\t\t\tops = Login\n\t\t\t`, localPort)\n\t\t\tclientConf := consts.LegacyDefaultClientConfig\n\n\t\t\tremotePort := f.AllocPort()\n\t\t\tclientConf += fmt.Sprintf(`\n\t\t\tmeta_token = 123\n\n\t\t\t[tcp]\n\t\t\ttype = tcp\n\t\t\tlocal_port = {{ .%s }}\n\t\t\tremote_port = %d\n\t\t\t`, framework.TCPEchoServerPort, remotePort)\n\n\t\t\tremotePort2 := f.AllocPort()\n\t\t\tinvalidTokenClientConf := consts.LegacyDefaultClientConfig + fmt.Sprintf(`\n\t\t\t[tcp2]\n\t\t\ttype = tcp\n\t\t\tlocal_port = {{ .%s }}\n\t\t\tremote_port = %d\n\t\t\t`, framework.TCPEchoServerPort, remotePort2)\n\n\t\t\tf.RunProcesses(serverConf, []string{clientConf, invalidTokenClientConf})\n\n\t\t\tframework.NewRequestExpect(f).Port(remotePort).Ensure()\n\t\t\tframework.NewRequestExpect(f).Port(remotePort2).ExpectError(true).Ensure()\n\n\t\t\tframework.ExpectTrue(clientAddressGot)\n\t\t})\n\t})\n\n\tginkgo.Describe(\"NewProxy\", func() {\n\t\tnewFunc := func() *plugin.Request {\n\t\t\tvar r plugin.Request\n\t\t\tr.Content = &plugin.NewProxyContent{}\n\t\t\treturn &r\n\t\t}\n\n\t\tginkgo.It(\"Validate Info\", func() {\n\t\t\tlocalPort := f.AllocPort()\n\t\t\thandler := func(req *plugin.Request) *plugin.Response {\n\t\t\t\tvar ret plugin.Response\n\t\t\t\tcontent := req.Content.(*plugin.NewProxyContent)\n\t\t\t\tif content.ProxyName == \"tcp\" {\n\t\t\t\t\tret.Unchange = true\n\t\t\t\t} else {\n\t\t\t\t\tret.Reject = true\n\t\t\t\t}\n\t\t\t\treturn &ret\n\t\t\t}\n\t\t\tpluginServer := pluginpkg.NewHTTPPluginServer(localPort, newFunc, handler, nil)\n\n\t\t\tf.RunServer(\"\", pluginServer)\n\n\t\t\tserverConf := consts.LegacyDefaultServerConfig + fmt.Sprintf(`\n\t\t\t[plugin.test]\n\t\t\taddr = 127.0.0.1:%d\n\t\t\tpath = /handler\n\t\t\tops = NewProxy\n\t\t\t`, localPort)\n\t\t\tclientConf := consts.LegacyDefaultClientConfig\n\n\t\t\tremotePort := f.AllocPort()\n\t\t\tclientConf += fmt.Sprintf(`\n\t\t\t[tcp]\n\t\t\ttype = tcp\n\t\t\tlocal_port = {{ .%s }}\n\t\t\tremote_port = %d\n\t\t\t`, framework.TCPEchoServerPort, remotePort)\n\n\t\t\tf.RunProcesses(serverConf, []string{clientConf})\n\n\t\t\tframework.NewRequestExpect(f).Port(remotePort).Ensure()\n\t\t})\n\n\t\tginkgo.It(\"Modify RemotePort\", func() {\n\t\t\tlocalPort := f.AllocPort()\n\t\t\tremotePort := f.AllocPort()\n\t\t\thandler := func(req *plugin.Request) *plugin.Response {\n\t\t\t\tvar ret plugin.Response\n\t\t\t\tcontent := req.Content.(*plugin.NewProxyContent)\n\t\t\t\tcontent.RemotePort = remotePort\n\t\t\t\tret.Content = content\n\t\t\t\treturn &ret\n\t\t\t}\n\t\t\tpluginServer := pluginpkg.NewHTTPPluginServer(localPort, newFunc, handler, nil)\n\n\t\t\tf.RunServer(\"\", pluginServer)\n\n\t\t\tserverConf := consts.LegacyDefaultServerConfig + fmt.Sprintf(`\n\t\t\t[plugin.test]\n\t\t\taddr = 127.0.0.1:%d\n\t\t\tpath = /handler\n\t\t\tops = NewProxy\n\t\t\t`, localPort)\n\t\t\tclientConf := consts.LegacyDefaultClientConfig\n\n\t\t\tclientConf += fmt.Sprintf(`\n\t\t\t[tcp]\n\t\t\ttype = tcp\n\t\t\tlocal_port = {{ .%s }}\n\t\t\tremote_port = 0\n\t\t\t`, framework.TCPEchoServerPort)\n\n\t\t\tf.RunProcesses(serverConf, []string{clientConf})\n\n\t\t\tframework.NewRequestExpect(f).Port(remotePort).Ensure()\n\t\t})\n\t})\n\n\tginkgo.Describe(\"CloseProxy\", func() {\n\t\tnewFunc := func() *plugin.Request {\n\t\t\tvar r plugin.Request\n\t\t\tr.Content = &plugin.CloseProxyContent{}\n\t\t\treturn &r\n\t\t}\n\n\t\tginkgo.It(\"Validate Info\", func() {\n\t\t\tlocalPort := f.AllocPort()\n\t\t\tvar recordProxyName string\n\t\t\thandler := func(req *plugin.Request) *plugin.Response {\n\t\t\t\tvar ret plugin.Response\n\t\t\t\tcontent := req.Content.(*plugin.CloseProxyContent)\n\t\t\t\trecordProxyName = content.ProxyName\n\t\t\t\treturn &ret\n\t\t\t}\n\t\t\tpluginServer := pluginpkg.NewHTTPPluginServer(localPort, newFunc, handler, nil)\n\n\t\t\tf.RunServer(\"\", pluginServer)\n\n\t\t\tserverConf := consts.LegacyDefaultServerConfig + fmt.Sprintf(`\n\t\t\t[plugin.test]\n\t\t\taddr = 127.0.0.1:%d\n\t\t\tpath = /handler\n\t\t\tops = CloseProxy\n\t\t\t`, localPort)\n\t\t\tclientConf := consts.LegacyDefaultClientConfig\n\n\t\t\tremotePort := f.AllocPort()\n\t\t\tclientConf += fmt.Sprintf(`\n\t\t\t[tcp]\n\t\t\ttype = tcp\n\t\t\tlocal_port = {{ .%s }}\n\t\t\tremote_port = %d\n\t\t\t`, framework.TCPEchoServerPort, remotePort)\n\n\t\t\t_, clients := f.RunProcesses(serverConf, []string{clientConf})\n\n\t\t\tframework.NewRequestExpect(f).Port(remotePort).Ensure()\n\n\t\t\tfor _, c := range clients {\n\t\t\t\t_ = c.Stop()\n\t\t\t}\n\n\t\t\ttime.Sleep(1 * time.Second)\n\n\t\t\tframework.ExpectEqual(recordProxyName, \"tcp\")\n\t\t})\n\t})\n\n\tginkgo.Describe(\"Ping\", func() {\n\t\tnewFunc := func() *plugin.Request {\n\t\t\tvar r plugin.Request\n\t\t\tr.Content = &plugin.PingContent{}\n\t\t\treturn &r\n\t\t}\n\n\t\tginkgo.It(\"Validate Info\", func() {\n\t\t\tlocalPort := f.AllocPort()\n\n\t\t\tvar record string\n\t\t\thandler := func(req *plugin.Request) *plugin.Response {\n\t\t\t\tvar ret plugin.Response\n\t\t\t\tcontent := req.Content.(*plugin.PingContent)\n\t\t\t\trecord = content.PrivilegeKey\n\t\t\t\tret.Unchange = true\n\t\t\t\treturn &ret\n\t\t\t}\n\t\t\tpluginServer := pluginpkg.NewHTTPPluginServer(localPort, newFunc, handler, nil)\n\n\t\t\tf.RunServer(\"\", pluginServer)\n\n\t\t\tserverConf := consts.LegacyDefaultServerConfig + fmt.Sprintf(`\n\t\t\t[plugin.test]\n\t\t\taddr = 127.0.0.1:%d\n\t\t\tpath = /handler\n\t\t\tops = Ping\n\t\t\t`, localPort)\n\n\t\t\tremotePort := f.AllocPort()\n\t\t\tclientConf := consts.LegacyDefaultClientConfig\n\t\t\tclientConf += fmt.Sprintf(`\n\t\t\theartbeat_interval = 1\n\t\t\tauthenticate_heartbeats = true\n\n\t\t\t[tcp]\n\t\t\ttype = tcp\n\t\t\tlocal_port = {{ .%s }}\n\t\t\tremote_port = %d\n\t\t\t`, framework.TCPEchoServerPort, remotePort)\n\n\t\t\tf.RunProcesses(serverConf, []string{clientConf})\n\n\t\t\tframework.NewRequestExpect(f).Port(remotePort).Ensure()\n\n\t\t\ttime.Sleep(3 * time.Second)\n\t\t\tframework.ExpectNotEqual(\"\", record)\n\t\t})\n\t})\n\n\tginkgo.Describe(\"NewWorkConn\", func() {\n\t\tnewFunc := func() *plugin.Request {\n\t\t\tvar r plugin.Request\n\t\t\tr.Content = &plugin.NewWorkConnContent{}\n\t\t\treturn &r\n\t\t}\n\n\t\tginkgo.It(\"Validate Info\", func() {\n\t\t\tlocalPort := f.AllocPort()\n\n\t\t\tvar record string\n\t\t\thandler := func(req *plugin.Request) *plugin.Response {\n\t\t\t\tvar ret plugin.Response\n\t\t\t\tcontent := req.Content.(*plugin.NewWorkConnContent)\n\t\t\t\trecord = content.RunID\n\t\t\t\tret.Unchange = true\n\t\t\t\treturn &ret\n\t\t\t}\n\t\t\tpluginServer := pluginpkg.NewHTTPPluginServer(localPort, newFunc, handler, nil)\n\n\t\t\tf.RunServer(\"\", pluginServer)\n\n\t\t\tserverConf := consts.LegacyDefaultServerConfig + fmt.Sprintf(`\n\t\t\t[plugin.test]\n\t\t\taddr = 127.0.0.1:%d\n\t\t\tpath = /handler\n\t\t\tops = NewWorkConn\n\t\t\t`, localPort)\n\n\t\t\tremotePort := f.AllocPort()\n\t\t\tclientConf := consts.LegacyDefaultClientConfig\n\t\t\tclientConf += fmt.Sprintf(`\n\t\t\t[tcp]\n\t\t\ttype = tcp\n\t\t\tlocal_port = {{ .%s }}\n\t\t\tremote_port = %d\n\t\t\t`, framework.TCPEchoServerPort, remotePort)\n\n\t\t\tf.RunProcesses(serverConf, []string{clientConf})\n\n\t\t\tframework.NewRequestExpect(f).Port(remotePort).Ensure()\n\n\t\t\tframework.ExpectNotEqual(\"\", record)\n\t\t})\n\t})\n\n\tginkgo.Describe(\"NewUserConn\", func() {\n\t\tnewFunc := func() *plugin.Request {\n\t\t\tvar r plugin.Request\n\t\t\tr.Content = &plugin.NewUserConnContent{}\n\t\t\treturn &r\n\t\t}\n\t\tginkgo.It(\"Validate Info\", func() {\n\t\t\tlocalPort := f.AllocPort()\n\n\t\t\tvar record string\n\t\t\thandler := func(req *plugin.Request) *plugin.Response {\n\t\t\t\tvar ret plugin.Response\n\t\t\t\tcontent := req.Content.(*plugin.NewUserConnContent)\n\t\t\t\trecord = content.RemoteAddr\n\t\t\t\tret.Unchange = true\n\t\t\t\treturn &ret\n\t\t\t}\n\t\t\tpluginServer := pluginpkg.NewHTTPPluginServer(localPort, newFunc, handler, nil)\n\n\t\t\tf.RunServer(\"\", pluginServer)\n\n\t\t\tserverConf := consts.LegacyDefaultServerConfig + fmt.Sprintf(`\n\t\t\t[plugin.test]\n\t\t\taddr = 127.0.0.1:%d\n\t\t\tpath = /handler\n\t\t\tops = NewUserConn\n\t\t\t`, localPort)\n\n\t\t\tremotePort := f.AllocPort()\n\t\t\tclientConf := consts.LegacyDefaultClientConfig\n\t\t\tclientConf += fmt.Sprintf(`\n\t\t\t[tcp]\n\t\t\ttype = tcp\n\t\t\tlocal_port = {{ .%s }}\n\t\t\tremote_port = %d\n\t\t\t`, framework.TCPEchoServerPort, remotePort)\n\n\t\t\tf.RunProcesses(serverConf, []string{clientConf})\n\n\t\t\tframework.NewRequestExpect(f).Port(remotePort).Ensure()\n\n\t\t\tframework.ExpectNotEqual(\"\", record)\n\t\t})\n\t})\n\n\tginkgo.Describe(\"HTTPS Protocol\", func() {\n\t\tnewFunc := func() *plugin.Request {\n\t\t\tvar r plugin.Request\n\t\t\tr.Content = &plugin.NewUserConnContent{}\n\t\t\treturn &r\n\t\t}\n\t\tginkgo.It(\"Validate Login Info, disable tls verify\", func() {\n\t\t\tlocalPort := f.AllocPort()\n\n\t\t\tvar record string\n\t\t\thandler := func(req *plugin.Request) *plugin.Response {\n\t\t\t\tvar ret plugin.Response\n\t\t\t\tcontent := req.Content.(*plugin.NewUserConnContent)\n\t\t\t\trecord = content.RemoteAddr\n\t\t\t\tret.Unchange = true\n\t\t\t\treturn &ret\n\t\t\t}\n\t\t\ttlsConfig, err := transport.NewServerTLSConfig(\"\", \"\", \"\")\n\t\t\tframework.ExpectNoError(err)\n\t\t\tpluginServer := pluginpkg.NewHTTPPluginServer(localPort, newFunc, handler, tlsConfig)\n\n\t\t\tf.RunServer(\"\", pluginServer)\n\n\t\t\tserverConf := consts.LegacyDefaultServerConfig + fmt.Sprintf(`\n\t\t\t[plugin.test]\n\t\t\taddr = https://127.0.0.1:%d\n\t\t\tpath = /handler\n\t\t\tops = NewUserConn\n\t\t\t`, localPort)\n\n\t\t\tremotePort := f.AllocPort()\n\t\t\tclientConf := consts.LegacyDefaultClientConfig\n\t\t\tclientConf += fmt.Sprintf(`\n\t\t\t[tcp]\n\t\t\ttype = tcp\n\t\t\tlocal_port = {{ .%s }}\n\t\t\tremote_port = %d\n\t\t\t`, framework.TCPEchoServerPort, remotePort)\n\n\t\t\tf.RunProcesses(serverConf, []string{clientConf})\n\n\t\t\tframework.NewRequestExpect(f).Port(remotePort).Ensure()\n\n\t\t\tframework.ExpectNotEqual(\"\", record)\n\t\t})\n\t})\n})\n"
  },
  {
    "path": "test/e2e/mock/server/httpserver/server.go",
    "content": "package httpserver\n\nimport (\n\t\"crypto/tls\"\n\t\"net\"\n\t\"net/http\"\n\t\"strconv\"\n\t\"time\"\n)\n\ntype Server struct {\n\tbindAddr string\n\tbindPort int\n\thandler  http.Handler\n\n\tl         net.Listener\n\ttlsConfig *tls.Config\n\ths        *http.Server\n}\n\ntype Option func(*Server) *Server\n\nfunc New(options ...Option) *Server {\n\ts := &Server{\n\t\tbindAddr: \"127.0.0.1\",\n\t}\n\n\tfor _, option := range options {\n\t\ts = option(s)\n\t}\n\treturn s\n}\n\nfunc WithBindAddr(addr string) Option {\n\treturn func(s *Server) *Server {\n\t\ts.bindAddr = addr\n\t\treturn s\n\t}\n}\n\nfunc WithBindPort(port int) Option {\n\treturn func(s *Server) *Server {\n\t\ts.bindPort = port\n\t\treturn s\n\t}\n}\n\nfunc WithTLSConfig(tlsConfig *tls.Config) Option {\n\treturn func(s *Server) *Server {\n\t\ts.tlsConfig = tlsConfig\n\t\treturn s\n\t}\n}\n\nfunc WithHandler(h http.Handler) Option {\n\treturn func(s *Server) *Server {\n\t\ts.handler = h\n\t\treturn s\n\t}\n}\n\nfunc WithResponse(resp []byte) Option {\n\treturn func(s *Server) *Server {\n\t\ts.handler = http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {\n\t\t\t_, _ = w.Write(resp)\n\t\t})\n\t\treturn s\n\t}\n}\n\nfunc (s *Server) Run() error {\n\tif err := s.initListener(); err != nil {\n\t\treturn err\n\t}\n\n\taddr := net.JoinHostPort(s.bindAddr, strconv.Itoa(s.bindPort))\n\ths := &http.Server{\n\t\tAddr:              addr,\n\t\tHandler:           s.handler,\n\t\tTLSConfig:         s.tlsConfig,\n\t\tReadHeaderTimeout: time.Minute,\n\t}\n\n\ts.hs = hs\n\tif s.tlsConfig == nil {\n\t\tgo func() {\n\t\t\t_ = hs.Serve(s.l)\n\t\t}()\n\t} else {\n\t\tgo func() {\n\t\t\t_ = hs.ServeTLS(s.l, \"\", \"\")\n\t\t}()\n\t}\n\treturn nil\n}\n\nfunc (s *Server) Close() error {\n\tif s.hs != nil {\n\t\treturn s.hs.Close()\n\t}\n\treturn nil\n}\n\nfunc (s *Server) initListener() (err error) {\n\ts.l, err = net.Listen(\"tcp\", net.JoinHostPort(s.bindAddr, strconv.Itoa(s.bindPort)))\n\treturn\n}\n\nfunc (s *Server) BindAddr() string {\n\treturn s.bindAddr\n}\n\nfunc (s *Server) BindPort() int {\n\treturn s.bindPort\n}\n"
  },
  {
    "path": "test/e2e/mock/server/interface.go",
    "content": "package server\n\ntype Server interface {\n\tRun() error\n\tClose() error\n\tBindAddr() string\n\tBindPort() int\n}\n"
  },
  {
    "path": "test/e2e/mock/server/oidcserver/oidcserver.go",
    "content": "// Copyright 2026 The frp Authors\n//\n// Licensed under the Apache License, Version 2.0 (the \"License\");\n// you may not use this file except in compliance with the License.\n// You may obtain a copy of the License at\n//\n//     http://www.apache.org/licenses/LICENSE-2.0\n//\n// Unless required by applicable law or agreed to in writing, software\n// distributed under the License is distributed on an \"AS IS\" BASIS,\n// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.\n// See the License for the specific language governing permissions and\n// limitations under the License.\n\n// Package oidcserver provides a minimal mock OIDC server for e2e testing.\n// It implements three endpoints:\n//   - /.well-known/openid-configuration (discovery)\n//   - /jwks (JSON Web Key Set)\n//   - /token (client_credentials grant)\npackage oidcserver\n\nimport (\n\t\"crypto\"\n\t\"crypto/rand\"\n\t\"crypto/rsa\"\n\t\"crypto/sha256\"\n\t\"encoding/base64\"\n\t\"encoding/json\"\n\t\"fmt\"\n\t\"math/big\"\n\t\"net\"\n\t\"net/http\"\n\t\"strconv\"\n\t\"sync/atomic\"\n\t\"time\"\n)\n\ntype Server struct {\n\tbindAddr string\n\tbindPort int\n\tl        net.Listener\n\ths       *http.Server\n\n\tprivateKey *rsa.PrivateKey\n\tkid        string\n\n\tclientID     string\n\tclientSecret string\n\taudience     string\n\tsubject      string\n\texpiresIn    int // seconds; 0 means omit expires_in from token response\n\n\ttokenRequestCount atomic.Int64\n}\n\ntype Option func(*Server)\n\nfunc WithBindPort(port int) Option {\n\treturn func(s *Server) { s.bindPort = port }\n}\n\nfunc WithClientCredentials(id, secret string) Option {\n\treturn func(s *Server) {\n\t\ts.clientID = id\n\t\ts.clientSecret = secret\n\t}\n}\n\nfunc WithAudience(aud string) Option {\n\treturn func(s *Server) { s.audience = aud }\n}\n\nfunc WithSubject(sub string) Option {\n\treturn func(s *Server) { s.subject = sub }\n}\n\nfunc WithExpiresIn(seconds int) Option {\n\treturn func(s *Server) { s.expiresIn = seconds }\n}\n\nfunc New(options ...Option) *Server {\n\ts := &Server{\n\t\tbindAddr:     \"127.0.0.1\",\n\t\tkid:          \"test-key-1\",\n\t\tclientID:     \"test-client\",\n\t\tclientSecret: \"test-secret\",\n\t\taudience:     \"frps\",\n\t\tsubject:      \"test-service\",\n\t\texpiresIn:    3600,\n\t}\n\tfor _, opt := range options {\n\t\topt(s)\n\t}\n\treturn s\n}\n\nfunc (s *Server) Run() error {\n\tkey, err := rsa.GenerateKey(rand.Reader, 2048)\n\tif err != nil {\n\t\treturn fmt.Errorf(\"generate RSA key: %w\", err)\n\t}\n\ts.privateKey = key\n\n\ts.l, err = net.Listen(\"tcp\", net.JoinHostPort(s.bindAddr, strconv.Itoa(s.bindPort)))\n\tif err != nil {\n\t\treturn err\n\t}\n\ts.bindPort = s.l.Addr().(*net.TCPAddr).Port\n\n\tmux := http.NewServeMux()\n\tmux.HandleFunc(\"/.well-known/openid-configuration\", s.handleDiscovery)\n\tmux.HandleFunc(\"/jwks\", s.handleJWKS)\n\tmux.HandleFunc(\"/token\", s.handleToken)\n\n\ts.hs = &http.Server{\n\t\tHandler:           mux,\n\t\tReadHeaderTimeout: time.Minute,\n\t}\n\tgo func() { _ = s.hs.Serve(s.l) }()\n\treturn nil\n}\n\nfunc (s *Server) Close() error {\n\tif s.hs != nil {\n\t\treturn s.hs.Close()\n\t}\n\treturn nil\n}\n\nfunc (s *Server) BindAddr() string { return s.bindAddr }\nfunc (s *Server) BindPort() int    { return s.bindPort }\n\nfunc (s *Server) Issuer() string {\n\treturn fmt.Sprintf(\"http://%s:%d\", s.bindAddr, s.bindPort)\n}\n\nfunc (s *Server) TokenEndpoint() string {\n\treturn s.Issuer() + \"/token\"\n}\n\n// TokenRequestCount returns the number of successful token requests served.\nfunc (s *Server) TokenRequestCount() int64 {\n\treturn s.tokenRequestCount.Load()\n}\n\nfunc (s *Server) handleDiscovery(w http.ResponseWriter, _ *http.Request) {\n\tissuer := s.Issuer()\n\tw.Header().Set(\"Content-Type\", \"application/json\")\n\t_ = json.NewEncoder(w).Encode(map[string]any{\n\t\t\"issuer\":                                issuer,\n\t\t\"token_endpoint\":                        issuer + \"/token\",\n\t\t\"jwks_uri\":                              issuer + \"/jwks\",\n\t\t\"response_types_supported\":              []string{\"code\"},\n\t\t\"subject_types_supported\":               []string{\"public\"},\n\t\t\"id_token_signing_alg_values_supported\": []string{\"RS256\"},\n\t})\n}\n\nfunc (s *Server) handleJWKS(w http.ResponseWriter, _ *http.Request) {\n\tpub := &s.privateKey.PublicKey\n\tw.Header().Set(\"Content-Type\", \"application/json\")\n\t_ = json.NewEncoder(w).Encode(map[string]any{\n\t\t\"keys\": []map[string]any{\n\t\t\t{\n\t\t\t\t\"kty\": \"RSA\",\n\t\t\t\t\"alg\": \"RS256\",\n\t\t\t\t\"use\": \"sig\",\n\t\t\t\t\"kid\": s.kid,\n\t\t\t\t\"n\":   base64.RawURLEncoding.EncodeToString(pub.N.Bytes()),\n\t\t\t\t\"e\":   base64.RawURLEncoding.EncodeToString(big.NewInt(int64(pub.E)).Bytes()),\n\t\t\t},\n\t\t},\n\t})\n}\n\nfunc (s *Server) handleToken(w http.ResponseWriter, r *http.Request) {\n\tif r.Method != http.MethodPost {\n\t\thttp.Error(w, \"method not allowed\", http.StatusMethodNotAllowed)\n\t\treturn\n\t}\n\tif err := r.ParseForm(); err != nil {\n\t\tw.Header().Set(\"Content-Type\", \"application/json\")\n\t\tw.WriteHeader(http.StatusBadRequest)\n\t\t_ = json.NewEncoder(w).Encode(map[string]any{\n\t\t\t\"error\": \"invalid_request\",\n\t\t})\n\t\treturn\n\t}\n\n\tif r.FormValue(\"grant_type\") != \"client_credentials\" {\n\t\tw.Header().Set(\"Content-Type\", \"application/json\")\n\t\tw.WriteHeader(http.StatusBadRequest)\n\t\t_ = json.NewEncoder(w).Encode(map[string]any{\n\t\t\t\"error\": \"unsupported_grant_type\",\n\t\t})\n\t\treturn\n\t}\n\n\t// Accept credentials from Basic Auth or form body.\n\tclientID, clientSecret, ok := r.BasicAuth()\n\tif !ok {\n\t\tclientID = r.FormValue(\"client_id\")\n\t\tclientSecret = r.FormValue(\"client_secret\")\n\t}\n\tif clientID != s.clientID || clientSecret != s.clientSecret {\n\t\tw.Header().Set(\"Content-Type\", \"application/json\")\n\t\tw.WriteHeader(http.StatusUnauthorized)\n\t\t_ = json.NewEncoder(w).Encode(map[string]any{\n\t\t\t\"error\": \"invalid_client\",\n\t\t})\n\t\treturn\n\t}\n\n\ttoken, err := s.signJWT()\n\tif err != nil {\n\t\thttp.Error(w, err.Error(), http.StatusInternalServerError)\n\t\treturn\n\t}\n\n\tresp := map[string]any{\n\t\t\"access_token\": token,\n\t\t\"token_type\":   \"Bearer\",\n\t}\n\tif s.expiresIn > 0 {\n\t\tresp[\"expires_in\"] = s.expiresIn\n\t}\n\n\ts.tokenRequestCount.Add(1)\n\tw.Header().Set(\"Content-Type\", \"application/json\")\n\t_ = json.NewEncoder(w).Encode(resp)\n}\n\nfunc (s *Server) signJWT() (string, error) {\n\tnow := time.Now()\n\theader, _ := json.Marshal(map[string]string{\n\t\t\"alg\": \"RS256\",\n\t\t\"kid\": s.kid,\n\t\t\"typ\": \"JWT\",\n\t})\n\tclaims, _ := json.Marshal(map[string]any{\n\t\t\"iss\": s.Issuer(),\n\t\t\"sub\": s.subject,\n\t\t\"aud\": s.audience,\n\t\t\"iat\": now.Unix(),\n\t\t\"exp\": now.Add(1 * time.Hour).Unix(),\n\t})\n\n\theaderB64 := base64.RawURLEncoding.EncodeToString(header)\n\tclaimsB64 := base64.RawURLEncoding.EncodeToString(claims)\n\tsigningInput := headerB64 + \".\" + claimsB64\n\n\th := sha256.Sum256([]byte(signingInput))\n\tsig, err := rsa.SignPKCS1v15(rand.Reader, s.privateKey, crypto.SHA256, h[:])\n\tif err != nil {\n\t\treturn \"\", err\n\t}\n\treturn signingInput + \".\" + base64.RawURLEncoding.EncodeToString(sig), nil\n}\n"
  },
  {
    "path": "test/e2e/mock/server/streamserver/server.go",
    "content": "package streamserver\n\nimport (\n\t\"bufio\"\n\t\"fmt\"\n\t\"io\"\n\t\"net\"\n\t\"strconv\"\n\n\tlibnet \"github.com/fatedier/frp/pkg/util/net\"\n\t\"github.com/fatedier/frp/test/e2e/pkg/rpc\"\n)\n\ntype Type string\n\nconst (\n\tTCP  Type = \"tcp\"\n\tUDP  Type = \"udp\"\n\tUnix Type = \"unix\"\n)\n\ntype Server struct {\n\tnetType     Type\n\tbindAddr    string\n\tbindPort    int\n\trespContent []byte\n\n\thandler func(net.Conn)\n\n\tl net.Listener\n}\n\ntype Option func(*Server) *Server\n\nfunc New(netType Type, options ...Option) *Server {\n\ts := &Server{\n\t\tnetType:  netType,\n\t\tbindAddr: \"127.0.0.1\",\n\t}\n\ts.handler = s.handle\n\n\tfor _, option := range options {\n\t\ts = option(s)\n\t}\n\treturn s\n}\n\nfunc WithBindAddr(addr string) Option {\n\treturn func(s *Server) *Server {\n\t\ts.bindAddr = addr\n\t\treturn s\n\t}\n}\n\nfunc WithBindPort(port int) Option {\n\treturn func(s *Server) *Server {\n\t\ts.bindPort = port\n\t\treturn s\n\t}\n}\n\nfunc WithRespContent(content []byte) Option {\n\treturn func(s *Server) *Server {\n\t\ts.respContent = content\n\t\treturn s\n\t}\n}\n\nfunc WithCustomHandler(handler func(net.Conn)) Option {\n\treturn func(s *Server) *Server {\n\t\ts.handler = handler\n\t\treturn s\n\t}\n}\n\nfunc (s *Server) Run() error {\n\tif err := s.initListener(); err != nil {\n\t\treturn err\n\t}\n\n\tgo func() {\n\t\tfor {\n\t\t\tc, err := s.l.Accept()\n\t\t\tif err != nil {\n\t\t\t\treturn\n\t\t\t}\n\t\t\tgo s.handler(c)\n\t\t}\n\t}()\n\treturn nil\n}\n\nfunc (s *Server) Close() error {\n\tif s.l != nil {\n\t\treturn s.l.Close()\n\t}\n\treturn nil\n}\n\nfunc (s *Server) initListener() (err error) {\n\tswitch s.netType {\n\tcase TCP:\n\t\ts.l, err = net.Listen(\"tcp\", net.JoinHostPort(s.bindAddr, strconv.Itoa(s.bindPort)))\n\tcase UDP:\n\t\ts.l, err = libnet.ListenUDP(s.bindAddr, s.bindPort)\n\tcase Unix:\n\t\ts.l, err = net.Listen(\"unix\", s.bindAddr)\n\tdefault:\n\t\treturn fmt.Errorf(\"unknown server type: %s\", s.netType)\n\t}\n\treturn err\n}\n\nfunc (s *Server) handle(c net.Conn) {\n\tdefer c.Close()\n\n\tvar reader io.Reader = c\n\tif s.netType == UDP {\n\t\treader = bufio.NewReader(c)\n\t}\n\tfor {\n\t\tbuf, err := rpc.ReadBytes(reader)\n\t\tif err != nil {\n\t\t\treturn\n\t\t}\n\n\t\tif len(s.respContent) > 0 {\n\t\t\tbuf = s.respContent\n\t\t}\n\t\t_, _ = rpc.WriteBytes(c, buf)\n\t}\n}\n\nfunc (s *Server) BindAddr() string {\n\treturn s.bindAddr\n}\n\nfunc (s *Server) BindPort() int {\n\treturn s.bindPort\n}\n"
  },
  {
    "path": "test/e2e/pkg/cert/generator.go",
    "content": "package cert\n\nimport (\n\t\"crypto/tls\"\n\t\"crypto/x509\"\n\t\"encoding/pem\"\n\t\"time\"\n)\n\n// Artifacts hosts a private key, its corresponding serving certificate and\n// the CA certificate that signs the serving certificate.\ntype Artifacts struct {\n\t// PEM encoded private key\n\tKey []byte\n\t// PEM encoded serving certificate\n\tCert []byte\n\t// PEM encoded CA private key\n\tCAKey []byte\n\t// PEM encoded CA certificate\n\tCACert []byte\n\t// Resource version of the certs\n\tResourceVersion string\n}\n\n// Generator is an interface to provision the serving certificate.\ntype Generator interface {\n\t// Generate returns a Artifacts struct.\n\tGenerate(CommonName string) (*Artifacts, error)\n\t// SetCA sets the PEM-encoded CA private key and CA cert for signing the generated serving cert.\n\tSetCA(caKey, caCert []byte)\n}\n\n// ValidCACert think cert and key are valid if they meet the following requirements:\n// - key and cert are valid pair\n// - caCert is the root ca of cert\n// - cert is for dnsName\n// - cert won't expire before time\nfunc ValidCACert(key, cert, caCert []byte, dnsName string, time time.Time) bool {\n\tif len(key) == 0 || len(cert) == 0 || len(caCert) == 0 {\n\t\treturn false\n\t}\n\t// Verify key and cert are valid pair\n\t_, err := tls.X509KeyPair(cert, key)\n\tif err != nil {\n\t\treturn false\n\t}\n\n\t// Verify cert is valid for at least 1 year.\n\tpool := x509.NewCertPool()\n\tif !pool.AppendCertsFromPEM(caCert) {\n\t\treturn false\n\t}\n\tblock, _ := pem.Decode(cert)\n\tif block == nil {\n\t\treturn false\n\t}\n\tc, err := x509.ParseCertificate(block.Bytes)\n\tif err != nil {\n\t\treturn false\n\t}\n\tops := x509.VerifyOptions{\n\t\tDNSName:     dnsName,\n\t\tRoots:       pool,\n\t\tCurrentTime: time,\n\t}\n\t_, err = c.Verify(ops)\n\treturn err == nil\n}\n"
  },
  {
    "path": "test/e2e/pkg/cert/selfsigned.go",
    "content": "package cert\n\nimport (\n\t\"crypto\"\n\t\"crypto/rand\"\n\t\"crypto/rsa\"\n\t\"crypto/x509\"\n\t\"crypto/x509/pkix\"\n\t\"encoding/pem\"\n\t\"errors\"\n\t\"fmt\"\n\t\"math\"\n\t\"math/big\"\n\t\"net\"\n\t\"time\"\n\n\t\"k8s.io/client-go/util/cert\"\n\t\"k8s.io/client-go/util/keyutil\"\n)\n\ntype SelfSignedCertGenerator struct {\n\tcaKey  []byte\n\tcaCert []byte\n}\n\nvar _ Generator = &SelfSignedCertGenerator{}\n\n// SetCA sets the PEM-encoded CA private key and CA cert for signing the generated serving cert.\nfunc (cp *SelfSignedCertGenerator) SetCA(caKey, caCert []byte) {\n\tcp.caKey = caKey\n\tcp.caCert = caCert\n}\n\n// Generate creates and returns a CA certificate, certificate and\n// key for the server or client. Key and Cert are used by the server or client\n// to establish trust for others, CA certificate is used by the\n// client or server to verify the other's authentication chain.\n// The cert will be valid for 365 days.\nfunc (cp *SelfSignedCertGenerator) Generate(commonName string) (*Artifacts, error) {\n\tvar signingKey *rsa.PrivateKey\n\tvar signingCert *x509.Certificate\n\tvar valid bool\n\tvar err error\n\n\tvalid, signingKey, signingCert = cp.validCACert()\n\tif !valid {\n\t\tsigningKey, err = NewPrivateKey()\n\t\tif err != nil {\n\t\t\treturn nil, fmt.Errorf(\"failed to create the CA private key: %v\", err)\n\t\t}\n\t\tsigningCert, err = cert.NewSelfSignedCACert(cert.Config{CommonName: commonName}, signingKey)\n\t\tif err != nil {\n\t\t\treturn nil, fmt.Errorf(\"failed to create the CA cert: %v\", err)\n\t\t}\n\t}\n\n\thostIP := net.ParseIP(commonName)\n\tvar altIPs []net.IP\n\tDNSNames := []string{\"localhost\"}\n\tif hostIP.To4() != nil {\n\t\taltIPs = append(altIPs, hostIP.To4())\n\t} else {\n\t\tDNSNames = append(DNSNames, commonName)\n\t}\n\n\tkey, err := NewPrivateKey()\n\tif err != nil {\n\t\treturn nil, fmt.Errorf(\"failed to create the private key: %v\", err)\n\t}\n\tsignedCert, err := NewSignedCert(\n\t\tcert.Config{\n\t\t\tCommonName: commonName,\n\t\t\tUsages:     []x509.ExtKeyUsage{x509.ExtKeyUsageServerAuth, x509.ExtKeyUsageClientAuth},\n\t\t\tAltNames:   cert.AltNames{IPs: altIPs, DNSNames: DNSNames},\n\t\t},\n\t\tkey, signingCert, signingKey,\n\t)\n\tif err != nil {\n\t\treturn nil, fmt.Errorf(\"failed to create the cert: %v\", err)\n\t}\n\treturn &Artifacts{\n\t\tKey:    EncodePrivateKeyPEM(key),\n\t\tCert:   EncodeCertPEM(signedCert),\n\t\tCAKey:  EncodePrivateKeyPEM(signingKey),\n\t\tCACert: EncodeCertPEM(signingCert),\n\t}, nil\n}\n\nfunc (cp *SelfSignedCertGenerator) validCACert() (bool, *rsa.PrivateKey, *x509.Certificate) {\n\tif !ValidCACert(cp.caKey, cp.caCert, cp.caCert, \"\",\n\t\ttime.Now().AddDate(1, 0, 0)) {\n\t\treturn false, nil, nil\n\t}\n\n\tvar ok bool\n\tkey, err := keyutil.ParsePrivateKeyPEM(cp.caKey)\n\tif err != nil {\n\t\treturn false, nil, nil\n\t}\n\tprivateKey, ok := key.(*rsa.PrivateKey)\n\tif !ok {\n\t\treturn false, nil, nil\n\t}\n\n\tcerts, err := cert.ParseCertsPEM(cp.caCert)\n\tif err != nil {\n\t\treturn false, nil, nil\n\t}\n\tif len(certs) != 1 {\n\t\treturn false, nil, nil\n\t}\n\treturn true, privateKey, certs[0]\n}\n\n// NewPrivateKey creates an RSA private key\nfunc NewPrivateKey() (*rsa.PrivateKey, error) {\n\treturn rsa.GenerateKey(rand.Reader, 2048)\n}\n\n// NewSignedCert creates a signed certificate using the given CA certificate and key\nfunc NewSignedCert(cfg cert.Config, key crypto.Signer, caCert *x509.Certificate, caKey crypto.Signer) (*x509.Certificate, error) {\n\tserial, err := rand.Int(rand.Reader, new(big.Int).SetInt64(math.MaxInt64))\n\tif err != nil {\n\t\treturn nil, err\n\t}\n\tif len(cfg.CommonName) == 0 {\n\t\treturn nil, errors.New(\"must specify a CommonName\")\n\t}\n\tif len(cfg.Usages) == 0 {\n\t\treturn nil, errors.New(\"must specify at least one ExtKeyUsage\")\n\t}\n\n\tcertTmpl := x509.Certificate{\n\t\tSubject: pkix.Name{\n\t\t\tCommonName:   cfg.CommonName,\n\t\t\tOrganization: cfg.Organization,\n\t\t},\n\t\tDNSNames:     cfg.AltNames.DNSNames,\n\t\tIPAddresses:  cfg.AltNames.IPs,\n\t\tSerialNumber: serial,\n\t\tNotBefore:    caCert.NotBefore,\n\t\tNotAfter:     time.Now().Add(time.Hour * 24 * 365 * 10).UTC(),\n\t\tKeyUsage:     x509.KeyUsageKeyEncipherment | x509.KeyUsageDigitalSignature,\n\t\tExtKeyUsage:  cfg.Usages,\n\t}\n\tcertDERBytes, err := x509.CreateCertificate(rand.Reader, &certTmpl, caCert, key.Public(), caKey)\n\tif err != nil {\n\t\treturn nil, err\n\t}\n\treturn x509.ParseCertificate(certDERBytes)\n}\n\n// EncodePrivateKeyPEM returns PEM-encoded private key data\nfunc EncodePrivateKeyPEM(key *rsa.PrivateKey) []byte {\n\tblock := pem.Block{\n\t\tType:  keyutil.RSAPrivateKeyBlockType,\n\t\tBytes: x509.MarshalPKCS1PrivateKey(key),\n\t}\n\treturn pem.EncodeToMemory(&block)\n}\n\n// EncodeCertPEM returns PEM-encoded certificate data\nfunc EncodeCertPEM(ct *x509.Certificate) []byte {\n\tblock := pem.Block{\n\t\tType:  cert.CertificateBlockType,\n\t\tBytes: ct.Raw,\n\t}\n\treturn pem.EncodeToMemory(&block)\n}\n"
  },
  {
    "path": "test/e2e/pkg/plugin/plugin.go",
    "content": "package plugin\n\nimport (\n\t\"crypto/tls\"\n\t\"encoding/json\"\n\t\"io\"\n\t\"net/http\"\n\n\tplugin \"github.com/fatedier/frp/pkg/plugin/server\"\n\t\"github.com/fatedier/frp/pkg/util/log\"\n\t\"github.com/fatedier/frp/test/e2e/mock/server/httpserver\"\n)\n\ntype Handler func(req *plugin.Request) *plugin.Response\n\ntype NewPluginRequest func() *plugin.Request\n\nfunc NewHTTPPluginServer(port int, newFunc NewPluginRequest, handler Handler, tlsConfig *tls.Config) *httpserver.Server {\n\treturn httpserver.New(\n\t\thttpserver.WithBindPort(port),\n\t\thttpserver.WithTLSConfig(tlsConfig),\n\t\thttpserver.WithHandler(http.HandlerFunc(func(w http.ResponseWriter, req *http.Request) {\n\t\t\tr := newFunc()\n\t\t\tbuf, err := io.ReadAll(req.Body)\n\t\t\tif err != nil {\n\t\t\t\tw.WriteHeader(500)\n\t\t\t\treturn\n\t\t\t}\n\t\t\tlog.Tracef(\"plugin request: %s\", string(buf))\n\t\t\terr = json.Unmarshal(buf, &r)\n\t\t\tif err != nil {\n\t\t\t\tw.WriteHeader(500)\n\t\t\t\treturn\n\t\t\t}\n\t\t\tresp := handler(r)\n\t\t\tbuf, _ = json.Marshal(resp)\n\t\t\tlog.Tracef(\"plugin response: %s\", string(buf))\n\t\t\t_, _ = w.Write(buf)\n\t\t})),\n\t)\n}\n"
  },
  {
    "path": "test/e2e/pkg/port/port.go",
    "content": "package port\n\nimport (\n\t\"fmt\"\n\t\"net\"\n\t\"strconv\"\n\t\"sync\"\n\n\t\"k8s.io/apimachinery/pkg/util/sets\"\n)\n\ntype Allocator struct {\n\treserved sets.Set[int]\n\tused     sets.Set[int]\n\tmu       sync.Mutex\n}\n\n// NewAllocator return a port allocator for testing.\n// Example: from: 10, to: 20, mod 4, index 1\n// Reserved ports: 13, 17\nfunc NewAllocator(from int, to int, mod int, index int) *Allocator {\n\tpa := &Allocator{\n\t\treserved: sets.New[int](),\n\t\tused:     sets.New[int](),\n\t}\n\n\tfor i := from; i <= to; i++ {\n\t\tif i%mod == index {\n\t\t\tpa.reserved.Insert(i)\n\t\t}\n\t}\n\treturn pa\n}\n\nfunc (pa *Allocator) Get() int {\n\treturn pa.GetByName(\"\")\n}\n\nfunc (pa *Allocator) GetByName(portName string) int {\n\tvar builder *nameBuilder\n\tif portName == \"\" {\n\t\tbuilder = &nameBuilder{}\n\t} else {\n\t\tvar err error\n\t\tbuilder, err = unmarshalFromName(portName)\n\t\tif err != nil {\n\t\t\tfmt.Println(err, portName)\n\t\t\treturn 0\n\t\t}\n\t}\n\n\tpa.mu.Lock()\n\tdefer pa.mu.Unlock()\n\n\tfor range 20 {\n\t\tport := pa.getByRange(builder.rangePortFrom, builder.rangePortTo)\n\t\tif port == 0 {\n\t\t\treturn 0\n\t\t}\n\n\t\tl, err := net.Listen(\"tcp\", net.JoinHostPort(\"0.0.0.0\", strconv.Itoa(port)))\n\t\tif err != nil {\n\t\t\t// Maybe not controlled by us, mark it used.\n\t\t\tpa.used.Insert(port)\n\t\t\tcontinue\n\t\t}\n\t\tl.Close()\n\n\t\tudpAddr, err := net.ResolveUDPAddr(\"udp\", net.JoinHostPort(\"0.0.0.0\", strconv.Itoa(port)))\n\t\tif err != nil {\n\t\t\tcontinue\n\t\t}\n\t\tudpConn, err := net.ListenUDP(\"udp\", udpAddr)\n\t\tif err != nil {\n\t\t\t// Maybe not controlled by us, mark it used.\n\t\t\tpa.used.Insert(port)\n\t\t\tcontinue\n\t\t}\n\t\tudpConn.Close()\n\n\t\tpa.used.Insert(port)\n\t\tpa.reserved.Delete(port)\n\t\treturn port\n\t}\n\treturn 0\n}\n\nfunc (pa *Allocator) getByRange(from, to int) int {\n\tif from <= 0 {\n\t\tport, _ := pa.reserved.PopAny()\n\t\treturn port\n\t}\n\n\t// choose a random port between from - to\n\tports := pa.reserved.UnsortedList()\n\tfor _, port := range ports {\n\t\tif port >= from && port <= to {\n\t\t\treturn port\n\t\t}\n\t}\n\treturn 0\n}\n\nfunc (pa *Allocator) Release(port int) {\n\tif port <= 0 {\n\t\treturn\n\t}\n\n\tpa.mu.Lock()\n\tdefer pa.mu.Unlock()\n\n\tif pa.used.Has(port) {\n\t\tpa.used.Delete(port)\n\t\tpa.reserved.Insert(port)\n\t}\n}\n"
  },
  {
    "path": "test/e2e/pkg/port/util.go",
    "content": "package port\n\nimport (\n\t\"fmt\"\n\t\"strconv\"\n\t\"strings\"\n)\n\nconst (\n\tNameDelimiter = \"_\"\n)\n\ntype NameOption func(*nameBuilder) *nameBuilder\n\ntype nameBuilder struct {\n\tname          string\n\trangePortFrom int\n\trangePortTo   int\n}\n\nfunc unmarshalFromName(name string) (*nameBuilder, error) {\n\tvar builder nameBuilder\n\tarrs := strings.Split(name, NameDelimiter)\n\tswitch len(arrs) {\n\tcase 2:\n\t\tbuilder.name = arrs[1]\n\tcase 4:\n\t\tbuilder.name = arrs[1]\n\t\tfromPort, err := strconv.Atoi(arrs[2])\n\t\tif err != nil {\n\t\t\treturn nil, fmt.Errorf(\"error range port from\")\n\t\t}\n\t\tbuilder.rangePortFrom = fromPort\n\n\t\ttoPort, err := strconv.Atoi(arrs[3])\n\t\tif err != nil {\n\t\t\treturn nil, fmt.Errorf(\"error range port to\")\n\t\t}\n\t\tbuilder.rangePortTo = toPort\n\tdefault:\n\t\treturn nil, fmt.Errorf(\"error port name format\")\n\t}\n\treturn &builder, nil\n}\n\nfunc (builder *nameBuilder) String() string {\n\tname := fmt.Sprintf(\"Port%s%s\", NameDelimiter, builder.name)\n\tif builder.rangePortFrom > 0 && builder.rangePortTo > 0 && builder.rangePortTo > builder.rangePortFrom {\n\t\tname += fmt.Sprintf(\"%s%d%s%d\", NameDelimiter, builder.rangePortFrom, NameDelimiter, builder.rangePortTo)\n\t}\n\treturn name\n}\n\nfunc WithRangePorts(from, to int) NameOption {\n\treturn func(builder *nameBuilder) *nameBuilder {\n\t\tbuilder.rangePortFrom = from\n\t\tbuilder.rangePortTo = to\n\t\treturn builder\n\t}\n}\n\nfunc GenName(name string, options ...NameOption) string {\n\tname = strings.ReplaceAll(name, \"-\", \"\")\n\tname = strings.ReplaceAll(name, \"_\", \"\")\n\tbuilder := &nameBuilder{name: name}\n\tfor _, option := range options {\n\t\tbuilder = option(builder)\n\t}\n\treturn builder.String()\n}\n"
  },
  {
    "path": "test/e2e/pkg/process/process.go",
    "content": "package process\n\nimport (\n\t\"bytes\"\n\t\"context\"\n\t\"errors\"\n\t\"fmt\"\n\t\"os/exec\"\n\t\"strings\"\n\t\"sync\"\n\t\"time\"\n)\n\n// SafeBuffer is a thread-safe wrapper around bytes.Buffer.\n// It is safe to call Write and String concurrently.\ntype SafeBuffer struct {\n\tmu  sync.Mutex\n\tbuf bytes.Buffer\n}\n\nfunc (b *SafeBuffer) Write(p []byte) (int, error) {\n\tb.mu.Lock()\n\tdefer b.mu.Unlock()\n\treturn b.buf.Write(p)\n}\n\nfunc (b *SafeBuffer) String() string {\n\tb.mu.Lock()\n\tdefer b.mu.Unlock()\n\treturn b.buf.String()\n}\n\ntype Process struct {\n\tcmd         *exec.Cmd\n\tcancel      context.CancelFunc\n\terrorOutput *SafeBuffer\n\tstdOutput   *SafeBuffer\n\n\tdone     chan struct{}\n\tcloseOne sync.Once\n\twaitErr  error\n\n\tstarted           bool\n\tbeforeStopHandler func()\n\tstopped           bool\n}\n\nfunc New(path string, params []string) *Process {\n\treturn NewWithEnvs(path, params, nil)\n}\n\nfunc NewWithEnvs(path string, params []string, envs []string) *Process {\n\tctx, cancel := context.WithCancel(context.Background())\n\tcmd := exec.CommandContext(ctx, path, params...)\n\tcmd.Env = envs\n\tp := &Process{\n\t\tcmd:    cmd,\n\t\tcancel: cancel,\n\t\tdone:   make(chan struct{}),\n\t}\n\tp.errorOutput = &SafeBuffer{}\n\tp.stdOutput = &SafeBuffer{}\n\tcmd.Stderr = p.errorOutput\n\tcmd.Stdout = p.stdOutput\n\treturn p\n}\n\nfunc (p *Process) Start() error {\n\tif p.started {\n\t\treturn errors.New(\"process already started\")\n\t}\n\tp.started = true\n\n\terr := p.cmd.Start()\n\tif err != nil {\n\t\tp.waitErr = err\n\t\tp.closeDone()\n\t\treturn err\n\t}\n\tgo func() {\n\t\tp.waitErr = p.cmd.Wait()\n\t\tp.closeDone()\n\t}()\n\treturn nil\n}\n\nfunc (p *Process) closeDone() {\n\tp.closeOne.Do(func() { close(p.done) })\n}\n\n// Done returns a channel that is closed when the process exits.\nfunc (p *Process) Done() <-chan struct{} {\n\treturn p.done\n}\n\nfunc (p *Process) Stop() error {\n\tif p.stopped || !p.started {\n\t\treturn nil\n\t}\n\tdefer func() {\n\t\tp.stopped = true\n\t}()\n\tif p.beforeStopHandler != nil {\n\t\tp.beforeStopHandler()\n\t}\n\tp.cancel()\n\t<-p.done\n\treturn p.waitErr\n}\n\nfunc (p *Process) ErrorOutput() string {\n\treturn p.errorOutput.String()\n}\n\nfunc (p *Process) StdOutput() string {\n\treturn p.stdOutput.String()\n}\n\nfunc (p *Process) Output() string {\n\treturn p.stdOutput.String() + p.errorOutput.String()\n}\n\n// CountOutput returns how many times pattern appears in the current accumulated output.\nfunc (p *Process) CountOutput(pattern string) int {\n\treturn strings.Count(p.Output(), pattern)\n}\n\nfunc (p *Process) SetBeforeStopHandler(fn func()) {\n\tp.beforeStopHandler = fn\n}\n\n// WaitForOutput polls the combined process output until the pattern is found\n// count time(s) or the timeout is reached. It also returns early if the process exits.\nfunc (p *Process) WaitForOutput(pattern string, count int, timeout time.Duration) error {\n\tdeadline := time.Now().Add(timeout)\n\tfor time.Now().Before(deadline) {\n\t\toutput := p.Output()\n\t\tif strings.Count(output, pattern) >= count {\n\t\t\treturn nil\n\t\t}\n\t\tselect {\n\t\tcase <-p.Done():\n\t\t\t// Process exited, check one last time.\n\t\t\toutput = p.Output()\n\t\t\tif strings.Count(output, pattern) >= count {\n\t\t\t\treturn nil\n\t\t\t}\n\t\t\treturn fmt.Errorf(\"process exited before %d occurrence(s) of %q found\", count, pattern)\n\t\tcase <-time.After(25 * time.Millisecond):\n\t\t}\n\t}\n\treturn fmt.Errorf(\"timeout waiting for %d occurrence(s) of %q\", count, pattern)\n}\n"
  },
  {
    "path": "test/e2e/pkg/request/request.go",
    "content": "package request\n\nimport (\n\t\"bufio\"\n\t\"bytes\"\n\t\"crypto/tls\"\n\t\"fmt\"\n\t\"io\"\n\t\"net\"\n\t\"net/http\"\n\t\"net/url\"\n\t\"strconv\"\n\t\"time\"\n\n\tlibnet \"github.com/fatedier/golib/net\"\n\n\thttppkg \"github.com/fatedier/frp/pkg/util/http\"\n\t\"github.com/fatedier/frp/test/e2e/pkg/rpc\"\n)\n\ntype Request struct {\n\tprotocol string\n\n\t// for all protocol\n\taddr     string\n\tport     int\n\tbody     []byte\n\ttimeout  time.Duration\n\tresolver *net.Resolver\n\n\t// for http or https\n\tmethod    string\n\thost      string\n\tpath      string\n\theaders   map[string]string\n\ttlsConfig *tls.Config\n\n\tauthValue string\n\n\tproxyURL string\n}\n\nfunc New() *Request {\n\treturn &Request{\n\t\tprotocol: \"tcp\",\n\t\taddr:     \"127.0.0.1\",\n\n\t\tmethod:  \"GET\",\n\t\tpath:    \"/\",\n\t\theaders: map[string]string{},\n\t}\n}\n\nfunc (r *Request) Protocol(protocol string) *Request {\n\tr.protocol = protocol\n\treturn r\n}\n\nfunc (r *Request) TCP() *Request {\n\tr.protocol = \"tcp\"\n\treturn r\n}\n\nfunc (r *Request) UDP() *Request {\n\tr.protocol = \"udp\"\n\treturn r\n}\n\nfunc (r *Request) HTTP() *Request {\n\tr.protocol = \"http\"\n\treturn r\n}\n\nfunc (r *Request) HTTPS() *Request {\n\tr.protocol = \"https\"\n\treturn r\n}\n\nfunc (r *Request) Proxy(url string) *Request {\n\tr.proxyURL = url\n\treturn r\n}\n\nfunc (r *Request) Addr(addr string) *Request {\n\tr.addr = addr\n\treturn r\n}\n\nfunc (r *Request) Port(port int) *Request {\n\tr.port = port\n\treturn r\n}\n\nfunc (r *Request) HTTPParams(method, host, path string, headers map[string]string) *Request {\n\tr.method = method\n\tr.host = host\n\tr.path = path\n\tr.headers = headers\n\treturn r\n}\n\nfunc (r *Request) HTTPHost(host string) *Request {\n\tr.host = host\n\treturn r\n}\n\nfunc (r *Request) HTTPPath(path string) *Request {\n\tr.path = path\n\treturn r\n}\n\nfunc (r *Request) HTTPHeaders(headers map[string]string) *Request {\n\tr.headers = headers\n\treturn r\n}\n\nfunc (r *Request) HTTPAuth(user, password string) *Request {\n\tr.authValue = httppkg.BasicAuth(user, password)\n\treturn r\n}\n\nfunc (r *Request) TLSConfig(tlsConfig *tls.Config) *Request {\n\tr.tlsConfig = tlsConfig\n\treturn r\n}\n\nfunc (r *Request) Timeout(timeout time.Duration) *Request {\n\tr.timeout = timeout\n\treturn r\n}\n\nfunc (r *Request) Body(content []byte) *Request {\n\tr.body = content\n\treturn r\n}\n\nfunc (r *Request) Resolver(resolver *net.Resolver) *Request {\n\tr.resolver = resolver\n\treturn r\n}\n\nfunc (r *Request) Do() (*Response, error) {\n\tvar (\n\t\tconn net.Conn\n\t\terr  error\n\t)\n\n\taddr := r.addr\n\tif r.port > 0 {\n\t\taddr = net.JoinHostPort(r.addr, strconv.Itoa(r.port))\n\t}\n\t// for protocol http and https\n\tif r.protocol == \"http\" || r.protocol == \"https\" {\n\t\treturn r.sendHTTPRequest(r.method, fmt.Sprintf(\"%s://%s%s\", r.protocol, addr, r.path),\n\t\t\tr.host, r.headers, r.proxyURL, r.body, r.tlsConfig)\n\t}\n\n\t// for protocol tcp and udp\n\tif len(r.proxyURL) > 0 {\n\t\tif r.protocol != \"tcp\" {\n\t\t\treturn nil, fmt.Errorf(\"only tcp protocol is allowed for proxy\")\n\t\t}\n\t\tproxyType, proxyAddress, auth, err := libnet.ParseProxyURL(r.proxyURL)\n\t\tif err != nil {\n\t\t\treturn nil, fmt.Errorf(\"parse ProxyURL error: %v\", err)\n\t\t}\n\t\tconn, err = libnet.Dial(addr, libnet.WithProxy(proxyType, proxyAddress), libnet.WithProxyAuth(auth))\n\t\tif err != nil {\n\t\t\treturn nil, err\n\t\t}\n\t} else {\n\t\tdialer := &net.Dialer{Resolver: r.resolver}\n\t\tswitch r.protocol {\n\t\tcase \"tcp\":\n\t\t\tconn, err = dialer.Dial(\"tcp\", addr)\n\t\tcase \"udp\":\n\t\t\tconn, err = dialer.Dial(\"udp\", addr)\n\t\tdefault:\n\t\t\treturn nil, fmt.Errorf(\"invalid protocol\")\n\t\t}\n\t\tif err != nil {\n\t\t\treturn nil, err\n\t\t}\n\t}\n\n\tdefer conn.Close()\n\tif r.timeout > 0 {\n\t\t_ = conn.SetDeadline(time.Now().Add(r.timeout))\n\t}\n\tbuf, err := r.sendRequestByConn(conn, r.body)\n\tif err != nil {\n\t\treturn nil, err\n\t}\n\treturn &Response{Content: buf}, nil\n}\n\ntype Response struct {\n\tCode    int\n\tHeader  http.Header\n\tContent []byte\n}\n\nfunc (r *Request) sendHTTPRequest(method, urlstr string, host string, headers map[string]string,\n\tproxy string, body []byte, tlsConfig *tls.Config,\n) (*Response, error) {\n\tvar inBody io.Reader\n\tif len(body) != 0 {\n\t\tinBody = bytes.NewReader(body)\n\t}\n\treq, err := http.NewRequest(method, urlstr, inBody)\n\tif err != nil {\n\t\treturn nil, err\n\t}\n\tif host != \"\" {\n\t\treq.Host = host\n\t}\n\tfor k, v := range headers {\n\t\treq.Header.Set(k, v)\n\t}\n\tif r.authValue != \"\" {\n\t\treq.Header.Set(\"Authorization\", r.authValue)\n\t}\n\ttr := &http.Transport{\n\t\tDialContext: (&net.Dialer{\n\t\t\tTimeout:   time.Second,\n\t\t\tKeepAlive: 30 * time.Second,\n\t\t\tDualStack: true,\n\t\t\tResolver:  r.resolver,\n\t\t}).DialContext,\n\t\tMaxIdleConns:          100,\n\t\tIdleConnTimeout:       90 * time.Second,\n\t\tTLSHandshakeTimeout:   10 * time.Second,\n\t\tExpectContinueTimeout: 1 * time.Second,\n\t\tTLSClientConfig:       tlsConfig,\n\t}\n\tif len(proxy) != 0 {\n\t\ttr.Proxy = func(req *http.Request) (*url.URL, error) {\n\t\t\treturn url.Parse(proxy)\n\t\t}\n\t}\n\tclient := http.Client{Transport: tr}\n\tresp, err := client.Do(req)\n\tif err != nil {\n\t\treturn nil, err\n\t}\n\tdefer resp.Body.Close()\n\n\tret := &Response{Code: resp.StatusCode, Header: resp.Header}\n\tbuf, err := io.ReadAll(resp.Body)\n\tif err != nil {\n\t\treturn nil, err\n\t}\n\tret.Content = buf\n\treturn ret, nil\n}\n\nfunc (r *Request) sendRequestByConn(c net.Conn, content []byte) ([]byte, error) {\n\t_, err := rpc.WriteBytes(c, content)\n\tif err != nil {\n\t\treturn nil, fmt.Errorf(\"write error: %v\", err)\n\t}\n\n\tvar reader io.Reader = c\n\tif r.protocol == \"udp\" {\n\t\treader = bufio.NewReader(c)\n\t}\n\n\tbuf, err := rpc.ReadBytes(reader)\n\tif err != nil {\n\t\treturn nil, fmt.Errorf(\"read error: %v\", err)\n\t}\n\treturn buf, nil\n}\n"
  },
  {
    "path": "test/e2e/pkg/rpc/rpc.go",
    "content": "package rpc\n\nimport (\n\t\"bytes\"\n\t\"encoding/binary\"\n\t\"errors\"\n\t\"fmt\"\n\t\"io\"\n)\n\nfunc WriteBytes(w io.Writer, buf []byte) (int, error) {\n\tout := bytes.NewBuffer(nil)\n\tif err := binary.Write(out, binary.BigEndian, int64(len(buf))); err != nil {\n\t\treturn 0, err\n\t}\n\n\tout.Write(buf)\n\treturn w.Write(out.Bytes())\n}\n\nfunc ReadBytes(r io.Reader) ([]byte, error) {\n\tvar length int64\n\tif err := binary.Read(r, binary.BigEndian, &length); err != nil {\n\t\treturn nil, err\n\t}\n\tif length < 0 || length > 10*1024*1024 {\n\t\treturn nil, fmt.Errorf(\"invalid length\")\n\t}\n\tbuffer := make([]byte, length)\n\tn, err := io.ReadFull(r, buffer)\n\tif err != nil {\n\t\treturn nil, err\n\t}\n\tif int64(n) != length {\n\t\treturn nil, errors.New(\"invalid length\")\n\t}\n\treturn buffer, nil\n}\n"
  },
  {
    "path": "test/e2e/pkg/ssh/client.go",
    "content": "package ssh\n\nimport (\n\t\"net\"\n\n\tlibio \"github.com/fatedier/golib/io\"\n\t\"golang.org/x/crypto/ssh\"\n)\n\ntype TunnelClient struct {\n\tlocalAddr string\n\tsshServer string\n\tcommands  string\n\n\tsshConn *ssh.Client\n\tln      net.Listener\n}\n\nfunc NewTunnelClient(localAddr string, sshServer string, commands string) *TunnelClient {\n\treturn &TunnelClient{\n\t\tlocalAddr: localAddr,\n\t\tsshServer: sshServer,\n\t\tcommands:  commands,\n\t}\n}\n\nfunc (c *TunnelClient) Start() error {\n\tconfig := &ssh.ClientConfig{\n\t\tUser:            \"v0\",\n\t\tHostKeyCallback: func(string, net.Addr, ssh.PublicKey) error { return nil },\n\t}\n\n\tconn, err := ssh.Dial(\"tcp\", c.sshServer, config)\n\tif err != nil {\n\t\treturn err\n\t}\n\tc.sshConn = conn\n\n\tl, err := conn.Listen(\"tcp\", \"0.0.0.0:80\")\n\tif err != nil {\n\t\treturn err\n\t}\n\tc.ln = l\n\tch, req, err := conn.OpenChannel(\"session\", []byte(\"\"))\n\tif err != nil {\n\t\treturn err\n\t}\n\tdefer ch.Close()\n\tgo ssh.DiscardRequests(req)\n\n\ttype command struct {\n\t\tCmd string\n\t}\n\t_, err = ch.SendRequest(\"exec\", false, ssh.Marshal(command{Cmd: c.commands}))\n\tif err != nil {\n\t\treturn err\n\t}\n\n\tgo c.serveListener()\n\treturn nil\n}\n\nfunc (c *TunnelClient) Close() {\n\tif c.sshConn != nil {\n\t\t_ = c.sshConn.Close()\n\t}\n\tif c.ln != nil {\n\t\t_ = c.ln.Close()\n\t}\n}\n\nfunc (c *TunnelClient) serveListener() {\n\tfor {\n\t\tconn, err := c.ln.Accept()\n\t\tif err != nil {\n\t\t\treturn\n\t\t}\n\t\tgo c.handleConn(conn)\n\t}\n}\n\nfunc (c *TunnelClient) handleConn(conn net.Conn) {\n\tdefer conn.Close()\n\tlocal, err := net.Dial(\"tcp\", c.localAddr)\n\tif err != nil {\n\t\treturn\n\t}\n\t_, _, _ = libio.Join(local, conn)\n}\n"
  },
  {
    "path": "test/e2e/suites.go",
    "content": "package e2e\n\n// CleanupSuite is the boilerplate that can be used after tests on ginkgo were run, on the SynchronizedAfterSuite step.\n// Similar to SynchronizedBeforeSuite, we want to run some operations only once (such as collecting cluster logs).\n// Here, the order of functions is reversed; first, the function which runs everywhere,\n// and then the function that only runs on the first Ginkgo node.\nfunc CleanupSuite() {\n\t// Run on all Ginkgo nodes\n}\n\n// AfterSuiteActions are actions that are run on ginkgo's SynchronizedAfterSuite\nfunc AfterSuiteActions() {\n\t// Run only Ginkgo on node 1\n}\n"
  },
  {
    "path": "test/e2e/v1/basic/annotations.go",
    "content": "package basic\n\nimport (\n\t\"fmt\"\n\t\"io\"\n\t\"net/http\"\n\n\t\"github.com/onsi/ginkgo/v2\"\n\t\"github.com/tidwall/gjson\"\n\n\t\"github.com/fatedier/frp/test/e2e/framework\"\n\t\"github.com/fatedier/frp/test/e2e/framework/consts\"\n)\n\nvar _ = ginkgo.Describe(\"[Feature: Annotations]\", func() {\n\tf := framework.NewDefaultFramework()\n\n\tginkgo.It(\"Set Proxy Annotations\", func() {\n\t\twebPort := f.AllocPort()\n\n\t\tserverConf := consts.DefaultServerConfig + fmt.Sprintf(`\n\t\twebServer.port = %d\n\t\t`, webPort)\n\n\t\tp1Port := f.AllocPort()\n\n\t\tclientConf := consts.DefaultClientConfig + fmt.Sprintf(`\n\t\t[[proxies]]\n\t\tname = \"p1\"\n\t\ttype = \"tcp\"\n\t\tlocalPort = {{ .%s }}\n\t\tremotePort = %d\n\t\t[proxies.annotations]\n\t\t\"frp.e2e.test/foo\" = \"value1\"\n\t\t\"frp.e2e.test/bar\" = \"value2\"\n\t\t`, framework.TCPEchoServerPort, p1Port)\n\n\t\tf.RunProcesses(serverConf, []string{clientConf})\n\n\t\tframework.NewRequestExpect(f).Port(p1Port).Ensure()\n\n\t\t// check annotations in frps\n\t\tresp, err := http.Get(fmt.Sprintf(\"http://127.0.0.1:%d/api/proxy/tcp/%s\", webPort, \"p1\"))\n\t\tframework.ExpectNoError(err)\n\t\tframework.ExpectEqual(resp.StatusCode, 200)\n\t\tdefer resp.Body.Close()\n\t\tcontent, err := io.ReadAll(resp.Body)\n\t\tframework.ExpectNoError(err)\n\n\t\tannotations := gjson.Get(string(content), \"conf.annotations\").Map()\n\t\tframework.ExpectEqual(\"value1\", annotations[\"frp.e2e.test/foo\"].String())\n\t\tframework.ExpectEqual(\"value2\", annotations[\"frp.e2e.test/bar\"].String())\n\t})\n})\n"
  },
  {
    "path": "test/e2e/v1/basic/basic.go",
    "content": "package basic\n\nimport (\n\t\"crypto/tls\"\n\t\"fmt\"\n\t\"strings\"\n\t\"time\"\n\n\t\"github.com/onsi/ginkgo/v2\"\n\n\t\"github.com/fatedier/frp/pkg/transport\"\n\t\"github.com/fatedier/frp/test/e2e/framework\"\n\t\"github.com/fatedier/frp/test/e2e/framework/consts\"\n\t\"github.com/fatedier/frp/test/e2e/mock/server/httpserver\"\n\t\"github.com/fatedier/frp/test/e2e/mock/server/streamserver\"\n\t\"github.com/fatedier/frp/test/e2e/pkg/port\"\n\t\"github.com/fatedier/frp/test/e2e/pkg/request\"\n)\n\nvar _ = ginkgo.Describe(\"[Feature: Basic]\", func() {\n\tf := framework.NewDefaultFramework()\n\n\tginkgo.Describe(\"TCP && UDP\", func() {\n\t\ttypes := []string{\"tcp\", \"udp\"}\n\t\tfor _, t := range types {\n\t\t\tproxyType := t\n\t\t\tginkgo.It(fmt.Sprintf(\"Expose a %s echo server\", strings.ToUpper(proxyType)), func() {\n\t\t\t\tserverConf := consts.DefaultServerConfig\n\t\t\t\tvar clientConf strings.Builder\n\t\t\t\tclientConf.WriteString(consts.DefaultClientConfig)\n\n\t\t\t\tlocalPortName := \"\"\n\t\t\t\tprotocol := \"tcp\"\n\t\t\t\tswitch proxyType {\n\t\t\t\tcase \"tcp\":\n\t\t\t\t\tlocalPortName = framework.TCPEchoServerPort\n\t\t\t\t\tprotocol = \"tcp\"\n\t\t\t\tcase \"udp\":\n\t\t\t\t\tlocalPortName = framework.UDPEchoServerPort\n\t\t\t\t\tprotocol = \"udp\"\n\t\t\t\t}\n\t\t\t\tgetProxyConf := func(proxyName string, portName string, extra string) string {\n\t\t\t\t\treturn fmt.Sprintf(`\n\t\t\t\t[[proxies]]\n\t\t\t\tname = \"%s\"\n\t\t\t\ttype = \"%s\"\n\t\t\t\tlocalPort = {{ .%s }}\n\t\t\t\tremotePort = {{ .%s }}\n\t\t\t\t`+extra, proxyName, proxyType, localPortName, portName)\n\t\t\t\t}\n\n\t\t\t\ttests := []struct {\n\t\t\t\t\tproxyName   string\n\t\t\t\t\tportName    string\n\t\t\t\t\textraConfig string\n\t\t\t\t}{\n\t\t\t\t\t{\n\t\t\t\t\t\tproxyName: \"normal\",\n\t\t\t\t\t\tportName:  port.GenName(\"Normal\"),\n\t\t\t\t\t},\n\t\t\t\t\t{\n\t\t\t\t\t\tproxyName:   \"with-encryption\",\n\t\t\t\t\t\tportName:    port.GenName(\"WithEncryption\"),\n\t\t\t\t\t\textraConfig: \"transport.useEncryption = true\",\n\t\t\t\t\t},\n\t\t\t\t\t{\n\t\t\t\t\t\tproxyName:   \"with-compression\",\n\t\t\t\t\t\tportName:    port.GenName(\"WithCompression\"),\n\t\t\t\t\t\textraConfig: \"transport.useCompression = true\",\n\t\t\t\t\t},\n\t\t\t\t\t{\n\t\t\t\t\t\tproxyName: \"with-encryption-and-compression\",\n\t\t\t\t\t\tportName:  port.GenName(\"WithEncryptionAndCompression\"),\n\t\t\t\t\t\textraConfig: `\n\t\t\t\t\t\ttransport.useEncryption = true\n\t\t\t\t\t\ttransport.useCompression = true\n\t\t\t\t\t\t`,\n\t\t\t\t\t},\n\t\t\t\t}\n\n\t\t\t\t// build all client config\n\t\t\t\tfor _, test := range tests {\n\t\t\t\t\tclientConf.WriteString(getProxyConf(test.proxyName, test.portName, test.extraConfig) + \"\\n\")\n\t\t\t\t}\n\t\t\t\t// run frps and frpc\n\t\t\t\tf.RunProcesses(serverConf, []string{clientConf.String()})\n\n\t\t\t\tfor _, test := range tests {\n\t\t\t\t\tframework.NewRequestExpect(f).\n\t\t\t\t\t\tProtocol(protocol).\n\t\t\t\t\t\tPortName(test.portName).\n\t\t\t\t\t\tExplain(test.proxyName).\n\t\t\t\t\t\tEnsure()\n\t\t\t\t}\n\t\t\t})\n\t\t}\n\t})\n\n\tginkgo.Describe(\"HTTP\", func() {\n\t\tginkgo.It(\"proxy to HTTP server\", func() {\n\t\t\tserverConf := consts.DefaultServerConfig\n\t\t\tvhostHTTPPort := f.AllocPort()\n\t\t\tserverConf += fmt.Sprintf(`\n\t\t\tvhostHTTPPort = %d\n\t\t\t`, vhostHTTPPort)\n\n\t\t\tvar clientConf strings.Builder\n\t\t\tclientConf.WriteString(consts.DefaultClientConfig)\n\n\t\t\tgetProxyConf := func(proxyName string, customDomains string, extra string) string {\n\t\t\t\treturn fmt.Sprintf(`\n\t\t\t\t[[proxies]]\n\t\t\t\tname = \"%s\"\n\t\t\t\ttype = \"http\"\n\t\t\t\tlocalPort = {{ .%s }}\n\t\t\t\tcustomDomains = %s\n\t\t\t\t`+extra, proxyName, framework.HTTPSimpleServerPort, customDomains)\n\t\t\t}\n\n\t\t\ttests := []struct {\n\t\t\t\tproxyName     string\n\t\t\t\tcustomDomains string\n\t\t\t\textraConfig   string\n\t\t\t}{\n\t\t\t\t{\n\t\t\t\t\tproxyName: \"normal\",\n\t\t\t\t},\n\t\t\t\t{\n\t\t\t\t\tproxyName:   \"with-encryption\",\n\t\t\t\t\textraConfig: \"transport.useEncryption = true\",\n\t\t\t\t},\n\t\t\t\t{\n\t\t\t\t\tproxyName:   \"with-compression\",\n\t\t\t\t\textraConfig: \"transport.useCompression = true\",\n\t\t\t\t},\n\t\t\t\t{\n\t\t\t\t\tproxyName: \"with-encryption-and-compression\",\n\t\t\t\t\textraConfig: `\n\t\t\t\t\ttransport.useEncryption = true\n\t\t\t\t\ttransport.useCompression = true\n\t\t\t\t\t`,\n\t\t\t\t},\n\t\t\t\t{\n\t\t\t\t\tproxyName:     \"multiple-custom-domains\",\n\t\t\t\t\tcustomDomains: `[\"a.example.com\", \"b.example.com\"]`,\n\t\t\t\t},\n\t\t\t}\n\n\t\t\t// build all client config\n\t\t\tfor i, test := range tests {\n\t\t\t\tif tests[i].customDomains == \"\" {\n\t\t\t\t\ttests[i].customDomains = fmt.Sprintf(`[\"%s\"]`, test.proxyName+\".example.com\")\n\t\t\t\t}\n\t\t\t\tclientConf.WriteString(getProxyConf(test.proxyName, tests[i].customDomains, test.extraConfig) + \"\\n\")\n\t\t\t}\n\t\t\t// run frps and frpc\n\t\t\tf.RunProcesses(serverConf, []string{clientConf.String()})\n\n\t\t\tfor _, test := range tests {\n\t\t\t\tfor domain := range strings.SplitSeq(test.customDomains, \",\") {\n\t\t\t\t\tdomain = strings.TrimSpace(domain)\n\t\t\t\t\tdomain = strings.TrimLeft(domain, \"[\\\"\")\n\t\t\t\t\tdomain = strings.TrimRight(domain, \"]\\\"\")\n\t\t\t\t\tframework.NewRequestExpect(f).\n\t\t\t\t\t\tExplain(test.proxyName + \"-\" + domain).\n\t\t\t\t\t\tPort(vhostHTTPPort).\n\t\t\t\t\t\tRequestModify(func(r *request.Request) {\n\t\t\t\t\t\t\tr.HTTP().HTTPHost(domain)\n\t\t\t\t\t\t}).\n\t\t\t\t\t\tEnsure()\n\t\t\t\t}\n\t\t\t}\n\n\t\t\t// not exist host\n\t\t\tframework.NewRequestExpect(f).\n\t\t\t\tExplain(\"not exist host\").\n\t\t\t\tPort(vhostHTTPPort).\n\t\t\t\tRequestModify(func(r *request.Request) {\n\t\t\t\t\tr.HTTP().HTTPHost(\"not-exist.example.com\")\n\t\t\t\t}).\n\t\t\t\tEnsure(framework.ExpectResponseCode(404))\n\t\t})\n\t})\n\n\tginkgo.Describe(\"HTTPS\", func() {\n\t\tginkgo.It(\"proxy to HTTPS server\", func() {\n\t\t\tserverConf := consts.DefaultServerConfig\n\t\t\tvhostHTTPSPort := f.AllocPort()\n\t\t\tserverConf += fmt.Sprintf(`\n\t\t\tvhostHTTPSPort = %d\n\t\t\t`, vhostHTTPSPort)\n\n\t\t\tlocalPort := f.AllocPort()\n\t\t\tvar clientConf strings.Builder\n\t\t\tclientConf.WriteString(consts.DefaultClientConfig)\n\t\t\tgetProxyConf := func(proxyName string, customDomains string, extra string) string {\n\t\t\t\treturn fmt.Sprintf(`\n\t\t\t\t[[proxies]]\n\t\t\t\tname = \"%s\"\n\t\t\t\ttype = \"https\"\n\t\t\t\tlocalPort = %d\n\t\t\t\tcustomDomains = %s\n\t\t\t\t`+extra, proxyName, localPort, customDomains)\n\t\t\t}\n\n\t\t\ttests := []struct {\n\t\t\t\tproxyName     string\n\t\t\t\tcustomDomains string\n\t\t\t\textraConfig   string\n\t\t\t}{\n\t\t\t\t{\n\t\t\t\t\tproxyName: \"normal\",\n\t\t\t\t},\n\t\t\t\t{\n\t\t\t\t\tproxyName:   \"with-encryption\",\n\t\t\t\t\textraConfig: \"transport.useEncryption = true\",\n\t\t\t\t},\n\t\t\t\t{\n\t\t\t\t\tproxyName:   \"with-compression\",\n\t\t\t\t\textraConfig: \"transport.useCompression = true\",\n\t\t\t\t},\n\t\t\t\t{\n\t\t\t\t\tproxyName: \"with-encryption-and-compression\",\n\t\t\t\t\textraConfig: `\n\t\t\t\t\t\ttransport.useEncryption = true\n\t\t\t\t\t\ttransport.useCompression = true\n\t\t\t\t\t\t`,\n\t\t\t\t},\n\t\t\t\t{\n\t\t\t\t\tproxyName:     \"multiple-custom-domains\",\n\t\t\t\t\tcustomDomains: `[\"a.example.com\", \"b.example.com\"]`,\n\t\t\t\t},\n\t\t\t}\n\n\t\t\t// build all client config\n\t\t\tfor i, test := range tests {\n\t\t\t\tif tests[i].customDomains == \"\" {\n\t\t\t\t\ttests[i].customDomains = fmt.Sprintf(`[\"%s\"]`, test.proxyName+\".example.com\")\n\t\t\t\t}\n\t\t\t\tclientConf.WriteString(getProxyConf(test.proxyName, tests[i].customDomains, test.extraConfig) + \"\\n\")\n\t\t\t}\n\t\t\t// run frps and frpc\n\t\t\tf.RunProcesses(serverConf, []string{clientConf.String()})\n\n\t\t\ttlsConfig, err := transport.NewServerTLSConfig(\"\", \"\", \"\")\n\t\t\tframework.ExpectNoError(err)\n\t\t\tlocalServer := httpserver.New(\n\t\t\t\thttpserver.WithBindPort(localPort),\n\t\t\t\thttpserver.WithTLSConfig(tlsConfig),\n\t\t\t\thttpserver.WithResponse([]byte(\"test\")),\n\t\t\t)\n\t\t\tf.RunServer(\"\", localServer)\n\n\t\t\tfor _, test := range tests {\n\t\t\t\tfor domain := range strings.SplitSeq(test.customDomains, \",\") {\n\t\t\t\t\tdomain = strings.TrimSpace(domain)\n\t\t\t\t\tdomain = strings.TrimLeft(domain, \"[\\\"\")\n\t\t\t\t\tdomain = strings.TrimRight(domain, \"]\\\"\")\n\t\t\t\t\tframework.NewRequestExpect(f).\n\t\t\t\t\t\tExplain(test.proxyName + \"-\" + domain).\n\t\t\t\t\t\tPort(vhostHTTPSPort).\n\t\t\t\t\t\tRequestModify(func(r *request.Request) {\n\t\t\t\t\t\t\tr.HTTPS().HTTPHost(domain).TLSConfig(&tls.Config{\n\t\t\t\t\t\t\t\tServerName:         domain,\n\t\t\t\t\t\t\t\tInsecureSkipVerify: true,\n\t\t\t\t\t\t\t})\n\t\t\t\t\t\t}).\n\t\t\t\t\t\tExpectResp([]byte(\"test\")).\n\t\t\t\t\t\tEnsure()\n\t\t\t\t}\n\t\t\t}\n\n\t\t\t// not exist host\n\t\t\tnotExistDomain := \"not-exist.example.com\"\n\t\t\tframework.NewRequestExpect(f).\n\t\t\t\tExplain(\"not exist host\").\n\t\t\t\tPort(vhostHTTPSPort).\n\t\t\t\tRequestModify(func(r *request.Request) {\n\t\t\t\t\tr.HTTPS().HTTPHost(notExistDomain).TLSConfig(&tls.Config{\n\t\t\t\t\t\tServerName:         notExistDomain,\n\t\t\t\t\t\tInsecureSkipVerify: true,\n\t\t\t\t\t})\n\t\t\t\t}).\n\t\t\t\tExpectError(true).\n\t\t\t\tEnsure()\n\t\t})\n\t})\n\n\tginkgo.Describe(\"STCP && SUDP && XTCP\", func() {\n\t\ttypes := []string{\"stcp\", \"sudp\", \"xtcp\"}\n\t\tfor _, t := range types {\n\t\t\tproxyType := t\n\t\t\tginkgo.It(fmt.Sprintf(\"Expose echo server with %s\", strings.ToUpper(proxyType)), func() {\n\t\t\t\tserverConf := consts.DefaultServerConfig\n\t\t\t\tvar clientServerConf strings.Builder\n\t\t\t\tclientServerConf.WriteString(consts.DefaultClientConfig + \"\\nuser = \\\"user1\\\"\")\n\t\t\t\tvar clientVisitorConf strings.Builder\n\t\t\t\tclientVisitorConf.WriteString(consts.DefaultClientConfig + \"\\nuser = \\\"user1\\\"\")\n\t\t\t\tvar clientUser2VisitorConf strings.Builder\n\t\t\t\tclientUser2VisitorConf.WriteString(consts.DefaultClientConfig + \"\\nuser = \\\"user2\\\"\")\n\n\t\t\t\tlocalPortName := \"\"\n\t\t\t\tprotocol := \"tcp\"\n\t\t\t\tswitch proxyType {\n\t\t\t\tcase \"stcp\":\n\t\t\t\t\tlocalPortName = framework.TCPEchoServerPort\n\t\t\t\t\tprotocol = \"tcp\"\n\t\t\t\tcase \"sudp\":\n\t\t\t\t\tlocalPortName = framework.UDPEchoServerPort\n\t\t\t\t\tprotocol = \"udp\"\n\t\t\t\tcase \"xtcp\":\n\t\t\t\t\tlocalPortName = framework.TCPEchoServerPort\n\t\t\t\t\tprotocol = \"tcp\"\n\t\t\t\t\tginkgo.Skip(\"stun server is not stable\")\n\t\t\t\t}\n\n\t\t\t\tcorrectSK := \"abc\"\n\t\t\t\twrongSK := \"123\"\n\n\t\t\t\tgetProxyServerConf := func(proxyName string, extra string) string {\n\t\t\t\t\treturn fmt.Sprintf(`\n\t\t\t\t[[proxies]]\n\t\t\t\tname = \"%s\"\n\t\t\t\ttype = \"%s\"\n\t\t\t\tsecretKey = \"%s\"\n\t\t\t\tlocalPort = {{ .%s }}\n\t\t\t\t`+extra, proxyName, proxyType, correctSK, localPortName)\n\t\t\t\t}\n\t\t\t\tgetProxyVisitorConf := func(proxyName string, portName, visitorSK, extra string) string {\n\t\t\t\t\treturn fmt.Sprintf(`\n\t\t\t\t[[visitors]]\n\t\t\t\tname = \"%s\"\n\t\t\t\ttype = \"%s\"\n\t\t\t\tserverName = \"%s\"\n\t\t\t\tsecretKey = \"%s\"\n\t\t\t\tbindPort = {{ .%s }}\n\t\t\t\t`+extra, proxyName, proxyType, proxyName, visitorSK, portName)\n\t\t\t\t}\n\n\t\t\t\ttests := []struct {\n\t\t\t\t\tproxyName          string\n\t\t\t\t\tbindPortName       string\n\t\t\t\t\tvisitorSK          string\n\t\t\t\t\tcommonExtraConfig  string\n\t\t\t\t\tproxyExtraConfig   string\n\t\t\t\t\tvisitorExtraConfig string\n\t\t\t\t\texpectError        bool\n\t\t\t\t\tdeployUser2Client  bool\n\t\t\t\t\t// skipXTCP is used to skip xtcp test case\n\t\t\t\t\tskipXTCP bool\n\t\t\t\t}{\n\t\t\t\t\t{\n\t\t\t\t\t\tproxyName:    \"normal\",\n\t\t\t\t\t\tbindPortName: port.GenName(\"Normal\"),\n\t\t\t\t\t\tvisitorSK:    correctSK,\n\t\t\t\t\t\tskipXTCP:     true,\n\t\t\t\t\t},\n\t\t\t\t\t{\n\t\t\t\t\t\tproxyName:         \"with-encryption\",\n\t\t\t\t\t\tbindPortName:      port.GenName(\"WithEncryption\"),\n\t\t\t\t\t\tvisitorSK:         correctSK,\n\t\t\t\t\t\tcommonExtraConfig: \"transport.useEncryption = true\",\n\t\t\t\t\t\tskipXTCP:          true,\n\t\t\t\t\t},\n\t\t\t\t\t{\n\t\t\t\t\t\tproxyName:         \"with-compression\",\n\t\t\t\t\t\tbindPortName:      port.GenName(\"WithCompression\"),\n\t\t\t\t\t\tvisitorSK:         correctSK,\n\t\t\t\t\t\tcommonExtraConfig: \"transport.useCompression = true\",\n\t\t\t\t\t\tskipXTCP:          true,\n\t\t\t\t\t},\n\t\t\t\t\t{\n\t\t\t\t\t\tproxyName:    \"with-encryption-and-compression\",\n\t\t\t\t\t\tbindPortName: port.GenName(\"WithEncryptionAndCompression\"),\n\t\t\t\t\t\tvisitorSK:    correctSK,\n\t\t\t\t\t\tcommonExtraConfig: `\n\t\t\t\t\t\ttransport.useEncryption = true\n\t\t\t\t\t\ttransport.useCompression = true\n\t\t\t\t\t\t`,\n\t\t\t\t\t\tskipXTCP: true,\n\t\t\t\t\t},\n\t\t\t\t\t{\n\t\t\t\t\t\tproxyName:    \"with-error-sk\",\n\t\t\t\t\t\tbindPortName: port.GenName(\"WithErrorSK\"),\n\t\t\t\t\t\tvisitorSK:    wrongSK,\n\t\t\t\t\t\texpectError:  true,\n\t\t\t\t\t},\n\t\t\t\t\t{\n\t\t\t\t\t\tproxyName:          \"allowed-user\",\n\t\t\t\t\t\tbindPortName:       port.GenName(\"AllowedUser\"),\n\t\t\t\t\t\tvisitorSK:          correctSK,\n\t\t\t\t\t\tproxyExtraConfig:   `allowUsers = [\"another\", \"user2\"]`,\n\t\t\t\t\t\tvisitorExtraConfig: `serverUser = \"user1\"`,\n\t\t\t\t\t\tdeployUser2Client:  true,\n\t\t\t\t\t},\n\t\t\t\t\t{\n\t\t\t\t\t\tproxyName:          \"not-allowed-user\",\n\t\t\t\t\t\tbindPortName:       port.GenName(\"NotAllowedUser\"),\n\t\t\t\t\t\tvisitorSK:          correctSK,\n\t\t\t\t\t\tproxyExtraConfig:   `allowUsers = [\"invalid\"]`,\n\t\t\t\t\t\tvisitorExtraConfig: `serverUser = \"user1\"`,\n\t\t\t\t\t\texpectError:        true,\n\t\t\t\t\t},\n\t\t\t\t\t{\n\t\t\t\t\t\tproxyName:          \"allow-all\",\n\t\t\t\t\t\tbindPortName:       port.GenName(\"AllowAll\"),\n\t\t\t\t\t\tvisitorSK:          correctSK,\n\t\t\t\t\t\tproxyExtraConfig:   `allowUsers = [\"*\"]`,\n\t\t\t\t\t\tvisitorExtraConfig: `serverUser = \"user1\"`,\n\t\t\t\t\t\tdeployUser2Client:  true,\n\t\t\t\t\t},\n\t\t\t\t}\n\n\t\t\t\t// build all client config\n\t\t\t\tfor _, test := range tests {\n\t\t\t\t\tclientServerConf.WriteString(getProxyServerConf(test.proxyName, test.commonExtraConfig+\"\\n\"+test.proxyExtraConfig) + \"\\n\")\n\t\t\t\t}\n\t\t\t\tfor _, test := range tests {\n\t\t\t\t\tconfig := getProxyVisitorConf(\n\t\t\t\t\t\ttest.proxyName, test.bindPortName, test.visitorSK, test.commonExtraConfig+\"\\n\"+test.visitorExtraConfig,\n\t\t\t\t\t) + \"\\n\"\n\t\t\t\t\tif test.deployUser2Client {\n\t\t\t\t\t\tclientUser2VisitorConf.WriteString(config)\n\t\t\t\t\t} else {\n\t\t\t\t\t\tclientVisitorConf.WriteString(config)\n\t\t\t\t\t}\n\t\t\t\t}\n\t\t\t\t// run frps and frpc\n\t\t\t\tf.RunProcesses(serverConf, []string{clientServerConf.String(), clientVisitorConf.String(), clientUser2VisitorConf.String()})\n\n\t\t\t\tfor _, test := range tests {\n\t\t\t\t\ttimeout := time.Second\n\t\t\t\t\tif t == \"xtcp\" {\n\t\t\t\t\t\tif test.skipXTCP {\n\t\t\t\t\t\t\tcontinue\n\t\t\t\t\t\t}\n\t\t\t\t\t\ttimeout = 10 * time.Second\n\t\t\t\t\t}\n\t\t\t\t\tframework.NewRequestExpect(f).\n\t\t\t\t\t\tRequestModify(func(r *request.Request) {\n\t\t\t\t\t\t\tr.Timeout(timeout)\n\t\t\t\t\t\t}).\n\t\t\t\t\t\tProtocol(protocol).\n\t\t\t\t\t\tPortName(test.bindPortName).\n\t\t\t\t\t\tExplain(test.proxyName).\n\t\t\t\t\t\tExpectError(test.expectError).\n\t\t\t\t\t\tEnsure()\n\t\t\t\t}\n\t\t\t})\n\t\t}\n\t})\n\n\tginkgo.Describe(\"TCPMUX\", func() {\n\t\tginkgo.It(\"Type tcpmux\", func() {\n\t\t\tserverConf := consts.DefaultServerConfig\n\t\t\tvar clientConf strings.Builder\n\t\t\tclientConf.WriteString(consts.DefaultClientConfig)\n\n\t\t\ttcpmuxHTTPConnectPortName := port.GenName(\"TCPMUX\")\n\t\t\tserverConf += fmt.Sprintf(`\n\t\t\ttcpmuxHTTPConnectPort = {{ .%s }}\n\t\t\t`, tcpmuxHTTPConnectPortName)\n\n\t\t\tgetProxyConf := func(proxyName string, extra string) string {\n\t\t\t\treturn fmt.Sprintf(`\n\t\t\t\t[[proxies]]\n\t\t\t\tname = \"%s\"\n\t\t\t\ttype = \"tcpmux\"\n\t\t\t\tmultiplexer = \"httpconnect\"\n\t\t\t\tlocalPort = {{ .%s }}\n\t\t\t\tcustomDomains = [\"%s\"]\n\t\t\t\t`+extra, proxyName, port.GenName(proxyName), proxyName)\n\t\t\t}\n\n\t\t\ttests := []struct {\n\t\t\t\tproxyName   string\n\t\t\t\textraConfig string\n\t\t\t}{\n\t\t\t\t{\n\t\t\t\t\tproxyName: \"normal\",\n\t\t\t\t},\n\t\t\t\t{\n\t\t\t\t\tproxyName:   \"with-encryption\",\n\t\t\t\t\textraConfig: \"transport.useEncryption = true\",\n\t\t\t\t},\n\t\t\t\t{\n\t\t\t\t\tproxyName:   \"with-compression\",\n\t\t\t\t\textraConfig: \"transport.useCompression = true\",\n\t\t\t\t},\n\t\t\t\t{\n\t\t\t\t\tproxyName: \"with-encryption-and-compression\",\n\t\t\t\t\textraConfig: `\n\t\t\t\t\ttransport.useEncryption = true\n\t\t\t\t\ttransport.useCompression = true\n\t\t\t\t\t`,\n\t\t\t\t},\n\t\t\t}\n\n\t\t\t// build all client config\n\t\t\tfor _, test := range tests {\n\t\t\t\tclientConf.WriteString(getProxyConf(test.proxyName, test.extraConfig) + \"\\n\")\n\n\t\t\t\tlocalServer := streamserver.New(streamserver.TCP, streamserver.WithBindPort(f.AllocPort()), streamserver.WithRespContent([]byte(test.proxyName)))\n\t\t\t\tf.RunServer(port.GenName(test.proxyName), localServer)\n\t\t\t}\n\n\t\t\t// run frps and frpc\n\t\t\tf.RunProcesses(serverConf, []string{clientConf.String()})\n\n\t\t\t// Request without HTTP connect should get error\n\t\t\tframework.NewRequestExpect(f).\n\t\t\t\tPortName(tcpmuxHTTPConnectPortName).\n\t\t\t\tExpectError(true).\n\t\t\t\tExplain(\"request without HTTP connect expect error\").\n\t\t\t\tEnsure()\n\n\t\t\tproxyURL := fmt.Sprintf(\"http://127.0.0.1:%d\", f.PortByName(tcpmuxHTTPConnectPortName))\n\t\t\t// Request with incorrect connect hostname\n\t\t\tframework.NewRequestExpect(f).RequestModify(func(r *request.Request) {\n\t\t\t\tr.Addr(\"invalid\").Proxy(proxyURL)\n\t\t\t}).ExpectError(true).Explain(\"request without HTTP connect expect error\").Ensure()\n\n\t\t\t// Request with correct connect hostname\n\t\t\tfor _, test := range tests {\n\t\t\t\tframework.NewRequestExpect(f).RequestModify(func(r *request.Request) {\n\t\t\t\t\tr.Addr(test.proxyName).Proxy(proxyURL)\n\t\t\t\t}).ExpectResp([]byte(test.proxyName)).Explain(test.proxyName).Ensure()\n\t\t\t}\n\t\t})\n\t})\n})\n"
  },
  {
    "path": "test/e2e/v1/basic/client.go",
    "content": "package basic\n\nimport (\n\t\"context\"\n\t\"fmt\"\n\t\"strconv\"\n\t\"strings\"\n\t\"time\"\n\n\t\"github.com/onsi/ginkgo/v2\"\n\n\t\"github.com/fatedier/frp/test/e2e/framework\"\n\t\"github.com/fatedier/frp/test/e2e/framework/consts\"\n\t\"github.com/fatedier/frp/test/e2e/pkg/request\"\n)\n\nvar _ = ginkgo.Describe(\"[Feature: ClientManage]\", func() {\n\tf := framework.NewDefaultFramework()\n\n\tginkgo.It(\"Update && Reload API\", func() {\n\t\tserverConf := consts.DefaultServerConfig\n\n\t\tadminPort := f.AllocPort()\n\n\t\tp1Port := f.AllocPort()\n\t\tp2Port := f.AllocPort()\n\t\tp3Port := f.AllocPort()\n\n\t\tclientConf := consts.DefaultClientConfig + fmt.Sprintf(`\n\t\twebServer.port = %d\n\n\t\t[[proxies]]\n\t\tname = \"p1\"\n\t\ttype = \"tcp\"\n\t\tlocalPort = {{ .%s }}\n\t\tremotePort = %d\n\n\t\t[[proxies]]\n\t\tname = \"p2\"\n\t\ttype = \"tcp\"\n\t\tlocalPort = {{ .%s }}\n\t\tremotePort = %d\n\n\t\t[[proxies]]\n\t\tname = \"p3\"\n\t\ttype = \"tcp\"\n\t\tlocalPort = {{ .%s }}\n\t\tremotePort = %d\n\t\t`, adminPort,\n\t\t\tframework.TCPEchoServerPort, p1Port,\n\t\t\tframework.TCPEchoServerPort, p2Port,\n\t\t\tframework.TCPEchoServerPort, p3Port)\n\n\t\tf.RunProcesses(serverConf, []string{clientConf})\n\n\t\tframework.NewRequestExpect(f).Port(p1Port).Ensure()\n\t\tframework.NewRequestExpect(f).Port(p2Port).Ensure()\n\t\tframework.NewRequestExpect(f).Port(p3Port).Ensure()\n\n\t\tclient := f.APIClientForFrpc(adminPort)\n\t\tconf, err := client.GetConfig(context.Background())\n\t\tframework.ExpectNoError(err)\n\n\t\tnewP2Port := f.AllocPort()\n\t\t// change p2 port and remove p3 proxy\n\t\tnewClientConf := strings.ReplaceAll(conf, strconv.Itoa(p2Port), strconv.Itoa(newP2Port))\n\t\tp3Index := strings.LastIndex(newClientConf, \"[[proxies]]\")\n\t\tif p3Index >= 0 {\n\t\t\tnewClientConf = newClientConf[:p3Index]\n\t\t}\n\n\t\terr = client.UpdateConfig(context.Background(), newClientConf)\n\t\tframework.ExpectNoError(err)\n\n\t\terr = client.Reload(context.Background(), true)\n\t\tframework.ExpectNoError(err)\n\t\ttime.Sleep(time.Second)\n\n\t\tframework.NewRequestExpect(f).Port(p1Port).Explain(\"p1 port\").Ensure()\n\t\tframework.NewRequestExpect(f).Port(p2Port).Explain(\"original p2 port\").ExpectError(true).Ensure()\n\t\tframework.NewRequestExpect(f).Port(newP2Port).Explain(\"new p2 port\").Ensure()\n\t\tframework.NewRequestExpect(f).Port(p3Port).Explain(\"p3 port\").ExpectError(true).Ensure()\n\t})\n\n\tginkgo.It(\"healthz\", func() {\n\t\tserverConf := consts.DefaultServerConfig\n\n\t\tdashboardPort := f.AllocPort()\n\t\tclientConf := consts.DefaultClientConfig + fmt.Sprintf(`\n        webServer.addr = \"0.0.0.0\"\n\t\twebServer.port = %d\n\t\twebServer.user = \"admin\"\n\t\twebServer.password = \"admin\"\n\t\t`, dashboardPort)\n\n\t\tf.RunProcesses(serverConf, []string{clientConf})\n\n\t\tframework.NewRequestExpect(f).RequestModify(func(r *request.Request) {\n\t\t\tr.HTTP().HTTPPath(\"/healthz\")\n\t\t}).Port(dashboardPort).ExpectResp([]byte(\"\")).Ensure()\n\n\t\tframework.NewRequestExpect(f).RequestModify(func(r *request.Request) {\n\t\t\tr.HTTP().HTTPPath(\"/\")\n\t\t}).Port(dashboardPort).\n\t\t\tEnsure(framework.ExpectResponseCode(401))\n\t})\n\n\tginkgo.It(\"stop\", func() {\n\t\tserverConf := consts.DefaultServerConfig\n\n\t\tadminPort := f.AllocPort()\n\t\ttestPort := f.AllocPort()\n\t\tclientConf := consts.DefaultClientConfig + fmt.Sprintf(`\n\t\twebServer.port = %d\n\n\t\t[[proxies]]\n\t\tname = \"test\"\n\t\ttype = \"tcp\"\n\t\tlocalPort = {{ .%s }}\n\t\tremotePort = %d\n\t\t`, adminPort, framework.TCPEchoServerPort, testPort)\n\n\t\tf.RunProcesses(serverConf, []string{clientConf})\n\n\t\tframework.NewRequestExpect(f).Port(testPort).Ensure()\n\n\t\tclient := f.APIClientForFrpc(adminPort)\n\t\terr := client.Stop(context.Background())\n\t\tframework.ExpectNoError(err)\n\n\t\ttime.Sleep(3 * time.Second)\n\n\t\t// frpc stopped so the port is not listened, expect error\n\t\tframework.NewRequestExpect(f).Port(testPort).ExpectError(true).Ensure()\n\t})\n})\n"
  },
  {
    "path": "test/e2e/v1/basic/client_server.go",
    "content": "package basic\n\nimport (\n\t\"fmt\"\n\t\"strings\"\n\t\"time\"\n\n\t\"github.com/onsi/ginkgo/v2\"\n\n\t\"github.com/fatedier/frp/test/e2e/framework\"\n\t\"github.com/fatedier/frp/test/e2e/framework/consts\"\n\t\"github.com/fatedier/frp/test/e2e/pkg/cert\"\n\t\"github.com/fatedier/frp/test/e2e/pkg/port\"\n)\n\ntype generalTestConfigures struct {\n\tserver        string\n\tclient        string\n\tclientPrefix  string\n\tclient2       string\n\tclient2Prefix string\n\ttestDelay     time.Duration\n\texpectError   bool\n}\n\nfunc renderBindPortConfig(protocol string) string {\n\tswitch protocol {\n\tcase \"kcp\":\n\t\treturn fmt.Sprintf(`kcpBindPort = {{ .%s }}`, consts.PortServerName)\n\tcase \"quic\":\n\t\treturn fmt.Sprintf(`quicBindPort = {{ .%s }}`, consts.PortServerName)\n\tdefault:\n\t\treturn \"\"\n\t}\n}\n\nfunc runClientServerTest(f *framework.Framework, configures *generalTestConfigures) {\n\tserverConf := consts.DefaultServerConfig\n\tclientConf := consts.DefaultClientConfig\n\tif configures.clientPrefix != \"\" {\n\t\tclientConf = configures.clientPrefix\n\t}\n\n\tserverConf += fmt.Sprintf(`\n\t%s\n\t`, configures.server)\n\n\ttcpPortName := port.GenName(\"TCP\")\n\tudpPortName := port.GenName(\"UDP\")\n\tclientConf += fmt.Sprintf(`\n\t\t%s\n\n\t\t[[proxies]]\n\t\tname = \"tcp\"\n\t\ttype = \"tcp\"\n\t\tlocalPort = {{ .%s }}\n\t\tremotePort = {{ .%s }}\n\n\t\t[[proxies]]\n\t\tname = \"udp\"\n\t\ttype = \"udp\"\n\t\tlocalPort = {{ .%s }}\n\t\tremotePort = {{ .%s }}\n\t\t`, configures.client,\n\t\tframework.TCPEchoServerPort, tcpPortName,\n\t\tframework.UDPEchoServerPort, udpPortName,\n\t)\n\n\tclientConfs := []string{clientConf}\n\tif configures.client2 != \"\" {\n\t\tclient2Conf := consts.DefaultClientConfig\n\t\tif configures.client2Prefix != \"\" {\n\t\t\tclient2Conf = configures.client2Prefix\n\t\t}\n\t\tclient2Conf += fmt.Sprintf(`\n\t\t\t%s\n\t\t`, configures.client2)\n\t\tclientConfs = append(clientConfs, client2Conf)\n\t}\n\n\tf.RunProcesses(serverConf, clientConfs)\n\n\tif configures.testDelay > 0 {\n\t\ttime.Sleep(configures.testDelay)\n\t}\n\n\tframework.NewRequestExpect(f).PortName(tcpPortName).ExpectError(configures.expectError).Explain(\"tcp proxy\").Ensure()\n\tframework.NewRequestExpect(f).Protocol(\"udp\").\n\t\tPortName(udpPortName).ExpectError(configures.expectError).Explain(\"udp proxy\").Ensure()\n}\n\n// defineClientServerTest test a normal tcp and udp proxy with specified TestConfigures.\nfunc defineClientServerTest(desc string, f *framework.Framework, configures *generalTestConfigures) {\n\tginkgo.It(desc, func() {\n\t\trunClientServerTest(f, configures)\n\t})\n}\n\nvar _ = ginkgo.Describe(\"[Feature: Client-Server]\", func() {\n\tf := framework.NewDefaultFramework()\n\n\tginkgo.Describe(\"Protocol\", func() {\n\t\tsupportProtocols := []string{\"tcp\", \"kcp\", \"quic\", \"websocket\"}\n\t\tfor _, protocol := range supportProtocols {\n\t\t\tconfigures := &generalTestConfigures{\n\t\t\t\tserver: fmt.Sprintf(`\n\t\t\t\t%s\n\t\t\t\t`, renderBindPortConfig(protocol)),\n\t\t\t\tclient: fmt.Sprintf(`transport.protocol = \"%s\"`, protocol),\n\t\t\t}\n\t\t\tdefineClientServerTest(protocol, f, configures)\n\t\t}\n\t})\n\n\t// wss is special, it needs to be tested separately.\n\t// frps only supports ws, so there should be a proxy to terminate TLS before frps.\n\tginkgo.Describe(\"Protocol wss\", func() {\n\t\twssPort := f.AllocPort()\n\t\tconfigures := &generalTestConfigures{\n\t\t\tclientPrefix: fmt.Sprintf(`\n\t\t\t\tserverAddr = \"127.0.0.1\"\n\t\t\t\tserverPort = %d\n\t\t\t\tloginFailExit = false\n\t\t\t\ttransport.protocol = \"wss\"\n\t\t\t\tlog.level = \"trace\"\n\t\t\t`, wssPort),\n\t\t\t// Due to the fact that frps cannot directly accept wss connections, we use the https2http plugin of another frpc to terminate TLS.\n\t\t\tclient2: fmt.Sprintf(`\n\t\t\t\t[[proxies]]\n\t\t\t\tname = \"wss2ws\"\n\t\t\t\ttype = \"tcp\"\n\t\t\t\tremotePort = %d\n\t\t\t\t[proxies.plugin]\n\t\t\t\ttype = \"https2http\"\n\t\t\t\tlocalAddr = \"127.0.0.1:{{ .%s }}\"\n\t\t\t`, wssPort, consts.PortServerName),\n\t\t\ttestDelay: 10 * time.Second,\n\t\t}\n\n\t\tdefineClientServerTest(\"wss\", f, configures)\n\t})\n\n\tginkgo.Describe(\"Authentication\", func() {\n\t\tdefineClientServerTest(\"Token Correct\", f, &generalTestConfigures{\n\t\t\tserver: `auth.token = \"123456\"`,\n\t\t\tclient: `auth.token = \"123456\"`,\n\t\t})\n\n\t\tdefineClientServerTest(\"Token Incorrect\", f, &generalTestConfigures{\n\t\t\tserver:      `auth.token = \"123456\"`,\n\t\t\tclient:      `auth.token = \"invalid\"`,\n\t\t\texpectError: true,\n\t\t})\n\t})\n\n\tginkgo.Describe(\"TLS\", func() {\n\t\tsupportProtocols := []string{\"tcp\", \"kcp\", \"quic\", \"websocket\"}\n\t\tfor _, protocol := range supportProtocols {\n\t\t\ttmp := protocol\n\t\t\t// Since v0.50.0, the default value of tls_enable has been changed to true.\n\t\t\t// Therefore, here it needs to be set as false to test the scenario of turning it off.\n\t\t\tdefineClientServerTest(\"Disable TLS over \"+strings.ToUpper(tmp), f, &generalTestConfigures{\n\t\t\t\tserver: fmt.Sprintf(`\n\t\t\t\t%s\n\t\t\t\t`, renderBindPortConfig(protocol)),\n\t\t\t\tclient: fmt.Sprintf(`transport.tls.enable = false\n\t\t\t\ttransport.protocol = \"%s\"\n\t\t\t\t`, protocol),\n\t\t\t})\n\t\t}\n\n\t\tdefineClientServerTest(\"enable tls force, client with TLS\", f, &generalTestConfigures{\n\t\t\tserver: \"transport.tls.force = true\",\n\t\t})\n\t\tdefineClientServerTest(\"enable tls force, client without TLS\", f, &generalTestConfigures{\n\t\t\tserver:      \"transport.tls.force = true\",\n\t\t\tclient:      \"transport.tls.enable = false\",\n\t\t\texpectError: true,\n\t\t})\n\t})\n\n\tginkgo.Describe(\"TLS with custom certificate\", func() {\n\t\tsupportProtocols := []string{\"tcp\", \"kcp\", \"quic\", \"websocket\"}\n\n\t\tvar (\n\t\t\tcaCrtPath                    string\n\t\t\tserverCrtPath, serverKeyPath string\n\t\t\tclientCrtPath, clientKeyPath string\n\t\t)\n\t\tginkgo.JustBeforeEach(func() {\n\t\t\tgenerator := &cert.SelfSignedCertGenerator{}\n\t\t\tartifacts, err := generator.Generate(\"127.0.0.1\")\n\t\t\tframework.ExpectNoError(err)\n\n\t\t\tcaCrtPath = f.WriteTempFile(\"ca.crt\", string(artifacts.CACert))\n\t\t\tserverCrtPath = f.WriteTempFile(\"server.crt\", string(artifacts.Cert))\n\t\t\tserverKeyPath = f.WriteTempFile(\"server.key\", string(artifacts.Key))\n\t\t\tgenerator.SetCA(artifacts.CACert, artifacts.CAKey)\n\t\t\t_, err = generator.Generate(\"127.0.0.1\")\n\t\t\tframework.ExpectNoError(err)\n\t\t\tclientCrtPath = f.WriteTempFile(\"client.crt\", string(artifacts.Cert))\n\t\t\tclientKeyPath = f.WriteTempFile(\"client.key\", string(artifacts.Key))\n\t\t})\n\n\t\tfor _, protocol := range supportProtocols {\n\t\t\ttmp := protocol\n\n\t\t\tginkgo.It(\"one-way authentication: \"+tmp, func() {\n\t\t\t\trunClientServerTest(f, &generalTestConfigures{\n\t\t\t\t\tserver: fmt.Sprintf(`\n\t\t\t\t\t\t%s\n\t\t\t\t\t\ttransport.tls.trustedCaFile = \"%s\"\n\t\t\t\t\t`, renderBindPortConfig(tmp), caCrtPath),\n\t\t\t\t\tclient: fmt.Sprintf(`\n\t\t\t\t\t\ttransport.protocol = \"%s\"\n\t\t\t\t\t\ttransport.tls.certFile = \"%s\"\n\t\t\t\t\t\ttransport.tls.keyFile = \"%s\"\n\t\t\t\t\t`, tmp, clientCrtPath, clientKeyPath),\n\t\t\t\t})\n\t\t\t})\n\n\t\t\tginkgo.It(\"mutual authentication: \"+tmp, func() {\n\t\t\t\trunClientServerTest(f, &generalTestConfigures{\n\t\t\t\t\tserver: fmt.Sprintf(`\n\t\t\t\t\t\t%s\n\t\t\t\t\t\ttransport.tls.certFile = \"%s\"\n\t\t\t\t\t\ttransport.tls.keyFile = \"%s\"\n\t\t\t\t\t\ttransport.tls.trustedCaFile = \"%s\"\n\t\t\t\t\t`, renderBindPortConfig(tmp), serverCrtPath, serverKeyPath, caCrtPath),\n\t\t\t\t\tclient: fmt.Sprintf(`\n\t\t\t\t\t\ttransport.protocol = \"%s\"\n\t\t\t\t\t\ttransport.tls.certFile = \"%s\"\n\t\t\t\t\t\ttransport.tls.keyFile = \"%s\"\n\t\t\t\t\t\ttransport.tls.trustedCaFile = \"%s\"\n\t\t\t\t\t`, tmp, clientCrtPath, clientKeyPath, caCrtPath),\n\t\t\t\t})\n\t\t\t})\n\t\t}\n\t})\n\n\tginkgo.Describe(\"TLS with custom certificate and specified server name\", func() {\n\t\tvar (\n\t\t\tcaCrtPath                    string\n\t\t\tserverCrtPath, serverKeyPath string\n\t\t\tclientCrtPath, clientKeyPath string\n\t\t)\n\t\tginkgo.JustBeforeEach(func() {\n\t\t\tgenerator := &cert.SelfSignedCertGenerator{}\n\t\t\tartifacts, err := generator.Generate(\"example.com\")\n\t\t\tframework.ExpectNoError(err)\n\n\t\t\tcaCrtPath = f.WriteTempFile(\"ca.crt\", string(artifacts.CACert))\n\t\t\tserverCrtPath = f.WriteTempFile(\"server.crt\", string(artifacts.Cert))\n\t\t\tserverKeyPath = f.WriteTempFile(\"server.key\", string(artifacts.Key))\n\t\t\tgenerator.SetCA(artifacts.CACert, artifacts.CAKey)\n\t\t\t_, err = generator.Generate(\"example.com\")\n\t\t\tframework.ExpectNoError(err)\n\t\t\tclientCrtPath = f.WriteTempFile(\"client.crt\", string(artifacts.Cert))\n\t\t\tclientKeyPath = f.WriteTempFile(\"client.key\", string(artifacts.Key))\n\t\t})\n\n\t\tginkgo.It(\"mutual authentication\", func() {\n\t\t\trunClientServerTest(f, &generalTestConfigures{\n\t\t\t\tserver: fmt.Sprintf(`\n\t\t\t\ttransport.tls.certFile = \"%s\"\n\t\t\t\ttransport.tls.keyFile = \"%s\"\n\t\t\t\ttransport.tls.trustedCaFile = \"%s\"\n\t\t\t\t`, serverCrtPath, serverKeyPath, caCrtPath),\n\t\t\t\tclient: fmt.Sprintf(`\n\t\t\t\ttransport.tls.serverName = \"example.com\"\n\t\t\t\ttransport.tls.certFile = \"%s\"\n\t\t\t\ttransport.tls.keyFile = \"%s\"\n\t\t\t\ttransport.tls.trustedCaFile = \"%s\"\n\t\t\t\t`, clientCrtPath, clientKeyPath, caCrtPath),\n\t\t\t})\n\t\t})\n\n\t\tginkgo.It(\"mutual authentication with incorrect server name\", func() {\n\t\t\trunClientServerTest(f, &generalTestConfigures{\n\t\t\t\tserver: fmt.Sprintf(`\n\t\t\t\ttransport.tls.certFile = \"%s\"\n\t\t\t\ttransport.tls.keyFile = \"%s\"\n\t\t\t\ttransport.tls.trustedCaFile = \"%s\"\n\t\t\t\t`, serverCrtPath, serverKeyPath, caCrtPath),\n\t\t\t\tclient: fmt.Sprintf(`\n\t\t\t\ttransport.tls.serverName = \"invalid.com\"\n\t\t\t\ttransport.tls.certFile = \"%s\"\n\t\t\t\ttransport.tls.keyFile = \"%s\"\n\t\t\t\ttransport.tls.trustedCaFile = \"%s\"\n\t\t\t\t`, clientCrtPath, clientKeyPath, caCrtPath),\n\t\t\t\texpectError: true,\n\t\t\t})\n\t\t})\n\t})\n\n\tginkgo.Describe(\"TLS with disableCustomTLSFirstByte set to false\", func() {\n\t\tsupportProtocols := []string{\"tcp\", \"kcp\", \"quic\", \"websocket\"}\n\t\tfor _, protocol := range supportProtocols {\n\t\t\ttmp := protocol\n\t\t\tdefineClientServerTest(\"TLS over \"+strings.ToUpper(tmp), f, &generalTestConfigures{\n\t\t\t\tserver: fmt.Sprintf(`\n\t\t\t\t\t%s\n\t\t\t\t\t`, renderBindPortConfig(protocol)),\n\t\t\t\tclient: fmt.Sprintf(`\n\t\t\t\t\ttransport.protocol = \"%s\"\n\t\t\t\t\ttransport.tls.disableCustomTLSFirstByte = false\n\t\t\t\t\t`, protocol),\n\t\t\t})\n\t\t}\n\t})\n\n\tginkgo.Describe(\"IPv6 bind address\", func() {\n\t\tsupportProtocols := []string{\"tcp\", \"kcp\", \"quic\", \"websocket\"}\n\t\tfor _, protocol := range supportProtocols {\n\t\t\ttmp := protocol\n\t\t\tdefineClientServerTest(\"IPv6 bind address: \"+strings.ToUpper(tmp), f, &generalTestConfigures{\n\t\t\t\tserver: fmt.Sprintf(`\n\t\t\t\t\tbindAddr = \"::\"\n\t\t\t\t\t%s\n\t\t\t\t\t`, renderBindPortConfig(protocol)),\n\t\t\t\tclient: fmt.Sprintf(`\n\t\t\t\t\ttransport.protocol = \"%s\"\n\t\t\t\t\t`, protocol),\n\t\t\t})\n\t\t}\n\t})\n\n\tginkgo.Describe(\"Use same port for bindPort and vhostHTTPSPort\", func() {\n\t\tsupportProtocols := []string{\"tcp\", \"kcp\", \"quic\", \"websocket\"}\n\t\tfor _, protocol := range supportProtocols {\n\t\t\ttmp := protocol\n\t\t\tdefineClientServerTest(\"Use same port for bindPort and vhostHTTPSPort: \"+strings.ToUpper(tmp), f, &generalTestConfigures{\n\t\t\t\tserver: fmt.Sprintf(`\n\t\t\t\t\tvhostHTTPSPort = {{ .%s }}\n\t\t\t\t\t%s\n\t\t\t\t\t`, consts.PortServerName, renderBindPortConfig(protocol)),\n\t\t\t\t// transport.tls.disableCustomTLSFirstByte should set to false when vhostHTTPSPort is same as bindPort\n\t\t\t\tclient: fmt.Sprintf(`\n\t\t\t\t\ttransport.protocol = \"%s\"\n\t\t\t\t\ttransport.tls.disableCustomTLSFirstByte = false\n\t\t\t\t\t`, protocol),\n\t\t\t})\n\t\t}\n\t})\n})\n"
  },
  {
    "path": "test/e2e/v1/basic/cmd.go",
    "content": "package basic\n\nimport (\n\t\"strconv\"\n\t\"strings\"\n\n\t\"github.com/onsi/ginkgo/v2\"\n\n\t\"github.com/fatedier/frp/test/e2e/framework\"\n\t\"github.com/fatedier/frp/test/e2e/pkg/request\"\n)\n\nconst (\n\tConfigValidStr = \"syntax is ok\"\n)\n\nvar _ = ginkgo.Describe(\"[Feature: Cmd]\", func() {\n\tf := framework.NewDefaultFramework()\n\n\tginkgo.Describe(\"Verify\", func() {\n\t\tginkgo.It(\"frps valid\", func() {\n\t\t\tpath := f.GenerateConfigFile(`\n\t\t\tbindAddr = \"0.0.0.0\"\n\t\t\tbindPort = 7000\n\t\t\t`)\n\t\t\t_, output, err := f.RunFrps(\"verify\", \"-c\", path)\n\t\t\tframework.ExpectNoError(err)\n\t\t\tframework.ExpectTrue(strings.Contains(output, ConfigValidStr), \"output: %s\", output)\n\t\t})\n\t\tginkgo.It(\"frps invalid\", func() {\n\t\t\tpath := f.GenerateConfigFile(`\n\t\t\tbindAddr = \"0.0.0.0\"\n\t\t\tbindPort = 70000\n\t\t\t`)\n\t\t\t_, output, err := f.RunFrps(\"verify\", \"-c\", path)\n\t\t\tframework.ExpectNoError(err)\n\t\t\tframework.ExpectTrue(!strings.Contains(output, ConfigValidStr), \"output: %s\", output)\n\t\t})\n\t\tginkgo.It(\"frpc valid\", func() {\n\t\t\tpath := f.GenerateConfigFile(`\n\t\t\tserverAddr = \"0.0.0.0\"\n\t\t\tserverPort = 7000\n\t\t\t`)\n\t\t\t_, output, err := f.RunFrpc(\"verify\", \"-c\", path)\n\t\t\tframework.ExpectNoError(err)\n\t\t\tframework.ExpectTrue(strings.Contains(output, ConfigValidStr), \"output: %s\", output)\n\t\t})\n\t\tginkgo.It(\"frpc invalid\", func() {\n\t\t\tpath := f.GenerateConfigFile(`\n\t\t\tserverAddr = \"0.0.0.0\"\n\t\t\tserverPort = 7000\n\t\t\ttransport.protocol = \"invalid\"\n\t\t\t`)\n\t\t\t_, output, err := f.RunFrpc(\"verify\", \"-c\", path)\n\t\t\tframework.ExpectNoError(err)\n\t\t\tframework.ExpectTrue(!strings.Contains(output, ConfigValidStr), \"output: %s\", output)\n\t\t})\n\t})\n\n\tginkgo.Describe(\"Single proxy\", func() {\n\t\tginkgo.It(\"TCP\", func() {\n\t\t\tserverPort := f.AllocPort()\n\t\t\t_, _, err := f.RunFrps(\"-t\", \"123\", \"-p\", strconv.Itoa(serverPort))\n\t\t\tframework.ExpectNoError(err)\n\n\t\t\tlocalPort := f.PortByName(framework.TCPEchoServerPort)\n\t\t\tremotePort := f.AllocPort()\n\t\t\t_, _, err = f.RunFrpc(\"tcp\", \"-s\", \"127.0.0.1\", \"-P\", strconv.Itoa(serverPort), \"-t\", \"123\", \"-u\", \"test\",\n\t\t\t\t\"-l\", strconv.Itoa(localPort), \"-r\", strconv.Itoa(remotePort), \"-n\", \"tcp_test\")\n\t\t\tframework.ExpectNoError(err)\n\n\t\t\tframework.NewRequestExpect(f).Port(remotePort).Ensure()\n\t\t})\n\n\t\tginkgo.It(\"UDP\", func() {\n\t\t\tserverPort := f.AllocPort()\n\t\t\t_, _, err := f.RunFrps(\"-t\", \"123\", \"-p\", strconv.Itoa(serverPort))\n\t\t\tframework.ExpectNoError(err)\n\n\t\t\tlocalPort := f.PortByName(framework.UDPEchoServerPort)\n\t\t\tremotePort := f.AllocPort()\n\t\t\t_, _, err = f.RunFrpc(\"udp\", \"-s\", \"127.0.0.1\", \"-P\", strconv.Itoa(serverPort), \"-t\", \"123\", \"-u\", \"test\",\n\t\t\t\t\"-l\", strconv.Itoa(localPort), \"-r\", strconv.Itoa(remotePort), \"-n\", \"udp_test\")\n\t\t\tframework.ExpectNoError(err)\n\n\t\t\tframework.NewRequestExpect(f).Protocol(\"udp\").\n\t\t\t\tPort(remotePort).Ensure()\n\t\t})\n\n\t\tginkgo.It(\"HTTP\", func() {\n\t\t\tserverPort := f.AllocPort()\n\t\t\tvhostHTTPPort := f.AllocPort()\n\t\t\t_, _, err := f.RunFrps(\"-t\", \"123\", \"-p\", strconv.Itoa(serverPort), \"--vhost-http-port\", strconv.Itoa(vhostHTTPPort))\n\t\t\tframework.ExpectNoError(err)\n\n\t\t\t_, _, err = f.RunFrpc(\"http\", \"-s\", \"127.0.0.1\", \"-P\", strconv.Itoa(serverPort), \"-t\", \"123\", \"-u\", \"test\",\n\t\t\t\t\"-n\", \"udp_test\", \"-l\", strconv.Itoa(f.PortByName(framework.HTTPSimpleServerPort)),\n\t\t\t\t\"--custom-domain\", \"test.example.com\")\n\t\t\tframework.ExpectNoError(err)\n\n\t\t\tframework.NewRequestExpect(f).Port(vhostHTTPPort).\n\t\t\t\tRequestModify(func(r *request.Request) {\n\t\t\t\t\tr.HTTP().HTTPHost(\"test.example.com\")\n\t\t\t\t}).\n\t\t\t\tEnsure()\n\t\t})\n\t})\n})\n"
  },
  {
    "path": "test/e2e/v1/basic/config.go",
    "content": "package basic\n\nimport (\n\t\"context\"\n\t\"fmt\"\n\n\t\"github.com/onsi/ginkgo/v2\"\n\n\t\"github.com/fatedier/frp/test/e2e/framework\"\n\t\"github.com/fatedier/frp/test/e2e/framework/consts\"\n\t\"github.com/fatedier/frp/test/e2e/pkg/port\"\n)\n\nvar _ = ginkgo.Describe(\"[Feature: Config]\", func() {\n\tf := framework.NewDefaultFramework()\n\n\tginkgo.Describe(\"Template\", func() {\n\t\tginkgo.It(\"render by env\", func() {\n\t\t\tserverConf := consts.DefaultServerConfig\n\t\t\tclientConf := consts.DefaultClientConfig\n\n\t\t\tportName := port.GenName(\"TCP\")\n\t\t\tserverConf += fmt.Sprintf(`\n\t\t\tauth.token = \"{{ %s{{ .Envs.FRP_TOKEN }}%s }}\"\n\t\t\t`, \"`\", \"`\")\n\n\t\t\tclientConf += fmt.Sprintf(`\n\t\t\tauth.token = \"{{ %s{{ .Envs.FRP_TOKEN }}%s }}\"\n\n\t\t\t[[proxies]]\n\t\t\tname = \"tcp\"\n\t\t\ttype = \"tcp\"\n\t\t\tlocalPort = {{ .%s }}\n\t\t\tremotePort = {{ .%s }}\n\t\t\t`, \"`\", \"`\", framework.TCPEchoServerPort, portName)\n\n\t\t\tf.SetEnvs([]string{\"FRP_TOKEN=123\"})\n\t\t\tf.RunProcesses(serverConf, []string{clientConf})\n\n\t\t\tframework.NewRequestExpect(f).PortName(portName).Ensure()\n\t\t})\n\n\t\tginkgo.It(\"Range ports mapping\", func() {\n\t\t\tserverConf := consts.DefaultServerConfig\n\t\t\tclientConf := consts.DefaultClientConfig\n\n\t\t\tadminPort := f.AllocPort()\n\n\t\t\tlocalPortsRange := \"13010-13012,13014\"\n\t\t\tremotePortsRange := \"23010-23012,23014\"\n\t\t\tescapeTemplate := func(s string) string {\n\t\t\t\treturn \"{{ `\" + s + \"` }}\"\n\t\t\t}\n\t\t\tclientConf += fmt.Sprintf(`\n\t\t\twebServer.port = %d\n\n\t\t\t%s\n\t\t\t[[proxies]]\n\t\t\tname = \"tcp-%s\"\n\t\t\ttype = \"tcp\"\n\t\t\tlocalPort = %s\n\t\t\tremotePort = %s\n\t\t\t%s\n\t\t\t`, adminPort,\n\t\t\t\tescapeTemplate(fmt.Sprintf(`{{- range $_, $v := parseNumberRangePair \"%s\" \"%s\" }}`, localPortsRange, remotePortsRange)),\n\t\t\t\tescapeTemplate(\"{{ $v.First }}\"),\n\t\t\t\tescapeTemplate(\"{{ $v.First }}\"),\n\t\t\t\tescapeTemplate(\"{{ $v.Second }}\"),\n\t\t\t\tescapeTemplate(\"{{- end }}\"),\n\t\t\t)\n\n\t\t\tf.RunProcesses(serverConf, []string{clientConf})\n\n\t\t\tclient := f.APIClientForFrpc(adminPort)\n\t\t\tcheckProxyFn := func(name string, localPort, remotePort int) {\n\t\t\t\tstatus, err := client.GetProxyStatus(context.Background(), name)\n\t\t\t\tframework.ExpectNoError(err)\n\n\t\t\t\tframework.ExpectContainSubstring(status.LocalAddr, fmt.Sprintf(\":%d\", localPort))\n\t\t\t\tframework.ExpectContainSubstring(status.RemoteAddr, fmt.Sprintf(\":%d\", remotePort))\n\t\t\t}\n\t\t\tcheckProxyFn(\"tcp-13010\", 13010, 23010)\n\t\t\tcheckProxyFn(\"tcp-13011\", 13011, 23011)\n\t\t\tcheckProxyFn(\"tcp-13012\", 13012, 23012)\n\t\t\tcheckProxyFn(\"tcp-13014\", 13014, 23014)\n\t\t})\n\t})\n\n\tginkgo.Describe(\"Includes\", func() {\n\t\tginkgo.It(\"split tcp proxies into different files\", func() {\n\t\t\tserverPort := f.AllocPort()\n\t\t\tserverConfigPath := f.GenerateConfigFile(fmt.Sprintf(`\n\t\t\tbindAddr = \"0.0.0.0\"\n\t\t\tbindPort = %d\n\t\t\t`, serverPort))\n\n\t\t\tremotePort := f.AllocPort()\n\t\t\tproxyConfigPath := f.GenerateConfigFile(fmt.Sprintf(`\n\t\t\t[[proxies]]\n\t\t\tname = \"tcp\"\n\t\t\ttype = \"tcp\"\n\t\t\tlocalPort = %d\n\t\t\tremotePort = %d\n\t\t\t`, f.PortByName(framework.TCPEchoServerPort), remotePort))\n\n\t\t\tremotePort2 := f.AllocPort()\n\t\t\tproxyConfigPath2 := f.GenerateConfigFile(fmt.Sprintf(`\n\t\t\t[[proxies]]\n\t\t\tname = \"tcp2\"\n\t\t\ttype = \"tcp\"\n\t\t\tlocalPort = %d\n\t\t\tremotePort = %d\n\t\t\t`, f.PortByName(framework.TCPEchoServerPort), remotePort2))\n\n\t\t\tclientConfigPath := f.GenerateConfigFile(fmt.Sprintf(`\n\t\t\tserverPort = %d\n\t\t\tincludes = [\"%s\",\"%s\"]\n\t\t\t`, serverPort, proxyConfigPath, proxyConfigPath2))\n\n\t\t\t_, _, err := f.RunFrps(\"-c\", serverConfigPath)\n\t\t\tframework.ExpectNoError(err)\n\n\t\t\t_, _, err = f.RunFrpc(\"-c\", clientConfigPath)\n\t\t\tframework.ExpectNoError(err)\n\n\t\t\tframework.NewRequestExpect(f).Port(remotePort).Ensure()\n\t\t\tframework.NewRequestExpect(f).Port(remotePort2).Ensure()\n\t\t})\n\t})\n\n\tginkgo.Describe(\"Support Formats\", func() {\n\t\tginkgo.It(\"YAML\", func() {\n\t\t\tserverConf := fmt.Sprintf(`\nbindPort: {{ .%s }}\nlog:\n  level: trace\n`, port.GenName(\"Server\"))\n\n\t\t\tremotePort := f.AllocPort()\n\t\t\tclientConf := fmt.Sprintf(`\nserverPort: {{ .%s }}\nlog:\n  level: trace\n\nproxies:\n- name: tcp\n  type: tcp\n  localPort: {{ .%s }}\n  remotePort: %d\n`, port.GenName(\"Server\"), framework.TCPEchoServerPort, remotePort)\n\n\t\t\tf.RunProcesses(serverConf, []string{clientConf})\n\t\t\tframework.NewRequestExpect(f).Port(remotePort).Ensure()\n\t\t})\n\n\t\tginkgo.It(\"JSON\", func() {\n\t\t\tserverConf := fmt.Sprintf(`{\"bindPort\": {{ .%s }}, \"log\": {\"level\": \"trace\"}}`, port.GenName(\"Server\"))\n\n\t\t\tremotePort := f.AllocPort()\n\t\t\tclientConf := fmt.Sprintf(`{\"serverPort\": {{ .%s }}, \"log\": {\"level\": \"trace\"},\n\"proxies\": [{\"name\": \"tcp\", \"type\": \"tcp\", \"localPort\": {{ .%s }}, \"remotePort\": %d}]}`,\n\t\t\t\tport.GenName(\"Server\"), framework.TCPEchoServerPort, remotePort)\n\n\t\t\tf.RunProcesses(serverConf, []string{clientConf})\n\t\t\tframework.NewRequestExpect(f).Port(remotePort).Ensure()\n\t\t})\n\t})\n})\n"
  },
  {
    "path": "test/e2e/v1/basic/http.go",
    "content": "package basic\n\nimport (\n\t\"fmt\"\n\t\"net/http\"\n\t\"net/url\"\n\t\"strconv\"\n\t\"time\"\n\n\t\"github.com/gorilla/websocket\"\n\t\"github.com/onsi/ginkgo/v2\"\n\n\t\"github.com/fatedier/frp/test/e2e/framework\"\n\t\"github.com/fatedier/frp/test/e2e/framework/consts\"\n\t\"github.com/fatedier/frp/test/e2e/mock/server/httpserver\"\n\t\"github.com/fatedier/frp/test/e2e/pkg/request\"\n)\n\nvar _ = ginkgo.Describe(\"[Feature: HTTP]\", func() {\n\tf := framework.NewDefaultFramework()\n\n\tgetDefaultServerConf := func(vhostHTTPPort int) string {\n\t\tconf := consts.DefaultServerConfig + `\n\t\tvhostHTTPPort = %d\n\t\t`\n\t\treturn fmt.Sprintf(conf, vhostHTTPPort)\n\t}\n\tnewHTTPServer := func(port int, respContent string) *httpserver.Server {\n\t\treturn httpserver.New(\n\t\t\thttpserver.WithBindPort(port),\n\t\t\thttpserver.WithHandler(framework.SpecifiedHTTPBodyHandler([]byte(respContent))),\n\t\t)\n\t}\n\n\tginkgo.It(\"HTTP route by locations\", func() {\n\t\tvhostHTTPPort := f.AllocPort()\n\t\tserverConf := getDefaultServerConf(vhostHTTPPort)\n\n\t\tfooPort := f.AllocPort()\n\t\tf.RunServer(\"\", newHTTPServer(fooPort, \"foo\"))\n\n\t\tbarPort := f.AllocPort()\n\t\tf.RunServer(\"\", newHTTPServer(barPort, \"bar\"))\n\n\t\tclientConf := consts.DefaultClientConfig\n\t\tclientConf += fmt.Sprintf(`\n\t\t\t[[proxies]]\n\t\t\tname = \"foo\"\n\t\t\ttype = \"http\"\n\t\t\tlocalPort = %d\n\t\t\tcustomDomains = [\"normal.example.com\"]\n\t\t\tlocations = [\"/\",\"/foo\"]\n\n\t\t\t[[proxies]]\n\t\t\tname = \"bar\"\n\t\t\ttype = \"http\"\n\t\t\tlocalPort = %d\n\t\t\tcustomDomains = [\"normal.example.com\"]\n\t\t\tlocations = [\"/bar\"]\n\t\t\t`, fooPort, barPort)\n\n\t\tf.RunProcesses(serverConf, []string{clientConf})\n\n\t\ttests := []struct {\n\t\t\tpath       string\n\t\t\texpectResp string\n\t\t\tdesc       string\n\t\t}{\n\t\t\t{path: \"/foo\", expectResp: \"foo\", desc: \"foo path\"},\n\t\t\t{path: \"/bar\", expectResp: \"bar\", desc: \"bar path\"},\n\t\t\t{path: \"/other\", expectResp: \"foo\", desc: \"other path\"},\n\t\t}\n\n\t\tfor _, test := range tests {\n\t\t\tframework.NewRequestExpect(f).Explain(test.desc).Port(vhostHTTPPort).\n\t\t\t\tRequestModify(func(r *request.Request) {\n\t\t\t\t\tr.HTTP().HTTPHost(\"normal.example.com\").HTTPPath(test.path)\n\t\t\t\t}).\n\t\t\t\tExpectResp([]byte(test.expectResp)).\n\t\t\t\tEnsure()\n\t\t}\n\t})\n\n\tginkgo.It(\"HTTP route by HTTP user\", func() {\n\t\tvhostHTTPPort := f.AllocPort()\n\t\tserverConf := getDefaultServerConf(vhostHTTPPort)\n\n\t\tfooPort := f.AllocPort()\n\t\tf.RunServer(\"\", newHTTPServer(fooPort, \"foo\"))\n\n\t\tbarPort := f.AllocPort()\n\t\tf.RunServer(\"\", newHTTPServer(barPort, \"bar\"))\n\n\t\totherPort := f.AllocPort()\n\t\tf.RunServer(\"\", newHTTPServer(otherPort, \"other\"))\n\n\t\tclientConf := consts.DefaultClientConfig\n\t\tclientConf += fmt.Sprintf(`\n\t\t\t[[proxies]]\n\t\t\tname = \"foo\"\n\t\t\ttype = \"http\"\n\t\t\tlocalPort = %d\n\t\t\tcustomDomains = [\"normal.example.com\"]\n\t\t\trouteByHTTPUser = \"user1\"\n\n\t\t\t[[proxies]]\n\t\t\tname = \"bar\"\n\t\t\ttype = \"http\"\n\t\t\tlocalPort = %d\n\t\t\tcustomDomains = [\"normal.example.com\"]\n\t\t\trouteByHTTPUser = \"user2\"\n\n\t\t\t[[proxies]]\n\t\t\tname = \"catchAll\"\n\t\t\ttype = \"http\"\n\t\t\tlocalPort = %d\n\t\t\tcustomDomains = [\"normal.example.com\"]\n\t\t\t`, fooPort, barPort, otherPort)\n\n\t\tf.RunProcesses(serverConf, []string{clientConf})\n\n\t\t// user1\n\t\tframework.NewRequestExpect(f).Explain(\"user1\").Port(vhostHTTPPort).\n\t\t\tRequestModify(func(r *request.Request) {\n\t\t\t\tr.HTTP().HTTPHost(\"normal.example.com\").HTTPAuth(\"user1\", \"\")\n\t\t\t}).\n\t\t\tExpectResp([]byte(\"foo\")).\n\t\t\tEnsure()\n\n\t\t// user2\n\t\tframework.NewRequestExpect(f).Explain(\"user2\").Port(vhostHTTPPort).\n\t\t\tRequestModify(func(r *request.Request) {\n\t\t\t\tr.HTTP().HTTPHost(\"normal.example.com\").HTTPAuth(\"user2\", \"\")\n\t\t\t}).\n\t\t\tExpectResp([]byte(\"bar\")).\n\t\t\tEnsure()\n\n\t\t// other user\n\t\tframework.NewRequestExpect(f).Explain(\"other user\").Port(vhostHTTPPort).\n\t\t\tRequestModify(func(r *request.Request) {\n\t\t\t\tr.HTTP().HTTPHost(\"normal.example.com\").HTTPAuth(\"user3\", \"\")\n\t\t\t}).\n\t\t\tExpectResp([]byte(\"other\")).\n\t\t\tEnsure()\n\t})\n\n\tginkgo.It(\"HTTP Basic Auth\", func() {\n\t\tvhostHTTPPort := f.AllocPort()\n\t\tserverConf := getDefaultServerConf(vhostHTTPPort)\n\n\t\tclientConf := consts.DefaultClientConfig\n\t\tclientConf += fmt.Sprintf(`\n\t\t\t[[proxies]]\n\t\t\tname = \"test\"\n\t\t\ttype = \"http\"\n\t\t\tlocalPort = {{ .%s }}\n\t\t\tcustomDomains = [\"normal.example.com\"]\n\t\t\thttpUser = \"test\"\n\t\t\thttpPassword = \"test\"\n\t\t\t`, framework.HTTPSimpleServerPort)\n\n\t\tf.RunProcesses(serverConf, []string{clientConf})\n\n\t\t// not set auth header\n\t\tframework.NewRequestExpect(f).Port(vhostHTTPPort).\n\t\t\tRequestModify(func(r *request.Request) {\n\t\t\t\tr.HTTP().HTTPHost(\"normal.example.com\")\n\t\t\t}).\n\t\t\tEnsure(framework.ExpectResponseCode(401))\n\n\t\t// set incorrect auth header\n\t\tframework.NewRequestExpect(f).Port(vhostHTTPPort).\n\t\t\tRequestModify(func(r *request.Request) {\n\t\t\t\tr.HTTP().HTTPHost(\"normal.example.com\").HTTPAuth(\"test\", \"invalid\")\n\t\t\t}).\n\t\t\tEnsure(framework.ExpectResponseCode(401))\n\n\t\t// set correct auth header\n\t\tframework.NewRequestExpect(f).Port(vhostHTTPPort).\n\t\t\tRequestModify(func(r *request.Request) {\n\t\t\t\tr.HTTP().HTTPHost(\"normal.example.com\").HTTPAuth(\"test\", \"test\")\n\t\t\t}).\n\t\t\tEnsure()\n\t})\n\n\tginkgo.It(\"Wildcard domain\", func() {\n\t\tvhostHTTPPort := f.AllocPort()\n\t\tserverConf := getDefaultServerConf(vhostHTTPPort)\n\n\t\tclientConf := consts.DefaultClientConfig\n\t\tclientConf += fmt.Sprintf(`\n\t\t\t[[proxies]]\n\t\t\tname = \"test\"\n\t\t\ttype = \"http\"\n\t\t\tlocalPort = {{ .%s }}\n\t\t\tcustomDomains = [\"*.example.com\"]\n\t\t\t`, framework.HTTPSimpleServerPort)\n\n\t\tf.RunProcesses(serverConf, []string{clientConf})\n\n\t\t// not match host\n\t\tframework.NewRequestExpect(f).Port(vhostHTTPPort).\n\t\t\tRequestModify(func(r *request.Request) {\n\t\t\t\tr.HTTP().HTTPHost(\"not-match.test.com\")\n\t\t\t}).\n\t\t\tEnsure(framework.ExpectResponseCode(404))\n\n\t\t// test.example.com match *.example.com\n\t\tframework.NewRequestExpect(f).Port(vhostHTTPPort).\n\t\t\tRequestModify(func(r *request.Request) {\n\t\t\t\tr.HTTP().HTTPHost(\"test.example.com\")\n\t\t\t}).\n\t\t\tEnsure()\n\n\t\t// sub.test.example.com match *.example.com\n\t\tframework.NewRequestExpect(f).Port(vhostHTTPPort).\n\t\t\tRequestModify(func(r *request.Request) {\n\t\t\t\tr.HTTP().HTTPHost(\"sub.test.example.com\")\n\t\t\t}).\n\t\t\tEnsure()\n\t})\n\n\tginkgo.It(\"Subdomain\", func() {\n\t\tvhostHTTPPort := f.AllocPort()\n\t\tserverConf := getDefaultServerConf(vhostHTTPPort)\n\t\tserverConf += `\n\t\tsubdomainHost = \"example.com\"\n\t\t`\n\n\t\tfooPort := f.AllocPort()\n\t\tf.RunServer(\"\", newHTTPServer(fooPort, \"foo\"))\n\n\t\tbarPort := f.AllocPort()\n\t\tf.RunServer(\"\", newHTTPServer(barPort, \"bar\"))\n\n\t\tclientConf := consts.DefaultClientConfig\n\t\tclientConf += fmt.Sprintf(`\n\t\t\t[[proxies]]\n\t\t\tname = \"foo\"\n\t\t\ttype = \"http\"\n\t\t\tlocalPort = %d\n\t\t\tsubdomain = \"foo\"\n\n\t\t\t[[proxies]]\n\t\t\tname = \"bar\"\n\t\t\ttype = \"http\"\n\t\t\tlocalPort = %d\n\t\t\tsubdomain = \"bar\"\n\t\t\t`, fooPort, barPort)\n\n\t\tf.RunProcesses(serverConf, []string{clientConf})\n\n\t\t// foo\n\t\tframework.NewRequestExpect(f).Explain(\"foo subdomain\").Port(vhostHTTPPort).\n\t\t\tRequestModify(func(r *request.Request) {\n\t\t\t\tr.HTTP().HTTPHost(\"foo.example.com\")\n\t\t\t}).\n\t\t\tExpectResp([]byte(\"foo\")).\n\t\t\tEnsure()\n\n\t\t// bar\n\t\tframework.NewRequestExpect(f).Explain(\"bar subdomain\").Port(vhostHTTPPort).\n\t\t\tRequestModify(func(r *request.Request) {\n\t\t\t\tr.HTTP().HTTPHost(\"bar.example.com\")\n\t\t\t}).\n\t\t\tExpectResp([]byte(\"bar\")).\n\t\t\tEnsure()\n\t})\n\n\tginkgo.It(\"Modify request headers\", func() {\n\t\tvhostHTTPPort := f.AllocPort()\n\t\tserverConf := getDefaultServerConf(vhostHTTPPort)\n\n\t\tlocalPort := f.AllocPort()\n\t\tlocalServer := httpserver.New(\n\t\t\thttpserver.WithBindPort(localPort),\n\t\t\thttpserver.WithHandler(http.HandlerFunc(func(w http.ResponseWriter, req *http.Request) {\n\t\t\t\t_, _ = w.Write([]byte(req.Header.Get(\"X-From-Where\")))\n\t\t\t})),\n\t\t)\n\t\tf.RunServer(\"\", localServer)\n\n\t\tclientConf := consts.DefaultClientConfig\n\t\tclientConf += fmt.Sprintf(`\n\t\t\t[[proxies]]\n\t\t\tname = \"test\"\n\t\t\ttype = \"http\"\n\t\t\tlocalPort = %d\n\t\t\tcustomDomains = [\"normal.example.com\"]\n\t\t\trequestHeaders.set.x-from-where = \"frp\"\n\t\t\t`, localPort)\n\n\t\tf.RunProcesses(serverConf, []string{clientConf})\n\n\t\tframework.NewRequestExpect(f).Port(vhostHTTPPort).\n\t\t\tRequestModify(func(r *request.Request) {\n\t\t\t\tr.HTTP().HTTPHost(\"normal.example.com\")\n\t\t\t}).\n\t\t\tExpectResp([]byte(\"frp\")). // local http server will write this X-From-Where header to response body\n\t\t\tEnsure()\n\t})\n\n\tginkgo.It(\"Modify response headers\", func() {\n\t\tvhostHTTPPort := f.AllocPort()\n\t\tserverConf := getDefaultServerConf(vhostHTTPPort)\n\n\t\tlocalPort := f.AllocPort()\n\t\tlocalServer := httpserver.New(\n\t\t\thttpserver.WithBindPort(localPort),\n\t\t\thttpserver.WithHandler(http.HandlerFunc(func(w http.ResponseWriter, req *http.Request) {\n\t\t\t\tw.WriteHeader(200)\n\t\t\t})),\n\t\t)\n\t\tf.RunServer(\"\", localServer)\n\n\t\tclientConf := consts.DefaultClientConfig\n\t\tclientConf += fmt.Sprintf(`\n\t\t\t[[proxies]]\n\t\t\tname = \"test\"\n\t\t\ttype = \"http\"\n\t\t\tlocalPort = %d\n\t\t\tcustomDomains = [\"normal.example.com\"]\n\t\t\tresponseHeaders.set.x-from-where = \"frp\"\n\t\t\t`, localPort)\n\n\t\tf.RunProcesses(serverConf, []string{clientConf})\n\n\t\tframework.NewRequestExpect(f).Port(vhostHTTPPort).\n\t\t\tRequestModify(func(r *request.Request) {\n\t\t\t\tr.HTTP().HTTPHost(\"normal.example.com\")\n\t\t\t}).\n\t\t\tEnsure(func(res *request.Response) bool {\n\t\t\t\treturn res.Header.Get(\"X-From-Where\") == \"frp\"\n\t\t\t})\n\t})\n\n\tginkgo.It(\"Host Header Rewrite\", func() {\n\t\tvhostHTTPPort := f.AllocPort()\n\t\tserverConf := getDefaultServerConf(vhostHTTPPort)\n\n\t\tlocalPort := f.AllocPort()\n\t\tlocalServer := httpserver.New(\n\t\t\thttpserver.WithBindPort(localPort),\n\t\t\thttpserver.WithHandler(http.HandlerFunc(func(w http.ResponseWriter, req *http.Request) {\n\t\t\t\t_, _ = w.Write([]byte(req.Host))\n\t\t\t})),\n\t\t)\n\t\tf.RunServer(\"\", localServer)\n\n\t\tclientConf := consts.DefaultClientConfig\n\t\tclientConf += fmt.Sprintf(`\n\t\t\t[[proxies]]\n\t\t\tname = \"test\"\n\t\t\ttype = \"http\"\n\t\t\tlocalPort = %d\n\t\t\tcustomDomains = [\"normal.example.com\"]\n\t\t\thostHeaderRewrite = \"rewrite.example.com\"\n\t\t\t`, localPort)\n\n\t\tf.RunProcesses(serverConf, []string{clientConf})\n\n\t\tframework.NewRequestExpect(f).Port(vhostHTTPPort).\n\t\t\tRequestModify(func(r *request.Request) {\n\t\t\t\tr.HTTP().HTTPHost(\"normal.example.com\")\n\t\t\t}).\n\t\t\tExpectResp([]byte(\"rewrite.example.com\")). // local http server will write host header to response body\n\t\t\tEnsure()\n\t})\n\n\tginkgo.It(\"Websocket protocol\", func() {\n\t\tvhostHTTPPort := f.AllocPort()\n\t\tserverConf := getDefaultServerConf(vhostHTTPPort)\n\n\t\tupgrader := websocket.Upgrader{}\n\n\t\tlocalPort := f.AllocPort()\n\t\tlocalServer := httpserver.New(\n\t\t\thttpserver.WithBindPort(localPort),\n\t\t\thttpserver.WithHandler(http.HandlerFunc(func(w http.ResponseWriter, req *http.Request) {\n\t\t\t\tc, err := upgrader.Upgrade(w, req, nil)\n\t\t\t\tif err != nil {\n\t\t\t\t\treturn\n\t\t\t\t}\n\t\t\t\tdefer c.Close()\n\t\t\t\tfor {\n\t\t\t\t\tmt, message, err := c.ReadMessage()\n\t\t\t\t\tif err != nil {\n\t\t\t\t\t\tbreak\n\t\t\t\t\t}\n\t\t\t\t\terr = c.WriteMessage(mt, message)\n\t\t\t\t\tif err != nil {\n\t\t\t\t\t\tbreak\n\t\t\t\t\t}\n\t\t\t\t}\n\t\t\t})),\n\t\t)\n\n\t\tf.RunServer(\"\", localServer)\n\n\t\tclientConf := consts.DefaultClientConfig\n\t\tclientConf += fmt.Sprintf(`\n\t\t\t[[proxies]]\n\t\t\tname = \"test\"\n\t\t\ttype = \"http\"\n\t\t\tlocalPort = %d\n\t\t\tcustomDomains = [\"127.0.0.1\"]\n\t\t\t`, localPort)\n\n\t\tf.RunProcesses(serverConf, []string{clientConf})\n\n\t\tu := url.URL{Scheme: \"ws\", Host: \"127.0.0.1:\" + strconv.Itoa(vhostHTTPPort)}\n\t\tc, _, err := websocket.DefaultDialer.Dial(u.String(), nil)\n\t\tframework.ExpectNoError(err)\n\n\t\terr = c.WriteMessage(websocket.TextMessage, []byte(consts.TestString))\n\t\tframework.ExpectNoError(err)\n\n\t\t_, msg, err := c.ReadMessage()\n\t\tframework.ExpectNoError(err)\n\t\tframework.ExpectEqualValues(consts.TestString, string(msg))\n\t})\n\n\tginkgo.It(\"vhostHTTPTimeout\", func() {\n\t\tvhostHTTPPort := f.AllocPort()\n\t\tserverConf := getDefaultServerConf(vhostHTTPPort)\n\t\tserverConf += `\n\t\tvhostHTTPTimeout = 2\n\t\t`\n\n\t\tdelayDuration := 0 * time.Second\n\t\tlocalPort := f.AllocPort()\n\t\tlocalServer := httpserver.New(\n\t\t\thttpserver.WithBindPort(localPort),\n\t\t\thttpserver.WithHandler(http.HandlerFunc(func(w http.ResponseWriter, req *http.Request) {\n\t\t\t\ttime.Sleep(delayDuration)\n\t\t\t\t_, _ = w.Write([]byte(req.Host))\n\t\t\t})),\n\t\t)\n\t\tf.RunServer(\"\", localServer)\n\n\t\tclientConf := consts.DefaultClientConfig\n\t\tclientConf += fmt.Sprintf(`\n\t\t\t[[proxies]]\n\t\t\tname = \"test\"\n\t\t\ttype = \"http\"\n\t\t\tlocalPort = %d\n\t\t\tcustomDomains = [\"normal.example.com\"]\n\t\t\t`, localPort)\n\n\t\tf.RunProcesses(serverConf, []string{clientConf})\n\n\t\tframework.NewRequestExpect(f).Port(vhostHTTPPort).\n\t\t\tRequestModify(func(r *request.Request) {\n\t\t\t\tr.HTTP().HTTPHost(\"normal.example.com\").HTTP().Timeout(time.Second)\n\t\t\t}).\n\t\t\tExpectResp([]byte(\"normal.example.com\")).\n\t\t\tEnsure()\n\n\t\tdelayDuration = 3 * time.Second\n\t\tframework.NewRequestExpect(f).Port(vhostHTTPPort).\n\t\t\tRequestModify(func(r *request.Request) {\n\t\t\t\tr.HTTP().HTTPHost(\"normal.example.com\").HTTP().Timeout(5 * time.Second)\n\t\t\t}).\n\t\t\tEnsure(framework.ExpectResponseCode(504))\n\t})\n})\n"
  },
  {
    "path": "test/e2e/v1/basic/oidc.go",
    "content": "// Copyright 2026 The frp Authors\n//\n// Licensed under the Apache License, Version 2.0 (the \"License\");\n// you may not use this file except in compliance with the License.\n// You may obtain a copy of the License at\n//\n//     http://www.apache.org/licenses/LICENSE-2.0\n//\n// Unless required by applicable law or agreed to in writing, software\n// distributed under the License is distributed on an \"AS IS\" BASIS,\n// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.\n// See the License for the specific language governing permissions and\n// limitations under the License.\n\npackage basic\n\nimport (\n\t\"fmt\"\n\t\"time\"\n\n\t\"github.com/onsi/ginkgo/v2\"\n\n\t\"github.com/fatedier/frp/test/e2e/framework\"\n\t\"github.com/fatedier/frp/test/e2e/framework/consts\"\n\t\"github.com/fatedier/frp/test/e2e/mock/server/oidcserver\"\n\t\"github.com/fatedier/frp/test/e2e/pkg/port\"\n)\n\nvar _ = ginkgo.Describe(\"[Feature: OIDC]\", func() {\n\tf := framework.NewDefaultFramework()\n\n\tginkgo.It(\"should work with OIDC authentication\", func() {\n\t\toidcSrv := oidcserver.New(oidcserver.WithBindPort(f.AllocPort()))\n\t\tf.RunServer(\"\", oidcSrv)\n\n\t\tportName := port.GenName(\"TCP\")\n\n\t\tserverConf := consts.DefaultServerConfig + fmt.Sprintf(`\nauth.method = \"oidc\"\nauth.oidc.issuer = \"%s\"\nauth.oidc.audience = \"frps\"\n`, oidcSrv.Issuer())\n\n\t\tclientConf := consts.DefaultClientConfig + fmt.Sprintf(`\nauth.method = \"oidc\"\nauth.oidc.clientID = \"test-client\"\nauth.oidc.clientSecret = \"test-secret\"\nauth.oidc.tokenEndpointURL = \"%s\"\n\n[[proxies]]\nname = \"tcp\"\ntype = \"tcp\"\nlocalPort = {{ .%s }}\nremotePort = {{ .%s }}\n`, oidcSrv.TokenEndpoint(), framework.TCPEchoServerPort, portName)\n\n\t\tf.RunProcesses(serverConf, []string{clientConf})\n\t\tframework.NewRequestExpect(f).PortName(portName).Ensure()\n\t})\n\n\tginkgo.It(\"should authenticate heartbeats with OIDC\", func() {\n\t\toidcSrv := oidcserver.New(oidcserver.WithBindPort(f.AllocPort()))\n\t\tf.RunServer(\"\", oidcSrv)\n\n\t\tserverPort := f.AllocPort()\n\t\tremotePort := f.AllocPort()\n\n\t\tserverConf := fmt.Sprintf(`\nbindAddr = \"0.0.0.0\"\nbindPort = %d\nlog.level = \"trace\"\nauth.method = \"oidc\"\nauth.additionalScopes = [\"HeartBeats\"]\nauth.oidc.issuer = \"%s\"\nauth.oidc.audience = \"frps\"\n`, serverPort, oidcSrv.Issuer())\n\n\t\tclientConf := fmt.Sprintf(`\nserverAddr = \"127.0.0.1\"\nserverPort = %d\nloginFailExit = false\nlog.level = \"trace\"\nauth.method = \"oidc\"\nauth.additionalScopes = [\"HeartBeats\"]\nauth.oidc.clientID = \"test-client\"\nauth.oidc.clientSecret = \"test-secret\"\nauth.oidc.tokenEndpointURL = \"%s\"\ntransport.heartbeatInterval = 1\n\n[[proxies]]\nname = \"tcp\"\ntype = \"tcp\"\nlocalPort = %d\nremotePort = %d\n`, serverPort, oidcSrv.TokenEndpoint(), f.PortByName(framework.TCPEchoServerPort), remotePort)\n\n\t\tserverConfigPath := f.GenerateConfigFile(serverConf)\n\t\tclientConfigPath := f.GenerateConfigFile(clientConf)\n\n\t\t_, _, err := f.RunFrps(\"-c\", serverConfigPath)\n\t\tframework.ExpectNoError(err)\n\t\tclientProcess, _, err := f.RunFrpc(\"-c\", clientConfigPath)\n\t\tframework.ExpectNoError(err)\n\n\t\t// Wait for several authenticated heartbeat cycles instead of a fixed sleep.\n\t\terr = clientProcess.WaitForOutput(\"send heartbeat to server\", 3, 10*time.Second)\n\t\tframework.ExpectNoError(err)\n\n\t\t// Proxy should still work: heartbeat auth has not failed.\n\t\tframework.NewRequestExpect(f).Port(remotePort).Ensure()\n\t})\n\n\tginkgo.It(\"should work when token has no expires_in\", func() {\n\t\toidcSrv := oidcserver.New(\n\t\t\toidcserver.WithBindPort(f.AllocPort()),\n\t\t\toidcserver.WithExpiresIn(0),\n\t\t)\n\t\tf.RunServer(\"\", oidcSrv)\n\n\t\tportName := port.GenName(\"TCP\")\n\n\t\tserverConf := consts.DefaultServerConfig + fmt.Sprintf(`\nauth.method = \"oidc\"\nauth.oidc.issuer = \"%s\"\nauth.oidc.audience = \"frps\"\n`, oidcSrv.Issuer())\n\n\t\tclientConf := consts.DefaultClientConfig + fmt.Sprintf(`\nauth.method = \"oidc\"\nauth.additionalScopes = [\"HeartBeats\"]\nauth.oidc.clientID = \"test-client\"\nauth.oidc.clientSecret = \"test-secret\"\nauth.oidc.tokenEndpointURL = \"%s\"\ntransport.heartbeatInterval = 1\n\n[[proxies]]\nname = \"tcp\"\ntype = \"tcp\"\nlocalPort = {{ .%s }}\nremotePort = {{ .%s }}\n`, oidcSrv.TokenEndpoint(), framework.TCPEchoServerPort, portName)\n\n\t\t_, clientProcesses := f.RunProcesses(serverConf, []string{clientConf})\n\t\tframework.NewRequestExpect(f).PortName(portName).Ensure()\n\n\t\tcountAfterLogin := oidcSrv.TokenRequestCount()\n\n\t\t// Wait for several heartbeat cycles instead of a fixed sleep.\n\t\t// Each heartbeat fetches a fresh token in non-caching mode.\n\t\terr := clientProcesses[0].WaitForOutput(\"send heartbeat to server\", 3, 10*time.Second)\n\t\tframework.ExpectNoError(err)\n\n\t\tframework.NewRequestExpect(f).PortName(portName).Ensure()\n\n\t\t// Each heartbeat should have fetched a new token (non-caching mode).\n\t\tcountAfterHeartbeats := oidcSrv.TokenRequestCount()\n\t\tframework.ExpectTrue(\n\t\t\tcountAfterHeartbeats > countAfterLogin,\n\t\t\t\"expected additional token requests for heartbeats, got %d before and %d after\",\n\t\t\tcountAfterLogin, countAfterHeartbeats,\n\t\t)\n\t})\n\n\tginkgo.It(\"should reject invalid OIDC credentials\", func() {\n\t\toidcSrv := oidcserver.New(oidcserver.WithBindPort(f.AllocPort()))\n\t\tf.RunServer(\"\", oidcSrv)\n\n\t\tportName := port.GenName(\"TCP\")\n\n\t\tserverConf := consts.DefaultServerConfig + fmt.Sprintf(`\nauth.method = \"oidc\"\nauth.oidc.issuer = \"%s\"\nauth.oidc.audience = \"frps\"\n`, oidcSrv.Issuer())\n\n\t\tclientConf := consts.DefaultClientConfig + fmt.Sprintf(`\nauth.method = \"oidc\"\nauth.oidc.clientID = \"test-client\"\nauth.oidc.clientSecret = \"wrong-secret\"\nauth.oidc.tokenEndpointURL = \"%s\"\n\n[[proxies]]\nname = \"tcp\"\ntype = \"tcp\"\nlocalPort = {{ .%s }}\nremotePort = {{ .%s }}\n`, oidcSrv.TokenEndpoint(), framework.TCPEchoServerPort, portName)\n\n\t\tf.RunProcesses(serverConf, []string{clientConf})\n\t\tframework.NewRequestExpect(f).PortName(portName).ExpectError(true).Ensure()\n\t})\n})\n"
  },
  {
    "path": "test/e2e/v1/basic/server.go",
    "content": "package basic\n\nimport (\n\t\"context\"\n\t\"fmt\"\n\t\"net\"\n\t\"strconv\"\n\n\t\"github.com/onsi/ginkgo/v2\"\n\n\t\"github.com/fatedier/frp/test/e2e/framework\"\n\t\"github.com/fatedier/frp/test/e2e/framework/consts\"\n\t\"github.com/fatedier/frp/test/e2e/pkg/port\"\n\t\"github.com/fatedier/frp/test/e2e/pkg/request\"\n)\n\nvar _ = ginkgo.Describe(\"[Feature: Server Manager]\", func() {\n\tf := framework.NewDefaultFramework()\n\n\tginkgo.It(\"Ports Whitelist\", func() {\n\t\tserverConf := consts.DefaultServerConfig\n\t\tclientConf := consts.DefaultClientConfig\n\n\t\tserverConf += `\n\t\tallowPorts = [\n\t\t  { start = 10000, end = 11000 },\n\t\t  { single = 11002 },\n\t\t  { start = 12000, end = 13000 },\n\t\t]\n\t\t`\n\n\t\ttcpPortName := port.GenName(\"TCP\", port.WithRangePorts(10000, 11000))\n\t\tudpPortName := port.GenName(\"UDP\", port.WithRangePorts(12000, 13000))\n\t\tclientConf += fmt.Sprintf(`\n\t\t\t[[proxies]]\n\t\t\tname = \"tcp-allowed-in-range\"\n\t\t\ttype = \"tcp\"\n\t\t\tlocalPort = {{ .%s }}\n\t\t\tremotePort = {{ .%s }}\n\t\t\t`, framework.TCPEchoServerPort, tcpPortName)\n\t\tclientConf += fmt.Sprintf(`\n\t\t\t[[proxies]]\n\t\t\tname = \"tcp-port-not-allowed\"\n\t\t\ttype = \"tcp\"\n\t\t\tlocalPort = {{ .%s }}\n\t\t\tremotePort = 11001\n\t\t\t`, framework.TCPEchoServerPort)\n\t\tclientConf += fmt.Sprintf(`\n\t\t\t[[proxies]]\n\t\t\tname = \"tcp-port-unavailable\"\n\t\t\ttype = \"tcp\"\n\t\t\tlocalPort = {{ .%s }}\n\t\t\tremotePort = {{ .%s }}\n\t\t\t`, framework.TCPEchoServerPort, consts.PortServerName)\n\t\tclientConf += fmt.Sprintf(`\n\t\t\t[[proxies]]\n\t\t\tname = \"udp-allowed-in-range\"\n\t\t\ttype = \"udp\"\n\t\t\tlocalPort = {{ .%s }}\n\t\t\tremotePort = {{ .%s }}\n\t\t\t`, framework.UDPEchoServerPort, udpPortName)\n\t\tclientConf += fmt.Sprintf(`\n\t\t\t[[proxies]]\n\t\t\tname = \"udp-port-not-allowed\"\n\t\t\ttype = \"udp\"\n\t\t\tlocalPort = {{ .%s }}\n\t\t\tremotePort = 11003\n\t\t\t`, framework.UDPEchoServerPort)\n\n\t\tf.RunProcesses(serverConf, []string{clientConf})\n\n\t\t// TCP\n\t\t// Allowed in range\n\t\tframework.NewRequestExpect(f).PortName(tcpPortName).Ensure()\n\n\t\t// Not Allowed\n\t\tframework.NewRequestExpect(f).Port(11001).ExpectError(true).Ensure()\n\n\t\t// Unavailable, already bind by frps\n\t\tframework.NewRequestExpect(f).PortName(consts.PortServerName).ExpectError(true).Ensure()\n\n\t\t// UDP\n\t\t// Allowed in range\n\t\tframework.NewRequestExpect(f).Protocol(\"udp\").PortName(udpPortName).Ensure()\n\n\t\t// Not Allowed\n\t\tframework.NewRequestExpect(f).RequestModify(func(r *request.Request) {\n\t\t\tr.UDP().Port(11003)\n\t\t}).ExpectError(true).Ensure()\n\t})\n\n\tginkgo.It(\"Alloc Random Port\", func() {\n\t\tserverConf := consts.DefaultServerConfig\n\t\tclientConf := consts.DefaultClientConfig\n\n\t\tadminPort := f.AllocPort()\n\t\tclientConf += fmt.Sprintf(`\n\t\twebServer.port = %d\n\n\t\t[[proxies]]\n\t\tname = \"tcp\"\n\t\ttype = \"tcp\"\n\t\tlocalPort = {{ .%s }}\n\n\t\t[[proxies]]\n\t\tname = \"udp\"\n\t\ttype = \"udp\"\n\t\tlocalPort = {{ .%s }}\n\t\t`, adminPort, framework.TCPEchoServerPort, framework.UDPEchoServerPort)\n\n\t\tf.RunProcesses(serverConf, []string{clientConf})\n\n\t\tclient := f.APIClientForFrpc(adminPort)\n\n\t\t// tcp random port\n\t\tstatus, err := client.GetProxyStatus(context.Background(), \"tcp\")\n\t\tframework.ExpectNoError(err)\n\n\t\t_, portStr, err := net.SplitHostPort(status.RemoteAddr)\n\t\tframework.ExpectNoError(err)\n\t\tport, err := strconv.Atoi(portStr)\n\t\tframework.ExpectNoError(err)\n\n\t\tframework.NewRequestExpect(f).Port(port).Ensure()\n\n\t\t// udp random port\n\t\tstatus, err = client.GetProxyStatus(context.Background(), \"udp\")\n\t\tframework.ExpectNoError(err)\n\n\t\t_, portStr, err = net.SplitHostPort(status.RemoteAddr)\n\t\tframework.ExpectNoError(err)\n\t\tport, err = strconv.Atoi(portStr)\n\t\tframework.ExpectNoError(err)\n\n\t\tframework.NewRequestExpect(f).Protocol(\"udp\").Port(port).Ensure()\n\t})\n\n\tginkgo.It(\"Port Reuse\", func() {\n\t\tserverConf := consts.DefaultServerConfig\n\t\t// Use same port as PortServer\n\t\tserverConf += fmt.Sprintf(`\n\t\tvhostHTTPPort = {{ .%s }}\n\t\t`, consts.PortServerName)\n\n\t\tclientConf := consts.DefaultClientConfig + fmt.Sprintf(`\n\t\t[[proxies]]\n\t\tname = \"http\"\n\t\ttype = \"http\"\n\t\tlocalPort = {{ .%s }}\n\t\tcustomDomains = [\"example.com\"]\n\t\t`, framework.HTTPSimpleServerPort)\n\n\t\tf.RunProcesses(serverConf, []string{clientConf})\n\n\t\tframework.NewRequestExpect(f).RequestModify(func(r *request.Request) {\n\t\t\tr.HTTP().HTTPHost(\"example.com\")\n\t\t}).PortName(consts.PortServerName).Ensure()\n\t})\n\n\tginkgo.It(\"healthz\", func() {\n\t\tserverConf := consts.DefaultServerConfig\n\t\tdashboardPort := f.AllocPort()\n\n\t\t// Use same port as PortServer\n\t\tserverConf += fmt.Sprintf(`\n\t\tvhostHTTPPort = {{ .%s }}\n\t\twebServer.addr = \"0.0.0.0\"\n\t\twebServer.port = %d\n\t\twebServer.user = \"admin\"\n\t\twebServer.password = \"admin\"\n\t\t`, consts.PortServerName, dashboardPort)\n\n\t\tclientConf := consts.DefaultClientConfig + fmt.Sprintf(`\n\t\t[[proxies]]\n\t\tname = \"http\"\n\t\ttype = \"http\"\n\t\tlocalPort = {{ .%s }}\n\t\tcustomDomains = [\"example.com\"]\n\t\t`, framework.HTTPSimpleServerPort)\n\n\t\tf.RunProcesses(serverConf, []string{clientConf})\n\n\t\tframework.NewRequestExpect(f).RequestModify(func(r *request.Request) {\n\t\t\tr.HTTP().HTTPPath(\"/healthz\")\n\t\t}).Port(dashboardPort).ExpectResp([]byte(\"\")).Ensure()\n\n\t\tframework.NewRequestExpect(f).RequestModify(func(r *request.Request) {\n\t\t\tr.HTTP().HTTPPath(\"/\")\n\t\t}).Port(dashboardPort).\n\t\t\tEnsure(framework.ExpectResponseCode(401))\n\t})\n})\n"
  },
  {
    "path": "test/e2e/v1/basic/tcpmux.go",
    "content": "package basic\n\nimport (\n\t\"bufio\"\n\t\"fmt\"\n\t\"net\"\n\t\"net/http\"\n\n\t\"github.com/onsi/ginkgo/v2\"\n\n\thttppkg \"github.com/fatedier/frp/pkg/util/http\"\n\t\"github.com/fatedier/frp/test/e2e/framework\"\n\t\"github.com/fatedier/frp/test/e2e/framework/consts\"\n\t\"github.com/fatedier/frp/test/e2e/mock/server/streamserver\"\n\t\"github.com/fatedier/frp/test/e2e/pkg/request\"\n\t\"github.com/fatedier/frp/test/e2e/pkg/rpc\"\n)\n\nvar _ = ginkgo.Describe(\"[Feature: TCPMUX httpconnect]\", func() {\n\tf := framework.NewDefaultFramework()\n\n\tgetDefaultServerConf := func(httpconnectPort int) string {\n\t\tconf := consts.DefaultServerConfig + `\n\t\ttcpmuxHTTPConnectPort = %d\n\t\t`\n\t\treturn fmt.Sprintf(conf, httpconnectPort)\n\t}\n\tnewServer := func(port int, respContent string) *streamserver.Server {\n\t\treturn streamserver.New(\n\t\t\tstreamserver.TCP,\n\t\t\tstreamserver.WithBindPort(port),\n\t\t\tstreamserver.WithRespContent([]byte(respContent)),\n\t\t)\n\t}\n\n\tproxyURLWithAuth := func(username, password string, port int) string {\n\t\tif username == \"\" {\n\t\t\treturn fmt.Sprintf(\"http://127.0.0.1:%d\", port)\n\t\t}\n\t\treturn fmt.Sprintf(\"http://%s:%s@127.0.0.1:%d\", username, password, port)\n\t}\n\n\tginkgo.It(\"Route by HTTP user\", func() {\n\t\tvhostPort := f.AllocPort()\n\t\tserverConf := getDefaultServerConf(vhostPort)\n\n\t\tfooPort := f.AllocPort()\n\t\tf.RunServer(\"\", newServer(fooPort, \"foo\"))\n\n\t\tbarPort := f.AllocPort()\n\t\tf.RunServer(\"\", newServer(barPort, \"bar\"))\n\n\t\totherPort := f.AllocPort()\n\t\tf.RunServer(\"\", newServer(otherPort, \"other\"))\n\n\t\tclientConf := consts.DefaultClientConfig\n\t\tclientConf += fmt.Sprintf(`\n\t\t\t[[proxies]]\n\t\t\tname = \"foo\"\n\t\t\ttype = \"tcpmux\"\n\t\t\tmultiplexer = \"httpconnect\"\n\t\t\tlocalPort = %d\n\t\t\tcustomDomains = [\"normal.example.com\"]\n\t\t\trouteByHTTPUser = \"user1\"\n\n\t\t\t[[proxies]]\n\t\t\tname = \"bar\"\n\t\t\ttype = \"tcpmux\"\n\t\t\tmultiplexer = \"httpconnect\"\n\t\t\tlocalPort = %d\n\t\t\tcustomDomains = [\"normal.example.com\"]\n\t\t\trouteByHTTPUser = \"user2\"\n\n\t\t\t[[proxies]]\n\t\t\tname = \"catchAll\"\n\t\t\ttype = \"tcpmux\"\n\t\t\tmultiplexer = \"httpconnect\"\n\t\t\tlocalPort = %d\n\t\t\tcustomDomains = [\"normal.example.com\"]\n\t\t\t`, fooPort, barPort, otherPort)\n\n\t\tf.RunProcesses(serverConf, []string{clientConf})\n\n\t\t// user1\n\t\tframework.NewRequestExpect(f).Explain(\"user1\").\n\t\t\tRequestModify(func(r *request.Request) {\n\t\t\t\tr.Addr(\"normal.example.com\").Proxy(proxyURLWithAuth(\"user1\", \"\", vhostPort))\n\t\t\t}).\n\t\t\tExpectResp([]byte(\"foo\")).\n\t\t\tEnsure()\n\n\t\t// user2\n\t\tframework.NewRequestExpect(f).Explain(\"user2\").\n\t\t\tRequestModify(func(r *request.Request) {\n\t\t\t\tr.Addr(\"normal.example.com\").Proxy(proxyURLWithAuth(\"user2\", \"\", vhostPort))\n\t\t\t}).\n\t\t\tExpectResp([]byte(\"bar\")).\n\t\t\tEnsure()\n\n\t\t// other user\n\t\tframework.NewRequestExpect(f).Explain(\"other user\").\n\t\t\tRequestModify(func(r *request.Request) {\n\t\t\t\tr.Addr(\"normal.example.com\").Proxy(proxyURLWithAuth(\"user3\", \"\", vhostPort))\n\t\t\t}).\n\t\t\tExpectResp([]byte(\"other\")).\n\t\t\tEnsure()\n\t})\n\n\tginkgo.It(\"Proxy auth\", func() {\n\t\tvhostPort := f.AllocPort()\n\t\tserverConf := getDefaultServerConf(vhostPort)\n\n\t\tfooPort := f.AllocPort()\n\t\tf.RunServer(\"\", newServer(fooPort, \"foo\"))\n\n\t\tclientConf := consts.DefaultClientConfig\n\t\tclientConf += fmt.Sprintf(`\n\t\t\t[[proxies]]\n\t\t\tname = \"test\"\n\t\t\ttype = \"tcpmux\"\n\t\t\tmultiplexer = \"httpconnect\"\n\t\t\tlocalPort = %d\n\t\t\tcustomDomains = [\"normal.example.com\"]\n\t\t\thttpUser = \"test\"\n\t\t\thttpPassword = \"test\"\n\t\t`, fooPort)\n\n\t\tf.RunProcesses(serverConf, []string{clientConf})\n\n\t\t// not set auth header\n\t\tframework.NewRequestExpect(f).Explain(\"no auth\").\n\t\t\tRequestModify(func(r *request.Request) {\n\t\t\t\tr.Addr(\"normal.example.com\").Proxy(proxyURLWithAuth(\"\", \"\", vhostPort))\n\t\t\t}).\n\t\t\tExpectError(true).\n\t\t\tEnsure()\n\n\t\t// set incorrect auth header\n\t\tframework.NewRequestExpect(f).Explain(\"incorrect auth\").\n\t\t\tRequestModify(func(r *request.Request) {\n\t\t\t\tr.Addr(\"normal.example.com\").Proxy(proxyURLWithAuth(\"test\", \"invalid\", vhostPort))\n\t\t\t}).\n\t\t\tExpectError(true).\n\t\t\tEnsure()\n\n\t\t// set correct auth header\n\t\tframework.NewRequestExpect(f).Explain(\"correct auth\").\n\t\t\tRequestModify(func(r *request.Request) {\n\t\t\t\tr.Addr(\"normal.example.com\").Proxy(proxyURLWithAuth(\"test\", \"test\", vhostPort))\n\t\t\t}).\n\t\t\tExpectResp([]byte(\"foo\")).\n\t\t\tEnsure()\n\t})\n\n\tginkgo.It(\"TCPMux Passthrough\", func() {\n\t\tvhostPort := f.AllocPort()\n\t\tserverConf := getDefaultServerConf(vhostPort)\n\t\tserverConf += `\n\t\ttcpmuxPassthrough = true\n\t\t`\n\n\t\tvar (\n\t\t\trespErr            error\n\t\t\tconnectRequestHost string\n\t\t)\n\t\tnewServer := func(port int) *streamserver.Server {\n\t\t\treturn streamserver.New(\n\t\t\t\tstreamserver.TCP,\n\t\t\t\tstreamserver.WithBindPort(port),\n\t\t\t\tstreamserver.WithCustomHandler(func(conn net.Conn) {\n\t\t\t\t\tdefer conn.Close()\n\n\t\t\t\t\t// read HTTP CONNECT request\n\t\t\t\t\tbufioReader := bufio.NewReader(conn)\n\t\t\t\t\treq, err := http.ReadRequest(bufioReader)\n\t\t\t\t\tif err != nil {\n\t\t\t\t\t\trespErr = err\n\t\t\t\t\t\treturn\n\t\t\t\t\t}\n\t\t\t\t\tconnectRequestHost = req.Host\n\n\t\t\t\t\t// return ok response\n\t\t\t\t\tres := httppkg.OkResponse()\n\t\t\t\t\tif res.Body != nil {\n\t\t\t\t\t\tdefer res.Body.Close()\n\t\t\t\t\t}\n\t\t\t\t\t_ = res.Write(conn)\n\n\t\t\t\t\tbuf, err := rpc.ReadBytes(conn)\n\t\t\t\t\tif err != nil {\n\t\t\t\t\t\trespErr = err\n\t\t\t\t\t\treturn\n\t\t\t\t\t}\n\t\t\t\t\t_, _ = rpc.WriteBytes(conn, buf)\n\t\t\t\t}),\n\t\t\t)\n\t\t}\n\n\t\tlocalPort := f.AllocPort()\n\t\tf.RunServer(\"\", newServer(localPort))\n\n\t\tclientConf := consts.DefaultClientConfig\n\t\tclientConf += fmt.Sprintf(`\n\t\t\t[[proxies]]\n\t\t\tname = \"test\"\n\t\t\ttype = \"tcpmux\"\n\t\t\tmultiplexer = \"httpconnect\"\n\t\t\tlocalPort = %d\n\t\t\tcustomDomains = [\"normal.example.com\"]\n\t\t\t`, localPort)\n\n\t\tf.RunProcesses(serverConf, []string{clientConf})\n\n\t\tframework.NewRequestExpect(f).\n\t\t\tRequestModify(func(r *request.Request) {\n\t\t\t\tr.Addr(\"normal.example.com\").Proxy(proxyURLWithAuth(\"\", \"\", vhostPort)).Body([]byte(\"frp\"))\n\t\t\t}).\n\t\t\tExpectResp([]byte(\"frp\")).\n\t\t\tEnsure()\n\t\tframework.ExpectNoError(respErr)\n\t\tframework.ExpectEqualValues(connectRequestHost, \"normal.example.com\")\n\t})\n})\n"
  },
  {
    "path": "test/e2e/v1/basic/token_source.go",
    "content": "// Copyright 2025 The frp Authors\n//\n// Licensed under the Apache License, Version 2.0 (the \"License\");\n// you may not use this file except in compliance with the License.\n// You may obtain a copy of the License at\n//\n//     http://www.apache.org/licenses/LICENSE-2.0\n//\n// Unless required by applicable law or agreed to in writing, software\n// distributed under the License is distributed on an \"AS IS\" BASIS,\n// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.\n// See the License for the specific language governing permissions and\n// limitations under the License.\n\npackage basic\n\nimport (\n\t\"fmt\"\n\t\"net\"\n\t\"os\"\n\t\"path/filepath\"\n\t\"strconv\"\n\t\"time\"\n\n\t\"github.com/onsi/ginkgo/v2\"\n\n\t\"github.com/fatedier/frp/test/e2e/framework\"\n\t\"github.com/fatedier/frp/test/e2e/framework/consts\"\n\t\"github.com/fatedier/frp/test/e2e/pkg/port\"\n)\n\nvar _ = ginkgo.Describe(\"[Feature: TokenSource]\", func() {\n\tf := framework.NewDefaultFramework()\n\n\tcreateExecTokenScript := func(name string) string {\n\t\tscriptPath := filepath.Join(f.TempDirectory, name)\n\t\tscriptContent := `#!/bin/sh\nprintf '%s\\n' \"$1\"\n`\n\t\terr := os.WriteFile(scriptPath, []byte(scriptContent), 0o600)\n\t\tframework.ExpectNoError(err)\n\t\terr = os.Chmod(scriptPath, 0o700)\n\t\tframework.ExpectNoError(err)\n\t\treturn scriptPath\n\t}\n\n\tginkgo.Describe(\"File-based token loading\", func() {\n\t\tginkgo.It(\"should work with file tokenSource\", func() {\n\t\t\t// Create a temporary token file\n\t\t\ttmpDir := f.TempDirectory\n\t\t\ttokenFile := filepath.Join(tmpDir, \"test_token\")\n\t\t\ttokenContent := \"test-token-123\"\n\n\t\t\terr := os.WriteFile(tokenFile, []byte(tokenContent), 0o600)\n\t\t\tframework.ExpectNoError(err)\n\n\t\t\tserverConf := consts.DefaultServerConfig\n\t\t\tclientConf := consts.DefaultClientConfig\n\n\t\t\tportName := port.GenName(\"TCP\")\n\n\t\t\t// Server config with tokenSource\n\t\t\tserverConf += fmt.Sprintf(`\nauth.tokenSource.type = \"file\"\nauth.tokenSource.file.path = \"%s\"\n`, tokenFile)\n\n\t\t\t// Client config with matching token\n\t\t\tclientConf += fmt.Sprintf(`\nauth.token = \"%s\"\n\n[[proxies]]\nname = \"tcp\"\ntype = \"tcp\"\nlocalPort = {{ .%s }}\nremotePort = {{ .%s }}\n`, tokenContent, framework.TCPEchoServerPort, portName)\n\n\t\t\tf.RunProcesses(serverConf, []string{clientConf})\n\n\t\t\tframework.NewRequestExpect(f).PortName(portName).Ensure()\n\t\t})\n\n\t\tginkgo.It(\"should work with client tokenSource\", func() {\n\t\t\t// Create a temporary token file\n\t\t\ttmpDir := f.TempDirectory\n\t\t\ttokenFile := filepath.Join(tmpDir, \"client_token\")\n\t\t\ttokenContent := \"client-token-456\"\n\n\t\t\terr := os.WriteFile(tokenFile, []byte(tokenContent), 0o600)\n\t\t\tframework.ExpectNoError(err)\n\n\t\t\tserverConf := consts.DefaultServerConfig\n\t\t\tclientConf := consts.DefaultClientConfig\n\n\t\t\tportName := port.GenName(\"TCP\")\n\n\t\t\t// Server config with matching token\n\t\t\tserverConf += fmt.Sprintf(`\nauth.token = \"%s\"\n`, tokenContent)\n\n\t\t\t// Client config with tokenSource\n\t\t\tclientConf += fmt.Sprintf(`\nauth.tokenSource.type = \"file\"\nauth.tokenSource.file.path = \"%s\"\n\n[[proxies]]\nname = \"tcp\"\ntype = \"tcp\"\nlocalPort = {{ .%s }}\nremotePort = {{ .%s }}\n`, tokenFile, framework.TCPEchoServerPort, portName)\n\n\t\t\tf.RunProcesses(serverConf, []string{clientConf})\n\n\t\t\tframework.NewRequestExpect(f).PortName(portName).Ensure()\n\t\t})\n\n\t\tginkgo.It(\"should work with both server and client tokenSource\", func() {\n\t\t\t// Create temporary token files\n\t\t\ttmpDir := f.TempDirectory\n\t\t\tserverTokenFile := filepath.Join(tmpDir, \"server_token\")\n\t\t\tclientTokenFile := filepath.Join(tmpDir, \"client_token\")\n\t\t\ttokenContent := \"shared-token-789\"\n\n\t\t\terr := os.WriteFile(serverTokenFile, []byte(tokenContent), 0o600)\n\t\t\tframework.ExpectNoError(err)\n\n\t\t\terr = os.WriteFile(clientTokenFile, []byte(tokenContent), 0o600)\n\t\t\tframework.ExpectNoError(err)\n\n\t\t\tserverConf := consts.DefaultServerConfig\n\t\t\tclientConf := consts.DefaultClientConfig\n\n\t\t\tportName := port.GenName(\"TCP\")\n\n\t\t\t// Server config with tokenSource\n\t\t\tserverConf += fmt.Sprintf(`\nauth.tokenSource.type = \"file\"\nauth.tokenSource.file.path = \"%s\"\n`, serverTokenFile)\n\n\t\t\t// Client config with tokenSource\n\t\t\tclientConf += fmt.Sprintf(`\nauth.tokenSource.type = \"file\"\nauth.tokenSource.file.path = \"%s\"\n\n[[proxies]]\nname = \"tcp\"\ntype = \"tcp\"\nlocalPort = {{ .%s }}\nremotePort = {{ .%s }}\n`, clientTokenFile, framework.TCPEchoServerPort, portName)\n\n\t\t\tf.RunProcesses(serverConf, []string{clientConf})\n\n\t\t\tframework.NewRequestExpect(f).PortName(portName).Ensure()\n\t\t})\n\n\t\tginkgo.It(\"should fail with mismatched tokens\", func() {\n\t\t\t// Create temporary token files with different content\n\t\t\ttmpDir := f.TempDirectory\n\t\t\tserverTokenFile := filepath.Join(tmpDir, \"server_token\")\n\t\t\tclientTokenFile := filepath.Join(tmpDir, \"client_token\")\n\n\t\t\terr := os.WriteFile(serverTokenFile, []byte(\"server-token\"), 0o600)\n\t\t\tframework.ExpectNoError(err)\n\n\t\t\terr = os.WriteFile(clientTokenFile, []byte(\"client-token\"), 0o600)\n\t\t\tframework.ExpectNoError(err)\n\n\t\t\tserverConf := consts.DefaultServerConfig\n\t\t\tclientConf := consts.DefaultClientConfig\n\n\t\t\tportName := port.GenName(\"TCP\")\n\n\t\t\t// Server config with tokenSource\n\t\t\tserverConf += fmt.Sprintf(`\nauth.tokenSource.type = \"file\"\nauth.tokenSource.file.path = \"%s\"\n`, serverTokenFile)\n\n\t\t\t// Client config with different tokenSource\n\t\t\tclientConf += fmt.Sprintf(`\nauth.tokenSource.type = \"file\"\nauth.tokenSource.file.path = \"%s\"\n\n[[proxies]]\nname = \"tcp\"\ntype = \"tcp\"\nlocalPort = {{ .%s }}\nremotePort = {{ .%s }}\n`, clientTokenFile, framework.TCPEchoServerPort, portName)\n\n\t\t\tf.RunProcesses(serverConf, []string{clientConf})\n\n\t\t\t// This should fail due to token mismatch - the client should not be able to connect\n\t\t\t// We expect the request to fail because the proxy tunnel is not established\n\t\t\tframework.NewRequestExpect(f).PortName(portName).ExpectError(true).Ensure()\n\t\t})\n\n\t\tginkgo.It(\"should fail with non-existent token file\", func() {\n\t\t\ttmpDir := f.TempDirectory\n\t\t\tnonExistentFile := filepath.Join(tmpDir, \"non_existent_token\")\n\n\t\t\tserverPort := f.AllocPort()\n\t\t\tserverConf := fmt.Sprintf(`\nbindAddr = \"0.0.0.0\"\nbindPort = %d\nauth.tokenSource.type = \"file\"\nauth.tokenSource.file.path = \"%s\"\n`, serverPort, nonExistentFile)\n\n\t\t\tserverConfigPath := f.GenerateConfigFile(serverConf)\n\n\t\t\t_, _, _ = f.RunFrps(\"-c\", serverConfigPath)\n\n\t\t\t// Server should have failed to start, so the port should not be listening.\n\t\t\tconn, err := net.DialTimeout(\"tcp\", net.JoinHostPort(\"127.0.0.1\", strconv.Itoa(serverPort)), 1*time.Second)\n\t\t\tif err == nil {\n\t\t\t\tconn.Close()\n\t\t\t}\n\t\t\tframework.ExpectTrue(err != nil, \"server should not be listening on port %d\", serverPort)\n\t\t})\n\t})\n\n\tginkgo.Describe(\"Exec-based token loading\", func() {\n\t\tginkgo.It(\"should work with server tokenSource\", func() {\n\t\t\texecValue := \"exec-server-value\"\n\t\t\tscriptPath := createExecTokenScript(\"server_token_exec.sh\")\n\n\t\t\tserverPort := f.AllocPort()\n\t\t\tremotePort := f.AllocPort()\n\n\t\t\tserverConf := fmt.Sprintf(`\nbindAddr = \"0.0.0.0\"\nbindPort = %d\n\nauth.tokenSource.type = \"exec\"\nauth.tokenSource.exec.command = %q\nauth.tokenSource.exec.args = [%q]\n`, serverPort, scriptPath, execValue)\n\n\t\t\tclientConf := fmt.Sprintf(`\nserverAddr = \"127.0.0.1\"\nserverPort = %d\nloginFailExit = false\nauth.token = %q\n\n[[proxies]]\nname = \"tcp\"\ntype = \"tcp\"\nlocalPort = %d\nremotePort = %d\n`, serverPort, execValue, f.PortByName(framework.TCPEchoServerPort), remotePort)\n\n\t\t\tserverConfigPath := f.GenerateConfigFile(serverConf)\n\t\t\tclientConfigPath := f.GenerateConfigFile(clientConf)\n\n\t\t\t_, _, err := f.RunFrps(\"-c\", serverConfigPath, \"--allow-unsafe=TokenSourceExec\")\n\t\t\tframework.ExpectNoError(err)\n\n\t\t\t_, _, err = f.RunFrpc(\"-c\", clientConfigPath, \"--allow-unsafe=TokenSourceExec\")\n\t\t\tframework.ExpectNoError(err)\n\n\t\t\tframework.NewRequestExpect(f).Port(remotePort).Ensure()\n\t\t})\n\n\t\tginkgo.It(\"should work with client tokenSource\", func() {\n\t\t\texecValue := \"exec-client-value\"\n\t\t\tscriptPath := createExecTokenScript(\"client_token_exec.sh\")\n\n\t\t\tserverPort := f.AllocPort()\n\t\t\tremotePort := f.AllocPort()\n\n\t\t\tserverConf := fmt.Sprintf(`\nbindAddr = \"0.0.0.0\"\nbindPort = %d\n\nauth.token = %q\n`, serverPort, execValue)\n\n\t\t\tclientConf := fmt.Sprintf(`\nserverAddr = \"127.0.0.1\"\nserverPort = %d\nloginFailExit = false\n\nauth.tokenSource.type = \"exec\"\nauth.tokenSource.exec.command = %q\nauth.tokenSource.exec.args = [%q]\n\n[[proxies]]\nname = \"tcp\"\ntype = \"tcp\"\nlocalPort = %d\nremotePort = %d\n`, serverPort, scriptPath, execValue, f.PortByName(framework.TCPEchoServerPort), remotePort)\n\n\t\t\tserverConfigPath := f.GenerateConfigFile(serverConf)\n\t\t\tclientConfigPath := f.GenerateConfigFile(clientConf)\n\n\t\t\t_, _, err := f.RunFrps(\"-c\", serverConfigPath, \"--allow-unsafe=TokenSourceExec\")\n\t\t\tframework.ExpectNoError(err)\n\n\t\t\t_, _, err = f.RunFrpc(\"-c\", clientConfigPath, \"--allow-unsafe=TokenSourceExec\")\n\t\t\tframework.ExpectNoError(err)\n\n\t\t\tframework.NewRequestExpect(f).Port(remotePort).Ensure()\n\t\t})\n\n\t\tginkgo.It(\"should work with both server and client tokenSource\", func() {\n\t\t\texecValue := \"exec-shared-value\"\n\t\t\tscriptPath := createExecTokenScript(\"shared_token_exec.sh\")\n\n\t\t\tserverPort := f.AllocPort()\n\t\t\tremotePort := f.AllocPort()\n\n\t\t\tserverConf := fmt.Sprintf(`\nbindAddr = \"0.0.0.0\"\nbindPort = %d\n\nauth.tokenSource.type = \"exec\"\nauth.tokenSource.exec.command = %q\nauth.tokenSource.exec.args = [%q]\n`, serverPort, scriptPath, execValue)\n\n\t\t\tclientConf := fmt.Sprintf(`\nserverAddr = \"127.0.0.1\"\nserverPort = %d\nloginFailExit = false\n\nauth.tokenSource.type = \"exec\"\nauth.tokenSource.exec.command = %q\nauth.tokenSource.exec.args = [%q]\n\n[[proxies]]\nname = \"tcp\"\ntype = \"tcp\"\nlocalPort = %d\nremotePort = %d\n`, serverPort, scriptPath, execValue, f.PortByName(framework.TCPEchoServerPort), remotePort)\n\n\t\t\tserverConfigPath := f.GenerateConfigFile(serverConf)\n\t\t\tclientConfigPath := f.GenerateConfigFile(clientConf)\n\n\t\t\t_, _, err := f.RunFrps(\"-c\", serverConfigPath, \"--allow-unsafe=TokenSourceExec\")\n\t\t\tframework.ExpectNoError(err)\n\n\t\t\t_, _, err = f.RunFrpc(\"-c\", clientConfigPath, \"--allow-unsafe=TokenSourceExec\")\n\t\t\tframework.ExpectNoError(err)\n\n\t\t\tframework.NewRequestExpect(f).Port(remotePort).Ensure()\n\t\t})\n\n\t\tginkgo.It(\"should fail validation without allow-unsafe\", func() {\n\t\t\texecValue := \"exec-unsafe-value\"\n\t\t\tscriptPath := createExecTokenScript(\"unsafe_token_exec.sh\")\n\n\t\t\tserverPort := f.AllocPort()\n\t\t\tserverConf := fmt.Sprintf(`\nbindAddr = \"0.0.0.0\"\nbindPort = %d\n\nauth.tokenSource.type = \"exec\"\nauth.tokenSource.exec.command = %q\nauth.tokenSource.exec.args = [%q]\n`, serverPort, scriptPath, execValue)\n\n\t\t\tserverConfigPath := f.GenerateConfigFile(serverConf)\n\n\t\t\t_, output, err := f.RunFrps(\"verify\", \"-c\", serverConfigPath)\n\t\t\tframework.ExpectNoError(err)\n\t\t\tframework.ExpectContainSubstring(output, \"unsafe feature \\\"TokenSourceExec\\\" is not enabled\")\n\t\t})\n\t})\n})\n"
  },
  {
    "path": "test/e2e/v1/basic/xtcp.go",
    "content": "package basic\n\nimport (\n\t\"fmt\"\n\t\"time\"\n\n\t\"github.com/onsi/ginkgo/v2\"\n\n\t\"github.com/fatedier/frp/test/e2e/framework\"\n\t\"github.com/fatedier/frp/test/e2e/framework/consts\"\n\t\"github.com/fatedier/frp/test/e2e/pkg/port\"\n\t\"github.com/fatedier/frp/test/e2e/pkg/request\"\n)\n\nvar _ = ginkgo.Describe(\"[Feature: XTCP]\", func() {\n\tf := framework.NewDefaultFramework()\n\n\tginkgo.It(\"Fallback To STCP\", func() {\n\t\tserverConf := consts.DefaultServerConfig\n\t\tclientConf := consts.DefaultClientConfig\n\n\t\tbindPortName := port.GenName(\"XTCP\")\n\t\tclientConf += fmt.Sprintf(`\n\t\t\t[[proxies]]\n\t\t\tname = \"foo\"\n\t\t\ttype = \"stcp\"\n\t\t\tlocalPort = {{ .%s }}\n\n\t\t\t[[visitors]]\n\t\t\tname = \"foo-visitor\"\n\t\t\ttype = \"stcp\"\n\t\t\tserverName = \"foo\"\n\t\t\tbindPort = -1\n\n\t\t\t[[visitors]]\n\t\t\tname = \"bar-visitor\"\n\t\t\ttype = \"xtcp\"\n\t\t\tserverName = \"bar\"\n\t\t\tbindPort = {{ .%s }}\n\t\t\tkeepTunnelOpen = true\n\t\t\tfallbackTo = \"foo-visitor\"\n\t\t\tfallbackTimeoutMs = 200\n\t\t\t`, framework.TCPEchoServerPort, bindPortName)\n\n\t\tf.RunProcesses(serverConf, []string{clientConf})\n\t\tframework.NewRequestExpect(f).\n\t\t\tRequestModify(func(r *request.Request) {\n\t\t\t\tr.Timeout(time.Second)\n\t\t\t}).\n\t\t\tPortName(bindPortName).\n\t\t\tEnsure()\n\t})\n})\n"
  },
  {
    "path": "test/e2e/v1/features/bandwidth_limit.go",
    "content": "package features\n\nimport (\n\t\"fmt\"\n\t\"strings\"\n\t\"time\"\n\n\t\"github.com/onsi/ginkgo/v2\"\n\n\tplugin \"github.com/fatedier/frp/pkg/plugin/server\"\n\t\"github.com/fatedier/frp/test/e2e/framework\"\n\t\"github.com/fatedier/frp/test/e2e/framework/consts\"\n\t\"github.com/fatedier/frp/test/e2e/mock/server/streamserver\"\n\tpluginpkg \"github.com/fatedier/frp/test/e2e/pkg/plugin\"\n\t\"github.com/fatedier/frp/test/e2e/pkg/request\"\n)\n\nvar _ = ginkgo.Describe(\"[Feature: Bandwidth Limit]\", func() {\n\tf := framework.NewDefaultFramework()\n\n\tginkgo.It(\"Proxy Bandwidth Limit by Client\", func() {\n\t\tserverConf := consts.DefaultServerConfig\n\t\tclientConf := consts.DefaultClientConfig\n\n\t\tlocalPort := f.AllocPort()\n\t\tlocalServer := streamserver.New(streamserver.TCP, streamserver.WithBindPort(localPort))\n\t\tf.RunServer(\"\", localServer)\n\n\t\tremotePort := f.AllocPort()\n\t\tclientConf += fmt.Sprintf(`\n\t\t\t[[proxies]]\n\t\t\tname = \"tcp\"\n\t\t\ttype = \"tcp\"\n\t\t\tlocalPort = %d\n\t\t\tremotePort = %d\n\t\t\ttransport.bandwidthLimit = \"10KB\"\n\t\t\t`, localPort, remotePort)\n\n\t\tf.RunProcesses(serverConf, []string{clientConf})\n\n\t\tcontent := strings.Repeat(\"a\", 50*1024) // 5KB\n\t\tstart := time.Now()\n\t\tframework.NewRequestExpect(f).Port(remotePort).RequestModify(func(r *request.Request) {\n\t\t\tr.Body([]byte(content)).Timeout(30 * time.Second)\n\t\t}).ExpectResp([]byte(content)).Ensure()\n\n\t\tduration := time.Since(start)\n\t\tframework.Logf(\"request duration: %s\", duration.String())\n\n\t\tframework.ExpectTrue(duration.Seconds() > 8, \"100Kb with 10KB limit, want > 8 seconds, but got %s\", duration.String())\n\t})\n\n\tginkgo.It(\"Proxy Bandwidth Limit by Server\", func() {\n\t\t// new test plugin server\n\t\tnewFunc := func() *plugin.Request {\n\t\t\tvar r plugin.Request\n\t\t\tr.Content = &plugin.NewProxyContent{}\n\t\t\treturn &r\n\t\t}\n\t\tpluginPort := f.AllocPort()\n\t\thandler := func(req *plugin.Request) *plugin.Response {\n\t\t\tvar ret plugin.Response\n\t\t\tcontent := req.Content.(*plugin.NewProxyContent)\n\t\t\tcontent.BandwidthLimit = \"10KB\"\n\t\t\tcontent.BandwidthLimitMode = \"server\"\n\t\t\tret.Content = content\n\t\t\treturn &ret\n\t\t}\n\t\tpluginServer := pluginpkg.NewHTTPPluginServer(pluginPort, newFunc, handler, nil)\n\n\t\tf.RunServer(\"\", pluginServer)\n\n\t\tserverConf := consts.DefaultServerConfig + fmt.Sprintf(`\n\t\t[[httpPlugins]]\n\t\tname = \"test\"\n\t\taddr = \"127.0.0.1:%d\"\n\t\tpath = \"/handler\"\n\t\tops = [\"NewProxy\"]\n\t\t`, pluginPort)\n\t\tclientConf := consts.DefaultClientConfig\n\n\t\tlocalPort := f.AllocPort()\n\t\tlocalServer := streamserver.New(streamserver.TCP, streamserver.WithBindPort(localPort))\n\t\tf.RunServer(\"\", localServer)\n\n\t\tremotePort := f.AllocPort()\n\t\tclientConf += fmt.Sprintf(`\n\t\t\t[[proxies]]\n\t\t\tname = \"tcp\"\n\t\t\ttype = \"tcp\"\n\t\t\tlocalPort = %d\n\t\t\tremotePort = %d\n\t\t\t`, localPort, remotePort)\n\n\t\tf.RunProcesses(serverConf, []string{clientConf})\n\n\t\tcontent := strings.Repeat(\"a\", 50*1024) // 5KB\n\t\tstart := time.Now()\n\t\tframework.NewRequestExpect(f).Port(remotePort).RequestModify(func(r *request.Request) {\n\t\t\tr.Body([]byte(content)).Timeout(30 * time.Second)\n\t\t}).ExpectResp([]byte(content)).Ensure()\n\n\t\tduration := time.Since(start)\n\t\tframework.Logf(\"request duration: %s\", duration.String())\n\n\t\tframework.ExpectTrue(duration.Seconds() > 8, \"100Kb with 10KB limit, want > 8 seconds, but got %s\", duration.String())\n\t})\n})\n"
  },
  {
    "path": "test/e2e/v1/features/chaos.go",
    "content": "package features\n\nimport (\n\t\"fmt\"\n\t\"time\"\n\n\t\"github.com/onsi/ginkgo/v2\"\n\n\t\"github.com/fatedier/frp/test/e2e/framework\"\n)\n\nvar _ = ginkgo.Describe(\"[Feature: Chaos]\", func() {\n\tf := framework.NewDefaultFramework()\n\n\tginkgo.It(\"reconnect after frps restart\", func() {\n\t\tserverPort := f.AllocPort()\n\t\tserverConfigPath := f.GenerateConfigFile(fmt.Sprintf(`\n\t\tbindAddr = \"0.0.0.0\"\n\t\tbindPort = %d\n\t\t`, serverPort))\n\n\t\tremotePort := f.AllocPort()\n\t\tclientConfigPath := f.GenerateConfigFile(fmt.Sprintf(`\n\t\tserverPort = %d\n\t\tlog.level = \"trace\"\n\n\t\t[[proxies]]\n\t\tname = \"tcp\"\n\t\ttype = \"tcp\"\n\t\tlocalPort = %d\n\t\tremotePort = %d\n\t\t`, serverPort, f.PortByName(framework.TCPEchoServerPort), remotePort))\n\n\t\t// 1. start frps and frpc, expect request success\n\t\tps, _, err := f.RunFrps(\"-c\", serverConfigPath)\n\t\tframework.ExpectNoError(err)\n\n\t\tpc, _, err := f.RunFrpc(\"-c\", clientConfigPath)\n\t\tframework.ExpectNoError(err)\n\t\tframework.NewRequestExpect(f).Port(remotePort).Ensure()\n\n\t\t// 2. stop frps, expect request failed\n\t\t_ = ps.Stop()\n\t\tframework.NewRequestExpect(f).Port(remotePort).ExpectError(true).Ensure()\n\n\t\t// 3. restart frps, expect request success\n\t\tsuccessCount := pc.CountOutput(\"[tcp] start proxy success\")\n\t\t_, _, err = f.RunFrps(\"-c\", serverConfigPath)\n\t\tframework.ExpectNoError(err)\n\t\tframework.ExpectNoError(pc.WaitForOutput(\"[tcp] start proxy success\", successCount+1, 5*time.Second))\n\t\tframework.NewRequestExpect(f).Port(remotePort).Ensure()\n\n\t\t// 4. stop frpc, expect request failed\n\t\t_ = pc.Stop()\n\t\tframework.ExpectNoError(framework.WaitForTCPUnreachable(fmt.Sprintf(\"127.0.0.1:%d\", remotePort), 100*time.Millisecond, 5*time.Second))\n\t\tframework.NewRequestExpect(f).Port(remotePort).ExpectError(true).Ensure()\n\n\t\t// 5. restart frpc, expect request success\n\t\tnewPc, _, err := f.RunFrpc(\"-c\", clientConfigPath)\n\t\tframework.ExpectNoError(err)\n\t\tframework.ExpectNoError(newPc.WaitForOutput(\"[tcp] start proxy success\", 1, 5*time.Second))\n\t\tframework.NewRequestExpect(f).Port(remotePort).Ensure()\n\t})\n})\n"
  },
  {
    "path": "test/e2e/v1/features/group.go",
    "content": "package features\n\nimport (\n\t\"crypto/tls\"\n\t\"fmt\"\n\t\"strconv\"\n\t\"sync\"\n\t\"time\"\n\n\t\"github.com/onsi/ginkgo/v2\"\n\n\t\"github.com/fatedier/frp/pkg/transport\"\n\t\"github.com/fatedier/frp/test/e2e/framework\"\n\t\"github.com/fatedier/frp/test/e2e/framework/consts\"\n\t\"github.com/fatedier/frp/test/e2e/mock/server/httpserver\"\n\t\"github.com/fatedier/frp/test/e2e/mock/server/streamserver\"\n\t\"github.com/fatedier/frp/test/e2e/pkg/request\"\n)\n\nvar _ = ginkgo.Describe(\"[Feature: Group]\", func() {\n\tf := framework.NewDefaultFramework()\n\n\tnewHTTPServer := func(port int, respContent string) *httpserver.Server {\n\t\treturn httpserver.New(\n\t\t\thttpserver.WithBindPort(port),\n\t\t\thttpserver.WithHandler(framework.SpecifiedHTTPBodyHandler([]byte(respContent))),\n\t\t)\n\t}\n\n\tvalidateFooBarResponse := func(resp *request.Response) bool {\n\t\tif string(resp.Content) == \"foo\" || string(resp.Content) == \"bar\" {\n\t\t\treturn true\n\t\t}\n\t\treturn false\n\t}\n\n\tdoFooBarHTTPRequest := func(vhostPort int, host string) []string {\n\t\tresults := []string{}\n\t\tvar wait sync.WaitGroup\n\t\tvar mu sync.Mutex\n\t\texpectFn := func() {\n\t\t\tframework.NewRequestExpect(f).Port(vhostPort).\n\t\t\t\tRequestModify(func(r *request.Request) {\n\t\t\t\t\tr.HTTP().HTTPHost(host)\n\t\t\t\t}).\n\t\t\t\tEnsure(validateFooBarResponse, func(resp *request.Response) bool {\n\t\t\t\t\tmu.Lock()\n\t\t\t\t\tdefer mu.Unlock()\n\t\t\t\t\tresults = append(results, string(resp.Content))\n\t\t\t\t\treturn true\n\t\t\t\t})\n\t\t}\n\t\tfor range 10 {\n\t\t\twait.Go(func() {\n\t\t\t\texpectFn()\n\t\t\t})\n\t\t}\n\n\t\twait.Wait()\n\t\treturn results\n\t}\n\n\tginkgo.Describe(\"Load Balancing\", func() {\n\t\tginkgo.It(\"TCP\", func() {\n\t\t\tserverConf := consts.DefaultServerConfig\n\t\t\tclientConf := consts.DefaultClientConfig\n\n\t\t\tfooPort := f.AllocPort()\n\t\t\tfooServer := streamserver.New(streamserver.TCP, streamserver.WithBindPort(fooPort), streamserver.WithRespContent([]byte(\"foo\")))\n\t\t\tf.RunServer(\"\", fooServer)\n\n\t\t\tbarPort := f.AllocPort()\n\t\t\tbarServer := streamserver.New(streamserver.TCP, streamserver.WithBindPort(barPort), streamserver.WithRespContent([]byte(\"bar\")))\n\t\t\tf.RunServer(\"\", barServer)\n\n\t\t\tremotePort := f.AllocPort()\n\t\t\tclientConf += fmt.Sprintf(`\n\t\t\t[[proxies]]\n\t\t\tname = \"foo\"\n\t\t\ttype = \"tcp\"\n\t\t\tlocalPort = %d\n\t\t\tremotePort = %d\n\t\t\tloadBalancer.group = \"test\"\n\t\t\tloadBalancer.groupKey = \"123\"\n\n\t\t\t[[proxies]]\n\t\t\tname = \"bar\"\n\t\t\ttype = \"tcp\"\n\t\t\tlocalPort = %d\n\t\t\tremotePort = %d\n\t\t\tloadBalancer.group = \"test\"\n\t\t\tloadBalancer.groupKey = \"123\"\n\t\t\t`, fooPort, remotePort, barPort, remotePort)\n\n\t\t\tf.RunProcesses(serverConf, []string{clientConf})\n\n\t\t\tfooCount := 0\n\t\t\tbarCount := 0\n\t\t\tfor i := range 10 {\n\t\t\t\tframework.NewRequestExpect(f).Explain(\"times \" + strconv.Itoa(i)).Port(remotePort).Ensure(func(resp *request.Response) bool {\n\t\t\t\t\tswitch string(resp.Content) {\n\t\t\t\t\tcase \"foo\":\n\t\t\t\t\t\tfooCount++\n\t\t\t\t\tcase \"bar\":\n\t\t\t\t\t\tbarCount++\n\t\t\t\t\tdefault:\n\t\t\t\t\t\treturn false\n\t\t\t\t\t}\n\t\t\t\t\treturn true\n\t\t\t\t})\n\t\t\t}\n\n\t\t\tframework.ExpectTrue(fooCount > 1 && barCount > 1, \"fooCount: %d, barCount: %d\", fooCount, barCount)\n\t\t})\n\n\t\tginkgo.It(\"HTTPS\", func() {\n\t\t\tvhostHTTPSPort := f.AllocPort()\n\t\t\tserverConf := consts.DefaultServerConfig + fmt.Sprintf(`\n\t\t\tvhostHTTPSPort = %d\n\t\t\t`, vhostHTTPSPort)\n\t\t\tclientConf := consts.DefaultClientConfig\n\n\t\t\ttlsConfig, err := transport.NewServerTLSConfig(\"\", \"\", \"\")\n\t\t\tframework.ExpectNoError(err)\n\n\t\t\tfooPort := f.AllocPort()\n\t\t\tfooServer := httpserver.New(\n\t\t\t\thttpserver.WithBindPort(fooPort),\n\t\t\t\thttpserver.WithHandler(framework.SpecifiedHTTPBodyHandler([]byte(\"foo\"))),\n\t\t\t\thttpserver.WithTLSConfig(tlsConfig),\n\t\t\t)\n\t\t\tf.RunServer(\"\", fooServer)\n\n\t\t\tbarPort := f.AllocPort()\n\t\t\tbarServer := httpserver.New(\n\t\t\t\thttpserver.WithBindPort(barPort),\n\t\t\t\thttpserver.WithHandler(framework.SpecifiedHTTPBodyHandler([]byte(\"bar\"))),\n\t\t\t\thttpserver.WithTLSConfig(tlsConfig),\n\t\t\t)\n\t\t\tf.RunServer(\"\", barServer)\n\n\t\t\tclientConf += fmt.Sprintf(`\n\t\t\t[[proxies]]\n\t\t\tname = \"foo\"\n\t\t\ttype = \"https\"\n\t\t\tlocalPort = %d\n\t\t\tcustomDomains = [\"example.com\"]\n\t\t\tloadBalancer.group = \"test\"\n\t\t\tloadBalancer.groupKey = \"123\"\n\n\t\t\t[[proxies]]\n\t\t\tname = \"bar\"\n\t\t\ttype = \"https\"\n\t\t\tlocalPort = %d\n\t\t\tcustomDomains = [\"example.com\"]\n\t\t\tloadBalancer.group = \"test\"\n\t\t\tloadBalancer.groupKey = \"123\"\n\t\t\t`, fooPort, barPort)\n\n\t\t\tf.RunProcesses(serverConf, []string{clientConf})\n\n\t\t\tfooCount := 0\n\t\t\tbarCount := 0\n\t\t\tfor i := range 10 {\n\t\t\t\tframework.NewRequestExpect(f).\n\t\t\t\t\tExplain(\"times \" + strconv.Itoa(i)).\n\t\t\t\t\tPort(vhostHTTPSPort).\n\t\t\t\t\tRequestModify(func(r *request.Request) {\n\t\t\t\t\t\tr.HTTPS().HTTPHost(\"example.com\").TLSConfig(&tls.Config{\n\t\t\t\t\t\t\tServerName:         \"example.com\",\n\t\t\t\t\t\t\tInsecureSkipVerify: true,\n\t\t\t\t\t\t})\n\t\t\t\t\t}).\n\t\t\t\t\tEnsure(func(resp *request.Response) bool {\n\t\t\t\t\t\tswitch string(resp.Content) {\n\t\t\t\t\t\tcase \"foo\":\n\t\t\t\t\t\t\tfooCount++\n\t\t\t\t\t\tcase \"bar\":\n\t\t\t\t\t\t\tbarCount++\n\t\t\t\t\t\tdefault:\n\t\t\t\t\t\t\treturn false\n\t\t\t\t\t\t}\n\t\t\t\t\t\treturn true\n\t\t\t\t\t})\n\t\t\t}\n\n\t\t\tframework.ExpectTrue(fooCount > 1 && barCount > 1, \"fooCount: %d, barCount: %d\", fooCount, barCount)\n\t\t})\n\n\t\tginkgo.It(\"TCPMux httpconnect\", func() {\n\t\t\tvhostPort := f.AllocPort()\n\t\t\tserverConf := consts.DefaultServerConfig + fmt.Sprintf(`\n\t\t\ttcpmuxHTTPConnectPort = %d\n\t\t\t`, vhostPort)\n\t\t\tclientConf := consts.DefaultClientConfig\n\n\t\t\tfooPort := f.AllocPort()\n\t\t\tfooServer := streamserver.New(streamserver.TCP, streamserver.WithBindPort(fooPort), streamserver.WithRespContent([]byte(\"foo\")))\n\t\t\tf.RunServer(\"\", fooServer)\n\n\t\t\tbarPort := f.AllocPort()\n\t\t\tbarServer := streamserver.New(streamserver.TCP, streamserver.WithBindPort(barPort), streamserver.WithRespContent([]byte(\"bar\")))\n\t\t\tf.RunServer(\"\", barServer)\n\n\t\t\tclientConf += fmt.Sprintf(`\n\t\t\t[[proxies]]\n\t\t\tname = \"foo\"\n\t\t\ttype = \"tcpmux\"\n\t\t\tmultiplexer = \"httpconnect\"\n\t\t\tlocalPort = %d\n\t\t\tcustomDomains = [\"tcpmux-group.example.com\"]\n\t\t\tloadBalancer.group = \"test\"\n\t\t\tloadBalancer.groupKey = \"123\"\n\n\t\t\t[[proxies]]\n\t\t\tname = \"bar\"\n\t\t\ttype = \"tcpmux\"\n\t\t\tmultiplexer = \"httpconnect\"\n\t\t\tlocalPort = %d\n\t\t\tcustomDomains = [\"tcpmux-group.example.com\"]\n\t\t\tloadBalancer.group = \"test\"\n\t\t\tloadBalancer.groupKey = \"123\"\n\t\t\t`, fooPort, barPort)\n\n\t\t\tf.RunProcesses(serverConf, []string{clientConf})\n\n\t\t\tproxyURL := fmt.Sprintf(\"http://127.0.0.1:%d\", vhostPort)\n\t\t\tfooCount := 0\n\t\t\tbarCount := 0\n\t\t\tfor i := range 10 {\n\t\t\t\tframework.NewRequestExpect(f).\n\t\t\t\t\tExplain(\"times \" + strconv.Itoa(i)).\n\t\t\t\t\tRequestModify(func(r *request.Request) {\n\t\t\t\t\t\tr.Addr(\"tcpmux-group.example.com\").Proxy(proxyURL)\n\t\t\t\t\t}).\n\t\t\t\t\tEnsure(func(resp *request.Response) bool {\n\t\t\t\t\t\tswitch string(resp.Content) {\n\t\t\t\t\t\tcase \"foo\":\n\t\t\t\t\t\t\tfooCount++\n\t\t\t\t\t\tcase \"bar\":\n\t\t\t\t\t\t\tbarCount++\n\t\t\t\t\t\tdefault:\n\t\t\t\t\t\t\treturn false\n\t\t\t\t\t\t}\n\t\t\t\t\t\treturn true\n\t\t\t\t\t})\n\t\t\t}\n\n\t\t\tframework.ExpectTrue(fooCount > 1 && barCount > 1, \"fooCount: %d, barCount: %d\", fooCount, barCount)\n\t\t})\n\t})\n\n\tginkgo.Describe(\"Health Check\", func() {\n\t\tginkgo.It(\"TCP\", func() {\n\t\t\tserverConf := consts.DefaultServerConfig\n\t\t\tclientConf := consts.DefaultClientConfig\n\n\t\t\tfooPort := f.AllocPort()\n\t\t\tfooServer := streamserver.New(streamserver.TCP, streamserver.WithBindPort(fooPort), streamserver.WithRespContent([]byte(\"foo\")))\n\t\t\tf.RunServer(\"\", fooServer)\n\n\t\t\tbarPort := f.AllocPort()\n\t\t\tbarServer := streamserver.New(streamserver.TCP, streamserver.WithBindPort(barPort), streamserver.WithRespContent([]byte(\"bar\")))\n\t\t\tf.RunServer(\"\", barServer)\n\n\t\t\tremotePort := f.AllocPort()\n\t\t\tclientConf += fmt.Sprintf(`\n\t\t\t[[proxies]]\n\t\t\tname = \"foo\"\n\t\t\ttype = \"tcp\"\n\t\t\tlocalPort = %d\n\t\t\tremotePort = %d\n\t\t\tloadBalancer.group = \"test\"\n\t\t\tloadBalancer.groupKey = \"123\"\n\t\t\thealthCheck.type = \"tcp\"\n\t\t\thealthCheck.intervalSeconds = 1\n\n\t\t\t[[proxies]]\n\t\t\tname = \"bar\"\n\t\t\ttype = \"tcp\"\n\t\t\tlocalPort = %d\n\t\t\tremotePort = %d\n\t\t\tloadBalancer.group = \"test\"\n\t\t\tloadBalancer.groupKey = \"123\"\n\t\t\thealthCheck.type = \"tcp\"\n\t\t\thealthCheck.intervalSeconds = 1\n\t\t\t`, fooPort, remotePort, barPort, remotePort)\n\n\t\t\t_, clientProcesses := f.RunProcesses(serverConf, []string{clientConf})\n\n\t\t\t// check foo and bar is ok\n\t\t\tresults := []string{}\n\t\t\tfor range 10 {\n\t\t\t\tframework.NewRequestExpect(f).Port(remotePort).Ensure(validateFooBarResponse, func(resp *request.Response) bool {\n\t\t\t\t\tresults = append(results, string(resp.Content))\n\t\t\t\t\treturn true\n\t\t\t\t})\n\t\t\t}\n\t\t\tframework.ExpectContainElements(results, []string{\"foo\", \"bar\"})\n\n\t\t\t// close bar server, check foo is ok\n\t\t\tfailedCount := clientProcesses[0].CountOutput(\"[bar] health check failed\")\n\t\t\tbarServer.Close()\n\t\t\tframework.ExpectNoError(clientProcesses[0].WaitForOutput(\"[bar] health check failed\", failedCount+1, 5*time.Second))\n\t\t\tfor range 10 {\n\t\t\t\tframework.NewRequestExpect(f).Port(remotePort).ExpectResp([]byte(\"foo\")).Ensure()\n\t\t\t}\n\n\t\t\t// resume bar server, check foo and bar is ok\n\t\t\tsuccessCount := clientProcesses[0].CountOutput(\"[bar] health check success\")\n\t\t\tf.RunServer(\"\", barServer)\n\t\t\tframework.ExpectNoError(clientProcesses[0].WaitForOutput(\"[bar] health check success\", successCount+1, 5*time.Second))\n\t\t\tresults = []string{}\n\t\t\tfor range 10 {\n\t\t\t\tframework.NewRequestExpect(f).Port(remotePort).Ensure(validateFooBarResponse, func(resp *request.Response) bool {\n\t\t\t\t\tresults = append(results, string(resp.Content))\n\t\t\t\t\treturn true\n\t\t\t\t})\n\t\t\t}\n\t\t\tframework.ExpectContainElements(results, []string{\"foo\", \"bar\"})\n\t\t})\n\n\t\tginkgo.It(\"HTTP\", func() {\n\t\t\tvhostPort := f.AllocPort()\n\t\t\tserverConf := consts.DefaultServerConfig + fmt.Sprintf(`\n\t\t\tvhostHTTPPort = %d\n\t\t\t`, vhostPort)\n\t\t\tclientConf := consts.DefaultClientConfig\n\n\t\t\tfooPort := f.AllocPort()\n\t\t\tfooServer := newHTTPServer(fooPort, \"foo\")\n\t\t\tf.RunServer(\"\", fooServer)\n\n\t\t\tbarPort := f.AllocPort()\n\t\t\tbarServer := newHTTPServer(barPort, \"bar\")\n\t\t\tf.RunServer(\"\", barServer)\n\n\t\t\tclientConf += fmt.Sprintf(`\n\t\t\t[[proxies]]\n\t\t\tname = \"foo\"\n\t\t\ttype = \"http\"\n\t\t\tlocalPort = %d\n\t\t\tcustomDomains = [\"example.com\"]\n\t\t\tloadBalancer.group = \"test\"\n\t\t\tloadBalancer.groupKey = \"123\"\n\t\t\thealthCheck.type = \"http\"\n\t\t\thealthCheck.intervalSeconds = 1\n\t\t\thealthCheck.path = \"/healthz\"\n\n\t\t\t[[proxies]]\n\t\t\tname = \"bar\"\n\t\t\ttype = \"http\"\n\t\t\tlocalPort = %d\n\t\t\tcustomDomains = [\"example.com\"]\n\t\t\tloadBalancer.group = \"test\"\n\t\t\tloadBalancer.groupKey = \"123\"\n\t\t\thealthCheck.type = \"http\"\n\t\t\thealthCheck.intervalSeconds = 1\n\t\t\thealthCheck.path = \"/healthz\"\n\t\t\t`, fooPort, barPort)\n\n\t\t\t_, clientProcesses := f.RunProcesses(serverConf, []string{clientConf})\n\n\t\t\t// send first HTTP request\n\t\t\tvar contents []string\n\t\t\tframework.NewRequestExpect(f).Port(vhostPort).\n\t\t\t\tRequestModify(func(r *request.Request) {\n\t\t\t\t\tr.HTTP().HTTPHost(\"example.com\")\n\t\t\t\t}).\n\t\t\t\tEnsure(func(resp *request.Response) bool {\n\t\t\t\t\tcontents = append(contents, string(resp.Content))\n\t\t\t\t\treturn true\n\t\t\t\t})\n\n\t\t\t// send second HTTP request, should be forwarded to another service\n\t\t\tframework.NewRequestExpect(f).Port(vhostPort).\n\t\t\t\tRequestModify(func(r *request.Request) {\n\t\t\t\t\tr.HTTP().HTTPHost(\"example.com\")\n\t\t\t\t}).\n\t\t\t\tEnsure(func(resp *request.Response) bool {\n\t\t\t\t\tcontents = append(contents, string(resp.Content))\n\t\t\t\t\treturn true\n\t\t\t\t})\n\n\t\t\tframework.ExpectContainElements(contents, []string{\"foo\", \"bar\"})\n\n\t\t\t// check foo and bar is ok\n\t\t\tresults := doFooBarHTTPRequest(vhostPort, \"example.com\")\n\t\t\tframework.ExpectContainElements(results, []string{\"foo\", \"bar\"})\n\n\t\t\t// close bar server, check foo is ok\n\t\t\tfailedCount := clientProcesses[0].CountOutput(\"[bar] health check failed\")\n\t\t\tbarServer.Close()\n\t\t\tframework.ExpectNoError(clientProcesses[0].WaitForOutput(\"[bar] health check failed\", failedCount+1, 5*time.Second))\n\t\t\tresults = doFooBarHTTPRequest(vhostPort, \"example.com\")\n\t\t\tframework.ExpectContainElements(results, []string{\"foo\"})\n\t\t\tframework.ExpectNotContainElements(results, []string{\"bar\"})\n\n\t\t\t// resume bar server, check foo and bar is ok\n\t\t\tsuccessCount := clientProcesses[0].CountOutput(\"[bar] health check success\")\n\t\t\tf.RunServer(\"\", barServer)\n\t\t\tframework.ExpectNoError(clientProcesses[0].WaitForOutput(\"[bar] health check success\", successCount+1, 5*time.Second))\n\t\t\tresults = doFooBarHTTPRequest(vhostPort, \"example.com\")\n\t\t\tframework.ExpectContainElements(results, []string{\"foo\", \"bar\"})\n\t\t})\n\t})\n})\n"
  },
  {
    "path": "test/e2e/v1/features/heartbeat.go",
    "content": "package features\n\nimport (\n\t\"fmt\"\n\t\"time\"\n\n\t\"github.com/onsi/ginkgo/v2\"\n\n\t\"github.com/fatedier/frp/test/e2e/framework\"\n)\n\nvar _ = ginkgo.Describe(\"[Feature: Heartbeat]\", func() {\n\tf := framework.NewDefaultFramework()\n\n\tginkgo.It(\"disable application layer heartbeat\", func() {\n\t\tserverPort := f.AllocPort()\n\t\tserverConf := fmt.Sprintf(`\n\t\tbindAddr = \"0.0.0.0\"\n\t\tbindPort = %d\n\t\ttransport.heartbeatTimeout = -1\n\t\ttransport.tcpMuxKeepaliveInterval = 2\n\t\t`, serverPort)\n\n\t\tremotePort := f.AllocPort()\n\t\tclientConf := fmt.Sprintf(`\n\t\tserverPort = %d\n\t\tlog.level = \"trace\"\n\t\ttransport.heartbeatInterval = -1\n\t\ttransport.heartbeatTimeout = -1\n\t\ttransport.tcpMuxKeepaliveInterval = 2\n\n\t\t[[proxies]]\n\t\tname = \"tcp\"\n\t\ttype = \"tcp\"\n\t\tlocalPort = %d\n\t\tremotePort = %d\n\t\t`, serverPort, f.PortByName(framework.TCPEchoServerPort), remotePort)\n\n\t\t// run frps and frpc\n\t\tf.RunProcesses(serverConf, []string{clientConf})\n\n\t\tframework.NewRequestExpect(f).Protocol(\"tcp\").Port(remotePort).Ensure()\n\n\t\ttime.Sleep(5 * time.Second)\n\t\tframework.NewRequestExpect(f).Protocol(\"tcp\").Port(remotePort).Ensure()\n\t})\n})\n"
  },
  {
    "path": "test/e2e/v1/features/monitor.go",
    "content": "package features\n\nimport (\n\t\"fmt\"\n\t\"strings\"\n\t\"time\"\n\n\t\"github.com/onsi/ginkgo/v2\"\n\n\t\"github.com/fatedier/frp/pkg/util/log\"\n\t\"github.com/fatedier/frp/test/e2e/framework\"\n\t\"github.com/fatedier/frp/test/e2e/framework/consts\"\n\t\"github.com/fatedier/frp/test/e2e/pkg/request\"\n)\n\nvar _ = ginkgo.Describe(\"[Feature: Monitor]\", func() {\n\tf := framework.NewDefaultFramework()\n\n\tginkgo.It(\"Prometheus metrics\", func() {\n\t\tdashboardPort := f.AllocPort()\n\t\tserverConf := consts.DefaultServerConfig + fmt.Sprintf(`\n\t\tenablePrometheus = true\n\t\twebServer.addr = \"0.0.0.0\"\n\t\twebServer.port = %d\n\t\t`, dashboardPort)\n\n\t\tclientConf := consts.DefaultClientConfig\n\t\tremotePort := f.AllocPort()\n\t\tclientConf += fmt.Sprintf(`\n\t\t[[proxies]]\n\t\tname = \"tcp\"\n\t\ttype = \"tcp\"\n\t\tlocalPort = {{ .%s }}\n\t\tremotePort = %d\n\t\t`, framework.TCPEchoServerPort, remotePort)\n\n\t\tf.RunProcesses(serverConf, []string{clientConf})\n\n\t\tframework.NewRequestExpect(f).Port(remotePort).Ensure()\n\t\ttime.Sleep(500 * time.Millisecond)\n\n\t\tframework.NewRequestExpect(f).RequestModify(func(r *request.Request) {\n\t\t\tr.HTTP().Port(dashboardPort).HTTPPath(\"/metrics\")\n\t\t}).Ensure(func(resp *request.Response) bool {\n\t\t\tlog.Tracef(\"prometheus metrics response: \\n%s\", resp.Content)\n\t\t\tif resp.Code != 200 {\n\t\t\t\treturn false\n\t\t\t}\n\t\t\tif !strings.Contains(string(resp.Content), \"traffic_in\") {\n\t\t\t\treturn false\n\t\t\t}\n\t\t\treturn true\n\t\t})\n\t})\n})\n"
  },
  {
    "path": "test/e2e/v1/features/real_ip.go",
    "content": "package features\n\nimport (\n\t\"bufio\"\n\t\"crypto/tls\"\n\t\"fmt\"\n\t\"net\"\n\t\"net/http\"\n\n\t\"github.com/onsi/ginkgo/v2\"\n\tpp \"github.com/pires/go-proxyproto\"\n\n\t\"github.com/fatedier/frp/pkg/transport\"\n\t\"github.com/fatedier/frp/pkg/util/log\"\n\t\"github.com/fatedier/frp/test/e2e/framework\"\n\t\"github.com/fatedier/frp/test/e2e/framework/consts\"\n\t\"github.com/fatedier/frp/test/e2e/mock/server/httpserver\"\n\t\"github.com/fatedier/frp/test/e2e/mock/server/streamserver\"\n\t\"github.com/fatedier/frp/test/e2e/pkg/request\"\n\t\"github.com/fatedier/frp/test/e2e/pkg/rpc\"\n)\n\nvar _ = ginkgo.Describe(\"[Feature: Real IP]\", func() {\n\tf := framework.NewDefaultFramework()\n\n\tginkgo.Describe(\"HTTP X-forwarded-For\", func() {\n\t\tginkgo.It(\"Client Without Header\", func() {\n\t\t\tvhostHTTPPort := f.AllocPort()\n\t\t\tserverConf := consts.DefaultServerConfig + fmt.Sprintf(`\n\t\tvhostHTTPPort = %d\n\t\t`, vhostHTTPPort)\n\n\t\t\tlocalPort := f.AllocPort()\n\t\t\tlocalServer := httpserver.New(\n\t\t\t\thttpserver.WithBindPort(localPort),\n\t\t\t\thttpserver.WithHandler(http.HandlerFunc(func(w http.ResponseWriter, req *http.Request) {\n\t\t\t\t\t_, _ = w.Write([]byte(req.Header.Get(\"X-Forwarded-For\")))\n\t\t\t\t})),\n\t\t\t)\n\t\t\tf.RunServer(\"\", localServer)\n\n\t\t\tclientConf := consts.DefaultClientConfig\n\t\t\tclientConf += fmt.Sprintf(`\n\t\t[[proxies]]\n\t\tname = \"test\"\n\t\ttype = \"http\"\n\t\tlocalPort = %d\n\t\tcustomDomains = [\"normal.example.com\"]\n\t\t`, localPort)\n\n\t\t\tf.RunProcesses(serverConf, []string{clientConf})\n\n\t\t\tframework.NewRequestExpect(f).Port(vhostHTTPPort).\n\t\t\t\tRequestModify(func(r *request.Request) {\n\t\t\t\t\tr.HTTP().HTTPHost(\"normal.example.com\")\n\t\t\t\t}).\n\t\t\t\tExpectResp([]byte(\"127.0.0.1\")).\n\t\t\t\tEnsure()\n\t\t})\n\n\t\tginkgo.It(\"Client With Header\", func() {\n\t\t\tvhostHTTPPort := f.AllocPort()\n\t\t\tserverConf := consts.DefaultServerConfig + fmt.Sprintf(`\n\t\tvhostHTTPPort = %d\n\t\t`, vhostHTTPPort)\n\n\t\t\tlocalPort := f.AllocPort()\n\t\t\tlocalServer := httpserver.New(\n\t\t\t\thttpserver.WithBindPort(localPort),\n\t\t\t\thttpserver.WithHandler(http.HandlerFunc(func(w http.ResponseWriter, req *http.Request) {\n\t\t\t\t\t_, _ = w.Write([]byte(req.Header.Get(\"X-Forwarded-For\")))\n\t\t\t\t})),\n\t\t\t)\n\t\t\tf.RunServer(\"\", localServer)\n\n\t\t\tclientConf := consts.DefaultClientConfig\n\t\t\tclientConf += fmt.Sprintf(`\n\t\t[[proxies]]\n\t\tname = \"test\"\n\t\ttype = \"http\"\n\t\tlocalPort = %d\n\t\tcustomDomains = [\"normal.example.com\"]\n\t\t`, localPort)\n\n\t\t\tf.RunProcesses(serverConf, []string{clientConf})\n\n\t\t\tframework.NewRequestExpect(f).Port(vhostHTTPPort).\n\t\t\t\tRequestModify(func(r *request.Request) {\n\t\t\t\t\tr.HTTP().HTTPHost(\"normal.example.com\")\n\t\t\t\t\tr.HTTP().HTTPHeaders(map[string]string{\"x-forwarded-for\": \"2.2.2.2\"})\n\t\t\t\t}).\n\t\t\t\tExpectResp([]byte(\"2.2.2.2, 127.0.0.1\")).\n\t\t\t\tEnsure()\n\t\t})\n\n\t\tginkgo.It(\"http2https plugin\", func() {\n\t\t\tvhostHTTPPort := f.AllocPort()\n\t\t\tserverConf := consts.DefaultServerConfig + fmt.Sprintf(`\n\t\tvhostHTTPPort = %d\n\t\t`, vhostHTTPPort)\n\n\t\t\tlocalPort := f.AllocPort()\n\n\t\t\tclientConf := consts.DefaultClientConfig\n\t\t\tclientConf += fmt.Sprintf(`\n\t\t[[proxies]]\n\t\tname = \"test\"\n\t\ttype = \"http\"\n\t\tcustomDomains = [\"normal.example.com\"]\n\t\t[proxies.plugin]\n\t\ttype = \"http2https\"\n\t\tlocalAddr = \"127.0.0.1:%d\"\n\t\t`, localPort)\n\n\t\t\tf.RunProcesses(serverConf, []string{clientConf})\n\n\t\t\ttlsConfig, err := transport.NewServerTLSConfig(\"\", \"\", \"\")\n\t\t\tframework.ExpectNoError(err)\n\n\t\t\tlocalServer := httpserver.New(\n\t\t\t\thttpserver.WithBindPort(localPort),\n\t\t\t\thttpserver.WithHandler(http.HandlerFunc(func(w http.ResponseWriter, req *http.Request) {\n\t\t\t\t\t_, _ = w.Write([]byte(req.Header.Get(\"X-Forwarded-For\")))\n\t\t\t\t})),\n\t\t\t\thttpserver.WithTLSConfig(tlsConfig),\n\t\t\t)\n\t\t\tf.RunServer(\"\", localServer)\n\n\t\t\tframework.NewRequestExpect(f).Port(vhostHTTPPort).\n\t\t\t\tRequestModify(func(r *request.Request) {\n\t\t\t\t\tr.HTTP().HTTPHost(\"normal.example.com\")\n\t\t\t\t\tr.HTTP().HTTPHeaders(map[string]string{\"x-forwarded-for\": \"2.2.2.2, 3.3.3.3\"})\n\t\t\t\t}).\n\t\t\t\tExpectResp([]byte(\"2.2.2.2, 3.3.3.3, 127.0.0.1\")).\n\t\t\t\tEnsure()\n\t\t})\n\n\t\tginkgo.It(\"https2http plugin\", func() {\n\t\t\tvhostHTTPSPort := f.AllocPort()\n\t\t\tserverConf := consts.DefaultServerConfig + fmt.Sprintf(`\n\t\tvhostHTTPSPort = %d\n\t\t`, vhostHTTPSPort)\n\n\t\t\tlocalPort := f.AllocPort()\n\n\t\t\tclientConf := consts.DefaultClientConfig\n\t\t\tclientConf += fmt.Sprintf(`\n\t\t[[proxies]]\n\t\tname = \"test\"\n\t\ttype = \"https\"\n\t\tcustomDomains = [\"normal.example.com\"]\n\t\t[proxies.plugin]\n\t\ttype = \"https2http\"\n\t\tlocalAddr = \"127.0.0.1:%d\"\n\t\t`, localPort)\n\n\t\t\tf.RunProcesses(serverConf, []string{clientConf})\n\n\t\t\tlocalServer := httpserver.New(\n\t\t\t\thttpserver.WithBindPort(localPort),\n\t\t\t\thttpserver.WithHandler(http.HandlerFunc(func(w http.ResponseWriter, req *http.Request) {\n\t\t\t\t\t_, _ = w.Write([]byte(req.Header.Get(\"X-Forwarded-For\")))\n\t\t\t\t})),\n\t\t\t)\n\t\t\tf.RunServer(\"\", localServer)\n\n\t\t\tframework.NewRequestExpect(f).Port(vhostHTTPSPort).\n\t\t\t\tRequestModify(func(r *request.Request) {\n\t\t\t\t\tr.HTTPS().HTTPHost(\"normal.example.com\").\n\t\t\t\t\t\tHTTPHeaders(map[string]string{\"x-forwarded-for\": \"2.2.2.2\"}).\n\t\t\t\t\t\tTLSConfig(&tls.Config{ServerName: \"normal.example.com\", InsecureSkipVerify: true})\n\t\t\t\t}).\n\t\t\t\tExpectResp([]byte(\"2.2.2.2, 127.0.0.1\")).\n\t\t\t\tEnsure()\n\t\t})\n\t})\n\n\tginkgo.Describe(\"Proxy Protocol\", func() {\n\t\tginkgo.It(\"TCP\", func() {\n\t\t\tserverConf := consts.DefaultServerConfig\n\t\t\tclientConf := consts.DefaultClientConfig\n\n\t\t\tlocalPort := f.AllocPort()\n\t\t\tlocalServer := streamserver.New(streamserver.TCP, streamserver.WithBindPort(localPort),\n\t\t\t\tstreamserver.WithCustomHandler(func(c net.Conn) {\n\t\t\t\t\tdefer c.Close()\n\t\t\t\t\trd := bufio.NewReader(c)\n\t\t\t\t\tppHeader, err := pp.Read(rd)\n\t\t\t\t\tif err != nil {\n\t\t\t\t\t\tlog.Errorf(\"read proxy protocol error: %v\", err)\n\t\t\t\t\t\treturn\n\t\t\t\t\t}\n\n\t\t\t\t\tfor {\n\t\t\t\t\t\tif _, err := rpc.ReadBytes(rd); err != nil {\n\t\t\t\t\t\t\treturn\n\t\t\t\t\t\t}\n\n\t\t\t\t\t\tbuf := []byte(ppHeader.SourceAddr.String())\n\t\t\t\t\t\t_, _ = rpc.WriteBytes(c, buf)\n\t\t\t\t\t}\n\t\t\t\t}))\n\t\t\tf.RunServer(\"\", localServer)\n\n\t\t\tremotePort := f.AllocPort()\n\t\t\tclientConf += fmt.Sprintf(`\n\t\t\t[[proxies]]\n\t\t\tname = \"tcp\"\n\t\t\ttype = \"tcp\"\n\t\t\tlocalPort = %d\n\t\t\tremotePort = %d\n\t\t\ttransport.proxyProtocolVersion = \"v2\"\n\t\t\t`, localPort, remotePort)\n\n\t\t\tf.RunProcesses(serverConf, []string{clientConf})\n\n\t\t\tframework.NewRequestExpect(f).Port(remotePort).Ensure(func(resp *request.Response) bool {\n\t\t\t\tlog.Tracef(\"proxy protocol get SourceAddr: %s\", string(resp.Content))\n\t\t\t\taddr, err := net.ResolveTCPAddr(\"tcp\", string(resp.Content))\n\t\t\t\tif err != nil {\n\t\t\t\t\treturn false\n\t\t\t\t}\n\t\t\t\tif addr.IP.String() != \"127.0.0.1\" {\n\t\t\t\t\treturn false\n\t\t\t\t}\n\t\t\t\treturn true\n\t\t\t})\n\t\t})\n\n\t\tginkgo.It(\"UDP\", func() {\n\t\t\tserverConf := consts.DefaultServerConfig\n\t\t\tclientConf := consts.DefaultClientConfig\n\n\t\t\tlocalPort := f.AllocPort()\n\t\t\tlocalServer := streamserver.New(streamserver.UDP, streamserver.WithBindPort(localPort),\n\t\t\t\tstreamserver.WithCustomHandler(func(c net.Conn) {\n\t\t\t\t\tdefer c.Close()\n\t\t\t\t\trd := bufio.NewReader(c)\n\t\t\t\t\tppHeader, err := pp.Read(rd)\n\t\t\t\t\tif err != nil {\n\t\t\t\t\t\tlog.Errorf(\"read proxy protocol error: %v\", err)\n\t\t\t\t\t\treturn\n\t\t\t\t\t}\n\n\t\t\t\t\t// Read the actual UDP content after proxy protocol header\n\t\t\t\t\tif _, err := rpc.ReadBytes(rd); err != nil {\n\t\t\t\t\t\treturn\n\t\t\t\t\t}\n\n\t\t\t\t\tbuf := []byte(ppHeader.SourceAddr.String())\n\t\t\t\t\t_, _ = rpc.WriteBytes(c, buf)\n\t\t\t\t}))\n\t\t\tf.RunServer(\"\", localServer)\n\n\t\t\tremotePort := f.AllocPort()\n\t\t\tclientConf += fmt.Sprintf(`\n\t\t\t[[proxies]]\n\t\t\tname = \"udp\"\n\t\t\ttype = \"udp\"\n\t\t\tlocalPort = %d\n\t\t\tremotePort = %d\n\t\t\ttransport.proxyProtocolVersion = \"v2\"\n\t\t\t`, localPort, remotePort)\n\n\t\t\tf.RunProcesses(serverConf, []string{clientConf})\n\n\t\t\tframework.NewRequestExpect(f).Protocol(\"udp\").Port(remotePort).Ensure(func(resp *request.Response) bool {\n\t\t\t\tlog.Tracef(\"udp proxy protocol get SourceAddr: %s\", string(resp.Content))\n\t\t\t\taddr, err := net.ResolveUDPAddr(\"udp\", string(resp.Content))\n\t\t\t\tif err != nil {\n\t\t\t\t\treturn false\n\t\t\t\t}\n\t\t\t\tif addr.IP.String() != \"127.0.0.1\" {\n\t\t\t\t\treturn false\n\t\t\t\t}\n\t\t\t\treturn true\n\t\t\t})\n\t\t})\n\n\t\tginkgo.It(\"HTTP\", func() {\n\t\t\tvhostHTTPPort := f.AllocPort()\n\t\t\tserverConf := consts.DefaultServerConfig + fmt.Sprintf(`\n\t\tvhostHTTPPort = %d\n\t\t`, vhostHTTPPort)\n\n\t\t\tclientConf := consts.DefaultClientConfig\n\n\t\t\tlocalPort := f.AllocPort()\n\t\t\tvar srcAddrRecord string\n\t\t\tlocalServer := streamserver.New(streamserver.TCP, streamserver.WithBindPort(localPort),\n\t\t\t\tstreamserver.WithCustomHandler(func(c net.Conn) {\n\t\t\t\t\tdefer c.Close()\n\t\t\t\t\trd := bufio.NewReader(c)\n\t\t\t\t\tppHeader, err := pp.Read(rd)\n\t\t\t\t\tif err != nil {\n\t\t\t\t\t\tlog.Errorf(\"read proxy protocol error: %v\", err)\n\t\t\t\t\t\treturn\n\t\t\t\t\t}\n\t\t\t\t\tsrcAddrRecord = ppHeader.SourceAddr.String()\n\t\t\t\t}))\n\t\t\tf.RunServer(\"\", localServer)\n\n\t\t\tclientConf += fmt.Sprintf(`\n\t\t\t[[proxies]]\n\t\t\tname = \"test\"\n\t\t\ttype = \"http\"\n\t\t\tlocalPort = %d\n\t\t\tcustomDomains = [\"normal.example.com\"]\n\t\t\ttransport.proxyProtocolVersion = \"v2\"\n\t\t\t`, localPort)\n\n\t\t\tf.RunProcesses(serverConf, []string{clientConf})\n\n\t\t\tframework.NewRequestExpect(f).Port(vhostHTTPPort).RequestModify(func(r *request.Request) {\n\t\t\t\tr.HTTP().HTTPHost(\"normal.example.com\")\n\t\t\t}).Ensure(framework.ExpectResponseCode(404))\n\n\t\t\tlog.Tracef(\"proxy protocol get SourceAddr: %s\", srcAddrRecord)\n\t\t\taddr, err := net.ResolveTCPAddr(\"tcp\", srcAddrRecord)\n\t\t\tframework.ExpectNoError(err, srcAddrRecord)\n\t\t\tframework.ExpectEqualValues(\"127.0.0.1\", addr.IP.String())\n\t\t})\n\t})\n})\n"
  },
  {
    "path": "test/e2e/v1/features/ssh_tunnel.go",
    "content": "package features\n\nimport (\n\t\"crypto/tls\"\n\t\"fmt\"\n\t\"net\"\n\t\"strconv\"\n\t\"time\"\n\n\t\"github.com/onsi/ginkgo/v2\"\n\n\t\"github.com/fatedier/frp/pkg/transport\"\n\t\"github.com/fatedier/frp/test/e2e/framework\"\n\t\"github.com/fatedier/frp/test/e2e/framework/consts\"\n\t\"github.com/fatedier/frp/test/e2e/mock/server/httpserver\"\n\t\"github.com/fatedier/frp/test/e2e/mock/server/streamserver\"\n\t\"github.com/fatedier/frp/test/e2e/pkg/request\"\n\t\"github.com/fatedier/frp/test/e2e/pkg/ssh\"\n)\n\nvar _ = ginkgo.Describe(\"[Feature: SSH Tunnel]\", func() {\n\tf := framework.NewDefaultFramework()\n\n\tginkgo.It(\"tcp\", func() {\n\t\tsshPort := f.AllocPort()\n\t\tserverConf := consts.DefaultServerConfig + fmt.Sprintf(`\n\t\tsshTunnelGateway.bindPort = %d\n\t\t`, sshPort)\n\n\t\tf.RunProcesses(serverConf, nil)\n\t\tframework.ExpectNoError(framework.WaitForTCPReady(net.JoinHostPort(\"127.0.0.1\", strconv.Itoa(sshPort)), 5*time.Second))\n\n\t\tlocalPort := f.PortByName(framework.TCPEchoServerPort)\n\t\tremotePort := f.AllocPort()\n\t\ttc := ssh.NewTunnelClient(\n\t\t\tfmt.Sprintf(\"127.0.0.1:%d\", localPort),\n\t\t\tfmt.Sprintf(\"127.0.0.1:%d\", sshPort),\n\t\t\tfmt.Sprintf(\"tcp --remote-port %d\", remotePort),\n\t\t)\n\t\tframework.ExpectNoError(tc.Start())\n\t\tdefer tc.Close()\n\n\t\ttime.Sleep(time.Second)\n\t\tframework.NewRequestExpect(f).Port(remotePort).Ensure()\n\t})\n\n\tginkgo.It(\"http\", func() {\n\t\tsshPort := f.AllocPort()\n\t\tvhostPort := f.AllocPort()\n\t\tserverConf := consts.DefaultServerConfig + fmt.Sprintf(`\n\t\tvhostHTTPPort = %d\n\t\tsshTunnelGateway.bindPort = %d\n\t\t`, vhostPort, sshPort)\n\n\t\tf.RunProcesses(serverConf, nil)\n\t\tframework.ExpectNoError(framework.WaitForTCPReady(net.JoinHostPort(\"127.0.0.1\", strconv.Itoa(sshPort)), 5*time.Second))\n\n\t\tlocalPort := f.PortByName(framework.HTTPSimpleServerPort)\n\t\ttc := ssh.NewTunnelClient(\n\t\t\tfmt.Sprintf(\"127.0.0.1:%d\", localPort),\n\t\t\tfmt.Sprintf(\"127.0.0.1:%d\", sshPort),\n\t\t\t\"http --custom-domain test.example.com\",\n\t\t)\n\t\tframework.ExpectNoError(tc.Start())\n\t\tdefer tc.Close()\n\n\t\ttime.Sleep(time.Second)\n\t\tframework.NewRequestExpect(f).Port(vhostPort).\n\t\t\tRequestModify(func(r *request.Request) {\n\t\t\t\tr.HTTP().HTTPHost(\"test.example.com\")\n\t\t\t}).\n\t\t\tEnsure()\n\t})\n\n\tginkgo.It(\"https\", func() {\n\t\tsshPort := f.AllocPort()\n\t\tvhostPort := f.AllocPort()\n\t\tserverConf := consts.DefaultServerConfig + fmt.Sprintf(`\n\t\tvhostHTTPSPort = %d\n\t\tsshTunnelGateway.bindPort = %d\n\t\t`, vhostPort, sshPort)\n\n\t\tf.RunProcesses(serverConf, nil)\n\t\tframework.ExpectNoError(framework.WaitForTCPReady(net.JoinHostPort(\"127.0.0.1\", strconv.Itoa(sshPort)), 5*time.Second))\n\n\t\tlocalPort := f.AllocPort()\n\t\ttestDomain := \"test.example.com\"\n\t\ttc := ssh.NewTunnelClient(\n\t\t\tfmt.Sprintf(\"127.0.0.1:%d\", localPort),\n\t\t\tfmt.Sprintf(\"127.0.0.1:%d\", sshPort),\n\t\t\tfmt.Sprintf(\"https --custom-domain %s\", testDomain),\n\t\t)\n\t\tframework.ExpectNoError(tc.Start())\n\t\tdefer tc.Close()\n\n\t\ttlsConfig, err := transport.NewServerTLSConfig(\"\", \"\", \"\")\n\t\tframework.ExpectNoError(err)\n\t\tlocalServer := httpserver.New(\n\t\t\thttpserver.WithBindPort(localPort),\n\t\t\thttpserver.WithTLSConfig(tlsConfig),\n\t\t\thttpserver.WithResponse([]byte(\"test\")),\n\t\t)\n\t\tf.RunServer(\"\", localServer)\n\n\t\ttime.Sleep(time.Second)\n\t\tframework.NewRequestExpect(f).\n\t\t\tPort(vhostPort).\n\t\t\tRequestModify(func(r *request.Request) {\n\t\t\t\tr.HTTPS().HTTPHost(testDomain).TLSConfig(&tls.Config{\n\t\t\t\t\tServerName:         testDomain,\n\t\t\t\t\tInsecureSkipVerify: true,\n\t\t\t\t})\n\t\t\t}).\n\t\t\tExpectResp([]byte(\"test\")).\n\t\t\tEnsure()\n\t})\n\n\tginkgo.It(\"tcpmux\", func() {\n\t\tsshPort := f.AllocPort()\n\t\ttcpmuxPort := f.AllocPort()\n\t\tserverConf := consts.DefaultServerConfig + fmt.Sprintf(`\n\t\ttcpmuxHTTPConnectPort = %d\n\t\tsshTunnelGateway.bindPort = %d\n\t\t`, tcpmuxPort, sshPort)\n\n\t\tf.RunProcesses(serverConf, nil)\n\t\tframework.ExpectNoError(framework.WaitForTCPReady(net.JoinHostPort(\"127.0.0.1\", strconv.Itoa(sshPort)), 5*time.Second))\n\n\t\tlocalPort := f.AllocPort()\n\t\ttestDomain := \"test.example.com\"\n\t\ttc := ssh.NewTunnelClient(\n\t\t\tfmt.Sprintf(\"127.0.0.1:%d\", localPort),\n\t\t\tfmt.Sprintf(\"127.0.0.1:%d\", sshPort),\n\t\t\tfmt.Sprintf(\"tcpmux --mux=httpconnect --custom-domain %s\", testDomain),\n\t\t)\n\t\tframework.ExpectNoError(tc.Start())\n\t\tdefer tc.Close()\n\n\t\tlocalServer := streamserver.New(\n\t\t\tstreamserver.TCP,\n\t\t\tstreamserver.WithBindPort(localPort),\n\t\t\tstreamserver.WithRespContent([]byte(\"test\")),\n\t\t)\n\t\tf.RunServer(\"\", localServer)\n\n\t\ttime.Sleep(time.Second)\n\t\t// Request without HTTP connect should get error\n\t\tframework.NewRequestExpect(f).\n\t\t\tPort(tcpmuxPort).\n\t\t\tExpectError(true).\n\t\t\tExplain(\"request without HTTP connect expect error\").\n\t\t\tEnsure()\n\n\t\tproxyURL := fmt.Sprintf(\"http://127.0.0.1:%d\", tcpmuxPort)\n\t\t// Request with incorrect connect hostname\n\t\tframework.NewRequestExpect(f).RequestModify(func(r *request.Request) {\n\t\t\tr.Addr(\"invalid\").Proxy(proxyURL)\n\t\t}).ExpectError(true).Explain(\"request without HTTP connect expect error\").Ensure()\n\n\t\t// Request with correct connect hostname\n\t\tframework.NewRequestExpect(f).RequestModify(func(r *request.Request) {\n\t\t\tr.Addr(testDomain).Proxy(proxyURL)\n\t\t}).ExpectResp([]byte(\"test\")).Ensure()\n\t})\n\n\tginkgo.It(\"stcp\", func() {\n\t\tsshPort := f.AllocPort()\n\t\tserverConf := consts.DefaultServerConfig + fmt.Sprintf(`\n\t\tsshTunnelGateway.bindPort = %d\n\t\t`, sshPort)\n\n\t\tbindPort := f.AllocPort()\n\t\tvisitorConf := consts.DefaultClientConfig + fmt.Sprintf(`\n        [[visitors]]\n\t\tname = \"stcp-test-visitor\"\n\t\ttype = \"stcp\"\n\t\tserverName = \"stcp-test\"\n\t\tsecretKey = \"abcdefg\"\n\t\tbindPort = %d\n\t\t`, bindPort)\n\n\t\tf.RunProcesses(serverConf, []string{visitorConf})\n\t\tframework.ExpectNoError(framework.WaitForTCPReady(net.JoinHostPort(\"127.0.0.1\", strconv.Itoa(sshPort)), 5*time.Second))\n\n\t\tlocalPort := f.PortByName(framework.TCPEchoServerPort)\n\t\ttc := ssh.NewTunnelClient(\n\t\t\tfmt.Sprintf(\"127.0.0.1:%d\", localPort),\n\t\t\tfmt.Sprintf(\"127.0.0.1:%d\", sshPort),\n\t\t\t\"stcp -n stcp-test --sk=abcdefg --allow-users=\\\"*\\\"\",\n\t\t)\n\t\tframework.ExpectNoError(tc.Start())\n\t\tdefer tc.Close()\n\n\t\ttime.Sleep(time.Second)\n\n\t\tframework.NewRequestExpect(f).\n\t\t\tPort(bindPort).\n\t\t\tEnsure()\n\t})\n})\n"
  },
  {
    "path": "test/e2e/v1/features/store.go",
    "content": "package features\n\nimport (\n\t\"encoding/json\"\n\t\"fmt\"\n\t\"strings\"\n\t\"time\"\n\n\t\"github.com/onsi/ginkgo/v2\"\n\n\t\"github.com/fatedier/frp/test/e2e/framework\"\n\t\"github.com/fatedier/frp/test/e2e/framework/consts\"\n\t\"github.com/fatedier/frp/test/e2e/pkg/request\"\n)\n\nvar _ = ginkgo.Describe(\"[Feature: Store]\", func() {\n\tf := framework.NewDefaultFramework()\n\n\tginkgo.Describe(\"Store API\", func() {\n\t\tginkgo.It(\"create proxy via API and verify connection\", func() {\n\t\t\tadminPort := f.AllocPort()\n\t\t\tremotePort := f.AllocPort()\n\n\t\t\tserverConf := consts.DefaultServerConfig\n\t\t\tclientConf := consts.DefaultClientConfig + fmt.Sprintf(`\n\t\t\twebServer.addr = \"127.0.0.1\"\n\t\t\twebServer.port = %d\n\n\t\t\t[store]\n\t\t\tpath = \"%s/store.json\"\n\t\t\t`, adminPort, f.TempDirectory)\n\n\t\t\tf.RunProcesses(serverConf, []string{clientConf})\n\t\t\tframework.ExpectNoError(framework.WaitForTCPReady(fmt.Sprintf(\"127.0.0.1:%d\", adminPort), 5*time.Second))\n\n\t\t\tproxyConfig := map[string]any{\n\t\t\t\t\"name\": \"test-tcp\",\n\t\t\t\t\"type\": \"tcp\",\n\t\t\t\t\"tcp\": map[string]any{\n\t\t\t\t\t\"localIP\":    \"127.0.0.1\",\n\t\t\t\t\t\"localPort\":  f.PortByName(framework.TCPEchoServerPort),\n\t\t\t\t\t\"remotePort\": remotePort,\n\t\t\t\t},\n\t\t\t}\n\t\t\tproxyBody, _ := json.Marshal(proxyConfig)\n\n\t\t\tframework.NewRequestExpect(f).RequestModify(func(r *request.Request) {\n\t\t\t\tr.HTTP().Port(adminPort).HTTPPath(\"/api/store/proxies\").HTTPParams(\"POST\", \"\", \"/api/store/proxies\", map[string]string{\n\t\t\t\t\t\"Content-Type\": \"application/json\",\n\t\t\t\t}).Body(proxyBody)\n\t\t\t}).Ensure(func(resp *request.Response) bool {\n\t\t\t\treturn resp.Code == 200\n\t\t\t})\n\n\t\t\tframework.ExpectNoError(framework.WaitForTCPReady(fmt.Sprintf(\"127.0.0.1:%d\", remotePort), 5*time.Second))\n\n\t\t\tframework.NewRequestExpect(f).Port(remotePort).Ensure()\n\t\t})\n\n\t\tginkgo.It(\"update proxy via API\", func() {\n\t\t\tadminPort := f.AllocPort()\n\t\t\tremotePort1 := f.AllocPort()\n\t\t\tremotePort2 := f.AllocPort()\n\n\t\t\tserverConf := consts.DefaultServerConfig\n\t\t\tclientConf := consts.DefaultClientConfig + fmt.Sprintf(`\n\t\t\twebServer.addr = \"127.0.0.1\"\n\t\t\twebServer.port = %d\n\n\t\t\t[store]\n\t\t\tpath = \"%s/store.json\"\n\t\t\t`, adminPort, f.TempDirectory)\n\n\t\t\tf.RunProcesses(serverConf, []string{clientConf})\n\t\t\tframework.ExpectNoError(framework.WaitForTCPReady(fmt.Sprintf(\"127.0.0.1:%d\", adminPort), 5*time.Second))\n\n\t\t\tproxyConfig := map[string]any{\n\t\t\t\t\"name\": \"test-tcp\",\n\t\t\t\t\"type\": \"tcp\",\n\t\t\t\t\"tcp\": map[string]any{\n\t\t\t\t\t\"localIP\":    \"127.0.0.1\",\n\t\t\t\t\t\"localPort\":  f.PortByName(framework.TCPEchoServerPort),\n\t\t\t\t\t\"remotePort\": remotePort1,\n\t\t\t\t},\n\t\t\t}\n\t\t\tproxyBody, _ := json.Marshal(proxyConfig)\n\n\t\t\tframework.NewRequestExpect(f).RequestModify(func(r *request.Request) {\n\t\t\t\tr.HTTP().Port(adminPort).HTTPPath(\"/api/store/proxies\").HTTPParams(\"POST\", \"\", \"/api/store/proxies\", map[string]string{\n\t\t\t\t\t\"Content-Type\": \"application/json\",\n\t\t\t\t}).Body(proxyBody)\n\t\t\t}).Ensure(func(resp *request.Response) bool {\n\t\t\t\treturn resp.Code == 200\n\t\t\t})\n\n\t\t\tframework.ExpectNoError(framework.WaitForTCPReady(fmt.Sprintf(\"127.0.0.1:%d\", remotePort1), 5*time.Second))\n\t\t\tframework.NewRequestExpect(f).Port(remotePort1).Ensure()\n\n\t\t\tproxyConfig[\"tcp\"].(map[string]any)[\"remotePort\"] = remotePort2\n\t\t\tproxyBody, _ = json.Marshal(proxyConfig)\n\n\t\t\tframework.NewRequestExpect(f).RequestModify(func(r *request.Request) {\n\t\t\t\tr.HTTP().Port(adminPort).HTTPPath(\"/api/store/proxies/test-tcp\").HTTPParams(\"PUT\", \"\", \"/api/store/proxies/test-tcp\", map[string]string{\n\t\t\t\t\t\"Content-Type\": \"application/json\",\n\t\t\t\t}).Body(proxyBody)\n\t\t\t}).Ensure(func(resp *request.Response) bool {\n\t\t\t\treturn resp.Code == 200\n\t\t\t})\n\n\t\t\tframework.ExpectNoError(framework.WaitForTCPReady(fmt.Sprintf(\"127.0.0.1:%d\", remotePort2), 5*time.Second))\n\t\t\tframework.ExpectNoError(framework.WaitForTCPUnreachable(fmt.Sprintf(\"127.0.0.1:%d\", remotePort1), 100*time.Millisecond, 5*time.Second))\n\t\t\tframework.NewRequestExpect(f).Port(remotePort2).Ensure()\n\t\t\tframework.NewRequestExpect(f).Port(remotePort1).ExpectError(true).Ensure()\n\t\t})\n\n\t\tginkgo.It(\"delete proxy via API\", func() {\n\t\t\tadminPort := f.AllocPort()\n\t\t\tremotePort := f.AllocPort()\n\n\t\t\tserverConf := consts.DefaultServerConfig\n\t\t\tclientConf := consts.DefaultClientConfig + fmt.Sprintf(`\n\t\t\twebServer.addr = \"127.0.0.1\"\n\t\t\twebServer.port = %d\n\n\t\t\t[store]\n\t\t\tpath = \"%s/store.json\"\n\t\t\t`, adminPort, f.TempDirectory)\n\n\t\t\tf.RunProcesses(serverConf, []string{clientConf})\n\t\t\tframework.ExpectNoError(framework.WaitForTCPReady(fmt.Sprintf(\"127.0.0.1:%d\", adminPort), 5*time.Second))\n\n\t\t\tproxyConfig := map[string]any{\n\t\t\t\t\"name\": \"test-tcp\",\n\t\t\t\t\"type\": \"tcp\",\n\t\t\t\t\"tcp\": map[string]any{\n\t\t\t\t\t\"localIP\":    \"127.0.0.1\",\n\t\t\t\t\t\"localPort\":  f.PortByName(framework.TCPEchoServerPort),\n\t\t\t\t\t\"remotePort\": remotePort,\n\t\t\t\t},\n\t\t\t}\n\t\t\tproxyBody, _ := json.Marshal(proxyConfig)\n\n\t\t\tframework.NewRequestExpect(f).RequestModify(func(r *request.Request) {\n\t\t\t\tr.HTTP().Port(adminPort).HTTPPath(\"/api/store/proxies\").HTTPParams(\"POST\", \"\", \"/api/store/proxies\", map[string]string{\n\t\t\t\t\t\"Content-Type\": \"application/json\",\n\t\t\t\t}).Body(proxyBody)\n\t\t\t}).Ensure(func(resp *request.Response) bool {\n\t\t\t\treturn resp.Code == 200\n\t\t\t})\n\n\t\t\tframework.ExpectNoError(framework.WaitForTCPReady(fmt.Sprintf(\"127.0.0.1:%d\", remotePort), 5*time.Second))\n\t\t\tframework.NewRequestExpect(f).Port(remotePort).Ensure()\n\n\t\t\tframework.NewRequestExpect(f).RequestModify(func(r *request.Request) {\n\t\t\t\tr.HTTP().Port(adminPort).HTTPPath(\"/api/store/proxies/test-tcp\").HTTPParams(\"DELETE\", \"\", \"/api/store/proxies/test-tcp\", nil)\n\t\t\t}).Ensure(func(resp *request.Response) bool {\n\t\t\t\treturn resp.Code == 200\n\t\t\t})\n\n\t\t\tframework.ExpectNoError(framework.WaitForTCPUnreachable(fmt.Sprintf(\"127.0.0.1:%d\", remotePort), 100*time.Millisecond, 5*time.Second))\n\t\t\tframework.NewRequestExpect(f).Port(remotePort).ExpectError(true).Ensure()\n\t\t})\n\n\t\tginkgo.It(\"list and get proxy via API\", func() {\n\t\t\tadminPort := f.AllocPort()\n\t\t\tremotePort := f.AllocPort()\n\n\t\t\tserverConf := consts.DefaultServerConfig\n\t\t\tclientConf := consts.DefaultClientConfig + fmt.Sprintf(`\n\t\t\twebServer.addr = \"127.0.0.1\"\n\t\t\twebServer.port = %d\n\n\t\t\t[store]\n\t\t\tpath = \"%s/store.json\"\n\t\t\t`, adminPort, f.TempDirectory)\n\n\t\t\tf.RunProcesses(serverConf, []string{clientConf})\n\t\t\tframework.ExpectNoError(framework.WaitForTCPReady(fmt.Sprintf(\"127.0.0.1:%d\", adminPort), 5*time.Second))\n\n\t\t\tproxyConfig := map[string]any{\n\t\t\t\t\"name\": \"test-tcp\",\n\t\t\t\t\"type\": \"tcp\",\n\t\t\t\t\"tcp\": map[string]any{\n\t\t\t\t\t\"localIP\":    \"127.0.0.1\",\n\t\t\t\t\t\"localPort\":  f.PortByName(framework.TCPEchoServerPort),\n\t\t\t\t\t\"remotePort\": remotePort,\n\t\t\t\t},\n\t\t\t}\n\t\t\tproxyBody, _ := json.Marshal(proxyConfig)\n\n\t\t\tframework.NewRequestExpect(f).RequestModify(func(r *request.Request) {\n\t\t\t\tr.HTTP().Port(adminPort).HTTPPath(\"/api/store/proxies\").HTTPParams(\"POST\", \"\", \"/api/store/proxies\", map[string]string{\n\t\t\t\t\t\"Content-Type\": \"application/json\",\n\t\t\t\t}).Body(proxyBody)\n\t\t\t}).Ensure(func(resp *request.Response) bool {\n\t\t\t\treturn resp.Code == 200\n\t\t\t})\n\n\t\t\tframework.NewRequestExpect(f).RequestModify(func(r *request.Request) {\n\t\t\t\tr.HTTP().Port(adminPort).HTTPPath(\"/api/store/proxies\")\n\t\t\t}).Ensure(func(resp *request.Response) bool {\n\t\t\t\treturn resp.Code == 200 && strings.Contains(string(resp.Content), \"test-tcp\")\n\t\t\t})\n\n\t\t\tframework.NewRequestExpect(f).RequestModify(func(r *request.Request) {\n\t\t\t\tr.HTTP().Port(adminPort).HTTPPath(\"/api/store/proxies/test-tcp\")\n\t\t\t}).Ensure(func(resp *request.Response) bool {\n\t\t\t\treturn resp.Code == 200 && strings.Contains(string(resp.Content), \"test-tcp\")\n\t\t\t})\n\n\t\t\tframework.NewRequestExpect(f).RequestModify(func(r *request.Request) {\n\t\t\t\tr.HTTP().Port(adminPort).HTTPPath(\"/api/store/proxies/nonexistent\")\n\t\t\t}).Ensure(func(resp *request.Response) bool {\n\t\t\t\treturn resp.Code == 404\n\t\t\t})\n\t\t})\n\n\t\tginkgo.It(\"store disabled returns 404\", func() {\n\t\t\tadminPort := f.AllocPort()\n\n\t\t\tserverConf := consts.DefaultServerConfig\n\t\t\tclientConf := consts.DefaultClientConfig + fmt.Sprintf(`\n\t\t\twebServer.addr = \"127.0.0.1\"\n\t\t\twebServer.port = %d\n\t\t\t`, adminPort)\n\n\t\t\tf.RunProcesses(serverConf, []string{clientConf})\n\t\t\tframework.ExpectNoError(framework.WaitForTCPReady(fmt.Sprintf(\"127.0.0.1:%d\", adminPort), 5*time.Second))\n\n\t\t\tframework.NewRequestExpect(f).RequestModify(func(r *request.Request) {\n\t\t\t\tr.HTTP().Port(adminPort).HTTPPath(\"/api/store/proxies\")\n\t\t\t}).Ensure(func(resp *request.Response) bool {\n\t\t\t\treturn resp.Code == 404\n\t\t\t})\n\t\t})\n\n\t\tginkgo.It(\"rejects mismatched type block\", func() {\n\t\t\tadminPort := f.AllocPort()\n\n\t\t\tserverConf := consts.DefaultServerConfig\n\t\t\tclientConf := consts.DefaultClientConfig + fmt.Sprintf(`\n\t\t\twebServer.addr = \"127.0.0.1\"\n\t\t\twebServer.port = %d\n\n\t\t\t[store]\n\t\t\tpath = \"%s/store.json\"\n\t\t\t`, adminPort, f.TempDirectory)\n\n\t\t\tf.RunProcesses(serverConf, []string{clientConf})\n\t\t\tframework.ExpectNoError(framework.WaitForTCPReady(fmt.Sprintf(\"127.0.0.1:%d\", adminPort), 5*time.Second))\n\n\t\t\tinvalidBody, _ := json.Marshal(map[string]any{\n\t\t\t\t\"name\": \"bad-proxy\",\n\t\t\t\t\"type\": \"tcp\",\n\t\t\t\t\"udp\": map[string]any{\n\t\t\t\t\t\"localPort\": 1234,\n\t\t\t\t},\n\t\t\t})\n\n\t\t\tframework.NewRequestExpect(f).RequestModify(func(r *request.Request) {\n\t\t\t\tr.HTTP().Port(adminPort).HTTPPath(\"/api/store/proxies\").HTTPParams(\"POST\", \"\", \"/api/store/proxies\", map[string]string{\n\t\t\t\t\t\"Content-Type\": \"application/json\",\n\t\t\t\t}).Body(invalidBody)\n\t\t\t}).Ensure(func(resp *request.Response) bool {\n\t\t\t\treturn resp.Code == 400\n\t\t\t})\n\t\t})\n\n\t\tginkgo.It(\"rejects path/body name mismatch on update\", func() {\n\t\t\tadminPort := f.AllocPort()\n\t\t\tremotePort := f.AllocPort()\n\n\t\t\tserverConf := consts.DefaultServerConfig\n\t\t\tclientConf := consts.DefaultClientConfig + fmt.Sprintf(`\n\t\t\twebServer.addr = \"127.0.0.1\"\n\t\t\twebServer.port = %d\n\n\t\t\t[store]\n\t\t\tpath = \"%s/store.json\"\n\t\t\t`, adminPort, f.TempDirectory)\n\n\t\t\tf.RunProcesses(serverConf, []string{clientConf})\n\t\t\tframework.ExpectNoError(framework.WaitForTCPReady(fmt.Sprintf(\"127.0.0.1:%d\", adminPort), 5*time.Second))\n\n\t\t\tcreateBody, _ := json.Marshal(map[string]any{\n\t\t\t\t\"name\": \"proxy-a\",\n\t\t\t\t\"type\": \"tcp\",\n\t\t\t\t\"tcp\": map[string]any{\n\t\t\t\t\t\"localIP\":    \"127.0.0.1\",\n\t\t\t\t\t\"localPort\":  f.PortByName(framework.TCPEchoServerPort),\n\t\t\t\t\t\"remotePort\": remotePort,\n\t\t\t\t},\n\t\t\t})\n\n\t\t\tframework.NewRequestExpect(f).RequestModify(func(r *request.Request) {\n\t\t\t\tr.HTTP().Port(adminPort).HTTPPath(\"/api/store/proxies\").HTTPParams(\"POST\", \"\", \"/api/store/proxies\", map[string]string{\n\t\t\t\t\t\"Content-Type\": \"application/json\",\n\t\t\t\t}).Body(createBody)\n\t\t\t}).Ensure(func(resp *request.Response) bool {\n\t\t\t\treturn resp.Code == 200\n\t\t\t})\n\n\t\t\tupdateBody, _ := json.Marshal(map[string]any{\n\t\t\t\t\"name\": \"proxy-b\",\n\t\t\t\t\"type\": \"tcp\",\n\t\t\t\t\"tcp\": map[string]any{\n\t\t\t\t\t\"localIP\":    \"127.0.0.1\",\n\t\t\t\t\t\"localPort\":  f.PortByName(framework.TCPEchoServerPort),\n\t\t\t\t\t\"remotePort\": remotePort,\n\t\t\t\t},\n\t\t\t})\n\n\t\t\tframework.NewRequestExpect(f).RequestModify(func(r *request.Request) {\n\t\t\t\tr.HTTP().Port(adminPort).HTTPPath(\"/api/store/proxies/proxy-a\").HTTPParams(\"PUT\", \"\", \"/api/store/proxies/proxy-a\", map[string]string{\n\t\t\t\t\t\"Content-Type\": \"application/json\",\n\t\t\t\t}).Body(updateBody)\n\t\t\t}).Ensure(func(resp *request.Response) bool {\n\t\t\t\treturn resp.Code == 400\n\t\t\t})\n\t\t})\n\t})\n})\n"
  },
  {
    "path": "test/e2e/v1/plugin/client.go",
    "content": "package plugin\n\nimport (\n\t\"crypto/tls\"\n\t\"fmt\"\n\t\"net/http\"\n\t\"strconv\"\n\t\"strings\"\n\n\t\"github.com/onsi/ginkgo/v2\"\n\n\t\"github.com/fatedier/frp/pkg/transport\"\n\t\"github.com/fatedier/frp/test/e2e/framework\"\n\t\"github.com/fatedier/frp/test/e2e/framework/consts\"\n\t\"github.com/fatedier/frp/test/e2e/mock/server/httpserver\"\n\t\"github.com/fatedier/frp/test/e2e/pkg/cert\"\n\t\"github.com/fatedier/frp/test/e2e/pkg/port\"\n\t\"github.com/fatedier/frp/test/e2e/pkg/request\"\n)\n\nvar _ = ginkgo.Describe(\"[Feature: Client-Plugins]\", func() {\n\tf := framework.NewDefaultFramework()\n\n\tginkgo.Describe(\"UnixDomainSocket\", func() {\n\t\tginkgo.It(\"Expose a unix domain socket echo server\", func() {\n\t\t\tserverConf := consts.DefaultServerConfig\n\t\t\tvar clientConf strings.Builder\n\t\t\tclientConf.WriteString(consts.DefaultClientConfig)\n\n\t\t\tgetProxyConf := func(proxyName string, portName string, extra string) string {\n\t\t\t\treturn fmt.Sprintf(`\n\t\t\t\t[[proxies]]\n\t\t\t\tname = \"%s\"\n\t\t\t\ttype = \"tcp\"\n\t\t\t\tremotePort = {{ .%s }}\n\t\t\t\t`+extra, proxyName, portName) + fmt.Sprintf(`\n\t\t\t\t[proxies.plugin]\n\t\t\t\ttype = \"unix_domain_socket\"\n\t\t\t\tunixPath = \"{{ .%s }}\"\n\t\t\t\t`, framework.UDSEchoServerAddr)\n\t\t\t}\n\n\t\t\ttests := []struct {\n\t\t\t\tproxyName   string\n\t\t\t\tportName    string\n\t\t\t\textraConfig string\n\t\t\t}{\n\t\t\t\t{\n\t\t\t\t\tproxyName: \"normal\",\n\t\t\t\t\tportName:  port.GenName(\"Normal\"),\n\t\t\t\t},\n\t\t\t\t{\n\t\t\t\t\tproxyName:   \"with-encryption\",\n\t\t\t\t\tportName:    port.GenName(\"WithEncryption\"),\n\t\t\t\t\textraConfig: \"transport.useEncryption = true\",\n\t\t\t\t},\n\t\t\t\t{\n\t\t\t\t\tproxyName:   \"with-compression\",\n\t\t\t\t\tportName:    port.GenName(\"WithCompression\"),\n\t\t\t\t\textraConfig: \"transport.useCompression = true\",\n\t\t\t\t},\n\t\t\t\t{\n\t\t\t\t\tproxyName: \"with-encryption-and-compression\",\n\t\t\t\t\tportName:  port.GenName(\"WithEncryptionAndCompression\"),\n\t\t\t\t\textraConfig: `\n\t\t\t\t\ttransport.useEncryption = true\n\t\t\t\t\ttransport.useCompression = true\n\t\t\t\t\t`,\n\t\t\t\t},\n\t\t\t}\n\n\t\t\t// build all client config\n\t\t\tfor _, test := range tests {\n\t\t\t\tclientConf.WriteString(getProxyConf(test.proxyName, test.portName, test.extraConfig) + \"\\n\")\n\t\t\t}\n\t\t\t// run frps and frpc\n\t\t\tf.RunProcesses(serverConf, []string{clientConf.String()})\n\n\t\t\tfor _, test := range tests {\n\t\t\t\tframework.NewRequestExpect(f).Port(f.PortByName(test.portName)).Ensure()\n\t\t\t}\n\t\t})\n\t})\n\n\tginkgo.It(\"http_proxy\", func() {\n\t\tserverConf := consts.DefaultServerConfig\n\t\tclientConf := consts.DefaultClientConfig\n\n\t\tremotePort := f.AllocPort()\n\t\tclientConf += fmt.Sprintf(`\n\t\t[[proxies]]\n\t\tname = \"tcp\"\n\t\ttype = \"tcp\"\n\t\tremotePort = %d\n\t\t[proxies.plugin]\n\t\ttype = \"http_proxy\"\n\t\thttpUser = \"abc\"\n\t\thttpPassword = \"123\"\n\t\t`, remotePort)\n\n\t\tf.RunProcesses(serverConf, []string{clientConf})\n\n\t\t// http proxy, no auth info\n\t\tframework.NewRequestExpect(f).PortName(framework.HTTPSimpleServerPort).RequestModify(func(r *request.Request) {\n\t\t\tr.HTTP().Proxy(\"http://127.0.0.1:\" + strconv.Itoa(remotePort))\n\t\t}).Ensure(framework.ExpectResponseCode(407))\n\n\t\t// http proxy, correct auth\n\t\tframework.NewRequestExpect(f).PortName(framework.HTTPSimpleServerPort).RequestModify(func(r *request.Request) {\n\t\t\tr.HTTP().Proxy(\"http://abc:123@127.0.0.1:\" + strconv.Itoa(remotePort))\n\t\t}).Ensure()\n\n\t\t// connect TCP server by CONNECT method\n\t\tframework.NewRequestExpect(f).PortName(framework.TCPEchoServerPort).RequestModify(func(r *request.Request) {\n\t\t\tr.TCP().Proxy(\"http://abc:123@127.0.0.1:\" + strconv.Itoa(remotePort))\n\t\t})\n\t})\n\n\tginkgo.It(\"socks5 proxy\", func() {\n\t\tserverConf := consts.DefaultServerConfig\n\t\tclientConf := consts.DefaultClientConfig\n\n\t\tremotePort := f.AllocPort()\n\t\tclientConf += fmt.Sprintf(`\n\t\t[[proxies]]\n\t\tname = \"tcp\"\n\t\ttype = \"tcp\"\n\t\tremotePort = %d\n\t\t[proxies.plugin]\n\t\ttype = \"socks5\"\n\t\tusername = \"abc\"\n\t\tpassword = \"123\"\n\t\t`, remotePort)\n\n\t\tf.RunProcesses(serverConf, []string{clientConf})\n\n\t\t// http proxy, no auth info\n\t\tframework.NewRequestExpect(f).PortName(framework.TCPEchoServerPort).RequestModify(func(r *request.Request) {\n\t\t\tr.TCP().Proxy(\"socks5://127.0.0.1:\" + strconv.Itoa(remotePort))\n\t\t}).ExpectError(true).Ensure()\n\n\t\t// http proxy, correct auth\n\t\tframework.NewRequestExpect(f).PortName(framework.TCPEchoServerPort).RequestModify(func(r *request.Request) {\n\t\t\tr.TCP().Proxy(\"socks5://abc:123@127.0.0.1:\" + strconv.Itoa(remotePort))\n\t\t}).Ensure()\n\t})\n\n\tginkgo.It(\"static_file\", func() {\n\t\tvhostPort := f.AllocPort()\n\t\tserverConf := consts.DefaultServerConfig + fmt.Sprintf(`\n\t\tvhostHTTPPort = %d\n\t\t`, vhostPort)\n\t\tclientConf := consts.DefaultClientConfig\n\n\t\tremotePort := f.AllocPort()\n\t\tf.WriteTempFile(\"test_static_file\", \"foo\")\n\t\tclientConf += fmt.Sprintf(`\n\t\t[[proxies]]\n\t\tname = \"tcp\"\n\t\ttype = \"tcp\"\n\t\tremotePort = %d\n\t\t[proxies.plugin]\n\t\ttype = \"static_file\"\n\t\tlocalPath = \"%s\"\n\n\t\t[[proxies]]\n\t\tname = \"http\"\n\t\ttype = \"http\"\n\t\tcustomDomains = [\"example.com\"]\n\t\t[proxies.plugin]\n\t\ttype = \"static_file\"\n\t\tlocalPath = \"%s\"\n\n\t\t[[proxies]]\n\t\tname = \"http-with-auth\"\n\t\ttype = \"http\"\n\t\tcustomDomains = [\"other.example.com\"]\n\t\t[proxies.plugin]\n\t\ttype = \"static_file\"\n\t\tlocalPath = \"%s\"\n\t\thttpUser = \"abc\"\n\t\thttpPassword = \"123\"\n\t\t`, remotePort, f.TempDirectory, f.TempDirectory, f.TempDirectory)\n\n\t\tf.RunProcesses(serverConf, []string{clientConf})\n\n\t\t// from tcp proxy\n\t\tframework.NewRequestExpect(f).Request(\n\t\t\tframework.NewHTTPRequest().HTTPPath(\"/test_static_file\").Port(remotePort),\n\t\t).ExpectResp([]byte(\"foo\")).Ensure()\n\n\t\t// from http proxy without auth\n\t\tframework.NewRequestExpect(f).Request(\n\t\t\tframework.NewHTTPRequest().HTTPHost(\"example.com\").HTTPPath(\"/test_static_file\").Port(vhostPort),\n\t\t).ExpectResp([]byte(\"foo\")).Ensure()\n\n\t\t// from http proxy with auth\n\t\tframework.NewRequestExpect(f).Request(\n\t\t\tframework.NewHTTPRequest().HTTPHost(\"other.example.com\").HTTPPath(\"/test_static_file\").Port(vhostPort).HTTPAuth(\"abc\", \"123\"),\n\t\t).ExpectResp([]byte(\"foo\")).Ensure()\n\t})\n\n\tginkgo.It(\"http2https\", func() {\n\t\tserverConf := consts.DefaultServerConfig\n\t\tvhostHTTPPort := f.AllocPort()\n\t\tserverConf += fmt.Sprintf(`\n\t\tvhostHTTPPort = %d\n\t\t`, vhostHTTPPort)\n\n\t\tlocalPort := f.AllocPort()\n\t\tclientConf := consts.DefaultClientConfig + fmt.Sprintf(`\n\t\t[[proxies]]\n\t\tname = \"http2https\"\n\t\ttype = \"http\"\n\t\tcustomDomains = [\"example.com\"]\n\t\t[proxies.plugin]\n\t\ttype = \"http2https\"\n\t\tlocalAddr = \"127.0.0.1:%d\"\n\t\t`, localPort)\n\n\t\tf.RunProcesses(serverConf, []string{clientConf})\n\n\t\ttlsConfig, err := transport.NewServerTLSConfig(\"\", \"\", \"\")\n\t\tframework.ExpectNoError(err)\n\t\tlocalServer := httpserver.New(\n\t\t\thttpserver.WithBindPort(localPort),\n\t\t\thttpserver.WithTLSConfig(tlsConfig),\n\t\t\thttpserver.WithResponse([]byte(\"test\")),\n\t\t)\n\t\tf.RunServer(\"\", localServer)\n\n\t\tframework.NewRequestExpect(f).\n\t\t\tPort(vhostHTTPPort).\n\t\t\tRequestModify(func(r *request.Request) {\n\t\t\t\tr.HTTP().HTTPHost(\"example.com\")\n\t\t\t}).\n\t\t\tExpectResp([]byte(\"test\")).\n\t\t\tEnsure()\n\t})\n\n\tginkgo.It(\"https2http\", func() {\n\t\tgenerator := &cert.SelfSignedCertGenerator{}\n\t\tartifacts, err := generator.Generate(\"example.com\")\n\t\tframework.ExpectNoError(err)\n\t\tcrtPath := f.WriteTempFile(\"server.crt\", string(artifacts.Cert))\n\t\tkeyPath := f.WriteTempFile(\"server.key\", string(artifacts.Key))\n\n\t\tserverConf := consts.DefaultServerConfig\n\t\tvhostHTTPSPort := f.AllocPort()\n\t\tserverConf += fmt.Sprintf(`\n\t\tvhostHTTPSPort = %d\n\t\t`, vhostHTTPSPort)\n\n\t\tlocalPort := f.AllocPort()\n\t\tclientConf := consts.DefaultClientConfig + fmt.Sprintf(`\n\t\t[[proxies]]\n\t\tname = \"https2http\"\n\t\ttype = \"https\"\n\t\tcustomDomains = [\"example.com\"]\n\t\t[proxies.plugin]\n\t\ttype = \"https2http\"\n\t\tlocalAddr = \"127.0.0.1:%d\"\n\t\tcrtPath = \"%s\"\n\t\tkeyPath = \"%s\"\n\t\t`, localPort, crtPath, keyPath)\n\n\t\tf.RunProcesses(serverConf, []string{clientConf})\n\n\t\tlocalServer := httpserver.New(\n\t\t\thttpserver.WithBindPort(localPort),\n\t\t\thttpserver.WithResponse([]byte(\"test\")),\n\t\t)\n\t\tf.RunServer(\"\", localServer)\n\n\t\tframework.NewRequestExpect(f).\n\t\t\tPort(vhostHTTPSPort).\n\t\t\tRequestModify(func(r *request.Request) {\n\t\t\t\tr.HTTPS().HTTPHost(\"example.com\").TLSConfig(&tls.Config{\n\t\t\t\t\tServerName:         \"example.com\",\n\t\t\t\t\tInsecureSkipVerify: true,\n\t\t\t\t})\n\t\t\t}).\n\t\t\tExpectResp([]byte(\"test\")).\n\t\t\tEnsure()\n\t})\n\n\tginkgo.It(\"https2https\", func() {\n\t\tgenerator := &cert.SelfSignedCertGenerator{}\n\t\tartifacts, err := generator.Generate(\"example.com\")\n\t\tframework.ExpectNoError(err)\n\t\tcrtPath := f.WriteTempFile(\"server.crt\", string(artifacts.Cert))\n\t\tkeyPath := f.WriteTempFile(\"server.key\", string(artifacts.Key))\n\n\t\tserverConf := consts.DefaultServerConfig\n\t\tvhostHTTPSPort := f.AllocPort()\n\t\tserverConf += fmt.Sprintf(`\n\t\tvhostHTTPSPort = %d\n\t\t`, vhostHTTPSPort)\n\n\t\tlocalPort := f.AllocPort()\n\t\tclientConf := consts.DefaultClientConfig + fmt.Sprintf(`\n\t\t[[proxies]]\n\t\tname = \"https2https\"\n\t\ttype = \"https\"\n\t\tcustomDomains = [\"example.com\"]\n\t\t[proxies.plugin]\n\t\ttype = \"https2https\"\n\t\tlocalAddr = \"127.0.0.1:%d\"\n\t\tcrtPath = \"%s\"\n\t\tkeyPath = \"%s\"\n\t\t`, localPort, crtPath, keyPath)\n\n\t\tf.RunProcesses(serverConf, []string{clientConf})\n\n\t\ttlsConfig, err := transport.NewServerTLSConfig(\"\", \"\", \"\")\n\t\tframework.ExpectNoError(err)\n\t\tlocalServer := httpserver.New(\n\t\t\thttpserver.WithBindPort(localPort),\n\t\t\thttpserver.WithResponse([]byte(\"test\")),\n\t\t\thttpserver.WithTLSConfig(tlsConfig),\n\t\t)\n\t\tf.RunServer(\"\", localServer)\n\n\t\tframework.NewRequestExpect(f).\n\t\t\tPort(vhostHTTPSPort).\n\t\t\tRequestModify(func(r *request.Request) {\n\t\t\t\tr.HTTPS().HTTPHost(\"example.com\").TLSConfig(&tls.Config{\n\t\t\t\t\tServerName:         \"example.com\",\n\t\t\t\t\tInsecureSkipVerify: true,\n\t\t\t\t})\n\t\t\t}).\n\t\t\tExpectResp([]byte(\"test\")).\n\t\t\tEnsure()\n\t})\n\n\tginkgo.Describe(\"http2http\", func() {\n\t\tginkgo.It(\"host header rewrite\", func() {\n\t\t\tserverConf := consts.DefaultServerConfig\n\n\t\t\tlocalPort := f.AllocPort()\n\t\t\tremotePort := f.AllocPort()\n\t\t\tclientConf := consts.DefaultClientConfig + fmt.Sprintf(`\n\t\t\t[[proxies]]\n\t\t\tname = \"http2http\"\n\t\t\ttype = \"tcp\"\n\t\t\tremotePort = %d\n\t\t\t[proxies.plugin]\n\t\t\ttype = \"http2http\"\n\t\t\tlocalAddr = \"127.0.0.1:%d\"\n\t\t\thostHeaderRewrite = \"rewrite.test.com\"\n\t\t\t`, remotePort, localPort)\n\n\t\t\tf.RunProcesses(serverConf, []string{clientConf})\n\n\t\t\tlocalServer := httpserver.New(\n\t\t\t\thttpserver.WithBindPort(localPort),\n\t\t\t\thttpserver.WithHandler(http.HandlerFunc(func(w http.ResponseWriter, req *http.Request) {\n\t\t\t\t\t_, _ = w.Write([]byte(req.Host))\n\t\t\t\t})),\n\t\t\t)\n\t\t\tf.RunServer(\"\", localServer)\n\n\t\t\tframework.NewRequestExpect(f).\n\t\t\t\tPort(remotePort).\n\t\t\t\tRequestModify(func(r *request.Request) {\n\t\t\t\t\tr.HTTP().HTTPHost(\"example.com\")\n\t\t\t\t}).\n\t\t\t\tExpectResp([]byte(\"rewrite.test.com\")).\n\t\t\t\tEnsure()\n\t\t})\n\n\t\tginkgo.It(\"set request header\", func() {\n\t\t\tserverConf := consts.DefaultServerConfig\n\n\t\t\tlocalPort := f.AllocPort()\n\t\t\tremotePort := f.AllocPort()\n\t\t\tclientConf := consts.DefaultClientConfig + fmt.Sprintf(`\n\t\t\t[[proxies]]\n\t\t\tname = \"http2http\"\n\t\t\ttype = \"tcp\"\n\t\t\tremotePort = %d\n\t\t\t[proxies.plugin]\n\t\t\ttype = \"http2http\"\n\t\t\tlocalAddr = \"127.0.0.1:%d\"\n\t\t\trequestHeaders.set.x-from-where = \"frp\"\n\t\t\t`, remotePort, localPort)\n\n\t\t\tf.RunProcesses(serverConf, []string{clientConf})\n\n\t\t\tlocalServer := httpserver.New(\n\t\t\t\thttpserver.WithBindPort(localPort),\n\t\t\t\thttpserver.WithHandler(http.HandlerFunc(func(w http.ResponseWriter, req *http.Request) {\n\t\t\t\t\t_, _ = w.Write([]byte(req.Header.Get(\"x-from-where\")))\n\t\t\t\t})),\n\t\t\t)\n\t\t\tf.RunServer(\"\", localServer)\n\n\t\t\tframework.NewRequestExpect(f).\n\t\t\t\tPort(remotePort).\n\t\t\t\tRequestModify(func(r *request.Request) {\n\t\t\t\t\tr.HTTP().HTTPHost(\"example.com\")\n\t\t\t\t}).\n\t\t\t\tExpectResp([]byte(\"frp\")).\n\t\t\t\tEnsure()\n\t\t})\n\t})\n\n\tginkgo.It(\"tls2raw\", func() {\n\t\tgenerator := &cert.SelfSignedCertGenerator{}\n\t\tartifacts, err := generator.Generate(\"example.com\")\n\t\tframework.ExpectNoError(err)\n\t\tcrtPath := f.WriteTempFile(\"tls2raw_server.crt\", string(artifacts.Cert))\n\t\tkeyPath := f.WriteTempFile(\"tls2raw_server.key\", string(artifacts.Key))\n\n\t\tserverConf := consts.DefaultServerConfig\n\t\tvhostHTTPSPort := f.AllocPort()\n\t\tserverConf += fmt.Sprintf(`\n\t\tvhostHTTPSPort = %d\n\t\t`, vhostHTTPSPort)\n\n\t\tlocalPort := f.AllocPort()\n\t\tclientConf := consts.DefaultClientConfig + fmt.Sprintf(`\n\t\t[[proxies]]\n\t\tname = \"tls2raw-test\"\n\t\ttype = \"https\"\n\t\tcustomDomains = [\"example.com\"]\n\t\t[proxies.plugin]\n\t\ttype = \"tls2raw\"\n\t\tlocalAddr = \"127.0.0.1:%d\"\n\t\tcrtPath = \"%s\"\n\t\tkeyPath = \"%s\"\n\t\t`, localPort, crtPath, keyPath)\n\n\t\tf.RunProcesses(serverConf, []string{clientConf})\n\n\t\tlocalServer := httpserver.New(\n\t\t\thttpserver.WithBindPort(localPort),\n\t\t\thttpserver.WithResponse([]byte(\"test\")),\n\t\t)\n\t\tf.RunServer(\"\", localServer)\n\n\t\tframework.NewRequestExpect(f).\n\t\t\tPort(vhostHTTPSPort).\n\t\t\tRequestModify(func(r *request.Request) {\n\t\t\t\tr.HTTPS().HTTPHost(\"example.com\").TLSConfig(&tls.Config{\n\t\t\t\t\tServerName:         \"example.com\",\n\t\t\t\t\tInsecureSkipVerify: true,\n\t\t\t\t})\n\t\t\t}).\n\t\t\tExpectResp([]byte(\"test\")).\n\t\t\tEnsure()\n\t})\n})\n"
  },
  {
    "path": "test/e2e/v1/plugin/server.go",
    "content": "package plugin\n\nimport (\n\t\"fmt\"\n\t\"time\"\n\n\t\"github.com/onsi/ginkgo/v2\"\n\n\tplugin \"github.com/fatedier/frp/pkg/plugin/server\"\n\t\"github.com/fatedier/frp/pkg/transport\"\n\t\"github.com/fatedier/frp/test/e2e/framework\"\n\t\"github.com/fatedier/frp/test/e2e/framework/consts\"\n\tpluginpkg \"github.com/fatedier/frp/test/e2e/pkg/plugin\"\n)\n\nvar _ = ginkgo.Describe(\"[Feature: Server-Plugins]\", func() {\n\tf := framework.NewDefaultFramework()\n\n\tginkgo.Describe(\"Login\", func() {\n\t\tnewFunc := func() *plugin.Request {\n\t\t\tvar r plugin.Request\n\t\t\tr.Content = &plugin.LoginContent{}\n\t\t\treturn &r\n\t\t}\n\n\t\tginkgo.It(\"Auth for custom meta token\", func() {\n\t\t\tlocalPort := f.AllocPort()\n\n\t\t\tclientAddressGot := false\n\t\t\thandler := func(req *plugin.Request) *plugin.Response {\n\t\t\t\tvar ret plugin.Response\n\t\t\t\tcontent := req.Content.(*plugin.LoginContent)\n\t\t\t\tif content.ClientAddress != \"\" {\n\t\t\t\t\tclientAddressGot = true\n\t\t\t\t}\n\t\t\t\tif content.Metas[\"token\"] == \"123\" {\n\t\t\t\t\tret.Unchange = true\n\t\t\t\t} else {\n\t\t\t\t\tret.Reject = true\n\t\t\t\t\tret.RejectReason = \"invalid token\"\n\t\t\t\t}\n\t\t\t\treturn &ret\n\t\t\t}\n\t\t\tpluginServer := pluginpkg.NewHTTPPluginServer(localPort, newFunc, handler, nil)\n\n\t\t\tf.RunServer(\"\", pluginServer)\n\n\t\t\tserverConf := consts.DefaultServerConfig + fmt.Sprintf(`\n\t\t\t[[httpPlugins]]\n\t\t\tname = \"user-manager\"\n\t\t\taddr = \"127.0.0.1:%d\"\n\t\t\tpath = \"/handler\"\n\t\t\tops = [\"Login\"]\n\t\t\t`, localPort)\n\t\t\tclientConf := consts.DefaultClientConfig\n\n\t\t\tremotePort := f.AllocPort()\n\t\t\tclientConf += fmt.Sprintf(`\n\t\t\tmetadatas.token = \"123\"\n\n\t\t\t[[proxies]]\n\t\t\tname = \"tcp\"\n\t\t\ttype = \"tcp\"\n\t\t\tlocalPort = {{ .%s }}\n\t\t\tremotePort = %d\n\t\t\t`, framework.TCPEchoServerPort, remotePort)\n\n\t\t\tremotePort2 := f.AllocPort()\n\t\t\tinvalidTokenClientConf := consts.DefaultClientConfig + fmt.Sprintf(`\n\t\t\t[[proxies]]\n\t\t\tname = \"tcp2\"\n\t\t\ttype = \"tcp\"\n\t\t\tlocalPort = {{ .%s }}\n\t\t\tremotePort = %d\n\t\t\t`, framework.TCPEchoServerPort, remotePort2)\n\n\t\t\tf.RunProcesses(serverConf, []string{clientConf, invalidTokenClientConf})\n\n\t\t\tframework.NewRequestExpect(f).Port(remotePort).Ensure()\n\t\t\tframework.NewRequestExpect(f).Port(remotePort2).ExpectError(true).Ensure()\n\n\t\t\tframework.ExpectTrue(clientAddressGot)\n\t\t})\n\t})\n\n\tginkgo.Describe(\"NewProxy\", func() {\n\t\tnewFunc := func() *plugin.Request {\n\t\t\tvar r plugin.Request\n\t\t\tr.Content = &plugin.NewProxyContent{}\n\t\t\treturn &r\n\t\t}\n\n\t\tginkgo.It(\"Validate Info\", func() {\n\t\t\tlocalPort := f.AllocPort()\n\t\t\thandler := func(req *plugin.Request) *plugin.Response {\n\t\t\t\tvar ret plugin.Response\n\t\t\t\tcontent := req.Content.(*plugin.NewProxyContent)\n\t\t\t\tif content.ProxyName == \"tcp\" {\n\t\t\t\t\tret.Unchange = true\n\t\t\t\t} else {\n\t\t\t\t\tret.Reject = true\n\t\t\t\t}\n\t\t\t\treturn &ret\n\t\t\t}\n\t\t\tpluginServer := pluginpkg.NewHTTPPluginServer(localPort, newFunc, handler, nil)\n\n\t\t\tf.RunServer(\"\", pluginServer)\n\n\t\t\tserverConf := consts.DefaultServerConfig + fmt.Sprintf(`\n\t\t\t[[httpPlugins]]\n\t\t\tname = \"test\"\n\t\t\taddr = \"127.0.0.1:%d\"\n\t\t\tpath = \"/handler\"\n\t\t\tops = [\"NewProxy\"]\n\t\t\t`, localPort)\n\t\t\tclientConf := consts.DefaultClientConfig\n\n\t\t\tremotePort := f.AllocPort()\n\t\t\tclientConf += fmt.Sprintf(`\n\t\t\t[[proxies]]\n\t\t\tname = \"tcp\"\n\t\t\ttype = \"tcp\"\n\t\t\tlocalPort = {{ .%s }}\n\t\t\tremotePort = %d\n\t\t\t`, framework.TCPEchoServerPort, remotePort)\n\n\t\t\tf.RunProcesses(serverConf, []string{clientConf})\n\n\t\t\tframework.NewRequestExpect(f).Port(remotePort).Ensure()\n\t\t})\n\n\t\tginkgo.It(\"Modify RemotePort\", func() {\n\t\t\tlocalPort := f.AllocPort()\n\t\t\tremotePort := f.AllocPort()\n\t\t\thandler := func(req *plugin.Request) *plugin.Response {\n\t\t\t\tvar ret plugin.Response\n\t\t\t\tcontent := req.Content.(*plugin.NewProxyContent)\n\t\t\t\tcontent.RemotePort = remotePort\n\t\t\t\tret.Content = content\n\t\t\t\treturn &ret\n\t\t\t}\n\t\t\tpluginServer := pluginpkg.NewHTTPPluginServer(localPort, newFunc, handler, nil)\n\n\t\t\tf.RunServer(\"\", pluginServer)\n\n\t\t\tserverConf := consts.DefaultServerConfig + fmt.Sprintf(`\n\t\t\t[[httpPlugins]]\n\t\t\tname = \"test\"\n\t\t\taddr = \"127.0.0.1:%d\"\n\t\t\tpath = \"/handler\"\n\t\t\tops = [\"NewProxy\"]\n\t\t\t`, localPort)\n\t\t\tclientConf := consts.DefaultClientConfig\n\n\t\t\tclientConf += fmt.Sprintf(`\n\t\t\t[[proxies]]\n\t\t\tname = \"tcp\"\n\t\t\ttype = \"tcp\"\n\t\t\tlocalPort = {{ .%s }}\n\t\t\tremotePort = 0\n\t\t\t`, framework.TCPEchoServerPort)\n\n\t\t\tf.RunProcesses(serverConf, []string{clientConf})\n\n\t\t\tframework.NewRequestExpect(f).Port(remotePort).Ensure()\n\t\t})\n\t})\n\n\tginkgo.Describe(\"CloseProxy\", func() {\n\t\tnewFunc := func() *plugin.Request {\n\t\t\tvar r plugin.Request\n\t\t\tr.Content = &plugin.CloseProxyContent{}\n\t\t\treturn &r\n\t\t}\n\n\t\tginkgo.It(\"Validate Info\", func() {\n\t\t\tlocalPort := f.AllocPort()\n\t\t\tvar recordProxyName string\n\t\t\thandler := func(req *plugin.Request) *plugin.Response {\n\t\t\t\tvar ret plugin.Response\n\t\t\t\tcontent := req.Content.(*plugin.CloseProxyContent)\n\t\t\t\trecordProxyName = content.ProxyName\n\t\t\t\treturn &ret\n\t\t\t}\n\t\t\tpluginServer := pluginpkg.NewHTTPPluginServer(localPort, newFunc, handler, nil)\n\n\t\t\tf.RunServer(\"\", pluginServer)\n\n\t\t\tserverConf := consts.DefaultServerConfig + fmt.Sprintf(`\n\t\t\t[[httpPlugins]]\n\t\t\tname = \"test\"\n\t\t\taddr = \"127.0.0.1:%d\"\n\t\t\tpath = \"/handler\"\n\t\t\tops = [\"CloseProxy\"]\n\t\t\t`, localPort)\n\t\t\tclientConf := consts.DefaultClientConfig\n\n\t\t\tremotePort := f.AllocPort()\n\t\t\tclientConf += fmt.Sprintf(`\n\t\t\t[[proxies]]\n\t\t\tname = \"tcp\"\n\t\t\ttype = \"tcp\"\n\t\t\tlocalPort = {{ .%s }}\n\t\t\tremotePort = %d\n\t\t\t`, framework.TCPEchoServerPort, remotePort)\n\n\t\t\t_, clients := f.RunProcesses(serverConf, []string{clientConf})\n\n\t\t\tframework.NewRequestExpect(f).Port(remotePort).Ensure()\n\n\t\t\tfor _, c := range clients {\n\t\t\t\t_ = c.Stop()\n\t\t\t}\n\n\t\t\ttime.Sleep(1 * time.Second)\n\n\t\t\tframework.ExpectEqual(recordProxyName, \"tcp\")\n\t\t})\n\t})\n\n\tginkgo.Describe(\"Ping\", func() {\n\t\tnewFunc := func() *plugin.Request {\n\t\t\tvar r plugin.Request\n\t\t\tr.Content = &plugin.PingContent{}\n\t\t\treturn &r\n\t\t}\n\n\t\tginkgo.It(\"Validate Info\", func() {\n\t\t\tlocalPort := f.AllocPort()\n\n\t\t\tvar record string\n\t\t\thandler := func(req *plugin.Request) *plugin.Response {\n\t\t\t\tvar ret plugin.Response\n\t\t\t\tcontent := req.Content.(*plugin.PingContent)\n\t\t\t\trecord = content.PrivilegeKey\n\t\t\t\tret.Unchange = true\n\t\t\t\treturn &ret\n\t\t\t}\n\t\t\tpluginServer := pluginpkg.NewHTTPPluginServer(localPort, newFunc, handler, nil)\n\n\t\t\tf.RunServer(\"\", pluginServer)\n\n\t\t\tserverConf := consts.DefaultServerConfig + fmt.Sprintf(`\n\t\t\t[[httpPlugins]]\n\t\t\tname = \"test\"\n\t\t\taddr = \"127.0.0.1:%d\"\n\t\t\tpath = \"/handler\"\n\t\t\tops = [\"Ping\"]\n\t\t\t`, localPort)\n\n\t\t\tremotePort := f.AllocPort()\n\t\t\tclientConf := consts.DefaultClientConfig\n\t\t\tclientConf += fmt.Sprintf(`\n\t\t\ttransport.heartbeatInterval = 1\n\t\t\tauth.additionalScopes = [\"HeartBeats\"]\n\n\t\t\t[[proxies]]\n\t\t\tname = \"tcp\"\n\t\t\ttype = \"tcp\"\n\t\t\tlocalPort = {{ .%s }}\n\t\t\tremotePort = %d\n\t\t\t`, framework.TCPEchoServerPort, remotePort)\n\n\t\t\tf.RunProcesses(serverConf, []string{clientConf})\n\n\t\t\tframework.NewRequestExpect(f).Port(remotePort).Ensure()\n\n\t\t\ttime.Sleep(3 * time.Second)\n\t\t\tframework.ExpectNotEqual(\"\", record)\n\t\t})\n\t})\n\n\tginkgo.Describe(\"NewWorkConn\", func() {\n\t\tnewFunc := func() *plugin.Request {\n\t\t\tvar r plugin.Request\n\t\t\tr.Content = &plugin.NewWorkConnContent{}\n\t\t\treturn &r\n\t\t}\n\n\t\tginkgo.It(\"Validate Info\", func() {\n\t\t\tlocalPort := f.AllocPort()\n\n\t\t\tvar record string\n\t\t\thandler := func(req *plugin.Request) *plugin.Response {\n\t\t\t\tvar ret plugin.Response\n\t\t\t\tcontent := req.Content.(*plugin.NewWorkConnContent)\n\t\t\t\trecord = content.RunID\n\t\t\t\tret.Unchange = true\n\t\t\t\treturn &ret\n\t\t\t}\n\t\t\tpluginServer := pluginpkg.NewHTTPPluginServer(localPort, newFunc, handler, nil)\n\n\t\t\tf.RunServer(\"\", pluginServer)\n\n\t\t\tserverConf := consts.DefaultServerConfig + fmt.Sprintf(`\n\t\t\t[[httpPlugins]]\n\t\t\tname = \"test\"\n\t\t\taddr = \"127.0.0.1:%d\"\n\t\t\tpath = \"/handler\"\n\t\t\tops = [\"NewWorkConn\"]\n\t\t\t`, localPort)\n\n\t\t\tremotePort := f.AllocPort()\n\t\t\tclientConf := consts.DefaultClientConfig\n\t\t\tclientConf += fmt.Sprintf(`\n\t\t\t[[proxies]]\n\t\t\tname = \"tcp\"\n\t\t\ttype = \"tcp\"\n\t\t\tlocalPort = {{ .%s }}\n\t\t\tremotePort = %d\n\t\t\t`, framework.TCPEchoServerPort, remotePort)\n\n\t\t\tf.RunProcesses(serverConf, []string{clientConf})\n\n\t\t\tframework.NewRequestExpect(f).Port(remotePort).Ensure()\n\n\t\t\tframework.ExpectNotEqual(\"\", record)\n\t\t})\n\t})\n\n\tginkgo.Describe(\"NewUserConn\", func() {\n\t\tnewFunc := func() *plugin.Request {\n\t\t\tvar r plugin.Request\n\t\t\tr.Content = &plugin.NewUserConnContent{}\n\t\t\treturn &r\n\t\t}\n\t\tginkgo.It(\"Validate Info\", func() {\n\t\t\tlocalPort := f.AllocPort()\n\n\t\t\tvar record string\n\t\t\thandler := func(req *plugin.Request) *plugin.Response {\n\t\t\t\tvar ret plugin.Response\n\t\t\t\tcontent := req.Content.(*plugin.NewUserConnContent)\n\t\t\t\trecord = content.RemoteAddr\n\t\t\t\tret.Unchange = true\n\t\t\t\treturn &ret\n\t\t\t}\n\t\t\tpluginServer := pluginpkg.NewHTTPPluginServer(localPort, newFunc, handler, nil)\n\n\t\t\tf.RunServer(\"\", pluginServer)\n\n\t\t\tserverConf := consts.DefaultServerConfig + fmt.Sprintf(`\n\t\t\t[[httpPlugins]]\n\t\t\tname = \"test\"\n\t\t\taddr = \"127.0.0.1:%d\"\n\t\t\tpath = \"/handler\"\n\t\t\tops = [\"NewUserConn\"]\n\t\t\t`, localPort)\n\n\t\t\tremotePort := f.AllocPort()\n\t\t\tclientConf := consts.DefaultClientConfig\n\t\t\tclientConf += fmt.Sprintf(`\n\t\t\t[[proxies]]\n\t\t\tname = \"tcp\"\n\t\t\ttype = \"tcp\"\n\t\t\tlocalPort = {{ .%s }}\n\t\t\tremotePort = %d\n\t\t\t`, framework.TCPEchoServerPort, remotePort)\n\n\t\t\tf.RunProcesses(serverConf, []string{clientConf})\n\n\t\t\tframework.NewRequestExpect(f).Port(remotePort).Ensure()\n\n\t\t\tframework.ExpectNotEqual(\"\", record)\n\t\t})\n\t})\n\n\tginkgo.Describe(\"HTTPS Protocol\", func() {\n\t\tnewFunc := func() *plugin.Request {\n\t\t\tvar r plugin.Request\n\t\t\tr.Content = &plugin.NewUserConnContent{}\n\t\t\treturn &r\n\t\t}\n\t\tginkgo.It(\"Validate Login Info, disable tls verify\", func() {\n\t\t\tlocalPort := f.AllocPort()\n\n\t\t\tvar record string\n\t\t\thandler := func(req *plugin.Request) *plugin.Response {\n\t\t\t\tvar ret plugin.Response\n\t\t\t\tcontent := req.Content.(*plugin.NewUserConnContent)\n\t\t\t\trecord = content.RemoteAddr\n\t\t\t\tret.Unchange = true\n\t\t\t\treturn &ret\n\t\t\t}\n\t\t\ttlsConfig, err := transport.NewServerTLSConfig(\"\", \"\", \"\")\n\t\t\tframework.ExpectNoError(err)\n\t\t\tpluginServer := pluginpkg.NewHTTPPluginServer(localPort, newFunc, handler, tlsConfig)\n\n\t\t\tf.RunServer(\"\", pluginServer)\n\n\t\t\tserverConf := consts.DefaultServerConfig + fmt.Sprintf(`\n\t\t\t[[httpPlugins]]\n\t\t\tname = \"test\"\n\t\t\taddr = \"https://127.0.0.1:%d\"\n\t\t\tpath = \"/handler\"\n\t\t\tops = [\"NewUserConn\"]\n\t\t\t`, localPort)\n\n\t\t\tremotePort := f.AllocPort()\n\t\t\tclientConf := consts.DefaultClientConfig\n\t\t\tclientConf += fmt.Sprintf(`\n\t\t\t[[proxies]]\n\t\t\tname = \"tcp\"\n\t\t\ttype = \"tcp\"\n\t\t\tlocalPort = {{ .%s }}\n\t\t\tremotePort = %d\n\t\t\t`, framework.TCPEchoServerPort, remotePort)\n\n\t\t\tf.RunProcesses(serverConf, []string{clientConf})\n\n\t\t\tframework.NewRequestExpect(f).Port(remotePort).Ensure()\n\n\t\t\tframework.ExpectNotEqual(\"\", record)\n\t\t})\n\t})\n})\n"
  },
  {
    "path": "web/frpc/.gitignore",
    "content": "# Logs\nlogs\n*.log\nnpm-debug.log*\nyarn-debug.log*\nyarn-error.log*\npnpm-debug.log*\nlerna-debug.log*\n\nnode_modules\n.DS_Store\ndist\ndist-ssr\ncoverage\n*.local\n\n/cypress/videos/\n/cypress/screenshots/\n\n# Editor directories and files\n.vscode/*\n!.vscode/extensions.json\n.idea\n*.suo\n*.ntvs*\n*.njsproj\n*.sln\n*.sw?\n"
  },
  {
    "path": "web/frpc/.prettierrc.json",
    "content": "{\n  \"tabWidth\": 2,\n  \"semi\": false,\n  \"singleQuote\": true\n}\n"
  },
  {
    "path": "web/frpc/Makefile",
    "content": ".PHONY: dist install build preview lint\n\ninstall:\n\t@cd .. && npm install\n\nbuild: install\n\t@npm run build\n\ndev:\n\t@npm run dev\n\npreview:\n\t@npm run preview\n\nlint:\n\t@npm run lint\n"
  },
  {
    "path": "web/frpc/README.md",
    "content": "# frpc-dashboard\n\n## Project Setup\n\n```sh\nyarn install\n```\n\n### Compile and Hot-Reload for Development\n\n```sh\nmake dev\n```\n\n### Type-Check, Compile and Minify for Production\n\n```sh\nmake build\n```\n\n### Lint with [ESLint](https://eslint.org/)\n\n```sh\nmake lint\n```\n"
  },
  {
    "path": "web/frpc/auto-imports.d.ts",
    "content": "/* eslint-disable */\n/* prettier-ignore */\n// @ts-nocheck\n// noinspection JSUnusedGlobalSymbols\n// Generated by unplugin-auto-import\nexport {}\ndeclare global {\n\n}\n"
  },
  {
    "path": "web/frpc/components.d.ts",
    "content": "/* eslint-disable */\n/* prettier-ignore */\n// @ts-nocheck\n// Generated by unplugin-vue-components\n// Read more: https://github.com/vuejs/core/pull/3399\nexport {}\n\ndeclare module 'vue' {\n  export interface GlobalComponents {\n    ConfigField: typeof import('./src/components/ConfigField.vue')['default']\n    ConfigSection: typeof import('./src/components/ConfigSection.vue')['default']\n    ElDialog: typeof import('element-plus/es')['ElDialog']\n    ElForm: typeof import('element-plus/es')['ElForm']\n    ElFormItem: typeof import('element-plus/es')['ElFormItem']\n    ElIcon: typeof import('element-plus/es')['ElIcon']\n    ElInput: typeof import('element-plus/es')['ElInput']\n    ElPopover: typeof import('element-plus/es')['ElPopover']\n    ElRadio: typeof import('element-plus/es')['ElRadio']\n    ElRadioGroup: typeof import('element-plus/es')['ElRadioGroup']\n    ElSwitch: typeof import('element-plus/es')['ElSwitch']\n    KeyValueEditor: typeof import('./src/components/KeyValueEditor.vue')['default']\n    ProxyAuthSection: typeof import('./src/components/proxy-form/ProxyAuthSection.vue')['default']\n    ProxyBackendSection: typeof import('./src/components/proxy-form/ProxyBackendSection.vue')['default']\n    ProxyBaseSection: typeof import('./src/components/proxy-form/ProxyBaseSection.vue')['default']\n    ProxyCard: typeof import('./src/components/ProxyCard.vue')['default']\n    ProxyFormLayout: typeof import('./src/components/proxy-form/ProxyFormLayout.vue')['default']\n    ProxyHealthSection: typeof import('./src/components/proxy-form/ProxyHealthSection.vue')['default']\n    ProxyHttpSection: typeof import('./src/components/proxy-form/ProxyHttpSection.vue')['default']\n    ProxyLoadBalanceSection: typeof import('./src/components/proxy-form/ProxyLoadBalanceSection.vue')['default']\n    ProxyMetadataSection: typeof import('./src/components/proxy-form/ProxyMetadataSection.vue')['default']\n    ProxyNatSection: typeof import('./src/components/proxy-form/ProxyNatSection.vue')['default']\n    ProxyRemoteSection: typeof import('./src/components/proxy-form/ProxyRemoteSection.vue')['default']\n    ProxyTransportSection: typeof import('./src/components/proxy-form/ProxyTransportSection.vue')['default']\n    RouterLink: typeof import('vue-router')['RouterLink']\n    RouterView: typeof import('vue-router')['RouterView']\n    StatusPills: typeof import('./src/components/StatusPills.vue')['default']\n    StringListEditor: typeof import('./src/components/StringListEditor.vue')['default']\n    VisitorBaseSection: typeof import('./src/components/visitor-form/VisitorBaseSection.vue')['default']\n    VisitorConnectionSection: typeof import('./src/components/visitor-form/VisitorConnectionSection.vue')['default']\n    VisitorFormLayout: typeof import('./src/components/visitor-form/VisitorFormLayout.vue')['default']\n    VisitorTransportSection: typeof import('./src/components/visitor-form/VisitorTransportSection.vue')['default']\n    VisitorXtcpSection: typeof import('./src/components/visitor-form/VisitorXtcpSection.vue')['default']\n  }\n  export interface ComponentCustomProperties {\n    vLoading: typeof import('element-plus/es')['ElLoadingDirective']\n  }\n}\n"
  },
  {
    "path": "web/frpc/embed.go",
    "content": "//go:build !noweb\n\npackage frpc\n\nimport (\n\t\"embed\"\n\n\t\"github.com/fatedier/frp/assets\"\n)\n\n//go:embed dist\nvar EmbedFS embed.FS\n\nfunc init() {\n\tassets.Register(EmbedFS)\n}\n"
  },
  {
    "path": "web/frpc/embed_stub.go",
    "content": "//go:build noweb\n\npackage frpc\n"
  },
  {
    "path": "web/frpc/env.d.ts",
    "content": "/// <reference types=\"vite/client\" />\n"
  },
  {
    "path": "web/frpc/eslint.config.js",
    "content": "import pluginVue from 'eslint-plugin-vue'\nimport vueTsEslintConfig from '@vue/eslint-config-typescript'\nimport skipFormatting from '@vue/eslint-config-prettier/skip-formatting'\n\nexport default [\n  {\n    name: 'app/files-to-lint',\n    files: ['**/*.{ts,mts,tsx,vue}'],\n  },\n  {\n    name: 'app/files-to-ignore',\n    ignores: ['**/dist/**', '**/dist-ssr/**', '**/coverage/**'],\n  },\n  ...pluginVue.configs['flat/essential'],\n  ...vueTsEslintConfig(),\n  {\n    rules: {\n      '@typescript-eslint/no-explicit-any': 'off',\n      '@typescript-eslint/no-unused-vars': [\n        'warn',\n        {\n          argsIgnorePattern: '^_',\n          varsIgnorePattern: '^_',\n          caughtErrorsIgnorePattern: '^_',\n        },\n      ],\n      'vue/multi-word-component-names': [\n        'error',\n        {\n          ignores: ['Overview'],\n        },\n      ],\n    },\n  },\n  skipFormatting,\n]\n"
  },
  {
    "path": "web/frpc/index.html",
    "content": "<!DOCTYPE html>\n<html lang=\"en\">\n\n<head>\n    <meta charset=\"utf-8\">\n    <meta name=\"viewport\" content=\"width=device-width, initial-scale=1.0\">\n    <title>frp client</title>\n</head>\n\n<body>\n    <div id=\"app\"></div>\n    <script type=\"module\" src=\"/src/main.ts\"></script>\n</body>\n\n</html>\n"
  },
  {
    "path": "web/frpc/package.json",
    "content": "{\n  \"name\": \"frpc-dashboard\",\n  \"version\": \"0.0.1\",\n  \"private\": true,\n  \"type\": \"module\",\n  \"scripts\": {\n    \"dev\": \"vite\",\n    \"build\": \"run-p type-check build-only\",\n    \"preview\": \"vite preview\",\n    \"build-only\": \"vite build\",\n    \"type-check\": \"vue-tsc --noEmit\",\n    \"lint\": \"eslint --fix\"\n  },\n  \"dependencies\": {\n    \"element-plus\": \"^2.13.0\",\n    \"pinia\": \"^3.0.4\",\n    \"vue\": \"^3.5.26\",\n    \"vue-router\": \"^4.6.4\"\n  },\n  \"devDependencies\": {\n    \"@types/node\": \"24\",\n    \"@vitejs/plugin-vue\": \"^6.0.3\",\n    \"@vue/eslint-config-prettier\": \"^10.2.0\",\n    \"@vue/eslint-config-typescript\": \"^14.7.0\",\n    \"@vue/tsconfig\": \"^0.8.1\",\n    \"@vueuse/core\": \"^14.1.0\",\n    \"eslint\": \"^9.39.0\",\n    \"eslint-plugin-vue\": \"^9.33.0\",\n    \"npm-run-all\": \"^4.1.5\",\n    \"prettier\": \"^3.7.4\",\n    \"sass\": \"^1.97.2\",\n    \"terser\": \"^5.44.1\",\n    \"typescript\": \"^5.9.3\",\n    \"unplugin-auto-import\": \"^0.17.5\",\n    \"unplugin-element-plus\": \"^0.11.2\",\n    \"unplugin-vue-components\": \"^0.26.0\",\n    \"vite\": \"^7.3.0\",\n    \"vite-svg-loader\": \"^5.1.0\",\n    \"vue-tsc\": \"^3.2.2\"\n  }\n}\n"
  },
  {
    "path": "web/frpc/src/App.vue",
    "content": "<template>\n  <div id=\"app\">\n    <header class=\"header\">\n      <div class=\"header-content\">\n        <div class=\"brand-section\">\n          <button v-if=\"isMobile\" class=\"hamburger-btn\" @click=\"toggleSidebar\" aria-label=\"Toggle menu\">\n            <span class=\"hamburger-icon\">&#9776;</span>\n          </button>\n          <div class=\"logo-wrapper\">\n            <LogoIcon class=\"logo-icon\" />\n          </div>\n          <span class=\"divider\">/</span>\n          <span class=\"brand-name\">frp</span>\n          <span class=\"badge\">Client</span>\n        </div>\n\n        <div class=\"header-controls\">\n          <a\n            class=\"github-link\"\n            href=\"https://github.com/fatedier/frp\"\n            target=\"_blank\"\n            aria-label=\"GitHub\"\n          >\n            <GitHubIcon class=\"github-icon\" />\n          </a>\n          <el-switch\n            v-model=\"isDark\"\n            inline-prompt\n            :active-icon=\"Moon\"\n            :inactive-icon=\"Sunny\"\n            class=\"theme-switch\"\n          />\n        </div>\n      </div>\n    </header>\n\n    <div class=\"layout\">\n      <!-- Mobile overlay -->\n      <div\n        v-if=\"isMobile && sidebarOpen\"\n        class=\"sidebar-overlay\"\n        @click=\"closeSidebar\"\n      />\n\n      <aside class=\"sidebar\" :class=\"{ 'mobile-open': isMobile && sidebarOpen }\">\n        <nav class=\"sidebar-nav\">\n          <router-link\n            to=\"/proxies\"\n            class=\"sidebar-link\"\n            :class=\"{ active: route.path.startsWith('/proxies') }\"\n            @click=\"closeSidebar\"\n          >\n            Proxies\n          </router-link>\n          <router-link\n            to=\"/visitors\"\n            class=\"sidebar-link\"\n            :class=\"{ active: route.path.startsWith('/visitors') }\"\n            @click=\"closeSidebar\"\n          >\n            Visitors\n          </router-link>\n          <router-link\n            to=\"/config\"\n            class=\"sidebar-link\"\n            :class=\"{ active: route.path === '/config' }\"\n            @click=\"closeSidebar\"\n          >\n            Config\n          </router-link>\n        </nav>\n      </aside>\n\n      <main id=\"content\">\n        <router-view></router-view>\n      </main>\n    </div>\n  </div>\n</template>\n\n<script setup lang=\"ts\">\nimport { ref, watch } from 'vue'\nimport { useRoute } from 'vue-router'\nimport { useDark } from '@vueuse/core'\nimport { Moon, Sunny } from '@element-plus/icons-vue'\nimport GitHubIcon from './assets/icons/github.svg?component'\nimport LogoIcon from './assets/icons/logo.svg?component'\nimport { useResponsive } from './composables/useResponsive'\n\nconst route = useRoute()\nconst isDark = useDark()\nconst { isMobile } = useResponsive()\n\nconst sidebarOpen = ref(false)\n\nconst toggleSidebar = () => {\n  sidebarOpen.value = !sidebarOpen.value\n}\n\nconst closeSidebar = () => {\n  sidebarOpen.value = false\n}\n\n// Auto-close sidebar on route change\nwatch(() => route.path, () => {\n  if (isMobile.value) {\n    closeSidebar()\n  }\n})\n</script>\n\n<style lang=\"scss\">\nbody {\n  margin: 0;\n  font-family: ui-sans-serif, -apple-system, system-ui, Segoe UI, Helvetica,\n    Arial, sans-serif;\n}\n\n*,\n:after,\n:before {\n  box-sizing: border-box;\n  -webkit-tap-highlight-color: transparent;\n}\n\nhtml, body {\n  height: 100%;\n  overflow: hidden;\n}\n\n#app {\n  height: 100vh;\n  height: 100dvh;\n  display: flex;\n  flex-direction: column;\n  background-color: $color-bg-secondary;\n}\n\n// Header\n.header {\n  flex-shrink: 0;\n  background: $color-bg-primary;\n  border-bottom: 1px solid $color-border-light;\n  height: $header-height;\n}\n\n.header-content {\n  display: flex;\n  align-items: center;\n  justify-content: space-between;\n  height: 100%;\n  padding: 0 $spacing-xl;\n}\n\n.brand-section {\n  display: flex;\n  align-items: center;\n  gap: $spacing-md;\n}\n\n.logo-wrapper {\n  display: flex;\n  align-items: center;\n}\n\n.logo-icon {\n  width: 28px;\n  height: 28px;\n}\n\n.divider {\n  color: $color-border;\n  font-size: 22px;\n  font-weight: 200;\n}\n\n.brand-name {\n  font-weight: $font-weight-semibold;\n  font-size: $font-size-xl;\n  color: $color-text-primary;\n  letter-spacing: -0.5px;\n}\n\n.badge {\n  font-size: $font-size-xs;\n  font-weight: $font-weight-medium;\n  color: $color-text-muted;\n  background: $color-bg-muted;\n  padding: 2px 8px;\n  border-radius: 4px;\n}\n\n.header-controls {\n  display: flex;\n  align-items: center;\n  gap: 16px;\n}\n\n.github-link {\n  @include flex-center;\n  width: 28px;\n  height: 28px;\n  border-radius: $radius-sm;\n  color: $color-text-secondary;\n  transition: all $transition-fast;\n\n  &:hover {\n    background: $color-bg-hover;\n    color: $color-text-primary;\n  }\n}\n\n.github-icon {\n  width: 18px;\n  height: 18px;\n}\n\n.theme-switch {\n  --el-switch-on-color: #2c2c3a;\n  --el-switch-off-color: #f2f2f2;\n  --el-switch-border-color: var(--color-border-light);\n}\n\nhtml.dark .theme-switch {\n  --el-switch-off-color: #333;\n}\n\n.theme-switch .el-switch__core .el-switch__inner .el-icon {\n  color: #909399 !important;\n}\n\n// Layout\n.layout {\n  flex: 1;\n  display: flex;\n  overflow: hidden;\n}\n\n.sidebar {\n  width: $sidebar-width;\n  flex-shrink: 0;\n  border-right: 1px solid $color-border-light;\n  padding: $spacing-lg $spacing-md;\n  overflow-y: auto;\n  display: flex;\n  flex-direction: column;\n}\n\n.sidebar-nav {\n  @include flex-column;\n  gap: 2px;\n}\n\n.sidebar-link {\n  display: block;\n  text-decoration: none;\n  font-size: $font-size-lg;\n  color: $color-text-secondary;\n  padding: 10px $spacing-md;\n  border-radius: $radius-sm;\n  transition: all $transition-fast;\n\n  &:hover {\n    color: $color-text-primary;\n    background: $color-bg-hover;\n  }\n\n  &.active {\n    color: $color-text-primary;\n    background: $color-bg-hover;\n    font-weight: $font-weight-medium;\n  }\n}\n\n// Hamburger button (mobile only)\n.hamburger-btn {\n  @include flex-center;\n  width: 36px;\n  height: 36px;\n  border: none;\n  border-radius: $radius-sm;\n  background: transparent;\n  cursor: pointer;\n  padding: 0;\n  transition: background $transition-fast;\n\n  &:hover {\n    background: $color-bg-hover;\n  }\n}\n\n.hamburger-icon {\n  font-size: 20px;\n  line-height: 1;\n  color: $color-text-primary;\n}\n\n// Mobile overlay\n.sidebar-overlay {\n  position: fixed;\n  inset: 0;\n  background: rgba(0, 0, 0, 0.5);\n  z-index: 99;\n}\n\n#content {\n  flex: 1;\n  min-width: 0;\n  overflow: hidden;\n  background: $color-bg-primary;\n}\n\n// Common page styles\n.page-title {\n  font-size: $font-size-xl + 2px;\n  font-weight: $font-weight-semibold;\n  color: $color-text-primary;\n  margin: 0;\n}\n\n.page-subtitle {\n  font-size: $font-size-md;\n  color: $color-text-muted;\n  margin: $spacing-sm 0 0;\n}\n\n.icon-btn {\n  @include flex-center;\n  width: 32px;\n  height: 32px;\n  border: none;\n  border-radius: $radius-sm;\n  background: transparent;\n  color: $color-text-muted;\n  cursor: pointer;\n  transition: all $transition-fast;\n\n  &:hover {\n    background: $color-bg-hover;\n    color: $color-text-primary;\n  }\n}\n\n.search-input {\n  width: 200px;\n\n  .el-input__wrapper {\n    border-radius: 10px;\n    background: $color-bg-tertiary;\n    box-shadow: 0 0 0 1px $color-border inset;\n\n    &.is-focus {\n      box-shadow: 0 0 0 1px $color-text-light inset;\n    }\n  }\n\n  .el-input__inner {\n    color: $color-text-primary;\n  }\n\n  .el-input__prefix {\n    color: $color-text-muted;\n  }\n\n  @include mobile {\n    flex: 1;\n    width: auto;\n  }\n}\n\n// Element Plus global overrides\n.el-button {\n  font-weight: $font-weight-medium;\n}\n\n.el-tag {\n  font-weight: $font-weight-medium;\n}\n\n.el-switch {\n  --el-switch-on-color: #606266;\n  --el-switch-off-color: #dcdfe6;\n}\n\nhtml.dark .el-switch {\n  --el-switch-on-color: #b0b0b0;\n  --el-switch-off-color: #404040;\n}\n\n.el-radio {\n  --el-radio-text-color: var(--color-text-primary) !important;\n  --el-radio-input-border-color-hover: #606266 !important;\n  --el-color-primary: #606266 !important;\n}\n\n.el-form-item {\n  margin-bottom: 16px;\n}\n\n.el-loading-mask {\n  border-radius: $radius-md;\n}\n\n// Select overrides\n.el-select__wrapper {\n  border-radius: $radius-md !important;\n  box-shadow: 0 0 0 1px $color-border-light inset !important;\n  transition: all $transition-fast;\n\n  &:hover {\n    box-shadow: 0 0 0 1px $color-border inset !important;\n  }\n\n  &.is-focused {\n    box-shadow: 0 0 0 1px $color-border inset !important;\n  }\n}\n\n.el-select-dropdown {\n  border-radius: 12px !important;\n  border: 1px solid $color-border-light !important;\n  box-shadow: 0 10px 25px -5px rgba(0, 0, 0, 0.1),\n              0 8px 10px -6px rgba(0, 0, 0, 0.1) !important;\n  padding: 4px !important;\n}\n\n.el-select-dropdown__item {\n  border-radius: $radius-sm;\n  margin: 2px 0;\n  transition: background $transition-fast;\n\n  &.is-selected {\n    color: $color-text-primary;\n    font-weight: $font-weight-medium;\n  }\n}\n\n// Input overrides\n.el-input__wrapper {\n  border-radius: $radius-md !important;\n  box-shadow: 0 0 0 1px $color-border-light inset !important;\n  transition: all $transition-fast;\n\n  &:hover {\n    box-shadow: 0 0 0 1px $color-border inset !important;\n  }\n\n  &.is-focus {\n    box-shadow: 0 0 0 1px $color-border inset !important;\n  }\n}\n\n// Status pill (shared)\n.status-pill {\n  display: inline-flex;\n  align-items: center;\n  gap: 5px;\n  font-size: $font-size-sm;\n  font-weight: $font-weight-medium;\n  padding: 3px 10px;\n  border-radius: 10px;\n  text-transform: capitalize;\n\n  &.running {\n    background: rgba(103, 194, 58, 0.1);\n    color: #67c23a;\n  }\n\n  &.error {\n    background: rgba(245, 108, 108, 0.1);\n    color: #f56c6c;\n  }\n\n  &.waiting {\n    background: rgba(230, 162, 60, 0.1);\n    color: #e6a23c;\n  }\n\n  &.disabled {\n    background: $color-bg-muted;\n    color: $color-text-light;\n  }\n\n  .status-dot {\n    width: 6px;\n    height: 6px;\n    border-radius: 50%;\n    background: currentColor;\n  }\n}\n\n// Mobile\n@include mobile {\n  .header-content {\n    padding: 0 $spacing-lg;\n  }\n\n  .sidebar {\n    position: fixed;\n    top: $header-height;\n    left: 0;\n    bottom: 0;\n    z-index: 100;\n    background: $color-bg-primary;\n    transform: translateX(-100%);\n    transition: transform 0.25s cubic-bezier(0.4, 0, 0.2, 1);\n    border-right: 1px solid $color-border-light;\n\n    &.mobile-open {\n      transform: translateX(0);\n    }\n  }\n\n  .sidebar-nav {\n    flex-direction: column;\n    gap: 2px;\n  }\n\n  #content {\n    width: 100%;\n  }\n\n  // Select dropdown overflow prevention\n  .el-select-dropdown {\n    max-width: calc(100vw - 32px);\n  }\n}\n</style>\n"
  },
  {
    "path": "web/frpc/src/api/frpc.ts",
    "content": "import { http } from './http'\nimport type {\n  StatusResponse,\n  ProxyListResp,\n  ProxyDefinition,\n  VisitorListResp,\n  VisitorDefinition,\n} from '../types'\n\nexport const getStatus = () => {\n  return http.get<StatusResponse>('/api/status')\n}\n\nexport const getConfig = () => {\n  return http.get<string>('/api/config')\n}\n\nexport const putConfig = (content: string) => {\n  return http.put<void>('/api/config', content)\n}\n\nexport const reloadConfig = () => {\n  return http.get<void>('/api/reload')\n}\n\n// Config lookup API (any source)\nexport const getProxyConfig = (name: string) => {\n  return http.get<ProxyDefinition>(\n    `/api/proxy/${encodeURIComponent(name)}/config`,\n  )\n}\n\nexport const getVisitorConfig = (name: string) => {\n  return http.get<VisitorDefinition>(\n    `/api/visitor/${encodeURIComponent(name)}/config`,\n  )\n}\n\n// Store API - Proxies\nexport const listStoreProxies = () => {\n  return http.get<ProxyListResp>('/api/store/proxies')\n}\n\nexport const getStoreProxy = (name: string) => {\n  return http.get<ProxyDefinition>(\n    `/api/store/proxies/${encodeURIComponent(name)}`,\n  )\n}\n\nexport const createStoreProxy = (config: ProxyDefinition) => {\n  return http.post<ProxyDefinition>('/api/store/proxies', config)\n}\n\nexport const updateStoreProxy = (name: string, config: ProxyDefinition) => {\n  return http.put<ProxyDefinition>(\n    `/api/store/proxies/${encodeURIComponent(name)}`,\n    config,\n  )\n}\n\nexport const deleteStoreProxy = (name: string) => {\n  return http.delete<void>(`/api/store/proxies/${encodeURIComponent(name)}`)\n}\n\n// Store API - Visitors\nexport const listStoreVisitors = () => {\n  return http.get<VisitorListResp>('/api/store/visitors')\n}\n\nexport const getStoreVisitor = (name: string) => {\n  return http.get<VisitorDefinition>(\n    `/api/store/visitors/${encodeURIComponent(name)}`,\n  )\n}\n\nexport const createStoreVisitor = (config: VisitorDefinition) => {\n  return http.post<VisitorDefinition>('/api/store/visitors', config)\n}\n\nexport const updateStoreVisitor = (\n  name: string,\n  config: VisitorDefinition,\n) => {\n  return http.put<VisitorDefinition>(\n    `/api/store/visitors/${encodeURIComponent(name)}`,\n    config,\n  )\n}\n\nexport const deleteStoreVisitor = (name: string) => {\n  return http.delete<void>(`/api/store/visitors/${encodeURIComponent(name)}`)\n}\n"
  },
  {
    "path": "web/frpc/src/api/http.ts",
    "content": "// http.ts - Base HTTP client\n\nclass HTTPError extends Error {\n  status: number\n  statusText: string\n\n  constructor(status: number, statusText: string, message?: string) {\n    super(message || statusText)\n    this.status = status\n    this.statusText = statusText\n  }\n}\n\nasync function request<T>(url: string, options: RequestInit = {}): Promise<T> {\n  const defaultOptions: RequestInit = {\n    credentials: 'include',\n  }\n\n  const response = await fetch(url, { ...defaultOptions, ...options })\n\n  if (!response.ok) {\n    throw new HTTPError(\n      response.status,\n      response.statusText,\n      `HTTP ${response.status}`,\n    )\n  }\n\n  // Handle empty response (e.g. 204 No Content)\n  if (response.status === 204) {\n    return {} as T\n  }\n\n  const contentType = response.headers.get('content-type')\n  if (contentType && contentType.includes('application/json')) {\n    return response.json()\n  }\n  return response.text() as unknown as T\n}\n\nexport const http = {\n  get: <T>(url: string, options?: RequestInit) =>\n    request<T>(url, { ...options, method: 'GET' }),\n  post: <T>(url: string, body?: any, options?: RequestInit) => {\n    const headers: HeadersInit = { ...options?.headers }\n    let requestBody = body\n\n    if (\n      body &&\n      typeof body === 'object' &&\n      !(body instanceof FormData) &&\n      !(body instanceof Blob)\n    ) {\n      if (!('Content-Type' in headers)) {\n        ;(headers as any)['Content-Type'] = 'application/json'\n      }\n      requestBody = JSON.stringify(body)\n    }\n\n    return request<T>(url, {\n      ...options,\n      method: 'POST',\n      headers,\n      body: requestBody,\n    })\n  },\n  put: <T>(url: string, body?: any, options?: RequestInit) => {\n    const headers: HeadersInit = { ...options?.headers }\n    let requestBody = body\n\n    if (\n      body &&\n      typeof body === 'object' &&\n      !(body instanceof FormData) &&\n      !(body instanceof Blob)\n    ) {\n      if (!('Content-Type' in headers)) {\n        ;(headers as any)['Content-Type'] = 'application/json'\n      }\n      requestBody = JSON.stringify(body)\n    }\n\n    return request<T>(url, {\n      ...options,\n      method: 'PUT',\n      headers,\n      body: requestBody,\n    })\n  },\n  delete: <T>(url: string, options?: RequestInit) =>\n    request<T>(url, { ...options, method: 'DELETE' }),\n}\n"
  },
  {
    "path": "web/frpc/src/assets/css/_form-layout.scss",
    "content": "@use './mixins' as *;\n\n/* Shared form layout styles for proxy/visitor form sections */\n.field-row {\n  display: grid;\n  gap: 16px;\n  align-items: start;\n}\n\n.field-row.two-col {\n  grid-template-columns: 1fr 1fr;\n}\n\n.field-row.three-col {\n  grid-template-columns: 1fr 1fr 1fr;\n}\n\n.field-grow {\n  min-width: 0;\n}\n\n.switch-field :deep(.el-form-item__content) {\n  min-height: 32px;\n  display: flex;\n  align-items: center;\n}\n\n@include mobile {\n  .field-row.two-col,\n  .field-row.three-col {\n    grid-template-columns: 1fr;\n  }\n}\n"
  },
  {
    "path": "web/frpc/src/assets/css/_index.scss",
    "content": "@forward './variables';\n@forward './mixins';\n"
  },
  {
    "path": "web/frpc/src/assets/css/_mixins.scss",
    "content": "@use './variables' as vars;\n\n@mixin mobile {\n  @media (max-width: #{vars.$breakpoint-mobile - 1px}) {\n    @content;\n  }\n}\n\n@mixin flex-center {\n  display: flex;\n  align-items: center;\n  justify-content: center;\n}\n\n@mixin flex-column {\n  display: flex;\n  flex-direction: column;\n}\n\n@mixin page-scroll {\n  height: 100%;\n  overflow-y: auto;\n  padding: vars.$spacing-xl 40px;\n\n  > * {\n    max-width: 960px;\n    margin: 0 auto;\n  }\n\n  @include mobile {\n    padding: vars.$spacing-xl;\n  }\n}\n\n@mixin custom-scrollbar {\n  &::-webkit-scrollbar {\n    width: 6px;\n    height: 6px;\n  }\n\n  &::-webkit-scrollbar-track {\n    background: transparent;\n  }\n\n  &::-webkit-scrollbar-thumb {\n    background: #d1d1d1;\n    border-radius: 3px;\n  }\n}\n"
  },
  {
    "path": "web/frpc/src/assets/css/_variables.scss",
    "content": "// Typography\n$font-size-xs: 11px;\n$font-size-sm: 13px;\n$font-size-md: 14px;\n$font-size-lg: 15px;\n$font-size-xl: 18px;\n\n$font-weight-normal: 400;\n$font-weight-medium: 500;\n$font-weight-semibold: 600;\n\n// Colors - Text\n$color-text-primary: var(--color-text-primary);\n$color-text-secondary: var(--color-text-secondary);\n$color-text-muted: var(--color-text-muted);\n$color-text-light: var(--color-text-light);\n\n// Colors - Background\n$color-bg-primary: var(--color-bg-primary);\n$color-bg-secondary: var(--color-bg-secondary);\n$color-bg-tertiary: var(--color-bg-tertiary);\n$color-bg-muted: var(--color-bg-muted);\n$color-bg-hover: var(--color-bg-hover);\n$color-bg-active: var(--color-bg-active);\n\n// Colors - Border\n$color-border: var(--color-border);\n$color-border-light: var(--color-border-light);\n$color-border-lighter: var(--color-border-lighter);\n\n// Colors - Status\n$color-primary: var(--color-primary);\n$color-danger: var(--color-danger);\n$color-danger-dark: var(--color-danger-dark);\n$color-danger-light: var(--color-danger-light);\n\n// Colors - Button\n$color-btn-primary: var(--color-btn-primary);\n$color-btn-primary-hover: var(--color-btn-primary-hover);\n\n// Spacing\n$spacing-xs: 4px;\n$spacing-sm: 8px;\n$spacing-md: 12px;\n$spacing-lg: 16px;\n$spacing-xl: 20px;\n\n// Border Radius\n$radius-sm: 6px;\n$radius-md: 8px;\n\n// Transitions\n$transition-fast: 0.15s ease;\n$transition-medium: 0.2s ease;\n\n// Layout\n$header-height: 50px;\n$sidebar-width: 200px;\n\n// Breakpoints\n$breakpoint-mobile: 768px;\n"
  },
  {
    "path": "web/frpc/src/assets/css/dark.css",
    "content": "/* Dark mode styles */\nhtml.dark {\n  --el-bg-color: #212121;\n  --el-bg-color-page: #181818;\n  --el-bg-color-overlay: #303030;\n  --el-fill-color-blank: #212121;\n  --el-border-color: #404040;\n  --el-border-color-light: #353535;\n  --el-border-color-lighter: #2a2a2a;\n  --el-text-color-primary: #e5e7eb;\n  --el-text-color-secondary: #888888;\n  --el-text-color-placeholder: #afafaf;\n  background-color: #212121;\n  color-scheme: dark;\n}\n\n/* Scrollbar */\nhtml.dark ::-webkit-scrollbar {\n  width: 6px;\n  height: 6px;\n}\n\nhtml.dark ::-webkit-scrollbar-track {\n  background: #303030;\n}\n\nhtml.dark ::-webkit-scrollbar-thumb {\n  background: #404040;\n  border-radius: 3px;\n}\n\nhtml.dark ::-webkit-scrollbar-thumb:hover {\n  background: #505050;\n}\n\n/* Form */\nhtml.dark .el-form-item__label {\n  color: #e5e7eb;\n}\n\n/* Input */\nhtml.dark .el-input__wrapper {\n  background: var(--color-bg-input);\n  box-shadow: 0 0 0 1px #404040 inset;\n}\n\nhtml.dark .el-input__wrapper:hover {\n  box-shadow: 0 0 0 1px #505050 inset;\n}\n\nhtml.dark .el-input__wrapper.is-focus {\n  box-shadow: 0 0 0 1px var(--el-color-primary) inset;\n}\n\nhtml.dark .el-input__inner {\n  color: #e5e7eb;\n}\n\nhtml.dark .el-input__inner::placeholder {\n  color: #afafaf;\n}\n\nhtml.dark .el-textarea__inner {\n  background: var(--color-bg-input);\n  box-shadow: 0 0 0 1px #404040 inset;\n  color: #e5e7eb;\n}\n\nhtml.dark .el-textarea__inner:hover {\n  box-shadow: 0 0 0 1px #505050 inset;\n}\n\nhtml.dark .el-textarea__inner:focus {\n  box-shadow: 0 0 0 1px var(--el-color-primary) inset;\n}\n\n/* Select */\nhtml.dark .el-select__wrapper {\n  background: var(--color-bg-input);\n  box-shadow: 0 0 0 1px #404040 inset;\n}\n\nhtml.dark .el-select__wrapper:hover {\n  box-shadow: 0 0 0 1px #505050 inset;\n}\n\nhtml.dark .el-select__selected-item {\n  color: #e5e7eb;\n}\n\nhtml.dark .el-select__placeholder {\n  color: #afafaf;\n}\n\nhtml.dark .el-select-dropdown {\n  background: #303030;\n  border-color: #404040;\n}\n\nhtml.dark .el-select-dropdown__item {\n  color: #e5e7eb;\n}\n\nhtml.dark .el-select-dropdown__item:hover {\n  background: #3a3a3a;\n}\n\nhtml.dark .el-select-dropdown__item.is-selected {\n  color: var(--el-color-primary);\n}\n\nhtml.dark .el-select-dropdown__item.is-disabled {\n  color: #666666;\n}\n\n/* Tag */\nhtml.dark .el-tag--info {\n  background: #303030;\n  border-color: #404040;\n  color: #b0b0b0;\n}\n\n/* Button */\nhtml.dark .el-button--default {\n  background: #303030;\n  border-color: #404040;\n  color: #e5e7eb;\n}\n\nhtml.dark .el-button--default:hover {\n  background: #3a3a3a;\n  border-color: #505050;\n  color: #e5e7eb;\n}\n\n/* Card */\nhtml.dark .el-card {\n  background: #212121;\n  border-color: #353535;\n  color: #b0b0b0;\n}\n\nhtml.dark .el-card__header {\n  border-bottom-color: #353535;\n  color: #e5e7eb;\n}\n\n/* Dialog */\nhtml.dark .el-dialog {\n  background: #212121;\n}\n\nhtml.dark .el-dialog__title {\n  color: #e5e7eb;\n}\n\n/* Message */\nhtml.dark .el-message {\n  background: #303030;\n  border-color: #404040;\n}\n\nhtml.dark .el-message--success {\n  background: #1e3d2e;\n  border-color: #3d6b4f;\n}\n\nhtml.dark .el-message--warning {\n  background: #3d3020;\n  border-color: #6b5020;\n}\n\nhtml.dark .el-message--error {\n  background: #3d2027;\n  border-color: #5c2d2d;\n}\n\n/* Loading */\nhtml.dark .el-loading-mask {\n  background-color: rgba(33, 33, 33, 0.9);\n}\n\n/* Overlay */\nhtml.dark .el-overlay {\n  background-color: rgba(0, 0, 0, 0.6);\n}\n\n/* Tooltip */\nhtml.dark .el-tooltip__popper {\n  background: #303030 !important;\n  border-color: #404040 !important;\n  color: #e5e7eb !important;\n}\n"
  },
  {
    "path": "web/frpc/src/assets/css/var.css",
    "content": ":root {\n  /* Text colors */\n  --color-text-primary: #303133;\n  --color-text-secondary: #606266;\n  --color-text-muted: #909399;\n  --color-text-light: #c0c4cc;\n  --color-text-placeholder: #a8abb2;\n\n  /* Background colors */\n  --color-bg-primary: #ffffff;\n  --color-bg-secondary: #f9f9f9;\n  --color-bg-tertiary: #fafafa;\n  --color-bg-surface: #ffffff;\n  --color-bg-muted: #f4f4f5;\n  --color-bg-input: #ffffff;\n  --color-bg-hover: #efefef;\n  --color-bg-active: #eaeaea;\n\n  /* Border colors */\n  --color-border: #dcdfe6;\n  --color-border-light: #e4e7ed;\n  --color-border-lighter: #ebeef5;\n  --color-border-extra-light: #f2f6fc;\n\n  /* Status colors */\n  --color-primary: #409eff;\n  --color-primary-light: #ecf5ff;\n  --color-success: #67c23a;\n  --color-warning: #e6a23c;\n  --color-danger: #f56c6c;\n  --color-danger-dark: #c45656;\n  --color-danger-light: #fef0f0;\n  --color-info: #909399;\n\n  /* Button colors */\n  --color-btn-primary: #303133;\n  --color-btn-primary-hover: #4a4d5c;\n\n  /* Element Plus mapping */\n  --el-color-primary: var(--color-primary);\n  --el-color-success: var(--color-success);\n  --el-color-warning: var(--color-warning);\n  --el-color-danger: var(--color-danger);\n  --el-color-info: var(--color-info);\n\n  --el-text-color-primary: var(--color-text-primary);\n  --el-text-color-regular: var(--color-text-secondary);\n  --el-text-color-secondary: var(--color-text-muted);\n  --el-text-color-placeholder: var(--color-text-placeholder);\n\n  --el-bg-color: var(--color-bg-primary);\n  --el-bg-color-page: var(--color-bg-secondary);\n  --el-bg-color-overlay: var(--color-bg-primary);\n\n  --el-border-color: var(--color-border);\n  --el-border-color-light: var(--color-border-light);\n  --el-border-color-lighter: var(--color-border-lighter);\n  --el-border-color-extra-light: var(--color-border-extra-light);\n\n  --el-fill-color-blank: var(--color-bg-primary);\n  --el-fill-color-light: var(--color-bg-tertiary);\n  --el-fill-color: var(--color-bg-tertiary);\n  --el-fill-color-dark: var(--color-bg-hover);\n  --el-fill-color-darker: var(--color-bg-active);\n\n  /* Input */\n  --el-input-bg-color: var(--color-bg-input);\n  --el-input-border-color: var(--color-border);\n  --el-input-hover-border-color: var(--color-border-light);\n\n  /* Dialog */\n  --el-dialog-bg-color: var(--color-bg-primary);\n  --el-overlay-color: rgba(0, 0, 0, 0.5);\n}\n\nhtml.dark {\n  /* Text colors */\n  --color-text-primary: #e5e7eb;\n  --color-text-secondary: #b0b0b0;\n  --color-text-muted: #888888;\n  --color-text-light: #666666;\n  --color-text-placeholder: #afafaf;\n\n  /* Background colors */\n  --color-bg-primary: #212121;\n  --color-bg-secondary: #181818;\n  --color-bg-tertiary: #303030;\n  --color-bg-surface: #303030;\n  --color-bg-muted: #303030;\n  --color-bg-input: #2f2f2f;\n  --color-bg-hover: #3a3a3a;\n  --color-bg-active: #454545;\n\n  /* Border colors */\n  --color-border: #404040;\n  --color-border-light: #353535;\n  --color-border-lighter: #2a2a2a;\n  --color-border-extra-light: #222222;\n\n  /* Status colors */\n  --color-primary: #409eff;\n  --color-danger: #f87171;\n  --color-danger-dark: #f87171;\n  --color-danger-light: #3d2027;\n  --color-info: #888888;\n\n  /* Button colors */\n  --color-btn-primary: #404040;\n  --color-btn-primary-hover: #505050;\n\n  /* Dark overrides */\n  --el-text-color-regular: var(--color-text-primary);\n  --el-overlay-color: rgba(0, 0, 0, 0.7);\n\n  background-color: #181818;\n  color-scheme: dark;\n}\n"
  },
  {
    "path": "web/frpc/src/components/ConfigField.vue",
    "content": "<template>\n  <!-- Edit mode: use el-form-item for validation -->\n  <el-form-item v-if=\"!readonly\" :label=\"label\" :prop=\"prop\" :class=\"($attrs.class as string)\">\n    <!-- text -->\n    <el-input\n      v-if=\"type === 'text'\"\n      :model-value=\"modelValue\"\n      :placeholder=\"placeholder\"\n      :disabled=\"disabled\"\n      @update:model-value=\"$emit('update:modelValue', $event)\"\n    />\n    <!-- number -->\n    <el-input\n      v-else-if=\"type === 'number'\"\n      :model-value=\"modelValue != null ? String(modelValue) : ''\"\n      :placeholder=\"placeholder\"\n      :disabled=\"disabled\"\n      @update:model-value=\"handleNumberInput($event)\"\n    />\n    <!-- switch -->\n    <div v-else-if=\"type === 'switch'\" class=\"config-field-switch-wrap\">\n      <el-switch\n        :model-value=\"modelValue\"\n        :disabled=\"disabled\"\n        size=\"small\"\n        @update:model-value=\"$emit('update:modelValue', $event)\"\n      />\n      <span v-if=\"tip\" class=\"config-field-switch-tip\">{{ tip }}</span>\n    </div>\n    <!-- select -->\n    <PopoverMenu\n      v-else-if=\"type === 'select'\"\n      :model-value=\"modelValue\"\n      :display-value=\"selectDisplayValue\"\n      :disabled=\"disabled\"\n      :width=\"selectWidth\"\n      selectable\n      full-width\n      filterable\n      :filter-placeholder=\"placeholder || 'Select...'\"\n      @update:model-value=\"$emit('update:modelValue', $event)\"\n    >\n      <template #default=\"{ filterText }\">\n        <PopoverMenuItem\n          v-for=\"opt in filteredOptions(filterText)\"\n          :key=\"opt.value\"\n          :value=\"opt.value\"\n        >\n          {{ opt.label }}\n        </PopoverMenuItem>\n      </template>\n    </PopoverMenu>\n    <!-- password -->\n    <el-input\n      v-else-if=\"type === 'password'\"\n      :model-value=\"modelValue\"\n      :placeholder=\"placeholder\"\n      :disabled=\"disabled\"\n      type=\"password\"\n      show-password\n      @update:model-value=\"$emit('update:modelValue', $event)\"\n    />\n    <!-- kv -->\n    <KeyValueEditor\n      v-else-if=\"type === 'kv'\"\n      :model-value=\"modelValue\"\n      :key-placeholder=\"keyPlaceholder\"\n      :value-placeholder=\"valuePlaceholder\"\n      @update:model-value=\"$emit('update:modelValue', $event)\"\n    />\n    <!-- tags (string array) -->\n    <StringListEditor\n      v-else-if=\"type === 'tags'\"\n      :model-value=\"modelValue || []\"\n      :placeholder=\"placeholder\"\n      @update:model-value=\"$emit('update:modelValue', $event)\"\n    />\n    <div v-if=\"tip && type !== 'switch'\" class=\"config-field-tip\">{{ tip }}</div>\n  </el-form-item>\n\n  <!-- Readonly mode: plain display -->\n  <div v-else class=\"config-field-readonly\" :class=\"($attrs.class as string)\">\n    <div class=\"config-field-label\">{{ label }}</div>\n    <!-- switch readonly -->\n    <el-switch\n      v-if=\"type === 'switch'\"\n      :model-value=\"modelValue\"\n      disabled\n      size=\"small\"\n    />\n    <!-- kv readonly -->\n    <KeyValueEditor\n      v-else-if=\"type === 'kv'\"\n      :model-value=\"modelValue || []\"\n      :key-placeholder=\"keyPlaceholder\"\n      :value-placeholder=\"valuePlaceholder\"\n      readonly\n    />\n    <!-- tags readonly -->\n    <StringListEditor\n      v-else-if=\"type === 'tags'\"\n      :model-value=\"modelValue || []\"\n      readonly\n    />\n    <!-- text/number/select/password readonly -->\n    <el-input\n      v-else\n      :model-value=\"displayValue\"\n      disabled\n    />\n  </div>\n</template>\n\n<script setup lang=\"ts\">\nimport { computed } from 'vue'\nimport KeyValueEditor from './KeyValueEditor.vue'\nimport StringListEditor from './StringListEditor.vue'\nimport PopoverMenu from '@shared/components/PopoverMenu.vue'\nimport PopoverMenuItem from '@shared/components/PopoverMenuItem.vue'\n\nconst props = withDefaults(\n  defineProps<{\n    label: string\n    type?: 'text' | 'number' | 'switch' | 'select' | 'password' | 'kv' | 'tags'\n    readonly?: boolean\n    modelValue?: any\n    placeholder?: string\n    disabled?: boolean\n    tip?: string\n    prop?: string\n    options?: Array<{ label: string; value: string | number }>\n    min?: number\n    max?: number\n    keyPlaceholder?: string\n    valuePlaceholder?: string\n  }>(),\n  {\n    type: 'text',\n    readonly: false,\n    modelValue: undefined,\n    placeholder: '',\n    disabled: false,\n    tip: '',\n    prop: '',\n    options: () => [],\n    min: undefined,\n    max: undefined,\n    keyPlaceholder: 'Key',\n    valuePlaceholder: 'Value',\n  },\n)\n\nconst emit = defineEmits<{\n  'update:modelValue': [value: any]\n}>()\n\nconst handleNumberInput = (val: string) => {\n  if (val === '') {\n    emit('update:modelValue', undefined)\n    return\n  }\n  const num = Number(val)\n  if (!isNaN(num)) {\n    let clamped = num\n    if (props.min != null && clamped < props.min) clamped = props.min\n    if (props.max != null && clamped > props.max) clamped = props.max\n    emit('update:modelValue', clamped)\n  }\n}\n\nconst selectDisplayValue = computed(() => {\n  const opt = props.options.find((o) => o.value === props.modelValue)\n  return opt ? opt.label : ''\n})\n\nconst selectWidth = computed(() => {\n  return Math.max(160, ...props.options.map((o) => o.label.length * 10 + 60))\n})\n\nconst filteredOptions = (filterText: string) => {\n  if (!filterText) return props.options\n  const lower = filterText.toLowerCase()\n  return props.options.filter((o) => o.label.toLowerCase().includes(lower))\n}\n\nconst displayValue = computed(() => {\n  if (props.modelValue == null || props.modelValue === '') return '—'\n  if (props.type === 'select') {\n    const opt = props.options.find((o) => o.value === props.modelValue)\n    return opt ? opt.label : String(props.modelValue)\n  }\n  if (props.type === 'password') {\n    return props.modelValue ? '••••••' : '—'\n  }\n  return String(props.modelValue)\n})\n</script>\n\n<style scoped>\n.config-field-switch-wrap {\n  display: flex;\n  align-items: center;\n  gap: 8px;\n  min-height: 32px;\n  width: 100%;\n}\n\n.config-field-switch-tip {\n  font-size: 12px;\n  color: var(--color-text-muted);\n}\n\n.config-field-tip {\n  font-size: 12px;\n  color: var(--el-text-color-secondary);\n  margin-top: 4px;\n}\n\n.config-field-readonly {\n  margin-bottom: 16px;\n}\n\n.config-field-label {\n  font-size: 13px;\n  font-weight: 500;\n  color: var(--color-text-secondary);\n  margin-bottom: 6px;\n  line-height: 1;\n}\n\n.config-field-readonly :deep(*) {\n  cursor: default !important;\n}\n\n.config-field-readonly :deep(.el-input.is-disabled .el-input__wrapper) {\n  background: var(--color-bg-tertiary);\n  box-shadow: 0 0 0 1px var(--color-border-lighter) inset;\n}\n\n.config-field-readonly :deep(.el-input.is-disabled .el-input__inner) {\n  color: var(--color-text-primary);\n  -webkit-text-fill-color: var(--color-text-primary);\n  font-family: ui-monospace, SFMono-Regular, Menlo, monospace;\n}\n\n.config-field-readonly :deep(.el-switch.is-disabled) {\n  opacity: 1;\n}\n</style>\n"
  },
  {
    "path": "web/frpc/src/components/ConfigSection.vue",
    "content": "<template>\n  <div class=\"config-section-card\">\n    <!-- Collapsible: header is a separate clickable area -->\n    <template v-if=\"collapsible\">\n      <div\n        v-if=\"title\"\n        class=\"section-header clickable\"\n        @click=\"handleToggle\"\n      >\n        <h3 class=\"section-title\">{{ title }}</h3>\n        <div class=\"section-header-right\">\n          <span v-if=\"readonly && !hasValue\" class=\"not-configured-badge\">\n            Not configured\n          </span>\n          <el-icon v-if=\"canToggle\" class=\"collapse-arrow\" :class=\"{ expanded }\">\n            <ArrowDown />\n          </el-icon>\n        </div>\n      </div>\n      <div class=\"collapse-wrapper\" :class=\"{ expanded }\">\n        <div class=\"collapse-inner\">\n          <div class=\"section-body\">\n            <slot />\n          </div>\n        </div>\n      </div>\n    </template>\n\n    <!-- Non-collapsible: title and content in one area -->\n    <template v-else>\n      <div class=\"section-body\">\n        <h3 v-if=\"title\" class=\"section-title section-title-inline\">{{ title }}</h3>\n        <slot />\n      </div>\n    </template>\n  </div>\n</template>\n\n<script setup lang=\"ts\">\nimport { ref, computed, watch } from 'vue'\nimport { ArrowDown } from '@element-plus/icons-vue'\n\nconst props = withDefaults(\n  defineProps<{\n    title?: string\n    collapsible?: boolean\n    readonly?: boolean\n    hasValue?: boolean\n  }>(),\n  {\n    title: '',\n    collapsible: false,\n    readonly: false,\n    hasValue: true,\n  },\n)\n\nconst computeInitial = () => {\n  if (!props.collapsible) return true\n  return props.hasValue\n}\n\nconst expanded = ref(computeInitial())\n\n// Only auto-expand when hasValue goes from false to true (async data loaded)\n// Never auto-collapse — don't override user interaction\nwatch(\n  () => props.hasValue,\n  (newVal, oldVal) => {\n    if (newVal && !oldVal && props.collapsible) {\n      expanded.value = true\n    }\n  },\n)\n\nconst canToggle = computed(() => {\n  if (!props.collapsible) return false\n  if (props.readonly && !props.hasValue) return false\n  return true\n})\n\nconst handleToggle = () => {\n  if (canToggle.value) {\n    expanded.value = !expanded.value\n  }\n}\n</script>\n\n<style scoped lang=\"scss\">\n.config-section-card {\n  background: var(--el-bg-color);\n  border: 1px solid var(--color-border-lighter);\n  border-radius: 12px;\n  margin-bottom: 16px;\n  overflow: hidden;\n}\n\n/* Collapsible header */\n.section-header {\n  display: flex;\n  justify-content: space-between;\n  align-items: center;\n  padding: 10px 20px;\n}\n\n.section-header.clickable {\n  cursor: pointer;\n  transition: background 0.15s;\n}\n\n.section-header.clickable:hover {\n  background: var(--color-bg-hover);\n}\n\n.section-title {\n  font-size: 14px;\n  font-weight: 600;\n  color: var(--color-text-primary);\n  margin: 0;\n}\n\n/* Inline title for non-collapsible sections */\n.section-title-inline {\n  margin-bottom: 16px;\n}\n\n.section-header-right {\n  display: flex;\n  align-items: center;\n  gap: 8px;\n}\n\n.not-configured-badge {\n  font-size: 11px;\n  color: var(--color-text-light);\n  background: var(--color-bg-muted);\n  padding: 2px 8px;\n  border-radius: 4px;\n}\n\n.collapse-arrow {\n  transition: transform 0.3s;\n  color: var(--color-text-muted);\n}\n\n.collapse-arrow.expanded {\n  transform: rotate(-180deg);\n}\n\n/* Grid-based collapse animation */\n.collapse-wrapper {\n  display: grid;\n  grid-template-rows: 0fr;\n  transition: grid-template-rows 0.25s ease;\n}\n\n.collapse-wrapper.expanded {\n  grid-template-rows: 1fr;\n}\n\n.collapse-inner {\n  overflow: hidden;\n}\n\n.section-body {\n  padding: 20px 20px 12px;\n  display: flex;\n  flex-direction: column;\n  gap: 16px;\n}\n\n.section-body :deep(.el-form-item) {\n  margin-bottom: 0;\n}\n\n.section-body :deep(.config-field-readonly) {\n  margin-bottom: 0;\n}\n\n@include mobile {\n  .section-body {\n    padding: 16px;\n  }\n}\n</style>\n"
  },
  {
    "path": "web/frpc/src/components/KeyValueEditor.vue",
    "content": "<template>\n  <div class=\"kv-editor\">\n    <template v-if=\"readonly\">\n      <div v-if=\"modelValue.length === 0\" class=\"kv-empty\">—</div>\n      <div v-for=\"(entry, index) in modelValue\" :key=\"index\" class=\"kv-readonly-row\">\n        <span class=\"kv-readonly-key\">{{ entry.key }}</span>\n        <span class=\"kv-readonly-value\">{{ entry.value }}</span>\n      </div>\n    </template>\n    <template v-else>\n      <div v-for=\"(entry, index) in modelValue\" :key=\"index\" class=\"kv-row\">\n        <el-input\n          :model-value=\"entry.key\"\n          :placeholder=\"keyPlaceholder\"\n          class=\"kv-input\"\n          @update:model-value=\"updateEntry(index, 'key', $event)\"\n        />\n        <el-input\n          :model-value=\"entry.value\"\n          :placeholder=\"valuePlaceholder\"\n          class=\"kv-input\"\n          @update:model-value=\"updateEntry(index, 'value', $event)\"\n        />\n        <button class=\"kv-remove-btn\" @click=\"removeEntry(index)\">\n          <svg viewBox=\"0 0 16 16\" fill=\"none\" xmlns=\"http://www.w3.org/2000/svg\">\n            <path\n              d=\"M5.5 5.5A.5.5 0 016 6v6a.5.5 0 01-1 0V6a.5.5 0 01.5-.5zm2.5 0a.5.5 0 01.5.5v6a.5.5 0 01-1 0V6a.5.5 0 01.5-.5zm3 .5a.5.5 0 00-1 0v6a.5.5 0 001 0V6z\"\n              fill=\"currentColor\"\n            />\n            <path\n              fill-rule=\"evenodd\"\n              clip-rule=\"evenodd\"\n              d=\"M14.5 3a1 1 0 01-1 1H13v9a2 2 0 01-2 2H5a2 2 0 01-2-2V4h-.5a1 1 0 010-2H6a1 1 0 011-1h2a1 1 0 011 1h3.5a1 1 0 011 1zM4.118 4L4 4.059V13a1 1 0 001 1h6a1 1 0 001-1V4.059L11.882 4H4.118zM6 2h4v1H6V2z\"\n              fill=\"currentColor\"\n            />\n          </svg>\n        </button>\n      </div>\n      <button class=\"kv-add-btn\" @click=\"addEntry\">\n        <svg viewBox=\"0 0 16 16\" fill=\"none\" xmlns=\"http://www.w3.org/2000/svg\">\n          <path\n            d=\"M8 2a.5.5 0 01.5.5v5h5a.5.5 0 010 1h-5v5a.5.5 0 01-1 0v-5h-5a.5.5 0 010-1h5v-5A.5.5 0 018 2z\"\n            fill=\"currentColor\"\n          />\n        </svg>\n        Add\n      </button>\n    </template>\n  </div>\n</template>\n\n<script setup lang=\"ts\">\ninterface KVEntry {\n  key: string\n  value: string\n}\n\ninterface Props {\n  modelValue: KVEntry[]\n  keyPlaceholder?: string\n  valuePlaceholder?: string\n  readonly?: boolean\n}\n\nconst props = withDefaults(defineProps<Props>(), {\n  keyPlaceholder: 'Key',\n  valuePlaceholder: 'Value',\n  readonly: false,\n})\n\nconst emit = defineEmits<{\n  'update:modelValue': [value: KVEntry[]]\n}>()\n\nconst updateEntry = (index: number, field: 'key' | 'value', val: string) => {\n  const updated = [...props.modelValue]\n  updated[index] = { ...updated[index], [field]: val }\n  emit('update:modelValue', updated)\n}\n\nconst addEntry = () => {\n  emit('update:modelValue', [...props.modelValue, { key: '', value: '' }])\n}\n\nconst removeEntry = (index: number) => {\n  const updated = props.modelValue.filter((_, i) => i !== index)\n  emit('update:modelValue', updated)\n}\n</script>\n\n<style scoped>\n.kv-editor {\n  display: flex;\n  flex-direction: column;\n  gap: 8px;\n  width: 100%;\n}\n\n.kv-row {\n  display: flex;\n  gap: 8px;\n  align-items: center;\n}\n\n.kv-input {\n  flex: 1;\n}\n\n.kv-remove-btn {\n  display: flex;\n  align-items: center;\n  justify-content: center;\n  width: 32px;\n  height: 32px;\n  border: none;\n  border-radius: 8px;\n  background: var(--el-fill-color);\n  color: var(--el-text-color-secondary);\n  cursor: pointer;\n  transition: all 0.15s ease;\n  flex-shrink: 0;\n}\n\n.kv-remove-btn svg {\n  width: 14px;\n  height: 14px;\n}\n\n.kv-remove-btn:hover {\n  background: rgba(239, 68, 68, 0.1);\n  color: #ef4444;\n}\n\nhtml.dark .kv-remove-btn:hover {\n  background: rgba(248, 113, 113, 0.15);\n  color: #f87171;\n}\n\n.kv-add-btn {\n  display: inline-flex;\n  align-items: center;\n  gap: 6px;\n  padding: 5px 12px;\n  border: 1px solid var(--color-border);\n  border-radius: 6px;\n  background: transparent;\n  color: var(--color-text-secondary);\n  font-size: 13px;\n  cursor: pointer;\n  transition: all 0.15s;\n  align-self: flex-start;\n}\n\n.kv-add-btn svg {\n  width: 13px;\n  height: 13px;\n}\n\n.kv-add-btn:hover {\n  background: var(--color-bg-hover);\n}\n\n.kv-empty {\n  color: var(--el-text-color-secondary);\n  font-size: 13px;\n}\n\n.kv-readonly-row {\n  display: flex;\n  gap: 8px;\n  padding: 4px 0;\n  font-size: 13px;\n}\n\n.kv-readonly-key {\n  color: var(--el-text-color-secondary);\n  min-width: 100px;\n}\n\n.kv-readonly-value {\n  color: var(--el-text-color-primary);\n  font-family: ui-monospace, SFMono-Regular, Menlo, monospace;\n}\n</style>\n"
  },
  {
    "path": "web/frpc/src/components/ProxyCard.vue",
    "content": "<template>\n  <div class=\"proxy-card\" :class=\"{ 'has-error': proxy.err }\" @click=\"$emit('click', proxy)\">\n    <div class=\"card-main\">\n      <div class=\"card-left\">\n        <div class=\"card-header\">\n          <span class=\"proxy-name\">{{ proxy.name }}</span>\n          <span class=\"type-tag\">{{ proxy.type.toUpperCase() }}</span>\n          <span class=\"status-pill\" :class=\"statusClass\">\n            <span class=\"status-dot\"></span>\n            {{ proxy.status }}\n          </span>\n        </div>\n        <div class=\"card-address\">\n          <template v-if=\"proxy.remote_addr && localDisplay\">\n            {{ proxy.remote_addr }} → {{ localDisplay }}\n          </template>\n          <template v-else-if=\"proxy.remote_addr\">{{ proxy.remote_addr }}</template>\n          <template v-else-if=\"localDisplay\">{{ localDisplay }}</template>\n        </div>\n      </div>\n      <div class=\"card-right\">\n        <span v-if=\"showSource\" class=\"source-label\">{{ displaySource }}</span>\n        <div v-if=\"showActions\" @click.stop>\n          <PopoverMenu :width=\"120\" placement=\"bottom-end\">\n            <template #trigger>\n              <ActionButton variant=\"outline\" size=\"small\">\n                <el-icon><MoreFilled /></el-icon>\n              </ActionButton>\n            </template>\n            <PopoverMenuItem v-if=\"proxy.status === 'disabled'\" @click=\"$emit('toggle', proxy, true)\">\n              <el-icon><Open /></el-icon>\n              Enable\n            </PopoverMenuItem>\n            <PopoverMenuItem v-else @click=\"$emit('toggle', proxy, false)\">\n              <el-icon><TurnOff /></el-icon>\n              Disable\n            </PopoverMenuItem>\n            <PopoverMenuItem @click=\"$emit('edit', proxy)\">\n              <el-icon><Edit /></el-icon>\n              Edit\n            </PopoverMenuItem>\n            <PopoverMenuItem danger @click=\"$emit('delete', proxy)\">\n              <el-icon><Delete /></el-icon>\n              Delete\n            </PopoverMenuItem>\n          </PopoverMenu>\n        </div>\n      </div>\n    </div>\n  </div>\n</template>\n\n<script setup lang=\"ts\">\nimport { computed } from 'vue'\nimport { MoreFilled, Edit, Delete, Open, TurnOff } from '@element-plus/icons-vue'\nimport ActionButton from '@shared/components/ActionButton.vue'\nimport PopoverMenu from '@shared/components/PopoverMenu.vue'\nimport PopoverMenuItem from '@shared/components/PopoverMenuItem.vue'\nimport type { ProxyStatus } from '../types'\n\ninterface Props {\n  proxy: ProxyStatus\n  showSource?: boolean\n  showActions?: boolean\n  deleting?: boolean\n}\n\nconst props = withDefaults(defineProps<Props>(), {\n  showSource: false,\n  showActions: false,\n  deleting: false,\n})\n\ndefineEmits<{\n  click: [proxy: ProxyStatus]\n  edit: [proxy: ProxyStatus]\n  delete: [proxy: ProxyStatus]\n  toggle: [proxy: ProxyStatus, enabled: boolean]\n}>()\n\nconst displaySource = computed(() => {\n  return props.proxy.source === 'store' ? 'store' : 'config'\n})\n\nconst localDisplay = computed(() => {\n  if (props.proxy.plugin) return `plugin:${props.proxy.plugin}`\n  return props.proxy.local_addr || ''\n})\n\nconst statusClass = computed(() => {\n  switch (props.proxy.status) {\n    case 'running':\n      return 'running'\n    case 'error':\n      return 'error'\n    case 'disabled':\n      return 'disabled'\n    default:\n      return 'waiting'\n  }\n})\n</script>\n\n<style scoped lang=\"scss\">\n.proxy-card {\n  background: $color-bg-primary;\n  border: 1px solid $color-border-lighter;\n  border-radius: $radius-md;\n  padding: 14px 20px;\n  cursor: pointer;\n  transition: all $transition-medium;\n\n  &:hover {\n    box-shadow: 0 2px 8px rgba(0, 0, 0, 0.04);\n    border-color: $color-border;\n  }\n\n  &.has-error {\n    border-color: rgba(245, 108, 108, 0.3);\n  }\n}\n\n.card-main {\n  display: flex;\n  justify-content: space-between;\n  align-items: center;\n  gap: $spacing-lg;\n}\n\n.card-left {\n  @include flex-column;\n  gap: $spacing-sm;\n  flex: 1;\n  min-width: 0;\n}\n\n.card-header {\n  display: flex;\n  align-items: center;\n  gap: $spacing-sm;\n}\n\n.proxy-name {\n  font-size: $font-size-lg;\n  font-weight: $font-weight-semibold;\n  color: $color-text-primary;\n}\n\n.type-tag {\n  font-size: $font-size-xs;\n  font-weight: $font-weight-medium;\n  padding: 2px 8px;\n  border-radius: 4px;\n  background: $color-bg-muted;\n  color: $color-text-secondary;\n}\n\n.card-address {\n  font-family: ui-monospace, SFMono-Regular, Menlo, Monaco, Consolas, monospace;\n  font-size: $font-size-sm;\n  color: $color-text-muted;\n  display: flex;\n  align-items: center;\n  gap: $spacing-sm;\n}\n\n\n\n.card-right {\n  display: flex;\n  align-items: center;\n  gap: $spacing-md;\n  flex-shrink: 0;\n}\n\n.source-label {\n  font-size: $font-size-xs;\n  color: $color-text-light;\n}\n\n\n.status-dot {\n  width: 6px;\n  height: 6px;\n  border-radius: 50%;\n  background: currentColor;\n}\n\n\n\n@include mobile {\n  .card-main {\n    flex-direction: column;\n    align-items: stretch;\n    gap: $spacing-sm;\n  }\n  .card-right {\n    justify-content: space-between;\n  }\n  .card-address {\n    word-break: break-all;\n  }\n}\n</style>\n"
  },
  {
    "path": "web/frpc/src/components/StatusPills.vue",
    "content": "<template>\n  <div class=\"status-pills\">\n    <button\n      v-for=\"pill in pills\"\n      :key=\"pill.status\"\n      class=\"pill\"\n      :class=\"{ active: modelValue === pill.status, [pill.status || 'all']: true }\"\n      @click=\"emit('update:modelValue', pill.status)\"\n    >\n      {{ pill.label }} {{ pill.count }}\n    </button>\n  </div>\n</template>\n\n<script setup lang=\"ts\">\nimport { computed } from 'vue'\n\ninterface Props {\n  items: Array<{ status: string }>\n  modelValue: string\n}\n\nconst props = defineProps<Props>()\n\nconst emit = defineEmits<{\n  'update:modelValue': [value: string]\n}>()\n\nconst pills = computed(() => {\n  const counts = { running: 0, error: 0, waiting: 0 }\n  for (const item of props.items) {\n    const s = item.status as keyof typeof counts\n    if (s in counts) {\n      counts[s]++\n    }\n  }\n  return [\n    { status: '', label: 'All', count: props.items.length },\n    { status: 'running', label: 'Running', count: counts.running },\n    { status: 'error', label: 'Error', count: counts.error },\n    { status: 'waiting', label: 'Waiting', count: counts.waiting },\n  ]\n})\n</script>\n\n<style scoped lang=\"scss\">\n.status-pills {\n  display: flex;\n  gap: $spacing-sm;\n}\n\n.pill {\n  border: none;\n  border-radius: 12px;\n  padding: $spacing-xs $spacing-md;\n  font-size: $font-size-xs;\n  font-weight: $font-weight-medium;\n  cursor: pointer;\n  background: $color-bg-muted;\n  color: $color-text-secondary;\n  transition: all $transition-fast;\n  white-space: nowrap;\n\n  &:hover {\n    opacity: 0.85;\n  }\n\n  &.active {\n    &.all {\n      background: $color-bg-muted;\n      color: $color-text-secondary;\n    }\n\n    &.running {\n      background: rgba(103, 194, 58, 0.1);\n      color: #67c23a;\n    }\n\n    &.error {\n      background: rgba(245, 108, 108, 0.1);\n      color: #f56c6c;\n    }\n\n    &.waiting {\n      background: rgba(230, 162, 60, 0.1);\n      color: #e6a23c;\n    }\n  }\n}\n\n@include mobile {\n  .status-pills {\n    overflow-x: auto;\n    flex-wrap: nowrap;\n    scrollbar-width: none;\n    -ms-overflow-style: none;\n\n    &::-webkit-scrollbar {\n      display: none;\n    }\n  }\n}\n</style>\n"
  },
  {
    "path": "web/frpc/src/components/StringListEditor.vue",
    "content": "<template>\n  <div class=\"string-list-editor\">\n    <template v-if=\"readonly\">\n      <div v-if=\"!modelValue || modelValue.length === 0\" class=\"list-empty\">—</div>\n      <div v-for=\"(item, index) in modelValue\" :key=\"index\" class=\"list-readonly-item\">\n        {{ item }}\n      </div>\n    </template>\n    <template v-else>\n      <div v-for=\"(item, index) in modelValue\" :key=\"index\" class=\"item-row\">\n        <el-input\n          :model-value=\"item\"\n          :placeholder=\"placeholder\"\n          @update:model-value=\"updateItem(index, $event)\"\n        />\n        <button class=\"item-remove\" @click=\"removeItem(index)\">\n          <svg viewBox=\"0 0 16 16\" fill=\"none\" xmlns=\"http://www.w3.org/2000/svg\">\n            <path d=\"M4.646 4.646a.5.5 0 0 1 .708 0L8 7.293l2.646-2.647a.5.5 0 0 1 .708.708L8.707 8l2.647 2.646a.5.5 0 0 1-.708.708L8 8.707l-2.646 2.647a.5.5 0 0 1-.708-.708L7.293 8 4.646 5.354a.5.5 0 0 1 0-.708z\" fill=\"currentColor\"/>\n          </svg>\n        </button>\n      </div>\n      <button class=\"list-add-btn\" @click=\"addItem\">\n        <svg viewBox=\"0 0 16 16\" fill=\"none\" xmlns=\"http://www.w3.org/2000/svg\">\n          <path d=\"M8 2a.5.5 0 01.5.5v5h5a.5.5 0 010 1h-5v5a.5.5 0 01-1 0v-5h-5a.5.5 0 010-1h5v-5A.5.5 0 018 2z\" fill=\"currentColor\"/>\n        </svg>\n        Add\n      </button>\n    </template>\n  </div>\n</template>\n\n<script setup lang=\"ts\">\nconst props = withDefaults(\n  defineProps<{\n    modelValue: string[]\n    placeholder?: string\n    readonly?: boolean\n  }>(),\n  {\n    placeholder: 'Enter value',\n    readonly: false,\n  },\n)\n\nconst emit = defineEmits<{\n  'update:modelValue': [value: string[]]\n}>()\n\nconst addItem = () => {\n  emit('update:modelValue', [...(props.modelValue || []), ''])\n}\n\nconst removeItem = (index: number) => {\n  const newValue = [...props.modelValue]\n  newValue.splice(index, 1)\n  emit('update:modelValue', newValue)\n}\n\nconst updateItem = (index: number, value: string) => {\n  const newValue = [...props.modelValue]\n  newValue[index] = value\n  emit('update:modelValue', newValue)\n}\n</script>\n\n<style scoped>\n.string-list-editor {\n  width: 100%;\n}\n\n.item-row {\n  display: flex;\n  align-items: center;\n  gap: 8px;\n  margin-bottom: 8px;\n}\n\n.item-row .el-input {\n  flex: 1;\n}\n\n.item-remove {\n  display: flex;\n  align-items: center;\n  justify-content: center;\n  width: 28px;\n  height: 28px;\n  border: none;\n  border-radius: 6px;\n  background: transparent;\n  color: var(--color-text-muted);\n  cursor: pointer;\n  flex-shrink: 0;\n  transition: all 0.15s;\n}\n\n.item-remove svg {\n  width: 14px;\n  height: 14px;\n}\n\n.item-remove:hover {\n  background: var(--color-bg-hover);\n  color: var(--color-text-primary);\n}\n\n.list-add-btn {\n  display: inline-flex;\n  align-items: center;\n  gap: 6px;\n  padding: 5px 12px;\n  border: 1px solid var(--color-border);\n  border-radius: 6px;\n  background: transparent;\n  color: var(--color-text-secondary);\n  font-size: 13px;\n  cursor: pointer;\n  transition: all 0.15s;\n}\n\n.list-add-btn svg {\n  width: 13px;\n  height: 13px;\n}\n\n.list-add-btn:hover {\n  background: var(--color-bg-hover);\n}\n\n.list-empty {\n  color: var(--color-text-muted);\n  font-size: 13px;\n}\n\n.list-readonly-item {\n  font-size: 13px;\n  color: var(--color-text-primary);\n  font-family: ui-monospace, SFMono-Regular, Menlo, monospace;\n  padding: 2px 0;\n}\n</style>\n"
  },
  {
    "path": "web/frpc/src/components/proxy-form/ProxyAuthSection.vue",
    "content": "<template>\n  <ConfigSection title=\"Authentication\" :readonly=\"readonly\">\n    <template v-if=\"['http', 'tcpmux'].includes(form.type)\">\n      <div class=\"field-row three-col\">\n        <ConfigField label=\"HTTP User\" type=\"text\" v-model=\"form.httpUser\" :readonly=\"readonly\" />\n        <ConfigField label=\"HTTP Password\" type=\"password\" v-model=\"form.httpPassword\" :readonly=\"readonly\" />\n        <ConfigField label=\"Route By HTTP User\" type=\"text\" v-model=\"form.routeByHTTPUser\" :readonly=\"readonly\" />\n      </div>\n    </template>\n    <template v-if=\"['stcp', 'sudp', 'xtcp'].includes(form.type)\">\n      <div class=\"field-row two-col\">\n        <ConfigField label=\"Secret Key\" type=\"password\" v-model=\"form.secretKey\" prop=\"secretKey\" :readonly=\"readonly\" />\n        <ConfigField label=\"Allow Users\" type=\"tags\" v-model=\"form.allowUsers\" placeholder=\"username\" :readonly=\"readonly\" />\n      </div>\n    </template>\n  </ConfigSection>\n</template>\n\n<script setup lang=\"ts\">\nimport { computed } from 'vue'\nimport type { ProxyFormData } from '../../types'\nimport ConfigSection from '../ConfigSection.vue'\nimport ConfigField from '../ConfigField.vue'\n\nconst props = withDefaults(defineProps<{\n  modelValue: ProxyFormData\n  readonly?: boolean\n}>(), { readonly: false })\n\nconst emit = defineEmits<{ 'update:modelValue': [value: ProxyFormData] }>()\n\nconst form = computed({\n  get: () => props.modelValue,\n  set: (val) => emit('update:modelValue', val),\n})\n</script>\n\n<style scoped lang=\"scss\">\n@use '@/assets/css/form-layout';\n</style>\n"
  },
  {
    "path": "web/frpc/src/components/proxy-form/ProxyBackendSection.vue",
    "content": "<template>\n  <!-- Backend Mode -->\n  <template v-if=\"!readonly\">\n    <el-form-item label=\"Backend Mode\">\n      <el-radio-group v-model=\"backendMode\">\n        <el-radio value=\"direct\">Direct</el-radio>\n        <el-radio value=\"plugin\">Plugin</el-radio>\n      </el-radio-group>\n    </el-form-item>\n  </template>\n\n  <!-- Direct mode -->\n  <template v-if=\"backendMode === 'direct'\">\n    <div class=\"field-row two-col\">\n      <ConfigField label=\"Local IP\" type=\"text\" v-model=\"form.localIP\" placeholder=\"127.0.0.1\" :readonly=\"readonly\" />\n      <ConfigField label=\"Local Port\" type=\"number\" v-model=\"form.localPort\" :min=\"0\" :max=\"65535\" prop=\"localPort\" :readonly=\"readonly\" />\n    </div>\n  </template>\n\n  <!-- Plugin mode -->\n  <template v-else>\n    <div class=\"field-row two-col\">\n      <ConfigField label=\"Plugin Type\" type=\"select\" v-model=\"form.pluginType\"\n        :options=\"PLUGIN_LIST.map((p) => ({ label: p, value: p }))\" :readonly=\"readonly\" />\n      <div></div>\n    </div>\n\n    <template v-if=\"['http2https', 'https2http', 'https2https', 'http2http', 'tls2raw'].includes(form.pluginType)\">\n      <div class=\"field-row two-col\">\n        <ConfigField label=\"Local Address\" type=\"text\" v-model=\"form.pluginConfig.localAddr\" placeholder=\"127.0.0.1:8080\" :readonly=\"readonly\" />\n        <ConfigField v-if=\"['http2https', 'https2http', 'https2https', 'http2http'].includes(form.pluginType)\"\n          label=\"Host Header Rewrite\" type=\"text\" v-model=\"form.pluginConfig.hostHeaderRewrite\" :readonly=\"readonly\" />\n        <div v-else></div>\n      </div>\n    </template>\n    <template v-if=\"['http2https', 'https2http', 'https2https', 'http2http'].includes(form.pluginType)\">\n      <ConfigField label=\"Request Headers\" type=\"kv\" v-model=\"pluginRequestHeaders\"\n        key-placeholder=\"Header\" value-placeholder=\"Value\" :readonly=\"readonly\" />\n    </template>\n    <template v-if=\"['https2http', 'https2https', 'tls2raw'].includes(form.pluginType)\">\n      <div class=\"field-row two-col\">\n        <ConfigField label=\"Certificate Path\" type=\"text\" v-model=\"form.pluginConfig.crtPath\" placeholder=\"/path/to/cert.pem\" :readonly=\"readonly\" />\n        <ConfigField label=\"Key Path\" type=\"text\" v-model=\"form.pluginConfig.keyPath\" placeholder=\"/path/to/key.pem\" :readonly=\"readonly\" />\n      </div>\n    </template>\n    <template v-if=\"['https2http', 'https2https'].includes(form.pluginType)\">\n      <ConfigField label=\"Enable HTTP/2\" type=\"switch\" v-model=\"form.pluginConfig.enableHTTP2\" :readonly=\"readonly\" />\n    </template>\n    <template v-if=\"form.pluginType === 'http_proxy'\">\n      <div class=\"field-row two-col\">\n        <ConfigField label=\"HTTP User\" type=\"text\" v-model=\"form.pluginConfig.httpUser\" :readonly=\"readonly\" />\n        <ConfigField label=\"HTTP Password\" type=\"password\" v-model=\"form.pluginConfig.httpPassword\" :readonly=\"readonly\" />\n      </div>\n    </template>\n    <template v-if=\"form.pluginType === 'socks5'\">\n      <div class=\"field-row two-col\">\n        <ConfigField label=\"Username\" type=\"text\" v-model=\"form.pluginConfig.username\" :readonly=\"readonly\" />\n        <ConfigField label=\"Password\" type=\"password\" v-model=\"form.pluginConfig.password\" :readonly=\"readonly\" />\n      </div>\n    </template>\n    <template v-if=\"form.pluginType === 'static_file'\">\n      <div class=\"field-row two-col\">\n        <ConfigField label=\"Local Path\" type=\"text\" v-model=\"form.pluginConfig.localPath\" placeholder=\"/path/to/files\" :readonly=\"readonly\" />\n        <ConfigField label=\"Strip Prefix\" type=\"text\" v-model=\"form.pluginConfig.stripPrefix\" :readonly=\"readonly\" />\n      </div>\n      <div class=\"field-row two-col\">\n        <ConfigField label=\"HTTP User\" type=\"text\" v-model=\"form.pluginConfig.httpUser\" :readonly=\"readonly\" />\n        <ConfigField label=\"HTTP Password\" type=\"password\" v-model=\"form.pluginConfig.httpPassword\" :readonly=\"readonly\" />\n      </div>\n    </template>\n    <template v-if=\"form.pluginType === 'unix_domain_socket'\">\n      <ConfigField label=\"Unix Socket Path\" type=\"text\" v-model=\"form.pluginConfig.unixPath\" placeholder=\"/tmp/socket.sock\" :readonly=\"readonly\" />\n    </template>\n  </template>\n</template>\n\n<script setup lang=\"ts\">\nimport { ref, computed, watch, nextTick, onMounted } from 'vue'\nimport type { ProxyFormData } from '../../types'\nimport ConfigField from '../ConfigField.vue'\n\nconst PLUGIN_LIST = [\n  'http2https', 'http_proxy', 'https2http', 'https2https', 'http2http',\n  'socks5', 'static_file', 'unix_domain_socket', 'tls2raw', 'virtual_net',\n]\n\nconst props = withDefaults(defineProps<{\n  modelValue: ProxyFormData\n  readonly?: boolean\n}>(), { readonly: false })\n\nconst emit = defineEmits<{ 'update:modelValue': [value: ProxyFormData] }>()\n\nconst form = computed({\n  get: () => props.modelValue,\n  set: (val) => emit('update:modelValue', val),\n})\n\nconst backendMode = ref<'direct' | 'plugin'>(form.value.pluginType ? 'plugin' : 'direct')\nconst isHydrating = ref(false)\n\nconst pluginRequestHeaders = computed({\n  get() {\n    const set = form.value.pluginConfig?.requestHeaders?.set\n    if (!set || typeof set !== 'object') return []\n    return Object.entries(set).map(([key, value]) => ({ key, value: String(value) }))\n  },\n  set(val: Array<{ key: string; value: string }>) {\n    if (!form.value.pluginConfig) form.value.pluginConfig = {}\n    if (val.length === 0) {\n      delete form.value.pluginConfig.requestHeaders\n    } else {\n      form.value.pluginConfig.requestHeaders = {\n        set: Object.fromEntries(val.map((e) => [e.key, e.value])),\n      }\n    }\n  },\n})\n\nwatch(() => form.value.pluginType, (newType, oldType) => {\n  if (isHydrating.value) return\n  if (!oldType || !newType || newType === oldType) return\n  if (form.value.pluginConfig && Object.keys(form.value.pluginConfig).length > 0) {\n    form.value.pluginConfig = {}\n  }\n})\n\nwatch(backendMode, (mode) => {\n  if (mode === 'direct') {\n    form.value.pluginType = ''\n    form.value.pluginConfig = {}\n  } else if (!form.value.pluginType) {\n    form.value.pluginType = 'http2https'\n  }\n})\n\nconst hydrate = () => {\n  isHydrating.value = true\n  backendMode.value = form.value.pluginType ? 'plugin' : 'direct'\n  nextTick(() => { isHydrating.value = false })\n}\n\nwatch(() => props.modelValue, () => { hydrate() })\nonMounted(() => { hydrate() })\n</script>\n\n<style scoped lang=\"scss\">\n@use '@/assets/css/form-layout';\n</style>\n"
  },
  {
    "path": "web/frpc/src/components/proxy-form/ProxyBaseSection.vue",
    "content": "<template>\n  <!-- Name / Type / Enabled -->\n  <div v-if=\"!readonly\" class=\"field-row three-col\">\n    <el-form-item label=\"Name\" prop=\"name\" class=\"field-grow\">\n      <el-input\n        v-model=\"form.name\"\n        :disabled=\"editing || readonly\"\n        placeholder=\"my-proxy\"\n      />\n    </el-form-item>\n    <ConfigField\n      label=\"Type\"\n      type=\"select\"\n      v-model=\"form.type\"\n      :disabled=\"editing\"\n      :options=\"PROXY_TYPES.map((t) => ({ label: t.toUpperCase(), value: t }))\"\n      prop=\"type\"\n    />\n    <el-form-item label=\"Enabled\" class=\"switch-field\">\n      <el-switch v-model=\"form.enabled\" size=\"small\" />\n    </el-form-item>\n  </div>\n  <div v-else class=\"field-row three-col\">\n    <ConfigField label=\"Name\" type=\"text\" :model-value=\"form.name\" readonly class=\"field-grow\" />\n    <ConfigField label=\"Type\" type=\"text\" :model-value=\"form.type.toUpperCase()\" readonly />\n    <ConfigField label=\"Enabled\" type=\"switch\" :model-value=\"form.enabled\" readonly />\n  </div>\n</template>\n\n<script setup lang=\"ts\">\nimport { computed } from 'vue'\nimport { PROXY_TYPES, type ProxyFormData } from '../../types'\nimport ConfigField from '../ConfigField.vue'\n\nconst props = withDefaults(defineProps<{\n  modelValue: ProxyFormData\n  readonly?: boolean\n  editing?: boolean\n}>(), { readonly: false, editing: false })\n\nconst emit = defineEmits<{ 'update:modelValue': [value: ProxyFormData] }>()\n\nconst form = computed({\n  get: () => props.modelValue,\n  set: (val) => emit('update:modelValue', val),\n})\n</script>\n\n<style scoped lang=\"scss\">\n@use '@/assets/css/form-layout';\n</style>\n"
  },
  {
    "path": "web/frpc/src/components/proxy-form/ProxyFormLayout.vue",
    "content": "<template>\n  <div class=\"proxy-form-layout\">\n    <ConfigSection :readonly=\"readonly\">\n      <ProxyBaseSection v-model=\"form\" :readonly=\"readonly\" :editing=\"editing\" />\n      <ProxyRemoteSection\n        v-if=\"['tcp', 'udp', 'http', 'https', 'tcpmux'].includes(form.type)\"\n        v-model=\"form\" :readonly=\"readonly\" />\n      <ProxyBackendSection v-model=\"form\" :readonly=\"readonly\" />\n    </ConfigSection>\n\n    <ProxyAuthSection\n      v-if=\"['http', 'tcpmux', 'stcp', 'sudp', 'xtcp'].includes(form.type)\"\n      v-model=\"form\" :readonly=\"readonly\" />\n    <ProxyHttpSection v-if=\"form.type === 'http'\" v-model=\"form\" :readonly=\"readonly\" />\n    <ProxyTransportSection v-model=\"form\" :readonly=\"readonly\" />\n    <ProxyHealthSection v-model=\"form\" :readonly=\"readonly\" />\n    <ProxyLoadBalanceSection v-model=\"form\" :readonly=\"readonly\" />\n    <ProxyNatSection v-if=\"form.type === 'xtcp'\" v-model=\"form\" :readonly=\"readonly\" />\n    <ProxyMetadataSection v-model=\"form\" :readonly=\"readonly\" />\n  </div>\n</template>\n\n<script setup lang=\"ts\">\nimport { computed } from 'vue'\nimport type { ProxyFormData } from '../../types'\nimport ConfigSection from '../ConfigSection.vue'\nimport ProxyBaseSection from './ProxyBaseSection.vue'\nimport ProxyRemoteSection from './ProxyRemoteSection.vue'\nimport ProxyBackendSection from './ProxyBackendSection.vue'\nimport ProxyAuthSection from './ProxyAuthSection.vue'\nimport ProxyHttpSection from './ProxyHttpSection.vue'\nimport ProxyTransportSection from './ProxyTransportSection.vue'\nimport ProxyHealthSection from './ProxyHealthSection.vue'\nimport ProxyLoadBalanceSection from './ProxyLoadBalanceSection.vue'\nimport ProxyNatSection from './ProxyNatSection.vue'\nimport ProxyMetadataSection from './ProxyMetadataSection.vue'\n\nconst props = withDefaults(defineProps<{\n  modelValue: ProxyFormData\n  readonly?: boolean\n  editing?: boolean\n}>(), { readonly: false, editing: false })\n\nconst emit = defineEmits<{ 'update:modelValue': [value: ProxyFormData] }>()\n\nconst form = computed({\n  get: () => props.modelValue,\n  set: (val) => emit('update:modelValue', val),\n})\n</script>\n"
  },
  {
    "path": "web/frpc/src/components/proxy-form/ProxyHealthSection.vue",
    "content": "<template>\n  <ConfigSection title=\"Health Check\" collapsible :readonly=\"readonly\" :has-value=\"!!form.healthCheckType\">\n    <div class=\"field-row two-col\">\n      <ConfigField label=\"Type\" type=\"select\" v-model=\"form.healthCheckType\"\n        :options=\"[{ label: 'Disabled', value: '' }, { label: 'TCP', value: 'tcp' }, { label: 'HTTP', value: 'http' }]\" :readonly=\"readonly\" />\n      <div></div>\n    </div>\n    <template v-if=\"form.healthCheckType\">\n      <div class=\"field-row three-col\">\n        <ConfigField label=\"Timeout (s)\" type=\"number\" v-model=\"form.healthCheckTimeoutSeconds\" :min=\"1\" :readonly=\"readonly\" />\n        <ConfigField label=\"Max Failed\" type=\"number\" v-model=\"form.healthCheckMaxFailed\" :min=\"1\" :readonly=\"readonly\" />\n        <ConfigField label=\"Interval (s)\" type=\"number\" v-model=\"form.healthCheckIntervalSeconds\" :min=\"1\" :readonly=\"readonly\" />\n      </div>\n      <template v-if=\"form.healthCheckType === 'http'\">\n        <ConfigField label=\"Path\" type=\"text\" v-model=\"form.healthCheckPath\" prop=\"healthCheckPath\" placeholder=\"/health\" :readonly=\"readonly\" />\n        <ConfigField label=\"HTTP Headers\" type=\"kv\" v-model=\"healthCheckHeaders\" key-placeholder=\"Header\" value-placeholder=\"Value\" :readonly=\"readonly\" />\n      </template>\n    </template>\n  </ConfigSection>\n</template>\n\n<script setup lang=\"ts\">\nimport { computed } from 'vue'\nimport type { ProxyFormData } from '../../types'\nimport ConfigSection from '../ConfigSection.vue'\nimport ConfigField from '../ConfigField.vue'\n\nconst props = withDefaults(defineProps<{\n  modelValue: ProxyFormData\n  readonly?: boolean\n}>(), { readonly: false })\n\nconst emit = defineEmits<{ 'update:modelValue': [value: ProxyFormData] }>()\n\nconst form = computed({\n  get: () => props.modelValue,\n  set: (val) => emit('update:modelValue', val),\n})\n\nconst healthCheckHeaders = computed({\n  get() {\n    return form.value.healthCheckHTTPHeaders.map((h) => ({ key: h.name, value: h.value }))\n  },\n  set(val: Array<{ key: string; value: string }>) {\n    form.value.healthCheckHTTPHeaders = val.map((h) => ({ name: h.key, value: h.value }))\n  },\n})\n</script>\n\n<style scoped lang=\"scss\">\n@use '@/assets/css/form-layout';\n</style>\n"
  },
  {
    "path": "web/frpc/src/components/proxy-form/ProxyHttpSection.vue",
    "content": "<template>\n  <ConfigSection title=\"HTTP Options\" collapsible :readonly=\"readonly\"\n    :has-value=\"form.locations.length > 0 || !!form.hostHeaderRewrite || form.requestHeaders.length > 0 || form.responseHeaders.length > 0\">\n    <ConfigField label=\"Locations\" type=\"tags\" v-model=\"form.locations\" placeholder=\"/path\" :readonly=\"readonly\" />\n    <ConfigField label=\"Host Header Rewrite\" type=\"text\" v-model=\"form.hostHeaderRewrite\" :readonly=\"readonly\" />\n    <ConfigField label=\"Request Headers\" type=\"kv\" v-model=\"form.requestHeaders\" key-placeholder=\"Header\" value-placeholder=\"Value\" :readonly=\"readonly\" />\n    <ConfigField label=\"Response Headers\" type=\"kv\" v-model=\"form.responseHeaders\" key-placeholder=\"Header\" value-placeholder=\"Value\" :readonly=\"readonly\" />\n  </ConfigSection>\n</template>\n\n<script setup lang=\"ts\">\nimport { computed } from 'vue'\nimport type { ProxyFormData } from '../../types'\nimport ConfigSection from '../ConfigSection.vue'\nimport ConfigField from '../ConfigField.vue'\n\nconst props = withDefaults(defineProps<{\n  modelValue: ProxyFormData\n  readonly?: boolean\n}>(), { readonly: false })\n\nconst emit = defineEmits<{ 'update:modelValue': [value: ProxyFormData] }>()\n\nconst form = computed({\n  get: () => props.modelValue,\n  set: (val) => emit('update:modelValue', val),\n})\n</script>\n\n<style scoped lang=\"scss\">\n@use '@/assets/css/form-layout';\n</style>\n"
  },
  {
    "path": "web/frpc/src/components/proxy-form/ProxyLoadBalanceSection.vue",
    "content": "<template>\n  <ConfigSection title=\"Load Balancer\" collapsible :readonly=\"readonly\" :has-value=\"!!form.loadBalancerGroup\">\n    <div class=\"field-row two-col\">\n      <ConfigField label=\"Group\" type=\"text\" v-model=\"form.loadBalancerGroup\" placeholder=\"Group name\" :readonly=\"readonly\" />\n      <ConfigField label=\"Group Key\" type=\"text\" v-model=\"form.loadBalancerGroupKey\" :readonly=\"readonly\" />\n    </div>\n  </ConfigSection>\n</template>\n\n<script setup lang=\"ts\">\nimport { computed } from 'vue'\nimport type { ProxyFormData } from '../../types'\nimport ConfigSection from '../ConfigSection.vue'\nimport ConfigField from '../ConfigField.vue'\n\nconst props = withDefaults(defineProps<{\n  modelValue: ProxyFormData\n  readonly?: boolean\n}>(), { readonly: false })\n\nconst emit = defineEmits<{ 'update:modelValue': [value: ProxyFormData] }>()\n\nconst form = computed({\n  get: () => props.modelValue,\n  set: (val) => emit('update:modelValue', val),\n})\n</script>\n\n<style scoped lang=\"scss\">\n@use '@/assets/css/form-layout';\n</style>\n"
  },
  {
    "path": "web/frpc/src/components/proxy-form/ProxyMetadataSection.vue",
    "content": "<template>\n  <ConfigSection title=\"Metadata\" collapsible :readonly=\"readonly\" :has-value=\"form.metadatas.length > 0 || form.annotations.length > 0\">\n    <ConfigField label=\"Metadatas\" type=\"kv\" v-model=\"form.metadatas\" :readonly=\"readonly\" />\n    <ConfigField label=\"Annotations\" type=\"kv\" v-model=\"form.annotations\" :readonly=\"readonly\" />\n  </ConfigSection>\n</template>\n\n<script setup lang=\"ts\">\nimport { computed } from 'vue'\nimport type { ProxyFormData } from '../../types'\nimport ConfigSection from '../ConfigSection.vue'\nimport ConfigField from '../ConfigField.vue'\n\nconst props = withDefaults(defineProps<{\n  modelValue: ProxyFormData\n  readonly?: boolean\n}>(), { readonly: false })\n\nconst emit = defineEmits<{ 'update:modelValue': [value: ProxyFormData] }>()\n\nconst form = computed({\n  get: () => props.modelValue,\n  set: (val) => emit('update:modelValue', val),\n})\n</script>\n\n<style scoped lang=\"scss\">\n@use '@/assets/css/form-layout';\n</style>\n"
  },
  {
    "path": "web/frpc/src/components/proxy-form/ProxyNatSection.vue",
    "content": "<template>\n  <ConfigSection title=\"NAT Traversal\" collapsible :readonly=\"readonly\" :has-value=\"form.natTraversalDisableAssistedAddrs\">\n    <ConfigField label=\"Disable Assisted Addresses\" type=\"switch\" v-model=\"form.natTraversalDisableAssistedAddrs\"\n      tip=\"Only use STUN-discovered public addresses\" :readonly=\"readonly\" />\n  </ConfigSection>\n</template>\n\n<script setup lang=\"ts\">\nimport { computed } from 'vue'\nimport type { ProxyFormData } from '../../types'\nimport ConfigSection from '../ConfigSection.vue'\nimport ConfigField from '../ConfigField.vue'\n\nconst props = withDefaults(defineProps<{\n  modelValue: ProxyFormData\n  readonly?: boolean\n}>(), { readonly: false })\n\nconst emit = defineEmits<{ 'update:modelValue': [value: ProxyFormData] }>()\n\nconst form = computed({\n  get: () => props.modelValue,\n  set: (val) => emit('update:modelValue', val),\n})\n</script>\n\n<style scoped lang=\"scss\">\n@use '@/assets/css/form-layout';\n</style>\n"
  },
  {
    "path": "web/frpc/src/components/proxy-form/ProxyRemoteSection.vue",
    "content": "<template>\n  <template v-if=\"['tcp', 'udp'].includes(form.type)\">\n    <div class=\"field-row two-col\">\n      <ConfigField label=\"Remote Port\" type=\"number\" v-model=\"form.remotePort\"\n        :min=\"0\" :max=\"65535\" prop=\"remotePort\" tip=\"Use 0 for random port assignment\" :readonly=\"readonly\" />\n      <div></div>\n    </div>\n  </template>\n  <template v-if=\"['http', 'https', 'tcpmux'].includes(form.type)\">\n    <div class=\"field-row two-col\">\n      <ConfigField label=\"Custom Domains\" type=\"tags\" v-model=\"form.customDomains\"\n        prop=\"customDomains\" placeholder=\"example.com\" :readonly=\"readonly\" />\n      <ConfigField v-if=\"form.type !== 'tcpmux'\" label=\"Subdomain\" type=\"text\"\n        v-model=\"form.subdomain\" placeholder=\"test\" :readonly=\"readonly\" />\n      <ConfigField v-if=\"form.type === 'tcpmux'\" label=\"Multiplexer\" type=\"select\"\n        v-model=\"form.multiplexer\" :options=\"[{ label: 'HTTP CONNECT', value: 'httpconnect' }]\" :readonly=\"readonly\" />\n    </div>\n  </template>\n</template>\n\n<script setup lang=\"ts\">\nimport { computed } from 'vue'\nimport type { ProxyFormData } from '../../types'\nimport ConfigField from '../ConfigField.vue'\n\nconst props = withDefaults(defineProps<{\n  modelValue: ProxyFormData\n  readonly?: boolean\n}>(), { readonly: false })\n\nconst emit = defineEmits<{ 'update:modelValue': [value: ProxyFormData] }>()\n\nconst form = computed({\n  get: () => props.modelValue,\n  set: (val) => emit('update:modelValue', val),\n})\n</script>\n\n<style scoped lang=\"scss\">\n@use '@/assets/css/form-layout';\n</style>\n"
  },
  {
    "path": "web/frpc/src/components/proxy-form/ProxyTransportSection.vue",
    "content": "<template>\n  <ConfigSection title=\"Transport\" collapsible :readonly=\"readonly\"\n    :has-value=\"form.useEncryption || form.useCompression || !!form.bandwidthLimit || (!!form.bandwidthLimitMode && form.bandwidthLimitMode !== 'client') || !!form.proxyProtocolVersion\">\n    <div class=\"field-row two-col\">\n      <ConfigField label=\"Use Encryption\" type=\"switch\" v-model=\"form.useEncryption\" :readonly=\"readonly\" />\n      <ConfigField label=\"Use Compression\" type=\"switch\" v-model=\"form.useCompression\" :readonly=\"readonly\" />\n    </div>\n    <div class=\"field-row three-col\">\n      <ConfigField label=\"Bandwidth Limit\" type=\"text\" v-model=\"form.bandwidthLimit\" placeholder=\"1MB\" tip=\"e.g., 1MB, 500KB\" :readonly=\"readonly\" />\n      <ConfigField label=\"Bandwidth Limit Mode\" type=\"select\" v-model=\"form.bandwidthLimitMode\"\n        :options=\"[{ label: 'Client', value: 'client' }, { label: 'Server', value: 'server' }]\" :readonly=\"readonly\" />\n      <ConfigField label=\"Proxy Protocol Version\" type=\"select\" v-model=\"form.proxyProtocolVersion\"\n        :options=\"[{ label: 'None', value: '' }, { label: 'v1', value: 'v1' }, { label: 'v2', value: 'v2' }]\" :readonly=\"readonly\" />\n    </div>\n  </ConfigSection>\n</template>\n\n<script setup lang=\"ts\">\nimport { computed } from 'vue'\nimport type { ProxyFormData } from '../../types'\nimport ConfigSection from '../ConfigSection.vue'\nimport ConfigField from '../ConfigField.vue'\n\nconst props = withDefaults(defineProps<{\n  modelValue: ProxyFormData\n  readonly?: boolean\n}>(), { readonly: false })\n\nconst emit = defineEmits<{ 'update:modelValue': [value: ProxyFormData] }>()\n\nconst form = computed({\n  get: () => props.modelValue,\n  set: (val) => emit('update:modelValue', val),\n})\n</script>\n\n<style scoped lang=\"scss\">\n@use '@/assets/css/form-layout';\n</style>\n"
  },
  {
    "path": "web/frpc/src/components/visitor-form/VisitorBaseSection.vue",
    "content": "<template>\n  <div v-if=\"!readonly\" class=\"field-row three-col\">\n    <el-form-item label=\"Name\" prop=\"name\" class=\"field-grow\">\n      <el-input v-model=\"form.name\" :disabled=\"editing || readonly\" placeholder=\"my-visitor\" />\n    </el-form-item>\n    <ConfigField label=\"Type\" type=\"select\" v-model=\"form.type\" :disabled=\"editing\"\n      :options=\"[{ label: 'STCP', value: 'stcp' }, { label: 'SUDP', value: 'sudp' }, { label: 'XTCP', value: 'xtcp' }]\" prop=\"type\" />\n    <el-form-item label=\"Enabled\" class=\"switch-field\">\n      <el-switch v-model=\"form.enabled\" size=\"small\" />\n    </el-form-item>\n  </div>\n  <div v-else class=\"field-row three-col\">\n    <ConfigField label=\"Name\" type=\"text\" :model-value=\"form.name\" readonly class=\"field-grow\" />\n    <ConfigField label=\"Type\" type=\"text\" :model-value=\"form.type.toUpperCase()\" readonly />\n    <ConfigField label=\"Enabled\" type=\"switch\" :model-value=\"form.enabled\" readonly />\n  </div>\n</template>\n\n<script setup lang=\"ts\">\nimport { computed } from 'vue'\nimport type { VisitorFormData } from '../../types'\nimport ConfigField from '../ConfigField.vue'\n\nconst props = withDefaults(defineProps<{\n  modelValue: VisitorFormData\n  readonly?: boolean\n  editing?: boolean\n}>(), { readonly: false, editing: false })\n\nconst emit = defineEmits<{ 'update:modelValue': [value: VisitorFormData] }>()\n\nconst form = computed({\n  get: () => props.modelValue,\n  set: (val) => emit('update:modelValue', val),\n})\n</script>\n\n<style scoped lang=\"scss\">\n@use '@/assets/css/form-layout';\n</style>\n"
  },
  {
    "path": "web/frpc/src/components/visitor-form/VisitorConnectionSection.vue",
    "content": "<template>\n  <ConfigSection title=\"Connection\" :readonly=\"readonly\">\n    <div class=\"field-row two-col\">\n      <ConfigField label=\"Server Name\" type=\"text\" v-model=\"form.serverName\" prop=\"serverName\"\n        placeholder=\"Name of the proxy to visit\" :readonly=\"readonly\" />\n      <ConfigField label=\"Server User\" type=\"text\" v-model=\"form.serverUser\"\n        placeholder=\"Leave empty for same user\" :readonly=\"readonly\" />\n    </div>\n    <ConfigField label=\"Secret Key\" type=\"password\" v-model=\"form.secretKey\"\n      placeholder=\"Shared secret\" :readonly=\"readonly\" />\n    <div class=\"field-row two-col\">\n      <ConfigField label=\"Bind Address\" type=\"text\" v-model=\"form.bindAddr\"\n        placeholder=\"127.0.0.1\" :readonly=\"readonly\" />\n      <ConfigField label=\"Bind Port\" type=\"number\" v-model=\"form.bindPort\"\n        :min=\"bindPortMin\" :max=\"65535\" prop=\"bindPort\" :readonly=\"readonly\" />\n    </div>\n  </ConfigSection>\n</template>\n\n<script setup lang=\"ts\">\nimport { computed } from 'vue'\nimport type { VisitorFormData } from '../../types'\nimport ConfigSection from '../ConfigSection.vue'\nimport ConfigField from '../ConfigField.vue'\n\nconst props = withDefaults(defineProps<{\n  modelValue: VisitorFormData\n  readonly?: boolean\n}>(), { readonly: false })\n\nconst emit = defineEmits<{ 'update:modelValue': [value: VisitorFormData] }>()\n\nconst form = computed({\n  get: () => props.modelValue,\n  set: (val) => emit('update:modelValue', val),\n})\n\nconst bindPortMin = computed(() => (form.value.type === 'sudp' ? 1 : undefined))\n</script>\n\n<style scoped lang=\"scss\">\n@use '@/assets/css/form-layout';\n</style>\n"
  },
  {
    "path": "web/frpc/src/components/visitor-form/VisitorFormLayout.vue",
    "content": "<template>\n  <div class=\"visitor-form-layout\">\n    <ConfigSection :readonly=\"readonly\">\n      <VisitorBaseSection v-model=\"form\" :readonly=\"readonly\" :editing=\"editing\" />\n    </ConfigSection>\n    <VisitorConnectionSection v-model=\"form\" :readonly=\"readonly\" />\n    <VisitorTransportSection v-model=\"form\" :readonly=\"readonly\" />\n    <VisitorXtcpSection v-if=\"form.type === 'xtcp'\" v-model=\"form\" :readonly=\"readonly\" />\n  </div>\n</template>\n\n<script setup lang=\"ts\">\nimport { computed } from 'vue'\nimport type { VisitorFormData } from '../../types'\nimport ConfigSection from '../ConfigSection.vue'\nimport VisitorBaseSection from './VisitorBaseSection.vue'\nimport VisitorConnectionSection from './VisitorConnectionSection.vue'\nimport VisitorTransportSection from './VisitorTransportSection.vue'\nimport VisitorXtcpSection from './VisitorXtcpSection.vue'\n\nconst props = withDefaults(defineProps<{\n  modelValue: VisitorFormData\n  readonly?: boolean\n  editing?: boolean\n}>(), { readonly: false, editing: false })\n\nconst emit = defineEmits<{ 'update:modelValue': [value: VisitorFormData] }>()\n\nconst form = computed({\n  get: () => props.modelValue,\n  set: (val) => emit('update:modelValue', val),\n})\n</script>\n"
  },
  {
    "path": "web/frpc/src/components/visitor-form/VisitorTransportSection.vue",
    "content": "<template>\n  <ConfigSection title=\"Transport Options\" collapsible :readonly=\"readonly\"\n    :has-value=\"form.useEncryption || form.useCompression\">\n    <div class=\"field-row two-col\">\n      <ConfigField label=\"Use Encryption\" type=\"switch\" v-model=\"form.useEncryption\" :readonly=\"readonly\" />\n      <ConfigField label=\"Use Compression\" type=\"switch\" v-model=\"form.useCompression\" :readonly=\"readonly\" />\n    </div>\n  </ConfigSection>\n</template>\n\n<script setup lang=\"ts\">\nimport { computed } from 'vue'\nimport type { VisitorFormData } from '../../types'\nimport ConfigSection from '../ConfigSection.vue'\nimport ConfigField from '../ConfigField.vue'\n\nconst props = withDefaults(defineProps<{\n  modelValue: VisitorFormData\n  readonly?: boolean\n}>(), { readonly: false })\n\nconst emit = defineEmits<{ 'update:modelValue': [value: VisitorFormData] }>()\n\nconst form = computed({\n  get: () => props.modelValue,\n  set: (val) => emit('update:modelValue', val),\n})\n</script>\n\n<style scoped lang=\"scss\">\n@use '@/assets/css/form-layout';\n</style>\n"
  },
  {
    "path": "web/frpc/src/components/visitor-form/VisitorXtcpSection.vue",
    "content": "<template>\n  <!-- XTCP Options -->\n  <ConfigSection title=\"XTCP Options\" collapsible :readonly=\"readonly\"\n    :has-value=\"form.protocol !== 'quic' || form.keepTunnelOpen || form.maxRetriesAnHour != null || form.minRetryInterval != null || !!form.fallbackTo || form.fallbackTimeoutMs != null\">\n    <ConfigField label=\"Protocol\" type=\"select\" v-model=\"form.protocol\"\n      :options=\"[{ label: 'QUIC', value: 'quic' }, { label: 'KCP', value: 'kcp' }]\" :readonly=\"readonly\" />\n    <ConfigField label=\"Keep Tunnel Open\" type=\"switch\" v-model=\"form.keepTunnelOpen\" :readonly=\"readonly\" />\n    <div class=\"field-row two-col\">\n      <ConfigField label=\"Max Retries per Hour\" type=\"number\" v-model=\"form.maxRetriesAnHour\" :min=\"0\" :readonly=\"readonly\" />\n      <ConfigField label=\"Min Retry Interval (s)\" type=\"number\" v-model=\"form.minRetryInterval\" :min=\"0\" :readonly=\"readonly\" />\n    </div>\n    <div class=\"field-row two-col\">\n      <ConfigField label=\"Fallback To\" type=\"text\" v-model=\"form.fallbackTo\" placeholder=\"Fallback visitor name\" :readonly=\"readonly\" />\n      <ConfigField label=\"Fallback Timeout (ms)\" type=\"number\" v-model=\"form.fallbackTimeoutMs\" :min=\"0\" :readonly=\"readonly\" />\n    </div>\n  </ConfigSection>\n\n  <!-- NAT Traversal -->\n  <ConfigSection title=\"NAT Traversal\" collapsible :readonly=\"readonly\"\n    :has-value=\"form.natTraversalDisableAssistedAddrs\">\n    <ConfigField label=\"Disable Assisted Addresses\" type=\"switch\" v-model=\"form.natTraversalDisableAssistedAddrs\"\n      tip=\"Only use STUN-discovered public addresses\" :readonly=\"readonly\" />\n  </ConfigSection>\n</template>\n\n<script setup lang=\"ts\">\nimport { computed } from 'vue'\nimport type { VisitorFormData } from '../../types'\nimport ConfigSection from '../ConfigSection.vue'\nimport ConfigField from '../ConfigField.vue'\n\nconst props = withDefaults(defineProps<{\n  modelValue: VisitorFormData\n  readonly?: boolean\n}>(), { readonly: false })\n\nconst emit = defineEmits<{ 'update:modelValue': [value: VisitorFormData] }>()\n\nconst form = computed({\n  get: () => props.modelValue,\n  set: (val) => emit('update:modelValue', val),\n})\n</script>\n\n<style scoped lang=\"scss\">\n@use '@/assets/css/form-layout';\n</style>\n"
  },
  {
    "path": "web/frpc/src/composables/useResponsive.ts",
    "content": "import { useBreakpoints } from '@vueuse/core'\n\nconst breakpoints = useBreakpoints({ mobile: 0, desktop: 768 })\n\nexport function useResponsive() {\n  const isMobile = breakpoints.smaller('desktop') // < 768px\n  return { isMobile }\n}\n"
  },
  {
    "path": "web/frpc/src/main.ts",
    "content": "import { createApp } from 'vue'\nimport { createPinia } from 'pinia'\nimport 'element-plus/theme-chalk/dark/css-vars.css'\nimport App from './App.vue'\nimport router from './router'\n\nimport './assets/css/var.css'\nimport './assets/css/dark.css'\n\nconst app = createApp(App)\n\napp.use(createPinia())\napp.use(router)\n\napp.mount('#app')\n"
  },
  {
    "path": "web/frpc/src/router/index.ts",
    "content": "import { createRouter, createWebHashHistory } from 'vue-router'\nimport { ElMessage } from 'element-plus'\nimport ClientConfigure from '../views/ClientConfigure.vue'\nimport ProxyEdit from '../views/ProxyEdit.vue'\nimport VisitorEdit from '../views/VisitorEdit.vue'\nimport { useProxyStore } from '../stores/proxy'\n\nconst router = createRouter({\n  history: createWebHashHistory(),\n  routes: [\n    {\n      path: '/',\n      redirect: '/proxies',\n    },\n    {\n      path: '/proxies',\n      name: 'ProxyList',\n      component: () => import('../views/ProxyList.vue'),\n    },\n    {\n      path: '/proxies/detail/:name',\n      name: 'ProxyDetail',\n      component: () => import('../views/ProxyDetail.vue'),\n    },\n    {\n      path: '/proxies/create',\n      name: 'ProxyCreate',\n      component: ProxyEdit,\n      meta: { requiresStore: true },\n    },\n    {\n      path: '/proxies/:name/edit',\n      name: 'ProxyEdit',\n      component: ProxyEdit,\n      meta: { requiresStore: true },\n    },\n    {\n      path: '/visitors',\n      name: 'VisitorList',\n      component: () => import('../views/VisitorList.vue'),\n    },\n    {\n      path: '/visitors/detail/:name',\n      name: 'VisitorDetail',\n      component: () => import('../views/VisitorDetail.vue'),\n    },\n    {\n      path: '/visitors/create',\n      name: 'VisitorCreate',\n      component: VisitorEdit,\n      meta: { requiresStore: true },\n    },\n    {\n      path: '/visitors/:name/edit',\n      name: 'VisitorEdit',\n      component: VisitorEdit,\n      meta: { requiresStore: true },\n    },\n    {\n      path: '/config',\n      name: 'ClientConfigure',\n      component: ClientConfigure,\n    },\n  ],\n})\n\nrouter.beforeEach(async (to) => {\n  if (!to.matched.some((record) => record.meta.requiresStore)) {\n    return true\n  }\n\n  const proxyStore = useProxyStore()\n  const enabled = await proxyStore.checkStoreEnabled()\n  if (enabled) {\n    return true\n  }\n\n  ElMessage.warning(\n    'Store is disabled. Enable Store in frpc config to create or edit store entries.',\n  )\n  return { name: 'ProxyList' }\n})\n\nexport default router\n"
  },
  {
    "path": "web/frpc/src/stores/client.ts",
    "content": "import { defineStore } from 'pinia'\nimport { ref } from 'vue'\nimport { getConfig, putConfig, reloadConfig } from '../api/frpc'\n\nexport const useClientStore = defineStore('client', () => {\n  const config = ref('')\n  const loading = ref(false)\n\n  const fetchConfig = async () => {\n    loading.value = true\n    try {\n      config.value = await getConfig()\n    } finally {\n      loading.value = false\n    }\n  }\n\n  const saveConfig = async (text: string) => {\n    await putConfig(text)\n    config.value = text\n  }\n\n  const reload = async () => {\n    await reloadConfig()\n  }\n\n  return { config, loading, fetchConfig, saveConfig, reload }\n})\n"
  },
  {
    "path": "web/frpc/src/stores/proxy.ts",
    "content": "import { defineStore } from 'pinia'\nimport { ref } from 'vue'\nimport type { ProxyStatus, ProxyDefinition } from '../types'\nimport {\n  getStatus,\n  listStoreProxies,\n  getStoreProxy,\n  createStoreProxy,\n  updateStoreProxy,\n  deleteStoreProxy,\n} from '../api/frpc'\n\nexport const useProxyStore = defineStore('proxy', () => {\n  const proxies = ref<ProxyStatus[]>([])\n  const storeProxies = ref<ProxyDefinition[]>([])\n  const storeEnabled = ref(false)\n  const storeChecked = ref(false)\n  const loading = ref(false)\n  const storeLoading = ref(false)\n  const error = ref<string | null>(null)\n\n  const fetchStatus = async () => {\n    loading.value = true\n    error.value = null\n    try {\n      const json = await getStatus()\n      const list: ProxyStatus[] = []\n      for (const key in json) {\n        for (const ps of json[key]) {\n          list.push(ps)\n        }\n      }\n      proxies.value = list\n    } catch (err: any) {\n      error.value = err.message\n      throw err\n    } finally {\n      loading.value = false\n    }\n  }\n\n  const fetchStoreProxies = async () => {\n    storeLoading.value = true\n    try {\n      const res = await listStoreProxies()\n      storeProxies.value = res.proxies || []\n      storeEnabled.value = true\n      storeChecked.value = true\n    } catch (err: any) {\n      if (err?.status === 404) {\n        storeEnabled.value = false\n      }\n      storeChecked.value = true\n    } finally {\n      storeLoading.value = false\n    }\n  }\n\n  const checkStoreEnabled = async () => {\n    if (storeChecked.value) return storeEnabled.value\n    await fetchStoreProxies()\n    return storeEnabled.value\n  }\n\n  const createProxy = async (data: ProxyDefinition) => {\n    await createStoreProxy(data)\n    await fetchStoreProxies()\n  }\n\n  const updateProxy = async (name: string, data: ProxyDefinition) => {\n    await updateStoreProxy(name, data)\n    await fetchStoreProxies()\n  }\n\n  const deleteProxy = async (name: string) => {\n    await deleteStoreProxy(name)\n    await fetchStoreProxies()\n  }\n\n  const toggleProxy = async (name: string, enabled: boolean) => {\n    const def = await getStoreProxy(name)\n    const block = (def as any)[def.type]\n    if (block) {\n      block.enabled = enabled\n    }\n    await updateStoreProxy(name, def)\n    await fetchStatus()\n    await fetchStoreProxies()\n  }\n\n  const storeProxyWithStatus = (def: ProxyDefinition): ProxyStatus => {\n    const block = (def as any)[def.type]\n    const enabled = block?.enabled !== false\n\n    const localIP = block?.localIP || '127.0.0.1'\n    const localPort = block?.localPort\n    const local_addr = localPort != null ? `${localIP}:${localPort}` : ''\n    const remotePort = block?.remotePort\n    const remote_addr = remotePort != null ? `:${remotePort}` : ''\n    const plugin = block?.plugin?.type || ''\n\n    const status = proxies.value.find((p) => p.name === def.name)\n    return {\n      name: def.name,\n      type: def.type,\n      status: !enabled ? 'disabled' : (status?.status || 'waiting'),\n      err: status?.err || '',\n      local_addr: status?.local_addr || local_addr,\n      remote_addr: status?.remote_addr || remote_addr,\n      plugin: status?.plugin || plugin,\n      source: 'store',\n    }\n  }\n\n  return {\n    proxies,\n    storeProxies,\n    storeEnabled,\n    storeChecked,\n    loading,\n    storeLoading,\n    error,\n    fetchStatus,\n    fetchStoreProxies,\n    checkStoreEnabled,\n    createProxy,\n    updateProxy,\n    deleteProxy,\n    toggleProxy,\n    storeProxyWithStatus,\n  }\n})\n"
  },
  {
    "path": "web/frpc/src/stores/visitor.ts",
    "content": "import { defineStore } from 'pinia'\nimport { ref } from 'vue'\nimport type { VisitorDefinition } from '../types'\nimport {\n  listStoreVisitors,\n  createStoreVisitor,\n  updateStoreVisitor,\n  deleteStoreVisitor,\n} from '../api/frpc'\n\nexport const useVisitorStore = defineStore('visitor', () => {\n  const storeVisitors = ref<VisitorDefinition[]>([])\n  const storeEnabled = ref(false)\n  const storeChecked = ref(false)\n  const loading = ref(false)\n  const error = ref<string | null>(null)\n\n  const fetchStoreVisitors = async () => {\n    loading.value = true\n    try {\n      const res = await listStoreVisitors()\n      storeVisitors.value = res.visitors || []\n      storeEnabled.value = true\n      storeChecked.value = true\n    } catch (err: any) {\n      if (err?.status === 404) {\n        storeEnabled.value = false\n      }\n      storeChecked.value = true\n    } finally {\n      loading.value = false\n    }\n  }\n\n  const checkStoreEnabled = async () => {\n    if (storeChecked.value) return storeEnabled.value\n    await fetchStoreVisitors()\n    return storeEnabled.value\n  }\n\n  const createVisitor = async (data: VisitorDefinition) => {\n    await createStoreVisitor(data)\n    await fetchStoreVisitors()\n  }\n\n  const updateVisitor = async (name: string, data: VisitorDefinition) => {\n    await updateStoreVisitor(name, data)\n    await fetchStoreVisitors()\n  }\n\n  const deleteVisitor = async (name: string) => {\n    await deleteStoreVisitor(name)\n    await fetchStoreVisitors()\n  }\n\n  return {\n    storeVisitors,\n    storeEnabled,\n    storeChecked,\n    loading,\n    error,\n    fetchStoreVisitors,\n    checkStoreEnabled,\n    createVisitor,\n    updateVisitor,\n    deleteVisitor,\n  }\n})\n"
  },
  {
    "path": "web/frpc/src/svg.d.ts",
    "content": "declare module '*.svg?component' {\n  import type { DefineComponent } from 'vue'\n  const component: DefineComponent<object, object, unknown>\n  export default component\n}\n"
  },
  {
    "path": "web/frpc/src/types/constants.ts",
    "content": "export const PROXY_TYPES = [\n  'tcp',\n  'udp',\n  'http',\n  'https',\n  'tcpmux',\n  'stcp',\n  'sudp',\n  'xtcp',\n] as const\n\nexport type ProxyType = (typeof PROXY_TYPES)[number]\n\nexport const VISITOR_TYPES = ['stcp', 'sudp', 'xtcp'] as const\n\nexport type VisitorType = (typeof VISITOR_TYPES)[number]\n\nexport const PLUGIN_TYPES = [\n  '',\n  'http2https',\n  'http_proxy',\n  'https2http',\n  'https2https',\n  'http2http',\n  'socks5',\n  'static_file',\n  'unix_domain_socket',\n  'tls2raw',\n  'virtual_net',\n] as const\n\nexport type PluginType = (typeof PLUGIN_TYPES)[number]\n"
  },
  {
    "path": "web/frpc/src/types/index.ts",
    "content": "export * from './constants'\nexport * from './proxy-status'\nexport * from './proxy-store'\nexport * from './proxy-form'\nexport * from './proxy-converters'\n"
  },
  {
    "path": "web/frpc/src/types/proxy-converters.ts",
    "content": "import type { ProxyType, VisitorType } from './constants'\nimport type { ProxyFormData, VisitorFormData } from './proxy-form'\nimport { createDefaultProxyForm, createDefaultVisitorForm } from './proxy-form'\nimport type { ProxyDefinition, VisitorDefinition } from './proxy-store'\n\n// ========================================\n// CONVERTERS: Form -> Store API\n// ========================================\n\nexport function formToStoreProxy(form: ProxyFormData): ProxyDefinition {\n  const block: Record<string, any> = {}\n\n  // Enabled (nil/true = enabled, false = disabled)\n  if (!form.enabled) {\n    block.enabled = false\n  }\n\n  // Backend - LocalIP/LocalPort\n  if (form.pluginType === '') {\n    if (form.localIP && form.localIP !== '127.0.0.1') {\n      block.localIP = form.localIP\n    }\n    if (form.localPort != null) {\n      block.localPort = form.localPort\n    }\n  } else {\n    block.plugin = {\n      type: form.pluginType,\n      ...form.pluginConfig,\n    }\n  }\n\n  // Transport\n  if (\n    form.useEncryption ||\n    form.useCompression ||\n    form.bandwidthLimit ||\n    (form.bandwidthLimitMode && form.bandwidthLimitMode !== 'client') ||\n    form.proxyProtocolVersion\n  ) {\n    block.transport = {}\n    if (form.useEncryption) block.transport.useEncryption = true\n    if (form.useCompression) block.transport.useCompression = true\n    if (form.bandwidthLimit) block.transport.bandwidthLimit = form.bandwidthLimit\n    if (form.bandwidthLimitMode && form.bandwidthLimitMode !== 'client') {\n      block.transport.bandwidthLimitMode = form.bandwidthLimitMode\n    }\n    if (form.proxyProtocolVersion) {\n      block.transport.proxyProtocolVersion = form.proxyProtocolVersion\n    }\n  }\n\n  // Load Balancer\n  if (form.loadBalancerGroup) {\n    block.loadBalancer = {\n      group: form.loadBalancerGroup,\n    }\n    if (form.loadBalancerGroupKey) {\n      block.loadBalancer.groupKey = form.loadBalancerGroupKey\n    }\n  }\n\n  // Health Check\n  if (form.healthCheckType) {\n    block.healthCheck = {\n      type: form.healthCheckType,\n    }\n    if (form.healthCheckTimeoutSeconds != null) {\n      block.healthCheck.timeoutSeconds = form.healthCheckTimeoutSeconds\n    }\n    if (form.healthCheckMaxFailed != null) {\n      block.healthCheck.maxFailed = form.healthCheckMaxFailed\n    }\n    if (form.healthCheckIntervalSeconds != null) {\n      block.healthCheck.intervalSeconds = form.healthCheckIntervalSeconds\n    }\n    if (form.healthCheckPath) {\n      block.healthCheck.path = form.healthCheckPath\n    }\n    if (form.healthCheckHTTPHeaders.length > 0) {\n      block.healthCheck.httpHeaders = form.healthCheckHTTPHeaders\n    }\n  }\n\n  // Metadata\n  if (form.metadatas.length > 0) {\n    block.metadatas = Object.fromEntries(\n      form.metadatas.map((m) => [m.key, m.value]),\n    )\n  }\n\n  // Annotations\n  if (form.annotations.length > 0) {\n    block.annotations = Object.fromEntries(\n      form.annotations.map((a) => [a.key, a.value]),\n    )\n  }\n\n  // Type-specific fields\n  if ((form.type === 'tcp' || form.type === 'udp') && form.remotePort != null) {\n    block.remotePort = form.remotePort\n  }\n\n  if (form.type === 'http' || form.type === 'https' || form.type === 'tcpmux') {\n    if (form.customDomains.length > 0) {\n      block.customDomains = form.customDomains.filter(Boolean)\n    }\n    if (form.subdomain) {\n      block.subdomain = form.subdomain\n    }\n  }\n\n  if (form.type === 'http') {\n    if (form.locations.length > 0) {\n      block.locations = form.locations.filter(Boolean)\n    }\n    if (form.httpUser) block.httpUser = form.httpUser\n    if (form.httpPassword) block.httpPassword = form.httpPassword\n    if (form.hostHeaderRewrite) block.hostHeaderRewrite = form.hostHeaderRewrite\n    if (form.routeByHTTPUser) block.routeByHTTPUser = form.routeByHTTPUser\n\n    if (form.requestHeaders.length > 0) {\n      block.requestHeaders = {\n        set: Object.fromEntries(\n          form.requestHeaders.map((h) => [h.key, h.value]),\n        ),\n      }\n    }\n    if (form.responseHeaders.length > 0) {\n      block.responseHeaders = {\n        set: Object.fromEntries(\n          form.responseHeaders.map((h) => [h.key, h.value]),\n        ),\n      }\n    }\n  }\n\n  if (form.type === 'tcpmux') {\n    if (form.httpUser) block.httpUser = form.httpUser\n    if (form.httpPassword) block.httpPassword = form.httpPassword\n    if (form.routeByHTTPUser) block.routeByHTTPUser = form.routeByHTTPUser\n    if (form.multiplexer && form.multiplexer !== 'httpconnect') {\n      block.multiplexer = form.multiplexer\n    }\n  }\n\n  if (form.type === 'stcp' || form.type === 'sudp' || form.type === 'xtcp') {\n    if (form.secretKey) block.secretKey = form.secretKey\n    if (form.allowUsers.length > 0) {\n      block.allowUsers = form.allowUsers.filter(Boolean)\n    }\n  }\n\n  if (form.type === 'xtcp' && form.natTraversalDisableAssistedAddrs) {\n    block.natTraversal = {\n      disableAssistedAddrs: true,\n    }\n  }\n\n  return withStoreProxyBlock(\n    {\n      name: form.name,\n      type: form.type,\n    },\n    form.type,\n    block,\n  )\n}\n\nexport function formToStoreVisitor(form: VisitorFormData): VisitorDefinition {\n  const block: Record<string, any> = {}\n\n  if (!form.enabled) {\n    block.enabled = false\n  }\n\n  if (form.useEncryption || form.useCompression) {\n    block.transport = {}\n    if (form.useEncryption) block.transport.useEncryption = true\n    if (form.useCompression) block.transport.useCompression = true\n  }\n\n  if (form.secretKey) block.secretKey = form.secretKey\n  if (form.serverUser) block.serverUser = form.serverUser\n  if (form.serverName) block.serverName = form.serverName\n  if (form.bindAddr && form.bindAddr !== '127.0.0.1') {\n    block.bindAddr = form.bindAddr\n  }\n  if (form.bindPort != null) {\n    block.bindPort = form.bindPort\n  }\n\n  if (form.type === 'xtcp') {\n    if (form.protocol && form.protocol !== 'quic') {\n      block.protocol = form.protocol\n    }\n    if (form.keepTunnelOpen) {\n      block.keepTunnelOpen = true\n    }\n    if (form.maxRetriesAnHour != null) {\n      block.maxRetriesAnHour = form.maxRetriesAnHour\n    }\n    if (form.minRetryInterval != null) {\n      block.minRetryInterval = form.minRetryInterval\n    }\n    if (form.fallbackTo) {\n      block.fallbackTo = form.fallbackTo\n    }\n    if (form.fallbackTimeoutMs != null) {\n      block.fallbackTimeoutMs = form.fallbackTimeoutMs\n    }\n    if (form.natTraversalDisableAssistedAddrs) {\n      block.natTraversal = {\n        disableAssistedAddrs: true,\n      }\n    }\n  }\n\n  return withStoreVisitorBlock(\n    {\n      name: form.name,\n      type: form.type,\n    },\n    form.type,\n    block,\n  )\n}\n\n// ========================================\n// CONVERTERS: Store API -> Form\n// ========================================\n\nfunction getStoreProxyBlock(config: ProxyDefinition): Record<string, any> {\n  switch (config.type) {\n    case 'tcp':\n      return config.tcp || {}\n    case 'udp':\n      return config.udp || {}\n    case 'http':\n      return config.http || {}\n    case 'https':\n      return config.https || {}\n    case 'tcpmux':\n      return config.tcpmux || {}\n    case 'stcp':\n      return config.stcp || {}\n    case 'sudp':\n      return config.sudp || {}\n    case 'xtcp':\n      return config.xtcp || {}\n  }\n}\n\nfunction withStoreProxyBlock(\n  payload: ProxyDefinition,\n  type: ProxyType,\n  block: Record<string, any>,\n): ProxyDefinition {\n  switch (type) {\n    case 'tcp':\n      payload.tcp = block\n      break\n    case 'udp':\n      payload.udp = block\n      break\n    case 'http':\n      payload.http = block\n      break\n    case 'https':\n      payload.https = block\n      break\n    case 'tcpmux':\n      payload.tcpmux = block\n      break\n    case 'stcp':\n      payload.stcp = block\n      break\n    case 'sudp':\n      payload.sudp = block\n      break\n    case 'xtcp':\n      payload.xtcp = block\n      break\n  }\n  return payload\n}\n\nfunction getStoreVisitorBlock(config: VisitorDefinition): Record<string, any> {\n  switch (config.type) {\n    case 'stcp':\n      return config.stcp || {}\n    case 'sudp':\n      return config.sudp || {}\n    case 'xtcp':\n      return config.xtcp || {}\n  }\n}\n\nfunction withStoreVisitorBlock(\n  payload: VisitorDefinition,\n  type: VisitorType,\n  block: Record<string, any>,\n): VisitorDefinition {\n  switch (type) {\n    case 'stcp':\n      payload.stcp = block\n      break\n    case 'sudp':\n      payload.sudp = block\n      break\n    case 'xtcp':\n      payload.xtcp = block\n      break\n  }\n  return payload\n}\n\nexport function storeProxyToForm(config: ProxyDefinition): ProxyFormData {\n  const c = getStoreProxyBlock(config)\n  const form = createDefaultProxyForm()\n\n  form.name = config.name || ''\n  form.type = config.type || 'tcp'\n  form.enabled = c.enabled !== false\n\n  // Backend\n  form.localIP = c.localIP || '127.0.0.1'\n  form.localPort = c.localPort\n  if (c.plugin?.type) {\n    form.pluginType = c.plugin.type\n    form.pluginConfig = { ...c.plugin }\n    delete form.pluginConfig.type\n  }\n\n  // Transport\n  if (c.transport) {\n    form.useEncryption = c.transport.useEncryption || false\n    form.useCompression = c.transport.useCompression || false\n    form.bandwidthLimit = c.transport.bandwidthLimit || ''\n    form.bandwidthLimitMode = c.transport.bandwidthLimitMode || 'client'\n    form.proxyProtocolVersion = c.transport.proxyProtocolVersion || ''\n  }\n\n  // Load Balancer\n  if (c.loadBalancer) {\n    form.loadBalancerGroup = c.loadBalancer.group || ''\n    form.loadBalancerGroupKey = c.loadBalancer.groupKey || ''\n  }\n\n  // Health Check\n  if (c.healthCheck) {\n    form.healthCheckType = c.healthCheck.type || ''\n    form.healthCheckTimeoutSeconds = c.healthCheck.timeoutSeconds\n    form.healthCheckMaxFailed = c.healthCheck.maxFailed\n    form.healthCheckIntervalSeconds = c.healthCheck.intervalSeconds\n    form.healthCheckPath = c.healthCheck.path || ''\n    form.healthCheckHTTPHeaders = c.healthCheck.httpHeaders || []\n  }\n\n  // Metadata\n  if (c.metadatas) {\n    form.metadatas = Object.entries(c.metadatas).map(([key, value]) => ({\n      key,\n      value: String(value),\n    }))\n  }\n\n  // Annotations\n  if (c.annotations) {\n    form.annotations = Object.entries(c.annotations).map(([key, value]) => ({\n      key,\n      value: String(value),\n    }))\n  }\n\n  // Type-specific fields\n  form.remotePort = c.remotePort\n\n  // Domain config\n  if (Array.isArray(c.customDomains)) {\n    form.customDomains = c.customDomains\n  } else if (c.customDomains) {\n    form.customDomains = [c.customDomains]\n  }\n  form.subdomain = c.subdomain || ''\n\n  // HTTP specific\n  if (Array.isArray(c.locations)) {\n    form.locations = c.locations\n  } else if (c.locations) {\n    form.locations = [c.locations]\n  }\n  form.httpUser = c.httpUser || ''\n  form.httpPassword = c.httpPassword || ''\n  form.hostHeaderRewrite = c.hostHeaderRewrite || ''\n  form.routeByHTTPUser = c.routeByHTTPUser || ''\n\n  // Header operations\n  if (c.requestHeaders?.set) {\n    form.requestHeaders = Object.entries(c.requestHeaders.set).map(\n      ([key, value]) => ({ key, value: String(value) }),\n    )\n  }\n  if (c.responseHeaders?.set) {\n    form.responseHeaders = Object.entries(c.responseHeaders.set).map(\n      ([key, value]) => ({ key, value: String(value) }),\n    )\n  }\n\n  // TCPMux\n  form.multiplexer = c.multiplexer || 'httpconnect'\n\n  // Secure types\n  form.secretKey = c.secretKey || ''\n  if (Array.isArray(c.allowUsers)) {\n    form.allowUsers = c.allowUsers\n  } else if (c.allowUsers) {\n    form.allowUsers = [c.allowUsers]\n  }\n\n  // XTCP NAT traversal\n  form.natTraversalDisableAssistedAddrs =\n    c.natTraversal?.disableAssistedAddrs || false\n\n  return form\n}\n\nexport function storeVisitorToForm(\n  config: VisitorDefinition,\n): VisitorFormData {\n  const c = getStoreVisitorBlock(config)\n  const form = createDefaultVisitorForm()\n\n  form.name = config.name || ''\n  form.type = config.type || 'stcp'\n  form.enabled = c.enabled !== false\n\n  // Transport\n  if (c.transport) {\n    form.useEncryption = c.transport.useEncryption || false\n    form.useCompression = c.transport.useCompression || false\n  }\n\n  // Base fields\n  form.secretKey = c.secretKey || ''\n  form.serverUser = c.serverUser || ''\n  form.serverName = c.serverName || ''\n  form.bindAddr = c.bindAddr || '127.0.0.1'\n  form.bindPort = c.bindPort\n\n  // XTCP specific\n  form.protocol = c.protocol || 'quic'\n  form.keepTunnelOpen = c.keepTunnelOpen || false\n  form.maxRetriesAnHour = c.maxRetriesAnHour\n  form.minRetryInterval = c.minRetryInterval\n  form.fallbackTo = c.fallbackTo || ''\n  form.fallbackTimeoutMs = c.fallbackTimeoutMs\n  form.natTraversalDisableAssistedAddrs =\n    c.natTraversal?.disableAssistedAddrs || false\n\n  return form\n}\n"
  },
  {
    "path": "web/frpc/src/types/proxy-form.ts",
    "content": "import type { ProxyType, VisitorType } from './constants'\n\nexport interface ProxyFormData {\n  // Base fields (ProxyBaseConfig)\n  name: string\n  type: ProxyType\n  enabled: boolean\n\n  // Backend (ProxyBackend)\n  localIP: string\n  localPort: number | undefined\n  pluginType: string\n  pluginConfig: Record<string, any>\n\n  // Transport (ProxyTransport)\n  useEncryption: boolean\n  useCompression: boolean\n  bandwidthLimit: string\n  bandwidthLimitMode: string\n  proxyProtocolVersion: string\n\n  // Load Balancer (LoadBalancerConfig)\n  loadBalancerGroup: string\n  loadBalancerGroupKey: string\n\n  // Health Check (HealthCheckConfig)\n  healthCheckType: string\n  healthCheckTimeoutSeconds: number | undefined\n  healthCheckMaxFailed: number | undefined\n  healthCheckIntervalSeconds: number | undefined\n  healthCheckPath: string\n  healthCheckHTTPHeaders: Array<{ name: string; value: string }>\n\n  // Metadata & Annotations\n  metadatas: Array<{ key: string; value: string }>\n  annotations: Array<{ key: string; value: string }>\n\n  // TCP/UDP specific\n  remotePort: number | undefined\n\n  // Domain (HTTP/HTTPS/TCPMux) - DomainConfig\n  customDomains: string[]\n  subdomain: string\n\n  // HTTP specific (HTTPProxyConfig)\n  locations: string[]\n  httpUser: string\n  httpPassword: string\n  hostHeaderRewrite: string\n  requestHeaders: Array<{ key: string; value: string }>\n  responseHeaders: Array<{ key: string; value: string }>\n  routeByHTTPUser: string\n\n  // TCPMux specific\n  multiplexer: string\n\n  // STCP/SUDP/XTCP specific\n  secretKey: string\n  allowUsers: string[]\n\n  // XTCP specific (NatTraversalConfig)\n  natTraversalDisableAssistedAddrs: boolean\n}\n\nexport interface VisitorFormData {\n  // Base fields (VisitorBaseConfig)\n  name: string\n  type: VisitorType\n  enabled: boolean\n\n  // Transport (VisitorTransport)\n  useEncryption: boolean\n  useCompression: boolean\n\n  // Connection\n  secretKey: string\n  serverUser: string\n  serverName: string\n  bindAddr: string\n  bindPort: number | undefined\n\n  // XTCP specific (XTCPVisitorConfig)\n  protocol: string\n  keepTunnelOpen: boolean\n  maxRetriesAnHour: number | undefined\n  minRetryInterval: number | undefined\n  fallbackTo: string\n  fallbackTimeoutMs: number | undefined\n  natTraversalDisableAssistedAddrs: boolean\n}\n\nexport function createDefaultProxyForm(): ProxyFormData {\n  return {\n    name: '',\n    type: 'tcp',\n    enabled: true,\n\n    localIP: '127.0.0.1',\n    localPort: undefined,\n    pluginType: '',\n    pluginConfig: {},\n\n    useEncryption: false,\n    useCompression: false,\n    bandwidthLimit: '',\n    bandwidthLimitMode: 'client',\n    proxyProtocolVersion: '',\n\n    loadBalancerGroup: '',\n    loadBalancerGroupKey: '',\n\n    healthCheckType: '',\n    healthCheckTimeoutSeconds: undefined,\n    healthCheckMaxFailed: undefined,\n    healthCheckIntervalSeconds: undefined,\n    healthCheckPath: '',\n    healthCheckHTTPHeaders: [],\n\n    metadatas: [],\n    annotations: [],\n\n    remotePort: undefined,\n\n    customDomains: [],\n    subdomain: '',\n\n    locations: [],\n    httpUser: '',\n    httpPassword: '',\n    hostHeaderRewrite: '',\n    requestHeaders: [],\n    responseHeaders: [],\n    routeByHTTPUser: '',\n\n    multiplexer: 'httpconnect',\n\n    secretKey: '',\n    allowUsers: [],\n\n    natTraversalDisableAssistedAddrs: false,\n  }\n}\n\nexport function createDefaultVisitorForm(): VisitorFormData {\n  return {\n    name: '',\n    type: 'stcp',\n    enabled: true,\n\n    useEncryption: false,\n    useCompression: false,\n\n    secretKey: '',\n    serverUser: '',\n    serverName: '',\n    bindAddr: '127.0.0.1',\n    bindPort: undefined,\n\n    protocol: 'quic',\n    keepTunnelOpen: false,\n    maxRetriesAnHour: undefined,\n    minRetryInterval: undefined,\n    fallbackTo: '',\n    fallbackTimeoutMs: undefined,\n    natTraversalDisableAssistedAddrs: false,\n  }\n}\n"
  },
  {
    "path": "web/frpc/src/types/proxy-status.ts",
    "content": "export interface ProxyStatus {\n  name: string\n  type: string\n  status: string\n  err: string\n  local_addr: string\n  plugin: string\n  remote_addr: string\n  source?: 'store' | 'config'\n  [key: string]: any\n}\n\nexport type StatusResponse = Record<string, ProxyStatus[]>\n"
  },
  {
    "path": "web/frpc/src/types/proxy-store.ts",
    "content": "import type { ProxyType, VisitorType } from './constants'\n\nexport interface ProxyDefinition {\n  name: string\n  type: ProxyType\n  tcp?: Record<string, any>\n  udp?: Record<string, any>\n  http?: Record<string, any>\n  https?: Record<string, any>\n  tcpmux?: Record<string, any>\n  stcp?: Record<string, any>\n  sudp?: Record<string, any>\n  xtcp?: Record<string, any>\n}\n\nexport interface VisitorDefinition {\n  name: string\n  type: VisitorType\n  stcp?: Record<string, any>\n  sudp?: Record<string, any>\n  xtcp?: Record<string, any>\n}\n\nexport interface ProxyListResp {\n  proxies: ProxyDefinition[]\n}\n\nexport interface VisitorListResp {\n  visitors: VisitorDefinition[]\n}\n"
  },
  {
    "path": "web/frpc/src/utils/format.ts",
    "content": "export function formatDistanceToNow(date: Date): string {\n  const seconds = Math.floor((new Date().getTime() - date.getTime()) / 1000)\n\n  let interval = seconds / 31536000\n  if (interval > 1) return Math.floor(interval) + ' years ago'\n\n  interval = seconds / 2592000\n  if (interval > 1) return Math.floor(interval) + ' months ago'\n\n  interval = seconds / 86400\n  if (interval > 1) return Math.floor(interval) + ' days ago'\n\n  interval = seconds / 3600\n  if (interval > 1) return Math.floor(interval) + ' hours ago'\n\n  interval = seconds / 60\n  if (interval > 1) return Math.floor(interval) + ' minutes ago'\n\n  return Math.floor(seconds) + ' seconds ago'\n}\n\nexport function formatFileSize(bytes: number): string {\n  if (!Number.isFinite(bytes) || bytes < 0) return '0 B'\n  if (bytes === 0) return '0 B'\n  const k = 1024\n  const sizes = ['B', 'KB', 'MB', 'GB', 'TB', 'PB', 'EB', 'ZB', 'YB']\n  const i = Math.floor(Math.log(bytes) / Math.log(k))\n  // Prevent index out of bounds for extremely large numbers\n  const unit = sizes[i] || sizes[sizes.length - 1]\n  const val = bytes / Math.pow(k, i)\n\n  return parseFloat(val.toFixed(2)) + ' ' + unit\n}\n"
  },
  {
    "path": "web/frpc/src/views/ClientConfigure.vue",
    "content": "<template>\n  <div class=\"configure-page\">\n    <div class=\"page-header\">\n      <div class=\"title-section\">\n        <h1 class=\"page-title\">Config</h1>\n      </div>\n    </div>\n\n    <div class=\"editor-header\">\n      <div class=\"header-left\">\n        <a\n          href=\"https://github.com/fatedier/frp#configuration-files\"\n          target=\"_blank\"\n          class=\"docs-link\"\n        >\n          <el-icon><Link /></el-icon>\n          Documentation\n        </a>\n      </div>\n      <div class=\"header-actions\">\n        <ActionButton @click=\"handleUpload\">Update & Reload</ActionButton>\n      </div>\n    </div>\n\n    <div class=\"editor-wrapper\">\n      <el-input\n        type=\"textarea\"\n        :autosize=\"false\"\n        v-model=\"configContent\"\n        placeholder=\"# frpc configuration file content...\n\nserverAddr = &quot;127.0.0.1&quot;\nserverPort = 7000\"\n        class=\"code-editor\"\n      ></el-input>\n    </div>\n\n    <ConfirmDialog\n      v-model=\"confirmVisible\"\n      title=\"Confirm Update\"\n      message=\"This operation will update your frpc configuration and reload it. Do you want to continue?\"\n      confirm-text=\"Update\"\n      :loading=\"uploading\"\n      :is-mobile=\"isMobile\"\n      @confirm=\"doUpload\"\n    />\n  </div>\n</template>\n\n<script setup lang=\"ts\">\nimport { ref } from 'vue'\nimport { ElMessage } from 'element-plus'\nimport { Link } from '@element-plus/icons-vue'\nimport { useClientStore } from '../stores/client'\nimport ActionButton from '@shared/components/ActionButton.vue'\nimport ConfirmDialog from '@shared/components/ConfirmDialog.vue'\nimport { useResponsive } from '../composables/useResponsive'\n\nconst { isMobile } = useResponsive()\nconst clientStore = useClientStore()\nconst configContent = ref('')\n\nconst fetchData = async () => {\n  try {\n    await clientStore.fetchConfig()\n    configContent.value = clientStore.config\n  } catch (err: any) {\n    ElMessage({\n      showClose: true,\n      message: 'Get configuration failed: ' + err.message,\n      type: 'warning',\n    })\n  }\n}\n\nconst confirmVisible = ref(false)\nconst uploading = ref(false)\n\nconst handleUpload = () => {\n  confirmVisible.value = true\n}\n\nconst doUpload = async () => {\n  if (!configContent.value.trim()) {\n    ElMessage.warning('Configuration content cannot be empty!')\n    return\n  }\n\n  uploading.value = true\n  try {\n    await clientStore.saveConfig(configContent.value)\n    await clientStore.reload()\n    ElMessage.success('Configuration updated and reloaded successfully')\n    confirmVisible.value = false\n  } catch (err: any) {\n    ElMessage.error('Update failed: ' + err.message)\n  } finally {\n    uploading.value = false\n  }\n}\n\nfetchData()\n</script>\n\n<style scoped lang=\"scss\">\n.configure-page {\n  height: 100%;\n  overflow: hidden;\n  padding: $spacing-xl 40px;\n  max-width: 960px;\n  margin: 0 auto;\n  @include flex-column;\n  gap: $spacing-sm;\n}\n\n.editor-wrapper {\n  flex: 1;\n  min-height: 0;\n  overflow: hidden;\n}\n\n\n.page-header {\n  @include flex-column;\n  gap: $spacing-sm;\n  margin-bottom: $spacing-sm;\n}\n\n\n.editor-header {\n  display: flex;\n  justify-content: space-between;\n  align-items: center;\n}\n\n.header-left {\n  display: flex;\n  align-items: center;\n}\n\n.header-actions {\n  display: flex;\n  align-items: center;\n  gap: $spacing-sm;\n}\n\n.docs-link {\n  display: flex;\n  align-items: center;\n  gap: $spacing-xs;\n  color: $color-text-muted;\n  text-decoration: none;\n  font-size: $font-size-sm;\n  transition: color $transition-fast;\n\n  &:hover {\n    color: $color-text-primary;\n  }\n}\n\n.code-editor {\n  height: 100%;\n\n  :deep(.el-textarea__inner) {\n    height: 100% !important;\n    overflow-y: auto;\n    font-family: ui-monospace, SFMono-Regular, Menlo, Monaco, Consolas, monospace;\n    font-size: $font-size-sm;\n    line-height: 1.6;\n    padding: $spacing-lg;\n    border-radius: $radius-md;\n    background: $color-bg-tertiary;\n    border: 1px solid $color-border-light;\n    resize: none;\n\n    &:focus {\n      border-color: $color-text-light;\n      box-shadow: none;\n    }\n  }\n}\n\n@include mobile {\n  .configure-page {\n    padding: $spacing-xl $spacing-lg;\n  }\n\n  .header-left {\n    justify-content: space-between;\n  }\n\n  .header-actions {\n    justify-content: flex-end;\n  }\n}\n</style>\n"
  },
  {
    "path": "web/frpc/src/views/ProxyDetail.vue",
    "content": "<template>\n  <div class=\"proxy-detail-page\">\n    <!-- Fixed Header -->\n    <div class=\"detail-top\">\n      <nav class=\"breadcrumb\">\n        <router-link :to=\"isStore ? '/proxies?tab=store' : '/proxies'\" class=\"breadcrumb-link\">Proxies</router-link>\n        <span class=\"breadcrumb-sep\">&rsaquo;</span>\n        <span class=\"breadcrumb-current\">{{ proxyName }}</span>\n      </nav>\n\n      <template v-if=\"proxy\">\n        <div class=\"detail-header\">\n          <div>\n            <div class=\"header-title-row\">\n              <h2 class=\"detail-title\">{{ proxy.name }}</h2>\n              <span class=\"status-pill\" :class=\"statusClass\">\n                <span class=\"status-dot\"></span>\n                {{ proxy.status }}\n              </span>\n            </div>\n            <p class=\"header-subtitle\">\n              Source: {{ displaySource }} &middot; Type:\n              {{ proxy.type.toUpperCase() }}\n            </p>\n          </div>\n          <div v-if=\"isStore\" class=\"header-actions\">\n            <ActionButton variant=\"outline\" size=\"small\" @click=\"handleEdit\">\n              Edit\n            </ActionButton>\n          </div>\n        </div>\n      </template>\n    </div>\n\n    <!-- Scrollable Content -->\n    <div v-if=\"notFound\" class=\"not-found\">\n      <p class=\"empty-text\">Proxy not found</p>\n      <p class=\"empty-hint\">The proxy \"{{ proxyName }}\" does not exist.</p>\n      <ActionButton variant=\"outline\" @click=\"router.push('/proxies')\">\n        Back to Proxies\n      </ActionButton>\n    </div>\n\n    <div v-else-if=\"proxy\" v-loading=\"loading\" class=\"detail-content\">\n      <!-- Error Banner -->\n      <div v-if=\"proxy.err\" class=\"error-banner\">\n        <el-icon class=\"error-icon\"><Warning /></el-icon>\n        <div>\n          <div class=\"error-title\">Connection Error</div>\n          <div class=\"error-message\">{{ proxy.err }}</div>\n        </div>\n      </div>\n\n      <!-- Config Sections -->\n      <ProxyFormLayout\n        v-if=\"formData\"\n        :model-value=\"formData\"\n        readonly\n      />\n    </div>\n\n    <div v-else v-loading=\"loading\" class=\"loading-area\"></div>\n\n  </div>\n</template>\n\n<script setup lang=\"ts\">\nimport { ref, computed, onMounted } from 'vue'\nimport { useRoute, useRouter } from 'vue-router'\nimport { ElMessage } from 'element-plus'\nimport { Warning } from '@element-plus/icons-vue'\nimport ActionButton from '@shared/components/ActionButton.vue'\nimport ProxyFormLayout from '../components/proxy-form/ProxyFormLayout.vue'\nimport { getProxyConfig, getStoreProxy } from '../api/frpc'\nimport { useProxyStore } from '../stores/proxy'\nimport { storeProxyToForm } from '../types'\nimport type { ProxyStatus, ProxyDefinition, ProxyFormData } from '../types'\n\nconst route = useRoute()\nconst router = useRouter()\nconst proxyStore = useProxyStore()\n\nconst proxyName = route.params.name as string\nconst proxy = ref<ProxyStatus | null>(null)\nconst proxyConfig = ref<ProxyDefinition | null>(null)\nconst loading = ref(true)\nconst notFound = ref(false)\nconst isStore = ref(false)\n\nonMounted(async () => {\n  try {\n    // Try status API first\n    await proxyStore.fetchStatus()\n    const found = proxyStore.proxies.find((p) => p.name === proxyName)\n\n    // Try config API (works for any source)\n    let configDef: ProxyDefinition | null = null\n    try {\n      configDef = await getProxyConfig(proxyName)\n      proxyConfig.value = configDef\n    } catch {\n      // Config not available\n    }\n\n    // Check if proxy is from the store (for Edit/Delete buttons)\n    try {\n      await getStoreProxy(proxyName)\n      isStore.value = true\n    } catch {\n      // Not a store proxy\n    }\n\n    if (found) {\n      proxy.value = found\n    } else if (configDef) {\n      // Proxy not in status (e.g. disabled), build from config definition\n      const block = (configDef as any)[configDef.type]\n      const localIP = block?.localIP || '127.0.0.1'\n      const localPort = block?.localPort\n      const enabled = block?.enabled !== false\n      proxy.value = {\n        name: configDef.name,\n        type: configDef.type,\n        status: enabled ? 'waiting' : 'disabled',\n        err: '',\n        local_addr: localPort != null ? `${localIP}:${localPort}` : '',\n        remote_addr: block?.remotePort != null ? `:${block.remotePort}` : '',\n        plugin: block?.plugin?.type || '',\n      }\n    } else {\n      notFound.value = true\n    }\n  } catch (err: any) {\n    ElMessage.error('Failed to load proxy: ' + err.message)\n  } finally {\n    loading.value = false\n  }\n})\n\nconst displaySource = computed(() =>\n  isStore.value ? 'store' : 'config',\n)\n\nconst statusClass = computed(() => {\n  const s = proxy.value?.status\n  if (s === 'running') return 'running'\n  if (s === 'error') return 'error'\n  if (s === 'disabled') return 'disabled'\n  return 'waiting'\n})\n\nconst formData = computed((): ProxyFormData | null => {\n  if (!proxyConfig.value) return null\n  return storeProxyToForm(proxyConfig.value)\n})\n\nconst handleEdit = () => {\n  router.push('/proxies/' + encodeURIComponent(proxyName) + '/edit')\n}\n\n</script>\n\n<style scoped lang=\"scss\">\n.proxy-detail-page {\n  display: flex;\n  flex-direction: column;\n  height: 100%;\n  max-width: 960px;\n  margin: 0 auto;\n}\n\n.detail-top {\n  flex-shrink: 0;\n  padding: $spacing-xl 24px 0;\n}\n\n.detail-content {\n  flex: 1;\n  overflow-y: auto;\n  padding: 0 24px 160px;\n}\n\n.breadcrumb {\n  display: flex;\n  align-items: center;\n  gap: $spacing-sm;\n  font-size: $font-size-md;\n  margin-bottom: $spacing-lg;\n}\n\n.breadcrumb-link {\n  color: $color-text-secondary;\n  text-decoration: none;\n\n  &:hover {\n    color: $color-text-primary;\n  }\n}\n\n.breadcrumb-sep {\n  color: $color-text-light;\n}\n\n.breadcrumb-current {\n  color: $color-text-primary;\n  font-weight: $font-weight-medium;\n}\n\n.detail-header {\n  display: flex;\n  justify-content: space-between;\n  align-items: flex-start;\n  margin-bottom: $spacing-xl;\n}\n\n.header-title-row {\n  display: flex;\n  align-items: center;\n  gap: $spacing-md;\n  margin-bottom: $spacing-sm;\n}\n\n.detail-title {\n  margin: 0;\n  font-size: 22px;\n  font-weight: $font-weight-semibold;\n  color: $color-text-primary;\n}\n\n.header-subtitle {\n  font-size: $font-size-sm;\n  color: $color-text-muted;\n  margin: 0;\n}\n\n.header-actions {\n  display: flex;\n  gap: $spacing-sm;\n}\n\n.error-banner {\n  display: flex;\n  align-items: flex-start;\n  gap: $spacing-sm;\n  padding: 12px 16px;\n  background: var(--color-danger-light);\n  border: 1px solid rgba(245, 108, 108, 0.2);\n  border-radius: $radius-md;\n  margin-bottom: $spacing-xl;\n\n  .error-icon {\n    color: $color-danger;\n    font-size: 18px;\n    margin-top: 2px;\n  }\n\n  .error-title {\n    font-size: $font-size-md;\n    font-weight: $font-weight-medium;\n    color: $color-danger;\n    margin-bottom: 2px;\n  }\n\n  .error-message {\n    font-size: $font-size-sm;\n    color: $color-text-muted;\n  }\n}\n\n.not-found,\n.loading-area {\n  text-align: center;\n  padding: 60px $spacing-xl;\n}\n\n.empty-text {\n  font-size: $font-size-lg;\n  font-weight: $font-weight-medium;\n  color: $color-text-secondary;\n  margin: 0 0 $spacing-xs;\n}\n\n.empty-hint {\n  font-size: $font-size-sm;\n  color: $color-text-muted;\n  margin: 0 0 $spacing-lg;\n}\n\n@include mobile {\n  .detail-top {\n    padding: $spacing-xl $spacing-lg 0;\n  }\n\n  .detail-content {\n    padding: 0 $spacing-lg $spacing-xl;\n  }\n\n  .detail-header {\n    flex-direction: column;\n    gap: $spacing-md;\n  }\n}\n</style>\n"
  },
  {
    "path": "web/frpc/src/views/ProxyEdit.vue",
    "content": "<template>\n  <div class=\"proxy-edit-page\">\n    <!-- Header with breadcrumb and actions -->\n    <div class=\"edit-header\">\n      <nav class=\"breadcrumb\">\n        <router-link to=\"/proxies?tab=store\" class=\"breadcrumb-item\">Proxies</router-link>\n        <span class=\"breadcrumb-separator\">&rsaquo;</span>\n        <span class=\"breadcrumb-current\">{{ isEditing ? 'Edit Proxy' : 'New Proxy' }}</span>\n      </nav>\n      <div class=\"header-actions\">\n        <ActionButton variant=\"outline\" size=\"small\" @click=\"goBack\">Cancel</ActionButton>\n        <ActionButton size=\"small\" :loading=\"saving\" @click=\"handleSave\">\n          {{ isEditing ? 'Update' : 'Create' }}\n        </ActionButton>\n      </div>\n    </div>\n\n    <div v-loading=\"pageLoading\" class=\"edit-content\">\n      <el-form\n        ref=\"formRef\"\n        :model=\"form\"\n        :rules=\"rules\"\n        label-position=\"top\"\n        @submit.prevent\n      >\n        <ProxyFormLayout v-model=\"form\" :editing=\"isEditing\" />\n      </el-form>\n    </div>\n\n    <ConfirmDialog\n      v-model=\"leaveDialogVisible\"\n      title=\"Unsaved Changes\"\n      message=\"You have unsaved changes. Are you sure you want to leave?\"\n      :is-mobile=\"isMobile\"\n      @confirm=\"handleLeaveConfirm\"\n      @cancel=\"handleLeaveCancel\"\n    />\n  </div>\n</template>\n\n<script setup lang=\"ts\">\nimport { ref, computed, onMounted, watch, nextTick } from 'vue'\nimport { useRoute, useRouter, onBeforeRouteLeave } from 'vue-router'\nimport { ElMessage } from 'element-plus'\nimport type { FormInstance, FormRules } from 'element-plus'\nimport {\n  type ProxyFormData,\n  createDefaultProxyForm,\n  formToStoreProxy,\n  storeProxyToForm,\n} from '../types'\nimport { getStoreProxy } from '../api/frpc'\nimport { useProxyStore } from '../stores/proxy'\nimport ActionButton from '@shared/components/ActionButton.vue'\nimport ConfirmDialog from '@shared/components/ConfirmDialog.vue'\nimport ProxyFormLayout from '../components/proxy-form/ProxyFormLayout.vue'\nimport { useResponsive } from '../composables/useResponsive'\n\nconst { isMobile } = useResponsive()\nconst route = useRoute()\nconst router = useRouter()\nconst proxyStore = useProxyStore()\n\nconst isEditing = computed(() => !!route.params.name)\nconst pageLoading = ref(false)\nconst saving = ref(false)\nconst formRef = ref<FormInstance>()\nconst form = ref<ProxyFormData>(createDefaultProxyForm())\nconst dirty = ref(false)\nconst formSaved = ref(false)\nconst trackChanges = ref(false)\n\nconst rules: FormRules = {\n  name: [\n    { required: true, message: 'Name is required', trigger: 'blur' },\n    { min: 1, max: 50, message: 'Length should be 1 to 50', trigger: 'blur' },\n  ],\n  type: [{ required: true, message: 'Type is required', trigger: 'change' }],\n  localPort: [\n    {\n      validator: (_rule, value, callback) => {\n        if (!form.value.pluginType && value == null) {\n          callback(new Error('Local port is required'))\n        } else {\n          callback()\n        }\n      },\n      trigger: 'blur',\n    },\n  ],\n  customDomains: [\n    {\n      validator: (_rule, value, callback) => {\n        if (\n          ['http', 'https', 'tcpmux'].includes(form.value.type) &&\n          (!value || value.length === 0) &&\n          !form.value.subdomain\n        ) {\n          callback(new Error('Custom domains or subdomain is required'))\n        } else {\n          callback()\n        }\n      },\n      trigger: 'blur',\n    },\n  ],\n  healthCheckPath: [\n    {\n      validator: (_rule, value, callback) => {\n        if (form.value.healthCheckType === 'http' && !value) {\n          callback(new Error('Path is required for HTTP health check'))\n        } else {\n          callback()\n        }\n      },\n      trigger: 'blur',\n    },\n  ],\n}\n\nconst goBack = () => {\n  router.back()\n}\n\nwatch(\n  () => form.value,\n  () => {\n    if (trackChanges.value) {\n      dirty.value = true\n    }\n  },\n  { deep: true },\n)\n\nconst leaveDialogVisible = ref(false)\nconst leaveResolve = ref<((value: boolean) => void) | null>(null)\n\nonBeforeRouteLeave(async () => {\n  if (dirty.value && !formSaved.value) {\n    leaveDialogVisible.value = true\n    return new Promise<boolean>((resolve) => {\n      leaveResolve.value = resolve\n    })\n  }\n})\n\nconst handleLeaveConfirm = () => {\n  leaveDialogVisible.value = false\n  leaveResolve.value?.(true)\n}\n\nconst handleLeaveCancel = () => {\n  leaveDialogVisible.value = false\n  leaveResolve.value?.(false)\n}\n\nconst loadProxy = async () => {\n  const name = route.params.name as string\n  if (!name) return\n\n  trackChanges.value = false\n  dirty.value = false\n  pageLoading.value = true\n  try {\n    const res = await getStoreProxy(name)\n    form.value = storeProxyToForm(res)\n    await nextTick()\n  } catch (err: any) {\n    ElMessage.error('Failed to load proxy: ' + err.message)\n    router.push('/proxies?tab=store')\n  } finally {\n    pageLoading.value = false\n    nextTick(() => {\n      trackChanges.value = true\n    })\n  }\n}\n\nconst handleSave = async () => {\n  if (!formRef.value) return\n\n  try {\n    await formRef.value.validate()\n  } catch {\n    ElMessage.warning('Please fix the form errors')\n    return\n  }\n\n  saving.value = true\n  try {\n    const data = formToStoreProxy(form.value)\n    if (isEditing.value) {\n      await proxyStore.updateProxy(form.value.name, data)\n      ElMessage.success('Proxy updated')\n    } else {\n      await proxyStore.createProxy(data)\n      ElMessage.success('Proxy created')\n    }\n    formSaved.value = true\n    router.push('/proxies?tab=store')\n  } catch (err: any) {\n    ElMessage.error('Operation failed: ' + (err.message || 'Unknown error'))\n  } finally {\n    saving.value = false\n  }\n}\n\nonMounted(() => {\n  if (isEditing.value) {\n    loadProxy()\n  } else {\n    trackChanges.value = true\n  }\n})\n\nwatch(\n  () => route.params.name,\n  (name, oldName) => {\n    if (name === oldName) return\n    if (name) {\n      loadProxy()\n      return\n    }\n    trackChanges.value = false\n    form.value = createDefaultProxyForm()\n    dirty.value = false\n    nextTick(() => {\n      trackChanges.value = true\n    })\n  },\n)\n</script>\n\n<style scoped lang=\"scss\">\n.proxy-edit-page {\n  display: flex;\n  flex-direction: column;\n  height: 100%;\n  max-width: 960px;\n  margin: 0 auto;\n}\n\n/* Edit Header */\n.edit-header {\n  display: flex;\n  align-items: center;\n  justify-content: space-between;\n  flex-shrink: 0;\n  padding: $spacing-xl 24px;\n}\n\n.edit-content {\n  flex: 1;\n  overflow-y: auto;\n  padding: 0 24px 160px;\n}\n\n.header-actions {\n  display: flex;\n  gap: $spacing-sm;\n}\n\n/* Breadcrumb */\n.breadcrumb {\n  display: flex;\n  align-items: center;\n  gap: 8px;\n  font-size: 14px;\n}\n\n.breadcrumb-item {\n  color: var(--text-secondary);\n  text-decoration: none;\n  transition: color 0.2s;\n}\n\n.breadcrumb-item:hover {\n  color: var(--el-color-primary);\n}\n\n.breadcrumb-separator {\n  color: var(--el-border-color);\n}\n\n.breadcrumb-current {\n  color: var(--text-primary);\n  font-weight: 500;\n}\n\n/* Responsive */\n@include mobile {\n  .edit-header {\n    padding: $spacing-lg;\n  }\n\n  .edit-content {\n    padding: 0 $spacing-lg 160px;\n  }\n}\n</style>\n"
  },
  {
    "path": "web/frpc/src/views/ProxyList.vue",
    "content": "<template>\n  <div class=\"proxies-page\">\n    <!-- Fixed top area -->\n    <div class=\"page-top\">\n      <!-- Header -->\n      <div class=\"page-header\">\n        <h2 class=\"page-title\">Proxies</h2>\n      </div>\n\n      <!-- Tabs -->\n      <div class=\"tab-bar\">\n        <div class=\"tab-buttons\">\n          <button class=\"tab-btn\" :class=\"{ active: activeTab === 'status' }\" @click=\"switchTab('status')\">Status</button>\n          <button class=\"tab-btn\" :class=\"{ active: activeTab === 'store' }\" @click=\"switchTab('store')\">Store</button>\n        </div>\n        <div class=\"tab-actions\">\n          <ActionButton variant=\"outline\" size=\"small\" @click=\"refreshData\">\n            <el-icon><Refresh /></el-icon>\n          </ActionButton>\n          <ActionButton v-if=\"activeTab === 'store' && proxyStore.storeEnabled\" size=\"small\" @click=\"handleCreate\">\n            + New Proxy\n          </ActionButton>\n        </div>\n      </div>\n\n      <!-- Status Tab Filters -->\n      <template v-if=\"activeTab === 'status'\">\n        <StatusPills v-if=\"!isMobile\" :items=\"proxyStore.proxies\" v-model=\"statusFilter\" />\n        <div class=\"filter-bar\">\n          <el-input v-model=\"searchText\" placeholder=\"Search...\" clearable class=\"search-input\">\n            <template #prefix><el-icon><Search /></el-icon></template>\n          </el-input>\n          <FilterDropdown v-model=\"sourceFilter\" label=\"Source\" :options=\"sourceOptions\" :min-width=\"140\" :is-mobile=\"isMobile\" />\n          <FilterDropdown v-model=\"typeFilter\" label=\"Type\" :options=\"typeOptions\" :min-width=\"140\" :is-mobile=\"isMobile\" />\n        </div>\n      </template>\n\n      <!-- Store Tab Filters -->\n      <template v-if=\"activeTab === 'store' && proxyStore.storeEnabled\">\n        <div class=\"filter-bar\">\n          <el-input v-model=\"storeSearch\" placeholder=\"Search...\" clearable class=\"search-input\">\n            <template #prefix><el-icon><Search /></el-icon></template>\n          </el-input>\n          <FilterDropdown v-model=\"storeTypeFilter\" label=\"Type\" :options=\"storeTypeOptions\" :min-width=\"140\" :is-mobile=\"isMobile\" />\n        </div>\n      </template>\n    </div>\n\n    <!-- Scrollable list area -->\n    <div class=\"page-content\">\n      <!-- Status Tab List -->\n      <div v-if=\"activeTab === 'status'\" v-loading=\"proxyStore.loading\">\n        <div v-if=\"filteredStatus.length > 0\" class=\"proxy-list\">\n          <ProxyCard\n            v-for=\"p in filteredStatus\"\n            :key=\"p.name\"\n            :proxy=\"p\"\n            showSource\n            @click=\"goToDetail(p.name)\"\n          />\n        </div>\n        <div v-else-if=\"!proxyStore.loading\" class=\"empty-state\">\n          <p class=\"empty-text\">No proxies found</p>\n          <p class=\"empty-hint\">Proxies will appear here once configured and connected.</p>\n        </div>\n      </div>\n\n      <!-- Store Tab List -->\n      <div v-if=\"activeTab === 'store'\" v-loading=\"proxyStore.storeLoading\">\n        <div v-if=\"!proxyStore.storeEnabled\" class=\"store-disabled\">\n          <p>Store is not enabled. Add the following to your frpc configuration:</p>\n          <pre class=\"config-hint\">[store]\npath = \"./frpc_store.json\"</pre>\n        </div>\n        <template v-else>\n          <div v-if=\"filteredStoreProxies.length > 0\" class=\"proxy-list\">\n            <ProxyCard\n              v-for=\"p in filteredStoreProxies\"\n              :key=\"p.name\"\n              :proxy=\"proxyStore.storeProxyWithStatus(p)\"\n              showActions\n              @click=\"goToDetail(p.name)\"\n              @edit=\"handleEdit\"\n              @toggle=\"handleToggleProxy\"\n              @delete=\"handleDeleteProxy(p.name)\"\n            />\n          </div>\n          <div v-else class=\"empty-state\">\n            <p class=\"empty-text\">No store proxies</p>\n            <p class=\"empty-hint\">Click \"New Proxy\" to create one.</p>\n          </div>\n        </template>\n      </div>\n    </div>\n\n    <ConfirmDialog\n      v-model=\"deleteDialog.visible\"\n      title=\"Delete Proxy\"\n      :message=\"deleteDialog.message\"\n      confirm-text=\"Delete\"\n      danger\n      :loading=\"deleteDialog.loading\"\n      :is-mobile=\"isMobile\"\n      @confirm=\"doDelete\"\n    />\n  </div>\n</template>\n\n<script setup lang=\"ts\">\nimport { ref, computed, reactive, onMounted } from 'vue'\nimport { useRoute, useRouter } from 'vue-router'\nimport { ElMessage } from 'element-plus'\nimport { Search, Refresh } from '@element-plus/icons-vue'\nimport ActionButton from '@shared/components/ActionButton.vue'\nimport StatusPills from '../components/StatusPills.vue'\nimport FilterDropdown from '@shared/components/FilterDropdown.vue'\nimport ProxyCard from '../components/ProxyCard.vue'\nimport ConfirmDialog from '@shared/components/ConfirmDialog.vue'\nimport { useProxyStore } from '../stores/proxy'\nimport { useResponsive } from '../composables/useResponsive'\nimport type { ProxyStatus } from '../types'\n\nconst { isMobile } = useResponsive()\n\nconst route = useRoute()\nconst router = useRouter()\nconst proxyStore = useProxyStore()\n\n// Tab\nconst activeTab = computed(() => {\n  const tab = route.query.tab as string\n  return tab === 'store' ? 'store' : 'status'\n})\n\nconst switchTab = (tab: string) => {\n  router.replace({ query: tab === 'status' ? {} : { tab } })\n}\n\n// Filters (local UI state)\nconst statusFilter = ref('')\nconst typeFilter = ref('')\nconst sourceFilter = ref('')\nconst searchText = ref('')\nconst storeSearch = ref('')\nconst storeTypeFilter = ref('')\n\n// Delete dialog\nconst deleteDialog = reactive({\n  visible: false,\n  title: 'Delete Proxy',\n  message: '',\n  loading: false,\n  name: '',\n})\n\n// Source handling\nconst displaySource = (proxy: ProxyStatus): string => {\n  return proxy.source === 'store' ? 'store' : 'config'\n}\n\n// Filter options\nconst sourceOptions = computed(() => {\n  const sources = new Set<string>()\n  sources.add('config')\n  sources.add('store')\n  proxyStore.proxies.forEach((p) => {\n    sources.add(displaySource(p))\n  })\n  return Array.from(sources)\n    .sort()\n    .map((s) => ({ label: s, value: s }))\n})\n\nconst PROXY_TYPE_ORDER = ['tcp', 'udp', 'http', 'https', 'tcpmux', 'stcp', 'sudp', 'xtcp']\n\nconst sortByTypeOrder = (types: string[]) => {\n  return types.sort((a, b) => {\n    const ia = PROXY_TYPE_ORDER.indexOf(a)\n    const ib = PROXY_TYPE_ORDER.indexOf(b)\n    return (ia === -1 ? 999 : ia) - (ib === -1 ? 999 : ib)\n  })\n}\n\nconst typeOptions = computed(() => {\n  const types = new Set<string>()\n  proxyStore.proxies.forEach((p) => types.add(p.type))\n  return sortByTypeOrder(Array.from(types))\n    .map((t) => ({ label: t.toUpperCase(), value: t }))\n})\n\nconst storeTypeOptions = computed(() => {\n  const types = new Set<string>()\n  proxyStore.storeProxies.forEach((p) => types.add(p.type))\n  return sortByTypeOrder(Array.from(types))\n    .map((t) => ({ label: t.toUpperCase(), value: t }))\n})\n\n// Filtered computeds — Status tab uses proxyStore.proxies (runtime only)\nconst filteredStatus = computed(() => {\n  let result = proxyStore.proxies as ProxyStatus[]\n\n  if (statusFilter.value) {\n    result = result.filter((p) => p.status === statusFilter.value)\n  }\n\n  if (typeFilter.value) {\n    result = result.filter((p) => p.type === typeFilter.value)\n  }\n\n  if (sourceFilter.value) {\n    result = result.filter((p) => displaySource(p) === sourceFilter.value)\n  }\n\n  if (searchText.value) {\n    const search = searchText.value.toLowerCase()\n    result = result.filter(\n      (p) =>\n        p.name.toLowerCase().includes(search) ||\n        p.type.toLowerCase().includes(search) ||\n        p.local_addr.toLowerCase().includes(search) ||\n        p.remote_addr.toLowerCase().includes(search),\n    )\n  }\n\n  return result\n})\n\nconst filteredStoreProxies = computed(() => {\n  let list = proxyStore.storeProxies\n\n  if (storeTypeFilter.value) {\n    list = list.filter((p) => p.type === storeTypeFilter.value)\n  }\n\n  if (storeSearch.value) {\n    const q = storeSearch.value.toLowerCase()\n    list = list.filter((p) => p.name.toLowerCase().includes(q))\n  }\n\n  return list\n})\n\n// Data fetching\nconst refreshData = () => {\n  proxyStore.fetchStatus().catch((err: any) => {\n    ElMessage.error('Failed to get status: ' + err.message)\n  })\n  proxyStore.fetchStoreProxies()\n}\n\n// Navigation\nconst goToDetail = (name: string) => {\n  router.push('/proxies/detail/' + encodeURIComponent(name))\n}\n\nconst handleCreate = () => {\n  router.push('/proxies/create')\n}\n\nconst handleEdit = (proxy: ProxyStatus) => {\n  router.push('/proxies/' + encodeURIComponent(proxy.name) + '/edit')\n}\n\nconst handleToggleProxy = async (proxy: ProxyStatus, enabled: boolean) => {\n  try {\n    await proxyStore.toggleProxy(proxy.name, enabled)\n    ElMessage.success(enabled ? 'Proxy enabled' : 'Proxy disabled')\n  } catch (err: any) {\n    ElMessage.error('Operation failed: ' + (err.message || 'Unknown error'))\n  }\n}\n\nconst handleDeleteProxy = (name: string) => {\n  deleteDialog.name = name\n  deleteDialog.message = `Are you sure you want to delete \"${name}\"? This action cannot be undone.`\n  deleteDialog.visible = true\n}\n\nconst doDelete = async () => {\n  deleteDialog.loading = true\n  try {\n    await proxyStore.deleteProxy(deleteDialog.name)\n    ElMessage.success('Proxy deleted')\n    deleteDialog.visible = false\n    proxyStore.fetchStatus()\n  } catch (err: any) {\n    ElMessage.error('Delete failed: ' + (err.message || 'Unknown error'))\n  } finally {\n    deleteDialog.loading = false\n  }\n}\n\nonMounted(() => {\n  refreshData()\n})\n</script>\n\n<style scoped lang=\"scss\">\n.proxies-page {\n  display: flex;\n  flex-direction: column;\n  height: 100%;\n  max-width: 960px;\n  margin: 0 auto;\n}\n\n.page-top {\n  flex-shrink: 0;\n  padding: $spacing-xl 40px 0;\n}\n\n.page-content {\n  flex: 1;\n  overflow-y: auto;\n  padding: 0 40px $spacing-xl;\n}\n\n.page-header {\n  display: flex;\n  align-items: center;\n  justify-content: space-between;\n  margin-bottom: $spacing-xl;\n}\n\n.tab-bar {\n  display: flex;\n  align-items: center;\n  justify-content: space-between;\n  border-bottom: 1px solid $color-border-lighter;\n  margin-bottom: $spacing-xl;\n}\n\n.tab-buttons {\n  display: flex;\n}\n\n.tab-actions {\n  display: flex;\n  align-items: center;\n  gap: $spacing-sm;\n}\n\n.tab-btn {\n  background: none;\n  border: none;\n  padding: $spacing-sm $spacing-xl;\n  font-size: $font-size-md;\n  color: $color-text-muted;\n  cursor: pointer;\n  border-bottom: 2px solid transparent;\n  transition: all $transition-fast;\n\n  &:hover { color: $color-text-primary; }\n  &.active {\n    color: $color-text-primary;\n    border-bottom-color: $color-text-primary;\n    font-weight: $font-weight-medium;\n  }\n}\n\n.filter-bar {\n  display: flex;\n  align-items: center;\n  gap: $spacing-sm;\n  margin-top: $spacing-lg;\n  padding-bottom: $spacing-lg;\n\n  :deep(.search-input) {\n    flex: 1;\n    min-width: 150px;\n  }\n}\n\n.proxy-list {\n  display: flex;\n  flex-direction: column;\n  gap: $spacing-md;\n}\n\n.empty-state {\n  text-align: center;\n  padding: 60px $spacing-xl;\n}\n\n.empty-text {\n  font-size: $font-size-lg;\n  font-weight: $font-weight-medium;\n  color: $color-text-secondary;\n  margin: 0 0 $spacing-xs;\n}\n\n.empty-hint {\n  font-size: $font-size-sm;\n  color: $color-text-muted;\n  margin: 0;\n}\n\n.store-disabled {\n  padding: 32px;\n  text-align: center;\n  color: $color-text-muted;\n}\n\n.config-hint {\n  display: inline-block;\n  text-align: left;\n  background: $color-bg-hover;\n  padding: 12px 20px;\n  border-radius: $radius-sm;\n  font-size: $font-size-sm;\n  margin-top: $spacing-md;\n}\n\n@include mobile {\n  .page-top {\n    padding: $spacing-lg $spacing-lg 0;\n  }\n\n  .page-content {\n    padding: 0 $spacing-lg $spacing-lg;\n  }\n\n  .page-header {\n    flex-direction: column;\n    align-items: stretch;\n    gap: $spacing-md;\n  }\n\n  .header-actions {\n    justify-content: flex-end;\n  }\n\n  .filter-bar {\n    :deep(.search-input) {\n      flex: 1;\n      min-width: 0;\n    }\n  }\n}\n</style>\n"
  },
  {
    "path": "web/frpc/src/views/VisitorDetail.vue",
    "content": "<template>\n  <div class=\"visitor-detail-page\">\n    <!-- Fixed Header -->\n    <div class=\"detail-top\">\n      <nav class=\"breadcrumb\">\n        <router-link to=\"/visitors\" class=\"breadcrumb-link\">Visitors</router-link>\n        <span class=\"breadcrumb-sep\">&rsaquo;</span>\n        <span class=\"breadcrumb-current\">{{ visitorName }}</span>\n      </nav>\n\n      <template v-if=\"visitor\">\n        <div class=\"detail-header\">\n          <div>\n            <h2 class=\"detail-title\">{{ visitor.name }}</h2>\n            <p class=\"header-subtitle\">Type: {{ visitor.type.toUpperCase() }}</p>\n          </div>\n          <div v-if=\"isStore\" class=\"header-actions\">\n            <ActionButton variant=\"outline\" size=\"small\" @click=\"handleEdit\">\n              Edit\n            </ActionButton>\n          </div>\n        </div>\n      </template>\n    </div>\n\n    <div v-if=\"notFound\" class=\"not-found\">\n      <p class=\"empty-text\">Visitor not found</p>\n      <p class=\"empty-hint\">The visitor \"{{ visitorName }}\" does not exist.</p>\n      <ActionButton variant=\"outline\" @click=\"router.push('/visitors')\">\n        Back to Visitors\n      </ActionButton>\n    </div>\n\n    <div v-else-if=\"visitor\" v-loading=\"loading\" class=\"detail-content\">\n      <VisitorFormLayout\n        v-if=\"formData\"\n        :model-value=\"formData\"\n        readonly\n      />\n    </div>\n\n    <div v-else v-loading=\"loading\" class=\"loading-area\"></div>\n\n  </div>\n</template>\n\n<script setup lang=\"ts\">\nimport { ref, computed, onMounted } from 'vue'\nimport { useRoute, useRouter } from 'vue-router'\nimport { ElMessage } from 'element-plus'\nimport ActionButton from '@shared/components/ActionButton.vue'\nimport VisitorFormLayout from '../components/visitor-form/VisitorFormLayout.vue'\nimport { getVisitorConfig, getStoreVisitor } from '../api/frpc'\nimport type { VisitorDefinition, VisitorFormData } from '../types'\nimport { storeVisitorToForm } from '../types'\n\nconst route = useRoute()\nconst router = useRouter()\n\nconst visitorName = route.params.name as string\nconst visitor = ref<VisitorDefinition | null>(null)\nconst loading = ref(true)\nconst notFound = ref(false)\nconst isStore = ref(false)\n\nonMounted(async () => {\n  try {\n    const config = await getVisitorConfig(visitorName)\n    visitor.value = config\n\n    // Check if visitor is from the store (for Edit/Delete buttons)\n    try {\n      await getStoreVisitor(visitorName)\n      isStore.value = true\n    } catch {\n      // Not a store visitor — Edit/Delete not available\n    }\n  } catch (err: any) {\n    if (err?.status === 404 || err?.response?.status === 404) {\n      notFound.value = true\n    } else {\n      notFound.value = true\n      ElMessage.error('Failed to load visitor: ' + err.message)\n    }\n  } finally {\n    loading.value = false\n  }\n})\n\nconst formData = computed<VisitorFormData | null>(() => {\n  if (!visitor.value) return null\n  return storeVisitorToForm(visitor.value)\n})\n\nconst handleEdit = () => {\n  router.push('/visitors/' + encodeURIComponent(visitorName) + '/edit')\n}\n\n</script>\n\n<style scoped lang=\"scss\">\n.visitor-detail-page {\n  display: flex;\n  flex-direction: column;\n  height: 100%;\n  max-width: 960px;\n  margin: 0 auto;\n}\n\n.detail-top {\n  flex-shrink: 0;\n  padding: $spacing-xl 24px 0;\n}\n\n.detail-content {\n  flex: 1;\n  overflow-y: auto;\n  padding: 0 24px 160px;\n}\n\n.breadcrumb {\n  display: flex;\n  align-items: center;\n  gap: $spacing-sm;\n  font-size: $font-size-md;\n  margin-bottom: $spacing-lg;\n}\n\n.breadcrumb-link {\n  color: $color-text-secondary;\n  text-decoration: none;\n\n  &:hover {\n    color: $color-text-primary;\n  }\n}\n\n.breadcrumb-sep {\n  color: $color-text-light;\n}\n\n.breadcrumb-current {\n  color: $color-text-primary;\n  font-weight: $font-weight-medium;\n}\n\n.detail-header {\n  display: flex;\n  justify-content: space-between;\n  align-items: flex-start;\n  margin-bottom: $spacing-xl;\n}\n\n.detail-title {\n  margin: 0;\n  font-size: 22px;\n  font-weight: $font-weight-semibold;\n  color: $color-text-primary;\n  margin-bottom: $spacing-sm;\n}\n\n.header-subtitle {\n  font-size: $font-size-sm;\n  color: $color-text-muted;\n  margin: 0;\n}\n\n.header-actions {\n  display: flex;\n  gap: $spacing-sm;\n}\n\n.not-found,\n.loading-area {\n  text-align: center;\n  padding: 60px $spacing-xl;\n}\n\n.empty-text {\n  font-size: $font-size-lg;\n  font-weight: $font-weight-medium;\n  color: $color-text-secondary;\n  margin: 0 0 $spacing-xs;\n}\n\n.empty-hint {\n  font-size: $font-size-sm;\n  color: $color-text-muted;\n  margin: 0 0 $spacing-lg;\n}\n\n@include mobile {\n  .detail-top {\n    padding: $spacing-xl $spacing-lg 0;\n  }\n\n  .detail-content {\n    padding: 0 $spacing-lg $spacing-xl;\n  }\n\n  .detail-header {\n    flex-direction: column;\n    gap: $spacing-md;\n  }\n}\n</style>\n"
  },
  {
    "path": "web/frpc/src/views/VisitorEdit.vue",
    "content": "<template>\n  <div class=\"visitor-edit-page\">\n    <div class=\"edit-header\">\n      <nav class=\"breadcrumb\">\n        <router-link to=\"/visitors\" class=\"breadcrumb-item\">Visitors</router-link>\n        <span class=\"breadcrumb-separator\">›</span>\n        <span class=\"breadcrumb-current\">{{ isEditing ? 'Edit Visitor' : 'New Visitor' }}</span>\n      </nav>\n      <div class=\"header-actions\">\n        <ActionButton variant=\"outline\" size=\"small\" @click=\"goBack\">Cancel</ActionButton>\n        <ActionButton size=\"small\" :loading=\"saving\" @click=\"handleSave\">\n          {{ isEditing ? 'Update' : 'Create' }}\n        </ActionButton>\n      </div>\n    </div>\n\n    <div v-loading=\"pageLoading\" class=\"edit-content\">\n      <el-form\n        ref=\"formRef\"\n        :model=\"form\"\n        :rules=\"formRules\"\n        label-position=\"top\"\n        @submit.prevent\n      >\n        <VisitorFormLayout v-model=\"form\" :editing=\"isEditing\" />\n      </el-form>\n    </div>\n\n    <ConfirmDialog\n      v-model=\"leaveDialogVisible\"\n      title=\"Unsaved Changes\"\n      message=\"You have unsaved changes. Are you sure you want to leave?\"\n      :is-mobile=\"isMobile\"\n      @confirm=\"handleLeaveConfirm\"\n      @cancel=\"handleLeaveCancel\"\n    />\n  </div>\n</template>\n\n<script setup lang=\"ts\">\nimport { ref, computed, onMounted, watch, nextTick } from 'vue'\nimport { useRoute, useRouter, onBeforeRouteLeave } from 'vue-router'\nimport { ElMessage } from 'element-plus'\nimport ActionButton from '@shared/components/ActionButton.vue'\nimport ConfirmDialog from '@shared/components/ConfirmDialog.vue'\nimport VisitorFormLayout from '../components/visitor-form/VisitorFormLayout.vue'\nimport { useResponsive } from '../composables/useResponsive'\nimport type { FormInstance, FormRules } from 'element-plus'\nimport {\n  type VisitorFormData,\n  createDefaultVisitorForm,\n  formToStoreVisitor,\n  storeVisitorToForm,\n} from '../types'\nimport { getStoreVisitor } from '../api/frpc'\nimport { useVisitorStore } from '../stores/visitor'\n\nconst { isMobile } = useResponsive()\nconst route = useRoute()\nconst router = useRouter()\nconst visitorStore = useVisitorStore()\n\nconst isEditing = computed(() => !!route.params.name)\nconst pageLoading = ref(false)\nconst saving = ref(false)\nconst formRef = ref<FormInstance>()\nconst form = ref<VisitorFormData>(createDefaultVisitorForm())\nconst dirty = ref(false)\nconst formSaved = ref(false)\nconst trackChanges = ref(false)\n\nconst formRules: FormRules = {\n  name: [\n    { required: true, message: 'Name is required', trigger: 'blur' },\n    { min: 1, max: 50, message: 'Length should be 1 to 50', trigger: 'blur' },\n  ],\n  type: [{ required: true, message: 'Type is required', trigger: 'change' }],\n  serverName: [\n    { required: true, message: 'Server name is required', trigger: 'blur' },\n  ],\n  bindPort: [\n    { required: true, message: 'Bind port is required', trigger: 'blur' },\n    {\n      validator: (_rule, value, callback) => {\n        if (value == null) {\n          callback(new Error('Bind port is required'))\n          return\n        }\n        if (value > 65535) {\n          callback(new Error('Bind port must be less than or equal to 65535'))\n          return\n        }\n        if (form.value.type === 'sudp') {\n          if (value < 1) {\n            callback(new Error('SUDP bind port must be greater than 0'))\n            return\n          }\n          callback()\n          return\n        }\n        if (value === 0) {\n          callback(new Error('Bind port cannot be 0'))\n          return\n        }\n        callback()\n      },\n      trigger: 'blur',\n    },\n  ],\n}\n\nconst goBack = () => {\n  router.back()\n}\n\nwatch(\n  () => form.value,\n  () => {\n    if (trackChanges.value) {\n      dirty.value = true\n    }\n  },\n  { deep: true },\n)\n\nconst leaveDialogVisible = ref(false)\nconst leaveResolve = ref<((value: boolean) => void) | null>(null)\n\nonBeforeRouteLeave(async () => {\n  if (dirty.value && !formSaved.value) {\n    leaveDialogVisible.value = true\n    return new Promise<boolean>((resolve) => {\n      leaveResolve.value = resolve\n    })\n  }\n})\n\nconst handleLeaveConfirm = () => {\n  leaveDialogVisible.value = false\n  leaveResolve.value?.(true)\n}\n\nconst handleLeaveCancel = () => {\n  leaveDialogVisible.value = false\n  leaveResolve.value?.(false)\n}\n\nconst loadVisitor = async () => {\n  const name = route.params.name as string\n  if (!name) return\n\n  trackChanges.value = false\n  dirty.value = false\n  pageLoading.value = true\n  try {\n    const res = await getStoreVisitor(name)\n    form.value = storeVisitorToForm(res)\n    await nextTick()\n  } catch (err: any) {\n    ElMessage.error('Failed to load visitor: ' + err.message)\n    router.push('/visitors')\n  } finally {\n    pageLoading.value = false\n    nextTick(() => {\n      trackChanges.value = true\n    })\n  }\n}\n\nconst handleSave = async () => {\n  if (!formRef.value) return\n\n  try {\n    await formRef.value.validate()\n  } catch {\n    ElMessage.warning('Please fix the form errors')\n    return\n  }\n\n  saving.value = true\n  try {\n    const data = formToStoreVisitor(form.value)\n    if (isEditing.value) {\n      await visitorStore.updateVisitor(form.value.name, data)\n      ElMessage.success('Visitor updated')\n    } else {\n      await visitorStore.createVisitor(data)\n      ElMessage.success('Visitor created')\n    }\n    formSaved.value = true\n    router.push('/visitors')\n  } catch (err: any) {\n    ElMessage.error('Operation failed: ' + (err.message || 'Unknown error'))\n  } finally {\n    saving.value = false\n  }\n}\n\nonMounted(() => {\n  if (isEditing.value) {\n    loadVisitor()\n  } else {\n    trackChanges.value = true\n  }\n})\n\nwatch(\n  () => route.params.name,\n  (name, oldName) => {\n    if (name === oldName) return\n    if (name) {\n      loadVisitor()\n      return\n    }\n    trackChanges.value = false\n    form.value = createDefaultVisitorForm()\n    dirty.value = false\n    nextTick(() => {\n      trackChanges.value = true\n    })\n  },\n)\n</script>\n\n<style scoped lang=\"scss\">\n.visitor-edit-page {\n  display: flex;\n  flex-direction: column;\n  height: 100%;\n  max-width: 960px;\n  margin: 0 auto;\n}\n\n/* Header */\n.edit-header {\n  display: flex;\n  align-items: center;\n  justify-content: space-between;\n  flex-shrink: 0;\n  padding: 20px 24px;\n}\n\n.edit-content {\n  flex: 1;\n  overflow-y: auto;\n  padding: 0 24px 160px;\n\n  > * {\n    max-width: 960px;\n    margin: 0 auto;\n  }\n}\n\n.header-actions {\n  display: flex;\n  gap: 8px;\n}\n\n/* Breadcrumb */\n.breadcrumb {\n  display: flex;\n  align-items: center;\n  gap: 8px;\n  font-size: 14px;\n}\n\n.breadcrumb-item {\n  color: var(--text-secondary);\n  text-decoration: none;\n  transition: color 0.2s;\n}\n\n.breadcrumb-item:hover {\n  color: var(--el-color-primary);\n}\n\n.breadcrumb-separator {\n  color: var(--el-border-color);\n}\n\n.breadcrumb-current {\n  color: var(--text-primary);\n  font-weight: 500;\n}\n\n@include mobile {\n  .edit-header {\n    padding: 20px 16px;\n  }\n\n  .edit-content {\n    padding: 0 16px 160px;\n  }\n}\n</style>\n"
  },
  {
    "path": "web/frpc/src/views/VisitorList.vue",
    "content": "<template>\n  <div class=\"visitors-page\">\n    <!-- Header -->\n    <div class=\"page-header\">\n      <h2 class=\"page-title\">Visitors</h2>\n    </div>\n\n    <!-- Tab bar -->\n    <div class=\"tab-bar\">\n      <div class=\"tab-buttons\">\n        <button class=\"tab-btn active\">Store</button>\n      </div>\n      <div class=\"tab-actions\">\n        <ActionButton variant=\"outline\" size=\"small\" @click=\"fetchData\">\n          <el-icon><Refresh /></el-icon>\n        </ActionButton>\n        <ActionButton v-if=\"visitorStore.storeEnabled\" size=\"small\" @click=\"handleCreate\">\n          + New Visitor\n        </ActionButton>\n      </div>\n    </div>\n\n    <div v-loading=\"visitorStore.loading\">\n      <div v-if=\"!visitorStore.storeEnabled\" class=\"store-disabled\">\n        <p>Store is not enabled. Add the following to your frpc configuration:</p>\n        <pre class=\"config-hint\">[store]\npath = \"./frpc_store.json\"</pre>\n      </div>\n\n      <template v-else>\n        <div class=\"filter-bar\">\n          <el-input v-model=\"searchText\" placeholder=\"Search...\" clearable class=\"search-input\">\n            <template #prefix><el-icon><Search /></el-icon></template>\n          </el-input>\n          <FilterDropdown v-model=\"typeFilter\" label=\"Type\" :options=\"typeOptions\" :min-width=\"140\" :is-mobile=\"isMobile\" />\n        </div>\n\n        <div v-if=\"filteredVisitors.length > 0\" class=\"visitor-list\">\n          <div v-for=\"v in filteredVisitors\" :key=\"v.name\" class=\"visitor-card\" @click=\"goToDetail(v.name)\">\n            <div class=\"card-left\">\n              <div class=\"card-header\">\n                <span class=\"visitor-name\">{{ v.name }}</span>\n                <span class=\"type-tag\">{{ v.type.toUpperCase() }}</span>\n              </div>\n              <div v-if=\"getServerName(v)\" class=\"card-meta\">{{ getServerName(v) }}</div>\n            </div>\n            <div class=\"card-right\">\n              <div @click.stop>\n                <PopoverMenu :width=\"120\" placement=\"bottom-end\">\n                  <template #trigger>\n                    <ActionButton variant=\"outline\" size=\"small\">\n                      <el-icon><MoreFilled /></el-icon>\n                    </ActionButton>\n                  </template>\n                  <PopoverMenuItem @click=\"handleEdit(v)\">\n                    <el-icon><Edit /></el-icon>\n                    Edit\n                  </PopoverMenuItem>\n                  <PopoverMenuItem danger @click=\"handleDelete(v.name)\">\n                    <el-icon><Delete /></el-icon>\n                    Delete\n                  </PopoverMenuItem>\n                </PopoverMenu>\n              </div>\n            </div>\n          </div>\n        </div>\n        <div v-else class=\"empty-state\">\n          <p class=\"empty-text\">No visitors found</p>\n          <p class=\"empty-hint\">Click \"New Visitor\" to create one.</p>\n        </div>\n      </template>\n    </div>\n\n    <ConfirmDialog v-model=\"deleteDialog.visible\" title=\"Delete Visitor\"\n      :message=\"deleteDialog.message\" confirm-text=\"Delete\" danger\n      :loading=\"deleteDialog.loading\" :is-mobile=\"isMobile\" @confirm=\"doDelete\" />\n  </div>\n</template>\n\n<script setup lang=\"ts\">\nimport { ref, computed, reactive, onMounted } from 'vue'\nimport { useRouter } from 'vue-router'\nimport { ElMessage } from 'element-plus'\nimport { Search, Refresh, MoreFilled, Edit, Delete } from '@element-plus/icons-vue'\nimport ActionButton from '@shared/components/ActionButton.vue'\nimport FilterDropdown from '@shared/components/FilterDropdown.vue'\nimport PopoverMenu from '@shared/components/PopoverMenu.vue'\nimport PopoverMenuItem from '@shared/components/PopoverMenuItem.vue'\nimport ConfirmDialog from '@shared/components/ConfirmDialog.vue'\nimport { useVisitorStore } from '../stores/visitor'\nimport { useResponsive } from '../composables/useResponsive'\nimport type { VisitorDefinition } from '../types'\n\nconst { isMobile } = useResponsive()\nconst router = useRouter()\nconst visitorStore = useVisitorStore()\n\nconst searchText = ref('')\nconst typeFilter = ref('')\n\nconst deleteDialog = reactive({\n  visible: false,\n  message: '',\n  loading: false,\n  name: '',\n})\n\nconst typeOptions = computed(() => {\n  return [\n    { label: 'STCP', value: 'stcp' },\n    { label: 'SUDP', value: 'sudp' },\n    { label: 'XTCP', value: 'xtcp' },\n  ]\n})\n\nconst filteredVisitors = computed(() => {\n  let list = visitorStore.storeVisitors\n\n  if (typeFilter.value) {\n    list = list.filter((v) => v.type === typeFilter.value)\n  }\n\n  if (searchText.value) {\n    const q = searchText.value.toLowerCase()\n    list = list.filter((v) => v.name.toLowerCase().includes(q))\n  }\n\n  return list\n})\n\nconst getServerName = (v: VisitorDefinition): string => {\n  const block = (v as any)[v.type]\n  return block?.serverName || ''\n}\n\nconst fetchData = () => {\n  visitorStore.fetchStoreVisitors()\n}\n\nconst handleCreate = () => {\n  router.push('/visitors/create')\n}\n\nconst handleEdit = (v: VisitorDefinition) => {\n  router.push('/visitors/' + encodeURIComponent(v.name) + '/edit')\n}\n\nconst goToDetail = (name: string) => {\n  router.push('/visitors/detail/' + encodeURIComponent(name))\n}\n\nconst handleDelete = (name: string) => {\n  deleteDialog.name = name\n  deleteDialog.message = `Are you sure you want to delete visitor \"${name}\"? This action cannot be undone.`\n  deleteDialog.visible = true\n}\n\nconst doDelete = async () => {\n  deleteDialog.loading = true\n  try {\n    await visitorStore.deleteVisitor(deleteDialog.name)\n    ElMessage.success('Visitor deleted')\n    deleteDialog.visible = false\n    fetchData()\n  } catch (err: any) {\n    ElMessage.error('Delete failed: ' + (err.message || 'Unknown error'))\n  } finally {\n    deleteDialog.loading = false\n  }\n}\n\nonMounted(() => {\n  fetchData()\n})\n</script>\n\n<style scoped lang=\"scss\">\n.visitors-page {\n  height: 100%;\n  overflow-y: auto;\n  padding: $spacing-xl 40px;\n  max-width: 960px;\n  margin: 0 auto;\n}\n\n.page-header {\n  display: flex;\n  align-items: center;\n  justify-content: space-between;\n  margin-bottom: $spacing-xl;\n}\n\n.tab-bar {\n  display: flex;\n  align-items: center;\n  justify-content: space-between;\n  border-bottom: 1px solid $color-border-lighter;\n  margin-bottom: $spacing-xl;\n}\n\n.tab-buttons {\n  display: flex;\n}\n\n.tab-btn {\n  background: none;\n  border: none;\n  padding: $spacing-sm $spacing-xl;\n  font-size: $font-size-md;\n  color: $color-text-muted;\n  cursor: pointer;\n  border-bottom: 2px solid transparent;\n  transition: all $transition-fast;\n\n  &:hover {\n    color: $color-text-primary;\n  }\n\n  &.active {\n    color: $color-text-primary;\n    border-bottom-color: $color-text-primary;\n    font-weight: $font-weight-medium;\n  }\n}\n\n.tab-actions {\n  display: flex;\n  align-items: center;\n  gap: $spacing-sm;\n}\n\n.filter-bar {\n  display: flex;\n  align-items: center;\n  gap: $spacing-sm;\n  margin-bottom: $spacing-xl;\n\n  :deep(.search-input) {\n    flex: 1;\n    min-width: 150px;\n  }\n}\n\n.visitor-list {\n  display: flex;\n  flex-direction: column;\n  gap: $spacing-md;\n}\n\n.visitor-card {\n  display: flex;\n  justify-content: space-between;\n  align-items: center;\n  background: $color-bg-primary;\n  border: 1px solid $color-border-lighter;\n  border-radius: $radius-md;\n  padding: 14px 20px;\n  cursor: pointer;\n  transition: all $transition-medium;\n\n  &:hover {\n    box-shadow: 0 2px 8px rgba(0, 0, 0, 0.04);\n    border-color: $color-border;\n  }\n}\n\n.card-left {\n  @include flex-column;\n  gap: $spacing-sm;\n  flex: 1;\n  min-width: 0;\n}\n\n.card-header {\n  display: flex;\n  align-items: center;\n  gap: $spacing-sm;\n}\n\n.visitor-name {\n  font-size: $font-size-lg;\n  font-weight: $font-weight-semibold;\n  color: $color-text-primary;\n}\n\n.type-tag {\n  font-size: $font-size-xs;\n  font-weight: $font-weight-medium;\n  padding: 2px 8px;\n  border-radius: 4px;\n  background: $color-bg-muted;\n  color: $color-text-secondary;\n}\n\n.card-meta {\n  font-size: $font-size-sm;\n  color: $color-text-muted;\n}\n\n.card-right {\n  display: flex;\n  align-items: center;\n  gap: $spacing-md;\n  flex-shrink: 0;\n}\n\n\n\n.store-disabled {\n  padding: 32px;\n  text-align: center;\n  color: $color-text-muted;\n}\n\n.config-hint {\n  display: inline-block;\n  text-align: left;\n  background: $color-bg-hover;\n  padding: 12px 20px;\n  border-radius: $radius-sm;\n  font-size: $font-size-sm;\n  margin-top: $spacing-md;\n}\n\n.empty-state {\n  text-align: center;\n  padding: 60px $spacing-xl;\n}\n\n.empty-text {\n  font-size: $font-size-lg;\n  font-weight: $font-weight-medium;\n  color: $color-text-secondary;\n  margin: 0 0 $spacing-xs;\n}\n\n.empty-hint {\n  font-size: $font-size-sm;\n  color: $color-text-muted;\n  margin: 0;\n}\n\n@include mobile {\n  .visitors-page {\n    padding: $spacing-lg;\n  }\n\n  .page-header {\n    flex-direction: column;\n    align-items: stretch;\n    gap: $spacing-md;\n  }\n\n  .filter-bar {\n    flex-wrap: wrap;\n\n    :deep(.search-input) {\n      flex: 1 1 100%;\n    }\n  }\n\n  .visitor-card {\n    flex-direction: column;\n    align-items: stretch;\n    gap: $spacing-sm;\n  }\n\n  .card-right {\n    justify-content: flex-end;\n  }\n}\n</style>\n"
  },
  {
    "path": "web/frpc/tsconfig.json",
    "content": "{\n  \"compilerOptions\": {\n    \"target\": \"ES2020\",\n    \"useDefineForClassFields\": true,\n    \"module\": \"ESNext\",\n    \"lib\": [\"ES2020\", \"DOM\", \"DOM.Iterable\"],\n    \"skipLibCheck\": true,\n\n    /* Bundler mode */\n    \"moduleResolution\": \"bundler\",\n    \"allowImportingTsExtensions\": true,\n    \"resolveJsonModule\": true,\n    \"isolatedModules\": true,\n    \"noEmit\": true,\n    \"jsx\": \"preserve\",\n\n    /* Linting */\n    \"strict\": true,\n    \"noUnusedLocals\": true,\n    \"noUnusedParameters\": true,\n    \"noFallthroughCasesInSwitch\": true,\n    \"paths\": {\n      \"@/*\": [\"./src/*\"],\n      \"@shared/*\": [\"../shared/*\"]\n    }\n  },\n  \"include\": [\"src/**/*.ts\", \"src/**/*.tsx\", \"src/**/*.vue\", \"../shared/**/*.ts\", \"../shared/**/*.vue\"],\n  \"references\": [{ \"path\": \"./tsconfig.node.json\" }]\n}\n"
  },
  {
    "path": "web/frpc/tsconfig.node.json",
    "content": "{\n  \"compilerOptions\": {\n    \"composite\": true,\n    \"skipLibCheck\": true,\n    \"module\": \"ESNext\",\n    \"moduleResolution\": \"bundler\",\n    \"allowSyntheticDefaultImports\": true\n  },\n  \"include\": [\"vite.config.ts\"]\n}\n"
  },
  {
    "path": "web/frpc/vite.config.mts",
    "content": "import { fileURLToPath, URL } from 'node:url'\n\nimport { defineConfig } from 'vite'\nimport vue from '@vitejs/plugin-vue'\nimport svgLoader from 'vite-svg-loader'\nimport AutoImport from 'unplugin-auto-import/vite'\nimport Components from 'unplugin-vue-components/vite'\nimport { ElementPlusResolver } from 'unplugin-vue-components/resolvers'\nimport ElementPlus from 'unplugin-element-plus/vite'\n\n// https://vitejs.dev/config/\nexport default defineConfig({\n  base: '',\n  plugins: [\n    vue(),\n    svgLoader(),\n    ElementPlus({}),\n    AutoImport({\n      resolvers: [ElementPlusResolver()],\n    }),\n    Components({\n      resolvers: [ElementPlusResolver()],\n    }),\n  ],\n  resolve: {\n    alias: {\n      '@': fileURLToPath(new URL('./src', import.meta.url)),\n      '@shared': fileURLToPath(new URL('../shared', import.meta.url)),\n    },\n    dedupe: ['vue', 'element-plus', '@element-plus/icons-vue'],\n    modules: [\n      fileURLToPath(new URL('../node_modules', import.meta.url)),\n      'node_modules',\n    ],\n  },\n  css: {\n    preprocessorOptions: {\n      scss: {\n        api: 'modern',\n        additionalData: `@use \"@shared/css/_index.scss\" as *;`,\n      },\n    },\n  },\n  build: {\n    assetsDir: '',\n    chunkSizeWarningLimit: 1000,\n    minify: 'terser',\n    terserOptions: {\n      compress: {\n        drop_console: true,\n        drop_debugger: true,\n      },\n    },\n  },\n  server: {\n    allowedHosts: process.env.ALLOWED_HOSTS\n      ? process.env.ALLOWED_HOSTS.split(',')\n      : [],\n    proxy: {\n      '/api': {\n        target: process.env.VITE_API_URL || 'http://127.0.0.1:7400',\n        changeOrigin: true,\n      },\n    },\n  },\n})\n"
  },
  {
    "path": "web/frps/.gitignore",
    "content": "# Logs\nlogs\n*.log\nnpm-debug.log*\nyarn-debug.log*\nyarn-error.log*\npnpm-debug.log*\nlerna-debug.log*\n\nnode_modules\n.DS_Store\ndist\ndist-ssr\ncoverage\n*.local\n\n/cypress/videos/\n/cypress/screenshots/\n\n# Editor directories and files\n.vscode/*\n!.vscode/extensions.json\n.idea\n*.suo\n*.ntvs*\n*.njsproj\n*.sln\n*.sw?\n"
  },
  {
    "path": "web/frps/.prettierrc.json",
    "content": "{\n  \"tabWidth\": 2,\n  \"semi\": false,\n  \"singleQuote\": true\n}\n"
  },
  {
    "path": "web/frps/Makefile",
    "content": ".PHONY: dist install build preview lint\n\ninstall:\n\t@cd .. && npm install\n\nbuild: install\n\t@npm run build\n\ndev:\n\t@npm run dev\n\npreview:\n\t@npm run preview\n\nlint:\n\t@npm run lint\n"
  },
  {
    "path": "web/frps/README.md",
    "content": "# frps-dashboard\n\n## Project Setup\n\n```sh\nyarn install\n```\n\n### Compile and Hot-Reload for Development\n\n```sh\nmake dev\n```\n\n### Type-Check, Compile and Minify for Production\n\n```sh\nmake build\n```\n\n### Lint with [ESLint](https://eslint.org/)\n\n```sh\nmake lint\n```\n"
  },
  {
    "path": "web/frps/auto-imports.d.ts",
    "content": "/* eslint-disable */\n/* prettier-ignore */\n// @ts-nocheck\n// noinspection JSUnusedGlobalSymbols\n// Generated by unplugin-auto-import\nexport {}\ndeclare global {\n\n}\n"
  },
  {
    "path": "web/frps/components.d.ts",
    "content": "/* eslint-disable */\n/* prettier-ignore */\n// @ts-nocheck\n// Generated by unplugin-vue-components\n// Read more: https://github.com/vuejs/core/pull/3399\nexport {}\n\ndeclare module 'vue' {\n  export interface GlobalComponents {\n    ClientCard: typeof import('./src/components/ClientCard.vue')['default']\n    ElButton: typeof import('element-plus/es')['ElButton']\n    ElCard: typeof import('element-plus/es')['ElCard']\n    ElCol: typeof import('element-plus/es')['ElCol']\n    ElDialog: typeof import('element-plus/es')['ElDialog']\n    ElEmpty: typeof import('element-plus/es')['ElEmpty']\n    ElIcon: typeof import('element-plus/es')['ElIcon']\n    ElInput: typeof import('element-plus/es')['ElInput']\n    ElPopover: typeof import('element-plus/es')['ElPopover']\n    ElRow: typeof import('element-plus/es')['ElRow']\n    ElSwitch: typeof import('element-plus/es')['ElSwitch']\n    ElTag: typeof import('element-plus/es')['ElTag']\n    ElTooltip: typeof import('element-plus/es')['ElTooltip']\n    ProxyCard: typeof import('./src/components/ProxyCard.vue')['default']\n    RouterLink: typeof import('vue-router')['RouterLink']\n    RouterView: typeof import('vue-router')['RouterView']\n    StatCard: typeof import('./src/components/StatCard.vue')['default']\n    Traffic: typeof import('./src/components/Traffic.vue')['default']\n  }\n  export interface ComponentCustomProperties {\n    vLoading: typeof import('element-plus/es')['ElLoadingDirective']\n  }\n}\n"
  },
  {
    "path": "web/frps/embed.go",
    "content": "//go:build !noweb\n\npackage frps\n\nimport (\n\t\"embed\"\n\n\t\"github.com/fatedier/frp/assets\"\n)\n\n//go:embed dist\nvar EmbedFS embed.FS\n\nfunc init() {\n\tassets.Register(EmbedFS)\n}\n"
  },
  {
    "path": "web/frps/embed_stub.go",
    "content": "//go:build noweb\n\npackage frps\n"
  },
  {
    "path": "web/frps/env.d.ts",
    "content": "/// <reference types=\"vite/client\" />\n"
  },
  {
    "path": "web/frps/eslint.config.js",
    "content": "import pluginVue from 'eslint-plugin-vue'\nimport vueTsEslintConfig from '@vue/eslint-config-typescript'\nimport skipFormatting from '@vue/eslint-config-prettier/skip-formatting'\n\nexport default [\n  {\n    name: 'app/files-to-lint',\n    files: ['**/*.{ts,mts,tsx,vue}'],\n  },\n  {\n    name: 'app/files-to-ignore',\n    ignores: ['**/dist/**', '**/dist-ssr/**', '**/coverage/**'],\n  },\n  ...pluginVue.configs['flat/essential'],\n  ...vueTsEslintConfig(),\n  {\n    rules: {\n      '@typescript-eslint/no-explicit-any': 'off',\n      '@typescript-eslint/no-unused-vars': [\n        'warn',\n        {\n          argsIgnorePattern: '^_',\n          varsIgnorePattern: '^_',\n          caughtErrorsIgnorePattern: '^_',\n        },\n      ],\n      'vue/multi-word-component-names': [\n        'error',\n        {\n          ignores: ['Traffic', 'Proxies', 'Clients'],\n        },\n      ],\n    },\n  },\n  skipFormatting,\n]\n"
  },
  {
    "path": "web/frps/index.html",
    "content": "<!DOCTYPE html>\n<html lang=\"en\" class=\"dark\">\n\n<head>\n    <meta charset=\"utf-8\">\n    <title>frp server</title>\n</head>\n\n<body>\n    <div id=\"app\"></div>\n    <script type=\"module\" src=\"/src/main.ts\"></script>\n</body>\n\n</html>\n"
  },
  {
    "path": "web/frps/package.json",
    "content": "{\n  \"name\": \"frps-dashboard\",\n  \"version\": \"0.0.1\",\n  \"private\": true,\n  \"type\": \"module\",\n  \"scripts\": {\n    \"dev\": \"vite\",\n    \"build\": \"run-p type-check build-only\",\n    \"preview\": \"vite preview\",\n    \"build-only\": \"vite build\",\n    \"type-check\": \"vue-tsc --noEmit\",\n    \"lint\": \"eslint --fix\"\n  },\n  \"dependencies\": {\n    \"element-plus\": \"^2.13.0\",\n    \"vue\": \"^3.5.26\",\n    \"vue-router\": \"^4.6.4\"\n  },\n  \"devDependencies\": {\n    \"@types/node\": \"24\",\n    \"@vitejs/plugin-vue\": \"^6.0.3\",\n    \"@vue/eslint-config-prettier\": \"^10.2.0\",\n    \"@vue/eslint-config-typescript\": \"^14.7.0\",\n    \"@vue/tsconfig\": \"^0.8.1\",\n    \"@vueuse/core\": \"^14.1.0\",\n    \"eslint\": \"^9.39.0\",\n    \"eslint-plugin-vue\": \"^9.33.0\",\n    \"npm-run-all\": \"^4.1.5\",\n    \"prettier\": \"^3.7.4\",\n    \"sass\": \"^1.97.2\",\n    \"terser\": \"^5.44.1\",\n    \"typescript\": \"^5.9.3\",\n    \"unplugin-auto-import\": \"^0.17.5\",\n    \"unplugin-element-plus\": \"^0.11.2\",\n    \"unplugin-vue-components\": \"^0.26.0\",\n    \"vite\": \"^7.3.0\",\n    \"vite-svg-loader\": \"^5.1.0\",\n    \"vue-tsc\": \"^3.2.2\"\n  }\n}\n"
  },
  {
    "path": "web/frps/src/App.vue",
    "content": "<template>\n  <div id=\"app\">\n    <header class=\"header\">\n      <div class=\"header-content\">\n        <div class=\"brand-section\">\n          <button\n            v-if=\"isMobile\"\n            class=\"hamburger-btn\"\n            @click=\"toggleSidebar\"\n            aria-label=\"Toggle menu\"\n          >\n            <span class=\"hamburger-icon\">&#9776;</span>\n          </button>\n          <div class=\"logo-wrapper\">\n            <LogoIcon class=\"logo-icon\" />\n          </div>\n          <span class=\"divider\">/</span>\n          <span class=\"brand-name\">frp</span>\n          <span class=\"badge server-badge\">Server</span>\n        </div>\n\n        <div class=\"header-controls\">\n          <a\n            class=\"github-link\"\n            href=\"https://github.com/fatedier/frp\"\n            target=\"_blank\"\n            aria-label=\"GitHub\"\n          >\n            <GitHubIcon class=\"github-icon\" />\n          </a>\n          <el-switch\n            v-model=\"isDark\"\n            inline-prompt\n            :active-icon=\"Moon\"\n            :inactive-icon=\"Sunny\"\n            class=\"theme-switch\"\n          />\n        </div>\n      </div>\n    </header>\n\n    <div class=\"layout\">\n      <!-- Mobile overlay -->\n      <div\n        v-if=\"isMobile && sidebarOpen\"\n        class=\"sidebar-overlay\"\n        @click=\"closeSidebar\"\n      />\n\n      <aside\n        class=\"sidebar\"\n        :class=\"{ 'mobile-open': isMobile && sidebarOpen }\"\n      >\n        <nav class=\"sidebar-nav\">\n          <router-link\n            to=\"/\"\n            class=\"sidebar-link\"\n            :class=\"{ active: route.path === '/' }\"\n            @click=\"closeSidebar\"\n          >\n            Overview\n          </router-link>\n          <router-link\n            to=\"/clients\"\n            class=\"sidebar-link\"\n            :class=\"{ active: route.path.startsWith('/clients') }\"\n            @click=\"closeSidebar\"\n          >\n            Clients\n          </router-link>\n          <router-link\n            to=\"/proxies\"\n            class=\"sidebar-link\"\n            :class=\"{\n              active:\n                route.path.startsWith('/proxies') ||\n                route.path.startsWith('/proxy'),\n            }\"\n            @click=\"closeSidebar\"\n          >\n            Proxies\n          </router-link>\n        </nav>\n      </aside>\n\n      <main id=\"content\">\n        <router-view></router-view>\n      </main>\n    </div>\n  </div>\n</template>\n\n<script setup lang=\"ts\">\nimport { ref, watch } from 'vue'\nimport { useRoute } from 'vue-router'\nimport { useDark } from '@vueuse/core'\nimport { Moon, Sunny } from '@element-plus/icons-vue'\nimport GitHubIcon from './assets/icons/github.svg?component'\nimport LogoIcon from './assets/icons/logo.svg?component'\nimport { useResponsive } from './composables/useResponsive'\n\nconst route = useRoute()\nconst isDark = useDark()\nconst { isMobile } = useResponsive()\n\nconst sidebarOpen = ref(false)\n\nconst toggleSidebar = () => {\n  sidebarOpen.value = !sidebarOpen.value\n}\n\nconst closeSidebar = () => {\n  sidebarOpen.value = false\n}\n\n// Auto-close sidebar on route change\nwatch(\n  () => route.path,\n  () => {\n    if (isMobile.value) {\n      closeSidebar()\n    }\n  },\n)\n</script>\n\n<style>\n:root {\n  --header-height: 50px;\n  --sidebar-width: 200px;\n  --header-bg: #ffffff;\n  --header-border: #e4e7ed;\n  --sidebar-bg: #ffffff;\n  --text-primary: #303133;\n  --text-secondary: #606266;\n  --text-muted: #909399;\n  --hover-bg: #efefef;\n  --content-bg: #f9f9f9;\n}\n\nhtml.dark {\n  --header-bg: #1e1e2e;\n  --header-border: #3a3d5c;\n  --sidebar-bg: #1e1e2e;\n  --text-primary: #e5e7eb;\n  --text-secondary: #b0b0b0;\n  --text-muted: #888888;\n  --hover-bg: #2a2a3e;\n  --content-bg: #181825;\n}\n\nbody {\n  margin: 0;\n  font-family:\n    ui-sans-serif, -apple-system, system-ui, Segoe UI, Helvetica, Arial,\n    sans-serif;\n}\n\n*,\n:after,\n:before {\n  box-sizing: border-box;\n  -webkit-tap-highlight-color: transparent;\n}\n\nhtml,\nbody {\n  height: 100%;\n  overflow: hidden;\n}\n\n#app {\n  height: 100vh;\n  height: 100dvh;\n  display: flex;\n  flex-direction: column;\n  background-color: var(--content-bg);\n}\n\n/* Header */\n.header {\n  flex-shrink: 0;\n  background: var(--header-bg);\n  border-bottom: 1px solid var(--header-border);\n  height: var(--header-height);\n}\n\n.header-content {\n  display: flex;\n  align-items: center;\n  justify-content: space-between;\n  height: 100%;\n  padding: 0 20px;\n}\n\n.brand-section {\n  display: flex;\n  align-items: center;\n  gap: 12px;\n}\n\n.logo-wrapper {\n  display: flex;\n  align-items: center;\n}\n\n.logo-icon {\n  width: 28px;\n  height: 28px;\n}\n\n.divider {\n  color: var(--header-border);\n  font-size: 22px;\n  font-weight: 200;\n}\n\n.brand-name {\n  font-weight: 600;\n  font-size: 18px;\n  color: var(--text-primary);\n  letter-spacing: -0.5px;\n}\n\n.badge {\n  font-size: 11px;\n  font-weight: 500;\n  color: var(--text-muted);\n  background: var(--hover-bg);\n  padding: 2px 8px;\n  border-radius: 4px;\n}\n\n.badge.server-badge {\n  background: linear-gradient(135deg, #3b82f6 0%, #06b6d4 100%);\n  color: white;\n  border: none;\n  font-weight: 500;\n}\n\nhtml.dark .badge.server-badge {\n  background: linear-gradient(135deg, #60a5fa 0%, #22d3ee 100%);\n}\n\n.header-controls {\n  display: flex;\n  align-items: center;\n  gap: 16px;\n}\n\n.github-link {\n  display: flex;\n  align-items: center;\n  justify-content: center;\n  width: 28px;\n  height: 28px;\n  border-radius: 6px;\n  color: var(--text-secondary);\n  transition: all 0.15s ease;\n  text-decoration: none;\n}\n\n.github-link:hover {\n  background: var(--hover-bg);\n  color: var(--text-primary);\n}\n\n.github-icon {\n  width: 18px;\n  height: 18px;\n}\n\n.theme-switch {\n  --el-switch-on-color: #2c2c3a;\n  --el-switch-off-color: #f2f2f2;\n  --el-switch-border-color: var(--header-border);\n}\n\nhtml.dark .theme-switch {\n  --el-switch-off-color: #333;\n}\n\n.theme-switch .el-switch__core .el-switch__inner .el-icon {\n  color: #909399 !important;\n}\n\n/* Layout */\n.layout {\n  flex: 1;\n  display: flex;\n  overflow: hidden;\n}\n\n/* Sidebar */\n.sidebar {\n  width: var(--sidebar-width);\n  flex-shrink: 0;\n  background: var(--sidebar-bg);\n  border-right: 1px solid var(--header-border);\n  padding: 16px 12px;\n  overflow-y: auto;\n  display: flex;\n  flex-direction: column;\n}\n\n.sidebar-nav {\n  display: flex;\n  flex-direction: column;\n  gap: 2px;\n}\n\n.sidebar-link {\n  display: block;\n  text-decoration: none;\n  font-size: 15px;\n  color: var(--text-secondary);\n  padding: 10px 12px;\n  border-radius: 6px;\n  transition: all 0.15s ease;\n}\n\n.sidebar-link:hover {\n  color: var(--text-primary);\n  background: var(--hover-bg);\n}\n\n.sidebar-link.active {\n  color: var(--text-primary);\n  background: var(--hover-bg);\n  font-weight: 500;\n}\n\n/* Hamburger button (mobile only) */\n.hamburger-btn {\n  display: flex;\n  align-items: center;\n  justify-content: center;\n  width: 36px;\n  height: 36px;\n  border: none;\n  border-radius: 6px;\n  background: transparent;\n  cursor: pointer;\n  padding: 0;\n  transition: background 0.15s ease;\n}\n\n.hamburger-btn:hover {\n  background: var(--hover-bg);\n}\n\n.hamburger-icon {\n  font-size: 20px;\n  line-height: 1;\n  color: var(--text-primary);\n}\n\n/* Mobile overlay */\n.sidebar-overlay {\n  position: fixed;\n  inset: 0;\n  background: rgba(0, 0, 0, 0.5);\n  z-index: 99;\n}\n\n/* Content */\n#content {\n  flex: 1;\n  min-width: 0;\n  overflow-y: auto;\n  padding: 40px;\n}\n\n#content > * {\n  max-width: 1024px;\n  margin: 0 auto;\n}\n\n/* Common page styles */\n.page-title {\n  font-size: 20px;\n  font-weight: 600;\n  color: var(--color-text-primary, var(--text-primary));\n  margin: 0;\n}\n\n.page-subtitle {\n  font-size: 14px;\n  color: var(--color-text-muted, var(--text-muted));\n  margin: 8px 0 0;\n}\n\n/* Element Plus global overrides */\n.el-button {\n  font-weight: 500;\n}\n\n.el-tag {\n  font-weight: 500;\n}\n\n.el-switch {\n  --el-switch-on-color: #606266;\n  --el-switch-off-color: #dcdfe6;\n}\n\nhtml.dark .el-switch {\n  --el-switch-on-color: #b0b0b0;\n  --el-switch-off-color: #3a3d5c;\n}\n\n.el-form-item {\n  margin-bottom: 16px;\n}\n\n.el-loading-mask {\n  border-radius: 8px;\n}\n\n/* Select overrides */\n.el-select__wrapper {\n  border-radius: 8px !important;\n  box-shadow: 0 0 0 1px var(--color-border-light, #e4e7ed) inset !important;\n  transition: all 0.15s ease;\n}\n\n.el-select__wrapper:hover {\n  box-shadow: 0 0 0 1px var(--color-border, #dcdfe6) inset !important;\n}\n\n.el-select__wrapper.is-focused {\n  box-shadow: 0 0 0 1px var(--color-border, #dcdfe6) inset !important;\n}\n\n.el-select-dropdown {\n  border-radius: 12px !important;\n  border: 1px solid var(--color-border-light, #e4e7ed) !important;\n  box-shadow:\n    0 10px 25px -5px rgba(0, 0, 0, 0.1),\n    0 8px 10px -6px rgba(0, 0, 0, 0.1) !important;\n  padding: 4px !important;\n}\n\n.el-select-dropdown__item {\n  border-radius: 6px;\n  margin: 2px 0;\n  transition: background 0.15s ease;\n}\n\n.el-select-dropdown__item.is-selected {\n  color: var(--color-text-primary, var(--text-primary));\n  font-weight: 500;\n}\n\n/* Input overrides */\n.el-input__wrapper {\n  border-radius: 8px !important;\n  box-shadow: 0 0 0 1px var(--color-border-light, #e4e7ed) inset !important;\n  transition: all 0.15s ease;\n}\n\n.el-input__wrapper:hover {\n  box-shadow: 0 0 0 1px var(--color-border, #dcdfe6) inset !important;\n}\n\n.el-input__wrapper.is-focus {\n  box-shadow: 0 0 0 1px var(--color-border, #dcdfe6) inset !important;\n}\n\n/* Card overrides */\n.el-card {\n  border-radius: 12px;\n  border: 1px solid var(--color-border-light, #e4e7ed);\n  transition: all 0.2s ease;\n}\n\n/* Scrollbar */\n::-webkit-scrollbar {\n  width: 6px;\n  height: 6px;\n}\n\n::-webkit-scrollbar-track {\n  background: transparent;\n}\n\n::-webkit-scrollbar-thumb {\n  background: #d1d1d1;\n  border-radius: 3px;\n}\n\n/* Mobile */\n@media (max-width: 767px) {\n  .header-content {\n    padding: 0 16px;\n  }\n\n  .sidebar {\n    position: fixed;\n    top: var(--header-height);\n    left: 0;\n    bottom: 0;\n    z-index: 100;\n    background: var(--sidebar-bg);\n    transform: translateX(-100%);\n    transition: transform 0.25s cubic-bezier(0.4, 0, 0.2, 1);\n    border-right: 1px solid var(--header-border);\n  }\n\n  .sidebar.mobile-open {\n    transform: translateX(0);\n  }\n\n  #content {\n    width: 100%;\n    padding: 20px;\n  }\n}\n</style>\n"
  },
  {
    "path": "web/frps/src/api/client.ts",
    "content": "import { http } from './http'\nimport type { ClientInfoData } from '../types/client'\n\nexport const getClients = () => {\n  return http.get<ClientInfoData[]>('../api/clients')\n}\n\nexport const getClient = (key: string) => {\n  return http.get<ClientInfoData>(`../api/clients/${key}`)\n}\n"
  },
  {
    "path": "web/frps/src/api/http.ts",
    "content": "// http.ts - Base HTTP client\n\nclass HTTPError extends Error {\n  status: number\n  statusText: string\n\n  constructor(status: number, statusText: string, message?: string) {\n    super(message || statusText)\n    this.status = status\n    this.statusText = statusText\n  }\n}\n\nasync function request<T>(url: string, options: RequestInit = {}): Promise<T> {\n  const defaultOptions: RequestInit = {\n    credentials: 'include',\n  }\n\n  const response = await fetch(url, { ...defaultOptions, ...options })\n\n  if (!response.ok) {\n    throw new HTTPError(\n      response.status,\n      response.statusText,\n      `HTTP ${response.status}`,\n    )\n  }\n\n  // Handle empty response (e.g. 204 No Content)\n  if (response.status === 204) {\n    return {} as T\n  }\n\n  return response.json()\n}\n\nexport const http = {\n  get: <T>(url: string, options?: RequestInit) =>\n    request<T>(url, { ...options, method: 'GET' }),\n  post: <T>(url: string, body?: any, options?: RequestInit) =>\n    request<T>(url, {\n      ...options,\n      method: 'POST',\n      headers: { 'Content-Type': 'application/json', ...options?.headers },\n      body: JSON.stringify(body),\n    }),\n  put: <T>(url: string, body?: any, options?: RequestInit) =>\n    request<T>(url, {\n      ...options,\n      method: 'PUT',\n      headers: { 'Content-Type': 'application/json', ...options?.headers },\n      body: JSON.stringify(body),\n    }),\n  delete: <T>(url: string, options?: RequestInit) =>\n    request<T>(url, { ...options, method: 'DELETE' }),\n}\n"
  },
  {
    "path": "web/frps/src/api/proxy.ts",
    "content": "import { http } from './http'\nimport type {\n  GetProxyResponse,\n  ProxyStatsInfo,\n  TrafficResponse,\n} from '../types/proxy'\n\nexport const getProxiesByType = (type: string) => {\n  return http.get<GetProxyResponse>(`../api/proxy/${type}`)\n}\n\nexport const getProxy = (type: string, name: string) => {\n  return http.get<ProxyStatsInfo>(`../api/proxy/${type}/${name}`)\n}\n\nexport const getProxyByName = (name: string) => {\n  return http.get<ProxyStatsInfo>(`../api/proxies/${name}`)\n}\n\nexport const getProxyTraffic = (name: string) => {\n  return http.get<TrafficResponse>(`../api/traffic/${name}`)\n}\n\nexport const clearOfflineProxies = () => {\n  return http.delete('../api/proxies?status=offline')\n}\n"
  },
  {
    "path": "web/frps/src/api/server.ts",
    "content": "import { http } from './http'\nimport type { ServerInfo } from '../types/server'\n\nexport const getServerInfo = () => {\n  return http.get<ServerInfo>('../api/serverinfo')\n}\n"
  },
  {
    "path": "web/frps/src/assets/css/custom.css",
    "content": ".el-form-item span {\n  margin-left: 15px;\n}\n\n.proxy-table-expand {\n  font-size: 0;\n}\n\n.proxy-table-expand .el-form-item__label{\n  width: 90px;\n  color: #99a9bf;\n}\n\n.proxy-table-expand .el-form-item {\n  margin-right: 0;\n  margin-bottom: 0;\n  width: 50%;\n}\n\n.el-table .el-table__expanded-cell {\n  padding: 20px 50px;\n}\n\n/* Modern styles */\n* {\n  box-sizing: border-box;\n}\n\n/* Smooth transitions */\n.el-button,\n.el-card,\n.el-input,\n.el-select,\n.el-tag {\n  transition: all 0.3s ease;\n}\n\n/* Card hover effects */\n.el-card:hover {\n  box-shadow: 0 8px 16px rgba(0, 0, 0, 0.1);\n}\n\n/* Better scrollbar */\n::-webkit-scrollbar {\n  width: 8px;\n  height: 8px;\n}\n\n::-webkit-scrollbar-track {\n  background: #f1f1f1;\n  border-radius: 4px;\n}\n\n::-webkit-scrollbar-thumb {\n  background: #c1c1c1;\n  border-radius: 4px;\n}\n\n::-webkit-scrollbar-thumb:hover {\n  background: #a8a8a8;\n}\n\n/* Page headers */\n.el-page-header {\n  padding: 16px 0;\n}\n\n.el-page-header__title {\n  font-size: 20px;\n  font-weight: 500;\n}\n\n/* Better form layouts */\n.el-form-item {\n  margin-bottom: 18px;\n}\n\n/* Responsive adjustments */\n@media (max-width: 768px) {\n  .el-row {\n    margin-left: 0 !important;\n    margin-right: 0 !important;\n  }\n\n  .el-col {\n    padding-left: 10px !important;\n    padding-right: 10px !important;\n  }\n}\n"
  },
  {
    "path": "web/frps/src/assets/css/dark.css",
    "content": "/* Dark mode styles */\nhtml.dark {\n  --el-bg-color: #1e1e2e;\n  --el-bg-color-page: #181825;\n  --el-bg-color-overlay: #27293d;\n  --el-fill-color-blank: #1e1e2e;\n  --el-border-color: #3a3d5c;\n  --el-border-color-light: #313348;\n  --el-border-color-lighter: #2a2a3e;\n  --el-text-color-primary: #e5e7eb;\n  --el-text-color-secondary: #888888;\n  --el-text-color-placeholder: #afafaf;\n  background-color: #1e1e2e;\n  color-scheme: dark;\n}\n\n/* Scrollbar */\nhtml.dark ::-webkit-scrollbar {\n  width: 6px;\n  height: 6px;\n}\n\nhtml.dark ::-webkit-scrollbar-track {\n  background: #27293d;\n}\n\nhtml.dark ::-webkit-scrollbar-thumb {\n  background: #3a3d5c;\n  border-radius: 3px;\n}\n\nhtml.dark ::-webkit-scrollbar-thumb:hover {\n  background: #4a4d6c;\n}\n\n/* Form */\nhtml.dark .el-form-item__label {\n  color: #e5e7eb;\n}\n\n/* Input */\nhtml.dark .el-input__wrapper {\n  background: var(--color-bg-input);\n  box-shadow: 0 0 0 1px #3a3d5c inset;\n}\n\nhtml.dark .el-input__wrapper:hover {\n  box-shadow: 0 0 0 1px #4a4d6c inset;\n}\n\nhtml.dark .el-input__wrapper.is-focus {\n  box-shadow: 0 0 0 1px var(--el-color-primary) inset;\n}\n\nhtml.dark .el-input__inner {\n  color: #e5e7eb;\n}\n\nhtml.dark .el-input__inner::placeholder {\n  color: #afafaf;\n}\n\nhtml.dark .el-textarea__inner {\n  background: var(--color-bg-input);\n  box-shadow: 0 0 0 1px #3a3d5c inset;\n  color: #e5e7eb;\n}\n\nhtml.dark .el-textarea__inner:hover {\n  box-shadow: 0 0 0 1px #4a4d6c inset;\n}\n\nhtml.dark .el-textarea__inner:focus {\n  box-shadow: 0 0 0 1px var(--el-color-primary) inset;\n}\n\n/* Select */\nhtml.dark .el-select__wrapper {\n  background: var(--color-bg-input);\n  box-shadow: 0 0 0 1px #3a3d5c inset;\n}\n\nhtml.dark .el-select__wrapper:hover {\n  box-shadow: 0 0 0 1px #4a4d6c inset;\n}\n\nhtml.dark .el-select__selected-item {\n  color: #e5e7eb;\n}\n\nhtml.dark .el-select__placeholder {\n  color: #afafaf;\n}\n\nhtml.dark .el-select-dropdown {\n  background: #27293d;\n  border-color: #3a3d5c;\n}\n\nhtml.dark .el-select-dropdown__item {\n  color: #e5e7eb;\n}\n\nhtml.dark .el-select-dropdown__item:hover {\n  background: #2a2a3e;\n}\n\nhtml.dark .el-select-dropdown__item.is-selected {\n  color: var(--el-color-primary);\n}\n\nhtml.dark .el-select-dropdown__item.is-disabled {\n  color: #666666;\n}\n\n/* Tag */\nhtml.dark .el-tag--info {\n  background: #27293d;\n  border-color: #3a3d5c;\n  color: #b0b0b0;\n}\n\n/* Button */\nhtml.dark .el-button--default {\n  background: #27293d;\n  border-color: #3a3d5c;\n  color: #e5e7eb;\n}\n\nhtml.dark .el-button--default:hover {\n  background: #2a2a3e;\n  border-color: #4a4d6c;\n  color: #e5e7eb;\n}\n\n/* Card */\nhtml.dark .el-card {\n  background: #1e1e2e;\n  border-color: #3a3d5c;\n  color: #b0b0b0;\n}\n\nhtml.dark .el-card__header {\n  border-bottom-color: #3a3d5c;\n  color: #e5e7eb;\n}\n\n/* Table */\nhtml.dark .el-table {\n  background-color: #1e1e2e;\n  color: #e5e7eb;\n}\n\nhtml.dark .el-table th {\n  background-color: #1e1e2e;\n  color: #e5e7eb;\n}\n\nhtml.dark .el-table tr {\n  background-color: #1e1e2e;\n}\n\nhtml.dark .el-table--striped .el-table__body tr.el-table__row--striped td {\n  background-color: #181825;\n}\n\n/* Dialog */\nhtml.dark .el-dialog {\n  background: #1e1e2e;\n}\n\nhtml.dark .el-dialog__title {\n  color: #e5e7eb;\n}\n\n/* Message */\nhtml.dark .el-message {\n  background: #27293d;\n  border-color: #3a3d5c;\n}\n\nhtml.dark .el-message--success {\n  background: #1e3d2e;\n  border-color: #3d6b4f;\n}\n\nhtml.dark .el-message--warning {\n  background: #3d3020;\n  border-color: #6b5020;\n}\n\nhtml.dark .el-message--error {\n  background: #3d2027;\n  border-color: #5c2d2d;\n}\n\n/* Loading */\nhtml.dark .el-loading-mask {\n  background-color: rgba(30, 30, 46, 0.9);\n}\n\n/* Overlay */\nhtml.dark .el-overlay {\n  background-color: rgba(0, 0, 0, 0.6);\n}\n\n/* Tooltip */\nhtml.dark .el-tooltip__popper {\n  background: #27293d !important;\n  border-color: #3a3d5c !important;\n  color: #e5e7eb !important;\n}\n"
  },
  {
    "path": "web/frps/src/assets/css/var.css",
    "content": ":root {\n  /* Text colors */\n  --color-text-primary: #303133;\n  --color-text-secondary: #606266;\n  --color-text-muted: #909399;\n  --color-text-light: #c0c4cc;\n  --color-text-placeholder: #a8abb2;\n\n  /* Background colors */\n  --color-bg-primary: #ffffff;\n  --color-bg-secondary: #f9f9f9;\n  --color-bg-tertiary: #fafafa;\n  --color-bg-surface: #ffffff;\n  --color-bg-muted: #f4f4f5;\n  --color-bg-input: #ffffff;\n  --color-bg-hover: #efefef;\n  --color-bg-active: #eaeaea;\n\n  /* Border colors */\n  --color-border: #dcdfe6;\n  --color-border-light: #e4e7ed;\n  --color-border-lighter: #ebeef5;\n  --color-border-extra-light: #f2f6fc;\n\n  /* Status colors */\n  --color-primary: #409eff;\n  --color-primary-light: #ecf5ff;\n  --color-success: #67c23a;\n  --color-warning: #e6a23c;\n  --color-danger: #f56c6c;\n  --color-danger-dark: #c45656;\n  --color-danger-light: #fef0f0;\n  --color-info: #909399;\n\n  /* Element Plus mapping */\n  --el-color-primary: var(--color-primary);\n  --el-color-success: var(--color-success);\n  --el-color-warning: var(--color-warning);\n  --el-color-danger: var(--color-danger);\n  --el-color-info: var(--color-info);\n\n  --el-text-color-primary: var(--color-text-primary);\n  --el-text-color-regular: var(--color-text-secondary);\n  --el-text-color-secondary: var(--color-text-muted);\n  --el-text-color-placeholder: var(--color-text-placeholder);\n\n  --el-bg-color: var(--color-bg-primary);\n  --el-bg-color-page: var(--color-bg-secondary);\n  --el-bg-color-overlay: var(--color-bg-primary);\n\n  --el-border-color: var(--color-border);\n  --el-border-color-light: var(--color-border-light);\n  --el-border-color-lighter: var(--color-border-lighter);\n  --el-border-color-extra-light: var(--color-border-extra-light);\n\n  --el-fill-color-blank: var(--color-bg-primary);\n  --el-fill-color-light: var(--color-bg-tertiary);\n  --el-fill-color: var(--color-bg-tertiary);\n  --el-fill-color-dark: var(--color-bg-hover);\n  --el-fill-color-darker: var(--color-bg-active);\n\n  /* Input */\n  --el-input-bg-color: var(--color-bg-input);\n  --el-input-border-color: var(--color-border);\n  --el-input-hover-border-color: var(--color-border-light);\n\n  /* Dialog */\n  --el-dialog-bg-color: var(--color-bg-primary);\n  --el-overlay-color: rgba(0, 0, 0, 0.5);\n}\n\nhtml.dark {\n  /* Text colors */\n  --color-text-primary: #e5e7eb;\n  --color-text-secondary: #b0b0b0;\n  --color-text-muted: #888888;\n  --color-text-light: #666666;\n  --color-text-placeholder: #afafaf;\n\n  /* Background colors */\n  --color-bg-primary: #1e1e2e;\n  --color-bg-secondary: #181825;\n  --color-bg-tertiary: #27293d;\n  --color-bg-surface: #27293d;\n  --color-bg-muted: #27293d;\n  --color-bg-input: #27293d;\n  --color-bg-hover: #2a2a3e;\n  --color-bg-active: #353550;\n\n  /* Border colors */\n  --color-border: #3a3d5c;\n  --color-border-light: #313348;\n  --color-border-lighter: #2a2a3e;\n  --color-border-extra-light: #222233;\n\n  /* Status colors */\n  --color-primary: #409eff;\n  --color-danger: #f87171;\n  --color-danger-dark: #f87171;\n  --color-danger-light: #3d2027;\n  --color-info: #888888;\n\n  /* Dark overrides */\n  --el-text-color-regular: var(--color-text-primary);\n  --el-overlay-color: rgba(0, 0, 0, 0.7);\n\n  background-color: #181825;\n  color-scheme: dark;\n}\n"
  },
  {
    "path": "web/frps/src/components/ClientCard.vue",
    "content": "<template>\n  <div class=\"client-card\" @click=\"viewDetail\">\n    <div class=\"card-icon-wrapper\">\n      <div\n        class=\"status-dot-large\"\n        :class=\"client.online ? 'online' : 'offline'\"\n      ></div>\n    </div>\n\n    <div class=\"card-content\">\n      <div class=\"card-header\">\n        <span class=\"client-main-id\">{{ client.displayName }}</span>\n        <span v-if=\"client.hostname\" class=\"hostname-badge\">{{\n          client.hostname\n        }}</span>\n        <el-tag v-if=\"client.version\" size=\"small\" type=\"success\"\n          >v{{ client.version }}</el-tag\n        >\n      </div>\n\n      <div class=\"card-meta\">\n        <div class=\"meta-group\">\n          <span v-if=\"client.ip\" class=\"meta-item\">\n            <span class=\"meta-label\">IP</span>\n            <span class=\"meta-value\">{{ client.ip }}</span>\n          </span>\n        </div>\n        <span class=\"meta-item activity\">\n          <el-icon class=\"activity-icon\"><DataLine /></el-icon>\n          <span class=\"meta-value\">{{\n            client.online ? client.lastConnectedAgo : client.disconnectedAgo\n          }}</span>\n        </span>\n      </div>\n    </div>\n\n    <div class=\"card-action\">\n      <div class=\"status-badge\" :class=\"client.online ? 'online' : 'offline'\">\n        {{ client.online ? 'Online' : 'Offline' }}\n      </div>\n      <el-icon class=\"arrow-icon\"><ArrowRight /></el-icon>\n    </div>\n  </div>\n</template>\n\n<script setup lang=\"ts\">\nimport { useRouter } from 'vue-router'\nimport { DataLine, ArrowRight } from '@element-plus/icons-vue'\nimport type { Client } from '../utils/client'\n\ninterface Props {\n  client: Client\n}\n\nconst props = defineProps<Props>()\nconst router = useRouter()\n\nconst viewDetail = () => {\n  router.push({\n    name: 'ClientDetail',\n    params: { key: props.client.key },\n  })\n}\n</script>\n\n<style scoped>\n.client-card {\n  display: flex;\n  align-items: center;\n  gap: 20px;\n  padding: 24px;\n  background: var(--el-bg-color);\n  border: 1px solid var(--el-border-color-lighter);\n  border-radius: 16px;\n  cursor: pointer;\n  transition: all 0.2s cubic-bezier(0.4, 0, 0.2, 1);\n  position: relative;\n  overflow: hidden;\n}\n\n.client-card:hover {\n  transform: translateY(-2px);\n  box-shadow: 0 8px 24px rgba(0, 0, 0, 0.04);\n  border-color: var(--el-border-color-light);\n}\n\n.card-icon-wrapper {\n  width: 32px;\n  height: 32px;\n  border-radius: 8px;\n  background: var(--el-fill-color);\n  display: flex;\n  align-items: center;\n  justify-content: center;\n  flex-shrink: 0;\n  transition: all 0.2s;\n}\n\n.client-card:hover .card-icon-wrapper {\n  background: var(--el-color-success-light-9);\n}\n\n.status-dot-large {\n  width: 6px;\n  height: 6px;\n  border-radius: 50%;\n  transition: all 0.3s;\n}\n\n.status-dot-large.online {\n  background-color: var(--el-color-success);\n  box-shadow: 0 0 0 2px var(--el-color-success-light-8);\n}\n\n.status-dot-large.offline {\n  background-color: var(--el-text-color-placeholder);\n}\n\n.card-content {\n  flex: 1;\n  display: flex;\n  flex-direction: column;\n  gap: 8px;\n  min-width: 0;\n}\n\n.card-header {\n  display: flex;\n  align-items: center;\n  gap: 12px;\n  flex-wrap: wrap;\n}\n\n.client-main-id {\n  font-size: 15px;\n  font-weight: 600;\n  color: var(--el-text-color-primary);\n  line-height: 1.2;\n}\n\n.hostname-badge {\n  font-size: 12px;\n  font-weight: 500;\n  padding: 2px 8px;\n  border-radius: 6px;\n  background: var(--el-fill-color-dark);\n  color: var(--el-text-color-regular);\n}\n\n.card-meta {\n  display: flex;\n  align-items: center;\n  gap: 24px;\n  font-size: 13px;\n  color: var(--el-text-color-regular);\n  flex-wrap: wrap;\n}\n\n.meta-group {\n  display: flex;\n  align-items: center;\n  gap: 16px;\n}\n\n.meta-item {\n  display: flex;\n  align-items: center;\n  gap: 8px;\n}\n\n.meta-label {\n  color: var(--el-text-color-placeholder);\n  font-weight: 500;\n  font-size: 13px;\n}\n\n.meta-value {\n  font-size: 13px;\n  font-weight: 500;\n  color: var(--el-text-color-primary);\n}\n\n.activity .meta-value {\n  font-weight: 400;\n  color: var(--el-text-color-secondary);\n}\n\n.card-action {\n  display: flex;\n  align-items: center;\n  gap: 20px;\n  flex-shrink: 0;\n}\n\n.status-badge {\n  padding: 4px 12px;\n  border-radius: 20px;\n  font-size: 13px;\n  font-weight: 500;\n  transition: all 0.2s;\n}\n\n.status-badge.online {\n  background: var(--el-color-success-light-9);\n  color: var(--el-color-success);\n}\n\n.status-badge.offline {\n  background: var(--el-fill-color);\n  color: var(--el-text-color-secondary);\n}\n\n.arrow-icon {\n  font-size: 18px;\n  color: var(--el-text-color-placeholder);\n  transition: all 0.2s;\n}\n\n.client-card:hover .arrow-icon {\n  color: var(--el-text-color-primary);\n  transform: translateX(4px);\n}\n\n/* Dark mode adjustments */\nhtml.dark .card-icon-wrapper {\n  background: var(--el-fill-color-light);\n}\n\nhtml.dark .client-card:hover .card-icon-wrapper {\n  background: var(--el-color-success-light-9);\n}\n\nhtml.dark .status-dot-large.online {\n  box-shadow: 0 0 0 2px rgba(var(--el-color-success-rgb), 0.2);\n}\n\n@media (max-width: 640px) {\n  .client-card {\n    flex-direction: column;\n    align-items: flex-start;\n    padding: 20px;\n  }\n\n  .card-icon-wrapper {\n    width: 48px;\n    height: 48px;\n  }\n\n  .card-content {\n    width: 100%;\n    gap: 12px;\n  }\n\n  .card-action {\n    width: 100%;\n    justify-content: space-between;\n    padding-top: 16px;\n    border-top: 1px solid var(--el-border-color-lighter);\n  }\n}\n</style>\n"
  },
  {
    "path": "web/frps/src/components/ProxyCard.vue",
    "content": "<template>\n  <router-link :to=\"proxyLink\" class=\"proxy-card\">\n    <div class=\"card-main\">\n      <div class=\"card-left\">\n        <div class=\"card-header\">\n          <span class=\"proxy-name\">{{ proxy.name }}</span>\n          <span v-if=\"showType\" class=\"type-tag\">{{\n            proxy.type.toUpperCase()\n          }}</span>\n        </div>\n\n        <div class=\"card-meta\">\n          <span v-if=\"proxy.port\" class=\"meta-item\">\n            <span class=\"meta-label\">Port:</span>\n            <span class=\"meta-value\">{{ proxy.port }}</span>\n          </span>\n          <span class=\"meta-item\">\n            <span class=\"meta-label\">Connections:</span>\n            <span class=\"meta-value\">{{ proxy.conns }}</span>\n          </span>\n          <span class=\"meta-item\" v-if=\"proxy.clientID\">\n            <span class=\"meta-label\">Client:</span>\n            <span class=\"meta-value\">{{\n              proxy.user ? `${proxy.user}.${proxy.clientID}` : proxy.clientID\n            }}</span>\n          </span>\n        </div>\n      </div>\n\n      <div class=\"card-right\">\n        <div class=\"traffic-stats\">\n          <div class=\"traffic-row\">\n            <el-icon class=\"traffic-icon out\"><Top /></el-icon>\n            <span class=\"traffic-value\">{{\n              formatFileSize(proxy.trafficOut)\n            }}</span>\n          </div>\n          <div class=\"traffic-row\">\n            <el-icon class=\"traffic-icon in\"><Bottom /></el-icon>\n            <span class=\"traffic-value\">{{\n              formatFileSize(proxy.trafficIn)\n            }}</span>\n          </div>\n        </div>\n\n        <div class=\"status-badge\" :class=\"proxy.status\">\n          {{ proxy.status }}\n        </div>\n      </div>\n    </div>\n  </router-link>\n</template>\n\n<script setup lang=\"ts\">\nimport { computed } from 'vue'\nimport { useRoute } from 'vue-router'\nimport { Top, Bottom } from '@element-plus/icons-vue'\nimport { formatFileSize } from '../utils/format'\nimport type { BaseProxy } from '../utils/proxy'\n\ninterface Props {\n  proxy: BaseProxy\n  showType?: boolean\n}\n\nconst props = defineProps<Props>()\nconst route = useRoute()\n\nconst proxyLink = computed(() => {\n  const base = `/proxy/${props.proxy.name}`\n  // If we're on a client detail page, pass client info\n  if (route.name === 'ClientDetail' && route.params.key) {\n    return `${base}?from=client&client=${route.params.key}`\n  }\n  return base\n})\n</script>\n\n<style scoped>\n.proxy-card {\n  display: block;\n  background: var(--el-bg-color);\n  border: 1px solid var(--el-border-color-lighter);\n  border-radius: 12px;\n  transition: all 0.2s ease-in-out;\n  overflow: hidden;\n  text-decoration: none;\n  cursor: pointer;\n}\n\n.proxy-card:hover {\n  border-color: var(--el-border-color-light);\n  box-shadow: 0 4px 12px rgba(0, 0, 0, 0.04);\n}\n\n.card-main {\n  display: flex;\n  justify-content: space-between;\n  align-items: center;\n  padding: 20px 24px;\n  gap: 24px;\n  min-height: 80px;\n}\n\n/* Left Section */\n.card-left {\n  display: flex;\n  flex-direction: column;\n  justify-content: center;\n  gap: 12px;\n  flex: 1;\n  min-width: 0;\n}\n\n.card-header {\n  display: flex;\n  align-items: center;\n  gap: 8px;\n}\n\n.proxy-name {\n  font-size: 16px;\n  font-weight: 600;\n  color: var(--el-text-color-primary);\n  line-height: 1.4;\n}\n\n.type-tag {\n  font-size: 11px;\n  font-weight: 500;\n  padding: 2px 6px;\n  border-radius: 4px;\n  background: var(--el-fill-color);\n  color: var(--el-text-color-secondary);\n}\n\n.card-meta {\n  display: flex;\n  align-items: center;\n  gap: 24px;\n  flex-wrap: wrap;\n}\n\n.meta-item {\n  display: flex;\n  align-items: baseline;\n  gap: 6px;\n  line-height: 1;\n}\n\n.meta-label {\n  color: var(--el-text-color-placeholder);\n  font-size: 13px;\n  font-weight: 500;\n}\n\n.meta-value {\n  font-size: 13px;\n  font-weight: 500;\n  color: var(--el-text-color-regular);\n}\n\n/* Right Section */\n.card-right {\n  display: flex;\n  align-items: center;\n  gap: 24px;\n  flex-shrink: 0;\n}\n\n.traffic-stats {\n  display: flex;\n  flex-direction: column;\n  gap: 4px;\n  align-items: flex-end;\n}\n\n.traffic-row {\n  display: flex;\n  align-items: center;\n  gap: 6px;\n  line-height: 1;\n}\n\n.traffic-icon {\n  font-size: 12px;\n}\n\n.traffic-icon.in {\n  color: var(--el-color-primary);\n}\n\n.traffic-icon.out {\n  color: var(--el-color-success);\n}\n\n.traffic-value {\n  font-size: 12px;\n  color: var(--el-text-color-secondary);\n  font-weight: 500;\n  text-align: right;\n}\n\n.status-badge {\n  display: inline-flex;\n  padding: 2px 10px;\n  border-radius: 10px;\n  font-size: 12px;\n  font-weight: 500;\n  text-transform: capitalize;\n}\n\n.status-badge.online {\n  background: var(--el-color-success-light-9);\n  color: var(--el-color-success);\n}\n\n.status-badge.offline {\n  background: var(--el-color-danger-light-9);\n  color: var(--el-color-danger);\n}\n\n/* Mobile Responsive */\n@media (max-width: 768px) {\n  .card-main {\n    flex-direction: column;\n    align-items: stretch;\n    gap: 16px;\n    padding: 16px;\n  }\n\n  .card-right {\n    flex-direction: row;\n    align-items: center;\n    justify-content: space-between;\n    border-top: 1px solid var(--el-border-color-lighter);\n    padding-top: 16px;\n  }\n\n  .traffic-stats {\n    align-items: flex-start;\n  }\n}\n</style>\n"
  },
  {
    "path": "web/frps/src/components/StatCard.vue",
    "content": "<template>\n  <el-card\n    class=\"stat-card\"\n    :class=\"{ clickable: !!to }\"\n    :body-style=\"{ padding: '20px' }\"\n    shadow=\"hover\"\n    @click=\"handleClick\"\n  >\n    <div class=\"stat-card-content\">\n      <div class=\"stat-icon\" :class=\"`icon-${type}`\">\n        <component :is=\"iconComponent\" class=\"icon\" />\n      </div>\n      <div class=\"stat-info\">\n        <div class=\"stat-value\">{{ value }}</div>\n        <div class=\"stat-label\">{{ label }}</div>\n      </div>\n      <el-icon v-if=\"to\" class=\"arrow-icon\"><ArrowRight /></el-icon>\n    </div>\n    <div v-if=\"subtitle\" class=\"stat-subtitle\">{{ subtitle }}</div>\n  </el-card>\n</template>\n\n<script setup lang=\"ts\">\nimport { computed } from 'vue'\nimport { useRouter } from 'vue-router'\nimport {\n  User,\n  Connection,\n  DataAnalysis,\n  Promotion,\n  ArrowRight,\n} from '@element-plus/icons-vue'\n\ninterface Props {\n  label: string\n  value: string | number\n  type?: 'clients' | 'proxies' | 'connections' | 'traffic'\n  subtitle?: string\n  to?: string\n}\n\nconst props = withDefaults(defineProps<Props>(), {\n  type: 'clients',\n})\n\nconst router = useRouter()\n\nconst iconComponent = computed(() => {\n  switch (props.type) {\n    case 'clients':\n      return User\n    case 'proxies':\n      return Connection\n    case 'connections':\n      return DataAnalysis\n    case 'traffic':\n      return Promotion\n    default:\n      return User\n  }\n})\n\nconst handleClick = () => {\n  if (props.to) {\n    router.push(props.to)\n  }\n}\n</script>\n\n<style scoped>\n.stat-card {\n  border-radius: 12px;\n  transition: all 0.3s ease;\n  border: 1px solid #e4e7ed;\n}\n\n.stat-card.clickable {\n  cursor: pointer;\n}\n\n.stat-card.clickable:hover {\n  transform: translateY(-4px);\n  box-shadow: 0 12px 24px rgba(0, 0, 0, 0.1);\n}\n\n.stat-card.clickable:hover .arrow-icon {\n  transform: translateX(4px);\n}\n\nhtml.dark .stat-card {\n  border-color: #3a3d5c;\n  background: #27293d;\n}\n\n.stat-card-content {\n  display: flex;\n  align-items: center;\n  gap: 16px;\n}\n\n.arrow-icon {\n  color: #909399;\n  font-size: 18px;\n  transition: transform 0.2s ease;\n  flex-shrink: 0;\n}\n\nhtml.dark .arrow-icon {\n  color: #9ca3af;\n}\n\n.stat-icon {\n  width: 56px;\n  height: 56px;\n  border-radius: 12px;\n  display: flex;\n  align-items: center;\n  justify-content: center;\n  flex-shrink: 0;\n}\n\n.stat-icon .icon {\n  width: 28px;\n  height: 28px;\n}\n\n.icon-clients {\n  background: linear-gradient(135deg, #667eea 0%, #764ba2 100%);\n  color: white;\n}\n\n.icon-proxies {\n  background: linear-gradient(135deg, #f093fb 0%, #f5576c 100%);\n  color: white;\n}\n\n.icon-connections {\n  background: linear-gradient(135deg, #4facfe 0%, #00f2fe 100%);\n  color: white;\n}\n\n.icon-traffic {\n  background: linear-gradient(135deg, #43e97b 0%, #38f9d7 100%);\n  color: white;\n}\n\nhtml.dark .icon-clients {\n  background: linear-gradient(135deg, #818cf8 0%, #a78bfa 100%);\n}\n\nhtml.dark .icon-proxies {\n  background: linear-gradient(135deg, #fb7185 0%, #f43f5e 100%);\n}\n\nhtml.dark .icon-connections {\n  background: linear-gradient(135deg, #60a5fa 0%, #3b82f6 100%);\n}\n\nhtml.dark .icon-traffic {\n  background: linear-gradient(135deg, #34d399 0%, #10b981 100%);\n}\n\n.stat-info {\n  flex: 1;\n  min-width: 0;\n}\n\n.stat-value {\n  font-size: 28px;\n  font-weight: 500;\n  line-height: 1.2;\n  color: #303133;\n  margin-bottom: 4px;\n}\n\nhtml.dark .stat-value {\n  color: #e5e7eb;\n}\n\n.stat-label {\n  font-size: 14px;\n  color: #909399;\n  font-weight: 500;\n}\n\nhtml.dark .stat-label {\n  color: #9ca3af;\n}\n\n.stat-subtitle {\n  margin-top: 12px;\n  padding-top: 12px;\n  border-top: 1px solid #e4e7ed;\n  font-size: 12px;\n  color: #909399;\n}\n\nhtml.dark .stat-subtitle {\n  border-top-color: #3a3d5c;\n  color: #9ca3af;\n}\n</style>\n"
  },
  {
    "path": "web/frps/src/components/Traffic.vue",
    "content": "<template>\n  <div class=\"traffic-chart-container\" v-loading=\"loading\">\n    <div v-if=\"!loading && chartData.length > 0\" class=\"chart-wrapper\">\n      <div class=\"y-axis\">\n        <div class=\"y-label\">{{ formatFileSize(maxVal) }}</div>\n        <div class=\"y-label\">{{ formatFileSize(maxVal / 2) }}</div>\n        <div class=\"y-label\">0</div>\n      </div>\n\n      <div class=\"bars-area\">\n        <!-- Grid Lines -->\n        <div class=\"grid-line top\"></div>\n        <div class=\"grid-line middle\"></div>\n        <div class=\"grid-line bottom\"></div>\n\n        <div v-for=\"(item, index) in chartData\" :key=\"index\" class=\"day-column\">\n          <div class=\"bars-group\">\n            <el-tooltip\n              :content=\"`In: ${formatFileSize(item.in)}`\"\n              placement=\"top\"\n            >\n              <div\n                class=\"bar bar-in\"\n                :style=\"{ height: Math.max(item.inPercent, 1) + '%' }\"\n              ></div>\n            </el-tooltip>\n            <el-tooltip\n              :content=\"`Out: ${formatFileSize(item.out)}`\"\n              placement=\"top\"\n            >\n              <div\n                class=\"bar bar-out\"\n                :style=\"{ height: Math.max(item.outPercent, 1) + '%' }\"\n              ></div>\n            </el-tooltip>\n          </div>\n          <div class=\"date-label\">{{ item.date }}</div>\n        </div>\n      </div>\n    </div>\n\n    <!-- Legend -->\n    <div v-if=\"!loading && chartData.length > 0\" class=\"legend\">\n      <div class=\"legend-item\"><span class=\"dot in\"></span> Traffic In</div>\n      <div class=\"legend-item\"><span class=\"dot out\"></span> Traffic Out</div>\n    </div>\n\n    <el-empty v-else-if=\"!loading\" description=\"No traffic data\" />\n  </div>\n</template>\n\n<script setup lang=\"ts\">\nimport { ref, onMounted } from 'vue'\nimport { ElMessage } from 'element-plus'\nimport { formatFileSize } from '../utils/format'\nimport { getProxyTraffic } from '../api/proxy'\n\nconst props = defineProps<{\n  proxyName: string\n}>()\n\nconst loading = ref(false)\nconst chartData = ref<\n  Array<{\n    date: string\n    in: number\n    out: number\n    inPercent: number\n    outPercent: number\n  }>\n>([])\nconst maxVal = ref(0)\n\nconst processData = (trafficIn: number[], trafficOut: number[]) => {\n  // Ensure we have arrays and reverse them (server returns newest first)\n  const inArr = [...(trafficIn || [])].reverse()\n  const outArr = [...(trafficOut || [])].reverse()\n\n  // Pad with zeros if less than 7 days\n  while (inArr.length < 7) inArr.unshift(0)\n  while (outArr.length < 7) outArr.unshift(0)\n\n  // Slice to last 7 entries just in case\n  const finalIn = inArr.slice(-7)\n  const finalOut = outArr.slice(-7)\n\n  // Calculate dates (last 7 days ending today)\n  const dates: string[] = []\n  const d = new Date()\n  d.setDate(d.getDate() - 6)\n\n  for (let i = 0; i < 7; i++) {\n    dates.push(`${d.getMonth() + 1}-${d.getDate()}`)\n    d.setDate(d.getDate() + 1)\n  }\n\n  // Find max value for scaling\n  const maxIn = Math.max(...finalIn)\n  const maxOut = Math.max(...finalOut)\n  maxVal.value = Math.max(maxIn, maxOut, 100) // Minimum scale 100 bytes\n\n  // Build chart data\n  chartData.value = dates.map((date, i) => ({\n    date,\n    in: finalIn[i],\n    out: finalOut[i],\n    inPercent: (finalIn[i] / maxVal.value) * 100,\n    outPercent: (finalOut[i] / maxVal.value) * 100,\n  }))\n}\n\nconst fetchData = () => {\n  loading.value = true\n  getProxyTraffic(props.proxyName)\n    .then((json) => {\n      processData(json.trafficIn, json.trafficOut)\n    })\n    .catch((err) => {\n      ElMessage({\n        showClose: true,\n        message: 'Get traffic info failed! ' + err,\n        type: 'warning',\n      })\n    })\n    .finally(() => {\n      loading.value = false\n    })\n}\n\nonMounted(() => {\n  fetchData()\n})\n</script>\n\n<style scoped>\n.traffic-chart-container {\n  width: 100%;\n  height: 400px;\n  display: flex;\n  flex-direction: column;\n  padding: 20px;\n}\n\n.chart-wrapper {\n  flex: 1;\n  display: flex;\n  gap: 10px;\n  position: relative;\n  margin-bottom: 20px;\n}\n\n.y-axis {\n  display: flex;\n  flex-direction: column;\n  justify-content: space-between;\n  text-align: right;\n  font-size: 12px;\n  color: #909399;\n  padding-bottom: 24px; /* Align with bars area excluding date labels */\n  height: calc(100% - 24px); /* Subtract date label height approx */\n}\n\n.bars-area {\n  flex: 1;\n  display: flex;\n  justify-content: space-between;\n  align-items: flex-end;\n  position: relative;\n  height: 100%;\n  padding-bottom: 24px; /* Space for date labels */\n}\n\n.grid-line {\n  position: absolute;\n  left: 0;\n  right: 0;\n  height: 1px;\n  background-color: #e4e7ed;\n  z-index: 0;\n}\n\nhtml.dark .grid-line {\n  background-color: #3a3d5c;\n}\n\n.grid-line.top {\n  top: 0;\n}\n.grid-line.middle {\n  top: 50%;\n  transform: translateY(-50%);\n}\n.grid-line.bottom {\n  bottom: 24px;\n} /* Align with bottom of bars */\n\n.day-column {\n  flex: 1;\n  height: 100%;\n  display: flex;\n  flex-direction: column;\n  justify-content: flex-end;\n  align-items: center;\n  position: relative;\n  z-index: 1;\n}\n\n.bars-group {\n  height: 100%;\n  display: flex;\n  align-items: flex-end;\n  gap: 4px;\n  width: 60%;\n}\n\n.bar {\n  flex: 1;\n  border-radius: 4px 4px 0 0;\n  transition: height 0.3s ease;\n  min-height: 1px;\n}\n\n.bar-in {\n  background-color: #5470c6;\n}\n\n.bar-out {\n  background-color: #91cc75;\n}\n\n.bar:hover {\n  opacity: 0.8;\n}\n\n.date-label {\n  position: absolute;\n  bottom: -24px;\n  font-size: 12px;\n  color: #909399;\n  width: 100%;\n  text-align: center;\n}\n\n.legend {\n  display: flex;\n  justify-content: center;\n  gap: 24px;\n  margin-top: 10px;\n}\n\n.legend-item {\n  display: flex;\n  align-items: center;\n  gap: 8px;\n  font-size: 14px;\n  color: #606266;\n}\n\nhtml.dark .legend-item {\n  color: #e5e7eb;\n}\n\n.dot {\n  width: 12px;\n  height: 12px;\n  border-radius: 50%;\n}\n\n.dot.in {\n  background-color: #5470c6;\n}\n.dot.out {\n  background-color: #91cc75;\n}\n</style>\n"
  },
  {
    "path": "web/frps/src/composables/useResponsive.ts",
    "content": "import { useBreakpoints } from '@vueuse/core'\n\nconst breakpoints = useBreakpoints({ mobile: 0, desktop: 768 })\n\nexport function useResponsive() {\n  const isMobile = breakpoints.smaller('desktop') // < 768px\n  return { isMobile }\n}\n"
  },
  {
    "path": "web/frps/src/main.ts",
    "content": "import { createApp } from 'vue'\nimport 'element-plus/theme-chalk/dark/css-vars.css'\nimport App from './App.vue'\nimport router from './router'\n\nimport './assets/css/var.css'\nimport './assets/css/dark.css'\n\nconst app = createApp(App)\n\napp.use(router)\n\napp.mount('#app')\n"
  },
  {
    "path": "web/frps/src/router/index.ts",
    "content": "import { createRouter, createWebHashHistory } from 'vue-router'\nimport ServerOverview from '../views/ServerOverview.vue'\nimport Clients from '../views/Clients.vue'\nimport ClientDetail from '../views/ClientDetail.vue'\nimport Proxies from '../views/Proxies.vue'\nimport ProxyDetail from '../views/ProxyDetail.vue'\n\nconst router = createRouter({\n  history: createWebHashHistory(),\n  scrollBehavior() {\n    return { top: 0 }\n  },\n  routes: [\n    {\n      path: '/',\n      name: 'ServerOverview',\n      component: ServerOverview,\n    },\n    {\n      path: '/clients',\n      name: 'Clients',\n      component: Clients,\n    },\n    {\n      path: '/clients/:key',\n      name: 'ClientDetail',\n      component: ClientDetail,\n    },\n    {\n      path: '/proxies/:type?',\n      name: 'Proxies',\n      component: Proxies,\n    },\n    {\n      path: '/proxy/:name',\n      name: 'ProxyDetail',\n      component: ProxyDetail,\n    },\n  ],\n})\n\nexport default router\n"
  },
  {
    "path": "web/frps/src/svg.d.ts",
    "content": "declare module '*.svg?component' {\n  import type { DefineComponent } from 'vue'\n  const component: DefineComponent<object, object, unknown>\n  export default component\n}\n"
  },
  {
    "path": "web/frps/src/types/client.ts",
    "content": "export interface ClientInfoData {\n  key: string\n  user: string\n  clientID: string\n  runID: string\n  version?: string\n  hostname: string\n  clientIP?: string\n  metas?: Record<string, string>\n  firstConnectedAt: number\n  lastConnectedAt: number\n  disconnectedAt?: number\n  online: boolean\n}\n"
  },
  {
    "path": "web/frps/src/types/proxy.ts",
    "content": "export interface ProxyStatsInfo {\n  name: string\n  conf: any\n  user: string\n  clientID: string\n  todayTrafficIn: number\n  todayTrafficOut: number\n  curConns: number\n  lastStartTime: string\n  lastCloseTime: string\n  status: string\n}\n\nexport interface GetProxyResponse {\n  proxies: ProxyStatsInfo[]\n}\n\nexport interface TrafficResponse {\n  name: string\n  trafficIn: number[]\n  trafficOut: number[]\n}\n"
  },
  {
    "path": "web/frps/src/types/server.ts",
    "content": "export interface ServerInfo {\n  version: string\n  bindPort: number\n  vhostHTTPPort: number\n  vhostHTTPSPort: number\n  tcpmuxHTTPConnectPort: number\n  kcpBindPort: number\n  quicBindPort: number\n  subdomainHost: string\n  maxPoolCount: number\n  maxPortsPerClient: number\n  heartbeatTimeout: number\n  allowPortsStr: string\n  tlsForce: boolean\n\n  // Stats\n  totalTrafficIn: number\n  totalTrafficOut: number\n  curConns: number\n  clientCounts: number\n  proxyTypeCount: Record<string, number>\n}\n"
  },
  {
    "path": "web/frps/src/utils/client.ts",
    "content": "import { formatDistanceToNow } from './format'\nimport type { ClientInfoData } from '../types/client'\n\nexport class Client {\n  key: string\n  user: string\n  clientID: string\n  runID: string\n  version: string\n  hostname: string\n  ip: string\n  metas: Map<string, string>\n  firstConnectedAt: Date\n  lastConnectedAt: Date\n  disconnectedAt?: Date\n  online: boolean\n\n  constructor(data: ClientInfoData) {\n    this.key = data.key\n    this.user = data.user\n    this.clientID = data.clientID\n    this.runID = data.runID\n    this.version = data.version || ''\n    this.hostname = data.hostname\n    this.ip = data.clientIP || ''\n    this.metas = new Map<string, string>()\n    if (data.metas) {\n      for (const [key, value] of Object.entries(data.metas)) {\n        this.metas.set(key, value)\n      }\n    }\n    this.firstConnectedAt = new Date(data.firstConnectedAt * 1000)\n    this.lastConnectedAt = new Date(data.lastConnectedAt * 1000)\n    if (data.disconnectedAt && data.disconnectedAt > 0) {\n      this.disconnectedAt = new Date(data.disconnectedAt * 1000)\n    }\n    this.online = data.online\n  }\n\n  get displayName(): string {\n    if (this.clientID) {\n      return this.user ? `${this.user}.${this.clientID}` : this.clientID\n    }\n    return this.runID\n  }\n\n  get shortRunId(): string {\n    return this.runID.substring(0, 8)\n  }\n\n  get firstConnectedAgo(): string {\n    return formatDistanceToNow(this.firstConnectedAt)\n  }\n\n  get lastConnectedAgo(): string {\n    return formatDistanceToNow(this.lastConnectedAt)\n  }\n\n  get disconnectedAgo(): string {\n    if (!this.disconnectedAt) return ''\n    return formatDistanceToNow(this.disconnectedAt)\n  }\n\n  get statusColor(): string {\n    return this.online ? 'success' : 'danger'\n  }\n\n  get metasArray(): Array<{ key: string; value: string }> {\n    const arr: Array<{ key: string; value: string }> = []\n    this.metas.forEach((value, key) => {\n      arr.push({ key, value })\n    })\n    return arr\n  }\n\n  matchesFilter(searchText: string): boolean {\n    const search = searchText.toLowerCase()\n    return (\n      this.key.toLowerCase().includes(search) ||\n      this.user.toLowerCase().includes(search) ||\n      this.clientID.toLowerCase().includes(search) ||\n      this.runID.toLowerCase().includes(search) ||\n      this.hostname.toLowerCase().includes(search)\n    )\n  }\n}\n"
  },
  {
    "path": "web/frps/src/utils/format.ts",
    "content": "export function formatDistanceToNow(date: Date): string {\n  const seconds = Math.floor((new Date().getTime() - date.getTime()) / 1000)\n\n  let interval = seconds / 31536000\n  if (interval > 1) return Math.floor(interval) + ' years ago'\n\n  interval = seconds / 2592000\n  if (interval > 1) return Math.floor(interval) + ' months ago'\n\n  interval = seconds / 86400\n  if (interval > 1) return Math.floor(interval) + ' days ago'\n\n  interval = seconds / 3600\n  if (interval > 1) return Math.floor(interval) + ' hours ago'\n\n  interval = seconds / 60\n  if (interval > 1) return Math.floor(interval) + ' minutes ago'\n\n  return Math.floor(seconds) + ' seconds ago'\n}\n\nexport function formatFileSize(bytes: number): string {\n  if (!Number.isFinite(bytes) || bytes < 0) return '0 B'\n  if (bytes === 0) return '0 B'\n  const k = 1024\n  const sizes = ['B', 'KB', 'MB', 'GB', 'TB', 'PB', 'EB', 'ZB', 'YB']\n  const i = Math.floor(Math.log(bytes) / Math.log(k))\n  // Prevent index out of bounds for extremely large numbers\n  const unit = sizes[i] || sizes[sizes.length - 1]\n  const val = bytes / Math.pow(k, i)\n\n  return parseFloat(val.toFixed(2)) + ' ' + unit\n}\n"
  },
  {
    "path": "web/frps/src/utils/proxy.ts",
    "content": "class BaseProxy {\n  name: string\n  type: string\n  annotations: Map<string, string>\n  encryption: boolean\n  compression: boolean\n  conns: number\n  trafficIn: number\n  trafficOut: number\n  lastStartTime: string\n  lastCloseTime: string\n  status: string\n  user: string\n  clientID: string\n  addr: string\n  port: number\n\n  customDomains: string\n  hostHeaderRewrite: string\n  locations: string\n  subdomain: string\n\n  // TCPMux specific\n  multiplexer: string\n  routeByHTTPUser: string\n\n  constructor(proxyStats: any) {\n    this.name = proxyStats.name\n    this.type = ''\n    this.annotations = new Map<string, string>()\n    if (proxyStats.conf?.annotations) {\n      for (const key in proxyStats.conf.annotations) {\n        this.annotations.set(key, proxyStats.conf.annotations[key])\n      }\n    }\n\n    this.encryption = false\n    this.compression = false\n    this.encryption =\n      proxyStats.conf?.transport?.useEncryption || this.encryption\n    this.compression =\n      proxyStats.conf?.transport?.useCompression || this.compression\n    this.conns = proxyStats.curConns\n    this.trafficIn = proxyStats.todayTrafficIn\n    this.trafficOut = proxyStats.todayTrafficOut\n    this.lastStartTime = proxyStats.lastStartTime\n    this.lastCloseTime = proxyStats.lastCloseTime\n    this.status = proxyStats.status\n    this.user = proxyStats.user || ''\n    this.clientID = proxyStats.clientID || ''\n\n    this.addr = ''\n    this.port = 0\n    this.customDomains = ''\n    this.hostHeaderRewrite = ''\n    this.locations = ''\n    this.subdomain = ''\n    this.multiplexer = ''\n    this.routeByHTTPUser = ''\n  }\n}\n\nclass TCPProxy extends BaseProxy {\n  constructor(proxyStats: any) {\n    super(proxyStats)\n    this.type = 'tcp'\n    if (proxyStats.conf != null) {\n      this.addr = ':' + proxyStats.conf.remotePort\n      this.port = proxyStats.conf.remotePort\n    } else {\n      this.addr = ''\n      this.port = 0\n    }\n  }\n}\n\nclass UDPProxy extends BaseProxy {\n  constructor(proxyStats: any) {\n    super(proxyStats)\n    this.type = 'udp'\n    if (proxyStats.conf != null) {\n      this.addr = ':' + proxyStats.conf.remotePort\n      this.port = proxyStats.conf.remotePort\n    } else {\n      this.addr = ''\n      this.port = 0\n    }\n  }\n}\n\nclass HTTPProxy extends BaseProxy {\n  constructor(proxyStats: any, port: number, subdomainHost: string) {\n    super(proxyStats)\n    this.type = 'http'\n    this.port = port\n    if (proxyStats.conf) {\n      this.customDomains = proxyStats.conf.customDomains || this.customDomains\n      this.hostHeaderRewrite = proxyStats.conf.hostHeaderRewrite\n      this.locations = proxyStats.conf.locations\n      if (proxyStats.conf.subdomain) {\n        this.subdomain = `${proxyStats.conf.subdomain}.${subdomainHost}`\n      }\n    }\n  }\n}\n\nclass HTTPSProxy extends BaseProxy {\n  constructor(proxyStats: any, port: number, subdomainHost: string) {\n    super(proxyStats)\n    this.type = 'https'\n    this.port = port\n    if (proxyStats.conf != null) {\n      this.customDomains = proxyStats.conf.customDomains || this.customDomains\n      if (proxyStats.conf.subdomain) {\n        this.subdomain = `${proxyStats.conf.subdomain}.${subdomainHost}`\n      }\n    }\n  }\n}\n\nclass TCPMuxProxy extends BaseProxy {\n  constructor(proxyStats: any, port: number, subdomainHost: string) {\n    super(proxyStats)\n    this.type = 'tcpmux'\n    this.port = port\n\n    if (proxyStats.conf) {\n      this.customDomains = proxyStats.conf.customDomains || this.customDomains\n      this.multiplexer = proxyStats.conf.multiplexer || ''\n      this.routeByHTTPUser = proxyStats.conf.routeByHTTPUser || ''\n      if (proxyStats.conf.subdomain) {\n        this.subdomain = `${proxyStats.conf.subdomain}.${subdomainHost}`\n      }\n    }\n  }\n}\n\nclass STCPProxy extends BaseProxy {\n  constructor(proxyStats: any) {\n    super(proxyStats)\n    this.type = 'stcp'\n  }\n}\n\nclass SUDPProxy extends BaseProxy {\n  constructor(proxyStats: any) {\n    super(proxyStats)\n    this.type = 'sudp'\n  }\n}\n\nexport {\n  BaseProxy,\n  TCPProxy,\n  UDPProxy,\n  TCPMuxProxy,\n  HTTPProxy,\n  HTTPSProxy,\n  STCPProxy,\n  SUDPProxy,\n}\n"
  },
  {
    "path": "web/frps/src/views/ClientDetail.vue",
    "content": "<template>\n  <div class=\"client-detail-page\">\n    <!-- Breadcrumb -->\n    <nav class=\"breadcrumb\">\n      <a class=\"breadcrumb-link\" @click=\"goBack\">\n        <el-icon><ArrowLeft /></el-icon>\n      </a>\n      <router-link to=\"/clients\" class=\"breadcrumb-item\">Clients</router-link>\n      <span class=\"breadcrumb-separator\">/</span>\n      <span class=\"breadcrumb-current\">{{\n        client?.displayName || route.params.key\n      }}</span>\n    </nav>\n\n    <div v-loading=\"loading\" class=\"detail-content\">\n      <template v-if=\"client\">\n        <!-- Header Card -->\n        <div class=\"header-card\">\n          <div class=\"header-main\">\n            <div class=\"header-left\">\n              <div class=\"client-avatar\">\n                {{ client.displayName.charAt(0).toUpperCase() }}\n              </div>\n              <div class=\"client-info\">\n                <div class=\"client-name-row\">\n                  <h1 class=\"client-name\">{{ client.displayName }}</h1>\n                  <el-tag v-if=\"client.version\" size=\"small\" type=\"success\"\n                    >v{{ client.version }}</el-tag\n                  >\n                </div>\n                <div class=\"client-meta\">\n                  <span v-if=\"client.ip\" class=\"meta-item\">{{\n                    client.ip\n                  }}</span>\n                  <span v-if=\"client.hostname\" class=\"meta-item\">{{\n                    client.hostname\n                  }}</span>\n                </div>\n              </div>\n            </div>\n            <div class=\"header-right\">\n              <span\n                class=\"status-badge\"\n                :class=\"client.online ? 'online' : 'offline'\"\n              >\n                {{ client.online ? 'Online' : 'Offline' }}\n              </span>\n            </div>\n          </div>\n\n          <!-- Info Section -->\n          <div class=\"info-section\">\n            <div class=\"info-item\">\n              <span class=\"info-label\">Connections</span>\n              <span class=\"info-value\">{{ totalConnections }}</span>\n            </div>\n            <div class=\"info-item\">\n              <span class=\"info-label\">Run ID</span>\n              <span class=\"info-value\">{{ client.runID }}</span>\n            </div>\n            <div class=\"info-item\">\n              <span class=\"info-label\">First Connected</span>\n              <span class=\"info-value\">{{ client.firstConnectedAgo }}</span>\n            </div>\n            <div class=\"info-item\">\n              <span class=\"info-label\">{{\n                client.online ? 'Connected' : 'Disconnected'\n              }}</span>\n              <span class=\"info-value\">{{\n                client.online ? client.lastConnectedAgo : client.disconnectedAgo\n              }}</span>\n            </div>\n          </div>\n        </div>\n\n        <!-- Proxies Card -->\n        <div class=\"proxies-card\">\n          <div class=\"proxies-header\">\n            <div class=\"proxies-title\">\n              <h2>Proxies</h2>\n              <span class=\"proxies-count\">{{ filteredProxies.length }}</span>\n            </div>\n            <el-input\n              v-model=\"proxySearch\"\n              placeholder=\"Search proxies...\"\n              :prefix-icon=\"Search\"\n              clearable\n              class=\"proxy-search\"\n            />\n          </div>\n          <div class=\"proxies-body\">\n            <div v-if=\"proxiesLoading\" class=\"loading-state\">\n              <el-icon class=\"is-loading\"><Loading /></el-icon>\n              <span>Loading...</span>\n            </div>\n            <div v-else-if=\"filteredProxies.length > 0\" class=\"proxies-list\">\n              <ProxyCard\n                v-for=\"proxy in filteredProxies\"\n                :key=\"proxy.name\"\n                :proxy=\"proxy\"\n                show-type\n              />\n            </div>\n            <div v-else-if=\"clientProxies.length > 0\" class=\"empty-state\">\n              <p>No proxies match \"{{ proxySearch }}\"</p>\n            </div>\n            <div v-else class=\"empty-state\">\n              <p>No proxies found</p>\n            </div>\n          </div>\n        </div>\n      </template>\n\n      <div v-else-if=\"!loading\" class=\"not-found\">\n        <h2>Client not found</h2>\n        <p>The client doesn't exist or has been removed.</p>\n        <router-link to=\"/clients\">\n          <el-button type=\"primary\">Back to Clients</el-button>\n        </router-link>\n      </div>\n    </div>\n  </div>\n</template>\n\n<script setup lang=\"ts\">\nimport { ref, onMounted, computed } from 'vue'\nimport { useRoute, useRouter } from 'vue-router'\nimport { ElMessage } from 'element-plus'\nimport { ArrowLeft, Loading, Search } from '@element-plus/icons-vue'\nimport { Client } from '../utils/client'\nimport { getClient } from '../api/client'\nimport { getProxiesByType } from '../api/proxy'\nimport {\n  BaseProxy,\n  TCPProxy,\n  UDPProxy,\n  HTTPProxy,\n  HTTPSProxy,\n  TCPMuxProxy,\n  STCPProxy,\n  SUDPProxy,\n} from '../utils/proxy'\nimport { getServerInfo } from '../api/server'\nimport ProxyCard from '../components/ProxyCard.vue'\n\nconst route = useRoute()\nconst router = useRouter()\nconst client = ref<Client | null>(null)\nconst loading = ref(true)\n\nconst goBack = () => {\n  if (window.history.length > 1) {\n    router.back()\n  } else {\n    router.push('/clients')\n  }\n}\nconst proxiesLoading = ref(false)\nconst allProxies = ref<BaseProxy[]>([])\nconst proxySearch = ref('')\n\nlet serverInfo: {\n  vhostHTTPPort: number\n  vhostHTTPSPort: number\n  tcpmuxHTTPConnectPort: number\n  subdomainHost: string\n} | null = null\n\nconst clientProxies = computed(() => {\n  if (!client.value) return []\n  return allProxies.value.filter(\n    (p) =>\n      p.clientID === client.value!.clientID && p.user === client.value!.user,\n  )\n})\n\nconst filteredProxies = computed(() => {\n  if (!proxySearch.value) return clientProxies.value\n  const search = proxySearch.value.toLowerCase()\n  return clientProxies.value.filter(\n    (p) =>\n      p.name.toLowerCase().includes(search) ||\n      p.type.toLowerCase().includes(search),\n  )\n})\n\nconst totalConnections = computed(() => {\n  return clientProxies.value.reduce((sum, p) => sum + p.conns, 0)\n})\n\nconst fetchServerInfo = async () => {\n  if (serverInfo) return serverInfo\n  const res = await getServerInfo()\n  serverInfo = res\n  return serverInfo\n}\n\nconst fetchClient = async () => {\n  const key = route.params.key as string\n  if (!key) {\n    loading.value = false\n    return\n  }\n  try {\n    const data = await getClient(key)\n    client.value = new Client(data)\n  } catch (error: any) {\n    ElMessage.error('Failed to fetch client: ' + error.message)\n  } finally {\n    loading.value = false\n  }\n}\n\nconst fetchProxies = async () => {\n  proxiesLoading.value = true\n  const proxyTypes = ['tcp', 'udp', 'http', 'https', 'tcpmux', 'stcp', 'sudp']\n  const proxies: BaseProxy[] = []\n  try {\n    const info = await fetchServerInfo()\n    for (const type of proxyTypes) {\n      try {\n        const json = await getProxiesByType(type)\n        if (!json.proxies) continue\n        if (type === 'tcp') {\n          proxies.push(...json.proxies.map((p: any) => new TCPProxy(p)))\n        } else if (type === 'udp') {\n          proxies.push(...json.proxies.map((p: any) => new UDPProxy(p)))\n        } else if (type === 'http' && info?.vhostHTTPPort) {\n          proxies.push(\n            ...json.proxies.map(\n              (p: any) =>\n                new HTTPProxy(p, info.vhostHTTPPort, info.subdomainHost),\n            ),\n          )\n        } else if (type === 'https' && info?.vhostHTTPSPort) {\n          proxies.push(\n            ...json.proxies.map(\n              (p: any) =>\n                new HTTPSProxy(p, info.vhostHTTPSPort, info.subdomainHost),\n            ),\n          )\n        } else if (type === 'tcpmux' && info?.tcpmuxHTTPConnectPort) {\n          proxies.push(\n            ...json.proxies.map(\n              (p: any) =>\n                new TCPMuxProxy(\n                  p,\n                  info.tcpmuxHTTPConnectPort,\n                  info.subdomainHost,\n                ),\n            ),\n          )\n        } else if (type === 'stcp') {\n          proxies.push(...json.proxies.map((p: any) => new STCPProxy(p)))\n        } else if (type === 'sudp') {\n          proxies.push(...json.proxies.map((p: any) => new SUDPProxy(p)))\n        }\n      } catch {\n        // Ignore\n      }\n    }\n    allProxies.value = proxies\n  } catch {\n    // Ignore\n  } finally {\n    proxiesLoading.value = false\n  }\n}\n\nonMounted(() => {\n  fetchClient()\n  fetchProxies()\n})\n</script>\n\n<style scoped>\n.client-detail-page {\n}\n\n/* Breadcrumb */\n.breadcrumb {\n  display: flex;\n  align-items: center;\n  gap: 8px;\n  font-size: 14px;\n  margin-bottom: 24px;\n}\n\n.breadcrumb-link {\n  display: flex;\n  align-items: center;\n  color: var(--text-secondary);\n  cursor: pointer;\n  transition: color 0.2s;\n  margin-right: 4px;\n}\n\n.breadcrumb-link:hover {\n  color: var(--text-primary);\n}\n\n.breadcrumb-item {\n  color: var(--text-secondary);\n  text-decoration: none;\n  transition: color 0.2s;\n}\n\n.breadcrumb-item:hover {\n  color: var(--el-color-primary);\n}\n\n.breadcrumb-separator {\n  color: var(--el-border-color);\n}\n\n.breadcrumb-current {\n  color: var(--text-primary);\n  font-weight: 500;\n}\n\n/* Card Base */\n.header-card,\n.proxies-card {\n  background: var(--el-bg-color);\n  border: 1px solid var(--header-border);\n  border-radius: 12px;\n  margin-bottom: 16px;\n}\n\n/* Header Card */\n.header-main {\n  display: flex;\n  justify-content: space-between;\n  align-items: flex-start;\n  padding: 24px;\n}\n\n.header-left {\n  display: flex;\n  gap: 16px;\n  align-items: center;\n}\n\n.client-avatar {\n  width: 48px;\n  height: 48px;\n  border-radius: 12px;\n  background: linear-gradient(135deg, #667eea 0%, #764ba2 100%);\n  color: white;\n  display: flex;\n  align-items: center;\n  justify-content: center;\n  font-size: 20px;\n  font-weight: 500;\n  flex-shrink: 0;\n}\n\n.client-info {\n  min-width: 0;\n}\n\n.client-name-row {\n  display: flex;\n  align-items: center;\n  gap: 10px;\n  margin-bottom: 4px;\n}\n\n.client-name {\n  font-size: 20px;\n  font-weight: 500;\n  color: var(--text-primary);\n  margin: 0;\n  line-height: 1.3;\n}\n\n.client-meta {\n  display: flex;\n  gap: 12px;\n  font-size: 14px;\n  color: var(--text-secondary);\n}\n\n.status-badge {\n  padding: 6px 12px;\n  border-radius: 6px;\n  font-size: 13px;\n  font-weight: 500;\n}\n\n.status-badge.online {\n  background: rgba(34, 197, 94, 0.1);\n  color: #16a34a;\n}\n\n.status-badge.offline {\n  background: var(--hover-bg);\n  color: var(--text-secondary);\n}\n\nhtml.dark .status-badge.online {\n  background: rgba(34, 197, 94, 0.15);\n  color: #4ade80;\n}\n\n/* Info Section */\n.info-section {\n  display: flex;\n  flex-wrap: wrap;\n  gap: 16px 32px;\n  padding: 16px 24px;\n}\n\n.info-item {\n  display: flex;\n  align-items: baseline;\n  gap: 8px;\n}\n\n.info-label {\n  font-size: 13px;\n  color: var(--text-secondary);\n}\n\n.info-label::after {\n  content: ':';\n}\n\n.info-value {\n  font-size: 13px;\n  color: var(--text-primary);\n  font-weight: 500;\n  word-break: break-all;\n}\n\n/* Proxies Card */\n.proxies-header {\n  display: flex;\n  justify-content: space-between;\n  align-items: center;\n  padding: 16px 20px;\n  gap: 16px;\n}\n\n.proxies-title {\n  display: flex;\n  align-items: center;\n  gap: 8px;\n}\n\n.proxies-title h2 {\n  font-size: 15px;\n  font-weight: 500;\n  color: var(--text-primary);\n  margin: 0;\n}\n\n.proxies-count {\n  font-size: 13px;\n  font-weight: 500;\n  color: var(--text-secondary);\n  background: var(--hover-bg);\n  padding: 4px 10px;\n  border-radius: 6px;\n}\n\n.proxy-search {\n  width: 200px;\n}\n\n.proxy-search :deep(.el-input__wrapper) {\n  border-radius: 6px;\n}\n\n.proxies-body {\n  padding: 16px;\n}\n\n.proxies-list {\n  display: flex;\n  flex-direction: column;\n  gap: 12px;\n}\n\n.loading-state {\n  display: flex;\n  align-items: center;\n  justify-content: center;\n  gap: 8px;\n  padding: 40px;\n  color: var(--text-secondary);\n}\n\n.empty-state {\n  text-align: center;\n  padding: 40px;\n  color: var(--text-secondary);\n}\n\n.empty-state p {\n  margin: 0;\n}\n\n/* Not Found */\n.not-found {\n  text-align: center;\n  padding: 60px 20px;\n}\n\n.not-found h2 {\n  font-size: 18px;\n  font-weight: 500;\n  color: var(--text-primary);\n  margin: 0 0 8px;\n}\n\n.not-found p {\n  font-size: 14px;\n  color: var(--text-secondary);\n  margin: 0 0 20px;\n}\n\n/* Responsive */\n@media (max-width: 640px) {\n  .header-main {\n    flex-direction: column;\n    gap: 16px;\n  }\n\n  .header-right {\n    align-self: flex-start;\n  }\n}\n</style>\n"
  },
  {
    "path": "web/frps/src/views/Clients.vue",
    "content": "<template>\n  <div class=\"clients-page\">\n    <div class=\"page-header\">\n      <div class=\"header-top\">\n        <div class=\"title-section\">\n          <h1 class=\"page-title\">Clients</h1>\n          <p class=\"page-subtitle\">Manage connected clients and their status</p>\n        </div>\n        <div class=\"status-tabs\">\n          <button\n            v-for=\"tab in statusTabs\"\n            :key=\"tab.value\"\n            class=\"status-tab\"\n            :class=\"{ active: statusFilter === tab.value }\"\n            @click=\"statusFilter = tab.value\"\n          >\n            <span class=\"status-dot\" :class=\"tab.value\"></span>\n            <span class=\"tab-label\">{{ tab.label }}</span>\n            <span class=\"tab-count\">{{ tab.count }}</span>\n          </button>\n        </div>\n      </div>\n\n      <div class=\"search-section\">\n        <el-input\n          v-model=\"searchText\"\n          placeholder=\"Search clients...\"\n          :prefix-icon=\"Search\"\n          clearable\n          class=\"search-input\"\n        />\n      </div>\n    </div>\n\n    <div v-loading=\"loading\" class=\"clients-content\">\n      <div v-if=\"filteredClients.length > 0\" class=\"clients-list\">\n        <ClientCard\n          v-for=\"client in filteredClients\"\n          :key=\"client.key\"\n          :client=\"client\"\n        />\n      </div>\n      <div v-else-if=\"!loading\" class=\"empty-state\">\n        <el-empty description=\"No clients found\" />\n      </div>\n    </div>\n  </div>\n</template>\n\n<script setup lang=\"ts\">\nimport { ref, computed, onMounted, onUnmounted } from 'vue'\nimport { ElMessage } from 'element-plus'\nimport { Search } from '@element-plus/icons-vue'\nimport { Client } from '../utils/client'\nimport ClientCard from '../components/ClientCard.vue'\nimport { getClients } from '../api/client'\n\nconst clients = ref<Client[]>([])\nconst loading = ref(false)\nconst searchText = ref('')\nconst statusFilter = ref<'all' | 'online' | 'offline'>('all')\n\nlet refreshTimer: number | null = null\n\nconst stats = computed(() => {\n  const total = clients.value.length\n  const online = clients.value.filter((c) => c.online).length\n  const offline = total - online\n  return { total, online, offline }\n})\n\nconst statusTabs = computed(() => [\n  { value: 'all' as const, label: 'All', count: stats.value.total },\n  { value: 'online' as const, label: 'Online', count: stats.value.online },\n  { value: 'offline' as const, label: 'Offline', count: stats.value.offline },\n])\n\nconst filteredClients = computed(() => {\n  let result = clients.value\n\n  // Filter by status\n  if (statusFilter.value === 'online') {\n    result = result.filter((c) => c.online)\n  } else if (statusFilter.value === 'offline') {\n    result = result.filter((c) => !c.online)\n  }\n\n  // Filter by search text\n  if (searchText.value) {\n    result = result.filter((c) => c.matchesFilter(searchText.value))\n  }\n\n  // Sort: online first, then by display name\n  result.sort((a, b) => {\n    if (a.online !== b.online) {\n      return a.online ? -1 : 1\n    }\n    return a.displayName.localeCompare(b.displayName)\n  })\n\n  return result\n})\n\nconst fetchData = async () => {\n  loading.value = true\n  try {\n    const json = await getClients()\n    clients.value = json.map((data) => new Client(data))\n  } catch (error: any) {\n    ElMessage({\n      showClose: true,\n      message: 'Failed to fetch clients: ' + error.message,\n      type: 'error',\n    })\n  } finally {\n    loading.value = false\n  }\n}\n\nconst startAutoRefresh = () => {\n  refreshTimer = window.setInterval(() => {\n    fetchData()\n  }, 5000)\n}\n\nconst stopAutoRefresh = () => {\n  if (refreshTimer !== null) {\n    window.clearInterval(refreshTimer)\n    refreshTimer = null\n  }\n}\n\nonMounted(() => {\n  fetchData()\n  startAutoRefresh()\n})\n\nonUnmounted(() => {\n  stopAutoRefresh()\n})\n</script>\n\n<style scoped>\n.clients-page {\n  display: flex;\n  flex-direction: column;\n  gap: 24px;\n}\n\n.page-header {\n  display: flex;\n  flex-direction: column;\n  gap: 24px;\n}\n\n.header-top {\n  display: flex;\n  justify-content: space-between;\n  align-items: flex-end;\n  gap: 20px;\n  flex-wrap: wrap;\n}\n\n.title-section {\n  display: flex;\n  flex-direction: column;\n  gap: 8px;\n}\n\n.page-title {\n  font-size: 28px;\n  font-weight: 600;\n  color: var(--el-text-color-primary);\n  margin: 0;\n  line-height: 1.2;\n}\n\n.page-subtitle {\n  font-size: 14px;\n  color: var(--el-text-color-secondary);\n  margin: 0;\n}\n\n.status-tabs {\n  display: flex;\n  gap: 12px;\n}\n\n.status-tab {\n  display: flex;\n  align-items: center;\n  gap: 8px;\n  padding: 8px 16px;\n  border: 1px solid var(--el-border-color);\n  border-radius: 20px;\n  background: var(--el-bg-color);\n  color: var(--el-text-color-regular);\n  font-size: 14px;\n  font-weight: 500;\n  cursor: pointer;\n  transition: all 0.2s;\n}\n\n.status-tab:hover {\n  border-color: var(--el-border-color-darker);\n  background: var(--el-fill-color-light);\n}\n\n.status-tab.active {\n  background: var(--el-fill-color-dark);\n  border-color: var(--el-text-color-primary);\n  color: var(--el-text-color-primary);\n}\n\n.status-dot {\n  width: 8px;\n  height: 8px;\n  border-radius: 50%;\n  background-color: var(--el-text-color-secondary);\n}\n\n.status-dot.online {\n  background-color: var(--el-color-success);\n}\n\n.status-dot.offline {\n  background-color: var(--el-text-color-placeholder);\n}\n\n.status-dot.all {\n  background-color: var(--el-text-color-regular);\n}\n\n.tab-count {\n  font-weight: 500;\n  opacity: 0.8;\n}\n\n.search-section {\n  width: 100%;\n}\n\n.search-input :deep(.el-input__wrapper) {\n  border-radius: 12px;\n  box-shadow: 0 1px 2px rgba(0, 0, 0, 0.04);\n  padding: 8px 16px;\n  border: 1px solid var(--el-border-color);\n  transition: all 0.2s;\n  height: 48px;\n  font-size: 15px;\n}\n\n.search-input :deep(.el-input__wrapper:hover) {\n  border-color: var(--el-border-color-darker);\n  box-shadow: 0 2px 4px rgba(0, 0, 0, 0.06);\n}\n\n.search-input :deep(.el-input__wrapper.is-focus) {\n  border-color: var(--el-color-primary);\n  box-shadow: 0 0 0 1px var(--el-color-primary);\n}\n\n.clients-content {\n  min-height: 200px;\n}\n\n.clients-list {\n  display: flex;\n  flex-direction: column;\n  gap: 16px;\n}\n\n.empty-state {\n  padding: 60px 0;\n}\n\n/* Dark mode adjustments */\nhtml.dark .status-tab {\n  background: var(--el-bg-color-overlay);\n}\n\nhtml.dark .status-tab.active {\n  background: var(--el-fill-color);\n}\n\n@media (max-width: 640px) {\n  .header-top {\n    flex-direction: column;\n    align-items: flex-start;\n  }\n\n  .status-tabs {\n    width: 100%;\n    overflow-x: auto;\n    padding-bottom: 4px;\n  }\n\n  .status-tab {\n    flex-shrink: 0;\n  }\n}\n</style>\n"
  },
  {
    "path": "web/frps/src/views/Proxies.vue",
    "content": "<template>\n  <div class=\"proxies-page\">\n    <div class=\"page-header\">\n      <div class=\"header-top\">\n        <div class=\"title-section\">\n          <h1 class=\"page-title\">Proxies</h1>\n          <p class=\"page-subtitle\">View and manage all proxy configurations</p>\n        </div>\n\n        <div class=\"actions-section\">\n          <ActionButton variant=\"outline\" size=\"small\" @click=\"fetchData\">\n            Refresh\n          </ActionButton>\n\n          <ActionButton variant=\"outline\" size=\"small\" danger @click=\"showClearDialog = true\">\n            Clear Offline\n          </ActionButton>\n        </div>\n      </div>\n\n      <div class=\"filter-section\">\n        <div class=\"search-row\">\n          <el-input\n            v-model=\"searchText\"\n            placeholder=\"Search proxies...\"\n            :prefix-icon=\"Search\"\n            clearable\n            class=\"main-search\"\n          />\n\n          <PopoverMenu\n            :model-value=\"selectedClientKey\"\n            :width=\"220\"\n            placement=\"bottom-end\"\n            selectable\n            filterable\n            filter-placeholder=\"Search clients...\"\n            :display-value=\"selectedClientLabel\"\n            clearable\n            class=\"client-filter\"\n            @update:model-value=\"onClientFilterChange($event as string)\"\n          >\n            <template #default=\"{ filterText }\">\n              <PopoverMenuItem value=\"\">All Clients</PopoverMenuItem>\n              <PopoverMenuItem\n                v-if=\"clientIDFilter && !selectedClientInList\"\n                :value=\"selectedClientKey\"\n              >\n                {{ userFilter ? userFilter + '.' : '' }}{{ clientIDFilter }} (not found)\n              </PopoverMenuItem>\n              <PopoverMenuItem\n                v-for=\"client in filteredClientOptions(filterText)\"\n                :key=\"client.key\"\n                :value=\"client.key\"\n              >\n                {{ client.label }}\n              </PopoverMenuItem>\n            </template>\n          </PopoverMenu>\n        </div>\n\n        <div class=\"type-tabs\">\n          <button\n            v-for=\"t in proxyTypes\"\n            :key=\"t.value\"\n            class=\"type-tab\"\n            :class=\"{ active: activeType === t.value }\"\n            @click=\"activeType = t.value\"\n          >\n            {{ t.label }}\n          </button>\n        </div>\n      </div>\n    </div>\n\n    <div v-loading=\"loading\" class=\"proxies-content\">\n      <div v-if=\"filteredProxies.length > 0\" class=\"proxies-list\">\n        <ProxyCard\n          v-for=\"proxy in filteredProxies\"\n          :key=\"proxy.name\"\n          :proxy=\"proxy\"\n        />\n      </div>\n      <div v-else-if=\"!loading\" class=\"empty-state\">\n        <el-empty description=\"No proxies found\" />\n      </div>\n    </div>\n\n    <ConfirmDialog\n      v-model=\"showClearDialog\"\n      title=\"Clear Offline\"\n      message=\"Are you sure you want to clear all offline proxies?\"\n      confirm-text=\"Clear\"\n      danger\n      @confirm=\"handleClearConfirm\"\n    />\n  </div>\n</template>\n\n<script setup lang=\"ts\">\nimport { ref, computed, watch } from 'vue'\nimport { useRoute, useRouter } from 'vue-router'\nimport { ElMessage } from 'element-plus'\nimport { Search } from '@element-plus/icons-vue'\nimport ActionButton from '@shared/components/ActionButton.vue'\nimport ConfirmDialog from '@shared/components/ConfirmDialog.vue'\nimport {\n  BaseProxy,\n  TCPProxy,\n  UDPProxy,\n  HTTPProxy,\n  HTTPSProxy,\n  TCPMuxProxy,\n  STCPProxy,\n  SUDPProxy,\n} from '../utils/proxy'\nimport ProxyCard from '../components/ProxyCard.vue'\nimport PopoverMenu from '@shared/components/PopoverMenu.vue'\nimport PopoverMenuItem from '@shared/components/PopoverMenuItem.vue'\nimport {\n  getProxiesByType,\n  clearOfflineProxies as apiClearOfflineProxies,\n} from '../api/proxy'\nimport { getServerInfo } from '../api/server'\nimport { getClients } from '../api/client'\nimport { Client } from '../utils/client'\n\nconst route = useRoute()\nconst router = useRouter()\n\nconst proxyTypes = [\n  { label: 'TCP', value: 'tcp' },\n  { label: 'UDP', value: 'udp' },\n  { label: 'HTTP', value: 'http' },\n  { label: 'HTTPS', value: 'https' },\n  { label: 'TCPMUX', value: 'tcpmux' },\n  { label: 'STCP', value: 'stcp' },\n  { label: 'SUDP', value: 'sudp' },\n]\n\nconst activeType = ref((route.params.type as string) || 'tcp')\nconst proxies = ref<BaseProxy[]>([])\nconst clients = ref<Client[]>([])\nconst loading = ref(false)\nconst searchText = ref('')\nconst showClearDialog = ref(false)\nconst clientIDFilter = ref((route.query.clientID as string) || '')\nconst userFilter = ref((route.query.user as string) || '')\n\nconst clientOptions = computed(() => {\n  return clients.value\n    .map((c) => ({\n      key: c.key,\n      clientID: c.clientID,\n      user: c.user,\n      label: c.user ? `${c.user}.${c.clientID}` : c.clientID,\n    }))\n    .sort((a, b) => a.label.localeCompare(b.label))\n})\n\n// Compute selected client key for el-select v-model\nconst selectedClientKey = computed(() => {\n  if (!clientIDFilter.value) return ''\n  const client = clientOptions.value.find(\n    (c) => c.clientID === clientIDFilter.value && c.user === userFilter.value,\n  )\n  // Return a synthetic key even if not found, so the select shows the filter is active\n  return client?.key || `${userFilter.value}:${clientIDFilter.value}`\n})\n\nconst selectedClientLabel = computed(() => {\n  if (!clientIDFilter.value) return 'All Clients'\n  const client = clientOptions.value.find(\n    (c) => c.clientID === clientIDFilter.value && c.user === userFilter.value,\n  )\n  return client?.label || `${userFilter.value ? userFilter.value + '.' : ''}${clientIDFilter.value}`\n})\n\nconst filteredClientOptions = (filterText: string) => {\n  if (!filterText) return clientOptions.value\n  const search = filterText.toLowerCase()\n  return clientOptions.value.filter((c) => c.label.toLowerCase().includes(search))\n}\n\n// Check if the filtered client exists in the client list\nconst selectedClientInList = computed(() => {\n  if (!clientIDFilter.value) return true\n  return clientOptions.value.some(\n    (c) => c.clientID === clientIDFilter.value && c.user === userFilter.value,\n  )\n})\n\nconst filteredProxies = computed(() => {\n  let result = proxies.value\n\n  // Filter by clientID and user if specified\n  if (clientIDFilter.value) {\n    result = result.filter(\n      (p) => p.clientID === clientIDFilter.value && p.user === userFilter.value,\n    )\n  }\n\n  // Filter by search text\n  if (searchText.value) {\n    const search = searchText.value.toLowerCase()\n    result = result.filter((p) => p.name.toLowerCase().includes(search))\n  }\n\n  return result\n})\n\nconst onClientFilterChange = (key: string) => {\n  if (key) {\n    const client = clientOptions.value.find((c) => c.key === key)\n    if (client) {\n      router.replace({\n        query: { ...route.query, clientID: client.clientID, user: client.user },\n      })\n    }\n  } else {\n    const query = { ...route.query }\n    delete query.clientID\n    delete query.user\n    router.replace({ query })\n  }\n}\n\nconst fetchClients = async () => {\n  try {\n    const json = await getClients()\n    clients.value = json.map((data) => new Client(data))\n  } catch {\n    // Ignore errors when fetching clients\n  }\n}\n\n// Server info cache\nlet serverInfo: {\n  vhostHTTPPort: number\n  vhostHTTPSPort: number\n  tcpmuxHTTPConnectPort: number\n  subdomainHost: string\n} | null = null\n\nconst fetchServerInfo = async () => {\n  if (serverInfo) return serverInfo\n  const res = await getServerInfo()\n  serverInfo = res\n  return serverInfo\n}\n\nconst fetchData = async () => {\n  loading.value = true\n  proxies.value = []\n\n  try {\n    const type = activeType.value\n    const json = await getProxiesByType(type)\n\n    if (type === 'tcp') {\n      proxies.value = json.proxies.map((p: any) => new TCPProxy(p))\n    } else if (type === 'udp') {\n      proxies.value = json.proxies.map((p: any) => new UDPProxy(p))\n    } else if (type === 'http') {\n      const info = await fetchServerInfo()\n      if (info && info.vhostHTTPPort) {\n        proxies.value = json.proxies.map(\n          (p: any) => new HTTPProxy(p, info.vhostHTTPPort, info.subdomainHost),\n        )\n      }\n    } else if (type === 'https') {\n      const info = await fetchServerInfo()\n      if (info && info.vhostHTTPSPort) {\n        proxies.value = json.proxies.map(\n          (p: any) =>\n            new HTTPSProxy(p, info.vhostHTTPSPort, info.subdomainHost),\n        )\n      }\n    } else if (type === 'tcpmux') {\n      const info = await fetchServerInfo()\n      if (info && info.tcpmuxHTTPConnectPort) {\n        proxies.value = json.proxies.map(\n          (p: any) =>\n            new TCPMuxProxy(p, info.tcpmuxHTTPConnectPort, info.subdomainHost),\n        )\n      }\n    } else if (type === 'stcp') {\n      proxies.value = json.proxies.map((p: any) => new STCPProxy(p))\n    } else if (type === 'sudp') {\n      proxies.value = json.proxies.map((p: any) => new SUDPProxy(p))\n    }\n  } catch (error: any) {\n    ElMessage({\n      showClose: true,\n      message: 'Failed to fetch proxies: ' + error.message,\n      type: 'error',\n    })\n  } finally {\n    loading.value = false\n  }\n}\n\nconst handleClearConfirm = async () => {\n  showClearDialog.value = false\n  await clearOfflineProxies()\n}\n\nconst clearOfflineProxies = async () => {\n  try {\n    await apiClearOfflineProxies()\n    ElMessage({\n      message: 'Successfully cleared offline proxies',\n      type: 'success',\n    })\n    fetchData()\n  } catch (err: any) {\n    ElMessage({\n      message: 'Failed to clear offline proxies: ' + err.message,\n      type: 'warning',\n    })\n  }\n}\n\n// Watch for type changes\nwatch(activeType, (newType) => {\n  // Update route but preserve query params\n  router.replace({ params: { type: newType }, query: route.query })\n  fetchData()\n})\n\n// Watch for route query changes (client filter)\nwatch(\n  () => [route.query.clientID, route.query.user],\n  ([newClientID, newUser]) => {\n    clientIDFilter.value = (newClientID as string) || ''\n    userFilter.value = (newUser as string) || ''\n  },\n)\n\n// Initial fetch\nfetchData()\nfetchClients()\n</script>\n\n<style scoped>\n.proxies-page {\n  display: flex;\n  flex-direction: column;\n  gap: 24px;\n}\n\n.page-header {\n  display: flex;\n  flex-direction: column;\n  gap: 24px;\n}\n\n.header-top {\n  display: flex;\n  justify-content: space-between;\n  align-items: flex-start;\n  gap: 20px;\n}\n\n.title-section {\n  display: flex;\n  flex-direction: column;\n  gap: 8px;\n}\n\n.page-title {\n  font-size: 28px;\n  font-weight: 600;\n  color: var(--el-text-color-primary);\n  margin: 0;\n  line-height: 1.2;\n}\n\n.page-subtitle {\n  font-size: 14px;\n  color: var(--el-text-color-secondary);\n  margin: 0;\n}\n\n.actions-section {\n  display: flex;\n  gap: 12px;\n}\n\n\n.filter-section {\n  display: flex;\n  flex-direction: column;\n  gap: 20px;\n  margin-top: 8px;\n}\n\n.search-row {\n  display: flex;\n  gap: 16px;\n  width: 100%;\n  align-items: center;\n}\n\n.main-search {\n  flex: 1;\n}\n\n.main-search :deep(.el-input__wrapper),\n.client-filter :deep(.el-input__wrapper) {\n  height: 32px;\n  border-radius: 8px;\n}\n\n.client-filter {\n  width: 240px;\n}\n\n.type-tabs {\n  display: flex;\n  gap: 8px;\n  overflow-x: auto;\n  padding-bottom: 4px;\n}\n\n.type-tab {\n  padding: 6px 16px;\n  border: 1px solid var(--el-border-color-lighter);\n  border-radius: 12px;\n  background: var(--el-bg-color);\n  color: var(--el-text-color-regular);\n  font-size: 13px;\n  font-weight: 500;\n  cursor: pointer;\n  transition: all 0.2s;\n  text-transform: uppercase;\n}\n\n.type-tab:hover {\n  background: var(--el-fill-color-light);\n}\n\n.type-tab.active {\n  background: var(--el-fill-color-darker);\n  color: var(--el-text-color-primary);\n  border-color: var(--el-fill-color-darker);\n}\n\n.proxies-content {\n  min-height: 200px;\n}\n\n.proxies-list {\n  display: flex;\n  flex-direction: column;\n  gap: 16px;\n}\n\n.empty-state {\n  padding: 60px 0;\n}\n\n@media (max-width: 768px) {\n  .search-row {\n    flex-direction: column;\n  }\n\n  .client-filter {\n    width: 100%;\n  }\n}\n</style>\n"
  },
  {
    "path": "web/frps/src/views/ProxyDetail.vue",
    "content": "<template>\n  <div class=\"proxy-detail-page\">\n    <!-- Breadcrumb -->\n    <nav class=\"breadcrumb\">\n      <a class=\"breadcrumb-link\" @click=\"goBack\">\n        <el-icon><ArrowLeft /></el-icon>\n      </a>\n      <template v-if=\"fromClient\">\n        <router-link to=\"/clients\" class=\"breadcrumb-item\">Clients</router-link>\n        <span class=\"breadcrumb-separator\">/</span>\n        <router-link :to=\"`/clients/${fromClient}`\" class=\"breadcrumb-item\">{{\n          fromClient\n        }}</router-link>\n        <span class=\"breadcrumb-separator\">/</span>\n      </template>\n      <template v-else>\n        <router-link to=\"/proxies\" class=\"breadcrumb-item\">Proxies</router-link>\n        <span class=\"breadcrumb-separator\">/</span>\n        <router-link\n          v-if=\"proxy?.clientID\"\n          :to=\"clientLink\"\n          class=\"breadcrumb-item\"\n        >\n          {{ proxy.user ? `${proxy.user}.${proxy.clientID}` : proxy.clientID }}\n        </router-link>\n        <span v-if=\"proxy?.clientID\" class=\"breadcrumb-separator\">/</span>\n      </template>\n      <span class=\"breadcrumb-current\">{{ proxyName }}</span>\n    </nav>\n\n    <div v-loading=\"loading\" class=\"detail-content\">\n      <template v-if=\"proxy\">\n        <!-- Header Section -->\n        <div class=\"header-section\">\n          <div class=\"header-main\">\n            <div\n              class=\"proxy-icon\"\n              :style=\"{ background: proxyIconConfig.gradient }\"\n            >\n              <el-icon><component :is=\"proxyIconConfig.icon\" /></el-icon>\n            </div>\n            <div class=\"header-info\">\n              <div class=\"header-title-row\">\n                <h1 class=\"proxy-name\">{{ proxy.name }}</h1>\n                <span class=\"type-tag\">{{ proxy.type.toUpperCase() }}</span>\n                <span class=\"status-badge\" :class=\"proxy.status\">\n                  {{ proxy.status }}\n                </span>\n              </div>\n              <div class=\"header-meta\">\n                <router-link\n                  v-if=\"proxy.clientID\"\n                  :to=\"clientLink\"\n                  class=\"meta-link\"\n                >\n                  <el-icon><Monitor /></el-icon>\n                  <span>{{\n                    proxy.user\n                      ? `${proxy.user}.${proxy.clientID}`\n                      : proxy.clientID\n                  }}</span>\n                </router-link>\n                <span v-if=\"proxy.lastStartTime\" class=\"meta-text\">\n                  <span class=\"meta-sep\">·</span>\n                  Last Started {{ proxy.lastStartTime }}\n                </span>\n                <span v-if=\"proxy.lastCloseTime\" class=\"meta-text\">\n                  <span class=\"meta-sep\">·</span>\n                  Last Closed {{ proxy.lastCloseTime }}\n                </span>\n              </div>\n            </div>\n          </div>\n        </div>\n\n        <!-- Stats Bar -->\n        <div class=\"stats-bar\">\n          <div v-if=\"proxy.port\" class=\"stats-item\">\n            <span class=\"stats-label\">Port</span>\n            <span class=\"stats-value\">{{ proxy.port }}</span>\n          </div>\n          <div class=\"stats-item\">\n            <span class=\"stats-label\">Connections</span>\n            <span class=\"stats-value\">{{ proxy.conns }}</span>\n          </div>\n          <div class=\"stats-item\">\n            <span class=\"stats-label\">Traffic</span>\n            <span class=\"stats-value\">↓ {{ formatTrafficValue(proxy.trafficIn) }} <small>{{ formatTrafficUnit(proxy.trafficIn) }}</small> / ↑ {{ formatTrafficValue(proxy.trafficOut) }} <small>{{ formatTrafficUnit(proxy.trafficOut) }}</small></span>\n          </div>\n        </div>\n\n        <!-- Configuration Section -->\n        <div class=\"config-section\">\n          <div class=\"config-section-header\">\n            <el-icon><Setting /></el-icon>\n            <h2>Configuration</h2>\n          </div>\n\n          <!-- Config Cards Grid -->\n          <div class=\"config-grid\">\n            <div class=\"config-item-card\">\n              <div class=\"config-item-icon encryption\">\n                <el-icon><Lock /></el-icon>\n              </div>\n              <div class=\"config-item-content\">\n                <span class=\"config-item-label\">Encryption</span>\n                <span class=\"config-item-value\">{{\n                  proxy.encryption ? 'Enabled' : 'Disabled'\n                }}</span>\n              </div>\n            </div>\n\n            <div class=\"config-item-card\">\n              <div class=\"config-item-icon compression\">\n                <el-icon><Lightning /></el-icon>\n              </div>\n              <div class=\"config-item-content\">\n                <span class=\"config-item-label\">Compression</span>\n                <span class=\"config-item-value\">{{\n                  proxy.compression ? 'Enabled' : 'Disabled'\n                }}</span>\n              </div>\n            </div>\n\n            <div v-if=\"proxy.customDomains\" class=\"config-item-card\">\n              <div class=\"config-item-icon domains\">\n                <el-icon><Link /></el-icon>\n              </div>\n              <div class=\"config-item-content\">\n                <span class=\"config-item-label\">Custom Domains</span>\n                <span class=\"config-item-value\">{{ proxy.customDomains }}</span>\n              </div>\n            </div>\n\n            <div v-if=\"proxy.subdomain\" class=\"config-item-card\">\n              <div class=\"config-item-icon subdomain\">\n                <el-icon><Link /></el-icon>\n              </div>\n              <div class=\"config-item-content\">\n                <span class=\"config-item-label\">Subdomain</span>\n                <span class=\"config-item-value\">{{ proxy.subdomain }}</span>\n              </div>\n            </div>\n\n            <div v-if=\"proxy.locations\" class=\"config-item-card\">\n              <div class=\"config-item-icon locations\">\n                <el-icon><Location /></el-icon>\n              </div>\n              <div class=\"config-item-content\">\n                <span class=\"config-item-label\">Locations</span>\n                <span class=\"config-item-value\">{{ proxy.locations }}</span>\n              </div>\n            </div>\n\n            <div v-if=\"proxy.hostHeaderRewrite\" class=\"config-item-card\">\n              <div class=\"config-item-icon host\">\n                <el-icon><Tickets /></el-icon>\n              </div>\n              <div class=\"config-item-content\">\n                <span class=\"config-item-label\">Host Rewrite</span>\n                <span class=\"config-item-value\">{{\n                  proxy.hostHeaderRewrite\n                }}</span>\n              </div>\n            </div>\n\n            <div v-if=\"proxy.multiplexer\" class=\"config-item-card\">\n              <div class=\"config-item-icon multiplexer\">\n                <el-icon><Cpu /></el-icon>\n              </div>\n              <div class=\"config-item-content\">\n                <span class=\"config-item-label\">Multiplexer</span>\n                <span class=\"config-item-value\">{{ proxy.multiplexer }}</span>\n              </div>\n            </div>\n\n            <div v-if=\"proxy.routeByHTTPUser\" class=\"config-item-card\">\n              <div class=\"config-item-icon route\">\n                <el-icon><Connection /></el-icon>\n              </div>\n              <div class=\"config-item-content\">\n                <span class=\"config-item-label\">Route By HTTP User</span>\n                <span class=\"config-item-value\">{{\n                  proxy.routeByHTTPUser\n                }}</span>\n              </div>\n            </div>\n          </div>\n\n          <!-- Annotations -->\n          <template v-if=\"proxy.annotations && proxy.annotations.size > 0\">\n            <div class=\"annotations-section\">\n              <div\n                v-for=\"[key, value] in proxy.annotations\"\n                :key=\"key\"\n                class=\"annotation-tag\"\n              >\n                {{ key }}: {{ value }}\n              </div>\n            </div>\n          </template>\n        </div>\n\n        <!-- Traffic Card -->\n        <div class=\"traffic-card\">\n          <div class=\"traffic-header\">\n            <h2>Traffic Statistics</h2>\n          </div>\n          <div class=\"traffic-body\">\n            <Traffic :proxy-name=\"proxyName\" />\n          </div>\n        </div>\n      </template>\n\n      <div v-else-if=\"!loading\" class=\"not-found\">\n        <h2>Proxy not found</h2>\n        <p>The proxy doesn't exist or has been removed.</p>\n        <router-link to=\"/proxies\">\n          <el-button type=\"primary\">Back to Proxies</el-button>\n        </router-link>\n      </div>\n    </div>\n  </div>\n</template>\n\n<script setup lang=\"ts\">\nimport { ref, computed, onMounted } from 'vue'\nimport { useRoute, useRouter } from 'vue-router'\nimport { ElMessage } from 'element-plus'\nimport {\n  ArrowLeft,\n  Monitor,\n  Connection,\n  Link,\n  Lock,\n  Promotion,\n  Grid,\n  Setting,\n  Cpu,\n  Lightning,\n  Tickets,\n  Location,\n} from '@element-plus/icons-vue'\nimport { getProxyByName } from '../api/proxy'\nimport { getServerInfo } from '../api/server'\nimport {\n  BaseProxy,\n  TCPProxy,\n  UDPProxy,\n  HTTPProxy,\n  HTTPSProxy,\n  TCPMuxProxy,\n  STCPProxy,\n  SUDPProxy,\n} from '../utils/proxy'\nimport Traffic from '../components/Traffic.vue'\n\nconst route = useRoute()\nconst router = useRouter()\nconst proxyName = computed(() => route.params.name as string)\nconst fromClient = computed(() => {\n  if (route.query.from === 'client' && route.query.client) {\n    return route.query.client as string\n  }\n  return null\n})\nconst proxy = ref<BaseProxy | null>(null)\nconst loading = ref(true)\n\nconst goBack = () => {\n  if (window.history.length > 1) {\n    router.back()\n  } else {\n    router.push('/proxies')\n  }\n}\n\nlet serverInfo: {\n  vhostHTTPPort: number\n  vhostHTTPSPort: number\n  tcpmuxHTTPConnectPort: number\n  subdomainHost: string\n} | null = null\n\nconst clientLink = computed(() => {\n  if (!proxy.value) return ''\n  const key = proxy.value.user\n    ? `${proxy.value.user}.${proxy.value.clientID}`\n    : proxy.value.clientID\n  return `/clients/${key}`\n})\n\nconst proxyIconConfig = computed(() => {\n  const type = proxy.value?.type?.toLowerCase() || ''\n  const configs: Record<string, { icon: any; gradient: string }> = {\n    tcp: {\n      icon: Connection,\n      gradient: 'linear-gradient(135deg, #3b82f6 0%, #1d4ed8 100%)',\n    },\n    udp: {\n      icon: Promotion,\n      gradient: 'linear-gradient(135deg, #8b5cf6 0%, #6d28d9 100%)',\n    },\n    http: {\n      icon: Link,\n      gradient: 'linear-gradient(135deg, #22c55e 0%, #16a34a 100%)',\n    },\n    https: {\n      icon: Lock,\n      gradient: 'linear-gradient(135deg, #14b8a6 0%, #0d9488 100%)',\n    },\n    stcp: {\n      icon: Lock,\n      gradient: 'linear-gradient(135deg, #f97316 0%, #ea580c 100%)',\n    },\n    sudp: {\n      icon: Lock,\n      gradient: 'linear-gradient(135deg, #f97316 0%, #ea580c 100%)',\n    },\n    tcpmux: {\n      icon: Grid,\n      gradient: 'linear-gradient(135deg, #06b6d4 0%, #0891b2 100%)',\n    },\n    xtcp: {\n      icon: Connection,\n      gradient: 'linear-gradient(135deg, #ec4899 0%, #db2777 100%)',\n    },\n  }\n  return (\n    configs[type] || {\n      icon: Connection,\n      gradient: 'linear-gradient(135deg, #3b82f6 0%, #1d4ed8 100%)',\n    }\n  )\n})\n\nconst formatTrafficValue = (bytes: number): string => {\n  if (bytes === 0) return '0'\n  const k = 1024\n  const i = Math.floor(Math.log(bytes) / Math.log(k))\n  const value = bytes / Math.pow(k, i)\n  return value < 10 ? value.toFixed(1) : Math.round(value).toString()\n}\n\nconst formatTrafficUnit = (bytes: number): string => {\n  if (bytes === 0) return 'B'\n  const units = ['B', 'KB', 'MB', 'GB', 'TB']\n  const k = 1024\n  const i = Math.floor(Math.log(bytes) / Math.log(k))\n  return units[i]\n}\n\nconst fetchServerInfo = async () => {\n  if (serverInfo) return serverInfo\n  const res = await getServerInfo()\n  serverInfo = res\n  return serverInfo\n}\n\nconst fetchProxy = async () => {\n  const name = proxyName.value\n  if (!name) {\n    loading.value = false\n    return\n  }\n\n  try {\n    const data = await getProxyByName(name)\n    const info = await fetchServerInfo()\n    const type = data.conf?.type || ''\n\n    if (type === 'tcp') {\n      proxy.value = new TCPProxy(data)\n    } else if (type === 'udp') {\n      proxy.value = new UDPProxy(data)\n    } else if (type === 'http' && info?.vhostHTTPPort) {\n      proxy.value = new HTTPProxy(data, info.vhostHTTPPort, info.subdomainHost)\n    } else if (type === 'https' && info?.vhostHTTPSPort) {\n      proxy.value = new HTTPSProxy(\n        data,\n        info.vhostHTTPSPort,\n        info.subdomainHost,\n      )\n    } else if (type === 'tcpmux' && info?.tcpmuxHTTPConnectPort) {\n      proxy.value = new TCPMuxProxy(\n        data,\n        info.tcpmuxHTTPConnectPort,\n        info.subdomainHost,\n      )\n    } else if (type === 'stcp') {\n      proxy.value = new STCPProxy(data)\n    } else if (type === 'sudp') {\n      proxy.value = new SUDPProxy(data)\n    } else {\n      proxy.value = new BaseProxy(data)\n      proxy.value.type = type\n    }\n  } catch (error: any) {\n    ElMessage.error('Failed to fetch proxy: ' + error.message)\n  } finally {\n    loading.value = false\n  }\n}\n\nonMounted(() => {\n  fetchProxy()\n})\n</script>\n\n<style scoped>\n.proxy-detail-page {\n}\n\n/* Breadcrumb */\n.breadcrumb {\n  display: flex;\n  align-items: center;\n  gap: 8px;\n  font-size: 14px;\n  margin-bottom: 24px;\n}\n\n.breadcrumb-link {\n  display: flex;\n  align-items: center;\n  color: var(--text-secondary);\n  cursor: pointer;\n  transition: color 0.2s;\n  margin-right: 4px;\n}\n\n.breadcrumb-link:hover {\n  color: var(--text-primary);\n}\n\n.breadcrumb-item {\n  color: var(--text-secondary);\n  text-decoration: none;\n  transition: color 0.2s;\n}\n\n.breadcrumb-item:hover {\n  color: var(--el-color-primary);\n}\n\n.breadcrumb-separator {\n  color: var(--el-border-color);\n}\n\n.breadcrumb-current {\n  color: var(--text-primary);\n  font-weight: 500;\n}\n\n/* Header Section */\n.header-section {\n  margin-bottom: 24px;\n}\n\n.header-main {\n  display: flex;\n  align-items: flex-start;\n  gap: 16px;\n}\n\n.proxy-icon {\n  width: 56px;\n  height: 56px;\n  border-radius: 14px;\n  display: flex;\n  align-items: center;\n  justify-content: center;\n  flex-shrink: 0;\n  font-size: 26px;\n  color: white;\n}\n\n.header-info {\n  flex: 1;\n  min-width: 0;\n}\n\n.header-title-row {\n  display: flex;\n  align-items: center;\n  gap: 12px;\n  flex-wrap: wrap;\n  margin-bottom: 8px;\n}\n\n.proxy-name {\n  font-size: 20px;\n  font-weight: 500;\n  color: var(--text-primary);\n  margin: 0;\n  line-height: 1.3;\n  word-break: break-all;\n}\n\n.type-tag {\n  font-size: 12px;\n  font-weight: 500;\n  padding: 4px 12px;\n  border-radius: 20px;\n  background: var(--el-fill-color-dark);\n  color: var(--el-text-color-secondary);\n  border: 1px solid var(--el-border-color-lighter);\n}\n\n.status-badge {\n  padding: 4px 12px;\n  border-radius: 6px;\n  font-size: 13px;\n  font-weight: 500;\n  text-transform: capitalize;\n}\n\n.status-badge.online {\n  background: rgba(34, 197, 94, 0.1);\n  color: #16a34a;\n}\n\n.status-badge.offline {\n  background: var(--hover-bg);\n  color: var(--text-secondary);\n}\n\nhtml.dark .status-badge.online {\n  background: rgba(34, 197, 94, 0.15);\n  color: #4ade80;\n}\n\n.header-meta {\n  display: flex;\n  align-items: center;\n  flex-wrap: wrap;\n  gap: 4px;\n  font-size: 13px;\n  color: var(--text-secondary);\n}\n\n.meta-link {\n  display: inline-flex;\n  align-items: center;\n  gap: 4px;\n  color: var(--text-secondary);\n  text-decoration: none;\n  transition: color 0.2s;\n}\n\n.meta-link:hover {\n  color: var(--el-color-primary);\n}\n\n.meta-text {\n  color: var(--text-muted);\n}\n\n.meta-sep {\n  margin: 0 4px;\n}\n\n/* Stats Bar */\n.stats-bar {\n  display: flex;\n  background: var(--el-bg-color);\n  border: 1px solid var(--header-border);\n  border-radius: 10px;\n  margin-bottom: 20px;\n}\n\n.stats-item {\n  flex: 1;\n  display: flex;\n  flex-direction: column;\n  gap: 4px;\n  padding: 16px 20px;\n}\n\n.stats-item + .stats-item {\n  border-left: 1px solid var(--header-border);\n}\n\n.stats-label {\n  font-size: 12px;\n  color: var(--text-secondary);\n}\n\n.stats-value {\n  font-size: 18px;\n  font-weight: 600;\n  color: var(--text-primary);\n}\n\n.stats-value small {\n  font-size: 12px;\n  font-weight: 500;\n  color: var(--text-secondary);\n}\n\n\n/* Card Base */\n.traffic-card {\n  background: var(--el-bg-color);\n  border: 1px solid var(--header-border);\n  border-radius: 12px;\n  margin-bottom: 16px;\n}\n\n/* Config Section */\n.config-section {\n  margin-bottom: 24px;\n}\n\n.config-section-header {\n  display: flex;\n  align-items: center;\n  gap: 8px;\n  margin-bottom: 16px;\n  color: var(--text-secondary);\n}\n\n.config-section-header h2 {\n  font-size: 16px;\n  font-weight: 500;\n  color: var(--text-primary);\n  margin: 0;\n}\n\n.config-grid {\n  display: grid;\n  grid-template-columns: repeat(2, 1fr);\n  gap: 16px;\n}\n\n.config-item-card {\n  display: flex;\n  align-items: flex-start;\n  gap: 14px;\n  padding: 20px;\n  background: var(--el-bg-color);\n  border: 1px solid var(--header-border);\n  border-radius: 12px;\n}\n\n.config-item-icon {\n  width: 40px;\n  height: 40px;\n  border-radius: 10px;\n  display: flex;\n  align-items: center;\n  justify-content: center;\n  font-size: 18px;\n  flex-shrink: 0;\n}\n\n.config-item-icon.encryption {\n  background: rgba(34, 197, 94, 0.1);\n  color: #22c55e;\n}\n\n.config-item-icon.compression {\n  background: rgba(34, 197, 94, 0.1);\n  color: #22c55e;\n}\n\n.config-item-icon.domains {\n  background: rgba(168, 85, 247, 0.1);\n  color: #a855f7;\n}\n\n.config-item-icon.subdomain {\n  background: rgba(168, 85, 247, 0.1);\n  color: #a855f7;\n}\n\n.config-item-icon.locations {\n  background: rgba(59, 130, 246, 0.1);\n  color: #3b82f6;\n}\n\n.config-item-icon.host {\n  background: rgba(249, 115, 22, 0.1);\n  color: #f97316;\n}\n\n.config-item-icon.multiplexer {\n  background: rgba(59, 130, 246, 0.1);\n  color: #3b82f6;\n}\n\n.config-item-icon.route {\n  background: rgba(236, 72, 153, 0.1);\n  color: #ec4899;\n}\n\nhtml.dark .config-item-icon.encryption,\nhtml.dark .config-item-icon.compression {\n  background: rgba(34, 197, 94, 0.15);\n}\n\nhtml.dark .config-item-icon.domains,\nhtml.dark .config-item-icon.subdomain {\n  background: rgba(168, 85, 247, 0.15);\n}\n\nhtml.dark .config-item-icon.locations,\nhtml.dark .config-item-icon.multiplexer {\n  background: rgba(59, 130, 246, 0.15);\n}\n\nhtml.dark .config-item-icon.host {\n  background: rgba(249, 115, 22, 0.15);\n}\n\nhtml.dark .config-item-icon.route {\n  background: rgba(236, 72, 153, 0.15);\n}\n\n.config-item-content {\n  display: flex;\n  flex-direction: column;\n  gap: 4px;\n  min-width: 0;\n}\n\n.config-item-label {\n  font-size: 13px;\n  color: var(--text-secondary);\n  font-weight: 500;\n}\n\n.config-item-value {\n  font-size: 15px;\n  color: var(--text-primary);\n  font-weight: 500;\n  word-break: break-all;\n}\n\n.annotations-section {\n  display: flex;\n  flex-wrap: wrap;\n  gap: 8px;\n  margin-top: 16px;\n}\n\n.annotation-tag {\n  display: inline-flex;\n  padding: 6px 12px;\n  background: var(--el-fill-color);\n  border-radius: 6px;\n  font-size: 13px;\n  color: var(--text-secondary);\n  font-weight: 500;\n}\n\n.traffic-header {\n  padding: 16px 20px;\n  border-bottom: 1px solid var(--header-border);\n}\n\n.traffic-header h2 {\n  font-size: 15px;\n  font-weight: 500;\n  color: var(--text-primary);\n  margin: 0;\n}\n\n/* Traffic Card */\n.traffic-body {\n  padding: 20px;\n}\n\n/* Not Found */\n.not-found {\n  text-align: center;\n  padding: 60px 20px;\n}\n\n.not-found h2 {\n  font-size: 18px;\n  font-weight: 500;\n  color: var(--text-primary);\n  margin: 0 0 8px;\n}\n\n.not-found p {\n  font-size: 14px;\n  color: var(--text-secondary);\n  margin: 0 0 20px;\n}\n\n/* Responsive */\n@media (max-width: 768px) {\n  .config-grid {\n    grid-template-columns: 1fr;\n  }\n\n  .stats-bar {\n    flex-wrap: wrap;\n  }\n\n  .stats-item {\n    flex: 1 1 40%;\n  }\n\n  .stats-item:nth-child(n+3) {\n    border-top: 1px solid var(--header-border);\n  }\n}\n\n@media (max-width: 640px) {\n  .header-main {\n    flex-direction: column;\n    gap: 16px;\n  }\n\n}\n</style>\n"
  },
  {
    "path": "web/frps/src/views/ServerOverview.vue",
    "content": "<template>\n  <div class=\"server-overview\">\n    <el-row :gutter=\"20\" class=\"stats-row\">\n      <el-col :xs=\"24\" :sm=\"12\" :lg=\"6\">\n        <StatCard\n          label=\"Clients\"\n          :value=\"data.clientCounts\"\n          type=\"clients\"\n          subtitle=\"Connected clients\"\n          to=\"/clients\"\n        />\n      </el-col>\n      <el-col :xs=\"24\" :sm=\"12\" :lg=\"6\">\n        <StatCard\n          label=\"Proxies\"\n          :value=\"data.proxyCounts\"\n          type=\"proxies\"\n          subtitle=\"Active proxies\"\n          to=\"/proxies/tcp\"\n        />\n      </el-col>\n      <el-col :xs=\"24\" :sm=\"12\" :lg=\"6\">\n        <StatCard\n          label=\"Connections\"\n          :value=\"data.curConns\"\n          type=\"connections\"\n          subtitle=\"Current connections\"\n        />\n      </el-col>\n      <el-col :xs=\"24\" :sm=\"12\" :lg=\"6\">\n        <StatCard\n          label=\"Traffic\"\n          :value=\"formatTrafficTotal()\"\n          type=\"traffic\"\n          subtitle=\"Total today\"\n        />\n      </el-col>\n    </el-row>\n\n    <el-row :gutter=\"20\" class=\"charts-row\">\n      <el-col :xs=\"24\" :md=\"12\">\n        <el-card class=\"chart-card\" shadow=\"hover\">\n          <template #header>\n            <div class=\"card-header\">\n              <span class=\"card-title\">Network Traffic</span>\n              <el-tag size=\"small\" type=\"info\">Today</el-tag>\n            </div>\n          </template>\n          <div class=\"traffic-summary\">\n            <div class=\"traffic-item in\">\n              <div class=\"traffic-icon\">\n                <el-icon><Download /></el-icon>\n              </div>\n              <div class=\"traffic-info\">\n                <div class=\"label\">Inbound</div>\n                <div class=\"value\">\n                  {{ formatFileSize(data.totalTrafficIn) }}\n                </div>\n              </div>\n            </div>\n            <div class=\"traffic-divider\"></div>\n            <div class=\"traffic-item out\">\n              <div class=\"traffic-icon\">\n                <el-icon><Upload /></el-icon>\n              </div>\n              <div class=\"traffic-info\">\n                <div class=\"label\">Outbound</div>\n                <div class=\"value\">\n                  {{ formatFileSize(data.totalTrafficOut) }}\n                </div>\n              </div>\n            </div>\n          </div>\n        </el-card>\n      </el-col>\n      <el-col :xs=\"24\" :md=\"12\">\n        <el-card class=\"chart-card\" shadow=\"hover\">\n          <template #header>\n            <div class=\"card-header\">\n              <span class=\"card-title\">Proxy Types</span>\n              <el-tag size=\"small\" type=\"info\">Now</el-tag>\n            </div>\n          </template>\n          <div class=\"proxy-types-grid\">\n            <div\n              v-for=\"(count, type) in data.proxyTypeCounts\"\n              :key=\"type\"\n              class=\"proxy-type-item\"\n              v-show=\"count > 0\"\n            >\n              <div class=\"proxy-type-name\">{{ type.toUpperCase() }}</div>\n              <div class=\"proxy-type-count\">{{ count }}</div>\n            </div>\n            <div v-if=\"!hasActiveProxies\" class=\"no-data\">\n              No active proxies\n            </div>\n          </div>\n        </el-card>\n      </el-col>\n    </el-row>\n\n    <el-card class=\"config-card\" shadow=\"hover\">\n      <template #header>\n        <div class=\"card-header\">\n          <span class=\"card-title\">Server Configuration</span>\n          <el-tag size=\"small\" type=\"success\">v{{ data.version }}</el-tag>\n        </div>\n      </template>\n      <div class=\"config-grid\">\n        <div class=\"config-item\">\n          <span class=\"config-label\">Bind Port</span>\n          <span class=\"config-value\">{{ data.bindPort }}</span>\n        </div>\n        <div class=\"config-item\" v-if=\"data.kcpBindPort != 0\">\n          <span class=\"config-label\">KCP Port</span>\n          <span class=\"config-value\">{{ data.kcpBindPort }}</span>\n        </div>\n        <div class=\"config-item\" v-if=\"data.quicBindPort != 0\">\n          <span class=\"config-label\">QUIC Port</span>\n          <span class=\"config-value\">{{ data.quicBindPort }}</span>\n        </div>\n        <div class=\"config-item\" v-if=\"data.vhostHTTPPort != 0\">\n          <span class=\"config-label\">HTTP Port</span>\n          <span class=\"config-value\">{{ data.vhostHTTPPort }}</span>\n        </div>\n        <div class=\"config-item\" v-if=\"data.vhostHTTPSPort != 0\">\n          <span class=\"config-label\">HTTPS Port</span>\n          <span class=\"config-value\">{{ data.vhostHTTPSPort }}</span>\n        </div>\n        <div class=\"config-item\" v-if=\"data.tcpmuxHTTPConnectPort != 0\">\n          <span class=\"config-label\">TCPMux Port</span>\n          <span class=\"config-value\">{{ data.tcpmuxHTTPConnectPort }}</span>\n        </div>\n        <div class=\"config-item\" v-if=\"data.subdomainHost != ''\">\n          <span class=\"config-label\">Subdomain Host</span>\n          <span class=\"config-value\">{{ data.subdomainHost }}</span>\n        </div>\n        <div class=\"config-item\">\n          <span class=\"config-label\">Max Pool Count</span>\n          <span class=\"config-value\">{{ data.maxPoolCount }}</span>\n        </div>\n        <div class=\"config-item\">\n          <span class=\"config-label\">Max Ports/Client</span>\n          <span class=\"config-value\">{{ data.maxPortsPerClient }}</span>\n        </div>\n        <div class=\"config-item\" v-if=\"data.allowPortsStr != ''\">\n          <span class=\"config-label\">Allow Ports</span>\n          <span class=\"config-value\">{{ data.allowPortsStr }}</span>\n        </div>\n        <div class=\"config-item\" v-if=\"data.tlsForce\">\n          <span class=\"config-label\">TLS Force</span>\n          <el-tag size=\"small\" type=\"warning\">Enabled</el-tag>\n        </div>\n        <div class=\"config-item\">\n          <span class=\"config-label\">Heartbeat Timeout</span>\n          <span class=\"config-value\">{{ data.heartbeatTimeout }}s</span>\n        </div>\n      </div>\n    </el-card>\n  </div>\n</template>\n\n<script setup lang=\"ts\">\nimport { ref, onMounted, computed } from 'vue'\nimport { ElMessage } from 'element-plus'\nimport { formatFileSize } from '../utils/format'\nimport { Download, Upload } from '@element-plus/icons-vue'\nimport StatCard from '../components/StatCard.vue'\nimport { getServerInfo } from '../api/server'\n\nconst data = ref({\n  version: '',\n  bindPort: 0,\n  kcpBindPort: 0,\n  quicBindPort: 0,\n  vhostHTTPPort: 0,\n  vhostHTTPSPort: 0,\n  tcpmuxHTTPConnectPort: 0,\n  subdomainHost: '',\n  maxPoolCount: 0,\n  maxPortsPerClient: '',\n  allowPortsStr: '',\n  tlsForce: false,\n  heartbeatTimeout: 0,\n  clientCounts: 0,\n  curConns: 0,\n  proxyCounts: 0,\n  totalTrafficIn: 0,\n  totalTrafficOut: 0,\n  proxyTypeCounts: {} as Record<string, number>,\n})\n\nconst hasActiveProxies = computed(() => {\n  return Object.values(data.value.proxyTypeCounts).some((c) => c > 0)\n})\n\nconst formatTrafficTotal = () => {\n  const total = data.value.totalTrafficIn + data.value.totalTrafficOut\n  return formatFileSize(total)\n}\n\nconst fetchData = async () => {\n  try {\n    const json = await getServerInfo()\n    data.value.version = json.version\n    data.value.bindPort = json.bindPort\n    data.value.kcpBindPort = json.kcpBindPort\n    data.value.quicBindPort = json.quicBindPort\n    data.value.vhostHTTPPort = json.vhostHTTPPort\n    data.value.vhostHTTPSPort = json.vhostHTTPSPort\n    data.value.tcpmuxHTTPConnectPort = json.tcpmuxHTTPConnectPort\n    data.value.subdomainHost = json.subdomainHost\n    data.value.maxPoolCount = json.maxPoolCount\n    data.value.maxPortsPerClient = String(json.maxPortsPerClient)\n    if (data.value.maxPortsPerClient == '0') {\n      data.value.maxPortsPerClient = 'no limit'\n    }\n    data.value.allowPortsStr = json.allowPortsStr\n    data.value.tlsForce = json.tlsForce\n    data.value.heartbeatTimeout = json.heartbeatTimeout\n    data.value.clientCounts = json.clientCounts\n    data.value.curConns = json.curConns\n    data.value.totalTrafficIn = json.totalTrafficIn\n    data.value.totalTrafficOut = json.totalTrafficOut\n    data.value.proxyTypeCounts = json.proxyTypeCount || {}\n\n    data.value.proxyCounts = 0\n    if (json.proxyTypeCount != null) {\n      Object.values(json.proxyTypeCount).forEach((count: any) => {\n        data.value.proxyCounts += count || 0\n      })\n    }\n  } catch {\n    ElMessage({\n      showClose: true,\n      message: 'Get server info from frps failed!',\n      type: 'error',\n    })\n  }\n}\n\nonMounted(() => {\n  fetchData()\n})\n</script>\n\n<style scoped>\n.server-overview {\n  padding: 0;\n}\n\n.stats-row {\n  margin-bottom: 20px;\n}\n\n.charts-row {\n  margin-bottom: 20px;\n}\n\n.chart-card {\n  border-radius: 12px;\n  border: 1px solid #e4e7ed;\n  height: 100%;\n}\n\nhtml.dark .chart-card {\n  border-color: #3a3d5c;\n  background: #27293d;\n}\n\n.config-card {\n  border-radius: 12px;\n  border: 1px solid #e4e7ed;\n  margin-bottom: 20px;\n}\n\nhtml.dark .config-card {\n  border-color: #3a3d5c;\n  background: #27293d;\n}\n\n.card-header {\n  display: flex;\n  justify-content: space-between;\n  align-items: center;\n}\n\n.card-title {\n  font-size: 16px;\n  font-weight: 500;\n  color: #303133;\n}\n\nhtml.dark .card-title {\n  color: #e5e7eb;\n}\n\n.traffic-summary {\n  display: flex;\n  align-items: center;\n  justify-content: space-around;\n  min-height: 120px;\n  padding: 10px 0;\n}\n\n.traffic-item {\n  display: flex;\n  align-items: center;\n  gap: 16px;\n}\n\n.traffic-icon {\n  width: 48px;\n  height: 48px;\n  border-radius: 50%;\n  display: flex;\n  align-items: center;\n  justify-content: center;\n  font-size: 24px;\n}\n\n.traffic-item.in .traffic-icon {\n  background: rgba(84, 112, 198, 0.1);\n  color: #5470c6;\n}\n\n.traffic-item.out .traffic-icon {\n  background: rgba(145, 204, 117, 0.1);\n  color: #91cc75;\n}\n\n.traffic-info {\n  display: flex;\n  flex-direction: column;\n}\n\n.traffic-info .label {\n  font-size: 14px;\n  color: #909399;\n}\n\n.traffic-info .value {\n  font-size: 24px;\n  font-weight: 500;\n  color: #303133;\n}\n\nhtml.dark .traffic-info .value {\n  color: #e5e7eb;\n}\n\n.traffic-divider {\n  width: 1px;\n  height: 60px;\n  background: #e4e7ed;\n}\n\nhtml.dark .traffic-divider {\n  background: #3a3d5c;\n}\n\n.proxy-types-grid {\n  display: grid;\n  grid-template-columns: repeat(auto-fill, minmax(100px, 1fr));\n  gap: 16px;\n  min-height: 120px;\n  align-content: center;\n  padding: 10px 0;\n}\n\n.proxy-type-item {\n  display: flex;\n  flex-direction: column;\n  align-items: center;\n  padding: 12px;\n  background: #f8f9fa;\n  border-radius: 8px;\n}\n\nhtml.dark .proxy-type-item {\n  background: #1e1e2d;\n}\n\n.proxy-type-name {\n  font-size: 12px;\n  color: #909399;\n  font-weight: 500;\n  margin-bottom: 4px;\n}\n\n.proxy-type-count {\n  font-size: 20px;\n  font-weight: 500;\n  color: #303133;\n}\n\nhtml.dark .proxy-type-count {\n  color: #e5e7eb;\n}\n\n.no-data {\n  grid-column: 1 / -1;\n  display: flex;\n  align-items: center;\n  justify-content: center;\n  height: 100%;\n  color: #909399;\n  font-size: 14px;\n}\n\n.config-grid {\n  display: grid;\n  grid-template-columns: repeat(auto-fill, minmax(180px, 1fr));\n  gap: 16px;\n}\n\n.config-item {\n  display: flex;\n  flex-direction: column;\n  gap: 4px;\n  padding: 12px;\n  background: #f8f9fa;\n  border-radius: 8px;\n  transition: background 0.2s;\n}\n\nhtml.dark .config-item {\n  background: #1e1e2d;\n}\n\n.config-label {\n  font-size: 12px;\n  color: #909399;\n  font-weight: 500;\n}\n\nhtml.dark .config-label {\n  color: #9ca3af;\n}\n\n.config-value {\n  font-size: 14px;\n  color: #303133;\n  font-weight: 500;\n  word-break: break-all;\n}\n\nhtml.dark .config-value {\n  color: #e5e7eb;\n}\n\n@media (max-width: 768px) {\n  .chart-container {\n    height: 250px;\n  }\n\n  .config-grid {\n    grid-template-columns: repeat(2, 1fr);\n  }\n}\n</style>\n"
  },
  {
    "path": "web/frps/tsconfig.json",
    "content": "{\n  \"compilerOptions\": {\n    \"target\": \"ES2020\",\n    \"useDefineForClassFields\": true,\n    \"module\": \"ESNext\",\n    \"lib\": [\"ES2020\", \"DOM\", \"DOM.Iterable\"],\n    \"skipLibCheck\": true,\n\n    /* Bundler mode */\n    \"moduleResolution\": \"bundler\",\n    \"allowImportingTsExtensions\": true,\n    \"resolveJsonModule\": true,\n    \"isolatedModules\": true,\n    \"noEmit\": true,\n    \"jsx\": \"preserve\",\n\n    /* Linting */\n    \"strict\": true,\n    \"noUnusedLocals\": true,\n    \"noUnusedParameters\": true,\n    \"noFallthroughCasesInSwitch\": true,\n    \"paths\": {\n      \"@/*\": [\"./src/*\"],\n      \"@shared/*\": [\"../shared/*\"]\n    }\n  },\n  \"include\": [\"src/**/*.ts\", \"src/**/*.tsx\", \"src/**/*.vue\", \"../shared/**/*.ts\", \"../shared/**/*.vue\"],\n  \"references\": [{ \"path\": \"./tsconfig.node.json\" }]\n}\n"
  },
  {
    "path": "web/frps/tsconfig.node.json",
    "content": "{\n  \"compilerOptions\": {\n    \"composite\": true,\n    \"skipLibCheck\": true,\n    \"module\": \"ESNext\",\n    \"moduleResolution\": \"bundler\",\n    \"allowSyntheticDefaultImports\": true\n  },\n  \"include\": [\"vite.config.ts\"]\n}\n"
  },
  {
    "path": "web/frps/vite.config.mts",
    "content": "import { fileURLToPath, URL } from 'node:url'\n\nimport { defineConfig } from 'vite'\nimport vue from '@vitejs/plugin-vue'\nimport svgLoader from 'vite-svg-loader'\nimport AutoImport from 'unplugin-auto-import/vite'\nimport Components from 'unplugin-vue-components/vite'\nimport { ElementPlusResolver } from 'unplugin-vue-components/resolvers'\nimport ElementPlus from 'unplugin-element-plus/vite'\n\n// https://vitejs.dev/config/\nexport default defineConfig({\n  base: '',\n  plugins: [\n    vue(),\n    svgLoader(),\n    ElementPlus({}),\n    AutoImport({\n      resolvers: [ElementPlusResolver()],\n    }),\n    Components({\n      resolvers: [ElementPlusResolver()],\n    }),\n  ],\n  resolve: {\n    alias: {\n      '@': fileURLToPath(new URL('./src', import.meta.url)),\n      '@shared': fileURLToPath(new URL('../shared', import.meta.url)),\n    },\n    dedupe: ['vue', 'element-plus', '@element-plus/icons-vue'],\n    modules: [\n      fileURLToPath(new URL('../node_modules', import.meta.url)),\n      'node_modules',\n    ],\n  },\n  css: {\n    preprocessorOptions: {\n      scss: {\n        api: 'modern',\n        additionalData: `@use \"@shared/css/_index.scss\" as *;`,\n      },\n    },\n  },\n  build: {\n    assetsDir: '',\n    chunkSizeWarningLimit: 1000,\n    minify: 'terser',\n    terserOptions: {\n      compress: {\n        drop_console: true,\n        drop_debugger: true,\n      },\n    },\n  },\n  server: {\n    allowedHosts: process.env.ALLOWED_HOSTS\n      ? process.env.ALLOWED_HOSTS.split(',')\n      : [],\n    proxy: {\n      '/api': {\n        target: process.env.VITE_API_URL || 'http://127.0.0.1:7500',\n        changeOrigin: true,\n      },\n    },\n  },\n})\n"
  },
  {
    "path": "web/package.json",
    "content": "{\n  \"name\": \"frp-web\",\n  \"private\": true,\n  \"workspaces\": [\"shared\", \"frpc\", \"frps\"]\n}\n"
  },
  {
    "path": "web/shared/components/ActionButton.vue",
    "content": "<template>\n  <button\n    type=\"button\"\n    class=\"action-button\"\n    :class=\"[variant, size, { 'is-loading': loading, 'is-danger': danger }]\"\n    :disabled=\"disabled || loading\"\n    @click=\"handleClick\"\n  >\n    <div v-if=\"loading\" class=\"spinner\"></div>\n    <span v-if=\"loading && loadingText\">{{ loadingText }}</span>\n    <slot v-else />\n  </button>\n</template>\n\n<script setup lang=\"ts\">\ninterface Props {\n  variant?: 'primary' | 'secondary' | 'outline'\n  size?: 'small' | 'medium' | 'large'\n  disabled?: boolean\n  loading?: boolean\n  loadingText?: string\n  danger?: boolean\n}\n\nconst props = withDefaults(defineProps<Props>(), {\n  variant: 'primary',\n  size: 'medium',\n  disabled: false,\n  loading: false,\n  loadingText: '',\n  danger: false,\n})\n\nconst emit = defineEmits<{\n  click: [event: MouseEvent]\n}>()\n\nconst handleClick = (event: MouseEvent) => {\n  if (!props.disabled && !props.loading) {\n    emit('click', event)\n  }\n}\n</script>\n\n<style scoped lang=\"scss\">\n.action-button {\n  display: inline-flex;\n  align-items: center;\n  justify-content: center;\n  gap: $spacing-sm;\n  border-radius: $radius-md;\n  font-weight: $font-weight-medium;\n  cursor: pointer;\n  transition: all $transition-fast;\n  border: 1px solid transparent;\n  white-space: nowrap;\n\n  .spinner {\n    width: 14px;\n    height: 14px;\n    border: 2px solid currentColor;\n    border-right-color: transparent;\n    border-radius: 50%;\n    animation: spin 0.75s linear infinite;\n  }\n\n  @keyframes spin {\n    from { transform: rotate(0deg); }\n    to { transform: rotate(360deg); }\n  }\n\n  &.small {\n    padding: 5px $spacing-md;\n    font-size: $font-size-sm;\n  }\n\n  &.medium {\n    padding: $spacing-sm $spacing-lg;\n    font-size: $font-size-md;\n  }\n\n  &.large {\n    padding: 10px $spacing-xl;\n    font-size: $font-size-lg;\n  }\n\n  &.primary {\n    background: $color-btn-primary;\n    border-color: $color-btn-primary;\n    color: #fff;\n\n    &:hover:not(:disabled) {\n      background: $color-btn-primary-hover;\n      border-color: $color-btn-primary-hover;\n    }\n  }\n\n  &.secondary {\n    background: $color-bg-hover;\n    border-color: $color-border-light;\n    color: $color-text-primary;\n\n    &:hover:not(:disabled) {\n      border-color: $color-border;\n    }\n  }\n\n  &.outline {\n    background: transparent;\n    border-color: $color-border;\n    color: $color-text-primary;\n\n    &:hover:not(:disabled) {\n      background: $color-bg-hover;\n    }\n  }\n\n  &.is-danger {\n    &.primary {\n      background: $color-danger;\n      border-color: $color-danger;\n\n      &:hover:not(:disabled) {\n        background: $color-danger-dark;\n        border-color: $color-danger-dark;\n      }\n    }\n\n    &.outline, &.secondary {\n      color: $color-danger;\n\n      &:hover:not(:disabled) {\n        border-color: $color-danger;\n        background: rgba(239, 68, 68, 0.08);\n      }\n    }\n  }\n\n  &:disabled {\n    opacity: 0.5;\n    cursor: not-allowed;\n  }\n}\n</style>\n"
  },
  {
    "path": "web/shared/components/BaseDialog.vue",
    "content": "<template>\n  <el-dialog\n    v-model=\"visible\"\n    :title=\"title\"\n    :width=\"dialogWidth\"\n    :destroy-on-close=\"destroyOnClose\"\n    :close-on-click-modal=\"closeOnClickModal\"\n    :close-on-press-escape=\"closeOnPressEscape\"\n    :append-to-body=\"appendToBody\"\n    :top=\"dialogTop\"\n    :fullscreen=\"isMobile\"\n    class=\"base-dialog\"\n    :class=\"{ 'mobile-dialog': isMobile }\"\n  >\n    <slot />\n    <template v-if=\"$slots.footer\" #footer>\n      <slot name=\"footer\" />\n    </template>\n  </el-dialog>\n</template>\n\n<script setup lang=\"ts\">\nimport { computed } from 'vue'\n\nconst props = withDefaults(\n  defineProps<{\n    modelValue: boolean\n    title: string\n    width?: string\n    destroyOnClose?: boolean\n    closeOnClickModal?: boolean\n    closeOnPressEscape?: boolean\n    appendToBody?: boolean\n    top?: string\n    isMobile?: boolean\n  }>(),\n  {\n    width: '480px',\n    destroyOnClose: true,\n    closeOnClickModal: true,\n    closeOnPressEscape: true,\n    appendToBody: false,\n    top: '15vh',\n    isMobile: false,\n  },\n)\n\nconst emit = defineEmits<{\n  (e: 'update:modelValue', value: boolean): void\n}>()\n\nconst visible = computed({\n  get: () => props.modelValue,\n  set: (value) => emit('update:modelValue', value),\n})\n\nconst dialogWidth = computed(() => {\n  if (props.isMobile) return '100%'\n  return props.width\n})\n\nconst dialogTop = computed(() => {\n  if (props.isMobile) return '0'\n  return props.top\n})\n</script>\n\n<style lang=\"scss\">\n.base-dialog.el-dialog {\n  border-radius: 16px;\n\n  .el-dialog__header {\n    display: flex;\n    align-items: center;\n    justify-content: space-between;\n    padding: 0 8px;\n    min-height: 42px;\n    margin: 0;\n    position: relative;\n\n    &::after {\n      content: \"\";\n      position: absolute;\n      bottom: 0;\n      left: 8px;\n      right: 8px;\n      height: 1px;\n      background: $color-border-lighter;\n    }\n  }\n\n  .el-dialog__title {\n    font-size: $font-size-lg;\n    font-weight: $font-weight-semibold;\n  }\n\n  .el-dialog__body {\n    padding: 16px 8px;\n  }\n\n  .el-dialog__headerbtn {\n    position: static;\n    width: 32px;\n    height: 32px;\n    @include flex-center;\n    border-radius: $radius-sm;\n    transition: background $transition-fast;\n\n    &:hover {\n      background: $color-bg-hover;\n    }\n  }\n\n  .el-dialog__footer {\n    padding: 8px;\n    display: flex;\n    justify-content: flex-end;\n    gap: 8px;\n  }\n\n  &.mobile-dialog {\n    border-radius: 0;\n    margin: 0;\n    height: 100%;\n    max-height: 100dvh;\n    display: flex;\n    flex-direction: column;\n\n    .el-dialog__body {\n      flex: 1;\n      overflow-y: auto;\n      padding: 16px 12px;\n    }\n\n    .el-dialog__footer {\n      padding: 8px 12px;\n      padding-bottom: calc(8px + env(safe-area-inset-bottom));\n    }\n  }\n}\n</style>\n"
  },
  {
    "path": "web/shared/components/ConfirmDialog.vue",
    "content": "<template>\n  <BaseDialog\n    v-model=\"visible\"\n    :title=\"title\"\n    width=\"400px\"\n    :close-on-click-modal=\"false\"\n    :append-to-body=\"true\"\n    :is-mobile=\"isMobile\"\n  >\n    <p class=\"confirm-message\">{{ message }}</p>\n    <template #footer>\n      <div class=\"dialog-footer\">\n        <ActionButton variant=\"outline\" @click=\"handleCancel\">\n          {{ cancelText }}\n        </ActionButton>\n        <ActionButton\n          :danger=\"danger\"\n          :loading=\"loading\"\n          @click=\"handleConfirm\"\n        >\n          {{ confirmText }}\n        </ActionButton>\n      </div>\n    </template>\n  </BaseDialog>\n</template>\n\n<script setup lang=\"ts\">\nimport { computed } from 'vue'\nimport BaseDialog from './BaseDialog.vue'\nimport ActionButton from '@shared/components/ActionButton.vue'\n\nconst props = withDefaults(\n  defineProps<{\n    modelValue: boolean\n    title: string\n    message: string\n    confirmText?: string\n    cancelText?: string\n    danger?: boolean\n    loading?: boolean\n    isMobile?: boolean\n  }>(),\n  {\n    confirmText: 'Confirm',\n    cancelText: 'Cancel',\n    danger: false,\n    loading: false,\n    isMobile: false,\n  },\n)\n\nconst emit = defineEmits<{\n  (e: 'update:modelValue', value: boolean): void\n  (e: 'confirm'): void\n  (e: 'cancel'): void\n}>()\n\nconst visible = computed({\n  get: () => props.modelValue,\n  set: (value) => emit('update:modelValue', value),\n})\n\nconst handleConfirm = () => {\n  emit('confirm')\n}\n\nconst handleCancel = () => {\n  visible.value = false\n  emit('cancel')\n}\n</script>\n\n<style scoped lang=\"scss\">\n.confirm-message {\n  margin: 0;\n  font-size: $font-size-md;\n  color: $color-text-secondary;\n  line-height: 1.6;\n}\n\n.dialog-footer {\n  display: flex;\n  justify-content: flex-end;\n  gap: $spacing-md;\n}\n</style>\n"
  },
  {
    "path": "web/shared/components/FilterDropdown.vue",
    "content": "<template>\n  <PopoverMenu\n    :model-value=\"modelValue\"\n    :width=\"width\"\n    placement=\"bottom-start\"\n    selectable\n    :display-value=\"displayLabel\"\n    @update:model-value=\"$emit('update:modelValue', $event as string)\"\n  >\n    <template #trigger>\n      <button class=\"filter-trigger\" :class=\"{ 'has-value': modelValue }\" :style=\"minWidth && !isMobile ? { minWidth: minWidth + 'px' } : undefined\">\n        <span class=\"filter-label\">{{ label }}:</span>\n        <span class=\"filter-value\">{{ displayLabel }}</span>\n        <el-icon class=\"filter-arrow\"><ArrowDown /></el-icon>\n      </button>\n    </template>\n    <PopoverMenuItem value=\"\">{{ allLabel }}</PopoverMenuItem>\n    <PopoverMenuItem\n      v-for=\"opt in options\"\n      :key=\"opt.value\"\n      :value=\"opt.value\"\n    >\n      {{ opt.label }}\n    </PopoverMenuItem>\n  </PopoverMenu>\n</template>\n\n<script setup lang=\"ts\">\nimport { computed } from 'vue'\nimport { ArrowDown } from '@element-plus/icons-vue'\nimport PopoverMenu from './PopoverMenu.vue'\nimport PopoverMenuItem from './PopoverMenuItem.vue'\n\ninterface Props {\n  modelValue: string\n  label: string\n  options: Array<{ label: string; value: string }>\n  allLabel?: string\n  width?: number\n  minWidth?: number\n  isMobile?: boolean\n}\n\nconst props = withDefaults(defineProps<Props>(), {\n  allLabel: 'All',\n  width: 150,\n})\n\ndefineEmits<{\n  'update:modelValue': [value: string]\n}>()\n\nconst displayLabel = computed(() => {\n  if (!props.modelValue) return props.allLabel\n  const found = props.options.find((o) => o.value === props.modelValue)\n  return found ? found.label : props.modelValue\n})\n</script>\n\n<style scoped lang=\"scss\">\n.filter-trigger {\n  display: inline-flex;\n  align-items: center;\n  gap: $spacing-sm;\n  padding: 7px 12px;\n  background: $color-bg-primary;\n  border: none;\n  border-radius: $radius-md;\n  box-shadow: 0 0 0 1px $color-border-light inset;\n  font-size: $font-size-sm;\n  color: $color-text-secondary;\n  cursor: pointer;\n  transition: box-shadow $transition-fast;\n  white-space: nowrap;\n\n  &:hover {\n    box-shadow: 0 0 0 1px $color-border inset;\n  }\n\n  &.has-value .filter-value {\n    color: $color-text-primary;\n  }\n}\n\n.filter-label {\n  color: $color-text-muted;\n  flex-shrink: 0;\n}\n\n.filter-value {\n  color: $color-text-secondary;\n  margin-left: auto;\n}\n\n.filter-arrow {\n  font-size: 12px;\n  color: $color-text-light;\n  flex-shrink: 0;\n}\n</style>\n"
  },
  {
    "path": "web/shared/components/PopoverMenu.vue",
    "content": "<template>\n  <div\n    class=\"popover-menu-wrapper\"\n    :class=\"{ 'is-full-width': fullWidth }\"\n    ref=\"wrapperRef\"\n  >\n    <el-popover\n      :visible=\"isOpen\"\n      :placement=\"placement\"\n      trigger=\"click\"\n      :width=\"popoverWidth\"\n      popper-class=\"popover-menu-popper\"\n      :persistent=\"false\"\n      :hide-after=\"0\"\n      :offset=\"8\"\n      :show-arrow=\"false\"\n    >\n      <template #reference>\n        <div\n          v-if=\"filterable\"\n          class=\"popover-trigger filterable-trigger\"\n          :class=\"{ 'show-clear': showClearIcon }\"\n          @click.stop\n          @mouseenter=\"isHovering = true\"\n          @mouseleave=\"isHovering = false\"\n        >\n          <el-input\n            ref=\"filterInputRef\"\n            :model-value=\"inputValue\"\n            :placeholder=\"inputPlaceholder\"\n            :disabled=\"disabled\"\n            :readonly=\"!isOpen\"\n            @click=\"handleInputClick\"\n            @update:model-value=\"handleFilterInput\"\n          >\n            <template #suffix>\n              <el-icon\n                v-if=\"showClearIcon\"\n                class=\"clear-icon\"\n                @click.stop=\"handleClear\"\n              >\n                <CircleClose />\n              </el-icon>\n              <el-icon v-else class=\"arrow-icon\"><ArrowDown /></el-icon>\n            </template>\n          </el-input>\n        </div>\n        <div v-else class=\"popover-trigger\" @click.stop=\"toggle\">\n          <slot name=\"trigger\" />\n        </div>\n      </template>\n      <div class=\"popover-menu-content\">\n        <slot :close=\"close\" :filter-text=\"filterText\" />\n      </div>\n    </el-popover>\n  </div>\n</template>\n\n<script lang=\"ts\">\n// Module-level singleton for coordinating popover menus\nconst popoverEventTarget = new EventTarget()\nconst CLOSE_ALL_EVENT = 'close-all-popovers'\n</script>\n\n<script setup lang=\"ts\">\nimport {\n  ref,\n  computed,\n  provide,\n  inject,\n  watch,\n  onMounted,\n  onUnmounted,\n} from 'vue'\nimport { formItemContextKey, ElInput } from 'element-plus'\nimport { ArrowDown, CircleClose } from '@element-plus/icons-vue'\n\ninterface Props {\n  width?: number\n  placement?:\n    | 'top'\n    | 'top-start'\n    | 'top-end'\n    | 'bottom'\n    | 'bottom-start'\n    | 'bottom-end'\n  modelValue?: string | number | null\n  selectable?: boolean\n  disabled?: boolean\n  fullWidth?: boolean\n  filterable?: boolean\n  filterPlaceholder?: string\n  displayValue?: string\n  clearable?: boolean\n}\n\nconst props = withDefaults(defineProps<Props>(), {\n  width: 160,\n  placement: 'bottom-end',\n  modelValue: null,\n  selectable: false,\n  disabled: false,\n  fullWidth: false,\n  filterable: false,\n  filterPlaceholder: 'Search...',\n  displayValue: '',\n  clearable: false,\n})\n\nconst emit = defineEmits<{\n  (e: 'update:modelValue', value: string | number | null): void\n  (e: 'filter-change', text: string): void\n}>()\n\nconst elFormItem = inject(formItemContextKey, undefined)\n\nconst isOpen = ref(false)\nconst wrapperRef = ref<HTMLElement | null>(null)\nconst instanceId = Symbol()\nconst filterText = ref('')\nconst filterInputRef = ref<InstanceType<typeof ElInput> | null>(null)\nconst isHovering = ref(false)\nconst triggerWidth = ref(0)\n\nconst popoverWidth = computed(() => {\n  if (props.filterable && triggerWidth.value > 0) {\n    return Math.max(triggerWidth.value, props.width)\n  }\n  return props.width\n})\n\nconst updateTriggerWidth = () => {\n  if (wrapperRef.value) {\n    triggerWidth.value = wrapperRef.value.offsetWidth\n  }\n}\n\nconst inputValue = computed(() => {\n  if (isOpen.value) return filterText.value\n  if (props.modelValue) return props.displayValue || ''\n  return ''\n})\n\nconst inputPlaceholder = computed(() => {\n  if (isOpen.value) return props.filterPlaceholder\n  if (!props.modelValue) return props.displayValue || props.filterPlaceholder\n  return props.filterPlaceholder\n})\n\nconst showClearIcon = computed(() => {\n  return (\n    props.clearable && props.modelValue && isHovering.value && !props.disabled\n  )\n})\n\nwatch(isOpen, (open) => {\n  if (!open && props.filterable) {\n    filterText.value = ''\n    emit('filter-change', '')\n  }\n})\n\nconst handleInputClick = () => {\n  if (props.disabled) return\n  if (!isOpen.value) {\n    updateTriggerWidth()\n    popoverEventTarget.dispatchEvent(\n      new CustomEvent(CLOSE_ALL_EVENT, { detail: instanceId }),\n    )\n    isOpen.value = true\n  }\n}\n\nconst handleFilterInput = (value: string) => {\n  filterText.value = value\n  emit('filter-change', value)\n}\n\nconst handleClear = () => {\n  emit('update:modelValue', '')\n  filterText.value = ''\n  emit('filter-change', '')\n  elFormItem?.validate?.('change')\n}\n\nconst toggle = () => {\n  if (props.disabled) return\n  if (!isOpen.value) {\n    popoverEventTarget.dispatchEvent(\n      new CustomEvent(CLOSE_ALL_EVENT, { detail: instanceId }),\n    )\n  }\n  isOpen.value = !isOpen.value\n}\n\nconst handleCloseAll = (e: Event) => {\n  const customEvent = e as CustomEvent\n  if (customEvent.detail !== instanceId) {\n    isOpen.value = false\n  }\n}\n\nconst close = () => {\n  isOpen.value = false\n}\n\nconst select = (value: string | number) => {\n  emit('update:modelValue', value)\n  if (props.filterable) {\n    filterText.value = ''\n    emit('filter-change', '')\n    filterInputRef.value?.blur()\n  }\n  close()\n  elFormItem?.validate?.('change')\n}\n\nconst handleClickOutside = (e: MouseEvent) => {\n  const target = e.target as HTMLElement\n  if (wrapperRef.value && !wrapperRef.value.contains(target)) {\n    close()\n  }\n}\n\nonMounted(() => {\n  document.addEventListener('click', handleClickOutside)\n  popoverEventTarget.addEventListener(CLOSE_ALL_EVENT, handleCloseAll)\n})\n\nonUnmounted(() => {\n  document.removeEventListener('click', handleClickOutside)\n  popoverEventTarget.removeEventListener(CLOSE_ALL_EVENT, handleCloseAll)\n})\n\nprovide('popoverMenu', {\n  close,\n  select,\n  selectable: props.selectable,\n  modelValue: () => props.modelValue,\n})\n</script>\n\n<style scoped lang=\"scss\">\n.popover-menu-wrapper {\n  display: inline-block;\n\n  &.is-full-width {\n    display: block;\n    width: 100%;\n\n    .popover-trigger {\n      display: block;\n      width: 100%;\n    }\n  }\n}\n\n.popover-trigger {\n  display: inline-flex;\n\n  &.filterable-trigger {\n    display: block;\n    width: 100%;\n\n    :deep(.el-input__wrapper) {\n      cursor: pointer;\n    }\n\n    :deep(.el-input__suffix) {\n      cursor: pointer;\n    }\n\n    .arrow-icon {\n      color: var(--el-text-color-placeholder);\n      transition: transform 0.2s;\n    }\n\n    .clear-icon {\n      color: var(--el-text-color-placeholder);\n      transition: color 0.2s;\n\n      &:hover {\n        color: var(--el-text-color-regular);\n      }\n    }\n  }\n}\n\n.popover-menu-content {\n  padding: 4px;\n}\n</style>\n\n<style lang=\"scss\">\n.popover-menu-popper {\n  padding: 0 !important;\n  border-radius: 12px !important;\n  border: 1px solid $color-border-light !important;\n  box-shadow:\n    0 10px 25px -5px rgba(0, 0, 0, 0.1),\n    0 8px 10px -6px rgba(0, 0, 0, 0.1) !important;\n}\n</style>\n"
  },
  {
    "path": "web/shared/components/PopoverMenuItem.vue",
    "content": "<template>\n  <button\n    class=\"popover-menu-item\"\n    :class=\"{\n      'is-danger': danger,\n      'is-selected': isSelected,\n      'is-disabled': disabled,\n    }\"\n    :disabled=\"disabled\"\n    @click=\"handleClick\"\n  >\n    <span class=\"item-content\">\n      <slot />\n    </span>\n    <el-icon v-if=\"isSelected\" class=\"check-icon\">\n      <Check />\n    </el-icon>\n  </button>\n</template>\n\n<script setup lang=\"ts\">\nimport { computed, inject } from 'vue'\nimport { Check } from '@element-plus/icons-vue'\n\ninterface Props {\n  danger?: boolean\n  disabled?: boolean\n  value?: string | number\n}\n\nconst props = withDefaults(defineProps<Props>(), {\n  danger: false,\n  disabled: false,\n  value: undefined,\n})\n\nconst emit = defineEmits<{\n  (e: 'click'): void\n}>()\n\nconst popoverMenu = inject<{\n  close: () => void\n  select: (value: string | number) => void\n  selectable: boolean\n  modelValue: () => string | number | null\n}>('popoverMenu')\n\nconst isSelected = computed(() => {\n  if (!popoverMenu?.selectable || props.value === undefined) return false\n  return popoverMenu.modelValue() === props.value\n})\n\nconst handleClick = () => {\n  if (props.disabled) return\n\n  if (popoverMenu?.selectable && props.value !== undefined) {\n    popoverMenu.select(props.value)\n  } else {\n    emit('click')\n    popoverMenu?.close()\n  }\n}\n</script>\n\n<style scoped lang=\"scss\">\n.popover-menu-item {\n  display: flex;\n  align-items: center;\n  justify-content: space-between;\n  width: 100%;\n  padding: 8px 12px;\n  border: none;\n  border-radius: 6px;\n  background: transparent;\n  color: $color-text-secondary;\n  font-size: 14px;\n  cursor: pointer;\n  transition: background 0.15s ease;\n  text-align: left;\n  white-space: nowrap;\n\n  &:hover:not(.is-disabled) {\n    background: $color-bg-hover;\n  }\n\n  &.is-disabled {\n    opacity: 0.5;\n    cursor: not-allowed;\n  }\n\n  &.is-danger {\n    color: $color-danger;\n\n    .item-content :deep(.el-icon) {\n      color: $color-danger;\n    }\n\n    &:hover:not(.is-disabled) {\n      background: $color-danger-light;\n    }\n  }\n\n  &.is-selected {\n    background: $color-bg-hover;\n  }\n\n  .item-content {\n    display: flex;\n    align-items: center;\n    gap: 10px;\n    color: inherit;\n\n    :deep(.el-icon) {\n      font-size: 16px;\n      color: $color-text-light;\n    }\n  }\n\n  .check-icon {\n    font-size: 16px;\n    color: $color-primary;\n    flex-shrink: 0;\n  }\n}\n</style>\n"
  },
  {
    "path": "web/shared/css/_index.scss",
    "content": "@forward './variables';\n@forward './mixins';\n"
  },
  {
    "path": "web/shared/css/_mixins.scss",
    "content": "@use './variables' as vars;\n\n@mixin mobile {\n  @media (max-width: #{vars.$breakpoint-mobile - 1px}) {\n    @content;\n  }\n}\n\n@mixin flex-center {\n  display: flex;\n  align-items: center;\n  justify-content: center;\n}\n\n@mixin flex-column {\n  display: flex;\n  flex-direction: column;\n}\n\n@mixin page-scroll {\n  height: 100%;\n  overflow-y: auto;\n  padding: vars.$spacing-xl 40px;\n\n  > * {\n    max-width: 960px;\n    margin: 0 auto;\n  }\n\n  @include mobile {\n    padding: vars.$spacing-xl;\n  }\n}\n\n@mixin custom-scrollbar {\n  &::-webkit-scrollbar {\n    width: 6px;\n    height: 6px;\n  }\n\n  &::-webkit-scrollbar-track {\n    background: transparent;\n  }\n\n  &::-webkit-scrollbar-thumb {\n    background: #d1d1d1;\n    border-radius: 3px;\n  }\n}\n"
  },
  {
    "path": "web/shared/css/_variables.scss",
    "content": "// Typography\n$font-size-xs: 11px;\n$font-size-sm: 13px;\n$font-size-md: 14px;\n$font-size-lg: 15px;\n$font-size-xl: 18px;\n\n$font-weight-normal: 400;\n$font-weight-medium: 500;\n$font-weight-semibold: 600;\n\n// Colors - Text\n$color-text-primary: var(--color-text-primary);\n$color-text-secondary: var(--color-text-secondary);\n$color-text-muted: var(--color-text-muted);\n$color-text-light: var(--color-text-light);\n\n// Colors - Background\n$color-bg-primary: var(--color-bg-primary);\n$color-bg-secondary: var(--color-bg-secondary);\n$color-bg-tertiary: var(--color-bg-tertiary);\n$color-bg-muted: var(--color-bg-muted);\n$color-bg-hover: var(--color-bg-hover);\n$color-bg-active: var(--color-bg-active);\n\n// Colors - Border\n$color-border: var(--color-border);\n$color-border-light: var(--color-border-light);\n$color-border-lighter: var(--color-border-lighter);\n\n// Colors - Status\n$color-primary: var(--color-primary);\n$color-danger: var(--color-danger);\n$color-danger-dark: var(--color-danger-dark);\n$color-danger-light: var(--color-danger-light);\n\n// Colors - Button\n$color-btn-primary: var(--color-btn-primary);\n$color-btn-primary-hover: var(--color-btn-primary-hover);\n\n// Spacing\n$spacing-xs: 4px;\n$spacing-sm: 8px;\n$spacing-md: 12px;\n$spacing-lg: 16px;\n$spacing-xl: 20px;\n\n// Border Radius\n$radius-sm: 6px;\n$radius-md: 8px;\n\n// Transitions\n$transition-fast: 0.15s ease;\n$transition-medium: 0.2s ease;\n\n// Layout\n$header-height: 50px;\n$sidebar-width: 200px;\n\n// Breakpoints\n$breakpoint-mobile: 768px;\n"
  },
  {
    "path": "web/shared/package.json",
    "content": "{\n  \"name\": \"frp-shared\",\n  \"private\": true\n}\n"
  }
]