228 lines
6.8 KiB
Go
228 lines
6.8 KiB
Go
package main
|
|
|
|
import (
|
|
"context"
|
|
"crypto/tls"
|
|
"errors"
|
|
"fmt"
|
|
"log/slog"
|
|
"net"
|
|
"net/http"
|
|
"os"
|
|
"os/signal"
|
|
"path/filepath"
|
|
"strings"
|
|
"syscall"
|
|
"time"
|
|
|
|
"github.com/jackc/pgx/v5"
|
|
"github.com/jackc/pgx/v5/stdlib"
|
|
|
|
"yovision/bell/contracts"
|
|
"yovision/bell/internal/audit"
|
|
"yovision/bell/internal/event"
|
|
"yovision/bell/internal/ingress"
|
|
"yovision/bell/internal/store"
|
|
)
|
|
|
|
var version = "dev"
|
|
|
|
type configuration struct {
|
|
address string
|
|
dsn string
|
|
keyFile string
|
|
tlsCert string
|
|
tlsKey string
|
|
eventIngressEnabled bool
|
|
eventKeyFile string
|
|
forbiddenNamesFile string
|
|
}
|
|
|
|
func main() {
|
|
logger := slog.New(slog.NewJSONHandler(os.Stdout, nil))
|
|
if err := run(logger); err != nil {
|
|
logger.Error("Bell stopped", "error", err)
|
|
os.Exit(1)
|
|
}
|
|
}
|
|
|
|
func loadConfiguration() (configuration, error) {
|
|
value := configuration{
|
|
address: envOr("BELL_HTTP_ADDR", "127.0.0.1:8081"),
|
|
dsn: os.Getenv("BELL_DB_DSN"),
|
|
keyFile: os.Getenv("BELL_AUDIT_KEYS_FILE"),
|
|
tlsCert: os.Getenv("BELL_TLS_CERT_FILE"),
|
|
tlsKey: os.Getenv("BELL_TLS_KEY_FILE"),
|
|
eventKeyFile: os.Getenv("BELL_EVENT_INGRESS_KEYS_FILE"),
|
|
forbiddenNamesFile: os.Getenv("BELL_EVIDENCE_FORBIDDEN_NAMES_FILE"),
|
|
}
|
|
switch os.Getenv("BELL_EVENT_INGRESS_ENABLED") {
|
|
case "", "false":
|
|
case "true":
|
|
value.eventIngressEnabled = true
|
|
default:
|
|
return configuration{}, errors.New("BELL_EVENT_INGRESS_ENABLED must be true or false")
|
|
}
|
|
if value.dsn == "" {
|
|
return configuration{}, errors.New("BELL_DB_DSN is required")
|
|
}
|
|
if value.keyFile == "" || !filepath.IsAbs(value.keyFile) {
|
|
return configuration{}, errors.New("BELL_AUDIT_KEYS_FILE must be an absolute external path")
|
|
}
|
|
host, _, err := net.SplitHostPort(value.address)
|
|
if err != nil {
|
|
return configuration{}, errors.New("invalid BELL_HTTP_ADDR")
|
|
}
|
|
ip := net.ParseIP(host)
|
|
loopback := host == "localhost" || (ip != nil && ip.IsLoopback())
|
|
if !loopback && (value.tlsCert == "" || value.tlsKey == "" || !filepath.IsAbs(value.tlsCert) || !filepath.IsAbs(value.tlsKey)) {
|
|
return configuration{}, errors.New("non-loopback Bell bind requires absolute TLS certificate and key paths")
|
|
}
|
|
if (value.tlsCert == "") != (value.tlsKey == "") {
|
|
return configuration{}, errors.New("Bell TLS certificate and key must be configured together")
|
|
}
|
|
if value.eventIngressEnabled {
|
|
if value.eventKeyFile == "" || !filepath.IsAbs(value.eventKeyFile) {
|
|
return configuration{}, errors.New("BELL_EVENT_INGRESS_KEYS_FILE must be an absolute external path when event ingress is enabled")
|
|
}
|
|
if value.forbiddenNamesFile == "" || !filepath.IsAbs(value.forbiddenNamesFile) {
|
|
return configuration{}, errors.New("BELL_EVIDENCE_FORBIDDEN_NAMES_FILE must be an absolute external path when event ingress is enabled")
|
|
}
|
|
}
|
|
return value, nil
|
|
}
|
|
|
|
func run(logger *slog.Logger) error {
|
|
cfg, err := loadConfiguration()
|
|
if err != nil {
|
|
return err
|
|
}
|
|
pgConfig, err := pgx.ParseConfig(cfg.dsn)
|
|
if err != nil {
|
|
return errors.New("invalid Bell postgres DSN")
|
|
}
|
|
if pgConfig.RuntimeParams == nil {
|
|
pgConfig.RuntimeParams = make(map[string]string)
|
|
}
|
|
pgConfig.RuntimeParams["application_name"] = "yovision-bell"
|
|
db := stdlib.OpenDB(*pgConfig)
|
|
db.SetMaxOpenConns(16)
|
|
db.SetMaxIdleConns(4)
|
|
db.SetConnMaxLifetime(30 * time.Minute)
|
|
defer db.Close()
|
|
|
|
ctx, stop := signal.NotifyContext(context.Background(), os.Interrupt, syscall.SIGTERM)
|
|
defer stop()
|
|
repository, err := store.OpenPostgres(ctx, db)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
if err := repository.AuditRelayReady(ctx); err != nil {
|
|
return err
|
|
}
|
|
keys, err := audit.LoadKeys(cfg.keyFile)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
handler, err := audit.NewHandler(repository, keys)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
mux := http.NewServeMux()
|
|
mux.Handle(audit.RelayPath, handler)
|
|
if cfg.eventIngressEnabled {
|
|
if err := repository.EventIngressReady(ctx); err != nil {
|
|
return err
|
|
}
|
|
eventKeys, err := ingress.LoadKeys(cfg.eventKeyFile)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
forbiddenNames, err := loadForbiddenNames(cfg.forbiddenNamesFile)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
guard, err := event.NewEvidenceGuard(forbiddenNames...)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
factory, err := event.NewFactory(contracts.EventV01Schema, event.ULIDGenerator{}, repository, guard)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
eventHandler, err := ingress.NewHandler(repository, eventKeys, factory)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
mux.Handle(ingress.Path, eventHandler)
|
|
}
|
|
mux.HandleFunc("GET /healthz", func(writer http.ResponseWriter, _ *http.Request) {
|
|
writeStatus(writer, http.StatusOK, "ok")
|
|
})
|
|
mux.HandleFunc("GET /readyz", func(writer http.ResponseWriter, request *http.Request) {
|
|
if err := repository.AuditRelayReady(request.Context()); err != nil {
|
|
writeStatus(writer, http.StatusServiceUnavailable, "not_ready")
|
|
return
|
|
}
|
|
if cfg.eventIngressEnabled {
|
|
if err := repository.EventIngressReady(request.Context()); err != nil {
|
|
writeStatus(writer, http.StatusServiceUnavailable, "not_ready")
|
|
return
|
|
}
|
|
}
|
|
writeStatus(writer, http.StatusOK, "ready")
|
|
})
|
|
server := &http.Server{Addr: cfg.address, Handler: mux, ReadHeaderTimeout: 5 * time.Second, ReadTimeout: 15 * time.Second, WriteTimeout: 15 * time.Second, IdleTimeout: 60 * time.Second, TLSConfig: &tls.Config{MinVersion: tls.VersionTLS12}}
|
|
serverErrors := make(chan error, 1)
|
|
go func() {
|
|
logger.Info("Bell listening", "address", cfg.address, "version", version, "tls_enabled", cfg.tlsCert != "")
|
|
if cfg.tlsCert != "" {
|
|
serverErrors <- server.ListenAndServeTLS(cfg.tlsCert, cfg.tlsKey)
|
|
return
|
|
}
|
|
serverErrors <- server.ListenAndServe()
|
|
}()
|
|
select {
|
|
case <-ctx.Done():
|
|
case serverErr := <-serverErrors:
|
|
if !errors.Is(serverErr, http.ErrServerClosed) {
|
|
return fmt.Errorf("serve Bell HTTP: %w", serverErr)
|
|
}
|
|
}
|
|
shutdownContext, cancel := context.WithTimeout(context.Background(), 5*time.Second)
|
|
defer cancel()
|
|
return server.Shutdown(shutdownContext)
|
|
}
|
|
|
|
func loadForbiddenNames(path string) ([]string, error) {
|
|
raw, err := os.ReadFile(path)
|
|
if err != nil || len(raw) > 64<<10 {
|
|
return nil, errors.New("read Bell evidence forbidden-names file")
|
|
}
|
|
var values []string
|
|
for _, line := range strings.Split(string(raw), "\n") {
|
|
line = strings.TrimSpace(line)
|
|
if line == "" {
|
|
continue
|
|
}
|
|
values = append(values, line)
|
|
}
|
|
if len(values) == 0 || len(values) > 256 {
|
|
return nil, errors.New("Bell evidence forbidden-names file must contain 1 to 256 names")
|
|
}
|
|
return values, nil
|
|
}
|
|
|
|
func writeStatus(writer http.ResponseWriter, status int, value string) {
|
|
writer.Header().Set("Content-Type", "application/json")
|
|
writer.WriteHeader(status)
|
|
_, _ = fmt.Fprintf(writer, `{"status":%q}`, value)
|
|
}
|
|
|
|
func envOr(name, fallback string) string {
|
|
if value := os.Getenv(name); value != "" {
|
|
return value
|
|
}
|
|
return fallback
|
|
}
|