From 64f7a2a9bf9d58c0ef0541bd0382b9598b73c2cb Mon Sep 17 00:00:00 2001 From: "PAEPCKE, Michael" Date: Mon, 9 Dec 2024 11:07:06 +0000 Subject: [PATCH] add: unifi backup frontend, fix backend symlink bug --- README.md | 5 +- api.go | 12 +-- example-env-config-unifi.sh | 3 +- httpd-handler.go | 16 ++-- httpd-ui.go | 4 +- setup.go | 78 +++++++++++++++---- srv.go | 92 +++++++++++----------- srvUnifi.go | 147 ++++++++++++++++++++++++------------ status.go | 34 ++++++++- store.go | 22 +++--- 10 files changed, 275 insertions(+), 138 deletions(-) diff --git a/README.md b/README.md index 109c6ca..cda3e20 100644 --- a/README.md +++ b/README.md @@ -108,7 +108,7 @@ see opnborg-prometheus-grafana.nix - OPN_APISECRET - OPNsense Backup User APISECRET [string, base64 encoded] - OPN_TARGETS - list of OPNSense Target Server to Backup [string, hostnames, comma separated] - OPN_TARGETS_* - alternative: custom groups for OPNSense Target server [example: OPN_TARGETS_INTRANET="opn-int-01.lan:8443,..."] -- OPN_TARGETS_IMGURL_* - alternative: custom image url for customs groups within WebUI [example: OPN_TARGETS_IMG_INTRANET="https://paepcke.de/img/intra.png"] +- OPN_TARGETS_IMGURL_* - alternative: custom image url for customs groups within WebUI [example: OPN_TARGETS_IMGURL_INTRANET="https://paepcke.de/img/intra.png"] # Optional - OPN_PATH - specify a target path (absolut or releative) to store backups [string: defaults to '.'] @@ -143,6 +143,9 @@ see opnborg-prometheus-grafana.nix # Unifi - OPN_UNIFI_WEBUI - Unifi Web Console target & port [example: http://localhost:8444] +- OPN_UNIFI_BACKUP_USER - Unifi Backup User Account +- OPN_UNIFI_BACKUP_SECRET - Unifi Backup User Account Password +- OPN_UNIFI_BACKUP_IMGURL - Unifi Backup Group Image URL [example: OPN_UNIFI_BACKUP_IMGURL="https://paepcke.de/img/unifi.png"] # Wazuh - OPN_WAZUH_WEBUI - Wazuh Web Console target & port [example: http://localhost:8446] diff --git a/api.go b/api.go index cdbcfcd..031ea5f 100644 --- a/api.go +++ b/api.go @@ -2,12 +2,11 @@ package opnborg import ( "net/url" - "sync" "sync/atomic" ) // global exported consts -const SemVer = "v0.1.44" +const SemVer = "v0.1.45" // global var var ( @@ -21,6 +20,8 @@ var ( // OPNGroup Type type OPNGroup struct { Name string // group name + OPN bool // is OPNsense Appliance + Unifi bool // is Unifi Controller Img bool // group image available ImgURL string // group image url Member []string // group member @@ -28,6 +29,7 @@ type OPNGroup struct { // OPNCall type OPNCall struct { + Enable bool // enable OPNsense Backup mode Targets string // list of OPNSense Appliances, csv comma seperated TGroups []OPNGroup // list of OPNSense Appliances Target Groups and Member Key string // OPNSense Backup User API Key (required) @@ -89,12 +91,6 @@ type OPNCall struct { } } -// global -var hive []string -var hiveMutex sync.Mutex -var updateOPN = make(chan bool, 1) -var updateUnifi = make(chan bool, 1) - // Start Application Server func Start(config *OPNCall) error { return srv(config) diff --git a/example-env-config-unifi.sh b/example-env-config-unifi.sh index 5efad9f..bb285c1 100644 --- a/example-env-config-unifi.sh +++ b/example-env-config-unifi.sh @@ -14,8 +14,6 @@ export OPN_SLEEP='60' export OPN_DEBUG='1' export OPN_SYNC_PKG='1' export OPN_HTTPD_SERVER='127.0.0.1:6464' -export OPN_RSYSLOG_ENABLE='1' -export OPN_RSYSLOG_SERVER='192.168.122.1:5140' export OPN_GRAFANA_WEBUI='http://localhost:9090' export OPN_GRAFANA_DASHBOARD_FREEBSD='Kczn-jPZz/node-exporter-freebsd' export OPN_GRAFANA_DASHBOARD_HAPROXY='rEqu1u5ue/haproxy-2-full' @@ -26,3 +24,4 @@ export OPN_UNIFI_WEBUI='https://localhost:8443' export OPN_UNIFI_VERSION='8.5.6' export OPN_UNIFI_BACKUP_USER='admin' export OPN_UNIFI_BACKUP_SECRET='start' +# export OPN_UNIFI_BACKUP_IMGURL='https://paepcke.de/res/uni.png' diff --git a/httpd-handler.go b/httpd-handler.go index 76e48ea..afad11b 100644 --- a/httpd-handler.go +++ b/httpd-handler.go @@ -120,13 +120,18 @@ func getHive() string { s.WriteString(_lf) for _, srv := range grp.Member { s.WriteString(" ") - for _, line := range hive { - if strings.Contains(line, srv) { - s.WriteString(line) - break + if grp.OPN { + for _, line := range hive { + if strings.Contains(line, srv) { + s.WriteString(line) + break + } } } - s.WriteString(" ") + if grp.Unifi { + s.WriteString(unifiStatus) + } + s.WriteString(" ") s.WriteString(_lf) } s.WriteString(" ") @@ -183,6 +188,7 @@ func getNavi() string { s.WriteString("> ") } if unifiWebUI != nil { + // if unifiWebUI != nil && !unifiEnable.Load() { s.WriteString(" DEGRADED` - // _ui = `Ubiquiti` - - // _gitLogLink = "BorgAUDIT
[ Module:Changelog:Active ]

" + _unifi = `` ) diff --git a/setup.go b/setup.go index beeb294..288e3f0 100644 --- a/setup.go +++ b/setup.go @@ -3,11 +3,22 @@ package opnborg import ( "errors" "fmt" + "net/url" "os" "path/filepath" "sort" "strconv" "strings" + "sync" +) + +// global +var ( + hive []string + hiveMutex, unifiMutex sync.Mutex + updateOPN = make(chan bool, 1) + updateUnifi = make(chan bool, 1) + unifiStatus string ) // Setup reads OPNBorgs configuration via env, sanitizes, sets sane defaults @@ -16,13 +27,9 @@ func Setup() (*OPNCall, error) { // var var err error - // check if setup requirements are meet - if err = checkSetRequired(); err != nil { - return nil, err - } - // setup from env config := &OPNCall{ + Enable: checkSetRequiredOPN(), Targets: os.Getenv("OPN_TARGETS"), Key: os.Getenv("OPN_APIKEY"), Secret: os.Getenv("OPN_APISECRET"), @@ -31,6 +38,12 @@ func Setup() (*OPNCall, error) { Email: os.Getenv("OPN_EMAIL"), } + // check if we meet basic requirements + config.Unifi.Backup.Enable = checkSetRequiredUnifi() + if !config.Enable && !config.Unifi.Backup.Enable { + return nil, errors.New("Please enable either OPN or Unifi backup. Please set OPN_APIKEY & OPN_APISECRET or OPN_UNIFI_BACKUP_USER & SECRET") + } + // setup app name if config.AppName == "" { config.AppName = "[OPNBORG-API]" @@ -199,16 +212,13 @@ func Setup() (*OPNCall, error) { } -// checkRequired env input -func checkSetRequired() error { +// checkRequired OPN env +func checkSetRequiredOPN() bool { - if !isEnv("OPN_APIKEY") { - return fmt.Errorf("set env variable 'OPN_APIKEY' to your opnsense api key") + if !isEnv("OPN_APIKEY") || !isEnv("OPN_APISECRET") { + return false } - if !isEnv("OPN_APISECRET") { - return fmt.Errorf("set env variable 'OPN_APISECRET' to your opnsense api key secret") - } if !isEnv("OPN_TARGETS") { member := "" env := os.Environ() @@ -229,6 +239,8 @@ func checkSetRequired() error { tg = append(tg, OPNGroup{ Name: grp[0][12:], Img: true, + OPN: true, + Unifi: false, ImgURL: os.Getenv("OPN_TARGETS_IMGURL_" + grp[0][12:]), Member: strings.Split(grp[1], ","), }) @@ -236,6 +248,8 @@ func checkSetRequired() error { tg = append(tg, OPNGroup{ Name: grp[0][12:], Img: false, + OPN: true, + Unifi: false, Member: strings.Split(grp[1], ","), }) } @@ -244,11 +258,45 @@ func checkSetRequired() error { } if len(member) > 0 { os.Setenv("OPN_TARGETS", member) - return nil + return true } } - return fmt.Errorf("add at least one target server to env var 'OPN_TARGETS' or 'OPN_TARGETS_* '(multi valued, comma seperated)") + return false } tg = append(tg, OPNGroup{Name: "", Member: strings.Split(os.Getenv("OPN_TARGETS"), ",")}) - return nil + return true +} + +// checkRequired Unifi env +func checkSetRequiredUnifi() bool { + + unifiURL, err := url.Parse(os.Getenv("OPN_UNIFI_WEBUI")) + if err != nil { + return false // detailed checks & err analysis later + } + + if !isEnv("OPN_UNIFI_BACKUP_USER") || !isEnv("OPN_UNIFI_BACKUP_SECRET") { + return false + } + + // add unifi group + if isEnv("OPN_UNIFI_BACKUP_IMGURL") { + tg = append(tg, OPNGroup{ + Name: "UNIFI CONTROLLER", + Img: true, + OPN: false, + Unifi: true, + ImgURL: os.Getenv("OPN_UNIFI_BACKUP_IMGURL"), + Member: strings.Split(unifiURL.Hostname(), ","), + }) + } else { + tg = append(tg, OPNGroup{ + Name: "UNIFI CONTROLLER", + Img: false, + OPN: false, + Unifi: true, + Member: strings.Split(unifiURL.Hostname(), ","), + }) + } + return true } diff --git a/srv.go b/srv.go index a9005b8..7c1629d 100644 --- a/srv.go +++ b/srv.go @@ -9,6 +9,7 @@ import ( func srv(config *OPNCall) error { // init var err error + var servers []string // spin up Log/Display Engine display.Add(1) @@ -16,6 +17,22 @@ func srv(config *OPNCall) error { // spin up internal log / display engine go startLog(config) + // startup app version & state, sleep panic gate + suffix := "[CLI-ONE-TIME-PASS-MODE]" + if config.Daemon { + suffix = "[DAEMON-MODE][SLEEP:" + sleep + " SECONDS]" + } + displayChan <- []byte("[STARTING][" + _app + "][" + SemVer + "]" + suffix) + + // arm background timer + go func() { + time.Sleep(time.Duration(config.Sleep) * time.Second) + updateOPN <- true + if unifiEnable.Load() { + updateUnifi <- true + } + }() + // spin up internal webserver state := "[DISABLED]" if config.Httpd.Enable { @@ -35,63 +52,54 @@ func srv(config *OPNCall) error { // spin up unifi backup server state = "[DISABLED]" if config.Unifi.Backup.Enable { + unifiStatus = _na + " Member: " + config.Unifi.WebUI.String() + " Version: n/a Last Seen: n/a
" go unifiBackupServer(config) state = "[ENABLED]" } displayChan <- []byte("[SERVICE][UNIFI-BACKUP]" + state) - // setup hive - servers := strings.Split(config.Targets, ",") - for _, server := range servers { - status := _na + " Member: " + server + " Version: n/a Last Seen: n/a
" - hive = append(hive, status) - } - - // startup app version & state, sleep panic gate - suffix := "[CLI-ONE-TIME-PASS-MODE]" - if config.Daemon { - suffix = "[DAEMON-MODE][SLEEP:" + sleep + " SECONDS]" - } - displayChan <- []byte("[STARTING][" + _app + "][" + SemVer + "]" + suffix) - - // spin up timer - go func() { - time.Sleep(time.Duration(config.Sleep) * time.Second) - updateOPN <- true - if unifiEnable.Load() { - updateUnifi <- true + // is opnsense hive is enabled? + if config.Enable { + // setup hive + servers := strings.Split(config.Targets, ",") + for _, server := range servers { + status := _na + " Member: " + server + " Version: n/a Last Seen: n/a
" + hive = append(hive, status) } - }() + } // main loop for { - - // fetch target configuration from master server - if config.Sync.Enable { - config.Sync.validConf = true - config, err = readMasterConf(config) - if err != nil { - config.Sync.validConf = false - displayChan <- []byte("[ERROR][UNABLE-TO-READ-MASTER-CONFIG]" + err.Error()) - } - } - // reset global (atomic) git worktree state tracker if config.Git { config.dirty.Store(false) } - // spinup individual worker for every server - if config.Debug { - displayChan <- []byte("[STARTING][BACKUP]") - } - for id, server := range servers { - wg.Add(1) - go actionOPN(server, config, id, &wg) - } + // is opnsense hive is enabled + if config.Enable { - // wait till all worker done - wg.Wait() + // fetch target configuration from master server + if config.Sync.Enable { + config.Sync.validConf = true + config, err = readMasterConf(config) + if err != nil { + config.Sync.validConf = false + displayChan <- []byte("[ERROR][UNABLE-TO-READ-MASTER-CONFIG]" + err.Error()) + } + } + + // spinup individual worker for every server + if config.Debug { + displayChan <- []byte("[STARTING][BACKUP]") + } + for id, server := range servers { + wg.Add(1) + go actionOPN(server, config, id, &wg) + } + + // wait till all worker done + wg.Wait() + } // check files into local git repo if config.dirty.Load() { diff --git a/srvUnifi.go b/srvUnifi.go index 2df489a..3a16ac1 100644 --- a/srvUnifi.go +++ b/srvUnifi.go @@ -54,83 +54,130 @@ func unifiBackupServer(config *OPNCall) { // init ts := time.Now() - isReachable := true + isReachable, backupOK, notice := true, false, "" // enfore init backup unifiBackupNow.Store(true) // loop for { - // reset - isReachable = true + // reset default state + isReachable, backupOK, notice = true, false, "status:ok" - // perform actual login + // perform authentication res, err := client.Post(config.Unifi.WebUI.String()+"/api/login", "application/json", bytes.NewBuffer(postLogin)) if err != nil { isReachable = false - displayChan <- []byte("[UNIFI][BACKUP][ERROR][UNABLE-TO-AUTENTHICATE]" + err.Error()) - } - if res.StatusCode != 200 { - isReachable = false - body, _ := ioutil.ReadAll(res.Body) - displayChan <- []byte("[UNIFI][BACKUP][ERROR][UNABLE-TO-AUTENTHICATE][BODY] ") - displayChan <- body + notice = "[UNIFI][BACKUP][ERROR][UNABLE-TO-AUTENTHICATE]" + err.Error() + displayChan <- []byte(notice) } - // perform actual system reachable test - res, err = client.Post(config.Unifi.WebUI.String()+"/api/s/default/cmd/system", "application/json", bytes.NewBuffer(postSystem)) - if err != nil { - isReachable = false - displayChan <- []byte("[UNIFI][BACKUP][ERROR][CONFIG-DOWNLOAD-FAIL] " + err.Error()) - } - if res.StatusCode != 200 { - isReachable = false - body, _ := ioutil.ReadAll(res.Body) - displayChan <- []byte("[UNIFI][BACKUP][ERROR][CONFIG-DOWNLOAD-FAIL][BODY] ") - displayChan <- body - } + // was authentication ok? + if isReachable { - // isReachable && last backup > 6 hours - if isReachable && time.Now().Sub(ts) < time.Duration(6*time.Hour) { - unifiBackupNow.Store(true) - } + // check http status code + if res.StatusCode != 200 { + isReachable = false + body, _ := ioutil.ReadAll(res.Body) + notice = "[UNIFI][BACKUP][ERROR][UNABLE-TO-AUTENTHICATE][BODY] " + displayChan <- []byte(notice) + displayChan <- body + } - // perform backup - if unifiBackupNow.Load() { + // was authentication and status code ok? + if isReachable { - // reset unifiBackupNow - unifiBackupNow.Store(false) + // perform actual fetch test + res, err = client.Post(config.Unifi.WebUI.String()+"/api/s/default/cmd/system", "application/json", bytes.NewBuffer(postSystem)) + if err != nil { + isReachable = false + notice = "[UNIFI][BACKUP][ERROR][CONFIG-DOWNLOAD-FAIL] " + err.Error() + displayChan <- []byte(notice) + } + if isReachable { + // was fetch sucessfull, check http code + if res.StatusCode != 200 { + isReachable = false + notice = "[UNIFI][BACKUP][ERROR][CONFIG-DOWNLOAD-FAIL][BODY] " + body, _ := ioutil.ReadAll(res.Body) + displayChan <- []byte(notice) + displayChan <- body + + } + } + } + } - // update timestamp - ts = time.Now() + // if reachable, proceed with backup + if isReachable { - // download backup file - res, err = client.Get(config.Unifi.WebUI.String() + "/dl/backup/" + config.Unifi.Version + ".unf") - if err != nil { - displayChan <- []byte("[UNIFI][BACKUP][ERROR][BACKUP-DOWNLOAD-FILE-HEAD-FAIL] " + err.Error()) + // if last backup > 6 hours + if time.Now().Sub(ts) < time.Duration(6*time.Hour) { + unifiBackupNow.Store(true) } - defer res.Body.Close() - // write file - if err == nil { + // perform backup + if unifiBackupNow.Load() { + + // reset unifiBackupNow + unifiBackupNow.Store(false) + + // update timestamp + ts = time.Now() - // read body - unf, err := io.ReadAll(res.Body) + // setup + backupOK = true + + // download backup file + res, err = client.Get(config.Unifi.WebUI.String() + "/dl/backup/" + config.Unifi.Version + ".unf") if err != nil { - displayChan <- []byte("[UNIFI][BACKUP][ERROR][BACKUP-DOWNLOAD-FILE-BODY-FAIL] " + err.Error()) + backupOK = false + notice = "[UNIFI][BACKUP][ERROR][BACKUP-DOWNLOAD-FILE-HEAD-FAIL] " + err.Error() + displayChan <- []byte(notice) } + defer res.Body.Close() + + // proceed + if backupOK { + + // read body + unf, err := io.ReadAll(res.Body) + if err != nil { + backupOK = false + notice = "[UNIFI][BACKUP][ERROR][BACKUP-DOWNLOAD-FILE-BODY-FAIL] " + err.Error() + displayChan <- []byte(notice) + } - // check into store - if err == nil { - checkIntoStore(config, "unifi-"+config.Unifi.WebUI.Hostname(), "unf", unf, ts, sha256.Sum256(unf)) - displayChan <- []byte("[UNIFI][BACKUP][SUCCESSFUL]") + // check file + if backupOK { + if len(unf) < 1024 { + backupOK = false + notice = "[UNIFI][BACKUP][ERROR][BACKUP-DOWNLOAD-FILE-TO-SMALL] " + err.Error() + displayChan <- []byte(notice) + } - // flag git store as dirty - config.dirty.Store(true) + // check into store + if backupOK { + + // check into store + checkIntoStore(config, config.Unifi.WebUI.Hostname(), "unf", unf, ts, sha256.Sum256(unf)) + + // flag git store as dirty + config.dirty.Store(true) + + // notify + displayChan <- []byte("[UNIFI][BACKUP][SUCCESSFUL]") + + } + } } + displayChan <- []byte("[UNIFI][BACKUP][END]") } - displayChan <- []byte("[UNIFI][BACKUP][FINISH]") } + + // set unifi status + setUnifiStatus(config, time.Now(), notice, isReachable, backupOK) + <-updateUnifi } } diff --git a/status.go b/status.go index 2dbd08e..e14667d 100644 --- a/status.go +++ b/status.go @@ -16,7 +16,7 @@ const ( _nwin = "target=\"_blank\"" ) -// setOPNStatus +// setOPNStatus sets the hive member server status func setOPNStatus(config *OPNCall, server string, id int, ts time.Time, notice string, degraded, ok bool) { year, month, _ := ts.Date() archive := filepath.Join(_archive, strconv.Itoa(year), padMonth(strconv.Itoa(int(month)))) @@ -43,9 +43,39 @@ func setOPNStatus(config *OPNCall, server string, id int, ts time.Time, notice s return } hiveMutex.Lock() + defer hiveMutex.Unlock() status := hive[id] status = strings.ReplaceAll(strings.ReplaceAll(strings.ReplaceAll(strings.ReplaceAll(status, _ok, ""), _na, ""), _fail, ""), _degraded, "") status = _fail + status hive[id] = status - hiveMutex.Unlock() +} + +// setUnifiStatus +func setUnifiStatus(config *OPNCall, ts time.Time, notice string, responsive, backup bool) { + // lock + unifiMutex.Lock() + defer unifiMutex.Unlock() + + // clean status + unifiStatus = strings.ReplaceAll(strings.ReplaceAll(strings.ReplaceAll(strings.ReplaceAll(unifiStatus, _unifi, ""), _na, ""), _fail, ""), _degraded, "") + + // setup + server := config.Unifi.WebUI.Hostname() + year, month, _ := ts.Date() + archive := filepath.Join(_archive, strconv.Itoa(year), padMonth(strconv.Itoa(int(month)))) + + if responsive { + state := _unifi + seen := ts.Format(time.RFC3339) + linkUI := " " + linkCurrent := "" + linkArchive := "" + links := linkCurrent + " " + linkArchive + if !backup { + state = _degraded + } + unifiStatus = state + _b + linkUI + _b + " " + links + "
" + return + } + unifiStatus = _fail + unifiStatus } diff --git a/store.go b/store.go index 5041367..4a62456 100644 --- a/store.go +++ b/store.go @@ -34,7 +34,8 @@ func checkIntoStore(config *OPNCall, server, ext string, serverXML []byte, ts ti // prep storage ext = "." + ext - current := "current" + ext + + //current := "current" + ext year, month, _ := ts.Date() // create store structure @@ -52,18 +53,19 @@ func checkIntoStore(config *OPNCall, server, ext string, serverXML []byte, ts ti return err } - // remove pre-existing last symlink (if any exist) - _ = os.Remove(current) - - // write server XML file(s) + // write archive file name := ts.UTC().Format("20060102T150405Z") + "-" + server + ext archiveFile := filepath.Join(store, name) - if err := os.WriteFile(current, serverXML, 0660); err != nil { - displayChan <- []byte("[BACKUP][ERROR][FAIL:UNABLE-TO-CREATE-CURRENTFILE] " + server) + if err := os.WriteFile(archiveFile, serverXML, 0660); err != nil { + displayChan <- []byte("[BACKUP][ERROR][FAIL:UNABLE-TO-CREATE-ARCHIVE-FILE] " + server) return err } - if err := os.WriteFile(archiveFile, serverXML, 0660); err != nil { - displayChan <- []byte("[BACKUP][ERROR][FAIL:UNABLE-TO-CREATE-FILE] " + archiveFile) + + // remove pre-existing current.xml file & write again + file := "current" + ext + _ = os.Remove(file) + if err := os.WriteFile(file, serverXML, 0660); err != nil { + displayChan <- []byte("[BACKUP][ERROR][FAIL:UNABLE-TO-CREATE-CURRENT-FILE] " + archiveFile) return err } @@ -87,7 +89,7 @@ func checkIntoStore(config *OPNCall, server, ext string, serverXML []byte, ts ti _ = os.Remove(_last) // rename current link pointer to last (if any exist) - _ = os.Rename(current, _last) + _ = os.Rename(_current, _last) // write current symlink pointer if err = os.Symlink(archiveFile, _current); err != nil {