diff --git a/.gitignore b/.gitignore index cefbc48..a95c1b5 100644 --- a/.gitignore +++ b/.gitignore @@ -23,5 +23,3 @@ _testmain.go *.test *.prof -urls.csv -config.ini diff --git a/Makefile b/Makefile index a05449e..413816a 100644 --- a/Makefile +++ b/Makefile @@ -1,21 +1,21 @@ -MAINTAINER := Ad Hoc Ops -VERSION_STRING ?= $(shell git describe --tags --long --dirty --always) -BUILD_DIR := $(TMPDIR)$(APPNAME)-build APPNAME=certwatcher -.PHONY: rpm clean +.PHONY: build local test lambda clean -buildlinux: clean - mkdir -p $(BUILD_DIR) - GOOS=linux GOARCH=amd64 go build -o $(BUILD_DIR)/$(APPNAME) +build: + go get && GOOS=linux go build -o $(APPNAME) -rpm: buildlinux - cp config.ini.example $(BUILD_DIR) - fpm -n $(APPNAME) -v $(VERSION_STRING) -a all -m "$(MAINTAINER)" \ - --rpm-os linux -s dir -t rpm -f \ - -a x86_64 -p $(BUILD_DIR)/$(APPNAME)-latest.rpm \ - -C $(BUILD_DIR) \ - ./$(APPNAME)=/usr/bin/$(APPNAME) ./config.ini.example=/etc/certwatcher/config.ini.example +local: clean build test + go run main.go -f cfg_example.json + +test: clean build + terraform fmt -recursive -write=true terraform + go test + @echo " -- Tests Complete -- \n" + +lambda: clean build + GOOS=linux go build -o main + zip terraform/example/certwatcher-lambda.zip $(APPNAME) clean: - rm -f *.rpm certwatcher \ No newline at end of file + rm -f $(APPNAME) diff --git a/certwatcher b/certwatcher new file mode 100755 index 0000000..8cdbf40 Binary files /dev/null and b/certwatcher differ diff --git a/cfg_example.json b/cfg_example.json new file mode 100644 index 0000000..3d4b08d --- /dev/null +++ b/cfg_example.json @@ -0,0 +1,10 @@ +{ + "urls": [ + "www.google.com", + "adhocteam.us", + "asdadsd.zaz" + ], + "days": 30, + "verbose": false, + "topic": "" +} \ No newline at end of file diff --git a/config.ini.example b/config.ini.example deleted file mode 100644 index dc0cc47..0000000 --- a/config.ini.example +++ /dev/null @@ -1,9 +0,0 @@ -[certwatcher] -username= -password= -host= -port= -rcpt= -from=certwatcher -subjectprefix="[certwatcher]" -sendmail= \ No newline at end of file diff --git a/go.mod b/go.mod new file mode 100644 index 0000000..aeb6a49 --- /dev/null +++ b/go.mod @@ -0,0 +1,51 @@ +module github.cms.gov/CMS-WDS/iam-certwatcher-lambda + +go 1.21 + +toolchain go1.23.4 + +require ( + github.com/aws/aws-lambda-go v1.28.0 + github.com/aws/aws-sdk-go v1.55.6 + github.com/aws/aws-sdk-go-v2 v1.36.0 + github.com/aws/aws-sdk-go-v2/config v1.29.5 + github.com/aws/aws-sdk-go-v2/service/sns v1.33.18 + github.com/sirupsen/logrus v1.4.2 + gopkg.in/ini.v1 v1.67.0 +) + +require ( + github.com/BurntSushi/toml v0.3.1 // indirect + github.com/aws/aws-sdk-go-v2/credentials v1.17.58 // indirect + github.com/aws/aws-sdk-go-v2/feature/ec2/imds v1.16.27 // indirect + github.com/aws/aws-sdk-go-v2/internal/configsources v1.3.31 // indirect + github.com/aws/aws-sdk-go-v2/internal/endpoints/v2 v2.6.31 // indirect + github.com/aws/aws-sdk-go-v2/internal/ini v1.8.2 // indirect + github.com/aws/aws-sdk-go-v2/service/internal/accept-encoding v1.12.2 // indirect + github.com/aws/aws-sdk-go-v2/service/internal/presigned-url v1.12.12 // indirect + github.com/aws/aws-sdk-go-v2/service/sso v1.24.14 // indirect + github.com/aws/aws-sdk-go-v2/service/ssooidc v1.28.13 // indirect + github.com/aws/aws-sdk-go-v2/service/sts v1.33.13 // indirect + github.com/aws/smithy-go v1.22.2 // indirect + github.com/cpuguy83/go-md2man/v2 v2.0.0 // indirect + github.com/davecgh/go-spew v1.1.1 // indirect + github.com/go-sql-driver/mysql v1.5.0 // indirect + github.com/google/go-cmp v0.4.0 // indirect + github.com/jmespath/go-jmespath v0.4.0 // indirect + github.com/jmespath/go-jmespath/internal/testify v1.5.1 // indirect + github.com/konsorten/go-windows-terminal-sequences v1.0.1 // indirect + github.com/pmezard/go-difflib v1.0.0 // indirect + github.com/russross/blackfriday/v2 v2.0.1 // indirect + github.com/shurcooL/sanitized_anchor_name v1.0.0 // indirect + github.com/stretchr/objx v0.1.1 // indirect + github.com/stretchr/testify v1.6.1 // indirect + github.com/urfave/cli/v2 v2.2.0 // indirect + golang.org/x/crypto v0.0.0-20190308221718-c2843e01d9a2 // indirect + golang.org/x/net v0.0.0-20200202094626-16171245cfb2 // indirect + golang.org/x/sys v0.0.0-20190422165155-953cdadca894 // indirect + golang.org/x/text v0.3.0 // indirect + golang.org/x/xerrors v0.0.0-20191204190536-9bdfabe68543 // indirect + gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405 // indirect + gopkg.in/yaml.v2 v2.2.8 // indirect + gopkg.in/yaml.v3 v3.0.0-20200615113413-eeeca48fe776 // indirect +) diff --git a/go.sum b/go.sum new file mode 100644 index 0000000..452d8d5 --- /dev/null +++ b/go.sum @@ -0,0 +1,88 @@ +github.com/BurntSushi/toml v0.3.1/go.mod h1:xHWCNGjB5oqiDr8zfno3MHue2Ht5sIBksp03qcyfWMU= +github.com/aws/aws-lambda-go v1.13.3 h1:SuCy7H3NLyp+1Mrfp+m80jcbi9KYWAs9/BXwppwRDzY= +github.com/aws/aws-lambda-go v1.13.3/go.mod h1:4UKl9IzQMoD+QF79YdCuzCwp8VbmG4VAQwij/eHl5CU= +github.com/aws/aws-lambda-go v1.28.0 h1:fZiik1PZqW2IyAN4rj+Y0UBaO1IDFlsNo9Zz/XnArK4= +github.com/aws/aws-lambda-go v1.28.0/go.mod h1:jJmlefzPfGnckuHdXX7/80O3BvUUi12XOkbv4w9SGLU= +github.com/aws/aws-sdk-go v1.27.3 h1:CBWC7Yot0U6OU/uosUmq7tKJVBTq6HrhgW1Vjpt9SMw= +github.com/aws/aws-sdk-go v1.27.3/go.mod h1:KmX6BPdI08NWTb3/sm4ZGu5ShLoqVDhKgpiN924inxo= +github.com/aws/aws-sdk-go v1.55.6 h1:cSg4pvZ3m8dgYcgqB97MrcdjUmZ1BeMYKUxMMB89IPk= +github.com/aws/aws-sdk-go v1.55.6/go.mod h1:eRwEWoyTWFMVYVQzKMNHWP5/RV4xIUGMQfXQHfHkpNU= +github.com/aws/aws-sdk-go-v2 v0.18.0 h1:qZ+woO4SamnH/eEbjM2IDLhRNwIwND/RQyVlBLp3Jqg= +github.com/aws/aws-sdk-go-v2 v0.18.0/go.mod h1:JWVYvqSMppoMJC0x5wdwiImzgXTI9FuZwxzkQq9wy+g= +github.com/aws/aws-sdk-go-v2 v0.24.0 h1:R0lL0krk9EyTI1vmO1ycoeceGZotSzCKO51LbPGq3rU= +github.com/aws/aws-sdk-go-v2 v0.24.0/go.mod h1:2LhT7UgHOXK3UXONKI5OMgIyoQL6zTAw/jwIeX6yqzw= +github.com/aws/aws-sdk-go-v2 v1.36.0 h1:b1wM5CcE65Ujwn565qcwgtOTT1aT4ADOHHgglKjG7fk= +github.com/aws/aws-sdk-go-v2 v1.36.0/go.mod h1:5PMILGVKiW32oDzjj6RU52yrNrDPUHcbZQYr1sM7qmM= +github.com/aws/aws-sdk-go-v2/config v1.29.5 h1:4lS2IB+wwkj5J43Tq/AwvnscBerBJtQQ6YS7puzCI1k= +github.com/aws/aws-sdk-go-v2/config v1.29.5/go.mod h1:SNzldMlDVbN6nWxM7XsUiNXPSa1LWlqiXtvh/1PrJGg= +github.com/aws/aws-sdk-go-v2/credentials v1.17.58 h1:/d7FUpAPU8Lf2KUdjniQvfNdlMID0Sd9pS23FJ3SS9Y= +github.com/aws/aws-sdk-go-v2/credentials v1.17.58/go.mod h1:aVYW33Ow10CyMQGFgC0ptMRIqJWvJ4nxZb0sUiuQT/A= +github.com/aws/aws-sdk-go-v2/feature/ec2/imds v1.16.27 h1:7lOW8NUwE9UZekS1DYoiPdVAqZ6A+LheHWb+mHbNOq8= +github.com/aws/aws-sdk-go-v2/feature/ec2/imds v1.16.27/go.mod h1:w1BASFIPOPUae7AgaH4SbjNbfdkxuggLyGfNFTn8ITY= +github.com/aws/aws-sdk-go-v2/internal/configsources v1.3.31 h1:lWm9ucLSRFiI4dQQafLrEOmEDGry3Swrz0BIRdiHJqQ= +github.com/aws/aws-sdk-go-v2/internal/configsources v1.3.31/go.mod h1:Huu6GG0YTfbPphQkDSo4dEGmQRTKb9k9G7RdtyQWxuI= +github.com/aws/aws-sdk-go-v2/internal/endpoints/v2 v2.6.31 h1:ACxDklUKKXb48+eg5ROZXi1vDgfMyfIA/WyvqHcHI0o= +github.com/aws/aws-sdk-go-v2/internal/endpoints/v2 v2.6.31/go.mod h1:yadnfsDwqXeVaohbGc/RaD287PuyRw2wugkh5ZL2J6k= +github.com/aws/aws-sdk-go-v2/internal/ini v1.8.2 h1:Pg9URiobXy85kgFev3og2CuOZ8JZUBENF+dcgWBaYNk= +github.com/aws/aws-sdk-go-v2/internal/ini v1.8.2/go.mod h1:FbtygfRFze9usAadmnGJNc8KsP346kEe+y2/oyhGAGc= +github.com/aws/aws-sdk-go-v2/service/internal/accept-encoding v1.12.2 h1:D4oz8/CzT9bAEYtVhSBmFj2dNOtaHOtMKc2vHBwYizA= +github.com/aws/aws-sdk-go-v2/service/internal/accept-encoding v1.12.2/go.mod h1:Za3IHqTQ+yNcRHxu1OFucBh0ACZT4j4VQFF0BqpZcLY= +github.com/aws/aws-sdk-go-v2/service/internal/presigned-url v1.12.12 h1:O+8vD2rGjfihBewr5bT+QUfYUHIxCVgG61LHoT59shM= +github.com/aws/aws-sdk-go-v2/service/internal/presigned-url v1.12.12/go.mod h1:usVdWJaosa66NMvmCrr08NcWDBRv4E6+YFG2pUdw1Lk= +github.com/aws/aws-sdk-go-v2/service/sns v1.33.18 h1:jiLcwPNwOzhnM7sIjuz0L5C3XglgohVj0kmPzsPntyY= +github.com/aws/aws-sdk-go-v2/service/sns v1.33.18/go.mod h1:2UJVrquCqVh4UXGmRXrqFAmuAPc61ybOekjnsjdKWwY= +github.com/aws/aws-sdk-go-v2/service/sso v1.24.14 h1:c5WJ3iHz7rLIgArznb3JCSQT3uUMiz9DLZhIX+1G8ok= +github.com/aws/aws-sdk-go-v2/service/sso v1.24.14/go.mod h1:+JJQTxB6N4niArC14YNtxcQtwEqzS3o9Z32n7q33Rfs= +github.com/aws/aws-sdk-go-v2/service/ssooidc v1.28.13 h1:f1L/JtUkVODD+k1+IiSJUUv8A++2qVr+Xvb3xWXETMU= +github.com/aws/aws-sdk-go-v2/service/ssooidc v1.28.13/go.mod h1:tvqlFoja8/s0o+UruA1Nrezo/df0PzdunMDDurUfg6U= +github.com/aws/aws-sdk-go-v2/service/sts v1.33.13 h1:3LXNnmtH3TURctC23hnC0p/39Q5gre3FI7BNOiDcVWc= +github.com/aws/aws-sdk-go-v2/service/sts v1.33.13/go.mod h1:7Yn+p66q/jt38qMoVfNvjbm3D89mGBnkwDcijgtih8w= +github.com/aws/smithy-go v1.22.2 h1:6D9hW43xKFrRx/tXXfAlIZc4JI+yQe6snnWcQyxSyLQ= +github.com/aws/smithy-go v1.22.2/go.mod h1:irrKGvNn1InZwb2d7fkIRNucdfwR8R+Ts3wxYa/cJHg= +github.com/cpuguy83/go-md2man/v2 v2.0.0-20190314233015-f79a8a8ca69d/go.mod h1:maD7wRr/U5Z6m/iR4s+kqSMx2CaBsrgA7czyZG/E6dU= +github.com/cpuguy83/go-md2man/v2 v2.0.0/go.mod h1:maD7wRr/U5Z6m/iR4s+kqSMx2CaBsrgA7czyZG/E6dU= +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/go-sql-driver/mysql v1.4.0/go.mod h1:zAC/RDZ24gD3HViQzih4MyKcchzm+sOG5ZlKdlhCg5w= +github.com/go-sql-driver/mysql v1.5.0/go.mod h1:DCzpHaOWr8IXmIStZouvnhqoel9Qv2LBy8hT2VhHyBg= +github.com/golang/protobuf v1.2.0/go.mod h1:6lQm79b+lXiMfvg/cZm0SGofjICqVBUtrP5yJMmIC1U= +github.com/google/go-cmp v0.3.1/go.mod h1:8QqcDgzrUqlUb/G2PQTWiueGozuR1884gddMywk6iLU= +github.com/google/go-cmp v0.4.0/go.mod h1:v8dTdLbMG2kIc/vJvl+f65V22dbkXbowE6jgT/gNBxE= +github.com/jmespath/go-jmespath v0.0.0-20180206201540-c2b33e8439af h1:pmfjZENx5imkbgOkpRUYLnmbU7UEFbjtDA2hxJ1ichM= +github.com/jmespath/go-jmespath v0.0.0-20180206201540-c2b33e8439af/go.mod h1:Nht3zPeWKUH0NzdCt2Blrr5ys8VGpn0CEB0cQHVjt7k= +github.com/jmespath/go-jmespath v0.4.0 h1:BEgLn5cpjn8UN1mAw4NjwDrS35OdebyEtFe+9YPoQUg= +github.com/jmespath/go-jmespath v0.4.0/go.mod h1:T8mJZnbsbmF+m6zOOFylbeCJqk5+pHWvzYPziyZiYoo= +github.com/jmespath/go-jmespath/internal/testify v1.5.1/go.mod h1:L3OGu8Wl2/fWfCI6z80xFu9LTZmf1ZRjMHUOPmWr69U= +github.com/konsorten/go-windows-terminal-sequences v1.0.1 h1:mweAR1A6xJ3oS2pRaGiHgQ4OO8tzTaLawm8vnODuwDk= +github.com/konsorten/go-windows-terminal-sequences v1.0.1/go.mod h1:T0+1ngSBFLxvqU3pZ+m/2kptfBszLMUkC4ZK/EgS/cQ= +github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4= +github.com/russross/blackfriday/v2 v2.0.1/go.mod h1:+Rmxgy9KzJVeS9/2gXHxylqXiyQDYRxCVz55jmeOWTM= +github.com/shurcooL/sanitized_anchor_name v1.0.0/go.mod h1:1NzhyTcUVG4SuEtjjoZeVRXNmyL/1OwPU0+IJeTBvfc= +github.com/sirupsen/logrus v1.4.2 h1:SPIRibHv4MatM3XXNO2BJeFLZwZ2LvZgfQ5+UNI2im4= +github.com/sirupsen/logrus v1.4.2/go.mod h1:tLMulIdttU9McNUspp0xgXVQah82FyeX6MwdIuYE2rE= +github.com/stretchr/objx v0.1.0/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME= +github.com/stretchr/objx v0.1.1/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME= +github.com/stretchr/testify v1.2.2/go.mod h1:a8OnRcib4nhh0OaRAV+Yts87kKdq0PP7pXfy6kDkUVs= +github.com/stretchr/testify v1.4.0/go.mod h1:j7eGeouHqKxXV5pUuKE4zz7dFj8WfuZ+81PSLYec5m4= +github.com/stretchr/testify v1.6.1/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg= +github.com/urfave/cli v1.22.1/go.mod h1:Gos4lmkARVdJ6EkW0WaNv/tZAAMe9V7XWyB60NtXRu0= +github.com/urfave/cli/v2 v2.2.0/go.mod h1:SE9GqnLQmjVa0iPEY0f1w3ygNIYcIJ0OKPMoW2caLfQ= +golang.org/x/crypto v0.0.0-20190308221718-c2843e01d9a2/go.mod h1:djNgcEr1/C05ACkg1iLfiJU5Ep61QUkGW8qpdssI0+w= +golang.org/x/net v0.0.0-20180724234803-3673e40ba225/go.mod h1:mL1N/T3taQHkDXs73rZJwtUhF3w3ftmwwsq0BUmARs4= +golang.org/x/net v0.0.0-20181201002055-351d144fa1fc h1:a3CU5tJYVj92DY2LaA1kUkrsqD5/3mLDhx2NcNqyW+0= +golang.org/x/net v0.0.0-20181201002055-351d144fa1fc/go.mod h1:mL1N/T3taQHkDXs73rZJwtUhF3w3ftmwwsq0BUmARs4= +golang.org/x/net v0.0.0-20200202094626-16171245cfb2/go.mod h1:z5CRVTTTmAJ677TzLLGU+0bjPO0LkuOLi4/5GtJWs/s= +golang.org/x/sys v0.0.0-20190215142949-d0b11bdaac8a/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY= +golang.org/x/sys v0.0.0-20190422165155-953cdadca894 h1:Cz4ceDQGXuKRnVBDTS23GTn/pU5OE2C0WrNTOYK1Uuc= +golang.org/x/sys v0.0.0-20190422165155-953cdadca894/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= +golang.org/x/text v0.3.0 h1:g61tztE5qeGQ89tm6NTjjM9VPIm088od1l6aSorWRWg= +golang.org/x/text v0.3.0/go.mod h1:NqM8EUOU14njkJ3fqMW+pc6Ldnwhi/IjpwHt7yyuwOQ= +golang.org/x/xerrors v0.0.0-20191204190536-9bdfabe68543/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0= +google.golang.org/appengine v1.2.0/go.mod h1:xpcJRLb0r/rnEns0DIKYYv+WjYCduHsrkT7/EB5XEv4= +gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= +gopkg.in/ini.v1 v1.67.0 h1:Dgnx+6+nfE+IfzjUEISNeydPJh9AXNNsWbGP9KzCsOA= +gopkg.in/ini.v1 v1.67.0/go.mod h1:pNLf8WUiyNEtQjuu5G5vTm06TEv9tsIgeAvK8hOrP4k= +gopkg.in/yaml.v2 v2.2.2/go.mod h1:hI93XBmqTisBFMUTm0b8Fm+jr3Dg1NNxqwp+5A1VGuI= +gopkg.in/yaml.v2 v2.2.8/go.mod h1:hI93XBmqTisBFMUTm0b8Fm+jr3Dg1NNxqwp+5A1VGuI= +gopkg.in/yaml.v3 v3.0.0-20200313102051-9f266ea9e77c/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= +gopkg.in/yaml.v3 v3.0.0-20200615113413-eeeca48fe776/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= diff --git a/main.go b/main.go index af51b90..7f2fca4 100644 --- a/main.go +++ b/main.go @@ -1,20 +1,23 @@ package main import ( + "context" "crypto/tls" - "encoding/csv" + "encoding/json" "errors" "flag" "fmt" - "log" + "io" "net" - "net/smtp" "os" - "strings" "sync" "time" - ini "gopkg.in/ini.v1" + "github.com/aws/aws-lambda-go/lambda" + "github.com/aws/aws-sdk-go-v2/config" + "github.com/aws/aws-sdk-go-v2/service/sns" + "github.com/aws/aws-sdk-go/aws" + log "github.com/sirupsen/logrus" ) var ( @@ -22,61 +25,64 @@ var ( errExpired = errors.New("expired") ) +type Config struct { + URLs []string `json:"urls"` + Days int `json:"days"` + Verbose bool `json:"verbose"` + Topic string `json:"topic"` +} + func main() { - urlFile := flag.String("urls", "/etc/certwatcher/urls.csv", "path to CSV containing list of URLs to monitor") - iniFile := flag.String("config", "/etc/certwatcher/config.ini", "path to config.ini") - days := flag.Int("days", 30, "number of days before triggering alert") - verbose := flag.Bool("v", false, "verbose output") + cfgPath := flag.String("f", "", "path to json cfg. this must be passed if running locally or not via lambda") flag.Parse() + if len(*cfgPath) != 0 { + cfg := parseCfg(*cfgPath) + checkCerts(cfg) + } else { + lambda.Start(handle) + } +} - // read config - cfg, err := ini.Load(*iniFile) - if err != nil { - log.Fatalf("could not open config file: %s", err) +func handle(ctx context.Context, event json.RawMessage) { + + var cfg Config + if err := json.Unmarshal(event, &cfg); err != nil { + log.Fatalf("Failed to unmarshal event: %v", err) } + failures := checkCerts(cfg) + notify(ctx, failures, cfg.Topic) +} - // load list of hosts to watch - f, err := os.Open(*urlFile) - if err != nil { - log.Fatalf("could not open URL file: %s", err) +func checkCerts(cfg Config) []string { + + if cfg.Verbose { + log.SetLevel(log.DebugLevel) } - rdr := csv.NewReader(f) - rdr.FieldsPerRecord = 2 - rdr.Comment = '#' - records, err := rdr.ReadAll() - if err != nil { - log.Fatalf("could not read %s: %s", *urlFile, err) + if len(cfg.URLs) == 0 { + log.Fatalf("No URLs found in %v", cfg) } + var failures []string var wg sync.WaitGroup - for _, r := range records { - host, desc := r[0], r[1] - if desc == "" { - desc = host - } - + for _, url := range cfg.URLs { wg.Add(1) - go func() { defer wg.Done() - if err := check(host, "443", *days, *verbose); err != nil { - switch err { - case errExpiringSoon, errExpired: - notify(host, desc, cfg, *days, err, *verbose) - log.Printf("main: sent notification for host %s - %s", host, err) - default: - log.Printf("main: ERROR: unexpected error checking host %s - %s", host, err) - } + if err := check(url, "443", cfg.Days); err != nil { + msg := fmt.Sprintf("failed host check %s - %s", url, err) + log.Errorf(msg) + failures = append(failures, msg) } }() } wg.Wait() + return failures } -func check(host, port string, days int, verbose bool) error { +func check(host, port string, days int) error { dialer := &net.Dialer{Timeout: 5 * time.Second} conn, err := tls.DialWithDialer(dialer, "tcp", host+":"+port, &tls.Config{ InsecureSkipVerify: true, @@ -96,12 +102,10 @@ func check(host, port string, days int, verbose bool) error { continue } - if verbose { - log.Printf("check: %s certificate %d: expires after %s (%s)", host, i, cert.NotAfter, time.Until(cert.NotAfter)) - log.Printf("check: %s certificate %d: issuer: %s", host, i, cert.Issuer.Names) - log.Printf("check: %s certificate %d: names: %s", host, i, cert.Subject.Names) - log.Printf("check: %s certificate %d: DNSNames: %s", host, i, cert.DNSNames) - } + log.Debugf("check: %s certificate %d: expires after %s (%s)", host, i, cert.NotAfter, time.Until(cert.NotAfter)) + log.Debugf("check: %s certificate %d: issuer: %s", host, i, cert.Issuer.Names) + log.Debugf("check: %s certificate %d: names: %s", host, i, cert.Subject.Names) + log.Debugf("check: %s certificate %d: DNSNames: %s", host, i, cert.DNSNames) if time.Now().After(cert.NotAfter) { return errExpired @@ -112,63 +116,38 @@ func check(host, port string, days int, verbose bool) error { } } - log.Printf("check: %s - certificate is ok", host) + log.Infof("check: %s - certificate is ok", host) return nil } -func notify(host, desc string, cfg *ini.File, days int, err error, verbose bool) { - section := cfg.Section("certwatcher") +func notify(ctx context.Context, failures []string, topicArn string) { - if !section.Key("sendmail").MustBool() { - log.Println("notify: refusing to send email due to config.") + awsConfig, err := config.LoadDefaultConfig(ctx) + if err != nil { + fmt.Println("Couldn't load default configuration. Have you set up your AWS account?") + fmt.Println(err) return } - - port := "587" - if section.Key("port").String() != "" { - port = section.Key("port").String() - } - mailhost := section.Key("host").String() - - auth := smtp.PlainAuth("", - section.Key("username").String(), - section.Key("password").String(), - mailhost, - ) - - to := []string{section.Key("rcpt").String()} - var subject, body string - if err == errExpiringSoon { - subject = fmt.Sprintf("Subject: %s certificate expiring soon: %s", section.Key("subjectprefix").String(), desc) - body = fmt.Sprintf("The SSL certificate for the host %s (%s) is expiring in less than %d days.", host, desc, days) - } else if err == errExpired { - subject = fmt.Sprintf("Subject: %s certificate has expired! %s", section.Key("subjectprefix").String(), desc) - body = fmt.Sprintf("The SSL certificate for the host %s (%s) has expired!", host, desc) - } - msg := []byte(strings.Join([]string{subject, - fmt.Sprintf("To: %s", strings.Join(to, ", ")), - fmt.Sprintf("From: %s", section.Key("from").String()), - "", - body, - "", - "Please take appropriate action!", - }, - "\r\n", - )) - - if verbose { - log.Printf("notify: sending host %s expiration notification to %s", host, section.Key("rcpt").String()) + client := sns.NewFromConfig(awsConfig) + for _, msg := range failures { + publishInput := sns.PublishInput{TopicArn: aws.String(topicArn), Message: aws.String(msg)} + _, err := client.Publish(ctx, &publishInput) + if err != nil { + log.Fatalf("Couldn't publish message to topic %v. %v", topicArn, err) + } + log.Infof("SNS notification sent: %s -> %s", msg, topicArn) } +} - errc := make(chan error, 1) - go func() { - errc <- smtp.SendMail(mailhost+":"+port, auth, section.Key("from").String(), to, msg) - }() - select { - case err := <-errc: - log.Fatalf("could not send email: %s", err) - case <-time.After(30 * time.Second): - log.Fatalf("Timeout reaching mail server") +func parseCfg(cfgPath string) Config { + jsonFile, err := os.Open(cfgPath) + if err != nil { + fmt.Println(err) } + defer jsonFile.Close() + byteValue, _ := io.ReadAll(jsonFile) + var cfg Config + json.Unmarshal(byteValue, &cfg) + return cfg } diff --git a/main_test.go b/main_test.go index 752782b..132b266 100644 --- a/main_test.go +++ b/main_test.go @@ -63,7 +63,7 @@ func TestCheck(t *testing.T) { t.Fatalf("error getting port of test TLS server: %v", err) } - if err := check("127.0.0.1", port, test.checkDaysExpiringWithin, false); err != test.err { + if err := check("127.0.0.1", port, test.checkDaysExpiringWithin); err != test.err { t.Errorf("%d: want %v, got %v", i, test.err, err) } s.Close() diff --git a/terraform/certwatcher-lambda/main.tf b/terraform/certwatcher-lambda/main.tf new file mode 100644 index 0000000..eaecc40 --- /dev/null +++ b/terraform/certwatcher-lambda/main.tf @@ -0,0 +1,104 @@ + +# upload lambda zip to S3 +resource "aws_s3_bucket_object" "lambda-storage" { + bucket = var.s3_bucket + key = var.s3_object + source = var.lambda_local_path + etag = filemd5(var.lambda_local_path) +} + +# lambda function +resource "aws_lambda_function" "certwatcher" { + function_name = "certwatcher" + handler = "main" + role = aws_iam_role.certwatcher-role.arn + description = "Checks certificate expiration" + runtime = "go1.x" + memory_size = "128" + timeout = "5" + s3_bucket = var.s3_bucket + s3_key = var.s3_object +} + +# lambda role / policy +resource "aws_iam_role_policy_attachment" "certwatcher" { + role = aws_iam_role.certwatcher-role.name + policy_arn = aws_iam_policy.policy.arn +} + +resource "aws_iam_role" "certwatcher-role" { + name_prefix = "certwatcher-role-" + assume_role_policy = jsonencode( + { + "Version" : "2012-10-17", + "Statement" : [ + { + "Action" : "sts:AssumeRole", + "Principal" : { + "Service" : "lambda.amazonaws.com" + }, + "Effect" : "Allow", + "Sid" : "" + } + ] + }) +} + +resource "aws_iam_policy" "lambda-policy" { + name = "certwatcher-exec" + path = "/" + description = "Policy to allow certwatcher to " + policy = jsonencode( + { + "Version" : "2012-10-17", + "Statement" : [ + { + "Sid" : "", + "Effect" : "Allow", + "Action" : [ + "sns:Publish" + ], + "Resource" : "*" + } + ] + }) +} + +# associate AWS's default Lambda role +resource "aws_iam_role_policy_attachment" "default-lambda" { + role = aws_iam_role.certwatcher_role.name + policy_arn = "arn:aws:iam::aws:policy/service-role/AWSLambdaBasicExecutionRole" +} + +resource "aws_cloudwatch_log_group" "logs" { + name = "/aws/lambda/${aws_lambda_function.certwatcher.function_name}" + retention_in_days = var.log_retention +} + +# sns topic for notifications +resource "aws_sns_topic" "certwatcher" { + name = "certwatcher" + display_name = "certwatcher" + kms_master_key_id = "alias/aws/sns" +} + +# cloudwatch rule to trigger the lambda +resource "aws_cloudwatch_event_rule" "event-rule" { + name_prefix = "scheduled-certwatcher" + description = "Invoke the certwatcher lambda" + schedule_expression = var.interval +} + +resource "aws_lambda_permission" "allow-cloudwatch-exec" { + statement_id_prefix = "AllowCloudWatchExecution-" + action = "lambda:InvokeFunction" + principal = "events.amazonaws.com" + function_name = aws_lambda_function.certwatcher.name + source_arn = aws_cloudwatch_event_rule.event-rule.arn +} + +resource "aws_cloudwatch_event_target" "lambda-target" { + rule = aws_cloudwatch_event_rule.event-rule.id + arn = aws_lambda_function.certwatcher.arn + input = jsonencode(merge(cfg, { "topic" : aws_sns_topic.certwatcher.arn })) +} diff --git a/terraform/certwatcher-lambda/outputs.tf b/terraform/certwatcher-lambda/outputs.tf new file mode 100644 index 0000000..e69de29 diff --git a/terraform/certwatcher-lambda/variables.tf b/terraform/certwatcher-lambda/variables.tf new file mode 100644 index 0000000..5714212 --- /dev/null +++ b/terraform/certwatcher-lambda/variables.tf @@ -0,0 +1,38 @@ +variable "s3_bucket" { + description = "S3 bucket that holds the certwatcher application code" +} + +variable "s3_object" { + default = "certwatcher-lambda.zip" +} + +variable "cfg" { + description = "variables to be passed into the lambda function" + type = object({ + urls = string + days = number + verbose = bool + }) + default = { + urls = [] + days = 30 + verbose = false + } +} + +variable "lambda_local_path" { + description = "path to zipped lambda function on local filesystem" + default = "certwatcher-lambda.zip" +} + +variable "interval" { + description = "how often to invoke the function. ex rate(1 day) or cron (* 5 * * *)" + default = "1 day" +} + +variable "log_retention" { + description = "how long to retain lambda execution log data in cloudwatch logs" + default = 30 +} + + diff --git a/terraform/example/main.tf b/terraform/example/main.tf new file mode 100644 index 0000000..cb233b6 --- /dev/null +++ b/terraform/example/main.tf @@ -0,0 +1,9 @@ +module "certwatcher" { + s3_bucket = "lambda-storage" + cfg = { + urls = [] + days = 30 + verbose = false + } +} +