sub: add support for custom subscription headers

This commit is contained in:
SashaGonch228 2026-10-11 14:36:39 +05:00
parent 8586674477
commit 9637e006c8
3 changed files with 139 additions and 0 deletions

View file

@ -91,6 +91,7 @@ var defaultValueMap = map[string]string{
"subURI": "",
"subJsonExt": "",
"subClashExt": "",
"subCustomHeaders": defaultCustomHeaders,
"subClashNoDefGrp": "false",
"subClashSprtAll": "false",
"subClashUdp": "false",
@ -540,6 +541,10 @@ func (s *SettingService) GetSubClashExt() (string, error) {
return s.getString("subClashExt")
}
func (s *SettingService) GetSubCustomHeaders() (string, error) {
return s.getString("subCustomHeaders")
}
// GetSubClashNoDefGrp reports whether the default "Proxy"/"Auto" proxy-groups
// should never be injected into a Clash subscription. When true, the config is
// left with exactly the groups the user defined.
@ -576,3 +581,13 @@ func (s *SettingService) fileExists(path string) error {
_, err := os.Stat(path)
return err
}
var defaultCustomHeaders = `# Custom Subscription Headers
# Syntax:
# Header-Name: Value
#
# Lines starting with # are comments and will be ignored.
#
# Example:
# announce: 42!
# announce-url: https://42.example/`

View file

@ -79,6 +79,52 @@ func (s *SubHandler) addHeaders(c *gin.Context, headers []string) {
c.Writer.Header().Set("Profile-Update-Interval", headers[1])
c.Writer.Header().Set("Profile-Title", headers[2])
c.Writer.Header().Set("Content-Disposition", contentDispositionHeader(headers[2]))
s.applyCustomHeaders(c)
}
type customHeader struct {
key string
value string
}
func parseCustomHeaders(raw string) []customHeader {
if len(strings.TrimSpace(raw)) == 0 {
return nil
}
var headers []customHeader
lines := strings.Split(strings.ReplaceAll(raw, "\r\n", "\n"), "\n")
for _, line := range lines {
line = strings.TrimSpace(line)
if line == "" || strings.HasPrefix(line, "#") || strings.HasPrefix(line, "//") {
continue
}
parts := strings.SplitN(line, ":", 2)
if len(parts) != 2 {
continue
}
key := strings.TrimSpace(parts[0])
val := strings.TrimSpace(parts[1])
if key != "" && val != "" {
headers = append(headers, customHeader{key: key, value: val})
}
}
return headers
}
func (s *SubHandler) applyCustomHeaders(c *gin.Context) {
customHeadersStr, err := s.SettingService.GetSubCustomHeaders()
if err != nil {
return
}
for _, h := range parseCustomHeaders(customHeadersStr) {
c.Writer.Header().Set(h.key, h.value)
}
}
func contentDispositionHeader(name string) string {

78
sub/subHandler_test.go Normal file
View file

@ -0,0 +1,78 @@
package sub
import (
"reflect"
"testing"
)
func TestParseCustomHeaders(t *testing.T) {
testCases := []struct {
name string
in string
want []customHeader
}{
{
name: "empty string",
in: "",
want: nil,
},
{
name: "whitespace only",
in: " \n\t \r\n ",
want: nil,
},
{
name: "comments test",
in: `# 424242`,
want: nil,
},
{
name: "valid headers with extra spaces",
in: `
announce : 42!
announce-url: t.me/4242
`,
want: []customHeader{
{key: "announce", value: "42!"},
{key: "announce-url", value: "t.me/4242"},
},
},
{
name: "urls containing colons and ports are preserved",
in: "Profile-Web-Page-Url: https://sub.example.com:8443/login?redirect=https://a.com",
want: []customHeader{
{key: "Profile-Web-Page-Url", value: "https://sub.example.com:8443/login?redirect=https://a.com"},
},
},
{
name: "windows crlf line endings and inline comments",
in: "# header 1\r\nX-Custom: value1\r\n// comment 2\r\nX-Other: value2\r\n",
want: []customHeader{
{key: "X-Custom", value: "value1"},
{key: "X-Other", value: "value2"},
},
},
{
name: "malformed lines are safely skipped without panic",
in: `
random text without colon
: missing key
empty value:
:
valid-key: ok
`,
want: []customHeader{
{key: "valid-key", value: "ok"},
},
},
}
for _, tc := range testCases {
t.Run(tc.name, func(t *testing.T) {
got := parseCustomHeaders(tc.in)
if !reflect.DeepEqual(got, tc.want) {
t.Errorf("parseCustomHeaders() mismatch for %q:\n got: %+v\n want: %+v", tc.name, got, tc.want)
}
})
}
}