package main import ( "context" "errors" "fmt" "log/slog" "net/http" "os" "os/signal" "path/filepath" "strings" "sync" "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/diagnostics" "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/updateagent" "github.com/root-kdw12/hetzner-api-dyndns-records-webinterface/internal/web" ) func main() { command := commandForInvocation(os.Args) if command == "healthcheck" { os.Exit(healthcheck()) } logger := slog.New(slog.NewJSONHandler(os.Stdout, &slog.HandlerOptions{Level: slog.LevelInfo})) var err error switch command { case "serve": err = run(logger) case "update-agent": ctx, stop := signal.NotifyContext(context.Background(), os.Interrupt, syscall.SIGTERM) defer stop() err = updateagent.Serve(ctx, updateagent.ServerConfigFromEnvironment(), logger) default: err = errors.New("unknown command; expected serve, healthcheck, or update-agent") } if err != nil { logger.Error("application stopped", "error", err) os.Exit(1) } } func commandForInvocation(arguments []string) string { if len(arguments) == 0 { return "invalid" } // v1.3 Compose files override the agent entrypoint and inherit the image's // default "serve" command. Keep that one-time manual upgrade path working // while the image itself still contains only one executable. if filepath.Base(arguments[0]) == "update-agent" && (len(arguments) == 1 || (len(arguments) == 2 && arguments[1] == "serve")) { return "update-agent" } if len(arguments) == 1 { return "serve" } if len(arguments) == 2 { return arguments[1] } return "invalid" } func run(logger *slog.Logger) error { startedAt := time.Now() // Credentials and session data must never inherit a permissive process umask. syscall.Umask(0o077) cfg, err := config.Load() if err != nil { return err } if err := diagnostics.Configure(filepath.Join(cfg.DataDir, "diagnostics.jsonl"), logger); err != nil { logger.Warn("diagnostic log unavailable") } diagnostics.Record(diagnostics.Event{Code: "startup", Network: diagnostics.CurrentNetwork()}) 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.RecoverInterruptedRuns(ctx); err != nil { return fmt.Errorf("recover interrupted update runs: %w", err) } 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 { cleanupCtx, cancel := context.WithTimeout(ctx, 10*time.Second) deleted, cleanupErr := updater.DisableIPv6(cleanupCtx) cancel() if cleanupErr != nil { logger.Warn("enforce disabled IPv6", "error", cleanupErr) } else if deleted > 0 { logger.Info("removed AAAA records for disabled IPv6", "count", deleted) } } restartRequested := make(chan struct{}) var restartOnce sync.Once requestRestart := func() { restartOnce.Do(func() { // Docker applies restart policies only after a container has stayed up // long enough to be considered successfully started. Keep very early // UI requests alive until that safety window has passed. delay := time.Until(startedAt.Add(11 * time.Second)) if delay > 0 { time.AfterFunc(delay, func() { close(restartRequested) }) return } close(restartRequested) }) } serverUI, err := web.New(cfg, database, cipher, updater, softwareUpdates, logger, requestRestart) if err != nil { return err } appCtx, stop := signal.NotifyContext(context.Background(), os.Interrupt, syscall.SIGTERM) defer stop() updater.Start(appCtx) defer func() { stop() waitCtx, cancel := context.WithTimeout(context.Background(), cfg.ShutdownTimeout) defer cancel() if err := updater.Wait(waitCtx); err != nil { logger.Warn("waiting for updater shutdown", "error", err) } }() 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 <-restartRequested: logger.Info("container restart requested") stop() shutdownCtx, cancel := context.WithTimeout(context.Background(), cfg.ShutdownTimeout) defer cancel() return httpServer.Shutdown(shutdownCtx) 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 }