#!/usr/bin/env perl

# Main qemu-web-desktop service script.
#
#   You must edit the /etc/qemu-web-desktop/config.pl file or config.d/*.pl to configure the service.
#   Think about editing the /usr/share/qemu-web-desktop/html/desktop/index.html
#   to comment/un-comment sections (GPU, oneshot).
#
#   When relevant, the equivalent OpenStack naming is indicated (as documented 
#   in https://docs.openstack.org/nova/latest/configuration/config.html)
#
# This script is triggered by a FORM or runs as a script.
# to test this script, launch from the project root level something like:
#
#   cd remote-desktop
#   perl cgi-bin/qemu-web-desktop.pl --dir_service=html/desktop \
#     --dir_html=html --dir_snapshots=/tmp \
#     --dir_machines=html/desktop/machines/ --oneshot=1
#
# Then follow printed instructions in the terminal:
# open a browser at something like:
# - http://localhost:38443/vnc.html?host=localhost&port=38443
#
# The script is effectively used in two steps (when executed as a CGI):
# - The HTML FORM launches the script as a CGI which starts the session.
#   QEMU and VNC are initiated, the message is displayed, but does not wait for
#   the end of the session.
#
# A running session with an attached JSON file can be stopped with:
#
#   perl qemu-web-desktop.pl --session_stop=/path/to/json
#
# To stop and clear all running sessions use:
#
#   perl qemu-web-desktop.pl --dir_snapshots=/tmp  --service_purge=1
#
# To monitor all running sessions, use:
#
#   perl cgi-bin/qemu-web-desktop.pl --service_monitor=1
#
#
# Requirements
# ============
# sudo apt install apache2 libapache2-mod-perl2
# sudo apt install qemu-system bridge-utils qemu iptables dnsmasq libguestfs-tools
# sudo apt install novnc websockify
#
# sudo apt install libsys-cpu-perl libsys-cpuload-perl libsys-meminfo-perl \
#   libcgi-pm-perl liblist-moreutils-perl libnet-dns-perl libjson-perl \
#   libproc-background-perl libproc-processtable-perl libemail-valid-perl \
#   libnet-smtps-perl libmail-imapclient-perl libnet-ldap-perl libemail-valid-perl \
#   libwww-perl liburi-perl libnet-ssh2-perl libtext-qrcode-perl
#
# (c) 2020- Emmanuel Farhi - GRADES - Synchrotron Soleil. AGPL3.
# https://gitlab.com/soleil-data-treatment/soleil-software-projects/qemu-web-desktop

# See 'support subroutines' below for in-line functions.

# ensure all fatals go to browser during debugging and set-up
# comment this BEGIN block out on production code for security
#  BEGIN {
#      $|=1;
#      use CGI::Carp('fatalsToBrowser');
#  }

# dependencies -----------------------------------------------------------------

use strict;
use warnings qw( all );

use CGI;                # use CGI.pm
use Cwd 'abs_path';     # for abs_path
use Date::Parse;        # to convert date/time
use File::Temp      qw/ tempdir tempfile /;
use File::Path      qw/ rmtree  /;
use File::Basename  qw/ fileparse dirname /;
use File::Copy      qw/ copy/;
use File::Spec;
use List::MoreUtils qw(uniq); # liblist-moreutils-perl
use List::Util;
use IO::Socket::INET;
use IO::Socket::IP;
use JSON;               # libjson-perl              for JSON
use Proc::Background;   # libproc-background-perl   for Background->new
use Proc::ProcessTable; # libproc-processtable-perl
use Proc::Killfam;      # libproc-processtable-perl for killfam (kill pid and children)
use Sys::MemInfo;

use Email::Valid;       # libemail-valid-perl
use Mail::IMAPClient;   # libmail-imapclient-perl   for imap user check
use LWP::UserAgent;     # libwww-perl               for new and get(URL)
use Net::Ping;          # perl                      to test server up-down
use Net::LDAP;          # libnet-ldap-perl          for ldap user check
use Net::SSH2;          # libnet-ssh2-perl          for local user check
use URI;                # liburi-perl               to split URL into parts

#   Sys::CpuLoad          libsys-cpuload-perl       for CpuLoad::load
#   Sys::CPU              libsys-cpu-perl           for CPU::cpu_count
#   System::CPU                                     for System::CPU::get_ncpu()
#   Net::SMTPS;           libnet-smtps-perl         for smtp user check and emailing
#   NOTE: we trap import errors (when not present)
BEGIN { eval { require Net::SMTPS;   }; Net::SMTPS->import;   }
BEGIN { eval { require Sys::CpuLoad; }; Sys::CpuLoad->import; }
BEGIN { eval { require Sys::CPU;     }; Sys::CPU->import;     }
BEGIN { eval { require System::CPU;  }; System::CPU->import;  }

# see http://honglus.blogspot.com/2010/08/resolving-perl-cgi-buffering-issue.html
$| = 1;
CGI->nph(1);

# use https://perl.apache.org/docs/2.0/api/Apache2/RequestIO.html
# for flush with CGI
my $r = shift;
if (not $r or not $r->can("rflush")) {
  push @ARGV, $r; # put back into ARGV when not a RequestIO object
}

use constant IS_MOD_PERL => exists $ENV{'MOD_PERL'};
use constant IS_CGI => IS_MOD_PERL || exists $ENV{'GATEWAY_INTERFACE'} || exists $ENV{'REQUEST_METHOD'};

# ------------------------------------------------------------------------------
#                 service configuration: tune for your needs
# ------------------------------------------------------------------------------

# NOTE: This is where you can tune the default service configuration.
#       Adapt the path, and default VM specifications.

# we use a Hash to store the configuration. This is simpler to pass to functions.
our %config;

$config{version}                  = "26.07.15";  # year.month.day

my $dirname = abs_path(dirname(__FILE__));

# Locations for configuration files
my @config_locations = (
    "$dirname/../etc/config.pl",
    "$dirname/../etc/config.d/*.pl",
    '/etc/qemu-web-desktop/config.pl',
    '/etc/qemu-web-desktop/config.d/*.pl',
    '/opt/homebrew/etc/qemu-web-desktop/config.pl',
    '/opt/homebrew/etc/qemu-web-desktop/config.d/*.pl',
);

my $config_found = 0;

# Search configuration file
foreach my $location (@config_locations) {
    # Unique file
    if (-e $location) {
        require $location;
        $config_found = 1;
    } else {
        # Files in config.d
        my @files = glob ($location);
        foreach my $file (@files) {
            if (-e $file) {
                require $file;
                $config_found = 1;
            }
        }
    }
}

unless ($config_found) {
    die "$0: Can not find config.pl or any .pl file in config.d (e.g. /etc/qemu-web-desktop/config.pl or $dirname/../config.pl)";
}

# search detached GPU (via vfio-pci). Only use video part, no audio.
# WARNING: Not supported on MacOS
if ($^O ne 'darwin') {
  # can be a VGA adaptor or a 'controller'
  my ($device_pci1, $device_model1, $device_name1) = pci_devices("lspci -nnk","vga","vfio");
  my ($device_pci2, $device_model2, $device_name2) = pci_devices("lspci -nnk","3d controller","vfio");
  
  my @gpu_pci  = (@$device_pci1,   @$device_pci2);
  my @gpu_model= (@$device_model1, @$device_model2);
  my @gpu_name = (@$device_name1,  @$device_name2);
  $config{gpu_model}             = \@gpu_model;
  $config{gpu_name}              = \@gpu_name;
  $config{gpu_pci}               = \@gpu_pci;
}

# ------------------------------------------------------------------------------
# update config with input arguments from the command line (when run as script)
# ------------------------------------------------------------------------------

# the 'service_monitor' can be set to true to generate a list of running sessions.
# each of these can display its configuration, and be stopped/cleaned.
$config{service_monitor}          = 0;
$config{session_stop}             = ""; # contains a json ref, stop service
$config{service_purge}            = 0;  # when true stop/clean all
$config{oneshot}                  = 0;

for(my $i = 0; $i < @ARGV; $i++) {
  $_ = $ARGV[$i];
  if(! $_) { next; }
  if(/--help|-h$/) {
    print STDERR "$0: launch a QEMU/KVM machine in a browser window. Version $config{version}\n\n";
    print STDERR "Usage: $0 --option1=value1 ...\n\n";
    print STDERR "Valid options are:\n";
    foreach my $key (sort keys %config) {
      print STDERR "  --$key=VALUE [$config{$key}]\n";
    }
    print STDERR "\nTo manually start virtual machine in DIR_VM/VM :\n";
    print STDERR "$0 --dir_machines=DIR_VM --machine=VM --dir_snapshots=/tmp --dir_cfg=/tmp --oneshot=1 --service_max_mem_fraction_nb_per_user=0.8 --service_max_cpu_fraction_nb_per_user=0.8 --snapshot_alloc_disk=30\n";
    print STDERR "or use: 'qwdctl start VM'.\n";
    exit;
  } elsif (/--version|-v$/) {
    print STDERR "$0 $config{version}\n\n";
    print STDERR "Copyright (C) 2020-     Synchrotron Soleil, AGPL3.\n";
    print STDERR "This is free software; see the source for copying conditions.  There is NO\n";
    print STDERR "warranty; not even for MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.\n\n";
    print STDERR "Written by Emmanuel Farhi <emmanuel.farhi\@synchrotron-soleil.fr>\n";
    exit;
  } elsif (/^--(\w+)=(\w+)$/) {      # e.g. '--opt=value'
    if (exists($config{$1})) {
      $config{$1} = $2;
    } 
  } elsif (/^--(\w+)=([a-zA-Z0-9_\ \"\.\-\:\~\\\/]+)$/) {      # e.g. '--opt=file'
    if (exists($config{$1})) {
      $config{$1} = $2;
    } 
  }
}

$config{session_nb}               = 0;

if ($config{session_stop}) { # from ARGS (command line)
  # wait for session to end, and clean files/PIDs.
  my $session_ref = session_load(\%config, $config{session_stop});
  if ($session_ref) {
    session_stop($session_ref);
  }
  exit;
}

# for I/O, to generate HTML display and email content.
my $error       = "";
my $output      = "";
my $output_email= ""; # message sent via email when MFA and check via email

# Check running snapshots and clean any left over.
{
  (my $err, my $nb) = service_housekeeping(\%config);  # see below for private subroutines.
  $error .= $err;
  $config{session_nb} = $nb;
};


if ($config{certificate_crt} and -e $config{certificate_crt} 
and $config{certificate_key} and -e $config{certificate_key}) {
  $config{http} = "https";  # secured connection with certificates
} else { $config{http} = "http"; }


# ------------------------------------------------------------------------------
# Session variables: into a hash as well.
# ------------------------------------------------------------------------------

my %session;

# transfer defaults
$session{machine}     = $config{machine};
# OpenStack DEFAULT.instance_name_template ?
$session{dir_snapshot}= tempdir(TEMPLATE => "$config{service}" . "_XXXXXXXX", 
  DIR => $config{dir_snapshots}) || die "$0: Can not create snapshot directory in $config{dir_snapshots}.";
$session{name}        = File::Basename::fileparse($session{dir_snapshot});
$session{snapshot}    = safe_path($session{dir_snapshot}, $config{service} . ".qcow2");
$session{json}        = safe_path($config{dir_cfg},       $session{name}   . ".json");
$session{output}      = safe_path($session{dir_snapshot}, "index.html");
$session{qrcode}      = "";

$session{user}        = "";
$session{password}    = "";
$session{persistent}  = "";  # not persistent implies lower server load
$session{cpu}         = int($config{snapshot_alloc_cpu});  # default: cores
$session{memory}      = int($config{snapshot_alloc_mem});  # default: in GB
$session{disk}        = int($config{snapshot_alloc_disk}); # default: only for ISO
$session{gpu}         = ""; # indicates PCI GPU passthrough request when not empty
$session{snapshot_lifetime} = int($config{snapshot_lifetime}); # default [sec]
$session{oneshot}     = $config{oneshot};

$session{user_email}  = "";

$session{date}        = localtime();
# see https://www.oreilly.com/library/view/perl-cookbook/1565922433/ch11s03.html#:~:text=To%20append%20a%20new%20value,values%20for%20the%20same%20key.
#   on how to handle arrays in a hash.
# push new PID: push @{ $session{pid} }, 1234;
# get PIDs:     my @pid = @{ $session{pid} };
$session{pid}         = ();     # we search all children in session_stop
# push @{ $session{pid} }, $$;    # do NOT add our own PID = common Perl for all cgi's
$session{pid_wait}    = $$;     # PID to wait for (daemon).
$session{port}        = $config{service_port}; # e.g. 6080 for websockify
$session{qemuvnc_ip}  = "127.0.0.1"; # OpenStack vnc.server_listen vnc.proxyclient_address
# cast a random token key for VNC: 8 random chars in [a-z A-Z digits]
sub rndStr{ join'', @_[ map{ rand @_ } 1 .. shift ] };
$session{token_vnc}   = rndStr (20, 'a'..'z', 'A'..'Z', 0..9);
$session{password_vnc}= rndStr (20, 'a'..'z', 'A'..'Z', 0..9);
$session{token_file}  = "";
$session{runs_as_cgi} = IS_CGI;
$session{url}         = "";

# auto: default QEMU executable
if ($config{qemu_exec} =~ /^(auto|host)$/) {
  my $arch=`uname -m`;
  if    ($arch =~ 'x86_64')          { $config{qemu_exec} = 'qemu-system-x86_64';  }
  elsif ($arch =~ /(aarch64|arm64)/) { $config{qemu_exec} = 'qemu-system-aarch64'; }
  else { die "$0: Can not determine qemu-system for architecture $arch. Set config{qemu_exec} in config.pl\n"; }
}
# auto: best QEMU options from host capabilities
if (not $error and $config{qemu_exec_options} =~ 'auto') {
  $config{qemu_exec_options} =~ s/auto//;
  my $accel=`$config{qemu_exec} -accel help`;
  my $mach=`$config{qemu_exec} -M help`;
  
  # test type of accelerator
  # usual case on Intel/AMD and Apple/Silicon
  if ($accel =~ "kvm") { 
    $config{qemu_exec_options} .= " -enable-kvm -cpu host";
    if ($mach =~ "Standard PC") { $config{qemu_exec_options} .= ",kvm=off"; }
  } elsif ($accel =~ "hvf") { $config{qemu_exec_options} .= " -accel hvf  -cpu host";  }

  # test type of target machine
  if ($mach =~ "Standard PC") {
    # usual case on Intel/AMD "PC"
    $config{qemu_exec_options} .= " -M pc";
    # test type of VGA (not on ARM)
    my $mach_vga= `$config{qemu_exec} -M pc -vga help`;
    if    ($mach_vga =~ "virtio") { $config{qemu_exec_options} .= " -vga virtio"; }
    elsif ($mach_vga =~ "qxl")    { $config{qemu_exec_options} .= " -vga qxl";    }
    else                          { $config{qemu_exec_options} .= " -device ramfb"; }
  } elsif ($mach =~ "virt") { 
    # Other, e.g. ARM
    $config{qemu_exec_options} .= " -M virt,iommu=smmuv3";
    $config{qemu_exec_options} .= " -device ramfb";
  }

# the device virtio-gpu is nice, but brings mouse pointer offset
#  if (`$config{qemu_exec} -device help` =~ "virtio-gpu") {
#    $config{qemu_exec_options} .= " -device virtio-gpu";
#  }

  # special case for ARM: require EFI boot
  if (`uname -m` =~ /(aarch64|arm64)/) {
    # search for EFI/ARM
    my @EFIs=('/usr/share/qemu-efi-aarch64/QEMU_EFI.fd',
              '/usr/share/qemu-efi/QEMU_EFI.fd',
              '/usr/share/ovmf/AARCH64/QEMU_EFI.fd',
              '/usr/share/edk2/arm/QEMU_EFI.fd',
              '/opt/homebrew/share/qemu/edk2-aarch64-code.fd');
    foreach my $EFI (@EFIs) {
      if (-f $EFI) {
        # Debian arm
        #   https://documentation.ubuntu.com/server/how-to/virtualisation/qemu/index.html
        #   https://wiki.debian.org/Arm64Qemu recommends to use the '-bios $EFI'
        # Special case for Apple/Silicon
        #   https://ww2.coastal.edu/mmurphy2/oer/qemu/aarch64/
        $config{qemu_exec_options} .= " -bios $EFI";
        last;
      }
    }
  }
} # end auto qemu options

# ------------------------------------------------------------------------------
# Update session info from CGI
# ------------------------------------------------------------------------------

$CGI::POST_MAX  = 65535;      # max size of POST message
my $q           = new CGI;    # create new CGI object "query"

if (my $res = $q->cgi_error()){
  if ($res =~ /^413\b/o) { $error .= "Maximum data limit exceeded. \n";  }
  else {                   $error .= "An unknown error has occured. \n"; }
}

$session{remote_host} = safe_path("",$q->remote_host()); # the 'client'
if ($session{remote_host} =~ "::1") {
  $session{remote_host} = "localhost";
}
$session{server_name} = safe_path("",$q->server_name()); # the 'server' OpenStack DEFAULT.host
if ($session{server_name} =~ "::1") {
  $session{server_name} = "localhost";
}
$config{server_name} = $session{server_name};

# check input arguments values (not 'password')
for ('machine','user','cpu','memory','gpu','snapshot_lifetime',
  'session_stop','service_purge','session_extend','oneshot','fallback_servers') {
  my $val = $q->param($_);
  if (defined($val)) {
    if ( $val =~ /^([a-zA-Z0-9_.\-@\: ]+)$/ ) {
      # all is fine
      $session{$_} = $val;
    } else {
      $error .= "$_ is not defined or contains invalid characters. \n";
    }
  }
}

# these are the "input" to collect from the HTML FORM. Store in $session
for ('password') {
  my $val = $q->param($_);
  if (defined($val)) {
    $session{$_} = $val;
  } 
}

# we get the list of fallback servers to be used when current machine is over-subscribed.
# The POST message will be updated with a reduced fallback_servers list
# and sent via LWP::UserAgent -> post
if (not defined $session{fallback_servers} and defined $config{fallback_servers}) {
  $session{fallback_servers} = $config{fallback_servers};
}
my $need_fallback_server = 0;


# check if the 'Manage button' was hit, instead of Create
if (defined $q->param('manage')) {
  $session{machine} = 'monitor';
}

if (not $session{runs_as_cgi}) {
  # not a CGI: running as detached script
  print STDERR $0.": WARNING: Running as detached script. No token. NO AUTHENTICATION.\n";
  $config{check_user_with_email}  = 0;
  $config{check_user_with_ldap}   = 0;
  $config{check_user_with_imap}   = 0;
  $config{check_user_with_smtp}   = 0;
  $session{password_vnc}          = "";
}

# assemble welcome message -----------------------------------------------------
#
# NOTE on HTML generation: from here on, $output only accumulates BODY content
# (status list, tables, messages). The full HTML document (doctype, <head>,
# shared <style>, and closing tags) is assembled exactly once, on demand, by
# html_page_wrap($title, $body_html) -- see 'support subroutines' below.
# This avoids duplicating the page skeleton at every exit point (session
# stop/extend, admin monitor/purge, error path, normal success path).

my $ok    = '<span class="badge ok">OK</span>';
my $title = "$config{service}: $session{machine}";

$output .= "<div class='card'>\n";

if (-f "$config{layout_header}") {
	# use custom header as file
	open FILE, "$config{layout_header}";
	$output .= join "", <FILE>;
	close FILE;
} elsif ("$config{layout_header}" ne "") {
  # use custom header as string
  $output .= "$config{layout_header}";
} else { # default header
  $output .= <<END_HTML;
  <a href="$config{http}://$session{server_name}/$config{service}/" target=_blank>
  <img alt="DARTS" title="DARTS" class="logo"
    src="$config{http}://$session{server_name}/$config{service}/images/darts_logo.png"></a>
END_HTML
}

# add title if missing
if ($output !~ m/<h1>/i) {
  $output .= "<h1>$config{layout_title}";

  if ($config{layout_level} >= 1) {
    $output .= ": $session{machine}";
  }
  $output .= "</h1>";
}
$output .= "<ul class='status'>"; # status list. Card + list are closed further down.

if ($config{layout_level} >= 2) {
  $output .= "<li>$ok Starting on $session{date}.</li>\n";
  $output .= "<li>$ok You are accessing this service from $session{remote_host}.</li>\n";
}

# stop/purge session from FORM - authentication is not needed as it was checked via previous form
if ($session{session_stop}) {
  # trigger session to end, and clean files/PIDs.
  my $session_ref = session_load(\%config, $session{session_stop});
  if ($session_ref) {
    session_stop($session_ref);
    my %session = %{ $session_ref };
    $output .= "</ul></div>"; # close status card
    $output .= "<div class='card'><p>$ok Stopped <b>$session{name} $session{machine}</b> that was started on [$session{date}] by "
      . safe_path("","$session{user} $session{user_email}") . "</p></div>";
    print "Content-type:text/html\r\n\r\n";
    print html_page_wrap($title, $output);
  }
  exit;
}

# extend session from FORM - authentication is not needed as it was checked via previous form
if ($session{session_extend}) {
  print STDERR $0.": Request session extension for $session{session_extend} +24h.\n";
  # add +24h to session life-time.
  my $session_ref = session_load(\%config, $session{session_extend});
  if ($session_ref) {
    session_extend($session_ref, 24*3600); # +24h
    my %session = %{ $session_ref };
    $output .= "</ul></div>"; # close status card
    $output .= "<div class='card'><p>$ok Extend <b>$session{name} $session{machine}</b> started on [$session{date}] by "
      . safe_path("","$session{user} $session{user_email}") . ": +24h</p></div>";
    print "Content-type:text/html\r\n\r\n";
    print html_page_wrap($title, $output);
  }
  exit;
}


# service monitoring requires user authentication
if ($session{machine} =~ 'monitor') {
  $config{service_monitor} = 1;
}

if ($session{machine} =~ 'purge' or $session{service_purge}) {
  $config{service_purge} = 1;
}

# handle 'admin' actions as well as stop/extend
if (not $error and ($config{service_monitor} or $config{service_purge} or $session{session_extend} or $session{session_stop})) {
  (my $out, my $err) = session_authenticate(\%config, \%session);
  
  if (not $err) { # must be identified
    my $user  = $session{user};
    my @admin = @{ $config{user_admin} };
    my $isadmin = 0;
    $session{machine} = "administration";
    if ($session{runs_as_cgi} and not grep( /^$user$/, @admin ) ) {
      $isadmin = 0;
    } else { $isadmin = 1; }
    
    if ($config{service_monitor}) {
      $output = service_monitor(\%config, $output, $isadmin, $user);
    } elsif ($config{service_purge}) {
      print STDERR $0." [$session{date}] Stopping all sessions.\n";
      service_purge(\%config);
      $output .= "</ul></div><div class='card'><h1>$ok Purged all</h1></div>";
    }
  } 
  
  if (not $err) {
    # display...
    print "Content-type:text/html\r\n\r\n";
    print html_page_wrap($title, $output);
    if (defined($r) and $r->can("rflush")) { 
      $r->rflush; 
    }
    exit;
  } else {
    $error .= $err;
  }
} # services


# ------------------------------------------------------------------------------
# Session checks: cpu, memory, disk, VM, config_script
# ------------------------------------------------------------------------------
$session{memory} = $session{memory}*1024; # GB -> MB

my $cpu_count    = 0;
if ($cpu_count == 0) {eval { $cpu_count = Sys::CPU::cpu_count();   }}
if ($cpu_count == 0) {eval { $cpu_count = System::CPU::get_ncpu(); }}

if ($config{service_monitor} or $config{service_purge}) {
  # admin actions must always be possible
} else {
  if ($config{service_max_session_nb} and $config{session_nb} > $config{service_max_session_nb}) {
    $error .= "Too many active sessions $config{session_nb}. Max $config{service_max_session_nb}. Try again later. \n";
    $need_fallback_server = 1;
  }
  # ignore error when CpuLoad module is not installed (Arch)
  eval {
    if ($config{service_max_load} and $config{service_max_load} < 1 and $cpu_count and $cpu_count-Sys::CpuLoad::load() < $session{cpu}) {
      $error .= "Not enough free CPU's (out of $cpu_count). Try again later. \n";
      $need_fallback_server = 1;
    }
    if ($config{service_max_load} and $config{service_max_load} < 1 and $cpu_count and Sys::CpuLoad::load() / $cpu_count > $config{service_max_load}) {
      $error .= "Server load exceeded. Try again later. \n";
      $need_fallback_server = 1;
    }
  };
  my ($total_memory_kb, $free_memory_kb, $avail_memory_kb) = get_freemem();
  if ($config{service_max_mem_fraction_nb_per_user} and $config{service_max_mem_fraction_nb_per_user} < 1 and $config{qemu_exec_options} !~ 'hvf' and $avail_memory_kb and $avail_memory_kb / 1024 < $session{memory}) { # in MB
    # The memory info is wrong on Mac OSX. Skip test in this case.
    $error .= "Not enough free memory ($avail_memory_kb kB), request $session{memory} MB. Try again later. \n";
    $need_fallback_server = 1;
  }
  if (not -e "$config{dir_machines}/$session{machine}") {
    $error .= "Can not find virtual machine $session{machine}.\n";
  }
  (my $nb_session, my $nb_cpu, my $nb_mem, my $nb_gpu) = service_check_user_max(\%config, $session{user});
  if ($config{service_max_session_nb_per_user} and $nb_session > $config{service_max_session_nb_per_user}) {
    $error .= "Too many opened sessions ($nb_session) for user, max $config{service_max_session_nb_per_user}. Please use [Manage sessions] to clear or reconnect to some already active sessions. \n";
    $need_fallback_server = 1;
  }
  if ($config{service_max_cpu_fraction_nb_per_user} and $config{service_max_cpu_fraction_nb_per_user} < 1 and $cpu_count and $nb_cpu/$cpu_count > $config{service_max_cpu_fraction_nb_per_user}) {
    $error .= "Too many used CPUs ($nb_cpu) for user, max $config{service_max_cpu_fraction_nb_per_user} fraction. Please use [Manage sessions] to clear or reconnect to some already active sessions. \n";
    $need_fallback_server = 1;
  }
  if ($config{service_max_mem_fraction_nb_per_user} and $config{service_max_mem_fraction_nb_per_user} < 1 and $avail_memory_kb and $nb_mem/($avail_memory_kb / 1024) > $config{service_max_mem_fraction_nb_per_user}) {
    $error .= "Too much memory ($nb_mem / $avail_memory_kb) for user, max $config{service_max_mem_fraction_nb_per_user} fraction. Please use [Manage sessions] to clear or reconnect to some already active sessions. \n";
    $need_fallback_server = 1;
  }
  if ($config{service_max_gpu_nb_per_user} and $nb_gpu > $config{service_max_gpu_nb_per_user}) {
    $error .= "Too many GPU ($nb_gpu) for user, max ($config{service_max_gpu_nb_per_user}). \n";
    $need_fallback_server = 1;
  }
}

# find a free GPU when requested
if (defined($session{gpu}) and $session{gpu} =~ /yes|gpu|true|1/i) { 
  # look for detached GPU PCI, and check if it is used by a running session
  $session{gpu} = "";
  foreach my $pci (@{ $config{gpu_pci} }) {
    if (not $session{gpu} and not session_use_gpu(\%config, $pci)) {
      $session{gpu} = $pci; # this is what we need to pass to qemu
    }
  }
  if ($session{gpu}) {
    if ($config{layout_level} >= 2) {
      $output .= "<li>$ok Assigned GPU at PCI $session{gpu}.</li>\n";
    }
  } else {
    $error .= "Can not find a free GPU as requested. Try again without. \n";
    $need_fallback_server = 1;
  }
}

# check if the current server is oversubscribed and there are fallback servers
if ($need_fallback_server and length($session{fallback_servers})) {

  # for loop: search a valid server separated with ','
  my $host="";
  my $fallback_server = "";
  my %valid_hosts = map { $_ => 1 } split /,/, $config{fallback_servers};

  for $host (split /,/, $session{fallback_servers}) {
    # get server to try: test if $host is not empty and active
    if ($valid_hosts{$host}) { # host must exist in the server config (to avoid injection)
      my $p   = Net::Ping->new();
      my $url = URI->new($host);
      my $h   = $host;
      if ($url->can("host")) { $h = $url->host; }
      if (length($host) and $url and $p->ping($h, 0.1)) {
        # server is active, remove it from the list of fallback_servers (replace with ' ')
        $session{fallback_servers} =~ s/$host/ /ig;
        if (not $url->scheme) {
          # add protocol/scheme
          $host = "$config{http}://$host";
        }
        if (not $url->path and IS_CGI) {
          # add CGI path
          $host .= $ENV{SCRIPT_NAME};
        }
        $fallback_server = $host;
        last;
      }
      $p->close();
    } else { $error .= "Invalid $host (not in server configuration). \n"; }
  }
  # end for. $host now contains the server to forward the POST to.
  
  if ($fallback_server) {
    # update the fallback_servers in the POST message
    print STDERR "$0: Warning: $error\n";
    print STDERR "$0: forwarding request to: $fallback_server\n";
    
    $q->param( fallback_servers => $session{fallback_servers} );
    
    # send POST message to $host server
    # ignore SSL certificate failures (we trust our fallback servers)
    my $ua = LWP::UserAgent->new(
      ssl_opts => { SSL_verify_mode => 0, verify_hostname => 0}); 
    # use a specific proxy, or system configuration (e.g. HTTP_PROXY env var)
    if ($config{service_proxy}) {
      $ua->proxy(['http', 'ftp', 'https'], $config{service_proxy});
    } else { $ua->env_proxy; }
    
    # forward POST with updated fallback_servers list
    my %params; # hash to store CGI arguments from the form
    foreach my $p ($q->param) {
      $params{$p} = $q->param($p);
    }
    
    # send to fallback server
    my $response = $ua->post($fallback_server, \%params); 

    # display message
    # NOTE: this is a full HTML document already rendered by the fallback
    # server itself, so it is passed through as-is (NOT re-wrapped with
    # html_page_wrap, which would double the page skeleton).
    if ($response->is_success) {
      my $output = $response->as_string();
      # must clean the message up to '<!DOCTYPE html'
      my $doctype_index = index($output, '<!DOCTYPE html'); # start at 0
      if ($doctype_index > -1) { 
        $output = substr($output, $doctype_index); # skip protocol info
      }
      print "Content-type:text/html\r\n\r\n";
      print "$output\n\n";
      $session{server_name} = $fallback_server;
      session_save(\%session);

      # exit
      exit
    } else {
      print STDERR "$0: forward not succesful from $fallback_server:\n";
      print STDERR $response->as_string();
    }
  } # else proceed with error message: no localhost cpu/mem/gpu resource left
} # fallback server handling (load levelling)

# ------------------------------------------------------------------------------
# User credentials checks, find communication ports
# ------------------------------------------------------------------------------
{
  (my $out, my $err, my $session_ref) = session_authenticate(\%config, \%session);
  $output .= $out;
  $error  .= $err;
  %session     = %{ $session_ref };
}

if (defined($session{oneshot}) and $session{oneshot} =~ /yes|oneshot|true|1/i) {
  if ($config{layout_level} >= 2) {
    $output .= "<li>$ok Using single login session (<b>one-shot</b> login).</li>\n";
  }
  $session{oneshot} = "yes";
  $session{token_vnc}= "";
  # must use one websockify per session
  $config{service_port_multiple} = 1;
} else {
  if ($config{layout_level} >= 2) {
    $output .= "<li>$ok Using multiple login session (reconnect/share).</li>\n";
  }
  $session{oneshot} = "";
}

# find a free port for websockify/noVNC on server. This is what goes in the URL given to user.
if ($session{port} and $config{service_port_multiple}) { 
  # range [service_port service_port+service_max_session_nb]
  my @port_list = List::Util::shuffle( $config{service_port} .. $config{service_port}+$config{service_max_session_nb}-1 );
  foreach my $port (@port_list) {
    my $socket = IO::Socket::INET->new(Proto => 'tcp', 
      LocalAddr => $session{qemuvnc_ip}, LocalPort => $port);
    if ($socket) { # must have been created on server
      $session{port} = $port;
      $socket->close;
      last;
    }
  }
} elsif (not $session{port})  {
  # port=0: request random port (0) to system in range [0 65535]
  my $socket = IO::Socket::INET->new(Proto => 'tcp', LocalAddr => $session{qemuvnc_ip});
  $session{port} = $socket->sockport();
  $socket->close;
}

my $port_vnc = undef;
# find another free VNC port at qemuvnc_ip (client). Random in 5900 range + max_nb
my @vnc_list = List::Util::shuffle( 
  $config{service_port_vnc} .. $config{service_port_vnc}+$config{service_max_session_nb}-1 );
for my $port (@vnc_list) {
  my $socket = IO::Socket::IP->new(Proto => 'tcp',
    PeerAddr => $session{qemuvnc_ip}, PeerPort => $port);
  if (not $socket) { # must not exist yet on client
    $port_vnc = $port;
    last;
  } else { $socket->close; }
}
if (not defined($port_vnc)) {
  $error .= "Can not find a port for the display. \n";
}
$session{port_vnc} = $port_vnc;

# ==============================================================================
# DO the work
# ==============================================================================

# NOTES: must make sure all commands redirect STDOUT to /dev/null not to collide
# with HTML generation. We use Proc::Background to launch tasks.

# Create snapshot --------------------------------------------------------------
if (not $error) {

  my $cmd  = "";
  my $res  = "";
  
  if ($session{machine} =~ /\.iso$/i) { # machine ends with .ISO
    $cmd = `qemu-img create -f qcow2 '$session{snapshot}' '$session{disk}G' > /dev/null 2>&1`;
    if ($config{layout_level} >= 2) {
      $output   .= "<li>$ok Will use ISO from ";
    }
  } else {
    # qemu-img now requires to specify master "backing" file format with '-F FMT'
    if ($config{snapshot_use_master} and -w "$config{dir_machines}/$session{machine}") {
      # option to NOT use snapshot. Work on master when writable.
      $session{snapshot} = "$config{dir_machines}/$session{machine}";
      if ($config{layout_level} >= 2) {
        $output  .= "<li>$ok Using master (changes will be kept) ";
      }
    } else {
      my $img_info = `qemu-img info '$config{dir_machines}/$session{machine}'`;
      my @backing_format = $img_info =~ /file format:\s*([a-z0-9]+)/i;
      my $F     = $backing_format[0];
      $res      = `qemu-img create -F $F -b '$config{dir_machines}/$session{machine}' -f qcow2 '$session{snapshot}' > /dev/null 2>&1`;
      if ($config{layout_level} >= 2) {
        $output  .= "<li>$ok Creating snapshot from ";
      }
    }
  }
  if ($config{layout_level} >= 2) {
    $output .= "<a href='$config{http}://$session{server_name}/$config{service}/machines/$session{machine}' target=_blank>$session{machine}</a> as session $session{name}</li>\n";
  }
  
  # check for existence of cloned VM
  sleep(1); # make sure the VM has been cloned
  if (not $error and not -e $session{snapshot}) {
    $error .= "Could not clone $session{machine} into snapshot. \n";
  }
} # Create snapshot

# LAUNCH optional 'configuration' scripts (system wide, and user level)
my $qemu_options="";
{
  my $virt_options="";
  my $cmd_vc="";
  my $cmd_qemu="";
  if (not $error and $config{config_script}) {
    if (!ref($config{config_script})) { # is string
      ($cmd_vc, $cmd_qemu) = session_run_script(\%config, \%session, $config{config_script});
      $qemu_options .= " $cmd_qemu";
      $virt_options .= " $cmd_vc";
    } else {                            # is ARRAY
      foreach my $c (@{ $config{config_script} }) {
        ($cmd_vc, $cmd_qemu) = session_run_script(\%config, \%session, $c);
        $qemu_options .= " $cmd_qemu";
        $virt_options .= " $cmd_vc";
      }
    }
  }
  if ($virt_options && $virt_options =~ /\S/) {
    # execute virt-customize with the list of all scripts to execute, in order
    # WARNING: NOT supported under macos
    my $res = `virt-customize -a '$session{snapshot}' $virt_options`;
  }
  if ($qemu_options && $qemu_options =~ /\S/) {
    if (rindex($qemu_options, "qemu:", 0) == 0) {
      # set QEMU options
      $qemu_options =~ s/qemu:/ /g;  # replace by space
    }
  }
}

# LAUNCH CLONED VM -------------------------------------------------------------
my $proc_qemu  = ""; # REQUIRED killed at END
if (not $error) {
  
  # common options for QEMU
  my $cmd = "$config{qemu_exec} -smp $session{cpu} "
    . " -name $session{name}:$session{user}:$session{machine}"
    . " $config{qemu_exec_options}"
    . " -m $session{memory} -device virtio-balloon"
    . " -hda $session{snapshot} -device ich9-ahci,id=ahci"
    . " -netdev user,id=mynet0 -device virtio-net,netdev=mynet0 -usb"
    . " -device qemu-xhci -device usb-kbd -device usb-tablet $qemu_options";
    
  # add copy/paste support (not always functional)
  $cmd .= " -chardev qemu-vdagent,id=ch1,name=vdagent,clipboard=on -device virtio-serial-pci"
       .  " -device virtserialport,chardev=ch1,id=ch1,name=com.redhat.spice.0";
    

  # performance options: network 
  #   see: https://elinux.org/images/3/3b/Kvm-network-performance.pdf
  # should use virtio-net. e1000 is best among emulated devices
  #   -netdev user,id=mynet0 -device virtio-net,netdev=mynet0
  
  # performance options: disk
  #   -device ich9-ahci,id=ahci
  
  # performance options: memory
  #   -device virtio-balloon (allows to only assign what is used by guests)
      
  # handle ISO boot
  if ($session{machine} =~ /\.iso$/i) {
    $cmd .= " -boot d -cdrom $config{dir_machines}/$session{machine}";
  } else {
    $cmd .= " -boot c";
  }
  
  # attach GPU on pre-assigned PCI
  if ($session{gpu}) {
    $cmd .= " -device vfio-pci,host=$session{gpu},multifunction=on";
  }
  
  # we add mounts using QEMU virt-9p, with tags 'host_<last_word>'
  #   see https://wiki.qemu.org/Documentation/9psetup
  # mounts are activated in the guest with:
  #   mount -t 9p -o trans=virtio,access=client [mount tag] [mount point]
  my @mounts = @{ $config{dir_mounts} };
  for(my $i = 0; $i <= $#mounts; $i++) {
    if (-d $mounts[$i]) { # mount must exist as a directory
      my $tag = (split '/', $mounts[$i])[-1];
      $cmd .= " -fsdev local,security_model=none,id=fsdev$i,path=$mounts[$i] -device virtio-9p-pci,id=fs$i,fsdev=fsdev$i,mount_tag=host_$tag";
    }
  }
  
  # add QEMU internal VNC
  my $port_vnc_5900=$port_vnc-5900;
  $cmd .= " -vnc $session{qemuvnc_ip}:$port_vnc_5900";
  
  my ($token_handle, $token_name) = tempfile(UNLINK => 1);
  # request VNC token when using multiple ports or MFA
  if ($session{password_vnc} and ($config{service_port_multiple} or $config{check_user_mfa})) {
    # must avoid output to STDOUT, so redirect STDOUT to NULL.
    #   file created just for the launch, removed immediately. 
    #   Any 'pipe' such as "echo 'change vnc password\n$token_vnc\n' | qemu ..." is shown in 'ps'.
    #   With a temp file and redirection, the token does not appear in the process list (ps).

    print $token_handle "change vnc password\n$session{password_vnc}\n";
    close($token_handle);
    # redirect 'token' to QEMU monitor STDIN to set the VNC password
    $cmd .= ",password=on -monitor stdio > /dev/null < $token_name";
  } else {
    $cmd .= ' > /dev/null';
  }
  
  # as stated in 
  # https://stackoverflow.com/questions/6024472/start-background-process-daemon-from-cgi-script
  # it is probably better to use 'batch' to launch background tasks.
  #   system("at now <<< '$cmd'")
  # $proc_qemu = system("echo '$cmd' | at now") || "";
  $proc_qemu = Proc::Background->new($cmd);
  if (not $proc_qemu or not $proc_qemu->pid) {
    $error  .= "Could not start QEMU/KVM for $session{machine}.\n";
  } else {
    print STDERR $0.": $session{name}: START: $cmd\n";
    if ($config{layout_level} >= 2) {
      $output .= "<li>$ok Started QEMU/KVM for $session{machine} with VNC.</li>\n";
    }
    push @{ $session{pid} }, $proc_qemu->pid;
  }
  sleep(1);
  unlink($token_name);
} # LAUNCH CLONED VM

# LAUNCH NOVNC (do not wait for VNC to stop) -----------------------------------
my $proc_novnc  = ""; # REQUIRED killed at END
# default is to use a single websockify instance (daemon)
my $dir_token   = $config{dir_snapshots} . "/websockify-target.d";
if ($config{service_port_multiple}) {
  # store the token in each snapshot
  $dir_token = $session{dir_snapshot} . "/websockify-target.d";
}
if (not $error and not -e "$dir_token") {
  if (not mkdir($dir_token), 0700) { 
    $error .= "Could not create directory for tokens. \n"
  };
}

# push the token into websocket dir
if (not $error and $session{token_vnc}) {
  $session{token_file} = "$dir_token/$session{token_vnc}";
  open my $fh, ">", $session{token_file} or die "$0: FATAL: Cannot open $session{token_file}: $!";;
  print $fh "$session{token_vnc}: $session{qemuvnc_ip}:$session{port_vnc}\n";
  close $fh;
}

if (not $error) {
  # we set a timeout for 1st connection, to make sure the session does not block
  # resources. Also, by setting a log record to the snapshot, we can add the 
  # session name to the process command line. Used for parsing PIDs.
  my $cmd = "$config{dir_websockify}" .
    " --web $config{dir_novnc}";
    
  if (not $config{service_port_multiple} and $session{token_file}) {
    $cmd .= " -D" .
      " --token-plugin=TokenFile" .
      " --token-source=$dir_token";
  }
  
  if ($session{oneshot}) { $cmd .= " --run-once"; }
  
  if ($config{certificate_crt} and -e $config{certificate_crt}) {
    $cmd .= " --cert=$config{certificate_crt}";
  }
  if ($config{certificate_key} and -e $config{certificate_key}) {
    $cmd .= " --key=$config{certificate_key}";
  }
  
  # extremely important: the websockify must run ONLY on localhost.
  # The Apache server ensures the connection via the reverse proxy.
  if ($config{service_reverse_proxy}) {
    # websockify serves 127.0.0.1
    $cmd .= " $session{qemuvnc_ip}:$session{port}";
  } else {
    # websockify serves 0.0.0.0 (all)
    $cmd .= " $session{port}";  
  }
  if ($config{service_port_multiple}) {
    $cmd .= " $session{qemuvnc_ip}:$session{port_vnc}";
  }

  # $proc_novnc = system("echo '$cmd' | at now") || "";
  $proc_novnc = Proc::Background->new($cmd);
    
  if ($config{service_port_multiple}) {
    if (not $proc_novnc or not $proc_novnc->pid) {
      $error .= "Could not start websockify/noVNC. \n";
    } else {
      # need to clear the multiple websockify calls
      push @{ $session{pid} }, $proc_novnc->pid;
    }
  }
} # LAUNCH NOVNC

# store the PID to wait for. Depends on persistent state.
if (not $error) {
  if ($proc_novnc and $proc_qemu) { 
    if (not $session{oneshot}) { 
      # qemu and session{name} alow to find the PID
      $session{pid_wait} = $proc_qemu->pid;
    } else {
      # $config{dir_novnc}/utils/websockify/run and $session{port}
      $session{pid_wait} = $proc_novnc->pid;
    }
  }
  # build the session URL
  my $append=$config{service_reverse_proxy}; # may be "" or ":port" or "/alias"
  if ($append eq "" or $config{service_port_multiple}) {
    $append=":$session{port}"; # default
  }
# set resize='scale' or 'remote'
  my $resize='scale';
  if ($config{layout_resize}) { $resize=$config{layout_resize}; }
  if ($config{service_port_multiple}) {
    $session{url} = "$config{http}://$session{server_name}$append/vnc.html?resize=$resize&autoconnect=true&host=$session{server_name}&port=$session{port}";
  } else {
    # in case of an Apache2 port redirection, set configuration:
    #   $config{service_port_multiple}    = 0;
    #   $config{service_reverse_proxy}    = "/darts";
    $session{url} = "$config{http}://$session{server_name}$append/vnc.html?resize=$resize&autoconnect=true&path=?token=$session{token_vnc}";
  }
}

# COMPLETE OUTPUT MESSAGE ------------------------------------------------------
if (not $error) {

  # save session info only when no error if found
  session_save(\%session);

  if ($config{layout_level} >= 2) {
    $output .= "<li>$ok Connect to your machine at <a href=$session{url} target=_blank>$session{url}</a>.</li>\n";
  }
  if ($session{password_vnc} and $config{service_port_multiple}
  and $config{layout_level} >= 2) {
    # this is also displayed as an H2 tag below
    $output .= "<li><b>$ok Connection password (noVNC) is: $session{password_vnc}</b></li>\n";
  }
  if ($session{snapshot_lifetime}) {
    if ($config{layout_level} >= 1) {
      my $datestring = localtime(time()+$session{snapshot_lifetime});
      $output .= "<li>$ok You can use your session until <b>$datestring.</b></li>\n";
    }
  }
  $output .= "</ul></div>\n"; # close status card

  # prepare some snippets
  my $output_token      = ""; # security code if any
  my $output_share      = ""; # how to share
  my $output_persistent = ""; # show reconnect info
  my $output_icon       = ""; # QR code
  
  if ($session{password_vnc} and ($config{service_port_multiple} or $config{check_user_mfa})) {
    $output_token = "<h2>Session password: $session{password_vnc}</h2>";
  }
  if (not $session{oneshot}) {

    if ($config{layout_level} >= 1) {
      $output_share = "<button onClick=\"SelfCopy(this.id)\" id=\"$session{url} $session{password_vnc}\" class=\"btn copy\" title=\"Copy link to clipboard, share it with your colleagues.\">&#128279;&nbsp;Share</button>";
      
      $output_persistent = "NOTES: <ul>" .
      "<li>You can close the browser and reconnect any time (within life-time) with the link above.</li>" .
      "<li>Select the <b><span style=\"color:green\">[Manage sessions]</span></b> item in the service login page to list, reconnect or abort your sessions.</li>" .
      "<li>You can collaborate in the same session with your colleagues. Just send them the link above.</li>";
    }
  } elsif ($config{layout_level} >= 1) {
    $output_persistent = "NOTES: <ul>";
  }
  
  # Conditional import of Text::QRCode module when installed
  if ($config{layout_level} >= 1) {
    $output_icon = "<img alt='$session{machine}' title='$session{machine}'
        src='$config{http}://$session{server_name}/$config{service}/images/logo-system.png'
        align='center' border='1' height='128'>";
  
    my $qr_enabled = eval
    {
      require Text::QRCode;
      Text::QRCode->import();
      1;
    };
    
    if ($qr_enabled) {
      # https://metacpan.org/pod/Text::QRCode
      my $arrayref = Text::QRCode->new()->plot("$session{url}");
      $session{qrcode} = join "\n", map { join '', @$_ } @$arrayref; 
    
      # code from HTML::QRCode
      my $w = "<td style=\"border:0;margin:0;padding:0;width:3px;height:3px;background-color: white;\">";
      my $b = "<td style=\"border:0;margin:0;padding:0;width:3px;height:3px;background-color: black;\">";
   
      $output_icon
          = '<table class="qrcode" style="margin:0;padding:0;border-width:0;border-spacing:0;">';
      $output_icon
          .= '<tr style="border:0;margin:0;padding:0;">'
          . join( '', map { $_ eq '*' ? $b : $w } @$_ ) . '</tr>'
          for (@$arrayref);
      $output_icon .= '</table>';
    }
  } # qr code
  
  if ($config{layout_level} >= 1) {
    $output_persistent .= "<li>This session has been created on request, and is NOT kept. ". 
      "Your work <b>must be saved elsewhere</b> " .
      "(e.g. mounted disk, sshfs/scp/sftp, Dropbox, OwnCloud...).</li></ul>";
  }
  
  $output_email = $output;

  # generate main text for the email/MFA, always with VNC token, no QR code (too big)
  $output_email .= session_message(\%config, \%session,
                    "<h2>Session: $session{name}</h2><h2>Connect: $session{url}</h2>", "", $output_token, "");
  # generate main text for the user (skip VNC token when MFA is on)
  if ($config{check_user_mfa} && $session{user_email}) {
    $output_token = "<h2>The session password has been sent to $session{user_email}</h2>\n";
  }
  $output .= session_message(\%config, \%session,
                    $output_icon, $output_share, $output_token, $output_persistent);


} else {
  $output .= "</ul></div>\n"; # close status card
  $output .= "<div class='card'><h2><span class='badge err'>ERROR</span></h2>\n";
  $output .= "<p>$error</p></div>\n";
}

# send message via email when possible
if (($config{check_user_with_email} or $config{check_user_mfa}) and not $error) {
  # send the full output message (with URL and VNC token) - must be a
  # complete, standalone HTML document, since it goes out as an email body.
  if ($session{user_email}) {
    my $ret = session_email(\%config, \%session, html_page_wrap($title, $output_email));
    if ($ret) {
      print STDERR $0.": $session{name}: FAILED sending email to $session{user_email}: $ret\n";
      $error .= "FAILED sending email to $session{user_email}: $ret \n";
    }
  } else {
    $error .= "This service requires a valid email. \n"; # fall-back to display
  }
}

if ($config{service_logfile} and ! -f $config{service_logfile}) {
  # create log file if not there yet
  if (open my $fh, ">", $config{service_logfile}) {
    print $fh "# $config{service} log file\n";
    close $fh;
  } else {
    print STDERR "$0: WARNING: No log file. Cannot open $config{service_logfile}: $!\n";
    $config{service_logfile} = "";
  }

}

# write an HTML file to store the message output
# display the output message (redirect) ----------------------------------------
if (not $error) {
  open my $fh, ">", $session{output} or die "$0: FATAL: Cannot open $session{output}: $!\n";
  print $fh html_page_wrap($title, $output);
  close $fh;
  
  print STDERR $0.": $session{name}: START  $session{machine} on $session{date}\n";
  print STDERR $0.": $session{name}: USER:  $session{user} $session{user_email}\n";
  # print STDERR $0.": $session{name}: json:  $session{json}\n";
  print STDERR $0.": $session{name}: GPU:   $session{gpu}\n"    if ($session{gpu});
  # print STDERR $0.": $session{name}: PIDs:  @{$session{pid}}\n" if ($session{pid});
  print STDERR $0.": $session{name}: URL:   $session{url}\n";
  
  if ($config{service_logfile}) {
    if (open my $fh, ">>", $config{service_logfile}) {
      print $fh "[$session{date}] START CPU=$session{cpu} MEM=$session{memory} GPU=$session{gpu} USER=$session{user} VM=$session{machine} NAME=$session{name}\n";
      close $fh;
    }
  }
} else {
  print STDERR $0.": $session{name}: $session{date}: ERROR $error\n";
  if ($config{service_logfile}) {
    if (open my $fh, ">>", $config{service_logfile}) {
      print $fh "[$session{date}] ERROR CPU=$session{cpu} MEM=$session{memory} GPU=$session{gpu} USER=$session{user} VM=$session{machine} $error\n";
      close $fh;
    }
  }
}
if ($session{runs_as_cgi}) {
  # CGI redirect works, but requires that the temporary file be accessible
  # my $redirect="http://$session{server_name}/qemu-web-desktop/snapshots/$session{name}.html";
  # print $q->redirect($redirect); # this works (does not wait for script to end before redirecting)
  
  if (not $error) { sleep($config{boot_delay}); } # make sure the display comes in.

  print "Content-type:text/html;\r\n\r\n";
  print html_page_wrap($title, $output);

  # the 1st argument of CGI is an Apache RequestIO
  if (defined($r) and $r->can("rflush")) {
    $r->rflush;
  }
}

# wait for end of processes.
if (not $error and $proc_novnc and $proc_qemu and $session{oneshot}) { 
	$proc_novnc->wait;
	session_stop(\%session);
}

exit; # end main

# ==============================================================================



























# ==============================================================================
# support subroutines
#
#  sub html_page_wrap
#  sub session_save
#  sub session_load
#  sub session_stop
#  sub session_extend
#  sub session_test_script
#  sub session_run_script
#  sub service_housekeeping
#  sub service_monitor
#  sub service_purge
#  sub session_email
#  sub session_message
#  sub service_check_user_max
#  sub session_authenticate
#  sub session_check_smtp
#  sub session_check_imap
#  sub session_check_ldap
#  sub session_check_local
#  sub session_check_custom
#  sub session_use_gpu
#  sub flatten
#  sub proc_getchildren
#  sub proc_running
#  sub pci_devices
#  sub get_freemem
#  sub safe_path

# ==============================================================================

# html_page_wrap($title, $body_html): wrap a body fragment into a complete,
#   standalone HTML document (doctype, head, shared style, body, closing tags).
#   This is the ONLY place the page skeleton and CSS are defined, so every
#   exit point in the script (session create/stop/extend, admin monitor/purge,
#   error display, email body, saved snapshot index.html) renders identically
#   and consistently, without duplicating markup.
#   $body_html is expected to be body-only content (no <html>/<head>/<body> tags).
# return: $html (complete document, string)
sub html_page_wrap {
  my $title     = shift;
  my $body_html = shift;

  $title     = "" if not defined $title;
  $body_html = "" if not defined $body_html;

  my $html = <<END_HTML;
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<meta http-equiv="Content-Security-Policy" content="default-src 'self'; script-src 'self' 'unsafe-inline'; style-src 'self' 'unsafe-inline'; img-src 'self' data:; connect-src 'self'; frame-src 'self';">
<meta http-equiv="X-Content-Type-Options" content="nosniff">
<meta http-equiv="X-Frame-Options" content="DENY">
<meta http-equiv="X-XSS-Protection" content="1; mode=block">
<title>$title</title>
<style>
  :root{
    --bg:#f4f6f8; --card:#ffffff; --border:#d9e0e6;
    --text:#1f2933; --muted:#6b7785;
    --ok:#1b8a3d; --warn:#b8860b; --err:#c0392b;
    --accent:#2563eb;
  }
  *{box-sizing:border-box}
  body{
    margin:0; padding:24px; background:var(--bg);
    font-family:-apple-system,Segoe UI,Roboto,Helvetica,Arial,sans-serif;
    color:var(--text); line-height:1.5;
  }
  .wrap{max-width:900px; margin:0 auto}
  .card{
    background:var(--card); border:1px solid var(--border);
    border-radius:10px; padding:20px 24px; margin-bottom:18px;
    box-shadow:0 1px 3px rgba(0,0,0,.05);
  }
  .card h1{font-size:1.4rem; margin:0 0 4px 0}
  .card h2{font-size:1.1rem; margin:0 0 10px 0}
  .logo{float:right; height:64px; width:auto; border-radius:6px}
  .badge{
    display:inline-block; padding:2px 9px; border-radius:12px;
    font-size:.8rem; font-weight:600; color:#fff;
  }
  .badge.ok{background:var(--ok)}
  .badge.warn{background:var(--warn)}
  .badge.err{background:var(--err)}
  ul.status{list-style:none; margin:0; padding:0}
  ul.status li{padding:4px 0; border-bottom:1px dashed var(--border)}
  ul.status li:last-child{border-bottom:none}
  table{width:100%; border-collapse:collapse; margin-top:8px; font-size:.92rem}
  th, td{padding:8px 10px; border:1px solid var(--border); text-align:left; vertical-align:middle}
  th{background:#eef1f4; font-weight:600}
  tr:hover td{background:#fbfbe8}
  table.qrcode{width:auto; margin:0 auto; border-collapse:collapse}
  table.qrcode td{padding:0; border:0}
  input:hover{background-color:lightgreen; color:black}
  select:hover{background-color:lightgreen}
  .btn{
    display:inline-block; padding:6px 14px; border-radius:6px;
    background:var(--accent); color:#fff !important; text-decoration:none;
    font-weight:600; font-size:.9rem; font-family:inherit; border:none; cursor:pointer;
    line-height:1.4;
  }
  .btn.connect{background:var(--ok)}
  .btn.stop{background:var(--err)}
  .btn.copy{background:#fff; color:var(--accent) !important; border:1px solid var(--accent)}
  .footer{color:var(--muted); font-size:.85rem; text-align:center; margin-top:20px}
</style>
</head>
<body>
<div class="wrap">
$body_html
</div>
<script>
function SelfCopy(copyText)
{
    navigator.clipboard.writeText(copyText);
    alert("Copied to clipboard for sharing: " + copyText);
}
</script>
</body>
</html>
END_HTML

  return $html;
} # html_page_wrap

# session_save(\%session): save session hash into a JSON.
sub session_save {
  my $session_ref  = shift;
  
  if (not $session_ref) { return; }
  my %session = %{ $session_ref };
  
  # Remove sensitive data before saving
  my %safe_session = %session;
  delete $safe_session{password};
  delete $safe_session{token_vnc};

  # Set restrictive permissions
  umask(0077);
  open my $fh, ">", $session{json} or die "$0: Cannot write $session{json}: $!";
  print $fh JSON::encode_json(\%safe_session);
  close $fh;
  chmod(0600, $session{json});  # Owner read/write only
} # session_save

# $session = session_load(\%config, $file): load session hash from JSON.
#   return $session reference
sub session_load {
  my $config_ref  = shift;
  my $file        = shift;
  
  if (not $config_ref or not $file) { return undef; } 
  my %config      = %{ $config_ref };

  # we test if the given ref is partial (just session name)
  if (not -e $file) {
     if (-e "$config{dir_cfg}/$file" or -e "$config{dir_cfg}/$file.json") {
      $file = "$config{dir_cfg}/$file";
    }
    if (-e "$file.json") {
      $file = "$file.json";
    }
  }
  if (not -e $file) { return undef; }
  
  open my $fh, "<", $file or return undef; # "$0: Cannot read $file: $!";
  my $json = <$fh>;
  close $fh;
  my $session = decode_json($json);
  return $session;
} # session_load

# session_stop(\%session): stop given session, and remove files.
sub session_stop {
  my $session_ref  = shift;
  if (not $session_ref) { return; }
  
  my %session = %{ $session_ref };

  # remove directory and JSON config
  if ($session{dir_snapshot} and -e $session{dir_snapshot})  
    { rmtree($session{dir_snapshot}); } 
  if ($session{json} and -e $session{json})          
    { unlink($session{json}); }
  if ($session{output} and -e $session{output})          
    { unlink($session{output}); }
  if ($session{token_file} and -e $session{token_file})
    { unlink($session{token_file}); }
  if ($session{qrcode} and -e $session{qrcode})
    { unlink($session{qrcode}); }
  
  my $now         = localtime();
  if ($session{remote_host}) { 
    print STDERR $0." [$now] STOP $session{name} $session{machine} started on [$session{date}] for $session{user}\@$session{remote_host} $session{user_email}\n";
  }
  
  # make sure QEMU/noVNC and asssigned SHELLs are killed
  # sometimes, PID's can change (more forks e.g. by websocket)
  if ($session{pid}) {
    my @pids = reverse uniq sort @{ $session{pid} };
    print STDERR $0." [$now]   Kill @pids\n";
    map {
      my @all = flatten(proc_getchildren($_));  # get all children from that PID
      killfam('KILL', reverse uniq sort @all);  # by reverse creation date/PID
    } reverse uniq sort @pids;
  }
  if ($config{service_logfile}) {
    if (open my $fh, ">>", $config{service_logfile}) {
      print $fh "[$now] STOP  CPU=$session{cpu} MEM=$session{memory} GPU=$session{gpu} USER=$session{user} VM=$session{machine} NAME=$session{name}\n";
      close $fh;
    }
  }
  
} # session_stop

# $session = session_extend(\%session, $extension): sextend session life-time
sub session_extend {
  my $session_ref    = shift;
  my $time_extension = shift;
  if (not $session_ref) { return; }
  
  my %session = %{ $session_ref };
  
  # extend snapshot_lifetime when it does not exceed $config{snapshot_lifetime}
  if ($session{snapshot_lifetime}
            and ($session{snapshot_lifetime} < $config{snapshot_lifetime}) ) {
    print STDERR $0." [$session{date}] Extend session $session{name} by $time_extension s.\n";
    $session{snapshot_lifetime} = $session{snapshot_lifetime} + $time_extension;
    # save new config
    session_save(\%session);
  }
  
} # session_extend

# (TF, $content) = session_test_script(\%session, content)
# Test the script '$content' after replacing symbols
#   The test expression must be found with syntax 'if(exp):'. It is extracted,
#   removed from the $content (which is returned after removal). Then it is 
#   evaluated, and its result is placed in TF (returned). False is returned
#   when the test can not be evaluated (fails).
# return (TF, $content)
sub session_test_script {
  my $session_ref = shift;
  my $content     = shift;
  my $TF          = 0;
  
  if (not $session_ref) { return (0,$content); }
  if (not $content)     { return (0,$content); }
  
  my %session = %{ $session_ref };
  
  # replace @symbols@ (globally)
  $content=~s/\@USER\@/$session{user}/g;
  $content=~s/\@SESSION_NAME\@/$session{name}/g;
  $content=~s/\@VM\@/$session{machine}/g;
  $content=~s/\@PW\@/$session{password}/g;
  
  # trim
  $content =~ s/^\s+|\s+$//g ; 

  # now search for the expression "if(EXPR):". Get 1st match.
  if ($content =~ m/if\((.*)\):/) {
    my $test = $1;
    $content =~ s/if\((.*)\)://; # remove match
    $content =~ s/^\s+|\s+$//g ; # trim again
    # evaluate the EXPR in test.
    $TF = eval $test;
    if ($@) { $TF = 0; } # error -> fails
    return ($TF,$content);
  } else {
    # no match: use content as is.
    return (1, $content);
  }
  
} # session_test_script

# session_run_script(\%config, \%session, $script): install a script for boot in VM
# The script can be specifyed as:
#   "http://some/url"
#   "/some/local/path/to/script"
#   "exec: some commands" separated by EOL (\n) or ';'
#   "virt-customize: commands" separated by EOL (\n)
#   "qemu: QEMU options" separated by spaces
#
# In addition, the script text (expression, file or URL) may contain the syntax:
#   if(EXPR):
# so that the script content is evaluated when EXPR is true.
# The script content and EXPR may use the @USER@ @PW@ @SESSION_NAME@ and @VM@ symbols,  
# which are replaced by the user name, the session name and the VM name.
#
# The returned value is ($virt_customize_cmd, $qemu_cmd)
sub session_run_script {
  my $config_ref  = shift;
  my $session_ref = shift;
  my $script      = shift;
  
  my $virt_customize_cmd = "";
  my $qemu_cmd           = "";
  
  if (not $session_ref) { return ("",""); }
  if (not $config_ref)  { return ("",""); }
  my %config  = %{ $config_ref };
  my %session = %{ $session_ref };
  
  if (length($script) > $config{service_max_script_length} || not $script) {
    return ("","");
  }
  my $TF=0;
  ($TF,$script) = session_test_script(\%session, $script); # handle any 'if(EXPR)' statement
  if (not $TF) { return ("",""); }
  
  my $content                 = undef;
  my $virt_customize_commands = 0;
  
  # add 'file://' for local file
  if (rindex($script, "bash:", 0) == 0 or rindex($script, "exec:", 0) == 0) {
    $content = substr($script, 5);
  }
  elsif (rindex($script, "virt-customize:", 0) == 0) {
    $content = substr($script, 15);
    $virt_customize_commands = 1;
  }
  elsif (-e "$script") {
    $script = "file://".$script;
  }
  elsif (rindex($script, "qemu:", 0) == 0) {
    return ("", $script);
  }
  
  # get script from $script URL
  # we try a few times to get the content. A single attempt is often not enough.
  if (not defined ($content) or not $content) {
    for (1..5) {
      my $ua = LWP::UserAgent->new(timeout => 2);
      # use a specific proxy, or system configuration (e.g. HTTP_PROXY env var)
      if ($config{service_proxy}) {
        $ua->proxy(['http', 'ftp', 'https'], $config{service_proxy});
      } else { $ua->env_proxy; }
      my $response = $ua->get($script);
      if (not $response->is_error) {
        $content = $response->content;
        last;
      }
    }
  }
    
  if (defined ($content) and length($content)) {
    # remove invalid characters
    $content=~s/[^\x00-\x7f]//g;
    if (length($content) < $config{service_max_script_length}) {
    
      # replace symbols. Script is known to be active from previous call to session_test_script
      ($TF, $content) = session_test_script(\%session, $content);
      
      # store script in our 'snapshot' directory for local exec.
      my ($script_handle, $script_name) = tempfile(TEMPLATE => "$session{dir_snapshot}/configuration_script_XXXXXXXX");
      if (not $script_name) { return ("",""); }
      print $script_handle "$content\n";
      close $script_handle;
      
      # output script into apache2/log
      print STDERR $0.": $session{name}: $session{date}: $session{user} uses config_script=$script -> $script_name\n";
#      print STDERR "$content\n";
      
      # call virt-customize: start VM, execute script, stop it. Only runs on stopped VMs.
      # virt-customize -a vm.qcow2 --run snapshot/script
      # my $cmd = "virt-customize -a $session{snapshot} ";
      my $cmd= "";
      if ($virt_customize_commands == 0) {
        $cmd = " --firstboot $script_name";
      } else {
        $cmd = " --commands-from-file $script_name";
      }
      return ($cmd, "");
    }
  }
} # session_run_script

# service_housekeeping(\%config): scan 'snapshot' and 'cfg' directories.
#   - kill over-time sessions
#   - check that 'snapshots' have a 'cfg'.
#   - remove orphan 'snapshots' (may be left from a hard reboot).
# return ($error,$nb) string (or empty when all is OK) and number of sessions.
sub service_housekeeping {
  my $config_ref  = shift;
  
  if (not $config_ref) { return; }
  my %config = %{ $config_ref };

  my $dir     = $config{dir_snapshots};
  my $cfg     = $config{dir_cfg};
  my $service = $config{service};
  
  # clean:
  # - remove orphan snapshots (no corresponding JSON file)
  # - remove orphan JSON      (no corresponding snapshots)
  # - remove snapshots that have gone above their lifetime
  my $now         = localtime();
  foreach my $snapshot (glob("$dir/$service"."_*")) {
    
    if (-d $snapshot && time - (stat($snapshot))[9] > 10*$config{boot_delay}) { # is a snapshot directory
      my $snaphot_name = fileparse($snapshot); # just the session name
      
      if (not -e "$cfg/$snaphot_name.json") {
        # remove orphan $snapshot (no JSON)
        print STDERR $0." [$now] housekeeping: $snapshot (left-over, no JSON - internal command)\n";
        rmtree( $snapshot ) || print STDERR $0." [$now] Failed removing $snapshot\n";
      } else {
        my $session_ref = session_load(\%config, "$cfg/$snaphot_name.json");
        if ($session_ref) {
          my %session = %{ $session_ref };
          if ($session{snapshot_lifetime}
            and (time > (stat $snapshot)[9] + $session{snapshot_lifetime}) 
             or (time > (stat $snapshot)[9] + $config{snapshot_lifetime}) ) { 
            # json exists, lifetime exceeded
            print STDERR $0." [$now] housekeeping: $cfg/$snaphot_name.json (exceeded life-time)\n";
            session_stop($session_ref);
          }
        } else { rmtree( $snapshot ) || print STDERR $0." [$now] Failed removing $snapshot\n"; }
      }
    }
  } # for snapshot
  
  # scan JSON files
  foreach my $json (glob("$cfg/*.json")) {
    
    # session exists, load JSON
    my $session_ref = session_load(\%config, $json);
    if ($session_ref && time - (stat($json))[9] > 10*$config{boot_delay}) {
      my %session = %{ $session_ref };
      if (not -d $session{dir_snapshot}) { 
        print STDERR $0." [$now] housekeeping: $json (left-over, no snapshot)\n";
        unlink($json); 
      }          # snapshot does not exist
    }
  }
  
  # now count how many active sessions we have.
  my @jsons = glob("$cfg/$service"."_*.json");
  my $nb    = scalar(@jsons);
  my $err   = "";
  $config{session_nb} = $nb;
  $err = "";
  return ($err,$nb);
} # service_housekeeping

# service_monitor(\%config, $out, $isadmin, $user): present a list of running sessions 
#   as well as the server usage and history.
#   $out is expected to arrive with an OPEN '<ul class="status">...<div class="card">'
#   (as built in the main flow); this sub closes it and appends its own cards.
#   return appended string $out
sub service_monitor {
  my $config_ref  = shift;
  my $out         = shift;
  my $isadmin     = shift;
  my $user        = shift;
  
  if (not $config_ref) { return; }
  my %config = %{ $config_ref };

  # first display server ID and usage
  my $cpu_count       = 0;
  if ($cpu_count == 0) {eval { $cpu_count = Sys::CPU::cpu_count();   }}
  if ($cpu_count == 0) {eval { $cpu_count = System::CPU::get_ncpu(); }}
  if ($cpu_count == 0) {$cpu_count=1;}
  my $cpu_load        = 0;
  eval {
    $cpu_load        = Sys::CpuLoad::load();
  };
  if (not $cpu_load and $^O eq 'darwin') {
    my $load_avg = `sysctl -n vm.loadavg`;
    chomp($load_avg);

    # Remove curly braces and trim whitespace
    $load_avg =~ s/[{}]//g;
    $load_avg =~ s/^\s+|\s+$//g;

    # Replace commas with dots
    $load_avg =~ s/,/./g;

    # Split and get the 1-min load average
    $cpu_load = split(/\s+/, $load_avg);
  }
  my $load            = $cpu_load  / $cpu_count;
  my ($total_memory_kb, $free_memory_kb, $avail_memory_kb) = get_freemem(); # in kB
  my $avail_memory_GB = $avail_memory_kb  / 1024/1024; # in GB
  my $total_memory_GB = $total_memory_kb  / 1024/1024;
  my $now             = localtime();
  
  # close the status card opened by the main flow, then start a new one
  $out .= "</ul></div>\n";

  # display a table with current info
  $out .= "<div class='card'>";
  $out .= "<h1>Current $config{service} status ($now)</h1>";
  $out .= "<img class='logo' src=\"$config{http}://$config{server_name}/$config{service}/images/virtualmachines.png\" height=128>";
  $out .= "<table>\n";
  $out .= "<tr><th>Server           </th><th>$config{server_name} running '$config{service}' version $config{version}</th></tr>\n";
  $out .= "<tr><td>#CPU total       </td><td>$cpu_count</td></tr>\n";
  $out .= "<tr><td>#CPU used        </td><td>$cpu_load</td></tr>\n";
  $out .= "<tr><td>Load [0-1]       </td><td>$load</td></tr>\n";
  $out .= "<tr><td>Total memory (GB)</td><td>$total_memory_GB</td></tr>\n";
  $out .= "<tr><td>Available memory (GB) </td><td>$avail_memory_GB</td></tr>\n";
  $out .= "<tr><td>Active sessions  </td><td>$config{session_nb} (total)</td></tr>\n";
  $out .= "</table>\n";
  $out .= "</div>\n";
  
  # display a list of active sessions for the given user
  my $dir     = $config{dir_snapshots};
  my $cfg     = $config{dir_cfg};
  my $service = $config{service};
  
  # iter=1: normal user; 2: admin user
  for my $iter (1..2) {
  
    # {name} {machine} {user} {date} {cpu} {mem} {lifetime} {url} {PIDs}
    if ($iter == 1) {
      $out .= "<div class='card'><h2>Active sessions for $user</h2><table>\n";
      $out .= "<tr><th>Start Date</th><th>Name</th><th>Machine</th><th>User</th>";
      $out .= "<th>life-time (h)</th><th>URL</th>";
      $out .= "<th>Process</th><th>CPU</th><th>Memory (MB)</th><th>GPU</th></tr>\n";
    } elsif ($iter == 2 and $isadmin) {
      $out .= "<div class='card'><h2>Active sessions [$config{session_nb}] (administration)</h2><table>\n";
      $out .= "<tr><th>Start Date</th><th>Name</th><th>Machine</th><th>User</th>";
      $out .= "<th>life-time (h)</th><th>URL</th>";
      $out .= "<th>Process <form action=\"$config{http}://$config{server_name}/cgi-bin/qemu-web-desktop.pl\" method=post target=_blank><input type=hidden name=service_purge value=$user><input type=submit class=\"btn stop\" value=\"STOP ALL\"></input></form></th><th>CPU</th><th>Memory (MB)</th><th>GPU</th></tr>\n";
    }

    foreach my $snapshot (glob("$dir/$service"."_*")) {
      if (-d $snapshot) { # is a snapshot directory
        my $snaphot_name = fileparse($snapshot); # just the session name
        if (-e "$cfg/$snaphot_name.json") {
          my $session_ref = session_load(\%config, "$cfg/$snaphot_name.json");
          if ($session_ref) {
            my %session = %{ $session_ref };
            my $flag=0;
            
            if    ($iter == 1 and $user eq $session{user}) { $flag = 1; }
            elsif ($iter == 2 and $isadmin)                { $flag = 1; }
            if ($session{pid} and $flag) {
              my @pids = @{ $session{pid} };
              $out .= "<tr>";
              $out .= "<td>$session{date}</td>";
              if ($iter == 2) { # admin only. Appear as a collapsible text
                $out .= "<td><details><summary>$session{name}</summary><p><small>";
                my %hash = %session;
                $hash{password}="XXXX"; # hide user pw
                $out .= "<b>$_</b>=$hash{$_}; " for (keys %hash);
                $out .= "</small></p></details>";
              } else {
                $out .= "<td>$session{name}</td>";
              }
              $out .= "<td><a href='$config{http}://$session{server_name}/$config{service}/machines/$session{machine}'>$session{machine}</a></td>";
              if ($session{user_email}) {
                $out .= "<td><a href='mailto:$session{user_email}'>$session{user}</a></td>";
              } else  {
                $out .= "<td>$session{user}</td>";
              }
              
              my $lt = $session{snapshot_lifetime}/3600;
              my $end_date = localtime(str2time($session{date}) + $session{snapshot_lifetime});
              $out .= "<td>$lt [<small>$end_date</small>] ";
              my $extend_by = 24; # hours
              if ($session{snapshot_lifetime} + 3600*$extend_by < $config{snapshot_lifetime}) {
                $out .= "<br><form action=\"$config{http}://$session{server_name}/cgi-bin/qemu-web-desktop.pl\" method=post target=_blank><input type=hidden name=session_extend value=\"$session{name}\"><input type=submit class=\"btn\" value=\"+$extend_by h\"></input></form>";
              }
              $out .= "</td>\n";
              
              if ($session{oneshot}) {
                $out .= "<td>one-shot";
              } else {
                $out .= "<td><a href='$session{url}' target=_blank class='btn connect' title='Connect'>&#9654;&nbsp;Connect</a>";
                if ($session{password_vnc} && ($config{service_port_multiple} or ($config{check_user_mfa} and $iter == 2))) {
                  $out .= " $session{password_vnc}";
                }
              }
              $out .= "</td>";
              # create a STOP button for each
              $out .= "<td><form action=\"$config{http}://$session{server_name}/cgi-bin/qemu-web-desktop.pl\" method=post target=_blank style=\"display:inline;margin:0\"><input type=hidden name=session_stop value=\"$session{name}\"><button type=submit class=\"btn stop\" title=\"STOP\">&#9632;&nbsp;Stop</button></form></td>\n";
              
              
              $out .= "<td>$session{cpu}</td>";
              $out .= "<td>$session{memory}</td>";
              if ($session{gpu}) {
                $out .= "<td>$session{gpu}</td>";
              } else { $out .= "<td></td>"; }
            }
          }
        }
      }
    } # foreach snapshot
    $out .= "</table></div>";
    
    # display full list of sessions (when user is marked as 'admin')
    if (not $isadmin) { return $out; }
  } # for 1..2
  
  return $out;
} # service_monitor

# service_purge(\%config): kill all running sessions 
sub service_purge {
  my $config_ref  = shift;
  
  if (not $config_ref) { return; }
  my %config = %{ $config_ref };
  
  my $dir     = $config{dir_snapshots};
  my $cfg     = $config{dir_cfg};
  my $service = $config{service};
  
  foreach my $snapshot (glob("$dir/$service"."_*")) {
      if (-d $snapshot) { # is a snapshot directory
        my $snaphot_name = fileparse($snapshot); # just the session name
        if (-e "$cfg/$snaphot_name.json") {
          my $session_ref = session_load(\%config, "$cfg/$snaphot_name.json");
          session_stop($session_ref);
        }
      }
  } # for
  
  
} # service_purge

# session_email(\%config, \%session, $output): send an email with URL
#   $output is expected to already be a complete HTML document (see
#   html_page_wrap) since it is used verbatim as the email body.
# return: "" or error message
sub session_email {
  my $config_ref  = shift;
  my $session_ref = shift;
  my $out         = shift;
  
  if (not $config_ref or not $session_ref or not $out) { return "Invalid session/config"; }
  my %config      = %{ $config_ref };
  my %session     = %{ $session_ref };
  if (not $session{user} or not $config{smtp_server}) {
    return "Invalid user or SMTP server";
  }

#   auto    use the provided smtp/email settings to decide what to do
#   SSL     use the server, port SSL, and email_from with email_passwd
#   simple  just use the server, and port 25
#   port    just use the server with given port

  my $smtp;
  my $method = $config{email_method};
  if ($method =~ 'auto') {
    if ( not $config{smtp_port} and not $config{smtp_use_ssl}) {
      $method = 'simple';
    } elsif ($config{smtp_port} and $config{smtp_use_ssl} and $config{email_passwd}) {
      $method = 'SSL';
    } else {
      $method = 'port';
    }
  }
  
  if ($method =~ 'SSL' and $config{smtp_port} and $config{smtp_use_ssl} 
    and $config{email_passwd}) {
    eval {
      $smtp = Net::SMTPS->new($config{smtp_server}, Port => $config{smtp_port},
      doSSL => $config{smtp_use_ssl}, SSL_version=>'TLSv1');
    };
    $method .= " $@" if $@; # add error from SMTP if any
  } elsif ($method =~ 'port' and $config{smtp_port}) {
    $smtp = Net::SMTP->new($config{smtp_server}, Port=>$config{smtp_port}, Timeout => 10);
  } else {
    $smtp = Net::SMTP->new($config{smtp_server}, Timeout => 10); # e.g. port 25
  } 
  
  if ($smtp) {
    if ($config{email_passwd}) {
      $smtp->auth($config{email_from},$config{email_passwd}) || return "Failed SMTP authentication for $config{email_from}";
    }
    $smtp->mail($config{email_from});
    $smtp->recipient($session{user_email});
    $smtp->data();
    $smtp->datasend("From: $config{email_from}\n");
    $smtp->datasend("To: $session{user_email}\n");
    # could add BCC to internal monitoring address $smtp->datasend("BCC: address\@example.com\n");
    $smtp->datasend("Subject: [$config{service}] Remote $session{name} connection information $session{password_vnc}\n");
    $smtp->datasend("Content-Type: text/html; charset=\"UTF-8\" \n");
    $smtp->datasend("\n"); # end of header
    $smtp->datasend($out);
    $smtp->dataend;
    $smtp->quit;
    return ""; # sent
  } else {
    return "Invalid SMTP connection: $method";
  }

} # session_email

# session_message(\%config, \%session,
#   $output_icon, $output_share, $output_token, $output_persistent);
# assemble a "Hello user" card to display/send.
#   Returns BODY content only (no <body>/<script>/<html> closing tags -
#   those belong to html_page_wrap, which is applied once at print/save time).
# return: $message
sub session_message {

  my $config_ref  = shift;
  my $session_ref = shift;

  my $output_icon = shift;
  my $output_share= shift;
  my $output_token= shift;
  my $output_persistent= shift;

  if (not $config_ref or not $session_ref) { return "Invalid session/coonfig.\n"; }
  my %config      = %{ $config_ref };
  my %session     = %{ $session_ref };
  my $footer      = "";
  
  if (-f "$config{layout_footer}") {
	  # use custom header as file
	  open FILE, "$config{layout_footer}";
	  $footer .= join "", <FILE>;
	  close FILE;
  } elsif ("$config{layout_footer}" ne "") {
    # use custom header as string
    $footer .= "$config{layout_footer}";
  } else { # default header
    $footer .= <<END_HTML;
    <span class="footer"><a href="$config{http}://$session{server_name}/$config{service}/" target=_blank>$config{layout_title}</a> - <a href="https://gitlab.com/soleil-data-treatment/remote-desktop">$config{service}</a></span>
END_HTML
  }
  my $u = $session{user};
  my $message = <<END_HTML;
  <div class="card">
    <h1>Hello $u !</h1>

    <div style="text-align: center;">
      <a href=$session{url} target=_blank>$output_icon</a>
      <br><br>

      <table><tr>

      <td><a href="$session{url}" target=_blank class="btn connect" title="Connect to your machine">&#9654;&nbsp;Connect</a>
      $output_share</td>

      <td style="text-align: right"><form action="$config{http}://$session{server_name}/cgi-bin/qemu-web-desktop.pl" method=post style="display:inline;margin:0">
      <input type=hidden name=session_stop value="$session{name}">
      <button type=submit class="btn stop" title="Stop this session">&#9632;&nbsp;Stop</button>
      </form></td>

      </tr></table>
      $output_token
    </div>

    <p>
    $output_persistent
    </p>

    <hr>
    $footer
  </div>
END_HTML

  return $message;
} # session_message

# ==============================================================================

# service_check_user_max(\%config, $user)
#   check resources used by user
#   return: ($nb_session, $nb_cpu, $nb_mem, $nb_gpu)
sub service_check_user_max {

  my $config_ref  = shift;
  my $user        = shift;
  
  if (not $config_ref ) { return(0,0,0); }
  my %config      = %{ $config_ref };
  
  my $nb_session = 0;
  my $nb_cpu     = 0;
  my $nb_mem     = 0;
  my $nb_gpu     = 0;
  
  # get the list of active sessions for the given user
  my $dir     = $config{dir_snapshots};
  my $cfg     = $config{dir_cfg};
  my $service = $config{service};
  
  foreach my $snapshot (glob("$dir/$service"."_*")) {
    if (-d $snapshot) { # is a snapshot directory
      my $snaphot_name = fileparse($snapshot); # just the session name
      if (-e "$cfg/$snaphot_name.json") {
        my $session_ref = session_load(\%config, "$cfg/$snaphot_name.json");
        if ($session_ref) {
          my %session = %{ $session_ref };
          if ($session{pid} and $user eq $session{user}) {
            $nb_cpu     += $session{cpu};
            $nb_session ++;
            $nb_mem     += $session{memory};
            if ($session{gpu}) { $nb_gpu ++; }
          }
        }
      }
    }
  }
  
  return ($nb_session, $nb_cpu, $nb_mem, $nb_gpu);
  
} # service_check_user_max

# session_authenticate(\%config, \%session)
#   check user credentials with SMTP, LDAP, IMAP and sendemail.
#   return: ($out, $err, $session)
sub session_authenticate {

  my $config_ref  = shift;
  my $session_ref = shift;
  
  if (not $config_ref or not $session_ref) { return ("",""); }
  my %config      = %{ $config_ref };
  my %session     = %{ $session_ref };
  
  my $out = "";
  my $err = "";

  if ($session{runs_as_cgi}) { # authentication block
    my $authenticated = "";
    if (not $err) {
      # $out .= "<li>$ok Hello <b>$session{user}</b> !</li>\n";
    }
    # when all fails or is not checked, consider sending an email.

    if (index($authenticated, "SUCCESS") < 0 and $config{check_user_with_email} 
                           and Email::Valid->address($session{user})) {
      $authenticated = "EMAIL";
      $session{user_email} = $session{user};
      if ($config{layout_level} > 1) {
        $out .= "<li>[OK] An email will be sent to indicate the URL.</li>\n";
      }
    }
    if (index($authenticated, "SUCCESS") < 0 and $config{check_user_with_local}) {
      $authenticated .= session_check_local(\%config, \%session); # checks local with ssh("user","password")
    }
    if (index($authenticated, "SUCCESS") < 0 and $config{check_user_with_imap}) {
      $authenticated .= session_check_imap(\%config, \%session); # checks IMAP("user","password")
      $session{user_email} = "$session{user}\@$config{imap_server}" if (not $session{user_email});
    }
    if (index($authenticated, "SUCCESS") < 0 and $config{check_user_with_smtp}) {
      $authenticated .= session_check_smtp(\%config, \%session); # checks SMTP("user","password")
      $session{user_email} = "$session{user}\@$config{smtp_server}" if (not $session{user_email});
    }
    if (index($authenticated, "SUCCESS") < 0 and $config{check_user_with_ldap}) {
      my $authenticated_ldap = "";
      my $session_ref;
      ($authenticated_ldap, $session_ref) = session_check_ldap(\%config, \%session); # checks LDAP("user","password")
      $authenticated .= $authenticated_ldap;
      if ($session_ref) {
        %session     = %{ $session_ref };
      }
    }
    if ($config{check_user_custom}) {
      $authenticated = session_check_custom(\%config, \%session, $authenticated); # checks custom("user","password",$authenticated)
    }
    # now we search for a "SUCCESS"
    if (index($authenticated, "SUCCESS") > -1) {
      # $out .= "<li>$ok You are authenticated: $authenticated</li>\n";
    } elsif (not $authenticated) {
      if ($config{layout_level} >= 2) {
        $out .= "<li><span class='badge warn'>WARNING</span> Service is running without user authentication.</li>\n";
      }
      # no authentication configured...
    } else {
      $err  .= "User $session{user} failed authentication. Check your username / password:  $authenticated ";
    }
    
    
  } # authentication block
  return ($out, $err, \%session);
  
} # session_authenticate

# session_check_smtp(\%config, \%session)
#   smtp_server, smtp_port, smtp_use_ssl are all needed.
#   return ""         when no check is done
#          "FAILED"   when authentication failed
#          "SUCCESS"  when authentication succeeded
sub session_check_smtp {
  my $config_ref  = shift;
  my $session_ref = shift;
  
  if (not $config_ref or not $session_ref) { return; }
  my %config      = %{ $config_ref };
  my %session     = %{ $session_ref };
  my $res="";

  # return when check can not be done
  if (not $config{check_user_with_smtp} or not $config{smtp_server} 
   or not $config{smtp_port} or not $config{smtp_use_ssl}) { return ""; }
  
  if (not $session{user} or not $session{password}) {
    return "FAILED: [SMTP] Missing Username/Password. \n ";
  }
  
  # must use encryption to check user.
  my $smtps = Net::SMTPS->new($config{smtp_server}, Port => $config{smtp_port},  
    doSSL => $config{smtp_use_ssl}, SSL_version=>'TLSv1') 
    or return "FAILED: [SMTP] Cannot connect to server. $@ \n ";

  # when USERNAME/PW is wrong, dies with no auth.
  if (not $smtps->auth ( $session{user}, $session{password} )) {
    $res = "FAILED: [SMTP] Wrong username/password (failed authentication). \n ";
  } else { 
    $res = "SUCCESS: [SMTP] $session{user} authenticated. \n ";
  }
  
  $smtps->quit;
  return $res;
  
} # session_check_smtp

# session_check_imap(\%config, \%session)
#   imap_server, imap_port are all needed.
#   return ""         when no check is done
#          "FAILED"   when authentication failed
#          "SUCCESS"  when authentication succeeded
sub session_check_imap {
  my $config_ref  = shift;
  my $session_ref = shift;

  if (not $config_ref or not $session_ref) { return; }
  my %config      = %{ $config_ref };
  my %session     = %{ $session_ref };
  my $res = "";
  
  # return when check can not be done
  if (not $config{check_user_with_imap} or not $config{imap_server} 
   or not $config{imap_port}) { return ""; }
  
  if (not $session{user} or not $session{password}) {
    return "FAILED: [IMAP] Missing Username/Password. \n ";
  }

  # Connect to IMAP server
  my $client = Mail::IMAPClient->new(
    Server   => $config{imap_server},
    User     => $session{user},
    Password => $session{password},
    Port     => $config{imap_port},
    Timeout  => 10,
    Ssl      =>  1)
    or return "FAILED: [IMAP] Cannot authenticate username/password. $@ \n "; # die when not auth

  # List folders on remote server (see if all is ok)
  if ($client->IsAuthenticated()) {
    $res = "SUCCESS: [IMAP] $session{user} authenticated. \n";
  } else {
    $res = "FAILED: [IMAP] Wrong username/password (failed authentication). \n";
  }

  $client->logout();
  return $res;
  
} # session_check_imap

# session_check_ldap(\%config, \%session)
#   ldap_server is needed. Return ($res, $session)
#   return ""         when no check is done
#          "FAILED"   when authentication failed
#          "SUCCESS"  when authentication succeeded

# used: http://articles.mongueurs.net/magazines/linuxmag68.html
sub session_check_ldap {
  my $config_ref  = shift;
  my $session_ref = shift;

  if (not $config_ref or not $session_ref) { return ""; }
  my %config      = %{ $config_ref };
  my %session     = %{ $session_ref };

  if (not %config or not %session) { return ""; }
  my $res    = "";
  my $filter = "";

  # return when check can not be done
  if (not $config{check_user_with_ldap} or not $config{ldap_server}
   or not $config{ldap_port}) { return ""; }

  if (not $session{user} or not $session{password}) {
    return "FAILED: [LDAP] Missing Username/Password. \n";
  }

  my $ldap = Net::LDAP->new($config{ldap_server}, port=>$config{ldap_port})
    or return "FAILED: [LDAP] Cannot connect to server. $@ \n";
    
  # check if "user" was given as an email.
  if (Email::Valid->address($session{user})) {
    $session{user_email} = $session{user};
    $filter = "mail=$session{user}";
  } else {
    $filter = "cn=$session{user}"; # may also be "uid=$session{user}"
  }
    
  # identify the DN
  my $mesg = $ldap->search(
    base => "dc=$config{ldap_domain}",
    filter => $filter,
    attrs => ['dn','mail','cn','uid']);
  
  if (not $mesg or not $mesg->count) {
    $res = "FAILED: [LDAP] empty LDAP search. \n";
  } else {
  
    foreach my $entry ($mesg->all_entries) {
      my $dn    = $entry->dn();
      my $email = $entry->get_value('mail');
      my $cn    = $entry->get_value('cn'); # may also be 'uid'
      my $bmesg = $ldap->bind($dn,password=>$session{password});
      if ( $bmesg and $bmesg->code() == 0 ) {
        if (not Email::Valid->address($session{user}) && $email) {
          $session{user_email} = $email;
        } elsif (Email::Valid->address($session{user})) {
          $session{user} = $cn;
        }
        $res = "SUCCESS: [LDAP] $session{user} authenticated. \n";
      }
      else{
        my $error = $bmesg->error();
        $res = "FAILED: [LDAP] Wrong username/password (failed authentication). $error \n";
      }
    }
  }
  
  $ldap->unbind;
  return ($res,\%session);

} # session_check_ldap

# session_check_local(\%config, \%session)
#   local authentication. Return ($res, $session)
#   return ""         when no check is done
#          "FAILED"   when authentication failed
#          "SUCCESS"  when authentication succeeded
sub session_check_local {
  my $config_ref  = shift;
  my $session_ref = shift;

  if (not $config_ref or not $session_ref) { return ""; }
  my %config      = %{ $config_ref };
  my %session     = %{ $session_ref };

  if (not %config or not %session) { return ""; }
  my $res    = "";
  
  if (not $session{user} or not $session{password}) {
    return "FAILED: [local] Missing Username/Password.\n ";
  }
  
  # we use perl Net::SSH2 with $session{user} and $session{password}
  # the target server must have openssh installed (apt install openssh-server)
  my $ssh = Net::SSH2->new();
  $ssh->connect($session{server_name}) || return "FAILED: [local] Could not connect to server $session{server_name}.\n ";
  $ssh->auth_password ( $session{user}, $session{password} ) || return "FAILED: [local] Wrong username/password (failed authentication).\n ";
  $ssh->disconnect;
     
  undef $ssh; # close connexion
  
  return "SUCCESS: [local] $session{user} authenticated.\n ";
  
} # session_check_local

# session_check_custom(\%config, \%session, $authenticated)
#   check with a user function defined in %config
#   return ""         when no check is done
#          "FAILED"   when authentication failed
#          "SUCCESS"  when authentication succeeded
#
# specify an authenticator function in /etc/qemu-web-desktop/config.pl or config.d/*.pl such as:
#
#  sub check_user_func {
#    my $user          = shift;
#    my $pw            = shift;
#    my $authenticated = shift; # previous authenticator results
#    my $session_ref   = shift;  
#    if (not $session_ref) { return $authenticated; }
#    my %session       = %{ $session_ref };
#    return "$auth and SUCCESS: [Custom] $user authenticated.";
#  }
#
#  $config{check_user_custom}   = \&check_user_func;

sub session_check_custom {
  my $config_ref  = shift;
  my $session_ref = shift;
  my $authenticated = shift;

  if (not $config_ref or not $session_ref) { return ""; }
  my %config      = %{ $config_ref };
  my %session     = %{ $session_ref };

  if (not %config or not %session) { return ""; }
  
  # check if existing custom auth
  if (not $config{check_user_custom}) { return ""; }
  my $res    = "";
  
  if (not $session{user}) {
    return "FAILED: [custom auth] Missing Username. \n";
  }
  
  # execute the referenced function
  my $cref = $config{check_user_custom};
  eval { $res = &$cref($session{user}, $session{password}, $authenticated, \%session); }; 
  print STDERR "$0: Error when executing custom authenticator: $@ \n" if $@;
  
  return $res;

} # session_check_custom

# session_use_gpu(\%config, $pci): return true if the given PCI is used by any running session
sub session_use_gpu {
  my $config_ref  = shift;
  my $pci         = shift;

  if (not $config_ref) { return 0; }
  my %config      = %{ $config_ref };
  my $cfg     = $config{dir_cfg};
  
  if ($config{gpu_blacklist} and $pci =~ $config{gpu_blacklist}) { 
    return 1; # PCI is black-listed
  } 
  
  # scan JSON files
  foreach my $json (glob("$cfg/*.json")) {
    
    # session exists, load JSON
    my $session_ref = session_load(\%config, $json);
    if ($session_ref) {
      my %session = %{ $session_ref };
      if ($session{gpu} eq $pci) { return 1; }          # PCI is used
    }
  }
  return 0; # no session is using that PCI slot
} # session_use_gpu

# proc_getchildren($pid): return all children PID's from parent.
# use: my @children = flatten(proc_getchildren($$));
sub flatten {
  map { ref $_ ? flatten(@{$_}) : $_ } @_;
}

sub proc_getchildren {
  my $parent= shift;
  my @pid = [];
  push @pid, $parent;
  if (not $parent) { return; }
  
  my $proc_table=Proc::ProcessTable->new();
  for my $proc (@{$proc_table->table()}) {
    if ($proc->ppid == $parent) {
      my $child = $proc->pid;
      push @pid, $child;
      my @pid_children = flatten(proc_getchildren($child));
      push @pid, @pid_children;
    }
  }
  return flatten(@pid);
} # proc_getchildren

# proc_running($pid): checks if $pid is running. 
#   input $pid is a PID number.
#   return 0 or 1 (running).
sub proc_running {
  my $pid   = shift;
  if (not $pid) { return 0; }
  
  my $found = 0; # we now search for the PID.
  my $proc_table=Proc::ProcessTable->new();
  for my $proc (@{$proc_table->table()}) {     
    if ($proc->pid == $pid) {
      # session is still running
      $found = $pid;
      last;
    }
  }
  return $found;
} # proc_running

# pci_devices($cmd,$type,$module): extract GPU info from lspci and identify the devices
#   input:  $cmd    command to execute, e.g. 'lspci -nnk'
#           $type   type of devive,     e.g. "vga" or empty (can be any word to search for)
#           $module used kernel module, e.g. "nvidia" or "vfio" or empty
#   output: list of devices matching criteria, (@$device_pci, @$device_model, @$device_name)
#
# example: my ($device_pci, $device_model, $device_name) = pci_devices("lspci -nnk","audio","");
#          print "$_\n" for @$device_pci;
# example: pci_devices("lspci -nnk","vga",  "vfio");
sub pci_devices {
    my ($cmd, $type, $module) = @_;
    my @device_pci;
    my @device_model;
    my @device_name;

    # Execute the command and capture its output
    open(my $pipe, '-|', $cmd) or return (\@device_pci, \@device_model, \@device_name);
    my @lines;
    while (my $line = <$pipe>) {
        chomp $line;
        push @lines, $line;
    }
    close($pipe);

    # Process each block of device information
    my $current_block = '';
    for my $line (@lines) {
        if ($line =~ /^([0-9a-f]{2}:[0-9a-f]{2}\.[0-9a-f])/) {
            # New device block starts
            if ($current_block) {
                # Process the previous block
                my ($pci, $model, $name) = _parse_device_block($current_block, $type, $module);
                push @device_pci, $pci if $pci;
                push @device_model, $model if $model;
                push @device_name, $name if $name;
            }
            $current_block = $line . "\n";
        } else {
            $current_block .= $line . "\n" if $current_block;
        }
    }

    # Process the last block
    if ($current_block) {
        my ($pci, $model, $name) = _parse_device_block($current_block, $type, $module);
        push @device_pci, $pci if $pci;
        push @device_model, $model if $model;
        push @device_name, $name if $name;
    }

    return (\@device_pci, \@device_model, \@device_name);
}

sub _parse_device_block {
    my ($block, $type, $module) = @_;
    my $pci;
    my $model;
    my $name;

    # Extract PCI address
    if ($block =~ /^([0-9a-f]{2}:[0-9a-f]{2}\.[0-9a-f])/) {
        $pci = $1;
    }

    # Extract vendor:ID
    if ($block =~ /\[([0-9a-f]{4}:[0-9a-f]{4})\]/) {
        $model = $1;
    }

    # Extract full device description (first line)
    if ($block =~ /^([^\n]+)/) {
        $name = $1;
    }

    # Check if the block matches the search criteria
    my $matches_type = !$type || ($block =~ /\b$type\b/i);
    my $matches_module = !$module || ($block =~ /\b$module\b/i);

    if ($matches_type && $matches_module) {
        return ($pci, $model, $name);
    } else {
        return (undef, undef, undef);
    }
} # pci_devices

# get_freemem(): get the total/free/available memory, in kB
#   The available memory is the sum of the free and cached memory.
#
# ($total_mem, $free_mem, $avail_mem) = get_freemem()
sub get_freemem {
  my $free_memory_kb  = 0;
  my $avail_memory_kb = 0;
  my $total_memory_kb = 0;

  if ($^O eq 'darwin') {
    $avail_memory_kb = 0; # vm_stat and Sys::MemInfo under-evaluate the free/available memory
    $total_memory_kb = `sysctl -n hw.memsize`;
    chomp($total_memory_kb);
    $total_memory_kb = $total_memory_kb/1024;

    my $used_memory_kb = 0;
    open(my $ps, '-|', 'ps -A -o rss=') or die "Failed to run ps: $!";
    while (my $line = <$ps>) {
        chomp $line;
        $used_memory_kb += $line if $line =~ /^\d+$/;
    }
    close($ps);
    $avail_memory_kb = $total_memory_kb - $used_memory_kb;

  } elsif (-f "/proc/meminfo") {
    my @meminfo;
    @meminfo = `/bin/cat /proc/meminfo`;
    foreach (@meminfo) {
      chomp;
      if (/^Mem(Total|Free|Available):\s+(\d+) kB/) {
        my $counter_name = $1;
        if    ($counter_name eq 'Free') {
            $free_memory_kb = $2;
        }
        elsif ($counter_name eq 'Total') {
            $total_memory_kb = $2;
        }
        elsif ($counter_name eq 'Available') {
            $avail_memory_kb = $2;
        }
      }
    }
    if (not $total_memory_kb) { $total_memory_kb = Sys::MemInfo::totalmem()/1024; }
    if (not $free_memory_kb)  { $free_memory_kb  = Sys::MemInfo::freemem() /1024; }
    if (not $avail_memory_kb) { $avail_memory_kb = $free_memory_kb; }
  }


  
  return ($total_memory_kb, $free_memory_kb, $avail_memory_kb);
} # get_freemem

# Validate and sanitize file paths/names
sub safe_path {
  my ($base, $path) = @_;
  $path =~ s|[^a-zA-Z0-9._\ \@\-]||g;  # Remove dangerous chars
  if (not $base) { return $path; }
  my $full = File::Spec->catfile($base, $path);
  # Ensure the resolved path is within $base
  my $abs = File::Spec->rel2abs($full);
  return $abs =~ /^\Q$base\E/ ? $abs : die "Path traversal attempt";
} # safe_path
