]> Sergey Matveev's repositories - public-inbox.git/commitdiff
daemon: support listening on Unix domain sockets
authorEric Wong <e@80x24.org>
Thu, 3 Mar 2016 10:33:02 +0000 (10:33 +0000)
committerEric Wong <e@80x24.org>
Fri, 4 Mar 2016 00:25:43 +0000 (00:25 +0000)
Listening on Unix domain sockets can be convenient for running
behind reverse proxies, avoiding port conflicts, limiting access,
or avoiding the overhead (if any) of TCP over loopback.

lib/PublicInbox/Daemon.pm
t/httpd-corner.t
t/httpd-unix.t [new file with mode: 0644]

index c101ecb7ad538313324879c4986587ca30fee0d8..9f33c05af5c6bb0e2066970f72121e359a30d2b1 100644 (file)
@@ -7,6 +7,7 @@ use strict;
 use warnings;
 use Getopt::Long qw/:config gnu_getopt no_ignore_case auto_abbrev/;
 use IO::Handle;
+use IO::Socket;
 STDOUT->autoflush(1);
 STDERR->autoflush(1);
 require Danga::Socket;
@@ -52,17 +53,35 @@ sub daemon_prepare ($) {
 
        foreach my $l (@cfg_listen) {
                next if $listener_names{$l}; # already inherited
-               require IO::Socket::INET6; # works for IPv4, too
-               my %o = (
-                       LocalAddr => $l,
-                       ReuseAddr => 1,
-                       Proto => 'tcp',
-               );
-               if (my $s = IO::Socket::INET6->new(%o)) {
+               my (%o, $sock_pkg);
+               if (index($l, '/') == 0) {
+                       $sock_pkg = 'IO::Socket::UNIX';
+                       eval "use $sock_pkg";
+                       die $@ if $@;
+                       %o = (Type => SOCK_STREAM, Peer => $l);
+                       if (-S $l) {
+                               my $c = $sock_pkg->new(%o);
+                               if (!defined($c) && $!{ECONNREFUSED}) {
+                                       unlink $l or die
+"failed to unlink stale socket=$l: $!\n";
+                               } # else: let the bind fail
+                       }
+                       $o{Local} = delete $o{Peer};
+               } else {
+                       $sock_pkg = 'IO::Socket::INET6'; # works for IPv4, too
+                       eval "use $sock_pkg";
+                       die $@ if $@;
+                       %o = (LocalAddr => $l, ReuseAddr => 1, Proto => 'tcp');
+               }
+               $o{Listen} = 1024;
+               my $prev = umask 0000;
+               my $s = eval { $sock_pkg->new(%o) };
+               warn "error binding $l: $!\n" unless $s;
+               umask $prev;
+
+               if ($s) {
                        $listener_names{sockname($s)} = $s;
                        push @listeners, $s;
-               } else {
-                       warn "error binding $l: $!\n";
                }
        }
        die "No listeners bound\n" unless @listeners;
@@ -165,15 +184,20 @@ sub sockname ($) {
 sub host_with_port ($) {
        my ($addr) = @_;
        my ($port, $host);
-       if (length($addr) >= 28) {
-               require Socket6;
-               ($port, $host) = Socket6::unpack_sockaddr_in6($addr);
-               $host = '['.Socket6::inet_ntop(Socket6::AF_INET6(), $host).']';
-       } else {
-               ($port, $host) = Socket::sockaddr_in($addr);
-               $host = Socket::inet_ntoa($host);
-       }
-       ($host, $port);
+
+       # this eval will die on Unix sockets:
+       eval {
+               if (length($addr) >= 28) {
+                       require Socket6;
+                       ($port, $host) = Socket6::unpack_sockaddr_in6($addr);
+                       $host = Socket6::inet_ntop(Socket6::AF_INET6(), $host);
+                       $host = "[$host]";
+               } else {
+                       ($port, $host) = Socket::sockaddr_in($addr);
+                       $host = Socket::inet_ntoa($host);
+               }
+       };
+       $@ ? ('127.0.0.1', 0) : ($host, $port);
 }
 
 sub inherit () {
index 198a7e90e7345398480f77cd1ae4f5367ac60008..19564074eaa00ec4d2060777a7d68cc16c1823a6 100644 (file)
@@ -16,6 +16,7 @@ use Digest::SHA qw(sha1_hex);
 use File::Temp qw/tempdir/;
 use Cwd qw/getcwd/;
 use IO::Socket;
+use IO::Socket::UNIX;
 use Fcntl qw(FD_CLOEXEC F_SETFD F_GETFD :seek);
 use Socket qw(SO_KEEPALIVE IPPROTO_TCP TCP_NODELAY);
 use POSIX qw(dup2 mkfifo :sys_wait_h);
@@ -34,20 +35,32 @@ my %opts = (
        Listen => 1024,
 );
 my $sock = IO::Socket::INET->new(%opts);
+my $upath = "$tmpdir/s";
+my $unix = IO::Socket::UNIX->new(
+       Listen => 1024,
+       Type => SOCK_STREAM,
+       Local => $upath
+);
+ok($unix, 'UNIX socket created');
 my $pid;
 END { kill 'TERM', $pid if defined $pid };
 my $spawn_httpd = sub {
        my (@args) = @_;
+       $! = 0;
        my $fl = fcntl($sock, F_GETFD, 0);
        ok(! $!, 'no error from fcntl(F_GETFD)');
        is($fl, FD_CLOEXEC, 'cloexec set by default (Perl behavior)');
        $pid = fork;
        if ($pid == 0) {
                # pretend to be systemd
-               fcntl($sock, F_SETFD, $fl &= ~FD_CLOEXEC);
                dup2(fileno($sock), 3) or die "dup2 failed: $!\n";
+               dup2(fileno($unix), 4) or die "dup2 failed: $!\n";
+               $sock = IO::Handle->new_from_fd(3, 'r');
+               $sock->fcntl(F_SETFD, 0);
+               $unix = IO::Handle->new_from_fd(4, 'r');
+               $unix->fcntl(F_SETFD, 0);
                $ENV{LISTEN_PID} = $$;
-               $ENV{LISTEN_FDS} = 1;
+               $ENV{LISTEN_FDS} = 2;
                exec $httpd, @args, "--stdout=$out", "--stderr=$err", $psgi;
                die "FAIL: $!\n";
        }
@@ -63,6 +76,16 @@ my $spawn_httpd = sub {
        $spawn_httpd->('-W0');
 }
 
+# Unix domain sockets
+{
+       my $u = IO::Socket::UNIX->new(Type => SOCK_STREAM, Peer => $upath);
+       ok($u, 'unix socket connected');
+       $u->write("GET /host-port HTTP/1.0\r\n\r\n");
+       $u->read(my $buf, 4096);
+       like($buf, qr!\r\n\r\n127\.0\.0\.1:0\z!,
+               'set REMOTE_ADDR and REMOTE_PORT for Unix socket');
+}
+
 sub conn_for {
        my ($sock, $msg) = @_;
        my $conn = IO::Socket::INET->new(
diff --git a/t/httpd-unix.t b/t/httpd-unix.t
new file mode 100644 (file)
index 0000000..580d14d
--- /dev/null
@@ -0,0 +1,105 @@
+# Copyright (C) 2016 all contributors <meta@public-inbox.org>
+# License: AGPL-3.0+ <https://www.gnu.org/licenses/agpl-3.0.txt>
+# Tests for binding Unix domain sockets
+use strict;
+use warnings;
+use Test::More;
+
+foreach my $mod (qw(Plack::Util Plack::Request Plack::Builder Danga::Socket
+                       HTTP::Parser::XS HTTP::Date HTTP::Status)) {
+       eval "require $mod";
+       plan skip_all => "$mod missing for httpd-unix.t" if $@;
+}
+
+use File::Temp qw/tempdir/;
+use IO::Socket::UNIX;
+use Cwd qw/getcwd/;
+use Fcntl qw(FD_CLOEXEC F_SETFD F_GETFD :seek);
+my $tmpdir = tempdir('httpd-unix-XXXXXX', TMPDIR => 1, CLEANUP => 1);
+my $unix = "$tmpdir/unix.sock";
+my $httpd = 'blib/script/public-inbox-httpd';
+my $psgi = getcwd() . '/t/httpd-corner.psgi';
+my $out = "$tmpdir/out.log";
+my $err = "$tmpdir/err.log";
+
+my $pid;
+END { kill 'TERM', $pid if defined $pid };
+
+my $spawn_httpd = sub {
+       my (@args) = @_;
+       $pid = fork;
+       if ($pid == 0) {
+               exec $httpd, @args, "--stdout=$out", "--stderr=$err", $psgi;
+               die "FAIL: $!\n";
+       }
+       ok(defined $pid, 'forked httpd process successfully');
+};
+
+ok(!-S $unix, 'UNIX socket does not exist, yet');
+$spawn_httpd->("-l$unix");
+for (1..1000) {
+       last if -S $unix;
+       select undef, undef, undef, 0.02
+}
+
+ok(-S $unix, 'UNIX socket was bound by -httpd');
+sub check_sock ($) {
+       my ($unix) = @_;
+       my $sock = IO::Socket::UNIX->new(Peer => $unix, Type => SOCK_STREAM);
+       ok($sock, 'client UNIX socket connected');
+       ok($sock->write("GET /host-port HTTP/1.0\r\n\r\n"),
+               'wrote req to server');
+       ok($sock->read(my $buf, 4096), 'read response');
+       like($buf, qr!\r\n\r\n127\.0\.0\.1:0\z!,
+               'set REMOTE_ADDR and REMOTE_PORT for Unix socket');
+}
+
+check_sock($unix);
+
+{ # do not clobber existing socket
+       my $fpid = fork;
+       if ($fpid == 0) {
+               open STDOUT, '>>', "$tmpdir/1" or die "redirect failed: $!";
+               open STDERR, '>>', "$tmpdir/2" or die "redirect failed: $!";
+               exec $httpd, '-l', $unix, '-W0', $psgi;
+               die "FAIL: $!\n";
+       }
+       is($fpid, waitpid($fpid, 0), 'second httpd exits');
+       isnt($?, 0, 'httpd failed with failure to bind');
+       open my $fh, "$tmpdir/2" or die "failed to open $tmpdir/2: $!";
+       local $/;
+       my $e = <$fh>;
+       like($e, qr/no listeners bound/i, 'got error message');
+       is(-s "$tmpdir/1", 0, 'stdout was empty');
+}
+
+{
+       my $kpid = $pid;
+       $pid = undef;
+       is(kill('TERM', $kpid), 1, 'terminate existing process');
+       is(waitpid($kpid, 0), $kpid, 'existing httpd terminated');
+       is($?, 0, 'existing httpd exited successfully');
+       ok(-S $unix, 'unix socket still exists');
+}
+{
+       # wait for daemonization
+       $spawn_httpd->("-l$unix", '-D', '-P', "$tmpdir/pid");
+       my $kpid = $pid;
+       $pid = undef;
+       is(waitpid($kpid, 0), $kpid, 'existing httpd terminated');
+       check_sock($unix);
+
+       ok(-f "$tmpdir/pid", 'pid file written');
+       open my $fh, '<', "$tmpdir/pid" or die "open failed: $!";
+       my $rpid = <$fh>;
+       chomp $rpid;
+       like($rpid, qr/\A\d+\z/s, 'pid file looks like a pid');
+       is(kill('TERM', $rpid), 1, 'signalled daemonized process');
+       for (1..100) {
+               kill(0, $rpid) or last;
+               select undef, undef, undef, 0.02;
+       }
+       is(kill(0, $rpid), 0, 'daemonized process exited')
+}
+
+done_testing();