sub: add custom subscription headers and enable gzip compression (#1289)

* sub: add support for custom subscription headers

* sub: gzip for subs

* sub: load and apply custom headers as middleware in initRouter

* sub: move header parsing to sub.go, empty default, validate header names and values

---------

Co-authored-by: Alireza Ahmadi <alireza7@gmail.com>
This commit is contained in:
SashaGonch228 2026-10-15 15:07:30 +05:00 • committed by GitHub
parent 0af0d37ab5
commit 5855d79bcf
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
5 changed files with 102 additions and 2 deletions

@ -1 +1 @@
Subproject commit e4525297b002c1c3be234cc1c9695ef84be741a0
Subproject commit 80de052a45835b2edb1110ce83e2a947555138f1

2
go.mod
View file

@ -23,6 +23,7 @@ require (
github.com/sagernet/sing-vmess v0.2.9-0.20260929152519-9b95ab8c9478
github.com/shirou/gopsutil/v4 v4.26.9
golang.org/x/crypto v0.57.0
golang.org/x/net v0.58.0
golang.zx2c4.com/wireguard/wgctrl v0.0.0-20241231184526-a9ab2273dd10
gopkg.in/yaml.v3 v3.0.1
gorm.io/driver/sqlite v1.6.0
@ -209,7 +210,6 @@ require (
golang.org/x/arch v0.29.0 // indirect
golang.org/x/exp v0.0.0-20260410095643-746e56fc9e2f // indirect
golang.org/x/mod v0.41.0 // indirect
golang.org/x/net v0.58.0 // indirect
golang.org/x/oauth2 v0.36.0 // indirect
golang.org/x/sync v0.23.0 // indirect
golang.org/x/sys v0.48.0 // indirect

View file

@ -91,6 +91,7 @@ var defaultValueMap = map[string]string{
"subURI": "",
"subJsonExt": "",
"subClashExt": "",
"subCustomHeaders": "",
"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.

View file

@ -7,6 +7,7 @@ import (
"net"
"net/http"
"strconv"
"strings"
"time"
"github.com/alireza0/s-ui/config"
@ -16,7 +17,9 @@ import (
"github.com/alireza0/s-ui/service"
"github.com/alireza0/s-ui/util/common"
"github.com/gin-contrib/gzip"
"github.com/gin-gonic/gin"
"golang.org/x/net/http/httpguts"
)
type Server struct {
@ -46,6 +49,20 @@ func (s *Server) initRouter() (*gin.Engine, error) {
}
engine := gin.Default()
engine.Use(gzip.Gzip(gzip.DefaultCompression))
customHeadersStr, err := s.SettingService.GetSubCustomHeaders()
if err != nil {
return nil, err
}
if customHeaders := parseCustomHeaders(customHeadersStr); len(customHeaders) > 0 {
engine.Use(func(c *gin.Context) {
for _, h := range customHeaders {
c.Writer.Header().Set(h.key, h.value)
}
c.Next()
})
}
subPath, err := s.SettingService.GetSubPath()
if err != nil {
@ -67,6 +84,32 @@ func (s *Server) initRouter() (*gin.Engine, error) {
return engine, nil
}
type customHeader struct {
key string
value string
}
func parseCustomHeaders(raw string) []customHeader {
var headers []customHeader
for _, line := range strings.Split(raw, "\n") {
line = strings.TrimSpace(line)
if line == "" || strings.HasPrefix(line, "#") || strings.HasPrefix(line, "//") {
continue
}
key, value, ok := strings.Cut(line, ":")
if !ok {
continue
}
key = strings.TrimSpace(key)
value = strings.TrimSpace(value)
if value == "" || !httpguts.ValidHeaderFieldName(key) || !httpguts.ValidHeaderFieldValue(value) {
continue
}
headers = append(headers, customHeader{key: key, value: value})
}
return headers
}
func (s *Server) Start() (err error) {
//This is an anonymous function, no function name
defer func() {

52
sub/sub_test.go Normal file
View file

@ -0,0 +1,52 @@
package sub
import (
"reflect"
"testing"
)
func TestParseCustomHeaders(t *testing.T) {
testCases := []struct {
name string
in string
want []customHeader
}{
{"empty string", "", nil},
{"whitespace only", " \n\t \r\n ", nil},
{"comment", "# 424242", nil},
{
"extra spaces",
"\n\tannounce : 42! \n\tannounce-url: t.me/4242\n\t",
[]customHeader{{"announce", "42!"}, {"announce-url", "t.me/4242"}},
},
{
"values keep their colons",
"Profile-Web-Page-Url: https://sub.example.com:8443/login?redirect=https://a.com",
[]customHeader{{"Profile-Web-Page-Url", "https://sub.example.com:8443/login?redirect=https://a.com"}},
},
{
"crlf line endings and comments",
"# header 1\r\nX-Custom: value1\r\n// comment 2\r\nX-Other: value2\r\n",
[]customHeader{{"X-Custom", "value1"}, {"X-Other", "value2"}},
},
{
"malformed lines are skipped",
"random text without colon\n: missing key\nempty value: \n:\nvalid-key: ok\n",
[]customHeader{{"valid-key", "ok"}},
},
{
// A name an HTTP header cannot carry would go out malformed.
"invalid names are skipped",
"Bad Name: x\nBad(Name): x\nGood-Name: x\n",
[]customHeader{{"Good-Name", "x"}},
},
}
for _, tc := range testCases {
t.Run(tc.name, func(t *testing.T) {
if got := parseCustomHeaders(tc.in); !reflect.DeepEqual(got, tc.want) {
t.Errorf("got %+v, want %+v", got, tc.want)
}
})
}
}