#!/usr/bin/perl #NNTB - Network News Transport for 'Blogs sub BEGIN { require File::Basename; File::Basename->import; unshift @INC, dirname($0)."/lib"; } use strict; use warnings; use vars qw($version @args %config %blogs $client_ip $confversion); use NNTB::Common; use NNTB::Weblog; $version = "0.01"; sub do_log(@) { _log("NNTB: ", @_); } sub _log(@) { return unless $config{loglevel} >= pop; print LOG scalar(localtime), " $$ ", @_, "\n"; } $SIG{__WARN__} = sub { do_log(@_, LOG_WARNING); }; $SIG{__DIE__} = sub { do_log(@_, LOG_ERROR); }; sub dispver() { print "NNTB: Network News Transport for 'Blogs.\n"; print "Version: $version\n"; exit 0; } sub help() { # If called with -h or --help # Get absolute path to $0 my($dir, $file) = fileparse($0); chdir $dir; my $dirpath = cwd(); my $path = File::Spec->catfile($dirpath, $file); print < Use -p to specify a port, -i if running from inetd, -v to show version and exit, -h to get this message. Default port is whatever nntp is listed on in /etc/services (119). Usage: $0 [-v] [-i] [-p port] [-c conffile] [-l loglevel] You can use either the short or long forms with one or two dashes in whatever combination you want. However, you can't combine options, as in $0 -ip 120 -h, --help: Print this message and exit -v, --version: Shows version and exits -i, --inetd: Use if starting from inetd The line in /etc/inetd.conf used to start NNTB should look like: nntp stream tcp nowait user $path nntb -i Starting from inetd means that perl must recompile nntb every time a client connects, which can be a large slowdown. -p, --port port: Listen on an alternate port instead of nntp. Meaningless in inetd mode. -c, --conf conffile: Use an alternate configuration file. This option may be specified multiple times. By default, NNTB will look first in /etc/nntb.conf and then in ~/.nntb . -d, --debug: Don't fork and log to STDERR with the most verbose logging level. EOF exit 0; } use Symbol qw(qualify_to_ref); use Getopt::Long; use Time::Local; use Sys::Hostname; use HTML::FormatText; use HTML::Parse; use IO::Handle; use IO::Socket::INET; use File::Basename; use File::Spec; use Cwd; use Time::Local; use Regexp::Shellish; #0.94 isn't out yet, for now we'll get by with 0.93 use MIME::Parser; sub init() { %config = ( debug => 0, loglevel => LOG_NOTICE, inetd => 0, port => (getservbyname("nntp", "tcp"))[2], ); open(LOG, ">&STDERR"); autoflush LOG 1; } sub loadconf() { #First we check to see what config files to use. #Then we use the config files. #Then we process command-line options. #We do it this way so that command-line options can override config files, and # you can still specify config files on the command-line. @args = @ARGV; #Used for SIGHUP Getopt::Long::Configure("no_ignore_case"); Getopt::Long::Configure("pass_through"); my $home = (getpwuid($>))[7]; my(@default_conffiles) = ("/etc/nntb.conf", File::Spec->catfile($home, ".nntb")); my @conffiles; my $okay = &GetOptions( "version|v" => \&dispver, "help|h" => \&help, "config|c=s\@" => \@conffiles, "debug|d" => \$config{debug}, ); if(@ARGV) { # We shouldn't have any arguments left over warn "Unknown options: ", join(" ", @ARGV), "\n"; $okay = 0; } if(!$okay) { warn "Try $0 --help if you need help.\n"; exit 1; } if($config{debug}) { $config{loglevel} = LOG_DEBUG; require Data::Dumper; } @conffiles = @default_conffiles unless @conffiles; #Okay, try loading the config files my $foundconfig = 0; foreach my $conffile(@conffiles) { $confversion = 0; $conffile =~ s/~/$home/; do_log("Trying to read config file $conffile", LOG_INFO); if(-d $conffile) { warn "$conffile is a directory!\n"; } elsif(-r $conffile) { do_log("Reading config file $conffile", LOG_INFO); $confversion = 0; do $conffile; die $@ if $@; if($confversion < 1) { warn "Not using config file $conffile - it is too old (\$confversion must be >= 1, it is $confversion)\n"; $foundconfig = -1 unless $foundconfig; } else { $foundconfig = 1; } } elsif(-e $conffile) { warn "Couldn't read config file $conffile\n"; } else { do_log("$conffile does not exist", LOG_INFO); } } unless($foundconfig) { warn < \$config{inetd}, "port|p=i" => \$config{port}, ); if(!$okay) { warn "Try $0 --help if you need help.\n"; exit 1; } open(LOG, ">>$config{logfile}") or die "Couldn't open $config{logfile} for writing: $!\n"; autoflush LOG 1; } sub loadblogs() { foreach my $blog (@{$config{blogs}}) { my $package = "NNTB::Weblog::$blog->{type}"; delete $blog->{type}; do_log("Requiring $package...", LOG_DEBUG); eval "require $package"; if($@) { do_log("Couldn't load $package: $@", LOG_ERROR); next; } do_log("Done.", LOG_DEBUG); my $blog_obj; eval { $blog_obj = $package->new(%$blog); }; if(!$blog_obj or $@) { do_log("Couldn't create new $package object: $@", LOG_ERROR); exit 1; } else { if(!$blog_obj->root) { do_log("$package gave us empty root - not using", LOG_ERROR); exit 1; } elsif(exists($blogs{lc($blog_obj->root)})) { do_log("Duplicate root ", $blog_obj->root, " for $package", LOG_ERROR); exit 1; } elsif(my(@dups) = grep { index(lc($blog_obj->root), lc($_->root)) != -1 or index(lc($_->root), lc($blog_obj->root)) != -1 } values %blogs ) { do_log("Root ", $blog_obj->root, " is a substring of the following roots: ", (map { $_->root } @dups), LOG_ERROR); exit 1; } else { $blogs{lc($blog_obj->root)} = $blog_obj; } } } die "Couldn't load any weblogs!\n" unless %blogs; } sub got_client(*); sub main() { init(); loadconf(); loadblogs(); if (!$config{inetd}) { #We're not using inetd use Socket; use POSIX; if(!$config{debug}) { my $pid = fork; exit if $pid; die "Couldn't fork: $!" unless defined($pid); POSIX::setsid() or die "Can't start a new session: $!"; chdir("/"); sub signal_handler { close SERVER; exit 1; } $SIG{INT} = $SIG{TERM} = $SIG{HUP} = \&signal_handler; #trap fatal sigs } #Code to restart on SIGHUP sub phoenix { close SERVER; exec($0, @args); } $SIG{HUP} = \&phoenix; #Without this code, we get lots of fresh victims for the ever-growing army of the undead (in other words, zombies) sub REAPER { 1 until (-1 == waitpid(-1, WNOHANG)); $SIG{CHLD} = \&REAPER; } $SIG{CHLD} = \&REAPER; #Code to handle incoming connections my $server = IO::Socket::INET->new( LocalPort => $config{port}, Listen => SOMAXCONN, Proto => 'tcp', ReuseAddr => 1 ) or die "Couldn't bind to socket: $!\n"; $server->listen() or die "Couldn't listen on socket: $!\n"; # Okay, time to drop privileges my $uid = getpwnam($config{user}) or die "Couldn't get UID for $config{user}\n"; my $gid = getgrnam($config{group}) or die "Couldn't get GID for $config{group}\n"; $( = $) = $gid; die "Couldn't change GID: $!\n" if $) != $gid; $< = $> = $uid; die "Couldn't change UID: $!\n" if $> != $uid; my $client; while(($client, $client_ip) = $server->accept()) { next if my $pid = fork; # parent die "fork: $!" unless defined $pid; # we are the world, we are the children $server->close(); got_client($client); exit; } continue { close $client; } $server->close();; #buh-bye } else { #inetd == 1 $client_ip = getpeername(STDIN); got_client(STDIN); } } sub nntp_put(@) { do_log("To NNTP : ", @_, LOG_DEBUG); print @_, "\r\n"; } # (group|ID) -> blog sub findblog($) { my $group = lc(shift); #do_log("findblog($group):", LOG_DEBUG); my($blog) = grep { my $pos = index($group, lc($_->root)); #do_log("\tindex($group, ", $_->root, ") == $pos", LOG_DEBUG); $pos == 0; # It is at the beginning of the string } values %blogs; return $blog; } sub got_client(*) { my($client, $currmsg, $currgroup, $user); $client = qualify_to_ref(shift, caller); (undef, $client_ip) = sockaddr_in($client_ip); $client_ip = inet_ntoa($client_ip); select $client; $| = 1; # Auto-flush the output. nntp_put("200 ", hostname(), " NNTB server $version ready"); do_log("Got a client from $client_ip", LOG_INFO); $_->got_client() foreach values %blogs; GETLINE: while(<$client>) { s/[\r\n]{1,2}$//; do_log("From NNTP: $_", LOG_DEBUG); my $text = $_; my @params = split; my $command = lc(shift @params); $_ = $command; my $blog; $blog = findblog($currgroup) if $currgroup; SWITCH: { if (/^article$/ || /^body$/ || /^head$/ || /^stat$/) { my ($msgnum, $msgid); if (!$params[0] or $params[0] !~ /\<.+\>/) { if(!$currgroup) { #Client tried to use a message number while no group was selected nntp_put("412 Not in a newsgroup"); last SWITCH; } else { $currmsg = $params[0] if $params[0]; $msgid = $blog->num2id($currgroup, $currmsg) if $blog; if(!$msgid) { nntp_put(ERR_NOARTICLE); next; } } } elsif($params[0]) { $msgid = $params[0]; if(!($blog = findblog($msgid))) { nntp_put(ERR_NOARTICLE); next; } $msgnum = 0; # We can't map ID -> num - what 'bout crossposts? } do_log("Msg-ID is $msgid, number is $msgnum\n", LOG_DEBUG); my($response, $resptext); if($command eq "article") { ($response, $resptext) = (220, "head and body follow"); } elsif($command eq "head") { ($response, $resptext) = (221, "head follows"); } elsif($command eq "body") { ($response, $resptext) = (222, "body follows"); } elsif($command eq "stat") { ($response, $resptext) = (223, "request text seperately"); next; } my($result, $body, %headers) = $blog->article($command, $msgid); if($result) { nntp_put("$response $msgnum $msgid article retrieved - $resptext"); if($command eq "head" or $command eq "article") { nntp_put("$_: ", $headers{lc($_)}) foreach map { s/^(.)/\U$1/; s/-(.)/-\U$1/g; $_; } map { lc($_) } keys %headers; nntp_put("") if $command eq "article"; } if($command eq "body" or $command eq "article") { $body =~ s/^\.$/../m; $body =~ s/(?!\r)\n/\r\n/g; $body =~ s/[\r\n]{1,2}$//; nntp_put($body); } nntp_put(".") unless $command eq "stat"; } else { nntp_put($blog->{errstr}); } next; } elsif (/^group$/) { unless (@params == 1) { print "501 Syntax error - group groupname\n"; last SWITCH; } my $group = shift @params; my $blog = findblog($group); if(!$blog or !$blog->is_group($group)) { nntp_put("411 no such group"); } else { my($first, $last, $num) = $blog->groupstats($group); if(!defined($first)) { nntp_put($blog->{errstr}); } else { $currgroup = $group; $currmsg = $first; nntp_put("211 $num $first $last $group group selected"); do_log("Okay, current group is now $group\n", LOG_DEBUG); } } next; } elsif (/^help$/) { nntp_put("Legal commands"); nntp_put($_) foreach qw(article head body stat group help ihave last), qw(next list newgroups newnews post quit slave), qw(mode date listgroup authinfo xhdr xover xpat xrover xmode); next; } elsif (/^ihave$/) { #This command is only for inter-server post transfers nntp_put("435 article not wanted - do not send it"); next; } elsif (/^last$/ || /^next$/) { if(!$currgroup) { nntp_put("412 no newsgroup selected"); } elsif(!$currmsg) { nntp_put("420 no current article has been selected"); } else { my $newmsg = /^last$/ ? $blog->prev($currgroup, $currmsg) : $blog->next($currgroup, $currmsg); if(!$newmsg) { if($params[0] eq "next") { nntp_put("421 no next article in this group"); } else { nntp_put("422 no previous article in this group"); } } else { $currmsg = $newmsg; my $msgid = $blog->num2id($currgroup, $currmsg); nntp_put("223 $currmsg $msgid article retrieved - request text separately"); } } next; } elsif (/^list$/) { if (lc($params[0]) eq "overview.fmt") { nntp_put("215 Order of fields in overview database."); foreach my $field (qw(Subject From Date Message-ID References Bytes Lines)) { nntp_put("$field:"); } nntp_put("Xref:full"); nntp_put("."); } elsif (lc($params[0]) eq "newsgroups") { my $output = "215 Newsgroups follow"; if($params[1]) { nntp_put("500 Command not implemented"); last SWITCH; } foreach my $root (sort keys %blogs) { $blog = $blogs{$root}; my %groups = $blog->groups(); if($output and %groups) { # $output iff we haven't had any success nntp_put($output); undef $output; } foreach my $group (sort keys %groups) { nntp_put("$group $groups{$group}"); } } if($output) { nntp_put($blog->{errstr}); } else { nntp_put("."); } } elsif(!$params[0] or lc($params[0]) eq "active") { if($params[1] and $params[1] =~ /[*?[]/) { # RFC 2980 - this is a wildmat nntp_put("500 Command not implemented"); } else { my $output = "215 list of newsgroups follows"; my $pattern = $params[1]; my $lastblog; # I don't *want* $blog to be local to the foreach, damnit! foreach $blog ($pattern ? findblog($pattern) : map {$blogs{$_}} sort keys %blogs) { $lastblog = $blog; my %groups; if($pattern) { if($blog and $blog->is_group($pattern)) { %groups = ($pattern => $blog->description($pattern)); } else { %groups = (); } } else { %groups = $blog->groups(); } foreach my $group(sort keys %groups) { my($first, $last) = $blog->groupstats($group); if(defined($first)) { if($output) { nntp_put($output); undef $output; } my $can_post = $blog->can_post($group) ? "y" : "n"; nntp_put("$group $first $last $can_post"); } } } if($output) { nntp_put($lastblog->{errstr}); } else { nntp_put("."); } } } else { nntp_put("500 Command not implemented"); } } elsif (/^newgroups$/) { my $time = params2time(@params); if(!$time) { nntp_put("501 Invalid command usage"); last SWITCH; } my $output = "231 list of new newsgroups follows"; foreach my $root (keys %blogs) { $blog = $blogs{$root}; my %groups = $blog->groups($time); foreach my $group(sort keys %groups) { my($first, $last) = $blog->groupstats($group); if(defined($first)) { if($output) { nntp_put($output); undef $output; } my $can_post = $blog->can_post($group) ? "y" : "n"; nntp_put("$group $first $last $can_post"); } } } if($output) { nntp_put($blog->{errstr}); } else { nntp_put("."); } } elsif (/^newnews$/) { my $groupglob = shift @params; my $time = params2time(@params); my @globs = split(/,/, $groupglob); my @posglobs = grep { not /^!/ } @globs; my @negglobs = map { s/^!// } grep { /^!/ } @globs; my @ret; foreach my $glob(@posglobs) { unless($glob =~ /\*/) { $blog = findblog($glob); undef $blog->{errstr}; my %articles = findblog($glob)->articles($glob, $time); push @ret, map {$articles{$_}} sort {$a <=> $b} keys %articles; } else { # Evil output management hack $blog->{errstr} = "500 NEWNEWS with glob not yet implemented"; } } if($blog->{errstr}) { nntp_put($blog->{errstr}); } else { nntp_put("230 list of new articles by message-id follows"); nntp_put($_) foreach @ret; nntp_put("."); } } elsif (/^post$/) { my (@message); nntp_put("340 send article to be posted. End with ."); my $error = ""; my $lastheader = ""; my %headers; my $body; GETHEAD: while(defined($_ = $client->getline())) { s/[\r\n]{1,2}$//; do_log("From NNTP: $_", LOG_DEBUG); last GETHEAD if $_ eq ""; if(/^\s/) { # Continue previous header s/^\s+//; if(!$lastheader) { $error = "500 Malformed header"; } else { $headers{$lastheader} .= $_; } } else { # New header /^([^:]*):\s*(.*)$/ or $error = "500 Malformed header"; $headers{lc($1)} = $2; $lastheader = lc($1); } } do_log("End of headers", LOG_DEBUG); do_log("Headers:", LOG_DEBUG); do_log("\t$_: $headers{$_}", LOG_DEBUG) foreach(keys %headers); do_log("Getting body...", LOG_DEBUG); GETBODY: while(defined($_ = $client->getline())) { s/[\r\n]{1,2}$/\n/; my $line = $_; $line =~ s/\n$//; do_log("From NNTP: $line", LOG_DEBUG); last GETBODY if $_ eq ".\n"; $body .= $_; } do_log("End of body", LOG_DEBUG); do_log("Body: $body", LOG_DEBUG); if($headers{'content-type'} =~ /^multipart/) { my $parser = new MIME::Parser; $parser->output_under($ENV{TMPDIR} || "/tmp"); my $message = join("\n", map { "$_: $headers{$_}" } keys %headers); $message .= "\n$body\n"; my $entity = $parser->parse_data($message); if(!$entity) { $error = "500 Couldn't parse MIME data"; } else { my $best_part; foreach my $part ($entity->parts) { if($part->mime_type eq "text/html") { $best_part = $part; last; } } ($best_part) ||= grep { $_->mime_type eq "text/plain" } $entity->parts; if(!$best_part) { $error = "500 Couldn't find a valid MIME part"; } else { $headers{'content-type'} = $best_part->mime_type; $body = join("", @{$best_part->body()}); } } } if($error) { nntp_put($error); } else { if($headers{newsgroups} =~ /,/) { nntp_put("500 Crossposting not allowed"); last SWITCH; } $blog = findblog($headers{newsgroups}); if(!$blog or !$blog->is_group($headers{newsgroups})) { nntp_put("500 Group does not exist"); last SWITCH; } if($headers{references}) { $headers{references} = (split(/\s+/, $headers{references}))[-1]; } undef $blog->{errstr}; if($blog->post(\%headers, $body)) { nntp_put("240 article posted ok"); } else { nntp_put($blog->{errstr} || "441 posting failed"); } } } elsif (/^quit$/) { nntp_put("205 closing connection - goodbye!"); last GETLINE; } elsif (/^slave$/) { nntp_put("202 slave status noted"); } elsif (/^mode$/ or /^xmode$/) { nntp_put("200 OK"); } elsif (/^date$/) { my @gmtime = gmtime(time); $gmtime[5] += 1900; $gmtime[4]++; my @time = reverse @gmtime[0..5]; nntp_put(sprintf("111 %4u%2u%2u%2u%2u%2u", @time)); # Commands below here are from RFC 2980 } elsif (/^listgroup$/) { my $group = $params[0] || $currgroup; $blog = findblog($group); unless ($blog and $blog->is_group($group)) { nntp_put("411 No such group $group"); last SWITCH; } my($first, $last, $num) = $blog->groupstats($group); if(!defined($first)) { nntp_put($blog->{errstr}); } else { $currgroup = $group; $currmsg = $first; nntp_put("211 list of article numbers follows"); my %articles = $blog->articles($group); nntp_put($_) foreach sort {$a <=> $b} keys %articles; nntp_put("."); } } elsif (/^authinfo$/) { if (lc($params[0]) eq "pass" and !"user") { nntp_put("482 USER required"); } elsif (lc($params[0]) eq "user") { $user = $params[1]; nntp_put("381 PASS required"); } elsif (lc($params[0]) eq "pass") { if(grep { $_->auth($user, $params[1]) } values %blogs) { nntp_put("281 Ok"); } else { nntp_put("503 Authentication error"); } } } elsif (/^xhdr$/) { xover("221 Header follows", $currgroup, $params[1], lc($params[0])); } elsif (/^xover$/) { xover("224 Overview information follows", $currgroup, $params[0]); } elsif (/^xpat$/) { my $header = shift @params; my $range = shift @params; xover("221 Header follows", $currgroup, $range, lc($header), join(" ", @params)); } elsif (/^xrover$/ ) { xover("224 Overview information follows", $currgroup, $params[0], "references"); } else { nntp_put("501 Command not understood"); } } } #No more input from client do_log("Client disconnected\n", LOG_INFO); $_->lost_client() foreach values %blogs; return; } sub xover($$$;$$) { my($response, $currgroup, $articles, $header, $pattern) = @_; my @articles; my @nums; my $blog; do_log("xover($response, $currgroup, $articles, $header, $pattern)", LOG_DEBUG); if($articles =~ /^groupstats($currgroup) unless $last; } else { $last = $first; } undef $blog->{errstr}; @nums = sort {$a <=> $b} $blog->msgnums($currgroup, $first, $last); if($blog->{errstr}) { nntp_put($blog->{errstr}); return; } @articles = map { $blog->num2id($currgroup, $_) } @nums; do_log("Articles: ", join(", ", @articles), LOG_DEBUG); do_log("Numbers: ", join(", ", @nums), LOG_DEBUG); } my @headers = $header ? ($header) : (); foreach my $article (@articles) { my($retval, undef, %headers) = $blog->article("head", $article, @headers); if($retval) { # We assume all articles exist ;) if($response) { nntp_put($response); undef $response; } $article = shift @nums if @nums; my $headtext = ""; do_log("$article: ", join(", ", map { "$_: $headers{$_}" } keys %headers), LOG_DEBUG); if($header) { $headtext = " $headers{$header}"; } else { foreach $header (qw(subject from date message-id references bytes lines xref)) { $headtext .= "\t$headers{$header}"; } } nntp_put("$article$headtext"); } } if($response) { nntp_put($blog->{errstr}); } else { nntp_put("."); } } sub params2time(@) { my($date, $time, $gmt) = @_; return undef unless $date =~ /(..)(..)(..)/; my($year, $month, $day) = ($1, $2, $3); $year += 100 if $year < 80; $month--; return undef unless $time =~ /(..)(..)(..)/; my($hours, $minutes, $seconds) = ($1, $2, $3); my @timeparms = ($seconds, $minutes, $hours, $day, $month, $year); $time = ($gmt eq "GMT") ? timegm(@timeparms) : timelocal(@timeparms); return $time; } main();