From 20f573629b8980d930f31a83cc2c2e2d96e9d31b Mon Sep 17 00:00:00 2001 From: xinau Date: Thu, 3 Sep 2020 15:21:11 +0200 Subject: [PATCH] initial commit Signed-off-by: xinau --- .gitignore | 3 + Dockerfile | 24 +++ LICENSE | 19 ++ Makefile | 126 +++++++++++++ README.md | 82 +++++++++ cmd/certspotter-sd/main.go | 106 +++++++++++ example/blackbox.yml | 10 ++ example/certspotter-sd.yml | 13 ++ example/prometheus.yml | 49 ++++++ example/rules.yml | 27 +++ go.mod | 10 ++ go.sum | 50 ++++++ internal/certspotter/client.go | 108 ++++++++++++ internal/certspotter/client_test.go | 119 +++++++++++++ internal/certspotter/issuances.go | 66 +++++++ internal/certspotter/issuances_test.go | 163 +++++++++++++++++ internal/config/config.go | 154 ++++++++++++++++ internal/discovery/client/client.go | 121 +++++++++++++ internal/discovery/client/client_test.go | 214 +++++++++++++++++++++++ internal/discovery/discovery.go | 105 +++++++++++ internal/export/export.go | 92 ++++++++++ internal/export/export_test.go | 106 +++++++++++ internal/export/target.go | 63 +++++++ internal/export/target_test.go | 190 ++++++++++++++++++++ internal/version/version.go | 27 +++ 25 files changed, 2047 insertions(+) create mode 100644 .gitignore create mode 100644 Dockerfile create mode 100644 LICENSE create mode 100644 Makefile create mode 100644 README.md create mode 100644 cmd/certspotter-sd/main.go create mode 100644 example/blackbox.yml create mode 100644 example/certspotter-sd.yml create mode 100644 example/prometheus.yml create mode 100644 example/rules.yml create mode 100644 go.mod create mode 100644 go.sum create mode 100644 internal/certspotter/client.go create mode 100644 internal/certspotter/client_test.go create mode 100644 internal/certspotter/issuances.go create mode 100644 internal/certspotter/issuances_test.go create mode 100644 internal/config/config.go create mode 100644 internal/discovery/client/client.go create mode 100644 internal/discovery/client/client_test.go create mode 100644 internal/discovery/discovery.go create mode 100644 internal/export/export.go create mode 100644 internal/export/export_test.go create mode 100644 internal/export/target.go create mode 100644 internal/export/target_test.go create mode 100644 internal/version/version.go diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..b1e9b4b --- /dev/null +++ b/.gitignore @@ -0,0 +1,3 @@ +bin/* +out/* + diff --git a/Dockerfile b/Dockerfile new file mode 100644 index 0000000..3805425 --- /dev/null +++ b/Dockerfile @@ -0,0 +1,24 @@ +FROM golang:1.15 as build + +COPY . /usr/share/repo +WORKDIR /usr/share/repo + +RUN apt-get update && apt-get install -y \ + ca-certificates +RUN make + +FROM debian:stable +LABEL maintainer="Felix Ehrenpfort " + +COPY --from=build /etc/ssl/certs/ca-certificates.crt /etc/ssl/certs/ca-certificates.crt +COPY --from=build /usr/share/repo/out/certspotter-sd /usr/local/bin/certspotter-sd +COPY example/certspotter-sd.yml /etc/prometheus/certspotter-sd.yml + +RUN mkdir -p /var/lib/certspotter-sd && \ + chown -R nobody:nogroup etc/prometheus /var/lib/certspotter-sd + +USER nobody +VOLUME [ "/var/lib/certspotter-sd" ] +ENTRYPOINT [ "/usr/local/bin/certspotter-sd" ] +CMD [ "--config.file=/etc/prometheus/certspotter-sd.yml" ] + diff --git a/LICENSE b/LICENSE new file mode 100644 index 0000000..4f3a9eb --- /dev/null +++ b/LICENSE @@ -0,0 +1,19 @@ +Copyright (c) 2020 codecentric AG + +Permission is hereby granted, free of charge, to any person obtaining a copy +of this software and associated documentation files (the "Software"), to deal +in the Software without restriction, including without limitation the rights +to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +copies of the Software, and to permit persons to whom the Software is +furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in all +copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +SOFTWARE. diff --git a/Makefile b/Makefile new file mode 100644 index 0000000..fdf5062 --- /dev/null +++ b/Makefile @@ -0,0 +1,126 @@ +MODULE = $(shell env GO111MODULE=on $(GO) list -m) +DATE ?= $(shell date -u +%F) +VERSION ?= $(shell git describe --tags --abbrev=0 --match=v* 2> /dev/null) +PKGS = $(or $(PKG),$(shell env GO111MODULE=on $(GO) list ./...)) +TESTPKGS = $(shell env GO111MODULE=on $(GO) list -f \ + '{{ if or .TestGoFiles .XTestGoFiles }}{{ .ImportPath }}{{ end }}' \ + $(PKGS)) +BIN = $(CURDIR)/bin +OUT = $(CURDIR)/out + +GO = go +TIMEOUT = 15 +LDFLAGS = "-X $(MODULE)/internal/version.Version=$(VERSION) -X $(MODULE)/internal/version.BuildDate=$(DATE)" + +PREV_VERSION = $(shell git describe --abbrev=0 --match=v* $(VERSION)^ 2> /dev/null) + +DEBUG = 0 +Q = $(if $(filter 1,$DEBUG),,@) +M = $(shell printf ">_ ") + +export GO111MODULE=on + +.DEFAULT_GOAL:=all +.PHONY: all +all: clean fmt lint static test build + +# Tools + +$(BIN): + @mkdir -p $@ +$(BIN)/%: | $(BIN) ; $(info $(M) building $(PACKAGE)...) + $Q tmp=$$(mktemp -d); \ + env GO111MODULE=off GOPATH=$$tmp GOBIN=$(BIN) $(GO) get $(PACKAGE) \ + || ret=$$?; \ + rm -rf $$tmp ; exit $$ret + +GOIMPORTS = $(BIN)/goimports +$(BIN)/goimports: PACKAGE=golang.org/x/tools/cmd/goimports + +GOLINT = $(BIN)/golint +$(BIN)/golint: PACKAGE=golang.org/x/lint/golint + +GOSTATICCHECK = $(BIN)/staticcheck +$(BIN)/staticcheck: PACKAGE=honnef.co/go/tools/cmd/staticcheck + +GOX = $(BIN)/gox +$(BIN)/gox: PACKAGE=github.com/mitchellh/gox + +# Tests + +TEST_TARGETS := test-default test-bench test-short test-verbose test-race +.PHONY: $(TEST_TARGETS) test +test-bench: ARGS=-run=__absolutelynothing__ -bench=. ## run test benchmarks +test-short: ARGS=-short ## run only short tests +test-verbose: ARGS=-v ## run tests with verbose +test-race: ARGS=-race ## run tests with race detector +$(TEST_TARGETS): NAME=$(MAKECMDGOALS:test-%=%) +$(TEST_TARGETS): test +test: fmt lint static ; $(info $(M) running $(NAME:%=% )tests...) @ ## run tests + $Q $(GO) test -timeout $(TIMEOUT)s $(ARGS) $(TESTPKGS) + +.PHONY: static +static: fmt lint | $(GOSTATICCHECK) ; $(info $(M) running staticcheck...) @ ## run staticcheck + $Q $(GOSTATICCHECK) $(PKGS) + +.PHONY: lint +lint: | $(GOLINT) ; $(info $(M) running golint...) @ ## run golint + $Q $(GOLINT) -set_exit_status $(PKGS) + +.PHONY: fmt +fmt: | $(GOIMPORTS) ; $(info $(M) running goimports...) @ ## run goimports + $Q $(GOIMPORTS) -l -w -local $(MODULE) $(subst $(MODULE),.,$(PKGS)) + +# Build + +.PHONY: build +build: fmt lint static ; $(info $(M) building local executable...) @ ## build local executable + $Q $(GO) build \ + -ldflags $(LDFLAGS) \ + -o $(OUT)/bin/certspotter-sd \ + $(MODULE)/cmd/certspotter-sd + +.PHONY: build-release +build-release: fmt lint static | $(GOX) ; $(info $(M) building all executables...) @ ## build release executables + $Q $(GOX) \ + -osarch "freebsd/amd64 freebsd/arm linux/amd64 linux/arm linux/arm64" \ + -ldflags $(LDFLAGS) \ + -output "$(OUT)/bin/{{.Dir}}.{{.OS}}-{{.Arch}}" \ + $(MODULE)/cmd/certspotter-sd + +# Release + +.PHONY: release +release: fmt lint static test build-release changelog ; $(info $(M) creating releases...) @ ## creating release archives + $Q cd $(OUT); for file in ./bin/certspotter-sd.* ; do \ + rm -rf certspotter-sd ; \ + mkdir -p certspotter-sd ; \ + cp -f $$file certspotter-sd/certspotter-sd ; \ + tar -czf $$file.tar.gz certspotter-sd ; \ + mv -f $$file.tar.gz ./ ; \ + done + $Q cd $(OUT); sha256sum *.tar.gz > sha256sums.txt + +# Misc + +.PHONY: changelog +changelog: ## print changelog + $Q printf "\n# changelog\n" +ifneq "$(PREV_VERSION)" "" + $Q git log --pretty="format:[\`%h\`](https://$(MODULE)/commit/%h) %s" $(PREV_VERSION)..$(VERSION) +else + $Q git log --pretty="format:[\`%h\`](https://$(MODULE)/commit/%h) %s" $(VERSION) +endif + +.PHONY: clean +clean: ; $(info $(M) cleaning...) @ ## clean up everything + $Q rm -rf $(BIN) $(OUT) + +.PHONY: help +help: + $Q grep -hE '^[ a-zA-Z_-]+:.*?## .*$$' $(MAKEFILE_LIST) | \ + awk 'BEGIN {FS = ":.*?## "}; {printf "%-17s %s\n", $$1, $$2}' + +.PHONY: version +version: ## print current version + $Q echo $(VERSION) diff --git a/README.md b/README.md new file mode 100644 index 0000000..22d933a --- /dev/null +++ b/README.md @@ -0,0 +1,82 @@ +This repository contains code for a prometheus service discovery on top of the +[SSLMate Cert Spotter][1]. The service discovery can be used to implement a +automatic certificate expiration monitoring using the prometheus +blackbox-exporter. + +## Installation + +The certspotter discovery can be installed by downloading the executable from +the [releases page][2] or by building it locally using make or docker. + +```bash +make +# or +docker build -t certspotter-sd . +``` + +## Configuration + +The certspotter service discovery can be configured using a configuration file +and command-line flags (configuration file to load and setting the logging +severity). + +The configuration uses the following format. +```yaml +# global configuartion +global: + # interval to use between polling the certspotter api. + polling_interval: + # rate limit to use for certspotter api (configured in Hz). + rate_limit: + # token to used for authenticating againts certspotter api. + token: + +# domains to query +domains: + # domain to request certificate issuances for + - domain: + # if sub domains should be included + include_subdomains: + +# files to export targets to +files: + # filename to export targets to + - file: + # labels to add to matching targets + labels: + : + # target labels to match to be included in file + match_re: + : +``` + +The certspotter service discovey is intended to be used with prometheus and the +blackbox-exporter this can be configured in prometheus as follows. A complete +configuration of certspotter-sd, blackbox-exporter and prometheus can be found +in the [example][3] folder. + +```yaml +- job_name: "blackbox:tcp" + metrics_path: /probe + params: + module: [tcp] + file_sd_configs: + - files: + - /etc/prometheus/targets.json + refresh_interval: 15s + relabel_configs: + - source_labels: [__address__, __port__] + separator: ":" + target_label: __param_target + - source_labels: [__param_target] + target_label: instance + - target_label: __address__ + replacement: "localhost:9115" +``` + +Atm. configuration can't be reloaded by sending a `SIGHUP` and must be +terminated and restarted instead. + +[1]: https://sslmate.com/certspotter/ +[2]: https://github.com/codecentric/certspotter-sd/releases +[3]: https://github.com/codecentric/certspotter-sd/tree/master/example diff --git a/cmd/certspotter-sd/main.go b/cmd/certspotter-sd/main.go new file mode 100644 index 0000000..71c9ce1 --- /dev/null +++ b/cmd/certspotter-sd/main.go @@ -0,0 +1,106 @@ +package main + +import ( + "context" + "flag" + "fmt" + "log" + "os" + "os/signal" + + "go.uber.org/zap" + "go.uber.org/zap/zapcore" + + "github.com/codecentric/certspotter-sd/internal/config" + "github.com/codecentric/certspotter-sd/internal/discovery" + "github.com/codecentric/certspotter-sd/internal/export" + "github.com/codecentric/certspotter-sd/internal/version" +) + +type arguments struct { + ConfigFile string + LogLevel *zapcore.Level +} + +func main() { + args := argsparse() + + logger := getlogger(*args.LogLevel) + defer logger.Sync() + sugar := logger.Sugar() + + cfg, err := config.LoadFile(args.ConfigFile) + if err != nil { + sugar.Fatalw("can't read configuration", "err", err) + } + + dc := discovery.NewDiscovery( + logger.With(zap.String("component", "discovery")), + &cfg.GlobalConfig, + ) + exporter := export.NewExporter( + logger.With(zap.String("component", "exporter")), + &cfg.GlobalConfig, + ) + + ctx, cancel := context.WithCancel(context.Background()) + go sighandler(ctx, func(sig os.Signal) { + sugar.Infow("stopping service discovery", "signal", sig) + cancel() + os.Exit(0) + }) + + sugar.Info("starting service discovery") + ch := dc.Discover(ctx, cfg.DomainConfigs) + exporter.Export(ctx, ch, cfg.FileConfigs) +} + +func argsparse() *arguments { + var fversion bool + + args := arguments{} + flag.StringVar(&args.ConfigFile, "config.file", + "/etc/prometheus/certspotter-sd.yml", + "configuration file to use.", + ) + args.LogLevel = zap.LevelFlag("log.level", + zap.InfoLevel, + "severity of log to write. (default info)", + ) + flag.BoolVar(&fversion, "version", + false, + "print certspotter-sd version.", + ) + flag.Parse() + + if fversion { + fmt.Println(version.Print()) + os.Exit(0) + } + + return &args +} + +func getlogger(lvl zapcore.Level) *zap.Logger { + cfg := zap.NewProductionConfig() + cfg.Level.SetLevel(lvl) + + logger, err := cfg.Build() + if err != nil { + log.Fatalf("can't initialize logger: %v", err) + } + return logger +} + +func sighandler(ctx context.Context, handler func(os.Signal)) { + ch := make(chan os.Signal, 1) + signal.Notify(ch, os.Interrupt) + for { + select { + case sig := <-ch: + handler(sig) + case <-ctx.Done(): + return + } + } +} diff --git a/example/blackbox.yml b/example/blackbox.yml new file mode 100644 index 0000000..668ac21 --- /dev/null +++ b/example/blackbox.yml @@ -0,0 +1,10 @@ +modules: + tcp: + prober: tcp + tcp: + preferred_ip_protocol: ip4 + tls: + prober: tcp + tcp: + preferred_ip_protocol: ip4 + tls: true diff --git a/example/certspotter-sd.yml b/example/certspotter-sd.yml new file mode 100644 index 0000000..01bc6d7 --- /dev/null +++ b/example/certspotter-sd.yml @@ -0,0 +1,13 @@ +global: + polling_interval: 1h + rate_limit: 1.25 + token: "" + +domains: + - domain: example.com + include_subdomains: true + +files: + - file: /var/lib/certspotter-sd/targets.json + match_re: + dns_names: .*example.* diff --git a/example/prometheus.yml b/example/prometheus.yml new file mode 100644 index 0000000..d3dacf7 --- /dev/null +++ b/example/prometheus.yml @@ -0,0 +1,49 @@ +global: + scrape_interval: 15s # By default, scrape targets every 15 seconds. + evaluation_interval: 15s # By default, scrape targets every 15 seconds. + +rule_files: + - /etc/prometheus/rules/* + +scrape_configs: + - job_name: "prometheus" + static_configs: + - targets: ["localhost:9090"] + + - job_name: "node" + static_configs: + - targets: ["localhost:9100"] + + - job_name: "blackbox:tcp" + metrics_path: /probe + params: + module: [tcp] + file_sd_configs: + - files: + - /etc/prometheus/targets.json + refresh_interval: 15s + relabel_configs: + - source_labels: [__address__, __port__] + separator: ":" + target_label: __param_target + - source_labels: [__param_target] + target_label: instance + - target_label: __address__ + replacement: "localhost:9115" + + - job_name: "blackbox:tls" + metrics_path: /probe + params: + module: [tls] + file_sd_configs: + - files: + - /etc/prometheus/targets.json + refresh_interval: 15s + relabel_configs: + - source_labels: [__address__, __port__] + separator: ":" + target_label: __param_target + - source_labels: [__param_target] + target_label: instance + - target_label: __address__ + replacement: "localhost:9115" diff --git a/example/rules.yml b/example/rules.yml new file mode 100644 index 0000000..0b41eee --- /dev/null +++ b/example/rules.yml @@ -0,0 +1,27 @@ +groups: + - name: blackbox + rules: + - record: probe_success:tls:eq_tcp + expr: probe_success{job="blackbox:tls"} == on (instance) probe_success{job="blackbox:tcp"} + + - record: probe_success:tls:neq_tcp + expr: probe_success{job="blackbox:tls"} != on (instance) probe_success{job="blackbox:tcp"} + + - record: probe_ssl_last_chain_expiry_timestamp_seconds:sub_time + expr: probe_ssl_last_chain_expiry_timestamp_seconds - time() + + - alert: TLSNotAvailable + expr: probe_success:tls:neq_tcp == 0 + + - alert: TLSLastChainExpiresNextWeek + expr: probe_ssl_last_chain_expiry_timestamp_seconds:sub_time < 86400 * 7 + + - alert: TLSLastChainExpiresTomorrow + expr: probe_ssl_last_chain_expiry_timestamp_seconds:sub_time < 86400 * 1 + + - alert: TLSVersionDeprecated + expr: probe_tls_version_info{version=~"TLS (1.0|1.1)"} + + - alert: TLSVersionUnknown + expr: probe_tls_version_info{version="unknown"} + diff --git a/go.mod b/go.mod new file mode 100644 index 0000000..2a54882 --- /dev/null +++ b/go.mod @@ -0,0 +1,10 @@ +module github.com/codecentric/certspotter-sd + +go 1.15 + +require ( + github.com/google/go-querystring v1.0.0 + go.uber.org/zap v1.15.0 + golang.org/x/time v0.0.0-20200630173020-3af7569d3a1e + gopkg.in/yaml.v2 v2.2.2 +) diff --git a/go.sum b/go.sum new file mode 100644 index 0000000..5668d39 --- /dev/null +++ b/go.sum @@ -0,0 +1,50 @@ +github.com/BurntSushi/toml v0.3.1 h1:WXkYYl6Yr3qBf1K79EBnL4mak0OimBfB0XUf9Vl28OQ= +github.com/BurntSushi/toml v0.3.1/go.mod h1:xHWCNGjB5oqiDr8zfno3MHue2Ht5sIBksp03qcyfWMU= +github.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= +github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= +github.com/google/go-querystring v1.0.0 h1:Xkwi/a1rcvNg1PPYe5vI8GbeBY/jrVuDX5ASuANWTrk= +github.com/google/go-querystring v1.0.0/go.mod h1:odCYkC5MyYFN7vkCjXpyrEuKhc/BUO6wN/zVPAxq5ck= +github.com/google/renameio v0.1.0/go.mod h1:KWCgfxg9yswjAJkECMjeO8J8rahYeXnNhOm40UhjYkI= +github.com/kisielk/gotool v1.0.0/go.mod h1:XhKaO+MFFWcvkIS/tQcRk01m1F5IRFswLeQ+oQHNcck= +github.com/kr/pretty v0.1.0/go.mod h1:dAy3ld7l9f0ibDNOQOHHMYYIIbhfbHSm3C4ZsoJORNo= +github.com/kr/pty v1.1.1/go.mod h1:pFQYn66WHrOpPYNljwOMqo10TkYh1fy3cYio2l3bCsQ= +github.com/kr/text v0.1.0/go.mod h1:4Jbv+DJW3UT/LiOwJeYQe1efqtUx/iVham/4vfdArNI= +github.com/pkg/errors v0.8.1/go.mod h1:bwawxfHBFNV+L2hUp1rHADufV3IMtnDRdf1r5NINEl0= +github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4= +github.com/rogpeppe/go-internal v1.3.0/go.mod h1:M8bDsm7K2OlrFYOpmOWEs/qY81heoFRclV5y23lUDJ4= +github.com/stretchr/objx v0.1.0/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME= +github.com/stretchr/testify v1.3.0/go.mod h1:M5WIy9Dh21IEIfnGCwXGc5bZfKNJtfHm1UVUgZn+9EI= +github.com/stretchr/testify v1.4.0/go.mod h1:j7eGeouHqKxXV5pUuKE4zz7dFj8WfuZ+81PSLYec5m4= +go.uber.org/atomic v1.6.0 h1:Ezj3JGmsOnG1MoRWQkPBsKLe9DwWD9QeXzTRzzldNVk= +go.uber.org/atomic v1.6.0/go.mod h1:sABNBOSYdrvTF6hTgEIbc7YasKWGhgEQZyfxyTvoXHQ= +go.uber.org/multierr v1.5.0 h1:KCa4XfM8CWFCpxXRGok+Q0SS/0XBhMDbHHGABQLvD2A= +go.uber.org/multierr v1.5.0/go.mod h1:FeouvMocqHpRaaGuG9EjoKcStLC43Zu/fmqdUMPcKYU= +go.uber.org/tools v0.0.0-20190618225709-2cfd321de3ee/go.mod h1:vJERXedbb3MVM5f9Ejo0C68/HhF8uaILCdgjnY+goOA= +go.uber.org/zap v1.15.0 h1:ZZCA22JRF2gQE5FoNmhmrf7jeJJ2uhqDUNRYKm8dvmM= +go.uber.org/zap v1.15.0/go.mod h1:Mb2vm2krFEG5DV0W9qcHBYFtp/Wku1cvYaqPsS/WYfc= +golang.org/x/crypto v0.0.0-20190308221718-c2843e01d9a2/go.mod h1:djNgcEr1/C05ACkg1iLfiJU5Ep61QUkGW8qpdssI0+w= +golang.org/x/crypto v0.0.0-20190510104115-cbcb75029529/go.mod h1:yigFU9vqHzYiE8UmvKecakEJjdnWj3jj499lnFckfCI= +golang.org/x/lint v0.0.0-20190930215403-16217165b5de/go.mod h1:6SW0HCj/g11FgYtHlgUYUwCkIfeOF89ocIRzGO/8vkc= +golang.org/x/mod v0.0.0-20190513183733-4bf6d317e70e/go.mod h1:mXi4GBBbnImb6dmsKGUJ2LatrhH/nqhxcFungHvyanc= +golang.org/x/net v0.0.0-20190311183353-d8887717615a/go.mod h1:t9HGtf8HONx5eT2rtn7q6eTqICYqUVnKs3thJo3Qplg= +golang.org/x/net v0.0.0-20190404232315-eb5bcb51f2a3/go.mod h1:t9HGtf8HONx5eT2rtn7q6eTqICYqUVnKs3thJo3Qplg= +golang.org/x/net v0.0.0-20190620200207-3b0461eec859/go.mod h1:z5CRVTTTmAJ677TzLLGU+0bjPO0LkuOLi4/5GtJWs/s= +golang.org/x/sync v0.0.0-20190423024810-112230192c58/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= +golang.org/x/sys v0.0.0-20190215142949-d0b11bdaac8a/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY= +golang.org/x/sys v0.0.0-20190412213103-97732733099d/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= +golang.org/x/text v0.3.0/go.mod h1:NqM8EUOU14njkJ3fqMW+pc6Ldnwhi/IjpwHt7yyuwOQ= +golang.org/x/time v0.0.0-20200630173020-3af7569d3a1e h1:EHBhcS0mlXEAVwNyO2dLfjToGsyY4j24pTs2ScHnX7s= +golang.org/x/time v0.0.0-20200630173020-3af7569d3a1e/go.mod h1:tRJNPiyCQ0inRvYxbN9jk5I+vvW/OXSQhTDSoE431IQ= +golang.org/x/tools v0.0.0-20190311212946-11955173bddd/go.mod h1:LCzVGOaR6xXOjkQ3onu1FJEFr0SW1gC7cKk1uF8kGRs= +golang.org/x/tools v0.0.0-20190621195816-6e04913cbbac/go.mod h1:/rFqwRUd4F7ZHNgwSSTFct+R/Kf4OFW1sUzUTQQTgfc= +golang.org/x/tools v0.0.0-20191029041327-9cc4af7d6b2c/go.mod h1:b+2E5dAYhXwXZwtnZ6UAqBI28+e2cm9otk0dWdXHAEo= +golang.org/x/tools v0.0.0-20191029190741-b9c20aec41a5 h1:hKsoRgsbwY1NafxrwTs+k64bikrLBkAgPir1TNCj3Zs= +golang.org/x/tools v0.0.0-20191029190741-b9c20aec41a5/go.mod h1:b+2E5dAYhXwXZwtnZ6UAqBI28+e2cm9otk0dWdXHAEo= +golang.org/x/xerrors v0.0.0-20190717185122-a985d3407aa7/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0= +gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= +gopkg.in/check.v1 v1.0.0-20180628173108-788fd7840127/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= +gopkg.in/errgo.v2 v2.1.0/go.mod h1:hNsd1EY+bozCKY1Ytp96fpM3vjJbqLJn88ws8XvfDNI= +gopkg.in/yaml.v2 v2.2.2 h1:ZCJp+EgiOT7lHqUV2J862kp8Qj64Jo6az82+3Td9dZw= +gopkg.in/yaml.v2 v2.2.2/go.mod h1:hI93XBmqTisBFMUTm0b8Fm+jr3Dg1NNxqwp+5A1VGuI= +honnef.co/go/tools v0.0.1-2019.2.3 h1:3JgtbtFHMiCmsznwGVTUWbgGov+pVqnlf1dEJTNAXeM= +honnef.co/go/tools v0.0.1-2019.2.3/go.mod h1:a3bituU0lyd329TUQxRnasdCoJDkEUEAqEt0JzvZhAg= diff --git a/internal/certspotter/client.go b/internal/certspotter/client.go new file mode 100644 index 0000000..780ef7a --- /dev/null +++ b/internal/certspotter/client.go @@ -0,0 +1,108 @@ +package certspotter + +import ( + "context" + "encoding/json" + "errors" + "fmt" + "net/http" + "net/url" + + "github.com/google/go-querystring/query" +) + +var ( + // ErrUnexpectedStatus is returned for status codes other than 2XX + ErrUnexpectedStatus = errors.New("unexpected status") +) + +var ( + // BaseURL is the base url for certspotter API endpoint. + BaseURL = "https://api.certspotter.com/v1" +) + +// Client is a certspotter API client. +type Client struct { + cfg *Config + client *http.Client + url string +} + +// Config is used for configuring the client. +type Config struct { + Token string + UserAgent string +} + +// DoOptions are options used when doing a request. +type DoOptions struct { + Method string + Path string + Parameters interface{} +} + +// NewClient returns a new certspotter API client. +func NewClient(cfg *Config) *Client { + return &Client{ + cfg: cfg, + client: &http.Client{}, + url: BaseURL, + } +} + +// GetURL returns a url string for path and parameters or errors. +func (c *Client) GetURL(path string, params interface{}) (string, error) { + endpoint := fmt.Sprintf("%s/%s", c.url, path) + url, err := url.Parse(endpoint) + if err != nil { + return "", err + } + + vals, err := query.Values(params) + if err != nil { + return "", err + } + + url.RawQuery = vals.Encode() + return url.String(), nil +} + +// Do sends a request with options to certspotter api and encodes json +// response into val. +func (c *Client) Do(ctx context.Context, val interface{}, opts *DoOptions) (*http.Response, error) { + url, err := c.GetURL(opts.Path, opts.Parameters) + if err != nil { + return nil, err + } + + req, err := http.NewRequest(opts.Method, url, nil) + if err != nil { + return nil, err + } + + if c.cfg.Token != "" { + req.Header.Add("Authorization", fmt.Sprintf("Bearer %s", c.cfg.Token)) + } + req.Header.Add("Content-Type", "application/json") + req.Header.Add("User-Agent", c.cfg.UserAgent) + + resp, err := c.client.Do(req.WithContext(ctx)) + if err != nil { + return nil, err + } + defer resp.Body.Close() + + if err := CheckResponse(resp); err != nil { + return nil, err + } + + return resp, json.NewDecoder(resp.Body).Decode(&val) +} + +// CheckResponse returns an error if http.Response was unsuccessful. +func CheckResponse(resp *http.Response) error { + if resp.StatusCode >= 200 && resp.StatusCode <= 399 { + return nil + } + return fmt.Errorf("%w %s", ErrUnexpectedStatus, resp.Status) +} diff --git a/internal/certspotter/client_test.go b/internal/certspotter/client_test.go new file mode 100644 index 0000000..2b49b23 --- /dev/null +++ b/internal/certspotter/client_test.go @@ -0,0 +1,119 @@ +package certspotter + +import ( + "context" + "errors" + "fmt" + "net/http" + "net/http/httptest" + "reflect" + "testing" +) + +func setup() (*Client, *http.ServeMux, func()) { + mux := http.NewServeMux() + ts := httptest.NewServer(mux) + return &Client{ + cfg: &Config{}, + client: &http.Client{}, + url: ts.URL, + }, mux, ts.Close +} + +func TestClientGetURL(t *testing.T) { + table := map[string]struct { + path string + params interface{} + want string + }{"only path": { + "issuances", + struct{}{}, + "issuances", + }, "with params": { + "issuances", + struct { + Domain string `url:"domain"` + IncludeSubdomains bool `url:"include_subdomains"` + }{ + "example.com", false, + }, + "issuances?domain=example.com&include_subdomains=false", + }} + + cl, _, stop := setup() + defer stop() + + for name, test := range table { + t.Logf("testing: %s", name) + + want := fmt.Sprintf("%s/%s", cl.url, test.want) + got, err := cl.GetURL(test.path, test.params) + if err != nil { + t.Errorf("unexpected error: %s", err) + } + if !reflect.DeepEqual(got, want) { + t.Errorf("got: %q want %q", got, want) + } + } +} + +type sample struct { + ID string `json:"id"` +} + +func TestClientDo(t *testing.T) { + table := map[string]struct { + data string + want sample + }{"empty sample": { + `{}`, + sample{}, + }, "complete sample": { + `{"id": "1"}`, + sample{ID: "1"}, + }} + + ctx := context.Background() + cl, mux, stop := setup() + defer stop() + + var tname string + mux.HandleFunc("/test", func(w http.ResponseWriter, r *http.Request) { + fmt.Fprint(w, table[tname].data) + }) + + opts := &DoOptions{Path: "/test"} + + for name, test := range table { + t.Logf("testing: %s", name) + + tname = name + var got sample + if _, err := cl.Do(ctx, &got, opts); err != nil { + t.Errorf("unexpected error: %s", err) + } + if !reflect.DeepEqual(got, test.want) { + t.Errorf("got: %q want %q", got, test.want) + } + } +} + +func TestCheckResponse(t *testing.T) { + table := map[string]struct { + resp *http.Response + want error + }{ + "status 200": {&http.Response{StatusCode: 200}, nil}, + "status 199": {&http.Response{StatusCode: 199}, ErrUnexpectedStatus}, + "status 400": {&http.Response{StatusCode: 400}, ErrUnexpectedStatus}, + } + + for name, test := range table { + t.Logf("testing: %s", name) + + got := CheckResponse(test.resp) + if !errors.Is(got, test.want) { + t.Errorf("got: %q; want: %q", got, test.want) + } + } +} diff --git a/internal/certspotter/issuances.go b/internal/certspotter/issuances.go new file mode 100644 index 0000000..8030c3a --- /dev/null +++ b/internal/certspotter/issuances.go @@ -0,0 +1,66 @@ +package certspotter + +import ( + "context" + "net/http" + "sort" + "strings" + "time" +) + +var _ sort.Interface = &Issuances{} + +// Certificate represents a cerspotter certificate object. +type Certificate struct { + Data string `json:"data"` + SHA256 string `json:"sha256"` + Type string `json:"type"` +} + +// Issuance represents a cerspotter issuance object. +type Issuance struct { + ID string `json:"id"` + DNSNames []string `json:"dns_names"` + TBSSHA256 string `json:"tbs_sha256"` + + NotBefore time.Time `json:"not_before"` + NotAfter time.Time `json:"not_after"` + PubKeySHA256 string `json:"pubkey_sha256"` + + Issuer *Issuer `json:"issuer"` + Certificate *Certificate `json:"cert"` +} + +// Issuances implements sort.Interface. +type Issuances []*Issuance + +// Issuer represents a cerspotter issuer object. +type Issuer struct { + Name string `json:"name"` + PubKeySHA256 string `json:"pubkey_sha256"` +} + +// GetIssuancesOptions are options used when getting issuances. +type GetIssuancesOptions struct { + Domain string `url:"domain"` + IncludeSubdomains bool `url:"include_subdomains,omitempty"` + MatchWildcards bool `url:"match_wildcards,omitempty"` + After string `url:"after,omitempty"` + Expand []string `url:"expand,omitempty"` +} + +// GetIssuances returns issuances and response for options. +func (c *Client) GetIssuances(ctx context.Context, opts *GetIssuancesOptions) ([]*Issuance, *http.Response, error) { + var val []*Issuance + resp, err := c.Do(ctx, &val, &DoOptions{ + Method: "GET", + Path: "/issuances", + Parameters: opts, + }) + return val, resp, err +} + +// Len, Swap, Less implement sort.Interface +func (is Issuances) Len() int { return len(is) } +func (is Issuances) Swap(i, j int) { is[i], is[j] = is[j], is[i] } +func (is Issuances) Less(i, j int) bool { return strings.Compare(is[i].ID, is[j].ID) == -1 } diff --git a/internal/certspotter/issuances_test.go b/internal/certspotter/issuances_test.go new file mode 100644 index 0000000..bb73c8e --- /dev/null +++ b/internal/certspotter/issuances_test.go @@ -0,0 +1,163 @@ +package certspotter + +import ( + "context" + "fmt" + "net/http" + "reflect" + "testing" + "time" +) + +func mustParseTime(str string) time.Time { + time, err := time.Parse(time.RFC3339, str) + if err != nil { + panic(err) + } + return time +} + +func TestClientGetIssuances(t *testing.T) { + table := map[string]struct { + data string + opts *GetIssuancesOptions + want []*Issuance + }{"no options": { + `[{ + "id":"648494876", + "tbs_sha256":"b0537995114358761f330303e5b8a0d7c7319a7e458495395e07004911f91c38", + "pubkey_sha256":"8bd1da95272f7fa4ffb24137fc0ed03aae67e5c4d8b3c50734e1050a7920b922", + "not_before":"2018-11-28T00:00:00-00:00", + "not_after":"2020-12-02T12:00:00-00:00" + }] + `, + &GetIssuancesOptions{Domain: "example.com"}, + []*Issuance{&Issuance{ + ID: "648494876", + TBSSHA256: "b0537995114358761f330303e5b8a0d7c7319a7e458495395e07004911f91c38", + PubKeySHA256: "8bd1da95272f7fa4ffb24137fc0ed03aae67e5c4d8b3c50734e1050a7920b922", + NotBefore: mustParseTime("2018-11-28T00:00:00-00:00"), + NotAfter: mustParseTime("2020-12-02T12:00:00-00:00"), + }}, + }, "expand cert": { + `[{ + "id":"648494876", + "tbs_sha256":"b0537995114358761f330303e5b8a0d7c7319a7e458495395e07004911f91c38", + "pubkey_sha256":"8bd1da95272f7fa4ffb24137fc0ed03aae67e5c4d8b3c50734e1050a7920b922", + "not_before":"2018-11-28T00:00:00-00:00", + "not_after":"2020-12-02T12:00:00-00:00", + "cert":{ + "type":"cert", + "sha256":"9250711c54de546f4370e0c3d3a3ec45bc96092a25a4a71a1afa396af7047eb8", + "data":"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" + } + }] + `, + &GetIssuancesOptions{ + Domain: "example.com", + Expand: []string{"cert"}, + }, + []*Issuance{&Issuance{ + ID: "648494876", + TBSSHA256: "b0537995114358761f330303e5b8a0d7c7319a7e458495395e07004911f91c38", + PubKeySHA256: "8bd1da95272f7fa4ffb24137fc0ed03aae67e5c4d8b3c50734e1050a7920b922", + NotBefore: mustParseTime("2018-11-28T00:00:00-00:00"), + NotAfter: mustParseTime("2020-12-02T12:00:00-00:00"), + Certificate: &Certificate{ + Type: "cert", + SHA256: "9250711c54de546f4370e0c3d3a3ec45bc96092a25a4a71a1afa396af7047eb8", + Data: "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", + }, + }}, + }, "expand dns_names": { + `[{ + "id":"648494876", + "tbs_sha256":"b0537995114358761f330303e5b8a0d7c7319a7e458495395e07004911f91c38", + "dns_names": [ + "example.com", + "example.edu", + "example.net", + "example.org", + "www.example.com", + "www.example.edu", + "www.example.net", + "www.example.org" + ], + "pubkey_sha256":"8bd1da95272f7fa4ffb24137fc0ed03aae67e5c4d8b3c50734e1050a7920b922", + "not_before":"2018-11-28T00:00:00-00:00", + "not_after":"2020-12-02T12:00:00-00:00" + }] + `, + &GetIssuancesOptions{ + Domain: "example.com", + Expand: []string{"dns_names"}, + }, + []*Issuance{&Issuance{ + ID: "648494876", + TBSSHA256: "b0537995114358761f330303e5b8a0d7c7319a7e458495395e07004911f91c38", + PubKeySHA256: "8bd1da95272f7fa4ffb24137fc0ed03aae67e5c4d8b3c50734e1050a7920b922", + NotBefore: mustParseTime("2018-11-28T00:00:00-00:00"), + NotAfter: mustParseTime("2020-12-02T12:00:00-00:00"), + DNSNames: []string{ + "example.com", + "example.edu", + "example.net", + "example.org", + "www.example.com", + "www.example.edu", + "www.example.net", + "www.example.org", + }, + }}, + }, "expand issuer": { + `[{ + "id":"648494876", + "tbs_sha256":"b0537995114358761f330303e5b8a0d7c7319a7e458495395e07004911f91c38", + "pubkey_sha256":"8bd1da95272f7fa4ffb24137fc0ed03aae67e5c4d8b3c50734e1050a7920b922", + "issuer":{ + "name":"C=US, O=DigiCert Inc, CN=DigiCert SHA2 Secure Server CA", + "pubkey_sha256":"e6426f344330d0a8eb080bbb7976391d976fc824b5dc16c0d15246d5148ff75c" + }, + "not_before":"2018-11-28T00:00:00-00:00", + "not_after":"2020-12-02T12:00:00-00:00" + }] + `, + &GetIssuancesOptions{ + Domain: "example.com", + Expand: []string{"issuer"}, + }, + []*Issuance{&Issuance{ + ID: "648494876", + TBSSHA256: "b0537995114358761f330303e5b8a0d7c7319a7e458495395e07004911f91c38", + PubKeySHA256: "8bd1da95272f7fa4ffb24137fc0ed03aae67e5c4d8b3c50734e1050a7920b922", + NotBefore: mustParseTime("2018-11-28T00:00:00-00:00"), + NotAfter: mustParseTime("2020-12-02T12:00:00-00:00"), + Issuer: &Issuer{ + Name: "C=US, O=DigiCert Inc, CN=DigiCert SHA2 Secure Server CA", + PubKeySHA256: "e6426f344330d0a8eb080bbb7976391d976fc824b5dc16c0d15246d5148ff75c", + }, + }}, + }} + + ctx := context.Background() + cl, mux, stop := setup() + defer stop() + + var tname string + mux.HandleFunc("/issuances", func(w http.ResponseWriter, r *http.Request) { + fmt.Fprint(w, table[tname].data) + }) + + for name, test := range table { + t.Logf("testing: %s", name) + + tname = name + got, _, err := cl.GetIssuances(ctx, test.opts) + if err != nil { + t.Errorf("unexpected error: %s", err) + } + if !reflect.DeepEqual(got, test.want) { + t.Errorf("got: %v want %v", got, test.want) + } + } +} diff --git a/internal/config/config.go b/internal/config/config.go new file mode 100644 index 0000000..49e8f29 --- /dev/null +++ b/internal/config/config.go @@ -0,0 +1,154 @@ +package config + +import ( + "fmt" + "io/ioutil" + "regexp" + "time" + + yaml "gopkg.in/yaml.v2" +) + +var ( + regexDomainName = regexp.MustCompile(`^([a-z0-9]+(-[a-z0-9]+)*\.)+[a-z]{2,}$`) +) + +var ( + // DefaultConfig is the top-level configuration. + DefaultConfig = Config{ + GlobalConfig: DefaultGlobalConfig, + } + + // DefaultGlobalConfig is the default global configuration. + DefaultGlobalConfig = GlobalConfig{ + Interval: time.Hour, + RateLimit: 1.25, + } + + // DefaultDomainConfig is the default domain configuration. + DefaultDomainConfig = DomainConfig{ + IncludeSubdomains: false, + } +) + +// Config is the top-level configuration. +type Config struct { + GlobalConfig GlobalConfig `yaml:"global"` + DomainConfigs []*DomainConfig `yaml:"domains"` + FileConfigs []*FileConfig `yaml:"files"` +} + +// GlobalConfig configures globally shared values. +type GlobalConfig struct { + // Interval to use between polling the certspotter api. + Interval time.Duration `yaml:"polling_interval"` + // RateLimit to use for certspotter api (configured in Hz). + RateLimit float64 `yaml:"rate_limit"` + // Token to used for authenticating againts certspotter api. + Token string `yaml:"token"` +} + +// DomainConfig configures domain requesting options. +type DomainConfig struct { + // Domain to use for requesting certificate issuances. + Domain string `yaml:"domain"` + // If sub domains should be included. + IncludeSubdomains bool `yaml:"include_subdomains"` +} + +// FileConfig configure a file for exporting issuances. +type FileConfig struct { + // Filename to export targets to + File string `yaml:"file"` + // Labels to add to targets before export + Labels map[string]string `yaml:"labels"` + // Matches for target to be included in file + MatchRE MatchRE `yaml:"match_re"` +} + +// MatchRE represents a map of regex patterns +type MatchRE map[string]*regexp.Regexp + +// UnmarshalYAML implements the yaml.Unmarshaler interface. +func (c *GlobalConfig) UnmarshalYAML(unmarshal func(interface{}) error) error { + *c = DefaultGlobalConfig + type plain GlobalConfig + + if err := unmarshal((*plain)(c)); err != nil { + return err + } + + if c.Interval <= 0 { + return fmt.Errorf("polling interval %s must be greater than 0s", c.Interval) + } + if c.RateLimit <= 0 { + return fmt.Errorf("rate limit %fHz must be greater than 0Hz", c.RateLimit) + } + if c.RateLimit > 20 { + return fmt.Errorf("rate limit %fHz must be smaller than 20Hz", c.RateLimit) + } + + return nil +} + +// UnmarshalYAML implements the yaml.Unmarshaler interface. +func (c *DomainConfig) UnmarshalYAML(unmarshal func(interface{}) error) error { + *c = DefaultDomainConfig + type plain DomainConfig + + if err := unmarshal((*plain)(c)); err != nil { + return err + } + + if !regexDomainName.MatchString(c.Domain) { + return fmt.Errorf("domain %s must be a valid domain", c.Domain) + } + + return nil +} + +// UnmarshalYAML implements the yaml.Unmarshaler interface. +func (m *MatchRE) UnmarshalYAML(unmarshal func(interface{}) error) error { + var matches map[string]string + if err := unmarshal(&matches); err != nil { + return err + } + + mm := make(map[string]*regexp.Regexp, len(matches)) + for name, str := range matches { + regex, err := regexp.Compile("^" + str + "$") + if err != nil { + return err + } + mm[name] = regex + } + *m = mm + + return nil +} + +// Load parses the YAML input s into a Config. +func Load(data string) (*Config, error) { + cfg := &Config{} + *cfg = DefaultConfig + + err := yaml.UnmarshalStrict([]byte(data), cfg) + if err != nil { + return nil, err + } + return cfg, nil +} + +// LoadFile parses the given YAML file into a Config. +func LoadFile(filename string) (*Config, error) { + content, err := ioutil.ReadFile(filename) + if err != nil { + return nil, err + } + + cfg, err := Load(string(content)) + if err != nil { + return nil, fmt.Errorf("parsing YAML file %s: %w", filename, err) + } + return cfg, nil +} diff --git a/internal/discovery/client/client.go b/internal/discovery/client/client.go new file mode 100644 index 0000000..b6ac9d5 --- /dev/null +++ b/internal/discovery/client/client.go @@ -0,0 +1,121 @@ +package client + +import ( + "context" + "net/http" + "strconv" + "time" + + "go.uber.org/zap" + "golang.org/x/time/rate" + + "github.com/codecentric/certspotter-sd/internal/certspotter" +) + +// Client is a thin wrapper around certspotter.Client. +type Client struct { + client *certspotter.Client + interval time.Duration + limiter *rate.Limiter + logger *zap.SugaredLogger +} + +// Config is used for configuring the client. +type Config struct { + // Interval used between polling for new issuances. + Interval time.Duration + // RateLimit used for sending certspotter api requests in Hz. + RateLimit float64 + // Token used for certspotter api. + Token string + // UserAgent used for client agent header. + UserAgent string +} + +// NewClient returns a new client for configuration. +func NewClient(logger *zap.Logger, cfg *Config) *Client { + client := certspotter.NewClient(&certspotter.Config{ + Token: cfg.Token, + UserAgent: cfg.UserAgent, + }) + limit := rate.Limit(cfg.RateLimit) + limiter := rate.NewLimiter(limit, 5) + + return &Client{ + client: client, + interval: cfg.Interval, + limiter: limiter, + logger: logger.Sugar(), + } +} + +// GetIssuances returns issuances for options. +// It takes care of rate limiting and pagination. +func (c *Client) GetIssuances(ctx context.Context, opts *certspotter.GetIssuancesOptions) ([]*certspotter.Issuance, *http.Response, error) { + var all []*certspotter.Issuance + + for { + c.limiter.Wait(ctx) + + issuances, resp, err := c.client.GetIssuances(ctx, opts) + if err != nil { + return nil, nil, err + } + all = append(all, issuances...) + + if len(issuances) == 0 { + return all, resp, nil + } + opts.After = issuances[len(issuances)-1].ID + } +} + +// SubIssuances returns a channel of issuances by subscribing to issuances for options. +func (c *Client) SubIssuances(ctx context.Context, opts *certspotter.GetIssuancesOptions) <-chan []*certspotter.Issuance { + var delay time.Duration + var ok bool + + ch := make(chan []*certspotter.Issuance) + go func() { + defer close(ch) + for { + select { + case <-time.After(delay): + issuances, resp, err := c.GetIssuances(ctx, opts) + c.logger.Debugw("getting issuances", "issuances", len(issuances)) + if err != nil { + c.logger.Errorw("getting issuances", "err", err) + } + + delay, ok = GetRetryAfter(resp) + if !ok || delay < c.interval { + delay = c.interval + } + + select { + case ch <- issuances: + case <-ctx.Done(): + return + } + case <-ctx.Done(): + return + } + } + }() + return ch +} + +// GetRetryAfter returns Retry-After duration or false if non could be parsed. +func GetRetryAfter(resp *http.Response) (time.Duration, bool) { + if resp == nil { + return 0, false + } + + header := resp.Header.Get("Retry-After") + after, err := strconv.Atoi(header) + + if err != nil { + return 0, false + } + return time.Second * time.Duration(after), true +} diff --git a/internal/discovery/client/client_test.go b/internal/discovery/client/client_test.go new file mode 100644 index 0000000..83244e6 --- /dev/null +++ b/internal/discovery/client/client_test.go @@ -0,0 +1,214 @@ +package client + +import ( + "context" + "fmt" + "net/http" + "net/http/httptest" + "reflect" + "testing" + "time" + + "go.uber.org/zap" + + "github.com/codecentric/certspotter-sd/internal/certspotter" +) + +func setup() (*Client, *http.ServeMux, func()) { + mux := http.NewServeMux() + ts := httptest.NewServer(mux) + certspotter.BaseURL = ts.URL + + logger := zap.NewNop() + client := NewClient(logger, &Config{ + Interval: 0, + }) + return client, mux, ts.Close +} + +func TestClientGetIssuances(t *testing.T) { + table := map[string]struct { + data map[string]string + opts *certspotter.GetIssuancesOptions + want []*certspotter.Issuance + }{"zero pages": { + map[string]string{ + "": `[]`, + }, + &certspotter.GetIssuancesOptions{Domain: "example.com"}, + []*certspotter.Issuance(nil), + }, "single page": { + map[string]string{ + "": `[{"id":"648494876"}]`, + "648494876": `[]`, + }, + &certspotter.GetIssuancesOptions{Domain: "example.com"}, + []*certspotter.Issuance{ + &certspotter.Issuance{ID: "648494876"}, + }, + }, "multiple pages": { + map[string]string{ + "": `[{"id":"648494876"}]`, + "648494876": `[{"id":"648494877"}]`, + "648494877": `[]`, + }, + &certspotter.GetIssuancesOptions{Domain: "example.com"}, + []*certspotter.Issuance{ + &certspotter.Issuance{ID: "648494876"}, + &certspotter.Issuance{ID: "648494877"}, + }, + }} + + ctx := context.Background() + cl, mux, stop := setup() + defer stop() + + var tname string + mux.HandleFunc("/issuances", func(w http.ResponseWriter, r *http.Request) { + query := r.URL.Query() + after := query.Get("after") + data := table[tname].data[after] + fmt.Fprint(w, data) + }) + + for name, test := range table { + t.Logf("testing: %s", name) + + tname = name + got, _, err := cl.GetIssuances(ctx, test.opts) + if err != nil { + t.Errorf("unexpected error: %s", err) + } + if !reflect.DeepEqual(got, test.want) { + t.Errorf("got: %#v want %#v", got, test.want) + } + } +} + +func TestClientSubIssuances(t *testing.T) { + table := map[string]struct { + datas []map[string]string + opts *certspotter.GetIssuancesOptions + want [][]*certspotter.Issuance + }{"zero new issuances": { + []map[string]string{ + map[string]string{ + "": `[{"id":"648494876"}]`, + "648494876": `[]`, + }, + map[string]string{ + "": `[{"id":"648494876"}]`, + "648494876": `[]`, + }, + }, + &certspotter.GetIssuancesOptions{Domain: "example.com"}, + [][]*certspotter.Issuance{ + []*certspotter.Issuance{&certspotter.Issuance{ID: "648494876"}}, + []*certspotter.Issuance(nil), + }, + }, "single new issuances": { + []map[string]string{ + map[string]string{ + "": `[{"id":"648494876"}]`, + "648494876": `[]`, + }, + map[string]string{ + "": `[{"id":"648494876"}]`, + "648494876": `[{"id":"648494877"}]`, + "648494877": `[]`, + }, + }, + &certspotter.GetIssuancesOptions{Domain: "example.com"}, + [][]*certspotter.Issuance{ + []*certspotter.Issuance{&certspotter.Issuance{ID: "648494876"}}, + []*certspotter.Issuance{&certspotter.Issuance{ID: "648494877"}}, + }, + }, "delayed new issuances": { + []map[string]string{ + map[string]string{ + "": `[{"id":"648494876"}]`, + "648494876": `[]`, + }, + map[string]string{ + "": `[{"id":"648494876"}]`, + "648494876": `[]`, + }, + map[string]string{ + "": `[{"id":"648494876"}]`, + "648494876": `[{"id":"648494877"}]`, + "648494877": `[]`, + }, + }, + &certspotter.GetIssuancesOptions{Domain: "example.com"}, + [][]*certspotter.Issuance{ + []*certspotter.Issuance{&certspotter.Issuance{ID: "648494876"}}, + []*certspotter.Issuance(nil), + []*certspotter.Issuance{&certspotter.Issuance{ID: "648494877"}}, + }, + }} + + read := func(tname string, num int) [][]*certspotter.Issuance { + ctx, cancel := context.WithCancel(context.Background()) + defer cancel() + cl, mux, stop := setup() + defer stop() + + var idx int + mux.HandleFunc("/issuances", func(w http.ResponseWriter, r *http.Request) { + query := r.URL.Query() + after := query.Get("after") + data := table[tname].datas[idx][after] + w.Header().Add("Retry-After", "0") + fmt.Fprint(w, data) + }) + + ch := cl.SubIssuances(ctx, table[tname].opts) + var issuances [][]*certspotter.Issuance + for ; idx < num; idx++ { + issuances = append(issuances, <-ch) + } + return issuances + } + + for name, test := range table { + t.Logf("testing: %s", name) + + got := read(name, len(test.want)) + if !reflect.DeepEqual(got, test.want) { + t.Errorf("got: %v want: %v", got, test.want) + } + } +} + +func TestGetRetryAfter(t *testing.T) { + table := map[string]struct { + resp *http.Response + want time.Duration + ok bool + }{"good header": { + &http.Response{Header: map[string][]string{ + "Retry-After": []string{"3600"}, + }}, + time.Second * 3600, true, + }, "malformed header": { + &http.Response{Header: map[string][]string{ + "Retry-After": []string{"malformed"}, + }}, + 0, false, + }, "missing header": { + &http.Response{Header: map[string][]string{}}, + 0, false, + }} + + for name, test := range table { + t.Logf("testing: %s", name) + + got, ok := GetRetryAfter(test.resp) + if !reflect.DeepEqual(ok, test.ok) { + t.Errorf("got: %t want: %t", ok, test.ok) + } + if !reflect.DeepEqual(got, test.want) { + t.Errorf("got: %v want: %v", got, test.want) + } + } +} diff --git a/internal/discovery/discovery.go b/internal/discovery/discovery.go new file mode 100644 index 0000000..3a2c41d --- /dev/null +++ b/internal/discovery/discovery.go @@ -0,0 +1,105 @@ +package discovery + +import ( + "context" + "sync" + + "go.uber.org/zap" + + "github.com/codecentric/certspotter-sd/internal/certspotter" + "github.com/codecentric/certspotter-sd/internal/config" + "github.com/codecentric/certspotter-sd/internal/discovery/client" + "github.com/codecentric/certspotter-sd/internal/version" +) + +// Discovery is for subscribing to issuances for domains. +type Discovery struct { + client *client.Client + logger *zap.SugaredLogger +} + +// NewDiscovery returns a new domain subscriber. +func NewDiscovery(logger *zap.Logger, cfg *config.GlobalConfig) *Discovery { + return &Discovery{ + client: client.NewClient(logger, &client.Config{ + Interval: cfg.Interval, + RateLimit: cfg.RateLimit, + Token: cfg.Token, + UserAgent: version.UserAgent(), + }), + logger: logger.Sugar(), + } +} + +// Discover subscribes to domains from configurations and sends all issuances to a channel on updates. +func (d *Discovery) Discover(ctx context.Context, cfgs []*config.DomainConfig) <-chan []*certspotter.Issuance { + var chans []<-chan []*certspotter.Issuance + for _, cfg := range cfgs { + d.logger.Infow("subscribing to issuances", "domain", cfg.Domain) + in := d.client.SubIssuances(ctx, &certspotter.GetIssuancesOptions{ + Domain: cfg.Domain, + Expand: []string{"cert", "dns_names", "issuer"}, + IncludeSubdomains: cfg.IncludeSubdomains, + }) + chans = append(chans, in) + } + + ch := d.Merge(ctx, chans...) + return d.Aggregate(ctx, ch) +} + +// Aggregate aggregates issuances recived on in channel and outputs all previously recived issuances to a channel on updates +func (d *Discovery) Aggregate(ctx context.Context, in <-chan []*certspotter.Issuance) <-chan []*certspotter.Issuance { + var all []*certspotter.Issuance + var send chan []*certspotter.Issuance + + issuances := make(map[string]*certspotter.Issuance) + out := make(chan []*certspotter.Issuance) + go func() { + defer close(out) + for { + select { + case recieved := <-in: + for _, issuance := range recieved { + issuances[issuance.ID] = issuance + } + + all = []*certspotter.Issuance{} + for _, issuance := range issuances { + all = append(all, issuance) + } + send = out + case send <- all: + send = nil + case <-ctx.Done(): + return + } + } + }() + return out +} + +// Merge merges mulptiple issuance channels into one +func (d *Discovery) Merge(ctx context.Context, cs ...<-chan []*certspotter.Issuance) <-chan []*certspotter.Issuance { + var wg sync.WaitGroup + out := make(chan []*certspotter.Issuance) + + wg.Add(len(cs)) + for _, in := range cs { + go func(in <-chan []*certspotter.Issuance) { + defer wg.Done() + for issuances := range in { + select { + case out <- issuances: + case <-ctx.Done(): + return + } + } + }(in) + } + go func() { + wg.Wait() + close(out) + }() + return out +} diff --git a/internal/export/export.go b/internal/export/export.go new file mode 100644 index 0000000..d7f8c08 --- /dev/null +++ b/internal/export/export.go @@ -0,0 +1,92 @@ +package export + +import ( + "context" + "encoding/json" + "os" + "time" + + "go.uber.org/zap" + + "github.com/codecentric/certspotter-sd/internal/certspotter" + "github.com/codecentric/certspotter-sd/internal/config" +) + +// Exporter is used for exporting issuances as targets to file. +type Exporter struct { + logger *zap.SugaredLogger +} + +// NewExporter returns a new exporter form global configuration. +func NewExporter(logger *zap.Logger, cfg *config.GlobalConfig) *Exporter { + return &Exporter{ + logger: logger.Sugar(), + } +} + +// Export exports valid issuances from channel as targets to files +func (e *Exporter) Export(ctx context.Context, in <-chan []*certspotter.Issuance, cfgs []*config.FileConfig) { + var issuances []*certspotter.Issuance + for { + select { + case issuances = <-in: + tgs := GetTargets(issuances) + for filename, tgs := range GetFileTargets(tgs, cfgs) { + if err := Write(filename, tgs); err != nil { + e.logger.Errorw("writing targets to file", "err", err) + } + } + case <-ctx.Done(): + return + } + } +} + +// GetTargets returns a set of valid targtes from issuances +func GetTargets(issuances []*certspotter.Issuance) []*Target { + now := time.Now() + var tgs []*Target + for _, issuance := range issuances { + if issuance.Certificate != nil && issuance.Certificate.Type == "precert" { + continue + } + if now.After(issuance.NotAfter) || now.Before(issuance.NotBefore) { + continue + } + tgs = append(tgs, NewTarget(issuance)) + } + return tgs +} + +// GetFileTargets returns a map of targets per matching file +func GetFileTargets(tgs []*Target, cfgs []*config.FileConfig) map[string][]*Target { + files := make(map[string][]*Target) + for _, cfg := range cfgs { + files[cfg.File] = []*Target{} + } + + for _, tg := range tgs { + for _, cfg := range cfgs { + if !tg.Matches(cfg.MatchRE) { + continue + } + tg.AddLabels(cfg.Labels) + files[cfg.File] = append(files[cfg.File], tg) + } + } + return files +} + +// Write writes targets as json array to filename +func Write(filename string, tgs []*Target) error { + file, err := os.OpenFile(filename, os.O_RDWR|os.O_CREATE|os.O_TRUNC, 0644) + if err != nil { + return err + } + defer file.Close() + + if tgs == nil { + tgs = []*Target{} + } + return json.NewEncoder(file).Encode(tgs) +} diff --git a/internal/export/export_test.go b/internal/export/export_test.go new file mode 100644 index 0000000..3db81d3 --- /dev/null +++ b/internal/export/export_test.go @@ -0,0 +1,106 @@ +package export + +import ( + "reflect" + "testing" + "time" + + "github.com/codecentric/certspotter-sd/internal/certspotter" +) + +func mustParseTime(str string) time.Time { + time, err := time.Parse(time.RFC3339, str) + if err != nil { + panic(err) + } + return time +} + +func TestGetTargets(t *testing.T) { + table := map[string]struct { + issuances []*certspotter.Issuance + want []*Target + }{"valid issuances": { + []*certspotter.Issuance{ + &certspotter.Issuance{ + ID: "648494876", + NotBefore: mustParseTime("2000-01-01T00:00:00-00:00"), + NotAfter: mustParseTime("2100-01-01T00:00:00-00:00"), + Certificate: &certspotter.Certificate{Type: "cert"}, + }, + &certspotter.Issuance{ + ID: "648494877", + NotBefore: mustParseTime("2000-01-01T00:00:00-00:00"), + NotAfter: mustParseTime("2100-01-01T00:00:00-00:00"), + Certificate: &certspotter.Certificate{Type: "cert"}, + }, + }, + []*Target{ + &Target{Labels: map[string]string{ + "__meta_certspotter_id": "648494876", + "__meta_certspotter_cert_sha256": "", + }}, + &Target{Labels: map[string]string{ + "__meta_certspotter_id": "648494877", + "__meta_certspotter_cert_sha256": "", + }}, + }, + }, "precert issuances": { + []*certspotter.Issuance{ + &certspotter.Issuance{ + ID: "648494876", + NotBefore: mustParseTime("2000-01-01T00:00:00-00:00"), + NotAfter: mustParseTime("2100-01-01T00:00:00-00:00"), + Certificate: &certspotter.Certificate{Type: "cert"}, + }, + &certspotter.Issuance{ + ID: "648494877", + NotBefore: mustParseTime("2000-01-01T00:00:00-00:00"), + NotAfter: mustParseTime("2100-01-01T00:00:00-00:00"), + Certificate: &certspotter.Certificate{Type: "precert"}, + }, + }, + []*Target{ + &Target{Labels: map[string]string{ + "__meta_certspotter_id": "648494876", + "__meta_certspotter_cert_sha256": "", + }}, + }, + }, "outdated issuances": { + []*certspotter.Issuance{ + &certspotter.Issuance{ + ID: "648494876", + NotBefore: mustParseTime("2000-01-01T00:00:00-00:00"), + NotAfter: mustParseTime("2100-01-01T00:00:00-00:00"), + Certificate: &certspotter.Certificate{Type: "cert"}, + }, + &certspotter.Issuance{ + ID: "648494877", + NotBefore: mustParseTime("2000-01-01T00:00:00-00:00"), + NotAfter: mustParseTime("2000-01-01T00:00:00-00:00"), + Certificate: &certspotter.Certificate{Type: "cert"}, + }, + &certspotter.Issuance{ + ID: "648494877", + NotBefore: mustParseTime("2100-01-01T00:00:00-00:00"), + NotAfter: mustParseTime("2100-01-01T00:00:00-00:00"), + Certificate: &certspotter.Certificate{Type: "cert"}, + }, + }, + []*Target{ + &Target{Labels: map[string]string{ + "__meta_certspotter_id": "648494876", + "__meta_certspotter_cert_sha256": "", + }}, + }, + }} + + for name, test := range table { + t.Logf("testing: %s", name) + + got := GetTargets(test.issuances) + if !reflect.DeepEqual(got, test.want) { + t.Errorf("got: %+v want: %+v", got[0], test.want[0]) + } + } +} diff --git a/internal/export/target.go b/internal/export/target.go new file mode 100644 index 0000000..8cfdec0 --- /dev/null +++ b/internal/export/target.go @@ -0,0 +1,63 @@ +package export + +import ( + "fmt" + "regexp" + "strings" + + "github.com/codecentric/certspotter-sd/internal/certspotter" +) + +// Target represents a prometheus file service discovery target +type Target struct { + Labels map[string]string `json:"labels"` + Targets []string `json:"targets"` +} + +// NewTarget returns a new target from a certspotter issuance. +func NewTarget(issuance *certspotter.Issuance) *Target { + labels := make(map[string]string) + + labels["__meta_certspotter_id"] = issuance.ID + if issuance.Certificate != nil { + labels["__meta_certspotter_cert_sha256"] = issuance.Certificate.SHA256 + } + if len(issuance.DNSNames) != 0 { + labels["__meta_certspotter_dns_names"] = strings.Join(issuance.DNSNames, ";") + } + if issuance.Issuer != nil { + labels["__meta_certspotter_issuer_name"] = issuance.Issuer.Name + } + + var targets []string + for _, name := range issuance.DNSNames { + if !strings.HasPrefix(name, "*.") { + targets = append(targets, name) + } + } + + return &Target{ + Labels: labels, + Targets: targets, + } +} + +// AddLabels adds labels to target with prefix __meta_certspotter_labels_ +func (t *Target) AddLabels(labels map[string]string) { + for name, val := range labels { + label := fmt.Sprintf("__meta_certspotter_labels_%s", name) + t.Labels[label] = val + } +} + +// Matches tests if target labels match map of regex patterns. +// __meta_certspotter_ is removed from target labels before matching. +func (t *Target) Matches(matches map[string]*regexp.Regexp) bool { + for name, re := range matches { + label := fmt.Sprintf("__meta_certspotter_%s", name) + if val, ok := t.Labels[label]; !ok || !re.MatchString(val) { + return false + } + } + return true +} diff --git a/internal/export/target_test.go b/internal/export/target_test.go new file mode 100644 index 0000000..044bdc3 --- /dev/null +++ b/internal/export/target_test.go @@ -0,0 +1,190 @@ +package export + +import ( + "reflect" + "regexp" + "testing" + + "github.com/codecentric/certspotter-sd/internal/certspotter" +) + +func TestNewTarget(t *testing.T) { + table := map[string]struct { + issuance *certspotter.Issuance + want *Target + }{"empty issuance": { + &certspotter.Issuance{}, + &Target{Labels: map[string]string{ + "__meta_certspotter_id": "", + }}, + }, "only cert sha256": { + &certspotter.Issuance{ + ID: "648494876", + Certificate: &certspotter.Certificate{ + SHA256: "9250711c54de546f4370e0c3d3a3ec45bc96092a25a4a71a1afa396af7047eb8", + }, + }, + &Target{ + Labels: map[string]string{ + "__meta_certspotter_id": "648494876", + "__meta_certspotter_cert_sha256": "9250711c54de546f4370e0c3d3a3ec45bc96092a25a4a71a1afa396af7047eb8", + }, + }, + }, "only dns names": { + &certspotter.Issuance{ + ID: "648494876", + DNSNames: []string{"example.com", "example2.com"}, + }, + &Target{ + Labels: map[string]string{ + "__meta_certspotter_id": "648494876", + "__meta_certspotter_dns_names": "example.com;example2.com", + }, + Targets: []string{"example.com", "example2.com"}, + }, + }, "only issuer name": { + &certspotter.Issuance{ + ID: "648494876", + Issuer: &certspotter.Issuer{ + Name: "C=US, O=DigiCert Inc, CN=DigiCert SHA2 Secure Server CA", + }, + }, + &Target{ + Labels: map[string]string{ + "__meta_certspotter_id": "648494876", + "__meta_certspotter_issuer_name": "C=US, O=DigiCert Inc, CN=DigiCert SHA2 Secure Server CA", + }, + }, + }, "complete issuance": { + &certspotter.Issuance{ + ID: "648494876", + Certificate: &certspotter.Certificate{ + SHA256: "9250711c54de546f4370e0c3d3a3ec45bc96092a25a4a71a1afa396af7047eb8", + }, + DNSNames: []string{"example.com", "example2.com"}, + Issuer: &certspotter.Issuer{ + Name: "C=US, O=DigiCert Inc, CN=DigiCert SHA2 Secure Server CA", + }, + }, + &Target{ + Labels: map[string]string{ + "__meta_certspotter_id": "648494876", + "__meta_certspotter_cert_sha256": "9250711c54de546f4370e0c3d3a3ec45bc96092a25a4a71a1afa396af7047eb8", + "__meta_certspotter_dns_names": "example.com;example2.com", + "__meta_certspotter_issuer_name": "C=US, O=DigiCert Inc, CN=DigiCert SHA2 Secure Server CA", + }, + Targets: []string{"example.com", "example2.com"}, + }, + }} + + for name, test := range table { + t.Logf("testing: %s", name) + + got := NewTarget(test.issuance) + if !reflect.DeepEqual(got, test.want) { + t.Errorf("got: %#v want: %#v", got, test.want) + } + } +} + +func TestTargetAddLabels(t *testing.T) { + table := map[string]struct { + target *Target + labels map[string]string + want *Target + }{"new labels": { + &Target{Labels: make(map[string]string)}, + map[string]string{ + "name1": "val1", + "name2": "val2", + }, + &Target{ + Labels: map[string]string{ + "__meta_certspotter_labels_name1": "val1", + "__meta_certspotter_labels_name2": "val2", + }, + }, + }, "existing labels": { + &Target{ + Labels: map[string]string{ + "__meta_certspotter_labels_name1": "val1", + "__meta_certspotter_labels_name2": "val2", + }, + }, + map[string]string{ + "name1": "val3", + "name2": "val2", + }, + &Target{ + Labels: map[string]string{ + "__meta_certspotter_labels_name1": "val3", + "__meta_certspotter_labels_name2": "val2", + }, + }, + }} + + for name, test := range table { + t.Logf("testing: %s", name) + + test.target.AddLabels(test.labels) + got := test.target + if !reflect.DeepEqual(got, test.want) { + t.Errorf("got: %#v want: %#v", got, test.want) + } + } +} + +func TestTargetMatches(t *testing.T) { + table := map[string]struct { + target *Target + matches map[string]*regexp.Regexp + want bool + }{"matching label": { + &Target{ + Labels: map[string]string{ + "__meta_certspotter_dns_names": "example.com", + }, + }, + map[string]*regexp.Regexp{ + "dns_names": regexp.MustCompile("example.com"), + }, + true, + }, "non matching label": { + &Target{ + Labels: map[string]string{ + "__meta_certspotter_dns_names": "example.com", + }, + }, + map[string]*regexp.Regexp{ + "dns_names": regexp.MustCompile("not-example.com"), + }, + false, + }, "non prefixed label": { + &Target{ + Labels: map[string]string{ + "dns_names": "example.com", + }, + }, + map[string]*regexp.Regexp{ + "dns_names": regexp.MustCompile("example.com"), + }, + false, + }, "non matches": { + &Target{ + Labels: map[string]string{ + "__meta_certspotter_dns_names": "example.com", + }, + }, + map[string]*regexp.Regexp{}, + true, + }} + + for name, test := range table { + t.Logf("testing: %s", name) + + got := test.target.Matches(test.matches) + if !reflect.DeepEqual(got, test.want) { + t.Errorf("got: %t want: %t", got, test.want) + } + } +} diff --git a/internal/version/version.go b/internal/version/version.go new file mode 100644 index 0000000..019a6b7 --- /dev/null +++ b/internal/version/version.go @@ -0,0 +1,27 @@ +package version + +import ( + "fmt" + "runtime" +) + +var ( + // BuildDate is supplied by linker + BuildDate = "invalid:-use-make-to-build" + // Version is supplied by linker + Version = "invalid:-use-make-to-build" +) + +// Print returns a string containing version information. +func Print() string { + return fmt.Sprintf("certspotter-sd version %s build date %s go version %s", + Version, + BuildDate, + runtime.Version(), + ) +} + +// UserAgent returns string to be used as User-Agent header. +func UserAgent() string { + return fmt.Sprintf("certspotter-sd/%s github.com/codecentric/certspotter-sd", Version) +}