diff --git a/NOTICE.txt b/NOTICE.txt index 8677d9c8..242d8831 100644 --- a/NOTICE.txt +++ b/NOTICE.txt @@ -100,6 +100,7 @@ SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. https://golangcode.com/get-the-request-ip-addr/ +https://golangcode.com/validate-an-email-address/ https://github.com/eddturtle/golangcode-site/blob/master/LICENSE MIT License @@ -177,3 +178,22 @@ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. + + + + +https://github.com/Showmax/go-fqdn/blob/master/LICENSE + +Copyright since 2015 Showmax s.r.o. + +Licensed under the Apache License, Version 2.0 (the "License"); +you may not use this file except in compliance with the License. +You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + +Unless required by applicable law or agreed to in writing, software +distributed under the License is distributed on an "AS IS" BASIS, +WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +See the License for the specific language governing permissions and +limitations under the License. diff --git a/README.md b/README.md index d47b8214..af04e3c9 100644 --- a/README.md +++ b/README.md @@ -107,7 +107,9 @@ See also: - levels, format and output (see [configuration settings doc](docs/configure.md)) -- Microsoft Teams notifications +- Optional notifications + - Microsoft Teams + - Email - generated for multiple events - alert received - disabled user @@ -153,7 +155,6 @@ Known issues: - Documentation - The docs are beginning to take overall shape, but still need a lot of work -- Email notifications are not currently supported (see GH-3) - Payloads are accepted from any IP Address - the expectation is that host-level firewall rules will be used to protect against this until a feature can be added to filter access (see GH-18) diff --git a/cmd/brick/message.go b/cmd/brick/message.go index 8c880e80..e53e1db5 100644 --- a/cmd/brick/message.go +++ b/cmd/brick/message.go @@ -17,9 +17,14 @@ package main import ( + "bytes" "context" "fmt" + "net/http" + "net/smtp" "strconv" + "strings" + "text/template" "time" "github.com/apex/log" @@ -125,42 +130,44 @@ func getTerminationResultsList(sTermResults []ezproxy.TerminateUserSessionResult return sessionResultsStringSets } -// getMsgCardMainSectionText evaluates the provided event Record and builds a -// primary message suitable for display as the main notification Text field. -// This message is generated first from the Note field if available, or from -// the Error field. This precedence allows for using a provided Note as a brief -// summary while still using the Error field in a dedicated section. -func getMsgCardMainSectionText(record events.Record) string { +// getMsgSummaryText evaluates the provided event Record and builds a primary +// message suitable for display as the main or summary notification text. This +// message is generated first from the Note field if available, or from the +// Error field. This precedence allows for using a provided Note as a brief +// summary while still using the Error field in a dedicated section of the +// notification. +func getMsgSummaryText(record events.Record) string { - // This part of the message card is valuable "real estate" for eyeballs; - // we should ensure we are communicating what just occurred instead - // of using a mostly static block of text. + // This part of the message is valuable "real estate" for eyeballs; we + // should ensure we are communicating what just occurred instead of using + // a mostly static block of text. - var msgCardTextField string + var msgSummaryText string switch { case record.Note != "": - msgCardTextField = "Summary: " + record.Note + msgSummaryText = "Summary: " + record.Note case record.Error != nil: - msgCardTextField = "Error: " + record.Error.Error() + msgSummaryText = "Error: " + record.Error.Error() // Attempting to use an empty string for the top-level message card Text // field results in a notification failure, so set *something* to meet - // those requirements. + // those requirements. This "guard rail" is also useful for ensuring that + // email notifications are similarly provided a fallback message. default: - msgCardTextField = "FIXME: Missing Note for this event record!" + msgSummaryText = "FIXME: Missing Note for this event record!" } - return msgCardTextField + return msgSummaryText } -// getMsgCardTitle is a helper function used to generate the title for message -// cards. This function uses the provided prefix and event Record to generate -// stable titles reflecting the step in the disable user process at which the -// notification was generated; the intent is to quickly tell where the process -// halted for troubleshooting purposes. -func getMsgCardTitle(msgCardTitlePrefix string, record events.Record) string { +// getMsgTitle is a helper function used to generate the title for outgoing +// notifications. This function uses the provided prefix and event Record to +// generate stable titles reflecting the step in the disable user process at +// which the notification was generated; the intent is to quickly tell where +// the process halted for troubleshooting purposes. +func getMsgTitle(msgTitlePrefix string, record events.Record) string { var msgCardTitle string @@ -172,49 +179,51 @@ func getMsgCardTitle(msgCardTitlePrefix string, record events.Record) string { // TODO: Calculate step labeling based off of enabled features (see GH-65). case events.ActionSuccessDisableRequestReceived, events.ActionFailureDisableRequestReceived: - msgCardTitle = msgCardTitlePrefix + "[step 1 of 3] " + record.Action + msgCardTitle = msgTitlePrefix + "[step 1 of 3] " + record.Action case events.ActionSuccessDisabledUsername, events.ActionFailureDisabledUsername: - msgCardTitle = msgCardTitlePrefix + "[step 2 of 3] " + record.Action + msgCardTitle = msgTitlePrefix + "[step 2 of 3] " + record.Action case events.ActionSuccessDuplicatedUsername, events.ActionFailureDuplicatedUsername: - msgCardTitle = msgCardTitlePrefix + "[step 2 of 3] " + record.Action + msgCardTitle = msgTitlePrefix + "[step 2 of 3] " + record.Action case events.ActionSuccessIgnoredUsername, events.ActionFailureIgnoredUsername: - msgCardTitle = msgCardTitlePrefix + "[step 2 of 3] " + record.Action + msgCardTitle = msgTitlePrefix + "[step 2 of 3] " + record.Action case events.ActionSuccessIgnoredIPAddress, events.ActionFailureIgnoredIPAddress: - msgCardTitle = msgCardTitlePrefix + "[step 2 of 3] " + record.Action + msgCardTitle = msgTitlePrefix + "[step 2 of 3] " + record.Action case events.ActionSuccessTerminatedUserSession, events.ActionFailureUserSessionLookupFailure, events.ActionFailureTerminatedUserSession, events.ActionSkippedTerminateUserSessions: - msgCardTitle = msgCardTitlePrefix + "[step 3 of 3] " + record.Action + msgCardTitle = msgTitlePrefix + "[step 3 of 3] " + record.Action default: - msgCardTitle = msgCardTitlePrefix + " [UNKNOWN] " + record.Action + msgCardTitle = msgTitlePrefix + " [UNKNOWN] " + record.Action log.Warnf("UNKNOWN record: %v+\n", record) } return msgCardTitle } -// createMessage receives an event Record and generates a MessageCard which is -// used to generate a Microsoft Teams message. -func createMessage(record events.Record) goteamsnotify.MessageCard { +// createTeamsMessage receives an event Record and generates a MessageCard +// which is used to generate a Microsoft Teams message. +func createTeamsMessage(record events.Record) goteamsnotify.MessageCard { - log.Debugf("createMessage: alert received: %#v", record) + myFuncName := caller.GetFuncName() + + log.Debugf("%s: alert received: %#v", myFuncName, record) // build MessageCard for submission msgCard := goteamsnotify.NewMessageCard() msgCardTitlePrefix := config.MyAppName + ": " - msgCard.Title = getMsgCardTitle(msgCardTitlePrefix, record) + msgCard.Title = getMsgTitle(msgCardTitlePrefix, record) // msgCard.Text = record.Note - msgCard.Text = getMsgCardMainSectionText(record) + msgCard.Text = getMsgSummaryText(record) /* Errors Section @@ -233,7 +242,7 @@ func createMessage(record events.Record) goteamsnotify.MessageCard { if err := msgCard.AddSection(disableUserRequestErrors); err != nil { errMsg := fmt.Sprintf("Error returned from attempt to add disableUserRequestErrors: %v", err) - log.Error("createMessage: " + errMsg) + log.Errorf("%s: %v", myFuncName, errMsg) msgCard.Text = msgCard.Text + "\n\n" + send2teams.TryToFormatAsCodeSnippet(errMsg) } @@ -248,7 +257,7 @@ func createMessage(record events.Record) goteamsnotify.MessageCard { if err := msgCard.AddSection(sessionTerminationResultsSection); err != nil { errMsg := fmt.Sprintf("Error returned from attempt to add sessionTerminationResultsSection: %v", err) - log.Error("createMessage: " + errMsg) + log.Errorf("%s: %v", myFuncName, errMsg) msgCard.Text = msgCard.Text + "\n\n" + send2teams.TryToFormatAsCodeSnippet(errMsg) } @@ -269,7 +278,7 @@ func createMessage(record events.Record) goteamsnotify.MessageCard { if err := msgCard.AddSection(disableUserRequestDetailsSection); err != nil { errMsg := fmt.Sprintf("Error returned from attempt to add disableUserRequestDetailsSection: %v", err) - log.Error("createMessage: " + errMsg) + log.Errorf("%s: %v", myFuncName, errMsg) msgCard.Text = msgCard.Text + "\n\n" + send2teams.TryToFormatAsCodeSnippet(errMsg) } @@ -288,7 +297,7 @@ func createMessage(record events.Record) goteamsnotify.MessageCard { if err := msgCard.AddSection(alertRequestSummarySection); err != nil { errMsg := fmt.Sprintf("Error returned from attempt to add alertRequestSummarySection: %v", err) - log.Error("createMessage: " + errMsg) + log.Errorf("%s: %v", myFuncName, errMsg) msgCard.Text = msgCard.Text + "\n\n" + send2teams.TryToFormatAsCodeSnippet(errMsg) } @@ -307,18 +316,33 @@ func createMessage(record events.Record) goteamsnotify.MessageCard { // process alert request headers - for header, values := range record.Alert.Headers { - for index, value := range values { + // Create a copy of the original so that we don't modify the original + // alert headers; other notifications (e.g., email) will need a fresh copy + // of those values so that any formatting applied here doesn't "spill + // over" to those notifications. + requestHeadersCopy := make(http.Header) + for key, value := range record.Alert.Headers { + requestHeadersCopy[key] = value + } + + for header, values := range requestHeadersCopy { + + // As with the enclosing map, we create a copy here so that we don't + // modify the original (which is used also by email notifications). + headerValuesCopy := make([]string, len(values)) + copy(headerValuesCopy, values) + + for index, value := range headerValuesCopy { // update value with code snippet formatting, assign back using // the available index value - values[index] = send2teams.TryToFormatAsCodeSnippet(value) + headerValuesCopy[index] = send2teams.TryToFormatAsCodeSnippet(value) } - addFactPair(&msgCard, alertRequestHeadersSection, header, values...) + addFactPair(&msgCard, alertRequestHeadersSection, header, headerValuesCopy...) } if err := msgCard.AddSection(alertRequestHeadersSection); err != nil { errMsg := fmt.Sprintf("Error returned from attempt to add alertRequestHeadersSection: %v", err) - log.Error("createMessage: " + errMsg) + log.Errorf("%s: %v", myFuncName, errMsg) msgCard.Text = msgCard.Text + "\n\n" + send2teams.TryToFormatAsCodeSnippet(errMsg) } @@ -328,18 +352,20 @@ func createMessage(record events.Record) goteamsnotify.MessageCard { trailerSection := goteamsnotify.NewMessageCardSection() trailerSection.StartGroup = true - trailerSection.Text = send2teams.ConvertEOLToBreak(config.MessageTrailer()) + trailerSection.Text = send2teams.ConvertEOLToBreak(config.MessageTrailer(config.BrandingMarkdownFormat)) if err := msgCard.AddSection(trailerSection); err != nil { errMsg := fmt.Sprintf("Error returned from attempt to add trailerSection: %v", err) - log.Error("createMessage: " + errMsg) + log.Errorf("%s: %v", myFuncName, errMsg) msgCard.Text = msgCard.Text + "\n\n" + send2teams.TryToFormatAsCodeSnippet(errMsg) } return msgCard } -// define function/wrapper for sending details to Microsoft Teams -func sendMessage( +// sendTeamsMessage wraps and orchestrates an external library function call +// to send Microsoft Teams messages. This includes honoring the provided +// schedule in order to comply with remote API rate limits. +func sendTeamsMessage( ctx context.Context, webhookURL string, msgCard goteamsnotify.MessageCard, @@ -348,18 +374,23 @@ func sendMessage( retriesDelay int, ) NotifyResult { + myFuncName := caller.GetFuncName() + // Note: We already do validation elsewhere, and the library call does // even more validation, but we can handle this obvious empty argument // problem directly if webhookURL == "" { return NotifyResult{ - Err: fmt.Errorf("sendMessage: webhookURL not defined, skipping message submission to Microsoft Teams channel"), + Err: fmt.Errorf( + "%s: webhookURL not defined, skipping message submission to Microsoft Teams channel", + myFuncName, + ), Success: false, } } - log.Debugf("sendMessage: Time now is %v", time.Now().Format("15:04:05")) - log.Debugf("sendMessage: Notification scheduled for: %v", schedule.Format("15:04:05")) + log.Debugf("%s: Time now is %v", myFuncName, time.Now().Format("15:04:05")) + log.Debugf("%s: Notification scheduled for: %v", myFuncName, schedule.Format("15:04:05")) // Set delay timer to meet received notification schedule. This helps // ensure that we delay the appropriate amount of time before we make our @@ -368,18 +399,23 @@ func sendMessage( notificationDelayTimer := time.NewTimer(notificationDelay) defer notificationDelayTimer.Stop() - log.Debugf("sendMessage: notificationDelayTimer created at %v with duration %v", + log.Debugf("%s: notificationDelayTimer created at %v with duration %v", + myFuncName, time.Now().Format("15:04:05"), notificationDelay, ) - log.Debug("sendMessage: Waiting for either context or notificationDelayTimer to expire before sending notification") + log.Debugf( + "%s: Waiting for either context or notificationDelayTimer to expire before sending notification", + myFuncName, + ) select { case <-ctx.Done(): ctxErr := ctx.Err() msg := NotifyResult{ - Val: fmt.Sprintf("sendMessage: Received Done signal at %v: %v, shutting down", + Val: fmt.Sprintf("%s: Received Done signal at %v: %v, shutting down", + myFuncName, time.Now().Format("15:04:05"), ctxErr.Error(), ), @@ -392,21 +428,27 @@ func sendMessage( // delay, regardless of whether the attempt is the first one or not case <-notificationDelayTimer.C: - log.Debugf("sendMessage: Waited %v before notification attempt at %v", + log.Debugf("%s: Waited %v before notification attempt at %v", + myFuncName, notificationDelay, time.Now().Format("15:04:05"), ) ctxExpires, ctxExpired := ctx.Deadline() if ctxExpired { - log.Debugf("sendMessage: WaitTimeout context expires at: %v", ctxExpires.Format("15:04:05")) + log.Debugf( + "%s: WaitTimeout context expires at: %v", + myFuncName, + ctxExpires.Format("15:04:05"), + ) } // check to see if context has expired during our delay if ctx.Err() != nil { msg := NotifyResult{ Val: fmt.Sprintf( - "sendMessage: context expired or cancelled at %v: %v, attempting to abort message submission", + "%s: context expired or cancelled at %v: %v, attempting to abort message submission", + myFuncName, time.Now().Format("15:04:05"), ctx.Err().Error(), ), @@ -423,7 +465,8 @@ func sendMessage( if err := send2teams.SendMessage(ctx, webhookURL, msgCard, retries, retriesDelay); err != nil { errMsg := NotifyResult{ Err: fmt.Errorf( - "sendMessage: ERROR: Failed to submit message to Microsoft Teams at %v: %v", + "%s: ERROR: Failed to submit message to Microsoft Teams at %v: %v", + myFuncName, time.Now().Format("15:04:05"), err, ), @@ -435,7 +478,456 @@ func sendMessage( successMsg := NotifyResult{ Val: fmt.Sprintf( - "sendMessage: Message successfully sent to Microsoft Teams at %v", + "%s: Message successfully sent to Microsoft Teams at %v", + myFuncName, + time.Now().Format("15:04:05"), + ), + Success: true, + } + + // Note success for potential troubleshooting + log.Debug(successMsg.Val) + + return successMsg + + } + +} + +// emailConfig represents user-provided settings specific to sending email +// notifications. This is mostly a helper for passing email settings "down" +// until sufficient work on GH-22 can be applied. +type emailConfig struct { + server string + serverPort int + senderAddress string + recipientAddresses []string + clientIdentity string + timeout time.Duration + notificationRateLimit time.Duration + notificationRetries int + notificationRetryDelay int + template *template.Template +} + +// createEmailMessage receives an event record and generates a formatted email. +func createEmailMessage(record events.Record, emailCfg emailConfig) string { + + myFuncName := caller.GetFuncName() + + emailSubjectPrefix := config.MyAppName + ": " + + // reuse existing Teams-specific getMsgTitle() func + emailSubject := getMsgTitle(emailSubjectPrefix, record) + + // main message section + emailIntroText := getMsgSummaryText(record) + + data := struct { + Record events.Record + EmailSubject string + EmailIntroText string + Branding string + }{ + Record: record, + EmailSubject: emailSubject, + EmailIntroText: emailIntroText, + Branding: config.MessageTrailer(config.BrandingTextileFormat), + } + + var renderedTmpl bytes.Buffer + var emailBody string + + tmplErr := emailCfg.template.Execute(&renderedTmpl, data) + switch { + case tmplErr != nil: + errMsg := fmt.Sprintf( + "Error returned from attempt to parse email template: %v", + tmplErr, + ) + log.Errorf("%s: %v", myFuncName, errMsg) + + emailBody = errMsg + default: + emailBody = renderedTmpl.String() + } + + //email := fmt.Sprintf("To: recipient@example.net\r\n" + + email := fmt.Sprintf( + "To: %s\r\n"+ + "From: %s\r\n"+ + "Subject: %s\r\n"+ + "\r\n"+ + "%s\r\n", + strings.Join(emailCfg.recipientAddresses, ", "), + emailCfg.senderAddress, + emailSubject, + emailBody, + ) + + return email + +} + +// sendEmail is an analogue of the abstraction/functionality provided by +// send2teams.SendMessage(...). The plan is to refactor this function as part +// of the work for GH-22. +func sendEmail( + ctx context.Context, + emailCfg emailConfig, + emailMsg string, +) error { + + myFuncName := caller.GetFuncName() + + smtpServer := fmt.Sprintf("%s:%d", emailCfg.server, emailCfg.serverPort) + + // FIXME: This function both logs *and* returns the error, which is + // duplication that will require fixing at some point. Leaving both in for + // the time being until this code proves stable. + send := func(ctx context.Context, emailCfg emailConfig, emailMsg string, myFuncName string) error { + + // Connect to the remote SMTP server. + c, dialErr := smtp.Dial(smtpServer) + if dialErr != nil { + errMsg := fmt.Errorf( + "%s: failed to connect to SMTP server %q on port %v: %w", + myFuncName, + emailCfg.server, + emailCfg.serverPort, + dialErr, + ) + log.Error(errMsg.Error()) + + return errMsg + } + + // At this point we have a Client, so we need to ensure that the QUIT + // command is sent to the SMTP server to clean up; close connection and + // send the QUIT command. + defer func() { + if err := c.Quit(); err != nil { + + fmt.Printf("Error type: %+v", err) + + errMsg := fmt.Errorf( + "%s: failure occurred sending QUIT command to %q on port %v: %w", + myFuncName, + emailCfg.server, + emailCfg.serverPort, + err, + ) + log.Error(errMsg.Error()) + } + + log.Debugf( + "%s: Successfully sent QUIT command to %q on port %v", + myFuncName, + emailCfg.server, + emailCfg.serverPort, + ) + }() + + // Use user-specified (or default) client identity in our greeting to the + // SMTP server. + log.Debugf( + "%s: Sending greeting to SMTP server %q on port %v with identity of %q", + myFuncName, + emailCfg.server, + emailCfg.serverPort, + emailCfg.clientIdentity, + ) + if err := c.Hello(emailCfg.clientIdentity); err != nil { + + errMsg := fmt.Errorf( + "%s: failure occurred sending greeting to SMTP server %q on port %v: %w", + myFuncName, + emailCfg.server, + emailCfg.serverPort, + err, + ) + log.Error(errMsg.Error()) + + return errMsg + } + + // Set the sender + if err := c.Mail(emailCfg.senderAddress); err != nil { + errMsg := fmt.Errorf( + "%s: failed to set sender address %q for email: %w", + myFuncName, + emailCfg.senderAddress, + err, + ) + log.Error(errMsg.Error()) + + return errMsg + } + + // Process one or more user-provided destination email addresses + for _, emailAddr := range emailCfg.recipientAddresses { + if err := c.Rcpt(emailAddr); err != nil { + errMsg := fmt.Errorf( + "%s: failed to set recipient address %q: %w", + myFuncName, + emailAddr, + err, + ) + log.Error(errMsg.Error()) + + return errMsg + } + } + + // Send the email body. + // + // Data issues a DATA command to the server and returns a writer that can + // be used to write the mail headers and body. The caller should close the + // writer before calling any more methods on c. A call to Data must be + // preceded by one or more calls to Rcpt. + wc, dataErr := c.Data() + if dataErr != nil { + errMsg := fmt.Errorf( + "%s: failure occurred when sending DATA command to SMTP server %q on port %v: %w", + myFuncName, + emailCfg.server, + emailCfg.serverPort, + dataErr, + ) + log.Error(errMsg.Error()) + + return dataErr + } + + defer func() { + + if err := wc.Close(); err != nil { + + fmt.Printf("Error type: %+v", err) + + errMsg := fmt.Errorf( + "%s: failure occurred closing mail headers and body writer: %w", + myFuncName, + err, + ) + log.Error(errMsg.Error()) + } + + log.Debugf( + "%s: Successfully closed mail headers and body writer", + myFuncName, + ) + }() + + if _, err := fmt.Fprint(wc, emailMsg); err != nil { + errMsg := fmt.Errorf( + "%s: failure occurred when writing message to connection for SMTP server %q on port %v: %w", + myFuncName, + emailCfg.server, + emailCfg.serverPort, + dataErr, + ) + log.Error(errMsg.Error()) + + return dataErr + + } + + return nil + } + + var result error + + // initial attempt + number of specified retries + attemptsAllowed := 1 + emailCfg.notificationRetries + + // attempt to send message to Microsoft Teams, retry specified number of + // times before giving up + for attempt := 1; attempt <= attemptsAllowed; attempt++ { + + // Check here at the start of the loop iteration (either first or + // subsequent) in order to return early in an effort to prevent + // undesired message attempts after the context has been cancelled. + if ctx.Err() != nil { + errMsg := fmt.Errorf( + "%s: context cancelled or expired: %v; aborting message submission after %d of %d attempts", + myFuncName, + ctx.Err().Error(), + attempt, + attemptsAllowed, + ) + + // If this is set, we're looking at the second (incomplete) + // iteration. Let's combine our error above with the last result + // in an effort to provide a more meaningful error. + if result != nil { + // TODO: How to properly combine these two errors? + errMsg = fmt.Errorf("%w: %v", result, errMsg) + } + + log.Error(errMsg.Error()) + return errMsg + } + + result = send(ctx, emailCfg, emailMsg, myFuncName) + if result != nil { + ourRetryDelay := time.Duration(emailCfg.notificationRetryDelay) * time.Second + + errMsg := fmt.Errorf( + "%s: Attempt %d of %d to send message failed: %v", + myFuncName, + attempt, + attemptsAllowed, + result, + ) + + log.Error(errMsg.Error()) + + // apply retry delay if our context hasn't been cancelled yet, + // otherwise continue with the loop to allow context cancellation + // handling logic to be applied + if ctx.Err() == nil { + log.Debugf( + "%s: Context not cancelled yet, applying retry delay of %v", + myFuncName, + ourRetryDelay, + ) + time.Sleep(ourRetryDelay) + } + + // retry send attempt (if attempts remain) + continue + + } + + log.Debugf( + "%s: successfully sent message after %d of %d attempts\n", + myFuncName, + attempt, + attemptsAllowed, + ) + + // break out of retry loop: we're done! + break + + } + + return result + +} + +// sendEmailMessage wraps and orchestrates another function call to send email +// messages. This includes honoring the provided schedule in order to comply +// with remote API rate limits. The plan is to refactor this function as part +// of the work for GH-22. +func sendEmailMessage( + ctx context.Context, + emailCfg emailConfig, + emailMsg string, + schedule time.Time, + +) NotifyResult { + + // TODO: Apply throttling and cancellation logic to this function like + // we're doing with sendTeamsMessage + + myFuncName := caller.GetFuncName() + + log.Debugf("%s: Time now is %v", myFuncName, time.Now().Format("15:04:05")) + log.Debugf("%s: Notification scheduled for: %v", myFuncName, schedule.Format("15:04:05")) + + // Set delay timer to meet received notification schedule. This helps + // ensure that we delay the appropriate amount of time before we make our + // first attempt at sending a message to the specified SMTP server. + notificationDelay := time.Until(schedule) + + notificationDelayTimer := time.NewTimer(notificationDelay) + defer notificationDelayTimer.Stop() + log.Debugf("%s: notificationDelayTimer created at %v with duration %v", + myFuncName, + time.Now().Format("15:04:05"), + notificationDelay, + ) + + log.Debugf( + "%s: Waiting for either context or notificationDelayTimer to expire before sending notification", + myFuncName, + ) + + select { + case <-ctx.Done(): + ctxErr := ctx.Err() + msg := NotifyResult{ + Val: fmt.Sprintf("%s: Received Done signal at %v: %v, shutting down", + myFuncName, + time.Now().Format("15:04:05"), + ctxErr.Error(), + ), + Success: false, + } + log.Debug(msg.Val) + return msg + + // Delay between message submission attempts; this will *always* + // delay, regardless of whether the attempt is the first one or not + case <-notificationDelayTimer.C: + + log.Debugf("%s: Waited %v before notification attempt at %v", + myFuncName, + notificationDelay, + time.Now().Format("15:04:05"), + ) + + ctxExpires, ctxExpired := ctx.Deadline() + if ctxExpired { + log.Debugf( + "%s: WaitTimeout context expires at: %v", + myFuncName, + ctxExpires.Format("15:04:05"), + ) + } + + // check to see if context has expired during our delay + if ctx.Err() != nil { + msg := NotifyResult{ + Val: fmt.Sprintf( + "%s: context expired or cancelled at %v: %v, attempting to abort message submission", + myFuncName, + time.Now().Format("15:04:05"), + ctx.Err().Error(), + ), + Success: false, + } + + log.Debug(msg.Val) + + return msg + } + + if err := sendEmail(ctx, emailCfg, emailMsg); err != nil { + + errMsg := NotifyResult{ + Err: fmt.Errorf( + "%s: ERROR: Failed to submit message to %s on port %v at %v: %v", + myFuncName, + emailCfg.server, + emailCfg.serverPort, + time.Now().Format("15:04:05"), + err, + ), + Success: false, + } + log.Error(errMsg.Err.Error()) + + return errMsg + } + + successMsg := NotifyResult{ + Val: fmt.Sprintf( + "%s: Message successfully sent to SMTP server %q on port %v at %v", + myFuncName, + emailCfg.server, + emailCfg.serverPort, time.Now().Format("15:04:05"), ), Success: true, diff --git a/cmd/brick/notify.go b/cmd/brick/notify.go index bef66f8f..ee4f7a49 100644 --- a/cmd/brick/notify.go +++ b/cmd/brick/notify.go @@ -20,6 +20,8 @@ import ( "context" "fmt" "runtime" + "strings" + "text/template" "time" "github.com/apex/log" @@ -441,8 +443,8 @@ func teamsNotifier( retryDelay int, resultQueue chan<- NotifyResult) { - ourMessage := createMessage(record) - resultQueue <- sendMessage(ctx, webhookURL, ourMessage, schedule, numRetries, retryDelay) + ourMessage := createTeamsMessage(record) + resultQueue <- sendTeamsMessage(ctx, webhookURL, ourMessage, schedule, numRetries, retryDelay) }(ctx, webhookURL, record, nextScheduledNotification, retries, retriesDelay, ourResultQueue) @@ -467,10 +469,7 @@ func teamsNotifier( // TODO: Refactor per GH-37 func emailNotifier( ctx context.Context, - sendTimeout time.Duration, - sendRateLimit time.Duration, - retries int, - retriesDelay int, + emailCfg emailConfig, incoming <-chan events.Record, notifyMgrResultQueue chan<- NotifyResult, done chan<- struct{}, @@ -485,7 +484,7 @@ func emailNotifier( // email notification attempts. This delay is added in order to rate limit // our outgoing messages to comply with any destination email server // limits. - notifyScheduler := newNotifyScheduler(sendRateLimit) + notifyScheduler := newNotifyScheduler(emailCfg.notificationRateLimit) for { @@ -525,10 +524,10 @@ func emailNotifier( ) timeoutValue := config.GetNotificationTimeout( - sendTimeout, + emailCfg.timeout, nextScheduledNotification, - retries, - retriesDelay, + emailCfg.notificationRetries, + emailCfg.notificationRetryDelay, ) ctx, cancel := context.WithTimeout(ctx, timeoutValue) @@ -560,16 +559,16 @@ func emailNotifier( // launch task in separate goroutine, each with its own schedule log.Debug("emailNotifier: Launching message creation/submission in separate goroutine") - // launch task in a separate goroutine - // FIXME: Implement most of the same parameters here as with the - // goroutine in teamsNotifier, pass ctx for email function to use. - go func(resultQueue chan<- NotifyResult) { - result := NotifyResult{ - Err: fmt.Errorf("emailNotifier: Sending email is not currently supported"), - } - log.Error(result.Err.Error()) - resultQueue <- result - }(ourResultQueue) + go func( + ctx context.Context, + record events.Record, + schedule time.Time, + emailCfg emailConfig, + resultQueue chan<- NotifyResult, + ) { + ourMessage := createEmailMessage(record, emailCfg) + resultQueue <- sendEmailMessage(ctx, emailCfg, ourMessage, schedule) + }(ctx, record, nextScheduledNotification, emailCfg, ourResultQueue) case result := <-ourResultQueue: @@ -644,12 +643,45 @@ func NotifyMgr(ctx context.Context, cfg *config.Config, notifyWorkQueue <-chan e if cfg.NotifyEmail() { log.Info("NotifyMgr: Email notifications enabled") log.Debug("NotifyMgr: Starting up emailNotifier") + + // TODO: Replace with a more dynamic process that allows for use + // of user-specified, file-based templates. For now, this is the + // minimum necessary to complete a first pass at GH-3. + + // TODO: Move these to external files + // activeTemplate := defaultEmailTemplate + // activeTemplate := textileEmailTemplate + + // FIXME: Keep linter from complaining about this being unused for + // now. + _ = defaultEmailTemplate + activeTemplate := textileEmailTemplate + + emailTemplate := template.Must( + template.New( + "emailTemplate", + ).Funcs(template.FuncMap{ + "trim": strings.TrimSpace, + }).Parse(activeTemplate)) + + // TODO: Refactor as fields for new email notifier (not sure of name + // yet) type as part of GH-22. + emailCfg := emailConfig{ + server: cfg.EmailServer(), + serverPort: cfg.EmailServerPort(), + senderAddress: cfg.EmailSenderAddress(), + recipientAddresses: cfg.EmailRecipientAddresses(), + clientIdentity: cfg.EmailClientIdentity(), + timeout: config.NotifyMgrEmailNotificationTimeout, + notificationRateLimit: cfg.EmailNotificationRateLimit(), + notificationRetries: cfg.EmailNotificationRetries(), + notificationRetryDelay: cfg.EmailNotificationRetryDelay(), + template: emailTemplate, + } + go emailNotifier( ctx, - config.NotifyMgrEmailNotificationTimeout, - cfg.EmailNotificationRateLimit(), - cfg.EmailNotificationRetries(), - cfg.EmailNotificationRetryDelay(), + emailCfg, emailNotifyWorkQueue, emailNotifyResultQueue, emailNotifyDone, diff --git a/cmd/brick/templates.go b/cmd/brick/templates.go index 536cb46c..c3e1d327 100644 --- a/cmd/brick/templates.go +++ b/cmd/brick/templates.go @@ -16,7 +16,125 @@ package main -// FIXME: Not sure if this is the final format. Stubbing out for reference. -// const disabledUserEmailTemplateText string = ` -// Username "{{ .Username }}" from source IP "{{ .UserIP }}" disabled at "{{ .ArrivalTime }}" per alert "{{ .AlertName }}" received by "{{ .PayloadSenderIP }}" (SearchID: "{{ .SearchID }}") -// ` +/* + + Structure of templates: + + Top-level Title + + Introduction block (e.g., msgCard.Text) + + Errors Section + + Disable User Request Details Section - Core of alert details + + Alert Request Summary Section - General client request details + + Alert Request Headers Section + + Branding / Trailer Section + +*/ + +const defaultEmailTemplate string = ` + +{{ $missingValue := "MISSING VALUE - Please file a bug report!" }} + +**Summary** + +{{ if ne .Record.Note "" -}} +{{ .Record.Note }} +{{- else -}} +{{ $missingValue }} +{{- end }} + + +**Disable User Request Errors** + +{{ if .Record.Error -}} +{{ .Record.Error }} +{{- else -}} +None +{{- end }} + + +**Disable User Request Details** + +* Username: {{ if .Record.Alert.Username }}{{ .Record.Alert.Username }}{{ else }}{{ $missingValue }}{{ end }} +* User IP: {{ if .Record.Alert.UserIP }}{{ .Record.Alert.UserIP }}{{ else }}{{ $missingValue }}{{ end }} +* Alert/Search Name: {{ if .Record.Alert.AlertName }}{{ .Record.Alert.AlertName }}{{ else }}{{ $missingValue }}{{ end }} +* Alert/Search ID: {{ if .Record.Alert.SearchID }}{{ .Record.Alert.SearchID }}{{ else }}{{ $missingValue }}{{ end }} + + +**Alert Request Summary** + +* Received at: {{ .Record.Alert.LocalTime }} +* Endpoint path: {{ .Record.Alert.EndpointPath }} +* HTTP Method: {{ .Record.Alert.HTTPMethod }} +* Alert Sender IP: {{ .Record.Alert.PayloadSenderIP }} + + +**Alert Request Headers** +{{ range $key, $slice := .Record.Alert.Headers }} +* {{ $key }}: {{ range $sliceValue := $slice }}{{ . }}{{ end }} +{{- else }} +* None +{{ end }} + +{{ .Branding }} + + +` + +const textileEmailTemplate string = ` + +{{ $missingValue := "MISSING VALUE - Please file a bug report!" }} + +**Summary** + +
+{{ if ne .Record.Note "" -}}
+{{ .Record.Note }}
+{{- else -}}
+{{ $missingValue }}
+{{- end }}
+
+ + +**Disable User Request Errors** + +{{ if .Record.Error -}} +
+{{ .Record.Error }}
+
+{{- else -}} +* None +{{- end }} + + +**Disable User Request Details** + +| Username | {{ if .Record.Alert.Username }}{{ .Record.Alert.Username }}{{ else }}{{ $missingValue }}{{ end }} | +| User IP | {{ if .Record.Alert.UserIP }}{{ .Record.Alert.UserIP }}{{ else }}{{ $missingValue }}{{ end }} | +| Alert/Search Name | {{ if .Record.Alert.AlertName }}{{ .Record.Alert.AlertName }}{{ else }}{{ $missingValue }}{{ end }} | +| Alert/Search ID | {{ if .Record.Alert.SearchID }}{{ .Record.Alert.SearchID }}{{ else }}{{ $missingValue }}{{ end }} | + + +**Alert Request Summary** + +| Received at | {{ .Record.Alert.LocalTime }} | +| Endpoint path | {{ .Record.Alert.EndpointPath }} | +| HTTP Method | {{ .Record.Alert.HTTPMethod }} | +| Alert Sender IP | {{ .Record.Alert.PayloadSenderIP }} | + + +**Alert Request Headers** +{{ range $key, $slice := .Record.Alert.Headers }} +| {{ $key }} | {{ range $sliceValue := $slice }}{{ . }}{{ end }} | +{{- else }} +| None | N/A | +{{ end }} + +{{ .Branding }} + +` diff --git a/config/config.go b/config/config.go index fe5fe436..7c160f62 100644 --- a/config/config.go +++ b/config/config.go @@ -37,7 +37,7 @@ var Version string = "x.y.z" func (c *Config) String() string { return fmt.Sprintf( "UnifiedConfig: { "+ - "Network.LocalTCPPort: %v "+ + "Network.LocalTCPPort: %v, "+ "Network.LocalIPAddress: %v, "+ "Logging.Level: %s, "+ "Logging.Output: %s, "+ @@ -47,26 +47,31 @@ func (c *Config) String() string { "DisabledUsers.FilePermissions: %v, "+ "ReportedUsers.LogFile: %q, "+ "ReportedUsers.LogFilePermissions: %v, "+ - "IgnoredUsers.File: %q,"+ - "IsSetIgnoredUsersFile: %t,"+ - "IgnoredIPAddresses.File: %q,"+ - "IsSetIgnoredIPAddressesFile: %t,"+ - "IgnoreLookupErrors: %t,"+ - "MSTeams.WebhookURL: %q,"+ - "MSTeams.RateLimit: %v,"+ - "MSTeams.Retries: %v,"+ - "MSTeams.RetryDelay: %v,"+ - "NotifyTeams: %t,"+ - "NotifyEmail: %t,"+ - "Email.RateLimit: %v,"+ - "Email.Retries: %v,"+ - "Email.RetryDelay: %v,"+ - "EZproxy.ExecutablePath: %v,"+ - "EZproxy.ActiveFilePath: %v,"+ - "EZproxy.AuditFileDirPath: %v,"+ - "EZproxy.SearchRetries: %v,"+ - "EZproxy.SearchDelay: %v,"+ - "EZproxy.TerminateSessions: %t,"+ + "IgnoredUsers.File: %q, "+ + "IsSetIgnoredUsersFile: %t, "+ + "IgnoredIPAddresses.File: %q, "+ + "IsSetIgnoredIPAddressesFile: %t, "+ + "IgnoreLookupErrors: %t, "+ + "MSTeams.WebhookURL: %q, "+ + "MSTeams.RateLimit: %v, "+ + "MSTeams.Retries: %v, "+ + "MSTeams.RetryDelay: %v, "+ + "NotifyTeams: %t, "+ + "NotifyEmail: %t, "+ + "Email.Server: %q, "+ + "Email.Port: %v, "+ + "Email.ClientIdentity: %q, "+ + "Email.SenderAddress: %q, "+ + "Email.RecipientAddresses: %v, "+ + "Email.RateLimit: %v, "+ + "Email.Retries: %v, "+ + "Email.RetryDelay: %v, "+ + "EZproxy.ExecutablePath: %v, "+ + "EZproxy.ActiveFilePath: %v, "+ + "EZproxy.AuditFileDirPath: %v, "+ + "EZproxy.SearchRetries: %v, "+ + "EZproxy.SearchDelay: %v, "+ + "EZproxy.TerminateSessions: %t, "+ "ConfigFile: %q}", c.LocalTCPPort(), c.LocalIPAddress(), @@ -89,6 +94,11 @@ func (c *Config) String() string { c.TeamsNotificationRetryDelay(), c.NotifyTeams(), c.NotifyEmail(), + c.EmailServer(), + c.EmailServerPort(), + c.EmailClientIdentity(), + c.EmailSenderAddress(), + c.EmailRecipientAddresses(), c.EmailNotificationRateLimit(), c.EmailNotificationRetries(), c.EmailNotificationRetryDelay(), @@ -143,9 +153,19 @@ func GetNotificationTimeout( } // MessageTrailer generates a branded "footer" for use with notifications. -func MessageTrailer() string { +func MessageTrailer(format BrandingFormat) string { + + switch format { + case BrandingMarkdownFormat: + case BrandingTextileFormat: + default: + errMsg := fmt.Sprintf("Invalid branding format %q used!", format) + log.Warn(errMsg) + return errMsg + } + return fmt.Sprintf( - "Message generated by [%s](%s) (%s) at %s", + string(format), MyAppName, MyAppURL, Version, diff --git a/config/constants.go b/config/constants.go index e84246b1..73574800 100644 --- a/config/constants.go +++ b/config/constants.go @@ -18,9 +18,14 @@ package config import ( "os" + "regexp" "time" ) +// BrandingFormat is a type used to emulate a set of enums for use with +// defining the format used in footer or "message trailers". +type BrandingFormat string + const ( // MyAppName is the public name of this application @@ -34,6 +39,25 @@ const ( MyAppDescription string = "Automatically disable EZproxy users via webhook requests" ) +const ( + + // BrandingMarkdownFormat is used as a Markdown-compatible template for + // message "trailers" or "footers" on outgoing notifications. This is + // primarily used for Microsoft Teams notifications. + BrandingMarkdownFormat BrandingFormat = `Message generated by [%s](%s) (%s) at %s` + + // BrandingTextileFormat is used as a Textile-compatible template for + // message "trailers" or "footers" on outgoing notifications. This is + // primarily used for email notifications that will be consumed by a + // Redmine instance configured to use the (older) Textile formatting + // option. + BrandingTextileFormat BrandingFormat = `Message generated by "%s":%s (%s) at %s` +) + +// emailRegex is a regular expression provided by the W3C for email address +// format validation. +var emailRegex = regexp.MustCompile("^[a-zA-Z0-9.!#$%&'*+\\/=?^_`{|}~-]+@[a-zA-Z0-9](?:[a-zA-Z0-9-]{0,61}[a-zA-Z0-9])?(?:\\.[a-zA-Z0-9](?:[a-zA-Z0-9-]{0,61}[a-zA-Z0-9])?)*$") + // Default (flag, config file, etc) settings if not overridden by user input const ( defaultLocalTCPPort int = 8000 @@ -63,41 +87,72 @@ const ( // No assumptions can be safely made here; user has to supply this defaultMSTeamsWebhookURL string = "" - // the number of seconds to wait between Microsoft Teams notification - // attempts. This rate limit is intended to help prevent unintentional - // abuse of remote services and is applied regardless of whether the last - // notification attempt was initially successful or required one or more - // retry attempts. + // defaultMSTeamsRateLimit is the number of seconds to wait between + // Microsoft Teams notification attempts. This rate limit is intended to + // help prevent unintentional abuse of remote services and is applied + // regardless of whether the last notification attempt was initially + // successful or required one or more retry attempts. defaultMSTeamsRateLimit int = 5 - // the number of attempts to deliver messages before giving up; applies to - // Microsoft Teams notifications only + // defaultMSTeamsRetries is the number of attempts to deliver messages + // before giving up; applies to Microsoft Teams notifications only. defaultMSTeamsRetries int = 2 - // the number of seconds to wait between retry attempts; applies to - // Microsoft Teams notifications only + // defaultMSTeamsRetryDelay is the number of seconds to wait between retry + // attempts; applies to Microsoft Teams notifications only. defaultMSTeamsRetryDelay int = 5 - // this is based on the official installation instructions + // defaultSMTPServerFQDN is the SMTP server that this application should + // connect to for email message delivery. + defaultSMTPServerFQDN string = "" + + // defaultSMTPServerPort is the TCP port that this application should + // connect to for email message delivery. + defaultSMTPServerPort int = 25 + + // defaultSMTPSenderAddress is the email address to use as the sender on + // all outgoing emails. + defaultSMTPSenderAddress string = "" + + // defaultSMTPClientIdentity is the hostname provided with the HELO or + // EHLO greeting to the SMTP server. + defaultSMTPClientIdentity string = "brick" + + // defaultSMTPRateLimit is the number of seconds to wait between email + // notification attempts. + defaultSMTPRateLimit int = 3 + + // defaultSMTPRetries is the number of attempts that this application will + // make to deliver email messages before giving up. + defaultSMTPRetries int = 2 + + // defaultSMTPRetryDelay is the number of seconds to wait between email + // message delivery attempts. + defaultSMTPRetryDelay int = 2 + + // defaultEZproxyExecutablePath is the fully-qualified path to the EZproxy + // binary. defaultEZproxyExecutablePath string = "/usr/local/ezproxy/ezproxy" - // This text file contains information on active users and virtual web - // server proxies. This file is also known as the Active Users and Hosts - // file or the "state" file and is found in the same directory as the - // EZproxy executable. + // defaultEZproxyActiveFilePath is the text file that contains information + // on active users and virtual web server proxies. This file is also known + // as the Active Users and Hosts file or the "state" file and is found in + // the same directory as the EZproxy executable. defaultEZproxyActiveFilePath string = "/usr/local/ezproxy/ezproxy.hst" - // Audit logs are stored in this path. + // defaultEZproxyAuditFileDirPath is the path where audit logs are stored. defaultEZproxyAuditFileDirPath string = "/usr/local/ezproxy/audit" - // Number of retry attempts that are made to lookup sessions for a - // specified username after receiving zero search results. + // defaultEZproxySearchRetries is the number of retry attempts that are + // made to lookup sessions for a specified username after receiving zero + // search results. defaultEZproxySearchRetries int = 7 - // Delay between search attempts in seconds + // defaultEZproxySearchDelay is the delay between search attempts in + // seconds defaultEZproxySearchDelay int = 1 - // Session termination is disabled by default + // defaultEZproxyTerminateSessions is the toggle for sessions termination defaultEZproxyTerminateSessions bool = false ) diff --git a/config/getters.go b/config/getters.go index 216e2fc1..3fb79d37 100644 --- a/config/getters.go +++ b/config/getters.go @@ -20,7 +20,7 @@ import ( "os" "time" - "github.com/apex/log" + "github.com/Showmax/go-fqdn" ) /****************************************************************** @@ -345,8 +345,7 @@ func (c Config) NotifyTeams() bool { // should be made to send a message to Teams. // For now, use the same logic that validate() uses to determine whether - // validation checks should be run: Is c.WebhookURL set to a non-empty - // string. + // validation checks should be run. return c.TeamsWebhookURL() != "" } @@ -355,10 +354,14 @@ func (c Config) NotifyTeams() bool { // sent via email to specified recipients. func (c Config) NotifyEmail() bool { - // TODO: Add support for email notifications. For now, this method is a - // placeholder to allow logic for future notification support to be - // written. - return false + // Assumption: config.validate() has already been called for the existing + // instance of the Config type and this method is now being called by + // later stages of the codebase to determine only whether an attempt + // should be made to send a message to a SMTP server. + + // For now, use the same logic that validate() uses to determine whether + // validation checks should be run. + return c.EmailServer() != "" } @@ -367,39 +370,130 @@ func (c Config) NotifyEmail() bool { // default value if not provided. CLI flag values take precedence if provided. func (c Config) EmailNotificationRateLimit() time.Duration { var rateLimitSeconds int - // switch { - // case c.cliConfig.Email.RateLimit != nil: - // rateLimitSeconds = *c.cliConfig.MSTeams.RateLimit - // case c.fileConfig.MSTeams.RateLimit != nil: - // rateLimitSeconds = *c.fileConfig.MSTeams.RateLimit - // default: - // rateLimitSeconds = defaultMSTeamsRateLimit - // } - - log.Warn("FIXME: Placeholder value until GH-3 is implemented") - rateLimitSeconds = 5 + switch { + case c.cliConfig.Email.RateLimit != nil: + rateLimitSeconds = *c.cliConfig.Email.RateLimit + case c.fileConfig.Email.RateLimit != nil: + rateLimitSeconds = *c.fileConfig.Email.RateLimit + default: + rateLimitSeconds = defaultSMTPRateLimit + } return time.Duration(rateLimitSeconds) * time.Second } +// EmailServer returns the user-provided SMTP server to be used for email +// notifications or the default value if not provided. CLI flag values take +// precedence if provided. +func (c Config) EmailServer() string { + switch { + case c.cliConfig.Email.Server != nil: + return *c.cliConfig.Email.Server + case c.fileConfig.Email.Server != nil: + return *c.fileConfig.Email.Server + default: + return defaultSMTPServerFQDN + } +} + +// EmailServerPort returns the user-provided TCP port for email notifications +// or the default value if not provided. CLI flag values take precedence if +// provided. +func (c Config) EmailServerPort() int { + switch { + case c.cliConfig.Email.Port != nil: + return *c.cliConfig.Email.Port + case c.fileConfig.Email.Port != nil: + return *c.fileConfig.Email.Port + default: + return defaultSMTPServerPort + } +} + +// EmailClientIdentity returns the user-provided identity for the server that +// this application sends email notifications on behalf of. If not provided, +// attempt to get the fully-qualified domain name for the system where this +// application is running. If there are issues resolving the fqdn use our +// fallback value. or the default CLI flag values take precedence if provided. +func (c Config) EmailClientIdentity() string { + switch { + case c.cliConfig.Email.ClientIdentity != nil: + return *c.cliConfig.Email.ClientIdentity + case c.fileConfig.Email.ClientIdentity != nil: + return *c.fileConfig.Email.ClientIdentity + default: + // Since sysadmin did not specify a value, attempt to get + // fully-qualified domain name for the system where this application + // is running. If there are issues resolving the fqdn use our fallback + // value. + hostname := fqdn.Get() + if hostname == "unknown" { + hostname = defaultSMTPClientIdentity + } + + return hostname + } +} + +// EmailSenderAddress returns the user-provided email address used as the +// sender for all outgoing email notifications from this application or the +// default value if not provided. CLI flag values take precedence if provided. +func (c Config) EmailSenderAddress() string { + switch { + case c.cliConfig.Email.SenderAddress != nil: + return *c.cliConfig.Email.SenderAddress + case c.fileConfig.Email.SenderAddress != nil: + return *c.fileConfig.Email.SenderAddress + default: + return defaultSMTPSenderAddress + } +} + +// EmailRecipientAddresses returns the user-provided list of email addresess +// to receive all outgoing email notifications from this application or the +// default value if not provided. CLI flag values take precedence if provided. +func (c Config) EmailRecipientAddresses() []string { + switch { + case c.cliConfig.Email.RecipientAddresses != nil: + return c.cliConfig.Email.RecipientAddresses + case c.fileConfig.Email.RecipientAddresses != nil: + return c.fileConfig.Email.RecipientAddresses + default: + // Validation should catch this and fail the start-up attempt IF the + // sysadmin opted to specify a SMTP mail server, but *not* provide one + // or more destination addresses. If the SMTP server is not specified, + // this method (and thus the value) should remain unused. + return make([]string, 0) + } +} + // EmailNotificationRetries returns the user-provided retry limit before // giving up on email message delivery or the default value if not provided. // CLI flag values take precedence if provided. func (c Config) EmailNotificationRetries() int { - - log.Warn("Implement this as part of GH-3") - - return 0 + switch { + case c.cliConfig.Email.Retries != nil: + return *c.cliConfig.Email.Retries + case c.fileConfig.Email.Retries != nil: + return *c.fileConfig.Email.Retries + default: + return defaultSMTPRetries + } } // EmailNotificationRetryDelay returns the user-provided delay for email // notifications or the default value if not provided. CLI flag values take -// precedence if provided. +// precedence if provided. This delay is added regardless of whether a +// previous notification delivery attempt has been made. func (c Config) EmailNotificationRetryDelay() int { - - log.Warn("Implement this as part of GH-3") - - return 0 + switch { + case c.cliConfig.Email.RetryDelay != nil: + return *c.cliConfig.Email.RetryDelay + case c.fileConfig.Email.RetryDelay != nil: + return *c.fileConfig.Email.RetryDelay + default: + return defaultSMTPRetryDelay + } } // EZproxyExecutablePath returns the user-provided, fully-qualified path to diff --git a/config/types.go b/config/types.go index dbd331b5..d6d689af 100644 --- a/config/types.go +++ b/config/types.go @@ -154,6 +154,55 @@ type MSTeams struct { Retries *int `toml:"retries" arg:"--teams-notify-retries,env:BRICK_MSTEAMS_WEBHOOK_RETRIES" help:"The number of attempts that this application will make to deliver Microsoft Teams messages before giving up."` } +// Email represents the various configuration settings ued to send email +// notifications. +type Email struct { + + // Server is the SMTP server that this application should connect to for + // email message delivery. Specify localhost if testing or sending mail + // via a local SMTP server instance. Examples include running a Postfix + // null client which sends all mail to a relayhost on the local network or + // a Maildev Docker container for development purposes. + Server *string `toml:"server" arg:"--email-server-name,env:BRICK_EMAIL_SERVER_NAME" help:"The SMTP server that this application should connect to for email message delivery. Specify localhost if testing or sending mail via a local SMTP server instance. Examples include running a Postfix null client which sends all mail to a relayhost on the local network or a Maildev Docker container for development purposes."` + + // Port is the TCP port that this application should connect to for email + // message delivery. The default is usually 25, but could be 1025 if using + // the default Maildev container port. + Port *int `toml:"port" arg:"--email-server-port,env:BRICK_EMAIL_SERVER_PORT" help:"The TCP port that this application should connect to for email message delivery. The default is usually 25, but could be 1025 if using the default Maildev container port."` + + // ClientIdentity is the hostname provided with the HELO or EHLO greeting + // to the SMTP server. Be aware that many SMTP servers expect this value + // to be a valid FQDN with forward and reverse DNS records. If left blank, + // this value is generated by retrieving the local system's + // fully-qualified domain name, the local hostname or as a fallback, the + // hard-coded default value. + ClientIdentity *string `toml:"client_identity" arg:"--email-client-identity,env:BRICK_EMAIL_CLIENT_IDENTITY" help:"The hostname provided with the HELO or EHLO greeting to the SMTP server. Be aware that many SMTP servers expect this value to be a valid FQDN with forward and reverse DNS records. If left blank, this value is generated by retrieving the local system's fully-qualified domain name, the local hostname or as a fallback, the hard-coded default value."` + + // SenderAddress is the email address used as the sender for all outgoing + // email notifications from this application. + SenderAddress *string `toml:"sender_address" arg:"--email-sender-address,env:BRICK_EMAIL_SENDER_ADDRESS" help:"The email address used as the sender for all outgoing email notifications from this application."` + + // RecipientAddresses is the comma or space-separated list of email + // addresses that should receive all outgoing email notifications from + // this application. + RecipientAddresses []string `toml:"recipient_addresses" arg:"--email-recipient-addresses,env:BRICK_EMAIL_RECIPIENT_ADDRESSES" help:"The comma or space-separated list of email addresses that should receive all outgoing email notifications from this application."` + + // RateLimit is the number of seconds to wait between email notification + // attempts. This rate limit is intended to help prevent unintentional + // abuse of remote services and is applied regardless of whether the last + // notification attempt was initially successful or required one or more + // retry attempts. + RateLimit *int `toml:"rate_limit" arg:"--email-notify-rate-limit,env:BRICK_EMAIL_NOTIFY_RATE_LIMIT" help:"The number of seconds to wait between email notification attempts. This rate limit is intended to help prevent unintentional abuse of remote services and is applied regardless of whether the last notification attempt was initially successful or required one or more retry attempts."` + + // RetryDelay is the number of seconds to wait between email message + // delivery attempts. + RetryDelay *int `toml:"retry_delay" arg:"--email-notify-retry-delay,env:BRICK_EMAIL_NOTIFY_RETRY_DELAY" help:"The number of seconds to wait between email message delivery retry attempts."` + + // Retries is the number of attempts that this application will make to + // deliver email messages before giving up. + Retries *int `toml:"retries" arg:"--email-notify-retries,env:BRICK_EMAIL_NOTIFY_RETRIES" help:"The number of attempts that this application will make to deliver email messages before giving up."` +} + // EZproxy represents that various configuration settings used to interact // with EZproxy and files/settings used by EZproxy. type EZproxy struct { @@ -223,6 +272,7 @@ type configTemplate struct { IgnoredUsers IgnoredIPAddresses MSTeams + Email EZproxy IgnoreLookupErrors *bool `toml:"ignore_lookup_errors" arg:"--ignore-lookup-errors,env:BRICK_IGNORE_LOOKUP_ERRORS" help:"Whether application should continue if attempts to lookup existing disabled or ignored status for a username or IP Address fail."` diff --git a/config/validate.go b/config/validate.go index 4a167655..eee4f87d 100644 --- a/config/validate.go +++ b/config/validate.go @@ -27,6 +27,68 @@ import ( send2teams "github.com/atc0005/send2teams/teams" ) +// validateEmailAddress receives a string representing an email address and +// the intent or purpose for the email address (e.g., "sender", "recipient") +// and validates the email address. A error message indicating the reason for +// validation failure is returned or nil if no issues were found. +func validateEmailAddress(emailAddr string, purpose string) error { + + // https://golangcode.com/validate-an-email-address/ + // https://www.w3.org/TR/2016/REC-html51-20161101/sec-forms.html#email-state-typeemail + + switch { + case emailAddr == "": + notSpecifiedErr := fmt.Errorf( + "%s email address not specified", + purpose, + ) + return notSpecifiedErr + + case (len(emailAddr) < 3 && len(emailAddr) > 254): + invalidLengthErr := fmt.Errorf( + "%s email address %q has invalid length of %d", + purpose, + emailAddr, + len(emailAddr), + ) + return invalidLengthErr + + case !emailRegex.MatchString(emailAddr): + invalidRegexExMatch := fmt.Errorf( + "%s email address %q does not match expected W3C format", + purpose, + emailAddr, + ) + return invalidRegexExMatch + } + + return nil +} + +// validateEmailAddresses receives a slice of email addresses and the +// associated intent (e.g., "sender", "recipient") and validates each of them +// returning the validation error if present or nil if no validation failures +// occur. +func validateEmailAddresses(emailAddresses []string, purpose string) error { + + if len(emailAddresses) == 0 { + + return fmt.Errorf( + "%s email address not specified", + purpose, + ) + } + + for _, emailAddr := range emailAddresses { + if err := validateEmailAddress(emailAddr, purpose); err != nil { + log.Debug(err.Error()) + return err + } + } + + return nil +} + // validate confirms that all config struct fields have reasonable values func validate(c Config) error { @@ -177,6 +239,92 @@ func validate(c Config) error { ) } + // Not specifying an email server is a valid choice. Perform validation of + // this and other related values if the server name is provided. + if c.EmailServer() != "" { + + log.Debugf("Email server provided: %q", c.EmailServer()) + + // TODO: This needs more work to be truly useful + if len(c.EmailServer()) <= 1 { + log.Debugf( + "unsupported email server name of %d characters specified for email notifications: %q", + len(c.EmailServer()), + c.EmailServer(), + ) + return fmt.Errorf( + "invalid server name specified for email notifications: %q", + c.EmailServer(), + ) + } + + if !(c.EmailServerPort() >= TCPSystemPortStart) && (c.EmailServerPort() <= TCPDynamicPrivatePortEnd) { + log.Debugf("invalid port %d specified", c.EmailServerPort()) + return fmt.Errorf( + "port %d is not a valid TCP port for the destination SMTP server", + c.EmailServerPort(), + ) + } + + if err := validateEmailAddress(c.EmailSenderAddress(), "sender"); err != nil { + log.Debug(err.Error()) + return err + } + + if err := validateEmailAddresses(c.EmailRecipientAddresses(), "recipient"); err != nil { + log.Debug(err.Error()) + return err + } + + if c.EmailClientIdentity() != "" { + if len(c.EmailClientIdentity()) <= 1 { + log.Debugf( + "unsupported email client identity of %d characters specified for email server connection: %q", + len(c.EmailClientIdentity()), + c.EmailClientIdentity(), + ) + return fmt.Errorf( + "unsupported email client identity specified for email server connection: %q", + c.EmailClientIdentity(), + ) + } + } + + if c.EmailNotificationRateLimit() < 0 { + log.Debugf( + "unsupported rate limit specified for email notifications: %d ", + c.EmailNotificationRateLimit(), + ) + return fmt.Errorf( + "invalid rate limit specified for email notifications: %d", + c.EmailNotificationRateLimit(), + ) + } + + if c.EmailNotificationRetryDelay() < 0 { + log.Debugf( + "unsupported delay specified for email notifications: %d ", + c.EmailNotificationRetryDelay(), + ) + return fmt.Errorf( + "invalid delay specified for email notifications: %d", + c.EmailNotificationRetryDelay(), + ) + } + + if c.EmailNotificationRetries() < 0 { + log.Debugf( + "unsupported retry limit specified for email notifications: %d", + c.EmailNotificationRetries(), + ) + return fmt.Errorf( + "invalid retries limit specified for email notifications: %d", + c.EmailNotificationRetries(), + ) + } + + } + if c.EZproxyExecutablePath() == "" { return fmt.Errorf("path to EZproxy executable file not provided") } diff --git a/contrib/brick/config.example.toml b/contrib/brick/config.example.toml index a23715e2..3c630827 100644 --- a/contrib/brick/config.example.toml +++ b/contrib/brick/config.example.toml @@ -130,17 +130,62 @@ retries = 2 retry_delay = 5 -# TODO: Add this in later +[email] + +# The SMTP server that this application should connect to for email message +# delivery. Specify localhost if testing or sending mail via a local SMTP +# server instance. Examples include running a Postfix null client which sends +# all mail to a relayhost on the local network or a Maildev Docker container +# for development purposes. +# server = "localhost" +server = "" + +# The TCP port that this application should connect to for email message +# delivery. If using a Maildev Docker container for testing you may want to +# try 1025 as the port. +# port = 1025 +port = 25 + +# Default SMTP connections are unauthenticated. The hope is to extend email +# notification functionality in the future with support for standard +# authentication mechanisms. # -# [email] -# -# smtp_server_fqdn = "" -# smtp_server_port = "" -# smtp_server_username = "" -# smtp_server_password = "" -# smtp_server_secure = "" -# retries = 2 -# delay = 2 +# username = "" +# password = "" + +# The hostname provided with the HELO or EHLO greeting to the SMTP server. If +# left blank, the default is used. Many SMTP servers will require that the +# hostname be provided in a FQDN format. Those same SMTP servers may equally +# require that both forward and reverse DNS lookups for this provided value +# resolve properly. +# client_identity = "localhost" +# client_identity = "ezproxy.example.com" +client_identity = "brick" + +# The email address to use as the sender on all outgoing emails. +sender_address = "brick@example.org" + +# The list of email addresses (one or many) to receive notifications from this +# application. +# recipient_emails = [ +# "help@example.org", +# "devteam@example.org", +# "sysadmins@example.org", +# ] +recipient_addresses = ["help@example.org"] + +# The number of seconds to wait between email notification attempts. This rate +# limit is intended to help prevent unintentional abuse of remote services and +# is applied regardless of whether the last notification attempt was initially +# successful or required one or more retry attempts. +rate_limit = 3 + +# The number of attempts that this application will make to deliver email +# messages before giving up. +retries = 2 + +# The number of seconds to wait between email message delivery attempts. +retry_delay = 2 [ezproxy] diff --git a/doc.go b/doc.go index 22f5a640..daf94804 100644 --- a/doc.go +++ b/doc.go @@ -54,10 +54,12 @@ FEATURES • User configurable support for ignoring specific IP Addresses (i.e., prevent disabling associated account) -• Microsoft Teams notifications generated for multiple events with configurable rate limit, notification retries, and retry delay - • Logging of all events (e.g., payload receipt, action taken due to payload) +• Optional Microsoft Teams notifications generated for multiple events with configurable rate limit, notification retries, and retry delay + +* Optional email notifications generated for multiple events with configurable rate limit, notification retries, and retry delay + • Optional automatic (but not officially documented) termination of user sessions via official EZproxy binary USAGE diff --git a/docs/configure.md b/docs/configure.md index 1a65b721..8095f64e 100644 --- a/docs/configure.md +++ b/docs/configure.md @@ -33,33 +33,41 @@ environment variables) and use the configuration file for the other settings. - Flags *not* marked as required are for settings where a useful default is already defined. -| Option | Required | Default | Repeat | Possible | Description | -| ------------------------------- | -------- | ---------------------------------------------- | ------ | ------------------------------------------ | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | -| `h`, `help` | No | `false` | No | `h`, `help` | Show Help text along with the list of supported flags. | -| `config-file` | No | *empty string* | No | *valid path to a file* | Fully-qualified path to a configuration file consulted for settings not already provided via CLI flags or environment variables. | -| `ignore-lookup-errors` | No | `false` | No | `true`, `false` | Whether application should continue if attempts to lookup existing disabled or ignored status for a username or IP Address fail. This is needed if you do not pre-create files used by this application ahead of time. WARNING: Because this can mask errors, you should probably only use it briefly when this application is first deployed, then later disable the setting once all files are in place. | -| `port` | No | `8000` | No | *valid TCP port number* | TCP port that this application should listen on for incoming HTTP requests. Tip: Use an unreserved port between 1024:49151 (inclusive) for the best results. | -| `ip-address` | No | `localhost` | No | *valid fqdn, local name or IP Address* | Local IP Address that this application should listen on for incoming HTTP requests. | -| `log-level` | No | `info` | No | `fatal`, `error`, `warn`, `info`, `debug` | Log message priority filter. Log messages with a lower level are ignored. | -| `log-output` | No | `stdout` | No | `stdout`, `stderr` | Log messages are written to this output target. | -| `log-format` | No | `text` | No | `cli`, `json`, `logfmt`, `text`, `discard` | Use the specified `apex/log` package "handler" to output log messages in that handler's format. | -| `disabled-users-file` | No | `/var/cache/brick/users.brick-disabled.txt` | No | *valid path to a file* | Fully-qualified path to the "disabled users" file | -| `disabled-users-file-perms` | No | `0o644` | No | *valid permissions in octal format* | Permissions (in octal) applied to newly created "disabled users" file. **NOTE:** `EZproxy` will need to be able to read this file. | -| `disabled-users-entry-suffix` | No | `::deny` | No | *valid EZproxy condition/action* | String that is appended after every username added to the disabled users file in order to deny login access. | -| `reported-users-log-file` | No | `/var/log/brick/users.brick-reported.log` | No | *valid path to a file* | Fully-qualified path to the log file where this application should log user disable request events for fail2ban to ingest. | -| `reported-users-log-file-perms` | No | `0o644` | No | *valid permissions in octal format* | Permissions (in octal) applied to newly created "reported users" log file. **NOTE:** `fail2ban` will need to be able to read this file. | -| `ignored-users-file` | No | `/usr/local/etc/brick/users.brick-ignored.txt` | No | *valid path to a file* | Fully-qualified path to the file containing a list of user accounts which should not be disabled and whose IP Address reported in the same alert should not be banned by this application. Leading and trailing whitespace per line is ignored. | -| `ignored-ips-file` | No | `/usr/local/etc/brick/ips.brick-ignored.txt` | No | *valid path to a file* | Fully-qualified path to the file containing a list of individual IP Addresses which should not be disabled and whose user account reported in the same alert should not be disabled by this application. Leading and trailing whitespace per line is ignored. | -| `teams-webhook-url` | No | *empty string* | No | [*valid webhook url*](#worth-noting) | The Webhook URL provided by a preconfigured Connector. If specified, this application will attempt to send client request details to the Microsoft Teams channel associated with the webhook URL. | -| `teams-notify-rate-limit` | No | `5` | No | *number of seconds as a whole number* | The number of seconds to wait between Microsoft Teams notification attempts. This rate limit is intended to help prevent unintentional abuse of remote services and is applied regardless of whether the last notification attempt was initially successful or required one or more retry attempts. | -| `teams-notify-retry-delay` | No | `5` | No | *valid whole number* | The number of seconds to wait between Microsoft Teams message retry delivery attempts. | -| `teams-notify-retries` | No | `2` | No | *valid whole number* | The number of attempts that this application will make to deliver Microsoft Teams messages before giving up. | -| `ezproxy-executable-path` | No | `/usr/local/ezproxy/ezproxy` | No | *valid path to a file* | The fully-qualified path to the EZproxy executable/binary. This executable is usually named 'ezproxy' and is set to start at system boot. The fully-qualified path to this executable is required for session termination. | -| `ezproxy-active-file-path` | No | `/usr/local/ezproxy/ezproxy.hst` | No | *valid path to a file* | The fully-qualified path to the Active Users and Hosts 'state' file used by EZproxy (and this application) to track current sessions and hosts managed by EZproxy. | -| `ezproxy-audit-file-dir-path` | No | `/usr/local/ezproxy/audit` | No | *valid path to a directory* | The path to the directory containing the EZproxy audit files. The assumption is made that all files within are based on YYYYMMDD.txt pattern. Any other file pattern found within this path is ignored (e.g, .zip or .tar or whatnot for a one-off quick backup made by a sysadmin of a specific file). | -| `ezproxy-search-retries` | No | `7` | No | *valid whole number* | The number of retries allowed for the audit log and active files before the application accepts that 'cannot find matching session IDs for specific user' is really the truth of it and not a race condition between this application and the EZproxy application (e.g., EZproxy accepts a login, but delays writing the state information for about 2 seconds to keep from hammering the storage device). | -| `ezproxy-search-delay` | No | `1` | No | *valid whole number* | The delay in seconds between searches of the audit log or active file for a specified username. This is an attempt to work around race conditions between EZproxy updating its state file (which has been observed to have a delay of up to several seconds) and this application *reading* the active file. This delay is applied to the initial search and each subsequent retried search for the provided username. | -| `ezproxy-terminate-sessions` | No | `false` | No | `true`, `false` | Whether session termination support is enabled. If false, session termination will not be initiated by this application, though current session IDs found as part of preparing for termination will still be logged for troubleshooting purposes. If setting (or leaving) this as false, the assumption is that either no handling of reported users is desired (other than perhaps logging and notification) or that a tool such as fail2ban is used to monitor the reported users log file and temporarily block the source IP in order to force session timeout. | +| Option | Required | Default | Repeat | Possible | Description | +| ------------------------------- | ------------------------ | ---------------------------------------------- | ------ | -------------------------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | +| `h`, `help` | No | `false` | No | `h`, `help` | Show Help text along with the list of supported flags. | +| `config-file` | No | *empty string* | No | *valid path to a file* | Fully-qualified path to a configuration file consulted for settings not already provided via CLI flags or environment variables. | +| `ignore-lookup-errors` | No | `false` | No | `true`, `false` | Whether application should continue if attempts to lookup existing disabled or ignored status for a username or IP Address fail. This is needed if you do not pre-create files used by this application ahead of time. WARNING: Because this can mask errors, you should probably only use it briefly when this application is first deployed, then later disable the setting once all files are in place. | +| `port` | No | `8000` | No | *valid TCP port number* | TCP port that this application should listen on for incoming HTTP requests. Tip: Use an unreserved port between 1024:49151 (inclusive) for the best results. | +| `ip-address` | No | `localhost` | No | *valid fqdn, local name or IP Address* | Local IP Address that this application should listen on for incoming HTTP requests. | +| `log-level` | No | `info` | No | `fatal`, `error`, `warn`, `info`, `debug` | Log message priority filter. Log messages with a lower level are ignored. | +| `log-output` | No | `stdout` | No | `stdout`, `stderr` | Log messages are written to this output target. | +| `log-format` | No | `text` | No | `cli`, `json`, `logfmt`, `text`, `discard` | Use the specified `apex/log` package "handler" to output log messages in that handler's format. | +| `disabled-users-file` | No | `/var/cache/brick/users.brick-disabled.txt` | No | *valid path to a file* | Fully-qualified path to the "disabled users" file | +| `disabled-users-file-perms` | No | `0o644` | No | *valid permissions in octal format* | Permissions (in octal) applied to newly created "disabled users" file. **NOTE:** `EZproxy` will need to be able to read this file. | +| `disabled-users-entry-suffix` | No | `::deny` | No | *valid EZproxy condition/action* | String that is appended after every username added to the disabled users file in order to deny login access. | +| `reported-users-log-file` | No | `/var/log/brick/users.brick-reported.log` | No | *valid path to a file* | Fully-qualified path to the log file where this application should log user disable request events for fail2ban to ingest. | +| `reported-users-log-file-perms` | No | `0o644` | No | *valid permissions in octal format* | Permissions (in octal) applied to newly created "reported users" log file. **NOTE:** `fail2ban` will need to be able to read this file. | +| `ignored-users-file` | No | `/usr/local/etc/brick/users.brick-ignored.txt` | No | *valid path to a file* | Fully-qualified path to the file containing a list of user accounts which should not be disabled and whose IP Address reported in the same alert should not be banned by this application. Leading and trailing whitespace per line is ignored. | +| `ignored-ips-file` | No | `/usr/local/etc/brick/ips.brick-ignored.txt` | No | *valid path to a file* | Fully-qualified path to the file containing a list of individual IP Addresses which should not be disabled and whose user account reported in the same alert should not be disabled by this application. Leading and trailing whitespace per line is ignored. | +| `teams-webhook-url` | [*Maybe*](#worth-noting) | *empty string* | No | [*valid webhook url*](#worth-noting) | The Webhook URL provided by a preconfigured Connector. If specified, this application will attempt to send applicable notifications to the Microsoft Teams channel associated with the webhook URL. | +| `teams-notify-rate-limit` | No | `5` | No | *number of seconds as a whole number* | The number of seconds to wait between Microsoft Teams notification attempts. This rate limit is intended to help prevent unintentional abuse of remote services and is applied regardless of whether the last notification attempt was initially successful or required one or more retry attempts. | +| `teams-notify-retry-delay` | No | `5` | No | *valid whole number* | The number of seconds to wait between Microsoft Teams message retry delivery attempts. | +| `teams-notify-retries` | No | `2` | No | *valid whole number* | The number of attempts that this application will make to deliver Microsoft Teams messages before giving up. | +| `email-server-name` | [*Maybe*](#worth-noting) | *empty string* | No | *valid fqdn or IP Address* | The TCP port that this application should connect to for email message delivery. The default is usually 25, but could be 1025 if using the default Maildev container port. If specified, this application will attempt to send applicable email notifications to this SMTP server. | +| `email-server-port` | No | `25` | No | *valid TCP port number* | the TCP port that this application should connect to for email message delivery. The default is usually 25, but could be 1025 if using the default Maildev container port. | +| `email-recipient-addresses` | [*Maybe*](#worth-noting) | *empty list* | No | *valid email addresses* | The comma or space-separated list of email addresses that should receive all outgoing email notifications from this application. | +| `email-sender-address` | [*Maybe*](#worth-noting) | *empty string* | No | *valid email address* | The email address used as the sender for all outgoing email notifications from this application. | +| `email-client-identity` | No | fqdn, local hostname or `brick` (fallback) | No | *valid fqdn, local host or application name* | The hostname provided with the HELO or EHLO greeting to the SMTP server. Be aware that many SMTP servers expect this value to be a valid FQDN with forward and reverse DNS records. If left blank, this value is generated by retrieving the local system's fully-qualified domain name, the local hostname or as a fallback, the hard-coded default value. | +| `email-notify-rate-limit` | No | `3` | No | *number of seconds as a whole number* | The number of seconds to wait between email notification attempts. This rate limit is intended to help prevent unintentional abuse of remote services and is applied regardless of whether the last notification attempt was initially successful or required one or more retry attempts. | +| `email-notify-retry-delay` | No | `5` | No | *valid whole number* | The number of seconds to wait between email message retry delivery attempts. | +| `email-notify-retries` | No | `2` | No | *valid whole number* | The number of attempts that this application will make to deliver email messages before giving up. | +| `ezproxy-executable-path` | No | `/usr/local/ezproxy/ezproxy` | No | *valid path to a file* | The fully-qualified path to the EZproxy executable/binary. This executable is usually named 'ezproxy' and is set to start at system boot. The fully-qualified path to this executable is required for session termination. | +| `ezproxy-active-file-path` | No | `/usr/local/ezproxy/ezproxy.hst` | No | *valid path to a file* | The fully-qualified path to the Active Users and Hosts 'state' file used by EZproxy (and this application) to track current sessions and hosts managed by EZproxy. | +| `ezproxy-audit-file-dir-path` | No | `/usr/local/ezproxy/audit` | No | *valid path to a directory* | The path to the directory containing the EZproxy audit files. The assumption is made that all files within are based on YYYYMMDD.txt pattern. Any other file pattern found within this path is ignored (e.g, .zip or .tar or whatnot for a one-off quick backup made by a sysadmin of a specific file). | +| `ezproxy-search-retries` | No | `7` | No | *valid whole number* | The number of retries allowed for the audit log and active files before the application accepts that 'cannot find matching session IDs for specific user' is really the truth of it and not a race condition between this application and the EZproxy application (e.g., EZproxy accepts a login, but delays writing the state information for about 2 seconds to keep from hammering the storage device). | +| `ezproxy-search-delay` | No | `1` | No | *valid whole number* | The delay in seconds between searches of the audit log or active file for a specified username. This is an attempt to work around race conditions between EZproxy updating its state file (which has been observed to have a delay of up to several seconds) and this application *reading* the active file. This delay is applied to the initial search and each subsequent retried search for the provided username. | +| `ezproxy-terminate-sessions` | No | `false` | No | `true`, `false` | Whether session termination support is enabled. If false, session termination will not be initiated by this application, though current session IDs found as part of preparing for termination will still be logged for troubleshooting purposes. If setting (or leaving) this as false, the assumption is that either no handling of reported users is desired (other than perhaps logging and notification) or that a tool such as fail2ban is used to monitor the reported users log file and temporarily block the source IP in order to force session timeout. | ## Environment Variables @@ -88,6 +96,14 @@ Arguments](#command-line-arguments) table for more information. | `teams-notify-rate-limit` | `BRICK_MSTEAMS_WEBHOOK_RATE_LIMIT` | | `BRICK_MSTEAMS_WEBHOOK_RATE_LIMIT=5` | | `teams-notify-retry-delay` | `BRICK_MSTEAMS_WEBHOOK_RETRY_DELAY` | | `BRICK_MSTEAMS_WEBHOOK_RETRY_DELAY="2"` | | `teams-notify-retries` | `BRICK_MSTEAMS_WEBHOOK_RETRIES` | | `BRICK_MSTEAMS_WEBHOOK_RETRIES="5"` | +| `email-server-name` | `BRICK_EMAIL_SERVER_NAME` | | `BRICK_EMAIL_SERVER_NAME="smtp.example.org"` | +| `email-server-port` | `BRICK_EMAIL_SERVER_PORT` | | `BRICK_EMAIL_SERVER_PORT="25"` | +| `email-recipient-addresses` | `BRICK_EMAIL_RECIPIENT_ADDRESSES` | | `BRICK_EMAIL_RECIPIENT_ADDRESSES=help@example.org,devteam@example.org,sysadmins@example.org"` | +| `email-sender-address` | `BRICK_EMAIL_SENDER_ADDRESS` | | `BRICK_EMAIL_SENDER_ADDRESS="help@example.org"` | +| `email-client-identity` | `BRICK_EMAIL_CLIENT_IDENTITY` | | `BRICK_EMAIL_CLIENT_IDENTITY="eres-proxy.example.org"` | +| `email-notify-rate-limit` | `BRICK_EMAIL_NOTIFY_RATE_LIMIT` | | `BRICK_EMAIL_NOTIFY_RATE_LIMIT="3"` | +| `email-notify-retry-delay` | `BRICK_EMAIL_NOTIFY_RETRY_DELAY` | | `BRICK_EMAIL_NOTIFY_RETRY_DELAY="2"` | +| `email-notify-retries` | `BRICK_EMAIL_NOTIFY_RETRIES` | | `BRICK_EMAIL_NOTIFY_RETRIES="2"` | | `ezproxy-executable-path` | `BRICK_EZPROXY_EXECUTABLE_PATH` | | `BRICK_EZPROXY_EXECUTABLE_PATH="/usr/local/ezproxy/ezproxy"` | | `ezproxy-active-file-path` | `BRICK_EZPROXY_ACTIVE_FILE_PATH` | | `BRICK_EZPROXY_ACTIVE_FILE_PATH="/usr/local/ezproxy/ezproxy.hst"` | | `ezproxy-audit-file-dir-path` | `BRICK_EZPROXY_AUDIT_FILE_DIR_PATH` | | `BRICK_EZPROXY_AUDIT_FILE_DIR_PATH="/usr/local/ezproxy/audit"` | @@ -103,31 +119,39 @@ See the [Command-line Arguments](#command-line-arguments) table for more information, including the available values for the listed configuration settings. -| Flag Name | Config file Setting Name | Section Name | Notes | -| ------------------------------- | ------------------------ | -------------------- | ----- | -| `ignore-lookup-errors` | `ignore_lookup_errors` | | | -| `port` | `local_tcp_port` | `network` | | -| `ip-address` | `local_ip_address` | `network` | | -| `log-level` | `level` | `logging` | | -| `log-format` | `format` | `logging` | | -| `log-out` | `output` | `logging` | | -| `disabled-users-file` | `file_path` | `disabledusers` | | -| `disabled-users-file-perms` | `file_permissions` | `disabledusers` | | -| `disabled-users-entry-suffix` | `entry_suffix` | `disabledusers` | | -| `reported-users-log-file` | `file_path` | `reportedusers` | | -| `reported-users-log-file-perms` | `file_permissions` | `reportedusers` | | -| `ignored-users-file` | `file_path` | `ignoredusers` | | -| `ignored-ips-file` | `file_path` | `ignoredipaddresses` | | -| `teams-webhook-url` | `webhook_url` | `msteams` | | -| `teams-notify-rate-limit` | `rate_limit` | `msteams` | | -| `teams-notify-retry-delay` | `retry_delay` | `msteams` | | -| `teams-notify-retries` | `retries` | `msteams` | | -| `ezproxy-executable-path` | `executable_path` | `ezproxy` | | -| `ezproxy-active-file-path` | `active_file_path` | `ezproxy` | | -| `ezproxy-audit-file-dir-path` | `audit_file_dir_path` | `ezproxy` | | -| `ezproxy-search-retries` | `search_retries` | `ezproxy` | | -| `ezproxy-search-delay` | `search_delay` | `ezproxy` | | -| `ezproxy-terminate-sessions` | `terminate_sessions` | `ezproxy` | | +| Flag Name | Config file Setting Name | Section Name | Notes | +| ------------------------------- | ------------------------ | -------------------- | ------------------------------------------------------------------------ | +| `ignore-lookup-errors` | `ignore_lookup_errors` | | | +| `port` | `local_tcp_port` | `network` | | +| `ip-address` | `local_ip_address` | `network` | | +| `log-level` | `level` | `logging` | | +| `log-format` | `format` | `logging` | | +| `log-out` | `output` | `logging` | | +| `disabled-users-file` | `file_path` | `disabledusers` | | +| `disabled-users-file-perms` | `file_permissions` | `disabledusers` | | +| `disabled-users-entry-suffix` | `entry_suffix` | `disabledusers` | | +| `reported-users-log-file` | `file_path` | `reportedusers` | | +| `reported-users-log-file-perms` | `file_permissions` | `reportedusers` | | +| `ignored-users-file` | `file_path` | `ignoredusers` | | +| `ignored-ips-file` | `file_path` | `ignoredipaddresses` | | +| `teams-webhook-url` | `webhook_url` | `msteams` | | +| `teams-notify-rate-limit` | `rate_limit` | `msteams` | | +| `teams-notify-retry-delay` | `retry_delay` | `msteams` | | +| `teams-notify-retries` | `retries` | `msteams` | | +| `email-server-name` | `server` | `email` | | +| `email-server-port` | `port` | `email` | | +| `email-recipient-addresses` | `recipient_addresses` | `email` | [Multi-line array](https://github.com/toml-lang/toml#user-content-array) | +| `email-sender-address` | `sender_address` | `email` | | +| `email-client-identity` | `client_identity` | `email` | | +| `email-notify-rate-limit` | `rate_limit` | `email` | | +| `email-notify-retry-delay` | `retry_delay` | `email` | | +| `email-notify-retries` | `retries` | `email` | | +| `ezproxy-executable-path` | `executable_path` | `ezproxy` | | +| `ezproxy-active-file-path` | `active_file_path` | `ezproxy` | | +| `ezproxy-audit-file-dir-path` | `audit_file_dir_path` | `ezproxy` | | +| `ezproxy-search-retries` | `search_retries` | `ezproxy` | | +| `ezproxy-search-delay` | `search_delay` | `ezproxy` | | +| `ezproxy-terminate-sessions` | `terminate_sessions` | `ezproxy` | | The [`contrib/brick/config.example.toml`](../contrib/brick/config.example.toml) @@ -142,6 +166,15 @@ arguments](#command-line-arguments) sections for usage details. ## Worth noting +- Notifications are disabled unless required values are provided + + | Notification Type | Required Value | + | ----------------- | --------------------------------------- | + | Microsoft Teams | webhook URL | + | Email | remote SMTP server (FQDN or IP Address) | + | Email | sender address | + | Email | recipient address(es) | + - For best results, limit your choice of TCP port to an unprivileged user port between `1024` and `49151` diff --git a/docs/demo.md b/docs/demo.md index 9031439c..1d3e08dc 100644 --- a/docs/demo.md +++ b/docs/demo.md @@ -11,6 +11,7 @@ - [Why brick was created](#why-brick-was-created) - [Preparation](#preparation) - [Environment](#environment) + - [Web browser / MailDev](#web-browser--maildev) - [Visual Studio Code](#visual-studio-code) - [brick](#brick) - [Terminal](#terminal) @@ -18,12 +19,13 @@ - [Slideshow](#slideshow) - [First payload with as-is demo settings](#first-payload-with-as-is-demo-settings) - [Simulate a repeat Splunk alert](#simulate-a-repeat-splunk-alert) - - [Enable Teams notifications](#enable-teams-notifications) + - [Enable Teams and email notifications](#enable-teams-and-email-notifications) - [Same test username, different IP Address](#same-test-username-different-ip-address) - [Ignored username, different IP Address](#ignored-username-different-ip-address) - [Ignored IP Address, different username](#ignored-ip-address-different-username) - [High-level overview](#high-level-overview) - [Improvements](#improvements) + - [Recent](#recent) - [Planned](#planned) - [Potential](#potential) - [Upstream feature requests](#upstream-feature-requests) @@ -41,7 +43,8 @@ This demo is intended to answer that question by covering: ### Environment -1. Boot throwaway Ubuntu Linux 16.04 LTS or 18.04 LTS desktop VM +1. Boot throwaway Ubuntu Linux 18.04 LTS desktop VM + older or newer versions *may* work, but have not received as much testing 1. Install `git` - `sudo apt-get update && sudo apt-get install -y git` 1. Clone remote repo @@ -67,6 +70,13 @@ This demo is intended to answer that question by covering: - this does not modify existing copies of the config files in the `/usr/local/etc/brick/` directory +### Web browser / MailDev + +1. Open a web browser directly within the demo VM or on the VM host +1. Navigate to the IP Address of the VM + `:1080` + - e.g., `http://192.168.92.136:1080/` or `http://localhost:1080/` +1. Enable automatic refresh of mailbox contents + ### Visual Studio Code 1. Add `brick` repo path to workspace @@ -106,8 +116,25 @@ Configure `brick`: 1. Set `msteams.webhook_url` to the webhook URL retrieved from the test Microsoft Teams channel 1. Comment out `msteams.webhook_url` line + - this disables Microsoft Teams notifications for now 1. Set `ezproxy.terminate_sessions` to `true` - TODO: Decide if this will be enabled initially, or as a follow-up item +1. Configure email settings + 1. `email.server` to `localhost` + 1. `email.port` to `25` + 1. `email.client_identity` to `brick` + - a production system should set this to the fully-qualified hostname of + the sending system + - a production system should also have a [Forward-confirmed reverse DNS + (FCrDNS](https://en.wikipedia.org/wiki/Forward-confirmed_reverse_DNS) + configuration to enable the most reliable delivery possible + 1. `email.sender_address` to `brick@example.org` + 1. `email.recipient_addresses` to `["help@example.org"]` + 1. `email.rate_limit` to `3` + 1. `email.retries` to `2` + 1. `email.retry_delay` to `2` +1. Comment out `email.server` + - this disables email notifications for now ### Terminal @@ -135,13 +162,15 @@ Configure `guake` for demo: @splunk-sanitized-payload-formatted.json http://localhost:8000/api/v1/users/disable` 1. `syslog entries` - - `/var/log/brick/syslog.log` + - `clear && tail -f /var/log/brick/syslog.log | ccze -A` 1. `disabled user entries` - - `/var/cache/brick/users.brick-disabled.txt` + - `clear && tail -f /var/cache/brick/users.brick-disabled.txt | ccze -A` 1. `reported user entries` - - `/var/log/brick/users.brick-reported.log` + - `clear && tail -f /var/log/brick/users.brick-reported.log | ccze -A` 1. `fail2ban log` - - `/var/log/fail2ban.log` + - `clear && tail -f /var/log/fail2ban.log | ccze -A` + 1. `email log` + 1. `clear && tail -f /var/log/mail.log | ccze -A` ## Presentation @@ -247,7 +276,7 @@ Configure `guake` for demo: users from that IP Address will be allowed to connect to EZproxy again. The disabled account will stay disabled. -### Enable Teams notifications +### Enable Teams and email notifications Use the same test username as before. @@ -268,6 +297,9 @@ Use the same test username as before. - Show familiar entries 1. Switch to the `fail2ban log` tab - Show familiar entries +1. Switch to the MailDev container + - Accessible within demo VM or externally on NAT network + - Step through all notifications ### Same test username, different IP Address @@ -348,12 +380,16 @@ See [Overview](start-here.md) doc for additional details. ## Improvements -### Planned +### Recent - Email notifications directly from `brick` - - "coming soon" - analogue to Teams notifications - - potentially 1:1, but likely less + +- Support for automatic sessions termination + - using official `ezproxy` binary + - using unsupported `kill` subcommand of official `ezproxy` binary + +### Planned - Additional endpoints - list disabled user accounts @@ -373,9 +409,6 @@ time/support from leadership. - Use LDAP package to add disabled users to a Library-specific AD group - Update EZproxy (test instance first) to deny login access to users in this AD group -- Takeover fail2ban responsibilities to reduce outside dependencies - - I.e., manage host-level firewall rules directly without reliance on - fail2ban - Refactoring to allow `brick` to take general actions from Splunk, Graylog or another alert - restart a service diff --git a/docs/deploy.md b/docs/deploy.md index c85a0654..8ad812f9 100644 --- a/docs/deploy.md +++ b/docs/deploy.md @@ -182,6 +182,11 @@ version. [configuration](configure.md) guide for more information. 1. Set a Microsoft Teams webhook URL to enable Teams channel notifications. - Skip this step if you don't use Microsoft Teams. +1. Configure email notifications + - Skip this step if you don't want email notifications. + - **NOTE**: If your SMTP server applies [Forward-confirmed reverse DNS + (FCrDNS)](https://en.wikipedia.org/wiki/Forward-confirmed_reverse_DNS) + verification the `client_identity` setting will need to reflect this. 1. Copy the starter/template "ignore" files and modify accordingly - `/usr/local/etc/brick/ips.brick-ignored.txt` - `/usr/local/etc/brick/users.brick-ignored.txt` diff --git a/docs/references.md b/docs/references.md index 2be210d8..2e111593 100644 --- a/docs/references.md +++ b/docs/references.md @@ -37,6 +37,7 @@ absolutely essential) while developing this application. - (upstream) - (fork) - + - - External - [Splunk](https://www.splunk.com/​) @@ -61,6 +62,7 @@ absolutely essential) while developing this application. - HTTP - + - - Splunk / JSON payload - [Splunk Enterprise (v8.0.1) > Alerting Manual > Use a webhook alert action](https://docs.splunk.com/Documentation/Splunk/8.0.1/Alert/Webhooks) @@ -76,6 +78,10 @@ absolutely essential) while developing this application. - - +- email + - + - + ### Related projects - diff --git a/go.mod b/go.mod index 2006cde1..e8417fd8 100644 --- a/go.mod +++ b/go.mod @@ -21,6 +21,7 @@ module github.com/atc0005/brick go 1.13 require ( + github.com/Showmax/go-fqdn v0.0.0-20180501083314-6f60894d629f github.com/alexflint/go-arg v1.3.0 github.com/apex/log v1.6.0 github.com/atc0005/go-ezproxy v0.1.3 diff --git a/go.sum b/go.sum index abe9ee0c..1d20ae50 100644 --- a/go.sum +++ b/go.sum @@ -1,5 +1,7 @@ github.com/BurntSushi/toml v0.3.1 h1:WXkYYl6Yr3qBf1K79EBnL4mak0OimBfB0XUf9Vl28OQ= github.com/BurntSushi/toml v0.3.1/go.mod h1:xHWCNGjB5oqiDr8zfno3MHue2Ht5sIBksp03qcyfWMU= +github.com/Showmax/go-fqdn v0.0.0-20180501083314-6f60894d629f h1:JqQetNUOVIen9o9K9c+BHgYePFGXQmedq/A6F58Xu+w= +github.com/Showmax/go-fqdn v0.0.0-20180501083314-6f60894d629f/go.mod h1:nxfWvpOWKx1oAU7G3U8UYWL/iY6EKdjjv1w/S8HDsvg= github.com/alexflint/go-arg v1.3.0 h1:UfldqSdFWeLtoOuVRosqofU4nmhI1pYEbT4ZFS34Bdo= github.com/alexflint/go-arg v1.3.0/go.mod h1:9iRbDxne7LcR/GSvEr7ma++GLpdIU1zrghf2y2768kM= github.com/alexflint/go-scalar v1.0.0 h1:NGupf1XV/Xb04wXskDFzS0KWOLH632W/EO4fAFi+A70= diff --git a/vendor/github.com/Showmax/go-fqdn/LICENSE b/vendor/github.com/Showmax/go-fqdn/LICENSE new file mode 100644 index 00000000..63de5e02 --- /dev/null +++ b/vendor/github.com/Showmax/go-fqdn/LICENSE @@ -0,0 +1,13 @@ +Copyright since 2015 Showmax s.r.o. + +Licensed under the Apache License, Version 2.0 (the "License"); +you may not use this file except in compliance with the License. +You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + +Unless required by applicable law or agreed to in writing, software +distributed under the License is distributed on an "AS IS" BASIS, +WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +See the License for the specific language governing permissions and +limitations under the License. diff --git a/vendor/github.com/Showmax/go-fqdn/README.md b/vendor/github.com/Showmax/go-fqdn/README.md new file mode 100644 index 00000000..0f65367c --- /dev/null +++ b/vendor/github.com/Showmax/go-fqdn/README.md @@ -0,0 +1,26 @@ +# go-fqdn +Simple wrapper around `net` and `os` golang standard libraries providing Fully Qualified Domain Name of the machine. + +## Usage +Get the library... +``` +$ go get github.com/Showmax/go-fqdn +``` +...and write some code. +``` +package main + +import ( + "fmt" + "github.com/Showmax/go-fqdn" +) + +func main() { + fmt.Println(fqdn.Get()) +} +``` + +`fqdn.Get()` returns: +- machine's FQDN if found. +- hostname if FQDN is not found. +- return "unknown" if nothing is found. diff --git a/vendor/github.com/Showmax/go-fqdn/fqdn.go b/vendor/github.com/Showmax/go-fqdn/fqdn.go new file mode 100644 index 00000000..9a49d457 --- /dev/null +++ b/vendor/github.com/Showmax/go-fqdn/fqdn.go @@ -0,0 +1,37 @@ +package fqdn + +import ( + "net" + "os" + "strings" +) + +// Get Fully Qualified Domain Name +// returns "unknown" or hostanme in case of error +func Get() string { + hostname, err := os.Hostname() + if err != nil { + return "unknown" + } + + addrs, err := net.LookupIP(hostname) + if err != nil { + return hostname + } + + for _, addr := range addrs { + if ipv4 := addr.To4(); ipv4 != nil { + ip, err := ipv4.MarshalText() + if err != nil { + return hostname + } + hosts, err := net.LookupAddr(string(ip)) + if err != nil || len(hosts) == 0 { + return hostname + } + fqdn := hosts[0] + return strings.TrimSuffix(fqdn, ".") // return fqdn without trailing dot + } + } + return hostname +} diff --git a/vendor/modules.txt b/vendor/modules.txt index e70475b7..a7c2d4b3 100644 --- a/vendor/modules.txt +++ b/vendor/modules.txt @@ -1,3 +1,5 @@ +# github.com/Showmax/go-fqdn v0.0.0-20180501083314-6f60894d629f +github.com/Showmax/go-fqdn # github.com/alexflint/go-arg v1.3.0 github.com/alexflint/go-arg # github.com/alexflint/go-scalar v1.0.0