diff --git a/.github/Dockerfile.release b/.github/Dockerfile.release index ee79c791..cca272b7 100644 --- a/.github/Dockerfile.release +++ b/.github/Dockerfile.release @@ -16,7 +16,7 @@ COPY release-bin/${TARGETOS}/${TARGETARCH}/resin-${TARGETOS}-${TARGETARCH} /usr/ COPY docker/entrypoint.sh /usr/local/bin/docker-entrypoint.sh RUN chmod +x /usr/local/bin/docker-entrypoint.sh -EXPOSE 2260 +EXPOSE 2260 21000-22000 VOLUME ["/var/cache/resin", "/var/lib/resin", "/var/log/resin"] ENTRYPOINT ["/usr/local/bin/docker-entrypoint.sh"] diff --git a/.github/workflows/dev-build.yml b/.github/workflows/dev-build.yml new file mode 100644 index 00000000..55e3feda --- /dev/null +++ b/.github/workflows/dev-build.yml @@ -0,0 +1,193 @@ +name: Dev Build + +on: + workflow_dispatch: + inputs: + version_suffix: + description: 'Version suffix (leave empty for auto: dev-YYYYMMDD-sha8)' + required: false + type: string + +concurrency: + group: dev-build-${{ github.ref }} + cancel-in-progress: true + +jobs: + build-binaries: + name: Build Binaries + runs-on: ubuntu-latest + permissions: + contents: read + strategy: + matrix: + include: + - goos: linux + goarch: amd64 + - goos: linux + goarch: arm64 + - goos: darwin + goarch: amd64 + - goos: darwin + goarch: arm64 + - goos: windows + goarch: amd64 + steps: + - name: Checkout Code + uses: actions/checkout@v4 + + - name: Compute Version + id: version + run: | + SUFFIX="${{ inputs.version_suffix }}" + if [ -z "$SUFFIX" ]; then + # Auto-generate: dev-YYYYMMDD-sha8 + VERSION="dev-$(date -u +'%Y%m%d')-${GITHUB_SHA::8}" + elif [[ "$SUFFIX" == dev-* ]]; then + # Already has dev- prefix + VERSION="$SUFFIX" + else + VERSION="dev-$SUFFIX" + fi + echo "version=$VERSION" >> $GITHUB_OUTPUT + echo "Version: $VERSION" + + - name: Setup Node.js + uses: actions/setup-node@v4 + with: + node-version: 22 + + - name: Build WebUI + working-directory: ./webui + run: | + npm ci + npm run build + + - name: Setup Go + uses: actions/setup-go@v5 + with: + go-version: '1.25.x' + + - name: Build Go Binary + env: + GOOS: ${{ matrix.goos }} + GOARCH: ${{ matrix.goarch }} + CGO_ENABLED: 0 + run: | + VERSION=${{ steps.version.outputs.version }} + GIT_COMMIT=${GITHUB_SHA::8} + BUILD_TIME=$(date -u +'%Y-%m-%dT%H:%M:%SZ') + + OUTPUT_NAME=resin-${GOOS}-${GOARCH} + if [ "$GOOS" = "windows" ]; then + OUTPUT_NAME="${OUTPUT_NAME}.exe" + fi + echo "OUTPUT_NAME=${OUTPUT_NAME}" >> $GITHUB_ENV + + mkdir -p build + + go build -trimpath -tags "with_quic with_wireguard with_grpc with_utls with_embedded_tor with_naive_outbound" \ + -ldflags="-s -w \ + -X github.com/Resinat/Resin/internal/buildinfo.Version=${VERSION} \ + -X github.com/Resinat/Resin/internal/buildinfo.GitCommit=${GIT_COMMIT} \ + -X github.com/Resinat/Resin/internal/buildinfo.BuildTime=${BUILD_TIME}" \ + -o build/${OUTPUT_NAME} ./cmd/resin + + cd build + + SIMPLE_NAME="resin" + if [ "$GOOS" = "windows" ]; then + SIMPLE_NAME="resin.exe" + fi + cp ${OUTPUT_NAME} ${SIMPLE_NAME} + + if [ "$GOOS" = "windows" ]; then + zip resin-${GOOS}-${GOARCH}.zip ${SIMPLE_NAME} + PACKAGE_NAME="resin-${GOOS}-${GOARCH}.zip" + else + tar -czvf resin-${GOOS}-${GOARCH}.tar.gz ${SIMPLE_NAME} + PACKAGE_NAME="resin-${GOOS}-${GOARCH}.tar.gz" + fi + + rm ${SIMPLE_NAME} + echo "PACKAGE_NAME=${PACKAGE_NAME}" >> $GITHUB_ENV + + - name: Upload Release Package Artifact + uses: actions/upload-artifact@v4 + with: + name: dev-release-${{ matrix.goos }}-${{ matrix.goarch }} + path: build/${{ env.PACKAGE_NAME }} + retention-days: 7 + + - name: Upload Linux bin for Docker + if: matrix.goos == 'linux' + uses: actions/upload-artifact@v4 + with: + name: dev-binary-${{ matrix.goos }}-${{ matrix.goarch }} + path: build/${{ env.OUTPUT_NAME }} + retention-days: 1 + + docker: + name: Build & Push Docker Image + runs-on: ubuntu-latest + needs: build-binaries + permissions: + contents: read + packages: write + steps: + - name: Checkout Code + uses: actions/checkout@v4 + + - name: Compute Version + id: version + run: | + SUFFIX="${{ inputs.version_suffix }}" + if [ -z "$SUFFIX" ]; then + VERSION="dev-$(date -u +'%Y%m%d')-${GITHUB_SHA::8}" + elif [[ "$SUFFIX" == dev-* ]]; then + VERSION="$SUFFIX" + else + VERSION="dev-$SUFFIX" + fi + echo "version=$VERSION" >> $GITHUB_OUTPUT + + - name: Download Linux amd64 binary + uses: actions/download-artifact@v4 + with: + name: dev-binary-linux-amd64 + path: release-bin/linux/amd64/ + + - name: Download Linux arm64 binary + uses: actions/download-artifact@v4 + with: + name: dev-binary-linux-arm64 + path: release-bin/linux/arm64/ + + - name: Give binaries execute permission + run: chmod +x release-bin/linux/amd64/resin-linux-amd64 release-bin/linux/arm64/resin-linux-arm64 + + - name: Set up QEMU + uses: docker/setup-qemu-action@v3 + + - name: Set up Docker Buildx + uses: docker/setup-buildx-action@v3 + + - name: Lowercase repository name + run: echo "REPO_LC=${GITHUB_REPOSITORY,,}" >> $GITHUB_ENV + + - name: Login to GitHub Container Registry + uses: docker/login-action@v3 + with: + registry: ghcr.io + username: ${{ github.actor }} + password: ${{ secrets.GITHUB_TOKEN }} + + - name: Build and push Docker image + uses: docker/build-push-action@v5 + with: + context: . + file: ./.github/Dockerfile.release + push: true + platforms: linux/amd64,linux/arm64 + tags: | + ghcr.io/${{ env.REPO_LC }}:${{ steps.version.outputs.version }} + ghcr.io/${{ env.REPO_LC }}:dev-latest diff --git a/.gitignore b/.gitignore index 6e65d729..9a5b9152 100644 --- a/.gitignore +++ b/.gitignore @@ -2,10 +2,16 @@ .env sing-box-reference .agents +.ace-tool/ +.claude/ +.cursor/ +.trellis/ +AGENTS.md +data/ +start.sh .devcontainer start-instance.sh # Claude Code / OMC tooling and local runtime data -.claude/ .omc/ .local/ diff --git a/DESIGN.md b/DESIGN.md index 8768c3b2..ad4d927b 100644 --- a/DESIGN.md +++ b/DESIGN.md @@ -29,6 +29,7 @@ * Name:平台名,全局唯一。 * StickyTTL: time.Duration,该平台的粘性租约寿命。 * RegexFilters:一个正则表达式列表。按照节点的 Tag 的正则表达式过滤器。同时满足所有过滤器才符合条件。 +* RegexExcludeFilters:一个正则表达式列表。命中任意排除规则的节点不进入该平台。 * RegexFiltersCompiled:编译后的正则表达式列表。用于运行时匹配。随着 RegexFilters 更新。 * RegionFilters:一个地区列表。小写 ISO codes (e.g., "hk", "us")。节点的出口 IP 地区属于该列表才符合条件。空表示不做地区筛选。 * 反向代理 Account 为空时的行为:随机路由 / 固定 Header 提取 / 按 Account Header Rule 提取。 @@ -281,12 +282,13 @@ No available proxy nodes * 职责:维护当前 Platform *此刻* 可用的节点列表。 * 特质:支持 O(1) 的随机选取与 O(1) 的增删查。 * 过滤条件: - 1. 节点状态正常(非 Circuit Break)。 - 2. 调用 `NodeEntry.MatchRegexs(Platform.RegexFilters)` 判断 Tag 是否匹配。 - 3. 节点必须有出口 IP(无论 Platform 是否配置 `RegionFilters`)。 - 4. 若 `RegionFilters` 非空,则节点出口 IP 地区必须符合 `RegionFilters`。 - 5. 有至少一条延迟信息。 - 6. Outbound 不为空 + 1. 节点未被管理员手动禁用(`NodeEntry.ManuallyDisabled == false`)。命中即整体短路,后续条件不再评估。 + 2. 节点状态正常(非 Circuit Break)。 + 3. 调用 `NodeEntry.MatchRegexs(Platform.RegexFilters)` 判断 Tag 是否匹配。 + 4. 节点必须有出口 IP(无论 Platform 是否配置 `RegionFilters`)。 + 5. 若 `RegionFilters` 非空,则节点出口 IP 地区必须符合 `RegionFilters`。 + 6. 有至少一条延迟信息。 + 7. Outbound 不为空 * 过滤源:遍历全局节点池中的所有 `NodeEntry`。 ##### Platform 节点视图动态更新 @@ -300,6 +302,7 @@ Platform 应该向外提供一个脏更新的接口,用来通知脏节点。 * 出口 IP 变更:当 `ProbeManager` 探测到节点出口 IP 发生变化(或从无到有)。属于脏更新。 * 节点引用变更:当节点的 SubscriptionIDs 发生变化,可能会影响 MatchRegexs 的结果(因为 Tag 集合变了)。属于脏更新。 * 熔断触发 / 恢复:属于脏更新。 + * 管理员手动禁用 / 启用:属于脏更新。`Pool.SetNodeManualDisable` 翻位后立即触发 `notifyAllPlatformsDirty`。禁用时随后调用 `Router.DeleteLeasesByNode` 解绑该节点所有 sticky lease。 * Platform 过滤器配置变更:全量重建。 ### 订阅 @@ -588,7 +591,7 @@ Resin 项目中所有的数据库都设计为单写,不会有多进程写入 ### SQLite 数据模型 #### state.db * system_config(config_json, version, updated_at_ns) -* platforms(id PK, name UNIQUE, sticky_ttl_ns, regex_filters_json, region_filters_json, reverse_proxy_miss_action, reverse_proxy_empty_account_behavior, reverse_proxy_fixed_account_header, allocation_policy, passive_circuit_breaker_disabled, updated_at_ns) +* platforms(id PK, name UNIQUE, sticky_ttl_ns, regex_filters_json, regex_exclude_filters_json, region_filters_json, reverse_proxy_miss_action, reverse_proxy_empty_account_behavior, reverse_proxy_fixed_account_header, allocation_policy, passive_circuit_breaker_disabled, updated_at_ns) * subscriptions(id PK, name, url, update_interval_ns, enabled, ephemeral, created_at_ns, updated_at_ns) * account_header_rules(url_prefix PK, headers_json, updated_at_ns) @@ -596,7 +599,7 @@ Resin 项目中所有的数据库都设计为单写,不会有多进程写入 #### cache.db * nodes_static(hash PK, raw_options_json, created_at_ns) -* nodes_dynamic(hash PK, failure_count, circuit_open_since, egress_ip, egress_updated_at_ns, last_latency_probe_attempt_ns, last_authority_latency_probe_attempt_ns, last_egress_update_attempt_ns) +* nodes_dynamic(hash PK, failure_count, circuit_open_since, egress_ip, egress_updated_at_ns, last_latency_probe_attempt_ns, last_authority_latency_probe_attempt_ns, last_egress_update_attempt_ns, manually_disabled) * node_latency(node_hash, domain, ewma_ns, last_updated_ns, PK(node_hash,domain))。 * leases(platform_id, account, node_hash, egress_ip, expiry_ns, last_accessed_ns, PK(platform_id,account))。 * subscription_nodes(subscription_id, node_hash, tags_json, PK(subscription_id,node_hash)) @@ -1151,6 +1154,7 @@ Body(partial patch 示例): "name": "Default", "sticky_ttl": "30m", "regex_filters": ["^sub1/.*", ".*hk.*"], + "regex_exclude_filters": [".*low-rate.*"], "region_filters": ["hk","us"], "routable_node_count": 123, "reverse_proxy_miss_action": "TREAT_AS_EMPTY|REJECT", @@ -1180,6 +1184,7 @@ Body: "name": "Platform-A", "sticky_ttl": "168h", "regex_filters": ["^sub1/.*"], + "regex_exclude_filters": [".*low-rate.*"], "region_filters": ["hk", "us"], "reverse_proxy_miss_action": "TREAT_AS_EMPTY", "reverse_proxy_empty_account_behavior": "ACCOUNT_HEADER_RULE", @@ -1192,7 +1197,7 @@ Body: 字段要求: * 必填字段:`name` -* 可选字段:`sticky_ttl`、`regex_filters`、`region_filters`、`reverse_proxy_miss_action`、`reverse_proxy_empty_account_behavior`、`reverse_proxy_fixed_account_header`、`allocation_policy`、`passive_circuit_breaker_disabled` +* 可选字段:`sticky_ttl`、`regex_filters`、`regex_exclude_filters`、`region_filters`、`reverse_proxy_miss_action`、`reverse_proxy_empty_account_behavior`、`reverse_proxy_fixed_account_header`、`allocation_policy`、`passive_circuit_breaker_disabled` * 不可传字段:`id`、`updated_at`、`routable_node_count` * 省略可选字段时,平台策略字段使用当前环境变量默认平台设置(`RESIN_DEFAULT_PLATFORM_*`)对应值;`passive_circuit_breaker_disabled` 默认 `false` @@ -1201,6 +1206,7 @@ Body: * `name`:trim 后需非空、全局唯一;不能为保留名 `Default` 或 `api`(大小写不敏感);且不能包含 `.:|/\@?#%~`、空格、tab、换行、回车。 * `sticky_ttl`:合法 Go duration。 * `regex_filters`:每项可被 regexp 编译。 +* `regex_exclude_filters`:每项可被 regexp 编译;节点命中任意一项即排除。 * `region_filters`:每项为 ISO 3166-1 alpha-2 小写代码。 * 枚举字段:`reverse_proxy_miss_action` 仅 `TREAT_AS_EMPTY|REJECT`;`reverse_proxy_empty_account_behavior` 仅 `RANDOM|FIXED_HEADER|ACCOUNT_HEADER_RULE`;`allocation_policy` 仅 `BALANCED|PREFER_LOW_LATENCY|PREFER_IDLE_IP`。 * `passive_circuit_breaker_disabled`:布尔值。设为 `true` 后,此 Platform 的用户代理请求失败不会增加节点熔断计数;主动探测不受影响。成功请求仍会清除节点连续失败计数并可恢复熔断节点。 @@ -1232,7 +1238,7 @@ Body(partial patch 示例): 字段要求: * 必填字段:无 -* 可改字段:`name`、`sticky_ttl`、`regex_filters`、`region_filters`、`reverse_proxy_miss_action`、`reverse_proxy_empty_account_behavior`、`reverse_proxy_fixed_account_header`、`allocation_policy`、`passive_circuit_breaker_disabled` +* 可改字段:`name`、`sticky_ttl`、`regex_filters`、`regex_exclude_filters`、`region_filters`、`reverse_proxy_miss_action`、`reverse_proxy_empty_account_behavior`、`reverse_proxy_fixed_account_header`、`allocation_policy`、`passive_circuit_breaker_disabled` * 不可改字段:`id`、`updated_at`、`routable_node_count` 关键校验:与“创建平台”一致。 @@ -1291,6 +1297,7 @@ Body(partial patch 示例): { "platform_spec": { "regex_filters": ["^subA/.*"], + "regex_exclude_filters": [".*low-rate.*"], "region_filters": ["hk", "us"] } } @@ -1299,12 +1306,13 @@ Body(partial patch 示例): 字段要求: * 必填字段:`platform_id` 与 `platform_spec` 二选一,且只能出现一个。 -* `platform_spec` 仅允许字段:`regex_filters`、`region_filters`。 +* `platform_spec` 仅允许字段:`regex_filters`、`regex_exclude_filters`、`region_filters`。 关键校验(最小集): * `platform_id`:必须存在。 * `platform_spec.regex_filters`:每项可被 regexp 编译。 +* `platform_spec.regex_exclude_filters`:每项可被 regexp 编译。 * `platform_spec.region_filters`:每项为 ISO 3166-1 alpha-2 小写代码。 错误码映射(最小集): @@ -1637,6 +1645,8 @@ Query: * `region`:hk/us/...(可选) * `circuit_open`:true|false(可选) * `has_outbound`:true|false(可选) +* `enabled`:true|false(可选),按"订阅维度"启用状态过滤 +* `manually_disabled`:true|false(可选),按"管理员手动禁用"标记过滤 * `egress_ip`:IP 地址(可选) * `probed_since`:RFC3339Nano(可选),按节点 `LastLatencyProbeAttempt` 过滤 * `sort_by`:排序字段(可选) @@ -1657,6 +1667,8 @@ Response: "node_hash": "9f2c0b1a6d3e4f5c8a9b0c1d2e3f4a5b", "created_at": "2026-02-10T12:00:00Z", + "enabled": true, + "manually_disabled": false, "has_outbound": true, "last_error": "...", "circuit_open_since": null, @@ -1731,6 +1743,60 @@ Response: 返回 LatencyTestURL 的站点在 TD-EWMA 后的延迟 `latency_ewma_ms`。 +#### 手动禁用节点(Action) + +**POST** `/nodes/{node_hash}/actions/disable` + +请求体:无。 + +效果: + +* 在节点上置位"管理员手动禁用"标记,节点立即从所有 Platform 的可路由视图中移除;后续 `RouteRequest` 不会再分配到该节点(即使存在 sticky lease 的旧绑定也会因下一次评估失败被替换)。 +* 同步遍历所有 Platform,将该节点上的全部 sticky leases 解绑(发出 `LeaseRemove` 事件以驱动 `leases` 表删除),并把解绑数量回传给调用方。 +* 标记位通过 `nodes_dynamic.manually_disabled` 列持久化,重启后恢复。 + +校验规则: + +* `node_hash` 必须为 32 位十六进制字符串(大小写均可)。 + +错误码映射(最小集): + +* `400 INVALID_ARGUMENT`:`node_hash` 格式非法。 +* `404 NOT_FOUND`:节点不存在。 + +返回: + +```json +{ "released_lease_count": 3 } +``` + +#### 启用节点(Action) + +**POST** `/nodes/{node_hash}/actions/enable` + +请求体:无。 + +效果: + +* 清除"管理员手动禁用"标记,节点会在下一次 Platform 视图评估中重新被纳入候选集合。不影响任何已有 lease,也不会主动创建新 lease。 + +校验规则: + +* `node_hash` 必须为 32 位十六进制字符串(大小写均可)。 + +错误码映射(最小集): + +* `400 INVALID_ARGUMENT`:`node_hash` 格式非法。 +* `404 NOT_FOUND`:节点不存在。 + +返回: + +```json +{ "released_lease_count": 0 } +``` + +`released_lease_count` 始终为 0,仅保持与 `disable` 同形以方便前端复用。 + ### Leases #### Lease 模型 diff --git a/Dockerfile b/Dockerfile index 27b72b5b..df94cefd 100644 --- a/Dockerfile +++ b/Dockerfile @@ -42,7 +42,7 @@ COPY --from=go-builder /out/resin /usr/local/bin/resin COPY docker/entrypoint.sh /usr/local/bin/docker-entrypoint.sh RUN chmod +x /usr/local/bin/docker-entrypoint.sh -EXPOSE 2260 +EXPOSE 2260 21000-22000 VOLUME ["/var/cache/resin", "/var/lib/resin", "/var/log/resin"] ENTRYPOINT ["/usr/local/bin/docker-entrypoint.sh"] diff --git a/README.md b/README.md index 535966ff..921bc2dc 100644 --- a/README.md +++ b/README.md @@ -158,6 +158,16 @@ us hk ``` +Regex filters are matched against `/`; all include regexes must match, and any exclude regex removes the node. For example, include a subscription but exclude low-rate nodes: + +``` +regex filters: +^ProviderA/.* + +regex exclude filters: +.*low-rate.* +``` + For forward proxy (HTTP / SOCKS5), include Platform in the auth info. Examples: ```bash @@ -178,6 +188,39 @@ For reverse proxy, include Platform in the URL prefix: curl http://127.0.0.1:2260/my-token/MyPlatform/https/api.ipify.org ``` +## 🔓 Free-Mode Ports (Password-less) + +If you want to use the proxy **without a password** and have “**one local port = one fixed egress IP**”, enable free-mode ports. + +Beyond the main port, Resin listens on a range of consecutive ports (starting at `21000` by default). The whole range is bound to a single platform (`MM` by default, **auto-created** and labeled as free-mode-only if missing); **each port** pins its own egress node/IP via a sticky lease — the same port keeps a stable egress, while different ports map to different egresses. Free-mode ports serve **both HTTP forward proxy and SOCKS5**, and expose **no** admin UI / reverse proxy. + +### Enabling (environment variables) + +| Variable | Description | Default | +| --- | --- | --- | +| `RESIN_FREE_PORT_START` | Start port; `0`/unset disables the feature | `0` | +| `RESIN_FREE_PORT_COUNT` | Number of ports to open (max 256) | `0` | +| `RESIN_FREE_PORT_PLATFORM` | Platform bound to the whole range (auto-created if missing) | empty | +| `RESIN_FREE_PORT_ACCESS_MODE` | Access control: `intranet` / `whitelist` | `intranet` | +| `RESIN_FREE_PORT_WHITELIST` | IP/CIDR list (JSON array) for whitelist mode | `[]` | + +> ⚠️ Security: free-mode ports rely entirely on access control. `intranet` (default) allows only private/loopback/link-local clients; `whitelist` allows only listed IP/CIDRs. **Never expose free-mode ports to the public internet with an empty or overly broad whitelist.** + +### Examples + +```bash +# HTTP forward proxy on port 21000, no auth required +curl -x http://127.0.0.1:21000 https://api.ipify.org + +# SOCKS5 on the same port +curl --proxy socks5h://127.0.0.1:21000 https://api.ipify.org + +# Different port = different fixed egress +curl -x http://127.0.0.1:21001 https://api.ipify.org +``` + +> For Docker, `docker-compose.yml.example` maps `21000-22000` by default. Note: mapping the whole span spawns ~1000 docker-proxy processes; in production, narrow the mapping to your actual count or use `network_mode: host`. + ## 📖 Advanced Usage: Sticky Session Proxy When your business depends on IP continuity or long-lived interactions, use Resin's core feature: **sticky proxying**. diff --git a/README.zh-CN.md b/README.zh-CN.md index b0d76321..d3e7b6b6 100644 --- a/README.zh-CN.md +++ b/README.zh-CN.md @@ -150,6 +150,16 @@ us hk ``` +正则规则会匹配 `<订阅名>/<节点名>`;包含正则需要全部命中,排除正则命中任意一条就会移除该节点。例如只使用某个订阅,但排除低倍率节点: + +``` +正则过滤规则: +^ProviderA/.* + +正则排除规则: +.*低倍率.* +``` + 对于正向代理(HTTP / SOCKS5),你可以在认证信息中填入希望使用的 Platform。下面分别给出一个 curl 例子: ```bash @@ -170,6 +180,39 @@ curl --proxy socks5h://127.0.0.1:2260 \ curl http://127.0.0.1:2260/my-token/MyPlatform/https/api.ipify.org ``` +## 🔓 免密端口段(Free-Mode Ports) + +如果你希望在**不填密码**的前提下使用代理,并且让“**一个本地端口 = 一个固定出口 IP**”,可以开启免密端口段。 + +它会在主端口之外额外监听一段连续端口(默认从 `21000` 起)。整段端口固定绑定到一个平台(默认 `MM`,不存在时**自动创建**,标注为“免密专用”);段内**每个端口**通过粘性租约稳定绑定该平台下的一个出口节点/IP——同一端口出口保持稳定,不同端口对应不同出口。免密端口同时支持 **HTTP 正向代理与 SOCKS5**,且**不暴露**管理后台 / 反向代理。 + +### 开启方式(环境变量) + +| 变量 | 说明 | 默认 | +| --- | --- | --- | +| `RESIN_FREE_PORT_START` | 起始端口;`0` 或不设表示关闭该功能 | `0` | +| `RESIN_FREE_PORT_COUNT` | 监听端口数量(上限 256) | `0` | +| `RESIN_FREE_PORT_PLATFORM` | 整段绑定的平台名(不存在则自动创建) | 空 | +| `RESIN_FREE_PORT_ACCESS_MODE` | 访问控制:`intranet`(内网)/ `whitelist`(白名单) | `intranet` | +| `RESIN_FREE_PORT_WHITELIST` | 白名单模式下的 IP/CIDR 列表(JSON 数组) | `[]` | + +> ⚠️ 安全:免密端口的安全完全由访问控制兜底。默认 `intranet` 仅允许内网/回环/链路本地地址访问;`whitelist` 仅允许指定 IP/CIDR。**切勿在公网暴露白名单为空或范围过宽的免密端口。** + +### 使用示例 + +```bash +# HTTP 正向代理:端口 21000,无需任何认证 +curl -x http://127.0.0.1:21000 https://api.ipify.org + +# SOCKS5:同一端口同时支持 +curl --proxy socks5h://127.0.0.1:21000 https://api.ipify.org + +# 不同端口 = 不同固定出口 +curl -x http://127.0.0.1:21001 https://api.ipify.org +``` + +> Docker 部署时,`docker-compose.yml.example` 已默认映射 `21000-22000`。注意:映射整段会拉起约 1000 个 docker-proxy 进程,生产建议把映射收窄到实际数量,或使用 `network_mode: host`。 + ## 📖 进阶使用:粘性代理 当业务遇到**对 IP 变化敏感**的服务,或者需要持续交互时,你需要使用 Resin 的核心特性:**粘性代理**。 diff --git a/cmd/resin/app_runtime.go b/cmd/resin/app_runtime.go index 6cc8539f..b5f497a7 100644 --- a/cmd/resin/app_runtime.go +++ b/cmd/resin/app_runtime.go @@ -1,6 +1,7 @@ package main import ( + "bufio" "context" "errors" "fmt" @@ -11,6 +12,7 @@ import ( "os/signal" "path/filepath" "strconv" + "strings" "sync/atomic" "syscall" "time" @@ -27,7 +29,6 @@ import ( "github.com/Resinat/Resin/internal/routing" "github.com/Resinat/Resin/internal/service" "github.com/Resinat/Resin/internal/state" - "github.com/joho/godotenv" ) type resinApp struct { @@ -47,6 +48,9 @@ type resinApp struct { } inboundLn net.Listener transportPool *proxy.OutboundTransportPool + + freePortServers []*inboundDemuxServer + freePortListeners []net.Listener } func run() error { @@ -91,10 +95,45 @@ func run() error { } func loadDotenvFile(path string) error { - if err := godotenv.Load(path); err != nil { - if os.IsNotExist(err) { - return nil + file, err := os.Open(path) + if os.IsNotExist(err) { + return nil + } + if err != nil { + return fmt.Errorf("load %s: %w", path, err) + } + defer file.Close() + + scanner := bufio.NewScanner(file) + for lineNo := 1; scanner.Scan(); lineNo++ { + line := strings.TrimSpace(scanner.Text()) + if line == "" || strings.HasPrefix(line, "#") { + continue + } + line = strings.TrimSpace(strings.TrimPrefix(line, "export ")) + key, value, ok := strings.Cut(line, "=") + if !ok { + return fmt.Errorf("load %s:%d: missing '='", path, lineNo) + } + key = strings.TrimSpace(key) + if key == "" { + return fmt.Errorf("load %s:%d: empty key", path, lineNo) } + if _, exists := os.LookupEnv(key); exists { + continue + } + value = strings.TrimSpace(value) + if len(value) >= 2 { + first, last := value[0], value[len(value)-1] + if (first == '"' && last == '"') || (first == '\'' && last == '\'') { + value = value[1 : len(value)-1] + } + } + if err := os.Setenv(key, value); err != nil { + return fmt.Errorf("load %s:%d: %w", path, lineNo, err) + } + } + if err := scanner.Err(); err != nil { return fmt.Errorf("load %s: %w", path, err) } return nil @@ -237,10 +276,15 @@ func (a *resinApp) wireRetryDownloader(retryDL *netutil.RetryDownloader) { } return res.NodeHash, nil } + proxyFetchMetadata := func(ctx context.Context, hash node.Hash, url string) (netutil.DownloadResponse, error) { + resp, _, err := a.topoRuntime.outboundMgr.FetchWithUserAgentMetadata(ctx, hash, url, currentDownloadUserAgent()) + return resp, err + } retryDL.ProxyFetch = func(ctx context.Context, hash node.Hash, url string) ([]byte, error) { - body, _, err := a.topoRuntime.outboundMgr.FetchWithUserAgent(ctx, hash, url, currentDownloadUserAgent()) - return body, err + resp, err := proxyFetchMetadata(ctx, hash, url) + return resp.Body, err } + retryDL.ProxyFetchMetadata = proxyFetchMetadata log.Println("RetryDownloader wiring complete") } @@ -473,6 +517,86 @@ func (a *resinApp) buildNetworkServers(engine *state.StateEngine) error { a.inboundLn = proxy.NewCountingListener(inboundLn, a.metricsManager) a.inboundSrv = newInboundDemuxServer(&http.Server{Handler: inboundHandler}, socks5Inbound) + if a.envCfg.FreePortStart > 0 { + if err := a.buildFreePortServers(proxyEvents, outboundTransportCfg); err != nil { + return err + } + } + + return nil +} + +// buildFreePortServers opens the password-less port range. One shared pair of +// handlers serves the whole range; each connection's account is derived from +// its local port, so every port pins its own sticky lease (its own egress) +// within the bound platform. Free-mode forces a V1 SOCKS5 handshake with an +// empty token (so NO_AUTH is accepted) regardless of the global auth mode, and +// the HTTP side mounts only the forward proxy — no control plane / reverse +// proxy is exposed on these ports. +func (a *resinApp) buildFreePortServers( + proxyEvents proxy.ConfigAwareEventEmitter, + outboundTransportCfg proxy.OutboundTransportConfig, +) error { + platformName := a.envCfg.FreePortPlatform + if _, ok := a.topoRuntime.pool.GetPlatformByName(platformName); !ok { + // Should not happen: ensureFreePortPlatform creates it during bootstrap. + return fmt.Errorf("free-port platform %q not found", platformName) + } + + accessController, err := netutil.NewAccessController( + a.envCfg.FreePortAccessMode, + a.envCfg.FreePortWhitelist, + ) + if err != nil { + return fmt.Errorf("free-port access controller: %w", err) + } + + freeForward := proxy.NewForwardProxy(proxy.ForwardProxyConfig{ + ProxyToken: "", + AuthVersion: string(config.AuthVersionV1), + Router: a.topoRuntime.router, + Pool: a.topoRuntime.pool, + Health: a.topoRuntime.pool, + Events: proxyEvents, + MetricsSink: a.metricsManager, + OutboundTransport: outboundTransportCfg, + TransportPool: a.transportPool, + ProxyBypassRules: a.envCfg.ProxyBypassRules, + ForcedPlatform: platformName, + AccountFromLocalPort: true, + }) + freeSocks5 := proxy.NewSocks5Inbound(proxy.Socks5InboundConfig{ + ProxyToken: "", + AuthVersion: string(config.AuthVersionV1), + Router: a.topoRuntime.router, + Pool: a.topoRuntime.pool, + Health: a.topoRuntime.pool, + Events: proxyEvents, + MetricsSink: a.metricsManager, + ProxyBypassRules: a.envCfg.ProxyBypassRules, + ForcedPlatform: platformName, + AccountFromLocalPort: true, + }) + + start := a.envCfg.FreePortStart + end := start + a.envCfg.FreePortCount - 1 + opened := 0 + for port := start; port <= end; port++ { + ln, lnErr := net.Listen("tcp", formatListenAddress(a.envCfg.ListenAddress, port)) + if lnErr != nil { + log.Printf("Free-port %d listen failed, skipping: %v", port, lnErr) + continue + } + srv := newInboundDemuxServer(&http.Server{Handler: freeForward}, freeSocks5) + srv.connGate = func(c net.Conn) bool { return accessController.Allow(c.RemoteAddr()) } + a.freePortServers = append(a.freePortServers, srv) + a.freePortListeners = append(a.freePortListeners, proxy.NewCountingListener(ln, a.metricsManager)) + opened++ + } + log.Printf( + "Free-mode ports: %d/%d opened on [%d-%d] -> platform %q (access=%s)", + opened, a.envCfg.FreePortCount, start, end, platformName, a.envCfg.FreePortAccessMode, + ) return nil } @@ -524,6 +648,14 @@ func (a *resinApp) startServers() <-chan error { reportServerErr("resin server", a.inboundSrv.Serve(a.inboundLn)) }() + for i := range a.freePortServers { + srv := a.freePortServers[i] + ln := a.freePortListeners[i] + go func() { + reportServerErr("free-port server", srv.Serve(ln)) + }() + } + return serverErrCh } @@ -554,6 +686,14 @@ func (a *resinApp) shutdown(ctx context.Context) { if err := a.inboundSrv.Shutdown(ctx); err != nil { log.Printf("Server shutdown error: %v", err) } + for _, srv := range a.freePortServers { + if err := srv.Shutdown(ctx); err != nil { + log.Printf("Free-port server shutdown error: %v", err) + } + } + if len(a.freePortServers) > 0 { + log.Printf("Free-mode ports stopped (%d)", len(a.freePortServers)) + } log.Println("Resin server stopped") if a.transportPool != nil { a.transportPool.CloseAll() diff --git a/cmd/resin/inbound_demux.go b/cmd/resin/inbound_demux.go index 5aaaf8c7..efa0024a 100644 --- a/cmd/resin/inbound_demux.go +++ b/cmd/resin/inbound_demux.go @@ -28,6 +28,7 @@ type inboundDemuxServer struct { httpServer *http.Server httpListener *connChannelListener socksHandler inboundConnHandler + connGate func(net.Conn) bool mu sync.Mutex outer net.Listener @@ -89,7 +90,6 @@ func (s *inboundDemuxServer) Serve(ln net.Listener) error { } go s.handleAcceptedConn(conn) } - return nil } func inboundDemuxAcceptRetryDelay(err error, prev time.Duration) (time.Duration, bool) { @@ -160,6 +160,12 @@ func (s *inboundDemuxServer) Shutdown(ctx context.Context) error { func (s *inboundDemuxServer) handleAcceptedConn(conn net.Conn) { defer s.workerWG.Done() + // Optional access gate (free-mode ports inject an IP allow-list / intranet + // check here; the main port leaves it nil so behavior is unchanged). + if s.connGate != nil && !s.connGate(conn) { + _ = conn.Close() + return + } s.trackActiveConn(conn) s.trackSniffConn(conn) diff --git a/cmd/resin/main.go b/cmd/resin/main.go index f2f01ca7..7c22e4cf 100644 --- a/cmd/resin/main.go +++ b/cmd/resin/main.go @@ -29,6 +29,7 @@ import ( "github.com/Resinat/Resin/internal/state" "github.com/Resinat/Resin/internal/subscription" "github.com/Resinat/Resin/internal/topology" + "github.com/google/uuid" ) type topologyRuntime struct { @@ -339,6 +340,11 @@ func newTopologyRuntime( SubManager: subManager, Pool: pool, Downloader: downloader, + OnSubUpdated: func(sub *subscription.Subscription) { + if err := engine.UpsertSubscription(runtimeSubscriptionToModel(sub)); err != nil { + log.Printf("persist subscription %s: %v", sub.ID, err) + } + }, OnSubReenabledNode: func(hash node.Hash) { outboundMgr.EnsureNodeOutbound(hash) probeMgr.TriggerImmediateEgressProbe(hash) @@ -395,6 +401,13 @@ func bootstrapTopology( sub.SetContent(ms.Content) sub.SetIncrementalAliveNodes(ms.IncrementalAliveNodes) sub.SetEphemeralNodeEvictDelayNs(ms.EphemeralNodeEvictDelayNs) + sub.SetUsage(subscription.UsageInfo{ + UploadBytes: ms.UsageUploadBytes, + DownloadBytes: ms.UsageDownloadBytes, + TotalBytes: ms.UsageTotalBytes, + ExpireUnix: ms.UsageExpireUnix, + UpdatedAtNs: ms.UsageUpdatedAtNs, + }) sub.CreatedAtNs = ms.CreatedAtNs sub.UpdatedAtNs = ms.UpdatedAtNs subManager.Register(sub) @@ -413,6 +426,9 @@ func bootstrapTopology( if err := ensureDefaultPlatform(engine, envCfg, dbPlats); err != nil { return fmt.Errorf("ensure default platform: %w", err) } + if err := ensureFreePortPlatform(engine, envCfg, dbPlats); err != nil { + return fmt.Errorf("ensure free-port platform: %w", err) + } dbPlats, err = engine.ListPlatforms() if err != nil { return fmt.Errorf("reload platforms: %w", err) @@ -428,6 +444,29 @@ func bootstrapTopology( return nil } +func runtimeSubscriptionToModel(sub *subscription.Subscription) model.Subscription { + usage := sub.Usage() + return model.Subscription{ + ID: sub.ID, + Name: sub.Name(), + SourceType: sub.SourceType(), + URL: sub.URL(), + Content: sub.Content(), + UpdateIntervalNs: sub.UpdateIntervalNs(), + Enabled: sub.Enabled(), + Ephemeral: sub.Ephemeral(), + IncrementalAliveNodes: sub.IncrementalAliveNodes(), + EphemeralNodeEvictDelayNs: sub.EphemeralNodeEvictDelayNs(), + UsageUploadBytes: usage.UploadBytes, + UsageDownloadBytes: usage.DownloadBytes, + UsageTotalBytes: usage.TotalBytes, + UsageExpireUnix: usage.ExpireUnix, + UsageUpdatedAtNs: usage.UpdatedAtNs, + CreatedAtNs: sub.CreatedAtNs, + UpdatedAtNs: sub.UpdatedAtNs, + } +} + func validatePersistedPlatformNamesForV1(platformsInDB []model.Platform) error { var invalidPlatformNames []string for _, p := range platformsInDB { @@ -481,6 +520,42 @@ func ensureDefaultPlatform( return nil } +// ensureFreePortPlatform auto-creates the platform bound to the free-mode ports +// when it does not already exist, mirroring ensureDefaultPlatform. The feature +// is disabled when RESIN_FREE_PORT_START is 0/unset. +func ensureFreePortPlatform( + engine *state.StateEngine, + envCfg *config.EnvConfig, + platformsInDB []model.Platform, +) error { + if envCfg == nil || envCfg.FreePortStart == 0 || envCfg.FreePortPlatform == "" { + return nil + } + for _, p := range platformsInDB { + if p.Name == envCfg.FreePortPlatform { + return nil // already exists; reuse it + } + } + + freePlatform := model.Platform{ + ID: uuid.NewString(), + Name: envCfg.FreePortPlatform, + StickyTTLNs: int64(envCfg.DefaultPlatformStickyTTL), + RegexFilters: append([]string(nil), envCfg.DefaultPlatformRegexFilters...), + RegionFilters: append([]string(nil), envCfg.DefaultPlatformRegionFilters...), + ReverseProxyMissAction: envCfg.DefaultPlatformReverseProxyMissAction, + ReverseProxyEmptyAccountBehavior: envCfg.DefaultPlatformReverseProxyEmptyAccountBehavior, + ReverseProxyFixedAccountHeader: envCfg.DefaultPlatformReverseProxyFixedAccountHeader, + AllocationPolicy: envCfg.DefaultPlatformAllocationPolicy, + UpdatedAtNs: time.Now().UnixNano(), + } + if err := engine.UpsertPlatform(freePlatform); err != nil { + return err + } + log.Printf("Created free-port platform %q (免密专用)", envCfg.FreePortPlatform) + return nil +} + var defaultFallbackAccountHeaders = []string{"Authorization", "x-api-key"} func ensureDefaultAccountHeaderRule(engine *state.StateEngine) error { @@ -543,6 +618,7 @@ func newFlushReaders( LastLatencyProbeAttemptNs: entry.LastLatencyProbeAttempt.Load(), LastAuthorityLatencyProbeAttemptNs: entry.LastAuthorityLatencyProbeAttempt.Load(), LastEgressUpdateAttemptNs: entry.LastEgressUpdateAttempt.Load(), + ManuallyDisabled: entry.IsManuallyDisabled(), } }, ReadNodeLatency: func(key model.NodeLatencyKey) *model.NodeLatency { @@ -873,6 +949,7 @@ func restoreBootstrapNodeDynamics( entry.LastLatencyProbeAttempt.Store(nd.LastLatencyProbeAttemptNs) entry.LastAuthorityLatencyProbeAttempt.Store(nd.LastAuthorityLatencyProbeAttemptNs) entry.LastEgressUpdateAttempt.Store(nd.LastEgressUpdateAttemptNs) + entry.ManuallyDisabled.Store(nd.ManuallyDisabled) if nd.EgressIP != "" { if ip, err := netip.ParseAddr(nd.EgressIP); err == nil { entry.SetEgressIP(ip) diff --git a/docker-compose.yml.example b/docker-compose.yml.example index 9736194f..a8deb2ea 100644 --- a/docker-compose.yml.example +++ b/docker-compose.yml.example @@ -12,9 +12,40 @@ services: RESIN_PROXY_TOKEN: ${RESIN_PROXY_TOKEN} RESIN_LISTEN_ADDRESS: 0.0.0.0 RESIN_PORT: 2260 + # --- Free-mode (password-less) ports --- + # Opens RESIN_FREE_PORT_COUNT consecutive ports from RESIN_FREE_PORT_START, + # all bound to platform RESIN_FREE_PORT_PLATFORM (auto-created if missing). + # Each port pins its own sticky egress IP. Default access mode is intranet. + RESIN_FREE_PORT_START: 21000 + RESIN_FREE_PORT_COUNT: 50 + RESIN_FREE_PORT_PLATFORM: MM + RESIN_FREE_PORT_ACCESS_MODE: intranet + # RESIN_FREE_PORT_WHITELIST: '["10.0.0.0/8","192.168.0.0/16"]' # whitelist mode only # TZ: + # --- Resource / startup tuning (large node pools) --- + # Each probe worker opens a real outbound and does an HTTPS request. The + # default (64) is safe; raise only on big hosts with many nodes. + RESIN_PROBE_CONCURRENCY: 64 + # Let the Go runtime honor the container's CPU/memory limits below. Without + # these, Go sees ALL host cores (GOMAXPROCS) and may over-parallelize + # startup work (outbound warmup, subscription refresh) and over-use memory. + GOMAXPROCS: "2" + GOMEMLIMIT: "900MiB" # ~85% of the memory limit below + # Caps how much CPU/RAM the container (and thus the Go runtime) may use. + # Honored by `docker compose up` (Compose v2). + deploy: + resources: + limits: + cpus: "2" + memory: 1g ports: - "2260:2260" + # Free-mode range. Map ONLY the ports you actually open + # (RESIN_FREE_PORT_START .. +RESIN_FREE_PORT_COUNT-1). Mapping a wider span + # spawns one docker-proxy process PER port (slow start, more memory). + # For large ranges or production, prefer `network_mode: host` (drop this + # block). The example matches RESIN_FREE_PORT_START=21000 / COUNT=50. + - "21000-21049:21000-21049" volumes: - resin_cache:/var/cache/resin - resin_state:/var/lib/resin diff --git a/go.mod b/go.mod index 45e10119..09f133e5 100644 --- a/go.mod +++ b/go.mod @@ -6,7 +6,6 @@ require ( github.com/ccojocar/zxcvbn-go v1.0.4 github.com/golang-migrate/migrate/v4 v4.19.1 github.com/google/uuid v1.6.0 - github.com/joho/godotenv v1.5.1 github.com/oschwald/maxminddb-golang v1.13.1 github.com/puzpuzpuz/xsync/v4 v4.4.0 github.com/robfig/cron/v3 v3.0.1 diff --git a/go.sum b/go.sum index e8aaeb2d..9bcd856c 100644 --- a/go.sum +++ b/go.sum @@ -99,8 +99,6 @@ github.com/illarion/gonotify/v2 v2.0.3 h1:B6+SKPo/0Sw8cRJh1aLzNEeNVFfzE3c6N+o+vy github.com/illarion/gonotify/v2 v2.0.3/go.mod h1:38oIJTgFqupkEydkkClkbL6i5lXV/bxdH9do5TALPEE= github.com/insomniacslk/dhcp v0.0.0-20250417080101-5f8cf70e8c5f h1:dd33oobuIv9PcBVqvbEiCXEbNTomOHyj3WFuC5YiPRU= github.com/insomniacslk/dhcp v0.0.0-20250417080101-5f8cf70e8c5f/go.mod h1:zhFlBeJssZ1YBCMZ5Lzu1pX4vhftDvU10WUVb1uXKtM= -github.com/joho/godotenv v1.5.1 h1:7eLL/+HRGLY0ldzfGMeQkb7vMd0as4CfYvUVzLqw0N0= -github.com/joho/godotenv v1.5.1/go.mod h1:f4LDr5Voq0i2e/R5DDNOoa2zzDfwtkZa6DnEwAbqwq4= github.com/jsimonetti/rtnetlink v1.4.0 h1:Z1BF0fRgcETPEa0Kt0MRk3yV5+kF1FWTni6KUFKrq2I= github.com/jsimonetti/rtnetlink v1.4.0/go.mod h1:5W1jDvWdnthFJ7fxYX1GMK07BUpI4oskfOqvPteYS6E= github.com/klauspost/compress v1.17.11 h1:In6xLpyWOi1+C7tXUUWv2ot1QvBjxevKAaI6IXrJmUc= diff --git a/internal/api/contract_test.go b/internal/api/contract_test.go index 70d012bc..19046783 100644 --- a/internal/api/contract_test.go +++ b/internal/api/contract_test.go @@ -478,6 +478,14 @@ func TestAPIContract_GetLease_IncludesNodeTag(t *testing.T) { raw := json.RawMessage(`{"type":"ss","server":"198.51.100.50","port":443}`) cp.Pool.AddNodeFromSub(hash, raw, older.ID) cp.Pool.AddNodeFromSub(hash, raw, newer.ID) + entry, ok := cp.Pool.GetEntry(hash) + if !ok { + t.Fatalf("node %s missing", hash.Hex()) + } + entry.LatencyTable.LoadEntry("cloudflare.com", node.DomainLatencyStats{ + Ewma: 58 * time.Millisecond, + LastUpdated: time.Now(), + }) now := time.Now().UnixNano() cp.Router.RestoreLeases([]model.Lease{ @@ -507,6 +515,106 @@ func TestAPIContract_GetLease_IncludesNodeTag(t *testing.T) { if body["node_tag"] != "Z-Provider/aa" { t.Fatalf("node_tag: got %v, want %q", body["node_tag"], "Z-Provider/aa") } + if body["reference_latency_ms"] != float64(58) { + t.Fatalf("reference_latency_ms: got %v, want 58", body["reference_latency_ms"]) + } +} + +func TestAPIContract_ListLeases_SortByReferenceLatency(t *testing.T) { + srv, cp, _ := newControlPlaneTestServer(t) + + platformID := mustCreatePlatform(t, srv, "lease-latency-sort") + sub := subscription.NewSubscription("lease-latency-sub", "Latency", "https://example.com/latency", true, false) + cp.SubMgr.Register(sub) + + addNode := func(raw []byte, tag, egressIP string, latencyMs int) node.Hash { + t.Helper() + hash := node.HashFromRawOptions(raw) + sub.ManagedNodes().StoreNode(hash, subscription.ManagedNode{Tags: []string{tag}}) + cp.Pool.AddNodeFromSub(hash, raw, sub.ID) + entry, ok := cp.Pool.GetEntry(hash) + if !ok { + t.Fatalf("node %s missing", hash.Hex()) + } + entry.SetEgressIP(netip.MustParseAddr(egressIP)) + if latencyMs >= 0 { + entry.LatencyTable.LoadEntry("cloudflare.com", node.DomainLatencyStats{ + Ewma: time.Duration(latencyMs) * time.Millisecond, + LastUpdated: time.Now(), + }) + } + return hash + } + + fast := addNode([]byte(`{"type":"ss","server":"198.51.100.61","port":443}`), "fast", "203.0.113.61", 20) + slow := addNode([]byte(`{"type":"ss","server":"198.51.100.62","port":443}`), "slow", "203.0.113.62", 80) + unknown := addNode([]byte(`{"type":"ss","server":"198.51.100.63","port":443}`), "unknown", "203.0.113.63", -1) + + now := time.Now().UnixNano() + cp.Router.RestoreLeases([]model.Lease{ + { + PlatformID: platformID, + Account: "slow", + NodeHash: slow.Hex(), + EgressIP: "203.0.113.62", + CreatedAtNs: now, + ExpiryNs: now + int64(time.Hour), + LastAccessedNs: now, + }, + { + PlatformID: platformID, + Account: "unknown", + NodeHash: unknown.Hex(), + EgressIP: "203.0.113.63", + CreatedAtNs: now, + ExpiryNs: now + int64(time.Hour), + LastAccessedNs: now, + }, + { + PlatformID: platformID, + Account: "fast", + NodeHash: fast.Hex(), + EgressIP: "203.0.113.61", + CreatedAtNs: now, + ExpiryNs: now + int64(time.Hour), + LastAccessedNs: now, + }, + }) + + assertOrder := func(sortOrder string, want []string) { + t.Helper() + rec := doJSONRequest( + t, + srv, + http.MethodGet, + "/api/v1/platforms/"+platformID+"/leases?sort_by=reference_latency_ms&sort_order="+sortOrder, + nil, + true, + ) + if rec.Code != http.StatusOK { + t.Fatalf("list leases status: got %d, want %d, body=%s", rec.Code, http.StatusOK, rec.Body.String()) + } + body := decodeJSONMap(t, rec) + items, ok := body["items"].([]any) + if !ok { + t.Fatalf("items type: got %T", body["items"]) + } + if len(items) != len(want) { + t.Fatalf("items len: got %d, want %d", len(items), len(want)) + } + for i, item := range items { + row, ok := item.(map[string]any) + if !ok { + t.Fatalf("item type: got %T", item) + } + if row["account"] != want[i] { + t.Fatalf("account[%d]: got %v, want %q (body=%s)", i, row["account"], want[i], rec.Body.String()) + } + } + } + + assertOrder("asc", []string{"fast", "slow", "unknown"}) + assertOrder("desc", []string{"slow", "fast", "unknown"}) } func TestAPIContract_ListLeases_AccountFuzzySearch(t *testing.T) { @@ -652,24 +760,44 @@ func TestAPIContract_PlatformListIncludesRoutableNodeCount(t *testing.T) { sub := subscription.NewSubscription("sub-test", "sub-test", "https://example.com/sub", true, false) cp.SubMgr.Register(sub) - raw := []byte(`{"type":"ss","server":"1.1.1.1","port":443}`) - hash := node.HashFromRawOptions(raw) - cp.Pool.AddNodeFromSub(hash, raw, "sub-test") - sub.ManagedNodes().StoreNode(hash, subscription.ManagedNode{Tags: []string{"seed"}}) + addRoutableNode := func(raw []byte, tag string) node.Hash { + t.Helper() + hash := node.HashFromRawOptions(raw) + cp.Pool.AddNodeFromSub(hash, raw, "sub-test") + sub.ManagedNodes().StoreNode(hash, subscription.ManagedNode{Tags: []string{tag}}) - entry, ok := cp.Pool.GetEntry(hash) - if !ok { - t.Fatalf("node %s missing after AddNodeFromSub", hash.Hex()) + entry, ok := cp.Pool.GetEntry(hash) + if !ok { + t.Fatalf("node %s missing after AddNodeFromSub", hash.Hex()) + } + entry.SetEgressIP(netip.MustParseAddr("203.0.113.10")) + if entry.LatencyTable == nil { + t.Fatalf("node %s latency table not initialized", hash.Hex()) + } + entry.LatencyTable.Update("example.com", 25*time.Millisecond, 10*time.Minute) + ob := testutil.NewNoopOutbound() + entry.Outbound.Store(&ob) + cp.Pool.RecordResult(hash, true) + cp.Pool.NotifyNodeDirty(hash) + return hash } - entry.SetEgressIP(netip.MustParseAddr("203.0.113.10")) - if entry.LatencyTable == nil { - t.Fatalf("node %s latency table not initialized", hash.Hex()) + + hashA := addRoutableNode([]byte(`{"type":"ss","server":"1.1.1.1","port":443}`), "seed-a") + hashB := addRoutableNode([]byte(`{"type":"ss","server":"2.2.2.2","port":443}`), "seed-b") + now := time.Now().UnixNano() + for account, hash := range map[string]node.Hash{"acct-a": hashA, "acct-b": hashB} { + if err := cp.Router.UpsertLease(model.Lease{ + PlatformID: platformID, + Account: account, + NodeHash: hash.Hex(), + EgressIP: "203.0.113.10", + CreatedAtNs: now, + ExpiryNs: now + int64(time.Hour), + LastAccessedNs: now, + }); err != nil { + t.Fatalf("upsert lease: %v", err) + } } - entry.LatencyTable.Update("example.com", 25*time.Millisecond, 10*time.Minute) - ob := testutil.NewNoopOutbound() - entry.Outbound.Store(&ob) - cp.Pool.RecordResult(hash, true) - cp.Pool.NotifyNodeDirty(hash) rec := doJSONRequest(t, srv, http.MethodGet, "/api/v1/platforms?keyword=routable-count-target", nil, true) if rec.Code != http.StatusOK { @@ -691,8 +819,17 @@ func TestAPIContract_PlatformListIncludesRoutableNodeCount(t *testing.T) { if item["id"] != platformID { t.Fatalf("item id: got %v, want %s", item["id"], platformID) } - if item["routable_node_count"] != float64(1) { - t.Fatalf("routable_node_count: got %v, want %v", item["routable_node_count"], 1) + if item["routable_node_count"] != float64(2) { + t.Fatalf("routable_node_count: got %v, want %v", item["routable_node_count"], 2) + } + if item["egress_ip_count"] != float64(1) { + t.Fatalf("egress_ip_count: got %v, want %v", item["egress_ip_count"], 1) + } + if item["active_lease_count"] != float64(2) { + t.Fatalf("active_lease_count: got %v, want %v", item["active_lease_count"], 2) + } + if item["is_builtin"] != false { + t.Fatalf("is_builtin: got %v, want false", item["is_builtin"]) } rec = doJSONRequest(t, srv, http.MethodGet, "/api/v1/platforms/"+platformID, nil, true) @@ -700,8 +837,14 @@ func TestAPIContract_PlatformListIncludesRoutableNodeCount(t *testing.T) { t.Fatalf("get platform status: got %d, want %d, body=%s", rec.Code, http.StatusOK, rec.Body.String()) } body = decodeJSONMap(t, rec) - if body["routable_node_count"] != float64(1) { - t.Fatalf("get platform routable_node_count: got %v, want %v", body["routable_node_count"], 1) + if body["routable_node_count"] != float64(2) { + t.Fatalf("get platform routable_node_count: got %v, want %v", body["routable_node_count"], 2) + } + if body["egress_ip_count"] != float64(1) { + t.Fatalf("get platform egress_ip_count: got %v, want %v", body["egress_ip_count"], 1) + } + if body["active_lease_count"] != float64(2) { + t.Fatalf("get platform active_lease_count: got %v, want %v", body["active_lease_count"], 2) } } @@ -756,6 +899,34 @@ func TestAPIContract_PlatformList_BuiltInFirstWithSortBy(t *testing.T) { } } +func TestAPIContract_PlatformList_FreePortPlatformIsBuiltin(t *testing.T) { + srv, cp, _ := newControlPlaneTestServer(t) + cp.EnvCfg.FreePortStart = 21000 + cp.EnvCfg.FreePortPlatform = "MM" + + platformID := mustCreatePlatform(t, srv, "MM") + rec := doJSONRequest(t, srv, http.MethodGet, "/api/v1/platforms?keyword=MM", nil, true) + if rec.Code != http.StatusOK { + t.Fatalf("list platforms status: got %d, want %d, body=%s", rec.Code, http.StatusOK, rec.Body.String()) + } + + body := decodeJSONMap(t, rec) + items, ok := body["items"].([]any) + if !ok || len(items) != 1 { + t.Fatalf("items mismatch: got %T len=%d", body["items"], len(items)) + } + item, ok := items[0].(map[string]any) + if !ok { + t.Fatalf("item type: got %T", items[0]) + } + if item["id"] != platformID { + t.Fatalf("item id: got %v, want %s", item["id"], platformID) + } + if item["is_builtin"] != true { + t.Fatalf("is_builtin: got %v, want true", item["is_builtin"]) + } +} + func TestAPIContract_KeywordFilteringOnListEndpoints(t *testing.T) { srv, _, _ := newControlPlaneTestServer(t) @@ -817,13 +988,47 @@ func TestAPIContract_KeywordFilteringOnListEndpoints(t *testing.T) { if rec.Code != http.StatusCreated { t.Fatalf("create subscription banana status: got %d, want %d, body=%s", rec.Code, http.StatusCreated, rec.Body.String()) } + rec = doJSONRequest(t, srv, http.MethodPost, "/api/v1/subscriptions", map[string]any{ + "name": "Aardvark Feed", + "url": "https://example.com/aardvark", + "enabled": false, + }, true) + if rec.Code != http.StatusCreated { + t.Fatalf("create subscription disabled status: got %d, want %d, body=%s", rec.Code, http.StatusCreated, rec.Body.String()) + } + + rec = doJSONRequest(t, srv, http.MethodGet, "/api/v1/subscriptions?keyword=Feed&enabled=true", nil, true) + if rec.Code != http.StatusOK { + t.Fatalf("list subscriptions default status sort status: got %d, want %d, body=%s", rec.Code, http.StatusOK, rec.Body.String()) + } + body = decodeJSONMap(t, rec) + subItems, ok := body["items"].([]any) + if !ok { + t.Fatalf("subscription status sort items type: got %T", body["items"]) + } + if len(subItems) != 2 { + t.Fatalf("subscription enabled items len: got %d, want %d, body=%s", len(subItems), 2, rec.Body.String()) + } + if got := subItems[0].(map[string]any)["name"]; got != "Apple Feed" { + t.Fatalf("subscription status sort first: got %v, want %q", got, "Apple Feed") + } + if got := subItems[1].(map[string]any)["name"]; got != "Banana Feed" { + t.Fatalf("subscription status sort second: got %v, want %q", got, "Banana Feed") + } + summary, ok := body["summary"].(map[string]any) + if !ok { + t.Fatalf("subscription summary type: got %T", body["summary"]) + } + if summary["enabled_count"] != float64(2) || summary["disabled_count"] != float64(1) { + t.Fatalf("subscription summary counts: got enabled=%v disabled=%v, want 2/1", summary["enabled_count"], summary["disabled_count"]) + } rec = doJSONRequest(t, srv, http.MethodGet, "/api/v1/subscriptions?keyword=BANANA&sort_by=name&sort_order=asc", nil, true) if rec.Code != http.StatusOK { t.Fatalf("list subscriptions keyword status: got %d, want %d, body=%s", rec.Code, http.StatusOK, rec.Body.String()) } body = decodeJSONMap(t, rec) - subItems, ok := body["items"].([]any) + subItems, ok = body["items"].([]any) if !ok { t.Fatalf("subscription items type: got %T", body["items"]) } diff --git a/internal/api/handler_data.go b/internal/api/handler_data.go new file mode 100644 index 00000000..4de0f2ae --- /dev/null +++ b/internal/api/handler_data.go @@ -0,0 +1,42 @@ +package api + +import ( + "net/http" + "time" + + "github.com/Resinat/Resin/internal/service" +) + +// HandleExportData returns a handler for GET /api/v1/data/export. +func HandleExportData(cp *service.ControlPlaneService) http.HandlerFunc { + return func(w http.ResponseWriter, r *http.Request) { + payload, err := cp.ExportData() + if err != nil { + writeServiceError(w, err) + return + } + + filename := "resin-export-" + time.Now().UTC().Format("20060102-150405") + ".json" + w.Header().Set("Content-Disposition", "attachment; filename=\""+filename+"\"") + WriteJSON(w, http.StatusOK, payload) + } +} + +// HandleImportData returns a handler for POST /api/v1/data/import. +func HandleImportData(cp *service.ControlPlaneService) http.HandlerFunc { + return func(w http.ResponseWriter, r *http.Request) { + var payload service.ExportPayload + if err := DecodeBody(r, &payload); err != nil { + writeDecodeBodyError(w, err) + return + } + + strategy := r.URL.Query().Get("strategy") + result, err := cp.ImportData(payload, strategy) + if err != nil { + writeServiceError(w, err) + return + } + WriteJSON(w, http.StatusOK, result) + } +} diff --git a/internal/api/handler_lease.go b/internal/api/handler_lease.go index 15757a99..7a84894e 100644 --- a/internal/api/handler_lease.go +++ b/internal/api/handler_lease.go @@ -19,6 +19,12 @@ func validateAccountPath(r *http.Request) (string, error) { func leaseSortKey(sortBy string, l service.LeaseResponse) string { switch sortBy { + case "node_tag": + return l.NodeTag + case "egress_ip": + return l.EgressIP + case "created_at": + return l.CreatedAt case "expiry": return l.Expiry case "last_accessed": @@ -28,6 +34,25 @@ func leaseSortKey(sortBy string, l service.LeaseResponse) string { } } +func sortLeaseResponsesByLatency(leases []service.LeaseResponse, sorting Sorting) { + slices.SortStableFunc(leases, func(a, b service.LeaseResponse) int { + if a.ReferenceLatencyMs == nil && b.ReferenceLatencyMs == nil { + return strings.Compare(a.Account, b.Account) + } + if a.ReferenceLatencyMs == nil { + return 1 + } + if b.ReferenceLatencyMs == nil { + return -1 + } + order := cmp.Compare(*a.ReferenceLatencyMs, *b.ReferenceLatencyMs) + if order != 0 { + return applySortOrder(order, sorting.SortOrder) + } + return strings.Compare(a.Account, b.Account) + }) +} + func compareIPLoadEntries(sortBy string, a, b service.IPLoadEntry) int { switch sortBy { case "egress_ip": @@ -92,13 +117,17 @@ func HandleListLeases(cp *service.ControlPlaneService) http.HandlerFunc { leases = filtered } - sorting, ok := parseSortingOrWriteInvalid(w, r, []string{"account", "expiry", "last_accessed"}, "expiry", "asc") + sorting, ok := parseSortingOrWriteInvalid(w, r, []string{"account", "node_tag", "egress_ip", "reference_latency_ms", "created_at", "expiry", "last_accessed"}, "expiry", "asc") if !ok { return } - SortSlice(leases, sorting, func(l service.LeaseResponse) string { - return leaseSortKey(sorting.SortBy, l) - }) + if sorting.SortBy == "reference_latency_ms" { + sortLeaseResponsesByLatency(leases, sorting) + } else { + SortSlice(leases, sorting, func(l service.LeaseResponse) string { + return leaseSortKey(sorting.SortBy, l) + }) + } pg, ok := parsePaginationOrWriteInvalid(w, r) if !ok { @@ -164,6 +193,35 @@ func HandleDeleteAllLeases(cp *service.ControlPlaneService) http.HandlerFunc { } } +// HandleBindLease returns a handler for PUT /api/v1/platforms/{id}/leases/{account}. +func HandleBindLease(cp *service.ControlPlaneService) http.HandlerFunc { + type bindRequest struct { + NodeHash string `json:"node_hash"` + } + return func(w http.ResponseWriter, r *http.Request) { + platformID, ok := requireUUIDPathParam(w, r, "id", "platform_id") + if !ok { + return + } + account, err := validateAccountPath(r) + if err != nil { + writeServiceError(w, err) + return + } + var req bindRequest + if err := DecodeBody(r, &req); err != nil { + writeDecodeBodyError(w, err) + return + } + lease, err := cp.BindLease(platformID, account, req.NodeHash) + if err != nil { + writeServiceError(w, err) + return + } + WriteJSON(w, http.StatusOK, lease) + } +} + // HandleIPLoad returns a handler for GET /api/v1/platforms/{id}/ip-load. func HandleIPLoad(cp *service.ControlPlaneService) http.HandlerFunc { return func(w http.ResponseWriter, r *http.Request) { diff --git a/internal/api/handler_node.go b/internal/api/handler_node.go index 53809b3c..651e8ad9 100644 --- a/internal/api/handler_node.go +++ b/internal/api/handler_node.go @@ -42,6 +42,8 @@ func compareNodeSummaries(sortBy string, a, b service.NodeSummary) int { order = cmp.Compare(a.FailureCount, b.FailureCount) case "region": order = strings.Compare(a.Region, b.Region) + case "lease_count": + order = cmp.Compare(a.LeaseCount, b.LeaseCount) default: order = strings.Compare(nodeTagSortKey(a), nodeTagSortKey(b)) } @@ -52,6 +54,26 @@ func compareNodeSummaries(sortBy string, a, b service.NodeSummary) int { } func sortNodeSummaries(nodes []service.NodeSummary, sorting Sorting) { + if sorting.SortBy == "reference_latency_ms" { + slices.SortStableFunc(nodes, func(a, b service.NodeSummary) int { + if a.ReferenceLatencyMs == nil && b.ReferenceLatencyMs == nil { + return strings.Compare(a.NodeHash, b.NodeHash) + } + if a.ReferenceLatencyMs == nil { + return 1 + } + if b.ReferenceLatencyMs == nil { + return -1 + } + order := cmp.Compare(*a.ReferenceLatencyMs, *b.ReferenceLatencyMs) + if order != 0 { + return applySortOrder(order, sorting.SortOrder) + } + return strings.Compare(a.NodeHash, b.NodeHash) + }) + return + } + slices.SortStableFunc(nodes, func(a, b service.NodeSummary) int { return applySortOrder(compareNodeSummaries(sorting.SortBy, a, b), sorting.SortOrder) }) @@ -137,6 +159,12 @@ func HandleListNodes(cp *service.ControlPlaneService) http.HandlerFunc { } filters.Enabled = enabled + manuallyDisabled, ok := parseBoolQueryOrWriteInvalid(w, r, "manually_disabled") + if !ok { + return + } + filters.ManuallyDisabled = manuallyDisabled + if v := q.Get("probed_since"); v != "" { t, err := time.Parse(time.RFC3339Nano, v) if err != nil { @@ -152,7 +180,7 @@ func HandleListNodes(cp *service.ControlPlaneService) http.HandlerFunc { return } - sorting, ok := parseSortingOrWriteInvalid(w, r, []string{"tag", "created_at", "failure_count", "region"}, "tag", "asc") + sorting, ok := parseSortingOrWriteInvalid(w, r, []string{"tag", "created_at", "failure_count", "region", "lease_count", "reference_latency_ms"}, "tag", "asc") if !ok { return } @@ -211,3 +239,71 @@ func HandleProbeLatency(cp *service.ControlPlaneService) http.HandlerFunc { WriteJSON(w, http.StatusOK, result) } } + +// HandleListNodeLeases returns a handler for GET /api/v1/nodes/{hash}/leases. +// It returns every lease currently bound to the node; pass platform_id=... to +// scope the result to a single platform. +func HandleListNodeLeases(cp *service.ControlPlaneService) http.HandlerFunc { + return func(w http.ResponseWriter, r *http.Request) { + hash := PathParam(r, "hash") + platformID, ok := parseOptionalUUIDQuery(w, r, "platform_id", "platform_id") + if !ok { + return + } + pid := "" + if platformID != nil { + pid = *platformID + } + leases, err := cp.ListLeasesByNode(hash, pid) + if err != nil { + writeServiceError(w, err) + return + } + WriteJSON(w, http.StatusOK, leases) + } +} + +// HandleDisableNode returns a handler for POST /api/v1/nodes/{hash}/actions/disable. +// Disabling a node removes it from all platform views and releases every lease +// currently bound to it; the released count is returned in the response. +func HandleDisableNode(cp *service.ControlPlaneService) http.HandlerFunc { + return func(w http.ResponseWriter, r *http.Request) { + hash := PathParam(r, "hash") + result, err := cp.SetNodeManualDisable(hash, true) + if err != nil { + writeServiceError(w, err) + return + } + WriteJSON(w, http.StatusOK, result) + } +} + +// HandleEnableNode returns a handler for POST /api/v1/nodes/{hash}/actions/enable. +// Enabling a node clears the admin disable flag so it can be selected again on +// the next routing decision. No leases are touched on enable. +func HandleEnableNode(cp *service.ControlPlaneService) http.HandlerFunc { + return func(w http.ResponseWriter, r *http.Request) { + hash := PathParam(r, "hash") + result, err := cp.SetNodeManualDisable(hash, false) + if err != nil { + writeServiceError(w, err) + return + } + WriteJSON(w, http.StatusOK, result) + } +} + +// HandleCleanupNode returns a handler for POST /api/v1/nodes/{hash}/actions/cleanup. +// Cleaning a node evicts every subscription reference and releases every lease +// currently bound to it. +func HandleCleanupNode(cp *service.ControlPlaneService) http.HandlerFunc { + return func(w http.ResponseWriter, r *http.Request) { + hash := PathParam(r, "hash") + result, err := cp.CleanupNode(hash) + if err != nil { + writeServiceError(w, err) + return + } + WriteJSON(w, http.StatusOK, result) + } +} diff --git a/internal/api/handler_node_test.go b/internal/api/handler_node_test.go index 4540f3b0..17f8da27 100644 --- a/internal/api/handler_node_test.go +++ b/internal/api/handler_node_test.go @@ -7,6 +7,7 @@ import ( "time" "github.com/Resinat/Resin/internal/config" + "github.com/Resinat/Resin/internal/model" "github.com/Resinat/Resin/internal/node" "github.com/Resinat/Resin/internal/probe" "github.com/Resinat/Resin/internal/service" @@ -215,6 +216,138 @@ func TestHandleListNodes_IncludesReferenceLatencyMs(t *testing.T) { } } +func TestHandleGetNode_IncludesOutboundDetails(t *testing.T) { + srv, cp, _ := newControlPlaneTestServer(t) + + sub := subscription.NewSubscription("11111111-1111-1111-1111-111111111111", "sub-a", "https://example.com/a", true, false) + cp.SubMgr.Register(sub) + + raw := `{"type":"http","tag":"proxy","server":"proxy.example.com","server_port":8080,"username":"user","password":"pass"}` + hash := node.HashFromRawOptions([]byte(raw)) + addNodeForNodeListTestWithTag(t, cp, sub, raw, "203.0.113.10", "proxy") + + rec := doJSONRequest(t, srv, http.MethodGet, "/api/v1/nodes/"+hash.Hex(), nil, true) + if rec.Code != http.StatusOK { + t.Fatalf("get node status: got %d, want %d, body=%s", rec.Code, http.StatusOK, rec.Body.String()) + } + body := decodeJSONMap(t, rec) + outbound, ok := body["outbound"].(map[string]any) + if !ok { + t.Fatalf("outbound type: got %T", body["outbound"]) + } + if outbound["type"] != "http" || outbound["server"] != "proxy.example.com" { + t.Fatalf("outbound mismatch: %#v", outbound) + } + proxyURLs, ok := body["proxy_urls"].([]any) + if !ok || len(proxyURLs) != 1 { + t.Fatalf("proxy_urls mismatch: got %T %#v", body["proxy_urls"], body["proxy_urls"]) + } + first, ok := proxyURLs[0].(map[string]any) + if !ok { + t.Fatalf("proxy_urls[0] type: got %T", proxyURLs[0]) + } + if first["type"] != "http" || first["url"] != "http://user:pass@proxy.example.com:8080#proxy" { + t.Fatalf("proxy url mismatch: %#v", first) + } + + rec = doJSONRequest(t, srv, http.MethodGet, "/api/v1/nodes?subscription_id="+sub.ID, nil, true) + if rec.Code != http.StatusOK { + t.Fatalf("list nodes status: got %d, want %d, body=%s", rec.Code, http.StatusOK, rec.Body.String()) + } + listBody := decodeJSONMap(t, rec) + items, ok := listBody["items"].([]any) + if !ok || len(items) != 1 { + t.Fatalf("items mismatch: got %T len=%d", listBody["items"], len(items)) + } + item, ok := items[0].(map[string]any) + if !ok { + t.Fatalf("item type: got %T", items[0]) + } + if _, ok := item["outbound"]; ok { + t.Fatalf("list item should not include outbound: %#v", item["outbound"]) + } + if _, ok := item["proxy_urls"]; ok { + t.Fatalf("list item should not include proxy_urls: %#v", item["proxy_urls"]) + } +} + +func TestHandleListNodes_SortsByReferenceLatencyMs(t *testing.T) { + srv, cp, runtimeCfg := newControlPlaneTestServer(t) + + cfg := config.NewDefaultRuntimeConfig() + cfg.LatencyAuthorities = []string{"cloudflare.com"} + runtimeCfg.Store(cfg) + + subA := subscription.NewSubscription("11111111-1111-1111-1111-111111111111", "sub-a", "https://example.com/a", true, false) + cp.SubMgr.Register(subA) + + rawFast := `{"type":"ss","server":"1.1.1.1","port":443}` + rawSlow := `{"type":"ss","server":"2.2.2.2","port":443}` + rawUnknown := `{"type":"ss","server":"3.3.3.3","port":443}` + addNodeForNodeListTestWithTag(t, cp, subA, rawFast, "", "fast") + addNodeForNodeListTestWithTag(t, cp, subA, rawSlow, "", "slow") + addNodeForNodeListTestWithTag(t, cp, subA, rawUnknown, "", "unknown") + + fastHash := node.HashFromRawOptions([]byte(rawFast)) + slowHash := node.HashFromRawOptions([]byte(rawSlow)) + unknownHash := node.HashFromRawOptions([]byte(rawUnknown)) + for hash, latency := range map[node.Hash]time.Duration{ + fastHash: 20 * time.Millisecond, + slowHash: 80 * time.Millisecond, + } { + entry, ok := cp.Pool.GetEntry(hash) + if !ok { + t.Fatalf("node %s missing after add", hash.Hex()) + } + entry.LatencyTable.LoadEntry("cloudflare.com", node.DomainLatencyStats{ + Ewma: latency, + LastUpdated: time.Now(), + }) + } + + hashesFromResponse := func(path string) []string { + t.Helper() + rec := doJSONRequest(t, srv, http.MethodGet, path, nil, true) + if rec.Code != http.StatusOK { + t.Fatalf("list nodes status: got %d, want %d, body=%s", rec.Code, http.StatusOK, rec.Body.String()) + } + body := decodeJSONMap(t, rec) + items, ok := body["items"].([]any) + if !ok { + t.Fatalf("items type: got %T", body["items"]) + } + hashes := make([]string, 0, len(items)) + for _, rawItem := range items { + item, ok := rawItem.(map[string]any) + if !ok { + t.Fatalf("item type: got %T", rawItem) + } + hash, ok := item["node_hash"].(string) + if !ok { + t.Fatalf("node_hash type: got %T", item["node_hash"]) + } + hashes = append(hashes, hash) + } + return hashes + } + + basePath := "/api/v1/nodes?subscription_id=" + subA.ID + "&sort_by=reference_latency_ms" + assertOrder := func(label string, got []string, want []node.Hash) { + t.Helper() + if len(got) != len(want) { + t.Fatalf("%s len: got %d, want %d (%v)", label, len(got), len(want), got) + } + for i, h := range want { + if got[i] != h.Hex() { + t.Fatalf("%s[%d]: got %s, want %s (all=%v)", label, i, got[i], h.Hex(), got) + } + } + } + + assertOrder("asc", hashesFromResponse(basePath+"&sort_order=asc"), []node.Hash{fastHash, slowHash, unknownHash}) + assertOrder("desc", hashesFromResponse(basePath+"&sort_order=desc"), []node.Hash{slowHash, fastHash, unknownHash}) +} + func TestHandleProbeEgress_ReturnsRegion(t *testing.T) { srv, cp, _ := newControlPlaneTestServer(t) @@ -253,6 +386,59 @@ func TestHandleProbeEgress_ReturnsRegion(t *testing.T) { } } +func TestHandleCleanupNode_RemovesNodeAndLeases(t *testing.T) { + srv, cp, _ := newControlPlaneTestServer(t) + + sub := subscription.NewSubscription("11111111-1111-1111-1111-111111111111", "sub-a", "https://example.com/a", true, false) + cp.SubMgr.Register(sub) + + raw := []byte(`{"type":"ss","server":"1.1.1.1","port":443}`) + hash := node.HashFromRawOptions(raw) + cp.Pool.AddNodeFromSub(hash, raw, sub.ID) + sub.ManagedNodes().StoreNode(hash, subscription.ManagedNode{Tags: []string{"tag"}}) + + now := time.Now().UnixNano() + if err := cp.Router.UpsertLease(model.Lease{ + PlatformID: "platform-a", + Account: "alice", + NodeHash: hash.Hex(), + EgressIP: "203.0.113.10", + CreatedAtNs: now, + ExpiryNs: now + int64(time.Hour), + LastAccessedNs: now, + }); err != nil { + t.Fatalf("upsert lease: %v", err) + } + + rec := doJSONRequest(t, srv, http.MethodPost, "/api/v1/nodes/"+hash.Hex()+"/actions/cleanup", nil, true) + if rec.Code != http.StatusOK { + t.Fatalf("cleanup status: got %d, want %d, body=%s", rec.Code, http.StatusOK, rec.Body.String()) + } + body := decodeJSONMap(t, rec) + if body["evicted_subscription_count"] != float64(1) { + t.Fatalf("evicted_subscription_count: got %v, want 1", body["evicted_subscription_count"]) + } + if body["released_lease_count"] != float64(1) { + t.Fatalf("released_lease_count: got %v, want 1", body["released_lease_count"]) + } + if _, ok := cp.Pool.GetEntry(hash); ok { + t.Fatal("node should be removed from pool") + } + managed, ok := sub.ManagedNodes().LoadNode(hash) + if !ok || !managed.Evicted { + t.Fatalf("managed node evicted = %v, ok=%v; want true", managed.Evicted, ok) + } + + rec = doJSONRequest(t, srv, http.MethodPost, "/api/v1/nodes/not-hex/actions/cleanup", nil, true) + if rec.Code != http.StatusBadRequest { + t.Fatalf("invalid hash status: got %d, want %d, body=%s", rec.Code, http.StatusBadRequest, rec.Body.String()) + } + rec = doJSONRequest(t, srv, http.MethodPost, "/api/v1/nodes/"+hash.Hex()+"/actions/cleanup", nil, true) + if rec.Code != http.StatusNotFound { + t.Fatalf("missing node status: got %d, want %d, body=%s", rec.Code, http.StatusNotFound, rec.Body.String()) + } +} + func TestHandleListNodes_EnabledFilter(t *testing.T) { srv, cp, _ := newControlPlaneTestServer(t) diff --git a/internal/api/handler_platform.go b/internal/api/handler_platform.go index 3b26a375..b8bd402e 100644 --- a/internal/api/handler_platform.go +++ b/internal/api/handler_platform.go @@ -5,7 +5,6 @@ import ( "slices" "strings" - "github.com/Resinat/Resin/internal/platform" "github.com/Resinat/Resin/internal/service" ) @@ -51,8 +50,8 @@ func platformSortKey(sortBy string, p service.PlatformResponse) string { } func comparePlatformsForList(a, b service.PlatformResponse, sorting Sorting) int { - aBuiltin := a.ID == platform.DefaultPlatformID - bBuiltin := b.ID == platform.DefaultPlatformID + aBuiltin := a.IsBuiltin + bBuiltin := b.IsBuiltin if aBuiltin != bBuiltin { if aBuiltin { return -1 diff --git a/internal/api/handler_subscription.go b/internal/api/handler_subscription.go index ba8b1b32..67c5d58c 100644 --- a/internal/api/handler_subscription.go +++ b/internal/api/handler_subscription.go @@ -2,11 +2,30 @@ package api import ( "net/http" + "slices" "strings" "github.com/Resinat/Resin/internal/service" ) +type subscriptionListSummary struct { + EnabledCount int `json:"enabled_count"` + DisabledCount int `json:"disabled_count"` + UsageUsedBytes int64 `json:"usage_used_bytes"` + UsageTotalBytes int64 `json:"usage_total_bytes"` + UsageRemainingBytes int64 `json:"usage_remaining_bytes"` + HealthyNodeCount int `json:"healthy_node_count"` + NodeCount int `json:"node_count"` +} + +type subscriptionListPageResponse struct { + Items []service.SubscriptionResponse `json:"items"` + Total int `json:"total"` + Limit int `json:"limit"` + Offset int `json:"offset"` + Summary subscriptionListSummary `json:"summary"` +} + func subscriptionMatchesKeyword(s service.SubscriptionResponse, keyword string) bool { contains := func(v string) bool { return strings.Contains(strings.ToLower(v), keyword) @@ -29,6 +48,19 @@ func filterSubscriptionsByKeyword(subs []service.SubscriptionResponse, rawKeywor return filtered } +func filterSubscriptionsByEnabled(subs []service.SubscriptionResponse, enabled *bool) []service.SubscriptionResponse { + if enabled == nil { + return subs + } + filtered := make([]service.SubscriptionResponse, 0, len(subs)) + for _, sub := range subs { + if sub.Enabled == *enabled { + filtered = append(filtered, sub) + } + } + return filtered +} + func subscriptionSortKey(sortBy string, s service.SubscriptionResponse) string { switch sortBy { case "created_at": @@ -42,6 +74,66 @@ func subscriptionSortKey(sortBy string, s service.SubscriptionResponse) string { } } +func nonNegativeInt64(value int64) int64 { + if value < 0 { + return 0 + } + return value +} + +func summarizeSubscriptions(subs []service.SubscriptionResponse) subscriptionListSummary { + var summary subscriptionListSummary + for _, sub := range subs { + if sub.Enabled { + summary.EnabledCount++ + } else { + summary.DisabledCount++ + continue + } + summary.HealthyNodeCount += sub.HealthyNodeCount + summary.NodeCount += sub.NodeCount + if sub.Usage == nil { + continue + } + usedBytes := nonNegativeInt64(sub.Usage.UploadBytes) + nonNegativeInt64(sub.Usage.DownloadBytes) + summary.UsageUsedBytes += usedBytes + if sub.Usage.TotalBytes > 0 { + summary.UsageTotalBytes += sub.Usage.TotalBytes + remaining := sub.Usage.TotalBytes - usedBytes + if remaining > 0 { + summary.UsageRemainingBytes += remaining + } + } + } + return summary +} + +func compareSubscriptionsForList(a, b service.SubscriptionResponse, sorting Sorting) int { + if sorting.SortBy == "status" { + if a.Enabled != b.Enabled { + order := 1 + if a.Enabled { + order = -1 + } + return applySortOrder(order, sorting.SortOrder) + } + if order := strings.Compare(a.Name, b.Name); order != 0 { + return order + } + if order := strings.Compare(b.CreatedAt, a.CreatedAt); order != 0 { + return order + } + return strings.Compare(a.ID, b.ID) + } + + order := strings.Compare(subscriptionSortKey(sorting.SortBy, a), subscriptionSortKey(sorting.SortBy, b)) + order = applySortOrder(order, sorting.SortOrder) + if order != 0 { + return order + } + return strings.Compare(a.ID, b.ID) +} + // HandleListSubscriptions returns a handler for GET /api/v1/subscriptions. func HandleListSubscriptions(cp *service.ControlPlaneService) http.HandlerFunc { return func(w http.ResponseWriter, r *http.Request) { @@ -49,32 +141,40 @@ func HandleListSubscriptions(cp *service.ControlPlaneService) http.HandlerFunc { if !ok { return } - subs, err := cp.ListSubscriptions(enabled) + subs, err := cp.ListSubscriptions(nil) if err != nil { writeServiceError(w, err) return } subs = filterSubscriptionsByKeyword(subs, r.URL.Query().Get("keyword")) + summary := summarizeSubscriptions(subs) + subs = filterSubscriptionsByEnabled(subs, enabled) sorting, ok := parseSortingOrWriteInvalid( w, r, - []string{"name", "created_at", "last_checked", "last_updated"}, - "created_at", + []string{"status", "name", "created_at", "last_checked", "last_updated"}, + "status", "asc", ) if !ok { return } - SortSlice(subs, sorting, func(s service.SubscriptionResponse) string { - return subscriptionSortKey(sorting.SortBy, s) + slices.SortStableFunc(subs, func(a, b service.SubscriptionResponse) int { + return compareSubscriptionsForList(a, b, sorting) }) pg, ok := parsePaginationOrWriteInvalid(w, r) if !ok { return } - WritePage(w, http.StatusOK, subs, pg) + WriteJSON(w, http.StatusOK, subscriptionListPageResponse{ + Items: PaginateSlice(subs, pg), + Total: len(subs), + Limit: pg.Limit, + Offset: pg.Offset, + Summary: summary, + }) } } diff --git a/internal/api/handler_subscription_test.go b/internal/api/handler_subscription_test.go new file mode 100644 index 00000000..ce59ac19 --- /dev/null +++ b/internal/api/handler_subscription_test.go @@ -0,0 +1,42 @@ +package api + +import ( + "testing" + + "github.com/Resinat/Resin/internal/service" +) + +func TestSummarizeSubscriptions_OnlyEnabledSubscriptionsContributeUsageAndNodes(t *testing.T) { + summary := summarizeSubscriptions([]service.SubscriptionResponse{ + { + Enabled: true, + HealthyNodeCount: 2, + NodeCount: 3, + Usage: &service.SubscriptionUsageResponse{ + UploadBytes: 10, + DownloadBytes: 20, + TotalBytes: 100, + }, + }, + { + Enabled: false, + HealthyNodeCount: 5, + NodeCount: 8, + Usage: &service.SubscriptionUsageResponse{ + UploadBytes: 1000, + DownloadBytes: 2000, + TotalBytes: 9000, + }, + }, + }) + + if summary.EnabledCount != 1 || summary.DisabledCount != 1 { + t.Fatalf("counts = %d/%d, want 1/1", summary.EnabledCount, summary.DisabledCount) + } + if summary.HealthyNodeCount != 2 || summary.NodeCount != 3 { + t.Fatalf("nodes = %d/%d, want 2/3", summary.HealthyNodeCount, summary.NodeCount) + } + if summary.UsageUsedBytes != 30 || summary.UsageTotalBytes != 100 || summary.UsageRemainingBytes != 70 { + t.Fatalf("usage = used %d total %d remaining %d, want 30/100/70", summary.UsageUsedBytes, summary.UsageTotalBytes, summary.UsageRemainingBytes) + } +} diff --git a/internal/api/server.go b/internal/api/server.go index fa070244..6ce8e502 100644 --- a/internal/api/server.go +++ b/internal/api/server.go @@ -89,6 +89,7 @@ func NewServerWithAddress( authed.Handle("GET /api/v1/platforms/{id}/leases", HandleListLeases(cp)) authed.Handle("DELETE /api/v1/platforms/{id}/leases", HandleDeleteAllLeases(cp)) authed.Handle("GET /api/v1/platforms/{id}/leases/{account}", HandleGetLease(cp)) + authed.Handle("PUT /api/v1/platforms/{id}/leases/{account}", HandleBindLease(cp)) authed.Handle("DELETE /api/v1/platforms/{id}/leases/{account}", HandleDeleteLease(cp)) authed.Handle("GET /api/v1/platforms/{id}/ip-load", HandleIPLoad(cp)) @@ -111,14 +112,22 @@ func NewServerWithAddress( // Nodes. authed.Handle("GET /api/v1/nodes", HandleListNodes(cp)) authed.Handle("GET /api/v1/nodes/{hash}", HandleGetNode(cp)) + authed.Handle("GET /api/v1/nodes/{hash}/leases", HandleListNodeLeases(cp)) authed.Handle("POST /api/v1/nodes/{hash}/actions/probe-egress", HandleProbeEgress(cp)) authed.Handle("POST /api/v1/nodes/{hash}/actions/probe-latency", HandleProbeLatency(cp)) + authed.Handle("POST /api/v1/nodes/{hash}/actions/disable", HandleDisableNode(cp)) + authed.Handle("POST /api/v1/nodes/{hash}/actions/enable", HandleEnableNode(cp)) + authed.Handle("POST /api/v1/nodes/{hash}/actions/cleanup", HandleCleanupNode(cp)) // GeoIP. authed.Handle("GET /api/v1/geoip/status", HandleGeoIPStatus(cp)) authed.Handle("GET /api/v1/geoip/lookup", HandleGeoIPLookup(cp)) authed.Handle("POST /api/v1/geoip/lookup", HandleGeoIPLookupPost(cp)) authed.Handle("POST /api/v1/geoip/actions/update-now", HandleGeoIPUpdate(cp)) + + // Data export / import. + authed.Handle("GET /api/v1/data/export", HandleExportData(cp)) + authed.Handle("POST /api/v1/data/import", HandleImportData(cp)) } // Request log endpoints (always registered if repo is available). diff --git a/internal/config/env.go b/internal/config/env.go index 151e5c9f..adfdbae5 100644 --- a/internal/config/env.go +++ b/internal/config/env.go @@ -10,6 +10,7 @@ import ( "strings" "time" + "github.com/Resinat/Resin/internal/netutil" "github.com/Resinat/Resin/internal/platform" "github.com/robfig/cron/v3" ) @@ -28,6 +29,13 @@ type EnvConfig struct { ResinPort int APIMaxBodyBytes int + // Free-mode (password-less) ports + FreePortStart int + FreePortCount int + FreePortPlatform string + FreePortAccessMode string + FreePortWhitelist []string + // Core MaxLatencyTableEntries int ProbeConcurrency int @@ -97,9 +105,19 @@ func LoadEnvConfig() (*EnvConfig, error) { cfg.ResinPort = envInt("RESIN_PORT", 2260, &errs) cfg.APIMaxBodyBytes = envInt("RESIN_API_MAX_BODY_BYTES", 1<<20, &errs) + // --- Free-mode ports (disabled when RESIN_FREE_PORT_START is 0/unset) --- + cfg.FreePortStart = envInt("RESIN_FREE_PORT_START", 0, &errs) + cfg.FreePortCount = envInt("RESIN_FREE_PORT_COUNT", 0, &errs) + cfg.FreePortPlatform = strings.TrimSpace(envStr("RESIN_FREE_PORT_PLATFORM", "")) + cfg.FreePortAccessMode = strings.TrimSpace(envStr("RESIN_FREE_PORT_ACCESS_MODE", netutil.AccessModeIntranet)) + cfg.FreePortWhitelist = envStringSlice("RESIN_FREE_PORT_WHITELIST", []string{}, &errs) + // --- Core --- cfg.MaxLatencyTableEntries = envInt("RESIN_MAX_LATENCY_TABLE_ENTRIES", 12, &errs) - cfg.ProbeConcurrency = envInt("RESIN_PROBE_CONCURRENCY", 1000, &errs) + // Default kept modest: each probe worker opens a real outbound (singbox) and + // performs an HTTPS request. With thousands of nodes, a high concurrency causes + // a CPU/network/disk-IO storm at startup. High-scale deployments can raise this. + cfg.ProbeConcurrency = envInt("RESIN_PROBE_CONCURRENCY", 64, &errs) cfg.GeoIPUpdateSchedule = envStr("RESIN_GEOIP_UPDATE_SCHEDULE", "0 7 * * *") cfg.DefaultPlatformStickyTTL = envDuration("RESIN_DEFAULT_PLATFORM_STICKY_TTL", 7*24*time.Hour, &errs) cfg.DefaultPlatformRegexFilters = envStringSlice("RESIN_DEFAULT_PLATFORM_REGEX_FILTERS", []string{}, &errs) @@ -219,6 +237,7 @@ func LoadEnvConfig() (*EnvConfig, error) { validatePort("RESIN_PORT", cfg.ResinPort, &errs) validatePositive("RESIN_API_MAX_BODY_BYTES", cfg.APIMaxBodyBytes, &errs) + validateFreePortConfig(cfg, &errs) validatePositive("RESIN_MAX_LATENCY_TABLE_ENTRIES", cfg.MaxLatencyTableEntries, &errs) if cfg.MaxLatencyTableEntries > 32 { @@ -434,6 +453,48 @@ func validatePositive(name string, value int, errs *[]string) { } } +// freePortMaxCount caps how many password-less ports may be opened at once, +// keeping fd/goroutine usage bounded (DESIGN: "按需指定数量"). +const freePortMaxCount = 256 + +// validateFreePortConfig validates free-mode port settings. The feature is +// disabled (and all checks skipped) when RESIN_FREE_PORT_START is 0/unset. +func validateFreePortConfig(cfg *EnvConfig, errs *[]string) { + if cfg.FreePortStart == 0 { + return + } + validatePort("RESIN_FREE_PORT_START", cfg.FreePortStart, errs) + if cfg.FreePortCount <= 0 { + *errs = append(*errs, "RESIN_FREE_PORT_COUNT must be positive when RESIN_FREE_PORT_START is set") + } else if cfg.FreePortCount > freePortMaxCount { + *errs = append(*errs, fmt.Sprintf("RESIN_FREE_PORT_COUNT must be <= %d", freePortMaxCount)) + } + if cfg.FreePortCount > 0 { + end := cfg.FreePortStart + cfg.FreePortCount - 1 + if end > 65535 { + *errs = append(*errs, fmt.Sprintf("RESIN_FREE_PORT range end %d exceeds 65535", end)) + } + if cfg.ResinPort >= cfg.FreePortStart && cfg.ResinPort <= end { + *errs = append(*errs, fmt.Sprintf( + "RESIN_PORT %d must not fall within free-port range [%d, %d]", + cfg.ResinPort, cfg.FreePortStart, end, + )) + } + } + if cfg.FreePortPlatform == "" { + *errs = append(*errs, "RESIN_FREE_PORT_PLATFORM must not be empty when RESIN_FREE_PORT_START is set") + } else if cfg.AuthVersion == AuthVersionV1 { + if err := platform.ValidatePlatformName(cfg.FreePortPlatform); err != nil { + *errs = append(*errs, fmt.Sprintf("RESIN_FREE_PORT_PLATFORM: %v", err)) + } + } + // Reuse AccessController construction as the single source of truth for + // access-mode / whitelist validation. + if _, err := netutil.NewAccessController(cfg.FreePortAccessMode, cfg.FreePortWhitelist); err != nil { + *errs = append(*errs, fmt.Sprintf("RESIN_FREE_PORT_ACCESS_MODE/RESIN_FREE_PORT_WHITELIST: %v", err)) + } +} + const ( v1ProxyTokenForbiddenChars = ".:|/\\@?#%~" v1ProxyTokenForbiddenSpacing = " \t\r\n" diff --git a/internal/config/env_test.go b/internal/config/env_test.go index 3599741f..fde3ff97 100644 --- a/internal/config/env_test.go +++ b/internal/config/env_test.go @@ -44,7 +44,7 @@ func TestLoadEnvConfig_Defaults(t *testing.T) { // Core assertEqual(t, "MaxLatencyTableEntries", cfg.MaxLatencyTableEntries, 12) - assertEqual(t, "ProbeConcurrency", cfg.ProbeConcurrency, 1000) + assertEqual(t, "ProbeConcurrency", cfg.ProbeConcurrency, 64) assertEqual(t, "GeoIPUpdateSchedule", cfg.GeoIPUpdateSchedule, "0 7 * * *") assertEqual(t, "DefaultPlatformStickyTTL", cfg.DefaultPlatformStickyTTL, 7*24*time.Hour) assertEqual(t, "DefaultPlatformRegexFiltersLength", len(cfg.DefaultPlatformRegexFilters), 0) @@ -592,6 +592,103 @@ func TestLoadEnvConfig_InvalidProxyTransportSettings(t *testing.T) { assertContains(t, err.Error(), "RESIN_PROXY_TRANSPORT_MAX_IDLE_CONNS_PER_HOST") } +func TestLoadEnvConfig_FreePortDisabledByDefault(t *testing.T) { + setEnvs(t, requiredEnvs()) + cfg, err := LoadEnvConfig() + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + assertEqual(t, "FreePortStart", cfg.FreePortStart, 0) +} + +func TestLoadEnvConfig_FreePortEnabled(t *testing.T) { + envs := requiredEnvs() + envs["RESIN_FREE_PORT_START"] = "21000" + envs["RESIN_FREE_PORT_COUNT"] = "50" + envs["RESIN_FREE_PORT_PLATFORM"] = "MM" + setEnvs(t, envs) + + cfg, err := LoadEnvConfig() + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + assertEqual(t, "FreePortStart", cfg.FreePortStart, 21000) + assertEqual(t, "FreePortCount", cfg.FreePortCount, 50) + assertEqual(t, "FreePortPlatform", cfg.FreePortPlatform, "MM") + assertEqual(t, "FreePortAccessMode", cfg.FreePortAccessMode, "intranet") +} + +func TestLoadEnvConfig_FreePortMissingCount(t *testing.T) { + envs := requiredEnvs() + envs["RESIN_FREE_PORT_START"] = "21000" + envs["RESIN_FREE_PORT_PLATFORM"] = "MM" + setEnvs(t, envs) + + _, err := LoadEnvConfig() + if err == nil { + t.Fatal("expected error when free-port count missing") + } + assertContains(t, err.Error(), "RESIN_FREE_PORT_COUNT") +} + +func TestLoadEnvConfig_FreePortMissingPlatform(t *testing.T) { + envs := requiredEnvs() + envs["RESIN_FREE_PORT_START"] = "21000" + envs["RESIN_FREE_PORT_COUNT"] = "10" + setEnvs(t, envs) + + _, err := LoadEnvConfig() + if err == nil { + t.Fatal("expected error when free-port platform missing") + } + assertContains(t, err.Error(), "RESIN_FREE_PORT_PLATFORM") +} + +func TestLoadEnvConfig_FreePortOverlapsMainPort(t *testing.T) { + envs := requiredEnvs() + envs["RESIN_PORT"] = "21010" + envs["RESIN_FREE_PORT_START"] = "21000" + envs["RESIN_FREE_PORT_COUNT"] = "50" + envs["RESIN_FREE_PORT_PLATFORM"] = "MM" + setEnvs(t, envs) + + _, err := LoadEnvConfig() + if err == nil { + t.Fatal("expected error when main port falls within free-port range") + } + assertContains(t, err.Error(), "RESIN_PORT") +} + +func TestLoadEnvConfig_FreePortWhitelistRequired(t *testing.T) { + envs := requiredEnvs() + envs["RESIN_FREE_PORT_START"] = "21000" + envs["RESIN_FREE_PORT_COUNT"] = "10" + envs["RESIN_FREE_PORT_PLATFORM"] = "MM" + envs["RESIN_FREE_PORT_ACCESS_MODE"] = "whitelist" + setEnvs(t, envs) + + _, err := LoadEnvConfig() + if err == nil { + t.Fatal("expected error for whitelist mode with empty whitelist") + } + assertContains(t, err.Error(), "RESIN_FREE_PORT") +} + +func TestLoadEnvConfig_FreePortInvalidPlatformNameV1(t *testing.T) { + envs := requiredEnvs() + envs["RESIN_AUTH_VERSION"] = "V1" + envs["RESIN_FREE_PORT_START"] = "21000" + envs["RESIN_FREE_PORT_COUNT"] = "10" + envs["RESIN_FREE_PORT_PLATFORM"] = "Bad.Name" + setEnvs(t, envs) + + _, err := LoadEnvConfig() + if err == nil { + t.Fatal("expected error for invalid V1 free-port platform name") + } + assertContains(t, err.Error(), "RESIN_FREE_PORT_PLATFORM") +} + // --- test helpers --- func assertEqual[T comparable](t *testing.T, name string, got, want T) { diff --git a/internal/model/models.go b/internal/model/models.go index ba2c124a..e7571459 100644 --- a/internal/model/models.go +++ b/internal/model/models.go @@ -9,6 +9,7 @@ type Platform struct { Name string `json:"name"` StickyTTLNs int64 `json:"sticky_ttl_ns"` RegexFilters []string + RegexExcludeFilters []string RegionFilters []string ReverseProxyMissAction string `json:"reverse_proxy_miss_action"` ReverseProxyEmptyAccountBehavior string `json:"reverse_proxy_empty_account_behavior"` @@ -30,6 +31,11 @@ type Subscription struct { Ephemeral bool `json:"ephemeral"` IncrementalAliveNodes bool `json:"incremental_alive_nodes"` EphemeralNodeEvictDelayNs int64 `json:"ephemeral_node_evict_delay_ns"` + UsageUploadBytes int64 `json:"usage_upload_bytes"` + UsageDownloadBytes int64 `json:"usage_download_bytes"` + UsageTotalBytes int64 `json:"usage_total_bytes"` + UsageExpireUnix int64 `json:"usage_expire_unix"` + UsageUpdatedAtNs int64 `json:"usage_updated_at_ns"` CreatedAtNs int64 `json:"created_at_ns"` UpdatedAtNs int64 `json:"updated_at_ns"` } @@ -59,6 +65,7 @@ type NodeDynamic struct { LastLatencyProbeAttemptNs int64 `json:"last_latency_probe_attempt_ns"` LastAuthorityLatencyProbeAttemptNs int64 `json:"last_authority_latency_probe_attempt_ns"` LastEgressUpdateAttemptNs int64 `json:"last_egress_update_attempt_ns"` + ManuallyDisabled bool `json:"manually_disabled"` } // NodeLatency holds per-domain latency statistics for a node. diff --git a/internal/netutil/access_control.go b/internal/netutil/access_control.go new file mode 100644 index 00000000..6657eaa3 --- /dev/null +++ b/internal/netutil/access_control.go @@ -0,0 +1,119 @@ +package netutil + +import ( + "fmt" + "net" + "net/netip" + "strings" +) + +// Access modes for free-mode (password-less) ports. +const ( + // AccessModeIntranet allows only private / loopback / link-local clients. + AccessModeIntranet = "intranet" + // AccessModeWhitelist allows only clients matching a configured IP/CIDR list. + AccessModeWhitelist = "whitelist" +) + +// AccessController decides whether a remote address may use a free-mode port. +// +// It is the single source of truth for both config validation (constructing it +// validates the inputs) and the runtime connection gate. +type AccessController struct { + mode string + prefixes []netip.Prefix +} + +// NewAccessController builds an AccessController and validates its inputs. +// - mode must be AccessModeIntranet or AccessModeWhitelist. +// - In whitelist mode the list must be non-empty and every entry must be a +// valid IP address or CIDR prefix. +func NewAccessController(mode string, whitelist []string) (*AccessController, error) { + switch mode { + case AccessModeIntranet: + return &AccessController{mode: mode}, nil + case AccessModeWhitelist: + if len(whitelist) == 0 { + return nil, fmt.Errorf("whitelist must be non-empty in %q mode", AccessModeWhitelist) + } + prefixes := make([]netip.Prefix, 0, len(whitelist)) + for _, raw := range whitelist { + entry := strings.TrimSpace(raw) + if entry == "" { + return nil, fmt.Errorf("whitelist entry must not be empty") + } + prefix, err := parsePrefixOrAddr(entry) + if err != nil { + return nil, fmt.Errorf("invalid IP/CIDR %q: %w", entry, err) + } + prefixes = append(prefixes, prefix) + } + return &AccessController{mode: mode, prefixes: prefixes}, nil + default: + return nil, fmt.Errorf( + "invalid access mode %q (allowed: %s, %s)", + mode, AccessModeIntranet, AccessModeWhitelist, + ) + } +} + +// Allow reports whether the remote address may use the port. +// A nil controller or unparseable address denies by default (fail-closed). +func (a *AccessController) Allow(remote net.Addr) bool { + if a == nil { + return false + } + addr, ok := addrToNetip(remote) + if !ok { + return false + } + addr = addr.Unmap() + switch a.mode { + case AccessModeIntranet: + return addr.IsPrivate() || addr.IsLoopback() || addr.IsLinkLocalUnicast() + case AccessModeWhitelist: + for _, prefix := range a.prefixes { + if prefix.Contains(addr) { + return true + } + } + return false + default: + return false + } +} + +func parsePrefixOrAddr(entry string) (netip.Prefix, error) { + if strings.Contains(entry, "/") { + prefix, err := netip.ParsePrefix(entry) + if err != nil { + return netip.Prefix{}, err + } + return prefix.Masked(), nil + } + addr, err := netip.ParseAddr(entry) + if err != nil { + return netip.Prefix{}, err + } + return netip.PrefixFrom(addr, addr.BitLen()), nil +} + +func addrToNetip(remote net.Addr) (netip.Addr, bool) { + if remote == nil { + return netip.Addr{}, false + } + if tcp, ok := remote.(*net.TCPAddr); ok { + if a, ok := netip.AddrFromSlice(tcp.IP); ok { + return a, true + } + } + host, _, err := net.SplitHostPort(remote.String()) + if err != nil { + host = remote.String() + } + a, err := netip.ParseAddr(host) + if err != nil { + return netip.Addr{}, false + } + return a, true +} diff --git a/internal/netutil/access_control_test.go b/internal/netutil/access_control_test.go new file mode 100644 index 00000000..5a3b05ba --- /dev/null +++ b/internal/netutil/access_control_test.go @@ -0,0 +1,86 @@ +package netutil + +import ( + "net" + "testing" +) + +func mustTCPAddr(t *testing.T, hostport string) *net.TCPAddr { + t.Helper() + addr, err := net.ResolveTCPAddr("tcp", hostport) + if err != nil { + t.Fatalf("resolve %q: %v", hostport, err) + } + return addr +} + +func TestNewAccessController_Errors(t *testing.T) { + if _, err := NewAccessController("nope", nil); err == nil { + t.Error("expected error for invalid mode") + } + if _, err := NewAccessController(AccessModeWhitelist, nil); err == nil { + t.Error("expected error for empty whitelist") + } + if _, err := NewAccessController(AccessModeWhitelist, []string{"not-an-ip"}); err == nil { + t.Error("expected error for invalid whitelist entry") + } + if _, err := NewAccessController(AccessModeWhitelist, []string{"203.0.113.0/24"}); err != nil { + t.Errorf("unexpected error for valid whitelist: %v", err) + } + if _, err := NewAccessController(AccessModeIntranet, nil); err != nil { + t.Errorf("unexpected error for intranet mode: %v", err) + } +} + +func TestAccessController_Intranet(t *testing.T) { + ac, err := NewAccessController(AccessModeIntranet, nil) + if err != nil { + t.Fatalf("unexpected: %v", err) + } + cases := map[string]bool{ + "10.0.0.5:1234": true, + "172.16.3.4:1": true, + "192.168.1.1:80": true, + "127.0.0.1:9": true, + "[::1]:9": true, + "169.254.1.1:9": true, // link-local + "8.8.8.8:53": false, // public + "[2001:4860:4860::8888]:53": false, + } + for hp, want := range cases { + if got := ac.Allow(mustTCPAddr(t, hp)); got != want { + t.Errorf("Allow(%s) = %v, want %v", hp, got, want) + } + } +} + +func TestAccessController_Whitelist(t *testing.T) { + ac, err := NewAccessController(AccessModeWhitelist, []string{"203.0.113.0/24", "8.8.8.8", "fd00::/8"}) + if err != nil { + t.Fatalf("unexpected: %v", err) + } + cases := map[string]bool{ + "203.0.113.7:1": true, + "8.8.8.8:1": true, + "8.8.4.4:1": false, + "10.0.0.1:1": false, + "[fd00::1]:1": true, + "[fe80::1]:1": false, + } + for hp, want := range cases { + if got := ac.Allow(mustTCPAddr(t, hp)); got != want { + t.Errorf("Allow(%s) = %v, want %v", hp, got, want) + } + } +} + +func TestAccessController_FailClosed(t *testing.T) { + ac, _ := NewAccessController(AccessModeIntranet, nil) + if ac.Allow(nil) { + t.Error("nil addr must be denied") + } + var nilAC *AccessController + if nilAC.Allow(mustTCPAddr(t, "127.0.0.1:1")) { + t.Error("nil controller must deny") + } +} diff --git a/internal/netutil/downloader.go b/internal/netutil/downloader.go index cc6f2bbd..323913c9 100644 --- a/internal/netutil/downloader.go +++ b/internal/netutil/downloader.go @@ -39,6 +39,20 @@ type Downloader interface { Download(ctx context.Context, url string) ([]byte, error) } +// DownloadResponse contains the response body plus HTTP metadata relevant to +// callers that need subscription headers. +type DownloadResponse struct { + Body []byte + Header http.Header +} + +// MetadataDownloader fetches remote resources while preserving response +// metadata. Downloader remains the compatibility interface for body-only +// callers. +type MetadataDownloader interface { + DownloadWithMetadata(ctx context.Context, url string) (DownloadResponse, error) +} + // DirectDownloader downloads via a standard HTTP client (no proxy). type DirectDownloader struct { Client *http.Client @@ -64,6 +78,15 @@ func NewDirectDownloader(timeoutFn func() time.Duration, userAgentFn func() stri // Download fetches the URL and returns the response body. func (d *DirectDownloader) Download(ctx context.Context, url string) ([]byte, error) { + resp, err := d.DownloadWithMetadata(ctx, url) + if err != nil { + return nil, err + } + return resp.Body, nil +} + +// DownloadWithMetadata fetches the URL and returns the response body and headers. +func (d *DirectDownloader) DownloadWithMetadata(ctx context.Context, url string) (DownloadResponse, error) { if ctx == nil { ctx = context.Background() } @@ -76,7 +99,7 @@ func (d *DirectDownloader) Download(ctx context.Context, url string) ([]byte, er req, err := http.NewRequestWithContext(ctx, http.MethodGet, url, nil) if err != nil { - return nil, &NonRetryableError{Err: err} + return DownloadResponse{}, &NonRetryableError{Err: err} } userAgent := d.currentUserAgent() if userAgent != "" { @@ -89,19 +112,22 @@ func (d *DirectDownloader) Download(ctx context.Context, url string) ([]byte, er } resp, err := client.Do(req) if err != nil { - return nil, fmt.Errorf("downloader: %w", err) + return DownloadResponse{}, fmt.Errorf("downloader: %w", err) } defer resp.Body.Close() if resp.StatusCode != http.StatusOK { - return nil, &HTTPStatusError{StatusCode: resp.StatusCode, URL: url} + return DownloadResponse{}, &HTTPStatusError{StatusCode: resp.StatusCode, URL: url} } body, err := io.ReadAll(resp.Body) if err != nil { - return nil, fmt.Errorf("downloader: %w", err) + return DownloadResponse{}, fmt.Errorf("downloader: %w", err) } - return body, nil + return DownloadResponse{ + Body: body, + Header: resp.Header.Clone(), + }, nil } func (d *DirectDownloader) currentTimeout() time.Duration { diff --git a/internal/netutil/outbound_http.go b/internal/netutil/outbound_http.go index 27f4fb3a..c79289e2 100644 --- a/internal/netutil/outbound_http.go +++ b/internal/netutil/outbound_http.go @@ -44,8 +44,23 @@ func HTTPGetViaOutbound( url string, opts OutboundHTTPOptions, ) ([]byte, time.Duration, error) { + resp, latency, err := HTTPGetViaOutboundWithMetadata(ctx, outbound, url, opts) + if err != nil { + return nil, latency, err + } + return resp.Body, latency, nil +} + +// HTTPGetViaOutboundWithMetadata executes an HTTP GET through the provided +// outbound and preserves response headers. +func HTTPGetViaOutboundWithMetadata( + ctx context.Context, + outbound adapter.Outbound, + url string, + opts OutboundHTTPOptions, +) (DownloadResponse, time.Duration, error) { if outbound == nil { - return nil, 0, fmt.Errorf("outbound fetch: outbound is nil") + return DownloadResponse{}, 0, fmt.Errorf("outbound fetch: outbound is nil") } transport := &http.Transport{ @@ -70,7 +85,7 @@ func HTTPGetViaOutbound( req, err := http.NewRequestWithContext(ctx, http.MethodGet, url, nil) if err != nil { - return nil, 0, err + return DownloadResponse{}, 0, err } userAgent := opts.UserAgent @@ -93,20 +108,20 @@ func HTTPGetViaOutbound( resp, err := client.Do(req) if err != nil { - return nil, 0, err + return DownloadResponse{}, 0, err } defer resp.Body.Close() if opts.RequireStatusOK && resp.StatusCode != http.StatusOK { - return nil, latency, fmt.Errorf("outbound fetch: unexpected status %d from %s", resp.StatusCode, url) + return DownloadResponse{}, latency, fmt.Errorf("outbound fetch: unexpected status %d from %s", resp.StatusCode, url) } body, err := io.ReadAll(resp.Body) if err != nil { - return nil, latency, err + return DownloadResponse{}, latency, err } - return body, latency, nil + return DownloadResponse{Body: body, Header: resp.Header.Clone()}, latency, nil } // connCloseHook wraps a net.Conn and calls onClose exactly once on Close. diff --git a/internal/netutil/outbound_http_test.go b/internal/netutil/outbound_http_test.go index 77b3fa59..c02b520c 100644 --- a/internal/netutil/outbound_http_test.go +++ b/internal/netutil/outbound_http_test.go @@ -57,6 +57,31 @@ func TestHTTPGetViaOutbound_AllowNon200(t *testing.T) { } } +func TestHTTPGetViaOutboundWithMetadata_PreservesHeaders(t *testing.T) { + srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) { + w.Header().Set("Subscription-Userinfo", "upload=1; download=2; total=3") + _, _ = w.Write([]byte("subscription-body")) + })) + defer srv.Close() + + ob, err := (&testutil.StubOutboundBuilder{}).Build(nil) + if err != nil { + t.Fatalf("build outbound: %v", err) + } + resp, _, err := HTTPGetViaOutboundWithMetadata(context.Background(), ob, srv.URL, OutboundHTTPOptions{ + RequireStatusOK: true, + }) + if err != nil { + t.Fatalf("expected metadata response, got: %v", err) + } + if string(resp.Body) != "subscription-body" { + t.Fatalf("unexpected body %q", string(resp.Body)) + } + if got := resp.Header.Get("Subscription-Userinfo"); got != "upload=1; download=2; total=3" { + t.Fatalf("unexpected subscription userinfo %q", got) + } +} + func TestConnCloseHook_CloseIsIdempotentAndConcurrentSafe(t *testing.T) { client, server := net.Pipe() defer server.Close() diff --git a/internal/netutil/retry_downloader.go b/internal/netutil/retry_downloader.go index dfb3d68e..58b32bee 100644 --- a/internal/netutil/retry_downloader.go +++ b/internal/netutil/retry_downloader.go @@ -17,30 +17,41 @@ type RetryDownloader struct { ProxyAttemptTimeout time.Duration NodePicker func(target string) (node.Hash, error) ProxyFetch func(ctx context.Context, hash node.Hash, url string) ([]byte, error) + ProxyFetchMetadata func(ctx context.Context, hash node.Hash, url string) (DownloadResponse, error) } // Download attempts direct download first, then falls back to proxy retries. func (r *RetryDownloader) Download(ctx context.Context, url string) ([]byte, error) { + resp, err := r.DownloadWithMetadata(ctx, url) + if err != nil { + return nil, err + } + return resp.Body, nil +} + +// DownloadWithMetadata attempts direct download first, then falls back to proxy +// retries while preserving response headers when the selected path provides them. +func (r *RetryDownloader) DownloadWithMetadata(ctx context.Context, url string) (DownloadResponse, error) { if ctx == nil { ctx = context.Background() } - body, err := r.Direct.Download(ctx, url) + resp, err := r.directDownloadWithMetadata(ctx, url) if err == nil { - return body, nil + return resp, nil } if !shouldRetryViaProxy(err) { - return nil, err + return DownloadResponse{}, err } - if r.NodePicker == nil || r.ProxyFetch == nil { - return nil, err + if r.NodePicker == nil || (r.ProxyFetch == nil && r.ProxyFetchMetadata == nil) { + return DownloadResponse{}, err } // Respect caller cancellation/deadline: don't extend lifecycle beyond caller ctx. if ctx.Err() != nil { - return nil, err + return DownloadResponse{}, err } attemptTimeout := r.proxyAttemptTimeout() @@ -48,7 +59,7 @@ func (r *RetryDownloader) Download(ctx context.Context, url string) ([]byte, err // Retry 2 times with random proxy nodes. for i := 0; i < 2; i++ { if ctx.Err() != nil { - return nil, err + return DownloadResponse{}, err } hash, pickErr := r.NodePicker(url) @@ -61,14 +72,36 @@ func (r *RetryDownloader) Download(ctx context.Context, url string) ([]byte, err if attemptTimeout > 0 { attemptCtx, cancel = context.WithTimeout(ctx, attemptTimeout) } - body, fetchErr := r.ProxyFetch(attemptCtx, hash, url) + resp, fetchErr := r.proxyFetchWithMetadata(attemptCtx, hash, url) cancel() if fetchErr == nil { - return body, nil + return resp, nil } } - return nil, err + return DownloadResponse{}, err +} + +func (r *RetryDownloader) directDownloadWithMetadata(ctx context.Context, url string) (DownloadResponse, error) { + if direct, ok := r.Direct.(MetadataDownloader); ok { + return direct.DownloadWithMetadata(ctx, url) + } + body, err := r.Direct.Download(ctx, url) + if err != nil { + return DownloadResponse{}, err + } + return DownloadResponse{Body: body}, nil +} + +func (r *RetryDownloader) proxyFetchWithMetadata(ctx context.Context, hash node.Hash, url string) (DownloadResponse, error) { + if r.ProxyFetchMetadata != nil { + return r.ProxyFetchMetadata(ctx, hash, url) + } + body, err := r.ProxyFetch(ctx, hash, url) + if err != nil { + return DownloadResponse{}, err + } + return DownloadResponse{Body: body}, nil } func shouldRetryViaProxy(err error) bool { diff --git a/internal/netutil/retry_downloader_test.go b/internal/netutil/retry_downloader_test.go index 68a739df..8e47e824 100644 --- a/internal/netutil/retry_downloader_test.go +++ b/internal/netutil/retry_downloader_test.go @@ -3,6 +3,7 @@ package netutil import ( "context" "errors" + "net/http" "strconv" "testing" "time" @@ -136,6 +137,40 @@ func TestRetryDownloader_RetryOnNetworkError(t *testing.T) { } } +func TestRetryDownloader_DownloadWithMetadataPreservesProxyHeaders(t *testing.T) { + var pickerCalls, proxyCalls int + + r := &RetryDownloader{ + Direct: downloaderFunc(func(_ context.Context, _ string) ([]byte, error) { + return nil, context.DeadlineExceeded + }), + NodePicker: func(_ string) (node.Hash, error) { + pickerCalls++ + return node.HashFromRawOptions([]byte(`{"id":"retry-node-metadata"}`)), nil + }, + ProxyFetchMetadata: func(_ context.Context, _ node.Hash, _ string) (DownloadResponse, error) { + proxyCalls++ + header := http.Header{} + header.Set("Subscription-Userinfo", "upload=1; download=2; total=3") + return DownloadResponse{Body: []byte("via-proxy"), Header: header}, nil + }, + } + + resp, err := r.DownloadWithMetadata(context.Background(), "https://example.com") + if err != nil { + t.Fatalf("expected proxy metadata success, got %v", err) + } + if string(resp.Body) != "via-proxy" { + t.Fatalf("unexpected body %q", string(resp.Body)) + } + if got := resp.Header.Get("Subscription-Userinfo"); got != "upload=1; download=2; total=3" { + t.Fatalf("unexpected subscription userinfo %q", got) + } + if pickerCalls != 1 || proxyCalls != 1 { + t.Fatalf("expected single successful retry, got picker=%d proxy=%d", pickerCalls, proxyCalls) + } +} + func TestRetryDownloader_NoRetryWhenContextDone(t *testing.T) { var pickerCalls int ctx, cancel := context.WithCancel(context.Background()) diff --git a/internal/node/entry.go b/internal/node/entry.go index 692fdd7f..d190a01f 100644 --- a/internal/node/entry.go +++ b/internal/node/entry.go @@ -43,6 +43,10 @@ type NodeEntry struct { LastEgressUpdateAttempt atomic.Int64 LatencyTable *LatencyTable // per-domain latency stats; nil if not initialized + // Admin-controlled disable flag. When true, the node is excluded from all + // platform views regardless of subscription state. Toggled via the API. + ManuallyDisabled atomic.Bool + // Outbound instance for this node. Outbound atomic.Pointer[adapter.Outbound] } @@ -153,6 +157,33 @@ func (e *NodeEntry) MatchRegexs(regexes []*regexp.Regexp, subLookup SubLookupFun return false } +// MatchAnyRegex reports whether any tag from any enabled subscription matches +// any regex. Empty regexes or a nil lookup never exclude anything. +func (e *NodeEntry) MatchAnyRegex(regexes []*regexp.Regexp, subLookup SubLookupFunc) bool { + if len(regexes) == 0 || subLookup == nil { + return false + } + + e.mu.RLock() + subs := make([]string, len(e.subscriptionIDs)) + copy(subs, e.subscriptionIDs) + e.mu.RUnlock() + + for _, subID := range subs { + name, enabled, tags, ok := subLookup(subID, e.Hash) + if !ok || !enabled { + continue + } + for _, tag := range tags { + candidate := name + "/" + tag + if matchesAny(candidate, regexes) { + return true + } + } + } + return false +} + // HasEnabledSubscription reports whether the node currently has at least one // enabled subscription reference, based on subLookup. // @@ -194,6 +225,15 @@ func matchesAll(s string, regexes []*regexp.Regexp) bool { return true } +func matchesAny(s string, regexes []*regexp.Regexp) bool { + for _, re := range regexes { + if re.MatchString(s) { + return true + } + } + return false +} + // --- Condition helpers for platform filtering --- // IsCircuitOpen returns true if the node is currently circuit-broken. @@ -201,6 +241,14 @@ func (e *NodeEntry) IsCircuitOpen() bool { return e.CircuitOpenSince.Load() != 0 } +// IsManuallyDisabled reports whether an admin has flagged this node as disabled. +func (e *NodeEntry) IsManuallyDisabled() bool { + if e == nil { + return false + } + return e.ManuallyDisabled.Load() +} + // HasLatency returns true if the node has at least one latency record. func (e *NodeEntry) HasLatency() bool { return e.LatencyTable != nil && e.LatencyTable.Size() > 0 diff --git a/internal/node/entry_test.go b/internal/node/entry_test.go index 20927c87..49fd09d3 100644 --- a/internal/node/entry_test.go +++ b/internal/node/entry_test.go @@ -168,6 +168,30 @@ func TestNodeEntry_MatchRegexs_MultiSub(t *testing.T) { } } +func TestNodeEntry_MatchAnyRegex_DisabledSubSkipped(t *testing.T) { + h := HashFromRawOptions([]byte(`{"type":"ss"}`)) + e := NewNodeEntry(h, nil, time.Now(), 0) + e.AddSubscriptionID("sub-disabled") + e.AddSubscriptionID("sub-enabled") + + lookup := func(subID string, hash Hash) (string, bool, []string, bool) { + switch subID { + case "sub-disabled": + return "Disabled", false, []string{"bad-node"}, true + case "sub-enabled": + return "Enabled", true, []string{"good-node"}, true + } + return "", false, nil, false + } + + if e.MatchAnyRegex([]*regexp.Regexp{regexp.MustCompile("bad")}, lookup) { + t.Fatal("disabled subscriptions should not trigger exclude regex") + } + if !e.MatchAnyRegex([]*regexp.Regexp{regexp.MustCompile("good")}, lookup) { + t.Fatal("enabled subscription tag should trigger exclude regex") + } +} + func TestNodeEntry_HasEnabledSubscription(t *testing.T) { h := HashFromRawOptions([]byte(`{"type":"ss"}`)) e := NewNodeEntry(h, nil, time.Now(), 0) diff --git a/internal/outbound/manager.go b/internal/outbound/manager.go index bccc17e4..55a59ce4 100644 --- a/internal/outbound/manager.go +++ b/internal/outbound/manager.go @@ -134,15 +134,30 @@ func (m *OutboundManager) FetchWithUserAgent( url string, userAgent string, ) ([]byte, time.Duration, error) { + resp, latency, err := m.FetchWithUserAgentMetadata(ctx, hash, url, userAgent) + if err != nil { + return nil, latency, err + } + return resp.Body, latency, nil +} + +// FetchWithUserAgentMetadata executes HTTP request using the node's outbound, +// applies the given User-Agent if non-empty, and preserves response headers. +func (m *OutboundManager) FetchWithUserAgentMetadata( + ctx context.Context, + hash node.Hash, + url string, + userAgent string, +) (netutil.DownloadResponse, time.Duration, error) { entry, ok := m.pool.GetEntry(hash) if !ok { - return nil, 0, errors.New("node not found") + return netutil.DownloadResponse{}, 0, errors.New("node not found") } outboundPtr := entry.Outbound.Load() // *adapter.Outbound if outboundPtr == nil { - return nil, 0, ErrOutboundNotReady + return netutil.DownloadResponse{}, 0, ErrOutboundNotReady } - return netutil.HTTPGetViaOutbound(ctx, *outboundPtr, url, netutil.OutboundHTTPOptions{ + return netutil.HTTPGetViaOutboundWithMetadata(ctx, *outboundPtr, url, netutil.OutboundHTTPOptions{ RequireStatusOK: true, UserAgent: userAgent, }) diff --git a/internal/platform/model_codec.go b/internal/platform/model_codec.go index 8220e8c3..2fb985b6 100644 --- a/internal/platform/model_codec.go +++ b/internal/platform/model_codec.go @@ -30,23 +30,33 @@ func ValidateRegionFilters(regionFilters []string) error { return nil } -// CompileRegexFilters compiles regex filters in order. -func CompileRegexFilters(regexFilters []string) ([]*regexp.Regexp, error) { +func compileRegexFilters(field string, regexFilters []string) ([]*regexp.Regexp, error) { compiled := make([]*regexp.Regexp, 0, len(regexFilters)) for i, re := range regexFilters { c, err := regexp.Compile(re) if err != nil { - return nil, fmt.Errorf("regex_filters[%d]: invalid regex: %v", i, err) + return nil, fmt.Errorf("%s[%d]: invalid regex: %v", field, i, err) } compiled = append(compiled, c) } return compiled, nil } +// CompileRegexFilters compiles regex filters in order. +func CompileRegexFilters(regexFilters []string) ([]*regexp.Regexp, error) { + return compileRegexFilters("regex_filters", regexFilters) +} + +// CompileRegexExcludeFilters compiles regex exclusion filters in order. +func CompileRegexExcludeFilters(regexFilters []string) ([]*regexp.Regexp, error) { + return compileRegexFilters("regex_exclude_filters", regexFilters) +} + // NewConfiguredPlatform builds a runtime platform with non-filter settings applied. func NewConfiguredPlatform( id, name string, regexFilters []*regexp.Regexp, + regexExcludeFilters []*regexp.Regexp, regionFilters []string, stickyTTLNs int64, missAction string, @@ -61,6 +71,7 @@ func NewConfiguredPlatform( fixedHeaders = nil } plat := NewPlatform(id, name, regexFilters, regionFilters) + plat.RegexExcludeFilters = regexExcludeFilters plat.StickyTTLNs = stickyTTLNs plat.ReverseProxyMissAction = missAction plat.ReverseProxyEmptyAccountBehavior = emptyAccountBehavior @@ -80,12 +91,25 @@ func CompileModelRegexFilters(platformID string, regexFilters []string) ([]*rege return compiled, nil } +// CompileModelRegexExcludeFilters compiles regex exclusion filters from persisted model values. +func CompileModelRegexExcludeFilters(platformID string, regexFilters []string) ([]*regexp.Regexp, error) { + compiled, err := CompileRegexExcludeFilters(regexFilters) + if err != nil { + return nil, fmt.Errorf("decode platform %s regex_exclude_filters: %w", platformID, err) + } + return compiled, nil +} + // BuildFromModel builds a runtime platform from a persisted model.Platform. func BuildFromModel(mp model.Platform) (*Platform, error) { regexFilters, err := CompileModelRegexFilters(mp.ID, mp.RegexFilters) if err != nil { return nil, err } + regexExcludeFilters, err := CompileModelRegexExcludeFilters(mp.ID, mp.RegexExcludeFilters) + if err != nil { + return nil, err + } if err := ValidateRegionFilters(mp.RegionFilters); err != nil { return nil, err } @@ -117,6 +141,7 @@ func BuildFromModel(mp model.Platform) (*Platform, error) { mp.ID, mp.Name, regexFilters, + regexExcludeFilters, append([]string(nil), mp.RegionFilters...), mp.StickyTTLNs, string(missAction), diff --git a/internal/platform/model_codec_test.go b/internal/platform/model_codec_test.go index 3a4cec1b..8ff602a1 100644 --- a/internal/platform/model_codec_test.go +++ b/internal/platform/model_codec_test.go @@ -14,6 +14,7 @@ func TestBuildFromModel_Success(t *testing.T) { Name: "Platform-1", StickyTTLNs: 3600, RegexFilters: []string{`^us-.*$`}, + RegexExcludeFilters: []string{`bad`}, RegionFilters: []string{"us", "jp"}, ReverseProxyMissAction: "REJECT", ReverseProxyEmptyAccountBehavior: "FIXED_HEADER", @@ -59,6 +60,9 @@ func TestBuildFromModel_Success(t *testing.T) { if len(plat.RegexFilters) != 1 || !plat.RegexFilters[0].MatchString("us-node") { t.Fatalf("regex filters not compiled as expected: %+v", plat.RegexFilters) } + if len(plat.RegexExcludeFilters) != 1 || !plat.RegexExcludeFilters[0].MatchString("bad-node") { + t.Fatalf("regex exclude filters not compiled as expected: %+v", plat.RegexExcludeFilters) + } if len(plat.RegionFilters) != 2 || plat.RegionFilters[0] != "us" || plat.RegionFilters[1] != "jp" { t.Fatalf("region filters mismatch: %+v", plat.RegionFilters) } @@ -77,6 +81,19 @@ func TestBuildFromModel_InvalidRegex(t *testing.T) { } } +func TestBuildFromModel_InvalidRegexExclude(t *testing.T) { + _, err := BuildFromModel(model.Platform{ + ID: "plat-1", + RegexExcludeFilters: []string{`(broken`}, + }) + if err == nil { + t.Fatal("expected regex exclude decode error") + } + if !strings.Contains(err.Error(), "regex_exclude_filters") { + t.Fatalf("unexpected error: %v", err) + } +} + func TestBuildFromModel_InvalidRegionFilters(t *testing.T) { _, err := BuildFromModel(model.Platform{ ID: "plat-1", @@ -188,6 +205,16 @@ func TestCompileRegexFilters_Invalid(t *testing.T) { } } +func TestCompileRegexExcludeFilters_Invalid(t *testing.T) { + _, err := CompileRegexExcludeFilters([]string{"(broken"}) + if err == nil { + t.Fatal("expected compile error") + } + if !strings.Contains(err.Error(), "regex_exclude_filters[0]") { + t.Fatalf("unexpected error: %v", err) + } +} + func TestValidateRegionFilters_Invalid(t *testing.T) { err := ValidateRegionFilters([]string{"US"}) if err == nil { diff --git a/internal/platform/platform.go b/internal/platform/platform.go index a17dd409..0e8f7e7a 100644 --- a/internal/platform/platform.go +++ b/internal/platform/platform.go @@ -29,8 +29,9 @@ type Platform struct { Name string // Filter configuration. - RegexFilters []*regexp.Regexp - RegionFilters []string // lowercase ISO codes, supports negation "!xx" + RegexFilters []*regexp.Regexp + RegexExcludeFilters []*regexp.Regexp + RegionFilters []string // lowercase ISO codes, supports negation "!xx" // Other config fields. StickyTTLNs int64 @@ -114,6 +115,11 @@ func (p *Platform) evaluateNode( subLookup node.SubLookupFunc, geoLookup GeoLookupFunc, ) bool { + // -1. Admin manually disabled — short-circuit before any other check. + if entry.IsManuallyDisabled() { + return false + } + // 0. Disabled nodes are never routable. if entry.IsDisabledBySubscriptions(subLookup) { return false @@ -128,6 +134,9 @@ func (p *Platform) evaluateNode( if !entry.MatchRegexs(p.RegexFilters, subLookup) { return false } + if entry.MatchAnyRegex(p.RegexExcludeFilters, subLookup) { + return false + } // 3. Egress IP must be known. egressIP := entry.GetEgressIP() diff --git a/internal/platform/platform_test.go b/internal/platform/platform_test.go index 49fe3368..5a5a81bc 100644 --- a/internal/platform/platform_test.go +++ b/internal/platform/platform_test.go @@ -137,6 +137,21 @@ func TestPlatform_EvaluateNode_RegexFilter(t *testing.T) { } } +func TestPlatform_EvaluateNode_RegexExcludeFilter(t *testing.T) { + p := NewPlatform("p1", "Test", []*regexp.Regexp{regexp.MustCompile("us")}, nil) + p.RegexExcludeFilters = []*regexp.Regexp{regexp.MustCompile("fast")} + h := makeHash(`{"type":"ss"}`) + entry := makeFullyRoutableEntry(h, "sub1") + + p.FullRebuild(func(fn func(node.Hash, *node.NodeEntry) bool) { + fn(h, entry) + }, alwaysLookup, usGeoLookup) + + if p.View().Size() != 0 { + t.Fatal("node matching exclude regex should not be routable") + } +} + func TestPlatform_EvaluateNode_RegionFilter(t *testing.T) { p := NewPlatform("p1", "Test", nil, []string{"us"}) h := makeHash(`{"type":"ss"}`) diff --git a/internal/probe/manager.go b/internal/probe/manager.go index d3c9a642..1162f784 100644 --- a/internal/probe/manager.go +++ b/internal/probe/manager.go @@ -5,6 +5,7 @@ import ( "log" "math/rand/v2" "net/netip" + "strings" "sync" "sync/atomic" "time" @@ -72,11 +73,23 @@ type ProbeManager struct { const ( egressTraceURL = "https://cloudflare.com/cdn-cgi/trace" - egressTraceDomain = "cloudflare.com" + egressIPifyURL = "https://api.ipify.org" + egressCheckIPURL = "https://checkip.amazonaws.com" defaultLatencyTestURL = "https://www.gstatic.com/generate_204" defaultQueueCap = 1024 ) +type egressProbeTarget struct { + url string + parse func([]byte) (netip.Addr, *string, error) +} + +var egressProbeTargets = []egressProbeTarget{ + {url: egressTraceURL, parse: ParseCloudflareTrace}, + {url: egressIPifyURL, parse: ParsePlainIP}, + {url: egressCheckIPURL, parse: ParsePlainIP}, +} + type probePriority uint8 const ( @@ -360,7 +373,7 @@ func (m *ProbeManager) ProbeEgressSync(hash node.Hash) (*EgressProbeResult, erro m.onProbeEvent("egress") } - ip, stage, err := m.performEgressProbe(hash) + ip, latencyDomain, stage, err := m.performEgressProbe(hash) if err != nil { if stage == egressProbeParseError { return nil, fmt.Errorf("parse egress IP: %w", err) @@ -368,10 +381,10 @@ func (m *ProbeManager) ProbeEgressSync(hash node.Hash) (*EgressProbeResult, erro return nil, fmt.Errorf("egress probe failed: %w", err) } - // Read back EWMA for cloudflare.com from the latency table. + // Read back EWMA for the probe target that succeeded. var ewmaMs float64 if entry.LatencyTable != nil { - if stats, ok := entry.LatencyTable.GetDomainStats(egressTraceDomain); ok { + if stats, ok := entry.LatencyTable.GetDomainStats(latencyDomain); ok { ewmaMs = float64(stats.Ewma) / float64(time.Millisecond) } } @@ -732,8 +745,8 @@ func (m *ProbeManager) isLatencyProbeDue( return !now.Before(authorityDeadline) } -// probeEgress performs a single egress probe against a node via Cloudflare trace. -// Writes back: RecordResult, RecordLatency (cloudflare.com), UpdateNodeEgressIP. +// probeEgress performs a single egress probe against a node. +// Writes back: RecordResult, RecordLatency, UpdateNodeEgressIP. func (m *ProbeManager) probeEgress(hash node.Hash, entry *node.NodeEntry) { if m.fetcher == nil { return @@ -748,7 +761,7 @@ func (m *ProbeManager) probeEgress(hash node.Hash, entry *node.NodeEntry) { m.onProbeEvent("egress") } - _, stage, err := m.performEgressProbe(hash) + _, _, stage, err := m.performEgressProbe(hash) if err != nil { if stage == egressProbeParseError { log.Printf("[probe] parse egress IP for %s: %v", hash.Hex(), err) @@ -781,26 +794,44 @@ func (m *ProbeManager) probeLatency(hash node.Hash, entry *node.NodeEntry, testU } } -func (m *ProbeManager) performEgressProbe(hash node.Hash) (netip.Addr, egressProbeErrorStage, error) { - body, latency, err := m.fetcher(hash, egressTraceURL) - if err != nil { - m.pool.RecordResult(hash, false) - m.pool.UpdateNodeEgressIP(hash, nil, nil) - return netip.Addr{}, egressProbeFetchError, err - } +func (m *ProbeManager) performEgressProbe(hash node.Hash) (netip.Addr, string, egressProbeErrorStage, error) { + var failures []string + var fetchFailures, parseFailures int - m.pool.RecordResult(hash, true) - if latency > 0 { - m.pool.RecordLatency(hash, egressTraceDomain, &latency) + for _, target := range egressProbeTargets { + body, latency, err := m.fetcher(hash, target.url) + if err != nil { + fetchFailures++ + failures = append(failures, fmt.Sprintf("%s fetch: %v", target.url, err)) + continue + } + + ip, loc, err := target.parse(body) + if err != nil { + parseFailures++ + failures = append(failures, fmt.Sprintf("%s parse: %v", target.url, err)) + continue + } + + m.pool.RecordResult(hash, true) + domain := netutil.ExtractDomain(target.url) + if latency > 0 { + m.pool.RecordLatency(hash, domain, &latency) + } + m.pool.UpdateNodeEgressIP(hash, &ip, loc) + return ip, domain, egressProbeNoError, nil } - ip, loc, err := ParseCloudflareTrace(body) - if err != nil { - m.pool.UpdateNodeEgressIP(hash, nil, nil) - return netip.Addr{}, egressProbeParseError, err + if len(failures) == 0 { + failures = append(failures, "no egress probe targets configured") + } + stage := egressProbeFetchError + if parseFailures > 0 && fetchFailures == 0 { + stage = egressProbeParseError } - m.pool.UpdateNodeEgressIP(hash, &ip, loc) - return ip, egressProbeNoError, nil + m.pool.RecordResult(hash, false) + m.pool.UpdateNodeEgressIP(hash, nil, nil) + return netip.Addr{}, "", stage, fmt.Errorf("all egress probe targets failed: %s", strings.Join(failures, "; ")) } func (m *ProbeManager) performLatencyProbe(hash node.Hash, testURL string) error { diff --git a/internal/probe/manager_test.go b/internal/probe/manager_test.go index d58ee052..03107d01 100644 --- a/internal/probe/manager_test.go +++ b/internal/probe/manager_test.go @@ -3,6 +3,7 @@ package probe import ( "errors" "net/netip" + "strings" "sync" "sync/atomic" "testing" @@ -114,6 +115,115 @@ func TestProbeEgress_Failure(t *testing.T) { } } +func TestProbeEgress_FallbackSuccess(t *testing.T) { + pool := topology.NewGlobalNodePool(topology.PoolConfig{ + MaxLatencyTableEntries: 16, + MaxConsecutiveFailures: func() int { return 3 }, + }) + + hash := node.HashFromRawOptions([]byte(`{"type":"egress-fallback"}`)) + pool.AddNodeFromSub(hash, []byte(`{"type":"egress-fallback"}`), "sub1") + + entry, ok := pool.GetEntry(hash) + if !ok { + t.Fatal("entry not found") + } + storeOutbound(entry) + + var gotURLs []string + mgr := NewProbeManager(ProbeConfig{ + Pool: pool, + Fetcher: func(_ node.Hash, url string) ([]byte, time.Duration, error) { + gotURLs = append(gotURLs, url) + switch url { + case egressTraceURL: + return nil, 0, errors.New("cloudflare blocked") + case egressIPifyURL: + return []byte("198.51.100.44\n"), 55 * time.Millisecond, nil + default: + return nil, 0, errors.New("unexpected URL") + } + }, + }) + + result, err := mgr.ProbeEgressSync(hash) + if err != nil { + t.Fatalf("ProbeEgressSync: %v", err) + } + + wantURLs := []string{egressTraceURL, egressIPifyURL} + if len(gotURLs) != len(wantURLs) { + t.Fatalf("probe URLs: got %v, want %v", gotURLs, wantURLs) + } + for i := range wantURLs { + if gotURLs[i] != wantURLs[i] { + t.Fatalf("probe URL[%d]: got %q, want %q", i, gotURLs[i], wantURLs[i]) + } + } + if result.EgressIP != "198.51.100.44" { + t.Fatalf("egress IP: got %q, want 198.51.100.44", result.EgressIP) + } + if result.LatencyEwmaMs != 55 { + t.Fatalf("latency_ewma_ms: got %f, want 55", result.LatencyEwmaMs) + } + if entry.FailureCount.Load() != 0 { + t.Fatalf("expected 0 failures, got %d", entry.FailureCount.Load()) + } +} + +func TestProbeEgress_AllTargetsFailedAggregatesErrors(t *testing.T) { + pool := topology.NewGlobalNodePool(topology.PoolConfig{ + MaxLatencyTableEntries: 16, + MaxConsecutiveFailures: func() int { return 3 }, + }) + + hash := node.HashFromRawOptions([]byte(`{"type":"egress-all-fail"}`)) + pool.AddNodeFromSub(hash, []byte(`{"type":"egress-all-fail"}`), "sub1") + + entry, ok := pool.GetEntry(hash) + if !ok { + t.Fatal("entry not found") + } + storeOutbound(entry) + + mgr := NewProbeManager(ProbeConfig{ + Pool: pool, + Fetcher: func(_ node.Hash, url string) ([]byte, time.Duration, error) { + switch url { + case egressTraceURL: + return nil, 0, errors.New("cloudflare blocked") + case egressIPifyURL: + return []byte("not an ip"), 10 * time.Millisecond, nil + case egressCheckIPURL: + return nil, 0, errors.New("aws blocked") + default: + return nil, 0, errors.New("unexpected URL") + } + }, + }) + + _, err := mgr.ProbeEgressSync(hash) + if err == nil { + t.Fatal("expected error") + } + msg := err.Error() + for _, want := range []string{ + egressTraceURL + " fetch", + egressIPifyURL + " parse", + egressCheckIPURL + " fetch", + } { + if !strings.Contains(msg, want) { + t.Fatalf("error %q does not contain %q", msg, want) + } + } + if entry.FailureCount.Load() != 1 { + t.Fatalf("expected 1 failure, got %d", entry.FailureCount.Load()) + } + if got := entry.GetEgressIP(); got.IsValid() { + t.Fatalf("egress IP should be empty, got %v", got) + } +} + // TestProbeEgress_CircuitBreak verifies consecutive failures trigger circuit break. func TestProbeEgress_CircuitBreak(t *testing.T) { pool := topology.NewGlobalNodePool(topology.PoolConfig{ @@ -974,3 +1084,16 @@ func TestParseCloudflareTrace_NoIP(t *testing.T) { t.Fatal("expected error when ip field is missing") } } + +func TestParsePlainIP_TrimSpace(t *testing.T) { + addr, loc, err := ParsePlainIP([]byte(" 2001:db8::1\n")) + if err != nil { + t.Fatal(err) + } + if addr != netip.MustParseAddr("2001:db8::1") { + t.Fatalf("got %v, want 2001:db8::1", addr) + } + if loc != nil { + t.Fatalf("loc: got %v, want nil", loc) + } +} diff --git a/internal/probe/trace.go b/internal/probe/trace.go index 8ae9673c..f8ccb220 100644 --- a/internal/probe/trace.go +++ b/internal/probe/trace.go @@ -50,3 +50,12 @@ func ParseCloudflareTrace(body []byte) (netip.Addr, *string, error) { } return ip, &locValue, nil } + +// ParsePlainIP parses probe endpoints that return only the caller IP. +func ParsePlainIP(body []byte) (netip.Addr, *string, error) { + addr, err := netip.ParseAddr(strings.TrimSpace(string(body))) + if err != nil { + return netip.Addr{}, nil, err + } + return addr, nil, nil +} diff --git a/internal/proxy/forward.go b/internal/proxy/forward.go index 0587225b..b666fd47 100644 --- a/internal/proxy/forward.go +++ b/internal/proxy/forward.go @@ -28,6 +28,13 @@ type ForwardProxyConfig struct { OutboundTransport OutboundTransportConfig TransportPool *OutboundTransportPool ProxyBypassRules []string + + // Free-mode (password-less) port support. When ForcedPlatform is non-empty, + // the proxy skips Proxy-Authorization and routes every request as + // (ForcedPlatform, account). With AccountFromLocalPort, the account is + // derived from the connection's local port so each port pins its own lease. + ForcedPlatform string + AccountFromLocalPort bool } // ForwardProxy implements an HTTP forward proxy with Proxy-Authorization @@ -46,6 +53,9 @@ type ForwardProxy struct { directTransport *http.Transport directOnce sync.Once bypass *TargetBypassMatcher + + forcedPlatform string + accountFromLocalPort bool } // NewForwardProxy creates a new forward proxy handler. @@ -74,6 +84,9 @@ func NewForwardProxy(cfg ForwardProxyConfig) *ForwardProxy { transportConfig: transportCfg, transportPool: transportPool, bypass: NewTargetBypassMatcher(cfg.ProxyBypassRules), + + forcedPlatform: cfg.ForcedPlatform, + accountFromLocalPort: cfg.AccountFromLocalPort, } } @@ -111,6 +124,20 @@ func (p *ForwardProxy) effectiveAuthVersion() config.AuthVersion { return config.AuthVersionLegacyV0 } +// resolveIdentity returns the (platformName, account) for a request. In +// free-mode (ForcedPlatform set) it bypasses Proxy-Authorization and derives a +// per-port sticky account; otherwise it parses Proxy-Authorization as usual. +func (p *ForwardProxy) resolveIdentity(r *http.Request) (string, string, *ProxyError) { + if p.forcedPlatform != "" { + account := "" + if p.accountFromLocalPort { + account = freeAccountFromRequest(r) + } + return p.forcedPlatform, account, nil + } + return p.authenticate(r) +} + // authenticate parses Proxy-Authorization and returns (platformName, account, error). func (p *ForwardProxy) authenticate(r *http.Request) (string, string, *ProxyError) { if p.effectiveAuthVersion() == config.AuthVersionV1 { @@ -305,7 +332,7 @@ func prepareForwardOutboundRequest(in *http.Request) *http.Request { } func (p *ForwardProxy) handleHTTP(w http.ResponseWriter, r *http.Request) { - platName, account, authErr := p.authenticate(r) + platName, account, authErr := p.resolveIdentity(r) if authErr != nil { writeProxyError(w, authErr) return @@ -403,7 +430,7 @@ func (p *ForwardProxy) handleHTTP(w http.ResponseWriter, r *http.Request) { func (p *ForwardProxy) handleCONNECT(w http.ResponseWriter, r *http.Request) { target := r.Host - platName, account, authErr := p.authenticate(r) + platName, account, authErr := p.resolveIdentity(r) if authErr != nil { writeProxyError(w, authErr) return diff --git a/internal/proxy/free_port.go b/internal/proxy/free_port.go new file mode 100644 index 00000000..f269c492 --- /dev/null +++ b/internal/proxy/free_port.go @@ -0,0 +1,44 @@ +package proxy + +import ( + "net" + "net/http" + "strconv" +) + +// freeAccountPrefix namespaces password-less ("free-mode") port accounts so +// they never collide with real business accounts and are recognizable in the +// lease panel. +const freeAccountPrefix = "__free_" + +// freeAccountFromPort derives the sticky-lease account for a free-mode port. +// Each port maps to a distinct account, so each port pins its own sticky lease +// (and thus its own egress node/IP) within the bound platform. +func freeAccountFromPort(port int) string { + return freeAccountPrefix + strconv.Itoa(port) +} + +// freeAccountFromAddr derives the account from a local listen address +// (host:port). Falls back to the bare prefix when the port cannot be parsed. +func freeAccountFromAddr(local net.Addr) string { + if local != nil { + if _, portStr, err := net.SplitHostPort(local.String()); err == nil { + if port, err := strconv.Atoi(portStr); err == nil { + return freeAccountFromPort(port) + } + } + } + return freeAccountPrefix +} + +// freeAccountFromRequest derives the account for an HTTP forward-proxy request +// served on a free-mode port, using the connection's local address exposed via +// http.LocalAddrContextKey. +func freeAccountFromRequest(r *http.Request) string { + if r != nil { + if local, ok := r.Context().Value(http.LocalAddrContextKey).(net.Addr); ok { + return freeAccountFromAddr(local) + } + } + return freeAccountPrefix +} diff --git a/internal/proxy/free_port_test.go b/internal/proxy/free_port_test.go new file mode 100644 index 00000000..7c1530b2 --- /dev/null +++ b/internal/proxy/free_port_test.go @@ -0,0 +1,21 @@ +package proxy + +import ( + "net" + "testing" +) + +func TestFreeAccountFromPort(t *testing.T) { + if got := freeAccountFromPort(21000); got != "__free_21000" { + t.Errorf("freeAccountFromPort(21000) = %q, want __free_21000", got) + } +} + +func TestFreeAccountFromAddr(t *testing.T) { + if got := freeAccountFromAddr(&net.TCPAddr{Port: 21005}); got != "__free_21005" { + t.Errorf("freeAccountFromAddr(:21005) = %q, want __free_21005", got) + } + if got := freeAccountFromAddr(nil); got != freeAccountPrefix { + t.Errorf("freeAccountFromAddr(nil) = %q, want %q", got, freeAccountPrefix) + } +} diff --git a/internal/proxy/socks5.go b/internal/proxy/socks5.go index 737bbc71..c5191c32 100644 --- a/internal/proxy/socks5.go +++ b/internal/proxy/socks5.go @@ -45,6 +45,13 @@ type Socks5InboundConfig struct { Events EventEmitter MetricsSink MetricsEventSink ProxyBypassRules []string + + // Free-mode (password-less) port support. When ForcedPlatform is non-empty, + // any client-supplied identity is ignored and every session routes as + // (ForcedPlatform, account); with AccountFromLocalPort the account is + // derived from the connection's local port so each port pins its own lease. + ForcedPlatform string + AccountFromLocalPort bool } // Socks5Inbound implements SOCKS5 CONNECT over a raw TCP connection. @@ -53,6 +60,9 @@ type Socks5Inbound struct { authVersion config.AuthVersion tunnel tunnelDeps events EventEmitter + + forcedPlatform string + accountFromLocalPort bool } type socks5HandshakeResult struct { @@ -83,6 +93,9 @@ func NewSocks5Inbound(cfg Socks5InboundConfig) *Socks5Inbound { bypass: NewTargetBypassMatcher(cfg.ProxyBypassRules), }, events: ev, + + forcedPlatform: cfg.ForcedPlatform, + accountFromLocalPort: cfg.AccountFromLocalPort, } } @@ -113,6 +126,8 @@ func (s *Socks5Inbound) ServeConnContext(baseCtx context.Context, conn net.Conn) } handshakePhase.Stop() + platformName, account := s.resolveIdentity(handshake, conn) + lifecycle := newRequestLifecycleFromMetadata( s.events, conn.RemoteAddr().String(), @@ -121,14 +136,14 @@ func (s *Socks5Inbound) ServeConnContext(baseCtx context.Context, conn net.Conn) true, ) lifecycle.setTarget(handshake.target, "") - lifecycle.setAccount(handshake.account) + lifecycle.setAccount(account) defer lifecycle.finish() prepare := prepareConnectTunnel( baseCtx, s.tunnel, - handshake.platformName, - handshake.account, + platformName, + account, handshake.target, ) if prepare.route.PlatformID != "" { @@ -169,6 +184,20 @@ func (s *Socks5Inbound) ServeConnContext(baseCtx context.Context, conn net.Conn) prepare.session.recordResult(relay.netOK) } +// resolveIdentity returns the (platform, account) to route with. In free-mode +// (forcedPlatform set) it ignores any client-supplied identity and derives a +// per-port sticky account; otherwise it uses the handshake identity. +func (s *Socks5Inbound) resolveIdentity(h socks5HandshakeResult, conn net.Conn) (string, string) { + if s.forcedPlatform == "" { + return h.platformName, h.account + } + account := "" + if s.accountFromLocalPort && conn != nil { + account = freeAccountFromAddr(conn.LocalAddr()) + } + return s.forcedPlatform, account +} + func (s *Socks5Inbound) performHandshake(conn net.Conn, reader *bufio.Reader) socks5HandshakeResult { if s.authVersion != config.AuthVersionV1 { _, _ = conn.Write([]byte{socks5Version, socks5MethodNoAcceptable}) diff --git a/internal/routing/router.go b/internal/routing/router.go index 76cb70fd..2b1fb6a6 100644 --- a/internal/routing/router.go +++ b/internal/routing/router.go @@ -229,6 +229,9 @@ func (r *Router) createOrAbortStickyLease( hadPreviousLease bool, invalidation leaseInvalidationReason, ) (Lease, xsync.ComputeOp, RouteResult, error) { + state.allocationMu.Lock() + defer state.allocationMu.Unlock() + newLease, createdResult, err := r.createLease(plat, state, targetDomain, now, nowNs) if err != nil { r.cleanupPreviousLease(state, previous, hadPreviousLease, invalidation, plat.ID, account) @@ -261,6 +264,16 @@ func (r *Router) tryLeaseHit( newLease := current newLease.LastAccessedNs = nowNs + + // Auto-renew: extend lease when within 1 minute of expiry. + if newLease.ExpiryNs-nowNs < int64(time.Minute) { + ttl := plat.StickyTTLNs + if ttl <= 0 { + ttl = int64(24 * time.Hour) + } + newLease.ExpiryNs = nowNs + ttl + } + r.emitLeaseEvent(LeaseEvent{ Type: LeaseTouch, PlatformID: plat.ID, @@ -297,6 +310,16 @@ func (r *Router) tryLeaseSameIPRotation( newLease := current newLease.NodeHash = bestHash newLease.LastAccessedNs = nowNs + + // Auto-renew: extend lease when within 1 minute of expiry. + if newLease.ExpiryNs-nowNs < int64(time.Minute) { + ttl := plat.StickyTTLNs + if ttl <= 0 { + ttl = int64(24 * time.Hour) + } + newLease.ExpiryNs = nowNs + ttl + } + r.emitLeaseEvent(LeaseEvent{ Type: LeaseReplace, PlatformID: plat.ID, @@ -393,6 +416,12 @@ func (r *Router) selectLiveRandomRoute( stats *IPLoadStats, targetDomain string, ) (node.Hash, *node.NodeEntry, error) { + if plat.AllocationPolicy == platform.AllocationPolicyPreferIdleIP { + if h, entry, ok := r.selectIdleIPRoute(plat, stats, targetDomain); ok { + return h, entry, nil + } + } + var lastMissing node.Hash for i := 0; i < livePickAttempts; i++ { h, err := randomRoute(plat, stats, r.pool, targetDomain, r.authorities(), r.p2cWindow()) @@ -411,6 +440,79 @@ func (r *Router) selectLiveRandomRoute( return node.Zero, nil, ErrNoAvailableNodes } +type idleIPRouteCandidate struct { + hash node.Hash + entry *node.NodeEntry + latency time.Duration + hasLatency bool + createdAt time.Time +} + +func (r *Router) selectIdleIPRoute( + plat *platform.Platform, + stats *IPLoadStats, + targetDomain string, +) (node.Hash, *node.NodeEntry, bool) { + authorities := r.authorities() + window := r.p2cWindow() + candidates := make(map[netip.Addr]idleIPRouteCandidate) + + plat.View().Range(func(h node.Hash) bool { + entry, ok := r.pool.GetEntry(h) + if !ok { + return true + } + ip := entry.GetEgressIP() + if !ip.IsValid() || stats.Get(ip) > 0 { + return true + } + + latency, hasLatency := sameIPCandidateLatency(entry, targetDomain, authorities, window) + next := idleIPRouteCandidate{ + hash: h, + entry: entry, + latency: latency, + hasLatency: hasLatency, + createdAt: entry.CreatedAt, + } + if current, ok := candidates[ip]; !ok || isBetterIdleIPCandidate(next, current) { + candidates[ip] = next + } + return true + }) + + best, ok := bestIdleIPCandidate(candidates) + if !ok { + return node.Zero, nil, false + } + return best.hash, best.entry, true +} + +func bestIdleIPCandidate(candidates map[netip.Addr]idleIPRouteCandidate) (idleIPRouteCandidate, bool) { + var best idleIPRouteCandidate + ok := false + for _, candidate := range candidates { + if !ok || isBetterIdleIPCandidate(candidate, best) { + best = candidate + ok = true + } + } + return best, ok +} + +func isBetterIdleIPCandidate(next, current idleIPRouteCandidate) bool { + if next.hasLatency != current.hasLatency { + return next.hasLatency + } + if next.hasLatency && next.latency != current.latency { + return next.latency < current.latency + } + if !next.createdAt.Equal(current.createdAt) { + return next.createdAt.Before(current.createdAt) + } + return next.hash.Hex() < current.hash.Hex() +} + func chooseSameIPRotationCandidate( plat *platform.Platform, pool PoolAccessor, @@ -586,6 +688,52 @@ func (r *Router) RangeLeases(platformID string, fn func(account string, lease Le return true } +// RangeAllLeases iterates over all leases across every platform. +// fn receives the owning platform ID alongside the account/lease pair. +// Returning false from fn stops the iteration early. +func (r *Router) RangeAllLeases(fn func(platformID, account string, lease Lease) bool) { + r.states.Range(func(platformID string, state *PlatformRoutingState) bool { + keepGoing := true + state.Leases.Range(func(account string, lease Lease) bool { + if !fn(platformID, account, lease) { + keepGoing = false + return false + } + return true + }) + return keepGoing + }) +} + +// SnapshotNodeLoad returns a best-effort point-in-time count of leases per +// node hash for a single platform. Empty map if the platform has no state. +func (r *Router) SnapshotNodeLoad(platformID string) map[node.Hash]int64 { + state, ok := r.states.Load(platformID) + if !ok { + return map[node.Hash]int64{} + } + out := make(map[node.Hash]int64) + state.Leases.Range(func(_ string, lease Lease) bool { + out[lease.NodeHash]++ + return true + }) + return out +} + +// SnapshotNodeLoadAll returns a best-effort point-in-time count of leases per +// node hash, aggregated across every platform. +func (r *Router) SnapshotNodeLoadAll() map[node.Hash]int64 { + out := make(map[node.Hash]int64) + r.states.Range(func(_ string, state *PlatformRoutingState) bool { + state.Leases.Range(func(_ string, lease Lease) bool { + out[lease.NodeHash]++ + return true + }) + return true + }) + return out +} + // DeleteLease removes a single lease by platform and account. // Returns true if a lease was deleted. Emits a LeaseRemove event. func (r *Router) DeleteLease(platformID, account string) bool { @@ -633,3 +781,33 @@ func (r *Router) DeleteAllLeases(platformID string) int { }) return count } + +// DeleteLeasesByNode removes every lease whose node_hash matches the target, +// across all platforms. Returns the total number of leases deleted. Emits a +// LeaseRemove event per deletion so persistence and metrics stay in sync. +func (r *Router) DeleteLeasesByNode(target node.Hash) int { + count := 0 + r.states.Range(func(platformID string, state *PlatformRoutingState) bool { + state.Leases.Range(func(account string, lease Lease) bool { + if lease.NodeHash != target { + return true + } + removed, deleted := state.Leases.DeleteLease(account) + if !deleted { + return true + } + r.emitLeaseEvent(LeaseEvent{ + Type: LeaseRemove, + PlatformID: platformID, + Account: account, + NodeHash: removed.NodeHash, + EgressIP: removed.EgressIP, + CreatedAtNs: removed.CreatedAtNs, + }) + count++ + return true + }) + return true + }) + return count +} diff --git a/internal/routing/router_matrix_test.go b/internal/routing/router_matrix_test.go index 4fc02292..655e2b48 100644 --- a/internal/routing/router_matrix_test.go +++ b/internal/routing/router_matrix_test.go @@ -246,6 +246,53 @@ func TestChooseSameIPRotationCandidate_PicksLowestLatency(t *testing.T) { } } +func TestSelectIdleIPRoutePrefersLatencyThenOlderCreatedAt(t *testing.T) { + pool := newRouterTestPool() + plat := platform.NewPlatform("plat-idle-ranking", "Plat-Idle-Ranking", nil, nil) + pool.addPlatform(plat) + + now := time.Now() + oldHash, oldEntry := newRoutableEntry(t, `{"id":"idle-old"}`, "198.51.100.81") + youngHash, youngEntry := newRoutableEntry(t, `{"id":"idle-young"}`, "198.51.100.82") + oldEntry.CreatedAt = now.Add(-time.Hour) + youngEntry.CreatedAt = now + + oldEntry.LatencyTable.LoadEntry("cloudflare.com", node.DomainLatencyStats{ + Ewma: 80 * time.Millisecond, + LastUpdated: now, + }) + youngEntry.LatencyTable.LoadEntry("cloudflare.com", node.DomainLatencyStats{ + Ewma: 20 * time.Millisecond, + LastUpdated: now, + }) + + pool.addEntry(oldHash, oldEntry) + pool.addEntry(youngHash, youngEntry) + pool.rebuildPlatformView(plat) + + router := newTestRouter(pool, nil) + stats := NewIPLoadStats() + got, _, ok := router.selectIdleIPRoute(plat, stats, "example.com") + if !ok { + t.Fatal("expected idle IP candidate") + } + if got != youngHash { + t.Fatalf("expected lower-latency young node %s, got %s", youngHash.Hex(), got.Hex()) + } + + youngEntry.LatencyTable.LoadEntry("cloudflare.com", node.DomainLatencyStats{ + Ewma: 80 * time.Millisecond, + LastUpdated: now, + }) + got, _, ok = router.selectIdleIPRoute(plat, stats, "example.com") + if !ok { + t.Fatal("expected idle IP candidate after latency tie") + } + if got != oldHash { + t.Fatalf("expected older node %s on latency tie, got %s", oldHash.Hex(), got.Hex()) + } +} + func TestRouteRequest_SameIPRotationMissRecreatesLease(t *testing.T) { pool := newRouterTestPool() plat := platform.NewPlatform("plat-miss", "Plat-Miss", nil, nil) diff --git a/internal/routing/routing_test.go b/internal/routing/routing_test.go index e7b3901f..fff9ce43 100644 --- a/internal/routing/routing_test.go +++ b/internal/routing/routing_test.go @@ -202,6 +202,78 @@ func TestStickyLease_CreateAndHit(t *testing.T) { } } +func TestStickyLease_PreferIdleIPUsesEveryIdleIPBeforeRepeating(t *testing.T) { + pool, subMgr := setupPool(t) + plat, ok := pool.GetPlatform(platID) + if !ok { + t.Fatal("platform not found") + } + plat.AllocationPolicy = platform.AllocationPolicyPreferIdleIP + + makeRoutableNode(t, pool, subMgr, `{"idle":"1a"}`, "10.0.0.1", "cloudflare.com", 50*time.Millisecond) + makeRoutableNode(t, pool, subMgr, `{"idle":"1b"}`, "10.0.0.1", "cloudflare.com", 40*time.Millisecond) + makeRoutableNode(t, pool, subMgr, `{"idle":"2"}`, "10.0.0.2", "cloudflare.com", 60*time.Millisecond) + makeRoutableNode(t, pool, subMgr, `{"idle":"3"}`, "10.0.0.3", "cloudflare.com", 70*time.Millisecond) + + router := makeRouter(pool, nil) + seen := map[netip.Addr]bool{} + for _, account := range []string{"user-idle-a", "user-idle-b", "user-idle-c"} { + res, err := router.RouteRequest(platName, account, "example.com") + if err != nil { + t.Fatalf("route %s: %v", account, err) + } + if seen[res.EgressIP] { + t.Fatalf("egress IP %s repeated before all idle IPs were used", res.EgressIP) + } + seen[res.EgressIP] = true + } + if len(seen) != 3 { + t.Fatalf("used idle IP count = %d, want 3", len(seen)) + } +} + +func TestStickyLease_PreferIdleIPConcurrentCreatesDoNotRepeatIdleIP(t *testing.T) { + pool, subMgr := setupPool(t) + plat, ok := pool.GetPlatform(platID) + if !ok { + t.Fatal("platform not found") + } + plat.AllocationPolicy = platform.AllocationPolicyPreferIdleIP + + makeRoutableNode(t, pool, subMgr, `{"idle-concurrent":"1"}`, "10.0.1.1", "cloudflare.com", 50*time.Millisecond) + makeRoutableNode(t, pool, subMgr, `{"idle-concurrent":"2"}`, "10.0.1.2", "cloudflare.com", 50*time.Millisecond) + makeRoutableNode(t, pool, subMgr, `{"idle-concurrent":"3"}`, "10.0.1.3", "cloudflare.com", 50*time.Millisecond) + + router := makeRouter(pool, nil) + var wg sync.WaitGroup + results := make(chan netip.Addr, 3) + for _, account := range []string{"concurrent-a", "concurrent-b", "concurrent-c"} { + wg.Add(1) + go func(account string) { + defer wg.Done() + res, err := router.RouteRequest(platName, account, "example.com") + if err != nil { + t.Errorf("route %s: %v", account, err) + return + } + results <- res.EgressIP + }(account) + } + wg.Wait() + close(results) + + seen := map[netip.Addr]bool{} + for ip := range results { + if seen[ip] { + t.Fatalf("egress IP %s repeated during concurrent idle allocation", ip) + } + seen[ip] = true + } + if len(seen) != 3 { + t.Fatalf("allocated IP count = %d, want 3", len(seen)) + } +} + func TestDeleteLease_EmitsLeaseRemoveWithLifetimeFields(t *testing.T) { pool, subMgr := setupPool(t) makeRoutableNode(t, pool, subMgr, `{"delete":"1"}`, "10.0.0.9", "cloudflare.com", 50*time.Millisecond) diff --git a/internal/routing/state.go b/internal/routing/state.go index dd9210be..f48320a7 100644 --- a/internal/routing/state.go +++ b/internal/routing/state.go @@ -1,10 +1,13 @@ package routing +import "sync" + // PlatformRoutingState encapsulates the routing state for a single platform. // This struct is stored in the Router's state map. type PlatformRoutingState struct { - Leases *LeaseTable - IPLoadStats *IPLoadStats + Leases *LeaseTable + IPLoadStats *IPLoadStats + allocationMu sync.Mutex } // NewPlatformRoutingState creates a new state instance. diff --git a/internal/service/control_plane_data.go b/internal/service/control_plane_data.go new file mode 100644 index 00000000..6190af90 --- /dev/null +++ b/internal/service/control_plane_data.go @@ -0,0 +1,379 @@ +package service + +import ( + "encoding/json" + "fmt" + "strings" + "time" + + "github.com/Resinat/Resin/internal/platform" + "github.com/Resinat/Resin/internal/subscription" +) + +// ------------------------------------------------------------------ +// Export / Import data types +// ------------------------------------------------------------------ + +const exportVersion = 1 + +// ExportEntry is a portable config object. Fields are filtered from the API +// response by the create/patch allowlists so export stays in sync with config. +type ExportEntry map[string]json.RawMessage + +// ExportPayload is the top-level JSON structure for data export/import. +type ExportPayload struct { + Version int `json:"version"` + ExportedAt string `json:"exported_at"` + Platforms []ExportEntry `json:"platforms"` + Subscriptions []ExportEntry `json:"subscriptions"` +} + +// ImportResult summarises what happened during an import. +type ImportResult struct { + PlatformsCreated int `json:"platforms_created"` + PlatformsSkipped int `json:"platforms_skipped"` + PlatformsOverwritten int `json:"platforms_overwritten"` + SubscriptionsCreated int `json:"subscriptions_created"` + SubscriptionsSkipped int `json:"subscriptions_skipped"` + SubscriptionsOverwritten int `json:"subscriptions_overwritten"` + Errors []string `json:"errors"` +} + +// ------------------------------------------------------------------ +// Export +// ------------------------------------------------------------------ + +// ExportData builds an ExportPayload containing all user-created platforms +// and all subscriptions. +func (s *ControlPlaneService) ExportData() (*ExportPayload, error) { + platforms, err := s.Engine.ListPlatforms() + if err != nil { + return nil, internal("list platforms for export", err) + } + + exportPlatforms := make([]ExportEntry, 0, len(platforms)) + for _, p := range platforms { + if p.ID == platform.DefaultPlatformID { + continue + } + entry, err := exportEntryFrom(platformToResponse(p), isPlatformConfigExportField) + if err != nil { + return nil, internal("encode platform for export", err) + } + exportPlatforms = append(exportPlatforms, entry) + } + + subs, err := s.ListSubscriptions(nil) + if err != nil { + return nil, internal("list subscriptions for export", err) + } + + exportSubs := make([]ExportEntry, 0, len(subs)) + for _, sub := range subs { + entry, err := exportEntryFrom(sub, isSubscriptionConfigExportField) + if err != nil { + return nil, internal("encode subscription for export", err) + } + exportSubs = append(exportSubs, entry) + } + + return &ExportPayload{ + Version: exportVersion, + ExportedAt: time.Now().UTC().Format(time.RFC3339), + Platforms: exportPlatforms, + Subscriptions: exportSubs, + }, nil +} + +// ------------------------------------------------------------------ +// Import +// ------------------------------------------------------------------ + +// ImportData imports platforms and subscriptions from the given payload. +// strategy must be "skip" (default) or "overwrite". +func (s *ControlPlaneService) ImportData(payload ExportPayload, strategy string) (*ImportResult, error) { + if strategy == "" { + strategy = "skip" + } + if strategy != "skip" && strategy != "overwrite" { + return nil, invalidArg("strategy must be 'skip' or 'overwrite'") + } + + result := &ImportResult{Errors: []string{}} + + s.importPlatforms(payload.Platforms, strategy, result) + s.importSubscriptions(payload.Subscriptions, strategy, result) + + return result, nil +} + +func (s *ControlPlaneService) importPlatforms(entries []ExportEntry, strategy string, result *ImportResult) { + existing, err := s.Engine.ListPlatforms() + if err != nil { + result.Errors = append(result.Errors, "failed to list existing platforms: "+err.Error()) + return + } + nameToID := make(map[string]string, len(existing)) + for _, p := range existing { + nameToID[p.Name] = p.ID + } + + seen := make(map[string]bool, len(entries)) + for i, entry := range entries { + name, err := requiredEntryString(entry, "name") + if err != nil { + result.Errors = append(result.Errors, fmt.Sprintf("platforms[%d]: %v, skipped", i, err)) + continue + } + if name == "" { + result.Errors = append(result.Errors, fmt.Sprintf("platforms[%d]: name is empty, skipped", i)) + continue + } + if seen[name] { + result.Errors = append(result.Errors, fmt.Sprintf("platforms[%d]: duplicate name %q in import payload, skipped", i, name)) + continue + } + seen[name] = true + + configEntry := filterEntry(entry, isPlatformConfigExportField) + existingID, exists := nameToID[name] + if exists && strategy == "skip" { + result.PlatformsSkipped++ + continue + } + + if exists && strategy == "overwrite" { + patchJSON, err := marshalEntry(configEntry) + if err != nil { + result.Errors = append(result.Errors, fmt.Sprintf("platform %q: encode patch failed: %v", name, err)) + continue + } + if _, err := s.UpdatePlatform(existingID, patchJSON); err != nil { + result.Errors = append(result.Errors, fmt.Sprintf("platform %q: overwrite failed: %v", name, err)) + continue + } + result.PlatformsOverwritten++ + continue + } + + req, err := decodeCreatePlatformRequest(configEntry) + if err != nil { + result.Errors = append(result.Errors, fmt.Sprintf("platform %q: decode failed: %v", name, err)) + continue + } + if _, err := s.CreatePlatform(req); err != nil { + result.Errors = append(result.Errors, fmt.Sprintf("platform %q: create failed: %v", name, err)) + continue + } + result.PlatformsCreated++ + } +} + +func (s *ControlPlaneService) importSubscriptions(entries []ExportEntry, strategy string, result *ImportResult) { + existingSubs, err := s.ListSubscriptions(nil) + if err != nil { + result.Errors = append(result.Errors, "failed to list existing subscriptions: "+err.Error()) + return + } + nameToID := make(map[string]string, len(existingSubs)) + urlToID := make(map[string]string, len(existingSubs)) + idToSourceType := make(map[string]string, len(existingSubs)) + for _, sub := range existingSubs { + nameToID[sub.Name] = sub.ID + idToSourceType[sub.ID] = sub.SourceType + if sub.URL != "" { + urlToID[sub.URL] = sub.ID + } + } + + seenName := make(map[string]bool, len(entries)) + seenURL := make(map[string]bool, len(entries)) + for i, entry := range entries { + name, err := requiredEntryString(entry, "name") + if err != nil { + result.Errors = append(result.Errors, fmt.Sprintf("subscriptions[%d]: %v, skipped", i, err)) + continue + } + if name == "" { + result.Errors = append(result.Errors, fmt.Sprintf("subscriptions[%d]: name is empty, skipped", i)) + continue + } + if seenName[name] { + result.Errors = append(result.Errors, fmt.Sprintf("subscriptions[%d]: duplicate name %q in import payload, skipped", i, name)) + continue + } + seenName[name] = true + + if _, exists := nameToID[name]; exists && strategy == "skip" { + result.SubscriptionsSkipped++ + continue + } + + sourceType, err := entrySourceType(entry) + if err != nil { + result.Errors = append(result.Errors, fmt.Sprintf("subscriptions[%d]: %v, skipped", i, err)) + continue + } + + url := "" + if sourceType == subscription.SourceTypeRemote { + url, err = optionalEntryString(entry, "url") + if err != nil { + result.Errors = append(result.Errors, fmt.Sprintf("subscriptions[%d]: %v, skipped", i, err)) + continue + } + url = strings.TrimSpace(url) + if url != "" { + if seenURL[url] { + result.Errors = append(result.Errors, fmt.Sprintf("subscriptions[%d]: duplicate url %q in import payload, skipped", i, url)) + continue + } + seenURL[url] = true + } + } + + existingID := "" + if id, ok := nameToID[name]; ok { + existingID = id + } else if sourceType == subscription.SourceTypeRemote && url != "" { + if id, ok := urlToID[url]; ok { + existingID = id + } + } + + if existingID != "" && strategy == "skip" { + result.SubscriptionsSkipped++ + continue + } + + configEntry := filterEntry(entry, isSubscriptionConfigExportField) + if existingID != "" && strategy == "overwrite" { + if existingSourceType := idToSourceType[existingID]; existingSourceType != "" && existingSourceType != sourceType { + result.Errors = append(result.Errors, fmt.Sprintf("subscription %q: source_type mismatch (%s != %s), skipped", name, sourceType, existingSourceType)) + continue + } + patchEntry := filterEntry(configEntry, func(key string) bool { + return key != "source_type" + }) + patchJSON, err := marshalEntry(patchEntry) + if err != nil { + result.Errors = append(result.Errors, fmt.Sprintf("subscription %q: encode patch failed: %v", name, err)) + continue + } + if _, err := s.UpdateSubscription(existingID, patchJSON); err != nil { + result.Errors = append(result.Errors, fmt.Sprintf("subscription %q: overwrite failed: %v", name, err)) + continue + } + result.SubscriptionsOverwritten++ + continue + } + + req, err := decodeCreateSubscriptionRequest(configEntry) + if err != nil { + result.Errors = append(result.Errors, fmt.Sprintf("subscription %q: decode failed: %v", name, err)) + continue + } + if _, err := s.CreateSubscription(req); err != nil { + result.Errors = append(result.Errors, fmt.Sprintf("subscription %q: create failed: %v", name, err)) + continue + } + result.SubscriptionsCreated++ + } +} + +func exportEntryFrom(value any, allow func(string) bool) (ExportEntry, error) { + data, err := json.Marshal(value) + if err != nil { + return nil, err + } + var object map[string]json.RawMessage + if err := json.Unmarshal(data, &object); err != nil { + return nil, err + } + return filterEntry(object, allow), nil +} + +func filterEntry(entry ExportEntry, allow func(string) bool) ExportEntry { + out := make(ExportEntry, len(entry)) + for key, raw := range entry { + if allow(key) { + out[key] = raw + } + } + return out +} + +func marshalEntry(entry ExportEntry) (json.RawMessage, error) { + data, err := json.Marshal(entry) + if err != nil { + return nil, err + } + return data, nil +} + +func isPlatformConfigExportField(key string) bool { + return platformPatchAllowedFields[key] +} + +func isSubscriptionConfigExportField(key string) bool { + return subscriptionPatchAllowedFields[key] || key == "source_type" +} + +func requiredEntryString(entry ExportEntry, field string) (string, error) { + value, err := optionalEntryString(entry, field) + if err != nil { + return "", err + } + return strings.TrimSpace(value), nil +} + +func optionalEntryString(entry ExportEntry, field string) (string, error) { + raw, ok := entry[field] + if !ok { + return "", nil + } + var value string + if err := json.Unmarshal(raw, &value); err != nil { + return "", fmt.Errorf("%s: must be a string", field) + } + return value, nil +} + +func entrySourceType(entry ExportEntry) (string, error) { + sourceType, err := optionalEntryString(entry, "source_type") + if err != nil { + return "", err + } + sourceType = strings.ToLower(strings.TrimSpace(sourceType)) + if sourceType == "" { + return subscription.SourceTypeRemote, nil + } + if sourceType != subscription.SourceTypeRemote && sourceType != subscription.SourceTypeLocal { + return "", fmt.Errorf("source_type: must be remote or local") + } + return sourceType, nil +} + +func decodeCreatePlatformRequest(entry ExportEntry) (CreatePlatformRequest, error) { + data, err := marshalEntry(entry) + if err != nil { + return CreatePlatformRequest{}, err + } + var req CreatePlatformRequest + if err := json.Unmarshal(data, &req); err != nil { + return CreatePlatformRequest{}, err + } + return req, nil +} + +func decodeCreateSubscriptionRequest(entry ExportEntry) (CreateSubscriptionRequest, error) { + data, err := marshalEntry(entry) + if err != nil { + return CreateSubscriptionRequest{}, err + } + var req CreateSubscriptionRequest + if err := json.Unmarshal(data, &req); err != nil { + return CreateSubscriptionRequest{}, err + } + return req, nil +} diff --git a/internal/service/control_plane_data_test.go b/internal/service/control_plane_data_test.go new file mode 100644 index 00000000..98243bd4 --- /dev/null +++ b/internal/service/control_plane_data_test.go @@ -0,0 +1,260 @@ +package service + +import ( + "encoding/json" + "net/netip" + "path/filepath" + "strings" + "testing" + "time" + + "github.com/Resinat/Resin/internal/config" + "github.com/Resinat/Resin/internal/state" + "github.com/Resinat/Resin/internal/topology" +) + +func newDataTestControlPlane(t *testing.T) *ControlPlaneService { + t.Helper() + + dir := t.TempDir() + engine, closer, err := state.PersistenceBootstrap( + filepath.Join(dir, "state"), + filepath.Join(dir, "cache"), + ) + if err != nil { + t.Fatalf("PersistenceBootstrap: %v", err) + } + t.Cleanup(func() { + _ = closer.Close() + }) + + subMgr := topology.NewSubscriptionManager() + pool := topology.NewGlobalNodePool(topology.PoolConfig{ + SubLookup: subMgr.Lookup, + GeoLookup: func(netip.Addr) string { return "us" }, + MaxLatencyTableEntries: 16, + MaxConsecutiveFailures: func() int { return 3 }, + LatencyDecayWindow: func() time.Duration { return 10 * time.Minute }, + }) + + return &ControlPlaneService{ + Engine: engine, + Pool: pool, + SubMgr: subMgr, + Scheduler: topology.NewSubscriptionScheduler(topology.SchedulerConfig{ + SubManager: subMgr, + Pool: pool, + Fetcher: func(string) ([]byte, error) { return []byte("[]"), nil }, + }), + EnvCfg: &config.EnvConfig{ + DefaultPlatformStickyTTL: 30 * time.Minute, + DefaultPlatformRegexFilters: []string{}, + DefaultPlatformRegionFilters: []string{}, + DefaultPlatformReverseProxyMissAction: "TREAT_AS_EMPTY", + DefaultPlatformAllocationPolicy: "BALANCED", + }, + } +} + +func exportTestEntry(t *testing.T, values map[string]any) ExportEntry { + t.Helper() + + entry := make(ExportEntry, len(values)) + for key, value := range values { + data, err := json.Marshal(value) + if err != nil { + t.Fatalf("marshal %s: %v", key, err) + } + entry[key] = data + } + return entry +} + +func entryStringValue(t *testing.T, entry ExportEntry, key string) string { + t.Helper() + + raw, ok := entry[key] + if !ok { + t.Fatalf("missing key %q in entry %v", key, entry) + } + var value string + if err := json.Unmarshal(raw, &value); err != nil { + t.Fatalf("unmarshal %s: %v", key, err) + } + return value +} + +func entryBoolValue(t *testing.T, entry ExportEntry, key string) bool { + t.Helper() + + raw, ok := entry[key] + if !ok { + t.Fatalf("missing key %q in entry %v", key, entry) + } + var value bool + if err := json.Unmarshal(raw, &value); err != nil { + t.Fatalf("unmarshal %s: %v", key, err) + } + return value +} + +func TestExportData_UsesConfigFieldAllowlists(t *testing.T) { + cp := newDataTestControlPlane(t) + + platformName := "export-platform" + disablePassiveBreaker := true + if _, err := cp.CreatePlatform(CreatePlatformRequest{ + Name: &platformName, + PassiveCircuitBreakerDisabled: &disablePassiveBreaker, + }); err != nil { + t.Fatalf("CreatePlatform: %v", err) + } + + subName := "export-subscription" + subURL := "https://example.com/sub" + incrementalAliveNodes := true + if _, err := cp.CreateSubscription(CreateSubscriptionRequest{ + Name: &subName, + URL: &subURL, + IncrementalAliveNodes: &incrementalAliveNodes, + }); err != nil { + t.Fatalf("CreateSubscription: %v", err) + } + + payload, err := cp.ExportData() + if err != nil { + t.Fatalf("ExportData: %v", err) + } + if len(payload.Platforms) != 1 { + t.Fatalf("exported platforms = %d, want 1", len(payload.Platforms)) + } + if len(payload.Subscriptions) != 1 { + t.Fatalf("exported subscriptions = %d, want 1", len(payload.Subscriptions)) + } + + platformEntry := payload.Platforms[0] + for key := range platformPatchAllowedFields { + if _, ok := platformEntry[key]; !ok { + t.Fatalf("platform export missing allowed field %q", key) + } + } + for _, key := range []string{"id", "routable_node_count", "updated_at"} { + if _, ok := platformEntry[key]; ok { + t.Fatalf("platform export includes read-only field %q", key) + } + } + if !entryBoolValue(t, platformEntry, "passive_circuit_breaker_disabled") { + t.Fatal("expected passive_circuit_breaker_disabled to round-trip true") + } + + subEntry := payload.Subscriptions[0] + for key := range subscriptionPatchAllowedFields { + if _, ok := subEntry[key]; !ok { + t.Fatalf("subscription export missing allowed field %q", key) + } + } + if _, ok := subEntry["source_type"]; !ok { + t.Fatal("subscription export missing source_type") + } + for _, key := range []string{"id", "node_count", "healthy_node_count", "created_at", "last_checked", "last_updated", "last_error", "usage"} { + if _, ok := subEntry[key]; ok { + t.Fatalf("subscription export includes read-only field %q", key) + } + } + if !entryBoolValue(t, subEntry, "incremental_alive_nodes") { + t.Fatal("expected incremental_alive_nodes to round-trip true") + } +} + +func TestImportData_OverwriteDynamicConfigFields(t *testing.T) { + cp := newDataTestControlPlane(t) + + platformName := "overwrite-platform" + if _, err := cp.CreatePlatform(CreatePlatformRequest{Name: &platformName}); err != nil { + t.Fatalf("CreatePlatform: %v", err) + } + subName := "overwrite-subscription" + subURL := "https://example.com/overwrite-sub" + if _, err := cp.CreateSubscription(CreateSubscriptionRequest{Name: &subName, URL: &subURL}); err != nil { + t.Fatalf("CreateSubscription: %v", err) + } + + payload := ExportPayload{ + Platforms: []ExportEntry{ + exportTestEntry(t, map[string]any{ + "name": platformName, + "passive_circuit_breaker_disabled": true, + }), + }, + Subscriptions: []ExportEntry{ + exportTestEntry(t, map[string]any{ + "name": subName, + "source_type": "remote", + "incremental_alive_nodes": true, + }), + }, + } + result, err := cp.ImportData(payload, "overwrite") + if err != nil { + t.Fatalf("ImportData: %v", err) + } + if len(result.Errors) != 0 { + t.Fatalf("ImportData errors = %v", result.Errors) + } + if result.PlatformsOverwritten != 1 || result.SubscriptionsOverwritten != 1 { + t.Fatalf("overwrite counts = %+v, want 1 platform and 1 subscription", result) + } + + exported, err := cp.ExportData() + if err != nil { + t.Fatalf("ExportData: %v", err) + } + if !entryBoolValue(t, exported.Platforms[0], "passive_circuit_breaker_disabled") { + t.Fatal("expected platform passive circuit breaker setting to be overwritten") + } + if !entryBoolValue(t, exported.Subscriptions[0], "incremental_alive_nodes") { + t.Fatal("expected subscription incremental mode to be overwritten") + } +} + +func TestImportData_OverwriteRejectsSubscriptionSourceTypeChange(t *testing.T) { + cp := newDataTestControlPlane(t) + + subName := "source-mismatch" + sourceType := "local" + content := "1.2.3.4:8080" + if _, err := cp.CreateSubscription(CreateSubscriptionRequest{ + Name: &subName, + SourceType: &sourceType, + Content: &content, + }); err != nil { + t.Fatalf("CreateSubscription: %v", err) + } + + result, err := cp.ImportData(ExportPayload{ + Subscriptions: []ExportEntry{ + exportTestEntry(t, map[string]any{ + "name": subName, + "source_type": "remote", + "url": "https://example.com/remote-sub", + }), + }, + }, "overwrite") + if err != nil { + t.Fatalf("ImportData: %v", err) + } + if result.SubscriptionsOverwritten != 0 { + t.Fatalf("subscriptions_overwritten = %d, want 0", result.SubscriptionsOverwritten) + } + if len(result.Errors) != 1 || !strings.Contains(result.Errors[0], "source_type mismatch") { + t.Fatalf("errors = %v, want source_type mismatch", result.Errors) + } + + exported, err := cp.ExportData() + if err != nil { + t.Fatalf("ExportData: %v", err) + } + if got := entryStringValue(t, exported.Subscriptions[0], "source_type"); got != "local" { + t.Fatalf("source_type = %q, want local", got) + } +} diff --git a/internal/service/control_plane_leases.go b/internal/service/control_plane_leases.go index 1702e2ba..1ebb52e8 100644 --- a/internal/service/control_plane_leases.go +++ b/internal/service/control_plane_leases.go @@ -1,6 +1,7 @@ package service import ( + "sort" "strings" "time" @@ -15,24 +16,28 @@ import ( // LeaseResponse is the API response for a lease. type LeaseResponse struct { - PlatformID string `json:"platform_id"` - Account string `json:"account"` - NodeHash string `json:"node_hash"` - NodeTag string `json:"node_tag"` - EgressIP string `json:"egress_ip"` - Expiry string `json:"expiry"` - LastAccessed string `json:"last_accessed"` + PlatformID string `json:"platform_id"` + Account string `json:"account"` + NodeHash string `json:"node_hash"` + NodeTag string `json:"node_tag"` + EgressIP string `json:"egress_ip"` + ReferenceLatencyMs *float64 `json:"reference_latency_ms,omitempty"` + CreatedAt string `json:"created_at"` + Expiry string `json:"expiry"` + LastAccessed string `json:"last_accessed"` } -func leaseToResponse(lease model.Lease, nodeTag string) LeaseResponse { +func leaseToResponse(lease model.Lease, nodeTag string, referenceLatencyMs *float64) LeaseResponse { return LeaseResponse{ - PlatformID: lease.PlatformID, - Account: lease.Account, - NodeHash: lease.NodeHash, - NodeTag: nodeTag, - EgressIP: lease.EgressIP, - Expiry: time.Unix(0, lease.ExpiryNs).UTC().Format(time.RFC3339Nano), - LastAccessed: time.Unix(0, lease.LastAccessedNs).UTC().Format(time.RFC3339Nano), + PlatformID: lease.PlatformID, + Account: lease.Account, + NodeHash: lease.NodeHash, + NodeTag: nodeTag, + EgressIP: lease.EgressIP, + ReferenceLatencyMs: referenceLatencyMs, + CreatedAt: time.Unix(0, lease.CreatedAtNs).UTC().Format(time.RFC3339Nano), + Expiry: time.Unix(0, lease.ExpiryNs).UTC().Format(time.RFC3339Nano), + LastAccessed: time.Unix(0, lease.LastAccessedNs).UTC().Format(time.RFC3339Nano), } } @@ -51,6 +56,33 @@ func (s *ControlPlaneService) resolveLeaseNodeTagFromHex(hashHex string) string return s.resolveLeaseNodeTag(hash) } +func (s *ControlPlaneService) resolveLeaseNodeReferenceLatency(hash node.Hash) *float64 { + if s == nil || s.Pool == nil || s.RuntimeCfg == nil { + return nil + } + entry, ok := s.Pool.GetEntry(hash) + if !ok { + return nil + } + cfg := s.RuntimeCfg.Load() + if cfg == nil { + return nil + } + avgMs, ok := node.AverageEWMAForDomainsMs(entry, cfg.LatencyAuthorities) + if !ok { + return nil + } + return &avgMs +} + +func (s *ControlPlaneService) resolveLeaseNodeReferenceLatencyFromHex(hashHex string) *float64 { + hash, err := node.ParseHex(hashHex) + if err != nil { + return nil + } + return s.resolveLeaseNodeReferenceLatency(hash) +} + // ListLeases returns all leases for a platform. func (s *ControlPlaneService) ListLeases(platformID string) ([]LeaseResponse, error) { if _, ok := s.Pool.GetPlatform(platformID); !ok { @@ -63,9 +95,10 @@ func (s *ControlPlaneService) ListLeases(platformID string) ([]LeaseResponse, er Account: account, NodeHash: lease.NodeHash.Hex(), EgressIP: lease.EgressIP.String(), + CreatedAtNs: lease.CreatedAtNs, ExpiryNs: lease.ExpiryNs, LastAccessedNs: lease.LastAccessedNs, - }, s.resolveLeaseNodeTag(lease.NodeHash))) + }, s.resolveLeaseNodeTag(lease.NodeHash), s.resolveLeaseNodeReferenceLatency(lease.NodeHash))) return true }) if result == nil { @@ -83,7 +116,7 @@ func (s *ControlPlaneService) GetLease(platformID, account string) (*LeaseRespon if ml == nil { return nil, notFound("lease not found") } - resp := leaseToResponse(*ml, s.resolveLeaseNodeTagFromHex(ml.NodeHash)) + resp := leaseToResponse(*ml, s.resolveLeaseNodeTagFromHex(ml.NodeHash), s.resolveLeaseNodeReferenceLatencyFromHex(ml.NodeHash)) return &resp, nil } @@ -148,12 +181,164 @@ func (s *ControlPlaneService) DeleteAllLeases(platformID string) error { return nil } +// BindLease binds (or rebinds) an account to a specific node on the given platform. +// The node must be routable on the platform. +func (s *ControlPlaneService) BindLease(platformID, account, nodeHashHex string) (*LeaseResponse, error) { + account = strings.TrimSpace(account) + if account == "" { + return nil, invalidArg("account: must be non-empty") + } + nodeHashHex = strings.TrimSpace(nodeHashHex) + h, err := node.ParseHex(nodeHashHex) + if err != nil { + return nil, invalidArg("node_hash: invalid format") + } + + plat, ok := s.Pool.GetPlatform(platformID) + if !ok { + return nil, notFound("platform not found") + } + + if !plat.View().Contains(h) { + return nil, notFound("node is not routable on this platform") + } + + entry, ok := s.Pool.GetEntry(h) + if !ok { + return nil, notFound("node not found") + } + egressIP := entry.GetEgressIP() + if !egressIP.IsValid() { + return nil, invalidArg("node has no egress IP") + } + + nowNs := time.Now().UnixNano() + ttlNs := plat.StickyTTLNs + if ttlNs <= 0 { + ttlNs = int64(24 * time.Hour) // default 24h + } + + ml := model.Lease{ + PlatformID: platformID, + Account: account, + NodeHash: h.Hex(), + EgressIP: egressIP.String(), + CreatedAtNs: nowNs, + ExpiryNs: nowNs + ttlNs, + LastAccessedNs: nowNs, + } + if err := s.Router.UpsertLease(ml); err != nil { + return nil, internal("bind lease", err) + } + + resp := leaseToResponse(ml, s.resolveLeaseNodeTag(h), s.resolveLeaseNodeReferenceLatency(h)) + return &resp, nil +} + // IPLoadEntry is the API response for IP load stats. type IPLoadEntry struct { EgressIP string `json:"egress_ip"` LeaseCount int64 `json:"lease_count"` } +// NodeLeaseResponse is the API response for a lease scoped to a specific node. +// Unlike LeaseResponse, it carries the owning platform so the caller can render +// cross-platform lease bindings for a single node. +type NodeLeaseResponse struct { + PlatformID string `json:"platform_id"` + PlatformName string `json:"platform_name"` + Account string `json:"account"` + NodeHash string `json:"node_hash"` + EgressIP string `json:"egress_ip"` + CreatedAt string `json:"created_at"` + Expiry string `json:"expiry"` + LastAccessed string `json:"last_accessed"` +} + +// ListLeasesByNode returns every lease bound to the given node hash. +// When platformID is non-empty, only leases under that platform are returned; +// otherwise leases across all platforms are aggregated. +// Results are sorted by CreatedAtNs descending (newest first). +func (s *ControlPlaneService) ListLeasesByNode(nodeHashHex, platformID string) ([]NodeLeaseResponse, error) { + nodeHashHex = strings.TrimSpace(nodeHashHex) + h, err := node.ParseHex(nodeHashHex) + if err != nil { + return nil, invalidArg("node_hash: invalid format") + } + if _, ok := s.Pool.GetEntry(h); !ok { + return nil, notFound("node not found") + } + + type entry struct { + resp NodeLeaseResponse + createdAtNs int64 + } + platformNameCache := make(map[string]string) + resolvePlatformName := func(pid string) string { + if name, ok := platformNameCache[pid]; ok { + return name + } + name := "" + if plat, ok := s.Pool.GetPlatform(pid); ok { + name = plat.Name + } + platformNameCache[pid] = name + return name + } + + var entries []entry + addLease := func(pid, account string, lease routing.Lease) { + if lease.NodeHash != h { + return + } + entries = append(entries, entry{ + resp: NodeLeaseResponse{ + PlatformID: pid, + PlatformName: resolvePlatformName(pid), + Account: account, + NodeHash: lease.NodeHash.Hex(), + EgressIP: lease.EgressIP.String(), + CreatedAt: time.Unix(0, lease.CreatedAtNs).UTC().Format(time.RFC3339Nano), + Expiry: time.Unix(0, lease.ExpiryNs).UTC().Format(time.RFC3339Nano), + LastAccessed: time.Unix(0, lease.LastAccessedNs).UTC().Format(time.RFC3339Nano), + }, + createdAtNs: lease.CreatedAtNs, + }) + } + + platformID = strings.TrimSpace(platformID) + if platformID != "" { + if _, ok := s.Pool.GetPlatform(platformID); !ok { + return nil, notFound("platform not found") + } + s.Router.RangeLeases(platformID, func(account string, lease routing.Lease) bool { + addLease(platformID, account, lease) + return true + }) + } else { + s.Router.RangeAllLeases(func(pid, account string, lease routing.Lease) bool { + addLease(pid, account, lease) + return true + }) + } + + sort.SliceStable(entries, func(i, j int) bool { + if entries[i].createdAtNs != entries[j].createdAtNs { + return entries[i].createdAtNs > entries[j].createdAtNs + } + if entries[i].resp.PlatformName != entries[j].resp.PlatformName { + return entries[i].resp.PlatformName < entries[j].resp.PlatformName + } + return entries[i].resp.Account < entries[j].resp.Account + }) + + result := make([]NodeLeaseResponse, 0, len(entries)) + for _, e := range entries { + result = append(result, e.resp) + } + return result, nil +} + // GetIPLoad returns IP load stats for a platform. func (s *ControlPlaneService) GetIPLoad(platformID string) ([]IPLoadEntry, error) { if _, ok := s.Pool.GetPlatform(platformID); !ok { diff --git a/internal/service/control_plane_leases_test.go b/internal/service/control_plane_leases_test.go index c58116e0..0901111e 100644 --- a/internal/service/control_plane_leases_test.go +++ b/internal/service/control_plane_leases_test.go @@ -4,9 +4,11 @@ import ( "encoding/json" "errors" "net/netip" + "sync/atomic" "testing" "time" + "github.com/Resinat/Resin/internal/config" "github.com/Resinat/Resin/internal/model" "github.com/Resinat/Resin/internal/node" "github.com/Resinat/Resin/internal/platform" @@ -33,11 +35,14 @@ func newLeaseInheritanceTestService() (*ControlPlaneService, *platform.Platform) Authorities: func() []string { return []string{"cloudflare.com"} }, P2CWindow: func() time.Duration { return 10 * time.Minute }, }) + runtimeCfg := &atomic.Pointer[config.RuntimeConfig]{} + runtimeCfg.Store(config.NewDefaultRuntimeConfig()) return &ControlPlaneService{ - Pool: pool, - SubMgr: subMgr, - Router: router, + Pool: pool, + SubMgr: subMgr, + Router: router, + RuntimeCfg: runtimeCfg, }, plat } @@ -193,6 +198,14 @@ func TestListLeases_NodeTagUsesEarliestSubscriptionThenMinTag(t *testing.T) { 60, []string{"0"}, ) + entry, ok := cp.Pool.GetEntry(hash) + if !ok { + t.Fatalf("node %s missing", hash.Hex()) + } + entry.LatencyTable.LoadEntry("cloudflare.com", node.DomainLatencyStats{ + Ewma: 42 * time.Millisecond, + LastUpdated: time.Now(), + }) now := time.Now().UnixNano() seedLease(t, cp, model.Lease{ @@ -215,6 +228,9 @@ func TestListLeases_NodeTagUsesEarliestSubscriptionThenMinTag(t *testing.T) { if leases[0].NodeTag != "OldSub/a" { t.Fatalf("node_tag: got %q, want %q", leases[0].NodeTag, "OldSub/a") } + if leases[0].ReferenceLatencyMs == nil || *leases[0].ReferenceLatencyMs != 42 { + t.Fatalf("reference_latency_ms: got %v, want 42", leases[0].ReferenceLatencyMs) + } } func TestInheritLeaseByPlatformName_Success(t *testing.T) { diff --git a/internal/service/control_plane_nodes.go b/internal/service/control_plane_nodes.go index 56d7f91b..69cec88e 100644 --- a/internal/service/control_plane_nodes.go +++ b/internal/service/control_plane_nodes.go @@ -15,15 +15,16 @@ import ( // NodeFilters holds query filters for listing nodes. type NodeFilters struct { - PlatformID *string - SubscriptionID *string - Enabled *bool - Region *string - CircuitOpen *bool - HasOutbound *bool - EgressIP *string - ProbedSince *time.Time - TagKeyword *string + PlatformID *string + SubscriptionID *string + Enabled *bool + ManuallyDisabled *bool + Region *string + CircuitOpen *bool + HasOutbound *bool + EgressIP *string + ProbedSince *time.Time + TagKeyword *string } // ListNodes returns nodes from the pool with optional filters. @@ -115,6 +116,25 @@ func (s *ControlPlaneService) ListNodes(filters NodeFilters) ([]NodeSummary, err if result == nil { result = []NodeSummary{} } + + if s.Router != nil { + var leaseLoads map[node.Hash]int64 + if filters.PlatformID != nil { + leaseLoads = s.Router.SnapshotNodeLoad(*filters.PlatformID) + } else { + leaseLoads = s.Router.SnapshotNodeLoadAll() + } + if len(leaseLoads) > 0 { + for i := range result { + h, err := node.ParseHex(result[i].NodeHash) + if err != nil { + continue + } + result[i].LeaseCount = leaseLoads[h] + } + } + } + return result, nil } @@ -123,6 +143,13 @@ func (s *ControlPlaneService) nodeEntryMatchesFilters( filters NodeFilters, subLookup node.SubLookupFunc, ) bool { + // Manually disabled filter (admin-controlled flag, independent of subscription state). + if filters.ManuallyDisabled != nil { + if entry.IsManuallyDisabled() != *filters.ManuallyDisabled { + return false + } + } + // Enabled/disabled filter. if filters.Enabled != nil { enabled := true @@ -215,7 +242,7 @@ func (s *ControlPlaneService) GetNode(hashStr string) (*NodeSummary, error) { if !ok { return nil, notFound("node not found") } - ns := s.nodeEntryToSummary(h, entry) + ns := s.nodeEntryToDetailSummary(h, entry) return &ns, nil } @@ -255,3 +282,99 @@ func (s *ControlPlaneService) ProbeLatency(hashStr string) (*probe.LatencyProbeR } return result, nil } + +// SetNodeManualDisableResult is returned by SetNodeManualDisable. When the +// caller disables a node we report the number of bound leases that were +// released as a side effect, so the UI can surface it. +type SetNodeManualDisableResult struct { + ReleasedLeaseCount int `json:"released_lease_count"` +} + +type CleanupNodeResult struct { + EvictedSubscriptionCount int `json:"evicted_subscription_count"` + ReleasedLeaseCount int `json:"released_lease_count"` +} + +// SetNodeManualDisable toggles the admin-controlled disable flag on a node. +// When transitioning to disabled, all leases currently bound to the node are +// immediately released so connected platforms reroute to other nodes. The +// flag is persisted via the node-dynamic dirty-set on the next flush. +func (s *ControlPlaneService) SetNodeManualDisable(hashStr string, disable bool) (SetNodeManualDisableResult, error) { + h, err := node.ParseHex(hashStr) + if err != nil { + return SetNodeManualDisableResult{}, invalidArg("node_hash: invalid format") + } + if _, ok := s.Pool.GetEntry(h); !ok { + return SetNodeManualDisableResult{}, notFound("node not found") + } + + // Flip the flag and trigger platform-view rebuilds so the node is removed + // from (or restored to) all routable views before we touch any leases. + s.Pool.SetNodeManualDisable(h, disable) + if s.Engine != nil { + s.Engine.MarkNodeDynamic(strings.ToLower(hashStr)) + } + + if !disable { + return SetNodeManualDisableResult{}, nil + } + + released := 0 + if s.Router != nil { + released = s.Router.DeleteLeasesByNode(h) + } + return SetNodeManualDisableResult{ReleasedLeaseCount: released}, nil +} + +func (s *ControlPlaneService) CleanupNode(hashStr string) (CleanupNodeResult, error) { + h, err := node.ParseHex(hashStr) + if err != nil { + return CleanupNodeResult{}, invalidArg("node_hash: invalid format") + } + entry, ok := s.Pool.GetEntry(h) + if !ok { + return CleanupNodeResult{}, notFound("node not found") + } + + evictedSubIDs := make([]string, 0, entry.SubscriptionCount()) + for _, subID := range entry.SubscriptionIDs() { + sub := s.SubMgr.Lookup(subID) + if sub == nil { + s.Pool.RemoveNodeFromSub(h, subID) + continue + } + + evicted := false + sub.WithOpLock(func() { + lockedSub := s.SubMgr.Lookup(subID) + if lockedSub == nil { + return + } + managed, ok := lockedSub.ManagedNodes().LoadNode(h) + if ok && !managed.Evicted { + managed.Evicted = true + lockedSub.ManagedNodes().StoreNode(h, managed) + evicted = true + } + s.Pool.RemoveNodeFromSub(h, subID) + }) + if evicted { + evictedSubIDs = append(evictedSubIDs, subID) + } + } + + if s.Engine != nil { + for _, subID := range evictedSubIDs { + s.Engine.MarkSubscriptionNode(subID, h.Hex()) + } + } + + released := 0 + if s.Router != nil { + released = s.Router.DeleteLeasesByNode(h) + } + return CleanupNodeResult{ + EvictedSubscriptionCount: len(evictedSubIDs), + ReleasedLeaseCount: released, + }, nil +} diff --git a/internal/service/control_plane_nodes_test.go b/internal/service/control_plane_nodes_test.go index 3280556f..e75c8bb4 100644 --- a/internal/service/control_plane_nodes_test.go +++ b/internal/service/control_plane_nodes_test.go @@ -1,6 +1,7 @@ package service import ( + "encoding/json" "net/netip" "sync/atomic" "testing" @@ -8,9 +9,11 @@ import ( "github.com/Resinat/Resin/internal/config" "github.com/Resinat/Resin/internal/geoip" + "github.com/Resinat/Resin/internal/model" "github.com/Resinat/Resin/internal/node" "github.com/Resinat/Resin/internal/platform" "github.com/Resinat/Resin/internal/probe" + "github.com/Resinat/Resin/internal/routing" "github.com/Resinat/Resin/internal/subscription" "github.com/Resinat/Resin/internal/testutil" "github.com/Resinat/Resin/internal/topology" @@ -66,6 +69,48 @@ func addRoutableNodeForSubscriptionWithTag( return hash } +func TestProxyURLsFromOutbound(t *testing.T) { + tests := []struct { + name string + raw string + want []NodeProxyURL + }{ + { + name: "http", + raw: `{"type":"http","tag":"plain","server":"proxy.example.com","server_port":8080,"username":"user","password":"pass"}`, + want: []NodeProxyURL{{Type: "http", URL: "http://user:pass@proxy.example.com:8080#plain"}}, + }, + { + name: "https", + raw: `{"type":"http","tag":"tls","server":"proxy.example.com","server_port":8443,"tls":{"enabled":true,"server_name":"sni.example.com","insecure":true}}`, + want: []NodeProxyURL{{Type: "https", URL: "https://proxy.example.com:8443?insecure=true&sni=sni.example.com#tls"}}, + }, + { + name: "socks5", + raw: `{"type":"socks","tag":"sock","server":"127.0.0.1","server_port":1080,"version":"5"}`, + want: []NodeProxyURL{{Type: "socks5", URL: "socks5://127.0.0.1:1080#sock"}}, + }, + { + name: "unsupported", + raw: `{"type":"shadowsocks","server":"1.1.1.1","server_port":443}`, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + got := proxyURLsFromOutbound(json.RawMessage(tt.raw)) + if len(got) != len(tt.want) { + t.Fatalf("len = %d, want %d: %#v", len(got), len(tt.want), got) + } + for i := range tt.want { + if got[i] != tt.want[i] { + t.Fatalf("proxy url[%d] = %#v, want %#v", i, got[i], tt.want[i]) + } + } + }) + } +} + func TestListNodes_PlatformAndSubscriptionFiltersReturnIntersection(t *testing.T) { subMgr := topology.NewSubscriptionManager() pool := newNodeListTestPool(subMgr) @@ -523,3 +568,74 @@ func TestProbeEgress_ReturnsRegion(t *testing.T) { t.Fatalf("region: got %q, want %q", got.Region, "jp") } } + +func TestCleanupNode_EvictsAllSubscriptionsAndReleasesLeases(t *testing.T) { + subMgr := topology.NewSubscriptionManager() + pool := newNodeListTestPool(subMgr) + router := routing.NewRouter(routing.RouterConfig{Pool: pool}) + + subA := subscription.NewSubscription("sub-a", "sub-a", "https://example.com/a", true, false) + subB := subscription.NewSubscription("sub-b", "sub-b", "https://example.com/b", true, false) + subMgr.Register(subA) + subMgr.Register(subB) + + raw := []byte(`{"type":"ss","server":"1.1.1.1","port":443}`) + hash := node.HashFromRawOptions(raw) + pool.AddNodeFromSub(hash, raw, subA.ID) + pool.AddNodeFromSub(hash, raw, subB.ID) + subA.ManagedNodes().StoreNode(hash, subscription.ManagedNode{Tags: []string{"a"}}) + subB.ManagedNodes().StoreNode(hash, subscription.ManagedNode{Tags: []string{"b"}}) + + now := time.Now().UnixNano() + for _, account := range []string{"alice", "bob"} { + if err := router.UpsertLease(model.Lease{ + PlatformID: "platform-a", + Account: account, + NodeHash: hash.Hex(), + EgressIP: "203.0.113.10", + CreatedAtNs: now, + ExpiryNs: now + int64(time.Hour), + LastAccessedNs: now, + }); err != nil { + t.Fatalf("upsert lease %s: %v", account, err) + } + } + + cp := &ControlPlaneService{ + Pool: pool, + SubMgr: subMgr, + Router: router, + } + result, err := cp.CleanupNode(hash.Hex()) + if err != nil { + t.Fatalf("CleanupNode: %v", err) + } + if result.EvictedSubscriptionCount != 2 { + t.Fatalf("evicted_subscription_count = %d, want 2", result.EvictedSubscriptionCount) + } + if result.ReleasedLeaseCount != 2 { + t.Fatalf("released_lease_count = %d, want 2", result.ReleasedLeaseCount) + } + if _, ok := pool.GetEntry(hash); ok { + t.Fatal("node should be removed from pool") + } + for _, sub := range []*subscription.Subscription{subA, subB} { + managed, ok := sub.ManagedNodes().LoadNode(hash) + if !ok { + t.Fatalf("managed node missing for subscription %s", sub.ID) + } + if !managed.Evicted { + t.Fatalf("managed node for subscription %s should be evicted", sub.ID) + } + } + remaining := 0 + router.RangeAllLeases(func(_, _ string, lease routing.Lease) bool { + if lease.NodeHash == hash { + remaining++ + } + return true + }) + if remaining != 0 { + t.Fatalf("remaining leases for node = %d, want 0", remaining) + } +} diff --git a/internal/service/control_plane_platform.go b/internal/service/control_plane_platform.go index c9100cd4..805fa8ba 100644 --- a/internal/service/control_plane_platform.go +++ b/internal/service/control_plane_platform.go @@ -1,10 +1,15 @@ package service import ( + "bytes" "encoding/json" "errors" "fmt" + "net" + "net/netip" + "net/url" "regexp" + "strconv" "strings" "time" @@ -13,6 +18,7 @@ import ( "github.com/Resinat/Resin/internal/model" "github.com/Resinat/Resin/internal/node" "github.com/Resinat/Resin/internal/platform" + "github.com/Resinat/Resin/internal/routing" "github.com/Resinat/Resin/internal/state" ) @@ -26,8 +32,12 @@ type PlatformResponse struct { Name string `json:"name"` StickyTTL string `json:"sticky_ttl"` RegexFilters []string `json:"regex_filters"` + RegexExcludeFilters []string `json:"regex_exclude_filters"` RegionFilters []string `json:"region_filters"` RoutableNodeCount int `json:"routable_node_count"` + EgressIPCount int `json:"egress_ip_count"` + ActiveLeaseCount int `json:"active_lease_count"` + IsBuiltin bool `json:"is_builtin"` ReverseProxyMissAction string `json:"reverse_proxy_miss_action"` ReverseProxyEmptyAccountBehavior string `json:"reverse_proxy_empty_account_behavior"` ReverseProxyFixedAccountHeader string `json:"reverse_proxy_fixed_account_header"` @@ -44,8 +54,12 @@ func platformToResponse(p model.Platform) PlatformResponse { Name: p.Name, StickyTTL: time.Duration(p.StickyTTLNs).String(), RegexFilters: append([]string(nil), p.RegexFilters...), + RegexExcludeFilters: append([]string(nil), p.RegexExcludeFilters...), RegionFilters: append([]string(nil), p.RegionFilters...), RoutableNodeCount: 0, + EgressIPCount: 0, + ActiveLeaseCount: 0, + IsBuiltin: false, ReverseProxyMissAction: p.ReverseProxyMissAction, ReverseProxyEmptyAccountBehavior: behavior, ReverseProxyFixedAccountHeader: fixedHeader, @@ -55,15 +69,43 @@ func platformToResponse(p model.Platform) PlatformResponse { } } -func (s *ControlPlaneService) withRoutableNodeCount(resp PlatformResponse) PlatformResponse { - if s == nil || s.Pool == nil { +func (s *ControlPlaneService) withPlatformRuntimeStats(resp PlatformResponse) PlatformResponse { + resp.IsBuiltin = resp.ID == platform.DefaultPlatformID || + (s != nil && s.EnvCfg != nil && s.EnvCfg.FreePortStart > 0 && + s.EnvCfg.FreePortPlatform != "" && resp.Name == s.EnvCfg.FreePortPlatform) + + if s == nil { return resp } - plat, ok := s.Pool.GetPlatform(resp.ID) - if !ok || plat == nil { - return resp + + if s.Pool != nil { + plat, ok := s.Pool.GetPlatform(resp.ID) + if ok && plat != nil { + seen := make(map[netip.Addr]struct{}) + view := plat.View() + resp.RoutableNodeCount = view.Size() + view.Range(func(h node.Hash) bool { + entry, ok := s.Pool.GetEntry(h) + if !ok || entry == nil { + return true + } + ip := entry.GetEgressIP() + if ip.IsValid() { + seen[ip] = struct{}{} + } + return true + }) + resp.EgressIPCount = len(seen) + } } - resp.RoutableNodeCount = plat.View().Size() + + if s.Router != nil { + s.Router.RangeLeases(resp.ID, func(_ string, _ routing.Lease) bool { + resp.ActiveLeaseCount++ + return true + }) + } + return resp } @@ -71,6 +113,7 @@ type platformConfig struct { Name string StickyTTLNs int64 RegexFilters []string + RegexExcludeFilters []string RegionFilters []string ReverseProxyMissAction string ReverseProxyEmptyAccountBehavior string @@ -98,8 +141,9 @@ func (s *ControlPlaneService) defaultPlatformConfig(name string) platformConfig return platformConfig{ Name: name, StickyTTLNs: int64(s.EnvCfg.DefaultPlatformStickyTTL), - RegexFilters: append([]string(nil), s.EnvCfg.DefaultPlatformRegexFilters...), - RegionFilters: append([]string(nil), s.EnvCfg.DefaultPlatformRegionFilters...), + RegexFilters: append([]string(nil), s.EnvCfg.DefaultPlatformRegexFilters...), + RegexExcludeFilters: []string{}, + RegionFilters: append([]string(nil), s.EnvCfg.DefaultPlatformRegionFilters...), ReverseProxyMissAction: s.EnvCfg.DefaultPlatformReverseProxyMissAction, ReverseProxyEmptyAccountBehavior: normalizePlatformEmptyAccountBehavior( s.EnvCfg.DefaultPlatformReverseProxyEmptyAccountBehavior, @@ -116,6 +160,7 @@ func platformConfigFromModel(mp model.Platform) platformConfig { Name: mp.Name, StickyTTLNs: mp.StickyTTLNs, RegexFilters: append([]string(nil), mp.RegexFilters...), + RegexExcludeFilters: append([]string(nil), mp.RegexExcludeFilters...), RegionFilters: append([]string(nil), mp.RegionFilters...), ReverseProxyMissAction: mp.ReverseProxyMissAction, ReverseProxyEmptyAccountBehavior: normalizePlatformEmptyAccountBehavior(mp.ReverseProxyEmptyAccountBehavior), @@ -131,6 +176,7 @@ func (cfg platformConfig) toModel(id string, updatedAtNs int64) model.Platform { Name: cfg.Name, StickyTTLNs: cfg.StickyTTLNs, RegexFilters: append([]string(nil), cfg.RegexFilters...), + RegexExcludeFilters: append([]string(nil), cfg.RegexExcludeFilters...), RegionFilters: append([]string(nil), cfg.RegionFilters...), ReverseProxyMissAction: cfg.ReverseProxyMissAction, ReverseProxyEmptyAccountBehavior: cfg.ReverseProxyEmptyAccountBehavior, @@ -146,10 +192,15 @@ func (cfg platformConfig) toRuntime(id string) (*platform.Platform, error) { if err != nil { return nil, err } + compiledRegexExcludeFilters, err := platform.CompileRegexExcludeFilters(cfg.RegexExcludeFilters) + if err != nil { + return nil, err + } return platform.NewConfiguredPlatform( id, cfg.Name, compiledRegexFilters, + compiledRegexExcludeFilters, cfg.RegionFilters, cfg.StickyTTLNs, cfg.ReverseProxyMissAction, @@ -298,7 +349,7 @@ func (s *ControlPlaneService) ListPlatforms() ([]PlatformResponse, error) { } resp := make([]PlatformResponse, len(platforms)) for i, p := range platforms { - resp[i] = s.withRoutableNodeCount(platformToResponse(p)) + resp[i] = s.withPlatformRuntimeStats(platformToResponse(p)) } return resp, nil } @@ -320,7 +371,7 @@ func (s *ControlPlaneService) GetPlatform(id string) (*PlatformResponse, error) if err != nil { return nil, err } - r := s.withRoutableNodeCount(platformToResponse(*mp)) + r := s.withPlatformRuntimeStats(platformToResponse(*mp)) return &r, nil } @@ -329,6 +380,7 @@ type CreatePlatformRequest struct { Name *string `json:"name"` StickyTTL *string `json:"sticky_ttl"` RegexFilters []string `json:"regex_filters"` + RegexExcludeFilters []string `json:"regex_exclude_filters"` RegionFilters []string `json:"region_filters"` ReverseProxyMissAction *string `json:"reverse_proxy_miss_action"` ReverseProxyEmptyAccountBehavior *string `json:"reverse_proxy_empty_account_behavior"` @@ -368,6 +420,9 @@ func (s *ControlPlaneService) CreatePlatform(req CreatePlatformRequest) (*Platfo if req.RegexFilters != nil { cfg.RegexFilters = req.RegexFilters } + if req.RegexExcludeFilters != nil { + cfg.RegexExcludeFilters = req.RegexExcludeFilters + } if req.RegionFilters != nil { cfg.RegionFilters = req.RegionFilters } @@ -408,7 +463,7 @@ func (s *ControlPlaneService) CreatePlatform(req CreatePlatformRequest) (*Platfo s.Pool.RebuildPlatform(plat) s.Pool.RegisterPlatform(plat) - r := s.withRoutableNodeCount(platformToResponse(mp)) + r := s.withPlatformRuntimeStats(platformToResponse(mp)) return &r, nil } @@ -468,6 +523,11 @@ func (s *ControlPlaneService) UpdatePlatform(id string, patchJSON json.RawMessag } else if ok { cfg.RegexFilters = filters } + if filters, ok, err := patch.optionalStringSlice("regex_exclude_filters"); err != nil { + return nil, err + } else if ok { + cfg.RegexExcludeFilters = filters + } regionFiltersPatched := false if filters, ok, err := patch.optionalStringSlice("region_filters"); err != nil { @@ -523,7 +583,7 @@ func (s *ControlPlaneService) UpdatePlatform(id string, patchJSON json.RawMessag return nil, internal("replace platform in pool", err) } - r := s.withRoutableNodeCount(platformToResponse(mp)) + r := s.withPlatformRuntimeStats(platformToResponse(mp)) return &r, nil } @@ -563,7 +623,7 @@ func (s *ControlPlaneService) ResetPlatformToDefault(id string) (*PlatformRespon return nil, internal("replace platform in pool", err) } - r := s.withRoutableNodeCount(platformToResponse(mp)) + r := s.withPlatformRuntimeStats(platformToResponse(mp)) return &r, nil } @@ -584,34 +644,44 @@ type PreviewFilterRequest struct { } type PlatformSpecFilter struct { - RegexFilters []string `json:"regex_filters"` - RegionFilters []string `json:"region_filters"` + RegexFilters []string `json:"regex_filters"` + RegexExcludeFilters []string `json:"regex_exclude_filters"` + RegionFilters []string `json:"region_filters"` } // NodeSummary is the API response for a node. type NodeSummary struct { - NodeHash string `json:"node_hash"` - CreatedAt string `json:"created_at"` - Enabled bool `json:"enabled"` - DisplayTag string `json:"display_tag,omitempty"` - HasOutbound bool `json:"has_outbound"` - LastError string `json:"last_error,omitempty"` - CircuitOpenSince *string `json:"circuit_open_since"` - FailureCount int `json:"failure_count"` - EgressIP string `json:"egress_ip,omitempty"` - Region string `json:"region,omitempty"` - LastEgressUpdate string `json:"last_egress_update,omitempty"` - LastLatencyProbeAttempt string `json:"last_latency_probe_attempt,omitempty"` - LastAuthorityLatencyProbeAttempt string `json:"last_authority_latency_probe_attempt,omitempty"` - ReferenceLatencyMs *float64 `json:"reference_latency_ms,omitempty"` - LastEgressUpdateAttempt string `json:"last_egress_update_attempt,omitempty"` - Tags []NodeTag `json:"tags"` + NodeHash string `json:"node_hash"` + CreatedAt string `json:"created_at"` + Enabled bool `json:"enabled"` + ManuallyDisabled bool `json:"manually_disabled"` + DisplayTag string `json:"display_tag,omitempty"` + HasOutbound bool `json:"has_outbound"` + LastError string `json:"last_error,omitempty"` + CircuitOpenSince *string `json:"circuit_open_since"` + FailureCount int `json:"failure_count"` + EgressIP string `json:"egress_ip,omitempty"` + Region string `json:"region,omitempty"` + LastEgressUpdate string `json:"last_egress_update,omitempty"` + LastLatencyProbeAttempt string `json:"last_latency_probe_attempt,omitempty"` + LastAuthorityLatencyProbeAttempt string `json:"last_authority_latency_probe_attempt,omitempty"` + ReferenceLatencyMs *float64 `json:"reference_latency_ms,omitempty"` + LastEgressUpdateAttempt string `json:"last_egress_update_attempt,omitempty"` + LeaseCount int64 `json:"lease_count"` + Tags []NodeTag `json:"tags"` + Outbound json.RawMessage `json:"outbound,omitempty"` + ProxyURLs []NodeProxyURL `json:"proxy_urls,omitempty"` +} + +type NodeProxyURL struct { + Type string `json:"type"` + URL string `json:"url"` } // IsHealthyAndEnabled follows the node-summary health rule used by API/UI // aggregates: enabled, outbound-ready, and not circuit-open. func (n NodeSummary) IsHealthyAndEnabled() bool { - return n.Enabled && n.HasOutbound && n.CircuitOpenSince == nil + return n.Enabled && !n.ManuallyDisabled && n.HasOutbound && n.CircuitOpenSince == nil } type NodeTag struct { @@ -635,6 +705,7 @@ func (s *ControlPlaneService) nodeEntryToSummary(h node.Hash, entry *node.NodeEn ns.Enabled = !s.Pool.IsNodeDisabled(h) ns.DisplayTag = s.Pool.ResolveNodeDisplayTag(h) } + ns.ManuallyDisabled = entry.IsManuallyDisabled() if cos := entry.CircuitOpenSince.Load(); cos > 0 { t := time.Unix(0, cos).UTC().Format(time.RFC3339Nano) @@ -697,6 +768,97 @@ func (s *ControlPlaneService) nodeEntryToSummary(h node.Hash, entry *node.NodeEn return ns } +func (s *ControlPlaneService) nodeEntryToDetailSummary(h node.Hash, entry *node.NodeEntry) NodeSummary { + ns := s.nodeEntryToSummary(h, entry) + raw := bytes.TrimSpace(entry.RawOptions) + if len(raw) == 0 || !json.Valid(raw) { + return ns + } + ns.Outbound = append(json.RawMessage(nil), raw...) + ns.ProxyURLs = proxyURLsFromOutbound(raw) + return ns +} + +type simpleProxyOutbound struct { + Type string `json:"type"` + Tag string `json:"tag"` + Server string `json:"server"` + ServerPort uint16 `json:"server_port"` + Username string `json:"username"` + Password string `json:"password"` + Version string `json:"version"` + TLS *simpleProxyTLS `json:"tls"` +} + +type simpleProxyTLS struct { + Enabled bool `json:"enabled"` + ServerName string `json:"server_name"` + Insecure bool `json:"insecure"` +} + +func proxyURLsFromOutbound(raw json.RawMessage) []NodeProxyURL { + var outbound simpleProxyOutbound + if err := json.Unmarshal(raw, &outbound); err != nil { + return nil + } + server := strings.TrimSpace(outbound.Server) + if server == "" || outbound.ServerPort == 0 { + return nil + } + + switch strings.ToLower(strings.TrimSpace(outbound.Type)) { + case "http": + if outbound.TLS != nil && outbound.TLS.Enabled { + return []NodeProxyURL{{Type: "https", URL: buildProxyURL("https", server, outbound)}} + } + return []NodeProxyURL{{Type: "http", URL: buildProxyURL("http", server, outbound)}} + case "socks", "socks5": + if !isSocks5Version(outbound.Version) { + return nil + } + return []NodeProxyURL{{Type: "socks5", URL: buildProxyURL("socks5", server, outbound)}} + default: + return nil + } +} + +func isSocks5Version(version string) bool { + switch strings.ToLower(strings.TrimSpace(version)) { + case "", "5", "5h", "socks5", "socks5h": + return true + default: + return false + } +} + +func buildProxyURL(scheme string, server string, outbound simpleProxyOutbound) string { + u := url.URL{ + Scheme: scheme, + Host: net.JoinHostPort(server, strconv.Itoa(int(outbound.ServerPort))), + } + if outbound.Username != "" || outbound.Password != "" { + if outbound.Password == "" { + u.User = url.User(outbound.Username) + } else { + u.User = url.UserPassword(outbound.Username, outbound.Password) + } + } + if tag := strings.TrimSpace(outbound.Tag); tag != "" { + u.Fragment = tag + } + if scheme == "https" && outbound.TLS != nil { + query := url.Values{} + if serverName := strings.TrimSpace(outbound.TLS.ServerName); serverName != "" { + query.Set("sni", serverName) + } + if outbound.TLS.Insecure { + query.Set("insecure", "true") + } + u.RawQuery = query.Encode() + } + return u.String() +} + // PreviewFilter returns nodes matching the given filter spec. func (s *ControlPlaneService) PreviewFilter(req PreviewFilterRequest) ([]NodeSummary, error) { hasPlatformID := req.PlatformID != nil && *req.PlatformID != "" @@ -707,6 +869,7 @@ func (s *ControlPlaneService) PreviewFilter(req PreviewFilterRequest) ([]NodeSum } var regexFilters []*regexp.Regexp + var regexExcludeFilters []*regexp.Regexp var regionFilters []string if hasPlatformID { @@ -715,13 +878,19 @@ func (s *ControlPlaneService) PreviewFilter(req PreviewFilterRequest) ([]NodeSum return nil, notFound("platform not found") } regexFilters = plat.RegexFilters + regexExcludeFilters = plat.RegexExcludeFilters regionFilters = plat.RegionFilters } else { compiled, err := platform.CompileRegexFilters(req.PlatformSpec.RegexFilters) if err != nil { return nil, invalidArg(err.Error()) } + compiledExclude, err := platform.CompileRegexExcludeFilters(req.PlatformSpec.RegexExcludeFilters) + if err != nil { + return nil, invalidArg(err.Error()) + } regexFilters = compiled + regexExcludeFilters = compiledExclude regionFilters = req.PlatformSpec.RegionFilters if err := platform.ValidateRegionFilters(regionFilters); err != nil { return nil, invalidArg(err.Error()) @@ -737,6 +906,9 @@ func (s *ControlPlaneService) PreviewFilter(req PreviewFilterRequest) ([]NodeSum if !entry.MatchRegexs(regexFilters, subLookup) { return true } + if entry.MatchAnyRegex(regexExcludeFilters, subLookup) { + return true + } if len(regionFilters) > 0 { region := entry.GetRegion(nil) if s.GeoIP != nil { diff --git a/internal/service/control_plane_platform_preview_test.go b/internal/service/control_plane_platform_preview_test.go index 4e01bd5d..f32fff3b 100644 --- a/internal/service/control_plane_platform_preview_test.go +++ b/internal/service/control_plane_platform_preview_test.go @@ -2,6 +2,7 @@ package service import ( "net/netip" + "strings" "testing" "time" @@ -161,3 +162,38 @@ func TestPreviewFilter_RegionNegation_UnknownRegionExcluded(t *testing.T) { } } } + +func TestPreviewFilter_RegexExclude(t *testing.T) { + fixture := buildPreviewFilterFixture(t) + + nodes, err := fixture.cp.PreviewFilter(PreviewFilterRequest{ + PlatformSpec: &PlatformSpecFilter{ + RegexFilters: []string{".*"}, + RegexExcludeFilters: []string{"hk"}, + }, + }) + if err != nil { + t.Fatalf("PreviewFilter: %v", err) + } + for _, node := range nodes { + if node.NodeHash == fixture.hkHash { + t.Fatalf("hk node %s should have been excluded", fixture.hkHash) + } + } + if len(nodes) != 2 { + t.Fatalf("nodes len = %d, want 2", len(nodes)) + } +} + +func TestPreviewFilter_InvalidRegexExclude(t *testing.T) { + fixture := buildPreviewFilterFixture(t) + + _, err := fixture.cp.PreviewFilter(PreviewFilterRequest{ + PlatformSpec: &PlatformSpecFilter{ + RegexExcludeFilters: []string{"(broken"}, + }, + }) + if err == nil || !strings.Contains(err.Error(), "regex_exclude_filters[0]") { + t.Fatalf("err = %v, want regex_exclude_filters[0]", err) + } +} diff --git a/internal/service/control_plane_subscription.go b/internal/service/control_plane_subscription.go index 9985f325..1ba4a1b5 100644 --- a/internal/service/control_plane_subscription.go +++ b/internal/service/control_plane_subscription.go @@ -22,22 +22,31 @@ import ( // SubscriptionResponse is the API response for a subscription. type SubscriptionResponse struct { - ID string `json:"id"` - Name string `json:"name"` - SourceType string `json:"source_type"` - URL string `json:"url"` - Content string `json:"content"` - UpdateInterval string `json:"update_interval"` - NodeCount int `json:"node_count"` - HealthyNodeCount int `json:"healthy_node_count"` - Ephemeral bool `json:"ephemeral"` - IncrementalAliveNodes bool `json:"incremental_alive_nodes"` - EphemeralNodeEvictDelay string `json:"ephemeral_node_evict_delay"` - Enabled bool `json:"enabled"` - CreatedAt string `json:"created_at"` - LastChecked string `json:"last_checked,omitempty"` - LastUpdated string `json:"last_updated,omitempty"` - LastError string `json:"last_error,omitempty"` + ID string `json:"id"` + Name string `json:"name"` + SourceType string `json:"source_type"` + URL string `json:"url"` + Content string `json:"content"` + UpdateInterval string `json:"update_interval"` + NodeCount int `json:"node_count"` + HealthyNodeCount int `json:"healthy_node_count"` + Ephemeral bool `json:"ephemeral"` + IncrementalAliveNodes bool `json:"incremental_alive_nodes"` + EphemeralNodeEvictDelay string `json:"ephemeral_node_evict_delay"` + Enabled bool `json:"enabled"` + CreatedAt string `json:"created_at"` + LastChecked string `json:"last_checked,omitempty"` + LastUpdated string `json:"last_updated,omitempty"` + LastError string `json:"last_error,omitempty"` + Usage *SubscriptionUsageResponse `json:"usage,omitempty"` +} + +type SubscriptionUsageResponse struct { + UploadBytes int64 `json:"upload_bytes"` + DownloadBytes int64 `json:"download_bytes"` + TotalBytes int64 `json:"total_bytes"` + ExpireUnix int64 `json:"expire_unix,omitempty"` + UpdatedAt string `json:"updated_at"` } func (s *ControlPlaneService) subToResponse(sub *subscription.Subscription) SubscriptionResponse { @@ -84,10 +93,30 @@ func (s *ControlPlaneService) subToResponse(sub *subscription.Subscription) Subs if lu := sub.LastUpdatedNs.Load(); lu > 0 { resp.LastUpdated = time.Unix(0, lu).UTC().Format(time.RFC3339Nano) } + if usage := sub.Usage(); usage.UpdatedAtNs > 0 { + resp.Usage = &SubscriptionUsageResponse{ + UploadBytes: usage.UploadBytes, + DownloadBytes: usage.DownloadBytes, + TotalBytes: usage.TotalBytes, + ExpireUnix: usage.ExpireUnix, + UpdatedAt: time.Unix(0, usage.UpdatedAtNs).UTC().Format(time.RFC3339Nano), + } + } resp.LastError = sub.GetLastError() return resp } +func subscriptionUsageToModelFields(usage subscription.UsageInfo, ms *model.Subscription) { + if ms == nil { + return + } + ms.UsageUploadBytes = usage.UploadBytes + ms.UsageDownloadBytes = usage.DownloadBytes + ms.UsageTotalBytes = usage.TotalBytes + ms.UsageExpireUnix = usage.ExpireUnix + ms.UsageUpdatedAtNs = usage.UpdatedAtNs +} + // ListSubscriptions returns all subscriptions, optionally filtered by enabled. func (s *ControlPlaneService) ListSubscriptions(enabled *bool) ([]SubscriptionResponse, error) { var result []SubscriptionResponse @@ -379,6 +408,7 @@ func (s *ControlPlaneService) UpdateSubscription(id string, patchJSON json.RawMe CreatedAtNs: sub.CreatedAtNs, UpdatedAtNs: now, } + subscriptionUsageToModelFields(sub.Usage(), &ms) if err := s.Engine.UpsertSubscription(ms); err != nil { return nil, internal("persist subscription", err) } diff --git a/internal/service/control_plane_system.go b/internal/service/control_plane_system.go index 38fa863c..25c4307a 100644 --- a/internal/service/control_plane_system.go +++ b/internal/service/control_plane_system.go @@ -93,6 +93,7 @@ var platformPatchAllowedFields = map[string]bool{ "name": true, "sticky_ttl": true, "regex_filters": true, + "regex_exclude_filters": true, "region_filters": true, "reverse_proxy_miss_action": true, "reverse_proxy_empty_account_behavior": true, diff --git a/internal/service/control_plane_test.go b/internal/service/control_plane_test.go index f583cd4c..9333416b 100644 --- a/internal/service/control_plane_test.go +++ b/internal/service/control_plane_test.go @@ -469,7 +469,8 @@ func TestCreatePlatform_BuildsRoutableViewBeforePublish(t *testing.T) { } name := "new-platform" - created, err := cp.CreatePlatform(CreatePlatformRequest{Name: &name}) + regexExcludeFilters := []string{"nope"} + created, err := cp.CreatePlatform(CreatePlatformRequest{Name: &name, RegexExcludeFilters: regexExcludeFilters}) if err != nil { t.Fatalf("CreatePlatform: %v", err) } @@ -484,9 +485,15 @@ func TestCreatePlatform_BuildsRoutableViewBeforePublish(t *testing.T) { if !plat.View().Contains(hash) { t.Fatalf("new platform view should contain seeded hash %s", hash.Hex()) } + if !reflect.DeepEqual(created.RegexExcludeFilters, regexExcludeFilters) { + t.Fatalf("created regex_exclude_filters = %v, want %v", created.RegexExcludeFilters, regexExcludeFilters) + } if created.PassiveCircuitBreakerDisabled { t.Fatal("new platform should default passive circuit breaker to not disabled") } + if len(plat.RegexExcludeFilters) != 1 || plat.RegexExcludeFilters[0].String() != "nope" { + t.Fatalf("runtime regex_exclude_filters = %v, want [nope]", plat.RegexExcludeFilters) + } if plat.PassiveCircuitBreakerDisabled { t.Fatal("runtime platform should default passive circuit breaker to not disabled") } @@ -884,6 +891,7 @@ func TestDeletePlatform_DoesNotDecodeCorruptPersistedFiltersJSON(t *testing.T) { platformRow.Name, nil, nil, + nil, platformRow.StickyTTLNs, platformRow.ReverseProxyMissAction, string(platform.ReverseProxyEmptyAccountBehaviorAccountHeaderRule), @@ -947,6 +955,7 @@ func TestResetPlatformToDefault_SupportsBuiltInDefaultPlatform(t *testing.T) { defaultRow.Name, nil, nil, + nil, defaultRow.StickyTTLNs, defaultRow.ReverseProxyMissAction, string(platform.ReverseProxyEmptyAccountBehaviorAccountHeaderRule), @@ -1089,6 +1098,7 @@ func TestResetPlatformToDefault_DoesNotDecodeCorruptPersistedFiltersJSON(t *test platformRow.Name, nil, nil, + nil, platformRow.StickyTTLNs, platformRow.ReverseProxyMissAction, string(platform.ReverseProxyEmptyAccountBehaviorAccountHeaderRule), diff --git a/internal/state/migrate.go b/internal/state/migrate.go index cd5cf3ac..bf67df59 100644 --- a/internal/state/migrate.go +++ b/internal/state/migrate.go @@ -25,6 +25,8 @@ const ( stateVersionNormalizeMissAction = 4 stateVersionAddIncrementalAliveNodes = 5 stateVersionAddPassiveCircuitBreakerDisabled = 6 + stateVersionAddSubscriptionUsageInfo = 7 + stateVersionAddRegexExcludeFilters = 8 stateLegacyBaselineVersion = stateVersionAddFixedAccountHeader stateBaseSchemaMigration = stateMigrationsPath + "/000001_state_base.up.sql" @@ -89,7 +91,7 @@ func prepareLegacyStateBaseline(db *sql.DB, driver migratedb.Driver) error { return err } if hasVersion { - return nil + return repairStateMigrationMetadata(db, driver) } hasPlatforms, err := hasTable(db, "platforms") @@ -116,10 +118,43 @@ func prepareLegacyStateBaseline(db *sql.DB, driver migratedb.Driver) error { if err != nil { return err } + hasSubscriptionUsageInfo, err := hasTableColumn(db, "subscriptions", "usage_updated_at_ns") + if err != nil { + return err + } + hasRegexExcludeFilters, err := hasTableColumn(db, "platforms", "regex_exclude_filters_json") + if err != nil { + return err + } switch { + case hasEmptyBehavior && hasFixedHeader && hasIncrementalAliveNodes && hasPassiveCircuitBreakerDisabled && hasSubscriptionUsageInfo: + if err := ensureSubscriptionUsageInfoColumns(db); err != nil { + return err + } + if hasRegexExcludeFilters { + return setLegacyMigrationVersion(db, driver, stateVersionAddRegexExcludeFilters) + } + return setLegacyMigrationVersion(db, driver, stateVersionAddSubscriptionUsageInfo) case hasEmptyBehavior && hasFixedHeader && hasIncrementalAliveNodes && hasPassiveCircuitBreakerDisabled: + if hasRegexExcludeFilters { + if err := ensureSubscriptionUsageInfoColumns(db); err != nil { + return err + } + return setLegacyMigrationVersion(db, driver, stateVersionAddRegexExcludeFilters) + } return setLegacyMigrationVersion(db, driver, stateVersionAddPassiveCircuitBreakerDisabled) + case hasEmptyBehavior && hasFixedHeader && hasIncrementalAliveNodes && hasSubscriptionUsageInfo: + if err := ensurePassiveCircuitBreakerColumn(db); err != nil { + return err + } + if err := ensureSubscriptionUsageInfoColumns(db); err != nil { + return err + } + if hasRegexExcludeFilters { + return setLegacyMigrationVersion(db, driver, stateVersionAddRegexExcludeFilters) + } + return setLegacyMigrationVersion(db, driver, stateVersionAddSubscriptionUsageInfo) case hasEmptyBehavior && hasFixedHeader && hasIncrementalAliveNodes: return setLegacyMigrationVersion(db, driver, stateVersionAddIncrementalAliveNodes) case hasEmptyBehavior && hasFixedHeader: @@ -143,6 +178,74 @@ func prepareLegacyStateBaseline(db *sql.DB, driver migratedb.Driver) error { } } +func repairStateMigrationMetadata(db *sql.DB, driver migratedb.Driver) error { + version, dirty, err := driver.Version() + if err != nil { + return fmt.Errorf("read migration version: %w", err) + } + if dirty { + return nil + } + + switch version { + case stateVersionAddPassiveCircuitBreakerDisabled: + if err := ensureStateBaseSchema(db); err != nil { + return err + } + if err := ensurePassiveCircuitBreakerColumn(db); err != nil { + return err + } + if err := ensureSubscriptionUsageInfoColumns(db); err != nil { + return err + } + hasRegexExcludeFilters, err := hasTableColumn(db, "platforms", "regex_exclude_filters_json") + if err != nil { + return err + } + if hasRegexExcludeFilters { + return setMigrationVersion(driver, stateVersionAddRegexExcludeFilters) + } + return setMigrationVersion(driver, stateVersionAddSubscriptionUsageInfo) + case stateVersionAddSubscriptionUsageInfo: + hasRegexExcludeFilters, err := hasTableColumn(db, "platforms", "regex_exclude_filters_json") + if err != nil { + return err + } + if hasRegexExcludeFilters { + return setMigrationVersion(driver, stateVersionAddRegexExcludeFilters) + } + } + return nil +} + +func ensurePassiveCircuitBreakerColumn(db *sql.DB) error { + return ensureTableColumn( + db, + "platforms", + "passive_circuit_breaker_disabled", + `passive_circuit_breaker_disabled INTEGER NOT NULL DEFAULT 0`, + ) +} + +func ensureSubscriptionUsageInfoColumns(db *sql.DB) error { + columns := []struct { + name string + ddl string + }{ + {"usage_upload_bytes", `usage_upload_bytes INTEGER NOT NULL DEFAULT 0`}, + {"usage_download_bytes", `usage_download_bytes INTEGER NOT NULL DEFAULT 0`}, + {"usage_total_bytes", `usage_total_bytes INTEGER NOT NULL DEFAULT 0`}, + {"usage_expire_unix", `usage_expire_unix INTEGER NOT NULL DEFAULT 0`}, + {"usage_updated_at_ns", `usage_updated_at_ns INTEGER NOT NULL DEFAULT 0`}, + } + for _, column := range columns { + if err := ensureTableColumn(db, "subscriptions", column.name, column.ddl); err != nil { + return err + } + } + return nil +} + func hasMigrationVersion(db *sql.DB, table string) (bool, error) { var count int if err := db.QueryRow(fmt.Sprintf("SELECT COUNT(*) FROM %s", table)).Scan(&count); err != nil { diff --git a/internal/state/migrations/cache/000003_nodes_dynamic_manually_disabled.down.sql b/internal/state/migrations/cache/000003_nodes_dynamic_manually_disabled.down.sql new file mode 100644 index 00000000..467ae64d --- /dev/null +++ b/internal/state/migrations/cache/000003_nodes_dynamic_manually_disabled.down.sql @@ -0,0 +1,24 @@ +CREATE TABLE IF NOT EXISTS nodes_dynamic__old_schema ( + hash TEXT PRIMARY KEY, + failure_count INTEGER NOT NULL DEFAULT 0, + circuit_open_since INTEGER NOT NULL DEFAULT 0, + egress_ip TEXT NOT NULL DEFAULT '', + egress_region TEXT NOT NULL DEFAULT '', + egress_updated_at_ns INTEGER NOT NULL DEFAULT 0, + last_latency_probe_attempt_ns INTEGER NOT NULL DEFAULT 0, + last_authority_latency_probe_attempt_ns INTEGER NOT NULL DEFAULT 0, + last_egress_update_attempt_ns INTEGER NOT NULL DEFAULT 0 +); + +INSERT INTO nodes_dynamic__old_schema ( + hash, failure_count, circuit_open_since, egress_ip, egress_region, egress_updated_at_ns, + last_latency_probe_attempt_ns, last_authority_latency_probe_attempt_ns, last_egress_update_attempt_ns +) +SELECT + hash, failure_count, circuit_open_since, egress_ip, egress_region, egress_updated_at_ns, + last_latency_probe_attempt_ns, last_authority_latency_probe_attempt_ns, last_egress_update_attempt_ns +FROM nodes_dynamic; + +DROP TABLE nodes_dynamic; + +ALTER TABLE nodes_dynamic__old_schema RENAME TO nodes_dynamic; diff --git a/internal/state/migrations/cache/000003_nodes_dynamic_manually_disabled.up.sql b/internal/state/migrations/cache/000003_nodes_dynamic_manually_disabled.up.sql new file mode 100644 index 00000000..f6908bfc --- /dev/null +++ b/internal/state/migrations/cache/000003_nodes_dynamic_manually_disabled.up.sql @@ -0,0 +1,2 @@ +ALTER TABLE nodes_dynamic +ADD COLUMN manually_disabled INTEGER NOT NULL DEFAULT 0; diff --git a/internal/state/migrations/cache/000004_cache_compat_noop.down.sql b/internal/state/migrations/cache/000004_cache_compat_noop.down.sql new file mode 100644 index 00000000..e0ac49d1 --- /dev/null +++ b/internal/state/migrations/cache/000004_cache_compat_noop.down.sql @@ -0,0 +1 @@ +SELECT 1; diff --git a/internal/state/migrations/cache/000004_cache_compat_noop.up.sql b/internal/state/migrations/cache/000004_cache_compat_noop.up.sql new file mode 100644 index 00000000..e0ac49d1 --- /dev/null +++ b/internal/state/migrations/cache/000004_cache_compat_noop.up.sql @@ -0,0 +1 @@ +SELECT 1; diff --git a/internal/state/migrations/state/000007_subscriptions_add_usage_info.down.sql b/internal/state/migrations/state/000007_subscriptions_add_usage_info.down.sql new file mode 100644 index 00000000..d381755c --- /dev/null +++ b/internal/state/migrations/state/000007_subscriptions_add_usage_info.down.sql @@ -0,0 +1,5 @@ +ALTER TABLE subscriptions DROP COLUMN usage_updated_at_ns; +ALTER TABLE subscriptions DROP COLUMN usage_expire_unix; +ALTER TABLE subscriptions DROP COLUMN usage_total_bytes; +ALTER TABLE subscriptions DROP COLUMN usage_download_bytes; +ALTER TABLE subscriptions DROP COLUMN usage_upload_bytes; diff --git a/internal/state/migrations/state/000007_subscriptions_add_usage_info.up.sql b/internal/state/migrations/state/000007_subscriptions_add_usage_info.up.sql new file mode 100644 index 00000000..e7f56fc8 --- /dev/null +++ b/internal/state/migrations/state/000007_subscriptions_add_usage_info.up.sql @@ -0,0 +1,5 @@ +ALTER TABLE subscriptions ADD COLUMN usage_upload_bytes INTEGER NOT NULL DEFAULT 0; +ALTER TABLE subscriptions ADD COLUMN usage_download_bytes INTEGER NOT NULL DEFAULT 0; +ALTER TABLE subscriptions ADD COLUMN usage_total_bytes INTEGER NOT NULL DEFAULT 0; +ALTER TABLE subscriptions ADD COLUMN usage_expire_unix INTEGER NOT NULL DEFAULT 0; +ALTER TABLE subscriptions ADD COLUMN usage_updated_at_ns INTEGER NOT NULL DEFAULT 0; diff --git a/internal/state/migrations/state/000008_platforms_add_regex_exclude_filters.down.sql b/internal/state/migrations/state/000008_platforms_add_regex_exclude_filters.down.sql new file mode 100644 index 00000000..93403e58 --- /dev/null +++ b/internal/state/migrations/state/000008_platforms_add_regex_exclude_filters.down.sql @@ -0,0 +1 @@ +ALTER TABLE platforms DROP COLUMN regex_exclude_filters_json; diff --git a/internal/state/migrations/state/000008_platforms_add_regex_exclude_filters.up.sql b/internal/state/migrations/state/000008_platforms_add_regex_exclude_filters.up.sql new file mode 100644 index 00000000..fe93f2b3 --- /dev/null +++ b/internal/state/migrations/state/000008_platforms_add_regex_exclude_filters.up.sql @@ -0,0 +1 @@ +ALTER TABLE platforms ADD COLUMN regex_exclude_filters_json TEXT NOT NULL DEFAULT '[]'; diff --git a/internal/state/repo_cache.go b/internal/state/repo_cache.go index ac2261dd..80639d43 100644 --- a/internal/state/repo_cache.go +++ b/internal/state/repo_cache.go @@ -86,6 +86,7 @@ func (r *CacheRepo) BulkUpsertNodesDynamic(nodes []model.NodeDynamic) error { n.LastLatencyProbeAttemptNs, n.LastAuthorityLatencyProbeAttemptNs, n.LastEgressUpdateAttemptNs, + n.ManuallyDisabled, ) return err }, @@ -109,7 +110,8 @@ func (r *CacheRepo) BulkDeleteNodesDynamic(hashes []string) error { func (r *CacheRepo) LoadAllNodesDynamic() ([]model.NodeDynamic, error) { rows, err := r.db.Query(` SELECT hash, failure_count, circuit_open_since, egress_ip, egress_region, egress_updated_at_ns, - last_latency_probe_attempt_ns, last_authority_latency_probe_attempt_ns, last_egress_update_attempt_ns + last_latency_probe_attempt_ns, last_authority_latency_probe_attempt_ns, last_egress_update_attempt_ns, + manually_disabled FROM nodes_dynamic`) if err != nil { return nil, err @@ -129,6 +131,7 @@ func (r *CacheRepo) LoadAllNodesDynamic() ([]model.NodeDynamic, error) { &n.LastLatencyProbeAttemptNs, &n.LastAuthorityLatencyProbeAttemptNs, &n.LastEgressUpdateAttemptNs, + &n.ManuallyDisabled, ); err != nil { return nil, err } @@ -398,6 +401,7 @@ func (r *CacheRepo) FlushTx(ops FlushOps) error { n.LastLatencyProbeAttemptNs, n.LastAuthorityLatencyProbeAttemptNs, n.LastEgressUpdateAttemptNs, + n.ManuallyDisabled, ) return err }}, @@ -453,9 +457,10 @@ const ( upsertNodesDynamicSQL = `INSERT INTO nodes_dynamic ( hash, failure_count, circuit_open_since, egress_ip, egress_region, egress_updated_at_ns, - last_latency_probe_attempt_ns, last_authority_latency_probe_attempt_ns, last_egress_update_attempt_ns + last_latency_probe_attempt_ns, last_authority_latency_probe_attempt_ns, last_egress_update_attempt_ns, + manually_disabled ) - VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?) + VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?) ON CONFLICT(hash) DO UPDATE SET failure_count = excluded.failure_count, circuit_open_since = excluded.circuit_open_since, @@ -464,7 +469,8 @@ const ( egress_updated_at_ns = excluded.egress_updated_at_ns, last_latency_probe_attempt_ns = excluded.last_latency_probe_attempt_ns, last_authority_latency_probe_attempt_ns = excluded.last_authority_latency_probe_attempt_ns, - last_egress_update_attempt_ns = excluded.last_egress_update_attempt_ns` + last_egress_update_attempt_ns = excluded.last_egress_update_attempt_ns, + manually_disabled = excluded.manually_disabled` upsertNodeLatencySQL = `INSERT INTO node_latency (node_hash, domain, ewma_ns, last_updated_ns) VALUES (?, ?, ?, ?) diff --git a/internal/state/repo_cache_test.go b/internal/state/repo_cache_test.go index beea95cf..a24ca259 100644 --- a/internal/state/repo_cache_test.go +++ b/internal/state/repo_cache_test.go @@ -22,6 +22,35 @@ func newTestCacheRepo(t *testing.T) *CacheRepo { return newCacheRepo(db) } +func TestMigrateCacheDB_AcceptsCompatVersion4(t *testing.T) { + dir := t.TempDir() + db, err := OpenDB(dir + "/cache.db") + if err != nil { + t.Fatal(err) + } + defer db.Close() + + if err := MigrateCacheDB(db); err != nil { + t.Fatalf("MigrateCacheDB: %v", err) + } + + var version int + var dirty bool + if err := db.QueryRow("SELECT version, dirty FROM schema_migrations LIMIT 1").Scan(&version, &dirty); err != nil { + t.Fatalf("read schema_migrations: %v", err) + } + if dirty { + t.Fatal("schema_migrations dirty=true") + } + if version != 4 { + t.Fatalf("schema_migrations version: got %d, want 4", version) + } + + if err := MigrateCacheDB(db); err != nil { + t.Fatalf("MigrateCacheDB existing version 4: %v", err) + } +} + // --- nodes_static --- func TestCacheRepo_NodesStatic_BulkUpsertAndLoad(t *testing.T) { diff --git a/internal/state/repo_state.go b/internal/state/repo_state.go index 6c6ad78c..01cc7282 100644 --- a/internal/state/repo_state.go +++ b/internal/state/repo_state.go @@ -106,6 +106,9 @@ func (r *StateRepo) UpsertPlatform(p model.Platform) error { if _, err := platform.CompileRegexFilters(p.RegexFilters); err != nil { return err } + if _, err := platform.CompileRegexExcludeFilters(p.RegexExcludeFilters); err != nil { + return err + } if err := platform.ValidateRegionFilters(p.RegionFilters); err != nil { return err } @@ -140,6 +143,10 @@ func (r *StateRepo) UpsertPlatform(p model.Platform) error { if err != nil { return fmt.Errorf("encode platform %s regex_filters: %w", p.ID, err) } + regexExcludeFiltersJSON, err := encodeStringSliceJSON(p.RegexExcludeFilters) + if err != nil { + return fmt.Errorf("encode platform %s regex_exclude_filters: %w", p.ID, err) + } regionFiltersJSON, err := encodeStringSliceJSON(p.RegionFilters) if err != nil { return fmt.Errorf("encode platform %s region_filters: %w", p.ID, err) @@ -149,15 +156,16 @@ func (r *StateRepo) UpsertPlatform(p model.Platform) error { defer r.mu.Unlock() _, err = r.db.Exec(` - INSERT INTO platforms (id, name, sticky_ttl_ns, regex_filters_json, region_filters_json, + INSERT INTO platforms (id, name, sticky_ttl_ns, regex_filters_json, regex_exclude_filters_json, region_filters_json, reverse_proxy_miss_action, reverse_proxy_empty_account_behavior, reverse_proxy_fixed_account_header, allocation_policy, passive_circuit_breaker_disabled, updated_at_ns) - VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?) + VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?) ON CONFLICT(id) DO UPDATE SET name = excluded.name, sticky_ttl_ns = excluded.sticky_ttl_ns, regex_filters_json = excluded.regex_filters_json, + regex_exclude_filters_json = excluded.regex_exclude_filters_json, region_filters_json = excluded.region_filters_json, reverse_proxy_miss_action = excluded.reverse_proxy_miss_action, reverse_proxy_empty_account_behavior = excluded.reverse_proxy_empty_account_behavior, @@ -165,7 +173,7 @@ func (r *StateRepo) UpsertPlatform(p model.Platform) error { allocation_policy = excluded.allocation_policy, passive_circuit_breaker_disabled = excluded.passive_circuit_breaker_disabled, updated_at_ns = excluded.updated_at_ns - `, p.ID, p.Name, p.StickyTTLNs, regexFiltersJSON, regionFiltersJSON, + `, p.ID, p.Name, p.StickyTTLNs, regexFiltersJSON, regexExcludeFiltersJSON, regionFiltersJSON, p.ReverseProxyMissAction, p.ReverseProxyEmptyAccountBehavior, p.ReverseProxyFixedAccountHeader, p.AllocationPolicy, p.PassiveCircuitBreakerDisabled, p.UpdatedAtNs) if err != nil { @@ -220,17 +228,17 @@ func (r *StateRepo) GetPlatformName(id string) (string, error) { // GetPlatform returns one platform by ID. func (r *StateRepo) GetPlatform(id string) (*model.Platform, error) { - row := r.db.QueryRow(`SELECT id, name, sticky_ttl_ns, regex_filters_json, region_filters_json, + row := r.db.QueryRow(`SELECT id, name, sticky_ttl_ns, regex_filters_json, regex_exclude_filters_json, region_filters_json, reverse_proxy_miss_action, reverse_proxy_empty_account_behavior, reverse_proxy_fixed_account_header, allocation_policy, passive_circuit_breaker_disabled, updated_at_ns FROM platforms WHERE id = ?`, id) var p model.Platform - var regexFiltersJSON, regionFiltersJSON string + var regexFiltersJSON, regexExcludeFiltersJSON, regionFiltersJSON string var passiveCircuitBreakerDisabled int if err := row.Scan(&p.ID, &p.Name, &p.StickyTTLNs, ®exFiltersJSON, - ®ionFiltersJSON, &p.ReverseProxyMissAction, &p.ReverseProxyEmptyAccountBehavior, + ®exExcludeFiltersJSON, ®ionFiltersJSON, &p.ReverseProxyMissAction, &p.ReverseProxyEmptyAccountBehavior, &p.ReverseProxyFixedAccountHeader, &p.AllocationPolicy, &passiveCircuitBreakerDisabled, &p.UpdatedAtNs); err != nil { if err == sql.ErrNoRows { return nil, ErrNotFound @@ -242,18 +250,23 @@ func (r *StateRepo) GetPlatform(id string) (*model.Platform, error) { if err != nil { return nil, fmt.Errorf("decode platform %s regex_filters_json: %w", p.ID, err) } + regexExcludeFilters, err := decodeStringSliceJSON(regexExcludeFiltersJSON) + if err != nil { + return nil, fmt.Errorf("decode platform %s regex_exclude_filters_json: %w", p.ID, err) + } regionFilters, err := decodeStringSliceJSON(regionFiltersJSON) if err != nil { return nil, fmt.Errorf("decode platform %s region_filters_json: %w", p.ID, err) } p.RegexFilters = regexFilters + p.RegexExcludeFilters = regexExcludeFilters p.RegionFilters = regionFilters return &p, nil } // ListPlatforms returns all platforms. func (r *StateRepo) ListPlatforms() ([]model.Platform, error) { - rows, err := r.db.Query("SELECT id, name, sticky_ttl_ns, regex_filters_json, region_filters_json, reverse_proxy_miss_action, reverse_proxy_empty_account_behavior, reverse_proxy_fixed_account_header, allocation_policy, passive_circuit_breaker_disabled, updated_at_ns FROM platforms") + rows, err := r.db.Query("SELECT id, name, sticky_ttl_ns, regex_filters_json, regex_exclude_filters_json, region_filters_json, reverse_proxy_miss_action, reverse_proxy_empty_account_behavior, reverse_proxy_fixed_account_header, allocation_policy, passive_circuit_breaker_disabled, updated_at_ns FROM platforms") if err != nil { return nil, err } @@ -262,10 +275,10 @@ func (r *StateRepo) ListPlatforms() ([]model.Platform, error) { var result []model.Platform for rows.Next() { var p model.Platform - var regexFiltersJSON, regionFiltersJSON string + var regexFiltersJSON, regexExcludeFiltersJSON, regionFiltersJSON string var passiveCircuitBreakerDisabled int if err := rows.Scan(&p.ID, &p.Name, &p.StickyTTLNs, ®exFiltersJSON, - ®ionFiltersJSON, &p.ReverseProxyMissAction, &p.ReverseProxyEmptyAccountBehavior, + ®exExcludeFiltersJSON, ®ionFiltersJSON, &p.ReverseProxyMissAction, &p.ReverseProxyEmptyAccountBehavior, &p.ReverseProxyFixedAccountHeader, &p.AllocationPolicy, &passiveCircuitBreakerDisabled, &p.UpdatedAtNs); err != nil { return nil, err } @@ -274,11 +287,16 @@ func (r *StateRepo) ListPlatforms() ([]model.Platform, error) { if err != nil { return nil, fmt.Errorf("decode platform %s regex_filters_json: %w", p.ID, err) } + regexExcludeFilters, err := decodeStringSliceJSON(regexExcludeFiltersJSON) + if err != nil { + return nil, fmt.Errorf("decode platform %s regex_exclude_filters_json: %w", p.ID, err) + } regionFilters, err := decodeStringSliceJSON(regionFiltersJSON) if err != nil { return nil, fmt.Errorf("decode platform %s region_filters_json: %w", p.ID, err) } p.RegexFilters = regexFilters + p.RegexExcludeFilters = regexExcludeFilters p.RegionFilters = regionFilters result = append(result, p) } @@ -307,8 +325,10 @@ func (r *StateRepo) UpsertSubscription(s model.Subscription) error { _, err := r.db.Exec(` INSERT INTO subscriptions (id, name, source_type, url, content, update_interval_ns, enabled, - ephemeral, incremental_alive_nodes, ephemeral_node_evict_delay_ns, created_at_ns, updated_at_ns) - VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?) + ephemeral, incremental_alive_nodes, ephemeral_node_evict_delay_ns, + usage_upload_bytes, usage_download_bytes, usage_total_bytes, + usage_expire_unix, usage_updated_at_ns, created_at_ns, updated_at_ns) + VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?) ON CONFLICT(id) DO UPDATE SET name = excluded.name, source_type = excluded.source_type, @@ -319,9 +339,16 @@ func (r *StateRepo) UpsertSubscription(s model.Subscription) error { ephemeral = excluded.ephemeral, incremental_alive_nodes = excluded.incremental_alive_nodes, ephemeral_node_evict_delay_ns = excluded.ephemeral_node_evict_delay_ns, + usage_upload_bytes = excluded.usage_upload_bytes, + usage_download_bytes = excluded.usage_download_bytes, + usage_total_bytes = excluded.usage_total_bytes, + usage_expire_unix = excluded.usage_expire_unix, + usage_updated_at_ns = excluded.usage_updated_at_ns, updated_at_ns = excluded.updated_at_ns `, s.ID, s.Name, s.SourceType, s.URL, s.Content, s.UpdateIntervalNs, s.Enabled, - s.Ephemeral, s.IncrementalAliveNodes, s.EphemeralNodeEvictDelayNs, s.CreatedAtNs, s.UpdatedAtNs) + s.Ephemeral, s.IncrementalAliveNodes, s.EphemeralNodeEvictDelayNs, + s.UsageUploadBytes, s.UsageDownloadBytes, s.UsageTotalBytes, + s.UsageExpireUnix, s.UsageUpdatedAtNs, s.CreatedAtNs, s.UpdatedAtNs) return err } @@ -344,7 +371,9 @@ func (r *StateRepo) DeleteSubscription(id string) error { // ListSubscriptions returns all subscriptions. func (r *StateRepo) ListSubscriptions() ([]model.Subscription, error) { rows, err := r.db.Query(`SELECT id, name, source_type, url, content, update_interval_ns, enabled, - ephemeral, incremental_alive_nodes, ephemeral_node_evict_delay_ns, created_at_ns, updated_at_ns FROM subscriptions`) + ephemeral, incremental_alive_nodes, ephemeral_node_evict_delay_ns, + usage_upload_bytes, usage_download_bytes, usage_total_bytes, usage_expire_unix, usage_updated_at_ns, + created_at_ns, updated_at_ns FROM subscriptions`) if err != nil { return nil, err } @@ -354,7 +383,9 @@ func (r *StateRepo) ListSubscriptions() ([]model.Subscription, error) { for rows.Next() { var s model.Subscription if err := rows.Scan(&s.ID, &s.Name, &s.SourceType, &s.URL, &s.Content, &s.UpdateIntervalNs, &s.Enabled, - &s.Ephemeral, &s.IncrementalAliveNodes, &s.EphemeralNodeEvictDelayNs, &s.CreatedAtNs, &s.UpdatedAtNs); err != nil { + &s.Ephemeral, &s.IncrementalAliveNodes, &s.EphemeralNodeEvictDelayNs, + &s.UsageUploadBytes, &s.UsageDownloadBytes, &s.UsageTotalBytes, &s.UsageExpireUnix, &s.UsageUpdatedAtNs, + &s.CreatedAtNs, &s.UpdatedAtNs); err != nil { return nil, err } if s.SourceType == "" { diff --git a/internal/state/repo_state_test.go b/internal/state/repo_state_test.go index 8863fe86..7b9ee22e 100644 --- a/internal/state/repo_state_test.go +++ b/internal/state/repo_state_test.go @@ -1,6 +1,7 @@ package state import ( + "database/sql" "errors" "fmt" "reflect" @@ -27,6 +28,20 @@ func newTestStateRepo(t *testing.T) *StateRepo { return newStateRepo(db) } +func requireTableColumn(t *testing.T, db *sql.DB, table, column string) { + t.Helper() + if ok, err := hasTableColumn(db, table, column); err != nil || !ok { + t.Fatalf("expected migrated column %s.%s, ok=%v err=%v", table, column, ok, err) + } +} + +func forceStateMigrationVersion(t *testing.T, db *sql.DB, version int) { + t.Helper() + if _, err := db.Exec("UPDATE schema_migrations SET version = ?, dirty = 0", version); err != nil { + t.Fatalf("force schema_migrations version: %v", err) + } +} + func TestMigrateStateDB_UpgradesLegacyPlatformsColumns(t *testing.T) { dir := t.TempDir() db, err := OpenDB(dir + "/state.db") @@ -106,17 +121,87 @@ func TestMigrateStateDB_LegacyBaselineAdvancesToLatest(t *testing.T) { if dirty { t.Fatalf("schema_migrations dirty=true") } - if version != stateVersionAddPassiveCircuitBreakerDisabled { - t.Fatalf("schema_migrations version: got %d, want %d", version, stateVersionAddPassiveCircuitBreakerDisabled) + if version != stateVersionAddRegexExcludeFilters { + t.Fatalf("schema_migrations version: got %d, want %d", version, stateVersionAddRegexExcludeFilters) } if ok, err := hasTableColumn(db, "subscriptions", "incremental_alive_nodes"); err != nil || !ok { t.Fatalf("expected migrated column subscriptions.incremental_alive_nodes, ok=%v err=%v", ok, err) } + if ok, err := hasTableColumn(db, "subscriptions", "usage_updated_at_ns"); err != nil || !ok { + t.Fatalf("expected migrated column subscriptions.usage_updated_at_ns, ok=%v err=%v", ok, err) + } if ok, err := hasTableColumn(db, "platforms", "passive_circuit_breaker_disabled"); err != nil || !ok { t.Fatalf("expected migrated column platforms.passive_circuit_breaker_disabled, ok=%v err=%v", ok, err) } } +func TestMigrateStateDB_LegacyBaselineWithRegexExcludeColumn(t *testing.T) { + dir := t.TempDir() + db, err := OpenDB(dir + "/state.db") + if err != nil { + t.Fatal(err) + } + defer db.Close() + + _, err = db.Exec(` + CREATE TABLE platforms ( + id TEXT PRIMARY KEY, + name TEXT NOT NULL UNIQUE, + sticky_ttl_ns INTEGER NOT NULL, + regex_filters_json TEXT NOT NULL DEFAULT '[]', + regex_exclude_filters_json TEXT NOT NULL DEFAULT '[]', + region_filters_json TEXT NOT NULL DEFAULT '[]', + reverse_proxy_miss_action TEXT NOT NULL DEFAULT 'RANDOM', + reverse_proxy_empty_account_behavior TEXT NOT NULL DEFAULT 'RANDOM', + reverse_proxy_fixed_account_header TEXT NOT NULL DEFAULT '', + allocation_policy TEXT NOT NULL DEFAULT 'BALANCED', + passive_circuit_breaker_disabled INTEGER NOT NULL DEFAULT 0, + updated_at_ns INTEGER NOT NULL + ); + + CREATE TABLE subscriptions ( + id TEXT PRIMARY KEY, + name TEXT NOT NULL, + source_type TEXT NOT NULL DEFAULT 'remote', + url TEXT NOT NULL, + content TEXT NOT NULL DEFAULT '', + update_interval_ns INTEGER NOT NULL, + enabled INTEGER NOT NULL DEFAULT 1, + ephemeral INTEGER NOT NULL DEFAULT 0, + incremental_alive_nodes INTEGER NOT NULL DEFAULT 0, + ephemeral_node_evict_delay_ns INTEGER NOT NULL, + usage_upload_bytes INTEGER NOT NULL DEFAULT 0, + usage_download_bytes INTEGER NOT NULL DEFAULT 0, + usage_total_bytes INTEGER NOT NULL DEFAULT 0, + usage_expire_unix INTEGER NOT NULL DEFAULT 0, + usage_updated_at_ns INTEGER NOT NULL DEFAULT 0, + created_at_ns INTEGER NOT NULL, + updated_at_ns INTEGER NOT NULL + ) + `) + if err != nil { + t.Fatalf("create latest-like legacy tables: %v", err) + } + + if err := MigrateStateDB(db); err != nil { + t.Fatalf("MigrateStateDB: %v", err) + } + + requireTableColumn(t, db, "platforms", "regex_exclude_filters_json") + + var version int + var dirty bool + if err := db.QueryRow("SELECT version, dirty FROM schema_migrations LIMIT 1").Scan(&version, &dirty); err != nil { + t.Fatalf("read schema_migrations: %v", err) + } + if dirty { + t.Fatalf("schema_migrations dirty=true") + } + if version != stateVersionAddRegexExcludeFilters { + t.Fatalf("schema_migrations version: got %d, want %d", version, stateVersionAddRegexExcludeFilters) + } +} + func TestMigrateStateDB_AddsIncrementalAliveNodesToLegacySubscriptions(t *testing.T) { dir := t.TempDir() db, err := OpenDB(dir + "/state.db") @@ -173,8 +258,11 @@ func TestMigrateStateDB_AddsIncrementalAliveNodesToLegacySubscriptions(t *testin if dirty { t.Fatalf("schema_migrations dirty=true") } - if version != stateVersionAddPassiveCircuitBreakerDisabled { - t.Fatalf("schema_migrations version: got %d, want %d", version, stateVersionAddPassiveCircuitBreakerDisabled) + if version != stateVersionAddRegexExcludeFilters { + t.Fatalf("schema_migrations version: got %d, want %d", version, stateVersionAddRegexExcludeFilters) + } + if ok, err := hasTableColumn(db, "subscriptions", "usage_updated_at_ns"); err != nil || !ok { + t.Fatalf("expected migrated column subscriptions.usage_updated_at_ns, ok=%v err=%v", ok, err) } if ok, err := hasTableColumn(db, "platforms", "passive_circuit_breaker_disabled"); err != nil || !ok { t.Fatalf("expected migrated column platforms.passive_circuit_breaker_disabled, ok=%v err=%v", ok, err) @@ -248,17 +336,132 @@ func TestMigrateStateDB_NormalizesLegacyRandomMissAction(t *testing.T) { if dirty { t.Fatalf("schema_migrations dirty=true") } - if version != stateVersionAddPassiveCircuitBreakerDisabled { - t.Fatalf("schema_migrations version: got %d, want %d", version, stateVersionAddPassiveCircuitBreakerDisabled) + if version != stateVersionAddRegexExcludeFilters { + t.Fatalf("schema_migrations version: got %d, want %d", version, stateVersionAddRegexExcludeFilters) } if ok, err := hasTableColumn(db, "subscriptions", "incremental_alive_nodes"); err != nil || !ok { t.Fatalf("expected migrated column subscriptions.incremental_alive_nodes, ok=%v err=%v", ok, err) } + if ok, err := hasTableColumn(db, "subscriptions", "usage_updated_at_ns"); err != nil || !ok { + t.Fatalf("expected migrated column subscriptions.usage_updated_at_ns, ok=%v err=%v", ok, err) + } if ok, err := hasTableColumn(db, "platforms", "passive_circuit_breaker_disabled"); err != nil || !ok { t.Fatalf("expected migrated column platforms.passive_circuit_breaker_disabled, ok=%v err=%v", ok, err) } } +func TestMigrateStateDB_RepairsForkVersion6SubscriptionUsage(t *testing.T) { + dir := t.TempDir() + db, err := OpenDB(dir + "/state.db") + if err != nil { + t.Fatal(err) + } + defer db.Close() + + if err := MigrateStateDB(db); err != nil { + t.Fatalf("initial MigrateStateDB: %v", err) + } + if _, err := db.Exec("ALTER TABLE platforms DROP COLUMN passive_circuit_breaker_disabled"); err != nil { + t.Fatalf("simulate fork version 6 schema: %v", err) + } + forceStateMigrationVersion(t, db, stateVersionAddPassiveCircuitBreakerDisabled) + + if err := MigrateStateDB(db); err != nil { + t.Fatalf("MigrateStateDB: %v", err) + } + + requireTableColumn(t, db, "platforms", "passive_circuit_breaker_disabled") + requireTableColumn(t, db, "subscriptions", "usage_updated_at_ns") + + var version int + var dirty bool + if err := db.QueryRow("SELECT version, dirty FROM schema_migrations LIMIT 1").Scan(&version, &dirty); err != nil { + t.Fatalf("read schema_migrations: %v", err) + } + if dirty { + t.Fatalf("schema_migrations dirty=true") + } + if version != stateVersionAddRegexExcludeFilters { + t.Fatalf("schema_migrations version: got %d, want %d", version, stateVersionAddRegexExcludeFilters) + } +} + +func TestMigrateStateDB_RepairsUpstreamVersion6PassiveCircuitBreaker(t *testing.T) { + dir := t.TempDir() + db, err := OpenDB(dir + "/state.db") + if err != nil { + t.Fatal(err) + } + defer db.Close() + + if err := MigrateStateDB(db); err != nil { + t.Fatalf("initial MigrateStateDB: %v", err) + } + for _, column := range []string{ + "usage_updated_at_ns", + "usage_expire_unix", + "usage_total_bytes", + "usage_download_bytes", + "usage_upload_bytes", + } { + if _, err := db.Exec("ALTER TABLE subscriptions DROP COLUMN " + column); err != nil { + t.Fatalf("simulate upstream version 6 schema: drop %s: %v", column, err) + } + } + forceStateMigrationVersion(t, db, stateVersionAddPassiveCircuitBreakerDisabled) + + if err := MigrateStateDB(db); err != nil { + t.Fatalf("MigrateStateDB: %v", err) + } + + requireTableColumn(t, db, "platforms", "passive_circuit_breaker_disabled") + requireTableColumn(t, db, "subscriptions", "usage_updated_at_ns") + + var version int + var dirty bool + if err := db.QueryRow("SELECT version, dirty FROM schema_migrations LIMIT 1").Scan(&version, &dirty); err != nil { + t.Fatalf("read schema_migrations: %v", err) + } + if dirty { + t.Fatalf("schema_migrations dirty=true") + } + if version != stateVersionAddRegexExcludeFilters { + t.Fatalf("schema_migrations version: got %d, want %d", version, stateVersionAddRegexExcludeFilters) + } +} + +func TestMigrateStateDB_RepairsVersion7WithRegexExcludeColumn(t *testing.T) { + dir := t.TempDir() + db, err := OpenDB(dir + "/state.db") + if err != nil { + t.Fatal(err) + } + defer db.Close() + + if err := MigrateStateDB(db); err != nil { + t.Fatalf("initial MigrateStateDB: %v", err) + } + forceStateMigrationVersion(t, db, stateVersionAddSubscriptionUsageInfo) + + if err := MigrateStateDB(db); err != nil { + t.Fatalf("MigrateStateDB: %v", err) + } + + requireTableColumn(t, db, "platforms", "regex_exclude_filters_json") + + var version int + var dirty bool + if err := db.QueryRow("SELECT version, dirty FROM schema_migrations LIMIT 1").Scan(&version, &dirty); err != nil { + t.Fatalf("read schema_migrations: %v", err) + } + if dirty { + t.Fatalf("schema_migrations dirty=true") + } + if version != stateVersionAddRegexExcludeFilters { + t.Fatalf("schema_migrations version: got %d, want %d", version, stateVersionAddRegexExcludeFilters) + } +} + // --- system_config --- func TestStateRepo_SystemConfig_RoundTrip(t *testing.T) { @@ -469,6 +672,13 @@ func TestStateRepo_Platform_ValidationRejectsInvalidRegex(t *testing.T) { t.Fatal("expected error for uncompilable regex") } + // Uncompilable exclude regex. + bad = base + bad.RegexExcludeFilters = []string{"(unclosed"} + if err := repo.UpsertPlatform(bad); err == nil { + t.Fatal("expected error for uncompilable exclude regex") + } + // Invalid region_filters. bad = base bad.RegionFilters = []string{""} @@ -478,6 +688,7 @@ func TestStateRepo_Platform_ValidationRejectsInvalidRegex(t *testing.T) { // Valid config should still succeed. base.RegexFilters = []string{"^ss$", "vmess"} + base.RegexExcludeFilters = []string{"bad"} base.RegionFilters = []string{"us", "jp"} if err := repo.UpsertPlatform(base); err != nil { t.Fatalf("valid platform rejected: %v", err) @@ -488,6 +699,9 @@ func TestStateRepo_Platform_ValidationRejectsInvalidRegex(t *testing.T) { if len(list) != 1 { t.Fatalf("expected 1 platform, got %d", len(list)) } + if !reflect.DeepEqual(list[0].RegexExcludeFilters, []string{"bad"}) { + t.Fatalf("regex_exclude_filters = %v, want [bad]", list[0].RegexExcludeFilters) + } } func TestStateRepo_Platform_ValidationRejectsInvalidName(t *testing.T) { @@ -634,6 +848,45 @@ func TestStateRepo_Subscription_LocalSourcePersists(t *testing.T) { } } +func TestStateRepo_Subscription_UsageInfoPersists(t *testing.T) { + repo := newTestStateRepo(t) + now := time.Now().UnixNano() + + s := model.Subscription{ + ID: "sub-usage", + Name: "UsageSub", + SourceType: "remote", + URL: "https://example.com/sub", + UpdateIntervalNs: int64(time.Hour), + Enabled: true, + Ephemeral: false, + EphemeralNodeEvictDelayNs: int64(72 * time.Hour), + UsageUploadBytes: 1024, + UsageDownloadBytes: 2048, + UsageTotalBytes: 4096, + UsageExpireUnix: 1893456000, + UsageUpdatedAtNs: now, + CreatedAtNs: now, + UpdatedAtNs: now, + } + if err := repo.UpsertSubscription(s); err != nil { + t.Fatal(err) + } + + list, err := repo.ListSubscriptions() + if err != nil { + t.Fatal(err) + } + if len(list) != 1 { + t.Fatalf("expected 1 subscription, got %d", len(list)) + } + got := list[0] + if got.UsageUploadBytes != 1024 || got.UsageDownloadBytes != 2048 || got.UsageTotalBytes != 4096 || + got.UsageExpireUnix != 1893456000 || got.UsageUpdatedAtNs != now { + t.Fatalf("unexpected usage fields: %+v", got) + } +} + // --- account_header_rules --- func TestStateRepo_AccountHeaderRules_CRUD(t *testing.T) { diff --git a/internal/subscription/subscription.go b/internal/subscription/subscription.go index 565fc395..e7913804 100644 --- a/internal/subscription/subscription.go +++ b/internal/subscription/subscription.go @@ -32,6 +32,15 @@ type ManagedNode struct { Evicted bool } +// UsageInfo contains optional usage metadata reported by a subscription source. +type UsageInfo struct { + UploadBytes int64 + DownloadBytes int64 + TotalBytes int64 + ExpireUnix int64 + UpdatedAtNs int64 +} + // ManagedNodes wraps hash->ManagedNode map. // // Maintenance rule: @@ -129,6 +138,7 @@ type Subscription struct { // ephemeralNodeEvictDelayNs is the per-subscription eviction delay for // circuit-broken nodes when Ephemeral is enabled. ephemeralNodeEvictDelayNs int64 + usage UsageInfo // Persistence timestamps (written under mu or single-writer context). CreatedAtNs int64 @@ -331,6 +341,20 @@ func (s *Subscription) SetEphemeralNodeEvictDelayNs(v int64) { s.mu.Unlock() } +// Usage returns the latest subscription usage metadata. +func (s *Subscription) Usage() UsageInfo { + s.mu.RLock() + defer s.mu.RUnlock() + return s.usage +} + +// SetUsage updates the latest subscription usage metadata. +func (s *Subscription) SetUsage(usage UsageInfo) { + s.mu.Lock() + s.usage = usage + s.mu.Unlock() +} + // ManagedNodes returns the current node view via atomic load. func (s *Subscription) ManagedNodes() *ManagedNodes { return s.managedNodes.Load() diff --git a/internal/subscription/userinfo.go b/internal/subscription/userinfo.go new file mode 100644 index 00000000..dae2058c --- /dev/null +++ b/internal/subscription/userinfo.go @@ -0,0 +1,49 @@ +package subscription + +import ( + "strconv" + "strings" +) + +const SubscriptionUserinfoHeader = "Subscription-Userinfo" + +// ParseSubscriptionUserinfo parses the common subscription usage header: +// upload=...; download=...; total=...; expire=... +func ParseSubscriptionUserinfo(raw string, updatedAtNs int64) (UsageInfo, bool) { + raw = strings.TrimSpace(raw) + if raw == "" { + return UsageInfo{}, false + } + + info := UsageInfo{UpdatedAtNs: updatedAtNs} + found := false + for _, part := range strings.Split(raw, ";") { + key, value, ok := strings.Cut(part, "=") + if !ok { + continue + } + n, err := strconv.ParseInt(strings.TrimSpace(value), 10, 64) + if err != nil || n < 0 { + continue + } + + switch strings.ToLower(strings.TrimSpace(key)) { + case "upload": + info.UploadBytes = n + found = true + case "download": + info.DownloadBytes = n + found = true + case "total": + info.TotalBytes = n + found = true + case "expire": + info.ExpireUnix = n + found = true + } + } + if !found { + return UsageInfo{}, false + } + return info, true +} diff --git a/internal/subscription/userinfo_test.go b/internal/subscription/userinfo_test.go new file mode 100644 index 00000000..713e38cc --- /dev/null +++ b/internal/subscription/userinfo_test.go @@ -0,0 +1,29 @@ +package subscription + +import "testing" + +func TestParseSubscriptionUserinfo(t *testing.T) { + info, ok := ParseSubscriptionUserinfo("upload=1024; download=2048; total=4096; expire=1893456000", 123) + if !ok { + t.Fatal("expected userinfo to parse") + } + if info.UploadBytes != 1024 || info.DownloadBytes != 2048 || info.TotalBytes != 4096 || info.ExpireUnix != 1893456000 || info.UpdatedAtNs != 123 { + t.Fatalf("unexpected usage info: %+v", info) + } +} + +func TestParseSubscriptionUserinfo_IgnoresInvalidFields(t *testing.T) { + info, ok := ParseSubscriptionUserinfo("upload=bad; download=8; total=-1; expire=bad", 456) + if !ok { + t.Fatal("expected valid fields to parse") + } + if info.UploadBytes != 0 || info.DownloadBytes != 8 || info.TotalBytes != 0 || info.ExpireUnix != 0 || info.UpdatedAtNs != 456 { + t.Fatalf("unexpected usage info: %+v", info) + } +} + +func TestParseSubscriptionUserinfo_Empty(t *testing.T) { + if info, ok := ParseSubscriptionUserinfo("", 123); ok || info != (UsageInfo{}) { + t.Fatalf("empty userinfo parsed: ok=%v info=%+v", ok, info) + } +} diff --git a/internal/topology/pool.go b/internal/topology/pool.go index e8fa1ca7..b4828a4d 100644 --- a/internal/topology/pool.go +++ b/internal/topology/pool.go @@ -401,6 +401,22 @@ func (p *GlobalNodePool) IsNodeDisabled(hash node.Hash) bool { return entry.IsDisabledBySubscriptions(p.MakeSubLookup()) } +// SetNodeManualDisable flips the admin-controlled disable flag and triggers +// per-platform re-evaluation so the node is added to or removed from each +// platform's routable view in the same call. Returns true when the flag value +// actually changed (i.e. the caller transitioned the node). +func (p *GlobalNodePool) SetNodeManualDisable(hash node.Hash, disabled bool) bool { + entry, ok := p.GetEntry(hash) + if !ok || entry == nil { + return false + } + if entry.ManuallyDisabled.Swap(disabled) == disabled { + return false + } + p.notifyAllPlatformsDirty(hash) + return true +} + // MakeHealthyAndEnabledEvaluator builds a predicate for pool-context health // aggregates: the node must not be disabled by subscription state and must // satisfy the entry-local health checks. diff --git a/internal/topology/scheduler_test.go b/internal/topology/scheduler_test.go index 0e6a7628..d5b17f5f 100644 --- a/internal/topology/scheduler_test.go +++ b/internal/topology/scheduler_test.go @@ -94,6 +94,37 @@ func TestScheduler_UpdateSubscription_Success(t *testing.T) { } } +func TestScheduler_UpdateSubscription_StoresUsageFromResponseHeader(t *testing.T) { + subMgr := NewSubscriptionManager() + sub := subscription.NewSubscription("s1", "TestSub", "http://example.com", true, false) + sub.SetFetchConfig(sub.URL(), int64(time.Hour)) + subMgr.Register(sub) + + pool := newTestPool(subMgr) + body := makeSubscriptionJSON( + `{"type":"shadowsocks","tag":"us-1","server":"1.1.1.1","server_port":443}`, + ) + sched := NewSubscriptionScheduler(SchedulerConfig{ + SubManager: subMgr, + Pool: pool, + MetadataFetcher: func(string) (netutil.DownloadResponse, error) { + header := http.Header{} + header.Set(subscription.SubscriptionUserinfoHeader, "upload=1024; download=2048; total=4096; expire=1893456000") + return netutil.DownloadResponse{Body: body, Header: header}, nil + }, + }) + + sched.UpdateSubscription(sub) + + usage := sub.Usage() + if usage.UploadBytes != 1024 || usage.DownloadBytes != 2048 || usage.TotalBytes != 4096 || usage.ExpireUnix != 1893456000 { + t.Fatalf("unexpected usage: %+v", usage) + } + if usage.UpdatedAtNs == 0 { + t.Fatal("usage updated_at should be set") + } +} + func TestScheduler_UpdateSubscription_DownloadViaHTTPServer(t *testing.T) { subMgr := NewSubscriptionManager() pool := newTestPool(subMgr) diff --git a/internal/topology/subscription_scheduler.go b/internal/topology/subscription_scheduler.go index f3bb150b..128be23b 100644 --- a/internal/topology/subscription_scheduler.go +++ b/internal/topology/subscription_scheduler.go @@ -25,7 +25,8 @@ type SubscriptionScheduler struct { // Fetcher fetches subscription data from a URL. // Defaults to downloader.Download; injectable for testing. - Fetcher func(url string) ([]byte, error) + Fetcher func(url string) ([]byte, error) + MetadataFetcher func(url string) (netutil.DownloadResponse, error) // For persistence. onSubUpdated func(sub *subscription.Subscription) @@ -39,11 +40,13 @@ type SubscriptionScheduler struct { // SchedulerConfig configures the SubscriptionScheduler. type SchedulerConfig struct { - SubManager *SubscriptionManager - Pool *GlobalNodePool - Downloader netutil.Downloader // shared downloader - Fetcher func(url string) ([]byte, error) // optional, defaults to Downloader.Download - OnSubUpdated func(sub *subscription.Subscription) + SubManager *SubscriptionManager + Pool *GlobalNodePool + Downloader netutil.Downloader // shared downloader + Fetcher func(url string) ([]byte, error) // optional, defaults to Downloader.Download + // MetadataFetcher is optional and preserves response headers for subscription usage. + MetadataFetcher func(url string) (netutil.DownloadResponse, error) + OnSubUpdated func(sub *subscription.Subscription) // OnSubReenabledNode is fired after false->true enabled transition. OnSubReenabledNode func(hash node.Hash) } @@ -66,6 +69,11 @@ func NewSubscriptionScheduler(cfg SchedulerConfig) *SubscriptionScheduler { } else { sched.Fetcher = sched.fetchViaDownloader } + if cfg.MetadataFetcher != nil { + sched.MetadataFetcher = cfg.MetadataFetcher + } else if cfg.Fetcher == nil { + sched.MetadataFetcher = sched.fetchViaDownloaderWithMetadata + } return sched } @@ -200,13 +208,15 @@ func (s *SubscriptionScheduler) UpdateSubscription(sub *subscription.Subscriptio // 1. Fetch/read content (lock-free). var ( - body []byte - err error + body []byte + usage subscription.UsageInfo + hasUsage bool + err error ) if attemptSourceType == subscription.SourceTypeLocal { body = []byte(attemptContent) } else { - body, err = s.Fetcher(attemptURL) + body, usage, hasUsage, err = s.fetchRemoteSubscription(attemptURL) if err != nil { s.handleUpdateFailure(sub, attemptStartedNs, attemptSeq, attemptConfigVersion, "fetch", err) return @@ -323,6 +333,12 @@ func (s *SubscriptionScheduler) UpdateSubscription(sub *subscription.Subscriptio now := time.Now().UnixNano() sub.LastCheckedNs.Store(now) sub.LastUpdatedNs.Store(now) + if hasUsage { + usage.UpdatedAtNs = now + sub.SetUsage(usage) + } else { + sub.SetUsage(subscription.UsageInfo{}) + } sub.MarkAppliedAttempt(attemptSeq) sub.SetLastError("") applied = true @@ -450,3 +466,34 @@ func (s *SubscriptionScheduler) RenameSubscription(sub *subscription.Subscriptio func (s *SubscriptionScheduler) fetchViaDownloader(url string) ([]byte, error) { return s.downloader.Download(s.downloadCtx, url) } + +func (s *SubscriptionScheduler) fetchViaDownloaderWithMetadata(url string) (netutil.DownloadResponse, error) { + if downloader, ok := s.downloader.(netutil.MetadataDownloader); ok { + return downloader.DownloadWithMetadata(s.downloadCtx, url) + } + body, err := s.downloader.Download(s.downloadCtx, url) + if err != nil { + return netutil.DownloadResponse{}, err + } + return netutil.DownloadResponse{Body: body}, nil +} + +func (s *SubscriptionScheduler) fetchRemoteSubscription(url string) ([]byte, subscription.UsageInfo, bool, error) { + if s.MetadataFetcher != nil { + resp, err := s.MetadataFetcher(url) + if err != nil { + return nil, subscription.UsageInfo{}, false, err + } + usage, ok := subscription.ParseSubscriptionUserinfo( + resp.Header.Get(subscription.SubscriptionUserinfoHeader), + 0, + ) + return resp.Body, usage, ok, nil + } + + body, err := s.Fetcher(url) + if err != nil { + return nil, subscription.UsageInfo{}, false, err + } + return body, subscription.UsageInfo{}, false, nil +} diff --git a/webui/index.html b/webui/index.html index 939be235..5edaa66b 100644 --- a/webui/index.html +++ b/webui/index.html @@ -7,6 +7,15 @@ Resin · Sticky Proxy Pool +
diff --git a/webui/package-lock.json b/webui/package-lock.json index 88505cff..96b10159 100644 --- a/webui/package-lock.json +++ b/webui/package-lock.json @@ -73,7 +73,6 @@ "integrity": "sha512-CGOfOJqWjg2qW/Mb6zNsDm+u5vFQ8DxXfbM09z69p5Z6+mE1ikP2jUXw+j42Pf1XTYED2Rni5f95npYeuwMDQA==", "dev": true, "license": "MIT", - "peer": true, "dependencies": { "@babel/code-frame": "^7.29.0", "@babel/generator": "^7.29.0", @@ -1666,7 +1665,6 @@ "integrity": "sha512-oH72nZRfDv9lADUBSo104Aq7gPHpQZc4BTx38r9xf9pg5LfP6EzSyH2n7qFmmxRQXh7YlUXODcYsg6PuTDSxGg==", "dev": true, "license": "MIT", - "peer": true, "dependencies": { "undici-types": "~7.16.0" } @@ -1677,7 +1675,6 @@ "integrity": "sha512-ilcTH/UniCkMdtexkoCN0bI7pMcJDvmQFPvuPvmEaYA/NSfFTAgdUSLAoVjaRJm7+6PvcM+q1zYOwS4wTYMF9w==", "devOptional": true, "license": "MIT", - "peer": true, "dependencies": { "csstype": "^3.2.2" } @@ -1743,7 +1740,6 @@ "integrity": "sha512-IgSWvLobTDOjnaxAfDTIHaECbkNlAlKv2j5SjpB2v7QHKv1FIfjwMy8FsDbVfDX/KjmCmYICcw7uGaXLhtsLNg==", "dev": true, "license": "MIT", - "peer": true, "dependencies": { "@typescript-eslint/scope-manager": "8.56.0", "@typescript-eslint/types": "8.56.0", @@ -2008,7 +2004,6 @@ "integrity": "sha512-UVJyE9MttOsBQIDKw1skb9nAwQuR5wuGD3+82K6JgJlm/Y+KI92oNsMNGZCYdDsVtRHSak0pcV5Dno5+4jh9sw==", "dev": true, "license": "MIT", - "peer": true, "bin": { "acorn": "bin/acorn" }, @@ -2117,7 +2112,6 @@ } ], "license": "MIT", - "peer": true, "dependencies": { "baseline-browser-mapping": "^2.9.0", "caniuse-lite": "^1.0.30001759", @@ -2491,7 +2485,6 @@ "integrity": "sha512-LEyamqS7W5HB3ujJyvi0HQK/dtVINZvd5mAAp9eT5S/ujByGjiZLCzPcHVzuXbpJDJF/cxwHlfceVUDZ2lnSTw==", "dev": true, "license": "MIT", - "peer": true, "dependencies": { "@eslint-community/eslint-utils": "^4.8.0", "@eslint-community/regexpp": "^4.12.1", @@ -2911,7 +2904,6 @@ } ], "license": "MIT", - "peer": true, "dependencies": { "@babel/runtime": "^7.28.4" }, @@ -3307,7 +3299,6 @@ "integrity": "sha512-5gTmgEY/sqK6gFXLIsQNH19lWb4ebPDLA4SdLP7dsWkIXHWlG66oPuVvXSGFPppYZz8ZDZq0dYYrbHfBCVUb1Q==", "dev": true, "license": "MIT", - "peer": true, "engines": { "node": ">=12" }, @@ -3369,7 +3360,6 @@ "resolved": "https://registry.npmjs.org/react/-/react-19.2.4.tgz", "integrity": "sha512-9nfp2hYpCwOjAN+8TZFGhtWEwgvWHXqESH8qT89AT/lWklpLON22Lc8pEtnpsZz7VmawabSU0gCjnj8aC0euHQ==", "license": "MIT", - "peer": true, "engines": { "node": ">=0.10.0" } @@ -3379,7 +3369,6 @@ "resolved": "https://registry.npmjs.org/react-dom/-/react-dom-19.2.4.tgz", "integrity": "sha512-AXJdLo8kgMbimY95O2aKQqsz2iWi9jMgKJhRBAxECE4IFxfcazB2LmzloIoibJI3C12IlY20+KFaLv+71bUJeQ==", "license": "MIT", - "peer": true, "dependencies": { "scheduler": "^0.27.0" }, @@ -3392,7 +3381,6 @@ "resolved": "https://registry.npmjs.org/react-hook-form/-/react-hook-form-7.71.1.tgz", "integrity": "sha512-9SUJKCGKo8HUSsCO+y0CtqkqI5nNuaDqTxyqPsZPqIwudpj4rCrAz/jZV+jn57bx5gtZKOh3neQu94DXMc+w5w==", "license": "MIT", - "peer": true, "engines": { "node": ">=18.0.0" }, @@ -3443,7 +3431,6 @@ "resolved": "https://registry.npmjs.org/react-redux/-/react-redux-9.2.0.tgz", "integrity": "sha512-ROY9fvHhwOD9ySfrF0wmvu//bKCQ6AeZZq1nJNtbDC+kk5DuSuNX/n6YWYF/SYy7bSba4D4FSz8DJeKY/S/r+g==", "license": "MIT", - "peer": true, "dependencies": { "@types/use-sync-external-store": "^0.0.6", "use-sync-external-store": "^1.4.0" @@ -3538,8 +3525,7 @@ "version": "5.0.1", "resolved": "https://registry.npmjs.org/redux/-/redux-5.0.1.tgz", "integrity": "sha512-M9/ELqF6fy8FwmkpnF0S3YKOqMyoWJ4+CS5Efg2ct3oY9daQvd/Pc71FpGZsVsbl3Cpb+IIcjBDUnnyBdQbq4w==", - "license": "MIT", - "peer": true + "license": "MIT" }, "node_modules/redux-thunk": { "version": "3.1.0", @@ -3747,7 +3733,6 @@ "integrity": "sha512-jl1vZzPDinLr9eUt3J/t7V6FgNEw9QjvBPdysz9KfQDD41fQrC2Y4vKQdiaUpFT4bXlb1RHhLpp8wtm6M5TgSw==", "devOptional": true, "license": "Apache-2.0", - "peer": true, "bin": { "tsc": "bin/tsc", "tsserver": "bin/tsserver" @@ -3865,7 +3850,6 @@ "integrity": "sha512-w+N7Hifpc3gRjZ63vYBXA56dvvRlNWRczTdmCBBa+CotUzAPf5b7YMdMR/8CQoeYE5LX3W4wj6RYTgonm1b9DA==", "dev": true, "license": "MIT", - "peer": true, "dependencies": { "esbuild": "^0.27.0", "fdir": "^6.5.0", @@ -3995,7 +3979,6 @@ "resolved": "https://registry.npmjs.org/zod/-/zod-4.3.6.tgz", "integrity": "sha512-rftlrkhHZOcjDwkGlnUtZZkvaPHCsDATp4pGpuOOMDaTdDDXF91wuVDJoWoPsKX/3YPQ5fHuF3STjcYyKr+Qhg==", "license": "MIT", - "peer": true, "funding": { "url": "https://github.com/sponsors/colinhacks" } diff --git a/webui/src/components/AppShell.tsx b/webui/src/components/AppShell.tsx index 6e49f9c7..0d8fe42c 100644 --- a/webui/src/components/AppShell.tsx +++ b/webui/src/components/AppShell.tsx @@ -20,6 +20,7 @@ import { useAuthStore } from "../features/auth/auth-store"; import { getEnvConfig } from "../features/systemConfig/api"; import { useI18n } from "../i18n"; import { LanguageSwitcher } from "./LanguageSwitcher"; +import { ThemeToggle } from "./ThemeToggle"; type NavItem = { label: string; @@ -151,6 +152,7 @@ export function AppShell() { ) : ( + + {t("正则排除")} + {regexExcludeCount} + {t("租约时长")} {stickyTTL} @@ -502,6 +353,17 @@ export function PlatformDetailPage() { ) : null} + {activeTab === "leases" ? ( +
+ +
+ ) : null} + {activeTab === "config" ? (
+
+ +