diff --git a/adapter/dynamic.go b/adapter/dynamic.go new file mode 100644 index 00000000..8c96c553 --- /dev/null +++ b/adapter/dynamic.go @@ -0,0 +1,6 @@ +package adapter + +// DynamicManager 是用于动态管理入站、出站和路由规则的接口 +type DynamicManager interface { + LifecycleService +} diff --git a/adapter/router.go b/adapter/router.go index 0b7c8f4f..c1393a52 100644 --- a/adapter/router.go +++ b/adapter/router.go @@ -24,6 +24,8 @@ type Router interface { RuleSet(tag string) (RuleSet, bool) NeedWIFIState() bool Rules() []Rule + AddRule(rule Rule) int + RemoveRule(index int) error AppendTracker(tracker ConnectionTracker) ResetNetwork() } diff --git a/box.go b/box.go index db900a7a..6dd019a1 100644 --- a/box.go +++ b/box.go @@ -116,6 +116,7 @@ func New(options Options) (*Box, error) { var needCacheFile bool var needClashAPI bool var needV2RayAPI bool + var needDynamicAPI bool if experimentalOptions.CacheFile != nil && experimentalOptions.CacheFile.Enabled || options.PlatformLogWriter != nil { needCacheFile = true } @@ -125,6 +126,9 @@ func New(options Options) (*Box, error) { if experimentalOptions.V2RayAPI != nil && experimentalOptions.V2RayAPI.Listen != "" { needV2RayAPI = true } + if experimentalOptions.DynamicAPI != nil && experimentalOptions.DynamicAPI.Listen != "" { + needDynamicAPI = true + } platformInterface := service.FromContext[platform.Interface](ctx) var defaultLogWriter io.Writer if platformInterface != nil { @@ -329,6 +333,14 @@ func New(options Options) (*Box, error) { service.MustRegister[adapter.V2RayServer](ctx, v2rayServer) } } + if needDynamicAPI { + dynamicAPIOptions := common.PtrValueOrDefault(experimentalOptions.DynamicAPI) + dynamicServer, err := experimental.NewDynamicManager(ctx, logFactory.NewLogger("dynamic-api"), dynamicAPIOptions) + if err != nil { + return nil, E.Cause(err, "create dynamic-api server") + } + services = append(services, dynamicServer) + } if ntpOptions.Enabled { ntpDialer, err := dialer.New(ctx, ntpOptions.DialerOptions, ntpOptions.ServerIsDomain()) if err != nil { diff --git a/experimental/dynamicapi.go b/experimental/dynamicapi.go new file mode 100644 index 00000000..2a59ac9d --- /dev/null +++ b/experimental/dynamicapi.go @@ -0,0 +1,21 @@ +package experimental + +import ( + "context" + + "github.com/sagernet/sing-box/adapter" + "github.com/sagernet/sing-box/log" + "github.com/sagernet/sing-box/option" +) + +var dynamicManagerConstructor DynamicManagerConstructor + +type DynamicManagerConstructor func(ctx context.Context, logger log.ContextLogger, options option.DynamicAPIOptions) (adapter.DynamicManager, error) + +func RegisterDynamicManagerConstructor(constructor DynamicManagerConstructor) { + dynamicManagerConstructor = constructor +} + +func NewDynamicManager(ctx context.Context, logger log.ContextLogger, options option.DynamicAPIOptions) (adapter.DynamicManager, error) { + return dynamicManagerConstructor(ctx, logger, options) +} diff --git a/experimental/dynamicapi/init.go b/experimental/dynamicapi/init.go new file mode 100644 index 00000000..0b1661ac --- /dev/null +++ b/experimental/dynamicapi/init.go @@ -0,0 +1,9 @@ +package dynamicapi + +import ( + "github.com/sagernet/sing-box/experimental" +) + +func init() { + experimental.RegisterDynamicManagerConstructor(NewServer) +} diff --git a/experimental/dynamicapi/server.go b/experimental/dynamicapi/server.go new file mode 100644 index 00000000..7038d073 --- /dev/null +++ b/experimental/dynamicapi/server.go @@ -0,0 +1,678 @@ +package dynamicapi + +import ( + "context" + "encoding/json" + "errors" + "io" + "net" + "net/http" + "os" + "strconv" + "strings" + "time" + + "github.com/sagernet/sing-box/adapter" + "github.com/sagernet/sing-box/log" + "github.com/sagernet/sing-box/option" + R "github.com/sagernet/sing-box/route/rule" + E "github.com/sagernet/sing/common/exceptions" + "github.com/sagernet/sing/service" + + "github.com/go-chi/chi/v5" + "github.com/go-chi/chi/v5/middleware" + "github.com/go-chi/render" +) + +var _ adapter.DynamicManager = (*Server)(nil) + +type Server struct { + ctx context.Context + router adapter.Router + inbound adapter.InboundManager + outbound adapter.OutboundManager + logger log.ContextLogger + logFactory log.Factory + httpServer *http.Server + listenAddress string + secret string +} + +func NewServer(ctx context.Context, logger log.ContextLogger, options option.DynamicAPIOptions) (adapter.DynamicManager, error) { + r := chi.NewRouter() + + inboundManager := service.FromContext[adapter.InboundManager](ctx) + outboundManager := service.FromContext[adapter.OutboundManager](ctx) + routerInstance := service.FromContext[adapter.Router](ctx) + logFactory := service.FromContext[log.Factory](ctx) + + s := &Server{ + ctx: ctx, + router: routerInstance, + inbound: inboundManager, + outbound: outboundManager, + logger: logger, + logFactory: logFactory, + listenAddress: options.Listen, + secret: options.Secret, + httpServer: &http.Server{ + Addr: options.Listen, + Handler: r, + }, + } + + r.Use(middleware.Logger) + r.Use(middleware.Recoverer) + r.Use(authentication(options.Secret)) + + // 添加API路由 + r.Route("/api", func(r chi.Router) { + // 入站API + r.Route("/inbound", func(r chi.Router) { + r.Post("/", s.createInbound) + r.Delete("/{tag}", s.removeInbound) + r.Get("/", s.listInbounds) + }) + + // 出站API + r.Route("/outbound", func(r chi.Router) { + r.Post("/", s.createOutbound) + r.Delete("/{tag}", s.removeOutbound) + r.Get("/", s.listOutbounds) + }) + + // 路由规则API + r.Route("/route", func(r chi.Router) { + r.Post("/rule", s.createRouteRule) + r.Delete("/rule/{index}", s.removeRouteRule) + r.Get("/rules", s.listRouteRules) + }) + }) + + return s, nil +} + +func (s *Server) Name() string { + return "dynamic api server" +} + +func (s *Server) Start(stage adapter.StartStage) error { + if stage != adapter.StartStatePostStart { + return nil + } + + listener, err := net.Listen("tcp", s.listenAddress) + if err != nil { + return E.Cause(err, "listen on ", s.listenAddress) + } + + s.logger.Info("dynamic api server listening at ", listener.Addr()) + + go func() { + err = s.httpServer.Serve(listener) + if err != nil && !errors.Is(err, http.ErrServerClosed) { + s.logger.Error("failed to serve: ", err) + } + }() + + return nil +} + +func (s *Server) Close() error { + if s.httpServer != nil { + ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second) + defer cancel() + return s.httpServer.Shutdown(ctx) + } + return nil +} + +// 修改createInbound方法 +func (s *Server) createInbound(w http.ResponseWriter, r *http.Request) { + // 从请求体中读取原始JSON数据 + body, err := io.ReadAll(r.Body) + if err != nil { + render.Status(r, http.StatusBadRequest) + render.JSON(w, r, map[string]string{"error": "无法读取请求体: " + err.Error()}) + return + } + defer r.Body.Close() + + // 首先尝试解析整个请求 + var requestMap map[string]interface{} + if err := json.Unmarshal(body, &requestMap); err != nil { + render.Status(r, http.StatusBadRequest) + render.JSON(w, r, map[string]string{"error": "无法解析请求: " + err.Error()}) + return + } + + // 提取tag和type + tag, tagExists := requestMap["tag"].(string) + inboundType, typeExists := requestMap["type"].(string) + + if !tagExists || !typeExists { + render.Status(r, http.StatusBadRequest) + render.JSON(w, r, map[string]string{"error": "tag和type不能为空"}) + return + } + + // 检查入站是否已存在 + if _, exists := s.inbound.Get(tag); exists { + render.Status(r, http.StatusConflict) + render.JSON(w, r, map[string]string{"error": "入站已存在: " + tag}) + return + } + + // 提取options + var optionsRaw interface{} + if options, hasOptions := requestMap["options"]; hasOptions { + optionsRaw = options + } else { + // 如果没有options字段,将请求中除了tag和type外的所有字段作为options + optionsMap := make(map[string]interface{}) + for key, value := range requestMap { + if key != "tag" && key != "type" { + optionsMap[key] = value + } + } + optionsRaw = optionsMap + } + + // 记录日志 + s.logger.Info("创建入站: ", inboundType, "[", tag, "]") + + // 获取入站注册表 + inboundRegistry := service.FromContext[adapter.InboundRegistry](s.ctx) + if inboundRegistry == nil { + render.Status(r, http.StatusInternalServerError) + render.JSON(w, r, map[string]string{"error": "入站注册服务不可用"}) + return + } + + // 创建入站配置对象 + optionsObj, exists := inboundRegistry.CreateOptions(inboundType) + if !exists { + render.Status(r, http.StatusBadRequest) + render.JSON(w, r, map[string]string{"error": "不支持的入站类型: " + inboundType}) + return + } + + // 将原始选项转换为正确的结构体 + optionsJson, err := json.Marshal(optionsRaw) + if err != nil { + render.Status(r, http.StatusBadRequest) + render.JSON(w, r, map[string]string{"error": "无法序列化选项: " + err.Error()}) + return + } + + err = json.Unmarshal(optionsJson, optionsObj) + if err != nil { + render.Status(r, http.StatusBadRequest) + render.JSON(w, r, map[string]string{"error": "选项格式错误: " + err.Error()}) + return + } + + // 创建入站 + err = s.inbound.Create(s.ctx, s.router, s.logger, tag, inboundType, optionsObj) + if err != nil { + render.Status(r, http.StatusBadRequest) + render.JSON(w, r, map[string]string{"error": "创建入站失败: " + err.Error()}) + return + } + + render.Status(r, http.StatusOK) + render.JSON(w, r, map[string]interface{}{ + "success": true, + "message": "入站创建成功", + "tag": tag, + "type": inboundType, + }) +} + +// 移除入站 +func (s *Server) removeInbound(w http.ResponseWriter, r *http.Request) { + tag := chi.URLParam(r, "tag") + if tag == "" { + render.Status(r, http.StatusBadRequest) + render.JSON(w, r, map[string]string{"error": "tag不能为空"}) + return + } + + // 检查入站是否存在 + _, exists := s.inbound.Get(tag) + if !exists { + render.Status(r, http.StatusNotFound) + render.JSON(w, r, map[string]string{"error": "入站不存在: " + tag}) + return + } + + // 移除入站 + err := s.inbound.Remove(tag) + if err != nil { + if errors.Is(err, os.ErrInvalid) { + render.Status(r, http.StatusNotFound) + render.JSON(w, r, map[string]string{"error": "入站不存在: " + tag}) + } else { + render.Status(r, http.StatusInternalServerError) + render.JSON(w, r, map[string]string{"error": "移除入站失败: " + err.Error()}) + } + return + } + + render.JSON(w, r, map[string]interface{}{ + "success": true, + "message": "入站移除成功", + "tag": tag, + }) +} + +// 列出所有入站 +func (s *Server) listInbounds(w http.ResponseWriter, r *http.Request) { + inbounds := s.inbound.Inbounds() + var result []map[string]string + + for _, inbound := range inbounds { + result = append(result, map[string]string{ + "tag": inbound.Tag(), + "type": inbound.Type(), + }) + } + + render.JSON(w, r, map[string]interface{}{ + "success": true, + "inbounds": result, + }) +} + +// 修改createOutbound方法 +func (s *Server) createOutbound(w http.ResponseWriter, r *http.Request) { + // 从请求体中读取原始JSON数据 + body, err := io.ReadAll(r.Body) + if err != nil { + render.Status(r, http.StatusBadRequest) + render.JSON(w, r, map[string]string{"error": "无法读取请求体: " + err.Error()}) + return + } + defer r.Body.Close() + + // 首先尝试解析整个请求 + var requestMap map[string]interface{} + if err := json.Unmarshal(body, &requestMap); err != nil { + render.Status(r, http.StatusBadRequest) + render.JSON(w, r, map[string]string{"error": "无法解析请求: " + err.Error()}) + return + } + + // 提取tag和type + tag, tagExists := requestMap["tag"].(string) + outboundType, typeExists := requestMap["type"].(string) + + if !tagExists || !typeExists { + render.Status(r, http.StatusBadRequest) + render.JSON(w, r, map[string]string{"error": "tag和type不能为空"}) + return + } + + // 检查出站是否已存在 + if _, exists := s.outbound.Outbound(tag); exists { + render.Status(r, http.StatusConflict) + render.JSON(w, r, map[string]string{"error": "出站已存在: " + tag}) + return + } + + // 提取options + var optionsRaw interface{} + if options, hasOptions := requestMap["options"]; hasOptions { + optionsRaw = options + } else { + // 如果没有options字段,将请求中除了tag和type外的所有字段作为options + optionsMap := make(map[string]interface{}) + for key, value := range requestMap { + if key != "tag" && key != "type" { + optionsMap[key] = value + } + } + optionsRaw = optionsMap + } + + // 记录日志 + s.logger.Info("创建出站: ", outboundType, "[", tag, "]") + + // 获取出站注册表 + outboundRegistry := service.FromContext[adapter.OutboundRegistry](s.ctx) + if outboundRegistry == nil { + render.Status(r, http.StatusInternalServerError) + render.JSON(w, r, map[string]string{"error": "出站注册服务不可用"}) + return + } + + // 创建出站配置对象 + optionsObj, exists := outboundRegistry.CreateOptions(outboundType) + if !exists { + render.Status(r, http.StatusBadRequest) + render.JSON(w, r, map[string]string{"error": "不支持的出站类型: " + outboundType}) + return + } + + // 将原始选项转换为正确的结构体 + optionsJson, err := json.Marshal(optionsRaw) + if err != nil { + render.Status(r, http.StatusBadRequest) + render.JSON(w, r, map[string]string{"error": "无法序列化选项: " + err.Error()}) + return + } + + err = json.Unmarshal(optionsJson, optionsObj) + if err != nil { + render.Status(r, http.StatusBadRequest) + render.JSON(w, r, map[string]string{"error": "选项格式错误: " + err.Error()}) + return + } + + // 创建出站 + err = s.outbound.Create(s.ctx, s.router, s.logger, tag, outboundType, optionsObj) + if err != nil { + render.Status(r, http.StatusBadRequest) + render.JSON(w, r, map[string]string{"error": "创建出站失败: " + err.Error()}) + return + } + + render.Status(r, http.StatusOK) + render.JSON(w, r, map[string]interface{}{ + "success": true, + "message": "出站创建成功", + "tag": tag, + "type": outboundType, + }) +} + +// 移除出站 +func (s *Server) removeOutbound(w http.ResponseWriter, r *http.Request) { + tag := chi.URLParam(r, "tag") + if tag == "" { + render.Status(r, http.StatusBadRequest) + render.JSON(w, r, map[string]string{"error": "tag不能为空"}) + return + } + + // 检查出站是否存在 + _, exists := s.outbound.Outbound(tag) + if !exists { + render.Status(r, http.StatusNotFound) + render.JSON(w, r, map[string]string{"error": "出站不存在: " + tag}) + return + } + + // 移除出站 + err := s.outbound.Remove(tag) + if err != nil { + if errors.Is(err, os.ErrInvalid) { + render.Status(r, http.StatusNotFound) + render.JSON(w, r, map[string]string{"error": "出站不存在: " + tag}) + } else { + render.Status(r, http.StatusInternalServerError) + render.JSON(w, r, map[string]string{"error": "移除出站失败: " + err.Error()}) + } + return + } + + render.JSON(w, r, map[string]interface{}{ + "success": true, + "message": "出站移除成功", + "tag": tag, + }) +} + +// 列出所有出站 +func (s *Server) listOutbounds(w http.ResponseWriter, r *http.Request) { + outbounds := s.outbound.Outbounds() + var result []map[string]string + + for _, outbound := range outbounds { + result = append(result, map[string]string{ + "tag": outbound.Tag(), + "type": outbound.Type(), + }) + } + + render.JSON(w, r, map[string]interface{}{ + "success": true, + "outbounds": result, + }) +} + +// 认证中间件 +func authentication(serverSecret string) func(next http.Handler) http.Handler { + return func(next http.Handler) http.Handler { + return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + if serverSecret == "" { + next.ServeHTTP(w, r) + return + } + + if secret := r.Header.Get("Authorization"); secret != serverSecret { + render.Status(r, http.StatusUnauthorized) + render.JSON(w, r, map[string]string{"error": "未经授权的访问"}) + return + } + + next.ServeHTTP(w, r) + }) + } +} + +// 创建路由规则 +func (s *Server) createRouteRule(w http.ResponseWriter, r *http.Request) { + // 从请求体中读取原始JSON数据 + body, err := io.ReadAll(r.Body) + if err != nil { + render.Status(r, http.StatusBadRequest) + render.JSON(w, r, map[string]string{"error": "无法读取请求体: " + err.Error()}) + return + } + defer r.Body.Close() + + // 首先尝试解析整个请求 + var requestMap map[string]interface{} + if err := json.Unmarshal(body, &requestMap); err != nil { + render.Status(r, http.StatusBadRequest) + render.JSON(w, r, map[string]string{"error": "无法解析请求: " + err.Error()}) + return + } + + // 检查outbound字段,这是必需的 + outboundRaw, hasOutbound := requestMap["outbound"] + if !hasOutbound { + render.Status(r, http.StatusBadRequest) + render.JSON(w, r, map[string]string{"error": "outbound字段是必需的"}) + return + } + + // 确保outbound是字符串 + outbound, ok := outboundRaw.(string) + if !ok { + render.Status(r, http.StatusBadRequest) + render.JSON(w, r, map[string]string{"error": "outbound必须是字符串"}) + return + } + + // 验证outbound标签存在 + if _, exists := s.outbound.Outbound(outbound); !exists { + render.Status(r, http.StatusBadRequest) + render.JSON(w, r, map[string]string{"error": "指定的出站不存在: " + outbound}) + return + } + + // 处理inbounds字段,如果未提供则使用所有入站 + var inbounds []string + inboundsRaw, hasInbounds := requestMap["inbounds"] + + if hasInbounds { + // 确保inbounds是字符串数组 + switch v := inboundsRaw.(type) { + case []interface{}: + for _, item := range v { + if strItem, ok := item.(string); ok { + inbounds = append(inbounds, strItem) + } else { + render.Status(r, http.StatusBadRequest) + render.JSON(w, r, map[string]string{"error": "inbounds必须是字符串数组"}) + return + } + } + default: + render.Status(r, http.StatusBadRequest) + render.JSON(w, r, map[string]string{"error": "inbounds必须是数组"}) + return + } + + // 验证所有inbound标签都存在 + for _, inbound := range inbounds { + if _, exists := s.inbound.Get(inbound); !exists { + render.Status(r, http.StatusBadRequest) + render.JSON(w, r, map[string]string{"error": "指定的入站不存在: " + inbound}) + return + } + } + } else { + // 如果没有提供inbounds,使用所有现有的入站 + for _, inb := range s.inbound.Inbounds() { + inbounds = append(inbounds, inb.Tag()) + } + + // 如果没有任何入站,返回错误 + if len(inbounds) == 0 { + render.Status(r, http.StatusBadRequest) + render.JSON(w, r, map[string]string{"error": "系统中没有可用的入站,请先创建入站或在请求中指定inbounds"}) + return + } + } + + // 记录日志 + s.logger.Info("添加路由规则: 从 ", strings.Join(inbounds, ", "), " 到 ", outbound) + + // 准备rule对象 + rule := option.Rule{ + Type: "", // 默认为 "default" 类型规则 + DefaultOptions: option.DefaultRule{ + RawDefaultRule: option.RawDefaultRule{ + Inbound: inbounds, + }, + RuleAction: option.RuleAction{ + Action: "route", // 设置为route动作 + RouteOptions: option.RouteActionOptions{ + Outbound: outbound, + }, + }, + }, + } + + // 从请求中提取其他规则选项 + if processNameRaw, ok := requestMap["process_name"]; ok { + if processNames, ok := processNameRaw.([]interface{}); ok { + var processNameList []string + for _, pn := range processNames { + if pnStr, ok := pn.(string); ok { + processNameList = append(processNameList, pnStr) + } + } + if len(processNameList) > 0 { + rule.DefaultOptions.RawDefaultRule.ProcessName = processNameList + } + } + } + + // 添加对process_pid的处理 + if processPIDRaw, ok := requestMap["process_pid"]; ok { + if processPIDs, ok := processPIDRaw.([]interface{}); ok { + var processPIDList []uint32 + for _, pid := range processPIDs { + if pidFloat, ok := pid.(float64); ok { + processPIDList = append(processPIDList, uint32(pidFloat)) + } else if pidNumber, ok := pid.(json.Number); ok { + if pidInt64, err := pidNumber.Int64(); err == nil { + processPIDList = append(processPIDList, uint32(pidInt64)) + } + } + } + if len(processPIDList) > 0 { + rule.DefaultOptions.RawDefaultRule.ProcessPID = processPIDList + } + } + } + + // 创建适配器Rule对象并添加到路由系统 + adapterRule, err := R.NewRule(s.ctx, s.logger, rule, true) + if err != nil { + render.Status(r, http.StatusBadRequest) + render.JSON(w, r, map[string]string{"error": "创建规则失败: " + err.Error()}) + return + } + + // 使用Router的AddRule方法添加规则 + ruleIndex := s.router.AddRule(adapterRule) + + render.Status(r, http.StatusOK) + render.JSON(w, r, map[string]interface{}{ + "success": true, + "message": "路由规则添加成功", + "inbounds": inbounds, + "outbound": outbound, + "index": ruleIndex, + }) +} + +// 添加removeRouteRule方法 +func (s *Server) removeRouteRule(w http.ResponseWriter, r *http.Request) { + indexStr := chi.URLParam(r, "index") + if indexStr == "" { + render.Status(r, http.StatusBadRequest) + render.JSON(w, r, map[string]string{"error": "索引不能为空"}) + return + } + + index, err := strconv.Atoi(indexStr) + if err != nil { + render.Status(r, http.StatusBadRequest) + render.JSON(w, r, map[string]string{"error": "索引必须是有效的整数"}) + return + } + + // 使用Router的RemoveRule方法移除规则 + err = s.router.RemoveRule(index) + if err != nil { + render.Status(r, http.StatusBadRequest) + render.JSON(w, r, map[string]string{"error": "移除规则失败: " + err.Error()}) + return + } + + render.Status(r, http.StatusOK) + render.JSON(w, r, map[string]interface{}{ + "success": true, + "message": "路由规则已删除", + "index": index, + }) +} + +// 添加listRouteRules方法 +func (s *Server) listRouteRules(w http.ResponseWriter, r *http.Request) { + // 获取实际路由规则列表 + rawRules := s.router.Rules() + + var rules []map[string]interface{} + for i, rule := range rawRules { + ruleMap := map[string]interface{}{ + "index": i, + "type": rule.Type(), + "outbound": rule.Action().String(), + "desc": rule.String(), + } + rules = append(rules, ruleMap) + } + + render.Status(r, http.StatusOK) + render.JSON(w, r, map[string]interface{}{ + "success": true, + "rules": rules, + }) +} diff --git a/include/dynamicapi.go b/include/dynamicapi.go new file mode 100644 index 00000000..c55f62d1 --- /dev/null +++ b/include/dynamicapi.go @@ -0,0 +1,5 @@ +//go:build with_dynamic_api + +package include + +import _ "github.com/sagernet/sing-box/experimental/dynamicapi" diff --git a/include/dynamicapi_stub.go b/include/dynamicapi_stub.go new file mode 100644 index 00000000..cf029b1d --- /dev/null +++ b/include/dynamicapi_stub.go @@ -0,0 +1,19 @@ +//go:build !with_dynamic_api + +package include + +import ( + "context" + + "github.com/sagernet/sing-box/adapter" + "github.com/sagernet/sing-box/experimental" + "github.com/sagernet/sing-box/log" + "github.com/sagernet/sing-box/option" + E "github.com/sagernet/sing/common/exceptions" +) + +func init() { + experimental.RegisterDynamicManagerConstructor(func(ctx context.Context, logger log.ContextLogger, options option.DynamicAPIOptions) (adapter.DynamicManager, error) { + return nil, E.New(`dynamic api is not included in this build, rebuild with -tags with_dynamic_api`) + }) +} diff --git a/option/dns.go b/option/dns.go index f303b894..9ff1e9a5 100644 --- a/option/dns.go +++ b/option/dns.go @@ -387,3 +387,50 @@ type DHCPDNSServerOptions struct { LocalDNSServerOptions Interface string `json:"interface,omitempty"` } + +type RawDefaultDNSRule struct { + Inbound badoption.Listable[string] `json:"inbound,omitempty"` + IPVersion int `json:"ip_version,omitempty"` + QueryType badoption.Listable[DNSQueryType] `json:"query_type,omitempty"` + Network badoption.Listable[string] `json:"network,omitempty"` + AuthUser badoption.Listable[string] `json:"auth_user,omitempty"` + Protocol badoption.Listable[string] `json:"protocol,omitempty"` + Client badoption.Listable[string] `json:"client,omitempty"` + Domain badoption.Listable[string] `json:"domain,omitempty"` + DomainSuffix badoption.Listable[string] `json:"domain_suffix,omitempty"` + DomainKeyword badoption.Listable[string] `json:"domain_keyword,omitempty"` + DomainRegex badoption.Listable[string] `json:"domain_regex,omitempty"` + Geosite badoption.Listable[string] `json:"geosite,omitempty"` + SourceGeoIP badoption.Listable[string] `json:"source_geoip,omitempty"` + GeoIP badoption.Listable[string] `json:"geoip,omitempty"` + SourceIPCIDR badoption.Listable[string] `json:"source_ip_cidr,omitempty"` + SourceIPIsPrivate bool `json:"source_ip_is_private,omitempty"` + IPCIDR badoption.Listable[string] `json:"ip_cidr,omitempty"` + IPIsPrivate bool `json:"ip_is_private,omitempty"` + IPAcceptAny bool `json:"ip_accept_any,omitempty"` + SourcePort badoption.Listable[uint16] `json:"source_port,omitempty"` + SourcePortRange badoption.Listable[string] `json:"source_port_range,omitempty"` + Port badoption.Listable[uint16] `json:"port,omitempty"` + PortRange badoption.Listable[string] `json:"port_range,omitempty"` + ProcessName badoption.Listable[string] `json:"process_name,omitempty"` + ProcessPath badoption.Listable[string] `json:"process_path,omitempty"` + ProcessPathRegex badoption.Listable[string] `json:"process_path_regex,omitempty"` + ProcessPID badoption.Listable[uint32] `json:"process_pid,omitempty"` + PackageName badoption.Listable[string] `json:"package_name,omitempty"` + User badoption.Listable[string] `json:"user,omitempty"` + UserID badoption.Listable[int32] `json:"user_id,omitempty"` + Outbound badoption.Listable[string] `json:"outbound,omitempty"` + ClashMode string `json:"clash_mode,omitempty"` + NetworkType badoption.Listable[InterfaceType] `json:"network_type,omitempty"` + NetworkIsExpensive bool `json:"network_is_expensive,omitempty"` + NetworkIsConstrained bool `json:"network_is_constrained,omitempty"` + WIFISSID badoption.Listable[string] `json:"wifi_ssid,omitempty"` + WIFIBSSID badoption.Listable[string] `json:"wifi_bssid,omitempty"` + RuleSet badoption.Listable[string] `json:"rule_set,omitempty"` + RuleSetIPCIDRMatchSource bool `json:"rule_set_ip_cidr_match_source,omitempty"` + RuleSetIPCIDRAcceptEmpty bool `json:"rule_set_ip_cidr_accept_empty,omitempty"` + Invert bool `json:"invert,omitempty"` + + // Deprecated: renamed to rule_set_ip_cidr_match_source + Deprecated_RulesetIPCIDRMatchSource bool `json:"rule_set_ipcidr_match_source,omitempty"` +} diff --git a/option/dynamic.go b/option/dynamic.go new file mode 100644 index 00000000..646ef4e8 --- /dev/null +++ b/option/dynamic.go @@ -0,0 +1,8 @@ +package option + +type DynamicAPIOptions struct { + // DynamicAPI服务器监听地址 + Listen string `json:"listen"` + // API认证密钥 + Secret string `json:"secret"` +} diff --git a/option/experimental.go b/option/experimental.go index bf0df9e7..f9a7d7a6 100644 --- a/option/experimental.go +++ b/option/experimental.go @@ -3,10 +3,11 @@ package option import "github.com/sagernet/sing/common/json/badoption" type ExperimentalOptions struct { - CacheFile *CacheFileOptions `json:"cache_file,omitempty"` - ClashAPI *ClashAPIOptions `json:"clash_api,omitempty"` - V2RayAPI *V2RayAPIOptions `json:"v2ray_api,omitempty"` - Debug *DebugOptions `json:"debug,omitempty"` + CacheFile *CacheFileOptions `json:"cache_file,omitempty"` + ClashAPI *ClashAPIOptions `json:"clash_api,omitempty"` + V2RayAPI *V2RayAPIOptions `json:"v2ray_api,omitempty"` + Debug *DebugOptions `json:"debug,omitempty"` + DynamicAPI *DynamicAPIOptions `json:"dynamic_api,omitempty"` } type CacheFileOptions struct { diff --git a/option/rule.go b/option/rule.go index 41bcc126..82c240e9 100644 --- a/option/rule.go +++ b/option/rule.go @@ -91,6 +91,7 @@ type RawDefaultRule struct { ProcessName badoption.Listable[string] `json:"process_name,omitempty"` ProcessPath badoption.Listable[string] `json:"process_path,omitempty"` ProcessPathRegex badoption.Listable[string] `json:"process_path_regex,omitempty"` + ProcessPID badoption.Listable[uint32] `json:"process_pid,omitempty"` PackageName badoption.Listable[string] `json:"package_name,omitempty"` User badoption.Listable[string] `json:"user,omitempty"` UserID badoption.Listable[int32] `json:"user_id,omitempty"` diff --git a/option/rule_dns.go b/option/rule_dns.go index 87b15017..2ad08b4f 100644 --- a/option/rule_dns.go +++ b/option/rule_dns.go @@ -9,7 +9,6 @@ import ( E "github.com/sagernet/sing/common/exceptions" "github.com/sagernet/sing/common/json" "github.com/sagernet/sing/common/json/badjson" - "github.com/sagernet/sing/common/json/badoption" ) type _DNSRule struct { @@ -67,50 +66,7 @@ func (r DNSRule) IsValid() bool { } } -type RawDefaultDNSRule struct { - Inbound badoption.Listable[string] `json:"inbound,omitempty"` - IPVersion int `json:"ip_version,omitempty"` - QueryType badoption.Listable[DNSQueryType] `json:"query_type,omitempty"` - Network badoption.Listable[string] `json:"network,omitempty"` - AuthUser badoption.Listable[string] `json:"auth_user,omitempty"` - Protocol badoption.Listable[string] `json:"protocol,omitempty"` - Domain badoption.Listable[string] `json:"domain,omitempty"` - DomainSuffix badoption.Listable[string] `json:"domain_suffix,omitempty"` - DomainKeyword badoption.Listable[string] `json:"domain_keyword,omitempty"` - DomainRegex badoption.Listable[string] `json:"domain_regex,omitempty"` - Geosite badoption.Listable[string] `json:"geosite,omitempty"` - SourceGeoIP badoption.Listable[string] `json:"source_geoip,omitempty"` - GeoIP badoption.Listable[string] `json:"geoip,omitempty"` - IPCIDR badoption.Listable[string] `json:"ip_cidr,omitempty"` - IPIsPrivate bool `json:"ip_is_private,omitempty"` - IPAcceptAny bool `json:"ip_accept_any,omitempty"` - SourceIPCIDR badoption.Listable[string] `json:"source_ip_cidr,omitempty"` - SourceIPIsPrivate bool `json:"source_ip_is_private,omitempty"` - SourcePort badoption.Listable[uint16] `json:"source_port,omitempty"` - SourcePortRange badoption.Listable[string] `json:"source_port_range,omitempty"` - Port badoption.Listable[uint16] `json:"port,omitempty"` - PortRange badoption.Listable[string] `json:"port_range,omitempty"` - ProcessName badoption.Listable[string] `json:"process_name,omitempty"` - ProcessPath badoption.Listable[string] `json:"process_path,omitempty"` - ProcessPathRegex badoption.Listable[string] `json:"process_path_regex,omitempty"` - PackageName badoption.Listable[string] `json:"package_name,omitempty"` - User badoption.Listable[string] `json:"user,omitempty"` - UserID badoption.Listable[int32] `json:"user_id,omitempty"` - Outbound badoption.Listable[string] `json:"outbound,omitempty"` - ClashMode string `json:"clash_mode,omitempty"` - NetworkType badoption.Listable[InterfaceType] `json:"network_type,omitempty"` - NetworkIsExpensive bool `json:"network_is_expensive,omitempty"` - NetworkIsConstrained bool `json:"network_is_constrained,omitempty"` - WIFISSID badoption.Listable[string] `json:"wifi_ssid,omitempty"` - WIFIBSSID badoption.Listable[string] `json:"wifi_bssid,omitempty"` - RuleSet badoption.Listable[string] `json:"rule_set,omitempty"` - RuleSetIPCIDRMatchSource bool `json:"rule_set_ip_cidr_match_source,omitempty"` - RuleSetIPCIDRAcceptEmpty bool `json:"rule_set_ip_cidr_accept_empty,omitempty"` - Invert bool `json:"invert,omitempty"` - - // Deprecated: renamed to rule_set_ip_cidr_match_source - Deprecated_RulesetIPCIDRMatchSource bool `json:"rule_set_ipcidr_match_source,omitempty"` -} +// RawDefaultDNSRule已在dns.go中定义 type DefaultDNSRule struct { RawDefaultDNSRule diff --git a/option/rule_set.go b/option/rule_set.go index bf644764..ce0da49f 100644 --- a/option/rule_set.go +++ b/option/rule_set.go @@ -161,6 +161,7 @@ type DefaultHeadlessRule struct { ProcessName badoption.Listable[string] `json:"process_name,omitempty"` ProcessPath badoption.Listable[string] `json:"process_path,omitempty"` ProcessPathRegex badoption.Listable[string] `json:"process_path_regex,omitempty"` + ProcessPID badoption.Listable[uint32] `json:"process_pid,omitempty"` PackageName badoption.Listable[string] `json:"package_name,omitempty"` NetworkType badoption.Listable[InterfaceType] `json:"network_type,omitempty"` NetworkIsExpensive bool `json:"network_is_expensive,omitempty"` diff --git a/route/router.go b/route/router.go index ae2ecb55..4dd085e5 100644 --- a/route/router.go +++ b/route/router.go @@ -203,6 +203,23 @@ func (r *Router) Rules() []adapter.Rule { return r.rules } +// AddRule 添加新的路由规则 +func (r *Router) AddRule(rule adapter.Rule) int { + r.rules = append(r.rules, rule) + return len(r.rules) - 1 +} + +// RemoveRule 根据索引移除路由规则 +func (r *Router) RemoveRule(index int) error { + if index < 0 || index >= len(r.rules) { + return E.New("规则索引超出范围") + } + + // 移除指定索引的规则 + r.rules = append(r.rules[:index], r.rules[index+1:]...) + return nil +} + func (r *Router) AppendTracker(tracker adapter.ConnectionTracker) { r.trackers = append(r.trackers, tracker) } diff --git a/route/rule/rule_default.go b/route/rule/rule_default.go index aa6059d2..c5f4a2dc 100644 --- a/route/rule/rule_default.go +++ b/route/rule/rule_default.go @@ -198,6 +198,11 @@ func NewDefaultRule(ctx context.Context, logger log.ContextLogger, options optio rule.items = append(rule.items, item) rule.allItems = append(rule.allItems, item) } + if len(options.ProcessPID) > 0 { + item := NewProcessPIDItem(options.ProcessPID) + rule.items = append(rule.items, item) + rule.allItems = append(rule.allItems, item) + } if len(options.PackageName) > 0 { item := NewPackageNameItem(options.PackageName) rule.items = append(rule.items, item) diff --git a/route/rule/rule_dns.go b/route/rule/rule_dns.go index 087fb7b2..8a4f1705 100644 --- a/route/rule/rule_dns.go +++ b/route/rule/rule_dns.go @@ -194,6 +194,11 @@ func NewDefaultDNSRule(ctx context.Context, logger log.ContextLogger, options op rule.items = append(rule.items, item) rule.allItems = append(rule.allItems, item) } + if len(options.ProcessPID) > 0 { + item := NewProcessPIDItem(options.ProcessPID) + rule.items = append(rule.items, item) + rule.allItems = append(rule.allItems, item) + } if len(options.PackageName) > 0 { item := NewPackageNameItem(options.PackageName) rule.items = append(rule.items, item) diff --git a/route/rule/rule_headless.go b/route/rule/rule_headless.go index 619856a5..5f9973e3 100644 --- a/route/rule/rule_headless.go +++ b/route/rule/rule_headless.go @@ -136,6 +136,11 @@ func NewDefaultHeadlessRule(ctx context.Context, options option.DefaultHeadlessR rule.items = append(rule.items, item) rule.allItems = append(rule.allItems, item) } + if len(options.ProcessPID) > 0 { + item := NewProcessPIDItem(options.ProcessPID) + rule.items = append(rule.items, item) + rule.allItems = append(rule.allItems, item) + } if len(options.PackageName) > 0 { item := NewPackageNameItem(options.PackageName) rule.items = append(rule.items, item) diff --git a/route/rule/rule_item_process_pid.go b/route/rule/rule_item_process_pid.go new file mode 100644 index 00000000..c0f4408c --- /dev/null +++ b/route/rule/rule_item_process_pid.go @@ -0,0 +1,44 @@ +package rule + +import ( + "strings" + + "github.com/sagernet/sing-box/adapter" + F "github.com/sagernet/sing/common/format" +) + +var _ RuleItem = (*ProcessPIDItem)(nil) + +type ProcessPIDItem struct { + processPIDs []uint32 + processPIDMap map[uint32]bool +} + +func NewProcessPIDItem(processPIDList []uint32) *ProcessPIDItem { + rule := &ProcessPIDItem{ + processPIDs: processPIDList, + processPIDMap: make(map[uint32]bool), + } + for _, processPID := range processPIDList { + rule.processPIDMap[processPID] = true + } + return rule +} + +func (r *ProcessPIDItem) Match(metadata *adapter.InboundContext) bool { + if metadata.ProcessInfo == nil || metadata.ProcessInfo.ProcessID == 0 { + return false + } + return r.processPIDMap[metadata.ProcessInfo.ProcessID] +} + +func (r *ProcessPIDItem) String() string { + var description string + pLen := len(r.processPIDs) + if pLen == 1 { + description = "process_pid=" + F.ToString(r.processPIDs[0]) + } else { + description = "process_pid=[" + strings.Join(F.MapToString(r.processPIDs), " ") + "]" + } + return description +} diff --git a/route/rule/rule_set.go b/route/rule/rule_set.go index 5e639a47..8d64a4de 100644 --- a/route/rule/rule_set.go +++ b/route/rule/rule_set.go @@ -59,7 +59,7 @@ func hasHeadlessRule(rules []option.HeadlessRule, cond func(rule option.DefaultH } func isProcessHeadlessRule(rule option.DefaultHeadlessRule) bool { - return len(rule.ProcessName) > 0 || len(rule.ProcessPath) > 0 || len(rule.ProcessPathRegex) > 0 || len(rule.PackageName) > 0 + return len(rule.ProcessName) > 0 || len(rule.ProcessPath) > 0 || len(rule.ProcessPathRegex) > 0 || len(rule.ProcessPID) > 0 || len(rule.PackageName) > 0 } func isWIFIHeadlessRule(rule option.DefaultHeadlessRule) bool { diff --git a/route/rule_conds.go b/route/rule_conds.go index 55c4a058..b746c870 100644 --- a/route/rule_conds.go +++ b/route/rule_conds.go @@ -38,11 +38,11 @@ func hasDNSRule(rules []option.DNSRule, cond func(rule option.DefaultDNSRule) bo } func isProcessRule(rule option.DefaultRule) bool { - return len(rule.ProcessName) > 0 || len(rule.ProcessPath) > 0 || len(rule.ProcessPathRegex) > 0 || len(rule.PackageName) > 0 || len(rule.User) > 0 || len(rule.UserID) > 0 + return len(rule.ProcessName) > 0 || len(rule.ProcessPath) > 0 || len(rule.ProcessPathRegex) > 0 || len(rule.ProcessPID) > 0 || len(rule.PackageName) > 0 || len(rule.User) > 0 || len(rule.UserID) > 0 } func isProcessDNSRule(rule option.DefaultDNSRule) bool { - return len(rule.ProcessName) > 0 || len(rule.ProcessPath) > 0 || len(rule.ProcessPathRegex) > 0 || len(rule.PackageName) > 0 || len(rule.User) > 0 || len(rule.UserID) > 0 + return len(rule.ProcessName) > 0 || len(rule.ProcessPath) > 0 || len(rule.ProcessPathRegex) > 0 || len(rule.ProcessPID) > 0 || len(rule.PackageName) > 0 || len(rule.User) > 0 || len(rule.UserID) > 0 } func isWIFIRule(rule option.DefaultRule) bool {