diff --git a/.env.example b/.env.example index a25d2a0..6f1b23d 100644 --- a/.env.example +++ b/.env.example @@ -1,3 +1,2 @@ -DISABLE_GLOOMI=false -GLOOMI_HOSTNAME=localhost +PRELOAD_PLUGINS='[{"file": "gloomi.so", "domains": ["localhost"]}]' # make sure gloomi.so is in the $PLUGINS_DIR PLUGINS_DIR=plugs diff --git a/DOCUMENTATION.md b/DOCUMENTATION.md index af7dd7d..cdbe142 100644 --- a/DOCUMENTATION.md +++ b/DOCUMENTATION.md @@ -60,7 +60,7 @@ type PluginUpload struct { ## GLoomI -GLoomI is the included plugin management interface for GLoom, it utilizes the GLoom RPC, much like you would if you wanted to make your own management interface. By default, GLoomI is configured to use 127.0.0.1 as the hostname, but you con configure it to use a different hostname by setting the `GLOOMI_HOSTNAME` environment variable. The endpoints for GLoomI are as follows: +GLoomI is the included plugin management interface for GLoom, it utilizes the GLoom RPC, much like you would if you wanted to make your own management interface. By default, GLoomI is configured to use localhost as the hostname, but you con configure it to use a different hostname by changing the `PRELOADED_PLUGINS` environment variable, as describe in the [README](README.md). The endpoints for GLoomI are as follows: - `GET /api/plugins` - This endpoint returns a list of all registered plugins and their domains. - `POST /api/plugins` - This endpoint uploads a plugin to GLoom. it takes a multipart/form-data request with the following fields: diff --git a/README.md b/README.md index df0ba58..7c63ff2 100644 --- a/README.md +++ b/README.md @@ -1,6 +1,6 @@ # GLoom -GLoom is a plugin-based web app manager written in Go (perhaps a pico-paas). GLoom's focus is to provide and simple and efficient way to host micro-web apps easily. Currently, GLoom is a fun little proof of concept, and now even supports unloading plugins, and gracefully handles plugins that crash, but it is not yet ready for production use, and may not ever be. GLoom is still in early development, so expect some rough edges and bugs and at its heart, GLoom is just a proof of concept, fun to write, and fun to use, but not production ready. +GLoom is a plugin-based web app manager written in Go (perhaps a pico-paas). GLoom's focus is to provide and simple and efficient way to host micro-web apps easily. Currently, GLoom is a fun little proof of concept, and now even supports unloading plugins, and gracefully handles plugins that crash, but it is not yet ready for production use, and may not ever be. GLoom is still in early development, so expect some rough edges and bugs and at its heart, GLoom is just a proof of concept, fun to write, and fun to use, but not production ready. If you want a more stable and production ready web app manager, check out my other project, [Flux](https://github.com/juls0730/flux) ;). ## Features @@ -15,6 +15,8 @@ GLoom is a plugin-based web app manager written in Go (perhaps a pico-paas). GLo - Go 1.20 or higher - [zqdgr](https://github.com/juls0730/zqdgr) +This project is primarily written for Linux, and has only been tested on Linux, you might have luck with other operating systems, but it is not guaranteed to work, feel free to open an issue if you encounter any problems. + ### Installation 1. Clone the repository: @@ -32,9 +34,9 @@ GLoom is a plugin-based web app manager written in Go (perhaps a pico-paas). GLo zqdgr build ``` - and if you want to build the project without the GLoom management Interface (you will not be able to manage plugins wunless you have another interface like GLoomI): + and if you want to build the project without the GLoom management Interface (you will not be able to manage plugins wunless you have another interface like GLoomI or mark all the plugins you want to use as preloaded): ```bash - zqdgr build:no-gloomi + zqdgr build:gloom ``` and make sure to clear the `PRELOAD_PLUGINS` environment variable and set it to your preferred management interface, or nothing at all if you don't want to use a management interface. @@ -48,6 +50,7 @@ GLoom is configured using environment variables. The following environment varia ```json [{"file": "gloomi.so", "domains": ["localhost"]}] ``` + It is not recommended to preload any plugin other than a management interface, this is because preloaded plugins are protected and cannot be updated in an green-blue fashion. - `PLUGINS_DIR` - The directory where plugins are stored. This is a string value, so you can set it to any directory path you want. The default value is `plugs`. ## Usage @@ -60,4 +63,4 @@ Contributions are welcome! ## License -GLoom is licensed under the MIT License. +GLoom is licensed under the MIT License and ever file is licensed under it unless otherwise specified. diff --git a/gloomi/zqdgr.config.json b/gloomi/zqdgr.config.json index 01f5e47..1020d13 100644 --- a/gloomi/zqdgr.config.json +++ b/gloomi/zqdgr.config.json @@ -1,9 +1,9 @@ { - "name": "Go Project", + "name": "GLoomI", "version": "0.0.1", - "description": "Example description", - "author": "you", - "license": "BSL-1.0", + "description": "GLoomI is the included plugin management interface for GLoom", + "author": "juls0730", + "license": "MIT", "scripts": { "build": "go build -buildmode=plugin -o ../plugs/gloomi.so main.go" }, diff --git a/go.mod b/go.mod index 53817ac..7fa2841 100644 --- a/go.mod +++ b/go.mod @@ -1,15 +1,11 @@ module github.com/juls0730/gloom -go 1.23.4 +go 1.24.2 require ( github.com/joho/godotenv v1.5.1 + github.com/juls0730/sentinel v0.0.0-20250515154110-2e7e6586cacd github.com/mattn/go-sqlite3 v1.14.24 ) -require ( - github.com/mattn/go-colorable v0.1.14 // indirect - github.com/mattn/go-isatty v0.0.20 // indirect - github.com/mgutz/ansi v0.0.0-20200706080929-d51e80ef957d // indirect - golang.org/x/sys v0.29.0 // indirect -) +require golang.org/x/sync v0.14.0 // indirect diff --git a/go.sum b/go.sum index cc9184e..5b272cb 100644 --- a/go.sum +++ b/go.sum @@ -1,13 +1,8 @@ github.com/joho/godotenv v1.5.1 h1:7eLL/+HRGLY0ldzfGMeQkb7vMd0as4CfYvUVzLqw0N0= github.com/joho/godotenv v1.5.1/go.mod h1:f4LDr5Voq0i2e/R5DDNOoa2zzDfwtkZa6DnEwAbqwq4= -github.com/mattn/go-colorable v0.1.14 h1:9A9LHSqF/7dyVVX6g0U9cwm9pG3kP9gSzcuIPHPsaIE= -github.com/mattn/go-colorable v0.1.14/go.mod h1:6LmQG8QLFO4G5z1gPvYEzlUgJ2wF+stgPZH1UqBm1s8= -github.com/mattn/go-isatty v0.0.20 h1:xfD0iDuEKnDkl03q4limB+vH+GxLEtL/jb4xVJSWWEY= -github.com/mattn/go-isatty v0.0.20/go.mod h1:W+V8PltTTMOvKvAeJH7IuucS94S2C6jfK/D7dTCTo3Y= +github.com/juls0730/sentinel v0.0.0-20250515154110-2e7e6586cacd h1:JNazPdlAs307Gtaqmb+wfCjcOzO3MRXxg9nf0bAKAnc= +github.com/juls0730/sentinel v0.0.0-20250515154110-2e7e6586cacd/go.mod h1:CnRvcleiS2kvK1N2PeQmeoRP5EXpBDpHPkg72vAUaSg= github.com/mattn/go-sqlite3 v1.14.24 h1:tpSp2G2KyMnnQu99ngJ47EIkWVmliIizyZBfPrBWDRM= github.com/mattn/go-sqlite3 v1.14.24/go.mod h1:Uh1q+B4BYcTPb+yiD3kU8Ct7aC0hY9fxUwlHK0RXw+Y= -github.com/mgutz/ansi v0.0.0-20200706080929-d51e80ef957d h1:5PJl274Y63IEHC+7izoQE9x6ikvDFZS2mDVS3drnohI= -github.com/mgutz/ansi v0.0.0-20200706080929-d51e80ef957d/go.mod h1:01TrycV0kFyexm33Z7vhZRXopbI8J3TDReVlkTgMUxE= -golang.org/x/sys v0.6.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= -golang.org/x/sys v0.29.0 h1:TPYlXGxvx1MGTn2GiZDhnjPA9wZzZeGKHHmKhHYvgaU= -golang.org/x/sys v0.29.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA= +golang.org/x/sync v0.14.0 h1:woo0S4Yywslg6hp4eUFjTVOyKt0RookbpAHG4c1HmhQ= +golang.org/x/sync v0.14.0/go.mod h1:1dzgHSNfp02xaA81J2MS99Qcpr2w7fw1gpm99rleRqA= diff --git a/libs/syncmap.go b/libs/syncmap.go index e63f842..6330d48 100644 --- a/libs/syncmap.go +++ b/libs/syncmap.go @@ -1,4 +1,6 @@ +// Copyright (c) 2024 @tarampampam // https://gist.github.com/tarampampam/f96538257ff125ab71785710d48b3118 + package libs import "sync" diff --git a/main.go b/main.go index 3128436..5cd7993 100644 --- a/main.go +++ b/main.go @@ -11,9 +11,7 @@ import ( "math/rand/v2" "net" "net/http" - "net/http/httputil" "net/rpc" - "net/url" "os" "os/exec" "path" @@ -21,11 +19,11 @@ import ( "strconv" "strings" "sync" - "sync/atomic" "time" "github.com/joho/godotenv" "github.com/juls0730/gloom/libs" + "github.com/juls0730/sentinel" _ "github.com/mattn/go-sqlite3" ) @@ -54,10 +52,10 @@ type GLoom struct { hostMap libs.SyncMap[string, bool] DB *sql.DB - ProxyManager *ProxyManager + ProxyManager *sentinel.ProxyManager } -func NewGloom(proxyManager *ProxyManager) (*GLoom, error) { +func NewGloom(proxyManager *sentinel.ProxyManager) (*GLoom, error) { pluginsDir := os.Getenv("PLUGINS_DIR") if pluginsDir == "" { pluginsDir = "plugs" @@ -132,11 +130,11 @@ func NewGloom(proxyManager *ProxyManager) (*GLoom, error) { } func (gloom *GLoom) LoadInitialPlugins() error { - slog.Debug("Loading initial plugins") + slog.Info("Loading initial plugins") for _, plugin := range gloom.preloadPlugins { if err := gloom.RegisterPlugin(filepath.Join(gloom.pluginDir, plugin.File), plugin.File, plugin.Domains); err != nil { - slog.Warn("Failed to register plugin", "pluginPath", plugin.File, "error", err) + panic(fmt.Errorf("failed to load preload plugin %s: %w (make sure its in %s)", plugin.File, err, gloom.pluginDir)) } } @@ -164,6 +162,8 @@ func (gloom *GLoom) LoadInitialPlugins() error { } } + slog.Info("Loaded initial plugins") + return nil } @@ -233,7 +233,15 @@ func (gloom *GLoom) RegisterPlugin(pluginPath string, name string, domains []str } process := cmd.Process + timeout := time.After(5 * time.Second) + for { + select { + case <-timeout: + _ = process.Signal(os.Interrupt) + return fmt.Errorf("timed out waiting for pluginHost to start (this is likely a GLoom bug)") + default: + } _, err := os.Stat(controlPath) if err == nil { break @@ -276,16 +284,22 @@ func (gloom *GLoom) RegisterPlugin(pluginPath string, name string, domains []str } } - proxy, err := NewDeploymentProxy(socketPath) + proxy, err := sentinel.NewDeploymentProxy(socketPath, NewUnixSocketTransport) if err != nil { return err } - var oldProxy *Proxy + var oldProxy *sentinel.Proxy for _, domain := range domains { var ok bool - oldProxy, ok = gloom.ProxyManager.Load(domain) - // there can only be one in a set of domains. If a is the domains already attached to the proxy, and b is + if value, exists := gloom.ProxyManager.Load(domain); exists { + oldProxy = value.(*sentinel.Proxy) + ok = true + } else { + ok = false + } + + // there can only be one proxy in a set of domains. If a is the domains already attached to the proxy, and b is // a superset of a, but the new members of b are not in any other set, then we can be sure there is just one if ok { break @@ -307,7 +321,11 @@ func (gloom *GLoom) RegisterPlugin(pluginPath string, name string, domains []str if oldProxy != nil { go func() { - oldProxy.GracefulShutdown(nil) + slog.Debug("Gracefully shutting down old proxy") + err := oldProxy.GracefulShutdown(nil) + if err != nil { + slog.Warn("Failed to gracefully shutdown old proxy", "error", err) + } }() } @@ -352,7 +370,7 @@ func (gloom *GLoom) StartRPCServer() error { return err } - fmt.Printf("RPC server running on port 7143\n") + slog.Info("RPC server running on port 7143\n") go func() { for { conn, err := listener.Accept() @@ -439,7 +457,6 @@ func (rpc *GloomRPC) UploadPlugin(plugin PluginUpload, reply *string) error { } var plugExists bool - // TODO: make name a consistent identifier slog.Debug("Checking if plugin exists", "pluginPath", pluginPath, "pluginName", plugin.Name) rpc.gloom.DB.QueryRow("SELECT 1 FROM plugins WHERE name = ?", plugin.Name).Scan(&plugExists) slog.Debug("Plugin exists", "pluginExists", plugExists) @@ -515,7 +532,7 @@ func (rpc *GloomRPC) UploadPlugin(plugin PluginUpload, reply *string) error { return err } - fmt.Println("Plugin uploaded successfully") + slog.Debug("Plugin uploaded successfully") if err := rpc.gloom.RegisterPlugin(pluginPath, plugin.Name, domains); err != nil { os.Remove(pluginPath) @@ -524,26 +541,23 @@ func (rpc *GloomRPC) UploadPlugin(plugin PluginUpload, reply *string) error { return err } - if !plugExists { - _, err = rpc.gloom.DB.Exec("INSERT INTO plugins (path, name, domains) VALUES (?, ?, ?)", pluginPath, plugin.Name, strings.Join(plugin.Domains, ",")) - if err != nil { - *reply = fmt.Sprintf("Plugin upload failed: %v", err) - return err - } - } else { + if plugExists { _, err = rpc.gloom.DB.Exec("UPDATE plugins SET domains = ?, path = ? WHERE name = ?", strings.Join(plugin.Domains, ","), pluginPath, plugin.Name) if err != nil { *reply = fmt.Sprintf("Plugin upload failed: %v", err) return err } - } - if plugExists { - // exit out early otherwise we risk creating multiple of the same plugin and causing undefined behavior *reply = "Plugin updated successfully" return nil } + _, err = rpc.gloom.DB.Exec("INSERT INTO plugins (path, name, domains) VALUES (?, ?, ?)", pluginPath, plugin.Name, strings.Join(plugin.Domains, ",")) + if err != nil { + *reply = fmt.Sprintf("Plugin upload failed: %v", err) + return err + } + *reply = "Plugin uploaded successfully" return nil } @@ -594,7 +608,7 @@ func main() { logger := slog.New(slog.NewTextHandler(os.Stdout, &slog.HandlerOptions{Level: level})) slog.SetDefault(logger) - proxyManager := NewProxyManager() + proxyManager := sentinel.NewProxyManager(RequestLogger{}) gloom, err := NewGloom(proxyManager) if err != nil { @@ -607,89 +621,15 @@ func main() { gloom.LoadInitialPlugins() - fmt.Println("Server running at http://localhost:3000") + slog.Info("Server running at http://localhost:3000") if err := gloom.ProxyManager.ListenAndServe("127.0.0.1:3000"); err != nil { panic(err) } } -// this is the object that oversees the proxying of requests to the correct deployment -type ProxyManager struct { - libs.SyncMap[string, *Proxy] -} +type RequestLogger struct{} -func NewProxyManager() *ProxyManager { - return &ProxyManager{} -} - -func (proxyManager *ProxyManager) ListenAndServe(host string) error { - slog.Info("Proxy server starting", "url", host) - if err := http.ListenAndServe(host, proxyManager); err != nil && err != http.ErrServerClosed { - return fmt.Errorf("failed to start proxy server: %v", err) - } - return nil -} - -// Stops forwarding traffic to a deployment -func (proxyManager *ProxyManager) RemoveDeployment(host string) { - slog.Info("Removing proxy", "host", host) - proxyManager.Delete(host) -} - -// Starts forwarding traffic to a deployment. The deployment must be ready to recieve requests before this is called. -func (proxyManager *ProxyManager) AddProxy(host string, proxy *Proxy) { - slog.Debug("Adding proxy", "host", host) - proxyManager.Store(host, proxy) -} - -// This function is responsible for taking an http request and forwarding it to the correct deployment -func (proxyManager *ProxyManager) ServeHTTP(w http.ResponseWriter, r *http.Request) { - start := time.Now() - host := r.Host - path := r.URL.Path - method := r.Method - ip := getClientIP(r) - - slog.Debug("Proxying request", "host", host, "path", path, "method", method, "ip", ip) - proxy, ok := proxyManager.Load(host) - if !ok { - http.Error(w, "Not found", http.StatusNotFound) - logRequest(host, http.StatusNotFound, time.Since(start), ip, method, path) - return - } - - // Create a custom ResponseWriter to capture the status code - rw := &ResponseWriterInterceptor{ResponseWriter: w, statusCode: http.StatusOK} - - proxy.proxyFunc.ServeHTTP(rw, r) - - latency := time.Since(start) - statusCode := rw.statusCode - - logRequest(host, statusCode, latency, ip, method, path) -} - -// getClientIP retrieves the client's IP address from the request. -// It handles cases where the IP might be forwarded by proxies. -func getClientIP(r *http.Request) string { - if forwarded := r.Header.Get("X-Forwarded-For"); forwarded != "" { - return forwarded - } - return r.RemoteAddr -} - -// ResponseWriterInterceptor is a custom http.ResponseWriter that captures the status code. -type ResponseWriterInterceptor struct { - http.ResponseWriter - statusCode int -} - -func (rw *ResponseWriterInterceptor) WriteHeader(code int) { - rw.statusCode = code - rw.ResponseWriter.WriteHeader(code) -} - -func logRequest(app string, status int, latency time.Duration, ip, method, path string) { +func (RequestLogger) LogRequest(app string, status int, latency time.Duration, ip, method, path string) { slog.Info("Proxy Request", slog.String("time", time.Now().Format(time.RFC3339)), slog.Int("status", status), @@ -710,85 +650,12 @@ func (d *unixDialer) DialContext(ctx context.Context, network, address string) ( return net.Dial("unix", d.socketPath) } -func NewUnixSocketTransport(socketPath string) *http.Transport { +func NewUnixSocketTransport(socket string) *http.Transport { return &http.Transport{ - DialContext: (&unixDialer{socketPath: socketPath}).DialContext, - } -} - -type Proxy struct { - socket string - proxyFunc *httputil.ReverseProxy - shutdownTimeout time.Duration - activeRequests int64 -} - -const PROXY_SHUTDOWN_TIMEOUT = 30 * time.Second - -// Creates a proxy for a given deployment -func NewDeploymentProxy(socket string) (*Proxy, error) { - proxy := &Proxy{ - socket: socket, - shutdownTimeout: PROXY_SHUTDOWN_TIMEOUT, - activeRequests: 0, - } - - transport := &http.Transport{ DialContext: (&unixDialer{socketPath: socket}).DialContext, MaxIdleConns: 100, IdleConnTimeout: 90 * time.Second, MaxIdleConnsPerHost: 100, ForceAttemptHTTP2: false, } - - proxy.proxyFunc = &httputil.ReverseProxy{ - Director: func(req *http.Request) { - req.URL = &url.URL{ - Scheme: "http", - Host: req.Host, - Path: req.URL.Path, - } - atomic.AddInt64(&proxy.activeRequests, 1) - }, - Transport: transport, - ModifyResponse: func(resp *http.Response) error { - atomic.AddInt64(&proxy.activeRequests, -1) - return nil - }, - ErrorHandler: func(w http.ResponseWriter, r *http.Request, err error) { - slog.Error("Proxy error", "error", err) - atomic.AddInt64(&proxy.activeRequests, -1) - w.WriteHeader(http.StatusInternalServerError) - }, - } - - return proxy, nil -} - -func (p *Proxy) GracefulShutdown(shutdownFunc func()) { - slog.Debug("Shutting down proxy", "socket", p.socket) - - ctx, cancel := context.WithTimeout(context.Background(), p.shutdownTimeout) - defer cancel() - - done := false - for !done { - select { - case <-ctx.Done(): - slog.Debug("Proxy shutdown timed out", "socket", p.socket) - - done = true - default: - if atomic.LoadInt64(&p.activeRequests) == 0 { - slog.Debug("Proxy shutdown completed successfully", "socket", p.socket) - done = true - } - - time.Sleep(time.Second) - } - } - - if shutdownFunc != nil { - shutdownFunc() - } } diff --git a/pluginHost/main.go b/pluginHost/main.go index c8ff541..b3a732b 100644 --- a/pluginHost/main.go +++ b/pluginHost/main.go @@ -33,7 +33,6 @@ func init() { type Plugin interface { Init() (*fiber.Config, error) RegisterRoutes(app fiber.Router) - // Name() string } type PluginInstance struct { @@ -43,12 +42,6 @@ type PluginInstance struct { Router *fiber.App } -// Init is the entry point for a container process -func (p *PluginInstance) Run(pluginName string) { - log.Printf("Starting container with plugin %s", pluginName) - // Load and initialize the plugin here -} - func main() { signalChan := make(chan os.Signal, 1) signal.Notify(signalChan, os.Interrupt) diff --git a/schema.sql b/schema.sql index 676422f..9c76b21 100644 --- a/schema.sql +++ b/schema.sql @@ -2,5 +2,6 @@ CREATE TABLE IF NOT EXISTS plugins ( id INTEGER PRIMARY KEY AUTOINCREMENT, path TEXT NOT NULL UNIQUE, name TEXT NOT NULL UNIQUE, + /* domains is a comma-separated list of domains */ domains TEXT NOT NULL ); diff --git a/zqdgr.config.json b/zqdgr.config.json index ba819ac..1c3cd70 100644 --- a/zqdgr.config.json +++ b/zqdgr.config.json @@ -5,10 +5,10 @@ "author": "juls0730", "license": "MIT", "scripts": { - "build": "zqdgr build:gloomi && zqdgr build:pluginHost && go build", + "build": "zqdgr build:gloomi && zqdgr build:gloom", "build:pluginHost": "sh -c \"cd pluginHost; go build -o ../host main.go\"", "build:gloomi": "sh -c \"cd gloomi; zqdgr build\"", - "build:no-gloomi": "go build", + "build:gloom": "zqdgr build:pluginHost && go build", "clean": "rm -rf plugs && rm -rf host && rm -rf gloom.db && rm -rf plugin/plugin.so && rm -rf gloom", "dev": "zqdgr build && ./gloom" },