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 package.json
Original file line number Diff line number Diff line change
Expand Up @@ -8,7 +8,7 @@
"deploy": "npm run setup-kv && wrangler deploy",
"dev": "wrangler dev",
"start": "wrangler dev",
"test": "vitest",
"test": "vitest run",
"setup-kv": "node scripts/setup-kv.cjs",
"build:node": "node_modules/.bin/esbuild src/platforms/node-server.js --bundle --platform=node --target=node18 --format=cjs --outfile=dist/node-server.cjs",
"dev:node": "npm run build:node && node dist/node-server.cjs"
Expand Down
43 changes: 20 additions & 23 deletions src/builders/ClashConfigBuilder.js
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,7 @@ import { addProxyWithDedup } from './helpers/proxyHelpers.js';
import { buildSelectorMembers, buildNodeSelectMembers, buildCustomRuleMembers, uniqueNames } from './helpers/groupBuilder.js';
import { emitClashRules, sanitizeClashProxyGroups } from './helpers/clashConfigUtils.js';
import { normalizeGroupName, findGroupIndexByName } from './helpers/groupNameUtils.js';
import { InvalidConfigError } from '../services/errors.js';

/**
* Check if the client supports MRS (Meta Rule Set) format
Expand Down Expand Up @@ -600,37 +601,35 @@ export class ClashConfigBuilder extends BaseConfigBuilder {
newGroup.use = newGroup.use.filter(p => allProviderNames.has(p));
}

// Add group if:
// 1. Has valid proxies or use, OR
// 2. Is url-test/fallback type (will be filled by validateProxyGroups)
const isAutoFillableType = newGroup.type === 'url-test' || newGroup.type === 'fallback';
if ((newGroup.proxies?.length > 0) || (newGroup.use?.length > 0) || isAutoFillableType) {
if ((newGroup.proxies?.length > 0) || (newGroup.use?.length > 0) || newGroup.type) {
this.config['proxy-groups'].push(newGroup);
}
}
});
}

/**
* Validate proxy groups before final output
* Ensures url-test/fallback groups have proxies, fills empty ones with all nodes
* Reject invalid proxy groups before final output.
* Why: empty groups make Clash reject the whole config, so we should fail fast
* instead of masking the upstream merge/parsing problem.
*/
validateProxyGroups() {
const proxyList = this.getProxyList();
const providerNames = this.getAllProviderNames();

(this.config['proxy-groups'] || []).forEach(group => {
// For url-test/fallback groups, ensure they have proxies or providers
if ((group.type === 'url-test' || group.type === 'fallback') &&
(!group.proxies || group.proxies.length === 0) &&
(!group.use || group.use.length === 0)) {
// Fill with all available proxies
group.proxies = [...proxyList];
// Also use all providers if available
if (providerNames.length > 0) {
group.use = [...providerNames];
}
const requiresMembers = group?.type === 'url-test' || group?.type === 'fallback';
if (!requiresMembers) {
return;
}

const hasProxyRefs = Array.isArray(group.proxies) && group.proxies.length > 0;
const hasProviderRefs = Array.isArray(group.use) && group.use.length > 0;
if (hasProxyRefs || hasProviderRefs) {
return;
}

const groupName = group?.name || '(unnamed group)';
throw new InvalidConfigError(
`Invalid proxy group "${groupName}": type "${group.type}" requires at least one proxy or provider reference`
);
});
}

Expand All @@ -657,10 +656,8 @@ export class ClashConfigBuilder extends BaseConfigBuilder {
};
}

// Validate proxy groups: fill empty url-test/fallback groups with all proxies
this.validateProxyGroups();

sanitizeClashProxyGroups(this.config);
this.validateProxyGroups();

this.config.rules = [
...ruleResults,
Expand Down
7 changes: 7 additions & 0 deletions src/services/errors.js
Original file line number Diff line number Diff line change
Expand Up @@ -19,3 +19,10 @@ export class InvalidPayloadError extends ServiceError {
this.name = 'InvalidPayloadError';
}
}

export class InvalidConfigError extends ServiceError {
constructor(message = 'Invalid config') {
super(message, 400);
this.name = 'InvalidConfigError';
}
}
14 changes: 4 additions & 10 deletions test/proxy-groups-override.test.js
Original file line number Diff line number Diff line change
Expand Up @@ -300,7 +300,7 @@ proxy-groups:
expect(autoGroups[0].url).toBe('http://custom.test/204');
});

it('should fill empty url-test proxies with all available nodes', async () => {
it('should reject empty url-test groups instead of silently filling them', async () => {
const inputWithEmptyProxies = `
proxies:
- name: Node-A
Expand All @@ -321,15 +321,9 @@ proxy-groups:
proxies: []
`;
const builder = new ClashConfigBuilder(inputWithEmptyProxies, 'minimal', [], null, 'zh-CN', 'test-agent');
const yamlText = await builder.build();
const config = yaml.load(yamlText);

const emptyGroup = config['proxy-groups'].find(g => g.name === 'Empty Test Group');
expect(emptyGroup).toBeDefined();
// Empty group should be filled with all available proxies
expect(emptyGroup.proxies.length).toBeGreaterThan(0);
expect(emptyGroup.proxies).toContain('Node-A');
expect(emptyGroup.proxies).toContain('Node-B');
await expect(builder.build()).rejects.toThrow(
'Invalid proxy group "Empty Test Group": type "url-test" requires at least one proxy or provider reference'
);
});

it('should filter out invalid proxy references from user groups', async () => {
Expand Down
23 changes: 23 additions & 0 deletions test/worker.test.js
Original file line number Diff line number Diff line change
Expand Up @@ -77,6 +77,29 @@ describe('Worker', () => {
expect(text).toContain('proxies:');
});

it('GET /clash rejects empty url-test proxy groups with a diagnostic error', async () => {
const app = createTestApp();
const config = `
proxies:
- name: Node-A
type: ss
server: a.example.com
port: 443
cipher: aes-128-gcm
password: test
proxy-groups:
- name: Empty Test Group
type: url-test
proxies: []
`;
const res = await app.request(`http://localhost/clash?config=${encodeURIComponent(config)}`);

expect(res.status).toBe(400);
const text = await res.text();
expect(text).toContain('Invalid proxy group "Empty Test Group"');
expect(text).toContain('requires at least one proxy or provider reference');
});

it('GET /shorten-v2 returns short code', async () => {
const url = 'http://example.com';
const kvMock = {
Expand Down
Loading