Add daemon support

This commit is contained in:
世界
2022-08-13 18:37:18 +08:00
parent a940703ae1
commit f48f8c5d1c
8 changed files with 667 additions and 1 deletions

165
experimental/daemon/daemon.go Executable file
View File

@@ -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()
}

View File

@@ -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
}

View File

@@ -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
}

View File

@@ -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)
}