Files
certspotter/monitor/notify.go
Andrew Ayer 209cdb181b Convert to a daemon and make many other improvements
Specifically, certspotter no longer terminates unless it receives SIGTERM
or SIGINT or there is a serious error.

Although using cron made sense in the early days of Certificate
Transparency, certspotter now needs to run continuously to reliably keep
up with the high growth rate of contemporary CT logs, and to gracefully
handle the many transient errors that can arise when monitoring CT.

Closes: #63
Closes: #37
Closes: #32 (presumably by eliminating $DNS_NAMES and $IP_ADDRESSES)
Closes: #21 (with $WATCH_ITEM)
Closes: #25
2023-02-03 14:12:03 -05:00

152 lines
4.2 KiB
Go

// Copyright (C) 2023 Opsmate, Inc.
//
// This Source Code Form is subject to the terms of the Mozilla
// Public License, v. 2.0. If a copy of the MPL was not distributed
// with this file, You can obtain one at http://mozilla.org/MPL/2.0/.
//
// This software is distributed WITHOUT A WARRANTY OF ANY KIND.
// See the Mozilla Public License for details.
package monitor
import (
"bytes"
"context"
"errors"
"fmt"
"io/fs"
"os"
"os/exec"
"path/filepath"
"strings"
"sync"
)
var stdoutMu sync.Mutex
type notification interface {
Environ() []string
EmailSubject() string
Text() string
}
func notify(ctx context.Context, config *Config, notif notification) error {
if config.Stdout {
writeToStdout(notif)
}
if len(config.Email) > 0 {
if err := sendEmail(ctx, config.Email, notif); err != nil {
return err
}
}
if config.Script != "" {
if err := execScript(ctx, config.Script, notif); err != nil {
return err
}
}
return nil
}
func writeToStdout(notif notification) {
stdoutMu.Lock()
defer stdoutMu.Unlock()
os.Stdout.WriteString(notif.Text() + "\n")
}
func sendEmail(ctx context.Context, to []string, notif notification) error {
stdin := new(bytes.Buffer)
stderr := new(bytes.Buffer)
fmt.Fprintf(stdin, "To: %s\n", strings.Join(to, ", "))
fmt.Fprintf(stdin, "Subject: %s\n", notif.EmailSubject())
fmt.Fprintf(stdin, "Mime-Version: 1.0\n")
fmt.Fprintf(stdin, "Content-Type: text/plain; charset=US-ASCII\n")
fmt.Fprintf(stdin, "X-Mailer: certspotter\n")
fmt.Fprintf(stdin, "\n")
fmt.Fprint(stdin, notif.Text())
args := []string{"-i", "--"}
args = append(args, to...)
sendmail := exec.CommandContext(ctx, "/usr/sbin/sendmail", args...)
sendmail.Stdin = stdin
sendmail.Stderr = stderr
if err := sendmail.Run(); err == nil {
return nil
} else if ctx.Err() != nil {
return ctx.Err()
} else if exitErr, isExitError := err.(*exec.ExitError); isExitError && exitErr.Exited() {
return fmt.Errorf("error sending email to %v: sendmail failed with exit code %d and error %q", to, exitErr.ExitCode(), strings.TrimSpace(stderr.String()))
} else {
return fmt.Errorf("error sending email to %v: %w", to, err)
}
}
func execScript(ctx context.Context, scriptPath string, notif notification) error {
// TODO-3: consider removing directory support (for now), and supporting $PATH lookups
info, err := os.Stat(scriptPath)
if errors.Is(err, fs.ErrNotExist) {
return fmt.Errorf("script %q does not exist", scriptPath)
} else if err != nil {
return fmt.Errorf("error executing script %q: %w", scriptPath, err)
} else if info.IsDir() {
return execScriptDir(ctx, scriptPath, notif)
} else {
return execScriptFile(ctx, scriptPath, notif)
}
}
func execScriptDir(ctx context.Context, dirPath string, notif notification) error {
dirents, err := os.ReadDir(dirPath)
if err != nil {
return fmt.Errorf("error executing scripts in directory %q: %w", dirPath, err)
}
for _, dirent := range dirents {
if strings.HasPrefix(dirent.Name(), ".") {
continue
}
scriptPath := filepath.Join(dirPath, dirent.Name())
info, err := os.Stat(scriptPath)
if errors.Is(err, fs.ErrNotExist) {
continue
} else if err != nil {
return fmt.Errorf("error executing %q in directory %q: %w", dirent.Name(), dirPath, err)
} else if info.Mode().IsRegular() && isExecutable(info.Mode()) {
if err := execScriptFile(ctx, scriptPath, notif); err != nil {
return err
}
}
}
return nil
}
func execScriptFile(ctx context.Context, scriptPath string, notif notification) error {
stderr := new(bytes.Buffer)
cmd := exec.CommandContext(ctx, scriptPath)
cmd.Env = os.Environ()
cmd.Env = append(cmd.Env, notif.Environ()...)
cmd.Stderr = stderr
if err := cmd.Run(); err == nil {
return nil
} else if ctx.Err() != nil {
return ctx.Err()
} else if exitErr, isExitError := err.(*exec.ExitError); isExitError && exitErr.Exited() {
return fmt.Errorf("script %q exited with code %d and error %q", scriptPath, exitErr.ExitCode(), strings.TrimSpace(stderr.String()))
} else if isExitError {
return fmt.Errorf("script %q terminated by signal with error %q", scriptPath, strings.TrimSpace(stderr.String()))
} else {
return fmt.Errorf("error executing script %q: %w", scriptPath, err)
}
}
func isExecutable(mode os.FileMode) bool {
return mode&0111 != 0
}