Skip to content

Commit

Permalink
validate ingress TLS certificates
Browse files Browse the repository at this point in the history
  • Loading branch information
reddec committed Dec 16, 2021
1 parent 2b5d29f commit 91c8489
Show file tree
Hide file tree
Showing 8 changed files with 215 additions and 34 deletions.
1 change: 1 addition & 0 deletions docs/index.md
Original file line number Diff line number Diff line change
Expand Up @@ -10,6 +10,7 @@ Features:
* Supports static configuration (in addition to Ingress objects)
* Multiarch docker images: for amd64 and for arm64
* Automatic even-based updates
* Automatic TLS expiration checks

Limitations:

Expand Down
1 change: 1 addition & 0 deletions go.mod
Original file line number Diff line number Diff line change
Expand Up @@ -23,6 +23,7 @@ require (
github.com/google/go-cmp v0.5.5 // indirect
github.com/google/gofuzz v1.1.0 // indirect
github.com/googleapis/gnostic v0.5.5 // indirect
github.com/hako/durafmt v0.0.0-20210608085754-5c1018a4e16b // indirect
github.com/hashicorp/errwrap v1.0.0 // indirect
github.com/hashicorp/go-multierror v1.1.1 // indirect
github.com/imdario/mergo v0.3.5 // indirect
Expand Down
2 changes: 2 additions & 0 deletions go.sum
Original file line number Diff line number Diff line change
Expand Up @@ -120,6 +120,8 @@ github.com/googleapis/gnostic v0.5.5 h1:9fHAtK0uDfpveeqqo1hkEZJcFvYXAiCN3UutL8F9
github.com/googleapis/gnostic v0.5.5/go.mod h1:7+EbHbldMins07ALC74bsA81Ovc97DwqyJO1AENw9kA=
github.com/gorilla/websocket v1.4.2/go.mod h1:YR8l580nyteQvAITg2hZ9XVh4b55+EU/adAjf1fMHhE=
github.com/gregjones/httpcache v0.0.0-20180305231024-9cad4c3443a7/go.mod h1:FecbI9+v66THATjSRHfNgh1IVFe/9kFxbXtjV0ctIMA=
github.com/hako/durafmt v0.0.0-20210608085754-5c1018a4e16b h1:wDUNC2eKiL35DbLvsDhiblTUXHxcOPwQSCzi7xpQUN4=
github.com/hako/durafmt v0.0.0-20210608085754-5c1018a4e16b/go.mod h1:VzxiSdG6j1pi7rwGm/xYI5RbtpBgM8sARDXlvEvxlu0=
github.com/hashicorp/errwrap v1.0.0 h1:hLrqtEDnRye3+sgx6z4qVLNuviH3MR5aQ0ykNJa/UYA=
github.com/hashicorp/errwrap v1.0.0/go.mod h1:YH+1FKiLXxHSkmPseP+kNlulaMuP3n2brvKWEqk/Jc4=
github.com/hashicorp/go-multierror v1.1.1 h1:H5DkEtf6CXdFp0N0Em5UCwQpXMWke8IA0+lD48awMYo=
Expand Down
93 changes: 93 additions & 0 deletions internal/kube_watch.go
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,7 @@ import (
"context"
"fmt"
"log"
"net/url"
"sort"
"strconv"
"sync"
Expand All @@ -21,6 +22,7 @@ const (
AnnoTitle = "ingress-dashboard/title"
AnnoHide = "ingress-dashboard/hide" // do not display ingress in dashboard
syncInterval = 30 * time.Second
tlsInterval = time.Hour
)

type Receiver interface {
Expand Down Expand Up @@ -50,6 +52,13 @@ func WatchKubernetes(global context.Context, clientset *kubernetes.Clientset, re
defer cancel()
watcher.runLogoFetcher(ctx)
}()

wg.Add(1)
go func() {
defer wg.Done()
defer cancel()
watcher.runCertsInfoCheck(ctx)
}()
wg.Wait()
}

Expand All @@ -59,6 +68,7 @@ func newWatcher(global context.Context, receiver Receiver, clientset *kubernetes
cache: make(map[string]Ingress),
receiver: receiver,
checkLogos: make(chan struct{}, 1),
checkCerts: make(chan struct{}, 1),
clientset: clientset,
}
}
Expand All @@ -70,6 +80,7 @@ type kubeWatcher struct {
lock sync.RWMutex
receiver Receiver
checkLogos chan struct{}
checkCerts chan struct{}
}

func (kw *kubeWatcher) OnAdd(obj interface{}) {
Expand Down Expand Up @@ -137,6 +148,11 @@ func (kw *kubeWatcher) notify() {
case kw.checkLogos <- struct{}{}:
default:
}

select {
case kw.checkCerts <- struct{}{}:
default:
}
}

func (kw *kubeWatcher) items() []Ingress {
Expand All @@ -156,6 +172,17 @@ func (kw *kubeWatcher) updateLogo(ingress Ingress) {
kw.cache[ingress.UID] = old
}

func (kw *kubeWatcher) updateTLSExpiration(ingress Ingress) {
kw.lock.Lock()
defer kw.lock.Unlock()
old, exists := kw.cache[ingress.UID]
if !exists {
return
}
old.TLSExpiration = ingress.TLSExpiration
kw.cache[ingress.UID] = old
}

func (kw *kubeWatcher) inspectIngress(ctx context.Context, ing *v12.Ingress) Ingress {
return Ingress{
Class: getClassName(ing),
Expand Down Expand Up @@ -228,6 +255,62 @@ func (kw *kubeWatcher) getPodsNum(ctx context.Context, ns string, svc *v12.Ingre
return len(info.Spec.ClusterIPs) + extHosts, nil
}

func (kw *kubeWatcher) runCertsInfoCheck(ctx context.Context) {
timer := time.NewTicker(tlsInterval)
defer timer.Stop()

for {
kw.scanTLSCerts(ctx)
select {
case <-kw.checkCerts:
case <-timer.C:
case <-ctx.Done():
return
}
}
}

func (kw *kubeWatcher) scanTLSCerts(ctx context.Context) {
var visited = map[string]time.Time{}

for _, item := range kw.items() {
if !item.TLS {
continue
}
var min time.Time
for _, u := range item.Refs {
if parsedURL, err := url.Parse(u.URL); err == nil {
host := parsedURL.Hostname()

if exp, ok := visited[host]; ok {
min = timeMin(min, exp)
continue
}

expiredAt, err := Expiration(ctx, host)
if err != nil {
log.Println("failed get expiration time", host, ":", err)
continue
}

if expiredAt.IsZero() {
// no expirations
continue
}

min = timeMin(min, expiredAt)
visited[host] = expiredAt
}
}

if !min.IsZero() {
item.TLSExpiration = min
kw.updateTLSExpiration(item)
}
}
kw.receiver.Set(kw.items())
}

func toBool(value string, defaultValue bool) bool {
if v, err := strconv.ParseBool(value); err == nil {
return v
Expand All @@ -242,3 +325,13 @@ func getClassName(ing *v12.Ingress) string {
}
return ing.Annotations[anno]
}

func timeMin(a, b time.Time) time.Time {
if a.IsZero() {
return b
}
if b.After(a) {
return a
}
return b
}
43 changes: 31 additions & 12 deletions internal/service.go
Original file line number Diff line number Diff line change
Expand Up @@ -11,25 +11,32 @@ import (
"path/filepath"
"strings"
"sync/atomic"
"time"

"github.com/hako/durafmt"
"github.com/reddec/ingress-dashboard/internal/auth"
"github.com/reddec/ingress-dashboard/internal/static"
"gopkg.in/yaml.v3"
)

const (
SoonExpiredInterval = 14 * 24 * time.Hour // 2 weeks
)

type Ingress struct {
ID string `yaml:"-"` // human readable ID (namespace with name)
UID string `yaml:"-"` // machine readable ID (guid in Kube)
Title string `yaml:"-"` // custom title in dashboard, overwrites Name
Name string `yaml:"name"` // ingress name as in Kube
Namespace string `yaml:"namespace"` // Kube namespace for ingress
Description string `yaml:"description"` // optional, human-readable description of Ingress
Hide bool `yaml:"-"` // hidden Ingresses will not appear in UI
LogoURL string `yaml:"logo_url"` // custom URL for icon
Class string `yaml:"-"` // Ingress class
Static bool `yaml:"-"`
Refs []Ref `yaml:"-"`
TLS bool `yaml:"-"`
ID string `yaml:"-"` // human readable ID (namespace with name)
UID string `yaml:"-"` // machine readable ID (guid in Kube)
Title string `yaml:"-"` // custom title in dashboard, overwrites Name
Name string `yaml:"name"` // ingress name as in Kube
Namespace string `yaml:"namespace"` // Kube namespace for ingress
Description string `yaml:"description"` // optional, human-readable description of Ingress
Hide bool `yaml:"-"` // hidden Ingresses will not appear in UI
LogoURL string `yaml:"logo_url"` // custom URL for icon
Class string `yaml:"-"` // Ingress class
Static bool `yaml:"-"`
Refs []Ref `yaml:"-"`
TLS bool `yaml:"-"`
TLSExpiration time.Time `yaml:"-"`
}

type Ref struct {
Expand Down Expand Up @@ -67,6 +74,18 @@ func (ingress Ingress) HasDeadRefs() bool {
return false
}

func (ingress Ingress) IsTLSExpired() bool {
return ingress.TLS && (time.Now().After(ingress.TLSExpiration))
}

func (ingress Ingress) IsTLSSoonExpire() bool {
return ingress.TLS && (ingress.TLSExpiration.Sub(time.Now()) < SoonExpiredInterval)
}

func (ingress Ingress) WhenTLSExpires() string {
return durafmt.Parse(ingress.TLSExpiration.Sub(time.Now())).String()
}

type UIContext struct {
Ingresses []Ingress
User *auth.User
Expand Down
56 changes: 34 additions & 22 deletions internal/static/assets/templates/index.gotemplate
Original file line number Diff line number Diff line change
Expand Up @@ -41,31 +41,39 @@
{{end}}
</div>
<p class="description">{{$ingress.Description}}</article>
{{range $ref := $ingress.Refs}}
<p class="ref">
<a href="{{$ref.URL}}" target="_blank">{{$ref.URL}}</a>
{{range $ref := $ingress.Refs}}
<p class="ref">
<a href="{{$ref.URL}}" target="_blank">{{$ref.URL}}</a>
</p>
{{- if not $ref.Static}}
{{if $ref.Pods}}
<p class="meta-info">
{{$ref.Pods}} host{{if gt $ref.Pods 1}}s{{end}}
</p>
{{- if not $ref.Static}}
{{if $ref.Pods}}
<p class="meta-info">
{{$ref.Pods}} host{{if gt $ref.Pods 1}}s{{end}}
</p>
{{else}}
<p class="meta-info {{if not $ref.Pods}}warn{{end}}">no hosts!</p>
{{end}}
{{- end}}
{{else}}
<p class="meta-info {{if not $ref.Pods}}warn{{end}}">no hosts!</p>
{{end}}
{{- end}}
{{end}}
{{if not $ingress.Static}}
<div class="status-line">
{{if $ingress.TLS}}
<span class="success" title="TLS enabled">🛡 TLS enabled️</span>
{{else}}
<span class="warn" title="Insecure connections">🔓 TLS not enabled</span>
{{end}}
{{if $ingress.HasDeadRefs}}
<span class="warn" title="Hosts are missing">☠️ no hosts</span>
{{end}}
</div>
<div class="status-line">
{{if $ingress.TLS}}
{{if $ingress.TLSExpiration.IsZero}}
<span title="TLS enabled but status not yet known">TLS status unknown</span>
{{else if $ingress.IsTLSExpired}}
<span class="warn" title="TLS certificate expired at {{$ingress.TLSExpiration}}">❌ TLS expired</span>
{{else if $ingress.IsTLSSoonExpire}}
<span class="danger" title="TLS certificate will expire after {{$ingress.WhenTLSExpires}}">🔔 TLS soon expire</span>
{{else}}
<span class="success" title="TLS enabled, valid until {{$ingress.TLSExpiration}}">🛡 TLS enabled️</span>
{{end}}
{{else}}
<span class="warn" title="Insecure connections">🔓 TLS not enabled</span>
{{end}}
{{if $ingress.HasDeadRefs}}
<span class="warn" title="Hosts are missing">☠️ no hosts</span>
{{end}}
</div>
{{end}}
</form>
{{end}}
Expand Down Expand Up @@ -147,6 +155,10 @@
color: #328132;
}

.danger {
color: #bbbb39;
}

.status-line {
display: flex;
justify-content: space-between;
Expand Down
34 changes: 34 additions & 0 deletions internal/tls_checker.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,34 @@
package internal

import (
"context"
"crypto/tls"
"fmt"
"net"
"time"
)

// Expiration time of TLS certificate.
func Expiration(ctx context.Context, host string) (time.Time, error) {
conn, err := tls.DialWithDialer(&net.Dialer{
Cancel: ctx.Done(),
}, "tcp", host+":443", &tls.Config{
InsecureSkipVerify: true,
})
if err != nil {
return time.Time{}, fmt.Errorf("dial %s: %w", host, err)
}
defer conn.Close()

var min time.Time
for i, cert := range conn.ConnectionState().PeerCertificates {
if !cert.NotAfter.IsZero() {
if i == 0 {
min = cert.NotAfter
} else if cert.NotAfter.Before(min) {
min = cert.NotAfter
}
}
}
return min, nil
}
19 changes: 19 additions & 0 deletions internal/tls_checker_test.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,19 @@
package internal

import (
"context"
"testing"
"time"

"github.com/stretchr/testify/require"
)

func TestExpiration(t *testing.T) {
ctx, cancel := context.WithTimeout(context.Background(), 10*time.Second)
defer cancel()

info, err := Expiration(ctx, "google.com")
require.NoError(t, err)

t.Log(info)
}

0 comments on commit 91c8489

Please sign in to comment.