Files
hetzner-api-dyndns-records-…/cmd/dyndns-web/main.go
T

220 lines
6.7 KiB
Go

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
}