]> git.proxmox.com Git - pmg-api.git/commitdiff
add SACustom Package and API Calls for custom SpamAssassin scores
authorDominik Csapak <d.csapak@proxmox.com>
Thu, 14 Nov 2019 11:18:53 +0000 (12:18 +0100)
committerDietmar Maurer <dietmar@proxmox.com>
Fri, 15 Nov 2019 08:46:53 +0000 (09:46 +0100)
this uses our INotify interface to parse and write a custom sa config
in /etc/mail/spamassassin/pmg-scores.cf with a shadow file in
/var/cache/pmg-scores.cf (to track the diff)

add also api calls to create a new/delete/edit/revert/apply those custom
rules

Signed-off-by: Dominik Csapak <d.csapak@proxmox.com>
src/Makefile
src/PMG/API2/Config.pm
src/PMG/API2/SACustom.pm [new file with mode: 0644]
src/PMG/SACustom.pm [new file with mode: 0644]

index 89658db8c8eb10f093ba8850541874979762dcdd..e3bee39b896a60dd067de6c3bc9ffd6df4e19ed7 100644 (file)
@@ -77,6 +77,7 @@ LIBSOURCES =                          \
        PMG/DKIMSign.pm                 \
        PMG/Quarantine.pm               \
        PMG/Report.pm                   \
+       PMG/SACustom.pm                 \
        PMG/RuleDB/Group.pm             \
        PMG/RuleDB/Rule.pm              \
        PMG/RuleDB/Object.pm            \
@@ -130,6 +131,7 @@ LIBSOURCES =                                \
        PMG/API2/Cluster.pm             \
        PMG/API2/ClamAV.pm              \
        PMG/API2/SpamAssassin.pm        \
+       PMG/API2/SACustom.pm            \
        PMG/API2/Statistics.pm          \
        PMG/API2/MailTracker.pm         \
        PMG/API2/Backup.pm              \
index 43653e458d90124a45884048ef9203147740a112..1b3743edc012552c99b1983bba3b8b162b9908fe 100644 (file)
@@ -24,6 +24,7 @@ use PMG::API2::MimeTypes;
 use PMG::API2::Fetchmail;
 use PMG::API2::DestinationTLSPolicy;
 use PMG::API2::DKIMSign;
+use PMG::API2::SACustom;
 
 use base qw(PVE::RESTHandler);
 
@@ -87,6 +88,11 @@ __PACKAGE__->register_method({
     path => 'dkim',
 });
 
+__PACKAGE__->register_method({
+    subclass => "PMG::API2::SACustom",
+    path => 'customscores',
+});
+
 __PACKAGE__->register_method ({
     name => 'index', 
     path => '',
diff --git a/src/PMG/API2/SACustom.pm b/src/PMG/API2/SACustom.pm
new file mode 100644 (file)
index 0000000..ac75402
--- /dev/null
@@ -0,0 +1,337 @@
+package PMG::API2::SACustom;
+
+use strict;
+use warnings;
+
+use PVE::SafeSyslog;
+use PVE::JSONSchema qw(get_standard_option);
+use PVE::RESTHandler;
+use PVE::INotify;
+use PVE::Tools qw(extract_param);
+use PVE::Exception qw(raise_param_exc);
+
+use PMG::RESTEnvironment;
+use PMG::Utils;
+use PMG::SACustom;
+
+use base qw(PVE::RESTHandler);
+
+my $score_properties = {
+    name => {
+       type => 'string',
+       description => "The name of the rule.",
+       pattern => '[a-zA-Z\_\-\.0-9]+',
+    },
+    score => {
+       type => 'number',
+       description => "The score the rule should be valued at.",
+    },
+    comment => {
+       type => 'string',
+       description => 'The Comment.',
+       optional => 1,
+    },
+};
+
+sub json_config_properties {
+    my ($props, $optional) = @_;
+
+    foreach my $opt (keys %$score_properties) {
+       # copy values and not the references
+       foreach my $prop (keys %{$score_properties->{$opt}}) {
+           $props->{$opt}->{$prop} = $score_properties->{$opt}->{$prop};
+       }
+       if ($optional->{$opt}) {
+           $props->{$opt}->{optional} = 1;
+       }
+    }
+
+    return $props;
+}
+
+__PACKAGE__->register_method({
+    name => 'list_scores',
+    path => '',
+    method => 'GET',
+    description => "List custom scores.",
+    #    protected => 1,
+    permissions => { check => [ 'admin', 'audit' ] },
+    proxyto => 'master',
+    parameters => {
+       additionalProperties => 0,
+       properties => { },
+    },
+    returns => {
+       type => 'array',
+       items => {
+           type => 'object',
+           properties => json_config_properties({
+               digest => get_standard_option('pve-config-digest'),
+           },
+           {
+               # mark all properties optional, so that we can have
+               # one entry with only digest, and all others without digest
+               name => 1,
+               score => 1,
+               comment => 1,
+           }),
+       },
+       links => [ { rel => 'child', href => "{name}" } ],
+    },
+    code => sub {
+       my ($param) = @_;
+
+       my $restenv = PMG::RESTEnvironment->get();
+
+       my $tmp = PVE::INotify::read_file('pmg-scores.cf', 1);
+
+       my $changes = $tmp->{changes};
+       $restenv->set_result_attrib('changes', $changes) if $changes;
+
+       my $res = [];
+
+       for my $rule (sort keys %{$tmp->{data}}) {
+           push @$res, $tmp->{data}->{$rule};
+       }
+
+       my $digest = PMG::SACustom::calc_digest($tmp->{data});
+
+       push @$res, {
+           digest => $digest,
+       };
+
+       return $res;
+    }});
+
+__PACKAGE__->register_method({
+    name => 'apply_score_changes',
+    path => '',
+    method => 'PUT',
+    protected => 1,
+    description => "Apply custom score changes.",
+    proxyto => 'master',
+    permissions => { check => [ 'admin' ] },
+    parameters => {
+       additionalProperties => 0,
+       properties => {
+           'restart-daemon' => {
+               type => 'boolean',
+               description => 'If set, also restarts pmg-smtp-filter. '.
+                              'This is necessary for the changes to work.',
+               default => 0,
+               optional => 1,
+           },
+           digest => get_standard_option('pve-config-digest'),
+       },
+    },
+    returns => { type => "string" },
+    code => sub {
+       my ($param) = @_;
+
+       my $restenv = PMG::RESTEnvironment->get();
+
+       my $user = $restenv->get_user();
+
+       my $config = PVE::INotify::read_file('pmg-scores.cf');
+
+       my $digest = PMG::SACustom::calc_digest($config);
+       PVE::Tools::assert_if_modified($digest, $param->{digest})
+           if $param->{digest};
+
+       my $realcmd = sub {
+           my $upid = shift;
+
+           PMG::SACustom::apply_changes();
+           if ($param->{'restart-daemon'}) {
+               syslog('info', "re-starting service pmg-smtp-filter: $upid\n");
+               PMG::Utils::service_cmd('pmg-smtp-filter', 'restart');
+           }
+       };
+
+       return $restenv->fork_worker('applycustomscores', undef, $user, $realcmd);
+    }});
+
+__PACKAGE__->register_method({
+    name => 'revert_score_changes',
+    path => '',
+    method => 'DELETE',
+    protected => 1,
+    description => "Revert custom score changes.",
+    proxyto => 'master',
+    permissions => { check => [ 'admin' ] },
+    parameters => {
+       additionalProperties => 0,
+       properties => { },
+    },
+    returns => { type => "null" },
+    code => sub {
+       my ($param) = @_;
+
+       unlink PMG::SACustom::get_shadow_path();
+
+       return undef;
+    }});
+
+
+__PACKAGE__->register_method({
+    name => 'create_score',
+    path => '',
+    method => 'POST',
+    description => "Create custom SpamAssassin score",
+    protected => 1,
+    proxyto => 'master',
+    parameters => {
+       additionalProperties => 0,
+       properties => json_config_properties({
+           digest => get_standard_option('pve-config-digest'),
+       }),
+    },
+    returns => { type => 'null' },
+    code => sub {
+       my ($param) = @_;
+
+       my $name = extract_param($param, 'name');
+       my $score = extract_param($param, 'score');
+       my $comment = extract_param($param, 'comment');
+
+       my $code = sub {
+           my $config = PVE::INotify::read_file('pmg-scores.cf');
+
+           my $digest = PMG::SACustom::calc_digest($config);
+           PVE::Tools::assert_if_modified($digest, $param->{digest})
+               if $param->{digest};
+
+           $config->{$name} = {
+               name => $name,
+               score => $score,
+               comment => $comment,
+           };
+
+           PVE::INotify::write_file('pmg-scores.cf', $config);
+       };
+
+       PVE::Tools::lock_file("/var/lock/pmg-scores.cf.lck", 10, $code);
+       die $@ if $@;
+
+       return undef;
+    }});
+
+__PACKAGE__->register_method({
+    name => 'get_score',
+    path => '{name}',
+    method => 'GET',
+    description => "Get custom SpamAssassin score",
+    protected => 1,
+    proxyto => 'master',
+    parameters => {
+       additionalProperties => 0,
+       properties => {
+           name => {
+               type => 'string',
+               description => "The name of the rule.",
+               pattern => '[a-zA-Z\_\-\.0-9]+',
+           },
+       },
+    },
+    returns => {
+       type => 'object',
+       properties => json_config_properties(),
+    },
+    code => sub {
+       my ($param) = @_;
+
+       my $name = extract_param($param, 'name');
+       my $config = PVE::INotify::read_file('pmg-scores.cf');
+
+       raise_param_exc({ name => "$name not found" })
+           if !$config->{$name};
+
+       return $config->{$name};
+    }});
+
+__PACKAGE__->register_method({
+    name => 'edit_score',
+    path => '{name}',
+    method => 'PUT',
+    description => "Edit custom SpamAssassin score",
+    protected => 1,
+    proxyto => 'master',
+    parameters => {
+       additionalProperties => 0,
+       properties => json_config_properties({
+           digest => get_standard_option('pve-config-digest'),
+       }),
+    },
+    returns => { type => 'null' },
+    code => sub {
+       my ($param) = @_;
+
+       my $name = extract_param($param, 'name');
+       my $score = extract_param($param, 'score');
+       my $comment = extract_param($param, 'comment');
+
+       my $code = sub {
+           my $config = PVE::INotify::read_file('pmg-scores.cf');
+
+           my $digest = PMG::SACustom::calc_digest($config);
+           PVE::Tools::assert_if_modified($digest, $param->{digest})
+               if $param->{digest};
+
+           $config->{$name} = {
+               name => $name,
+               score => $score,
+               comment => $comment,
+           };
+
+           PVE::INotify::write_file('pmg-scores.cf', $config);
+       };
+
+       PVE::Tools::lock_file("/var/lock/pmg-scores.cf.lck", 10, $code);
+       die $@ if $@;
+
+       return undef;
+    }});
+
+__PACKAGE__->register_method({
+    name => 'delete_score',
+    path => '{name}',
+    method => 'DELETE',
+    description => "Edit custom SpamAssassin score",
+    protected => 1,
+    proxyto => 'master',
+    parameters => {
+       additionalProperties => 0,
+       properties => {
+           name => {
+               type => 'string',
+               description => "The name of the rule.",
+               pattern => '[a-zA-Z\_\-\.0-9]+',
+           },
+           digest => get_standard_option('pve-config-digest'),
+       },
+    },
+    returns => { type => 'null' },
+    code => sub {
+       my ($param) = @_;
+
+       my $name = extract_param($param, 'name');
+
+       my $code = sub {
+           my $config = PVE::INotify::read_file('pmg-scores.cf');
+
+           my $digest = PMG::SACustom::calc_digest($config);
+           PVE::Tools::assert_if_modified($digest, $param->{digest})
+               if $param->{digest};
+
+           delete $config->{$name};
+
+           PVE::INotify::write_file('pmg-scores.cf', $config);
+       };
+
+       PVE::Tools::lock_file("/var/lock/pmg-scores.cf.lck", 10, $code);
+       die $@ if $@;
+
+       return undef;
+    }});
+
+1;
diff --git a/src/PMG/SACustom.pm b/src/PMG/SACustom.pm
new file mode 100644 (file)
index 0000000..f91ebf2
--- /dev/null
@@ -0,0 +1,89 @@
+package PMG::SACustom;
+
+use strict;
+use warnings;
+
+use PVE::INotify;
+use Digest::SHA;
+
+my $shadow_path = "/var/cache/pmg-scores.cf";
+my $conf_path = "/etc/mail/spamassassin/pmg-scores.cf";
+
+sub get_shadow_path {
+    return $shadow_path;
+}
+
+sub apply_changes {
+    rename($shadow_path, $conf_path) if -f $shadow_path;
+}
+
+sub calc_digest {
+    my ($data) = @_;
+
+    my $raw = '';
+
+    foreach my $rule (sort keys %$data) {
+       my $score = $data->{$rule}->{score};
+       my $comment = $data->{$rule}->{comment} // "";
+       $raw .= "$rule$score$comment";
+    }
+
+    my $digest = Digest::SHA::sha1_hex($raw);
+    return $digest;
+}
+
+PVE::INotify::register_file('pmg-scores.cf', $conf_path,
+                           \&read_pmg_cf,
+                           \&write_pmg_cf,
+                           undef,
+                           always_call_parser => 1,
+                           shadow => $shadow_path,
+                           );
+
+sub read_pmg_cf {
+    my ($filename, $fh) = @_;
+
+    my $scores = {};
+
+    my $comment = '';
+    if (defined($fh)) {
+       while (defined(my $line = <$fh>)) {
+           chomp $line;
+           next if $line =~ m/^\s*$/;
+           if ($line =~ m/^# ?(.*)\s*$/) {
+               $comment = $1;
+               next;
+           }
+           if ($line =~ m/^score\s+(\S+)\s+(\S+)\s*$/) {
+               my $rule = $1;
+               my $score = $2;
+               $scores->{$rule} = {
+                   name => $rule,
+                   score => $score,
+                   comment => $comment,
+               };
+               $comment = '';
+           } else {
+               warn "parse error in '$filename': $line\n";
+               $comment = '';
+           }
+       }
+    }
+
+    return $scores;
+}
+
+sub write_pmg_cf {
+    my ($filename, $fh, $scores) = @_;
+
+    my $content = "";
+    foreach my $rule (sort keys %$scores) {
+       my $comment = $scores->{$rule}->{comment};
+       my $score = sprintf("%.3f", $scores->{$rule}->{score});
+       $content .= "# $comment\n" if defined($comment) && $comment !~ m/^\s*$/;
+       $content .= "score $rule $score\n";
+    }
+    PVE::Tools::safe_print($filename, $fh, $content);
+}
+
+1;