#!/usr/bin/perl -w # iCal to WCAP Gateway # HTTP server that implements GET and PUT for both subscribing to and # publishing calendars using Apple iCal and Sun ONE (Java System, iPlanet) # Calendar Server. # # Copyright (C) 2004-2006, John "Rowan" Littell ######### # modules use strict; use Getopt::Std; use HTTP::Daemon; use HTTP::Status; use MIME::Base64; use LWP::UserAgent; use HTTP::Request; use HTTP::Request::Common; use HTTP::Response; use HTTP::Date; use POSIX qw(strftime); use Sys::Syslog qw(:DEFAULT setlogsock); ######### # globals my $CALSERVER = ""; my $REALM = "icald wcap gateway"; my $LOCALADDR = ""; my $LOCALPORT = 7080; my $SSL_PORT = 7443; my $DEBUG = 0; my $SERVER_ID = "icald"; my $SERVER_VERSION = "1.5"; my $PIDFILE = "/var/run/icald.pid"; my $LOGFILE = "/var/log/icald/access.log"; ############ # prototypes sub sighup_handler ($); sub access_log ($$$$$); sub handle_put ($$); sub handle_get ($$); sub handle_unknown ($$); sub wcap_command ($;@); sub wcap_command_post ($$;@); sub wcap_login ($$); sub wcap_logout ($); sub wcap_get_calprops ($$); sub wcap_deletecomponents_by_range ($$); sub wcap_import ($$$); sub wcap_export ($$); sub wcap_fetchcomponents_by_range ($$); ############## # main program MAIN: { my (%opts); getopts ('de:r:a:u:s:p:l:', \%opts); ($opts{'d'}) && ($DEBUG = 1); ($opts{'s'}) && ($CALSERVER = $opts{'s'}); ($opts{'a'}) && ($LOCALADDR = $opts{'a'}); ($opts{'p'}) && ($LOCALPORT = $opts{'p'}); ($opts{'r'}) && ($REALM = $opts{'r'}); ($opts{'l'}) && ($LOGFILE = $opts{'l'}); ($opts{'e'}) && ($SSL_PORT = $opts{'e'}); $0 = $SERVER_ID; if ($CALSERVER eq "") { die "Please specify a calendar server.\n"; } if (!$DEBUG) { my $pid = fork(); if (!defined $pid) { # fork error die "fork: $!\n"; } elsif ($pid) { # parent, record PID and then close open (P, ">$PIDFILE"); print P $pid; close (P); exit; } else { # close open file descriptors close STDIN; close STDOUT; close STDERR; } # tell system we don't care about child procs $SIG{'CHLD'} = 'IGNORE'; } # open syslog setlogsock ('unix'); openlog ($SERVER_ID, 'pid', 'user'); # create the HTTP daemon my $daemon = HTTP::Daemon->new ( LocalAddr => $LOCALADDR, LocalPort => $LOCALPORT, Listen => 10, Reuse => 1 ); if (!defined $daemon) { syslog('err', "could not bind to address $LOCALADDR:$LOCALPORT: $!"); closelog(); exit; } # setuid after binding to the port, if requested if ($opts{'u'}) { if ($< == 0) { my ($uid, $gid) = (getpwnam $opts{'u'})[2,3]; $< = $> = $uid; $( = $) = $gid; } } # open access logfile if (!open (LOG, ">>$LOGFILE")) { syslog('err', "could not open access log file $LOGFILE"); closelog(); exit; } else { select LOG; $| = 1; } # set signal handler for SIGHUP (reopen log file) $SIG{'HUP'} = 'sighup_handler'; # enter main accept loop while (1) { my $conn = $daemon->accept; if (!$conn) { next; } # in normal mode, we spawn off a child process to handle the request if (!$DEBUG && fork()) { # parent $conn->close; undef ($conn); } else { # child # handle requests while (my $req = $conn->get_request) { if (!defined $req) { next; } $conn->autoflush; # currently we only deal with GET and PUT # GET = iCal subscription # PUT = iCal publish my $method = $req->method; if ($method eq 'PUT') { handle_put ($conn, $req); } elsif ($method eq 'GET') { handle_get ($conn, $req); } else { # unknown method, send 501 not implemented # iCal will send a DELETE for a published # calendar to its old location if you change # the publish location. It doesn't care what # the return is, though, so a 501 is perfectly fine. handle_unknown ($conn, $req); } } # shutdown the connection $conn->close; undef($conn); # in normal mode, we've fork()ed, so exit when we're done if (!$DEBUG) { exit; } } } } ################## # signal handler for SIGHUP # close and re-open log file sub sighup_handler ($) { my ($sig) = @_; syslog('info', "SIG$sig - reopening access log $LOGFILE"); close (LOG); if (!open (LOG, ">>$LOGFILE")) { syslog('err', "could not open access log file $LOGFILE"); closelog(); exit; } else { select LOG; $| = 1; } } sub access_log ($$$$$) { my ($client, $logname, $username, $request, $resp) = @_; ($username eq "") ? $username = "-" : $username = $username; my $date = strftime ("%d/%b/%Y:%T %z", localtime(time())); my $code = $resp->code(); my $size = length ($resp->as_string()); my $log = "$client $logname $username [$date] \"$request\" $code $size"; print LOG "$log\n"; } #################### # handle_put # iCal publish # requires: # $conn -- client connection opject # $req -- client request object sub handle_put ($$) { my ($conn, $req) = @_; my ($url, $resp, $h, $username, $password); # need authorization my $auth = $req->header("Authorization"); if ($auth ne "") { my ($type, $cred) = (split /\s+/, $auth); my $decode = decode_base64($cred); ($username, $password) = (split ':', $decode); } else { $username = $password = ""; } my $id = wcap_login ($username, $password); if (!defined $id || $id eq "0") { $h = HTTP::Headers->new; $h->header('Connection' => 'close'); $h->header('WWW-Authenticate' => "Basic realm=\"$REALM\""); my $content = '
'; $resp = HTTP::Response->new ("401", "Authorization Required", $h, $content); } else { # In all cases, $username from the Authenticate header is used # for login -- this may be different from the USERNAME part of # the URI, which would indicate someone modifying another's calendar. # iCal attaches .ics to the calendar name that it publishes; # we strip it off. # URIs # /USERNAME/USERNAME -> calid=USERNAME # /USERNAME/CALENDAR -> calid=USERNAME:CALENDAR # /CALENDAR -> calid=CALENDAR my $calname = $req->uri; $calname =~ s/^https?:\/\/[^\/]+//; $url = $calname; if ($calname =~ /^\/([^\/]+)\/([-_\+\d\w]+)(\.ics)?$/) { my ($tuser, $tname) = ($1, $2); if ($tname eq $tuser) { $calname = "$tuser"; } else { $calname = "$tuser:$tname"; } } elsif ($calname =~ /([^\/]+)(\.ics)?$/) { $calname = $1; } # check for existence my ($errno, $content) = wcap_get_calprops ($id, $calname); if ($errno ne "0") { $h = HTTP::Headers->new; $h->header('Connection' => 'close'); $resp = HTTP::Response->new("404", "Calendar $calname not found", $h); } else { # calendar exists, now delete all entries and upload new one ($errno, $content) = wcap_deletecomponents_by_range ($id, $calname); wcap_import ($id, $calname, $req->content); $h = HTTP::Headers->new; $h->header('Connection' => 'close'); $resp = HTTP::Response->new("200", "Ok", $h); } wcap_logout ($id); } access_log ($conn->peerhost, "-", $username, "PUT ".$url, $resp); $conn->send_response($resp); } #################### # handle_get # iCal subscribe # requires: # $conn -- client connection opject # $req -- client request object sub handle_get ($$) { my ($conn, $req) = @_; my ($url, $username, $password); my ($resp, $h, $need_auth, $need_ssl, $ssl_uri); # if we're given an auth header, use it my $id = "0"; my $auth = $req->header("Authorization"); if ($auth ne "") { my ($type, $cred) = (split /\s+/, $auth); my $decode = decode_base64($cred); ($username, $password) = (split ':', $decode); $id = wcap_login ($username, $password); } else { $username = $password = ""; } # construct the calendar name # URIs: # /CALENDAR -> calid=CALENDAR (including CALENDAR == $username) # /USERNAME/CALENDAR -> calid=USERNAME:CALENDAR # /login/CALENDAR -> calid=CALENDAR, requires AUTH # /login/USERNAME/CALENDAR -> calid=USERNAME:CALENDAR, requires AUTH $need_auth = 0; $need_ssl = 0; my $calname = $req->uri; my $uri = $calname; $calname =~ s/^https?:\/\/[^\/]+//; $url = $calname; if ($calname =~ /^\/login\/([^\/]+)\/([^\/]+)$/) { # /login/USERNAME/CALENDAR $calname = "$1:$2"; $need_auth = 1; } elsif ($calname =~ /^\/login\/([^\/]+)$/) { # /login/CALENDAR $calname = $1; $need_auth = 1; } elsif ($calname =~ /^\/loginssl\/([^\/]+)\/([^\/]+)$/) { # /loginssl/USERNAME/CALENDAR $calname = "$1:$2"; $need_auth = 1; if ($uri !~ /^https/) { $need_ssl = 1; my ($hostname) = (split (/\/+/, $uri))[1]; $hostname =~ s/:\d+$//; $ssl_uri = "https://$hostname:$SSL_PORT$url"; $ssl_uri =~ s/loginssl/login/; } } elsif ($calname =~ /^\/loginssl\/([^\/]+)$/) { # /loginssl/CALENDAR $calname = $1; $need_auth = 1; if ($uri !~ /^https/) { $need_ssl = 1; my ($hostname) = (split (/\/+/, $uri))[1]; $hostname =~ s/:\d+$//; $ssl_uri = "https://$hostname:$SSL_PORT$url"; $ssl_uri =~ s/loginssl/login/; } } elsif ($calname =~ /^\/([^\/]+)\/([^\/]+)$/) { # /USERNAME/CALENDAR $calname = "$1:$2"; $need_auth = 0; } elsif ($calname =~ /([^\/]+)$/) { # /CALENDAR $calname = $1; $need_auth = 0; } # if need ssl, return a redirect if ($need_ssl) { $h = HTTP::Headers->new; $h->header('Connection' => 'close'); $h->header('Content-Type' => 'text/html; charset=iso-8859-1'); $h->header('Location' => $ssl_uri); my $content = '
'; $resp = HTTP::Response->new ("401", "Authorization Required", $h, $content); } elsif ($errno eq "29") { # nonexistent calendar $h = HTTP::Headers->new; $h->header('Connection' => 'close'); $resp = HTTP::Response->new("404", "Calendar not found", $h); } elsif ($errno eq "0") { # found calendar #($errno, $content) = wcap_fetchcomponents_by_range($id, $calname); ($errno, $content) = wcap_export($id, $calname); # munge content to take out stuff that iCal/iSync doesn't like my $inorg = 0; my $munged_content = ''; foreach my $line (split(/\r\n/, $content)) { if ($inorg && $line !~ /^\s+/) { $inorg = 0; } if ($line =~ /^ORGANIZER/) { $inorg = 1; } if (!$inorg) { if ($line !~ /^X-NSCP-/ && $line !~ /^ ;X-NSCP-/) { $munged_content .= "$line\r\n"; } } } $h = HTTP::Headers->new; $h->header('Connection' => 'close'); $h->header('Content-Type' => 'text/calendar'); $h->header('Content-Disposition' => "attachment; filename=\"$calname.ics\""); $resp = HTTP::Response->new("200", "Ok", $h, $munged_content); } if ($id ne "0") { wcap_logout ($id); } access_log ($conn->peerhost, "-", $username, "GET ".$url, $resp); $conn->send_response($resp); } #################### # handle_unknown # for any unknown HTTP methods # sends a 501 Method Not Implemented to the client # requires: # $conn -- client connection opject # $req -- client request object sub handle_unknown ($$) { my ($conn, $req) = @_; my $h = HTTP::Headers->new; $h->header('Connection' => 'close'); $h->header('Content-Type' => 'text/html; charset=iso-8859-1'); my $content = '
';
my $resp = HTTP::Response->new("501", "Method Not Implemented", $h, $content);
$conn->send_response($resp);
}
################################################################
# WCAP INTERFACE
################################################################
################
# standard wcap commands (GET method)
# arguments:
# $command -- the command name
# @args -- a list of arguments to send to the command (optional)
# returns:
# errno and content in an array context
# content in scalar context
sub wcap_command ($;@) {
my ($command, @args) = @_;
my ($argstring, $url);
if (@args) {
$argstring = join ('&', @args);
}
if ($argstring ne "") {
$url = "http://$CALSERVER/$command.wcap?$argstring";
} else {
$url = "http://$CALSERVER/$command.wcap";
}
my $request = HTTP::Request->new (GET => $url);
my $browser = LWP::UserAgent->new;
$browser->agent("$SERVER_ID/$SERVER_VERSION");
my $response = $browser->simple_request($request);
if ($DEBUG) {
open (T, ">>/tmp/ical.log");
print T "Request: $url\n";
print T "Response:\n", $response->content, "\n";
close (T);
}
if (wantarray) {
my $errno;
$errno = $response->content;
$errno =~ /X-NSCP-WCAP-ERRNO:(\d+)/;
$errno = $1;
return ($errno, $response->content);
} else {
return ($response->content);
}
}
################
# POST wcap commands
# specifically tuned to the IMPORT command; content is assumed to be
# text/calendar and sent as a form submission
# arguments:
# $command -- the command name
# $content -- the data to POST
# @args -- a list of arguments to send to the command (optional)
# returns:
# errno and content in an array context
# content in scalar context
sub wcap_command_post ($$;@) {
my ($command, $content, @args) = @_;
my ($argstring, $request, $url);
if (@args) {
$argstring = join ('&', @args);
}
if ($argstring ne "") {
$url = "http://$CALSERVER/$command.wcap?$argstring";
} else {
$url = "http://$CALSERVER/$command.wcap";
}
if ($command eq "import") {
$request = POST(
$url,
Content_Type => 'form-data',
Content => [
Upload => [
undef, "ical.ics",
Content_Type => 'text/calendar',
Content => $content
]
]
);
} elsif ($command eq "export") {
$request = POST(
$url,
Content_Type => 'form-data',
Content => [
Download => [
undef, "export.ics",
Accept => 'image/gif, image/x-xbitmap, image/jpeg, image/pjpeg, image/png, */*',
Accept_Encoding => 'deflate,gzip',
Accept_Language => 'en',
Accept_Charset => 'iso-8859-1,*,utf-8'
]
]
);
}
my $browser = LWP::UserAgent->new;
$browser->agent("$SERVER_ID/$SERVER_VERSION");
my $response = $browser->request($request);
if ($DEBUG) {
open (T, ">>/tmp/ical.log");
print T "Request: $url\n";
print T "Content: $content\n";
print T "Response:\n", $response->content, "\n";
close (T);
}
if (wantarray) {
my $errno;
$errno = $response->content;
$errno =~ /X-NSCP-WCAP-ERRNO:(\d+)/;
$errno = $1;
return ($errno, $response->content);
} else {
return ($response->content);
}
}
#############################################
# Specific WCAP commands
#############################################
# login to the calendar server and return an authentication ID
sub wcap_login ($$) {
my ($user, $pass) = @_;
my ($url, $id, $content);
$id = "0";
if ($user eq "" || $pass eq "") {
return $id;
}
$content = wcap_command("login", "user=$user", "password=$pass");
my $tid;
if ($content =~ /var id='(\w+)'/) {
# WCAP pre 3.0 (Calendar 5.x)
$tid = $1;
} elsif ($content =~ /X-NSCP-WCAP-SESSION-ID:(\w+)/) {
# WCAP 3.0 (Calendar 6.x)
$tid = $1;
}
if (defined $tid && $tid ne "") {
$id = $tid;
}
return ($id);
}
# destroy any logged in session on the server with the authentication ID
sub wcap_logout ($) {
my ($id) = @_;
wcap_command("logout", "id=$id");
}
# get the info about a calendar
# primarily used to see if the calendar exists and if we have read/write
# access.
# errno == 0, access granted, no error
# errno == 28, read access denied
# errno == 29, calendar does not exist
sub wcap_get_calprops ($$) {
my ($id, $cal) = @_;
my ($errno, $content);
($errno, $content) = wcap_command ("get_calprops", "id=$id", "calid=$cal", "fmt-out=text/calendar");
if (wantarray) {
return ($errno, $content);
} else {
return $content;
}
}
# delete the contents of a calendar
sub wcap_deletecomponents_by_range ($$) {
my ($id, $calid) = @_;
my ($errno, $content) = wcap_command ("deletecomponents_by_range", "id=$id", "calid=$calid", "fmt-out=text/calendar");
if (wantarray) {
return ($errno, $content);
} else {
return $content;
}
}
# import a calendar in text/calendar format
sub wcap_import ($$$) {
my ($id, $calid, $content) = @_;
wcap_command_post ("import", $content, "id=$id", "calid=$calid", "content-in=text/calendar")
}
# export a calendar in text/calendar format
sub wcap_export ($$) {
my ($id, $calid) = @_;
wcap_command_post ("export", "", "id=$id", "calid=$calid", "content-out=text/calendar")
}
# retrieve the contents of a calendar in text/calendar format (subscriptions)
sub wcap_fetchcomponents_by_range ($$) {
my ($id, $calid) = @_;
my ($errno, $content) = wcap_command ("fetchcomponents_by_range", "id=$id", "calid=$calid", "fmt-out=text/calendar");
if (wantarray) {
return ($errno, $content);
} else {
return $content;
}
}
############################################################################
# POD DOCUMENTATION
=pod
=head1 NAME
icald - iCal to WCAP calendar publish and subscribe gateway
=head1 SYNOPSIS
B