// SPDX-License-Identifier: AGPL-3.0-or-later // // BirdWatch Relay — self-hosted, end-to-end encrypted push relay for Frigate NVR. // Subscribes to Frigate's /ws WebSocket, encrypts each event payload with each // registered device's X25519 public key (NaCl sealed box), and forwards the // ciphertext via Firebase Cloud Messaging. Google FCM only ever sees ciphertext // and a device token — never event content. package main import ( "context" "errors" "log/slog" "net/http" "os" "os/signal" "syscall" "time" "github.com/cyrilinait/birdwatch-relay/internal/config" "github.com/cyrilinait/birdwatch-relay/internal/fcm" "github.com/cyrilinait/birdwatch-relay/internal/frigate" "github.com/cyrilinait/birdwatch-relay/internal/handlers" "github.com/cyrilinait/birdwatch-relay/internal/notify" "github.com/cyrilinait/birdwatch-relay/internal/storage" ) func main() { logger := slog.New(slog.NewJSONHandler(os.Stdout, nil)) slog.SetDefault(logger) cfg, err := config.Load() if err != nil { logger.Error("config load failed", "err", err) os.Exit(1) } store, err := storage.Open(cfg.DBPath) if err != nil { logger.Error("storage open failed", "err", err, "path", cfg.DBPath) os.Exit(1) } defer store.Close() rootCtx, cancelRoot := context.WithCancel(context.Background()) defer cancelRoot() sender, err := fcm.New(rootCtx, cfg.FirebaseKeyPath) if err != nil { logger.Error("fcm init failed", "err", err) os.Exit(1) } dispatcher := notify.NewDispatcher(store, sender) frigateClient, err := frigate.NewClient( cfg.FrigateURL, cfg.FrigateUser, cfg.FrigatePassword, func(payload []byte) { ctx, cancel := context.WithTimeout(rootCtx, 15*time.Second) defer cancel() dispatcher.Send(ctx, payload) }, ) if err != nil { logger.Error("frigate client init failed", "err", err) os.Exit(1) } go frigateClient.Run(rootCtx) logger.Info("frigate ws subscriber started", "url", cfg.FrigateURL) mux := http.NewServeMux() handlers.Register(mux, store, cfg) srv := &http.Server{ Addr: ":" + cfg.Port, Handler: mux, ReadHeaderTimeout: 5 * time.Second, ReadTimeout: 30 * time.Second, WriteTimeout: 30 * time.Second, IdleTimeout: 120 * time.Second, MaxHeaderBytes: 8 * 1024, } go func() { logger.Info("birdwatch-relay listening", "port", cfg.Port) if err := srv.ListenAndServe(); err != nil && !errors.Is(err, http.ErrServerClosed) { logger.Error("http server failed", "err", err) os.Exit(1) } }() stop := make(chan os.Signal, 1) signal.Notify(stop, os.Interrupt, syscall.SIGTERM) <-stop logger.Info("shutdown signal received") cancelRoot() shutdownCtx, cancelShutdown := context.WithTimeout(context.Background(), 10*time.Second) defer cancelShutdown() if err := srv.Shutdown(shutdownCtx); err != nil { logger.Error("shutdown error", "err", err) } }