diff --git a/.env.example b/.env.example index 5587527..a25d2a0 100644 --- a/.env.example +++ b/.env.example @@ -1,2 +1,3 @@ DISABLE_GLOOMI=false GLOOMI_HOSTNAME=localhost +PLUGINS_DIR=plugs diff --git a/.gitignore b/.gitignore index 17521a8..f063f1d 100644 --- a/.gitignore +++ b/.gitignore @@ -1,5 +1,6 @@ -plugs/** +plugs gloom +host **/*.so .env gloom.db \ No newline at end of file diff --git a/DOCUMENTATION.md b/DOCUMENTATION.md index 510c1d3..c213f1f 100644 --- a/DOCUMENTATION.md +++ b/DOCUMENTATION.md @@ -2,6 +2,9 @@ ## Plugins +> [!IMPORTANT] +> Plugins __must__ be compiled with the same version of Go that GLoom was compiled with. This is a limitation of Golang's plugin system, which is what GLoom uses to load plugins. + Plugins are the core of GLoom, they are responsible for handling requests and providing routes. When building a plugin, it's expected that all the assets you need will be bundled with the plugin. However, you are allowed to create directories for assets like file uploads, but we urge you to create a specific directory for assets (ie a database or a public directory) to avoid cluttering the root directory of gloom. ### Plugin Interface @@ -9,7 +12,6 @@ Plugins are the core of GLoom, they are responsible for handling requests and pr The `Plugin` interface is the main interface for plugins to implement. It has three methods: - `Init() (*fiber.Config, error)` - This method is called when the plugin is loaded. It is the function that is initially called when the plugin is loaded. -- `Name() string` - This method returns the name of the plugin. - `RegisterRoutes(router fiber.Router)` - This method is called when the plugin is loaded and is responsible for registering routes to the router. Furthermore, your plugin should export a symbol named `Plugin` that implements the `Plugin` interface. The easiest way to do this in Go is simply diff --git a/README.md b/README.md index b8c655c..6ba5052 100644 --- a/README.md +++ b/README.md @@ -1,18 +1,12 @@ # GLoom -GLoom is a plugin-based web app manager written in Go. 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, but it suffers from a few issues: - -- Incorrectly confgiured plugins will cause GLoom to crash -- GLoom plugins are cannot be reloaded when they are updated - -As far as I see it, these issues are unfixable currently, Go Plugins __cannot__ be unloaded, and there's no way to separate GLoom plugins from the host proces, thus meaning if a plugin crashes, GLoom will crash as well. +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. ## Features - Plugin-based architecture - RPC-based communication between GLoom and plugins - Built-in plugin management system -- Built-in plugin management UI ## Getting Started @@ -24,22 +18,34 @@ As far as I see it, these issues are unfixable currently, Go Plugins __cannot__ ### Installation 1. Clone the repository: - -```bash -git clone https://github.com/juls0730/gloom.git -``` + ```bash + git clone https://github.com/juls0730/gloom.git + ``` 2. Run the project: + ```bash + zqdgr run + ``` -```bash -zqdgr run -``` + or if you want to build the project: + ```bash + 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): + ```bash + zqdgr build:no-gloomi + ``` -or if you want to build the project: + and make sure to set the `DISABLE_GLOOMI` environment variable to `true` in the `.env` file. -```bash -zqdgr build -``` +## Configuring + +GLoom is configured using environment variables. The following environment variables are supported: + +- `DEBUG` - Enables debug logging. This is a boolean value, so you can set it to any truthy value to enable debug logging. +- `DISABLE_GLOOMI` - Disables the GLoomI plugin. This is a boolean value, so you can set it to any truthy value to disable the GLoomI plugin. +- `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 diff --git a/gloomi/go.sum b/gloomi/go.sum index 092a83e..93a18fd 100644 --- a/gloomi/go.sum +++ b/gloomi/go.sum @@ -1,5 +1,7 @@ github.com/andybalholm/brotli v1.1.1 h1:PR2pgnyFznKEugtsUo0xLdDop5SKXd5Qf5ysW+7XdTA= github.com/andybalholm/brotli v1.1.1/go.mod h1:05ib4cKhjx3OQYUY22hTVd34Bc8upXjOLL2rKwwZBoA= +github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c= +github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= github.com/fxamacker/cbor/v2 v2.7.0 h1:iM5WgngdRBanHcxugY4JySA0nk1wZorNOpTgCMedv5E= github.com/fxamacker/cbor/v2 v2.7.0/go.mod h1:pxXPTn3joSm21Gbwsv0w9OSA2y1HFR9qXEeXQVeNoDQ= github.com/gofiber/fiber/v3 v3.0.0-beta.4 h1:KzDSavvhG7m81NIsmnu5l3ZDbVS4feCidl4xlIfu6V0= @@ -19,6 +21,10 @@ github.com/mattn/go-isatty v0.0.20 h1:xfD0iDuEKnDkl03q4limB+vH+GxLEtL/jb4xVJSWWE github.com/mattn/go-isatty v0.0.20/go.mod h1:W+V8PltTTMOvKvAeJH7IuucS94S2C6jfK/D7dTCTo3Y= github.com/philhofer/fwd v1.1.3-0.20240916144458-20a13a1f6b7c h1:dAMKvw0MlJT1GshSTtih8C2gDs04w8dReiOGXrGLNoY= github.com/philhofer/fwd v1.1.3-0.20240916144458-20a13a1f6b7c/go.mod h1:RqIHx9QI14HlwKwm98g9Re5prTQ6LdeRQn+gXJFxsJM= +github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM= +github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4= +github.com/stretchr/testify v1.10.0 h1:Xv5erBjTwe/5IxqUQTdXv5kgmIvbHo3QQyRwhJsOfJA= +github.com/stretchr/testify v1.10.0/go.mod h1:r2ic/lqez/lEtzL7wO/rwa5dbSLXVDPFyf8C91i36aY= github.com/tinylib/msgp v1.2.5 h1:WeQg1whrXRFiZusidTQqzETkRpGjFjcIhW6uqWH09po= github.com/tinylib/msgp v1.2.5/go.mod h1:ykjzy2wzgrlvpDCRc4LA8UXy6D8bzMSuAF3WD57Gok0= github.com/valyala/bytebufferpool v1.0.0 h1:GqA5TC/0021Y/b9FG4Oi9Mr3q7XYx6KllzawFIhcdPw= @@ -29,6 +35,7 @@ github.com/valyala/tcplisten v1.0.0 h1:rBHj/Xf+E1tRGZyWIWwJDiRY0zc1Js+CV5DqwacVS github.com/valyala/tcplisten v1.0.0/go.mod h1:T0xQ8SeCZGxckz9qRXTfG43PvQ/mcWh7FwZEA7Ioqkc= github.com/x448/float16 v0.8.4 h1:qLwI1I70+NjRFUR3zs1JPUCgaCXSh3SW62uAKT1mSBM= github.com/x448/float16 v0.8.4/go.mod h1:14CWIYCyZA/cWjXOioeEpHeN/83MdbZDRQHoFcYsOfg= +github.com/xyproto/randomstring v1.0.5 h1:YtlWPoRdgMu3NZtP45drfy1GKoojuR7hmRcnhZqKjWU= github.com/xyproto/randomstring v1.0.5/go.mod h1:rgmS5DeNXLivK7YprL0pY+lTuhNQW3iGxZ18UQApw/E= golang.org/x/crypto v0.31.0 h1:ihbySMvVjLAeSH1IbfcRTkD/iNscyz8rGzjF/E5hV6U= golang.org/x/crypto v0.31.0/go.mod h1:kDsLvtWBEx7MV9tJOj9bnXsPbxwJQ6csT/x4KIN4Ssk= @@ -40,3 +47,5 @@ golang.org/x/sys v0.28.0 h1:Fksou7UEQUWlKvIdsqzJmUmCX3cZuD2+P3XyyzwMhlA= golang.org/x/sys v0.28.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA= golang.org/x/text v0.21.0 h1:zyQAAkrwaneQ066sspRyJaG9VNi/YJ1NfzcGB3hZ/qo= golang.org/x/text v0.21.0/go.mod h1:4IBbMaMmOPCJ8SecivzSH54+73PCFmPWxNTLm+vZkEQ= +gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA= +gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= diff --git a/gloomi/main.go b/gloomi/main.go index adcb3e6..0d03ce8 100644 --- a/gloomi/main.go +++ b/gloomi/main.go @@ -21,11 +21,9 @@ func (p *GLoomI) Init() (*fiber.Config, error) { return nil, fmt.Errorf("failed to connect to Gloom RPC server: %w", err) } p.client = client - return nil, nil -} - -func (p *GLoomI) Name() string { - return "GLoomI" + return &fiber.Config{ + BodyLimit: 1024 * 1024 * 1024 * 5, // 5GB + }, nil } type PluginData struct { @@ -95,12 +93,13 @@ func (p *GLoomI) RegisterRoutes(router fiber.Router) { return c.Status(fiber.StatusInternalServerError).SendString("Failed to read plugin file: " + err.Error()) } - err = p.client.Call("GloomRPC.UploadPlugin", pluginUploadStruct, nil) + reply := new(string) + err = p.client.Call("GloomRPC.UploadPlugin", pluginUploadStruct, reply) if err != nil { return c.Status(fiber.StatusInternalServerError).SendString("Failed to upload plugin: " + err.Error()) } - return c.Status(fiber.StatusOK).SendString("Plugin uploaded successfully") + return c.Status(fiber.StatusOK).SendString(*reply) }) apiRouter.Delete("/plugins/:pluginName", func(c fiber.Ctx) error { diff --git a/go.mod b/go.mod index c69c908..53817ac 100644 --- a/go.mod +++ b/go.mod @@ -2,27 +2,14 @@ module github.com/juls0730/gloom go 1.23.4 -require github.com/mattn/go-sqlite3 v1.14.24 +require ( + github.com/joho/godotenv v1.5.1 + github.com/mattn/go-sqlite3 v1.14.24 +) require ( - github.com/andybalholm/brotli v1.1.1 // indirect - github.com/fxamacker/cbor/v2 v2.7.0 // indirect - github.com/gofiber/fiber/v3 v3.0.0-beta.4 // indirect - github.com/gofiber/schema v1.2.0 // indirect - github.com/gofiber/utils/v2 v2.0.0-beta.7 // indirect - github.com/google/uuid v1.6.0 // indirect - github.com/joho/godotenv v1.5.1 // indirect - github.com/klauspost/compress v1.17.11 // indirect - github.com/mattn/go-colorable v0.1.13 // indirect + github.com/mattn/go-colorable v0.1.14 // indirect github.com/mattn/go-isatty v0.0.20 // indirect - github.com/philhofer/fwd v1.1.3-0.20240916144458-20a13a1f6b7c // indirect - github.com/tinylib/msgp v1.2.5 // indirect - github.com/valyala/bytebufferpool v1.0.0 // indirect - github.com/valyala/fasthttp v1.58.0 // indirect - github.com/valyala/tcplisten v1.0.0 // indirect - github.com/x448/float16 v0.8.4 // indirect - golang.org/x/crypto v0.31.0 // indirect - golang.org/x/net v0.31.0 // indirect - golang.org/x/sys v0.28.0 // indirect - golang.org/x/text v0.21.0 // indirect + github.com/mgutz/ansi v0.0.0-20200706080929-d51e80ef957d // indirect + golang.org/x/sys v0.29.0 // indirect ) diff --git a/go.sum b/go.sum index 7d560a4..cc9184e 100644 --- a/go.sum +++ b/go.sum @@ -1,46 +1,13 @@ -github.com/andybalholm/brotli v1.1.1 h1:PR2pgnyFznKEugtsUo0xLdDop5SKXd5Qf5ysW+7XdTA= -github.com/andybalholm/brotli v1.1.1/go.mod h1:05ib4cKhjx3OQYUY22hTVd34Bc8upXjOLL2rKwwZBoA= -github.com/fxamacker/cbor/v2 v2.7.0 h1:iM5WgngdRBanHcxugY4JySA0nk1wZorNOpTgCMedv5E= -github.com/fxamacker/cbor/v2 v2.7.0/go.mod h1:pxXPTn3joSm21Gbwsv0w9OSA2y1HFR9qXEeXQVeNoDQ= -github.com/gofiber/fiber/v3 v3.0.0-beta.4 h1:KzDSavvhG7m81NIsmnu5l3ZDbVS4feCidl4xlIfu6V0= -github.com/gofiber/fiber/v3 v3.0.0-beta.4/go.mod h1:/WFUoHRkZEsGHyy2+fYcdqi109IVOFbVwxv1n1RU+kk= -github.com/gofiber/schema v1.2.0 h1:j+ZRrNnUa/0ZuWrn/6kAtAufEr4jCJ+JuTURAMxNSZg= -github.com/gofiber/schema v1.2.0/go.mod h1:YYwj01w3hVfaNjhtJzaqetymL56VW642YS3qZPhuE6c= -github.com/gofiber/utils/v2 v2.0.0-beta.7 h1:NnHFrRHvhrufPABdWajcKZejz9HnCWmT/asoxRsiEbQ= -github.com/gofiber/utils/v2 v2.0.0-beta.7/go.mod h1:J/M03s+HMdZdvhAeyh76xT72IfVqBzuz/OJkrMa7cwU= -github.com/google/uuid v1.6.0 h1:NIvaJDMOsjHA8n1jAhLSgzrAzy1Hgr+hNrb57e+94F0= -github.com/google/uuid v1.6.0/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo= github.com/joho/godotenv v1.5.1 h1:7eLL/+HRGLY0ldzfGMeQkb7vMd0as4CfYvUVzLqw0N0= github.com/joho/godotenv v1.5.1/go.mod h1:f4LDr5Voq0i2e/R5DDNOoa2zzDfwtkZa6DnEwAbqwq4= -github.com/klauspost/compress v1.17.11 h1:In6xLpyWOi1+C7tXUUWv2ot1QvBjxevKAaI6IXrJmUc= -github.com/klauspost/compress v1.17.11/go.mod h1:pMDklpSncoRMuLFrf1W9Ss9KT+0rH90U12bZKk7uwG0= -github.com/mattn/go-colorable v0.1.13 h1:fFA4WZxdEF4tXPZVKMLwD8oUnCTTo08duU7wxecdEvA= -github.com/mattn/go-colorable v0.1.13/go.mod h1:7S9/ev0klgBDR4GtXTXX8a3vIGJpMovkB8vQcUbaXHg= -github.com/mattn/go-isatty v0.0.16/go.mod h1:kYGgaQfpe5nmfYZH+SKPsOc2e4SrIfOl2e/yFXSvRLM= +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/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/philhofer/fwd v1.1.3-0.20240916144458-20a13a1f6b7c h1:dAMKvw0MlJT1GshSTtih8C2gDs04w8dReiOGXrGLNoY= -github.com/philhofer/fwd v1.1.3-0.20240916144458-20a13a1f6b7c/go.mod h1:RqIHx9QI14HlwKwm98g9Re5prTQ6LdeRQn+gXJFxsJM= -github.com/tinylib/msgp v1.2.5 h1:WeQg1whrXRFiZusidTQqzETkRpGjFjcIhW6uqWH09po= -github.com/tinylib/msgp v1.2.5/go.mod h1:ykjzy2wzgrlvpDCRc4LA8UXy6D8bzMSuAF3WD57Gok0= -github.com/valyala/bytebufferpool v1.0.0 h1:GqA5TC/0021Y/b9FG4Oi9Mr3q7XYx6KllzawFIhcdPw= -github.com/valyala/bytebufferpool v1.0.0/go.mod h1:6bBcMArwyJ5K/AmCkWv1jt77kVWyCJ6HpOuEn7z0Csc= -github.com/valyala/fasthttp v1.58.0 h1:GGB2dWxSbEprU9j0iMJHgdKYJVDyjrOwF9RE59PbRuE= -github.com/valyala/fasthttp v1.58.0/go.mod h1:SYXvHHaFp7QZHGKSHmoMipInhrI5StHrhDTYVEjK/Kw= -github.com/valyala/tcplisten v1.0.0 h1:rBHj/Xf+E1tRGZyWIWwJDiRY0zc1Js+CV5DqwacVSA8= -github.com/valyala/tcplisten v1.0.0/go.mod h1:T0xQ8SeCZGxckz9qRXTfG43PvQ/mcWh7FwZEA7Ioqkc= -github.com/x448/float16 v0.8.4 h1:qLwI1I70+NjRFUR3zs1JPUCgaCXSh3SW62uAKT1mSBM= -github.com/x448/float16 v0.8.4/go.mod h1:14CWIYCyZA/cWjXOioeEpHeN/83MdbZDRQHoFcYsOfg= -github.com/xyproto/randomstring v1.0.5/go.mod h1:rgmS5DeNXLivK7YprL0pY+lTuhNQW3iGxZ18UQApw/E= -golang.org/x/crypto v0.31.0 h1:ihbySMvVjLAeSH1IbfcRTkD/iNscyz8rGzjF/E5hV6U= -golang.org/x/crypto v0.31.0/go.mod h1:kDsLvtWBEx7MV9tJOj9bnXsPbxwJQ6csT/x4KIN4Ssk= -golang.org/x/net v0.31.0 h1:68CPQngjLL0r2AlUKiSxtQFKvzRVbnzLwMUn5SzcLHo= -golang.org/x/net v0.31.0/go.mod h1:P4fl1q7dY2hnZFxEk4pPSkDHF+QqjitcnDjUQyMM+pM= -golang.org/x/sys v0.0.0-20220811171246-fbc7d0a398ab/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= +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.28.0 h1:Fksou7UEQUWlKvIdsqzJmUmCX3cZuD2+P3XyyzwMhlA= -golang.org/x/sys v0.28.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA= -golang.org/x/text v0.21.0 h1:zyQAAkrwaneQ066sspRyJaG9VNi/YJ1NfzcGB3hZ/qo= -golang.org/x/text v0.21.0/go.mod h1:4IBbMaMmOPCJ8SecivzSH54+73PCFmPWxNTLm+vZkEQ= +golang.org/x/sys v0.29.0 h1:TPYlXGxvx1MGTn2GiZDhnjPA9wZzZeGKHHmKhHYvgaU= +golang.org/x/sys v0.29.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA= diff --git a/main.go b/main.go index ead9677..19ca9c0 100644 --- a/main.go +++ b/main.go @@ -1,48 +1,66 @@ package main import ( + "bufio" + "context" "database/sql" "embed" "fmt" "log/slog" + "math/rand/v2" "net" + "net/http" + "net/http/httputil" "net/rpc" + "net/url" "os" - "plugin" + "os/exec" + "path" + "path/filepath" + "strconv" "strings" + "sync" + "sync/atomic" + "time" - "github.com/gofiber/fiber/v3" - "github.com/gofiber/fiber/v3/middleware/logger" "github.com/joho/godotenv" "github.com/juls0730/gloom/libs" _ "github.com/mattn/go-sqlite3" ) -//go:embed schema.sql +//go:embed schema.sql host var embeddedAssets embed.FS -type Plugin interface { - Init() (*fiber.Config, error) - RegisterRoutes(app fiber.Router) - Name() string -} - -type PluginInstance struct { - Plugin Plugin - Name string - Path string - Router *fiber.App +type PluginHost struct { + UnixSocket string + Process *os.Process + Domains []string } type GLoom struct { - Plugins []PluginInstance - domainMap libs.SyncMap[string, *PluginInstance] - DB *sql.DB - fiber *fiber.App + // path to the pluginHost binary + tmpDir string + pluginDir string + + plugins libs.SyncMap[string, *PluginHost] + hostMap libs.SyncMap[string, bool] + + DB *sql.DB + ProxyManager *ProxyManager } -func NewGloom(app *fiber.App) (*GLoom, error) { - if err := os.MkdirAll("plugs", 0755); err != nil { +func NewGloom(proxyManager *ProxyManager) (*GLoom, error) { + pluginsDir := os.Getenv("PLUGINS_DIR") + if pluginsDir == "" { + pluginsDir = "plugs" + } + + pluginsDir, err := filepath.Abs(pluginsDir) + if err != nil { + return nil, err + } + + if err := os.MkdirAll(pluginsDir, 0755); err != nil { if os.IsNotExist(err) { panic(err) } @@ -63,18 +81,35 @@ func NewGloom(app *fiber.App) (*GLoom, error) { return nil, err } + pluginHost, err := embeddedAssets.ReadFile("host") + if err != nil { + return nil, err + } + + tmpDir, err := os.MkdirTemp(os.TempDir(), "gloom") + if err != nil { + return nil, err + } + if err = os.WriteFile(tmpDir+"/pluginHost", pluginHost, 0755); err != nil { + return nil, err + } + slog.Debug("Wrote pluginHost", "dir", tmpDir+"/pluginHost") + gloom := &GLoom{ - Plugins: []PluginInstance{}, - domainMap: libs.SyncMap[string, *PluginInstance]{}, - DB: db, - fiber: app, + tmpDir: tmpDir, + pluginDir: pluginsDir, + plugins: libs.SyncMap[string, *PluginHost]{}, + DB: db, + ProxyManager: proxyManager, } return gloom, nil } func (gloom *GLoom) LoadInitialPlugins() error { - plugins, err := gloom.DB.Query("SELECT path, domains FROM plugins") + slog.Debug("Loading initial plugins") + + plugins, err := gloom.DB.Query("SELECT path, domains, name FROM plugins") if err != nil { return err } @@ -84,15 +119,16 @@ func (gloom *GLoom) LoadInitialPlugins() error { var plugin struct { Path string Domain string + Name string } - if err := plugins.Scan(&plugin.Path, &plugin.Domain); err != nil { + if err := plugins.Scan(&plugin.Path, &plugin.Domain, &plugin.Name); err != nil { return err } domains := strings.Split(plugin.Domain, ",") - if err := gloom.RegisterPlugin(plugin.Path, domains); err != nil { + if err := gloom.RegisterPlugin(plugin.Path, plugin.Name, domains); err != nil { slog.Warn("Failed to register plugin", "pluginPath", plugin.Path, "error", err) } } @@ -100,59 +136,177 @@ func (gloom *GLoom) LoadInitialPlugins() error { return nil } -func (gloom *GLoom) RegisterPlugin(pluginPath string, domains []string) error { +var ErrLocked = fmt.Errorf("item is locked") + +type MutexLock[T comparable] struct { + mu sync.Mutex + deployed map[T]context.CancelFunc +} + +func NewMutexLock[T comparable]() *MutexLock[T] { + return &MutexLock[T]{ + deployed: make(map[T]context.CancelFunc), + } +} + +func (dt *MutexLock[T]) Lock(id T, ctx context.Context) (context.Context, error) { + dt.mu.Lock() + defer dt.mu.Unlock() + + // Check if the object is locked + if _, exists := dt.deployed[id]; exists { + slog.Debug("Item is locked", "id", id) + return nil, ErrLocked + } + + // Create a context that can be cancelled + ctx, cancel := context.WithCancel(ctx) + + // Store the cancel function + dt.deployed[id] = cancel + + return ctx, nil +} + +func (dt *MutexLock[T]) Unlock(id T) { + dt.mu.Lock() + defer dt.mu.Unlock() + + // Remove the app from deployed tracking + if cancel, exists := dt.deployed[id]; exists { + // Cancel the context + cancel() + // Remove from map + delete(dt.deployed, id) + } +} + +var deploymentLock = NewMutexLock[string]() + +func (gloom *GLoom) RegisterPlugin(pluginPath string, name string, domains []string) (err error) { slog.Info("Registering plugin", "pluginPath", pluginPath, "domains", domains) - p, err := plugin.Open(pluginPath) + pathStr := strconv.FormatUint(uint64(rand.Uint64()), 16) + socketPath := path.Join(gloom.tmpDir, pathStr+".sock") + controlPath := path.Join(gloom.tmpDir, pathStr+"-control.sock") + + slog.Debug("Starting pluginHost", "pluginPath", pluginPath, "socketPath", socketPath) + + processPath := path.Join(gloom.tmpDir, "pluginHost") + args := []string{pluginPath, socketPath, controlPath} + slog.Debug("Starting pluginHost", "args", args) + + cmd := exec.Command(processPath, args...) + if err := cmd.Start(); err != nil { + return fmt.Errorf("failed to start pluginHost: %w", err) + } + process := cmd.Process + + for { + _, err := os.Stat(controlPath) + if err == nil { + break + } + time.Sleep(100 * time.Millisecond) + } + + conn, err := net.DialTimeout("unix", controlPath, 5*time.Second) + if err != nil { + _ = process.Signal(os.Interrupt) + return fmt.Errorf("failed to connect to plugin control socket: %w", err) + } + defer conn.Close() + + reader := bufio.NewReader(conn) + readTimeout := time.After(30 * time.Second) + + select { + case <-readTimeout: + _ = process.Signal(os.Interrupt) + return fmt.Errorf("timed out waiting for plugin status") + default: + status, err := reader.ReadString('\n') + if err != nil { + _ = process.Signal(os.Interrupt) + return fmt.Errorf("error reading plugin status: %w", err) + } + status = strings.TrimSpace(status) + + if status == "ready" { + slog.Debug("PluginHost ported ready", "pluginPath", pluginPath) + break + } else if strings.HasPrefix(status, "Error: ") { + errorMessage := strings.TrimPrefix(status, "Error: ") + _ = process.Signal(os.Interrupt) + return fmt.Errorf("plugin reported error: %s", errorMessage) + } else { + _ = process.Signal(os.Interrupt) + return fmt.Errorf("received unknown status from plugin: %s", status) + } + } + + proxy, err := NewDeploymentProxy(socketPath) if err != nil { return err } - symbol, err := p.Lookup("Plugin") - if err != nil { - return err - } - - pluginLib, ok := symbol.(Plugin) - if !ok { - return fmt.Errorf("plugin is not a Plugin") - } - - fiberConfig, err := pluginLib.Init() - if err != nil { - return err - } - - if fiberConfig == nil { - fiberConfig = &fiber.Config{} - } - - router := fiber.New(*fiberConfig) - pluginLib.RegisterRoutes(router) - - pluginInstance := PluginInstance{ - Plugin: pluginLib, - Name: pluginLib.Name(), - Path: pluginPath, - Router: router, - } - - gloom.Plugins = append(gloom.Plugins, pluginInstance) - pluginPtr := &gloom.Plugins[len(gloom.Plugins)-1] + var oldProxy *Proxy for _, domain := range domains { - gloom.domainMap.Store(domain, pluginPtr) + 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 + // 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 + } } + // this will replace the old proxy with a new one + for _, domain := range domains { + gloom.ProxyManager.AddProxy(domain, proxy) + } + + plugHost := &PluginHost{ + UnixSocket: socketPath, + Process: process, + Domains: domains, + } + + gloom.plugins.Store(pluginPath, plugHost) + + if oldProxy != nil { + go func() { + oldProxy.GracefulShutdown(nil) + }() + } + + slog.Debug("Registered plugin", "pluginPath", pluginPath, "domains", domains) + return nil } -func (gloom *GLoom) DeletePlugin(pluginName string) { - gloom.domainMap.Range(func(domain string, plugin *PluginInstance) bool { - if plugin.Name == pluginName { - gloom.domainMap.Delete(domain) - } - return true - }) +// removes plugin from proxy and kills the process +func (gloom *GLoom) DeletePlugin(pluginName string) error { + slog.Debug("Deleting plugin", "pluginName", pluginName) + + plug, ok := gloom.plugins.Load(pluginName) + if !ok { + return fmt.Errorf("plugin not found") + } + + for _, domain := range plug.Domains { + gloom.ProxyManager.RemoveDeployment(domain) + gloom.hostMap.Store(domain, false) + } + + plug.Process.Signal(os.Interrupt) + for _, domain := range plug.Domains { + gloom.ProxyManager.RemoveDeployment(domain) + } + + gloom.plugins.Delete(pluginName) + + return nil } func (gloom *GLoom) StartRPCServer() error { @@ -192,23 +346,17 @@ type PluginData struct { } func (rpc *GloomRPC) ListPlugins(_ struct{}, reply *[]PluginData) error { - var plugins []PluginData = make([]PluginData, 0) - var domains map[string][]string = make(map[string][]string) - - rpc.gloom.domainMap.Range(func(domain string, plugin *PluginInstance) bool { - domains[plugin.Name] = append(domains[plugin.Name], domain) + var pluginsArray []PluginData = make([]PluginData, 0, len(rpc.gloom.plugins.Keys())) + rpc.gloom.plugins.Range(func(key string, value *PluginHost) (shouldContinue bool) { + pluginData := PluginData{ + Name: key, + Domains: value.Domains, + } + pluginsArray = append(pluginsArray, pluginData) return true }) - for _, plugin := range rpc.gloom.Plugins { - var pluginDataStruct PluginData - pluginDataStruct.Name = plugin.Name - pluginDataStruct.Domains = domains[plugin.Name] - - plugins = append(plugins, pluginDataStruct) - } - - *reply = plugins + *reply = pluginsArray return nil } @@ -219,85 +367,143 @@ type PluginUpload struct { } func (rpc *GloomRPC) UploadPlugin(plugin PluginUpload, reply *string) error { + _, err := deploymentLock.Lock(plugin.Name, context.Background()) + if err != nil && err == ErrLocked { + *reply = "Plugin is already being updated" + return fmt.Errorf("plugin is already being updated") + } + defer deploymentLock.Unlock(plugin.Name) + slog.Info("Uploading plugin", "plugin", plugin.Name, "domains", plugin.Domains) + pluginPath, err := filepath.Abs(fmt.Sprintf("plugs/%s", plugin.Name)) + if err != nil { + *reply = "Plugin upload failed" + return err + } + var plugExists bool - rpc.gloom.DB.QueryRow("SELECT path FROM plugins WHERE path = ?", "plugs/"+plugin.Name).Scan(&plugExists) + // 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) var domains []string + var newDomains []string if plugExists { // if plugin exists, we need to not check for domains that this plug has already registered, but instead check for new domains this plugin is registering - domains = make([]string, 0) - var existingDomains []string - err := rpc.gloom.DB.QueryRow("SELECT domains FROM plugins WHERE path = ?", "plugs/"+plugin.Name).Scan(&existingDomains) + domainsMap := map[string]bool{} + newDomains = make([]string, 0) + removedDomains := make([]string, 0) + var sqlDomains string + err := rpc.gloom.DB.QueryRow("SELECT domains FROM plugins WHERE name = ?", plugin.Name).Scan(&sqlDomains) if err != nil { return err } + // domains that are already related to the plugin + existingDomains := strings.Split(sqlDomains, ",") + + for _, domain := range plugin.Domains { + domainsMap[domain] = true + } + for _, domain := range existingDomains { - var found bool - for _, domainToCheck := range plugin.Domains { - if domain == domainToCheck { - found = true - break - } + if _, ok := domainsMap[domain]; !ok { + removedDomains = append(removedDomains, domain) } - if !found { - domains = append(domains, domain) + } + + for _, domain := range removedDomains { + slog.Debug("Removing domain from plugin", "domain", domain, "plugin", plugin.Name) + rpc.gloom.ProxyManager.RemoveDeployment(domain) + } + + for domain := range domainsMap { + if exists, _ := rpc.gloom.hostMap.Load(domain); !exists { + newDomains = append(newDomains, domain) } - found = false + slog.Debug("Adding domain to plugin", "domain", domain, "plugin", plugin.Name) + domains = append(domains, domain) } } else { domains = plugin.Domains + newDomains = plugin.Domains } - for _, domain := range domains { - _, ok := rpc.gloom.domainMap.Load(domain) + for _, domain := range newDomains { + _, ok := rpc.gloom.hostMap.Load(domain) if ok { *reply = fmt.Sprintf("Domain %s already exists", domain) return nil } } + plugsDir := "plugs" + + if os.Getenv("PLUGINS_DIR") != "" { + plugsDir = os.Getenv("PLUGINS_DIR") + } + + if _, err := os.Stat(plugsDir); os.IsNotExist(err) { + if err := os.Mkdir(plugsDir, 0755); err != nil { + *reply = "Plugin upload failed" + return err + } + } + // regardless of if plugin exists or not, we'll upload the file since this could be an update to an existing plugin - if err := os.WriteFile(fmt.Sprintf("plugs/%s", plugin.Name), plugin.Data, 0644); err != nil { + if err := os.WriteFile(pluginPath, plugin.Data, 0644); err != nil { + *reply = "Plugin upload failed" return err } fmt.Println("Plugin uploaded successfully") + if err := rpc.gloom.RegisterPlugin(pluginPath, plugin.Name, domains); err != nil { + os.Remove(pluginPath) + slog.Warn("Failed to register uplaoded plguin", "pluginPath", pluginPath, "error", err) + *reply = fmt.Sprintf("Plugin upload failed: %v", err) + 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 { + _, 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 } - if err := rpc.gloom.RegisterPlugin("plugs/"+plugin.Name, plugin.Domains); err != nil { - slog.Warn("Failed to register uplaoded plguin", "pluginPath", "plugs/"+plugin.Name, "error", err) - *reply = "Plugin upload failed" - return nil - } - rpc.gloom.DB.Exec("INSERT INTO plugins (path, domains) VALUES (?, ?)", "plugs/"+plugin.Name, strings.Join(plugin.Domains, ",")) *reply = "Plugin uploaded successfully" return nil } func (rpc *GloomRPC) DeletePlugin(pluginName string, reply *string) error { - var targetPlugin PluginInstance - for _, plugin := range rpc.gloom.Plugins { - if plugin.Name == pluginName { - targetPlugin = plugin - break - } + if pluginName == "GLoomI" { + *reply = "GLoomI cannot be deleted since it is not a plugin that is loaded by a user. If you wish to disable GLoomI, set DISABLE_GLOOMI=true in your .env file" + return nil } - _, err := rpc.gloom.DB.Exec("DELETE FROM plugins WHERE path = ?", targetPlugin.Path) - if err != nil { + _, ok := rpc.gloom.plugins.Load(pluginName) + if !ok { *reply = "Plugin not found" - return err + return nil } - err = os.Remove(targetPlugin.Path) + _, err := rpc.gloom.DB.Exec("DELETE FROM plugins WHERE name = ?", pluginName) if err != nil { *reply = "Plugin not found" return err @@ -316,54 +522,230 @@ func init() { } func main() { - app := fiber.New(fiber.Config{ - BodyLimit: 1024 * 1024 * 1024 * 5, // 5GB - }) + debug, err := strconv.ParseBool(os.Getenv("DEBUG")) + if err != nil { + debug = false + } - app.Use(logger.New(logger.Config{ - CustomTags: map[string]logger.LogFunc{ - "app": func(output logger.Buffer, c fiber.Ctx, data *logger.Data, extraParam string) (int, error) { - output.WriteString(c.Host()) - return len(output.Bytes()), nil - }, - }, - Format: " ${time} | ${status} | ${latency} | ${ip} | ${method} | ${app} | ${path}\n", - })) + level := slog.LevelInfo + if debug { + level = slog.LevelDebug + } - gloom, err := NewGloom(app) + logger := slog.New(slog.NewTextHandler(os.Stdout, &slog.HandlerOptions{Level: level})) + slog.SetDefault(logger) + + proxyManager := NewProxyManager() + + gloom, err := NewGloom(proxyManager) if err != nil { panic(err) } - app.Use(func(c fiber.Ctx) error { - host := c.Host() - if plugin, ok := gloom.domainMap.Load(host); ok { - plugin.Router.Handler()(c.RequestCtx()) - return nil - } - - return c.Status(404).SendString("Domain not found") - }) - if err := gloom.StartRPCServer(); err != nil { panic("Failed to start RPC server: " + err.Error()) } gloom.LoadInitialPlugins() - if os.Getenv("DISABLE_GLOOMI") != "true" { + enableGloomi, err := strconv.ParseBool(os.Getenv("ENABLE_GLOOMI")) + if err != nil { + enableGloomi = true + } + + if enableGloomi { hostname := os.Getenv("GLOOMI_HOSTNAME") if hostname == "" { hostname = "127.0.0.1" } - if err := gloom.RegisterPlugin("plugs/gloomi.so", []string{hostname}); err != nil { + if err := gloom.RegisterPlugin("plugs/gloomi.so", "GLoomI", []string{hostname}); err != nil { panic("Failed to register GLoomI: " + err.Error()) } } fmt.Println("Server running at http://localhost:3000") - if err := app.Listen(":3000"); err != nil { + 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] +} + +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) { + slog.Info("Proxy Request", + slog.String("time", time.Now().Format(time.RFC3339)), + slog.Int("status", status), + slog.Duration("latency", latency), + slog.String("ip", ip), + slog.String("method", method), + slog.String("app", app), + slog.String("path", path), + ) +} + +type unixDialer struct { + socketPath string +} + +// dialContext implements DialContext but ignored everthing and just gives you a connection to the unix socket +func (d *unixDialer) DialContext(ctx context.Context, network, address string) (net.Conn, error) { + return net.Dial("unix", d.socketPath) +} + +func NewUnixSocketTransport(socketPath 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/plugin/go.mod b/plugin/go.mod index 33d0289..fdb2701 100644 --- a/plugin/go.mod +++ b/plugin/go.mod @@ -2,10 +2,11 @@ module github.com/juls0730/gloom-plugin go 1.23.4 +require github.com/gofiber/fiber/v3 v3.0.0-beta.4 + require ( github.com/andybalholm/brotli v1.1.1 // indirect github.com/fxamacker/cbor/v2 v2.7.0 // indirect - github.com/gofiber/fiber/v3 v3.0.0-beta.4 // indirect github.com/gofiber/schema v1.2.0 // indirect github.com/gofiber/utils/v2 v2.0.0-beta.7 // indirect github.com/google/uuid v1.6.0 // indirect diff --git a/plugin/go.sum b/plugin/go.sum index 092a83e..93a18fd 100644 --- a/plugin/go.sum +++ b/plugin/go.sum @@ -1,5 +1,7 @@ github.com/andybalholm/brotli v1.1.1 h1:PR2pgnyFznKEugtsUo0xLdDop5SKXd5Qf5ysW+7XdTA= github.com/andybalholm/brotli v1.1.1/go.mod h1:05ib4cKhjx3OQYUY22hTVd34Bc8upXjOLL2rKwwZBoA= +github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c= +github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= github.com/fxamacker/cbor/v2 v2.7.0 h1:iM5WgngdRBanHcxugY4JySA0nk1wZorNOpTgCMedv5E= github.com/fxamacker/cbor/v2 v2.7.0/go.mod h1:pxXPTn3joSm21Gbwsv0w9OSA2y1HFR9qXEeXQVeNoDQ= github.com/gofiber/fiber/v3 v3.0.0-beta.4 h1:KzDSavvhG7m81NIsmnu5l3ZDbVS4feCidl4xlIfu6V0= @@ -19,6 +21,10 @@ github.com/mattn/go-isatty v0.0.20 h1:xfD0iDuEKnDkl03q4limB+vH+GxLEtL/jb4xVJSWWE github.com/mattn/go-isatty v0.0.20/go.mod h1:W+V8PltTTMOvKvAeJH7IuucS94S2C6jfK/D7dTCTo3Y= github.com/philhofer/fwd v1.1.3-0.20240916144458-20a13a1f6b7c h1:dAMKvw0MlJT1GshSTtih8C2gDs04w8dReiOGXrGLNoY= github.com/philhofer/fwd v1.1.3-0.20240916144458-20a13a1f6b7c/go.mod h1:RqIHx9QI14HlwKwm98g9Re5prTQ6LdeRQn+gXJFxsJM= +github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM= +github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4= +github.com/stretchr/testify v1.10.0 h1:Xv5erBjTwe/5IxqUQTdXv5kgmIvbHo3QQyRwhJsOfJA= +github.com/stretchr/testify v1.10.0/go.mod h1:r2ic/lqez/lEtzL7wO/rwa5dbSLXVDPFyf8C91i36aY= github.com/tinylib/msgp v1.2.5 h1:WeQg1whrXRFiZusidTQqzETkRpGjFjcIhW6uqWH09po= github.com/tinylib/msgp v1.2.5/go.mod h1:ykjzy2wzgrlvpDCRc4LA8UXy6D8bzMSuAF3WD57Gok0= github.com/valyala/bytebufferpool v1.0.0 h1:GqA5TC/0021Y/b9FG4Oi9Mr3q7XYx6KllzawFIhcdPw= @@ -29,6 +35,7 @@ github.com/valyala/tcplisten v1.0.0 h1:rBHj/Xf+E1tRGZyWIWwJDiRY0zc1Js+CV5DqwacVS github.com/valyala/tcplisten v1.0.0/go.mod h1:T0xQ8SeCZGxckz9qRXTfG43PvQ/mcWh7FwZEA7Ioqkc= github.com/x448/float16 v0.8.4 h1:qLwI1I70+NjRFUR3zs1JPUCgaCXSh3SW62uAKT1mSBM= github.com/x448/float16 v0.8.4/go.mod h1:14CWIYCyZA/cWjXOioeEpHeN/83MdbZDRQHoFcYsOfg= +github.com/xyproto/randomstring v1.0.5 h1:YtlWPoRdgMu3NZtP45drfy1GKoojuR7hmRcnhZqKjWU= github.com/xyproto/randomstring v1.0.5/go.mod h1:rgmS5DeNXLivK7YprL0pY+lTuhNQW3iGxZ18UQApw/E= golang.org/x/crypto v0.31.0 h1:ihbySMvVjLAeSH1IbfcRTkD/iNscyz8rGzjF/E5hV6U= golang.org/x/crypto v0.31.0/go.mod h1:kDsLvtWBEx7MV9tJOj9bnXsPbxwJQ6csT/x4KIN4Ssk= @@ -40,3 +47,5 @@ golang.org/x/sys v0.28.0 h1:Fksou7UEQUWlKvIdsqzJmUmCX3cZuD2+P3XyyzwMhlA= golang.org/x/sys v0.28.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA= golang.org/x/text v0.21.0 h1:zyQAAkrwaneQ066sspRyJaG9VNi/YJ1NfzcGB3hZ/qo= golang.org/x/text v0.21.0/go.mod h1:4IBbMaMmOPCJ8SecivzSH54+73PCFmPWxNTLm+vZkEQ= +gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA= +gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= diff --git a/plugin/main.go b/plugin/main.go index fc18dee..820af88 100644 --- a/plugin/main.go +++ b/plugin/main.go @@ -8,13 +8,9 @@ func (p *MyPlugin) Init() (*fiber.Config, error) { return nil, nil } -func (p *MyPlugin) Name() string { - return "MyPlugin" -} - func (p *MyPlugin) RegisterRoutes(router fiber.Router) { router.Get("/", func(c fiber.Ctx) error { - return c.Status(fiber.StatusTeapot).SendString("Welcome to MyPlugin!") + return c.Status(fiber.StatusOK).SendString("Welcome to MyPlugin!") }) router.Get("/hello", func(c fiber.Ctx) error { diff --git a/pluginHost/README.md b/pluginHost/README.md new file mode 100644 index 0000000..f9d7baf --- /dev/null +++ b/pluginHost/README.md @@ -0,0 +1,26 @@ +# Plugin Host +This is the plugin host for GLoom. This is a small program that is responsible for loading and managing plugins. It is responsible for starting the plugin and forwarding requests to it. This is meant to be used with GLoom, but can be used as a standalone program if you so choose. The Plugin Host is built automatically when you build GLoom via `zqdgr build`. + +## Building +To build the plugin host standalone, run the following command in the `pluginHost` directory: + +```bash +zqdgr build +``` + +or run the following command in the project root: + +```bash +zqdgr build:pluginHost +``` + +## Running +To run the plugin host, run the following command: + +```bash +./host [controlPath] +``` + +- `pluginPath` - The path to the plugin to load. +- `socketPath` - The path to the socket that the plugin will use to listen for http requests through. +- `controlPath` - (Optional) The path to the control socket. If not provided, the host will not send errors or status messages to the control socket and instead log them to stdout and stderr. \ No newline at end of file diff --git a/pluginHost/go.mod b/pluginHost/go.mod new file mode 100644 index 0000000..2a5d00b --- /dev/null +++ b/pluginHost/go.mod @@ -0,0 +1,26 @@ +module github.com/juls0730/gloom/pluginHost + +go 1.24.2 + +require github.com/gofiber/fiber/v3 v3.0.0-beta.4 + +require ( + github.com/andybalholm/brotli v1.1.1 // indirect + github.com/fxamacker/cbor/v2 v2.7.0 // indirect + github.com/gofiber/schema v1.2.0 // indirect + github.com/gofiber/utils/v2 v2.0.0-beta.7 // indirect + github.com/google/uuid v1.6.0 // indirect + github.com/klauspost/compress v1.17.11 // indirect + github.com/mattn/go-colorable v0.1.13 // indirect + github.com/mattn/go-isatty v0.0.20 // indirect + github.com/philhofer/fwd v1.1.3-0.20240916144458-20a13a1f6b7c // indirect + github.com/tinylib/msgp v1.2.5 // indirect + github.com/valyala/bytebufferpool v1.0.0 // indirect + github.com/valyala/fasthttp v1.58.0 // indirect + github.com/valyala/tcplisten v1.0.0 // indirect + github.com/x448/float16 v0.8.4 // indirect + golang.org/x/crypto v0.31.0 // indirect + golang.org/x/net v0.31.0 // indirect + golang.org/x/sys v0.28.0 // indirect + golang.org/x/text v0.21.0 // indirect +) diff --git a/pluginHost/go.sum b/pluginHost/go.sum new file mode 100644 index 0000000..93a18fd --- /dev/null +++ b/pluginHost/go.sum @@ -0,0 +1,51 @@ +github.com/andybalholm/brotli v1.1.1 h1:PR2pgnyFznKEugtsUo0xLdDop5SKXd5Qf5ysW+7XdTA= +github.com/andybalholm/brotli v1.1.1/go.mod h1:05ib4cKhjx3OQYUY22hTVd34Bc8upXjOLL2rKwwZBoA= +github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c= +github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= +github.com/fxamacker/cbor/v2 v2.7.0 h1:iM5WgngdRBanHcxugY4JySA0nk1wZorNOpTgCMedv5E= +github.com/fxamacker/cbor/v2 v2.7.0/go.mod h1:pxXPTn3joSm21Gbwsv0w9OSA2y1HFR9qXEeXQVeNoDQ= +github.com/gofiber/fiber/v3 v3.0.0-beta.4 h1:KzDSavvhG7m81NIsmnu5l3ZDbVS4feCidl4xlIfu6V0= +github.com/gofiber/fiber/v3 v3.0.0-beta.4/go.mod h1:/WFUoHRkZEsGHyy2+fYcdqi109IVOFbVwxv1n1RU+kk= +github.com/gofiber/schema v1.2.0 h1:j+ZRrNnUa/0ZuWrn/6kAtAufEr4jCJ+JuTURAMxNSZg= +github.com/gofiber/schema v1.2.0/go.mod h1:YYwj01w3hVfaNjhtJzaqetymL56VW642YS3qZPhuE6c= +github.com/gofiber/utils/v2 v2.0.0-beta.7 h1:NnHFrRHvhrufPABdWajcKZejz9HnCWmT/asoxRsiEbQ= +github.com/gofiber/utils/v2 v2.0.0-beta.7/go.mod h1:J/M03s+HMdZdvhAeyh76xT72IfVqBzuz/OJkrMa7cwU= +github.com/google/uuid v1.6.0 h1:NIvaJDMOsjHA8n1jAhLSgzrAzy1Hgr+hNrb57e+94F0= +github.com/google/uuid v1.6.0/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo= +github.com/klauspost/compress v1.17.11 h1:In6xLpyWOi1+C7tXUUWv2ot1QvBjxevKAaI6IXrJmUc= +github.com/klauspost/compress v1.17.11/go.mod h1:pMDklpSncoRMuLFrf1W9Ss9KT+0rH90U12bZKk7uwG0= +github.com/mattn/go-colorable v0.1.13 h1:fFA4WZxdEF4tXPZVKMLwD8oUnCTTo08duU7wxecdEvA= +github.com/mattn/go-colorable v0.1.13/go.mod h1:7S9/ev0klgBDR4GtXTXX8a3vIGJpMovkB8vQcUbaXHg= +github.com/mattn/go-isatty v0.0.16/go.mod h1:kYGgaQfpe5nmfYZH+SKPsOc2e4SrIfOl2e/yFXSvRLM= +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/philhofer/fwd v1.1.3-0.20240916144458-20a13a1f6b7c h1:dAMKvw0MlJT1GshSTtih8C2gDs04w8dReiOGXrGLNoY= +github.com/philhofer/fwd v1.1.3-0.20240916144458-20a13a1f6b7c/go.mod h1:RqIHx9QI14HlwKwm98g9Re5prTQ6LdeRQn+gXJFxsJM= +github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM= +github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4= +github.com/stretchr/testify v1.10.0 h1:Xv5erBjTwe/5IxqUQTdXv5kgmIvbHo3QQyRwhJsOfJA= +github.com/stretchr/testify v1.10.0/go.mod h1:r2ic/lqez/lEtzL7wO/rwa5dbSLXVDPFyf8C91i36aY= +github.com/tinylib/msgp v1.2.5 h1:WeQg1whrXRFiZusidTQqzETkRpGjFjcIhW6uqWH09po= +github.com/tinylib/msgp v1.2.5/go.mod h1:ykjzy2wzgrlvpDCRc4LA8UXy6D8bzMSuAF3WD57Gok0= +github.com/valyala/bytebufferpool v1.0.0 h1:GqA5TC/0021Y/b9FG4Oi9Mr3q7XYx6KllzawFIhcdPw= +github.com/valyala/bytebufferpool v1.0.0/go.mod h1:6bBcMArwyJ5K/AmCkWv1jt77kVWyCJ6HpOuEn7z0Csc= +github.com/valyala/fasthttp v1.58.0 h1:GGB2dWxSbEprU9j0iMJHgdKYJVDyjrOwF9RE59PbRuE= +github.com/valyala/fasthttp v1.58.0/go.mod h1:SYXvHHaFp7QZHGKSHmoMipInhrI5StHrhDTYVEjK/Kw= +github.com/valyala/tcplisten v1.0.0 h1:rBHj/Xf+E1tRGZyWIWwJDiRY0zc1Js+CV5DqwacVSA8= +github.com/valyala/tcplisten v1.0.0/go.mod h1:T0xQ8SeCZGxckz9qRXTfG43PvQ/mcWh7FwZEA7Ioqkc= +github.com/x448/float16 v0.8.4 h1:qLwI1I70+NjRFUR3zs1JPUCgaCXSh3SW62uAKT1mSBM= +github.com/x448/float16 v0.8.4/go.mod h1:14CWIYCyZA/cWjXOioeEpHeN/83MdbZDRQHoFcYsOfg= +github.com/xyproto/randomstring v1.0.5 h1:YtlWPoRdgMu3NZtP45drfy1GKoojuR7hmRcnhZqKjWU= +github.com/xyproto/randomstring v1.0.5/go.mod h1:rgmS5DeNXLivK7YprL0pY+lTuhNQW3iGxZ18UQApw/E= +golang.org/x/crypto v0.31.0 h1:ihbySMvVjLAeSH1IbfcRTkD/iNscyz8rGzjF/E5hV6U= +golang.org/x/crypto v0.31.0/go.mod h1:kDsLvtWBEx7MV9tJOj9bnXsPbxwJQ6csT/x4KIN4Ssk= +golang.org/x/net v0.31.0 h1:68CPQngjLL0r2AlUKiSxtQFKvzRVbnzLwMUn5SzcLHo= +golang.org/x/net v0.31.0/go.mod h1:P4fl1q7dY2hnZFxEk4pPSkDHF+QqjitcnDjUQyMM+pM= +golang.org/x/sys v0.0.0-20220811171246-fbc7d0a398ab/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= +golang.org/x/sys v0.6.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= +golang.org/x/sys v0.28.0 h1:Fksou7UEQUWlKvIdsqzJmUmCX3cZuD2+P3XyyzwMhlA= +golang.org/x/sys v0.28.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA= +golang.org/x/text v0.21.0 h1:zyQAAkrwaneQ066sspRyJaG9VNi/YJ1NfzcGB3hZ/qo= +golang.org/x/text v0.21.0/go.mod h1:4IBbMaMmOPCJ8SecivzSH54+73PCFmPWxNTLm+vZkEQ= +gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA= +gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= diff --git a/pluginHost/main.go b/pluginHost/main.go new file mode 100644 index 0000000..c8ff541 --- /dev/null +++ b/pluginHost/main.go @@ -0,0 +1,145 @@ +package main + +import ( + "fmt" + "io" + "log" + "net" + "os" + "os/signal" + "path/filepath" + "plugin" + + "github.com/gofiber/fiber/v3" +) + +var pluginPath string +var socketPath string +var controlPath string + +func init() { + if len(os.Args) < 3 { + fmt.Fprintf(os.Stderr, "Usage: pluginHost ") + os.Exit(1) + } + + pluginPath = os.Args[1] + socketPath = os.Args[2] + if len(os.Args) > 3 { + controlPath = os.Args[3] + } +} + +type Plugin interface { + Init() (*fiber.Config, error) + RegisterRoutes(app fiber.Router) + // Name() string +} + +type PluginInstance struct { + Plugin Plugin + Name string + Path string + 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) + + go func() { + <-signalChan + // TODO: maybe do something graceful here + fmt.Println("Received SIGINT, shutting down...") + os.Exit(0) + }() + + var writer io.Writer + writer = os.Stderr + if controlPath != "" { + fmt.Printf("Waiting for control connection on %s\n", controlPath) + + controlListener, err := net.Listen("unix", controlPath) + if err != nil { + log.Fatalf("Error listening on control socket: %v", err) + } + defer controlListener.Close() + + conn, err := controlListener.Accept() + if err != nil { + log.Printf("Error accepting control connection: %v", err) + return + } + defer conn.Close() + + var ok bool + writer, ok = conn.(io.Writer) + if !ok { + log.Printf("Control connection is not a writer") + return + } + } + + if _, err := os.Stat(socketPath); err == nil { + fmt.Fprintf(writer, "Error: Socket %s already exists\n", socketPath) + os.Exit(1) + } + + realPluginPath, err := filepath.Abs(pluginPath) + if err != nil { + fmt.Fprintf(writer, "Error: could not get absolute plugin path: %v\n", err) + os.Exit(1) + } + + p, err := plugin.Open(realPluginPath) + if err != nil { + fmt.Fprintf(writer, "Error: could not open plugin %s: %v\n", realPluginPath, err) + os.Exit(1) + } + + symbol, err := p.Lookup("Plugin") + if err != nil { + fmt.Fprintf(writer, "Error: could not find 'Plugin' symbol in %s: %v\n", realPluginPath, err) + os.Exit(1) + } + + pluginLib, ok := symbol.(Plugin) + if !ok { + fmt.Fprintf(writer, "Error: symbol 'Plugin' in %s is not a Plugin interface\n", realPluginPath) + os.Exit(1) + } + + pluginConfig, err := pluginLib.Init() + if err != nil { + fmt.Fprintf(writer, "Error: error initializing plugin %s: %v\n", realPluginPath, err) + os.Exit(1) + } + + config := fiber.Config{} + if pluginConfig != nil { + config = *pluginConfig + } + router := fiber.New(config) + + pluginLib.RegisterRoutes(router) + + // listen for connections on the socket + listener, err := net.Listen("unix", socketPath) + if err != nil { + fmt.Fprintf(writer, "Error: error listening on socket %s: %v\n", socketPath, err) + os.Exit(1) + } + + fmt.Fprintf(writer, "ready\n") + + // technically this can still error + router.Listener(listener, fiber.ListenConfig{ + DisableStartupMessage: true, + }) +} diff --git a/schema.sql b/schema.sql index 3633ece..676422f 100644 --- a/schema.sql +++ b/schema.sql @@ -1,5 +1,6 @@ CREATE TABLE IF NOT EXISTS plugins ( id INTEGER PRIMARY KEY AUTOINCREMENT, path TEXT NOT NULL UNIQUE, + name TEXT NOT NULL UNIQUE, domains TEXT NOT NULL ); diff --git a/zqdgr.config.json b/zqdgr.config.json index c98af45..ba819ac 100644 --- a/zqdgr.config.json +++ b/zqdgr.config.json @@ -1,12 +1,16 @@ { - "name": "Go Project", + "name": "GLoom", "version": "0.0.1", - "description": "Example description", - "author": "you", - "license": "BSL-1.0", + "description": "GLoom is a plugin-based web app manager", + "author": "juls0730", + "license": "MIT", "scripts": { - "build": "sh -c \"cd gloomi; zqdgr build\" && go build", - "dev": "sh -c \"cd gloomi; zqdgr build\" && go run main.go" + "build": "zqdgr build:gloomi && zqdgr build:pluginHost && go build", + "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", + "clean": "rm -rf plugs && rm -rf host && rm -rf gloom.db && rm -rf plugin/plugin.so && rm -rf gloom", + "dev": "zqdgr build && ./gloom" }, "pattern": "**/*.go", "excluded_dirs": []