mirror of
https://github.com/shtorm-7/sing-box-extended.git
synced 2026-06-16 08:12:02 +03:00
Add daemon support
This commit is contained in:
165
experimental/daemon/daemon.go
Executable file
165
experimental/daemon/daemon.go
Executable 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()
|
||||
}
|
||||
58
experimental/daemon/instance.go
Normal file
58
experimental/daemon/instance.go
Normal 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
|
||||
}
|
||||
20
experimental/daemon/interface.go
Normal file
20
experimental/daemon/interface.go
Normal 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
|
||||
}
|
||||
147
experimental/daemon/server.go
Normal file
147
experimental/daemon/server.go
Normal 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)
|
||||
}
|
||||
Reference in New Issue
Block a user