diff --git a/docs/index.md b/docs/index.md index dffd94c..80d462a 100644 --- a/docs/index.md +++ b/docs/index.md @@ -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: diff --git a/go.mod b/go.mod index c46531c..c07a0f7 100644 --- a/go.mod +++ b/go.mod @@ -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 diff --git a/go.sum b/go.sum index 0339f94..3722797 100644 --- a/go.sum +++ b/go.sum @@ -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= diff --git a/internal/kube_watch.go b/internal/kube_watch.go index 07cdffa..04ad063 100644 --- a/internal/kube_watch.go +++ b/internal/kube_watch.go @@ -4,6 +4,7 @@ import ( "context" "fmt" "log" + "net/url" "sort" "strconv" "sync" @@ -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 { @@ -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() } @@ -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, } } @@ -70,6 +80,7 @@ type kubeWatcher struct { lock sync.RWMutex receiver Receiver checkLogos chan struct{} + checkCerts chan struct{} } func (kw *kubeWatcher) OnAdd(obj interface{}) { @@ -137,6 +148,11 @@ func (kw *kubeWatcher) notify() { case kw.checkLogos <- struct{}{}: default: } + + select { + case kw.checkCerts <- struct{}{}: + default: + } } func (kw *kubeWatcher) items() []Ingress { @@ -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), @@ -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 @@ -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 +} diff --git a/internal/service.go b/internal/service.go index 7022f55..dccc19e 100644 --- a/internal/service.go +++ b/internal/service.go @@ -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 { @@ -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 diff --git a/internal/static/assets/templates/index.gotemplate b/internal/static/assets/templates/index.gotemplate index d0fe319..9eeac7b 100644 --- a/internal/static/assets/templates/index.gotemplate +++ b/internal/static/assets/templates/index.gotemplate @@ -41,31 +41,39 @@ {{end}}
{{$ingress.Description}} - {{range $ref := $ingress.Refs}} -
- {{$ref.URL}} + {{range $ref := $ingress.Refs}} +
+ {{$ref.URL}} +
+ {{- if not $ref.Static}} + {{if $ref.Pods}} + - {{- if not $ref.Static}} - {{if $ref.Pods}} - - {{else}} - - {{end}} - {{- end}} + {{else}} + + {{end}} + {{- end}} {{end}} {{if not $ingress.Static}} -