diff --git a/cmd/channel/email/main.go b/cmd/channel/email/main.go index ac6edd56b..878bb011b 100644 --- a/cmd/channel/email/main.go +++ b/cmd/channel/email/main.go @@ -80,7 +80,75 @@ func (ch *Email) SetConfig(jsonStr json.RawMessage) error { } func (ch *Email) GetInfo() *plugin.Info { - return &plugin.Info{Name: "Email"} + var elements []*plugin.FormElement + elements = append(elements, + &plugin.FormElement{ + Name: "host", + Type: "text", + Options: map[string]string{ + "label": "SMTP Host", + "placeholder": "localhost", + }, + }, + &plugin.FormElement{ + Name: "port", + Type: "select", + Options: map[string]string{ + "label": "SMTP Port", + "options": `{"25" : "25"," 465" :"465", "587" : "587", "2525" : "2525"}`, + }, + }, + &plugin.FormElement{ + Name: "from", + Type: "text", + Options: map[string]string{ + "auto-complete": "off", + "label": "From", + }, + }, + &plugin.FormElement{ + Name: "password", + Type: "password", + Options: map[string]string{ + "auto-complete": "off", + "label": "Password", + }, + }, + &plugin.FormElement{ + Name: "tls", + Type: "checkbox", + Options: map[string]string{ + "label": "TLS / SSL", + "class": "autosubmit", + "checkedValue": "1", + "uncheckedValue": "0", + "value": "1", + }, + }, + &plugin.FormElement{ + Name: "tls_certcheck", + Type: "checkbox", + Options: map[string]string{ + "label": "Certificate Check", + "class": "autosubmit", + "checkedValue": "1", + "uncheckedValue": "0", + "value": "0", + }, + }, + ) + + marshal, err := json.Marshal(elements) + if err != nil { + return nil + } + + return &plugin.Info{ + Name: "Email", + Version: "0.0", + AuthorName: "", + ConfigAttributes: marshal, + } } func (ch *Email) GetServer() string { diff --git a/cmd/channel/rocketchat/main.go b/cmd/channel/rocketchat/main.go index db919529b..3f79498ab 100644 --- a/cmd/channel/rocketchat/main.go +++ b/cmd/channel/rocketchat/main.go @@ -80,5 +80,45 @@ func (ch *RocketChat) SetConfig(jsonStr json.RawMessage) error { } func (ch *RocketChat) GetInfo() *plugin.Info { - return &plugin.Info{Name: "Rocket.Chat"} + var elements []*plugin.FormElement + elements = append(elements, + &plugin.FormElement{ + Name: "url", + Type: "text", + Options: map[string]string{ + "required": "true", + "label": "Rocket.Chat URL", + }, + }, + &plugin.FormElement{ + Name: "user_id", + Type: "text", + Options: map[string]string{ + "required": "true", + "auto-complete": "off", + "label": "User ID", + }, + }, + &plugin.FormElement{ + Name: "token", + Type: "password", + Options: map[string]string{ + "required": "true", + "auto-complete": "off", + "label": "Personal Access Token", + }, + }, + ) + + marshal, err := json.Marshal(elements) + if err != nil { + return nil + } + + return &plugin.Info{ + Name: "Rocket.Chat", + Version: "0.0", + AuthorName: "", + ConfigAttributes: marshal, + } } diff --git a/cmd/icinga-notifications-daemon/main.go b/cmd/icinga-notifications-daemon/main.go index 503550537..617cdae51 100644 --- a/cmd/icinga-notifications-daemon/main.go +++ b/cmd/icinga-notifications-daemon/main.go @@ -5,11 +5,15 @@ import ( "flag" "fmt" "github.com/icinga/icinga-notifications/internal" + "github.com/icinga/icinga-notifications/internal/channel" "github.com/icinga/icinga-notifications/internal/config" "github.com/icinga/icinga-notifications/internal/listener" + "github.com/icinga/icinga-notifications/pkg/plugin" + "github.com/icinga/icingadb/pkg/icingadb" "github.com/icinga/icingadb/pkg/logging" "github.com/icinga/icingadb/pkg/utils" "go.uber.org/zap" + "log" "os" "runtime" "time" @@ -73,6 +77,8 @@ func main() { } } + syncPlugins(conf.ChannelPluginDir, logs, db) + runtimeConfig := config.NewRuntimeConfig(db, logs) if err := runtimeConfig.UpdateFromDatabase(context.TODO()); err != nil { logger.Fatalw("failed to load config from database", zap.Error(err)) @@ -84,3 +90,46 @@ func main() { panic(err) } } + +func syncPlugins(channelPluginDir string, logs *logging.Logging, db *icingadb.DB) { + files, err := os.ReadDir(channelPluginDir) + if err != nil { + log.Fatal(err) + } + + var PluginInfos []*plugin.Info + + for _, file := range files { + p := channel.Plugin{ + Logger: logs.GetChildLogger("plugin initialise").With(zap.String("name", file.Name())), + } + err := p.StartPlugin(file.Name()) + if err != nil { + return + } + + info, err := p.GetInfo() + if err != nil { + return + } + p.ResetPlugin() + + PluginInfos = append(PluginInfos, info) + } + ctx := context.Background() + tx, err := db.BeginTxx(ctx, nil) + defer func() { _ = tx.Rollback() }() + if err != nil { + log.Fatal("failed to start channel plugin database transaction: %w", err) + } + + stmt, _ := db.BuildUpsertStmt(&plugin.Info{}) + println(stmt) + _, err = tx.NamedExecContext(ctx, stmt, PluginInfos) + if err != nil { + log.Fatal("failed to upsert channel plugin: %s", err) + } + if err = tx.Commit(); err != nil { + log.Fatal("can't commit channel plugin transaction: %w", err) + } +} diff --git a/internal/channel/channel.go b/internal/channel/channel.go index 9374bd4bc..002d64f81 100644 --- a/internal/channel/channel.go +++ b/internal/channel/channel.go @@ -8,7 +8,6 @@ import ( "io" "os/exec" "path/filepath" - "regexp" "sync" "time" ) @@ -20,26 +19,30 @@ type Channel struct { Config string `db:"config" json:"-"` // excluded from JSON config dump as this may contain sensitive information Logger *zap.SugaredLogger +} + +type Plugin struct { cmd *exec.Cmd rpc *rpc.RPC mu sync.Mutex + Logger *zap.SugaredLogger } -func (c *Channel) Start(pluginDir string) error { - c.mu.Lock() - defer c.mu.Unlock() +func (p *Plugin) Start(pluginPath string) error { + p.mu.Lock() + defer p.mu.Unlock() - if c.cmd != nil && c.rpc.Err() == nil { + if p.cmd != nil && p.rpc.Err() == nil { return nil } - if matched, _ := regexp.MatchString("^[a-zA-Z0-9]*$", c.Type); !matched { - return fmt.Errorf("channel type must only contain a-zA-Z0-9, %q given", c.Type) - } + /*if matched, _ := regexp.MatchString("^[a-zA-Z0-9]*$", p.Type); !matched { + return fmt.Errorf("channel type must only contain a-zA-Z0-9, %q given", p.Type) + }*/ - path := filepath.Join(pluginDir, c.Type) + pluginPath = filepath.Join("/usr/libexec/icinga-notifications/channel", pluginPath) - cmd := exec.Command(path) + cmd := exec.Command(pluginPath) writer, err := cmd.StdinPipe() if err != nil { @@ -56,44 +59,34 @@ func (c *Channel) Start(pluginDir string) error { return fmt.Errorf("failed to create stderr pipe: %w", err) } - go forwardLogs(errPipe, c.Logger) + go forwardLogs(errPipe, p.Logger) if err = cmd.Start(); err != nil { return fmt.Errorf("failed to start cmd: %w", err) } - c.Logger.Debug("Cmd started successfully") + p.Logger.Debug("Cmd started successfully") - c.cmd = cmd - c.rpc = rpc.NewRPC(writer, reader, c.Logger) - - if err = c.SetConfig(c.Config); err != nil { - go c.terminate(c.cmd, c.rpc) - - c.cmd = nil - c.rpc = nil - - return fmt.Errorf("failed to set config: %w", err) - } - c.Logger.Debugw("Successfully set config", zap.String("config", c.Config)) + p.cmd = cmd + p.rpc = rpc.NewRPC(writer, reader, p.Logger) return nil } -func (c *Channel) Stop() { - c.mu.Lock() - defer c.mu.Unlock() +func (p *Plugin) Stop() { + p.mu.Lock() + defer p.mu.Unlock() - if c.cmd == nil { - c.Logger.Debug("channel plugin has already been stopped") + if p.cmd == nil { + p.Logger.Debug("channel plugin has already been stopped") return } - go c.terminate(c.cmd, c.rpc) + go p.terminate(p.cmd, p.rpc) - c.cmd = nil - c.rpc = nil + p.cmd = nil + p.rpc = nil - c.Logger.Debug("Stopped channel plugin successfully") + p.Logger.Debug("Stopped channel plugin successfully") } func forwardLogs(errPipe io.Reader, logger *zap.SugaredLogger) { @@ -113,16 +106,16 @@ func forwardLogs(errPipe io.Reader, logger *zap.SugaredLogger) { } // run as go routine to terminate given channel -func (c *Channel) terminate(cmd *exec.Cmd, rpc *rpc.RPC) { - c.Logger.Debug("terminating channel plugin") +func (p *Plugin) terminate(cmd *exec.Cmd, rpc *rpc.RPC) { + p.Logger.Debug("terminating channel plugin") _ = rpc.Close() timer := time.AfterFunc(5*time.Second, func() { - c.Logger.Debug("killing the channel plugin") + p.Logger.Debug("killing the channel plugin") _ = cmd.Process.Kill() }) _ = cmd.Wait() timer.Stop() - c.Logger.Debug("Channel plugin terminated successfully") + p.Logger.Debug("Channel plugin terminated successfully") } diff --git a/internal/channel/plugin.go b/internal/channel/plugin.go index 8d510e980..d3b6ebd52 100644 --- a/internal/channel/plugin.go +++ b/internal/channel/plugin.go @@ -12,8 +12,8 @@ import ( "strings" ) -func (c *Channel) GetInfo() (*plugin.Info, error) { - result, err := c.rpcCall(plugin.MethodGetInfo, nil) +func (p *Plugin) GetInfo() (*plugin.Info, error) { + result, err := p.rpcCall(plugin.MethodGetInfo, nil) if err != nil { return nil, err } @@ -27,13 +27,13 @@ func (c *Channel) GetInfo() (*plugin.Info, error) { return info, nil } -func (c *Channel) SetConfig(config string) error { - _, err := c.rpcCall(plugin.MethodSetConfig, json.RawMessage(config)) +func (p *Plugin) SetConfig(config string) error { + _, err := p.rpcCall(plugin.MethodSetConfig, json.RawMessage(config)) return err } -func (c *Channel) SendNotification(contact *recipient.Contact, i contracts.Incident, ev *event.Event, icingaweb2Url string) error { +func (p *Plugin) SendNotification(contact *recipient.Contact, i contracts.Incident, ev *event.Event, icingaweb2Url string) error { contactStruct := &plugin.Contact{FullName: contact.FullName} for _, addr := range contact.Addresses { contactStruct.Addresses = append(contactStruct.Addresses, &plugin.Address{Type: addr.Type, Address: addr.Address}) @@ -69,17 +69,17 @@ func (c *Channel) SendNotification(contact *recipient.Contact, i contracts.Incid return fmt.Errorf("failed to prepare request params: %w", err) } - _, err = c.rpcCall(plugin.MethodSendNotification, params) + _, err = p.rpcCall(plugin.MethodSendNotification, params) return err } -func (c *Channel) rpcCall(method string, params json.RawMessage) (json.RawMessage, error) { - result, err := c.rpc.Call(method, params) +func (p *Plugin) rpcCall(method string, params json.RawMessage) (json.RawMessage, error) { + result, err := p.rpc.Call(method, params) var rpcErr *rpc.Error if errors.As(err, &rpcErr) { - c.Stop() + p.Stop() } return result, err diff --git a/internal/config/channel.go b/internal/config/channel.go index a1cfc83af..46242d0c0 100644 --- a/internal/config/channel.go +++ b/internal/config/channel.go @@ -68,7 +68,7 @@ func (r *RuntimeConfig) applyPendingChannels() { currentChannel.Config = pendingChannel.Config currentChannel.Logger.Info("Stopping the channel plugin because the config has been changed") - currentChannel.Stop() + //currentChannel.Stop() } } else { r.Channels[typ] = pendingChannel diff --git a/internal/incident/incident.go b/internal/incident/incident.go index c2153c309..bea20110a 100644 --- a/internal/incident/incident.go +++ b/internal/incident/incident.go @@ -4,6 +4,7 @@ import ( "context" "errors" "fmt" + "github.com/icinga/icinga-notifications/internal/channel" "github.com/icinga/icinga-notifications/internal/config" "github.com/icinga/icinga-notifications/internal/contracts" "github.com/icinga/icinga-notifications/internal/event" @@ -439,13 +440,20 @@ func (i *Incident) notifyContacts(ctx context.Context, tx *sqlx.Tx, ev *event.Ev continue } - err = ch.Start(i.configFile.ChannelPluginDir) + plugin := channel.Plugin{Logger: ch.Logger} + err = plugin.Start(chType) if err != nil { - i.logger.Errorw("Could not initialize channel", zap.String("type", chType), zap.Error(err)) + i.logger.Errorw("Could not initialize channel plugin", zap.String("type", chType), zap.Error(err)) continue } - err = ch.SendNotification(contact, i, ev, i.configFile.Icingaweb2URL) + if err = plugin.SetConfig(ch.Config); err != nil { + plugin.Stop() + return fmt.Errorf("failed to set config: %w", err) + } + plugin.Logger.Debugw("Successfully set config", zap.String("config", ch.Config)) + + err = plugin.SendNotification(contact, i, ev, i.configFile.Icingaweb2URL) if err != nil { i.logger.Errorw("Failed to send via channel", zap.String("type", chType), zap.Error(err)) continue diff --git a/pkg/plugin/plugin.go b/pkg/plugin/plugin.go index e5c1093eb..0d2c8d684 100644 --- a/pkg/plugin/plugin.go +++ b/pkg/plugin/plugin.go @@ -18,9 +18,31 @@ const ( MethodSendNotification = "SendNotification" ) +type FormElement struct { + Name string `json:"name"` + Type string `json:"type"` + Options map[string]string `json:"options"` +} + type Info struct { - Name string `json:"display_name"` - ConfigAttributes json.RawMessage `json:"config_attrs"` + Name string `db:"name"` + Version string `db:"version"` + AuthorName string `db:"author_name"` + ConfigAttributes json.RawMessage `db:"config_attrs"` +} + +// TableName implements the contracts.TableNamer interface. +func (c *Info) TableName() string { + return "plugin" +} + +// Upsert implements the contracts.Upserter interface. +func (c *Info) Upsert() interface{} { + return struct { + Version string `db:"version"` + AuthorName string `db:"author_name"` + ConfigAttributes json.RawMessage `db:"config_attrs"` + }{} } type Contact struct { diff --git a/schema/pgsql/upgrades/plugin_table.sql b/schema/pgsql/upgrades/plugin_table.sql new file mode 100644 index 000000000..977143863 --- /dev/null +++ b/schema/pgsql/upgrades/plugin_table.sql @@ -0,0 +1,8 @@ +CREATE TABLE plugin ( + name text NOT NULL, + version text NOT NULL, + author_name text NOT NULL, + config_attrs text NOT NULL, + + CONSTRAINT pk_plugin PRIMARY KEY (name) +);