package main import ( "crypto/x509" "database/sql" "encoding/json" "encoding/pem" "fmt" "log" "net/http" "regexp" "strconv" "strings" "time" humanize "github.com/dustin/go-humanize" "github.com/pkg/errors" "github.com/spf13/viper" ) // Activity is a message to be shown on the dashboard, with timestamp and css class type Activity struct { Title string Message string Timestamp string TimestampRel string Class string } func _removeAnsiColors(line string) string { b := make([]byte, len(line)) var bl int for i := 0; i < len(line); i++ { c := line[i] if c >= 32 && c != 127 { b[bl] = c bl++ } } line = string(b[:bl]) line = strings.ReplaceAll(line, "[31m", "") line = strings.ReplaceAll(line, "[1m", "") line = strings.ReplaceAll(line, "[0m", "") return line } func toJson(line string) (map[string]interface{}, int) { var obj map[string]interface{} idx := strings.Index(line, "JSON={") if idx > -1 { jsonStr := line[idx+5:] err := json.Unmarshal([]byte(jsonStr), &obj) if err != nil { fmt.Println(err) } } return obj, idx } func _parseLine(line string, loc *time.Location) Activity { var activity Activity line = _removeAnsiColors(line) re := regexp.MustCompile(`^.*\|\s*(\S+)(.*\:)? (\S) (\S+) (\S+) (.*)$`) result := re.FindStringSubmatch(line) if len(result) == 0 { return activity } activity.Class = "" if result[3] == "4" { activity.Class = "warning" } if result[3] == "3" { activity.Class = "error" } timestamp, err := time.ParseInLocation("2006-01-02T15:04:05.000000+00:00Z", result[1], loc) activity.Timestamp = "" activity.TimestampRel = "??" if err == nil { activity.Timestamp = timestamp.Format("02-Jan-2006 15:04:05 MST") activity.Timestamp = strings.ReplaceAll(activity.Timestamp, "+0000", "GMT") activity.TimestampRel = humanize.RelTime(timestamp, time.Now(), "", "") } activity.Title = "" if len(result[4]) > 2 { tail := result[4][len(result[4])-2:] switch tail { case "ca": activity.Title = "Certification Agent" case "ra": activity.Title = "Registration Agent" case "sa": activity.Title = "Storage Agent" case "va": activity.Title = "Validation Agent" } } if activity.Title == "" { switch result[4] { case "nonce-service": activity.Title = "Nonce Service" case "boulder-publisher": activity.Title = "Publisher" case "log-validator": activity.Title = "Log Validator" case "crl-storer": activity.Title = "CRL Storer" case "crl-updater": activity.Title = "CRL Updater" case "ocsp-responder": activity.Title = "OCSP Responder" default: activity.Title = result[4] } } message := result[6] var idx int //idx := strings.Index(message, ".well-known/acme-challenge") //if idx > -1 { // message = message[0:idx] //} if strings.Contains(message, "Checked CAA records for") { message = message[0:strings.Index(message, ",")] } msgJson, jsonIdx := toJson(message) if strings.Contains(message, "Validation result") { var ctyp string var cstat string if chall, ok := msgJson["Challenge"].(map[string]interface{}); ok { ctyp = fmt.Sprintf(" Type=%s", chall["type"]) cstat = fmt.Sprintf(" Status=%s", chall["status"]) } message = message[0:jsonIdx-1] + ":" + fmt.Sprintf(" Identifier=%s", msgJson["Identifier"]) + ctyp + cstat } if strings.Contains(message, "Signing precert") || strings.Contains(message, "Signing cert") { var comnm string var dnsnms string if issreq, ok := msgJson["IssuanceRequest"].(map[string]interface{}); ok { comnm = fmt.Sprintf(" CommonName=%s", issreq["CommonName"]) dnsnms = fmt.Sprintf(" DNSNames=%s", issreq["DNSNames"]) } message = message[0:jsonIdx-1] + ":" + comnm + dnsnms + fmt.Sprintf(" Issuer=%s", msgJson["Issuer"]) } idx = strings.Index(message, " csr=[") if idx > -1 { message = message[0:idx] } idx = strings.Index(message, " certificate=[") if idx > -1 { message = message[0:idx] } idx = strings.Index(message, " precert=[") if idx > -1 { message = message[0:idx] } idx = strings.Index(message, " precertificate=[") if idx > -1 { message = message[0:idx] } if strings.Contains(message, "Certificate request - ") { idx = strings.Index(message, " JSON={") if idx > -1 { message = message[0:idx] } } if strings.Contains(message, "failed to complete security handshake") { activity.Class = "warning" } if strings.Contains(message, "failed to receive the preface from client") { activity.Class = "warning" } activity.Message = message return activity } func _parseActivity(data string) []Activity { var activity []Activity lines := strings.Split(data, "\n") if lines[0] == "/UTC" { lines[0] = "Etc/UTC" } loc, err := time.LoadLocation(lines[0]) if err != nil { log.Printf("Could not determine location: %s\n", err) loc = time.Local } for i := len(lines) - 2; i >= 1; i-- { activity = append(activity, _parseLine(lines[i], loc)) } return activity } // Component contains status info related to a LabCA component type Component struct { Name string Timestamp string TimestampRel string Class string LogURL string LogTitle string Buttons []map[string]interface{} } func _parseComponents(data string) []Component { var components []Component if data[len(data)-1:] == "\n" { data = data[0 : len(data)-1] } parts := strings.Split(data, "|") if len(parts) < 7 { components = append(components, Component{Name: "Boulder (ACME)"}) components = append(components, Component{Name: "consul (Boulder)"}) components = append(components, Component{Name: "pkimetal (Boulder)"}) components = append(components, Component{Name: "redis (Boulder)"}) components = append(components, Component{Name: "LabCA Application"}) components = append(components, Component{Name: "LabCA Controller"}) components = append(components, Component{Name: "MySQL Database"}) components = append(components, Component{Name: "NGINX Webserver"}) return components } nginx, err := time.Parse(time.RFC3339Nano, parts[0]) nginxReal := "" nginxNice := "stopped" nginxClass := "error" if err == nil { nginxReal = nginx.Format("02-Jan-2006 15:04:05 MST") nginxNice = humanize.RelTime(nginx, time.Now(), "", "") nginxClass = "" } svc, err := time.Parse(time.RFC3339Nano, parts[1]) svcReal := "" svcNice := "stopped" svcClass := "error" if err == nil { svcReal = svc.Format("02-Jan-2006 15:04:05 MST") svcNice = humanize.RelTime(svc, time.Now(), "", "") svcClass = "" } boulder, err := time.Parse(time.RFC3339Nano, parts[2]) boulderReal := "" boulderNice := "stopped" boulderClass := "error" if err == nil { boulderReal = boulder.Format("02-Jan-2006 15:04:05 MST") boulderNice = humanize.RelTime(boulder, time.Now(), "", "") boulderClass = "" } labca, err := time.Parse(time.RFC3339Nano, parts[3]) labcaReal := "" labcaNice := "stopped" labcaClass := "error" if err == nil { labcaReal = labca.Format("02-Jan-2006 15:04:05 MST") labcaNice = humanize.RelTime(labca, time.Now(), "", "") labcaClass = "" } mysql, err := time.Parse(time.RFC3339Nano, parts[4]) mysqlReal := "" mysqlNice := "stopped" mysqlClass := "error" if err == nil { mysqlReal = mysql.Format("02-Jan-2006 15:04:05 MST") mysqlNice = humanize.RelTime(mysql, time.Now(), "", "") mysqlClass = "" } consul, err := time.Parse(time.RFC3339Nano, parts[5]) consulReal := "" consulNice := "stopped" consulClass := "error" if err == nil { consulReal = consul.Format("02-Jan-2006 15:04:05 MST") consulNice = humanize.RelTime(consul, time.Now(), "", "") consulClass = "" } pkimetal, err := time.Parse(time.RFC3339Nano, parts[6]) pkimetalReal := "" pkimetalNice := "stopped" pkimetalClass := "error" if err == nil { pkimetalReal = pkimetal.Format("02-Jan-2006 15:04:05 MST") pkimetalNice = humanize.RelTime(pkimetal, time.Now(), "", "") pkimetalClass = "" } redis, err := time.Parse(time.RFC3339Nano, parts[7]) redisReal := "" redisNice := "stopped" redisClass := "error" if err == nil { redisReal = redis.Format("02-Jan-2006 15:04:05 MST") redisNice = humanize.RelTime(redis, time.Now(), "", "") redisClass = "" } components = append(components, Component{Name: "Boulder (ACME)", Timestamp: boulderReal, TimestampRel: boulderNice, Class: boulderClass}) components = append(components, Component{Name: "consul (Boulder)", Timestamp: consulReal, TimestampRel: consulNice, Class: consulClass}) components = append(components, Component{Name: "pkimetal (Boulder)", Timestamp: pkimetalReal, TimestampRel: pkimetalNice, Class: pkimetalClass}) components = append(components, Component{Name: "redis (Boulder)", Timestamp: redisReal, TimestampRel: redisNice, Class: redisClass}) components = append(components, Component{Name: "LabCA Application", Timestamp: labcaReal, TimestampRel: labcaNice, Class: labcaClass}) components = append(components, Component{Name: "LabCA Controller", Timestamp: svcReal, TimestampRel: svcNice, Class: svcClass}) components = append(components, Component{Name: "MySQL Database", Timestamp: mysqlReal, TimestampRel: mysqlNice, Class: mysqlClass}) components = append(components, Component{Name: "NGINX Webserver", Timestamp: nginxReal, TimestampRel: nginxNice, Class: nginxClass}) return components } // Stat contains a statistic type Stat struct { Name string Hint string Value string Class string } // The stats as reported by docker type DockerStat struct { Name string MemUsage uint64 MemLimit uint64 MemPerc float64 Pids uint64 } func _parseStats(data string, components []Component) []Stat { var stats []Stat if data[len(data)-1:] == "\n" { data = data[0 : len(data)-1] } parts := strings.Split(data, "|") if parts[0] == "/UTC" { parts[0] = "Etc/UTC" } loc, err := time.LoadLocation(parts[0]) if err != nil { log.Printf("Could not determine location: %s\n", err) loc = time.Local } since, err := time.ParseInLocation("2006-01-02 15:04:05", parts[1], loc) var sinceReal string sinceNice := "??" if err == nil { sinceReal = since.Format("02-Jan-2006 15:04:05 MST") sinceNice = humanize.RelTime(since, time.Now(), "", "") } stats = append(stats, Stat{Name: "System Uptime", Hint: sinceReal, Value: sinceNice}) if components == nil { return stats } stats = append(stats, Stat{Name: "Memory Limit", Value: ""}) stats = append(stats, Stat{Name: "Memory Used", Value: ""}) stats = append(stats, Stat{Name: "Memory Used [%]", Value: ""}) return stats } func getStatsStandalone() []Stat { var stats []Stat out, err := exeCmd("cat /etc/timezone") if err != nil || string(out) == "/UTC\n" { out = []byte("Etc/UTC") } if string(out[len(out)-1:]) == "\n" { out = out[0 : len(out)-1] } loc, err := time.LoadLocation(string(out)) if err != nil { log.Printf("Could not determine location: %s\n", err) loc = time.Local } out, _ = exeCmd("uptime -s") if string(out[len(out)-1:]) == "\n" { out = out[0 : len(out)-1] } since, err := time.ParseInLocation("2006-01-02 15:04:05", string(out), loc) var sinceReal string sinceNice := "??" if err == nil { sinceReal = since.Format("02-Jan-2006 15:04:05 MST") sinceNice = humanize.RelTime(since, time.Now(), "", "") } stats = append(stats, Stat{Name: "System Uptime", Hint: sinceReal, Value: sinceNice}) total := "0" avail := "0" out, err = exeCmd("free -b --si") if err == nil { lines := strings.Split(string(out), "\n") line := "" for i := 0; i < len(lines); i++ { if strings.Contains(lines[i], ":") { line = lines[i] break } } re := regexp.MustCompile(`.*?\s+(\d+)\s+.*`) segs := re.FindStringSubmatch(line) if len(segs) > 1 { total = segs[1] } re = regexp.MustCompile(`.*\s+(\d+)$`) segs = re.FindStringSubmatch(line) if len(segs) > 1 { avail = segs[1] } } memUsed := uint64(0) memAvail, err := strconv.ParseUint(avail, 10, 64) if err != nil { memAvail = 0 } memTotal, err := strconv.ParseUint(total, 10, 64) if err != nil { memUsed = 0 } else { memUsed = memTotal - memAvail } percMem := float64(0) if (memUsed + memAvail) > 0 { percMem = float64(100) * float64(memUsed) / float64(memUsed+memAvail) } usedHuman := humanize.IBytes(memUsed) availHuman := humanize.IBytes(memAvail) percHuman := fmt.Sprintf("%s %%", humanize.FtoaWithDigits(percMem, 1)) stats = append(stats, Stat{Name: "Memory Used", Value: usedHuman}) class := "" if percMem > 75 { class = "warning" } if percMem > 90 { class = "error" } stats = append(stats, Stat{Name: "Memory Used [%]", Value: percHuman, Class: class}) class = "" if memAvail < 250000000 { class = "warning" } if memAvail < 100000000 { class = "error" } stats = append(stats, Stat{Name: "Memory Available", Value: availHuman, Class: class}) return stats } // What we return as json type AjaxStat struct { Stat MemoryUsed string MemoryPerc string NumPids int } func parseDockerStats(data string) []AjaxStat { var stats []AjaxStat dockerStats := []DockerStat{} rawStats := strings.Split(data, "\n") for _, rawStat := range rawStats { if len(rawStat) > 0 { elms := strings.Fields(rawStat) if len(elms) > 13 { stat := DockerStat{} // CONTAINER ID NAME CPU % MEM USAGE / LIMIT MEM % NET I/O BLOCK I/O PIDS // 817bdaec6daf boulder-boulder-1 0.07% 255.3MiB / 1GiB 24.93% 1.18MB / 339kB 0B / 0B 158 stat.Name = elms[1] x, err := humanize.ParseBigBytes(elms[3]) if err == nil { stat.MemUsage = x.Uint64() } x, err = humanize.ParseBigBytes(elms[5]) if err == nil { stat.MemLimit = x.Uint64() } y, err := strconv.ParseFloat(strings.ReplaceAll(elms[6], "%", ""), 64) if err == nil { stat.MemPerc = y } p, err := strconv.ParseUint(elms[13], 10, 64) if err == nil { stat.Pids = p } dockerStats = append(dockerStats, stat) } } } // Update the component stats totalMemUsage := uint64(0) for _, docker := range dockerStats { stat := AjaxStat{} if strings.Contains(docker.Name, "-boulder-") { stat.Name = "Boulder (ACME)" } if strings.Contains(docker.Name, "-bconsul-") { stat.Name = "consul (Boulder)" } if strings.Contains(docker.Name, "-bpkimetal-") { stat.Name = "pkimetal (Boulder)" } if strings.Contains(docker.Name, "-bredis-") { stat.Name = "redis (Boulder)" } if strings.Contains(docker.Name, "labca-gui-") { stat.Name = "LabCA Application" } if strings.Contains(docker.Name, "-control-") { stat.Name = "LabCA Controller" } if strings.Contains(docker.Name, "-nginx-") { stat.Name = "NGINX Webserver" } if strings.Contains(docker.Name, "-bmysql-") { stat.Name = "MySQL Database" } stat.MemoryUsed = humanize.IBytes(docker.MemUsage) stat.MemoryPerc = fmt.Sprintf("%s%%", humanize.FtoaWithDigits(docker.MemPerc, 1)) stat.NumPids = int(docker.Pids) stats = append(stats, stat) totalMemUsage += docker.MemUsage } percMem := float64(0) if (dockerStats[0].MemLimit) > 0 { percMem = float64(100) * float64(totalMemUsage) / float64(dockerStats[0].MemLimit) } usedHuman := humanize.IBytes(totalMemUsage) limitHuman := humanize.IBytes(dockerStats[0].MemLimit) percHuman := fmt.Sprintf("%s%%", humanize.FtoaWithDigits(percMem, 1)) stats = append(stats, AjaxStat{Stat: Stat{Name: "Memory Limit", Value: limitHuman}}) stats = append(stats, AjaxStat{Stat: Stat{Name: "Memory Used", Value: usedHuman}}) class := "" if percMem > 75 { class = "warning" } if percMem > 90 { class = "error" } stats = append(stats, AjaxStat{Stat: Stat{Name: "Memory Used [%]", Value: percHuman, Class: class}}) return stats } // CollectDashboardData collects all data relevant for building the dashboard page func CollectDashboardData(w http.ResponseWriter, r *http.Request) (map[string]interface{}, error) { db, err := sql.Open(dbType, dbConn) if err != nil { errorHandler(w, r, err, http.StatusInternalServerError) return nil, err } defer func() { _ = db.Close() }() dashboardData := make(map[string]interface{}) dashboardData["RequestBase"] = r.Header.Get("X-Request-Base") var rows *sql.Rows if viper.GetString("backend") == "step-ca" { rows, err = db.Query("SELECT count(*) FROM acme_accounts") } else { rows, err = db.Query("SELECT count(*) FROM registrations") } if err != nil { errorHandler(w, r, err, http.StatusInternalServerError) return nil, err } var dbres int if rows.Next() { err = rows.Scan(&dbres) if err != nil { errorHandler(w, r, err, http.StatusInternalServerError) return nil, err } dashboardData["NumAccounts"] = dbres } if viper.GetString("backend") == "step-ca" { var numcerts int var numexpired int revokeds, err := stepcaGetRevokeds(db) if err != nil { errorHandler(w, r, err, http.StatusInternalServerError) return nil, err } rows, err = db.Query("SELECT nvalue FROM acme_certs") if err != nil { errorHandler(w, r, err, http.StatusInternalServerError) return nil, err } var row []byte var dbcert stepcaCert for rows.Next() { err = rows.Scan(&row) if err != nil { errorHandler(w, r, err, http.StatusInternalServerError) return nil, err } err = json.Unmarshal(row, &dbcert) if err != nil { errorHandler(w, r, err, http.StatusInternalServerError) return nil, err } var block *pem.Block var crt *x509.Certificate for len(dbcert.Leaf) > 0 { block, dbcert.Leaf = pem.Decode(dbcert.Leaf) if block == nil { break } if block.Type != "CERTIFICATE" { errorHandler(w, r, err, http.StatusInternalServerError) return nil, errors.New("error decoding PEM: data contains block that is not a certificate") } crt, err = x509.ParseCertificate(block.Bytes) if err != nil { errorHandler(w, r, err, http.StatusInternalServerError) return nil, errors.Wrapf(err, "error parsing x509 certificate") } } if len(dbcert.Leaf) > 0 { errorHandler(w, r, err, http.StatusInternalServerError) return nil, errors.New("error decoding PEM: unexpected data") } if time.Now().After(crt.NotAfter) { numexpired += 1 } else { if _, found := revokeds[crt.SerialNumber.Text(10)]; !found { numcerts += 1 } } } dashboardData["NumCerts"] = numcerts dashboardData["NumExpired"] = numexpired } else { rows, err = db.Query("SELECT count(*) FROM certificateStatus WHERE (revokedDate='0000-00-00 00:00:00' OR revokedDate='2000-01-01 00:00:00') AND notAfter >= NOW()") if err != nil { errorHandler(w, r, err, http.StatusInternalServerError) return nil, err } if rows.Next() { err = rows.Scan(&dbres) if err != nil { errorHandler(w, r, err, http.StatusInternalServerError) return nil, err } dashboardData["NumCerts"] = dbres } rows, err = db.Query("SELECT count(*) FROM certificateStatus WHERE notAfter < NOW()") if err != nil { errorHandler(w, r, err, http.StatusInternalServerError) return nil, err } if rows.Next() { err = rows.Scan(&dbres) if err != nil { errorHandler(w, r, err, http.StatusInternalServerError) return nil, err } dashboardData["NumExpired"] = dbres } } if viper.GetString("backend") == "step-ca" { rows, err = db.Query("SELECT count(*) FROM revoked_x509_certs") } else { rows, err = db.Query("SELECT count(*) FROM certificateStatus WHERE revokedDate<>'0000-00-00 00:00:00' AND revokedDate<>'2000-01-01 00:00:00'") } if err != nil { errorHandler(w, r, err, http.StatusInternalServerError) return nil, err } if rows.Next() { err = rows.Scan(&dbres) if err != nil { errorHandler(w, r, err, http.StatusInternalServerError) return nil, err } dashboardData["NumRevoked"] = dbres } if viper.GetString("backend") == "step-ca" { dashboardData["Standalone"] = true dashboardData["Components"] = []Component{} dashboardData["Stats"] = getStatsStandalone() } else { activity := getLog(w, r, "activity") dashboardData["Activity"] = _parseActivity(activity) components := _parseComponents(getLog(w, r, "components")) uptime := getLog(w, r, "uptime") dashboardData["Stats"] = _parseStats(uptime, components) dashboardData["Components"] = components } return dashboardData, nil }