Add frontend to certmon

This commit is contained in:
akp 2024-03-19 15:56:41 +00:00
parent 5beb5ceb44
commit 8222543223
No known key found for this signature in database
GPG key ID: CF8D58F3DEB20755
10 changed files with 265 additions and 17 deletions

7
go.mod
View file

@ -5,10 +5,12 @@ go 1.21.4
require (
git.tdpain.net/codemicro/kindle-dashboard v0.1.2
git.tdpain.net/pkg/cfger v0.1.0
github.com/a-h/templ v0.2.639
github.com/carlmjohnson/requests v0.23.5
github.com/go-playground/validator v9.31.0+incompatible
github.com/jakobvarmose/go-qidenticon v0.0.0-20170128000056-5c327fb4e74a
github.com/jmoiron/sqlx v1.3.5
github.com/jordan-wright/email v4.0.1-0.20210109023952-943e75fe5223+incompatible
github.com/julienschmidt/httprouter v1.3.0
github.com/maragudk/gomponents v0.20.2
github.com/mattn/go-sqlite3 v1.14.22
@ -33,13 +35,12 @@ require (
github.com/golang/freetype v0.0.0-20170609003504-e2365dfdc4a0 // indirect
github.com/golang/protobuf v1.5.3 // indirect
github.com/gorilla/css v1.0.0 // indirect
github.com/jordan-wright/email v4.0.1-0.20210109023952-943e75fe5223+incompatible // indirect
github.com/leodido/go-urn v1.2.1 // indirect
github.com/tdewolff/canvas v0.0.0-20231102134958-6de43c767dbf // indirect
github.com/tdewolff/minify/v2 v2.20.5 // indirect
github.com/tdewolff/parse/v2 v2.7.3 // indirect
golang.org/x/net v0.17.0 // indirect
golang.org/x/text v0.13.0 // indirect
golang.org/x/net v0.19.0 // indirect
golang.org/x/text v0.14.0 // indirect
google.golang.org/appengine v1.6.7 // indirect
google.golang.org/protobuf v1.31.0 // indirect
gopkg.in/go-playground/assert.v1 v1.2.1 // indirect

16
go.sum
View file

@ -6,6 +6,8 @@ git.tdpain.net/pkg/cfger v0.1.0 h1:Yhs2DaFIdbcSrbyywhsoxrHPevDEEBEKbqJqrUa3eso=
git.tdpain.net/pkg/cfger v0.1.0/go.mod h1:Kq5/hsUnYSYM2BVGFtXMlYEDIsIYiZTz4MUIlqZeX0k=
github.com/ByteArena/poly2tri-go v0.0.0-20170716161910-d102ad91854f h1:l7moT9o/v/9acCWA64Yz/HDLqjcRTvc0noQACi4MsJw=
github.com/ByteArena/poly2tri-go v0.0.0-20170716161910-d102ad91854f/go.mod h1:vIOkSdX3NDCPwgu8FIuTat2zDF0FPXXQ0RYFRy+oQic=
github.com/a-h/templ v0.2.639 h1:iNyjh6gllEshVDcj3taqtz7dltPKBtncvP+M8HNGdGQ=
github.com/a-h/templ v0.2.639/go.mod h1:SA7mtYwVEajbIXFRh3vKdYm/4FYyLQAtPH1+KxzGPA8=
github.com/ajstarks/svgo v0.0.0-20211024235047-1546f124cd8b h1:slYM766cy2nI3BwyRiyQj/Ud48djTMtMebDqepE95rw=
github.com/ajstarks/svgo v0.0.0-20211024235047-1546f124cd8b/go.mod h1:1KcenG0jGWcpt8ov532z81sp/kMMUG485J2InIOyADM=
github.com/aymerick/douceur v0.2.0 h1:Mv+mAeH1Q+n9Fr+oyamOlAkUNPWPlA8PPGR0QAaYuPk=
@ -57,8 +59,8 @@ github.com/golang/protobuf v1.5.0/go.mod h1:FsONVRAS9T7sI+LIUmWTfcYkHO4aIWwzhcaS
github.com/golang/protobuf v1.5.3 h1:KhyjKVUg7Usr/dYsdSqoFveMYd5ko72D+zANwlG1mmg=
github.com/golang/protobuf v1.5.3/go.mod h1:XVQd3VNwM+JqD3oG2Ue2ip4fOMUkwXdXDdiuN0vRsmY=
github.com/google/go-cmp v0.5.5/go.mod h1:v8dTdLbMG2kIc/vJvl+f65V22dbkXbowE6jgT/gNBxE=
github.com/google/go-cmp v0.5.9 h1:O2Tfq5qg4qc4AmwVlvv0oLiVAGB7enBSJ2x2DqQFi38=
github.com/google/go-cmp v0.5.9/go.mod h1:17dUlkBOakJ0+DkrSSNjCkIjxS6bF9zb3elmeNGIjoY=
github.com/google/go-cmp v0.6.0 h1:ofyhxvXcZhMsU5ulbFiLKl/XBFqE1GSq7atu8tAmTRI=
github.com/google/go-cmp v0.6.0/go.mod h1:17dUlkBOakJ0+DkrSSNjCkIjxS6bF9zb3elmeNGIjoY=
github.com/gorilla/css v1.0.0 h1:BQqNyPTi50JCFMTw/b67hByjMVXZRwGha6wxVGkeihY=
github.com/gorilla/css v1.0.0/go.mod h1:Dn721qIggHpt4+EFCcTLTU/vk5ySda2ReITrtgBl60c=
github.com/jakobvarmose/go-qidenticon v0.0.0-20170128000056-5c327fb4e74a h1:1aXp5vaXeDYGVzOx20czCIsrjvLX+n+2OIChSS3FN7A=
@ -75,8 +77,6 @@ github.com/leodido/go-urn v1.2.1 h1:BqpAaACuzVSgi/VLzGZIobT2z4v53pjosyNd9Yv6n/w=
github.com/leodido/go-urn v1.2.1/go.mod h1:zt4jvISO2HfUBqxjfIshjdMTYS56ZS/qv49ictyFfxY=
github.com/lib/pq v1.2.0 h1:LXpIM/LZ5xGFhOpXAQUIMM1HdyqzVYM13zNdjCEEcA0=
github.com/lib/pq v1.2.0/go.mod h1:5WUZQaWbwv1U+lTReE5YruASi9Al49XbQIvNi/34Woo=
github.com/maragudk/gomponents v0.20.1 h1:TeJY1fXEcfUvzmvjeUgxol42dvkYMggK1c0V67crWWs=
github.com/maragudk/gomponents v0.20.1/go.mod h1:nHkNnZL6ODgMBeJhrZjkMHVvNdoYsfmpKB2/hjdQ0Hg=
github.com/maragudk/gomponents v0.20.2 h1:39FhnBNNCJzqNcD9Hmvp/5xj0otweFoyvVgFG6kXoy0=
github.com/maragudk/gomponents v0.20.2/go.mod h1:nHkNnZL6ODgMBeJhrZjkMHVvNdoYsfmpKB2/hjdQ0Hg=
github.com/mattn/go-sqlite3 v1.14.6/go.mod h1:NyWgC/yNuGj7Q9rpYnZvas74GogHl5/Z4A/KQRfk6bU=
@ -109,8 +109,8 @@ golang.org/x/image v0.13.0 h1:3cge/F/QTkNLauhf2QoE9zp+7sr+ZcL4HnoZmdwg9sg=
golang.org/x/image v0.13.0/go.mod h1:6mmbMOeV28HuMTgA6OSRkdXKYw/t5W9Uwn2Yv1r3Yxk=
golang.org/x/net v0.0.0-20190603091049-60506f45cf65/go.mod h1:HSz+uSET+XFnRR8LxR5pz3Of3rY3CfYBVs4xY44aLks=
golang.org/x/net v0.0.0-20210510120150-4163338589ed/go.mod h1:9nx3DQGgdP8bBQD5qxJ1jj9UTztislL4KSBs9R2vV5Y=
golang.org/x/net v0.17.0 h1:pVaXccu2ozPjCXewfr1S7xza/zcXTity9cCdXQYSjIM=
golang.org/x/net v0.17.0/go.mod h1:NxSsAGuq816PNPmqtQdLE42eU2Fs7NoRIZrHJAlaCOE=
golang.org/x/net v0.19.0 h1:zTwKpTd2XuCqf8huc7Fo2iSy+4RHPd10s4KzeTnVr1c=
golang.org/x/net v0.19.0/go.mod h1:CfAk/cbD4CthTvqiEl8NpboMuiuOYsAr/7NOjZJtv1U=
golang.org/x/oauth2 v0.13.0 h1:jDDenyj+WgFtmV3zYVoi8aE2BwtXFLWOA67ZfNWftiY=
golang.org/x/oauth2 v0.13.0/go.mod h1:/JMhi4ZRXAf4HG9LiNmxvk+45+96RUlVThiH8FzNBn0=
golang.org/x/sys v0.0.0-20190215142949-d0b11bdaac8a/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY=
@ -121,8 +121,8 @@ golang.org/x/text v0.3.0/go.mod h1:NqM8EUOU14njkJ3fqMW+pc6Ldnwhi/IjpwHt7yyuwOQ=
golang.org/x/text v0.3.2/go.mod h1:bEr9sfX3Q8Zfm5fL9x+3itogRgK3+ptLWKqgva+5dAk=
golang.org/x/text v0.3.6/go.mod h1:5Zoc/QRtKVWzQhOtBMvqHzDpF6irO9z98xDceosuGiQ=
golang.org/x/text v0.3.7/go.mod h1:u+2+/6zg+i71rQMx5EYifcz6MCKuco9NR6JIITiCfzQ=
golang.org/x/text v0.13.0 h1:ablQoSUd0tRdKxZewP80B+BaqeKJuVhuRxj/dkrun3k=
golang.org/x/text v0.13.0/go.mod h1:TvPlkZtksWOMsz7fbANvkp4WM8x/WCo/om8BMLbz+aE=
golang.org/x/text v0.14.0 h1:ScX5w1eTa3QqT8oi6+ziP7dTV1S2+ALU0bI+0zXKWiQ=
golang.org/x/text v0.14.0/go.mod h1:18ZOQIKpY8NJVqYksKHtTdi31H5itFRjB5/qKTNYzSU=
golang.org/x/tools v0.0.0-20180917221912-90fa682c2a6e/go.mod h1:n7NCudcB/nEzxVGmLbDWY5pfWTLqBcC2KZ6jyYvM4mQ=
golang.org/x/xerrors v0.0.0-20191204190536-9bdfabe68543/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0=
gonum.org/v1/plot v0.14.0 h1:+LBDVFYwFe4LHhdP8coW6296MBEY4nQ+Y4vuUpJopcE=

View file

@ -7,8 +7,11 @@ import (
"github.com/codemicro/platform/platform"
"github.com/codemicro/platform/platform/storage"
"github.com/codemicro/platform/platform/util"
"github.com/codemicro/platform/platform/util/htmlutil"
"github.com/jordan-wright/email"
"github.com/julienschmidt/httprouter"
"html/template"
"net/http"
"net/smtp"
"strings"
"time"
@ -23,11 +26,31 @@ var (
)
func init() {
router.GET("/", util.WrapHandler(func(rw http.ResponseWriter, rq *http.Request, _ httprouter.Params) error {
certs, err := ((*certificate)(nil)).List(db)
if err != nil {
return fmt.Errorf("list certificates: %w", err)
}
activeCerts, err := renderActiveCertificatesView(certs)
if err != nil {
return err
}
return htmlutil.GovUKDesignBasePage(
rw,
"Certificate Monitor",
template.HTML(activeCerts),
)
}))
platform.RegisterProvider(moduleName, router)
if err := setupDB(); err != nil {
panic(err)
}
fmt.Println(monitorJob())
}
func monitorJob() error {

View file

@ -60,7 +60,8 @@ type certificate struct {
URL string `db:"url"`
Type certificateType `db:"type"`
Cert *x509.Certificate `db:"-"`
Domains []string `db:"-"`
Cert *x509.Certificate `db:"-"`
}
func (cert *certificate) Insert(dbe sqlx.Ext) error {
@ -80,7 +81,53 @@ func (cert *certificate) Get(dbe sqlx.Queryer) error {
)
}
func (cert *certificate) List(dbe sqlx.Queryer) ([]*certificate, error) {
type syntheticResult struct {
Fingerprint string `db:"fingerprint"`
SerialNumber string `db:"serial"`
NotBefore time.Time `db:"not_before"`
NotAfter time.Time `db:"not_after"`
URL string `db:"url"`
Type certificateType `db:"type"`
DNSName string `db:"dns_name"`
}
var res []*syntheticResult
err := sqlx.Select(
dbe,
&res,
`SELECT dns_names.dns_name, certificates.fingerprint, serial, not_before, not_after, type, url FROM dns_names JOIN certificates ON dns_names.fingerprint = certificates.fingerprint;`,
)
if err != nil {
return nil, fmt.Errorf("list all certificates: %w", err)
}
var mappedRes []*certificate
for _, item := range res {
mappedRes = append(mappedRes, &certificate{
Fingerprint: item.Fingerprint,
SerialNumber: item.SerialNumber,
NotBefore: item.NotBefore,
NotAfter: item.NotAfter,
URL: item.URL,
Type: item.Type,
Domains: []string{item.DNSName},
})
}
return mappedRes, nil
}
func (cert *certificate) getAssociatedDomains() []string {
if cert.Cert == nil {
if len(cert.Domains) != 0 {
return cert.Domains
}
panic("nil cert") // spicy!
}
// What we should do here is slightly unclear - RFC5280 says we should consider both the common name and DNS
// alternative names but RFC6125 says we should only consider the common name if there are no DNS names. The
// latter is new enough that some CAs don't abide by it and this is so low stakes that I'm just going to stick
@ -104,14 +151,14 @@ func (cert *certificate) getAssociatedDomains() []string {
}
type dnsName struct {
Fingerprint string `db:"serial"`
Fingerprint string `db:"fingerprint"`
DNSName string `db:"dns_name"`
}
func (dnsn *dnsName) Insert(dbe sqlx.Ext) error {
_, err := sqlx.NamedExec(
dbe,
`INSERT INTO dns_names(serial, dns_name) VALUES(:serial, :dns_name);`,
`INSERT INTO dns_names(fingerprint, dns_name) VALUES(:fingerprint, :dns_name);`,
dnsn,
)
return err

View file

@ -11,6 +11,7 @@ CREATE TABLE IF NOT EXISTS certificates(
---
CREATE TABLE IF NOT EXISTS dns_names(
serial VARCHAR,
dns_name VARCHAR
fingerprint VARCHAR,
dns_name VARCHAR,
PRIMARY KEY (fingerprint, dns_name)
);

View file

@ -0,0 +1,38 @@
<div class="govuk-grid-row">
<div class="govuk-grid-column-full">
<h1 class="govuk-heading-l">Active certificates</h1>
<p class="govuk-body">Jump to...</p>
<ul class="govuk-list govuk-list--bullet">
{{ range . }}
<li><a class="govuk-link" href="#prop-{{ .SanitisedProperty }}">{{ .Property }}</a></li>
{{ end }}
</ul>
{{ range $i, $prop := . }}
<h2 class="govuk-heading-m" id="prop-{{ $prop.SanitisedProperty }}">{{ $prop.Property }}</h2>
<table class="govuk-table">
<!-- <thead class="govuk-table__head">-->
<!-- <tr class="govuk-table__row">-->
<!-- <th scope="col" class="govuk-table__header">Domain</th>-->
<!-- <th scope="col" class="govuk-table__header">Expires</th>-->
<!-- <th scope="col" class="govuk-table__header">Status</th>-->
<!-- <th scope="col" class="govuk-table__header"></th>-->
<!-- </tr>-->
<!-- </thead>-->
<tbody class="govuk-table__body">
{{ range $prop.Certificates }}
{{ if eq .Type "precertificate" }}
{{ continue }}
{{ end }}
<tr class="govuk-table__row">
<th scope="row" class="govuk-table__header">{{ index .Domains 0 }}</th>
<td class="govuk-table__cell">{{ .NotAfter.Format "2006-01-02" }}</td>
<td class="govuk-table__cell">{{ if isExpired .NotAfter }}<strong class="govuk-tag govuk-tag--red">Expired</strong>{{ else if isExpiringSoon .NotAfter }}<strong class="govuk-tag govuk-tag--yellow">Expiring soon</strong>{{ else }}<strong class="govuk-tag govuk-tag--green">Active</strong>{{ end }}</td>
<td class="govuk-table__cell"><a href="{{ .URL }}">Details</a></td>
</tr>
{{ end }}
</tbody>
</table>
{{ end }}
</div>
</div>

View file

@ -0,0 +1,62 @@
package certificateMonitor
import (
"embed"
"fmt"
"github.com/codemicro/platform/config"
"html/template"
"io/fs"
"sort"
"strings"
"time"
)
//go:embed templates
var templateFS embed.FS
var templates = template.Must(template.New("root").Funcs(template.FuncMap{
"isExpiringSoon": func(ref time.Time) bool {
return ref.Add(-time.Hour * 24 * 30).Before(time.Now())
},
"isExpired": func(ref time.Time) bool {
return ref.Before(time.Now())
},
}).ParseFS(fs.FS(templateFS), "templates/*.tpl.html"))
func renderActiveCertificatesView(certs []*certificate) (string, error) {
type propertyCertificates struct {
Property string
SanitisedProperty string
Certificates []*certificate
}
var certGroups []*propertyCertificates
{
groups := make(map[string]*propertyCertificates)
for _, prop := range strings.Split(config.Get().CertificateMonitor.Properties, " ") {
groups[strings.ToLower(prop)] = &propertyCertificates{Property: prop, SanitisedProperty: strings.ReplaceAll(prop, ".", "")}
}
for _, cert := range certs {
for property, group := range groups {
if strings.HasSuffix(cert.getAssociatedDomains()[0], property) {
group.Certificates = append(group.Certificates, cert)
break
}
}
}
for _, x := range groups {
certGroups = append(certGroups, x)
}
}
sort.Slice(certGroups, func(i, j int) bool {
return strings.ToLower(certGroups[i].Property) < strings.ToLower(certGroups[j].Property)
})
sb := new(strings.Builder)
if err := templates.ExecuteTemplate(sb, "activeCertificates.tpl.html", certGroups); err != nil {
return "", fmt.Errorf("execute activeCertificates.tpl.html: %w", err)
}
return sb.String(), nil
}

View file

@ -0,0 +1,53 @@
<!DOCTYPE html>
<html lang="en" class="govuk-template">
<head>
<meta charset="utf-8">
<title>{{ .Title }}</title>
<meta name="viewport" content="width=device-width, initial-scale=1, viewport-fit=cover">
<link rel="stylesheet" href="//{{ .Host }}/main.css">
</head>
<body class="govuk-template__body">
<script>
document.body.className += ' js-enabled' + ('noModule' in HTMLScriptElement.prototype ? ' govuk-frontend-supported' : '');
</script>
<a href="#main-content" class="govuk-skip-link" data-module="govuk-skip-link">Skip to main content</a>
<header class="govuk-header" role="banner" data-module="govuk-header">
<div class="govuk-header__container govuk-width-container">
<div class="govuk-header__logo">
<a href="/" class="govuk-header__link govuk-header__link--homepage">
<span>{{ .Title }}</span>
</a>
</div>
</div>
</header>
<div class="govuk-width-container">
<main class="govuk-main-wrapper" id="main-content" role="main">
{{ .BodyContent }}
</main>
</div>
<footer class="govuk-footer" role="contentinfo">
<div class="govuk-width-container">
<div class="govuk-footer__meta">
<div class="govuk-footer__meta-item govuk-footer__meta-item--grow">
<span class="govuk-footer__licence-description">The design of this site uses the <a href="https://design-system.service.gov.uk" class="govuk-footer__link">GOV.UK design system</a>, which is covered by the
<a
class="govuk-footer__link"
href="https://github.com/alphagov/govuk-frontend/blob/main/LICENSE.txt"
rel="license">MIT License</a>.
</span>
</div>
</div>
</div>
</footer>
<script type="module" src="//{{ .Host }}/govuk-frontend.min.js"></script>
<script type="module">
import {
initAll
} from '//{{ .Host }}/govuk-frontend.min.js'
initAll()
</script>
</body>
</html>

View file

@ -1,8 +1,12 @@
package htmlutil
import (
_ "embed"
"github.com/codemicro/platform/config"
g "github.com/maragudk/gomponents"
. "github.com/maragudk/gomponents/html"
"html/template"
"io"
)
func BasePage(title string, content ...g.Node) g.Node {
@ -26,3 +30,21 @@ func UnorderedList(x []string) g.Node {
return Li(g.Text(s))
})...)
}
//go:embed gds.html
var rawGovUKTemplate string
var govUKTemplate = template.Must(template.New("gds-base").Parse(rawGovUKTemplate))
func GovUKDesignBasePage(buf io.Writer, title string, content template.HTML) error {
type tplData struct {
Host string
Title string
BodyContent template.HTML
}
return govUKTemplate.Execute(buf, &tplData{
Host: config.Get().HostSuffix,
Title: title,
BodyContent: content, // casting to this type prevents escaping
})
}

View file

@ -5,4 +5,5 @@
}
$govuk-font-family: "Inter", sans-serif;
@import "../node_modules/govuk-frontend/dist/govuk/all";
$govuk-brand-colour: #df3062;
@import "../node_modules/govuk-frontend/dist/govuk/all";