From 9637e006c88dbd1130ee72f87f2aa993fbf8b06c Mon Sep 17 00:00:00 2001 From: SashaGonch228 Date: Sun, 11 Oct 2026 14:36:39 +0500 Subject: [PATCH] sub: add support for custom subscription headers --- service/setting.go | 15 ++++++++ sub/subHandler.go | 46 +++++++++++++++++++++++++ sub/subHandler_test.go | 78 ++++++++++++++++++++++++++++++++++++++++++ 3 files changed, 139 insertions(+) create mode 100644 sub/subHandler_test.go diff --git a/service/setting.go b/service/setting.go index a47d63f..2cf5ded 100644 --- a/service/setting.go +++ b/service/setting.go @@ -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/` diff --git a/sub/subHandler.go b/sub/subHandler.go index 346b417..8d122a6 100644 --- a/sub/subHandler.go +++ b/sub/subHandler.go @@ -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 { diff --git a/sub/subHandler_test.go b/sub/subHandler_test.go new file mode 100644 index 0000000..95b96a7 --- /dev/null +++ b/sub/subHandler_test.go @@ -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) + } + }) + } +}