diff --git a/.env.example b/.env.example deleted file mode 100644 index 6f1b23d..0000000 --- a/.env.example +++ /dev/null @@ -1,2 +0,0 @@ -PRELOAD_PLUGINS='[{"file": "gloomi.so", "domains": ["localhost"]}]' # make sure gloomi.so is in the $PLUGINS_DIR -PLUGINS_DIR=plugs diff --git a/.gitignore b/.gitignore index f063f1d..c026bbb 100644 --- a/.gitignore +++ b/.gitignore @@ -1,6 +1,4 @@ plugs -gloom -host +dist **/*.so -.env -gloom.db \ No newline at end of file +gloomd diff --git a/README.md b/README.md index 7c63ff2..c63a7d9 100644 --- a/README.md +++ b/README.md @@ -39,19 +39,33 @@ This project is primarily written for Linux, and has only been tested on Linux, 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. + This will give you the same standalone binary that you would get if you ran `zqdgr build`, but without the GLoom + management interface and with a blank config file. ## Configuring -GLoom is configured using environment variables. The following environment variables are supported: +GLoom is primarily configured through the config.toml file, but there are a few environment variables that can be used +to confgiure GLoom. 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. +- `GLOOM_DIR` - The directory where GLoom stores its data. plugins will be stored in this directory, the GLoom config + will be stored in this directory, and the pluginHost binary will be unpacked into this directory. the default value is + `$XDG_DATA_HOME/gloom` if it exists, otherwise `$HOME/.local/share/gloom`. + +GLoom will also use a config file in the `GLOOM_DIR` to configure plugins and other settings. The config file is a toml file, and the default config is: + +```toml +[[plugins]] +file = "gloomi.so" +domains = ["localhost"] +``` + +The `[[plugins]]` array is a list of plugins to preload. Each plugin is an object with the following fields: + +- `file` - The path to the plugin file. This is a string value. +- `domains` - An array of domains to forward requests to this plugin. This is an array of strings. -- `DEBUG` - Enables debug logging. This is a boolean value, so you can set it to any truthy value to enable debug logging. -- `PRELOAD_PLUGINS` - A json array of plugins to preload. The default value of this is `gloomi`, this is how GLoomI is loaded by default, and how replacement interfaces can be loaded. The format is in json, and the default value is: - ```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 diff --git a/embed_default.go b/embed_default.go new file mode 100644 index 0000000..811b60c --- /dev/null +++ b/embed_default.go @@ -0,0 +1,15 @@ +//go:build gloomi +// +build gloomi + +package main + +import "embed" + +//go:embed schema.sql dist/host dist/gloomi.so +var embeddedAssets embed.FS + +var defaultConfig = []byte(` +[[plugins]] +file = "gloomi.so" +domains = ["localhost"] +`) diff --git a/embed_nogloomi.go b/embed_nogloomi.go new file mode 100644 index 0000000..74be3ec --- /dev/null +++ b/embed_nogloomi.go @@ -0,0 +1,11 @@ +//go:build !gloomi +// +build !gloomi + +package main + +import "embed" + +//go:embed schema.sql dist/host +var embeddedAssets embed.FS + +var defaultConfig = []byte("# no plugins to preload in this build\n") diff --git a/gloomi/zqdgr.config.json b/gloomi/zqdgr.config.json index 1020d13..a6877cd 100644 --- a/gloomi/zqdgr.config.json +++ b/gloomi/zqdgr.config.json @@ -5,7 +5,7 @@ "author": "juls0730", "license": "MIT", "scripts": { - "build": "go build -buildmode=plugin -o ../plugs/gloomi.so main.go" + "build": "go build -ldflags '-w -s' -buildmode=plugin -o ../dist/gloomi.so main.go" }, "pattern": "**/*.go", "excluded_dirs": [] diff --git a/go.mod b/go.mod index 7fa2841..03b74e8 100644 --- a/go.mod +++ b/go.mod @@ -8,4 +8,7 @@ require ( github.com/mattn/go-sqlite3 v1.14.24 ) -require golang.org/x/sync v0.14.0 // indirect +require ( + github.com/BurntSushi/toml v1.5.0 + golang.org/x/sync v0.14.0 // indirect +) diff --git a/go.sum b/go.sum index 5b272cb..7f4890b 100644 --- a/go.sum +++ b/go.sum @@ -1,3 +1,5 @@ +github.com/BurntSushi/toml v1.5.0 h1:W5quZX/G/csjUnuI8SUYlsHs9M38FC7znL0lIO+DvMg= +github.com/BurntSushi/toml v1.5.0/go.mod h1:ukJfTF/6rtPPRCnwkur4qwRxa8vTRFBF0uk2lLoLwho= github.com/joho/godotenv v1.5.1 h1:7eLL/+HRGLY0ldzfGMeQkb7vMd0as4CfYvUVzLqw0N0= github.com/joho/godotenv v1.5.1/go.mod h1:f4LDr5Voq0i2e/R5DDNOoa2zzDfwtkZa6DnEwAbqwq4= github.com/juls0730/sentinel v0.0.0-20250515154110-2e7e6586cacd h1:JNazPdlAs307Gtaqmb+wfCjcOzO3MRXxg9nf0bAKAnc= diff --git a/main.go b/main.go index 5cd7993..56c813f 100644 --- a/main.go +++ b/main.go @@ -4,8 +4,6 @@ import ( "bufio" "context" "database/sql" - "embed" - "encoding/json" "fmt" "log/slog" "math/rand/v2" @@ -21,34 +19,39 @@ import ( "sync" "time" + "github.com/BurntSushi/toml" "github.com/joho/godotenv" "github.com/juls0730/gloom/libs" "github.com/juls0730/sentinel" _ "github.com/mattn/go-sqlite3" ) -//go:embed schema.sql host -var embeddedAssets embed.FS - type PluginHost struct { UnixSocket string Process *os.Process Domains []string } +type PluginConfig struct { + // TODO +} + type PreloadPlugin struct { File string `json:"file"` Domains []string `json:"domains"` } type GLoom struct { - // path to the pluginHost binary + // path to the /tmp directory where the pluginHost binary is unpacked, and where the pluginHost sockets are created tmpDir string + gloomDir string pluginDir string preloadPlugins []PreloadPlugin + // maps plugin names to plugins plugins libs.SyncMap[string, *PluginHost] + // maps domain names to whether or not they are currently being forwarded to hostMap libs.SyncMap[string, bool] DB *sql.DB @@ -56,23 +59,27 @@ type GLoom struct { } func NewGloom(proxyManager *sentinel.ProxyManager) (*GLoom, error) { - pluginsDir := os.Getenv("PLUGINS_DIR") - if pluginsDir == "" { - pluginsDir = "plugs" + gloomDir := os.Getenv("GLOOM_DIR") + if gloomDir == "" { + if os.Getenv("XDG_DATA_HOME") != "" { + gloomDir = filepath.Join(os.Getenv("XDG_DATA_HOME"), "gloom") + } else { + gloomDir = filepath.Join(os.Getenv("HOME"), ".local/share/gloom") + } } - pluginsDir, err := filepath.Abs(pluginsDir) + gloomDir, err := filepath.Abs(gloomDir) if err != nil { return nil, err } - if err := os.MkdirAll(pluginsDir, 0755); err != nil { + if err := os.MkdirAll(gloomDir, 0755); err != nil { if os.IsNotExist(err) { panic(err) } } - db, err := sql.Open("sqlite3", "gloom.db") + db, err := sql.Open("sqlite3", filepath.Join(gloomDir, "gloom.db")) if err != nil { return nil, err } @@ -87,48 +94,112 @@ func NewGloom(proxyManager *sentinel.ProxyManager) (*GLoom, error) { return nil, err } - pluginHost, err := embeddedAssets.ReadFile("host") + pluginHost, err := embeddedAssets.ReadFile("dist/host") if err != nil { return nil, err } + pluginDir := filepath.Join(gloomDir, "plugs") + if err := os.MkdirAll(pluginDir, 0755); err != nil { + return nil, err + } + + // if gloomi is built into the binary + if _, err := embeddedAssets.Open("dist/gloomi.so"); err == nil { + // and if the plugin doesn't exist, copy it over + if _, err := os.Stat(filepath.Join(pluginDir, "gloomi.so")); os.IsNotExist(err) { + gloomiData, err := embeddedAssets.ReadFile("dist/gloomi.so") + if err != nil { + return nil, err + } + + if err := os.WriteFile(filepath.Join(pluginDir, "gloomi.so"), gloomiData, 0755); 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") - var preloadPlugins []PreloadPlugin - preloadPluginsEnv, ok := os.LookupEnv("PRELOAD_PLUGINS") - if ok { - err = json.Unmarshal([]byte(preloadPluginsEnv), &preloadPlugins) - if err != nil { - panic(err) - } - } else { - preloadPlugins = []PreloadPlugin{ - { - File: "gloomi.so", - Domains: []string{"localhost"}, - }, + if _, err := os.Stat(filepath.Join(gloomDir, "pluginHost")); os.IsNotExist(err) { + if err := os.WriteFile(filepath.Join(gloomDir, "pluginHost"), pluginHost, 0755); err != nil { + return nil, err } + + slog.Debug("Wrote pluginHost", "dir", filepath.Join(gloomDir, "pluginHost")) } gloom := &GLoom{ - tmpDir: tmpDir, - pluginDir: pluginsDir, - preloadPlugins: preloadPlugins, - plugins: libs.SyncMap[string, *PluginHost]{}, - DB: db, - ProxyManager: proxyManager, + tmpDir: tmpDir, + gloomDir: gloomDir, + pluginDir: pluginDir, + plugins: libs.SyncMap[string, *PluginHost]{}, + DB: db, + ProxyManager: proxyManager, + } + + if err := gloom.loadConfig(); err != nil { + return nil, err } return gloom, nil } +func (gloom *GLoom) loadConfig() error { + configPath := filepath.Join(gloom.gloomDir, "config.toml") + if _, err := os.Stat(configPath); os.IsNotExist(err) { + // no config file, write default config + if err := os.WriteFile(configPath, defaultConfig, 0644); err != nil { + return nil + } + } + + configBytes, err := os.ReadFile(configPath) + if err != nil { + return err + } + + var config any + _, err = toml.Decode(string(configBytes), &config) + if err != nil { + return err + } + + proloadPlugins, ok := config.(map[string]any)["plugins"].([]map[string]any) + if ok { + for _, plugin := range proloadPlugins { + file, ok := plugin["file"].(string) + if !ok { + return fmt.Errorf("plugin file is not a string") + } + + _, ok = plugin["domains"].([]any) + if !ok { + return fmt.Errorf("plugin domains is not an array") + } + + domains := make([]string, 0, len(plugin["domains"].([]any))) + for _, domain := range plugin["domains"].([]any) { + if _, ok := domain.(string); !ok { + return fmt.Errorf("plugin domain is not a string") + } + + domains = append(domains, domain.(string)) + } + + gloom.preloadPlugins = append(gloom.preloadPlugins, PreloadPlugin{ + File: file, + Domains: domains, + }) + } + } + + return nil +} + func (gloom *GLoom) LoadInitialPlugins() error { slog.Info("Loading initial plugins") @@ -221,11 +292,10 @@ func (gloom *GLoom) RegisterPlugin(pluginPath string, name string, domains []str socketPath := path.Join(gloom.tmpDir, pathStr+".sock") controlPath := path.Join(gloom.tmpDir, pathStr+"-control.sock") - slog.Debug("Starting pluginHost", "pluginPath", pluginPath, "socketPath", socketPath) + slog.Debug("Starting pluginHost", "pluginPath", pluginPath, "socketPath", socketPath, "controlPath", controlPath) - processPath := path.Join(gloom.tmpDir, "pluginHost") + processPath := path.Join(gloom.gloomDir, "pluginHost") args := []string{pluginPath, socketPath, controlPath} - slog.Debug("Starting pluginHost", "args", args) cmd := exec.Command(processPath, args...) if err := cmd.Start(); err != nil { @@ -338,23 +408,36 @@ func (gloom *GLoom) RegisterPlugin(pluginPath string, name string, domains []str func (gloom *GLoom) DeletePlugin(pluginName string) error { slog.Debug("Deleting plugin", "pluginName", pluginName) - plug, ok := gloom.plugins.Load(pluginName) + pluginHost, ok := gloom.plugins.Load(pluginName) if !ok { return fmt.Errorf("plugin not found") } - for _, domain := range plug.Domains { + for _, domain := range pluginHost.Domains { gloom.ProxyManager.RemoveDeployment(domain) gloom.hostMap.Store(domain, false) } - plug.Process.Signal(os.Interrupt) - for _, domain := range plug.Domains { + pluginHost.Process.Signal(os.Interrupt) + for _, domain := range pluginHost.Domains { gloom.ProxyManager.RemoveDeployment(domain) } gloom.plugins.Delete(pluginName) + var pluginPath string + if err := gloom.DB.QueryRow("SELECT path FROM plugins WHERE name = ?", pluginName).Scan(&pluginPath); err != nil { + return fmt.Errorf("failed to get plugin path: %w", err) + } + + if _, err := gloom.DB.Exec("DELETE FROM plugins WHERE name = ?", pluginName); err != nil { + return fmt.Errorf("failed to delete plugin from database: %w", err) + } + + if err := os.Remove(pluginPath); err != nil { + return fmt.Errorf("failed to remove plugin: %w", err) + } + return nil } @@ -450,7 +533,7 @@ func (rpc *GloomRPC) UploadPlugin(plugin PluginUpload, reply *string) error { 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)) + pluginPath, err := filepath.Abs(filepath.Join(rpc.gloom.pluginDir, (plugin.Name + ".so"))) if err != nil { *reply = "Plugin upload failed" return err @@ -506,8 +589,8 @@ func (rpc *GloomRPC) UploadPlugin(plugin PluginUpload, reply *string) error { } for _, domain := range newDomains { - _, ok := rpc.gloom.hostMap.Load(domain) - if ok { + exists, _ := rpc.gloom.hostMap.Load(domain) + if exists { *reply = fmt.Sprintf("Domain %s already exists", domain) return nil } @@ -576,14 +659,11 @@ func (rpc *GloomRPC) DeletePlugin(pluginName string, reply *string) error { return nil } - _, err := rpc.gloom.DB.Exec("DELETE FROM plugins WHERE name = ?", pluginName) - if err != nil { - *reply = "Plugin not found" + if err := rpc.gloom.DeletePlugin(pluginName); err != nil { + *reply = fmt.Sprintf("Failed to delete plugin: %v", err) return err } - rpc.gloom.DeletePlugin(pluginName) - *reply = "Plugin deleted successfully" return nil } diff --git a/pluginHost/README.md b/pluginHost/README.md index f9d7baf..2106c47 100644 --- a/pluginHost/README.md +++ b/pluginHost/README.md @@ -23,4 +23,6 @@ To run the plugin host, run the following command: - `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 +- `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. This connection is destroyed when a message + is sent to the socket. \ No newline at end of file diff --git a/pluginHost/main.go b/pluginHost/main.go index b3a732b..aad7cc6 100644 --- a/pluginHost/main.go +++ b/pluginHost/main.go @@ -46,15 +46,35 @@ func main() { signalChan := make(chan os.Signal, 1) signal.Notify(signalChan, os.Interrupt) + var listener net.Listener + var router *fiber.App + go func() { <-signalChan - // TODO: maybe do something graceful here fmt.Println("Received SIGINT, shutting down...") + + if router != nil { + if err := router.Shutdown(); err != nil { + log.Printf("Error shutting down router: %v", err) + } + } + + if listener != nil { + if err := listener.Close(); err != nil { + log.Printf("Error closing listener: %v", err) + } + + os.Remove(socketPath) + } + os.Exit(0) }() - var writer io.Writer - writer = os.Stderr + var Print = func(format string, args ...any) { + fmt.Fprintf(os.Stderr, format, args...) + fmt.Fprintf(os.Stderr, "\n") + } + if controlPath != "" { fmt.Printf("Waiting for control connection on %s\n", controlPath) @@ -72,7 +92,15 @@ func main() { defer conn.Close() var ok bool + var writer io.Writer writer, ok = conn.(io.Writer) + // send the one message we can send and kill the connection + Print = func(format string, args ...any) { + fmt.Fprintf(writer, format, args...) + fmt.Fprintf(writer, "\n") + conn.Close() + os.Remove(controlPath) + } if !ok { log.Printf("Control connection is not a writer") return @@ -80,37 +108,37 @@ func main() { } if _, err := os.Stat(socketPath); err == nil { - fmt.Fprintf(writer, "Error: Socket %s already exists\n", socketPath) + Print("Error: Socket %s already exists", 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) + Print("Error: could not get absolute plugin path: %v", 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) + Print("Error: could not open plugin %s: %v", 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) + Print("Error: could not find 'Plugin' symbol in %s: %v", 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) + Print("Error: symbol 'Plugin' in %s is not a Plugin interface", realPluginPath) os.Exit(1) } pluginConfig, err := pluginLib.Init() if err != nil { - fmt.Fprintf(writer, "Error: error initializing plugin %s: %v\n", realPluginPath, err) + Print("Error: error initializing plugin %s: %v", realPluginPath, err) os.Exit(1) } @@ -118,21 +146,25 @@ func main() { if pluginConfig != nil { config = *pluginConfig } - router := fiber.New(config) + router = fiber.New(config) pluginLib.RegisterRoutes(router) // listen for connections on the socket - listener, err := net.Listen("unix", socketPath) + listener, err = net.Listen("unix", socketPath) if err != nil { - fmt.Fprintf(writer, "Error: error listening on socket %s: %v\n", socketPath, err) + Print("Error: error listening on socket %s: %v", socketPath, err) os.Exit(1) } - fmt.Fprintf(writer, "ready\n") - - // technically this can still error - router.Listener(listener, fiber.ListenConfig{ + if err := router.Listener(listener, fiber.ListenConfig{ DisableStartupMessage: true, - }) + BeforeServeFunc: func(app *fiber.App) error { + Print("ready") + return nil + }, + }); err != nil { + Print("Error: error starting server: %v", err) + os.Exit(1) + } } diff --git a/zqdgr.config.json b/zqdgr.config.json index 1c3cd70..d396889 100644 --- a/zqdgr.config.json +++ b/zqdgr.config.json @@ -6,11 +6,12 @@ "license": "MIT", "scripts": { "build": "zqdgr build:gloomi && zqdgr build:gloom", - "build:pluginHost": "sh -c \"cd pluginHost; go build -o ../host main.go\"", + "build:pluginHost": "sh -c \"cd pluginHost; go build -ldflags '-w -s' -o ../dist/host main.go\"", "build:gloomi": "sh -c \"cd gloomi; zqdgr 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" + "build:gloom": "zqdgr build:pluginHost && go build -tags=gloomi -o dist/gloom", + "build:nogloomi": "zqdgr build:pluginHost && go build -tags=!gloomi -o dist/gloom", + "clean": "rm -rf plugs && rm -rf dist && rm -rf plugin/plugin.so", + "dev": "zqdgr build && ./dist/gloom" }, "pattern": "**/*.go", "excluded_dirs": []