Fix SB_URL_PREFIX handling, added SB_LOG_PUSH to ask clients to push

client logs to the server
This commit is contained in:
Zef Hemel
2025-09-19 16:10:14 +02:00
parent 3e444ffb2a
commit fdd8899b33
17 changed files with 271 additions and 290 deletions
+77 -15
View File
@@ -4,9 +4,11 @@ import (
"fmt"
"html/template"
"log"
"math"
"net/http"
"path"
"strings"
"sync"
"time"
"github.com/go-chi/chi/v5"
@@ -27,15 +29,14 @@ func addAuthEndpoints(r chi.Router, config *ServerConfig) {
// Logout
r.Get("/.logout", func(w http.ResponseWriter, r *http.Request) {
host := extractHost(r)
spaceConfig := spaceConfigFromContext(r.Context())
cookieOptions := CookieOptions{
Path: fmt.Sprintf("%s/", spaceConfig.HostURLPrefix),
Path: fmt.Sprintf("%s/", config.HostURLPrefix),
}
deleteCookie(w, authCookieName(host), cookieOptions)
deleteCookie(w, "refreshLogin", cookieOptions)
http.Redirect(w, r, applyURLPrefix("/.auth", spaceConfig.HostURLPrefix), http.StatusFound)
http.Redirect(w, r, applyURLPrefix("/.auth", config.HostURLPrefix), http.StatusFound)
})
// Auth page
@@ -58,7 +59,7 @@ func addAuthEndpoints(r chi.Router, config *ServerConfig) {
HostPrefix string
SpaceName string
}{
HostPrefix: spaceConfig.HostURLPrefix,
HostPrefix: config.HostURLPrefix,
SpaceName: spaceConfig.SpaceName,
}
@@ -88,13 +89,13 @@ func addAuthEndpoints(r chi.Router, config *ServerConfig) {
from := r.FormValue("from")
if username == "" || password == "" {
http.Redirect(w, r, applyURLPrefix("/.auth?error=0", spaceConfig.HostURLPrefix), http.StatusFound)
http.Redirect(w, r, applyURLPrefix("/.auth?error=0", config.HostURLPrefix), http.StatusFound)
return
}
if spaceConfig.LockoutTimer.IsLocked() {
log.Println("Authentication locked out, redirecting to auth page.")
http.Redirect(w, r, applyURLPrefix("/.auth?error=2", spaceConfig.HostURLPrefix), http.StatusFound)
http.Redirect(w, r, applyURLPrefix("/.auth?error=2", config.HostURLPrefix), http.StatusFound)
return
}
@@ -122,7 +123,7 @@ func addAuthEndpoints(r chi.Router, config *ServerConfig) {
inAWeek := time.Now().Add(time.Duration(authenticationExpirySeconds) * time.Second)
cookieOptions := CookieOptions{
Path: fmt.Sprintf("%s/", spaceConfig.HostURLPrefix),
Path: fmt.Sprintf("%s/", config.HostURLPrefix),
Expires: inAWeek,
}
@@ -137,12 +138,12 @@ func addAuthEndpoints(r chi.Router, config *ServerConfig) {
redirectPath = from
}
http.Redirect(w, r, applyURLPrefix(redirectPath, spaceConfig.HostURLPrefix), http.StatusFound)
http.Redirect(w, r, applyURLPrefix(redirectPath, config.HostURLPrefix), http.StatusFound)
} else {
log.Println("Authentication failed, redirecting to auth page.")
spaceConfig.LockoutTimer.AddCount()
http.Redirect(w, r, applyURLPrefix("/.auth?error=1", spaceConfig.HostURLPrefix), http.StatusFound)
http.Redirect(w, r, applyURLPrefix("/.auth?error=1", config.HostURLPrefix), http.StatusFound)
}
})
}
@@ -178,7 +179,7 @@ func authMiddleware(config *ServerConfig) func(http.Handler) http.Handler {
return
}
path := removeURLPrefix(r.URL.Path, spaceConfig.HostURLPrefix)
path := removeURLPrefix(r.URL.Path, config.HostURLPrefix)
host := extractHost(r)
if isExcludedPath(path) {
@@ -211,7 +212,7 @@ func authMiddleware(config *ServerConfig) func(http.Handler) http.Handler {
if authCookie == "" {
log.Printf("Unauthorized access to %s, redirecting to auth page", path)
redirectToAuth(w, "/.auth", path, spaceConfig.HostURLPrefix)
redirectToAuth(w, "/.auth", path, config.HostURLPrefix)
return
}
@@ -219,14 +220,14 @@ func authMiddleware(config *ServerConfig) func(http.Handler) http.Handler {
claims, err := spaceConfig.JwtIssuer.VerifyAndDecodeJWT(authCookie)
if err != nil {
log.Printf("Error verifying JWT on %s, redirecting to auth page: %v\n", path, err)
redirectToAuth(w, "/.auth", path, spaceConfig.HostURLPrefix)
redirectToAuth(w, "/.auth", path, config.HostURLPrefix)
return
}
username, ok := claims["username"].(string)
if !ok || username != spaceConfig.Auth.User {
log.Printf("Username mismatch in JWT on %s", path)
redirectToAuth(w, "/.auth", path, spaceConfig.HostURLPrefix)
redirectToAuth(w, "/.auth", path, config.HostURLPrefix)
return
}
@@ -239,13 +240,12 @@ func authMiddleware(config *ServerConfig) func(http.Handler) http.Handler {
// refreshLogin refreshes the login cookie if needed
func refreshLogin(w http.ResponseWriter, r *http.Request, config *ServerConfig, host string) {
if getCookie(r, "refreshLogin") != "" {
spaceConfig := spaceConfigFromContext(r.Context())
inAWeek := time.Now().Add(time.Duration(authenticationExpirySeconds) * time.Second)
jwt := getCookie(r, authCookieName(host))
if jwt != "" {
cookieOptions := CookieOptions{
Path: fmt.Sprintf("%s/", spaceConfig.HostURLPrefix),
Path: fmt.Sprintf("%s/", config.HostURLPrefix),
Expires: inAWeek,
}
@@ -254,3 +254,65 @@ func refreshLogin(w http.ResponseWriter, r *http.Request, config *ServerConfig,
}
}
}
// LockoutTimer implements a simple rate limiter to prevent brute force attacks
type LockoutTimer struct {
mutex sync.Mutex
bucketTime int64
bucketCount int
bucketSize int64 // duration in milliseconds
limit int
disabled bool
}
// NewLockoutTimer creates a new lockout timer
// countPeriodMs: time window in milliseconds
// limit: maximum attempts allowed in the time window
func NewLockoutTimer(countPeriodMs int, limit int) *LockoutTimer {
disabled := math.IsNaN(float64(countPeriodMs)) || math.IsNaN(float64(limit)) ||
countPeriodMs < 1 || limit < 1
return &LockoutTimer{
bucketSize: int64(countPeriodMs),
limit: limit,
disabled: disabled,
}
}
// updateBucketTime updates the current bucket time and resets count if needed
func (lt *LockoutTimer) updateBucketTime() {
currentBucketTime := time.Now().UnixMilli() / lt.bucketSize
if lt.bucketTime == currentBucketTime {
return
}
// the bucket is too old - empty it
lt.bucketTime = currentBucketTime
lt.bucketCount = 0
}
// IsLocked checks if the timer is currently locked due to too many attempts
func (lt *LockoutTimer) IsLocked() bool {
if lt.disabled {
return false
}
lt.mutex.Lock()
defer lt.mutex.Unlock()
lt.updateBucketTime()
return lt.bucketCount >= lt.limit
}
// AddCount increments the attempt counter
// IsLocked() should be called first to keep bucketTime current
func (lt *LockoutTimer) AddCount() {
if lt.disabled {
return
}
lt.mutex.Lock()
defer lt.mutex.Unlock()
// updateBucketTime should have been called by IsLocked first
lt.bucketCount++
}
+7 -3
View File
@@ -53,6 +53,11 @@ func buildConfig(bundledFiles fs.FS, args []string) *server.ServerConfig {
rootSpaceConfig.SpaceFolderPath = os.Getenv("SB_FOLDER")
}
if os.Getenv("SB_LOG_PUSH") != "" {
rootSpaceConfig.LogPush = true
log.Println("Client log push enabled")
}
if rootSpaceConfig.SpaceFolderPath == "" {
log.Fatal("No folder specified. Please pass a folder as an argument or set SB_FOLDER environment variable.")
}
@@ -126,7 +131,7 @@ func buildConfig(bundledFiles fs.FS, args []string) *server.ServerConfig {
if hostUrlPrefix != "" {
log.Printf("Host URL Prefix: %s", hostUrlPrefix)
rootSpaceConfig.HostURLPrefix = hostUrlPrefix
serverConfig.HostURLPrefix = hostUrlPrefix
}
}
@@ -141,7 +146,6 @@ func buildConfig(bundledFiles fs.FS, args []string) *server.ServerConfig {
serverConfig.ClientBundle = server.NewReadOnlyFallthroughSpacePrimitives(bundledFiles, "dist_client_bundle", bundlePathDate, nil)
rootSpaceConfig.SpacePrimitives = server.NewReadOnlyFallthroughSpacePrimitives(bundledFiles, "dist_base_fs_bundle", bundlePathDate, spacePrimitives)
log.Printf("Starting SilverBullet binding to %s:%d", serverConfig.BindHost, serverConfig.Port)
if serverConfig.BindHost == "127.0.0.1" {
log.Println("SilverBullet will only be available locally, to allow outside connections, pass -L0.0.0.0 as a flag, and put a TLS terminator on top.")
}
@@ -173,7 +177,7 @@ func ServerCommand(bundledFiles fs.FS) *cobra.Command {
Args: cobra.MinimumNArgs(0),
Run: func(cmd *cobra.Command, args []string) {
serverConfig := buildConfig(bundledFiles, args)
if port != 0 {
if port != 3000 {
serverConfig.Port = port
}
if hostname != "" {
-69
View File
@@ -1,69 +0,0 @@
package server
import (
"math"
"sync"
"time"
)
// LockoutTimer implements a simple rate limiter to prevent brute force attacks
type LockoutTimer struct {
mutex sync.Mutex
bucketTime int64
bucketCount int
bucketSize int64 // duration in milliseconds
limit int
disabled bool
}
// NewLockoutTimer creates a new lockout timer
// countPeriodMs: time window in milliseconds
// limit: maximum attempts allowed in the time window
func NewLockoutTimer(countPeriodMs int, limit int) *LockoutTimer {
disabled := math.IsNaN(float64(countPeriodMs)) || math.IsNaN(float64(limit)) ||
countPeriodMs < 1 || limit < 1
return &LockoutTimer{
bucketSize: int64(countPeriodMs),
limit: limit,
disabled: disabled,
}
}
// updateBucketTime updates the current bucket time and resets count if needed
func (lt *LockoutTimer) updateBucketTime() {
currentBucketTime := time.Now().UnixMilli() / lt.bucketSize
if lt.bucketTime == currentBucketTime {
return
}
// the bucket is too old - empty it
lt.bucketTime = currentBucketTime
lt.bucketCount = 0
}
// IsLocked checks if the timer is currently locked due to too many attempts
func (lt *LockoutTimer) IsLocked() bool {
if lt.disabled {
return false
}
lt.mutex.Lock()
defer lt.mutex.Unlock()
lt.updateBucketTime()
return lt.bucketCount >= lt.limit
}
// AddCount increments the attempt counter
// IsLocked() should be called first to keep bucketTime current
func (lt *LockoutTimer) AddCount() {
if lt.disabled {
return
}
lt.mutex.Lock()
defer lt.mutex.Unlock()
// updateBucketTime should have been called by IsLocked first
lt.bucketCount++
}
-61
View File
@@ -1,61 +0,0 @@
package server
import (
"net/http"
"github.com/go-chi/render"
)
// ManifestIcon represents an icon in the PWA manifest
type ManifestIcon struct {
Src string `json:"src"`
Type string `json:"type"`
Sizes string `json:"sizes"`
}
// Manifest represents the structure of a PWA manifest.json file
type Manifest struct {
ShortName string `json:"short_name"`
Name string `json:"name"`
Icons []ManifestIcon `json:"icons"`
CaptureLinks string `json:"capture_links"`
StartURL string `json:"start_url"`
Display string `json:"display"`
DisplayOverride []string `json:"display_override"`
Scope string `json:"scope"`
ThemeColor string `json:"theme_color"`
Description string `json:"description"`
}
// Generate PWA manifest.json with dynamic values
func manifestHandler(config *ServerConfig) http.HandlerFunc {
return func(w http.ResponseWriter, r *http.Request) {
spaceConfig := spaceConfigFromContext(r.Context())
manifest := Manifest{
ShortName: spaceConfig.SpaceName,
Name: spaceConfig.SpaceName,
Icons: []ManifestIcon{
{
Src: "/.client/logo-dock.png",
Type: "image/png",
Sizes: "512x512",
},
},
CaptureLinks: "new-client",
StartURL: "/#boot",
Display: "standalone",
DisplayOverride: []string{"window-controls-overlay"},
Scope: "/",
ThemeColor: "#e1e1e1",
Description: spaceConfig.SpaceDescription,
}
if spaceConfig.HostURLPrefix != "" {
manifest.Icons[0].Src = spaceConfig.HostURLPrefix + manifest.Icons[0].Src
manifest.StartURL = spaceConfig.HostURLPrefix + manifest.StartURL
manifest.Scope = spaceConfig.HostURLPrefix + manifest.Scope
}
render.JSON(w, r, manifest)
}
}
+74 -76
View File
@@ -16,81 +16,79 @@ func init() {
localhostRegex = regexp.MustCompile(`^(localhost|127\.0\.0\.1|\d+\.\d+\.\d+\.\d+)`)
}
func proxyHandler(config *ServerConfig) http.HandlerFunc {
return func(w http.ResponseWriter, r *http.Request) {
spaceConfig := spaceConfigFromContext(r.Context())
if spaceConfig.ReadOnlyMode {
http.Error(w, "Read only mode, no proxy allowed", http.StatusMethodNotAllowed)
return
}
// Extract the URI from the path
path := chi.URLParam(r, "*")
if path == "" {
http.Error(w, "No URI provided", http.StatusBadRequest)
return
}
// Add query parameters if they exist
if r.URL.RawQuery != "" {
path += "?" + r.URL.RawQuery
}
// Determine protocol based on hostname
var targetURL string
if localhostRegex.MatchString(path) {
targetURL = "http://" + path
} else {
targetURL = "https://" + path
}
log.Printf("Proxying to %s\n", targetURL)
// Create the proxy request
req, err := http.NewRequest(r.Method, targetURL, r.Body)
if err != nil {
http.Error(w, err.Error(), http.StatusInternalServerError)
return
}
// Copy safe headers
safeHeaders := []string{"Authorization", "Accept", "Content-Type"}
for _, header := range safeHeaders {
if value := r.Header.Get(header); value != "" {
req.Header.Set(header, value)
}
}
// Copy headers starting with X-Proxy-Header-, removing the prefix
for key, values := range r.Header {
if strings.HasPrefix(strings.ToLower(key), "x-proxy-header-") {
headerName := key[len("x-proxy-header-"):]
for _, value := range values {
req.Header.Set(headerName, value)
}
}
}
// Make the request
client := http.DefaultClient
resp, err := client.Do(req)
if err != nil {
log.Printf("Error fetching proxied URL: %v\n", err)
http.Error(w, err.Error(), http.StatusInternalServerError)
return
}
defer resp.Body.Close()
// Copy response headers
for key, values := range resp.Header {
for _, value := range values {
w.Header().Set(key, value)
}
}
// Set status code and copy body
w.WriteHeader(resp.StatusCode)
io.Copy(w, resp.Body)
func proxyHandler(w http.ResponseWriter, r *http.Request) {
spaceConfig := spaceConfigFromContext(r.Context())
if spaceConfig.ReadOnlyMode {
http.Error(w, "Read only mode, no proxy allowed", http.StatusMethodNotAllowed)
return
}
// Extract the URI from the path
path := chi.URLParam(r, "*")
if path == "" {
http.Error(w, "No URI provided", http.StatusBadRequest)
return
}
// Add query parameters if they exist
if r.URL.RawQuery != "" {
path += "?" + r.URL.RawQuery
}
// Determine protocol based on hostname
var targetURL string
if localhostRegex.MatchString(path) {
targetURL = "http://" + path
} else {
targetURL = "https://" + path
}
log.Printf("Proxying to %s\n", targetURL)
// Create the proxy request
req, err := http.NewRequest(r.Method, targetURL, r.Body)
if err != nil {
http.Error(w, err.Error(), http.StatusInternalServerError)
return
}
// Copy safe headers
safeHeaders := []string{"Authorization", "Accept", "Content-Type"}
for _, header := range safeHeaders {
if value := r.Header.Get(header); value != "" {
req.Header.Set(header, value)
}
}
// Copy headers starting with X-Proxy-Header-, removing the prefix
for key, values := range r.Header {
if strings.HasPrefix(strings.ToLower(key), "x-proxy-header-") {
headerName := key[len("x-proxy-header-"):]
for _, value := range values {
req.Header.Set(headerName, value)
}
}
}
// Make the request
client := http.DefaultClient
resp, err := client.Do(req)
if err != nil {
log.Printf("Error fetching proxied URL: %v\n", err)
http.Error(w, err.Error(), http.StatusInternalServerError)
return
}
defer resp.Body.Close()
// Copy response headers
for key, values := range resp.Header {
for _, value := range values {
w.Header().Set(key, value)
}
}
// Set status code and copy body
w.WriteHeader(resp.StatusCode)
io.Copy(w, resp.Body)
}
+24 -7
View File
@@ -17,6 +17,9 @@ 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"`
}
func RunServer(config *ServerConfig) error {
@@ -26,6 +29,8 @@ func RunServer(config *ServerConfig) error {
r.Use(middleware.Logger)
}
r.Use(middleware.RealIP)
// Expose space primitives and path to the request
r.Use(spaceMiddleware(config))
@@ -35,22 +40,25 @@ func RunServer(config *ServerConfig) error {
// Authentication endpoints (must come before auth middleware)
addAuthEndpoints(r, config)
r.Get("/.ping", func(w http.ResponseWriter, r *http.Request) {
routes := chi.NewRouter()
routes.Get("/.ping", func(w http.ResponseWriter, r *http.Request) {
w.Header().Set("Cache-Control", "no-cache")
w.WriteHeader(http.StatusOK)
w.Write([]byte("OK"))
})
// Mount filesystem routes under /.fs
r.Mount("/.fs", buildFsRoutes())
routes.Mount("/.fs", buildFsRoutes())
// Config endpoint
r.Get("/.config", func(w http.ResponseWriter, r *http.Request) {
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,
}
w.Header().Set("Cache-Control", "no-cache")
@@ -58,15 +66,18 @@ func RunServer(config *ServerConfig) error {
})
// Shell endpoint
r.Post("/.shell", handleShellEndpoint(config))
routes.Post("/.shell", handleShellEndpoint)
// Log collection endpoint
routes.Post("/.logs", handleLogsEndpoint)
// Proxy endpoint
r.HandleFunc("/.proxy/*", proxyHandler(config))
routes.HandleFunc("/.proxy/*", proxyHandler)
// Manifest endpoint
r.HandleFunc("/.client/manifest.json", manifestHandler(config))
routes.HandleFunc("/.client/manifest.json", manifestHandler)
r.HandleFunc("/*", func(w http.ResponseWriter, r *http.Request) {
routes.HandleFunc("/*", func(w http.ResponseWriter, r *http.Request) {
path := chi.URLParam(r, "*")
spaceConfig := spaceConfigFromContext(r.Context())
@@ -90,6 +101,12 @@ func RunServer(config *ServerConfig) error {
ServerSideRender(config, spaceConfig, path, w, r)
})
if config.HostURLPrefix == "" {
r.Mount("/", routes)
} else {
r.Mount(config.HostURLPrefix, routes)
}
// Display the final server running message
visibleHostname := config.BindHost
if config.BindHost == "127.0.0.1" {
+21 -23
View File
@@ -7,30 +7,28 @@ import (
)
// handleShellEndpoint handles POST requests to /.shell for executing shell commands
func handleShellEndpoint(config *ServerConfig) http.HandlerFunc {
return func(w http.ResponseWriter, r *http.Request) {
spaceConfig := spaceConfigFromContext(r.Context())
// Parse the request body
var shellRequest ShellRequest
if err := json.NewDecoder(r.Body).Decode(&shellRequest); err != nil {
http.Error(w, "Invalid JSON request", http.StatusBadRequest)
return
}
func handleShellEndpoint(w http.ResponseWriter, r *http.Request) {
spaceConfig := spaceConfigFromContext(r.Context())
// Parse the request body
var shellRequest ShellRequest
if err := json.NewDecoder(r.Body).Decode(&shellRequest); err != nil {
http.Error(w, "Invalid JSON request", http.StatusBadRequest)
return
}
// Execute the shell command
shellResponse, err := spaceConfig.ShellBackend.Handle(shellRequest)
if err != nil {
log.Printf("Shell error: %v\n", err)
http.Error(w, err.Error(), http.StatusInternalServerError)
return
}
// Execute the shell command
shellResponse, err := spaceConfig.ShellBackend.Handle(shellRequest)
if err != nil {
log.Printf("Shell error: %v\n", err)
http.Error(w, err.Error(), http.StatusInternalServerError)
return
}
// Return the response as JSON
w.Header().Set("Content-Type", "application/json")
if err := json.NewEncoder(w).Encode(shellResponse); err != nil {
log.Printf("Error encoding shell response: %v\n", err)
http.Error(w, "Internal server error", http.StatusInternalServerError)
return
}
// Return the response as JSON
w.Header().Set("Content-Type", "application/json")
if err := json.NewEncoder(w).Encode(shellResponse); err != nil {
log.Printf("Error encoding shell response: %v\n", err)
http.Error(w, "Internal server error", http.StatusInternalServerError)
return
}
}
+8 -1
View File
@@ -6,12 +6,15 @@ import (
)
const spaceConfigKey = "spaceConfig"
const serverConfigKey = "serverConfig"
func spaceMiddleware(config *ServerConfig) func(http.Handler) http.Handler {
return func(next http.Handler) http.Handler {
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
spaceConfig := config.SpaceConfigResolver(r)
augmentedRequest := r.WithContext(context.WithValue(r.Context(), spaceConfigKey, spaceConfig))
ctx := context.WithValue(r.Context(), spaceConfigKey, spaceConfig)
ctx = context.WithValue(ctx, serverConfigKey, config)
augmentedRequest := r.WithContext(ctx)
next.ServeHTTP(w, augmentedRequest)
})
}
@@ -20,3 +23,7 @@ func spaceMiddleware(config *ServerConfig) func(http.Handler) http.Handler {
func spaceConfigFromContext(ctx context.Context) *SpaceConfig {
return ctx.Value(spaceConfigKey).(*SpaceConfig)
}
func serverConfigFromContext(ctx context.Context) *ServerConfig {
return ctx.Value(serverConfigKey).(*ServerConfig)
}
+1 -1
View File
@@ -69,7 +69,7 @@ func ServerSideRender(config *ServerConfig, spaceConfig *SpaceConfig, pageName s
Content string
}{
Title: "SilverBullet",
HostPrefix: spaceConfig.HostURLPrefix,
HostPrefix: config.HostURLPrefix,
}
if spaceConfig.ReadOnlyMode && spaceConfig.Auth == nil {
+5 -3
View File
@@ -11,14 +11,15 @@ type ServerConfig struct {
BindHost string
Port int
EnableHTTPLogging bool
// TODO: Ideally this is configurable per space, but kinda hard
HostURLPrefix string
ClientBundle SpacePrimitives
}
type SpaceConfig struct {
Hostname string
HostURLPrefix string
Auth *AuthOptions
Hostname string
Auth *AuthOptions
SpacePrimitives SpacePrimitives
@@ -26,6 +27,7 @@ type SpaceConfig struct {
IndexPage string
GitIgnore string
ReadOnlyMode bool
LogPush bool
// Used for the PWA manifest and login page
SpaceName string