boulder/cmd/id-exporter/main.go

305 lines
8.6 KiB
Go

package notmain
import (
"bufio"
"context"
"encoding/json"
"errors"
"flag"
"fmt"
"os"
"strings"
"time"
"github.com/jmhodges/clock"
"github.com/letsencrypt/boulder/cmd"
"github.com/letsencrypt/boulder/db"
"github.com/letsencrypt/boulder/features"
blog "github.com/letsencrypt/boulder/log"
"github.com/letsencrypt/boulder/sa"
)
type idExporter struct {
log blog.Logger
dbMap *db.WrappedMap
clk clock.Clock
grace time.Duration
}
// resultEntry is a JSON marshalable exporter result entry.
type resultEntry struct {
// ID is exported to support marshaling to JSON.
ID int64 `json:"id"`
// Hostname is exported to support marshaling to JSON. Not all queries
// will fill this field, so it's JSON field tag marks at as
// omittable.
Hostname string `json:"hostname,omitempty"`
}
// reverseHostname converts (reversed) names sourced from the
// registrations table to standard hostnames.
func (r *resultEntry) reverseHostname() {
r.Hostname = sa.ReverseName(r.Hostname)
}
// idExporterResults is passed as a selectable 'holder' for the results
// of id-exporter database queries
type idExporterResults []*resultEntry
// marshalToJSON returns JSON as bytes for all elements of the inner `id`
// slice.
func (i *idExporterResults) marshalToJSON() ([]byte, error) {
data, err := json.Marshal(i)
if err != nil {
return nil, err
}
data = append(data, '\n')
return data, nil
}
// writeToFile writes the contents of the inner `ids` slice, as JSON, to
// a file
func (i *idExporterResults) writeToFile(outfile string) error {
data, err := i.marshalToJSON()
if err != nil {
return err
}
return os.WriteFile(outfile, data, 0644)
}
// findIDs gathers all registration IDs with unexpired certificates.
func (c idExporter) findIDs(ctx context.Context) (idExporterResults, error) {
var holder idExporterResults
_, err := c.dbMap.Select(
ctx,
&holder,
`SELECT DISTINCT r.id
FROM registrations AS r
INNER JOIN certificates AS c on c.registrationID = r.id
WHERE r.contact NOT IN ('[]', 'null')
AND c.expires >= :expireCutoff;`,
map[string]interface{}{
"expireCutoff": c.clk.Now().Add(-c.grace),
})
if err != nil {
c.log.AuditErrf("Error finding IDs: %s", err)
return nil, err
}
return holder, nil
}
// findIDsWithExampleHostnames gathers all registration IDs with
// unexpired certificates and a corresponding example hostname.
func (c idExporter) findIDsWithExampleHostnames(ctx context.Context) (idExporterResults, error) {
var holder idExporterResults
_, err := c.dbMap.Select(
ctx,
&holder,
`SELECT SQL_BIG_RESULT
cert.registrationID AS id,
name.reversedName AS hostname
FROM certificates AS cert
INNER JOIN issuedNames AS name ON name.serial = cert.serial
WHERE cert.expires >= :expireCutoff
GROUP BY cert.registrationID;`,
map[string]interface{}{
"expireCutoff": c.clk.Now().Add(-c.grace),
})
if err != nil {
c.log.AuditErrf("Error finding IDs and example hostnames: %s", err)
return nil, err
}
for _, result := range holder {
result.reverseHostname()
}
return holder, nil
}
// findIDsForHostnames gathers all registration IDs with unexpired
// certificates for each `hostnames` entry.
func (c idExporter) findIDsForHostnames(ctx context.Context, hostnames []string) (idExporterResults, error) {
var holder idExporterResults
for _, hostname := range hostnames {
// Pass the same list in each time, borp will happily just append to the slice
// instead of overwriting it each time
// https://github.com/letsencrypt/borp/blob/c87bd6443d59746a33aca77db34a60cfc344adb2/select.go#L349-L353
_, err := c.dbMap.Select(
ctx,
&holder,
`SELECT DISTINCT c.registrationID AS id
FROM certificates AS c
INNER JOIN issuedNames AS n ON c.serial = n.serial
WHERE c.expires >= :expireCutoff
AND n.reversedName = :reversedName;`,
map[string]interface{}{
"expireCutoff": c.clk.Now().Add(-c.grace),
"reversedName": sa.ReverseName(hostname),
},
)
if err != nil {
if db.IsNoRows(err) {
continue
}
return nil, err
}
}
return holder, nil
}
const usageIntro = `
Introduction:
The ID exporter exists to retrieve the IDs of all registered
users with currently unexpired certificates. This list of registration IDs can
then be given as input to the notification mailer to send bulk notifications.
The -grace parameter can be used to allow registrations with certificates that
have already expired to be included in the export. The argument is a Go duration
obeying the usual suffix rules (e.g. 24h).
Registration IDs are favoured over email addresses as the intermediate format in
order to ensure the most up to date contact information is used at the time of
notification. The notification mailer will resolve the ID to email(s) when the
mailing is underway, ensuring we use the correct address if a user has updated
their contact information between the time of export and the time of
notification.
By default, the ID exporter's output will be JSON of the form:
[
{ "id": 1 },
...
{ "id": n }
]
Operations that return a hostname will be JSON of the form:
[
{ "id": 1, "hostname": "example-1.com" },
...
{ "id": n, "hostname": "example-n.com" }
]
Examples:
Export all registration IDs with unexpired certificates to "regs.json":
id-exporter -config test/config/id-exporter.json -outfile regs.json
Export all registration IDs with certificates that are unexpired or expired
within the last two days to "regs.json":
id-exporter -config test/config/id-exporter.json -grace 48h -outfile
"regs.json"
Required arguments:
- config
- outfile`
// unmarshalHostnames unmarshals a hostnames file and ensures that the file
// contained at least one entry.
func unmarshalHostnames(filePath string) ([]string, error) {
file, err := os.Open(filePath)
if err != nil {
return nil, err
}
defer file.Close()
scanner := bufio.NewScanner(file)
scanner.Split(bufio.ScanLines)
var hostnames []string
for scanner.Scan() {
line := scanner.Text()
if strings.Contains(line, " ") {
return nil, fmt.Errorf(
"line: %q contains more than one entry, entries must be separated by newlines", line)
}
hostnames = append(hostnames, line)
}
if len(hostnames) == 0 {
return nil, errors.New("provided file contains 0 hostnames")
}
return hostnames, nil
}
type Config struct {
ContactExporter struct {
DB cmd.DBConfig
cmd.PasswordConfig
Features map[string]bool
}
}
func main() {
outFile := flag.String("outfile", "", "File to output results JSON to.")
grace := flag.Duration("grace", 2*24*time.Hour, "Include results with certificates that expired in < grace ago.")
hostnamesFile := flag.String(
"hostnames", "", "Only include results with unexpired certificates that contain hostnames\nlisted (newline separated) in this file.")
withExampleHostnames := flag.Bool(
"with-example-hostnames", false, "Include an example hostname for each registration ID with an unexpired certificate.")
configFile := flag.String("config", "", "File containing a JSON config.")
flag.Usage = func() {
fmt.Fprintf(os.Stderr, "%s\n\n", usageIntro)
fmt.Fprintf(os.Stderr, "Usage of %s:\n", os.Args[0])
flag.PrintDefaults()
}
// Parse flags and check required.
flag.Parse()
if *outFile == "" || *configFile == "" {
flag.Usage()
os.Exit(1)
}
log := cmd.NewLogger(cmd.SyslogConfig{StdoutLevel: 7})
// Load configuration file.
configData, err := os.ReadFile(*configFile)
cmd.FailOnError(err, fmt.Sprintf("Reading %q", *configFile))
// Unmarshal JSON config file.
var cfg Config
err = json.Unmarshal(configData, &cfg)
cmd.FailOnError(err, "Unmarshaling config")
err = features.Set(cfg.ContactExporter.Features)
cmd.FailOnError(err, "Failed to set feature flags")
dbMap, err := sa.InitWrappedDb(cfg.ContactExporter.DB, nil, log)
cmd.FailOnError(err, "While initializing dbMap")
exporter := idExporter{
log: log,
dbMap: dbMap,
clk: cmd.Clock(),
grace: *grace,
}
var results idExporterResults
if *hostnamesFile != "" {
hostnames, err := unmarshalHostnames(*hostnamesFile)
cmd.FailOnError(err, "Problem unmarshalling hostnames")
results, err = exporter.findIDsForHostnames(context.TODO(), hostnames)
cmd.FailOnError(err, "Could not find IDs for hostnames")
} else if *withExampleHostnames {
results, err = exporter.findIDsWithExampleHostnames(context.TODO())
cmd.FailOnError(err, "Could not find IDs with hostnames")
} else {
results, err = exporter.findIDs(context.TODO())
cmd.FailOnError(err, "Could not find IDs")
}
err = results.writeToFile(*outFile)
cmd.FailOnError(err, fmt.Sprintf("Could not write result to outfile %q", *outFile))
}
func init() {
cmd.RegisterCommand("id-exporter", main, &cmd.ConfigValidator{Config: &Config{}})
}