Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion README-CN.md
Original file line number Diff line number Diff line change
Expand Up @@ -4,7 +4,7 @@
<h1>SubBoost</h1>
<p>
<img src="https://img.shields.io/badge/platform-Linux%20%2B%20Docker-lightgrey.svg" alt="平台:Linux + Docker">
<img src="https://img.shields.io/badge/version-2.5.1-green.svg" alt="版本 2.5.1">
<img src="https://img.shields.io/badge/version-2.6.0-green.svg" alt="版本 2.6.0">
<a href="https://subboost.org"><img src="https://img.shields.io/badge/app-subboost.org-brightgreen.svg" alt="在线入口"></a>
<a href="https://docs.subboost.org"><img src="https://img.shields.io/badge/docs-subboost.org-blue.svg" alt="文档"></a>
<img src="https://img.shields.io/badge/image-GHCR-blue.svg" alt="GHCR 镜像">
Expand Down
2 changes: 1 addition & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -4,7 +4,7 @@
<h1>SubBoost</h1>
<p>
<img src="https://img.shields.io/badge/platform-Linux%20%2B%20Docker-lightgrey.svg" alt="Platform: Linux + Docker">
<img src="https://img.shields.io/badge/version-2.5.1-green.svg" alt="Version 2.5.1">
<img src="https://img.shields.io/badge/version-2.6.0-green.svg" alt="Version 2.6.0">
<a href="https://subboost.org"><img src="https://img.shields.io/badge/app-subboost.org-brightgreen.svg" alt="Online app"></a>
<a href="https://docs.subboost.org"><img src="https://img.shields.io/badge/docs-subboost.org-blue.svg" alt="Documentation"></a>
<img src="https://img.shields.io/badge/image-GHCR-blue.svg" alt="GHCR image">
Expand Down
38 changes: 19 additions & 19 deletions docs/release-notes.md
Original file line number Diff line number Diff line change
@@ -1,43 +1,43 @@
# SubBoost v2.5.1
# SubBoost v2.6.0

## 中文

### 更新重点

SubBoost v2.5.1 是一个修复版本,主要修复自定义代理组的默认成员逻辑,并提升自部署安装、更新时的版本来源稳定性。建议 v2.5.0 用户升级。
SubBoost v2.6.0 改进了高级代理组的成员管理,并增强多种订阅格式和 ECH 参数的兼容性。自部署备份流程也更加可靠。建议 v2.5.1 用户升级。

### 主要变化

- 修复新建自定义代理组时默认成员过宽的问题,避免默认生成出“节点选择”和自定义组互相引用的循环配置
- `🚀 节点选择` 默认不再自动包含自定义代理组,减少代理组套娃导致配置异常或预览卡顿的风险
- 新建普通自定义代理组默认只包含 `DIRECT`、`REJECT` 和真实节点,保留高级模式里的手动配置能力
- 改进自部署安装和 `subboost update` 的版本来源处理,稳定版安装资产会默认固定到对应 release 版本
- 改进自部署管理脚本的更新来源,减少从指定版本更新时意外跟随其它发布通道的风险
- 高级代理组现在可以一键添加或移除全部节点、全部代理组,并可恢复默认成员;批量添加代理组时会自动跳过可能形成循环引用的项目
- 代理组成员区域会分别显示节点数和代理组数,代理组卡片的节点统计只计算真实节点,不再把 `DIRECT`、`REJECT` 或其它代理组引用计入节点数量
- 改进 Clash/Mihomo YAML 导入兼容性,可识别以单行对象列表书写的节点配置,并容忍这类列表中常见的缩进不一致
- 修复 VMess、VLESS、Trojan 和 AnyTLS 分享链接中的 ECH 查询服务器名称在转换时丢失的问题
- 自部署备份现在会在数据库导出失败时安全终止,不再把不完整文件当作成功备份;备份保留数量可通过 `SUBBOOST_BACKUP_RETENTION_COUNT` 调整,默认仍保留 10 份

### 升级说明

- 建议升级前备份 `/opt/subboost/.env` 和数据库,方便需要时回滚。
- 已安装 v2.5.0 的自部署实例可以继续使用 `subboost update` 更新。
- 普通订阅转换、模板和规则功能不需要手动改环境变量
- 已经手动配置过的高级代理组不会被自动改写;本次主要改变新建和默认生成行为
- 现有 v2.5.1 自部署实例可以继续使用 `subboost update` 更新。
- 本次升级不要求手动迁移数据库,也不要求新增环境变量;只有需要修改默认备份保留数量时,才需要设置 `SUBBOOST_BACKUP_RETENTION_COUNT`
- 现有订阅、模板、规则和高级代理组配置保持兼容,不会因为升级自动改写成员选择

## English

### Highlights

SubBoost v2.5.1 is a patch release that fixes default custom proxy group membership and improves version-source stability for self-hosted install and update flows. v2.5.0 users are encouraged to upgrade.
SubBoost v2.6.0 improves member management for advanced proxy groups and expands compatibility with several subscription formats and ECH parameters. It also makes self-hosted backups more reliable. Users on v2.5.1 are encouraged to upgrade.

### Main Changes

- Fixed overly broad default members for newly created custom proxy groups, preventing default configurations where node selection and custom groups reference each other in a loop.
- `🚀 节点选择` no longer automatically includes custom proxy groups by default, reducing the risk of nested proxy group loops causing invalid output or preview slowdowns.
- Newly created normal custom proxy groups now default to `DIRECT`, `REJECT`, and real nodes only, while manual advanced configuration remains available.
- Improved self-hosted install and `subboost update` version-source handling so stable release assets stay pinned to the matching release version by default.
- Improved the self-hosted manager update source to reduce the risk of a pinned-version update unexpectedly following another release channel.
- Advanced proxy groups can now add or remove all nodes or all proxy groups in one action, and restore their default members. Bulk proxy-group additions automatically skip entries that could create circular references.
- Proxy-group member sections now show separate node and proxy-group counts. Node totals on proxy-group cards count real nodes only, excluding `DIRECT`, `REJECT`, and references to other proxy groups.
- Improved Clash/Mihomo YAML import compatibility for node configurations written as lists of single-line objects, including common indentation inconsistencies in those lists.
- Fixed ECH query server names being lost when converting VMess, VLESS, Trojan, and AnyTLS share links.
- Self-hosted backups now stop safely when the database export fails instead of treating an incomplete file as a successful backup. Backup retention can be adjusted with `SUBBOOST_BACKUP_RETENTION_COUNT`, while the default remains 10 backups.

### Upgrade Notes

- Back up `/opt/subboost/.env` and the database before upgrading so rollback is easier if needed.
- Existing v2.5.0 self-hosted installations can continue to update with `subboost update`.
- Normal subscription conversion, templates, and rules do not require manual environment-variable changes.
- Manually configured advanced proxy groups are not rewritten automatically; this release mainly changes new and default generated behavior.
- Existing v2.5.1 self-hosted installations can continue to update with `subboost update`.
- This upgrade does not require a manual database migration or a new environment variable. Set `SUBBOOST_BACKUP_RETENTION_COUNT` only if you want to change the default backup retention count.
- Existing subscriptions, templates, rules, and advanced proxy-group configurations remain compatible, and upgrading does not automatically rewrite member selections.
2 changes: 1 addition & 1 deletion local/package.json
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
{
"name": "@subboost/local",
"version": "2.5.1",
"version": "2.6.0",
"license": "AGPL-3.0-only",
"private": true,
"type": "module",
Expand Down
4 changes: 2 additions & 2 deletions local/scripts/selfhost-shell.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -282,8 +282,8 @@ ENV
expect(result.status).toBe(0);
expect(result.stdout).toContain("健康检查: 正常");
expect(result.stdout).not.toContain("健康检查: 异常");
// wait_for_health stops once ready succeeds, then status_cmd performs one final live+ready check.
expect(result.stdout).toContain("curl_count=7");
// wait_for_health checks live once per attempt, then status_cmd performs one final live+ready check.
expect(result.stdout).toContain("curl_count=8");
}, 10_000);

it("uses refreshed release metadata before pulling during update", () => {
Expand Down
41 changes: 32 additions & 9 deletions local/scripts/subboost.sh
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,7 @@ set -Eeuo pipefail

DEFAULT_HOME="/opt/subboost"
DEFAULT_STABLE_RELEASE_URL="https://github.com/SubBoost/subboost/releases/latest/download/release.json"
DEFAULT_BACKUP_RETENTION_COUNT="10"
SUBBOOST_HOME="${SUBBOOST_HOME:-$DEFAULT_HOME}"
ENV_FILE="$SUBBOOST_HOME/.env"
COMPOSE_FILE="$SUBBOOST_HOME/docker-compose.yml"
Expand Down Expand Up @@ -225,17 +226,25 @@ health_status_text() {
}

health_status_code() {
local port base
local port base live_ok
port="$(port_number "${SUBBOOST_PORT:-3000}")"
base="http://127.0.0.1:$port"
if ! command -v curl >/dev/null 2>&1; then
printf 'curl-missing\n'
elif curl -fsS "$base/api/health/live" >/dev/null 2>&1 && curl -fsS "$base/api/health/ready" >/dev/null 2>&1; then
printf 'ok\n'
elif curl -fsS "$base/api/health/live" >/dev/null 2>&1; then
printf 'not-ready\n'
else
printf 'unhealthy\n'
if curl -fsS "$base/api/health/live" >/dev/null 2>&1; then
live_ok=1
else
live_ok=0
fi

if [ "$live_ok" = "1" ] && curl -fsS "$base/api/health/ready" >/dev/null 2>&1; then
printf 'ok\n'
elif [ "$live_ok" = "1" ]; then
printf 'not-ready\n'
else
printf 'unhealthy\n'
fi
fi
}

Expand All @@ -249,6 +258,7 @@ health_status_label() {
}

wait_for_health() {
# Default max wait is about 30 seconds: 15 attempts with a 2-second interval.
local attempts="${SUBBOOST_DOCTOR_HEALTH_ATTEMPTS:-15}"
local interval="${SUBBOOST_DOCTOR_HEALTH_INTERVAL_SECONDS:-2}"
local index status
Expand Down Expand Up @@ -336,12 +346,25 @@ backup_cmd() {
sudo_do mkdir -p "$BACKUP_DIR"
local stamp db_tmp db_out env_out
local -a sql_backups env_backups
local i
local -a backup_status
local i backup_retention_count
backup_retention_count="${SUBBOOST_BACKUP_RETENTION_COUNT:-$DEFAULT_BACKUP_RETENTION_COUNT}"
if ! [[ "$backup_retention_count" =~ ^[0-9]+$ ]] || (( backup_retention_count < 1 )); then
die "SUBBOOST_BACKUP_RETENTION_COUNT must be a positive integer"
fi
stamp="$(date -u +%Y%m%dT%H%M%SZ)"
db_tmp="$BACKUP_DIR/subboost-$stamp.sql.gz.partial"
db_out="$BACKUP_DIR/subboost-$stamp.sql.gz"
env_out="$BACKUP_DIR/subboost-$stamp.env"
set +e
compose exec -T db pg_dump -U "${POSTGRES_USER:-subboost}" -d "${POSTGRES_DB:-subboost}" | gzip -c | sudo_do tee "$db_tmp" >/dev/null
backup_status=("${PIPESTATUS[@]}")
set -e
if (( backup_status[0] != 0 || backup_status[1] != 0 || backup_status[2] != 0 )); then
sudo_do rm -f -- "$db_tmp"
say "Backup failed: pg_dump=${backup_status[0]} gzip=${backup_status[1]} write=${backup_status[2]}"
return 1
fi
sudo_do mv "$db_tmp" "$db_out"
sudo_do install -m 600 "$ENV_FILE" "$env_out"

Expand All @@ -350,10 +373,10 @@ backup_cmd() {
env_backups=("$BACKUP_DIR"/subboost-*.env)
shopt -u nullglob

for ((i = 0; i < ${#sql_backups[@]} - 10; i++)); do
for ((i = 0; i < ${#sql_backups[@]} - backup_retention_count; i++)); do
sudo_do rm -f -- "${sql_backups[$i]}"
done
for ((i = 0; i < ${#env_backups[@]} - 10; i++)); do
for ((i = 0; i < ${#env_backups[@]} - backup_retention_count; i++)); do
sudo_do rm -f -- "${env_backups[$i]}"
done
say "Backup written:"
Expand Down
6 changes: 3 additions & 3 deletions package-lock.json

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

2 changes: 1 addition & 1 deletion package.json
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
{
"name": "subboost",
"version": "2.5.1",
"version": "2.6.0",
"license": "AGPL-3.0-only",
"private": true,
"engines": {
Expand Down
30 changes: 30 additions & 0 deletions packages/core/src/mihomo/ech.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,30 @@
import { describe, expect, it } from "vitest";
import {
buildMihomoEchOptsFromShareValue,
isMihomoEchQueryServerName,
isStandardBase64String,
} from "./ech";

describe("Mihomo ECH helpers", () => {
it("classifies share values without promoting arbitrary text", () => {
expect(buildMihomoEchOptsFromShareValue(null)).toEqual({ enable: true });
expect(buildMihomoEchOptsFromShareValue(" ")).toEqual({ enable: true });
expect(buildMihomoEchOptsFromShareValue(" Y29uZmln ")).toEqual({ enable: true, config: "Y29uZmln" });
expect(buildMihomoEchOptsFromShareValue(" cloudflare-ech.com ")).toEqual({
enable: true,
"query-server-name": "cloudflare-ech.com",
});
expect(buildMihomoEchOptsFromShareValue("not base64!")).toEqual({ enable: true });
});

it("keeps Base64 and DNS-name validation bounded and conservative", () => {
expect(isStandardBase64String("+w==")).toBe(true);
expect(isStandardBase64String("dGVzdA")).toBe(false);
expect(isMihomoEchQueryServerName("ech.example.com")).toBe(true);
expect(isMihomoEchQueryServerName("ech.example.com.")).toBe(true);
expect(isMihomoEchQueryServerName("1.1.1.1")).toBe(false);
expect(isMihomoEchQueryServerName("single-label")).toBe(false);
expect(isMihomoEchQueryServerName(`bad_label.${"a".repeat(64)}.example.com`)).toBe(false);
expect(isMihomoEchQueryServerName(`${"a".repeat(100_000)}.example.com`)).toBe(false);
});
});
31 changes: 31 additions & 0 deletions packages/core/src/mihomo/ech.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,31 @@
const STANDARD_BASE64_PATTERN = /^(?:[A-Za-z0-9+/]{4})*(?:[A-Za-z0-9+/]{2}==|[A-Za-z0-9+/]{3}=)?$/;
const DNS_LABEL_PATTERN = /^[A-Za-z0-9](?:[A-Za-z0-9-]{0,61}[A-Za-z0-9])?$/;

export interface MihomoEchOpts {
enable: true;
config?: string;
"query-server-name"?: string;
}

export function isStandardBase64String(value: string): boolean {
const trimmed = value.trim();
return trimmed.length > 0 && trimmed.length % 4 === 0 && STANDARD_BASE64_PATTERN.test(trimmed);
}

export function isMihomoEchQueryServerName(value: string): boolean {
const trimmed = value.trim();
const hostname = trimmed.endsWith(".") ? trimmed.slice(0, -1) : trimmed;
if (!hostname || hostname.length > 253 || !hostname.includes(".") || !/[A-Za-z]/.test(hostname)) return false;

return hostname.split(".").every((label) => label.length <= 63 && DNS_LABEL_PATTERN.test(label));
}

export function buildMihomoEchOptsFromShareValue(value: unknown): MihomoEchOpts {
const normalized = typeof value === "string" ? value.trim() : "";
if (!normalized) return { enable: true };
if (isStandardBase64String(normalized)) return { enable: true, config: normalized };
if (isMihomoEchQueryServerName(normalized)) {
return { enable: true, "query-server-name": normalized };
}
return { enable: true };
}
Comment on lines +23 to +31
50 changes: 50 additions & 0 deletions packages/core/src/mihomo/proxy-sanitizer-ech.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,50 @@
import { describe, expect, it } from "vitest";
import { sanitizeMihomoProxyNode } from "./proxy-sanitizer";

describe("Mihomo ECH sanitizer compatibility", () => {
it("repairs legacy domain-valued config without overriding explicit query server names", () => {
const legacy = sanitizeMihomoProxyNode({
name: "Legacy ECH",
type: "vless",
server: "legacy.example.com",
port: 443,
uuid: "11111111-1111-4111-8111-111111111111",
"ech-opts": { enable: true, config: " cloudflare-ech.com " },
});
const explicitFirst = sanitizeMihomoProxyNode({
name: "Explicit First",
type: "vless",
server: "explicit-first.example.com",
port: 443,
uuid: "11111111-1111-4111-8111-111111111111",
"ech-opts": {
"query-server-name": "explicit.example.com",
config: "legacy.example.com",
},
});
const explicitLast = sanitizeMihomoProxyNode({
name: "Explicit Last",
type: "vless",
server: "explicit-last.example.com",
port: 443,
uuid: "11111111-1111-4111-8111-111111111111",
"ech-opts": {
config: "legacy.example.com",
"query-server-name": "explicit.example.com",
},
});
const invalid = sanitizeMihomoProxyNode({
name: "Invalid ECH",
type: "vless",
server: "invalid.example.com",
port: 443,
uuid: "11111111-1111-4111-8111-111111111111",
"ech-opts": { enable: true, config: "not base64!" },
});

expect(legacy["ech-opts"]).toEqual({ enable: true, "query-server-name": "cloudflare-ech.com" });
expect(explicitFirst["ech-opts"]).toEqual({ "query-server-name": "explicit.example.com" });
expect(explicitLast["ech-opts"]).toEqual({ "query-server-name": "explicit.example.com" });
expect(invalid["ech-opts"]).toEqual({ enable: true });
});
});
17 changes: 9 additions & 8 deletions packages/core/src/mihomo/proxy-sanitizer.ts
Original file line number Diff line number Diff line change
@@ -1,8 +1,8 @@
import type { ParsedNode } from "@subboost/core/types/node";
import { splitWsPathEarlyData } from "@subboost/core/parser/ws-early-data";
import { isMihomoEchQueryServerName, isStandardBase64String } from "./ech";
import { normalizeRealityShortId } from "./reality";

const STANDARD_BASE64_PATTERN = /^(?:[A-Za-z0-9+/]{4})*(?:[A-Za-z0-9+/]{2}==|[A-Za-z0-9+/]{3}=)?$/;
const REALITY_PUBLIC_KEY_PATTERN = /^[A-Za-z0-9_-]{43}$/;
const WIREGUARD_KEY_PATTERN = /^[A-Za-z0-9+/]{43}=$/;
const CERTIFICATE_FINGERPRINT_HEX_PATTERN = /^[A-Fa-f0-9]{64}$/;
Expand Down Expand Up @@ -158,10 +158,7 @@ function normalizeSshServerFingerprint(value: unknown): string | null {
return normalized;
}

export function isStandardBase64String(value: string): boolean {
const trimmed = value.trim();
return trimmed.length > 0 && trimmed.length % 4 === 0 && STANDARD_BASE64_PATTERN.test(trimmed);
}
export { isStandardBase64String } from "./ech";

export function normalizeMihomoRealityPublicKey(input: unknown): string | null {
const value = normalizeString(input);
Expand Down Expand Up @@ -240,6 +237,7 @@ function sanitizeEchOpts(value: unknown): unknown {
if (!isPlainObject(value)) return undefined;

const out: Record<string, unknown> = {};
const explicitQueryServerName = normalizeString(value["query-server-name"]);
for (const [key, raw] of Object.entries(value)) {
if (key.startsWith("_")) continue;

Expand All @@ -251,13 +249,16 @@ function sanitizeEchOpts(value: unknown): unknown {

if (key === "config") {
const config = normalizeString(raw);
if (config && isStandardBase64String(config)) out[key] = config;
if (config && isStandardBase64String(config)) {
out[key] = config;
} else if (config && !explicitQueryServerName && isMihomoEchQueryServerName(config)) {
out["query-server-name"] = config;
Comment on lines 251 to +255
}
continue;
}

if (key === "query-server-name") {
const queryServerName = normalizeString(raw);
if (queryServerName) out[key] = queryServerName;
if (explicitQueryServerName) out[key] = explicitQueryServerName;
continue;
}

Expand Down
Loading
Loading