Commit 97fdd9ee authored by Wes Cilldhaire's avatar Wes Cilldhaire 💻
Browse files

Initial import

parents
/backend/meerkat
/backend/meerkat.exe
/backend/meerkat.toml
/backend/dashboards
/backend/dashboards-data
/frontend/node_modules
/frontend/dist
/config
FROM golang:alpine AS backend-build
RUN apk add --update --no-cache gcc libc-dev git
FROM backend-build AS backend
COPY ./backend /root/backend
WORKDIR /root/backend
RUN go build
FROM node:lts AS frontend
COPY ./frontend /root/frontend
WORKDIR /root/frontend
RUN npm install && npm run prod
FROM alpine:latest
WORKDIR /meerkat
COPY --from=backend /root/backend/meerkat .
COPY --from=frontend /root/frontend frontend
USER 9001
ENTRYPOINT ["/meerkat/meerkat", "-config", "/meerkat/config/meerkat.toml"]
PROJECT_NAME := meerkat
IMAGE := hub.sol1.net/${PROJECT_NAME}
TAG := latest
BUILD_OPTS := --build-arg=http_proxy="${http_proxy}"
.PHONY: default
default: push
@printf "${IMAGE}:${TAG} ready\n"
.PHONY: push
push: build git-check
docker push ${IMAGE}:${TAG}
.PHONY: build
build:
docker build --pull ${BUILD_OPTS} -t ${IMAGE}:${TAG} .
.PHONY: git-check
git-check:
@if [ "${TAG}" = "latest" ]; then \
if [ -n "$$(git status --porcelain)" ]; then \
echo "\033[1;31mCan only build 'latest' from a clean working copy\033[0m" >&2; \
echo "\033[1;31mFor testing purposes, provide an alternate tag, eg make TAG=bobtest\033[0m" >&2; \
exit 1; \
fi; \
git push; \
fi
.PHONY: backend-dev
backend-dev:
docker build --pull ${BUILD_OPTS} --target backend-build -t ${PROJECT_NAME}-backend-dev:latest .
docker rm -f ${PROJECT_NAME}-backend-dev || true
docker run --rm -it --name ${PROJECT_NAME}-backend-dev --hostname ${PROJECT_NAME}-backend -v $$(pwd)/backend:/tmp/backend -v $$(pwd)/frontend:/tmp/backend/frontend:ro -e HOME=/tmp --workdir /tmp/backend -u $$(id -u) -p 8585 ${PROJECT_NAME}-backend-dev:latest /bin/sh
.PHONY: frontend-dev
frontend-dev:
docker rm -f ${PROJECT_NAME}-frontend-dev || true
docker run --rm -it --name ${PROJECT_NAME}-frontend-dev -v $$(pwd)/frontend:/tmp/frontend --workdir /tmp/frontend -u $$(id -u) -e HOME=/tmp node:lts /bin/sh -c 'npm install && npm run dev'
.PHONY: frontend-shell
frontend-shell:
docker rm -f ${PROJECT_NAME}-frontend-dev-shell 2>/dev/null || true
docker run --rm -it --name ${PROJECT_NAME}-frontend-dev-shell -v $$(pwd)/frontend:/tmp/frontend --workdir /tmp/frontend -u $$(id -u) -e HOME=/tmp node:lts /bin/sh -c 'npm install && /bin/sh'
.PHONY: browse-dev
browse-dev:
@if [ -n "$$(command -v xdg-open)" ]; then \
xdg-open http://$$(docker inspect --format='{{.NetworkSettings.IPAddress}}' ${PROJECT_NAME}-backend-dev):8585; \
else \
open http://localhost:$$(docker inspect --format='{{(index (index .NetworkSettings.Ports "8585/tcp") 0).HostPort}}' ${PROJECT_NAME}-backend-dev); \
fi
.PHONY: deploy
deploy:
git pull
docker-compose up --build --force-recreate -d
package main
import (
"bytes"
"encoding/json"
"fmt"
"io"
"io/ioutil"
"log"
"net/http"
"os"
"path"
"path/filepath"
"regexp"
"strconv"
"strings"
"unicode/utf8"
"github.com/go-chi/chi"
)
//Dashboard contains all information to render a dashboard
type Dashboard struct {
Title string `json:"title"`
Slug string `json:"slug"`
Background string `json:"background"`
Width string `json:"width"`
Height string `json:"height"`
Tags []string `json:"tags"`
Elements []Element `json:"elements"`
GlobalMute bool `json:"globalMute"`
OkSound string `json:"okSound"`
CriticalSound string `json:"criticalSound"`
WarningSound string `json:"warningSound"`
UnknownSound string `json:"unknownSound"`
}
//Element contains any service/host information needed
type Element struct {
Type string `json:"type"`
Title string `json:"title"`
Rect Rect `json:"rect"`
Options Option `json:"options"`
Rotation float64 `json:"rotation"`
}
//Option contains element options
type Option struct {
CheckID string `json:"checkId"`
NameFontSize int `json:"nameFontSize"`
StatusFontSize int `json:"statusFontSize"`
RightArrow bool `json:"rightArrow"`
LeftArrow bool `json:"leftArrow"`
StrokeWidth int `json:"strokeWidth"`
Image string `json:"image"`
OKSvg string `json:"okSvg"`
OKStrokeColor string `json:"okStrokeColor"`
WarningStrokeColor string `json:"warningStrokeColor"`
WarningSvg string `json:"warningSvg"`
UnknownStrokeColor string `json:"unknownStrokeColor"`
UnknownSvg string `json:"unknownSvg"`
CriticalStrokeColor string `json:"criticalStrokeColor"`
CriticalSvg string `json:"criticalSvg"`
CriticalImage string `json:"criticalImage"`
OkImage string `json:"okImage"`
UnknownImage string `json:"unknownImage"`
WarningImage string `json:"warningImage"`
Svg string `json:"svg"`
StrokeColor string `json:"strokeColor"`
Source string `json:"source"`
AudioSource string `json:"audioSource"`
Text string `json:"text"`
BackgroundColor string `json:"backgroundColor"`
TextAlign string `json:"textAlign"`
TextVerticalAlign string `json:"textVerticalAlign"`
FontColor string `json:"fontColor"`
FontSize string `json:"fontSize"`
LinkURL string `json:"linkURL"`
OkSound string `json:"okSound"`
WarningSound string `json:"warningSound"`
UnknownSound string `json:"unknownSound"`
CriticalSound string `json:"criticalSound"`
MuteAlerts bool `json:"muteAlerts"`
}
//Rect helper struct for positions
type Rect struct {
X float64 `json:"x"`
Y float64 `json:"y"`
W float64 `json:"w"`
H float64 `json:"h"`
}
func titleToSlug(title string) string {
title = strings.ToLower(title) //convert upper case to lower case
title = strings.TrimSpace(title) //remove preceeding and trailing whitespace
dashSpaceMatch := regexp.MustCompile(`[_\s]`) //convert spaces and underscores to dashes
title = dashSpaceMatch.ReplaceAllString(title, "-")
unwantedMatch := regexp.MustCompile(`[^a-z0-9\-]`) //Remove any other characters
title = unwantedMatch.ReplaceAllString(title, "")
return title
}
func arrayContains(array []string, value string) bool {
for _, v := range array {
if v == value {
return true
}
}
return false
}
func slugFromFileName(fileName string) string {
return strings.TrimSuffix(fileName, filepath.Ext(fileName))
}
func handleListDashboards(w http.ResponseWriter, r *http.Request) {
files, err := ioutil.ReadDir("dashboards")
if err != nil {
log.Printf("Failed to read directory: %w", err.Error())
http.Error(w, "Failed to read directory: "+err.Error(), http.StatusInternalServerError)
return
}
var dashboards = []Dashboard{}
tagParam := r.URL.Query().Get("tag")
for _, f := range files {
if strings.HasSuffix(f.Name(), ".json") {
data, err := ioutil.ReadFile(path.Join("dashboards", f.Name()))
if err != nil {
log.Printf("Error reading file contents: %w", err.Error())
http.Error(w, "Error reading file contents: "+err.Error(), http.StatusInternalServerError)
return
}
var dashboard Dashboard
err = json.Unmarshal(data, &dashboard)
//skip files with invalid json
if err != nil {
log.Printf("Invalid dashboard file %s: %w", f.Name(), err)
http.Error(w, "Invalid file: "+err.Error(), http.StatusInternalServerError)
continue
}
dashboard.Slug = slugFromFileName(f.Name())
if tagParam != "" {
if arrayContains(dashboard.Tags, tagParam) {
dashboards = append(dashboards, dashboard)
}
} else {
dashboards = append(dashboards, dashboard)
}
}
}
enc := json.NewEncoder(w)
err = enc.Encode(dashboards)
if err != nil {
log.Printf("Error encoding response: %s\n", err)
}
}
func handleListDashboard(w http.ResponseWriter, r *http.Request) {
slug := chi.URLParam(r, "slug")
//Check dashboard exists
if f, err := os.Open(path.Join("dashboards", slug+".json")); os.IsNotExist(err) {
http.Error(w, "Not found", http.StatusNotFound)
return
} else if err != nil {
log.Printf("Error checking that file exists: %w", err.Error())
http.Error(w, "Error checking file exists: "+err.Error(), http.StatusInternalServerError)
return
} else {
w.Header().Add("content-type", "application/json")
defer f.Close()
_, err = io.Copy(w, f)
if err != nil {
log.Printf("Error writing response: %w", err.Error())
http.Error(w, "Error writing response: "+err.Error(), http.StatusInternalServerError)
return
}
}
}
//SlugResponse contains the slug for the client to route to
type SlugResponse struct {
Slug string `json:"slug"`
}
func handleCreateDashboard(w http.ResponseWriter, r *http.Request) {
//Decode body
buf := new(bytes.Buffer)
buf.ReadFrom(r.Body)
var dashboard Dashboard
err := json.Unmarshal(buf.Bytes(), &dashboard)
if err != nil {
log.Printf("JSON body decode failure: %w", err.Error())
http.Error(w, "Error decoding json body: "+err.Error(), http.StatusBadRequest)
return
}
//Conver title to slug
slug := titleToSlug(dashboard.Title)
if len(slug) < 1 {
log.Printf("Slugless URL")
http.Error(w, "Generated URL must be atleast one character", http.StatusBadRequest)
return
}
outputFile := path.Join("dashboards", slug+".json")
//Check dashboard exists
if _, err := os.Stat(outputFile); os.IsNotExist(err) {
// path/to/whatever does not exist
err = ioutil.WriteFile(outputFile, buf.Bytes(), 0655)
if err != nil {
log.Printf("Error writing file: %w", err.Error())
http.Error(w, "Error writing file: "+err.Error(), http.StatusInternalServerError)
return
}
} else if err != nil {
log.Printf("Error checking file exists: %w", err.Error())
http.Error(w, "Error checking file exists: "+err.Error(), http.StatusInternalServerError)
return
}
//Return slug
enc := json.NewEncoder(w)
enc.Encode(SlugResponse{Slug: slug})
}
func trimFirstRune(s string) string {
_, i := utf8.DecodeRuneInString(s)
return s[i:]
}
func handleUpdateDashboard(w http.ResponseWriter, r *http.Request) {
slug := chi.URLParam(r, "slug")
//Decode body
buf := new(bytes.Buffer)
buf.ReadFrom(r.Body)
var dashboard Dashboard
err := json.Unmarshal(buf.Bytes(), &dashboard)
width, height := getImageDimension(trimFirstRune(dashboard.Background))
dashboard.Height = strconv.Itoa(height)
dashboard.Width = strconv.Itoa(width)
// fmt.Println(width, height)
if err != nil {
log.Printf("JSON decode failure: %w", err.Error())
http.Error(w, "Error decoding json body: "+err.Error(), http.StatusBadRequest)
return
}
//Check dashboard exists
if _, err := os.Stat(path.Join("dashboards", slug+".json")); os.IsNotExist(err) {
http.Error(w, "Not found", http.StatusNotFound)
return
}
//Convert title to slug
slugNew := titleToSlug(dashboard.Title)
if len(slug) < 1 {
log.Printf("Slugless URL")
http.Error(w, "Generated URL must be atleast one character", http.StatusBadRequest)
return
}
//Write updated file
err = ioutil.WriteFile(path.Join("dashboards", slugNew+".json"), buf.Bytes(), 0655)
if err != nil {
log.Printf("Error writing file: %w", err.Error())
http.Error(w, "Error writing file: "+err.Error(), http.StatusInternalServerError)
return
}
//Delete old file if slug updated
if slug != slugNew {
fmt.Printf("Slug updated %s -> %s deleting old data\n", slug, slugNew)
err := os.Remove(path.Join("dashboards", slug+".json"))
if err != nil {
log.Printf("Failed to remove old file: %w", err.Error())
http.Error(w, "Failed to remove old file: "+err.Error(), http.StatusInternalServerError)
return
}
}
//Write slug to response so we can route to it
enc := json.NewEncoder(w)
enc.Encode(SlugResponse{Slug: slugNew})
}
func handleDeleteDashboard(w http.ResponseWriter, r *http.Request) {
slug := chi.URLParam(r, "slug")
//Check dashboard exists
if _, err := os.Stat(path.Join("dashboards", slug+".json")); os.IsNotExist(err) {
http.Error(w, "Not found", http.StatusNotFound)
return
}
fmt.Printf("Deleting dashbaord %s\n", slug)
err := os.Remove(path.Join("dashboards", slug+".json"))
if err != nil {
log.Printf("Failed to remove old file: %w", err.Error())
http.Error(w, "Failed to remove old file: "+err.Error(), http.StatusInternalServerError)
return
}
}
module gitlab.sol1.net/SOL1/meerkat
go 1.13
require (
github.com/BurntSushi/toml v0.3.1
github.com/go-chi/chi v4.1.1+incompatible
golang.org/x/net v0.0.0-20200528225125-3c3fba18258b // indirect
)
github.com/BurntSushi/toml v0.3.1 h1:WXkYYl6Yr3qBf1K79EBnL4mak0OimBfB0XUf9Vl28OQ=
github.com/BurntSushi/toml v0.3.1/go.mod h1:xHWCNGjB5oqiDr8zfno3MHue2Ht5sIBksp03qcyfWMU=
github.com/go-chi/chi v4.1.1+incompatible h1:MmTgB0R8Bt/jccxp+t6S/1VGIKdJw5J74CK/c9tTfA4=
github.com/go-chi/chi v4.1.1+incompatible/go.mod h1:eB3wogJHnLi3x/kFX2A+IbTBlXxmMeXJVKy9tTv1XzQ=
golang.org/x/crypto v0.0.0-20190308221718-c2843e01d9a2/go.mod h1:djNgcEr1/C05ACkg1iLfiJU5Ep61QUkGW8qpdssI0+w=
golang.org/x/net v0.0.0-20200528225125-3c3fba18258b h1:IYiJPiJfzktmDAO1HQiwjMjwjlYKHAL7KzeD544RJPs=
golang.org/x/net v0.0.0-20200528225125-3c3fba18258b/go.mod h1:qpuaurCH72eLCgpAm/N6yyVIVM9cpaDIP3A8BGJEC5A=
golang.org/x/sys v0.0.0-20190215142949-d0b11bdaac8a/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY=
golang.org/x/sys v0.0.0-20200323222414-85ca7c5b95cd/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
golang.org/x/text v0.3.0 h1:g61tztE5qeGQ89tm6NTjjM9VPIm088od1l6aSorWRWg=
golang.org/x/text v0.3.0/go.mod h1:NqM8EUOU14njkJ3fqMW+pc6Ldnwhi/IjpwHt7yyuwOQ=
package main
import (
"crypto/tls"
"encoding/json"
"fmt"
"log"
"net/http"
"net/url"
"path"
"strings"
"github.com/go-chi/chi"
)
type icingaAPIResults struct {
Results []icingaAPIResult `json:"results"`
}
type icingaAPIResult struct {
Attributes icingaAttributes `json:"attrs"`
Name string `json:"name"`
Type string `json:"type"`
}
type icingaAttributes struct {
Name string `json:"name"`
HostName string `json:"host_name"`
DisplayName string `json:"display_name"`
Command string `json:"check_command"`
State float32 `json:"state"`
Interval float32 `json:"check_interval"`
Groups []string `json:"groups"`
}
func (ir *icingaAPIResult) toIcingaObject() icingaObject {
return icingaObject{
ID: ir.Name,
Type: ir.Type,
Name: ir.Attributes.Name,
HostName: ir.Attributes.HostName,
DisplayName: ir.Attributes.DisplayName,
Command: ir.Attributes.Command,
State: ir.Attributes.State,
Interval: ir.Attributes.Interval,
Groups: ir.Attributes.Groups,
}
}
type icingaObject struct {
ID string `json:"id"`
Type string `json:"type"`
Name string `json:"name"`
HostName string `json:"hostName"`
DisplayName string `json:"displayName"`
Command string `json:"checkCommand"`
State float32 `json:"state"`
Interval float32 `json:"checkInterval"`
Groups []string `json:"groups"`
}
func handleIcingaCheckState(w http.ResponseWriter, r *http.Request) {
object_type := r.URL.Query().Get("object_type")
filter := r.URL.Query().Get("filter")
if object_type == "" {
http.Error(w, "No object type specified", http.StatusBadRequest)
return
}
if filter == "" {
http.Error(w, "No filter specified", http.StatusBadRequest)
return
}
//We only support looking up hosts and services
if object_type != "host" && object_type != "service" {
http.Error(w, "Invalid object type", http.StatusBadRequest)
return
}
client := &http.Client{}
//Disable TLS verification if config says so
if config.IcingaInsecureTLS {
client.Transport = &http.Transport{
TLSClientConfig: &tls.Config{InsecureSkipVerify: true},
}
}
//Create HTTP request
req_url, err := url.Parse(config.IcingaURL)
if err != nil {
log.Printf("Failed to parse IcingaURL: %w", err)
http.Error(w, "Server Error", http.StatusInternalServerError)
return
}
req_url.Path = path.Join(req_url.Path, "/v1/objects", object_type + "s")
req_url.RawQuery = strings.ReplaceAll(url.Values{"filter": []string{filter}}.Encode(), "+", "%20")
req, err := http.NewRequest("GET", req_url.String(), nil)
if err != nil {
log.Printf("Failed to create HTTP request: %w", err)
http.Error(w, "Error creating http request: "+err.Error(), http.StatusInternalServerError)
return
}
req.SetBasicAuth(config.IcingaUsername, config.IcingaPassword)
//Make request
res, err := client.Do(req)
if err != nil {
log.Printf("Icinga2 API error: %w", err.Error())
http.Error(w, "Icinga2 API error: "+err.Error(), http.StatusInternalServerError)
return
}
defer res.Body.Close()
var results icingaAPIResults
dec := json.NewDecoder(res.Body)
err = dec.Decode(&results)
if err != nil {
log.Printf("Error decoding Icinga2 API response: %w", err)
http.Error(w, "Error decoding Icinga2 API response: "+err.Error(), http.StatusInternalServerError)
return
}
if len(results.Results) == 0 {
http.Error(w, fmt.Sprintf("No %s objects matched filter %s", object_type, filter), http.StatusBadRequest)
return
}
max_state := int64(0)
for _, obj := range results.Results {
if int64(obj.Attributes.State) > max_state {
max_state = int64(obj.Attributes.State)
}
}
enc := json.NewEncoder(w)
enc.Encode(max_state)
}
func handleIcingaCheck(w http.ResponseWriter, r *http.Request) {
checkType := chi.URLParam(r, "check-type")
objectID := chi.URLParam(r, "object-id")
//We only support looking up hosts and services
if checkType != "hosts" && checkType != "services" && checkType != "hostgroups" && checkType != "servicegroups" {
http.Error(w, "Endpoint must be 'hosts' or 'services'", http.StatusBadRequest)
return
}
client := &http.Client{}
//Disable TLS verification if config says so
if config.IcingaInsecureTLS {
client.Transport = &http.Transport{
TLSClientConfig: &tls.Config{InsecureSkipVerify: true},
}
}
//Create HTTP request
req_url, err := url.Parse(config.IcingaURL)
if err != nil {
log.Printf("Failed to parse IcingaURL: %w", err)
http.Error(w, "Server Error", http.StatusInternalServerError)
return
}
req_url.Path = path.Join(req_url.Path, "/v1/objects", checkType)
if objectID != "" {
req_url.Path = path.Join(req_url.Path, objectID)
}
if filter := r.URL.Query().Get("filter"); filter != "" {
req_url.RawQuery = strings.ReplaceAll(url.Values{"filter": []string{filter}}.Encode(), "+", "%20")
}
log.Printf("Requesting %s", req_url.String())
req, err := http.NewRequest("GET", req_url.String(), nil)
if err != nil {
log.Printf("Failed to create HTTP request: %w", err)
http.Error(w, "Error creating http request: "+err.Error(), http.StatusInternalServerError)
return