Files
sing-box-extended/service/admin_panel/service.go

281 lines
7.7 KiB
Go

//go:build with_admin_panel
package admin_panel
import (
"context"
"embed"
"encoding/json"
"errors"
"io/fs"
"mime"
"net/http"
"path"
"strconv"
"strings"
"time"
"github.com/sagernet/sing-box/adapter"
boxService "github.com/sagernet/sing-box/adapter/service"
"github.com/sagernet/sing-box/common/listener"
"github.com/sagernet/sing-box/common/tls"
C "github.com/sagernet/sing-box/constant"
"github.com/sagernet/sing-box/log"
"github.com/sagernet/sing-box/option"
"github.com/sagernet/sing/common"
E "github.com/sagernet/sing/common/exceptions"
N "github.com/sagernet/sing/common/network"
aTLS "github.com/sagernet/sing/common/tls"
sHTTP "github.com/sagernet/sing/protocol/http"
"github.com/go-chi/chi/v5"
"golang.org/x/net/http2"
)
// distFS holds the SPA bytes produced by `npm run build` (Vite) and then
// post-processed by `cmd/internal/admin_panel_pack`. The directory
// is checked into the repo so a plain `go build -tags with_admin_panel`
// produces a self-contained binary — no Node.js required at compile time.
//
// The post-processor:
// - deletes the legacy *.woff fonts (every browser since 2014 reads
// WOFF2 natively) and removes their references from the bundled CSS;
// - drops a gzip-compressed `*.gz` companion next to every compressible
// text asset (.html, .css, .js, …) using BestCompression. We pass
// those bytes through verbatim with Content-Encoding: gzip when the
// client advertises gzip, and fall back to the raw file otherwise.
//
//go:embed dist
var distFS embed.FS
// distRoot is the embed.FS rooted at `dist/`, so handlers can use plain
// "index.html" / "assets/..." keys instead of the "dist/..." prefix.
var distRoot = func() fs.FS {
sub, err := fs.Sub(distFS, "dist")
if err != nil {
// Cannot happen unless the //go:embed pattern above and the
// fs.Sub argument disagree; bail loudly so the mismatch is
// caught at startup.
panic(err)
}
return sub
}()
func RegisterService(registry *boxService.Registry) {
boxService.Register[option.AdminPanelServiceOptions](registry, C.TypeAdminPanel, NewService)
}
type Service struct {
boxService.Adapter
ctx context.Context
logger log.ContextLogger
listener *listener.Listener
tlsConfig tls.ServerConfig
httpServer *http.Server
options option.AdminPanelServiceOptions
}
func NewService(ctx context.Context, logger log.ContextLogger, tag string, options option.AdminPanelServiceOptions) (adapter.Service, error) {
return &Service{
Adapter: boxService.NewAdapter(C.TypeAdminPanel, tag),
ctx: ctx,
logger: logger,
listener: listener.New(listener.Options{
Context: ctx,
Logger: logger,
Network: []string{N.NetworkTCP},
Listen: options.ListenOptions,
}),
options: options,
}, nil
}
func (s *Service) Start(stage adapter.StartStage) error {
if stage != adapter.StartStateStart {
return nil
}
chiRouter := chi.NewRouter()
s.Route(chiRouter)
if s.options.TLS != nil {
tlsConfig, err := tls.NewServer(s.ctx, s.logger, common.PtrValueOrDefault(s.options.TLS))
if err != nil {
return err
}
s.tlsConfig = tlsConfig
}
if s.tlsConfig != nil {
err := s.tlsConfig.Start()
if err != nil {
return E.Cause(err, "create TLS config")
}
}
tcpListener, err := s.listener.ListenTCP()
if err != nil {
return err
}
if s.tlsConfig != nil {
if !common.Contains(s.tlsConfig.NextProtos(), http2.NextProtoTLS) {
s.tlsConfig.SetNextProtos(append([]string{"h2"}, s.tlsConfig.NextProtos()...))
}
tcpListener = aTLS.NewListener(tcpListener, s.tlsConfig)
}
s.httpServer = &http.Server{
Handler: chiRouter,
ReadHeaderTimeout: 10 * time.Second,
ReadTimeout: 30 * time.Second,
WriteTimeout: 30 * time.Second,
IdleTimeout: 2 * time.Minute,
}
go func() {
err := s.httpServer.Serve(tcpListener)
if err != nil && !errors.Is(err, http.ErrServerClosed) {
s.logger.Error("serve error: ", err)
}
}()
return nil
}
func (s *Service) Close() error {
if s.httpServer != nil {
ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second)
defer cancel()
_ = s.httpServer.Shutdown(ctx)
}
return common.Close(
common.PtrOrNil(s.listener),
s.tlsConfig,
)
}
func (s *Service) Route(r chi.Router) {
r.Use(func(handler http.Handler) http.Handler {
return http.HandlerFunc(func(writer http.ResponseWriter, request *http.Request) {
s.logger.Debug(request.Method, " ", request.RequestURI, " ", sHTTP.SourceAddress(request))
handler.ServeHTTP(writer, request)
})
})
r.Get("/version", func(w http.ResponseWriter, _ *http.Request) {
w.Header().Set("Content-Type", "application/json; charset=utf-8")
w.Header().Set("Cache-Control", "no-store")
_ = json.NewEncoder(w).Encode(map[string]string{
"version": C.Version,
})
})
handler := newSPAHandler()
r.Method(http.MethodGet, "/*", handler)
r.Method(http.MethodHead, "/*", handler)
}
func newSPAHandler() http.Handler {
_, hasIndex := readFile("index.html")
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
reqPath := strings.TrimPrefix(path.Clean(r.URL.Path), "/")
if reqPath != "" && reqPath != "index.html" {
if data, ok := readFile(reqPath); ok {
serveAsset(w, r, reqPath, data)
return
}
}
if !hasIndex {
http.Error(w, "admin panel not built", http.StatusInternalServerError)
return
}
serveIndex(w, r)
})
}
func readFile(name string) ([]byte, bool) {
data, err := fs.ReadFile(distRoot, name)
if err != nil {
return nil, false
}
return data, true
}
func gzipCompanion(name string) ([]byte, bool) {
return readFile(name + ".gz")
}
func serveAsset(w http.ResponseWriter, r *http.Request, name string, raw []byte) {
if ctype := contentType(name); ctype != "" {
w.Header().Set("Content-Type", ctype)
}
if isHashedAsset(name) {
w.Header().Set("Cache-Control", "public, max-age=31536000, immutable")
} else {
w.Header().Set("Cache-Control", "no-cache, must-revalidate")
}
writeBody(w, r, name, raw)
}
func serveIndex(w http.ResponseWriter, r *http.Request) {
raw, _ := readFile("index.html")
w.Header().Set("Content-Type", "text/html; charset=utf-8")
w.Header().Set("Cache-Control", "no-store, no-cache, must-revalidate, max-age=0")
w.Header().Set("Pragma", "no-cache")
w.Header().Set("Expires", "0")
writeBody(w, r, "index.html", raw)
}
func writeBody(w http.ResponseWriter, r *http.Request, name string, raw []byte) {
header := w.Header()
if gz, ok := gzipCompanion(name); ok {
// Even when we end up serving the raw bytes (because the client
// declined gzip), let any shared cache know the response varies
// by Accept-Encoding so it doesn't hand a gzipped payload to a
// non-gzip client.
header.Add("Vary", "Accept-Encoding")
if acceptsGzip(r) {
header.Set("Content-Encoding", "gzip")
header.Set("Content-Length", strconv.Itoa(len(gz)))
if r.Method == http.MethodHead {
return
}
_, _ = w.Write(gz)
return
}
}
header.Set("Content-Length", strconv.Itoa(len(raw)))
if r.Method == http.MethodHead {
return
}
_, _ = w.Write(raw)
}
func acceptsGzip(r *http.Request) bool {
for _, h := range r.Header.Values("Accept-Encoding") {
for _, part := range strings.Split(h, ",") {
tok := strings.TrimSpace(part)
if i := strings.IndexByte(tok, ';'); i >= 0 {
tok = strings.TrimSpace(tok[:i])
}
if strings.EqualFold(tok, "gzip") {
return true
}
}
}
return false
}
func isHashedAsset(name string) bool {
return strings.HasPrefix(name, "assets/")
}
func contentType(name string) string {
ext := path.Ext(name)
if ct := mime.TypeByExtension(ext); ct != "" {
return ct
}
switch ext {
case ".js", ".mjs":
return "application/javascript; charset=utf-8"
case ".css":
return "text/css; charset=utf-8"
case ".html":
return "text/html; charset=utf-8"
case ".svg":
return "image/svg+xml"
}
return ""
}