Compare commits

...

12 Commits

Author SHA1 Message Date
Andrew Ayer
8435e9046a Release v0.21.0 2025-07-02 16:45:31 -04:00
Andrew Ayer
86873ee4a8 Update man page about error handling 2025-06-29 17:55:12 -04:00
Andrew Ayer
b9e9bd0471 Print non-log errors (e.g. log list download failure) to stderr
These are important and should not happen very often.
2025-06-29 17:35:00 -04:00
Andrew Ayer
bcefb76275 Remove unused code 2025-06-29 17:33:07 -04:00
Andrew Ayer
4fbbc5818e Store log errors in state directory
Instead of writing log errors to stderr, write them to a file in the state directory. When reporting a health check failure, include the path to the file and the last several lines.

Log files are named by date, and the last 7 days are kept.

Closes #106
2025-06-29 17:23:38 -04:00
Andrew Ayer
5a8dd2ca82 Improve -version and User-Agent 2025-06-29 17:18:42 -04:00
Andrew Ayer
b649b399e4 Do not run actions on pull requests
It's a security minefield.  Thanks to caching of the build environment,
not even read-only actions are safe.
2025-06-23 23:20:54 -04:00
Andrew Ayer
aecfa745ca Add GitHub Actions for test and lint 2025-06-23 23:10:11 -04:00
Andrew Ayer
f5779c283c Add staticcheck configuration 2025-06-23 23:10:05 -04:00
Andrew Ayer
3e811e86d7 Decapitalize some error messages 2025-06-23 22:33:57 -04:00
Andrew Ayer
a4048f47f8 Send helpful User-Agent string with all requests 2025-06-23 16:32:35 -04:00
Daniel Peukert
187aed078c
Fix fmt typos 2025-06-23 19:27:39 +02:00
17 changed files with 300 additions and 64 deletions

35
.github/workflows/test.yml vendored Normal file
View File

@ -0,0 +1,35 @@
name: Test and lint Go Code
on:
push:
schedule:
- cron: '42 9 * * *' # Runs daily at 09:42 UTC
workflow_dispatch: # Allows manual triggering
permissions:
contents: read
jobs:
test:
name: Test
runs-on: ubuntu-latest
steps:
- name: Checkout repository
uses: actions/checkout@v4
with:
fetch-depth: 0
- name: Set up Go
uses: actions/setup-go@v5
with:
go-version-file: go.mod
- name: Run tests
run: CGO_ENABLED=1 go test -race ./...
- name: Install staticcheck
run: go install honnef.co/go/tools/cmd/staticcheck@latest
- name: Run staticcheck
run: staticcheck ./...

View File

@ -1,5 +1,10 @@
# Change Log # Change Log
## v0.21.0 (2025-07-02)
- Instead of writing log errors to stderr, save the last 7 days worth in the
state directory, and include recent errors in failed health check notifications.
- Send a meaningful User-Agent string with HTTP requests.
## v0.20.1 (2025-06-19) ## v0.20.1 (2025-06-19)
- Add resilience against sendmail hanging indefinitely. - Add resilience against sendmail hanging indefinitely.
- Add resilience against hooks which fork and keep stderr open. - Add resilience against hooks which fork and keep stderr open.

View File

@ -46,7 +46,7 @@ func decodeASN1String(value *asn1.RawValue) (string, error) {
if value.Tag == 12 { if value.Tag == 12 {
// UTF8String // UTF8String
if !utf8.Valid(value.Bytes) { if !utf8.Valid(value.Bytes) {
return "", errors.New("Malformed UTF8String") return "", errors.New("malformed UTF8String")
} }
return string(value.Bytes), nil return string(value.Bytes), nil
} else if value.Tag == 19 || value.Tag == 22 || value.Tag == 20 || value.Tag == 26 { } else if value.Tag == 19 || value.Tag == 22 || value.Tag == 20 || value.Tag == 26 {
@ -74,5 +74,5 @@ func decodeASN1String(value *asn1.RawValue) (string, error) {
return stringFromUint32Slice(runes), nil return stringFromUint32Slice(runes), nil
} }
} }
return "", errors.New("Not a string") return "", errors.New("not a string")
} }

View File

@ -253,5 +253,5 @@ func decodeASN1Time(value *asn1.RawValue) (time.Time, error) {
return parseGeneralizedTime(value.Bytes) return parseGeneralizedTime(value.Bytes)
} }
} }
return time.Time{}, errors.New("Not a time value") return time.Time{}, errors.New("not a time value")
} }

View File

@ -25,22 +25,22 @@ import (
"syscall" "syscall"
"time" "time"
"software.sslmate.com/src/certspotter/ctclient"
"software.sslmate.com/src/certspotter/loglist" "software.sslmate.com/src/certspotter/loglist"
"software.sslmate.com/src/certspotter/monitor" "software.sslmate.com/src/certspotter/monitor"
) )
var programName = os.Args[0] var programName = os.Args[0]
var Version = "" var Version = "unknown"
var Source = "unknown"
const defaultLogList = "https://loglist.certspotter.org/monitor.json" const defaultLogList = "https://loglist.certspotter.org/monitor.json"
func certspotterVersion() string { func certspotterVersion() (string, string) {
if Version != "" { if buildinfo, ok := debug.ReadBuildInfo(); ok && strings.HasPrefix(buildinfo.Main.Version, "v") {
return Version + "?" return strings.TrimPrefix(buildinfo.Main.Version, "v"), buildinfo.Main.Path
} else if info, ok := debug.ReadBuildInfo(); ok && strings.HasPrefix(info.Main.Version, "v") {
return info.Main.Version
} else { } else {
return "unknown" return Version, Source
} }
} }
@ -138,7 +138,10 @@ func appendFunc(slice *[]string) func(string) error {
} }
func main() { func main() {
loglist.UserAgent = fmt.Sprintf("certspotter/%s (%s; %s; %s)", certspotterVersion(), runtime.Version(), runtime.GOOS, runtime.GOARCH) version, source := certspotterVersion()
ctclient.UserAgent = fmt.Sprintf("certspotter/%s (%s; %s; %s; %s; +https://github.com/SSLMate/certspotter)", version, source, runtime.Version(), runtime.GOOS, runtime.GOARCH)
loglist.UserAgent = ctclient.UserAgent
var flags struct { var flags struct {
batchSize bool batchSize bool
@ -173,7 +176,7 @@ func main() {
os.Exit(2) os.Exit(2)
} }
if flags.version { if flags.version {
fmt.Fprintf(os.Stdout, "certspotter version %s\n", certspotterVersion()) fmt.Fprintf(os.Stdout, "certspotter version %s (%s)\n", version, source)
os.Exit(0) os.Exit(0)
} }
if flags.watchlist == "" { if flags.watchlist == "" {
@ -189,7 +192,6 @@ func main() {
ScriptDir: defaultScriptDir(), ScriptDir: defaultScriptDir(),
Email: flags.email, Email: flags.email,
Stdout: flags.stdout, Stdout: flags.stdout,
Quiet: !flags.verbose,
} }
config := &monitor.Config{ config := &monitor.Config{
LogListSource: flags.logs, LogListSource: flags.logs,
@ -238,6 +240,19 @@ func main() {
ctx, stop := signal.NotifyContext(context.Background(), os.Interrupt, syscall.SIGTERM) ctx, stop := signal.NotifyContext(context.Background(), os.Interrupt, syscall.SIGTERM)
defer stop() defer stop()
go func() {
ticker := time.NewTicker(24*time.Hour)
defer ticker.Stop()
for {
fsstate.PruneOldErrors()
select {
case <-ctx.Done():
return
case <-ticker.C:
}
}
}()
if err := monitor.Run(ctx, config); ctx.Err() == context.Canceled && errors.Is(err, context.Canceled) { if err := monitor.Run(ctx, config); ctx.Err() == context.Canceled && errors.Is(err, context.Canceled) {
if flags.verbose { if flags.verbose {
fmt.Fprintf(os.Stderr, "%s: exiting due to SIGINT or SIGTERM\n", programName) fmt.Fprintf(os.Stderr, "%s: exiting due to SIGINT or SIGTERM\n", programName)

View File

@ -24,6 +24,8 @@ import (
"time" "time"
) )
var UserAgent = ""
// Create an HTTP client suitable for communicating with CT logs. dialContext, if non-nil, is used for dialing. // Create an HTTP client suitable for communicating with CT logs. dialContext, if non-nil, is used for dialing.
func NewHTTPClient(dialContext func(context.Context, string, string) (net.Conn, error)) *http.Client { func NewHTTPClient(dialContext func(context.Context, string, string) (net.Conn, error)) *http.Client {
return &http.Client{ return &http.Client{
@ -61,7 +63,7 @@ func get(ctx context.Context, httpClient *http.Client, fullURL string) ([]byte,
if err != nil { if err != nil {
return nil, err return nil, err
} }
request.Header.Set("User-Agent", "") // Don't send a User-Agent to make life harder for malicious logs request.Header.Set("User-Agent", UserAgent)
if httpClient == nil { if httpClient == nil {
httpClient = defaultHTTPClient httpClient = defaultHTTPClient

View File

@ -21,7 +21,7 @@ import (
"time" "time"
) )
var UserAgent = "certspotter" var UserAgent = ""
type ModificationToken struct { type ModificationToken struct {
etag string etag string
@ -112,7 +112,7 @@ func Unmarshal(jsonBytes []byte) (*List, error) {
return nil, err return nil, err
} }
if err := list.Validate(); err != nil { if err := list.Validate(); err != nil {
return nil, fmt.Errorf("Invalid log list: %s", err) return nil, fmt.Errorf("invalid log list: %s", err)
} }
return list, nil return list, nil
} }

View File

@ -53,8 +53,8 @@ You can use Cert Spotter to detect:
: Filename or HTTPS URL of a v2 or v3 JSON log list containing logs to monitor. : Filename or HTTPS URL of a v2 or v3 JSON log list containing logs to monitor.
The schema for this file can be found at <https://www.gstatic.com/ct/log_list/v3/log_list_schema.json>. The schema for this file can be found at <https://www.gstatic.com/ct/log_list/v3/log_list_schema.json>.
Defaults to <https://loglist.certspotter.org/monitor.json>, which includes Defaults to <https://loglist.certspotter.org/monitor.json>, which includes
the union of active logs recognized by Chrome and Apple. certspotter periodically the union of active logs recognized by Chrome and Apple. certspotter loads the
reloads the log list in case it has changed. log list when starting up, and periodically reloads it in case it has changed.
-no\_save -no\_save
@ -90,7 +90,7 @@ You can use Cert Spotter to detect:
-verbose -verbose
: Print detailed information about certspotter's operation (such as errors contacting logs) to stderr. : Print detailed information about certspotter's operation to stderr.
-version -version
@ -136,7 +136,7 @@ the script interface, see certspotter-script(8).
# OPERATION # OPERATION
certspotter continuously monitors all browser-recognized Certificate certspotter continuously monitors all browser-recognized Certificate
Transparency logs looking for certificates (including precertificates) Transparency logs (both RFC6962 and static-ct-api) looking for certificates (including precertificates)
which are valid for any domain on your watch list. When certspotter which are valid for any domain on your watch list. When certspotter
detects a matching certificate, it emails you, executes a script, and/or detects a matching certificate, it emails you, executes a script, and/or
writes a report to standard out, as described above. writes a report to standard out, as described above.
@ -169,12 +169,17 @@ API <https://sslmate.com/ct_search_api>, or a CT search engine such as
# ERROR HANDLING # ERROR HANDLING
When certspotter encounters a problem with the local system (e.g. failure When certspotter encounters a problem with the local system (e.g. failure
to write a file or execute a script), it prints a message to stderr and to write a file, send an email, or execute a script), it prints a message to stderr and
exits with a non-zero status. exits with a non-zero status.
When certspotter encounters a problem monitoring a log, it prints a message When certspotter encounters a problem loading the log list during startup, it
to stderr if `-verbose` is specified and continues running. It will try monitoring the log again later; prints a message to stderr and exits with a non-zero status. When certspotter encounters a problem
most log errors are transient. reloading the log list, it prints a message to stderr and continues running with the previously-loaded
log list. It will try reloading the log list again later.
When certspotter encounters a problem contacting a log, it writes the error to a file in
the state directory and continues running. It will try contacting the log again later;
most log errors are transient. The last 7 days of errors are kept.
Every 24 hours (unless overridden by `-healthcheck`), certspotter performs the Every 24 hours (unless overridden by `-healthcheck`), certspotter performs the
following health checks: following health checks:
@ -186,11 +191,12 @@ following health checks:
* Ensure that certspotter is not falling behind monitoring any logs. * Ensure that certspotter is not falling behind monitoring any logs.
If any health check fails, certspotter notifies you by email, script, and/or If any health check fails, certspotter notifies you by email, script, and/or
standard out, as described above. standard out, as described above. The notification includes the last several errors
encountered when contacting the log.
Health check failures should be rare, and you should take them seriously because it means Health check failures should be rare, and you should take them seriously because it means
certspotter might not detect all certificates. It might also be an indication certspotter might not detect all certificates. It might also be an indication
of CT log misbehavior. Enable the `-verbose` flag and consult stderr for details, and if of CT log misbehavior. Check the error files for details, and if
you need help, file an issue at <https://github.com/SSLMate/certspotter>. you need help, file an issue at <https://github.com/SSLMate/certspotter>.
# EXIT STATUS # EXIT STATUS

View File

@ -44,17 +44,23 @@ type daemon struct {
tasks map[LogID]task tasks map[LogID]task
logsLoadedAt time.Time logsLoadedAt time.Time
logListToken *loglist.ModificationToken logListToken *loglist.ModificationToken
logListError string
logListErrorAt time.Time
} }
func (daemon *daemon) healthCheck(ctx context.Context) error { func (daemon *daemon) healthCheck(ctx context.Context) error {
if time.Since(daemon.logsLoadedAt) >= daemon.config.HealthCheckInterval { if time.Since(daemon.logsLoadedAt) >= daemon.config.HealthCheckInterval {
errors, err := daemon.config.State.GetErrors(ctx, nil, recentErrorCount)
if err != nil {
return fmt.Errorf("error getting recent errors: %w", err)
}
var errorsDir string
if fsstate, ok := daemon.config.State.(*FilesystemState); ok {
errorsDir = fsstate.errorDir(nil)
}
info := &StaleLogListInfo{ info := &StaleLogListInfo{
Source: daemon.config.LogListSource, Source: daemon.config.LogListSource,
LastSuccess: daemon.logsLoadedAt, LastSuccess: daemon.logsLoadedAt,
LastError: daemon.logListError, RecentErrors: errors,
LastErrorTime: daemon.logListErrorAt, ErrorsDir: errorsDir,
} }
if err := daemon.config.State.NotifyHealthCheckFailure(ctx, nil, info); err != nil { if err := daemon.config.State.NotifyHealthCheckFailure(ctx, nil, info); err != nil {
return fmt.Errorf("error notifying about stale log list: %w", err) return fmt.Errorf("error notifying about stale log list: %w", err)
@ -143,8 +149,6 @@ func (daemon *daemon) run(ctx context.Context) error {
return ctx.Err() return ctx.Err()
case <-reloadLogListTicker.C: case <-reloadLogListTicker.C:
if err := daemon.loadLogList(ctx); err != nil { if err := daemon.loadLogList(ctx); err != nil {
daemon.logListError = err.Error()
daemon.logListErrorAt = time.Now()
recordError(ctx, daemon.config, nil, fmt.Errorf("error reloading log list (will try again later): %w", err)) recordError(ctx, daemon.config, nil, fmt.Errorf("error reloading log list (will try again later): %w", err))
} }
reloadLogListTicker.Reset(reloadLogListInterval()) reloadLogListTicker.Reset(reloadLogListInterval())

View File

@ -14,7 +14,9 @@ import (
"encoding/hex" "encoding/hex"
"encoding/json" "encoding/json"
"fmt" "fmt"
"io"
"os" "os"
"slices"
) )
func randomFileSuffix() string { func randomFileSuffix() string {
@ -69,3 +71,47 @@ func fileExists(filename string) bool {
_, err := os.Lstat(filename) _, err := os.Lstat(filename)
return err == nil return err == nil
} }
func tailFile(filename string, linesWanted int) ([]byte, int, error) {
file, err := os.Open(filename)
if err != nil {
return nil, 0, err
}
defer file.Close()
return tail(file, linesWanted, 4096)
}
func tail(r io.ReadSeeker, linesWanted int, chunkSize int) ([]byte, int, error) {
var buf []byte
linesGot := 0
offset, err := r.Seek(0, io.SeekEnd)
if err != nil {
return nil, 0, err
}
for offset > 0 {
readSize := chunkSize
if offset < int64(readSize) {
readSize = int(offset)
}
offset -= int64(readSize)
if _, err := r.Seek(offset, io.SeekStart); err != nil {
return nil, 0, err
}
buf = slices.Grow(buf, readSize)
copy(buf[readSize:len(buf)+readSize], buf)
buf = buf[:len(buf)+readSize]
if _, err := io.ReadFull(r, buf[:readSize]); err != nil {
return nil, 0, err
}
for i := readSize; i > 0; i-- {
if buf[i-1] == '\n' {
if linesGot == linesWanted {
return buf[i:], linesGot, nil
}
linesGot++
}
}
}
return buf, linesGot, nil
}

View File

@ -20,12 +20,17 @@ import (
"os" "os"
"path/filepath" "path/filepath"
"strings" "strings"
"sync"
"time"
"software.sslmate.com/src/certspotter/cttypes" "software.sslmate.com/src/certspotter/cttypes"
"software.sslmate.com/src/certspotter/loglist" "software.sslmate.com/src/certspotter/loglist"
"software.sslmate.com/src/certspotter/merkletree" "software.sslmate.com/src/certspotter/merkletree"
) )
const keepErrorDays = 7
const errorDateFormat = "2006-01-02"
type FilesystemState struct { type FilesystemState struct {
StateDir string StateDir string
CacheDir string CacheDir string
@ -34,7 +39,7 @@ type FilesystemState struct {
ScriptDir string ScriptDir string
Email []string Email []string
Stdout bool Stdout bool
Quiet bool errorMu sync.Mutex
} }
func (s *FilesystemState) logStateDir(logID LogID) string { func (s *FilesystemState) logStateDir(logID LogID) string {
@ -57,8 +62,9 @@ func (s *FilesystemState) PrepareLog(ctx context.Context, logID LogID) error {
sthsDirPath = filepath.Join(stateDirPath, "unverified_sths") sthsDirPath = filepath.Join(stateDirPath, "unverified_sths")
malformedDirPath = filepath.Join(stateDirPath, "malformed_entries") malformedDirPath = filepath.Join(stateDirPath, "malformed_entries")
healthchecksDirPath = filepath.Join(stateDirPath, "healthchecks") healthchecksDirPath = filepath.Join(stateDirPath, "healthchecks")
errorsDirPath = filepath.Join(stateDirPath, "errors")
) )
for _, dirPath := range []string{stateDirPath, sthsDirPath, malformedDirPath, healthchecksDirPath} { for _, dirPath := range []string{stateDirPath, sthsDirPath, malformedDirPath, healthchecksDirPath, errorsDirPath} {
if err := os.Mkdir(dirPath, 0777); err != nil && !errors.Is(err, fs.ErrExist) { if err := os.Mkdir(dirPath, 0777); err != nil && !errors.Is(err, fs.ErrExist) {
return err return err
} }
@ -227,6 +233,13 @@ func (s *FilesystemState) healthCheckDir(ctlog *loglist.Log) string {
} }
} }
func (s *FilesystemState) errorDir(ctlog *loglist.Log) string {
if ctlog == nil {
return filepath.Join(s.StateDir, "errors")
}
return filepath.Join(s.logStateDir(ctlog.LogID), "errors")
}
func (s *FilesystemState) NotifyHealthCheckFailure(ctx context.Context, ctlog *loglist.Log, info HealthCheckFailure) error { func (s *FilesystemState) NotifyHealthCheckFailure(ctx context.Context, ctlog *loglist.Log, info HealthCheckFailure) error {
textPath := filepath.Join(s.healthCheckDir(ctlog), healthCheckFilename()) textPath := filepath.Join(s.healthCheckDir(ctlog), healthCheckFilename())
environ := []string{ environ := []string{
@ -248,13 +261,84 @@ func (s *FilesystemState) NotifyHealthCheckFailure(ctx context.Context, ctlog *l
return nil return nil
} }
func (s *FilesystemState) NotifyError(ctx context.Context, ctlog *loglist.Log, err error) error { func (s *FilesystemState) NotifyError(ctx context.Context, ctlog *loglist.Log, notifyErr error) error {
if !s.Quiet { if ctlog == nil {
if ctlog == nil { log.Print(notifyErr)
log.Print(err) }
} else {
log.Print(ctlog.GetMonitoringURL(), ": ", err) var (
now = time.Now()
filePath = filepath.Join(s.errorDir(ctlog), now.Format(errorDateFormat))
line = now.Format(time.RFC3339) + " " + notifyErr.Error() + "\n"
)
s.errorMu.Lock()
defer s.errorMu.Unlock()
file, err := os.OpenFile(filePath, os.O_WRONLY|os.O_CREATE|os.O_APPEND, 0666)
if err != nil {
return err
}
defer file.Close()
if _, err := file.WriteString(line); err != nil {
return err
}
return file.Close()
}
func (s *FilesystemState) GetErrors(ctx context.Context, ctlog *loglist.Log, count int) (string, error) {
dir := s.errorDir(ctlog)
now := time.Now()
var buf []byte
for daysBack := 0; count > 0 && daysBack < keepErrorDays; daysBack++ {
datePath := filepath.Join(dir, now.AddDate(0, 0, -daysBack).Format(errorDateFormat))
dateBuf, dateLines, err := tailFile(datePath, count)
if errors.Is(err, fs.ErrNotExist) {
continue
} else if err != nil {
return "", err
}
buf = append(dateBuf, buf...)
count -= dateLines
}
return string(buf), nil
}
func (s *FilesystemState) PruneOldErrors() {
cutoff := time.Now().AddDate(0, 0, -keepErrorDays)
pruneDir := func(dir string) {
entries, err := os.ReadDir(dir)
if errors.Is(err, fs.ErrNotExist) {
return
} else if err != nil {
log.Printf("unable to read error directory: %s", err)
return
}
for _, entry := range entries {
if entry.IsDir() {
continue
}
date, err := time.Parse(errorDateFormat, entry.Name())
if err != nil {
continue
}
if date.Before(cutoff) {
if err := os.Remove(filepath.Join(dir, entry.Name())); err != nil && !errors.Is(err, fs.ErrNotExist) {
log.Printf("unable to remove old error file: %s", err)
}
}
} }
} }
return nil pruneDir(filepath.Join(s.StateDir, "errors"))
logsDir := filepath.Join(s.StateDir, "logs")
logDirs, err := os.ReadDir(logsDir)
if err != nil && !errors.Is(err, fs.ErrNotExist) {
log.Printf("unable to read logs directory: %s", err)
return
}
for _, d := range logDirs {
if !d.IsDir() {
continue
}
pruneDir(filepath.Join(logsDir, d.Name(), "errors"))
}
} }

View File

@ -19,6 +19,8 @@ import (
"software.sslmate.com/src/certspotter/loglist" "software.sslmate.com/src/certspotter/loglist"
) )
const recentErrorCount = 10
func healthCheckFilename() string { func healthCheckFilename() string {
return time.Now().UTC().Format(time.RFC3339) + ".txt" return time.Now().UTC().Format(time.RFC3339) + ".txt"
} }
@ -48,20 +50,37 @@ func healthCheckLog(ctx context.Context, config *Config, ctlog *loglist.Log) err
return fmt.Errorf("error loading STHs: %w", err) return fmt.Errorf("error loading STHs: %w", err)
} }
var errorsDir string
if fsstate, ok := config.State.(*FilesystemState); ok {
errorsDir = fsstate.errorDir(ctlog)
}
if len(sths) == 0 { if len(sths) == 0 {
errors, err := config.State.GetErrors(ctx, ctlog, recentErrorCount)
if err != nil {
return fmt.Errorf("error getting recent errors: %w", err)
}
info := &StaleSTHInfo{ info := &StaleSTHInfo{
Log: ctlog, Log: ctlog,
LastSuccess: lastSuccess, LastSuccess: lastSuccess,
LatestSTH: verifiedSTH, LatestSTH: verifiedSTH,
RecentErrors: errors,
ErrorsDir: errorsDir,
} }
if err := config.State.NotifyHealthCheckFailure(ctx, ctlog, info); err != nil { if err := config.State.NotifyHealthCheckFailure(ctx, ctlog, info); err != nil {
return fmt.Errorf("error notifying about stale STH: %w", err) return fmt.Errorf("error notifying about stale STH: %w", err)
} }
} else { } else {
errors, err := config.State.GetErrors(ctx, ctlog, recentErrorCount)
if err != nil {
return fmt.Errorf("error getting recent errors: %w", err)
}
info := &BacklogInfo{ info := &BacklogInfo{
Log: ctlog, Log: ctlog,
LatestSTH: sths[len(sths)-1], LatestSTH: sths[len(sths)-1],
Position: position, Position: position,
RecentErrors: errors,
ErrorsDir: errorsDir,
} }
if err := config.State.NotifyHealthCheckFailure(ctx, ctlog, info); err != nil { if err := config.State.NotifyHealthCheckFailure(ctx, ctlog, info); err != nil {
return fmt.Errorf("error notifying about backlog: %w", err) return fmt.Errorf("error notifying about backlog: %w", err)
@ -77,22 +96,26 @@ type HealthCheckFailure interface {
} }
type StaleSTHInfo struct { type StaleSTHInfo struct {
Log *loglist.Log Log *loglist.Log
LastSuccess time.Time // may be zero LastSuccess time.Time // may be zero
LatestSTH *cttypes.SignedTreeHead // may be nil LatestSTH *cttypes.SignedTreeHead // may be nil
RecentErrors string
ErrorsDir string
} }
type BacklogInfo struct { type BacklogInfo struct {
Log *loglist.Log Log *loglist.Log
LatestSTH *StoredSTH LatestSTH *StoredSTH
Position uint64 Position uint64
RecentErrors string
ErrorsDir string
} }
type StaleLogListInfo struct { type StaleLogListInfo struct {
Source string Source string
LastSuccess time.Time LastSuccess time.Time
LastError string RecentErrors string
LastErrorTime time.Time ErrorsDir string
} }
func (e *StaleSTHInfo) LastSuccessString() string { func (e *StaleSTHInfo) LastSuccessString() string {
@ -120,33 +143,45 @@ func (e *StaleSTHInfo) Text() string {
text := new(strings.Builder) text := new(strings.Builder)
fmt.Fprintf(text, "certspotter has been unable to contact %s since %s. Consequentially, certspotter may fail to notify you about certificates in this log.\n", e.Log.GetMonitoringURL(), e.LastSuccessString()) fmt.Fprintf(text, "certspotter has been unable to contact %s since %s. Consequentially, certspotter may fail to notify you about certificates in this log.\n", e.Log.GetMonitoringURL(), e.LastSuccessString())
fmt.Fprintf(text, "\n") fmt.Fprintf(text, "\n")
fmt.Fprintf(text, "For details, enable -verbose and see certspotter's stderr output.\n")
fmt.Fprintf(text, "\n")
if e.LatestSTH != nil { if e.LatestSTH != nil {
fmt.Fprintf(text, "Latest known log size = %d\n", e.LatestSTH.TreeSize) fmt.Fprintf(text, "Latest known log size = %d\n", e.LatestSTH.TreeSize)
} else { } else {
fmt.Fprintf(text, "Latest known log size = none\n") fmt.Fprintf(text, "Latest known log size = none\n")
} }
if e.RecentErrors != "" {
fmt.Fprintf(text, "\n")
fmt.Fprintf(text, "Recent errors (see %s for complete records):\n", e.ErrorsDir)
fmt.Fprintf(text, "\n")
fmt.Fprint(text, e.RecentErrors)
}
return text.String() return text.String()
} }
func (e *BacklogInfo) Text() string { func (e *BacklogInfo) Text() string {
text := new(strings.Builder) text := new(strings.Builder)
fmt.Fprintf(text, "certspotter has been unable to download entries from %s in a timely manner. Consequentially, certspotter may be slow to notify you about certificates in this log.\n", e.Log.GetMonitoringURL()) fmt.Fprintf(text, "certspotter has been unable to download entries from %s in a timely manner. Consequentially, certspotter may be slow to notify you about certificates in this log.\n", e.Log.GetMonitoringURL())
fmt.Fprintf(text, "\n") fmt.Fprintf(text, "\n")
fmt.Fprintf(text, "For details, enable -verbose and see certspotter's stderr output.\n")
fmt.Fprintf(text, "\n")
fmt.Fprintf(text, "Current log size = %d (as of %s)\n", e.LatestSTH.TreeSize, e.LatestSTH.StoredAt) fmt.Fprintf(text, "Current log size = %d (as of %s)\n", e.LatestSTH.TreeSize, e.LatestSTH.StoredAt)
fmt.Fprintf(text, "Current position = %d\n", e.Position) fmt.Fprintf(text, "Current position = %d\n", e.Position)
fmt.Fprintf(text, " Backlog = %d\n", e.Backlog()) fmt.Fprintf(text, " Backlog = %d\n", e.Backlog())
if e.RecentErrors != "" {
fmt.Fprintf(text, "\n")
fmt.Fprintf(text, "Recent errors (see %s for complete records):\n", e.ErrorsDir)
fmt.Fprintf(text, "\n")
fmt.Fprint(text, e.RecentErrors)
}
return text.String() return text.String()
} }
func (e *StaleLogListInfo) Text() string { func (e *StaleLogListInfo) Text() string {
text := new(strings.Builder) text := new(strings.Builder)
fmt.Fprintf(text, "certspotter has been unable to retrieve the log list from %s since %s.\n", e.Source, e.LastSuccess) fmt.Fprintf(text, "certspotter has been unable to retrieve the log list from %s since %s.\n", e.Source, e.LastSuccess)
fmt.Fprintf(text, "\n") fmt.Fprintf(text, "\n")
fmt.Fprintf(text, "Last error (at %s): %s\n", e.LastErrorTime, e.LastError)
fmt.Fprintf(text, "\n")
fmt.Fprintf(text, "Consequentially, certspotter may not be monitoring all logs, and might fail to detect certificates.\n") fmt.Fprintf(text, "Consequentially, certspotter may not be monitoring all logs, and might fail to detect certificates.\n")
if e.RecentErrors != "" {
fmt.Fprintf(text, "\n")
fmt.Fprintf(text, "Recent errors (see %s for complete records):\n", e.ErrorsDir)
fmt.Fprintf(text, "\n")
fmt.Fprint(text, e.RecentErrors)
}
return text.String() return text.String()
} }

View File

@ -206,7 +206,7 @@ func newLogClient(config *Config, ctlog *loglist.Log) (ctclient.Log, ctclient.Is
logGetter: client, logGetter: client,
}, nil }, nil
default: default:
return nil, nil, fmt.Errorf("log uses unknown protocol") return nil, nil, errors.New("log uses unknown protocol")
} }
} }

View File

@ -99,7 +99,7 @@ func sendEmail(ctx context.Context, to []string, notif *notification) error {
if err := sendmail.Run(); err == nil || err == exec.ErrWaitDelay { if err := sendmail.Run(); err == nil || err == exec.ErrWaitDelay {
return nil return nil
} else if sendmailCtx.Err() != nil && ctx.Err() == nil { } else if sendmailCtx.Err() != nil && ctx.Err() == nil {
return fmt.Errorf("error sending email to %v: sendmail command timed out") return fmt.Errorf("error sending email to %v: sendmail command timed out", to)
} else if ctx.Err() != nil { } else if ctx.Err() != nil {
// if the context was canceled, we can't be sure that the error is the fault of sendmail, so ignore it // if the context was canceled, we can't be sure that the error is the fault of sendmail, so ignore it
return ctx.Err() return ctx.Err()

View File

@ -85,4 +85,7 @@ type StateProvider interface {
// not associated with a log. Note that most errors are transient, and // not associated with a log. Note that most errors are transient, and
// certspotter will retry the failed operation later. // certspotter will retry the failed operation later.
NotifyError(context.Context, *loglist.Log, error) error NotifyError(context.Context, *loglist.Log, error) error
// Retrieve the specified number of most recent errors.
GetErrors(context.Context, *loglist.Log, int) (string, error)
} }

View File

@ -145,7 +145,7 @@ func prepareStateDir(stateDir string) error {
return fmt.Errorf("%s was created by a newer version of certspotter; upgrade to the latest version of certspotter or remove this directory to start from scratch", stateDir) return fmt.Errorf("%s was created by a newer version of certspotter; upgrade to the latest version of certspotter or remove this directory to start from scratch", stateDir)
} }
for _, subdir := range []string{"certs", "logs", "healthchecks"} { for _, subdir := range []string{"certs", "logs", "healthchecks", "errors"} {
if err := os.Mkdir(filepath.Join(stateDir, subdir), 0777); err != nil && !errors.Is(err, fs.ErrExist) { if err := os.Mkdir(filepath.Join(stateDir, subdir), 0777); err != nil && !errors.Is(err, fs.ErrExist) {
return err return err
} }

1
staticcheck.conf Normal file
View File

@ -0,0 +1 @@
checks = ["inherit", "-ST1005", "-S1002"]