Compare commits

..

2 Commits

Author SHA1 Message Date
c6db237448 fix: 自定义规则置顶生效,写入前校验策略名存在
规则首条匹配即生效,自定义规则此前插在模板规则之后、FINAL 之前,被
download.conf 等宽泛规则集抢先命中(brew.sh 配了代理仍直连)。现在新建
规则插到 seq 0,重新播种时也把自定义规则整体排在模板规则之前。

同时把 BUILTIN_POLICIES 提到 shared 共用,规则新建/编辑时校验策略必须是
内置策略或已有策略组,避免引用不存在的策略被 IR 静默剔除;前端表单默认
策略由不存在的「Proxy」改为 DIRECT。

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-09-03 16:44:40 +08:00
a30360ab38 fix: ShadowRocket 策略名去引号,修复规则整条失效
ShadowRocket 的 conf 语法没有引号机制,含空格/emoji 的策略名一律裸写。
此前 quoteName 给策略名加引号,引号被当作名字的一部分,规则指向不存在的
策略组而失效,流量落到默认行为走代理(小红书/微信/抖音配了直连仍走代理)。
改为 policyName 剔除引号与逗号后裸写,补回归测试,并在 CLAUDE.md 记录此约束。

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-09-03 16:44:40 +08:00
8 changed files with 89 additions and 41 deletions

View File

@@ -74,7 +74,7 @@ IR 层承担三件防护,改动时不要绕过:
### ShadowRocket 分两步导入 ### ShadowRocket 分两步导入
ShadowRocket 的原生订阅格式只承载节点,因此拆成两个端点:`/shadowrocket`(节点 base64 URI 列表)与 `/shadowrocket-conf`(策略组与规则的 .conf。**ShadowRocket 没有 Surge 的 `use=` 订阅引用语法**(真机验证过:`use=true` 会被当成解析不了的成员残留),策略组收节点只能靠 `policy-regex-filter` 从 App 内全部节点里筛(「收纳全部节点」输出 `policy-regex-filter=.*`),因此订阅名随意,但用户在 App 里挂的其他订阅的节点也会被筛进来。ShadowRocket 不支持 `DOMAIN-SET``AND``URL-REGEX`,生成时跳过并回报 `skippedRules` ShadowRocket 的原生订阅格式只承载节点,因此拆成两个端点:`/shadowrocket`(节点 base64 URI 列表)与 `/shadowrocket-conf`(策略组与规则的 .conf。**ShadowRocket 没有 Surge 的 `use=` 订阅引用语法**(真机验证过:`use=true` 会被当成解析不了的成员残留),策略组收节点只能靠 `policy-regex-filter` 从 App 内全部节点里筛(「收纳全部节点」输出 `policy-regex-filter=.*`),因此订阅名随意,但用户在 App 里挂的其他订阅的节点也会被筛进来。ShadowRocket 不支持 `DOMAIN-SET``AND``URL-REGEX`,生成时跳过并回报 `skippedRules`**ShadowRocket 的 conf 语法没有引号机制**策略名含空格emoji一律裸写加引号会被当作名字的一部分导致规则指向不存在的策略组而整条失效、流量落到默认行为走代理——Surge 生成器可以加引号ShadowRocket 生成器绝不可以。
### 鉴权与订阅 ### 鉴权与订阅

View File

@@ -59,6 +59,12 @@ export function seedRulesFromTemplate(db: Db, opts: { keepCustom: boolean }) {
if (!opts.keepCustom) db.delete(rules).where(eq(rules.source, 'custom')).run(); if (!opts.keepCustom) db.delete(rules).where(eq(rules.source, 'custom')).run();
let seq = 0; let seq = 0;
// 规则首条匹配即生效:自定义规则保持原相对顺序,整体排在全部模板规则之前
for (const c of custom.sort((a, b) => a.seq - b.seq)) {
db.insert(rules)
.values({ ...c, seq: seq++ })
.run();
}
for (const r of parsed.rules) { for (const r of parsed.rules) {
let value = r.value; let value = r.value;
if ((r.type === 'RULE-SET' || r.type === 'DOMAIN-SET') && value && /^https?:\/\//.test(value)) { if ((r.type === 'RULE-SET' || r.type === 'DOMAIN-SET') && value && /^https?:\/\//.test(value)) {
@@ -95,18 +101,6 @@ export function seedRulesFromTemplate(db: Db, opts: { keepCustom: boolean }) {
}) })
.run(); .run();
} }
// 自定义规则重新插回:保持原相对顺序,排在 FINAL 之前
if (custom.length > 0) {
const finalRow = db.select().from(rules).where(eq(rules.type, 'FINAL')).get();
let base = finalRow ? finalRow.seq : seq;
// 给自定义行腾出 seq 空间FINAL 及之后行整体后移
for (const c of custom.sort((a, b) => a.seq - b.seq)) {
db.insert(rules)
.values({ ...c, seq: base++ })
.run();
}
if (finalRow) db.update(rules).set({ seq: base }).where(eq(rules.id, finalRow.id)).run();
}
} }
export function seedIfEmpty(db: Db) { export function seedIfEmpty(db: Db) {

View File

@@ -1,11 +1,11 @@
import { Hono } from 'hono'; import { Hono } from 'hono';
import { zValidator } from '@hono/zod-validator'; import { zValidator } from '@hono/zod-validator';
import { asc, eq } from 'drizzle-orm'; import { asc, eq, sql } from 'drizzle-orm';
import { nanoid } from 'nanoid'; import { nanoid } from 'nanoid';
import { z } from 'zod'; import { z } from 'zod';
import { ruleInputSchema, type RuleDto } from '@proxy-station/shared'; import { BUILTIN_POLICIES, ruleInputSchema, type RuleDto } from '@proxy-station/shared';
import { db } from '../db/index.js'; import { db } from '../db/index.js';
import { rules, rulesets } from '../db/schema.js'; import { policyGroups, rules, rulesets } from '../db/schema.js';
import { seedRulesFromTemplate } from '../db/seed.js'; import { seedRulesFromTemplate } from '../db/seed.js';
function loadRules(): RuleDto[] { function loadRules(): RuleDto[] {
@@ -22,29 +22,26 @@ function loadRules(): RuleDto[] {
}); });
} }
function nextSeq(): number { /** 引用不存在的策略组会被 IR 静默剔除,必须在写入前拦下 */
const rows = db.select({ seq: rules.seq }).from(rules).all(); function policyError(policy: string): string | null {
return rows.length ? Math.max(...rows.map((r) => r.seq)) + 1 : 0; if ((BUILTIN_POLICIES as readonly string[]).includes(policy)) return null;
const exists = db.select({ id: policyGroups.id }).from(policyGroups).where(eq(policyGroups.name, policy)).get();
return exists ? null : `策略「${policy}」不存在:请选择内置策略或已有策略组`;
} }
export const rulesRoute = new Hono() export const rulesRoute = new Hono()
.get('/', (c) => c.json(loadRules())) .get('/', (c) => c.json(loadRules()))
.post('/', zValidator('json', ruleInputSchema), (c) => { .post('/', zValidator('json', ruleInputSchema), (c) => {
const input = c.req.valid('json'); const input = c.req.valid('json');
// 新自定义规则插到 FINAL 之前 const err = policyError(input.policy);
const finalRow = db.select().from(rules).where(eq(rules.type, 'FINAL')).get(); if (err) return c.json({ error: err }, 400);
let seq: number; // 规则首条匹配即生效,自定义规则必须压过模板里的宽泛规则集(如 download.conf插到最前
if (finalRow) { db.update(rules).set({ seq: sql`${rules.seq} + 1` }).run();
seq = finalRow.seq;
db.update(rules).set({ seq: finalRow.seq + 1 }).where(eq(rules.id, finalRow.id)).run();
} else {
seq = nextSeq();
}
const id = nanoid(10); const id = nanoid(10);
db.insert(rules) db.insert(rules)
.values({ .values({
id, id,
seq, seq: 0,
type: input.type, type: input.type,
value: input.value ?? null, value: input.value ?? null,
policy: input.policy, policy: input.policy,
@@ -69,6 +66,8 @@ export const rulesRoute = new Hono()
const existing = db.select().from(rules).where(eq(rules.id, id)).get(); const existing = db.select().from(rules).where(eq(rules.id, id)).get();
if (!existing) return c.json({ error: '规则不存在' }, 404); if (!existing) return c.json({ error: '规则不存在' }, 404);
const input = c.req.valid('json'); const input = c.req.valid('json');
const err = policyError(input.policy);
if (err) return c.json({ error: err }, 400);
db.update(rules) db.update(rules)
.set({ .set({
type: input.type, type: input.type,

View File

@@ -0,0 +1,48 @@
import { describe, expect, it } from 'vitest';
import { generateShadowrocketConf } from './shadowrocket-conf.js';
import type { ProfileIR } from '../ir.js';
const ir: ProfileIR = {
nodes: [],
groups: [
{
name: '🇨🇳 Mainland',
type: 'select',
testUrl: null,
interval: null,
tolerance: null,
filterRegex: null,
includeAllNodes: false,
members: [{ kind: 'builtin', name: 'DIRECT' }],
},
{
name: '🎯 NoAuto',
type: 'select',
testUrl: null,
interval: null,
tolerance: null,
filterRegex: null,
includeAllNodes: false,
members: [
{ kind: 'group', name: '🇨🇳 Mainland' },
{ kind: 'group', name: '⚡️ Automatic' },
],
},
],
rules: [
{ type: 'DOMAIN-SUFFIX', value: 'xiaohongshu.com', policy: '🇨🇳 Mainland', params: [], ruleset: null },
{ type: 'FINAL', value: null, policy: '🎯 NoAuto', params: ['dns-failed'], ruleset: null },
],
sections: {},
};
describe('generateShadowrocketConf', () => {
// ShadowRocket 不剥引号:带引号的策略名会匹配不到策略组,整条规则失效(真机与社区配置证实)
it('含空格/emoji 的策略名一律裸写,不加引号', () => {
const { content } = generateShadowrocketConf(ir);
expect(content).not.toContain('"');
expect(content).toContain('DOMAIN-SUFFIX,xiaohongshu.com,🇨🇳 Mainland');
expect(content).toContain('FINAL,🎯 NoAuto');
expect(content).toContain('🎯 NoAuto = select, 🇨🇳 Mainland, ⚡️ Automatic');
});
});

View File

@@ -1,8 +1,12 @@
import type { ProfileIR } from '../ir.js'; import type { ProfileIR } from '../ir.js';
/** 成员名含空格/逗号时加引号 */ /**
function quoteName(name: string): string { * ShadowRocket 的 conf 语法没有引号机制:策略名一律裸写,含空格/emoji 均可;
return /[\s,]/.test(name) ? `"${name}"` : name; * 加引号会被当作名字的一部分,导致规则指向不存在的策略组而整条失效。
* 逗号是唯一分隔符,名字里的逗号无法表达,只能剔除。
*/
function policyName(name: string): string {
return name.replace(/[",]/g, '').trim();
} }
/** /**
@@ -67,7 +71,7 @@ export function generateShadowrocketConf(ir: ProfileIR): ShadowrocketConfOutput
for (const g of ir.groups) { for (const g of ir.groups) {
const parts: string[] = [g.type]; const parts: string[] = [g.type];
if (!g.filterRegex && !g.includeAllNodes) { if (!g.filterRegex && !g.includeAllNodes) {
const members = g.members.map((m) => quoteName(m.name)); const members = g.members.map((m) => policyName(m.name));
parts.push(...(members.length ? members : ['DIRECT'])); parts.push(...(members.length ? members : ['DIRECT']));
} }
if (g.type !== 'select') { if (g.type !== 'select') {
@@ -78,14 +82,14 @@ export function generateShadowrocketConf(ir: ProfileIR): ShadowrocketConfOutput
// policy-regex-filter 从 App 内全部节点里筛选;「收纳全部节点」用全匹配正则 // policy-regex-filter 从 App 内全部节点里筛选;「收纳全部节点」用全匹配正则
if (g.filterRegex) parts.push(`policy-regex-filter=${g.filterRegex}`); if (g.filterRegex) parts.push(`policy-regex-filter=${g.filterRegex}`);
else if (g.includeAllNodes) parts.push('policy-regex-filter=.*'); else if (g.includeAllNodes) parts.push('policy-regex-filter=.*');
lines.push(`${g.name} = ${parts.join(', ')}`); lines.push(`${policyName(g.name)} = ${parts.join(', ')}`);
} }
const skippedRules: string[] = []; const skippedRules: string[] = [];
lines.push('', '[Rule]'); lines.push('', '[Rule]');
for (const r of ir.rules) { for (const r of ir.rules) {
if (r.type === 'FINAL') { if (r.type === 'FINAL') {
lines.push(['FINAL', quoteName(r.policy)].join(',')); lines.push(['FINAL', policyName(r.policy)].join(','));
continue; continue;
} }
if (SR_UNSUPPORTED_RULE_TYPES.has(r.type)) { if (SR_UNSUPPORTED_RULE_TYPES.has(r.type)) {
@@ -93,7 +97,7 @@ export function generateShadowrocketConf(ir: ProfileIR): ShadowrocketConfOutput
continue; continue;
} }
const value = r.ruleset ? r.ruleset.surgeUrl : r.value; const value = r.ruleset ? r.ruleset.surgeUrl : r.value;
lines.push([r.type, value, quoteName(r.policy), ...r.params].join(',')); lines.push([r.type, value, policyName(r.policy), ...r.params].join(','));
} }
for (const section of ['Host', 'URL Rewrite', 'MITM']) { for (const section of ['Host', 'URL Rewrite', 'MITM']) {

View File

@@ -1,7 +1,7 @@
import { asc, eq } from 'drizzle-orm'; import { asc, eq } from 'drizzle-orm';
import { db } from '../db/index.js'; import { db } from '../db/index.js';
import { groupMembers, nodes, policyGroups, rules, rulesets, settings } from '../db/schema.js'; import { groupMembers, nodes, policyGroups, rules, rulesets, settings } from '../db/schema.js';
import type { NodeDto } from '@proxy-station/shared'; import { BUILTIN_POLICIES, type NodeDto } from '@proxy-station/shared';
import { rowToDto } from '../routes/nodes.js'; import { rowToDto } from '../routes/nodes.js';
export interface GroupIR { export interface GroupIR {
@@ -71,9 +71,9 @@ export function buildProfileIR(opts: { nodeIds?: string[] } = {}): ProfileIR {
// 停用的组从输出中剔除;引用它的成员与规则一并跳过,保证产物有效 // 停用的组从输出中剔除;引用它的成员与规则一并跳过,保证产物有效
const groupRows = allGroupRows.filter((g) => g.enabled); const groupRows = allGroupRows.filter((g) => g.enabled);
const liveNames = new Set(groupRows.map((g) => g.name)); const liveNames = new Set(groupRows.map((g) => g.name));
const BUILTIN_POLICIES = new Set(['DIRECT', 'REJECT', 'REJECT-DROP', 'REJECT-NO-DROP', 'REJECT-TINYGIF']); const builtins = new Set<string>(BUILTIN_POLICIES);
/** 组已被删除或停用时,引用它的成员/规则应当跳过 */ /** 组已被删除或停用时,引用它的成员/规则应当跳过 */
const isDeadRef = (name: string) => !liveNames.has(name) && !BUILTIN_POLICIES.has(name); const isDeadRef = (name: string) => !liveNames.has(name) && !builtins.has(name);
const groups: GroupIR[] = groupRows.map((g) => ({ const groups: GroupIR[] = groupRows.map((g) => ({
name: g.name, name: g.name,
type: g.type, type: g.type,

View File

@@ -85,6 +85,9 @@ export const RULE_TYPES = [
'FINAL', 'FINAL',
] as const; ] as const;
/** 无需策略组即可引用的内置策略 */
export const BUILTIN_POLICIES = ['DIRECT', 'REJECT', 'REJECT-DROP', 'REJECT-NO-DROP', 'REJECT-TINYGIF'] as const;
export const ruleInputSchema = z.object({ export const ruleInputSchema = z.object({
type: z.enum(RULE_TYPES), type: z.enum(RULE_TYPES),
value: z.string().optional().nullable(), value: z.string().optional().nullable(),

View File

@@ -39,7 +39,7 @@ function openPreview(rule: RuleDto) {
previewRule.value = rule; previewRule.value = rule;
showPreview.value = true; showPreview.value = true;
} }
const form = ref<RuleInput>({ type: 'DOMAIN-SUFFIX', value: '', policy: 'Proxy', params: [], enabled: true }); const form = ref<RuleInput>({ type: 'DOMAIN-SUFFIX', value: '', policy: 'DIRECT', params: [], enabled: true });
const policyOptions = computed(() => [ const policyOptions = computed(() => [
...['DIRECT', 'REJECT', 'REJECT-DROP', 'REJECT-NO-DROP'].map((p) => ({ label: p, value: p })), ...['DIRECT', 'REJECT', 'REJECT-DROP', 'REJECT-NO-DROP'].map((p) => ({ label: p, value: p })),
@@ -53,7 +53,7 @@ const typeOptions = RULE_TYPES.filter((t) => t !== 'RULE-SET' && t !== 'DOMAIN-S
function openCreate() { function openCreate() {
editing.value = null; editing.value = null;
form.value = { type: 'DOMAIN-SUFFIX', value: '', policy: 'Proxy', params: [], enabled: true }; form.value = { type: 'DOMAIN-SUFFIX', value: '', policy: 'DIRECT', params: [], enabled: true };
showForm.value = true; showForm.value = true;
} }