package main import ( "context" "errors" "log/slog" "net/http" "os" "os/signal" "strings" "syscall" "time" "github.com/root-kdw12/hetzner-api-dyndns-records-webinterface/internal/appupdate" "github.com/root-kdw12/hetzner-api-dyndns-records-webinterface/internal/config" "github.com/root-kdw12/hetzner-api-dyndns-records-webinterface/internal/hetzner" "github.com/root-kdw12/hetzner-api-dyndns-records-webinterface/internal/ipresolve" "github.com/root-kdw12/hetzner-api-dyndns-records-webinterface/internal/scheduler" "github.com/root-kdw12/hetzner-api-dyndns-records-webinterface/internal/secure" "github.com/root-kdw12/hetzner-api-dyndns-records-webinterface/internal/store" "github.com/root-kdw12/hetzner-api-dyndns-records-webinterface/internal/web" ) func main() { if len(os.Args) > 1 && os.Args[1] == "healthcheck" { os.Exit(healthcheck()) } logger := slog.New(slog.NewJSONHandler(os.Stdout, &slog.HandlerOptions{Level: slog.LevelInfo})) if err := run(logger); err != nil { logger.Error("application stopped", "error", err) os.Exit(1) } } func run(logger *slog.Logger) error { // Credentials and session data must never inherit a permissive process umask. syscall.Umask(0o077) cfg, err := config.Load() if err != nil { return err } cipher, err := secure.LoadOrCreateCipher(cfg.MasterKeyFile) if err != nil { return err } ctx := context.Background() database, err := store.Open(ctx, cfg.DatabasePath) if err != nil { return err } defer database.Close() if err := database.Prune(ctx); err != nil { logger.Warn("prune old local data", "error", err) } provider := hetzner.New(cfg.HCloudBaseURL) updater := scheduler.New(database, provider, ipresolve.New(), cipher, logger) softwareUpdates, err := appupdate.New(database, cipher, cfg.UpdateAPIURL, cfg.UpdateAgentSock, cfg.UpdateInterval, logger) if err != nil { return err } settings, err := database.GetSettings(ctx) if err != nil { return err } if !settings.IPv6Enabled { deleted, cleanupErr := updater.DisableIPv6(ctx) if cleanupErr != nil { logger.Warn("enforce disabled IPv6", "error", cleanupErr) } else if deleted > 0 { logger.Info("removed AAAA records for disabled IPv6", "count", deleted) } } serverUI, err := web.New(cfg, database, cipher, updater, softwareUpdates, logger) if err != nil { return err } appCtx, stop := signal.NotifyContext(context.Background(), os.Interrupt, syscall.SIGTERM) defer stop() updater.Start(appCtx) softwareUpdates.Start(appCtx) go pruneStore(appCtx, database, logger) httpServer := &http.Server{ Addr: cfg.Address, Handler: serverUI.Handler(), ReadHeaderTimeout: 5 * time.Second, ReadTimeout: 15 * time.Second, WriteTimeout: 30 * time.Second, IdleTimeout: 60 * time.Second, MaxHeaderBytes: 16 << 10, } serverErrors := make(chan error, 1) go func() { logger.Info("starting server", "address", cfg.Address, "mode", cfg.Mode) serverErrors <- httpServer.ListenAndServe() }() select { case err := <-serverErrors: if !errors.Is(err, http.ErrServerClosed) { return err } return nil case <-appCtx.Done(): logger.Info("shutdown requested") shutdownCtx, cancel := context.WithTimeout(context.Background(), cfg.ShutdownTimeout) defer cancel() return httpServer.Shutdown(shutdownCtx) } } func pruneStore(ctx context.Context, database *store.Store, logger *slog.Logger) { ticker := time.NewTicker(24 * time.Hour) defer ticker.Stop() for { select { case <-ctx.Done(): return case <-ticker.C: pruneCtx, cancel := context.WithTimeout(ctx, 30*time.Second) err := database.Prune(pruneCtx) cancel() if err != nil && !errors.Is(err, context.Canceled) { logger.Warn("prune old local data", "error", err) } } } } func healthcheck() int { url := strings.TrimSpace(os.Getenv("APP_HEALTHCHECK_URL")) if url == "" { url = "http://127.0.0.1:8080/healthz" } client := &http.Client{Timeout: 3 * time.Second} response, err := client.Get(url) if err != nil { return 1 } defer response.Body.Close() if response.StatusCode != http.StatusOK { return 1 } return 0 }