diff --git a/.github/workflows/docker-build.yml b/.github/workflows/docker-build.yml new file mode 100644 index 0000000..f530211 --- /dev/null +++ b/.github/workflows/docker-build.yml @@ -0,0 +1,15 @@ +name: docker + +on: + pull_request: + +jobs: + build: + runs-on: ubuntu-latest + name: build + + steps: + - uses: actions/checkout@v2 + + - name: Build image + run: docker build . --file Dockerfile --tag github.com/mrtazz/certcal diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml new file mode 100644 index 0000000..8c1cdd3 --- /dev/null +++ b/.github/workflows/release.yml @@ -0,0 +1,57 @@ +name: release +on: + push: + tags: + - '*' + +jobs: + release: + name: github + runs-on: ubuntu-latest + env: + BUILDER_NAME: "GitHub Actions" + BUILDER_EMAIL: noreply@actions.github.com + + steps: + + - name: Set up Go 1.17 + uses: actions/setup-go@v2 + with: + go-version: 1.17 + id: go + + - name: Check out code + uses: actions/checkout@v2 + with: + fetch-depth: 1 + + - name: build for platforms + run: | + BUILD_GOARCH=amd64 BUILD_GOOS=freebsd make build-artifact + BUILD_GOARCH=amd64 BUILD_GOOS=linux make build-artifact + BUILD_GOARCH=amd64 BUILD_GOOS=darwin make build-artifact + + - name: create release + run: make github-release + env: + GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} + + docker: + name: docker + env: + BUILDER_NAME: "GitHub Actions" + BUILDER_EMAIL: actions@noreply.github.com + + runs-on: ubuntu-latest + + steps: + - uses: actions/checkout@v2 + - name: Build the Docker image + run: docker build . --file Dockerfile --tag mrtazz/certcal:${GITHUB_SHA} --tag mrtazz/certcal:latest + + - name: push to ghcr + run: | + echo "${{ secrets.GITHUB_TOKEN }}" | docker login ghcr.io -u ${{ github.actor }} --password-stdin + docker push mrtazz/certcal:${GITHUB_SHA} + docker push mrtazz/certcal:latest + docker logout ghcr.io diff --git a/.github/workflows/tests.yml b/.github/workflows/tests.yml new file mode 100644 index 0000000..59555bb --- /dev/null +++ b/.github/workflows/tests.yml @@ -0,0 +1,23 @@ +name: CI +on: push +jobs: + + test: + name: test + runs-on: ubuntu-latest + steps: + + - name: Set up Go 1.17 + uses: actions/setup-go@v2 + with: + go-version: 1.17 + id: go + + - name: Check out code + uses: actions/checkout@v2 + with: + fetch-depth: 1 + + - name: Run tests + run: | + make test diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..6fd55ee --- /dev/null +++ b/.gitignore @@ -0,0 +1,2 @@ +certcal +.release_artifacts diff --git a/Dockerfile b/Dockerfile new file mode 100644 index 0000000..a2a8fc5 --- /dev/null +++ b/Dockerfile @@ -0,0 +1,21 @@ +FROM golang:1.17-alpine3.15 AS builder + +RUN mkdir /app + +WORKDIR /app + +ADD . /app/ + +RUN go build -o certcal certcal.go + +FROM alpine:3.15 +RUN apk --no-cache add ca-certificates + +WORKDIR /app +COPY --from=builder /app/certcal /app/certcal +RUN chown -R nobody /app && chmod +x /app/certcal + +USER nobody +ENV PORT=3000 +ENV CERTCAL_HOSTS="unwiredcouch.com,github.com" +ENTRYPOINT ["/app/certcal"] diff --git a/LICENSE b/LICENSE new file mode 100644 index 0000000..3860573 --- /dev/null +++ b/LICENSE @@ -0,0 +1,19 @@ +Copyright (C) 2022 Daniel Schauenberg + +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..8820a35 --- /dev/null +++ b/Makefile @@ -0,0 +1,75 @@ +# +# some housekeeping tasks +# + +# variable definitions +NAME := certcal +DESC := provide an iCal web feed for certificate expiration +PREFIX ?= usr/local +VERSION := $(shell git describe --tags --always --dirty) +GOVERSION := $(shell go version) + +BUILD_GOOS ?= $(shell go env GOOS) +BUILD_GOARCH ?= $(shell go env GOARCH) + +RELEASE_ARTIFACTS_DIR := .release_artifacts +CHECKSUM_FILE := checksums.txt + +$(RELEASE_ARTIFACTS_DIR): + install -d $@ + +BUILDER := $(shell echo "${BUILDER_NAME} <${EMAIL}>") + +PKG_RELEASE ?= 1 +PROJECT_URL := "https://github.com/mrtazz/$(NAME)" +LDFLAGS := -X 'main.version=$(VERSION)' \ + -X 'main.goversion=$(GOVERSION)' + +TARGETS := certcal +INSTALLED_TARGETS = $(addprefix $(PREFIX)/bin/, $(TARGETS)) + +.PHONY: certcal +certcal: certcal.go + GOOS=$(BUILD_GOOS) GOARCH=$(BUILD_GOARCH) go build -ldflags "$(LDFLAGS)" -o $@ $< +.DEFAULT_GOAL:=certcal + +# development tasks +.PHONY: test +test: + go test -v ./... + +.PHONY: coverage +coverage: + go test -v -race -coverprofile=cover.out ./... + @-go tool cover -html=cover.out -o cover.html + +.PHONY: benchmark +benchmark: + @echo "Running tests..." + @go test -bench=. ${NAME} + +# install tasks +$(PREFIX)/bin/%: % + install -d $$(dirname $@) + install -m 755 $< $@ + +.PHONY: install +install: $(INSTALLED_TARGETS) $(INSTALLED_MAN_TARGETS) + +.PHONY: local-install +local-install: + $(MAKE) install PREFIX=usr/local + +.PHONY: build-artifact +build-artifact: certcal $(RELEASE_ARTIFACTS_DIR) + mv certcal $(RELEASE_ARTIFACTS_DIR)/certcal-$(VERSION).$(BUILD_GOOS).$(BUILD_GOARCH) + cd $(RELEASE_ARTIFACTS_DIR) && shasum -a 256 certcal-$(VERSION).$(BUILD_GOOS).$(BUILD_GOARCH) >> $(CHECKSUM_FILE) + +.PHONY: github-release +github-release: + gh release create $(VERSION) --title 'Release $(VERSION)' --notes-file docs/releases/$(VERSION).md $(RELEASE_ARTIFACTS_DIR)/* + +# clean up tasks +.PHONY: clean +clean: + git clean -fdx diff --git a/README.md b/README.md index 885c94d..51ee4d6 100644 --- a/README.md +++ b/README.md @@ -1,2 +1,80 @@ # certcal -provide an iCal web feed for certificate expiry + +Provide an iCal web feed for certificate expiry. + + +## Usage + +There are a couple of ways to run this + +### Standalone +You can download one of the binaries and run it as a standalone: + +```shell +% ./certcal serve --help +Usage: certcal serve --hosts=HOSTS,... --interval="24h" + +run the server. + +Flags: + -h, --help Show context-sensitive help. + + --hosts=HOSTS,... hosts to check certs for ($CERTCAL_HOSTS). + --interval="24h" interval in which to check certs ($CERTCAL_INTERVAL) + --port=3000 port for the server to listen on ($PORT) + +% PORT=3000 CERTCAL_INTERVAL=5h CERTCAL_HOSTS="unwiredcouch.com" ./certcal +``` + + +### As part of an existing http mux + +You can include the handler in your existing mux, something along the lines +of: + +```go +import ( + "github.com/mrtazz/certcal/handler" + "github.com/mrtazz/certcal/hosts" + "net/http" + "time" +) + +func Run() { + + ... + + hosts.AddHosts([]string{"unwiredcouch.com"}) + hosts.UpdateEvery(5 * time.Hour) + + http.HandleFunc("/hosts", handler.Handler) + http.ListenAndServe(":3000", nil) + +} +``` + + +### Via Docker +There is a docker image as well that you can use: + +```sh +docker pull ghcr.io/mrtazz/certcal +``` + + +## FAQ + +### Shouldn't certs renew automatically? +Probably. But sometimes they aren't. + +### Shouldn't this be an alert somewhere? +Maybe, up to you. + +### Old expiry events are disappearing! +That is by design. The UID of the `VEVENT` is the `sha256sum` of the summary +of the event. Because generally if the cert got renewed the old event is just +cruft. + + +## Inspiration +[genuinetools/certok](https://github.com/genuinetools/certok) inspired this diff --git a/certcal.go b/certcal.go new file mode 100644 index 0000000..292119d --- /dev/null +++ b/certcal.go @@ -0,0 +1,59 @@ +package main + +import ( + "fmt" + "github.com/alecthomas/kong" + "github.com/mrtazz/certcal/handler" + "github.com/mrtazz/certcal/hosts" + log "github.com/sirupsen/logrus" + "net/http" + "time" +) + +var ( + version = "" + goversion = "" +) + +// CLI defines the command line arguments +var CLI struct { + Serve struct { + Hosts []string `required:"" help:"hosts to check certs for." env:"CERTCAL_HOSTS"` + Interval string `required:"" help:"interval in which to check certs" env:"CERTCAL_INTERVAL" default:"24h"` + Port int `help:"port for the server to listen on" env:"PORT" default:"3000"` + } `cmd:"" help:"run the server."` +} + +func main() { + + log.SetFormatter(&log.TextFormatter{ + FullTimestamp: true, + }) + logger := log.WithFields(log.Fields{ + "package": "main", + }) + + ctx := kong.Parse(&CLI) + switch ctx.Command() { + case "serve": + duration, err := time.ParseDuration(CLI.Serve.Interval) + if err != nil { + logger.Error("unable to parse CERTCAL_INTERVAL, defaulting to 1 day") + duration = 24 * time.Hour + } + hosts.AddHosts(CLI.Serve.Hosts) + hosts.UpdateEvery(duration) + + address := fmt.Sprintf(":%d", CLI.Serve.Port) + + logger.WithFields(log.Fields{ + "address": address, + }).Info("starting web server") + http.HandleFunc("/hosts", handler.Handler) + http.ListenAndServe(address, nil) + + default: + logger.Error("Unknown command: " + ctx.Command()) + } + +} diff --git a/docs/releases/0.1.0.md b/docs/releases/0.1.0.md new file mode 100644 index 0000000..9bbcd0c --- /dev/null +++ b/docs/releases/0.1.0.md @@ -0,0 +1,3 @@ +## v0.1.0 (2022-??-??) +- initial version + diff --git a/go.mod b/go.mod new file mode 100644 index 0000000..a75cbe1 --- /dev/null +++ b/go.mod @@ -0,0 +1,17 @@ +module github.com/mrtazz/certcal + +go 1.17 + +require ( + github.com/sirupsen/logrus v1.8.1 + github.com/stretchr/testify v1.7.0 +) + +require ( + github.com/alecthomas/kong v0.4.1 // indirect + github.com/davecgh/go-spew v1.1.1 // indirect + github.com/pkg/errors v0.9.1 // indirect + github.com/pmezard/go-difflib v1.0.0 // indirect + golang.org/x/sys v0.0.0-20191026070338-33540a1f6037 // indirect + gopkg.in/yaml.v3 v3.0.0-20210107192922-496545a6307b // indirect +) diff --git a/go.sum b/go.sum new file mode 100644 index 0000000..d619aaf --- /dev/null +++ b/go.sum @@ -0,0 +1,24 @@ +github.com/alecthomas/kong v0.4.1 h1:0sFnMts+ijOiFuSHsMB9MlDi3NGINBkx9KIw1/gcuDw= +github.com/alecthomas/kong v0.4.1/go.mod h1:uzxf/HUh0tj43x1AyJROl3JT7SgsZ5m+icOv1csRhc0= +github.com/alecthomas/repr v0.0.0-20210801044451-80ca428c5142/go.mod h1:2kn6fqh/zIyPLmm3ugklbEi5hg5wS435eygvNfaDQL8= +github.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= +github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c= +github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= +github.com/pkg/errors v0.9.1 h1:FEBLx1zS214owpjy7qsBeixbURkuhQAwrK5UwLGTwt4= +github.com/pkg/errors v0.9.1/go.mod h1:bwawxfHBFNV+L2hUp1rHADufV3IMtnDRdf1r5NINEl0= +github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM= +github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4= +github.com/sirupsen/logrus v1.8.1 h1:dJKuHgqk1NNQlqoA6BTlM1Wf9DOH3NBjQyu0h9+AZZE= +github.com/sirupsen/logrus v1.8.1/go.mod h1:yWOB1SBYBC5VeMP7gHvWumXLIWorT60ONWic61uBYv0= +github.com/stretchr/objx v0.1.0/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME= +github.com/stretchr/testify v1.2.2/go.mod h1:a8OnRcib4nhh0OaRAV+Yts87kKdq0PP7pXfy6kDkUVs= +github.com/stretchr/testify v1.7.0 h1:nwc3DEeHmmLAfoZucVR881uASk0Mfjw8xYJ99tb5CcY= +github.com/stretchr/testify v1.7.0/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg= +golang.org/x/sys v0.0.0-20191026070338-33540a1f6037 h1:YyJpGZS1sBuBCzLAR1VEpK193GlqGZbnPFnPV/5Rsb4= +golang.org/x/sys v0.0.0-20191026070338-33540a1f6037/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= +gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405 h1:yhCVgyC4o1eVCa2tZl7eS0r+SDo693bJlVdllGtEeKM= +gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= +gopkg.in/yaml.v3 v3.0.0-20200313102051-9f266ea9e77c h1:dUUwHk2QECo/6vqA44rthZ8ie2QXMNeKRTHCNY2nXvo= +gopkg.in/yaml.v3 v3.0.0-20200313102051-9f266ea9e77c/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= +gopkg.in/yaml.v3 v3.0.0-20210107192922-496545a6307b h1:h8qDotaEPuJATrMmW04NCwg7v22aHH28wwpauUhK9Oo= +gopkg.in/yaml.v3 v3.0.0-20210107192922-496545a6307b/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= diff --git a/handler/handler.go b/handler/handler.go new file mode 100644 index 0000000..abc3e02 --- /dev/null +++ b/handler/handler.go @@ -0,0 +1,52 @@ +package handler + +import ( + "fmt" + "github.com/mrtazz/certcal/hosts" + "github.com/mrtazz/certcal/ical" + log "github.com/sirupsen/logrus" + "net/http" + "time" +) + +const ( + contentType = "application/octet-stream" +) + +var ( + logger = log.WithFields(log.Fields{ + "package": "handler", + }) +) + +// Handler implements the ical handler +func Handler(w http.ResponseWriter, req *http.Request) { + checkedHosts := hosts.GetHosts() + cal := ical.Calendar{ + Events: make([]ical.Event, 0, len(checkedHosts)), + } + + for _, h := range checkedHosts { + if len(h.Certs) > 0 { + cal.AddEvent(ical.Event{ + CreatedAt: time.Now(), + LastModified: time.Now(), + DtStamp: time.Now(), + Summary: fmt.Sprintf("cert for %s expires", h.HostString), + Start: h.Certs[0].NotAfter, + End: h.Certs[0].NotAfter, + URL: "", + Description: fmt.Sprintf("cert for %s expires", h.HostString), + }) + } + } + + out, err := cal.Render() + if err != nil { + logger.Error(err) + w.WriteHeader(http.StatusInternalServerError) + } + + w.Header().Set("Content-Type", contentType) + fmt.Fprintf(w, out) +} diff --git a/hosts/hosts.go b/hosts/hosts.go new file mode 100644 index 0000000..f5f9ea1 --- /dev/null +++ b/hosts/hosts.go @@ -0,0 +1,111 @@ +package hosts + +import ( + "crypto/tls" + "crypto/x509" + log "github.com/sirupsen/logrus" + "net" + "strings" + "sync" + "time" +) + +var ( + info []*Info + lock sync.Mutex + done = make(chan bool) + logger = log.WithFields(log.Fields{ + "package": "hosts", + }) +) + +// GetHosts returns all configured hosts +func GetHosts() []*Info { + lock.Lock() + defer lock.Unlock() + return info +} + +// UpdateEvery runs an update on all hosts every interval +func UpdateEvery(interval time.Duration) { + ticker := time.NewTicker(interval) + go func() { + for { + select { + case <-done: + return + case <-ticker.C: + logger.Info("updating hosts") + updateAllHosts() + } + } + }() +} + +func updateAllHosts() { + lock.Lock() + defer lock.Unlock() + for _, i := range info { + logger.WithFields(log.Fields{ + "host": i.HostString, + }).Info("retrieving certs") + i.GetCerts(5 * time.Second) + } +} + +// Info provides information for a host +type Info struct { + HostString string + Certs []*x509.Certificate +} + +// AddHost adds a new host to watch +func AddHost(hostString string) { + if !strings.Contains(hostString, ":") { + hostString = hostString + ":443" + } + info = append(info, &Info{ + HostString: hostString, + }) +} + +// AddHosts adds a set of hosts to watch +func AddHosts(hostStrings []string) { + for _, h := range hostStrings { + AddHost(h) + } +} + +// GetCerts retrieves certs for the configure Host +func (i *Info) GetCerts(timeout time.Duration) error { + dialer := &net.Dialer{Timeout: timeout} + conn, err := tls.DialWithDialer(dialer, "tcp", i.HostString, + &tls.Config{ + InsecureSkipVerify: true, + }) + if err != nil { + return err + } + + defer conn.Close() + + if err := conn.Handshake(); err != nil { + return err + } + + peerCerts := conn.ConnectionState().PeerCertificates + i.Certs = make([]*x509.Certificate, 0, len(peerCerts)) + logger.WithFields(log.Fields{ + "num_certs": len(peerCerts), + "host": i.HostString, + }).Info("found certs") + + for _, cert := range peerCerts { + if cert.IsCA { + continue + } + i.Certs = append(i.Certs, cert) + } + + return nil +} diff --git a/ical/ical.go b/ical/ical.go new file mode 100644 index 0000000..ddad60e --- /dev/null +++ b/ical/ical.go @@ -0,0 +1,69 @@ +package ical + +import ( + "bytes" + "crypto/sha256" + "fmt" + "text/template" + "time" +) + +const ( + icalTemplate = `BEGIN:VCALENDAR +VERSION:2.0 +METHOD:PUBLISH +PRODID:-//github.com/mrtazz/certcal//iCal cert feed//EN +{{- range .Events }} +BEGIN:VEVENT +CREATED:{{ .CreatedAt.Format "20060102T150405Z" }} +LAST-MODIFIED:{{ .LastModified.Format "20060102T150405Z" }} +DTSTAMP:{{ .DtStamp.Format "20060102T150405Z"}} +SUMMARY:{{ .Summary }} +DTSTART;VALUE=DATE:{{ .Start.Format "20060102"}} +DTEND;VALUE=DATE:{{ .End.Format "20060102"}} +URL:{{ .URL }} +DESCRIPTION:{{ .Description }} +TRANSP:TRANSPARENT +UID:{{ .UID }}@certcal.mrtazz.github.com +END:VEVENT +{{- end }} +END:VCALENDAR` +) + +// Event represents a calendar +type Event struct { + CreatedAt time.Time + LastModified time.Time + DtStamp time.Time + Summary string + Start time.Time + End time.Time + URL string + Description string + UID string +} + +// Calendar represents a calendar feed +type Calendar struct { + Events []Event +} + +// AddEvent adds an event to the calendar +func (c *Calendar) AddEvent(e Event) { + e.UID = fmt.Sprintf("%x", sha256.Sum256([]byte(e.Summary))) + c.Events = append(c.Events, e) +} + +// Render a calendar feed +func (c *Calendar) Render() (string, error) { + tmpl, err := template.New("feed").Parse(icalTemplate) + if err != nil { + return "", err + } + var tpl bytes.Buffer + err = tmpl.Execute(&tpl, c) + if err != nil { + return "", err + } + return tpl.String(), nil +} diff --git a/ical/ical_test.go b/ical/ical_test.go new file mode 100644 index 0000000..9122fef --- /dev/null +++ b/ical/ical_test.go @@ -0,0 +1,63 @@ +package ical + +import ( + "github.com/stretchr/testify/assert" + "testing" + "time" +) + +func TestRender(t *testing.T) { + + testDate := time.Date(2009, time.November, 10, 23, 0, 0, 0, time.UTC) + assert := assert.New(t) + tests := map[string]struct { + events []Event + want string + }{ + "simple": { + events: []Event{ + { + CreatedAt: testDate, + LastModified: testDate, + DtStamp: testDate, + Summary: "test event", + Start: testDate, + End: testDate, + URL: "", + Description: "description of test event", + }, + }, + want: `BEGIN:VCALENDAR +VERSION:2.0 +METHOD:PUBLISH +PRODID:-//github.com/mrtazz/certcal//iCal cert feed//EN +BEGIN:VEVENT +CREATED:20091110T230000Z +LAST-MODIFIED:20091110T230000Z +DTSTAMP:20091110T230000Z +SUMMARY:test event +DTSTART;VALUE=DATE:20091110 +DTEND;VALUE=DATE:20091110 +URL: +DESCRIPTION:description of test event +TRANSP:TRANSPARENT +UID:3f81ea40a91ac4d91eda58327fcfae58bc6b6e8535a4531bb3f129e1abe7c0bc@certcal.mrtazz.github.com +END:VEVENT +END:VCALENDAR`, + }, + } + + for name, tc := range tests { + t.Run(name, func(t *testing.T) { + + c := Calendar{} + for _, e := range tc.events { + c.AddEvent(e) + } + + out, err := c.Render() + assert.Equal(nil, err) + assert.Equal(tc.want, out) + }) + } +}