#!/usr/bin/perl

# Copyright (C) 2013 Zentyal S.L.
#
# This program is free software; you can redistribute it and/or modify
# it under the terms of the GNU General Public License, version 2, as
# published by the Free Software Foundation.
#
# This program is distributed in the hope that it will be useful,
# but WITHOUT ANY WARRANTY; without even the implied warranty of
# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the
# GNU General Public License for more details.
#
# You should have received a copy of the GNU General Public License
# along with this program; if not, write to the Free Software
# Foundation, Inc., 59 Temple Place, Suite 330, Boston, MA  02111-1307  USA

use strict;
use warnings;
use threads;

use Thread::Queue;
use IO::Socket::UNIX;
use ClamAV::Client;
use JSON::XS;
use TryCatch::Lite;
use File::Temp qw(tempfile);
use File::Slurp;
use Time::HiRes;
use POSIX;
use Data::Hexdumper;

use constant DEBUG => 0;

my $LOGFILE = '/var/log/zentyal/samba-antivirus.log';
my $CONFFILE = '/var/lib/zentyal/conf/samba-antivirus.conf';

my $CONF;

logevent('INFO', 'Zentyal Antivirus for samba started');

try {
    $CONF = decode_json(read_file($CONFFILE));
} catch {
    logevent('ERROR', "Cannot read $CONFFILE. Exiting now...");
    exit 1;
}

# Create the queue and launch threads
my $q = Thread::Queue->new();
my $nThreadsConf = $CONF->{nThreadsConf};
my $nThreads = (defined $nThreadsConf and length $nThreadsConf) ? $nThreadsConf : 4;
threads->create(\&work) for (1 .. $nThreads);
threads->create(\&control);

my $zavsSocket = $CONF->{zavsSocket};

#  Create socket for scannedonly VFS plugin
my $socket = undef;
try {
    unlink $zavsSocket if (-S $zavsSocket);
    $socket = new IO::Socket::UNIX(Local  => $zavsSocket,
                                   Type   => SOCK_DGRAM,
                                   ReuseAddr => 1,
                                   Listen => SOMAXCONN)
        or fatal("socket: $!");
    $socket->setsockopt(SOL_SOCKET, SO_RCVBUF, 524288)
        or fatal("setsockopt: $!");
    $socket->setsockopt(SOL_SOCKET, SO_SNDBUF, 524288)
        or fatal("setsockopt: $!");
    $socket->setsockopt(SOL_SOCKET, SO_REUSEADDR, 1)
        or fatal("setsockopt: $!");
    chmod(0777, $zavsSocket)
	or fatal("chmod: $!");
} catch {
    exit 1;
}


##
## Main loop
##
while (my $line = <$socket>) {
    logevent('DEBUG', "Received:\n" . hexdump(data => $line, suppress_warnings => 1));
    chomp $line;
    # Fields are delimited by the non-printable ASCII char 0x1E:
    # user | ip address | path
    my ($user, $addr, $dir, $name) = $line =~ /^([^\x{1E}]*)\x{1E}([^\x{1E}]*)\x{1E}(.*\/)(.*?)$/;
    my $data = {
        user => $user,
        addr => $addr,
        path => $dir,
        name => $name,
    };
    $q->enqueue($data);
}

# Control thread: Check for died threads and relaunch
sub control
{
    while (1) {
        foreach my $thr (threads->list()) {
            if ($thr->tid() > 0 and $thr->is_joinable() and not threads::equal($thr, threads->self)) {
                logevent('WARN', 'Detected dead thread ' . $thr->tid());
                $thr->join();
                threads->create(\&work);
            }
        }
        sleep (5);
    }
}

sub work
{
    my $tid = threads->self->tid();
    logevent('INFO', "Scanner thread $tid started");

    # Instance scanner
    my $scanner = new ClamAV::Client(socket_name => $CONF->{clamavSocket});
    unless (defined $scanner) {
        logevent('ERROR', 'Could not create scanner, thread dying!');
        threads->exit();
    }

    my $pingOK = 0;
    while (not $pingOK) {
        try {
            $pingOK = $scanner->ping();
        } catch {
        }

        unless ($pingOK) {
            logevent('WARN', 'clamAV daemon not responding, will try again in 10 seconds...');
            sleep (10);
        }
    }

    my $FD = undef;
    while (my $data = $q->dequeue()) {
	logevent('DEBUG', "Dequeued $data");
        try {
            my $path = $data->{path};
            my $name = $data->{name};
            my $addr = $data->{addr};
            my $user = $data->{user};
            next unless (defined $path and defined $name);

            my $file = "$path" . "$name";
            logevent('DEBUG', "Scanning (path => '$path', name => '$name', file => '$file')");
            my (undef, $result) = $scanner->scan_path($file);
            logevent('DEBUG', "Scanned");

            if (defined $result) {
                logevent('INFO', "VIRUS|$user|$addr|$file|$result");

                # Create an empty file to inform the user that a virus has been found
                my $fullPath = "${path}VIRUS_found_in_${name}.txt";
                unlink $fullPath if -f $fullPath;
                open ($FD,">>$fullPath") && close ($FD);

                # Quarantine the infected file
                my (undef, $newFile) = tempfile("$name.XXXXX", DIR => $CONF->{quarantineDir});
                unless (rename ($file, $newFile)) {
                    logevent('ERROR', "Could not rename file $file to $newFile: $!");
                    next;
                }
                logevent('INFO', "QUARANTINE|$user|$addr|$file|$newFile");
            } else {
                # File is clean, create the scanned file
                my $fullPath = "$path.scanned:$name";
                unlink $fullPath if -f $fullPath;
                logevent('DEBUG', "Unlinking cache file '$fullPath'");
                open ($FD,">>$fullPath") && close ($FD);
                logevent('DEBUG', "Cache file '$fullPath' created");
            }
        } catch (ClamAV::Client::Error $e) {
            logevent('ERROR', "Error scanning: $e");

            my $path = $data->{path};
            my $name = $data->{name};
            my $fullPath = "$path.failed:$name";
            unlink $fullPath if -f $fullPath;
            open ($FD,">>$fullPath") && close ($FD);
        } catch ($e) {
            logevent('ERROR', "Unexpected error, thread ends now: $e");
            threads->exit();
        }
    }
}

sub logevent
{
    my ($type, $msg) = @_;

    return if ($type eq 'DEBUG' and not DEBUG);

    open (my $log, '>>', $LOGFILE);
    my ($x,$y) = Time::HiRes::gettimeofday();
    $y = sprintf("%06d", $y / 1000);
    my $timestamp = POSIX::strftime("%Y-%m-%d %H:%M:%S", localtime ($x)) . ".$y";
    print $log "$timestamp $type> $msg\n";
    close ($LOGFILE);
}

sub fatal
{
    my ($error) = @_;

    logevent('ERROR', $error);
    die;
}
