Compare commits
43 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
169a60690d | ||
|
|
819c29b843 | ||
|
|
f1f875a325 | ||
|
|
8175fa7fae | ||
|
|
60bbadde5b | ||
|
|
a09aadb40f | ||
|
|
a178233898 | ||
|
|
f752e7fe47 | ||
|
|
52afa7d3e6 | ||
|
|
95867d1a82 | ||
|
|
12556b3d48 | ||
|
|
9694d199d5 | ||
|
|
71c295474a | ||
|
|
daa4d71cc9 | ||
|
|
fe784dc01a | ||
|
|
9c79951e0d | ||
|
|
00cedaad11 | ||
|
|
a827a26733 | ||
|
|
76e38c3004 | ||
|
|
9cb1d12901 | ||
|
|
17dab506d3 | ||
|
|
1fb8aff952 | ||
|
|
c04bc468b4 | ||
|
|
0001c58876 | ||
|
|
4f1d7526f1 | ||
|
|
84d5a7ec19 | ||
|
|
a7b70e4cb2 | ||
|
|
79248500cb | ||
|
|
de1f1b0fe4 | ||
|
|
d48175430b | ||
|
|
9ad191ed39 | ||
|
|
b9e8dea1b0 | ||
|
|
f54dc6494d | ||
|
|
9b65b07710 | ||
|
|
36ab458cb6 | ||
|
|
6f95939dd3 | ||
|
|
20711b1513 | ||
|
|
bd792cbdd7 | ||
|
|
a8ba8108b0 | ||
|
|
2e32922701 | ||
|
|
2a308a722c | ||
|
|
6554bf6288 | ||
|
|
8cbc63cf2d |
2
.github/mihomo-meta.rev
vendored
2
.github/mihomo-meta.rev
vendored
@@ -1 +1 @@
|
||||
5e22035118d13fa609164670111cc674906bb2a4
|
||||
fc8c5a24b16991f98cd736950c17d1aa306a5041
|
||||
|
||||
88
.github/workflows/build-dockerhub.yml
vendored
88
.github/workflows/build-dockerhub.yml
vendored
@@ -16,7 +16,17 @@ on:
|
||||
branches:
|
||||
- master
|
||||
- dev
|
||||
workflow_dispatch: {}
|
||||
workflow_dispatch:
|
||||
inputs:
|
||||
release_tag:
|
||||
description: "Existing release tag to rebuild, e.g. v1.2.3"
|
||||
required: false
|
||||
type: string
|
||||
overwrite_existing_release:
|
||||
description: "Overwrite assets/body of an existing GitHub Release"
|
||||
required: false
|
||||
default: false
|
||||
type: boolean
|
||||
schedule:
|
||||
- cron: '0 19 * * 0'
|
||||
|
||||
@@ -53,12 +63,44 @@ jobs:
|
||||
|
||||
- name: Determine version
|
||||
id: version
|
||||
env:
|
||||
DISPATCH_RELEASE_TAG: ${{ inputs.release_tag }}
|
||||
OVERWRITE_EXISTING_RELEASE: ${{ inputs.overwrite_existing_release }}
|
||||
run: |
|
||||
if [[ "${{ github.ref }}" == refs/tags/* ]]; then
|
||||
VERSION="${GITHUB_REF#refs/tags/}"
|
||||
echo "version=$VERSION" >> $GITHUB_OUTPUT
|
||||
echo "is_release=true" >> $GITHUB_OUTPUT
|
||||
echo "Detected version from tag: $VERSION"
|
||||
elif [[ "${{ github.event_name }}" == "workflow_dispatch" && "$OVERWRITE_EXISTING_RELEASE" == "true" ]]; then
|
||||
git fetch --tags --force origin
|
||||
|
||||
VERSION="${DISPATCH_RELEASE_TAG:-}"
|
||||
VERSION="${VERSION#"${VERSION%%[![:space:]]*}"}"
|
||||
VERSION="${VERSION%"${VERSION##*[![:space:]]}"}"
|
||||
|
||||
if [[ "$VERSION" != v* ]]; then
|
||||
VERSION="v$VERSION"
|
||||
fi
|
||||
|
||||
if [[ ! "$VERSION" =~ ^v[0-9]+\.[0-9]+\.[0-9]+$ ]]; then
|
||||
echo "::error::Invalid release tag '$VERSION'. Use vX.Y.Z, for example v1.2.3."
|
||||
exit 1
|
||||
fi
|
||||
|
||||
if ! git ls-remote --exit-code --tags origin "refs/tags/$VERSION" >/dev/null 2>&1; then
|
||||
echo "::error::Tag '$VERSION' does not exist on origin."
|
||||
exit 1
|
||||
fi
|
||||
|
||||
TAG_COMMIT="$(git rev-list -n 1 "$VERSION")"
|
||||
MASTER_COMMIT="$(git rev-parse HEAD)"
|
||||
echo "Overwrite release tag: $VERSION"
|
||||
echo "Tag commit: $TAG_COMMIT"
|
||||
echo "Master commit used for rebuilt assets: $MASTER_COMMIT"
|
||||
|
||||
echo "version=$VERSION" >> $GITHUB_OUTPUT
|
||||
echo "is_release=true" >> $GITHUB_OUTPUT
|
||||
elif [[ "${{ github.ref }}" == refs/heads/master ]] && ([[ "${{ github.event_name }}" == "workflow_dispatch" ]] || [[ "${{ github.event_name }}" == "schedule" ]]); then
|
||||
LATEST_TAG=$(git describe --tags --abbrev=0 2>/dev/null || echo "")
|
||||
if [ -n "$LATEST_TAG" ]; then
|
||||
@@ -505,7 +547,7 @@ jobs:
|
||||
name: "🚀 Release Artifacts"
|
||||
runs-on: ubuntu-latest
|
||||
needs: [prepare, build-linux, build-windows-amd64, merge-manifest]
|
||||
if: startsWith(github.ref, 'refs/tags/')
|
||||
if: startsWith(github.ref, 'refs/tags/') || (github.event_name == 'workflow_dispatch' && inputs.overwrite_existing_release == true)
|
||||
permissions:
|
||||
contents: write
|
||||
steps:
|
||||
@@ -515,32 +557,42 @@ jobs:
|
||||
fetch-depth: 0
|
||||
|
||||
- name: Prepare Release Notes Context
|
||||
env:
|
||||
RELEASE_VERSION: ${{ needs.prepare.outputs.version }}
|
||||
OVERWRITE_EXISTING_RELEASE: ${{ inputs.overwrite_existing_release }}
|
||||
run: |
|
||||
set -euo pipefail
|
||||
|
||||
git fetch --tags --force origin
|
||||
|
||||
CURRENT_TAG="${GITHUB_REF_NAME}"
|
||||
CURRENT_COMMIT="$(git rev-list -n 1 "$CURRENT_TAG")"
|
||||
PREVIOUS_TAG="$(git describe --tags --abbrev=0 --match 'v[0-9]*.[0-9]*.[0-9]*' "${CURRENT_COMMIT}^" 2>/dev/null || true)"
|
||||
CURRENT_TAG="$RELEASE_VERSION"
|
||||
if [ "${GITHUB_EVENT_NAME}" = "workflow_dispatch" ] && [ "${OVERWRITE_EXISTING_RELEASE}" = "true" ]; then
|
||||
CURRENT_COMMIT="$(git rev-parse HEAD)"
|
||||
TAG_COMMIT="$(git rev-list -n 1 "$CURRENT_TAG")"
|
||||
PREVIOUS_TAG="$(git describe --tags --abbrev=0 --match 'v[0-9]*.[0-9]*.[0-9]*' "${TAG_COMMIT}^" 2>/dev/null || true)"
|
||||
else
|
||||
CURRENT_COMMIT="$(git rev-list -n 1 "$CURRENT_TAG")"
|
||||
PREVIOUS_TAG="$(git describe --tags --abbrev=0 --match 'v[0-9]*.[0-9]*.[0-9]*' "${CURRENT_COMMIT}^" 2>/dev/null || true)"
|
||||
fi
|
||||
EMPTY_TREE="4b825dc642cb6eb9a060e54bf8d69288fbee4904"
|
||||
|
||||
if [ -n "$PREVIOUS_TAG" ]; then
|
||||
RANGE_LABEL="$PREVIOUS_TAG..$CURRENT_TAG"
|
||||
git log --pretty=format:'- %h %s' "$PREVIOUS_TAG..$CURRENT_TAG" > commits.md
|
||||
git diff --name-only "$PREVIOUS_TAG" "$CURRENT_TAG" > changed-files.md
|
||||
git diff --stat "$PREVIOUS_TAG" "$CURRENT_TAG" > diffstat.md
|
||||
RANGE_LABEL="$PREVIOUS_TAG..$CURRENT_COMMIT"
|
||||
git log --pretty=format:'- %h %s' "$PREVIOUS_TAG..$CURRENT_COMMIT" > commits.md
|
||||
git diff --name-only "$PREVIOUS_TAG" "$CURRENT_COMMIT" > changed-files.md
|
||||
git diff --stat "$PREVIOUS_TAG" "$CURRENT_COMMIT" > diffstat.md
|
||||
else
|
||||
RANGE_LABEL="$CURRENT_TAG"
|
||||
git log --pretty=format:'- %h %s' "$CURRENT_TAG" > commits.md
|
||||
git diff --name-only "$EMPTY_TREE" "$CURRENT_TAG^{tree}" > changed-files.md
|
||||
git diff --stat "$EMPTY_TREE" "$CURRENT_TAG^{tree}" > diffstat.md
|
||||
RANGE_LABEL="$CURRENT_COMMIT"
|
||||
git log --pretty=format:'- %h %s' "$CURRENT_COMMIT" > commits.md
|
||||
git diff --name-only "$EMPTY_TREE" "$CURRENT_COMMIT^{tree}" > changed-files.md
|
||||
git diff --stat "$EMPTY_TREE" "$CURRENT_COMMIT^{tree}" > diffstat.md
|
||||
fi
|
||||
|
||||
{
|
||||
echo "# Release Context"
|
||||
echo
|
||||
echo "Current tag: $CURRENT_TAG"
|
||||
echo "Current commit: $CURRENT_COMMIT"
|
||||
echo "Previous tag: ${PREVIOUS_TAG:-none}"
|
||||
echo "Range: $RANGE_LABEL"
|
||||
echo
|
||||
@@ -687,6 +739,14 @@ jobs:
|
||||
- `SHA256SUMS`
|
||||
EOF
|
||||
|
||||
if [ "${{ github.event_name }}" = "workflow_dispatch" ] && [ "${{ inputs.overwrite_existing_release }}" = "true" ]; then
|
||||
{
|
||||
echo
|
||||
echo "<!-- rebuilt-from: $(git rev-parse HEAD) -->"
|
||||
echo "<!-- rebuilt-at: $(date -u +%Y-%m-%dT%H:%M:%SZ) -->"
|
||||
} >> release-notes.md
|
||||
fi
|
||||
|
||||
- name: Download all artifacts
|
||||
uses: actions/download-artifact@v8
|
||||
with:
|
||||
@@ -707,9 +767,11 @@ jobs:
|
||||
- name: Create Release
|
||||
uses: softprops/action-gh-release@b4309332981a82ec1c5618f44dd2e27cc8bfbfda # v3
|
||||
with:
|
||||
tag_name: ${{ needs.prepare.outputs.version }}
|
||||
files: |
|
||||
artifacts/linux-*/*.tar.gz
|
||||
artifacts/openwrt-*/*.apk
|
||||
artifacts/windows-*/*.zip
|
||||
SHA256SUMS
|
||||
body_path: release-notes.md
|
||||
overwrite_files: true
|
||||
|
||||
124
.github/workflows/sync-dev-to-master.yml
vendored
124
.github/workflows/sync-dev-to-master.yml
vendored
@@ -3,15 +3,23 @@ name: Sync Dev to Master
|
||||
on:
|
||||
workflow_dispatch:
|
||||
inputs:
|
||||
release_mode:
|
||||
description: "Release mode: new creates a tag, overwrite rebuilds an existing release, sync_only only syncs branches."
|
||||
required: true
|
||||
default: "new"
|
||||
type: choice
|
||||
options:
|
||||
- new
|
||||
- overwrite
|
||||
- sync_only
|
||||
version:
|
||||
description: "Release tag, e.g. v1.2.3. Leave empty to auto bump patch. Used only when create_release_tag is true."
|
||||
description: "Release tag, e.g. v1.2.3. Empty auto bumps for new or uses latest existing tag for overwrite."
|
||||
required: false
|
||||
type: string
|
||||
create_release_tag:
|
||||
description: "Create and push a new release tag after syncing."
|
||||
confirm_overwrite:
|
||||
description: "Type OVERWRITE to confirm rebuilding an existing release."
|
||||
required: false
|
||||
default: true
|
||||
type: boolean
|
||||
type: string
|
||||
|
||||
concurrency:
|
||||
group: sync-dev-to-master
|
||||
@@ -84,10 +92,13 @@ jobs:
|
||||
git push origin master
|
||||
fi
|
||||
|
||||
- name: Create and Push Release Tag
|
||||
if: ${{ inputs.create_release_tag != false }}
|
||||
- name: Resolve Release Tag
|
||||
id: release_tag
|
||||
if: ${{ inputs.release_mode != 'sync_only' }}
|
||||
env:
|
||||
REQUESTED_VERSION: ${{ github.event.inputs.version }}
|
||||
RELEASE_MODE: ${{ inputs.release_mode }}
|
||||
REQUESTED_VERSION: ${{ inputs.version }}
|
||||
CONFIRM_OVERWRITE: ${{ inputs.confirm_overwrite }}
|
||||
run: |
|
||||
set -euo pipefail
|
||||
|
||||
@@ -97,6 +108,11 @@ jobs:
|
||||
REQUESTED_VERSION="${REQUESTED_VERSION#"${REQUESTED_VERSION%%[![:space:]]*}"}"
|
||||
REQUESTED_VERSION="${REQUESTED_VERSION%"${REQUESTED_VERSION##*[![:space:]]}"}"
|
||||
|
||||
if [ "$RELEASE_MODE" = "overwrite" ] && [ "${CONFIRM_OVERWRITE:-}" != "OVERWRITE" ]; then
|
||||
echo "::error::confirm_overwrite must be exactly 'OVERWRITE' when release_mode=overwrite."
|
||||
exit 1
|
||||
fi
|
||||
|
||||
if [ -n "$REQUESTED_VERSION" ]; then
|
||||
TAG_NAME="$REQUESTED_VERSION"
|
||||
if [[ "$TAG_NAME" != v* ]]; then
|
||||
@@ -107,13 +123,23 @@ jobs:
|
||||
LATEST_TAG="$(git tag --list 'v*.*.*' --sort=-v:refname | grep -E '^v[0-9]+\.[0-9]+\.[0-9]+$' | head -n 1 || true)"
|
||||
|
||||
if [ -z "$LATEST_TAG" ]; then
|
||||
TAG_NAME="v0.1.0"
|
||||
echo "No existing vX.Y.Z tag found. Starting at $TAG_NAME"
|
||||
if [ "$RELEASE_MODE" = "new" ]; then
|
||||
TAG_NAME="v0.1.0"
|
||||
echo "No existing vX.Y.Z tag found. Starting at $TAG_NAME"
|
||||
else
|
||||
echo "::error::No existing vX.Y.Z tag found to overwrite."
|
||||
exit 1
|
||||
fi
|
||||
else
|
||||
VERSION="${LATEST_TAG#v}"
|
||||
IFS='.' read -r MAJOR MINOR PATCH <<< "$VERSION"
|
||||
TAG_NAME="v${MAJOR}.${MINOR}.$((PATCH + 1))"
|
||||
echo "Auto bumped release tag from $LATEST_TAG to $TAG_NAME"
|
||||
if [ "$RELEASE_MODE" = "new" ]; then
|
||||
VERSION="${LATEST_TAG#v}"
|
||||
IFS='.' read -r MAJOR MINOR PATCH <<< "$VERSION"
|
||||
TAG_NAME="v${MAJOR}.${MINOR}.$((PATCH + 1))"
|
||||
echo "Auto bumped release tag from $LATEST_TAG to $TAG_NAME"
|
||||
else
|
||||
TAG_NAME="$LATEST_TAG"
|
||||
echo "No release tag requested. Using latest existing release tag: $TAG_NAME"
|
||||
fi
|
||||
fi
|
||||
fi
|
||||
|
||||
@@ -122,21 +148,71 @@ jobs:
|
||||
exit 1
|
||||
fi
|
||||
|
||||
if git rev-parse -q --verify "refs/tags/$TAG_NAME" >/dev/null; then
|
||||
echo "::error::Tag '$TAG_NAME' already exists locally."
|
||||
exit 1
|
||||
if [ "$RELEASE_MODE" = "new" ]; then
|
||||
if git rev-parse -q --verify "refs/tags/$TAG_NAME" >/dev/null; then
|
||||
echo "::error::Tag '$TAG_NAME' already exists locally."
|
||||
exit 1
|
||||
fi
|
||||
|
||||
if git ls-remote --exit-code --tags origin "refs/tags/$TAG_NAME" >/dev/null 2>&1; then
|
||||
echo "::error::Tag '$TAG_NAME' already exists on origin."
|
||||
exit 1
|
||||
fi
|
||||
else
|
||||
if ! git rev-parse -q --verify "refs/tags/$TAG_NAME" >/dev/null; then
|
||||
echo "::error::Tag '$TAG_NAME' does not exist locally."
|
||||
exit 1
|
||||
fi
|
||||
|
||||
if ! git ls-remote --exit-code --tags origin "refs/tags/$TAG_NAME" >/dev/null 2>&1; then
|
||||
echo "::error::Tag '$TAG_NAME' does not exist on origin."
|
||||
exit 1
|
||||
fi
|
||||
|
||||
TAG_COMMIT="$(git rev-list -n 1 "$TAG_NAME")"
|
||||
MASTER_COMMIT="$(git rev-parse HEAD)"
|
||||
echo "Overwrite release tag: $TAG_NAME"
|
||||
echo "Tag commit: $TAG_COMMIT"
|
||||
echo "Master commit used for rebuilt assets: $MASTER_COMMIT"
|
||||
fi
|
||||
|
||||
if git ls-remote --exit-code --tags origin "refs/tags/$TAG_NAME" >/dev/null 2>&1; then
|
||||
echo "::error::Tag '$TAG_NAME' already exists on origin."
|
||||
exit 1
|
||||
fi
|
||||
echo "tag_name=$TAG_NAME" >> "$GITHUB_OUTPUT"
|
||||
|
||||
- name: Create and Push Release Tag
|
||||
if: ${{ inputs.release_mode == 'new' }}
|
||||
env:
|
||||
TAG_NAME: ${{ steps.release_tag.outputs.tag_name }}
|
||||
run: |
|
||||
set -euo pipefail
|
||||
|
||||
git tag -a "$TAG_NAME" -m "Release $TAG_NAME"
|
||||
git push origin "$TAG_NAME"
|
||||
|
||||
- name: Skip Release Tag
|
||||
if: ${{ inputs.create_release_tag == false }}
|
||||
- name: Dispatch Overwrite Release Build
|
||||
if: ${{ inputs.release_mode == 'overwrite' }}
|
||||
env:
|
||||
GH_TOKEN: ${{ secrets.PAT_TOKEN }}
|
||||
TAG_NAME: ${{ steps.release_tag.outputs.tag_name }}
|
||||
run: |
|
||||
echo "create_release_tag=false; synced dev to master without creating a release tag."
|
||||
set -euo pipefail
|
||||
|
||||
gh workflow run build-dockerhub.yml \
|
||||
--ref master \
|
||||
-f release_tag="$TAG_NAME" \
|
||||
-f overwrite_existing_release=true
|
||||
|
||||
echo "Dispatched release rebuild for $TAG_NAME from master."
|
||||
|
||||
- name: Dispatch Sync-only Master Build
|
||||
if: ${{ inputs.release_mode == 'sync_only' }}
|
||||
env:
|
||||
GH_TOKEN: ${{ secrets.PAT_TOKEN }}
|
||||
run: |
|
||||
set -euo pipefail
|
||||
|
||||
MASTER_COMMIT="$(git rev-parse HEAD)"
|
||||
|
||||
gh workflow run build-dockerhub.yml \
|
||||
--ref master
|
||||
|
||||
echo "release_mode=sync_only; synced dev to master at $MASTER_COMMIT and dispatched a master build without creating or overwriting a release."
|
||||
|
||||
@@ -76,6 +76,7 @@ ADD_EXECUTABLE(${BUILD_TARGET_NAME}
|
||||
src/handler/dashboard_page.cpp
|
||||
src/handler/inspect_page.cpp
|
||||
src/handler/interfaces.cpp
|
||||
src/handler/webapp_page.cpp
|
||||
src/handler/multithread.cpp
|
||||
src/handler/statistics.cpp
|
||||
src/handler/upload.cpp
|
||||
|
||||
34
Dockerfile
34
Dockerfile
@@ -18,6 +18,7 @@ RUN apt-get update && \
|
||||
# Copy committed Go module files and source.
|
||||
COPY bridge/go.mod bridge/go.sum ./
|
||||
COPY bridge/converter.go ./
|
||||
COPY bridge/preprocess.go ./
|
||||
|
||||
RUN set -xe && \
|
||||
if [ "${REFRESH_GO_DEPS}" = "true" ]; then \
|
||||
@@ -161,29 +162,14 @@ RUN set -xe && \
|
||||
# 收集 glibc 运行时依赖(动态探测,避免固定版本)
|
||||
RUN set -xe && \
|
||||
mkdir -p /runtime-libs && \
|
||||
ldd /src/subconverter /usr/lib/libmihomo.so | \
|
||||
awk '{for (i=1; i<=NF; i++) if ($i ~ "^/") print $i}' | \
|
||||
sort -u | \
|
||||
while read -r lib; do \
|
||||
if [ -e "$lib" ]; then \
|
||||
mkdir -p "/runtime-libs$(dirname "$lib")" && \
|
||||
cp -aL "$lib" "/runtime-libs$lib"; \
|
||||
fi; \
|
||||
done && \
|
||||
for loader in /lib64/ld-linux-x86-64.so.2 /lib/x86_64-linux-gnu/ld-linux-x86-64.so.2 /lib/ld-linux-aarch64.so.1 /lib/aarch64-linux-gnu/ld-linux-aarch64.so.1; do \
|
||||
if [ -e "$loader" ]; then \
|
||||
mkdir -p "/runtime-libs$(dirname "$loader")" && \
|
||||
cp -aL "$loader" "/runtime-libs$loader"; \
|
||||
fi; \
|
||||
done && \
|
||||
libc_path="$(ldd /src/subconverter | awk '$1 == "libc.so.6" {print $3; exit}')" && \
|
||||
libc_dir="$(dirname "${libc_path:-/lib/x86_64-linux-gnu/libc.so.6}")" && \
|
||||
for extra in libnss_dns.so.2 libnss_files.so.2 libnss_compat.so.2 libresolv.so.2; do \
|
||||
if [ -e "$libc_dir/$extra" ]; then \
|
||||
mkdir -p "/runtime-libs$libc_dir" && \
|
||||
cp -aL "$libc_dir/$extra" "/runtime-libs$libc_dir/$extra"; \
|
||||
fi; \
|
||||
done && \
|
||||
ELF_LIBRARY_PATH="/usr/lib:/lib/x86_64-linux-gnu:/usr/lib/x86_64-linux-gnu:/lib/aarch64-linux-gnu:/usr/lib/aarch64-linux-gnu:/lib64" \
|
||||
bash /src/scripts/ci/copy-elf-runtime-deps.sh /runtime-libs \
|
||||
/src/subconverter \
|
||||
/usr/lib/libmihomo.so \
|
||||
libnss_dns.so.2 \
|
||||
libnss_files.so.2 \
|
||||
libnss_compat.so.2 \
|
||||
libresolv.so.2 && \
|
||||
if [ -f /etc/nsswitch.conf ]; then \
|
||||
mkdir -p /runtime-libs/etc && \
|
||||
cp -aL /etc/nsswitch.conf /runtime-libs/etc/nsswitch.conf; \
|
||||
@@ -214,9 +200,7 @@ RUN apk add --no-cache ca-certificates tzdata && \
|
||||
|
||||
COPY --from=builder /src/subconverter /usr/bin/subconverter
|
||||
COPY --from=builder /src/base /base/
|
||||
COPY --from=builder /usr/lib/libmihomo.so /usr/lib/
|
||||
COPY --from=builder /runtime-libs/ /
|
||||
COPY --from=builder /etc/nsswitch.conf /etc/nsswitch.conf
|
||||
|
||||
# 确保二进制和库可执行
|
||||
RUN chmod +x /usr/bin/subconverter && chmod +x /usr/lib/libmihomo.so
|
||||
|
||||
141
README.md
141
README.md
@@ -192,13 +192,29 @@ https://github.com/<owner>/<repo>/raw/<ref>/<path>
|
||||
https://github.com/<owner>/<repo>/blob/<ref>/<path>
|
||||
```
|
||||
|
||||
#### 4. 兼容性保证 🤝
|
||||
#### 4. 请求诊断台 🔎
|
||||
|
||||
内置 `explain=true` 诊断模式和 `/inspect` 网页诊断台,方便在不改变实际转换逻辑的前提下排查请求:
|
||||
|
||||
* ✅ 展示请求参数是否被识别、是否生效、是否被项目安全逻辑覆盖
|
||||
* ✅ 汇总外部配置、规则集、自定义组、Provider、输出大小等关键状态
|
||||
* ✅ 对订阅来源等敏感信息只展示预览、长度或短哈希,便于排障时降低泄露风险
|
||||
|
||||
#### 5. 运行仪表盘 📊
|
||||
|
||||
启用统计后,`/dashboard` 可展示服务运行期转换统计,适合公开服务部署者观察后端使用情况:
|
||||
|
||||
* ✅ 展示本次启动、历史总计、最近 24 小时和滚动时间窗口统计、访问者地理位置分布
|
||||
* ✅ 按请求数和规则转换数展示国家 / 地区分布与排行
|
||||
* ✅ 支持统计数据持久化和可选 Basic Auth 验证,便于公网部署时限制访问
|
||||
|
||||
#### 6. 兼容性保证 🤝
|
||||
|
||||
* ✅ **无缝切换**:兼容常见传统 subconverter API 接口,客户端侧几乎无需学习成本即可迁移
|
||||
* ✅ **模板兼容**:继续沿用传统外部模板,由后端内置逻辑确保 `proxy-provider` 模式在分流规则中正确生成
|
||||
* ✅ **自动跟进**:编译时自动遍历 [Mihomo 内核源码仓库](https://github.com/MetaCubeX/mihomo/meta),提取最新解析模块、协议格式与可覆写参数
|
||||
|
||||
#### 5. 新手友好 👶
|
||||
#### 7. 新手友好 👶
|
||||
|
||||
* ✅ 使用 **[Custom_OpenClash_Rules](https://github.com/Aethersailor/Custom_OpenClash_Rules)** 远程配置模板,替代默认内置模板与自定义代理组功能
|
||||
* ✅ 锁定 API 模式,强制关闭相关接口,降低新手误配置带来的安全风险
|
||||
@@ -532,6 +548,9 @@ logread -e subconverter
|
||||
> [!IMPORTANT]
|
||||
> 默认输出为**最简配置**,不包含 DNS 参数,请在各 Clash 客户端中启用 DNS 覆写功能,或在生成的配置文件中自行补全 DNS 配置。
|
||||
|
||||
<details open>
|
||||
<summary><strong>快速调用与常用参数</strong></summary>
|
||||
|
||||
### 常用参数一览
|
||||
|
||||
| 参数 | 说明 | 示例 |
|
||||
@@ -554,6 +573,11 @@ https://api.asailor.org/sub?target=clash&url=https%3A%2F%2Fexample.com%2Fsub&con
|
||||
https://api.asailor.org/sub?target=clash&url=provider%3AHK%2Chttps%3A%2F%2Fexample.com%2Fsub&include=%E9%A6%99%E6%B8%AF&emoji=true
|
||||
```
|
||||
|
||||
</details>
|
||||
|
||||
<details>
|
||||
<summary><strong>诊断与排障</strong></summary>
|
||||
|
||||
### `explain=true` 诊断模式
|
||||
|
||||
在 `/sub` 请求中追加 `explain=true` 后,后端会按同一组参数执行转换流程,但返回 JSON 诊断报告,而不是返回 Clash/Surge/QuanX 配置文件。
|
||||
@@ -566,10 +590,11 @@ https://api.asailor.org/sub?target=clash&url=https%3A%2F%2Fexample.com%2Fsub&exp
|
||||
|
||||
这个模式适合排查“参数是否生效”“是否进入 `proxy-provider` 模式”“外部配置是否加载成功”“规则集和节点数量是否符合预期”等问题。报告会包含目标格式、模式开关、输入数量、外部配置状态、规则集统计、provider 数量和输出大小等信息。
|
||||
|
||||
> [!NOTE]
|
||||
> * `explain=true` 只改变响应内容,不改变实际转换逻辑。
|
||||
> * 如果同一请求里包含上传参数,诊断模式会抑制上传,避免排障时产生托管配置写入。
|
||||
> * 诊断报告不会直接回显原始订阅地址;provider 来源会以短哈希形式显示,便于区分来源又避免泄露完整链接。
|
||||
**说明:**
|
||||
|
||||
* `explain=true` 只改变响应内容,不改变实际转换逻辑。
|
||||
* 如果同一请求里包含上传参数,诊断模式会抑制上传,避免排障时产生托管配置写入。
|
||||
* 诊断报告不会直接回显原始订阅地址;provider 来源会以短哈希形式显示,便于区分来源又避免泄露完整链接。
|
||||
|
||||
### `/inspect` 请求诊断台
|
||||
|
||||
@@ -594,10 +619,101 @@ http://localhost:25500/inspect
|
||||
* `include` / `exclude`、`emoji`、`new_name`、`config` 等外部参数最终是否参与转换
|
||||
* 外部配置、规则集、自定义组、Provider 是否按预期加载或生成
|
||||
|
||||
> [!NOTE]
|
||||
> * `/inspect` 只是 `explain=true` 诊断报告的可视化界面,不会改变实际转换逻辑。
|
||||
> * 页面会隐藏敏感输入的明文,仅展示预览、长度和短哈希等排障信息。
|
||||
> * 请求诊断台会保留原始 JSON 区域,方便复制给维护者进一步分析。
|
||||
**说明:**
|
||||
|
||||
* `/inspect` 只是 `explain=true` 诊断报告的可视化界面,不会改变实际转换逻辑。
|
||||
* 页面会隐藏敏感输入的明文,仅展示预览、长度和短哈希等排障信息。
|
||||
* 请求诊断台会保留原始 JSON 区域,方便复制给维护者进一步分析。
|
||||
|
||||
</details>
|
||||
|
||||
<details>
|
||||
<summary><strong>/dashboard 运行仪表盘</strong></summary>
|
||||
|
||||
### `/dashboard` 使用方法
|
||||
|
||||
`/dashboard` 用于查看运行期转换统计。该功能默认关闭;只有在配置文件中启用 `statistics.enabled` 后,服务才会注册 `/dashboard` 和 `/dashboard/data` 路由。
|
||||
|
||||
启用后可访问:
|
||||
|
||||
```text
|
||||
http://localhost:25500/dashboard
|
||||
```
|
||||
|
||||
公网或反代部署时,请替换为实际域名:
|
||||
|
||||
```text
|
||||
https://sub.example.com/dashboard
|
||||
```
|
||||
|
||||
`/dashboard/data` 会返回仪表盘使用的 JSON 数据,适合接入外部监控或自行排查:
|
||||
|
||||
```text
|
||||
http://localhost:25500/dashboard/data
|
||||
```
|
||||
|
||||
仪表盘主要展示:
|
||||
|
||||
* 服务启动时间、本次运行时长、累计运行时长和启动次数
|
||||
* 成功 `/sub` 转换请求数与规则转换数
|
||||
* 最近 24 小时请求 / 规则转换柱状图
|
||||
* 按 1 小时、1 天、7 天、30 天、半年、1 年和历史总计统计的国家 / 地区分布与排行
|
||||
* 当可信边缘网关提供地区请求头时,展示中国地区请求 / 规则转换地图和排行
|
||||
|
||||
**说明:**
|
||||
|
||||
* 统计只在 `statistics.enabled=true` 后开始写入,启用前的历史请求不会回补。
|
||||
* 统计模块只记录成功的 `GET /sub` 转换请求和规则转换计数,不存储订阅链接、节点内容或访问者 IP。
|
||||
* 国家 / 地区来源于配置的国家码请求头;中国地区来源于配置的地区请求头;无法识别时会归为未知。
|
||||
* Docker 部署如需跨重启保留统计数据,请将 `data_dir` 对应目录挂载为卷,例如 `./stats:/base/stats`。
|
||||
|
||||
### 启用示例(TOML)
|
||||
|
||||
修改 `base/pref.toml` 后重启服务:
|
||||
|
||||
```toml
|
||||
[statistics]
|
||||
enabled = true
|
||||
data_dir = "stats"
|
||||
flush_interval = 5
|
||||
|
||||
[statistics.geo]
|
||||
provider = "header"
|
||||
country_headers = ["CF-IPCountry", "X-Geo-Country", "X-Vercel-IP-Country", "CloudFront-Viewer-Country"]
|
||||
china_region_headers = ["CF-Region-Code", "cf-region-code", "X-Geo-Subdivision"]
|
||||
|
||||
[statistics.dashboard_auth]
|
||||
enabled = true
|
||||
username = "admin"
|
||||
password = "change-this-password"
|
||||
max_failures = 5
|
||||
window_seconds = 300
|
||||
lock_seconds = 900
|
||||
```
|
||||
|
||||
### 新增配置项说明
|
||||
|
||||
| TOML / YAML 配置项 | INI 配置项 | 默认值 | 说明 |
|
||||
| :--- | :--- | :--- | :--- |
|
||||
| `statistics.enabled` | `enabled` | `false` | 是否启用运行期统计和 `/dashboard`。关闭时不会注册 `/dashboard` 与 `/dashboard/data`。 |
|
||||
| `statistics.data_dir` | `data_dir` | `stats` | 统计数据目录,按程序工作目录解析;Docker 中可挂载 `/base/stats` 持久化。 |
|
||||
| `statistics.flush_interval` | `flush_interval` | `5` | 统计数据最小写盘间隔,单位为秒。 |
|
||||
| `statistics.geo.provider` | `geo_provider` | `header` | 国家 / 地区识别方式。`header` 表示读取国家码请求头,`none` 表示全部记为未知。 |
|
||||
| `statistics.geo.country_headers` | `country_headers` | `CF-IPCountry`, `X-Geo-Country`, `X-Vercel-IP-Country`, `CloudFront-Viewer-Country` | `provider=header` 时依次尝试读取的国家码请求头。 |
|
||||
| `statistics.geo.china_region_headers` | `china_region_headers` | `CF-Region-Code`, `cf-region-code`, `X-Geo-Subdivision` | 可信边缘网关注入中国地区码时依次尝试读取的请求头,用于中国地区地图和排行。 |
|
||||
| `statistics.dashboard_auth.enabled` | `dashboard_auth_enabled` | `false` | 是否为 `/dashboard` 和 `/dashboard/data` 启用 Basic Auth。 |
|
||||
| `statistics.dashboard_auth.username` | `dashboard_auth_username` | 空 | Basic Auth 用户名。启用认证后不能为空。 |
|
||||
| `statistics.dashboard_auth.password` | `dashboard_auth_password` | 空 | Basic Auth 密码。启用认证后不能为空;公网部署建议配合 HTTPS。 |
|
||||
| `statistics.dashboard_auth.max_failures` | `dashboard_auth_max_failures` | `5` | 在统计窗口内允许的失败登录次数。 |
|
||||
| `statistics.dashboard_auth.window_seconds` | `dashboard_auth_window_seconds` | `300` | 失败登录统计窗口,单位为秒。 |
|
||||
| `statistics.dashboard_auth.lock_seconds` | `dashboard_auth_lock_seconds` | `900` | 超过失败次数后的锁定时长,单位为秒。 |
|
||||
|
||||
**提示:** `pref.yml` 使用同名嵌套字段;`pref.ini` 的上述 INI 配置项均写在 `[statistics]` 段内。
|
||||
|
||||
</details>
|
||||
|
||||
<details>
|
||||
<summary><strong>Proxy-Provider 自定义名称</strong></summary>
|
||||
|
||||
### `provider` 前缀(仅适用于 Clash/ClashR 订阅链接)
|
||||
|
||||
@@ -611,14 +727,15 @@ url=provider:HK,https://a|provider:HK,https://b
|
||||
url=provider%3AHK%2Chttps%3A%2F%2Fexample.com%2Fsub
|
||||
```
|
||||
|
||||
> [!NOTE]
|
||||
> 在 OpenClash 这类预置“订阅地址”输入框的软件中,无需填写开头的 `url=`,直接填入等号后的内容即可。
|
||||
**说明:** 在 OpenClash 这类预置“订阅地址”输入框的软件中,无需填写开头的 `url=`,直接填入等号后的内容即可。
|
||||
|
||||
补充说明:
|
||||
|
||||
* 支持中文名称;非法字符或空值会回退为默认 `Provider_<MD5>`
|
||||
* 重名时会自动追加 `_1`、`_2` 等后缀
|
||||
|
||||
</details>
|
||||
|
||||
---
|
||||
|
||||
## 🛠️ 配置说明
|
||||
|
||||
@@ -175,6 +175,8 @@ flush_interval=5
|
||||
;none records all countries as unknown.
|
||||
geo_provider=header
|
||||
country_headers=CF-IPCountry,X-Geo-Country,X-Vercel-IP-Country,CloudFront-Viewer-Country
|
||||
;Used for China regional statistics when visitor location headers are added by a trusted edge.
|
||||
china_region_headers=CF-Region-Code,cf-region-code,X-Geo-Subdivision
|
||||
;Optional Basic authentication for /dashboard and /dashboard/data.
|
||||
;Only applies when statistics enabled=true.
|
||||
;Missing or false keeps the dashboard password disabled.
|
||||
|
||||
@@ -95,6 +95,8 @@ statistics:
|
||||
# none records all countries as unknown.
|
||||
provider: header
|
||||
country_headers: ["CF-IPCountry", "X-Geo-Country", "X-Vercel-IP-Country", "CloudFront-Viewer-Country"]
|
||||
# Used for China regional statistics when visitor location headers are added by a trusted edge.
|
||||
china_region_headers: ["CF-Region-Code", "cf-region-code", "X-Geo-Subdivision"]
|
||||
dashboard_auth:
|
||||
# Optional Basic authentication for /dashboard and /dashboard/data.
|
||||
# Only applies when statistics.enabled is true.
|
||||
|
||||
394
base/pref.toml
Normal file
394
base/pref.toml
Normal file
@@ -0,0 +1,394 @@
|
||||
version = 1
|
||||
[common]
|
||||
# API mode is hardcoded to true for security - cannot be configured
|
||||
# Token authentication is disabled - users must provide url parameter
|
||||
|
||||
|
||||
# Default URLs, used when no URL is provided in request, use "|" to separate multiple subscription links, supports local files/URL
|
||||
default_url = []
|
||||
|
||||
# Insert subscription links to requests. Can be used to add node(s) to all exported subscriptions.
|
||||
enable_insert = true
|
||||
# URLs to insert before subscription links, can be used to add node(s) to all exported subscriptions, supports local files/URL
|
||||
insert_url = [""]
|
||||
# Prepend inserted URLs to subscription links. Nodes in insert_url will be added to groups first with non-group-specific match pattern.
|
||||
prepend_insert_url = true
|
||||
|
||||
# Exclude nodes which remarks match the following patterns. Supports regular expression.
|
||||
exclude_remarks = ["(到期|剩余流量|时间|官网|产品)"]
|
||||
|
||||
# Only include nodes which remarks match the following patterns. Supports regular expression.
|
||||
#include_remarks = ["V3.*港"]
|
||||
|
||||
# Enable script support for filtering nodes
|
||||
enable_filter = false
|
||||
# Script used for filtering nodes. Supports inline script and script path. A "filter" function with 1 argument which is a node should be defined in the script.
|
||||
# Example: Inline script: set value to content of script.
|
||||
# Script path: set value to "path:/path/to/script.js".
|
||||
#filter_script = '''
|
||||
#function filter(node) {
|
||||
# const info = JSON.parse(node.ProxyInfo);
|
||||
# if(info.EncryptMethod.includes('chacha20'))
|
||||
# return true;
|
||||
# return false;
|
||||
#}
|
||||
#'''
|
||||
|
||||
default_external_config = "https://testingcf.jsdelivr.net/gh/Aethersailor/Custom_OpenClash_Rules@refs/heads/main/cfg/Custom_Clash.ini"
|
||||
|
||||
# The file scope limit of the 'rule_base' options in external configs.
|
||||
base_path = "base"
|
||||
|
||||
# Clash config base used by the generator, supports local files/URL
|
||||
clash_rule_base = "base/all_base.tpl"
|
||||
|
||||
# Surge config base used by the generator, supports local files/URL
|
||||
surge_rule_base = "base/all_base.tpl"
|
||||
|
||||
# Surfboard config base used by the generator, supports local files/URL
|
||||
surfboard_rule_base = "base/all_base.tpl"
|
||||
|
||||
# Mellow config base used by the generator, supports local files/URL
|
||||
mellow_rule_base = "base/all_base.tpl"
|
||||
|
||||
# Quantumult config base used by the generator, supports local files/URL
|
||||
quan_rule_base = "base/all_base.tpl"
|
||||
|
||||
# Quantumult X config base used by the generator, supports local files/URL
|
||||
quanx_rule_base = "base/all_base.tpl"
|
||||
|
||||
# Loon config base used by the generator, supports local files/URL
|
||||
loon_rule_base = "base/all_base.tpl"
|
||||
|
||||
# Shadowsocks Android config base used by the generator, supports local files/URL
|
||||
sssub_rule_base = "base/all_base.tpl"
|
||||
|
||||
# sing-box config base used by the generator, supports local files/URL
|
||||
singbox_rule_base = "base/all_base.tpl"
|
||||
|
||||
# Proxy used to download rulesets or subscriptions, set to NONE or empty to disable it, set to SYSTEM to use system proxy.
|
||||
# Accept cURL-supported proxies (http:// https:// socks4a:// socks5://)
|
||||
|
||||
proxy_config = "SYSTEM"
|
||||
proxy_ruleset = "SYSTEM"
|
||||
proxy_subscription = "NONE"
|
||||
|
||||
# Append a proxy type string ([SS] [SSR] [VMess]) to node remark.
|
||||
append_proxy_type = false
|
||||
|
||||
# When requesting /sub, reload this config file first.
|
||||
reload_conf_on_request = false
|
||||
|
||||
[[userinfo.stream_rule]]
|
||||
# Rules to extract stream data from node
|
||||
# Format: full_match_regex|new_format_regex
|
||||
# where new_format_regex should be like "total=$1&left=$2&used=$3"
|
||||
match = '^剩余流量:(.*?)\|总流量:(.*)$'
|
||||
replace = 'total=$2&left=$1'
|
||||
|
||||
[[userinfo.stream_rule]]
|
||||
match = '^剩余流量:(.*?) (.*)$'
|
||||
replace = 'total=$1&left=$2'
|
||||
|
||||
[[userinfo.stream_rule]]
|
||||
match = '^Bandwidth: (.*?)/(.*)$'
|
||||
replace = 'used=$1&total=$2'
|
||||
|
||||
[[userinfo.stream_rule]]
|
||||
match = '^.*剩余(.*?)(?:\s*?)@(?:.*)$'
|
||||
replace = 'total=$1'
|
||||
|
||||
[[userinfo.time_rule]]
|
||||
# Rules to extract expire time data from node
|
||||
# Format: full_match_regex|new_format_regex
|
||||
# where new_format_regex should follow this example: yyyy:mm:dd:hh:mm:ss
|
||||
match = '^过期时间:(\d+)-(\d+)-(\d+) (\d+):(\d+):(\d+)$'
|
||||
replace = '$1:$2:$3:$4:$5:$6'
|
||||
|
||||
[[userinfo.time_rule]]
|
||||
match = '^到期时间:(\d+)-(\d+)-(\d+)$'
|
||||
replace = '$1:$2:$3:0:0:0'
|
||||
|
||||
[[userinfo.time_rule]]
|
||||
match = '^Smart Access expire: (\d+)/(\d+)/(\d+)$'
|
||||
replace = '$1:$2:$3:0:0:0'
|
||||
|
||||
[node_pref]
|
||||
#udp_flag = false
|
||||
#tcp_fast_open_flag = false
|
||||
#skip_cert_verify_flag = false
|
||||
#tls13_flag = false
|
||||
|
||||
sort_flag = false
|
||||
# Script used for sorting nodes. A "compare" function with 2 arguments which are the 2 nodes to be compared should be defined in the script. Supports inline script and script path.
|
||||
# Examples can be seen at the filter_script option in [common] section.
|
||||
#sort_script = '''
|
||||
#function compare(node_a, node_b) {
|
||||
# return info_a.Remark > info_b.Remark;
|
||||
#}
|
||||
#'''
|
||||
|
||||
filter_deprecated_nodes = false
|
||||
append_sub_userinfo = true
|
||||
clash_use_new_field_name = true
|
||||
|
||||
# Generate style of the proxies and proxy groups section of Clash subscriptions.
|
||||
# Supported styles: block, flow, compact
|
||||
# Block: - name: name1 Flow: - {name: name1, key: value} Compact: [{name: name1, key: value},{name: name2, key: value}]
|
||||
# key: value - {name: name2, key: value}
|
||||
# - name: name2
|
||||
# key: value
|
||||
clash_proxies_style = "flow"
|
||||
clash_proxy_groups_style = "flow"
|
||||
|
||||
# add Clash mode to sing-box rules, and add a GLOBAL group to end of outbounds
|
||||
singbox_add_clash_modes = true
|
||||
|
||||
[[node_pref.rename_node]]
|
||||
match = '\(?((x|X)?(\d+)(\.?\d+)?)((\s?倍率?)|(x|X))\)?'
|
||||
replace = "$1x"
|
||||
|
||||
[managed_config]
|
||||
# Append a '#!MANAGED-CONFIG' info to Surge configurations
|
||||
write_managed_config = true
|
||||
|
||||
# Address prefix for MANAGED-CONFIG info, without the trailing "/".
|
||||
managed_config_prefix = "http://127.0.0.1:25500"
|
||||
|
||||
# Managed config update interval in seconds, determine how long the config will be updated.
|
||||
config_update_interval = 86400
|
||||
|
||||
# If config_update_strict is set to true, Surge will require a force update after the interval.
|
||||
config_update_strict = false
|
||||
|
||||
# Device ID to be written to rewrite scripts for some version of Quantumult X
|
||||
quanx_device_id = ""
|
||||
|
||||
[surge_external_proxy]
|
||||
#surge_ssr_path = "/usr/bin/ssr-local"
|
||||
resolve_hostname = true
|
||||
|
||||
[security]
|
||||
# Security profile:
|
||||
# lan - default, legacy behavior for private/LAN deployments. Local, private
|
||||
# and fake-ip resources are allowed.
|
||||
# public - for Internet-facing deployments. Only untrusted request-controlled
|
||||
# fetches are restricted; built-in local templates and trusted config
|
||||
# files continue to work.
|
||||
# strict - same public fetch restrictions, and public upload cannot be enabled.
|
||||
# Environment override: SUBCONVERTER_SECURITY_PROFILE=lan|public|strict
|
||||
profile = "lan"
|
||||
# Only used by public profile. lan keeps legacy upload behavior; strict always
|
||||
# disables public upload.
|
||||
# Environment override: SUBCONVERTER_ALLOW_PUBLIC_UPLOAD=true|false
|
||||
allow_public_upload = false
|
||||
|
||||
[statistics]
|
||||
# Opt-in runtime statistics and /dashboard. Missing or false keeps it disabled
|
||||
# and avoids registering the dashboard or statistics-enabled request handler.
|
||||
enabled = false
|
||||
# Put this directory on a Docker volume if statistics should survive restarts.
|
||||
data_dir = "stats"
|
||||
# Minimum seconds between persistence writes.
|
||||
flush_interval = 5
|
||||
|
||||
[statistics.geo]
|
||||
# header uses country-only headers such as CF-IPCountry and never stores IPs.
|
||||
# none records all countries as unknown.
|
||||
provider = "header"
|
||||
country_headers = ["CF-IPCountry", "X-Geo-Country", "X-Vercel-IP-Country", "CloudFront-Viewer-Country"]
|
||||
|
||||
[statistics.dashboard_auth]
|
||||
# Optional Basic authentication for /dashboard and /dashboard/data.
|
||||
# Only applies when statistics.enabled is true.
|
||||
# Missing or false keeps the dashboard password disabled.
|
||||
enabled = false
|
||||
username = ""
|
||||
password = ""
|
||||
# Failed login attempts allowed within window_seconds before lock_seconds applies.
|
||||
max_failures = 5
|
||||
window_seconds = 300
|
||||
lock_seconds = 900
|
||||
|
||||
[emojis]
|
||||
add_emoji = false
|
||||
remove_old_emoji = true
|
||||
|
||||
[[emojis.emoji]]
|
||||
#match = '(流量|时间|应急)'
|
||||
#emoji = '🏳️🌈'
|
||||
import = "snippets/emoji.toml"
|
||||
|
||||
# [[custom_groups]]
|
||||
# name = "Auto"
|
||||
# type = "url-test"
|
||||
# rule = [".*"]
|
||||
# url = "http://www.gstatic.com/generate_204"
|
||||
# interval = 300
|
||||
# tolerance = 150
|
||||
# lazy = true
|
||||
|
||||
# [[custom_groups]]
|
||||
# name = "Proxy"
|
||||
# type = "select"
|
||||
# rule = [".*", "[]DIRECT"]
|
||||
# disable_udp = false
|
||||
|
||||
# [[custom_groups]]
|
||||
# name = "LoadBalance"
|
||||
# type = "load-balance"
|
||||
# rule = [".*", "[]Proxy", "[]DIRECT"]
|
||||
# interval = 100
|
||||
# strategy = "consistent-hashing"
|
||||
# url = "http://www.gstatic.com/generate_204"
|
||||
|
||||
[[custom_groups]]
|
||||
import = "snippets/groups.toml"
|
||||
|
||||
[ruleset]
|
||||
# Enable generating rules with rulesets
|
||||
enabled = true
|
||||
|
||||
# Overwrite the existing rules in rule_base
|
||||
overwrite_original_rules = false
|
||||
|
||||
# Perform a ruleset update on request
|
||||
update_ruleset_on_request = false
|
||||
|
||||
# [[rulesets]]
|
||||
# group = "Proxy"
|
||||
# ruleset = "https://raw.githubusercontent.com/DivineEngine/Profiles/master/Surge/Ruleset/Unbreak.list"
|
||||
# type = "surge-ruleset"
|
||||
# interval = 86400
|
||||
|
||||
[[rulesets]]
|
||||
import = "snippets/rulesets.toml"
|
||||
|
||||
[template]
|
||||
template_path = ""
|
||||
|
||||
[[template.globals]]
|
||||
key = "clash.http_port"
|
||||
value = "7890"
|
||||
|
||||
[[template.globals]]
|
||||
key = "clash.socks_port"
|
||||
value = "7891"
|
||||
|
||||
[[template.globals]]
|
||||
key = "clash.allow_lan"
|
||||
value = "true"
|
||||
|
||||
[[template.globals]]
|
||||
key = "clash.log_level"
|
||||
value = "info"
|
||||
|
||||
[[template.globals]]
|
||||
key = "clash.external_controller"
|
||||
value = "127.0.0.1:9090"
|
||||
|
||||
[[template.globals]]
|
||||
key = "singbox.allow_lan"
|
||||
value = "true"
|
||||
|
||||
[[template.globals]]
|
||||
key = "singbox.mixed_port"
|
||||
value = "2080"
|
||||
|
||||
[[aliases]]
|
||||
uri = "/clash"
|
||||
target = "/sub?target=clash"
|
||||
|
||||
[[aliases]]
|
||||
uri = "/clashr"
|
||||
target = "/sub?target=clashr"
|
||||
|
||||
[[aliases]]
|
||||
uri = "/surge"
|
||||
target = "/sub?target=surge"
|
||||
|
||||
[[aliases]]
|
||||
uri = "/quan"
|
||||
target = "/sub?target=quan"
|
||||
|
||||
[[aliases]]
|
||||
uri = "/quanx"
|
||||
target = "/sub?target=quanx"
|
||||
|
||||
[[aliases]]
|
||||
uri = "/mellow"
|
||||
target = "/sub?target=mellow"
|
||||
|
||||
[[aliases]]
|
||||
uri = "/surfboard"
|
||||
target = "/sub?target=surfboard"
|
||||
|
||||
[[aliases]]
|
||||
uri = "/loon"
|
||||
target = "/sub?target=loon"
|
||||
|
||||
[[aliases]]
|
||||
uri = "/singbox"
|
||||
target = "/sub?target=singbox"
|
||||
|
||||
[[aliases]]
|
||||
uri = "/ss"
|
||||
target = "/sub?target=ss"
|
||||
|
||||
[[aliases]]
|
||||
uri = "/ssd"
|
||||
target = "/sub?target=ssd"
|
||||
|
||||
[[aliases]]
|
||||
uri = "/sssub"
|
||||
target = "/sub?target=sssub"
|
||||
|
||||
[[aliases]]
|
||||
uri = "/ssr"
|
||||
target = "/sub?target=ssr"
|
||||
|
||||
[[aliases]]
|
||||
uri = "/v2ray"
|
||||
target = "/sub?target=v2ray"
|
||||
|
||||
[[aliases]]
|
||||
uri = "/trojan"
|
||||
target = "/sub?target=trojan"
|
||||
|
||||
[[aliases]]
|
||||
uri = "/test"
|
||||
target = "/render?path=templates/test.tpl"
|
||||
|
||||
#[[tasks]]
|
||||
#name = "tick"
|
||||
#cronexp = "0/10 * * * * ?"
|
||||
#path = "tick.js"
|
||||
#timeout = 3
|
||||
|
||||
[server]
|
||||
listen = "0.0.0.0"
|
||||
port = 25500
|
||||
serve_file_root = "web"
|
||||
|
||||
[advanced]
|
||||
log_level = "info"
|
||||
print_debug_info = false
|
||||
max_pending_connections = 10240
|
||||
max_concurrent_threads = 16
|
||||
# Maximum HTTP worker threads during bursts. Requests above this limit remain
|
||||
# queued instead of being rejected.
|
||||
max_server_threads = 128
|
||||
max_allowed_rulesets = 64
|
||||
max_allowed_rules = 0
|
||||
max_allowed_download_size = 0
|
||||
enable_cache = true
|
||||
cache_subscription = 60
|
||||
cache_config = 300
|
||||
cache_ruleset = 21600
|
||||
script_clean_context = true
|
||||
async_fetch_ruleset = true
|
||||
skip_failed_links = true
|
||||
enable_request_coalescing = true
|
||||
coalesce_retry_on_5xx = true
|
||||
# 0 disables completed response caching. If enabled, values above 5 seconds are clamped to 5.
|
||||
response_cache_ttl = 0
|
||||
@@ -20,7 +20,7 @@ go build \
|
||||
-buildmode=c-archive \
|
||||
-ldflags="-s -w" \
|
||||
-o libmihomo.a \
|
||||
converter.go
|
||||
.
|
||||
|
||||
echo "==> Build完成!"
|
||||
echo "Generated files:"
|
||||
|
||||
@@ -6,41 +6,11 @@ package main
|
||||
import "C"
|
||||
import (
|
||||
"encoding/json"
|
||||
"net/url"
|
||||
"strings"
|
||||
"unsafe"
|
||||
|
||||
"github.com/metacubex/mihomo/common/convert"
|
||||
)
|
||||
|
||||
// preprocessSubscription fixes URL encoding issues in subscription links
|
||||
// Decodes the entire URL line to ensure Mihomo parser receives properly unencoded links
|
||||
func preprocessSubscription(subscription string) string {
|
||||
lines := strings.Split(subscription, "\n")
|
||||
var result []string
|
||||
|
||||
for _, line := range lines {
|
||||
line = strings.TrimRight(line, " \r")
|
||||
if line == "" {
|
||||
result = append(result, line)
|
||||
continue
|
||||
}
|
||||
|
||||
// Decode the entire URL line
|
||||
// This fixes issues like v2rayN's uuid%3Apassword encoding
|
||||
// Safe for all protocols: url.QueryUnescape only decodes %XX patterns
|
||||
// and leaves structural characters (://, @, ?, #) intact
|
||||
if decoded, err := url.QueryUnescape(line); err == nil {
|
||||
line = decoded
|
||||
}
|
||||
// If decoding fails (malformed %), keep original line
|
||||
|
||||
result = append(result, line)
|
||||
}
|
||||
|
||||
return strings.Join(result, "\n")
|
||||
}
|
||||
|
||||
// ConvertSubscription converts V2Ray subscription links to mihomo proxy configs
|
||||
//
|
||||
//export ConvertSubscription
|
||||
|
||||
@@ -2,7 +2,7 @@ module github.com/aethersailor/subconverter-extended/bridge
|
||||
|
||||
go 1.25.5
|
||||
|
||||
require github.com/metacubex/mihomo v1.19.25
|
||||
require github.com/metacubex/mihomo v1.19.26
|
||||
|
||||
require (
|
||||
github.com/RyuaNerin/go-krypto v1.3.0 // indirect
|
||||
@@ -24,7 +24,7 @@ require (
|
||||
github.com/metacubex/randv2 v0.2.0 // indirect
|
||||
github.com/metacubex/sing v0.5.7 // indirect
|
||||
github.com/metacubex/sing-shadowsocks v0.2.12 // indirect
|
||||
github.com/metacubex/tls v0.1.5 // indirect
|
||||
github.com/metacubex/tls v0.1.6 // indirect
|
||||
github.com/oasisprotocol/deoxysii v0.0.0-20220228165953-2091330c22b7 // indirect
|
||||
github.com/samber/lo v1.53.0 // indirect
|
||||
github.com/sina-ghaderi/poly1305 v0.0.0-20220724002748-c5926b03988b // indirect
|
||||
|
||||
@@ -21,8 +21,8 @@ github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c
|
||||
github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
|
||||
github.com/dunglas/httpsfv v1.0.2 h1:iERDp/YAfnojSDJ7PW3dj1AReJz4MrwbECSSE59JWL0=
|
||||
github.com/dunglas/httpsfv v1.0.2/go.mod h1:zID2mqw9mFsnt7YC3vYQ9/cjq30q41W+1AnDwH8TiMg=
|
||||
github.com/enfein/mieru/v3 v3.31.0 h1:Fl2ocRCRXJzMygzdRjBHgqI996ZuIDHUmyQyovSf9sA=
|
||||
github.com/enfein/mieru/v3 v3.31.0/go.mod h1:zJBUCsi5rxyvHM8fjFf+GLaEl4OEjjBXr1s5F6Qd3hM=
|
||||
github.com/enfein/mieru/v3 v3.33.0 h1:hv2jK8nqYHwpSG86U2rpZR2I8Aff1/J3ifRmd9NBbFc=
|
||||
github.com/enfein/mieru/v3 v3.33.0/go.mod h1:zJBUCsi5rxyvHM8fjFf+GLaEl4OEjjBXr1s5F6Qd3hM=
|
||||
github.com/ericlagergren/aegis v0.0.0-20250325060835-cd0defd64358 h1:kXYqH/sL8dS/FdoFjr12ePjnLPorPo2FsnrHNuXSDyo=
|
||||
github.com/ericlagergren/aegis v0.0.0-20250325060835-cd0defd64358/go.mod h1:hkIFzoiIPZYxdFOOLyDho59b7SrDfo+w3h+yWdlg45I=
|
||||
github.com/ericlagergren/polyval v0.0.0-20230805202542-18692a1b76f9 h1:NUmyvuwVoDsIFzOGFKW4zpCtQTbX2T4JpSn1jal64gM=
|
||||
@@ -109,18 +109,18 @@ github.com/metacubex/hpke v0.1.0 h1:gu2jUNhraehWi0P/z5HX2md3d7L1FhPQE6/Q0E9r9xQ=
|
||||
github.com/metacubex/hpke v0.1.0/go.mod h1:vfDm6gfgrwlXUxKDkWbcE44hXtmc1uxLDm2BcR11b3U=
|
||||
github.com/metacubex/http v0.1.6 h1:xvXuvXMCMxCWMF5nEJF4yiKvXL+p2atWMzs37e80m1I=
|
||||
github.com/metacubex/http v0.1.6/go.mod h1:Nxx0zZAo2AhRfanyL+fmmK6ACMtVsfpwIl1aFAik2Eg=
|
||||
github.com/metacubex/jsonv2 v0.0.0-20260513175203-1c6abea7534c h1:KGhBHDe6FveU0ury+9RyX329nclM1CHODa0Fi+uOAYM=
|
||||
github.com/metacubex/jsonv2 v0.0.0-20260513175203-1c6abea7534c/go.mod h1:F4sVXat6QjPXkNsKRDyyG3BhSkxPFFnRPEIwmmyCgbg=
|
||||
github.com/metacubex/jsonv2 v0.0.0-20260518173308-f4597c22f1df h1:S0vBzqjXok24VopstOgPd1JdgglW9tXehrqvwpQWbQ8=
|
||||
github.com/metacubex/jsonv2 v0.0.0-20260518173308-f4597c22f1df/go.mod h1:F4sVXat6QjPXkNsKRDyyG3BhSkxPFFnRPEIwmmyCgbg=
|
||||
github.com/metacubex/kcp-go v0.0.0-20260105040817-550693377604 h1:hJwCVlE3ojViC35MGHB+FBr8TuIf3BUFn2EQ1VIamsI=
|
||||
github.com/metacubex/kcp-go v0.0.0-20260105040817-550693377604/go.mod h1:lpmN3m269b3V5jFCWtffqBLS4U3QQoIid9ugtO+OhVc=
|
||||
github.com/metacubex/mihomo v1.19.25 h1:gVoiMx4jljDeGzfxXj3XlyY6clTk7npY+SGfZg8bBYQ=
|
||||
github.com/metacubex/mihomo v1.19.25/go.mod h1:2wPHhdSY53InPrws2ZyzT1rPZgfy9eiag1aNsId1sZE=
|
||||
github.com/metacubex/mihomo v1.19.26 h1:zTOrwEzgji2N6jFZwe6411hCbKmV1VGxVYZFKriX6uw=
|
||||
github.com/metacubex/mihomo v1.19.26/go.mod h1:+L7tiesjMrF9I8lzTRsAFmHM9yh9RYdMEQP2tYnJqa8=
|
||||
github.com/metacubex/mlkem v0.1.0 h1:wFClitonSFcmipzzQvax75beLQU+D7JuC+VK1RzSL8I=
|
||||
github.com/metacubex/mlkem v0.1.0/go.mod h1:amhaXZVeYNShuy9BILcR7P0gbeo/QLZsnqCdL8U2PDQ=
|
||||
github.com/metacubex/qpack v0.6.0 h1:YqClGIMOpiRYLjV1qOs483Od08MdPgRnHjt90FuaAKw=
|
||||
github.com/metacubex/qpack v0.6.0/go.mod h1:lKGSi7Xk94IMvHGOmxS9eIei3bvIqpOAImEBsaOwTkA=
|
||||
github.com/metacubex/quic-go v0.59.1-0.20260413153657-53bb22f2c306 h1:HlGLmLsWJMLSu0CMI9z/BmEnithB4oXM5Rom6/0Qxtg=
|
||||
github.com/metacubex/quic-go v0.59.1-0.20260413153657-53bb22f2c306/go.mod h1:oNzMrmylS897M3zSMuapIdwSwfq6F2qW01Z3NhVRJhk=
|
||||
github.com/metacubex/quic-go v0.59.1-0.20260520020949-fcd18c7b6ace h1:KXacx7dp1GYVMgxezwXRt5BMsEbvAYuA6rPFUmdAvcQ=
|
||||
github.com/metacubex/quic-go v0.59.1-0.20260520020949-fcd18c7b6ace/go.mod h1:2YEQEvFrZ5V76oynMBDTlN+4fdnSHCa2uNJxv3cm1HU=
|
||||
github.com/metacubex/randv2 v0.2.0 h1:uP38uBvV2SxYfLj53kuvAjbND4RUDfFJjwr4UigMiLs=
|
||||
github.com/metacubex/randv2 v0.2.0/go.mod h1:kFi2SzrQ5WuneuoLLCMkABtiBu6VRrMrWFqSPyj2cxY=
|
||||
github.com/metacubex/restls-client-go v0.1.7 h1:eCwiXCTQb5WJu9IlgYvDBA1OgrINv58dEe7hcN5H15k=
|
||||
@@ -129,30 +129,30 @@ github.com/metacubex/sing v0.5.7 h1:8OC+fhKFSv/l9ehEhJRaZZAOuthfZo68SteBVLe8QqM=
|
||||
github.com/metacubex/sing v0.5.7/go.mod h1:ypf0mjwlZm0sKdQSY+yQvmsbWa0hNPtkeqyRMGgoN+w=
|
||||
github.com/metacubex/sing-mux v0.3.9 h1:/aoBD2+sK2qsXDlNDe3hkR0GZuFDtwIZhOeGUx9W0Yk=
|
||||
github.com/metacubex/sing-mux v0.3.9/go.mod h1:8bT7ZKT3clRrJjYc/x5CRYibC1TX/bK73a3r3+2E+Fc=
|
||||
github.com/metacubex/sing-quic v0.0.0-20260512151354-8475655be853 h1:nZ5WNU6kjj6kBu4+2eMySFkUVGCop64rZnLMm+HPh8w=
|
||||
github.com/metacubex/sing-quic v0.0.0-20260512151354-8475655be853/go.mod h1:6ayFGfzzBE85csgQkM3gf4neFq6s0losHlPRSxY+nuk=
|
||||
github.com/metacubex/sing-quic v0.0.0-20260527143057-68e10a6afdc3 h1:PnMby5+kZXTl/CFDHfxMbMTaSRD+uMKMsrDYVQyAmX8=
|
||||
github.com/metacubex/sing-quic v0.0.0-20260527143057-68e10a6afdc3/go.mod h1:6ayFGfzzBE85csgQkM3gf4neFq6s0losHlPRSxY+nuk=
|
||||
github.com/metacubex/sing-shadowsocks v0.2.12 h1:Wqzo8bYXrK5aWqxu/TjlTnYZzAKtKsaFQBdr6IHFaBE=
|
||||
github.com/metacubex/sing-shadowsocks v0.2.12/go.mod h1:2e5EIaw0rxKrm1YTRmiMnDulwbGxH9hAFlrwQLQMQkU=
|
||||
github.com/metacubex/sing-shadowsocks2 v0.2.7 h1:hSuuc0YpsfiqYqt1o+fP4m34BQz4e6wVj3PPBVhor3A=
|
||||
github.com/metacubex/sing-shadowsocks2 v0.2.7/go.mod h1:vOEbfKC60txi0ca+yUlqEwOGc3Obl6cnSgx9Gf45KjE=
|
||||
github.com/metacubex/sing-shadowtls v0.0.0-20250503063515-5d9f966d17a2 h1:gXU+MYPm7Wme3/OAY2FFzVq9d9GxPHOqu5AQfg/ddhI=
|
||||
github.com/metacubex/sing-shadowtls v0.0.0-20250503063515-5d9f966d17a2/go.mod h1:mbfboaXauKJNIHJYxQRa+NJs4JU9NZfkA+I33dS2+9E=
|
||||
github.com/metacubex/sing-shadowtls v0.0.0-20260517015314-c11c36474edc h1:8wLoFfYQ88iGPL+krQ5tJsI8IAmkFjKpQL2q+y3pvss=
|
||||
github.com/metacubex/sing-shadowtls v0.0.0-20260517015314-c11c36474edc/go.mod h1:mbfboaXauKJNIHJYxQRa+NJs4JU9NZfkA+I33dS2+9E=
|
||||
github.com/metacubex/sing-vmess v0.2.5 h1:m9Zt5I27lB9fmLMZfism9sH2LcnAfShZfwSkf6/KJoE=
|
||||
github.com/metacubex/sing-vmess v0.2.5/go.mod h1:AwtlzUgf8COe9tRYAKqWZ+leDH7p5U98a0ZUpYehl8Q=
|
||||
github.com/metacubex/sing-wireguard v0.0.0-20260507084707-690d479ec947 h1:IB03BvRQtvjWScyOK5jSQVJYY8osmZXHL+4VCEFMWcM=
|
||||
github.com/metacubex/sing-wireguard v0.0.0-20260507084707-690d479ec947/go.mod h1:jpAkVLPnCpGSfNyVmj6Cq4YbuZsFepm/Dc+9BAOcR80=
|
||||
github.com/metacubex/sing-wireguard v0.0.0-20260520151737-7e7c7c1b854c h1:tH9FuQW357zp2xAGzkoZTGpNGMVmEFZov0iV5M2S5ew=
|
||||
github.com/metacubex/sing-wireguard v0.0.0-20260520151737-7e7c7c1b854c/go.mod h1:eQZDJTx+IH3k4mXqaOJ3VJ9h9ZqOl60F7TLi5wAU51Q=
|
||||
github.com/metacubex/smux v0.0.0-20260105030934-d0c8756d3141 h1:DK2l6m2Fc85H2BhiAPgbJygiWhesPlfGmF+9Vw6ARdk=
|
||||
github.com/metacubex/smux v0.0.0-20260105030934-d0c8756d3141/go.mod h1:/yI4OiGOSn0SURhZdJF3CbtPg3nwK700bG8TZLMBvAg=
|
||||
github.com/metacubex/ssh v0.1.0 h1:iGfr99qk/eMHzUnQ/0bTxXT8+8SWqLSHBWDHoAhngzw=
|
||||
github.com/metacubex/ssh v0.1.0/go.mod h1:NUtl0d+/f2cG9ECEpMM8iCVOpmggQlC13oLeDUONDlU=
|
||||
github.com/metacubex/tailscale v0.0.0-20260516120020-a21c2c99dcbe h1:ZdAKshacNruZGuKTE8WMTuxyGgpv/LySLoE/EEmgF9c=
|
||||
github.com/metacubex/tailscale v0.0.0-20260516120020-a21c2c99dcbe/go.mod h1:2G1V82OGXgxT7m7046GA80I9SlcvczljCK0C7NQ3c10=
|
||||
github.com/metacubex/tailscale-wireguard-go v0.0.0-20260513233728-8bc7ee255d04 h1:zk+mDDSBl5lv80WWtaFUbpj8XLb7AhjCUbn2pB37N0U=
|
||||
github.com/metacubex/tailscale-wireguard-go v0.0.0-20260513233728-8bc7ee255d04/go.mod h1:pKUKBy7IcQ5r0i66gWENHgxKvBn8tlgAGx0DZMq8h5M=
|
||||
github.com/metacubex/tailscale v0.0.0-20260520011538-f23132fac4b7 h1:LoJR4NMyNKHeEJoeGDtcsao7sV0NRkzMeV5H/0J0MIE=
|
||||
github.com/metacubex/tailscale v0.0.0-20260520011538-f23132fac4b7/go.mod h1:MAo3HhE7968rIwmDvYTYE8xCsV4x+hLnkChdXeP3X4c=
|
||||
github.com/metacubex/tailscale-wireguard-go v0.0.0-20260521124654-e1bf77ef79af h1:c60IbBMUq2h1M2m7+grMJJmBmrObxL8SwvNtm6Ozbwk=
|
||||
github.com/metacubex/tailscale-wireguard-go v0.0.0-20260521124654-e1bf77ef79af/go.mod h1:i3zLKytWkOnyT1i9OmiLevWvrN5J5HE1+yjE7UYNfcQ=
|
||||
github.com/metacubex/tfo-go v0.0.0-20251130171125-413e892ac443 h1:H6TnfM12tOoTizYE/qBHH3nEuibIelmHI+BVSxVJr8o=
|
||||
github.com/metacubex/tfo-go v0.0.0-20251130171125-413e892ac443/go.mod h1:l9oLnLoEXyGZ5RVLsh7QCC5XsouTUyKk4F2nLm2DHLw=
|
||||
github.com/metacubex/tls v0.1.5 h1:ECcB83dj+zadnhlKcLnUUf1Sq6+vU0f/zoyU0+9oPTc=
|
||||
github.com/metacubex/tls v0.1.5/go.mod h1:0XeVdL0cBw+8i5Hqy3lVeP9IyD/LFTq02ExvHM6rzEM=
|
||||
github.com/metacubex/tls v0.1.6 h1:t2ubLneYa4ceyIC++54a57BLqZFA/QYUrhdjLk2GPwo=
|
||||
github.com/metacubex/tls v0.1.6/go.mod h1:0XeVdL0cBw+8i5Hqy3lVeP9IyD/LFTq02ExvHM6rzEM=
|
||||
github.com/metacubex/utls v1.8.4 h1:HmL9nUApDdWSkgUyodfwF6hSjtiwCGGdyhaSpEejKpg=
|
||||
github.com/metacubex/utls v1.8.4/go.mod h1:kncGGVhFaoGn5M3pFe3SXhZCzsbCJayNOH4UEqTKTko=
|
||||
github.com/metacubex/wireguard-go v0.0.0-20250820062549-a6cecdd7f57f h1:FGBPRb1zUabhPhDrlKEjQ9lgIwQ6cHL4x8M9lrERhbk=
|
||||
@@ -175,6 +175,8 @@ github.com/pires/go-proxyproto v0.8.0 h1:5unRmEAPbHXHuLjDg01CxJWf91cw3lKHc/0xzKp
|
||||
github.com/pires/go-proxyproto v0.8.0/go.mod h1:iknsfgnH8EkjrMeMyvfKByp9TiBZCKZM0jx2xmKqnVY=
|
||||
github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM=
|
||||
github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4=
|
||||
github.com/rasky/go-lzo v0.0.0-20200203143853-96a758eda86e h1:dCWirM5F3wMY+cmRda/B1BiPsFtmzXqV9b0hLWtVBMs=
|
||||
github.com/rasky/go-lzo v0.0.0-20200203143853-96a758eda86e/go.mod h1:9leZcVcItj6m9/CfHY5Em/iBrCz7js8LcRQGTKEEv2M=
|
||||
github.com/safchain/ethtool v0.3.0 h1:gimQJpsI6sc1yIqP/y8GYgiXn/NjgvpM0RNoWLVVmP0=
|
||||
github.com/safchain/ethtool v0.3.0/go.mod h1:SA9BwrgyAqNo7M+uaL6IYbxpm5wk3L7Mm6ocLW+CJUs=
|
||||
github.com/samber/lo v1.53.0 h1:t975lj2py4kJPQ6haz1QMgtId2gtmfktACxIXArw3HM=
|
||||
|
||||
@@ -87,7 +87,13 @@ typedef struct { void *data; GoInt len; GoInt cap; } GoSlice;
|
||||
extern "C" {
|
||||
#endif
|
||||
|
||||
|
||||
// ConvertSubscription converts V2Ray subscription links to mihomo proxy configs
|
||||
//
|
||||
extern char* ConvertSubscription(char* data);
|
||||
|
||||
// FreeString frees memory allocated by Go (must be called from C++ after using the result)
|
||||
//
|
||||
extern void FreeString(char* s);
|
||||
|
||||
#ifdef __cplusplus
|
||||
|
||||
181
bridge/preprocess.go
Normal file
181
bridge/preprocess.go
Normal file
@@ -0,0 +1,181 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"encoding/base64"
|
||||
"encoding/json"
|
||||
"net/url"
|
||||
"strconv"
|
||||
"strings"
|
||||
)
|
||||
|
||||
// preprocessSubscription fixes URL encoding issues and legacy share links before
|
||||
// the subscription is handed to mihomo's parser.
|
||||
func preprocessSubscription(subscription string) string {
|
||||
lines := strings.Split(subscription, "\n")
|
||||
result := make([]string, 0, len(lines))
|
||||
|
||||
for _, line := range lines {
|
||||
line = strings.TrimRight(line, " \r")
|
||||
if line == "" {
|
||||
result = append(result, line)
|
||||
continue
|
||||
}
|
||||
|
||||
// Decode the entire URL line. This fixes inputs such as v2rayN's
|
||||
// uuid%3Apassword encoding and keeps malformed percent escapes unchanged.
|
||||
if decoded, err := url.QueryUnescape(line); err == nil {
|
||||
line = decoded
|
||||
}
|
||||
|
||||
line = normalizeLegacyShadowrocketVMess(line)
|
||||
result = append(result, line)
|
||||
}
|
||||
|
||||
return strings.Join(result, "\n")
|
||||
}
|
||||
|
||||
func normalizeLegacyShadowrocketVMess(line string) string {
|
||||
const prefix = "vmess://"
|
||||
if !strings.HasPrefix(line, prefix) {
|
||||
return line
|
||||
}
|
||||
|
||||
body := strings.TrimPrefix(line, prefix)
|
||||
queryStart := strings.IndexByte(body, '?')
|
||||
if queryStart < 0 {
|
||||
return line
|
||||
}
|
||||
|
||||
encoded := body[:queryStart]
|
||||
rawQuery := body[queryStart+1:]
|
||||
if encoded == "" || rawQuery == "" {
|
||||
return line
|
||||
}
|
||||
|
||||
decoded, ok := decodeLooseBase64(encoded)
|
||||
if !ok {
|
||||
return line
|
||||
}
|
||||
|
||||
cipher, remainder, ok := strings.Cut(decoded, ":")
|
||||
if !ok {
|
||||
return line
|
||||
}
|
||||
uuid, serverPort, ok := strings.Cut(remainder, "@")
|
||||
if !ok {
|
||||
return line
|
||||
}
|
||||
server, port, ok := splitHostPortLoose(serverPort)
|
||||
if !ok || uuid == "" || server == "" || port == "" {
|
||||
return line
|
||||
}
|
||||
if _, err := strconv.Atoi(port); err != nil {
|
||||
return line
|
||||
}
|
||||
|
||||
query, err := url.ParseQuery(rawQuery)
|
||||
if err != nil {
|
||||
return line
|
||||
}
|
||||
|
||||
aid := firstQueryValue(query, "aid", "alterId")
|
||||
if aid == "" {
|
||||
aid = "0"
|
||||
}
|
||||
|
||||
network := "tcp"
|
||||
headerType := "none"
|
||||
host := ""
|
||||
path := ""
|
||||
obfs := strings.ToLower(firstQueryValue(query, "obfs"))
|
||||
switch obfs {
|
||||
case "websocket", "ws":
|
||||
network = "ws"
|
||||
host = firstQueryValue(query, "obfsParam", "host", "wsHost")
|
||||
path = firstQueryValue(query, "path", "wspath")
|
||||
case "":
|
||||
if value := firstQueryValue(query, "network", "net", "type"); value != "" {
|
||||
network = strings.ToLower(value)
|
||||
}
|
||||
host = firstQueryValue(query, "wsHost", "host")
|
||||
path = firstQueryValue(query, "wspath", "path")
|
||||
if value := firstQueryValue(query, "headerType"); value != "" {
|
||||
headerType = value
|
||||
}
|
||||
case "none":
|
||||
headerType = "none"
|
||||
default:
|
||||
headerType = obfs
|
||||
}
|
||||
|
||||
tls := ""
|
||||
switch strings.ToLower(firstQueryValue(query, "tls", "security")) {
|
||||
case "1", "true", "tls":
|
||||
tls = "tls"
|
||||
}
|
||||
|
||||
remarks := firstQueryValue(query, "remarks", "remark", "name")
|
||||
if remarks == "" {
|
||||
remarks = server + ":" + port
|
||||
}
|
||||
|
||||
values := map[string]string{
|
||||
"v": "2",
|
||||
"ps": remarks,
|
||||
"add": server,
|
||||
"port": port,
|
||||
"id": uuid,
|
||||
"aid": aid,
|
||||
"scy": cipher,
|
||||
"net": network,
|
||||
"type": headerType,
|
||||
"host": host,
|
||||
"path": path,
|
||||
"tls": tls,
|
||||
}
|
||||
if sni := firstQueryValue(query, "sni", "peer"); sni != "" {
|
||||
values["sni"] = sni
|
||||
}
|
||||
if alpn := firstQueryValue(query, "alpn"); alpn != "" {
|
||||
values["alpn"] = alpn
|
||||
}
|
||||
|
||||
jsonBytes, err := json.Marshal(values)
|
||||
if err != nil {
|
||||
return line
|
||||
}
|
||||
return prefix + base64.StdEncoding.EncodeToString(jsonBytes)
|
||||
}
|
||||
|
||||
func decodeLooseBase64(value string) (string, bool) {
|
||||
encodings := []*base64.Encoding{
|
||||
base64.RawURLEncoding,
|
||||
base64.URLEncoding,
|
||||
base64.RawStdEncoding,
|
||||
base64.StdEncoding,
|
||||
}
|
||||
for _, encoding := range encodings {
|
||||
decoded, err := encoding.DecodeString(value)
|
||||
if err == nil {
|
||||
return string(decoded), true
|
||||
}
|
||||
}
|
||||
return "", false
|
||||
}
|
||||
|
||||
func splitHostPortLoose(value string) (string, string, bool) {
|
||||
colon := strings.LastIndexByte(value, ':')
|
||||
if colon <= 0 || colon == len(value)-1 {
|
||||
return "", "", false
|
||||
}
|
||||
return strings.Trim(value[:colon], "[]"), value[colon+1:], true
|
||||
}
|
||||
|
||||
func firstQueryValue(values url.Values, keys ...string) string {
|
||||
for _, key := range keys {
|
||||
if value := values.Get(key); value != "" {
|
||||
return value
|
||||
}
|
||||
}
|
||||
return ""
|
||||
}
|
||||
55
bridge/preprocess_test.go
Normal file
55
bridge/preprocess_test.go
Normal file
@@ -0,0 +1,55 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
"github.com/metacubex/mihomo/common/convert"
|
||||
)
|
||||
|
||||
func TestPreprocessLegacyShadowrocketVmess(t *testing.T) {
|
||||
input := strings.Join([]string{
|
||||
"vmess://YXV0bzowNmNlNzU4Yy1iNTNkLTQ2NzQtOTdhNy01M2U4YmFhOGQwMjlAMjE2LjE0NC4yMjQuNjk6MzMwNg?remarks=%E7%BE%8E%E5%9B%BD%E8%87%AA%E5%BB%BA&path=/&obfs=none&alterId=0",
|
||||
"vmess://YXV0bzpmMmJiMmE4ZC02YWM0LTQ3NGYtYjJlYS1lMjJjNzhlYjkwMGZAMTI5LjE1MS4yNS4xOjE4MjU?remarks=US-O&udp=1&alterId=0",
|
||||
}, "\n")
|
||||
|
||||
proxies, err := convert.ConvertsV2Ray([]byte(preprocessSubscription(input)))
|
||||
if err != nil {
|
||||
t.Fatalf("convert error: %v", err)
|
||||
}
|
||||
if len(proxies) != 2 {
|
||||
t.Fatalf("got %d proxies: %#v", len(proxies), proxies)
|
||||
}
|
||||
|
||||
first := proxies[0]
|
||||
if first["type"] != "vmess" {
|
||||
t.Fatalf("unexpected first type: %#v", first["type"])
|
||||
}
|
||||
if first["name"] != "美国自建" {
|
||||
t.Fatalf("unexpected first name: %#v", first["name"])
|
||||
}
|
||||
if first["server"] != "216.144.224.69" {
|
||||
t.Fatalf("unexpected first server: %#v", first["server"])
|
||||
}
|
||||
if first["port"] != "3306" {
|
||||
t.Fatalf("unexpected first port: %#v", first["port"])
|
||||
}
|
||||
if first["uuid"] != "06ce758c-b53d-4674-97a7-53e8baa8d029" {
|
||||
t.Fatalf("unexpected first uuid: %#v", first["uuid"])
|
||||
}
|
||||
|
||||
second := proxies[1]
|
||||
if second["server"] != "129.151.25.1" {
|
||||
t.Fatalf("unexpected second server: %#v", second["server"])
|
||||
}
|
||||
if second["port"] != "1825" {
|
||||
t.Fatalf("unexpected second port: %#v", second["port"])
|
||||
}
|
||||
}
|
||||
|
||||
func TestPreprocessKeepsStandardVmess(t *testing.T) {
|
||||
input := "vmess://uuid@example.com:443?encryption=auto#name"
|
||||
if got := preprocessSubscription(input); got != input {
|
||||
t.Fatalf("standard vmess changed:\nwant %q\n got %q", input, got)
|
||||
}
|
||||
}
|
||||
@@ -16,6 +16,7 @@ RUN apt-get update && \
|
||||
|
||||
COPY bridge/go.mod bridge/go.sum ./
|
||||
COPY bridge/converter.go ./
|
||||
COPY bridge/preprocess.go ./
|
||||
|
||||
RUN set -xe && \
|
||||
if [ "${REFRESH_GO_DEPS}" = "true" ]; then \
|
||||
@@ -183,15 +184,23 @@ RUN set -xe && \
|
||||
ln -snf /usr/share/zoneinfo/Asia/Shanghai /runtime-root/etc/localtime && \
|
||||
echo Asia/Shanghai > /runtime-root/etc/timezone && \
|
||||
if [ -f /etc/nsswitch.conf ]; then cp -aL /etc/nsswitch.conf /runtime-root/etc/nsswitch.conf; fi && \
|
||||
for dir in /lib/arm-linux-gnueabihf /usr/lib/arm-linux-gnueabihf /usr/arm-linux-gnueabihf/lib; do \
|
||||
if [ -d "$dir" ]; then \
|
||||
find "$dir" -maxdepth 1 \( -name '*.so' -o -name '*.so.*' \) -print | \
|
||||
while read -r lib; do \
|
||||
dest="/runtime-root/usr/lib/arm-linux-gnueabihf/$(basename "$lib")"; \
|
||||
cp -aL "$lib" "$dest"; \
|
||||
done; \
|
||||
READELF=arm-linux-gnueabihf-readelf \
|
||||
ELF_LIBRARY_PATH="/usr/lib/arm-linux-gnueabihf:/lib/arm-linux-gnueabihf:/usr/arm-linux-gnueabihf/lib:/usr/lib:/lib" \
|
||||
bash /src/scripts/ci/copy-elf-runtime-deps.sh /runtime-root \
|
||||
/src/subconverter \
|
||||
libnss_dns.so.2 \
|
||||
libnss_files.so.2 \
|
||||
libnss_compat.so.2 \
|
||||
libresolv.so.2 && \
|
||||
if [ -d /runtime-root/lib ]; then \
|
||||
install -d /runtime-root/usr/lib/arm-linux-gnueabihf; \
|
||||
find /runtime-root/lib -maxdepth 1 -type f -name '*.so*' -exec mv -f -t /runtime-root/usr/lib/arm-linux-gnueabihf {} +; \
|
||||
if [ -d /runtime-root/lib/arm-linux-gnueabihf ]; then \
|
||||
cp -a /runtime-root/lib/arm-linux-gnueabihf/. /runtime-root/usr/lib/arm-linux-gnueabihf/; \
|
||||
rm -rf /runtime-root/lib/arm-linux-gnueabihf; \
|
||||
fi; \
|
||||
done
|
||||
rmdir /runtime-root/lib 2>/dev/null || true; \
|
||||
fi
|
||||
|
||||
# ========== FINAL STAGE ==========
|
||||
FROM --platform=$TARGETPLATFORM mirror.gcr.io/library/debian:trixie-slim
|
||||
|
||||
@@ -22,6 +22,7 @@ RUN apt-get update && \
|
||||
# Copy committed Go module files and source.
|
||||
COPY bridge/go.mod bridge/go.sum ./
|
||||
COPY bridge/converter.go ./
|
||||
COPY bridge/preprocess.go ./
|
||||
|
||||
RUN set -xe && \
|
||||
if [ "${REFRESH_GO_DEPS}" = "true" ]; then \
|
||||
|
||||
@@ -8,8 +8,8 @@
|
||||
#ifndef CPPHTTPLIB_HTTPLIB_H
|
||||
#define CPPHTTPLIB_HTTPLIB_H
|
||||
|
||||
#define CPPHTTPLIB_VERSION "0.46.0"
|
||||
#define CPPHTTPLIB_VERSION_NUM "0x002e00"
|
||||
#define CPPHTTPLIB_VERSION "0.46.1"
|
||||
#define CPPHTTPLIB_VERSION_NUM "0x002e01"
|
||||
|
||||
#ifdef _WIN32
|
||||
#if defined(_WIN32_WINNT) && _WIN32_WINNT < 0x0A00
|
||||
@@ -1643,6 +1643,8 @@ public:
|
||||
using Expect100ContinueHandler =
|
||||
std::function<int(const Request &, Response &)>;
|
||||
|
||||
using StartHandler = std::function<void()>;
|
||||
|
||||
using WebSocketHandler =
|
||||
std::function<void(const Request &, ws::WebSocket &)>;
|
||||
using SubProtocolSelector =
|
||||
@@ -1694,6 +1696,9 @@ public:
|
||||
Server &set_pre_request_handler(HandlerWithResponse handler);
|
||||
|
||||
Server &set_expect_100_continue_handler(Expect100ContinueHandler handler);
|
||||
|
||||
Server &set_start_handler(StartHandler handler);
|
||||
|
||||
Server &set_logger(Logger logger);
|
||||
Server &set_pre_compression_logger(Logger logger);
|
||||
Server &set_error_logger(ErrorLogger error_logger);
|
||||
@@ -1883,6 +1888,7 @@ private:
|
||||
Handler post_routing_handler_;
|
||||
HandlerWithResponse pre_request_handler_;
|
||||
Expect100ContinueHandler expect_100_continue_handler_;
|
||||
StartHandler start_handler_;
|
||||
|
||||
mutable std::mutex logger_mutex_;
|
||||
Logger logger_;
|
||||
@@ -3842,6 +3848,7 @@ public:
|
||||
void set_socket_options(SocketOptions socket_options);
|
||||
void set_connection_timeout(time_t sec, time_t usec = 0);
|
||||
void set_interface(const std::string &intf);
|
||||
void set_hostname_addr_map(std::map<std::string, std::string> addr_map);
|
||||
|
||||
#ifdef CPPHTTPLIB_SSL_ENABLED
|
||||
void set_ca_cert_path(const std::string &path);
|
||||
@@ -3876,6 +3883,9 @@ private:
|
||||
time_t connection_timeout_usec_ = CPPHTTPLIB_CONNECTION_TIMEOUT_USECOND;
|
||||
std::string interface_;
|
||||
|
||||
// Hostname-IP map
|
||||
std::map<std::string, std::string> addr_map_;
|
||||
|
||||
#ifdef CPPHTTPLIB_SSL_ENABLED
|
||||
bool is_ssl_ = false;
|
||||
tls::ctx_t tls_ctx_ = nullptr;
|
||||
@@ -4629,7 +4639,7 @@ inline std::string sha1(const std::string &input) {
|
||||
// Pre-processing: adding padding bits
|
||||
std::string msg = input;
|
||||
uint64_t original_bit_len = static_cast<uint64_t>(msg.size()) * 8;
|
||||
msg.push_back(static_cast<char>(0x80));
|
||||
msg.push_back(static_cast<char>(0x80u));
|
||||
while (msg.size() % 64 != 56) {
|
||||
msg.push_back(0);
|
||||
}
|
||||
@@ -5730,7 +5740,7 @@ inline int getaddrinfo_with_timeout(const char *node, const char *service,
|
||||
|
||||
#ifdef _WIN32
|
||||
// Windows-specific implementation using GetAddrInfoEx with overlapped I/O
|
||||
OVERLAPPED overlapped = {0};
|
||||
OVERLAPPED overlapped = {};
|
||||
HANDLE event = CreateEventW(nullptr, TRUE, FALSE, nullptr);
|
||||
if (!event) { return EAI_FAIL; }
|
||||
|
||||
@@ -5739,7 +5749,7 @@ inline int getaddrinfo_with_timeout(const char *node, const char *service,
|
||||
PADDRINFOEXW result_addrinfo = nullptr;
|
||||
HANDLE cancel_handle = nullptr;
|
||||
|
||||
ADDRINFOEXW hints_ex = {0};
|
||||
ADDRINFOEXW hints_ex = {};
|
||||
if (hints) {
|
||||
hints_ex.ai_flags = hints->ai_flags;
|
||||
hints_ex.ai_family = hints->ai_family;
|
||||
@@ -8443,6 +8453,14 @@ inline void coalesce_ranges(Ranges &ranges, size_t content_length) {
|
||||
|
||||
inline bool range_error(Request &req, Response &res) {
|
||||
if (!req.ranges.empty() && 200 <= res.status && res.status < 300) {
|
||||
if (res.body.empty() && res.content_provider_ && res.content_length_ == 0) {
|
||||
req.ranges.clear();
|
||||
if (res.status == StatusCode::PartialContent_206) {
|
||||
res.status = StatusCode::OK_200;
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
ssize_t content_len = static_cast<ssize_t>(
|
||||
res.content_length_ ? res.content_length_ : res.body.size());
|
||||
|
||||
@@ -11092,6 +11110,11 @@ Server::set_expect_100_continue_handler(Expect100ContinueHandler handler) {
|
||||
return *this;
|
||||
}
|
||||
|
||||
inline Server &Server::set_start_handler(StartHandler handler) {
|
||||
start_handler_ = std::move(handler);
|
||||
return *this;
|
||||
}
|
||||
|
||||
inline Server &Server::set_address_family(int family) {
|
||||
address_family_ = family;
|
||||
return *this;
|
||||
@@ -11787,6 +11810,8 @@ inline bool Server::listen_internal() {
|
||||
is_running_ = true;
|
||||
auto se = detail::scope_exit([&]() { is_running_ = false; });
|
||||
|
||||
if (start_handler_) { start_handler_(); }
|
||||
|
||||
{
|
||||
std::unique_ptr<TaskQueue> task_queue(new_task_queue());
|
||||
|
||||
@@ -13810,13 +13835,28 @@ inline bool ClientImpl::process_request(Stream &strm, Request &req,
|
||||
}
|
||||
#endif
|
||||
|
||||
// Handle Expect: 100-continue with timeout
|
||||
if (expect_100_continue && CPPHTTPLIB_EXPECT_100_TIMEOUT_MSECOND > 0) {
|
||||
time_t sec = CPPHTTPLIB_EXPECT_100_TIMEOUT_MSECOND / 1000;
|
||||
time_t usec = (CPPHTTPLIB_EXPECT_100_TIMEOUT_MSECOND % 1000) * 1000;
|
||||
auto ret = detail::select_read(strm.socket(), sec, usec);
|
||||
if (ret <= 0) {
|
||||
// Timeout or error: send body anyway (server didn't respond in time)
|
||||
// Handle Expect: 100-continue.
|
||||
//
|
||||
// Wait for an interim/early response by attempting to read the status line
|
||||
// under a short timeout, instead of trusting raw socket readability. Over
|
||||
// TLS, post-handshake records (e.g. session tickets) make the socket
|
||||
// readable without any HTTP response being available; relying on
|
||||
// `select_read` there caused the body to be withheld forever and the
|
||||
// request to fail with `Read` (#2458). If no status line arrives within the
|
||||
// timeout, send the body anyway (matching curl's behavior).
|
||||
auto status_line_read = false;
|
||||
if (expect_100_continue && write_request_success) {
|
||||
if (CPPHTTPLIB_EXPECT_100_TIMEOUT_MSECOND > 0) {
|
||||
time_t sec = CPPHTTPLIB_EXPECT_100_TIMEOUT_MSECOND / 1000;
|
||||
time_t usec = (CPPHTTPLIB_EXPECT_100_TIMEOUT_MSECOND % 1000) * 1000;
|
||||
strm.set_read_timeout(sec, usec);
|
||||
status_line_read = read_response_line(strm, req, res, false);
|
||||
strm.set_read_timeout(read_timeout_sec_, read_timeout_usec_);
|
||||
}
|
||||
|
||||
if (!status_line_read) {
|
||||
// No interim response within the timeout: send the body and handle the
|
||||
// response as usual.
|
||||
if (!write_request_body(strm, req, error)) { return false; }
|
||||
expect_100_continue = false; // Switch to normal response handling
|
||||
}
|
||||
@@ -13824,7 +13864,8 @@ inline bool ClientImpl::process_request(Stream &strm, Request &req,
|
||||
|
||||
// Receive response and headers
|
||||
// When using Expect: 100-continue, don't auto-skip `100 Continue` response
|
||||
if (!read_response_line(strm, req, res, !expect_100_continue) ||
|
||||
if ((!status_line_read &&
|
||||
!read_response_line(strm, req, res, !expect_100_continue)) ||
|
||||
!detail::read_headers(strm, res.headers)) {
|
||||
if (write_request_success) { error = Error::Read; }
|
||||
output_error_log(error, &req);
|
||||
@@ -20289,9 +20330,14 @@ inline bool WebSocketClient::connect() {
|
||||
if (!is_valid_) { return false; }
|
||||
shutdown_and_close();
|
||||
|
||||
// Check is custom IP specified for host_
|
||||
std::string ip;
|
||||
auto it = addr_map_.find(host_);
|
||||
if (it != addr_map_.end()) { ip = it->second; }
|
||||
|
||||
Error error;
|
||||
sock_ = detail::create_client_socket(
|
||||
host_, std::string(), port_, address_family_, tcp_nodelay_, ipv6_v6only_,
|
||||
host_, ip, port_, address_family_, tcp_nodelay_, ipv6_v6only_,
|
||||
socket_options_, connection_timeout_sec_, connection_timeout_usec_,
|
||||
read_timeout_sec_, read_timeout_usec_, write_timeout_sec_,
|
||||
write_timeout_usec_, interface_, error);
|
||||
@@ -20386,6 +20432,11 @@ inline void WebSocketClient::set_interface(const std::string &intf) {
|
||||
interface_ = intf;
|
||||
}
|
||||
|
||||
inline void WebSocketClient::set_hostname_addr_map(
|
||||
std::map<std::string, std::string> addr_map) {
|
||||
addr_map_ = std::move(addr_map);
|
||||
}
|
||||
|
||||
#ifdef CPPHTTPLIB_SSL_ENABLED
|
||||
|
||||
inline void WebSocketClient::set_ca_cert_path(const std::string &path) {
|
||||
|
||||
394
pref.toml
Normal file
394
pref.toml
Normal file
@@ -0,0 +1,394 @@
|
||||
version = 1
|
||||
[common]
|
||||
# API mode is hardcoded to true for security - cannot be configured
|
||||
# Token authentication is disabled - users must provide url parameter
|
||||
|
||||
|
||||
# Default URLs, used when no URL is provided in request, use "|" to separate multiple subscription links, supports local files/URL
|
||||
default_url = []
|
||||
|
||||
# Insert subscription links to requests. Can be used to add node(s) to all exported subscriptions.
|
||||
enable_insert = true
|
||||
# URLs to insert before subscription links, can be used to add node(s) to all exported subscriptions, supports local files/URL
|
||||
insert_url = [""]
|
||||
# Prepend inserted URLs to subscription links. Nodes in insert_url will be added to groups first with non-group-specific match pattern.
|
||||
prepend_insert_url = true
|
||||
|
||||
# Exclude nodes which remarks match the following patterns. Supports regular expression.
|
||||
exclude_remarks = ["(到期|剩余流量|时间|官网|产品)"]
|
||||
|
||||
# Only include nodes which remarks match the following patterns. Supports regular expression.
|
||||
#include_remarks = ["V3.*港"]
|
||||
|
||||
# Enable script support for filtering nodes
|
||||
enable_filter = false
|
||||
# Script used for filtering nodes. Supports inline script and script path. A "filter" function with 1 argument which is a node should be defined in the script.
|
||||
# Example: Inline script: set value to content of script.
|
||||
# Script path: set value to "path:/path/to/script.js".
|
||||
#filter_script = '''
|
||||
#function filter(node) {
|
||||
# const info = JSON.parse(node.ProxyInfo);
|
||||
# if(info.EncryptMethod.includes('chacha20'))
|
||||
# return true;
|
||||
# return false;
|
||||
#}
|
||||
#'''
|
||||
|
||||
default_external_config = "https://testingcf.jsdelivr.net/gh/Aethersailor/Custom_OpenClash_Rules@refs/heads/main/cfg/Custom_Clash.ini"
|
||||
|
||||
# The file scope limit of the 'rule_base' options in external configs.
|
||||
base_path = "base"
|
||||
|
||||
# Clash config base used by the generator, supports local files/URL
|
||||
clash_rule_base = "base/all_base.tpl"
|
||||
|
||||
# Surge config base used by the generator, supports local files/URL
|
||||
surge_rule_base = "base/all_base.tpl"
|
||||
|
||||
# Surfboard config base used by the generator, supports local files/URL
|
||||
surfboard_rule_base = "base/all_base.tpl"
|
||||
|
||||
# Mellow config base used by the generator, supports local files/URL
|
||||
mellow_rule_base = "base/all_base.tpl"
|
||||
|
||||
# Quantumult config base used by the generator, supports local files/URL
|
||||
quan_rule_base = "base/all_base.tpl"
|
||||
|
||||
# Quantumult X config base used by the generator, supports local files/URL
|
||||
quanx_rule_base = "base/all_base.tpl"
|
||||
|
||||
# Loon config base used by the generator, supports local files/URL
|
||||
loon_rule_base = "base/all_base.tpl"
|
||||
|
||||
# Shadowsocks Android config base used by the generator, supports local files/URL
|
||||
sssub_rule_base = "base/all_base.tpl"
|
||||
|
||||
# sing-box config base used by the generator, supports local files/URL
|
||||
singbox_rule_base = "base/all_base.tpl"
|
||||
|
||||
# Proxy used to download rulesets or subscriptions, set to NONE or empty to disable it, set to SYSTEM to use system proxy.
|
||||
# Accept cURL-supported proxies (http:// https:// socks4a:// socks5://)
|
||||
|
||||
proxy_config = "SYSTEM"
|
||||
proxy_ruleset = "SYSTEM"
|
||||
proxy_subscription = "NONE"
|
||||
|
||||
# Append a proxy type string ([SS] [SSR] [VMess]) to node remark.
|
||||
append_proxy_type = false
|
||||
|
||||
# When requesting /sub, reload this config file first.
|
||||
reload_conf_on_request = false
|
||||
|
||||
[[userinfo.stream_rule]]
|
||||
# Rules to extract stream data from node
|
||||
# Format: full_match_regex|new_format_regex
|
||||
# where new_format_regex should be like "total=$1&left=$2&used=$3"
|
||||
match = '^剩余流量:(.*?)\|总流量:(.*)$'
|
||||
replace = 'total=$2&left=$1'
|
||||
|
||||
[[userinfo.stream_rule]]
|
||||
match = '^剩余流量:(.*?) (.*)$'
|
||||
replace = 'total=$1&left=$2'
|
||||
|
||||
[[userinfo.stream_rule]]
|
||||
match = '^Bandwidth: (.*?)/(.*)$'
|
||||
replace = 'used=$1&total=$2'
|
||||
|
||||
[[userinfo.stream_rule]]
|
||||
match = '^.*剩余(.*?)(?:\s*?)@(?:.*)$'
|
||||
replace = 'total=$1'
|
||||
|
||||
[[userinfo.time_rule]]
|
||||
# Rules to extract expire time data from node
|
||||
# Format: full_match_regex|new_format_regex
|
||||
# where new_format_regex should follow this example: yyyy:mm:dd:hh:mm:ss
|
||||
match = '^过期时间:(\d+)-(\d+)-(\d+) (\d+):(\d+):(\d+)$'
|
||||
replace = '$1:$2:$3:$4:$5:$6'
|
||||
|
||||
[[userinfo.time_rule]]
|
||||
match = '^到期时间:(\d+)-(\d+)-(\d+)$'
|
||||
replace = '$1:$2:$3:0:0:0'
|
||||
|
||||
[[userinfo.time_rule]]
|
||||
match = '^Smart Access expire: (\d+)/(\d+)/(\d+)$'
|
||||
replace = '$1:$2:$3:0:0:0'
|
||||
|
||||
[node_pref]
|
||||
#udp_flag = false
|
||||
#tcp_fast_open_flag = false
|
||||
#skip_cert_verify_flag = false
|
||||
#tls13_flag = false
|
||||
|
||||
sort_flag = false
|
||||
# Script used for sorting nodes. A "compare" function with 2 arguments which are the 2 nodes to be compared should be defined in the script. Supports inline script and script path.
|
||||
# Examples can be seen at the filter_script option in [common] section.
|
||||
#sort_script = '''
|
||||
#function compare(node_a, node_b) {
|
||||
# return info_a.Remark > info_b.Remark;
|
||||
#}
|
||||
#'''
|
||||
|
||||
filter_deprecated_nodes = false
|
||||
append_sub_userinfo = true
|
||||
clash_use_new_field_name = true
|
||||
|
||||
# Generate style of the proxies and proxy groups section of Clash subscriptions.
|
||||
# Supported styles: block, flow, compact
|
||||
# Block: - name: name1 Flow: - {name: name1, key: value} Compact: [{name: name1, key: value},{name: name2, key: value}]
|
||||
# key: value - {name: name2, key: value}
|
||||
# - name: name2
|
||||
# key: value
|
||||
clash_proxies_style = "flow"
|
||||
clash_proxy_groups_style = "flow"
|
||||
|
||||
# add Clash mode to sing-box rules, and add a GLOBAL group to end of outbounds
|
||||
singbox_add_clash_modes = true
|
||||
|
||||
[[node_pref.rename_node]]
|
||||
match = '\(?((x|X)?(\d+)(\.?\d+)?)((\s?倍率?)|(x|X))\)?'
|
||||
replace = "$1x"
|
||||
|
||||
[managed_config]
|
||||
# Append a '#!MANAGED-CONFIG' info to Surge configurations
|
||||
write_managed_config = true
|
||||
|
||||
# Address prefix for MANAGED-CONFIG info, without the trailing "/".
|
||||
managed_config_prefix = "http://127.0.0.1:25500"
|
||||
|
||||
# Managed config update interval in seconds, determine how long the config will be updated.
|
||||
config_update_interval = 86400
|
||||
|
||||
# If config_update_strict is set to true, Surge will require a force update after the interval.
|
||||
config_update_strict = false
|
||||
|
||||
# Device ID to be written to rewrite scripts for some version of Quantumult X
|
||||
quanx_device_id = ""
|
||||
|
||||
[surge_external_proxy]
|
||||
#surge_ssr_path = "/usr/bin/ssr-local"
|
||||
resolve_hostname = true
|
||||
|
||||
[security]
|
||||
# Security profile:
|
||||
# lan - default, legacy behavior for private/LAN deployments. Local, private
|
||||
# and fake-ip resources are allowed.
|
||||
# public - for Internet-facing deployments. Only untrusted request-controlled
|
||||
# fetches are restricted; built-in local templates and trusted config
|
||||
# files continue to work.
|
||||
# strict - same public fetch restrictions, and public upload cannot be enabled.
|
||||
# Environment override: SUBCONVERTER_SECURITY_PROFILE=lan|public|strict
|
||||
profile = "lan"
|
||||
# Only used by public profile. lan keeps legacy upload behavior; strict always
|
||||
# disables public upload.
|
||||
# Environment override: SUBCONVERTER_ALLOW_PUBLIC_UPLOAD=true|false
|
||||
allow_public_upload = false
|
||||
|
||||
[statistics]
|
||||
# Opt-in runtime statistics and /dashboard. Missing or false keeps it disabled
|
||||
# and avoids registering the dashboard or statistics-enabled request handler.
|
||||
enabled = false
|
||||
# Put this directory on a Docker volume if statistics should survive restarts.
|
||||
data_dir = "stats"
|
||||
# Minimum seconds between persistence writes.
|
||||
flush_interval = 5
|
||||
|
||||
[statistics.geo]
|
||||
# header uses country-only headers such as CF-IPCountry and never stores IPs.
|
||||
# none records all countries as unknown.
|
||||
provider = "header"
|
||||
country_headers = ["CF-IPCountry", "X-Geo-Country", "X-Vercel-IP-Country", "CloudFront-Viewer-Country"]
|
||||
|
||||
[statistics.dashboard_auth]
|
||||
# Optional Basic authentication for /dashboard and /dashboard/data.
|
||||
# Only applies when statistics.enabled is true.
|
||||
# Missing or false keeps the dashboard password disabled.
|
||||
enabled = false
|
||||
username = ""
|
||||
password = ""
|
||||
# Failed login attempts allowed within window_seconds before lock_seconds applies.
|
||||
max_failures = 5
|
||||
window_seconds = 300
|
||||
lock_seconds = 900
|
||||
|
||||
[emojis]
|
||||
add_emoji = false
|
||||
remove_old_emoji = true
|
||||
|
||||
[[emojis.emoji]]
|
||||
#match = '(流量|时间|应急)'
|
||||
#emoji = '🏳️🌈'
|
||||
import = "snippets/emoji.toml"
|
||||
|
||||
# [[custom_groups]]
|
||||
# name = "Auto"
|
||||
# type = "url-test"
|
||||
# rule = [".*"]
|
||||
# url = "http://www.gstatic.com/generate_204"
|
||||
# interval = 300
|
||||
# tolerance = 150
|
||||
# lazy = true
|
||||
|
||||
# [[custom_groups]]
|
||||
# name = "Proxy"
|
||||
# type = "select"
|
||||
# rule = [".*", "[]DIRECT"]
|
||||
# disable_udp = false
|
||||
|
||||
# [[custom_groups]]
|
||||
# name = "LoadBalance"
|
||||
# type = "load-balance"
|
||||
# rule = [".*", "[]Proxy", "[]DIRECT"]
|
||||
# interval = 100
|
||||
# strategy = "consistent-hashing"
|
||||
# url = "http://www.gstatic.com/generate_204"
|
||||
|
||||
[[custom_groups]]
|
||||
import = "snippets/groups.toml"
|
||||
|
||||
[ruleset]
|
||||
# Enable generating rules with rulesets
|
||||
enabled = true
|
||||
|
||||
# Overwrite the existing rules in rule_base
|
||||
overwrite_original_rules = false
|
||||
|
||||
# Perform a ruleset update on request
|
||||
update_ruleset_on_request = false
|
||||
|
||||
# [[rulesets]]
|
||||
# group = "Proxy"
|
||||
# ruleset = "https://raw.githubusercontent.com/DivineEngine/Profiles/master/Surge/Ruleset/Unbreak.list"
|
||||
# type = "surge-ruleset"
|
||||
# interval = 86400
|
||||
|
||||
[[rulesets]]
|
||||
import = "snippets/rulesets.toml"
|
||||
|
||||
[template]
|
||||
template_path = ""
|
||||
|
||||
[[template.globals]]
|
||||
key = "clash.http_port"
|
||||
value = "7890"
|
||||
|
||||
[[template.globals]]
|
||||
key = "clash.socks_port"
|
||||
value = "7891"
|
||||
|
||||
[[template.globals]]
|
||||
key = "clash.allow_lan"
|
||||
value = "true"
|
||||
|
||||
[[template.globals]]
|
||||
key = "clash.log_level"
|
||||
value = "info"
|
||||
|
||||
[[template.globals]]
|
||||
key = "clash.external_controller"
|
||||
value = "127.0.0.1:9090"
|
||||
|
||||
[[template.globals]]
|
||||
key = "singbox.allow_lan"
|
||||
value = "true"
|
||||
|
||||
[[template.globals]]
|
||||
key = "singbox.mixed_port"
|
||||
value = "2080"
|
||||
|
||||
[[aliases]]
|
||||
uri = "/clash"
|
||||
target = "/sub?target=clash"
|
||||
|
||||
[[aliases]]
|
||||
uri = "/clashr"
|
||||
target = "/sub?target=clashr"
|
||||
|
||||
[[aliases]]
|
||||
uri = "/surge"
|
||||
target = "/sub?target=surge"
|
||||
|
||||
[[aliases]]
|
||||
uri = "/quan"
|
||||
target = "/sub?target=quan"
|
||||
|
||||
[[aliases]]
|
||||
uri = "/quanx"
|
||||
target = "/sub?target=quanx"
|
||||
|
||||
[[aliases]]
|
||||
uri = "/mellow"
|
||||
target = "/sub?target=mellow"
|
||||
|
||||
[[aliases]]
|
||||
uri = "/surfboard"
|
||||
target = "/sub?target=surfboard"
|
||||
|
||||
[[aliases]]
|
||||
uri = "/loon"
|
||||
target = "/sub?target=loon"
|
||||
|
||||
[[aliases]]
|
||||
uri = "/singbox"
|
||||
target = "/sub?target=singbox"
|
||||
|
||||
[[aliases]]
|
||||
uri = "/ss"
|
||||
target = "/sub?target=ss"
|
||||
|
||||
[[aliases]]
|
||||
uri = "/ssd"
|
||||
target = "/sub?target=ssd"
|
||||
|
||||
[[aliases]]
|
||||
uri = "/sssub"
|
||||
target = "/sub?target=sssub"
|
||||
|
||||
[[aliases]]
|
||||
uri = "/ssr"
|
||||
target = "/sub?target=ssr"
|
||||
|
||||
[[aliases]]
|
||||
uri = "/v2ray"
|
||||
target = "/sub?target=v2ray"
|
||||
|
||||
[[aliases]]
|
||||
uri = "/trojan"
|
||||
target = "/sub?target=trojan"
|
||||
|
||||
[[aliases]]
|
||||
uri = "/test"
|
||||
target = "/render?path=templates/test.tpl"
|
||||
|
||||
#[[tasks]]
|
||||
#name = "tick"
|
||||
#cronexp = "0/10 * * * * ?"
|
||||
#path = "tick.js"
|
||||
#timeout = 3
|
||||
|
||||
[server]
|
||||
listen = "0.0.0.0"
|
||||
port = 25500
|
||||
serve_file_root = "web"
|
||||
|
||||
[advanced]
|
||||
log_level = "info"
|
||||
print_debug_info = false
|
||||
max_pending_connections = 10240
|
||||
max_concurrent_threads = 16
|
||||
# Maximum HTTP worker threads during bursts. Requests above this limit remain
|
||||
# queued instead of being rejected.
|
||||
max_server_threads = 128
|
||||
max_allowed_rulesets = 64
|
||||
max_allowed_rules = 0
|
||||
max_allowed_download_size = 0
|
||||
enable_cache = true
|
||||
cache_subscription = 60
|
||||
cache_config = 300
|
||||
cache_ruleset = 21600
|
||||
script_clean_context = true
|
||||
async_fetch_ruleset = true
|
||||
skip_failed_links = true
|
||||
enable_request_coalescing = true
|
||||
coalesce_retry_on_5xx = true
|
||||
# 0 disables completed response caching. If enabled, values above 5 seconds are clamped to 5.
|
||||
response_cache_ttl = 0
|
||||
126
scripts/ci/copy-elf-runtime-deps.sh
Normal file
126
scripts/ci/copy-elf-runtime-deps.sh
Normal file
@@ -0,0 +1,126 @@
|
||||
#!/usr/bin/env bash
|
||||
set -euo pipefail
|
||||
|
||||
DEST_ROOT="${1:?destination root is required}"
|
||||
shift
|
||||
|
||||
READELF="${READELF:-readelf}"
|
||||
ELF_LIBRARY_PATH="${ELF_LIBRARY_PATH:-}"
|
||||
|
||||
declare -a SEARCH_DIRS=()
|
||||
if [ -n "${ELF_LIBRARY_PATH}" ]; then
|
||||
IFS=':' read -r -a SEARCH_DIRS <<< "${ELF_LIBRARY_PATH}"
|
||||
fi
|
||||
|
||||
SEARCH_DIRS+=(
|
||||
/lib
|
||||
/usr/lib
|
||||
/lib64
|
||||
/usr/lib64
|
||||
/lib/x86_64-linux-gnu
|
||||
/usr/lib/x86_64-linux-gnu
|
||||
/lib/aarch64-linux-gnu
|
||||
/usr/lib/aarch64-linux-gnu
|
||||
/lib/arm-linux-gnueabihf
|
||||
/usr/lib/arm-linux-gnueabihf
|
||||
/usr/arm-linux-gnueabihf/lib
|
||||
)
|
||||
|
||||
declare -A COPIED=()
|
||||
declare -A SCANNED=()
|
||||
declare -a QUEUE=()
|
||||
|
||||
canonical_path() {
|
||||
local path="$1"
|
||||
if command -v realpath >/dev/null 2>&1; then
|
||||
realpath "$path"
|
||||
else
|
||||
readlink -f "$path"
|
||||
fi
|
||||
}
|
||||
|
||||
copy_runtime_file() {
|
||||
local source="$1"
|
||||
local logical="$source"
|
||||
local resolved
|
||||
resolved="$(canonical_path "$source")"
|
||||
|
||||
if [ -n "${COPIED[$logical]:-}" ]; then
|
||||
return
|
||||
fi
|
||||
|
||||
local dest="${DEST_ROOT}${logical}"
|
||||
mkdir -p "$(dirname "$dest")"
|
||||
cp -aL "$source" "$dest"
|
||||
COPIED["$logical"]=1
|
||||
QUEUE+=("$resolved")
|
||||
}
|
||||
|
||||
resolve_soname() {
|
||||
local soname="$1"
|
||||
|
||||
if [[ "$soname" == */* ]] && [ -e "$soname" ]; then
|
||||
printf '%s\n' "$soname"
|
||||
return 0
|
||||
fi
|
||||
|
||||
local dir candidate
|
||||
for dir in "${SEARCH_DIRS[@]}"; do
|
||||
[ -n "$dir" ] || continue
|
||||
candidate="${dir}/${soname}"
|
||||
if [ -e "$candidate" ]; then
|
||||
printf '%s\n' "$candidate"
|
||||
return 0
|
||||
fi
|
||||
done
|
||||
|
||||
return 1
|
||||
}
|
||||
|
||||
copy_needed_by() {
|
||||
local elf="$1"
|
||||
|
||||
if [ -n "${SCANNED[$elf]:-}" ]; then
|
||||
return
|
||||
fi
|
||||
SCANNED["$elf"]=1
|
||||
|
||||
if ! "$READELF" -h "$elf" >/dev/null 2>&1; then
|
||||
return
|
||||
fi
|
||||
|
||||
local interp
|
||||
interp="$("$READELF" -l "$elf" 2>/dev/null | sed -n 's/.*Requesting program interpreter: \(.*\)\]/\1/p' | head -n 1)"
|
||||
if [ -n "$interp" ] && [ -e "$interp" ]; then
|
||||
copy_runtime_file "$interp"
|
||||
fi
|
||||
|
||||
local needed resolved
|
||||
while IFS= read -r needed; do
|
||||
[ -n "$needed" ] || continue
|
||||
if resolved="$(resolve_soname "$needed")"; then
|
||||
copy_runtime_file "$resolved"
|
||||
else
|
||||
echo "warning: could not resolve ELF dependency '$needed' required by $elf" >&2
|
||||
fi
|
||||
done < <("$READELF" -d "$elf" 2>/dev/null | sed -n 's/.*Shared library: \[\(.*\)\].*/\1/p')
|
||||
}
|
||||
|
||||
for input in "$@"; do
|
||||
if [ -e "$input" ]; then
|
||||
case "$(basename "$input")" in
|
||||
*.so|*.so.*|ld-*.so*|ld-linux*.so*) copy_runtime_file "$input" ;;
|
||||
esac
|
||||
copy_needed_by "$(canonical_path "$input")"
|
||||
elif resolved="$(resolve_soname "$input")"; then
|
||||
copy_runtime_file "$resolved"
|
||||
else
|
||||
echo "warning: could not resolve requested runtime file '$input'" >&2
|
||||
fi
|
||||
done
|
||||
|
||||
while [ "${#QUEUE[@]}" -gt 0 ]; do
|
||||
current="${QUEUE[0]}"
|
||||
QUEUE=("${QUEUE[@]:1}")
|
||||
copy_needed_by "$current"
|
||||
done
|
||||
@@ -25,7 +25,6 @@ chmod +x ./subconverter
|
||||
case "${MODE}" in
|
||||
shared)
|
||||
docker cp "${CID}:/runtime-libs" ./runtime-libs
|
||||
docker cp "${CID}:/usr/lib/libmihomo.so" ./libmihomo.so
|
||||
;;
|
||||
root)
|
||||
docker cp "${CID}:/runtime-root" ./runtime-root
|
||||
|
||||
@@ -27,11 +27,6 @@ mkdir -p "${PACKAGE_DIR}"
|
||||
install -m755 subconverter "${PACKAGE_DIR}/subconverter"
|
||||
cp -a base "${PACKAGE_DIR}/"
|
||||
|
||||
if [ -f libmihomo.so ]; then
|
||||
mkdir -p "${PACKAGE_DIR}/usr/lib"
|
||||
install -m755 libmihomo.so "${PACKAGE_DIR}/usr/lib/libmihomo.so"
|
||||
fi
|
||||
|
||||
copy_dir_contents runtime-libs
|
||||
copy_dir_contents runtime-root
|
||||
|
||||
|
||||
@@ -12,6 +12,7 @@ from __future__ import annotations
|
||||
import argparse
|
||||
import difflib
|
||||
import json
|
||||
import re
|
||||
import sys
|
||||
import urllib.error
|
||||
import urllib.parse
|
||||
@@ -29,12 +30,22 @@ def build_url(base_url: str, path: str, params: dict[str, str] | None = None) ->
|
||||
return f"{base}{path}" + (f"?{query}" if query else "")
|
||||
|
||||
|
||||
def fetch(base_url: str, path: str, params: dict[str, str] | None, timeout: int) -> str:
|
||||
def fetch_response(
|
||||
base_url: str,
|
||||
path: str,
|
||||
params: dict[str, str] | None,
|
||||
timeout: int,
|
||||
headers: dict[str, str] | None = None,
|
||||
) -> tuple[str, dict[str, str]]:
|
||||
url = build_url(base_url, path, params)
|
||||
request = urllib.request.Request(url, headers=headers or {})
|
||||
try:
|
||||
with urllib.request.urlopen(url, timeout=timeout) as response:
|
||||
with urllib.request.urlopen(request, timeout=timeout) as response:
|
||||
status = response.status
|
||||
body = response.read().decode("utf-8", errors="replace")
|
||||
response_headers = {
|
||||
key.lower(): value for key, value in response.headers.items()
|
||||
}
|
||||
except urllib.error.HTTPError as exc:
|
||||
body = exc.read().decode("utf-8", errors="replace")
|
||||
raise AssertionError(f"{url} returned HTTP {exc.code}\n{body}") from exc
|
||||
@@ -43,6 +54,11 @@ def fetch(base_url: str, path: str, params: dict[str, str] | None, timeout: int)
|
||||
|
||||
if status < 200 or status >= 300:
|
||||
raise AssertionError(f"{url} returned HTTP {status}\n{body}")
|
||||
return body, response_headers
|
||||
|
||||
|
||||
def fetch(base_url: str, path: str, params: dict[str, str] | None, timeout: int) -> str:
|
||||
body, _ = fetch_response(base_url, path, params, timeout)
|
||||
return body
|
||||
|
||||
|
||||
@@ -76,6 +92,75 @@ def run_checks(base_url: str, timeout: int, snapshot_dir: Path | None, update: b
|
||||
if health.strip() != "ok":
|
||||
raise AssertionError(f"/healthz returned unexpected body: {health!r}")
|
||||
|
||||
version_page, version_headers = fetch_response(
|
||||
base_url, "/version", None, timeout
|
||||
)
|
||||
if (
|
||||
"<!DOCTYPE html>" not in version_page
|
||||
or "SubConverter-Extended" not in version_page
|
||||
):
|
||||
raise AssertionError("/version did not return the HTML version page")
|
||||
if not version_headers.get("content-type", "").lower().startswith("text/html"):
|
||||
raise AssertionError("/version HTML response has an unexpected content type")
|
||||
|
||||
navigation_page, navigation_headers = fetch_response(
|
||||
base_url,
|
||||
"/version",
|
||||
None,
|
||||
timeout,
|
||||
{
|
||||
"Origin": "https://edgetunnel.example",
|
||||
"Sec-Fetch-Mode": "navigate",
|
||||
"Sec-Fetch-Dest": "document",
|
||||
},
|
||||
)
|
||||
if "<!DOCTYPE html>" not in navigation_page:
|
||||
raise AssertionError("/version navigation request did not return HTML")
|
||||
if not navigation_headers.get("content-type", "").lower().startswith(
|
||||
"text/html"
|
||||
):
|
||||
raise AssertionError("/version navigation response has an unexpected content type")
|
||||
|
||||
probe_headers = {
|
||||
"Origin": "https://edgetunnel.example",
|
||||
"Sec-Fetch-Mode": "cors",
|
||||
"Sec-Fetch-Dest": "empty",
|
||||
}
|
||||
version_probe, version_probe_headers = fetch_response(
|
||||
base_url, "/version", None, timeout, probe_headers
|
||||
)
|
||||
version_probe_line = version_probe.strip()
|
||||
if not re.fullmatch(
|
||||
r"SubConverter-Extended \S+ backend", version_probe_line
|
||||
):
|
||||
raise AssertionError(
|
||||
f"/version probe returned an unexpected body: {version_probe!r}"
|
||||
)
|
||||
if "subconverter" not in version_probe_line.lower() or "<" in version_probe_line:
|
||||
raise AssertionError("/version probe is not compatible with backend detection")
|
||||
if not version_probe_headers.get("content-type", "").lower().startswith(
|
||||
"text/plain"
|
||||
):
|
||||
raise AssertionError("/version probe response has an unexpected content type")
|
||||
if version_probe_headers.get("access-control-allow-origin") != "*":
|
||||
raise AssertionError("/version probe response is missing the CORS header")
|
||||
if "no-store" not in version_probe_headers.get("cache-control", "").lower():
|
||||
raise AssertionError("/version probe response is missing no-store caching")
|
||||
vary = version_probe_headers.get("vary", "").lower()
|
||||
for header in ("sec-fetch-mode", "sec-fetch-dest", "origin"):
|
||||
if header not in vary:
|
||||
raise AssertionError(f"/version probe Vary header is missing {header}")
|
||||
|
||||
legacy_probe, _ = fetch_response(
|
||||
base_url,
|
||||
"/version",
|
||||
None,
|
||||
timeout,
|
||||
{"Origin": "https://edgetunnel.example"},
|
||||
)
|
||||
if legacy_probe != version_probe:
|
||||
raise AssertionError("/version legacy browser probe response is inconsistent")
|
||||
|
||||
inspect_page = fetch(base_url, "/inspect", None, timeout)
|
||||
if (
|
||||
"Request Inspector" not in inspect_page
|
||||
|
||||
@@ -237,7 +237,8 @@ namespace INIBinding
|
||||
continue;
|
||||
}
|
||||
|
||||
if(conf.Type == ProxyGroupType::URLTest || conf.Type == ProxyGroupType::LoadBalance || conf.Type == ProxyGroupType::Fallback)
|
||||
if(conf.Type == ProxyGroupType::URLTest || conf.Type == ProxyGroupType::LoadBalance ||
|
||||
conf.Type == ProxyGroupType::Fallback || conf.Type == ProxyGroupType::Smart)
|
||||
{
|
||||
if(rules_upper_bound < 5)
|
||||
continue;
|
||||
|
||||
@@ -292,6 +292,36 @@ bool applyMatcher(const std::string &rule, std::string &real_rule,
|
||||
return true;
|
||||
}
|
||||
|
||||
static bool parseProviderGroupIdMatcher(const std::string &rule,
|
||||
std::string &target,
|
||||
std::string &real_rule) {
|
||||
static const std::string groupid_regex =
|
||||
R"(^!!GROUPID=([\d\-+!,]+)(?:!!(.*))?$)";
|
||||
if (!startsWith(rule, "!!GROUPID="))
|
||||
return false;
|
||||
target.clear();
|
||||
real_rule.clear();
|
||||
return regGetMatch(rule, groupid_regex, 3,
|
||||
static_cast<std::string *>(nullptr), &target,
|
||||
&real_rule) == 0 &&
|
||||
!target.empty();
|
||||
}
|
||||
|
||||
static bool isProviderRegexRule(const std::string &rule) {
|
||||
return !rule.empty() && rule[0] != '[' && rule != "DIRECT" &&
|
||||
rule != "REJECT";
|
||||
}
|
||||
|
||||
static YAML::Node providersMatchingGroupId(
|
||||
const std::string &target, const std::vector<ProxyProvider> &providers) {
|
||||
YAML::Node use_node(YAML::NodeType::Sequence);
|
||||
for (const ProxyProvider &p : providers) {
|
||||
if (p.groupId >= 0 && matchRange(target, p.groupId))
|
||||
use_node.push_back(p.name);
|
||||
}
|
||||
return use_node;
|
||||
}
|
||||
|
||||
void processRemark(std::string &remark, const string_array &remarks_list,
|
||||
bool proc_comma = true) {
|
||||
// Replace every '=' with '-' in the remark string to avoid parse errors from
|
||||
@@ -1057,20 +1087,39 @@ void proxyToClash(std::vector<Proxy> &nodes, YAML::Node &yamlnode,
|
||||
// 检查策略组是否包含正则表达式(用于匹配节点)
|
||||
bool has_regex = false;
|
||||
std::string regex_pattern;
|
||||
bool has_groupid_provider_match = false;
|
||||
YAML::Node groupid_use_node(YAML::NodeType::Sequence);
|
||||
|
||||
for (const auto &proxy : x.Proxies) {
|
||||
// 如果不是以 [] 开头,则认为是正则表达式
|
||||
if (!proxy.empty() && proxy[0] != '[' && proxy != "DIRECT" &&
|
||||
proxy != "REJECT") {
|
||||
if (isProviderRegexRule(proxy)) {
|
||||
std::string groupid_target, groupid_filter;
|
||||
if (parseProviderGroupIdMatcher(proxy, groupid_target,
|
||||
groupid_filter)) {
|
||||
YAML::Node matched_providers =
|
||||
providersMatchingGroupId(groupid_target, ext.providers);
|
||||
if (matched_providers.size() > 0) {
|
||||
has_groupid_provider_match = true;
|
||||
groupid_use_node = matched_providers;
|
||||
regex_pattern = groupid_filter;
|
||||
has_regex = !regex_pattern.empty();
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
has_regex = true;
|
||||
regex_pattern = proxy;
|
||||
break; // 找到第一个正则就够了
|
||||
}
|
||||
}
|
||||
|
||||
// 只有包含正则表达式的策略组才引用 provider
|
||||
// 不包含正则的策略组只引用其他策略组,不需要 provider
|
||||
if (has_regex && !regex_pattern.empty()) {
|
||||
if (has_groupid_provider_match) {
|
||||
singlegroup["use"] = groupid_use_node;
|
||||
if (has_regex)
|
||||
singlegroup["filter"] = regex_pattern;
|
||||
} else if (has_regex && !regex_pattern.empty()) {
|
||||
// 只有包含正则表达式的策略组才引用 provider
|
||||
// 不包含正则的策略组只引用其他策略组,不需要 provider
|
||||
// 添加 use 字段引用所有原始 provider
|
||||
YAML::Node use_node(YAML::NodeType::Sequence);
|
||||
for (const ProxyProvider &p : ext.providers) {
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -2,6 +2,7 @@
|
||||
|
||||
#include <string>
|
||||
|
||||
#include "handler/settings.h"
|
||||
#include "utils/logger.h"
|
||||
#include "version.h"
|
||||
|
||||
@@ -14,6 +15,21 @@ std::string page(Request &request, Response &response) {
|
||||
LOG_LEVEL_INFO);
|
||||
response.headers["X-Robots-Tag"] =
|
||||
"noindex, nofollow, noarchive, nosnippet, noimageindex";
|
||||
std::string dashboard_link =
|
||||
global.statisticsEnabled
|
||||
? R"html(
|
||||
<a class="page-link" href="/dashboard" aria-label="Open dashboard">
|
||||
<svg viewBox="0 0 24 24" aria-hidden="true">
|
||||
<path d="M4 19V5"></path>
|
||||
<path d="M4 19h16"></path>
|
||||
<path d="M8 16v-5"></path>
|
||||
<path d="M13 16V8"></path>
|
||||
<path d="M18 16v-3"></path>
|
||||
</svg>
|
||||
<span data-lang="en">Dashboard</span>
|
||||
<span data-lang="zh">仪表盘</span>
|
||||
</a>)html"
|
||||
: "";
|
||||
|
||||
return R"html(<!DOCTYPE html>
|
||||
<html lang="en">
|
||||
@@ -128,14 +144,11 @@ std::string page(Request &request, Response &response) {
|
||||
|
||||
body {
|
||||
font-family: 'Outfit', system-ui, -apple-system, BlinkMacSystemFont, "Segoe UI", "Microsoft YaHei", "PingFang SC", "Noto Sans CJK SC", sans-serif;
|
||||
margin: 0;
|
||||
min-height: 100vh;
|
||||
min-height: 100svh;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
background: var(--bg-gradient);
|
||||
background-attachment: fixed;
|
||||
padding: 24px;
|
||||
color: var(--text-primary);
|
||||
-webkit-font-smoothing: antialiased;
|
||||
-moz-osx-font-smoothing: grayscale;
|
||||
@@ -166,38 +179,82 @@ std::string page(Request &request, Response &response) {
|
||||
opacity: 0.82;
|
||||
}
|
||||
|
||||
.lang-toggle {
|
||||
position: fixed;
|
||||
top: calc(18px + env(safe-area-inset-top, 0px));
|
||||
right: calc(18px + env(safe-area-inset-right, 0px));
|
||||
z-index: 10;
|
||||
display: inline-flex;
|
||||
.shell {
|
||||
position: relative;
|
||||
z-index: 1;
|
||||
width: min(1180px, calc(100% - 32px));
|
||||
margin: 0 auto;
|
||||
padding: 28px 0 42px;
|
||||
}
|
||||
.topbar {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
justify-content: space-between;
|
||||
gap: 16px;
|
||||
margin-bottom: 24px;
|
||||
}
|
||||
.brand-row {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 14px;
|
||||
min-width: 0;
|
||||
}
|
||||
.brand-row img {
|
||||
width: 48px;
|
||||
height: 48px;
|
||||
flex: 0 0 auto;
|
||||
filter: drop-shadow(0 12px 24px rgba(2, 132, 199, 0.16));
|
||||
}
|
||||
.brand-row h1 {
|
||||
margin: 0;
|
||||
font-size: 1.8rem;
|
||||
line-height: 1.08;
|
||||
letter-spacing: 0;
|
||||
overflow-wrap: anywhere;
|
||||
background: none;
|
||||
-webkit-background-clip: unset;
|
||||
background-clip: unset;
|
||||
-webkit-text-fill-color: unset;
|
||||
}
|
||||
.brand-row .top-subtitle {
|
||||
margin-top: 5px;
|
||||
color: var(--text-secondary);
|
||||
font-size: 0.94rem;
|
||||
font-weight: 600;
|
||||
}
|
||||
.actions {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 10px;
|
||||
flex-wrap: wrap;
|
||||
justify-content: flex-end;
|
||||
}
|
||||
.lang-btn {
|
||||
border: 1px solid var(--control-border);
|
||||
border-radius: 999px;
|
||||
background: var(--control-bg);
|
||||
backdrop-filter: blur(18px);
|
||||
-webkit-backdrop-filter: blur(18px);
|
||||
box-shadow: var(--control-shadow);
|
||||
color: var(--text-primary);
|
||||
cursor: pointer;
|
||||
font: inherit;
|
||||
font-size: 0.86rem;
|
||||
font-size: 0.88rem;
|
||||
font-weight: 700;
|
||||
line-height: 1;
|
||||
min-height: 40px;
|
||||
min-width: 76px;
|
||||
padding: 9px 13px;
|
||||
transition: background 0.2s ease, border-color 0.2s ease, box-shadow 0.2s ease, transform 0.2s ease;
|
||||
backdrop-filter: blur(18px);
|
||||
-webkit-backdrop-filter: blur(18px);
|
||||
transition: background 0.2s ease, border-color 0.2s ease, transform 0.2s ease;
|
||||
}
|
||||
|
||||
.lang-toggle:hover {
|
||||
.lang-btn:hover {
|
||||
background: var(--control-hover);
|
||||
transform: translateY(-1px);
|
||||
}
|
||||
.lang-btn:focus-visible {
|
||||
outline: 3px solid rgba(99, 179, 237, 0.35);
|
||||
outline-offset: 2px;
|
||||
}
|
||||
|
||||
.lang-toggle:focus-visible,
|
||||
button:focus-visible,
|
||||
textarea:focus-visible,
|
||||
input:focus-visible {
|
||||
@@ -205,15 +262,53 @@ std::string page(Request &request, Response &response) {
|
||||
outline-offset: 2px;
|
||||
}
|
||||
|
||||
.lang-toggle svg {
|
||||
.page-links {
|
||||
display: inline-flex;
|
||||
justify-content: center;
|
||||
flex-wrap: wrap;
|
||||
gap: 10px;
|
||||
margin-top: 18px;
|
||||
}
|
||||
|
||||
.page-link {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
gap: 8px;
|
||||
min-height: 40px;
|
||||
padding: 9px 14px;
|
||||
border: 1px solid var(--control-border);
|
||||
border-radius: 999px;
|
||||
background: var(--control-bg);
|
||||
box-shadow: var(--control-shadow);
|
||||
color: var(--text-primary);
|
||||
font-size: 0.86rem;
|
||||
font-weight: 700;
|
||||
line-height: 1;
|
||||
text-decoration: none;
|
||||
transition: background 0.2s ease, border-color 0.2s ease, box-shadow 0.2s ease, transform 0.2s ease;
|
||||
}
|
||||
|
||||
.page-link:hover {
|
||||
background: var(--control-hover);
|
||||
color: var(--text-primary);
|
||||
transform: translateY(-1px);
|
||||
}
|
||||
|
||||
.page-link:focus-visible {
|
||||
outline: 3px solid rgba(99, 179, 237, 0.35);
|
||||
outline-offset: 2px;
|
||||
}
|
||||
|
||||
.page-link svg {
|
||||
width: 17px;
|
||||
height: 17px;
|
||||
flex: 0 0 auto;
|
||||
}
|
||||
|
||||
.lang-toggle-text {
|
||||
min-width: 20px;
|
||||
text-align: center;
|
||||
fill: none;
|
||||
stroke: currentColor;
|
||||
stroke-width: 1.9;
|
||||
stroke-linecap: round;
|
||||
stroke-linejoin: round;
|
||||
}
|
||||
|
||||
.container {
|
||||
@@ -682,9 +777,14 @@ std::string page(Request &request, Response &response) {
|
||||
|
||||
@media (max-width: 780px) {
|
||||
body {
|
||||
align-items: stretch;
|
||||
padding: 76px 14px 18px;
|
||||
padding: 0;
|
||||
}
|
||||
.shell {
|
||||
padding: 16px 0 24px;
|
||||
width: min(100% - 20px, 1180px);
|
||||
}
|
||||
.topbar { flex-direction: column; align-items: flex-start; }
|
||||
.brand-row h1 { font-size: 1.4rem; }
|
||||
|
||||
.container {
|
||||
border-radius: 24px;
|
||||
@@ -727,6 +827,17 @@ std::string page(Request &request, Response &response) {
|
||||
grid-template-columns: 1fr;
|
||||
}
|
||||
|
||||
.page-links {
|
||||
margin-top: 16px;
|
||||
gap: 8px;
|
||||
}
|
||||
|
||||
.page-link {
|
||||
min-height: 38px;
|
||||
padding: 8px 12px;
|
||||
font-size: 0.8rem;
|
||||
}
|
||||
|
||||
.request-preview {
|
||||
max-height: 150px;
|
||||
}
|
||||
@@ -751,17 +862,22 @@ std::string page(Request &request, Response &response) {
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
<button class="lang-toggle" type="button" aria-label="Switch language">
|
||||
<svg viewBox="0 0 24 24" aria-hidden="true">
|
||||
<path d="M4 5h9M9 3v2m1.7 0c-.6 3.5-2.4 6.1-5.2 7.7m2.8-3.1c1.1 1.3 2.3 2.3 3.7 3" fill="none" stroke="currentColor" stroke-width="1.8" stroke-linecap="round" stroke-linejoin="round"/>
|
||||
<path d="M14 20l4-9 4 9m-6.7-3h5.4" fill="none" stroke="currentColor" stroke-width="1.8" stroke-linecap="round" stroke-linejoin="round"/>
|
||||
</svg>
|
||||
<span class="lang-toggle-text" data-lang="en">中</span>
|
||||
<span class="lang-toggle-text" data-lang="zh">EN</span>
|
||||
</button>
|
||||
|
||||
<main class="container">
|
||||
<header>
|
||||
<div class="shell">
|
||||
<div class="topbar">
|
||||
<div class="brand-row">
|
||||
<picture>
|
||||
<source media="(prefers-color-scheme: dark)" srcset="/version/favicon-dark.svg">
|
||||
<img src="/version/favicon-light.svg" alt="SubConverter-Extended" width="48" height="48" decoding="async">
|
||||
</picture>
|
||||
<div>
|
||||
<h1>SubConverter-Extended</h1>
|
||||
<div class="top-subtitle">
|
||||
<span data-lang="en">Request Inspector</span>
|
||||
<span data-lang="zh">请求诊断台</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="actions">
|
||||
<picture class="brand-mark">
|
||||
<source media="(prefers-color-scheme: dark)" srcset="/version/favicon-dark.svg">
|
||||
<img src="/version/favicon-light.svg" alt="SubConverter-Extended icon" width="88" height="88" decoding="async">
|
||||
@@ -779,7 +895,23 @@ std::string page(Request &request, Response &response) {
|
||||
<span data-lang="en">Explain conversion without writing managed output</span>
|
||||
<span data-lang="zh">以只读方式解释转换结果</span>
|
||||
</p>
|
||||
</header>
|
||||
<nav class="page-links" aria-label="Page navigation">
|
||||
<a class="page-link" href="/version" aria-label="Open version page">
|
||||
<svg viewBox="0 0 24 24" aria-hidden="true">
|
||||
<path d="M20.6 13.2 13.2 20.6a2 2 0 0 1-2.8 0L3.4 13.6a2 2 0 0 1-.6-1.4V5a2 2 0 0 1 2-2h7.2a2 2 0 0 1 1.4.6l7.2 7.2a2 2 0 0 1 0 2.8Z"></path>
|
||||
<circle cx="7.5" cy="7.5" r="1.2"></circle>
|
||||
</svg>
|
||||
<span data-lang="en">Version</span>
|
||||
<span data-lang="zh">版本信息</span>
|
||||
</a>)html" +
|
||||
dashboard_link + R"html(
|
||||
<button type="button" class="lang-btn" id="lang-toggle" aria-label="Switch language">
|
||||
<span class="lang-toggle-text">中</span>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
<main class="container">
|
||||
<header>
|
||||
|
||||
<section class="section">
|
||||
<div class="section-title">
|
||||
@@ -943,13 +1075,13 @@ std::string page(Request &request, Response &response) {
|
||||
<footer>
|
||||
<span data-lang="en">SubConverter-Extended )html" +
|
||||
std::string(VERSION) +
|
||||
R"html( · <a href="/version">Version</a></span>
|
||||
R"html( · <a href="/version">Version</a> · Source Code: <a href="https://github.com/Aethersailor/SubConverter-Extended" target="_blank" rel="noopener noreferrer">GitHub</a> · License: <a href="https://www.gnu.org/licenses/gpl-3.0.html" target="_blank" rel="noopener noreferrer">GPL-3.0</a></span>
|
||||
<span data-lang="zh">SubConverter-Extended )html" +
|
||||
std::string(VERSION) +
|
||||
R"html( · <a href="/version">版本信息</a></span>
|
||||
R"html( · <a href="/version">版本信息</a> · 源代码:<a href="https://github.com/Aethersailor/SubConverter-Extended" target="_blank" rel="noopener noreferrer">GitHub</a> · 许可证:<a href="https://www.gnu.org/licenses/gpl-3.0.html" target="_blank" rel="noopener noreferrer">GPL-3.0</a></span>
|
||||
</footer>
|
||||
</main>
|
||||
|
||||
</div>
|
||||
<script>
|
||||
(function () {
|
||||
var input = document.getElementById("request-input");
|
||||
@@ -1415,7 +1547,7 @@ std::string page(Request &request, Response &response) {
|
||||
}
|
||||
}
|
||||
|
||||
document.querySelector(".lang-toggle").addEventListener("click", function () {
|
||||
document.getElementById("lang-toggle").addEventListener("click", function () {
|
||||
document.documentElement.lang = isZh() ? "en" : "zh-CN";
|
||||
if (lastReport) {
|
||||
renderReport(lastReport);
|
||||
|
||||
@@ -708,6 +708,12 @@ void readYAMLConf(YAML::Node &node) {
|
||||
if (!country_headers.empty())
|
||||
global.statisticsCountryHeaders = country_headers;
|
||||
}
|
||||
if (stats["geo"]["china_region_headers"].IsSequence()) {
|
||||
string_array region_headers;
|
||||
stats["geo"]["china_region_headers"] >> region_headers;
|
||||
if (!region_headers.empty())
|
||||
global.statisticsChinaRegionHeaders = region_headers;
|
||||
}
|
||||
}
|
||||
if (stats["dashboard_auth"].IsDefined()) {
|
||||
YAML::Node auth = stats["dashboard_auth"];
|
||||
@@ -942,6 +948,10 @@ void readTOMLConf(toml::value &root) {
|
||||
section_statistics_geo, "country_headers", string_array{});
|
||||
if (!country_headers.empty())
|
||||
global.statisticsCountryHeaders = country_headers;
|
||||
string_array region_headers = toml::find_or<string_array>(
|
||||
section_statistics_geo, "china_region_headers", string_array{});
|
||||
if (!region_headers.empty())
|
||||
global.statisticsChinaRegionHeaders = region_headers;
|
||||
auto section_dashboard_auth =
|
||||
toml::find_or(section_statistics, "dashboard_auth",
|
||||
toml::value(toml::table()));
|
||||
@@ -978,6 +988,8 @@ void readConf() {
|
||||
global.statisticsCountryHeaders = {"CF-IPCountry", "X-Geo-Country",
|
||||
"X-Vercel-IP-Country",
|
||||
"CloudFront-Viewer-Country"};
|
||||
global.statisticsChinaRegionHeaders = {"CF-Region-Code", "cf-region-code",
|
||||
"X-Geo-Subdivision"};
|
||||
global.dashboardAuthEnabled = false;
|
||||
global.dashboardAuthUsername.clear();
|
||||
global.dashboardAuthPassword.clear();
|
||||
@@ -1273,6 +1285,18 @@ void readConf() {
|
||||
if (!country_headers.empty())
|
||||
global.statisticsCountryHeaders = country_headers;
|
||||
}
|
||||
if (ini.item_exist("china_region_headers")) {
|
||||
string_array region_headers =
|
||||
split(ini.get("china_region_headers"), ",");
|
||||
for (std::string &header : region_headers)
|
||||
header = trimWhitespace(header, true, true);
|
||||
region_headers.erase(
|
||||
std::remove_if(region_headers.begin(), region_headers.end(),
|
||||
[](const std::string &value) { return value.empty(); }),
|
||||
region_headers.end());
|
||||
if (!region_headers.empty())
|
||||
global.statisticsChinaRegionHeaders = region_headers;
|
||||
}
|
||||
ini.get_bool_if_exist("dashboard_auth_enabled",
|
||||
global.dashboardAuthEnabled);
|
||||
ini.get_if_exist("dashboard_auth_username",
|
||||
|
||||
@@ -90,6 +90,8 @@ struct Settings {
|
||||
string_array statisticsCountryHeaders = {
|
||||
"CF-IPCountry", "X-Geo-Country", "X-Vercel-IP-Country",
|
||||
"CloudFront-Viewer-Country"};
|
||||
string_array statisticsChinaRegionHeaders = {
|
||||
"CF-Region-Code", "cf-region-code", "X-Geo-Subdivision"};
|
||||
bool dashboardAuthEnabled = false;
|
||||
std::string dashboardAuthUsername, dashboardAuthPassword;
|
||||
int dashboardAuthMaxFailures = 5, dashboardAuthWindowSeconds = 300,
|
||||
|
||||
@@ -49,12 +49,14 @@ struct Bucket {
|
||||
int64_t minute = 0;
|
||||
Counters counters;
|
||||
std::vector<CountryBucketEntry> countries;
|
||||
std::vector<CountryBucketEntry> china_regions;
|
||||
};
|
||||
|
||||
struct DailyBucket {
|
||||
int64_t day = 0;
|
||||
Counters counters;
|
||||
std::vector<CountryBucketEntry> countries;
|
||||
std::vector<CountryBucketEntry> china_regions;
|
||||
};
|
||||
|
||||
struct SnapshotCountry {
|
||||
@@ -62,6 +64,11 @@ struct SnapshotCountry {
|
||||
CountryCounters counters;
|
||||
};
|
||||
|
||||
struct GeoLocation {
|
||||
std::string country_code;
|
||||
std::string china_region_code;
|
||||
};
|
||||
|
||||
struct State {
|
||||
bool initialized = false;
|
||||
bool dirty = false;
|
||||
@@ -76,6 +83,8 @@ struct State {
|
||||
Counters lifetime;
|
||||
std::map<std::string, CountryCounters> startup_countries;
|
||||
std::map<std::string, CountryCounters> lifetime_countries;
|
||||
std::map<std::string, CountryCounters> startup_china_regions;
|
||||
std::map<std::string, CountryCounters> lifetime_china_regions;
|
||||
std::array<Bucket, kBucketCount> buckets;
|
||||
std::array<DailyBucket, kDailyBucketCount> daily_buckets;
|
||||
};
|
||||
@@ -175,6 +184,30 @@ std::string normalizeCountryCode(std::string value) {
|
||||
return value;
|
||||
}
|
||||
|
||||
bool validChinaRegionSuffix(const std::string &value) {
|
||||
static const std::array<const char *, 35> codes = {
|
||||
"AH", "BJ", "CQ", "FJ", "GD", "GS", "GX", "GZ", "HA", "HB",
|
||||
"HE", "HI", "HK", "HL", "HN", "JL", "JS", "JX", "LN", "MO",
|
||||
"NM", "NX", "QH", "SC", "SD", "SH", "SN", "SX", "TJ", "TW",
|
||||
"XJ", "XZ", "YN", "ZJ", "XX"};
|
||||
return std::any_of(codes.begin(), codes.end(), [&](const char *code) {
|
||||
return value == code;
|
||||
});
|
||||
}
|
||||
|
||||
std::string normalizeChinaRegionCode(std::string value) {
|
||||
value = toUpper(trimWhitespace(value, true, true));
|
||||
for (char &ch : value) {
|
||||
if (ch == '_')
|
||||
ch = '-';
|
||||
}
|
||||
if (value.rfind("CN-", 0) == 0)
|
||||
value = value.substr(3);
|
||||
if (!validChinaRegionSuffix(value))
|
||||
return "";
|
||||
return "CN-" + value;
|
||||
}
|
||||
|
||||
std::string countryFromHeaders(const Request &request) {
|
||||
if (toLower(global.statisticsGeoProvider) == "none")
|
||||
return "ZZ";
|
||||
@@ -190,6 +223,33 @@ std::string countryFromHeaders(const Request &request) {
|
||||
return "ZZ";
|
||||
}
|
||||
|
||||
std::string chinaRegionFromHeaders(const Request &request,
|
||||
const std::string &country) {
|
||||
if (country == "HK" || country == "MO" || country == "TW")
|
||||
return "CN-" + country;
|
||||
if (country != "CN")
|
||||
return "";
|
||||
|
||||
for (const std::string &header : global.statisticsChinaRegionHeaders) {
|
||||
auto iter = request.headers.find(header);
|
||||
if (iter == request.headers.end())
|
||||
continue;
|
||||
std::string code = normalizeChinaRegionCode(iter->second);
|
||||
if (!code.empty())
|
||||
return code;
|
||||
}
|
||||
return "CN-XX";
|
||||
}
|
||||
|
||||
GeoLocation geoLocationFromHeaders(const Request &request) {
|
||||
GeoLocation location;
|
||||
location.country_code = countryFromHeaders(request);
|
||||
if (toLower(global.statisticsGeoProvider) != "none")
|
||||
location.china_region_code =
|
||||
chinaRegionFromHeaders(request, location.country_code);
|
||||
return location;
|
||||
}
|
||||
|
||||
void addCounters(Counters &target, uint64_t requests, uint64_t rules) {
|
||||
target.subscription_requests += requests;
|
||||
target.rule_conversions += rules;
|
||||
@@ -254,6 +314,29 @@ std::vector<SnapshotCountry> countryWindowLocked(int64_t now_minute,
|
||||
return result;
|
||||
}
|
||||
|
||||
std::vector<SnapshotCountry> chinaRegionWindowLocked(int64_t now_minute,
|
||||
int minutes) {
|
||||
std::map<std::string, CountryCounters> totals;
|
||||
if (!g_state)
|
||||
return {};
|
||||
int64_t earliest = now_minute - minutes + 1;
|
||||
for (const Bucket &bucket : g_state->buckets) {
|
||||
if (bucket.minute < earliest || bucket.minute > now_minute)
|
||||
continue;
|
||||
for (const CountryBucketEntry &entry : bucket.china_regions) {
|
||||
addCountryCounters(totals, entry.code,
|
||||
entry.counters.subscription_requests,
|
||||
entry.counters.rule_conversions);
|
||||
}
|
||||
}
|
||||
|
||||
std::vector<SnapshotCountry> result;
|
||||
result.reserve(totals.size());
|
||||
for (const auto &entry : totals)
|
||||
result.push_back({entry.first, entry.second});
|
||||
return result;
|
||||
}
|
||||
|
||||
Counters dailyWindowCountersLocked(int64_t now_day, int days) {
|
||||
Counters result;
|
||||
if (!g_state)
|
||||
@@ -291,6 +374,29 @@ std::vector<SnapshotCountry> countryDailyWindowLocked(int64_t now_day,
|
||||
return result;
|
||||
}
|
||||
|
||||
std::vector<SnapshotCountry> chinaRegionDailyWindowLocked(int64_t now_day,
|
||||
int days) {
|
||||
std::map<std::string, CountryCounters> totals;
|
||||
if (!g_state)
|
||||
return {};
|
||||
int64_t earliest = now_day - days + 1;
|
||||
for (const DailyBucket &bucket : g_state->daily_buckets) {
|
||||
if (bucket.day < earliest || bucket.day > now_day)
|
||||
continue;
|
||||
for (const CountryBucketEntry &entry : bucket.china_regions) {
|
||||
addCountryCounters(totals, entry.code,
|
||||
entry.counters.subscription_requests,
|
||||
entry.counters.rule_conversions);
|
||||
}
|
||||
}
|
||||
|
||||
std::vector<SnapshotCountry> result;
|
||||
result.reserve(totals.size());
|
||||
for (const auto &entry : totals)
|
||||
result.push_back({entry.first, entry.second});
|
||||
return result;
|
||||
}
|
||||
|
||||
std::vector<SnapshotCountry>
|
||||
countrySnapshotLocked(const std::map<std::string, CountryCounters> &source) {
|
||||
std::vector<SnapshotCountry> result;
|
||||
@@ -374,6 +480,7 @@ void seedDailyBucketsFromMinuteBucketsLocked() {
|
||||
g_state->daily_buckets[index].day = day;
|
||||
g_state->daily_buckets[index].counters = Counters();
|
||||
g_state->daily_buckets[index].countries.clear();
|
||||
g_state->daily_buckets[index].china_regions.clear();
|
||||
}
|
||||
addCounters(g_state->daily_buckets[index].counters,
|
||||
bucket.counters.subscription_requests,
|
||||
@@ -383,6 +490,11 @@ void seedDailyBucketsFromMinuteBucketsLocked() {
|
||||
entry.counters.subscription_requests,
|
||||
entry.counters.rule_conversions);
|
||||
}
|
||||
for (const CountryBucketEntry &entry : bucket.china_regions) {
|
||||
addCountryCounters(g_state->daily_buckets[index].china_regions,
|
||||
entry.code, entry.counters.subscription_requests,
|
||||
entry.counters.rule_conversions);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -406,7 +518,7 @@ void loadLocked() {
|
||||
try {
|
||||
json root = json::parse(content);
|
||||
int schema = root.value("schema", 1);
|
||||
if (schema < 1 || schema > 3)
|
||||
if (schema < 1 || schema > 4)
|
||||
return;
|
||||
|
||||
g_state->last_flush = root.value("updated_at", 0LL);
|
||||
@@ -436,6 +548,20 @@ void loadLocked() {
|
||||
g_state->lifetime_countries[code] = counters;
|
||||
}
|
||||
|
||||
auto china_regions = root.value("china_regions", json::object());
|
||||
for (auto iter = china_regions.begin(); iter != china_regions.end();
|
||||
++iter) {
|
||||
std::string code = normalizeChinaRegionCode(iter.key());
|
||||
if (code.empty())
|
||||
continue;
|
||||
CountryCounters counters;
|
||||
counters.subscription_requests =
|
||||
iter.value().value("subscription_requests", 0ULL);
|
||||
counters.rule_conversions = iter.value().value("rule_conversions", 0ULL);
|
||||
if (counters.subscription_requests || counters.rule_conversions)
|
||||
g_state->lifetime_china_regions[code] = counters;
|
||||
}
|
||||
|
||||
auto buckets = root.value("buckets", json::array());
|
||||
for (const auto &item : buckets) {
|
||||
int64_t minute = item.value("minute", 0LL);
|
||||
@@ -448,6 +574,7 @@ void loadLocked() {
|
||||
g_state->buckets[index].counters.rule_conversions =
|
||||
item.value("rule_conversions", 0ULL);
|
||||
g_state->buckets[index].countries.clear();
|
||||
g_state->buckets[index].china_regions.clear();
|
||||
|
||||
auto country_items = item.value("countries", json::array());
|
||||
for (const auto &country_item : country_items) {
|
||||
@@ -460,6 +587,20 @@ void loadLocked() {
|
||||
addCountryCounters(g_state->buckets[index].countries, code, requests,
|
||||
rules);
|
||||
}
|
||||
|
||||
auto region_items = item.value("china_regions", json::array());
|
||||
for (const auto ®ion_item : region_items) {
|
||||
std::string code =
|
||||
normalizeChinaRegionCode(region_item.value("code", ""));
|
||||
if (code.empty())
|
||||
continue;
|
||||
uint64_t requests =
|
||||
region_item.value("subscription_requests", 0ULL);
|
||||
uint64_t rules = region_item.value("rule_conversions", 0ULL);
|
||||
if (requests || rules)
|
||||
addCountryCounters(g_state->buckets[index].china_regions, code,
|
||||
requests, rules);
|
||||
}
|
||||
}
|
||||
|
||||
bool loaded_daily_buckets = false;
|
||||
@@ -475,6 +616,7 @@ void loadLocked() {
|
||||
g_state->daily_buckets[index].counters.rule_conversions =
|
||||
item.value("rule_conversions", 0ULL);
|
||||
g_state->daily_buckets[index].countries.clear();
|
||||
g_state->daily_buckets[index].china_regions.clear();
|
||||
|
||||
auto country_items = item.value("countries", json::array());
|
||||
for (const auto &country_item : country_items) {
|
||||
@@ -487,6 +629,19 @@ void loadLocked() {
|
||||
addCountryCounters(g_state->daily_buckets[index].countries, code,
|
||||
requests, rules);
|
||||
}
|
||||
auto region_items = item.value("china_regions", json::array());
|
||||
for (const auto ®ion_item : region_items) {
|
||||
std::string code =
|
||||
normalizeChinaRegionCode(region_item.value("code", ""));
|
||||
if (code.empty())
|
||||
continue;
|
||||
uint64_t requests =
|
||||
region_item.value("subscription_requests", 0ULL);
|
||||
uint64_t rules = region_item.value("rule_conversions", 0ULL);
|
||||
if (requests || rules)
|
||||
addCountryCounters(g_state->daily_buckets[index].china_regions, code,
|
||||
requests, rules);
|
||||
}
|
||||
if (g_state->daily_buckets[index].counters.subscription_requests ||
|
||||
g_state->daily_buckets[index].counters.rule_conversions)
|
||||
loaded_daily_buckets = true;
|
||||
@@ -513,7 +668,7 @@ bool flushLocked(bool stopping, int64_t now) {
|
||||
g_state->last_stopped_at = now;
|
||||
|
||||
json root;
|
||||
root["schema"] = 3;
|
||||
root["schema"] = 4;
|
||||
root["updated_at"] = now;
|
||||
root["runtime"] = {
|
||||
{"first_started_at", g_state->first_started_at},
|
||||
@@ -525,6 +680,8 @@ bool flushLocked(bool stopping, int64_t now) {
|
||||
{"last_stopped_at", g_state->last_stopped_at}};
|
||||
root["lifetime"] = countersJson(g_state->lifetime);
|
||||
root["countries"] = countriesObjectJson(g_state->lifetime_countries);
|
||||
root["china_regions"] =
|
||||
countriesObjectJson(g_state->lifetime_china_regions);
|
||||
|
||||
json buckets = json::array();
|
||||
for (const Bucket &bucket : g_state->buckets) {
|
||||
@@ -544,12 +701,24 @@ bool flushLocked(bool stopping, int64_t now) {
|
||||
{"rule_conversions",
|
||||
entry.counters.rule_conversions}});
|
||||
}
|
||||
json china_regions = json::array();
|
||||
for (const CountryBucketEntry &entry : bucket.china_regions) {
|
||||
if (!entry.counters.subscription_requests &&
|
||||
!entry.counters.rule_conversions)
|
||||
continue;
|
||||
china_regions.push_back({{"code", entry.code},
|
||||
{"subscription_requests",
|
||||
entry.counters.subscription_requests},
|
||||
{"rule_conversions",
|
||||
entry.counters.rule_conversions}});
|
||||
}
|
||||
buckets.push_back({{"minute", bucket.minute},
|
||||
{"subscription_requests",
|
||||
bucket.counters.subscription_requests},
|
||||
{"rule_conversions",
|
||||
bucket.counters.rule_conversions},
|
||||
{"countries", countries}});
|
||||
{"countries", countries},
|
||||
{"china_regions", china_regions}});
|
||||
}
|
||||
root["buckets"] = buckets;
|
||||
|
||||
@@ -571,12 +740,24 @@ bool flushLocked(bool stopping, int64_t now) {
|
||||
{"rule_conversions",
|
||||
entry.counters.rule_conversions}});
|
||||
}
|
||||
json china_regions = json::array();
|
||||
for (const CountryBucketEntry &entry : bucket.china_regions) {
|
||||
if (!entry.counters.subscription_requests &&
|
||||
!entry.counters.rule_conversions)
|
||||
continue;
|
||||
china_regions.push_back({{"code", entry.code},
|
||||
{"subscription_requests",
|
||||
entry.counters.subscription_requests},
|
||||
{"rule_conversions",
|
||||
entry.counters.rule_conversions}});
|
||||
}
|
||||
daily_buckets.push_back({{"day", bucket.day},
|
||||
{"subscription_requests",
|
||||
bucket.counters.subscription_requests},
|
||||
{"rule_conversions",
|
||||
bucket.counters.rule_conversions},
|
||||
{"countries", countries}});
|
||||
{"countries", countries},
|
||||
{"china_regions", china_regions}});
|
||||
}
|
||||
root["daily_buckets"] = daily_buckets;
|
||||
|
||||
@@ -662,7 +843,7 @@ void recordSubscriptionConversion(const Request &request,
|
||||
int64_t now = nowSeconds();
|
||||
int64_t minute = now / 60;
|
||||
int64_t day = now / (24 * 60 * 60);
|
||||
std::string country = countryFromHeaders(request);
|
||||
GeoLocation location = geoLocationFromHeaders(request);
|
||||
|
||||
std::lock_guard<std::mutex> lock(g_mutex);
|
||||
if (!g_state || !g_state->initialized)
|
||||
@@ -670,30 +851,45 @@ void recordSubscriptionConversion(const Request &request,
|
||||
|
||||
addCounters(g_state->startup, 1, rule_conversions);
|
||||
addCounters(g_state->lifetime, 1, rule_conversions);
|
||||
addCountryCounters(g_state->startup_countries, country, 1, rule_conversions);
|
||||
addCountryCounters(g_state->lifetime_countries, country, 1,
|
||||
addCountryCounters(g_state->startup_countries, location.country_code, 1,
|
||||
rule_conversions);
|
||||
addCountryCounters(g_state->lifetime_countries, location.country_code, 1,
|
||||
rule_conversions);
|
||||
if (!location.china_region_code.empty()) {
|
||||
addCountryCounters(g_state->startup_china_regions,
|
||||
location.china_region_code, 1, rule_conversions);
|
||||
addCountryCounters(g_state->lifetime_china_regions,
|
||||
location.china_region_code, 1, rule_conversions);
|
||||
}
|
||||
|
||||
size_t index = static_cast<size_t>(minute % kBucketCount);
|
||||
if (g_state->buckets[index].minute != minute) {
|
||||
g_state->buckets[index].minute = minute;
|
||||
g_state->buckets[index].counters = Counters();
|
||||
g_state->buckets[index].countries.clear();
|
||||
g_state->buckets[index].china_regions.clear();
|
||||
}
|
||||
addCounters(g_state->buckets[index].counters, 1, rule_conversions);
|
||||
addCountryCounters(g_state->buckets[index].countries, country, 1,
|
||||
rule_conversions);
|
||||
addCountryCounters(g_state->buckets[index].countries, location.country_code,
|
||||
1, rule_conversions);
|
||||
if (!location.china_region_code.empty())
|
||||
addCountryCounters(g_state->buckets[index].china_regions,
|
||||
location.china_region_code, 1, rule_conversions);
|
||||
|
||||
size_t daily_index = static_cast<size_t>(day % kDailyBucketCount);
|
||||
if (g_state->daily_buckets[daily_index].day != day) {
|
||||
g_state->daily_buckets[daily_index].day = day;
|
||||
g_state->daily_buckets[daily_index].counters = Counters();
|
||||
g_state->daily_buckets[daily_index].countries.clear();
|
||||
g_state->daily_buckets[daily_index].china_regions.clear();
|
||||
}
|
||||
addCounters(g_state->daily_buckets[daily_index].counters, 1,
|
||||
rule_conversions);
|
||||
addCountryCounters(g_state->daily_buckets[daily_index].countries, country, 1,
|
||||
rule_conversions);
|
||||
addCountryCounters(g_state->daily_buckets[daily_index].countries,
|
||||
location.country_code, 1, rule_conversions);
|
||||
if (!location.china_region_code.empty())
|
||||
addCountryCounters(g_state->daily_buckets[daily_index].china_regions,
|
||||
location.china_region_code, 1, rule_conversions);
|
||||
|
||||
g_state->dirty = true;
|
||||
}
|
||||
@@ -758,6 +954,30 @@ std::string dashboardData(RESPONSE_CALLBACK_ARGS) {
|
||||
root["country_windows"] = country_windows;
|
||||
root["countries"] = country_windows["lifetime"];
|
||||
|
||||
json china_region_windows = json::object();
|
||||
china_region_windows["startup"] =
|
||||
countriesJson(g_state
|
||||
? countrySnapshotLocked(g_state->startup_china_regions)
|
||||
: std::vector<SnapshotCountry>());
|
||||
china_region_windows["hour"] =
|
||||
countriesJson(chinaRegionWindowLocked(now_minute, 60));
|
||||
china_region_windows["day"] =
|
||||
countriesJson(chinaRegionWindowLocked(now_minute, 24 * 60));
|
||||
china_region_windows["seven_days"] =
|
||||
countriesJson(chinaRegionWindowLocked(now_minute, 7 * 24 * 60));
|
||||
china_region_windows["thirty_days"] =
|
||||
countriesJson(chinaRegionWindowLocked(now_minute, 30 * 24 * 60));
|
||||
china_region_windows["half_year"] =
|
||||
countriesJson(chinaRegionDailyWindowLocked(now_day, 183));
|
||||
china_region_windows["year"] =
|
||||
countriesJson(chinaRegionDailyWindowLocked(now_day, 365));
|
||||
china_region_windows["lifetime"] =
|
||||
countriesJson(g_state ? countrySnapshotLocked(
|
||||
g_state->lifetime_china_regions)
|
||||
: std::vector<SnapshotCountry>());
|
||||
root["china_region_windows"] = china_region_windows;
|
||||
root["china_regions"] = china_region_windows["lifetime"];
|
||||
|
||||
json series = json::array();
|
||||
std::vector<Counters> hourly = hourlySeriesLocked(now_minute, 24);
|
||||
int64_t current_hour = now_minute / 60;
|
||||
|
||||
@@ -2,6 +2,8 @@
|
||||
|
||||
#include <string>
|
||||
|
||||
#include "handler/settings.h"
|
||||
#include "utils/string.h"
|
||||
#include "version.h"
|
||||
|
||||
namespace {
|
||||
@@ -87,6 +89,32 @@ std::string buildCommitLink(const std::string &build_id) {
|
||||
build_id + "</a>";
|
||||
}
|
||||
|
||||
std::string headerValue(const Request &request, const std::string &name) {
|
||||
auto iter = request.headers.find(name);
|
||||
if (iter == request.headers.end())
|
||||
return "";
|
||||
return trimWhitespace(iter->second, true, true);
|
||||
}
|
||||
|
||||
bool isScriptVersionProbe(const Request &request) {
|
||||
std::string fetch_mode = toLower(headerValue(request, "Sec-Fetch-Mode"));
|
||||
std::string fetch_dest = toLower(headerValue(request, "Sec-Fetch-Dest"));
|
||||
|
||||
if (fetch_mode == "cors" && fetch_dest == "empty")
|
||||
return true;
|
||||
|
||||
return fetch_mode.empty() && fetch_dest.empty() &&
|
||||
!headerValue(request, "Origin").empty();
|
||||
}
|
||||
|
||||
std::string buildPlainVersion() {
|
||||
std::string version = VERSION;
|
||||
std::string build_id = BUILD_ID;
|
||||
if (!build_id.empty())
|
||||
version += "-" + build_id;
|
||||
return "SubConverter-Extended " + version + " backend\n";
|
||||
}
|
||||
|
||||
} // namespace
|
||||
|
||||
namespace version_page {
|
||||
@@ -101,9 +129,16 @@ std::string faviconLight(Request &, Response &response) {
|
||||
return VERSION_FAVICON_LIGHT;
|
||||
}
|
||||
|
||||
std::string page(Request &, Response &response) {
|
||||
std::string page(Request &request, Response &response) {
|
||||
response.headers["X-Robots-Tag"] =
|
||||
"noindex, nofollow, noarchive, nosnippet, noimageindex";
|
||||
response.headers["Vary"] = "Sec-Fetch-Mode, Sec-Fetch-Dest, Origin";
|
||||
if (isScriptVersionProbe(request)) {
|
||||
response.content_type = "text/plain; charset=utf-8";
|
||||
response.headers["Cache-Control"] = "no-store";
|
||||
return buildPlainVersion();
|
||||
}
|
||||
|
||||
std::string build_id = BUILD_ID;
|
||||
std::string build_date = BUILD_DATE;
|
||||
std::string build_date_display = formatBuildDate(build_date);
|
||||
@@ -112,6 +147,21 @@ std::string page(Request &, Response &response) {
|
||||
? R"html(<span data-lang="en">unknown</span><span data-lang="zh">未知</span>)html"
|
||||
: build_date_display;
|
||||
std::string commit_link = buildCommitLink(build_id);
|
||||
std::string dashboard_link =
|
||||
global.statisticsEnabled
|
||||
? R"html(
|
||||
<a class="page-link" href="/dashboard" aria-label="Open dashboard">
|
||||
<svg viewBox="0 0 24 24" aria-hidden="true">
|
||||
<path d="M4 19V5"></path>
|
||||
<path d="M4 19h16"></path>
|
||||
<path d="M8 16v-5"></path>
|
||||
<path d="M13 16V8"></path>
|
||||
<path d="M18 16v-3"></path>
|
||||
</svg>
|
||||
<span data-lang="en">Dashboard</span>
|
||||
<span data-lang="zh">仪表盘</span>
|
||||
</a>)html"
|
||||
: "";
|
||||
|
||||
return R"html(<!DOCTYPE html>
|
||||
<html lang="en">
|
||||
@@ -220,14 +270,11 @@ std::string page(Request &, Response &response) {
|
||||
|
||||
body {
|
||||
font-family: 'Outfit', system-ui, -apple-system, BlinkMacSystemFont, "Segoe UI", "Microsoft YaHei", "PingFang SC", "Noto Sans CJK SC", sans-serif;
|
||||
margin: 0;
|
||||
min-height: 100vh;
|
||||
min-height: 100svh;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
background: var(--bg-gradient);
|
||||
background-attachment: fixed;
|
||||
padding: 24px;
|
||||
color: var(--text-primary);
|
||||
-webkit-font-smoothing: antialiased;
|
||||
-moz-osx-font-smoothing: grayscale;
|
||||
@@ -258,51 +305,133 @@ std::string page(Request &, Response &response) {
|
||||
opacity: 0.82;
|
||||
}
|
||||
|
||||
.lang-toggle {
|
||||
position: fixed;
|
||||
top: calc(18px + env(safe-area-inset-top, 0px));
|
||||
right: calc(18px + env(safe-area-inset-right, 0px));
|
||||
z-index: 10;
|
||||
display: inline-flex;
|
||||
.shell {
|
||||
position: relative;
|
||||
z-index: 1;
|
||||
width: min(1180px, calc(100% - 32px));
|
||||
margin: 0 auto;
|
||||
padding: 28px 0 42px;
|
||||
}
|
||||
.topbar {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
justify-content: space-between;
|
||||
gap: 16px;
|
||||
margin-bottom: 24px;
|
||||
}
|
||||
.brand-row {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 14px;
|
||||
min-width: 0;
|
||||
}
|
||||
.brand-row img {
|
||||
width: 48px;
|
||||
height: 48px;
|
||||
flex: 0 0 auto;
|
||||
filter: drop-shadow(0 12px 24px rgba(2, 132, 199, 0.16));
|
||||
}
|
||||
.brand-row h1 {
|
||||
margin: 0;
|
||||
font-size: 1.8rem;
|
||||
line-height: 1.08;
|
||||
letter-spacing: 0;
|
||||
overflow-wrap: anywhere;
|
||||
background: none;
|
||||
-webkit-background-clip: unset;
|
||||
background-clip: unset;
|
||||
-webkit-text-fill-color: unset;
|
||||
}
|
||||
.brand-row .top-subtitle {
|
||||
margin-top: 5px;
|
||||
color: var(--text-secondary);
|
||||
font-size: 0.94rem;
|
||||
font-weight: 600;
|
||||
}
|
||||
.actions {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 10px;
|
||||
flex-wrap: wrap;
|
||||
justify-content: flex-end;
|
||||
}
|
||||
.lang-btn {
|
||||
border: 1px solid var(--control-border);
|
||||
border-radius: 999px;
|
||||
background: var(--control-bg);
|
||||
backdrop-filter: blur(18px);
|
||||
-webkit-backdrop-filter: blur(18px);
|
||||
box-shadow: var(--control-shadow);
|
||||
color: var(--text-primary);
|
||||
cursor: pointer;
|
||||
font: inherit;
|
||||
font-size: 0.86rem;
|
||||
font-size: 0.88rem;
|
||||
font-weight: 700;
|
||||
line-height: 1;
|
||||
min-height: 40px;
|
||||
min-width: 76px;
|
||||
padding: 9px 13px;
|
||||
transition: background 0.2s ease, border-color 0.2s ease, box-shadow 0.2s ease, transform 0.2s ease;
|
||||
backdrop-filter: blur(18px);
|
||||
-webkit-backdrop-filter: blur(18px);
|
||||
transition: background 0.2s ease, border-color 0.2s ease, transform 0.2s ease;
|
||||
}
|
||||
|
||||
.lang-toggle:hover {
|
||||
.lang-btn:hover {
|
||||
background: var(--control-hover);
|
||||
transform: translateY(-1px);
|
||||
}
|
||||
|
||||
.lang-toggle:focus-visible {
|
||||
.lang-btn:focus-visible {
|
||||
outline: 3px solid rgba(99, 179, 237, 0.35);
|
||||
outline-offset: 2px;
|
||||
}
|
||||
|
||||
.lang-toggle svg {
|
||||
.page-links {
|
||||
display: inline-flex;
|
||||
justify-content: center;
|
||||
flex-wrap: wrap;
|
||||
gap: 10px;
|
||||
margin-top: 18px;
|
||||
}
|
||||
|
||||
.page-link {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
gap: 8px;
|
||||
min-height: 40px;
|
||||
padding: 9px 14px;
|
||||
border: 1px solid var(--control-border);
|
||||
border-radius: 999px;
|
||||
background: var(--control-bg);
|
||||
box-shadow: var(--control-shadow);
|
||||
color: var(--text-primary);
|
||||
font-size: 0.86rem;
|
||||
font-weight: 700;
|
||||
line-height: 1;
|
||||
text-decoration: none;
|
||||
transition: background 0.2s ease, border-color 0.2s ease, box-shadow 0.2s ease, transform 0.2s ease;
|
||||
}
|
||||
|
||||
.page-link:hover {
|
||||
background: var(--control-hover);
|
||||
color: var(--text-primary);
|
||||
transform: translateY(-1px);
|
||||
}
|
||||
|
||||
.page-link::after {
|
||||
display: none;
|
||||
}
|
||||
|
||||
.page-link:focus-visible {
|
||||
outline: 3px solid rgba(99, 179, 237, 0.35);
|
||||
outline-offset: 2px;
|
||||
}
|
||||
|
||||
.page-link svg {
|
||||
width: 17px;
|
||||
height: 17px;
|
||||
flex: 0 0 auto;
|
||||
}
|
||||
|
||||
.lang-toggle-text {
|
||||
min-width: 20px;
|
||||
text-align: center;
|
||||
fill: none;
|
||||
stroke: currentColor;
|
||||
stroke-width: 1.9;
|
||||
stroke-linecap: round;
|
||||
stroke-linejoin: round;
|
||||
}
|
||||
|
||||
.container {
|
||||
@@ -637,12 +766,12 @@ std::string page(Request &, Response &response) {
|
||||
.container::after {
|
||||
border-radius: 26px;
|
||||
}
|
||||
.lang-toggle {
|
||||
top: calc(14px + env(safe-area-inset-top, 0px));
|
||||
right: calc(14px + env(safe-area-inset-right, 0px));
|
||||
min-height: 38px;
|
||||
min-width: 70px;
|
||||
.shell {
|
||||
padding: 16px 0 24px;
|
||||
width: min(100% - 20px, 1180px);
|
||||
}
|
||||
.topbar { flex-direction: column; align-items: flex-start; }
|
||||
.brand-row h1 { font-size: 1.4rem; }
|
||||
header { margin-bottom: 24px; }
|
||||
h1 {
|
||||
font-size: 2em;
|
||||
@@ -664,6 +793,15 @@ std::string page(Request &, Response &response) {
|
||||
font-size: 0.72rem;
|
||||
margin-bottom: 12px;
|
||||
}
|
||||
.page-links {
|
||||
margin-top: 16px;
|
||||
gap: 8px;
|
||||
}
|
||||
.page-link {
|
||||
min-height: 38px;
|
||||
padding: 8px 12px;
|
||||
font-size: 0.8rem;
|
||||
}
|
||||
.info-grid { grid-template-columns: 1fr; gap: 12px; }
|
||||
.info-card {
|
||||
min-height: auto;
|
||||
@@ -684,17 +822,22 @@ std::string page(Request &, Response &response) {
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
<button type="button" class="lang-toggle" id="lang-toggle">
|
||||
<svg viewBox="0 0 24 24" aria-hidden="true" fill="none" stroke="currentColor" stroke-width="1.9" stroke-linecap="round" stroke-linejoin="round">
|
||||
<circle cx="12" cy="12" r="10"></circle>
|
||||
<path d="M2 12h20"></path>
|
||||
<path d="M12 2a15.3 15.3 0 0 1 0 20"></path>
|
||||
<path d="M12 2a15.3 15.3 0 0 0 0 20"></path>
|
||||
</svg>
|
||||
<span class="lang-toggle-text">中</span>
|
||||
</button>
|
||||
<div class="container">
|
||||
<header>
|
||||
<main class="shell">
|
||||
<div class="topbar">
|
||||
<div class="brand-row">
|
||||
<picture>
|
||||
<source media="(prefers-color-scheme: dark)" srcset="/version/favicon-dark.svg">
|
||||
<img src="/version/favicon-light.svg" alt="SubConverter-Extended" width="48" height="48" decoding="async">
|
||||
</picture>
|
||||
<div>
|
||||
<h1>SubConverter-Extended</h1>
|
||||
<div class="top-subtitle">
|
||||
<span data-lang="en">A Modern Evolution of Subconverter</span>
|
||||
<span data-lang="zh">Subconverter 的现代化演进版本</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="actions">
|
||||
<picture class="brand-mark">
|
||||
<source media="(prefers-color-scheme: dark)" srcset="/version/favicon-dark.svg">
|
||||
<img src="/version/favicon-light.svg" alt="SubConverter-Extended icon" width="96" height="96" decoding="async">
|
||||
@@ -709,7 +852,25 @@ std::string page(Request &, Response &response) {
|
||||
<span data-lang="en">A Modern Evolution of Subconverter</span>
|
||||
<span data-lang="zh">Subconverter 的现代化演进版本</span>
|
||||
</p>
|
||||
</header>
|
||||
<nav class="page-links" aria-label="Page navigation">
|
||||
<a class="page-link" href="/inspect" aria-label="Open inspector">
|
||||
<svg viewBox="0 0 24 24" aria-hidden="true">
|
||||
<circle cx="11" cy="11" r="6"></circle>
|
||||
<path d="m16 16 4 4"></path>
|
||||
<path d="M8.5 11h5"></path>
|
||||
<path d="M11 8.5v5"></path>
|
||||
</svg>
|
||||
<span data-lang="en">Inspector</span>
|
||||
<span data-lang="zh">诊断台</span>
|
||||
</a>)html" +
|
||||
dashboard_link + R"html(
|
||||
<button type="button" class="lang-btn" id="lang-toggle" aria-label="Switch language">
|
||||
<span class="lang-toggle-text">中</span>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
<div class="container">
|
||||
<header>
|
||||
|
||||
<div class="info-grid">
|
||||
<div class="info-card">
|
||||
@@ -788,6 +949,7 @@ std::string page(Request &, Response &response) {
|
||||
<span data-lang="zh">源代码:<a href="https://github.com/Aethersailor/SubConverter-Extended" target="_blank" rel="noopener noreferrer">GitHub</a> • 许可证:<a href="https://www.gnu.org/licenses/gpl-3.0.html" target="_blank" rel="noopener noreferrer">GPL-3.0</a></span>
|
||||
</div>
|
||||
</div>
|
||||
</main>
|
||||
<script>
|
||||
(function () {
|
||||
var toggle = document.getElementById("lang-toggle");
|
||||
|
||||
1046
src/handler/webapp_page.cpp
Normal file
1046
src/handler/webapp_page.cpp
Normal file
File diff suppressed because it is too large
Load Diff
14
src/handler/webapp_page.h
Normal file
14
src/handler/webapp_page.h
Normal file
@@ -0,0 +1,14 @@
|
||||
#ifndef WEBAPP_PAGE_H_INCLUDED
|
||||
#define WEBAPP_PAGE_H_INCLUDED
|
||||
|
||||
#include <string>
|
||||
|
||||
#include "server/webserver.h"
|
||||
|
||||
namespace webapp_page {
|
||||
|
||||
std::string page(Request &, Response &response);
|
||||
|
||||
} // namespace webapp_page
|
||||
|
||||
#endif // WEBAPP_PAGE_H_INCLUDED
|
||||
10
src/main.cpp
10
src/main.cpp
@@ -15,6 +15,7 @@
|
||||
#include "handler/statistics.h"
|
||||
#include "handler/version_page.h"
|
||||
#include "handler/webget.h"
|
||||
#include "handler/webapp_page.h"
|
||||
#include "script/cron.h"
|
||||
#include "server/socket.h"
|
||||
#include "server/webserver.h"
|
||||
@@ -184,13 +185,8 @@ int main(int argc, char *argv[]) {
|
||||
if (global.generatorMode)
|
||||
return simpleGenerator();
|
||||
|
||||
/*
|
||||
webServer.append_response("GET", "/", "text/plain", [](RESPONSE_CALLBACK_ARGS)
|
||||
-> std::string
|
||||
{
|
||||
return "SubConverter-Extended " VERSION " backend\n";
|
||||
});
|
||||
*/
|
||||
webServer.append_response("GET", "/", "text/html; charset=utf-8",
|
||||
webapp_page::page);
|
||||
|
||||
webServer.append_response("GET", "/version/favicon-dark.svg",
|
||||
"image/svg+xml; charset=utf-8",
|
||||
|
||||
@@ -101,6 +101,8 @@ const std::map<std::string, std::map<std::string, ParamCompatInfo>> PARAM_COMPAT
|
||||
{"mptcp", {true, "bool", false}}, // BasicOption
|
||||
{"name", {true, "string", false}}, // hy2
|
||||
{"obfs", {true, "string", false}}, // hy2
|
||||
{"obfs-max-packet-size", {true, "int", false}}, // hy2
|
||||
{"obfs-min-packet-size", {true, "int", false}}, // hy2
|
||||
{"obfs-password", {true, "string", false}}, // hy2
|
||||
{"password", {true, "string", false}}, // hy2
|
||||
{"port", {true, "int", false}}, // hy2
|
||||
@@ -169,6 +171,8 @@ const std::map<std::string, std::map<std::string, ParamCompatInfo>> PARAM_COMPAT
|
||||
{"mptcp", {true, "bool", false}}, // BasicOption
|
||||
{"name", {true, "string", false}}, // hysteria2
|
||||
{"obfs", {true, "string", false}}, // hysteria2
|
||||
{"obfs-max-packet-size", {true, "int", false}}, // hysteria2
|
||||
{"obfs-min-packet-size", {true, "int", false}}, // hysteria2
|
||||
{"obfs-password", {true, "string", false}}, // hysteria2
|
||||
{"password", {true, "string", false}}, // hysteria2
|
||||
{"port", {true, "int", true}}, // hysteria2 [HARDCODED]
|
||||
|
||||
Reference in New Issue
Block a user