43 Commits

Author SHA1 Message Date
sakuradairong
169a60690d feat: add consistent top navigation bar to all pages
Some checks failed
Watch Mihomo Meta / watch (push) Has been cancelled
Sync Upstream Parser / sync (push) Has been cancelled
Build and Push Docker image / 🔧 Prepare Metadata (push) Has been cancelled
Build and Push Docker image / 🐳 Docker Build (${{ matrix.arch }}) (push) Has been cancelled
Build and Push Docker image / 🪟 Windows Build (amd64) (push) Has been cancelled
Build and Push Docker image / 🔗 Docker Manifest (push) Has been cancelled
Build and Push Docker image / 🚀 Release Artifacts (push) Has been cancelled
- Add topbar with brand icon, nav links (Version/Inspector/Dashboard),
  and language toggle to version page and inspect page
- Change both pages from centered-container layout to shell+topbar layout
- Update JavaScript lang toggle selector for consistency
- WebApp page: add Version/Inspect nav links and more quick-action buttons
2026-06-16 10:34:33 +08:00
sakuradairong
819c29b843 feat(webapp): add modern WebApp UI with tabs, presets and history
- Add tabbed interface (Basic/Advanced/History)
- Add quick action buttons for common formats
- Add preset system (Clash/Surge/QuanX/Sing-box/Node list)
- Add form persistence with localStorage
- Add conversion history with restore functionality
- Add URL format validation
- Add result statistics (size/lines/time)
- Add toast notifications
- Add light/dark theme support
- Add i18n (Chinese/English auto-detect)
- Add responsive mobile layout
- Update CMakeLists.txt and main.cpp routing
2026-06-15 15:33:41 +08:00
github-actions[bot]
f1f875a325 chore: sync dev to master
Some checks failed
Build and Push Docker image / 🔧 Prepare Metadata (push) Has been cancelled
Build and Push Docker image / 🐳 Docker Build (${{ matrix.arch }}) (push) Has been cancelled
Build and Push Docker image / 🪟 Windows Build (amd64) (push) Has been cancelled
Build and Push Docker image / 🔗 Docker Manifest (push) Has been cancelled
Build and Push Docker image / 🚀 Release Artifacts (push) Has been cancelled
2026-06-06 05:22:11 +00:00
github-actions[bot]
8175fa7fae chore: update auto-generated files and header libraries from build [skip ci] 2026-06-06 05:15:34 +00:00
Aethersailor
60bbadde5b fix(version): support lightweight backend probes 2026-06-06 13:11:48 +08:00
dependabot[bot]
a09aadb40f chore(deps): bump github.com/metacubex/mihomo from 1.19.25 to 1.19.26 in /bridge in the go-dependencies group (#73)
Bumps the go-dependencies group in /bridge with 1 update: [github.com/metacubex/mihomo](https://github.com/metacubex/mihomo).


Updates `github.com/metacubex/mihomo` from 1.19.25 to 1.19.26
- [Release notes](https://github.com/metacubex/mihomo/releases)
- [Commits](https://github.com/metacubex/mihomo/compare/v1.19.25...v1.19.26)

---
updated-dependencies:
- dependency-name: github.com/metacubex/mihomo
  dependency-version: 1.19.26
  dependency-type: direct:production
  update-type: version-update:semver-patch
  dependency-group: go-dependencies
...

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2026-06-03 16:53:39 +00:00
Aethersailor
a178233898 docs(readme): refine dashboard overview 2026-06-02 10:05:34 +08:00
Aethersailor
f752e7fe47 docs(readme): polish folded usage sections 2026-06-02 10:00:01 +08:00
Aethersailor
52afa7d3e6 docs(readme): add diagnostics and dashboard docs 2026-06-02 09:53:08 +08:00
github-actions[bot]
95867d1a82 chore: sync dev to master
Some checks failed
Build and Push Docker image / 🔧 Prepare Metadata (push) Has been cancelled
Build and Push Docker image / 🐳 Docker Build (${{ matrix.arch }}) (push) Has been cancelled
Build and Push Docker image / 🪟 Windows Build (amd64) (push) Has been cancelled
Build and Push Docker image / 🔗 Docker Manifest (push) Has been cancelled
Build and Push Docker image / 🚀 Release Artifacts (push) Has been cancelled
2026-06-02 01:37:24 +00:00
Aethersailor
12556b3d48 fix(dashboard): simplify map tooltips 2026-06-02 09:23:30 +08:00
Aethersailor
9694d199d5 feat(ui): add source and license footer links 2026-06-02 09:04:37 +08:00
Aethersailor
71c295474a fix(dashboard): improve contrast for map and metrics 2026-06-02 09:04:19 +08:00
Aethersailor
daa4d71cc9 fix(dashboard): lower China map body 2026-06-02 08:47:00 +08:00
Aethersailor
fe784dc01a fix(dashboard): show data before map assets load 2026-06-02 08:38:30 +08:00
Aethersailor
9c79951e0d fix(dashboard): center China map inset 2026-06-02 08:38:18 +08:00
Aethersailor
00cedaad11 fix(dashboard): make China inset hoverable 2026-06-02 01:11:51 +08:00
Aethersailor
a827a26733 fix(dashboard): separate China south sea inset 2026-06-02 01:00:40 +08:00
Aethersailor
76e38c3004 fix(dashboard): rewind China map geometry 2026-06-02 00:47:58 +08:00
github-actions[bot]
9cb1d12901 chore: update auto-generated files and header libraries from build [skip ci] 2026-06-01 16:31:13 +00:00
Aethersailor
17dab506d3 fix(dashboard): stabilize map tooltip overlay 2026-06-02 00:26:47 +08:00
Aethersailor
1fb8aff952 feat(dashboard): split geolocation rankings by metric 2026-06-02 00:26:09 +08:00
Aethersailor
c04bc468b4 feat(statistics): track China regional counters 2026-06-02 00:20:18 +08:00
Aethersailor
0001c58876 feat(dashboard): split country maps by conversion type 2026-06-01 23:47:23 +08:00
Aethersailor
4f1d7526f1 fix(dashboard): keep map tooltip above content 2026-06-01 23:40:09 +08:00
Aethersailor
84d5a7ec19 fix(dashboard): label CN as mainland China 2026-06-01 23:36:07 +08:00
github-actions[bot]
a7b70e4cb2 chore: sync dev to master
Some checks failed
Build and Push Docker image / 🐳 Docker Build (${{ matrix.arch }}) (push) Has been cancelled
Build and Push Docker image / 🔧 Prepare Metadata (push) Has been cancelled
Build and Push Docker image / 🪟 Windows Build (amd64) (push) Has been cancelled
Build and Push Docker image / 🔗 Docker Manifest (push) Has been cancelled
Build and Push Docker image / 🚀 Release Artifacts (push) Has been cancelled
2026-06-01 12:49:37 +00:00
Aethersailor
79248500cb feat(ui): add navigation between service pages 2026-06-01 19:53:40 +08:00
github-actions[bot]
de1f1b0fe4 chore: sync dev to master 2026-06-01 11:24:20 +00:00
Aethersailor
d48175430b fix(package): avoid armv7 runtime root lib conflict 2026-06-01 19:16:18 +08:00
github-actions[bot]
9ad191ed39 chore: sync dev to master 2026-06-01 11:05:25 +00:00
Aethersailor
b9e8dea1b0 build(package): trim bundled runtime libraries 2026-06-01 18:56:10 +08:00
Aethersailor
f54dc6494d ci(release): dispatch master build for sync-only 2026-06-01 18:47:56 +08:00
Aethersailor
9b65b07710 ci(release): dispatch master build for sync-only 2026-06-01 18:47:35 +08:00
github-actions[bot]
36ab458cb6 chore(mihomo): update Meta revision 2026-05-31 19:10:51 +00:00
github-actions[bot]
6f95939dd3 chore: update auto-generated files and header libraries from build [skip ci] 2026-05-31 17:42:40 +00:00
Aethersailor
20711b1513 fix(provider): honor GROUPID filters in provider mode https://github.com/Aethersailor/SubConverter-Extended/issues/69 2026-06-01 01:38:54 +08:00
github-actions[bot]
bd792cbdd7 chore: update auto-generated files and header libraries from build [skip ci] 2026-05-30 15:07:18 +00:00
Aethersailor
a8ba8108b0 build(bridge): include preprocess source in builds 2026-05-30 23:03:43 +08:00
Aethersailor
2e32922701 fix(bridge): normalize legacy vmess links 2026-05-30 23:00:47 +08:00
github-actions[bot]
2a308a722c chore: sync dev to master 2026-05-27 13:25:04 +00:00
Aethersailor
6554bf6288 ci(release): add overwrite release rebuild mode 2026-05-27 21:24:10 +08:00
Aethersailor
8cbc63cf2d fix(dashboard): refine region labels and chart refresh 2026-05-27 20:39:33 +08:00
36 changed files with 4165 additions and 452 deletions

View File

@@ -1 +1 @@
5e22035118d13fa609164670111cc674906bb2a4
fc8c5a24b16991f98cd736950c17d1aa306a5041

View File

@@ -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

View File

@@ -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."

View File

@@ -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

View File

@@ -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
View File

@@ -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>
---
## 🛠️ 配置说明

View File

@@ -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.

View File

@@ -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
View 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

View File

@@ -20,7 +20,7 @@ go build \
-buildmode=c-archive \
-ldflags="-s -w" \
-o libmihomo.a \
converter.go
.
echo "==> Build完成"
echo "Generated files:"

View File

@@ -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

View File

@@ -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

View File

@@ -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=

View File

@@ -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
View 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
View 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)
}
}

View File

@@ -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

View File

@@ -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 \

View File

@@ -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
View 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

View 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

View File

@@ -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

View File

@@ -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

View File

@@ -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

View File

@@ -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;

View File

@@ -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

View File

@@ -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);

View File

@@ -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",

View File

@@ -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,

View File

@@ -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 &region_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 &region_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;

View File

@@ -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

File diff suppressed because it is too large Load Diff

14
src/handler/webapp_page.h Normal file
View 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

View File

@@ -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",

View File

@@ -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]