diff --git a/.github/scripts/changelog-section.sh b/.github/scripts/changelog-section.sh new file mode 100755 index 00000000..a72802c6 --- /dev/null +++ b/.github/scripts/changelog-section.sh @@ -0,0 +1,35 @@ +#!/usr/bin/env bash +# Prints the CHANGELOG.md section for one tag, without its "## [tag] - date" heading. +# +# .github/scripts/changelog-section.sh v0.10.1 [CHANGELOG.md] +# +# Exits 1 when the tag has no section, or the section is empty, so a release cannot go +# out without notes. Used by .github/workflows/release.yml. +set -euo pipefail + +tag="${1:?usage: changelog-section.sh [changelog]}" +file="${2:-CHANGELOG.md}" + +section="$(awk -v tag="$tag" ' + /^## \[/ { + if (found) exit + # "## [v0.10.1] - 2026-08-11" -> "v0.10.1" + name = $0 + sub(/^## \[/, "", name) + sub(/\].*$/, "", name) + if (name == tag) { found = 1; next } + } + # Link reference definitions at the bottom of the file end the last section. + found && /^\[[^]]+\]: / { exit } + found { print } +' "$file")" + +# Trim leading and trailing blank lines. +section="$(printf '%s\n' "$section" | sed -e '/./,$!d' | tac | sed -e '/./,$!d' | tac)" + +if [ -z "$section" ]; then + echo "CHANGELOG: no section for ${tag} in ${file}. Add '## [${tag}] - YYYY-MM-DD' before tagging." >&2 + exit 1 +fi + +printf '%s\n' "$section" diff --git a/.github/workflows/fork-checks.yml b/.github/workflows/fork-checks.yml new file mode 100644 index 00000000..09f92bd9 --- /dev/null +++ b/.github/workflows/fork-checks.yml @@ -0,0 +1,87 @@ +# Fork-local checks. +# +# This is a NEW workflow file rather than an edit to master-merge-check.yml on purpose: +# this repository is a fork, and a new file can never produce a rebase conflict against +# upstream, whereas editing the shared workflow would conflict on every upstream change. +# +# It also deliberately does NOT enable `go test ./...`. That target is red for a reason +# unrelated to this fork -- services/workerd.TestRunWorker hardcodes /home/coder/... paths +# and only passes in the original author's dev container. Turning it on would make every +# run look broken. The package list below is the subset that actually passes. +name: fork checks + +on: + push: + branches: [main] + pull_request: + branches: [main] + workflow_dispatch: + +jobs: + go: + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v4 + + - uses: actions/setup-go@v5 + with: + # Tracks the go directive in go.mod, so bumping frp (which can raise the + # required Go version) does not also require editing this file. + go-version-file: go.mod + cache: true + + # cmd/frpp/main.go has `//go:embed all:out`, and cmd/frpp/out is gitignored because + # it is the Next.js static export. Without it, one broken package aborts the entire + # ./... walk and every Go command fails. The frontend is not needed for these checks. + - name: Satisfy the frontend embed + run: mkdir -p cmd/frpp/out && touch cmd/frpp/out/.gitkeep + + - name: Build + run: go build ./... + + - name: Vet + run: go vet ./conf/... ./internal/... ./services/... ./utils/... ./biz/... + + # services/dao runs against a temporary sqlite file; biz/... holds the handler tests. + - name: Unit tests + run: go test ./conf/... ./models/... ./services/wg/... ./services/dao/... ./utils/... ./internal/... ./biz/... ./hack/... + + # The real gate for an frp upgrade: stands up a live frps + frpc in one process and + # pushes bytes through tcp, udp, http, https, stcp, sudp and tcpmux (xtcp is skipped + # on loopback), then checks that `enabled: false` takes effect on a hot update. + - name: Tunnel integration tests + run: go test -tags integration -run TestTunnelMatrix -timeout 5m ./internal/frpx/... + + # The frontend gate FEATURE-MATRIX.md always described but this workflow never ran. + www: + runs-on: ubuntu-latest + defaults: + run: + working-directory: www + steps: + - uses: actions/checkout@v4 + + - uses: pnpm/action-setup@v4 + with: + version: 9 + + - uses: actions/setup-node@v4 + with: + # Same major as release.yml. + node-version: 20 + cache: pnpm + cache-dependency-path: www/pnpm-lock.yaml + + - name: Install + run: pnpm install --frozen-lockfile + + - name: Unit tests + run: pnpm test + + - name: Lint + run: pnpm lint + + # Type-checks and compiles the static export. `pnpm build` would also copy it into + # cmd/frpp/out, which this job does not need. + - name: Build + run: pnpm exec next build diff --git a/.github/workflows/master-merge-check.yml b/.github/workflows/master-merge-check.yml index 8edead6f..d72db76b 100644 --- a/.github/workflows/master-merge-check.yml +++ b/.github/workflows/master-merge-check.yml @@ -23,7 +23,7 @@ jobs: - name: Setup Go uses: actions/setup-go@v4 with: - go-version: '1.24.x' + go-version: '1.25.x' - name: Install npm dependencies run: | diff --git a/.github/workflows/master.workflow.yml b/.github/workflows/master.workflow.yml index 5c7d0c5d..0fb0160e 100644 --- a/.github/workflows/master.workflow.yml +++ b/.github/workflows/master.workflow.yml @@ -20,7 +20,7 @@ jobs: - name: Setup Go uses: actions/setup-go@v4 with: - go-version: "1.24.x" + go-version: "1.25.x" - name: npm install and build run: | cd www diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml new file mode 100644 index 00000000..90d78374 --- /dev/null +++ b/.github/workflows/release.yml @@ -0,0 +1,145 @@ +# Fork-local release. +# +# Like fork-checks.yml, this is a NEW file rather than an edit to tag.workflow.yml on +# purpose: this repository is a fork, and a new file can never produce a rebase conflict +# against upstream, whereas editing the shared workflow would conflict on every upstream +# change. The upstream tag workflows are disabled repo-side (Actions -> Workflows -> +# Disable) so the two do not race for the same release. +# +# It differs from upstream's tag.workflow.yml in exactly three ways: +# +# 1. `permissions: contents: write`. This repo's default GITHUB_TOKEN permission is +# read-only, which is why upstream's workflow died with "Resource not accessible by +# integration" on v0.10.0 -- a read-only token cannot create a release or upload +# assets. An explicit permissions block overrides the repo default, so this works +# without depending on a repo setting that a re-fork or a settings reset would lose. +# +# 2. Container images go to GitHub Container Registry (ghcr.io//), not +# Docker Hub. Upstream's ko steps need DOCKERHUB_USERNAME and DOCKERHUB_TOKEN, which +# are not set here, and are hardcoded to docker.io/vaalacat/frp-panel -- upstream's +# namespace. GHCR takes this workflow's own token (`packages: write`), so there is no +# registry secret to manage. The images themselves are built exactly like upstream's: +# ko with .ko.yaml (alpine base, every platform alpine ships) and .ko.workerd.yaml. +# +# 3. Release notes are the tag's section of CHANGELOG.md, and a tag without one is not +# released (.github/scripts/changelog-section.sh). +# +# The build steps below are copied verbatim from upstream's tag.workflow.yml. They are +# known-green on this fork as of v0.10.0 (that run reached the release step before +# failing), so they are deliberately not "cleaned up" -- notably the redundant-looking +# `npm install` before pnpm, which the frontend build in build.sh relies on in practice. +name: fork release + +on: + push: + tags: + - "v*" + workflow_dispatch: + +permissions: + contents: write + # Pushing container images to ghcr.io. + packages: write + +jobs: + release: + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v4 + with: + fetch-depth: 0 + + # Release notes come from CHANGELOG.md, one section per tag. A tag without a section + # fails here, before anything is built or published. Relative .md links are made + # absolute, because they would not resolve on the release page. + - name: Release notes from CHANGELOG.md + if: startsWith(github.ref, 'refs/tags/v') + run: | + set -euo pipefail + bash .github/scripts/changelog-section.sh "${GITHUB_REF_NAME}" CHANGELOG.md \ + | sed -E "s#\]\(([A-Za-z0-9_./-]+\.md)\)#](${GITHUB_SERVER_URL}/${GITHUB_REPOSITORY}/blob/${GITHUB_REF_NAME}/\1)#g" \ + > "${RUNNER_TEMP}/release-notes.md" + cat "${RUNNER_TEMP}/release-notes.md" + + - name: npm setup + uses: actions/setup-node@v4 + with: + node-version: 20 + + - name: Setup Go + uses: actions/setup-go@v5 + with: + go-version: "1.25.x" + + - name: npm install and build + run: | + cd www + npm install && npm install -g pnpm + + # build.sh stamps conf.gitVersion from GITHUB_REF_NAME when it is set (build.sh:24), + # so the `version` subcommand reports the tag rather than `git describe` output. + - name: Compile server + run: bash ./build.sh + + - name: Publish release + if: startsWith(github.ref, 'refs/tags/v') + uses: softprops/action-gh-release@v2 + with: + tag_name: ${{ github.ref_name }} + files: dist/* + body_path: ${{ runner.temp }}/release-notes.md + # Appended below the CHANGELOG section: the commit and PR list for the tag. + generate_release_notes: true + # dist/ silently empty would publish a release with no binaries -- the exact + # failure this workflow exists to prevent. + fail_on_unmatched_files: true + + # install.sh downloads from the `latest` release tag by name, so upstream keeps a + # rolling `latest` release alongside the versioned one. Preserved here. + - name: Refresh rolling `latest` release + if: startsWith(github.ref, 'refs/tags/v') + env: + GH_TOKEN: ${{ secrets.GITHUB_TOKEN }} + run: | + set -euo pipefail + git tag -f latest "${GITHUB_SHA}" + git push -f origin latest + if gh release view latest >/dev/null 2>&1; then + gh release edit latest --target "${GITHUB_SHA}" \ + --notes "Rolling build of ${GITHUB_REF_NAME}." + else + gh release create latest --target "${GITHUB_SHA}" --title latest \ + --notes "Rolling build of ${GITHUB_REF_NAME}." + fi + gh release upload latest dist/* --clobber + + # Last on purpose: an image problem must never hold back the binaries above. + # setup-ko logs in to ghcr.io with this job's token and sets + # KO_DOCKER_REPO=ghcr.io//; --bare pushes to exactly that name. + - name: Setup ko + if: startsWith(github.ref, 'refs/tags/v') + uses: ko-build/setup-ko@v0.9 + with: + version: v0.19.1 + + # The build above left the frontend in cmd/frpp/out, which the binary embeds. + # The source label is what links the GHCR package to this repository. + - name: Publish container images + if: startsWith(github.ref, 'refs/tags/v') + run: | + set -euo pipefail + labels=( + --image-label "org.opencontainers.image.source=${GITHUB_SERVER_URL}/${GITHUB_REPOSITORY}" + --image-label "org.opencontainers.image.version=${GITHUB_REF_NAME}" + --image-label "org.opencontainers.image.revision=${GITHUB_SHA}" + --image-label "org.opencontainers.image.licenses=AGPL-3.0" + ) + ko build ./cmd/frpp --sbom=none --bare "${labels[@]}" -t "${GITHUB_REF_NAME}" -t latest + + # The workerd variant (Cloudflare workerd as the base, for agents that run + # Workers). KO_CONFIG_PATH takes a directory, hence the copy. + mkdir -p "${RUNNER_TEMP}/ko-workerd" + cp .ko.workerd.yaml "${RUNNER_TEMP}/ko-workerd/.ko.yaml" + KO_CONFIG_PATH="${RUNNER_TEMP}/ko-workerd" \ + ko build ./cmd/frpp --sbom=none --bare "${labels[@]}" \ + -t "${GITHUB_REF_NAME}-workerd" -t latest-workerd diff --git a/.github/workflows/tag.workflow.yml b/.github/workflows/tag.workflow.yml index 8075a3c1..70674a40 100644 --- a/.github/workflows/tag.workflow.yml +++ b/.github/workflows/tag.workflow.yml @@ -18,7 +18,7 @@ jobs: - name: Setup Go uses: actions/setup-go@v4 with: - go-version: "1.24.x" + go-version: "1.25.x" - name: npm install and build run: | cd www diff --git a/.github/workflows/workerd-docker-tag.workflow.yml b/.github/workflows/workerd-docker-tag.workflow.yml index 75f16438..fac2666b 100644 --- a/.github/workflows/workerd-docker-tag.workflow.yml +++ b/.github/workflows/workerd-docker-tag.workflow.yml @@ -18,7 +18,7 @@ jobs: - name: Setup Go uses: actions/setup-go@v4 with: - go-version: "1.24.x" + go-version: "1.25.x" - name: npm install and build run: | cd www diff --git a/CHANGELOG.md b/CHANGELOG.md new file mode 100644 index 00000000..a22b8cc7 --- /dev/null +++ b/CHANGELOG.md @@ -0,0 +1,136 @@ +# Changelog + +All notable changes to **this fork** ([felix-homelab/frp-panel](https://github.com/felix-homelab/frp-panel)) +of [VaalaCat/frp-panel](https://github.com/VaalaCat/frp-panel). One section per release tag; the +release workflow publishes the section that matches the tag as the GitHub release notes, and refuses +to release a tag that has no section here. + +Format: [Keep a Changelog](https://keepachangelog.com/en/1.1.0/). Item IDs (`BUG-12`, `FS-11`, …) +refer to [`FEATURE-MATRIX.md`](FEATURE-MATRIX.md) and [`ROADMAP.md`](ROADMAP.md). + +## How this fork relates to upstream + +- **Forked from** upstream **v0.1.37** (commit `1a58b85`, 2026-04-19, "feat: add ephemeral flag"), + which was also upstream's latest commit when the fork started. Everything up to and including + v0.1.37 is upstream's work — see [upstream's releases](https://github.com/VaalaCat/frp-panel/releases). +- **Version numbers are the fork's own.** `v0.10.x` is not an upstream version and does not mean + "upstream 0.1 + 9". Upstream continues its own `v0.1.x` line. +- **Cumulative differences** to upstream v0.1.37, as of the newest entry below: + - frp **v0.70.1** instead of v0.65.0, built with Go **1.25** instead of 1.24. + - A form for **every** frp proxy type (upstream: tcp, stcp, udp, http), for visitors (upstream: none), + for all nine client plugins (upstream: seven), and full frpc / frps settings forms, so nearly + everything that upstream only reaches through the raw JSON editor has a form. + - Many fixes for config that was silently lost or rejected — listed per release below. + - Releases are built by the fork's own `release.yml`. Container images go to + `ghcr.io/felix-homelab/frp-panel` instead of upstream's `docker.io/vaalacat/frp-panel`, starting + with the first release after v0.10.1; v0.10.0 and v0.10.1 have binaries only. + - **Not yet changed:** `install.sh`, `install.ps1`, the in-app upgrade and the "download" links in the + UI still fetch **upstream's** binaries (ROADMAP `REL-01`). Install this fork's binaries from its + [releases page](https://github.com/felix-homelab/frp-panel/releases) instead. + +## [Unreleased] + +Upgrade order is unchanged: Servers, then the Master, then Clients. The fixes marked *agent* only take +effect on agents running this version. + +### Added +- **Your own frps HTTP plugins** (`FS-11`). The server form has an "HTTP plugins" section for adding + frp server plugins — name, address, path, operations, TLS verification. The panel's own + authentication plugin is shown read-only and is always kept last; a user plugin cannot take its name. +- **Request headers and HTTP/2 on client plugins** (`PLG-04`). `http2http`, `http2https`, `https2http` + and `https2https` can set request headers; `https2http` and `https2https` can switch HTTP/2 off. +- **wireProtocol v2 can be selected** (`BUMP-07`) — but only when both the client's agent and the + server's agent run frp v0.69 or newer, and not for clients that use an external frps URL. The Master + re-checks this on every save, so a v2 config can no longer reach an agent that cannot speak it. +- Agents now report the frp version they are built with (`CAP-01`); it is also printed by `version`. +- **Docker images** (`REL-04`). Each release publishes `ghcr.io/felix-homelab/frp-panel:` and + `:latest` for every platform Alpine supports (amd64, arm64, arm/v6, arm/v7, 386, ppc64le, riscv64, + s390x), plus `:-workerd` and `:latest-workerd` (amd64, arm64) for agents that run Workers. + They are built like upstream's images, so upstream's Docker instructions apply with the image name + swapped. + +### Changed +- **Proxy forms offer only plugins that work for the proxy type** (`PLG-05`): for example no + `static_file` on `https`, and no plugins at all on `udp` / `sudp`, which frp ignores. A plugin that + is already configured stays visible. +- A proxy switched off with **Enabled** now shows as `disabled` in the proxy list instead of `error`. +- The Master rejects a `transport.wireProtocol` other than `v1` or `v2` before it reaches an agent. +- **With wireProtocol v2 set, saving that client needs both agents online**, because an offline agent + cannot confirm its frp version. + +### Fixed +- *agent* — **Switching a proxy off had no effect until the agent restarted** (`BUG-12`). frp only + honours `enabled: false` when a client starts; every later update now applies it too. +- **Clicking "Add" in a list or key/value field submitted the whole form** (`BUG-13`). In the client + form this pushed the client's configuration to the agent on every added metadata key. +- **Proxies were re-created with new IDs on every client save, and a stopped proxy could appear twice** + (`BUG-14`). Existing duplicates are cleaned up automatically the next time that client is saved. +- **Updating an http proxy through the API dropped six of its settings** (`BUG-08`): `enabled`, local + IP and port, plugin, health check and PROXY protocol version. Not reachable from the UI. + +### Developer +- Fork CI (`fork-checks.yml`) now also runs the `biz` and `services/dao` tests and a frontend job + (`pnpm test`, `pnpm lint`, `next build`). +- `pnpm test` in `www/` runs frontend unit tests with Node's built-in test runner — no new dependency. +- New [`ROADMAP.md`](ROADMAP.md) tracks the backlog's order and progress. +- `go run ./hack/frpdrift` lists frp config fields added or removed since the last review. +- Release notes for each tag now come from this file, and a tag without a section here is not released. + +## [v0.10.1] - 2026-08-11 + +The first release of this fork with downloadable binaries. + +### Added +- **Forms for every proxy type.** https, tcpmux, xtcp and sudp join tcp, udp, http and stcp, each with + collapsible sections for transport (encryption, compression, bandwidth limit, PROXY protocol), + health checks, load balancing, header rewriting, metadata, annotations and allowed users. +- **Visitors** can be created and edited from the client card, including xtcp tuning and a helper that + pairs a visitor with an existing stcp / xtcp / sudp proxy. +- **Client (frpc) settings form**: TLS, connection tuning, proxy URL, QUIC, STUN / DNS server, logging, + the agent web server, login-failure behaviour, UDP packet size and metadata. +- **Server (frps) settings form**: HTTPS virtual-host port and HTTP timeout, allowed ports and per-client + port limits, dashboard and Prometheus, transport and TLS, SSH tunnel gateway, tcpmux, logging and a + custom 404 page. +- The fork's own release workflow (`.github/workflows/release.yml`). + +### Changed +- Configurations with **duplicate proxy or visitor names are rejected** instead of one silently + replacing the other (`BUG-10`). +- **Server configurations are validated before they are saved** (`BUG-11`). + +### Fixed +- xtcp visitor settings were dropped whenever a proxy of that client was edited (`BUG-01`). +- Creating a proxy from raw JSON was refused (`BUG-02`). +- Proxy types without a form showed an empty panel (`BUG-03`). +- Duplicate frps HTTP plugin entries were not removed (`BUG-04`). +- Client TLS settings were saved in a shape frp could not load (`BUG-05`). +- Saving the client or server form deleted every setting the form does not show — visitors, logging, + web server and more (`BUG-06`, `BUG-07`). +- An invalid server configuration (for example a bad log level) crashed the Server agent (`BUG-11`). + +## [v0.10.0] - 2026-08-11 + +> The GitHub release for this tag has **no binaries**: it was built by upstream's workflow, which +> failed on this fork. Use v0.10.1 or later. + +### Changed +- **frp v0.65.0 → v0.70.1**, golib v0.5.1 → v0.8.1, **Go 1.24 → 1.25**. +- Proxy status keeps working across frp v0.68's change to how proxy names are stored, so mixed + old/new agents do not show every proxy as `error` during an upgrade. +- frp's new client-side proxy store stays disabled on purpose: it would bring back proxies that were + deleted in the panel. + +### Added +- Plugin forms for **http2http** and **tls2raw**. +- An **Enabled** switch on every proxy form. + +### Developer +- All frp imports live behind `internal/frpx`, so an frp upgrade touches one package. +- A live tunnel test (`go test -tags integration ./internal/frpx/...`) that runs a real frps and frpc + and sends traffic through every proxy type; the fork's CI workflow (`fork-checks.yml`); and an frp + upgrade runbook (`docs/frp-upgrade-verification.md`). +- [`FEATURE-MATRIX.md`](FEATURE-MATRIX.md): the backlog of frp features the UI does not cover yet. + +[Unreleased]: https://github.com/felix-homelab/frp-panel/compare/v0.10.1...HEAD +[v0.10.1]: https://github.com/felix-homelab/frp-panel/compare/v0.10.0...v0.10.1 +[v0.10.0]: https://github.com/felix-homelab/frp-panel/compare/1a58b85...v0.10.0 diff --git a/FEATURE-MATRIX.md b/FEATURE-MATRIX.md new file mode 100644 index 00000000..a8d87483 --- /dev/null +++ b/FEATURE-MATRIX.md @@ -0,0 +1,358 @@ +# frp Feature Gap Matrix + +> **frp-panel @ `dd79e50`+working tree · frp v0.70.1 · go 1.25.0 · reviewed 2026-10-05** + +This is the project's backlog: every `frp` capability that frp-panel does not surface, scored and +prioritized. Rows are stable handles — reference them from commits, issues, and PRs. + +## The one thing to understand first + +**frp-panel does not model frp config in protobuf or in the database.** Client, server, and proxy +configs cross the wire and land in the DB as an **opaque JSON blob** — `pb.Client.config`, +`pb.Server.config`, and `pb.ProxyConfig.config` are all `optional string` +(`idl/common.proto:39,54,88`), stored as `ConfigContent []byte` (`models/client.go:21-39`, +`models/server.go:17-29`, `models/proxy_config.go:21-31`). The Go side hands that blob to frp's own +strict `config/v1` loader (`utils/load.go`). + +**So the backend accepts 100% of frp's configuration surface, and always has.** Every `gap` row in +this document is a **UI/UX gap, not a capability gap** — anything listed here can be configured +today through the raw-JSON "Advanced" editors: + +- `www/components/frpc/frpc_editor.tsx` — frpc common config +- `www/components/frps/frps_editor.tsx` — frps config +- `www/components/proxy/mutate_proxy_config.tsx` — per-proxy config (Advanced toggle) + +What you buy by closing a gap is **discoverability and safety**, not new capability. + +**Three things that are *not* true of the raw editors, and bound how much they can be leaned on:** + +1. The loader runs with `DisallowUnknownFields`, all the way into each proxy and visitor element. + One misspelled key rejects the whole config. +2. When the agent rejects a config it has **already stopped and deleted** the previous handler + (`biz/client/update_tunnel.go:29-31`), so an invalid push takes that client's whole tunnel set + down until the next valid one. The panic is recovered; the outage is not. +3. `zodResolver` hands `handleSubmit` the *parsed* object and zod strips unknown keys. Any structured + form that submits `values` alone therefore deletes every key it does not itself model. This was a + live data-loss bug in both structured forms (BUG-06, BUG-07) and is the reason both now merge over + the stored config rather than replacing it. **Any new form must do the same.** + +## How to read this + +| Column | Meaning | +|---|---| +| **ID** | Stable handle. Never reused after a row is removed. | +| **Feature** | The frp field path, verbatim — greppable against frp's docs and pasteable into the raw-JSON editor. | +| **St** | Status, see below. | +| **D** | Difficulty 1–5. | +| **V** | User value 1–5. | +| **P** | Priority bucket, derived. | + +**Status:** `gap` not in the UI, raw-JSON only · `partial` some UI exists, incomplete · +`reserved` the backend overwrites it — **do not** turn it into a user field · `bug` the code actively +does the wrong thing · `won't-do` deliberately rejected, with the reason recorded. + +**Difficulty**, anchored to this codebase: + +| D | Means | +|---|---| +| **1** | One key in an existing zod schema + one primitive from `www/components/base/form-field.tsx` + 2 i18n keys. ~15 lines, no backend. | +| **2** | A new form section or sub-component, a new field primitive, or a new interface in `www/types/*.ts`. Still frontend-only. | +| **3** | A new top-level form wired into an existing switch, plus validator / preview / dropdown plumbing. | +| **4** | A new UI surface with its own CRUD path over existing RPC: new page or dialog, new `www/api/*` functions, new i18n namespace. | +| **5** | Cross-cutting: new proto messages and/or an `AutoMigrate` change and/or a new agent RPC event. `idl/*.proto` + `./codegen.sh` + `models/` + `biz/`. | + +> If a change requires touching `idl/`, it is at least a D4. + +**Value.** Because the raw-JSON escape hatch reaches everything, **no `gap` can score V5 on +capability grounds.** V5 means broken, silently lossy, or advertised-but-unusable. + +**Priority:** `P = 2V − D`, bucketed **P1** ≥ 7 · **P2** 4–6 · **P3** ≤ 3. `bug` rows are **P0**. + +--- + +## 0. Bugs — P0 + +| ID | Issue | St | D | V | +|---|---|---|---|---| +| **BUG-09** | **frp-level `enabled` and panel-level `ProxyConfig.Stopped` are independent and can disagree.** The proxy list's start/stop action drives `Stopped`; the form switch drives `enabled`. A proxy can read "running" in the list while frp has it disabled, or vice versa. Needs a decision on which is authoritative, not just a display fix. Also: editing a stopped proxy resurrects it — `create_proxy_config.go` never reads `Stopped` and re-adds the proxy to the client config. Options and a recommendation: `ROADMAP.md` → Decisions. | bug | 3 | 3 | + +### Closed in this pass + +`BUG-01` visitor fields dropped by `SetConfigContent` (`models/client.go`) — fixed, with +`models/client_test.go` as the regression gate · `BUG-02` raw-JSON proxy creation silently refused · +`BUG-03` blank panel for unhandled proxy types · `BUG-04` frps `httpPlugins` de-dup no-op · +`BUG-05` `www/types/client.ts` drift — all four faults, including **(d)** `TLSClientConfig.tls` +being nested when frp inlines it (`transport.tls.tls.certFile` was a hard load failure) · +**BUG-06** (new) the frpc structured form deleted every config key outside `{proxies, transport}` · +**BUG-07** (new) the frps structured form had the identical defect · **BUG-10** (new) duplicate +proxy/visitor names collapsed silently — frp's own check lives in the path-based loader this +codebase never calls, so `utils.ValidateNoDuplicateNames` now mirrors it · **BUG-11** (new) +`UpdateFrpsHander` never validated the config it stored, so a decodable-but-invalid frps config +(bad `log.level`, bad `httpPlugins.ops`) was a remote-reachable panic on the Server agent. + +Closed 2026-10-05: **BUG-08** `UpdateWorkerLoadBalancerGroup` now mutates the `*HTTPProxyConfig` in +place instead of round-tripping it through `msg.NewProxy`; `frpx.NewProxyMsg` is deleted so the trap +cannot come back (`biz/master/proxy/update_proxy_config_test.go`) · **BUG-12** (new) `enabled: false` +was applied only when frpc was *created* — frp's `Service.UpdateAllConfigurer` does not run +`FilterClientConfigurers` — so on every hot update a disabled proxy kept running; every update now +goes through `frpx.UpdateClientConfigurers`, and the master reports `disabled` rather than `error` +for such a proxy (`TestTunnelMatrix/enabled=false_on_hot_update`) · **BUG-13** (new) the "add" +buttons in `www/components/base/list-input.tsx` were untyped, i.e. submit buttons, so adding a +metadata key in the frpc form pushed the whole client config · **BUG-14** (new) +`RebuildProxyConfigFromClient` looked old rows up by `OriginClientID == `, which never +matches, so every rebuild re-created every row under a new ID and duplicated a stopped row whose +name came back; existing duplicates collapse on the next rebuild (`services/dao/proxy_test.go`). + +--- + +## 1. Proxy types — closed + +All **8** frp proxy types now have a form and appear in both type pickers. `stcp`/`xtcp`/`sudp` share +one parametrized `SecretProxyForm`; `TypedProxyForm` has a `default` branch so an unrecognized type +explains itself instead of rendering blank. + +The per-type forms moved from `proxy_form.tsx` into `www/components/frpc/proxy_forms/` — a +fork-owned directory upstream has no counterpart for, so it cannot produce a rebase conflict, while +`proxy_form.tsx` stays at its original path as a thin dispatcher. + +> **Watch out when adding a ninth type:** `coerceProxyConfigToType` +> (`proxy_forms/shared/build.ts`) must learn its key set. Without it, switching type in the create +> dialog carries the previous type's keys along and the strict decoder rejects the whole config. + +--- + +## 2. Per-proxy fields — closed + +`transport.*` (encryption, compression, bandwidth limit + mode, PROXY protocol), `healthCheck.*`, +`loadBalancer.*`, `hostHeaderRewrite`, `requestHeaders`, `routeByHTTPUser`, `metadatas`, +`annotations` and `allowUsers` are all reachable, as collapsed accordion groups shared across the +eight type forms (`proxy_forms/shared/sections.tsx`). + +Two panel-owned hazards are guarded, and **must stay guarded**: + +- **Worker-ingress load balancer groups.** The panel writes `loadBalancer.group = + "lb-group--"`. The fields render disabled for worker-managed proxies, and a zod + refine rejects the `lb-group-` prefix elsewhere. +- **Reserved annotations.** `models/proxy_config.go:71-81` derives the `worker_id` DB column from + `annotations`, so clobbering them orphans the worker link. Reserved keys never enter the editor, + are shown as disabled badges, and are re-applied unconditionally in `buildProxyConfig`. + +--- + +## 3. Client plugins + +All 9 frp client plugins have forms (`www/components/frpc/plugins/`). `virtual_net` is the tenth and +is deliberately absent. + +Closed 2026-10-05: **PLG-05** each proxy type is offered only plugins that can work behind it +(`proxy_forms/shared/plugins.ts`, with the reasoning per type; a stored plugin stays selectable) · +**PLG-04** `requestHeaders.set` on the four http(s)2http(s) plugins plus `enableHTTP2` on the two +https ones (`plugins/shared_fields.tsx`). The row was mis-scoped: frp v0.70.1 plugin options have +**no `responseHeaders`** — only `HTTPProxyConfig` does — and a form emitting it would have been +rejected by the strict decoder. `enableHTTP2` was also missing from the TS mirror. + +| ID | Feature | St | D | V | P | +|---|---|---|---|---|---| +| PLG-03 | `virtual_net` plugin form | won't-do | — | — | — | + +> **PLG-03 is blocked by design, not by effort.** `virtual_net` is alpha and gated off by default in +> frp, and the gate (`featureGates`) is applied **process-globally** by the agent +> (`services/client/frpc_service.go:33-37`). A per-client field would silently affect every other +> frpc in that agent. The correct surface would be an agent-level setting in `conf/settings.go` +> (`CLIENT_*` env, like the existing `CLIENT_FEATURES_ENABLE_FUNCTIONS`) — see FC-08. + +--- + +## 4. frpc common config + +Now an RHF form with accordion sections (`www/components/frpc/form/`). Closed: **FC-01** TLS, +**FC-02** connection tuning, **FC-03** `proxyURL`/`connectServerLocalIP`, **FC-04** QUIC, +**FC-05** `natHoleStunServer`/`dnsServer`, **FC-07** log + agent web server, and the +`loginFailExit`/`udpPacketSize`/`metadatas` part of **FC-09**. + +| ID | Feature | St | Why | +|---|---|---|---| +| FC-06 | `auth.{method,token,oidc.*}` | won't-do | frp-panel authenticates through the frps `multiuser` HTTP plugin plus `user` + `metadatas[token]`. frps runs the built-in verifier *as well*, and it passes today only because both ends have an empty token. Setting a token on one side without atomically setting it on every frpc bound to that server drops every tunnel at once — and `update_tunnel.go` writes one client at a time, so a form field guarantees an outage window. See **FS-06** for the replacement proposal. | +| FC-08 | `featureGates`, `virtualNet.address` | won't-do (as a per-client field) | Process-global on the agent, as above. Documented in `www/types/client.ts` so it is visible to anyone auditing from the TS mirror. Belongs in `conf/settings.go` if ever wanted. | +| FC-09 | `start[]` | won't-do | Superseded by frp's per-proxy `enabled`, which the panel already wires. Three overlapping enable mechanisms (`start[]`, `enabled`, `ProxyConfig.Stopped`) is already one too many — see **BUG-09**. `utils/load.go:123-132` keeps the filter for raw-editor compatibility. | +| FC-10 | `includes[]` | won't-do | Resolves paths on the agent's filesystem. frp-panel's model is DB-sourced config pushed over gRPC, and `internal/frpx/config.go` never uses the path-based loader, so the key would be accepted and then silently ignored — worse than absent. | + +--- + +## 5. frps config + +Now an RHF form with accordion sections (`www/components/frps/form/`). Closed: **FS-01** +`vhostHTTPSPort` + `vhostHTTPTimeout` (the P1 row — `https` was an advertised proxy type whose +listener port could not be set), **FS-02** `allowPorts` + `maxPortsPerClient`, **FS-03** webServer + +Prometheus, **FS-04** transport + TLS, **FS-05** SSH tunnel gateway, **FS-07** tcpmux, +**FS-08** log + `detailedErrorsToClient`, **FS-09** `custom404Page`, **FS-10** the timing/size knobs. + +| ID | Feature | St | D | V | P | +|---|---|---|---|---|---| +| FS-06 | `auth.{method,token,oidc.*}` | won't-do | — | — | — | +| **FS-12** | **Panel-managed shared auth token** — replaces FS-06/FC-06. Store one token on the `Server` record and have the backend inject it into the frps blob *and* every child frpc blob in one transaction, alongside `metadatas[token]`. This is the only way to set an frp auth token without an outage window. | gap | 4 | 3 | P2 | + +Closed 2026-10-05: **FS-11** user `httpPlugins[]` entries (`www/components/frps/form/http_plugins.tsx`). + +> **The panel's own entry stays panel-owned.** frps calls the `multiuser` plugin back to authenticate +> every login. The master and the Server agent both rebuild the list with `conf.WithFRPsAuthPlugin` +> (one tested helper; it replaced two inline copies): every entry by that name is dropped and the +> panel's is appended last. The form renders it read-only, submits user entries only, and zod rejects +> a user entry named `multiuser` or a duplicate name. frp calls Login plugins **in list order with +> mutable content**, so a user Login plugin runs before the panel's and can reject or rewrite every +> login — the form says so when `Login` is ticked. +--- + +## 6. Visitors — closed + +Visitor CRUD (**VIS-01**), xtcp tuning fields (**VIS-02**) and the proxy-pairing helper +(**VIS-03**) ship as a section inside the frpc card (`www/components/frpc/visitor_form.tsx`). + +**No new proto and no new DB table were needed** — visitors already cross the wire inside +`UpdateFRPCRequest.config` and `biz/client/update_tunnel.go` already applies them. + +> A top-level `/visitors` page **would** be a D5, not the D4 originally scored. `ProxyConfig` is a +> first-class table with a paginated endpoint; visitors have neither, and `ListClients` cannot +> substitute because `MakeClientShadowed` sets `ConfigContent = nil` on the origin client and the +> list response carries no usable `serverId`. Enumerating visitors across clients needs a new proto +> endpoint or a new table plus a `RebuildVisitorConfigFromClient` twin. + +Two details that are load-bearing and easy to get wrong later: + +- **`serverName` is the raw, unprefixed proxy name and `serverUser` stays empty.** frp builds the + wire target as `BuildTargetServerProxyName(localUser, serverUser, serverName)`, which with an + empty `serverUser` prefixes the visitor's *own* user — and the master forces every client's `user` + to the panel account, so both sides always agree. `serverUser` only matters for cross-*user* + access, which additionally needs `allowUsers` on the proxy. Proven by + `internal/frpx/tunnel_integration_test.go:122-126`. +- **`fallbackTo` names a sibling *visitor*, not a proxy** (`client/visitor/xtcp.go:177`). The + idiomatic target is an stcp visitor with `bindPort: -1`, which is why `ZodVisitorBindPortSchema` + permits negatives and rejects only 0. + +--- + +## 7. Reserved / not-a-gap — do not "fix" these + +| Field | Why | Enforced at | +|---|---|---| +| frpc `serverAddr`, `serverPort` | Derived from the selected server, or from the frps URL. `serverPort` is chosen by protocol: kcp→`KCPBindPort`, quic→`QUICBindPort`, else `BindPort` | `biz/master/client/update_tunnel.go:98-106,137-138` | +| frpc `user` | Set to the panel account name. Also a security boundary — it namespaces proxy names on the wire and drives `allowUsers` | `biz/master/client/update_tunnel.go:143` | +| frpc `metadatas["token"]`, `metadatas["x-vaala-frp-client-id"]` | Panel auth + client identity. **Only these two keys** — the master *merges* rather than replaces, so user keys survive, which is why they are editable in the form | `biz/master/client/update_tunnel.go:145-150`, `defs/const.go:20,23` | +| frpc `transport.protocol` | **Exposed but mediated.** Users can set it, but the backend derives `serverPort` from it and the frps-URL path overwrites it from the URL scheme. Do not add a `serverPort` field alongside it | `biz/master/client/update_tunnel.go:98-150` | +| frps `httpPlugins[]` entry named `multiuser` | The panel's own auth plugin; user entries around it are fine (FS-11) | `conf/frps_auth_plugin.go` (`WithFRPsAuthPlugin`), entry built by `conf.FRPsAuthOption` | +| frps form `publicHost` | **Not an frp field.** A panel-level value, stripped before submit and sent as the `server_ip` request param | `www/components/frps/frps_form.tsx` | +| frpc `store` | Deliberately disabled — a persisted agent-side store would resurrect proxies the master deleted | `internal/frpx/client.go:31-37` | + +--- + +## 8. frp version-tracking rows + +frp is pinned at **v0.70.1** (`go.mod:13`), go 1.25.0 (`go.mod:3`). + +| ID | frp | Feature | St | D | V | +|---|---|---|---|---|---| +| BUMP-04 | 0.67 | Native frpc `clientID`; retire `metadatas["x-vaala-frp-client-id"]` | gap — **recommended won't-do**, see below | 3 | 3 | +| BUMP-03 | 0.66 | OIDC `tokenSource` | won't-do | — | 1 | + +**BUMP-04 adds a failure mode and buys nothing the panel uses.** With `clientID` set, frps enforces +one online session per `(user, clientID)` and refuses a login from a new run ID while the old session +is still registered (`server/registry/registry.go:94`). A new run ID is exactly what an agent restart, +or the agent recreating its frpc when the common config changes, produces. frpc's `loginFailExit` +defaults to `true` (`pkg/config/v1/client.go:88`), so losing that race makes frpc **exit** with the +agent's whole tunnel set down. Nothing in the panel reads frp's clientID. And the metadata key is not +dead: it is written at `biz/master/client/update_tunnel.go:150` and read nowhere in this repo, but +since FS-11 user frps plugins receive login metadata and can use it to identify a client. Retiring it +would break them. + +**BUMP-07 is closed.** `v2` is offered only when both the client agent and the server agent report +frp ≥ v0.69: agents now send `ClientVersion.FrpVersion` (CAP-01, `frpx.Version()`), the form reads it +through `getClientsStatus`, and `biz/master/client/wire_protocol.go` re-checks it on every save. The +check runs outside the frpsUrl branch, always refuses `v2` for an external frps, and fails closed for +an offline agent or one too old to report its version. That last part means a client set to `v2` +can only be saved while both agents are online. + +**BUMP-03 is unreachable**, not merely parked: `internal/frpx/{client,server}.go` pass a nil +`UnsafeFeatures`, and frp's only unsafe feature is `TokenSourceExec`. Only `tokenSource.file` could +ever work, and it depends on FC-06/FS-06, which are won't-do. + +### Closed or rejected in this pass + +`BUMP-07` wireProtocol v2 — **done** 2026-10-05, see above · `BUMP-00` frp v0.65.0 → v0.70.1 — **done** (commit `eec2a63`) · `BUMP-01` per-proxy `enabled` — +**done**, see BUG-09 for the follow-on · `BUMP-02` `loadBalancer` for https — **deleted**, +`loadBalancer` lives on `ProxyBaseConfig` so it always applied to every type; folded into PF-06 · +`BUMP-05` frpc `[store]` — **rejected**, decision recorded in `internal/frpx/client.go:31-37` · +`BUMP-06` `noweb` build tag — **rejected**, gates only frp's own `cmd/frps` and `cmd/frpc` mains, +which this codebase never imports, so there are zero bytes to gain · `BUMP-08` frps dashboard API v2 +— **rejected**, the panel collects stats in-process via `mem.StatsCollector`, and its own +`HistoryProxyStats` survives frps restarts, spans servers and is tenant-scoped, none of which frps's +in-memory history does; adopting it would be a regression · `BUMP-09` binary WS frames — **done by +the bump**, no panel surface · `BUMP-10` duplicate name rejection — **done**, see BUG-10. + +--- + +## Appendix: maintaining this file + +**This file is hand-maintained. There is deliberately no generator.** The question a generator would +have to answer is *"is this field exposed in the UI?"*, and that is not mechanically derivable — +fields reach forms through zod schema keys, JSX `name=` props, object spreads, and the raw editor. + +**One thing is automated: `hack/frpdrift`** (built 2026-10-05). It reflects over +`v1.ClientCommonConfig`, `v1.ServerConfig`, every proxy and visitor type and every client and visitor +plugin, emits the flat set of JSON field paths the strict decoder accepts, and diffs it against +`hack/frpdrift/known.txt` — the set accepted at the last review. It answers exactly one question: + +> *Has frp gained (or dropped) a field since this matrix was last reviewed?* + +It is what would have caught `featureGates`, `virtualNet`, `clientID`, `store`, +`HTTPProxyConfig.responseHeaders`, `XTCPProxyConfig.natTraversal` and +`XTCPVisitorConfig.natTraversal.disableAssistedAddrs` — all of which were absent from both the TS +mirror and this matrix until the v0.70.1 review. + +```bash +go run ./hack/frpdrift # exit 1 and list +added / -removed paths on drift +go run ./hack/frpdrift -write # after reviewing each one against this file +``` + +A baseline file rather than "every name mentioned in this file", because closed rows are deleted from +here by convention, so a name-mention diff would grow noisier with every closed row. The check itself +is not part of CI on purpose — drift is a review prompt, not a failure — but the walker's tests are, +and `TestCurrentPathsAgainstLinkedFrp` pins the facts this file relies on (TLS inlined under +`transport.tls`, no `responseHeaders` on plugins). It cannot detect a new proxy, visitor or plugin +*type*: frp's type registries are unexported, so the type list in `main.go` is maintained by hand. + +**Conventions** + +1. A PR that closes a row **deletes that row in the same PR**, moving it to the section's + "Closed in this pass" note if the reasoning is worth keeping. +2. Refresh the header stamp on every edit. A stale stamp is an honest signal that the file is stale. +3. **Cap this file at ~80 rows.** +4. Scores are defaults, not mandates. Override a bucket when you have a reason, and write the reason. +5. **Any new structured form must merge over the stored config, never submit `values` alone** — see + the third numbered point at the top of this file. + +## Verification + +`.github/workflows/fork-checks.yml` is the fork's own gate (a new file, so it cannot conflict with +upstream). It runs: + +```bash +mkdir -p cmd/frpp/out && touch cmd/frpp/out/.gitkeep # once per clone; the go:embed target +go build ./... +go vet ./conf/... ./internal/... ./services/... ./utils/... ./biz/... +go test ./conf/... ./models/... ./services/wg/... ./services/dao/... ./utils/... ./internal/... ./biz/... +go test -tags integration -run TestTunnelMatrix -timeout 5m ./internal/frpx/... +cd www && pnpm test && pnpm lint && pnpm exec next build +``` + +Until 2026-10-05 the workflow had no frontend job at all, although this section listed one. +`pnpm test` runs `www/test/*.test.mjs` on Node's built-in test runner; `www/test/ts-hooks.mjs` resolves +the `@/` alias and transpiles TypeScript with the project's own compiler, so it adds no dependency. +It covers pure modules only — there is no DOM. + +Note the package list rather than `./...`: `services/workerd`'s `TestRunWorker` hardcodes +`/home/coder/...` paths and only passes in the original author's dev container. That is pre-existing. + +`TestTunnelMatrix` (`internal/frpx/tunnel_integration_test.go`) stands up a live frps + frpc in one +process and pushes real bytes through 7 of the 8 proxy types (xtcp is skipped on loopback) and +stcp / sudp visitors, then checks that `enabled: false` takes effect on a hot update. It is the real +gate for anything touching config shape. diff --git a/README.md b/README.md index 40665aee..9e770bde 100644 --- a/README.md +++ b/README.md @@ -8,6 +8,43 @@ FRP-Panel is a visualization management dashboard for FRP, offering centralized English | [中文](./README_zh.md) +## About this fork + +This repository, **[felix-homelab/frp-panel](https://github.com/felix-homelab/frp-panel)**, is a fork of +**[VaalaCat/frp-panel](https://github.com/VaalaCat/frp-panel)**, based on upstream v0.1.37. Everything +else on this page describes the original project, which still applies. What this fork changes: + +- **A newer frp.** Built on frp **v0.70.1** (upstream: v0.65.0) and Go 1.25. +- **Forms instead of raw JSON.** Every proxy type has a form — https, tcpmux, xtcp and sudp included — + with sections for encryption, compression, bandwidth limits, health checks, load balancing and + headers. Visitors, all nine client plugins, and the full client and server settings have forms too. + The raw JSON editors are still there for anything a form does not cover. +- **Saving no longer loses settings.** Saving a form used to delete settings it did not show, drop + visitor options, or quietly accept configs that later broke an agent. These cases are fixed, and + configs with duplicate names or invalid server settings are now refused with an error instead. +- **Switches that actually switch.** Turning a proxy off takes effect right away instead of after the + agent restarts, and the proxy list shows it as `disabled`. +- **Safer advanced options.** Newer frp features such as wire protocol v2 are only offered when both + agents involved run a version that supports them. +- **Your own frps plugins.** Add your own frp server plugins next to the panel's built-in one. +- **Its own downloads.** Releases contain binaries for Linux, macOS, Windows and Android, and Docker + images are published to **`ghcr.io/felix-homelab/frp-panel`** (`:latest`, `:`, and + `-workerd` variants). Upstream's Docker instructions work unchanged with the image name swapped + for `vaalacat/frp-panel`. Images start with the first release after v0.10.1. + +Good to know: + +- **Version numbers are this fork's own.** `v0.10.x` is not an upstream release. +- **Run the Master, Servers and Clients from this fork together.** An upstream agent may reject settings + its older frp does not know (for example, a proxy's *Enabled* switch needs frp v0.66 or newer). +- **The install scripts, in-app upgrade and "Download" links still fetch upstream's builds.** Until + that is changed, download this fork's binaries from its + [releases page](https://github.com/felix-homelab/frp-panel/releases). + +Details: [CHANGELOG.md](./CHANGELOG.md) (per release, including the full difference to upstream) · +[ROADMAP.md](./ROADMAP.md) (what is being worked on) · [FEATURE-MATRIX.md](./FEATURE-MATRIX.md) (frp +features the UI does not cover yet). +
VaalaCat/frp-panel | Trendshift diff --git a/README_zh.md b/README_zh.md index e8bbbf20..8e045b1a 100644 --- a/README_zh.md +++ b/README_zh.md @@ -8,6 +8,13 @@ FRP-Panel 是一款基于 FRP 的可视化管理面板,提供中心化配置 中文文档 | [English](./README.md) +> **关于本分支:** 本仓库 [felix-homelab/frp-panel](https://github.com/felix-homelab/frp-panel) 是 +> [VaalaCat/frp-panel](https://github.com/VaalaCat/frp-panel) 的分支(基于上游 v0.1.37)。主要区别:基于 frp +> v0.70.1、所有代理类型 / 访问者 / 客户端插件以及 frpc、frps 设置均提供表单、修复了保存时丢失配置等问题,Docker 镜像发布在 +> `ghcr.io/felix-homelab/frp-panel`(v0.10.1 之后的版本起;用法与上游 `vaalacat/frp-panel` 相同)。安装脚本、应用内升级和"下载"链接目前仍指向上游版本,请从本分支的 +> [Releases](https://github.com/felix-homelab/frp-panel/releases) 下载。详见英文 [README](./README.md#about-this-fork) +> 与 [CHANGELOG.md](./CHANGELOG.md)。 + diff --git a/ROADMAP.md b/ROADMAP.md new file mode 100644 index 00000000..4c3e041d --- /dev/null +++ b/ROADMAP.md @@ -0,0 +1,100 @@ +# Roadmap + +> **frp-panel fork (`felix-homelab/frp-panel`) · frp v0.70.1 · go 1.25.0 · updated 2026-10-05** + +This file tracks **order and progress**. [`FEATURE-MATRIX.md`](FEATURE-MATRIX.md) stays the source of +truth for **what** each frp-related row is and **why** it is scored the way it is — read the row there +before starting one here. IDs are shared between the two files; rows that exist only here (bugs found +while working the backlog, tooling, release work) are marked *new*. User-facing changes go into +[`CHANGELOG.md`](CHANGELOG.md) under `[Unreleased]` in the same change. + +**Status:** `todo` · `wip` · `done` · `blocked` (waiting on a decision, named in the row) · `dropped` +(with the reason). + +**Done means** (from the HU definition of done): builds cleanly with no new warnings, existing tests +pass, the change has tests that cover failure paths and not just the happy path, and anything that +could not be verified is written down here. + +--- + +## M1 — Correctness (P0) + +| ID | Item | St | Verification | +|---|---|---|---| +| BUG-08 | `UpdateWorkerLoadBalancerGroup` round-tripped the http proxy through `msg.NewProxy` and dropped `enabled`, `localIP`, `localPort`, `plugin`, `healthCheck.*`, `transport.proxyProtocolVersion` | **done** — mutates the `*HTTPProxyConfig` in place; the now-unused `frpx.NewProxyMsg` is deleted | `biz/master/proxy/update_proxy_config_test.go` (fails on the old code) | +| BUG-12 *new* | `enabled: false` was honoured only when the frpc service is **created**; every later update went through `Service.UpdateAllConfigurer`, which does not run frp's `FilterClientConfigurers` (frp `client/service.go:183` vs `:368`) | **done** — `frpx.UpdateClientConfigurers` applies frp's filter on every hot update; the master reports `disabled` instead of `error` for such a proxy | `TestTunnelMatrix/enabled=false_on_hot_update` (fails without the fix: proxy still up after 30s) + `TestLocalProxyStatus` | +| BUG-13 *new* | The "Add" buttons in `www/components/base/list-input.tsx` had no `type`, so they were submit buttons; in the frpc form, adding a metadata key **pushed the whole client config to the agent** | **done** — `type="button"` on all three | lint + type-check only — DOM behaviour, no DOM test environment | +| BUG-14 *new* | `RebuildProxyConfigFromClient` looked the old row up by `OriginClientID == child client id`, which never matches: every rebuild re-created every row under a new ID, and a stopped row was duplicated when its proxy reappeared in the client config | **done** — looks up by `ClientID`; existing duplicates collapse on the next rebuild, so no data migration | `services/dao/proxy_test.go` on sqlite (2 of 5 fail on the old code) | +| BUG-09 | frp `enabled` and panel `ProxyConfig.Stopped` are independent and disagree. Editing a stopped proxy also resurrects it (`create_proxy_config.go` never reads `Stopped`) | **blocked** — needs a decision on which flag is authoritative, see [Decisions](#decisions-needed) | — | + +## M2 — UI gaps + +| ID | Item | St | Verification | +|---|---|---|---| +| PLG-05 | Offer each proxy type only plugins that can work on it | **done** — `proxy_forms/shared/plugins.ts`; a stored unsupported plugin stays selectable | `www/test/proxy-plugins.test.mjs` | +| PLG-04 | **Scope corrected.** frp v0.70.1 plugins have `requestHeaders` but **no `responseHeaders`**. Real scope: `requestHeaders.set` on the four http(s)2http(s) plugins, plus `enableHTTP2` on the two https ones (also missing from the TS mirror) | **done** — `plugins/shared_fields.tsx`, i18n in all 4 locales | lint + type-check; a `hack/frpdrift` test pins "no `responseHeaders` on plugins" | +| FS-11 | frps `httpPlugins[]` user entries; the panel's `multiuser` entry stays read-only | **done** — `frps/form/http_plugins.tsx`; the two backend copies of strip-and-append are one helper, `conf.WithFRPsAuthPlugin` | `conf/frps_auth_plugin_test.go` + `www/test/frps-http-plugins.test.mjs` | + +## M3 — Version-gated migrations + +| ID | Item | St | Verification | +|---|---|---|---| +| CAP-01 *new* | Agents report their frp version (`ClientVersion.FrpVersion`, `frpx.Version()`); one version gate in `internal/frpx/version.go`. Agents that don't report it count as too old | **done** — proto regenerated with the pinned toolchain (protoc 3.21.11, protoc-gen-go 1.36.8, protoc-gen-go-grpc 1.5.1, protobuf-ts 2.9.3); unchanged files came out byte-identical | `internal/frpx/version_test.go` | +| BUMP-07 | Finish `transport.wireProtocol: v2` | **done** — the form offers v2 only when both agents report frp ≥ v0.69; `biz/master/client/wire_protocol.go` re-checks on every save, outside the frpsUrl branch, and always refuses an external frps. **Trade-off:** a client set to v2 can only be saved while both agents are online | `wire_protocol_test.go` (no ping unless v2) + `www/test/wire-protocol.test.mjs` | +| BUMP-04 | Native frpc `clientID`; retire `metadatas["x-vaala-frp-client-id"]` | **blocked** — **recommend won't-do**: with `clientID`, frps refuses a login from a new run ID while the old session is registered, and frpc's `loginFailExit` defaults to `true`, so an agent restart can take every tunnel down. The metadata key is now useful to FS-11 user plugins. See [Decisions](#decisions-needed) | — | + +## M4 — Features + +| ID | Item | St | Verification | +|---|---|---|---| +| FS-12 | Panel-managed shared frp auth token: one token on the `Server` record, injected into the frps blob and every child frpc blob in one transaction | todo — **design first**: a new `Server` column (AutoMigrate), and a rollout order across agents. frps restarts on any config change, so some reconnect window exists either way; the design has to bound it | — | +| UI-01 *new, from `notes.md`* | A settings editor in the web UI for panel/agent settings — e.g. the install location. Today these are env-only (`conf/settings.go`, ~39 settings) | todo — scope it first: which settings are safe to change at runtime, and where an agent-side setting would live | — | + +## M5 — Tooling, CI, docs + +| ID | Item | St | Verification | +|---|---|---|---| +| TOOL-01 | `hack/frpdrift`: frp config field paths vs a reviewed baseline (`known.txt`, 428 paths at v0.70.1) | **done** | `hack/frpdrift/walk_test.go`; drift check exits 1 on a stale baseline | +| TOOL-02 *new* | `fork-checks.yml` had no frontend job although the matrix said it did; `biz/`, `services/dao` and `hack/` tests were not run | **done** — frontend job (`pnpm test`, `pnpm lint`, `next build`), Go test list extended | **not run on GitHub yet** — the same commands pass locally (frontend tests also on Node 20 in Docker) | +| TOOL-03 *new* | No frontend test runner | **done** — `pnpm test`: Node's built-in runner + `www/test/ts-hooks.mjs`, which reuses the project's TypeScript. No new dependency. Pure modules only (no DOM) | 17 tests on Node 24 and Node 20 | +| DOC-01 *new* | Matrix and runbook corrections | **done** — closed rows moved to "Closed" notes, PLG-04 scope, stale line refs, Verification section, runbook §3 (it still said four types and visitors had no UI) | — | +| DOC-02 *new* | `CHANGELOG.md` per tag, with the difference to upstream; README "About this fork" (+ a short note in `README_zh.md`) | **done** — `release.yml` now publishes the tag's CHANGELOG section as release notes and **refuses to release a tag without one** (`.github/scripts/changelog-section.sh`) | script tested locally (present / missing / empty section); workflow not run yet | + +## M6 — Release and distribution *new* + +Found while writing the changelog. Verified in code; the GitHub-side facts come from the public API. + +| ID | Item | St | Verification | +|---|---|---|---| +| REL-01 | **Everything that downloads frp-panel still points at upstream**: `install.sh:63-86`, `install.ps1:6`, the join commands the UI generates (`www/lib/consts.ts:316,329`, which fetch upstream's `install.sh` from `raw.githubusercontent.com/VaalaCat/...`), the OTA self-upgrade (`biz/common/upgrade/download_url.go:21`) and three UI download links. An in-app upgrade of a fork agent therefore installs **upstream's** build — frp v0.65, without this fork's fixes | **blocked** — see [Decisions](#decisions-needed). Your note "install scripts hosted on the same endpoint as frp web panel" (`notes.md`) is the clean fix for the install-script half | — | +| REL-02 | The fork has **no `latest` tag or release**, although the "Refresh rolling `latest` release" step reported success on v0.10.1. `install.sh` downloads `releases/download/latest/…`, so pointing it at the fork today would 404 | todo — investigate the workflow run before REL-01 lands | — | +| REL-04 *new* | The fork published no container images: upstream's ko steps need Docker Hub secrets and push to `docker.io/vaalacat/frp-panel` | **done** — `release.yml` builds them like upstream (ko, `.ko.yaml` / `.ko.workerd.yaml`) and pushes `ghcr.io/felix-homelab/frp-panel:{,latest,-workerd,latest-workerd}` with the job's own token. **Check after the first release:** that the GHCR package is public; new packages can start out private | local `ko build --push=false` for all alpine platforms and the workerd variant; the amd64 image ran `version` (frp 0.70.1) with the source label set; `actionlint` + shellcheck clean. The real push is **not run yet** | +| REL-03 | Upstream's tag workflows (`Master Release`, `Version Tag Release`, workerd docker, VitePress) still run on this fork and fail, contradicting the comment in `release.yml` that they are disabled | todo — disable them repo-side (Actions → Workflows → Disable); a repo setting, nothing to commit | — | + +--- + +## Decisions needed + +1. **BUG-09 — which flag is authoritative?** + - **A. `Stopped` is authoritative** *(recommended)*. It works on every agent version, because a + stopped proxy is removed from the config rather than relying on the agent to skip it. The form's + `enabled` switch goes away (frp's field stays reachable through the raw editor, and since BUG-12 + it now works and shows as `disabled`). Editing a stopped proxy keeps it stopped. + - **B. `enabled` is authoritative**, and `Stopped` is derived from it. This is closer to frp, but it + needs a data migration of every stopped row, and agents older than frp v0.66 reject the key. + - **C. Keep both and sync them on every write path.** This adds more places that can drift. +2. **BUMP-04 — accept won't-do?** Recommended. The reasons are recorded in `FEATURE-MATRIX.md` §8. +3. **REL-01 — where should fork installs and upgrades come from?** Recommended: `felix-homelab/frp-panel` + for the OTA upgrade and download links, and the master serving its own `install.sh` / `install.ps1` + (your note) for the join commands. This needs REL-02 fixed first. +4. **What next: FS-12 or UI-01?** Both need a short design pass before code. + +## Progress log + +- **2026-10-05** — Roadmap created. Baseline was green (build, vet, unit tests, `TestTunnelMatrix`, + `pnpm lint` with 11 pre-existing `react-hooks/exhaustive-deps` warnings, `next build`). Closed: + BUG-08, BUG-12, BUG-13, BUG-14, PLG-04, PLG-05, FS-11, CAP-01, BUMP-07, TOOL-01–03, DOC-01, DOC-02. + Found and recorded: BUG-12–14, REL-01–03, the PLG-04 scope error, the BUMP-04 hazard. Final state: + every Go package in the CI list passes, as do the tunnel matrix and 17 frontend tests; lint has no + new findings and the build has no new warnings. Nothing is committed yet: the work is on branch + `improvement/backlog-roadmap`, uncommitted. diff --git a/biz/client/get_proxy_info.go b/biz/client/get_proxy_info.go index 4f3b3b78..5609ed2a 100644 --- a/biz/client/get_proxy_info.go +++ b/biz/client/get_proxy_info.go @@ -3,12 +3,41 @@ package client import ( "fmt" + "github.com/VaalaCat/frp-panel/internal/frpx" "github.com/VaalaCat/frp-panel/pb" "github.com/VaalaCat/frp-panel/services/app" "github.com/VaalaCat/frp-panel/utils/logger" "github.com/samber/lo" ) +// lookupProxyStatus resolves a proxy by whichever name form frpc happens to key it by. +// +// The master sends the wire name ("{user}.{name}", see biz/master/proxy/get_proxy_config.go), +// but the client-side status map is keyed by the *config* name, and frp moved where the +// user prefix is applied in v0.68: through v0.67 Complete rewrote Name to "{user}.{name}", +// so the key was the wire name; from v0.68 the name stays raw and prefixing happens at the +// wire layer. Trying both forms keeps this working against either frp generation, which +// matters during a staged rollout when an upgraded agent may still be talking to an +// older master (or the reverse). +func lookupProxyStatus(cli app.ClientHandler, proxyName string) (*frpx.ProxyWorkingStatus, bool) { + if st, ok := cli.GetProxyStatus(proxyName); ok { + return st, true + } + + var user string + if common := cli.GetCommonCfg(); common != nil { + user = common.User + } + if user == "" { + return nil, false + } + + if st, ok := cli.GetProxyStatus(frpx.RawProxyName(user, proxyName)); ok { + return st, true + } + return cli.GetProxyStatus(frpx.WireProxyName(user, proxyName)) +} + func GetProxyConfig(c *app.Context, req *pb.GetProxyConfigRequest) (*pb.GetProxyConfigResponse, error) { var ( clientID = req.GetClientId() @@ -22,7 +51,7 @@ func GetProxyConfig(c *app.Context, req *pb.GetProxyConfigRequest) (*pb.GetProxy logger.Logger(c).Errorf("cannot get client, clientID: [%s], serverID: [%s]", clientID, serverID) return nil, fmt.Errorf("cannot get client") } - workingStatus, ok := cli.GetProxyStatus(proxyName) + workingStatus, ok := lookupProxyStatus(cli, proxyName) if !ok { logger.Logger(c).Errorf("cannot get proxy status, client: [%s], server: [%s], proxy name: [%s]", clientID, serverID, proxyName) return nil, fmt.Errorf("cannot get proxy status") diff --git a/biz/master/auth/handler.go b/biz/master/auth/handler.go index b7a659b7..3e1ef55d 100644 --- a/biz/master/auth/handler.go +++ b/biz/master/auth/handler.go @@ -3,11 +3,11 @@ package auth import ( "net/http" + "github.com/VaalaCat/frp-panel/internal/frpx" "github.com/VaalaCat/frp-panel/services/app" "github.com/VaalaCat/frp-panel/services/cache" "github.com/VaalaCat/frp-panel/services/dao" "github.com/VaalaCat/frp-panel/utils/logger" - plugin "github.com/fatedier/frp/pkg/plugin/server" "github.com/gin-gonic/gin" ) @@ -45,8 +45,8 @@ func MakeGinHandlerFunc(appInstance app.Application, handler HandlerFunc) gin.Ha } func HandleLogin(ctx *app.Context) (interface{}, error) { - var r plugin.Request - var content plugin.LoginContent + var r frpx.PluginRequest + var content frpx.PluginLoginContent r.Content = &content if err := ctx.GetGinCtx().BindJSON(&r); err != nil { return nil, &HTTPError{ @@ -55,7 +55,7 @@ func HandleLogin(ctx *app.Context) (interface{}, error) { } } - var res plugin.Response + var res frpx.PluginResponse token := content.Metas["token"] if len(content.User) == 0 || len(token) == 0 { res.Reject = true diff --git a/biz/master/client/update_tunnel.go b/biz/master/client/update_tunnel.go index 10567771..fde06f92 100644 --- a/biz/master/client/update_tunnel.go +++ b/biz/master/client/update_tunnel.go @@ -140,6 +140,18 @@ func UpdateFrpcHander(c *app.Context, req *pb.UpdateFRPCRequest) (*pb.UpdateFRPC cli.FrpsUrl = urlToParse } + // Outside the frpsUrl branch on purpose: it must also catch v2 on the panel-managed path. + agentID := lo.Ternary(len(cli.OriginClientID) > 0, cli.OriginClientID, cli.ClientID) + if err := checkWireProtocol(cliCfg.Transport.WireProtocol, len(cli.FrpsUrl) > 0, + func() string { return agentFrpVersion(c, agentID) }, + func() string { return agentFrpVersion(c, serverID) }, + ); err != nil { + logger.Logger(c).WithError(err).Errorf("rejected wire protocol, client: [%s], server: [%s]", reqClientID, serverID) + return &pb.UpdateFRPCResponse{ + Status: &pb.Status{Code: pb.RespCode_RESP_CODE_INVALID, Message: err.Error()}, + }, err + } + cliCfg.User = userInfo.GetUserName() if cliCfg.Metadatas == nil { diff --git a/biz/master/client/wire_protocol.go b/biz/master/client/wire_protocol.go new file mode 100644 index 00000000..3223be01 --- /dev/null +++ b/biz/master/client/wire_protocol.go @@ -0,0 +1,63 @@ +package client + +import ( + "fmt" + + "github.com/VaalaCat/frp-panel/internal/frpx" + "github.com/VaalaCat/frp-panel/pb" + "github.com/VaalaCat/frp-panel/services/app" + "github.com/VaalaCat/frp-panel/services/rpc" + "google.golang.org/protobuf/proto" +) + +// checkWireProtocol is the authoritative gate for frpc transport.wireProtocol (BUMP-07). +// The form offers v2 only when it looks safe; this check is what makes it safe, because +// a wrong v2 fails silently -- the tunnel just never comes up: +// +// - frpc older than v0.69 rejects the unknown key, so the agent drops the whole config; +// - frps older than v0.69 cannot parse a v2 session. +// +// An external frps (frpsUrl) has no agent to ask, so v2 is always refused for it. The +// version lookups are functions so that nothing is pinged unless v2 is requested. +func checkWireProtocol(wireProtocol string, externalFrps bool, clientFrpVersion, serverFrpVersion func() string) error { + switch wireProtocol { + case "", "v1": + return nil + case "v2": + default: + // frp's own validation would reject it on the agent, after the old config is gone. + return fmt.Errorf("invalid transport.wireProtocol %q, must be v1 or v2", wireProtocol) + } + + if externalFrps { + return fmt.Errorf("transport.wireProtocol v2 is not allowed with an external frps url: its frp version cannot be verified") + } + if v := clientFrpVersion(); !frpx.SupportsWireProtocolV2(v) { + return fmt.Errorf("transport.wireProtocol v2 needs frp >= v0.69 on the client agent, which reports %s", describeFrpVersion(v)) + } + if v := serverFrpVersion(); !frpx.SupportsWireProtocolV2(v) { + return fmt.Errorf("transport.wireProtocol v2 needs frp >= v0.69 on the server agent, which reports %s", describeFrpVersion(v)) + } + return nil +} + +func describeFrpVersion(v string) string { + if v == "" { + return "no frp version (offline, or older than this check)" + } + return fmt.Sprintf("frp %s", v) +} + +// agentFrpVersion asks a connected agent which frp it is built with. "" means unknown: +// the agent is offline, did not answer, or predates pb.ClientVersion.FrpVersion. +func agentFrpVersion(c *app.Context, agentID string) string { + resp, err := rpc.CallClient(c, agentID, pb.Event_EVENT_PING, &pb.CommonRequest{}) + if err != nil || resp == nil { + return "" + } + v := &pb.ClientVersion{} + if err := proto.Unmarshal(resp.GetData(), v); err != nil { + return "" + } + return v.GetFrpVersion() +} diff --git a/biz/master/client/wire_protocol_test.go b/biz/master/client/wire_protocol_test.go new file mode 100644 index 00000000..34deeaf5 --- /dev/null +++ b/biz/master/client/wire_protocol_test.go @@ -0,0 +1,67 @@ +package client + +import ( + "strings" + "testing" +) + +func fixed(v string) func() string { return func() string { return v } } + +func mustNotCall(t *testing.T) func() string { + return func() string { + t.Helper() + t.Fatal("agent was pinged although wireProtocol v2 was not requested") + return "" + } +} + +func TestCheckWireProtocolV1NeverPings(t *testing.T) { + for _, wire := range []string{"", "v1"} { + if err := checkWireProtocol(wire, true, mustNotCall(t), mustNotCall(t)); err != nil { + t.Errorf("wireProtocol %q rejected: %v", wire, err) + } + } +} + +func TestCheckWireProtocolRejectsUnknownValue(t *testing.T) { + for _, wire := range []string{"v3", "V2", "2"} { + err := checkWireProtocol(wire, false, mustNotCall(t), mustNotCall(t)) + if err == nil || !strings.Contains(err.Error(), "must be v1 or v2") { + t.Errorf("wireProtocol %q: err = %v, want an invalid-value error", wire, err) + } + } +} + +func TestCheckWireProtocolV2(t *testing.T) { + cases := []struct { + name string + external bool + client, server string + wantErr string // substring; "" means accepted + }{ + {"both new enough", false, "0.70.1", "0.69.0", ""}, + {"external frps", true, "0.70.1", "0.70.1", "external frps"}, + {"old client agent", false, "0.68.0", "0.70.1", "client agent"}, + {"client agent offline or too old to report", false, "", "0.70.1", "no frp version"}, + {"old server agent", false, "0.70.1", "0.65.0", "server agent"}, + {"server agent unknown", false, "0.70.1", "", "server agent"}, + } + for _, tc := range cases { + t.Run(tc.name, func(t *testing.T) { + err := checkWireProtocol("v2", tc.external, fixed(tc.client), fixed(tc.server)) + switch { + case tc.wantErr == "" && err != nil: + t.Fatalf("rejected: %v", err) + case tc.wantErr != "" && (err == nil || !strings.Contains(err.Error(), tc.wantErr)): + t.Fatalf("err = %v, want one mentioning %q", err, tc.wantErr) + } + }) + } +} + +// An external frps must be refused before anything is pinged: there is no agent behind it. +func TestCheckWireProtocolExternalDoesNotPing(t *testing.T) { + if err := checkWireProtocol("v2", true, mustNotCall(t), mustNotCall(t)); err == nil { + t.Fatal("v2 accepted for an external frps") + } +} diff --git a/biz/master/proxy/get_proxy_config.go b/biz/master/proxy/get_proxy_config.go index beb42656..80500a6f 100644 --- a/biz/master/proxy/get_proxy_config.go +++ b/biz/master/proxy/get_proxy_config.go @@ -31,10 +31,10 @@ func GetProxyConfig(c *app.Context, req *pb.GetProxyConfigRequest) (*pb.GetProxy return nil, err } - resp := &pb.GetProxyConfigResponse{WorkingStatus: &pb.ProxyWorkingStatus{ - Status: lo.ToPtr("stopped"), - }} - if !proxyConfig.Stopped { + resp := &pb.GetProxyConfigResponse{WorkingStatus: &pb.ProxyWorkingStatus{}} + if status, known := localProxyStatus(proxyConfig); known { + resp.WorkingStatus.Status = lo.ToPtr(status) + } else { if err := rpc.CallClientWrapper(c, proxyConfig.OriginClientID, pb.Event_EVENT_GET_PROXY_INFO, &pb.GetProxyConfigRequest{ ClientId: lo.ToPtr(proxyConfig.ClientID), ServerId: lo.ToPtr(proxyConfig.ServerID), @@ -68,3 +68,22 @@ func GetProxyConfig(c *app.Context, req *pb.GetProxyConfigRequest) (*pb.GetProxy WorkingStatus: resp.GetWorkingStatus(), }, nil } + +// localProxyStatus answers the status question from the master's own record when the +// agent cannot: a panel-stopped proxy is not in the agent's config at all, and an +// `enabled: false` one is filtered out by frp, so asking the agent for either only +// yields a lookup miss -- which would be shown as "error". +func localProxyStatus(p *models.ProxyConfig) (status string, known bool) { + if p.Stopped { + return "stopped", true + } + cfg, err := p.GetTypedProxyConfig() + if err != nil || cfg.ProxyConfigurer == nil { + // Unreadable content: let the agent's answer decide, as before. + return "", false + } + if enabled := cfg.GetBaseConfig().Enabled; enabled != nil && !*enabled { + return "disabled", true + } + return "", false +} diff --git a/biz/master/proxy/get_proxy_config_test.go b/biz/master/proxy/get_proxy_config_test.go new file mode 100644 index 00000000..5faa1e02 --- /dev/null +++ b/biz/master/proxy/get_proxy_config_test.go @@ -0,0 +1,38 @@ +package proxy + +import ( + "testing" + + "github.com/VaalaCat/frp-panel/models" +) + +func TestLocalProxyStatus(t *testing.T) { + cases := []struct { + name string + stopped bool + content string + wantStatus string + wantKnown bool + }{ + {"stopped wins over content", true, `{"name":"a","type":"tcp","enabled":true}`, "stopped", true}, + {"stopped with unreadable content", true, `not json`, "stopped", true}, + {"enabled false", false, `{"name":"a","type":"tcp","enabled":false}`, "disabled", true}, + {"enabled true asks the agent", false, `{"name":"a","type":"tcp","enabled":true}`, "", false}, + {"enabled absent asks the agent", false, `{"name":"a","type":"tcp"}`, "", false}, + {"unreadable content asks the agent", false, `not json`, "", false}, + {"empty content asks the agent", false, ``, "", false}, + {"unknown proxy type asks the agent", false, `{"name":"a","type":"nope","enabled":false}`, "", false}, + } + for _, tc := range cases { + t.Run(tc.name, func(t *testing.T) { + p := &models.ProxyConfig{ProxyConfigEntity: &models.ProxyConfigEntity{ + Stopped: tc.stopped, + Content: []byte(tc.content), + }} + status, known := localProxyStatus(p) + if status != tc.wantStatus || known != tc.wantKnown { + t.Fatalf("localProxyStatus = (%q, %v), want (%q, %v)", status, known, tc.wantStatus, tc.wantKnown) + } + }) + } +} diff --git a/biz/master/proxy/update_proxy_config.go b/biz/master/proxy/update_proxy_config.go index f7672ef8..2684a97c 100644 --- a/biz/master/proxy/update_proxy_config.go +++ b/biz/master/proxy/update_proxy_config.go @@ -13,7 +13,6 @@ import ( "github.com/VaalaCat/frp-panel/utils" "github.com/VaalaCat/frp-panel/utils/logger" v1 "github.com/fatedier/frp/pkg/config/v1" - "github.com/fatedier/frp/pkg/msg" "github.com/samber/lo" ) @@ -139,27 +138,29 @@ func UpdateProxyConfig(c *app.Context, req *pb.UpdateProxyConfigRequest) (*pb.Up }, nil } +// UpdateWorkerLoadBalancerGroup re-derives the load balancer group of a worker-ingress +// http proxy, in place. Anything that is not a worker-ingress http proxy is returned +// untouched. +// +// It must not rebuild the config through msg.NewProxy: that message carries only the +// wire subset, and the round-trip used to drop enabled, localIP, localPort, plugin, +// healthCheck and transport.proxyProtocolVersion on every update (BUG-08). func UpdateWorkerLoadBalancerGroup(typedProxyCfg v1.TypedProxyConfig) v1.TypedProxyConfig { - annotations := typedProxyCfg.GetBaseConfig().Annotations - workerId := "" - if len(annotations) > 0 { - if annotations[defs.FrpProxyAnnotationsKey_Ingress] != "" && len(annotations[defs.FrpProxyAnnotationsKey_WorkerId]) > 0 { - workerId = annotations[defs.FrpProxyAnnotationsKey_WorkerId] - } + httpProxyCfg, ok := typedProxyCfg.ProxyConfigurer.(*v1.HTTPProxyConfig) + if !ok { + return typedProxyCfg } - httpProxyCfg := &v1.HTTPProxyConfig{} - msg := &msg.NewProxy{} - typedProxyCfg.ProxyConfigurer.MarshalToMsg(msg) - httpProxyCfg.UnmarshalFromMsg(msg) - if len(workerId) > 0 { - httpProxyCfg.LoadBalancer = v1.LoadBalancerConfig{ - Group: models.HttpIngressLBGroup(workerId, httpProxyCfg), - GroupKey: workerId, - } + annotations := httpProxyCfg.Annotations + workerId := annotations[defs.FrpProxyAnnotationsKey_WorkerId] + if annotations[defs.FrpProxyAnnotationsKey_Ingress] == "" || len(workerId) == 0 { + return typedProxyCfg } - typedProxyCfg.ProxyConfigurer = httpProxyCfg + httpProxyCfg.LoadBalancer = v1.LoadBalancerConfig{ + Group: models.HttpIngressLBGroup(workerId, httpProxyCfg), + GroupKey: workerId, + } return typedProxyCfg } diff --git a/biz/master/proxy/update_proxy_config_test.go b/biz/master/proxy/update_proxy_config_test.go new file mode 100644 index 00000000..4a6a4202 --- /dev/null +++ b/biz/master/proxy/update_proxy_config_test.go @@ -0,0 +1,117 @@ +package proxy + +import ( + "reflect" + "testing" + + "github.com/VaalaCat/frp-panel/defs" + "github.com/VaalaCat/frp-panel/models" + v1 "github.com/fatedier/frp/pkg/config/v1" + "github.com/samber/lo" +) + +// fullHTTPProxy sets every field BUG-08 used to drop, so a regression shows up as a +// diff rather than as a silently-zero field nobody looked at. +func fullHTTPProxy(annotations map[string]string) *v1.HTTPProxyConfig { + return &v1.HTTPProxyConfig{ + ProxyBaseConfig: v1.ProxyBaseConfig{ + Name: "web", + Type: string(v1.ProxyTypeHTTP), + Enabled: lo.ToPtr(false), + Annotations: annotations, + Transport: v1.ProxyTransport{ + UseEncryption: true, + ProxyProtocolVersion: "v2", + }, + LoadBalancer: v1.LoadBalancerConfig{Group: "user-group", GroupKey: "user-key"}, + HealthCheck: v1.HealthCheckConfig{ + Type: "http", + TimeoutSeconds: 3, + MaxFailed: 2, + IntervalSeconds: 10, + Path: "/healthz", + }, + ProxyBackend: v1.ProxyBackend{ + LocalIP: "10.0.0.5", + LocalPort: 8080, + Plugin: v1.TypedClientPluginOptions{ + Type: "http2https", + ClientPluginOptions: &v1.HTTP2HTTPSPluginOptions{Type: "http2https", LocalAddr: "127.0.0.1:443"}, + }, + }, + }, + DomainConfig: v1.DomainConfig{CustomDomains: []string{"a.example.com"}, SubDomain: "a"}, + Locations: []string{"/"}, + } +} + +func typed(c v1.ProxyConfigurer) v1.TypedProxyConfig { + return v1.TypedProxyConfig{Type: c.GetBaseConfig().Type, ProxyConfigurer: c} +} + +func TestUpdateWorkerLoadBalancerGroupKeepsEveryField(t *testing.T) { + annotations := map[string]string{ + defs.FrpProxyAnnotationsKey_Ingress: "true", + defs.FrpProxyAnnotationsKey_WorkerId: "worker-1", + } + got := UpdateWorkerLoadBalancerGroup(typed(fullHTTPProxy(annotations))) + + gotHTTP, ok := got.ProxyConfigurer.(*v1.HTTPProxyConfig) + if !ok { + t.Fatalf("configurer type changed to %T", got.ProxyConfigurer) + } + + want := fullHTTPProxy(annotations) + want.LoadBalancer = v1.LoadBalancerConfig{ + Group: models.HttpIngressLBGroup("worker-1", want), + GroupKey: "worker-1", + } + if !reflect.DeepEqual(gotHTTP, want) { + t.Fatalf("config changed beyond loadBalancer:\n got %+v\n want %+v", gotHTTP, want) + } +} + +func TestUpdateWorkerLoadBalancerGroupLeavesOtherProxiesAlone(t *testing.T) { + cases := map[string]map[string]string{ + "no annotations": nil, + "ingress without worker": {defs.FrpProxyAnnotationsKey_Ingress: "true"}, + "worker without ingress": {defs.FrpProxyAnnotationsKey_WorkerId: "worker-1"}, + "empty worker id": { + defs.FrpProxyAnnotationsKey_Ingress: "true", + defs.FrpProxyAnnotationsKey_WorkerId: "", + }, + } + for name, annotations := range cases { + t.Run(name, func(t *testing.T) { + got := UpdateWorkerLoadBalancerGroup(typed(fullHTTPProxy(annotations))) + if !reflect.DeepEqual(got.ProxyConfigurer, fullHTTPProxy(annotations)) { + t.Fatalf("non-worker proxy was modified: %+v", got.ProxyConfigurer) + } + }) + } +} + +// The old implementation rebuilt *any* input as an HTTPProxyConfig, so a non-http +// proxy reaching it would have changed type. +func TestUpdateWorkerLoadBalancerGroupIgnoresNonHTTP(t *testing.T) { + tcp := &v1.TCPProxyConfig{ + ProxyBaseConfig: v1.ProxyBaseConfig{ + Name: "ssh", + Type: string(v1.ProxyTypeTCP), + Annotations: map[string]string{ + defs.FrpProxyAnnotationsKey_Ingress: "true", + defs.FrpProxyAnnotationsKey_WorkerId: "worker-1", + }, + }, + RemotePort: 6000, + } + got := UpdateWorkerLoadBalancerGroup(typed(tcp)) + if got.ProxyConfigurer != tcp || tcp.LoadBalancer != (v1.LoadBalancerConfig{}) { + t.Fatalf("tcp proxy was modified: %+v", got.ProxyConfigurer) + } + + empty := UpdateWorkerLoadBalancerGroup(v1.TypedProxyConfig{}) + if empty.ProxyConfigurer != nil { + t.Fatalf("nil configurer was replaced with %T", empty.ProxyConfigurer) + } +} diff --git a/biz/master/server/update_tunnel.go b/biz/master/server/update_tunnel.go index 6d0d2bff..600f97c8 100644 --- a/biz/master/server/update_tunnel.go +++ b/biz/master/server/update_tunnel.go @@ -6,15 +6,13 @@ import ( "github.com/VaalaCat/frp-panel/common" "github.com/VaalaCat/frp-panel/conf" - "github.com/VaalaCat/frp-panel/defs" + "github.com/VaalaCat/frp-panel/internal/frpx" "github.com/VaalaCat/frp-panel/pb" "github.com/VaalaCat/frp-panel/services/app" "github.com/VaalaCat/frp-panel/services/dao" "github.com/VaalaCat/frp-panel/services/rpc" "github.com/VaalaCat/frp-panel/utils" "github.com/VaalaCat/frp-panel/utils/logger" - v1 "github.com/fatedier/frp/pkg/config/v1" - "github.com/samber/lo" ) func UpdateFrpsHander(c *app.Context, req *pb.UpdateFRPSRequest) (*pb.UpdateFRPSResponse, error) { @@ -42,10 +40,18 @@ func UpdateFrpsHander(c *app.Context, req *pb.UpdateFRPSRequest) (*pb.UpdateFRPS return nil, err } - lo.Filter(srvCfg.HTTPPlugins, func(item v1.HTTPPluginOptions, _ int) bool { - return item.Name != defs.FRP_Plugin_Multiuser - }) - srvCfg.HTTPPlugins = append(srvCfg.HTTPPlugins, conf.FRPsAuthOption(c.GetApp().GetConfig())) + // Decoding only proves the shape is right. A config that decodes but fails frp's + // validation (a bad log level, an unsupported httpPlugins op) is accepted here and + // then panics the Server agent when it tries to build frps, so validate before the + // config is ever stored or pushed. + if warning, err := frpx.ValidateServerConfig(srvCfg); err != nil { + logger.Logger(c).WithError(err).Errorf("invalid server config") + return nil, err + } else if warning != nil { + logger.Logger(c).Warnf("server config warning: %v", warning) + } + + srvCfg.HTTPPlugins = conf.WithFRPsAuthPlugin(c.GetApp().GetConfig(), srvCfg.HTTPPlugins) if err := srv.SetConfigContent(srvCfg); err != nil { logger.Logger(context.Background()).WithError(err).Errorf("cannot set server config") diff --git a/biz/server/auth_handler.go b/biz/server/auth_handler.go index 28a1e729..4196aa84 100644 --- a/biz/server/auth_handler.go +++ b/biz/server/auth_handler.go @@ -4,10 +4,10 @@ import ( "net/http" "github.com/VaalaCat/frp-panel/defs" + "github.com/VaalaCat/frp-panel/internal/frpx" "github.com/VaalaCat/frp-panel/pb" "github.com/VaalaCat/frp-panel/services/app" "github.com/VaalaCat/frp-panel/utils/logger" - plugin "github.com/fatedier/frp/pkg/plugin/server" "github.com/gin-gonic/gin" ) @@ -50,8 +50,8 @@ func MakeGinHandlerFunc(appInstance app.Application, handler HandlerFunc) gin.Ha } func HandleLogin(ctx *app.Context) (interface{}, error) { - var r plugin.Request - var content plugin.LoginContent + var r frpx.PluginRequest + var content frpx.PluginLoginContent r.Content = &content if err := ctx.GetGinCtx().BindJSON(&r); err != nil { return nil, &HTTPError{ @@ -60,7 +60,7 @@ func HandleLogin(ctx *app.Context) (interface{}, error) { } } - var res plugin.Response + var res frpx.PluginResponse token := content.Metas[defs.FRPAuthTokenKey] if len(content.User) == 0 || len(token) == 0 { res.Reject = true diff --git a/biz/server/rpc_pull_config.go b/biz/server/rpc_pull_config.go index 1d8f8454..e570e5fc 100644 --- a/biz/server/rpc_pull_config.go +++ b/biz/server/rpc_pull_config.go @@ -5,14 +5,12 @@ import ( "reflect" "github.com/VaalaCat/frp-panel/conf" - "github.com/VaalaCat/frp-panel/defs" "github.com/VaalaCat/frp-panel/pb" "github.com/VaalaCat/frp-panel/services/app" "github.com/VaalaCat/frp-panel/services/server" "github.com/VaalaCat/frp-panel/utils" "github.com/VaalaCat/frp-panel/utils/logger" v1 "github.com/fatedier/frp/pkg/config/v1" - "github.com/samber/lo" ) func PullConfig(appInstance app.Application, serverID, serverSecret string) error { @@ -66,11 +64,5 @@ func PullConfig(appInstance app.Application, serverID, serverSecret string) erro } func InjectAuthPlugin(ctx *app.Context, cfg *v1.ServerConfig) { - cfg.HTTPPlugins = lo.Filter(cfg.HTTPPlugins, func(item v1.HTTPPluginOptions, _ int) bool { - return item.Name != defs.FRP_Plugin_Multiuser - }) - cfg.HTTPPlugins = append( - cfg.HTTPPlugins, - conf.FRPsAuthOption(ctx.GetApp().GetConfig()), - ) + cfg.HTTPPlugins = conf.WithFRPsAuthPlugin(ctx.GetApp().GetConfig(), cfg.HTTPPlugins) } diff --git a/conf/frps_auth_plugin.go b/conf/frps_auth_plugin.go new file mode 100644 index 00000000..54ee7c39 --- /dev/null +++ b/conf/frps_auth_plugin.go @@ -0,0 +1,22 @@ +package conf + +import ( + "github.com/VaalaCat/frp-panel/defs" + v1 "github.com/fatedier/frp/pkg/config/v1" + "github.com/samber/lo" +) + +// WithFRPsAuthPlugin returns plugins with the panel's own `multiuser` entry as the last +// element and no other entry by that name. +// +// frps calls that entry back to authenticate every login, so it is owned by the panel: +// a stored copy can be stale (the server API port moved) and a user-supplied one would +// redirect authentication. Every other entry is a user's own plugin and is kept, in +// order. The master and the Server agent both apply this, so the result must be stable +// when applied twice. The input slice is not modified. +func WithFRPsAuthPlugin(cfg Config, plugins []v1.HTTPPluginOptions) []v1.HTTPPluginOptions { + userPlugins := lo.Filter(plugins, func(item v1.HTTPPluginOptions, _ int) bool { + return item.Name != defs.FRP_Plugin_Multiuser + }) + return append(userPlugins, FRPsAuthOption(cfg)) +} diff --git a/conf/frps_auth_plugin_test.go b/conf/frps_auth_plugin_test.go new file mode 100644 index 00000000..3d5584cf --- /dev/null +++ b/conf/frps_auth_plugin_test.go @@ -0,0 +1,61 @@ +package conf + +import ( + "reflect" + "testing" + + "github.com/VaalaCat/frp-panel/defs" + v1 "github.com/fatedier/frp/pkg/config/v1" +) + +func authTestConfig(apiPort int) Config { + var cfg Config + cfg.Server.APIPort = apiPort + return cfg +} + +func TestWithFRPsAuthPlugin(t *testing.T) { + cfg := authTestConfig(8999) + panel := FRPsAuthOption(cfg) + audit := v1.HTTPPluginOptions{Name: "audit", Addr: "127.0.0.1:9001", Path: "/handler", Ops: []string{"NewProxy"}} + quota := v1.HTTPPluginOptions{Name: "quota", Addr: "127.0.0.1:9002", Path: "/q", Ops: []string{"Login", "Ping"}} + stale := FRPsAuthOption(authTestConfig(1234)) + hijack := v1.HTTPPluginOptions{Name: defs.FRP_Plugin_Multiuser, Addr: "evil.example:80", Path: "/auth", Ops: []string{"Login"}} + + cases := []struct { + name string + in []v1.HTTPPluginOptions + want []v1.HTTPPluginOptions + }{ + {"nil", nil, []v1.HTTPPluginOptions{panel}}, + {"empty", []v1.HTTPPluginOptions{}, []v1.HTTPPluginOptions{panel}}, + {"user entries keep their order", []v1.HTTPPluginOptions{quota, audit}, []v1.HTTPPluginOptions{quota, audit, panel}}, + {"stale panel entry is replaced", []v1.HTTPPluginOptions{audit, stale}, []v1.HTTPPluginOptions{audit, panel}}, + {"user-supplied multiuser is dropped", []v1.HTTPPluginOptions{hijack, audit}, []v1.HTTPPluginOptions{audit, panel}}, + {"several multiuser entries collapse", []v1.HTTPPluginOptions{stale, audit, hijack}, []v1.HTTPPluginOptions{audit, panel}}, + } + for _, tc := range cases { + t.Run(tc.name, func(t *testing.T) { + got := WithFRPsAuthPlugin(cfg, tc.in) + if !reflect.DeepEqual(got, tc.want) { + t.Fatalf("got %+v\nwant %+v", got, tc.want) + } + if again := WithFRPsAuthPlugin(cfg, got); !reflect.DeepEqual(again, tc.want) { + t.Fatalf("not idempotent: second pass gave %+v", again) + } + }) + } +} + +func TestWithFRPsAuthPluginDoesNotModifyInput(t *testing.T) { + audit := v1.HTTPPluginOptions{Name: "audit", Addr: "127.0.0.1:9001", Path: "/handler", Ops: []string{"NewProxy"}} + hijack := v1.HTTPPluginOptions{Name: defs.FRP_Plugin_Multiuser, Addr: "evil.example:80"} + in := make([]v1.HTTPPluginOptions, 2, 8) // spare capacity, so an in-place append would show + in[0], in[1] = hijack, audit + + WithFRPsAuthPlugin(authTestConfig(8999), in) + + if !reflect.DeepEqual(in, []v1.HTTPPluginOptions{hijack, audit}) { + t.Fatalf("input modified: %+v", in) + } +} diff --git a/conf/version.go b/conf/version.go index ec16a301..6e7ad169 100644 --- a/conf/version.go +++ b/conf/version.go @@ -6,6 +6,7 @@ import ( "html/template" "runtime" + "github.com/VaalaCat/frp-panel/internal/frpx" "github.com/VaalaCat/frp-panel/pb" ) @@ -24,10 +25,11 @@ type VersionInfo struct { GoVersion string `json:"goVersion" yaml:"goVersion"` Compiler string `json:"compiler" yaml:"compiler"` Platform string `json:"platform" yaml:"platform"` + FrpVersion string `json:"frpVersion" yaml:"frpVersion"` } func (v *VersionInfo) String() string { - tempStr := "BinVersion: {{.GitVersion}}\nGitCommit: {{.GitCommit}}\nBuildDate: {{.BuildDate}}\nGoVersion: {{.GoVersion}}\nCompiler: {{.Compiler}}\nPlatform: {{.Platform}}" + tempStr := "BinVersion: {{.GitVersion}}\nGitCommit: {{.GitCommit}}\nBuildDate: {{.BuildDate}}\nGoVersion: {{.GoVersion}}\nCompiler: {{.Compiler}}\nPlatform: {{.Platform}}\nFrpVersion: {{.FrpVersion}}" temp, err := template.New("version").Parse(tempStr) if err != nil { return "" @@ -49,6 +51,7 @@ func (v *VersionInfo) ToProto() *pb.ClientVersion { GoVersion: v.GoVersion, Compiler: v.Compiler, Platform: v.Platform, + FrpVersion: v.FrpVersion, } } @@ -61,5 +64,6 @@ func GetVersion() *VersionInfo { GoVersion: runtime.Version(), Compiler: runtime.Compiler, Platform: fmt.Sprintf("%s/%s", runtime.GOOS, runtime.GOARCH), + FrpVersion: frpx.Version(), } } diff --git a/docs/frp-upgrade-verification.md b/docs/frp-upgrade-verification.md new file mode 100644 index 00000000..4679f399 --- /dev/null +++ b/docs/frp-upgrade-verification.md @@ -0,0 +1,187 @@ +# Verifying an frp upgrade + +What to check when `github.com/fatedier/frp` moves in `go.mod`, and in what order to roll +the result out. + +This document is fork-local (`felix-homelab/frp-panel`) and is not part of the published +VitePress site. + +## 1. Automated: the tunnel matrix + +`internal/frpx/tunnel_integration_test.go` stands up a real frps and a real frpc in one +process and pushes real bytes through every supported tunnel type. It is the only test in +the repository that exercises a tunnel. + +```bash +mkdir -p cmd/frpp/out && touch cmd/frpp/out/.gitkeep # satisfies //go:embed all:out +go build ./... +go test ./conf/... ./models/... ./services/wg/... ./services/dao/... ./utils/... ./internal/... ./biz/... +go test -tags integration -run TestTunnelMatrix -v -timeout 5m ./internal/frpx/... +``` + +Also re-check `internal/frpx/version.go`: `minWireProtocolV2` and any other frp version +gates there are statements about frp releases, and agents now report `frpx.Version()` to +the master. + +Covers **tcp, udp, http, https, stcp, sudp, tcpmux**. `xtcp` is skipped by default because +NAT hole punching is unreliable on loopback — verify it manually (§3) or attempt it with +`FRPX_TEST_XTCP=1`. The last subtest switches a proxy to `enabled: false` on the running +client and back, which guards `frpx.UpdateClientConfigurers` (BUG-12): frp only applies +`enabled` by itself when a client service is created. + +> `go test ./...` is **not** a valid gate. `services/workerd.TestRunWorker` hardcodes +> `/home/coder/...` paths and fails everywhere except the original author's dev container. +> That failure is pre-existing and unrelated to any upgrade. + +### The assertion that is supposed to fail + +The `proxy name correlation` subtest pins how proxy names flow. It is the intended tripwire +for an frp bump, and it failed **by design** when this repo crossed frp v0.68: + +| | frp ≤ v0.67 | frp ≥ v0.68 | +|------------------------------|-------------|-------------| +| config `Name` after Complete | `alice.ssh` | `ssh` | +| client status map key | `alice.ssh` | `ssh` | +| wire `msg.NewProxy.ProxyName`| `alice.ssh` | `alice.ssh` | +| frps `mem.ProxyStats.Name` | `alice.ssh` | `alice.ssh` | + +The **wire name is unchanged**, which is why tunnels keep interoperating across the gap. +What moves is the key frp-panel must use for a *client-side* status lookup. + +**Status: crossed at frp v0.70.1.** The v0.65.0 → v0.70.1 bump confirmed the table above +empirically — the subtest now logs `raw("tcp-test")=true wire("alice.tcp-test")=false` +while frps still reports `alice.tcp-test`. Two things were done in response, and both +remain the current state: + +- The subtest's client-side expectations were inverted to the frp ≥ v0.68 semantics. +- `biz/client/get_proxy_info.go` gained `lookupProxyStatus`, which tries the name as sent + and then both `frpx.RawProxyName` and `frpx.WireProxyName`. The master still sends the + *wire* name (`biz/master/proxy/get_proxy_config.go`), so the agent — not the master — is + what absorbs the difference, which keeps mixed-version fleets working during a rollout. + +If this subtest fails again on a future bump, that is the finding: flip the assertion and +re-check that lookup. The visible symptom of getting it wrong is every proxy showing +**`error`** in the panel's status column — `biz/master/proxy/get_proxy_config.go` sets that +whenever the client RPC misses. + +### Collateral: the Go floor and quic-go + +frp v0.70.1 requires **Go 1.25**. Bumping `go.mod` past a Go release means the four +upstream workflows that hardcode `go-version` (`master`, `tag`, `master-merge-check`, +`workerd-docker-tag`) need the same bump; `fork-checks.yml` reads `go-version-file: go.mod` +and follows automatically. + +frp also drags `quic-go` forward, and `github.com/imroc/req/v3` compiles against quic-go's +internal HTTP/3 surface. frp v0.70.1 pulled quic-go v0.60.0, which broke `req v3.55.0` +(`undefined: quic.ConnectionTracingID`). The fix is to move `req` in the same commit — +v3.60.0 resolves it. **Expect to bump `req` whenever frp bumps quic-go.** + +## 2. Build gate + +```bash +./build.sh --current # closest local reproduction of CI +cd www && pnpm lint # the only linter CI runs +``` + +## 3. Manual: the UI and API path + +The Go test proves the frp layer. It does not prove frp-panel's HTTP API, the database +round-trip, or the UI. + +All eight proxy types have a form (since v0.10.1). The payloads below are for the +raw-JSON **Advanced** editor anyway: pasting the same payload every time is faster and +more repeatable than clicking through eight forms, and it exercises the strict decoder. + +Bring up a Master, register a Server and a Client, then for each type below paste the +payload into the proxy **Advanced** editor and confirm the result. + +**What to look for in every case: the working-status column reads `running`, not `error`.** +`error` there is the signature of the name-correlation break. + +```jsonc +// tcp +{ "name": "t-tcp", "type": "tcp", "localIP": "127.0.0.1", "localPort": 22, "remotePort": 6022 } + +// udp +{ "name": "t-udp", "type": "udp", "localIP": "127.0.0.1", "localPort": 53, "remotePort": 6053 } + +// http — needs frps vhostHTTPPort +{ "name": "t-http", "type": "http", "localIP": "127.0.0.1", "localPort": 8080, + "customDomains": ["http.example.com"] } + +// https — needs frps vhostHTTPSPort (server form, "HTTPS virtual host port") +{ "name": "t-https", "type": "https", "customDomains": ["https.example.com"], + "plugin": { "type": "https2http", "localAddr": "127.0.0.1:8080", + "crtPath": "/etc/frpp/tls.crt", "keyPath": "/etc/frpp/tls.key" } } + +// tcpmux — needs frps tcpmuxHTTPConnectPort (server form, virtual host section) +{ "name": "t-mux", "type": "tcpmux", "multiplexer": "httpconnect", + "customDomains": ["mux.example.com"], "localIP": "127.0.0.1", "localPort": 22 } + +// stcp — plus a visitor on the consuming client +{ "name": "t-stcp", "type": "stcp", "secretKey": "s3cret", + "localIP": "127.0.0.1", "localPort": 22 } + +// xtcp — plus a visitor; needs a reachable STUN server (natHoleStunServer, FC-05) +{ "name": "t-xtcp", "type": "xtcp", "secretKey": "s3cret", + "localIP": "127.0.0.1", "localPort": 22 } +``` + +Create the visitor on the consuming client — from the client card's visitor section, or in +its raw config: + +```jsonc +"visitors": [ + { "name": "v-stcp", "type": "stcp", "serverName": "t-stcp", + "secretKey": "s3cret", "bindAddr": "127.0.0.1", "bindPort": 9000 } +] +``` + +> Editing a proxy used to strip every xtcp-specific visitor field (`protocol`, +> `keepTunnelOpen`, `fallbackTo`, …) from the same client — `BUG-01`, fixed in v0.10.1 with +> `models/client_test.go` as its regression gate. If those fields vanish again during a +> verification, that is a regression, not an upgrade artefact. + +## 4. Rollout order + +frp v0.69 introduced a formal compatibility policy: each minor is supported until nine +newer minors exist, and **frps should be upgraded before frpc**. At the current pin +(v0.70.1) that window runs to v0.79.0, so a v0.70 frpc and an frps anywhere from v0.61 +upward interoperate — mixed-version fleets are safe for the duration of a rollout. + +For frp-panel, where Master, Server and Client are the same binary deployed as separate +long-lived processes: + +1. **Server nodes** (they run the public-facing frps) +2. **Master** +3. **Clients** + +Master's only frp surface is the `pkg/plugin/server` types and the `pkg/config/v1` structs, +so a newer Master serves older clients without issue. + +**OTA caveat.** `biz/common/upgrade/` lets a client upgrade itself, independently of the +Master. Prefer holding the client rollout until Master and every frps node are done — but +once `biz/client/get_proxy_info.go` accepts both proxy-name forms, an out-of-order client +upgrade is cosmetically harmless rather than status-breaking. + +## 5. Things that must not change quietly + +- **`transport.wireProtocol` must stay `v1`.** A v2 frpc **cannot connect to an older + frps**, and this project upgrades frps and frpc independently. frp's default is still + `v1` (`ClientTransportConfig.Complete()` fills it in), and the field is typed in + `www/types/client.ts` for raw-config editing only — do **not** surface it as a UI control + without a server-version check. +- **`enabled` is tri-state.** frp models `ProxyBaseConfig.Enabled` / `VisitorBaseConfig.Enabled` + as `*bool` with `omitempty`: absent or `true` means enabled, and only an explicit `false` + disables. Anything that writes these configs must omit the key rather than default it to + `false`, or it will silently disable every proxy it touches. The proxy form routes this + through `enabledToConfigValue` in `www/components/frpc/proxy_form.tsx`. Note this is + distinct from the panel's own `ProxyConfig.Stopped`, which is what the list's start/stop + action drives — the two can disagree. +- **Do not adopt frpc's `[store]` config source.** frp's aggregator merges the store with + the pushed config, so a persisted store would resurrect proxies the Master deleted. +- **Watch for restart loops.** `biz/server/rpc_pull_config.go`, `biz/server/update_tunnel.go` + and `biz/client/rpc_pull_config.go` compare configs to decide whether to recreate frps/frpc. + A field that `Complete()` fills non-deterministically would make that comparison always + differ and drive an unbounded restart loop. Symptom: tunnels flapping on the config-pull + interval with a repeating "config changed, will recreate it" log line. diff --git a/go.mod b/go.mod index 024a68f4..45bb7441 100644 --- a/go.mod +++ b/go.mod @@ -1,8 +1,6 @@ module github.com/VaalaCat/frp-panel -go 1.24.0 - -toolchain go1.24.1 +go 1.25.0 require ( github.com/UserExistsError/conpty v0.1.4 @@ -12,8 +10,8 @@ require ( github.com/coreos/go-iptables v0.8.0 github.com/creack/pty v1.1.24 github.com/failsafe-go/failsafe-go v0.9.4 - github.com/fatedier/frp v0.65.0 - github.com/fatedier/golib v0.5.1 + github.com/fatedier/frp v0.70.1 + github.com/fatedier/golib v0.8.1 github.com/gin-contrib/static v0.0.1 github.com/gin-gonic/gin v1.9.1 github.com/glebarez/sqlite v1.11.0 @@ -23,13 +21,12 @@ require ( github.com/gorilla/websocket v1.5.3 github.com/iamacarpet/go-winpty v1.0.4 github.com/ilyakaznacheev/cleanenv v1.5.0 - github.com/imroc/req/v3 v3.55.0 + github.com/imroc/req/v3 v3.60.0 github.com/jackpal/gateway v1.0.16 github.com/joho/godotenv v1.5.1 github.com/kardianos/service v1.2.2 github.com/lucasepe/codename v0.2.0 github.com/mattn/go-isatty v0.0.20 - github.com/pion/stun/v3 v3.0.2 github.com/prometheus-community/pro-bing v0.7.0 github.com/samber/lo v1.47.0 github.com/schollz/progressbar/v3 v3.19.0 @@ -45,9 +42,9 @@ require ( github.com/vishvananda/netlink v1.3.1 go.uber.org/fx v1.23.0 go.uber.org/multierr v1.11.0 - golang.org/x/crypto v0.42.0 - golang.org/x/net v0.44.0 - golang.org/x/sys v0.36.0 + golang.org/x/crypto v0.54.0 + golang.org/x/net v0.57.0 + golang.org/x/sys v0.47.0 golang.zx2c4.com/wireguard v0.0.0-20250521234502-f333402bd9cb golang.zx2c4.com/wireguard/wgctrl v0.0.0-20241231184526-a9ab2273dd10 google.golang.org/grpc v1.67.1 @@ -60,9 +57,9 @@ require ( ) require ( - github.com/Azure/go-ntlmssp v0.0.0-20221128193559-754e69321358 // indirect + github.com/Azure/go-ntlmssp v0.1.0 // indirect github.com/BurntSushi/toml v1.4.1-0.20240526193622-a339e1f7089c // indirect - github.com/andybalholm/brotli v1.2.0 // indirect + github.com/andybalholm/brotli v1.2.2 // indirect github.com/armon/go-socks5 v0.0.0-20160902184237-e75332964ef5 // indirect github.com/beorn7/perks v1.0.1 // indirect github.com/bits-and-blooms/bitset v1.24.4 // indirect @@ -72,14 +69,14 @@ require ( github.com/cespare/xxhash/v2 v2.3.0 // indirect github.com/chenzhuoyu/base64x v0.0.0-20221115062448-fe3a3abad311 // indirect github.com/cloudflare/circl v1.6.1 // indirect - github.com/coreos/go-oidc/v3 v3.14.1 // indirect + github.com/coreos/go-oidc/v3 v3.18.0 // indirect github.com/davecgh/go-spew v1.1.2-0.20180830191138-d8f796af33cc // indirect github.com/dustin/go-humanize v1.0.1 // indirect github.com/ebitengine/purego v0.8.2 // indirect github.com/gabriel-vasile/mimetype v1.4.2 // indirect github.com/gin-contrib/sse v0.1.0 // indirect github.com/glebarez/go-sqlite v1.21.2 // indirect - github.com/go-jose/go-jose/v4 v4.0.5 // indirect + github.com/go-jose/go-jose/v4 v4.1.4 // indirect github.com/go-ole/go-ole v1.2.6 // indirect github.com/go-playground/locales v0.14.1 // indirect github.com/go-playground/universal-translator v0.18.1 // indirect @@ -90,10 +87,10 @@ require ( github.com/golang-sql/sqlexp v0.1.0 // indirect github.com/golang/snappy v0.0.4 // indirect github.com/google/btree v1.1.2 // indirect - github.com/google/go-querystring v1.1.0 // indirect + github.com/google/go-querystring v1.2.0 // indirect github.com/gorilla/mux v1.8.1 // indirect github.com/hashicorp/yamux v0.1.1 // indirect - github.com/icholy/digest v1.1.0 // indirect + github.com/icholy/digest v1.2.0 // indirect github.com/inconshreveable/mousetrap v1.1.0 // indirect github.com/jackc/pgpassfile v1.0.0 // indirect github.com/jackc/pgservicefile v0.0.0-20221227161230-091c0ba34f0a // indirect @@ -103,7 +100,7 @@ require ( github.com/jinzhu/now v1.1.5 // indirect github.com/jonboulle/clockwork v0.4.0 // indirect github.com/json-iterator/go v1.1.12 // indirect - github.com/klauspost/compress v1.18.0 // indirect + github.com/klauspost/compress v1.19.1 // indirect github.com/klauspost/cpuid/v2 v2.2.6 // indirect github.com/klauspost/reedsolomon v1.12.0 // indirect github.com/leodido/go-urn v1.2.4 // indirect @@ -113,12 +110,6 @@ require ( github.com/modern-go/concurrent v0.0.0-20180306012644-bacd9c7ef1dd // indirect github.com/modern-go/reflect2 v1.0.2 // indirect github.com/pelletier/go-toml/v2 v2.2.0 // indirect - github.com/pion/dtls/v2 v2.2.7 // indirect - github.com/pion/dtls/v3 v3.0.8 // indirect - github.com/pion/logging v0.2.4 // indirect - github.com/pion/stun/v2 v2.0.0 // indirect - github.com/pion/transport/v2 v2.2.1 // indirect - github.com/pion/transport/v3 v3.1.1 // indirect github.com/pires/go-proxyproto v0.7.0 // indirect github.com/pkg/errors v0.9.1 // indirect github.com/pmezard/go-difflib v1.0.1-0.20181226105442-5d4384ee4fb2 // indirect @@ -127,9 +118,9 @@ require ( github.com/prometheus/client_model v0.5.0 // indirect github.com/prometheus/common v0.48.0 // indirect github.com/prometheus/procfs v0.12.0 // indirect - github.com/quic-go/qpack v0.5.1 // indirect - github.com/quic-go/quic-go v0.53.0 // indirect - github.com/refraction-networking/utls v1.7.3 // indirect + github.com/quic-go/qpack v0.6.0 // indirect + github.com/quic-go/quic-go v0.61.0 // indirect + github.com/refraction-networking/utls v1.8.2 // indirect github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec // indirect github.com/rivo/uniseg v0.4.7 // indirect github.com/robfig/cron/v3 v3.0.1 // indirect @@ -143,21 +134,17 @@ require ( github.com/twitchyliquid64/golang-asm v0.15.1 // indirect github.com/ugorji/go/codec v1.2.11 // indirect github.com/vishvananda/netns v0.0.5 // indirect - github.com/wlynxg/anet v0.0.5 // indirect github.com/xtaci/kcp-go/v5 v5.6.13 // indirect github.com/yusufpapurcu/wmi v1.2.4 // indirect go.uber.org/dig v1.18.1 // indirect - go.uber.org/mock v0.5.2 // indirect go.uber.org/zap v1.26.0 // indirect golang.org/x/arch v0.3.0 // indirect golang.org/x/exp v0.0.0-20250620022241-b7579e27df2b // indirect - golang.org/x/mod v0.27.0 // indirect - golang.org/x/oauth2 v0.28.0 // indirect - golang.org/x/sync v0.17.0 // indirect - golang.org/x/term v0.35.0 // indirect - golang.org/x/text v0.29.0 // indirect - golang.org/x/time v0.7.0 // indirect - golang.org/x/tools v0.36.0 // indirect + golang.org/x/oauth2 v0.36.0 // indirect + golang.org/x/sync v0.22.0 // indirect + golang.org/x/term v0.45.0 // indirect + golang.org/x/text v0.40.0 // indirect + golang.org/x/time v0.10.0 // indirect golang.zx2c4.com/wintun v0.0.0-20230126152724-0fa3db229ce2 // indirect google.golang.org/genproto/googleapis/rpc v0.0.0-20240814211410-ddb44dafa142 // indirect gopkg.in/ini.v1 v1.67.0 // indirect diff --git a/go.sum b/go.sum index 58e53a9a..2bb510ff 100644 --- a/go.sum +++ b/go.sum @@ -14,8 +14,8 @@ github.com/Azure/azure-sdk-for-go/sdk/security/keyvault/azkeys v1.0.0 h1:yfJe15a github.com/Azure/azure-sdk-for-go/sdk/security/keyvault/azkeys v1.0.0/go.mod h1:Q28U+75mpCaSCDowNEmhIo/rmgdkqmkmzI7N6TGR4UY= github.com/Azure/azure-sdk-for-go/sdk/security/keyvault/internal v0.8.0 h1:T028gtTPiYt/RMUfs8nVsAL7FDQrfLlrm/NnRG/zcC4= github.com/Azure/azure-sdk-for-go/sdk/security/keyvault/internal v0.8.0/go.mod h1:cw4zVQgBby0Z5f2v0itn6se2dDP17nTjbZFXW5uPyHA= -github.com/Azure/go-ntlmssp v0.0.0-20221128193559-754e69321358 h1:mFRzDkZVAjdal+s7s0MwaRv9igoPqLRdzOLzw/8Xvq8= -github.com/Azure/go-ntlmssp v0.0.0-20221128193559-754e69321358/go.mod h1:chxPXzSsl7ZWRAuOIE23GDNzjWuZquvFlgA8xmpunjU= +github.com/Azure/go-ntlmssp v0.1.0 h1:DjFo6YtWzNqNvQdrwEyr/e4nhU3vRiwenz5QX7sFz+A= +github.com/Azure/go-ntlmssp v0.1.0/go.mod h1:NYqdhxd/8aAct/s4qSYZEerdPuH1liG2/X9DiVTbhpk= github.com/AzureAD/microsoft-authentication-library-for-go v1.0.0/go.mod h1:kgDmCTgBzIEPFElEF+FK0SdjAor06dRq2Go927dnQ6o= github.com/AzureAD/microsoft-authentication-library-for-go v1.1.0 h1:HCc0+LpPfpCKs6LGGLAhwBARt9632unrVcI6i8s/8os= github.com/AzureAD/microsoft-authentication-library-for-go v1.1.0/go.mod h1:wP83P5OoQ5p6ip3ScPr0BAq0BvuPAvacpEuSzyouqAI= @@ -27,6 +27,8 @@ github.com/UserExistsError/conpty v0.1.4 h1:+3FhJhiqhyEJa+K5qaK3/w6w+sN3Nh9O9VbJ github.com/UserExistsError/conpty v0.1.4/go.mod h1:PDglKIkX3O/2xVk0MV9a6bCWxRmPVfxqZoTG/5sSd9I= github.com/andybalholm/brotli v1.2.0 h1:ukwgCxwYrmACq68yiUqwIWnGY0cTPox/M94sVwToPjQ= github.com/andybalholm/brotli v1.2.0/go.mod h1:rzTDkvFWvIrjDXZHkuS16NPggd91W3kUSvPlQ1pLaKY= +github.com/andybalholm/brotli v1.2.2 h1:HzTuoo2ErYQqf5qvcJInB8uvqSVxRttzkFexPWtnceM= +github.com/andybalholm/brotli v1.2.2/go.mod h1:rzTDkvFWvIrjDXZHkuS16NPggd91W3kUSvPlQ1pLaKY= github.com/armon/go-socks5 v0.0.0-20160902184237-e75332964ef5 h1:0CwZNZbxp69SHPdPJAN/hZIm0C4OItdklCFmMRWYpio= github.com/armon/go-socks5 v0.0.0-20160902184237-e75332964ef5/go.mod h1:wHh0iHkYZB8zMSxRWpUBQtwG5a7fFgvEO+odwuTv2gs= github.com/beorn7/perks v1.0.1 h1:VlbKKnNfV8bJzeqoa4cOKqO6bYr3WgKZxO8Z16+hsOM= @@ -61,8 +63,8 @@ github.com/coocood/freecache v1.2.4 h1:UdR6Yz/X1HW4fZOuH0Z94KwG851GWOSknua5VUbb/ github.com/coocood/freecache v1.2.4/go.mod h1:RBUWa/Cy+OHdfTGFEhEuE1pMCMX51Ncizj7rthiQ3vk= github.com/coreos/go-iptables v0.8.0 h1:MPc2P89IhuVpLI7ETL/2tx3XZ61VeICZjYqDEgNsPRc= github.com/coreos/go-iptables v0.8.0/go.mod h1:Qe8Bv2Xik5FyTXwgIbLAnv2sWSBmvWdFETJConOQ//Q= -github.com/coreos/go-oidc/v3 v3.14.1 h1:9ePWwfdwC4QKRlCXsJGou56adA/owXczOzwKdOumLqk= -github.com/coreos/go-oidc/v3 v3.14.1/go.mod h1:HaZ3szPaZ0e4r6ebqvsLWlk2Tn+aejfmrfah6hnSYEU= +github.com/coreos/go-oidc/v3 v3.18.0 h1:V9orjXynvu5wiC9SemFTWnG4F45v403aIcjWo0d41+A= +github.com/coreos/go-oidc/v3 v3.18.0/go.mod h1:DYCf24+ncYi+XkIH97GY1+dqoRlbaSI26KVTCI9SrY4= github.com/cpuguy83/go-md2man/v2 v2.0.3/go.mod h1:tgQtvFlXSQOSOSIRvRPT7W67SCa46tRHOmNcaadrF8o= github.com/creack/pty v1.1.24 h1:bJrF4RRfyJnbTJqzRLHzcGaZK1NeM5kTC9jGgovnR1s= github.com/creack/pty v1.1.24/go.mod h1:08sCNb52WyoAwi2QDyzUCTgcvVFhUzewun7wtTfvcwE= @@ -81,10 +83,10 @@ github.com/envoyproxy/go-control-plane v0.9.4/go.mod h1:6rpuAdCZL397s3pYoYcLgu1m github.com/envoyproxy/protoc-gen-validate v0.1.0/go.mod h1:iSmxcyjqTsJpI2R4NaDN7+kN2VEUnK/pcBlmesArF7c= github.com/failsafe-go/failsafe-go v0.9.4 h1:dSIZYxXvRqh+PndhTb6LMeMXPz8iC21LfmwxSofMHSw= github.com/failsafe-go/failsafe-go v0.9.4/go.mod h1:IeRpglkcwzKagjDMh90ZhN2l4Ovt3+jemQBUbThag54= -github.com/fatedier/frp v0.65.0 h1:Wn8qjXXf+G7+czZB0o7Y4U7Ohy9q/LOAqzK5AVKEel4= -github.com/fatedier/frp v0.65.0/go.mod h1:P5aPIs0cqLWDKIYkOziZQGZpb4DwzQkRyK5h+OtPp0Y= -github.com/fatedier/golib v0.5.1 h1:hcKAnaw5mdI/1KWRGejxR+i1Hn/NvbY5UsMKDr7o13M= -github.com/fatedier/golib v0.5.1/go.mod h1:W6kIYkIFxHsTzbgqg5piCxIiDo4LzwgTY6R5W8l9NFQ= +github.com/fatedier/frp v0.70.1 h1:PRZ7YEXfjVVd0br7ERCuHZEA8fww1nAtAno7uwyAQi0= +github.com/fatedier/frp v0.70.1/go.mod h1:3WRYduUt1JO8OUr0uedp2lHGIA9Vh3C4+mPqIdiS9os= +github.com/fatedier/golib v0.8.1 h1:pHcIu0zAcZ6VTkO1dW/meelCGN5nem52DKCBY7cUvyA= +github.com/fatedier/golib v0.8.1/go.mod h1:ArUGvPg2cOw/py2RAuBt46nNZH2VQ5Z70p109MAZpJw= github.com/frankban/quicktest v1.14.6 h1:7Xjx+VpznH+oBnejlPUj8oUpdxnVs4f8XU8WnHkI4W8= github.com/frankban/quicktest v1.14.6/go.mod h1:4ptaffx2x8+WTWXmUCuVU6aPUX1/Mz7zb5vbUoiM6w0= github.com/gabriel-vasile/mimetype v1.4.2 h1:w5qFW6JKBz9Y393Y4q372O9A7cUSequkh1Q7OhCmWKU= @@ -102,8 +104,8 @@ github.com/glebarez/sqlite v1.11.0 h1:wSG0irqzP6VurnMEpFGer5Li19RpIRi2qvQz++w0GM github.com/glebarez/sqlite v1.11.0/go.mod h1:h8/o8j5wiAsqSPoWELDUdJXhjAhsVliSn7bWZjOhrgQ= github.com/go-co-op/gocron/v2 v2.1.2 h1:+6tTOA9aBaKXpDWExw07hYoGEBzT+4CkGSVAiJ7WSXs= github.com/go-co-op/gocron/v2 v2.1.2/go.mod h1:0MfNAXEchzeSH1vtkZrTAcSMWqyL435kL6CA4b0bjrg= -github.com/go-jose/go-jose/v4 v4.0.5 h1:M6T8+mKZl/+fNNuFHvGIzDz7BTLQPIounk/b9dw3AaE= -github.com/go-jose/go-jose/v4 v4.0.5/go.mod h1:s3P1lRrkT8igV8D9OjyL4WRyHvjB6a4JSllnOrmmBOA= +github.com/go-jose/go-jose/v4 v4.1.4 h1:moDMcTHmvE6Groj34emNPLs/qtYXRVcd6S7NHbHz3kA= +github.com/go-jose/go-jose/v4 v4.1.4/go.mod h1:x4oUasVrzR7071A4TnHLGSPpNOm2a21K9Kf04k1rs08= github.com/go-ole/go-ole v1.2.6 h1:/Fpf6oFPoeFik9ty7siob0G6Ke8QvQEuVcuChpwXzpY= github.com/go-ole/go-ole v1.2.6/go.mod h1:pprOEPIfldk/42T2oK7lQ4v4JSDwmV0As9GaiUsvbm0= github.com/go-playground/assert/v2 v2.0.1/go.mod h1:VDjEfimB/XKnb+ZQfWdccd7VUvScMdVu0Titje2rxJ4= @@ -155,10 +157,13 @@ github.com/google/go-cmp v0.3.1/go.mod h1:8QqcDgzrUqlUb/G2PQTWiueGozuR1884gddMyw github.com/google/go-cmp v0.4.0/go.mod h1:v8dTdLbMG2kIc/vJvl+f65V22dbkXbowE6jgT/gNBxE= github.com/google/go-cmp v0.5.2/go.mod h1:v8dTdLbMG2kIc/vJvl+f65V22dbkXbowE6jgT/gNBxE= github.com/google/go-cmp v0.5.6/go.mod h1:v8dTdLbMG2kIc/vJvl+f65V22dbkXbowE6jgT/gNBxE= +github.com/google/go-cmp v0.6.0/go.mod h1:17dUlkBOakJ0+DkrSSNjCkIjxS6bF9zb3elmeNGIjoY= github.com/google/go-cmp v0.7.0 h1:wk8382ETsv4JYUZwIsn6YpYiWiBsYLSJiTsyBybVuN8= github.com/google/go-cmp v0.7.0/go.mod h1:pXiqmnSA92OHEEa9HXL2W4E7lf9JzCmGVUdgjX3N/iU= github.com/google/go-querystring v1.1.0 h1:AnCroh3fv4ZBgVIf1Iwtovgjaw/GiKJo8M8yD/fhyJ8= github.com/google/go-querystring v1.1.0/go.mod h1:Kcdr2DB4koayq7X8pmAG4sNG59So17icRSOU623lUBU= +github.com/google/go-querystring v1.2.0 h1:yhqkPbu2/OH+V9BfpCVPZkNmUXhb2gBxJArfhIxNtP0= +github.com/google/go-querystring v1.2.0/go.mod h1:8IFJqpSRITyJ8QhQ13bmbeMBDfmeEJZD5A0egEOmkqU= github.com/google/gofuzz v1.0.0/go.mod h1:dBl0BpW6vV/+mYPU4Po3pmUjxk6FQPldtuIdl/M65Eg= github.com/google/pprof v0.0.0-20250403155104-27863c87afa6 h1:BHT72Gu3keYf3ZEu2J0b1vyeLSOYI8bm5wbJM/8yDe8= github.com/google/pprof v0.0.0-20250403155104-27863c87afa6/go.mod h1:boTsfXsheKC2y+lKOCMpSfarhxDeIzfZG1jqGcPl3cA= @@ -179,10 +184,14 @@ github.com/iamacarpet/go-winpty v1.0.4 h1:r42xaLLRZcUqjX6vHZeHos2haACfWkooOJTnFd github.com/iamacarpet/go-winpty v1.0.4/go.mod h1:50yLtqN2hFb5sYO5Qm2LegB166oAEw/PZYRn+FPWj0Q= github.com/icholy/digest v1.1.0 h1:HfGg9Irj7i+IX1o1QAmPfIBNu/Q5A5Tu3n/MED9k9H4= github.com/icholy/digest v1.1.0/go.mod h1:QNrsSGQ5v7v9cReDI0+eyjsXGUoRSUZQHeQ5C4XLa0Y= +github.com/icholy/digest v1.2.0 h1:oTbG4IsNOmidJ+421ehG7Ty93yt1yotq13kFMG569yw= +github.com/icholy/digest v1.2.0/go.mod h1:1P1+LzUv48ybX7bu8tVpZ2QWdd+xRuePNuGawHjwRUE= github.com/ilyakaznacheev/cleanenv v1.5.0 h1:0VNZXggJE2OYdXE87bfSSwGxeiGt9moSR2lOrsHHvr4= github.com/ilyakaznacheev/cleanenv v1.5.0/go.mod h1:a5aDzaJrLCQZsazHol1w8InnDcOX0OColm64SlIi6gk= github.com/imroc/req/v3 v3.55.0 h1:vg2Q33TGU12wZWZyPkiPbCGGTeiOmlEOdOwHLH03//I= github.com/imroc/req/v3 v3.55.0/go.mod h1:MOn++r2lE4+du3nuefTaPGQ6pY3/yRP2r1pFK1BUqq0= +github.com/imroc/req/v3 v3.60.0 h1:Jc7wtqNg/F5e5ylFxjzG+PYQYkV+0BTMZimPTVcFF0A= +github.com/imroc/req/v3 v3.60.0/go.mod h1:n3pgwn6NCSSdf0JeKcICLEW5Dn37ziZ5FQ995ThjpEw= github.com/inconshreveable/mousetrap v1.1.0 h1:wN+x4NVGpMsO7ErUn/mUI3vEoE6Jt13X2s0bqwp9tc8= github.com/inconshreveable/mousetrap v1.1.0/go.mod h1:vpF70FUmC8bwa3OWnCshd2FqLfsEA9PFc4w1p2J65bw= github.com/influxdata/tdigest v0.0.1 h1:XpFptwYmnEKUqmkcDjrzffswZ3nvNeevbUSLPP/ZzIY= @@ -218,6 +227,8 @@ github.com/kardianos/service v1.2.2 h1:ZvePhAHfvo0A7Mftk/tEzqEZ7Q4lgnR8sGz4xu1YX github.com/kardianos/service v1.2.2/go.mod h1:CIMRFEJVL+0DS1a3Nx06NaMn4Dz63Ng6O7dl0qH0zVM= github.com/klauspost/compress v1.18.0 h1:c/Cqfb0r+Yi+JtIEq73FWXVkRonBlf0CRNYc8Zttxdo= github.com/klauspost/compress v1.18.0/go.mod h1:2Pp+KzxcywXVXMr50+X0Q/Lsb43OQHYWRCY2AiWywWQ= +github.com/klauspost/compress v1.19.1 h1:VsB4HPswih7mmZ8WleSFQ75c/Ui1M4trX5oAsJnhSlk= +github.com/klauspost/compress v1.19.1/go.mod h1:cwPg85FWrGar70rWktvGQj8/hthj3wpl0PGDogxkrSQ= github.com/klauspost/cpuid/v2 v2.0.9/go.mod h1:FInQzS24/EEf25PyTYn52gqo7WaD8xa0213Md/qVLRg= github.com/klauspost/cpuid/v2 v2.2.6 h1:ndNyv040zDGIDh8thGkXYjnFtiN02M1PVVF+JE/48xc= github.com/klauspost/cpuid/v2 v2.2.6/go.mod h1:Lcz8mBdAVJIBVzewtcLocK12l3Y+JytZYpaMropDUws= @@ -257,22 +268,6 @@ github.com/modocache/gover v0.0.0-20171022184752-b58185e213c5/go.mod h1:caMODM3P github.com/montanaflynn/stats v0.7.0/go.mod h1:etXPPgVO6n31NxCd9KQUMvCM+ve0ruNzt6R8Bnaayow= github.com/pelletier/go-toml/v2 v2.2.0 h1:QLgLl2yMN7N+ruc31VynXs1vhMZa7CeHHejIeBAsoHo= github.com/pelletier/go-toml/v2 v2.2.0/go.mod h1:1t835xjRzz80PqgE6HHgN2JOsmgYu/h4qDAS4n929Rs= -github.com/pion/dtls/v2 v2.2.7 h1:cSUBsETxepsCSFSxC3mc/aDo14qQLMSL+O6IjG28yV8= -github.com/pion/dtls/v2 v2.2.7/go.mod h1:8WiMkebSHFD0T+dIU+UeBaoV7kDhOW5oDCzZ7WZ/F9s= -github.com/pion/dtls/v3 v3.0.8 h1:ZrPUrvPVDaTJDM8Vu1veatzXebLlsIWeT7Vaate/zwM= -github.com/pion/dtls/v3 v3.0.8/go.mod h1:abApPjgadS/ra1wvUzHLc3o2HvoxppAh+NZkyApL4Os= -github.com/pion/logging v0.2.2/go.mod h1:k0/tDVsRCX2Mb2ZEmTqNa7CWsQPc+YYCB7Q+5pahoms= -github.com/pion/logging v0.2.4 h1:tTew+7cmQ+Mc1pTBLKH2puKsOvhm32dROumOZ655zB8= -github.com/pion/logging v0.2.4/go.mod h1:DffhXTKYdNZU+KtJ5pyQDjvOAh/GsNSyv1lbkFbe3so= -github.com/pion/stun/v2 v2.0.0 h1:A5+wXKLAypxQri59+tmQKVs7+l6mMM+3d+eER9ifRU0= -github.com/pion/stun/v2 v2.0.0/go.mod h1:22qRSh08fSEttYUmJZGlriq9+03jtVmXNODgLccj8GQ= -github.com/pion/stun/v3 v3.0.2 h1:BJuGEN2oLrJisiNEJtUTJC4BGbzbfp37LizfqswblFU= -github.com/pion/stun/v3 v3.0.2/go.mod h1:JFJKfIWvt178MCF5H/YIgZ4VX3LYE77vca4b9HP60SA= -github.com/pion/transport/v2 v2.2.1 h1:7qYnCBlpgSJNYMbLCKuSY9KbQdBFoETvPNETv0y4N7c= -github.com/pion/transport/v2 v2.2.1/go.mod h1:cXXWavvCnFF6McHTft3DWS9iic2Mftcz1Aq29pGcU5g= -github.com/pion/transport/v3 v3.0.1/go.mod h1:UY7kiITrlMv7/IKgd5eTUcaahZx5oUN3l9SzK5f5xE0= -github.com/pion/transport/v3 v3.1.1 h1:Tr684+fnnKlhPceU+ICdrw6KKkTms+5qHMgw6bIkYOM= -github.com/pion/transport/v3 v3.1.1/go.mod h1:+c2eewC5WJQHiAA46fkMMzoYZSuGzA/7E2FPrOYHctQ= github.com/pires/go-proxyproto v0.7.0 h1:IukmRewDQFWC7kfnb66CSomk2q/seBuilHBYFwyq0Hs= github.com/pires/go-proxyproto v0.7.0/go.mod h1:Vz/1JPY/OACxWGQNIRY2BeyDmpoaWmEP40O9LbuiFR4= github.com/pkg/browser v0.0.0-20210911075715-681adbf594b8 h1:KoWmjvw+nsYOo29YJK9vDA65RGE3NrOnUtO7a+RF9HU= @@ -295,12 +290,18 @@ github.com/prometheus/common v0.48.0 h1:QO8U2CdOzSn1BBsmXJXduaaW+dY/5QLjfB8svtSz github.com/prometheus/common v0.48.0/go.mod h1:0/KsvlIEfPQCQ5I2iNSAWKPZziNCvRs5EC6ILDTlAPc= github.com/prometheus/procfs v0.12.0 h1:jluTpSng7V9hY0O2R9DzzJHYb2xULk9VTR1V1R/k6Bo= github.com/prometheus/procfs v0.12.0/go.mod h1:pcuDEFsWDnvcgNzo4EEweacyhjeA9Zk3cnaOZAZEfOo= -github.com/quic-go/qpack v0.5.1 h1:giqksBPnT/HDtZ6VhtFKgoLOWmlyo9Ei6u9PqzIMbhI= -github.com/quic-go/qpack v0.5.1/go.mod h1:+PC4XFrEskIVkcLzpEkbLqq1uCoxPhQuvK5rH1ZgaEg= -github.com/quic-go/quic-go v0.53.0 h1:QHX46sISpG2S03dPeZBgVIZp8dGagIaiu2FiVYvpCZI= -github.com/quic-go/quic-go v0.53.0/go.mod h1:e68ZEaCdyviluZmy44P6Iey98v/Wfz6HCjQEm+l8zTY= +github.com/quic-go/go-ossfuzz-seeds v0.1.0 h1:APacT+iIaNF6fd8AGEiN3bT/Jtkd2jz4v4TzM7MFjy0= +github.com/quic-go/go-ossfuzz-seeds v0.1.0/go.mod h1:3IOHRbJIc+L6YKMwfDtJAM9Vj9k0YY4muhuyUYk5tbk= +github.com/quic-go/qpack v0.6.0 h1:g7W+BMYynC1LbYLSqRt8PBg5Tgwxn214ZZR34VIOjz8= +github.com/quic-go/qpack v0.6.0/go.mod h1:lUpLKChi8njB4ty2bFLX2x4gzDqXwUpaO1DP9qMDZII= +github.com/quic-go/quic-go v0.60.0 h1:xcQioE8OM66UQLeUMHltK1CCcOu3JbVB4JAQdDQSB+0= +github.com/quic-go/quic-go v0.60.0/go.mod h1:wpKpjmPpftl30sL6pFh7REVpjbcCVy4zt2vDyK1TuJk= +github.com/quic-go/quic-go v0.61.0 h1:ui88A53s8MSVYLC56en0KQ17HARk+9986Dn0SBfKNvA= +github.com/quic-go/quic-go v0.61.0/go.mod h1:9So2anK4Tp22URSQq00k+Vo2PNkle96ycDPDHL4s9vs= github.com/refraction-networking/utls v1.7.3 h1:L0WRhHY7Oq1T0zkdzVZMR6zWZv+sXbHB9zcuvsAEqCo= github.com/refraction-networking/utls v1.7.3/go.mod h1:TUhh27RHMGtQvjQq+RyO11P6ZNQNBb3N0v7wsEjKAIQ= +github.com/refraction-networking/utls v1.8.2 h1:j4Q1gJj0xngdeH+Ox/qND11aEfhpgoEvV+S9iJ2IdQo= +github.com/refraction-networking/utls v1.8.2/go.mod h1:jkSOEkLqn+S/jtpEHPOsVv/4V4EVnelwbMQl4vCWXAM= github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec h1:W09IVJc94icq4NjY3clb7Lk8O1qJ8BdBEF8z0ibU0rE= github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec/go.mod h1:qqbHyh8v60DhA7CoWK5oRCqLrMHRGoxYCSS9EjAz6Eo= github.com/rivo/uniseg v0.4.7 h1:WUdvkW8uEhrYfLC4ZzdpI2ztxP1I582+49Oc5Mq64VQ= @@ -340,7 +341,6 @@ github.com/stretchr/testify v1.7.1/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/ github.com/stretchr/testify v1.8.0/go.mod h1:yNjHg4UonilssWZ8iaSj1OCr/vHnekPRkoO+kdMU+MU= github.com/stretchr/testify v1.8.1/go.mod h1:w2LPCIKwWwSfY2zedu0+kehJoqGctiVI29o6fzry7u4= github.com/stretchr/testify v1.8.2/go.mod h1:w2LPCIKwWwSfY2zedu0+kehJoqGctiVI29o6fzry7u4= -github.com/stretchr/testify v1.8.3/go.mod h1:sz/lmYIOXD/1dqDmKjjqLyZ2RngseejIcXlSw2iwfAo= github.com/stretchr/testify v1.8.4/go.mod h1:sz/lmYIOXD/1dqDmKjjqLyZ2RngseejIcXlSw2iwfAo= github.com/stretchr/testify v1.9.0/go.mod h1:r2ic/lqez/lEtzL7wO/rwa5dbSLXVDPFyf8C91i36aY= github.com/stretchr/testify v1.11.1 h1:7s2iGBzp5EwR7/aIZr8ao5+dra3wiQyKjjFuvgVKu7U= @@ -369,14 +369,13 @@ github.com/vishvananda/netlink v1.3.1 h1:3AEMt62VKqz90r0tmNhog0r/PpWKmrEShJU0wJW github.com/vishvananda/netlink v1.3.1/go.mod h1:ARtKouGSTGchR8aMwmkzC0qiNPrrWO5JS/XMVl45+b4= github.com/vishvananda/netns v0.0.5 h1:DfiHV+j8bA32MFM7bfEunvT8IAqQ/NzSJHtcmW5zdEY= github.com/vishvananda/netns v0.0.5/go.mod h1:SpkAiCQRtJ6TvvxPnOSyH3BMl6unz3xZlaprSwhNNJM= -github.com/wlynxg/anet v0.0.5 h1:J3VJGi1gvo0JwZ/P1/Yc/8p63SoW98B5dHkYDmpgvvU= -github.com/wlynxg/anet v0.0.5/go.mod h1:eay5PRQr7fIVAMbTbchTnO9gG65Hg/uYGdc7mguHxoA= github.com/xtaci/kcp-go/v5 v5.6.13 h1:FEjtz9+D4p8t2x4WjciGt/jsIuhlWjjgPCCWjrVR4Hk= github.com/xtaci/kcp-go/v5 v5.6.13/go.mod h1:75S1AKYYzNUSXIv30h+jPKJYZUwqpfvLshu63nCNSOM= github.com/xtaci/lossyconn v0.0.0-20200209145036-adba10fffc37 h1:EWU6Pktpas0n8lLQwDsRyZfmkPeRbdgPtW609es+/9E= github.com/xtaci/lossyconn v0.0.0-20200209145036-adba10fffc37/go.mod h1:HpMP7DB2CyokmAh4lp0EQnnWhmycP/TvwBGzvuie+H0= github.com/xyproto/randomstring v1.0.5 h1:YtlWPoRdgMu3NZtP45drfy1GKoojuR7hmRcnhZqKjWU= github.com/xyproto/randomstring v1.0.5/go.mod h1:rgmS5DeNXLivK7YprL0pY+lTuhNQW3iGxZ18UQApw/E= +github.com/xyproto/randomstring v1.2.0 h1:y7PXAEBM3XlwJjPG2JQg4voxBYZ4+hPgRdGKCfU8wik= github.com/yuin/goldmark v1.4.13/go.mod h1:6yULJ656Px+3vBD8DxQVa3kxgyrAnzto9xy5taEt/CY= github.com/yusufpapurcu/wmi v1.2.4 h1:zFUKzehAFReQwLys1b/iSMl+JQGSCSjtVqQn9bBrPo0= github.com/yusufpapurcu/wmi v1.2.4/go.mod h1:SBZ9tNy3G9/m5Oi98Zks0QjeHVDvuK0qfxQmPyzfmi0= @@ -388,6 +387,7 @@ go.uber.org/goleak v1.3.0 h1:2K3zAYmnTNqV73imy9J1T3WC+gmCePx2hEGkimedGto= go.uber.org/goleak v1.3.0/go.mod h1:CoHD4mav9JJNrW/WLlf7HGZPjdw8EucARQHekz1X6bE= go.uber.org/mock v0.5.2 h1:LbtPTcP8A5k9WPXj54PPPbjcI4Y6lhyOZXn+VS7wNko= go.uber.org/mock v0.5.2/go.mod h1:wLlUxC2vVTPTaE3UD51E0BGOAElKrILxhVSDYQLld5o= +go.uber.org/mock v0.6.0 h1:hyF9dfmbgIX5EfOdasqLsWD6xqpNZlXblLB/Dbnwv3Y= go.uber.org/multierr v1.11.0 h1:blXXJkSxSSfBVBlC76pxqeO+LN3aDfLQo+309xJstO0= go.uber.org/multierr v1.11.0/go.mod h1:20+QtiLqy0Nd6FdQB9TLXag12DsQkrbs3htMFfDN80Y= go.uber.org/zap v1.26.0 h1:sI7k6L95XOKS281NhVKOFCUNIvv9e0w4BF8N3u+tCRo= @@ -401,11 +401,10 @@ golang.org/x/crypto v0.0.0-20201012173705-84dcc777aaee/go.mod h1:LzIPMQfyMNhhGPh golang.org/x/crypto v0.0.0-20210921155107-089bfa567519/go.mod h1:GvvjBRRGRdwPK5ydBHafDWAxML/pGHZbMvKqRZ5+Abc= golang.org/x/crypto v0.6.0/go.mod h1:OFC/31mSvZgRz0V1QTNCzfAI1aIRzbiufJtkMIlEp58= golang.org/x/crypto v0.7.0/go.mod h1:pYwdfH91IfpZVANVyUOhSIPZaFoJGxTFbZhFTx+dXZU= -golang.org/x/crypto v0.8.0/go.mod h1:mRqEX+O9/h5TFCrQhkgjo2yKi0yYA+9ecGkdQoHrywE= golang.org/x/crypto v0.9.0/go.mod h1:yrmDGqONDYtNj3tH8X9dzUun2m2lzPa9ngI6/RUPGR0= golang.org/x/crypto v0.12.0/go.mod h1:NF0Gs7EO5K4qLn+Ylc+fih8BSTeIjAP05siRnAh98yw= -golang.org/x/crypto v0.42.0 h1:chiH31gIWm57EkTXpwnqf8qeuMUi0yekh6mT2AvFlqI= -golang.org/x/crypto v0.42.0/go.mod h1:4+rDnOTJhQCx2q7/j6rAN5XDw8kPjeaXEUR2eL94ix8= +golang.org/x/crypto v0.54.0 h1:YLIA59K4fiNzHzjnZt2tUJQjQtUWfWbeHBqKtk3eScw= +golang.org/x/crypto v0.54.0/go.mod h1:KWL8ny2AZdGR2cWmzeHrp2azQPGogOv+HeQaVEXC2dk= golang.org/x/exp v0.0.0-20190121172915-509febef88a4/go.mod h1:CJ0aWSM057203Lf6IL+f9T1iT9GByDxfZKAQTCR3kQA= golang.org/x/exp v0.0.0-20250620022241-b7579e27df2b h1:M2rDM6z3Fhozi9O7NWsxAkg/yqS/lQJ6PmkyIV3YP+o= golang.org/x/exp v0.0.0-20250620022241-b7579e27df2b/go.mod h1:3//PLf8L/X+8b4vuAfHzxeRUl04Adcb341+IGKfnqS8= @@ -414,8 +413,6 @@ golang.org/x/lint v0.0.0-20190227174305-5b3e6a55c961/go.mod h1:wehouNa3lNwaWXcvx golang.org/x/lint v0.0.0-20190313153728-d0100b6bd8b3/go.mod h1:6SW0HCj/g11FgYtHlgUYUwCkIfeOF89ocIRzGO/8vkc= golang.org/x/mod v0.6.0-dev.0.20220419223038-86c51ed26bb4/go.mod h1:jJ57K6gSWd91VN4djpZkiMVwK6gcyfeH4XE8wZrZaV4= golang.org/x/mod v0.8.0/go.mod h1:iBbtSCu2XBx23ZKBPSOrRkjjQPZFPuis4dIYUhu/chs= -golang.org/x/mod v0.27.0 h1:kb+q2PyFnEADO2IEF935ehFUXlWiNjJWtRNgBLSfbxQ= -golang.org/x/mod v0.27.0/go.mod h1:rWI627Fq0DEoudcK+MBkNkCe0EetEaDSwJJkCcjpazc= golang.org/x/net v0.0.0-20180724234803-3673e40ba225/go.mod h1:mL1N/T3taQHkDXs73rZJwtUhF3w3ftmwwsq0BUmARs4= golang.org/x/net v0.0.0-20180826012351-8a410e7b638d/go.mod h1:mL1N/T3taQHkDXs73rZJwtUhF3w3ftmwwsq0BUmARs4= golang.org/x/net v0.0.0-20190213061140-3a22650c66bd/go.mod h1:mL1N/T3taQHkDXs73rZJwtUhF3w3ftmwwsq0BUmARs4= @@ -429,21 +426,22 @@ golang.org/x/net v0.0.0-20220722155237-a158d28d115b/go.mod h1:XRhObCWvk6IyKnWLug golang.org/x/net v0.6.0/go.mod h1:2Tu9+aMcznHK/AK1HMvgo6xiTLG5rD5rZLDS+rp2Bjs= golang.org/x/net v0.7.0/go.mod h1:2Tu9+aMcznHK/AK1HMvgo6xiTLG5rD5rZLDS+rp2Bjs= golang.org/x/net v0.8.0/go.mod h1:QVkue5JL9kW//ek3r6jTKnTFis1tRmNAW2P1shuFdJc= -golang.org/x/net v0.9.0/go.mod h1:d48xBJpPfHeWQsugry2m+kC02ZBRGRgulfHnEXEuWns= golang.org/x/net v0.10.0/go.mod h1:0qNGK6F8kojg2nk9dLZ2mShWaEBan6FAoqfSigmmuDg= golang.org/x/net v0.14.0/go.mod h1:PpSgVXXLK0OxS0F31C1/tv6XNguvCrnXIDrFMspZIUI= -golang.org/x/net v0.44.0 h1:evd8IRDyfNBMBTTY5XRF1vaZlD+EmWx6x8PkhR04H/I= -golang.org/x/net v0.44.0/go.mod h1:ECOoLqd5U3Lhyeyo/QDCEVQ4sNgYsqvCZ722XogGieY= +golang.org/x/net v0.56.0 h1:Rw8j/hFzGvJUZwNBXnAtf5sVDVt+65SK2C7IxCxZt5o= +golang.org/x/net v0.56.0/go.mod h1:D3Ku6r+V6JROoZK144D2XfMHFcMq/0zSfLelVTCFKec= +golang.org/x/net v0.57.0 h1:K5+3DljvIuDG9/Jv9rvyMywYNFCQ9RSUY6OOTTkT+tE= +golang.org/x/net v0.57.0/go.mod h1:KpXc8iv+r3XplLAG/f7Jsf9RPszJzdR0f58q9vGOuEU= golang.org/x/oauth2 v0.0.0-20180821212333-d2e6202438be/go.mod h1:N/0e6XlmueqKjAGxoOufVs8QHGRruUQn6yWY3a++T0U= -golang.org/x/oauth2 v0.28.0 h1:CrgCKl8PPAVtLnU3c+EDw6x11699EWlsDeWNWKdIOkc= -golang.org/x/oauth2 v0.28.0/go.mod h1:onh5ek6nERTohokkhCD/y2cV4Do3fxFHFuAejCkRWT8= +golang.org/x/oauth2 v0.36.0 h1:peZ/1z27fi9hUOFCAZaHyrpWG5lwe0RJEEEeH0ThlIs= +golang.org/x/oauth2 v0.36.0/go.mod h1:YDBUJMTkDnJS+A4BP4eZBjCqtokkg1hODuPjwiGPO7Q= golang.org/x/sync v0.0.0-20180314180146-1d60e4601c6f/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= golang.org/x/sync v0.0.0-20181108010431-42b317875d0f/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= golang.org/x/sync v0.0.0-20190423024810-112230192c58/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= golang.org/x/sync v0.0.0-20220722155255-886fb9371eb4/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= golang.org/x/sync v0.1.0/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= -golang.org/x/sync v0.17.0 h1:l60nONMj9l5drqw6jlhIELNv9I0A4OFgRsG9k2oT9Ug= -golang.org/x/sync v0.17.0/go.mod h1:9KTHXmSnoGruLpwFjVSX0lNNA75CykiMECbovNTZqGI= +golang.org/x/sync v0.22.0 h1:SZjpbeLmrCk4xhRSZFNZW5gFUeCeFgjekvI/+gfScek= +golang.org/x/sync v0.22.0/go.mod h1:9xrNwdLfx4jkKbNva9FpL6vEN7evnE43NNNJQ2LF3+0= golang.org/x/sys v0.0.0-20180830151530-49385e6e1522/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY= golang.org/x/sys v0.0.0-20190215142949-d0b11bdaac8a/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY= golang.org/x/sys v0.0.0-20190412213103-97732733099d/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= @@ -461,21 +459,19 @@ golang.org/x/sys v0.0.0-20220722155257-8c9f86f7a55f/go.mod h1:oPkhp1MJrh7nUepCBc golang.org/x/sys v0.2.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= golang.org/x/sys v0.5.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= golang.org/x/sys v0.6.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= -golang.org/x/sys v0.7.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= golang.org/x/sys v0.8.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= golang.org/x/sys v0.10.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= golang.org/x/sys v0.11.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= -golang.org/x/sys v0.36.0 h1:KVRy2GtZBrk1cBYA7MKu5bEZFxQk4NIDV6RLVcC8o0k= -golang.org/x/sys v0.36.0/go.mod h1:OgkHotnGiDImocRcuBABYBEXf8A9a87e/uXjp9XT3ks= +golang.org/x/sys v0.47.0 h1:o7XGOvZQCADBQQ4Y7VNq2dRWQR7JmOUW8Kxx4ZsNgWs= +golang.org/x/sys v0.47.0/go.mod h1:4GL1E5IUh+htKOUEOaiffhrAeqysfVGipDYzABqnCmw= golang.org/x/term v0.0.0-20201126162022-7de9c90e9dd1/go.mod h1:bj7SfCRtBDWHUb9snDiAeCFNEtKQo2Wmx5Cou7ajbmo= golang.org/x/term v0.0.0-20210927222741-03fcf44c2211/go.mod h1:jbD1KX2456YbFQfuXm/mYQcufACuNUgVhRMnK/tPxf8= golang.org/x/term v0.5.0/go.mod h1:jMB1sMXY+tzblOD4FWmEbocvup2/aLOaQEp7JmGp78k= golang.org/x/term v0.6.0/go.mod h1:m6U89DPEgQRMq3DNkDClhWw02AUbt2daBVO4cn4Hv9U= -golang.org/x/term v0.7.0/go.mod h1:P32HKFT3hSsZrRxla30E9HqToFYAQPCMs/zFMBUFqPY= golang.org/x/term v0.8.0/go.mod h1:xPskH00ivmX89bAKVGSKKtLOWNx2+17Eiy94tnKShWo= golang.org/x/term v0.11.0/go.mod h1:zC9APTIj3jG3FdV/Ons+XE1riIZXG4aZ4GTHiPZJPIU= -golang.org/x/term v0.35.0 h1:bZBVKBudEyhRcajGcNc3jIfWPqV4y/Kt2XcoigOWtDQ= -golang.org/x/term v0.35.0/go.mod h1:TPGtkTLesOwf2DE8CgVYiZinHAOuy5AYUYT1lENIZnA= +golang.org/x/term v0.45.0 h1:NwWyBmoJCbfTHpxrWoZ9C6/VxOf7ic219I8xZZFdrf0= +golang.org/x/term v0.45.0/go.mod h1:9aqxs0blBcrm/n0L9QW0aRVD+ktan8ssZromtqJC43w= golang.org/x/text v0.3.0/go.mod h1:NqM8EUOU14njkJ3fqMW+pc6Ldnwhi/IjpwHt7yyuwOQ= golang.org/x/text v0.3.2/go.mod h1:bEr9sfX3Q8Zfm5fL9x+3itogRgK3+ptLWKqgva+5dAk= golang.org/x/text v0.3.3/go.mod h1:5Zoc/QRtKVWzQhOtBMvqHzDpF6irO9z98xDceosuGiQ= @@ -484,10 +480,10 @@ golang.org/x/text v0.7.0/go.mod h1:mrYo+phRRbMaCq/xk9113O4dZlRixOauAjOtrjsXDZ8= golang.org/x/text v0.8.0/go.mod h1:e1OnstbJyHTd6l/uOt8jFFHp6TRDWZR/bV3emEE/zU8= golang.org/x/text v0.9.0/go.mod h1:e1OnstbJyHTd6l/uOt8jFFHp6TRDWZR/bV3emEE/zU8= golang.org/x/text v0.12.0/go.mod h1:TvPlkZtksWOMsz7fbANvkp4WM8x/WCo/om8BMLbz+aE= -golang.org/x/text v0.29.0 h1:1neNs90w9YzJ9BocxfsQNHKuAT4pkghyXc4nhZ6sJvk= -golang.org/x/text v0.29.0/go.mod h1:7MhJOA9CD2qZyOKYazxdYMF85OwPdEr9jTtBpO7ydH4= -golang.org/x/time v0.7.0 h1:ntUhktv3OPE6TgYxXWv9vKvUSJyIFJlyohwbkEwPrKQ= -golang.org/x/time v0.7.0/go.mod h1:3BpzKBy/shNhVucY/MWOyx10tF3SFh9QdLuxbVysPQM= +golang.org/x/text v0.40.0 h1:Ub2Z6/xjgF1WrYQz2nuITOEegKFtiIy+rieRJ5lHZKs= +golang.org/x/text v0.40.0/go.mod h1:hpnzDAfGV753zIKo+wk3u1bVKCGPbrnF7+7LBF/UHVY= +golang.org/x/time v0.10.0 h1:3usCWA8tQn0L8+hFJQNgzpWbd89begxN66o1Ojdn5L4= +golang.org/x/time v0.10.0/go.mod h1:3BpzKBy/shNhVucY/MWOyx10tF3SFh9QdLuxbVysPQM= golang.org/x/tools v0.0.0-20180917221912-90fa682c2a6e/go.mod h1:n7NCudcB/nEzxVGmLbDWY5pfWTLqBcC2KZ6jyYvM4mQ= golang.org/x/tools v0.0.0-20190114222345-bf090417da8b/go.mod h1:n7NCudcB/nEzxVGmLbDWY5pfWTLqBcC2KZ6jyYvM4mQ= golang.org/x/tools v0.0.0-20190226205152-f727befe758c/go.mod h1:9Yl7xja0Znq3iFh3HoIrodX9oNMXvdceNzlUR8zjMvY= @@ -497,8 +493,6 @@ golang.org/x/tools v0.0.0-20190524140312-2c0ae7006135/go.mod h1:RgjU9mgBXZiqYHBn golang.org/x/tools v0.0.0-20191119224855-298f0cb1881e/go.mod h1:b+2E5dAYhXwXZwtnZ6UAqBI28+e2cm9otk0dWdXHAEo= golang.org/x/tools v0.1.12/go.mod h1:hNGJHUnrk76NpqgfD5Aqm5Crs+Hm0VOH/i9J2+nxYbc= golang.org/x/tools v0.6.0/go.mod h1:Xwgl3UAJ/d3gWutnCtw505GrjyAbvKui8lOU390QaIU= -golang.org/x/tools v0.36.0 h1:kWS0uv/zsvHEle1LbV5LE8QujrxB3wfQyxHfhOk0Qkg= -golang.org/x/tools v0.36.0/go.mod h1:WBDiHKJK8YgLHlcQPYQzNCkUxUypCaa5ZegCVutKm+s= golang.org/x/xerrors v0.0.0-20190717185122-a985d3407aa7/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0= golang.org/x/xerrors v0.0.0-20191204190536-9bdfabe68543/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0= golang.zx2c4.com/wintun v0.0.0-20230126152724-0fa3db229ce2 h1:B82qJJgjvYKsXS9jeunTOisW56dUokqW/FOteYJJ/yg= @@ -555,6 +549,7 @@ gorm.io/plugin/dbresolver v1.5.2 h1:Iut7lW4TXNoVs++I+ra3zxjSxTRj4ocIeFEVp4lLhII= gorm.io/plugin/dbresolver v1.5.2/go.mod h1:jPh59GOQbO7v7v28ZKZPd45tr+u3vyT+8tHdfdfOWcU= gotest.tools/v3 v3.5.1 h1:EENdUnS3pdur5nybKYIh2Vfgc8IUNBjxDPSjtiJcOzU= gotest.tools/v3 v3.5.1/go.mod h1:isy3WKz7GK6uNw/sbHzfKBLvlvXwUyV06n6brMxxopU= +gotest.tools/v3 v3.5.2 h1:7koQfIKdy+I8UTetycgUqXWSDwpgv193Ka+qRsmBY8Q= gvisor.dev/gvisor v0.0.0-20250503011706-39ed1f5ac29c h1:m/r7OM+Y2Ty1sgBQ7Qb27VgIMBW8ZZhT4gLnUyDIhzI= gvisor.dev/gvisor v0.0.0-20250503011706-39ed1f5ac29c/go.mod h1:3r5CMtNQMKIvBlrmM9xWUNamjKBYPOWyXOjmg5Kts3g= honnef.co/go/tools v0.0.0-20190102054323-c2f93a96b099/go.mod h1:rf3lG4BRIbNafJWhAfAdb/ePZxsR/4RtNHQocxwk9r4= diff --git a/hack/frpdrift/known.txt b/hack/frpdrift/known.txt new file mode 100644 index 00000000..f6130166 --- /dev/null +++ b/hack/frpdrift/known.txt @@ -0,0 +1,430 @@ +# frp config field paths reviewed against FEATURE-MATRIX.md. +# Regenerate with: go run ./hack/frpdrift -write +client auth.additionalScopes[] +client auth.method +client auth.oidc.additionalEndpointParams +client auth.oidc.audience +client auth.oidc.clientID +client auth.oidc.clientSecret +client auth.oidc.insecureSkipVerify +client auth.oidc.proxyURL +client auth.oidc.scope +client auth.oidc.tokenEndpointURL +client auth.oidc.tokenSource.exec.args[] +client auth.oidc.tokenSource.exec.command +client auth.oidc.tokenSource.exec.env[].name +client auth.oidc.tokenSource.exec.env[].value +client auth.oidc.tokenSource.file.path +client auth.oidc.tokenSource.type +client auth.oidc.trustedCaFile +client auth.token +client auth.tokenSource.exec.args[] +client auth.tokenSource.exec.command +client auth.tokenSource.exec.env[].name +client auth.tokenSource.exec.env[].value +client auth.tokenSource.file.path +client auth.tokenSource.type +client clientID +client dnsServer +client featureGates +client includes[] +client log.disablePrintColor +client log.level +client log.maxDays +client log.to +client loginFailExit +client metadatas +client natHoleStunServer +client serverAddr +client serverPort +client start[] +client store.path +client transport.connectServerLocalIP +client transport.dialServerKeepalive +client transport.dialServerTimeout +client transport.heartbeatInterval +client transport.heartbeatTimeout +client transport.poolCount +client transport.protocol +client transport.proxyURL +client transport.quic.keepalivePeriod +client transport.quic.maxIdleTimeout +client transport.quic.maxIncomingStreams +client transport.tcpMux +client transport.tcpMuxKeepaliveInterval +client transport.tls.certFile +client transport.tls.disableCustomTLSFirstByte +client transport.tls.enable +client transport.tls.keyFile +client transport.tls.serverName +client transport.tls.trustedCaFile +client transport.wireProtocol +client udpPacketSize +client user +client version +client virtualNet.address +client webServer.addr +client webServer.assetsDir +client webServer.password +client webServer.port +client webServer.pprofEnable +client webServer.tls.certFile +client webServer.tls.keyFile +client webServer.tls.serverName +client webServer.tls.trustedCaFile +client webServer.user +plugin.http2http hostHeaderRewrite +plugin.http2http localAddr +plugin.http2http requestHeaders.set +plugin.http2http type +plugin.http2https hostHeaderRewrite +plugin.http2https localAddr +plugin.http2https requestHeaders.set +plugin.http2https type +plugin.http_proxy httpPassword +plugin.http_proxy httpUser +plugin.http_proxy type +plugin.https2http crtPath +plugin.https2http enableHTTP2 +plugin.https2http hostHeaderRewrite +plugin.https2http keyPath +plugin.https2http localAddr +plugin.https2http requestHeaders.set +plugin.https2http type +plugin.https2https crtPath +plugin.https2https enableHTTP2 +plugin.https2https hostHeaderRewrite +plugin.https2https keyPath +plugin.https2https localAddr +plugin.https2https requestHeaders.set +plugin.https2https type +plugin.socks5 password +plugin.socks5 type +plugin.socks5 username +plugin.static_file httpPassword +plugin.static_file httpUser +plugin.static_file localPath +plugin.static_file stripPrefix +plugin.static_file type +plugin.tls2raw crtPath +plugin.tls2raw keyPath +plugin.tls2raw localAddr +plugin.tls2raw type +plugin.unix_domain_socket type +plugin.unix_domain_socket unixPath +plugin.virtual_net type +proxy.http annotations +proxy.http customDomains[] +proxy.http enabled +proxy.http healthCheck.httpHeaders[].name +proxy.http healthCheck.httpHeaders[].value +proxy.http healthCheck.intervalSeconds +proxy.http healthCheck.maxFailed +proxy.http healthCheck.path +proxy.http healthCheck.timeoutSeconds +proxy.http healthCheck.type +proxy.http hostHeaderRewrite +proxy.http httpPassword +proxy.http httpUser +proxy.http loadBalancer.group +proxy.http loadBalancer.groupKey +proxy.http localIP +proxy.http localPort +proxy.http locations[] +proxy.http metadatas +proxy.http name +proxy.http plugin +proxy.http requestHeaders.set +proxy.http responseHeaders.set +proxy.http routeByHTTPUser +proxy.http subdomain +proxy.http transport.bandwidthLimit +proxy.http transport.bandwidthLimitMode +proxy.http transport.proxyProtocolVersion +proxy.http transport.useCompression +proxy.http transport.useEncryption +proxy.http type +proxy.https annotations +proxy.https customDomains[] +proxy.https enabled +proxy.https healthCheck.httpHeaders[].name +proxy.https healthCheck.httpHeaders[].value +proxy.https healthCheck.intervalSeconds +proxy.https healthCheck.maxFailed +proxy.https healthCheck.path +proxy.https healthCheck.timeoutSeconds +proxy.https healthCheck.type +proxy.https loadBalancer.group +proxy.https loadBalancer.groupKey +proxy.https localIP +proxy.https localPort +proxy.https metadatas +proxy.https name +proxy.https plugin +proxy.https subdomain +proxy.https transport.bandwidthLimit +proxy.https transport.bandwidthLimitMode +proxy.https transport.proxyProtocolVersion +proxy.https transport.useCompression +proxy.https transport.useEncryption +proxy.https type +proxy.stcp allowUsers[] +proxy.stcp annotations +proxy.stcp enabled +proxy.stcp healthCheck.httpHeaders[].name +proxy.stcp healthCheck.httpHeaders[].value +proxy.stcp healthCheck.intervalSeconds +proxy.stcp healthCheck.maxFailed +proxy.stcp healthCheck.path +proxy.stcp healthCheck.timeoutSeconds +proxy.stcp healthCheck.type +proxy.stcp loadBalancer.group +proxy.stcp loadBalancer.groupKey +proxy.stcp localIP +proxy.stcp localPort +proxy.stcp metadatas +proxy.stcp name +proxy.stcp plugin +proxy.stcp secretKey +proxy.stcp transport.bandwidthLimit +proxy.stcp transport.bandwidthLimitMode +proxy.stcp transport.proxyProtocolVersion +proxy.stcp transport.useCompression +proxy.stcp transport.useEncryption +proxy.stcp type +proxy.sudp allowUsers[] +proxy.sudp annotations +proxy.sudp enabled +proxy.sudp healthCheck.httpHeaders[].name +proxy.sudp healthCheck.httpHeaders[].value +proxy.sudp healthCheck.intervalSeconds +proxy.sudp healthCheck.maxFailed +proxy.sudp healthCheck.path +proxy.sudp healthCheck.timeoutSeconds +proxy.sudp healthCheck.type +proxy.sudp loadBalancer.group +proxy.sudp loadBalancer.groupKey +proxy.sudp localIP +proxy.sudp localPort +proxy.sudp metadatas +proxy.sudp name +proxy.sudp plugin +proxy.sudp secretKey +proxy.sudp transport.bandwidthLimit +proxy.sudp transport.bandwidthLimitMode +proxy.sudp transport.proxyProtocolVersion +proxy.sudp transport.useCompression +proxy.sudp transport.useEncryption +proxy.sudp type +proxy.tcp annotations +proxy.tcp enabled +proxy.tcp healthCheck.httpHeaders[].name +proxy.tcp healthCheck.httpHeaders[].value +proxy.tcp healthCheck.intervalSeconds +proxy.tcp healthCheck.maxFailed +proxy.tcp healthCheck.path +proxy.tcp healthCheck.timeoutSeconds +proxy.tcp healthCheck.type +proxy.tcp loadBalancer.group +proxy.tcp loadBalancer.groupKey +proxy.tcp localIP +proxy.tcp localPort +proxy.tcp metadatas +proxy.tcp name +proxy.tcp plugin +proxy.tcp remotePort +proxy.tcp transport.bandwidthLimit +proxy.tcp transport.bandwidthLimitMode +proxy.tcp transport.proxyProtocolVersion +proxy.tcp transport.useCompression +proxy.tcp transport.useEncryption +proxy.tcp type +proxy.tcpmux annotations +proxy.tcpmux customDomains[] +proxy.tcpmux enabled +proxy.tcpmux healthCheck.httpHeaders[].name +proxy.tcpmux healthCheck.httpHeaders[].value +proxy.tcpmux healthCheck.intervalSeconds +proxy.tcpmux healthCheck.maxFailed +proxy.tcpmux healthCheck.path +proxy.tcpmux healthCheck.timeoutSeconds +proxy.tcpmux healthCheck.type +proxy.tcpmux httpPassword +proxy.tcpmux httpUser +proxy.tcpmux loadBalancer.group +proxy.tcpmux loadBalancer.groupKey +proxy.tcpmux localIP +proxy.tcpmux localPort +proxy.tcpmux metadatas +proxy.tcpmux multiplexer +proxy.tcpmux name +proxy.tcpmux plugin +proxy.tcpmux routeByHTTPUser +proxy.tcpmux subdomain +proxy.tcpmux transport.bandwidthLimit +proxy.tcpmux transport.bandwidthLimitMode +proxy.tcpmux transport.proxyProtocolVersion +proxy.tcpmux transport.useCompression +proxy.tcpmux transport.useEncryption +proxy.tcpmux type +proxy.udp annotations +proxy.udp enabled +proxy.udp healthCheck.httpHeaders[].name +proxy.udp healthCheck.httpHeaders[].value +proxy.udp healthCheck.intervalSeconds +proxy.udp healthCheck.maxFailed +proxy.udp healthCheck.path +proxy.udp healthCheck.timeoutSeconds +proxy.udp healthCheck.type +proxy.udp loadBalancer.group +proxy.udp loadBalancer.groupKey +proxy.udp localIP +proxy.udp localPort +proxy.udp metadatas +proxy.udp name +proxy.udp plugin +proxy.udp remotePort +proxy.udp transport.bandwidthLimit +proxy.udp transport.bandwidthLimitMode +proxy.udp transport.proxyProtocolVersion +proxy.udp transport.useCompression +proxy.udp transport.useEncryption +proxy.udp type +proxy.xtcp allowUsers[] +proxy.xtcp annotations +proxy.xtcp enabled +proxy.xtcp healthCheck.httpHeaders[].name +proxy.xtcp healthCheck.httpHeaders[].value +proxy.xtcp healthCheck.intervalSeconds +proxy.xtcp healthCheck.maxFailed +proxy.xtcp healthCheck.path +proxy.xtcp healthCheck.timeoutSeconds +proxy.xtcp healthCheck.type +proxy.xtcp loadBalancer.group +proxy.xtcp loadBalancer.groupKey +proxy.xtcp localIP +proxy.xtcp localPort +proxy.xtcp metadatas +proxy.xtcp name +proxy.xtcp natTraversal.disableAssistedAddrs +proxy.xtcp plugin +proxy.xtcp secretKey +proxy.xtcp transport.bandwidthLimit +proxy.xtcp transport.bandwidthLimitMode +proxy.xtcp transport.proxyProtocolVersion +proxy.xtcp transport.useCompression +proxy.xtcp transport.useEncryption +proxy.xtcp type +server allowPorts[].end +server allowPorts[].single +server allowPorts[].start +server auth.additionalScopes[] +server auth.method +server auth.oidc.audience +server auth.oidc.issuer +server auth.oidc.skipExpiryCheck +server auth.oidc.skipIssuerCheck +server auth.token +server auth.tokenSource.exec.args[] +server auth.tokenSource.exec.command +server auth.tokenSource.exec.env[].name +server auth.tokenSource.exec.env[].value +server auth.tokenSource.file.path +server auth.tokenSource.type +server bindAddr +server bindPort +server custom404Page +server detailedErrorsToClient +server enablePrometheus +server httpPlugins[].addr +server httpPlugins[].name +server httpPlugins[].ops[] +server httpPlugins[].path +server httpPlugins[].tlsVerify +server kcpBindPort +server log.disablePrintColor +server log.level +server log.maxDays +server log.to +server maxPortsPerClient +server natholeAnalysisDataReserveHours +server proxyBindAddr +server quicBindPort +server sshTunnelGateway.authorizedKeysFile +server sshTunnelGateway.autoGenPrivateKeyPath +server sshTunnelGateway.bindPort +server sshTunnelGateway.privateKeyFile +server subDomainHost +server tcpmuxHTTPConnectPort +server tcpmuxPassthrough +server transport.heartbeatTimeout +server transport.maxPoolCount +server transport.quic.keepalivePeriod +server transport.quic.maxIdleTimeout +server transport.quic.maxIncomingStreams +server transport.tcpKeepalive +server transport.tcpMux +server transport.tcpMuxKeepaliveInterval +server transport.tls.certFile +server transport.tls.force +server transport.tls.keyFile +server transport.tls.serverName +server transport.tls.trustedCaFile +server udpPacketSize +server userConnTimeout +server version +server vhostHTTPPort +server vhostHTTPSPort +server vhostHTTPTimeout +server webServer.addr +server webServer.assetsDir +server webServer.password +server webServer.port +server webServer.pprofEnable +server webServer.tls.certFile +server webServer.tls.keyFile +server webServer.tls.serverName +server webServer.tls.trustedCaFile +server webServer.user +visitor.stcp bindAddr +visitor.stcp bindPort +visitor.stcp enabled +visitor.stcp name +visitor.stcp plugin +visitor.stcp secretKey +visitor.stcp serverName +visitor.stcp serverUser +visitor.stcp transport.useCompression +visitor.stcp transport.useEncryption +visitor.stcp type +visitor.sudp bindAddr +visitor.sudp bindPort +visitor.sudp enabled +visitor.sudp name +visitor.sudp plugin +visitor.sudp secretKey +visitor.sudp serverName +visitor.sudp serverUser +visitor.sudp transport.useCompression +visitor.sudp transport.useEncryption +visitor.sudp type +visitor.xtcp bindAddr +visitor.xtcp bindPort +visitor.xtcp enabled +visitor.xtcp fallbackTimeoutMs +visitor.xtcp fallbackTo +visitor.xtcp keepTunnelOpen +visitor.xtcp maxRetriesAnHour +visitor.xtcp minRetryInterval +visitor.xtcp name +visitor.xtcp natTraversal.disableAssistedAddrs +visitor.xtcp plugin +visitor.xtcp protocol +visitor.xtcp secretKey +visitor.xtcp serverName +visitor.xtcp serverUser +visitor.xtcp transport.useCompression +visitor.xtcp transport.useEncryption +visitor.xtcp type +visitorPlugin.virtual_net destinationIP +visitorPlugin.virtual_net type diff --git a/hack/frpdrift/main.go b/hack/frpdrift/main.go new file mode 100644 index 00000000..1bbf894e --- /dev/null +++ b/hack/frpdrift/main.go @@ -0,0 +1,171 @@ +// Command frpdrift answers one question: has frp gained or dropped a config field since +// FEATURE-MATRIX.md was last reviewed? +// +// It reflects over frp's config types -- the frpc common config, the frps config, every +// proxy and visitor type and every client and visitor plugin -- and compares the JSON +// field paths with known.txt, the set accepted at the last review. +// +// go run ./hack/frpdrift # compare with known.txt; exit 1 on drift +// go run ./hack/frpdrift -write # accept the current set as reviewed +// go run ./hack/frpdrift -list # print every path +// +// Run it on every frp bump, review each added path against FEATURE-MATRIX.md, then +// -write. It deliberately is not wired into CI: drift is a review prompt, not a failure. +// +// It cannot see a new proxy, visitor or plugin *type*: frp's type registries are +// unexported, so the type names below are listed by hand. frp's release notes still need +// reading for those. +package main + +import ( + "bufio" + "encoding/json" + "flag" + "fmt" + "os" + "reflect" + "sort" + "strings" + + v1 "github.com/fatedier/frp/pkg/config/v1" +) + +var ( + proxyTypes = []v1.ProxyType{ + v1.ProxyTypeTCP, v1.ProxyTypeUDP, v1.ProxyTypeTCPMUX, v1.ProxyTypeHTTP, + v1.ProxyTypeHTTPS, v1.ProxyTypeSTCP, v1.ProxyTypeXTCP, v1.ProxyTypeSUDP, + } + visitorTypes = []v1.VisitorType{v1.VisitorTypeSTCP, v1.VisitorTypeXTCP, v1.VisitorTypeSUDP} + pluginTypes = []string{ + v1.PluginHTTP2HTTPS, v1.PluginHTTPProxy, v1.PluginHTTPS2HTTP, v1.PluginHTTPS2HTTPS, + v1.PluginHTTP2HTTP, v1.PluginSocks5, v1.PluginStaticFile, v1.PluginUnixDomainSocket, + v1.PluginTLS2Raw, v1.PluginVirtualNet, + } + visitorPluginTypes = []string{v1.VisitorPluginVirtualNet} +) + +// currentPaths is every " " line for the frp linked into this binary. +func currentPaths() ([]string, error) { + roots := map[string]reflect.Type{ + "client": reflect.TypeFor[v1.ClientCommonConfig](), + "server": reflect.TypeFor[v1.ServerConfig](), + } + for _, t := range proxyTypes { + c := v1.NewProxyConfigurerByType(t) + if c == nil { + return nil, fmt.Errorf("frp no longer knows proxy type %q", t) + } + roots["proxy."+string(t)] = reflect.TypeOf(c) + } + for _, t := range visitorTypes { + c := v1.NewVisitorConfigurerByType(t) + if c == nil { + return nil, fmt.Errorf("frp no longer knows visitor type %q", t) + } + roots["visitor."+string(t)] = reflect.TypeOf(c) + } + // Plugin option types are only reachable through the typed wrappers' decoders. + for _, t := range pluginTypes { + var p v1.TypedClientPluginOptions + if err := json.Unmarshal([]byte(fmt.Sprintf(`{"type":%q}`, t)), &p); err != nil || p.ClientPluginOptions == nil { + return nil, fmt.Errorf("frp no longer knows client plugin %q: %v", t, err) + } + roots["plugin."+t] = reflect.TypeOf(p.ClientPluginOptions) + } + for _, t := range visitorPluginTypes { + var p v1.TypedVisitorPluginOptions + if err := json.Unmarshal([]byte(fmt.Sprintf(`{"type":%q}`, t)), &p); err != nil || p.VisitorPluginOptions == nil { + return nil, fmt.Errorf("frp no longer knows visitor plugin %q: %v", t, err) + } + roots["visitorPlugin."+t] = reflect.TypeOf(p.VisitorPluginOptions) + } + + var out []string + for root, t := range roots { + for _, p := range fieldPaths(t) { + out = append(out, root+" "+p) + } + } + sort.Strings(out) + return out, nil +} + +func readKnown(path string) (map[string]bool, error) { + f, err := os.Open(path) + if err != nil { + return nil, err + } + defer f.Close() + known := map[string]bool{} + sc := bufio.NewScanner(f) + for sc.Scan() { + line := strings.TrimSpace(sc.Text()) + if line == "" || strings.HasPrefix(line, "#") { + continue + } + known[line] = true + } + return known, sc.Err() +} + +// diff returns the paths in current but not in known, and the reverse. +func diff(current []string, known map[string]bool) (added, removed []string) { + seen := map[string]bool{} + for _, p := range current { + seen[p] = true + if !known[p] { + added = append(added, p) + } + } + for p := range known { + if !seen[p] { + removed = append(removed, p) + } + } + sort.Strings(removed) + return added, removed +} + +func main() { + knownPath := flag.String("known", "hack/frpdrift/known.txt", "reviewed field paths") + write := flag.Bool("write", false, "accept the current paths as reviewed") + list := flag.Bool("list", false, "print every current path") + flag.Parse() + + current, err := currentPaths() + if err != nil { + fmt.Fprintln(os.Stderr, "frpdrift:", err) + os.Exit(2) + } + + switch { + case *list: + fmt.Println(strings.Join(current, "\n")) + case *write: + body := "# frp config field paths reviewed against FEATURE-MATRIX.md.\n" + + "# Regenerate with: go run ./hack/frpdrift -write\n" + strings.Join(current, "\n") + "\n" + if err := os.WriteFile(*knownPath, []byte(body), 0o644); err != nil { + fmt.Fprintln(os.Stderr, "frpdrift:", err) + os.Exit(2) + } + fmt.Printf("wrote %d paths to %s\n", len(current), *knownPath) + default: + known, err := readKnown(*knownPath) + if err != nil { + fmt.Fprintln(os.Stderr, "frpdrift:", err) + os.Exit(2) + } + added, removed := diff(current, known) + for _, p := range added { + fmt.Println("+ " + p) + } + for _, p := range removed { + fmt.Println("- " + p) + } + if len(added)+len(removed) > 0 { + fmt.Fprintf(os.Stderr, "frpdrift: %d added, %d removed since the last review\n", len(added), len(removed)) + os.Exit(1) + } + fmt.Printf("frpdrift: no drift (%d paths)\n", len(current)) + } +} diff --git a/hack/frpdrift/walk.go b/hack/frpdrift/walk.go new file mode 100644 index 00000000..4cac1dbc --- /dev/null +++ b/hack/frpdrift/walk.go @@ -0,0 +1,108 @@ +package main + +import ( + "encoding" + "encoding/json" + "reflect" + "sort" + "strings" +) + +var ( + jsonMarshaler = reflect.TypeFor[json.Marshaler]() + textMarshaler = reflect.TypeFor[encoding.TextMarshaler]() +) + +// fieldPaths returns the JSON field path of every leaf reachable from t, the way +// encoding/json -- and therefore frp's strict decoder -- sees it: +// +// - the JSON name comes from the tag; untagged fields use the Go name; +// - `json:"-"` and unexported fields are skipped; +// - an embedded struct without a JSON name is inlined into its parent, which is how +// frp flattens ProxyBaseConfig, DomainConfig, ProxyBackend and TLSConfig; +// - a type with its own JSON or text marshalling, a map, or an interface is a leaf, +// because its keys are not a fixed field set; +// - slices and arrays of structs continue as "path[].field". +func fieldPaths(t reflect.Type) []string { + set := map[string]struct{}{} + walk(t, "", set, map[reflect.Type]bool{}) + out := make([]string, 0, len(set)) + for p := range set { + out = append(out, p) + } + sort.Strings(out) + return out +} + +func walk(t reflect.Type, prefix string, out map[string]struct{}, onPath map[reflect.Type]bool) { + for t.Kind() == reflect.Pointer { + t = t.Elem() + } + + if prefix != "" && isLeaf(t) { + out[prefix] = struct{}{} + return + } + + switch t.Kind() { + case reflect.Slice, reflect.Array: + walk(t.Elem(), prefix+"[]", out, onPath) + return + case reflect.Struct: + default: + if prefix != "" { + out[prefix] = struct{}{} + } + return + } + + if onPath[t] { // a recursive type; frp has none today, but do not loop if it gains one + out[prefix] = struct{}{} + return + } + onPath[t] = true + defer delete(onPath, t) + + for i := 0; i < t.NumField(); i++ { + f := t.Field(i) + name, _, _ := strings.Cut(f.Tag.Get("json"), ",") + if name == "-" { + continue + } + if f.Anonymous && name == "" { + ft := f.Type + for ft.Kind() == reflect.Pointer { + ft = ft.Elem() + } + if ft.Kind() == reflect.Struct && !isLeaf(ft) { + walk(ft, prefix, out, onPath) // inlined + } + // An embedded interface (frp's TypedClientPluginOptions) carries no fields of + // its own here; plugin option types are separate roots. + continue + } + if !f.IsExported() { + continue + } + if name == "" { + name = f.Name + } + path := name + if prefix != "" { + path = prefix + "." + name + } + walk(f.Type, path, out, onPath) + } +} + +func isLeaf(t reflect.Type) bool { + if t.Implements(jsonMarshaler) || reflect.PointerTo(t).Implements(jsonMarshaler) || + t.Implements(textMarshaler) || reflect.PointerTo(t).Implements(textMarshaler) { + return true + } + switch t.Kind() { + case reflect.Map, reflect.Interface: + return true + } + return false +} diff --git a/hack/frpdrift/walk_test.go b/hack/frpdrift/walk_test.go new file mode 100644 index 00000000..71933e15 --- /dev/null +++ b/hack/frpdrift/walk_test.go @@ -0,0 +1,109 @@ +package main + +import ( + "reflect" + "slices" + "strings" + "testing" + "time" +) + +type leafText struct{ v string } + +func (l leafText) MarshalText() ([]byte, error) { return []byte(l.v), nil } + +type inlineBase struct { + Name string `json:"name"` +} + +type nested struct { + Port int `json:"port,omitempty"` +} + +type sample struct { + inlineBase // untagged embed: inlined + Tagged *nested `json:"tagged"` + List []nested `json:"list"` + Strings []string `json:"strings"` + Meta map[string]string `json:"meta"` + Text leafText `json:"text"` + When time.Time `json:"when"` // has its own marshalling + Skipped string `json:"-"` + Untagged bool // Go name + Any interface{} `json:"any"` + hidden string //nolint:unused // unexported: skipped + Named inlineBase `json:"named"` // tagged embed-like field: nested + Deep map[string][]nested `json:"deep"` +} + +func TestFieldPaths(t *testing.T) { + got := fieldPaths(reflect.TypeFor[sample]()) + want := []string{ + "Untagged", "any", "deep", "list[].port", "meta", "name", "named.name", + "strings[]", "tagged.port", "text", "when", + } + if !slices.Equal(got, want) { + t.Fatalf("fieldPaths:\n got %v\n want %v", got, want) + } +} + +type loop struct { + Next *loop `json:"next"` + ID int `json:"id"` +} + +func TestFieldPathsStopsOnRecursion(t *testing.T) { + got := fieldPaths(reflect.TypeFor[loop]()) + if !slices.Equal(got, []string{"id", "next"}) { + t.Fatalf("fieldPaths = %v", got) + } +} + +// Pins facts the matrix relies on, against the frp actually linked in. +func TestCurrentPathsAgainstLinkedFrp(t *testing.T) { + paths, err := currentPaths() + if err != nil { + t.Fatal(err) + } + has := func(p string) bool { return slices.Contains(paths, p) } + + for _, p := range []string{ + "client transport.wireProtocol", + "client featureGates", + "server httpPlugins[].ops[]", + "proxy.http requestHeaders.set", + "proxy.http responseHeaders.set", + "proxy.tcp enabled", + "proxy.https plugin", // typed plugin wrapper: a leaf, its options are plugin.* roots + "plugin.https2http enableHTTP2", + "plugin.http2http requestHeaders.set", + "visitor.xtcp natTraversal.disableAssistedAddrs", + // TLSConfig is embedded without a tag, so its fields sit next to `enable`. + "client transport.tls.certFile", + } { + if !has(p) { + t.Errorf("missing %q", p) + } + } + + // PLG-04: frp's plugins have no responseHeaders; only HTTPProxyConfig does. + for _, p := range paths { + if strings.HasPrefix(p, "plugin.") && strings.Contains(p, "responseHeaders") { + t.Errorf("unexpected %q", p) + } + } + if has("client transport.tls.tls.certFile") { + t.Error("TLSConfig is nested, not inlined (BUG-05 shape)") + } +} + +func TestDiff(t *testing.T) { + added, removed := diff([]string{"a", "b"}, map[string]bool{"b": true, "c": true}) + if !slices.Equal(added, []string{"a"}) || !slices.Equal(removed, []string{"c"}) { + t.Fatalf("diff = %v, %v", added, removed) + } + added, removed = diff(nil, map[string]bool{}) + if len(added)+len(removed) != 0 { + t.Fatalf("empty diff = %v, %v", added, removed) + } +} diff --git a/idl/api_master.proto b/idl/api_master.proto index 06972e98..414312a4 100644 --- a/idl/api_master.proto +++ b/idl/api_master.proto @@ -28,6 +28,9 @@ message ClientVersion { string Compiler = 5; string Platform = 6; string GitBranch = 7; + // The frp library version the agent is built with. Empty from agents that predate + // the field, which callers must treat as "too old" (fail closed). + string FrpVersion = 8; } message GetClientsStatusRequest { diff --git a/internal/frpx/client.go b/internal/frpx/client.go new file mode 100644 index 00000000..ece6afd0 --- /dev/null +++ b/internal/frpx/client.go @@ -0,0 +1,84 @@ +package frpx + +import ( + "github.com/fatedier/frp/client" + "github.com/fatedier/frp/client/proxy" + "github.com/fatedier/frp/pkg/config" + "github.com/fatedier/frp/pkg/config/source" + v1 "github.com/fatedier/frp/pkg/config/v1" + "github.com/fatedier/frp/pkg/config/v1/validation" + "github.com/fatedier/frp/pkg/policy/featuregate" +) + +// ClientService is a running frpc. +type ClientService = client.Service + +// ProxyWorkingStatus is the runtime status of a single proxy on the client side. +type ProxyWorkingStatus = proxy.WorkingStatus + +// ClientOptions is frp-panel's stable view of frp's client.ServiceOptions. +// +// frp restructured ServiceOptions in v0.68: ProxyCfgs/VisitorCfgs were removed and a +// config-source aggregator became mandatory. This struct keeps the original shape so the +// call site in services/client does not move with frp. +type ClientOptions struct { + Common *v1.ClientCommonConfig + ProxyCfgs []v1.ProxyConfigurer + VisitorCfgs []v1.VisitorConfigurer +} + +// NewClientService constructs an frpc service. It does not start it. +// +// The aggregator is deliberately left with only a config source. frp's other source is +// the frpc-local store ([store] in frpc.toml, new in v0.68), which frp-panel must not +// enable: the store persists proxies frpc-side and the aggregator merges them back in, +// which would resurrect proxies the master has deleted. +func NewClientService(o ClientOptions) (*ClientService, error) { + cfgSource := source.NewConfigSource() + if err := cfgSource.ReplaceAll(o.ProxyCfgs, o.VisitorCfgs); err != nil { + return nil, err + } + + return client.NewService(client.ServiceOptions{ + Common: o.Common, + ConfigSourceAggregator: source.NewAggregator(cfgSource), + }) +} + +// UpdateClientConfigurers replaces the proxies and visitors of a running frpc. +// +// frp applies FilterClientConfigurers -- the start[] allow-list and each proxy's and +// visitor's `enabled` -- only when it builds its config itself: in NewService and when +// reloading from its config source. Service.UpdateAllConfigurer takes the slices as +// given. Calling it directly therefore left a proxy switched to `enabled: false` +// running until the agent restarted (BUG-12). Every hot update must come through here. +func UpdateClientConfigurers( + svc *ClientService, + common *v1.ClientCommonConfig, + proxyCfgs []v1.ProxyConfigurer, + visitorCfgs []v1.VisitorConfigurer, +) error { + proxyCfgs, visitorCfgs = config.FilterClientConfigurers(common, proxyCfgs, visitorCfgs) + return svc.UpdateAllConfigurer(proxyCfgs, visitorCfgs) +} + +// SetFeatureGates enables or disables frp feature gates process-wide. +func SetFeatureGates(m map[string]bool) error { + return featuregate.SetFromMap(m) +} + +// ValidateClientConfig validates a complete frpc config. The first return is a +// non-fatal warning; the second is a fatal error. +// +// The nil unsafe-feature set (added as a parameter in v0.68) means no opt-in unsafe +// feature is permitted. frp's only such feature is TokenSourceExec, which runs an +// external command to fetch an OIDC token; frp-panel does not use it. The nil is safe: +// UnsafeFeatures.IsEnabled guards against a nil receiver. +func ValidateClientConfig( + c *v1.ClientCommonConfig, + proxyCfgs []v1.ProxyConfigurer, + visitorCfgs []v1.VisitorConfigurer, +) (error, error) { + warning, err := validation.ValidateAllClientConfig(c, proxyCfgs, visitorCfgs, nil) + return warning, err +} diff --git a/internal/frpx/config.go b/internal/frpx/config.go new file mode 100644 index 00000000..fda878e6 --- /dev/null +++ b/internal/frpx/config.go @@ -0,0 +1,47 @@ +package frpx + +import ( + "github.com/fatedier/frp/pkg/config" + v1 "github.com/fatedier/frp/pkg/config/v1" +) + +// TemplateValues are the values frp exposes to config templates. +type TemplateValues = config.Values + +// TemplateDefaults returns the default template values (env, etc.). +func TemplateDefaults() *TemplateValues { + return config.GetValues() +} + +// RenderTemplate expands a config's template directives. +func RenderTemplate(content []byte, values *TemplateValues) ([]byte, error) { + return config.RenderWithTemplate(content, values) +} + +// DecodeConfig decodes TOML/YAML/JSON config content into out. +// +// frp-panel always decodes from a []byte pushed over gRPC by the master, never from a +// file path, so frp's path-based loaders are deliberately not used here. +func DecodeConfig(content []byte, out any, strict bool) error { + return config.LoadConfigure(content, out, strict) +} + +// CompleteProxy applies frp's defaults to a proxy config. +// +// The global config is still accepted but no longer used: through v0.67 Complete took +// the user name in order to rewrite Name to "{user}.{name}", and from v0.68 it takes +// nothing because user-prefixing moved to the wire layer. Keeping the parameter means +// utils.LoadClientConfig did not have to change when frp did. +// +// Completing twice is harmless now that Name is no longer rewritten, which matters +// because client.NewService completes every configurer again internally. +func CompleteProxy(c v1.ProxyConfigurer, _ *v1.ClientCommonConfig) { + c.Complete() +} + +// CompleteVisitor applies frp's defaults to a visitor config. +// +// See CompleteProxy for why the global config is still a parameter. +func CompleteVisitor(c v1.VisitorConfigurer, _ *v1.ClientCommonConfig) { + c.Complete() +} diff --git a/internal/frpx/doc.go b/internal/frpx/doc.go new file mode 100644 index 00000000..6747d042 --- /dev/null +++ b/internal/frpx/doc.go @@ -0,0 +1,102 @@ +// Package frpx is the single seam between frp-panel and the frp library. +// +// # Why this package exists +// +// frp-panel is a fork. Every file it edits that upstream also owns is a permanent +// rebase cost, paid again on every merge from upstream. frp itself makes breaking +// changes to its non-config packages across minor releases. +// +// frpx concentrates both costs into one directory that upstream does not have, so: +// +// - Upgrading frp touches this package plus go.mod, and nothing else. +// - Rebasing onto upstream/main can never conflict here, because upstream has no +// internal/frpx to conflict with. +// +// # The rule +// +// Wrap *behavior*, pass *data* through. +// +// Every frp import that is NOT "pkg/config/v1" belongs in this package. Calls into +// pkg/config/v1 *behavior* (the Complete methods) also belong here, because that is +// where frp actually breaks compatibility. +// +// The v1.* config structs themselves are deliberately NOT wrapped, and are imported +// directly by the ~22 files that need them. Aliasing them would give zero insulation +// (an alias is the identical type), and wrapping them for real would break both the +// database blob format (models/proxy_config.go stores cfg.MarshalJSON()) and the +// frontend JSON contract. Across frp v0.65.0 -> v0.70.1, pkg/config/v1 produced only +// additive field changes plus two Complete() signature changes -- and those are +// behavior, which is exactly what this package absorbs. +// +// # Signature stability +// +// Exported signatures here are chosen so they can be implemented against multiple frp +// versions without the call sites changing. Where that requires keeping a parameter frp +// no longer needs, the parameter is kept on purpose and documented at the call. Freezing +// a call site in an upstream-owned file is worth an unused argument in a fork-owned one. +// +// # Currently pinned +// +// github.com/fatedier/frp v0.70.1 +// github.com/fatedier/golib v0.8.1 +// +// frp v0.70.1 requires Go 1.25. Raising the go directive also means bumping the four +// upstream workflows that hardcode go-version; fork-checks.yml tracks go.mod itself. +// +// # Upgrade checklist +// +// These are every frp symbol frp-panel depends on. Re-verify each one when bumping frp, +// then run `go test -tags integration ./internal/frpx/...`. +// +// client.Service client.go +// client.ServiceOptions client.go +// client.NewService client.go +// client.Service.Run/Close client.go +// client.Service.GracefulClose client.go +// client.Service.StatusExporter client.go +// client.Service.UpdateAllConfigurer client.go +// pkg/config.FilterClientConfigurers client.go +// client/proxy.WorkingStatus client.go +// pkg/config/source.NewConfigSource client.go +// pkg/config/source.ConfigSource.ReplaceAll client.go +// pkg/config/source.NewAggregator client.go +// pkg/policy/featuregate.SetFromMap client.go +// pkg/config/v1/validation client.go, server.go +// server.Service server.go +// server.NewService server.go +// pkg/metrics.EnableMem/Prometheus server.go +// pkg/metrics/mem.StatsCollector server.go +// pkg/metrics/mem.ServerStats server.go +// pkg/metrics/mem.ProxyStats server.go +// pkg/config.Values/GetValues config.go +// pkg/config.RenderWithTemplate config.go +// pkg/config.LoadConfigure config.go +// v1.ProxyConfigurer.Complete config.go +// v1.VisitorConfigurer.Complete config.go +// pkg/plugin/server.Request/Response plugin.go +// pkg/plugin/server.LoginContent plugin.go +// pkg/util/log.Logger log.go +// pkg/util/version.Full version.go +// +// Things that moved in v0.68 and are now settled here, recorded so the next bump is not +// re-diagnosed from scratch: +// +// - client.ServiceOptions dropped ProxyCfgs/VisitorCfgs for a mandatory +// ConfigSourceAggregator. NewClientService builds one from a config source alone; the +// store source stays unset on purpose (it would resurrect master-deleted proxies). +// - pkg/featuregate moved to pkg/policy/featuregate. SetFromMap itself is unchanged. +// - ProxyConfigurer.Complete and VisitorConfigurer.Complete lost their arguments. +// CompleteProxy/CompleteVisitor keep theirs so utils.LoadClientConfig never moved. +// - validation.ValidateServerConfig became a method on validation.ConfigValidator, and +// ValidateAllClientConfig gained a *security.UnsafeFeatures parameter. Both are passed +// nil here: frp's only unsafe feature is TokenSourceExec, which frp-panel does not use, +// and UnsafeFeatures.IsEnabled is nil-receiver safe. +// +// Not a symbol, but it breaks builds: frp drags quic-go forward, and imroc/req/v3 compiles +// against quic-go's internals. Bumping frp usually means bumping req in the same commit. +// +// Proxy naming (naming.go) is a behavioral contract rather than a symbol: through frp +// v0.67 the "{user}." prefix was applied to config during Complete(); from v0.68 the +// config keeps the raw name and the prefix is applied at the wire layer. The wire name +// is identical either way. See naming.go. +package frpx diff --git a/internal/frpx/log.go b/internal/frpx/log.go new file mode 100644 index 00000000..2959ab78 --- /dev/null +++ b/internal/frpx/log.go @@ -0,0 +1,14 @@ +package frpx + +import ( + frplog "github.com/fatedier/frp/pkg/util/log" + golog "github.com/fatedier/golib/log" +) + +// SetFRPLogger redirects frp's own logging into the given logger. +// +// This writes frp's package-level Logger variable, so it affects every frps and frpc in +// the process and is not safe to call concurrently with a running tunnel. +func SetFRPLogger(l *golog.Logger) { + frplog.Logger = l +} diff --git a/internal/frpx/naming.go b/internal/frpx/naming.go new file mode 100644 index 00000000..ddef64ec --- /dev/null +++ b/internal/frpx/naming.go @@ -0,0 +1,39 @@ +package frpx + +import "strings" + +// Proxy naming across frp versions. +// +// frp namespaces proxies per user. A proxy named "ssh" belonging to user "alice" is +// "alice.ssh" as far as frps is concerned. Where that prefix gets applied moved in +// frp v0.68: +// +// frp <= v0.67 frp >= v0.68 +// config Name after Complete alice.ssh ssh +// client-side status map key alice.ssh ssh +// wire msg.NewProxy.ProxyName alice.ssh alice.ssh <- unchanged +// frps mem.ProxyStats.Name alice.ssh alice.ssh <- unchanged +// +// The wire name is identical in both, which is why tunnels interoperate across the +// version gap. What changed is the key frp-panel must use to look a proxy up on the +// *client* side. Use these helpers at any point where a name crosses between +// frp-panel's own records and frp's runtime. + +// WireProxyName returns the name frps knows a proxy by: "{user}.{name}". +// With an empty user the name is unchanged. +func WireProxyName(user, name string) string { + if user == "" { + return name + } + return user + "." + name +} + +// RawProxyName strips a leading "{user}." from a wire proxy name, returning the name as +// authored. It is a no-op if the prefix is absent, so it is safe to apply to a name that +// may already be raw. +func RawProxyName(user, name string) string { + if user == "" { + return name + } + return strings.TrimPrefix(name, user+".") +} diff --git a/internal/frpx/plugin.go b/internal/frpx/plugin.go new file mode 100644 index 00000000..9430751b --- /dev/null +++ b/internal/frpx/plugin.go @@ -0,0 +1,18 @@ +package frpx + +import "github.com/fatedier/frp/pkg/plugin/server" + +// frps server-plugin HTTP contract. +// +// frp-panel registers itself as an frps "multiuser" server plugin (see conf/helper.go) +// so that frps calls back into the panel to authenticate every tunnel login. These are +// the types on that HTTP boundary. + +// PluginRequest is the envelope frps POSTs to a server plugin. +type PluginRequest = server.Request + +// PluginResponse is what a server plugin returns to frps. +type PluginResponse = server.Response + +// PluginLoginContent is the payload of a Login operation. +type PluginLoginContent = server.LoginContent diff --git a/internal/frpx/server.go b/internal/frpx/server.go new file mode 100644 index 00000000..83679f86 --- /dev/null +++ b/internal/frpx/server.go @@ -0,0 +1,59 @@ +package frpx + +import ( + v1 "github.com/fatedier/frp/pkg/config/v1" + "github.com/fatedier/frp/pkg/config/v1/validation" + "github.com/fatedier/frp/pkg/metrics" + "github.com/fatedier/frp/pkg/metrics/mem" + "github.com/fatedier/frp/server" +) + +// ServerService is a running frps. +type ServerService = server.Service + +// ServerStats is a point-in-time snapshot of frps-wide counters. +type ServerStats = mem.ServerStats + +// ProxyStats is a point-in-time snapshot of one proxy's counters. +// +// Note that ProxyStats.Name is the *wire* proxy name, so it carries the "{user}." +// prefix. See naming.go. +type ProxyStats = mem.ProxyStats + +// NewServerService constructs an frps service. It does not start it. +func NewServerService(cfg *v1.ServerConfig) (*ServerService, error) { + return server.NewService(cfg) +} + +// ValidateServerConfig validates an frps config. The first return is a non-fatal +// warning; the second is a fatal error. +// +// frp moved this from a package function to a method on a validator type in v0.68. The +// nil unsafe-feature set means no opt-in unsafe feature is permitted; see +// ValidateClientConfig in client.go for why that is the right default here. +func ValidateServerConfig(cfg *v1.ServerConfig) (error, error) { + warning, err := validation.NewConfigValidator(nil).ValidateServerConfig(cfg) + return warning, err +} + +// EnableMemMetrics turns on frp's in-memory stats collector. It must be called before +// any frps starts, and it mutates process-global state. +func EnableMemMetrics() { + metrics.EnableMem() +} + +// EnablePrometheusMetrics turns on frp's Prometheus collector. Same constraints as +// EnableMemMetrics. +func EnablePrometheusMetrics() { + metrics.EnablePrometheus() +} + +// ServerStatsSnapshot returns current frps-wide counters. +func ServerStatsSnapshot() *ServerStats { + return mem.StatsCollector.GetServer() +} + +// ProxyStatsByType returns current counters for every proxy of the given type. +func ProxyStatsByType(proxyType v1.ProxyType) []*ProxyStats { + return mem.StatsCollector.GetProxiesByType(string(proxyType)) +} diff --git a/internal/frpx/tunnel_integration_test.go b/internal/frpx/tunnel_integration_test.go new file mode 100644 index 00000000..2bab70ac --- /dev/null +++ b/internal/frpx/tunnel_integration_test.go @@ -0,0 +1,627 @@ +//go:build integration + +// Package frpx_test stands up a real frps and a real frpc in one process and pushes real +// bytes through every tunnel type frp-panel supports. +// +// This exists to gate frp version bumps. frp-panel has no other test that exercises a +// tunnel, and CI runs no Go tests at all, so without this an frp upgrade is verified only +// by "it compiles". +// +// Run it explicitly: +// +// go test -tags integration -run TestTunnelMatrix -v ./internal/frpx/... +// +// It is behind a build tag because it binds real ports and takes ~30s. +// +// It is deliberately an external test package (frpx_test): it imports services/client and +// services/server, both of which import internal/frpx, which would be an import cycle for +// an in-package test. +package frpx_test + +import ( + "bufio" + "crypto/rand" + "crypto/rsa" + "crypto/tls" + "crypto/x509" + "crypto/x509/pkix" + "encoding/pem" + "fmt" + "io" + "math/big" + "net" + "net/http" + "os" + "path/filepath" + "strings" + "sync" + "testing" + "time" + + "github.com/VaalaCat/frp-panel/internal/frpx" + fclient "github.com/VaalaCat/frp-panel/services/client" + fserver "github.com/VaalaCat/frp-panel/services/server" + "github.com/VaalaCat/frp-panel/utils" + v1 "github.com/fatedier/frp/pkg/config/v1" +) + +const ( + testUser = "alice" + testToken = "integration-test-token" + + tcpEchoReply = "echo-tcp" + udpEchoReply = "echo-udp" + httpBodyReply = "echo-http" + + phaseRunning = "running" + + startupTimeout = 30 * time.Second + dialTimeout = 5 * time.Second +) + +var enableMetricsOnce sync.Once + +// --------------------------------------------------------------------------- +// The test +// --------------------------------------------------------------------------- + +func TestTunnelMatrix(t *testing.T) { + enableMetricsOnce.Do(frpx.EnableMemMetrics) + + p := allocPorts(t, + "frps", "vhostHTTP", "vhostHTTPS", "tcpmux", + "tcpRemote", "udpRemote", + "stcpVisitor", "sudpVisitor", + ) + + // Local backends the tunnels point at. + tcpBackend := startTCPEcho(t) + udpBackend := startUDPEcho(t) + httpBackend := startHTTPBackend(t) + + certPath, keyPath := writeSelfSignedCert(t) + + // ---- frps ---- + svrCfg := utils.NewBaseFRPServerConfig(p["frps"], testToken) + svrCfg.VhostHTTPPort = p["vhostHTTP"] + svrCfg.VhostHTTPSPort = p["vhostHTTPS"] + svrCfg.TCPMuxHTTPConnectPort = p["tcpmux"] + svrCfg.Complete() + + srv := fserver.NewServerHandler(svrCfg) + go srv.Run() + t.Cleanup(srv.Stop) + + waitPortOpen(t, p["frps"]) + + // ---- frpc ---- + // + // Deliberately routed through utils.LoadClientConfig rather than hand-built structs. + // That is the exact path production uses (master pushes JSON, agent loads it), and it + // is where frp's Complete() fan-out runs -- the thing that changed in frp v0.68. + cfgJSON := fmt.Sprintf(`{ + "user": %q, + "serverAddr": "127.0.0.1", + "serverPort": %d, + "auth": { "method": "token", "token": %q }, + "proxies": [ + { "name": "tcp-test", "type": "tcp", "localIP": "127.0.0.1", "localPort": %d, "remotePort": %d }, + { "name": "udp-test", "type": "udp", "localIP": "127.0.0.1", "localPort": %d, "remotePort": %d }, + { "name": "http-test", "type": "http", "localIP": "127.0.0.1", "localPort": %d, + "customDomains": ["http.example.com"] }, + { "name": "https-test", "type": "https", + "customDomains": ["https.example.com"], + "plugin": { "type": "https2http", "localAddr": "127.0.0.1:%d", "crtPath": %q, "keyPath": %q } }, + { "name": "tcpmux-test", "type": "tcpmux", "multiplexer": "httpconnect", + "customDomains": ["mux.example.com"], "localIP": "127.0.0.1", "localPort": %d }, + { "name": "stcp-test", "type": "stcp", "secretKey": "stcp-secret", + "localIP": "127.0.0.1", "localPort": %d }, + { "name": "sudp-test", "type": "sudp", "secretKey": "sudp-secret", + "localIP": "127.0.0.1", "localPort": %d } + ], + "visitors": [ + { "name": "stcp-visitor", "type": "stcp", "serverName": "stcp-test", + "secretKey": "stcp-secret", "bindAddr": "127.0.0.1", "bindPort": %d }, + { "name": "sudp-visitor", "type": "sudp", "serverName": "sudp-test", + "secretKey": "sudp-secret", "bindAddr": "127.0.0.1", "bindPort": %d } + ] +}`, + testUser, p["frps"], testToken, + tcpBackend, p["tcpRemote"], + udpBackend, p["udpRemote"], + httpBackend, + httpBackend, certPath, keyPath, + tcpBackend, + tcpBackend, + udpBackend, + p["stcpVisitor"], p["sudpVisitor"], + ) + cliCfg, proxyCfgs, visitorCfgs := loadClientConfig(t, cfgJSON) + + cli := fclient.NewClientHandler(cliCfg, proxyCfgs, visitorCfgs) + go cli.Run() + t.Cleanup(cli.Stop) + + proxyNames := []string{ + "tcp-test", "udp-test", "http-test", "https-test", + "tcpmux-test", "stcp-test", "sudp-test", + } + for _, name := range proxyNames { + waitProxyRunning(t, cli, name) + } + + // ---- the tunnels ---- + + t.Run("tcp", func(t *testing.T) { + assertTCPEcho(t, fmt.Sprintf("127.0.0.1:%d", p["tcpRemote"])) + }) + + t.Run("udp", func(t *testing.T) { + assertUDPEcho(t, fmt.Sprintf("127.0.0.1:%d", p["udpRemote"])) + }) + + t.Run("http", func(t *testing.T) { + assertHTTPBody(t, fmt.Sprintf("http://127.0.0.1:%d/", p["vhostHTTP"]), "http.example.com", nil) + }) + + t.Run("https", func(t *testing.T) { + assertHTTPBody(t, + fmt.Sprintf("https://127.0.0.1:%d/", p["vhostHTTPS"]), + "https.example.com", + &tls.Config{InsecureSkipVerify: true, ServerName: "https.example.com"}, + ) + }) + + t.Run("tcpmux", func(t *testing.T) { + conn, err := net.DialTimeout("tcp", fmt.Sprintf("127.0.0.1:%d", p["tcpmux"]), dialTimeout) + if err != nil { + t.Fatalf("dial tcpmux connect port: %v", err) + } + defer conn.Close() + + // httpconnect multiplexing: the CONNECT target selects the proxy by domain. + req := "CONNECT mux.example.com:80 HTTP/1.1\r\nHost: mux.example.com:80\r\n\r\n" + if _, err := conn.Write([]byte(req)); err != nil { + t.Fatalf("write CONNECT: %v", err) + } + br := bufio.NewReader(conn) + statusLine, err := br.ReadString('\n') + if err != nil { + t.Fatalf("read CONNECT response: %v", err) + } + if !strings.Contains(statusLine, "200") { + t.Fatalf("CONNECT rejected: %q", strings.TrimSpace(statusLine)) + } + for { // drain headers + line, err := br.ReadString('\n') + if err != nil { + t.Fatalf("drain CONNECT headers: %v", err) + } + if strings.TrimSpace(line) == "" { + break + } + } + + if _, err := conn.Write([]byte(tcpEchoReply)); err != nil { + t.Fatalf("write through tcpmux: %v", err) + } + got := make([]byte, len(tcpEchoReply)) + _ = conn.SetReadDeadline(time.Now().Add(dialTimeout)) + if _, err := io.ReadFull(br, got); err != nil { + t.Fatalf("read through tcpmux: %v", err) + } + if string(got) != tcpEchoReply { + t.Fatalf("tcpmux echo = %q, want %q", got, tcpEchoReply) + } + }) + + t.Run("stcp", func(t *testing.T) { + waitPortOpen(t, p["stcpVisitor"]) + assertTCPEcho(t, fmt.Sprintf("127.0.0.1:%d", p["stcpVisitor"])) + }) + + t.Run("sudp", func(t *testing.T) { + assertUDPEcho(t, fmt.Sprintf("127.0.0.1:%d", p["sudpVisitor"])) + }) + + t.Run("xtcp", func(t *testing.T) { + if os.Getenv("FRPX_TEST_XTCP") == "" { + t.Skip("xtcp needs NAT hole punching and is unreliable on loopback; " + + "set FRPX_TEST_XTCP=1 to attempt it. Covered by the manual runbook instead.") + } + t.Skip("xtcp loopback harness not implemented; verify via docs/frp-upgrade-verification.md") + }) + + // ---- the regression guard this file exists for ---- + // + // frp <= v0.67 rewrote the config Name to "{user}.{name}" during Complete(), so the + // client-side status map was keyed by the prefixed name. frp >= v0.68 keeps the raw + // name in config and applies the prefix only on the wire. + // + // This repo crossed that boundary at frp v0.70.1; the expectations below are the + // post-v0.68 ones. The wire name is identical in both generations, which is why the + // server-side assertion is version-independent while the client-side one is not. If + // this subtest fails after a future frp bump, that IS the finding -- flip the + // expectation and fix the lookups that depend on it (biz/client/get_proxy_info.go, + // which resolves both forms via frpx.RawProxyName / frpx.WireProxyName). + t.Run("proxy name correlation", func(t *testing.T) { + raw := "tcp-test" + wire := frpx.WireProxyName(testUser, raw) + + _, rawOK := cli.GetProxyStatus(raw) + _, wireOK := cli.GetProxyStatus(wire) + t.Logf("client-side status lookup: raw(%q)=%v wire(%q)=%v", raw, rawOK, wire, wireOK) + + // frp >= v0.68 semantics: the client keys proxies by the RAW config name. + if !rawOK { + t.Errorf("client-side lookup by raw name %q failed; "+ + "frp >= v0.68 should key by the raw config name.", raw) + } + if wireOK { + t.Errorf("client-side lookup by wire name %q succeeded; "+ + "frp >= v0.68 should no longer key by the prefixed name. "+ + "If frp reverted to prefixing during Complete(), update this assertion "+ + "and re-check biz/client/get_proxy_info.go.", wire) + } + + // Version-independent: frps always sees the prefixed name. + stats := srv.GetProxyStatsByType(v1.ProxyTypeTCP) + names := make([]string, 0, len(stats)) + for _, s := range stats { + names = append(names, s.Name) + } + t.Logf("server-side ProxyStats names: %v", names) + if !contains(names, wire) { + t.Errorf("frps ProxyStats has no proxy named %q; got %v. "+ + "The wire proxy name is expected to be stable across frp versions.", wire, names) + } + }) + + // BUG-12: the master pushes config changes to a running agent as a hot update, and + // frp's UpdateAllConfigurer does not apply `enabled` by itself. Runs last because it + // takes tcp-test down and back up. + t.Run("enabled=false on hot update", func(t *testing.T) { + remote := fmt.Sprintf("127.0.0.1:%d", p["tcpRemote"]) + + // Fresh structs every time: frp diffs the new configurers against the ones it + // holds, so mutating those in place would look like "no change". + _, disabled, visitors := loadClientConfig(t, cfgJSON) + for _, c := range disabled { + if c.GetBaseConfig().Name == "tcp-test" { + c.GetBaseConfig().Enabled = new(bool) + } + } + cli.Update(disabled, visitors) + + waitProxyGone(t, cli, "tcp-test") + waitPortClosed(t, p["tcpRemote"]) + assertUDPEcho(t, fmt.Sprintf("127.0.0.1:%d", p["udpRemote"])) // the others stay up + + _, enabled, visitors := loadClientConfig(t, cfgJSON) + cli.Update(enabled, visitors) + + waitProxyRunning(t, cli, "tcp-test") + waitPortOpen(t, p["tcpRemote"]) + assertTCPEcho(t, remote) + }) +} + +// --------------------------------------------------------------------------- +// config +// --------------------------------------------------------------------------- + +func loadClientConfig(t *testing.T, cfgJSON string) ( + *v1.ClientCommonConfig, []v1.ProxyConfigurer, []v1.VisitorConfigurer, +) { + t.Helper() + common, proxies, visitors, err := utils.LoadClientConfig([]byte(cfgJSON), true) + if err != nil { + t.Fatalf("load client config: %v\n---\n%s", err, cfgJSON) + } + return common, proxies, visitors +} + +// --------------------------------------------------------------------------- +// waiting +// --------------------------------------------------------------------------- + +// waitProxyRunning blocks until the named proxy reports "running". +// +// The lookup is tolerant of both name forms on purpose: this helper must keep working +// across the frp v0.68 naming change so that a bump produces a clear assertion failure in +// the correlation subtest rather than a 30s hang in every subtest. +func waitProxyRunning(t *testing.T, cli interface { + GetProxyStatus(string) (*frpx.ProxyWorkingStatus, bool) +}, rawName string) { + t.Helper() + + wireName := frpx.WireProxyName(testUser, rawName) + deadline := time.Now().Add(startupTimeout) + var last string + + for time.Now().Before(deadline) { + for _, candidate := range []string{rawName, wireName} { + st, ok := cli.GetProxyStatus(candidate) + if !ok { + continue + } + if st.Phase == phaseRunning { + return + } + last = fmt.Sprintf("phase=%q err=%q", st.Phase, st.Err) + } + time.Sleep(200 * time.Millisecond) + } + t.Fatalf("proxy %q never reached %q within %s (last: %s)", rawName, phaseRunning, startupTimeout, last) +} + +// waitProxyGone blocks until the client no longer reports the named proxy at all, under +// either name form -- which is what frp does for a proxy it has filtered out. +func waitProxyGone(t *testing.T, cli interface { + GetProxyStatus(string) (*frpx.ProxyWorkingStatus, bool) +}, rawName string) { + t.Helper() + + wireName := frpx.WireProxyName(testUser, rawName) + deadline := time.Now().Add(startupTimeout) + for time.Now().Before(deadline) { + _, rawOK := cli.GetProxyStatus(rawName) + _, wireOK := cli.GetProxyStatus(wireName) + if !rawOK && !wireOK { + return + } + time.Sleep(200 * time.Millisecond) + } + t.Fatalf("proxy %q is still registered %s after being disabled", rawName, startupTimeout) +} + +// waitPortClosed is waitPortOpen's inverse: frps releases a proxy's remote port once +// the client stops serving it. +func waitPortClosed(t *testing.T, port int) { + t.Helper() + addr := fmt.Sprintf("127.0.0.1:%d", port) + deadline := time.Now().Add(startupTimeout) + for time.Now().Before(deadline) { + conn, err := net.DialTimeout("tcp", addr, 500*time.Millisecond) + if err != nil { + return + } + _ = conn.Close() + time.Sleep(150 * time.Millisecond) + } + t.Fatalf("port %d is still open %s after its proxy was disabled", port, startupTimeout) +} + +func waitPortOpen(t *testing.T, port int) { + t.Helper() + addr := fmt.Sprintf("127.0.0.1:%d", port) + deadline := time.Now().Add(startupTimeout) + for time.Now().Before(deadline) { + conn, err := net.DialTimeout("tcp", addr, 500*time.Millisecond) + if err == nil { + _ = conn.Close() + return + } + time.Sleep(150 * time.Millisecond) + } + t.Fatalf("port %d never opened within %s", port, startupTimeout) +} + +// --------------------------------------------------------------------------- +// assertions +// --------------------------------------------------------------------------- + +func assertTCPEcho(t *testing.T, addr string) { + t.Helper() + conn, err := net.DialTimeout("tcp", addr, dialTimeout) + if err != nil { + t.Fatalf("dial %s: %v", addr, err) + } + defer conn.Close() + _ = conn.SetDeadline(time.Now().Add(dialTimeout)) + + if _, err := conn.Write([]byte(tcpEchoReply)); err != nil { + t.Fatalf("write %s: %v", addr, err) + } + got := make([]byte, len(tcpEchoReply)) + if _, err := io.ReadFull(conn, got); err != nil { + t.Fatalf("read %s: %v", addr, err) + } + if string(got) != tcpEchoReply { + t.Fatalf("tcp echo via %s = %q, want %q", addr, got, tcpEchoReply) + } +} + +func assertUDPEcho(t *testing.T, addr string) { + t.Helper() + conn, err := net.DialTimeout("udp", addr, dialTimeout) + if err != nil { + t.Fatalf("dial udp %s: %v", addr, err) + } + defer conn.Close() + + // UDP through frp can drop the first datagram while the session is being set up. + var lastErr error + for attempt := 0; attempt < 5; attempt++ { + _ = conn.SetDeadline(time.Now().Add(2 * time.Second)) + if _, err := conn.Write([]byte(udpEchoReply)); err != nil { + lastErr = err + continue + } + buf := make([]byte, 64) + n, err := conn.Read(buf) + if err != nil { + lastErr = err + continue + } + if string(buf[:n]) != udpEchoReply { + t.Fatalf("udp echo via %s = %q, want %q", addr, buf[:n], udpEchoReply) + } + return + } + t.Fatalf("udp echo via %s failed after 5 attempts: %v", addr, lastErr) +} + +func assertHTTPBody(t *testing.T, url, host string, tlsCfg *tls.Config) { + t.Helper() + client := &http.Client{ + Timeout: dialTimeout, + Transport: &http.Transport{TLSClientConfig: tlsCfg}, + } + req, err := http.NewRequest(http.MethodGet, url, nil) + if err != nil { + t.Fatalf("build request %s: %v", url, err) + } + req.Host = host + + resp, err := client.Do(req) + if err != nil { + t.Fatalf("GET %s (Host %s): %v", url, host, err) + } + defer resp.Body.Close() + + body, err := io.ReadAll(resp.Body) + if err != nil { + t.Fatalf("read body from %s: %v", url, err) + } + if resp.StatusCode != http.StatusOK { + t.Fatalf("GET %s (Host %s) = %d, want 200 (body %q)", url, host, resp.StatusCode, body) + } + if strings.TrimSpace(string(body)) != httpBodyReply { + t.Fatalf("GET %s (Host %s) body = %q, want %q", url, host, body, httpBodyReply) + } +} + +// --------------------------------------------------------------------------- +// local backends +// --------------------------------------------------------------------------- + +func startTCPEcho(t *testing.T) int { + t.Helper() + ln, err := net.Listen("tcp", "127.0.0.1:0") + if err != nil { + t.Fatalf("listen tcp echo: %v", err) + } + t.Cleanup(func() { _ = ln.Close() }) + + go func() { + for { + conn, err := ln.Accept() + if err != nil { + return + } + go func(c net.Conn) { + defer c.Close() + _, _ = io.Copy(c, c) + }(conn) + } + }() + return ln.Addr().(*net.TCPAddr).Port +} + +func startUDPEcho(t *testing.T) int { + t.Helper() + pc, err := net.ListenPacket("udp", "127.0.0.1:0") + if err != nil { + t.Fatalf("listen udp echo: %v", err) + } + t.Cleanup(func() { _ = pc.Close() }) + + go func() { + buf := make([]byte, 2048) + for { + n, addr, err := pc.ReadFrom(buf) + if err != nil { + return + } + _, _ = pc.WriteTo(buf[:n], addr) + } + }() + return pc.LocalAddr().(*net.UDPAddr).Port +} + +func startHTTPBackend(t *testing.T) int { + t.Helper() + ln, err := net.Listen("tcp", "127.0.0.1:0") + if err != nil { + t.Fatalf("listen http backend: %v", err) + } + srv := &http.Server{ + Handler: http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) { + _, _ = io.WriteString(w, httpBodyReply) + }), + } + go func() { _ = srv.Serve(ln) }() + t.Cleanup(func() { _ = srv.Close() }) + return ln.Addr().(*net.TCPAddr).Port +} + +// --------------------------------------------------------------------------- +// helpers +// --------------------------------------------------------------------------- + +// allocPorts reserves a free port per name by binding and immediately releasing. This is +// inherently racy, but it is what frp's own tests do and the window is small. +func allocPorts(t *testing.T, names ...string) map[string]int { + t.Helper() + out := make(map[string]int, len(names)) + for _, name := range names { + ln, err := net.Listen("tcp", "127.0.0.1:0") + if err != nil { + t.Fatalf("allocate port for %s: %v", name, err) + } + out[name] = ln.Addr().(*net.TCPAddr).Port + _ = ln.Close() + } + return out +} + +func writeSelfSignedCert(t *testing.T) (certPath, keyPath string) { + t.Helper() + dir := t.TempDir() + certPath = filepath.Join(dir, "tls.crt") + keyPath = filepath.Join(dir, "tls.key") + + key, err := rsa.GenerateKey(rand.Reader, 2048) + if err != nil { + t.Fatalf("generate key: %v", err) + } + tmpl := x509.Certificate{ + SerialNumber: big.NewInt(1), + Subject: pkix.Name{CommonName: "https.example.com"}, + DNSNames: []string{"https.example.com"}, + NotBefore: time.Now().Add(-time.Hour), + NotAfter: time.Now().Add(24 * time.Hour), + KeyUsage: x509.KeyUsageKeyEncipherment | x509.KeyUsageDigitalSignature, + ExtKeyUsage: []x509.ExtKeyUsage{x509.ExtKeyUsageServerAuth}, + } + der, err := x509.CreateCertificate(rand.Reader, &tmpl, &tmpl, &key.PublicKey, key) + if err != nil { + t.Fatalf("create certificate: %v", err) + } + + writePEM(t, certPath, &pem.Block{Type: "CERTIFICATE", Bytes: der}) + writePEM(t, keyPath, &pem.Block{Type: "RSA PRIVATE KEY", Bytes: x509.MarshalPKCS1PrivateKey(key)}) + return certPath, keyPath +} + +func writePEM(t *testing.T, path string, blk *pem.Block) { + t.Helper() + f, err := os.Create(path) + if err != nil { + t.Fatalf("create %s: %v", path, err) + } + defer f.Close() + if err := pem.Encode(f, blk); err != nil { + t.Fatalf("encode %s: %v", path, err) + } +} + +func contains(haystack []string, needle string) bool { + for _, s := range haystack { + if s == needle { + return true + } + } + return false +} diff --git a/internal/frpx/version.go b/internal/frpx/version.go new file mode 100644 index 00000000..e6a157e4 --- /dev/null +++ b/internal/frpx/version.go @@ -0,0 +1,71 @@ +package frpx + +import ( + "strconv" + "strings" + + "github.com/fatedier/frp/pkg/util/version" +) + +// Version is the frp library version compiled into this binary, e.g. "0.70.1". +// +// Agents report it to the master (pb.ClientVersion.FrpVersion), which is the only way +// the master can learn what an agent's frp understands: the panel's own GitVersion says +// nothing about which frp it was built against. +func Version() string { + return version.Full() +} + +// minWireProtocolV2 is the first frp release whose frpc can send, and whose frps can +// accept, transport.wireProtocol "v2". Older frpc rejects the key outright (the config +// decoder is strict), and older frps cannot parse a v2 session. +const minWireProtocolV2 = "0.69.0" + +// SupportsWireProtocolV2 reports whether an agent built with frp version v can take part +// in a wireProtocol v2 session. An empty or unparsable version is treated as too old. +func SupportsWireProtocolV2(v string) bool { + return VersionAtLeast(v, minWireProtocolV2) +} + +// VersionAtLeast compares dotted numeric versions such as "0.70.1" or "v0.69". A +// pre-release or build suffix ("-rc1", "+meta") is ignored. It fails closed: if either +// side does not parse, the answer is false. +func VersionAtLeast(v, min string) bool { + have, ok := parseVersion(v) + if !ok { + return false + } + want, ok := parseVersion(min) + if !ok { + return false + } + for i := range have { + if have[i] != want[i] { + return have[i] > want[i] + } + } + return true +} + +func parseVersion(v string) ([3]int, bool) { + var out [3]int + v = strings.TrimPrefix(strings.TrimSpace(v), "v") + if i := strings.IndexAny(v, "-+"); i >= 0 { + v = v[:i] + } + if v == "" { + return out, false + } + parts := strings.Split(v, ".") + if len(parts) > len(out) { + return out, false + } + for i, p := range parts { + n, err := strconv.Atoi(p) + if err != nil || n < 0 { + return out, false + } + out[i] = n + } + return out, true +} diff --git a/internal/frpx/version_test.go b/internal/frpx/version_test.go new file mode 100644 index 00000000..aaeb3009 --- /dev/null +++ b/internal/frpx/version_test.go @@ -0,0 +1,43 @@ +package frpx + +import "testing" + +func TestVersionAtLeast(t *testing.T) { + cases := []struct { + v, min string + want bool + }{ + {"0.70.1", "0.69.0", true}, + {"0.69.0", "0.69.0", true}, + {"0.69", "0.69.0", true}, + {"v0.69.0", "0.69.0", true}, + {"0.68.9", "0.69.0", false}, + {"0.9.0", "0.69.0", false}, // numeric, not lexical + {"1.0.0", "0.69.0", true}, + {"0.69.0-rc1", "0.69.0", true}, // suffix ignored + {" 0.70.1 ", "0.69.0", true}, + // fail closed + {"", "0.69.0", false}, + {"dev-build", "0.69.0", false}, + {"0.x.1", "0.69.0", false}, + {"0.69.0.1", "0.69.0", false}, + {"0.-1.0", "0.0.0", false}, + {"0.70.1", "", false}, + } + for _, tc := range cases { + if got := VersionAtLeast(tc.v, tc.min); got != tc.want { + t.Errorf("VersionAtLeast(%q, %q) = %v, want %v", tc.v, tc.min, got, tc.want) + } + } +} + +func TestSupportsWireProtocolV2(t *testing.T) { + if !SupportsWireProtocolV2(Version()) { + t.Fatalf("this build's frp %q should support wireProtocol v2", Version()) + } + for _, v := range []string{"", "0.68.0", "unknown"} { + if SupportsWireProtocolV2(v) { + t.Errorf("SupportsWireProtocolV2(%q) = true, want false", v) + } + } +} diff --git a/models/client.go b/models/client.go index e8646f08..98eb7383 100644 --- a/models/client.go +++ b/models/client.go @@ -43,17 +43,22 @@ func (*Client) TableName() string { } func (c *ClientEntity) SetConfigContent(cfg v1.ClientConfig) error { + // Visitors must be marshalled as the concrete VisitorConfigurer, not as a + // *VisitorBaseConfig downcast: the base config carries none of the XTCP-specific + // fields (protocol, keepTunnelOpen, maxRetriesAnHour, minRetryInterval, fallbackTo, + // fallbackTimeoutMs), so downcasting silently drops them on every proxy CRUD + // operation. This mirrors biz/master/client/update_tunnel.go. newCfg := struct { v1.ClientCommonConfig Proxies []v1.ProxyConfigurer `json:"proxies,omitempty"` - Visitors []v1.VisitorBaseConfig `json:"visitors,omitempty"` + Visitors []v1.VisitorConfigurer `json:"visitors,omitempty"` }{ ClientCommonConfig: cfg.ClientCommonConfig, Proxies: lo.Map(cfg.Proxies, func(item v1.TypedProxyConfig, _ int) v1.ProxyConfigurer { return item.ProxyConfigurer }), - Visitors: lo.Map(cfg.Visitors, func(item v1.TypedVisitorConfig, _ int) v1.VisitorBaseConfig { - return *item.GetBaseConfig() + Visitors: lo.Map(cfg.Visitors, func(item v1.TypedVisitorConfig, _ int) v1.VisitorConfigurer { + return item.VisitorConfigurer }), } raw, err := json.Marshal(newCfg) diff --git a/models/client_test.go b/models/client_test.go new file mode 100644 index 00000000..95f40df0 --- /dev/null +++ b/models/client_test.go @@ -0,0 +1,113 @@ +package models_test + +import ( + "testing" + + "github.com/VaalaCat/frp-panel/models" + "github.com/VaalaCat/frp-panel/utils" + v1 "github.com/fatedier/frp/pkg/config/v1" + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +// An xtcp visitor with every XTCP-specific field set to a non-default value. All six +// live on XTCPVisitorConfig, not on VisitorBaseConfig, so a base-config downcast in the +// serializer drops them without error. +const xtcpVisitorClientConfig = `{ + "user": "alice", + "serverAddr": "127.0.0.1", + "serverPort": 7000, + "proxies": [ + {"name": "stcp-proxy", "type": "stcp", "secretKey": "sk", "localIP": "127.0.0.1", "localPort": 22} + ], + "visitors": [ + { + "name": "xtcp-visitor", + "type": "xtcp", + "serverName": "xtcp-proxy", + "secretKey": "sk", + "bindAddr": "127.0.0.1", + "bindPort": 9001, + "protocol": "kcp", + "keepTunnelOpen": true, + "maxRetriesAnHour": 3, + "minRetryInterval": 30, + "fallbackTo": "stcp-visitor", + "fallbackTimeoutMs": 250 + } + ] +}` + +func loadClientConfig(t *testing.T, raw string) *v1.ClientConfig { + t.Helper() + cfg, err := utils.LoadClientConfigNormal([]byte(raw), true) + require.NoError(t, err) + require.NotNil(t, cfg) + return cfg +} + +// SetConfigContent used to marshal visitors as []v1.VisitorBaseConfig, which silently +// discarded every XTCP-specific field. Because it is called from every proxy create / +// update / delete / start / stop and from tenant tunnel sync, editing any proxy on a +// client wiped that client's xtcp visitor tuning. +func TestSetConfigContentPreservesXTCPVisitor(t *testing.T) { + cfg := loadClientConfig(t, xtcpVisitorClientConfig) + + entity := &models.ClientEntity{} + require.NoError(t, entity.SetConfigContent(*cfg)) + + got, err := entity.GetConfigContent() + require.NoError(t, err) + require.Len(t, got.Visitors, 1) + + xtcp, ok := got.Visitors[0].VisitorConfigurer.(*v1.XTCPVisitorConfig) + require.True(t, ok, "visitor decoded as %T, want *v1.XTCPVisitorConfig", got.Visitors[0].VisitorConfigurer) + + assert.Equal(t, "kcp", xtcp.Protocol) + assert.True(t, xtcp.KeepTunnelOpen) + assert.Equal(t, 3, xtcp.MaxRetriesAnHour) + assert.Equal(t, 30, xtcp.MinRetryInterval) + assert.Equal(t, "stcp-visitor", xtcp.FallbackTo) + assert.Equal(t, 250, xtcp.FallbackTimeoutMs) + + // The base fields must survive too. + assert.Equal(t, "xtcp-visitor", xtcp.Name) + assert.Equal(t, "xtcp-proxy", xtcp.ServerName) + assert.Equal(t, 9001, xtcp.BindPort) +} + +// Set -> Get -> Set must be a fixed point, otherwise repeated proxy edits would keep +// mutating the stored blob. +func TestSetConfigContentIsIdempotent(t *testing.T) { + cfg := loadClientConfig(t, xtcpVisitorClientConfig) + + first := &models.ClientEntity{} + require.NoError(t, first.SetConfigContent(*cfg)) + + roundTripped, err := first.GetConfigContent() + require.NoError(t, err) + + second := &models.ClientEntity{} + require.NoError(t, second.SetConfigContent(*roundTripped)) + + assert.Equal(t, string(first.ConfigContent), string(second.ConfigContent)) +} + +// Proxies were never affected by the visitor bug, but they share the serializer, so +// pin the behaviour. +func TestSetConfigContentPreservesProxies(t *testing.T) { + cfg := loadClientConfig(t, xtcpVisitorClientConfig) + + entity := &models.ClientEntity{} + require.NoError(t, entity.SetConfigContent(*cfg)) + + got, err := entity.GetConfigContent() + require.NoError(t, err) + require.Len(t, got.Proxies, 1) + + stcp, ok := got.Proxies[0].ProxyConfigurer.(*v1.STCPProxyConfig) + require.True(t, ok, "proxy decoded as %T, want *v1.STCPProxyConfig", got.Proxies[0].ProxyConfigurer) + assert.Equal(t, "stcp-proxy", stcp.Name) + assert.Equal(t, "sk", stcp.Secretkey) + assert.Equal(t, 22, stcp.LocalPort) +} diff --git a/pb/api_master.pb.go b/pb/api_master.pb.go index 49b98c0a..c34dc4aa 100644 --- a/pb/api_master.pb.go +++ b/pb/api_master.pb.go @@ -166,14 +166,17 @@ func (x *ClientStatus) GetConnectTime() int64 { } type ClientVersion struct { - state protoimpl.MessageState `protogen:"open.v1"` - GitVersion string `protobuf:"bytes,1,opt,name=GitVersion,proto3" json:"GitVersion,omitempty"` - GitCommit string `protobuf:"bytes,2,opt,name=GitCommit,proto3" json:"GitCommit,omitempty"` - BuildDate string `protobuf:"bytes,3,opt,name=BuildDate,proto3" json:"BuildDate,omitempty"` - GoVersion string `protobuf:"bytes,4,opt,name=GoVersion,proto3" json:"GoVersion,omitempty"` - Compiler string `protobuf:"bytes,5,opt,name=Compiler,proto3" json:"Compiler,omitempty"` - Platform string `protobuf:"bytes,6,opt,name=Platform,proto3" json:"Platform,omitempty"` - GitBranch string `protobuf:"bytes,7,opt,name=GitBranch,proto3" json:"GitBranch,omitempty"` + state protoimpl.MessageState `protogen:"open.v1"` + GitVersion string `protobuf:"bytes,1,opt,name=GitVersion,proto3" json:"GitVersion,omitempty"` + GitCommit string `protobuf:"bytes,2,opt,name=GitCommit,proto3" json:"GitCommit,omitempty"` + BuildDate string `protobuf:"bytes,3,opt,name=BuildDate,proto3" json:"BuildDate,omitempty"` + GoVersion string `protobuf:"bytes,4,opt,name=GoVersion,proto3" json:"GoVersion,omitempty"` + Compiler string `protobuf:"bytes,5,opt,name=Compiler,proto3" json:"Compiler,omitempty"` + Platform string `protobuf:"bytes,6,opt,name=Platform,proto3" json:"Platform,omitempty"` + GitBranch string `protobuf:"bytes,7,opt,name=GitBranch,proto3" json:"GitBranch,omitempty"` + // The frp library version the agent is built with. Empty from agents that predate + // the field, which callers must treat as "too old" (fail closed). + FrpVersion string `protobuf:"bytes,8,opt,name=FrpVersion,proto3" json:"FrpVersion,omitempty"` unknownFields protoimpl.UnknownFields sizeCache protoimpl.SizeCache } @@ -257,6 +260,13 @@ func (x *ClientVersion) GetGitBranch() string { return "" } +func (x *ClientVersion) GetFrpVersion() string { + if x != nil { + return x.FrpVersion + } + return "" +} + type GetClientsStatusRequest struct { state protoimpl.MessageState `protogen:"open.v1"` ClientType ClientType `protobuf:"varint,1,opt,name=client_type,json=clientType,proto3,enum=common.ClientType" json:"client_type,omitempty"` @@ -584,7 +594,7 @@ const file_api_master_proto_rawDesc = "" + "\n" + "\b_versionB\a\n" + "\x05_addrB\x0f\n" + - "\r_connect_time\"\xdf\x01\n" + + "\r_connect_time\"\xff\x01\n" + "\rClientVersion\x12\x1e\n" + "\n" + "GitVersion\x18\x01 \x01(\tR\n" + @@ -594,7 +604,10 @@ const file_api_master_proto_rawDesc = "" + "\tGoVersion\x18\x04 \x01(\tR\tGoVersion\x12\x1a\n" + "\bCompiler\x18\x05 \x01(\tR\bCompiler\x12\x1a\n" + "\bPlatform\x18\x06 \x01(\tR\bPlatform\x12\x1c\n" + - "\tGitBranch\x18\a \x01(\tR\tGitBranch\"m\n" + + "\tGitBranch\x18\a \x01(\tR\tGitBranch\x12\x1e\n" + + "\n" + + "FrpVersion\x18\b \x01(\tR\n" + + "FrpVersion\"m\n" + "\x17GetClientsStatusRequest\x123\n" + "\vclient_type\x18\x01 \x01(\x0e2\x12.common.ClientTypeR\n" + "clientType\x12\x1d\n" + diff --git a/services/app/provider.go b/services/app/provider.go index 28504213..de7fb42d 100644 --- a/services/app/provider.go +++ b/services/app/provider.go @@ -5,13 +5,12 @@ import ( "time" "github.com/VaalaCat/frp-panel/defs" + "github.com/VaalaCat/frp-panel/internal/frpx" "github.com/VaalaCat/frp-panel/pb" "github.com/VaalaCat/frp-panel/utils" "github.com/casbin/casbin/v2" - "github.com/fatedier/frp/client/proxy" v1 "github.com/fatedier/frp/pkg/config/v1" - "github.com/fatedier/frp/pkg/metrics/mem" "gorm.io/gorm" ) @@ -103,7 +102,7 @@ type ClientHandler interface { AddVisitor(v1.VisitorConfigurer) RemoveProxy(v1.ProxyConfigurer) RemoveVisitor(v1.VisitorConfigurer) - GetProxyStatus(string) (*proxy.WorkingStatus, bool) + GetProxyStatus(string) (*frpx.ProxyWorkingStatus, bool) GetCommonCfg() *v1.ClientCommonConfig GetProxyCfgs() map[string]v1.ProxyConfigurer GetVisitorCfgs() map[string]v1.VisitorConfigurer @@ -148,8 +147,8 @@ type ServerHandler interface { Stop() IsFirstSync() bool GetCommonCfg() *v1.ServerConfig - GetMem() *mem.ServerStats - GetProxyStatsByType(v1.ProxyType) []*mem.ProxyStats + GetMem() *frpx.ServerStats + GetProxyStatsByType(v1.ProxyType) []*frpx.ProxyStats } // rpc/master.go diff --git a/services/client/frpc_service.go b/services/client/frpc_service.go index 2bc9378d..25193d71 100644 --- a/services/client/frpc_service.go +++ b/services/client/frpc_service.go @@ -7,20 +7,17 @@ import ( "syscall" "time" + "github.com/VaalaCat/frp-panel/internal/frpx" "github.com/VaalaCat/frp-panel/services/app" "github.com/VaalaCat/frp-panel/utils" "github.com/VaalaCat/frp-panel/utils/logger" - "github.com/fatedier/frp/client" - "github.com/fatedier/frp/client/proxy" v1 "github.com/fatedier/frp/pkg/config/v1" - "github.com/fatedier/frp/pkg/config/v1/validation" - "github.com/fatedier/frp/pkg/featuregate" "github.com/samber/lo" "github.com/sourcegraph/conc" ) type clientImpl struct { - cli *client.Service + cli *frpx.ClientService Common *v1.ClientCommonConfig ProxyCfgs map[string]v1.ProxyConfigurer VisitorCfgs map[string]v1.VisitorConfigurer @@ -34,12 +31,12 @@ func NewClientHandler(commonCfg *v1.ClientCommonConfig, ctx := context.Background() if len(commonCfg.FeatureGates) > 0 { - if err := featuregate.SetFromMap(commonCfg.FeatureGates); err != nil { + if err := frpx.SetFeatureGates(commonCfg.FeatureGates); err != nil { logger.Logger(ctx).WithError(err).Errorf("there's a feature gate settings, but set failed: %+v, skip", commonCfg.FeatureGates) } } - warning, err := validation.ValidateAllClientConfig(commonCfg, proxyCfgs, visitorCfgs) + warning, err := frpx.ValidateClientConfig(commonCfg, proxyCfgs, visitorCfgs) if warning != nil { logger.Logger(ctx).WithError(err).Warnf("validate client config warning: %+v", warning) } @@ -47,7 +44,7 @@ func NewClientHandler(commonCfg *v1.ClientCommonConfig, logger.Logger(ctx).Panic(err) } - cli, err := client.NewService(client.ServiceOptions{ + cli, err := frpx.NewClientService(frpx.ClientOptions{ Common: commonCfg, ProxyCfgs: proxyCfgs, VisitorCfgs: visitorCfgs, @@ -103,17 +100,17 @@ func (c *clientImpl) Stop() { func (c *clientImpl) Update(proxyCfgs []v1.ProxyConfigurer, visitorCfgs []v1.VisitorConfigurer) { c.ProxyCfgs = lo.SliceToMap(proxyCfgs, utils.TransformProxyConfigurerToMap) c.VisitorCfgs = lo.SliceToMap(visitorCfgs, utils.TransformVisitorConfigurerToMap) - c.cli.UpdateAllConfigurer(proxyCfgs, visitorCfgs) + frpx.UpdateClientConfigurers(c.cli, c.Common, proxyCfgs, visitorCfgs) } func (c *clientImpl) AddProxy(proxyCfg v1.ProxyConfigurer) { c.ProxyCfgs[proxyCfg.GetBaseConfig().Name] = proxyCfg - c.cli.UpdateAllConfigurer(lo.Values(c.ProxyCfgs), lo.Values(c.VisitorCfgs)) + frpx.UpdateClientConfigurers(c.cli, c.Common, lo.Values(c.ProxyCfgs), lo.Values(c.VisitorCfgs)) } func (c *clientImpl) AddVisitor(visitorCfg v1.VisitorConfigurer) { c.VisitorCfgs[visitorCfg.GetBaseConfig().Name] = visitorCfg - c.cli.UpdateAllConfigurer(lo.Values(c.ProxyCfgs), lo.Values(c.VisitorCfgs)) + frpx.UpdateClientConfigurers(c.cli, c.Common, lo.Values(c.ProxyCfgs), lo.Values(c.VisitorCfgs)) } func (c *clientImpl) RemoveProxy(proxyCfg v1.ProxyConfigurer) { @@ -121,7 +118,7 @@ func (c *clientImpl) RemoveProxy(proxyCfg v1.ProxyConfigurer) { delete(old, proxyCfg.GetBaseConfig().Name) c.ProxyCfgs = old - c.cli.UpdateAllConfigurer(lo.Values(c.ProxyCfgs), lo.Values(c.VisitorCfgs)) + frpx.UpdateClientConfigurers(c.cli, c.Common, lo.Values(c.ProxyCfgs), lo.Values(c.VisitorCfgs)) } func (c *clientImpl) RemoveVisitor(visitorCfg v1.VisitorConfigurer) { @@ -129,10 +126,10 @@ func (c *clientImpl) RemoveVisitor(visitorCfg v1.VisitorConfigurer) { delete(old, visitorCfg.GetBaseConfig().Name) c.VisitorCfgs = old - c.cli.UpdateAllConfigurer(lo.Values(c.ProxyCfgs), lo.Values(c.VisitorCfgs)) + frpx.UpdateClientConfigurers(c.cli, c.Common, lo.Values(c.ProxyCfgs), lo.Values(c.VisitorCfgs)) } -func (c *clientImpl) GetProxyStatus(name string) (*proxy.WorkingStatus, bool) { +func (c *clientImpl) GetProxyStatus(name string) (*frpx.ProxyWorkingStatus, bool) { return c.cli.StatusExporter().GetProxyStatus(name) } @@ -156,7 +153,7 @@ func (c *clientImpl) Wait() { <-c.done } -func handleTermSignal(svr *client.Service) { +func handleTermSignal(svr *frpx.ClientService) { ch := make(chan os.Signal, 1) signal.Notify(ch, syscall.SIGINT, syscall.SIGTERM) <-ch diff --git a/services/dao/proxy.go b/services/dao/proxy.go index 75e3c8ed..7fb1ef19 100644 --- a/services/dao/proxy.go +++ b/services/dao/proxy.go @@ -289,7 +289,14 @@ func (m *proxyMutation) RebuildProxyConfigFromClient(userInfo models.UserInfo, c proxyCfg := &models.ProxyConfig{ ProxyConfigEntity: &models.ProxyConfigEntity{}, } - if oldProxyCfg, err := query.GetProxyConfigByOriginClientIDAndName(userInfo, client.ClientID, pxyCfg.GetBaseConfig().Name); err == nil { + // Look the old row up by the column this rebuild writes, ClientID. Matching + // OriginClientID against client.ClientID (as this used to) never hits for a child + // client, so every rebuild re-created every row under a new ID, and a stopped row + // -- spared by the delete below -- was duplicated when its name came back (BUG-14). + if oldProxyCfg, err := query.GetProxyConfigByFilter(userInfo, &models.ProxyConfigEntity{ + ClientID: client.ClientID, + Name: pxyCfg.GetBaseConfig().Name, + }); err == nil { logger.Logger(context.Background()).WithError(err).Warnf("proxy config already exist, will be override, clientID: [%s], name: [%s]", client.ClientID, pxyCfg.GetBaseConfig().Name) proxyCfg.Model = oldProxyCfg.Model diff --git a/services/dao/proxy_test.go b/services/dao/proxy_test.go new file mode 100644 index 00000000..eceae2c2 --- /dev/null +++ b/services/dao/proxy_test.go @@ -0,0 +1,206 @@ +package dao + +import ( + "context" + "path/filepath" + "sort" + "testing" + + "github.com/VaalaCat/frp-panel/defs" + "github.com/VaalaCat/frp-panel/models" + "github.com/VaalaCat/frp-panel/services/app" + "github.com/glebarez/sqlite" + "gorm.io/gorm" +) + +const ( + testOriginClientID = "c1" + testChildClientID = "c1@1" // app.ShadowedClientID(testOriginClientID, 1) + testServerID = "s1" +) + +var testUser = &models.UserEntity{UserID: 7, TenantID: 3, UserName: "alice"} + +// newTestDB is a file-backed sqlite DB rather than ":memory:": every pooled connection +// to ":memory:" opens its own empty database. +func newTestDB(t *testing.T) (*app.Context, *gorm.DB) { + t.Helper() + db, err := gorm.Open(sqlite.Open(filepath.Join(t.TempDir(), "test.db")), &gorm.Config{}) + if err != nil { + t.Fatalf("open sqlite: %v", err) + } + mgr := models.NewDBManager(defs.DBTypeSQLite3) + mgr.SetDB(defs.DBTypeSQLite3, defs.DBRoleDefault, db) + mgr.Init() + + a := app.NewApp() + a.SetDBManager(mgr) + return app.NewContext(context.Background(), a), db +} + +func childClient(proxiesJSON string) *models.Client { + return &models.Client{ClientEntity: &models.ClientEntity{ + ClientID: testChildClientID, + OriginClientID: testOriginClientID, + ServerID: testServerID, + UserID: testUser.UserID, + TenantID: testUser.TenantID, + ConfigContent: []byte(`{"proxies":` + proxiesJSON + `}`), + }} +} + +func insertProxyRow(t *testing.T, db *gorm.DB, name string, stopped bool) *models.ProxyConfig { + t.Helper() + row := &models.ProxyConfig{ProxyConfigEntity: &models.ProxyConfigEntity{ + ServerID: testServerID, + ClientID: testChildClientID, + OriginClientID: testOriginClientID, + UserID: testUser.UserID, + TenantID: testUser.TenantID, + Name: name, + Type: "tcp", + Content: []byte(`{"name":"` + name + `","type":"tcp","remotePort":6000}`), + Stopped: stopped, + }} + if err := db.Create(row).Error; err != nil { + t.Fatalf("insert %s: %v", name, err) + } + return row +} + +type rowSummary struct { + ID uint + Name string + Stopped bool +} + +func proxyRows(t *testing.T, db *gorm.DB) []rowSummary { + t.Helper() + var rows []*models.ProxyConfig + if err := db.Order("id").Find(&rows).Error; err != nil { + t.Fatalf("list rows: %v", err) + } + out := make([]rowSummary, 0, len(rows)) + for _, r := range rows { + out = append(out, rowSummary{ID: r.ID, Name: r.Name, Stopped: r.Stopped}) + } + sort.Slice(out, func(i, j int) bool { return out[i].Name < out[j].Name }) + return out +} + +const twoProxies = `[ + {"name":"a","type":"tcp","localPort":22,"remotePort":6000}, + {"name":"b","type":"tcp","localPort":80,"remotePort":6001} +]` + +// BUG-14: the lookup used to match OriginClientID against the child client id, so it +// never found the old row and every rebuild re-created every row under a new ID. +func TestRebuildProxyConfigKeepsRowIdentity(t *testing.T) { + ctx, db := newTestDB(t) + m := NewMutation(ctx) + + if err := m.RebuildProxyConfigFromClient(testUser, childClient(twoProxies)); err != nil { + t.Fatalf("first rebuild: %v", err) + } + first := proxyRows(t, db) + if len(first) != 2 { + t.Fatalf("after first rebuild: %+v, want rows a and b", first) + } + + if err := m.RebuildProxyConfigFromClient(testUser, childClient(twoProxies)); err != nil { + t.Fatalf("second rebuild: %v", err) + } + if second := proxyRows(t, db); !equalRows(first, second) { + t.Fatalf("rebuild changed row identity:\n before %+v\n after %+v", first, second) + } +} + +// A stopped proxy is absent from the client config, which is why the rebuild's delete +// spares stopped rows. When the same name comes back into the config (editing a stopped +// proxy does that today), the rebuild must update that row, not add a second one. +func TestRebuildProxyConfigDoesNotDuplicateStoppedRow(t *testing.T) { + ctx, db := newTestDB(t) + stopped := insertProxyRow(t, db, "a", true) + + if err := NewMutation(ctx).RebuildProxyConfigFromClient(testUser, childClient(twoProxies)); err != nil { + t.Fatalf("rebuild: %v", err) + } + + rows := proxyRows(t, db) + if len(rows) != 2 || rows[0].Name != "a" || rows[1].Name != "b" { + t.Fatalf("rows = %+v, want exactly one a and one b", rows) + } + if rows[0].ID != stopped.ID || rows[0].Stopped { + t.Fatalf("row a = %+v, want the existing row %d, now running because it is in the config", rows[0], stopped.ID) + } +} + +// Databases written before the fix can already hold the duplicate. The next rebuild +// has to collapse it, so no separate data migration is needed. +func TestRebuildProxyConfigHealsExistingDuplicate(t *testing.T) { + ctx, db := newTestDB(t) + stopped := insertProxyRow(t, db, "a", true) + insertProxyRow(t, db, "a", false) + + if err := NewMutation(ctx).RebuildProxyConfigFromClient(testUser, childClient(twoProxies)); err != nil { + t.Fatalf("rebuild: %v", err) + } + + rows := proxyRows(t, db) + if len(rows) != 2 || rows[0].Name != "a" || rows[1].Name != "b" { + t.Fatalf("rows = %+v, want the duplicate collapsed to one a, plus b", rows) + } + if rows[0].ID != stopped.ID || rows[0].Stopped { + t.Fatalf("row a = %+v, want the oldest row %d kept, running", rows[0], stopped.ID) + } +} + +func TestRebuildProxyConfigKeepsStoppedRowsOutsideConfig(t *testing.T) { + ctx, db := newTestDB(t) + stopped := insertProxyRow(t, db, "c", true) + insertProxyRow(t, db, "gone", false) + + if err := NewMutation(ctx).RebuildProxyConfigFromClient(testUser, childClient(twoProxies)); err != nil { + t.Fatalf("rebuild: %v", err) + } + + rows := proxyRows(t, db) + want := []string{"a", "b", "c"} + if len(rows) != len(want) { + t.Fatalf("rows = %+v, want names %v (the running row dropped from config must be deleted)", rows, want) + } + for i, name := range want { + if rows[i].Name != name { + t.Fatalf("rows = %+v, want names %v", rows, want) + } + } + if rows[2].ID != stopped.ID || !rows[2].Stopped { + t.Fatalf("stopped row = %+v, want row %d untouched and still stopped", rows[2], stopped.ID) + } +} + +func TestRebuildProxyConfigRejectsInvalidConfigWithoutTouchingRows(t *testing.T) { + ctx, db := newTestDB(t) + insertProxyRow(t, db, "a", false) + before := proxyRows(t, db) + + bad := childClient(`[{"name":"a","type":"tcp","remotePort":6000,"noSuchField":1}]`) + if err := NewMutation(ctx).RebuildProxyConfigFromClient(testUser, bad); err == nil { + t.Fatal("rebuild accepted a config the strict decoder must reject") + } + if after := proxyRows(t, db); !equalRows(before, after) { + t.Fatalf("failed rebuild modified rows:\n before %+v\n after %+v", before, after) + } +} + +func equalRows(a, b []rowSummary) bool { + if len(a) != len(b) { + return false + } + for i := range a { + if a[i] != b[i] { + return false + } + } + return true +} diff --git a/services/server/frps_service.go b/services/server/frps_service.go index db7276c4..4aecfc06 100644 --- a/services/server/frps_service.go +++ b/services/server/frps_service.go @@ -4,17 +4,15 @@ import ( "context" "sync" + "github.com/VaalaCat/frp-panel/internal/frpx" "github.com/VaalaCat/frp-panel/services/app" "github.com/VaalaCat/frp-panel/utils/logger" v1 "github.com/fatedier/frp/pkg/config/v1" - "github.com/fatedier/frp/pkg/config/v1/validation" - "github.com/fatedier/frp/pkg/metrics/mem" - "github.com/fatedier/frp/server" "github.com/sourcegraph/conc" ) type serverImpl struct { - srv *server.Service + srv *frpx.ServerService Common *v1.ServerConfig firstSync sync.Once } @@ -23,7 +21,7 @@ func NewServerHandler(svrCfg *v1.ServerConfig) app.ServerHandler { svrCfg.Complete() ctx := context.Background() - warning, err := validation.ValidateServerConfig(svrCfg) + warning, err := frpx.ValidateServerConfig(svrCfg) if warning != nil { logger.Logger(ctx).WithError(err).Warnf("validate server config warning: %+v", warning) } @@ -31,9 +29,9 @@ func NewServerHandler(svrCfg *v1.ServerConfig) app.ServerHandler { logger.Logger(ctx).Panic(err) } - var svr *server.Service + var svr *frpx.ServerService - if svr, err = server.NewService(svrCfg); err != nil { + if svr, err = frpx.NewServerService(svrCfg); err != nil { logger.Logger(ctx).WithError(err).Panic("cannot create server, exit and restart") } @@ -69,12 +67,12 @@ func (s *serverImpl) GetCommonCfg() *v1.ServerConfig { return s.Common } -func (s *serverImpl) GetMem() *mem.ServerStats { - return mem.StatsCollector.GetServer() +func (s *serverImpl) GetMem() *frpx.ServerStats { + return frpx.ServerStatsSnapshot() } -func (s *serverImpl) GetProxyStatsByType(proxyType v1.ProxyType) []*mem.ProxyStats { - return mem.StatsCollector.GetProxiesByType(string(proxyType)) +func (s *serverImpl) GetProxyStatsByType(proxyType v1.ProxyType) []*frpx.ProxyStats { + return frpx.ProxyStatsByType(proxyType) } func (s *serverImpl) IsFirstSync() bool { diff --git a/services/tunnel/server.go b/services/tunnel/server.go index 39e379f7..4c3e3c44 100644 --- a/services/tunnel/server.go +++ b/services/tunnel/server.go @@ -3,8 +3,8 @@ package tunnel import ( "sync" + "github.com/VaalaCat/frp-panel/internal/frpx" "github.com/VaalaCat/frp-panel/services/app" - "github.com/fatedier/frp/pkg/metrics" ) type serverController struct { @@ -12,8 +12,8 @@ type serverController struct { } func NewServerController() app.ServerController { - metrics.EnableMem() - metrics.EnablePrometheus() + frpx.EnableMemMetrics() + frpx.EnablePrometheusMetrics() return &serverController{ servers: &sync.Map{}, } diff --git a/utils/load.go b/utils/load.go index 8cd19253..5055302e 100644 --- a/utils/load.go +++ b/utils/load.go @@ -1,22 +1,53 @@ package utils import ( - "github.com/fatedier/frp/pkg/config" + "fmt" + + "github.com/VaalaCat/frp-panel/internal/frpx" v1 "github.com/fatedier/frp/pkg/config/v1" "github.com/samber/lo" "k8s.io/apimachinery/pkg/util/sets" ) -func LoadContentWithTemplate(content []byte, values *config.Values) ([]byte, error) { - return config.RenderWithTemplate(content, values) +// ValidateNoDuplicateNames mirrors frp's own pkg/config.validateNoDuplicateNames, which +// frp-panel never reaches: that check lives in the path-based LoadClientConfigResult, and +// this codebase always decodes from a []byte pushed over gRPC. Without it a duplicate is +// not an error -- frp's ConfigSource keys proxies and visitors by name in a map, so the +// collision silently collapses and the last entry wins. +// +// Names are checked per kind, matching frp: a proxy and a visitor may share a name. +func ValidateNoDuplicateNames(proxyCfgs []v1.ProxyConfigurer, visitorCfgs []v1.VisitorConfigurer) error { + proxyNames := sets.New[string]() + for _, c := range proxyCfgs { + name := c.GetBaseConfig().Name + if proxyNames.Has(name) { + return fmt.Errorf("duplicate proxy name: %s", name) + } + proxyNames.Insert(name) + } + + visitorNames := sets.New[string]() + for _, c := range visitorCfgs { + name := c.GetBaseConfig().Name + if visitorNames.Has(name) { + return fmt.Errorf("duplicate visitor name: %s", name) + } + visitorNames.Insert(name) + } + + return nil +} + +func LoadContentWithTemplate(content []byte, values *frpx.TemplateValues) ([]byte, error) { + return frpx.RenderTemplate(content, values) } func LoadConfigureFromContent(content []byte, c any, strict bool) error { - ans, err := LoadContentWithTemplate(content, config.GetValues()) + ans, err := LoadContentWithTemplate(content, frpx.TemplateDefaults()) if err != nil { return err } - return config.LoadConfigure(ans, c, strict) + return frpx.DecodeConfig(ans, c, strict) } func LoadProxiesFromContent(content []byte) ([]v1.TypedProxyConfig, error) { @@ -48,6 +79,13 @@ func LoadClientConfigNormal(content []byte, strict bool) (*v1.ClientConfig, erro if err := LoadConfigureFromContent(content, &allCfg, strict); err != nil { return nil, err } + if err := ValidateNoDuplicateNames( + lo.Map(allCfg.Proxies, func(c v1.TypedProxyConfig, _ int) v1.ProxyConfigurer { return c.ProxyConfigurer }), + lo.Map(allCfg.Visitors, func(c v1.TypedVisitorConfig, _ int) v1.VisitorConfigurer { return c.VisitorConfigurer }), + ); err != nil { + return nil, err + } + cliCfg = &allCfg.ClientCommonConfig cliCfg.Complete() allCfg.ClientCommonConfig = *cliCfg @@ -78,6 +116,10 @@ func LoadClientConfig(content []byte, strict bool) ( visitorCfgs = append(visitorCfgs, c.VisitorConfigurer) } + if err := ValidateNoDuplicateNames(proxyCfgs, visitorCfgs); err != nil { + return nil, nil, nil, err + } + // Filter by start if len(cliCfg.Start) > 0 { startSet := sets.New(cliCfg.Start...) @@ -92,10 +134,10 @@ func LoadClientConfig(content []byte, strict bool) ( cliCfg.Complete() for _, c := range proxyCfgs { - c.Complete(cliCfg.User) + frpx.CompleteProxy(c, cliCfg) } for _, c := range visitorCfgs { - c.Complete(cliCfg) + frpx.CompleteVisitor(c, cliCfg) } return cliCfg, proxyCfgs, visitorCfgs, nil } diff --git a/utils/load_test.go b/utils/load_test.go index aaa41982..57666571 100644 --- a/utils/load_test.go +++ b/utils/load_test.go @@ -1,9 +1,12 @@ -package utils +package utils_test import ( "testing" + "github.com/VaalaCat/frp-panel/utils" v1 "github.com/fatedier/frp/pkg/config/v1" + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" ) func TestLoadConfigureFromContent(t *testing.T) { @@ -16,8 +19,57 @@ remotePort = 6000`) allCfg := &v1.ClientConfig{} - if err := LoadConfigureFromContent(content, allCfg, true); err != nil { + if err := utils.LoadConfigureFromContent(content, allCfg, true); err != nil { t.Error(err) } t.Logf("%+v", allCfg) } + +// frp's own duplicate-name check lives in the path-based loader, which frp-panel never +// calls. Without the explicit check the duplicate is not rejected -- frp's ConfigSource +// keys entries by name in a map, so the second one silently replaces the first. +func TestLoadClientConfigRejectsDuplicateProxyNames(t *testing.T) { + const cfg = `{ + "serverAddr": "127.0.0.1", "serverPort": 7000, + "proxies": [ + {"name": "dup", "type": "tcp", "localPort": 22, "remotePort": 6000}, + {"name": "dup", "type": "tcp", "localPort": 23, "remotePort": 6001} + ] + }` + + _, err := utils.LoadClientConfigNormal([]byte(cfg), true) + require.Error(t, err) + assert.Contains(t, err.Error(), "duplicate proxy name: dup") + + _, _, _, err = utils.LoadClientConfig([]byte(cfg), true) + require.Error(t, err) + assert.Contains(t, err.Error(), "duplicate proxy name: dup") +} + +func TestLoadClientConfigRejectsDuplicateVisitorNames(t *testing.T) { + const cfg = `{ + "serverAddr": "127.0.0.1", "serverPort": 7000, + "visitors": [ + {"name": "dup", "type": "stcp", "serverName": "a", "secretKey": "s", "bindPort": 9001}, + {"name": "dup", "type": "stcp", "serverName": "b", "secretKey": "s", "bindPort": 9002} + ] + }` + + _, err := utils.LoadClientConfigNormal([]byte(cfg), true) + require.Error(t, err) + assert.Contains(t, err.Error(), "duplicate visitor name: dup") +} + +// Names are scoped per kind, matching frp: a proxy and a visitor may share one. +func TestLoadClientConfigAllowsProxyAndVisitorSharingAName(t *testing.T) { + const cfg = `{ + "serverAddr": "127.0.0.1", "serverPort": 7000, + "proxies": [{"name": "same", "type": "stcp", "secretKey": "s", "localPort": 22}], + "visitors": [{"name": "same", "type": "stcp", "serverName": "other", "secretKey": "s", "bindPort": 9001}] + }` + + got, err := utils.LoadClientConfigNormal([]byte(cfg), true) + require.NoError(t, err) + assert.Len(t, got.Proxies, 1) + assert.Len(t, got.Visitors, 1) +} diff --git a/utils/logger/init.go b/utils/logger/init.go index b1e6f322..1cef3712 100644 --- a/utils/logger/init.go +++ b/utils/logger/init.go @@ -10,17 +10,17 @@ import ( "strconv" "strings" - frplog "github.com/fatedier/frp/pkg/util/log" + "github.com/VaalaCat/frp-panel/internal/frpx" "github.com/fatedier/golib/log" "github.com/sirupsen/logrus" ) func initFrpLogger(frpLogLevel log.Level) { - frplog.Logger = log.New( + frpx.SetFRPLogger(log.New( log.WithCaller(true), log.AddCallerSkip(1), log.WithLevel(frpLogLevel), - log.WithOutput(LoggerWriter("frp", logrus.InfoLevel))) + log.WithOutput(LoggerWriter("frp", logrus.InfoLevel)))) } func InitLogger() { diff --git a/www/components/base/form-field.tsx b/www/components/base/form-field.tsx index 5bd5a1d1..cf1b83b9 100644 --- a/www/components/base/form-field.tsx +++ b/www/components/base/form-field.tsx @@ -1,9 +1,13 @@ -import React from 'react' +import React, { useState } from 'react' import { Control } from 'react-hook-form' -import { FormControl, FormField, FormItem, FormLabel, FormMessage } from '@/components/ui/form' +import { FormControl, FormDescription, FormField, FormItem, FormLabel, FormMessage } from '@/components/ui/form' import { Input } from '@/components/ui/input' +import { Label } from '@/components/ui/label' +import { Switch } from '@/components/ui/switch' +import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from '@/components/ui/select' import { useTranslation } from 'react-i18next' -import StringListInput from './list-input' +import StringListInput, { KeyValueListInput, PortRangeListInput, PortsRange } from './list-input' +import { BandwidthUnit } from '@/types/common' export const HostField = ({ control, @@ -104,12 +108,14 @@ export const StringField = ({ name, label, placeholder, + description, defaultValue, }: { control: Control name: string label: string placeholder?: string + description?: string defaultValue?: string }) => { const { t } = useTranslation() @@ -121,8 +127,9 @@ export const StringField = ({ {t(label)} - + + {description && {t(description)}} )} @@ -162,3 +169,353 @@ export const StringArrayField = ({ /> ) } + +/** + * Numeric input. Coercion deliberately lives in the zod atom (see ZodOptionalIntSchema), + * not here -- same split as PortField/ZodPortSchema, so a resolver hands handleSubmit a + * real number and an emptied field becomes undefined rather than 0. + */ +export const NumberField = ({ + control, + name, + label, + placeholder, + description, + defaultValue, +}: { + control: Control + name: string + label: string + placeholder?: string + description?: string + defaultValue?: number +}) => { + const { t } = useTranslation() + return ( + ( + + {t(label)} + + + + {description && {t(description)}} + + + )} + defaultValue={defaultValue} + /> + ) +} + +/** Boolean bound to the form, for values that get validated and serialized. */ +export const SwitchField = ({ + control, + name, + label, + description, + disabled, + defaultValue, +}: { + control: Control + name: string + label: string + description?: string + disabled?: boolean + defaultValue?: boolean +}) => { + const { t } = useTranslation() + return ( + ( + +
+ {t(label)} + {description && {t(description)}} +
+ + + +
+ )} + defaultValue={defaultValue} + /> + ) +} + +/** + * Toggle backed by local state rather than the form -- used for "enable this section" + * style switches whose value is never serialized. Promoted out of proxy_form.tsx so the + * shared field sections can use it without importing back into that module. + */ +export const SwitchWithLabel = ({ + name, + label, + defaultValue, + setValue, +}: { + name: string + label: string + defaultValue?: boolean + setValue: (value: boolean) => void +}) => { + const { t } = useTranslation() + return ( +
+ + +
+ ) +} + +// Radix throws on , so "unset" needs a sentinel that is mapped +// back to undefined on the way into the form. +const SELECT_NONE = '__none__' + +export const SelectField = ({ + control, + name, + label, + options, + placeholder, + description, + allowUnset, + unsetLabel, + disabled, + defaultValue, +}: { + control: Control + name: string + label: string + options: { value: string; label: string }[] + placeholder?: string + description?: string + allowUnset?: boolean + unsetLabel?: string + disabled?: boolean + defaultValue?: string +}) => { + const { t } = useTranslation() + return ( + ( + + {t(label)} + + {description && {t(description)}} + + + )} + defaultValue={defaultValue} + /> + ) +} + +export const KeyValueField = ({ + control, + name, + label, + keyPlaceholder, + valuePlaceholder, + description, + keyValidator, + reservedEntries, + defaultValue, +}: { + control: Control + name: string + label: string + keyPlaceholder?: string + valuePlaceholder?: string + description?: string + keyValidator?: (key: string) => string | undefined + reservedEntries?: Record + defaultValue?: Record +}) => { + const { t } = useTranslation() + return ( + ( + + {t(label)} + + + + {description && {t(description)}} + + + )} + defaultValue={defaultValue} + /> + ) +} + +export const PortRangeField = ({ + control, + name, + label, + placeholder, + description, + defaultValue, +}: { + control: Control + name: string + label: string + placeholder?: string + description?: string + defaultValue?: PortsRange[] +}) => { + const { t } = useTranslation() + return ( + ( + + {t(label)} + + + + {description && {t(description)}} + + + )} + defaultValue={defaultValue} + /> + ) +} + +const BANDWIDTH_UNITS: BandwidthUnit[] = ['KB', 'MB'] +const BANDWIDTH_RE = /^(\d+(?:\.\d+)?)(KB|MB)$/ + +/** + * frp's BandwidthQuantity is a string carrying its own suffix, e.g. "1MB". The number + * and the unit are edited separately and joined; an empty number clears the whole field + * back to undefined. Stored blobs routinely hold "" because frp writes the zero value, + * so treat that as unset too. + */ +export const BandwidthField = ({ + control, + name, + label, + placeholder, + description, + defaultValue, +}: { + control: Control + name: string + label: string + placeholder?: string + description?: string + defaultValue?: string +}) => { + const { t } = useTranslation() + return ( + } + defaultValue={defaultValue} + /> + ) +} + +const BandwidthControl = ({ + field, + label, + placeholder, + description, + t, +}: { + field: { value?: string; onChange: (v: string | undefined) => void } + label: string + placeholder?: string + description?: string + t: (k: string) => string +}) => { + const parsed = BANDWIDTH_RE.exec(field.value || '') + const [unit, setUnit] = useState((parsed?.[2] as BandwidthUnit) || 'MB') + const amount = parsed?.[1] ?? '' + + const emit = (nextAmount: string, nextUnit: BandwidthUnit) => { + const trimmed = nextAmount.trim() + field.onChange(trimmed ? `${trimmed}${nextUnit}` : undefined) + } + + return ( + + {t(label)} +
+ + emit(e.target.value, unit)} + /> + + +
+ {description && {t(description)}} + +
+ ) +} diff --git a/www/components/base/list-input.tsx b/www/components/base/list-input.tsx index 2eb755a4..93aa175b 100644 --- a/www/components/base/list-input.tsx +++ b/www/components/base/list-input.tsx @@ -45,7 +45,11 @@ const StringListInput: React.FC = ({ value, onChange, plac className="flex-1 px-4 py-2 border rounded-md shadow-sm focus:outline-none focus:ring-2 focus:ring-blue-500 text-sm" placeholder={placeholder || t('input.list.placeholder')} /> + {/* type="button": these editors render inside RHF forms, and an untyped + button is a submit button -- in the frpc form, "add" pushed the whole + client config to the agent (BUG-13). Same for the two editors below. */} +
+ {error &&

{error}

} +
+ {(value || []).map((r, index) => ( + + {formatRange(r)} +
handleRemove(index)} + className="ml-1 h-4 w-4 text-center rounded-full hover:text-red-500 cursor-pointer" + > + × +
+
+ ))} +
+ + ); +}; + +interface KeyValueListInputProps { + value?: Record; + onChange: (value: Record | undefined) => void; + keyPlaceholder?: string; + valuePlaceholder?: string; + className?: string; + /** Return a message to block the key, or undefined to accept it. */ + keyValidator?: (key: string) => string | undefined; + /** Keys the caller owns; rendered as disabled badges and never editable here. */ + reservedEntries?: Record; +} + +/** + * Map editor for frp's `metadatas`, `annotations` and `requestHeaders.set`. + * Deliberately dumb: reserved-key policy lives with the caller, which passes the + * reserved entries in for display and merges them back on submit. + */ +export const KeyValueListInput: React.FC = ({ + value, + onChange, + keyPlaceholder, + valuePlaceholder, + className, + keyValidator, + reservedEntries, +}) => { + const { t } = useTranslation(); + const [keyInput, setKeyInput] = useState(''); + const [valueInput, setValueInput] = useState(''); + + const keyError = keyInput ? keyValidator?.(keyInput) : undefined; + const duplicate = !!value && Object.prototype.hasOwnProperty.call(value, keyInput); + + const handleAdd = () => { + if (!keyInput.trim() || keyError || duplicate) { + return; + } + onChange({ ...(value || {}), [keyInput]: valueInput }); + setKeyInput(''); + setValueInput(''); + }; + + const handleRemove = (keyToRemove: string) => { + const next = { ...(value || {}) }; + delete next[keyToRemove]; + onChange(Object.keys(next).length ? next : undefined); + }; + + return ( +
+
+ setKeyInput(e.target.value)} + className="flex-1 text-sm" + placeholder={keyPlaceholder || t('input.kv.key_placeholder')} + /> + setValueInput(e.target.value)} + className="flex-1 text-sm" + placeholder={valuePlaceholder || t('input.kv.value_placeholder')} + /> + +
+ {keyError &&

{keyError}

} +
+ {reservedEntries && + Object.entries(reservedEntries).map(([k, v]) => ( + + {k}: {v} + + ))} + {value && + Object.entries(value).map(([k, v]) => ( + + {k}: {v} +
handleRemove(k)} + className="ml-1 h-4 w-4 text-center rounded-full hover:text-red-500 cursor-pointer" + > + × +
+
+ ))} +
+
+ ); +}; \ No newline at end of file diff --git a/www/components/base/visit-preview.tsx b/www/components/base/visit-preview.tsx index 06521c45..d8c89baa 100644 --- a/www/components/base/visit-preview.tsx +++ b/www/components/base/visit-preview.tsx @@ -25,21 +25,48 @@ export function VisitPreview({ server, typedProxyConfig, direction, withIcon = t export function ServerSideVisitPreview({ server, typedProxyConfig, withIcon = true }: { server: Server; typedProxyConfig: TypedProxyConfig; withIcon?: boolean }) { const serverCfg = JSON.parse(server?.config || '{}') as ServerConfig; const serverAddress = server.ip || serverCfg.bindAddr || 'Unknown'; - const serverPort = getServerPort(typedProxyConfig, serverCfg); return
{withIcon && } - {typedProxyConfig.type == "http" ? "http://" : ""}{ - typedProxyConfig.type == "http" ? ( - getServerAuth(typedProxyConfig as HTTPProxyConfig) + getServerHost(typedProxyConfig as HTTPProxyConfig, serverCfg, serverAddress) - ) : serverAddress - }:{serverPort || "?"}{ - typedProxyConfig.type == "http" ? - getServerPath(typedProxyConfig as HTTPProxyConfig) : "" - } + {describeServerSide(typedProxyConfig, serverCfg, serverAddress)}
} +/** + * How a proxy is reached from outside. The scheme and host rules differ per type, and + * the secret types have no server-side address at all -- they are reached through a + * visitor on another client, so printing `host:?` for them is actively misleading. + */ +function describeServerSide(cfg: TypedProxyConfig, serverCfg: ServerConfig, serverAddress: string): string { + const port = getServerPort(cfg, serverCfg); + const portSuffix = `:${port || '?'}`; + + switch (cfg.type) { + case 'tcp': + case 'udp': + return `${serverAddress}${portSuffix}`; + case 'http': { + const http = cfg as HTTPProxyConfig; + return `http://${getServerAuth(http)}${getServerHost(http, serverCfg, serverAddress)}${portSuffix}${getServerPath(http)}`; + } + case 'https': + return `https://${getDomainHost(cfg, serverCfg, serverAddress)}${portSuffix}`; + case 'tcpmux': + return `${getDomainHost(cfg, serverCfg, serverAddress)}${portSuffix} (${(cfg as { multiplexer?: string }).multiplexer || 'httpconnect'})`; + case 'stcp': + case 'xtcp': + case 'sudp': + return 'via visitor'; + default: + return `${serverAddress}${portSuffix}`; + } +} + +/** Domain resolution for the vhost-routed types, without http-only auth or paths. */ +function getDomainHost(cfg: TypedProxyConfig, serverCfg: ServerConfig, serverAddress: string) { + return getServerHost(cfg as HTTPProxyConfig, serverCfg, serverAddress); +} + export function ClientSideVisitPreview({ typedProxyConfig, withIcon = true }: { typedProxyConfig: TypedProxyConfig, withIcon?: boolean }) { const localAddress = typedProxyConfig.localIP || '127.0.0.1'; const localPort = typedProxyConfig.localPort; @@ -63,6 +90,8 @@ function getServerPort(proxyConfig: TypedProxyConfig, serverConfig: ServerConfig return serverConfig.vhostHTTPPort; case 'https': return serverConfig.vhostHTTPSPort; + case 'tcpmux': + return serverConfig.tcpmuxHTTPConnectPort; default: return undefined; } diff --git a/www/components/frpc/client_plugins.tsx b/www/components/frpc/client_plugins.tsx index e4d42c2b..b35116bc 100644 --- a/www/components/frpc/client_plugins.tsx +++ b/www/components/frpc/client_plugins.tsx @@ -6,31 +6,37 @@ import { TypedClientPluginOptions, ClientPluginType, HTTPProxyPluginOptions, + HTTP2HTTPPluginOptions, HTTP2HTTPSPluginOptions, HTTPS2HTTPPluginOptions, HTTPS2HTTPSPluginOptions, Socks5PluginOptions, StaticFilePluginOptions, + TLS2RawPluginOptions, UnixDomainSocketPluginOptions, } from '@/types/plugin' import { Label } from '@/components/ui/label' import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from '@/components/ui/select' import { HTTPProxyPluginForm } from './plugins/http_proxy_plugin_form' +import { HTTP2HTTPPluginForm } from './plugins/http_2_http_plugin_form' import { HTTP2HTTPSPluginForm } from './plugins/http_2_https_plugin_form' import { HTTPS2HTTPPluginForm } from './plugins/https_2_http_plugin_form' import { HTTPS2HTTPSPluginForm } from './plugins/https_2_https_plugin_form' import { Socks5PluginForm } from './plugins/socks5_plugin_form' import { StaticFilePluginForm } from './plugins/static_file_plugin_form' +import { TLS2RawPluginForm } from './plugins/tls_2_raw_plugin_form' import { UnixDomainSocketPluginForm } from './plugins/unix_domain_socket_plugin_form' import { useTranslation } from 'react-i18next' const pluginTypeMap: Record = { http_proxy: 'HTTP Proxy', + http2http: 'HTTP→HTTP', http2https: 'HTTP→HTTPS', https2http: 'HTTPS→HTTP', https2https: 'HTTPS→HTTPS', socks5: 'SOCKS5', static_file: 'Static File', + tls2raw: 'TLS→Raw', unix_domain_socket: 'Unix Domain Socket', } @@ -59,7 +65,8 @@ export function PluginConfigForm({ defaultPluginConfig, setPluginConfig, support plugins && plugins.map((plugin, i) => ( - {pluginTypeMap[plugin]} + {/* A stored plugin the form has no label for (e.g. virtual_net) shows its raw name. */} + {pluginTypeMap[plugin] ?? plugin} )) ) @@ -85,6 +92,9 @@ export function PluginConfigForm({ defaultPluginConfig, setPluginConfig, support {config.type === 'http_proxy' && ( updateConfig(c)} /> )} + {config.type === 'http2http' && ( + updateConfig(c)} /> + )} {config.type === 'http2https' && ( updateConfig(c)} /> )} @@ -100,6 +110,9 @@ export function PluginConfigForm({ defaultPluginConfig, setPluginConfig, support {config.type === 'static_file' && ( updateConfig(c)} /> )} + {config.type === 'tls2raw' && ( + updateConfig(c)} /> + )} {config.type === 'unix_domain_socket' && ( = v0.69. A v2 frpc cannot connect to an frps older than v0.69, so this is + // guarded at the field, the schema and the backend rather than offered freely. + wireProtocol: z.enum(['v1', 'v2']).optional(), + dialServerTimeout: ZodOptionalIntSchema, + dialServerKeepalive: ZodOptionalSignedIntSchema, + connectServerLocalIP: ZodHostSchema.optional(), + proxyURL: ZodStringOptionalSchema, + poolCount: ZodOptionalIntSchema, + tcpMux: z.boolean().optional(), + tcpMuxKeepaliveInterval: ZodOptionalSignedIntSchema, + // Both complete to -1 ("disabled") when tcpMux is on. + heartbeatInterval: ZodOptionalSignedIntSchema, + heartbeatTimeout: ZodOptionalSignedIntSchema, + quic: z + .object({ + keepalivePeriod: ZodOptionalIntSchema, + maxIdleTimeout: ZodOptionalIntSchema, + maxIncomingStreams: ZodOptionalIntSchema, + }) + .optional(), + tls: TLSClientFields.optional(), + }) + .optional() + +export const ClientCommonConfigSchema = z.object({ + transport: ClientTransportSchema, + natHoleStunServer: ZodStringOptionalSchema, + dnsServer: ZodStringOptionalSchema, + loginFailExit: z.boolean().optional(), + udpPacketSize: ZodOptionalIntSchema, + metadatas: z.record(z.string(), z.string()).optional(), + log: z + .object({ + to: ZodStringOptionalSchema, + level: z.enum(['trace', 'debug', 'info', 'warn', 'error']).optional(), + maxDays: ZodOptionalIntSchema, + disablePrintColor: z.boolean().optional(), + }) + .optional(), + webServer: z + .object({ + addr: ZodHostSchema.optional(), + port: ZodPortOptionalSchema, + user: ZodStringOptionalSchema, + password: ZodStringOptionalSchema, + pprofEnable: z.boolean().optional(), + }) + .optional(), +}) + +export type ClientCommonConfigValues = z.infer diff --git a/www/components/frpc/form/sections.tsx b/www/components/frpc/form/sections.tsx new file mode 100644 index 00000000..7e0a7c65 --- /dev/null +++ b/www/components/frpc/form/sections.tsx @@ -0,0 +1,158 @@ +import React from 'react' +import { Control } from 'react-hook-form' +import { useTranslation } from 'react-i18next' + +import { Accordion, AccordionContent, AccordionItem, AccordionTrigger } from '@/components/ui/accordion' +import { + HostField, + KeyValueField, + NumberField, + PortField, + SecretStringField, + SelectField, + StringField, + SwitchField, +} from '@/components/base/form-field' +import { LogLevels, WireProtocols } from '@/lib/consts' + +type SectionProps = { control: Control } + +const Section = ({ value, label, children }: { value: string; label: string; children: React.ReactNode }) => ( + + {label} + {children} + +) + +export const FRPCAdvancedSections = ({ + control, + reservedMetadatas, + allowWireProtocolV2, +}: SectionProps & { + reservedMetadatas?: Record + /** False whenever the target server is not known to run frp >= v0.69. */ + allowWireProtocolV2?: boolean +}) => { + const { t } = useTranslation() + + return ( + +
+ + + + + + + v === 'v1' || allowWireProtocolV2).map((v) => ({ value: v, label: v }))} + allowUnset + description={ + allowWireProtocolV2 ? t('frpc.form.wire_protocol_description') : t('frpc.form.wire_protocol_locked') + } + /> +
+ +
+ + + + +
+ +
+ + + + + + + + + + +
+ +
+

{t('frpc.form.web_server_redundant_hint')}

+ + ({ value: v, label: v }))} + allowUnset + /> + + + + + + + +
+ +
+ + + {/* metadatas is not reserved as a whole: the master merges its two keys rather + than replacing the map, so user-authored keys survive. */} + +
+
+ ) +} diff --git a/www/components/frpc/frpc_card.tsx b/www/components/frpc/frpc_card.tsx index a4656866..a8f93efc 100644 --- a/www/components/frpc/frpc_card.tsx +++ b/www/components/frpc/frpc_card.tsx @@ -11,6 +11,7 @@ import { FRPCEditor } from './frpc_editor' import { FRPCForm } from './frpc_form' import { useSearchParams } from 'next/navigation' import { ClientConfig } from '@/types/client' +import { TypedVisitorConfig } from '@/types/visitor' import { TypedProxyConfig } from '@/types/proxy' import { ClientSelector } from '../base/client-selector' import { ServerSelector } from '../base/server-selector' @@ -35,6 +36,7 @@ export const FRPCFormCard: React.FC = ({ const searchParams = useSearchParams() const paramClientID = searchParams.get('clientID') const [clientProxyConfigs, setClientProxyConfigs] = useState([]) + const [clientVisitorConfigs, setClientVisitorConfigs] = useState([]) const [frpsUrl, setFrpsUrl] = useState() const [selectedServer, setSelectedServer] = useState(undefined) @@ -58,6 +60,7 @@ export const FRPCFormCard: React.FC = ({ useEffect(() => { if (error) { setClientProxyConfigs([]) + setClientVisitorConfigs([]) } }, [error]) @@ -75,6 +78,10 @@ export const FRPCFormCard: React.FC = ({ setClientProxyConfigs([]) } + // Visitors live in the same blob as proxies; without hydrating them here the + // form would submit an empty list and delete them. + setClientVisitorConfigs(clientConf.visitors ?? []) + if (client?.client?.frpsUrl) { setFrpsUrl(client?.client?.frpsUrl) } @@ -133,6 +140,8 @@ export const FRPCFormCard: React.FC = ({ clientID={clientID} serverID={serverID} clientProxyConfigs={clientProxyConfigs} setClientProxyConfigs={setClientProxyConfigs} + clientVisitorConfigs={clientVisitorConfigs} + setClientVisitorConfigs={setClientVisitorConfigs} frpsUrl={frpsUrl} /> } diff --git a/www/components/frpc/frpc_form.tsx b/www/components/frpc/frpc_form.tsx index 02b1659f..a0a5e93a 100644 --- a/www/components/frpc/frpc_form.tsx +++ b/www/components/frpc/frpc_form.tsx @@ -5,19 +5,29 @@ import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from '@ import { Label } from '@radix-ui/react-label' import { TypedProxyForm } from './proxy_form' import { Button } from '@/components/ui/button' -import { Client, RespCode } from '@/lib/pb/common' +import { Client, ClientType, RespCode } from '@/lib/pb/common' import { ClientConfig } from '@/types/client' import { Popover, PopoverContent, PopoverTrigger } from '@/components/ui/popover' import { Accordion, AccordionContent, AccordionItem, AccordionTrigger } from '@/components/ui/accordion' import { Input } from '@/components/ui/input' import { AccordionHeader } from '@radix-ui/react-accordion' -import { QueryObserverResult, RefetchOptions, useMutation } from '@tanstack/react-query' +import { QueryObserverResult, RefetchOptions, useMutation, useQuery } from '@tanstack/react-query' import { updateFRPC } from '@/api/frp' +import { getClientsStatus } from '@/api/platform' +import { SupportsWireProtocolV2 } from '@/config/notify' import { GetClientResponse } from '@/lib/pb/api_client' import { useTranslation } from 'react-i18next' import { toast } from 'sonner' -import { BaseSelector } from '../base/selector' import { ConnectionProtocols } from '@/lib/consts' +import { ObjToUint8Array } from '@/lib/utils' +import { useForm } from 'react-hook-form' +import { zodResolver } from '@hookform/resolvers/zod' +import { Form } from '@/components/ui/form' +import { SelectField } from '@/components/base/form-field' +import { ClientCommonConfigSchema, ClientCommonConfigValues } from './form/schema' +import { FRPCAdvancedSections } from './form/sections' +import { VisitorSection } from './visitor_form' +import { TypedVisitorConfig } from '@/types/visitor' export interface FRPCFormProps { clientID: string @@ -28,18 +38,23 @@ export interface FRPCFormProps { refetchClient: (options?: RefetchOptions) => Promise> clientProxyConfigs: TypedProxyConfig[] setClientProxyConfigs: React.Dispatch> + // Optional so FRPCEditor, which shares this props type, needs no change. + clientVisitorConfigs?: TypedVisitorConfig[] + setClientVisitorConfigs?: React.Dispatch> } -export const FRPCForm: React.FC = ({ clientID, serverID, clientConfig, client, refetchClient, clientProxyConfigs, setClientProxyConfigs, frpsUrl }) => { +export const FRPCForm: React.FC = ({ clientID, serverID, clientConfig, client, refetchClient, clientProxyConfigs, setClientProxyConfigs, clientVisitorConfigs = [], setClientVisitorConfigs, frpsUrl }) => { const { t } = useTranslation() const [proxyType, setProxyType] = useState('http') const [proxyName, setProxyName] = useState() - const [protocol, setProtocol] = useState("tcp") + + const form = useForm({ + resolver: zodResolver(ClientCommonConfigSchema), + defaultValues: { transport: { protocol: 'tcp' } }, + }) useEffect(() => { - if (clientConfig.transport?.protocol) { - setProtocol(clientConfig.transport?.protocol) - } + form.reset(clientConfig as ClientCommonConfigValues) }, [clientConfig]) const handleTypeChange = (value: string) => { @@ -70,19 +85,56 @@ export const FRPCForm: React.FC = ({ clientID, serverID, clientCo const updateFrpc = useMutation({ mutationFn: updateFRPC }) - const handleUpdate = async () => { + // wireProtocol v2 needs frp >= v0.69 on both agents (BUMP-07). An external frps has no + // agent to ask, so v2 stays locked for it; the master enforces the same rule on save. + const externalFrps = !!(frpsUrl || client?.frpsUrl) + const agentID = client?.originClientId || clientID + const { data: agentFrpVersions } = useQuery({ + queryKey: ['agentFrpVersions', agentID, serverID], + queryFn: async () => { + const [cli, srv] = await Promise.all([ + getClientsStatus({ clientIds: [agentID], clientType: ClientType.FRPC }), + getClientsStatus({ clientIds: [serverID], clientType: ClientType.FRPS }), + ]) + return { + client: cli.clients[agentID]?.version?.frpVersion, + server: srv.clients[serverID]?.version?.frpVersion, + } + }, + enabled: !!agentID && !!serverID && !externalFrps, + }) + const allowWireProtocolV2 = + !externalFrps && + SupportsWireProtocolV2(agentFrpVersions?.client) && + SupportsWireProtocolV2(agentFrpVersions?.server) + + const handleUpdate = async (values: ClientCommonConfigValues) => { + // The client query is keyed on (clientID, serverID) and has no placeholderData, so + // right after the server selector changes `client` is undefined and clientConfig + // falls back to {} -- while clientProxyConfigs still holds the previous client's + // list. Submitting in that window would write a config built from nothing. Gate on + // `client` rather than client.config: a freshly created client legitimately has an + // empty config and must stay submittable. + if (!client) { + toast(t('proxy.status.update'), { description: t('frpc.form.not_loaded') }) + return + } try { const res = await updateFrpc.mutateAsync({ - //@ts-ignore - config: Buffer.from( - JSON.stringify({ - proxies: clientProxyConfigs, - transport: { - ...clientConfig.transport, - protocol, - } - } as ClientConfig), - ), + // Spread the whole stored config: this endpoint replaces it wholesale, so + // sending only {proxies, transport} silently erased everything the raw editor + // can author (visitors, log, webServer, auth, dnsServer, start, ...). Replace + // semantics are deliberate -- they are how the raw editor deletes keys -- so the + // fix belongs here, not in a backend merge. + config: ObjToUint8Array({ + ...clientConfig, + ...values, + proxies: clientProxyConfigs, + visitors: clientVisitorConfigs, + // Merge rather than replace: `values.transport` is the resolver's output and + // carries only the keys this schema declares. + transport: { ...clientConfig.transport, ...values.transport }, + } as ClientConfig), serverId: serverID, clientId: clientID, frpsUrl: frpsUrl, @@ -119,9 +171,13 @@ export const FRPCForm: React.FC = ({ clientID, serverID, clientCo {t('proxy.type.http')} + {t('proxy.type.https')} {t('proxy.type.tcp')} {t('proxy.type.udp')} + {t('proxy.type.tcpmux')} {t('proxy.type.stcp')} + {t('proxy.type.xtcp')} + {t('proxy.type.sudp')} - - { return { label: item, value: item } })} - placeholder={t('proxy.form.protocol')} - label={t('proxy.form.protocol')} /> +
+ + ({ label: item, value: item }))} + // Exposed but mediated: the master derives serverPort from this, and the + // frpsUrl path overwrites it from the URL scheme. Do not add a serverPort field. + description={t('frpc.form.protocol_description')} + /> + + + @@ -176,12 +247,15 @@ export const FRPCForm: React.FC = ({ clientID, serverID, clientCo - diff --git a/www/components/frpc/plugins/http_2_http_plugin_form.tsx b/www/components/frpc/plugins/http_2_http_plugin_form.tsx new file mode 100644 index 00000000..9ba51d51 --- /dev/null +++ b/www/components/frpc/plugins/http_2_http_plugin_form.tsx @@ -0,0 +1,43 @@ +'use client' + +import { Input } from '@/components/ui/input' +import { Label } from '@/components/ui/label' +import { HTTP2HTTPPluginOptions } from '@/types/plugin' +import { useTranslation } from 'react-i18next' +import { PluginRequestHeadersField } from './shared_fields' + +interface Props { + config: HTTP2HTTPPluginOptions + setConfig: (c: HTTP2HTTPPluginOptions) => void +} + +export function HTTP2HTTPPluginForm({ config, setConfig }: Props) { + const { t } = useTranslation() + + return ( +
+
+ + setConfig({ ...config, localAddr: e.target.value })} + placeholder="127.0.0.1:8080" + /> +
+
+ + setConfig({ ...config, hostHeaderRewrite: e.target.value })} + placeholder="example.com" + /> +
+ setConfig({ ...config, requestHeaders })} + /> +
+ ) +} diff --git a/www/components/frpc/plugins/http_2_https_plugin_form.tsx b/www/components/frpc/plugins/http_2_https_plugin_form.tsx index 9f09a375..06be643c 100644 --- a/www/components/frpc/plugins/http_2_https_plugin_form.tsx +++ b/www/components/frpc/plugins/http_2_https_plugin_form.tsx @@ -4,6 +4,7 @@ import { Input } from '@/components/ui/input' import { Label } from '@/components/ui/label' import { HTTP2HTTPSPluginOptions } from '@/types/plugin' import { useTranslation } from 'react-i18next' +import { PluginRequestHeadersField } from './shared_fields' interface Props { config: HTTP2HTTPSPluginOptions @@ -34,6 +35,10 @@ export function HTTP2HTTPSPluginForm({ config, setConfig }: Props) { /> {/* You could add a custom HeaderOperations component here */} + setConfig({ ...config, requestHeaders })} + /> ) } diff --git a/www/components/frpc/plugins/https_2_http_plugin_form.tsx b/www/components/frpc/plugins/https_2_http_plugin_form.tsx index 078d59c1..78c79d43 100644 --- a/www/components/frpc/plugins/https_2_http_plugin_form.tsx +++ b/www/components/frpc/plugins/https_2_http_plugin_form.tsx @@ -4,6 +4,7 @@ import { Input } from '@/components/ui/input' import { Label } from '@/components/ui/label' import { HTTPS2HTTPPluginOptions } from '@/types/plugin' import { useTranslation } from 'react-i18next' +import { PluginEnableHTTP2Field, PluginRequestHeadersField } from './shared_fields' interface Props { config: HTTPS2HTTPPluginOptions @@ -51,6 +52,14 @@ export function HTTPS2HTTPPluginForm({ config, setConfig }: Props) { placeholder="/path/to/key.pem" /> + setConfig({ ...config, requestHeaders })} + /> + setConfig({ ...config, enableHTTP2 })} + /> ) } diff --git a/www/components/frpc/plugins/shared_fields.tsx b/www/components/frpc/plugins/shared_fields.tsx new file mode 100644 index 00000000..5bba7679 --- /dev/null +++ b/www/components/frpc/plugins/shared_fields.tsx @@ -0,0 +1,60 @@ +'use client' + +import { Label } from '@/components/ui/label' +import { KeyValueListInput } from '@/components/base/list-input' +import { SwitchWithLabel } from '@/components/base/form-field' +import { HeaderOperations } from '@/types/common' +import { useTranslation } from 'react-i18next' + +/** + * `requestHeaders.set` for http2http, http2https, https2http and https2https (PLG-04). + * + * There is deliberately no response-headers counterpart: frp's plugin options have no + * `responseHeaders` field (only HTTPProxyConfig does), and the strict decoder would + * reject the whole client config if a form emitted one. + */ +export function PluginRequestHeadersField({ + value, + onChange, +}: { + value?: HeaderOperations + onChange: (v: HeaderOperations | undefined) => void +}) { + const { t } = useTranslation() + return ( +
+ + onChange(set ? { ...value, set } : undefined)} + keyPlaceholder="X-From-Where" + valuePlaceholder="frp" + /> +
+ ) +} + +/** + * `enableHTTP2` for https2http and https2https. frp's Complete() turns an unset value + * into true, so "on" is written as an absent key and only "off" is stored explicitly. + */ +export function PluginEnableHTTP2Field({ + value, + onChange, +}: { + value?: boolean + onChange: (v: false | undefined) => void +}) { + const { t } = useTranslation() + return ( +
+ onChange(on ? undefined : false)} + /> +

{t('frpc.plugins.enable_http2_description')}

+
+ ) +} diff --git a/www/components/frpc/plugins/tls_2_raw_plugin_form.tsx b/www/components/frpc/plugins/tls_2_raw_plugin_form.tsx new file mode 100644 index 00000000..4eb403ef --- /dev/null +++ b/www/components/frpc/plugins/tls_2_raw_plugin_form.tsx @@ -0,0 +1,47 @@ +'use client' + +import { Input } from '@/components/ui/input' +import { Label } from '@/components/ui/label' +import { TLS2RawPluginOptions } from '@/types/plugin' +import { useTranslation } from 'react-i18next' + +interface Props { + config: TLS2RawPluginOptions + setConfig: (c: TLS2RawPluginOptions) => void +} + +export function TLS2RawPluginForm({ config, setConfig }: Props) { + const { t } = useTranslation() + + return ( +
+
+ + setConfig({ ...config, localAddr: e.target.value })} + placeholder="127.0.0.1:8080" + /> +
+
+ + setConfig({ ...config, crtPath: e.target.value })} + placeholder="/path/to/cert.pem" + /> +
+
+ + setConfig({ ...config, keyPath: e.target.value })} + placeholder="/path/to/key.pem" + /> +
+
+ ) +} diff --git a/www/components/frpc/proxy_form.tsx b/www/components/frpc/proxy_form.tsx index d843202b..56bef1b5 100644 --- a/www/components/frpc/proxy_form.tsx +++ b/www/components/frpc/proxy_form.tsx @@ -1,644 +1,59 @@ -import { HTTPProxyConfig, TCPProxyConfig, TypedProxyConfig, UDPProxyConfig, STCPProxyConfig } from '@/types/proxy' -import * as z from 'zod' import React from 'react' -import { TypedProxyConfigValid, ZodPortSchema, ZodStringOptionalSchema, ZodStringSchema } from '@/lib/consts' -import { useEffect, useState } from 'react' -import { zodResolver } from '@hookform/resolvers/zod' -import { useForm } from 'react-hook-form' -import { Button } from '@/components/ui/button' -import { Form, FormDescription } from '@/components/ui/form' -import { Label } from '@/components/ui/label' -import { YesIcon } from '@/components/ui/icon' import { useTranslation } from 'react-i18next' -import { useQuery } from '@tanstack/react-query' -import { getServer } from '@/api/server' -import { Switch } from '@/components/ui/switch' -import { VisitPreview } from '../base/visit-preview' -import { HostField, PortField, SecretStringField, StringArrayField, StringField } from '../base/form-field' -import PluginConfigForm from './client_plugins' -import { TypedClientPluginOptions } from '@/types/plugin' -import { toast } from 'sonner' -export const TCPConfigSchema = z.object({ - remotePort: ZodPortSchema.optional(), - localIP: ZodStringSchema.default('127.0.0.1').optional(), - localPort: ZodPortSchema.optional(), -}) - -export const UDPConfigSchema = z.object({ - remotePort: ZodPortSchema.optional(), - localIP: ZodStringSchema.default('127.0.0.1').optional(), - localPort: ZodPortSchema.optional(), -}) - -export const HTTPConfigSchema = z.object({ - localPort: ZodPortSchema.optional(), - localIP: ZodStringSchema.default('127.0.0.1').optional(), - subdomain: ZodStringOptionalSchema, - locations: z.array(ZodStringSchema).optional(), - customDomains: z.array(ZodStringSchema).optional(), - httpUser: ZodStringOptionalSchema, - httpPassword: ZodStringOptionalSchema, -}) - -export const STCPConfigSchema = z.object({ - localIP: ZodStringSchema.default('127.0.0.1').optional(), - localPort: ZodPortSchema.optional(), - secretKey: ZodStringSchema.optional(), -}) - -export interface ProxyFormProps { - clientID: string - serverID: string - proxyName: string - enablePreview?: boolean - defaultProxyConfig?: TypedProxyConfig - clientProxyConfigs: TypedProxyConfig[] - setClientProxyConfigs: React.Dispatch> -} - -export const TypedProxyForm: React.FC = ({ - serverID, - clientID, - defaultProxyConfig, - proxyName, - clientProxyConfigs, - setClientProxyConfigs, - enablePreview, -}) => { - if (!defaultProxyConfig) { +import { TCPProxyForm, UDPProxyForm } from './proxy_forms/port_proxy_form' +import { HTTPProxyForm } from './proxy_forms/http_proxy_form' +import { HTTPSProxyForm } from './proxy_forms/https_proxy_form' +import { TCPMuxProxyForm } from './proxy_forms/tcpmux_proxy_form' +import { STCPProxyForm, SUDPProxyForm, XTCPProxyForm } from './proxy_forms/secret_proxy_form' +import { ProxyFormProps } from './proxy_forms/shared/types' + +// The per-type forms live in ./proxy_forms/. Keeping this module small and at its +// original path means an upstream change to it merges as content rather than colliding +// with a file that was deleted and replaced by a directory. +export type { ProxyFormProps } +export { TCPProxyForm, UDPProxyForm, HTTPProxyForm, HTTPSProxyForm, TCPMuxProxyForm } +export { STCPProxyForm, SUDPProxyForm, XTCPProxyForm } +export { SecretProxyForm } from './proxy_forms/secret_proxy_form' + +export const TypedProxyForm: React.FC = (props) => { + const { serverID, clientID, defaultProxyConfig } = props + + if (!defaultProxyConfig || !serverID || !clientID) { return <> } - return ( - <> - {defaultProxyConfig.type === 'tcp' && serverID && clientID && ( - - )} - {defaultProxyConfig.type === 'udp' && serverID && clientID && ( - - )} - {defaultProxyConfig.type === 'http' && serverID && clientID && ( - - )} - {defaultProxyConfig.type === 'stcp' && serverID && clientID && ( - - )} - - ) -} - -export const TCPProxyForm: React.FC = ({ - serverID, - clientID, - defaultProxyConfig, - proxyName, - clientProxyConfigs, - setClientProxyConfigs, - enablePreview, -}) => { - const defaultConfig = defaultProxyConfig as TCPProxyConfig - const [timeoutID, setTimeoutID] = useState() - const form = useForm>({ - resolver: zodResolver(TCPConfigSchema), - defaultValues: { - remotePort: defaultConfig?.remotePort, - localIP: defaultConfig?.localIP, - localPort: defaultConfig?.localPort, - }, - }) - - const [usePlugin, setUsePlugin] = useState( - (defaultConfig.plugin && defaultConfig.plugin.type.length > 0) || false, - ) - const [pluginConfig, setPluginConfig] = useState(defaultConfig.plugin) - - const onSubmit = async (values: z.infer) => { - const cfgToSubmit = { - ...defaultConfig, - ...values, - plugin: usePlugin ? pluginConfig : undefined, - type: 'tcp', - name: proxyName, - } as TCPProxyConfig - if (!TypedProxyConfigValid(cfgToSubmit)) { - toast.error('Invalid configuration') - return - } - handleSave() - const newProxiyConfigs = clientProxyConfigs.map((proxyCfg) => { - if (proxyCfg.name === proxyName) { - return cfgToSubmit - } - return proxyCfg - }) - setClientProxyConfigs(newProxiyConfigs) - } - - const [isSaveDisabled, setSaveDisabled] = useState(false) - - const handleSave = () => { - setSaveDisabled(true) - if (timeoutID) { - clearTimeout(timeoutID) - } - setTimeoutID( - setTimeout(() => { - setSaveDisabled(false) - }, 3000), - ) - } - - const { t } = useTranslation() - - const { data: server } = useQuery({ - queryKey: ['getServer', serverID], - queryFn: () => { - return getServer({ serverId: serverID }) - }, - }) - - return ( -
- - {server?.server?.ip && - defaultConfig.remotePort && - defaultConfig.localIP && - defaultConfig.localPort && - enablePreview && ( -
- -
- -
-
- )} - - - - { - setUsePlugin(value) - if (!value) { - setPluginConfig(undefined) - } - }} - /> - {usePlugin ? ( - - ) : null} - - - - ) -} - -export const STCPProxyForm: React.FC = ({ - serverID, - clientID, - defaultProxyConfig, - proxyName, - clientProxyConfigs, - setClientProxyConfigs, - enablePreview, -}) => { - const defaultConfig = defaultProxyConfig as STCPProxyConfig - const [timeoutID, setTimeoutID] = useState() - const form = useForm>({ - resolver: zodResolver(STCPConfigSchema), - defaultValues: { - localPort: defaultConfig?.localPort, - localIP: defaultConfig?.localIP, - secretKey: defaultConfig?.secretKey, - }, - }) - - const [usePlugin, setUsePlugin] = useState( - (defaultConfig.plugin && defaultConfig.plugin.type.length > 0) || false, - ) - - const [pluginConfig, setPluginConfig] = useState(defaultConfig.plugin) - - const onSubmit = async (values: z.infer) => { - const cfgToSubmit = { - ...defaultConfig, - ...values, - plugin: usePlugin ? pluginConfig : undefined, - type: 'stcp', - name: proxyName, - } as STCPProxyConfig - if (!TypedProxyConfigValid(cfgToSubmit)) { - toast.error('Invalid configuration') - return - } - handleSave() - const newProxiyConfigs = clientProxyConfigs.map((proxyCfg) => { - if (proxyCfg.name === proxyName) { - return cfgToSubmit - } - return proxyCfg - }) - setClientProxyConfigs(newProxiyConfigs) - } - - const [isSaveDisabled, setSaveDisabled] = useState(false) - - const handleSave = () => { - setSaveDisabled(true) - if (timeoutID) { - clearTimeout(timeoutID) - } - setTimeoutID( - setTimeout(() => { - setSaveDisabled(false) - }, 3000), - ) + // A switch rather than a module-level Record: the default branch means + // a proxy type with no form -- including any type a future frp adds -- explains itself + // instead of rendering an empty panel. + switch (defaultProxyConfig.type) { + case 'tcp': + return + case 'udp': + return + case 'http': + return + case 'https': + return + case 'tcpmux': + return + case 'stcp': + return + case 'xtcp': + return + case 'sudp': + return + default: + return } - - const { t } = useTranslation() - - return ( -
- - - - { - setUsePlugin(value) - if (!value) { - setPluginConfig(undefined) - } - }} - label={t('proxy.form.use_plugin')} - /> - {usePlugin ? ( - - ) : null} - - - - - ) -} - -export const UDPProxyForm: React.FC = ({ - serverID, - clientID, - defaultProxyConfig, - proxyName, - clientProxyConfigs, - setClientProxyConfigs, - enablePreview, -}) => { - const defaultConfig = defaultProxyConfig as UDPProxyConfig - const [timeoutID, setTimeoutID] = useState() - const form = useForm>({ - resolver: zodResolver(UDPConfigSchema), - defaultValues: { - localPort: defaultConfig?.localPort, - localIP: defaultConfig?.localIP, - remotePort: defaultConfig?.remotePort, - }, - }) - - const [usePlugin, setUsePlugin] = useState( - (defaultConfig.plugin && defaultConfig.plugin.type.length > 0) || false, - ) - - const [pluginConfig, setPluginConfig] = useState(defaultConfig.plugin) - - const onSubmit = async (values: z.infer) => { - const cfgToSubmit = { - ...defaultConfig, - ...values, - plugin: usePlugin ? pluginConfig : undefined, - type: 'udp', - name: proxyName, - } as UDPProxyConfig - if (!TypedProxyConfigValid(cfgToSubmit)) { - toast.error('Invalid configuration') - return - } - handleSave() - const newProxiyConfigs = clientProxyConfigs.map((proxyCfg) => { - if (proxyCfg.name === proxyName) { - return cfgToSubmit - } - return proxyCfg - }) - setClientProxyConfigs(newProxiyConfigs) - } - - const [isSaveDisabled, setSaveDisabled] = useState(false) - - const handleSave = () => { - setSaveDisabled(true) - if (timeoutID) { - clearTimeout(timeoutID) - } - setTimeoutID( - setTimeout(() => { - setSaveDisabled(false) - }, 3000), - ) - } - - const { t } = useTranslation() - - const { data: server } = useQuery({ - queryKey: ['getServer', serverID], - queryFn: () => { - return getServer({ serverId: serverID }) - }, - }) - - return ( -
- - {server?.server?.ip && - defaultConfig.remotePort && - defaultConfig.localIP && - defaultConfig.localPort && - enablePreview && ( -
- -
- -
-
- )} - - - - { - setUsePlugin(value) - if (!value) { - setPluginConfig(undefined) - } - }} - label={t('proxy.form.use_plugin')} - /> - {usePlugin ? ( - - ) : null} - - - - ) -} - -export const HTTPProxyForm: React.FC = ({ - serverID, - clientID, - defaultProxyConfig, - proxyName, - clientProxyConfigs, - setClientProxyConfigs, - enablePreview, -}) => { - const defaultConfig = defaultProxyConfig as HTTPProxyConfig - const [timeoutID, setTimeoutID] = useState() - const [moreSettings, setMoreSettings] = useState(false) - const [useAuth, setUseAuth] = useState(false) - - const form = useForm>({ - resolver: zodResolver(HTTPConfigSchema), - defaultValues: { - localIP: defaultConfig?.localIP, - localPort: defaultConfig?.localPort, - subdomain: defaultConfig?.subdomain, - locations: defaultConfig?.locations, - customDomains: defaultConfig?.customDomains, - httpPassword: defaultConfig?.httpPassword, - httpUser: defaultConfig?.httpUser, - }, - }) - - const [usePlugin, setUsePlugin] = useState( - (defaultConfig.plugin && defaultConfig.plugin.type.length > 0) || false, - ) - - const [pluginConfig, setPluginConfig] = useState(defaultConfig.plugin) - - const onSubmit = async (values: z.infer) => { - const cfgToSubmit = { - ...defaultConfig, - ...values, - plugin: usePlugin ? pluginConfig : undefined, - type: 'http', - name: proxyName, - } as HTTPProxyConfig - if (!TypedProxyConfigValid(cfgToSubmit)) { - toast.error('Invalid configuration') - return - } - if (!values.customDomains && !values.subdomain) { - toast.error('Please provide a subdomain or custom domains') - return - } - handleSave() - const newProxiyConfigs = clientProxyConfigs.map((proxyCfg) => { - if (proxyCfg.name === proxyName) { - return cfgToSubmit - } - return proxyCfg - }) - setClientProxyConfigs(newProxiyConfigs) - } - - const [isSaveDisabled, setSaveDisabled] = useState(false) - - useEffect(() => { - if (defaultConfig?.httpPassword || defaultConfig?.httpUser) { - setUseAuth(true) - } - }, [defaultConfig?.httpPassword, defaultConfig?.httpUser]) - - const handleSave = () => { - setSaveDisabled(true) - if (timeoutID) { - clearTimeout(timeoutID) - } - setTimeoutID( - setTimeout(() => { - setSaveDisabled(false) - }, 3000), - ) - } - - const { t } = useTranslation() - - const { data: server } = useQuery({ - queryKey: ['getServer', serverID], - queryFn: () => { - return getServer({ serverId: serverID }) - }, - }) - - return ( -
- - {server && - server.server && - server.server.ip && - defaultConfig && - defaultConfig.localIP && - defaultConfig.localPort && - defaultConfig.subdomain && - enablePreview && ( -
- -
- -
-
- )} - - - - - {t('proxy.form.domain_description')} - { - setUsePlugin(value) - if (!value) { - setPluginConfig(undefined) - } - }} - label={t('proxy.form.use_plugin')} - /> - {usePlugin ? ( - - ) : null} - - {moreSettings && ( -
- - - {useAuth && ( -
- - -
- )} -
- )} - - - - ) } -const SwitchWithLabel = ({ - name, - label, - defaultValue, - setValue, -}: { - name: string - label: string - defaultValue?: boolean - setValue: (value: boolean) => void -}) => { +const UnsupportedProxyTypeNotice: React.FC<{ type: string }> = ({ type }) => { const { t } = useTranslation() return ( -
- - +
+

{t('proxy.form.unsupported_type.title', { type })}

+

{t('proxy.form.unsupported_type.description')}

) } diff --git a/www/components/frpc/proxy_forms/http_proxy_form.tsx b/www/components/frpc/proxy_forms/http_proxy_form.tsx new file mode 100644 index 00000000..394312a8 --- /dev/null +++ b/www/components/frpc/proxy_forms/http_proxy_form.tsx @@ -0,0 +1,94 @@ +import React, { useEffect, useState } from 'react' +import * as z from 'zod' +import { useForm } from 'react-hook-form' +import { zodResolver } from '@hookform/resolvers/zod' + +import { Form } from '@/components/ui/form' +import { StringArrayField, SwitchWithLabel } from '@/components/base/form-field' +import { HTTPProxyConfig } from '@/types/proxy' +import { HTTPConfigSchema } from './shared/schemas' +import { isWorkerManaged, splitAnnotations } from './shared/build' +import { DomainFields, domainsMissing, HTTPAuthFields, LocalBackendFields } from './shared/domain_fields' +import { AdvancedProxySections } from './shared/sections' +import { ProxyFormFooter, ProxyPreview, useProxyForm } from './shared/use_proxy_form' +import { ProxyFormProps } from './shared/types' + +export const HTTPProxyForm: React.FC = (props) => { + const [moreSettings, setMoreSettings] = useState(false) + const [useAuth, setUseAuth] = useState(false) + + const ctx = useProxyForm({ + type: 'http', + props, + extraValidate: (values) => (domainsMissing(values) ? 'proxy.form.domain_required' : undefined), + }) + const defaultConfig = ctx.defaultConfig as HTTPProxyConfig + + const form = useForm>({ + resolver: zodResolver(HTTPConfigSchema), + defaultValues: { + localIP: defaultConfig?.localIP, + localPort: defaultConfig?.localPort, + subdomain: defaultConfig?.subdomain, + locations: defaultConfig?.locations, + customDomains: defaultConfig?.customDomains, + httpUser: defaultConfig?.httpUser, + httpPassword: defaultConfig?.httpPassword, + hostHeaderRewrite: defaultConfig?.hostHeaderRewrite, + requestHeaders: defaultConfig?.requestHeaders, + routeByHTTPUser: defaultConfig?.routeByHTTPUser, + ...ctx.commonDefaults, + }, + }) + + useEffect(() => { + if (defaultConfig?.httpPassword || defaultConfig?.httpUser) { + setUseAuth(true) + } + }, [defaultConfig?.httpPassword, defaultConfig?.httpUser]) + + return ( +
+ + + + + + + + {moreSettings && ( +
+ + +
+ )} +
+ + + ) +} diff --git a/www/components/frpc/proxy_forms/https_proxy_form.tsx b/www/components/frpc/proxy_forms/https_proxy_form.tsx new file mode 100644 index 00000000..6589b9a9 --- /dev/null +++ b/www/components/frpc/proxy_forms/https_proxy_form.tsx @@ -0,0 +1,60 @@ +import React from 'react' +import * as z from 'zod' +import { useForm } from 'react-hook-form' +import { zodResolver } from '@hookform/resolvers/zod' + +import { Form, FormDescription } from '@/components/ui/form' +import { HTTPSProxyConfig } from '@/types/proxy' +import { HTTPSConfigSchema } from './shared/schemas' +import { isWorkerManaged, splitAnnotations } from './shared/build' +import { DomainFields, domainsMissing, LocalBackendFields } from './shared/domain_fields' +import { AdvancedProxySections } from './shared/sections' +import { ProxyFormFooter, ProxyPreview, useProxyForm } from './shared/use_proxy_form' +import { ProxyFormProps } from './shared/types' + +/** HTTPSProxyConfig is ProxyBaseConfig + DomainConfig and nothing else. */ +export const HTTPSProxyForm: React.FC = (props) => { + const ctx = useProxyForm({ + type: 'https', + props, + extraValidate: (values) => (domainsMissing(values) ? 'proxy.form.domain_required' : undefined), + }) + const defaultConfig = ctx.defaultConfig as HTTPSProxyConfig + + const form = useForm>({ + resolver: zodResolver(HTTPSConfigSchema), + defaultValues: { + localIP: defaultConfig?.localIP, + localPort: defaultConfig?.localPort, + subdomain: defaultConfig?.subdomain, + customDomains: defaultConfig?.customDomains, + ...ctx.commonDefaults, + }, + }) + + return ( +
+ + + + + {/* frps only listens for https proxies when vhostHTTPSPort is set, and that is + configured on the server, not here. Without it the proxy is dead on arrival. */} + {ctx.t('proxy.form.https_port_hint')} + + + + + ) +} diff --git a/www/components/frpc/proxy_forms/port_proxy_form.tsx b/www/components/frpc/proxy_forms/port_proxy_form.tsx new file mode 100644 index 00000000..bada4a6f --- /dev/null +++ b/www/components/frpc/proxy_forms/port_proxy_form.tsx @@ -0,0 +1,66 @@ +import React from 'react' +import * as z from 'zod' +import { useForm } from 'react-hook-form' +import { zodResolver } from '@hookform/resolvers/zod' + +import { Form } from '@/components/ui/form' +import { PortField } from '@/components/base/form-field' +import { TCPProxyConfig, UDPProxyConfig } from '@/types/proxy' +import { TCPConfigSchema, UDPConfigSchema } from './shared/schemas' +import { isWorkerManaged, splitAnnotations } from './shared/build' +import { LocalBackendFields } from './shared/domain_fields' +import { AdvancedProxySections } from './shared/sections' +import { ProxyFormFooter, ProxyPreview, useProxyForm } from './shared/use_proxy_form' +import { ProxyFormProps } from './shared/types' + +/** tcp and udp differ only in the literal they write into `type`. */ +const PortProxyForm: React.FC = ({ type, ...props }) => { + const schema = type === 'tcp' ? TCPConfigSchema : UDPConfigSchema + const ctx = useProxyForm({ type, props }) + const defaultConfig = ctx.defaultConfig as TCPProxyConfig | UDPProxyConfig + + const form = useForm>({ + resolver: zodResolver(schema), + defaultValues: { + remotePort: defaultConfig?.remotePort, + localIP: defaultConfig?.localIP, + localPort: defaultConfig?.localPort, + ...ctx.commonDefaults, + }, + }) + + return ( +
+ + + + + + + + + ) +} + +export const TCPProxyForm: React.FC = (props) => +export const UDPProxyForm: React.FC = (props) => diff --git a/www/components/frpc/proxy_forms/secret_proxy_form.tsx b/www/components/frpc/proxy_forms/secret_proxy_form.tsx new file mode 100644 index 00000000..cdb58b06 --- /dev/null +++ b/www/components/frpc/proxy_forms/secret_proxy_form.tsx @@ -0,0 +1,73 @@ +import React from 'react' +import * as z from 'zod' +import { useForm } from 'react-hook-form' +import { zodResolver } from '@hookform/resolvers/zod' + +import { Form, FormDescription } from '@/components/ui/form' +import { SecretStringField, StringArrayField } from '@/components/base/form-field' +import { STCPProxyConfig, SUDPProxyConfig, XTCPProxyConfig } from '@/types/proxy' +import { SecretConfigSchema } from './shared/schemas' +import { isWorkerManaged, splitAnnotations } from './shared/build' +import { LocalBackendFields } from './shared/domain_fields' +import { AdvancedProxySections } from './shared/sections' +import { ProxyFormFooter, useProxyForm } from './shared/use_proxy_form' +import { ProxyFormProps } from './shared/types' + +export type SecretProxyType = 'stcp' | 'xtcp' | 'sudp' + +/** + * stcp, xtcp and sudp share one config shape -- a secret key, an allow-list and a local + * backend -- and frp's validators for all three are no-ops. One parametrized form covers + * them; the type-specific part is only the hint about what else has to be configured. + */ +export const SecretProxyForm: React.FC = ({ type, ...props }) => { + const ctx = useProxyForm({ type, props }) + const defaultConfig = ctx.defaultConfig as STCPProxyConfig | XTCPProxyConfig | SUDPProxyConfig + + const form = useForm>({ + resolver: zodResolver(SecretConfigSchema), + defaultValues: { + localIP: defaultConfig?.localIP, + localPort: defaultConfig?.localPort, + secretKey: defaultConfig?.secretKey, + allowUsers: defaultConfig?.allowUsers, + ...ctx.commonDefaults, + }, + }) + + return ( +
+ + + + + {ctx.t('proxy.form.allow_users_description')} + {/* These proxy types publish nothing reachable on their own -- something has to + connect to them through a visitor. */} + {ctx.t('proxy.form.visitor_required_hint')} + {type === 'xtcp' && {ctx.t('proxy.form.stun_hint')}} + + + + + ) +} + +export const STCPProxyForm: React.FC = (props) => +export const XTCPProxyForm: React.FC = (props) => +export const SUDPProxyForm: React.FC = (props) => diff --git a/www/components/frpc/proxy_forms/shared/build.ts b/www/components/frpc/proxy_forms/shared/build.ts new file mode 100644 index 00000000..741e72f7 --- /dev/null +++ b/www/components/frpc/proxy_forms/shared/build.ts @@ -0,0 +1,187 @@ +import { MANAGED_LB_GROUP_PREFIX, RESERVED_PROXY_ANNOTATION_KEYS } from '@/lib/consts' +import { ProxyType, TypedProxyConfig } from '@/types/proxy' +import { TypedClientPluginOptions } from '@/types/plugin' + +// frp's `enabled` (>= v0.66) is tri-state: absent or true means enabled, and only an +// explicit false disables the entry. Write `false` alone and leave the key off otherwise +// -- serializing `true` is redundant, and defaulting the field to `false` anywhere in the +// form would silently disable every proxy it touches. +// +// Note this is frp-level and independent of the panel's own ProxyConfig.Stopped flag, +// which is what the proxy list's start/stop action drives. +export const enabledToConfigValue = (enabled: boolean): false | undefined => (enabled ? undefined : false) + +type AnyRecord = Record + +const pick = (src: AnyRecord | undefined, keys: string[]): AnyRecord => { + const out: AnyRecord = {} + if (!src) return out + for (const k of keys) { + if (src[k] !== undefined) out[k] = src[k] + } + return out +} + +const omit = (src: AnyRecord | undefined, keys: string[]): AnyRecord => { + const out: AnyRecord = {} + if (!src) return out + for (const k of Object.keys(src)) { + if (!keys.includes(k)) out[k] = src[k] + } + return out +} + +/** + * frp writes zero values into these nested objects because `omitempty` does nothing for + * structs in encoding/json, so almost every stored blob carries + * `{"transport":{"bandwidthLimit":""}}`, `{"loadBalancer":{"group":""}}` and + * `{"healthCheck":{"type":"","intervalSeconds":0}}`. Loading those straight into + * defaultValues would show every proxy as having a health check on a 0s interval. + */ +const isBlank = (v: unknown) => v === undefined || v === null || v === '' || v === 0 + +const dropIfAllBlank = (obj: T | undefined): T | undefined => { + if (!obj) return undefined + return Object.values(obj).every(isBlank) ? undefined : obj +} + +/** Strip frp's zero-value sentinels so an untouched section renders as empty. */ +export const sanitizeProxyDefaults = (cfg: TypedProxyConfig | undefined) => { + if (!cfg) return {} + const anyCfg = cfg as AnyRecord + return { + transport: dropIfAllBlank(anyCfg.transport), + healthCheck: anyCfg.healthCheck?.type ? anyCfg.healthCheck : undefined, + loadBalancer: anyCfg.loadBalancer?.group ? anyCfg.loadBalancer : undefined, + metadatas: dropIfAllBlank(anyCfg.metadatas), + annotations: splitAnnotations(anyCfg.annotations).user, + } +} + +/** + * The panel stores its own keys in `annotations` -- models/proxy_config.go derives the + * worker_id column from them, so a user overwriting them orphans the DB link to the + * worker. Reserved keys never enter the editor and are re-applied on the way out. + */ +export const splitAnnotations = (a?: Record) => ({ + reserved: pick(a, RESERVED_PROXY_ANNOTATION_KEYS) as Record, + user: (Object.keys(omit(a, RESERVED_PROXY_ANNOTATION_KEYS)).length + ? omit(a, RESERVED_PROXY_ANNOTATION_KEYS) + : undefined) as Record | undefined, +}) + +export const mergeReservedAnnotations = (orig?: Record, user?: Record) => ({ + ...user, + ...splitAnnotations(orig).reserved, +}) + +/** True when the panel owns this proxy's load-balancer group (worker ingress). */ +export const isWorkerManaged = (cfg: TypedProxyConfig | undefined): boolean => { + const a = (cfg as AnyRecord | undefined)?.annotations + return !!a?.ingress && !!a?.worker_id +} + +export const isManagedLoadBalancerGroup = (group?: string) => !!group?.startsWith(MANAGED_LB_GROUP_PREFIX) + +const KEYS_BY_TYPE: Record = { + tcp: ['remotePort'], + udp: ['remotePort'], + http: [ + 'locations', + 'httpUser', + 'httpPassword', + 'hostHeaderRewrite', + 'requestHeaders', + 'responseHeaders', + 'routeByHTTPUser', + 'subdomain', + 'customDomains', + ], + https: ['subdomain', 'customDomains'], + tcpmux: ['httpUser', 'httpPassword', 'routeByHTTPUser', 'multiplexer', 'subdomain', 'customDomains'], + stcp: ['secretKey', 'allowUsers'], + xtcp: ['secretKey', 'allowUsers', 'natTraversal'], + sudp: ['secretKey', 'allowUsers'], +} + +const BASE_KEYS = [ + 'name', + 'type', + 'enabled', + 'transport', + 'metadatas', + 'annotations', + 'loadBalancer', + 'healthCheck', + 'localIP', + 'localPort', + 'plugin', +] + +/** + * Changing the type in the create/edit dialog used to spread the previous config over the + * new one, carrying type-specific keys along -- e.g. `remotePort` onto an http proxy. The + * backend decodes with DisallowUnknownFields all the way into each proxy element, so that + * is a hard `unknown field "remotePort"` rather than a field that is quietly ignored. + */ +export const coerceProxyConfigToType = (cfg: AnyRecord | undefined, type: ProxyType): TypedProxyConfig => + ({ + ...pick(cfg, [...BASE_KEYS, ...KEYS_BY_TYPE[type]]), + type, + // frp rejects an empty multiplexer outright, and the agent tears the tunnel down + // when validation fails, so never leave it unset. + ...(type === 'tcpmux' ? { multiplexer: cfg?.multiplexer ?? 'httpconnect' } : {}), + }) as TypedProxyConfig + +export interface BuildProxyConfigOptions { + type: ProxyType + name: string + enabled: boolean + plugin?: TypedClientPluginOptions +} + +/** + * Assembles the config a form submits. The nested sections are merged key-wise rather + * than replaced: a shallow `...values` would drop raw-JSON-authored keys the zod schema + * does not model, such as healthCheck.httpHeaders. + */ +export const buildProxyConfig = ( + defaultConfig: AnyRecord | undefined, + values: AnyRecord, + opts: BuildProxyConfigOptions, +): TypedProxyConfig => { + const merged: AnyRecord = { + ...defaultConfig, + ...values, + type: opts.type, + name: opts.name, + enabled: enabledToConfigValue(opts.enabled), + plugin: opts.plugin, + } + + for (const key of ['transport', 'healthCheck', 'loadBalancer'] as const) { + const combined = { ...(defaultConfig?.[key] || {}), ...(values[key] || {}) } + const kept = dropIfAllBlank(combined) + if (kept) { + merged[key] = kept + } else { + delete merged[key] + } + } + + const annotations = mergeReservedAnnotations(defaultConfig?.annotations, values.annotations) + if (Object.keys(annotations).length) { + merged.annotations = annotations + } else { + delete merged.annotations + } + + if (!merged.metadatas || !Object.keys(merged.metadatas).length) { + delete merged.metadatas + } + if (merged.plugin === undefined) { + delete merged.plugin + } + + return merged as TypedProxyConfig +} diff --git a/www/components/frpc/proxy_forms/shared/domain_fields.tsx b/www/components/frpc/proxy_forms/shared/domain_fields.tsx new file mode 100644 index 00000000..138a98a5 --- /dev/null +++ b/www/components/frpc/proxy_forms/shared/domain_fields.tsx @@ -0,0 +1,80 @@ +import React from 'react' +import { Control } from 'react-hook-form' +import { useTranslation } from 'react-i18next' + +import { FormDescription } from '@/components/ui/form' +import { HostField, PortField, StringArrayField, StringField, SwitchWithLabel } from '@/components/base/form-field' + +type FieldProps = { control: Control } + +/** localIP + localPort -- the backend every proxy type points at. */ +export const LocalBackendFields = ({ control }: FieldProps) => { + const { t } = useTranslation() + return ( + <> + + + + ) +} + +/** subdomain + customDomains, shared by http, https and tcpmux. */ +export const DomainFields = ({ control }: FieldProps) => { + const { t } = useTranslation() + return ( + <> + + + {t('proxy.form.domain_description')} + + ) +} + +/** frp requires a subdomain or at least one custom domain for domain-routed types. */ +export const domainsMissing = (values: { subdomain?: string; customDomains?: string[] }) => + !values.subdomain && !values.customDomains?.length + +/** httpUser + httpPassword behind a toggle, shared by http and tcpmux. */ +export const HTTPAuthFields = ({ + control, + useAuth, + setUseAuth, +}: FieldProps & { useAuth: boolean; setUseAuth: (v: boolean) => void }) => { + const { t } = useTranslation() + return ( + <> + + {useAuth && ( +
+ + +
+ )} + + ) +} diff --git a/www/components/frpc/proxy_forms/shared/plugins.ts b/www/components/frpc/proxy_forms/shared/plugins.ts new file mode 100644 index 00000000..363077b9 --- /dev/null +++ b/www/components/frpc/proxy_forms/shared/plugins.ts @@ -0,0 +1,55 @@ +import { ClientPluginType } from '@/types/plugin' +import { ProxyType } from '@/types/proxy' + +const ALL_STREAM_PLUGINS: ClientPluginType[] = [ + 'http_proxy', + 'http2http', + 'http2https', + 'https2http', + 'https2https', + 'socks5', + 'static_file', + 'tls2raw', + 'unix_domain_socket', +] + +/** + * Which client plugins can actually work behind each proxy type (PLG-05). + * + * A plugin replaces the local backend and receives whatever byte stream frps hands the + * proxy, so the type decides what that stream is: + * + * - tcp, stcp, xtcp, tcpmux: an opaque TCP stream -- every plugin applies. + * - http: plain HTTP requests routed by Host. Only plugins that speak HTTP server-side. + * `http_proxy` is excluded on purpose: it forwards to `req.URL`, which is relative + * for vhost-routed requests. + * - https: an undecrypted TLS stream routed by SNI. Only plugins that terminate TLS, + * plus `unix_domain_socket`, which passes bytes through untouched. + * - udp, sudp: frp's UDP proxies never consult the plugin, so offering one would only + * produce a config that is accepted and silently ignored. + * + * This narrows the form only. The raw-JSON editor still accepts any combination. + */ +export const PLUGINS_BY_PROXY_TYPE: Record = { + tcp: ALL_STREAM_PLUGINS, + stcp: ALL_STREAM_PLUGINS, + xtcp: ALL_STREAM_PLUGINS, + tcpmux: ALL_STREAM_PLUGINS, + http: ['http2http', 'http2https', 'static_file', 'unix_domain_socket'], + https: ['https2http', 'https2https', 'tls2raw', 'unix_domain_socket'], + udp: [], + sudp: [], +} + +/** + * The plugin choices to offer. A plugin already stored on the proxy stays selectable + * even when it is not supported, so opening the form never blanks the select or + * silently swaps the plugin out. + */ +export const pluginChoices = (type: ProxyType, current?: string): ClientPluginType[] => { + const supported = PLUGINS_BY_PROXY_TYPE[type] ?? ALL_STREAM_PLUGINS + if (current && !supported.includes(current as ClientPluginType)) { + return [...supported, current as ClientPluginType] + } + return supported +} diff --git a/www/components/frpc/proxy_forms/shared/schemas.ts b/www/components/frpc/proxy_forms/shared/schemas.ts new file mode 100644 index 00000000..36b805bb --- /dev/null +++ b/www/components/frpc/proxy_forms/shared/schemas.ts @@ -0,0 +1,137 @@ +import * as z from 'zod' +import { + AnnotationKeyRegex, + MANAGED_LB_GROUP_PREFIX, + ZodBandwidthSchema, + ZodOptionalIntSchema, + ZodOptionalSecondsSchema, + ZodPortSchema, + ZodStringOptionalSchema, + ZodStringSchema, +} from '@/lib/consts' + +export const TransportSchema = z + .object({ + useEncryption: z.boolean().optional(), + useCompression: z.boolean().optional(), + bandwidthLimit: ZodBandwidthSchema, + bandwidthLimitMode: z.enum(['client', 'server']).optional(), + proxyProtocolVersion: z.enum(['v1', 'v2']).optional(), + }) + .optional() + +export const HealthCheckSchema = z + .object({ + type: z.enum(['tcp', 'http']).optional(), + timeoutSeconds: ZodOptionalSecondsSchema, + maxFailed: ZodOptionalIntSchema, + intervalSeconds: ZodOptionalSecondsSchema, + path: ZodStringOptionalSchema, + }) + .optional() + // frp rejects an http health check without a path, and a rejected config takes the + // client's whole tunnel set down until the next valid push. + .superRefine((v, ctx) => { + if (v?.type === 'http' && !v.path) { + ctx.addIssue({ + code: z.ZodIssueCode.custom, + path: ['path'], + message: 'validation.healthCheckPathRequired', + }) + } + }) + +export const LoadBalancerSchema = z + .object({ + group: ZodStringOptionalSchema.refine((g) => !g?.startsWith(MANAGED_LB_GROUP_PREFIX), { + message: 'validation.reservedLoadBalancerGroup', + }), + groupKey: ZodStringOptionalSchema, + }) + .optional() + +export const KeyValueMapSchema = z.record(z.string(), z.string()).optional() + +export const AnnotationMapSchema = z + .record(z.string().regex(AnnotationKeyRegex, { message: 'validation.annotationKey' }), z.string()) + .optional() + +/** + * Spread into every per-type schema. + * + * These must stay in lockstep with the JSX: zod objects strip unknown keys by default and + * zodResolver hands handleSubmit the *parsed* object, so a field rendered under a name the + * schema does not declare is dropped at submit with no error at all. + */ +export const ProxyCommonFields = { + transport: TransportSchema, + healthCheck: HealthCheckSchema, + loadBalancer: LoadBalancerSchema, + metadatas: KeyValueMapSchema, + annotations: AnnotationMapSchema, +} + +const LocalBackendFields = { + localIP: ZodStringSchema.default('127.0.0.1').optional(), + localPort: ZodPortSchema.optional(), +} + +const DomainFields = { + subdomain: ZodStringOptionalSchema, + customDomains: z.array(ZodStringSchema).optional(), +} + +const HTTPAuthFields = { + httpUser: ZodStringOptionalSchema, + httpPassword: ZodStringOptionalSchema, +} + +export const TCPConfigSchema = z.object({ + remotePort: ZodPortSchema.optional(), + ...LocalBackendFields, + ...ProxyCommonFields, +}) + +export const UDPConfigSchema = z.object({ + remotePort: ZodPortSchema.optional(), + ...LocalBackendFields, + ...ProxyCommonFields, +}) + +export const HTTPConfigSchema = z.object({ + ...LocalBackendFields, + ...DomainFields, + ...HTTPAuthFields, + locations: z.array(ZodStringSchema).optional(), + hostHeaderRewrite: ZodStringOptionalSchema, + requestHeaders: z.object({ set: KeyValueMapSchema }).optional(), + routeByHTTPUser: ZodStringOptionalSchema, + ...ProxyCommonFields, +}) + +export const HTTPSConfigSchema = z.object({ + ...LocalBackendFields, + ...DomainFields, + ...ProxyCommonFields, +}) + +export const TCPMuxConfigSchema = z.object({ + ...LocalBackendFields, + ...DomainFields, + ...HTTPAuthFields, + routeByHTTPUser: ZodStringOptionalSchema, + // 'httpconnect' is the only value frp accepts, and empty is a hard validation failure. + multiplexer: z.enum(['httpconnect']).default('httpconnect'), + ...ProxyCommonFields, +}) + +/** stcp, xtcp and sudp are identical at the config layer: a secret plus an allow-list. */ +export const SecretConfigSchema = z.object({ + ...LocalBackendFields, + secretKey: ZodStringSchema, + allowUsers: z.array(ZodStringSchema).optional(), + ...ProxyCommonFields, +}) + +// Kept for backwards compatibility with the pre-split module layout. +export const STCPConfigSchema = SecretConfigSchema diff --git a/www/components/frpc/proxy_forms/shared/sections.tsx b/www/components/frpc/proxy_forms/shared/sections.tsx new file mode 100644 index 00000000..76ffd854 --- /dev/null +++ b/www/components/frpc/proxy_forms/shared/sections.tsx @@ -0,0 +1,224 @@ +import React from 'react' +import { Control, useWatch } from 'react-hook-form' +import { useTranslation } from 'react-i18next' + +import { Accordion, AccordionContent, AccordionItem, AccordionTrigger } from '@/components/ui/accordion' +import { + BandwidthField, + KeyValueField, + NumberField, + SelectField, + StringField, + SwitchField, +} from '@/components/base/form-field' +import { AnnotationKeyRegex, BandwidthLimitModes, HealthCheckTypes, ProxyProtocolVersions } from '@/lib/consts' +import { ProxyType } from '@/types/proxy' + +// Types frps can put into a load-balancing group. The others have no server-side +// listener to balance across, so the section would be a field that does nothing. +const LOAD_BALANCED_TYPES: ProxyType[] = ['tcp', 'http', 'https', 'tcpmux'] + +type SectionProps = { control: Control } + +export const TransportSection = ({ control }: SectionProps) => { + const { t } = useTranslation() + return ( + + {t('proxy.form.transport.title')} + + + + + ({ value: v, label: v }))} + allowUnset + unsetLabel={t('proxy.form.transport.bandwidth_limit_mode_default')} + /> + ({ value: v, label: v }))} + allowUnset + unsetLabel={t('proxy.form.transport.proxy_protocol_off')} + description={t('proxy.form.transport.proxy_protocol_description')} + /> + + + ) +} + +export const HealthCheckSection = ({ control }: SectionProps) => { + const { t } = useTranslation() + const checkType = useWatch({ control, name: 'healthCheck.type' }) + + return ( + + {t('proxy.form.health_check.title')} + + ({ value: v, label: v.toUpperCase() }))} + allowUnset + unsetLabel={t('proxy.form.health_check.off')} + /> + {checkType && ( + <> + {checkType === 'http' && ( + + )} + + + + + )} + + + ) +} + +export const LoadBalancerSection = ({ control, managed }: SectionProps & { managed?: boolean }) => { + const { t } = useTranslation() + return ( + + {t('proxy.form.load_balancer.title')} + + {managed &&

{t('proxy.form.load_balancer.managed_hint')}

} + + +
+
+ ) +} + +export const HTTPSection = ({ control }: SectionProps) => { + const { t } = useTranslation() + return ( + + {t('proxy.form.http.title')} + + + + + + + ) +} + +export const MetadataSection = ({ + control, + reservedAnnotations, +}: SectionProps & { reservedAnnotations?: Record }) => { + const { t } = useTranslation() + const annotationKeyValidator = (key: string) => + AnnotationKeyRegex.test(key) ? undefined : t('validation.annotationKey') + + return ( + + {t('proxy.form.metadata.title')} + + + + + + ) +} + +/** + * Every per-type form composes the advanced groups with a single line. Fields common to + * all proxy types live here; the flat, always-visible fields stay in the type form. + */ +export const AdvancedProxySections = ({ + control, + type, + managedLoadBalancer, + reservedAnnotations, +}: SectionProps & { + type: ProxyType + managedLoadBalancer?: boolean + reservedAnnotations?: Record +}) => ( + + + + {LOAD_BALANCED_TYPES.includes(type) && } + {type === 'http' && } + + +) diff --git a/www/components/frpc/proxy_forms/shared/types.ts b/www/components/frpc/proxy_forms/shared/types.ts new file mode 100644 index 00000000..f560bd32 --- /dev/null +++ b/www/components/frpc/proxy_forms/shared/types.ts @@ -0,0 +1,12 @@ +import React from 'react' +import { TypedProxyConfig } from '@/types/proxy' + +export interface ProxyFormProps { + clientID: string + serverID: string + proxyName: string + enablePreview?: boolean + defaultProxyConfig?: TypedProxyConfig + clientProxyConfigs: TypedProxyConfig[] + setClientProxyConfigs: React.Dispatch> +} diff --git a/www/components/frpc/proxy_forms/shared/use_proxy_form.tsx b/www/components/frpc/proxy_forms/shared/use_proxy_form.tsx new file mode 100644 index 00000000..7f1a56da --- /dev/null +++ b/www/components/frpc/proxy_forms/shared/use_proxy_form.tsx @@ -0,0 +1,176 @@ +import React, { useEffect, useMemo, useRef, useState } from 'react' +import { useQuery } from '@tanstack/react-query' +import { useTranslation } from 'react-i18next' +import { toast } from 'sonner' + +import { getServer } from '@/api/server' +import { Button } from '@/components/ui/button' +import { Label } from '@/components/ui/label' +import { YesIcon } from '@/components/ui/icon' +import { TypedProxyConfigValid } from '@/lib/consts' +import { ProxyType, TypedProxyConfig } from '@/types/proxy' +import { ClientPluginType, TypedClientPluginOptions } from '@/types/plugin' +import { VisitPreview } from '@/components/base/visit-preview' +import { SwitchWithLabel } from '@/components/base/form-field' +import PluginConfigForm from '../../client_plugins' +import { buildProxyConfig, sanitizeProxyDefaults } from './build' +import { pluginChoices } from './plugins' +import { ProxyFormProps } from './types' + +/** + * The per-type forms all shared the same ~40 lines of state, debounce and server-query + * boilerplate. Extracting it here is what keeps adding four more proxy types from + * quadrupling that duplication. + */ +export function useProxyForm({ + type, + props, + extraValidate, +}: { + type: ProxyType + props: ProxyFormProps + /** Return an i18n key to block the save, or undefined to accept it. */ + extraValidate?: (values: Record) => string | undefined +}) { + const { proxyName, serverID, defaultProxyConfig, clientProxyConfigs, setClientProxyConfigs } = props + const { t } = useTranslation() + const defaultConfig = (defaultProxyConfig || {}) as Record + + const [enabled, setEnabled] = useState(defaultConfig.enabled !== false) + const [usePlugin, setUsePlugin] = useState(!!defaultConfig.plugin?.type?.length) + const [pluginConfig, setPluginConfig] = useState(defaultConfig.plugin) + const storedPluginType: string | undefined = defaultConfig.plugin?.type + const supportedPlugins = useMemo(() => pluginChoices(type, storedPluginType), [type, storedPluginType]) + const [isSaveDisabled, setSaveDisabled] = useState(false) + const timeoutRef = useRef() + + useEffect(() => () => timeoutRef.current && clearTimeout(timeoutRef.current), []) + + const flashSaved = () => { + setSaveDisabled(true) + if (timeoutRef.current) clearTimeout(timeoutRef.current) + timeoutRef.current = setTimeout(() => setSaveDisabled(false), 3000) + } + + const { data: server } = useQuery({ + queryKey: ['getServer', serverID], + queryFn: () => getServer({ serverId: serverID }), + }) + + const submit = (values: Record) => { + const blocked = extraValidate?.(values) + if (blocked) { + toast.error(t(blocked)) + return + } + const cfgToSubmit = buildProxyConfig(defaultConfig, values, { + type, + name: proxyName, + enabled, + plugin: usePlugin ? pluginConfig : undefined, + }) + if (!TypedProxyConfigValid(cfgToSubmit)) { + toast.error(t('proxy.config.invalid_config')) + return + } + flashSaved() + setClientProxyConfigs( + clientProxyConfigs.map((proxyCfg) => (proxyCfg.name === proxyName ? cfgToSubmit : proxyCfg)), + ) + } + + return { + t, + defaultConfig, + /** Sanitized nested defaults; spread into useForm's defaultValues. */ + commonDefaults: sanitizeProxyDefaults(defaultProxyConfig), + enabled, + setEnabled, + usePlugin, + setUsePlugin, + pluginConfig, + setPluginConfig, + supportedPlugins, + isSaveDisabled, + server: server?.server, + submit, + } +} + +/** Server-side access preview, shown above the fields when the caller asks for it. */ +export const ProxyPreview = ({ + server, + config, + show, +}: { + server?: Parameters[0]['server'] + config: TypedProxyConfig + show?: boolean +}) => { + const { t } = useTranslation() + if (!show || !server) return null + return ( +
+ +
+ +
+
+ ) +} + +/** The enabled / plugin toggles and the save button every proxy form ends with. */ +export const ProxyFormFooter = ({ + enabled, + setEnabled, + usePlugin, + setUsePlugin, + pluginConfig, + setPluginConfig, + supportedPlugins, + isSaveDisabled, + children, +}: { + enabled: boolean + setEnabled: (v: boolean) => void + usePlugin: boolean + setUsePlugin: (v: boolean) => void + pluginConfig?: TypedClientPluginOptions + setPluginConfig: (c: TypedClientPluginOptions) => void + /** Plugins this proxy type can use; see PLUGINS_BY_PROXY_TYPE. */ + supportedPlugins: ClientPluginType[] + isSaveDisabled: boolean + children?: React.ReactNode +}) => { + const { t } = useTranslation() + return ( + <> + + {/* Hidden when no plugin can work on this type (udp, sudp) -- unless one is + already stored, so it can still be switched off. */} + {supportedPlugins.length > 0 || usePlugin ? ( + { + setUsePlugin(value) + if (!value) setPluginConfig(undefined as unknown as TypedClientPluginOptions) + }} + /> + ) : null} + {usePlugin ? ( + + ) : null} + {children} + + + ) +} diff --git a/www/components/frpc/proxy_forms/tcpmux_proxy_form.tsx b/www/components/frpc/proxy_forms/tcpmux_proxy_form.tsx new file mode 100644 index 00000000..ee3da161 --- /dev/null +++ b/www/components/frpc/proxy_forms/tcpmux_proxy_form.tsx @@ -0,0 +1,79 @@ +import React, { useEffect, useState } from 'react' +import * as z from 'zod' +import { useForm } from 'react-hook-form' +import { zodResolver } from '@hookform/resolvers/zod' + +import { Form, FormDescription } from '@/components/ui/form' +import { SelectField, StringField } from '@/components/base/form-field' +import { TCPMultiplexers } from '@/lib/consts' +import { TCPMuxProxyConfig } from '@/types/proxy' +import { TCPMuxConfigSchema } from './shared/schemas' +import { isWorkerManaged, splitAnnotations } from './shared/build' +import { DomainFields, domainsMissing, HTTPAuthFields, LocalBackendFields } from './shared/domain_fields' +import { AdvancedProxySections } from './shared/sections' +import { ProxyFormFooter, useProxyForm } from './shared/use_proxy_form' +import { ProxyFormProps } from './shared/types' + +export const TCPMuxProxyForm: React.FC = (props) => { + const [useAuth, setUseAuth] = useState(false) + const ctx = useProxyForm({ + type: 'tcpmux', + props, + extraValidate: (values) => (domainsMissing(values) ? 'proxy.form.domain_required' : undefined), + }) + const defaultConfig = ctx.defaultConfig as TCPMuxProxyConfig + + const form = useForm>({ + resolver: zodResolver(TCPMuxConfigSchema), + defaultValues: { + localIP: defaultConfig?.localIP, + localPort: defaultConfig?.localPort, + subdomain: defaultConfig?.subdomain, + customDomains: defaultConfig?.customDomains, + httpUser: defaultConfig?.httpUser, + httpPassword: defaultConfig?.httpPassword, + routeByHTTPUser: defaultConfig?.routeByHTTPUser, + // Never leave this empty: frp validates it against the literal 'httpconnect' and + // a rejected config takes the client's whole tunnel set down. + multiplexer: defaultConfig?.multiplexer || 'httpconnect', + ...ctx.commonDefaults, + }, + }) + + useEffect(() => { + if (defaultConfig?.httpPassword || defaultConfig?.httpUser) { + setUseAuth(true) + } + }, [defaultConfig?.httpPassword, defaultConfig?.httpUser]) + + return ( +
+ + + + ({ value: v, label: v }))} + /> + {/* The matching listener is frps tcpmuxHTTPConnectPort, set on the server. */} + {ctx.t('proxy.form.tcpmux_port_hint')} + + + + + + + ) +} diff --git a/www/components/frpc/visitor_form.tsx b/www/components/frpc/visitor_form.tsx new file mode 100644 index 00000000..941f6f61 --- /dev/null +++ b/www/components/frpc/visitor_form.tsx @@ -0,0 +1,404 @@ +import React, { useState } from 'react' +import * as z from 'zod' +import { useForm } from 'react-hook-form' +import { zodResolver } from '@hookform/resolvers/zod' +import { useQuery } from '@tanstack/react-query' +import { useTranslation } from 'react-i18next' +import { toast } from 'sonner' + +import { Accordion, AccordionContent, AccordionItem, AccordionTrigger } from '@/components/ui/accordion' +import { AccordionHeader } from '@radix-ui/react-accordion' +import { Button } from '@/components/ui/button' +import { Form, FormDescription } from '@/components/ui/form' +import { Input } from '@/components/ui/input' +import { Label } from '@radix-ui/react-label' +import { Popover, PopoverContent, PopoverTrigger } from '@/components/ui/popover' +import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from '@/components/ui/select' +import { + HostField, + NumberField, + SecretStringField, + SelectField, + StringField, + SwitchField, +} from '@/components/base/form-field' +import { BaseSelector } from '@/components/base/selector' +import { listProxyConfig } from '@/api/proxy' +import { + TypedVisitorConfigValid, + XTCPVisitorProtocols, + ZodStringOptionalSchema, + ZodStringSchema, + ZodVisitorBindPortSchema, +} from '@/lib/consts' +import { STCPProxyConfig } from '@/types/proxy' +import { TypedVisitorConfig, VisitorType, XTCPVisitorConfig } from '@/types/visitor' + +const VISITOR_TYPES: VisitorType[] = ['stcp', 'xtcp', 'sudp'] + +/** + * Mirrors frp's validateVisitorBaseConfig. Everything here is a real frp key -- the + * decoder rejects the whole config on an unknown one, so no UI-only helper fields may + * reach the emitted object. + */ +export const VisitorBaseSchema = z.object({ + serverName: ZodStringSchema, + secretKey: ZodStringOptionalSchema, + bindAddr: ZodStringSchema.default('127.0.0.1').optional(), + bindPort: ZodVisitorBindPortSchema, + transport: z + .object({ + useEncryption: z.boolean().optional(), + useCompression: z.boolean().optional(), + }) + .optional(), +}) + +export const XTCPVisitorSchema = VisitorBaseSchema.extend({ + protocol: z.enum(['quic', 'kcp']).optional(), + keepTunnelOpen: z.boolean().optional(), + maxRetriesAnHour: z.coerce.number().int().min(0).optional(), + minRetryInterval: z.coerce.number().int().min(0).optional(), + fallbackTo: ZodStringOptionalSchema, + fallbackTimeoutMs: z.coerce.number().int().min(0).optional(), +}) + +export interface VisitorFormProps { + clientID: string + serverID: string + visitorName: string + defaultVisitorConfig?: TypedVisitorConfig + clientVisitorConfigs: TypedVisitorConfig[] + setClientVisitorConfigs: React.Dispatch> +} + +/** + * Picks a secret-type proxy on the same server and fills in what a visitor needs to + * reach it. + * + * serverName is the proxy's raw, unprefixed name and serverUser stays empty: frp builds + * the wire target as BuildTargetServerProxyName(localUser, serverUser, serverName), which + * with an empty serverUser prefixes the visitor's own user -- and the master forces every + * client's `user` to the panel account, so both sides always agree. serverUser only + * matters for cross-user access, which additionally needs allowUsers on the proxy. + */ +const ProxyPairingPicker = ({ + serverID, + visitorType, + onPick, +}: { + serverID: string + visitorType: VisitorType + onPick: (proxyName: string, secretKey?: string) => void +}) => { + const { t } = useTranslation() + + const { data: proxies } = useQuery({ + queryKey: ['listProxyConfigs', 'pairable', serverID], + queryFn: () => listProxyConfig({ page: 1, pageSize: 100, serverId: serverID }), + enabled: !!serverID, + }) + + // A visitor can only reach a proxy of the same type, published to the frps its own + // client is connected to. + const options = (proxies?.proxyConfigs ?? []).filter((p) => p.type === visitorType) + + if (!options.length) { + return ( +
+ {t('visitor.pairing.empty', { type: visitorType })} +
+ ) + } + + return ( +
+ + ({ value: p.name || '', label: p.name || '' }))} + setValue={(name) => { + const picked = options.find((p) => p.name === name) + if (!picked) return + let secretKey: string | undefined + try { + secretKey = (JSON.parse(picked.config || '{}') as STCPProxyConfig).secretKey + } catch { + secretKey = undefined + } + onPick(name, secretKey) + }} + placeholder={t('visitor.pairing.placeholder')} + /> + {t('visitor.pairing.hint_same_server')} +
+ ) +} + +const useVisitorSubmit = ({ + type, + visitorName, + clientVisitorConfigs, + setClientVisitorConfigs, +}: { + type: VisitorType +} & Pick) => { + const { t } = useTranslation() + return (values: Record) => { + const cfg = { ...values, type, name: visitorName } as TypedVisitorConfig + if (!TypedVisitorConfigValid(cfg)) { + toast.error(t('visitor.status.invalid_config')) + return + } + setClientVisitorConfigs(clientVisitorConfigs.map((v) => (v.name === visitorName ? cfg : v))) + toast(t('visitor.status.staged')) + } +} + +/** stcp and sudp visitors are structurally identical: a bare VisitorBaseConfig. */ +export const SecretVisitorForm: React.FC = ({ type, ...props }) => { + const { t } = useTranslation() + const defaultConfig = (props.defaultVisitorConfig || {}) as TypedVisitorConfig + const submit = useVisitorSubmit({ type, ...props }) + + const form = useForm>({ + resolver: zodResolver(VisitorBaseSchema), + defaultValues: { + serverName: defaultConfig.serverName, + secretKey: defaultConfig.secretKey, + bindAddr: defaultConfig.bindAddr, + bindPort: defaultConfig.bindPort, + transport: defaultConfig.transport, + }, + }) + + return ( +
+ + { + form.setValue('serverName', name) + if (secretKey) form.setValue('secretKey', secretKey) + }} + /> + + + + + ) +} + +export const XTCPVisitorForm: React.FC = (props) => { + const { t } = useTranslation() + const defaultConfig = (props.defaultVisitorConfig || {}) as XTCPVisitorConfig + const submit = useVisitorSubmit({ type: 'xtcp', ...props }) + + const form = useForm>({ + resolver: zodResolver(XTCPVisitorSchema), + defaultValues: { + serverName: defaultConfig.serverName, + secretKey: defaultConfig.secretKey, + bindAddr: defaultConfig.bindAddr, + bindPort: defaultConfig.bindPort, + transport: defaultConfig.transport, + // Write these only when the user sets them -- frp's Complete() fills its own + // defaults on the agent, and serializing them here would freeze today's values. + protocol: defaultConfig.protocol, + keepTunnelOpen: defaultConfig.keepTunnelOpen, + maxRetriesAnHour: defaultConfig.maxRetriesAnHour, + minRetryInterval: defaultConfig.minRetryInterval, + fallbackTo: defaultConfig.fallbackTo, + fallbackTimeoutMs: defaultConfig.fallbackTimeoutMs, + }, + }) + + // fallbackTo names a sibling *visitor*, not a proxy: frp hands the connection to it + // via TransferConn when hole punching does not succeed in time. + const fallbackTargets = props.clientVisitorConfigs + .filter((v) => v.name !== props.visitorName) + .map((v) => ({ value: v.name, label: v.name })) + + return ( +
+ + { + form.setValue('serverName', name) + if (secretKey) form.setValue('secretKey', secretKey) + }} + /> + + ({ value: v, label: v.toUpperCase() }))} + allowUnset + unsetLabel={t('visitor.form.protocol_default')} + /> + + + + + + + + + ) +} + +const VisitorCommonFields = ({ control }: { control: any }) => { + const { t } = useTranslation() + return ( + <> + + + + + + + + ) +} + +export const TypedVisitorForm: React.FC = (props) => { + const { t } = useTranslation() + const { defaultVisitorConfig, serverID, clientID } = props + + if (!defaultVisitorConfig || !serverID || !clientID) return <> + + switch (defaultVisitorConfig.type) { + case 'stcp': + return + case 'sudp': + return + case 'xtcp': + return + default: + return ( +
+ {t('visitor.form.unsupported_type', { type: (defaultVisitorConfig as { type: string }).type })} +
+ ) + } +} + +/** + * Add-popover plus the list of visitors on this client. Visitors ride inside the client + * config blob, so this stages into the parent array and the single FRPCForm submit pushes + * everything -- there is no separate visitor endpoint and none is needed. + */ +export const VisitorSection: React.FC<{ + clientID: string + serverID: string + clientVisitorConfigs: TypedVisitorConfig[] + setClientVisitorConfigs: React.Dispatch> +}> = ({ clientID, serverID, clientVisitorConfigs, setClientVisitorConfigs }) => { + const { t } = useTranslation() + const [visitorName, setVisitorName] = useState('') + const [visitorType, setVisitorType] = useState('stcp') + + const handleAdd = () => { + if (!visitorName) return + // frp >= v0.70 rejects duplicate visitor names outright, so catch it here rather + // than letting the agent fail the whole config push. + if (clientVisitorConfigs.some((v) => v.name === visitorName)) { + toast(t('visitor.status.create'), { description: t('visitor.status.name_exists') }) + return + } + setClientVisitorConfigs([...clientVisitorConfigs, { name: visitorName, type: visitorType } as TypedVisitorConfig]) + setVisitorName('') + } + + const handleDelete = (name: string) => + setClientVisitorConfigs(clientVisitorConfigs.filter((v) => v.name !== name)) + + return ( +
+ + + + + + + setVisitorName(e.target.value)} /> + + + + + + + + + +

{t('visitor.form.config')}

+

{t('visitor.form.expand', { count: clientVisitorConfigs.length })}

+
+
+ + {clientVisitorConfigs.map((item, index) => ( + + + +
+ +
+ {t('visitor.form.name')}: {item.name} +
+
{t('visitor.form.type_label', { type: item.type })}
+
+
+ + + +
+
+ ))} +
+
+
+
+ ) +} diff --git a/www/components/frps/form/http_plugins.tsx b/www/components/frps/form/http_plugins.tsx new file mode 100644 index 00000000..6d429bce --- /dev/null +++ b/www/components/frps/form/http_plugins.tsx @@ -0,0 +1,107 @@ +import React from 'react' +import { Control, useFieldArray } from 'react-hook-form' +import { useTranslation } from 'react-i18next' + +import { Badge } from '@/components/ui/badge' +import { Button } from '@/components/ui/button' +import { Checkbox } from '@/components/ui/checkbox' +import { FormControl, FormField, FormItem, FormLabel, FormMessage } from '@/components/ui/form' +import { StringField, SwitchField } from '@/components/base/form-field' +import { HTTPPluginOptions } from '@/types/common' +import { HTTPPluginOps } from './schema' + +/** + * frps `httpPlugins[]` (FS-11). Edits the user's entries only; the panel's own auth + * entry is shown read-only and re-attached by the backend on every save. + */ +export const HTTPPluginsFields = ({ + control, + panelPlugin, +}: { + control: Control + panelPlugin?: HTTPPluginOptions +}) => { + const { t } = useTranslation() + const { fields, append, remove } = useFieldArray({ control, name: 'httpPlugins' }) + + return ( +
+

{t('server.form.http_plugins.description')}

+ {panelPlugin && ( +
+ + {panelPlugin.name}: {panelPlugin.addr} + {panelPlugin.path} ({panelPlugin.ops?.join(', ')}) + + {t('server.form.http_plugins.panel_entry')} +
+ )} + {fields.map((field, index) => ( +
+ + + + { + const selected: string[] = opsField.value || [] + return ( + + {t('server.form.http_plugins.ops')} + +
+ {HTTPPluginOps.map((op) => ( + + ))} +
+
+ {selected.includes('Login') && ( +

{t('server.form.http_plugins.login_warning')}

+ )} + +
+ ) + }} + /> + + +
+ ))} + +
+ ) +} diff --git a/www/components/frps/form/schema.ts b/www/components/frps/form/schema.ts new file mode 100644 index 00000000..3399c2b6 --- /dev/null +++ b/www/components/frps/form/schema.ts @@ -0,0 +1,161 @@ +import * as z from 'zod' +import { + ZodHostSchema, + ZodOptionalIntSchema, + ZodOptionalSignedIntSchema, + ZodPortOptionalSchema, + ZodPortSchema, + ZodPortsRangeListSchema, + ZodStringOptionalSchema, + ZodStringSchema, +} from '@/lib/consts' + +const TLSFields = z.object({ + // frp embeds TLSConfig without a JSON tag, so these are siblings of `force`, not + // nested under another `tls` key. + force: z.boolean().optional(), + certFile: ZodStringOptionalSchema, + keyFile: ZodStringOptionalSchema, + trustedCaFile: ZodStringOptionalSchema, + serverName: ZodStringOptionalSchema, +}) + +const QUICFields = z.object({ + keepalivePeriod: ZodOptionalIntSchema, + maxIdleTimeout: ZodOptionalIntSchema, + // Completes to 100000, so this cannot use the seconds atom's 86400 ceiling. + maxIncomingStreams: ZodOptionalIntSchema, +}) + +const WebServerFields = z.object({ + addr: ZodHostSchema.optional(), + port: ZodPortOptionalSchema, + user: ZodStringOptionalSchema, + password: ZodStringOptionalSchema, + assetsDir: ZodStringOptionalSchema, + pprofEnable: z.boolean().optional(), + tls: z + .object({ + certFile: ZodStringOptionalSchema, + keyFile: ZodStringOptionalSchema, + trustedCaFile: ZodStringOptionalSchema, + serverName: ZodStringOptionalSchema, + }) + .optional(), +}) + +const LogFields = z.object({ + to: ZodStringOptionalSchema, + level: z.enum(['trace', 'debug', 'info', 'warn', 'error']).optional(), + maxDays: ZodOptionalIntSchema, + disablePrintColor: z.boolean().optional(), +}) + +// frp's SupportedHTTPPluginOps (pkg/config/v1/validation/validation.go). frp rejects +// any other value, and the master validates before storing (BUG-11). +export const HTTPPluginOps = ['Login', 'NewProxy', 'CloseProxy', 'Ping', 'NewWorkConn', 'NewUserConn'] as const + +// The panel's own frps auth plugin (defs.FRP_Plugin_Multiuser). The backend drops any +// entry by this name and appends its own (conf.WithFRPsAuthPlugin), so the form never +// edits it and must not let a user entry take the name. +export const PANEL_AUTH_PLUGIN_NAME = 'multiuser' + +const HTTPPluginEntrySchema = z + .object({ + name: ZodStringSchema.refine((v) => v !== PANEL_AUTH_PLUGIN_NAME, { + message: 'server.form.http_plugins.reserved_name', + }), + addr: ZodStringSchema, + path: ZodStringOptionalSchema, + ops: z.array(z.enum(HTTPPluginOps)).min(1, { message: 'server.form.http_plugins.ops_required' }), + tlsVerify: z.boolean().optional(), + }) + // Keep keys a newer frp may add, the same reason the form merges over loadedConfig. + .passthrough() + +const HTTPPluginListSchema = z + .array(HTTPPluginEntrySchema) + .optional() + .superRefine((plugins, ctx) => { + const seen = new Set() + plugins?.forEach((p, i) => { + if (seen.has(p.name)) { + ctx.addIssue({ + code: z.ZodIssueCode.custom, + path: [i, 'name'], + message: 'server.form.http_plugins.duplicate_name', + }) + } + seen.add(p.name) + }) + }) + +export const ServerConfigSchema = z.object({ + // publicHost is a panel-level value, not an frp field: it is stripped before submit + // and sent as the server_ip request param. + publicHost: ZodStringSchema.optional(), + + bindAddr: ZodHostSchema.default('0.0.0.0').optional(), + bindPort: ZodPortSchema.default(7000), + proxyBindAddr: ZodHostSchema.optional(), + quicBindPort: ZodPortOptionalSchema, + kcpBindPort: ZodPortOptionalSchema, + + // Virtual hosts + vhostHTTPPort: ZodPortOptionalSchema, + vhostHTTPSPort: ZodPortOptionalSchema, + vhostHTTPTimeout: ZodOptionalIntSchema, + subDomainHost: ZodStringOptionalSchema, + tcpmuxHTTPConnectPort: ZodPortOptionalSchema, + tcpmuxPassthrough: z.boolean().optional(), + custom404Page: ZodStringOptionalSchema, + + // Security and limits + allowPorts: ZodPortsRangeListSchema, + maxPortsPerClient: ZodOptionalIntSchema, + userConnTimeout: ZodOptionalIntSchema, + udpPacketSize: ZodOptionalIntSchema, + natholeAnalysisDataReserveHours: ZodOptionalIntSchema, + detailedErrorsToClient: z.boolean().optional(), + + transport: z + .object({ + tcpMux: z.boolean().optional(), + tcpMuxKeepaliveInterval: ZodOptionalSignedIntSchema, + // Completes to -1 ("disabled"), as does heartbeatTimeout when tcpMux is on. + tcpKeepalive: ZodOptionalSignedIntSchema, + maxPoolCount: ZodOptionalIntSchema, + heartbeatTimeout: ZodOptionalSignedIntSchema, + quic: QUICFields.optional(), + tls: TLSFields.optional(), + }) + .optional(), + + sshTunnelGateway: z + .object({ + bindPort: ZodPortOptionalSchema, + privateKeyFile: ZodStringOptionalSchema, + autoGenPrivateKeyPath: ZodStringOptionalSchema, + authorizedKeysFile: ZodStringOptionalSchema, + }) + .optional(), + + webServer: WebServerFields.optional(), + enablePrometheus: z.boolean().optional(), + log: LogFields.optional(), + + // User entries only; see splitHTTPPlugins. + httpPlugins: HTTPPluginListSchema, +}) + +/** + * Splits a stored httpPlugins list into the user's entries (editable) and the panel's + * own auth entry (display only). The stored list normally ends with the panel entry, + * because the master appends it before saving. + */ +export const splitHTTPPlugins = (plugins?: T[]) => ({ + user: (plugins || []).filter((p) => p.name !== PANEL_AUTH_PLUGIN_NAME), + panel: (plugins || []).find((p) => p.name === PANEL_AUTH_PLUGIN_NAME), +}) + +export const ServerConfigZodSchema = ServerConfigSchema diff --git a/www/components/frps/form/sections.tsx b/www/components/frps/form/sections.tsx new file mode 100644 index 00000000..38c46fac --- /dev/null +++ b/www/components/frps/form/sections.tsx @@ -0,0 +1,203 @@ +import React from 'react' +import { Control, useWatch } from 'react-hook-form' +import { useTranslation } from 'react-i18next' + +import { Accordion, AccordionContent, AccordionItem, AccordionTrigger } from '@/components/ui/accordion' +import { + HostField, + NumberField, + PortField, + PortRangeField, + SecretStringField, + SelectField, + StringField, + SwitchField, +} from '@/components/base/form-field' +import { LogLevels } from '@/lib/consts' +import { HTTPPluginOptions } from '@/types/common' +import { HTTPPluginsFields } from './http_plugins' + +type SectionProps = { control: Control } + +const Section = ({ value, label, children }: { value: string; label: string; children: React.ReactNode }) => ( + + {label} + {children} + +) + +export const FRPSAdvancedSections = ({ + control, + panelHTTPPlugin, +}: SectionProps & { panelHTTPPlugin?: HTTPPluginOptions }) => { + const { t } = useTranslation() + // frps only serves /metrics from its own web server, so enabling Prometheus without a + // webServer.port produces nothing. (The panel's own stats do not go through it.) + const webServerPort = useWatch({ control, name: 'webServer.port' }) + + return ( + +
+ + + + +
+ +
+ + + + + + +
+ +
+ + + + + + + + + + + + + +
+ +
+

{t('server.form.ssh.paths_hint')}

+ + + + +
+ +
+

{t('server.form.web_server.port_conflict_hint')}

+ + + + + + + +
+ +
+ + ({ value: v, label: v }))} + allowUnset + /> + + +
+ +
+ +
+
+ ) +} diff --git a/www/components/frps/frps_form.tsx b/www/components/frps/frps_form.tsx index cc1427a4..96ee7f03 100644 --- a/www/components/frps/frps_form.tsx +++ b/www/components/frps/frps_form.tsx @@ -1,31 +1,24 @@ import { ServerConfig } from '@/types/server' -import { useEffect } from 'react' +import { useEffect, useState } from 'react' import { zodResolver } from '@hookform/resolvers/zod' import { useForm } from 'react-hook-form' import * as z from 'zod' import { Button } from '@/components/ui/button' import { Form } from '@/components/ui/form' -import { ZodIPSchema, ZodPortSchema, ZodStringSchema } from '@/lib/consts' +import { ServerConfigZodSchema as ServerConfigSchema, splitHTTPPlugins } from './form/schema' +import { FRPSAdvancedSections } from './form/sections' import { RespCode, Server } from '@/lib/pb/common' import { updateFRPS } from '@/api/frp' import { useMutation } from '@tanstack/react-query' import { Label } from '@radix-ui/react-label' import { useTranslation } from 'react-i18next' import { toast } from 'sonner' +import { ObjToUint8Array } from '@/lib/utils' import { HostField, PortField } from '../base/form-field' -const ServerConfigSchema = z.object({ - bindAddr: ZodIPSchema.default('0.0.0.0').optional(), - bindPort: ZodPortSchema.default(7000), - proxyBindAddr: ZodIPSchema.optional(), - vhostHTTPPort: ZodPortSchema.optional(), - subDomainHost: ZodStringSchema.optional(), - publicHost: ZodStringSchema.optional(), - quicBindPort: ZodPortSchema.optional(), - kcpBindPort: ZodPortSchema.optional(), -}) - -export const ServerConfigZodSchema = ServerConfigSchema +// The schema and its accordion sections live in ./form/, a fork-owned directory that +// upstream has no counterpart for, so they can never produce a rebase conflict. +export { ServerConfigZodSchema } from './form/schema' export interface FRPSFormProps { serverID: string @@ -35,33 +28,45 @@ export interface FRPSFormProps { const FRPSForm: React.FC = ({ serverID, server, frpsUrls }) => { const { t } = useTranslation() - const form = useForm>({ - resolver: zodResolver(ServerConfigZodSchema), + const form = useForm>({ + resolver: zodResolver(ServerConfigSchema), }) const updateFrps = useMutation({ mutationFn: updateFRPS }) + // The full stored config, kept alongside the form. zodResolver hands handleSubmit the + // *parsed* object and zod strips unknown keys, so `values` contains only this form's + // fields -- submitting it alone deleted every key the raw editor can author + // (allowPorts, custom404Page, sshTunnelGateway, webServer, user httpPlugins entries). + // Defaults survived only because the master re-materializes them via Complete(). + const [loadedConfig, setLoadedConfig] = useState({} as ServerConfig) + useEffect(() => { form.reset({}) }, []) useEffect(() => { - form.reset(JSON.parse(server?.config || '{}') as ServerConfig) + const parsed = JSON.parse(server?.config || '{}') as ServerConfig + setLoadedConfig(parsed) + // The form edits user plugin entries only; the panel's auth entry is display-only + // and the backend re-attaches it, so submitting without it is correct. + // The cast narrows `ops: string[]` to frp's op enum; the resolver re-checks it on submit. + form.reset({ ...parsed, httpPlugins: splitHTTPPlugins(parsed.httpPlugins).user } as z.infer< + typeof ServerConfigSchema + >) }, [server]) - const onSubmit = async (values: z.infer) => { + const onSubmit = async (values: z.infer) => { try { const { publicHost, ...rest } = values let resp = await updateFrps.mutateAsync({ serverIp: publicHost, serverId: serverID, frpsUrls: frpsUrls, - // @ts-ignore - config: Buffer.from( - JSON.stringify({ - ...rest, - } as ServerConfig), - ), + config: ObjToUint8Array({ + ...loadedConfig, + ...rest, + } as ServerConfig), }) toast(resp.status?.code === RespCode.SUCCESS ? t('server.operation.update_success') : t('server.operation.update_failed'), { description: resp.status?.message, @@ -89,9 +94,16 @@ const FRPSForm: React.FC = ({ serverID, server, frpsUrls }) => { + {/* Without this, `https` is a proxy type the panel offers but frps has no + listener for -- the proxy is dead on arrival. */} + + diff --git a/www/components/proxy/mutate_proxy_config.tsx b/www/components/proxy/mutate_proxy_config.tsx index f626f918..013baf69 100644 --- a/www/components/proxy/mutate_proxy_config.tsx +++ b/www/components/proxy/mutate_proxy_config.tsx @@ -17,6 +17,7 @@ import { useTranslation } from 'react-i18next' import { ServerSelector } from '../base/server-selector' import { ClientSelector } from '../base/client-selector' import { TypedProxyForm } from '../frpc/proxy_form' +import { coerceProxyConfigToType } from '../frpc/proxy_forms/shared/build' import { ProxyType, TypedProxyConfig } from '@/types/proxy' import { BaseSelector } from '../base/selector' import { createProxyConfig } from '@/api/proxy' @@ -73,7 +74,7 @@ export const ProxyConfigMutateForm = ({ const [proxyName, setProxyName] = useState('') const [proxyType, setProxyType] = useState('http') const [selectedServer, setSelectedServer] = useState() - const supportedProxyTypes: ProxyType[] = ['http', 'tcp', 'udp'] + const supportedProxyTypes: ProxyType[] = ['http', 'https', 'tcp', 'udp', 'tcpmux', 'stcp', 'xtcp', 'sudp'] // advanced mode toggle const [advancedMode, setAdvancedMode] = useState(false) const [rawConfig, setRawConfig] = useState('{}') @@ -104,7 +105,10 @@ export const ProxyConfigMutateForm = ({ useEffect(() => { if (proxyName && proxyType) { - setProxyConfigs([{ ...defaultProxyConfig, name: proxyName, type: proxyType }]) + // Coerce rather than spread: keys that belong to the previous type would ride + // along (e.g. remotePort onto an http proxy) and the backend decodes with + // DisallowUnknownFields, so that is a hard rejection rather than a stray field. + setProxyConfigs([{ ...coerceProxyConfigToType(defaultProxyConfig, proxyType), name: proxyName }]) } }, [proxyName, proxyType]) @@ -125,6 +129,15 @@ export const ProxyConfigMutateForm = ({ } }, [defaultProxyConfig, defaultOriginalProxyConfig]) + // One predicate for both the disabled state and the click guard -- they used to be + // written twice and had drifted, so advanced mode looked submittable and then refused. + // The backend accepts exactly one proxy per create call + // (biz/master/proxy/create_proxy_config.go), hence the length check rather than + // running TypedProxyConfigValid over a raw array: that validator requires + // localPort + localIP, which is wrong for a plugin-only or secret-type proxy and + // would defeat the point of advanced mode. + const canSubmit = advancedMode ? proxyConfigs.length === 1 : TypedProxyConfigValid(proxyConfigs[0]) + return ( <> @@ -200,10 +213,10 @@ export const ProxyConfigMutateForm = ({ )}