pcmt/run.go
surtur 6b45213649
All checks were successful
continuous-integration/drone/push Build is passing
go: add user onboarding, HIBP search functionality
* add user onboarding workflow
* fix user editing (no edits of passwords of regular users after
  onboarding)
* refresh HIBP breach cache in DB on app start-up
* display HIBP breach details
* fix request scheduling to prevent panics (this still needs some love..)
* fix middleware auth
* add TODOs
* update head.tmpl
* reword some error messages
2023-08-24 18:43:24 +02:00

372 lines
9.1 KiB
Go

// Copyright 2023 wanderer <a_mirre at utb dot cz>
// 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 <https://www.gnu.org/licenses/>.`
)
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 upcoming 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)
wg.Add(1)
go func() { // nolint:wsl
defer wg.Done() // for this goroutine
// this is for the scheduler goroutine.
wg.Add(1) // nolint:staticcheck
go hibp.RunReqScheduler(schedQuit, errCh, wg) // nolint:wsl
// TODO: pass a chan to the scheduler for reporting readiness.
log.Info("Giving HIBP requests scheduler time to start")
time.Sleep(time.Second)
if err = hibp.CheckSaveAllBreaches(
context.WithValue(context.Background(), hibp.CtxKey{}, slogger),
db); err != nil {
log.Error("failed to refresh HIBP db cache")
}
}()
// 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,
)
}