diff --git a/example/plugins/helloworld/go.mod b/example/plugins/helloworld/go.mod new file mode 100644 index 0000000..acfde6b --- /dev/null +++ b/example/plugins/helloworld/go.mod @@ -0,0 +1,3 @@ +module example.com/zoraxy/helloworld + +go 1.23.6 diff --git a/example/plugins/helloworld/index.html b/example/plugins/helloworld/index.html new file mode 100644 index 0000000..3edafe1 --- /dev/null +++ b/example/plugins/helloworld/index.html @@ -0,0 +1,24 @@ + + + + + + Hello World + + + +
+

Hello World

+

Welcome to your first Zoraxy plugin

+
+ + \ No newline at end of file diff --git a/example/plugins/helloworld/main.go b/example/plugins/helloworld/main.go new file mode 100644 index 0000000..b05ab65 --- /dev/null +++ b/example/plugins/helloworld/main.go @@ -0,0 +1,49 @@ +package main + +import ( + _ "embed" + "fmt" + "net/http" + "strconv" + + plugin "example.com/zoraxy/helloworld/zoraxy_plugin" +) + +//go:embed index.html +var indexHTML string + +func helloWorldHandler(w http.ResponseWriter, r *http.Request) { + fmt.Fprint(w, indexHTML) +} + +func main() { + // Serve the plugin intro spect + // This will print the plugin intro spect and exit if the -introspect flag is provided + runtimeCfg, err := plugin.ServeAndRecvSpec(&plugin.IntroSpect{ + ID: "com.example.helloworld", + Name: "Hello World Plugin", + Author: "foobar", + AuthorContact: "admin@example.com", + Description: "A simple hello world plugin", + URL: "https://example.com", + Type: plugin.PluginType_Utilities, + VersionMajor: 1, + VersionMinor: 0, + VersionPatch: 0, + + // As this is a utility plugin, we don't need to capture any traffic + // but only serve the UI, so we set the UI (relative to the plugin path) to "/" + UIPath: "/", + }) + + if err != nil { + //Terminate or enter standalone mode here + panic(err) + } + + // Serve the hello world page + // This will serve the index.html file embedded in the binary + http.HandleFunc("/", helloWorldHandler) + fmt.Println("Server started at http://localhost:" + strconv.Itoa(runtimeCfg.Port)) + http.ListenAndServe(":"+strconv.Itoa(runtimeCfg.Port), nil) +} diff --git a/example/plugins/helloworld/zoraxy_plugin/zoraxy_plugin.go b/example/plugins/helloworld/zoraxy_plugin/zoraxy_plugin.go new file mode 100644 index 0000000..4778e4e --- /dev/null +++ b/example/plugins/helloworld/zoraxy_plugin/zoraxy_plugin.go @@ -0,0 +1,186 @@ +package zoraxy_plugin + +import ( + "encoding/json" + "fmt" + "os" + "strings" +) + +/* + Plugins Includes.go + + This file is copied from Zoraxy source code + You can always find the latest version under mod/plugins/includes.go + Usually this file are backward compatible +*/ + +type PluginType int + +const ( + PluginType_Router PluginType = 0 //Router Plugin, used for handling / routing / forwarding traffic + PluginType_Utilities PluginType = 1 //Utilities Plugin, used for utilities like Zerotier or Static Web Server that do not require interception with the dpcore +) + +type CaptureRule struct { + CapturePath string `json:"capture_path"` + IncludeSubPaths bool `json:"include_sub_paths"` +} + +type ControlStatusCode int + +const ( + ControlStatusCode_CAPTURED ControlStatusCode = 280 //Traffic captured by plugin, ask Zoraxy not to process the traffic + ControlStatusCode_UNHANDLED ControlStatusCode = 284 //Traffic not handled by plugin, ask Zoraxy to process the traffic + ControlStatusCode_ERROR ControlStatusCode = 580 //Error occurred while processing the traffic, ask Zoraxy to process the traffic and log the error +) + +type SubscriptionEvent struct { + EventName string `json:"event_name"` + EventSource string `json:"event_source"` + Payload string `json:"payload"` //Payload of the event, can be empty +} + +type RuntimeConstantValue struct { + ZoraxyVersion string `json:"zoraxy_version"` + ZoraxyUUID string `json:"zoraxy_uuid"` +} + +/* +IntroSpect Payload + +When the plugin is initialized with -introspect flag, +the plugin shell return this payload as JSON and exit +*/ +type IntroSpect struct { + /* Plugin metadata */ + ID string `json:"id"` //Unique ID of your plugin, recommended using your own domain in reverse like com.yourdomain.pluginname + Name string `json:"name"` //Name of your plugin + Author string `json:"author"` //Author name of your plugin + AuthorContact string `json:"author_contact"` //Author contact of your plugin, like email + Description string `json:"description"` //Description of your plugin + URL string `json:"url"` //URL of your plugin + Type PluginType `json:"type"` //Type of your plugin, Router(0) or Utilities(1) + VersionMajor int `json:"version_major"` //Major version of your plugin + VersionMinor int `json:"version_minor"` //Minor version of your plugin + VersionPatch int `json:"version_patch"` //Patch version of your plugin + + /* + + Endpoint Settings + + */ + + /* + Global Capture Settings + + Once plugin is enabled these rules always applies, no matter which HTTP Proxy rule it is enabled on + This captures the whole traffic of Zoraxy + + Notes: Will raise a warning on the UI when the user enables the plugin on a HTTP Proxy rule + */ + GlobalCapturePath []CaptureRule `json:"global_capture_path"` //Global traffic capture path of your plugin + GlobalCaptureIngress string `json:"global_capture_ingress"` //Global traffic capture ingress path of your plugin (e.g. /g_handler) + + /* + Always Capture Settings + + Once the plugin is enabled on a given HTTP Proxy rule, + these always applies + */ + AlwaysCapturePath []CaptureRule `json:"always_capture_path"` //Always capture path of your plugin when enabled on a HTTP Proxy rule (e.g. /myapp) + AlwaysCaptureIngress string `json:"always_capture_ingress"` //Always capture ingress path of your plugin when enabled on a HTTP Proxy rule (e.g. /a_handler) + + /* + Dynamic Capture Settings + + Once the plugin is enabled on a given HTTP Proxy rule, + the plugin can capture the request and decided if the request + shall be handled by itself or let it pass through + + */ + DynmaicCaptureIngress string `json:"capture_path"` //Traffic capture path of your plugin (e.g. /capture) + DynamicHandleIngress string `json:"handle_path"` //Traffic handle path of your plugin (e.g. /handler) + + /* UI Path for your plugin */ + UIPath string `json:"ui_path"` //UI path of your plugin (e.g. /ui), will proxy the whole subpath tree to Zoraxy Web UI as plugin UI + + /* Subscriptions Settings */ + SubscriptionPath string `json:"subscription_path"` //Subscription event path of your plugin (e.g. /notifyme), a POST request with SubscriptionEvent as body will be sent to this path when the event is triggered + SubscriptionsEvents map[string]string `json:"subscriptions_events"` //Subscriptions events of your plugin, see Zoraxy documentation for more details +} + +/* +ServeIntroSpect Function + +This function will check if the plugin is initialized with -introspect flag, +if so, it will print the intro spect and exit + +Place this function at the beginning of your plugin main function +*/ +func ServeIntroSpect(pluginSpect *IntroSpect) { + if len(os.Args) > 1 && os.Args[1] == "-introspect" { + //Print the intro spect and exit + jsonData, _ := json.MarshalIndent(pluginSpect, "", " ") + fmt.Println(string(jsonData)) + os.Exit(0) + } +} + +/* +ConfigureSpec Payload + +Zoraxy will start your plugin with -configure flag, +the plugin shell read this payload as JSON and configure itself +by the supplied values like starting a web server at given port +that listens to 127.0.0.1:port +*/ +type ConfigureSpec struct { + Port int `json:"port"` //Port to listen + RuntimeConst RuntimeConstantValue `json:"runtime_const"` //Runtime constant values + //To be expanded +} + +/* +RecvExecuteConfigureSpec Function + +This function will read the configure spec from Zoraxy +and return the ConfigureSpec object + +Place this function after ServeIntroSpect function in your plugin main function +*/ +func RecvConfigureSpec() (*ConfigureSpec, error) { + for i, arg := range os.Args { + if strings.HasPrefix(arg, "-configure=") { + var configSpec ConfigureSpec + if err := json.Unmarshal([]byte(arg[11:]), &configSpec); err != nil { + return nil, err + } + return &configSpec, nil + } else if arg == "-configure" { + var configSpec ConfigureSpec + var nextArg string + if len(os.Args) > i+1 { + nextArg = os.Args[i+1] + if err := json.Unmarshal([]byte(nextArg), &configSpec); err != nil { + return nil, err + } + } else { + return nil, fmt.Errorf("No port specified after -configure flag") + } + return &configSpec, nil + } + } + return nil, fmt.Errorf("No -configure flag found") +} + +/* +ServeAndRecvSpec Function + +This function will serve the intro spect and return the configure spec +See the ServeIntroSpect and RecvConfigureSpec for more details +*/ +func ServeAndRecvSpec(pluginSpect *IntroSpect) (*ConfigureSpec, error) { + ServeIntroSpect(pluginSpect) + return RecvConfigureSpec() +} diff --git a/src/def.go b/src/def.go index 23d2ae4..b03fd8d 100644 --- a/src/def.go +++ b/src/def.go @@ -30,6 +30,7 @@ import ( "imuslab.com/zoraxy/mod/mdns" "imuslab.com/zoraxy/mod/netstat" "imuslab.com/zoraxy/mod/pathrule" + "imuslab.com/zoraxy/mod/plugins" "imuslab.com/zoraxy/mod/sshprox" "imuslab.com/zoraxy/mod/statistic" "imuslab.com/zoraxy/mod/statistic/analytic" @@ -42,8 +43,8 @@ import ( const ( /* Build Constants */ SYSTEM_NAME = "Zoraxy" - SYSTEM_VERSION = "3.1.8" - DEVELOPMENT_BUILD = false /* Development: Set to false to use embedded web fs */ + SYSTEM_VERSION = "3.1.9" + DEVELOPMENT_BUILD = true /* Development: Set to false to use embedded web fs */ /* System Constants */ TMP_FOLDER = "./tmp" @@ -139,6 +140,7 @@ var ( staticWebServer *webserv.WebServer //Static web server for hosting simple stuffs forwardProxy *forwardproxy.Handler //HTTP Forward proxy, basically VPN for web browser loadBalancer *loadbalance.RouteManager //Global scope loadbalancer, store the state of the lb routing + pluginManager *plugins.Manager //Plugin manager for managing plugins //Authentication Provider autheliaRouter *authelia.AutheliaRouter //Authelia router for Authelia authentication diff --git a/src/mod/plugins/includes.go b/src/mod/plugins/includes.go index 38f4d8d..89fb5f9 100644 --- a/src/mod/plugins/includes.go +++ b/src/mod/plugins/includes.go @@ -1,5 +1,12 @@ package plugins +import ( + "encoding/json" + "fmt" + "os" + "strings" +) + /* Plugins Includes.go @@ -103,6 +110,23 @@ type IntroSpect struct { SubscriptionsEvents map[string]string `json:"subscriptions_events"` //Subscriptions events of your plugin, see Zoraxy documentation for more details } +/* +ServeIntroSpect Function + +This function will check if the plugin is initialized with -introspect flag, +if so, it will print the intro spect and exit + +Place this function at the beginning of your plugin main function +*/ +func ServeIntroSpect(pluginSpect *IntroSpect) { + if len(os.Args) > 1 && os.Args[1] == "-introspect" { + //Print the intro spect and exit + jsonData, _ := json.MarshalIndent(pluginSpect, "", " ") + fmt.Println(string(jsonData)) + os.Exit(0) + } +} + /* ConfigureSpec Payload @@ -116,3 +140,47 @@ type ConfigureSpec struct { RuntimeConst RuntimeConstantValue `json:"runtime_const"` //Runtime constant values //To be expanded } + +/* +RecvExecuteConfigureSpec Function + +This function will read the configure spec from Zoraxy +and return the ConfigureSpec object + +Place this function after ServeIntroSpect function in your plugin main function +*/ +func RecvConfigureSpec() (*ConfigureSpec, error) { + for i, arg := range os.Args { + if strings.HasPrefix(arg, "-configure=") { + var configSpec ConfigureSpec + if err := json.Unmarshal([]byte(arg[11:]), &configSpec); err != nil { + return nil, err + } + return &configSpec, nil + } else if arg == "-configure" { + var configSpec ConfigureSpec + var nextArg string + if len(os.Args) > i+1 { + nextArg = os.Args[i+1] + if err := json.Unmarshal([]byte(nextArg), &configSpec); err != nil { + return nil, err + } + } else { + return nil, fmt.Errorf("No port specified after -configure flag") + } + return &configSpec, nil + } + } + return nil, fmt.Errorf("No -configure flag found") +} + +/* +ServeAndRecvSpec Function + +This function will serve the intro spect and return the configure spec +See the ServeIntroSpect and RecvConfigureSpec for more details +*/ +func ServeAndRecvSpec(pluginSpect *IntroSpect) (*ConfigureSpec, error) { + ServeIntroSpect(pluginSpect) + return RecvConfigureSpec() +} diff --git a/src/mod/plugins/introspect.go b/src/mod/plugins/introspect.go index 0143434..4c40776 100644 --- a/src/mod/plugins/introspect.go +++ b/src/mod/plugins/introspect.go @@ -2,6 +2,7 @@ package plugins import ( "context" + "encoding/json" "fmt" "os/exec" "time" @@ -29,6 +30,11 @@ func (m *Manager) LoadPluginSpec(pluginPath string) (*Plugin, error) { return nil, err } + err = validatePluginSpec(pluginSpec) + if err != nil { + return nil, err + } + return &Plugin{ Spec: pluginSpec, Enabled: false, @@ -37,12 +43,12 @@ func (m *Manager) LoadPluginSpec(pluginPath string) (*Plugin, error) { // GetPluginEntryPoint returns the plugin entry point func (m *Manager) GetPluginSpec(entryPoint string) (*IntroSpect, error) { - pluginSpec := &IntroSpect{} + pluginSpec := IntroSpect{} ctx, cancel := context.WithTimeout(context.Background(), 10*time.Second) defer cancel() cmd := exec.CommandContext(ctx, entryPoint, "-introspect") - err := cmd.Run() + output, err := cmd.Output() if ctx.Err() == context.DeadlineExceeded { return nil, fmt.Errorf("plugin introspect timed out") } @@ -50,5 +56,11 @@ func (m *Manager) GetPluginSpec(entryPoint string) (*IntroSpect, error) { return nil, err } - return pluginSpec, nil + // Assuming the output is JSON and needs to be unmarshaled into pluginSpec + err = json.Unmarshal(output, &pluginSpec) + if err != nil { + return nil, fmt.Errorf("failed to unmarshal plugin spec: %v", err) + } + + return &pluginSpec, nil } diff --git a/src/mod/plugins/lifecycle.go b/src/mod/plugins/lifecycle.go index 4c41453..6b4ea75 100644 --- a/src/mod/plugins/lifecycle.go +++ b/src/mod/plugins/lifecycle.go @@ -3,8 +3,13 @@ package plugins import ( "encoding/json" "errors" + "io" + "os" "os/exec" "path/filepath" + "strconv" + "strings" + "time" ) func (m *Manager) StartPlugin(pluginID string) error { @@ -13,8 +18,10 @@ func (m *Manager) StartPlugin(pluginID string) error { return errors.New("plugin not found") } + thisPlugin := plugin.(*Plugin) + //Get the plugin Entry point - pluginEntryPoint, err := m.GetPluginEntryPoint(pluginID) + pluginEntryPoint, err := m.GetPluginEntryPoint(thisPlugin.RootDir) if err != nil { //Plugin removed after introspect? return err @@ -33,18 +40,85 @@ func (m *Manager) StartPlugin(pluginID string) error { } js, _ := json.Marshal(pluginConfiguration) + m.Log("Starting plugin "+thisPlugin.Spec.Name+" at :"+strconv.Itoa(pluginConfiguration.Port), nil) cmd := exec.Command(absolutePath, "-configure="+string(js)) cmd.Dir = filepath.Dir(absolutePath) + stdoutPipe, err := cmd.StdoutPipe() + if err != nil { + return err + } + if err := cmd.Start(); err != nil { return err } + go func() { + buf := make([]byte, 1) + lineBuf := "" + for { + n, err := stdoutPipe.Read(buf) + if n > 0 { + lineBuf += string(buf[:n]) + for { + if idx := strings.IndexByte(lineBuf, '\n'); idx != -1 { + m.handlePluginSTDOUT(pluginID, lineBuf[:idx]) + lineBuf = lineBuf[idx+1:] + } else { + break + } + } + } + if err != nil { + if err != io.EOF { + m.handlePluginSTDOUT(pluginID, lineBuf) // handle any remaining data + } + break + } + } + }() + // Store the cmd object so it can be accessed later for stopping the plugin plugin.(*Plugin).Process = cmd plugin.(*Plugin).Enabled = true return nil } +func (m *Manager) handlePluginSTDOUT(pluginID string, line string) { + thisPlugin, err := m.GetPluginByID(pluginID) + processID := -1 + if thisPlugin.Process != nil && thisPlugin.Process.Process != nil { + // Get the process ID of the plugin + processID = thisPlugin.Process.Process.Pid + } + if err != nil { + m.Log("[unknown:"+strconv.Itoa(processID)+"] "+line, err) + return + } + m.Log("["+thisPlugin.Spec.Name+":"+strconv.Itoa(processID)+"] "+line, nil) +} + +func (m *Manager) StopPlugin(pluginID string) error { + plugin, ok := m.LoadedPlugins.Load(pluginID) + if !ok { + return errors.New("plugin not found") + } + + thisPlugin := plugin.(*Plugin) + thisPlugin.Process.Process.Signal(os.Interrupt) + go func() { + //Wait for 10 seconds for the plugin to stop gracefully + time.Sleep(10 * time.Second) + if thisPlugin.Process.ProcessState == nil || !thisPlugin.Process.ProcessState.Exited() { + m.Log("Plugin "+thisPlugin.Spec.Name+" failed to stop gracefully, killing it", nil) + thisPlugin.Process.Process.Kill() + } else { + m.Log("Plugin "+thisPlugin.Spec.Name+" background process stopped", nil) + } + }() + plugin.(*Plugin).Enabled = false + return nil +} + // Check if the plugin is still running func (m *Manager) PluginStillRunning(pluginID string) bool { plugin, ok := m.LoadedPlugins.Load(pluginID) diff --git a/src/mod/plugins/plugins.go b/src/mod/plugins/plugins.go index 1a2bf0d..957eaa4 100644 --- a/src/mod/plugins/plugins.go +++ b/src/mod/plugins/plugins.go @@ -6,24 +6,24 @@ import ( "os/exec" "path/filepath" "sync" - "time" "imuslab.com/zoraxy/mod/database" "imuslab.com/zoraxy/mod/info/logger" + "imuslab.com/zoraxy/mod/utils" ) type Plugin struct { + RootDir string //The root directory of the plugin Spec *IntroSpect //The plugin specification Process *exec.Cmd //The process of the plugin Enabled bool //Whether the plugin is enabled } type ManagerOptions struct { - ZoraxyVersion string - PluginDir string - SystemConst *RuntimeConstantValue - Database database.Database - Logger *logger.Logger + PluginDir string + SystemConst *RuntimeConstantValue + Database *database.Database + Logger *logger.Logger } type Manager struct { @@ -31,15 +31,24 @@ type Manager struct { Options *ManagerOptions } +// NewPluginManager creates a new plugin manager func NewPluginManager(options *ManagerOptions) *Manager { + if options.PluginDir == "" { + options.PluginDir = "./plugins" + } + + if !utils.FileExists(options.PluginDir) { + os.MkdirAll(options.PluginDir, 0755) + } + return &Manager{ LoadedPlugins: sync.Map{}, Options: options, } } -// LoadPlugins loads all plugins from the plugin directory -func (m *Manager) LoadPlugins() error { +// LoadPluginsFromDisk loads all plugins from the plugin directory +func (m *Manager) LoadPluginsFromDisk() error { // Load all plugins from the plugin directory foldersInPluginDir, err := os.ReadDir(m.Options.PluginDir) if err != nil { @@ -54,9 +63,20 @@ func (m *Manager) LoadPlugins() error { m.Log("Failed to load plugin: "+filepath.Base(pluginPath), err) continue } + thisPlugin.RootDir = pluginPath m.LoadedPlugins.Store(thisPlugin.Spec.ID, thisPlugin) + m.Log("Loaded plugin: "+thisPlugin.Spec.Name, nil) + + //TODO: Move this to a separate function + // Enable the plugin if it is enabled in the database + err = m.StartPlugin(thisPlugin.Spec.ID) + if err != nil { + m.Log("Failed to enable plugin: "+thisPlugin.Spec.Name, err) + } + } } + return nil } @@ -71,34 +91,21 @@ func (m *Manager) GetPluginByID(pluginID string) (*Plugin, error) { // EnablePlugin enables a plugin func (m *Manager) EnablePlugin(pluginID string) error { - plugin, ok := m.LoadedPlugins.Load(pluginID) - if !ok { - return errors.New("plugin not found") + err := m.StartPlugin(pluginID) + if err != nil { + return err } - plugin.(*Plugin).Enabled = true + //TODO: Add database record return nil } // DisablePlugin disables a plugin func (m *Manager) DisablePlugin(pluginID string) error { - plugin, ok := m.LoadedPlugins.Load(pluginID) - if !ok { - return errors.New("plugin not found") + err := m.StopPlugin(pluginID) + //TODO: Add database record + if err != nil { + return err } - - thisPlugin := plugin.(*Plugin) - thisPlugin.Process.Process.Signal(os.Interrupt) - go func() { - //Wait for 10 seconds for the plugin to stop gracefully - time.Sleep(10 * time.Second) - if thisPlugin.Process.ProcessState == nil || !thisPlugin.Process.ProcessState.Exited() { - m.Log("Plugin "+thisPlugin.Spec.Name+" failed to stop gracefully, killing it", nil) - thisPlugin.Process.Process.Kill() - } else { - m.Log("Plugin "+thisPlugin.Spec.Name+" background process stopped", nil) - } - }() - thisPlugin.Enabled = false return nil } diff --git a/src/mod/plugins/utils.go b/src/mod/plugins/utils.go index a6671ea..aeb0e1f 100644 --- a/src/mod/plugins/utils.go +++ b/src/mod/plugins/utils.go @@ -60,3 +60,22 @@ func getRandomPortNumber() int { } return portNo } + +func validatePluginSpec(pluginSpec *IntroSpect) error { + if pluginSpec.Name == "" { + return errors.New("plugin name is empty") + } + if pluginSpec.Description == "" { + return errors.New("plugin description is empty") + } + if pluginSpec.Author == "" { + return errors.New("plugin author is empty") + } + if pluginSpec.UIPath == "" { + return errors.New("plugin UI path is empty") + } + if pluginSpec.ID == "" { + return errors.New("plugin ID is empty") + } + return nil +} diff --git a/src/start.go b/src/start.go index d237b8a..7a7171d 100644 --- a/src/start.go +++ b/src/start.go @@ -26,6 +26,7 @@ import ( "imuslab.com/zoraxy/mod/mdns" "imuslab.com/zoraxy/mod/netstat" "imuslab.com/zoraxy/mod/pathrule" + "imuslab.com/zoraxy/mod/plugins" "imuslab.com/zoraxy/mod/sshprox" "imuslab.com/zoraxy/mod/statistic" "imuslab.com/zoraxy/mod/statistic/analytic" @@ -317,6 +318,25 @@ func startupSequence() { log.Fatal(err) } + /* + Plugin Manager + */ + + pluginManager = plugins.NewPluginManager(&plugins.ManagerOptions{ + PluginDir: "./plugins", + SystemConst: &plugins.RuntimeConstantValue{ + ZoraxyVersion: SYSTEM_VERSION, + ZoraxyUUID: nodeUUID, + }, + Database: sysdb, + Logger: SystemWideLogger, + }) + + err = pluginManager.LoadPluginsFromDisk() + if err != nil { + SystemWideLogger.PrintAndLog("Plugin Manager", "Failed to load plugins", err) + } + /* Docker UX Optimizer */ if runtime.GOOS == "windows" && *runningInDocker { SystemWideLogger.PrintAndLog("warning", "Invalid start flag combination: docker=true && runtime.GOOS == windows. Running in docker UX development mode.", nil) diff --git a/src/web/components/plugins.html b/src/web/components/plugins.html new file mode 100644 index 0000000..15a6775 --- /dev/null +++ b/src/web/components/plugins.html @@ -0,0 +1,40 @@ +
+
+

Plugins Manager

+

Add custom features to Zoraxy

+
+ + + + + + + + + + + + + + + + + + + + +
Plugin NameDescriptionsCatergoryVersionAuthorAction
{{plugin.name}}{{plugin.description}}{{plugin.category}}{{plugin.version}}{{plugin.author}} +
+ +
+ +
+
+ + + + + diff --git a/src/web/index.html b/src/web/index.html index 2880c16..8527b00 100644 --- a/src/web/index.html +++ b/src/web/index.html @@ -78,6 +78,9 @@ SSO / Oauth + + Plugins Manager + Static Web Server @@ -138,7 +141,10 @@
- + +
+ +