Files
yovision/Bell/cmd/bell-api/main.go
T

336 lines
10 KiB
Go
Raw Normal View History

2026-08-11 00:24:32 +08:00
package main
import (
"context"
"crypto/tls"
"errors"
"fmt"
"log/slog"
"net"
"net/http"
"os"
"os/signal"
"path/filepath"
"strconv"
"strings"
2026-08-11 00:24:32 +08:00
"syscall"
"time"
"github.com/jackc/pgx/v5"
"github.com/jackc/pgx/v5/stdlib"
"yovision/bell/contracts"
"yovision/bell/internal/alert"
2026-08-11 00:24:32 +08:00
"yovision/bell/internal/audit"
"yovision/bell/internal/event"
"yovision/bell/internal/ingress"
2026-08-11 00:24:32 +08:00
"yovision/bell/internal/store"
bellweb "yovision/bell/web"
2026-08-11 00:24:32 +08:00
)
var version = "dev"
type configuration struct {
address string
dsn string
keyFile string
tlsCert string
tlsKey string
eventIngressEnabled bool
eventKeyFile string
forbiddenNamesFile string
alertsEnabled bool
alertRulesFile string
alertConsoleEnabled bool
alertConsoleTokenFile string
alertConsoleTenantID int64
alertConsoleSiteID int64
alertConsoleActorRef string
2026-08-11 00:24:32 +08:00
}
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"),
alertRulesFile: os.Getenv("BELL_ALERT_RULES_FILE"),
alertConsoleTokenFile: os.Getenv("BELL_ALERT_CONSOLE_TOKEN_FILE"),
alertConsoleActorRef: os.Getenv("BELL_ALERT_CONSOLE_ACTOR_REF"),
}
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")
2026-08-11 00:24:32 +08:00
}
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")
}
}
value.alertsEnabled, err = strictBoolEnv("BELL_ALERTS_ENABLED")
if err != nil {
return configuration{}, err
}
value.alertConsoleEnabled, err = strictBoolEnv("BELL_ALERT_CONSOLE_ENABLED")
if err != nil {
return configuration{}, err
}
if value.alertsEnabled && (value.alertRulesFile == "" || !filepath.IsAbs(value.alertRulesFile)) {
return configuration{}, errors.New("BELL_ALERT_RULES_FILE must be an absolute external path when alerts are enabled")
}
if value.alertConsoleEnabled {
if !value.alertsEnabled || !loopback || os.Getenv("BELL_HTTP_ADDR") == "" {
return configuration{}, errors.New("Bell alert console requires alerts and an explicit loopback bind")
}
if value.alertConsoleTokenFile == "" || !filepath.IsAbs(value.alertConsoleTokenFile) {
return configuration{}, errors.New("BELL_ALERT_CONSOLE_TOKEN_FILE must be an absolute external path")
}
value.alertConsoleTenantID, err = positiveEnv("BELL_ALERT_CONSOLE_TENANT_ID")
if err != nil {
return configuration{}, err
}
value.alertConsoleSiteID, err = positiveEnv("BELL_ALERT_CONSOLE_SITE_ID")
if err != nil {
return configuration{}, err
}
if value.alertConsoleActorRef == "" {
return configuration{}, errors.New("BELL_ALERT_CONSOLE_ACTOR_REF is required")
}
}
2026-08-11 00:24:32 +08:00
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)
}
if cfg.alertsEnabled {
if err := repository.AlertReady(ctx); err != nil {
return err
}
rules, err := alert.LoadRules(cfg.alertRulesFile)
if err != nil {
return err
}
if err := alert.Publish(ctx, repository, rules); err != nil {
return err
}
go alert.RunWorker(ctx, repository, func(workerErr error) {
logger.Error("Bell alert worker retrying", "error", workerErr)
})
}
if cfg.alertConsoleEnabled {
token, err := loadConsoleToken(cfg.alertConsoleTokenFile)
if err != nil {
return err
}
console, err := bellweb.NewHandler(repository, bellweb.Config{
Token: token, TenantID: cfg.alertConsoleTenantID, SiteID: cfg.alertConsoleSiteID, ActorRef: cfg.alertConsoleActorRef,
})
if err != nil {
return err
}
console.Register(mux)
}
2026-08-11 00:24:32 +08:00
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
}
}
if cfg.alertsEnabled {
if err := repository.AlertReady(request.Context()); err != nil {
writeStatus(writer, http.StatusServiceUnavailable, "not_ready")
return
}
}
2026-08-11 00:24:32 +08:00
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
}
2026-08-11 00:24:32 +08:00
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
}
func strictBoolEnv(name string) (bool, error) {
switch os.Getenv(name) {
case "", "false":
return false, nil
case "true":
return true, nil
default:
return false, fmt.Errorf("%s must be true or false", name)
}
}
func positiveEnv(name string) (int64, error) {
value, err := strconv.ParseInt(os.Getenv(name), 10, 64)
if err != nil || value < 1 {
return 0, fmt.Errorf("%s must be a positive integer", name)
}
return value, nil
}
func loadConsoleToken(path string) (string, error) {
raw, err := os.ReadFile(path)
if err != nil || len(raw) > 4096 {
return "", errors.New("read Bell alert console token file")
}
value := strings.TrimSpace(string(raw))
if len(value) < 32 || len(value) > 256 || strings.ContainsAny(value, " \t\r\n") {
return "", errors.New("Bell alert console token must contain 32 to 256 non-whitespace characters")
}
return value, nil
}