[pmg-devel] [PATCH pmg-api v2 2/3] add SACustom Package and API Calls for custom SpamAssassin scores
Dominik Csapak
d.csapak at proxmox.com
Thu Nov 14 12:18:53 CET 2019
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 at proxmox.com>
---
changes from v1:
* copy properties instead of its references
* write pmg-scores.cf all at once
src/Makefile | 2 +
src/PMG/API2/Config.pm | 6 +
src/PMG/API2/SACustom.pm | 337 +++++++++++++++++++++++++++++++++++++++
src/PMG/SACustom.pm | 89 +++++++++++
4 files changed, 434 insertions(+)
create mode 100644 src/PMG/API2/SACustom.pm
create mode 100644 src/PMG/SACustom.pm
diff --git a/src/Makefile b/src/Makefile
index 89658db..e3bee39 100644
--- a/src/Makefile
+++ b/src/Makefile
@@ -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 \
diff --git a/src/PMG/API2/Config.pm b/src/PMG/API2/Config.pm
index 43653e4..1b3743e 100644
--- a/src/PMG/API2/Config.pm
+++ b/src/PMG/API2/Config.pm
@@ -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
index 0000000..ac75402
--- /dev/null
+++ b/src/PMG/API2/SACustom.pm
@@ -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
index 0000000..f91ebf2
--- /dev/null
+++ b/src/PMG/SACustom.pm
@@ -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;
--
2.20.1
More information about the pmg-devel
mailing list