package main import ( "context" "crypto/tls" "errors" "fmt" "log/slog" "net" "net/http" "os" "os/signal" "path/filepath" "syscall" "time" "github.com/jackc/pgx/v5" "github.com/jackc/pgx/v5/stdlib" "yovision/bell/internal/audit" "yovision/bell/internal/store" ) var version = "dev" type configuration struct { address string dsn string keyFile string tlsCert string tlsKey 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"), } 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") } 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) 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 } 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 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 }