diff --git a/.github/workflows/lint-and-build-code.yml b/.github/workflows/lint-and-build-code.yml index 1ab003ba..f4e30e7d 100644 --- a/.github/workflows/lint-and-build-code.yml +++ b/.github/workflows/lint-and-build-code.yml @@ -92,7 +92,10 @@ jobs: - name: Build with (mostly) default options # Note: We use the `-mod=vendor` flag to explicitly request that our # top-level vendor folder be used instead of fetching remote packages - run: go build -v -mod=vendor ./cmd/brick + run: | + go build -v -mod=vendor ./cmd/brick + go build -v -mod=vendor ./cmd/es + go build -v -mod=vendor ./cmd/ezproxy - name: Build using project Makefile run: make all diff --git a/.gitignore b/.gitignore index fb0e868e..865b3533 100644 --- a/.gitignore +++ b/.gitignore @@ -19,6 +19,9 @@ # Linux binaries /brick +/es +/ezproxy +/ezproxy-mock # Local Visual Studio Code editor settings (e.g., ignored words for Spelling extension) /.vscode diff --git a/Makefile b/Makefile index 7916c056..3b7bc0e8 100644 --- a/Makefile +++ b/Makefile @@ -32,7 +32,7 @@ SHELL = /bin/bash # Space-separated list of cmd/BINARY_NAME directories to build -WHAT = brick +WHAT = brick es ezproxy # TODO: This will need to be standardized across all cmd files in order to # work as intended. @@ -53,7 +53,7 @@ GOLANGCI_LINT_VERSION = v1.27.0 # The default `go build` process embeds debugging information. Building # without that debugging information reduces the binary size by around 28%. -BUILDCMD = go build -mod=vendor -a -ldflags="-s -w -X $(VERSION_VAR_PKG).version=$(VERSION)" +BUILDCMD = go build -mod=vendor -a -ldflags="-s -w -X $(VERSION_VAR_PKG).Version=$(VERSION)" GOCLEANCMD = go clean -mod=vendor ./... GITCLEANCMD = git clean -xfd CHECKSUMCMD = sha256sum -b @@ -149,11 +149,11 @@ windows: @for target in $(WHAT); do \ mkdir -p $(OUTPUTDIR)/$$target && \ - echo "Building 386 binaries" && \ + echo "Building $$target 386 binary" && \ env GOOS=windows GOARCH=386 $(BUILDCMD) -o $(OUTPUTDIR)/$$target/$$target-$(VERSION)-windows-386.exe ${PWD}/cmd/$$target && \ - echo "Building amd64 binaries" && \ + echo "Building $$target amd64 binary" && \ env GOOS=windows GOARCH=amd64 $(BUILDCMD) -o $(OUTPUTDIR)/$$target/$$target-$(VERSION)-windows-amd64.exe ${PWD}/cmd/$$target && \ - echo "Generating checksum files" && \ + echo "Generating $$target checksum files" && \ $(CHECKSUMCMD) $(OUTPUTDIR)/$$target/$$target-$(VERSION)-windows-386.exe > $(OUTPUTDIR)/$$target/$$target-$(VERSION)-windows-386.exe.sha256 && \ $(CHECKSUMCMD) $(OUTPUTDIR)/$$target/$$target-$(VERSION)-windows-amd64.exe > $(OUTPUTDIR)/$$target/$$target-$(VERSION)-windows-amd64.exe.sha256; \ done @@ -167,11 +167,11 @@ linux: @for target in $(WHAT); do \ mkdir -p $(OUTPUTDIR)/$$target && \ - echo "Building 386 binaries" && \ + echo "Building $$target 386 binary" && \ env GOOS=linux GOARCH=386 $(BUILDCMD) -o $(OUTPUTDIR)/$$target/$$target-$(VERSION)-linux-386 ${PWD}/cmd/$$target && \ - echo "Building amd64 binaries" && \ + echo "Building $$target amd64 binary" && \ env GOOS=linux GOARCH=amd64 $(BUILDCMD) -o $(OUTPUTDIR)/$$target/$$target-$(VERSION)-linux-amd64 ${PWD}/cmd/$$target && \ - echo "Generating checksum files" && \ + echo "Generating $$target checksum files" && \ $(CHECKSUMCMD) $(OUTPUTDIR)/$$target/$$target-$(VERSION)-linux-386 > $(OUTPUTDIR)/$$target/$$target-$(VERSION)-linux-386.sha256 && \ $(CHECKSUMCMD) $(OUTPUTDIR)/$$target/$$target-$(VERSION)-linux-amd64 > $(OUTPUTDIR)/$$target/$$target-$(VERSION)-linux-amd64.sha256; \ done diff --git a/README.md b/README.md index 4f533996..629cb8d1 100644 --- a/README.md +++ b/README.md @@ -13,9 +13,9 @@ Automatically disable EZproxy user accounts via incoming webhook requests. ## Table of contents +- [Project home](#project-home) - [Status](#status) - [Overview](#overview) -- [Project home](#project-home) - [Features](#features) - [Current](#current) - [Missing](#missing) @@ -28,6 +28,12 @@ Automatically disable EZproxy user accounts via incoming webhook requests. - [License](#license) - [References](#references) +## Project home + +See [our GitHub repo](https://github.com/atc0005/brick) for the latest code, +to file an issue or submit improvements for review and potential inclusion +into the project. + ## Status Alpha quality; most @@ -63,12 +69,6 @@ See also: - [High-level overview](docs/start-here.md) - our other [documentation](#documentation) for further instructions -## Project home - -See [our GitHub repo](https://github.com/atc0005/brick) for the latest code, -to file an issue or submit improvements for review and potential inclusion -into the project. - ## Features ### Current diff --git a/cmd/brick/handlers.go b/cmd/brick/handlers.go index 75b67b00..933ce117 100644 --- a/cmd/brick/handlers.go +++ b/cmd/brick/handlers.go @@ -20,6 +20,7 @@ import ( "bytes" "encoding/json" "fmt" + "io" "io/ioutil" "net/http" "time" @@ -90,6 +91,11 @@ func disableUserHandler( disabledUsers *files.DisabledUsers, ignoredSources files.IgnoredSources, notifyWorkQueue chan<- events.Record, + terminateSessions bool, + ezproxyActiveFilePath string, + ezproxySessionsSearchDelay int, + ezproxySessionSearchRetries int, + ezproxyExecutable string, ) http.HandlerFunc { return func(w http.ResponseWriter, r *http.Request) { @@ -121,9 +127,9 @@ func disableUserHandler( // have the option of displaying it in a raw format (e.g., // troubleshooting), replace the Body with a new io.ReadCloser to // allow later access to r.Body for JSON-decoding purposes - requestBody, err := ioutil.ReadAll(r.Body) - if err != nil { - http.Error(w, err.Error(), http.StatusBadRequest) + requestBody, requestBodyReadErr := ioutil.ReadAll(r.Body) + if requestBodyReadErr != nil { + http.Error(w, requestBodyReadErr.Error(), http.StatusBadRequest) return } @@ -135,8 +141,7 @@ func disableUserHandler( // error, respond to the client with the error message and appropriate // status code. var payloadV2 events.SplunkAlertPayloadV2 - err = json.NewDecoder(r.Body).Decode(&payloadV2) - if err != nil { + if err := json.NewDecoder(r.Body).Decode(&payloadV2); err != nil { log.Errorf("Error decoding r.Body into payloadV2:\n%v\n\n", err) http.Error(w, err.Error(), http.StatusBadRequest) return @@ -154,8 +159,25 @@ func disableUserHandler( } - // inform sender that the payload was received - fmt.Fprintln(w, "OK: Payload received") + // Explicitly confirm that the payload was received so that the sender + // can go ahead and disconnect. This prevents holding up the sender + // while this application performs further (unrelated from the + // sender's perspective) processing. + // + // FIXME: Is having a newline here best practice, or no? + if _, err := io.WriteString(w, "OK: Payload received\n"); err != nil { + log.Error("disableUserHandler: Failed to send OK status response to payload sender") + } + + // Manually flush http.ResponseWriter in an additional effort to + // prevent undue wait time for payload sender + if f, ok := w.(http.Flusher); ok { + log.Debug("disableUserHandler: Manually flushing http.ResponseWriter") + f.Flush() + } else { + log.Warn("disableUserHandler: http.Flusher interface not available, cannot flush http.ResponseWriter") + log.Warn("disableUserHandler: Not flushing http.ResponseWriter may cause a noticeable delay between requests") + } // if we made it this far, the payload checks out and we should be // able to safely retrieve values that we need. We will also append @@ -174,50 +196,27 @@ func disableUserHandler( Headers: r.Header, } - log.Debugf( - "disableUserHandler: Received disable request from %q for user %q from IP %q.", - alert.PayloadSenderIP, - alert.Username, - alert.UserIP, - ) - - // Send to Notification Manager for processing - record := events.Record{ - Alert: alert, - Note: "disable request received", - } - go func() { notifyWorkQueue <- record }() - - if err := files.ProcessEvent( + // All return values from subfunction calls are dropped into the + // notifyWorkQueue channel; nothing is returned here for further + // processing. + // + // NOTE: Because this is executed in a goroutine, the client (e.g., + // monitoring system) gets a near-immediate response back and the + // connection is closed. There are probably other/better ways to + // achieve that specific result without using a goroutine, but the + // effect is worth noting for further exploration later. + go files.ProcessDisableEvent( alert, disabledUsers, reportedUserEventsLog, ignoredSources, notifyWorkQueue, - ); err != nil { - - // TODO: Ensure that any errors bubble-up to *something* that will - // get the errors in front of us. A later step in this app's - // design is to expose errors via an endpoint so that monitoring - // systems like Nagios can poll for unresolved errors. Perhaps - // CRITICAL for failed writes, WARNING for writes pending a - // delayed retry (based on the assumption that a later re-check in - // the monitoring system will find the pending write no longer - // present, signaling an OK state). - log.Error(err.Error()) - - // FIXME: This is likely a duplicate of something handled further - // down? - go func() { - notifyWorkQueue <- events.Record{ - Alert: alert, - Error: err, - Note: "ProcessEvent() failure", - } - }() - - return - } + terminateSessions, + ezproxyActiveFilePath, + ezproxySessionsSearchDelay, + ezproxySessionSearchRetries, + ezproxyExecutable, + ) } } diff --git a/cmd/brick/main.go b/cmd/brick/main.go index b7914883..f41d6cad 100644 --- a/cmd/brick/main.go +++ b/cmd/brick/main.go @@ -105,9 +105,8 @@ func main() { syscall.SIGTERM, // full restart ) - // Where events.EventRecord values will be sent for processing. We - // use a buffered channel in an effort to reduce the delay for client - // requests. + // Where events will be sent for processing. We use a buffered channel in + // an effort to reduce the delay for client requests. notifyWorkQueue := make(chan events.Record, config.NotifyMgrQueueDepth) // Create "notifications manager" function as persistent goroutine to @@ -155,6 +154,11 @@ func main() { disabledUsers, ignoredSources, notifyWorkQueue, + appConfig.EZproxyTerminateSessions(), + appConfig.EZproxyActiveFilePath(), + appConfig.EZproxySearchDelay(), + appConfig.EZproxySearchRetries(), + appConfig.EZproxyExecutablePath(), ), ) diff --git a/cmd/brick/message.go b/cmd/brick/message.go index e21a4e88..69a998bd 100644 --- a/cmd/brick/message.go +++ b/cmd/brick/message.go @@ -19,12 +19,14 @@ package main import ( "context" "fmt" - "runtime" + "strconv" "time" "github.com/apex/log" "github.com/atc0005/brick/config" "github.com/atc0005/brick/events" + "github.com/atc0005/brick/internal/caller" + "github.com/atc0005/go-ezproxy" // use our fork for now until recent work can be submitted for inclusion // in the upstream project @@ -33,83 +35,231 @@ import ( send2teams "github.com/atc0005/send2teams/teams" ) -func createMessage(record events.Record) goteamsnotify.MessageCard { +// addFactPair accepts a MessageCard, MessageCardSection, a key and one or +// many values related to the provided key. An attempt is made to format the +// key and all values as code in an effort to keep Teams from parsing some +// special characters as Markdown code formatting characters. If an error +// occurs, this error is used as the MessageCard Text field to help make this +// failure more prominent, otherwise the top-level MessageCard fields remain +// untouched. +// +// FIXME: Rework and offer upstream? +func addFactPair(msg *goteamsnotify.MessageCard, section *goteamsnotify.MessageCardSection, key string, values ...string) { - log.Debugf("createMessage: alert received: %#v", record) + for idx := range values { + values[idx] = send2teams.TryToFormatAsCodeSnippet(values[idx]) + } - // FIXME: Pull this out as a separate helper function? - // FIXME: Rework and offer upstream? - addFactPair := func(msg *goteamsnotify.MessageCard, section *goteamsnotify.MessageCardSection, key string, values ...string) { + if err := section.AddFactFromKeyValue( + key, + values..., + ); err != nil { + from := caller.GetFuncFileLineInfo() + errMsg := fmt.Sprintf("%s error returned from attempt to add fact from key/value pair: %v", from, err) + log.Errorf("%s %s", from, errMsg) + msg.Text = msg.Text + "\n\n" + send2teams.TryToFormatAsCodeSnippet(errMsg) + } +} - // attempt to format all values as code in an effort to keep Teams - // from parsing some special characters as Markdown code formatting - // characters - for idx := range values { - values[idx] = send2teams.TryToFormatAsCodeSnippet(values[idx]) - } +// FIXME: Move this elsewhere or remove; it does not look like a viable option +// func generateTable(sTermResults []ezproxy.TerminateUserSessionResult) string { +// +// // TODO: Can we generate a Markdown table here? +// sessionResultsTableHeader := ` +// | ID | IP | ExitCode | StdOut | StdErr | ErrorMsg |
+// | --- | --- | --- | --- | --- | --- |
+// ` +// +// // addFactPair(&msgCard, sessionTerminationResultsSection, "Error", record.Error.Error()) +// +// var sessionResultsTableBody string +// for _, result := range sTermResults { +// +// // guard against (nil) lack of error in results slice entry +// errStr := "None" +// if result.Error != nil { +// errStr = result.Error.Error() +// } +// +// sessionResultsTableBody += fmt.Sprintf( +// "| %s | %s | %d | %s | %s | %s |
", +// result.SessionID, +// result.IPAddress, +// result.ExitCode, +// result.StdOut, +// result.StdErr, +// errStr, +// ) +// } +// +// // TODO: No clue if this will work, figured it was worth a shot. +// sessionResultsTable := sessionResultsTableHeader + sessionResultsTableBody +// +// return sessionResultsTable +// } - if err := section.AddFactFromKeyValue( - key, - values..., - ); err != nil { +// getTerminationResultsList generates a Markdown list summarizing session +// termination results. See also atc0005/go-ezproxy#18. +func getTerminationResultsList(sTermResults []ezproxy.TerminateUserSessionResult) string { - // runtime.Caller(skip int) (pc uintptr, file string, line int, ok bool) - _, file, line, ok := runtime.Caller(0) - from := fmt.Sprintf("createMessage [file %s, line %d]:", file, line) - if !ok { - from = "createMessage:" - } - errMsg := fmt.Sprintf("%s error returned from attempt to add fact from key/value pair: %v", from, err) - log.Errorf("%s %s", from, errMsg) - msg.Text = msg.Text + "\n\n" + send2teams.TryToFormatAsCodeSnippet(errMsg) + var sessionResultsStringSets string + for _, result := range sTermResults { + + // guard against (nil) lack of error in results slice entry + errStr := "None" + if result.Error != nil { + errStr = result.Error.Error() } + + sessionResultsStringSets += fmt.Sprintf( + "- { SessionID: %q, IPAddress: %q, ExitCode: %q, StdOut: %q, StdErr: %q, Error: %q }\n\n", + result.SessionID, + result.IPAddress, + strconv.Itoa(result.ExitCode), + result.StdOut, + result.StdErr, + errStr, + ) + } + + 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 provide Note as a brief +// summary while still using the Error field in a dedicated section. +func getMsgCardMainSectionText(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. + + var msgCardTextField string + + switch { + case record.Note != "": + msgCardTextField = "Summary: " + record.Note + case record.Error != nil: + msgCardTextField = "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. + default: + msgCardTextField = "FIXME: Missing Note for this event record!" + } + + return msgCardTextField + +} + +// 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 { + + var msgCardTitle string + + switch record.Action { + + // case record.Error != nil: + // msgCardTitle = "[ERROR] " + record.Error.Error() + + // 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 + + case events.ActionSuccessDisabledUsername, events.ActionFailureDisabledUsername: + msgCardTitle = msgCardTitlePrefix + "[step 2 of 3] " + record.Action + + case events.ActionSuccessDuplicatedUsername, events.ActionFailureDuplicatedUsername: + msgCardTitle = msgCardTitlePrefix + "[step 2 of 3] " + record.Action + + case events.ActionSuccessIgnoredUsername, events.ActionFailureIgnoredUsername: + msgCardTitle = msgCardTitlePrefix + "[step 2 of 3] " + record.Action + + case events.ActionSuccessIgnoredIPAddress, events.ActionFailureIgnoredIPAddress: + msgCardTitle = msgCardTitlePrefix + "[step 2 of 3] " + record.Action + + case events.ActionSuccessTerminatedUserSession, + events.ActionFailureUserSessionLookupFailure, + events.ActionFailureTerminatedUserSession, + events.ActionSkippedTerminateUserSessions: + msgCardTitle = msgCardTitlePrefix + "[step 3 of 3] " + record.Action + + default: + msgCardTitle = msgCardTitlePrefix + " [UNKNOWN] " + record.Action + log.Warnf("UNKNOWN record: %v+\n", record) } + return msgCardTitle +} + +// createMessage receives an event Record and generates a MessageCard suitable +// for use with sendMessage() to generate a Microsoft Teams message. +func createMessage(record events.Record) goteamsnotify.MessageCard { + + log.Debugf("createMessage: alert received: %#v", record) + // build MessageCard for submission msgCard := goteamsnotify.NewMessageCard() msgCardTitlePrefix := config.MyAppName + ": " - if record.Action == "" { - msgCard.Title = msgCardTitlePrefix + "(1/2) Disable user account request received" - } else { - msgCard.Title = msgCardTitlePrefix + "(2/2) " + record.Action - } + msgCard.Title = getMsgCardTitle(msgCardTitlePrefix, record) - msgCard.Text = fmt.Sprintf( - "Disable request received for user %s at IP %s by alert %s", - send2teams.TryToFormatAsCodeSnippet(record.Alert.Username), - send2teams.TryToFormatAsCodeSnippet(record.Alert.UserIP), - send2teams.TryToFormatAsCodeSnippet(record.Alert.AlertName), - ) + // msgCard.Text = record.Note + msgCard.Text = getMsgCardMainSectionText(record) /* - Record/Annotations Section + Errors Section */ - // TODO: Flesh this section out more by reviewing/updating app code to - // provide more Error and Note details + // TODO: (GH-59) + // + // Flesh this section out more by reviewing/updating app code to provide + // more Error and Note details - if record.Error != nil || record.Note != "" { - disableUserRequestAnnotationsSection := goteamsnotify.NewMessageCardSection() - disableUserRequestAnnotationsSection.Title = "## Disable User Request Annotations" - disableUserRequestAnnotationsSection.StartGroup = true + disableUserRequestErrors := goteamsnotify.NewMessageCardSection() + disableUserRequestErrors.Title = "## Disable User Request Errors" + disableUserRequestErrors.StartGroup = true - // TODO: Should we display these fields regardless? + switch { + case record.Error != nil: + addFactPair(&msgCard, disableUserRequestErrors, "Error", record.Error.Error()) + case record.Error == nil: + disableUserRequestErrors.Text = "None" + } - if record.Error != nil { - addFactPair(&msgCard, disableUserRequestAnnotationsSection, "Error", record.Error.Error()) - } + if err := msgCard.AddSection(disableUserRequestErrors); err != nil { + errMsg := fmt.Sprintf("Error returned from attempt to add disableUserRequestErrors: %v", err) + log.Error("createMessage: " + errMsg) + msgCard.Text = msgCard.Text + "\n\n" + send2teams.TryToFormatAsCodeSnippet(errMsg) + } - if record.Note != "" { - disableUserRequestAnnotationsSection.Text = "Note: " + record.Note - } + // If Session Termination is enabled, create Termination Results section + if record.SessionTerminationResults != nil { + + sessionTerminationResultsSection := goteamsnotify.NewMessageCardSection() + sessionTerminationResultsSection.Title = "## Session Termination Results" + sessionTerminationResultsSection.StartGroup = true - if err := msgCard.AddSection(disableUserRequestAnnotationsSection); err != nil { - errMsg := fmt.Sprintf("Error returned from attempt to add disableUserRequestAnnotationsSection: %v", err) + // List out IP Address associated with Session ID; we previously noted + // the IP Address associated with the reported username. + + sessionTerminationResultsSection.Text = getTerminationResultsList(record.SessionTerminationResults) + + if err := msgCard.AddSection(sessionTerminationResultsSection); err != nil { + errMsg := fmt.Sprintf("Error returned from attempt to add sessionTerminationResultsSection: %v", err) log.Error("createMessage: " + errMsg) msgCard.Text = msgCard.Text + "\n\n" + send2teams.TryToFormatAsCodeSnippet(errMsg) } + } /* diff --git a/cmd/es/main.go b/cmd/es/main.go new file mode 100644 index 00000000..69ae2a85 --- /dev/null +++ b/cmd/es/main.go @@ -0,0 +1,223 @@ +// Copyright 2020 Adam Chalkley +// +// https://github.com/atc0005/brick +// +// 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 +// +// https://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. + +package main + +import ( + "flag" + "fmt" + "os" + "path/filepath" + "text/tabwriter" + + "github.com/apex/log" + "github.com/apex/log/handlers/cli" + + "github.com/atc0005/brick/config" + "github.com/atc0005/go-ezproxy" + "github.com/atc0005/go-ezproxy/activefile" +) + +// Primarily used with branding +const myAppName string = "es" +const myAppURL string = "https://github.com/atc0005/brick" + +type AppConfig struct { + + // Username is the name of the user account that we are searching for. + Username string + + // ActiveFilePath is 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. + ActiveFilePath string + + // EZproxyExecutable is the fully-qualified path to the EZproxy + // executable/binary. This is the same executable that starts at boot. + // This file is usually named 'ezproxy'. + EZproxyExecutable string + + // TerminateSessions controls whether session termination support is + // enabled. + TerminateSessions bool + + // SearchDelay is 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. + SearchDelay int + + // SearchRetries is 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). + SearchRetries int +} + +// Branding is responsible for emitting application name, version and origin +func Branding() { + fmt.Fprintf(flag.CommandLine.Output(), "\n%s %s\n%s\n\n", myAppName, config.Version, myAppURL) +} + +// flagsUsage displays branding information and general usage details +func flagsUsage() func() { + + return func() { + + myBinaryName := filepath.Base(os.Args[0]) + + Branding() + + fmt.Fprintf(flag.CommandLine.Output(), "Usage of \"%s\":\n", + myBinaryName, + ) + flag.PrintDefaults() + + fmt.Fprintf(flag.CommandLine.Output(), "\n") + + } +} + +func main() { + + // logging controls for this application + log.SetLevel(log.InfoLevel) + log.SetHandler(cli.New(os.Stdout)) + + // logging controls for imported ezproxy package and subpackages + // ezproxy.EnableLogging() + // ezproxy.DisableLogging() + + config := AppConfig{} + + flag.StringVar(&config.ActiveFilePath, "active-file-path", "", "The fully-qualified path to the EZproxy active users/state file") + flag.StringVar(&config.ActiveFilePath, "auf", "", "The fully-qualified path to the EZproxy active users/state file") + + flag.StringVar(&config.EZproxyExecutable, "executable", "/opt/ezprozy/ezproxy", "The fully-qualified path to the EZproxy application/binary file") + flag.StringVar(&config.EZproxyExecutable, "exe", "/opt/ezprozy/ezproxy", "The fully-qualified path to the EZproxy application/binary file") + + flag.StringVar(&config.Username, "username", "", "The name of the username to use when searching for active sessions") + + flag.BoolVar(&config.TerminateSessions, "terminate", false, "Whether active sessions for specified user should be terminated") + flag.BoolVar(&config.TerminateSessions, "kill", false, "Whether active sessions for specified user should be terminated") + + flag.IntVar(&config.SearchDelay, "search-delay", 1, "The delay in seconds between search attempts.") + flag.IntVar(&config.SearchDelay, "sd", 1, "The delay in seconds between search attempts.") + flag.IntVar(&config.SearchDelay, "delay", 1, "The delay in seconds between search attempts.") + + flag.IntVar(&config.SearchRetries, "search-retries", 10, "The number of additional retries allowed when receiving zero search results") + flag.IntVar(&config.SearchRetries, "sr", 10, "The number of additional retries allowed when receiving zero search results") + flag.IntVar(&config.SearchRetries, "retries", 10, "The number of additional retries allowed when receiving zero search results") + + flag.Usage = flagsUsage() + flag.Parse() + + handleError := func(err error) { + if err != nil { + log.Error(err.Error()) + + // fine for this standalone tool, but *not* how we can implement this + // within brick itself + os.Exit(1) + } + } + + // flag validation + if flagsErr := validate(config); flagsErr != nil { + handleError(flagsErr) + } + + reader, err := activefile.NewReader(config.Username, config.ActiveFilePath) + handleError(err) + + // Adjust stubbornness of newly created reader (overridding library/package + // default values with our own) + handleError(reader.SetSearchDelay(config.SearchDelay)) + handleError(reader.SetSearchRetries(config.SearchRetries)) + + log.Infof("Searching %q for %q", config.ActiveFilePath, config.Username) + activeSessions, err := reader.MatchingUserSessions() + handleError(err) + + if len(activeSessions) == 0 { + log.Infof("No active sessions found for username %q", config.Username) + return + } + + fmt.Printf( + "\nSessions (%d) active for username %q:\n\n", + len(activeSessions), + config.Username, + ) + + for _, session := range activeSessions { + fmt.Printf( + "Username: %q, SessionID: %q, IP Address: %q\n", + session.Username, + session.SessionID, + session.IPAddress, + ) + } + + if config.TerminateSessions { + + results := activeSessions.Terminate(config.EZproxyExecutable) + + writer := tabwriter.NewWriter(os.Stdout, 0, 8, 1, '\t', tabwriter.AlignRight) + + fmt.Fprintf(writer, "ID\tExitCode\tStdOut\tStdErr\tErrorMsg\n") + + // Separator row + // TODO: I'm sure this can be handled better + fmt.Fprintln(writer, "---\t---\t---\t---\t---\t") + + var termSuccess int + + for _, result := range results { + if result.ExitCode == ezproxy.KillSubCmdExitCodeSessionTerminated { + termSuccess++ + } + } + + fmt.Printf("\nSessions (%d) terminated:\n\n", termSuccess) + + // check the results, report any issues + for _, result := range results { + + // guard against (nil) lack of error in results slice entry + errStr := "" + if result.Error != nil { + errStr = result.Error.Error() + } + + fmt.Fprintf(writer, "%s\t%d\t%s\t%s\t%s\t\n", + result.SessionID, + result.ExitCode, + result.StdOut, + result.StdErr, + errStr, + ) + } + fmt.Fprintln(writer) + writer.Flush() + + } +} diff --git a/cmd/es/validate.go b/cmd/es/validate.go new file mode 100644 index 00000000..67cbc4ec --- /dev/null +++ b/cmd/es/validate.go @@ -0,0 +1,59 @@ +// Copyright 2020 Adam Chalkley +// +// https://github.com/atc0005/brick +// +// 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 +// +// https://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. + +package main + +import ( + "fmt" +) + +func validate(config AppConfig) error { + + if config.Username == "" { + return fmt.Errorf( + "error: missing username", + ) + } + + if config.ActiveFilePath == "" { + return fmt.Errorf( + "error: missing filename", + ) + } + + if config.EZproxyExecutable == "" { + return fmt.Errorf( + "error: missing EZproxy executable name", + ) + } + + if config.SearchDelay < 0 { + return fmt.Errorf( + "%d is not a valid number of seconds for search delay", + config.SearchDelay, + ) + } + + if config.SearchRetries < 0 { + return fmt.Errorf( + "%d is not a valid number of search retries", + config.SearchRetries, + ) + } + + return nil + +} diff --git a/cmd/ezproxy/main.go b/cmd/ezproxy/main.go new file mode 100644 index 00000000..c2603bf0 --- /dev/null +++ b/cmd/ezproxy/main.go @@ -0,0 +1,136 @@ +// Copyright 2020 Adam Chalkley +// +// https://github.com/atc0005/brick +// +// 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 +// +// https://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. + +package main + +import ( + "fmt" + "math/rand" + "os" + "regexp" + "strings" + "time" + + "github.com/apex/log" + + "github.com/atc0005/go-ezproxy" +) + +type Results struct { + ExitOutput string + ExitCode int +} + +func main() { + + log.SetLevel(log.InfoLevel) + + log.Debug("Starting EZproxy mock binary") + + // called as: ezproxy kill SESSION_ID_HERE + + switch len(os.Args) { + + // ezproxy (without subcommand or arguments) + case 1: + fmt.Println("missing subcommand") + os.Exit(1) + + // ezproxy kill (without specifying session ID) + case 2: + + // subcommand is present, but not session ID + if strings.EqualFold(os.Args[1], ezproxy.SubCmdNameSessionTerminate) { + fmt.Println(ezproxy.KillSubCmdExitTextSessionNotSpecified) + os.Exit(ezproxy.KillSubCmdExitCodeSessionNotSpecified) + } + + // caller didn't even get the subcommand right, complain about that + // and don't even mention that session id wasn't provided + fmt.Println("invalid subcommand") + os.Exit(1) + + // ezproxy kill SESSION_ID_HERE (valid number of arguments) + case 3: + + // verify that the correct subcommand was used + if !strings.EqualFold(os.Args[1], ezproxy.SubCmdNameSessionTerminate) { + fmt.Println("invalid subcommand") + os.Exit(1) + } + + // ensure that the provided value is of the length previously observed + // in the active users file + // + // TODO: Try to find an official OCLC reference for the session ID + // restrictions (characters, length, etc) + if len(os.Args[2]) < ezproxy.SessionIDLength { + fmt.Println("invalid session ID length provided") + os.Exit(1) + } + + matchOK, matchErr := regexp.MatchString( + ezproxy.SessionIDRegex, + os.Args[2], + ) + if !matchOK { + if matchErr != nil { + log.Error(matchErr.Error()) + } + fmt.Println("invalid session ID pattern provided") + os.Exit(1) + } + + resultCodes := []Results{ + { + ExitOutput: fmt.Sprintf( + ezproxy.KillSubCmdExitTextTemplateSessionTerminated, + os.Args[2], + ), + ExitCode: ezproxy.KillSubCmdExitCodeSessionTerminated, + }, + { + ExitOutput: fmt.Sprintf( + ezproxy.KillSubCmdExitTextTemplateSessionDoesNotExist, + os.Args[2], + ), + ExitCode: ezproxy.KillSubCmdExitCodeSessionDoesNotExist, + }, + { + ExitOutput: "Did you know that fish sticks pizza is really a thing?", + ExitCode: 2, + }, + { + ExitOutput: "Did you know that macaroni pizza is really a thing?", + ExitCode: 2, + }, + } + + // randomly return one of the result codes from the list above + seed := rand.NewSource(time.Now().UnixNano()) + rng := rand.New(seed) + randomResultIdx := rng.Intn(len(resultCodes)) + fmt.Println(resultCodes[randomResultIdx].ExitOutput) + os.Exit(resultCodes[randomResultIdx].ExitCode) + + default: + + fmt.Println("too many options provided, I can't decide!") + os.Exit(1) + + } + +} diff --git a/config/config.go b/config/config.go index edf54302..8142d2ab 100644 --- a/config/config.go +++ b/config/config.go @@ -28,11 +28,11 @@ import ( "github.com/pelletier/go-toml" ) -// version is updated via Makefile builds by referencing the fully-qualified +// Version is updated via Makefile builds by referencing the fully-qualified // path to this variable, including the package. We set a placeholder value so // that something resembling a version string will be provided for // non-Makefile builds. -var version string = "x.y.z" +var Version string = "x.y.z" func (c *Config) String() string { return fmt.Sprintf( @@ -72,12 +72,12 @@ func (c *Config) String() string { ) } -// Version emit version information and associated branding details whenever +// Version emits version information and associated branding details whenever // the user specifies the `--version` flag. The application exits after // displaying this information. func (c configTemplate) Version() string { return fmt.Sprintf("\n%s %s\n%s\n\n", - MyAppName, version, MyAppURL) + MyAppName, Version, MyAppURL) } // Description emits branding information whenever the user specifies the `-h` @@ -111,8 +111,8 @@ func MessageTrailer() string { "Message generated by [%s](%s) (%s) at %s", MyAppName, MyAppURL, - version, - time.Now().Format(time.RFC3339), + Version, + time.Now().Format(time.RFC3339Nano), ) } diff --git a/config/constants.go b/config/constants.go index 25efedc8..7b742f4c 100644 --- a/config/constants.go +++ b/config/constants.go @@ -42,6 +42,11 @@ const ( defaultLogOutput string = "stdout" defaultLogFormat string = "text" + // This application does not assume a specific path for the configuration + // file, so we default to an empty string if the user does not specify a + // value via CLI or environment variable. + defaultConfigFile string = "" + // This is appended to each username as it is written to the file in order // for EZproxy to treat the user account as ineligible to login defaultDisabledUsersFileEntrySuffix string = "::deny" @@ -65,6 +70,28 @@ const ( // the number of seconds to wait between retry attempts; applies to // Microsoft Teams notifications only defaultMSTeamsDelay int = 5 + + // this is based on the official installation instructions + 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 string = "/usr/local/ezproxy/ezproxy.hst" + + // Audit logs are stored in this path. + 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 int = 7 + + // Delay between search attempts in seconds + defaultEZproxySearchDelay int = 1 + + // Session termination is disabled by default + defaultEZproxyTerminateSessions bool = false ) // TODO: Expose these settings via flags, config file diff --git a/config/getters.go b/config/getters.go index 147cd1dd..05c1dc68 100644 --- a/config/getters.go +++ b/config/getters.go @@ -184,6 +184,7 @@ func (c Config) IgnoredUsersFile() string { // IsSetIgnoredUsersFile indicates whether a user-provided path to the file // containing a list of user accounts which should not be disabled and whose // associated IP should not be banned by this application was provided. +// DEPRECATED: See GH-46 func (c Config) IsSetIgnoredUsersFile() bool { switch { case c.cliConfig.IgnoredUsers.File != nil: @@ -212,6 +213,7 @@ func (c Config) IgnoredIPAddressesFile() string { // IsSetIgnoredIPAddressesFile indicates whether a user-provided path to the // file containing a list of individual IP Addresses which should not be // banned by this application was provided. +// DEPRECATED: See GH-46 func (c Config) IsSetIgnoredIPAddressesFile() bool { switch { case c.cliConfig.IgnoredIPAddresses.File != nil: @@ -224,16 +226,20 @@ func (c Config) IsSetIgnoredIPAddressesFile() bool { } // ConfigFile returns the user-provided path to the config file for this -// application or an empty string otherwise. +// application or the default value if not provided. CLI flag or environment +// variables are the only way to specify a value for this setting. func (c Config) ConfigFile() string { switch { case c.cliConfig.ConfigFile != nil: return *c.cliConfig.ConfigFile default: - return "" + return defaultConfigFile } } +// IgnoreLookupErrors returns the user-provided choice regarding ignoring +// lookup errors or the default value if not provided. CLI flag values take +// precedence if provided. func (c Config) IgnoreLookupErrors() bool { switch { case c.cliConfig.IgnoreLookupErrors != nil: @@ -245,6 +251,9 @@ func (c Config) IgnoreLookupErrors() bool { } } +// DisabledUsersFileEntrySuffix returns the user-provided disabled users entry +// suffix or the default value if not provided. CLI flag values take +// precedence if provided. func (c Config) DisabledUsersFileEntrySuffix() string { // TODO: Set this as a method on the DisabledUsers type instead/also? switch { @@ -257,6 +266,9 @@ func (c Config) DisabledUsersFileEntrySuffix() string { } } +// TeamsWebhookURL returns the user-provided webhook URL used for Teams +// notifications or the default value if not provided. CLI flag values take +// precedence if provided. func (c Config) TeamsWebhookURL() string { switch { @@ -350,3 +362,88 @@ func (c Config) EmailNotificationDelay() int { return 0 } + +// EZproxyExecutablePath returns the user-provided, fully-qualified path to +// the EZproxy executable or the default value if not provided. CLI flag +// values take precedence if provided. +func (c Config) EZproxyExecutablePath() string { + switch { + case c.cliConfig.EZproxy.ExecutablePath != nil: + return *c.cliConfig.EZproxy.ExecutablePath + case c.fileConfig.EZproxy.ExecutablePath != nil: + return *c.fileConfig.EZproxy.ExecutablePath + default: + return defaultEZproxyExecutablePath + } +} + +// EZproxyActiveFilePath returns the user-provided, fully-qualified path to +// the EZproxy Active Users and Hosts "state" file or the default value if not +// provided. CLI flag values take precedence if provided. +func (c Config) EZproxyActiveFilePath() string { + switch { + case c.cliConfig.EZproxy.ActiveFilePath != nil: + return *c.cliConfig.EZproxy.ActiveFilePath + case c.fileConfig.EZproxy.ActiveFilePath != nil: + return *c.fileConfig.EZproxy.ActiveFilePath + default: + return defaultEZproxyActiveFilePath + } +} + +// EZproxyAuditFileDirPath returns the user-provided, fully-qualified path to +// the EZproxy audit files directory or the default value if not provided. CLI +// flag values take precedence if provided. +func (c Config) EZproxyAuditFileDirPath() string { + switch { + case c.cliConfig.EZproxy.AuditFileDirPath != nil: + return *c.cliConfig.EZproxy.AuditFileDirPath + case c.fileConfig.EZproxy.AuditFileDirPath != nil: + return *c.fileConfig.EZproxy.AuditFileDirPath + default: + return defaultEZproxyAuditFileDirPath + } +} + +// EZproxySearchRetries returns the user-provided number of retry attempts to +// make for session lookup attempts that return zero results or the default +// value if not provided. CLI flag values take precedence if provided. +func (c Config) EZproxySearchRetries() int { + switch { + case c.cliConfig.EZproxy.SearchRetries != nil: + return *c.cliConfig.EZproxy.SearchRetries + case c.fileConfig.EZproxy.SearchRetries != nil: + return *c.fileConfig.EZproxy.SearchRetries + default: + return defaultEZproxySearchRetries + } +} + +// EZproxySearchDelay returns the user-provided number of seconds between +// session lookup attempts or the default value if not provided. CLI flag +// values take precedence if provided. +func (c Config) EZproxySearchDelay() int { + switch { + case c.cliConfig.EZproxy.SearchDelay != nil: + return *c.cliConfig.EZproxy.SearchDelay + case c.fileConfig.EZproxy.SearchDelay != nil: + return *c.fileConfig.EZproxy.SearchDelay + default: + return defaultEZproxySearchDelay + } +} + +// EZproxyTerminateSessions indicates whether attempts should be made to +// terminate sessions for reported user accounts. The user-provided value is +// returned or the default value if not provided. CLI flag values take +// precedence if provided. +func (c Config) EZproxyTerminateSessions() bool { + switch { + case c.cliConfig.EZproxy.TerminateSessions != nil: + return *c.cliConfig.EZproxy.TerminateSessions + case c.fileConfig.EZproxy.TerminateSessions != nil: + return *c.fileConfig.EZproxy.TerminateSessions + default: + return defaultEZproxyTerminateSessions + } +} diff --git a/config/types.go b/config/types.go index 6fcfdf28..143a059c 100644 --- a/config/types.go +++ b/config/types.go @@ -147,6 +147,60 @@ 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."` } +// EZproxy represents that various configuration settings used to interact +// with EZproxy and files/settings used by EZproxy. +type EZproxy struct { + + // ExecutablePath is 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. + ExecutablePath *string `toml:"executable_path" arg:"--ezproxy-executable-path,env:BRICK_EZPROXY_EXECUTABLE_PATH" help:"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."` + + // ActiveFilePath is 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. + ActiveFilePath *string `toml:"active_file_path" arg:"--ezproxy-active-file-path,env:BRICK_EZPROXY_ACTIVE_FILE_PATH" help:"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."` + + // AuditFileDirPath is 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). + AuditFileDirPath *string `toml:"audit_file_dir_path" arg:"--ezproxy-audit-file-dir-path,env:BRICK_EZPROXY_AUDIT_FILE_DIR_PATH" help:"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)."` + + // SearchRetries is 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). + SearchRetries *int `toml:"search_retries" arg:"--ezproxy-search-retries,env:BRICK_EZPROXY_SEARCH_RETRIES" help:"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)."` + + // SearchDelay is 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. + SearchDelay *int `toml:"search_retries" arg:"--ezproxy-search-delay,env:BRICK_EZPROXY_SEARCH_DELAY" help:"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."` + + // TerminateSessions controls 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. + TerminateSessions *bool `toml:"terminate_sessions" arg:"--ezproxy-terminate-sessions,env:BRICK_EZPROXY_TERMINATE_SESSIONS" help:"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."` +} + // configTemplate is our base configuration template used to collect values // specified by various configuration sources. This template struct is // embedded within the main Config struct once for each config source. @@ -162,6 +216,7 @@ type configTemplate struct { IgnoredUsers IgnoredIPAddresses MSTeams + 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 45da6a9f..c96f1878 100644 --- a/config/validate.go +++ b/config/validate.go @@ -122,14 +122,16 @@ func validate(c Config) error { return fmt.Errorf("path to reported users log file not provided") } - // Verify that the user did not option to set an empty string as the - // value, otherwise we fail the config validation by returning an error. + // Verify that the user did not opt to set an empty string as the value, + // otherwise we fail the config validation by returning an error. + // DEPRECATED: See GH-46 if c.IsSetIgnoredUsersFile() && c.IgnoredUsersFile() == "" { return fmt.Errorf("empty path to ignored users file provided") } - // Verify that the user did not option to set an empty string as the - // value, otherwise we fail the config validation by returning an error. + // Verify that the user did not opt to set an empty string as the value, + // otherwise we fail the config validation by returning an error. + // DEPRECATED: See GH-46 if c.IsSetIgnoredIPAddressesFile() && c.IgnoredIPAddressesFile() == "" { return fmt.Errorf("empty path to ignored ip addresses file provided") } @@ -167,6 +169,34 @@ func validate(c Config) error { ) } + if c.EZproxyExecutablePath() == "" { + return fmt.Errorf("path to EZproxy executable file not provided") + } + + if c.EZproxyActiveFilePath() == "" { + return fmt.Errorf("path to EZproxy active users state file not provided") + } + + if c.EZproxyAuditFileDirPath() == "" { + return fmt.Errorf("path to EZproxy audit file directory not provided") + } + + if c.EZproxySearchDelay() < 0 { + log.Debugf("unsupported delay specified for EZproxy session lookup attempts: %d ", c.EZproxySearchDelay()) + return fmt.Errorf( + "invalid delay specified for EZproxy session lookup attempts: %d", + c.EZproxySearchDelay(), + ) + } + + if c.EZproxySearchRetries() < 0 { + log.Debugf("unsupported retry limit specified for EZproxy session lookup attempts: %d ", c.EZproxySearchRetries()) + return fmt.Errorf( + "invalid retries limit specified for EZproxy session lookup attempts: %d", + c.EZproxySearchRetries(), + ) + } + // if we made it this far then we signal all is well return nil diff --git a/contrib/brick/config.example.toml b/contrib/brick/config.example.toml index 2e5d8f46..62fd76d1 100644 --- a/contrib/brick/config.example.toml +++ b/contrib/brick/config.example.toml @@ -120,3 +120,50 @@ delay = 5 # smtp_server_secure = "" # retries = 2 # delay = 2 + + +[ezproxy] + +# Fully-qualified path to the EZproxy executable/binary. This is the same +# executable that starts at boot. This file is usually named 'ezproxy'. +executable_path = "/usr/local/ezproxy/ezproxy" + +# 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. +active_file_path = "/usr/local/ezproxy/ezproxy.hst" + +# 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). +audit_file_dir_path = "/usr/local/ezproxy/audit" + +# 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). +search_retries = 7 + +# 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. +search_delay = 1 + +# 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. +terminate_sessions = false diff --git a/contrib/demo/scripts/01-install-docker.sh b/contrib/demo/scripts/01-install-docker.sh index 15101d07..a6c8a526 100644 --- a/contrib/demo/scripts/01-install-docker.sh +++ b/contrib/demo/scripts/01-install-docker.sh @@ -19,7 +19,7 @@ # Purpose: Install Docker for use in demo environment if [[ "$UID" -eq 0 ]]; then - echoerr "Run this script without sudo or as root, sudo will be called as needed." + echo "Run this script without sudo or as root, sudo will be called as needed." exit 1 fi diff --git a/contrib/demo/scripts/02-install-dependencies.sh b/contrib/demo/scripts/02-install-dependencies.sh index 5b513132..82efaa20 100644 --- a/contrib/demo/scripts/02-install-dependencies.sh +++ b/contrib/demo/scripts/02-install-dependencies.sh @@ -19,7 +19,7 @@ # Purpose: Install core dependencies of this application. if [[ "$UID" -eq 0 ]]; then - echoerr "Run this script without sudo or as root, sudo will be called as needed." + echo "Run this script without sudo or as root, sudo will be called as needed." exit 1 fi @@ -45,9 +45,10 @@ then # install Go toolchain echo "Downloading and installing Go toolchain" cd /tmp - rm -f go1.14.3.linux-amd64.tar.gz - curl -L -O https://dl.google.com/go/go1.14.3.linux-amd64.tar.gz - sudo tar zxf go1.14.3.linux-amd64.tar.gz -C /usr/local/ + export GO_VERSION="1.14.4" + rm -f go1.*.linux-amd64.tar.gz + curl -L -O https://dl.google.com/go/go${GO_VERSION}.linux-amd64.tar.gz + sudo tar zxf go${GO_VERSION}.linux-amd64.tar.gz -C /usr/local/ /usr/local/go/bin/go version else echo "Go toolchain already present" diff --git a/contrib/demo/scripts/03-install-demo-tooling.sh b/contrib/demo/scripts/03-install-demo-tooling.sh index b0ccb64e..bfffd919 100644 --- a/contrib/demo/scripts/03-install-demo-tooling.sh +++ b/contrib/demo/scripts/03-install-demo-tooling.sh @@ -19,7 +19,7 @@ # Purpose: Install applications specific to demo environment if [[ "$UID" -eq 0 ]]; then - echoerr "Run this script without sudo or as root, sudo will be called as needed." + echo "Run this script without sudo or as root, sudo will be called as needed." exit 1 fi diff --git a/contrib/demo/scripts/04-setup-env.sh b/contrib/demo/scripts/04-setup-env.sh index aaeb4133..698f3e15 100644 --- a/contrib/demo/scripts/04-setup-env.sh +++ b/contrib/demo/scripts/04-setup-env.sh @@ -22,6 +22,11 @@ # Create user account sudo useradd --system --shell /bin/false brick +# Setup test ezproxy directory +sudo mkdir -vp /usr/local/ezproxy/audit +sudo chown -Rv brick:$USER /usr/local/ezproxy +sudo chmod -Rv u=rwX,g=rwX,o= brick:$USER /usr/local/ezproxy + # Setup output directories sudo mkdir -vp /usr/local/etc/brick sudo mkdir -vp /var/log/brick diff --git a/contrib/demo/scripts/05-deploy.sh b/contrib/demo/scripts/05-deploy.sh index 64be3cc1..c3b2e1c1 100644 --- a/contrib/demo/scripts/05-deploy.sh +++ b/contrib/demo/scripts/05-deploy.sh @@ -27,17 +27,25 @@ # current working directory. if [[ "$UID" -eq 0 ]]; then - echoerr "Run this script without sudo or as root, sudo will be called as needed." + echo "Run this script without sudo or as root, sudo will be called as needed." exit 1 fi -# Build application +# Build applications go build -mod=vendor ../../../cmd/brick/ +go build -mod=vendor ../../../cmd/es/ +go build -mod=vendor ../../../cmd/ezproxy/ -# Deploy application +# Deploy applications sudo service brick stop sudo mv -vf brick /usr/local/sbin/brick +sudo mv -vf ezproxy /usr/local/ezproxy/ezproxy +sudo mv -vf es /usr/local/sbin/es + +# Set executable bit sudo chmod -v +x /usr/local/sbin/brick +sudo chmod -v +x /usr/local/ezproxy/ezproxy +sudo chmod -v +x /usr/local/sbin/es # Start app sudo systemctl enable brick diff --git a/contrib/demo/scripts/06-reset-env.sh b/contrib/demo/scripts/06-reset-env.sh index 81e4a5b0..8a1aace7 100644 --- a/contrib/demo/scripts/06-reset-env.sh +++ b/contrib/demo/scripts/06-reset-env.sh @@ -27,7 +27,7 @@ # current working directory. if [[ "$UID" -eq 0 ]]; then - echoerr "Run this script without sudo or as root, sudo will be called as needed." + echo "Run this script without sudo or as root, sudo will be called as needed." exit 1 fi diff --git a/docs/configure.md b/docs/configure.md index 6a8d2d87..2fdbe484 100644 --- a/docs/configure.md +++ b/docs/configure.md @@ -33,26 +33,32 @@ 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 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 disabled 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 which 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-delay` | No | `5` | No | *valid whole number* | The number of seconds to wait between Microsoft Teams message 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. | +| 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 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 disabled 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 which 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-delay` | No | `5` | No | *valid whole number* | The number of seconds to wait between Microsoft Teams message 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. | ## Environment Variables @@ -80,6 +86,12 @@ Arguments](#command-line-arguments) table for more information. | `teams-webhook-url` | `BRICK_MSTEAMS_WEBHOOK_URL` | | `BRICK_MSTEAMS_WEBHOOK_URL="https://outlook.office.com/webhook/a1269812-6d10-44b1-abc5-b84f93580ba0@9e7b80c7-d1eb-4b52-8582-76f921e416d9/IncomingWebhook/3fdd6767bae44ac58e5995547d66a4e4/f332c8d9-3397-4ac5-957b-b8e3fc465a8c"` | | `teams-notify-delay` | `BRICK_MSTEAMS_WEBHOOK_DELAY` | | `BRICK_MSTEAMS_WEBHOOK_DELAY="2"` | | `teams-notify-retries` | `BRICK_MSTEAMS_WEBHOOK_RETRIES` | | `BRICK_MSTEAMS_WEBHOOK_RETRIES="5"` | +| `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"` | +| `ezproxy-search-retries` | `BRICK_EZPROXY_SEARCH_RETRIES` | | `BRICK_EZPROXY_SEARCH_RETRIES="7"` | +| `ezproxy-search-delay` | `BRICK_EZPROXY_SEARCH_DELAY` | | `BRICK_EZPROXY_SEARCH_DELAY="1"` | +| `ezproxy-terminate-sessions` | `BRICK_EZPROXY_TERMINATE_SESSIONS` | | `BRICK_EZPROXY_TERMINATE_SESSIONS="false"` | ## Configuration File @@ -107,6 +119,12 @@ settings. | `teams-webhook-url` | `webhook_url` | `msteams` | | | `teams-notify-delay` | `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` | | The [`contrib/brick/config.example.toml`](../contrib/brick/config.example.toml) diff --git a/docs/demo.md b/docs/demo.md index 90112473..9031439c 100644 --- a/docs/demo.md +++ b/docs/demo.md @@ -73,8 +73,9 @@ This demo is intended to answer that question by covering: 1. Add `/var/log/brick` path to workspace 1. Add `/usr/local/etc/brick` path to workspace 1. Add `/var/cache/brick` path to workspace +1. Add `/usr/local/ezproxy` path to workspace 1. Install extensions - - `Better TOML` + - `Even Better TOML` (`tamasfe.even-better-toml`) 1. Configure settings ```json @@ -105,6 +106,8 @@ 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 +1. Set `ezproxy.terminate_sessions` to `true` + - TODO: Decide if this will be enabled initially, or as a follow-up item ### Terminal @@ -136,7 +139,7 @@ Configure `guake` for demo: 1. `disabled user entries` - `/var/cache/brick/users.brick-disabled.txt` 1. `reported user entries` - - `/var/log/brick/users.brick-reported.txt` + - `/var/log/brick/users.brick-reported.log` 1. `fail2ban log` - `/var/log/fail2ban.log` @@ -395,9 +398,12 @@ The following enhancement requests would help regardless of whether we use - Add support for dynamically denying access to specified IPs - without restarting EZproxy -- Add support to terminate active user sessions via API or other external - control +- Add (official) support to terminate active user sessions via API or other + external control - without restarting EZproxy + - Note: This support is available as of right now (learned this after the + May 2020 demo), but OCLC Support has indicated it isn't official and could + go away without notice. ## References diff --git a/docs/references.md b/docs/references.md index 0101a1e4..2be210d8 100644 --- a/docs/references.md +++ b/docs/references.md @@ -36,6 +36,7 @@ absolutely essential) while developing this application. - `go-teams-notify` - (upstream) - (fork) + - - External - [Splunk](https://www.splunk.com/​) @@ -45,6 +46,12 @@ absolutely essential) while developing this application. ### Instruction / Examples - EZproxy + - + - + - + - + - + - - - @@ -52,6 +59,9 @@ 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) - [Splunk Enterprise > Getting Data In > How timestamp assignment works > How Splunk software assigns timestamps](https://docs.splunk.com/Documentation/Splunk/latest/Data/HowSplunkextractstimestamps) diff --git a/events/record.go b/events/record.go index e21bc44a..0c0e2f86 100644 --- a/events/record.go +++ b/events/record.go @@ -16,21 +16,36 @@ package events -const ( - ActionSuccessDisabledUsername string = "Username disabled" - ActionSuccessDuplicateUsername string = "Username already disabled" - ActionSuccessIgnoredUsername string = "Username ignored due to ignore username entry" - ActionSuccessIgnoredIPAddress string = "Username ignored due to ignore IP entry" - - // ActionFailureDisabledUsername string = "" - // ActionFailureDuplicateUsername string = "" - // ActionFailureIgnoredUsername string = "" - // ActionFailureIgnoredIPAddress string = "" +import ( + "fmt" + + "github.com/apex/log" + + "github.com/atc0005/brick/internal/caller" + "github.com/atc0005/go-ezproxy" ) -// AlertResponse is meant to help indicate what specific action we took based -// on the received alert. -// type AlertResponse string +// This is a set of constants used with the Record.Action field to note the +// action taken in response to a received alert. The common use case is +// building a dynamic Title/Subject for various notifications. +const ( + ActionSuccessDisableRequestReceived string = "Disable user account request received" + ActionSuccessDisabledUsername string = "Username disabled" + ActionSuccessDuplicatedUsername string = "Username already disabled" + ActionSuccessIgnoredUsername string = "Username ignored due to ignore username entry" + ActionSuccessIgnoredIPAddress string = "Username ignored due to ignore IP entry" + ActionSuccessTerminatedUserSession string = "User sessions terminated" + + ActionSkippedTerminateUserSessions string = "User sessions termination not enabled; skipped" + + ActionFailureDisableRequestReceived string = "Disable user account request log failure" + ActionFailureDisabledUsername string = "Username disable failure" + ActionFailureDuplicatedUsername string = "Username (duplicate) disable failure" + ActionFailureIgnoredUsername string = "Username ignore status check failure" + ActionFailureIgnoredIPAddress string = "IP Address ignore status check failure" + ActionFailureUserSessionLookupFailure string = "Failed to lookup user sessions" + ActionFailureTerminatedUserSession string = "User session termination failure" +) // Record is a collection of details that is saved to log files, sent by // Microsoft Teams or email; this is a superset of types. This type contains @@ -42,15 +57,141 @@ type Record struct { // notifications and log entries Alert SplunkAlertEvent - // Error optionally identifies the latest error with the associated event + // Error optionally identifies the latest error with the associated event. + // For Teams messages, this field is added as a "Fact" pair. Error error // Note is the additional message text used in notifications and log - // entries - // FIXME: Not a fan of the field name + // entries. This field is not mutually exclusive; this field is displayed + // alongside any error referenced by the `Error` field. For Teams + // messages, this field is added as unformatted text via the associated + // section's `Text` field. + // + // TODO: Rename this to `Summary?` Note string - // Action notes what this application did in response to a received alert - // Action AlertResponse + // Action briefly indicates what this application did in response to a + // received alert. Values assigned to this field should only come from a + // set of predefined constants in order to ensure consistency of log + // messages, etc. This value is often used in single-event alert + // notifications as part of the Subject or Title. Action string + + // SessionTerminationResults is a collection of results from attempts to + // terminate sessions for the username specified in the alert payload. + SessionTerminationResults []ezproxy.TerminateUserSessionResult +} + +// NewRecord is a factory function that creates a Record from provided +// values. This function mostly exists as a way of having the compiler enforce +// that all required values for notifications are present. +func NewRecord( + alert SplunkAlertEvent, + err error, + note string, + action string, + terminationResults []ezproxy.TerminateUserSessionResult, +) Record { + + record := Record{ + Alert: alert, + Error: err, + Note: note, + Action: action, + SessionTerminationResults: terminationResults, + } + + if valid, err := record.Valid(); !valid { + log.Errorf( + "invalid Record '%#v' created at %s: %w", + record, + // Get the details for where NewRecord() was called, not the + // details of where we are calling GetParentFuncFileLineInfo. + caller.GetParentFuncFileLineInfo(), + err, + ) + } + + return record + +} + +// Valid is a helper method to perform light validation on Record fields in +// order to ensure that expected values are present. This is particularly +// important for the Action and Notes fields as they're heavily used in +// generated notifications. +func (rc Record) Valid() (bool, error) { + + // validate values + + // rely on compiler enforcing a valid SplunkAlertEvent is provided + // alert + + // rely again on compiler + // err + + // This is optional if an error value is already provided. The plan is to + // have the msgCard.Text field use the Note field is if it is available, + // otherwise call back to the Error field value. + if rc.Error == nil { + if rc.Note == "" { + return false, fmt.Errorf( + "empty or invalid Note field value provided: %s", + rc.Note, + ) + } + } + + switch rc.Action { + case "": + case ActionSuccessDisableRequestReceived: + case ActionSuccessDisabledUsername: + case ActionSuccessDuplicatedUsername: + case ActionSuccessIgnoredUsername: + case ActionSuccessIgnoredIPAddress: + case ActionSuccessTerminatedUserSession: + case ActionSkippedTerminateUserSessions: + case ActionFailureDisableRequestReceived: + case ActionFailureDisabledUsername: + case ActionFailureDuplicatedUsername: + case ActionFailureIgnoredUsername: + case ActionFailureIgnoredIPAddress: + case ActionFailureUserSessionLookupFailure: + case ActionFailureTerminatedUserSession: + default: + return false, fmt.Errorf( + "empty or invalid Action field value provided: %s", + rc.Action, + ) + } + + return true, nil + +} + +// Records is a collection of Record values intended to allow easier bulk +// processing of event details. +type Records []Record + +// Add is a helper method to collect event Records for later processing. +func (rcs *Records) Add(record Record) { + + // if pc, file, line, ok := runtime.Caller(1); ok { + // log.Warnf( + // "func %s called (from %q, line %d): ", + // runtime.FuncForPC(pc).Name(), + // file, + // line, + // ) + // } + + // TODO: Apply field validation to ensure we're collecting the absolute + // minimum required details for later use. For example, we may need to + // enforce that the Action field of a provided event Record only contains + // known/valid Action constants for later use. + + // dereference pointer to Records to get access to slice + // https://blog.golang.org/slices + *rcs = append(*rcs, record) + } diff --git a/files/files.go b/files/files.go index 7459f1fa..54f0b36c 100644 --- a/files/files.go +++ b/files/files.go @@ -24,12 +24,15 @@ import ( "text/template" "github.com/atc0005/brick/events" + "github.com/atc0005/go-ezproxy" ) // fileEntry represents the values that are used when generating entries via -// templates for flat-files: disable user accounts, log file of actions taken +// templates for flat-files associated with disabling user accounts, +// terminating active user sessions and logging actions taken. type fileEntry struct { - events.SplunkAlertEvent + Alert events.SplunkAlertEvent + UserSession ezproxy.UserSession EntrySuffix string IgnoredEntriesFile string } @@ -106,9 +109,9 @@ type ReportedUserEventsLog struct { // the user account is disabled. DisableFirstEventTemplate *template.Template - // DisableRepeatEventTemplate is a parsed template representing the log line written - // when a user account is reported via alert payload again after the user - // account is already disabled. + // DisableRepeatEventTemplate is a parsed template representing the log + // line written when a user account is reported via alert payload again + // after the user account is already disabled. DisableRepeatEventTemplate *template.Template // IgnoreTemplate is a parsed template representing the log line written @@ -116,6 +119,12 @@ type ReportedUserEventsLog struct { // or associated IP Address is ignored due to its presence in either the // specified "safe" or "ignored" user accounts file or IP Addresses file. IgnoreTemplate *template.Template + + // TerminateUserSessionEventTemplate is a parsed template representing the + // log line written when a user account is reported via alert payload and + // an associated user session is terminated. There may be multiple log + // lines, one for each active user session associated with the username. + TerminateUserSessionEventTemplate *template.Template } // IgnoredSources represents the various sources of "safe" or "ignore" entries @@ -144,15 +153,19 @@ func NewReportedUserEventsLog(path string, permissions os.FileMode) *ReportedUse ignoredUserEventTemplate := template.Must(template.New( "ignoredUserEventTemplate").Parse(ignoredUserEventTemplateText)) + terminatedUserSessionEventTemplate := template.Must(template.New( + "terminatedUserSessionEventTemplate").Parse(terminatedUserEventTemplateText)) + ruel := ReportedUserEventsLog{ FlatFile: FlatFile{ FilePath: path, FilePermissions: permissions, }, - ReportTemplate: reportedUserEventTemplate, - DisableFirstEventTemplate: disabledUserFirstEventTemplate, - DisableRepeatEventTemplate: disabledUserRepeatEventTemplate, - IgnoreTemplate: ignoredUserEventTemplate, + ReportTemplate: reportedUserEventTemplate, + DisableFirstEventTemplate: disabledUserFirstEventTemplate, + DisableRepeatEventTemplate: disabledUserRepeatEventTemplate, + IgnoreTemplate: ignoredUserEventTemplate, + TerminateUserSessionEventTemplate: terminatedUserSessionEventTemplate, } return &ruel diff --git a/files/log.go b/files/log.go index 52c8a074..ddc9a9a8 100644 --- a/files/log.go +++ b/files/log.go @@ -18,144 +18,468 @@ package files import ( "fmt" + "strings" "github.com/apex/log" "github.com/atc0005/brick/events" -) + "github.com/atc0005/brick/internal/caller" -func logEventReportingUsername(alert events.SplunkAlertEvent, reportedUserEventsLog *ReportedUserEventsLog) error { + "github.com/atc0005/go-ezproxy" +) - msgTemplate := "Disable request received from %q for username %q from IP %q" +// logEventDisableRequestReceived handles logging the event where a username +// has been reported by the remote monitoring system. This function emits the +// output to stdout for the init system to catch and also writes a templated +// message to the reported user events log for potential automation. +func logEventDisableRequestReceived(alert events.SplunkAlertEvent, reportedUserEventsLog *ReportedUserEventsLog) events.Record { - log.Debugf( - "LogEventReportedUser: "+msgTemplate, - alert.Username, + requestReceivedMessage := fmt.Sprintf( + "Disable request received from %q for username %q from IP %q", alert.PayloadSenderIP, + alert.Username, alert.UserIP, ) + log.Debug(caller.GetFuncFileLineInfo()) + log.Infof(requestReceivedMessage) + if err := appendToFile( fileEntry{ - SplunkAlertEvent: alert, + Alert: alert, }, reportedUserEventsLog.ReportTemplate, reportedUserEventsLog.FilePath, reportedUserEventsLog.FilePermissions, ); err != nil { - return fmt.Errorf( - "func LogEventReportedUser: error updating events log file %q: %w", + recordEventErr := fmt.Errorf( + "func %s: error updating events log file %q: %w", + caller.GetFuncName(), reportedUserEventsLog.FilePath, err, ) - } - log.Infof(msgTemplate, alert.PayloadSenderIP, alert.Username, alert.UserIP) + return events.NewRecord( + alert, + recordEventErr, + requestReceivedMessage, + events.ActionFailureDisableRequestReceived, + nil, + ) - return nil + } + + return events.NewRecord( + alert, + nil, + requestReceivedMessage, + events.ActionSuccessDisableRequestReceived, + nil, + ) } -func logEventDisablingUsername(alert events.SplunkAlertEvent, reportedUserEventsLog *ReportedUserEventsLog) error { +// logEventDisablingUsername handles logging the event where a username is +// being disabled. This function emits the output to stdout for the init +// system to catch. This function does NOT report the intent via +// notifications. +func logEventDisablingUsername(alert events.SplunkAlertEvent, reportedUserEventsLog *ReportedUserEventsLog) { msgTemplate := "Disabling username %q from IP %q per report from %q" - log.Debugf( - "LogEventDisablingUsername: "+msgTemplate, + log.Debug(caller.GetFuncFileLineInfo()) + + log.Infof(msgTemplate, alert.Username, alert.UserIP, alert.PayloadSenderIP) + +} + +// logEventDisabledUsername handles logging the event where a username +// has been successfully disabled. This function is responsible for emitting +// the success message to stdout for the init system to catch, write a +// templated message to the reported user events log for potential automation. +func logEventDisabledUsername(alert events.SplunkAlertEvent, reportedUserEventsLog *ReportedUserEventsLog) events.Record { + + disableSuccessMsg := fmt.Sprintf( + "Disabled username %q from IP %q per report from %q", alert.Username, alert.UserIP, alert.PayloadSenderIP, ) + log.Debug(caller.GetFuncFileLineInfo()) + + // emit to stdout right away in case we have problems recording this event + // in the report users event log + log.Info(disableSuccessMsg) + if err := appendToFile( fileEntry{ - SplunkAlertEvent: alert, + Alert: alert, }, reportedUserEventsLog.DisableFirstEventTemplate, reportedUserEventsLog.FilePath, reportedUserEventsLog.FilePermissions, ); err != nil { - return fmt.Errorf( - "func LogEventDisablingUsername: error updating events log file %q: %w", + recordEventErr := fmt.Errorf( + "func %s: error updating events log file %q: %w", + caller.GetFuncName(), reportedUserEventsLog.FilePath, err, ) - } - log.Infof(msgTemplate, alert.Username, alert.UserIP, alert.PayloadSenderIP) + return events.NewRecord( + alert, + recordEventErr, + disableSuccessMsg, + events.ActionFailureDisabledUsername, + nil, + ) + } - return nil + return events.NewRecord( + alert, + nil, + disableSuccessMsg, + events.ActionSuccessDisabledUsername, + nil, + ) } -func logEventUserAlreadyDisabled(alert events.SplunkAlertEvent, reportedUserEventsLog *ReportedUserEventsLog) error { - - msgTemplate := "Username %q already disabled (current IP %q per report from %q)" - - log.Debugf( - "logEventUserAlreadyDisabled: "+msgTemplate, +// logEventUsernameAlreadyDisabled handles logging the event where a username +// is already disabled, but another request has arrived to disable it, usually +// as a result of account compromise/sharing. This function emits the output +// to stdout for the init system to catch and also writes a templated message +// to the reported user events log for potential automation. +func logEventUsernameAlreadyDisabled(alert events.SplunkAlertEvent, reportedUserEventsLog *ReportedUserEventsLog) events.Record { + + // alreadyDisabledMsg := fmt.Sprintf( + // "Received disable request from %q for user %q from IP %q;"+ + // " username is already disabled", + // alert.PayloadSenderIP, + // alert.Username, + // alert.UserIP, + // ) + + alreadyDisabledMsg := fmt.Sprintf( + "Username %q already disabled (current IP %q per report from %q)", alert.Username, alert.UserIP, alert.PayloadSenderIP, ) + log.Debug(caller.GetFuncFileLineInfo()) + log.Info(alreadyDisabledMsg) + if err := appendToFile( fileEntry{ - SplunkAlertEvent: alert, + Alert: alert, }, reportedUserEventsLog.DisableRepeatEventTemplate, reportedUserEventsLog.FilePath, reportedUserEventsLog.FilePermissions, ); err != nil { - return fmt.Errorf( - "func LogEventDisabledUser: error updating events log file %q: %w", + recordEventErr := fmt.Errorf( + "func %s: error updating events log file %q: %w", + caller.GetFuncName(), reportedUserEventsLog.FilePath, err, ) - } - log.Infof(msgTemplate, alert.Username, alert.UserIP, alert.PayloadSenderIP) + return events.NewRecord( + alert, + recordEventErr, + alreadyDisabledMsg, + events.ActionFailureDuplicatedUsername, + nil, + ) + + } - return nil + return events.NewRecord( + alert, + nil, + alreadyDisabledMsg, + events.ActionSuccessDuplicatedUsername, + nil, + ) } -func logEventIgnoringUsername(alert events.SplunkAlertEvent, reportedUserEventsLog *ReportedUserEventsLog, ignoredEntriesFile string) error { +// logEventIgnoredIPAddress handles logging the event where an IP Address has +// been ignored due to inclusion of that IP Address in an "ignore file" for IP +// Addresses. This function emits the output to stdout for the init system to +// catch and also writes a templated message to the reported user events log +// for potential automation. +func logEventIgnoredIPAddress(alert events.SplunkAlertEvent, reportedUserEventsLog *ReportedUserEventsLog, ignoredEntriesFile string) events.Record { + + ignoreIPAddressMsg := fmt.Sprintf( + "Ignored disable request from %q for user %q from IP %q due to presence in %q file.", + alert.PayloadSenderIP, + alert.Username, + alert.UserIP, + ignoredEntriesFile, + ) + + log.Debug(caller.GetFuncFileLineInfo()) + + log.Info(ignoreIPAddressMsg) + + if err := appendToFile( + fileEntry{ + Alert: alert, + IgnoredEntriesFile: ignoredEntriesFile, + }, + reportedUserEventsLog.IgnoreTemplate, + reportedUserEventsLog.FilePath, + reportedUserEventsLog.FilePermissions, + ); err != nil { + recordEventErr := fmt.Errorf( + "func %s: error updating events log file %q: %w", + caller.GetFuncName(), + reportedUserEventsLog.FilePath, + err, + ) + + return events.NewRecord( + alert, + recordEventErr, + ignoreIPAddressMsg, + events.ActionFailureIgnoredIPAddress, + nil, + ) + } - msgTemplate := "Ignoring disable request from %q for user %q from IP %q due to presence in %q file." + return events.NewRecord( + alert, + nil, + ignoreIPAddressMsg, + events.ActionSuccessIgnoredIPAddress, + nil, + ) - log.Debugf( - "LogEventIgnoredUserOrIP: "+msgTemplate, +} + +// logEventIgnoredUsername handles logging the event where a username has been +// ignored due to inclusion of that username in an "ignore file" for +// usernames. This function emits the output to stdout for the init system to +// catch and also writes a templated message to the reported user events log +// for potential automation. +func logEventIgnoredUsername(alert events.SplunkAlertEvent, reportedUserEventsLog *ReportedUserEventsLog, ignoredEntriesFile string) events.Record { + + ignoreUsernameMsg := fmt.Sprintf( + "Ignored disable request from %q for user %q from IP %q due to presence in %q file.", alert.PayloadSenderIP, alert.Username, alert.UserIP, ignoredEntriesFile, ) + log.Debug(caller.GetFuncFileLineInfo()) + + log.Info(ignoreUsernameMsg) + if err := appendToFile( fileEntry{ - SplunkAlertEvent: alert, + Alert: alert, IgnoredEntriesFile: ignoredEntriesFile, }, reportedUserEventsLog.IgnoreTemplate, reportedUserEventsLog.FilePath, reportedUserEventsLog.FilePermissions, ); err != nil { - return fmt.Errorf( - "func LogEventIgnoredUserOrIP: error updating events log file %q: %w", + recordEventErr := fmt.Errorf( + "func %s: error updating events log file %q: %w", + caller.GetFuncName(), reportedUserEventsLog.FilePath, err, ) + + return events.NewRecord( + alert, + recordEventErr, + ignoreUsernameMsg, + events.ActionFailureIgnoredUsername, + nil, + ) } + return events.NewRecord( + alert, + nil, + ignoreUsernameMsg, + events.ActionSuccessIgnoredUsername, + nil, + ) + +} + +// logEventTerminatingUserSession handles logging the event where a session +// for a username is being terminated. This function may be called multiple +// times, once per session associated with a username. This function emits the +// output to stdout for the init system to catch. This function does NOT +// record the event within the reported users event log nor does it generate a +// notification. +func logEventTerminatingUserSession( + alert events.SplunkAlertEvent, + userSession ezproxy.UserSession, +) { + + msgTemplate := "Terminating session %q (associated with IP %q) for username %q (from IP %q) per report from %q" + + log.Debug(caller.GetFuncFileLineInfo()) + log.Infof( msgTemplate, - alert.PayloadSenderIP, + userSession.SessionID, + userSession.IPAddress, alert.Username, alert.UserIP, - ignoredEntriesFile, + alert.PayloadSenderIP, ) - return nil +} + +// logEventTerminatedUserSessions handles logging the event where sessions for +// a username have been terminated. This function is called once for a +// collection of termination results associated with a username. This function +// emits the output to stdout for the init system to catch and also sends a +// summary of the termination results as a notification. +func logEventTerminatedUserSessions( + alert events.SplunkAlertEvent, + reportedUserEventsLog *ReportedUserEventsLog, + terminationResults ezproxy.TerminateUserSessionResults, +) events.Record { + + // Record origin *before* we start processing via loop + log.Debug(caller.GetFuncFileLineInfo()) + + // emit each termination result to stdout, write to the reported user + // events log file + successfulTerminationTmplPrefix := "Successfully terminated" + failedTerminationTmplPrefix := "Failed to terminate" + terminatedMsgTmpl := "%s session %q (associated with IP %q) for username %q (from IP %q) per report from %q [ExitCode: %d, StdOut: %q, StdErr: %q, Error: %q]" + + var failedTerminationsNum int + var failedTerminationsSessionIDs []string + for _, result := range terminationResults { + + // be optimistic! + terminatedMsgPrefix := successfulTerminationTmplPrefix + + if result.Error != nil { + terminatedMsgPrefix = failedTerminationTmplPrefix + failedTerminationsSessionIDs = append(failedTerminationsSessionIDs, result.SessionID) + failedTerminationsNum++ + } + + // guard against (nil) lack of error in results slice entry + errStr := "None" + if result.Error != nil { + errStr = result.Error.Error() + } + + terminatedMsg := fmt.Sprintf( + terminatedMsgTmpl, + terminatedMsgPrefix, + result.SessionID, + result.IPAddress, + alert.Username, + alert.UserIP, + alert.PayloadSenderIP, + result.ExitCode, + result.StdOut, + result.StdErr, + errStr, + ) + + log.Info(terminatedMsg) + + // only record successful terminations in the reported user events log + if result.Error == nil { + + var recordEventErr error + if err := appendToFile( + fileEntry{ + Alert: alert, + UserSession: result.UserSession, + }, + reportedUserEventsLog.TerminateUserSessionEventTemplate, + reportedUserEventsLog.FilePath, + reportedUserEventsLog.FilePermissions, + ); err != nil { + + recordEventErr = fmt.Errorf( + "func %s: error updating events log file %q: %w", + caller.GetFuncName(), + reportedUserEventsLog.FilePath, + recordEventErr, + ) + + return events.NewRecord( + alert, + recordEventErr, + terminatedMsg, + events.ActionFailureTerminatedUserSession, + terminationResults, + ) + + } + + } + + } + + successfulTerminations := len(terminationResults) - failedTerminationsNum + + // emit via stdout (for systemd/syslog) + log.Infof( + "Session termination summary for %q: [success: %d, failure: %d]", + alert.Username, + successfulTerminations, + failedTerminationsNum, + ) + + if terminationResults.HasError() { + + terminationResultsFailureMsg := fmt.Sprintf( + "%d errors occurred while terminating %d sessions for username %s", + failedTerminationsNum, + len(terminationResults), + alert.Username, + ) + + terminationResultsError := fmt.Errorf( + "failed to terminate sessions: %s", + strings.Join(failedTerminationsSessionIDs, ", "), + ) + + return events.NewRecord( + alert, + terminationResultsError, + terminationResultsFailureMsg, + events.ActionFailureTerminatedUserSession, + terminationResults, + ) + } + + // TODO: We need to determine our % of success and convey that at a + // glance. The current "User sessions terminated" suffix conveys at + // *something* was terminated, but this may not be true (e.g., the recent + // test case was a complete failure and still that suffix is used) + + sessionTerminationResultsSuccessMsg := fmt.Sprintf( + "Successfully terminated all %d user sessions for %q", + len(terminationResults), + alert.Username, + ) + + return events.NewRecord( + alert, + nil, + sessionTerminationResultsSuccessMsg, + events.ActionSuccessTerminatedUserSession, + terminationResults, + ) } diff --git a/files/process.go b/files/process.go index 399e3210..394df394 100644 --- a/files/process.go +++ b/files/process.go @@ -17,7 +17,6 @@ package files import ( - "bufio" "fmt" "os" "strings" @@ -26,25 +25,113 @@ import ( "github.com/apex/log" + "github.com/atc0005/go-ezproxy" + "github.com/atc0005/go-ezproxy/activefile" + "github.com/atc0005/brick/events" + "github.com/atc0005/brick/internal/caller" + "github.com/atc0005/brick/internal/fileutils" ) -func ProcessEvent( +func processRecord(record events.Record, notifyWorkQueue chan<- events.Record) { + + if record.Error != nil { + log.Error(record.Error.Error()) + } + + // shouldn't encounter "loop variable XYZ captured by func literal" issue + // because we're not in a loop (record isn't changing) + go func() { + notifyWorkQueue <- record + }() + +} + +// ProcessDisableEvent receives a care-package of configuration settings, the +// original alert, a channel to send event records on and values representing +// the disabled users and reported user events log files. This function +// handles orchestration of multiple actions taken in response to the received +// alert and request to disable a user account (and disable the associated +// sessions). This function returns a collection of +// +// TODO: This function and those called within are *badly* in need of +// refactoring. +func ProcessDisableEvent( alert events.SplunkAlertEvent, disabledUsers *DisabledUsers, reportedUserEventsLog *ReportedUserEventsLog, ignoredSources IgnoredSources, notifyWorkQueue chan<- events.Record, -) error { + terminateSessions bool, + ezproxyActiveFilePath string, + ezproxySessionsSearchDelay int, + ezproxySessionSearchRetries int, + ezproxyExecutable string, +) { + + // Record/log that a username was reported + // + // It so happens that we are going to try and disable a username. The + // assumption with this remark is that the remote monitoring system is + // just passing along specific information to a specific endpoint, but in + // reality we're setting up an alert in the monitoring system with a + // specific outcome in mind. + disableRequestReceivedResult := logEventDisableRequestReceived( + alert, + reportedUserEventsLog, + ) + + processRecord(disableRequestReceivedResult, notifyWorkQueue) + + // check whether username or IP Address is ignored, return early if true + // or if there is an error looking up the status which the sysadmin did + // not opt to disregard. + ignoredEntryFound, ignoredEntryResults := isIgnored(alert, reportedUserEventsLog, ignoredSources) + switch { + case ignoredEntryResults.Error != nil: + + if ignoredSources.IgnoreLookupErrors { + // If sysadmin opted to ignore lookup errors then honor the + // request; emit complaint (to console, local logs, syslog via + // systemd, etc) and ignore the lookup error by proceeding. + // + // WARNING: See GH-62; this "feature" may be removed in a future + // release in order to avoid potentially unexpected logic bugs. + log.Warn(ignoredEntryResults.Error.Error()) + break + } + + // send record for notification + processRecord(ignoredEntryResults, notifyWorkQueue) + + // exit after sending notification + return + + // early exit to force desired ignore behavior + case ignoredEntryFound: + + // Note: `logEventIgnoredUsername()` is called within `isIgnored()`, + // so we refrain from calling it again explicitly here. + processRecord(ignoredEntryResults, notifyWorkQueue) + + // exit after sending notification + return - if err := logEventReportingUsername(alert, reportedUserEventsLog); err != nil { - return err } // check to see if username has already been disabled disabledUserEntry := alert.Username + disabledUsers.EntrySuffix - disableEntryFound, disableEntryLookupErr := inList(disabledUserEntry, disabledUsers.FilePath) - if disableEntryLookupErr != nil { + disableEntryFound, disableEntryLookupErr := fileutils.HasLine( + disabledUserEntry, + "#", + disabledUsers.FilePath, + ) + + // Handle logic for disabling user account + switch { + + case disableEntryLookupErr != nil: + errMsg := fmt.Errorf( "error while checking disabled status for user %q from IP %q: %w", alert.Username, @@ -52,280 +139,394 @@ func ProcessEvent( disableEntryLookupErr, ) - if !ignoredSources.IgnoreLookupErrors { + if ignoredSources.IgnoreLookupErrors { + // If sysadmin opted to ignore lookup errors then honor the + // request; emit complaint (to console, local logs, syslog via + // systemd, etc) and ignore the lookup error by proceeding. + // + // WARNING: See GH-62; this "feature" may be removed in a future + // release in order to avoid potentially unexpected logic bugs. + log.Warn(disableEntryLookupErr.Error()) + + // NOTE: If the lookup error is being ignored, we skip all + // attempts to disable the user account. + break + } - // only send notifications if lookup errors are not ignored - go func() { - notifyWorkQueue <- events.Record{ - Alert: alert, - Error: errMsg, - } - }() + result := events.NewRecord( + alert, + errMsg, + // FIXME: Not sure what Note or "summary" field value to use here + "", + events.ActionFailureDisabledUsername, + nil, + ) - return errMsg - } + processRecord(result, notifyWorkQueue) - // console, local logs, syslog via systemd, etc. - log.Warn(errMsg.Error()) - } + return - // if username has not been disabled yet, proceed with additional checks - // before attempting to disable the account - if !disableEntryFound { + case !disableEntryFound: - // check to see if username has been ignored - userIgnoreEntryFound, userIgnoreLookupErr := inList(alert.Username, ignoredSources.IgnoredUsersFile) - if userIgnoreLookupErr != nil { + // log our intent to disable the username + logEventDisablingUsername(alert, reportedUserEventsLog) - errMsg := fmt.Errorf( - "error while checking ignored status for user %q from IP %q: %w", - alert.Username, - alert.UserIP, - userIgnoreLookupErr, + // disable usename + if err := disableUser(alert, disabledUsers); err != nil { + result := events.NewRecord( + alert, + err, + // FIXME: Unsure what note to use here + "", + events.ActionFailureDisabledUsername, + nil, ) - if !ignoredSources.IgnoreLookupErrors { + processRecord(result, notifyWorkQueue) - // only send notifications if lookup errors are not ignored - go func() { - notifyWorkQueue <- events.Record{ - Alert: alert, - Error: errMsg, - } - }() + return + } - return errMsg - } + // log success (file, notifications, etc.) + disableUsernameResult := logEventDisabledUsername(alert, reportedUserEventsLog) + processRecord(disableUsernameResult, notifyWorkQueue) - // console, local logs, syslog via systemd, etc. - log.Warn(errMsg.Error()) - } + case disableEntryFound: + + usernameAlreadyDisabledResult := logEventUsernameAlreadyDisabled(alert, reportedUserEventsLog) + processRecord(usernameAlreadyDisabledResult, notifyWorkQueue) + + } - if userIgnoreEntryFound { + // At this point the username has been disabled, either just now or as + // part of a previous report. We should proceed with session termination + // if enabled or note that the setting is not enabled for troubleshooting + // purposes later. + switch { + case !terminateSessions: + + log.Warn("Sessions termination is disabled via configuration setting. Sessions will persist until they timeout.") + + userSessions, userSessionsLookupErr := getUserSessions( + alert, + reportedUserEventsLog, + ezproxyActiveFilePath, + ezproxySessionsSearchDelay, + ezproxySessionSearchRetries, + ezproxyExecutable, + ) - logEventIgnoringUsernameErr := logEventIgnoringUsername( + if userSessionsLookupErr != nil { + record := events.NewRecord( alert, - reportedUserEventsLog, - ignoredSources.IgnoredUsersFile, + userSessionsLookupErr, + "", + events.ActionFailureUserSessionLookupFailure, + nil, ) - ignoreUserMsg := fmt.Sprintf( - "Ignoring disable request from %q for user %q from IP %q due to presence in %q file.", - alert.PayloadSenderIP, - alert.Username, - alert.UserIP, - ignoredSources.IgnoredUsersFile, - ) - go func() { - notifyWorkQueue <- events.Record{ - Alert: alert, - Error: logEventIgnoringUsernameErr, - Note: ignoreUserMsg, - Action: events.ActionSuccessIgnoredUsername, - } - }() - - return logEventIgnoringUsernameErr + processRecord(record, notifyWorkQueue) + } - // check to see if IP Address has been ignored - ipAddressIgnoreEntryFound, ipAddressIgnoreLookupErr := inList( - alert.UserIP, ignoredSources.IgnoredIPAddressesFile) - if ipAddressIgnoreLookupErr != nil { + var userSessionIDs []string + for _, session := range userSessions { + userSessionIDs = append(userSessionIDs, session.SessionID) + } - errMsg := fmt.Errorf( - "error while checking ignored status for IP %q associated with user %q: %w", - alert.UserIP, - alert.Username, - ipAddressIgnoreLookupErr, - ) + sessionsSkipped := strings.Join(userSessionIDs, `", "`) - if !ignoredSources.IgnoreLookupErrors { + sessionsSkippedMsg := fmt.Sprintf( + `Skipping termination of sessions: "%s"`, + sessionsSkipped, + ) - // only send notifications if lookup errors are not ignored - go func() { - notifyWorkQueue <- events.Record{ - Alert: alert, - Error: errMsg, - } - }() + log.Warn(sessionsSkippedMsg) - return errMsg - } + record := events.NewRecord( + alert, + nil, + sessionsSkippedMsg, + events.ActionSkippedTerminateUserSessions, + nil, + ) - // console, local logs, syslog via systemd, etc. - log.Warn(errMsg.Error()) - } + processRecord(record, notifyWorkQueue) - if ipAddressIgnoreEntryFound { + case terminateSessions: - logEventIgnoringUsernameErr := logEventIgnoringUsername( - alert, - reportedUserEventsLog, - ignoredSources.IgnoredIPAddressesFile, - ) + userSessions, userSessionsLookupErr := getUserSessions( + alert, + reportedUserEventsLog, + ezproxyActiveFilePath, + ezproxySessionsSearchDelay, + ezproxySessionSearchRetries, + ezproxyExecutable, + ) - ignoreIPAddressMsg := fmt.Sprintf( - "Ignoring disable request from %q for user %q from IP %q due to presence in %q file.", - alert.PayloadSenderIP, - alert.Username, - alert.UserIP, - ignoredSources.IgnoredIPAddressesFile, + if userSessionsLookupErr != nil { + record := events.NewRecord( + alert, + userSessionsLookupErr, + "", + events.ActionFailureUserSessionLookupFailure, + nil, ) - go func() { - notifyWorkQueue <- events.Record{ - Alert: alert, - Error: logEventIgnoringUsernameErr, - Note: ignoreIPAddressMsg, - Action: events.ActionSuccessIgnoredIPAddress, - } - }() - - return logEventIgnoringUsernameErr + processRecord(record, notifyWorkQueue) } - // disable user account - if err := disableUser(alert, disabledUsers); err != nil { + // logEventTerminatingUserSession is called within this function for + // each session termination attempt (one or many) and + // logEventTerminatedUserSessions is called at the end of the function + // to provide a summary of the results. + terminateUserSessionsResult := terminateUserSessions( + alert, + reportedUserEventsLog, + userSessions, + ezproxyActiveFilePath, + ezproxyExecutable, + ) - go func() { - notifyWorkQueue <- events.Record{ - Alert: alert, - Error: err, - } - }() + processRecord(terminateUserSessionsResult, notifyWorkQueue) - return err - } + } + +} - if err := logEventDisablingUsername(alert, reportedUserEventsLog); err != nil { +// isIgnored is a wrapper function to help concentrate common ignored status +// checks in one place. If there are issues checking ignored status, +// explicitly state that the username or IP Address is ignored and return the +// error. The caller can then apply other logic to determine how the error +// condition should be treated. +func isIgnored( + alert events.SplunkAlertEvent, + reportedUserEventsLog *ReportedUserEventsLog, + ignoredSources IgnoredSources, +) (bool, events.Record) { - go func() { - notifyWorkQueue <- events.Record{ - Alert: alert, - Error: err, - } - }() + ignoredUserEntryFound, ignoredUserLookupErr := fileutils.HasLine( + alert.Username, + "#", + ignoredSources.IgnoredUsersFile, + ) - return err - } + if ignoredUserLookupErr != nil { - disableSuccessMsg := fmt.Sprintf( - "Disabled user %q from IP %q", + errMsg := fmt.Errorf( + "error while checking ignored status for user %q from IP %q: %w", alert.Username, alert.UserIP, + ignoredUserLookupErr, + ) + + result := events.NewRecord( + alert, + errMsg, + // FIXME: Unsure what note to add here + "", + events.ActionFailureIgnoredUsername, + nil, ) - go func() { - notifyWorkQueue <- events.Record{ - Alert: alert, - Note: disableSuccessMsg, - Action: events.ActionSuccessDisabledUsername, - } - }() + // on error, assume username or IP should be ignored + return true, result + + } - log.Infof(disableSuccessMsg) + if ignoredUserEntryFound { + ignoredUsernameResult := logEventIgnoredUsername( + alert, + reportedUserEventsLog, + ignoredSources.IgnoredUsersFile, + ) - // required to indicate that we successfully disabled user account - return nil + return true, ignoredUsernameResult } - // if username is already disabled, skip adding to disable file, but still - // log the request so that all associated IPs can be banned by fail2ban - alreadyDisabledMsg := fmt.Sprintf( - "Received disable request from %q for user %q from IP %q;"+ - " username is already disabled", - alert.PayloadSenderIP, - alert.Username, + // check to see if IP Address has been ignored + ipAddressIgnoreEntryFound, ipAddressIgnoreLookupErr := fileutils.HasLine( alert.UserIP, + "#", + ignoredSources.IgnoredIPAddressesFile, ) - go func() { - notifyWorkQueue <- events.Record{ - Alert: alert, - // this isn't technically an "error", more of a "something worth - // noting" event - Note: alreadyDisabledMsg, - Action: events.ActionSuccessDuplicateUsername, - } - }() + if ipAddressIgnoreLookupErr != nil { - log.Debug(alreadyDisabledMsg) + errMsg := fmt.Errorf( + "error while checking ignored status for IP %q associated with user %q: %w", + alert.UserIP, + alert.Username, + ipAddressIgnoreLookupErr, + ) - if err := logEventUserAlreadyDisabled(alert, reportedUserEventsLog); err != nil { + result := events.NewRecord( + alert, + errMsg, + // FIXME: Unsure what note to add here + "", + events.ActionFailureIgnoredIPAddress, + nil, + ) - go func() { - notifyWorkQueue <- events.Record{ - Alert: alert, - Error: err, - } - }() + // on error, assume username or IP should be ignored + return true, result + } + + if ipAddressIgnoreEntryFound { + + ignoredIPAddressResult := logEventIgnoredIPAddress( + alert, + reportedUserEventsLog, + ignoredSources.IgnoredIPAddressesFile, + ) + + return true, ignoredIPAddressResult - return err } - // FIXME: Anything else needed at this point? - return nil + // the username and associated IP Addr is *not* ignored if: + // + // - no error occurs looking up the ignored status + // - no match is found + + // FIXME: Not a fan of returning an empty Record here. If we drop Records + // directly into the notifyWorkQueue channel instead of passing up this is + // no longer necessary. + return false, events.Record{} } -// inList accepts a string and a fully-qualified path to a file containing a -// list of such strings (commonly usernames or single IP Addresses), one per -// line. Lines beginning with a `#` character are ignored. Leading and -// trailing whitespace per line is ignored. -func inList(needle string, haystack string) (bool, error) { +func getUserSessions( + alert events.SplunkAlertEvent, + reportedUserEventsLog *ReportedUserEventsLog, + ezproxyActiveFilePath string, + ezproxySessionsSearchDelay int, + ezproxySessionSearchRetries int, + ezproxyExecutable string, +) (ezproxy.UserSessions, error) { + + reader, readerErr := activefile.NewReader(alert.Username, ezproxyActiveFilePath) + if readerErr != nil { + activeFileReaderErr := fmt.Errorf( + "error while creating activeFile reader to retrieve sessions associated with user %q: %w", + alert.Username, + readerErr, + ) - log.Debugf("Attempting to open %q", haystack) + return nil, activeFileReaderErr + } + + // Adjust stubbornness of newly created reader (overridding + // library/package default values with our own) + if err := reader.SetSearchDelay(ezproxySessionsSearchDelay); err != nil { + searchDelayErr := fmt.Errorf( + "error while setting search delay for activeFile reader to retrieve sessions associated with user %q: %w", + alert.Username, + err, + ) + + return nil, searchDelayErr - // TODO: How do we handle the situation where the file does not exist - // ahead of time? Since this application will manage the file, it should - // be able to create it with the desired permissions? - f, err := os.Open(haystack) - if err != nil { - return false, fmt.Errorf("error encountered opening file %q: %w", haystack, err) } - defer f.Close() - log.Debugf("Searching for: %q", needle) + if err := reader.SetSearchRetries(ezproxySessionSearchRetries); err != nil { + searchRetriesErr := fmt.Errorf( + "error while setting search retries for activeFile reader to retrieve sessions associated with user %q: %w", + alert.Username, + err, + ) - s := bufio.NewScanner(f) - var lineno int + return nil, searchRetriesErr + } - // TODO: Does Scan() perform any whitespace manipulation already? - for s.Scan() { - lineno++ - currentLine := s.Text() - log.Debugf("Scanned line %d from %q: %q\n", lineno, haystack, currentLine) + log.Debugf( + "%s: Searching %q for %q", + caller.GetFuncName(), + ezproxyActiveFilePath, + alert.Username, + ) - currentLine = strings.TrimSpace(currentLine) - log.Debugf("Line %d from %q after whitespace removal: %q\n", - lineno, haystack, currentLine) + activeSessions, userSessionsLookupErr := reader.MatchingUserSessions() + if userSessionsLookupErr != nil { + userSessionsRetrievalErr := fmt.Errorf( + "error retrieving matching user sessions associated with user %q: %w", + alert.Username, + userSessionsLookupErr, + ) - // explicitly ignore comments - if strings.HasPrefix(currentLine, "#") { - log.Debugf("Ignoring comment line %d", lineno) - continue - } + return nil, userSessionsRetrievalErr + } - log.Debugf("Checking whether line %d is a match: %q %q", lineno, currentLine, needle) - if strings.EqualFold(currentLine, needle) { - log.Debugf("Match found on line %d, returning true to indicate this", lineno) - return true, nil - } + return activeSessions, nil +} + +func terminateUserSessions( + alert events.SplunkAlertEvent, + reportedUserEventsLog *ReportedUserEventsLog, + activeSessions ezproxy.UserSessions, + ezproxyActiveFilePath string, + ezproxyExecutable string, +) events.Record { + + // TODO: On the fence re emitting this output each time + // log.Debug("ProcessEvent: Session termination enabled") + log.Info("Session termination enabled") + + // build sessions list specific to provided user and active file using + // an ezproxy.SessionsReader + + // If we received an alert from monitoring systems, there *should* be + // at least one user session active in order for the alert to have + // been generated in the first place. If not, we are considering that + // an error. + // + // NOTE: The atc0005/go-ezproxy package performs retries per our above + // configuration, so this session count "error" is *after* we have + // already retried a set number of times; retries are performed in + // case there is a race condition between EZproxy creating the session + // and our receiving the notification. + if len(activeSessions) == 0 { + + activeSessionsCountErr := fmt.Errorf( + "0 active sessions found for username %q in file %q", + alert.Username, + ezproxyActiveFilePath, + ) + return events.NewRecord( + alert, + activeSessionsCountErr, + "", + events.ActionFailureTerminatedUserSession, + nil, + ) } - log.Debug("Exited s.Scan() loop") + log.Infof( + "%d active sessions found for %q", + len(activeSessions), + alert.Username, + ) - // report any errors encountered while scanning the input file - if err := s.Err(); err != nil { - return false, err + for _, session := range activeSessions { + logEventTerminatingUserSession(alert, session) } - // otherwise, report that the requested needle was not found - return false, nil + terminationResults := activeSessions.Terminate(ezproxyExecutable) + + // User sessions *should* now be terminated; results of the attempts + // are recorded for further review to confirm. + + logTerminatedUserSessionsResult := logEventTerminatedUserSessions( + alert, + reportedUserEventsLog, + terminationResults, + ) + + return logTerminatedUserSessionsResult } @@ -339,8 +540,8 @@ func disableUser(alert events.SplunkAlertEvent, disabledUsers *DisabledUsers) er log.Debug("DisableUser: disabling user per alert") if err := appendToFile( fileEntry{ - SplunkAlertEvent: alert, - EntrySuffix: disabledUsers.EntrySuffix, + Alert: alert, + EntrySuffix: disabledUsers.EntrySuffix, }, disabledUsers.Template, disabledUsers.FilePath, diff --git a/files/templates.go b/files/templates.go index ff9e28ce..9b945ece 100644 --- a/files/templates.go +++ b/files/templates.go @@ -20,23 +20,29 @@ package files // order to increase fail2ban parsing reliability const disabledUsersFileTemplateText string = ` -# Username "{{ .Username }}" from source IP "{{ .UserIP }}" disabled at "{{ .ArrivalTime }}" per alert "{{ .AlertName }}" received by "{{ .PayloadSenderIP }}" (SearchID: "{{ .SearchID }}") -{{ ToLower .Username }}{{ .EntrySuffix }} +# Username "{{ .Alert.Username }}" from source IP "{{ .Alert.UserIP }}" disabled at "{{ .Alert.ArrivalTime }}" per alert "{{ .Alert.AlertName }}" received by "{{ .Alert.PayloadSenderIP }}" (SearchID: "{{ .Alert.SearchID }}") +{{ ToLower .Alert.Username }}{{ .EntrySuffix }} ` // This is a standard message and only indicates that a report was received, // not that a user was disabled. This message should be followed by another // message indicating whether the user was disabled or ignored -const reportedUserEventTemplateText string = `{{ .ArrivalTime }} [REPORTED] Username "{{ .Username }}" from source IP "{{ .UserIP }}" reported via alert "{{ .AlertName }}" received by "{{ .PayloadSenderIP }}" (SearchID: "{{ .SearchID }}") +const reportedUserEventTemplateText string = `{{ .Alert.ArrivalTime }} [REPORTED] Username "{{ .Alert.Username }}" from source IP "{{ .Alert.UserIP }}" reported via alert "{{ .Alert.AlertName }}" received by "{{ .Alert.PayloadSenderIP }}" (SearchID: "{{ .Alert.SearchID }}") ` -const disabledUserFirstEventTemplateText string = `{{ .ArrivalTime }} [DISABLED] Username "{{ .Username }}" from source IP "{{ .UserIP }}" disabled due to alert "{{ .AlertName }}" received by "{{ .PayloadSenderIP }}" (SearchID: "{{ .SearchID }}") +const disabledUserFirstEventTemplateText string = `{{ .Alert.ArrivalTime }} [DISABLED] Username "{{ .Alert.Username }}" from source IP "{{ .Alert.UserIP }}" disabled due to alert "{{ .Alert.AlertName }}" received by "{{ .Alert.PayloadSenderIP }}" (SearchID: "{{ .Alert.SearchID }}") ` -const disabledUserRepeatEventTemplateText string = `{{ .ArrivalTime }} [DISABLED] Username "{{ .Username }}" from source IP "{{ .UserIP }}" already disabled, but would be again due to alert "{{ .AlertName }}" received by "{{ .PayloadSenderIP }}" (SearchID: "{{ .SearchID }}") +const disabledUserRepeatEventTemplateText string = `{{ .Alert.ArrivalTime }} [DISABLED] Username "{{ .Alert.Username }}" from source IP "{{ .Alert.UserIP }}" already disabled, but would be again due to alert "{{ .Alert.AlertName }}" received by "{{ .Alert.PayloadSenderIP }}" (SearchID: "{{ .Alert.SearchID }}") ` // NOTE: This template is used for ignored users and IP Addresses based on // presence in the ignored users list and the ignored IP Addresses list. -const ignoredUserEventTemplateText string = `{{ .ArrivalTime }} [IGNORED] Username "{{ .Username }}" from source IP "{{ .UserIP }}" ignored per entry in "{{ .IgnoredEntriesFile }}" (SearchID: "{{ .SearchID }}") +const ignoredUserEventTemplateText string = `{{ .Alert.ArrivalTime }} [IGNORED] Username "{{ .Alert.Username }}" from source IP "{{ .Alert.UserIP }}" ignored per entry in "{{ .IgnoredEntriesFile }}" (SearchID: "{{ .Alert.SearchID }}") +` + +// This template is used to write out the results of each session termination +// attempt; this template is not used to generate a bulk summary for multiple +// sessions +const terminatedUserEventTemplateText string = `{{ .Alert.ArrivalTime }} [TERMINATED] Session "{{ .UserSession.SessionID }}" associated with {{ .UserSession.IPAddress }} for username "{{ .Alert.Username }}" from source IP "{{ .Alert.UserIP }}" terminated due to alert "{{ .Alert.AlertName }}" received by "{{ .Alert.PayloadSenderIP }}" (SearchID: "{{ .Alert.SearchID }}") ` diff --git a/go.mod b/go.mod index 897a93e8..74a0ea3b 100644 --- a/go.mod +++ b/go.mod @@ -16,11 +16,14 @@ module github.com/atc0005/brick +// replace github.com/atc0005/go-ezproxy => ../go-ezproxy + go 1.13 require ( github.com/alexflint/go-arg v1.3.0 github.com/apex/log v1.4.0 + github.com/atc0005/go-ezproxy v0.1.2 // temporarily use our fork; waiting on changes to be accepted upstream github.com/atc0005/go-teams-notify v1.3.1-0.20200419155834-55cca556e726 github.com/atc0005/send2teams v0.4.4 diff --git a/go.sum b/go.sum index fb60428d..70b4624d 100644 --- a/go.sum +++ b/go.sum @@ -9,6 +9,8 @@ github.com/apex/log v1.4.0/go.mod h1:UMNC4vQNC7hb5gyr47r18ylK1n34rV7GO+gb0wpXvcE github.com/apex/logs v0.0.7/go.mod h1:XzxuLZ5myVHDy9SAmYpamKKRNApGj54PfYLcFrXqDwo= github.com/aphistic/golf v0.0.0-20180712155816-02c07f170c5a/go.mod h1:3NqKYiepwy8kCu4PNA+aP7WUV72eXWJeP9/r3/K9aLE= github.com/aphistic/sweet v0.2.0/go.mod h1:fWDlIh/isSE9n6EPsRmC0det+whmX6dJid3stzu0Xys= +github.com/atc0005/go-ezproxy v0.1.2 h1:xCiLys6Lqc7CHYWOTHjW0ZVVtLFF2Hn6jggfhTCwaMg= +github.com/atc0005/go-ezproxy v0.1.2/go.mod h1:petHyxg0DLcJTNRmLRV5k4hx/qdVTR8Ns81UFhugw6Y= github.com/atc0005/go-teams-notify v1.3.1-0.20200419155834-55cca556e726 h1:pUJFxj7XRR6UxgWTvG4BCf1cVsn7nNqxdigBhMd1r/c= github.com/atc0005/go-teams-notify v1.3.1-0.20200419155834-55cca556e726/go.mod h1:zUADEXrhalWyaQvxzYgHswljBWycIpX1UAFrggjcdi4= github.com/atc0005/send2teams v0.4.4 h1:d7OfycX7MEcF088Ohy/QdgUT0dkYdw9xPr/E7EMDwXQ= diff --git a/internal/caller/caller.go b/internal/caller/caller.go new file mode 100644 index 00000000..113d4d9d --- /dev/null +++ b/internal/caller/caller.go @@ -0,0 +1,80 @@ +// Copyright 2020 Adam Chalkley +// +// https://github.com/atc0005/brick +// +// 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 +// +// https://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. + +package caller + +import ( + "fmt" + "runtime" +) + +// GetFuncFileLineInfo is a wrapper around the runtime.Caller() function. This +// function returns the calling function name, filename and line number to +// help with debugging efforts. +func GetFuncFileLineInfo() string { + + if pc, file, line, ok := runtime.Caller(1); ok { + return fmt.Sprintf( + "func %s called (from %q, line %d): ", + runtime.FuncForPC(pc).Name(), + file, + line, + ) + } + + return "error: unable to recover caller origin via runtime.Caller()" +} + +// GetFuncName is a wrapper around the runtime.Caller() function. This +// function returns the calling function name and discards other return +// values. +func GetFuncName() string { + + if pc, _, _, ok := runtime.Caller(1); ok { + return runtime.FuncForPC(pc).Name() + } + + return "error: unable to recover caller origin via runtime.Caller()" +} + +// GetParentFuncFileLineInfo is a wrapper around the runtime.Caller() +// function. This function returns the parent calling function name, filename +// and line number to help with debugging efforts. +func GetParentFuncFileLineInfo() string { + + if pc, file, line, ok := runtime.Caller(2); ok { + return fmt.Sprintf( + "func %s called (from %q, line %d): ", + runtime.FuncForPC(pc).Name(), + file, + line, + ) + } + + return "error: unable to recover caller parent origin via runtime.Caller()" +} + +// GetParentFuncName is a wrapper around the runtime.Caller() function. This +// function returns the parent calling function name and discards other return +// values. +func GetParentFuncName() string { + + if pc, _, _, ok := runtime.Caller(2); ok { + return runtime.FuncForPC(pc).Name() + } + + return "error: unable to recover caller parent origin via runtime.Caller()" +} diff --git a/internal/caller/doc.go b/internal/caller/doc.go new file mode 100644 index 00000000..04b30420 --- /dev/null +++ b/internal/caller/doc.go @@ -0,0 +1,19 @@ +// Copyright 2020 Adam Chalkley +// +// https://github.com/atc0005/brick +// +// 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 +// +// https://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. + +// Package caller is an internal package that contains helper functions for +// retrieving metadata about the caller of those functions. +package caller diff --git a/internal/fileutils/contains.go b/internal/fileutils/contains.go new file mode 100644 index 00000000..2e016f95 --- /dev/null +++ b/internal/fileutils/contains.go @@ -0,0 +1,90 @@ +// Copyright 2020 Adam Chalkley +// +// https://github.com/atc0005/brick +// +// 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 +// +// https://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. + +package fileutils + +import ( + "bufio" + "fmt" + "os" + "strings" + + "github.com/apex/log" +) + +// HasLine accepts a search string, an optional pattern to ignore and a +// fully-qualified path to a file containing a list of such strings (e.g., +// commonly usernames or single IP Addresses), one per line. Lines beginning +// with the optional ignore pattern (e.g., a `#` character) are ignored. +// Leading and trailing whitespace per line is ignored. +func HasLine(searchTerm string, ignorePrefix string, filename string) (bool, error) { + + log.Debugf("Attempting to open %q", filename) + + // TODO: How do we handle the situation where the file does not exist + // ahead of time? Since this application will manage the file, it should + // be able to create it with the desired permissions? + f, err := os.Open(filename) + if err != nil { + return false, fmt.Errorf("error encountered opening file %q: %w", filename, err) + } + defer f.Close() + + log.Debugf("Searching for: %q", searchTerm) + + s := bufio.NewScanner(f) + var lineno int + + // TODO: Does Scan() perform any whitespace manipulation already? + for s.Scan() { + lineno++ + currentLine := s.Text() + log.Debugf("Scanned line %d from %q: %q\n", lineno, filename, currentLine) + + currentLine = strings.TrimSpace(currentLine) + log.Debugf("Line %d from %q after lowercasing and whitespace removal: %q\n", + lineno, filename, currentLine) + + // explicitly ignore lines beginning with specified pattern, if + // provided + if ignorePrefix != "" { + if strings.HasPrefix(currentLine, ignorePrefix) { + log.Debugf("Ignoring line %d due to leading %q", + lineno, ignorePrefix) + continue + } + } + + log.Debugf("Checking whether line %d is a match for %q: %q", + lineno, searchTerm, currentLine) + if strings.EqualFold(currentLine, searchTerm) { + log.Debugf("Match found on line %d, returning true to indicate this", lineno) + return true, nil + } + + } + + log.Debug("Exited s.Scan() loop") + + // report any errors encountered while scanning the input file + if err := s.Err(); err != nil { + return false, err + } + + // otherwise, report that the requested searchTerm was not found + return false, nil + +} diff --git a/internal/fileutils/doc.go b/internal/fileutils/doc.go new file mode 100644 index 00000000..6aaa5355 --- /dev/null +++ b/internal/fileutils/doc.go @@ -0,0 +1,19 @@ +// Copyright 2020 Adam Chalkley +// +// https://github.com/atc0005/brick +// +// 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 +// +// https://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. + +// Package fileutils is an internal package that contains helper functions for +// working with common file types/formats. +package fileutils diff --git a/internal/textutils/doc.go b/internal/textutils/doc.go new file mode 100644 index 00000000..75129700 --- /dev/null +++ b/internal/textutils/doc.go @@ -0,0 +1,19 @@ +// Copyright 2020 Adam Chalkley +// +// https://github.com/atc0005/brick +// +// 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 +// +// https://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. + +// Package textutils is an internal package that contains helper functions for +// working with common text structures. +package textutils diff --git a/internal/textutils/inlist.go b/internal/textutils/inlist.go new file mode 100644 index 00000000..11209dfa --- /dev/null +++ b/internal/textutils/inlist.go @@ -0,0 +1,28 @@ +// Copyright 2020 Adam Chalkley +// +// https://github.com/atc0005/brick +// +// 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 +// +// https://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. + +package textutils + +// InList is a helper function to emulate Python's `if "x" +// in list:` functionality +func InList(needle string, haystack []string) bool { + for _, item := range haystack { + if item == needle { + return true + } + } + return false +} diff --git a/vendor/github.com/atc0005/go-ezproxy/.golangci.yml b/vendor/github.com/atc0005/go-ezproxy/.golangci.yml new file mode 100644 index 00000000..4ae837ae --- /dev/null +++ b/vendor/github.com/atc0005/go-ezproxy/.golangci.yml @@ -0,0 +1,33 @@ +# Copyright 2020 Adam Chalkley +# +# https://github.com/atc0005/go-ezproxy +# +# 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 +# +# https://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. + +linters: + enable: + - depguard + - dogsled + - dupl + - goconst + - gocritic + - gofmt + - goimports + - golint + - gosec + - maligned + - misspell + - prealloc + - scopelint + - stylecheck + - unconvert diff --git a/vendor/github.com/atc0005/go-ezproxy/.markdownlint.yml b/vendor/github.com/atc0005/go-ezproxy/.markdownlint.yml new file mode 100644 index 00000000..f8bc6e6e --- /dev/null +++ b/vendor/github.com/atc0005/go-ezproxy/.markdownlint.yml @@ -0,0 +1,31 @@ +# Copyright 2020 Adam Chalkley +# +# https://github.com/atc0005/go-ezproxy +# +# 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 +# +# https://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. + +# https://github.com/igorshubovych/markdownlint-cli#configuration +# https://github.com/DavidAnson/markdownlint#optionsconfig + +# Setting the special default rule to true or false includes/excludes all +# rules by default. +"default": true + +# We know that line lengths will be long in the main README file, so don't +# report those cases. +"MD013": false + +# Don't complain if sub-heading names are duplicated since this is a common +# practice in CHANGELOG.md (e.g., "Fixed"). +"MD024": + "siblings_only": true diff --git a/vendor/github.com/atc0005/go-ezproxy/CHANGELOG.md b/vendor/github.com/atc0005/go-ezproxy/CHANGELOG.md new file mode 100644 index 00000000..28029d0b --- /dev/null +++ b/vendor/github.com/atc0005/go-ezproxy/CHANGELOG.md @@ -0,0 +1,123 @@ +# Changelog + +## Overview + +All notable changes to this project will be documented in this file. + +The format is based on [Keep a +Changelog](https://keepachangelog.com/en/1.0.0/), and this project adheres to +[Semantic Versioning](https://semver.org/spec/v2.0.0.html). + +Please [open an issue][repo-url-issues] for any +deviations that you spot; I'm still learning!. + +## Types of changes + +The following types of changes will be recorded in this file: + +- `Added` for new features. +- `Changed` for changes in existing functionality. +- `Deprecated` for soon-to-be removed features. +- `Removed` for now removed features. +- `Fixed` for any bug fixes. +- `Security` in case of vulnerabilities. + +## [Unreleased] + +- placeholder + +## [v0.1.2] - 2020-06-19 + +### Changed + +- Embed `UserSession` within `TerminateSessionResult` instead of + cherry-picking specific values. The intent is to allow deeper layers of + client code to easily access the original `UserSession` field values (e.g., + IP Address). + +- Update dependencies + - `actions/checkout` + - `v2.3.0` to `v2.3.1` + +## [v0.1.1] - 2020-06-17 + +### Added + +- New `TerminateUserSessionResults` type + +- New `HasError()` method to report whether an error was recorded when + terminating user sessions + +### Changed + +- Return type for multiple functions changed from + `[]TerminateUserSessionResult` to `TerminateUserSessionResults` + +- Enable Dependabot updates + - GitHub Actions + - Go Modules + +- Update dependencies + - `actions/setup-go` + - `v1` to `v2.0.3` + - `actions/checkout` + - `v1` to `v2.3.0` + - `actions/setup-node` + - `v1` to `v2.0.0` + +### Fixed + +- Doc comment: Fix name of MatchingUserSessions func + +## [v0.1.0] - 2020-06-09 + +Initial release! + +This release provides an early release version of a library intended for use +with the processing of EZproxy related files and sessions. This library was +developed specifically to support the development of an in-progress +application, so the full context may not be entirely clear until that +application is released (currently pending review). + +### Added + +- generate a list of audit records for session-related events + - for all usernames + - for a specific username + +- generate a list of active sessions using audit log + - using entires without a corresponding logout event type + +- generate a list of active sessions using active file + - for all usernames + - for a specific username + +- terminate user sessions + - single user session + - bulk user sessions + +- Go modules support (vs classic `GOPATH` setup) + +### Missing + +- Anything to do with traffic log entries +- Examples + - the in-progress [atc0005/brick][related-brick-project] should serve well + for this once it is released + + + +[Unreleased]: https://github.com/atc0005/go-ezproxy/compare/v0.1.2...HEAD +[v0.1.2]: https://github.com/atc0005/go-ezproxy/releases/tag/v0.1.1 +[v0.1.1]: https://github.com/atc0005/go-ezproxy/releases/tag/v0.1.1 +[v0.1.0]: https://github.com/atc0005/go-ezproxy/releases/tag/v0.1.0 + + + +[repo-url-home]: "This project's GitHub repo" +[repo-url-issues]: "This project's issues list" +[repo-url-release-latest]: "This project's latest release" + +[docs-homepage]: "GoDoc coverage" + +[related-brick-project]: "atc0005/brick project URL" diff --git a/vendor/github.com/atc0005/go-ezproxy/LICENSE b/vendor/github.com/atc0005/go-ezproxy/LICENSE new file mode 100644 index 00000000..261eeb9e --- /dev/null +++ b/vendor/github.com/atc0005/go-ezproxy/LICENSE @@ -0,0 +1,201 @@ + Apache License + Version 2.0, January 2004 + http://www.apache.org/licenses/ + + TERMS AND CONDITIONS FOR USE, REPRODUCTION, AND DISTRIBUTION + + 1. Definitions. + + "License" shall mean the terms and conditions for use, reproduction, + and distribution as defined by Sections 1 through 9 of this document. + + "Licensor" shall mean the copyright owner or entity authorized by + the copyright owner that is granting the License. + + "Legal Entity" shall mean the union of the acting entity and all + other entities that control, are controlled by, or are under common + control with that entity. For the purposes of this definition, + "control" means (i) the power, direct or indirect, to cause the + direction or management of such entity, whether by contract or + otherwise, or (ii) ownership of fifty percent (50%) or more of the + outstanding shares, or (iii) beneficial ownership of such entity. + + "You" (or "Your") shall mean an individual or Legal Entity + exercising permissions granted by this License. + + "Source" form shall mean the preferred form for making modifications, + including but not limited to software source code, documentation + source, and configuration files. + + "Object" form shall mean any form resulting from mechanical + transformation or translation of a Source form, including but + not limited to compiled object code, generated documentation, + and conversions to other media types. + + "Work" shall mean the work of authorship, whether in Source or + Object form, made available under the License, as indicated by a + copyright notice that is included in or attached to the work + (an example is provided in the Appendix below). + + "Derivative Works" shall mean any work, whether in Source or Object + form, that is based on (or derived from) the Work and for which the + editorial revisions, annotations, elaborations, or other modifications + represent, as a whole, an original work of authorship. For the purposes + of this License, Derivative Works shall not include works that remain + separable from, or merely link (or bind by name) to the interfaces of, + the Work and Derivative Works thereof. + + "Contribution" shall mean any work of authorship, including + the original version of the Work and any modifications or additions + to that Work or Derivative Works thereof, that is intentionally + submitted to Licensor for inclusion in the Work by the copyright owner + or by an individual or Legal Entity authorized to submit on behalf of + the copyright owner. For the purposes of this definition, "submitted" + means any form of electronic, verbal, or written communication sent + to the Licensor or its representatives, including but not limited to + communication on electronic mailing lists, source code control systems, + and issue tracking systems that are managed by, or on behalf of, the + Licensor for the purpose of discussing and improving the Work, but + excluding communication that is conspicuously marked or otherwise + designated in writing by the copyright owner as "Not a Contribution." + + "Contributor" shall mean Licensor and any individual or Legal Entity + on behalf of whom a Contribution has been received by Licensor and + subsequently incorporated within the Work. + + 2. Grant of Copyright License. Subject to the terms and conditions of + this License, each Contributor hereby grants to You a perpetual, + worldwide, non-exclusive, no-charge, royalty-free, irrevocable + copyright license to reproduce, prepare Derivative Works of, + publicly display, publicly perform, sublicense, and distribute the + Work and such Derivative Works in Source or Object form. + + 3. Grant of Patent License. Subject to the terms and conditions of + this License, each Contributor hereby grants to You a perpetual, + worldwide, non-exclusive, no-charge, royalty-free, irrevocable + (except as stated in this section) patent license to make, have made, + use, offer to sell, sell, import, and otherwise transfer the Work, + where such license applies only to those patent claims licensable + by such Contributor that are necessarily infringed by their + Contribution(s) alone or by combination of their Contribution(s) + with the Work to which such Contribution(s) was submitted. If You + institute patent litigation against any entity (including a + cross-claim or counterclaim in a lawsuit) alleging that the Work + or a Contribution incorporated within the Work constitutes direct + or contributory patent infringement, then any patent licenses + granted to You under this License for that Work shall terminate + as of the date such litigation is filed. + + 4. Redistribution. You may reproduce and distribute copies of the + Work or Derivative Works thereof in any medium, with or without + modifications, and in Source or Object form, provided that You + meet the following conditions: + + (a) You must give any other recipients of the Work or + Derivative Works a copy of this License; and + + (b) You must cause any modified files to carry prominent notices + stating that You changed the files; and + + (c) You must retain, in the Source form of any Derivative Works + that You distribute, all copyright, patent, trademark, and + attribution notices from the Source form of the Work, + excluding those notices that do not pertain to any part of + the Derivative Works; and + + (d) If the Work includes a "NOTICE" text file as part of its + distribution, then any Derivative Works that You distribute must + include a readable copy of the attribution notices contained + within such NOTICE file, excluding those notices that do not + pertain to any part of the Derivative Works, in at least one + of the following places: within a NOTICE text file distributed + as part of the Derivative Works; within the Source form or + documentation, if provided along with the Derivative Works; or, + within a display generated by the Derivative Works, if and + wherever such third-party notices normally appear. The contents + of the NOTICE file are for informational purposes only and + do not modify the License. You may add Your own attribution + notices within Derivative Works that You distribute, alongside + or as an addendum to the NOTICE text from the Work, provided + that such additional attribution notices cannot be construed + as modifying the License. + + You may add Your own copyright statement to Your modifications and + may provide additional or different license terms and conditions + for use, reproduction, or distribution of Your modifications, or + for any such Derivative Works as a whole, provided Your use, + reproduction, and distribution of the Work otherwise complies with + the conditions stated in this License. + + 5. Submission of Contributions. Unless You explicitly state otherwise, + any Contribution intentionally submitted for inclusion in the Work + by You to the Licensor shall be under the terms and conditions of + this License, without any additional terms or conditions. + Notwithstanding the above, nothing herein shall supersede or modify + the terms of any separate license agreement you may have executed + with Licensor regarding such Contributions. + + 6. Trademarks. This License does not grant permission to use the trade + names, trademarks, service marks, or product names of the Licensor, + except as required for reasonable and customary use in describing the + origin of the Work and reproducing the content of the NOTICE file. + + 7. Disclaimer of Warranty. Unless required by applicable law or + agreed to in writing, Licensor provides the Work (and each + Contributor provides its Contributions) on an "AS IS" BASIS, + WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or + implied, including, without limitation, any warranties or conditions + of TITLE, NON-INFRINGEMENT, MERCHANTABILITY, or FITNESS FOR A + PARTICULAR PURPOSE. You are solely responsible for determining the + appropriateness of using or redistributing the Work and assume any + risks associated with Your exercise of permissions under this License. + + 8. Limitation of Liability. In no event and under no legal theory, + whether in tort (including negligence), contract, or otherwise, + unless required by applicable law (such as deliberate and grossly + negligent acts) or agreed to in writing, shall any Contributor be + liable to You for damages, including any direct, indirect, special, + incidental, or consequential damages of any character arising as a + result of this License or out of the use or inability to use the + Work (including but not limited to damages for loss of goodwill, + work stoppage, computer failure or malfunction, or any and all + other commercial damages or losses), even if such Contributor + has been advised of the possibility of such damages. + + 9. Accepting Warranty or Additional Liability. While redistributing + the Work or Derivative Works thereof, You may choose to offer, + and charge a fee for, acceptance of support, warranty, indemnity, + or other liability obligations and/or rights consistent with this + License. However, in accepting such obligations, You may act only + on Your own behalf and on Your sole responsibility, not on behalf + of any other Contributor, and only if You agree to indemnify, + defend, and hold each Contributor harmless for any liability + incurred by, or claims asserted against, such Contributor by reason + of your accepting any such warranty or additional liability. + + END OF TERMS AND CONDITIONS + + APPENDIX: How to apply the Apache License to your work. + + To apply the Apache License to your work, attach the following + boilerplate notice, with the fields enclosed by brackets "[]" + replaced with your own identifying information. (Don't include + the brackets!) The text should be enclosed in the appropriate + comment syntax for the file format. We also recommend that a + file or class name and description of purpose be included on the + same "printed page" as the copyright notice for easier + identification within third-party archives. + + Copyright [yyyy] [name of copyright owner] + + 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/atc0005/go-ezproxy/Makefile b/vendor/github.com/atc0005/go-ezproxy/Makefile new file mode 100644 index 00000000..044dbbdc --- /dev/null +++ b/vendor/github.com/atc0005/go-ezproxy/Makefile @@ -0,0 +1,115 @@ +# Copyright 2020 Adam Chalkley +# +# https://github.com/atc0005/go-ezproxy +# +# 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 +# +# https://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. + +SHELL = /bin/bash + +# https://github.com/golangci/golangci-lint#install +# https://github.com/golangci/golangci-lint/releases/latest +GOLANGCI_LINT_VERSION = v1.27.0 + +BUILDCMD = go build -mod=vendor ./... +GOCLEANCMD = go clean -mod=vendor ./... +GITCLEANCMD = git clean -xfd +CHECKSUMCMD = sha256sum -b + +.DEFAULT_GOAL := help + + ########################################################################## + # Targets will not work properly if a file with the same name is ever + # created in this directory. We explicitly declare our targets to be phony + # by making them a prerequisite of the special target .PHONY + ########################################################################## + +.PHONY: help +## help: prints this help message +help: + @echo "Usage:" + @sed -n 's/^##//p' ${MAKEFILE_LIST} | column -t -s ':' | sed -e 's/^/ /' + +.PHONY: lintinstall +## lintinstall: install common linting tools +# https://github.com/golang/go/issues/30515#issuecomment-582044819 +lintinstall: + @echo "Installing linting tools" + + @export PATH="${PATH}:$(go env GOPATH)/bin" + + @echo "Explicitly enabling Go modules mode per command" + (cd; GO111MODULE="on" go get honnef.co/go/tools/cmd/staticcheck) + + @echo Installing golangci-lint ${GOLANGCI_LINT_VERSION} per official binary installation docs ... + curl -sSfL https://raw.githubusercontent.com/golangci/golangci-lint/master/install.sh | sh -s -- -b $(shell go env GOPATH)/bin ${GOLANGCI_LINT_VERSION} + golangci-lint --version + + @echo "Finished updating linting tools" + +.PHONY: linting +## linting: runs common linting checks +linting: + @echo "Running linting tools ..." + + @echo "Running go vet ..." + @go vet -mod=vendor $(shell go list -mod=vendor ./... | grep -v /vendor/) + + @echo "Running golangci-lint ..." + @golangci-lint run + + @echo "Running staticcheck ..." + @staticcheck $(shell go list -mod=vendor ./... | grep -v /vendor/) + + @echo "Finished running linting checks" + +.PHONY: gotests +## gotests: runs go test recursively, verbosely +gotests: + @echo "Running go tests ..." + @go test -mod=vendor ./... + @echo "Finished running go tests" + +.PHONY: goclean +## goclean: removes local build artifacts, temporary files, etc +goclean: + @echo "Removing object files and cached files ..." + @$(GOCLEANCMD) + +.PHONY: clean +## clean: alias for goclean +clean: goclean + +.PHONY: gitclean +## gitclean: WARNING - recursively cleans working tree by removing non-versioned files +gitclean: + @echo "Removing non-versioned files ..." + @$(GITCLEANCMD) + +.PHONY: pristine +## pristine: run goclean and gitclean to remove local changes +pristine: goclean gitclean + +.PHONY: all +# https://stackoverflow.com/questions/3267145/makefile-execute-another-target +## all: run all applicable build steps +all: clean build + @echo "Completed build process ..." + +.PHONY: build +## build: ensure that packages build +build: + @echo "Building packages ..." + + $(BUILDCMD) + + @echo "Completed build tasks" diff --git a/vendor/github.com/atc0005/go-ezproxy/NOTICE.txt b/vendor/github.com/atc0005/go-ezproxy/NOTICE.txt new file mode 100644 index 00000000..f097c13a --- /dev/null +++ b/vendor/github.com/atc0005/go-ezproxy/NOTICE.txt @@ -0,0 +1,14 @@ +Copyright 2020-Present Adam Chalkley +https://github.com/atc0005/go-ezproxy/blob/master/LICENSE + +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/atc0005/go-ezproxy/README.md b/vendor/github.com/atc0005/go-ezproxy/README.md new file mode 100644 index 00000000..d836abb4 --- /dev/null +++ b/vendor/github.com/atc0005/go-ezproxy/README.md @@ -0,0 +1,153 @@ + +# go-ezproxy + +Go library and tooling for working with EZproxy. + +[![Latest Release](https://img.shields.io/github/release/atc0005/go-ezproxy.svg?style=flat-square)][repo-url-release-latest] +[![GoDoc](https://godoc.org/github.com/atc0005/go-ezproxy?status.svg)][docs-homepage] +![Validate Codebase](https://github.com/atc0005/go-ezproxy/workflows/Validate%20Codebase/badge.svg) +![Validate Docs](https://github.com/atc0005/go-ezproxy/workflows/Validate%20Docs/badge.svg) + + +## Table of contents + +- [Status](#status) +- [Overview](#overview) +- [Project home](#project-home) +- [Features](#features) + - [Current](#current) + - [Missing](#missing) +- [Changelog](#changelog) +- [Documentation](#documentation) +- [Examples](#examples) +- [License](#license) +- [References](#references) + - [Related projects](#related-projects) + - [Official EZproxy docs](#official-ezproxy-docs) + +## Status + +Alpha; very much getting a feel for how the project will be structured +long-term and what functionality will be offered. + +The existing functionality was added specifically to support the +in-development [atc0005/brick][related-brick-project]. This library is subject +to change in order to better support that project. + +## Overview + +This library is intended to provide common EZproxy-related functionality such +as reporting or terminating active login sessions (either for all usernames or +specific usernames), filtering (or not) audit file entries or traffic patterns +(not implemented yet) for specific usernames or domains. + +**Just to be perfectly clear**: + +- this library is intended to supplement the provided functionality of the + official OCLC-developed/supported `EZproxy` application, not in any way + replace it. +- this library is not in any way associated with OCLC, `EZproxy` or other + services offered by OCLC. + +## Project home + +See [our GitHub repo][repo-url-home] for the latest code, to file an issue or +submit improvements for review and potential inclusion into the project. + +## Features + +### Current + +- generate a list of audit records for session-related events + - for all usernames + - for a specific username + +- generate a list of active sessions using the audit log + - using entires without a corresponding logout event type + +- generate a list of active sessions using the active file + - for all usernames + - for a specific username + +- terminate user sessions + - single user session + - bulk user sessions + +### Missing + +- Anything to do with traffic log entries +- [Examples](examples/README.md) + +## Changelog + +See the [`CHANGELOG.md`](CHANGELOG.md) file for the changes associated with +each release of this application. Changes that have been merged to `master`, +but not yet an official release may also be noted in the file under the +`Unreleased` section. A helpful link to the Git commit history since the last +official release is also provided for further review. + +## Documentation + +Please see our [GoDoc][docs-homepage] coverage. If something doesn't make +sense, please [file an issue][repo-url-issues] and note what is (or was) unclear. + +## Examples + +Please see our [GoDoc][docs-homepage] coverage for general usage and the +[examples](examples/README.md) doc for a list of applications developed using +this module. + +## License + +Taken directly from the [`LICENSE`](LICENSE) and [`NOTICE.txt`](NOTICE.txt) files: + +```License +Copyright 2020-Present Adam Chalkley + +https://github.com/atc0005/go-ezproxy/blob/master/LICENSE + +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. +``` + +## References + +### Related projects + +- [atc0005/brick][related-brick-project] project + - this project uses this library to provides tools (two as of this writing) + intended to help manage login sessions. + +- + - + - this is the project that proved to me that EZproxy sessions *can* be + terminated programatically. + +### Official EZproxy docs + +- +- +- +- +- +- + + + +[repo-url-home]: "This project's GitHub repo" +[repo-url-issues]: "This project's issues list" +[repo-url-release-latest]: "This project's latest release" + +[docs-homepage]: "GoDoc coverage" + +[related-brick-project]: "atc0005/brick project URL" + + diff --git a/vendor/github.com/atc0005/go-ezproxy/activefile/activefile.go b/vendor/github.com/atc0005/go-ezproxy/activefile/activefile.go new file mode 100644 index 00000000..388374ad --- /dev/null +++ b/vendor/github.com/atc0005/go-ezproxy/activefile/activefile.go @@ -0,0 +1,390 @@ +// Copyright 2020 Adam Chalkley +// +// https://github.com/atc0005/go-ezproxy +// +// 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 +// +// https://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. + +package activefile + +import ( + "bufio" + "errors" + "fmt" + "os" + "strings" + "time" + + "github.com/atc0005/go-ezproxy" +) + +const ( + // All lines containing a session ID (among other details) begin with this + // single letter prefix + SessionLinePrefix string = "S" + + // Indicate that this line should be found on even numbered lines + SessionLineEvenNumbered bool = true + + // All lines containing a username begin with this single letter prefix + UsernameLinePrefix string = "L" + + // Indicate that this line should be found on odd numbered lines + UsernameLineEvenNumbered bool = false +) + +// SessionEntry reflects a line in the ezproxy.hst file that contains session +// information. We have to tie this information back to a specific username +// based on line ordering. The session line comes first in the set followed by +// one or more additional lines, one of which contains the username. +type SessionEntry struct { + + // Type is the first field in the file. Observed entries thus far are "P, + // M, H, S, L, g, s". Those lines relevant to our purposes of matching + // session IDs to usernames are "S" for Session and "L" for Login. + Type string + + // SessionID is the second field for a line in the ActiveFile that starts + // with capital letter 'S'. We need to tie this back to a specific + // username in order to reliably terminate active sessions. + SessionID string + + // IPAddress is the seventh field for aline in the ActiveFile that starts + // with capital letter 'S'. We *could* use this value to determine which + // session ID to terminate, though using this value from a remote + // payload/report by itself has a greater chance of terminating the wrong + // user session. + IPAddress string +} + +// activeFileReader represents a file reader specific to the EZProxy active +// users and hosts file. +type activeFileReader struct { + // SearchDelay is the intentional delay between each attempt to open and + // search the specified filename for the specified username. + SearchDelay time.Duration + + // SearchRetries is the number of additional search attempts that will be + // made whenever the initial search attempt returns zero results. Each + // attempt to read the active file is subject to a race condition; EZproxy + // does not immediately write session information to disk when creating or + // terminating sessions, so some amount of delay and a number of retry + // attempts are used in an effort to work around that write delay. + SearchRetries int + + // Username is the name of the user account to search for within the + // specified file. + Username string + + // Filename is the name of the file which will be parsed/searched for the + // specified username. + Filename string +} + +// NewReader creates a new instance of a SessionReader that provides access to +// a collection of user sessions for the specified username. +func NewReader(username string, filename string) (ezproxy.SessionsReader, error) { + + if username == "" { + return nil, errors.New( + "func NewReader: missing username", + ) + } + + if filename == "" { + return nil, errors.New( + "func NewReader: missing filename", + ) + } + + reader := activeFileReader{ + SearchDelay: ezproxy.DefaultSearchDelay, + SearchRetries: ezproxy.DefaultSearchRetries, + Username: username, + Filename: filename, + } + + return &reader, nil +} + +// filterEntries is a helper function that returns all entries from the +// provided active file that have the required line prefix. Other methods +// handle converting these entries to UserSession values. +func (afr activeFileReader) filterEntries(validPrefixes []string) ([]ezproxy.FileEntry, error) { + + ezproxy.Logger.Printf("filterEntries: Attempting to open %q\n", afr.Filename) + + f, err := os.Open(afr.Filename) + if err != nil { + return nil, fmt.Errorf("func filterEntries: error encountered opening file %q: %w", afr.Filename, err) + } + defer f.Close() + + s := bufio.NewScanner(f) + + var lineno int + + var validLines []ezproxy.FileEntry + + // TODO: Does Scan() perform any whitespace manipulation already? + for s.Scan() { + lineno++ + currentLine := s.Text() + //ezproxy.Logger.Printf("Scanned line %d from %q: %q\n", lineno, filename, currentLine) + + currentLine = strings.TrimSpace(currentLine) + // ezproxy.Logger.Printf("Line %d from %q after whitespace removal: %q\n", + // lineno, filename, currentLine) + + if currentLine != "" { + for _, validPrefix := range validPrefixes { + if strings.HasPrefix(currentLine, validPrefix) { + validLines = append(validLines, ezproxy.FileEntry{ + Text: currentLine, + Number: lineno, + }) + } + } + } + } + + ezproxy.Logger.Printf("Exited s.Scan() loop") + + // report any errors encountered while scanning the input file + if err := s.Err(); err != nil { + return nil, fmt.Errorf("func filterEntries: errors encountered while scanning the input file: %w", err) + } + + return validLines, nil +} + +// SetSearchRetries is a helper method for setting the number of additional +// retries allowed when receiving zero search results. +func (afr *activeFileReader) SetSearchRetries(retries int) error { + if retries < 0 { + return fmt.Errorf("func SetSearchRetries: %d is not a valid number of search retries", retries) + } + + afr.SearchRetries = retries + + return nil +} + +// SetSearchDelay is a helper method for setting the delay in seconds between +// search attempts. +func (afr *activeFileReader) SetSearchDelay(delay int) error { + if delay < 0 { + return fmt.Errorf("func SetSearchDelay: %d is not a valid number of seconds for search delay", delay) + } + + afr.SearchDelay = time.Duration(delay) * time.Second + + return nil +} + +// AllUserSessions returns a list of all session IDs along with their associated +// IP Address in the form of a slice of UserSession values. This list of +// session IDs is intended for further processing such as filtering to a +// specific username or aggregating to check thresholds. +func (afr activeFileReader) AllUserSessions() (ezproxy.UserSessions, error) { + + // Lines containing the session entries + validPrefixes := []string{ + SessionLinePrefix, + UsernameLinePrefix, + } + + var allUserSessions ezproxy.UserSessions + + fileEntryDelimiter := " " + + validLines, filterErr := afr.filterEntries(validPrefixes) + if filterErr != nil { + return nil, fmt.Errorf( + "failed to filter active file entries while generating list of user sessions: %w", + filterErr, + ) + } + + // Ensure that the gathered lines consist of pairs, otherwise we are + // likely dealing with an invalid active users file. At this point we + // should bail as continuing would likely mean identifying the wrong user + // session for termination. + if !(len(validLines)%2 == 0) { + errMsg := fmt.Sprintf( + "error: Incomplete data pairs (%d lines) found in file %q while searching for %q user sessions", + len(validLines), + afr.Filename, + afr.Username, + ) + ezproxy.Logger.Println(errMsg) + return nil, errors.New(errMsg) + } + + for idx, currentLine := range validLines { + + activeFileEntry := strings.Split(currentLine.Text, fileEntryDelimiter) + lineno := currentLine.Number + switch activeFileEntry[0] { + case SessionLinePrefix: + // line 1 of 2 (even numbered idx) + // session ID as field 2, IP Address as field 7 + + // if not even numbered line, but the username only occurs on even + // numbered lines + // if !(idx%2 == 0) { + if (idx%2 == 1) && (SessionLineEvenNumbered) { + + // We have found a "S" prefixed line out of expected order. + // This suggests an invalid active users file or a bug in the + // earlier application logic used when generating the list of + // valid file entries. + + errMsg := fmt.Sprintf( + "error: Unexpected data pair ordering encountered at line %d in the active users file %q while searching for %q; "+ + "session line is odd numbered", + lineno, + afr.Filename, + afr.Username, + ) + ezproxy.Logger.Println(errMsg) + return nil, errors.New(errMsg) + } + + allUserSessions = append(allUserSessions, ezproxy.UserSession{ + SessionID: activeFileEntry[1], + IPAddress: activeFileEntry[6], + }) + case UsernameLinePrefix: + // line 2 of 2 (odd numbered idx) + // username as field 2 + + if (idx%2 == 1) && (UsernameLineEvenNumbered) { + + // We have found a "L" prefixed line out of expected order. + // This suggests an invalid active users file or a bug in the + // earlier application logic used when generating the list of + // valid file entries. + + errMsg := fmt.Sprintf( + "error: Unexpected data pair ordering encountered at line %d in the active users file %q while searching for %q; "+ + "session line is odd numbered", + lineno, + afr.Filename, + afr.Username, + ) + ezproxy.Logger.Println(errMsg) + return nil, errors.New(errMsg) + } + + // Use the length of the collected user sessions minus 1 as the + // index into the allUserSessions slice. The intent is to get + // access to the partial ActiveUserSession that was just + // constructed from the previous 'S' line in order to include the + // username alongside the existing Session ID and IP Address + // fields. + prevSessionIdx := len(allUserSessions) - 1 + if prevSessionIdx < 0 { + + ezproxy.Logger.Printf( + "Current text from username line %d: %v", + lineno, + activeFileEntry, + ) + + errMsg := fmt.Sprintf( + "error: unable to update partial ActiveUserSession from line %d; "+ + "unable to reliably determine session ID for %q", + lineno-1, + afr.Username, + ) + ezproxy.Logger.Println(errMsg) + return nil, errors.New(errMsg) + } + allUserSessions[prevSessionIdx].Username = activeFileEntry[1] + default: + continue + } + + } + + ezproxy.Logger.Printf( + "Found %d active sessions\n", + len(allUserSessions), + ) + + return allUserSessions, nil + +} + +// MatchingUserSessions uses the previously provided username to return a list +// of all matching session IDs along with their associated IP Address in the +// form of a slice of UserSession values. +func (afr activeFileReader) MatchingUserSessions() (ezproxy.UserSessions, error) { + + // What we will return to the the caller + requestedUserSessions := make([]ezproxy.UserSession, 0, ezproxy.SessionsLimit) + + searchAttemptsAllowed := afr.SearchRetries + 1 + + // Perform the search up to X times + for searchAttempts := 1; searchAttempts <= searchAttemptsAllowed; searchAttempts++ { + + ezproxy.Logger.Printf( + "Beginning search attempt %d of %d for %q\n", + searchAttempts, + searchAttemptsAllowed, + afr.Username, + ) + + // Intentional delay in an effort to better avoid stale data due to + // potential race condition with EZproxy write delays. + ezproxy.Logger.Printf( + "Intentionally delaying for %v to help avoid race condition due to delayed EZproxy writes\n", + afr.SearchDelay, + ) + time.Sleep(afr.SearchDelay) + + allUserSessions, err := afr.AllUserSessions() + if err != nil { + return nil, fmt.Errorf( + "func UserSessions: failed to retrieve all user sessions in order to filter to specific username: %w", + err, + ) + } + + // filter all user sessions found earlier just to the requested user + for _, session := range allUserSessions { + if strings.EqualFold(afr.Username, session.Username) { + requestedUserSessions = append(requestedUserSessions, session) + } + } + + // skip further attempts to find sessions if we already found some + if len(requestedUserSessions) > 0 { + break + } + + // try again (unless we hit our limit) + continue + + } + + ezproxy.Logger.Printf( + "Found %d active sessions for %q\n", + len(requestedUserSessions), + afr.Username, + ) + + return requestedUserSessions, nil + +} diff --git a/vendor/github.com/atc0005/go-ezproxy/activefile/doc.go b/vendor/github.com/atc0005/go-ezproxy/activefile/doc.go new file mode 100644 index 00000000..4672d344 --- /dev/null +++ b/vendor/github.com/atc0005/go-ezproxy/activefile/doc.go @@ -0,0 +1,19 @@ +// Copyright 2020 Adam Chalkley +// +// https://github.com/atc0005/go-ezproxy +// +// 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 +// +// https://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. + +// Package activefile is intended for the processing of EZproxy active users +// and hosts files. +package activefile diff --git a/vendor/github.com/atc0005/go-ezproxy/doc.go b/vendor/github.com/atc0005/go-ezproxy/doc.go new file mode 100644 index 00000000..aeaa10a8 --- /dev/null +++ b/vendor/github.com/atc0005/go-ezproxy/doc.go @@ -0,0 +1,73 @@ +// Copyright 2020 Adam Chalkley +// +// https://github.com/atc0005/go-ezproxy +// +// 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 +// +// https://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. + +/* + +Package ezproxy is intended for the processing of EZproxy related files and +sessions. + +PROJECT HOME + +See our GitHub repo (https://github.com/atc0005/go-ezproxy) for the latest +code, to file an issue or submit improvements for review and potential +inclusion into the project. + +PURPOSE + +Process EZproxy related files and sessions. + +FEATURES + +• generate a list of audit records for session-related events for all usernames or just for a specific username + +• generate a list of active sessions using the audit log using entires without a corresponding logout event type + +• generate a list of active sessions using the active file for all usernames or just for a specific username + +• terminate single user session or bulk user sessions + +OVERVIEW + +Ultimately, this package was written in order to support retrieving session +information for a specific username so that the session can be terminated. +Because of this the majority of the functionality is specific to user +sessions. + +General workflow: + +1. Import this package +2. Import one or more of the subpackages +3. Create a new reader for the file type you need to work with +4. Using the new reader, generate a UserSessions collection +5. Use the Terminate method to terminate user sessions + +If using the ezproxy/auditlog package, you can also generate a SessionEntries +collection representing all SessionEntry values from a specified audit log +file or just the values applicable to a specifc user. + +FUTURE + +This package currently provides functionality for working with an active user +or audit log file, but not for EZproxy traffic log files. Having minimal +support for traffic log files could provide a way to die activity for a +specific user account to specifc resources accessed by that user account. This +could prove invaluable where automation is used to automatically terminate +user sessions; after account termination, a report could be generated for the +incident listing at a high-level the providers accessed and general statistics +associated with the access (e.g., PDF downloads, total bandwidth, etc.). + +*/ +package ezproxy diff --git a/vendor/github.com/atc0005/go-ezproxy/ezproxy.go b/vendor/github.com/atc0005/go-ezproxy/ezproxy.go new file mode 100644 index 00000000..c268a2bd --- /dev/null +++ b/vendor/github.com/atc0005/go-ezproxy/ezproxy.go @@ -0,0 +1,179 @@ +// Copyright 2020 Adam Chalkley +// +// https://github.com/atc0005/go-ezproxy +// +// 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 +// +// https://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. + +package ezproxy + +import ( + "io/ioutil" + "log" + "os" + "time" +) + +// Logger is a package logger that can be enabled from client code to allow +// logging output from this package when desired/needed for troubleshooting. +// This variable is exported in order to allow subpackages to use it without +// defining their own. The intent is to make it easier for consumers of the +// package to have one set of methods for enabling or disabling logging output +// for this package and subpackages. +var Logger *log.Logger + +func init() { + + // Disable logging output by default unless client code explicitly + // requests it + Logger = log.New(os.Stderr, "[ezproxy] ", 0) + Logger.SetOutput(ioutil.Discard) + +} + +// EnableLogging enables logging output from this package. Output is muted by +// default unless explicitly requested (by calling this function). +func EnableLogging() { + Logger.SetFlags(log.Ldate | log.Ltime | log.Lshortfile) + Logger.SetOutput(os.Stderr) +} + +// DisableLogging reapplies default package-level logging settings of muting +// all logging output. +func DisableLogging() { + Logger.SetFlags(0) + Logger.SetOutput(ioutil.Discard) +} + +// These "SessionsLimit" constants are used as preallocation values for maps +// and slices. +const ( + + // This is intended to approximate the `::Limit=X` value (where X is a + // positive whole number) set within the user.txt EZproxy config file. + // This package uses this value as a preallocation capacity value for maps + // and slices. + SessionsLimit int = 4 + + // This is simply a guess to use as a baseline for preallocating maps and + // slices capacity in regards to ALL user sessions + AllUsersSessionsLimit int = SessionsLimit * 10 +) + +// These are the known/confirmed details regarding Session IDs as of the 6.x +// series. +const ( + SessionIDLength int = 15 + SessionIDRegex string = "[a-zA-Z0-9]{15}" +) + +// These are the known EZproxy binary exit codes and the associated output as +// of the 6.x series. Please open an issue +// (https://github.com/atc0005/go-ezproxy/issues) if you encounter others not +// listed here. +const ( + + // KillSubCmdExitCodeSessionTerminated is the exit code for sessions that + // are successfully terminated via the `ezproxy kill` subcommand. + KillSubCmdExitCodeSessionTerminated int = 0 + + // KillSubCmdExitTextTemplateSessionTerminated is a formatted string + // template for the output shown when a session is successfully + // terminated via the `ezproxy kill` subcommand. + KillSubCmdExitTextTemplateSessionTerminated string = "Session %s terminated" + + // KillSubCmdExitCodeSessionNotSpecified is the exit code for calling + // `ezproxy kill` without specifying a session id. + KillSubCmdExitCodeSessionNotSpecified int = 1 + + // KillSubCmdExitTextSessionNotSpecified is the string returned when + // calling `ezproxy kill` without specifying a session id. + KillSubCmdExitTextSessionNotSpecified string = "Session must be specified" + + // KillSubCmdExitCodeSessionDoesNotExist is the exit code for attempts to + // terminate a session that EZproxy does not believe exists. + KillSubCmdExitCodeSessionDoesNotExist int = 3 + + // KillSubCmdExitTextTemplateSessionDoesNotExist is a formatted string + // template for the output shown when an attempt is made to terminate a + // session that EZproxy does not believe exists. + KillSubCmdExitTextTemplateSessionDoesNotExist string = "Session %s does not exist" +) + +const ( + + // BinaryName is a constant for the name of the EZproxy application binary. + BinaryName string = "ezproxy" + + // SubCmdNameSessionTerminate is the name of the EZproxy application + // subcommand used to terminate user sessions. + SubCmdNameSessionTerminate string = "kill" +) + +const ( + + // DefaultSearchDelay is the delay applied before attempting to read + // either of the Audit File or Active Users File. This intentional delay + // is applied in an effort to account for time between EZproxy noting an + // event and recording it to the file we are reading. + DefaultSearchDelay time.Duration = 1 * time.Second + + // DefaultSearchRetries is the number of retries beyond the first attempt + // that will be made after the first attempt at finding active sessions + // for a specified username yields no results. + DefaultSearchRetries int = 7 +) + +// FileEntry reflects a line of text found in a file and the line number +// associated with it +type FileEntry struct { + Text string + Number int +} + +// A UserSession represents a session for a specific user account. These +// values are returned after processing either an audit file or the active +// file. +type UserSession struct { + // SessionID SessionID + SessionID string + IPAddress string + Username string +} + +// UserSessions is a collection of UserSession values. Intended for +// aggregation before bulk processing of some kind. +type UserSessions []UserSession + +// SessionsReader is an interface used as the API for retrieving user sessions +// from one of the audit log or active users and hosts files. +type SessionsReader interface { + + // AllUserSessions returns a list of all session IDs along with their associated + // IP Address in the form of a slice of UserSession values. This list of + // session IDs is intended for further processing such as filtering to a + // specific username or aggregating to check thresholds. + AllUserSessions() (UserSessions, error) + + // MatchingUserSessions uses the previously provided username to return a + // list of all matching session IDs along with their associated IP Address + // in the form of a slice of UserSession values. + MatchingUserSessions() (UserSessions, error) + + // SetSearchRetries is a helper method for setting the number of additional + // retries allowed when receiving zero search results. + SetSearchRetries(retries int) error + + // SetSearchDelay is a helper method for setting the delay in seconds between + // search attempts. + SetSearchDelay(delay int) error +} diff --git a/vendor/github.com/atc0005/go-ezproxy/go.mod b/vendor/github.com/atc0005/go-ezproxy/go.mod new file mode 100644 index 00000000..4bb0f02b --- /dev/null +++ b/vendor/github.com/atc0005/go-ezproxy/go.mod @@ -0,0 +1,19 @@ +// Copyright 2020 Adam Chalkley +// +// https://github.com/atc0005/go-ezproxy +// +// 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 +// +// https://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. + +module github.com/atc0005/go-ezproxy + +go 1.13 diff --git a/vendor/github.com/atc0005/go-ezproxy/terminate.go b/vendor/github.com/atc0005/go-ezproxy/terminate.go new file mode 100644 index 00000000..be7ab774 --- /dev/null +++ b/vendor/github.com/atc0005/go-ezproxy/terminate.go @@ -0,0 +1,180 @@ +// Copyright 2020 Adam Chalkley +// +// https://github.com/atc0005/go-ezproxy +// +// 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 +// +// https://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. + +package ezproxy + +import ( + "bytes" + "os/exec" + "strings" +) + +// Terminator is an interface that represents the ability to terminate user +// sessions via the Terminate method. +type Terminator interface { + Terminate() TerminateUserSessionResults +} + +// TerminateUserSessionResult reflects the result of calling the `kill` +// subcommand of the ezproxy binary to terminate a specific user session. +type TerminateUserSessionResult struct { + + // UserSession value is embedded in an attempt to tie together termination + // results and the original session values in order to make processing + // related values more reliable/easier in deeper layers of client code. + UserSession + + // ExitCode is what the command called by this application returns + ExitCode int + + // StdOut is the output (if any) sent to stdout by the command called from + // this application + StdOut string + + // StdErr is the output (if any) sent to stderr by the command called from + // this application + StdErr string + + // Error is the error (if any) from the attempt to run the specified + // command + Error error +} + +// TerminateUserSessionResults is a collection of user session termination +// results. Intended for bulk processing of some kind. +type TerminateUserSessionResults []TerminateUserSessionResult + +// TerminateUserSession receives the path to an executable and one or many +// UserSession values, calling the `kill` subcommand of that (presumably +// ezproxy) binary. The result code, stdout, stderr output is captured for +// each subcommand call and returned (along with other details) as a slice of +// `TerminateUserSessionResult`. +func TerminateUserSession(executable string, sessions ...UserSession) TerminateUserSessionResults { + + results := make([]TerminateUserSessionResult, 0, SessionsLimit) + + for _, session := range sessions { + + Logger.Printf( + "Terminating session %q for username %q ... ", + session.SessionID, + session.Username, + ) + + // cmd := exec.Command( + // "echo", + // "hello", + // ) + cmd := exec.Command( + executable, + SubCmdNameSessionTerminate, + session.SessionID, + ) + + printCmdStr := func(cmd *exec.Cmd) string { + return strings.Join(cmd.Args, " ") + } + + Logger.Printf("Executing: %s\n", printCmdStr(cmd)) + + // setup buffer to capture stdout + var cmdStdOut bytes.Buffer + cmd.Stdout = &cmdStdOut + + //setup buffer to capture stderr + var cmdStdErr bytes.Buffer + cmd.Stderr = &cmdStdErr + + cmdErr := cmd.Run() + if cmdErr != nil { + + switch v := cmdErr.(type) { + + // returned by LookPath when it fails to classify a file as an + // executable. + case *exec.Error: + + Logger.Printf( + "An error occurred attempting to run %q: %v\n", + printCmdStr(cmd), + v.Error(), + ) + + // command fail; non-zero (unsuccessful) exit code + case *exec.ExitError: + + if cmd.ProcessState.ExitCode() == -1 { + Logger.Println("-1 returned from ExitCode() method") + + if cmd.ProcessState.Exited() { + Logger.Println("cmd has exited per Exited() method") + } else { + Logger.Println("cmd has NOT exited per Exited() method") + } + } + + default: + + Logger.Printf( + "An unexpected error occurred attempting to run %q: [Type: %T Text: %q]\n", + printCmdStr(cmd), + cmdErr, + cmdErr.Error(), + ) + + } + + } + + Logger.Printf("Exit Code: %d\n", cmd.ProcessState.ExitCode()) + Logger.Printf("Captured stdout: %s\n", cmdStdOut.String()) + Logger.Printf("Captured stderr: %s\n", cmdStdErr.String()) + + result := TerminateUserSessionResult{ + UserSession: session, + ExitCode: cmd.ProcessState.ExitCode(), + StdOut: strings.TrimSpace(cmdStdOut.String()), + StdErr: strings.TrimSpace(cmdStdErr.String()), + Error: cmdErr, + } + + results = append(results, result) + + } + + return results + +} + +// Terminate attempts to process each UserSession using the provided +// executable, returning the result code, stdout, stderr output as captured +// for each subcommand call (along with other details) as a slice of +// `TerminateUserSessionResult`. +func (us UserSessions) Terminate(executable string) TerminateUserSessionResults { + return TerminateUserSession(executable, us...) +} + +// HasError returns true if any errors were recorded when terminating user +// sessions, false otherwise. +func (tusr TerminateUserSessionResults) HasError() bool { + for idx := range tusr { + if tusr[idx].Error != nil { + return true + } + } + + return false +} diff --git a/vendor/modules.txt b/vendor/modules.txt index 60132801..8fc1f872 100644 --- a/vendor/modules.txt +++ b/vendor/modules.txt @@ -9,6 +9,9 @@ github.com/apex/log/handlers/discard github.com/apex/log/handlers/json github.com/apex/log/handlers/logfmt github.com/apex/log/handlers/text +# github.com/atc0005/go-ezproxy v0.1.2 +github.com/atc0005/go-ezproxy +github.com/atc0005/go-ezproxy/activefile # github.com/atc0005/go-teams-notify v1.3.1-0.20200419155834-55cca556e726 github.com/atc0005/go-teams-notify # github.com/atc0005/send2teams v0.4.4