diff --git a/Makefile b/Makefile index 05049e14..f4cbe80a 100644 --- a/Makefile +++ b/Makefile @@ -1,6 +1,6 @@ NAME = sing-box COMMIT = $(shell git rev-parse --short HEAD) -TAGS ?= with_quic,with_wireguard,with_clash_api +TAGS ?= with_quic,with_wireguard,with_clash_api,with_daemon PARAMS = -v -trimpath -tags '$(TAGS)' -ldflags \ '-X "github.com/sagernet/sing-box/constant.Commit=$(COMMIT)" \ -w -s -buildid=' diff --git a/cmd/sing-box/cmd_daemon.go b/cmd/sing-box/cmd_daemon.go new file mode 100644 index 00000000..e7993dd6 --- /dev/null +++ b/cmd/sing-box/cmd_daemon.go @@ -0,0 +1,272 @@ +//go:build with_daemon + +package main + +import ( + "bytes" + "io" + "net" + "net/http" + "net/url" + "os" + + "github.com/sagernet/sing-box/common/json" + "github.com/sagernet/sing-box/experimental/daemon" + "github.com/sagernet/sing-box/log" + "github.com/sagernet/sing/common" + E "github.com/sagernet/sing/common/exceptions" + F "github.com/sagernet/sing/common/format" + + "github.com/spf13/cobra" +) + +var commandDaemon = &cobra.Command{ + Use: "daemon", +} + +func init() { + commandDaemon.AddCommand(commandDaemonInstall) + commandDaemon.AddCommand(commandDaemonUninstall) + commandDaemon.AddCommand(commandDaemonStart) + commandDaemon.AddCommand(commandDaemonStop) + commandDaemon.AddCommand(commandDaemonRestart) + commandDaemon.AddCommand(commandDaemonRun) + mainCommand.AddCommand(commandDaemon) + mainCommand.AddCommand(commandStart) + mainCommand.AddCommand(commandStop) + mainCommand.AddCommand(commandStatus) +} + +var commandDaemonInstall = &cobra.Command{ + Use: "install", + Short: "Install daemon", + Run: func(cmd *cobra.Command, args []string) { + err := installDaemon() + if err != nil { + log.Fatal(err) + } + }, + Args: cobra.NoArgs, +} + +var commandDaemonUninstall = &cobra.Command{ + Use: "uninstall", + Short: "Uninstall daemon", + Run: func(cmd *cobra.Command, args []string) { + err := uninstallDaemon() + if err != nil { + log.Fatal(err) + } + }, + Args: cobra.NoArgs, +} + +var commandDaemonStart = &cobra.Command{ + Use: "start", + Short: "Start daemon", + Run: func(cmd *cobra.Command, args []string) { + err := startDaemon() + if err != nil { + log.Fatal(err) + } + }, + Args: cobra.NoArgs, +} + +var commandDaemonStop = &cobra.Command{ + Use: "stop", + Short: "Stop daemon", + Run: func(cmd *cobra.Command, args []string) { + err := stopDaemon() + if err != nil { + log.Fatal(err) + } + }, + Args: cobra.NoArgs, +} + +var commandDaemonRestart = &cobra.Command{ + Use: "restart", + Short: "Restart daemon", + Run: func(cmd *cobra.Command, args []string) { + err := restartDaemon() + if err != nil { + log.Fatal(err) + } + }, + Args: cobra.NoArgs, +} + +var commandDaemonRun = &cobra.Command{ + Use: "run", + Short: "Run daemon", + Run: func(cmd *cobra.Command, args []string) { + err := runDaemon() + if err != nil { + log.Fatal(err) + } + }, + Args: cobra.NoArgs, +} + +func installDaemon() error { + instance, err := daemon.New() + if err != nil { + return err + } + return instance.Install() +} + +func uninstallDaemon() error { + instance, err := daemon.New() + if err != nil { + return err + } + return instance.Uninstall() +} + +func startDaemon() error { + instance, err := daemon.New() + if err != nil { + return err + } + return instance.Start() +} + +func stopDaemon() error { + instance, err := daemon.New() + if err != nil { + return err + } + return instance.Stop() +} + +func restartDaemon() error { + instance, err := daemon.New() + if err != nil { + return err + } + return instance.Restart() +} + +func runDaemon() error { + instance, err := daemon.New() + if err != nil { + return err + } + return instance.Run() +} + +var commandStart = &cobra.Command{ + Use: "start", + Short: "Start service", + Run: func(cmd *cobra.Command, args []string) { + err := startService() + if err != nil { + log.Fatal(err) + } + }, + Args: cobra.NoArgs, +} + +var commandStop = &cobra.Command{ + Use: "stop", + Short: "Stop service", + Run: func(cmd *cobra.Command, args []string) { + err := stopService() + if err != nil { + log.Fatal(err) + } + }, + Args: cobra.NoArgs, +} + +var commandStatus = &cobra.Command{ + Use: "status", + Short: "Check service", + Run: func(cmd *cobra.Command, args []string) { + err := checkService() + if err != nil { + log.Fatal(err) + } + }, + Args: cobra.NoArgs, +} + +func doRequest(method string, path string, params url.Values, body io.ReadCloser) ([]byte, error) { + requestURL := url.URL{ + Scheme: "http", + Path: path, + Host: net.JoinHostPort("127.0.0.1", F.ToString(daemon.DefaultDaemonPort)), + } + if params != nil { + requestURL.RawQuery = params.Encode() + } + request, err := http.NewRequest(method, requestURL.String(), body) + if err != nil { + return nil, err + } + response, err := http.DefaultClient.Do(request) + if err != nil { + return nil, err + } + defer response.Body.Close() + var content []byte + if response.StatusCode != http.StatusNoContent { + content, err = io.ReadAll(response.Body) + if err != nil { + return nil, err + } + } + if response.StatusCode != http.StatusOK && response.StatusCode != http.StatusNoContent { + return nil, E.New(string(content)) + } + return content, nil +} + +func ping() error { + response, err := doRequest("GET", "/ping", nil, nil) + if err != nil || string(response) != "pong" { + return E.New("daemon not running") + } + return nil +} + +func startService() error { + if err := ping(); err != nil { + return err + } + configContent, err := os.ReadFile(configPath) + if err != nil { + return E.Cause(err, "read config") + } + return common.Error(doRequest("POST", "/run", nil, io.NopCloser(bytes.NewReader(configContent)))) +} + +func stopService() error { + if err := ping(); err != nil { + return err + } + return common.Error(doRequest("GET", "/stop", nil, nil)) +} + +func checkService() error { + if err := ping(); err != nil { + return err + } + response, err := doRequest("GET", "/status", nil, nil) + if err != nil { + return err + } + var statusResponse daemon.StatusResponse + err = json.Unmarshal(response, &statusResponse) + if err != nil { + return err + } + if statusResponse.Running { + log.Info("service running") + } else { + log.Info("service stopped") + } + return nil +} diff --git a/experimental/daemon/daemon.go b/experimental/daemon/daemon.go new file mode 100755 index 00000000..cf4f2f8f --- /dev/null +++ b/experimental/daemon/daemon.go @@ -0,0 +1,165 @@ +package daemon + +import ( + "io" + "os" + "path/filepath" + "runtime" + "strings" + + E "github.com/sagernet/sing/common/exceptions" + "github.com/sagernet/sing/common/rw" + + "github.com/kardianos/service" + C "github.com/sagernet/sing-box/constant" +) + +const ( + DefaultDaemonName = "sing-box-daemon" + DefaultDaemonPort = 9091 +) + +var defaultDaemonOptions = Options{ + Listen: "127.0.0.1", + ListenPort: DefaultDaemonPort, + WorkingDirectory: workingDirectory(), +} + +func workingDirectory() string { + switch runtime.GOOS { + case "linux": + return filepath.Join("/usr/local/lib", DefaultDaemonName) + default: + configDir, err := os.UserConfigDir() + if err == nil { + return filepath.Join(configDir, DefaultDaemonName) + } else { + return DefaultDaemonName + } + } +} + +const systemdScript = `[Unit] +Description=sing-box service +Documentation=https://sing-box.sagernet.org +After=network.target nss-lookup.target + +[Service] +User=root +ExecStart={{.Path|cmdEscape}}{{range .Arguments}} {{.|cmd}}{{end}} +WorkingDirectory={{.WorkingDirectory|cmdEscape}} +Restart=on-failure +RestartSec=10s +LimitNOFILE=infinity + +[Install] +WantedBy=multi-user.target` + +type Daemon struct { + service service.Service + workingDirectory string + executable string +} + +func New() (*Daemon, error) { + daemonInterface := NewInterface(defaultDaemonOptions) + executable := filepath.Join(defaultDaemonOptions.WorkingDirectory, "sing-box") + if C.IsWindows { + executable += ".exe" + } + daemonService, err := service.New(daemonInterface, &service.Config{ + Name: DefaultDaemonName, + Description: "The universal proxy platform.", + Arguments: []string{"daemon", "run"}, + Executable: executable, + Option: service.KeyValue{ + "SystemdScript": systemdScript, + }, + }) + if err != nil { + return nil, E.New(strings.ToLower(err.Error())) + } + return &Daemon{ + service: daemonService, + workingDirectory: defaultDaemonOptions.WorkingDirectory, + executable: executable, + }, nil +} + +func (d *Daemon) Install() error { + _, err := d.service.Status() + if err != service.ErrNotInstalled { + d.service.Stop() + err = d.service.Uninstall() + if err != nil { + return err + } + } + executablePath, err := os.Executable() + if err != nil { + return err + } + if !rw.FileExists(d.workingDirectory) { + err = os.MkdirAll(d.workingDirectory, 0o755) + if err != nil { + return err + } + } + outputFile, err := os.OpenFile(d.executable, os.O_CREATE|os.O_WRONLY, 0o755) + if err != nil { + return err + } + inputFile, err := os.Open(executablePath) + if err != nil { + outputFile.Close() + return err + } + _, err = io.Copy(outputFile, inputFile) + inputFile.Close() + outputFile.Close() + if err != nil { + return err + } + err = d.service.Install() + if err != nil { + return err + } + return d.service.Start() +} + +func (d *Daemon) Uninstall() error { + _, err := d.service.Status() + if err != service.ErrNotInstalled { + d.service.Stop() + err = d.service.Uninstall() + if err != nil { + return err + } + } + return os.RemoveAll(d.workingDirectory) +} + +func (d *Daemon) Run() error { + d.chdir() + return d.service.Run() +} + +func (d *Daemon) chdir() error { + executable, err := os.Executable() + if err != nil { + return err + } + return os.Chdir(filepath.Dir(executable)) +} + +func (d *Daemon) Start() error { + return d.service.Start() +} + +func (d *Daemon) Stop() error { + return d.service.Stop() +} + +func (d *Daemon) Restart() error { + return d.service.Restart() +} diff --git a/experimental/daemon/instance.go b/experimental/daemon/instance.go new file mode 100644 index 00000000..7647b3f2 --- /dev/null +++ b/experimental/daemon/instance.go @@ -0,0 +1,58 @@ +package daemon + +import ( + "context" + "os" + "sync" + + "github.com/sagernet/sing-box" + "github.com/sagernet/sing-box/option" +) + +type Instance struct { + access sync.Mutex + boxInstance *box.Box + boxCancel context.CancelFunc +} + +func (i *Instance) Running() bool { + i.access.Lock() + defer i.access.Unlock() + return i.boxInstance != nil +} + +func (i *Instance) Start(options option.Options) error { + i.access.Lock() + defer i.access.Unlock() + if i.boxInstance != nil { + i.boxCancel() + i.boxInstance.Close() + } + ctx, cancel := context.WithCancel(context.Background()) + instance, err := box.New(ctx, options) + if err != nil { + cancel() + return err + } + err = instance.Start() + if err != nil { + cancel() + return err + } + i.boxInstance = instance + i.boxCancel = cancel + return nil +} + +func (i *Instance) Close() error { + i.access.Lock() + defer i.access.Unlock() + if i.boxInstance == nil { + return os.ErrClosed + } + i.boxCancel() + err := i.boxInstance.Close() + i.boxInstance = nil + i.boxCancel = nil + return err +} diff --git a/experimental/daemon/interface.go b/experimental/daemon/interface.go new file mode 100644 index 00000000..ea01f43f --- /dev/null +++ b/experimental/daemon/interface.go @@ -0,0 +1,20 @@ +package daemon + +import "github.com/kardianos/service" + +type Interface struct { + server *Server +} + +func NewInterface(options Options) *Interface { + return &Interface{NewServer(options)} +} + +func (d *Interface) Start(_ service.Service) error { + return d.server.Start() +} + +func (d *Interface) Stop(_ service.Service) error { + d.server.Close() + return nil +} diff --git a/experimental/daemon/server.go b/experimental/daemon/server.go new file mode 100644 index 00000000..a9fd19e2 --- /dev/null +++ b/experimental/daemon/server.go @@ -0,0 +1,147 @@ +package daemon + +import ( + "io" + "net" + "net/http" + "net/http/pprof" + "strings" + + "github.com/sagernet/sing-box/common/json" + "github.com/sagernet/sing-box/log" + "github.com/sagernet/sing-box/option" + "github.com/sagernet/sing/common" + E "github.com/sagernet/sing/common/exceptions" + F "github.com/sagernet/sing/common/format" + + "github.com/go-chi/chi/v5" + "github.com/go-chi/cors" + "github.com/go-chi/render" + "github.com/gorilla/websocket" +) + +type Options struct { + Listen string `json:"listen"` + ListenPort uint16 `json:"listen_port"` + Secret string `json:"secret"` + WorkingDirectory string `json:"working_directory"` +} + +type Server struct { + options Options + httpServer *http.Server + instance Instance +} + +func NewServer(options Options) *Server { + return &Server{ + options: options, + } +} + +func (s *Server) Start() error { + tcpConn, err := net.Listen("tcp", net.JoinHostPort(s.options.Listen, F.ToString(s.options.ListenPort))) + if err != nil { + return err + } + router := chi.NewRouter() + router.Use(cors.New(cors.Options{ + AllowedOrigins: []string{"*"}, + AllowedMethods: []string{"GET", "POST", "PUT", "PATCH", "DELETE"}, + AllowedHeaders: []string{"Content-Type", "Authorization"}, + MaxAge: 300, + }).Handler) + if s.options.Secret != "" { + router.Use(s.authentication) + } + router.Get("/ping", s.ping) + router.Get("/status", s.status) + router.Post("/run", s.run) + router.Get("/stop", s.stop) + router.Route("/debug/pprof", func(r chi.Router) { + r.HandleFunc("/", pprof.Index) + r.HandleFunc("/cmdline", pprof.Cmdline) + r.HandleFunc("/profile", pprof.Profile) + r.HandleFunc("/symbol", pprof.Symbol) + r.HandleFunc("/trace", pprof.Trace) + }) + httpServer := &http.Server{ + Handler: router, + } + go httpServer.Serve(tcpConn) + s.httpServer = httpServer + return nil +} + +func (s *Server) authentication(next http.Handler) http.Handler { + return http.HandlerFunc(func(writer http.ResponseWriter, request *http.Request) { + if websocket.IsWebSocketUpgrade(request) && request.URL.Query().Get("token") != "" { + token := request.URL.Query().Get("token") + if token != s.options.Secret { + render.Status(request, http.StatusUnauthorized) + return + } + next.ServeHTTP(writer, request) + return + } + header := request.Header.Get("Authorization") + bearer, token, found := strings.Cut(header, " ") + hasInvalidHeader := bearer != "Bearer" + hasInvalidSecret := !found || token != s.options.Secret + if hasInvalidHeader || hasInvalidSecret { + render.Status(request, http.StatusUnauthorized) + return + } + next.ServeHTTP(writer, request) + }) +} + +func (s *Server) Close() error { + return common.Close( + common.PtrOrNil(s.httpServer), + &s.instance, + ) +} + +func (s *Server) ping(writer http.ResponseWriter, request *http.Request) { + render.PlainText(writer, request, "pong") +} + +type StatusResponse struct { + Running bool `json:"running"` +} + +func (s *Server) status(writer http.ResponseWriter, request *http.Request) { + render.JSON(writer, request, StatusResponse{ + Running: s.instance.Running(), + }) +} + +func (s *Server) run(writer http.ResponseWriter, request *http.Request) { + err := s.run0(request) + if err != nil { + log.Warn(err) + render.Status(request, http.StatusBadRequest) + render.PlainText(writer, request, err.Error()) + return + } + writer.WriteHeader(http.StatusNoContent) +} + +func (s *Server) run0(request *http.Request) error { + configContent, err := io.ReadAll(request.Body) + if err != nil { + return E.Cause(err, "read config") + } + var options option.Options + err = json.Unmarshal(configContent, &options) + if err != nil { + return E.Cause(err, "decode config") + } + return s.instance.Start(options) +} + +func (s *Server) stop(writer http.ResponseWriter, request *http.Request) { + s.instance.Close() + writer.WriteHeader(http.StatusNoContent) +} diff --git a/go.mod b/go.mod index 68320359..e7481efc 100644 --- a/go.mod +++ b/go.mod @@ -14,6 +14,7 @@ require ( github.com/gofrs/uuid v4.2.0+incompatible github.com/gorilla/websocket v1.5.0 github.com/hashicorp/yamux v0.1.1 + github.com/kardianos/service v1.2.1 github.com/logrusorgru/aurora v2.0.3+incompatible github.com/mholt/acmez v1.0.4 github.com/oschwald/maxminddb-golang v1.10.0 diff --git a/go.sum b/go.sum index 76d90c63..9d8d7f01 100644 --- a/go.sum +++ b/go.sum @@ -88,6 +88,8 @@ github.com/hashicorp/yamux v0.1.1/go.mod h1:CtWFDAQgb7dxtzFs4tWbplKIe2jSi3+5vKbg github.com/hpcloud/tail v1.0.0/go.mod h1:ab1qPbhIpdTxEkNHXyeSf5vhxWSCs/tWer42PpOxQnU= github.com/inconshreveable/mousetrap v1.0.0 h1:Z8tu5sraLXCXIcARxBp/8cbvlwVa7Z1NHg9XEKhtSvM= github.com/inconshreveable/mousetrap v1.0.0/go.mod h1:PxqpIevigyE2G7u3NXJIT2ANytuPF1OarO4DADm73n8= +github.com/kardianos/service v1.2.1 h1:AYndMsehS+ywIS6RB9KOlcXzteWUzxgMgBymJD7+BYk= +github.com/kardianos/service v1.2.1/go.mod h1:CIMRFEJVL+0DS1a3Nx06NaMn4Dz63Ng6O7dl0qH0zVM= github.com/klauspost/cpuid/v2 v2.0.9/go.mod h1:FInQzS24/EEf25PyTYn52gqo7WaD8xa0213Md/qVLRg= github.com/klauspost/cpuid/v2 v2.1.0 h1:eyi1Ad2aNJMW95zcSbmGg7Cg6cq3ADwLpMAP96d8rF0= github.com/klauspost/cpuid/v2 v2.1.0/go.mod h1:RVVoqg1df56z8g3pUjL/3lE5UfnlrJX8tyFgg4nqhuY= @@ -239,6 +241,7 @@ golang.org/x/sys v0.0.0-20200217220822-9197077df867/go.mod h1:h1NjWce9XRLGQEsW7w golang.org/x/sys v0.0.0-20200323222414-85ca7c5b95cd/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= golang.org/x/sys v0.0.0-20200519105757-fe76b779f299/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= golang.org/x/sys v0.0.0-20200930185726-fdedc70b468f/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= +golang.org/x/sys v0.0.0-20201015000850-e3ed0017c211/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= golang.org/x/sys v0.0.0-20201119102817-f84b799fce68/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= golang.org/x/sys v0.0.0-20210112080510-489259a85091/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= golang.org/x/sys v0.0.0-20210119212857-b64e53b001e4/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=