Browse Source

Add daemon support

世界 3 years ago
parent
commit
f48f8c5d1c

+ 1 - 1
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='

+ 272 - 0
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
+}

+ 165 - 0
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()
+}

+ 58 - 0
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
+}

+ 20 - 0
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
+}

+ 147 - 0
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)
+}

+ 1 - 0
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

+ 3 - 0
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=