summaryrefslogtreecommitdiff
path: root/trunk/Echolot/Conf.pm
diff options
context:
space:
mode:
authorPeter Palfrader <peter@palfrader.org>2006-03-06 15:10:03 +0000
committerPeter Palfrader <peter@palfrader.org>2006-03-06 15:10:03 +0000
commit0a27014ee8ba24a3ca3d78cefdeda8ba391e42ba (patch)
tree2d08c30a4ece1139d9f203be2ecc3b25a45bfec4 /trunk/Echolot/Conf.pm
parent0468b8a264c429c66a92ff56e012f6f794603f09 (diff)
Tag as release_2_1_8-4, againecholot-2.1.8-4
Diffstat (limited to 'trunk/Echolot/Conf.pm')
-rw-r--r--trunk/Echolot/Conf.pm531
1 files changed, 531 insertions, 0 deletions
diff --git a/trunk/Echolot/Conf.pm b/trunk/Echolot/Conf.pm
new file mode 100644
index 0000000..2bfa582
--- /dev/null
+++ b/trunk/Echolot/Conf.pm
@@ -0,0 +1,531 @@
+package Echolot::Conf;
+
+#
+# $Id$
+#
+# This file is part of Echolot - a Pinger for anonymous remailers.
+#
+# Copyright (c) 2002, 2003, 2004 Peter Palfrader <peter@palfrader.org>
+#
+# This program is free software. you can redistribute it and/or modify
+# it under the terms of the GNU General Public License as published by
+# the Free Software Foundation; either version 2 of the License, or
+# (at your option) any later version.
+#
+# 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., 51 Franklin St, Fifth Floor, Boston, MA 02110-1301 USA
+#
+
+=pod
+
+=head1 Name
+
+Echolot::Conf - remailer Configuration/Capabilities
+
+=head1 DESCRIPTION
+
+This package provides functions for requesting, parsing, and analyzing
+remailer-conf and remailer-key replies.
+
+=head1 CAVEATS
+
+When parsing OpenPGP keys only the address of the primary user id is taken into
+account (This is the one with the latest self signature I think).
+
+=cut
+
+use strict;
+use Echolot::Log;
+use GnuPG::Interface;
+
+
+sub is_not_a_remailer($) {
+ my ($reply) = @_;
+ if ($reply =~ /^\s* not \s+ a \s+ remailer\b/xi) {
+ return 1;
+ } else {
+ return 0;
+ };
+};
+
+sub send_requests($;$) {
+ my ($scheduled_for, $which) = @_;
+
+ $which = '' unless defined $which;
+
+ my $call_intervall = Echolot::Config::get()->{'getkeyconf_interval'};
+ my $send_every_n_calls = Echolot::Config::get()->{'getkeyconf_every_nth_time'};
+
+ my $timemod = int ($scheduled_for / $call_intervall);
+ my $this_call_id = $timemod % $send_every_n_calls;
+ my $session_id = int ($scheduled_for / ($call_intervall * $send_every_n_calls));
+
+ Echolot::Globals::get()->{'storage'}->delay_commit();
+
+ for my $remailer (Echolot::Globals::get()->{'storage'}->get_addresses()) {
+ next unless ($remailer->{'status'} eq 'active');
+ next unless ($remailer->{'fetch'});
+ my $address = $remailer->{'address'};
+
+ next unless (
+ $which eq 'all' ||
+ $which eq $address ||
+ $which eq '');
+
+ for my $type (qw{conf key help stats adminkey}) {
+
+ next unless (
+ $which eq $address ||
+ $which eq 'all' ||
+ (($which eq '') && ($this_call_id == (Echolot::Tools::makeShortNumHash($address.$type.$session_id) % $send_every_n_calls))));
+
+ Echolot::Log::debug("Sending $type request to ".$address.".");
+
+ my $source_text = Echolot::Config::get()->{'remailerxxxtext'};
+ my $template = HTML::Template->new(
+ scalarref => \$source_text,
+ strict => 0,
+ global_vars => 1 );
+ $template->param ( address => $address );
+ $template->param ( operator_address => Echolot::Config::get()->{'operator_address'} );
+ my $body = $template->output();
+
+ Echolot::Tools::send_message(
+ 'To' => $address,
+ 'Subject' => 'remailer-'.$type,
+ 'Token' => $type.'.'.$remailer->{'id'},
+ 'Body' => $body);
+
+ Echolot::Globals::get()->{'storage'}->decrease_ttl($address) if (($type eq 'conf') && ($which eq ''));
+ };
+ };
+ Echolot::Globals::get()->{'storage'}->enable_commit();
+};
+
+sub check_resurrection() {
+ Echolot::Globals::get()->{'storage'}->delay_commit();
+ for my $remailer (Echolot::Globals::get()->{'storage'}->get_addresses()) {
+ next unless ($remailer->{'status'} eq 'ttl timeout');
+ next unless ($remailer->{'fetch'});
+ next unless ($remailer->{'resurrection_ttl'});
+ Echolot::Log::debug("Sending request to ".$remailer->{'address'}." to check for resurrection.");
+ for my $type (qw{conf key help stats adminkey}) {
+ Echolot::Tools::send_message(
+ 'To' => $remailer->{'address'},
+ 'Subject' => 'remailer-'.$type,
+ 'Token' => $type.'.'.$remailer->{'id'})
+ };
+ Echolot::Globals::get()->{'storage'}->decrease_resurrection_ttl($remailer->{'address'});
+ };
+ Echolot::Globals::get()->{'storage'}->enable_commit();
+};
+
+
+sub remailer_caps($$$;$) {
+ my ($conf, $token, $time, $dontexpire) = @_;
+
+ my ($id) = $token =~ /^conf\.(\d+)$/;
+ (defined $id) or
+ Echolot::Log::info("Returned token '$token' has no id at all."),
+ return 0;
+
+ Echolot::Log::info("Could not find id in token '$token'."), return 0 unless defined $id;
+ my ($remailer_type) = ($conf =~ /^\s*Remailer-Type:\s* (.*?) \s*$/imx);
+ Echolot::Log::info("No remailer type found in remailer_caps from '$token'."), return 0 unless defined $remailer_type;
+ my ($remailer_caps) = ($conf =~ /^\s*( \$remailer{".*"} \s*=\s* "<.*@.*>.*"; )\s*$/imx);
+ Echolot::Log::info("No remailer caps found in remailer_caps from '$token'."), return 0 unless defined $remailer_caps;
+ my ($remailer_nick, $remailer_address) = ($remailer_caps =~ /^\s* \$remailer{"(.*)"} \s*=\s* "<(.*@.*)>.*"; \s*$/ix);
+ Echolot::Log::info("No remailer nick found in remailer_caps from '$token': '$remailer_caps'."), return 0 unless defined $remailer_nick;
+ Echolot::Log::info("No remailer address found in remailer_caps from '$token': '$remailer_caps'."), return 0 unless defined $remailer_address;
+
+
+ my $remailer = Echolot::Globals::get()->{'storage'}->get_address_by_id($id);
+ Echolot::Log::info("No remailer found for id '$id'."), return 0 unless defined $remailer;
+ if ($remailer->{'address'} ne $remailer_address) {
+ # Address mismatch -> Ignore reply and add $remailer_address to prospective addresses
+ Echolot::Log::info("Remailer address mismatch $remailer->{'address'} vs $remailer_address. Adding latter to prospective remailers.");
+ Echolot::Globals::get()->{'storage'}->add_prospective_address($remailer_address, 'self-capsstring-conf', $remailer_address);
+ } else {
+ Echolot::Log::debug("Setting capabilities for $remailer_address");
+ Echolot::Globals::get()->{'storage'}->restore_ttl( $remailer->{'address'} );
+ Echolot::Globals::get()->{'storage'}->set_caps($remailer_type, $remailer_caps, $remailer_nick, $remailer_address, $time, $dontexpire);
+
+ # if remailer is cpunk and not pgponly
+ if (($remailer_caps =~ /\bcpunk\b/) && !($remailer_caps =~ /\bpgponly\b/)) {
+ Echolot::Globals::get()->{'storage'}->set_key(
+ 'cpunk-clear',
+ $remailer_nick,
+ $remailer->{'address'},
+ 'N/A',
+ 'none',
+ 'N/A',
+ 'N/A',
+ 'N/A',
+ $time);
+ }
+ }
+
+
+ # Fetch prospective remailers from reliable's remailer-conf reply:
+ my @lines = split /\r?\n/, $conf;
+
+ while (1) {
+ my $head;
+ while (@lines) {
+ $head = $lines[0];
+ chomp $head;
+ shift @lines;
+ last if ($head eq 'SUPPORTED CPUNK (TYPE I) REMAILERS' ||
+ $head eq 'SUPPORTED MIXMASTER (TYPE II) REMAILERS');
+ };
+ last unless defined $head;
+ my $wanting = $head eq 'SUPPORTED CPUNK (TYPE I) REMAILERS' ? 1 :
+ $head eq 'SUPPORTED MIXMASTER (TYPE II) REMAILERS' ? 2 :
+ undef;
+ last unless defined $wanting;
+
+ while (@lines) {
+ $head = $lines[0];
+ chomp $head;
+ shift @lines;
+ if ($wanting == 1) {
+ last unless ($head =~ /<(.*?@.*?)>/);
+ Echolot::Globals::get()->{'storage'}->add_prospective_address($1, 'reliable-caps-reply-type1', $remailer_address);
+ } elsif ($wanting == 2) {
+ last unless ($head =~ /\s(.*?@.*?)\s/);
+ Echolot::Globals::get()->{'storage'}->add_prospective_address($1, 'reliable-caps-reply-type2', $remailer_address);
+ } else {
+ Echolot::Log::confess("Shouldn't be here. wanting == $wanting.");
+ };
+ };
+ };
+
+ return 1;
+};
+
+sub remailer_conf($$$) {
+ my ($reply, $token, $time) = @_;
+
+ my ($id) = $token =~ /^conf\.(\d+)$/;
+ (defined $id) or
+ Echolot::Log::info ("Returned token '$token' has no id at all."),
+ return 0;
+
+ my $remailer = Echolot::Globals::get()->{'storage'}->get_address_by_id($id);
+ Echolot::Log::info("No remailer found for id '$id'."), return 0 unless defined $remailer;
+ Echolot::Log::debug("Received remailer-conf reply for $remailer."),
+
+ Echolot::Globals::get()->{'storage'}->not_a_remailer($id), return 1
+ if (is_not_a_remailer($reply));
+ Echolot::Thesaurus::save_thesaurus('conf', $id, $reply);
+
+
+ remailer_caps($reply, $token, $time);
+};
+
+sub set_caps_manually($$) {
+ my ($addr, $caps) = @_;
+
+ defined $addr or
+ Echolot::Log::info("Address not defined."),
+ return 0;
+ defined $caps or
+ Echolot::Log::info("Caps not defined."),
+ return 0;
+
+ Echolot::Log::info("Setting caps for $addr manually to $caps.");
+
+ my $remailer = Echolot::Globals::get()->{'storage'}->get_address($addr);
+ defined $remailer or
+ Echolot::Log::info("Remailer address $addr did not give a valid remailer."),
+ return 0;
+ my $id = $remailer->{'id'};
+ defined $id or
+ Echolot::Log::info("Remailer address $addr did not give a remailer with an id."),
+ return 0;
+ my $token = 'conf.'.$id;
+
+ my $conf = "Remailer-Type: set-manually\n$caps";
+ remailer_caps($conf, $token, time, 1);
+
+ return 1;
+};
+
+sub parse_mix_key($$$) {
+ my ($reply, $time, $remailer) = @_;
+
+# -----Begin Mix Key-----
+# 7f6d997678b19ccac110f6e669143126
+# 258
+# AASyedeKiP1/UKyfrBz2K6gIhv4jfXIaHo8dGmwD
+# KqkG3DwytgSySSY3wYm0foT7KvEnkG2aTi/uJva/
+# gymE+tsuM8l8iY1FOiXwHWLDdyUBPbrLjRkgm7GD
+# Y7ogSjPhVLeMpzkSyO/ryeUfLZskBUBL0LxjLInB
+# YBR3o6p/RiT0EQAAAAAAAAAAAAAAAAAAAAAAAAAA
+# AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA
+# AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA
+# AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA
+# AAAAAAAAAAAAAAAAAAAAAQAB
+# -----End Mix Key-----
+
+ my %mixmasters;
+ # rot26 rot26@mix.uucico.de 7f6d997678b19ccac110f6e669143126 2.9b33 MC
+ my @mix_confs = ($reply =~ /^
+ [a-z0-9]+
+ \s+
+ \S+\@\S+
+ \s+
+ [0-9a-f]{32}
+ .*?$/xmg);
+ my @mix_keys = ($reply =~ /^-----Begin \s Mix \s Key-----\r?\n
+ [0-9a-f]{32}\r?\n
+ \d+\r?\n
+ (?:[a-zA-Z0-9+\/]*\r?\n)+
+ -----End \s Mix \s Key-----$/xmg );
+ for (@mix_confs) {
+ my ($nick, $address, $keyid, $version, $caps, $created, $expires) = /^
+ ([a-z0-9]+)
+ \s+
+ (\S+@\S+)
+ \s+
+ ([0-9a-f]{32})
+ (?: [ \t]+
+ (\S+)
+ (?: [ \t]+
+ (\S+)
+ (?: [ \t]+
+ (\d{4}-\d{2}-\d{2})
+ (?: [ \t]+
+ (\d{4}-\d{2}-\d{2})
+ )?
+ )?
+ )?
+ )? .*?/x;
+ $mixmasters{$keyid} = {
+ nick => $nick,
+ address => $address,
+ version => $version,
+ caps => $caps,
+ created => $created,
+ expires => $expires,
+ summary => $_
+ };
+ };
+ for (@mix_keys) {
+ my ($keyid) = /^-----Begin \s Mix \s Key-----\r?\n
+ ([0-9a-f]{32})\r?\n
+ \d+\r?\n
+ (?:[a-zA-Z0-9+\/]*\r?\n)+
+ -----End \s Mix \s Key-----$/xmg;
+ $mixmasters{$keyid}->{'key'} = $_;
+ };
+
+ for my $keyid (keys %mixmasters) {
+ my $remailer_address = $mixmasters{$keyid}->{'address'};
+ (defined $mixmasters{$keyid}->{'nick'}) or
+ Echolot::Log::info("Could not parse a remailer-key reply."),
+ next;
+ (defined $mixmasters{$keyid}->{'nick'} && ! defined $mixmasters{$keyid}->{'key'}) and
+ Echolot::Log::info("Mixmaster key header without key in reply from $remailer_address."),
+ next;
+ (! defined $mixmasters{$keyid}->{'nick'} && defined $mixmasters{$keyid}->{'key'}) and
+ Echolot::Log::info("Mixmaster key without key header in reply from $remailer_address."),
+ next;
+ my ($sec,$min,$hour,$mday,$mon,$year,$wday,$yday) = gmtime();
+ my $today = sprintf("%04d-%02d-%02d", $year+1900, $mon+1, $mday);
+ (defined $mixmasters{$keyid}->{'created'} && ($today lt $mixmasters{$keyid}->{'created'})) and
+ Echolot::Log::info("Mixmaster key for $remailer_address created in the future ($today < ".$mixmasters{$keyid}->{'created'}.")."),
+ next;
+ (defined $mixmasters{$keyid}->{'expires'} && ($mixmasters{$keyid}->{'expires'} lt $today)) and
+ Echolot::Log::info("Mixmaster key for $remailer_address expired (".$mixmasters{$keyid}->{'expires'}." < $today)."),
+ next;
+
+ if ($remailer->{'address'} ne $remailer_address) {
+ # Address mismatch -> Ignore reply and add $remailer_address to prospective addresses
+ Echolot::Log::info("Remailer address mismatch $remailer->{'address'} vs $remailer_address. Adding latter to prospective remailers.");
+ Echolot::Globals::get()->{'storage'}->add_prospective_address($remailer_address, 'self-capsstring-key', $remailer_address);
+ } else {
+ Echolot::Log::debug("Setting mix key for $remailer_address: $keyid");
+ Echolot::Globals::get()->{'storage'}->restore_ttl( $remailer->{'address'} );
+ Echolot::Globals::get()->{'storage'}->set_key(
+ 'mix',
+ $mixmasters{$keyid}->{'nick'},
+ $mixmasters{$keyid}->{'address'},
+ $mixmasters{$keyid}->{'key'},
+ $keyid,
+ $mixmasters{$keyid}->{'version'},
+ $mixmasters{$keyid}->{'caps'},
+ $mixmasters{$keyid}->{'summary'},
+ $time);
+ }
+ };
+
+ return 1;
+};
+
+sub parse_cpunk_key($$$) {
+ my ($reply, $time, $remailer) = @_;
+
+ my $GnuPG = new GnuPG::Interface;
+ $GnuPG->call( Echolot::Config::get()->{'gnupg'} ) if (Echolot::Config::get()->{'gnupg'});
+ $GnuPG->options->hash_init(
+ homedir => Echolot::Config::get()->{'gnupghome'} );
+ $GnuPG->options->meta_interactive( 0 );
+ my %cypherpunk;
+
+ my @pgp_keys = ($reply =~ /^-----BEGIN \s PGP \s PUBLIC \s KEY \s BLOCK-----\r?\n
+ (?:.+\r?\n)*
+ \r?\n
+ (?:[a-zA-Z0-9+\/=]*\r?\n)+
+ -----END \s PGP \s PUBLIC \s KEY \s BLOCK-----$/xmg );
+ for my $key (@pgp_keys) {
+ my ( $stdin_fh, $stdout_fh, $stderr_fh, $status_fh, $handles ) = Echolot::Tools::make_gpg_fds();
+ my $pid = $GnuPG->wrap_call(
+ commands => [qw{--with-colons}],
+ command_args => [qw{--no-options --no-secmem-warning --no-default-keyring --fast-list-mode}],
+ handles => $handles );
+ my ($stdout, $stderr, $status) = Echolot::Tools::readwrite_gpg($key, $stdin_fh, $stdout_fh, $stderr_fh, $status_fh);
+ waitpid $pid, 0;
+
+ ($stderr eq '') or
+ Echolot::Log::info("GnuPG returned something in stderr: '$stderr' when checking key '$key'; So what?");
+ ($status eq '') or
+ Echolot::Log::info("GnuPG returned something in status '$status' when checking key '$key': So what?");
+
+ my @included_keys = $stdout =~ /^pub:.*$/mg;
+ (scalar @included_keys >= 2) &&
+ # FIXME handle more than one key per block nicely
+ Echolot::Log::debug ("Cannot handle more than one key per block nicely (correctly) yet. Found ".(scalar @included_keys)." in one block from ".$remailer->{'address'}.".");
+ for my $included_key (@included_keys) {
+ my ($type, $keyid, $uid) = $included_key =~ /pub::\d+:(\d+):([0-9A-F]+):[^:]+:[^:]*:::([^:]+):/;
+ (defined $uid) or
+ Echolot::Log::info ("Unexpected format of '$included_key' by ".$remailer->{'address'}."; Skipping."),
+ next;
+ my ($address) = $uid =~ /<(.*?)>/;
+ $cypherpunk{$keyid} = {
+ address => $address,
+ type => $type,
+ key => $key # FIXME handle more than one key per block correctly
+ };
+ };
+ };
+
+ for my $keyid (keys %cypherpunk) {
+ my $remailer_address = $cypherpunk{$keyid}->{'address'};
+
+ if ($remailer->{'address'} ne $remailer_address) {
+ # Address mismatch -> Ignore reply and add $remailer_address to prospective addresses
+ Echolot::Log::info("Remailer address mismatch $remailer->{'address'} vs $remailer_address id key $keyid. Adding latter to prospective remailers.");
+ Echolot::Globals::get()->{'storage'}->add_prospective_address($remailer_address, 'self-capsstring-key', $remailer_address);
+ } else {
+ Echolot::Globals::get()->{'storage'}->restore_ttl( $remailer->{'address'} );
+ # 1 .. RSA
+ # 17 .. DSA
+ if ($cypherpunk{$keyid}->{'type'} == 1 || $cypherpunk{$keyid}->{'type'} == 17 ) {
+ Echolot::Log::debug("Setting cpunk key for $remailer_address: $keyid; type ".$cypherpunk{$keyid}->{'type'});
+ Echolot::Globals::get()->{'storage'}->set_key(
+ (($cypherpunk{$keyid}->{'type'} == 1) ? 'cpunk-rsa' :
+ (($cypherpunk{$keyid}->{'type'} == 17) ? 'cpunk-dsa' :
+ 'ERROR')),
+ $keyid, # as nick
+ $cypherpunk{$keyid}->{'address'},
+ $cypherpunk{$keyid}->{'key'},
+ $keyid,
+ 'N/A',
+ 'N/A',
+ 'N/A',
+ $time);
+ } else {
+ Echolot::Log::info("$keyid from $remailer_address has algoid ".$cypherpunk{$keyid}->{'type'}.". Cannot handle those.");
+ };
+ }
+ };
+
+ return 1;
+};
+
+sub remailer_key($$$) {
+ my ($reply, $token, $time) = @_;
+
+ my $cp_reply = $reply;
+ $cp_reply =~ s/^- -/-/gm; # PGP Signed messages
+
+ my ($id) = $token =~ /^key\.(\d+)$/;
+ (defined $id) or
+ Echolot::Log::info ("Returned token '$token' has no id at all."),
+ return 0;
+
+ my $remailer = Echolot::Globals::get()->{'storage'}->get_address_by_id($id);
+ Echolot::Log::info("No remailer found for id '$id'."), return 0 unless defined $remailer;
+ Echolot::Log::debug("Received remailer-keys reply for $remailer."),
+
+ Echolot::Globals::get()->{'storage'}->not_a_remailer($id), return 1
+ if (is_not_a_remailer($reply));
+ Echolot::Thesaurus::save_thesaurus('key', $id, $reply);
+
+ parse_mix_key($cp_reply, $time, $remailer);
+ parse_cpunk_key($cp_reply, $time, $remailer);
+
+ return 1;
+};
+
+sub remailer_stats($$$) {
+ my ($reply, $token, $time) = @_;
+
+ my ($id) = $token =~ /^stats\.(\d+)$/;
+ (defined $id) or
+ Echolot::Log::info ("Returned token '$token' has no id at all."),
+ return 0;
+
+
+ my $remailer = Echolot::Globals::get()->{'storage'}->get_address_by_id($id);
+ Echolot::Log::info("No remailer found for id '$id'."), return 0 unless defined $remailer;
+ Echolot::Log::debug("Received remailer-stats reply for $remailer."),
+
+ Echolot::Globals::get()->{'storage'}->not_a_remailer($id), return 1
+ if (is_not_a_remailer($reply));
+ Echolot::Thesaurus::save_thesaurus('stats', $id, $reply);
+};
+
+sub remailer_help($$$) {
+ my ($reply, $token, $time) = @_;
+
+ my ($id) = $token =~ /^help\.(\d+)$/;
+ (defined $id) or
+ Echolot::Log::info ("Returned token '$token' has no id at all."),
+ return 0;
+
+ my $remailer = Echolot::Globals::get()->{'storage'}->get_address_by_id($id);
+ Echolot::Log::info("No remailer found for id '$id'."), return 0 unless defined $remailer;
+ Echolot::Log::debug("Received remailer-help reply for $remailer."),
+
+ Echolot::Globals::get()->{'storage'}->not_a_remailer($id), return 1
+ if (is_not_a_remailer($reply));
+ Echolot::Thesaurus::save_thesaurus('help', $id, $reply);
+};
+
+sub remailer_adminkey($$$) {
+ my ($reply, $token, $time) = @_;
+
+ my ($id) = $token =~ /^adminkey\.(\d+)$/;
+ (defined $id) or
+ Echolot::Log::info ("Returned token '$token' has no id at all."),
+ return 0;
+
+ my $remailer = Echolot::Globals::get()->{'storage'}->get_address_by_id($id);
+ Echolot::Log::info("No remailer found for id '$id'."), return 0 unless defined $remailer;
+ Echolot::Log::debug("Received remailer-adminkey reply for $remailer."),
+
+ Echolot::Globals::get()->{'storage'}->not_a_remailer($id), return 1
+ if (is_not_a_remailer($reply));
+ Echolot::Thesaurus::save_thesaurus('adminkey', $id, $reply);
+};
+
+1;
+# vim: set ts=4 shiftwidth=4: