package updateagent import ( "context" "errors" "log/slog" "net" "net/http" "os" "path/filepath" "strings" "time" ) type ServerConfig struct { Socket string DockerSocket string ImageRepository string TargetLabel string } func ServerConfigFromEnvironment() ServerConfig { return ServerConfig{ Socket: environmentValue("UPDATE_AGENT_SOCKET", "/run/domain-control-update/agent.sock"), DockerSocket: environmentValue("UPDATE_DOCKER_SOCKET", "/var/run/docker.sock"), ImageRepository: environmentValue("UPDATE_IMAGE_REPOSITORY", defaultImageRepository), TargetLabel: environmentValue("UPDATE_TARGET_LABEL", "de.j-mahler.domain-control.role=app"), } } func Serve(ctx context.Context, config ServerConfig, logger *slog.Logger) error { if err := validateServerConfig(config); err != nil { return err } directory := filepath.Dir(config.Socket) if err := os.MkdirAll(directory, 0o711); err != nil { return err } info, err := os.Lstat(directory) if err != nil || !info.IsDir() || info.Mode()&os.ModeSymlink != 0 { return errors.New("update agent socket directory is not a regular directory") } if err := os.Chmod(directory, 0o711); err != nil { return err } if info, err := os.Lstat(config.Socket); err == nil { if info.Mode()&os.ModeSocket == 0 { return errors.New("update agent socket path is occupied by a non-socket file") } if err := os.Remove(config.Socket); err != nil { return err } } else if !errors.Is(err, os.ErrNotExist) { return err } listener, err := net.Listen("unix", config.Socket) if err != nil { return err } defer listener.Close() defer os.Remove(config.Socket) // The agent container runs as uid 0 with gid 10001. Group-only access lets // the unprivileged web process connect without exposing the control socket. if err := os.Chmod(config.Socket, 0o660); err != nil { return err } runner := NewDockerRunner(config.DockerSocket, config.ImageRepository, config.TargetLabel, logger) controller := NewController(runner, logger) server := &http.Server{ Handler: controller.Handler(), ReadHeaderTimeout: 3 * time.Second, ReadTimeout: 5 * time.Second, WriteTimeout: 5 * time.Second, IdleTimeout: 15 * time.Second, MaxHeaderBytes: 8 << 10, } serverErrors := make(chan error, 1) go func() { logger.Info("update agent ready", "socket", config.Socket) serverErrors <- server.Serve(listener) }() select { case err := <-serverErrors: if errors.Is(err, http.ErrServerClosed) { return nil } return err case <-ctx.Done(): shutdownCtx, cancel := context.WithTimeout(context.Background(), 5*time.Second) defer cancel() return server.Shutdown(shutdownCtx) } } func validateServerConfig(config ServerConfig) error { socket := filepath.Clean(config.Socket) dockerSocket := filepath.Clean(config.DockerSocket) if !filepath.IsAbs(socket) || filepath.Dir(socket) == "/" { return errors.New("update agent socket must use a dedicated absolute directory") } if !filepath.IsAbs(dockerSocket) || socket == dockerSocket { return errors.New("Docker socket path is invalid") } if config.ImageRepository == "" || strings.ContainsAny(config.ImageRepository, " \t\r\n") { return errors.New("update image repository is invalid") } if len(config.TargetLabel) == 0 || len(config.TargetLabel) > 256 || !strings.Contains(config.TargetLabel, "=") { return errors.New("update target label is invalid") } return nil } func environmentValue(name, fallback string) string { if value := strings.TrimSpace(os.Getenv(name)); value != "" { return value } return fallback }