feat(sub): add Happ client integration, routing presets, and app management (#6434)

* feat(sub): add Happ client integration, routing presets, and app management

Implement comprehensive Happ proxy client integration according to official developer specifications.

- Fix header emission on disabled routing and hidden settings to send explicit '0' headers rather than omitting, allowing Happ clients to reset cached settings.
- Add support for 'happ://routing/off' deeplink in routing validation.
- Preserve '?serverDescription=' query parameters in link fragments without escaping to support Happ server subtitles across VMess, VLESS, Trojan and SS.
- Add Happ application management headers: ProviderID, New-Url, Fallback-Url, Sub-Info banners, Sub-Expire notifications, No-Limit mode, hardware ID enforcement, TUN modes/types, route exclusions, APNS exclusions, and per-app proxy settings.
- Add curated routing presets (Iran Bypass, China Direct, AdBlock, Global) and interactive visual rule generator in frontend settings.
- Synchronize all 13 translation locales with native Persian, Russian, and Chinese translations.

* fix(sub): keep Happ header overrides behind the auto-detect opt-in

The Routing-Enable/Hide-Settings off values were emitted on the
User-Agent alone, so every panel that upgraded would push
"Routing-Enable: 0" — documented by happ.su as disabling routing
globally — to every Happ client without the operator enabling anything.
They now ride subHappAutoDetect like every other Happ header.

Two further mismatches against the vendor spec:

- serverDescription was written as a key of the VMess base64 JSON
  object. happ.su documents it as a "#Title?serverDescription=<base64>"
  link parameter or a JSON "meta" entry, so the caption never reached
  Happ while every other VMess consumer received an unknown key.
  Dropped rather than moved: emitting the documented form is unsafe
  here because our own parser base64-decodes the whole VMess body
  (internal/util/link/outbound.go).

- The TUN Mode dropdown stored the literal "default", forwarded as
  "Tun-Mode: default", where happ.su documents system|gvisor only. It
  now stores the unset value so no header is sent. TUN Type "default"
  is a documented value and is unchanged.

Each fix carries a test that fails without it.
This commit is contained in:
Pejman Yousefi
2026-09-10 17:16:12 +03:30
committed by GitHub
parent d5ab84e8d5
commit 1456658028
35 changed files with 3460 additions and 51 deletions
+85
View File
@@ -0,0 +1,85 @@
import { describe, expect, it } from 'vitest';
import { buildHappPresetDeeplink, parseList, toBase64Utf8 } from '@/pages/settings/happPresets';
describe('Happ presets and helpers', () => {
it('correctly parses comma and newline separated lists', () => {
const raw = 'domain:ir\nregexp:.*\\.ir$\n, example.com, , google.com';
const parsed = parseList(raw);
expect(parsed).toEqual(['domain:ir', 'regexp:.*\\.ir$', 'example.com', 'google.com']);
});
it('generates valid happ://routing/off for off preset', () => {
const link = buildHappPresetDeeplink('off');
expect(link).toBe('happ://routing/off');
});
it('generates valid base64 payload for iran-bypass preset', () => {
const link = buildHappPresetDeeplink('iran-bypass');
expect(link.startsWith('happ://routing/onadd/')).toBe(true);
const b64 = link.replace('happ://routing/onadd/', '');
const jsonStr = atob(b64);
const parsed = JSON.parse(jsonStr);
expect(parsed).toHaveProperty('rules');
expect(Array.isArray(parsed.rules)).toBe(true);
const directRule = parsed.rules.find(
(r: { outboundTag: string }) => r.outboundTag === 'direct',
);
expect(directRule).toBeDefined();
expect(directRule.domain).toContain('domain:ir');
expect(directRule.ip).toContain('geoip:ir');
const blockRule = parsed.rules.find((r: { outboundTag: string }) => r.outboundTag === 'block');
expect(blockRule).toBeDefined();
expect(blockRule.domain).toContain('geosite:category-ads-all');
});
it('generates valid base64 payload for china-direct preset', () => {
const link = buildHappPresetDeeplink('china-direct');
expect(link.startsWith('happ://routing/onadd/')).toBe(true);
const b64 = link.replace('happ://routing/onadd/', '');
const jsonStr = atob(b64);
const parsed = JSON.parse(jsonStr);
const directRule = parsed.rules.find(
(r: { outboundTag: string }) => r.outboundTag === 'direct',
);
expect(directRule.domain).toContain('domain:cn');
expect(directRule.ip).toContain('geoip:cn');
});
it('generates valid base64 payload for adblock preset', () => {
const link = buildHappPresetDeeplink('adblock');
const b64 = link.replace('happ://routing/onadd/', '');
const jsonStr = atob(b64);
const parsed = JSON.parse(jsonStr);
const blockRule = parsed.rules.find((r: { outboundTag: string }) => r.outboundTag === 'block');
expect(blockRule.domain).toContain('geosite:category-ads-all');
});
it('generates valid base64 payload for global preset', () => {
const link = buildHappPresetDeeplink('global');
const b64 = link.replace('happ://routing/onadd/', '');
const jsonStr = atob(b64);
const parsed = JSON.parse(jsonStr);
expect(parsed.rules[0].outboundTag).toBe('proxy');
expect(parsed.rules[0].network).toBe('tcp,udp');
});
it('encodes unicode properly via toBase64Utf8', () => {
const text = 'فیلترشکن و روتینگ';
const b64 = toBase64Utf8(text);
const decoded = decodeURIComponent(
Array.prototype.map
.call(atob(b64), (c: string) => '%' + ('00' + c.charCodeAt(0).toString(16)).slice(-2))
.join(''),
);
expect(decoded).toBe(text);
});
});