237 lines
6.5 KiB
Go
237 lines
6.5 KiB
Go
package server
|
|
|
|
import (
|
|
"context"
|
|
"errors"
|
|
"fmt"
|
|
"log"
|
|
"net"
|
|
"net/http"
|
|
"os"
|
|
"os/signal"
|
|
"syscall"
|
|
"time"
|
|
|
|
"github.com/go-chi/chi/v5"
|
|
"github.com/go-chi/chi/v5/middleware"
|
|
"github.com/go-chi/render"
|
|
)
|
|
|
|
const indexHtmlPath = ".client/index.html"
|
|
|
|
// BootConfig represents the client configuration
|
|
type BootConfig struct {
|
|
SpaceFolderPath string `json:"spaceFolderPath"`
|
|
IndexPage string `json:"indexPage"`
|
|
ReadOnly bool `json:"readOnly"`
|
|
|
|
// Whether or not the client should push logs to the server
|
|
LogPush bool `json:"logPush"`
|
|
|
|
// Encryption
|
|
EnableClientEncryption bool `json:"enableClientEncryption"`
|
|
|
|
// When true, the client skips service worker registration and
|
|
// unregisters/flushes any previously-installed service worker.
|
|
DisableServiceWorker bool `json:"disableServiceWorker,omitempty"`
|
|
}
|
|
|
|
func Router(config *ServerConfig) chi.Router {
|
|
r := chi.NewRouter()
|
|
|
|
if config.EnableHTTPLogging {
|
|
r.Use(middleware.Logger)
|
|
}
|
|
|
|
r.Use(middleware.RealIP)
|
|
r.Use(middleware.Compress(5, "application/json", "text/markdown", "text/javascript", "text/html", "text/css", "text/plain"))
|
|
|
|
// Expose space primitives and path to the request
|
|
r.Use(spaceMiddleware(config))
|
|
|
|
r.Use(httpStatsMiddleware(config))
|
|
|
|
// Authentication middleware (applies to all routes after this point)
|
|
r.Use(authMiddleware(config))
|
|
|
|
routes := chi.NewRouter()
|
|
|
|
// Authentication endpoints (must come before auth middleware)
|
|
addAuthEndpoints(routes, config)
|
|
|
|
routes.Get("/.ping", func(w http.ResponseWriter, r *http.Request) {
|
|
spaceConfig := spaceConfigFromContext(r.Context())
|
|
w.Header().Set("Cache-Control", "no-cache")
|
|
w.Header().Set("X-Space-Path", spaceConfig.SpaceFolderPath)
|
|
w.Header().Set("X-Server-Version", config.Version)
|
|
w.WriteHeader(http.StatusOK)
|
|
w.Write([]byte("OK"))
|
|
})
|
|
|
|
// Mount filesystem routes under /.fs
|
|
routes.Mount("/.fs", buildFsRoutes())
|
|
|
|
// Config endpoint
|
|
routes.Get("/.config", func(w http.ResponseWriter, r *http.Request) {
|
|
spaceConfig := spaceConfigFromContext(r.Context())
|
|
clientConfig := &BootConfig{
|
|
SpaceFolderPath: spaceConfig.SpaceFolderPath,
|
|
IndexPage: spaceConfig.IndexPage,
|
|
ReadOnly: spaceConfig.ReadOnlyMode,
|
|
LogPush: spaceConfig.LogPush,
|
|
// Client encryption is offered as an option when auth is enabled only
|
|
EnableClientEncryption: spaceConfig.Auth != nil,
|
|
DisableServiceWorker: spaceConfig.DisableServiceWorker,
|
|
}
|
|
|
|
w.Header().Set("Cache-Control", "no-cache")
|
|
render.JSON(w, r, clientConfig)
|
|
})
|
|
|
|
// Shell endpoint
|
|
routes.Post("/.shell", handleShellEndpoint)
|
|
|
|
// Log collection endpoint
|
|
routes.Post("/.logs", handleLogsEndpoint)
|
|
|
|
// Runtime API: Lua evaluation endpoints (via CDP to headless Chrome)
|
|
bridge := config.RuntimeBridge
|
|
if bridge == nil {
|
|
bridge = NewRuntimeBridge(nil)
|
|
}
|
|
routes.Group(func(r chi.Router) {
|
|
r.Use(func(next http.Handler) http.Handler {
|
|
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
|
spaceConfig := spaceConfigFromContext(r.Context())
|
|
if !spaceConfig.EnableRuntimeAPI {
|
|
writeJSON(w, http.StatusServiceUnavailable, map[string]any{"error": "Runtime API is not enabled"})
|
|
return
|
|
}
|
|
next.ServeHTTP(w, r)
|
|
})
|
|
})
|
|
r.Post("/.runtime/lua", bridge.HandleLuaAPI)
|
|
r.Post("/.runtime/lua_script", bridge.HandleLuaScriptAPI)
|
|
r.Get("/.runtime/screenshot", bridge.HandleScreenshot)
|
|
r.Get("/.runtime/logs", bridge.HandleConsoleLogs)
|
|
})
|
|
|
|
// Proxy endpoint
|
|
routes.HandleFunc("/.proxy/*", proxyHandler)
|
|
|
|
// Manifest endpoint
|
|
routes.HandleFunc("/.client/manifest.json", manifestHandler)
|
|
|
|
routes.HandleFunc("/*", func(w http.ResponseWriter, r *http.Request) {
|
|
path := DecodeURLParam(r, "*")
|
|
|
|
spaceConfig := spaceConfigFromContext(r.Context())
|
|
|
|
// See if it's in the client bundle
|
|
data, meta, err := config.ClientBundle.ReadFile(path)
|
|
if err == nil {
|
|
// File is in the bundle, let's serve it
|
|
if r.Header.Get("If-Modified-Since") == utcDateString(meta.LastModified) {
|
|
w.WriteHeader(304)
|
|
return
|
|
}
|
|
w.Header().Set("Content-Type", meta.ContentType)
|
|
w.Header().Set("Last-Modified", utcDateString(meta.LastModified))
|
|
w.WriteHeader(200)
|
|
w.Write(data)
|
|
return
|
|
}
|
|
|
|
// TODO: handle request types
|
|
ServerSideRender(config, spaceConfig, path, w, r)
|
|
})
|
|
|
|
if config.HostURLPrefix == "" {
|
|
r.Mount("/", routes)
|
|
} else {
|
|
r.Mount(config.HostURLPrefix, routes)
|
|
}
|
|
return r
|
|
}
|
|
|
|
func RunServer(config *ServerConfig) error {
|
|
// Set up headless config runtime values and create RuntimeBridge
|
|
if config.HeadlessConfig != nil {
|
|
config.HeadlessConfig.ServerURL = fmt.Sprintf("http://127.0.0.1:%d%s", config.Port, config.HostURLPrefix)
|
|
|
|
// Generate a random token for headless browser authentication
|
|
token, err := generateRandomToken(32)
|
|
if err != nil {
|
|
log.Printf("[Headless] Warning: failed to generate auth token: %v", err)
|
|
} else {
|
|
config.HeadlessToken = token
|
|
config.HeadlessConfig.HeadlessToken = token
|
|
}
|
|
|
|
config.RuntimeBridge = NewRuntimeBridge(config.HeadlessConfig)
|
|
} else {
|
|
config.RuntimeBridge = NewRuntimeBridge(nil)
|
|
}
|
|
|
|
r := Router(config)
|
|
|
|
network := "tcp"
|
|
addr := fmt.Sprintf("%s:%d", config.BindHost, config.Port)
|
|
|
|
if config.UnixSocket != "" {
|
|
network = "unix"
|
|
addr = config.UnixSocket
|
|
}
|
|
|
|
listener, err := net.Listen(network, addr)
|
|
if err != nil {
|
|
return fmt.Errorf("failed to listen on %s: %w", addr, err)
|
|
}
|
|
|
|
// Display the final server running message
|
|
visibleAddr := "http://" + addr
|
|
if config.BindHost == "127.0.0.1" {
|
|
visibleAddr = fmt.Sprintf("http://localhost:%d", config.Port)
|
|
}
|
|
|
|
if config.UnixSocket != "" {
|
|
visibleAddr = "unix://" + config.UnixSocket
|
|
}
|
|
log.Printf("SilverBullet is now running: %s", visibleAddr)
|
|
|
|
server := &http.Server{
|
|
Handler: r,
|
|
}
|
|
|
|
shutdownChannel := make(chan bool, 1)
|
|
|
|
go func() {
|
|
if err := server.Serve(listener); !errors.Is(err, http.ErrServerClosed) {
|
|
log.Fatalf("HTTP server error: %v", err)
|
|
}
|
|
log.Println("Stopped serving new connections.")
|
|
shutdownChannel <- true
|
|
}()
|
|
|
|
go runMetricsServer(config)
|
|
|
|
signalChannel := make(chan os.Signal, 1)
|
|
signal.Notify(signalChannel, syscall.SIGINT, syscall.SIGTERM)
|
|
// Block on incoming signals.
|
|
s := <-signalChannel
|
|
log.Println("Received signal:", s)
|
|
|
|
// Stop headless browser (if running) before server shutdown
|
|
config.RuntimeBridge.Stop()
|
|
|
|
shutdownCtx, shutdownRelease := context.WithTimeout(context.Background(), 10*time.Second)
|
|
defer shutdownRelease()
|
|
|
|
if err := server.Shutdown(shutdownCtx); err != nil {
|
|
log.Fatalf("HTTP shutdown error: %v", err)
|
|
}
|
|
<-shutdownChannel
|
|
log.Println("Graceful shutdown complete.")
|
|
return nil
|
|
}
|