- Introduced health checks for Nextcloud, Immich, and Jitsi in the .env.example file. - Implemented Prometheus metrics for HTTP requests, IMAP sync, outbox processing, and webhook executions. - Added Grafana configuration files for dashboards and data sources. - Updated Docker Compose to include Prometheus and Grafana services. - Enhanced logging middleware to include request IDs and metrics tracking. - Created health checker for monitoring database and external service statuses. - Updated README with observability setup instructions and service URLs.
233 lines
7.0 KiB
Go
233 lines
7.0 KiB
Go
package main
|
|
|
|
import (
|
|
"context"
|
|
"encoding/json"
|
|
"fmt"
|
|
"log/slog"
|
|
"net/http"
|
|
"os"
|
|
"os/signal"
|
|
"syscall"
|
|
"time"
|
|
|
|
"github.com/go-chi/chi/v5"
|
|
"github.com/go-chi/cors"
|
|
"github.com/jackc/pgx/v5/pgxpool"
|
|
"github.com/minio/minio-go/v7"
|
|
"github.com/minio/minio-go/v7/pkg/credentials"
|
|
"github.com/prometheus/client_golang/prometheus/promhttp"
|
|
"github.com/redis/go-redis/v9"
|
|
|
|
"github.com/ultisuite/ulti-backend/internal/api/admin"
|
|
"github.com/ultisuite/ulti-backend/internal/api/apiresponse"
|
|
"github.com/ultisuite/ulti-backend/internal/api/calendar"
|
|
"github.com/ultisuite/ulti-backend/internal/api/contacts"
|
|
"github.com/ultisuite/ulti-backend/internal/api/drive"
|
|
mailapi "github.com/ultisuite/ulti-backend/internal/api/mail"
|
|
meetapi "github.com/ultisuite/ulti-backend/internal/api/meet"
|
|
"github.com/ultisuite/ulti-backend/internal/api/middleware"
|
|
photosapi "github.com/ultisuite/ulti-backend/internal/api/photos"
|
|
"github.com/ultisuite/ulti-backend/internal/auth"
|
|
"github.com/ultisuite/ulti-backend/internal/config"
|
|
"github.com/ultisuite/ulti-backend/internal/envexpand"
|
|
mailcredentials "github.com/ultisuite/ulti-backend/internal/mail/credentials"
|
|
imapsync "github.com/ultisuite/ulti-backend/internal/mail/imap"
|
|
"github.com/ultisuite/ulti-backend/internal/mail/smtp"
|
|
"github.com/ultisuite/ulti-backend/internal/meet"
|
|
"github.com/ultisuite/ulti-backend/internal/nextcloud"
|
|
"github.com/ultisuite/ulti-backend/internal/observability"
|
|
"github.com/ultisuite/ulti-backend/internal/photos"
|
|
"github.com/ultisuite/ulti-backend/internal/realtime"
|
|
"github.com/ultisuite/ulti-backend/internal/search"
|
|
"github.com/ultisuite/ulti-backend/internal/securityaudit"
|
|
)
|
|
|
|
func main() {
|
|
ctx, cancel := context.WithCancel(context.Background())
|
|
defer cancel()
|
|
|
|
loadDotEnv()
|
|
|
|
cfg, err := config.Load()
|
|
if err != nil {
|
|
slog.Error("failed to load config", "error", err)
|
|
os.Exit(1)
|
|
}
|
|
|
|
pool, err := pgxpool.New(ctx, cfg.DatabaseURL)
|
|
if err != nil {
|
|
slog.Error("failed to connect to database", "error", err)
|
|
os.Exit(1)
|
|
}
|
|
defer pool.Close()
|
|
|
|
rdb := redis.NewClient(&redis.Options{Addr: cfg.KeyDBAddr})
|
|
defer rdb.Close()
|
|
|
|
_, err = minio.New(cfg.RustFSEndpoint, &minio.Options{
|
|
Creds: credentials.NewStaticV4(cfg.RustFSAccessKey, cfg.RustFSSecretKey, ""),
|
|
Secure: cfg.RustFSUseSSL,
|
|
})
|
|
if err != nil {
|
|
slog.Error("failed to create RustFS client", "error", err)
|
|
os.Exit(1)
|
|
}
|
|
|
|
verifier, err := auth.NewVerifier(ctx, cfg.OIDCIssuer, cfg.OIDCClientID)
|
|
if err != nil {
|
|
slog.Warn("OIDC verifier not available (Authentik may not be running)", "error", err)
|
|
}
|
|
if cfg.IsProduction() {
|
|
if cfg.OIDCIssuer == "" || cfg.OIDCClientID == "" {
|
|
slog.Error("missing required OIDC configuration in production",
|
|
"ULTID_OIDC_ISSUER_set", cfg.OIDCIssuer != "",
|
|
"ULTID_OIDC_CLIENT_ID_set", cfg.OIDCClientID != "")
|
|
os.Exit(1)
|
|
}
|
|
if verifier == nil {
|
|
slog.Error("OIDC verifier initialization failed in production")
|
|
os.Exit(1)
|
|
}
|
|
if err := cfg.ValidateSecretRotation(); err != nil {
|
|
slog.Error("secret rotation policy check failed", "error", err)
|
|
os.Exit(1)
|
|
}
|
|
} else if err := cfg.ValidateSecretRotation(); err != nil {
|
|
slog.Warn("secret rotation policy warning", "error", err)
|
|
}
|
|
|
|
credentialManager, err := mailcredentials.NewManager(cfg.MailCredentialKeys, cfg.MailActiveCredentialKeyID)
|
|
if err != nil {
|
|
slog.Error("mail credential encryption not configured", "error", err)
|
|
os.Exit(1)
|
|
}
|
|
|
|
auditLogger := securityaudit.NewLogger(pool)
|
|
|
|
// Nextcloud client (nil if disabled)
|
|
var ncClient *nextcloud.Client
|
|
if cfg.NextcloudEnabled {
|
|
ncClient = nextcloud.NewClient(cfg.NextcloudURL, cfg.NCAdminUser, cfg.NCAdminPass)
|
|
slog.Info("nextcloud enabled", "url", cfg.NextcloudURL)
|
|
}
|
|
|
|
// Meet config (nil if disabled)
|
|
var meetCfg *meet.Config
|
|
if cfg.JitsiEnabled {
|
|
meetCfg = meet.NewConfig(cfg.JitsiAppID, cfg.JitsiAppSecret, cfg.Domain)
|
|
slog.Info("jitsi meet enabled", "domain", cfg.JitsiDomain)
|
|
}
|
|
|
|
// Photos client (nil if disabled)
|
|
var photosClient *photos.Client
|
|
if cfg.ImmichEnabled {
|
|
photosClient = photos.NewClient(cfg.ImmichAPIURL)
|
|
slog.Info("immich photos enabled", "url", cfg.ImmichAPIURL)
|
|
}
|
|
|
|
// WebSocket hub
|
|
hub := realtime.NewHub()
|
|
healthChecker := observability.NewHealthChecker(cfg, pool, rdb)
|
|
|
|
// Start background workers
|
|
go imapsync.NewSyncWorker(pool, cfg.MailSyncInterval, credentialManager).Start(ctx)
|
|
|
|
sender := smtp.NewSender(pool, credentialManager)
|
|
go smtp.NewOutboxProcessor(pool, sender, cfg.MailOutboxInterval).Start(ctx)
|
|
|
|
// Router
|
|
r := chi.NewRouter()
|
|
|
|
r.Use(cors.Handler(cors.Options{
|
|
AllowedOrigins: []string{"*"},
|
|
AllowedMethods: []string{"GET", "POST", "PUT", "PATCH", "DELETE", "OPTIONS"},
|
|
AllowedHeaders: []string{"Authorization", "Content-Type", apiresponse.TraceIDHeader},
|
|
ExposedHeaders: []string{apiresponse.TraceIDHeader},
|
|
AllowCredentials: false,
|
|
MaxAge: 300,
|
|
}))
|
|
r.Use(middleware.TraceID)
|
|
r.Use(observability.HTTPMetrics)
|
|
r.Use(middleware.Logging)
|
|
|
|
r.Get("/healthz", func(w http.ResponseWriter, r *http.Request) {
|
|
report := healthChecker.Check(r.Context())
|
|
statusCode := http.StatusOK
|
|
if report.Status != "ok" {
|
|
statusCode = http.StatusServiceUnavailable
|
|
}
|
|
w.Header().Set("Content-Type", "application/json")
|
|
w.WriteHeader(statusCode)
|
|
_ = json.NewEncoder(w).Encode(report)
|
|
})
|
|
r.Handle("/metrics", promhttp.Handler())
|
|
|
|
r.Get("/ws", hub.HandleWS)
|
|
|
|
r.Group(func(r chi.Router) {
|
|
r.Use(middleware.Auth(verifier, auditLogger))
|
|
|
|
r.Mount("/api/v1/mail", mailapi.NewHandler(pool, auditLogger, credentialManager).Routes())
|
|
r.Mount("/api/v1/admin", admin.NewHandler(pool, auditLogger).Routes())
|
|
r.Get("/api/v1/search", search.NewHandler(pool).Search)
|
|
|
|
if ncClient != nil {
|
|
r.Mount("/api/v1/drive", drive.NewHandler(ncClient).Routes())
|
|
r.Mount("/api/v1/calendar", calendar.NewHandler(ncClient).Routes())
|
|
r.Mount("/api/v1/contacts", contacts.NewHandler(ncClient).Routes())
|
|
}
|
|
if meetCfg != nil {
|
|
r.Mount("/api/v1/meet", meetapi.NewHandler(meetCfg).Routes())
|
|
}
|
|
if photosClient != nil {
|
|
r.Mount("/api/v1/photos", photosapi.NewHandler(photosClient).Routes())
|
|
}
|
|
})
|
|
|
|
_ = rdb
|
|
|
|
srv := &http.Server{
|
|
Addr: fmt.Sprintf(":%d", cfg.Port),
|
|
Handler: r,
|
|
}
|
|
|
|
errCh := make(chan error, 1)
|
|
go func() {
|
|
slog.Info("server starting", "port", cfg.Port)
|
|
if err := srv.ListenAndServe(); err != nil && err != http.ErrServerClosed {
|
|
errCh <- err
|
|
}
|
|
}()
|
|
|
|
quit := make(chan os.Signal, 1)
|
|
signal.Notify(quit, syscall.SIGINT, syscall.SIGTERM)
|
|
|
|
select {
|
|
case <-quit:
|
|
case err := <-errCh:
|
|
slog.Error("server error", "error", err)
|
|
}
|
|
|
|
slog.Info("shutting down server")
|
|
cancel()
|
|
|
|
shutdownCtx, shutdownCancel := context.WithTimeout(context.Background(), 10*time.Second)
|
|
defer shutdownCancel()
|
|
|
|
if err := srv.Shutdown(shutdownCtx); err != nil {
|
|
slog.Error("server forced shutdown", "error", err)
|
|
os.Exit(1)
|
|
}
|
|
slog.Info("server stopped")
|
|
}
|
|
|
|
func loadDotEnv() {
|
|
for _, path := range []string{".env", "../.env"} {
|
|
if err := envexpand.ApplyFile(path); err == nil {
|
|
slog.Debug("loaded env file", "path", path)
|
|
return
|
|
}
|
|
}
|
|
}
|