From aa537fa55a5c5a7977c1c2d9312bb01ed76b46ac Mon Sep 17 00:00:00 2001 From: Ashley Davis Date: Wed, 13 Mar 2024 17:32:10 +0000 Subject: [PATCH] Add a "guestbook" This can be signed manually or signed by the demo. Users who sign manually get a star! Currently uses a local sqlite database, but this can be replicated in future Signed-off-by: Ashley Davis --- .gitignore | 2 + certificate.html | 19 ++ cluster_issuer.yaml | 6 +- guestbook/Makefile | 15 ++ guestbook/README.md | 68 +++++++ guestbook/certificate.yaml | 28 +++ guestbook/go.mod | 21 ++ guestbook/go.sum | 39 ++++ guestbook/guestbook.service | 16 ++ guestbook/index.sh | 5 + guestbook/litestream.yml | 4 + guestbook/main.go | 335 +++++++++++++++++++++++++++++++ guestbook/root-ca.pem | 13 ++ guestbook/write.sh | 8 + main.go | 134 ++++++++++++- root-print-your-cert-ca.yaml.age | Bin 0 -> 3236 bytes setup_cluster.sh | 19 ++ 17 files changed, 723 insertions(+), 9 deletions(-) create mode 100644 guestbook/Makefile create mode 100644 guestbook/README.md create mode 100644 guestbook/certificate.yaml create mode 100644 guestbook/go.mod create mode 100644 guestbook/go.sum create mode 100644 guestbook/guestbook.service create mode 100755 guestbook/index.sh create mode 100644 guestbook/litestream.yml create mode 100644 guestbook/main.go create mode 100644 guestbook/root-ca.pem create mode 100755 guestbook/write.sh create mode 100644 root-print-your-cert-ca.yaml.age create mode 100755 setup_cluster.sh diff --git a/.gitignore b/.gitignore index 451f3be..4fc9669 100644 --- a/.gitignore +++ b/.gitignore @@ -6,3 +6,5 @@ print-your-cert-front.tar *.tar *amd64 *arm64 +guestbook/guestbook +guestbook/guestbook.sqlite diff --git a/certificate.html b/certificate.html index 73bf310..31bd08d 100644 --- a/certificate.html +++ b/certificate.html @@ -67,8 +67,27 @@

Hi, {{.Name}} <{{.Email}}>!


+ +
{{end}} +
+

+ Signing is much more fun using your certificate!
+ + The tarball download contains instructions.
+ + Signing with your cert will gain you a special badge on the guestbook!
+

+ + + +
+ +
+
diff --git a/cluster_issuer.yaml b/cluster_issuer.yaml index 0b69eb4..cd47aad 100644 --- a/cluster_issuer.yaml +++ b/cluster_issuer.yaml @@ -22,12 +22,8 @@ spec: - CNCF organizationalUnits: - cert-manager - countries: - - GB - - US + countries: # Change for the country you're issuing in! - FR - - ES - - NL duration: 438000h # 50 years. issuerRef: name: root-print-your-cert-ca-issuer diff --git a/guestbook/Makefile b/guestbook/Makefile new file mode 100644 index 0000000..fb2b69b --- /dev/null +++ b/guestbook/Makefile @@ -0,0 +1,15 @@ +MAKEFLAGS += --warn-undefined-variables --no-builtin-rules +SHELL := /usr/bin/env bash +.SHELLFLAGS := -uo pipefail -c +.DEFAULT_GOAL := help +.DELETE_ON_ERROR: +.SUFFIXES: + +.PHONY: build +build: guestbook guestbook-linux-amd64 + +guestbook: main.go + go build -o guestbook main.go + +guestbook-linux-amd64: main.go + GOOS=linux GOARCH=amd64 go build -o guestbook-linux-amd64 main.go diff --git a/guestbook/README.md b/guestbook/README.md new file mode 100644 index 0000000..6bab5f9 --- /dev/null +++ b/guestbook/README.md @@ -0,0 +1,68 @@ +# cert-manager Booth Guestbook + +## Setup + +1. Manually copied a locally built guestbook binary, litestream.yml, the systemd service, the cert, key and CA files to the remote VM. +2. Installed litestream manually, then moved litestream.yml to /etc/litestream.yml +3. Moved the systemd unit to /usr/lib/systemd/system +4. Created /var/guestbook +5. Ran the guestbook with init-db to create the db, moved it to /var/guestbook +6. Enabled litestream and the systemd unit + +## Root CA + +The root CA for the whole booth demo is below: + +```text +-----BEGIN CERTIFICATE----- +MIIB7TCCAZOgAwIBAgIRAJ0xoqVXNnNYDT5ZomjDrnAwCgYIKoZIzj0EAwIwVTEN +MAsGA1UEChMEQ05DRjEVMBMGA1UECxMMY2VydC1tYW5hZ2VyMS0wKwYDVQQDEyRU +aGUgY2VydC1tYW5hZ2VyIG1haW50YWluZXJzIFJvb3QgQ0EwIBcNMjQwMzE1MTcw +NjU5WhgPMjEyNDAyMjAxNzA2NTlaMFUxDTALBgNVBAoTBENOQ0YxFTATBgNVBAsT +DGNlcnQtbWFuYWdlcjEtMCsGA1UEAxMkVGhlIGNlcnQtbWFuYWdlciBtYWludGFp +bmVycyBSb290IENBMFkwEwYHKoZIzj0CAQYIKoZIzj0DAQcDQgAEIHB+NLHy2VDv +QyPUVY7tPlQxfQla1dAMZGpJTy/omh3KYjDAnkW3HQYLoCOunGvdueZcGj7TC/6h +uA2FJpMgqqNCMEAwDgYDVR0PAQH/BAQDAgKkMA8GA1UdEwEB/wQFMAMBAf8wHQYD +VR0OBBYEFDUCC+tbCj9UK7ucreYunGkKnwGmMAoGCCqGSM49BAMCA0gAMEUCIQDC +dHkfIZx5ZNiZ2B0bdI9BfgGb+/kQW2ZXzLwm/FP6QAIgZq2wn5fZVux8ZXF7Bx22 +ZEpst23GWAwfamTUmBLlgFA= +-----END CERTIFICATE----- +``` + +```text +Certificate: + Data: + Version: 3 (0x2) + Serial Number: + 9d:31:a2:a5:57:36:73:58:0d:3e:59:a2:68:c3:ae:70 + Signature Algorithm: ecdsa-with-SHA256 + Issuer: O=CNCF, OU=cert-manager, CN=The cert-manager maintainers Root CA + Validity + Not Before: Mar 15 17:06:59 2024 GMT + Not After : Feb 20 17:06:59 2124 GMT + Subject: O=CNCF, OU=cert-manager, CN=The cert-manager maintainers Root CA + Subject Public Key Info: + Public Key Algorithm: id-ecPublicKey + Public-Key: (256 bit) + pub: + 04:20:70:7e:34:b1:f2:d9:50:ef:43:23:d4:55:8e: + ed:3e:54:31:7d:09:5a:d5:d0:0c:64:6a:49:4f:2f: + e8:9a:1d:ca:62:30:c0:9e:45:b7:1d:06:0b:a0:23: + ae:9c:6b:dd:b9:e6:5c:1a:3e:d3:0b:fe:a1:b8:0d: + 85:26:93:20:aa + ASN1 OID: prime256v1 + NIST CURVE: P-256 + X509v3 extensions: + X509v3 Key Usage: critical + Digital Signature, Key Encipherment, Certificate Sign + X509v3 Basic Constraints: critical + CA:TRUE + X509v3 Subject Key Identifier: + 35:02:0B:EB:5B:0A:3F:54:2B:BB:9C:AD:E6:2E:9C:69:0A:9F:01:A6 + Signature Algorithm: ecdsa-with-SHA256 + Signature Value: + 30:45:02:21:00:c2:74:79:1f:21:9c:79:64:d8:99:d8:1d:1b: + 74:8f:41:7e:01:9b:fb:f9:10:5b:66:57:cc:bc:26:fc:53:fa: + 40:02:20:66:ad:b0:9f:97:d9:56:ec:7c:65:71:7b:07:1d:b6: + 64:4a:6c:b7:6d:c6:58:0c:1f:6a:64:d4:98:12:e5:80:50 +``` diff --git a/guestbook/certificate.yaml b/guestbook/certificate.yaml new file mode 100644 index 0000000..bd7009e --- /dev/null +++ b/guestbook/certificate.yaml @@ -0,0 +1,28 @@ +apiVersion: cert-manager.io/v1 +kind: Certificate +metadata: + name: guestbook-tls + namespace: cert-manager +spec: + privateKey: + algorithm: ECDSA + size: 256 + secretName: guestbook-tls + commonName: guestbook.print-your-cert.cert-manager.io + subject: + organizations: + - CNCF + organizationalUnits: + - cert-manager + countries: + - GB + - US + - FR + - ES + - NL + duration: 87600h # 10 years + dnsNames: + - guestbook.print-your-cert.cert-manager.io + issuerRef: + name: root-print-your-cert-ca-issuer + kind: Issuer diff --git a/guestbook/go.mod b/guestbook/go.mod new file mode 100644 index 0000000..439111e --- /dev/null +++ b/guestbook/go.mod @@ -0,0 +1,21 @@ +module github.com/cert-manager/print-your-cert/guestbook + +go 1.22 + +require modernc.org/sqlite v1.29.1 + +require ( + github.com/dustin/go-humanize v1.0.1 // indirect + github.com/google/uuid v1.3.0 // indirect + github.com/hashicorp/golang-lru/v2 v2.0.7 // indirect + github.com/mattn/go-isatty v0.0.16 // indirect + github.com/ncruces/go-strftime v0.1.9 // indirect + github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec // indirect + golang.org/x/sys v0.16.0 // indirect + modernc.org/gc/v3 v3.0.0-20240107210532-573471604cb6 // indirect + modernc.org/libc v1.41.0 // indirect + modernc.org/mathutil v1.6.0 // indirect + modernc.org/memory v1.7.2 // indirect + modernc.org/strutil v1.2.0 // indirect + modernc.org/token v1.1.0 // indirect +) diff --git a/guestbook/go.sum b/guestbook/go.sum new file mode 100644 index 0000000..5aba5c1 --- /dev/null +++ b/guestbook/go.sum @@ -0,0 +1,39 @@ +github.com/dustin/go-humanize v1.0.1 h1:GzkhY7T5VNhEkwH0PVJgjz+fX1rhBrR7pRT3mDkpeCY= +github.com/dustin/go-humanize v1.0.1/go.mod h1:Mu1zIs6XwVuF/gI1OepvI0qD18qycQx+mFykh5fBlto= +github.com/google/pprof v0.0.0-20221118152302-e6195bd50e26 h1:Xim43kblpZXfIBQsbuBVKCudVG457BR2GZFIz3uw3hQ= +github.com/google/pprof v0.0.0-20221118152302-e6195bd50e26/go.mod h1:dDKJzRmX4S37WGHujM7tX//fmj1uioxKzKxz3lo4HJo= +github.com/google/uuid v1.3.0 h1:t6JiXgmwXMjEs8VusXIJk2BXHsn+wx8BZdTaoZ5fu7I= +github.com/google/uuid v1.3.0/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo= +github.com/hashicorp/golang-lru/v2 v2.0.7 h1:a+bsQ5rvGLjzHuww6tVxozPZFVghXaHOwFs4luLUK2k= +github.com/hashicorp/golang-lru/v2 v2.0.7/go.mod h1:QeFd9opnmA6QUJc5vARoKUSoFhyfM2/ZepoAG6RGpeM= +github.com/mattn/go-isatty v0.0.16 h1:bq3VjFmv/sOjHtdEhmkEV4x1AJtvUvOJ2PFAZ5+peKQ= +github.com/mattn/go-isatty v0.0.16/go.mod h1:kYGgaQfpe5nmfYZH+SKPsOc2e4SrIfOl2e/yFXSvRLM= +github.com/mattn/go-sqlite3 v1.14.16 h1:yOQRA0RpS5PFz/oikGwBEqvAWhWg5ufRz4ETLjwpU1Y= +github.com/mattn/go-sqlite3 v1.14.16/go.mod h1:2eHXhiwb8IkHr+BDWZGa96P6+rkvnG63S2DGjv9HUNg= +github.com/ncruces/go-strftime v0.1.9 h1:bY0MQC28UADQmHmaF5dgpLmImcShSi2kHU9XLdhx/f4= +github.com/ncruces/go-strftime v0.1.9/go.mod h1:Fwc5htZGVVkseilnfgOVb9mKy6w1naJmn9CehxcKcls= +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/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec h1:W09IVJc94icq4NjY3clb7Lk8O1qJ8BdBEF8z0ibU0rE= +github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec/go.mod h1:qqbHyh8v60DhA7CoWK5oRCqLrMHRGoxYCSS9EjAz6Eo= +golang.org/x/mod v0.14.0 h1:dGoOF9QVLYng8IHTm7BAyWqCqSheQ5pYWGhzW00YJr0= +golang.org/x/mod v0.14.0/go.mod h1:hTbmBsO62+eylJbnUtE2MGJUyE7QWk4xUqPFrRgJ+7c= +golang.org/x/sys v0.0.0-20220811171246-fbc7d0a398ab/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= +golang.org/x/sys v0.16.0 h1:xWw16ngr6ZMtmxDyKyIgsE93KNKz5HKmMa3b8ALHidU= +golang.org/x/sys v0.16.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA= +golang.org/x/tools v0.17.0 h1:FvmRgNOcs3kOa+T20R1uhfP9F6HgG2mfxDv1vrx1Htc= +golang.org/x/tools v0.17.0/go.mod h1:xsh6VxdV005rRVaS6SSAf9oiAqljS7UZUacMZ8Bnsps= +modernc.org/gc/v3 v3.0.0-20240107210532-573471604cb6 h1:5D53IMaUuA5InSeMu9eJtlQXS2NxAhyWQvkKEgXZhHI= +modernc.org/gc/v3 v3.0.0-20240107210532-573471604cb6/go.mod h1:Qz0X07sNOR1jWYCrJMEnbW/X55x206Q7Vt4mz6/wHp4= +modernc.org/libc v1.41.0 h1:g9YAc6BkKlgORsUWj+JwqoB1wU3o4DE3bM3yvA3k+Gk= +modernc.org/libc v1.41.0/go.mod h1:w0eszPsiXoOnoMJgrXjglgLuDy/bt5RR4y3QzUUeodY= +modernc.org/mathutil v1.6.0 h1:fRe9+AmYlaej+64JsEEhoWuAYBkOtQiMEU7n/XgfYi4= +modernc.org/mathutil v1.6.0/go.mod h1:Ui5Q9q1TR2gFm0AQRqQUaBWFLAhQpCwNcuhBOSedWPo= +modernc.org/memory v1.7.2 h1:Klh90S215mmH8c9gO98QxQFsY+W451E8AnzjoE2ee1E= +modernc.org/memory v1.7.2/go.mod h1:NO4NVCQy0N7ln+T9ngWqOQfi7ley4vpwvARR+Hjw95E= +modernc.org/sqlite v1.29.1 h1:19GY2qvWB4VPw0HppFlZCPAbmxFU41r+qjKZQdQ1ryA= +modernc.org/sqlite v1.29.1/go.mod h1:hG41jCYxOAOoO6BRK66AdRlmOcDzXf7qnwlwjUIOqa0= +modernc.org/strutil v1.2.0 h1:agBi9dp1I+eOnxXeiZawM8F4LawKv4NzGWSaLfyeNZA= +modernc.org/strutil v1.2.0/go.mod h1:/mdcBmfOibveCTBxUl5B5l6W+TTH1FXPLHZE6bTosX0= +modernc.org/token v1.1.0 h1:Xl7Ap9dKaEs5kLoOQeQmPWevfnk/DM5qcLcYlA8ys6Y= +modernc.org/token v1.1.0/go.mod h1:UGzOrNV1mAFSEB63lOFHIpNRUVMvYTc6yu1SMY/XTDM= diff --git a/guestbook/guestbook.service b/guestbook/guestbook.service new file mode 100644 index 0000000..f68dfb8 --- /dev/null +++ b/guestbook/guestbook.service @@ -0,0 +1,16 @@ +[Unit] +Description=cert-manager Booth Guestbook +After=network.target + +[Service] +# TODO: Add custom user +# User=guestbook +# Group=guestbook +ExecStart=/usr/bin/guestbook -ca-cert /var/guestbook/ca.crt -tls-chain /etc/ssl/tls.chain -tls-key /etc/ssl/tls.key -db-path /var/guestbook/guestbook.sqlite +StandardOutput=journal +StandardError=journal +Type=simple +Restart=always + +[Install] +WantedBy=multi-user.target diff --git a/guestbook/index.sh b/guestbook/index.sh new file mode 100755 index 0000000..6b3c496 --- /dev/null +++ b/guestbook/index.sh @@ -0,0 +1,5 @@ +#!/usr/bin/env bash + +set -eu -o pipefail + +curl --cacert root-ca.pem --cert /tmp/chain.pem --key /tmp/pkey.pem https://guestbook.print-your-cert.cert-manager.io/ diff --git a/guestbook/litestream.yml b/guestbook/litestream.yml new file mode 100644 index 0000000..39c67bf --- /dev/null +++ b/guestbook/litestream.yml @@ -0,0 +1,4 @@ +dbs: +- path: /var/guestbook/guestbook.sqlite + replicas: + - url: gcs://cert-manager-booth-bucket/guestbook.sqlite diff --git a/guestbook/main.go b/guestbook/main.go new file mode 100644 index 0000000..9e21a48 --- /dev/null +++ b/guestbook/main.go @@ -0,0 +1,335 @@ +package main + +import ( + "bytes" + "context" + "crypto/tls" + "crypto/x509" + "database/sql" + "flag" + "fmt" + "io" + "log/slog" + "net" + "net/http" + "os" + "os/signal" + "strings" + "syscall" + "text/tabwriter" + "time" + + _ "modernc.org/sqlite" +) + +var ( + listen = flag.String("listen", "127.0.0.1:9090", "Address and port to listen on") + caCertPath = flag.String("ca-cert", "", "Path to CA certs to trust for client certs") + chainPath = flag.String("tls-chain", "", "Path to TLS cert chain") + privateKeyPath = flag.String("tls-key", "", "Path to TLS private key") + + dbPath = flag.String("db-path", "guestbook.sqlite", "Path to sqlite database") + initDB = flag.Bool("init-db", false, "If set, initialise a fresh database at db-path") +) + +func indexPage(db *sql.DB) http.Handler { + return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + if r.URL.Path != "/" { + logger := LoggerFromContext(r.Context()).With("handler", "notfound") + logger.Info("not found", "path", r.URL.Path) + w.WriteHeader(http.StatusNotFound) + _, _ = w.Write([]byte("not found")) + return + } + + logger := LoggerFromContext(r.Context()).With("handler", "index") + + // TODO: shouldn't run allMessages in handler, should cache the result and rebuild periodically + content, err := allMessages(r.Context(), db, w) + if err != nil { + logger.Error("failed to fetch from database", "error", err) + http.Error(w, "failed to fetch messages from database", http.StatusInternalServerError) + return + } + + w.WriteHeader(http.StatusOK) + _, _ = w.Write(content) + }) +} + +func allMessages(ctx context.Context, db *sql.DB, w io.Writer) ([]byte, error) { + rows, err := db.QueryContext(ctx, `SELECT email, user_agent, date, message from entries;`) + if err != nil { + return nil, err + } + + buf := &bytes.Buffer{} + tw := tabwriter.NewWriter(buf, 10, 80, 2, ' ', 0) + fmt.Fprintf(tw, "email\tmessage\tuser-agent\ttimestamp\tstar?\n") + + for rows.Next() { + var email, userAgent, date, message string + + if err := rows.Scan(&email, &userAgent, &date, &message); err != nil { + return nil, err + } + + star := "⭐" + if strings.ToLower(userAgent) == "kiosk" { + star = "❌" + } + + fmt.Fprintf(tw, "%s\t%s\t%s\t%s\t%s\n", email, message, userAgent, date, star) + } + + tw.Flush() + + return buf.Bytes(), nil +} + +func addMessage(ctx context.Context, db *sql.DB, email string, userAgent string, msg string) error { + timestamp := time.Now().Format(time.RFC3339Nano) + + _, err := db.ExecContext(ctx, `insert into entries(email, user_agent, date, message) values($1, $2, $3, $4);`, email, userAgent, timestamp, msg) + if err != nil { + return err + } + + return nil +} + +func getUserAgent(r *http.Request) string { + agent := r.Header.Get("user-agent") + if agent == "" { + return "" + } + + return agent +} + +func writePage(db *sql.DB) http.Handler { + return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + logger := LoggerFromContext(r.Context()).With("handler", "write") + err := r.ParseForm() + if err != nil { + http.Error(w, "bad form", http.StatusBadRequest) + return + } + + email := EmailFromContext(r.Context()) + userAgent := getUserAgent(r) + message := r.Form.Get("message") + + err = addMessage(r.Context(), db, email, userAgent, message) + if err != nil { + w.WriteHeader(http.StatusBadRequest) + http.Error(w, "failed to add message to database", http.StatusBadRequest) + return + } + + logger.Info("added message", "email", email, "contents", message, "user-agent", userAgent) + + w.WriteHeader(http.StatusOK) + w.Write([]byte("successfully added message")) + }) +} + +func loadCACerts(path string) (*x509.CertPool, error) { + certBytes, err := os.ReadFile(path) + if err != nil { + return nil, err + } + + certPool := x509.NewCertPool() + ok := certPool.AppendCertsFromPEM(certBytes) + if !ok { + return nil, fmt.Errorf("failed to add certs from %q to cert pool", path) + } + + return certPool, nil +} + +type contextKey int + +const ( + loggerContextKey contextKey = 0 + emailContextKey contextKey = 1 +) + +func LoggerFromContext(ctx context.Context) *slog.Logger { + cert, ok := ctx.Value(loggerContextKey).(*slog.Logger) + if !ok { + panic("LoggerFromContext called without a configured logger") + } + + return cert +} + +func EmailFromContext(ctx context.Context) string { + email, ok := ctx.Value(emailContextKey).(string) + if !ok { + panic("EmailFromContext called on context without email") + } + + return email +} + +func certExtractMiddleware(h http.Handler) http.Handler { + return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + logger := LoggerFromContext(r.Context()).With("handler", "certExtractMiddleware") + + chains := r.TLS.VerifiedChains + + if len(chains) == 0 { + w.WriteHeader(http.StatusUnauthorized) + w.Write([]byte("no client cert")) + logger.Error("failed to fetch client identity: no cert") + return + } + + if len(chains) > 1 { + logger.Warn("got more than one verified chain from client", "count", len(chains)) + } + + chain := chains[0] + + if len(chain[0].EmailAddresses) == 0 { + w.WriteHeader(http.StatusUnauthorized) + w.Write([]byte("no email in client cert")) + logger.Error("failed to fetch client identity: no email addresses") + return + } + + email := chain[0].EmailAddresses[0] + logger.Info("got request", "email", email) + + r = r.WithContext(context.WithValue(r.Context(), emailContextKey, email)) + + h.ServeHTTP(w, r) + }) +} + +func cachingHeadersMiddleware(h http.Handler) http.Handler { + return http.HandlerFunc(func(w http.ResponseWriter, req *http.Request) { + w.Header().Set("Vary", "Accept-Encoding") + w.Header().Set("Cache-Control", "public, max-age=7776000") + + h.ServeHTTP(w, req) + }) + +} + +func run(ctx context.Context) error { + if *caCertPath == "" { + return fmt.Errorf("missing required path to CA cert") + } + + logger := LoggerFromContext(ctx) + + db, err := sql.Open("sqlite", *dbPath) + if err != nil { + return err + } + + cert, err := tls.LoadX509KeyPair(*chainPath, *privateKeyPath) + if err != nil { + return err + } + + certPool, err := loadCACerts(*caCertPath) + if err != nil { + return err + } + + serveMux := http.NewServeMux() + + serveMux.Handle("GET /", certExtractMiddleware(indexPage(db))) + serveMux.Handle("POST /write", certExtractMiddleware(writePage(db))) + + server := &http.Server{ + Handler: serveMux, + BaseContext: func(_ net.Listener) context.Context { return ctx }, + ErrorLog: slog.NewLogLogger(logger.With("handler", "http.Server").Handler(), slog.LevelError), + } + + listener, err := net.Listen("tcp", *listen) + if err != nil { + return fmt.Errorf("failed to create TCP listener: %s", err) + } + + tlsConfig := &tls.Config{ + Certificates: []tls.Certificate{cert}, + ClientCAs: certPool, + ClientAuth: tls.RequireAndVerifyClientCert, + } + + listener = tls.NewListener(listener, tlsConfig) + + logger.Info("listening", "address", *listen) + + sigs := make(chan os.Signal, 1) + + signal.Notify(sigs, syscall.SIGINT) + + go func() { + err := server.Serve(listener) + if err != nil && err != http.ErrServerClosed { + logger.Info("failed to listen", "error", err) + } + }() + + <-sigs + logger.Info("shutting down") + + err = server.Shutdown(context.Background()) + if err != nil { + return err + } + + return nil +} + +func createDB(ctx context.Context, path string) error { + if path == "" { + return fmt.Errorf("missing required value: path") + } + + db, err := sql.Open("sqlite", path) + if err != nil { + return fmt.Errorf("failed to open database at %q: %w", dbPath, err) + } + + defer db.Close() + + if _, err = db.ExecContext(ctx, `create table entries(email, user_agent, date, message);`); err != nil { + return err + } + + return nil +} + +func main() { + flag.Parse() + + ctx := context.Background() + logger := slog.New(slog.NewJSONHandler(os.Stdout, nil)) + + ctx = context.WithValue(ctx, loggerContextKey, logger) + + if *initDB { + err := createDB(ctx, *dbPath) + if err != nil { + logger.Error("failed to create sqlite database", "path", *dbPath, "error", err) + os.Exit(1) + } + + logger.Info("created sqlite database", "path", *dbPath) + os.Exit(0) + } + + err := run(ctx) + if err != nil { + logger.Error("fatal error", "err", err) + os.Exit(1) + } +} diff --git a/guestbook/root-ca.pem b/guestbook/root-ca.pem new file mode 100644 index 0000000..ec43f60 --- /dev/null +++ b/guestbook/root-ca.pem @@ -0,0 +1,13 @@ +-----BEGIN CERTIFICATE----- +MIIB7TCCAZOgAwIBAgIRAJ0xoqVXNnNYDT5ZomjDrnAwCgYIKoZIzj0EAwIwVTEN +MAsGA1UEChMEQ05DRjEVMBMGA1UECxMMY2VydC1tYW5hZ2VyMS0wKwYDVQQDEyRU +aGUgY2VydC1tYW5hZ2VyIG1haW50YWluZXJzIFJvb3QgQ0EwIBcNMjQwMzE1MTcw +NjU5WhgPMjEyNDAyMjAxNzA2NTlaMFUxDTALBgNVBAoTBENOQ0YxFTATBgNVBAsT +DGNlcnQtbWFuYWdlcjEtMCsGA1UEAxMkVGhlIGNlcnQtbWFuYWdlciBtYWludGFp +bmVycyBSb290IENBMFkwEwYHKoZIzj0CAQYIKoZIzj0DAQcDQgAEIHB+NLHy2VDv +QyPUVY7tPlQxfQla1dAMZGpJTy/omh3KYjDAnkW3HQYLoCOunGvdueZcGj7TC/6h +uA2FJpMgqqNCMEAwDgYDVR0PAQH/BAQDAgKkMA8GA1UdEwEB/wQFMAMBAf8wHQYD +VR0OBBYEFDUCC+tbCj9UK7ucreYunGkKnwGmMAoGCCqGSM49BAMCA0gAMEUCIQDC +dHkfIZx5ZNiZ2B0bdI9BfgGb+/kQW2ZXzLwm/FP6QAIgZq2wn5fZVux8ZXF7Bx22 +ZEpst23GWAwfamTUmBLlgFA= +-----END CERTIFICATE----- diff --git a/guestbook/write.sh b/guestbook/write.sh new file mode 100755 index 0000000..cffa9d9 --- /dev/null +++ b/guestbook/write.sh @@ -0,0 +1,8 @@ +#!/usr/bin/env bash + +set -eu -o pipefail + +curl --cacert root-ca.pem --cert /tmp/chain.pem --key /tmp/pkey.pem https://guestbook.print-your-cert.cert-manager.io/write \ + -X POST \ + -H "Content-Type: application/x-www-form-urlencoded" \ + -d "message=hello, world" diff --git a/main.go b/main.go index e8568fc..a3dbaaa 100644 --- a/main.go +++ b/main.go @@ -10,6 +10,7 @@ import ( "crypto/rand" "crypto/rsa" "crypto/sha256" + "crypto/tls" "crypto/x509" "embed" _ "embed" @@ -19,12 +20,15 @@ import ( "flag" "fmt" "html/template" + "io" "log" "net/http" "net/mail" "net/url" + "os" "sort" "strconv" + "strings" "time" apiutil "github.com/cert-manager/cert-manager/pkg/api/util" @@ -46,6 +50,9 @@ var ( issuerKind = flag.String("issuer-kind", "Issuer", "This flag can be used to select the namespaced 'Issuer', or to select an 'external' issuer, e.g., 'AWSPCAIssuer'.") issuerGroup = flag.String("issuer-group", "cert-manager.io", "This flag allows you to give a different API group when using an 'external' issuer, e.g., 'awspca.cert-manager.io'.") inCluster = flag.Bool("in-cluster", false, "Use the in-cluster kube config to connect to Kubernetes. Use this flag when running in a pod.") + + guestbookURL = flag.String("guestbook-url", "https://guestbook.print-your-cert.cert-manager.io/write", "URL of the write path for the guestbook") + guestbookRootCAPath = flag.String("guestbook-ca", "guestbook/ca.crt", "Path to the CA certificate for the guestbook") ) const ( @@ -199,6 +206,12 @@ func landingPage(kclient kubernetes.Interface, cmclient cmversioned.Interface) f Spec: certmanagerv1.CertificateSpec{ CommonName: commonName, Duration: &metav1.Duration{Duration: 3 * 3650 * 24 * time.Hour}, // 30 years. + Subject: &certmanagerv1.X509Subject{ + // Update this with the country you're issuing in! + Countries: []string{ + "FR", + }, + }, SecretName: certName, IssuerRef: cmmetav1.ObjectReference{ Name: *issuer, @@ -310,7 +323,7 @@ func downloadCertPage(kclient kubernetes.Interface, ns string) http.Handler { certPem, ok := secret.Data["tls.crt"] if !ok { - http.Error(w, "The Secret does not contain a certificate, try again later.", 423) + w.WriteHeader(423) tmpl.ExecuteTemplate(w, "certificate.html", certificatePageData{Error: "Internal issue with the stored certificate in Kubernetes."}) log.Printf("GET /download: the requested certificate %s in namespace %s exists, but the Secret %s does not contain a key 'tls.crt'.", certName, *namespace, cert.Spec.SecretName) return @@ -344,7 +357,7 @@ func downloadPrivateKeyPage(kclient kubernetes.Interface, ns string) http.Handle keyPEM, ok := secret.Data["tls.key"] if !ok { - http.Error(w, "The Secret does not contain a private key, try again later.", 423) + w.WriteHeader(423) tmpl.ExecuteTemplate(w, "certificate.html", certificatePageData{Error: "Internal issue with the stored certificate in Kubernetes."}) log.Printf("GET /downloadpkey: the requested certificate %s in namespace %s exists, but the Secret %s does not contain a key 'tls.crt'.", certName, *namespace, cert.Spec.SecretName) return @@ -378,7 +391,7 @@ func downloadTarPage(kclient kubernetes.Interface, ns string) http.Handler { certPEM, ok := secret.Data["tls.crt"] if !ok { - http.Error(w, "The Secret does not contain a certificate, try again later.", 423) + w.WriteHeader(423) tmpl.ExecuteTemplate(w, "certificate.html", certificatePageData{Error: "Internal issue with the stored certificate in Kubernetes."}) log.Printf("GET /download: the requested certificate %s in namespace %s exists, but the Secret %s does not contain a key 'tls.crt'.", certName, *namespace, cert.Spec.SecretName) return @@ -386,7 +399,7 @@ func downloadTarPage(kclient kubernetes.Interface, ns string) http.Handler { keyPEM, ok := secret.Data["tls.key"] if !ok { - http.Error(w, "The Secret does not contain a private key, try again later.", 423) + w.WriteHeader(423) tmpl.ExecuteTemplate(w, "certificate.html", certificatePageData{Error: "Internal issue with the stored certificate in Kubernetes."}) log.Printf("GET /downloadpkey: the requested certificate %s in namespace %s exists, but the Secret %s does not contain a key 'tls.crt'.", certName, *namespace, cert.Spec.SecretName) return @@ -436,6 +449,103 @@ func downloadTarPage(kclient kubernetes.Interface, ns string) http.Handler { }) } +func signGuestbookPage(guestbookURL string, remoteRoots *x509.CertPool, kclient kubernetes.Interface, namespace string) http.Handler { + return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + if r.Method != "POST" { + http.Error(w, fmt.Sprintf("Only the GET method is supported supported on the path %s.\n", r.URL.Path), http.StatusMethodNotAllowed) + return + } + + cert := CertFromContext(r.Context()) + certName := cert.ObjectMeta.Name + fetchKey := FetchKeyFromContext(r.Context()) + + secret, err := kclient.CoreV1().Secrets(namespace).Get(r.Context(), cert.Spec.SecretName, metav1.GetOptions{}) + if err != nil { + http.Error(w, "A certificate already exists, but the secret does not exist. Try again later.", 423) + log.Printf("POST /sign-guestbook: the requested certificate %s in namespace %s exists, but the Secret %s does not.", certName, namespace, cert.Spec.SecretName) + return + } + + certPEM, ok := secret.Data["tls.crt"] + if !ok { + w.WriteHeader(423) + tmpl.ExecuteTemplate(w, "error.html", errorPageData{Error: "Internal issue with the stored certificate in Kubernetes."}) + log.Printf("POST /sign-guestbook: the requested certificate %s in namespace %s exists, but the Secret %s does not contain a key 'tls.crt'.", certName, namespace, cert.Spec.SecretName) + return + } + + keyPEM, ok := secret.Data["tls.key"] + if !ok { + w.WriteHeader(423) + tmpl.ExecuteTemplate(w, "error.html", errorPageData{Error: "Internal issue with the stored certificate in Kubernetes."}) + log.Printf("POST /sign-guestbook: the requested certificate %s in namespace %s exists, but the Secret %s does not contain a key 'tls.crt'.", certName, namespace, cert.Spec.SecretName) + return + } + + clientCertKeyPair, err := tls.X509KeyPair(certPEM, keyPEM) + if err != nil { + w.WriteHeader(500) + tmpl.ExecuteTemplate(w, "error.html", errorPageData{Error: "Internal issue with the stored certificate in Kubernetes."}) + log.Printf("POST /sign-guestbook: invalid certificate: %s", err) + return + } + + guestbookClient := &http.Client{ + Transport: &http.Transport{ + TLSClientConfig: &tls.Config{ + Certificates: []tls.Certificate{clientCertKeyPair}, + RootCAs: remoteRoots, + }, + }, + Timeout: 5 * time.Second, + } + + postValues := url.Values{} + postValues.Add("message", "hello from the cert-manager kiosk") + + req, err := http.NewRequestWithContext(r.Context(), "POST", guestbookURL, strings.NewReader(postValues.Encode())) + if err != nil { + w.WriteHeader(500) + tmpl.ExecuteTemplate(w, "error.html", errorPageData{Error: "Internal issue with creating request for guestbook"}) + log.Printf("POST /sign-guestbook: couldn't create request: %s", err) + return + } + + req.Header.Add("content-type", "application/x-www-form-urlencoded") + req.Header.Add("user-agent", "kiosk") + + guestbookResponse, err := guestbookClient.Do(req) + if err != nil { + // 503 might not be right but this is a demo, so we'll just use it unconditionally for simplicity + w.WriteHeader(503) + tmpl.ExecuteTemplate(w, "error.html", errorPageData{Error: "Internal issue with creating request for guestbook"}) + log.Printf("POST /sign-guestbook: couldn't make request: %s", err) + return + } + + defer guestbookResponse.Body.Close() + + if guestbookResponse.StatusCode != http.StatusOK { + body, err := io.ReadAll(guestbookResponse.Body) + if err != nil { + log.Println("failed to read error response body") + } else { + log.Printf("failed to sign guestbook: %s", string(body)) + } + } + + destQuery := url.Values{ + "certName": []string{certName}, + "fetchKey": []string{fetchKey}, + } + + destination := "/certificate?" + destQuery.Encode() + http.Redirect(w, r, destination, http.StatusFound) + tmpl.ExecuteTemplate(w, "landing.html", landingPageData{Error: "Redirecting..."}) + }) +} + func parseNameAndEmail(cert *certmanagerv1.Certificate) (string, string, error) { if cert == nil { return "", "", errors.New("empty cert") @@ -902,6 +1012,21 @@ func main() { log.Fatal(err) } + remoteRoots, err := x509.SystemCertPool() + if err != nil { + log.Fatalf("failed to get system cert pool: %s", err) + } + + if *guestbookRootCAPath != "" { + guestbookRoot, err := os.ReadFile(*guestbookRootCAPath) + if err != nil { + log.Fatalf("failed to read guestbook root CA from %q: %s", *guestbookRootCAPath, err) + } + + log.Printf("loaded root CA from %s", *guestbookRootCAPath) + remoteRoots.AppendCertsFromPEM(guestbookRoot) + } + http.HandleFunc("/", landingPage(kclient, cmclient)) http.HandleFunc("/list", listPage(kclient, cmclient)) @@ -910,6 +1035,7 @@ func main() { http.Handle("/downloadpkey", certFetchMiddleware(cmclient, downloadPrivateKeyPage(kclient, *namespace))) http.Handle("/cert-manager-bundle.tar", certFetchMiddleware(cmclient, downloadTarPage(kclient, *namespace))) http.Handle("/certificate", certFetchMiddleware(cmclient, certificatePage(kclient, *namespace))) + http.Handle("/sign-guestbook", certFetchMiddleware(cmclient, signGuestbookPage(*guestbookURL, remoteRoots, kclient, *namespace))) fileserver := http.StripPrefix("/", http.FileServer(http.FS(static))) http.Handle("/static/", cachingHeadersMiddleware(fileserver)) diff --git a/root-print-your-cert-ca.yaml.age b/root-print-your-cert-ca.yaml.age new file mode 100644 index 0000000000000000000000000000000000000000..5e0ccd01a8ff16c07a9bf059a5badf8ca801a89d GIT binary patch literal 3236 zcmV;V3|sSIXJsvAZewzJaCB*JZZ2s!MKW4KYHn?AMsrO;MN4&3M>BSCdU#T9Xh?ERdN)Zna%4GY z3TBZ&jQ(2RW@rf0=zGg#eqp-m| z>VS->(9qz^H-@WQuvE|1u;l^D@zDQAD~m zJ6F!XVD6CWerb(GM4+}?QHkA4AibDs(x8+4K0!(TM1R|nJXLbPuS(ZztGk2H?h(nu zU4N$x{gDqdn(J*74VsYA+JK6#;yo0t9~;kIWc#FEF-NF$h9~_Sx0;cR0zKID($dRL z*LUgHinIYY!0U{vje89U3QSgq8NtAc8!3Eu+!%1{?%gpvlYD~$4|ir@|Z*s8kM*(T@JTIi9@Z|d%-T8-ENjF98fH4om^n*vRUOISh>P??b@GA#k z9iU`v!O;JEWkHMlYHrYd_S+D;WvU7Za7cfU3J*^QXzRGvT0$w4= zy1U|j5l4docZ9_kBe<=?ym4D334OR7n(H!v7?fM2tCB&B{rt)7$B~$D+ThM8)%g!} zmRk`$@q~HDPQljVPs=RNA}nH3<|2bcI3eYX66a0+bWu3fFL1ANs?{4=*82$+!W$yk zoxWp~PRPVP?1-#fF{vPCcCyWozU}*b%W7BrpE4x%8HVX+d1=_I5}^Ac?!+FPkdjRxn3_PC;3-x#uilK2Bqqbnwdy^%NUHz10@6o$8U0EqAq zso-Z=?%6(YzsY+PAET^Wr!jiOcK&3+pXOGVGTSdQo~z3-;l_<>q(Iisvx$D(xL*E2 zBDQ=6jh`p*ztSR1)^II;eLPIeVs|WC29tE-=-vsi%plr}wkL^8g_&Kgk56kv0F{n1zJYP+FVq3<( zq-#m9;5qduu)dj+qAbPEDELYAA_HYP^&A@(9o_FYXSOm(mftU^M`OowN>2Gj4gDq4 z7eNPmBs*oym)W-m%9)HPe<2pHV0M_na6k9o1(n3Q$5`y_r-f=kqDJ!TJf){u^M*-w z1Y3RJzMA!Gs6(-4X%i5b=Y%#|Hg3=zXR;MU4#C_xWLVbu^_wU!4y*cz{RhWsys(D` z-Wc`gVskLMG&;GMT1OkK7UV`OJ~vRfU@?l1f)vRsVT8_a@+KDJ-i$M8Z*j!DnuV z!K-h&K?J=nA{+*X^TMs2xI8ho&h&WX`Hc(YJ;NEbj2;{Zn_gc;)*6$A;e(R_pF<5; zBp@CcdL@k$b?MVMTA@4^lpM&m6##Tg;!*Jv=*o7-aZhellxP!%1zUquCHD+bmk751 z{^~o%r#>rh>{DSu>3S{t@(ox=_pm{_8)FX$E5oSxrawBEJLm2N;z)5yeOYZq`J44E zWjEw0lyxF1PeKPnda`QKoF?(?&zOlJUSwMhxCI@exm%jN6B1u?`EU(1Fg3l$sG%Nl z#k&Go!8iUXjVbVMsnG^VtvZwNg4Rjc3;fpPhB`fC5B?EQ>ovrnk1$i?`!u#(@vv2~6x-6(UZiTE{aWq?C<$!YTA|A{4ru`(5=%UbhKRl~Elf+)@Ly zdR(&f*eaY~V>To|fJajRV`vWo<>z-&VDb7d4<=z2#Qp%6OQ*n1JJ5MimJ(s-Jqx{L#Smu*pD*a09X+N&uE&cCs(d z@+ef>YMrc(ZOH*$6N}P87F~@arJWh0z8aq<%CJ_f;q|zgbwX#q^O=xX=hr-C| zHss==HSpsUD1OVf1Um?_p0sDo$%pxSx}j5$e1KdVZrpr`+I0Z}J)XFmcv3cUv3cxL z?2mA93bGJ~1RP`)v&$&S6lgxuRM#PXqJ3#JlnY@geXxwal@sk`qoD^H27~hr!M(#3 zCK+(rL<%B9*9sO5y;8$E&JCoy{} z3N RYZBVfq< z!B7xokGw@;Ar3CyDG&eEZeY|(OlS-MU{6PJ$5dwDD~IhJG?CRs z+*yLRd@QW*R}fX-Vr6dWN*RC7%ELaeTY*rxM*VN;WtQrK=|qS_ns2`wFh*Ytqo+(+J2=cc4<&*~K0s z9wtda%;o~%VZNPFAmTOiVW%dGLWSE1A;h19mwx+^r+|@{ozPZ7$Pp_-VDWGX2qWOH z|7cy_nQ?1*ka6#HRf_Z?Un=ExX#>$=bBCGr{NLwT8yEK+cN_|#O`TrfS(@fRE}fLy zWJ{#cWDf-E#k{m+4AJZ__xfwg!YaPtI z#Td$Wu;9N8Z53*rM4*hx&tZ%eEmcCb@k!k=IV2s%zimrr42#e9m-=Lba3%{4$u4;b zm8$7(-s38V{8qZdO#Do#8!5|x_Bu|E>)?__ASoCriQD{^Ps!@7{;OP=hvGRMG7@wT z=+j|~xO*9EsnKZs{BL8o?7xk9+>?KE`7sg~U%B-nM3`2xZF>-0WqTWj2hH6>>OT_O z`?p*=rdd#4fz8y|4DH19=e0pluX_(jE*dui8r4P1S8#P%I?Bzx2ScR0i!vZz9(O@H z`V0msi_^_`>h8QFpu4z3raW`veJQbdiz@cHc>x6$(rK3Zj*WI2nch~+Dl7;Uw9vqF zr}1>`{OZS7kN0lLy!B)UmGne|A0H@x6KhWgo2)xp(5Rtx?@f8k9R^J3`QzRkBOTrxV!^-qz08nShS9q9;1>XRs^5#BCTDmF}xze)C zCc}scOmptWc;*P~m=H&;M-(CSXT<*=tmNsZ;e$CSnaj2FFwS2)bU%fHpFbM~) W%>7Vdckt2U%&Zfw`^M&L28-@DD>DxO literal 0 HcmV?d00001 diff --git a/setup_cluster.sh b/setup_cluster.sh new file mode 100755 index 0000000..5392111 --- /dev/null +++ b/setup_cluster.sh @@ -0,0 +1,19 @@ +#!/usr/bin/env bash + + +set -eu -o pipefail + +helm repo add jetstack https://charts.jetstack.io --force-update +helm upgrade --install cert-manager --namespace cert-manager jetstack/cert-manager --set installCRDs=true --create-namespace + +kubectl apply -f root_issuer_dev.yaml --wait +kubectl apply -f cluster_issuer.yaml --wait + +kubectl apply -f guestbook/certificate.yaml --wait + +sleep 10 + +kubectl get -n cert-manager secrets guestbook-tls -ojson | jq -r '.data."tls.crt"' | base64 -d > guestbook/tls.crt +kubectl get -n cert-manager secrets guestbook-tls -ojson | jq -r '.data."tls.key"' | base64 -d > guestbook/tls.key + +kubectl get -n cert-manager secrets root-print-your-cert-ca -ojson | jq -r '.data."tls.crt"' | base64 -d > guestbook/ca.crt