mirror of
https://github.com/MHSanaei/3x-ui.git
synced 2026-09-16 15:17:14 +00:00
feat(sub): refine Happ routing presets, serverDescription escaping, and auto-detect placement (#6488)
* feat(sub): refine Happ routing presets, serverDescription escaping, and auto-detect placement * fix(sub): address PR review findings on routing parity, agent regex, and i18n
This commit is contained in:
@@ -51,55 +51,19 @@ export default function HappSettingsContent({
|
|||||||
};
|
};
|
||||||
|
|
||||||
const handleBuildDeeplink = () => {
|
const handleBuildDeeplink = () => {
|
||||||
interface FieldRule {
|
const profile = {
|
||||||
type: string;
|
Name: 'Custom Rules',
|
||||||
outboundTag: string;
|
GlobalProxy: 'true',
|
||||||
domain?: string[];
|
DirectSites: parseList(directDomains),
|
||||||
ip?: string[];
|
DirectIp: parseList(directIPs),
|
||||||
network?: string;
|
ProxySites: parseList(proxyDomains),
|
||||||
}
|
ProxyIp: parseList(proxyIPs),
|
||||||
const rules: FieldRule[] = [];
|
BlockSites: parseList(blockDomains),
|
||||||
|
BlockIp: parseList(blockIPs),
|
||||||
|
DomainStrategy: 'IPIfNonMatch',
|
||||||
|
};
|
||||||
|
|
||||||
const bDom = parseList(blockDomains);
|
const deeplink = 'happ://routing/onadd/' + toBase64Utf8(JSON.stringify(profile));
|
||||||
const bIp = parseList(blockIPs);
|
|
||||||
if (bDom.length > 0 || bIp.length > 0) {
|
|
||||||
rules.push({
|
|
||||||
type: 'field',
|
|
||||||
outboundTag: 'block',
|
|
||||||
...(bDom.length > 0 ? { domain: bDom } : {}),
|
|
||||||
...(bIp.length > 0 ? { ip: bIp } : {}),
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
const dDom = parseList(directDomains);
|
|
||||||
const dIp = parseList(directIPs);
|
|
||||||
if (dDom.length > 0 || dIp.length > 0) {
|
|
||||||
rules.push({
|
|
||||||
type: 'field',
|
|
||||||
outboundTag: 'direct',
|
|
||||||
...(dDom.length > 0 ? { domain: dDom } : {}),
|
|
||||||
...(dIp.length > 0 ? { ip: dIp } : {}),
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
const pDom = parseList(proxyDomains);
|
|
||||||
const pIp = parseList(proxyIPs);
|
|
||||||
if (pDom.length > 0 || pIp.length > 0) {
|
|
||||||
rules.push({
|
|
||||||
type: 'field',
|
|
||||||
outboundTag: 'proxy',
|
|
||||||
...(pDom.length > 0 ? { domain: pDom } : {}),
|
|
||||||
...(pIp.length > 0 ? { ip: pIp } : {}),
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
rules.push({
|
|
||||||
type: 'field',
|
|
||||||
outboundTag: 'proxy',
|
|
||||||
network: 'tcp,udp',
|
|
||||||
});
|
|
||||||
|
|
||||||
const deeplink = 'happ://routing/onadd/' + toBase64Utf8(JSON.stringify({ rules }));
|
|
||||||
updateSetting({ subRoutingRules: deeplink });
|
updateSetting({ subRoutingRules: deeplink });
|
||||||
setIsModalOpen(false);
|
setIsModalOpen(false);
|
||||||
message.success(t('pages.settings.subHappDeeplinkGenerated'));
|
message.success(t('pages.settings.subHappDeeplinkGenerated'));
|
||||||
@@ -107,6 +71,17 @@ export default function HappSettingsContent({
|
|||||||
|
|
||||||
return (
|
return (
|
||||||
<>
|
<>
|
||||||
|
<SettingListItem
|
||||||
|
paddings="small"
|
||||||
|
title={t('pages.settings.subHappAutoDetect')}
|
||||||
|
description={t('pages.settings.subHappAutoDetectDesc')}
|
||||||
|
>
|
||||||
|
<Switch
|
||||||
|
checked={allSetting.subHappAutoDetect}
|
||||||
|
onChange={(v) => updateSetting({ subHappAutoDetect: v })}
|
||||||
|
/>
|
||||||
|
</SettingListItem>
|
||||||
|
|
||||||
<Tabs
|
<Tabs
|
||||||
type="card"
|
type="card"
|
||||||
size="small"
|
size="small"
|
||||||
@@ -454,11 +429,43 @@ export default function HappSettingsContent({
|
|||||||
title={t('pages.settings.subHappColorProfile')}
|
title={t('pages.settings.subHappColorProfile')}
|
||||||
description={t('pages.settings.subHappColorProfileDesc')}
|
description={t('pages.settings.subHappColorProfileDesc')}
|
||||||
>
|
>
|
||||||
<Input
|
<Space orientation="vertical" style={{ width: '100%' }}>
|
||||||
value={allSetting.subHappColorProfile}
|
<Input
|
||||||
placeholder="default, violet, turquoise, cyberpunk, or custom JSON"
|
value={allSetting.subHappColorProfile}
|
||||||
onChange={(e) => updateSetting({ subHappColorProfile: e.target.value })}
|
placeholder='{"serverRowBackgroundColor":"#21003D67"} or resetcolors'
|
||||||
/>
|
onChange={(e) => updateSetting({ subHappColorProfile: e.target.value })}
|
||||||
|
/>
|
||||||
|
<Space wrap size="small">
|
||||||
|
<Button
|
||||||
|
size="small"
|
||||||
|
onClick={() => updateSetting({ subHappColorProfile: 'resetcolors' })}
|
||||||
|
>
|
||||||
|
{t('reset')}
|
||||||
|
</Button>
|
||||||
|
<Button
|
||||||
|
size="small"
|
||||||
|
onClick={() =>
|
||||||
|
updateSetting({
|
||||||
|
subHappColorProfile:
|
||||||
|
'{"serverRowBackgroundColor":"#21003D67","cardBackgroundColor":"#120023B3"}',
|
||||||
|
})
|
||||||
|
}
|
||||||
|
>
|
||||||
|
Violet
|
||||||
|
</Button>
|
||||||
|
<Button
|
||||||
|
size="small"
|
||||||
|
onClick={() =>
|
||||||
|
updateSetting({
|
||||||
|
subHappColorProfile:
|
||||||
|
'{"serverRowBackgroundColor":"#002B3667","cardBackgroundColor":"#001F27B3"}',
|
||||||
|
})
|
||||||
|
}
|
||||||
|
>
|
||||||
|
Turquoise
|
||||||
|
</Button>
|
||||||
|
</Space>
|
||||||
|
</Space>
|
||||||
</SettingListItem>
|
</SettingListItem>
|
||||||
</>
|
</>
|
||||||
),
|
),
|
||||||
@@ -472,17 +479,6 @@ export default function HappSettingsContent({
|
|||||||
),
|
),
|
||||||
children: (
|
children: (
|
||||||
<>
|
<>
|
||||||
<SettingListItem
|
|
||||||
paddings="small"
|
|
||||||
title={t('pages.settings.subHappAutoDetect')}
|
|
||||||
description={t('pages.settings.subHappAutoDetectDesc')}
|
|
||||||
>
|
|
||||||
<Switch
|
|
||||||
checked={allSetting.subHappAutoDetect}
|
|
||||||
onChange={(v) => updateSetting({ subHappAutoDetect: v })}
|
|
||||||
/>
|
|
||||||
</SettingListItem>
|
|
||||||
|
|
||||||
<SettingListItem
|
<SettingListItem
|
||||||
paddings="small"
|
paddings="small"
|
||||||
title={t('pages.settings.subHappProviderId')}
|
title={t('pages.settings.subHappProviderId')}
|
||||||
|
|||||||
@@ -25,24 +25,15 @@ export function buildHappPresetDeeplink(preset: string): string {
|
|||||||
'happ://routing/onadd/' +
|
'happ://routing/onadd/' +
|
||||||
toBase64Utf8(
|
toBase64Utf8(
|
||||||
JSON.stringify({
|
JSON.stringify({
|
||||||
rules: [
|
Name: 'Iran Bypass',
|
||||||
{
|
GlobalProxy: 'true',
|
||||||
type: 'field',
|
DirectSites: ['domain:ir', 'regexp:.*\\.ir$'],
|
||||||
outboundTag: 'direct',
|
DirectIp: ['geoip:ir', '10.0.0.0/8', '172.16.0.0/12', '192.168.0.0/16'],
|
||||||
domain: ['domain:ir', 'regexp:.*\\.ir$'],
|
BlockSites: ['geosite:category-ads-all'],
|
||||||
ip: ['geoip:ir', 'geoip:private'],
|
BlockIp: [],
|
||||||
},
|
ProxySites: [],
|
||||||
{
|
ProxyIp: [],
|
||||||
type: 'field',
|
DomainStrategy: 'IPIfNonMatch',
|
||||||
outboundTag: 'block',
|
|
||||||
domain: ['geosite:category-ads-all'],
|
|
||||||
},
|
|
||||||
{
|
|
||||||
type: 'field',
|
|
||||||
outboundTag: 'proxy',
|
|
||||||
network: 'tcp,udp',
|
|
||||||
},
|
|
||||||
],
|
|
||||||
}),
|
}),
|
||||||
)
|
)
|
||||||
);
|
);
|
||||||
@@ -51,24 +42,15 @@ export function buildHappPresetDeeplink(preset: string): string {
|
|||||||
'happ://routing/onadd/' +
|
'happ://routing/onadd/' +
|
||||||
toBase64Utf8(
|
toBase64Utf8(
|
||||||
JSON.stringify({
|
JSON.stringify({
|
||||||
rules: [
|
Name: 'China Direct',
|
||||||
{
|
GlobalProxy: 'true',
|
||||||
type: 'field',
|
DirectSites: ['geosite:cn', 'geosite:geolocation-cn'],
|
||||||
outboundTag: 'direct',
|
DirectIp: ['geoip:cn', '10.0.0.0/8', '172.16.0.0/12', '192.168.0.0/16'],
|
||||||
domain: ['domain:cn', 'geosite:cn'],
|
BlockSites: ['geosite:category-ads-all'],
|
||||||
ip: ['geoip:cn', 'geoip:private'],
|
BlockIp: [],
|
||||||
},
|
ProxySites: [],
|
||||||
{
|
ProxyIp: [],
|
||||||
type: 'field',
|
DomainStrategy: 'IPIfNonMatch',
|
||||||
outboundTag: 'block',
|
|
||||||
domain: ['geosite:category-ads-all'],
|
|
||||||
},
|
|
||||||
{
|
|
||||||
type: 'field',
|
|
||||||
outboundTag: 'proxy',
|
|
||||||
network: 'tcp,udp',
|
|
||||||
},
|
|
||||||
],
|
|
||||||
}),
|
}),
|
||||||
)
|
)
|
||||||
);
|
);
|
||||||
@@ -77,23 +59,15 @@ export function buildHappPresetDeeplink(preset: string): string {
|
|||||||
'happ://routing/onadd/' +
|
'happ://routing/onadd/' +
|
||||||
toBase64Utf8(
|
toBase64Utf8(
|
||||||
JSON.stringify({
|
JSON.stringify({
|
||||||
rules: [
|
Name: 'AdBlock',
|
||||||
{
|
GlobalProxy: 'true',
|
||||||
type: 'field',
|
DirectSites: [],
|
||||||
outboundTag: 'block',
|
DirectIp: ['10.0.0.0/8', '172.16.0.0/12', '192.168.0.0/16'],
|
||||||
domain: ['geosite:category-ads-all'],
|
BlockSites: ['geosite:category-ads-all'],
|
||||||
},
|
BlockIp: [],
|
||||||
{
|
ProxySites: [],
|
||||||
type: 'field',
|
ProxyIp: [],
|
||||||
outboundTag: 'direct',
|
DomainStrategy: 'IPIfNonMatch',
|
||||||
ip: ['geoip:private'],
|
|
||||||
},
|
|
||||||
{
|
|
||||||
type: 'field',
|
|
||||||
outboundTag: 'proxy',
|
|
||||||
network: 'tcp,udp',
|
|
||||||
},
|
|
||||||
],
|
|
||||||
}),
|
}),
|
||||||
)
|
)
|
||||||
);
|
);
|
||||||
@@ -102,13 +76,15 @@ export function buildHappPresetDeeplink(preset: string): string {
|
|||||||
'happ://routing/onadd/' +
|
'happ://routing/onadd/' +
|
||||||
toBase64Utf8(
|
toBase64Utf8(
|
||||||
JSON.stringify({
|
JSON.stringify({
|
||||||
rules: [
|
Name: 'Global Proxy',
|
||||||
{
|
GlobalProxy: 'true',
|
||||||
type: 'field',
|
DirectSites: [],
|
||||||
outboundTag: 'proxy',
|
DirectIp: [],
|
||||||
network: 'tcp,udp',
|
BlockSites: [],
|
||||||
},
|
BlockIp: [],
|
||||||
],
|
ProxySites: [],
|
||||||
|
ProxyIp: [],
|
||||||
|
DomainStrategy: 'AsIs',
|
||||||
}),
|
}),
|
||||||
)
|
)
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -22,19 +22,11 @@ describe('Happ presets and helpers', () => {
|
|||||||
const jsonStr = atob(b64);
|
const jsonStr = atob(b64);
|
||||||
const parsed = JSON.parse(jsonStr);
|
const parsed = JSON.parse(jsonStr);
|
||||||
|
|
||||||
expect(parsed).toHaveProperty('rules');
|
expect(parsed.Name).toBe('Iran Bypass');
|
||||||
expect(Array.isArray(parsed.rules)).toBe(true);
|
expect(parsed.GlobalProxy).toBe('true');
|
||||||
|
expect(parsed.DirectSites).toContain('domain:ir');
|
||||||
const directRule = parsed.rules.find(
|
expect(parsed.DirectIp).toContain('geoip:ir');
|
||||||
(r: { outboundTag: string }) => r.outboundTag === 'direct',
|
expect(parsed.BlockSites).toContain('geosite:category-ads-all');
|
||||||
);
|
|
||||||
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', () => {
|
it('generates valid base64 payload for china-direct preset', () => {
|
||||||
@@ -45,11 +37,9 @@ describe('Happ presets and helpers', () => {
|
|||||||
const jsonStr = atob(b64);
|
const jsonStr = atob(b64);
|
||||||
const parsed = JSON.parse(jsonStr);
|
const parsed = JSON.parse(jsonStr);
|
||||||
|
|
||||||
const directRule = parsed.rules.find(
|
expect(parsed.Name).toBe('China Direct');
|
||||||
(r: { outboundTag: string }) => r.outboundTag === 'direct',
|
expect(parsed.DirectSites).toContain('geosite:cn');
|
||||||
);
|
expect(parsed.DirectIp).toContain('geoip:cn');
|
||||||
expect(directRule.domain).toContain('domain:cn');
|
|
||||||
expect(directRule.ip).toContain('geoip:cn');
|
|
||||||
});
|
});
|
||||||
|
|
||||||
it('generates valid base64 payload for adblock preset', () => {
|
it('generates valid base64 payload for adblock preset', () => {
|
||||||
@@ -58,8 +48,8 @@ describe('Happ presets and helpers', () => {
|
|||||||
const jsonStr = atob(b64);
|
const jsonStr = atob(b64);
|
||||||
const parsed = JSON.parse(jsonStr);
|
const parsed = JSON.parse(jsonStr);
|
||||||
|
|
||||||
const blockRule = parsed.rules.find((r: { outboundTag: string }) => r.outboundTag === 'block');
|
expect(parsed.Name).toBe('AdBlock');
|
||||||
expect(blockRule.domain).toContain('geosite:category-ads-all');
|
expect(parsed.BlockSites).toContain('geosite:category-ads-all');
|
||||||
});
|
});
|
||||||
|
|
||||||
it('generates valid base64 payload for global preset', () => {
|
it('generates valid base64 payload for global preset', () => {
|
||||||
@@ -68,8 +58,8 @@ describe('Happ presets and helpers', () => {
|
|||||||
const jsonStr = atob(b64);
|
const jsonStr = atob(b64);
|
||||||
const parsed = JSON.parse(jsonStr);
|
const parsed = JSON.parse(jsonStr);
|
||||||
|
|
||||||
expect(parsed.rules[0].outboundTag).toBe('proxy');
|
expect(parsed.Name).toBe('Global Proxy');
|
||||||
expect(parsed.rules[0].network).toBe('tcp,udp');
|
expect(parsed.DomainStrategy).toBe('AsIs');
|
||||||
});
|
});
|
||||||
|
|
||||||
it('encodes unicode properly via toBase64Utf8', () => {
|
it('encodes unicode properly via toBase64Utf8', () => {
|
||||||
|
|||||||
@@ -69,4 +69,25 @@ describe('Happ TUN Mode select', () => {
|
|||||||
const select = selectFor('TUN Mode');
|
const select = selectFor('TUN Mode');
|
||||||
expect(select.querySelector('.ant-select-content')?.textContent).toBe('Default');
|
expect(select.querySelector('.ant-select-content')?.textContent).toBe('Default');
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it('renders Auto-Detection master switch at the top and triggers update', () => {
|
||||||
|
const updateSetting = vi.fn();
|
||||||
|
const allSetting = new AllSetting();
|
||||||
|
allSetting.subHappAutoDetect = false;
|
||||||
|
|
||||||
|
renderWithProviders(
|
||||||
|
<HappSettingsContent
|
||||||
|
allSetting={allSetting}
|
||||||
|
updateSetting={updateSetting}
|
||||||
|
isMobile={false}
|
||||||
|
remoteSourceBadge={() => null}
|
||||||
|
/>,
|
||||||
|
);
|
||||||
|
|
||||||
|
const switchBtn = document.querySelector('.ant-switch');
|
||||||
|
if (!switchBtn) throw new Error('switch not found');
|
||||||
|
fireEvent.click(switchBtn);
|
||||||
|
|
||||||
|
expect(updateSetting).toHaveBeenCalledWith({ subHappAutoDetect: true });
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -1,6 +1,7 @@
|
|||||||
package sub
|
package sub
|
||||||
|
|
||||||
import (
|
import (
|
||||||
|
"encoding/base64"
|
||||||
"strings"
|
"strings"
|
||||||
|
|
||||||
"github.com/mhsanaei/3x-ui/v3/internal/database/model"
|
"github.com/mhsanaei/3x-ui/v3/internal/database/model"
|
||||||
@@ -20,10 +21,11 @@ import (
|
|||||||
// because three behaviors branch on the raw string (keep-base, obj["tls"]
|
// because three behaviors branch on the raw string (keep-base, obj["tls"]
|
||||||
// rewrite, none-strip).
|
// rewrite, none-strip).
|
||||||
type ShareEndpoint struct {
|
type ShareEndpoint struct {
|
||||||
Address string
|
Address string
|
||||||
Port int
|
Port int
|
||||||
Remark string // extra remark slot fed to genRemark, not a rendered remark
|
Remark string // extra remark slot fed to genRemark, not a rendered remark
|
||||||
ForceTls string
|
ServerDescription string // subtitle caption displayed in Happ client
|
||||||
|
ForceTls string
|
||||||
|
|
||||||
// ep is the source externalProxy entry. nil for host/default endpoints.
|
// ep is the source externalProxy entry. nil for host/default endpoints.
|
||||||
ep map[string]any
|
ep map[string]any
|
||||||
@@ -38,6 +40,7 @@ func externalProxyToEndpoint(ep map[string]any) ShareEndpoint {
|
|||||||
e.Port = int(p)
|
e.Port = int(p)
|
||||||
}
|
}
|
||||||
e.Remark, _ = ep["remark"].(string)
|
e.Remark, _ = ep["remark"].(string)
|
||||||
|
e.ServerDescription, _ = ep["serverDescription"].(string)
|
||||||
e.ForceTls, _ = ep["forceTls"].(string)
|
e.ForceTls, _ = ep["forceTls"].(string)
|
||||||
return e
|
return e
|
||||||
}
|
}
|
||||||
@@ -106,10 +109,14 @@ func (s *SubService) buildEndpointLinks(
|
|||||||
applyEndpointHostPath(e, nextParams)
|
applyEndpointHostPath(e, nextParams)
|
||||||
applyEndpointFinalMask(e, nextParams)
|
applyEndpointFinalMask(e, nextParams)
|
||||||
applyEndpointAllowInsecure(e, nextParams, securityToApply)
|
applyEndpointAllowInsecure(e, nextParams, securityToApply)
|
||||||
|
remark := makeRemark(e)
|
||||||
|
if e.ServerDescription != "" {
|
||||||
|
remark = appendHappServerDescription(remark, e.ServerDescription)
|
||||||
|
}
|
||||||
links = append(links, buildLinkWithParamsAndSecurity(
|
links = append(links, buildLinkWithParamsAndSecurity(
|
||||||
makeLink(e),
|
makeLink(e),
|
||||||
nextParams,
|
nextParams,
|
||||||
makeRemark(e),
|
remark,
|
||||||
securityToApply,
|
securityToApply,
|
||||||
e.ForceTls == "none",
|
e.ForceTls == "none",
|
||||||
))
|
))
|
||||||
@@ -117,6 +124,14 @@ func (s *SubService) buildEndpointLinks(
|
|||||||
return strings.Join(links, "\n")
|
return strings.Join(links, "\n")
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func appendHappServerDescription(remark, desc string) string {
|
||||||
|
if desc == "" {
|
||||||
|
return remark
|
||||||
|
}
|
||||||
|
encoded := base64.StdEncoding.EncodeToString([]byte(desc))
|
||||||
|
return remark + "?serverDescription=" + encoded
|
||||||
|
}
|
||||||
|
|
||||||
// buildEndpointVmessLinks renders one VMess base64-JSON link per endpoint.
|
// buildEndpointVmessLinks renders one VMess base64-JSON link per endpoint.
|
||||||
func (s *SubService) buildEndpointVmessLinks(eps []ShareEndpoint, baseObj map[string]any, inbound *model.Inbound, email string, transport string) string {
|
func (s *SubService) buildEndpointVmessLinks(eps []ShareEndpoint, baseObj map[string]any, inbound *model.Inbound, email string, transport string) string {
|
||||||
var links strings.Builder
|
var links strings.Builder
|
||||||
@@ -132,6 +147,9 @@ func (s *SubService) buildEndpointVmessLinks(eps []ShareEndpoint, baseObj map[st
|
|||||||
if e.ForceTls != "same" {
|
if e.ForceTls != "same" {
|
||||||
newObj["tls"] = e.ForceTls
|
newObj["tls"] = e.ForceTls
|
||||||
}
|
}
|
||||||
|
if e.ServerDescription != "" {
|
||||||
|
newObj["serverDescription"] = e.ServerDescription
|
||||||
|
}
|
||||||
applyEndpointTLSObj(e, newObj, securityToApply)
|
applyEndpointTLSObj(e, newObj, securityToApply)
|
||||||
applyEndpointHostPathObj(e, newObj)
|
applyEndpointHostPathObj(e, newObj)
|
||||||
applyEndpointFinalMaskObj(e, newObj)
|
applyEndpointFinalMaskObj(e, newObj)
|
||||||
|
|||||||
@@ -116,8 +116,8 @@ func TestBuildEndpointVmessLinks(t *testing.T) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// happ.su documents serverDescription as a "#title?serverDescription=<base64>"
|
// happ.su documents serverDescription for VMess as a JSON object key
|
||||||
// link parameter, never a key of the VMess object, so nothing may leak into it.
|
// {"add":"...","ps":"...","serverDescription":"Happ the best"}.
|
||||||
func TestBuildEndpointVmessLinks_HostServerDescription(t *testing.T) {
|
func TestBuildEndpointVmessLinks_HostServerDescription(t *testing.T) {
|
||||||
s := &SubService{}
|
s := &SubService{}
|
||||||
in := &model.Inbound{Remark: "ib"}
|
in := &model.Inbound{Remark: "ib"}
|
||||||
@@ -137,8 +137,8 @@ func TestBuildEndpointVmessLinks_HostServerDescription(t *testing.T) {
|
|||||||
if obj["add"] != "a.example.com" {
|
if obj["add"] != "a.example.com" {
|
||||||
t.Fatalf("host endpoint not applied: add = %v", obj["add"])
|
t.Fatalf("host endpoint not applied: add = %v", obj["add"])
|
||||||
}
|
}
|
||||||
if value, ok := obj["serverDescription"]; ok {
|
if obj["serverDescription"] != "Berlin premium" {
|
||||||
t.Fatalf("VMess object carries serverDescription = %v; it is not a VMess object key", value)
|
t.Fatalf("VMess object missing serverDescription: got %v, want Berlin premium", obj["serverDescription"])
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
+11
-8
@@ -41,21 +41,25 @@ func IsHappClient(userAgent string) bool {
|
|||||||
return happUserAgentRegex.MatchString(userAgent)
|
return happUserAgentRegex.MatchString(userAgent)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func sanitizeHeaderValue(v string) string {
|
||||||
|
return strings.ReplaceAll(strings.ReplaceAll(strings.TrimSpace(v), "\r", ""), "\n", "")
|
||||||
|
}
|
||||||
|
|
||||||
// ApplyHappHeaders sets standard and advanced Happ subscription headers.
|
// ApplyHappHeaders sets standard and advanced Happ subscription headers.
|
||||||
func ApplyHappHeaders(c *gin.Context, cfg HappConfig, isHapp bool) {
|
func ApplyHappHeaders(c *gin.Context, cfg HappConfig, isHapp bool) {
|
||||||
if c == nil || c.Writer == nil || !cfg.AutoDetect || !isHapp {
|
if c == nil || c.Writer == nil || !cfg.AutoDetect || !isHapp {
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
if cfg.ProviderId != "" {
|
if cfg.ProviderId != "" {
|
||||||
c.Writer.Header().Set("ProviderID", cfg.ProviderId)
|
c.Writer.Header().Set("ProviderID", strings.TrimSpace(cfg.ProviderId))
|
||||||
}
|
}
|
||||||
if cfg.NewUrl != "" {
|
if cfg.NewUrl != "" {
|
||||||
c.Writer.Header().Set("New-Url", cfg.NewUrl)
|
c.Writer.Header().Set("New-Url", strings.TrimSpace(cfg.NewUrl))
|
||||||
}
|
}
|
||||||
if cfg.FallbackUrl != "" {
|
if cfg.FallbackUrl != "" {
|
||||||
c.Writer.Header().Set("Fallback-Url", cfg.FallbackUrl)
|
c.Writer.Header().Set("Fallback-Url", strings.TrimSpace(cfg.FallbackUrl))
|
||||||
}
|
}
|
||||||
if text := strings.TrimSpace(cfg.SubInfoText); text != "" {
|
if text := sanitizeHeaderValue(cfg.SubInfoText); text != "" {
|
||||||
color := strings.TrimSpace(cfg.SubInfoColor)
|
color := strings.TrimSpace(cfg.SubInfoColor)
|
||||||
switch strings.ToLower(color) {
|
switch strings.ToLower(color) {
|
||||||
case "primary", "info":
|
case "primary", "info":
|
||||||
@@ -69,10 +73,10 @@ func ApplyHappHeaders(c *gin.Context, cfg HappConfig, isHapp bool) {
|
|||||||
}
|
}
|
||||||
c.Writer.Header().Set("Sub-Info-Color", color)
|
c.Writer.Header().Set("Sub-Info-Color", color)
|
||||||
c.Writer.Header().Set("Sub-Info-Text", text)
|
c.Writer.Header().Set("Sub-Info-Text", text)
|
||||||
if btnText := strings.TrimSpace(cfg.SubInfoButtonText); btnText != "" {
|
if btnText := sanitizeHeaderValue(cfg.SubInfoButtonText); btnText != "" {
|
||||||
c.Writer.Header().Set("Sub-Info-Button-Text", btnText)
|
c.Writer.Header().Set("Sub-Info-Button-Text", btnText)
|
||||||
}
|
}
|
||||||
if btnLink := strings.TrimSpace(cfg.SubInfoButtonLink); btnLink != "" {
|
if btnLink := sanitizeHeaderValue(cfg.SubInfoButtonLink); btnLink != "" {
|
||||||
c.Writer.Header().Set("Sub-Info-Button-Link", btnLink)
|
c.Writer.Header().Set("Sub-Info-Button-Link", btnLink)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -103,8 +107,7 @@ func ApplyHappHeaders(c *gin.Context, cfg HappConfig, isHapp bool) {
|
|||||||
if cfg.ExcludeApns {
|
if cfg.ExcludeApns {
|
||||||
c.Writer.Header().Set("Exclude-Apns-Enable", "true")
|
c.Writer.Header().Set("Exclude-Apns-Enable", "true")
|
||||||
}
|
}
|
||||||
if profile := strings.TrimSpace(cfg.ColorProfile); profile != "" {
|
if profile := sanitizeHeaderValue(cfg.ColorProfile); profile != "" {
|
||||||
profile = strings.ReplaceAll(strings.ReplaceAll(profile, "\r", ""), "\n", "")
|
|
||||||
c.Writer.Header().Set("Color-Profile", profile)
|
c.Writer.Header().Set("Color-Profile", profile)
|
||||||
}
|
}
|
||||||
if ping := strings.TrimSpace(cfg.PingType); ping != "" {
|
if ping := strings.TrimSpace(cfg.PingType); ping != "" {
|
||||||
|
|||||||
+124
-1
@@ -1,8 +1,10 @@
|
|||||||
package sub
|
package sub
|
||||||
|
|
||||||
import (
|
import (
|
||||||
|
"encoding/base64"
|
||||||
"net/http"
|
"net/http"
|
||||||
"net/http/httptest"
|
"net/http/httptest"
|
||||||
|
"strings"
|
||||||
"testing"
|
"testing"
|
||||||
|
|
||||||
"github.com/gin-gonic/gin"
|
"github.com/gin-gonic/gin"
|
||||||
@@ -53,12 +55,15 @@ func TestApplyCommonHeaders_HappClientHeaders(t *testing.T) {
|
|||||||
ctx.Request = httptest.NewRequest(http.MethodGet, "/sub/test", nil)
|
ctx.Request = httptest.NewRequest(http.MethodGet, "/sub/test", nil)
|
||||||
ctx.Request.Header.Set("User-Agent", "Happ/1.2.0 (iPhone; iOS 17.5)")
|
ctx.Request.Header.Set("User-Agent", "Happ/1.2.0 (iPhone; iOS 17.5)")
|
||||||
|
|
||||||
controller.ApplyCommonHeaders(ctx, "upload=0; download=100; total=1000; expire=1800000000", "12", "MyTitle", "", "", "", false, "", false)
|
controller.ApplyCommonHeaders(ctx, "upload=0; download=100; total=1000; expire=1800000000", "12", "MyTitle", "", "", "", false, "happ://routing/onadd/existing-rules", false)
|
||||||
|
|
||||||
h := recorder.Header()
|
h := recorder.Header()
|
||||||
if h.Get("Routing-Enable") != "0" {
|
if h.Get("Routing-Enable") != "0" {
|
||||||
t.Fatalf("Routing-Enable = %q, want 0 for Happ with disabled routing", h.Get("Routing-Enable"))
|
t.Fatalf("Routing-Enable = %q, want 0 for Happ with disabled routing", h.Get("Routing-Enable"))
|
||||||
}
|
}
|
||||||
|
if h.Get("Routing") != "happ://routing/onadd/existing-rules" {
|
||||||
|
t.Fatalf("Routing = %q, want happ://routing/onadd/existing-rules for Happ with configured routing", h.Get("Routing"))
|
||||||
|
}
|
||||||
if h.Get("Hide-Settings") != "0" {
|
if h.Get("Hide-Settings") != "0" {
|
||||||
t.Fatalf("Hide-Settings = %q, want 0 for Happ with disabled hideSettings", h.Get("Hide-Settings"))
|
t.Fatalf("Hide-Settings = %q, want 0 for Happ with disabled hideSettings", h.Get("Hide-Settings"))
|
||||||
}
|
}
|
||||||
@@ -109,6 +114,28 @@ func TestApplyCommonHeaders_HappClientHeaders(t *testing.T) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func TestApplyCommonHeaders_HappRoutingOffDeeplink(t *testing.T) {
|
||||||
|
gin.SetMode(gin.TestMode)
|
||||||
|
|
||||||
|
cfg := HappConfig{AutoDetect: true}
|
||||||
|
controller := &SUBController{happConfig: cfg}
|
||||||
|
recorder := httptest.NewRecorder()
|
||||||
|
ctx, _ := gin.CreateTestContext(recorder)
|
||||||
|
ctx.Request = httptest.NewRequest(http.MethodGet, "/sub/test", nil)
|
||||||
|
ctx.Request.Header.Set("User-Agent", "Happ/1.2.0 (iPhone)")
|
||||||
|
|
||||||
|
// When rules is happ://routing/off, Routing-Enable is 0 and Routing is happ://routing/off
|
||||||
|
controller.ApplyCommonHeaders(ctx, "", "", "Title", "", "", "", false, "happ://routing/off", false)
|
||||||
|
|
||||||
|
h := recorder.Header()
|
||||||
|
if h.Get("Routing-Enable") != "0" {
|
||||||
|
t.Fatalf("Routing-Enable = %q, want 0 when rules is happ://routing/off", h.Get("Routing-Enable"))
|
||||||
|
}
|
||||||
|
if h.Get("Routing") != "happ://routing/off" {
|
||||||
|
t.Fatalf("Routing = %q, want happ://routing/off", h.Get("Routing"))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
func TestApplyHappHeaders_Gating(t *testing.T) {
|
func TestApplyHappHeaders_Gating(t *testing.T) {
|
||||||
gin.SetMode(gin.TestMode)
|
gin.SetMode(gin.TestMode)
|
||||||
|
|
||||||
@@ -165,6 +192,9 @@ func TestApplyHappHeaders_Gating(t *testing.T) {
|
|||||||
if got := recorder.Header().Get("Hide-Settings"); got != "" {
|
if got := recorder.Header().Get("Hide-Settings"); got != "" {
|
||||||
t.Fatalf("Hide-Settings emitted when AutoDetect is false: %q", got)
|
t.Fatalf("Hide-Settings emitted when AutoDetect is false: %q", got)
|
||||||
}
|
}
|
||||||
|
if got := recorder.Header().Get("Routing"); got != "" {
|
||||||
|
t.Fatalf("Routing emitted when AutoDetect is false: %q", got)
|
||||||
|
}
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -225,3 +255,96 @@ func TestApplyHappHeaders_Aliases(t *testing.T) {
|
|||||||
})
|
})
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func TestAppendHappServerDescription(t *testing.T) {
|
||||||
|
desc := "VIP Server"
|
||||||
|
encoded := base64.StdEncoding.EncodeToString([]byte(desc))
|
||||||
|
|
||||||
|
got := appendHappServerDescription("My Node", desc)
|
||||||
|
want := "My Node?serverDescription=" + encoded
|
||||||
|
if got != want {
|
||||||
|
t.Fatalf("appendHappServerDescription = %q, want %q", got, want)
|
||||||
|
}
|
||||||
|
|
||||||
|
if gotEmpty := appendHappServerDescription("My Node", ""); gotEmpty != "My Node" {
|
||||||
|
t.Fatalf("appendHappServerDescription with empty desc = %q, want My Node", gotEmpty)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestAppendQueryAndFragment_PreservesServerDescription(t *testing.T) {
|
||||||
|
desc := "Fast Server"
|
||||||
|
encoded := base64.StdEncoding.EncodeToString([]byte(desc))
|
||||||
|
|
||||||
|
t.Run("preserves serverDescription with encoded title", func(t *testing.T) {
|
||||||
|
fragment := "Server 01?serverDescription=" + encoded
|
||||||
|
link := appendQueryAndFragment("vless://user@host:443", nil, fragment, "", false)
|
||||||
|
want := "vless://user@host:443#Server%2001?serverDescription=" + encoded
|
||||||
|
if link != want {
|
||||||
|
t.Fatalf("appendQueryAndFragment = %q, want %q", link, want)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
t.Run("properly escapes remark containing literal question mark without serverDescription", func(t *testing.T) {
|
||||||
|
fragment := "Fast? Server"
|
||||||
|
link := appendQueryAndFragment("vless://user@host:443", nil, fragment, "", false)
|
||||||
|
want := "vless://user@host:443#Fast%3F%20Server"
|
||||||
|
if link != want {
|
||||||
|
t.Fatalf("appendQueryAndFragment = %q, want %q", link, want)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
t.Run("properly escapes remark containing literal question mark with serverDescription", func(t *testing.T) {
|
||||||
|
fragment := "Fast? Server?serverDescription=" + encoded
|
||||||
|
link := appendQueryAndFragment("vless://user@host:443", nil, fragment, "", false)
|
||||||
|
want := "vless://user@host:443#Fast%3F%20Server?serverDescription=" + encoded
|
||||||
|
if link != want {
|
||||||
|
t.Fatalf("appendQueryAndFragment = %q, want %q", link, want)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
t.Run("escapes fragment when serverDescription tail contains invalid base64", func(t *testing.T) {
|
||||||
|
fragment := "Node 1?serverDescription=not-base64!!!"
|
||||||
|
link := appendQueryAndFragment("vless://user@host:443", nil, fragment, "", false)
|
||||||
|
want := "vless://user@host:443#Node%201%3FserverDescription%3Dnot-base64%21%21%21"
|
||||||
|
if link != want {
|
||||||
|
t.Fatalf("appendQueryAndFragment = %q, want %q", link, want)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
t.Run("escapes fragment when serverDescription tail contains newline injection", func(t *testing.T) {
|
||||||
|
fragment := "Node 1?serverDescription=" + encoded + "\nevil://inject"
|
||||||
|
link := appendQueryAndFragment("vless://user@host:443", nil, fragment, "", false)
|
||||||
|
if strings.Contains(link, "\n") {
|
||||||
|
t.Fatalf("appendQueryAndFragment emitted raw newline: %q", link)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestIsHappClient(t *testing.T) {
|
||||||
|
matching := []string{
|
||||||
|
"Happ/1.2.0 (iPhone; iOS 17.5)",
|
||||||
|
"happ/2.0",
|
||||||
|
"happ",
|
||||||
|
"HAPP/1.0",
|
||||||
|
"Mozilla/5.0 Happ/1.0",
|
||||||
|
}
|
||||||
|
for _, ua := range matching {
|
||||||
|
if !IsHappClient(ua) {
|
||||||
|
t.Errorf("IsHappClient(%q) = false, want true", ua)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
nonMatching := []string{
|
||||||
|
"Happy/2.0",
|
||||||
|
"happier-client",
|
||||||
|
"Happening/1.0",
|
||||||
|
"unhappy",
|
||||||
|
"v2rayNG/1.8.5",
|
||||||
|
"",
|
||||||
|
}
|
||||||
|
for _, ua := range nonMatching {
|
||||||
|
if IsHappClient(ua) {
|
||||||
|
t.Errorf("IsHappClient(%q) = true, want false", ua)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
@@ -107,6 +107,9 @@ func hostToExternalProxyMap(h *model.Host, defaultDest string, defaultPort int)
|
|||||||
if h.VlessRoute != "" {
|
if h.VlessRoute != "" {
|
||||||
ep["vlessRoute"] = h.VlessRoute
|
ep["vlessRoute"] = h.VlessRoute
|
||||||
}
|
}
|
||||||
|
if h.ServerDescription != "" {
|
||||||
|
ep["serverDescription"] = h.ServerDescription
|
||||||
|
}
|
||||||
return ep
|
return ep
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
+12
-2
@@ -2271,8 +2271,18 @@ func appendQueryAndFragment(link string, params map[string]string, fragment, sec
|
|||||||
|
|
||||||
if fragment != "" {
|
if fragment != "" {
|
||||||
sb.WriteByte('#')
|
sb.WriteByte('#')
|
||||||
// Match the frontend's encodeURIComponent(remark): spaces become %20.
|
if before, after, ok := strings.Cut(fragment, "?serverDescription="); ok {
|
||||||
sb.WriteString(strings.ReplaceAll(url.QueryEscape(fragment), "+", "%20"))
|
if _, err := base64.StdEncoding.DecodeString(after); err == nil && len(after) > 0 && !strings.ContainsAny(after, " \r\n\t#&") {
|
||||||
|
sb.WriteString(strings.ReplaceAll(url.QueryEscape(before), "+", "%20"))
|
||||||
|
sb.WriteString("?serverDescription=")
|
||||||
|
sb.WriteString(after)
|
||||||
|
} else {
|
||||||
|
sb.WriteString(strings.ReplaceAll(url.QueryEscape(fragment), "+", "%20"))
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
// Match the frontend's encodeURIComponent(remark): spaces become %20.
|
||||||
|
sb.WriteString(strings.ReplaceAll(url.QueryEscape(fragment), "+", "%20"))
|
||||||
|
}
|
||||||
}
|
}
|
||||||
return sb.String()
|
return sb.String()
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user