diff --git a/adapter/router.go b/adapter/router.go index e1807747..c1080c45 100644 --- a/adapter/router.go +++ b/adapter/router.go @@ -17,6 +17,7 @@ import ( type Router interface { Service + Inbound(tag string) (Inbound, bool) Outbounds() []Outbound Outbound(tag string) (Outbound, bool) DefaultOutbound(network string) Outbound diff --git a/adapter/subscription.go b/adapter/subscription.go new file mode 100644 index 00000000..a5365dd0 --- /dev/null +++ b/adapter/subscription.go @@ -0,0 +1,16 @@ +package adapter + +type SubscriptionSupport interface { + GenerateSubscription(options GenerateSubscriptionOptions) ([]byte, error) +} + +type GenerateSubscriptionOptions struct { + Format string + Application string + Remarks string + ServerAddress string +} + +type SubscriptionServer interface { + Service +} diff --git a/box.go b/box.go index d2d30637..4b434aa0 100644 --- a/box.go +++ b/box.go @@ -12,6 +12,7 @@ import ( C "github.com/sagernet/sing-box/constant" "github.com/sagernet/sing-box/experimental" "github.com/sagernet/sing-box/experimental/libbox/platform" + "github.com/sagernet/sing-box/experimental/subscription" "github.com/sagernet/sing-box/inbound" "github.com/sagernet/sing-box/log" "github.com/sagernet/sing-box/option" @@ -25,16 +26,16 @@ import ( var _ adapter.Service = (*Box)(nil) type Box struct { - createdAt time.Time - router adapter.Router - inbounds []adapter.Inbound - outbounds []adapter.Outbound - logFactory log.Factory - logger log.ContextLogger - logFile *os.File - clashServer adapter.ClashServer - v2rayServer adapter.V2RayServer - done chan struct{} + createdAt time.Time + router adapter.Router + inbounds []adapter.Inbound + outbounds []adapter.Outbound + logFactory log.Factory + logger log.ContextLogger + logFile *os.File + preServices map[string]adapter.Service + postServices map[string]adapter.Service + done chan struct{} } func New(ctx context.Context, options option.Options, platformInterface platform.Interface) (*Box, error) { @@ -43,6 +44,7 @@ func New(ctx context.Context, options option.Options, platformInterface platform var needClashAPI bool var needV2RayAPI bool + var needSubscriptionServer bool if options.Experimental != nil { if options.Experimental.ClashAPI != nil && options.Experimental.ClashAPI.ExternalController != "" { needClashAPI = true @@ -50,6 +52,9 @@ func New(ctx context.Context, options option.Options, platformInterface platform if options.Experimental.V2RayAPI != nil && options.Experimental.V2RayAPI.Listen != "" { needV2RayAPI = true } + if options.Experimental.Subscription != nil && options.Experimental.Subscription.Listen != "" { + needSubscriptionServer = true + } } var logFactory log.Factory @@ -165,33 +170,41 @@ func New(ctx context.Context, options option.Options, platformInterface platform return nil, err } - var clashServer adapter.ClashServer - var v2rayServer adapter.V2RayServer + preServices := make(map[string]adapter.Service) + postServices := make(map[string]adapter.Service) if needClashAPI { - clashServer, err = experimental.NewClashServer(router, observableLogFactory, common.PtrValueOrDefault(options.Experimental.ClashAPI)) + clashServer, err := experimental.NewClashServer(router, observableLogFactory, common.PtrValueOrDefault(options.Experimental.ClashAPI)) if err != nil { return nil, E.Cause(err, "create clash api server") } router.SetClashServer(clashServer) + preServices["clash api"] = clashServer } if needV2RayAPI { - v2rayServer, err = experimental.NewV2RayServer(logFactory.NewLogger("v2ray-api"), common.PtrValueOrDefault(options.Experimental.V2RayAPI)) + v2rayServer, err := experimental.NewV2RayServer(logFactory.NewLogger("v2ray-api"), common.PtrValueOrDefault(options.Experimental.V2RayAPI)) if err != nil { return nil, E.Cause(err, "create v2ray api server") } - router.SetV2RayServer(v2rayServer) + preServices["v2ray api"] = v2rayServer + } + if needSubscriptionServer { + subscriptionServer, err := subscription.NewServer(ctx, router, logFactory.NewLogger("subscription"), common.PtrValueOrDefault(options.Experimental.Subscription)) + if err != nil { + return nil, E.Cause(err, "create subscription server") + } + postServices["subscription"] = subscriptionServer } return &Box{ - router: router, - inbounds: inbounds, - outbounds: outbounds, - createdAt: createdAt, - logFactory: logFactory, - logger: logFactory.Logger(), - logFile: logFile, - clashServer: clashServer, - v2rayServer: v2rayServer, - done: make(chan struct{}), + router: router, + inbounds: inbounds, + outbounds: outbounds, + createdAt: createdAt, + logFactory: logFactory, + logger: logFactory.Logger(), + logFile: logFile, + preServices: preServices, + postServices: postServices, + done: make(chan struct{}), }, nil } @@ -213,16 +226,10 @@ func (s *Box) Start() error { } func (s *Box) start() error { - if s.clashServer != nil { - err := s.clashServer.Start() + for name, preServer := range s.preServices { + err := preServer.Start() if err != nil { - return E.Cause(err, "start clash api server") - } - } - if s.v2rayServer != nil { - err := s.v2rayServer.Start() - if err != nil { - return E.Cause(err, "start v2ray api server") + return E.Cause(err, "start ", name, " service") } } for i, out := range s.outbounds { @@ -256,6 +263,13 @@ func (s *Box) start() error { } } + for name, service := range s.postServices { + err = service.Start() + if err != nil { + return E.Cause(err, "start ", name, " service") + } + } + s.logger.Info("sing-box started (", F.Seconds(time.Since(s.createdAt).Seconds()), "s)") return nil } @@ -268,6 +282,11 @@ func (s *Box) Close() error { close(s.done) } var errors error + for name, service := range s.postServices { + errors = E.Append(errors, service.Close(), func(err error) error { + return E.Cause(err, "close ", name, " service") + }) + } for i, in := range s.inbounds { errors = E.Append(errors, in.Close(), func(err error) error { return E.Cause(err, "close inbound/", in.Type(), "[", i, "]") @@ -283,21 +302,16 @@ func (s *Box) Close() error { return E.Cause(err, "close router") }) } + for name, service := range s.preServices { + errors = E.Append(errors, service.Close(), func(err error) error { + return E.Cause(err, "close ", name, " service") + }) + } if err := common.Close(s.logFactory); err != nil { errors = E.Append(errors, err, func(err error) error { return E.Cause(err, "close log factory") }) } - if err := common.Close(s.clashServer); err != nil { - errors = E.Append(errors, err, func(err error) error { - return E.Cause(err, "close clash api server") - }) - } - if err := common.Close(s.v2rayServer); err != nil { - errors = E.Append(errors, err, func(err error) error { - return E.Cause(err, "close v2ray api server") - }) - } if s.logFile != nil { errors = E.Append(errors, s.logFile.Close(), func(err error) error { return E.Cause(err, "close log file") diff --git a/common/tls/acme.go b/common/tls/acme.go index cb447628..ad39ea81 100644 --- a/common/tls/acme.go +++ b/common/tls/acme.go @@ -63,6 +63,9 @@ func startACME(ctx context.Context, options option.InboundACMEOptions) (*tls.Con zap.InfoLevel, )), } + if config.DefaultServerName == "" { + config.DefaultServerName = options.Domain[0] + } acmeConfig := certmagic.ACMEIssuer{ CA: acmeServer, Email: options.Email, diff --git a/common/tls/listener.go b/common/tls/listener.go new file mode 100644 index 00000000..b9385eaf --- /dev/null +++ b/common/tls/listener.go @@ -0,0 +1,39 @@ +package tls + +import ( + "context" + "net" + + E "github.com/sagernet/sing/common/exceptions" + "github.com/sagernet/sing/common/logger" +) + +type Listener struct { + net.Listener + ctx context.Context + logger logger.Logger + config ServerConfig +} + +func NewListener(ctx context.Context, logger logger.Logger, inner net.Listener, config ServerConfig) net.Listener { + return &Listener{ + Listener: inner, + ctx: ctx, + logger: logger, + config: config, + } +} + +func (l *Listener) Accept() (net.Conn, error) { + conn, err := l.Listener.Accept() + if err != nil { + return nil, err + } + tlsConn, err := ServerHandshake(l.ctx, conn, l.config) + if err != nil { + l.logger.Error(E.Cause(err, "accept connection from ", conn.RemoteAddr(), ": ")) + conn.Close() + return l.Accept() + } + return tlsConn, err +} diff --git a/constant/subscription.go b/constant/subscription.go new file mode 100644 index 00000000..245f581b --- /dev/null +++ b/constant/subscription.go @@ -0,0 +1,10 @@ +package constant + +const ( + SubscriptionTypeRaw = "raw" + SubscriptionTypeSIP008 = "sip008" +) + +const ( + SubscriptionApplicationShadowrocket = "shadowrocket" +) diff --git a/experimental/subscription/server.go b/experimental/subscription/server.go new file mode 100644 index 00000000..542b6742 --- /dev/null +++ b/experimental/subscription/server.go @@ -0,0 +1,172 @@ +package subscription + +import ( + "context" + "encoding/base64" + "encoding/json" + "errors" + "net" + "net/http" + "strings" + + "github.com/sagernet/sing-box/adapter" + "github.com/sagernet/sing-box/common/tls" + C "github.com/sagernet/sing-box/constant" + "github.com/sagernet/sing-box/log" + "github.com/sagernet/sing-box/option" + "github.com/sagernet/sing/common" + E "github.com/sagernet/sing/common/exceptions" + "github.com/sagernet/sing/common/logger" + sHttp "github.com/sagernet/sing/protocol/http" + + "github.com/go-chi/chi/v5" + "github.com/go-chi/render" + "golang.org/x/net/http2" +) + +type Server struct { + ctx context.Context + router adapter.Router + logger log.Logger + httpServer *http.Server + tlsConfig tls.ServerConfig + servers []ServerItem +} + +type ServerItem struct { + Remarks string + ServerAddress string + Interface adapter.SubscriptionSupport +} + +func NewServer(ctx context.Context, router adapter.Router, logger logger.ContextLogger, options option.SubscriptionOptions) (adapter.SubscriptionServer, error) { + chiRouter := chi.NewRouter() + server := &Server{ + ctx: ctx, + router: router, + logger: logger, + httpServer: &http.Server{ + Addr: options.Listen, + Handler: chiRouter, + }, + } + if options.TLS != nil && options.TLS.Enabled { + tlsConfig, err := tls.NewServer(ctx, router, logger, common.PtrValueOrDefault(options.TLS)) + if err != nil { + return nil, err + } + server.tlsConfig = tlsConfig + } + listenPrefix := options.ListenPrefix + if !strings.HasPrefix(listenPrefix, "/") { + listenPrefix = "/" + listenPrefix + } + chiRouter.Get("/", server.handleSubscription) + + for i, serverOptions := range options.Servers { + serverAddress := serverOptions.ServerAddress + if serverAddress == "" { + serverAddress = options.ServerAddress + } + if serverAddress == "" { + return nil, E.New("parse servers[", i, "]: ", "missing server address") + } + if serverOptions.InboundTag != "" { + inbound, loaded := router.Inbound(serverOptions.InboundTag) + if !loaded { + return nil, E.New("parse servers[", i, "]: ", "inbound not found: ", serverOptions.InboundTag) + } + subscriptionSupport, loaded := inbound.(adapter.SubscriptionSupport) + if !loaded { + return nil, E.New("parse servers[", i, "]: ", "inbound have no subscription support: ", serverOptions.InboundTag) + } + server.servers = append(server.servers, ServerItem{ + Remarks: serverOptions.Remarks, + ServerAddress: serverAddress, + Interface: subscriptionSupport, + }) + } + } + + return server, nil +} + +func (s *Server) Start() error { + listener, err := net.Listen("tcp", s.httpServer.Addr) + if err != nil { + return E.Cause(err, "subscription server listen error") + } + if s.tlsConfig != nil { + s.tlsConfig.SetNextProtos([]string{http2.NextProtoTLS, "http/1.1"}) + err = s.tlsConfig.Start() + if err != nil { + return err + } + listener = tls.NewListener(s.ctx, s.logger, listener, s.tlsConfig) + } + s.logger.Info("subscription server listening at ", listener.Addr()) + go func() { + err = s.httpServer.Serve(listener) + if err != nil && !errors.Is(err, http.ErrServerClosed) { + s.logger.Error("subscription server serve error: ", err) + } + }() + return nil +} + +func (s *Server) Close() error { + return common.Close( + common.PtrOrNil(s.httpServer), + s.tlsConfig, + ) +} + +func (s *Server) handleSubscription(writer http.ResponseWriter, request *http.Request) { + userAgent := request.Header.Get("User-Agent") + s.logger.Info("accepted request from ", sHttp.SourceAddress(request), " with User-Agent: ", userAgent) + + subscriptionFormat := chi.URLParam(request, "type") + if subscriptionFormat == "" { + subscriptionFormat = C.SubscriptionTypeRaw + } + + application := chi.URLParam(request, "application") + if application == "" { + if strings.Contains(userAgent, "Shadowrocket") { + application = C.SubscriptionApplicationShadowrocket + } + } + + generateOptions := adapter.GenerateSubscriptionOptions{ + Format: subscriptionFormat, + Application: application, + } + + var contentList [][]byte + for _, serverItem := range s.servers { + generateOptions.Remarks = serverItem.Remarks + generateOptions.ServerAddress = serverItem.ServerAddress + content, err := serverItem.Interface.GenerateSubscription(generateOptions) + if err != nil { + // TODO: process error + continue + } + contentList = append(contentList, content) + } + + switch subscriptionFormat { + case C.SubscriptionTypeSIP008: + render.JSON(writer, request, render.M{ + "version": 1, + "servers": common.Map(contentList, func(content []byte) json.RawMessage { + return content + }), + }) + case C.SubscriptionTypeRaw: + rawConfig := strings.Join(common.Map(contentList, func(it []byte) string { return string(it) }), "\n") + render.PlainText(writer, request, base64.URLEncoding.EncodeToString([]byte(rawConfig))) + default: + render.Status(request, http.StatusBadRequest) + render.PlainText(writer, request, "unsupported subscription format: "+subscriptionFormat) + } +} diff --git a/inbound/subscription.go b/inbound/subscription.go new file mode 100644 index 00000000..45d001c6 --- /dev/null +++ b/inbound/subscription.go @@ -0,0 +1,79 @@ +package inbound + +import ( + "encoding/base64" + "net/url" + "strings" + + "github.com/sagernet/sing-box/adapter" + "github.com/sagernet/sing-box/common/json" + C "github.com/sagernet/sing-box/constant" + E "github.com/sagernet/sing/common/exceptions" + M "github.com/sagernet/sing/common/metadata" + N "github.com/sagernet/sing/common/network" + + "github.com/gofrs/uuid" +) + +var _ adapter.SubscriptionSupport = (*Shadowsocks)(nil) + +func (h *Shadowsocks) GenerateSubscription(options adapter.GenerateSubscriptionOptions) ([]byte, error) { + serverAddress := options.ServerAddress + if serverAddress == "" { + serverAddress = h.listenOptions.Listen.Build().String() + } + switch options.Format { + case C.SubscriptionTypeRaw: + if options.Application == C.SubscriptionApplicationShadowrocket { + hostString := base64.URLEncoding.EncodeToString([]byte( + h.service.Name() + ":" + h.service.Password() + "@" + M.ParseSocksaddrHostPort(serverAddress, h.listenOptions.ListenPort).String(), + )) + shadowrocketURL := &url.URL{ + Scheme: "ss", + Host: "$", + Fragment: options.Remarks, + } + requestParams := make(url.Values) + if len(h.network) == 1 && h.network[0] == N.NetworkTCP { + requestParams.Set("uot", "1") + requestParams.Set("udp-over-tcp", "1") + requestParams.Set("udp_over_tcp", "1") + } + if len(requestParams) > 0 { + shadowrocketURL.RawQuery = requestParams.Encode() + } + return []byte(strings.ReplaceAll(shadowrocketURL.String(), "$", hostString)), nil + } + + var useBase64Format bool + if options.Application != "" || !strings.HasPrefix(h.service.Name(), "2022-") { + useBase64Format = true + } + + sip002URL := &url.URL{ + Scheme: "ss", + Host: M.ParseSocksaddrHostPort(serverAddress, h.listenOptions.ListenPort).String(), + Fragment: options.Remarks, + } + var sip002URI string + if !useBase64Format { + sip002URL.User = url.UserPassword(h.service.Name(), h.service.Password()) + sip002URI = sip002URL.String() + } else { + sip002URL.User = url.User("$") + sip002URI = strings.ReplaceAll(sip002URL.String(), "$", base64.URLEncoding.EncodeToString([]byte(h.service.Name()+":"+h.service.Password()))) + } + return []byte(sip002URI), nil + case C.SubscriptionTypeSIP008: + return json.Marshal(map[string]any{ + "id": uuid.NewV5(uuid.Nil, options.Remarks).String(), + "remarks": options.Remarks, + "server": serverAddress, + "server_port": h.listenOptions.ListenPort, + "password": h.service.Password(), + "method": h.service.Name(), + }) + default: + return nil, E.New("unknown subscription format ", options.Format) + } +} diff --git a/option/experimental.go b/option/experimental.go index 2167ddaa..b3e522b1 100644 --- a/option/experimental.go +++ b/option/experimental.go @@ -1,6 +1,7 @@ package option type ExperimentalOptions struct { - ClashAPI *ClashAPIOptions `json:"clash_api,omitempty"` - V2RayAPI *V2RayAPIOptions `json:"v2ray_api,omitempty"` + ClashAPI *ClashAPIOptions `json:"clash_api,omitempty"` + V2RayAPI *V2RayAPIOptions `json:"v2ray_api,omitempty"` + Subscription *SubscriptionOptions `json:"subscription,omitempty"` } diff --git a/option/subscription.go b/option/subscription.go new file mode 100644 index 00000000..83853393 --- /dev/null +++ b/option/subscription.go @@ -0,0 +1,15 @@ +package option + +type SubscriptionOptions struct { + Listen string `json:"listen,omitempty"` + ListenPrefix string `json:"listen_prefix,omitempty"` + TLS *InboundTLSOptions `json:"tls,omitempty"` + ServerAddress string `json:"server_address,omitempty"` + Servers []SubscriptionServerOptions `json:"servers,omitempty"` +} + +type SubscriptionServerOptions struct { + InboundTag string `json:"inbound_tag,omitempty"` + ServerAddress string `json:"server_address,omitempty"` + Remarks string `json:"remarks,omitempty"` +} diff --git a/route/router.go b/route/router.go index 7b48f260..0009aa76 100644 --- a/route/router.go +++ b/route/router.go @@ -400,10 +400,28 @@ func (r *Router) Initialize(inbounds []adapter.Inbound, outbounds []adapter.Outb return nil } +func (r *Router) Inbound(tag string) (adapter.Inbound, bool) { + inbound, loaded := r.inboundByTag[tag] + return inbound, loaded +} + func (r *Router) Outbounds() []adapter.Outbound { return r.outbounds } +func (r *Router) Outbound(tag string) (adapter.Outbound, bool) { + outbound, loaded := r.outboundByTag[tag] + return outbound, loaded +} + +func (r *Router) DefaultOutbound(network string) adapter.Outbound { + if network == N.NetworkTCP { + return r.defaultOutboundForConnection + } else { + return r.defaultOutboundForPacketConnection + } +} + func (r *Router) Start() error { if r.needGeoIPDatabase { err := r.prepareGeoIPDatabase() @@ -531,19 +549,6 @@ func (r *Router) LoadGeosite(code string) (adapter.Rule, error) { return rule, nil } -func (r *Router) Outbound(tag string) (adapter.Outbound, bool) { - outbound, loaded := r.outboundByTag[tag] - return outbound, loaded -} - -func (r *Router) DefaultOutbound(network string) adapter.Outbound { - if network == N.NetworkTCP { - return r.defaultOutboundForConnection - } else { - return r.defaultOutboundForPacketConnection - } -} - func (r *Router) RouteConnection(ctx context.Context, conn net.Conn, metadata adapter.InboundContext) error { if metadata.InboundDetour != "" { if metadata.LastInbound == metadata.InboundDetour {