// Copyright 2023 wanderer // SPDX-License-Identifier: AGPL-3.0-only package main import ( "context" "flag" "fmt" "net/http" "os" "os/signal" "strconv" "sync" "syscall" "time" "golang.org/x/exp/slog" // pure go postgres driver. _ "github.com/lib/pq" // ent pure go sqlite3 driver instead of "github.com/mattn/go-sqlite3". _ "github.com/xiaoqidun/entps" "git.dotya.ml/mirre-mt/pcmt/app" "git.dotya.ml/mirre-mt/pcmt/app/settings" "git.dotya.ml/mirre-mt/pcmt/config" "git.dotya.ml/mirre-mt/pcmt/ent" moddb "git.dotya.ml/mirre-mt/pcmt/modules/db" "git.dotya.ml/mirre-mt/pcmt/modules/hibp" "git.dotya.ml/mirre-mt/pcmt/modules/localbreach" "git.dotya.ml/mirre-mt/pcmt/slogging" ) const ( banner = ` ██████╗ ██████╗███╗ ███╗████████╗ ██╔══██╗██╔════╝████╗ ████║╚══██╔══╝ ██████╔╝██║ ██╔████╔██║ ██║ ██╔═══╝ ██║ ██║╚██╔╝██║ ██║ ██║ ╚██████╗██║ ╚═╝ ██║ ██║ ╚═╝ ╚═════╝╚═╝ ╚═╝ ╚═╝ ` slug = `Password Compromise Monitoring Tool version: '%s' https://git.dotya.ml/mirre-mt/pcmt ____________________________________` licenseHeader = `pcmt - Password Compromise Monitoring Tool Copyright (C) git.dotya.ml/wanderer This program is free software: you can redistribute it and/or modify it under the terms of the GNU Affero General Public License as published by the Free Software Foundation version 3 of the License. This program is distributed in the hope that it will be useful, but WITHOUT ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU Affero General Public License for more details. You should have received a copy of the GNU Affero General Public License along with this program. If not, see .` ) var ( version = "dev" // the global logger. slogger *slogging.Slogger // local logger instance. log slogging.Slogger ) func run() error { //nolint:gocognit flag.Parse() if *licenseFlag { fmt.Fprintln(os.Stderr, licenseHeader) return nil } if *versionFlag { fmt.Fprintf(os.Stderr, "pcmt version %s\n", version) return nil } // skip printing program header and some other stuff. shortCircuit := false var doingImport bool if importFlag != nil && *importFlag != "" { doingImport = true shortCircuit = true config.BeQuiet() } if *printMigrationFlag { shortCircuit = true config.BeQuiet() } if !shortCircuit { printHeader() } slogger = slogging.Logger() if slogger == nil { slogger = slogging.Init(true) } log = *slogger // local copy. log.Logger = log.Logger.With( // local attrs. slog.Group("pcmt extra", slog.String("module", "run")), ) // TODO: allow different configuration formats (toml, ini) // TODO: rename main.go to pcmt.go // TODO: add flake.nix // TODO: SBOM: https://actuated.dev/blog/sbom-in-github-actions // TODO: SBOM: https://www.docker.com/blog/generate-sboms-with-buildkit/ // TODO: integrate with Graylog (https://github.com/samber/slog-graylog). // TODO: add mailer (https://github.com/wneessen/go-mail). // TODO: deploy containers with podman using Ansible (https://www.redhat.com/sysadmin/automate-podman-ansible) // TODO: add health checks for pod's containers (db, app) // load the config if possible, an error could mean multiple things: // * just couldn't read the config // * parsed the config but: // * the schema provided is too new for the app - keep the older schema or update the app // * the schema provided is too old for the app - upgrade the config schema or downgrade the app conf, err := config.Load(*configFlag, *configIsPathFlag) if err != nil { return fmt.Errorf("couldn't load the configuration (isPath: '%t') '%s', full err: %w", *configIsPathFlag, *configFlag, err, ) } setting := settings.New() if !shortCircuit { setting.Consolidate( conf, hostFlag, portFlag, develFlag, version, ) } // expected connstring form for "github.com/xiaoqidun/entps": // "file:ent?mode=memory&cache=shared&_fk=1" // and for the postgres driver "github.com/lib/pq": // "host=127.0.0.1 sslmode=disable port=5432 user=postgres dbname=postgres password=postgres". connstr := os.Getenv("PCMT_CONNSTRING") dbtype := os.Getenv("PCMT_DBTYPE") // check and bail early. switch { case connstr == "" || dbtype == "": log.Errorf("PCMT_CONNSTRING or PCMT_DBTYPE or *both* were UNSET, bailing...") return errDBNotConfigured case dbtype != "postgres" && dbtype != "sqlite3": log.Errorf("unsupported DB type specified, bailing...") return errUnsupportedDBType default: setting.SetDbConnstring(connstr) // type can be one of "postgres" or "sqlite3". setting.SetDbType(dbtype) } log.Infof("connecting to db at '%s'", connstr) db, err := ent.Open(setting.DbType(), setting.DbConnstring()) if err != nil { if doingImport { log.Error("Import was not possible", "error", err) fmt.Fprintf(os.Stdout, "\033[31m✗\033[0m Import was not possible due to an error: %q\n", err) return errImportFailed } log.Errorf("Error while connecting to DB: %q", err) return errDBConnFailed } defer db.Close() ctx := context.WithValue(context.Background(), moddb.CtxKey{}, slogger) if *printMigrationFlag { log.Debug("printing the following migration to stdout") return moddb.PrintMigration(ctx, db) } log.Info("ensuring the db is set up and attempting to automatically migrate db schema") // make sure the database is set up and optionally creates an administrator // user (only when setting up the db). if err = moddb.SetUp(ctx, db, setting.InitCreateAdmin(), setting.InitAdminPassword()); err != nil { return err } setting.SetDbIsSetUp(true) // don't load the app, return early. if doingImport { lb, err := localbreach.Load(*importFlag) if err != nil { return err } if _, err = localbreach.ImportLocalBreach( context.WithValue( context.Background(), localbreach.CtxKey{}, slogger), db, lb, ); err != nil { return err } log.Infof("Import succeeded: %+v", lb) fmt.Fprint(os.Stdout, "\033[32m✓\033[0m Import succeeded \n") return nil } a := &app.App{} if err = a.Init(setting, slogger, db); err != nil { return err } a.PrintConfiguration() a.SetEmbeds(templates, assets) if err = a.SetupRoutes(); err != nil { return err } a.SetServerSettings() if err = setting.EraseENVs(); err != nil { log.Error("failed to erase PCMT ENVs") return err } log.Debug("erased PCMT ENVs") e := a.E() // channel used to check whether the app had troubles starting up. started := make(chan error, 1) go func() { defer close(started) p := setting.Port() h := setting.Host() address := h + ":" + strconv.Itoa(p) if err := e.Start(address); err != nil && err != http.ErrServerClosed { log.Error("troubles running the server, bailing...", "error", err) started <- err return } started <- nil }() quit := make(chan os.Signal, 1) schedQuit := make(chan os.Signal, 1) errCh := make(chan error, 1) wg := &sync.WaitGroup{} handleSigs(schedQuit, quit) go func() { wg.Add(1) // nolint:staticcheck go hibp.RunReqScheduler(schedQuit, errCh, wg) // nolint:wsl }() // non-blocking channel receive. select { // monitor the server start-up chan. case err := <-started: if err != nil { return err } case err := <-errCh: defer func() { signal.Stop(quit) close(quit) close(errCh) }() if err != nil { return errHIBPSchedulerFailed } case <-quit: // after the timeout the server forcefully quits. shutdownTimeout := 10 * time.Second log.Infof("Interrupt received, gracefully shutting down the server (timeout %s)", shutdownTimeout) ctx, cancel := context.WithTimeout(context.Background(), shutdownTimeout) defer func() { cancel() signal.Stop(quit) close(quit) close(errCh) log.Info("Bye!") }() if err = e.Shutdown(ctx); err != nil { log.Error("There was an error shutting the server down") return err } select { case <-ctx.Done(): log.Error("Failed to stop the server in time, yolo") return ctx.Err() default: wg.Wait() } } return nil } // handleSigs configures given chans to be notified on os signals. func handleSigs(chans ...chan os.Signal) { sigs := []os.Signal{os.Interrupt, syscall.SIGHUP, syscall.SIGTERM} for _, ch := range chans { signal.Notify(ch, sigs...) } } func printHeader() { slug := fmt.Sprintf(slug, version) fmt.Fprintf(os.Stderr, "\033[34m%s%s\033[0m\n\n\n", banner, slug, ) }