happyDomain/internal/app/app.go
Pierre-Olivier Mercier 55c4237f6e api-admin: domain controller delegates to AdminDomainUsecase
Add an AdminDomainUsecase interface for the cross-user operations the
admin API needs (list every domain, fetch any by ID or by FQDN+owner, raw
create/update, clear all) and implement it on domain.Service. Refactor
the admin DomainController to depend on DomainUsecase + AdminDomainUsecase
only, dropping its direct domain.DomainStorage handle. Per-user clears now
go through DomainUsecase.DeleteDomain, which already encapsulates the
deletion side effects.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-10 20:17:55 +08:00

527 lines
17 KiB
Go

// This file is part of the happyDomain (R) project.
// Copyright (c) 2020-2024 happyDomain
// Authors: Pierre-Olivier Mercier, et al.
//
// This program is offered under a commercial and under the AGPL license.
// For commercial licensing, contact us at <contact@happydomain.org>.
//
// For AGPL licensing:
// This program is free software: you can redistribute it and/or modify
// it under the terms of the GNU Affero General Public License as published by
// the Free Software Foundation, either version 3 of the License, or
// (at your option) any later version.
//
// This program is distributed in the hope that it will be useful,
// but WITHOUT ANY WARRANTY; without even the implied warranty of
// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
// GNU Affero General Public License for more details.
//
// You should have received a copy of the GNU Affero General Public License
// along with this program. If not, see <https://www.gnu.org/licenses/>.
package app
import (
"context"
"log"
"net/http"
"time"
"github.com/gin-contrib/sessions"
"github.com/gin-gonic/gin"
api "git.happydns.org/happyDomain/internal/api/route"
"git.happydns.org/happyDomain/internal/captcha"
"git.happydns.org/happyDomain/internal/mailer"
"git.happydns.org/happyDomain/internal/metrics"
"git.happydns.org/happyDomain/internal/newsletter"
notifPkg "git.happydns.org/happyDomain/internal/notifier"
"git.happydns.org/happyDomain/internal/session"
"git.happydns.org/happyDomain/internal/storage"
"git.happydns.org/happyDomain/internal/usecase"
authuserUC "git.happydns.org/happyDomain/internal/usecase/authuser"
checkerUC "git.happydns.org/happyDomain/internal/usecase/checker"
domainUC "git.happydns.org/happyDomain/internal/usecase/domain"
domainlogUC "git.happydns.org/happyDomain/internal/usecase/domain_log"
emailAutoconfigUC "git.happydns.org/happyDomain/internal/usecase/emailautoconfig"
notifUC "git.happydns.org/happyDomain/internal/usecase/notification"
"git.happydns.org/happyDomain/internal/usecase/orchestrator"
providerUC "git.happydns.org/happyDomain/internal/usecase/provider"
serviceUC "git.happydns.org/happyDomain/internal/usecase/service"
sessionUC "git.happydns.org/happyDomain/internal/usecase/session"
userUC "git.happydns.org/happyDomain/internal/usecase/user"
zoneUC "git.happydns.org/happyDomain/internal/usecase/zone"
zoneServiceUC "git.happydns.org/happyDomain/internal/usecase/zone_service"
"git.happydns.org/happyDomain/model"
"git.happydns.org/happyDomain/pkg/domaininfo"
"git.happydns.org/happyDomain/services/abstract"
"git.happydns.org/happyDomain/web"
)
type Usecases struct {
authentication happydns.AuthenticationUsecase
authUser happydns.AuthUserUsecase
authUserAdmin happydns.AdminAuthUserUsecase
domain happydns.DomainUsecase
domainAdmin happydns.AdminDomainUsecase
domainInfo happydns.DomainInfoUsecase
domainLog happydns.DomainLogUsecase
emailAutoconfig happydns.EmailAutoconfigUsecase
provider happydns.ProviderUsecase
providerAdmin happydns.ProviderUsecase
providerSpecs happydns.ProviderSpecsUsecase
providerSettings happydns.ProviderSettingsUsecase
resolver happydns.ResolverUsecase
session happydns.SessionUsecase
service happydns.ServiceUsecase
serviceSpecs happydns.ServiceSpecsUsecase
user happydns.UserUsecase
userAdmin happydns.AdminUserUsecase
zone happydns.ZoneUsecase
zoneService happydns.ZoneServiceUsecase
orchestrator *orchestrator.Orchestrator
checkerEngine happydns.CheckerEngine
checkerOptionsUC *checkerUC.CheckerOptionsUsecase
checkerPlanUC *checkerUC.CheckPlanUsecase
checkerStatusUC *checkerUC.CheckStatusUsecase
checkerScheduler *checkerUC.Scheduler
checkerJanitor *checkerUC.Janitor
checkerUserGater *checkerUC.UserGater
notificationDispatcher *notifUC.Dispatcher
notificationRegistry *notifPkg.Registry
}
type App struct {
captchaVerifier happydns.CaptchaVerifier
cfg *happydns.Options
failureTracker *captcha.FailureTracker
insights *insightsCollector
mailer happydns.Mailer
newsletter happydns.NewsletterSubscriptor
router *gin.Engine
srv *http.Server
store storage.Storage
usecases Usecases
}
func NewApp(cfg *happydns.Options) *App {
app := &App{
cfg: cfg,
}
app.initMailer()
app.initStorageEngine()
app.initNewsletter()
app.initInsights()
if err := app.initPlugins(); err != nil {
log.Fatalf("Plugin initialization error: %s", err)
}
app.initUsecases()
app.initCaptcha()
app.setupRouter()
return app
}
func NewAppWithStorage(cfg *happydns.Options, store storage.Storage) *App {
app := &App{
cfg: cfg,
store: store,
}
app.initMailer()
app.initNewsletter()
if err := app.initPlugins(); err != nil {
log.Fatalf("Plugin initialization error: %s", err)
}
app.initUsecases()
app.initCaptcha()
app.setupRouter()
return app
}
func (app *App) initCaptcha() {
app.captchaVerifier = captcha.NewVerifier(app.cfg.CaptchaProvider)
threshold := app.cfg.CaptchaLoginThreshold
if threshold <= 0 {
threshold = 3
}
app.failureTracker = captcha.NewFailureTracker(threshold, 15*time.Minute)
}
func (app *App) initMailer() {
if app.cfg.MailSMTPHost != "" {
m := &mailer.Mailer{
MailFrom: &app.cfg.MailFrom,
SendMethod: mailer.NewSMTPMailer(app.cfg.MailSMTPHost, app.cfg.MailSMTPPort, app.cfg.MailSMTPUsername, app.cfg.MailSMTPPassword),
}
if app.cfg.MailSMTPTLSSNoVerify {
m.SendMethod.(*mailer.SMTPMailer).WithTLSNoVerify()
}
app.mailer = m
} else if !app.cfg.NoMail {
app.mailer = &mailer.Mailer{
MailFrom: &app.cfg.MailFrom,
SendMethod: &mailer.SystemSendmail{},
}
} else {
app.mailer = &mailer.LogMailer{}
}
}
func (app *App) initStorageEngine() {
if s, ok := storage.StorageEngines[app.cfg.StorageEngine]; !ok {
log.Fatalf("Nonexistent storage engine: %q, please select one of: %v", app.cfg.StorageEngine, storage.GetStorageEngines())
} else {
var err error
log.Println("Opening database...")
app.store, err = s()
if err != nil {
log.Fatal("Could not open the database: ", err)
}
log.Println("Performing database migrations...")
if err = app.store.MigrateSchema(); err != nil {
log.Fatal("Could not migrate database: ", err)
}
metrics.NewStorageStatsCollector(storage.NewStatsProvider(app.store))
app.store = newInstrumentedStorage(app.store)
}
}
func (app *App) initNewsletter() {
if app.cfg.ListmonkURL.String() != "" {
app.newsletter = &newsletter.ListmonkNewsletterSubscription{
ListmonkURL: &app.cfg.ListmonkURL,
ListmonkID: app.cfg.ListmonkID,
}
} else {
app.newsletter = &newsletter.DummyNewsletterSubscription{}
}
}
func (app *App) initInsights() {
if !app.cfg.OptOutInsights {
app.insights = &insightsCollector{
cfg: app.cfg,
store: app.store,
stop: make(chan struct{}, 1),
}
}
}
func (app *App) initUsecases() {
sessionService := sessionUC.NewService(app.store)
authUserService := authuserUC.NewAuthUserUsecases(
app.cfg,
app.mailer,
app.store,
sessionService,
)
domainLogService := domainlogUC.NewService(app.store)
providerService := providerUC.NewRestrictedService(app.cfg, app.store)
providerAdminService := providerUC.NewService(app.store, nil)
serviceService := serviceUC.NewServiceUsecases()
zoneService := zoneUC.NewZoneUsecases(app.store, serviceService)
app.usecases.providerSpecs = usecase.NewProviderSpecsUsecase()
app.usecases.provider = providerService
app.usecases.providerAdmin = providerAdminService
app.usecases.providerSettings = usecase.NewProviderSettingsUsecase(app.cfg, app.usecases.provider)
app.usecases.service = serviceService
app.usecases.serviceSpecs = usecase.NewServiceSpecsUsecase()
app.usecases.zone = zoneService
app.usecases.domainInfo = usecase.NewDomainInfoUsecase(
domaininfo.GetDomainRDAPInfo,
domaininfo.GetDomainWhoisInfo,
)
app.usecases.domainLog = domainLogService
// Email auto-configuration: derive the autoconfig CNAME target from
// MailAutoconfigHost (if set) or fall back to ExternalURL.Host.
autoconfigHost := app.cfg.MailAutoconfigHost
if autoconfigHost == "" {
autoconfigHost = app.cfg.ExternalURL.Hostname()
}
abstract.SetAutoconfigHost(autoconfigHost)
app.usecases.emailAutoconfig = emailAutoconfigUC.NewUsecase(app.store, zoneService.GetZoneUC)
domainService := domainUC.NewService(
app.store,
providerAdminService,
zoneService.GetZoneUC,
providerAdminService,
domainLogService,
)
app.usecases.domain = domainService
app.usecases.domainAdmin = domainService
app.usecases.zoneService = zoneServiceUC.NewZoneServiceUsecases(
domainService,
zoneService.CreateZoneUC,
serviceService.ValidateServiceUC,
app.store,
)
userService := userUC.NewUserUsecases(
app.store,
app.newsletter,
authUserService,
sessionService,
)
app.usecases.user = userService
app.usecases.userAdmin = userService
app.usecases.authentication = usecase.NewAuthenticationUsecase(app.cfg, app.store, app.usecases.user)
app.usecases.authUser = authUserService
app.usecases.authUserAdmin = authUserService
app.usecases.resolver = usecase.NewResolverUsecase(app.cfg)
app.usecases.session = sessionService
app.usecases.orchestrator = orchestrator.NewOrchestrator(
domainLogService,
domainService,
providerAdminService,
zoneService.ListRecordsUC,
providerAdminService,
zoneService.CreateZoneUC,
zoneService.GetZoneUC,
providerAdminService,
zoneService.UpdateZoneUC,
)
// Checker system.
app.usecases.checkerOptionsUC = checkerUC.NewCheckerOptionsUsecase(app.store, app.store)
app.usecases.checkerPlanUC = checkerUC.NewCheckPlanUsecase(app.store)
app.usecases.checkerStatusUC = checkerUC.NewCheckStatusUsecase(app.store, app.store, app.store, app.store)
app.usecases.checkerOptionsUC.WithDiscoveryEntryStore(app.store)
app.usecases.checkerEngine = checkerUC.NewCheckerEngine(
app.usecases.checkerOptionsUC,
app.store,
app.store,
app.store,
app.store,
app.store,
app.store,
)
// Build the user-level gate so paused or long-inactive users do not
// get checked. The same user resolver is reused by the janitor for
// per-user retention overrides.
app.usecases.checkerUserGater = checkerUC.NewUserGater(app.store, app.cfg.CheckerInactivityPauseDays, app.cfg.CheckerMaxChecksPerDay)
app.usecases.checkerScheduler = checkerUC.NewScheduler(
app.usecases.checkerEngine,
app.cfg.CheckerMaxConcurrency,
app.store, app.store, app.store, app.store,
app.usecases.checkerUserGater.AllowWithInterval,
app.usecases.checkerUserGater.IncrementUsage,
)
// Invalidate the scheduler's user gate cache whenever a user is updated
// (e.g. login refreshing LastSeen, admin toggling SchedulingPaused).
userService.SetOnUserChanged(func(id happydns.Identifier) {
app.usecases.checkerUserGater.Invalidate(id.String())
})
// Retention janitor.
app.usecases.checkerJanitor = checkerUC.NewJanitor(
app.store,
app.store,
app.store,
app.store,
app.store,
checkerUC.DefaultRetentionPolicy(app.cfg.CheckerRetentionDays),
app.cfg.CheckerJanitorInterval,
)
// Wire scheduler notifications for incremental queue updates.
domainService.SetSchedulerNotifier(app.usecases.checkerScheduler)
app.usecases.orchestrator.SetSchedulerNotifier(app.usecases.checkerScheduler)
// Notification system: dispatcher fans out checker results to user
// channels (email/webhook/UnifiedPush) based on per-target preferences.
baseURL := app.cfg.GetBaseURL()
registry := notifPkg.NewRegistry()
registry.Register(notifPkg.Adapt(notifPkg.NewEmailSender(app.mailer, baseURL)))
registry.Register(notifPkg.Adapt(notifPkg.NewWebhookSender(baseURL)))
registry.Register(notifPkg.Adapt(notifPkg.NewUnifiedPushSender(baseURL)))
app.usecases.notificationRegistry = registry
resolver := notifUC.NewResolver(app.store, app.store)
pool := notifUC.NewPool(registry, app.store)
tester := notifUC.NewTester(registry)
stateLocker := notifUC.NewStateLocker()
ack := notifUC.NewAckService(app.store, stateLocker)
app.usecases.notificationDispatcher = notifUC.NewDispatcher(
app.store,
app.store,
app.store,
resolver,
pool,
tester,
ack,
stateLocker,
)
if cb, ok := app.usecases.checkerEngine.(checkerUC.ExecutionCallbackSetter); ok {
cb.SetExecutionCallback(app.usecases.notificationDispatcher.OnExecutionComplete)
}
}
func (app *App) setupRouter() {
if app.cfg.DevProxy == "" {
gin.SetMode(gin.ReleaseMode)
}
gin.ForceConsoleColor()
app.router = gin.New()
app.router.Use(gin.Logger(), gin.Recovery(), metrics.HTTPMiddleware(), sessions.Sessions(
session.COOKIE_NAME,
session.NewSessionStore(app.cfg, app.store, []byte(app.cfg.JWTSecretKey)),
))
if len(app.cfg.BasePath) > 0 {
app.router.GET("/", func(c *gin.Context) {
c.Redirect(http.StatusFound, app.cfg.BasePath)
})
}
baserouter := app.router.Group(app.cfg.BasePath)
api.DeclareRoutes(
app.cfg,
baserouter,
api.Dependencies{
Authentication: app.usecases.authentication,
AuthUser: app.usecases.authUser,
CaptchaVerifier: app.captchaVerifier,
Domain: app.usecases.domain,
DomainInfo: app.usecases.domainInfo,
DomainLog: app.usecases.domainLog,
EmailAutoconfig: app.usecases.emailAutoconfig,
FailureTracker: app.failureTracker,
Provider: app.usecases.provider,
ProviderSettings: app.usecases.providerSettings,
ProviderSpecs: app.usecases.providerSpecs,
RemoteZoneImporter: app.usecases.orchestrator.RemoteZoneImporter,
Resolver: app.usecases.resolver,
Service: app.usecases.service,
ServiceSpecs: app.usecases.serviceSpecs,
Session: app.usecases.session,
User: app.usecases.user,
Zone: app.usecases.zone,
ZoneCorrectionApplier: app.usecases.orchestrator.ZoneCorrectionApplier,
ZoneImporter: app.usecases.orchestrator.ZoneImporter,
ZoneService: app.usecases.zoneService,
CheckerEngine: app.usecases.checkerEngine,
CheckerOptionsUC: app.usecases.checkerOptionsUC,
CheckPlanUC: app.usecases.checkerPlanUC,
CheckStatusUC: app.usecases.checkerStatusUC,
PlannedProvider: app.usecases.checkerScheduler,
BudgetChecker: app.usecases.checkerUserGater,
CountManualTriggers: app.cfg.CheckerCountManualTriggers,
NotificationDispatcher: app.usecases.notificationDispatcher,
NotificationRegistry: app.usecases.notificationRegistry,
NotificationChannels: app.store,
NotificationPrefs: app.store,
NotificationRecords: app.store,
},
)
web.DeclareRoutes(app.cfg, baserouter, app.captchaVerifier)
web.NoRoute(app.cfg, app.router)
}
func (app *App) Start() {
app.srv = &http.Server{
Addr: app.cfg.Bind,
Handler: app.router,
ReadHeaderTimeout: 15 * time.Second,
}
if app.insights != nil {
go app.insights.Run()
}
// Reconcile executions left "running" by a previous process that
// crashed or was killed mid-run, before the scheduler starts queuing
// new work.
if recoverer, ok := app.usecases.checkerEngine.(interface {
RecoverStaleExecutions(ctx context.Context) (int, error)
}); ok {
if n, err := recoverer.RecoverStaleExecutions(context.Background()); err != nil {
log.Printf("CheckerEngine: failed to recover stale executions: %v", err)
} else if n > 0 {
log.Printf("CheckerEngine: recovered %d stale execution(s) from previous run", n)
}
}
if app.usecases.checkerScheduler != nil {
app.usecases.checkerScheduler.Start(context.Background())
}
if app.usecases.checkerJanitor != nil {
app.usecases.checkerJanitor.Start(context.Background())
}
if app.usecases.checkerUserGater != nil {
app.usecases.checkerUserGater.Start(context.Background())
}
if app.usecases.notificationDispatcher != nil {
app.usecases.notificationDispatcher.Start()
}
log.Printf("Public interface listening on %s\n", app.cfg.Bind)
if err := app.srv.ListenAndServe(); err != nil && err != http.ErrServerClosed {
log.Fatalf("listen: %s\n", err)
}
}
func (app *App) Stop() {
// Stop background workers first so they don't dispatch new work while
// the HTTP server is draining. Each Stop() cancels its context and
// waits for in-flight goroutines to return.
if app.usecases.checkerScheduler != nil {
app.usecases.checkerScheduler.Stop()
}
if app.usecases.checkerJanitor != nil {
app.usecases.checkerJanitor.Stop()
}
if app.usecases.checkerUserGater != nil {
app.usecases.checkerUserGater.Stop()
}
// Drain in-flight notification sends after the scheduler is stopped
// so no new jobs can be enqueued while we wait.
if app.usecases.notificationDispatcher != nil {
app.usecases.notificationDispatcher.Stop()
}
ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second)
defer cancel()
if err := app.srv.Shutdown(ctx); err != nil {
// Don't log.Fatal here: that would skip the storage/insights
// cleanup below and risk leaving state on disk inconsistent.
log.Printf("Server Shutdown: %v", err)
}
// Close storage
if app.store != nil {
app.store.Close()
}
if app.insights != nil {
app.insights.Close()
}
if app.failureTracker != nil {
app.failureTracker.Close()
}
}